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.
- package/CHANGELOG.md +100 -0
- package/README.md +17 -12
- package/client.js +201 -27
- package/index.js +443 -72
- 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
|
|
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
|
|
287
|
-
|
|
288
|
-
|
|
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
|
|
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 **
|
|
776
|
-
smoke **
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1540
|
-
|
|
1541
|
-
const
|
|
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
|
-
|
|
2618
|
+
INLINE_TOKEN_RE.lastIndex = 0
|
|
2532
2619
|
let last = 0
|
|
2533
2620
|
let match
|
|
2534
2621
|
let index = 0
|
|
2535
|
-
while ((match =
|
|
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
|
|
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' },
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
5429
|
-
|
|
5430
|
-
|
|
5431
|
-
|
|
5432
|
-
|
|
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:
|
|
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, {
|
|
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(
|
|
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()
|