Files
writer-work-flow/apps/web/components/workbench/ContinuePanel.tsx
Yaojia Wang 1afeb2cdb5 feat(ui): 强化 AI 工作中动效——构思占位/流式进度条/波动三点/转圈
- 动效基元:ai-dot 波动(三点竖跳)、ai-stream 不确定进度条、ai-breathe 呼吸微光(均带 reduced-motion 回退)
- ThinkingIndicator 升级为明显波动;新增 Spinner / StreamingBar / GenerationSkeleton 原子件;Button 加 loading 态
- 写作台:首 token 前正文区『AI 正在构思本章…』占位(补最大缺口,不再空白像卡死)+ 流式顶部进度条
- 整章重写流式进度条;续写/润色忙碌骨架 + 触发键转圈;生成器/角色/世界观改用 Button loading
2026-07-12 19:32:18 +02:00

126 lines
4.3 KiB
TypeScript
Raw Permalink 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 { 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-onlymeta 存候选序号。
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>
);
}