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:
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
import { CommandPaletteMount } from "@/components/command/CommandPaletteMount";
|
||||
import { ToastProvider } from "@/components/Toast";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -16,7 +17,10 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body className="font-sans antialiased">
|
||||
<ToastProvider>{children}</ToastProvider>
|
||||
<ToastProvider>
|
||||
{children}
|
||||
<CommandPaletteMount />
|
||||
</ToastProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
42
apps/web/app/projects/[id]/codex/page.tsx
Normal file
42
apps/web/app/projects/[id]/codex/page.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { CodexPage } from "@/components/codex/CodexPage";
|
||||
import {
|
||||
fetchCharacters,
|
||||
fetchProject,
|
||||
fetchWorldEntities,
|
||||
} from "@/lib/api/server";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ gen?: string }>;
|
||||
}
|
||||
|
||||
// 设定库 Codex 页(UX §6.5)。Server Component 取项目 + 跨会话全量人物/世界观真源;
|
||||
// CodexPage(Client)承载生成/管理 + 本会话新入库回显。
|
||||
// ?gen=character|world 由命令面板动作跳转,进页直开对应生成器 tab。
|
||||
export default async function CodexRoutePage({
|
||||
params,
|
||||
searchParams,
|
||||
}: PageProps) {
|
||||
const { id } = await params;
|
||||
const { gen } = await searchParams;
|
||||
const initialTab = gen === "world" ? "world" : "characters";
|
||||
try {
|
||||
const [project, characters, worldEntities] = await Promise.all([
|
||||
fetchProject(id),
|
||||
fetchCharacters(id),
|
||||
fetchWorldEntities(id),
|
||||
]);
|
||||
return (
|
||||
<CodexPage
|
||||
project={project}
|
||||
initialCharacters={characters.characters ?? []}
|
||||
initialWorldEntities={worldEntities.world_entities ?? []}
|
||||
initialTab={initialTab}
|
||||
/>
|
||||
);
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,23 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { OutlineEditor } from "@/components/outline/OutlineEditor";
|
||||
import { fetchProject } from "@/lib/api/server";
|
||||
import { fetchOutline, fetchProject } from "@/lib/api/server";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
// 大纲编辑器页(UX §6.7)。Server Component 取项目;OutlineEditor(Client)按需
|
||||
// 触发 POST .../outline 生成大纲(M3 无 GET 大纲端点,进页空、生成后填充)。
|
||||
// 大纲编辑器页(UX §6.7)。Server Component 取项目 + 已存大纲(GET .../outline,
|
||||
// 空/错误降级为空列表)作为初始章节,重访时显已生成的大纲;OutlineEditor(Client)
|
||||
// 仍按需触发 POST .../outline 生成/覆盖所显章节。
|
||||
export default async function OutlinePage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const project = await fetchProject(id);
|
||||
return <OutlineEditor project={project} initialChapters={[]} />;
|
||||
const initialChapters = await fetchOutline(id);
|
||||
return (
|
||||
<OutlineEditor project={project} initialChapters={initialChapters} />
|
||||
);
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { ReviewReport } from "@/components/review/ReviewReport";
|
||||
import { fetchProject, fetchReviews } from "@/lib/api/server";
|
||||
import { fetchDraft, fetchProject, fetchReviews } from "@/lib/api/server";
|
||||
import { latestReview } from "@/lib/review/history";
|
||||
|
||||
interface PageProps {
|
||||
@@ -20,12 +20,14 @@ export default async function ReviewPage({ params, searchParams }: PageProps) {
|
||||
try {
|
||||
const project = await fetchProject(id);
|
||||
const history = await fetchReviews(id, chapterNo);
|
||||
// 终稿初值取已存草稿(GET .../draft,404→null→"");否则 final_text 为空 → accept 422。
|
||||
const draft = await fetchDraft(id, chapterNo);
|
||||
return (
|
||||
<ReviewReport
|
||||
project={project}
|
||||
chapterNo={chapterNo}
|
||||
initialReview={latestReview(history.reviews)}
|
||||
initialDraft=""
|
||||
initialDraft={draft?.content ?? ""}
|
||||
/>
|
||||
);
|
||||
} catch {
|
||||
|
||||
20
apps/web/app/projects/[id]/rules/page.tsx
Normal file
20
apps/web/app/projects/[id]/rules/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { RulesPage } from "@/components/rules/RulesPage";
|
||||
import { fetchProject, fetchRules } from "@/lib/api/server";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
// 规则页(UX §7)。Server Component 取项目 + 规则列表;RulesPage(Client)承载新增。
|
||||
export default async function RulesRoutePage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const project = await fetchProject(id);
|
||||
const rules = await fetchRules(id);
|
||||
return <RulesPage project={project} initialRules={rules.rules ?? []} />;
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
}
|
||||
20
apps/web/app/projects/[id]/skills/page.tsx
Normal file
20
apps/web/app/projects/[id]/skills/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { SkillsPage } from "@/components/skills/SkillsPage";
|
||||
import { fetchProject, fetchSkills } from "@/lib/api/server";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
// 技能库页(UX §7)。Server Component 取项目 + 技能注册表(只读)。
|
||||
export default async function SkillsRoutePage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const project = await fetchProject(id);
|
||||
const skills = await fetchSkills();
|
||||
return <SkillsPage project={project} skills={skills.skills ?? []} />;
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
}
|
||||
27
apps/web/app/projects/[id]/style/page.tsx
Normal file
27
apps/web/app/projects/[id]/style/page.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { StylePage } from "@/components/style/StylePage";
|
||||
import { fetchProject, fetchStyleFingerprint } from "@/lib/api/server";
|
||||
import { normalizeFingerprint } from "@/lib/style/style";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
// 文风页(UX §6.9)。Server Component 取项目 + 最新指纹(GET /style,无则 null);
|
||||
// StylePage(Client)承载上传/学文风/指纹展示交互。
|
||||
export default async function StyleRoutePage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const project = await fetchProject(id);
|
||||
const fingerprint = await fetchStyleFingerprint(id);
|
||||
return (
|
||||
<StylePage
|
||||
project={project}
|
||||
initialFingerprint={normalizeFingerprint(fingerprint ?? undefined)}
|
||||
/>
|
||||
);
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,36 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { Workbench } from "@/components/workbench/Workbench";
|
||||
import { fetchProject } from "@/lib/api/server";
|
||||
import { fetchDraft, fetchProject } from "@/lib/api/server";
|
||||
import type { ProjectResponse } from "@/lib/api/types";
|
||||
import { WORKBENCH_CHAPTER_NO } from "@/lib/workbench/chapter";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
// 写作工作台页(UX §6.3)。Server Component 取项目,Workbench 负责编辑/流式/保存。
|
||||
// 写作工作台页(UX §6.3)。Server Component 取项目 + 当前章已存草稿(GET .../draft,
|
||||
// 404→null=空编辑器),把正文作为编辑器初值种入,重访时重载已写章节;Workbench
|
||||
// 负责编辑/流式/保存(流式照常覆盖初值)。
|
||||
export default async function WritePage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
// 仅当项目确实取不到时才判 404;草稿/瞬时错误不应把整页变成「找不到页面」。
|
||||
let project: ProjectResponse;
|
||||
try {
|
||||
const project = await fetchProject(id);
|
||||
return <Workbench project={project} />;
|
||||
project = await fetchProject(id);
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// 草稿读取失败(后端瞬时错误 / 尚无草稿)→ 空编辑器初值,绝不 404。
|
||||
let initialText = "";
|
||||
try {
|
||||
const draft = await fetchDraft(id, WORKBENCH_CHAPTER_NO);
|
||||
initialText = draft?.content ?? "";
|
||||
} catch {
|
||||
initialText = "";
|
||||
}
|
||||
|
||||
return <Workbench project={project} initialText={initialText} />;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { ProvidersSettings } from "@/components/settings/ProvidersSettings";
|
||||
import { fetchProviders } from "@/lib/api/server";
|
||||
import { fetchKimiOauthStatus, fetchProviders } from "@/lib/api/server";
|
||||
import type { ProvidersResponse } from "@/lib/api/types";
|
||||
|
||||
// 模型与提供商设置(UX §6.10)。Server Component 取初始数据。
|
||||
// 模型与提供商设置(UX §6.10)。Server Component 取初始数据(含 Kimi Code OAuth 状态)。
|
||||
export default async function ProvidersSettingsPage() {
|
||||
let initial: ProvidersResponse = { providers: [], tier_routing: [] };
|
||||
let kimiOauth = { connected: false, expiresAt: null as string | null };
|
||||
let loadError = false;
|
||||
try {
|
||||
initial = await fetchProviders();
|
||||
const [providers, oauthStatus] = await Promise.all([
|
||||
fetchProviders(),
|
||||
fetchKimiOauthStatus(),
|
||||
]);
|
||||
initial = providers;
|
||||
kimiOauth = {
|
||||
connected: oauthStatus.connected === true,
|
||||
expiresAt:
|
||||
typeof oauthStatus.expires_at === "string"
|
||||
? oauthStatus.expires_at
|
||||
: null,
|
||||
};
|
||||
} catch {
|
||||
loadError = true;
|
||||
}
|
||||
@@ -21,7 +33,7 @@ export default async function ProvidersSettingsPage() {
|
||||
无法连接后端服务,请确认 API 已启动后刷新。
|
||||
</p>
|
||||
) : (
|
||||
<ProvidersSettings initial={initial} />
|
||||
<ProvidersSettings initial={initial} kimiOauth={kimiOauth} />
|
||||
)}
|
||||
</div>
|
||||
</AppShell>
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
167
apps/web/components/codex/CodexPage.tsx
Normal file
167
apps/web/components/codex/CodexPage.tsx
Normal 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: "时间线" },
|
||||
];
|
||||
|
||||
// 设定库 Codex(UX §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>
|
||||
);
|
||||
}
|
||||
152
apps/web/components/command/CommandPalette.tsx
Normal file
152
apps/web/components/command/CommandPalette.tsx
Normal 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;
|
||||
}
|
||||
|
||||
// 命令面板(⌘K,UX §7):键盘触发的快速导航/动作。
|
||||
// 焦点管理(打开自动聚焦输入框、Esc 关闭、上下选择、回车执行)+ a11y(role=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>
|
||||
);
|
||||
}
|
||||
12
apps/web/components/command/CommandPaletteMount.tsx
Normal file
12
apps/web/components/command/CommandPaletteMount.tsx
Normal 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 ?? "/"} />;
|
||||
}
|
||||
97
apps/web/components/generation/CharacterCardItem.tsx
Normal file
97
apps/web/components/generation/CharacterCardItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
171
apps/web/components/generation/CharacterGenerator.tsx
Normal file
171
apps/web/components/generation/CharacterGenerator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
apps/web/components/generation/ConflictAdjudication.tsx
Normal file
75
apps/web/components/generation/ConflictAdjudication.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
apps/web/components/generation/WorldGenerator.tsx
Normal file
78
apps/web/components/generation/WorldGenerator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
107
apps/web/components/rules/RulesPage.tsx
Normal file
107
apps/web/components/rules/RulesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
127
apps/web/components/settings/KimiCodeOauth.tsx
Normal file
127
apps/web/components/settings/KimiCodeOauth.tsx
Normal 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 Code(OAuth 订阅 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 Code(OAuth)</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 Code(OAuth)"
|
||||
: "连接 Kimi Code(OAuth)"}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
77
apps/web/components/skills/SkillsPage.tsx
Normal file
77
apps/web/components/skills/SkillsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
apps/web/components/style/FingerprintView.tsx
Normal file
65
apps/web/components/style/FingerprintView.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import type { Fingerprint } from "@/lib/style/style";
|
||||
|
||||
interface FingerprintViewProps {
|
||||
fingerprint: Fingerprint | null;
|
||||
}
|
||||
|
||||
// 文风指纹展示(UX §6.9):16 维行(维度名 + 值 + 可展开原文证据)。
|
||||
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>
|
||||
);
|
||||
}
|
||||
100
apps/web/components/style/RefineView.tsx
Normal file
100
apps/web/components/style/RefineView.tsx
Normal 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;
|
||||
}
|
||||
|
||||
// 回炉 diff(UX §8.3):POST .../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>
|
||||
);
|
||||
}
|
||||
48
apps/web/components/style/StylePage.tsx
Normal file
48
apps/web/components/style/StylePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
apps/web/components/style/StylePanel.tsx
Normal file
87
apps/web/components/style/StylePanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
apps/web/components/style/StyleUpload.tsx
Normal file
110
apps/web/components/style/StyleUpload.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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`}
|
||||
|
||||
1124
apps/web/lib/api/schema.d.ts
vendored
1124
apps/web/lib/api/schema.d.ts
vendored
File diff suppressed because it is too large
Load Diff
68
apps/web/lib/api/server.test.ts
Normal file
68
apps/web/lib/api/server.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { fetchDraft, fetchOutline } from "./server";
|
||||
|
||||
// 纯加载/降级逻辑(mock fetch,无网络/DOM):大纲解包/降级、草稿 404→null。
|
||||
function mockFetch(status: number, body: unknown): void {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("fetchOutline", () => {
|
||||
it("returns the persisted chapters on 200", async () => {
|
||||
mockFetch(200, { chapters: [{ no: 1, volume: 1, beats: ["开场"] }] });
|
||||
|
||||
const chapters = await fetchOutline("p1");
|
||||
|
||||
expect(chapters).toHaveLength(1);
|
||||
expect(chapters[0].no).toBe(1);
|
||||
});
|
||||
|
||||
it("returns empty array when the project has no outline", async () => {
|
||||
mockFetch(200, { chapters: [] });
|
||||
|
||||
expect(await fetchOutline("p1")).toEqual([]);
|
||||
});
|
||||
|
||||
it("falls back to empty array on any error (does not throw)", async () => {
|
||||
mockFetch(404, { error: { code: "NOT_FOUND", message: "no project" } });
|
||||
|
||||
expect(await fetchOutline("missing")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchDraft", () => {
|
||||
it("returns the saved draft view on 200", async () => {
|
||||
mockFetch(200, {
|
||||
project_id: "p1",
|
||||
chapter_no: 1,
|
||||
volume: 1,
|
||||
status: "draft",
|
||||
version: 2,
|
||||
content: "第一章正文",
|
||||
length: 5,
|
||||
});
|
||||
|
||||
const draft = await fetchDraft("p1", 1);
|
||||
|
||||
expect(draft?.content).toBe("第一章正文");
|
||||
expect(draft?.version).toBe(2);
|
||||
});
|
||||
|
||||
it("returns null when no draft exists (404 → empty editor)", async () => {
|
||||
mockFetch(404, { error: { code: "NOT_FOUND", message: "no draft" } });
|
||||
|
||||
expect(await fetchDraft("p1", 9)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,19 @@
|
||||
import { serverApiBase } from "./config";
|
||||
import type {
|
||||
CharacterListResponse,
|
||||
DraftView,
|
||||
ForeshadowBoardResponse,
|
||||
OutlineChapterView,
|
||||
OutlineResponse,
|
||||
ProjectListResponse,
|
||||
ProjectResponse,
|
||||
OAuthStatusResponse,
|
||||
ProvidersResponse,
|
||||
ReviewHistoryResponse,
|
||||
RuleListResponse,
|
||||
SkillListResponse,
|
||||
StyleFingerprintResponse,
|
||||
WorldEntityListResponse,
|
||||
} from "./types";
|
||||
|
||||
// 服务端只读取数据(Server Components)。失败时抛出,由页面边界处理。
|
||||
@@ -16,6 +25,16 @@ async function getJson<T>(path: string): Promise<T> {
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
// 像上面一样读取,但 404(无指纹)返回 null 而非抛出(首学前页面照常渲染)。
|
||||
async function getJsonOrNull<T>(path: string): Promise<T | null> {
|
||||
const res = await fetch(`${serverApiBase()}${path}`, { cache: "no-store" });
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) {
|
||||
throw new Error(`请求失败 ${res.status}: ${path}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export async function fetchProjects(): Promise<ProjectListResponse> {
|
||||
return getJson<ProjectListResponse>("/projects");
|
||||
}
|
||||
@@ -28,6 +47,13 @@ export async function fetchProviders(): Promise<ProvidersResponse> {
|
||||
return getJson<ProvidersResponse>("/settings/providers");
|
||||
}
|
||||
|
||||
// Kimi Code OAuth 连接态(GET .../oauth/status;无 token 本体)。
|
||||
export async function fetchKimiOauthStatus(): Promise<OAuthStatusResponse> {
|
||||
return getJson<OAuthStatusResponse>(
|
||||
"/settings/providers/kimi-code/oauth/status",
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchReviews(
|
||||
projectId: string,
|
||||
chapterNo: number,
|
||||
@@ -43,3 +69,66 @@ export async function fetchForeshadow(
|
||||
): Promise<ForeshadowBoardResponse> {
|
||||
return getJson<ForeshadowBoardResponse>(`/projects/${projectId}/foreshadow`);
|
||||
}
|
||||
|
||||
// 最新文风指纹(GET /style);无指纹(404)→ null(首学前页面照常渲染)。
|
||||
export async function fetchStyleFingerprint(
|
||||
projectId: string,
|
||||
): Promise<StyleFingerprintResponse | null> {
|
||||
return getJsonOrNull<StyleFingerprintResponse>(
|
||||
`/projects/${projectId}/style`,
|
||||
);
|
||||
}
|
||||
|
||||
// 规则列表(GET .../rules,规则页)。
|
||||
export async function fetchRules(
|
||||
projectId: string,
|
||||
): Promise<RuleListResponse> {
|
||||
return getJson<RuleListResponse>(`/projects/${projectId}/rules`);
|
||||
}
|
||||
|
||||
// 技能库注册表(GET /skills,全局只读)。
|
||||
export async function fetchSkills(): Promise<SkillListResponse> {
|
||||
return getJson<SkillListResponse>("/skills");
|
||||
}
|
||||
|
||||
// 设定库 Codex:跨会话全量已入库角色(GET .../characters;无行→空列表,非 404)。
|
||||
export async function fetchCharacters(
|
||||
projectId: string,
|
||||
): Promise<CharacterListResponse> {
|
||||
return getJson<CharacterListResponse>(`/projects/${projectId}/characters`);
|
||||
}
|
||||
|
||||
// 设定库 Codex:跨会话全量已入库世界观实体(GET .../world_entities;无行→空列表)。
|
||||
export async function fetchWorldEntities(
|
||||
projectId: string,
|
||||
): Promise<WorldEntityListResponse> {
|
||||
return getJson<WorldEntityListResponse>(
|
||||
`/projects/${projectId}/world_entities`,
|
||||
);
|
||||
}
|
||||
|
||||
// 已存大纲(GET .../outline,与 POST 同形,按 chapter_no 排序)。
|
||||
// 无大纲→空列表;任何错误亦降级为空(进页不阻塞,生成流照常工作)。
|
||||
export async function fetchOutline(
|
||||
projectId: string,
|
||||
): Promise<OutlineChapterView[]> {
|
||||
try {
|
||||
const res = await getJson<OutlineResponse>(
|
||||
`/projects/${projectId}/outline`,
|
||||
);
|
||||
return res.chapters ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 已存草稿(GET .../draft,含正文,供工作台重访重载编辑器)。
|
||||
// 404(尚无草稿,新章)→ null,由调用方当空编辑器处理,不抛错。
|
||||
export async function fetchDraft(
|
||||
projectId: string,
|
||||
chapterNo: number,
|
||||
): Promise<DraftView | null> {
|
||||
return getJsonOrNull<DraftView>(
|
||||
`/projects/${projectId}/chapters/${chapterNo}/draft`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ export type ProjectCreateRequest = components["schemas"]["ProjectCreateRequest"]
|
||||
export type ProjectListResponse = components["schemas"]["ProjectListResponse"];
|
||||
export type DraftSaveRequest = components["schemas"]["DraftSaveRequest"];
|
||||
export type DraftResponse = components["schemas"]["DraftResponse"];
|
||||
// GET .../draft 读端点:含正文,供工作台重访时重载编辑器(404→空编辑器)。
|
||||
export type DraftView = components["schemas"]["DraftView"];
|
||||
export type ProvidersResponse = components["schemas"]["ProvidersResponse"];
|
||||
export type ProviderView = components["schemas"]["ProviderView"];
|
||||
export type TierRoutingView = components["schemas"]["TierRoutingView"];
|
||||
@@ -40,3 +42,48 @@ export type OutlineChapterView = components["schemas"]["OutlineChapterView"];
|
||||
export type OutlineGenerateRequest =
|
||||
components["schemas"]["OutlineGenerateRequest"];
|
||||
export type OutlineResponse = components["schemas"]["OutlineResponse"];
|
||||
|
||||
// M4 文风:学文风(jobs 异步)+ 最新指纹 + 回炉(C3 扩 T4.3)。
|
||||
export type StyleLearnRequest = components["schemas"]["StyleLearnRequest"];
|
||||
export type StyleLearnResponse = components["schemas"]["StyleLearnResponse"];
|
||||
export type StyleFingerprintResponse =
|
||||
components["schemas"]["StyleFingerprintResponse"];
|
||||
export type RefineRequest = components["schemas"]["RefineRequest"];
|
||||
export type RefineResponse = components["schemas"]["RefineResponse"];
|
||||
|
||||
// M5 生成 + 设定库(C3 扩 T5.2)。
|
||||
export type WorldGenerateRequest =
|
||||
components["schemas"]["WorldGenerateRequest"];
|
||||
export type WorldGenPreviewResponse =
|
||||
components["schemas"]["WorldGenPreviewResponse"];
|
||||
export type WorldEntityCardView =
|
||||
components["schemas"]["WorldEntityCardView"];
|
||||
export type CharacterGenerateRequest =
|
||||
components["schemas"]["CharacterGenerateRequest"];
|
||||
export type CharacterGenPreviewResponse =
|
||||
components["schemas"]["CharacterGenPreviewResponse"];
|
||||
export type CharacterCardView = components["schemas"]["CharacterCardView"];
|
||||
export type CharacterRelationView =
|
||||
components["schemas"]["CharacterRelationView"];
|
||||
export type CharacterIngestRequest =
|
||||
components["schemas"]["CharacterIngestRequest"];
|
||||
export type CharacterIngestResponse =
|
||||
components["schemas"]["CharacterIngestResponse"];
|
||||
// 设定库 Codex 读端点(C3 扩 M5 R1 follow-up):跨会话全量已入库角色/世界观。
|
||||
export type CharacterListResponse =
|
||||
components["schemas"]["CharacterListResponse"];
|
||||
export type WorldEntityListResponse =
|
||||
components["schemas"]["WorldEntityListResponse"];
|
||||
|
||||
// K1 Kimi Code OAuth device-flow(C3 扩 K1.3)。
|
||||
export type OAuthStartResponse = components["schemas"]["OAuthStartResponse"];
|
||||
export type OAuthDisconnectResponse =
|
||||
components["schemas"]["OAuthDisconnectResponse"];
|
||||
export type OAuthStatusResponse = components["schemas"]["OAuthStatusResponse"];
|
||||
|
||||
// M5 规则 + 技能(C3 扩 T5.2 / T5.5)。
|
||||
export type RuleCreateRequest = components["schemas"]["RuleCreateRequest"];
|
||||
export type RuleView = components["schemas"]["RuleView"];
|
||||
export type RuleListResponse = components["schemas"]["RuleListResponse"];
|
||||
export type SkillView = components["schemas"]["SkillView"];
|
||||
export type SkillListResponse = components["schemas"]["SkillListResponse"];
|
||||
|
||||
75
apps/web/lib/command/palette.test.ts
Normal file
75
apps/web/lib/command/palette.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
clampHighlight,
|
||||
filterCommands,
|
||||
globalCommands,
|
||||
moveHighlight,
|
||||
projectCommands,
|
||||
} from "./palette";
|
||||
|
||||
describe("projectCommands", () => {
|
||||
it("includes nav + action commands wired to the project id", () => {
|
||||
const cmds = projectCommands("p1");
|
||||
const write = cmds.find((c) => c.id === "nav-write");
|
||||
expect(write?.href).toBe("/projects/p1/write");
|
||||
const genChar = cmds.find((c) => c.id === "action-gen-character");
|
||||
expect(genChar?.kind).toBe("action");
|
||||
expect(genChar?.href).toBe("/projects/p1/codex?gen=character");
|
||||
});
|
||||
});
|
||||
|
||||
describe("globalCommands", () => {
|
||||
it("offers home + settings", () => {
|
||||
expect(globalCommands().map((c) => c.id)).toEqual([
|
||||
"nav-home",
|
||||
"nav-settings",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterCommands", () => {
|
||||
const cmds = projectCommands("p1");
|
||||
|
||||
it("returns all on empty query", () => {
|
||||
expect(filterCommands(cmds, " ")).toHaveLength(cmds.length);
|
||||
});
|
||||
|
||||
it("matches on title", () => {
|
||||
const out = filterCommands(cmds, "审稿");
|
||||
expect(out.map((c) => c.id)).toContain("nav-review");
|
||||
});
|
||||
|
||||
it("matches on keyword case-insensitively", () => {
|
||||
const out = filterCommands(cmds, "FORESHADOW");
|
||||
expect(out.map((c) => c.id)).toContain("nav-foreshadow");
|
||||
});
|
||||
|
||||
it("matches generation actions by 角色/世界观", () => {
|
||||
expect(filterCommands(cmds, "角色").map((c) => c.id)).toContain(
|
||||
"action-gen-character",
|
||||
);
|
||||
expect(filterCommands(cmds, "世界观").map((c) => c.id)).toContain(
|
||||
"action-gen-world",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns empty when nothing matches", () => {
|
||||
expect(filterCommands(cmds, "zzzz-no-match")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clampHighlight / moveHighlight", () => {
|
||||
it("wraps within [0, len)", () => {
|
||||
expect(clampHighlight(0, 3)).toBe(0);
|
||||
expect(clampHighlight(3, 3)).toBe(0);
|
||||
expect(clampHighlight(-1, 3)).toBe(2);
|
||||
expect(clampHighlight(5, 0)).toBe(0);
|
||||
});
|
||||
|
||||
it("moves up/down with wrap", () => {
|
||||
expect(moveHighlight(0, 1, 3)).toBe(1);
|
||||
expect(moveHighlight(2, 1, 3)).toBe(0);
|
||||
expect(moveHighlight(0, -1, 3)).toBe(2);
|
||||
});
|
||||
});
|
||||
154
apps/web/lib/command/palette.ts
Normal file
154
apps/web/lib/command/palette.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
// 命令面板(⌘K)纯逻辑:命令清单组装 + 模糊过滤 + 键盘选择状态机。
|
||||
// 对齐 UX §7(快速导航/动作)。纯逻辑,便于 node 环境单测(不依赖 DOM)。
|
||||
|
||||
export type CommandKind = "navigate" | "action";
|
||||
|
||||
export interface Command {
|
||||
id: string;
|
||||
// 展示标题(如「写本章」「跳转伏笔」)。
|
||||
title: string;
|
||||
// 用于匹配的关键词(拼音/别名),与 title 一起参与过滤。
|
||||
keywords: string[];
|
||||
kind: CommandKind;
|
||||
// navigate:跳转路径;action:交由调用方按 id 分派。
|
||||
href?: string;
|
||||
group: string;
|
||||
}
|
||||
|
||||
// 项目内可用命令(projectId 已知)。导航项给 href;动作项(生成角色等)给 id。
|
||||
export function projectCommands(projectId: string): Command[] {
|
||||
const p = `/projects/${projectId}`;
|
||||
return [
|
||||
{
|
||||
id: "nav-write",
|
||||
title: "写本章",
|
||||
keywords: ["write", "写作", "工作台", "起草"],
|
||||
kind: "navigate",
|
||||
href: `${p}/write`,
|
||||
group: "导航",
|
||||
},
|
||||
{
|
||||
id: "nav-review",
|
||||
title: "审稿",
|
||||
keywords: ["review", "质检", "冲突", "裁决"],
|
||||
kind: "navigate",
|
||||
href: `${p}/review`,
|
||||
group: "导航",
|
||||
},
|
||||
{
|
||||
id: "nav-outline",
|
||||
title: "大纲",
|
||||
keywords: ["outline", "节拍", "beats"],
|
||||
kind: "navigate",
|
||||
href: `${p}/outline`,
|
||||
group: "导航",
|
||||
},
|
||||
{
|
||||
id: "nav-foreshadow",
|
||||
title: "跳转伏笔",
|
||||
keywords: ["foreshadow", "伏笔", "看板", "线索"],
|
||||
kind: "navigate",
|
||||
href: `${p}/foreshadow`,
|
||||
group: "导航",
|
||||
},
|
||||
{
|
||||
id: "nav-style",
|
||||
title: "文风",
|
||||
keywords: ["style", "文风", "指纹", "漂移"],
|
||||
kind: "navigate",
|
||||
href: `${p}/style`,
|
||||
group: "导航",
|
||||
},
|
||||
{
|
||||
id: "nav-codex",
|
||||
title: "搜设定",
|
||||
keywords: ["codex", "设定库", "人物", "世界观", "角色"],
|
||||
kind: "navigate",
|
||||
href: `${p}/codex`,
|
||||
group: "导航",
|
||||
},
|
||||
{
|
||||
id: "nav-rules",
|
||||
title: "规则",
|
||||
keywords: ["rules", "规则", "硬规则"],
|
||||
kind: "navigate",
|
||||
href: `${p}/rules`,
|
||||
group: "导航",
|
||||
},
|
||||
{
|
||||
id: "nav-skills",
|
||||
title: "技能库",
|
||||
keywords: ["skills", "技能", "注册表"],
|
||||
kind: "navigate",
|
||||
href: `${p}/skills`,
|
||||
group: "导航",
|
||||
},
|
||||
{
|
||||
id: "action-gen-character",
|
||||
title: "生成角色",
|
||||
keywords: ["character", "角色", "生成器", "群像"],
|
||||
kind: "action",
|
||||
href: `${p}/codex?gen=character`,
|
||||
group: "动作",
|
||||
},
|
||||
{
|
||||
id: "action-gen-world",
|
||||
title: "生成世界观",
|
||||
keywords: ["world", "世界观", "设定", "设计器"],
|
||||
kind: "action",
|
||||
href: `${p}/codex?gen=world`,
|
||||
group: "动作",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// 全局命令(无 projectId 时;如作品库/设置)。
|
||||
export function globalCommands(): Command[] {
|
||||
return [
|
||||
{
|
||||
id: "nav-home",
|
||||
title: "作品库",
|
||||
keywords: ["home", "作品", "library", "projects"],
|
||||
kind: "navigate",
|
||||
href: "/",
|
||||
group: "导航",
|
||||
},
|
||||
{
|
||||
id: "nav-settings",
|
||||
title: "设置",
|
||||
keywords: ["settings", "设置", "凭据", "provider", "档位"],
|
||||
kind: "navigate",
|
||||
href: "/settings/providers",
|
||||
group: "导航",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// 模糊过滤:空查询返回全部;否则在 title + keywords 上做大小写无关子串匹配(保持原顺序)。
|
||||
export function filterCommands(
|
||||
commands: readonly Command[],
|
||||
query: string,
|
||||
): Command[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return [...commands];
|
||||
return commands.filter((cmd) => {
|
||||
if (cmd.title.toLowerCase().includes(q)) return true;
|
||||
return cmd.keywords.some((kw) => kw.toLowerCase().includes(q));
|
||||
});
|
||||
}
|
||||
|
||||
// 键盘高亮索引在 [0, len) 内循环(空列表归 0)。
|
||||
export function clampHighlight(index: number, length: number): number {
|
||||
if (length <= 0) return 0;
|
||||
const wrapped = index % length;
|
||||
return wrapped < 0 ? wrapped + length : wrapped;
|
||||
}
|
||||
|
||||
// 上/下方向移动高亮(循环)。
|
||||
export function moveHighlight(
|
||||
index: number,
|
||||
delta: number,
|
||||
length: number,
|
||||
): number {
|
||||
return clampHighlight(index + delta, length);
|
||||
}
|
||||
199
apps/web/lib/generation/cards.test.ts
Normal file
199
apps/web/lib/generation/cards.test.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type {
|
||||
CharacterCardView,
|
||||
WorldEntityCardView,
|
||||
} from "@/lib/api/types";
|
||||
import {
|
||||
buildCharacterGenerateRequest,
|
||||
buildCharacterIngestRequest,
|
||||
buildWorldGenerateRequest,
|
||||
clampCharacterCount,
|
||||
errorCode,
|
||||
extractIngestConflicts,
|
||||
generationErrorMessage,
|
||||
MAX_CHARACTER_COUNT,
|
||||
mergeCharacterCards,
|
||||
mergeWorldEntities,
|
||||
MIN_CHARACTER_COUNT,
|
||||
updateCard,
|
||||
worldEntityRules,
|
||||
} from "./cards";
|
||||
|
||||
const card = (over: Partial<CharacterCardView> = {}): CharacterCardView => ({
|
||||
name: "甲",
|
||||
role: "主角",
|
||||
backstory: "出身寒门",
|
||||
arc: "由怯转勇",
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("buildWorldGenerateRequest", () => {
|
||||
it("trims brief", () => {
|
||||
expect(buildWorldGenerateRequest(" 修真世界 ")).toEqual({
|
||||
brief: "修真世界",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("clampCharacterCount", () => {
|
||||
it("clamps to [MIN, MAX] and rounds", () => {
|
||||
expect(clampCharacterCount(0)).toBe(MIN_CHARACTER_COUNT);
|
||||
expect(clampCharacterCount(99)).toBe(MAX_CHARACTER_COUNT);
|
||||
expect(clampCharacterCount(3.6)).toBe(4);
|
||||
expect(clampCharacterCount(Number.NaN)).toBe(MIN_CHARACTER_COUNT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildCharacterGenerateRequest", () => {
|
||||
it("trims brief, clamps count, omits empty role", () => {
|
||||
expect(buildCharacterGenerateRequest(" 剑修 ", 99)).toEqual({
|
||||
brief: "剑修",
|
||||
count: MAX_CHARACTER_COUNT,
|
||||
});
|
||||
});
|
||||
|
||||
it("includes trimmed role when present", () => {
|
||||
expect(buildCharacterGenerateRequest("剑修", 2, " 对手 ")).toEqual({
|
||||
brief: "剑修",
|
||||
count: 2,
|
||||
role: "对手",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildCharacterIngestRequest", () => {
|
||||
it("copies cards and carries acknowledge flag", () => {
|
||||
const cards = [card()];
|
||||
const req = buildCharacterIngestRequest(cards, true);
|
||||
expect(req.acknowledge_conflicts).toBe(true);
|
||||
expect(req.cards).toEqual(cards);
|
||||
expect(req.cards[0]).not.toBe(cards[0]); // immutable copy
|
||||
});
|
||||
|
||||
it("defaults acknowledge to false", () => {
|
||||
expect(buildCharacterIngestRequest([card()]).acknowledge_conflicts).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateCard", () => {
|
||||
it("returns a new card with patch applied (no mutation)", () => {
|
||||
const original = card();
|
||||
const next = updateCard(original, { name: "乙" });
|
||||
expect(next.name).toBe("乙");
|
||||
expect(original.name).toBe("甲");
|
||||
expect(next).not.toBe(original);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractIngestConflicts", () => {
|
||||
it("narrows CONFLICT_UNRESOLVED envelope details", () => {
|
||||
const out = extractIngestConflicts({
|
||||
error: {
|
||||
code: "CONFLICT_UNRESOLVED",
|
||||
details: {
|
||||
conflicts: [
|
||||
{ type: "性格漂移", where: "第3章", refs: ["甲"], suggestion: "改" },
|
||||
"junk",
|
||||
],
|
||||
conflict_count: 2,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(out).toEqual({
|
||||
conflictCount: 2,
|
||||
conflicts: [
|
||||
{ type: "性格漂移", where: "第3章", refs: ["甲"], suggestion: "改" },
|
||||
{ type: "未分类", where: "", refs: [], suggestion: "" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for non-conflict errors", () => {
|
||||
expect(extractIngestConflicts({ error: { code: "LLM_UNAVAILABLE" } })).toBeNull();
|
||||
expect(extractIngestConflicts(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back conflict_count to conflicts length", () => {
|
||||
const out = extractIngestConflicts({
|
||||
error: {
|
||||
code: "CONFLICT_UNRESOLVED",
|
||||
details: { conflicts: [{ type: "设定违例" }] },
|
||||
},
|
||||
});
|
||||
expect(out?.conflictCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("errorCode / generationErrorMessage", () => {
|
||||
it("extracts code and maps 503 to settings prompt", () => {
|
||||
expect(errorCode({ error: { code: "LLM_UNAVAILABLE" } })).toBe(
|
||||
"LLM_UNAVAILABLE",
|
||||
);
|
||||
expect(generationErrorMessage({ error: { code: "LLM_UNAVAILABLE" } })).toContain(
|
||||
"设置",
|
||||
);
|
||||
expect(generationErrorMessage({ error: { code: "INTERNAL" } })).toContain(
|
||||
"重试",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("worldEntityRules", () => {
|
||||
it("returns rules or empty array", () => {
|
||||
expect(worldEntityRules({ type: "势力", name: "宗门", rules: ["a"] })).toEqual([
|
||||
"a",
|
||||
]);
|
||||
expect(worldEntityRules({ type: "势力", name: "宗门" })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeCharacterCards", () => {
|
||||
it("appends session cards not already in the persisted list", () => {
|
||||
const persisted = [card({ name: "甲" })];
|
||||
const session = [card({ name: "乙" })];
|
||||
const out = mergeCharacterCards(persisted, session);
|
||||
expect(out.map((c) => c.name)).toEqual(["甲", "乙"]);
|
||||
});
|
||||
|
||||
it("dedups by name (persisted wins, no duplicate on reload echo)", () => {
|
||||
const persisted = [card({ name: "甲", role: "主角" })];
|
||||
const session = [card({ name: "甲", role: "对手" })];
|
||||
const out = mergeCharacterCards(persisted, session);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].role).toBe("主角"); // persisted version kept
|
||||
});
|
||||
|
||||
it("returns persisted list when no session cards", () => {
|
||||
const persisted = [card({ name: "甲" })];
|
||||
expect(mergeCharacterCards(persisted, [])).toEqual(persisted);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeWorldEntities", () => {
|
||||
const entity = (
|
||||
over: Partial<WorldEntityCardView> = {},
|
||||
): WorldEntityCardView => ({ type: "势力", name: "宗门", ...over });
|
||||
|
||||
it("appends entities not already present (keyed by type+name)", () => {
|
||||
const persisted = [entity({ name: "天剑宗" })];
|
||||
const session = [entity({ name: "魔渊" })];
|
||||
expect(mergeWorldEntities(persisted, session).map((e) => e.name)).toEqual([
|
||||
"天剑宗",
|
||||
"魔渊",
|
||||
]);
|
||||
});
|
||||
|
||||
it("dedups by type+name; same name different type kept separate", () => {
|
||||
const persisted = [entity({ type: "势力", name: "玄" })];
|
||||
const session = [
|
||||
entity({ type: "势力", name: "玄" }),
|
||||
entity({ type: "地点", name: "玄" }),
|
||||
];
|
||||
const out = mergeWorldEntities(persisted, session);
|
||||
expect(out).toHaveLength(2);
|
||||
expect(out.map((e) => e.type)).toEqual(["势力", "地点"]);
|
||||
});
|
||||
});
|
||||
BIN
apps/web/lib/generation/cards.ts
Normal file
BIN
apps/web/lib/generation/cards.ts
Normal file
Binary file not shown.
150
apps/web/lib/generation/useCharacterGen.ts
Normal file
150
apps/web/lib/generation/useCharacterGen.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api/client";
|
||||
import { useToast } from "@/components/Toast";
|
||||
import type {
|
||||
CharacterCardView,
|
||||
CharacterIngestResponse,
|
||||
} from "@/lib/api/types";
|
||||
import {
|
||||
buildCharacterGenerateRequest,
|
||||
buildCharacterIngestRequest,
|
||||
extractIngestConflicts,
|
||||
generationErrorMessage,
|
||||
type IngestConflicts,
|
||||
} from "./cards";
|
||||
|
||||
export type CharacterGenStatus = "idle" | "generating" | "preview" | "error";
|
||||
export type IngestStatus = "idle" | "ingesting" | "conflict" | "done" | "error";
|
||||
|
||||
export interface CharacterGenInput {
|
||||
projectId: string;
|
||||
brief: string;
|
||||
count: number;
|
||||
role?: string | null;
|
||||
}
|
||||
|
||||
export interface UseCharacterGen {
|
||||
genStatus: CharacterGenStatus;
|
||||
cards: CharacterCardView[];
|
||||
ingestStatus: IngestStatus;
|
||||
// 409 冲突详情(待作者裁决);裁决后带 acknowledge 重发。
|
||||
conflicts: IngestConflicts | null;
|
||||
created: string[];
|
||||
generate: (input: CharacterGenInput) => Promise<void>;
|
||||
// 入库选中卡;acknowledge 用于过 409(已查看冲突后重发)。
|
||||
ingest: (
|
||||
projectId: string,
|
||||
cards: readonly CharacterCardView[],
|
||||
acknowledge?: boolean,
|
||||
) => Promise<boolean>;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
// 角色生成器(UX §6.6):生成预览 → 作者选/改 → 入库(处理 409 裁决流)。
|
||||
export function useCharacterGen(): UseCharacterGen {
|
||||
const [genStatus, setGenStatus] = useState<CharacterGenStatus>("idle");
|
||||
const [cards, setCards] = useState<CharacterCardView[]>([]);
|
||||
const [ingestStatus, setIngestStatus] = useState<IngestStatus>("idle");
|
||||
const [conflicts, setConflicts] = useState<IngestConflicts | null>(null);
|
||||
const [created, setCreated] = useState<string[]>([]);
|
||||
const toast = useToast();
|
||||
|
||||
const generate = useCallback<UseCharacterGen["generate"]>(
|
||||
async ({ projectId, brief, count, role }) => {
|
||||
setGenStatus("generating");
|
||||
setCards([]);
|
||||
setConflicts(null);
|
||||
setIngestStatus("idle");
|
||||
try {
|
||||
const { data, error } = await api.POST(
|
||||
"/projects/{project_id}/characters/generate",
|
||||
{
|
||||
params: { path: { project_id: projectId } },
|
||||
body: buildCharacterGenerateRequest(brief, count, role),
|
||||
},
|
||||
);
|
||||
if (error || !data) {
|
||||
setGenStatus("error");
|
||||
toast(generationErrorMessage(error), "error");
|
||||
return;
|
||||
}
|
||||
setCards(data.cards ?? []);
|
||||
setGenStatus("preview");
|
||||
} catch {
|
||||
setGenStatus("error");
|
||||
toast("生成请求异常,请检查网络。", "error");
|
||||
}
|
||||
},
|
||||
[toast],
|
||||
);
|
||||
|
||||
const ingest = useCallback<UseCharacterGen["ingest"]>(
|
||||
async (projectId, ingestCards, acknowledge = false) => {
|
||||
if (ingestCards.length === 0) {
|
||||
toast("请至少选择一张角色卡。", "error");
|
||||
return false;
|
||||
}
|
||||
setIngestStatus("ingesting");
|
||||
try {
|
||||
const { data, error } = await api.POST(
|
||||
"/projects/{project_id}/characters",
|
||||
{
|
||||
params: { path: { project_id: projectId } },
|
||||
body: buildCharacterIngestRequest(ingestCards, acknowledge),
|
||||
},
|
||||
);
|
||||
if (error || !data) {
|
||||
const conflict = extractIngestConflicts(error);
|
||||
if (conflict) {
|
||||
// 409 CONFLICT_UNRESOLVED:展示冲突让作者裁决,再带 acknowledge 重发。
|
||||
setConflicts(conflict);
|
||||
setIngestStatus("conflict");
|
||||
return false;
|
||||
}
|
||||
setIngestStatus("error");
|
||||
toast(generationErrorMessage(error), "error");
|
||||
return false;
|
||||
}
|
||||
const result = data as CharacterIngestResponse;
|
||||
setCreated(result.created ?? []);
|
||||
setConflicts(null);
|
||||
setIngestStatus("done");
|
||||
toast(`已入库 ${result.created?.length ?? 0} 个角色`, "success");
|
||||
if (result.rejected_tables && result.rejected_tables.length > 0) {
|
||||
toast(
|
||||
`越权写表被丢弃:${result.rejected_tables.join("、")}`,
|
||||
"info",
|
||||
);
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
setIngestStatus("error");
|
||||
toast("入库请求异常,请检查网络。", "error");
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[toast],
|
||||
);
|
||||
|
||||
const reset = useCallback((): void => {
|
||||
setGenStatus("idle");
|
||||
setCards([]);
|
||||
setIngestStatus("idle");
|
||||
setConflicts(null);
|
||||
setCreated([]);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
genStatus,
|
||||
cards,
|
||||
ingestStatus,
|
||||
conflicts,
|
||||
created,
|
||||
generate,
|
||||
ingest,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
58
apps/web/lib/generation/useWorldGen.ts
Normal file
58
apps/web/lib/generation/useWorldGen.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api/client";
|
||||
import { useToast } from "@/components/Toast";
|
||||
import type { WorldEntityCardView } from "@/lib/api/types";
|
||||
import { buildWorldGenerateRequest, generationErrorMessage } from "./cards";
|
||||
|
||||
export type WorldGenStatus = "idle" | "generating" | "done" | "error";
|
||||
|
||||
export interface UseWorldGen {
|
||||
status: WorldGenStatus;
|
||||
entities: WorldEntityCardView[];
|
||||
generate: (projectId: string, brief: string) => Promise<void>;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
// 世界观设计器:POST /world/generate → 预览实体(不入库)。
|
||||
export function useWorldGen(): UseWorldGen {
|
||||
const [status, setStatus] = useState<WorldGenStatus>("idle");
|
||||
const [entities, setEntities] = useState<WorldEntityCardView[]>([]);
|
||||
const toast = useToast();
|
||||
|
||||
const generate = useCallback<UseWorldGen["generate"]>(
|
||||
async (projectId, brief) => {
|
||||
setStatus("generating");
|
||||
setEntities([]);
|
||||
try {
|
||||
const { data, error } = await api.POST(
|
||||
"/projects/{project_id}/world/generate",
|
||||
{
|
||||
params: { path: { project_id: projectId } },
|
||||
body: buildWorldGenerateRequest(brief),
|
||||
},
|
||||
);
|
||||
if (error || !data) {
|
||||
setStatus("error");
|
||||
toast(generationErrorMessage(error), "error");
|
||||
return;
|
||||
}
|
||||
setEntities(data.entities ?? []);
|
||||
setStatus("done");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
toast("生成请求异常,请检查网络。", "error");
|
||||
}
|
||||
},
|
||||
[toast],
|
||||
);
|
||||
|
||||
const reset = useCallback((): void => {
|
||||
setStatus("idle");
|
||||
setEntities([]);
|
||||
}, []);
|
||||
|
||||
return { status, entities, generate, reset };
|
||||
}
|
||||
150
apps/web/lib/jobs/job.test.ts
Normal file
150
apps/web/lib/jobs/job.test.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
initialPollState,
|
||||
isTerminal,
|
||||
narrowJob,
|
||||
reducePoll,
|
||||
styleLearnResult,
|
||||
type JobView,
|
||||
} from "./job";
|
||||
|
||||
describe("narrowJob", () => {
|
||||
it("narrows a well-formed loose dict into JobView", () => {
|
||||
const job = narrowJob({
|
||||
id: "j1",
|
||||
kind: "style_learn",
|
||||
status: "running",
|
||||
progress: 42,
|
||||
result: null,
|
||||
error: null,
|
||||
});
|
||||
expect(job).toEqual({
|
||||
id: "j1",
|
||||
kind: "style_learn",
|
||||
status: "running",
|
||||
progress: 42,
|
||||
result: null,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back safely for missing/wrong-typed fields", () => {
|
||||
expect(narrowJob({})).toEqual({
|
||||
id: "",
|
||||
kind: "",
|
||||
status: "queued",
|
||||
progress: 0,
|
||||
result: null,
|
||||
error: null,
|
||||
});
|
||||
// unknown status → queued; out-of-range progress clamped.
|
||||
expect(narrowJob({ status: "weird", progress: 250 }).status).toBe("queued");
|
||||
expect(narrowJob({ progress: 250 }).progress).toBe(100);
|
||||
expect(narrowJob({ progress: -5 }).progress).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps result only when an object (not array)", () => {
|
||||
expect(narrowJob({ result: { version: 2 } }).result).toEqual({ version: 2 });
|
||||
expect(narrowJob({ result: [1, 2] }).result).toBeNull();
|
||||
});
|
||||
|
||||
it("narrows non-object input", () => {
|
||||
expect(narrowJob(null).status).toBe("queued");
|
||||
expect(narrowJob("nope").id).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isTerminal", () => {
|
||||
it("done and failed are terminal", () => {
|
||||
expect(isTerminal("done")).toBe(true);
|
||||
expect(isTerminal("failed")).toBe(true);
|
||||
expect(isTerminal("queued")).toBe(false);
|
||||
expect(isTerminal("running")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reducePoll", () => {
|
||||
const job = (over: Partial<JobView>): JobView => ({
|
||||
id: "j1",
|
||||
kind: "style_learn",
|
||||
status: "running",
|
||||
progress: 0,
|
||||
result: null,
|
||||
error: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
it("stays polling for queued/running and tracks progress", () => {
|
||||
const out = reducePoll(initialPollState, {
|
||||
type: "tick",
|
||||
job: job({ status: "running", progress: 30 }),
|
||||
});
|
||||
expect(out.status).toBe("polling");
|
||||
expect(out.progress).toBe(30);
|
||||
});
|
||||
|
||||
it("transitions to done with progress 100", () => {
|
||||
const out = reducePoll(initialPollState, {
|
||||
type: "tick",
|
||||
job: job({ status: "done", result: { version: 2, dims_count: 16 } }),
|
||||
});
|
||||
expect(out.status).toBe("done");
|
||||
expect(out.progress).toBe(100);
|
||||
expect(out.job?.result).toEqual({ version: 2, dims_count: 16 });
|
||||
});
|
||||
|
||||
it("transitions to error on failed, using job.error", () => {
|
||||
const out = reducePoll(initialPollState, {
|
||||
type: "tick",
|
||||
job: job({ status: "failed", error: "网关爆炸", progress: 20 }),
|
||||
});
|
||||
expect(out.status).toBe("error");
|
||||
expect(out.error).toBe("网关爆炸");
|
||||
expect(out.progress).toBe(20);
|
||||
});
|
||||
|
||||
it("defaults error text when failed without error string", () => {
|
||||
const out = reducePoll(initialPollState, {
|
||||
type: "tick",
|
||||
job: job({ status: "failed", error: null }),
|
||||
});
|
||||
expect(out.error).toBe("任务失败");
|
||||
});
|
||||
|
||||
it("handles a fail action (poll request failure)", () => {
|
||||
const out = reducePoll(initialPollState, {
|
||||
type: "fail",
|
||||
error: "网络断了",
|
||||
});
|
||||
expect(out.status).toBe("error");
|
||||
expect(out.error).toBe("网络断了");
|
||||
});
|
||||
});
|
||||
|
||||
describe("styleLearnResult", () => {
|
||||
it("extracts version + dims_count from done result", () => {
|
||||
const out = styleLearnResult({
|
||||
id: "j",
|
||||
kind: "style_learn",
|
||||
status: "done",
|
||||
progress: 100,
|
||||
result: { version: 3, dims_count: 16 },
|
||||
error: null,
|
||||
});
|
||||
expect(out).toEqual({ version: 3, dimsCount: 16 });
|
||||
});
|
||||
|
||||
it("returns nulls for missing result fields", () => {
|
||||
expect(
|
||||
styleLearnResult({
|
||||
id: "j",
|
||||
kind: "style_learn",
|
||||
status: "done",
|
||||
progress: 100,
|
||||
result: null,
|
||||
error: null,
|
||||
}),
|
||||
).toEqual({ version: null, dimsCount: null });
|
||||
});
|
||||
});
|
||||
111
apps/web/lib/jobs/job.ts
Normal file
111
apps/web/lib/jobs/job.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
// 长任务(jobs)轮询纯逻辑:把松散 `GET /jobs/{id}` 响应安全收窄成 JobView,
|
||||
// 并提供一个 setTimeout 轮询状态机的纯 reducer(C3.5 / ARCH §7.4)。
|
||||
// schema 把 GET /jobs/{id} 标成 {[k]:unknown}(T0.3 裸端点),这里安全narrow。
|
||||
|
||||
// 后端 job 状态机(C3.5):queued → running → done|failed。
|
||||
export type JobStatus = "queued" | "running" | "done" | "failed";
|
||||
|
||||
export interface JobView {
|
||||
id: string;
|
||||
kind: string;
|
||||
status: JobStatus;
|
||||
progress: number;
|
||||
result: Record<string, unknown> | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function asString(v: unknown, fallback = ""): string {
|
||||
return typeof v === "string" ? v : fallback;
|
||||
}
|
||||
|
||||
function asStatus(v: unknown): JobStatus {
|
||||
return v === "running" || v === "done" || v === "failed" || v === "queued"
|
||||
? v
|
||||
: "queued";
|
||||
}
|
||||
|
||||
function asProgress(v: unknown): number {
|
||||
if (typeof v !== "number" || !Number.isFinite(v)) return 0;
|
||||
return Math.min(100, Math.max(0, Math.round(v)));
|
||||
}
|
||||
|
||||
function asResult(v: unknown): Record<string, unknown> | null {
|
||||
return typeof v === "object" && v !== null && !Array.isArray(v)
|
||||
? (v as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
// 把松散 GET /jobs/{id} 响应收窄成 JobView(缺字段给安全默认)。
|
||||
export function narrowJob(raw: unknown): JobView {
|
||||
const dict =
|
||||
typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
|
||||
return {
|
||||
id: asString(dict["id"]),
|
||||
kind: asString(dict["kind"]),
|
||||
status: asStatus(dict["status"]),
|
||||
progress: asProgress(dict["progress"]),
|
||||
result: asResult(dict["result"]),
|
||||
error: typeof dict["error"] === "string" ? (dict["error"] as string) : null,
|
||||
};
|
||||
}
|
||||
|
||||
// 学文风 job 完成态的 result:{version, dims_count}(C3 扩 work 摘要)。
|
||||
export interface StyleLearnResult {
|
||||
version: number | null;
|
||||
dimsCount: number | null;
|
||||
}
|
||||
|
||||
export function styleLearnResult(job: JobView): StyleLearnResult {
|
||||
const r = job.result ?? {};
|
||||
const version = typeof r["version"] === "number" ? r["version"] : null;
|
||||
const dimsCount =
|
||||
typeof r["dims_count"] === "number" ? r["dims_count"] : null;
|
||||
return { version, dimsCount };
|
||||
}
|
||||
|
||||
// 轮询状态机:polling(queued/running)→ done | error(done/failed)。
|
||||
export type PollStatus = "polling" | "done" | "error";
|
||||
|
||||
export interface PollState {
|
||||
status: PollStatus;
|
||||
progress: number;
|
||||
job: JobView | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export const initialPollState: PollState = {
|
||||
status: "polling",
|
||||
progress: 0,
|
||||
job: null,
|
||||
error: null,
|
||||
};
|
||||
|
||||
export type PollAction =
|
||||
| { type: "tick"; job: JobView }
|
||||
| { type: "fail"; error: string };
|
||||
|
||||
// 纯 reducer:吃一次轮询结果,映射成轮询状态。
|
||||
// done → 终态 done;failed → 终态 error(用 job.error 文案);其余 → 继续 polling。
|
||||
export function reducePoll(state: PollState, action: PollAction): PollState {
|
||||
if (action.type === "fail") {
|
||||
return { ...state, status: "error", error: action.error };
|
||||
}
|
||||
const { job } = action;
|
||||
if (job.status === "done") {
|
||||
return { status: "done", progress: 100, job, error: null };
|
||||
}
|
||||
if (job.status === "failed") {
|
||||
return {
|
||||
status: "error",
|
||||
progress: job.progress,
|
||||
job,
|
||||
error: job.error ?? "任务失败",
|
||||
};
|
||||
}
|
||||
return { status: "polling", progress: job.progress, job, error: null };
|
||||
}
|
||||
|
||||
// 是否终态(停止轮询)。
|
||||
export function isTerminal(status: JobStatus): boolean {
|
||||
return status === "done" || status === "failed";
|
||||
}
|
||||
88
apps/web/lib/jobs/useJobPoll.ts
Normal file
88
apps/web/lib/jobs/useJobPoll.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useReducer, useRef } from "react";
|
||||
|
||||
import { api } from "@/lib/api/client";
|
||||
import {
|
||||
initialPollState,
|
||||
isTerminal,
|
||||
narrowJob,
|
||||
reducePoll,
|
||||
type PollState,
|
||||
} from "./job";
|
||||
|
||||
const POLL_INTERVAL_MS = 1500;
|
||||
|
||||
export interface UseJobPoll extends PollState {
|
||||
// 开始轮询某个 job(替换正在轮询的 job)。
|
||||
poll: (jobId: string) => void;
|
||||
// 复位到初始(停止当前轮询)。
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
// 轮询 GET /jobs/{job_id}:setTimeout 循环,done/failed 终态停止,卸载清理。
|
||||
// 纯状态机在 job.ts(可单测);本 hook 只负责定时器 + fetch 编排。
|
||||
export function useJobPoll(): UseJobPoll {
|
||||
const [state, dispatch] = useReducer(reducePoll, initialPollState);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const activeRef = useRef<string | null>(null);
|
||||
|
||||
const clearTimer = useCallback((): void => {
|
||||
if (timerRef.current !== null) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const tick = useCallback(
|
||||
async (jobId: string): Promise<void> => {
|
||||
if (activeRef.current !== jobId) return;
|
||||
try {
|
||||
const { data, error } = await api.GET("/jobs/{job_id}", {
|
||||
params: { path: { job_id: jobId } },
|
||||
});
|
||||
if (activeRef.current !== jobId) return; // 中途切换/卸载。
|
||||
if (error || !data) {
|
||||
dispatch({ type: "fail", error: "轮询任务状态失败" });
|
||||
return;
|
||||
}
|
||||
const job = narrowJob(data);
|
||||
dispatch({ type: "tick", job });
|
||||
if (isTerminal(job.status)) {
|
||||
activeRef.current = null;
|
||||
return;
|
||||
}
|
||||
timerRef.current = setTimeout(() => void tick(jobId), POLL_INTERVAL_MS);
|
||||
} catch (err: unknown) {
|
||||
if (activeRef.current !== jobId) return;
|
||||
const message = err instanceof Error ? err.message : "未知网络错误";
|
||||
dispatch({ type: "fail", error: message });
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const poll = useCallback(
|
||||
(jobId: string): void => {
|
||||
clearTimer();
|
||||
activeRef.current = jobId;
|
||||
void tick(jobId);
|
||||
},
|
||||
[clearTimer, tick],
|
||||
);
|
||||
|
||||
const reset = useCallback((): void => {
|
||||
clearTimer();
|
||||
activeRef.current = null;
|
||||
}, [clearTimer]);
|
||||
|
||||
// 卸载清理。
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current !== null) clearTimeout(timerRef.current);
|
||||
activeRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { ...state, poll, reset };
|
||||
}
|
||||
@@ -42,6 +42,18 @@ export interface PaceReport {
|
||||
beat_map: number[];
|
||||
}
|
||||
|
||||
// 文风漂移(第四审,C4 扩 T4.2):整体相似度 score + 段级漂移。
|
||||
export interface StyleDriftSegment {
|
||||
idx: number;
|
||||
score: number;
|
||||
label: string | null;
|
||||
}
|
||||
|
||||
export interface StyleDriftReport {
|
||||
score: number;
|
||||
segments: StyleDriftSegment[];
|
||||
}
|
||||
|
||||
export interface SectionEvent {
|
||||
event: "section";
|
||||
data: { name: string; status: SectionStatus };
|
||||
@@ -68,6 +80,17 @@ export interface PaceEvent {
|
||||
beat_map: number[];
|
||||
};
|
||||
}
|
||||
export interface StyleEvent {
|
||||
event: "style";
|
||||
data: {
|
||||
score: number;
|
||||
segments: {
|
||||
idx: number;
|
||||
score: number;
|
||||
label?: string | null;
|
||||
}[];
|
||||
};
|
||||
}
|
||||
export interface DoneEvent {
|
||||
event: "done";
|
||||
data: { length: number };
|
||||
@@ -81,6 +104,7 @@ export type ReviewSseEvent =
|
||||
| ConflictEvent
|
||||
| ForeshadowEvent
|
||||
| PaceEvent
|
||||
| StyleEvent
|
||||
| DoneEvent
|
||||
| ErrorEvent;
|
||||
|
||||
@@ -89,6 +113,7 @@ const KNOWN_EVENTS = new Set([
|
||||
"conflict",
|
||||
"foreshadow",
|
||||
"pace",
|
||||
"style",
|
||||
"done",
|
||||
"error",
|
||||
]);
|
||||
@@ -160,6 +185,7 @@ export interface ReviewStreamState {
|
||||
conflicts: ReviewConflict[];
|
||||
foreshadow: ForeshadowSuggestion[];
|
||||
pace: PaceReport | null;
|
||||
style: StyleDriftReport | null;
|
||||
error: { code: string; message: string; request_id?: string | null } | null;
|
||||
}
|
||||
|
||||
@@ -169,6 +195,7 @@ export const initialReviewState: ReviewStreamState = {
|
||||
conflicts: [],
|
||||
foreshadow: [],
|
||||
pace: null,
|
||||
style: null,
|
||||
error: null,
|
||||
};
|
||||
|
||||
@@ -202,6 +229,19 @@ export function reduceReview(
|
||||
phase: "reviewing",
|
||||
pace: { ...event.data },
|
||||
};
|
||||
case "style":
|
||||
return {
|
||||
...state,
|
||||
phase: "reviewing",
|
||||
style: {
|
||||
score: event.data.score,
|
||||
segments: event.data.segments.map((s) => ({
|
||||
idx: s.idx,
|
||||
score: s.score,
|
||||
label: s.label ?? null,
|
||||
})),
|
||||
},
|
||||
};
|
||||
case "done":
|
||||
return { ...state, phase: "done" };
|
||||
case "error":
|
||||
|
||||
70
apps/web/lib/review/style-sse.test.ts
Normal file
70
apps/web/lib/review/style-sse.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
initialReviewState,
|
||||
parseReviewBlock,
|
||||
reduceReview,
|
||||
type ReviewSseEvent,
|
||||
} from "./sse";
|
||||
|
||||
describe("parseReviewBlock — style (C4 扩 T4.2)", () => {
|
||||
it("parses a style frame", () => {
|
||||
const evt = parseReviewBlock(
|
||||
'event:style\ndata:{"score":87,"segments":[{"idx":3,"score":60,"label":"口语化"}]}',
|
||||
);
|
||||
expect(evt).toEqual({
|
||||
event: "style",
|
||||
data: {
|
||||
score: 87,
|
||||
segments: [{ idx: 3, score: 60, label: "口语化" }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a degrade态 style frame (无指纹 score=100/空段)", () => {
|
||||
expect(parseReviewBlock('event:style\ndata:{"score":100,"segments":[]}')).toEqual(
|
||||
{ event: "style", data: { score: 100, segments: [] } },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reduceReview — style (replace-style like pace)", () => {
|
||||
it("sets style report and marks reviewing", () => {
|
||||
const evt: ReviewSseEvent = {
|
||||
event: "style",
|
||||
data: { score: 80, segments: [{ idx: 1, score: 50, label: "突兀" }] },
|
||||
};
|
||||
const out = reduceReview(initialReviewState, evt);
|
||||
expect(out.phase).toBe("reviewing");
|
||||
expect(out.style).toEqual({
|
||||
score: 80,
|
||||
segments: [{ idx: 1, score: 50, label: "突兀" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces (not accumulates) the style report and defaults label null", () => {
|
||||
const first = reduceReview(initialReviewState, {
|
||||
event: "style",
|
||||
data: { score: 70, segments: [{ idx: 0, score: 40 }] },
|
||||
});
|
||||
const second = reduceReview(first, {
|
||||
event: "style",
|
||||
data: { score: 95, segments: [] },
|
||||
});
|
||||
expect(first.style?.segments[0]).toEqual({ idx: 0, score: 40, label: null });
|
||||
expect(second.style).toEqual({ score: 95, segments: [] });
|
||||
});
|
||||
|
||||
it("leaves style untouched on a section event", () => {
|
||||
const seeded = reduceReview(initialReviewState, {
|
||||
event: "style",
|
||||
data: { score: 88, segments: [] },
|
||||
});
|
||||
const out = reduceReview(seeded, {
|
||||
event: "section",
|
||||
data: { name: "style", status: "done" },
|
||||
});
|
||||
expect(out.style).toEqual({ score: 88, segments: [] });
|
||||
expect(out.sections).toEqual([{ name: "style", status: "done" }]);
|
||||
});
|
||||
});
|
||||
@@ -11,12 +11,14 @@ import {
|
||||
type PaceReport,
|
||||
type ReviewConflict,
|
||||
type ReviewStreamState,
|
||||
type StyleDriftReport,
|
||||
} from "./sse";
|
||||
|
||||
export interface ReviewSeed {
|
||||
conflicts: ReviewConflict[];
|
||||
foreshadow: ForeshadowSuggestion[];
|
||||
pace: PaceReport | null;
|
||||
style: StyleDriftReport | null;
|
||||
}
|
||||
|
||||
type Action =
|
||||
@@ -46,6 +48,7 @@ function reducer(state: ReviewStreamState, action: Action): ReviewStreamState {
|
||||
conflicts: action.seed.conflicts,
|
||||
foreshadow: action.seed.foreshadow,
|
||||
pace: action.seed.pace,
|
||||
style: action.seed.style,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
|
||||
54
apps/web/lib/rules/rules.test.ts
Normal file
54
apps/web/lib/rules/rules.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { RuleView } from "@/lib/api/types";
|
||||
import {
|
||||
buildRuleCreateRequest,
|
||||
groupByLevel,
|
||||
hasUsableContent,
|
||||
isRuleLevel,
|
||||
RULE_LEVELS,
|
||||
} from "./rules";
|
||||
|
||||
describe("isRuleLevel", () => {
|
||||
it("accepts the four known levels only", () => {
|
||||
for (const lv of RULE_LEVELS) expect(isRuleLevel(lv)).toBe(true);
|
||||
expect(isRuleLevel("bogus")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupByLevel", () => {
|
||||
it("groups rules by level, unknown level falls to project", () => {
|
||||
const rules: RuleView[] = [
|
||||
{ level: "global", content: "g1" },
|
||||
{ level: "project", content: "p1" },
|
||||
{ level: "weird", content: "x1" },
|
||||
];
|
||||
const groups = groupByLevel(rules);
|
||||
expect(groups.global).toEqual([{ level: "global", content: "g1" }]);
|
||||
expect(groups.project).toEqual([
|
||||
{ level: "project", content: "p1" },
|
||||
{ level: "weird", content: "x1" },
|
||||
]);
|
||||
expect(groups.genre).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles undefined", () => {
|
||||
expect(groupByLevel(undefined).style).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildRuleCreateRequest", () => {
|
||||
it("trims content", () => {
|
||||
expect(buildRuleCreateRequest("project", " 禁现代词 ")).toEqual({
|
||||
level: "project",
|
||||
content: "禁现代词",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasUsableContent", () => {
|
||||
it("true only when non-blank", () => {
|
||||
expect(hasUsableContent(" ")).toBe(false);
|
||||
expect(hasUsableContent(" x ")).toBe(true);
|
||||
});
|
||||
});
|
||||
58
apps/web/lib/rules/rules.ts
Normal file
58
apps/web/lib/rules/rules.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
// 规则页纯逻辑:四级合并展示、请求体组装、级别文案。
|
||||
// 对齐 C3 扩(GET/POST .../rules)。纯逻辑,便于 node 环境单测。
|
||||
|
||||
import type { RuleCreateRequest, RuleView } from "@/lib/api/types";
|
||||
|
||||
// 四级规则(global→genre→style→project,越具体越优先;对齐后端 RuleCreateRequest Literal)。
|
||||
export type RuleLevel = "global" | "genre" | "style" | "project";
|
||||
|
||||
export const RULE_LEVELS: readonly RuleLevel[] = [
|
||||
"global",
|
||||
"genre",
|
||||
"style",
|
||||
"project",
|
||||
] as const;
|
||||
|
||||
export const RULE_LEVEL_LABELS: Record<RuleLevel, string> = {
|
||||
global: "全局",
|
||||
genre: "题材",
|
||||
style: "文风",
|
||||
project: "本作",
|
||||
};
|
||||
|
||||
export function isRuleLevel(v: string): v is RuleLevel {
|
||||
return (
|
||||
v === "global" || v === "genre" || v === "style" || v === "project"
|
||||
);
|
||||
}
|
||||
|
||||
export type RuleGroups = Record<RuleLevel, RuleView[]>;
|
||||
|
||||
function emptyGroups(): RuleGroups {
|
||||
return { global: [], genre: [], style: [], project: [] };
|
||||
}
|
||||
|
||||
// 按 level 分组(未知 level 落 project 兜底,保持服务端顺序)。
|
||||
export function groupByLevel(
|
||||
rules: readonly RuleView[] | undefined,
|
||||
): RuleGroups {
|
||||
const groups = emptyGroups();
|
||||
for (const rule of rules ?? []) {
|
||||
const level = isRuleLevel(rule.level) ? rule.level : "project";
|
||||
groups[level] = [...groups[level], rule];
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
// 组装新增规则请求体(去空白)。
|
||||
export function buildRuleCreateRequest(
|
||||
level: RuleLevel,
|
||||
content: string,
|
||||
): RuleCreateRequest {
|
||||
return { level, content: content.trim() };
|
||||
}
|
||||
|
||||
// 内容是否可提交(非空白)。
|
||||
export function hasUsableContent(content: string): boolean {
|
||||
return content.trim().length > 0;
|
||||
}
|
||||
62
apps/web/lib/rules/useRules.ts
Normal file
62
apps/web/lib/rules/useRules.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api/client";
|
||||
import { useToast } from "@/components/Toast";
|
||||
import type { RuleView } from "@/lib/api/types";
|
||||
import { buildRuleCreateRequest, hasUsableContent, type RuleLevel } from "./rules";
|
||||
|
||||
export interface UseRules {
|
||||
items: RuleView[];
|
||||
busy: boolean;
|
||||
// 新增一条规则(乐观追加 + 失败回滚 + Toast)。
|
||||
add: (
|
||||
projectId: string,
|
||||
level: RuleLevel,
|
||||
content: string,
|
||||
) => Promise<boolean>;
|
||||
}
|
||||
|
||||
// 规则页(UX §7):列出 + 新增规则。乐观追加,失败回滚(仿 useForeshadow)。
|
||||
export function useRules(initial: RuleView[]): UseRules {
|
||||
const [items, setItems] = useState<RuleView[]>(initial);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
const add = useCallback<UseRules["add"]>(
|
||||
async (projectId, level, content) => {
|
||||
if (!hasUsableContent(content)) {
|
||||
toast("请填写规则正文。", "error");
|
||||
return false;
|
||||
}
|
||||
const snapshot = items;
|
||||
const optimistic: RuleView = { level, content: content.trim() };
|
||||
setItems((prev) => [...prev, optimistic]);
|
||||
setBusy(true);
|
||||
try {
|
||||
const { data, error } = await api.POST(
|
||||
"/projects/{project_id}/rules",
|
||||
{
|
||||
params: { path: { project_id: projectId } },
|
||||
body: buildRuleCreateRequest(level, content),
|
||||
},
|
||||
);
|
||||
if (error || !data) {
|
||||
setItems(snapshot); // 回滚
|
||||
toast("新增规则失败,请稍后重试。", "error");
|
||||
return false;
|
||||
}
|
||||
// 用服务端权威行替换乐观行(去掉乐观项、追加返回项)。
|
||||
setItems((prev) => [...prev.slice(0, snapshot.length), data]);
|
||||
toast("已新增规则", "success");
|
||||
return true;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[items, toast],
|
||||
);
|
||||
|
||||
return { items, busy, add };
|
||||
}
|
||||
191
apps/web/lib/settings/kimiOauth.test.ts
Normal file
191
apps/web/lib/settings/kimiOauth.test.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { JobView, PollState } from "@/lib/jobs/job";
|
||||
import {
|
||||
authOpenUrl,
|
||||
connectPhase,
|
||||
formatExpiresAt,
|
||||
jobConnected,
|
||||
KIMI_CODE_MODEL,
|
||||
KIMI_CODE_PROVIDER,
|
||||
toConnectionStatus,
|
||||
toDeviceDisplay,
|
||||
} from "./kimiOauth";
|
||||
|
||||
const poll = (over: Partial<PollState>): PollState => ({
|
||||
status: "polling",
|
||||
progress: 0,
|
||||
job: null,
|
||||
error: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
const job = (over: Partial<JobView>): JobView => ({
|
||||
id: "j1",
|
||||
kind: "kimi_oauth",
|
||||
status: "done",
|
||||
progress: 100,
|
||||
result: null,
|
||||
error: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("KIMI_CODE constants", () => {
|
||||
it("uses the OAuth provider + coding model names", () => {
|
||||
expect(KIMI_CODE_PROVIDER).toBe("kimi-code");
|
||||
expect(KIMI_CODE_MODEL).toBe("kimi-for-coding");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toDeviceDisplay", () => {
|
||||
it("maps start response fields", () => {
|
||||
const d = toDeviceDisplay({
|
||||
job_id: "abc",
|
||||
user_code: "WXYZ-1234",
|
||||
verification_uri: "https://auth.kimi.com/device",
|
||||
verification_uri_complete: "https://auth.kimi.com/device?code=WXYZ-1234",
|
||||
expires_in: 600,
|
||||
interval: 5,
|
||||
});
|
||||
expect(d).toEqual({
|
||||
userCode: "WXYZ-1234",
|
||||
verificationUri: "https://auth.kimi.com/device",
|
||||
verificationUriComplete: "https://auth.kimi.com/device?code=WXYZ-1234",
|
||||
expiresIn: 600,
|
||||
interval: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults missing complete uri to null", () => {
|
||||
const d = toDeviceDisplay({
|
||||
job_id: "abc",
|
||||
user_code: "CODE",
|
||||
verification_uri: "https://auth.kimi.com/device",
|
||||
verification_uri_complete: null,
|
||||
expires_in: 600,
|
||||
interval: 5,
|
||||
});
|
||||
expect(d.verificationUriComplete).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("authOpenUrl", () => {
|
||||
it("prefers the complete uri when present", () => {
|
||||
expect(
|
||||
authOpenUrl({
|
||||
userCode: "C",
|
||||
verificationUri: "https://auth.kimi.com/device",
|
||||
verificationUriComplete: "https://auth.kimi.com/device?code=C",
|
||||
expiresIn: 600,
|
||||
interval: 5,
|
||||
}),
|
||||
).toBe("https://auth.kimi.com/device?code=C");
|
||||
});
|
||||
|
||||
it("falls back to the plain uri", () => {
|
||||
expect(
|
||||
authOpenUrl({
|
||||
userCode: "C",
|
||||
verificationUri: "https://auth.kimi.com/device",
|
||||
verificationUriComplete: null,
|
||||
expiresIn: 600,
|
||||
interval: 5,
|
||||
}),
|
||||
).toBe("https://auth.kimi.com/device");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toConnectionStatus", () => {
|
||||
it("narrows connected + expires_at", () => {
|
||||
expect(
|
||||
toConnectionStatus({ connected: true, expires_at: "2026-07-01T00:00:00Z" }),
|
||||
).toEqual({ connected: true, expiresAt: "2026-07-01T00:00:00Z" });
|
||||
});
|
||||
|
||||
it("defaults non-string expires_at to null", () => {
|
||||
expect(toConnectionStatus({ connected: false })).toEqual({
|
||||
connected: false,
|
||||
expiresAt: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("jobConnected", () => {
|
||||
it("true only when result.connected === true (no token expected)", () => {
|
||||
expect(jobConnected(job({ result: { connected: true, provider: "kimi-code" } }))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(jobConnected(job({ result: { connected: false } }))).toBe(false);
|
||||
expect(jobConnected(job({ result: null }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("connectPhase", () => {
|
||||
it("not started + not connected → idle", () => {
|
||||
expect(
|
||||
connectPhase({ started: false, connected: false, poll: poll({}) }),
|
||||
).toBe("idle");
|
||||
});
|
||||
|
||||
it("not started + connected (status query) → connected", () => {
|
||||
expect(
|
||||
connectPhase({ started: false, connected: true, poll: poll({}) }),
|
||||
).toBe("connected");
|
||||
});
|
||||
|
||||
it("started + polling → awaiting", () => {
|
||||
expect(
|
||||
connectPhase({
|
||||
started: true,
|
||||
connected: false,
|
||||
poll: poll({ status: "polling" }),
|
||||
}),
|
||||
).toBe("awaiting");
|
||||
});
|
||||
|
||||
it("started + done + job.connected → connected", () => {
|
||||
expect(
|
||||
connectPhase({
|
||||
started: true,
|
||||
connected: false,
|
||||
poll: poll({
|
||||
status: "done",
|
||||
job: job({ result: { connected: true, provider: "kimi-code" } }),
|
||||
}),
|
||||
}),
|
||||
).toBe("connected");
|
||||
});
|
||||
|
||||
it("started + done but job not connected → error", () => {
|
||||
expect(
|
||||
connectPhase({
|
||||
started: true,
|
||||
connected: false,
|
||||
poll: poll({ status: "done", job: job({ result: { connected: false } }) }),
|
||||
}),
|
||||
).toBe("error");
|
||||
});
|
||||
|
||||
it("started + poll error (expired/denied/network) → error", () => {
|
||||
expect(
|
||||
connectPhase({
|
||||
started: true,
|
||||
connected: false,
|
||||
poll: poll({ status: "error", error: "授权已过期" }),
|
||||
}),
|
||||
).toBe("error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatExpiresAt", () => {
|
||||
it("returns null for null/invalid input", () => {
|
||||
expect(formatExpiresAt(null)).toBeNull();
|
||||
expect(formatExpiresAt("not-a-date")).toBeNull();
|
||||
});
|
||||
|
||||
it("formats a valid ISO timestamp to a non-empty string", () => {
|
||||
const out = formatExpiresAt("2026-07-01T08:30:00Z");
|
||||
expect(typeof out).toBe("string");
|
||||
expect(out).not.toBe("");
|
||||
});
|
||||
});
|
||||
101
apps/web/lib/settings/kimiOauth.ts
Normal file
101
apps/web/lib/settings/kimiOauth.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
// Kimi Code OAuth device-flow 连接态纯逻辑(C3 扩 K1.3,K1.4 前端)。
|
||||
// 把「连接状态查询」「device 启动响应」「job 轮询结果」收窄/归一成一个
|
||||
// 可渲染的连接阶段(connect phase),组件只读结果、不含分支逻辑。
|
||||
// 纯函数 + node-env 单测;token 永不出现(job 结果只 {connected, provider})。
|
||||
|
||||
import type { JobView, PollState } from "@/lib/jobs/job";
|
||||
import type {
|
||||
OAuthStartResponse,
|
||||
OAuthStatusResponse,
|
||||
} from "@/lib/api/types";
|
||||
|
||||
// Kimi Code 是 OAuth 订阅 plan 提供商(与 API-key provider `kimi` 分离)。
|
||||
export const KIMI_CODE_PROVIDER = "kimi-code";
|
||||
export const KIMI_CODE_MODEL = "kimi-for-coding";
|
||||
|
||||
// 连接流阶段:
|
||||
// - idle:未发起且未连接(展示「连接」按钮)。
|
||||
// - connected:状态查询/job 完成显示已连接(展示「断开」)。
|
||||
// - awaiting:已拿 device code,正在等待用户在浏览器授权 + 轮询 job。
|
||||
// - error:device 启动失败 / 轮询失败 / 过期 / 拒绝(展示错误 + 允许重试)。
|
||||
export type ConnectPhase = "idle" | "awaiting" | "connected" | "error";
|
||||
|
||||
// device 启动后用户面要展示的信息(无 token)。
|
||||
export interface DeviceDisplay {
|
||||
userCode: string;
|
||||
verificationUri: string;
|
||||
verificationUriComplete: string | null;
|
||||
expiresIn: number;
|
||||
interval: number;
|
||||
}
|
||||
|
||||
// 把 OAuthStartResponse 收窄成展示用 DeviceDisplay(缺字段给安全默认)。
|
||||
export function toDeviceDisplay(res: OAuthStartResponse): DeviceDisplay {
|
||||
return {
|
||||
userCode: res.user_code,
|
||||
verificationUri: res.verification_uri,
|
||||
verificationUriComplete: res.verification_uri_complete ?? null,
|
||||
expiresIn: typeof res.expires_in === "number" ? res.expires_in : 0,
|
||||
interval: typeof res.interval === "number" ? res.interval : 5,
|
||||
};
|
||||
}
|
||||
|
||||
// 优先打开的授权 URL:有 complete(带 user_code 预填)就用它,否则裸 verification_uri。
|
||||
export function authOpenUrl(device: DeviceDisplay): string {
|
||||
return device.verificationUriComplete ?? device.verificationUri;
|
||||
}
|
||||
|
||||
// 连接状态(GET .../oauth/status)收窄。
|
||||
export interface ConnectionStatus {
|
||||
connected: boolean;
|
||||
expiresAt: string | null;
|
||||
}
|
||||
|
||||
export function toConnectionStatus(
|
||||
res: OAuthStatusResponse,
|
||||
): ConnectionStatus {
|
||||
return {
|
||||
connected: res.connected === true,
|
||||
expiresAt: typeof res.expires_at === "string" ? res.expires_at : null,
|
||||
};
|
||||
}
|
||||
|
||||
// kimi_oauth job 完成态的 result:{connected, provider}(**绝无 token**)。
|
||||
export function jobConnected(job: JobView): boolean {
|
||||
return job.result?.["connected"] === true;
|
||||
}
|
||||
|
||||
// 把「是否已发起连接 + 轮询状态」映射成连接阶段。
|
||||
// - 未发起(started=false):connected ? connected : idle。
|
||||
// - 已发起:done 且 job.connected → connected;error → error;否则 awaiting。
|
||||
export function connectPhase(args: {
|
||||
started: boolean;
|
||||
connected: boolean;
|
||||
poll: PollState;
|
||||
}): ConnectPhase {
|
||||
const { started, connected, poll } = args;
|
||||
if (!started) {
|
||||
return connected ? "connected" : "idle";
|
||||
}
|
||||
if (poll.status === "done") {
|
||||
return poll.job !== null && jobConnected(poll.job) ? "connected" : "error";
|
||||
}
|
||||
if (poll.status === "error") {
|
||||
return "error";
|
||||
}
|
||||
return "awaiting";
|
||||
}
|
||||
|
||||
// 把 ISO8601 过期时刻格式化成可读文案(无效/缺省→null)。
|
||||
export function formatExpiresAt(iso: string | null): string | null {
|
||||
if (!iso) return null;
|
||||
const ms = Date.parse(iso);
|
||||
if (Number.isNaN(ms)) return null;
|
||||
return new Date(ms).toLocaleString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
107
apps/web/lib/settings/providers.test.ts
Normal file
107
apps/web/lib/settings/providers.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
API_KEY_PROVIDERS,
|
||||
KNOWN_PROVIDERS,
|
||||
applyProviderChange,
|
||||
defaultModelFor,
|
||||
draftsToRoutingInput,
|
||||
toRoutingDrafts,
|
||||
type RoutingDraft,
|
||||
} from "./providers";
|
||||
|
||||
describe("KNOWN_PROVIDERS", () => {
|
||||
it("includes kimi-code as an OAuth provider with a fixed model", () => {
|
||||
const kc = KNOWN_PROVIDERS.find((p) => p.id === "kimi-code");
|
||||
expect(kc).toBeDefined();
|
||||
expect(kc?.auth).toBe("oauth");
|
||||
expect(kc?.defaultModel).toBe("kimi-for-coding");
|
||||
});
|
||||
|
||||
it("API_KEY_PROVIDERS excludes the OAuth provider", () => {
|
||||
expect(API_KEY_PROVIDERS.some((p) => p.id === "kimi-code")).toBe(false);
|
||||
expect(API_KEY_PROVIDERS.some((p) => p.id === "deepseek")).toBe(true);
|
||||
});
|
||||
|
||||
it("includes kimi-code-key as an api_key provider with a fixed model", () => {
|
||||
const kck = KNOWN_PROVIDERS.find((p) => p.id === "kimi-code-key");
|
||||
expect(kck).toBeDefined();
|
||||
expect(kck?.auth).toBe("api_key");
|
||||
expect(kck?.defaultModel).toBe("kimi-for-coding");
|
||||
});
|
||||
|
||||
it("API_KEY_PROVIDERS includes the static-key Kimi Code provider", () => {
|
||||
expect(API_KEY_PROVIDERS.some((p) => p.id === "kimi-code-key")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultModelFor", () => {
|
||||
it("returns the OAuth provider's fixed model", () => {
|
||||
expect(defaultModelFor("kimi-code")).toBe("kimi-for-coding");
|
||||
});
|
||||
|
||||
it("returns the static-key Kimi Code provider's fixed model", () => {
|
||||
expect(defaultModelFor("kimi-code-key")).toBe("kimi-for-coding");
|
||||
});
|
||||
|
||||
it("returns empty string for api_key providers / unknown", () => {
|
||||
expect(defaultModelFor("deepseek")).toBe("");
|
||||
expect(defaultModelFor("nope")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toRoutingDrafts", () => {
|
||||
it("expands to all tiers, filling missing ones empty", () => {
|
||||
const drafts = toRoutingDrafts([
|
||||
{ tier: "writer", provider: "deepseek", model: "deepseek-chat" },
|
||||
]);
|
||||
expect(drafts.map((d) => d.tier)).toEqual(["writer", "analyst", "light"]);
|
||||
expect(drafts[0]).toEqual({
|
||||
tier: "writer",
|
||||
provider: "deepseek",
|
||||
model: "deepseek-chat",
|
||||
});
|
||||
expect(drafts[1]).toEqual({ tier: "analyst", provider: "", model: "" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyProviderChange", () => {
|
||||
it("auto-fills the OAuth provider's fixed model", () => {
|
||||
const next = applyProviderChange(
|
||||
{ tier: "writer", provider: "", model: "" },
|
||||
"kimi-code",
|
||||
);
|
||||
expect(next).toEqual({
|
||||
tier: "writer",
|
||||
provider: "kimi-code",
|
||||
model: "kimi-for-coding",
|
||||
});
|
||||
});
|
||||
|
||||
it("clears the model when switching to an api_key provider", () => {
|
||||
const next = applyProviderChange(
|
||||
{ tier: "writer", provider: "kimi-code", model: "kimi-for-coding" },
|
||||
"deepseek",
|
||||
);
|
||||
expect(next).toEqual({ tier: "writer", provider: "deepseek", model: "" });
|
||||
});
|
||||
|
||||
it("does not mutate the input draft", () => {
|
||||
const draft: RoutingDraft = { tier: "writer", provider: "", model: "" };
|
||||
applyProviderChange(draft, "kimi-code");
|
||||
expect(draft).toEqual({ tier: "writer", provider: "", model: "" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("draftsToRoutingInput", () => {
|
||||
it("keeps only rows with both provider and model", () => {
|
||||
const input = draftsToRoutingInput([
|
||||
{ tier: "writer", provider: "kimi-code", model: "kimi-for-coding" },
|
||||
{ tier: "analyst", provider: "", model: "" },
|
||||
{ tier: "light", provider: "deepseek", model: "" },
|
||||
]);
|
||||
expect(input).toEqual([
|
||||
{ tier: "writer", provider: "kimi-code", model: "kimi-for-coding" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,103 @@
|
||||
// 提供商鉴权方式:api_key(凭据行)或 oauth(device-flow 连接按钮,如 Kimi Code)。
|
||||
export type ProviderAuthKind = "api_key" | "oauth";
|
||||
|
||||
export interface KnownProvider {
|
||||
id: string;
|
||||
label: string;
|
||||
auth: ProviderAuthKind;
|
||||
// OAuth 提供商在档位路由里用的默认 model(API-key 提供商由用户填写/后端默认)。
|
||||
defaultModel?: string;
|
||||
}
|
||||
|
||||
// 已知提供商(UX §6.10)。后端按 provider 字符串识别;这里只是 UI 候选列表。
|
||||
export const KNOWN_PROVIDERS: { id: string; label: string }[] = [
|
||||
{ id: "anthropic", label: "Anthropic" },
|
||||
{ id: "deepseek", label: "DeepSeek" },
|
||||
{ id: "kimi", label: "Kimi" },
|
||||
{ id: "openai", label: "OpenAI" },
|
||||
{ id: "qwen", label: "通义千问" },
|
||||
{ id: "glm", label: "智谱 GLM" },
|
||||
{ id: "gemini", label: "Gemini" },
|
||||
// `kimi-code` 是 OAuth 订阅 plan 提供商(device flow,伪造客户端头,有封号风险),与 API-key
|
||||
// 的 `kimi` 分离(K1.4)。`kimi-code-key` 是同一订阅 plan 的**静态 Console Key** 路径(ToS 合规、
|
||||
// 无伪造头)——普通 api_key 提供商,固定 model `kimi-for-coding`。
|
||||
export const KNOWN_PROVIDERS: KnownProvider[] = [
|
||||
{ id: "anthropic", label: "Anthropic", auth: "api_key" },
|
||||
{ id: "deepseek", label: "DeepSeek", auth: "api_key" },
|
||||
{ id: "kimi", label: "Kimi", auth: "api_key" },
|
||||
{ id: "openai", label: "OpenAI", auth: "api_key" },
|
||||
{ id: "qwen", label: "通义千问", auth: "api_key" },
|
||||
{ id: "glm", label: "智谱 GLM", auth: "api_key" },
|
||||
{ id: "gemini", label: "Gemini", auth: "api_key" },
|
||||
{
|
||||
id: "kimi-code-key",
|
||||
label: "Kimi Code(订阅 Key)",
|
||||
auth: "api_key",
|
||||
defaultModel: "kimi-for-coding",
|
||||
},
|
||||
{
|
||||
id: "kimi-code",
|
||||
label: "Kimi Code(OAuth)",
|
||||
auth: "oauth",
|
||||
defaultModel: "kimi-for-coding",
|
||||
},
|
||||
];
|
||||
|
||||
// API-key 凭据行只展示 api_key 提供商;OAuth 提供商有独立连接区。
|
||||
export const API_KEY_PROVIDERS: KnownProvider[] = KNOWN_PROVIDERS.filter(
|
||||
(p) => p.auth === "api_key",
|
||||
);
|
||||
|
||||
export const TIER_LABELS: Record<string, string> = {
|
||||
writer: "写手档",
|
||||
analyst: "分析档",
|
||||
light: "轻量档",
|
||||
};
|
||||
|
||||
// 档位顺序(路由编辑器列出全部档位,缺省的也能配)。
|
||||
export const TIERS = ["writer", "analyst", "light"] as const;
|
||||
export type Tier = (typeof TIERS)[number];
|
||||
|
||||
// 某 provider 在路由里使用的默认 model:OAuth 提供商有固定 model,其余给空串占位。
|
||||
export function defaultModelFor(providerId: string): string {
|
||||
return (
|
||||
KNOWN_PROVIDERS.find((p) => p.id === providerId)?.defaultModel ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
// 单条档位路由的可编辑草稿(纯数据)。
|
||||
export interface RoutingDraft {
|
||||
tier: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export interface TierRouting {
|
||||
tier: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
fallback?: string[] | null;
|
||||
}
|
||||
|
||||
// 把已存路由(可能缺档位)展开成「全部档位」的可编辑草稿(缺的给空)。
|
||||
export function toRoutingDrafts(existing: TierRouting[]): RoutingDraft[] {
|
||||
const byTier = new Map(existing.map((r) => [r.tier, r]));
|
||||
return TIERS.map((tier) => {
|
||||
const row = byTier.get(tier);
|
||||
return {
|
||||
tier,
|
||||
provider: row?.provider ?? "",
|
||||
model: row?.model ?? "",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// 选择 provider 时自动套用该 provider 的默认 model(OAuth 固定 model;否则保留已填值或清空)。
|
||||
export function applyProviderChange(
|
||||
draft: RoutingDraft,
|
||||
providerId: string,
|
||||
): RoutingDraft {
|
||||
const def = defaultModelFor(providerId);
|
||||
return { ...draft, provider: providerId, model: def !== "" ? def : "" };
|
||||
}
|
||||
|
||||
// 草稿 → PUT body 的 tier_routing:只取选了 provider+model 的行(不可变)。
|
||||
export function draftsToRoutingInput(
|
||||
drafts: RoutingDraft[],
|
||||
): { tier: string; provider: string; model: string }[] {
|
||||
return drafts
|
||||
.filter((d) => d.provider.trim() !== "" && d.model.trim() !== "")
|
||||
.map((d) => ({ tier: d.tier, provider: d.provider, model: d.model }));
|
||||
}
|
||||
|
||||
139
apps/web/lib/settings/useKimiOauth.ts
Normal file
139
apps/web/lib/settings/useKimiOauth.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api/client";
|
||||
import { useToast } from "@/components/Toast";
|
||||
import { useJobPoll } from "@/lib/jobs/useJobPoll";
|
||||
import {
|
||||
authOpenUrl,
|
||||
connectPhase,
|
||||
toConnectionStatus,
|
||||
toDeviceDisplay,
|
||||
type ConnectPhase,
|
||||
type DeviceDisplay,
|
||||
} from "./kimiOauth";
|
||||
|
||||
const START = "/settings/providers/kimi-code/oauth/start";
|
||||
const DISCONNECT = "/settings/providers/kimi-code/oauth/disconnect";
|
||||
|
||||
export interface UseKimiOauth {
|
||||
// 派生连接阶段(idle/awaiting/connected/error),驱动 UI。
|
||||
phase: ConnectPhase;
|
||||
// device 启动后展示给用户的信息(user_code + 验证 URL);未启动→null。
|
||||
device: DeviceDisplay | null;
|
||||
// 已连接时的 access token 过期时刻(ISO8601;可能为 null)。
|
||||
expiresAt: string | null;
|
||||
// 任一在途请求(start/disconnect)或正在轮询。
|
||||
busy: boolean;
|
||||
// 错误文案(device 启动失败 / 轮询失败 / 过期 / 拒绝)。
|
||||
error: string | null;
|
||||
// 发起连接:POST start → 拿 user_code + job_id → 开浏览器 + 轮询 job。
|
||||
connect: () => Promise<void>;
|
||||
// 断开:POST disconnect → 复位为未连接。
|
||||
disconnect: () => Promise<void>;
|
||||
}
|
||||
|
||||
// 在浏览器打开授权页(device complete URL 优先)。SSR/无 window 时静默跳过。
|
||||
function openVerification(device: DeviceDisplay): void {
|
||||
if (typeof window === "undefined") return;
|
||||
window.open(authOpenUrl(device), "_blank", "noopener,noreferrer");
|
||||
}
|
||||
|
||||
// Kimi Code OAuth device-flow 连接编排(K1.4)。
|
||||
// connect:POST .../oauth/start(202)→ 展示 user_code + 开浏览器 → 轮询 job 到 done/failed。
|
||||
// status 进页传入 initialConnected/initialExpiresAt(Server Component 取)。
|
||||
export function useKimiOauth(args: {
|
||||
initialConnected: boolean;
|
||||
initialExpiresAt: string | null;
|
||||
}): UseKimiOauth {
|
||||
const [connected, setConnected] = useState(args.initialConnected);
|
||||
const [expiresAt, setExpiresAt] = useState<string | null>(
|
||||
args.initialExpiresAt,
|
||||
);
|
||||
const [device, setDevice] = useState<DeviceDisplay | null>(null);
|
||||
const [started, setStarted] = useState(false);
|
||||
const [inFlight, setInFlight] = useState(false);
|
||||
const poll = useJobPoll();
|
||||
const toast = useToast();
|
||||
const startedRef = useRef(false);
|
||||
|
||||
const phase = connectPhase({ started, connected, poll });
|
||||
|
||||
// 轮询终态:done 且 job.connected → 已连接(刷新状态);error → 提示。
|
||||
useEffect(() => {
|
||||
if (!startedRef.current) return;
|
||||
if (poll.status === "done") {
|
||||
void refreshStatus().then((ok) => {
|
||||
if (ok) {
|
||||
setConnected(true);
|
||||
toast("已连接 Kimi Code。", "success");
|
||||
}
|
||||
});
|
||||
}
|
||||
if (poll.status === "error") {
|
||||
toast(`连接失败:${poll.error ?? "授权未完成或已过期"}`, "error");
|
||||
}
|
||||
// 仅在 poll.status 变化时反应。
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [poll.status]);
|
||||
|
||||
const refreshStatus = useCallback(async (): Promise<boolean> => {
|
||||
const { data, error } = await api.GET(
|
||||
"/settings/providers/kimi-code/oauth/status",
|
||||
);
|
||||
if (error || !data) return false;
|
||||
const status = toConnectionStatus(data);
|
||||
setExpiresAt(status.expiresAt);
|
||||
return status.connected;
|
||||
}, []);
|
||||
|
||||
const connect = useCallback<UseKimiOauth["connect"]>(async () => {
|
||||
setInFlight(true);
|
||||
try {
|
||||
const { data, error } = await api.POST(START, {});
|
||||
if (error || !data) {
|
||||
toast("发起 Kimi Code 连接失败,请稍后重试。", "error");
|
||||
return;
|
||||
}
|
||||
const dev = toDeviceDisplay(data);
|
||||
setDevice(dev);
|
||||
setStarted(true);
|
||||
startedRef.current = true;
|
||||
openVerification(dev);
|
||||
poll.poll(data.job_id);
|
||||
} finally {
|
||||
setInFlight(false);
|
||||
}
|
||||
}, [poll, toast]);
|
||||
|
||||
const disconnect = useCallback<UseKimiOauth["disconnect"]>(async () => {
|
||||
setInFlight(true);
|
||||
try {
|
||||
const { data, error } = await api.POST(DISCONNECT, {});
|
||||
if (error || !data) {
|
||||
toast("断开 Kimi Code 失败,请稍后重试。", "error");
|
||||
return;
|
||||
}
|
||||
poll.reset();
|
||||
startedRef.current = false;
|
||||
setStarted(false);
|
||||
setDevice(null);
|
||||
setConnected(false);
|
||||
setExpiresAt(null);
|
||||
toast("已断开 Kimi Code。", "success");
|
||||
} finally {
|
||||
setInFlight(false);
|
||||
}
|
||||
}, [poll, toast]);
|
||||
|
||||
return {
|
||||
phase,
|
||||
device,
|
||||
expiresAt,
|
||||
busy: inFlight || (startedRef.current && poll.status === "polling"),
|
||||
error: poll.status === "error" ? poll.error : null,
|
||||
connect,
|
||||
disconnect,
|
||||
};
|
||||
}
|
||||
38
apps/web/lib/skills/skills.test.ts
Normal file
38
apps/web/lib/skills/skills.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { SkillView } from "@/lib/api/types";
|
||||
import { groupByScope, scopeLabel, tierLabel } from "./skills";
|
||||
|
||||
const skill = (over: Partial<SkillView>): SkillView => ({
|
||||
name: "x",
|
||||
scope: "builtin",
|
||||
tier: "analyst",
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("scopeLabel / tierLabel", () => {
|
||||
it("maps known values, passes through unknown", () => {
|
||||
expect(scopeLabel("builtin")).toBe("内置");
|
||||
expect(scopeLabel("mystery")).toBe("mystery");
|
||||
expect(tierLabel("writer")).toBe("写手");
|
||||
expect(tierLabel("xl")).toBe("xl");
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupByScope", () => {
|
||||
it("groups by scope in first-seen order, preserves within-group order", () => {
|
||||
const skills: SkillView[] = [
|
||||
skill({ name: "a", scope: "builtin" }),
|
||||
skill({ name: "b", scope: "custom" }),
|
||||
skill({ name: "c", scope: "builtin" }),
|
||||
];
|
||||
const groups = groupByScope(skills);
|
||||
expect(groups.map((g) => g.scope)).toEqual(["builtin", "custom"]);
|
||||
expect(groups[0].skills.map((s) => s.name)).toEqual(["a", "c"]);
|
||||
expect(groups[1].skills.map((s) => s.name)).toEqual(["b"]);
|
||||
});
|
||||
|
||||
it("handles undefined", () => {
|
||||
expect(groupByScope(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
44
apps/web/lib/skills/skills.ts
Normal file
44
apps/web/lib/skills/skills.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// 技能库纯逻辑:按 scope 分组、档位文案。
|
||||
// 对齐 C3 扩(GET /skills,只读注册表视图)。纯逻辑,便于 node 环境单测。
|
||||
|
||||
import type { SkillView } from "@/lib/api/types";
|
||||
|
||||
export type SkillScope = "builtin" | "custom" | "community";
|
||||
|
||||
export const SCOPE_LABELS: Record<string, string> = {
|
||||
builtin: "内置",
|
||||
custom: "自定义",
|
||||
community: "社区",
|
||||
};
|
||||
|
||||
export const TIER_LABELS: Record<string, string> = {
|
||||
writer: "写手",
|
||||
analyst: "分析",
|
||||
light: "轻量",
|
||||
};
|
||||
|
||||
export function scopeLabel(scope: string): string {
|
||||
return SCOPE_LABELS[scope] ?? scope;
|
||||
}
|
||||
|
||||
export function tierLabel(tier: string): string {
|
||||
return TIER_LABELS[tier] ?? tier;
|
||||
}
|
||||
|
||||
export type SkillGroups = { scope: string; skills: SkillView[] }[];
|
||||
|
||||
// 按 scope 分组(保持服务端 name 升序);scope 顺序按首次出现。
|
||||
export function groupByScope(
|
||||
skills: readonly SkillView[] | undefined,
|
||||
): SkillGroups {
|
||||
const order: string[] = [];
|
||||
const map = new Map<string, SkillView[]>();
|
||||
for (const skill of skills ?? []) {
|
||||
if (!map.has(skill.scope)) {
|
||||
order.push(skill.scope);
|
||||
map.set(skill.scope, []);
|
||||
}
|
||||
map.get(skill.scope)?.push(skill);
|
||||
}
|
||||
return order.map((scope) => ({ scope, skills: map.get(scope) ?? [] }));
|
||||
}
|
||||
127
apps/web/lib/style/style.test.ts
Normal file
127
apps/web/lib/style/style.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type {
|
||||
ReviewHistoryItem,
|
||||
StyleFingerprintResponse,
|
||||
} from "@/lib/api/types";
|
||||
import {
|
||||
buildLearnRequest,
|
||||
buildRefineRequest,
|
||||
hasUsableSamples,
|
||||
narrowStyleEvent,
|
||||
normalizeFingerprint,
|
||||
normalizeStyleDrift,
|
||||
} from "./style";
|
||||
|
||||
const reviewItem = (over: Partial<ReviewHistoryItem>): ReviewHistoryItem => ({
|
||||
id: "00000000-0000-0000-0000-000000000001",
|
||||
project_id: "00000000-0000-0000-0000-000000000002",
|
||||
chapter_no: 1,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("normalizeFingerprint", () => {
|
||||
it("aligns dims with evidence by name, preserving key order", () => {
|
||||
const resp: StyleFingerprintResponse = {
|
||||
dimensions: { 句长: "偏短", 比喻密度: "高" },
|
||||
evidence: { 句长: ["他来了。"], 比喻密度: ["如龙似虎", "若即若离"] },
|
||||
version: 2,
|
||||
};
|
||||
const fp = normalizeFingerprint(resp);
|
||||
expect(fp).toEqual({
|
||||
version: 2,
|
||||
dimensions: [
|
||||
{ name: "句长", value: "偏短", evidence: ["他来了。"] },
|
||||
{ name: "比喻密度", value: "高", evidence: ["如龙似虎", "若即若离"] },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("coerces non-string dim values and missing evidence", () => {
|
||||
const fp = normalizeFingerprint({
|
||||
dimensions: { 节奏: 5, 视角: true },
|
||||
evidence: { 节奏: ["x", 1] },
|
||||
version: 1,
|
||||
});
|
||||
expect(fp?.dimensions).toEqual([
|
||||
{ name: "节奏", value: "5", evidence: ["x"] },
|
||||
{ name: "视角", value: "true", evidence: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns null when fingerprint is undefined", () => {
|
||||
expect(normalizeFingerprint(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeStyleDrift", () => {
|
||||
it("tightens style dict into report, filtering bad segments", () => {
|
||||
const out = normalizeStyleDrift(
|
||||
reviewItem({
|
||||
style: {
|
||||
score: 87,
|
||||
segments: [
|
||||
{ idx: 3, score: 60, label: "口语化" },
|
||||
{ idx: 5, score: 72 },
|
||||
"junk",
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(out).toEqual({
|
||||
score: 87,
|
||||
segments: [
|
||||
{ idx: 3, score: 60, label: "口语化" },
|
||||
{ idx: 5, score: 72, label: null },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults score to 100 (degrade态) and returns null when missing", () => {
|
||||
expect(normalizeStyleDrift(reviewItem({ style: {} }))).toEqual({
|
||||
score: 100,
|
||||
segments: [],
|
||||
});
|
||||
expect(normalizeStyleDrift(reviewItem({ style: null }))).toBeNull();
|
||||
expect(normalizeStyleDrift(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("narrowStyleEvent", () => {
|
||||
it("narrows SSE style event data with label fallback", () => {
|
||||
expect(
|
||||
narrowStyleEvent({ score: 90, segments: [{ idx: 1, score: 50 }] }),
|
||||
).toEqual({ score: 90, segments: [{ idx: 1, score: 50, label: null }] });
|
||||
});
|
||||
|
||||
it("returns degrade态 for non-object data", () => {
|
||||
expect(narrowStyleEvent(null)).toEqual({ score: 100, segments: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildLearnRequest", () => {
|
||||
it("trims and drops empty samples; passes mode through", () => {
|
||||
expect(buildLearnRequest([" 甲 ", "", "乙"], "update")).toEqual({
|
||||
samples: ["甲", "乙"],
|
||||
mode: "update",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasUsableSamples", () => {
|
||||
it("true only when at least one non-blank sample", () => {
|
||||
expect(hasUsableSamples(["", " "])).toBe(false);
|
||||
expect(hasUsableSamples(["", "正文"])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildRefineRequest", () => {
|
||||
it("trims segment and omits empty instruction", () => {
|
||||
expect(buildRefineRequest(" 这一段 ")).toEqual({ segment: "这一段" });
|
||||
expect(buildRefineRequest("段", " ")).toEqual({ segment: "段" });
|
||||
expect(buildRefineRequest("段", " 更紧凑 ")).toEqual({
|
||||
segment: "段",
|
||||
instruction: "更紧凑",
|
||||
});
|
||||
});
|
||||
});
|
||||
129
apps/web/lib/style/style.ts
Normal file
129
apps/web/lib/style/style.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
// 文风纯逻辑:指纹归一(dims+evidence)、漂移归一(从 chapter_reviews.style)、
|
||||
// 学文风/回炉请求体组装。纯逻辑,便于 node 环境单测(C3 扩 T4.3 / C4 扩 T4.2)。
|
||||
|
||||
import type {
|
||||
ReviewHistoryItem,
|
||||
StyleFingerprintResponse,
|
||||
StyleLearnRequest,
|
||||
RefineRequest,
|
||||
} from "@/lib/api/types";
|
||||
import type { StyleDriftReport, StyleDriftSegment } from "@/lib/review/sse";
|
||||
|
||||
export type { StyleDriftReport, StyleDriftSegment } from "@/lib/review/sse";
|
||||
|
||||
// ── 指纹(16 维 + 证据,UX §6.9)─────────────────────────────────
|
||||
// 后端 dimensions={name:value}、evidence={name:[quotes]}(松散 JSONB)。
|
||||
export interface FingerprintDimension {
|
||||
name: string;
|
||||
value: string;
|
||||
evidence: string[];
|
||||
}
|
||||
|
||||
export interface Fingerprint {
|
||||
dimensions: FingerprintDimension[];
|
||||
version: number;
|
||||
}
|
||||
|
||||
function asStringArray(v: unknown): string[] {
|
||||
if (!Array.isArray(v)) return [];
|
||||
return v.filter((x): x is string => typeof x === "string");
|
||||
}
|
||||
|
||||
function asDisplayValue(v: unknown): string {
|
||||
if (typeof v === "string") return v;
|
||||
if (typeof v === "number" || typeof v === "boolean") return String(v);
|
||||
return "";
|
||||
}
|
||||
|
||||
// 把松散 GET /style 响应收窄成 Fingerprint。
|
||||
// dimensions 的 key 顺序即维度顺序(身份);evidence 按维度名对齐。
|
||||
export function normalizeFingerprint(
|
||||
resp: StyleFingerprintResponse | undefined,
|
||||
): Fingerprint | null {
|
||||
if (!resp) return null;
|
||||
const dims = resp.dimensions ?? {};
|
||||
const evid = resp.evidence ?? {};
|
||||
const names = Object.keys(dims);
|
||||
const dimensions: FingerprintDimension[] = names.map((name) => ({
|
||||
name,
|
||||
value: asDisplayValue(dims[name]),
|
||||
evidence: asStringArray(evid[name]),
|
||||
}));
|
||||
return { dimensions, version: resp.version };
|
||||
}
|
||||
|
||||
// ── 漂移(第四审,C4 扩 T4.2)────────────────────────────────────
|
||||
// chapter_reviews.style = {score:int, segments:[{idx,score,label?}]}。
|
||||
// 类型 StyleDriftReport/StyleDriftSegment 在 lib/review/sse.ts(reduce 复用),上面已 re-export。
|
||||
|
||||
function asInt(v: unknown, fallback = 0): number {
|
||||
return typeof v === "number" && Number.isFinite(v) ? Math.round(v) : fallback;
|
||||
}
|
||||
|
||||
function asOptionalString(v: unknown): string | null {
|
||||
return typeof v === "string" ? v : null;
|
||||
}
|
||||
|
||||
// 把松散 style dict 收窄成漂移报告;缺失/非 dict → null(不渲染)。
|
||||
export function normalizeStyleDrift(
|
||||
item: ReviewHistoryItem | undefined,
|
||||
): StyleDriftReport | null {
|
||||
const raw = item?.style;
|
||||
if (typeof raw !== "object" || raw === null) return null;
|
||||
const dict = raw as Record<string, unknown>;
|
||||
const segRaw = Array.isArray(dict["segments"]) ? dict["segments"] : [];
|
||||
const segments: StyleDriftSegment[] = segRaw
|
||||
.filter((s): s is Record<string, unknown> => typeof s === "object" && s !== null)
|
||||
.map((s) => ({
|
||||
idx: asInt(s["idx"]),
|
||||
score: asInt(s["score"], 100),
|
||||
label: asOptionalString(s["label"]),
|
||||
}));
|
||||
// score 缺省 100(无指纹降级态,对齐后端默认)。
|
||||
return { score: asInt(dict["score"], 100), segments };
|
||||
}
|
||||
|
||||
// 同 style SSE 事件 data 也用此收窄(reduceReview case "style" 复用)。
|
||||
export function narrowStyleEvent(data: unknown): StyleDriftReport {
|
||||
if (typeof data !== "object" || data === null) {
|
||||
return { score: 100, segments: [] };
|
||||
}
|
||||
const dict = data as Record<string, unknown>;
|
||||
const segRaw = Array.isArray(dict["segments"]) ? dict["segments"] : [];
|
||||
const segments: StyleDriftSegment[] = segRaw
|
||||
.filter((s): s is Record<string, unknown> => typeof s === "object" && s !== null)
|
||||
.map((s) => ({
|
||||
idx: asInt(s["idx"]),
|
||||
score: asInt(s["score"], 100),
|
||||
label: asOptionalString(s["label"]),
|
||||
}));
|
||||
return { score: asInt(dict["score"], 100), segments };
|
||||
}
|
||||
|
||||
// ── 请求体组装 ──────────────────────────────────────────────────
|
||||
export type StyleLearnMode = "create" | "update";
|
||||
|
||||
// 组装学文风请求体:去掉空白样本;mode 透传(首学/更新仅前端语义)。
|
||||
export function buildLearnRequest(
|
||||
samples: readonly string[],
|
||||
mode: StyleLearnMode,
|
||||
): StyleLearnRequest {
|
||||
const cleaned = samples.map((s) => s.trim()).filter((s) => s.length > 0);
|
||||
return { samples: cleaned, mode };
|
||||
}
|
||||
|
||||
// 至少一条非空样本才可提交(对齐后端 min_length=1)。
|
||||
export function hasUsableSamples(samples: readonly string[]): boolean {
|
||||
return samples.some((s) => s.trim().length > 0);
|
||||
}
|
||||
|
||||
// 组装回炉请求体(段去空白;空指令省略)。
|
||||
export function buildRefineRequest(
|
||||
segment: string,
|
||||
instruction?: string | null,
|
||||
): RefineRequest {
|
||||
const body: RefineRequest = { segment: segment.trim() };
|
||||
const trimmed = instruction?.trim();
|
||||
if (trimmed) body.instruction = trimmed;
|
||||
return body;
|
||||
}
|
||||
91
apps/web/lib/style/useRefine.ts
Normal file
91
apps/web/lib/style/useRefine.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api/client";
|
||||
import { useToast } from "@/components/Toast";
|
||||
import type { RefineResponse } from "@/lib/api/types";
|
||||
import { buildRefineRequest } from "./style";
|
||||
|
||||
export type RefineStatus = "idle" | "refining" | "done" | "error";
|
||||
|
||||
export interface RefineResult {
|
||||
original: string;
|
||||
refined: string;
|
||||
}
|
||||
|
||||
export interface UseRefine {
|
||||
status: RefineStatus;
|
||||
result: RefineResult | null;
|
||||
// 回炉重写某段(可选改写指令)→ 返回 {original, refined} 或 null(失败)。
|
||||
refine: (
|
||||
projectId: string,
|
||||
chapterNo: number,
|
||||
segment: string,
|
||||
instruction?: string,
|
||||
) => Promise<RefineResult | null>;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
function errorCode(error: unknown): string | undefined {
|
||||
if (typeof error !== "object" || error === null) return undefined;
|
||||
const env = error as { error?: { code?: unknown } };
|
||||
return typeof env.error?.code === "string" ? env.error.code : undefined;
|
||||
}
|
||||
|
||||
// 回炉(POST .../refine):同步返回新旧 diff,不写库(不变量#3)。
|
||||
// 503 LLM_UNAVAILABLE(无凭据)优雅提示去设置;其余失败 toast。
|
||||
export function useRefine(): UseRefine {
|
||||
const [status, setStatus] = useState<RefineStatus>("idle");
|
||||
const [result, setResult] = useState<RefineResult | null>(null);
|
||||
const toast = useToast();
|
||||
|
||||
const refine = useCallback<UseRefine["refine"]>(
|
||||
async (projectId, chapterNo, segment, instruction) => {
|
||||
setStatus("refining");
|
||||
setResult(null);
|
||||
try {
|
||||
const { data, error } = await api.POST(
|
||||
"/projects/{project_id}/chapters/{chapter_no}/refine",
|
||||
{
|
||||
params: {
|
||||
path: { project_id: projectId, chapter_no: chapterNo },
|
||||
},
|
||||
body: buildRefineRequest(segment, instruction),
|
||||
},
|
||||
);
|
||||
if (error || !data) {
|
||||
setStatus("error");
|
||||
const code = errorCode(error);
|
||||
toast(
|
||||
code === "LLM_UNAVAILABLE"
|
||||
? "未配置提供商,请先去设置页连一家。"
|
||||
: "回炉失败,请稍后重试。",
|
||||
"error",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const next = data as RefineResponse;
|
||||
const outcome: RefineResult = {
|
||||
original: next.original,
|
||||
refined: next.refined,
|
||||
};
|
||||
setResult(outcome);
|
||||
setStatus("done");
|
||||
return outcome;
|
||||
} catch {
|
||||
setStatus("error");
|
||||
toast("回炉请求异常,请检查网络。", "error");
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[toast],
|
||||
);
|
||||
|
||||
const reset = useCallback((): void => {
|
||||
setStatus("idle");
|
||||
setResult(null);
|
||||
}, []);
|
||||
|
||||
return { status, result, refine, reset };
|
||||
}
|
||||
123
apps/web/lib/style/useStyleLearn.ts
Normal file
123
apps/web/lib/style/useStyleLearn.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api/client";
|
||||
import { useToast } from "@/components/Toast";
|
||||
import type { StyleFingerprintResponse } from "@/lib/api/types";
|
||||
import { useJobPoll } from "@/lib/jobs/useJobPoll";
|
||||
import { styleLearnResult } from "@/lib/jobs/job";
|
||||
import {
|
||||
buildLearnRequest,
|
||||
normalizeFingerprint,
|
||||
type Fingerprint,
|
||||
type StyleLearnMode,
|
||||
} from "./style";
|
||||
|
||||
export interface UseStyleLearn {
|
||||
// 提交中(POST /style 受理)或轮询中。
|
||||
busy: boolean;
|
||||
pollStatus: ReturnType<typeof useJobPoll>["status"] | "idle";
|
||||
progress: number;
|
||||
fingerprint: Fingerprint | null;
|
||||
// 学文风:POST /style → 拿 job_id → 轮询 → done 后拉最新指纹。
|
||||
learn: (
|
||||
projectId: string,
|
||||
samples: string[],
|
||||
mode: StyleLearnMode,
|
||||
) => Promise<boolean>;
|
||||
}
|
||||
|
||||
function errorCode(error: unknown): string | undefined {
|
||||
if (typeof error !== "object" || error === null) return undefined;
|
||||
const env = error as { error?: { code?: unknown } };
|
||||
return typeof env.error?.code === "string" ? env.error.code : undefined;
|
||||
}
|
||||
|
||||
// 学文风编排:受理(202 job_id)→ useJobPoll 轮询 → done 拉 GET /style 展示指纹。
|
||||
export function useStyleLearn(initial: Fingerprint | null): UseStyleLearn {
|
||||
const [fingerprint, setFingerprint] = useState<Fingerprint | null>(initial);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [pollStatus, setPollStatus] = useState<
|
||||
ReturnType<typeof useJobPoll>["status"] | "idle"
|
||||
>("idle");
|
||||
const [projectId, setProjectId] = useState<string | null>(null);
|
||||
const poll = useJobPoll();
|
||||
const toast = useToast();
|
||||
// 仅在提交过一次学文风后才反映轮询状态(initialPollState.status 默认 "polling")。
|
||||
const startedRef = useRef(false);
|
||||
|
||||
// 轮询完成 → 拉最新指纹;失败 → toast。
|
||||
useEffect(() => {
|
||||
if (!startedRef.current) return;
|
||||
setPollStatus(poll.status);
|
||||
if (poll.status === "done" && projectId) {
|
||||
void refetchFingerprint(projectId).then((fp) => {
|
||||
if (fp) setFingerprint(fp);
|
||||
toast("文风指纹已更新。", "success");
|
||||
});
|
||||
}
|
||||
if (poll.status === "error") {
|
||||
toast(`学文风失败:${poll.error ?? "未知原因"}`, "error");
|
||||
}
|
||||
// 仅在 poll.status 变化时反应。
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [poll.status]);
|
||||
|
||||
const learn = useCallback<UseStyleLearn["learn"]>(
|
||||
async (pid, samples, mode) => {
|
||||
setSubmitting(true);
|
||||
setProjectId(pid);
|
||||
try {
|
||||
const { data, error } = await api.POST("/projects/{project_id}/style", {
|
||||
params: { path: { project_id: pid } },
|
||||
body: buildLearnRequest(samples, mode),
|
||||
});
|
||||
if (error || !data) {
|
||||
const code = errorCode(error);
|
||||
toast(
|
||||
code === "LLM_UNAVAILABLE"
|
||||
? "未配置提供商,请先去设置页连一家。"
|
||||
: "学文风受理失败,请稍后重试。",
|
||||
"error",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
startedRef.current = true;
|
||||
poll.poll(data.job_id);
|
||||
setPollStatus("polling");
|
||||
return true;
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
[poll, toast],
|
||||
);
|
||||
|
||||
return {
|
||||
busy: submitting || pollStatus === "polling",
|
||||
pollStatus,
|
||||
progress: poll.progress,
|
||||
fingerprint,
|
||||
learn,
|
||||
};
|
||||
}
|
||||
|
||||
// 客户端拉最新指纹(done 后刷新展示);404/失败 → null。
|
||||
async function refetchFingerprint(
|
||||
projectId: string,
|
||||
): Promise<Fingerprint | null> {
|
||||
const { data, error } = await api.GET("/projects/{project_id}/style", {
|
||||
params: { path: { project_id: projectId } },
|
||||
});
|
||||
if (error || !data) return null;
|
||||
return normalizeFingerprint(data as StyleFingerprintResponse);
|
||||
}
|
||||
|
||||
// 仅供测试/复用:保证 job done result 的版本回显(不阻断 UI)。
|
||||
export function learnSummary(
|
||||
poll: ReturnType<typeof useJobPoll>,
|
||||
): { version: number | null; dimsCount: number | null } | null {
|
||||
if (poll.status !== "done" || !poll.job) return null;
|
||||
return styleLearnResult(poll.job);
|
||||
}
|
||||
5
apps/web/lib/workbench/chapter.ts
Normal file
5
apps/web/lib/workbench/chapter.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// 工作台当前章号常量(纯模块,**非** "use client")。
|
||||
// 必须放在非客户端模块:Server Component(write/page.tsx)要在服务端用它拼 GET draft 的 URL;
|
||||
// 若从客户端组件(Workbench.tsx,"use client")导入,Next 会把它替换成客户端引用代理,
|
||||
// 服务端使用时变成抛错的函数 → draft URL 变成 `/chapters/function(){…}/draft`(422)。
|
||||
export const WORKBENCH_CHAPTER_NO = 1;
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user