feat(web): 角色群像按定位配比生成(role_mix 缩减版)

- 契约: CharacterGenerateRequest 加 role_mix{定位:数量},model_validator 归一
  count=各值之和(每项≥1、总和≤12),缺省退回单一 role+count
- @llm: build_character_gen_context/run_character_gen 加 role_mix 参并注入配比块
  (确定性、无时间戳,守 assemble 纯函数);router 穿参
- character-gen.md 加"按配比严格分配 role"纪律 + regen prompt_hashes 金标准
- 前端: sanitizeRoleMix/roleMixTotal + buildCharacterGenerateRequest 第4参 +
  useCharacterGen 穿参 + RoleMixEditor + CharacterGenerator 单一/配比双模
This commit is contained in:
Yaojia Wang
2026-07-06 17:24:16 +02:00
parent 72b3c81146
commit 7babb4854b
16 changed files with 428 additions and 50 deletions

View File

@@ -7,6 +7,7 @@ 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";
@@ -14,11 +15,26 @@ 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;
@@ -35,6 +51,10 @@ export function CharacterGenerator({
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());
@@ -46,8 +66,17 @@ export function CharacterGenerator({
}, []);
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]);
}, [gen, projectId, brief, count, role, mode, mixRows]);
// gen.cards 变化(新预览)→ 重新同步本地草稿。
const previewKey = gen.cards.map((c) => c.name).join("|");
@@ -96,43 +125,64 @@ export function CharacterGenerator({
placeholder="如:一个亦正亦邪的剑修,背负灭门血仇"
/>
</Field>
<div className="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>
<Button
onClick={() => void onGenerate()}
disabled={
generating ||
brief.trim().length === 0 ||
count < MIN_CHARACTER_COUNT ||
count > MAX_CHARACTER_COUNT
}
variant="primary"
>
<Sparkles className="h-4 w-4" aria-hidden="true" />
{generating ? "生成中…" : "生成"}
</Button>
<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()}
disabled={
generating ||
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" />
{generating ? "生成中…" : "生成"}
</Button>
</div>
{generating ? (