Files
writer-work-flow/apps/web/components/generation/CharacterGenerator.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

241 lines
8.1 KiB
TypeScript
Raw 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, useMemo, useState } from "react";
import { Database, Sparkles } from "lucide-react";
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { Button } from "@/components/ui/Button";
import { Field } from "@/components/ui/Field";
import { SectionHeader } from "@/components/ui/SectionHeader";
import { SegmentedControl } from "@/components/ui/SegmentedControl";
import { StatusNote } from "@/components/ui/StatusNote";
import { TextInput } from "@/components/ui/TextInput";
import type { CharacterCardView } from "@/lib/api/types";
import {
clampCharacterCount,
MAX_CHARACTER_COUNT,
MIN_CHARACTER_COUNT,
roleMixTotal,
sanitizeRoleMix,
updateCard,
} from "@/lib/generation/cards";
import { useCharacterGen } from "@/lib/generation/useCharacterGen";
import { CharacterCardItem } from "./CharacterCardItem";
import { ConflictAdjudication } from "./ConflictAdjudication";
import { RoleMixEditor, type RoleMixRow } from "./RoleMixEditor";
type GenMode = "single" | "mix";
const MODE_OPTIONS: Array<{ value: GenMode; label: string }> = [
{ value: "single", label: "单一定位" },
{ value: "mix", label: "按定位配比" },
];
const DEFAULT_MIX_ROWS: RoleMixRow[] = [
{ role: "主角", count: 1 },
{ role: "对手", count: 1 },
];
interface CharacterGeneratorProps {
projectId: string;
// 入库成功后回调(如刷新 Codex 本地列表)。
onIngested?: (created: string[], cards: CharacterCardView[]) => void;
}
// 角色生成器UX §6.6):一句话需求 + 数量 + 定位 → 预览卡 → 选/改 → 入库(处理 409 裁决)。
export function CharacterGenerator({
projectId,
onIngested,
}: CharacterGeneratorProps) {
const gen = useCharacterGen();
const [brief, setBrief] = useState("");
const [count, setCount] = useState(MIN_CHARACTER_COUNT);
const [role, setRole] = useState("");
const [mode, setMode] = useState<GenMode>("single");
const [mixRows, setMixRows] = useState<RoleMixRow[]>(DEFAULT_MIX_ROWS);
// 配比模式下的有效合计sanitize 后,已按 MAX 截断)——门控生成按钮。
const mixTotal = roleMixTotal(sanitizeRoleMix(mixRows));
// 编辑态卡片(预览返回后拷贝进本地,允许就地改)。
const [drafts, setDrafts] = useState<CharacterCardView[]>([]);
const [selected, setSelected] = useState<Set<number>>(new Set());
// 预览返回 → 同步进本地编辑态 + 默认全选。
const syncDrafts = useCallback((cards: CharacterCardView[]) => {
setDrafts(cards);
setSelected(new Set(cards.map((_, i) => i)));
}, []);
const onGenerate = useCallback(async () => {
if (mode === "mix") {
await gen.generate({
projectId,
brief,
count,
roleMix: sanitizeRoleMix(mixRows),
});
return;
}
await gen.generate({ projectId, brief, count, role });
}, [gen, projectId, brief, count, role, mode, mixRows]);
// gen.cards 变化(新预览)→ 重新同步本地草稿。
const previewKey = gen.cards.map((c) => c.name).join("|");
useEffect(() => {
if (gen.genStatus === "preview") syncDrafts(gen.cards);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [previewKey, gen.genStatus]);
const toggle = (i: number): void => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(i)) next.delete(i);
else next.add(i);
return next;
});
};
const selectedCards = useMemo(
() => drafts.filter((_, i) => selected.has(i)),
[drafts, selected],
);
const doIngest = useCallback(
async (acknowledge: boolean) => {
const ok = await gen.ingest(projectId, selectedCards, acknowledge);
if (ok) {
onIngested?.(gen.created, selectedCards);
setDrafts([]);
setSelected(new Set());
}
},
[gen, projectId, selectedCards, onIngested],
);
const generating = gen.genStatus === "generating";
const ingesting = gen.ingestStatus === "ingesting";
return (
<section className="flex flex-col gap-4" aria-label="角色生成器">
<div className="rounded border border-line bg-panel p-4">
<SectionHeader title="角色生成器" className="mb-3" />
<Field label="一句话需求" className="mb-3">
<TextInput
value={brief}
onChange={(e) => setBrief(e.target.value)}
placeholder="如:一个亦正亦邪的剑修,背负灭门血仇"
/>
</Field>
<div className="mb-3">
<SegmentedControl
options={MODE_OPTIONS}
value={mode}
onChange={setMode}
ariaLabel="生成模式"
/>
</div>
{mode === "mix" ? (
<Field
label="角色定位配比"
className="mb-3"
help="每个定位产出对应数量,合计即总张数"
>
<RoleMixEditor rows={mixRows} onChange={setMixRows} />
</Field>
) : (
<div className="mb-3 flex flex-wrap items-end gap-3">
<Field
label="数量"
className="w-28"
help={`${MIN_CHARACTER_COUNT}${MAX_CHARACTER_COUNT}`}
>
<TextInput
type="number"
min={MIN_CHARACTER_COUNT}
max={MAX_CHARACTER_COUNT}
value={count}
onChange={(e) =>
setCount(clampCharacterCount(Number(e.target.value)))
}
/>
</Field>
<Field label="定位(可选)" className="min-w-[12rem] flex-1">
<TextInput
value={role}
onChange={(e) => setRole(e.target.value)}
placeholder="主角 / CP / 对手 / 导师 / 工具人"
/>
</Field>
</div>
)}
<Button
onClick={() => void onGenerate()}
loading={generating}
disabled={
brief.trim().length === 0 ||
(mode === "mix"
? mixTotal < MIN_CHARACTER_COUNT
: count < MIN_CHARACTER_COUNT || count > MAX_CHARACTER_COUNT)
}
variant="primary"
>
<Sparkles className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
{generating ? (
<div className="rounded border border-line bg-panel/50 p-4 text-cinnabar">
<ThinkingIndicator label="生成中" />
</div>
) : null}
{gen.genStatus === "preview" && drafts.length > 0 ? (
<div className="flex flex-col gap-3">
<p className="text-xs text-ink-soft">
{drafts.length} {selectedCards.length} /
</p>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{drafts.map((card, i) => (
<CharacterCardItem
key={i}
card={card}
selected={selected.has(i)}
onToggle={() => toggle(i)}
onEdit={(patch) =>
setDrafts((prev) =>
prev.map((c, j) => (j === i ? updateCard(c, patch) : c)),
)
}
/>
))}
</div>
{gen.ingestStatus === "conflict" && gen.conflicts ? (
<ConflictAdjudication
conflicts={gen.conflicts}
busy={ingesting}
onAcknowledge={() => void doIngest(true)}
onCancel={() => gen.reset()}
/>
) : (
<Button
onClick={() => void doIngest(false)}
disabled={ingesting || selectedCards.length === 0}
variant="primary"
>
<Database className="h-4 w-4" aria-hidden="true" />
{ingesting ? "入库中…" : `入库选中 ${selectedCards.length}`}
</Button>
)}
</div>
) : null}
{gen.ingestStatus === "done" && gen.created.length > 0 ? (
<StatusNote variant="success">{gen.created.join("、")}</StatusNote>
) : null}
</section>
);
}