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:
@@ -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) {
|
@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 {
|
.typewriter-cursor {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,12 +17,12 @@ export function ThinkingIndicator({
|
|||||||
role="status"
|
role="status"
|
||||||
aria-live="polite"
|
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) => (
|
{[0, 1, 2].map((i) => (
|
||||||
<span
|
<span
|
||||||
key={i}
|
key={i}
|
||||||
className="h-1.5 w-1.5 rounded-full bg-current opacity-70 motion-safe:animate-pulse"
|
className="ai-dot h-2 w-2 rounded-full bg-current"
|
||||||
style={{ animationDelay: `${i * 200}ms` }}
|
style={{ animationDelay: `${i * 150}ms` }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -171,8 +171,8 @@ export function CharacterGenerator({
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={() => void onGenerate()}
|
onClick={() => void onGenerate()}
|
||||||
|
loading={generating}
|
||||||
disabled={
|
disabled={
|
||||||
generating ||
|
|
||||||
brief.trim().length === 0 ||
|
brief.trim().length === 0 ||
|
||||||
(mode === "mix"
|
(mode === "mix"
|
||||||
? mixTotal < MIN_CHARACTER_COUNT
|
? mixTotal < MIN_CHARACTER_COUNT
|
||||||
@@ -181,7 +181,7 @@ export function CharacterGenerator({
|
|||||||
variant="primary"
|
variant="primary"
|
||||||
>
|
>
|
||||||
<Sparkles className="h-4 w-4" aria-hidden="true" />
|
<Sparkles className="h-4 w-4" aria-hidden="true" />
|
||||||
{generating ? "生成中…" : "生成"}
|
生成
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -66,11 +66,12 @@ export function WorldGenerator({ projectId }: WorldGeneratorProps) {
|
|||||||
</Field>
|
</Field>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => void onGenerate()}
|
onClick={() => void onGenerate()}
|
||||||
disabled={generating || brief.trim().length === 0}
|
loading={generating}
|
||||||
|
disabled={brief.trim().length === 0}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
>
|
>
|
||||||
<Sparkles className="h-4 w-4" aria-hidden="true" />
|
<Sparkles className="h-4 w-4" aria-hidden="true" />
|
||||||
{generating ? "生成中…" : "生成世界观"}
|
生成世界观
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -254,9 +254,9 @@ export function GeneratorRunner({
|
|||||||
onChange={(v) => setField(field.name, v)}
|
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" />
|
<Sparkles className="h-4 w-4" aria-hidden="true" />
|
||||||
{generating ? "生成中…" : "生成"}
|
生成
|
||||||
</Button>
|
</Button>
|
||||||
{generating ? (
|
{generating ? (
|
||||||
<ThinkingIndicator
|
<ThinkingIndicator
|
||||||
@@ -297,15 +297,14 @@ export function GeneratorRunner({
|
|||||||
{canIngest && gen.ingestStatus !== "conflict" ? (
|
{canIngest && gen.ingestStatus !== "conflict" ? (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => void runIngest(false)}
|
onClick={() => void runIngest(false)}
|
||||||
disabled={ingesting || (!singleObject && selected.size === 0)}
|
loading={ingesting}
|
||||||
|
disabled={!singleObject && selected.size === 0}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
>
|
>
|
||||||
<Database className="h-4 w-4" aria-hidden="true" />
|
<Database className="h-4 w-4" aria-hidden="true" />
|
||||||
{ingesting
|
{singleObject
|
||||||
? "入库中…"
|
? `入库为规则至 ${table}`
|
||||||
: singleObject
|
: `入库选中(${selected.size})至 ${table}`}
|
||||||
? `入库为规则至 ${table}`
|
|
||||||
: `入库选中(${selected.size})至 ${table}`}
|
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,14 @@ import {
|
|||||||
type ButtonSize,
|
type ButtonSize,
|
||||||
type ButtonVariant,
|
type ButtonVariant,
|
||||||
} from "@/lib/ui/variants";
|
} from "@/lib/ui/variants";
|
||||||
|
import { Spinner } from "./Spinner";
|
||||||
|
|
||||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
variant?: ButtonVariant;
|
variant?: ButtonVariant;
|
||||||
size?: ButtonSize;
|
size?: ButtonSize;
|
||||||
|
// 忙碌态:显示转圈 + 禁用 + aria-busy,统一各处"处理中"的过程感。
|
||||||
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||||
@@ -19,6 +22,8 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
|||||||
variant,
|
variant,
|
||||||
size,
|
size,
|
||||||
type = "button",
|
type = "button",
|
||||||
|
loading = false,
|
||||||
|
disabled,
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
@@ -28,8 +33,11 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
type={type}
|
type={type}
|
||||||
className={buttonClass({ variant, size, className })}
|
className={buttonClass({ variant, size, className })}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
aria-busy={loading || undefined}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
{loading ? <Spinner className="h-4 w-4" /> : null}
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
49
apps/web/components/ui/GenerationSkeleton.tsx
Normal file
49
apps/web/components/ui/GenerationSkeleton.tsx
Normal 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-hidden;role=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>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
apps/web/components/ui/Spinner.tsx
Normal file
18
apps/web/components/ui/Spinner.tsx
Normal 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,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
apps/web/components/ui/StreamingBar.tsx
Normal file
18
apps/web/components/ui/StreamingBar.tsx
Normal 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)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
|||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { SectionHeader } from "@/components/ui/SectionHeader";
|
import { SectionHeader } from "@/components/ui/SectionHeader";
|
||||||
import { StatusNote } from "@/components/ui/StatusNote";
|
import { StatusNote } from "@/components/ui/StatusNote";
|
||||||
|
import { StreamingBar } from "@/components/ui/StreamingBar";
|
||||||
import { TextArea } from "@/components/ui/TextArea";
|
import { TextArea } from "@/components/ui/TextArea";
|
||||||
import type { AiMessageView } from "@/lib/api/types";
|
import type { AiMessageView } from "@/lib/api/types";
|
||||||
import { friendlyError } from "@/lib/errors/messages";
|
import { friendlyError } from "@/lib/errors/messages";
|
||||||
@@ -198,6 +199,11 @@ export function ChapterRewritePanel({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 重写流式期间:气泡区顶部挂一条不确定进度条,明确「进行中」(生成结束卸载)。 */}
|
||||||
|
{rewrite.isStreaming ? (
|
||||||
|
<StreamingBar label="重写生成中" className="mt-3" />
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* 内联对话气泡流:本章 rewrite 往复(已落库)+ 本轮进行中气泡(流式逐字),作者右 / AI 左,滚动区。 */}
|
{/* 内联对话气泡流:本章 rewrite 往复(已落库)+ 本轮进行中气泡(流式逐字),作者右 / AI 左,滚动区。 */}
|
||||||
{flatCount > 0 || status === "error" ? (
|
{flatCount > 0 || status === "error" ? (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
import { useCallback, useEffect, useRef } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
import { Check, Plus, X } from "lucide-react";
|
import { Check, Plus, X } from "lucide-react";
|
||||||
|
|
||||||
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { GenerationSkeleton } from "@/components/ui/GenerationSkeleton";
|
||||||
import { SectionHeader } from "@/components/ui/SectionHeader";
|
import { SectionHeader } from "@/components/ui/SectionHeader";
|
||||||
import { StatusNote } from "@/components/ui/StatusNote";
|
import { StatusNote } from "@/components/ui/StatusNote";
|
||||||
import { useContinue } from "@/lib/workbench/useContinue";
|
import { useContinue } from "@/lib/workbench/useContinue";
|
||||||
@@ -72,12 +72,6 @@ export function ContinuePanel({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{candidates.length === 0 && busy ? (
|
|
||||||
<div className="mt-3">
|
|
||||||
<ThinkingIndicator label="续写中" className="text-xs text-cinnabar" />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{candidates.length === 0 && status === "error" ? (
|
{candidates.length === 0 && status === "error" ? (
|
||||||
<StatusNote variant="danger" className="mt-3 text-xs" title="续写失败">
|
<StatusNote variant="danger" className="mt-3 text-xs" title="续写失败">
|
||||||
<button
|
<button
|
||||||
@@ -112,10 +106,13 @@ export function ContinuePanel({
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
{/* 续写为同步请求(无逐字流):生成期间放一块呼吸微光骨架占位,比单三点更明显地示意正在生成。 */}
|
||||||
|
{busy ? <GenerationSkeleton label="续写中" className="mt-3" /> : null}
|
||||||
|
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => void runGenerate()}
|
onClick={() => void runGenerate()}
|
||||||
disabled={busy}
|
loading={busy}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { GenerationSkeleton } from "@/components/ui/GenerationSkeleton";
|
||||||
import { cn } from "@/lib/ui/variants";
|
import { cn } from "@/lib/ui/variants";
|
||||||
import type { AiMessageView } from "@/lib/api/types";
|
import type { AiMessageView } from "@/lib/api/types";
|
||||||
import { bubbleSide, type AcceptAction } from "@/lib/workbench/aiConversation";
|
import { bubbleSide, type AcceptAction } from "@/lib/workbench/aiConversation";
|
||||||
@@ -73,7 +73,8 @@ function Bubble({ message, action, onAccept }: BubbleProps) {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{showThinking ? (
|
{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">
|
<p className="max-h-56 overflow-y-auto whitespace-pre-wrap text-sm text-ink">
|
||||||
{message.content}
|
{message.content}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
import { cn, focusRing, proseBody } from "@/lib/ui/variants";
|
import { cn, focusRing, proseBody } from "@/lib/ui/variants";
|
||||||
|
import { ThinkingPlaceholder } from "./ThinkingPlaceholder";
|
||||||
|
|
||||||
export interface EditorSelection {
|
export interface EditorSelection {
|
||||||
start: number;
|
start: number;
|
||||||
@@ -14,6 +15,9 @@ interface EditorProps {
|
|||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
streaming: boolean;
|
streaming: boolean;
|
||||||
|
// 构思中(流式已开始但首 token 未到):在正文区覆盖「AI 正在构思本章…」占位,
|
||||||
|
// 首个 token 到达即消失换成正文。纯展示层,不触碰流式逻辑。
|
||||||
|
isThinking?: boolean;
|
||||||
// 选区变化上报(供「润色选段」等选区级 AI 动作取材)。空选区也会上报(start===end)。
|
// 选区变化上报(供「润色选段」等选区级 AI 动作取材)。空选区也会上报(start===end)。
|
||||||
onSelectionChange?: (selection: EditorSelection) => void;
|
onSelectionChange?: (selection: EditorSelection) => void;
|
||||||
}
|
}
|
||||||
@@ -24,6 +28,7 @@ export function Editor({
|
|||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
streaming,
|
streaming,
|
||||||
|
isThinking = false,
|
||||||
onSelectionChange,
|
onSelectionChange,
|
||||||
}: EditorProps) {
|
}: EditorProps) {
|
||||||
const ref = useRef<HTMLTextAreaElement>(null);
|
const ref = useRef<HTMLTextAreaElement>(null);
|
||||||
@@ -46,7 +51,7 @@ export function Editor({
|
|||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-prose">
|
<div className="relative mx-auto max-w-prose">
|
||||||
<label htmlFor="chapter-editor" className="sr-only">
|
<label htmlFor="chapter-editor" className="sr-only">
|
||||||
正文编辑器
|
正文编辑器
|
||||||
</label>
|
</label>
|
||||||
@@ -65,12 +70,13 @@ export function Editor({
|
|||||||
focusRing,
|
focusRing,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{streaming ? (
|
{streaming && !isThinking ? (
|
||||||
<span
|
<span
|
||||||
className="typewriter-cursor ml-0.5 inline-block h-[1.2em] w-[2px] translate-y-1 bg-cinnabar align-middle"
|
className="typewriter-cursor ml-0.5 inline-block h-[1.2em] w-[2px] translate-y-1 bg-cinnabar align-middle"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{isThinking ? <ThinkingPlaceholder /> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -283,7 +283,8 @@ export function RefinePanel({
|
|||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => void onRecommunicate(false)}
|
onClick={() => void onRecommunicate(false)}
|
||||||
disabled={busy || clarifying || instruction.trim().length === 0}
|
loading={busy}
|
||||||
|
disabled={clarifying || instruction.trim().length === 0}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
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 { Drawer } from "@/components/Drawer";
|
||||||
import { useToast } from "@/components/Toast";
|
import { useToast } from "@/components/Toast";
|
||||||
import { StatusNote } from "@/components/ui/StatusNote";
|
import { StatusNote } from "@/components/ui/StatusNote";
|
||||||
|
import { StreamingBar } from "@/components/ui/StreamingBar";
|
||||||
import type { ProjectResponse } from "@/lib/api/types";
|
import type { ProjectResponse } from "@/lib/api/types";
|
||||||
import { friendlyError } from "@/lib/errors/messages";
|
import { friendlyError } from "@/lib/errors/messages";
|
||||||
import { useAutosave } from "@/lib/autosave/useAutosave";
|
import { useAutosave } from "@/lib/autosave/useAutosave";
|
||||||
@@ -90,6 +91,8 @@ export function Workbench({
|
|||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const autosave = useAutosave(project.id, chapterNo, initialText);
|
const autosave = useAutosave(project.id, chapterNo, initialText);
|
||||||
const stream = useDraftStream();
|
const stream = useDraftStream();
|
||||||
|
// 构思中 = 流式已开始但首 token 未到(正文区覆盖构思占位);有字后即为在吐字。
|
||||||
|
const isThinking = stream.isStreaming && stream.state.text.length === 0;
|
||||||
const lastStreamText = useRef("");
|
const lastStreamText = useRef("");
|
||||||
const canRefine = selection !== null && selection.text.trim().length > 0;
|
const canRefine = selection !== null && selection.text.trim().length > 0;
|
||||||
const chapterTriggerRef = useRef<HTMLButtonElement>(null);
|
const chapterTriggerRef = useRef<HTMLButtonElement>(null);
|
||||||
@@ -382,6 +385,10 @@ export function Workbench({
|
|||||||
onClose={() => setRewriteOpen(false)}
|
onClose={() => setRewriteOpen(false)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{/* 写本章流式期间:编辑区顶部常挂一条不确定进度条,明确「进行中」(生成结束卸载)。 */}
|
||||||
|
{stream.isStreaming ? (
|
||||||
|
<StreamingBar label="AI 正在写本章" className="shrink-0" />
|
||||||
|
) : null}
|
||||||
<div ref={editorScrollRef} className="flex-1 overflow-auto px-6 py-8">
|
<div ref={editorScrollRef} className="flex-1 overflow-auto px-6 py-8">
|
||||||
{chapters.length === 0 ? (
|
{chapters.length === 0 ? (
|
||||||
<StatusNote variant="info" className="mb-4">
|
<StatusNote variant="info" className="mb-4">
|
||||||
@@ -392,6 +399,7 @@ export function Workbench({
|
|||||||
value={text}
|
value={text}
|
||||||
onChange={onEditorChange}
|
onChange={onEditorChange}
|
||||||
streaming={stream.isStreaming}
|
streaming={stream.isStreaming}
|
||||||
|
isThinking={isThinking}
|
||||||
onSelectionChange={setSelection}
|
onSelectionChange={setSelection}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user