dsh-sidecard-ask 2.0.1 → 2.0.2

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 (5) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +17 -12
  3. package/client.js +201 -27
  4. package/index.js +443 -72
  5. package/package.json +4 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,105 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.0.2
4
+
5
+ **主题:跨端(网页端 / 桌面端)排查后的六个修复 —— 插件 API 补上宿主会话鉴权、诊断上报不再互相覆盖、prompt 预算真正守住 60k、SMTP 密码状态自洽、未知接口状态码对称、用户层配置按 profile 分文件。**
6
+
7
+ 排查方式:网页端在真实宿主上跑运行态探针(curl 打全部 7 个接口 + 真跑一次 SSE 作答);桌面端读
8
+ `DeepSeek Harness.exe` 里 `app.asar` 的 `lib/main.js`(网络转发、权限、下载拦截、窗口参数)做静态取证。
9
+
10
+ ### 安全
11
+
12
+ - **插件 API 之前绕过了宿主的 401 登录门禁**(D1)。`ctx.webServer.register` 注册的 prefix 路由不经过
13
+ `dsh-client-connection` 的 `authorizeIndex`,插件自己又只做「回环 / 可信域 + `Origin` + `Sec-Fetch-Site`」围栏——
14
+ 实测**不带任何 cookie 的 `POST /sidecard-ask/api/ask` 真的会 spawn 一个子代理**,`POST /config` 会写配置。
15
+ 现在在围栏之后追加一次 `ctx.get('connection').requestRejection(req)`:
16
+ - 返回 `401` → 一律 `{code:"unauthorized"}`「需要先通过 DSH 登录(宿主会话未认证)」;
17
+ - 返回 `403` → `{code:"forbidden"}`「请求来源不受信任」;
18
+ - 宿主**没有**挂载 `connection` 服务(旧组合 / 单测)→ 只记一条 info 日志,退回原来的单层围栏;
19
+ - 校验本身抛错 → fail-closed 按 401 拒绝。
20
+ 桌面端不受影响:`forwardWebRequest` 本来就会注入宿主启动时换来的鉴权 cookie(`lib/main.js:8268-8299`)。
21
+
22
+ ### 修复
23
+
24
+ - **`/diagnose` 上报是 last-write-wins**(D2)。`clientReport = report` 无条件覆盖,于是「哪个页面最后上报,
25
+ `/state` 里的客户端诊断就只剩它看到的那点东西」——实测发一个 `{}` 就能把整段诊断清空。改为
26
+ `mergeClientReport(previous, next)`:本次没观测到的字段保留旧值(`slots` / `native` / `better` 浅合并),
27
+ `at` 取新值;完全空的上报直接 `400 bad-request`,不再当成一次有效观测。
28
+ - **prompt 预算算不准**(D3)。`truncateSelection` 把省略标记加在预算之外(给 100 字符的预算返回 119 字符),
29
+ `fixedLength` 又比实际 join 少算一个分隔符,实测总量落到 60018–60023(`MAX_PROMPT_CHARS = 60000`),
30
+ 极端组合(超长 question + 6 轮历史)能把 selection 压到 1 个字符。现在:
31
+ - 省略标记**计入**预算,任何 `maxChars` 下结果都 ≤ cap(`test/contract-test.mjs` 用 9 档 cap × 900 点的
32
+ emoji 样本逐档断言);
33
+ - 固定块先算长度、selection 吃剩余预算,问题行长按 `MAX_QUESTION_CHARS = 20000` 先截断,
34
+ selection 保留 `SELECTION_FLOOR_CHARS = 2000` 下限,预算不够时按轮丢弃更旧的历史而不是把 selection 压没;
35
+ - 最坏组合(19000 字问题 + 6 轮历史 + 20000 字 selection)总量验证 ≤ 60000;
36
+ - `start` 帧新增 `promptTruncated` / `promptDroppedChars` / `promptHistoryDropped`:prompt 侧的截断
37
+ 以前对客户端完全不可见,用户只会看到"答案变短了"。
38
+ - **SMTP 状态自相矛盾**(D4)。`configured` 走 `resolveSmtp()`(环境变量优先),`hasPassword` 却只看设置页里那一格,
39
+ 于是「凭据来自环境变量」时报出 `configured: true` + `hasPassword: false`。现在 `hasPassword` 表示
40
+ **手上真的有一份能用的密码**(任一层),并新增 `source`(`config` / `env`)与 `storedPassword`
41
+ (设置页里是否存了一份)区分两层来源。
42
+ - **未知接口的状态码不对称**(D5)。`GET /nope` 回 405(`method-error`)、`POST /nope` 回 404(`not-found`)——
43
+ 方法名判定排在动词判定之后。现在先判方法名:任何动词打未知接口都是 `404 not-found`,已知接口用错动词才是 405。
44
+ - **网页端与桌面端共用同一份 `config.json`**(D6)。两边是同一台机器上的两个 profile,共用一个 `DSH_HOME`,
45
+ 于是 web 端存的 `sideTools: inherit` 会直接作用于桌面端的运行。用户层文件改为
46
+ `<DSH_HOME>/sidecard-ask/config.<DSH_PROFILE>.json`(profile 名只接受 `[A-Za-z0-9._-]`,否则退回旧名,
47
+ 防止路径穿越);`DSH_PROFILE` 未设置时仍是 `config.json`,**旧文件不迁移、不删除**:新 profile 第一次启动
48
+ 会继承它一次(`/state` 的 `provenance.persistedInheritedFrom` 会说明),下一次保存在自己的文件里落盘。
49
+ - **客户端 `getState()` 丢掉了宿主的错误码**(D1 的配套)。`ok !== true` 时统一抛 `bad-response`,于是 401 会被
50
+ 渲染成"宿主接口不可达"。现在透传 `body.error.code`,并给 `unauthorized` / `forbidden` 各加一条中英文案。
51
+
52
+ ### 复审追加(同版本内)
53
+
54
+ - **一个 `%` 就能打断设置页**。`parseSmtpUrl` 用 `decodeURIComponent` 解账号/密码,密码里带未转义的 `%`
55
+ (`pass%word`)会抛 `URIError`;异常经 `describeFeedback → resolveSmtp → readSmtpFromEnv` 一路逃到 `/state`,
56
+ 而 `/state` 分支当时在 `routeHandler` 的 `try` 之外,于是整页变成「宿主接口不可达」。现在解析失败返回
57
+ `{ok:false, reason}`,且 `/state` 与其余接口共用同一个 try(失败回可读的 500,而不是未处理的拒绝)。
58
+ - **`history` 里的坏条目会把提问打成 TypeError**。`composeSidePrompt` 直接读 `turn.question`,`history:[null]`
59
+ 在 `ask()` 的 try 之前抛出,前端只看到英文 `Cannot read properties of null`。现在非对象条目被丢弃;
60
+ 顺带修掉 `historyTurns: 0` 被 `slice(-0)` 解释成「全部历史」的边界。
61
+ - **历史持久化在配额满时是 O(n²)**。按字段上限单卡最坏 ~0.9 MB、默认 10 卡 ~8.8 MB,超过常见 5 MB 配额后
62
+ 旧实现逐张丢弃并整份重新 `JSON.stringify`。现在先按 2M UTF-16 单元的总预算裁剪,失败时折半丢弃。
63
+ - **答案流式期间重复解析 reasoning**。`renderRichText(card.reasoning)` 未 memo,答案每 50 ms 刷新都重解析
64
+ 整段思考过程;现在与正文一样按文本 memo。
65
+ - **旧版插件探测在启动 6 秒内全文档扫描**。`MutationObserver` 每次 DOM 变更都跑
66
+ `querySelectorAll('.dsa-layer:not([data-dsa-owner])')`,现在合并成 250 ms 一次的尾随扫描,卸载时清定时器。
67
+ - **已存的 SMTP 密码无法清除**(空串=保留)。新增 `feedbackSmtpPassClear: true` 显式清除。
68
+ - `inlineNodes` 的 token 正则从每次调用新建改为模块级常量(用前复位 `lastIndex`)。
69
+ - `openSse.end()` 在流已关闭时提前返回、跳过 `clearInterval`,现在关闭动作统一走 `shutdown()`。
70
+ - README 自测规模更正为实测值,并补上本轮 9 条回归断言(坏 `%` 两处、坏 `history` 条目、`historyTurns: 0`、
71
+ 配额下的裁剪/落盘/删除保持)——现为 verify 57 + contract 1377 + float-placement 82 + smoke 262 = **1778 项**;
72
+ 同时删掉仓库里未声明、无 lock 的 `node_modules`(js-yaml / argparse)残留,`.acl-recovery/` 加入 `.gitignore`。
73
+
74
+ ### 未修:宿主 / 安装包侧(不是本插件能修的)
75
+
76
+ - **桌面端在本机冷启动即崩**:`DeepSeek Harness.exe` 6 秒内退出,唯一确凿栈是
77
+ `Received fatal exception EXCEPTION_BREAKPOINT` → `electron::fuses::IsLoadBrowserProcessSpecificV8SnapshotEnabled`
78
+ → `v8::ScriptCompiler::CreateCodeCacheForFunction`,崩在 V8 snapshot fuse 初始化阶段、早于任何插件加载。
79
+ 安装根还缺 `browser_v8_context_snapshot.bin`。因此桌面端的结论只能来自 `app.asar` 静态取证,没有运行态验证。
80
+ - **桌面壳没有 `app.setAppUserModelId`**(只有 `setAsDefaultProtocolClient("dsh")`,`lib/main.js:13055`):
81
+ Windows 上 toast 通知可能不显示。插件 `notifyOnDone` 走的就是 `new window.Notification`,这一项**待验证**,
82
+ 插件侧无法兜底(`Notification` 是桌面壳的默认会话权限,非麦克风权限一律放行)。
83
+
84
+ ### 排查中推翻的三个旧判断
85
+
86
+ 1. **桌面端下载没有被取消。** `will-download → preventDefault()` 只出现在 `dsh-policy-auth-*` 登录窗
87
+ (`lib/main.js:10113`)和侧边栏 webview 的 `dsh-sidebar-browser-*` 分区(`lib/main.js:10632`,由
88
+ `configureSession` 安装)。主窗口走 `defaultSession`,没有该拦截,`downloadMarkdown()` 在桌面端可用。
89
+ 2. **剪贴板不存在"静默无操作"。** `copyText(text, fallbackElement)` 在 `navigator.clipboard` 缺失或失败时
90
+ 会 `selectNodeContents` 选中卡片正文并返回 `'selected'`,最后才 `'failed'`。
91
+ 3. **`getState()` 没有未处理的 rejection。** 它的两个调用点都收口(`await` + `.catch(() => {})`)。
92
+
93
+ ### 测试
94
+
95
+ - `node test/contract-test.mjs` 1377 通过(2.0.1 是 1334);`smoke-test.mjs` 262(原 226);`verify.mjs` 57;
96
+ `float-placement.mjs` 82。合计 **1778 通过,0 失败**。
97
+ - 新增覆盖:`truncateSelection` 的「结果 ≤ cap」不变式(9 档 cap)、最坏组合的 prompt 预算、`start` 帧的
98
+ prompt 侧截断字段、`/diagnose` 的合并与空上报拒绝、宿主会话门禁四种返回(401 / 403 / undefined / 抛错)、
99
+ 未知接口在 GET/HEAD/POST 下的 404、profile 分文件与一次性继承、`describeSmtpSettings` 的两层来源;
100
+ 复审追加 9 条:坏 `%` 的解析与 `/state` 降级(4)、坏 `history` 条目与 `historyTurns: 0`(2)、
101
+ 配额下的历史裁剪 / 落盘 / 删除保持(3)。
102
+
3
103
  ## 2.0.1
4
104
 
5
105
  **主题:清掉 6 个死 i18n 键。无行为变化,无 API / 配置变化,升级无需任何操作。**
package/README.md CHANGED
@@ -153,7 +153,8 @@ plugin_manager(action="install_bundle", target="D:\\Users\\34332\\AI\\dsh-sideca
153
153
 
154
154
  1. **内置默认值**(`index.js` 的 `DEFAULT_CONFIG`)
155
155
  2. **bundle 补丁层**:`cordis.patch.yml` 的 `config:`(改这里需要重启)
156
- 3. **用户层**:设置页保存后写入 `<DSH_HOME>/sidecard-ask/config.json`
156
+ 3. **用户层**:设置页保存后写入 `<DSH_HOME>/sidecard-ask/config.<profile>.json`,其中 `<profile>` 取自
157
+ `DSH_PROFILE`(web 端与桌面端各一份,互不覆盖);`DSH_PROFILE` 未设置时用旧名 `config.json`
157
158
  (`DSH_HOME` 未设置或为空白时回退到 `~/.dsh`;**不会**写进程当前目录)
158
159
 
159
160
  设置页点「重置为 patch 配置」会清空用户层。
@@ -187,7 +188,8 @@ plugin_manager(action="install_bundle", target="D:\\Users\\34332\\AI\\dsh-sideca
187
188
  | `feedbackSmtpPort` | 数字,1–65535 | `465` | SMTP 端口。465 配隐式 TLS,587 配 STARTTLS(服务器没声明 STARTTLS 时不会硬升级) | 立即 |
188
189
  | `feedbackSmtpSecure` | 布尔 | `true` | 隐式 TLS(465)。关掉则按 STARTTLS 处理 | 立即 |
189
190
  | `feedbackSmtpUser` | 字符串,≤254 | `''` | SMTP 登录账号。部分中继给的是裸账号名而非邮箱,故不强制邮箱格式 | 立即 |
190
- | `feedbackSmtpPass` | 字符串,≤512 | `''` | **密码 / 授权码**(QQ、163 必须用授权码)。**只写不读**:`/state` 会删掉它、页面永远拿到空串;留空保存=保留原值 | 立即 |
191
+ | `feedbackSmtpPass` | 字符串,≤512 | `''` | **密码 / 授权码**(QQ、163 必须用授权码)。**只写不读**:`/state` 会删掉它、页面永远拿到空串;留空保存=保留原值(要删掉已存的密码用下一行的 `feedbackSmtpPassClear`) | 立即 |
192
+ | `feedbackSmtpPassClear` | 布尔 | `false` | **显式清除已存的 SMTP 密码**(典型场景:改回用环境变量)。只在 `/config` 请求里生效,本身不落盘;与 `feedbackSmtpPass` 同时出现时以清除为准 | 立即 |
191
193
  | `feedbackSmtpFrom` | 字符串(邮箱)\| `''` | `''` | 发信账号显式指定的 `From:`,留空等于 `feedbackSmtpUser`。必须是 SMTP 服务商允许的地址 | 立即 |
192
194
 
193
195
  非法值会被拒绝(设置页报错、接口返回 `invalid-config`),并把被忽略的项列在 `/state` 的 `problems` 里——不会静默吞掉。
@@ -283,14 +285,14 @@ bleeds = 对话区右边界 - left > 1 // 只有带宽 < 240px 时才
283
285
 
284
286
  **收件邮箱默认可用,也可被环境变量接管。** 打包默认值是维护者邮箱(`b64:` 混淆存储,见上表);
285
287
  环境变量 `DSH_SIDECARD_FEEDBACK_TO` 或 `DSH_CRB_FEEDBACK_TO`(与 dsh-conversation-recycle-bin 共用)
286
- **优先**于它。用户层配置落在 `$DSH_HOME/sidecard-ask/config.json`
287
- (Windows 默认 `C:\Users\<你>\.dsh\sidecard-ask\config.json`)——**不在插件目录里,因此不会被提交、
288
- 不会进 npm 包**。
288
+ **优先**于它。用户层配置落在 `$DSH_HOME/sidecard-ask/config.<profile>.json`
289
+ (`<profile>` = `DSH_PROFILE`;Windows 默认 `C:\Users\<你>\.dsh\sidecard-ask\config.web.json`
290
+ / `config.desktop.json`,两个壳各一份)——**不在插件目录里,因此不会被提交、不会进 npm 包**。
289
291
 
290
292
  **SMTP 凭证有两种给法**,都落在部署方/维护者这一侧(1.9.1 起设置页不再提供填写框):
291
293
 
292
- 1. **用户层配置**(`$DSH_HOME/sidecard-ask/config.json`,Windows 默认
293
- `C:\Users\<你>\.dsh\sidecard-ask\config.json`,或 profile 的 `cordis.patch.yml` 里给该行写 `config`):
294
+ 1. **用户层配置**(`$DSH_HOME/sidecard-ask/config.<profile>.json`,Windows 默认
295
+ `C:\Users\<你>\.dsh\sidecard-ask\config.web.json`,或 profile 的 `cordis.patch.yml` 里给该行写 `config`):
294
296
  `feedbackSmtpHost` / `feedbackSmtpPort` / `feedbackSmtpSecure` / `feedbackSmtpUser` / `feedbackSmtpPass` /
295
297
  `feedbackSmtpFrom`。**密码只写不读**:`/state` 删除该键、页面永远拿到空串,留空保存表示保留原值。
296
298
  QQ 邮箱请填 `smtp.qq.com` / 465 / `secure: true` / 你的 QQ 邮箱 / **授权码**
@@ -772,9 +774,9 @@ node test/float-placement.mjs # 浮层定位矩阵
772
774
  node test/smoke-test.mjs # 冒烟:装载、槽位、渲染、错误路径
773
775
  ```
774
776
 
775
- 当前规模(本机实测,四项均 exit 0):verify **57** 项 + contract **1239** 项 + float-placement **78** 项 +
776
- smoke **226** 项 = **1600 项全部通过**(较 1.8.4 的 1359 多 241 项;contract 从 989 涨到 1239,新增大多来自
777
- 反馈功能:
777
+ 当前规模(本机实测,四项均 exit 0):verify **57** 项 + contract **1377** 项 + float-placement **82** 项 +
778
+ smoke **262** 项 = **1778 项全部通过**(较 1.8.4 的 1359 多 419 项;contract 从 989 涨到 1377,新增大多来自
779
+ 反馈功能与 2.0.x 的跨端修复:
778
780
  纯函数边界、`parseSmtpUrl` / `readSmtpFromEnv` 两种写法(含复用 `DSH_CRB_SMTP_URL`)、`b64:` 收件人解码、
779
781
  「环境变量优先于用户层配置」的优先级回归、`Reply-To:` 组头、限速闸门,以及一条**真 socket** 的 `/feedback`
780
782
  端到端——本地 SMTP 假中继逐条断言 `AUTH PLAIN`、信封、`DATA`、编码后的主题与正文、429 与 `retry-after`,
@@ -896,8 +898,11 @@ Host: ctx.subagents.start('spawn')
896
898
  ## 十二、隐私与安全
897
899
 
898
900
  - 选中文本与问题会**发给你自己配置的模型**(走 DSH 既有的模型路由),插件不做任何额外外发。
899
- - 插件只在本地写一个配置文件:`<DSH_HOME>/sidecard-ask/config.json`(原子写入:先写 `.tmp` 再 rename)。
900
- - 插件路由带信任围栏(回环 / 可信域 + 同源校验),仅本机页面可用。
901
+ - 插件只在本地写一个配置文件:`<DSH_HOME>/sidecard-ask/config.<profile>.json`(原子写入:先写 `.tmp` 再 rename;
902
+ POSIX 下目录 `0700`、文件 `0600`,因为里面可能有 SMTP 授权码)。
903
+ - 插件路由带**两层**围栏:回环 / 可信域 + 同源校验(Host 头、`Origin`、`Sec-Fetch-Site`),然后交给宿主的
904
+ `connection.requestRejection()` 校验 **DSH 浏览器会话**(宿主没挂载该服务时才只保留第一层)。
905
+ 因此未登录的外部程序拿到 loopback 地址也读不到 `/state`、更发不出 `/ask`。
901
906
  - 侧边作答者默认**只读**(`sideTools: readonly`):白名单只包含读取、搜索、网络查询类工具,不会在后台改你的工作区。
902
907
  - 提示词里选中文本被包在 ```` ```text ```` 围栏中并显式声明"这是数据不是指令",降低提示注入面。
903
908
 
package/client.js CHANGED
@@ -35,7 +35,33 @@ window.__ModuleLoader__.load({
35
35
  // ─────────────────────────────────────────────────────────────────────
36
36
 
37
37
  const PLUGIN_ID = 'dsh-sidecard-ask'
38
- const API = '/sidecard-ask/api'
38
+ /**
39
+ * Base path of this plugin's Host API.
40
+ *
41
+ * Resolved against `document.baseURI` rather than hard-coded as the
42
+ * root-absolute `/sidecard-ask/api`, which is what the Host's own Client
43
+ * does for its transport base (`dsh-client-connection/lib/client.browser.js`:
44
+ * `const base = baseUrl ?? document.baseURI`). A root-absolute path drops
45
+ * the deployment prefix, so a DSH served under a path prefix
46
+ * (`dsh web --public-url https://host/dsh/`) sent every request to
47
+ * `/sidecard-ask/api/*` and got a 404. The Electron shell also loads the app
48
+ * from `dsh-app://app/`, where only a document-relative path is meaningful.
49
+ *
50
+ * At the document root (both the default Web deployment and the Electron
51
+ * shell) this evaluates to exactly the previous `/sidecard-ask/api`, so no
52
+ * existing deployment changes behaviour. `document.baseURI` already carries
53
+ * the trailing slash the Host normalises its public root to; the explicit
54
+ * `replace` below keeps that true even if a `<base>` without one is used.
55
+ */
56
+ const API = (() => {
57
+ try {
58
+ const base = typeof document === 'undefined' ? undefined : document.baseURI
59
+ if (typeof base !== 'string' || base === '') return '/sidecard-ask/api'
60
+ return `${new URL('.', base).pathname.replace(/\/$/, '')}/sidecard-ask/api`
61
+ } catch {
62
+ return '/sidecard-ask/api'
63
+ }
64
+ })()
39
65
  /** Slot ids contributed by this plugin (also the overlay entry ids). */
40
66
  const IDS = {
41
67
  overlay: 'sidecard-ask',
@@ -47,7 +73,7 @@ window.__ModuleLoader__.load({
47
73
  * the Host and the module's `api.version` both read it, so a report can
48
74
  * never claim a generation the browser is not actually running.
49
75
  */
50
- const CLIENT_VERSION = '2.0.1'
76
+ const CLIENT_VERSION = '2.0.2'
51
77
 
52
78
  /**
53
79
  * Tab kind served by the DSH native right rail (also its implementation id).
@@ -77,6 +103,12 @@ window.__ModuleLoader__.load({
77
103
  */
78
104
  const HISTORY_TURNS = 6
79
105
 
106
+ // Per-turn wire caps mirroring the Host's prompt-side limits: a long earlier
107
+ // answer must neither bloat the request body past the 1 MiB limit nor push
108
+ // the current question out of the child prompt.
109
+ const HISTORY_TURN_Q_CHARS = 500
110
+ const HISTORY_TURN_A_CHARS = 4000
111
+
80
112
  /**
81
113
  * Client-side mirror of the Host `DEFAULT_CONFIG`. The client needs values
82
114
  * before `/state` answers (first paint) and when the Host is unreachable;
@@ -508,6 +540,8 @@ window.__ModuleLoader__.load({
508
540
  errorNoParentBody: '当前没有活动的会话代理,无法发起独立作答',
509
541
  errorAborted: '作答被取消或超时',
510
542
  errorUnreachableBody: '宿主接口不可达',
543
+ errorUnauthorizedBody: '需要先通过 DSH 登录(宿主会话未认证)',
544
+ errorForbiddenBody: '请求来源不受信任(Host / Origin 不匹配)',
511
545
  errorBadResponseBody: '宿主返回了无法解析的响应',
512
546
  errorStreamBrokenBody: '流式连接中断',
513
547
  errorStreamEndedBody: '流式连接意外结束',
@@ -776,6 +810,8 @@ window.__ModuleLoader__.load({
776
810
  errorNoParentBody: 'No active session agent to answer independently',
777
811
  errorAborted: 'The answer was cancelled or timed out',
778
812
  errorUnreachableBody: 'The host API is unreachable',
813
+ errorUnauthorizedBody: 'Sign in to DSH first (the host session is not authenticated)',
814
+ errorForbiddenBody: 'Untrusted request origin (Host / Origin mismatch)',
779
815
  errorBadResponseBody: 'The host returned an unreadable response',
780
816
  errorStreamBrokenBody: 'The stream connection broke',
781
817
  errorStreamEndedBody: 'The stream ended without a terminal event',
@@ -1049,6 +1085,11 @@ window.__ModuleLoader__.load({
1049
1085
  'no-parent': { key: 'errorNoParentBody' },
1050
1086
  aborted: { key: 'errorAborted' },
1051
1087
  unreachable: { key: 'errorUnreachableBody' },
1088
+ // The Host now gates these routes on the DSH browser session, so both are
1089
+ // fully known Client-side: "log in" and "untrusted origin" are actionable
1090
+ // in a way a generic unreachable message is not.
1091
+ unauthorized: { key: 'errorUnauthorizedBody' },
1092
+ forbidden: { key: 'errorForbiddenBody' },
1052
1093
  'bad-response': { key: 'errorBadResponseBody' },
1053
1094
  'stream-broken': { key: 'errorStreamBrokenBody' },
1054
1095
  'stream-ended': { key: 'errorStreamEndedBody' },
@@ -1102,10 +1143,24 @@ window.__ModuleLoader__.load({
1102
1143
 
1103
1144
  /** GET the plugin state (`/state` also answers POST, GET keeps it cacheable-free). */
1104
1145
  async function getState() {
1105
- const response = await fetch(`${API}/state`, { method: 'GET' })
1106
- const body = await response.json()
1107
- if (body?.ok !== true) throw Object.assign(new Error('state failed'), { code: 'bad-response' })
1108
- return body.value
1146
+ let response
1147
+ try {
1148
+ response = await fetch(`${API}/state`, { method: 'GET' })
1149
+ } catch (error) {
1150
+ throw Object.assign(new Error(String(error?.message ?? error)), { code: 'unreachable' })
1151
+ }
1152
+ let body
1153
+ try {
1154
+ body = await response.json()
1155
+ } catch {
1156
+ throw Object.assign(new Error(`HTTP ${response.status}`), { code: 'bad-response' })
1157
+ }
1158
+ // Keep the Host's own code: a 401 ("log in first") and a 403 ("untrusted
1159
+ // origin") are actionable in a way that a generic bad-response is not.
1160
+ if (body?.ok === true) return body.value
1161
+ const failure = new Error(body?.error?.message ?? `HTTP ${response.status}`)
1162
+ failure.code = body?.error?.code ?? 'bad-response'
1163
+ throw failure
1109
1164
  }
1110
1165
 
1111
1166
  /**
@@ -1130,7 +1185,18 @@ window.__ModuleLoader__.load({
1130
1185
  return
1131
1186
  }
1132
1187
  if (!response.ok || response.body === null) {
1133
- handlers.onError({ code: `http-${response.status}`, message: `HTTP ${response.status}` })
1188
+ // Non-200 answers are JSON errors ({ok:false,error:{code,message}}),
1189
+ // not SSE; surface the server's localized code instead of a bare
1190
+ // "HTTP 400".
1191
+ let wire = null
1192
+ try {
1193
+ const rawBody = await response.text()
1194
+ const parsed = rawBody ? JSON.parse(rawBody) : null
1195
+ if (parsed?.error !== null && typeof parsed?.error === 'object') wire = parsed.error
1196
+ } catch { wire = null }
1197
+ handlers.onError(wire && typeof wire.code === 'string'
1198
+ ? wire
1199
+ : { code: `http-${response.status}`, message: `HTTP ${response.status}` })
1134
1200
  return
1135
1201
  }
1136
1202
  const reader = response.body.getReader()
@@ -1531,14 +1597,25 @@ window.__ModuleLoader__.load({
1531
1597
  * Truncate an over-long selection for an outgoing prompt, keeping head and
1532
1598
  * tail. Mirrors the Host's `truncateSelection` (asserted by contract test):
1533
1599
  * code points, not UTF-16 units, so a surrogate pair is never split.
1600
+ *
1601
+ * The omission note is charged to the budget, not added on top of it: an
1602
+ * "inclusive cap" that the note itself overflows by ~19 characters is not a
1603
+ * cap, and the Host sizes its prompt budget from what a fixed prompt
1604
+ * leaves over.
1534
1605
  * @returns {{text: string, truncated: boolean, droppedChars: number}}
1535
1606
  */
1536
1607
  function truncateSelection(text, maxChars) {
1537
1608
  const source = typeof text === 'string' ? text : ''
1538
1609
  const points = Array.from(source)
1539
- if (points.length <= maxChars) return { text: source, truncated: false, droppedChars: 0 }
1540
- const head = Math.max(1, Math.ceil(maxChars * 0.7))
1541
- const tail = Math.max(0, maxChars - head)
1610
+ const cap = Number.isFinite(maxChars) ? Math.max(0, Math.floor(maxChars)) : points.length
1611
+ if (points.length <= cap) return { text: source, truncated: false, droppedChars: 0 }
1612
+ const noteLength = Array.from(`\n…(已省略中间 ${points.length} 个字符)…\n`).length
1613
+ const room = cap - noteLength
1614
+ if (room < 2) {
1615
+ return { text: points.slice(0, cap).join(''), truncated: true, droppedChars: points.length - cap }
1616
+ }
1617
+ const head = Math.max(1, Math.ceil(room * 0.7))
1618
+ const tail = Math.max(0, room - head)
1542
1619
  const dropped = points.length - head - tail
1543
1620
  return {
1544
1621
  text: `${points.slice(0, head).join('')}\n…(已省略中间 ${dropped} 个字符)…\n${tail > 0 ? points.slice(points.length - tail).join('') : ''}`,
@@ -2525,14 +2602,24 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
2525
2602
  return h('style', { 'data-dsa-style': '' }, CSS_TEXT)
2526
2603
  }
2527
2604
 
2605
+ /**
2606
+ * Inline markdown-lite token scanner.
2607
+ *
2608
+ * Hoisted out of {@link inlineNodes} because a long answer calls it once per
2609
+ * line/paragraph on every render, and a fresh RegExp literal per call was
2610
+ * pure allocation on the streaming hot path. `/g` is stateful, so the
2611
+ * scanner resets `lastIndex` before each use (it never recurses).
2612
+ */
2613
+ const INLINE_TOKEN_RE = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*\n]+\*|\[[^\]\n]+\]\(https?:\/\/[^\s)]+\))/g
2614
+
2528
2615
  /** Inline markdown-lite: `code`, **bold**, *italic*, [label](http url) — never innerHTML. */
2529
2616
  function inlineNodes(text, key) {
2530
2617
  const out = []
2531
- const pattern = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*\n]+\*|\[[^\]\n]+\]\(https?:\/\/[^\s)]+\))/g
2618
+ INLINE_TOKEN_RE.lastIndex = 0
2532
2619
  let last = 0
2533
2620
  let match
2534
2621
  let index = 0
2535
- while ((match = pattern.exec(text)) !== null) {
2622
+ while ((match = INLINE_TOKEN_RE.exec(text)) !== null) {
2536
2623
  if (match.index > last) out.push(text.slice(last, match.index))
2537
2624
  const token = match[0]
2538
2625
  if (token.startsWith('`')) {
@@ -3873,18 +3960,27 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
3873
3960
  // context, not the answer, and a long chain-of-thought would otherwise
3874
3961
  // bury the answer the user asked for. Collapsed by default, open on
3875
3962
  // demand — the text inside still streams live while it is open.
3876
- const reasoning = typeof card.reasoning === 'string' && card.reasoning !== ''
3963
+ const reasoningText = typeof card.reasoning === 'string' ? card.reasoning : ''
3964
+ // Memoised like the answer below: without this every 50 ms delta of the
3965
+ // ANSWER re-parsed the whole reasoning stream as well, and reasoning is
3966
+ // usually the longer of the two.
3967
+ const reasoningNodes = React.useMemo(() => renderRichText(reasoningText), [reasoningText])
3968
+ const reasoning = reasoningText !== ''
3877
3969
  ? h('details', { className: 'dsa-details' },
3878
3970
  h('summary', null, t('reasoningTitle')),
3879
- h('div', { className: 'dsa-reasoning' }, renderRichText(card.reasoning)))
3971
+ h('div', { className: 'dsa-reasoning' }, reasoningNodes))
3880
3972
  : null
3881
3973
  // P2-4: the `<followups>` block is machine-readable scaffolding, never
3882
3974
  // prose. While streaming it is hidden from the first unclosed tag (a
3883
3975
  // half-written marker must not flash); once terminal the whole block is
3884
3976
  // stripped and only the answer is rendered.
3885
- const bodyText = card.status === 'streaming'
3977
+ // P6: parseFollowUps/streamVisibleText run over the whole answer on every
3978
+ // 50ms flush; memoize by the text so an unrelated store update does not
3979
+ // re-parse a long answer.
3980
+ const bodyText = React.useMemo(() => card.status === 'streaming'
3886
3981
  ? streamVisibleText(card.text)
3887
- : parseFollowUps(card.text).text
3982
+ : parseFollowUps(card.text).text, [card.text, card.status])
3983
+ const answerNodes = React.useMemo(() => renderRichText(bodyText), [bodyText])
3888
3984
  // Follow the stream only while the reader sits at the bottom; scrolling
3889
3985
  // up to reread an earlier paragraph must win over the auto-scroll. The
3890
3986
  // 48px slack keeps single-line deltas from losing the tail.
@@ -3914,7 +4010,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
3914
4010
  return h('div', null, reasoning, h('div', { className: 'dsa-muted' }, t('answerEmpty')))
3915
4011
  }
3916
4012
  return h('div', { className: 'dsa-answer', ref: containerRef },
3917
- reasoning, renderRichText(bodyText))
4013
+ reasoning, answerNodes)
3918
4014
  }
3919
4015
 
3920
4016
  /** Card footer: status, copy, follow-up, retry/edit-retry, close. */
@@ -5382,6 +5478,31 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
5382
5478
  const PERSIST_LEGACY_KEY = 'dsh-sidecard-ask:cards:v1'
5383
5479
  const PERSIST_FALLBACK_CARDS = 10
5384
5480
  const PERSIST_TEXT_LIMIT = 65536
5481
+ /**
5482
+ * Total character budget for the whole v3 payload (every card, every
5483
+ * field), in UTF-16 code units — what the localStorage quota counts.
5484
+ *
5485
+ * The per-field cap alone allows ~0.9 MB for ONE card (four 64k fields
5486
+ * plus five 64k×2 versions), so the default 10-card history could reach
5487
+ * ~9 MB and blow the usual 5 MB quota on every write, which then fell
5488
+ * into the drop-one-and-retry loop below. 2M units stays under the
5489
+ * quota for mixed CJK/ASCII content and still holds ~20 long answers.
5490
+ */
5491
+ const PERSIST_TOTAL_CHARS = 2_000_000
5492
+
5493
+ /** Size of one history entry, in UTF-16 code units. */
5494
+ const entryChars = (entry) => {
5495
+ let total = 0
5496
+ for (const key of ['question', 'selected', 'selection', 'text', 'reasoning']) {
5497
+ total += String(entry?.[key] ?? '').length
5498
+ }
5499
+ for (const version of Array.isArray(entry?.versions) ? entry.versions : []) {
5500
+ total += String(version?.question ?? '').length
5501
+ + String(version?.text ?? '').length
5502
+ + String(version?.reasoning ?? '').length
5503
+ }
5504
+ return total
5505
+ }
5385
5506
 
5386
5507
  /** Configured durable cap, clamped to the Host's 0–50 range. */
5387
5508
  const historyCap = () => {
@@ -5425,12 +5546,36 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
5425
5546
  }
5426
5547
 
5427
5548
  function writePersisted(cards) {
5428
- try {
5429
- window.localStorage?.setItem(PERSIST_KEY, JSON.stringify({ version: 3, cards }))
5430
- } catch (error) {
5431
- // Quota or privacy mode — the cards stay on screen, just not durable.
5432
- console.warn(`[${PLUGIN_ID}] card history not persisted:`, error)
5549
+ let remaining = Array.isArray(cards) ? [...cards] : []
5550
+ // Budget the WHOLE payload first (see PERSIST_TOTAL_CHARS). Trimming
5551
+ // here keeps the common case on the first successful write instead of
5552
+ // walking into the quota branch at all.
5553
+ let total = remaining.reduce((sum, entry) => sum + entryChars(entry), 0)
5554
+ while (remaining.length > 0 && total > PERSIST_TOTAL_CHARS) {
5555
+ total -= entryChars(remaining[remaining.length - 1])
5556
+ remaining.pop()
5557
+ }
5558
+ // Newest first (cards are prepended); on quota, drop the OLDEST (tail)
5559
+ // and retry instead of abandoning every card. An EMPTY array must still
5560
+ // be written: deleting the last card clears the durable set on disk.
5561
+ // Returns what actually landed so the manager mirrors the durable set.
5562
+ let lastError = null
5563
+ for (;;) {
5564
+ try {
5565
+ window.localStorage?.setItem(PERSIST_KEY, JSON.stringify({ version: 3, cards: remaining }))
5566
+ return remaining
5567
+ } catch (error) {
5568
+ lastError = error
5569
+ if (remaining.length === 0) break
5570
+ // Drop HALF, not one: every retry re-serialises the whole payload,
5571
+ // so the old one-at-a-time fallback was O(n²) stringify work on the
5572
+ // main thread — exactly while the UI is finishing a card.
5573
+ remaining = remaining.slice(0, Math.floor(remaining.length / 2))
5574
+ }
5433
5575
  }
5576
+ // Quota or privacy mode — the cards stay on screen, just not durable.
5577
+ if (lastError !== null) console.warn(`[${PLUGIN_ID}] card history not persisted:`, lastError)
5578
+ return []
5434
5579
  }
5435
5580
 
5436
5581
  /** Recompute the durable set from the live store and write it. */
@@ -5480,8 +5625,8 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
5480
5625
  store.set({ history: [] })
5481
5626
  return
5482
5627
  }
5483
- writePersisted(finished)
5484
- store.set({ history: finished })
5628
+ const persisted = writePersisted(finished)
5629
+ store.set({ history: persisted })
5485
5630
  }
5486
5631
 
5487
5632
  /** Replay finished cards from a previous page load into the store. */
@@ -5905,7 +6050,10 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
5905
6050
  return result
5906
6051
  },
5907
6052
  followUp(card, text) {
5908
- const history = [...card.history, { question: card.question, answer: card.text }]
6053
+ const history = [...card.history, {
6054
+ question: String(card.question ?? '').slice(0, HISTORY_TURN_Q_CHARS),
6055
+ answer: String(card.text ?? '').slice(0, HISTORY_TURN_A_CHARS),
6056
+ }].slice(-HISTORY_TURNS)
5909
6057
  const next = patchCard(card.id, {
5910
6058
  question: text,
5911
6059
  history,
@@ -6146,7 +6294,10 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
6146
6294
  zone: card.zone,
6147
6295
  carrier: 'side',
6148
6296
  sessionId: currentSessionId(ctx),
6149
- history: card.history.slice(-HISTORY_TURNS),
6297
+ history: card.history.slice(-HISTORY_TURNS).map(turn => ({
6298
+ question: String(turn?.question ?? '').slice(0, HISTORY_TURN_Q_CHARS),
6299
+ answer: String(turn?.answer ?? '').slice(0, HISTORY_TURN_A_CHARS),
6300
+ })),
6150
6301
  }, {
6151
6302
  signal: controller.signal,
6152
6303
  onStart: (data) => {
@@ -6789,12 +6940,33 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
6789
6940
  scheduleReport(0)
6790
6941
  }
6791
6942
  const legacyTimers = [300, 1200, 3000].map(delay => setTimeout(detectLegacy, delay))
6943
+ // Coalesce the observer: `detectLegacy` runs a whole-document
6944
+ // `querySelectorAll`, and a streaming answer mutates the DOM dozens of
6945
+ // times per second — unthrottled, every one of those mutations became a
6946
+ // full-tree scan for the first six seconds of the page's life. One
6947
+ // trailing scan per 250 ms is plenty for a layer that only has to be
6948
+ // noticed, never measured.
6949
+ let legacyScanTimer = null
6950
+ const queueLegacyScan = () => {
6951
+ if (legacyScanTimer !== null) return
6952
+ legacyScanTimer = setTimeout(() => {
6953
+ legacyScanTimer = null
6954
+ detectLegacy()
6955
+ }, 250)
6956
+ }
6792
6957
  if (typeof document !== 'undefined' && typeof MutationObserver === 'function') {
6793
- legacyObserver = new MutationObserver(() => detectLegacy())
6958
+ legacyObserver = new MutationObserver(queueLegacyScan)
6794
6959
  try {
6795
6960
  legacyObserver.observe(document.documentElement ?? document, { childList: true, subtree: true })
6796
6961
  } catch { /* document without a root yet — timers still cover it */ }
6797
6962
  }
6963
+ // After the boot probe window closes with no legacy, stop the
6964
+ // document-wide observer: left attached it would re-scan the whole tree
6965
+ // on every DOM mutation for the page's lifetime.
6966
+ const legacyStopTimer = setTimeout(() => {
6967
+ try { legacyObserver?.disconnect() } catch { /* already gone */ }
6968
+ legacyObserver = null
6969
+ }, 6000)
6798
6970
 
6799
6971
  reelect()
6800
6972
  void refreshState().catch(() => { /* the settings page shows the failure */ })
@@ -6815,6 +6987,8 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
6815
6987
  if (toastTimer !== null) clearTimeout(toastTimer)
6816
6988
  if (reportTimer !== null) clearTimeout(reportTimer)
6817
6989
  for (const timer of legacyTimers) clearTimeout(timer)
6990
+ clearTimeout(legacyStopTimer)
6991
+ if (legacyScanTimer !== null) clearTimeout(legacyScanTimer)
6818
6992
  try {
6819
6993
  if (typeof document !== 'undefined' && document.getElementById) {
6820
6994
  document.getElementById('dsa-family-legacy-style')?.remove()