@y9931666/dsh-plugin-crossfire 0.1.3 → 0.2.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.
Files changed (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +10 -7
  3. package/lib/index.js +675 -66
  4. package/lib/types/budget.d.ts +86 -0
  5. package/lib/types/client/ReviewCard.d.ts +41 -0
  6. package/lib/types/client/SettingsView.d.ts +34 -0
  7. package/lib/types/client/brand.d.ts +55 -0
  8. package/lib/types/client/defectTitle.d.ts +32 -0
  9. package/lib/types/client/icons.d.ts +23 -0
  10. package/lib/types/client/index.d.ts +21 -0
  11. package/lib/types/client/locales.d.ts +19 -0
  12. package/lib/types/client/merge.d.ts +27 -0
  13. package/lib/types/client/promptView.d.ts +63 -0
  14. package/lib/types/client/session-cache.d.ts +49 -0
  15. package/lib/types/client/slot-boundary.d.ts +46 -0
  16. package/lib/types/client/wire.d.ts +192 -0
  17. package/lib/types/dimensions.d.ts +47 -0
  18. package/lib/types/engine.d.ts +190 -0
  19. package/lib/types/etag.d.ts +40 -0
  20. package/lib/types/expert-events.d.ts +110 -0
  21. package/lib/types/export-md.d.ts +21 -0
  22. package/lib/types/harness-compat.d.ts +104 -0
  23. package/lib/types/index.d.ts +89 -0
  24. package/lib/types/members.d.ts +143 -0
  25. package/lib/types/noise.d.ts +29 -0
  26. package/lib/types/plan.d.ts +111 -0
  27. package/lib/types/preferences.d.ts +134 -0
  28. package/lib/types/prompt.d.ts +16 -0
  29. package/lib/types/redteam.d.ts +123 -0
  30. package/lib/types/review.d.ts +152 -0
  31. package/lib/types/rpc.d.ts +176 -0
  32. package/lib/types/split.d.ts +156 -0
  33. package/lib/types/state.d.ts +84 -0
  34. package/lib/types/tools.d.ts +60 -0
  35. package/lib/types/types.d.ts +336 -0
  36. package/lib/types/version.d.ts +18 -0
  37. package/lib/types/web-guard.d.ts +55 -0
  38. package/package.json +40 -37
  39. package/release-notes/v0.2.0.md +44 -0
@@ -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,32 @@
1
+ /**
2
+ * 观点标题:一条缺陷卡片的"抬头"。
3
+ *
4
+ * 红队的报告信封要求每条发言以 `观点 N(指向 X,维度Y):正文` 开头,而这个前缀
5
+ * 恰恰就是最有信息量的一行(编号 + 定位 + 维度)。用户反馈:"观点虽然说我可以理解,
6
+ * 把完整内容给写出来了,但我觉得还是要在最开始那里立一个标题,这样目的明确"。
7
+ *
8
+ * ## 为什么单独一个模块
9
+ *
10
+ * 标题按钮是**折叠态唯一可见的内容**,所以这里的两条边界(空括号信封、内嵌换行)
11
+ * 一旦退化,肉眼看到的是"整张卡片塌成一团",而不是一条错误文案。它是纯字符串函数,
12
+ * 因此抽出来直接单测 —— 不需要 jsdom / react 测试环境(本仓库只有 `node:test`)。
13
+ *
14
+ * ## 标题从哪里来(三层,逐层兜底)
15
+ *
16
+ * 1. **压缩信封**:`观点 N(指向 dsh-plugin-crossfire/src/client/ReviewCard.module.css:106-176,维度 交互)`
17
+ * → `观点 N · ReviewCard.module.css:106-176 · 交互`
18
+ * 红队写**完整路径**是对的(审计要能定位到文件),但它不该占满标题 —— 用户实测反馈:
19
+ * "标题过长,导致后面无法显示;且有很多无用信息,比如路径的位置,这个可以放在内容里"。
20
+ * 所以完整路径仍留在缺陷正文(可追溯),标题只留**文件名 + 行号 + 维度**。
21
+ * 2. **原始信封**(压缩不出来时,例如括号里没有 `指向`/`维度` 的旧格式)→ 原样返回前缀。
22
+ * 3. **首句兜底**:未按格式提交时取首句(按换行、再按中英文句末标点切,限长)。
23
+ *
24
+ * **绝不**返回空串 —— 标题位空了比不显示更糟。
25
+ */
26
+ /**
27
+ * 从一条缺陷正文里导出标题。见模块注释的三层兜底。
28
+ *
29
+ * @param content 缺陷原文(可以是任意多行文本)。
30
+ * @returns 非空标题,最长 {@link TITLE_MAX} 字符。
31
+ */
32
+ export declare function defectTitle(content: string): string;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * 内联 SVG 图标(零依赖、零资源、随主题变色)。
3
+ *
4
+ * ## 为什么不用图标库
5
+ *
6
+ * 一个图标库会带进几百 KB 和一套自己的设计语言;本插件只需要设置页区块标题前
7
+ * 的少量"目录图标"。这里只保留真正用到的几何形状,描边一律 `currentColor`,
8
+ * 因此浅色 / 深色主题都不用写 override。
9
+ *
10
+ * ## 尺寸约定
11
+ *
12
+ * 16×16 viewBox,1.4px 描边,默认渲染 14px。这些值恰好与正文 13px / 说明 12px
13
+ * 形成一档"辅佐"层级,图标不会比文字更抢。
14
+ *
15
+ * @module dsh-plugin-crossfire/client/icons
16
+ */
17
+ /** 设置页用到的图标名。新增图标必须同时加进 {@link SHAPES}。 */
18
+ export type IconName = 'layers' | 'gauge' | 'role' | 'squad' | 'panel' | 'skill' | 'message';
19
+ /** 渲染一个 16×16 线性图标,颜色跟随 `currentColor`。 */
20
+ export declare function InlineIcon({ name, size }: {
21
+ name: IconName;
22
+ size?: number;
23
+ }): JSX.Element;
@@ -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' | 'countPendingOfTotal' | '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' | 'expertPromptView' | 'expertPromptTitle' | '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,63 @@
1
+ /**
2
+ * "查看专家原提示词"的展开状态机。
3
+ *
4
+ * ## 它修的是什么
5
+ *
6
+ * 展开区此前用"`promptViews[key]` 是否存在"同时表达两件事:**是否有缓存**和
7
+ * **是否展开**。一个变量两个含义,于是两处都错:
8
+ *
9
+ * 1. 命中缓存时直接 `delete`,注释承诺的"只拉一次"变成"每次收起都丢掉缓存",
10
+ * 再点就是一次新的 RPC;
11
+ * 2. 收起发生在请求返回**之前**时,条目被删掉,但异步回调**无条件**把结果写回,
12
+ * 界面在用户已经收起之后又自己冒出来(反弹)。
13
+ *
14
+ * 修法是显式三态(`loading` / `ready` / `error`)+ 两个纯函数:
15
+ * - {@link togglePromptView}:**看有无条目**决定"发起加载"还是"收起",绝不二义;
16
+ * - {@link applyPromptResult}:只在**该键仍处于 loading**时写回结果 —— 用户已收起
17
+ * (键被删)或已经拿到别的结果时,迟到的响应一律丢弃,不再靠 `alive` ref 兜底
18
+ * (那个只在组件卸载时变化,挡不住"收起")。
19
+ *
20
+ * 状态转移与 React 无关,因此可以脱离渲染直接单测。
21
+ */
22
+ /** 一个 viewKey 对应的展开状态。`loading` 的条目存在 = 已展开且正在等结果。 */
23
+ export type PromptView = {
24
+ status: 'loading';
25
+ } | {
26
+ status: 'ready';
27
+ persona: string;
28
+ } | {
29
+ status: 'error';
30
+ message: string;
31
+ };
32
+ /** 展开表:键为 `${reviewId}::${expertKey}`。 */
33
+ export type PromptViewTable = Record<string, PromptView>;
34
+ /**
35
+ * 一次点击的结果:新的展开表 + 这次点击**是否需要真的发请求**。
36
+ *
37
+ * `start` 为 false 即"这只是一次收起",调用方不得发 RPC。
38
+ */
39
+ export interface PromptViewToggle {
40
+ readonly table: PromptViewTable;
41
+ readonly start: boolean;
42
+ }
43
+ /**
44
+ * 点击"查看原提示词":无条目 → 写入 `loading` 并要求发请求;有条目 → 删除该条目
45
+ * (收起),`start` 为 false。
46
+ *
47
+ * 三态一律同样对待:`ready` / `error` / 正在 `loading` 的条目,再点都是收起。
48
+ * 失败后保留 `error` 条目正是为此 —— 用户再点即收起,而不是无限重拉。
49
+ */
50
+ export declare function togglePromptView(table: PromptViewTable, key: string): PromptViewToggle;
51
+ /**
52
+ * 把一次 RPC 结果写回展开表。
53
+ *
54
+ * **只有该键仍处于 `loading` 时才写回**。用户已收起(键不存在)、或该键已经处于
55
+ * 终态时,返回原表**引用不变** —— 这样 `setState` 不会触发一次无意义的重渲染。
56
+ */
57
+ export declare function applyPromptResult(table: PromptViewTable, key: string, result: {
58
+ readonly ok: true;
59
+ readonly persona: string;
60
+ } | {
61
+ readonly ok: false;
62
+ readonly message: string;
63
+ }): PromptViewTable;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * 会话身份的**浏览器侧缓存**(设置页保存失败的真实修复)。
3
+ *
4
+ * ## 它修的是什么
5
+ *
6
+ * `crossfire/prefs.set` 在宿主侧要求"必须声称一个身份"(`judgeActor(undefined, session)`)。
7
+ * 而宿主的 slot 契约决定了设置页**结构上拿不到** `sessionId` —— 运行时 slot 目录里:
8
+ *
9
+ * conversation.view → standardProps 含 `sessionId: SessionId`
10
+ * tool.call.toolview → 含
11
+ * settings.section → **不含**(只有 useResource / useWorkspaces / usePanelInfo /
12
+ * useSessions / useSessionStatus / useSessionRetainInfo)
13
+ *
14
+ * 于是卡片(conversation.view / toolcall)保存偏好成功,而设置页的每一次保存都被
15
+ * 拒成"保存失败"—— Bug 的真实形态:**角色预设 / 阵容预设 / 强度 / 预算全部存不进去**。
16
+ *
17
+ * ## 为什么用一个模块级缓存而不是给设置页造假身份
18
+ *
19
+ * 设置页确实是全局的(偏好也是全局的),但它不能在**不知道自己是谁**的情况下写入。
20
+ * 而会话标签页(`conversation.view`)与轨迹卡片(`tool.call.toolview`)都拿到了宿主
21
+ * 注入的真实 `sessionId` —— 用户打开过任意一个,就等于把身份告诉了插件。这里把它记下来,
22
+ * 供设置页在提交时声明。
23
+ *
24
+ * ## 诚实的边界
25
+ *
26
+ * 冷启动后**直接**打开设置页、且本次会话从未渲染过上述任一入口时,缓存是空的,
27
+ * 保存仍会被宿主拒绝(这是刻意的:插件不编造身份)。绕开方式很简单——先看一眼
28
+ * 「针锋」标签页或对话卡片,再回来保存。
29
+ */
30
+ /**
31
+ * 记下宿主注入的会话 id。
32
+ *
33
+ * 只接受非空字符串;重复值不产生副作用。返回是否**发生了变化**,便于调用方避免
34
+ * 无意义的重渲染。
35
+ */
36
+ export declare function rememberSessionId(value: unknown): boolean;
37
+ /** 当前已知的会话 id(空串 = 尚未观察到,调用方不得据此编造一个)。 */
38
+ export declare function knownSessionId(): string;
39
+ /** 仅供测试:把缓存恢复到初始状态。 */
40
+ export declare function resetSessionIdCache(): void;
41
+ /**
42
+ * 把一个 payload 补上会话身份(已有 `session` 时不覆盖)。
43
+ *
44
+ * 这是设置页真正用得上的那个函数:它只在**确实知道**身份时才附加字段,否则原样返回,
45
+ * 让宿主按它自己的规则拒绝 —— 而不是伪造一个 `session: ''` 去骗过存在性检查。
46
+ */
47
+ export declare function withSessionIdentity<T extends Record<string, unknown>>(payload: T): T & {
48
+ session?: string;
49
+ };
@@ -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 {};