Files
writer-work-flow/apps/web/components/workbench/ChapterRewritePanel.tsx
Yaojia Wang 542c2abac9 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%。
2026-07-08 07:12:04 +02:00

131 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}