feat(frontend): 润色/整章重写面板内联对话气泡流(就地可见)

用户反馈:不想每次开「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 保留原样(底栏仍可开,跨章/项目级全量历史浏览)。
This commit is contained in:
Yaojia Wang
2026-07-10 16:32:10 +02:00
parent 5af45f5034
commit 1185069572
3 changed files with 279 additions and 137 deletions

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useRef, useState } from "react"; import { useEffect, 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";
@@ -8,48 +8,61 @@ 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 { TextArea } from "@/components/ui/TextArea"; import { TextArea } from "@/components/ui/TextArea";
import type { AiMessageView } from "@/lib/api/types";
import { friendlyError } from "@/lib/errors/messages"; 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 { import { acceptActionFor, type AcceptAction } from "@/lib/workbench/aiConversation";
AiTurnInput, import { mergeInlineThreads, toPendingView } from "@/lib/workbench/inlineConversation";
AiTurnMessage, import type { AiTurnMessage, UseAiConversation } from "@/lib/workbench/useAiConversation";
} from "@/lib/workbench/useAiConversation";
import { ChoiceChips } from "./ChoiceChips"; import { ChoiceChips } from "./ChoiceChips";
import { ConversationThread } from "./ConversationThread";
// 门控阈值:整章意见 trim 后短于此字数才自动预检反问(清晰意见不交往返税)。 // 门控阈值:整章意见 trim 后短于此字数才自动预检反问(清晰意见不交往返税)。
const CLARIFY_MIN_CHARS = 10; const CLARIFY_MIN_CHARS = 10;
// 本面板内联气泡流展示的 kinds本轮 rewrite 往复 + 其 clarify 反问;后者持久化时并入 rewrite 批)。
const REWRITE_KINDS = ["rewrite", "clarify"] as const;
interface ChapterRewritePanelProps { interface ChapterRewritePanelProps {
projectId: string; projectId: string;
chapterNo: number; chapterNo: number;
// 当前整章正文(首轮重写的 prior_draft // 当前整章正文(首轮重写的 prior_draft
currentDraft: string; currentDraft: string;
// 接受某一版 → 替换整章正文HITL作者显式点选才落草稿 // 接受某一版 → 替换整章正文HITL作者显式点选才落草稿;气泡「再接受」亦复用此回调
onAccept: (text: string) => void; onAccept: (text: string) => void;
// 留痕:每版重写记 [author(折后意见), ai(版本正文)],含缓冲的 clarify Q&A同 thread 迭代 // Workbench 级持久化对话(本章留痕数据源 + fail-soft append
appendMessages: (turn: AiTurnInput) => void; conversation: UseAiConversation;
onClose: () => void; onClose: () => void;
} }
// 整章「再沟通 / 重写」结果卡(内联,非模态):作者给意见 → 意见含糊/极短时 AI 先反问给方向选项(预检、只读、 // 整章「再沟通 / 重写」结果卡(内联,非模态):作者给意见 → 意见含糊/极短时 AI 先反问给方向选项
// HITL 就着当前整章 + 完整记忆注入流式重写一版 → 版本栈可多轮迭代 → 接受某版才替换整章正文(不变量 #3 // 就着当前整章 + 完整记忆注入流式重写一版。面板正文是一条内联对话气泡流(本章已落库 rewrite 往复 +
// 本轮进行中气泡实时汇入,流式 token 逐字进气泡),输入框在底部。接受某版才替换整章正文(不变量 #3
export function ChapterRewritePanel({ export function ChapterRewritePanel({
projectId, projectId,
chapterNo, chapterNo,
currentDraft, currentDraft,
onAccept, onAccept,
appendMessages, conversation,
onClose, onClose,
}: ChapterRewritePanelProps) { }: ChapterRewritePanelProps) {
const { messages, status, ensureLoaded, appendMessages } = conversation;
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);
// 本轮进行中(尚未落库)的作者/反问气泡;生成开始时置入、结束或失败时清空 → 交由已落库气泡接管。
const [liveTurn, setLiveTurn] = useState<AiTurnMessage[] | null>(null);
// 本面板一次会话 = 一个 thread多版迭代 + 澄清往复归此组,版本号本地累加。 // 本面板一次会话 = 一个 thread多版迭代 + 澄清往复归此组,版本号本地累加。
const threadId = useRef(crypto.randomUUID()).current; const threadId = useRef(crypto.randomUUID()).current;
const versionNoRef = useRef(0); const versionNoRef = useRef(0);
const streamRef = useRef<HTMLDivElement>(null);
// 打开即懒加载本章留痕Workbench 级 hookappend 也会触发——已加载则空操作)。
useEffect(() => {
ensureLoaded();
}, [ensureLoaded]);
const hasVersions = rewrite.versions.length > 0; const hasVersions = rewrite.versions.length > 0;
const clarifying = clarify.status === "checking"; const clarifying = clarify.status === "checking";
@@ -66,28 +79,28 @@ export function ChapterRewritePanel({
clarify.reset(); clarify.reset();
setPendingFeedback(null); setPendingFeedback(null);
setFeedback(""); setFeedback("");
// clarify 分支:作者答复 IS 本轮 author 输入;否则记 author(意见)。
const authorTurn: AiTurnMessage = { role: "author", content: fb };
setLiveTurn(clarifyTurns ?? [authorTurn]);
void (async () => { void (async () => {
const v = await rewrite.send(projectId, chapterNo, fb, priorDraft); const v = await rewrite.send(projectId, chapterNo, fb, priorDraft);
if (!v) return; // 停止/失败/空版:不留痕。 if (v) {
versionNoRef.current += 1; versionNoRef.current += 1;
const versionNo = versionNoRef.current; const versionNo = versionNoRef.current;
const aiRow: AiTurnMessage = { const aiRow: AiTurnMessage = {
role: "ai", role: "ai",
content: v.text, content: v.text,
meta: { version_no: versionNo }, meta: { version_no: versionNo },
}; };
// clarify 分支:作者答复 IS 本轮 author 输入;否则记 author(折后意见, meta{version_no})。 const rows = clarifyTurns
const messages = clarifyTurns ? [...clarifyTurns, aiRow]
? [...clarifyTurns, aiRow] : [
: [ { role: "author" as const, content: fb, meta: { version_no: versionNo } },
{ aiRow,
role: "author" as const, ];
content: fb, appendMessages({ threadId, kind: "rewrite", chapterNo, messages: rows });
meta: { version_no: versionNo }, }
}, setLiveTurn(null);
aiRow,
];
appendMessages({ threadId, kind: "rewrite", chapterNo, messages });
})(); })();
}; };
@@ -130,8 +143,51 @@ export function ChapterRewritePanel({
runSend(foldClarifications(base, [{ question, answer }]), clarifyTurns); runSend(foldClarifications(base, [{ question, answer }]), clarifyTurns);
}; };
// 本轮进行中气泡liveTurn已定的作者/反问)或反问待答时的作者意见 + 流式产出的 ai 气泡(逐字更新)。
const pending: AiMessageView[] = [];
{
let seq = 0;
const add = (seed: Parameters<typeof toPendingView>[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 ( return (
<div className="max-h-[70vh] overflow-auto border-b border-line bg-panel px-4 py-3 sm:px-6"> <div className="max-h-[75vh] overflow-auto border-b border-line bg-panel px-4 py-3 sm:px-6">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<SectionHeader <SectionHeader
title="对整章再沟通 / 重写" title="对整章再沟通 / 重写"
@@ -142,20 +198,40 @@ export function ChapterRewritePanel({
</Button> </Button>
</div> </div>
{/* 内联对话气泡流:本章 rewrite 往复(已落库)+ 本轮进行中气泡(流式逐字),作者右 / AI 左,滚动区。 */}
{flatCount > 0 || status === "error" ? (
<div
ref={streamRef}
role="log"
aria-live="polite"
aria-relevant="additions"
aria-label="整章重写对话"
className="mt-3 max-h-[45vh] space-y-3 overflow-auto overscroll-contain rounded border border-line bg-bg/40 p-3"
>
{status === "error" ? (
<StatusNote variant="danger" className="text-xs" title="历史对话加载失败">
<Button
onClick={conversation.reload}
variant="secondary"
size="sm"
className="mt-1"
>
</Button>
</StatusNote>
) : null}
{threads.map((thread) => (
<ConversationThread
key={thread.threadId}
messages={thread.messages}
action={acceptActionFor(thread)}
onAccept={onBubbleAccept}
/>
))}
</div>
) : null}
<div className="mt-3 space-y-2"> <div className="mt-3 space-y-2">
<label className="block">
<span className="sr-only"></span>
<TextArea
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
rows={2}
placeholder={
hasVersions
? "对这一版还想怎么改?(如「高潮再拉满」「删掉第 2 段闪回」)"
: "这一章哪里不满意?想怎么改?(意见太笼统时 AI 会先反问、给方向选项)"
}
/>
</label>
{asking && firstQuestion ? ( {asking && firstQuestion ? (
// role="status"AI 反问动态出现时向读屏播报WCAG 4.1.3 Status Messages // role="status"AI 反问动态出现时向读屏播报WCAG 4.1.3 Status Messages
<div role="status" className="rounded border border-cinnabar/40 bg-bg p-3"> <div role="status" className="rounded border border-cinnabar/40 bg-bg p-3">
@@ -169,6 +245,19 @@ export function ChapterRewritePanel({
/> />
</div> </div>
) : null} ) : null}
<label className="block">
<span className="sr-only"></span>
<TextArea
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
rows={2}
placeholder={
hasVersions
? "对这一版还想怎么改?(如「高潮再拉满」「删掉第 2 段闪回」)"
: "这一章哪里不满意?想怎么改?(意见太笼统时 AI 会先反问、给方向选项)"
}
/>
</label>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{rewrite.isStreaming ? ( {rewrite.isStreaming ? (
<Button onClick={rewrite.stop} variant="danger" size="sm"> <Button onClick={rewrite.stop} variant="danger" size="sm">
@@ -196,6 +285,16 @@ export function ChapterRewritePanel({
<MessagesSquare className="h-4 w-4" aria-hidden="true" /> <MessagesSquare className="h-4 w-4" aria-hidden="true" />
</Button> </Button>
{hasVersions && !rewrite.isStreaming ? (
<Button
onClick={() => rewrite.latest && onAccept(rewrite.latest.text)}
variant="primary"
size="sm"
>
<Check className="h-4 w-4" aria-hidden="true" />
</Button>
) : null}
{rewrite.isStreaming ? ( {rewrite.isStreaming ? (
<ThinkingIndicator label="重写中" className="text-xs text-cinnabar" /> <ThinkingIndicator label="重写中" className="text-xs text-cinnabar" />
) : clarifying ? ( ) : clarifying ? (
@@ -208,37 +307,6 @@ export function ChapterRewritePanel({
</StatusNote> </StatusNote>
) : null} ) : null}
</div> </div>
{rewrite.isStreaming && rewrite.state.text ? (
<figure className="mt-3 rounded border border-cinnabar/40 bg-bg p-3">
<figcaption className="mb-1 text-2xs text-ink-soft"></figcaption>
<p className="whitespace-pre-wrap text-sm text-ink">{rewrite.state.text}</p>
</figure>
) : null}
{hasVersions && !rewrite.isStreaming ? (
<ul className="mt-3 space-y-2">
{[...rewrite.versions].reverse().map((version, i) => {
const versionNo = rewrite.versions.length - i;
return (
<li key={versionNo} className="rounded border border-line bg-bg p-3">
<div className="mb-1 flex flex-wrap items-center justify-between gap-2">
<span className="text-2xs text-ink-soft">
{versionNo} · {version.feedback}
</span>
<Button onClick={() => onAccept(version.text)} variant="primary" size="sm">
<Check className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
<p className="max-h-48 overflow-auto whitespace-pre-wrap text-sm text-ink">
{version.text}
</p>
</li>
);
})}
</ul>
) : null}
</div> </div>
); );
} }

View File

@@ -8,48 +8,63 @@ 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 { TextArea } from "@/components/ui/TextArea"; import { TextArea } from "@/components/ui/TextArea";
import type { AiMessageView } from "@/lib/api/types";
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 { import { acceptActionFor, type AcceptAction } from "@/lib/workbench/aiConversation";
AiTurnInput, import {
AiTurnMessage, mergeInlineThreads,
} from "@/lib/workbench/useAiConversation"; readSegment,
toPendingView,
} from "@/lib/workbench/inlineConversation";
import type { AiTurnMessage, UseAiConversation } from "@/lib/workbench/useAiConversation";
import { ChoiceChips } from "./ChoiceChips"; import { ChoiceChips } from "./ChoiceChips";
import { ConversationThread } from "./ConversationThread";
// 门控阈值:再沟通意见 trim 后短于此字数才自动预检反问(清晰意见不交往返税)。 // 门控阈值:再沟通意见 trim 后短于此字数才自动预检反问(清晰意见不交往返税)。
const CLARIFY_MIN_CHARS = 10; const CLARIFY_MIN_CHARS = 10;
// 本面板内联气泡流展示的 kinds本轮 refine 往复 + 其 clarify 反问;后者持久化时并入 refine 批)。
const REFINE_KINDS = ["refine", "clarify"] as const;
interface RefinePanelProps { interface RefinePanelProps {
projectId: string; projectId: string;
chapterNo: number; chapterNo: number;
// 选中原文;打开即对其回炉一次。 // 选中原文;打开即对其回炉一次。
original: string; original: string;
// 接受回填:把选段替换为该产出(调用方按内容重锚落回草稿)。 // 接受回填:把「当前选段替换为该产出(调用方按选区坐标重锚落回草稿)。
onAccept: (refined: string) => void; onAccept: (refined: string) => void;
// 留痕:把本轮往复(含缓冲的 clarify Q&A作为一批 appendfail-soft不阻塞回炉 // 从气泡「回填选段」按该版存档的原选段meta.segment内容重锚回填drift-safe返回是否成功
appendMessages: (turn: AiTurnInput) => void; onReaccept: (segment: string, refined: string) => boolean;
// Workbench 级持久化对话(本章留痕数据源 + fail-soft append
conversation: UseAiConversation;
onClose: () => void; onClose: () => void;
} }
// 润色 / 再沟通结果卡(内联,非模态):打开即润色选段;作者可「再沟通」追加意见迭代出新版 // 润色 / 再沟通结果卡(内联,非模态):打开即润色选段;作者可「再沟通」追加意见迭代出新版
// 择版接受回填正文,或放弃。原文只读、生成不写库(不变量 #3accept 才动草稿 // 面板正文是一条内联对话气泡流(本章已落库往复 + 本轮进行中气泡实时汇入),输入框在底部,读起来像聊天
// 原文只读、生成不写库(不变量 #3accept 才动草稿。
export function RefinePanel({ export function RefinePanel({
projectId, projectId,
chapterNo, chapterNo,
original, original,
onAccept, onAccept,
appendMessages, onReaccept,
conversation,
onClose, onClose,
}: RefinePanelProps) { }: RefinePanelProps) {
const { status, versions, latest, refine, recommunicate } = useRefine(); const { messages, status, ensureLoaded, appendMessages } = conversation;
const { status: refineStatus, versions, latest, refine, recommunicate } = useRefine();
const clarify = useClarify(); const clarify = useClarify();
const [instruction, setInstruction] = useState(""); const [instruction, setInstruction] = useState("");
const [pendingInstruction, setPendingInstruction] = useState<string | null>(null); const [pendingInstruction, setPendingInstruction] = useState<string | null>(null);
// 本轮进行中(尚未落库)的作者/反问气泡;生成开始时置入、结束或失败时清空 → 交由已落库气泡接管。
const [liveTurn, setLiveTurn] = useState<AiTurnMessage[] | null>(null);
const ranRef = useRef(false); const ranRef = useRef(false);
// 本面板一次会话 = 一个 thread所有润色/再沟通/澄清往复归此组。版本号 repo 无关,本地累加。 // 本面板一次会话 = 一个 thread所有润色/再沟通/澄清往复归此组。版本号 repo 无关,本地累加。
const threadId = useRef(crypto.randomUUID()).current; const threadId = useRef(crypto.randomUUID()).current;
const versionNoRef = useRef(0); const versionNoRef = useRef(0);
const streamRef = useRef<HTMLDivElement>(null);
// 一版 ai 产出气泡content=产出meta 存版本号 + 原选段(每版都存,供任意版 re-accept 重锚)。 // 一版 ai 产出气泡content=产出meta 存版本号 + 原选段(每版都存,供任意版 re-accept 重锚)。
const aiRow = (refined: string): AiTurnMessage => { const aiRow = (refined: string): AiTurnMessage => {
@@ -65,6 +80,8 @@ export function RefinePanel({
useEffect(() => { useEffect(() => {
if (ranRef.current) return; if (ranRef.current) return;
ranRef.current = true; ranRef.current = true;
ensureLoaded();
setLiveTurn([{ role: "author", content: original }]);
void (async () => { void (async () => {
const v = await refine(projectId, chapterNo, original); const v = await refine(projectId, chapterNo, original);
if (v) { if (v) {
@@ -75,11 +92,12 @@ export function RefinePanel({
messages: [{ role: "author", content: original }, aiRow(v.refined)], messages: [{ role: "author", content: original }, aiRow(v.refined)],
}); });
} }
setLiveTurn(null);
})(); })();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId, chapterNo, original, refine]); }, [projectId, chapterNo, original, refine, ensureLoaded, appendMessages]);
const busy = status === "refining"; const busy = refineStatus === "refining";
const clarifying = clarify.status === "checking"; const clarifying = clarify.status === "checking";
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;
@@ -92,14 +110,18 @@ export function RefinePanel({
clarify.reset(); clarify.reset();
setPendingInstruction(null); setPendingInstruction(null);
setInstruction(""); setInstruction("");
// clarify 分支:作者答复 IS 本轮 author 输入,不再另加 author(意见) 气泡;否则记 author(意见)。
const authorTurn: AiTurnMessage = { role: "author", content: finalInstruction };
setLiveTurn(clarifyTurns ?? [authorTurn]);
void (async () => { void (async () => {
const v = await recommunicate(projectId, chapterNo, finalInstruction); const v = await recommunicate(projectId, chapterNo, finalInstruction);
if (!v) return; if (v) {
// clarify 分支:作者答复 IS 本轮 author 输入,不再另加 author(意见) 气泡;否则记 author(意见)。 const rows = clarifyTurns
const messages = clarifyTurns ? [...clarifyTurns, aiRow(v.refined)]
? [...clarifyTurns, aiRow(v.refined)] : [authorTurn, aiRow(v.refined)];
: [{ role: "author" as const, content: finalInstruction }, aiRow(v.refined)]; appendMessages({ threadId, kind: "refine", chapterNo, messages: rows });
appendMessages({ threadId, kind: "refine", chapterNo, messages }); }
setLiveTurn(null);
})(); })();
}; };
@@ -144,6 +166,43 @@ export function RefinePanel({
runRecommunicate(foldClarifications(base, [{ question, answer }]), clarifyTurns); runRecommunicate(foldClarifications(base, [{ question, answer }]), clarifyTurns);
}; };
// 本轮进行中气泡liveTurn已定的作者/反问)+ 反问待答时的作者意见 + 生成中的 ai 气泡(空正文=思考)。
const pending: AiMessageView[] = [];
{
let seq = 0;
const add = (
seed: Parameters<typeof toPendingView>[0],
): void => {
pending.push(toPendingView(seed, { threadId, chapterNo, kind: "refine", 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: pendingInstruction ?? instruction.trim(),
});
}
if (busy && liveTurn) {
add({ localId: `pt-${threadId}-answer`, role: "ai", content: "", streaming: true });
}
}
const threads = mergeInlineThreads(messages, chapterNo, REFINE_KINDS, pending);
const flatCount = threads.reduce((n, t) => n + t.messages.length, 0);
// 新气泡出现即滚到底聊天视图reduced-motion 下瞬时滚动即可,无动画。
useEffect(() => {
const el = streamRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [flatCount]);
const onBubbleAccept = (action: AcceptAction, m: AiMessageView): void => {
if (action === "refill_segment") onReaccept(readSegment(m.meta), m.content);
};
return ( return (
<div className="border-b border-line bg-panel px-4 py-3 sm:px-6"> <div className="border-b border-line bg-panel px-4 py-3 sm:px-6">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
@@ -156,49 +215,49 @@ export function RefinePanel({
</Button> </Button>
</div> </div>
<div className="mt-3 grid gap-3 sm:grid-cols-2"> {/* 内联对话气泡流:本章 refine 往复(已落库)+ 本轮进行中气泡,作者右 / AI 左,滚动区。 */}
<figure className="rounded border border-line bg-bg p-3"> <div
<figcaption className="mb-1 text-2xs text-ink-soft"></figcaption> ref={streamRef}
<p className="whitespace-pre-wrap text-sm text-ink-soft">{original}</p> role="log"
</figure> aria-live="polite"
<figure className="rounded border border-cinnabar/40 bg-bg p-3"> aria-relevant="additions"
<figcaption className="mb-1 flex items-center gap-2 text-2xs text-ink-soft"> aria-label="润色对话"
className="mt-3 max-h-[40vh] space-y-3 overflow-auto overscroll-contain rounded border border-line bg-bg/40 p-3"
{versions.length > 0 ? ( >
<span className="rounded bg-panel px-1.5 py-0.5 tabular-nums"> {status === "error" ? (
{versions.length} <StatusNote variant="danger" className="text-xs" title="历史对话加载失败">
</span> <Button onClick={conversation.reload} variant="secondary" size="sm" className="mt-1">
) : null}
</figcaption> </Button>
{busy && !latest ? ( </StatusNote>
<ThinkingIndicator label="润色中" className="text-xs text-cinnabar" /> ) : null}
) : latest ? ( {flatCount === 0 && status !== "error" && refineStatus !== "error" ? (
<p className="whitespace-pre-wrap text-sm text-ink">{latest.refined}</p> <p className="py-2 text-center text-xs text-ink-soft"></p>
) : status === "error" ? ( ) : (
<StatusNote variant="danger" className="text-xs" title="回炉失败"> threads.map((thread) => (
<button <ConversationThread
type="button" key={thread.threadId}
onClick={() => void refine(projectId, chapterNo, original)} messages={thread.messages}
className="mt-1 inline-flex items-center gap-1 text-cinnabar hover:underline" action={acceptActionFor(thread)}
> onAccept={onBubbleAccept}
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" /> />
))
</button> )}
</StatusNote> {refineStatus === "error" ? (
) : null} <StatusNote variant="danger" className="text-xs" title="回炉失败">
</figure> <button
type="button"
onClick={() => void refine(projectId, chapterNo, original)}
className="mt-1 inline-flex items-center gap-1 text-cinnabar hover:underline"
>
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</StatusNote>
) : null}
</div> </div>
<div className="mt-3 space-y-2"> <div className="mt-3 space-y-2">
<label className="block">
<span className="sr-only"></span>
<TextArea
value={instruction}
onChange={(e) => setInstruction(e.target.value)}
rows={2}
placeholder="再沟通:想怎么改?(意见太笼统时 AI 会先反问、给方向选项)"
/>
</label>
{asking && firstQuestion ? ( {asking && firstQuestion ? (
// role="status"AI 反问动态出现时向读屏播报WCAG 4.1.3 Status Messages // role="status"AI 反问动态出现时向读屏播报WCAG 4.1.3 Status Messages
<div role="status" className="rounded border border-cinnabar/40 bg-bg p-3"> <div role="status" className="rounded border border-cinnabar/40 bg-bg p-3">
@@ -212,6 +271,15 @@ export function RefinePanel({
/> />
</div> </div>
) : null} ) : null}
<label className="block">
<span className="sr-only"></span>
<TextArea
value={instruction}
onChange={(e) => setInstruction(e.target.value)}
rows={2}
placeholder="再沟通:想怎么改?(意见太笼统时 AI 会先反问、给方向选项)"
/>
</label>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Button <Button
onClick={() => void onRecommunicate(false)} onClick={() => void onRecommunicate(false)}
@@ -247,6 +315,11 @@ export function RefinePanel({
className="text-xs text-cinnabar" className="text-xs text-cinnabar"
/> />
) : null} ) : null}
{versions.length > 0 ? (
<span className="ml-auto rounded bg-bg px-1.5 py-0.5 text-2xs tabular-nums text-ink-soft">
{versions.length}
</span>
) : null}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -329,7 +329,8 @@ export function Workbench({
chapterNo={chapterNo} chapterNo={chapterNo}
original={selection.text} original={selection.text}
onAccept={onAcceptRefine} onAccept={onAcceptRefine}
appendMessages={conversation.appendMessages} onReaccept={onRefillSegmentFromHistory}
conversation={conversation}
onClose={() => setRefineOpen(false)} onClose={() => setRefineOpen(false)}
/> />
) : null} ) : null}
@@ -361,7 +362,7 @@ export function Workbench({
chapterNo={chapterNo} chapterNo={chapterNo}
currentDraft={text} currentDraft={text}
onAccept={onAcceptRewrite} onAccept={onAcceptRewrite}
appendMessages={conversation.appendMessages} conversation={conversation}
onClose={() => setRewriteOpen(false)} onClose={() => setRewriteOpen(false)}
/> />
) : null} ) : null}