feat(frontend): 五类交互接入 AI 对话留痕 + Workbench 抽屉入口(AC-3)
- RefinePanel/ChapterRewritePanel:结束后 append [author(意见/答复), ai(产出)];
clarify buffer-until-concluded——澄清 Q&A(原意见→反问→答复)缓冲,随最终产出批
一起落库同 thread;纯放弃不记。refine ai 行 meta 存 version_no+原选段(每版重锚)。
- ContinuePanel:ai-only(续写无作者输入),每候选一条 ai 气泡 meta{candidate_index}。
- GeneratorRunner:成功后 append [author(字段摘要, meta{tool_key,input_fields}),
ai(渲染预览, meta{tool_key,output_kind})];空产物不记。lib/toolbox/aiSummary.ts 纯助手+单测。
- InlineToolbox→GeneratorRunner 透传 append+本章号;ToolboxPage 用 silent 直投项目级(chapter_no=null)。
- Workbench:挂 useAiConversation 单实例 + AiConversationDrawer + 底栏「AI 对话」入口(flag 灰度),
三个 DRAFT-only 再接受回调(替换整章/插入正文/回填选段),成功关抽屉+toast、漂移不盲替换。
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { ArrowLeft, Database, Sparkles } from "lucide-react";
|
import { ArrowLeft, Database, Sparkles } from "lucide-react";
|
||||||
|
|
||||||
import { useToast } from "@/components/Toast";
|
import { useToast } from "@/components/Toast";
|
||||||
@@ -23,8 +23,10 @@ import {
|
|||||||
type PreviewItem,
|
type PreviewItem,
|
||||||
} from "@/lib/toolbox/toolbox";
|
} from "@/lib/toolbox/toolbox";
|
||||||
import { ingestTable, isSingleObjectIngest } from "@/lib/toolbox/ingest";
|
import { ingestTable, isSingleObjectIngest } from "@/lib/toolbox/ingest";
|
||||||
|
import { fieldSummary, renderPreviewText } from "@/lib/toolbox/aiSummary";
|
||||||
import { buildManuscriptExcerpt } from "@/lib/workbench/manuscriptExcerpt";
|
import { buildManuscriptExcerpt } from "@/lib/workbench/manuscriptExcerpt";
|
||||||
import { useGenerator } from "@/lib/toolbox/useGenerator";
|
import { useGenerator } from "@/lib/toolbox/useGenerator";
|
||||||
|
import type { AiTurnInput } from "@/lib/workbench/useAiConversation";
|
||||||
import { TemplateFiller } from "./TemplateFiller";
|
import { TemplateFiller } from "./TemplateFiller";
|
||||||
|
|
||||||
interface GeneratorRunnerProps {
|
interface GeneratorRunnerProps {
|
||||||
@@ -36,6 +38,10 @@ interface GeneratorRunnerProps {
|
|||||||
// 可选:作者【已写正文】。传入且当前工具有 brief 字段时,表单里出现「用当前正文」按钮,
|
// 可选:作者【已写正文】。传入且当前工具有 brief 字段时,表单里出现「用当前正文」按钮,
|
||||||
// 点击把正文摘录填进 brief,让内容感知型生成器(如书名)据正文反推。工具箱页不传 → 不显示。
|
// 点击把正文摘录填进 brief,让内容感知型生成器(如书名)据正文反推。工具箱页不传 → 不显示。
|
||||||
manuscriptText?: string;
|
manuscriptText?: string;
|
||||||
|
// 可选留痕:生成成功后 append 一批 [author(字段摘要), ai(预览文本)]。
|
||||||
|
// 内联工具箱传 conversation.appendMessages + 当前章号;工具箱页传 silent 直投 + chapterNo=null。
|
||||||
|
appendMessages?: (turn: AiTurnInput) => void;
|
||||||
|
logChapterNo?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 声明驱动的通用生成器(核心):按 descriptor.input_fields 渲染表单 → 调通用 generate →
|
// 声明驱动的通用生成器(核心):按 descriptor.input_fields 渲染表单 → 调通用 generate →
|
||||||
@@ -47,6 +53,8 @@ export function GeneratorRunner({
|
|||||||
onClose,
|
onClose,
|
||||||
onInsertText,
|
onInsertText,
|
||||||
manuscriptText,
|
manuscriptText,
|
||||||
|
appendMessages,
|
||||||
|
logChapterNo,
|
||||||
}: GeneratorRunnerProps) {
|
}: GeneratorRunnerProps) {
|
||||||
const gen = useGenerator();
|
const gen = useGenerator();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
@@ -55,6 +63,8 @@ export function GeneratorRunner({
|
|||||||
);
|
);
|
||||||
// 入库勾选:预览行下标集合(与 gen.rawPreview 行对齐)。
|
// 入库勾选:预览行下标集合(与 gen.rawPreview 行对齐)。
|
||||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||||
|
// 本 runner 一次会话 = 一个 thread;多次生成归此组。
|
||||||
|
const threadId = useRef(crypto.randomUUID()).current;
|
||||||
|
|
||||||
const setField = useCallback((name: string, value: string): void => {
|
const setField = useCallback((name: string, value: string): void => {
|
||||||
setValues((prev) => ({ ...prev, [name]: value }));
|
setValues((prev) => ({ ...prev, [name]: value }));
|
||||||
@@ -99,8 +109,36 @@ export function GeneratorRunner({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSelected(new Set());
|
setSelected(new Set());
|
||||||
await gen.generate(projectId, tool.key, buildGenerateRequest(values));
|
const artifact = await gen.generate(
|
||||||
}, [gen, projectId, tool, values, toast]);
|
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(
|
const rows = useMemo(
|
||||||
() => previewRows(gen.rawPreview),
|
() => previewRows(gen.rawPreview),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { EmptyState } from "@/components/ui/EmptyState";
|
|||||||
import { PageHeader } from "@/components/ui/PageHeader";
|
import { PageHeader } from "@/components/ui/PageHeader";
|
||||||
import type { ProjectResponse, ToolDescriptorView } from "@/lib/api/types";
|
import type { ProjectResponse, ToolDescriptorView } from "@/lib/api/types";
|
||||||
import { isLegacyTool, resolveLegacyRoute } from "@/lib/toolbox/toolbox";
|
import { isLegacyTool, resolveLegacyRoute } from "@/lib/toolbox/toolbox";
|
||||||
|
import { useAiConversation } from "@/lib/workbench/useAiConversation";
|
||||||
import { ToolCard } from "./ToolCard";
|
import { ToolCard } from "./ToolCard";
|
||||||
import { GeneratorRunner } from "./GeneratorRunner";
|
import { GeneratorRunner } from "./GeneratorRunner";
|
||||||
|
|
||||||
@@ -27,6 +28,8 @@ export function ToolboxPage({
|
|||||||
initialOpenKey,
|
initialOpenKey,
|
||||||
}: ToolboxPageProps) {
|
}: ToolboxPageProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
// 工具箱页无抽屉:silent 直投——项目级(chapter_no=null)留痕,不 GET、失败不 toast。
|
||||||
|
const conversation = useAiConversation(project.id, 0, { silent: true });
|
||||||
|
|
||||||
const findOpenable = useCallback(
|
const findOpenable = useCallback(
|
||||||
(key: string | undefined): ToolDescriptorView | null => {
|
(key: string | undefined): ToolDescriptorView | null => {
|
||||||
@@ -76,6 +79,8 @@ export function ToolboxPage({
|
|||||||
<GeneratorRunner
|
<GeneratorRunner
|
||||||
projectId={project.id}
|
projectId={project.id}
|
||||||
tool={active}
|
tool={active}
|
||||||
|
appendMessages={conversation.appendMessages}
|
||||||
|
logChapterNo={null}
|
||||||
onClose={() => setActive(null)}
|
onClose={() => setActive(null)}
|
||||||
/>
|
/>
|
||||||
) : sorted.length === 0 ? (
|
) : sorted.length === 0 ? (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { Check, MessagesSquare, RotateCcw, Square, X } from "lucide-react";
|
import { Check, MessagesSquare, RotateCcw, Square, X } from "lucide-react";
|
||||||
|
|
||||||
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
||||||
@@ -12,6 +12,10 @@ import { friendlyError } from "@/lib/errors/messages";
|
|||||||
import { foldClarifications, needsClarifyGate } from "@/lib/workbench/clarify";
|
import { foldClarifications, needsClarifyGate } from "@/lib/workbench/clarify";
|
||||||
import { useChapterRewrite } from "@/lib/workbench/useChapterRewrite";
|
import { useChapterRewrite } from "@/lib/workbench/useChapterRewrite";
|
||||||
import { useRewriteClarify } from "@/lib/workbench/useRewriteClarify";
|
import { useRewriteClarify } from "@/lib/workbench/useRewriteClarify";
|
||||||
|
import type {
|
||||||
|
AiTurnInput,
|
||||||
|
AiTurnMessage,
|
||||||
|
} from "@/lib/workbench/useAiConversation";
|
||||||
import { ChoiceChips } from "./ChoiceChips";
|
import { ChoiceChips } from "./ChoiceChips";
|
||||||
|
|
||||||
// 门控阈值:整章意见 trim 后短于此字数才自动预检反问(清晰意见不交往返税)。
|
// 门控阈值:整章意见 trim 后短于此字数才自动预检反问(清晰意见不交往返税)。
|
||||||
@@ -24,6 +28,8 @@ interface ChapterRewritePanelProps {
|
|||||||
currentDraft: string;
|
currentDraft: string;
|
||||||
// 接受某一版 → 替换整章正文(HITL,作者显式点选才落草稿)。
|
// 接受某一版 → 替换整章正文(HITL,作者显式点选才落草稿)。
|
||||||
onAccept: (text: string) => void;
|
onAccept: (text: string) => void;
|
||||||
|
// 留痕:每版重写记 [author(折后意见), ai(版本正文)],含缓冲的 clarify Q&A;同 thread 迭代。
|
||||||
|
appendMessages: (turn: AiTurnInput) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,12 +40,16 @@ export function ChapterRewritePanel({
|
|||||||
chapterNo,
|
chapterNo,
|
||||||
currentDraft,
|
currentDraft,
|
||||||
onAccept,
|
onAccept,
|
||||||
|
appendMessages,
|
||||||
onClose,
|
onClose,
|
||||||
}: ChapterRewritePanelProps) {
|
}: ChapterRewritePanelProps) {
|
||||||
const rewrite = useChapterRewrite();
|
const rewrite = useChapterRewrite();
|
||||||
const clarify = useRewriteClarify();
|
const clarify = useRewriteClarify();
|
||||||
const [feedback, setFeedback] = useState("");
|
const [feedback, setFeedback] = useState("");
|
||||||
const [pendingFeedback, setPendingFeedback] = useState<string | null>(null);
|
const [pendingFeedback, setPendingFeedback] = useState<string | null>(null);
|
||||||
|
// 本面板一次会话 = 一个 thread;多版迭代 + 澄清往复归此组,版本号本地累加。
|
||||||
|
const threadId = useRef(crypto.randomUUID()).current;
|
||||||
|
const versionNoRef = useRef(0);
|
||||||
|
|
||||||
const hasVersions = rewrite.versions.length > 0;
|
const hasVersions = rewrite.versions.length > 0;
|
||||||
const clarifying = clarify.status === "checking";
|
const clarifying = clarify.status === "checking";
|
||||||
@@ -51,12 +61,34 @@ export function ChapterRewritePanel({
|
|||||||
const priorDraft = rewrite.latest?.text ?? currentDraft;
|
const priorDraft = rewrite.latest?.text ?? currentDraft;
|
||||||
const err = rewrite.state.phase === "error" ? rewrite.state.error : null;
|
const err = rewrite.state.phase === "error" ? rewrite.state.error : null;
|
||||||
|
|
||||||
// 折入澄清答案(或原意见)后流式重写,并清理澄清态。
|
// 折入澄清答案(或原意见)后流式重写,结束后留痕;clarifyTurns 非空时把缓冲 Q&A 一并落库。
|
||||||
const runSend = (fb: string): void => {
|
const runSend = (fb: string, clarifyTurns: AiTurnMessage[] | null): void => {
|
||||||
clarify.reset();
|
clarify.reset();
|
||||||
setPendingFeedback(null);
|
setPendingFeedback(null);
|
||||||
setFeedback("");
|
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)先预检反问;否则直接流式重写。
|
// 「重写整章 / 再改一版」:意见含糊/极短(或 force)先预检反问;否则直接流式重写。
|
||||||
@@ -70,14 +102,32 @@ export function ChapterRewritePanel({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
runSend(fb);
|
runSend(fb, null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 作者答复澄清(点选 value 或自由输入)→ 折进意见 → 流式重写。
|
// 作者答复澄清(点选 value 或自由输入)→ 缓冲 Q&A(原意见→反问→答复)+ 折进意见 → 流式重写。
|
||||||
const onClarifyAnswer = (answer: string): void => {
|
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();
|
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 (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
import { Check, Plus, X } from "lucide-react";
|
import { Check, Plus, X } from "lucide-react";
|
||||||
|
|
||||||
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
||||||
@@ -8,12 +8,15 @@ import { Button } from "@/components/ui/Button";
|
|||||||
import { SectionHeader } from "@/components/ui/SectionHeader";
|
import { SectionHeader } from "@/components/ui/SectionHeader";
|
||||||
import { StatusNote } from "@/components/ui/StatusNote";
|
import { StatusNote } from "@/components/ui/StatusNote";
|
||||||
import { useContinue } from "@/lib/workbench/useContinue";
|
import { useContinue } from "@/lib/workbench/useContinue";
|
||||||
|
import type { AiTurnInput } from "@/lib/workbench/useAiConversation";
|
||||||
|
|
||||||
interface ContinuePanelProps {
|
interface ContinuePanelProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
chapterNo: number;
|
chapterNo: number;
|
||||||
// 插入选中候选到正文(追加到章末)。
|
// 插入选中候选到正文(追加到章末)。
|
||||||
onInsert: (text: string) => void;
|
onInsert: (text: string) => void;
|
||||||
|
// 留痕:每条候选记一条 ai 气泡(续写无作者输入,故无 author 气泡)。
|
||||||
|
appendMessages: (turn: AiTurnInput) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,16 +26,37 @@ export function ContinuePanel({
|
|||||||
projectId,
|
projectId,
|
||||||
chapterNo,
|
chapterNo,
|
||||||
onInsert,
|
onInsert,
|
||||||
|
appendMessages,
|
||||||
onClose,
|
onClose,
|
||||||
}: ContinuePanelProps) {
|
}: ContinuePanelProps) {
|
||||||
const { status, candidates, generate } = useContinue();
|
const { status, candidates, generate } = useContinue();
|
||||||
const ranRef = useRef(false);
|
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<void> => {
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
if (ranRef.current) return;
|
if (ranRef.current) return;
|
||||||
ranRef.current = true;
|
ranRef.current = true;
|
||||||
void generate(projectId, chapterNo);
|
void runGenerate();
|
||||||
}, [projectId, chapterNo, generate]);
|
}, [runGenerate]);
|
||||||
|
|
||||||
const busy = status === "generating";
|
const busy = status === "generating";
|
||||||
|
|
||||||
@@ -58,7 +82,7 @@ export function ContinuePanel({
|
|||||||
<StatusNote variant="danger" className="mt-3 text-xs" title="续写失败">
|
<StatusNote variant="danger" className="mt-3 text-xs" title="续写失败">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void generate(projectId, chapterNo)}
|
onClick={() => void runGenerate()}
|
||||||
className="mt-1 inline-flex items-center gap-1 text-cinnabar hover:underline"
|
className="mt-1 inline-flex items-center gap-1 text-cinnabar hover:underline"
|
||||||
>
|
>
|
||||||
重试
|
重试
|
||||||
@@ -90,7 +114,7 @@ export function ContinuePanel({
|
|||||||
|
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => void generate(projectId, chapterNo)}
|
onClick={() => void runGenerate()}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -11,11 +11,16 @@ import { GeneratorRunner } from "@/components/toolbox/GeneratorRunner";
|
|||||||
import type { ToolDescriptorView } from "@/lib/api/types";
|
import type { ToolDescriptorView } from "@/lib/api/types";
|
||||||
import { isLegacyTool } from "@/lib/toolbox/toolbox";
|
import { isLegacyTool } from "@/lib/toolbox/toolbox";
|
||||||
import { useToolboxTools } from "@/lib/toolbox/useToolboxTools";
|
import { useToolboxTools } from "@/lib/toolbox/useToolboxTools";
|
||||||
|
import type { AiTurnInput } from "@/lib/workbench/useAiConversation";
|
||||||
|
|
||||||
interface InlineToolboxProps {
|
interface InlineToolboxProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
// 当前章号:内联工具箱生成留痕挂本章(chapter_no=current)。
|
||||||
|
chapterNo: number;
|
||||||
// 把生成结果插入正文(追加到章末)。
|
// 把生成结果插入正文(追加到章末)。
|
||||||
onInsertText: (text: string) => void;
|
onInsertText: (text: string) => void;
|
||||||
|
// 留痕:生成成功后 append 一批(透传给 GeneratorRunner)。
|
||||||
|
appendMessages: (turn: AiTurnInput) => void;
|
||||||
// 当前正文(编辑器 text)。透传给 GeneratorRunner,供 book-title 等据正文反推 brief。
|
// 当前正文(编辑器 text)。透传给 GeneratorRunner,供 book-title 等据正文反推 brief。
|
||||||
manuscriptText?: string;
|
manuscriptText?: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -26,7 +31,9 @@ interface InlineToolboxProps {
|
|||||||
// 人设/大纲)仍走各自更丰富的独立页,不塞进内联面板。
|
// 人设/大纲)仍走各自更丰富的独立页,不塞进内联面板。
|
||||||
export function InlineToolbox({
|
export function InlineToolbox({
|
||||||
projectId,
|
projectId,
|
||||||
|
chapterNo,
|
||||||
onInsertText,
|
onInsertText,
|
||||||
|
appendMessages,
|
||||||
manuscriptText,
|
manuscriptText,
|
||||||
onClose,
|
onClose,
|
||||||
}: InlineToolboxProps) {
|
}: InlineToolboxProps) {
|
||||||
@@ -52,6 +59,8 @@ export function InlineToolbox({
|
|||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
tool={active}
|
tool={active}
|
||||||
manuscriptText={manuscriptText}
|
manuscriptText={manuscriptText}
|
||||||
|
appendMessages={appendMessages}
|
||||||
|
logChapterNo={chapterNo}
|
||||||
onClose={() => setActive(null)}
|
onClose={() => setActive(null)}
|
||||||
onInsertText={(text) => {
|
onInsertText={(text) => {
|
||||||
if (text.trim().length > 0) onInsertText(text);
|
if (text.trim().length > 0) onInsertText(text);
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import { TextArea } from "@/components/ui/TextArea";
|
|||||||
import { useRefine } from "@/lib/workbench/useRefine";
|
import { useRefine } from "@/lib/workbench/useRefine";
|
||||||
import { useClarify } from "@/lib/workbench/useClarify";
|
import { useClarify } from "@/lib/workbench/useClarify";
|
||||||
import { foldClarifications, needsClarifyGate } from "@/lib/workbench/clarify";
|
import { foldClarifications, needsClarifyGate } from "@/lib/workbench/clarify";
|
||||||
|
import type {
|
||||||
|
AiTurnInput,
|
||||||
|
AiTurnMessage,
|
||||||
|
} from "@/lib/workbench/useAiConversation";
|
||||||
import { ChoiceChips } from "./ChoiceChips";
|
import { ChoiceChips } from "./ChoiceChips";
|
||||||
|
|
||||||
// 门控阈值:再沟通意见 trim 后短于此字数才自动预检反问(清晰意见不交往返税)。
|
// 门控阈值:再沟通意见 trim 后短于此字数才自动预检反问(清晰意见不交往返税)。
|
||||||
@@ -23,6 +27,8 @@ interface RefinePanelProps {
|
|||||||
original: string;
|
original: string;
|
||||||
// 接受回填:把选段替换为该产出(调用方按内容重锚落回草稿)。
|
// 接受回填:把选段替换为该产出(调用方按内容重锚落回草稿)。
|
||||||
onAccept: (refined: string) => void;
|
onAccept: (refined: string) => void;
|
||||||
|
// 留痕:把本轮往复(含缓冲的 clarify Q&A)作为一批 append(fail-soft,不阻塞回炉)。
|
||||||
|
appendMessages: (turn: AiTurnInput) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +39,7 @@ export function RefinePanel({
|
|||||||
chapterNo,
|
chapterNo,
|
||||||
original,
|
original,
|
||||||
onAccept,
|
onAccept,
|
||||||
|
appendMessages,
|
||||||
onClose,
|
onClose,
|
||||||
}: RefinePanelProps) {
|
}: RefinePanelProps) {
|
||||||
const { status, versions, latest, refine, recommunicate } = useRefine();
|
const { status, versions, latest, refine, recommunicate } = useRefine();
|
||||||
@@ -40,12 +47,36 @@ export function RefinePanel({
|
|||||||
const [instruction, setInstruction] = useState("");
|
const [instruction, setInstruction] = useState("");
|
||||||
const [pendingInstruction, setPendingInstruction] = useState<string | null>(null);
|
const [pendingInstruction, setPendingInstruction] = useState<string | null>(null);
|
||||||
const ranRef = useRef(false);
|
const ranRef = useRef(false);
|
||||||
|
// 本面板一次会话 = 一个 thread;所有润色/再沟通/澄清往复归此组。版本号 repo 无关,本地累加。
|
||||||
|
const threadId = useRef(crypto.randomUUID()).current;
|
||||||
|
const versionNoRef = useRef(0);
|
||||||
|
|
||||||
// 打开即对选段回炉一次(仅一次,避免重复请求)。
|
// 一版 ai 产出气泡:content=产出,meta 存版本号 + 原选段(每版都存,供任意版 re-accept 重锚)。
|
||||||
|
const aiRow = (refined: string): AiTurnMessage => {
|
||||||
|
versionNoRef.current += 1;
|
||||||
|
return {
|
||||||
|
role: "ai",
|
||||||
|
content: refined,
|
||||||
|
meta: { version_no: versionNoRef.current, segment: original },
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 打开即对选段回炉一次(仅一次,避免重复请求);成功后留痕 [author(原选段), ai(润色)]。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (ranRef.current) return;
|
if (ranRef.current) return;
|
||||||
ranRef.current = true;
|
ranRef.current = true;
|
||||||
void refine(projectId, chapterNo, original);
|
void (async () => {
|
||||||
|
const v = await refine(projectId, chapterNo, original);
|
||||||
|
if (v) {
|
||||||
|
appendMessages({
|
||||||
|
threadId,
|
||||||
|
kind: "refine",
|
||||||
|
chapterNo,
|
||||||
|
messages: [{ role: "author", content: original }, aiRow(v.refined)],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [projectId, chapterNo, original, refine]);
|
}, [projectId, chapterNo, original, refine]);
|
||||||
|
|
||||||
const busy = status === "refining";
|
const busy = status === "refining";
|
||||||
@@ -53,12 +84,23 @@ export function RefinePanel({
|
|||||||
const firstQuestion = clarify.decision?.questions[0] ?? null;
|
const firstQuestion = clarify.decision?.questions[0] ?? null;
|
||||||
const asking = clarify.status === "asking" && firstQuestion !== null;
|
const asking = clarify.status === "asking" && firstQuestion !== null;
|
||||||
|
|
||||||
// 折入澄清答案(或原意见)后回炉,并清理澄清态。
|
// 折入澄清答案(或原意见)后回炉,结束后留痕;clarifyTurns 非空时把缓冲的 Q&A 一并落库。
|
||||||
const runRecommunicate = (finalInstruction: string): void => {
|
const runRecommunicate = (
|
||||||
|
finalInstruction: string,
|
||||||
|
clarifyTurns: AiTurnMessage[] | null,
|
||||||
|
): void => {
|
||||||
clarify.reset();
|
clarify.reset();
|
||||||
setPendingInstruction(null);
|
setPendingInstruction(null);
|
||||||
setInstruction("");
|
setInstruction("");
|
||||||
void recommunicate(projectId, chapterNo, finalInstruction);
|
void (async () => {
|
||||||
|
const v = await recommunicate(projectId, chapterNo, finalInstruction);
|
||||||
|
if (!v) return;
|
||||||
|
// clarify 分支:作者答复 IS 本轮 author 输入,不再另加 author(意见) 气泡;否则记 author(意见)。
|
||||||
|
const messages = clarifyTurns
|
||||||
|
? [...clarifyTurns, aiRow(v.refined)]
|
||||||
|
: [{ role: "author" as const, content: finalInstruction }, aiRow(v.refined)];
|
||||||
|
appendMessages({ threadId, kind: "refine", chapterNo, messages });
|
||||||
|
})();
|
||||||
};
|
};
|
||||||
|
|
||||||
// 「再改一版」:意见含糊/极短(或 force)先预检反问;否则直接回炉。
|
// 「再改一版」:意见含糊/极短(或 force)先预检反问;否则直接回炉。
|
||||||
@@ -73,14 +115,33 @@ export function RefinePanel({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
runRecommunicate(trimmed);
|
runRecommunicate(trimmed, null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 作者答复澄清(点选 value 或自由输入)→ 折进意见 → 回炉。
|
// 作者答复澄清(点选 value 或自由输入)→ 缓冲 Q&A(原意见→反问→答复)+ 折进意见 → 回炉。
|
||||||
const onClarifyAnswer = (answer: string): void => {
|
const onClarifyAnswer = (answer: string): void => {
|
||||||
const question = clarify.decision?.questions[0]?.question ?? "";
|
const q = clarify.decision?.questions[0];
|
||||||
|
const question = q?.question ?? "";
|
||||||
const base = pendingInstruction ?? instruction.trim();
|
const base = pendingInstruction ?? instruction.trim();
|
||||||
runRecommunicate(foldClarifications(base, [{ question, answer }]));
|
// 缓冲三条:原含糊意见(author) → AI 反问(ai, 选项进 meta) → 作者答复(author),随本轮产出一起落库。
|
||||||
|
const clarifyTurns: AiTurnMessage[] = [
|
||||||
|
{ role: "author", content: base },
|
||||||
|
{
|
||||||
|
role: "ai",
|
||||||
|
content: question,
|
||||||
|
meta: {
|
||||||
|
parent_kind: "refine",
|
||||||
|
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 },
|
||||||
|
];
|
||||||
|
runRecommunicate(foldClarifications(base, [{ question, answer }]), clarifyTurns);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
FileText,
|
FileText,
|
||||||
Library,
|
Library,
|
||||||
|
MessagesSquare,
|
||||||
PanelLeft,
|
PanelLeft,
|
||||||
PanelRight,
|
PanelRight,
|
||||||
PenLine,
|
PenLine,
|
||||||
@@ -51,9 +52,14 @@ import { ContinuePanel } from "./ContinuePanel";
|
|||||||
import { InlineToolbox } from "./InlineToolbox";
|
import { InlineToolbox } from "./InlineToolbox";
|
||||||
import { ChapterRewritePanel } from "./ChapterRewritePanel";
|
import { ChapterRewritePanel } from "./ChapterRewritePanel";
|
||||||
import { ContextDrawer, CONTEXT_DRAWER_ENABLED } from "./ContextDrawer";
|
import { ContextDrawer, CONTEXT_DRAWER_ENABLED } from "./ContextDrawer";
|
||||||
|
import {
|
||||||
|
AiConversationDrawer,
|
||||||
|
AI_CONVERSATION_ENABLED,
|
||||||
|
} from "./AiConversationDrawer";
|
||||||
import { GenrePicker } from "./GenrePicker";
|
import { GenrePicker } from "./GenrePicker";
|
||||||
import { GENRES } from "@/lib/wizard/wizard";
|
import { GENRES } from "@/lib/wizard/wizard";
|
||||||
import { useGenreGate, toGenreDirective } from "@/lib/workbench/useGenreGate";
|
import { useGenreGate, toGenreDirective } from "@/lib/workbench/useGenreGate";
|
||||||
|
import { useAiConversation } from "@/lib/workbench/useAiConversation";
|
||||||
|
|
||||||
interface WorkbenchProps {
|
interface WorkbenchProps {
|
||||||
project: ProjectResponse;
|
project: ProjectResponse;
|
||||||
@@ -96,6 +102,10 @@ export function Workbench({
|
|||||||
const [genrePromptOpen, setGenrePromptOpen] = useState(false);
|
const [genrePromptOpen, setGenrePromptOpen] = useState(false);
|
||||||
// WFW-6 上下文速查抽屉:加法式、flag 灰度;与三张 AI 结果卡互不干扰。
|
// WFW-6 上下文速查抽屉:加法式、flag 灰度;与三张 AI 结果卡互不干扰。
|
||||||
const [contextOpen, setContextOpen] = useState(false);
|
const [contextOpen, setContextOpen] = useState(false);
|
||||||
|
// AC-3 AI 对话抽屉:Workbench 级单实例(append 即使抽屉关着也能触发);flag 灰度。
|
||||||
|
const [conversationOpen, setConversationOpen] = useState(false);
|
||||||
|
const conversation = useAiConversation(project.id, chapterNo);
|
||||||
|
const conversationTriggerRef = useRef<HTMLButtonElement>(null);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const autosave = useAutosave(project.id, chapterNo, initialText);
|
const autosave = useAutosave(project.id, chapterNo, initialText);
|
||||||
const stream = useDraftStream();
|
const stream = useDraftStream();
|
||||||
@@ -225,6 +235,37 @@ export function Workbench({
|
|||||||
setContinueOpen(false);
|
setContinueOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 从「AI 对话」历史再接受(复用 DRAFT-only HITL:只回填编辑器草稿,不绕开验收事务——不变量 #3)。
|
||||||
|
// 返回 true=成功则抽屉自关,露出改动后的编辑器 + 成功 toast;refine 找不到原选段返回 false + 漂移提示。
|
||||||
|
const onReplaceChapterFromHistory = (content: string): boolean => {
|
||||||
|
setText(content);
|
||||||
|
autosave.onChange(content);
|
||||||
|
toast("已替换整章。", "success");
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onAppendDraftFromHistory = (content: string): boolean => {
|
||||||
|
appendToDraft(content);
|
||||||
|
toast("已插入正文。", "success");
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRefillSegmentFromHistory = (
|
||||||
|
segment: string,
|
||||||
|
refined: string,
|
||||||
|
): boolean => {
|
||||||
|
// 合成零区间强制 slice 校验失败 → 走 indexOf 按存档的原选段重锚;找不到则不盲替换。
|
||||||
|
const next = applyRefinement(text, segment, refined, { start: 0, end: 0 });
|
||||||
|
if (next === null) {
|
||||||
|
toast("正文已改动,找不到原选段,无法回填。", "error");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setText(next);
|
||||||
|
autosave.onChange(next);
|
||||||
|
toast("已回填选段。", "success");
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
// 字数统计非空白字符(与中文读者直觉一致:标点/空格不计入正文体量)。
|
// 字数统计非空白字符(与中文读者直觉一致:标点/空格不计入正文体量)。
|
||||||
const wordCount = text.replace(/\s+/g, "").length;
|
const wordCount = text.replace(/\s+/g, "").length;
|
||||||
const closePanel = (): void => setMobilePanel(null);
|
const closePanel = (): void => setMobilePanel(null);
|
||||||
@@ -288,6 +329,7 @@ export function Workbench({
|
|||||||
chapterNo={chapterNo}
|
chapterNo={chapterNo}
|
||||||
original={selection.text}
|
original={selection.text}
|
||||||
onAccept={onAcceptRefine}
|
onAccept={onAcceptRefine}
|
||||||
|
appendMessages={conversation.appendMessages}
|
||||||
onClose={() => setRefineOpen(false)}
|
onClose={() => setRefineOpen(false)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -296,13 +338,16 @@ export function Workbench({
|
|||||||
projectId={project.id}
|
projectId={project.id}
|
||||||
chapterNo={chapterNo}
|
chapterNo={chapterNo}
|
||||||
onInsert={onInsertContinuation}
|
onInsert={onInsertContinuation}
|
||||||
|
appendMessages={conversation.appendMessages}
|
||||||
onClose={() => setContinueOpen(false)}
|
onClose={() => setContinueOpen(false)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{toolboxOpen ? (
|
{toolboxOpen ? (
|
||||||
<InlineToolbox
|
<InlineToolbox
|
||||||
projectId={project.id}
|
projectId={project.id}
|
||||||
|
chapterNo={chapterNo}
|
||||||
manuscriptText={text}
|
manuscriptText={text}
|
||||||
|
appendMessages={conversation.appendMessages}
|
||||||
onInsertText={(chunk) => {
|
onInsertText={(chunk) => {
|
||||||
appendToDraft(chunk);
|
appendToDraft(chunk);
|
||||||
setToolboxOpen(false);
|
setToolboxOpen(false);
|
||||||
@@ -316,6 +361,7 @@ export function Workbench({
|
|||||||
chapterNo={chapterNo}
|
chapterNo={chapterNo}
|
||||||
currentDraft={text}
|
currentDraft={text}
|
||||||
onAccept={onAcceptRewrite}
|
onAccept={onAcceptRewrite}
|
||||||
|
appendMessages={conversation.appendMessages}
|
||||||
onClose={() => setRewriteOpen(false)}
|
onClose={() => setRewriteOpen(false)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -350,6 +396,8 @@ export function Workbench({
|
|||||||
onRewrite={openRewrite}
|
onRewrite={openRewrite}
|
||||||
onOpenContext={() => setContextOpen(true)}
|
onOpenContext={() => setContextOpen(true)}
|
||||||
contextTriggerRef={contextTriggerRef}
|
contextTriggerRef={contextTriggerRef}
|
||||||
|
onOpenConversation={() => setConversationOpen(true)}
|
||||||
|
conversationTriggerRef={conversationTriggerRef}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -387,6 +435,20 @@ export function Workbench({
|
|||||||
onClose={() => setContextOpen(false)}
|
onClose={() => setContextOpen(false)}
|
||||||
triggerRef={contextTriggerRef}
|
triggerRef={contextTriggerRef}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* AC-3 AI 对话:右侧 slide-over 气泡视图;从历史再接受只回填草稿,不绕验收事务。flag 灰度。 */}
|
||||||
|
{AI_CONVERSATION_ENABLED ? (
|
||||||
|
<AiConversationDrawer
|
||||||
|
open={conversationOpen}
|
||||||
|
onClose={() => setConversationOpen(false)}
|
||||||
|
conversation={conversation}
|
||||||
|
currentChapterNo={chapterNo}
|
||||||
|
onReplaceChapter={onReplaceChapterFromHistory}
|
||||||
|
onAppendDraft={onAppendDraftFromHistory}
|
||||||
|
onRefillSegment={onRefillSegmentFromHistory}
|
||||||
|
triggerRef={conversationTriggerRef}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -585,6 +647,9 @@ interface ToolbarProps {
|
|||||||
// 打开上下文速查抽屉(WFW-6)。
|
// 打开上下文速查抽屉(WFW-6)。
|
||||||
onOpenContext: () => void;
|
onOpenContext: () => void;
|
||||||
contextTriggerRef: RefObject<HTMLButtonElement | null>;
|
contextTriggerRef: RefObject<HTMLButtonElement | null>;
|
||||||
|
// 打开 AI 对话抽屉(AC-3)。
|
||||||
|
onOpenConversation: () => void;
|
||||||
|
conversationTriggerRef: RefObject<HTMLButtonElement | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Toolbar({
|
function Toolbar({
|
||||||
@@ -605,6 +670,8 @@ function Toolbar({
|
|||||||
onRewrite,
|
onRewrite,
|
||||||
onOpenContext,
|
onOpenContext,
|
||||||
contextTriggerRef,
|
contextTriggerRef,
|
||||||
|
onOpenConversation,
|
||||||
|
conversationTriggerRef,
|
||||||
}: ToolbarProps) {
|
}: ToolbarProps) {
|
||||||
return (
|
return (
|
||||||
<div className="sticky bottom-0 z-10 border-t border-line bg-panel/95 px-4 py-2 backdrop-blur sm:px-6 sm:py-3">
|
<div className="sticky bottom-0 z-10 border-t border-line bg-panel/95 px-4 py-2 backdrop-blur sm:px-6 sm:py-3">
|
||||||
@@ -681,6 +748,18 @@ function Toolbar({
|
|||||||
速查
|
速查
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
{AI_CONVERSATION_ENABLED ? (
|
||||||
|
<Button
|
||||||
|
ref={conversationTriggerRef}
|
||||||
|
onClick={onOpenConversation}
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
title="打开 AI 对话(润色/重写/续写/工具箱往复留痕)"
|
||||||
|
>
|
||||||
|
<MessagesSquare className="h-4 w-4" aria-hidden="true" />
|
||||||
|
AI 对话
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
<Link
|
<Link
|
||||||
href={`/projects/${projectId}/outline`}
|
href={`/projects/${projectId}/outline`}
|
||||||
className={buttonClass({ variant: "secondary", size: "sm" })}
|
className={buttonClass({ variant: "secondary", size: "sm" })}
|
||||||
|
|||||||
51
apps/web/lib/toolbox/aiSummary.test.ts
Normal file
51
apps/web/lib/toolbox/aiSummary.test.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { ToolInputFieldView } from "@/lib/api/types";
|
||||||
|
import type { PreviewModel } from "./toolbox";
|
||||||
|
import { fieldSummary, renderPreviewText } from "./aiSummary";
|
||||||
|
|
||||||
|
const fields: ToolInputFieldView[] = [
|
||||||
|
{ name: "brief", label: "脑洞", type: "textarea", required: true, help: null },
|
||||||
|
{ name: "count", label: "数量", type: "number", required: false, help: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("fieldSummary", () => {
|
||||||
|
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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
33
apps/web/lib/toolbox/aiSummary.ts
Normal file
33
apps/web/lib/toolbox/aiSummary.ts
Normal file
@@ -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");
|
||||||
|
}
|
||||||
@@ -55,7 +55,7 @@ describe("useGenerator", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { result } = renderHook(() => useGenerator());
|
const { result } = renderHook(() => useGenerator());
|
||||||
let returned: { outputKind: string; rawPreview: unknown } | null = null;
|
let returned: unknown;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
returned = await result.current.generate("p1", "idea", { brief: "脑洞" });
|
returned = await result.current.generate("p1", "idea", { brief: "脑洞" });
|
||||||
});
|
});
|
||||||
@@ -67,8 +67,9 @@ describe("useGenerator", () => {
|
|||||||
expect(result.current.preview?.items[0]?.heading).toBe("废柴逆袭");
|
expect(result.current.preview?.items[0]?.heading).toBe("废柴逆袭");
|
||||||
expect(toast).not.toHaveBeenCalled();
|
expect(toast).not.toHaveBeenCalled();
|
||||||
// 追加断言:generate 返回本次产物(供 GeneratorRunner 结束后留痕)。
|
// 追加断言:generate 返回本次产物(供 GeneratorRunner 结束后留痕)。
|
||||||
expect(returned?.outputKind).toBe("IdeaListResult");
|
const artifact = returned as { outputKind: string; rawPreview: unknown };
|
||||||
expect(returned?.rawPreview).toEqual(preview);
|
expect(artifact.outputKind).toBe("IdeaListResult");
|
||||||
|
expect(artifact.rawPreview).toEqual(preview);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("生成后端返回 error:genStatus=error 且弹错误 toast", async () => {
|
it("生成后端返回 error:genStatus=error 且弹错误 toast", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user