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:
Yaojia Wang
2026-06-21 19:32:49 +02:00
parent 016509c5c6
commit c6651b74b9
36 changed files with 792 additions and 140 deletions

View File

@@ -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 trapTab/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}

View File

@@ -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]);

View File

@@ -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 trapTab/Shift+Tab 在对话框内循环不逃逸到背景WCAG 2.1.2)。
if (dialogRef.current) handleTabTrap(dialogRef.current, e);
}}
>
<input
ref={inputRef}

View File

@@ -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}

View File

@@ -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)}

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { trapTarget } from "./focusTrap";
// P1-7焦点陷阱决策核纯函数node 可测DOM 包装 handleTabTrap 留浏览器)。
describe("trapTargetfocus 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");
});
});

View 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;
}
}

View File

@@ -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");
});
});

View File

@@ -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":[...]}` → 裸 listworldbuilder 形变的逆向)。
*/
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: {

View File

@@ -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 () => {

View File

@@ -16,13 +16,29 @@ import type {
WorldEntityListResponse,
} from "./types";
// 解析 JSON 响应体并做最小运行时校验:非 null 对象/数组才放行,
// 否则抛结构化错误(后端契约保证为 JSON 对象;防御被代理/网关污染的非 JSON 响应)。
// 校验通过后 narrow 到 TOpenAPI 生成类型为权威形状,此处仅守边界非 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> {

View File

@@ -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"];

View File

@@ -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(

View File

@@ -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)),
);

View File

@@ -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", () => {

View File

@@ -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 {

View File

@@ -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", () => {

View File

@@ -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);

View File

@@ -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: "" },
]);

View File

@@ -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
// 把一条留痕的 conflictsReviewConflictView[],已强类型)映射成 ReviewConflict
// 保持顺序=下标身份(对齐冲突 gatetype 空串回退「未分类」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,
}));
}

View File

@@ -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", () => {

View File

@@ -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";
}
// 增量缓冲:吃进一段文本,吐出已完成的事件块(空行分隔),保留未完成尾部。

View File

@@ -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);

View File

@@ -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", () => {

View File

@@ -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", () => {

View File

@@ -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;
}
}
// 增量缓冲:吃进一段文本,吐出已完成的事件块(以空行分隔),保留未完成尾部。

View File

@@ -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([

View File

@@ -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 };
}

View File

@@ -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");

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
// P1-6 回归守卫学文风轮询完成done 边沿effect 必须用「最近一次 learn 传入的
// projectId」去拉指纹而非闭包捕获的旧值。useStyleLearn 用 ref 追踪 projectId 来保证这点。
//
// 该 hook 依赖浏览器/React 渲染node 环境无法整体跑;这里以最小模型固化 ref 追踪契约:
// 切项目(连续两次 learndone 时读到的应是后者,不会是首次的陈旧值。
describe("useStyleLearn projectId 追踪P1-6 stale-closure 守卫)", () => {
// 复刻 hook 内部learn 同步写 refdone 时同步读 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();
// Assertdone 边沿读到的是 B而非闭包里陈旧的 A。
expect(used).toBe("project-B");
});
it("未 learn 时为 null不误拉指纹", () => {
const t = makeTracker();
expect(t.onDone()).toBeNull();
});
});

View File

@@ -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 而非 statedone 边沿的 effect 须读到「最近一次 learn 传入的」
// 而非闭包捕获的旧值(修 stale-closure。learn 同步写 refeffect 同步读。
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

View File

@@ -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],
);

View File

@@ -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