- 动效基元:ai-dot 波动(三点竖跳)、ai-stream 不确定进度条、ai-breathe 呼吸微光(均带 reduced-motion 回退) - ThinkingIndicator 升级为明显波动;新增 Spinner / StreamingBar / GenerationSkeleton 原子件;Button 加 loading 态 - 写作台:首 token 前正文区『AI 正在构思本章…』占位(补最大缺口,不再空白像卡死)+ 流式顶部进度条 - 整章重写流式进度条;续写/润色忙碌骨架 + 触发键转圈;生成器/角色/世界观改用 Button loading
126 lines
4.3 KiB
TypeScript
126 lines
4.3 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useRef } from "react";
|
||
import { Check, Plus, X } from "lucide-react";
|
||
|
||
import { Button } from "@/components/ui/Button";
|
||
import { GenerationSkeleton } from "@/components/ui/GenerationSkeleton";
|
||
import { SectionHeader } from "@/components/ui/SectionHeader";
|
||
import { StatusNote } from "@/components/ui/StatusNote";
|
||
import { useContinue } from "@/lib/workbench/useContinue";
|
||
import type { AiTurnInput } from "@/lib/workbench/useAiConversation";
|
||
|
||
interface ContinuePanelProps {
|
||
projectId: string;
|
||
chapterNo: number;
|
||
// 插入选中候选到正文(追加到章末)。
|
||
onInsert: (text: string) => void;
|
||
// 留痕:每条候选记一条 ai 气泡(续写无作者输入,故无 author 气泡)。
|
||
appendMessages: (turn: AiTurnInput) => void;
|
||
onClose: () => void;
|
||
}
|
||
|
||
// 续写结果卡(内联,非模态):打开即读该章前文续写一条候选;可「再来一个」累加多候选,
|
||
// 作者点某条「插入正文」才落回草稿(HITL,不静默写入)。
|
||
export function ContinuePanel({
|
||
projectId,
|
||
chapterNo,
|
||
onInsert,
|
||
appendMessages,
|
||
onClose,
|
||
}: ContinuePanelProps) {
|
||
const { status, candidates, generate } = useContinue();
|
||
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(() => {
|
||
if (ranRef.current) return;
|
||
ranRef.current = true;
|
||
void runGenerate();
|
||
}, [runGenerate]);
|
||
|
||
const busy = status === "generating";
|
||
|
||
return (
|
||
<div className="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="承接本章前文续写;点「再来一个」多生成几版,选中意的加到章末。"
|
||
/>
|
||
<Button onClick={onClose} variant="ghost" size="icon" aria-label="关闭续写">
|
||
<X className="h-4 w-4" aria-hidden="true" />
|
||
</Button>
|
||
</div>
|
||
|
||
{candidates.length === 0 && status === "error" ? (
|
||
<StatusNote variant="danger" className="mt-3 text-xs" title="续写失败">
|
||
<button
|
||
type="button"
|
||
onClick={() => void runGenerate()}
|
||
className="mt-1 inline-flex items-center gap-1 text-cinnabar hover:underline"
|
||
>
|
||
重试
|
||
</button>
|
||
</StatusNote>
|
||
) : null}
|
||
|
||
<ul className="mt-3 space-y-2">
|
||
{candidates.map((text, i) => (
|
||
<li
|
||
key={i}
|
||
className="rounded border border-line bg-bg p-3"
|
||
>
|
||
<div className="mb-1 flex items-center justify-between gap-2">
|
||
<span className="text-2xs text-ink-soft">候选 {i + 1}</span>
|
||
<Button
|
||
onClick={() => onInsert(text)}
|
||
variant="primary"
|
||
size="sm"
|
||
>
|
||
<Check className="h-4 w-4" aria-hidden="true" />
|
||
采用(加到章末)
|
||
</Button>
|
||
</div>
|
||
<p className="whitespace-pre-wrap text-sm text-ink">{text}</p>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
|
||
{/* 续写为同步请求(无逐字流):生成期间放一块呼吸微光骨架占位,比单三点更明显地示意正在生成。 */}
|
||
{busy ? <GenerationSkeleton label="续写中" className="mt-3" /> : null}
|
||
|
||
<div className="mt-3">
|
||
<Button
|
||
onClick={() => void runGenerate()}
|
||
loading={busy}
|
||
variant="secondary"
|
||
size="sm"
|
||
>
|
||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||
再来一个候选
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|