fix(web): stale-closure + focus trap + 去边界强转 + a11y/性能打磨 + 类型化客户端
P1-6 useStyleLearn/useKimiOauth 修 stale-closure 依赖。 P1-7 CommandPalette/Drawer 加 focus trap(新 lib/a11y/focusTrap)。 P1-8 SSE 解析与 server.ts 去 as 强转改类型守卫/运行时校验。 P2 删冗余强转、开 noUncheckedIndexedAccess、Toast 清理+稳定 key、列表 key 稳定化、 rel=noopener、useMemo 大文本、ConflictCard role=group、useInjection 加锁、 client.test 真断言。 codegen 重生成 schema.d.ts + 消费 DimensionEntry/ReviewConflictView 强类型。
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
|
||||
import { handleTabTrap } from "@/lib/a11y/focusTrap";
|
||||
|
||||
interface DrawerProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -49,6 +51,10 @@ export function Drawer({
|
||||
aria-modal="true"
|
||||
aria-label={label}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
// focus trap:Tab/Shift+Tab 在抽屉内循环,不逃逸到背景(WCAG 2.1.2)。
|
||||
if (panelRef.current) handleTabTrap(panelRef.current, e);
|
||||
}}
|
||||
className={`absolute top-0 h-full w-64 max-w-[80vw] overflow-auto border-line bg-panel py-4 shadow-paper outline-none ${sideClass}`}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -4,7 +4,9 @@ import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
@@ -25,13 +27,28 @@ const TOAST_TTL_MS = 4000;
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [items, setItems] = useState<ToastItem[]>([]);
|
||||
// 单调计数器作 id(不再用 Date.now()+random,避免极小概率碰撞且稳定可测)。
|
||||
const nextIdRef = useRef(0);
|
||||
// 在途定时器集合:卸载时统一清理,避免 setState-after-unmount 泄漏。
|
||||
const timersRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());
|
||||
|
||||
const show = useCallback<ShowToast>((message, kind = "info") => {
|
||||
const id = Date.now() + Math.random();
|
||||
const id = nextIdRef.current++;
|
||||
setItems((prev) => [...prev, { id, message, kind }]);
|
||||
setTimeout(() => {
|
||||
const timer = setTimeout(() => {
|
||||
timersRef.current.delete(timer);
|
||||
setItems((prev) => prev.filter((t) => t.id !== id));
|
||||
}, TOAST_TTL_MS);
|
||||
timersRef.current.add(timer);
|
||||
}, []);
|
||||
|
||||
// 卸载清理所有在途定时器。
|
||||
useEffect(() => {
|
||||
const timers = timersRef.current;
|
||||
return () => {
|
||||
timers.forEach((t) => clearTimeout(t));
|
||||
timers.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => show, [show]);
|
||||
|
||||
@@ -10,11 +10,12 @@ import {
|
||||
projectCommands,
|
||||
type Command,
|
||||
} from "@/lib/command/palette";
|
||||
import { handleTabTrap } from "@/lib/a11y/focusTrap";
|
||||
|
||||
// 从 pathname 抽取当前 projectId(/projects/<id>/...)。
|
||||
function projectIdFromPath(pathname: string): string | null {
|
||||
const m = pathname.match(/^\/projects\/([^/]+)/);
|
||||
return m ? m[1] : null;
|
||||
return m?.[1] ?? null;
|
||||
}
|
||||
|
||||
interface CommandPaletteProps {
|
||||
@@ -29,6 +30,7 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [highlight, setHighlight] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const projectId = projectIdFromPath(pathname);
|
||||
const commands = useMemo<Command[]>(
|
||||
@@ -102,11 +104,16 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
|
||||
onClick={close}
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
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()}
|
||||
onKeyDown={(e) => {
|
||||
// focus trap:Tab/Shift+Tab 在对话框内循环,不逃逸到背景(WCAG 2.1.2)。
|
||||
if (dialogRef.current) handleTabTrap(dialogRef.current, e);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
|
||||
@@ -32,7 +32,10 @@ export function ConflictAdjudication({
|
||||
</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">
|
||||
<li
|
||||
key={`${c.type}-${c.where}-${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}
|
||||
|
||||
@@ -26,7 +26,7 @@ export function AnnotatedText({
|
||||
<div className="mb-3 flex flex-wrap gap-2 border-b border-line pb-3">
|
||||
{conflicts.map((c, i) => (
|
||||
<button
|
||||
key={i}
|
||||
key={`${c.type}-${c.where}-${i}`}
|
||||
type="button"
|
||||
id={`anchor-${i}`}
|
||||
onClick={() => onAnchorClick(i)}
|
||||
|
||||
@@ -89,8 +89,10 @@ export function ConflictCard({
|
||||
))}
|
||||
</div>
|
||||
|
||||
<fieldset className="mt-3">
|
||||
<legend className="sr-only">冲突 {index + 1} 裁决</legend>
|
||||
<div role="group" aria-labelledby={`verdict-label-${index}`} className="mt-3">
|
||||
<span id={`verdict-label-${index}`} className="sr-only">
|
||||
冲突 {index + 1} 裁决
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{VERDICT_OPTIONS.map((opt) => {
|
||||
const active = draft.verdict === opt.value;
|
||||
@@ -132,7 +134,7 @@ export function ConflictCard({
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</fieldset>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import type { ProjectResponse, ReviewHistoryItem } from "@/lib/api/types";
|
||||
@@ -185,11 +186,10 @@ export function ReviewReport({
|
||||
}
|
||||
};
|
||||
|
||||
// 漂移段 idx → 终稿对应段正文(按空行切段;越界则空串)。
|
||||
const segmentText = (idx: number): string => {
|
||||
const paras = finalText.split(/\n{2,}/);
|
||||
return paras[idx]?.trim() ?? "";
|
||||
};
|
||||
// 终稿按空行切段(大文本:仅在 finalText 变化时重算,不在每次 render split)。
|
||||
const finalParas = useMemo(() => finalText.split(/\n{2,}/), [finalText]);
|
||||
// 漂移段 idx → 终稿对应段正文(越界则空串)。
|
||||
const segmentText = (idx: number): string => finalParas[idx]?.trim() ?? "";
|
||||
|
||||
// 采纳:把重写段替换终稿中的原段(首个匹配),合入终稿(经既有 draft 路径)。
|
||||
const onAdopt = (original: string, refined: string): void => {
|
||||
@@ -407,12 +407,12 @@ function ReviewErrorNote({
|
||||
{friendly.actionHref ? (
|
||||
<>
|
||||
{" "}
|
||||
<a
|
||||
<Link
|
||||
href={friendly.actionHref}
|
||||
className="underline hover:text-cinnabar"
|
||||
>
|
||||
{friendly.actionLabel}
|
||||
</a>
|
||||
</Link>
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
|
||||
72
apps/web/lib/a11y/focusTrap.test.ts
Normal file
72
apps/web/lib/a11y/focusTrap.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { trapTarget } from "./focusTrap";
|
||||
|
||||
// P1-7:焦点陷阱决策核(纯函数,node 可测;DOM 包装 handleTabTrap 留浏览器)。
|
||||
describe("trapTarget(focus trap 决策)", () => {
|
||||
it("Tab 在尾元素时环回到首", () => {
|
||||
expect(
|
||||
trapTarget({
|
||||
focusableCount: 3,
|
||||
activeIndex: 2,
|
||||
containsActive: true,
|
||||
shiftKey: false,
|
||||
}),
|
||||
).toBe("first");
|
||||
});
|
||||
|
||||
it("Shift+Tab 在首元素时环回到尾", () => {
|
||||
expect(
|
||||
trapTarget({
|
||||
focusableCount: 3,
|
||||
activeIndex: 0,
|
||||
containsActive: true,
|
||||
shiftKey: true,
|
||||
}),
|
||||
).toBe("last");
|
||||
});
|
||||
|
||||
it("Tab 在中间元素不接管(放行原生顺序)", () => {
|
||||
expect(
|
||||
trapTarget({
|
||||
focusableCount: 3,
|
||||
activeIndex: 1,
|
||||
containsActive: true,
|
||||
shiftKey: false,
|
||||
}),
|
||||
).toBe("none");
|
||||
});
|
||||
|
||||
it("焦点已逃出容器时 Tab 拉回首元素", () => {
|
||||
expect(
|
||||
trapTarget({
|
||||
focusableCount: 3,
|
||||
activeIndex: -1,
|
||||
containsActive: false,
|
||||
shiftKey: false,
|
||||
}),
|
||||
).toBe("first");
|
||||
});
|
||||
|
||||
it("焦点已逃出容器时 Shift+Tab 拉回尾元素", () => {
|
||||
expect(
|
||||
trapTarget({
|
||||
focusableCount: 3,
|
||||
activeIndex: -1,
|
||||
containsActive: false,
|
||||
shiftKey: true,
|
||||
}),
|
||||
).toBe("last");
|
||||
});
|
||||
|
||||
it("无可聚焦子元素时把焦点钉在容器上", () => {
|
||||
expect(
|
||||
trapTarget({
|
||||
focusableCount: 0,
|
||||
activeIndex: -1,
|
||||
containsActive: true,
|
||||
shiftKey: false,
|
||||
}),
|
||||
).toBe("container");
|
||||
});
|
||||
});
|
||||
80
apps/web/lib/a11y/focusTrap.ts
Normal file
80
apps/web/lib/a11y/focusTrap.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
// 模态对话框焦点陷阱(WCAG 2.1.2:把焦点关在对话框内循环)。
|
||||
// 自写 querySelectorAll 焦点循环,不引入新依赖。
|
||||
// 纯决策核(trapTarget,可 node 单测)+ 薄 DOM 包装(handleTabTrap)。
|
||||
|
||||
// 可聚焦元素选择器(排除 disabled / tabindex=-1 / 隐藏)。
|
||||
const FOCUSABLE_SELECTOR = [
|
||||
"a[href]",
|
||||
"button:not([disabled])",
|
||||
"input:not([disabled])",
|
||||
"select:not([disabled])",
|
||||
"textarea:not([disabled])",
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(",");
|
||||
|
||||
// 取容器内当前可聚焦(且可见)的元素,按文档顺序。
|
||||
export function focusableElements(container: HTMLElement): HTMLElement[] {
|
||||
const nodes = container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
|
||||
return Array.from(nodes).filter(
|
||||
(el) => el.offsetParent !== null || el === document.activeElement,
|
||||
);
|
||||
}
|
||||
|
||||
export type TrapAction = "none" | "first" | "last" | "container";
|
||||
|
||||
// 纯决策:给定可聚焦数量、当前焦点在循环中的位置、容器是否含焦点、是否 Shift+Tab,
|
||||
// 决定 Tab 应跳到何处(环回首/尾/容器)。-1 表示 active 不在 focusable 列表内。
|
||||
export function trapTarget(args: {
|
||||
focusableCount: number;
|
||||
activeIndex: number; // active 在 focusable 中的下标;-1 = 不在列表
|
||||
containsActive: boolean; // 容器是否包含当前焦点
|
||||
shiftKey: boolean;
|
||||
}): TrapAction {
|
||||
const { focusableCount, activeIndex, containsActive, shiftKey } = args;
|
||||
if (focusableCount === 0) return "container";
|
||||
if (shiftKey) {
|
||||
// 在首元素或焦点已逃出容器 → 环回到尾。
|
||||
if (activeIndex === 0 || !containsActive) return "last";
|
||||
return "none";
|
||||
}
|
||||
// 在尾元素或焦点已逃出容器 → 环回到首。
|
||||
if (activeIndex === focusableCount - 1 || !containsActive) return "first";
|
||||
return "none";
|
||||
}
|
||||
|
||||
// 处理 Tab / Shift+Tab:在容器内循环。返回 true 表示已接管(已 preventDefault)。
|
||||
export function handleTabTrap(
|
||||
container: HTMLElement,
|
||||
event: { key: string; shiftKey: boolean; preventDefault: () => void },
|
||||
): boolean {
|
||||
if (event.key !== "Tab") return false;
|
||||
const focusable = focusableElements(container);
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
const containsActive = active !== null && container.contains(active);
|
||||
const activeIndex =
|
||||
active !== null ? focusable.indexOf(active) : -1;
|
||||
|
||||
const action = trapTarget({
|
||||
focusableCount: focusable.length,
|
||||
activeIndex,
|
||||
containsActive,
|
||||
shiftKey: event.shiftKey,
|
||||
});
|
||||
|
||||
switch (action) {
|
||||
case "none":
|
||||
return false;
|
||||
case "container":
|
||||
event.preventDefault();
|
||||
container.focus();
|
||||
return true;
|
||||
case "first":
|
||||
event.preventDefault();
|
||||
focusable[0]?.focus();
|
||||
return true;
|
||||
case "last":
|
||||
event.preventDefault();
|
||||
focusable[focusable.length - 1]?.focus();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("api base url", () => {
|
||||
it("falls back to localhost when env unset", () => {
|
||||
const base = process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:8000";
|
||||
expect(base).toMatch(/^https?:\/\//);
|
||||
// P2:真正断言——mock openapi-fetch,验证 client 用正确 baseUrl 初始化。
|
||||
// createClient 在模块加载时被调用一次,故每个用例 resetModules + 重新 import。
|
||||
|
||||
const createClientMock = vi.fn(() => ({ GET: vi.fn(), POST: vi.fn() }));
|
||||
|
||||
vi.mock("openapi-fetch", () => ({
|
||||
default: createClientMock,
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetModules();
|
||||
vi.unstubAllEnvs();
|
||||
createClientMock.mockClear();
|
||||
});
|
||||
|
||||
describe("api client baseUrl", () => {
|
||||
it("uses NEXT_PUBLIC_API_BASE when set", async () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_API_BASE", "https://api.example.com");
|
||||
|
||||
await import("./client");
|
||||
|
||||
expect(createClientMock).toHaveBeenCalledTimes(1);
|
||||
expect(createClientMock).toHaveBeenCalledWith({
|
||||
baseUrl: "https://api.example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to localhost when env unset", async () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_API_BASE", undefined);
|
||||
|
||||
await import("./client");
|
||||
|
||||
expect(createClientMock).toHaveBeenCalledWith({
|
||||
baseUrl: "http://localhost:8000",
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes the constructed client as `api`", async () => {
|
||||
const mod = await import("./client");
|
||||
expect(mod.api).toBeDefined();
|
||||
expect(typeof mod.api.GET).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
190
apps/web/lib/api/schema.d.ts
vendored
190
apps/web/lib/api/schema.d.ts
vendored
@@ -236,6 +236,7 @@ export interface paths {
|
||||
* List Foreshadow
|
||||
* @description 伏笔看板:按 `status` 过滤(缺省=全部),按 code 升序。
|
||||
*
|
||||
* 项目不存在 → 404(与写端点一致,避免不存在 project 返回误导性空 200)。
|
||||
* `status` 非法(不在 OPEN/PARTIAL/CLOSED/OVERDUE)→ VALIDATION 信封。四泳道前端
|
||||
* 据 `status` 分组;OVERDUE 泳道 + 逾期标记用 `expected_close_to`(看板字段已齐)。
|
||||
*/
|
||||
@@ -427,6 +428,7 @@ export interface paths {
|
||||
* List Characters
|
||||
* @description 已入库角色全量列表(设定库 Codex 真源;复用 C5 读侧 SqlCharacterRepo)。
|
||||
*
|
||||
* 项目不存在 → 404(与写端点一致,避免不存在 project 返回误导性空 200)。
|
||||
* DB JSONB dict 列 → API list/str 反向解包(入库形变的逆向,见 `_existing_characters`)。
|
||||
*/
|
||||
get: operations["list_characters_projects__project_id__characters_get"];
|
||||
@@ -453,6 +455,7 @@ export interface paths {
|
||||
* List World Entities
|
||||
* @description 已入库世界观实体全量列表(设定库 Codex 真源;复用 C5 读侧 SqlWorldEntityRepo)。
|
||||
*
|
||||
* 项目不存在 → 404(与写端点一致)。
|
||||
* DB `rules` JSONB dict `{"rules":[...]}` → 裸 list(worldbuilder 形变的逆向)。
|
||||
*/
|
||||
get: operations["list_world_entities_projects__project_id__world_entities_get"];
|
||||
@@ -813,6 +816,18 @@ export interface components {
|
||||
*/
|
||||
note?: string | null;
|
||||
};
|
||||
/**
|
||||
* DimensionEntry
|
||||
* @description 单个文风维度:名称 + 判定值 + 原文证据摘录(强类型,替代裸 dict[str, Any])。
|
||||
*/
|
||||
DimensionEntry: {
|
||||
/** Name */
|
||||
name: string;
|
||||
/** Value */
|
||||
value: string;
|
||||
/** Evidence */
|
||||
evidence?: string[];
|
||||
};
|
||||
/**
|
||||
* DraftResponse
|
||||
* @description 草稿保存结果(脱敏:只回元信息 + 长度,不回灌正文以外的衍生)。
|
||||
@@ -879,6 +894,23 @@ export interface components {
|
||||
/** Length */
|
||||
length: number;
|
||||
};
|
||||
/** ErrorBody */
|
||||
ErrorBody: {
|
||||
/** Code */
|
||||
code: string;
|
||||
/** Message */
|
||||
message: string;
|
||||
/** Details */
|
||||
details?: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/** Request Id */
|
||||
request_id?: string | null;
|
||||
};
|
||||
/** ErrorEnvelope */
|
||||
ErrorEnvelope: {
|
||||
error: components["schemas"]["ErrorBody"];
|
||||
};
|
||||
/**
|
||||
* ForeshadowBoardResponse
|
||||
* @description GET /projects/:id/foreshadow?status=:伏笔看板(四泳道 + OVERDUE 字段齐)。
|
||||
@@ -1077,6 +1109,35 @@ export interface components {
|
||||
*/
|
||||
excluded?: components["schemas"]["InjectionEntityRef"][];
|
||||
};
|
||||
/**
|
||||
* JobResponse
|
||||
* @description 长任务状态(`GET /jobs/{id}`):状态 + 进度 + 结果/错误。
|
||||
*
|
||||
* `result` 是任务成功后的非密摘要(如 `{"connected": true, ...}`);`error` 是失败后的
|
||||
* 面向用户文案(已脱敏,绝不含 `str(exc)`,见 `services/job_runner._classify_job_error`)。
|
||||
*/
|
||||
JobResponse: {
|
||||
/**
|
||||
* Id
|
||||
* Format: uuid
|
||||
*/
|
||||
id: string;
|
||||
/** Kind */
|
||||
kind: string;
|
||||
/** Status */
|
||||
status: string;
|
||||
/**
|
||||
* Progress
|
||||
* @default 0
|
||||
*/
|
||||
progress: number;
|
||||
/** Result */
|
||||
result?: {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
/** Error */
|
||||
error?: string | null;
|
||||
};
|
||||
/**
|
||||
* OAuthDisconnectResponse
|
||||
* @description 断开:是否删到凭据行。
|
||||
@@ -1171,7 +1232,7 @@ export interface components {
|
||||
/** Theme */
|
||||
theme?: string | null;
|
||||
/** Selling Points */
|
||||
selling_points?: unknown[];
|
||||
selling_points?: string[];
|
||||
/** Structure */
|
||||
structure?: string | null;
|
||||
};
|
||||
@@ -1204,7 +1265,7 @@ export interface components {
|
||||
/** Theme */
|
||||
theme?: string | null;
|
||||
/** Selling Points */
|
||||
selling_points?: unknown[];
|
||||
selling_points?: string[];
|
||||
/** Structure */
|
||||
structure?: string | null;
|
||||
};
|
||||
@@ -1268,6 +1329,31 @@ export interface components {
|
||||
/** Refined */
|
||||
refined: string;
|
||||
};
|
||||
/**
|
||||
* ReviewConflictView
|
||||
* @description 单条一致性冲突(chapter_reviews.conflicts JSONB 子项;形对齐 SSE `conflict` 事件)。
|
||||
*
|
||||
* 强类型化此前的裸 `dict[str, Any]`,给 TS 客户端真实字段类型。容忍历史/缺省(默认值)。
|
||||
*/
|
||||
ReviewConflictView: {
|
||||
/**
|
||||
* Type
|
||||
* @default
|
||||
*/
|
||||
type: string;
|
||||
/**
|
||||
* Where
|
||||
* @default
|
||||
*/
|
||||
where: string;
|
||||
/** Refs */
|
||||
refs?: string[];
|
||||
/**
|
||||
* Suggestion
|
||||
* @default
|
||||
*/
|
||||
suggestion: string;
|
||||
};
|
||||
/**
|
||||
* ReviewHistoryItem
|
||||
* @description 单条审稿留痕(GET .../reviews 历史项;snake_case)。
|
||||
@@ -1288,9 +1374,7 @@ export interface components {
|
||||
/** Chapter Version */
|
||||
chapter_version?: number | null;
|
||||
/** Conflicts */
|
||||
conflicts?: {
|
||||
[key: string]: unknown;
|
||||
}[];
|
||||
conflicts?: components["schemas"]["ReviewConflictView"][];
|
||||
/** Foreshadow Sug */
|
||||
foreshadow_sug?: {
|
||||
[key: string]: unknown;
|
||||
@@ -1397,17 +1481,14 @@ export interface components {
|
||||
};
|
||||
/**
|
||||
* StyleFingerprintResponse
|
||||
* @description 最新文风指纹(`GET /style`):完整 16 维 + 证据 + 版本(对齐 UX §6.9)。
|
||||
* @description 最新文风指纹(`GET /style`):完整 16 维(名称/值/证据)+ 版本(对齐 UX §6.9)。
|
||||
*
|
||||
* DB 存两列并行 dict(`{name:value}` + `{name:[evidence]}`);响应合并为
|
||||
* `list[DimensionEntry]`,给 TS 客户端强类型(替代弱类型 `dict[str, Any]`)。
|
||||
*/
|
||||
StyleFingerprintResponse: {
|
||||
/** Dimensions */
|
||||
dimensions?: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/** Evidence */
|
||||
evidence?: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
dimensions?: components["schemas"]["DimensionEntry"][];
|
||||
/** Version */
|
||||
version: number;
|
||||
};
|
||||
@@ -1616,10 +1697,17 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
[key: string]: unknown;
|
||||
"application/json": components["schemas"]["JobResponse"];
|
||||
};
|
||||
};
|
||||
/** @description job 不存在 */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
@@ -1705,6 +1793,15 @@ export interface operations {
|
||||
"application/json": components["schemas"]["ProjectResponse"];
|
||||
};
|
||||
};
|
||||
/** @description 资源不存在 */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
@@ -1737,6 +1834,15 @@ export interface operations {
|
||||
"application/json": components["schemas"]["InjectionResponse"];
|
||||
};
|
||||
};
|
||||
/** @description 资源不存在 */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
@@ -1773,6 +1879,15 @@ export interface operations {
|
||||
"application/json": components["schemas"]["InjectionResponse"];
|
||||
};
|
||||
};
|
||||
/** @description 资源不存在 */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
@@ -1805,6 +1920,15 @@ export interface operations {
|
||||
"application/json": components["schemas"]["DraftView"];
|
||||
};
|
||||
};
|
||||
/** @description 资源不存在 */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
@@ -1981,6 +2105,24 @@ export interface operations {
|
||||
"application/json": components["schemas"]["AcceptResponse"];
|
||||
};
|
||||
};
|
||||
/** @description 资源不存在 */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 存在未裁决冲突 */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
@@ -1990,6 +2132,15 @@ export interface operations {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
/** @description LLM 不可用 */
|
||||
503: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
list_foreshadow_projects__project_id__foreshadow_get: {
|
||||
@@ -2248,6 +2399,15 @@ export interface operations {
|
||||
"application/json": components["schemas"]["StyleFingerprintResponse"];
|
||||
};
|
||||
};
|
||||
/** @description 项目或指纹不存在 */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
|
||||
@@ -26,7 +26,7 @@ describe("fetchOutline", () => {
|
||||
const chapters = await fetchOutline("p1");
|
||||
|
||||
expect(chapters).toHaveLength(1);
|
||||
expect(chapters[0].no).toBe(1);
|
||||
expect(chapters[0]?.no).toBe(1);
|
||||
});
|
||||
|
||||
it("returns empty array when the project has no outline", async () => {
|
||||
|
||||
@@ -16,13 +16,29 @@ import type {
|
||||
WorldEntityListResponse,
|
||||
} from "./types";
|
||||
|
||||
// 解析 JSON 响应体并做最小运行时校验:非 null 对象/数组才放行,
|
||||
// 否则抛结构化错误(后端契约保证为 JSON 对象;防御被代理/网关污染的非 JSON 响应)。
|
||||
// 校验通过后 narrow 到 T(OpenAPI 生成类型为权威形状,此处仅守边界非 null)。
|
||||
async function parseJsonBody<T>(res: Response, path: string): Promise<T> {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch {
|
||||
throw new Error(`响应非 JSON:${path}`);
|
||||
}
|
||||
if (typeof body !== "object" || body === null) {
|
||||
throw new Error(`响应体形状异常(期望对象):${path}`);
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
|
||||
// 服务端只读取数据(Server Components)。失败时抛出,由页面边界处理。
|
||||
async function getJson<T>(path: string): Promise<T> {
|
||||
const res = await fetch(`${serverApiBase()}${path}`, { cache: "no-store" });
|
||||
if (!res.ok) {
|
||||
throw new Error(`请求失败 ${res.status}: ${path}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
return parseJsonBody<T>(res, path);
|
||||
}
|
||||
|
||||
// 像上面一样读取,但 404(无指纹)返回 null 而非抛出(首学前页面照常渲染)。
|
||||
@@ -32,7 +48,7 @@ async function getJsonOrNull<T>(path: string): Promise<T | null> {
|
||||
if (!res.ok) {
|
||||
throw new Error(`请求失败 ${res.status}: ${path}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
return parseJsonBody<T>(res, path);
|
||||
}
|
||||
|
||||
export async function fetchProjects(): Promise<ProjectListResponse> {
|
||||
|
||||
@@ -22,6 +22,8 @@ export type CapabilitiesView = components["schemas"]["CapabilitiesView"];
|
||||
// M2 审/裁/验收(C3 扩)。
|
||||
export type ReviewRequest = components["schemas"]["ReviewRequest"];
|
||||
export type ReviewHistoryItem = components["schemas"]["ReviewHistoryItem"];
|
||||
// 单条一致性冲突(chapter_reviews.conflicts 子项,强类型替代裸 dict)。
|
||||
export type ReviewConflictView = components["schemas"]["ReviewConflictView"];
|
||||
export type ReviewHistoryResponse =
|
||||
components["schemas"]["ReviewHistoryResponse"];
|
||||
export type ConflictDecision = components["schemas"]["ConflictDecision"];
|
||||
@@ -48,6 +50,8 @@ export type StyleLearnRequest = components["schemas"]["StyleLearnRequest"];
|
||||
export type StyleLearnResponse = components["schemas"]["StyleLearnResponse"];
|
||||
export type StyleFingerprintResponse =
|
||||
components["schemas"]["StyleFingerprintResponse"];
|
||||
// 单个文风维度(name/value/evidence,强类型替代裸 dict)。
|
||||
export type DimensionEntry = components["schemas"]["DimensionEntry"];
|
||||
export type RefineRequest = components["schemas"]["RefineRequest"];
|
||||
export type RefineResponse = components["schemas"]["RefineResponse"];
|
||||
|
||||
|
||||
@@ -28,7 +28,9 @@ export function useAutosave(
|
||||
chapterNo: number,
|
||||
initialSavedText?: string,
|
||||
): UseAutosave {
|
||||
const hasInitialDraft = (initialSavedText ?? "").length > 0;
|
||||
// 归一为非空基线(undefined → ""),避免后续非空断言/强转。
|
||||
const initialDraft = initialSavedText ?? "";
|
||||
const hasInitialDraft = initialDraft.length > 0;
|
||||
const [status, setStatus] = useState<SaveStatus>(
|
||||
hasInitialDraft ? "saved" : "idle",
|
||||
);
|
||||
@@ -38,7 +40,7 @@ export function useAutosave(
|
||||
const toast = useToast();
|
||||
// 种入已存草稿为基线:用户未改动时不触发冗余保存(save 早退)。
|
||||
const lastSavedRef = useRef<string | null>(
|
||||
hasInitialDraft ? (initialSavedText as string) : null,
|
||||
hasInitialDraft ? initialDraft : null,
|
||||
);
|
||||
|
||||
const save = useCallback(
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
buildTransitionRequest,
|
||||
extractReason,
|
||||
validationReasonMessage,
|
||||
type ForeshadowStatus,
|
||||
type RegisterInput,
|
||||
type TransitionInput,
|
||||
} from "./board";
|
||||
@@ -60,7 +59,7 @@ export function useForeshadow(initial: ForeshadowView[]): UseForeshadow {
|
||||
const snapshot = items;
|
||||
// 乐观:本地先改 status(若给了 toStatus)。
|
||||
if (input.toStatus) {
|
||||
const next = input.toStatus as ForeshadowStatus;
|
||||
const next = input.toStatus;
|
||||
setItems((prev) =>
|
||||
prev.map((it) => (it.code === code ? { ...it, status: next } : it)),
|
||||
);
|
||||
|
||||
@@ -163,7 +163,7 @@ describe("mergeCharacterCards", () => {
|
||||
const session = [card({ name: "甲", role: "对手" })];
|
||||
const out = mergeCharacterCards(persisted, session);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].role).toBe("主角"); // persisted version kept
|
||||
expect(out[0]?.role).toBe("主角"); // persisted version kept
|
||||
});
|
||||
|
||||
it("returns persisted list when no session cards", () => {
|
||||
|
||||
@@ -4,10 +4,7 @@ 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 type { CharacterCardView } from "@/lib/api/types";
|
||||
import {
|
||||
buildCharacterGenerateRequest,
|
||||
buildCharacterIngestRequest,
|
||||
@@ -108,16 +105,12 @@ export function useCharacterGen(): UseCharacterGen {
|
||||
toast(generationErrorMessage(error), "error");
|
||||
return false;
|
||||
}
|
||||
const result = data as CharacterIngestResponse;
|
||||
setCreated(result.created ?? []);
|
||||
setCreated(data.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",
|
||||
);
|
||||
toast(`已入库 ${data.created?.length ?? 0} 个角色`, "success");
|
||||
if (data.rejected_tables && data.rejected_tables.length > 0) {
|
||||
toast(`越权写表被丢弃:${data.rejected_tables.join("、")}`, "info");
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
|
||||
@@ -9,9 +9,9 @@ describe("aiToolItems", () => {
|
||||
|
||||
it("leads with 写本章 (primary, key write)", () => {
|
||||
const [first] = aiToolItems("p1");
|
||||
expect(first.label).toBe("写本章");
|
||||
expect(first.primary).toBe(true);
|
||||
expect(first.key).toBe("write");
|
||||
expect(first?.label).toBe("写本章");
|
||||
expect(first?.primary).toBe(true);
|
||||
expect(first?.key).toBe("write");
|
||||
});
|
||||
|
||||
it("scopes every href under the project path", () => {
|
||||
|
||||
@@ -57,6 +57,7 @@ describe("isNavItemActive", () => {
|
||||
|
||||
it("matches a global item by exact pathname only", () => {
|
||||
const works = GLOBAL_NAV_ITEMS[0];
|
||||
if (!works) throw new Error("expected GLOBAL_NAV_ITEMS[0]");
|
||||
expect(isNavItemActive(works, { pathname: "/" })).toBe(true);
|
||||
expect(isNavItemActive(works, { pathname: "/projects/p1/write" })).toBe(
|
||||
false,
|
||||
@@ -65,6 +66,7 @@ describe("isNavItemActive", () => {
|
||||
|
||||
it("ignores activeNav for global items (no key)", () => {
|
||||
const settings = GLOBAL_NAV_ITEMS[1];
|
||||
if (!settings) throw new Error("expected GLOBAL_NAV_ITEMS[1]");
|
||||
expect(
|
||||
isNavItemActive(settings, { pathname: "/", activeNav: "write" }),
|
||||
).toBe(false);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ReviewHistoryItem } from "@/lib/api/types";
|
||||
import type { ReviewConflictView, ReviewHistoryItem } from "@/lib/api/types";
|
||||
import {
|
||||
latestReview,
|
||||
normalizeConflicts,
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "./history";
|
||||
|
||||
const item = (
|
||||
conflicts: Record<string, unknown>[],
|
||||
conflicts: ReviewConflictView[],
|
||||
): ReviewHistoryItem => ({
|
||||
id: "00000000-0000-0000-0000-000000000001",
|
||||
project_id: "00000000-0000-0000-0000-000000000002",
|
||||
@@ -36,8 +36,10 @@ describe("normalizeConflicts", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("fills safe defaults for missing/wrong-typed fields", () => {
|
||||
const out = normalizeConflicts(item([{ where: 42, refs: "x" }]));
|
||||
it("falls back type to 未分类 and tolerates missing refs", () => {
|
||||
const out = normalizeConflicts(
|
||||
item([{ type: "", where: "", suggestion: "" }]),
|
||||
);
|
||||
expect(out).toEqual([
|
||||
{ type: "未分类", where: "", refs: [], suggestion: "" },
|
||||
]);
|
||||
|
||||
@@ -18,26 +18,22 @@ function asOptionalString(v: unknown): string | null {
|
||||
return typeof v === "string" ? v : null;
|
||||
}
|
||||
|
||||
function asStringArray(v: unknown): string[] {
|
||||
if (!Array.isArray(v)) return [];
|
||||
return v.filter((x): x is string => typeof x === "string");
|
||||
}
|
||||
|
||||
function asIntArray(v: unknown): number[] {
|
||||
if (!Array.isArray(v)) return [];
|
||||
return v.filter((x): x is number => typeof x === "number" && Number.isFinite(x));
|
||||
}
|
||||
|
||||
// 把一条留痕的 conflicts 收紧(缺字段给安全默认;保持顺序=下标身份,对齐冲突 gate)。
|
||||
// 把一条留痕的 conflicts(ReviewConflictView[],已强类型)映射成 ReviewConflict。
|
||||
// 保持顺序=下标身份(对齐冲突 gate);type 空串回退「未分类」,refs 容忍缺省。
|
||||
export function normalizeConflicts(
|
||||
item: ReviewHistoryItem | undefined,
|
||||
): ReviewConflict[] {
|
||||
const raw = item?.conflicts ?? [];
|
||||
return raw.map((c) => ({
|
||||
type: asString(c["type"], "未分类"),
|
||||
where: asString(c["where"]),
|
||||
refs: asStringArray(c["refs"]),
|
||||
suggestion: asString(c["suggestion"]),
|
||||
type: c.type || "未分类",
|
||||
where: c.where,
|
||||
refs: c.refs ?? [],
|
||||
suggestion: c.suggestion,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,49 @@ describe("parseReviewBlock", () => {
|
||||
expect(parseReviewBlock('event:token\ndata:{"text":"x"}')).toBeNull();
|
||||
expect(parseReviewBlock("event:conflict\ndata:{not json")).toBeNull();
|
||||
});
|
||||
|
||||
// P1-8:逐事件类型守卫——已知事件但 data 形状不符也拒绝(不再强转放行)。
|
||||
it("returns null when conflict frame lacks required string fields", () => {
|
||||
expect(
|
||||
parseReviewBlock('event:conflict\ndata:{"type":"设定违例","refs":[]}'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when section status is invalid", () => {
|
||||
expect(
|
||||
parseReviewBlock('event:section\ndata:{"name":"c","status":"bogus"}'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("drops non-string refs and coerces nullable foreshadow fields", () => {
|
||||
expect(
|
||||
parseReviewBlock(
|
||||
'event:conflict\ndata:{"type":"设定违例","where":"第1段","refs":["第3章",7],"suggestion":"s"}',
|
||||
),
|
||||
).toEqual({
|
||||
event: "conflict",
|
||||
data: {
|
||||
type: "设定违例",
|
||||
where: "第1段",
|
||||
refs: ["第3章"],
|
||||
suggestion: "s",
|
||||
},
|
||||
});
|
||||
expect(
|
||||
parseReviewBlock(
|
||||
'event:foreshadow\ndata:{"kind":"planted","title":"残图"}',
|
||||
),
|
||||
).toEqual({
|
||||
event: "foreshadow",
|
||||
data: {
|
||||
kind: "planted",
|
||||
code: null,
|
||||
title: "残图",
|
||||
where: null,
|
||||
note: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ReviewFrameBuffer", () => {
|
||||
|
||||
@@ -139,7 +139,125 @@ export function parseReviewBlock(block: string): ReviewSseEvent | null {
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return { event, data } as ReviewSseEvent;
|
||||
return narrowReviewEvent(event, data);
|
||||
}
|
||||
|
||||
function isRecord(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
function asStringArray(v: unknown): string[] {
|
||||
return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
|
||||
}
|
||||
|
||||
function asNumberArray(v: unknown): number[] {
|
||||
return Array.isArray(v)
|
||||
? v.filter((x): x is number => typeof x === "number" && Number.isFinite(x))
|
||||
: [];
|
||||
}
|
||||
|
||||
function nullableString(v: unknown): string | null {
|
||||
return typeof v === "string" ? v : null;
|
||||
}
|
||||
|
||||
function asPaceIssues(v: unknown): PaceIssue[] {
|
||||
if (!Array.isArray(v)) return [];
|
||||
return v.flatMap((x) =>
|
||||
isRecord(x) && typeof x.where === "string" && typeof x.reason === "string"
|
||||
? [{ where: x.where, reason: x.reason }]
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
function asStyleSegments(v: unknown): StyleEvent["data"]["segments"] {
|
||||
if (!Array.isArray(v)) return [];
|
||||
return v.flatMap((x) =>
|
||||
isRecord(x) && typeof x.idx === "number" && typeof x.score === "number"
|
||||
? [{ idx: x.idx, score: x.score, label: nullableString(x.label) }]
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
// 逐事件类型守卫:按 event 名收窄 data 形状;形状不符返回 null(安全跳过)。
|
||||
function narrowReviewEvent(event: string, data: unknown): ReviewSseEvent | null {
|
||||
if (!isRecord(data)) return null;
|
||||
switch (event) {
|
||||
case "section":
|
||||
return typeof data.name === "string" && isSectionStatus(data.status)
|
||||
? { event: "section", data: { name: data.name, status: data.status } }
|
||||
: null;
|
||||
case "conflict":
|
||||
return typeof data.type === "string" &&
|
||||
typeof data.where === "string" &&
|
||||
typeof data.suggestion === "string"
|
||||
? {
|
||||
event: "conflict",
|
||||
data: {
|
||||
type: data.type,
|
||||
where: data.where,
|
||||
refs: asStringArray(data.refs),
|
||||
suggestion: data.suggestion,
|
||||
},
|
||||
}
|
||||
: null;
|
||||
case "foreshadow":
|
||||
return isForeshadowKind(data.kind) && typeof data.title === "string"
|
||||
? {
|
||||
event: "foreshadow",
|
||||
data: {
|
||||
kind: data.kind,
|
||||
code: nullableString(data.code),
|
||||
title: data.title,
|
||||
where: nullableString(data.where),
|
||||
note: nullableString(data.note),
|
||||
},
|
||||
}
|
||||
: null;
|
||||
case "pace":
|
||||
return {
|
||||
event: "pace",
|
||||
data: {
|
||||
water: asPaceIssues(data.water),
|
||||
hook: data.hook === true,
|
||||
beat_map: asNumberArray(data.beat_map),
|
||||
},
|
||||
};
|
||||
case "style":
|
||||
return typeof data.score === "number"
|
||||
? {
|
||||
event: "style",
|
||||
data: {
|
||||
score: data.score,
|
||||
segments: asStyleSegments(data.segments),
|
||||
},
|
||||
}
|
||||
: null;
|
||||
case "done":
|
||||
return typeof data.length === "number"
|
||||
? { event: "done", data: { length: data.length } }
|
||||
: null;
|
||||
case "error":
|
||||
return typeof data.code === "string" && typeof data.message === "string"
|
||||
? {
|
||||
event: "error",
|
||||
data: {
|
||||
code: data.code,
|
||||
message: data.message,
|
||||
request_id: nullableString(data.request_id),
|
||||
},
|
||||
}
|
||||
: null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isSectionStatus(v: unknown): v is SectionStatus {
|
||||
return v === "started" || v === "done" || v === "incomplete";
|
||||
}
|
||||
|
||||
function isForeshadowKind(v: unknown): v is ForeshadowKind {
|
||||
return v === "planted" || v === "resolved";
|
||||
}
|
||||
|
||||
// 增量缓冲:吃进一段文本,吐出已完成的事件块(空行分隔),保留未完成尾部。
|
||||
|
||||
@@ -60,7 +60,19 @@ export function useKimiOauth(args: {
|
||||
|
||||
const phase = connectPhase({ started, connected, poll });
|
||||
|
||||
// refreshStatus 无依赖、稳定(useCallback []);声明在 effect 之前以便纳入依赖。
|
||||
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;
|
||||
}, []);
|
||||
|
||||
// 轮询终态:done 且 job.connected → 已连接(刷新状态);error → 提示。
|
||||
// refreshStatus / toast 均稳定,poll.error 随状态变化——全部纳入依赖,无 stale-closure。
|
||||
useEffect(() => {
|
||||
if (!startedRef.current) return;
|
||||
if (poll.status === "done") {
|
||||
@@ -74,19 +86,7 @@ export function useKimiOauth(args: {
|
||||
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;
|
||||
}, []);
|
||||
}, [poll.status, poll.error, refreshStatus, toast]);
|
||||
|
||||
const connect = useCallback<UseKimiOauth["connect"]>(async () => {
|
||||
setInFlight(true);
|
||||
|
||||
@@ -28,8 +28,8 @@ describe("groupByScope", () => {
|
||||
];
|
||||
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"]);
|
||||
expect(groups[0]?.skills.map((s) => s.name)).toEqual(["a", "c"]);
|
||||
expect(groups[1]?.skills.map((s) => s.name)).toEqual(["b"]);
|
||||
});
|
||||
|
||||
it("handles undefined", () => {
|
||||
|
||||
@@ -41,6 +41,24 @@ describe("parseSseBlock", () => {
|
||||
it("returns null for malformed json", () => {
|
||||
expect(parseSseBlock("event:token\ndata:{not json")).toBeNull();
|
||||
});
|
||||
|
||||
// P1-8:逐事件类型守卫——已知事件但 data 形状不符也拒绝(不再强转放行)。
|
||||
it("returns null when token frame lacks string text", () => {
|
||||
expect(parseSseBlock('event:token\ndata:{"text":42}')).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when error frame lacks code/message", () => {
|
||||
expect(parseSseBlock('event:error\ndata:{"code":"X"}')).toBeNull();
|
||||
});
|
||||
|
||||
it("coerces missing request_id to null on error frame", () => {
|
||||
expect(
|
||||
parseSseBlock('event:error\ndata:{"code":"X","message":"m"}'),
|
||||
).toEqual({
|
||||
event: "error",
|
||||
data: { code: "X", message: "m", request_id: null },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("SseFrameBuffer", () => {
|
||||
|
||||
@@ -39,7 +39,40 @@ export function parseSseBlock(block: string): SseEvent | null {
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return { event, data } as SseEvent;
|
||||
return narrowSseEvent(event, data);
|
||||
}
|
||||
|
||||
function isRecord(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
// 逐事件类型守卫:按 event 名收窄 data 形状;形状不符则返回 null(安全跳过)。
|
||||
function narrowSseEvent(event: string, data: unknown): SseEvent | null {
|
||||
if (!isRecord(data)) return null;
|
||||
switch (event) {
|
||||
case "token":
|
||||
return typeof data.text === "string"
|
||||
? { event: "token", data: { text: data.text } }
|
||||
: null;
|
||||
case "done":
|
||||
return typeof data.length === "number"
|
||||
? { event: "done", data: { length: data.length } }
|
||||
: null;
|
||||
case "error":
|
||||
return typeof data.code === "string" && typeof data.message === "string"
|
||||
? {
|
||||
event: "error",
|
||||
data: {
|
||||
code: data.code,
|
||||
message: data.message,
|
||||
request_id:
|
||||
typeof data.request_id === "string" ? data.request_id : null,
|
||||
},
|
||||
}
|
||||
: null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 增量缓冲:吃进一段文本,吐出已完成的事件块(以空行分隔),保留未完成尾部。
|
||||
|
||||
@@ -21,10 +21,12 @@ const reviewItem = (over: Partial<ReviewHistoryItem>): ReviewHistoryItem => ({
|
||||
});
|
||||
|
||||
describe("normalizeFingerprint", () => {
|
||||
it("aligns dims with evidence by name, preserving key order", () => {
|
||||
it("maps DimensionEntry[] preserving order, evidence per entry", () => {
|
||||
const resp: StyleFingerprintResponse = {
|
||||
dimensions: { 句长: "偏短", 比喻密度: "高" },
|
||||
evidence: { 句长: ["他来了。"], 比喻密度: ["如龙似虎", "若即若离"] },
|
||||
dimensions: [
|
||||
{ name: "句长", value: "偏短", evidence: ["他来了。"] },
|
||||
{ name: "比喻密度", value: "高", evidence: ["如龙似虎", "若即若离"] },
|
||||
],
|
||||
version: 2,
|
||||
};
|
||||
const fp = normalizeFingerprint(resp);
|
||||
@@ -37,10 +39,12 @@ describe("normalizeFingerprint", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("coerces non-string dim values and missing evidence", () => {
|
||||
it("defaults missing evidence to empty array", () => {
|
||||
const fp = normalizeFingerprint({
|
||||
dimensions: { 节奏: 5, 视角: true },
|
||||
evidence: { 节奏: ["x", 1] },
|
||||
dimensions: [
|
||||
{ name: "节奏", value: "5", evidence: ["x"] },
|
||||
{ name: "视角", value: "true" },
|
||||
],
|
||||
version: 1,
|
||||
});
|
||||
expect(fp?.dimensions).toEqual([
|
||||
|
||||
@@ -12,7 +12,7 @@ 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)。
|
||||
// 后端 dimensions: DimensionEntry[](每条 {name,value,evidence},强类型,替代弱类型 dict)。
|
||||
export interface FingerprintDimension {
|
||||
name: string;
|
||||
value: string;
|
||||
@@ -24,31 +24,19 @@ export interface Fingerprint {
|
||||
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 按维度名对齐。
|
||||
// 把 GET /style 响应映射成 Fingerprint。
|
||||
// dimensions 数组顺序即维度顺序(身份);每条已携带 name/value/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]),
|
||||
}));
|
||||
const dimensions: FingerprintDimension[] = (resp.dimensions ?? []).map(
|
||||
(d) => ({
|
||||
name: d.name,
|
||||
value: d.value,
|
||||
evidence: d.evidence ?? [],
|
||||
}),
|
||||
);
|
||||
return { dimensions, version: resp.version };
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ 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";
|
||||
@@ -65,10 +64,9 @@ export function useRefine(): UseRefine {
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const next = data as RefineResponse;
|
||||
const outcome: RefineResult = {
|
||||
original: next.original,
|
||||
refined: next.refined,
|
||||
original: data.original,
|
||||
refined: data.refined,
|
||||
};
|
||||
setResult(outcome);
|
||||
setStatus("done");
|
||||
|
||||
41
apps/web/lib/style/useStyleLearn.test.ts
Normal file
41
apps/web/lib/style/useStyleLearn.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// P1-6 回归守卫:学文风轮询完成(done 边沿)时,effect 必须用「最近一次 learn 传入的
|
||||
// projectId」去拉指纹,而非闭包捕获的旧值。useStyleLearn 用 ref 追踪 projectId 来保证这点。
|
||||
//
|
||||
// 该 hook 依赖浏览器/React 渲染,node 环境无法整体跑;这里以最小模型固化 ref 追踪契约:
|
||||
// 切项目(连续两次 learn)后,done 时读到的应是后者,不会是首次的陈旧值。
|
||||
describe("useStyleLearn projectId 追踪(P1-6 stale-closure 守卫)", () => {
|
||||
// 复刻 hook 内部:learn 同步写 ref,done 时同步读 ref。
|
||||
function makeTracker() {
|
||||
const projectIdRef: { current: string | null } = { current: null };
|
||||
return {
|
||||
// learn(pid):受理时记录最近项目。
|
||||
learn(pid: string): void {
|
||||
projectIdRef.current = pid;
|
||||
},
|
||||
// onDone():轮询完成时取用于拉指纹的项目(应为最新)。
|
||||
onDone(): string | null {
|
||||
return projectIdRef.current;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("done 时用最近一次 learn 的 projectId(切项目后不复用旧值)", () => {
|
||||
// Arrange
|
||||
const t = makeTracker();
|
||||
|
||||
// Act:先 learn 项目 A,再切到项目 B(模拟用户换项目重学),此时 A 的轮询尚未完成。
|
||||
t.learn("project-A");
|
||||
t.learn("project-B");
|
||||
const used = t.onDone();
|
||||
|
||||
// Assert:done 边沿读到的是 B,而非闭包里陈旧的 A。
|
||||
expect(used).toBe("project-B");
|
||||
});
|
||||
|
||||
it("未 learn 时为 null(不误拉指纹)", () => {
|
||||
const t = makeTracker();
|
||||
expect(t.onDone()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,6 @@ 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 {
|
||||
@@ -41,16 +40,20 @@ export function useStyleLearn(initial: Fingerprint | null): UseStyleLearn {
|
||||
const [pollStatus, setPollStatus] = useState<
|
||||
ReturnType<typeof useJobPoll>["status"] | "idle"
|
||||
>("idle");
|
||||
const [projectId, setProjectId] = useState<string | null>(null);
|
||||
// projectId 用 ref 而非 state:done 边沿的 effect 须读到「最近一次 learn 传入的」
|
||||
// 而非闭包捕获的旧值(修 stale-closure)。learn 同步写 ref,effect 同步读。
|
||||
const projectIdRef = useRef<string | null>(null);
|
||||
const poll = useJobPoll();
|
||||
const toast = useToast();
|
||||
// 仅在提交过一次学文风后才反映轮询状态(initialPollState.status 默认 "polling")。
|
||||
const startedRef = useRef(false);
|
||||
|
||||
// 轮询完成 → 拉最新指纹;失败 → toast。
|
||||
// 依赖 poll.status / poll.error / toast(均稳定或随状态变化),projectId 经 ref 取最新。
|
||||
useEffect(() => {
|
||||
if (!startedRef.current) return;
|
||||
setPollStatus(poll.status);
|
||||
const projectId = projectIdRef.current;
|
||||
if (poll.status === "done" && projectId) {
|
||||
void refetchFingerprint(projectId).then((fp) => {
|
||||
if (fp) setFingerprint(fp);
|
||||
@@ -60,14 +63,12 @@ export function useStyleLearn(initial: Fingerprint | null): UseStyleLearn {
|
||||
if (poll.status === "error") {
|
||||
toast(`学文风失败:${poll.error ?? "未知原因"}`, "error");
|
||||
}
|
||||
// 仅在 poll.status 变化时反应。
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [poll.status]);
|
||||
}, [poll.status, poll.error, toast]);
|
||||
|
||||
const learn = useCallback<UseStyleLearn["learn"]>(
|
||||
async (pid, samples, mode) => {
|
||||
setSubmitting(true);
|
||||
setProjectId(pid);
|
||||
projectIdRef.current = pid;
|
||||
try {
|
||||
const { data, error } = await api.POST("/projects/{project_id}/style", {
|
||||
params: { path: { project_id: pid } },
|
||||
@@ -111,7 +112,7 @@ async function refetchFingerprint(
|
||||
params: { path: { project_id: projectId } },
|
||||
});
|
||||
if (error || !data) return null;
|
||||
return normalizeFingerprint(data as StyleFingerprintResponse);
|
||||
return normalizeFingerprint(data);
|
||||
}
|
||||
|
||||
// 仅供测试/复用:保证 job done result 的版本回显(不阻断 UI)。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api/client";
|
||||
import {
|
||||
@@ -36,6 +36,8 @@ export function useInjection(projectId: string, chapterNo: number): UseInjection
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// 并发锁:保存在途时 ref 同步置位,拦下连点/快速切换造成的乱序覆盖(state 异步不可靠)。
|
||||
const savingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -63,8 +65,12 @@ export function useInjection(projectId: string, chapterNo: number): UseInjection
|
||||
|
||||
const save = useCallback(
|
||||
async (override: InjectionOverrideRequest) => {
|
||||
// 已有保存在途 → 早返回(避免并发 PUT 互相覆盖、回放结果乱序)。
|
||||
if (savingRef.current) return;
|
||||
savingRef.current = true;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { data: body, error: err } = await api.PUT(INJECTION_PATH, {
|
||||
params: { path: { project_id: projectId, chapter_no: chapterNo } },
|
||||
body: override,
|
||||
@@ -74,7 +80,10 @@ export function useInjection(projectId: string, chapterNo: number): UseInjection
|
||||
} else {
|
||||
setData(body); // 以服务端确定结果为准(不变量 #6)。
|
||||
}
|
||||
} finally {
|
||||
savingRef.current = false;
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[projectId, chapterNo],
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user