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:
Yaojia Wang
2026-07-12 19:32:18 +02:00
parent 9bc0e1f2e6
commit 1afeb2cdb5
16 changed files with 227 additions and 28 deletions

View File

@@ -164,7 +164,81 @@ body {
}
}
/* AI 工作中·三点波动(比 animate-pulse 更明显:竖向弹跳 + 亮度,供 ThinkingIndicator 用)。 */
.ai-dot {
animation: ai-dot 1.2s ease-in-out infinite;
}
@keyframes ai-dot {
0%,
80%,
100% {
transform: translateY(0);
opacity: 0.4;
}
40% {
transform: translateY(-3px);
opacity: 1;
}
}
/* AI 流式/处理中·不确定进度条(细条来回扫,明确传达"正在工作")。 */
.ai-stream-track {
position: relative;
overflow: hidden;
background: var(--color-cinnabar-wash);
}
.ai-stream-track::after {
content: "";
position: absolute;
inset-block: 0;
left: -40%;
width: 40%;
border-radius: 9999px;
background: var(--color-cinnabar);
animation: ai-stream 1.4s ease-in-out infinite;
}
@keyframes ai-stream {
0% {
left: -40%;
width: 35%;
}
50% {
width: 55%;
}
100% {
left: 100%;
width: 35%;
}
}
/* AI 构思中·占位微光(正文区首 token 前的呼吸感,比骨架更"活")。 */
.ai-breathe {
animation: ai-breathe 1.8s ease-in-out infinite;
}
@keyframes ai-breathe {
0%,
100% {
opacity: 0.55;
}
50% {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.ai-dot {
animation: none;
opacity: 0.85;
}
.ai-stream-track::after {
animation: none;
left: 0;
width: 100%;
opacity: 0.35;
}
.ai-breathe {
animation: none;
}
.typewriter-cursor {
animation: none;
}

View File

@@ -17,12 +17,12 @@ export function ThinkingIndicator({
role="status"
aria-live="polite"
>
<span className="inline-flex gap-0.5" aria-hidden="true">
<span className="inline-flex gap-1" aria-hidden="true">
{[0, 1, 2].map((i) => (
<span
key={i}
className="h-1.5 w-1.5 rounded-full bg-current opacity-70 motion-safe:animate-pulse"
style={{ animationDelay: `${i * 200}ms` }}
className="ai-dot h-2 w-2 rounded-full bg-current"
style={{ animationDelay: `${i * 150}ms` }}
/>
))}
</span>

View File

@@ -171,8 +171,8 @@ export function CharacterGenerator({
<Button
onClick={() => void onGenerate()}
loading={generating}
disabled={
generating ||
brief.trim().length === 0 ||
(mode === "mix"
? mixTotal < MIN_CHARACTER_COUNT
@@ -181,7 +181,7 @@ export function CharacterGenerator({
variant="primary"
>
<Sparkles className="h-4 w-4" aria-hidden="true" />
{generating ? "生成中…" : "生成"}
</Button>
</div>

View File

@@ -66,11 +66,12 @@ export function WorldGenerator({ projectId }: WorldGeneratorProps) {
</Field>
<Button
onClick={() => void onGenerate()}
disabled={generating || brief.trim().length === 0}
loading={generating}
disabled={brief.trim().length === 0}
variant="primary"
>
<Sparkles className="h-4 w-4" aria-hidden="true" />
{generating ? "生成中…" : "生成世界观"}
</Button>
</div>

View File

@@ -254,9 +254,9 @@ export function GeneratorRunner({
onChange={(v) => setField(field.name, v)}
/>
))}
<Button type="submit" disabled={generating} variant="primary">
<Button type="submit" loading={generating} variant="primary">
<Sparkles className="h-4 w-4" aria-hidden="true" />
{generating ? "生成中…" : "生成"}
</Button>
{generating ? (
<ThinkingIndicator
@@ -297,13 +297,12 @@ export function GeneratorRunner({
{canIngest && gen.ingestStatus !== "conflict" ? (
<Button
onClick={() => void runIngest(false)}
disabled={ingesting || (!singleObject && selected.size === 0)}
loading={ingesting}
disabled={!singleObject && selected.size === 0}
variant="outline"
>
<Database className="h-4 w-4" aria-hidden="true" />
{ingesting
? "入库中…"
: singleObject
{singleObject
? `入库为规则至 ${table}`
: `入库选中(${selected.size})至 ${table}`}
</Button>

View File

@@ -5,11 +5,14 @@ import {
type ButtonSize,
type ButtonVariant,
} from "@/lib/ui/variants";
import { Spinner } from "./Spinner";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
children: ReactNode;
variant?: ButtonVariant;
size?: ButtonSize;
// 忙碌态:显示转圈 + 禁用 + aria-busy统一各处"处理中"的过程感。
loading?: boolean;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
@@ -19,6 +22,8 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
variant,
size,
type = "button",
loading = false,
disabled,
...props
},
ref,
@@ -28,8 +33,11 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
ref={ref}
type={type}
className={buttonClass({ variant, size, className })}
disabled={disabled || loading}
aria-busy={loading || undefined}
{...props}
>
{loading ? <Spinner className="h-4 w-4" /> : null}
{children}
</button>
);

View File

@@ -0,0 +1,49 @@
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { cn } from "@/lib/ui/variants";
interface GenerationSkeletonProps {
// 状态文案如「续写中」「AI 正在构思本章…」)。
label: string;
// 呼吸微光占位行数(默认 3
lines?: number;
// sm面板内小占位lg正文区大占位更粗行 + 稍大标签)。
size?: "sm" | "lg";
className?: string;
}
// 占位行宽度序列(错落感,示意即将落笔的段落)。
const LINE_WIDTHS = ["90%", "78%", "84%", "68%", "72%"];
// AI 同步/构思等待占位:三点波动标签 + `ai-breathe` 呼吸微光占位行,
// 比单三点更明显地传达「正在生成」。占位行 aria-hiddenrole=status/aria-live 由
// ThinkingIndicator 提供读屏可知在生成。ai-breathe 自带 reduced-motion 回退。
export function GenerationSkeleton({
label,
lines = 3,
size = "sm",
className,
}: GenerationSkeletonProps) {
return (
<div className={cn("space-y-2.5", className)}>
<ThinkingIndicator
label={label}
className={cn("text-cinnabar", size === "lg" ? "text-sm" : "text-xs")}
/>
<div className="space-y-2" aria-hidden="true">
{Array.from({ length: lines }).map((_, i) => (
<div
key={i}
className={cn(
"ai-breathe rounded bg-line/60",
size === "lg" ? "h-3.5" : "h-3",
)}
style={{
width: LINE_WIDTHS[i % LINE_WIDTHS.length],
animationDelay: `${i * 160}ms`,
}}
/>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,18 @@
import { cn } from "@/lib/ui/variants";
interface SpinnerProps {
className?: string;
}
// 转圈忙碌指示环形顶部留缺口motion-safe 旋转reduced-motion 下为静止环。
export function Spinner({ className }: SpinnerProps) {
return (
<span
aria-hidden="true"
className={cn(
"inline-block h-4 w-4 shrink-0 rounded-full border-2 border-current border-t-transparent motion-safe:animate-spin",
className,
)}
/>
);
}

View File

@@ -0,0 +1,18 @@
import { cn } from "@/lib/ui/variants";
interface StreamingBarProps {
label?: string;
className?: string;
}
// AI 流式/处理中·不确定进度条:细朱砂条来回扫,明确传达"正在工作"。
// 显隐由调用方控制只在生成时挂载reduced-motion 下为静态朱砂条(见 globals.css
export function StreamingBar({ label = "AI 生成中", className }: StreamingBarProps) {
return (
<div
role="progressbar"
aria-label={label}
className={cn("ai-stream-track h-0.5 w-full rounded-full", className)}
/>
);
}

View File

@@ -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

View File

@@ -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"
>

View File

@@ -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}

View File

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

View File

@@ -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"
>

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

View File

@@ -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>