feat: M4 文风 + M5 生成/多provider/Skill + Kimi Code 订阅接入 + 本地联调修复

M4(文风): style-auditor 双轨(提取指纹/漂移第四审)+ jobs 长任务框架(zombie reaper) + 回炉 refine + GET /style read-back。
M5(生成+扩展): worldbuilder/character-gen(入库 continuity 409 gate + partition_writes 白名单 + schema→JSONB 形变);
  网关多 provider 回退链/熔断/能力降级(Anthropic/Gemini 适配器);Skill registry + 表权限沙箱 + 规则;
  前端 角色生成器/世界观/Codex/规则页/技能库/⌘K 命令面板。
K1(Kimi Code 订阅接入): OAuth device-flow(kimi-code)+ 静态 Console key(kimi-code-key)两路径;
  coding 端点 KimiCLI 伪造头(实测 UA allow-list 门禁,缺则 403)+ JSON 模式结构化(thinking ⊥ tool_choice)。
本地联调修复: CORS 中间件;assemble 注入 premise+「写第N章」指令(修空 prompt 400);
  GET /outline·/draft read-back + 大纲/工作台/审稿页重载;写页 client/server 常量边界 + notFound 健壮化;
  字数 toLocaleString locale 水合;审稿页终稿从已存草稿 seed(修 accept 422)。
门禁: backend ruff/mypy(157)/alembic 无漂移/pytest 451 · frontend lint/tsc/vitest/build。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yaojia Wang
2026-06-20 10:39:58 +02:00
parent 5fb7bfb1de
commit 765dbdfbd4
161 changed files with 17330 additions and 208 deletions

View File

@@ -1,7 +1,7 @@
import Link from "next/link";
import type { ReactNode } from "react";
import { LeftNav } from "./LeftNav";
import { LeftNav, type ActiveNav } from "./LeftNav";
interface AppShellProps {
children: ReactNode;
@@ -11,7 +11,7 @@ interface AppShellProps {
// 项目内页给出 projectId → 左导航展开项目级入口(大纲/伏笔/审稿UX §3.1)。
projectId?: string;
// 当前激活的项目级入口键(用于高亮)。
activeNav?: "write" | "outline" | "foreshadow" | "review";
activeNav?: ActiveNav;
}
// 全局外壳:顶栏(64px) + 左导航 + 主区UX §5.1)。

View File

@@ -3,7 +3,15 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
export type ActiveNav = "write" | "outline" | "foreshadow" | "review";
export type ActiveNav =
| "write"
| "outline"
| "foreshadow"
| "review"
| "style"
| "codex"
| "rules"
| "skills";
interface LeftNavProps {
// 项目内页传 projectId → 展开项目级入口(大纲/伏笔/审稿UX §3.1)。
@@ -50,8 +58,30 @@ function projectItems(projectId: string): NavItem[] {
enabled: true,
key: "review",
},
{ href: "#codex", label: "设定库", enabled: false },
{ href: "#style", label: "文风", enabled: false },
{
href: `/projects/${projectId}/style`,
label: "文风",
enabled: true,
key: "style",
},
{
href: `/projects/${projectId}/codex`,
label: "设定库",
enabled: true,
key: "codex",
},
{
href: `/projects/${projectId}/rules`,
label: "规则",
enabled: true,
key: "rules",
},
{
href: `/projects/${projectId}/skills`,
label: "技能库",
enabled: true,
key: "skills",
},
];
}

View File

@@ -0,0 +1,167 @@
"use client";
import { useMemo, useState } from "react";
import { AppShell } from "@/components/AppShell";
import { CharacterGenerator } from "@/components/generation/CharacterGenerator";
import { WorldGenerator } from "@/components/generation/WorldGenerator";
import type {
CharacterCardView,
ProjectResponse,
WorldEntityCardView,
} from "@/lib/api/types";
import {
mergeCharacterCards,
worldEntityRules,
} from "@/lib/generation/cards";
type CodexTab = "characters" | "world" | "timeline";
interface CodexPageProps {
project: ProjectResponse;
// 后端读端点GET .../characters / .../world_entities拉来的跨会话全量真源。
initialCharacters: CharacterCardView[];
initialWorldEntities: WorldEntityCardView[];
// 进页可经 ?gen=character|world 直接打开对应生成器(命令面板动作跳转)。
initialTab?: CodexTab;
}
const TABS: { key: CodexTab; label: string }[] = [
{ key: "characters", label: "人物" },
{ key: "world", label: "世界观" },
{ key: "timeline", label: "时间线" },
];
// 设定库 CodexUX §6.5):管理人物 / 世界观 / 时间线。
// 初始列表来自后端读端点(跨会话全量真源);本会话新入库的角色卡再合并补显,
// 故刷新后不再为空、且不与真源重复(按 name 去重,见 mergeCharacterCards
// 世界观本期无入库端点(仅生成预览)→ 展示真源 + 生成器预览。时间线为 P2/派生,暂占位。
export function CodexPage({
project,
initialCharacters,
initialWorldEntities,
initialTab = "characters",
}: CodexPageProps) {
const [tab, setTab] = useState<CodexTab>(initialTab);
// 本会话内新入库的角色(即时回显,无需刷新即见)。
const [sessionCards, setSessionCards] = useState<CharacterCardView[]>([]);
// 真源(初始全量)+ 本会话新卡,按 name 去重合并。
const characters = useMemo(
() => mergeCharacterCards(initialCharacters, sessionCards),
[initialCharacters, sessionCards],
);
return (
<AppShell
title={`${project.title}`}
subtitle="设定库"
projectId={project.id}
activeNav="codex"
>
<div className="flex h-[calc(100vh-4rem)] flex-col p-4">
<div className="mb-4 flex items-center gap-2" role="tablist">
{TABS.map((t) => (
<button
key={t.key}
type="button"
role="tab"
aria-selected={tab === t.key}
onClick={() => setTab(t.key)}
className={`rounded px-3 py-1.5 text-sm ${
tab === t.key
? "border-b-2 border-cinnabar text-cinnabar"
: "text-ink-soft hover:text-ink"
}`}
>
{t.label}
</button>
))}
</div>
<div className="min-h-0 flex-1 overflow-auto">
{tab === "characters" ? (
<div className="flex flex-col gap-4">
<section className="rounded border border-line bg-panel p-3">
<h3 className="mb-2 font-serif text-sm text-ink">
{characters.length}
</h3>
{characters.length > 0 ? (
<ul className="flex flex-wrap gap-2">
{characters.map((c, i) => (
<li
key={`${c.name}-${i}`}
className="rounded bg-bg px-2 py-1 text-xs text-ink-soft"
>
{c.name}{c.role}
</li>
))}
</ul>
) : (
<p className="text-xs text-ink-soft">
</p>
)}
</section>
<CharacterGenerator
projectId={project.id}
onIngested={(_, cards) =>
setSessionCards((prev) => [...prev, ...cards])
}
/>
</div>
) : null}
{tab === "world" ? (
<div className="flex flex-col gap-4">
<section className="rounded border border-line bg-panel p-3">
<h3 className="mb-2 font-serif text-sm text-ink">
{initialWorldEntities.length}
</h3>
{initialWorldEntities.length > 0 ? (
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{initialWorldEntities.map((entity, i) => (
<article
key={`${entity.name}-${i}`}
className="rounded border border-line bg-bg p-3 text-sm"
>
<header className="mb-2 flex items-center gap-2">
<span className="font-serif text-base text-ink">
{entity.name}
</span>
<span className="rounded bg-panel px-2 py-0.5 text-xs text-ink-soft">
{entity.type}
</span>
</header>
{worldEntityRules(entity).length > 0 ? (
<ul className="flex list-disc flex-col gap-1 pl-5 text-ink-soft">
{worldEntityRules(entity).map((rule, j) => (
<li key={j}>{rule}</li>
))}
</ul>
) : (
<p className="text-ink-soft"></p>
)}
</article>
))}
</div>
) : (
<p className="text-xs text-ink-soft">
</p>
)}
</section>
<WorldGenerator projectId={project.id} />
</div>
) : null}
{tab === "timeline" ? (
<div className="rounded border border-dashed border-line bg-panel p-6 text-center text-sm text-ink-soft">
线P2 +
</div>
) : null}
</div>
</div>
</AppShell>
);
}

View File

@@ -0,0 +1,152 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import {
filterCommands,
globalCommands,
moveHighlight,
projectCommands,
type Command,
} from "@/lib/command/palette";
// 从 pathname 抽取当前 projectId/projects/<id>/...)。
function projectIdFromPath(pathname: string): string | null {
const m = pathname.match(/^\/projects\/([^/]+)/);
return m ? m[1] : null;
}
interface CommandPaletteProps {
pathname: string;
}
// 命令面板⌘KUX §7键盘触发的快速导航/动作。
// 焦点管理打开自动聚焦输入框、Esc 关闭、上下选择、回车执行)+ a11yrole=dialog/listbox
export function CommandPalette({ pathname }: CommandPaletteProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [highlight, setHighlight] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const projectId = projectIdFromPath(pathname);
const commands = useMemo<Command[]>(
() =>
projectId
? [...projectCommands(projectId), ...globalCommands()]
: globalCommands(),
[projectId],
);
const results = useMemo(
() => filterCommands(commands, query),
[commands, query],
);
const close = useCallback((): void => {
setOpen(false);
setQuery("");
setHighlight(0);
}, []);
const run = useCallback(
(cmd: Command | undefined): void => {
if (!cmd) return;
close();
if (cmd.href) router.push(cmd.href);
},
[close, router],
);
// ⌘K / Ctrl+K 全局开关。
useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setOpen((prev) => !prev);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
// 打开时聚焦输入框;切查询重置高亮。
useEffect(() => {
if (open) inputRef.current?.focus();
}, [open]);
useEffect(() => {
setHighlight(0);
}, [query]);
if (!open) return null;
const onInputKey = (e: React.KeyboardEvent): void => {
if (e.key === "Escape") {
e.preventDefault();
close();
} else if (e.key === "ArrowDown") {
e.preventDefault();
setHighlight((h) => moveHighlight(h, 1, results.length));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setHighlight((h) => moveHighlight(h, -1, results.length));
} else if (e.key === "Enter") {
e.preventDefault();
run(results[highlight]);
}
};
return (
<div
className="fixed inset-0 z-[60] flex items-start justify-center bg-black/30 pt-[15vh] motion-safe:transition-opacity"
onClick={close}
>
<div
role="dialog"
aria-modal="true"
aria-label="命令面板"
className="w-full max-w-lg overflow-hidden rounded-lg border border-line bg-panel shadow-paper"
onClick={(e) => e.stopPropagation()}
>
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onInputKey}
placeholder="搜索命令或页面…(写本章 / 审稿 / 生成角色 / 跳转伏笔 / 搜设定)"
aria-label="命令搜索"
aria-controls="command-list"
className="w-full border-b border-line bg-bg px-4 py-3 text-sm text-ink outline-none"
/>
<ul
id="command-list"
role="listbox"
aria-label="命令列表"
className="max-h-80 overflow-auto py-1"
>
{results.length === 0 ? (
<li className="px-4 py-3 text-sm text-ink-soft"></li>
) : (
results.map((cmd, i) => (
<li
key={cmd.id}
role="option"
aria-selected={i === highlight}
onMouseEnter={() => setHighlight(i)}
onClick={() => run(cmd)}
className={`flex cursor-pointer items-center justify-between px-4 py-2 text-sm ${
i === highlight
? "bg-[var(--color-cinnabar-wash)] text-cinnabar"
: "text-ink"
}`}
>
<span>{cmd.title}</span>
<span className="text-xs text-ink-soft">{cmd.group}</span>
</li>
))
)}
</ul>
</div>
</div>
);
}

View File

@@ -0,0 +1,12 @@
"use client";
import { usePathname } from "next/navigation";
import { CommandPalette } from "./CommandPalette";
// 全局挂载命令面板⌘K读当前 pathname 注入项目上下文。
// 放在 RootLayout 内,对所有页面生效。
export function CommandPaletteMount() {
const pathname = usePathname();
return <CommandPalette pathname={pathname ?? "/"} />;
}

View File

@@ -0,0 +1,97 @@
"use client";
import type { CharacterCardView } from "@/lib/api/types";
interface CharacterCardItemProps {
card: CharacterCardView;
selected: boolean;
onToggle: () => void;
// 入库前作者就地编辑名/弧光(最小可编辑面,避免重型表单)。
onEdit?: (patch: Partial<CharacterCardView>) => void;
}
// 单张角色预览卡UX §6.6):勾选入库 + 关键字段就地编辑。
export function CharacterCardItem({
card,
selected,
onToggle,
onEdit,
}: CharacterCardItemProps) {
return (
<article
className={`rounded border p-3 text-sm ${
selected ? "border-cinnabar bg-[var(--color-cinnabar-wash)]" : "border-line bg-panel"
}`}
>
<header className="mb-2 flex items-center gap-2">
<input
type="checkbox"
checked={selected}
onChange={onToggle}
aria-label={`选择角色 ${card.name}`}
className="size-4 accent-cinnabar"
/>
{onEdit ? (
<input
value={card.name}
onChange={(e) => onEdit({ name: e.target.value })}
aria-label="角色名"
className="flex-1 rounded border border-line bg-bg px-2 py-1 font-serif text-base text-ink"
/>
) : (
<span className="flex-1 font-serif text-base text-ink">{card.name}</span>
)}
<span className="rounded bg-bg px-2 py-0.5 text-xs text-ink-soft">
{card.role}
</span>
</header>
{card.traits && card.traits.length > 0 ? (
<p className="mb-1 text-ink-soft">
<span className="text-ink"></span>
{card.traits.join("、")}
</p>
) : null}
<p className="mb-1 text-ink-soft">
<span className="text-ink"></span>
{card.backstory}
</p>
<div className="mb-1 flex items-start gap-1 text-ink-soft">
<span className="shrink-0 text-ink"></span>
{onEdit ? (
<input
value={card.arc}
onChange={(e) => onEdit({ arc: e.target.value })}
aria-label="人物弧光"
className="flex-1 rounded border border-line bg-bg px-2 py-1 text-ink"
/>
) : (
<span>{card.arc}</span>
)}
</div>
{card.speech_tics && card.speech_tics.length > 0 ? (
<p className="mb-1 text-ink-soft">
<span className="text-ink"></span>
{card.speech_tics.join("、")}
</p>
) : null}
{card.relations && card.relations.length > 0 ? (
<ul className="mt-1 flex flex-wrap gap-1">
{card.relations.map((r, i) => (
<li
key={`${r.name}-${i}`}
className="rounded bg-bg px-2 py-0.5 text-xs text-ink-soft"
>
{r.kind}{r.name}
{r.note ? `${r.note}` : ""}
</li>
))}
</ul>
) : null}
</article>
);
}

View File

@@ -0,0 +1,171 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { CharacterCardView } from "@/lib/api/types";
import {
MAX_CHARACTER_COUNT,
MIN_CHARACTER_COUNT,
updateCard,
} from "@/lib/generation/cards";
import { useCharacterGen } from "@/lib/generation/useCharacterGen";
import { CharacterCardItem } from "./CharacterCardItem";
import { ConflictAdjudication } from "./ConflictAdjudication";
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 [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 () => {
await gen.generate({ projectId, brief, count, role });
}, [gen, projectId, brief, count, role]);
// 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">
<h2 className="mb-3 font-serif text-base text-ink"></h2>
<label className="mb-2 block text-sm text-ink-soft">
<input
value={brief}
onChange={(e) => setBrief(e.target.value)}
placeholder="如:一个亦正亦邪的剑修,背负灭门血仇"
className="mt-1 w-full rounded border border-line bg-bg px-3 py-2 text-sm text-ink"
/>
</label>
<div className="flex flex-wrap items-end gap-3">
<label className="text-sm text-ink-soft">
<input
type="number"
min={MIN_CHARACTER_COUNT}
max={MAX_CHARACTER_COUNT}
value={count}
onChange={(e) => setCount(Number(e.target.value))}
className="mt-1 block w-20 rounded border border-line bg-bg px-2 py-1.5 text-sm text-ink"
/>
</label>
<label className="flex-1 text-sm text-ink-soft">
<input
value={role}
onChange={(e) => setRole(e.target.value)}
placeholder="主角 / CP / 对手 / 导师 / 工具人"
className="mt-1 block w-full rounded border border-line bg-bg px-2 py-1.5 text-sm text-ink"
/>
</label>
<button
type="button"
onClick={() => void onGenerate()}
disabled={generating || brief.trim().length === 0}
className="rounded bg-cinnabar px-4 py-2 text-sm text-white disabled:opacity-50"
>
{generating ? "生成中…" : "生成"}
</button>
</div>
</div>
{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
type="button"
onClick={() => void doIngest(false)}
disabled={ingesting || selectedCards.length === 0}
className="self-start rounded bg-cinnabar px-4 py-2 text-sm text-white disabled:opacity-50"
>
{ingesting ? "入库中…" : `入库选中 ${selectedCards.length}`}
</button>
)}
</div>
) : null}
{gen.ingestStatus === "done" && gen.created.length > 0 ? (
<p className="text-sm text-pass">{gen.created.join("、")}</p>
) : null}
</section>
);
}

View File

@@ -0,0 +1,75 @@
"use client";
import type { IngestConflicts } from "@/lib/generation/cards";
interface ConflictAdjudicationProps {
conflicts: IngestConflicts;
busy: boolean;
// 作者已查看冲突,确认带 acknowledge 重发入库。
onAcknowledge: () => void;
onCancel: () => void;
}
// 入库 409 CONFLICT_UNRESOLVED 裁决面板UX §7 / 不变量#3
// 展示 continuity 预检冲突 → 作者裁决(确认入库 = acknowledge 重发 / 取消回去改卡)。
export function ConflictAdjudication({
conflicts,
busy,
onAcknowledge,
onCancel,
}: ConflictAdjudicationProps) {
return (
<div
role="alertdialog"
aria-label="continuity 冲突裁决"
className="rounded border border-conflict bg-panel p-4"
>
<h3 className="mb-1 font-serif text-base text-conflict">
{conflicts.conflictCount}
</h3>
<p className="mb-3 text-xs text-ink-soft">
</p>
<ul className="mb-3 flex flex-col gap-2">
{conflicts.conflicts.map((c, i) => (
<li key={i} className="rounded border border-line bg-bg p-2 text-sm">
<div className="mb-1 flex items-center gap-2">
<span className="rounded bg-conflict/10 px-2 py-0.5 text-xs text-conflict">
{c.type}
</span>
{c.where ? (
<span className="text-xs text-ink-soft">{c.where}</span>
) : null}
</div>
{c.refs.length > 0 ? (
<p className="mb-1 text-xs text-ink-soft">
{c.refs.join("、")}
</p>
) : null}
{c.suggestion ? (
<p className="text-ink">{c.suggestion}</p>
) : null}
</li>
))}
</ul>
<div className="flex gap-2">
<button
type="button"
onClick={onAcknowledge}
disabled={busy}
className="rounded bg-conflict px-3 py-1.5 text-sm text-white disabled:opacity-50"
>
</button>
<button
type="button"
onClick={onCancel}
disabled={busy}
className="rounded border border-line px-3 py-1.5 text-sm text-ink disabled:opacity-50"
>
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,78 @@
"use client";
import { useCallback, useState } from "react";
import { worldEntityRules } from "@/lib/generation/cards";
import { useWorldGen } from "@/lib/generation/useWorldGen";
interface WorldGeneratorProps {
projectId: string;
}
// 世界观设计器UX §6.5):一句话需求 → 预览实体卡type/name/rules
// 本期世界观入库端点未建(仅预览);预览结果供作者参考/复制。
export function WorldGenerator({ projectId }: WorldGeneratorProps) {
const gen = useWorldGen();
const [brief, setBrief] = useState("");
const onGenerate = useCallback(async () => {
await gen.generate(projectId, brief);
}, [gen, projectId, brief]);
const generating = gen.status === "generating";
return (
<section className="flex flex-col gap-4" aria-label="世界观设计器">
<div className="rounded border border-line bg-panel p-4">
<h2 className="mb-3 font-serif text-base text-ink"></h2>
<label className="mb-3 block text-sm text-ink-soft">
<textarea
value={brief}
onChange={(e) => setBrief(e.target.value)}
placeholder="如:东方修真世界,灵气复苏,宗门林立,强者寿元有限"
rows={3}
className="mt-1 w-full resize-y rounded border border-line bg-bg px-3 py-2 text-sm text-ink"
/>
</label>
<button
type="button"
onClick={() => void onGenerate()}
disabled={generating || brief.trim().length === 0}
className="rounded bg-cinnabar px-4 py-2 text-sm text-white disabled:opacity-50"
>
{generating ? "生成中…" : "生成世界观"}
</button>
</div>
{gen.status === "done" && gen.entities.length > 0 ? (
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{gen.entities.map((entity, i) => (
<article
key={`${entity.name}-${i}`}
className="rounded border border-line bg-panel p-3 text-sm"
>
<header className="mb-2 flex items-center gap-2">
<span className="font-serif text-base text-ink">
{entity.name}
</span>
<span className="rounded bg-bg px-2 py-0.5 text-xs text-ink-soft">
{entity.type}
</span>
</header>
{worldEntityRules(entity).length > 0 ? (
<ul className="flex list-disc flex-col gap-1 pl-5 text-ink-soft">
{worldEntityRules(entity).map((rule, j) => (
<li key={j}>{rule}</li>
))}
</ul>
) : (
<p className="text-ink-soft"></p>
)}
</article>
))}
</div>
) : null}
</section>
);
}

View File

@@ -20,12 +20,16 @@ import {
} from "@/lib/review/history";
import { useAccept } from "@/lib/review/useAccept";
import { useReviewStream } from "@/lib/review/useReviewStream";
import type { ReviewConflict } from "@/lib/review/sse";
import type { ReviewConflict, StyleDriftSegment } from "@/lib/review/sse";
import { normalizeStyleDrift } from "@/lib/style/style";
import { useToast } from "@/components/Toast";
import { AcceptPanel } from "./AcceptPanel";
import { AnnotatedText } from "./AnnotatedText";
import { ConflictCard } from "./ConflictCard";
import { ForeshadowSuggestions } from "./ForeshadowSuggestions";
import { PacePanel } from "./PacePanel";
import { StylePanel } from "@/components/style/StylePanel";
import { RefineView } from "@/components/style/RefineView";
interface ReviewReportProps {
project: ProjectResponse;
@@ -45,6 +49,7 @@ export function ReviewReport({
}: ReviewReportProps) {
const review = useReviewStream();
const accept = useAccept();
const toast = useToast();
const [finalText, setFinalText] = useState(initialDraft);
const [drafts, setDrafts] = useState<DecisionDraft[]>(() =>
@@ -52,6 +57,10 @@ export function ReviewReport({
);
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
const [missing, setMissing] = useState<Set<number>>(new Set());
// 回炉中的漂移段null=未打开 RefineView
const [refineSegment, setRefineSegment] = useState<StyleDriftSegment | null>(
null,
);
const seededRef = useRef(false);
// 进页一次性把历史留痕(冲突 + 伏笔建议 + 节奏)种入流状态(无需重审即可裁决/查看)。
@@ -67,26 +76,33 @@ export function ReviewReport({
() => normalizePace(initialReview),
[initialReview],
);
const seededStyle = useMemo(
() => normalizeStyleDrift(initialReview),
[initialReview],
);
useEffect(() => {
if (seededRef.current) return;
seededRef.current = true;
const hasSeed =
seededConflicts.length > 0 ||
seededForeshadow.length > 0 ||
seededPace !== null;
seededPace !== null ||
seededStyle !== null;
if (hasSeed) {
review.seed({
conflicts: seededConflicts,
foreshadow: seededForeshadow,
pace: seededPace,
style: seededStyle,
});
}
}, [review, seededConflicts, seededForeshadow, seededPace]);
}, [review, seededConflicts, seededForeshadow, seededPace, seededStyle]);
// 当前生效冲突 = 流状态(重审后即时更新,否则种入的历史)。
const conflicts: ReviewConflict[] = review.state.conflicts;
const foreshadow = review.state.foreshadow;
const pace = review.state.pace;
const style = review.state.style;
const sectionStatus = (name: string): string | undefined =>
review.state.sections.find((s) => s.name === name)?.status;
@@ -147,6 +163,26 @@ export function ReviewReport({
}
};
// 漂移段 idx → 终稿对应段正文(按空行切段;越界则空串)。
const segmentText = (idx: number): string => {
const paras = finalText.split(/\n{2,}/);
return paras[idx]?.trim() ?? "";
};
// 采纳:把重写段替换终稿中的原段(首个匹配),合入终稿(经既有 draft 路径)。
const onAdopt = (original: string, refined: string): void => {
setFinalText((prev) => {
const idx = prev.indexOf(original);
if (idx === -1) {
toast("未在终稿中找到原段,请手动合入。", "error");
return prev;
}
return prev.slice(0, idx) + refined + prev.slice(idx + original.length);
});
setRefineSegment(null);
toast("已合入终稿,记得验收时复核。", "success");
};
const resolved = allResolved(drafts);
const unresolved = unresolvedIndices(drafts).length;
const reviewing = review.isReviewing;
@@ -279,6 +315,20 @@ export function ReviewReport({
pace={pace}
incomplete={sectionStatus("pace") === "incomplete"}
/>
<StylePanel
style={style}
incomplete={sectionStatus("style") === "incomplete"}
onRefine={setRefineSegment}
/>
{refineSegment !== null ? (
<RefineView
projectId={project.id}
chapterNo={chapterNo}
segment={segmentText(refineSegment.idx)}
onAdopt={onAdopt}
onClose={() => setRefineSegment(null)}
/>
) : null}
</div>
<section className="mt-4">

View File

@@ -0,0 +1,107 @@
"use client";
import { useMemo, useState } from "react";
import { AppShell } from "@/components/AppShell";
import type { ProjectResponse, RuleView } from "@/lib/api/types";
import {
RULE_LEVELS,
RULE_LEVEL_LABELS,
groupByLevel,
type RuleLevel,
} from "@/lib/rules/rules";
import { useRules } from "@/lib/rules/useRules";
interface RulesPageProps {
project: ProjectResponse;
initialRules: RuleView[];
}
// 规则页UX §7四级规则列表 + 新增(乐观 + 回滚)。
export function RulesPage({ project, initialRules }: RulesPageProps) {
const { items, busy, add } = useRules(initialRules);
const [level, setLevel] = useState<RuleLevel>("project");
const [content, setContent] = useState("");
const groups = useMemo(() => groupByLevel(items), [items]);
const onSubmit = async (e: React.FormEvent): Promise<void> => {
e.preventDefault();
const ok = await add(project.id, level, content);
if (ok) setContent("");
};
return (
<AppShell
title={`${project.title}`}
subtitle="规则"
projectId={project.id}
activeNav="rules"
>
<div className="mx-auto flex max-w-3xl flex-col gap-6 p-6">
<form
onSubmit={onSubmit}
className="rounded border border-line bg-panel p-4"
>
<h1 className="mb-3 font-serif text-lg text-ink"></h1>
<div className="mb-3 flex items-end gap-3">
<label className="text-sm text-ink-soft">
<select
value={level}
onChange={(e) => setLevel(e.target.value as RuleLevel)}
className="mt-1 block rounded border border-line bg-bg px-2 py-1.5 text-sm text-ink"
>
{RULE_LEVELS.map((lv) => (
<option key={lv} value={lv}>
{RULE_LEVEL_LABELS[lv]}
</option>
))}
</select>
</label>
</div>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="如:本作禁用现代科技词汇;称呼一律用「道友」"
rows={3}
className="w-full resize-y rounded border border-line bg-bg px-3 py-2 text-sm text-ink"
/>
<button
type="submit"
disabled={busy || content.trim().length === 0}
className="mt-3 rounded bg-cinnabar px-4 py-2 text-sm text-white disabled:opacity-50"
>
{busy ? "保存中…" : "新增规则"}
</button>
</form>
<div className="flex flex-col gap-4">
{RULE_LEVELS.map((lv) => (
<section key={lv}>
<h2 className="mb-2 font-serif text-sm text-ink">
{RULE_LEVEL_LABELS[lv]}
<span className="ml-2 text-xs text-ink-soft">
{groups[lv].length}
</span>
</h2>
{groups[lv].length === 0 ? (
<p className="text-xs text-ink-soft"></p>
) : (
<ul className="flex flex-col gap-2">
{groups[lv].map((rule, i) => (
<li
key={i}
className="rounded border border-line bg-panel px-3 py-2 text-sm text-ink"
>
{rule.content}
</li>
))}
</ul>
)}
</section>
))}
</div>
</div>
</AppShell>
);
}

View File

@@ -0,0 +1,127 @@
"use client";
import { authOpenUrl, formatExpiresAt } from "@/lib/settings/kimiOauth";
import { useKimiOauth } from "@/lib/settings/useKimiOauth";
interface KimiCodeOauthProps {
initialConnected: boolean;
initialExpiresAt: string | null;
}
// Kimi CodeOAuth 订阅 plan连接区K1.4)。
// 与 API-key 凭据行分离:无 key 输入,连接经 device flow显示 user_code + 开授权页 + 轮询 job
export function KimiCodeOauth({
initialConnected,
initialExpiresAt,
}: KimiCodeOauthProps) {
const oauth = useKimiOauth({ initialConnected, initialExpiresAt });
const { phase, device, expiresAt, busy, error } = oauth;
const expiresLabel = formatExpiresAt(expiresAt);
return (
<section className="mb-10">
<h2 className="mb-1 font-serif text-xl text-ink">Kimi CodeOAuth</h2>
<p className="mb-3 text-sm text-ink-soft">
plan OAuth API Key
<span className="font-mono"> kimi-code · kimi-for-coding</span>
</p>
<div className="rounded border border-line bg-panel p-4">
<div className="flex flex-wrap items-center gap-3">
<span
className={`h-2 w-2 rounded-full ${
phase === "connected" ? "bg-pass" : "bg-line"
}`}
aria-hidden="true"
/>
<span className="text-sm text-ink">
{phase === "connected" ? "已连接" : "未连接"}
</span>
{phase === "connected" && expiresLabel ? (
<span className="font-mono text-xs text-ink-soft">
{expiresLabel}
</span>
) : null}
<div className="ml-auto flex gap-2">
{phase === "connected" ? (
<button
type="button"
onClick={() => void oauth.disconnect()}
disabled={busy}
className="rounded border border-line px-3 py-1.5 text-sm text-ink disabled:opacity-40"
>
{busy ? "处理中…" : "断开"}
</button>
) : (
<button
type="button"
onClick={() => void oauth.connect()}
disabled={busy}
className="rounded bg-cinnabar px-3 py-1.5 text-sm text-panel disabled:opacity-40"
>
{busy
? "等待授权…"
: phase === "error"
? "重新连接 Kimi CodeOAuth"
: "连接 Kimi CodeOAuth"}
</button>
)}
</div>
</div>
{phase === "awaiting" && device ? (
<DeviceInstructions
userCode={device.userCode}
openUrl={authOpenUrl(device)}
/>
) : null}
{phase === "error" && error ? (
<p className="mt-3 text-sm text-conflict" role="alert">
{error}
</p>
) : null}
</div>
</section>
);
}
interface DeviceInstructionsProps {
userCode: string;
openUrl: string;
}
// 设备授权指引:突出展示 user_code可读、可选、可聚焦+ 打开授权页按钮。
function DeviceInstructions({ userCode, openUrl }: DeviceInstructionsProps) {
const openAuth = (): void => {
if (typeof window !== "undefined") {
window.open(openUrl, "_blank", "noopener,noreferrer");
}
};
return (
<div className="motion-safe:transition-opacity mt-4 rounded border border-dashed border-line bg-bg p-4">
<p className="mb-2 text-sm text-ink-soft">
</p>
<output
aria-label="设备授权码"
tabIndex={0}
className="mb-3 block select-all rounded bg-panel px-4 py-3 text-center font-mono text-2xl tracking-[0.3em] text-ink"
>
{userCode}
</output>
<button
type="button"
onClick={openAuth}
className="rounded border border-cinnabar px-3 py-1.5 text-sm text-cinnabar"
>
</button>
<p className="mt-2 text-xs text-ink-soft">
</p>
</div>
);
}

View File

@@ -4,15 +4,25 @@ import { useState } from "react";
import { api } from "@/lib/api/client";
import { useToast } from "@/components/Toast";
import { KNOWN_PROVIDERS, TIER_LABELS } from "@/lib/settings/providers";
import { KimiCodeOauth } from "@/components/settings/KimiCodeOauth";
import {
API_KEY_PROVIDERS,
KNOWN_PROVIDERS,
TIER_LABELS,
applyProviderChange,
draftsToRoutingInput,
toRoutingDrafts,
type RoutingDraft,
} from "@/lib/settings/providers";
import type {
CapabilitiesView,
ProvidersResponse,
TierRoutingView,
} from "@/lib/api/types";
interface ProvidersSettingsProps {
initial: ProvidersResponse;
// Kimi Code OAuth 连接态GET .../oauth/status进页 Server Component 取)。
kimiOauth: { connected: boolean; expiresAt: string | null };
}
interface TestResult {
@@ -20,11 +30,17 @@ interface TestResult {
capabilities: CapabilitiesView;
}
// 设置页主体UX §6.10):档位路由(只读展示)+ 凭据行(脱敏 / 添加更新 / 测试连接
export function ProvidersSettings({ initial }: ProvidersSettingsProps) {
// 设置页主体UX §6.10):档位路由(可编辑)+ API-key 凭据行 + Kimi Code OAuth 连接
export function ProvidersSettings({
initial,
kimiOauth,
}: ProvidersSettingsProps) {
const toast = useToast();
const [providers, setProviders] = useState(initial.providers ?? []);
const tierRouting: TierRoutingView[] = initial.tier_routing ?? [];
const [routing, setRouting] = useState<RoutingDraft[]>(
toRoutingDrafts(initial.tier_routing ?? []),
);
const [savingRouting, setSavingRouting] = useState(false);
const [drafts, setDrafts] = useState<Record<string, string>>({});
const [savingId, setSavingId] = useState<string | null>(null);
const [testingId, setTestingId] = useState<string | null>(null);
@@ -33,6 +49,35 @@ export function ProvidersSettings({ initial }: ProvidersSettingsProps) {
const maskedFor = (id: string): string | null =>
providers.find((p) => p.provider === id)?.masked_key ?? null;
const updateRouting = (tier: string, providerId: string): void => {
setRouting((prev) =>
prev.map((d) =>
d.tier === tier ? applyProviderChange(d, providerId) : d,
),
);
};
const updateRoutingModel = (tier: string, model: string): void => {
setRouting((prev) =>
prev.map((d) => (d.tier === tier ? { ...d, model } : d)),
);
};
const saveRouting = async (): Promise<void> => {
setSavingRouting(true);
const { data, error } = await api.PUT("/settings/providers", {
body: { tier_routing: draftsToRoutingInput(routing) },
});
setSavingRouting(false);
if (error || !data) {
toast("保存档位路由失败,请重试", "error");
return;
}
setProviders(data.providers ?? []);
setRouting(toRoutingDrafts(data.tier_routing ?? []));
toast("档位路由已保存", "success");
};
const saveCredential = async (providerId: string): Promise<void> => {
const apiKey = (drafts[providerId] ?? "").trim();
if (!apiKey) {
@@ -74,34 +119,61 @@ export function ProvidersSettings({ initial }: ProvidersSettingsProps) {
<div>
<section className="mb-10">
<h2 className="mb-3 font-serif text-xl text-ink"></h2>
{tierRouting.length === 0 ? (
<p className="rounded border border-dashed border-line bg-panel p-4 text-sm text-ink-soft">
使
</p>
) : (
<ul className="divide-y divide-line rounded border border-line bg-panel">
{tierRouting.map((t) => (
<li
key={t.tier}
className="flex items-center gap-4 px-4 py-3 text-sm"
<ul className="divide-y divide-line rounded border border-line bg-panel">
{routing.map((row) => (
<li
key={row.tier}
className="flex flex-wrap items-center gap-3 px-4 py-3 text-sm"
>
<span className="w-20 text-ink">
{TIER_LABELS[row.tier] ?? row.tier}
</span>
<label className="sr-only" htmlFor={`route-provider-${row.tier}`}>
{TIER_LABELS[row.tier] ?? row.tier}
</label>
<select
id={`route-provider-${row.tier}`}
value={row.provider}
onChange={(e) => updateRouting(row.tier, e.target.value)}
className="rounded border border-line bg-bg px-2 py-1.5 text-sm text-ink focus:border-cinnabar focus:outline-none"
>
<span className="w-20 text-ink">
{TIER_LABELS[t.tier] ?? t.tier}
</span>
<span className="font-mono text-ink-soft">
{t.provider} · {t.model}
</span>
{t.fallback && t.fallback.length > 0 ? (
<span className="ml-auto font-mono text-xs text-ink-soft">
退 {t.fallback.join(", ")}
</span>
) : null}
</li>
))}
</ul>
)}
<option value=""></option>
{KNOWN_PROVIDERS.map((p) => (
<option key={p.id} value={p.id}>
{p.label}
</option>
))}
</select>
<label className="sr-only" htmlFor={`route-model-${row.tier}`}>
{TIER_LABELS[row.tier] ?? row.tier}
</label>
<input
id={`route-model-${row.tier}`}
value={row.model}
onChange={(e) => updateRoutingModel(row.tier, e.target.value)}
placeholder="model"
className="min-w-[10rem] flex-1 rounded border border-line bg-bg px-3 py-1.5 font-mono text-sm text-ink focus:border-cinnabar focus:outline-none"
/>
</li>
))}
</ul>
<div className="mt-3 flex justify-end">
<button
type="button"
onClick={() => void saveRouting()}
disabled={savingRouting}
className="rounded bg-cinnabar px-3 py-1.5 text-sm text-panel disabled:opacity-40"
>
{savingRouting ? "保存中…" : "保存档位路由"}
</button>
</div>
</section>
<KimiCodeOauth
initialConnected={kimiOauth.connected}
initialExpiresAt={kimiOauth.expiresAt}
/>
<section>
<h2 className="mb-3 font-serif text-xl text-ink"></h2>
{providers.length === 0 ? (
@@ -111,7 +183,7 @@ export function ProvidersSettings({ initial }: ProvidersSettingsProps) {
</p>
) : null}
<ul className="divide-y divide-line rounded border border-line bg-panel">
{KNOWN_PROVIDERS.map((prov) => {
{API_KEY_PROVIDERS.map((prov) => {
const masked = maskedFor(prov.id);
const result = results[prov.id];
return (

View File

@@ -0,0 +1,77 @@
import { AppShell } from "@/components/AppShell";
import type { ProjectResponse, SkillView } from "@/lib/api/types";
import { groupByScope, scopeLabel, tierLabel } from "@/lib/skills/skills";
interface SkillsPageProps {
project: ProjectResponse;
skills: SkillView[];
}
// 技能库UX §7只读注册表视图name/scope/tier/reads/writes。Server Component纯读
export function SkillsPage({ project, skills }: SkillsPageProps) {
const groups = groupByScope(skills);
return (
<AppShell
title={`${project.title}`}
subtitle="技能库"
projectId={project.id}
activeNav="skills"
>
<div className="mx-auto flex max-w-4xl flex-col gap-6 p-6">
<h1 className="font-serif text-lg text-ink"></h1>
<p className="text-xs text-ink-soft">
Skill /
</p>
{groups.length === 0 ? (
<p className="text-sm text-ink-soft"></p>
) : (
groups.map((group) => (
<section key={group.scope}>
<h2 className="mb-2 font-serif text-sm text-ink">
{scopeLabel(group.scope)}
<span className="ml-2 text-xs text-ink-soft">
{group.skills.length}
</span>
</h2>
<ul className="flex flex-col gap-2">
{group.skills.map((skill) => (
<li
key={skill.name}
className="rounded border border-line bg-panel p-3 text-sm"
>
<div className="mb-1 flex flex-wrap items-center gap-2">
<span className="font-mono text-ink">{skill.name}</span>
<span className="rounded bg-bg px-2 py-0.5 text-xs text-ink-soft">
{tierLabel(skill.tier)}
</span>
{skill.genre ? (
<span className="rounded bg-bg px-2 py-0.5 text-xs text-ink-soft">
{skill.genre}
</span>
) : null}
</div>
<div className="flex flex-wrap gap-4 text-xs text-ink-soft">
<span>
{skill.reads && skill.reads.length > 0
? skill.reads.join("、")
: "—"}
</span>
<span>
{skill.writes && skill.writes.length > 0
? skill.writes.join("、")
: "—"}
</span>
</div>
</li>
))}
</ul>
</section>
))
)}
</div>
</AppShell>
);
}

View File

@@ -0,0 +1,65 @@
"use client";
import type { Fingerprint } from "@/lib/style/style";
interface FingerprintViewProps {
fingerprint: Fingerprint | null;
}
// 文风指纹展示UX §6.916 维行(维度名 + 值 + 可展开原文证据)。
export function FingerprintView({ fingerprint }: FingerprintViewProps) {
if (fingerprint === null) {
return (
<p className="rounded border border-dashed border-line p-4 text-sm text-ink-soft">
</p>
);
}
return (
<div>
<div className="mb-3 flex items-center gap-2">
<h2 className="font-serif text-lg text-ink"></h2>
<span className="font-mono text-xs text-ink-soft">
v{fingerprint.version} · {fingerprint.dimensions.length}
</span>
</div>
{fingerprint.dimensions.length === 0 ? (
<p className="text-sm text-ink-soft"></p>
) : (
<ul className="space-y-2">
{fingerprint.dimensions.map((dim) => (
<li
key={dim.name}
className="rounded border border-line bg-panel p-3"
>
<div className="flex items-baseline gap-3">
<span className="w-28 shrink-0 text-sm font-semibold text-ink">
{dim.name}
</span>
<span className="text-sm text-ink-soft">{dim.value}</span>
</div>
{dim.evidence.length > 0 ? (
<details className="mt-2">
<summary className="cursor-pointer text-xs text-ink-soft hover:text-cinnabar">
{dim.evidence.length}
</summary>
<ul className="mt-1 space-y-1 border-l-2 border-line pl-3">
{dim.evidence.map((quote, i) => (
<li
key={i}
className="text-xs italic leading-relaxed text-ink-soft"
>
{quote}
</li>
))}
</ul>
</details>
) : null}
</li>
))}
</ul>
)}
</div>
);
}

View File

@@ -0,0 +1,100 @@
"use client";
import { useRefine } from "@/lib/style/useRefine";
import { useEffect } from "react";
interface RefineViewProps {
projectId: string;
chapterNo: number;
// 选中漂移段的原文(从终稿按段切出;空则提示先编辑终稿)。
segment: string;
// 采纳:把 refined 合入终稿(经审稿页 finalText → 既有 draft 自动保存路径)。
onAdopt: (original: string, refined: string) => void;
onClose: () => void;
}
// 回炉 diffUX §8.3POST .../refine → 展示原段/重写段对比 → 采纳合入正文。
// 不另写库(不变量#3采纳经既有 draft 自动保存合入。
export function RefineView({
projectId,
chapterNo,
segment,
onAdopt,
onClose,
}: RefineViewProps) {
const refiner = useRefine();
// 进入即触发回炉(段非空)。
useEffect(() => {
if (segment.trim().length > 0) {
void refiner.refine(projectId, chapterNo, segment);
}
// 仅在段变化时触发。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [segment, projectId, chapterNo]);
return (
<div
className="rounded border border-cinnabar bg-panel p-3 text-xs"
role="dialog"
aria-label="回炉对比"
>
<div className="mb-2 flex items-center justify-between">
<h3 className="font-semibold text-ink"></h3>
<button
type="button"
onClick={onClose}
className="text-ink-soft hover:text-cinnabar"
aria-label="关闭回炉对比"
>
</button>
</div>
{segment.trim().length === 0 ? (
<p className="text-overdue">稿稿</p>
) : refiner.status === "refining" ? (
<p className="text-info" aria-live="polite">
</p>
) : refiner.status === "error" ? (
<p className="text-conflict"></p>
) : refiner.result ? (
<div className="space-y-2">
<div>
<p className="mb-1 text-ink-soft"></p>
<p className="whitespace-pre-wrap rounded border border-line bg-bg p-2 text-ink-soft line-through">
{refiner.result.original}
</p>
</div>
<div>
<p className="mb-1 text-cinnabar"></p>
<p className="whitespace-pre-wrap rounded border border-cinnabar bg-bg p-2 text-ink">
{refiner.result.refined}
</p>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => {
if (refiner.result) {
onAdopt(refiner.result.original, refiner.result.refined);
}
}}
className="rounded bg-cinnabar px-3 py-1.5 text-panel hover:opacity-90"
>
</button>
<button
type="button"
onClick={onClose}
className="rounded border border-line px-3 py-1.5 text-ink hover:border-cinnabar"
>
</button>
</div>
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,48 @@
"use client";
import { AppShell } from "@/components/AppShell";
import type { ProjectResponse } from "@/lib/api/types";
import { useStyleLearn } from "@/lib/style/useStyleLearn";
import type { Fingerprint, StyleLearnMode } from "@/lib/style/style";
import { FingerprintView } from "./FingerprintView";
import { StyleUpload } from "./StyleUpload";
interface StylePageProps {
project: ProjectResponse;
initialFingerprint: Fingerprint | null;
}
// 文风页UX §6.9):左侧上传样本学文风,右侧展示 16 维指纹 + 证据。
export function StylePage({ project, initialFingerprint }: StylePageProps) {
const learn = useStyleLearn(initialFingerprint);
const onLearn = (samples: string[], mode: StyleLearnMode): void => {
void learn.learn(project.id, samples, mode);
};
return (
<AppShell
title={`${project.title}`}
subtitle="文风指纹"
projectId={project.id}
activeNav="style"
>
<div className="grid h-[calc(100vh-4rem)] grid-cols-1 lg:grid-cols-[28rem_1fr]">
<section className="flex flex-col overflow-auto border-r border-line bg-panel px-6 py-6">
<h1 className="mb-3 font-serif text-lg text-ink"></h1>
<StyleUpload
busy={learn.busy}
pollStatus={learn.pollStatus === "idle" ? "idle" : learn.pollStatus}
progress={learn.progress}
hasFingerprint={learn.fingerprint !== null}
onLearn={onLearn}
/>
</section>
<section className="overflow-auto bg-bg px-6 py-6">
<FingerprintView fingerprint={learn.fingerprint} />
</section>
</div>
</AppShell>
);
}

View File

@@ -0,0 +1,87 @@
"use client";
import type { StyleDriftReport, StyleDriftSegment } from "@/lib/review/sse";
interface StylePanelProps {
style: StyleDriftReport | null;
incomplete: boolean;
// 选中段做回炉(打开 RefineView
onRefine: (segment: StyleDriftSegment) => void;
}
// 整体相似度的四分圆字符档(◔◑◕●),按 score 映射UX §6.4 ③)。
const QUARTER_CHARS = ["○", "◔", "◑", "◕", "●"] as const;
function quarterChar(score: number): string {
const clamped = Math.min(100, Math.max(0, score));
const idx = Math.round((clamped / 100) * (QUARTER_CHARS.length - 1));
return QUARTER_CHARS[idx] ?? QUARTER_CHARS[0];
}
// 文风审区UX §6.4 ③):整体相似度 ◔% + 漂移段列表,每段可一键回炉。只读建议。
export function StylePanel({ style, incomplete, onRefine }: StylePanelProps) {
return (
<section className="mb-4">
<h2 className="text-xs font-semibold uppercase tracking-wide text-ink-soft">
(style-auditor)
</h2>
{incomplete ? (
<p className="mt-1 text-xs text-overdue"> </p>
) : style === null ? (
<p className="mt-1 text-xs text-ink-soft">
</p>
) : (
<div className="mt-2 space-y-2 text-xs">
<p
className="flex items-center gap-1.5"
aria-label={`整体文风相似度 ${style.score}%`}
role="img"
>
<span className="text-lg leading-none text-cinnabar" aria-hidden="true">
{quarterChar(style.score)}
</span>
<span className="text-ink">
<span className="font-mono">{style.score}%</span>
</span>
</p>
{style.segments.length === 0 ? (
<p className="text-pass"> </p>
) : (
<ul className="space-y-2">
{style.segments.map((seg, i) => (
<li
key={i}
className="rounded border border-line bg-panel p-2"
data-testid="drift-segment"
>
<div className="flex items-center gap-2">
<span className="font-mono text-overdue">
{seg.idx}
</span>
<span className="font-mono text-ink-soft">
{seg.score}%
</span>
{seg.label ? (
<span className="text-ink-soft">{seg.label}</span>
) : null}
</div>
<div className="mt-1.5 flex gap-2">
<button
type="button"
onClick={() => onRefine(seg)}
className="rounded bg-cinnabar px-2 py-1 text-panel hover:opacity-90"
>
</button>
</div>
</li>
))}
</ul>
)}
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,110 @@
"use client";
import { useCallback, useState, type ChangeEvent } from "react";
import { hasUsableSamples, type StyleLearnMode } from "@/lib/style/style";
import { useToast } from "@/components/Toast";
interface StyleUploadProps {
busy: boolean;
pollStatus: "idle" | "polling" | "done" | "error";
progress: number;
// 是否已有指纹(决定默认 mode = update
hasFingerprint: boolean;
onLearn: (samples: string[], mode: StyleLearnMode) => void;
}
// 学文风输入UX §6.9):多段文本粘贴 + 文件选择(在浏览器读成文本)。
// 样本以正文文本入 body无对象存储确认决策
export function StyleUpload({
busy,
pollStatus,
progress,
hasFingerprint,
onLearn,
}: StyleUploadProps) {
const [text, setText] = useState("");
const toast = useToast();
// 读取选中的文本文件,追加到文本框(多文件用空行分隔)。
const onFiles = useCallback(
async (e: ChangeEvent<HTMLInputElement>): Promise<void> => {
const files = Array.from(e.target.files ?? []);
if (files.length === 0) return;
try {
const contents = await Promise.all(files.map((f) => f.text()));
setText((prev) => {
const joined = contents.join("\n\n");
return prev.trim().length > 0 ? `${prev}\n\n${joined}` : joined;
});
} catch {
toast("读取文件失败,请改用粘贴。", "error");
} finally {
e.target.value = ""; // 允许重复选同一文件。
}
},
[toast],
);
const submit = (): void => {
// 以空行切分成多段样本(对齐后端 samples:list[str])。
const samples = text
.split(/\n{2,}/)
.map((s) => s.trim())
.filter((s) => s.length > 0);
if (!hasUsableSamples(samples)) {
toast("请粘贴或选择至少一段样本正文。", "error");
return;
}
onLearn(samples, hasFingerprint ? "update" : "create");
};
return (
<div className="space-y-3">
<div>
<label
htmlFor="style-samples"
className="mb-1 block text-sm font-semibold text-ink"
>
</label>
<textarea
id="style-samples"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="粘贴你想学习文风的章节正文…"
className="min-h-[30vh] w-full resize-y rounded border border-line bg-panel p-3 font-serif text-[15px] leading-[1.9] text-ink focus:border-cinnabar focus:outline-none"
/>
</div>
<div className="flex items-center gap-3">
<label className="cursor-pointer rounded border border-line px-3 py-1.5 text-sm text-ink hover:border-cinnabar hover:text-cinnabar">
<input
type="file"
accept=".txt,.md,text/plain,text/markdown"
multiple
onChange={(e) => void onFiles(e)}
className="sr-only"
/>
</label>
<button
type="button"
onClick={submit}
disabled={busy}
className="rounded bg-cinnabar px-4 py-1.5 text-sm text-panel hover:opacity-90 disabled:opacity-50"
>
{hasFingerprint ? "重新学习文风" : "学习文风"}
</button>
</div>
{pollStatus === "polling" ? (
<p className="text-xs text-info" aria-live="polite">
{progress}%
</p>
) : pollStatus === "error" ? (
<p className="text-xs text-conflict"></p>
) : null}
</div>
);
}

View File

@@ -7,20 +7,25 @@ import { AppShell } from "@/components/AppShell";
import type { ProjectResponse } from "@/lib/api/types";
import { useAutosave } from "@/lib/autosave/useAutosave";
import { useDraftStream } from "@/lib/stream/useDraftStream";
import { WORKBENCH_CHAPTER_NO } from "@/lib/workbench/chapter";
import { ChapterList } from "./ChapterList";
import { ChapterAssistant } from "./ChapterAssistant";
import { Editor } from "./Editor";
interface WorkbenchProps {
project: ProjectResponse;
// RSC 种入的已存草稿正文GET .../draft无草稿404→ ""(空编辑器)。
initialText?: string;
}
// M1单章工作台。章节列表为占位无章节列表端点默认第 1 章。
const M1_CHAPTER_NO = 1;
// WORKBENCH_CHAPTER_NO 移到 lib/workbench/chapter.ts非客户端模块供 Server Component 安全导入。
export function Workbench({ project }: WorkbenchProps) {
const chapterNo = M1_CHAPTER_NO;
const [text, setText] = useState("");
export function Workbench({ project, initialText = "" }: WorkbenchProps) {
const chapterNo = WORKBENCH_CHAPTER_NO;
// 用已存草稿作初值;流式开始后由下方 effect 覆盖(流不会被初值反扑——
// stream.state.text 起始为空,仅当 streaming/done/aborted 且变化才写回)。
const [text, setText] = useState(initialText);
const autosave = useAutosave(project.id, chapterNo);
const stream = useDraftStream();
const lastStreamText = useRef("");
@@ -137,7 +142,7 @@ function Toolbar({
</button>
)}
<span className="font-mono text-xs text-ink-soft">
{wordCount.toLocaleString()}
{wordCount.toLocaleString("en-US")}
</span>
<Link
href={`/projects/${projectId}/outline`}