@y9931666/dsh-plugin-crossfire 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ import { a as newReviewId, c as reviewDirOf, d as withCrossfireLock, f as writeJsonAtomic, i as listReviewIds, l as reviewLockKey, n as appendJsonLine, o as readJsonFile, p as writeTextAtomic, r as crossfireRootOf, s as readJsonLines, t as CROSSFIRE_NAMESPACE, u as stateRootOf } from "./state-DzrgVdrB.js";
2
+
3
+ export { CROSSFIRE_NAMESPACE, appendJsonLine, crossfireRootOf, listReviewIds, newReviewId, readJsonFile, readJsonLines, reviewDirOf, reviewLockKey, stateRootOf, withCrossfireLock, writeJsonAtomic, writeTextAtomic };
@@ -0,0 +1,138 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { dirname, join } from "node:path";
3
+ import { appendFile, mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
4
+
5
+ //#region src/state.ts
6
+ /** 本插件在状态根下的固定命名空间目录名。 */
7
+ const CROSSFIRE_NAMESPACE = "crossfire";
8
+ /** Process-local mutex chain keyed by an arbitrary lock key. */
9
+ const locks = /* @__PURE__ */ new Map();
10
+ /**
11
+ * Serialize async operations sharing one key inside this process.
12
+ *
13
+ * 注意(已知限制,写在任务书里):这是**进程内**锁,跨进程不互斥。多窗口同时
14
+ * 操作同一场评审时,后写者覆盖前者,只靠原子写保证文件不会写坏。v0.1.0 接受。
15
+ */
16
+ async function withCrossfireLock(key, operation) {
17
+ const current = (locks.get(key) ?? Promise.resolve()).then(operation, operation);
18
+ locks.set(key, current.then(() => void 0, () => void 0));
19
+ return current;
20
+ }
21
+ /** 锁 key:同一场评审的操作互相串行。 */
22
+ function reviewLockKey(stateRoot, reviewId) {
23
+ return `crossfire:${stateRoot}:${reviewId}`;
24
+ }
25
+ /** Absolute state root under a workspace. */
26
+ function stateRootOf(workspace, stateDir) {
27
+ return join(workspace, stateDir);
28
+ }
29
+ /** `<stateRoot>/crossfire` —— 本插件的命名空间目录。 */
30
+ function crossfireRootOf(stateRoot) {
31
+ return join(stateRoot, CROSSFIRE_NAMESPACE);
32
+ }
33
+ /** Absolute directory of one review. */
34
+ function reviewDirOf(stateRoot, reviewId) {
35
+ return join(crossfireRootOf(stateRoot), reviewId);
36
+ }
37
+ /** 生成一个可读、可排序、无碰撞的评审 id:`x-<yyyyMMdd-HHmmss>-<4位>`。 */
38
+ function newReviewId(now = Date.now(), random = randomUUID()) {
39
+ const d = new Date(now);
40
+ const pad = (value, width = 2) => String(value).padStart(width, "0");
41
+ return `x-${`${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`}-${random.replace(/[^a-z0-9]/gi, "").slice(0, 4).toLowerCase() || "r000"}`;
42
+ }
43
+ /** Cached host atomic writer: `null` = not resolved yet, `undefined` = unavailable. */
44
+ let hostAtomicWriter = null;
45
+ /**
46
+ * Resolve the host's atomic writer **without making it a load gate**.
47
+ *
48
+ * 静态 import 会让缺少 `@deepseek-ai/dsh-atomic-write` 的组合直接加载失败 ——
49
+ * 那正是"可选能力变成硬门禁"的经典事故(圆桌 v0.2.36 修过一次)。所以这里
50
+ * 懒解析一次,取不到就退到下面的本地实现。
51
+ */
52
+ async function resolveHostWriter() {
53
+ if (hostAtomicWriter !== null) return hostAtomicWriter;
54
+ try {
55
+ const write = (await import("@deepseek-ai/dsh-atomic-write")).writeFileAtomic;
56
+ hostAtomicWriter = typeof write === "function" ? (file, text) => write(file, text, { mode: 420 }) : void 0;
57
+ } catch {
58
+ hostAtomicWriter = void 0;
59
+ }
60
+ return hostAtomicWriter;
61
+ }
62
+ /** Atomically publish a UTF-8 text file. */
63
+ async function writeTextAtomic(file, text) {
64
+ const hostWrite = await resolveHostWriter();
65
+ if (hostWrite !== void 0) {
66
+ await hostWrite(file, text);
67
+ return;
68
+ }
69
+ await mkdir(dirname(file), { recursive: true });
70
+ const tmp = join(dirname(file), `.${randomUUID()}.tmp`);
71
+ await writeFile(tmp, text, "utf8");
72
+ await rename(tmp, file);
73
+ }
74
+ /** Atomically publish a JSON file (same-directory tmp + rename). */
75
+ async function writeJsonAtomic(file, value) {
76
+ await writeTextAtomic(file, JSON.stringify(value, null, 2));
77
+ }
78
+ /**
79
+ * 读一个 JSON 文件;**不存在或损坏都返回 undefined**(铁律 1)。
80
+ *
81
+ * 调用方拿到 undefined 时应当降级,而不是把异常抛给用户界面。
82
+ */
83
+ async function readJsonFile(file) {
84
+ try {
85
+ const text = await readFile(file, "utf8");
86
+ return JSON.parse(text);
87
+ } catch {
88
+ return;
89
+ }
90
+ }
91
+ /** 追加一条 JSONL 记录(自动建目录)。 */
92
+ async function appendJsonLine(file, value) {
93
+ await mkdir(dirname(file), { recursive: true });
94
+ await appendFile(file, `${JSON.stringify(value)}\n`, "utf8");
95
+ }
96
+ /**
97
+ * 读 JSONL;坏行被跳过但**计数**。
98
+ *
99
+ * 容忍被截断的尾行(进程在写一半时崩溃留下的半行 JSON),但它同样计入
100
+ * `malformed` —— 因为那条记录确实丢了。
101
+ */
102
+ async function readJsonLines(file) {
103
+ let text;
104
+ try {
105
+ text = await readFile(file, "utf8");
106
+ } catch {
107
+ return {
108
+ items: [],
109
+ malformed: 0
110
+ };
111
+ }
112
+ const items = [];
113
+ let malformed = 0;
114
+ for (const line of text.split("\n")) {
115
+ const trimmed = line.trim();
116
+ if (trimmed === "") continue;
117
+ try {
118
+ items.push(JSON.parse(trimmed));
119
+ } catch {
120
+ malformed += 1;
121
+ }
122
+ }
123
+ return {
124
+ items,
125
+ malformed
126
+ };
127
+ }
128
+ /** 列出状态根下的全部评审 id(按名字排序,名字本身按时间可排)。 */
129
+ async function listReviewIds(stateRoot) {
130
+ try {
131
+ return (await readdir(crossfireRootOf(stateRoot), { withFileTypes: true })).filter((entry) => entry.isDirectory() && entry.name.startsWith("x-")).map((entry) => entry.name).sort();
132
+ } catch {
133
+ return [];
134
+ }
135
+ }
136
+
137
+ //#endregion
138
+ export { newReviewId as a, reviewDirOf as c, withCrossfireLock as d, writeJsonAtomic as f, listReviewIds as i, reviewLockKey as l, appendJsonLine as n, readJsonFile as o, writeTextAtomic as p, crossfireRootOf as r, readJsonLines as s, CROSSFIRE_NAMESPACE as t, stateRootOf as u };
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Crossfire 预算:一条**熔断线**,不是一台状态机。
3
+ *
4
+ * ## 为什么刻意没有 active / muted / ended
5
+ *
6
+ * 圆桌的预算有三态与"闭麦":一次会议是持续存在的对象,预算耗尽只是把它**调成
7
+ * 静音**,之后还能靠 `roundtable_set_budget` 补预算复活。针锋没有多轮辩论,
8
+ * 预算的角色因此完全不同 —— 它唯一的作用是**在超限的那一刻把后续工作停下来并
9
+ * 报错**。停下不改变任何评审状态:`status` 由 `review.ts` 独占管理,本模块
10
+ * **一行都不写 `record.status`**(这是设计边界,不是疏忽)。
11
+ *
12
+ * ## 两条判据(与设置页「0 = 不限制」严格一致)
13
+ *
14
+ * - `rounds`:`usedRounds > maxRounds`。设 N 轮就能开满 N 轮,只有想开第 N+1 轮
15
+ * 才触顶 —— `>=` 会让 `maxRounds = 10` 实际只能开 9 轮,对面向用户的设置项
16
+ * 不直觉(圆桌已经踩过这个坑,注释里写明了原因,这里沿用同一口径)。
17
+ * - `tokens`:`usedTokens >= maxTokens`,且仅当 `maxTokens > 0` 时才判。
18
+ * - 两条轴的 `max*` 为 `0` 时该轴**不限制**(设置页对这四个数字字段都写着
19
+ * 「0 = 不限制」,实现必须与文案一致,否则用户关掉限制反而被立刻熔断)。
20
+ *
21
+ * ## 调用方契约
22
+ *
23
+ * `chargeTokens` / `beginPass` 都是**原地修改** `record` 的纯计算,不落盘 ——
24
+ * 落盘由调用方负责(状态写入统一走 `state.ts` 的原子写 + 进程内锁)。
25
+ *
26
+ * @module dsh-plugin-crossfire/budget
27
+ */
28
+ import type { CrossfireRecord } from './types.ts';
29
+ /**
30
+ * 估算文本的 token 成本(粗估:CJK 0.6/字,其它非空白 0.3/字符,向上取整)。
31
+ *
32
+ * 口径与圆桌 `budget.ts` 的 `estimateTokens` **完全一致**(逐字符 `for...of`
33
+ * 按码点遍历,`code >= 0x2e80` 记 CJK,空白不计入 other,最后 `Math.ceil`)。
34
+ * 这是刻意照搬而不是重新设计:两个插件对同一段中文给出的预算数字必须可比。
35
+ *
36
+ * 再次强调:这是一个**已说出的文本长度估算**,不是模型真实用量。
37
+ */
38
+ export declare function estimateTokens(text: string): number;
39
+ /**
40
+ * 哪条预算轴被突破(无则 `undefined`)。
41
+ *
42
+ * 语义(与圆桌一致,见文件头):两条轴的 `max*` 为 `0` 表示该轴不限制。
43
+ * 返回的是**轴名**,不是布尔 —— 调用方需要把它透出给用户,让"是哪条线断了"
44
+ * 可诊断,而不是笼统的一句"预算用完了"。
45
+ */
46
+ export declare function budgetExceeded(record: CrossfireRecord): 'rounds' | 'tokens' | undefined;
47
+ /**
48
+ * 预算熔断错误。
49
+ *
50
+ * 不是"会议已结束",也不是"会议被静音":针锋只有这一种停止原因,且**可恢复**
51
+ * —— 用户把设置里的上限调大即可继续(状态从未被改动,所以不存在"复活"一说)。
52
+ */
53
+ export declare class CrossfireMutedError extends Error {
54
+ readonly axis: 'rounds' | 'tokens';
55
+ constructor(reviewId: string, axis: 'rounds' | 'tokens');
56
+ }
57
+ /**
58
+ * 熔断门禁:超限即抛 {@link CrossfireMutedError},否则什么都不做。
59
+ *
60
+ * **不修改 `record`**(与圆桌 `markMuted` 的本质区别):针锋不把"超预算"写成
61
+ * 一个持久状态,因为那个状态会反过来挡住用户自救的路径 —— 圆桌的真实事故就是
62
+ * `ensureActive` 先改状态再抛错,导致连补预算/导出都被同一个门禁拒绝,花完预算
63
+ * 的会议永远卡在 `active` 却什么都做不了。这里只抛、不改,问题无从被固化。
64
+ *
65
+ * @param record - 被检查的评审记录。
66
+ * @param options.allowMuted - 收尾路径(导出、表态、定稿)必须保持可达,
67
+ * 否则用户连"把已经打出来的缺陷看完并裁决"都做不了;置 `true` 时直接放行。
68
+ */
69
+ export declare function assertBudget(record: CrossfireRecord, options?: {
70
+ allowMuted?: boolean;
71
+ }): void;
72
+ /**
73
+ * 把一段专家输出计入 token 用量(**原地修改** `record`,由调用方负责落盘)。
74
+ *
75
+ * 只在文本非空时累加,避免空发言把 `usedTokens` 上一堆无意义的 `+0` 调用;
76
+ * 估算口径交给 {@link estimateTokens},本函数不做任何额外加权。
77
+ */
78
+ export declare function chargeTokens(record: CrossfireRecord, text: string): void;
79
+ /**
80
+ * 推进一轮:`reviewPass += 1` 并同步 `budget.usedRounds`,返回新的轮次。
81
+ *
82
+ * 这里**不**做预算检查:门禁由调用方在动手之前调用 {@link assertBudget},因为
83
+ * "先检查后推进"的顺序一旦反过来,第 N+1 轮的副作用就已经发生了。
84
+ * `usedRounds` 与 `reviewPass` 必须同进同退,两者脱钩会让预算判据失去意义。
85
+ */
86
+ export declare function beginPass(record: CrossfireRecord): number;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * 针锋的 Chat 卡片(注册在 `tool.call.toolview`,key = `crossfire_open`)。
3
+ *
4
+ * ## 为什么卡片只认领一个工具名
5
+ *
6
+ * 宿主的 toolview 是按 **wire 工具名** dispatch 的,一次工具调用渲染一张。如果给
7
+ * 七个工具都注册,同一场评审会沿轨迹长出七张一模一样的卡。所以只认领
8
+ * `crossfire_open`(卡片第一次出现的位置),后续的 attack / collect / finish
9
+ * 调用渲染成通用工具行;而卡片本身**每秒轮询快照**,内容自动跟着状态机走。
10
+ *
11
+ * ## 四态与直推(O1)
12
+ *
13
+ * `draft → attacking → judging → settled`,卡片按当前态只给出**下一步**那一个按钮,
14
+ * 点击即经 RPC 直推 host 状态机 —— 不必等一个模型回合。这正是针锋比圆桌简洁的
15
+ * 地方。
16
+ *
17
+ * ## 一条不可越界的规则
18
+ *
19
+ * 表态(认定 / 驳回 / 待定)只能在这里发生。卡片是唯一入口,因为**没有对应的
20
+ * agent 工具**,也永远不该有。
21
+ *
22
+ * @module dsh-plugin-crossfire/client/ReviewCard
23
+ */
24
+ import { type ReactElement } from 'react';
25
+ import { type RpcCaller } from './wire.ts';
26
+ /**
27
+ * 卡片收到的注入面(`tool.call.toolview` 不保证给 inject,因此组件对它容错)。
28
+ *
29
+ * `sessionId` **不是**我们注入的:它是宿主给每个 slot 组件的**标准注入** ——
30
+ * 运行时 `Slots.listSubTree` 在 `tool.call.toolview` 的 `standardProps` 里列出的
31
+ * 正是 `sessionId: SessionId`(已核实,不是推断)。圆桌的 tab 组件同样从 props 取它。
32
+ * 组件用 `Partial<>` 同时接住两类来源。
33
+ */
34
+ export interface CrossfireCardInjected {
35
+ rpc: RpcCaller;
36
+ t: (key: string) => string;
37
+ /** 当前会话 id(宿主标准注入提供;拿不到时组件退化为空串)。 */
38
+ sessionId?: string;
39
+ }
40
+ /** 轮询间隔。与圆桌一致:本机 HTTP,1 秒足够跟手。 */
41
+ export declare function CrossfireCard(props: Partial<CrossfireCardInjected>): ReactElement;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * 针锋的设置页(`settings.section`,id `crossfire`)。
3
+ *
4
+ * ## 覆盖范围
5
+ *
6
+ * 五块:默认强度、维度池(固定池展示 + 自定义增删)、预算、**角色预设**、
7
+ * **阵容预设**,末尾是四条边界说明与 locale 侧的批次批注。
8
+ *
9
+ * 角色预设与阵容预设是**纯本地偏好**(`crossfire/preferences.json`):它们只
10
+ * 回答"谁来"(key / 角色说明 / 模型路由),不描述"怎么开"——没有维度、没有流程。
11
+ *
12
+ * ## 三条实现约定
13
+ *
14
+ * - **选项类改动立即落库**:点击/枚举类操作直接提交 `crossfire/prefs.set`,
15
+ * 成功后用服务端返回的 prefs 覆盖本地快照。
16
+ * - **数字输入走"本地草稿 + 失焦提交"**:避免每敲一个字符就写一次盘;四个数字
17
+ * 字段都遵循 `0 = 不限制`。
18
+ * - **渲染只读快照**:服务端返回的 prefs 是唯一真相,本地不做长驻乐观状态,
19
+ * 否则一次失败的保存会让界面显示一个并不存在的值。唯一的例外是正在编辑的
20
+ * 草稿(还没提交),以及"整条条目文本/路由原样就是旧值"时的本地重试。
21
+ *
22
+ * 长度与数量上限在 host `preferences.ts` 里重新净化一次,本页只是提前拦截并给出
23
+ * 即时反馈。
24
+ *
25
+ * @module dsh-plugin-crossfire/client/SettingsView
26
+ */
27
+ import { type ReactElement } from 'react';
28
+ import type { RpcCaller } from './wire.ts';
29
+ /** slot entry 收到的注入面(见 client/index.ts)。 */
30
+ export interface CrossfireSettingsInjected {
31
+ rpc: RpcCaller;
32
+ t: (key: string) => string;
33
+ }
34
+ export declare function CrossfireSettings(props: CrossfireSettingsInjected): ReactElement;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Provider 品牌标识(彩色圆角块 + 缩写)—— 零资源依赖。
3
+ *
4
+ * ## 为什么不用真 logo
5
+ *
6
+ * 圆桌会议的做法是 `PROVIDER_BRAND`(正则 → key)+ `brand-logos.generated.ts`
7
+ * (**330 KB 的 base64 图片**)。本插件刻意**不复制那个文件**:它会把包体积撑大
8
+ * 约三倍,而收益只是"块里换成图片"。这里只取同一份方案的**缩写 + 品牌色**:
9
+ * 品牌色圆角块 + 2–3 字母缩写,代价为零。
10
+ *
11
+ * ## 一条必须守住的边界
12
+ *
13
+ * 品牌色是**第三方商标色**,不是主题令牌 —— 主题令牌无法表达"DeepSeek 是蓝的"。
14
+ * 因此它只用于**小面积的身份标记**(头像块),文字一律保持 `currentColor`:
15
+ * 浅色/深色主题下可读性由底色与白字的对比度保证,与主题无关。
16
+ * 除此之外本模块不产生任何颜色。
17
+ *
18
+ * 与圆桌 `RoundTableView.tsx` 的 `PROVIDER_BRAND` 保持同源,但**不共享代码**:
19
+ * 两个插件各自独立演进,跨插件 import 会把一份 1888 行的客户端模块拖进本包的
20
+ * 类型程序(`tsconfig.client.json` 只 include `src/client`)。
21
+ *
22
+ * @module dsh-plugin-crossfire/client/brand
23
+ */
24
+ /** 一位 provider(或一个模型路由)的展示用品牌标识。 */
25
+ export interface Brand {
26
+ /** 2–3 字母缩写(最长 3 位,保证块内不折行)。 */
27
+ abbr: string;
28
+ /** 品牌底色(圆角块背景);文字固定白色。 */
29
+ color: string;
30
+ }
31
+ /**
32
+ * provider 名 → 品牌标识。空 / 未识别时退化为中性块。
33
+ *
34
+ * 未识别的兜底与圆桌一致:取名称里的字母数字前两位大写;一个字符都没有时(例如
35
+ * provider 为 `***`)用 `?`,宁可不说话也不编一个假缩写。
36
+ */
37
+ export declare function providerBrand(provider: string | undefined): Brand;
38
+ /**
39
+ * provider + model 的**路由标记**缩写。
40
+ *
41
+ * 刻意**只返回 provider 的缩写**:这个块画的是 provider 的品牌色,若里面写的是模型名
42
+ * 的首字母(`deepseek-v4-flash` → `DEFL`),一个颜色块就同时表达了两个互相矛盾的身份
43
+ * —— 用户会以为 `DEFL` 是某个 provider。块的语义必须单一:**底色与字母都只属于
44
+ * provider**;模型是哪一档由块右边那行完整路由文本回答(`routeText`)。
45
+ *
46
+ * 参数收下 `model` 只为了在模型缺失时也能给出同一个标记(两者行为一致),
47
+ * 保留它是为了让调用点读起来是"给一个路由",而不是"给一个 provider"。
48
+ */
49
+ export declare function routeAbbr(provider: string | undefined, model: string | undefined): string;
50
+ /**
51
+ * provider + model 的完整可读串(`deepseek-official / deepseek-v4-flash`)。
52
+ *
53
+ * 缩写块有 `title`,但 `title` 在触屏上读不到 —— 列表行因此同时给出完整路由文本。
54
+ */
55
+ export declare function routeText(provider: string | undefined, model: string | undefined): string;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * dsh-plugin-crossfire — browser half.
3
+ *
4
+ * 批 1 只注册一个 entry:`settings.section`(id `crossfire`)—— 设置页。
5
+ * 批 3 会加 `tool.call.toolview`(key = `crossfire_*` 工具名)承载轨迹内卡片;
6
+ * `conversation.chat.node` **不可用**(宿主固定 key 表,插件加不了新 kind),
7
+ * 这一点已实测,写在这里以免以后有人再试一次。
8
+ *
9
+ * ## inject 为什么只有两个名字
10
+ *
11
+ * cordis 的 `inject` 是**加载门禁**:列进去的服务必须存在,否则整个 client 插件
12
+ * 保持 PENDING —— 表现是"设置页根本不存在"且没有任何报错。`connection` 因此
13
+ * 刻意**不列**,改用 `ctx.get('connection')` 懒取(它就是下面 RPC 兜底传输需要
14
+ * 的东西)。这是圆桌 v0.2.21 用一次真实故障换来的教训。
15
+ *
16
+ * @module dsh-plugin-crossfire/client
17
+ */
18
+ import type { Context } from '@deepseek-ai/cordis';
19
+ /** Required services: the two slot registries plus locale (see module doc). */
20
+ export declare const inject: string[];
21
+ export declare function apply(ctx: Context): void;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Locale dictionaries for the Crossfire browser UI.
3
+ *
4
+ * 命名空间 `crossfire` 与圆桌会议的 `roundtable` **完全分开**:两个插件共享一个
5
+ * 命名空间会让任意一方加键就撞上另一方。
6
+ *
7
+ * @module dsh-plugin-crossfire/client/locales
8
+ */
9
+ export declare const NS = "crossfire";
10
+ /** 本插件用到的全部文案键。新增键必须同时补 zh 与 en,否则 Record 类型会报错。 */
11
+ export type CrossfireKey = 'renderFailed' | 'renderFailedHint' | 'tab' | 'settingsNav' | 'settingsTitle' | 'settingsIntro' | 'boundaryTitle' | 'boundaryLine1' | 'boundaryLine2' | 'boundaryLine3' | 'boundaryLine4' | 'defaultIntensity' | 'intensityQuick' | 'intensityStandard' | 'intensityDeep' | 'intensityHint' | 'intensityPerDimension' | 'intensityCrossYes' | 'intensityCrossNo' | 'intensityRecheckCap' | 'dimensionsTitle' | 'dimensionsHint' | 'poolLabel' | 'customLabel' | 'customEmpty' | 'customPlaceholder' | 'customAdd' | 'customRemove' | 'customLimit' | 'customInvalid' | 'budgetTitle' | 'budgetHint' | 'budgetRounds' | 'budgetTokens' | 'expertMaxTokens' | 'expertMaxOpinions' | 'unlimited' | 'loading' | 'loadFailed' | 'saved' | 'saveFailed' | 'batchNote' | 'cardTitle' | 'statusDraft' | 'statusAttacking' | 'statusJudging' | 'statusSettled' | 'statusNext' | 'statusStrip' | 'statusHintDraft' | 'statusHintAttacking' | 'statusHintJudging' | 'statusHintSettled' | 'passBadge' | 'subjectLabel' | 'stanceLabel' | 'stanceEmpty' | 'dimensionsLabel' | 'expertsLabel' | 'expertPending' | 'expertRunning' | 'expertDone' | 'expertFailed' | 'expertSilent' | 'expertDefects' | 'expertReported' | 'expertSubmitted' | 'expertNoOutput' | 'impactTitle' | 'countEndorsed' | 'countRejected' | 'countPending' | 'defectEmpty' | 'defectsTitle' | 'evidenceRepro' | 'evidenceArgument' | 'strikesStance' | 'dimensionFallback' | 'recheckLabel' | 'recheckNote' | 'verdictFixed' | 'verdictUnfixed' | 'verdictPartial' | 'verdictUndecided' | 'recheckNoteNeedsVerdict' | 'btnAttack' | 'btnCollect' | 'btnFinish' | 'btnRecheck' | 'btnExport' | 'btnEndorse' | 'btnReject' | 'btnPending' | 'rejectPrompt' | 'rejectPlaceholder' | 'rejectConfirm' | 'rejectCancel' | 'busyWorking' | 'noReviews' | 'cardHint' | 'actionFailed' | 'cardFooterNote' | 'noSessionHint' | 'expand' | 'collapse' | 'expandSection' | 'collapseSection' | 'expertsTitle' | 'expertRosterHint' | 'expertRoleLabel' | 'expertRolePlaceholder' | 'expertRoleEdit' | 'expertRoleSaved' | 'expertRoleFromRoster' | 'stanceSectionTitle' | 'stanceExpand' | 'stanceCollapse' | 'explainDismiss' | 'recheckApprovalAsk' | 'recheckApprovalYes' | 'recheckApprovalNo' | 'settingsExplainTitle' | 'settingsExplainRestore' | 'settingsExplainNone' | 'settingsExplainCount' | 'stanceEdit' | 'stanceSave' | 'stanceSaved' | 'stanceHint' | 'exportDone' | 'exportFailed' | 'settingsPresetsTitle' | 'settingsPresetsHint' | 'settingsPresetsEmpty' | 'presetName' | 'presetRole' | 'presetProvider' | 'presetModel' | 'modelInherit' | 'presetAdd' | 'presetAddCount' | 'presetLimitInline' | 'modelRoute' | 'modelCatalogLoading' | 'modelCatalogFailed' | 'modelCatalogEmpty' | 'presetProviderNeeded' | 'presetSave' | 'presetCancel' | 'presetEdit' | 'presetDelete' | 'presetInvalid' | 'presetLimit' | 'presetSaved' | 'settingsSquadsTitle' | 'settingsSquadsHint' | 'settingsSquadsEmpty' | 'squadAdd' | 'squadAddCount' | 'squadName' | 'squadMembers' | 'squadMemberKey' | 'squadMemberRole' | 'squadAddMember' | 'squadRemoveMember' | 'squadSave' | 'squadCancel' | 'squadEdit' | 'squadDelete' | 'squadInvalid' | 'squadSaved' | 'squadLimit' | 'squadMemberCount';
12
+ declare module '@deepseek-ai/dsh-client-ui-slots' {
13
+ interface LocaleNamespaceMap {
14
+ /** The crossfire settings page copy. */
15
+ crossfire: CrossfireKey;
16
+ }
17
+ }
18
+ export declare const zh: Record<CrossfireKey, string>;
19
+ export declare const en: Record<CrossfireKey, string>;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * 预设 / 阵容的列表合并规则(纯函数)。
3
+ *
4
+ * ## 为什么单独一个模块
5
+ *
6
+ * 它修的是一个**静默数据丢失**:设置页编辑既有条目后保存,界面报"已保存",改动却被
7
+ * 丢掉。链路是
8
+ * 客户端 append 同 id 条目(旧比较判据在编辑场景下必然走 append)
9
+ * → host `sanitizeRolePresets` 按 id 去重、保留**第一个**(= 编辑前的旧值)
10
+ * → 界面拿到回包里的旧值,用户的编辑无声消失。
11
+ *
12
+ * 症状与"保存失败"完全不同(没有任何错误提示),所以只有断言能钉住它。
13
+ *
14
+ * 放在独立的 `.ts` 里而不是 `SettingsView.tsx` 内:Node 的 type stripping 支持 `.ts`,
15
+ * 但**不支持 `.tsx`**(JSX 无法剥离),所以测试只能 import `.ts` 模块。
16
+ *
17
+ * @module dsh-plugin-crossfire/client/merge
18
+ */
19
+ import type { WireRolePreset, WireRoleSquad } from './wire.ts';
20
+ /**
21
+ * 把一条预设并入数组:**同 id 就地替换,新 id 才追加**。
22
+ *
23
+ * 位置保持不变(编辑不应把条目挪到末尾,否则列表顺序会在每次编辑后跳动)。
24
+ */
25
+ export declare function mergePreset(list: readonly WireRolePreset[], preset: WireRolePreset): WireRolePreset[];
26
+ /** 阵容版本:与 {@link mergePreset} 同一规则(同 id 就地替换,新 id 追加)。 */
27
+ export declare function mergeSquad(list: readonly WireRoleSquad[], squad: WireRoleSquad): WireRoleSquad[];
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Render-failure boundary for the Crossfire slot entries.
3
+ *
4
+ * WHY THIS EXISTS: DSH retires a slot entry that throws during render
5
+ * (`onEntryError` + abdication) —— 隔离本身是对的,但对用户来说就是"针锋的
6
+ * 设置页根本不存在",且任何地方都没有提示。那种静默消失正是最难从外部诊断的
7
+ * 故障形态。
8
+ *
9
+ * 给每个 entry 套一层 boundary,就把"崩溃"变成一块写明错误的可读面板,同时把
10
+ * 同样的信息(含组件栈)镜像到浏览器控制台。boundary 刻意不渲染自己的任何数据,
11
+ * 因此它不可能成为第二个故障源;刷新后 React 一旦渲染出健康树,面板即被换掉。
12
+ *
13
+ * @module dsh-plugin-crossfire/client/slot-boundary
14
+ */
15
+ import { Component, type ComponentType, type ErrorInfo, type ReactNode } from 'react';
16
+ /** The inject face every Crossfire slot entry receives (see client/index.ts). */
17
+ export interface SlotEntryFace {
18
+ /** Locale-bound translator for the `crossfire` namespace. */
19
+ t?: (key: string) => string;
20
+ }
21
+ interface SlotBoundaryProps {
22
+ title: string;
23
+ hint: string;
24
+ children?: ReactNode;
25
+ }
26
+ interface SlotBoundaryState {
27
+ error: Error | null;
28
+ }
29
+ /** Class-component boundary (React only catches render errors in class components). */
30
+ export declare class SlotBoundary extends Component<SlotBoundaryProps, SlotBoundaryState> {
31
+ state: SlotBoundaryState;
32
+ static getDerivedStateFromError(error: Error): SlotBoundaryState;
33
+ componentDidCatch(error: Error, info: ErrorInfo): void;
34
+ render(): ReactNode;
35
+ }
36
+ /**
37
+ * Wrap one slot component so a render crash renders a report instead of
38
+ * vanishing. The inner component keeps receiving every prop it got before
39
+ * (owner share + standard seats + the inject face), so this is transparent on
40
+ * the happy path.
41
+ *
42
+ * @param Inner - the registered slot component.
43
+ * @returns a component with the same props contract.
44
+ */
45
+ export declare function withSlotBoundary<P extends object>(Inner: ComponentType<P>): ComponentType<P>;
46
+ export {};