diff --git a/apps/web/components/toolbox/GeneratorRunner.tsx b/apps/web/components/toolbox/GeneratorRunner.tsx index c18a494..32fb0a9 100644 --- a/apps/web/components/toolbox/GeneratorRunner.tsx +++ b/apps/web/components/toolbox/GeneratorRunner.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { ArrowLeft, Database, Sparkles } from "lucide-react"; import { useToast } from "@/components/Toast"; @@ -23,8 +23,10 @@ import { type PreviewItem, } from "@/lib/toolbox/toolbox"; import { ingestTable, isSingleObjectIngest } from "@/lib/toolbox/ingest"; +import { fieldSummary, renderPreviewText } from "@/lib/toolbox/aiSummary"; import { buildManuscriptExcerpt } from "@/lib/workbench/manuscriptExcerpt"; import { useGenerator } from "@/lib/toolbox/useGenerator"; +import type { AiTurnInput } from "@/lib/workbench/useAiConversation"; import { TemplateFiller } from "./TemplateFiller"; interface GeneratorRunnerProps { @@ -36,6 +38,10 @@ interface GeneratorRunnerProps { // 可选:作者【已写正文】。传入且当前工具有 brief 字段时,表单里出现「用当前正文」按钮, // 点击把正文摘录填进 brief,让内容感知型生成器(如书名)据正文反推。工具箱页不传 → 不显示。 manuscriptText?: string; + // 可选留痕:生成成功后 append 一批 [author(字段摘要), ai(预览文本)]。 + // 内联工具箱传 conversation.appendMessages + 当前章号;工具箱页传 silent 直投 + chapterNo=null。 + appendMessages?: (turn: AiTurnInput) => void; + logChapterNo?: number | null; } // 声明驱动的通用生成器(核心):按 descriptor.input_fields 渲染表单 → 调通用 generate → @@ -47,6 +53,8 @@ export function GeneratorRunner({ onClose, onInsertText, manuscriptText, + appendMessages, + logChapterNo, }: GeneratorRunnerProps) { const gen = useGenerator(); const toast = useToast(); @@ -55,6 +63,8 @@ export function GeneratorRunner({ ); // 入库勾选:预览行下标集合(与 gen.rawPreview 行对齐)。 const [selected, setSelected] = useState>(new Set()); + // 本 runner 一次会话 = 一个 thread;多次生成归此组。 + const threadId = useRef(crypto.randomUUID()).current; const setField = useCallback((name: string, value: string): void => { setValues((prev) => ({ ...prev, [name]: value })); @@ -99,8 +109,36 @@ export function GeneratorRunner({ return; } setSelected(new Set()); - await gen.generate(projectId, tool.key, buildGenerateRequest(values)); - }, [gen, projectId, tool, values, toast]); + const artifact = await gen.generate( + projectId, + tool.key, + buildGenerateRequest(values), + ); + // 留痕(成功且有可读预览时):author(字段摘要) + ai(渲染预览);空产物/错误路径不记。 + if (artifact && appendMessages) { + const aiText = renderPreviewText(artifact.preview); + if (aiText.length > 0) { + appendMessages({ + threadId, + kind: "generator", + toolKey: tool.key, + chapterNo: logChapterNo ?? null, + messages: [ + { + role: "author", + content: fieldSummary(tool.input_fields ?? [], values) || tool.title, + meta: { tool_key: tool.key, input_fields: values }, + }, + { + role: "ai", + content: aiText, + meta: { tool_key: tool.key, output_kind: artifact.outputKind }, + }, + ], + }); + } + } + }, [gen, projectId, tool, values, toast, appendMessages, logChapterNo, threadId]); const rows = useMemo( () => previewRows(gen.rawPreview), diff --git a/apps/web/components/toolbox/ToolboxPage.tsx b/apps/web/components/toolbox/ToolboxPage.tsx index 037e5aa..c8c3605 100644 --- a/apps/web/components/toolbox/ToolboxPage.tsx +++ b/apps/web/components/toolbox/ToolboxPage.tsx @@ -9,6 +9,7 @@ import { EmptyState } from "@/components/ui/EmptyState"; import { PageHeader } from "@/components/ui/PageHeader"; import type { ProjectResponse, ToolDescriptorView } from "@/lib/api/types"; import { isLegacyTool, resolveLegacyRoute } from "@/lib/toolbox/toolbox"; +import { useAiConversation } from "@/lib/workbench/useAiConversation"; import { ToolCard } from "./ToolCard"; import { GeneratorRunner } from "./GeneratorRunner"; @@ -27,6 +28,8 @@ export function ToolboxPage({ initialOpenKey, }: ToolboxPageProps) { const router = useRouter(); + // 工具箱页无抽屉:silent 直投——项目级(chapter_no=null)留痕,不 GET、失败不 toast。 + const conversation = useAiConversation(project.id, 0, { silent: true }); const findOpenable = useCallback( (key: string | undefined): ToolDescriptorView | null => { @@ -76,6 +79,8 @@ export function ToolboxPage({ setActive(null)} /> ) : sorted.length === 0 ? ( diff --git a/apps/web/components/workbench/ChapterRewritePanel.tsx b/apps/web/components/workbench/ChapterRewritePanel.tsx index 453e4ec..3543bca 100644 --- a/apps/web/components/workbench/ChapterRewritePanel.tsx +++ b/apps/web/components/workbench/ChapterRewritePanel.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { Check, MessagesSquare, RotateCcw, Square, X } from "lucide-react"; import { ThinkingIndicator } from "@/components/ThinkingIndicator"; @@ -12,6 +12,10 @@ import { friendlyError } from "@/lib/errors/messages"; import { foldClarifications, needsClarifyGate } from "@/lib/workbench/clarify"; import { useChapterRewrite } from "@/lib/workbench/useChapterRewrite"; import { useRewriteClarify } from "@/lib/workbench/useRewriteClarify"; +import type { + AiTurnInput, + AiTurnMessage, +} from "@/lib/workbench/useAiConversation"; import { ChoiceChips } from "./ChoiceChips"; // 门控阈值:整章意见 trim 后短于此字数才自动预检反问(清晰意见不交往返税)。 @@ -24,6 +28,8 @@ interface ChapterRewritePanelProps { currentDraft: string; // 接受某一版 → 替换整章正文(HITL,作者显式点选才落草稿)。 onAccept: (text: string) => void; + // 留痕:每版重写记 [author(折后意见), ai(版本正文)],含缓冲的 clarify Q&A;同 thread 迭代。 + appendMessages: (turn: AiTurnInput) => void; onClose: () => void; } @@ -34,12 +40,16 @@ export function ChapterRewritePanel({ chapterNo, currentDraft, onAccept, + appendMessages, onClose, }: ChapterRewritePanelProps) { const rewrite = useChapterRewrite(); const clarify = useRewriteClarify(); const [feedback, setFeedback] = useState(""); const [pendingFeedback, setPendingFeedback] = useState(null); + // 本面板一次会话 = 一个 thread;多版迭代 + 澄清往复归此组,版本号本地累加。 + const threadId = useRef(crypto.randomUUID()).current; + const versionNoRef = useRef(0); const hasVersions = rewrite.versions.length > 0; const clarifying = clarify.status === "checking"; @@ -51,12 +61,34 @@ export function ChapterRewritePanel({ const priorDraft = rewrite.latest?.text ?? currentDraft; const err = rewrite.state.phase === "error" ? rewrite.state.error : null; - // 折入澄清答案(或原意见)后流式重写,并清理澄清态。 - const runSend = (fb: string): void => { + // 折入澄清答案(或原意见)后流式重写,结束后留痕;clarifyTurns 非空时把缓冲 Q&A 一并落库。 + const runSend = (fb: string, clarifyTurns: AiTurnMessage[] | null): void => { clarify.reset(); setPendingFeedback(null); setFeedback(""); - void rewrite.send(projectId, chapterNo, fb, priorDraft); + void (async () => { + const v = await rewrite.send(projectId, chapterNo, fb, priorDraft); + if (!v) return; // 停止/失败/空版:不留痕。 + versionNoRef.current += 1; + const versionNo = versionNoRef.current; + const aiRow: AiTurnMessage = { + role: "ai", + content: v.text, + meta: { version_no: versionNo }, + }; + // clarify 分支:作者答复 IS 本轮 author 输入;否则记 author(折后意见, meta{version_no})。 + const messages = clarifyTurns + ? [...clarifyTurns, aiRow] + : [ + { + role: "author" as const, + content: fb, + meta: { version_no: versionNo }, + }, + aiRow, + ]; + appendMessages({ threadId, kind: "rewrite", chapterNo, messages }); + })(); }; // 「重写整章 / 再改一版」:意见含糊/极短(或 force)先预检反问;否则直接流式重写。 @@ -70,14 +102,32 @@ export function ChapterRewritePanel({ return; } } - runSend(fb); + runSend(fb, null); }; - // 作者答复澄清(点选 value 或自由输入)→ 折进意见 → 流式重写。 + // 作者答复澄清(点选 value 或自由输入)→ 缓冲 Q&A(原意见→反问→答复)+ 折进意见 → 流式重写。 const onClarifyAnswer = (answer: string): void => { - const question = clarify.decision?.questions[0]?.question ?? ""; + const q = clarify.decision?.questions[0]; + const question = q?.question ?? ""; const base = pendingFeedback ?? feedback.trim(); - runSend(foldClarifications(base, [{ question, answer }])); + const clarifyTurns: AiTurnMessage[] = [ + { role: "author", content: base }, + { + role: "ai", + content: question, + meta: { + parent_kind: "rewrite", + options: (q?.options ?? []).map((o) => ({ + label: o.label, + value: o.value, + })), + allow_free_text: q?.allowFreeText ?? false, + verification: clarify.decision?.verification ?? "", + }, + }, + { role: "author", content: answer }, + ]; + runSend(foldClarifications(base, [{ question, answer }]), clarifyTurns); }; return ( diff --git a/apps/web/components/workbench/ContinuePanel.tsx b/apps/web/components/workbench/ContinuePanel.tsx index 2617650..a01e3ce 100644 --- a/apps/web/components/workbench/ContinuePanel.tsx +++ b/apps/web/components/workbench/ContinuePanel.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { Check, Plus, X } from "lucide-react"; import { ThinkingIndicator } from "@/components/ThinkingIndicator"; @@ -8,12 +8,15 @@ import { Button } from "@/components/ui/Button"; import { SectionHeader } from "@/components/ui/SectionHeader"; import { StatusNote } from "@/components/ui/StatusNote"; import { useContinue } from "@/lib/workbench/useContinue"; +import type { AiTurnInput } from "@/lib/workbench/useAiConversation"; interface ContinuePanelProps { projectId: string; chapterNo: number; // 插入选中候选到正文(追加到章末)。 onInsert: (text: string) => void; + // 留痕:每条候选记一条 ai 气泡(续写无作者输入,故无 author 气泡)。 + appendMessages: (turn: AiTurnInput) => void; onClose: () => void; } @@ -23,16 +26,37 @@ export function ContinuePanel({ projectId, chapterNo, onInsert, + appendMessages, onClose, }: ContinuePanelProps) { const { status, candidates, generate } = useContinue(); const ranRef = useRef(false); + // 本面板一次会话 = 一个 thread;多条候选归此组,candidate_index 本地累加。 + const threadId = useRef(crypto.randomUUID()).current; + const candidateIdxRef = useRef(0); + + // 生成一条候选并(成功时)留痕:ai-only,meta 存候选序号。 + const runGenerate = useCallback(async (): Promise => { + const text = await generate(projectId, chapterNo); + if (!text) return; + const candidateIndex = candidateIdxRef.current; + candidateIdxRef.current += 1; + appendMessages({ + threadId, + kind: "continue", + toolKey: "continue", + chapterNo, + messages: [ + { role: "ai", content: text, meta: { candidate_index: candidateIndex } }, + ], + }); + }, [generate, projectId, chapterNo, appendMessages, threadId]); useEffect(() => { if (ranRef.current) return; ranRef.current = true; - void generate(projectId, chapterNo); - }, [projectId, chapterNo, generate]); + void runGenerate(); + }, [runGenerate]); const busy = status === "generating"; @@ -58,7 +82,7 @@ export function ContinuePanel({ ) : null} + {AI_CONVERSATION_ENABLED ? ( + + ) : null} { + it("拼出「label:value」摘要,跳过空值", () => { + const summary = fieldSummary(fields, { brief: "废柴逆袭", count: "" }); + expect(summary).toBe("脑洞:废柴逆袭"); + }); + + it("多字段用 · 连接", () => { + const summary = fieldSummary(fields, { brief: "扮猪吃虎", count: "3" }); + expect(summary).toBe("脑洞:扮猪吃虎 · 数量:3"); + }); + + it("字段为空 / 无值 → 空串", () => { + expect(fieldSummary([], {})).toBe(""); + expect(fieldSummary(fields, {})).toBe(""); + }); +}); + +describe("renderPreviewText", () => { + it("把预览项渲染成人读文本(heading + 字段 + 正文)", () => { + const preview: PreviewModel = { + kind: "IdeaListResult", + items: [ + { + heading: "废柴逆袭", + fields: [{ label: "钩子", value: "扮猪吃虎" }], + body: null, + }, + { heading: null, fields: [], body: "一段开篇正文。" }, + ], + }; + const text = renderPreviewText(preview); + expect(text).toContain("废柴逆袭"); + expect(text).toContain("钩子:扮猪吃虎"); + expect(text).toContain("一段开篇正文。"); + }); + + it("空预览 → 空串", () => { + expect(renderPreviewText({ kind: "x", items: [] })).toBe(""); + }); +}); diff --git a/apps/web/lib/toolbox/aiSummary.ts b/apps/web/lib/toolbox/aiSummary.ts new file mode 100644 index 0000000..93fa8c0 --- /dev/null +++ b/apps/web/lib/toolbox/aiSummary.ts @@ -0,0 +1,33 @@ +// 工具箱生成留痕的两个纯格式化助手(AI 对话记录用): +// - fieldSummary:把作者填写的输入字段拼成人读摘要(author 气泡 content;长文进 content 不进 meta)。 +// - renderPreviewText:把结构化预览渲染成人读文本(ai 气泡 content;不把原始产物塞进 meta)。 +// 确定性纯函数,无 IO;供 GeneratorRunner 结束后 append 一批往复。 + +import type { ToolInputFieldView } from "@/lib/api/types"; +import type { FieldValues, PreviewModel } from "./toolbox"; + +// 输入字段 → 「label:value」摘要,跳过空值,多字段用 · 连接。 +export function fieldSummary( + fields: readonly ToolInputFieldView[], + values: FieldValues, +): string { + return fields + .map((f) => ({ label: f.label, value: (values[f.name] ?? "").trim() })) + .filter((entry) => entry.value.length > 0) + .map((entry) => `${entry.label}:${entry.value}`) + .join(" · "); +} + +// 预览模型 → 人读文本:每项 = heading + 「label:value」次要字段 + 正文段,项间空行分隔。 +export function renderPreviewText(preview: PreviewModel): string { + return preview.items + .map((item) => { + const lines: string[] = []; + if (item.heading) lines.push(item.heading); + for (const f of item.fields) lines.push(`${f.label}:${f.value}`); + if (item.body) lines.push(item.body); + return lines.join("\n"); + }) + .filter((block) => block.length > 0) + .join("\n\n"); +} diff --git a/apps/web/lib/toolbox/useGenerator.test.ts b/apps/web/lib/toolbox/useGenerator.test.ts index c8fc9f5..20a4e23 100644 --- a/apps/web/lib/toolbox/useGenerator.test.ts +++ b/apps/web/lib/toolbox/useGenerator.test.ts @@ -55,7 +55,7 @@ describe("useGenerator", () => { }); const { result } = renderHook(() => useGenerator()); - let returned: { outputKind: string; rawPreview: unknown } | null = null; + let returned: unknown; await act(async () => { returned = await result.current.generate("p1", "idea", { brief: "脑洞" }); }); @@ -67,8 +67,9 @@ describe("useGenerator", () => { expect(result.current.preview?.items[0]?.heading).toBe("废柴逆袭"); expect(toast).not.toHaveBeenCalled(); // 追加断言:generate 返回本次产物(供 GeneratorRunner 结束后留痕)。 - expect(returned?.outputKind).toBe("IdeaListResult"); - expect(returned?.rawPreview).toEqual(preview); + const artifact = returned as { outputKind: string; rawPreview: unknown }; + expect(artifact.outputKind).toBe("IdeaListResult"); + expect(artifact.rawPreview).toEqual(preview); }); it("生成后端返回 error:genStatus=error 且弹错误 toast", async () => {