feat(frontend): 整章「再沟通/重写」面板——给意见流式重写整章,版本栈迭代,接受替换正文
WFW-8 整章级再沟通(前端)。底栏新增「整章重写」→ ChapterRewritePanel: - 作者给意见 → useChapterRewrite 消费 POST .../rewrite 的 SSE 流(复用 lib/stream/sse 帧解析),流式 live 展示新一版; - 每轮完成压入版本栈,「再改一版」以最新版为 prior_draft + 新意见迭代(多轮对话式); - 接受某版才「替换整章正文」落草稿(HITL,AI 绝不静默覆盖);停/错误/网络异常均归一处理。 四张 AI 结果卡(润色/续写/工具箱/整章重写)互斥。gen:api 重生成 TS 客户端。 门禁绿:tsc/lint/vitest 636(+7 useChapterRewrite)/build/coverage 95.36%。
This commit is contained in:
130
apps/web/components/workbench/ChapterRewritePanel.tsx
Normal file
130
apps/web/components/workbench/ChapterRewritePanel.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Check, RotateCcw, Square, X } from "lucide-react";
|
||||
|
||||
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
||||
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 { friendlyError } from "@/lib/errors/messages";
|
||||
import { useChapterRewrite } from "@/lib/workbench/useChapterRewrite";
|
||||
|
||||
interface ChapterRewritePanelProps {
|
||||
projectId: string;
|
||||
chapterNo: number;
|
||||
// 当前整章正文(首轮重写的 prior_draft)。
|
||||
currentDraft: string;
|
||||
// 接受某一版 → 替换整章正文(HITL,作者显式点选才落草稿)。
|
||||
onAccept: (text: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// 整章「再沟通 / 重写」结果卡(内联,非模态):作者给意见 → AI 就着当前整章 + 完整记忆注入
|
||||
// 流式重写一版 → 版本栈可多轮迭代 → 接受某版才替换整章正文。生成不写库(HITL,不变量 #3)。
|
||||
export function ChapterRewritePanel({
|
||||
projectId,
|
||||
chapterNo,
|
||||
currentDraft,
|
||||
onAccept,
|
||||
onClose,
|
||||
}: ChapterRewritePanelProps) {
|
||||
const rewrite = useChapterRewrite();
|
||||
const [feedback, setFeedback] = useState("");
|
||||
|
||||
const hasVersions = rewrite.versions.length > 0;
|
||||
const canSend = feedback.trim().length > 0 && !rewrite.isStreaming;
|
||||
// 下一轮以最新版正文为基础(无版本则用当前整章正文)迭代。
|
||||
const priorDraft = rewrite.latest?.text ?? currentDraft;
|
||||
const err = rewrite.state.phase === "error" ? rewrite.state.error : null;
|
||||
|
||||
const onSend = (): void => {
|
||||
if (!canSend) return;
|
||||
const fb = feedback.trim();
|
||||
setFeedback("");
|
||||
void rewrite.send(projectId, chapterNo, fb, priorDraft);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-h-[70vh] overflow-auto border-b border-line bg-panel px-4 py-3 sm:px-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<SectionHeader
|
||||
title="对整章再沟通 / 重写"
|
||||
description="给意见 → AI 就着当前整章 + 完整设定重写一版;不满意可再给意见迭代,满意再替换正文。"
|
||||
/>
|
||||
<Button onClick={onClose} variant="ghost" size="icon" aria-label="关闭整章重写">
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<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 段闪回」)"
|
||||
: "这一章哪里不满意?想怎么改?"
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{rewrite.isStreaming ? (
|
||||
<Button onClick={rewrite.stop} variant="danger" size="sm">
|
||||
<Square className="h-4 w-4" aria-hidden="true" />
|
||||
停
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={onSend} disabled={!canSend} variant="primary" size="sm">
|
||||
<RotateCcw className="h-4 w-4" aria-hidden="true" />
|
||||
{hasVersions ? "再改一版" : "重写整章"}
|
||||
</Button>
|
||||
)}
|
||||
{rewrite.isStreaming ? (
|
||||
<ThinkingIndicator label="重写中" className="text-xs text-cinnabar" />
|
||||
) : null}
|
||||
</div>
|
||||
{err ? (
|
||||
<StatusNote variant="danger" className="text-xs" title="整章重写失败">
|
||||
<p>{friendlyError(err.code, err.message).text}</p>
|
||||
</StatusNote>
|
||||
) : null}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user