From 1185069572cd24f23629aa7c5f1fb1683059350f Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Fri, 10 Jul 2026 16:32:10 +0200 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20=E6=B6=A6=E8=89=B2/=E6=95=B4?= =?UTF-8?q?=E7=AB=A0=E9=87=8D=E5=86=99=E9=9D=A2=E6=9D=BF=E5=86=85=E8=81=94?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E6=B0=94=E6=B3=A1=E6=B5=81=EF=BC=88=E5=B0=B1?= =?UTF-8?q?=E5=9C=B0=E5=8F=AF=E8=A7=81=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户反馈:不想每次开「AI 对话」抽屉才看得到对话,要在对话时就地内联可见。 RefinePanel/ChapterRewritePanel 内联渲染 role=log 滚动气泡流(本章 refine/rewrite + 其 clarify 往复 ∪ 本轮进行中气泡:流式 token 逐字进气泡、缓冲的 clarify Q&A 亦实时可见),输入框置底像聊天。pending→persisted 无重影(进行中气泡结束即清、 已落库去重);fail-soft(历史加载失败仍显示本轮实时气泡,不阻塞回炉/重写); 气泡「再接受/回填」复用既有 DRAFT-only HITL 回调(drift-safe,正文仍只在验收才变); clarify gating/abort/停/接受悉数保留。Workbench 改传 conversation + onReaccept。 AiConversationDrawer 保留原样(底栏仍可开,跨章/项目级全量历史浏览)。 --- .../workbench/ChapterRewritePanel.tsx | 220 ++++++++++++------ apps/web/components/workbench/RefinePanel.tsx | 191 ++++++++++----- apps/web/components/workbench/Workbench.tsx | 5 +- 3 files changed, 279 insertions(+), 137 deletions(-) diff --git a/apps/web/components/workbench/ChapterRewritePanel.tsx b/apps/web/components/workbench/ChapterRewritePanel.tsx index 3543bca..a1e31da 100644 --- a/apps/web/components/workbench/ChapterRewritePanel.tsx +++ b/apps/web/components/workbench/ChapterRewritePanel.tsx @@ -1,6 +1,6 @@ "use client"; -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Check, MessagesSquare, RotateCcw, Square, X } from "lucide-react"; import { ThinkingIndicator } from "@/components/ThinkingIndicator"; @@ -8,48 +8,61 @@ import { Button } from "@/components/ui/Button"; import { SectionHeader } from "@/components/ui/SectionHeader"; import { StatusNote } from "@/components/ui/StatusNote"; import { TextArea } from "@/components/ui/TextArea"; +import type { AiMessageView } from "@/lib/api/types"; 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 { acceptActionFor, type AcceptAction } from "@/lib/workbench/aiConversation"; +import { mergeInlineThreads, toPendingView } from "@/lib/workbench/inlineConversation"; +import type { AiTurnMessage, UseAiConversation } from "@/lib/workbench/useAiConversation"; import { ChoiceChips } from "./ChoiceChips"; +import { ConversationThread } from "./ConversationThread"; // 门控阈值:整章意见 trim 后短于此字数才自动预检反问(清晰意见不交往返税)。 const CLARIFY_MIN_CHARS = 10; +// 本面板内联气泡流展示的 kinds(本轮 rewrite 往复 + 其 clarify 反问;后者持久化时并入 rewrite 批)。 +const REWRITE_KINDS = ["rewrite", "clarify"] as const; interface ChapterRewritePanelProps { projectId: string; chapterNo: number; // 当前整章正文(首轮重写的 prior_draft)。 currentDraft: string; - // 接受某一版 → 替换整章正文(HITL,作者显式点选才落草稿)。 + // 接受某一版 → 替换整章正文(HITL,作者显式点选才落草稿);气泡「再接受」亦复用此回调。 onAccept: (text: string) => void; - // 留痕:每版重写记 [author(折后意见), ai(版本正文)],含缓冲的 clarify Q&A;同 thread 迭代。 - appendMessages: (turn: AiTurnInput) => void; + // Workbench 级持久化对话(本章留痕数据源 + fail-soft append)。 + conversation: UseAiConversation; onClose: () => void; } -// 整章「再沟通 / 重写」结果卡(内联,非模态):作者给意见 → 意见含糊/极短时 AI 先反问给方向选项(预检、只读、 -// HITL)→ 就着当前整章 + 完整记忆注入流式重写一版 → 版本栈可多轮迭代 → 接受某版才替换整章正文(不变量 #3)。 +// 整章「再沟通 / 重写」结果卡(内联,非模态):作者给意见 → 意见含糊/极短时 AI 先反问给方向选项 → +// 就着当前整章 + 完整记忆注入流式重写一版。面板正文是一条内联对话气泡流(本章已落库 rewrite 往复 + +// 本轮进行中气泡实时汇入,流式 token 逐字进气泡),输入框在底部。接受某版才替换整章正文(不变量 #3)。 export function ChapterRewritePanel({ projectId, chapterNo, currentDraft, onAccept, - appendMessages, + conversation, onClose, }: ChapterRewritePanelProps) { + const { messages, status, ensureLoaded, appendMessages } = conversation; const rewrite = useChapterRewrite(); const clarify = useRewriteClarify(); const [feedback, setFeedback] = useState(""); const [pendingFeedback, setPendingFeedback] = useState(null); + // 本轮进行中(尚未落库)的作者/反问气泡;生成开始时置入、结束或失败时清空 → 交由已落库气泡接管。 + const [liveTurn, setLiveTurn] = useState(null); // 本面板一次会话 = 一个 thread;多版迭代 + 澄清往复归此组,版本号本地累加。 const threadId = useRef(crypto.randomUUID()).current; const versionNoRef = useRef(0); + const streamRef = useRef(null); + + // 打开即懒加载本章留痕(Workbench 级 hook;append 也会触发——已加载则空操作)。 + useEffect(() => { + ensureLoaded(); + }, [ensureLoaded]); const hasVersions = rewrite.versions.length > 0; const clarifying = clarify.status === "checking"; @@ -66,28 +79,28 @@ export function ChapterRewritePanel({ clarify.reset(); setPendingFeedback(null); setFeedback(""); + // clarify 分支:作者答复 IS 本轮 author 输入;否则记 author(意见)。 + const authorTurn: AiTurnMessage = { role: "author", content: fb }; + setLiveTurn(clarifyTurns ?? [authorTurn]); 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 }); + if (v) { + versionNoRef.current += 1; + const versionNo = versionNoRef.current; + const aiRow: AiTurnMessage = { + role: "ai", + content: v.text, + meta: { version_no: versionNo }, + }; + const rows = clarifyTurns + ? [...clarifyTurns, aiRow] + : [ + { role: "author" as const, content: fb, meta: { version_no: versionNo } }, + aiRow, + ]; + appendMessages({ threadId, kind: "rewrite", chapterNo, messages: rows }); + } + setLiveTurn(null); })(); }; @@ -130,8 +143,51 @@ export function ChapterRewritePanel({ runSend(foldClarifications(base, [{ question, answer }]), clarifyTurns); }; + // 本轮进行中气泡:liveTurn(已定的作者/反问)或反问待答时的作者意见 + 流式产出的 ai 气泡(逐字更新)。 + const pending: AiMessageView[] = []; + { + let seq = 0; + const add = (seed: Parameters[0]): void => { + pending.push(toPendingView(seed, { threadId, chapterNo, kind: "rewrite", seq: seq++ })); + }; + if (liveTurn) { + liveTurn.forEach((m, i) => + add({ localId: `pt-${threadId}-${i}`, role: m.role, content: m.content }), + ); + } else if (asking) { + add({ + localId: `pt-${threadId}-ask`, + role: "author", + content: pendingFeedback ?? feedback.trim(), + }); + } + // 生成中(含 token 已到但流未结束)显示流式 ai 气泡;正文空 → 「生成中」指示。 + const answerActive = + liveTurn !== null && (rewrite.isStreaming || rewrite.state.text.length > 0); + if (answerActive) { + add({ + localId: `pt-${threadId}-answer`, + role: "ai", + content: rewrite.state.text, + streaming: rewrite.isStreaming, + }); + } + } + const threads = mergeInlineThreads(messages, chapterNo, REWRITE_KINDS, pending); + const flatCount = threads.reduce((n, t) => n + t.messages.length, 0); + + // 新气泡/流式 token 出现即滚到底(聊天视图)。 + useEffect(() => { + const el = streamRef.current; + if (el) el.scrollTop = el.scrollHeight; + }, [flatCount, rewrite.state.text]); + + const onBubbleAccept = (action: AcceptAction, m: AiMessageView): void => { + if (action === "replace_chapter") onAccept(m.content); + }; + return ( -
+
+ {/* 内联对话气泡流:本章 rewrite 往复(已落库)+ 本轮进行中气泡(流式逐字),作者右 / AI 左,滚动区。 */} + {flatCount > 0 || status === "error" ? ( +
+ {status === "error" ? ( + + + + ) : null} + {threads.map((thread) => ( + + ))} +
+ ) : null} +
-