feat(ui): 强化 AI 工作中动效——构思占位/流式进度条/波动三点/转圈
- 动效基元:ai-dot 波动(三点竖跳)、ai-stream 不确定进度条、ai-breathe 呼吸微光(均带 reduced-motion 回退) - ThinkingIndicator 升级为明显波动;新增 Spinner / StreamingBar / GenerationSkeleton 原子件;Button 加 loading 态 - 写作台:首 token 前正文区『AI 正在构思本章…』占位(补最大缺口,不再空白像卡死)+ 流式顶部进度条 - 整章重写流式进度条;续写/润色忙碌骨架 + 触发键转圈;生成器/角色/世界观改用 Button loading
This commit is contained in:
@@ -7,6 +7,7 @@ import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { SectionHeader } from "@/components/ui/SectionHeader";
|
||||
import { StatusNote } from "@/components/ui/StatusNote";
|
||||
import { StreamingBar } from "@/components/ui/StreamingBar";
|
||||
import { TextArea } from "@/components/ui/TextArea";
|
||||
import type { AiMessageView } from "@/lib/api/types";
|
||||
import { friendlyError } from "@/lib/errors/messages";
|
||||
@@ -198,6 +199,11 @@ export function ChapterRewritePanel({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 重写流式期间:气泡区顶部挂一条不确定进度条,明确「进行中」(生成结束卸载)。 */}
|
||||
{rewrite.isStreaming ? (
|
||||
<StreamingBar label="重写生成中" className="mt-3" />
|
||||
) : null}
|
||||
|
||||
{/* 内联对话气泡流:本章 rewrite 往复(已落库)+ 本轮进行中气泡(流式逐字),作者右 / AI 左,滚动区。 */}
|
||||
{flatCount > 0 || status === "error" ? (
|
||||
<div
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { Check, Plus, X } from "lucide-react";
|
||||
|
||||
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
||||
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";
|
||||
@@ -72,12 +72,6 @@ export function ContinuePanel({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{candidates.length === 0 && busy ? (
|
||||
<div className="mt-3">
|
||||
<ThinkingIndicator label="续写中" className="text-xs text-cinnabar" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{candidates.length === 0 && status === "error" ? (
|
||||
<StatusNote variant="danger" className="mt-3 text-xs" title="续写失败">
|
||||
<button
|
||||
@@ -112,10 +106,13 @@ export function ContinuePanel({
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* 续写为同步请求(无逐字流):生成期间放一块呼吸微光骨架占位,比单三点更明显地示意正在生成。 */}
|
||||
{busy ? <GenerationSkeleton label="续写中" className="mt-3" /> : null}
|
||||
|
||||
<div className="mt-3">
|
||||
<Button
|
||||
onClick={() => void runGenerate()}
|
||||
disabled={busy}
|
||||
loading={busy}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { GenerationSkeleton } from "@/components/ui/GenerationSkeleton";
|
||||
import { cn } from "@/lib/ui/variants";
|
||||
import type { AiMessageView } from "@/lib/api/types";
|
||||
import { bubbleSide, type AcceptAction } from "@/lib/workbench/aiConversation";
|
||||
@@ -73,7 +73,8 @@ function Bubble({ message, action, onAccept }: BubbleProps) {
|
||||
)}
|
||||
>
|
||||
{showThinking ? (
|
||||
<ThinkingIndicator label="生成中" className="text-sm text-cinnabar" />
|
||||
// 空正文的进行中 ai 气泡(如同步 refine 的整段等待):呼吸微光骨架,比单三点更明显。
|
||||
<GenerationSkeleton label="生成中" lines={2} className="min-w-[8rem]" />
|
||||
) : (
|
||||
<p className="max-h-56 overflow-y-auto whitespace-pre-wrap text-sm text-ink">
|
||||
{message.content}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { cn, focusRing, proseBody } from "@/lib/ui/variants";
|
||||
import { ThinkingPlaceholder } from "./ThinkingPlaceholder";
|
||||
|
||||
export interface EditorSelection {
|
||||
start: number;
|
||||
@@ -14,6 +15,9 @@ interface EditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
streaming: boolean;
|
||||
// 构思中(流式已开始但首 token 未到):在正文区覆盖「AI 正在构思本章…」占位,
|
||||
// 首个 token 到达即消失换成正文。纯展示层,不触碰流式逻辑。
|
||||
isThinking?: boolean;
|
||||
// 选区变化上报(供「润色选段」等选区级 AI 动作取材)。空选区也会上报(start===end)。
|
||||
onSelectionChange?: (selection: EditorSelection) => void;
|
||||
}
|
||||
@@ -24,6 +28,7 @@ export function Editor({
|
||||
value,
|
||||
onChange,
|
||||
streaming,
|
||||
isThinking = false,
|
||||
onSelectionChange,
|
||||
}: EditorProps) {
|
||||
const ref = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -46,7 +51,7 @@ export function Editor({
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-prose">
|
||||
<div className="relative mx-auto max-w-prose">
|
||||
<label htmlFor="chapter-editor" className="sr-only">
|
||||
正文编辑器
|
||||
</label>
|
||||
@@ -65,12 +70,13 @@ export function Editor({
|
||||
focusRing,
|
||||
)}
|
||||
/>
|
||||
{streaming ? (
|
||||
{streaming && !isThinking ? (
|
||||
<span
|
||||
className="typewriter-cursor ml-0.5 inline-block h-[1.2em] w-[2px] translate-y-1 bg-cinnabar align-middle"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
{isThinking ? <ThinkingPlaceholder /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -283,7 +283,8 @@ export function RefinePanel({
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
onClick={() => void onRecommunicate(false)}
|
||||
disabled={busy || clarifying || instruction.trim().length === 0}
|
||||
loading={busy}
|
||||
disabled={clarifying || instruction.trim().length === 0}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
|
||||
13
apps/web/components/workbench/ThinkingPlaceholder.tsx
Normal file
13
apps/web/components/workbench/ThinkingPlaceholder.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { GenerationSkeleton } from "@/components/ui/GenerationSkeleton";
|
||||
|
||||
// 正文区「AI 正在构思本章」占位(补首 token 前的最大缺口——长首字延迟不再像卡死)。
|
||||
// 覆盖编辑区(absolute inset-0 + bg-bg 遮住空/陈旧正文),三点波动 + 呼吸微光占位行示意即将落笔;
|
||||
// 首个 token 到达(stream 有正文)即由 Editor 卸载换成正文。role=status/aria-live 由内部
|
||||
// ThinkingIndicator 提供,读屏可知在生成。放在 Editor 的 relative 容器内。
|
||||
export function ThinkingPlaceholder() {
|
||||
return (
|
||||
<div className="absolute inset-0 z-10 rounded bg-bg pt-1">
|
||||
<GenerationSkeleton label="AI 正在构思本章…" size="lg" lines={5} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { AppShell } from "@/components/AppShell";
|
||||
import { Drawer } from "@/components/Drawer";
|
||||
import { useToast } from "@/components/Toast";
|
||||
import { StatusNote } from "@/components/ui/StatusNote";
|
||||
import { StreamingBar } from "@/components/ui/StreamingBar";
|
||||
import type { ProjectResponse } from "@/lib/api/types";
|
||||
import { friendlyError } from "@/lib/errors/messages";
|
||||
import { useAutosave } from "@/lib/autosave/useAutosave";
|
||||
@@ -90,6 +91,8 @@ export function Workbench({
|
||||
const toast = useToast();
|
||||
const autosave = useAutosave(project.id, chapterNo, initialText);
|
||||
const stream = useDraftStream();
|
||||
// 构思中 = 流式已开始但首 token 未到(正文区覆盖构思占位);有字后即为在吐字。
|
||||
const isThinking = stream.isStreaming && stream.state.text.length === 0;
|
||||
const lastStreamText = useRef("");
|
||||
const canRefine = selection !== null && selection.text.trim().length > 0;
|
||||
const chapterTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -382,6 +385,10 @@ export function Workbench({
|
||||
onClose={() => setRewriteOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
{/* 写本章流式期间:编辑区顶部常挂一条不确定进度条,明确「进行中」(生成结束卸载)。 */}
|
||||
{stream.isStreaming ? (
|
||||
<StreamingBar label="AI 正在写本章" className="shrink-0" />
|
||||
) : null}
|
||||
<div ref={editorScrollRef} className="flex-1 overflow-auto px-6 py-8">
|
||||
{chapters.length === 0 ? (
|
||||
<StatusNote variant="info" className="mb-4">
|
||||
@@ -392,6 +399,7 @@ export function Workbench({
|
||||
value={text}
|
||||
onChange={onEditorChange}
|
||||
streaming={stream.isStreaming}
|
||||
isThinking={isThinking}
|
||||
onSelectionChange={setSelection}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user