dsh-instruction-memory 1.3.3 → 1.4.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,38 @@
7
7
  > 1.0.13 起由 CI 经 OIDC Trusted Publishing 发布(首个带 provenance 的版本)。
8
8
  > 这些历史 tag **不应回填**——npm 上的版本号已被占用。
9
9
 
10
+ ## [1.4.0] — 2026-10-18
11
+
12
+ ### 新增
13
+
14
+ - **设置页「反馈」按钮(可选 SMTP 邮件)**:位于设置卡片最上方,填写问题类型、标题、正文与可选联系方式后,
15
+ 由 Host 进程通过 `node:net` / `node:tls` 手写的 SMTP 会话直接发信,**未引入任何第三方依赖**。
16
+ 正文自动附带插件版本(运行时读 `package.json`,不硬编码)、Node/DSH 版本、平台与架构、
17
+ 界面语言、时区、`User-Agent` 与提交时间,**不附带任何条目正文或形象内容**。
18
+ 新增 `lib/mailer.js`(表单校验、环境变量/仓库外 `feedback.json` 配置、MIME 组信、SMTP 会话)
19
+ 与 Host 的 `send-feedback` 分发方法;客户端补充加载态、成功/失败提示、30 秒冷却与错误日志。
20
+ 收件邮箱、SMTP 主机与凭证只从环境变量或仓库外配置文件读取,**不写入仓库**。
21
+ 表单「联系方式」若为纯邮箱地址会自动写入 `Reply-To`,维护者可一键回复反馈者;自由文本不会进入邮件头。
22
+ - **收件邮箱静态加密(可选)**:`feedback.json` 支持 `toEncrypted`(`im1:` + AES-256-GCM 密文,
23
+ scrypt 派生密钥,含认证标签),密钥只从 `DSH_INSTRUCTION_MEMORY_FEEDBACK_SECRET` 读取;
24
+ `node lib/mailer.js encrypt-to <email>` 生成密文。错误提示、页面与日志中的发件/收件地址会被隐藏。
25
+ 明确边界:SMTP 信封仍向服务器明文提交收件人,本项只保护配置文件静态副本。
26
+ - **复用已有 SMTP 凭据**:可读取本机共享的 Windows 用户级 `DSH_CRB_SMTP_URL`
27
+ (`smtps://user:pass@host:port`),逐字段优先级为插件专用 `..._SMTP_*` > 该 URL > `feedback.json`;
28
+ `smtp://` 默认 STARTTLS、端口 465 自动按隐式 TLS,特殊字符需 URL 编码,错误信息不回显 URL/密码。
29
+ - 测试新增 `feedback-test.mjs`(纯函数 + 假 SMTP 服务器 + 真实路由端到端 + 加解密/密钥错误用例),
30
+ 并在 `ui-test.mjs` 覆盖按钮/表单/本地校验/未配置提示/清空/收起及「反馈位于卡片最上方」。
31
+ README 增加配置、加密与排查说明。
32
+
33
+ ### 修复
34
+
35
+ - **开发安装后,插件能运行但不出现在插件市场的「已安装」列表**:宿主只需 junction +
36
+ `dsh.profile.bundles` 就能装载插件,而 dshmarket 的已安装列表只取 profile `package.json`
37
+ 的 `dependencies`(`readInstalled()`),因此「开发安装」的插件一直对市场不可见。
38
+ `install.mjs` 现在增加第三步,把 `dsh-instruction-memory: link:<本仓库路径>` 写进 profile
39
+ 依赖(`--remove` 同步删除),幂等且与 `pnpm-lock.yaml` 一致。
40
+ 仅影响开发安装流程;npm 包内容不变(`files` 不含 `install.mjs`)。
41
+
10
42
  ## [1.3.3] — 2026-10-16
11
43
 
12
44
  一处注释整理,无代码与行为变更。
package/README.md CHANGED
@@ -37,7 +37,7 @@ dsh plugin --profile web remove dsh-instruction-memory
37
37
  | `lib/index.js` | Host 半:读取/保存记忆,注册 `systemPrompt` 段落,暴露同源 JSON 路由 |
38
38
  | `lib/client.js` | Client 半:在 `settings.section` 注册「指令记忆」设置页(手写模块,无需构建) |
39
39
  | `cordis.patch.yml` | bundle patch:把 Host 行插入宿主组合 |
40
- | `install.mjs` | 开发安装脚本:从本仓库检出安装时,把包链接进 web profile 的 `node_modules`(junction)并注册 `dsh.profile.bundles`;`--remove` 完整回滚,改 profile 前自动备份 |
40
+ | `install.mjs` | 开发安装脚本:从本仓库检出安装时,把包链接进 web profile 的 `node_modules`(junction)、注册 `dsh.profile.bundles`,并在 profile `dependencies` 里声明 `link:<本仓库路径>`(缺这一步插件能跑,但插件市场的「已安装」列表看不到它——市场只读 `dependencies`);`--remove` 三步完整回滚,改 profile 前自动备份 |
41
41
  | `contract-test.mjs` | 契约测试:驱动真实路由,验证两半之间的响应格式(含配置集、版本历史、源码模式、备份位置) |
42
42
  | `ui-test.mjs` | 设置页行为测试:用一个 ~60 行的 React 垫片真实渲染面板、点按钮/填输入框,再断言注入内容随之变化(不出图、不依赖浏览器) |
43
43
  | `verify.mjs` | 装载验证:按 DSH 的方式加载两半,并逐项断言 `memory.schema.json` 与代码中的上限一致 |
@@ -381,6 +381,122 @@ DSH 就会得到第二份空记忆,看起来像"记忆消失"。
381
381
  请勿在不可信网络暴露,并指向下节「经反向代理对外发布」。该判定与路由实际执行的 Host 校验
382
382
  是**同一个开关**;快照同时携带 `boundHost`(路由注册时捕获的绑定地址),警告会点名具体地址。
383
383
 
384
+ ## 反馈邮件(可选配置)
385
+
386
+ 设置页最上方(第一块)新增「反馈」按钮:填写问题类型、标题、正文与可选联系方式后,由 **Host 进程**
387
+ 直接通过 SMTP 把邮件发到维护者邮箱。客户端只提交表单,不接触任何服务凭证;
388
+ 未配置时按钮仍可点开,提交会返回一条带配置指引的错误,并保留已填内容以便重试。
389
+
390
+ **隐私边界**:邮件自动附带插件版本、Node/DSH 版本、平台与架构、界面语言、时区、
391
+ `User-Agent` 和提交时间;**绝不附带**任何条目正文、形象内容或整份 `memory.json`。
392
+
393
+ **回复路径**:`From` 固定为认证的发件账号;若「联系方式」是一个纯邮箱地址,Host 会把它写成
394
+ `Reply-To`,维护者点「回复」即可直接回到反馈者(微信、GitHub 用户名等自由文本不会进入邮件头)。
395
+
396
+ 配置只认环境变量或仓库外的 JSON 文件,**不要写进 `cordis.patch.yml` 或任何提交进仓库的文件**。
397
+
398
+ ### 环境变量(优先)
399
+
400
+ | 变量 | 必填 | 说明 |
401
+ | --- | --- | --- |
402
+ | `DSH_INSTRUCTION_MEMORY_FEEDBACK_TO` | 是 | 收件邮箱(单个地址) |
403
+ | `DSH_INSTRUCTION_MEMORY_FEEDBACK_SMTP_HOST` | 是 | SMTP 服务器主机名 |
404
+ | `DSH_INSTRUCTION_MEMORY_FEEDBACK_FROM` | 否 | 发件地址,默认取 SMTP 用户名;部分服务商要求与登录账号同域 |
405
+ | `DSH_INSTRUCTION_MEMORY_FEEDBACK_SMTP_PORT` | 否 | 1–65535,默认按 `_SMTP_SECURE` 取 465 / 587 |
406
+ | `DSH_INSTRUCTION_MEMORY_FEEDBACK_SMTP_SECURE` | 否 | `1/true/yes` 走隐式 TLS(465);默认 `0` 并用 STARTTLS 升级(587) |
407
+ | `DSH_INSTRUCTION_MEMORY_FEEDBACK_SMTP_USER` | 否 | 登录用户名;与密码必须成对出现 |
408
+ | `DSH_INSTRUCTION_MEMORY_FEEDBACK_SMTP_PASS` | 否 | 登录密码 / 应用专用密码 |
409
+ | `DSH_CRB_SMTP_URL` | 否 | 复用的 URL 形凭据(格式见下节),优先级低于上面五个 `..._SMTP_*` 变量 |
410
+ | `DSH_INSTRUCTION_MEMORY_FEEDBACK_CONFIG` | 否 | 配置文件路径,默认 `<存储目录>/feedback.json` |
411
+ | `DSH_INSTRUCTION_MEMORY_FEEDBACK_COOLDOWN_MS` | 否 | 两次发送的最小间隔,默认 `30000`,`0` 关闭 |
412
+ | `DSH_INSTRUCTION_MEMORY_FEEDBACK_SECRET` | 否 | 解密 `toEncrypted` 的密钥,至少 16 个字符;只在环境变量里提供,不要写进配置文件或仓库 |
413
+
414
+ 存储目录默认是 `%USERPROFILE%\.dsh\instruction-memory`(受 `DSH_HOME` 影响),
415
+ 即默认配置文件为 `…\instruction-memory\feedback.json`。
416
+
417
+ ### 复用已有的 `DSH_CRB_SMTP_URL`(可选)
418
+
419
+ 如果本机其他工具已经在 **Windows 用户级环境变量** `DSH_CRB_SMTP_URL` 里保存了可用的
420
+ SMTP 凭据,插件可以直接复用,不必再把主机/账号/密码抄一份到本插件的变量或文件里:
421
+
422
+ ```
423
+ smtps://用户:密码@smtp.qq.com:465
424
+ smtp://用户:密码@smtp.example.com:587?secure=0&from=bot@example.com
425
+ ```
426
+
427
+ - `smtps://` 走隐式 TLS(通常 465);`smtp://` 默认 STARTTLS,可用 `secure=1/0` 覆盖;
428
+ `smtp://` 且端口为 465 时按隐式 TLS 处理(服务商常见约定)。
429
+ - 用户名、密码中的 `@ : / %` 等字符必须 URL 编码,例如 `1234567890%40qq.com`。
430
+ - 逐字段优先级:本插件专用 `..._SMTP_*` 变量 > `DSH_CRB_SMTP_URL` > `feedback.json`。
431
+ 因此可以只把密码留在共享 URL 里,其他字段仍用插件变量覆盖。
432
+ - `from` 可用 `?from=` 指定;缺省取 SMTP 用户名(若像邮箱),否则取收件人。
433
+ - 这个变量携带密码:不要写进仓库、不要截图;修改后需要重启 DSH 才会被新进程读到。
434
+
435
+ ### 配置文件(与环境变量逐字段合并,环境变量优先)
436
+
437
+ ```json
438
+ {
439
+ "to": "feedback@example.com",
440
+ "from": "dsh-bot@example.com",
441
+ "smtp": {
442
+ "host": "smtp.example.com",
443
+ "port": 465,
444
+ "secure": true,
445
+ "user": "dsh-bot@example.com",
446
+ "pass": "应用专用密码"
447
+ }
448
+ }
449
+ ```
450
+
451
+ 文件权限请自行收紧(Windows 可用 `icacls feedback.json /inheritance:r /grant:r "%USERNAME%:R"`)。
452
+ 凭据缺失时只报"未配置",不会把已配置的密码回显到页面或日志。
453
+
454
+ ### 收件邮箱加密(可选)
455
+
456
+ 如果不想让收件邮箱以明文留在 `feedback.json` 里(例如担心截图、误提交或备份泄漏),
457
+ 可以把 `"to"` 换成密文字段 `"toEncrypted"`,密钥只放在环境变量里:
458
+
459
+ ```json
460
+ {
461
+ "toEncrypted": "im1:……",
462
+ "smtp": { "host": "smtp.qq.com", "port": 465, "secure": true, "user": "…", "pass": "授权码" }
463
+ }
464
+ ```
465
+
466
+ 1. 设置密钥环境变量(至少 16 个字符,不能写进仓库):
467
+ `DSH_INSTRUCTION_MEMORY_FEEDBACK_SECRET=……`
468
+ 2. 用插件自带的小工具生成密文(密钥从环境变量读取,不会打印到终端):
469
+ `node lib/mailer.js encrypt-to you@example.com`
470
+ 3. 把输出的 `im1:…` 粘贴为 `toEncrypted`,删除 `"to"`,然后重启 DSH(环境变量只在启动时读取)。
471
+
472
+ 实现是 AES-256-GCM + scrypt 派生密钥,密文带认证标签:改错一个字符或密钥不匹配都会明确报
473
+ `无法解密反馈收件邮箱`,不会静默换收件人。若同时存在 `to`(环境变量或文件),明文优先,
474
+ 方便临时覆盖。
475
+
476
+ **能力边界(请勿误解)**:SMTP 信封必须把收件人明文交给服务器,TLS 只保护传输链路;
477
+ 本加密保护的是配置文件**静态副本**,不防"能以同一 Windows 用户身份读取环境变量和文件"的攻击者,
478
+ 也不隐藏邮件服务商看到的收件人。需要更强隔离时,应使用独立的 Windows 账户或独立机器运行 DSH。
479
+ `node lib/mailer.js encrypt-to` 生成的密文与密钥绑定,换机/换密钥后需重新生成。
480
+
481
+ 国内邮箱示例:QQ 邮箱填 `smtp.qq.com`、端口 465、`"secure": true`,用户名为完整 QQ 邮箱地址,
482
+ 密码填在 QQ 邮箱「设置 → 账户」开启 SMTP 后生成的**授权码**(不是登录密码)。
483
+ 若收件人是该 QQ 邮箱、发件人另用其他账号,`from` 要与发件账号同域,否则可能被服务端拒收。
484
+
485
+ ### 发送行为与排查
486
+
487
+ - 465 端口:直接 TLS;587 端口:EHLO → STARTTLS → 再次 EHLO →(有账号时)`AUTH LOGIN`/`AUTH PLAIN`。
488
+ - **拒绝在明文连接上发送密码**:服务器不提供 STARTTLS 时会直接失败,而不是降级。
489
+ - 主题与正文均为 UTF-8(RFC 2047 编码主题、base64 正文),中文不会乱码。
490
+ - 成功后清空草稿并提示"反馈已发送";失败时保留草稿,页面显示原因,宿主日志记一行脱敏错误。
491
+ - 默认 30 秒冷却:同一进程内连续提交会被拒绝(防误触重复发信),可用上面的变量调整。
492
+
493
+ 常见失败:`尚未配置反馈收件邮箱` → 环境变量/文件未生效(环境变量改完要重启 DSH;
494
+ 配置文件每次发送都会重新读取,无需重启);`反馈收件邮箱已加密,但未设置 …SECRET` 或
495
+ `无法解密反馈收件邮箱` → 密钥缺失/不匹配,重新执行 `encrypt-to` 并同步更新环境变量;
496
+ `SMTP 连接失败` → 主机/端口/防火墙;`STARTTLS 失败` → 服务商要求 465 隐式 TLS;
497
+ `认证失败` → 用户名/密码或应用专用密码,部分邮箱需先开启 SMTP 服务;
498
+ `发件地址被拒绝` → 显式设置 `FROM` 为与账号同域的地址。
499
+
384
500
  ## 请求来源防护(Host / Origin)
385
501
 
386
502
  设置页路由之所以要求 `content-type: application/json`,是因为 HTML 表单无法伪造 JSON body,
@@ -451,19 +567,21 @@ GUI 与路由本就同主机不同端口),`none`(用户直接导航)放
451
567
 
452
568
  | 等级 | 证明什么 | 覆盖 |
453
569
  | --- | --- | --- |
454
- | ① **真实启动** | 在该宿主版本上真的把 DSH 跑起来:插件装载、注入真的进入**组装后的提示**、插件 HTTP 路由可应答、客户端设置页模块被下发(Windows / Node 22) | `0.1.3-alpha.2` · `0.1.5-alpha.1` · `0.1.5-alpha.2` · `0.1.5-rc.1` · `0.1.5-rc.2` · **`0.2.0-rc.1`** ⁽*⁾ · **`0.2.0-rc.2`** ⁽**⁾ |
455
- | ② **契约核验** | 抓该版本**真实发布的源码**,逐个核验本插件实际用到的每个符号:`systemPrompt.section()`、`assemble()` 是否求值**函数型** `text`、`FILE_REFERENCE=900` / `TOOL_BASH=1000` 仍在(决定 order 950 是否空档)、重名注册是否抛错、`webServer.register()` / `get host()`、`ctx.inject()` / `ctx.effect()`、`dsh.client.inject` 两包在该版本是否存在且声明了 `dsh.client` | 声明范围接住的**全部 15 个已发布版本**(`0.1.3-alpha.2` → `0.2.1-alpha.1`) |
570
+ | ① **真实启动** | 在该宿主版本上真的把 DSH 跑起来:插件装载、注入真的进入**组装后的提示**、插件 HTTP 路由可应答、客户端设置页模块被下发(Windows / Node 22) | `0.1.3-alpha.2` · `0.1.5-alpha.1` · `0.1.5-alpha.2` · `0.1.5-rc.1` · `0.1.5-rc.2` · `0.1.5-rc.3` · **`0.2.0-rc.1`** ⁽*⁾ · **`0.2.0-rc.2`** ⁽**⁾ · `0.2.1-alpha.2` ⁽***⁾ · **`0.2.1-alpha.3`** ⁽***⁾ · **`0.2.1-alpha.4`** ⁽***⁾ |
571
+ | ② **契约核验** | 抓该版本**真实发布的源码**,逐个核验本插件实际用到的每个符号:`systemPrompt.section()`、`assemble()` 是否求值**函数型** `text`、`FILE_REFERENCE=900` / `TOOL_BASH=1000` 仍在(决定 order 950 是否空档)、重名注册是否抛错、`webServer.register()` / `get host()`、`ctx.inject()` / `ctx.effect()`、`dsh.client.inject` 两包在该版本是否存在且声明了 `dsh.client` | 声明范围接住的**全部 18 个已发布版本**(`0.1.3-alpha.2` → `0.2.1-alpha.4`) |
456
572
  | ③ **真实执行** | 把插件**真的挂载**进该版本的 `@deepseek-ai/cordis`,跑装载 / 路由注册 / 卸载共 9 项断言 | cordis `4.0.2` 与 `4.0.4`(各 9/9) |
457
573
 
458
574
  ⁽*⁾ **`0.2.0-rc.1` 的①档有一条要说明**:插件装载、注入进入组装后的提示、HTTP 路由可应答三项**均已实测**(路由 `HTTP 200`;`injection.registered=true`、`chars=437`、**`pulls=38`**——即 0.2.0-rc.1 的 agent loop 真的求值了 38 次函数型 `text`)。第四项"客户端模块被下发"**没有直接抓取**:首页需要鉴权(`/` 返回 `401`),我无法取到 `window.__DSH_BOOT__`。这一项的支撑是间接但强的两条:① `dsh-client-modules` 在合成阶段对 `dsh.client` 声明做硬校验,不合格会抛 `ClientPackageCompositionError` 直接导致启动失败——宿主正常启动即说明声明通过了 0.2.0-rc.1 的校验;② 本插件用到的三个客户端契约包(`-slots` / `-renderer` / `-settings`)相对升级前真正在跑的 `0.1.7-rc.2` **逐字节未变**。**未做人工视觉确认**(没有打开设置页看)。
459
575
 
460
576
  ⁽**⁾ **`0.2.0-rc.2` 的①档**:桌面版 `0.2.0-rc.2`(私有发行版 `dsh-desktop-runtime`,见仓库 `dsh-memory-verification/DSH-VERSION-COMPAT.md` §12)上完成过路由 200 + `registered=true` + 注入段落真实出现在会话提示中的活体实测;网页版宿主 `0.2.0-rc.2` 上对本插件路由的 `state` 探测同样返回 200。同样**未做设置页人工视觉确认**。
461
577
 
462
- 此外 `host-range-test.mjs` 的固定矩阵覆盖 `0.1.6-alpha.2`、`0.1.7-rc.2`、`0.2.0-rc.1`、`0.2.0-rc.2` 与 `0.2.1-alpha.1`,并会**读取本机实际安装的宿主版本**做实时断言。
578
+ ⁽***⁾ **`0.2.1-alpha.2`(2026-10-10)与 `0.2.1-alpha.3` / `0.2.1-alpha.4`(2026-10-16)的①档**:隔离安装该版本宿主树 + 独立 `DSH_HOME` 装入 npm 正式发布物,真实启动 web 宿主后以 **21 项 HTTP 断言**驱动(CRUD、落盘持久化、SSE 连接帧、`section` 注册/注销、4 项请求门禁、IPv6 同源等价与混源拒绝、30 连发稳定性、清理回基线)——三个版本各 **21/21 PASS**。证据:`dsh-memory-verification/out/compat-1016-live-live-alpha2.json`、`dsh-memory-verification/out/compat-133-live-alpha{3,4}.json`。同样**未做设置页人工视觉确认**。
579
+
580
+ 此外 `host-range-test.mjs` 的固定矩阵覆盖 `0.1.6-alpha.2`、`0.1.7-rc.2`、`0.2.0-rc.1`、`0.2.0-rc.2` 与 `0.2.1-alpha.1`~`0.2.1-alpha.4`(回归守卫把最新验证过的宿主逐名钉住),并会**读取本机实际安装的宿主版本**做实时断言。
463
581
 
464
- **关于 1.0.10 新增的 `ctx.inject`。** 这是新版唯一的硬依赖增量:`inject` 去掉了 `webServer`,设置页路由改为非阻塞的 `ctx.inject(['webServer'], …)` 子 fiber(原因见上)。为此逐个核验了 **cordis 全部 7 个已发布版本**(`4.0.1-rc.1` → `4.0.5-alpha.1`)——**每一个都具备 `ctx.inject`**(其中 4.0.2 / 4.0.4 另做了真实挂载执行,4.0.5-alpha.1 为源码符号核验),因此声明范围内不存在因它而失效的宿主版本。
582
+ **关于 1.0.10 新增的 `ctx.inject`。** 这是新版唯一的硬依赖增量:`inject` 去掉了 `webServer`,设置页路由改为非阻塞的 `ctx.inject(['webServer'], …)` 子 fiber(原因见上)。为此逐个核验了 **cordis 全部 8 个已发布版本**(`4.0.1-rc.1` → `4.0.5-alpha.2`)——**每一个都具备 `ctx.inject`**(其中 4.0.2 / 4.0.4 另做了真实挂载执行,4.0.5-alpha.1 与 4.0.5-alpha.2 为源码符号核验),因此声明范围内不存在因它而失效的宿主版本。
465
583
 
466
- **关于声明范围是否"超范围承诺"。** 已对全部 **30 个**已发布宿主版本穷举核验(2026-10-05,npm 官方 registry):范围内 **15 个**版本的 API 面**全部齐备,超范围承诺 0 项**,且默认与 `includePrerelease` 两种语义在这 15 个版本上零分歧。范围外的 15 个版本(`0.0.1-rc.1` → `0.1.2-rc.1`)API 面**同样齐备**,但它们**从未真实启动验证过**,所以下限维持不动、不向外放宽——不把没验证过的东西写进声明。
584
+ **关于声明范围是否"超范围承诺"。** 已对全部 **33 个**已发布宿主版本核验(2026-10-05 穷举 30 个;新增版本随发布补充——`0.2.1-alpha.2` 于 2026-10-10、`0.2.1-alpha.3` / `0.2.1-alpha.4` 于 2026-10-16,npm 官方 registry):范围内 **18 个**版本的 API 面**全部齐备,超范围承诺 0 项**,且默认与 `includePrerelease` 两种语义在这 18 个版本上零分歧。范围外的 15 个版本(`0.0.1-rc.1` → `0.1.2-rc.1`)API 面**同样齐备**,但它们**从未真实启动验证过**,所以下限维持不动、不向外放宽——不把没验证过的东西写进声明。
467
585
 
468
586
  **关于 `0.2.1-alpha.1`(2026-10-03 发布,1.0.16 起覆盖)。** 它是前述"未来 minor 预发布版本在默认语义下接不住"的**第一个真实发生案例**:1.0.15 的范围在宿主门禁与市场判定(均用 `includePrerelease`)下放行它,但在 npm/pnpm 默认 peer 解析下拒绝(ERESOLVE);其耦合面(9 个契约点)经最近 10 版本矩阵核验全部齐备,cordis 为 `4.0.5-alpha.1`。1.0.16 新增 `>=0.2.1-alpha.0 <0.3.0-0` 枚举段后两种语义一致放行。**该版本仅有契约层证据,未做真机启动实测。**
469
587
 
package/lib/client.js CHANGED
@@ -22,6 +22,12 @@ window.__ModuleLoader__.load({
22
22
 
23
23
  const API = '/instruction-memory/api'
24
24
 
25
+ // Feedback form: the kinds are a closed set shared with the Host (see
26
+ // lib/mailer.js); the ceilings repeat the Host defaults so an older Host
27
+ // that does not publish limits.maxFeedback* still renders a safe form.
28
+ const FEEDBACK_TYPES = ['bug', 'suggestion', 'other']
29
+ const FEEDBACK_DEFAULTS = Object.freeze({ maxTitle: 120, maxBody: 4000, maxContact: 200 })
30
+
25
31
  const CSS = `
26
32
  .im-root { display:flex; flex-direction:column; gap:16px; padding:6px 2px 56px; max-width:860px;
27
33
  color:var(--dsw-alias-label-primary,#1f2329); font-size:13px; line-height:1.6; }
@@ -173,6 +179,16 @@ window.__ModuleLoader__.load({
173
179
  padding:6px 10px; border-radius:8px; font-size:12px;
174
180
  border:1px solid var(--dsw-alias-state-warn-primary,#d97706);
175
181
  background:var(--dsw-alias-state-warn-bg,rgba(217,119,6,.12)); color:var(--dsw-alias-state-warn-primary,#d97706); }
182
+ /* ---- 反馈邮件 ---- */
183
+ .im-feedback { display:flex; flex-direction:column; gap:8px; }
184
+ .im-feedback-head { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
185
+ .im-feedback-head .im-section-title { flex:1; }
186
+ .im-feedback-form { display:flex; flex-direction:column; gap:8px; padding:10px 12px; border-radius:8px;
187
+ border:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.1)); background:var(--dsw-alias-bg-base,#fff); }
188
+ .im-feedback-form .im-textarea { min-height:110px; }
189
+ .im-feedback-hint { font-size:11.5px; color:var(--dsw-alias-label-secondary,#8a9099); }
190
+ .im-feedback-actions { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
191
+ .im-feedback-actions .im-meta { flex:1; }
176
192
  @media (max-width:640px) { .im-grid2 { grid-template-columns:1fr; } }
177
193
  `
178
194
 
@@ -369,6 +385,34 @@ window.__ModuleLoader__.load({
369
385
  'diag.issueWarning': '存储警告',
370
386
  'diag.issueHistory': '历史错误',
371
387
  'diag.issueSection': '注入错误',
388
+ // ---- 反馈邮件 ----
389
+ 'feedback.section': '反馈',
390
+ 'feedback.show': '反馈',
391
+ 'feedback.hide': '收起反馈',
392
+ 'feedback.intro': '遇到问题或有功能建议?填写下面的表单,通过邮件发送给插件维护者。',
393
+ 'feedback.typeLabel': '问题类型',
394
+ 'feedback.typeBug': '问题反馈(Bug)',
395
+ 'feedback.typeSuggestion': '功能建议',
396
+ 'feedback.typeOther': '其他',
397
+ 'feedback.titleLabel': '标题',
398
+ 'feedback.titlePlaceholder': '一句话概括问题或建议',
399
+ 'feedback.bodyLabel': '详细描述',
400
+ 'feedback.bodyPlaceholder': '复现步骤、期望结果、实际表现;建议请说明使用场景',
401
+ 'feedback.contactLabel': '联系方式(可选)',
402
+ 'feedback.contactPlaceholder': '邮箱 / 微信 / GitHub 用户名,便于回复你',
403
+ 'feedback.hint': '发送时会自动附带插件版本、DSH / Node 版本与运行平台,不包含你的指令内容。',
404
+ 'feedback.send': '发送反馈',
405
+ 'feedback.sending': '正在发送…',
406
+ 'feedback.clear': '清空',
407
+ 'feedback.sent': '反馈已发送,感谢你的反馈!',
408
+ 'feedback.failed': '反馈发送失败,请稍后重试。',
409
+ 'feedback.failedDetail': '反馈发送失败:{detail}',
410
+ 'feedback.errType': '请选择有效的问题类型',
411
+ 'feedback.errTitle': '请填写标题',
412
+ 'feedback.errTitleLong': '标题不能超过 {max} 个字符',
413
+ 'feedback.errBody': '请补充详细描述(至少 5 个字符)',
414
+ 'feedback.errBodyLong': '详细描述不能超过 {max} 个字符',
415
+ 'feedback.errContact': '联系方式不能超过 {max} 个字符',
372
416
  // ---- F7 临时静音 ----
373
417
  'mute.menu': '暂停注入…',
374
418
  'mute.30m': '30 分钟',
@@ -620,6 +664,34 @@ window.__ModuleLoader__.load({
620
664
  'diag.issueWarning': 'Storage warning',
621
665
  'diag.issueHistory': 'History error',
622
666
  'diag.issueSection': 'Injection error',
667
+ // ---- feedback mail ----
668
+ 'feedback.section': 'Feedback',
669
+ 'feedback.show': 'Feedback',
670
+ 'feedback.hide': 'Hide feedback',
671
+ 'feedback.intro': 'Found a problem or have a suggestion? Send it to the plugin maintainer by email with the form below.',
672
+ 'feedback.typeLabel': 'Category',
673
+ 'feedback.typeBug': 'Problem report (bug)',
674
+ 'feedback.typeSuggestion': 'Feature request',
675
+ 'feedback.typeOther': 'Other',
676
+ 'feedback.titleLabel': 'Title',
677
+ 'feedback.titlePlaceholder': 'Sum up the problem or suggestion in one line',
678
+ 'feedback.bodyLabel': 'Details',
679
+ 'feedback.bodyPlaceholder': 'Steps to reproduce, expected result, actual result; for a suggestion, describe the use case',
680
+ 'feedback.contactLabel': 'Contact (optional)',
681
+ 'feedback.contactPlaceholder': 'Email / WeChat / GitHub handle so the maintainer can reply',
682
+ 'feedback.hint': 'The mail automatically includes the plugin version, DSH / Node versions and platform — never your instruction content.',
683
+ 'feedback.send': 'Send feedback',
684
+ 'feedback.sending': 'Sending…',
685
+ 'feedback.clear': 'Clear',
686
+ 'feedback.sent': 'Feedback sent — thank you!',
687
+ 'feedback.failed': 'Could not send the feedback. Please try again later.',
688
+ 'feedback.failedDetail': 'Could not send the feedback: {detail}',
689
+ 'feedback.errType': 'Choose a valid category',
690
+ 'feedback.errTitle': 'Enter a title',
691
+ 'feedback.errTitleLong': 'The title must be at most {max} characters',
692
+ 'feedback.errBody': 'Add a description (at least 5 characters)',
693
+ 'feedback.errBodyLong': 'The description must be at most {max} characters',
694
+ 'feedback.errContact': 'The contact must be at most {max} characters',
623
695
  // ---- F7 temporary mute ----
624
696
  'mute.menu': 'Pause injection…',
625
697
  'mute.30m': '30 minutes',
@@ -1280,6 +1352,36 @@ window.__ModuleLoader__.load({
1280
1352
  }
1281
1353
  }
1282
1354
 
1355
+ /**
1356
+ * Client-side mirror of the Host's normalizeFeedback. Returns either
1357
+ * `{ ok: true, value }` (trimmed) or `{ ok: false, message }` (already
1358
+ * translated); the Host re-validates and stays authoritative (a forged
1359
+ * request can bypass every check here).
1360
+ */
1361
+ function feedbackVerdict(type, title, body, contact, limits) {
1362
+ const source = limits !== null && typeof limits === 'object' ? limits : FEEDBACK_DEFAULTS
1363
+ const maxTitle = typeof source.maxFeedbackTitle === 'number' ? source.maxFeedbackTitle : FEEDBACK_DEFAULTS.maxTitle
1364
+ const maxBody = typeof source.maxFeedbackBody === 'number' ? source.maxFeedbackBody : FEEDBACK_DEFAULTS.maxBody
1365
+ const maxContact = typeof source.maxFeedbackContact === 'number' ? source.maxFeedbackContact : FEEDBACK_DEFAULTS.maxContact
1366
+ const kind = typeof type === 'string' ? type : ''
1367
+ if (!FEEDBACK_TYPES.includes(kind)) return { ok: false, message: t('feedback.errType') }
1368
+ const cleanTitle = typeof title === 'string' ? title.trim() : ''
1369
+ if (cleanTitle === '') return { ok: false, message: t('feedback.errTitle') }
1370
+ if (cleanTitle.length > maxTitle) {
1371
+ return { ok: false, message: t('feedback.errTitleLong', { max: maxTitle }) }
1372
+ }
1373
+ const cleanBody = typeof body === 'string' ? body.trim() : ''
1374
+ if (cleanBody.length < 5) return { ok: false, message: t('feedback.errBody') }
1375
+ if (cleanBody.length > maxBody) {
1376
+ return { ok: false, message: t('feedback.errBodyLong', { max: maxBody }) }
1377
+ }
1378
+ const cleanContact = typeof contact === 'string' ? contact.trim() : ''
1379
+ if (cleanContact.length > maxContact) {
1380
+ return { ok: false, message: t('feedback.errContact', { max: maxContact }) }
1381
+ }
1382
+ return { ok: true, value: { type: kind, title: cleanTitle, body: cleanBody, contact: cleanContact } }
1383
+ }
1384
+
1283
1385
  function EntryEditor(props) {
1284
1386
  const draft = props.draft
1285
1387
  const set = (patch) => props.onChange(Object.assign(cloneEntry(draft), patch))
@@ -1548,6 +1650,18 @@ window.__ModuleLoader__.load({
1548
1650
  // pretending the copy happened.
1549
1651
  const [diagOpen, setDiagOpen] = React.useState(false)
1550
1652
  const [diagFallback, setDiagFallback] = React.useState(null)
1653
+ // Feedback form: closed by default so the settings card stays compact.
1654
+ // The draft lives in the panel (not in a child) because the send handler
1655
+ // needs the same values the inputs show, and keeping them here survives
1656
+ // an SSE snapshot absorb while the user is typing.
1657
+ const [feedbackOpen, setFeedbackOpen] = React.useState(false)
1658
+ const [feedbackType, setFeedbackType] = React.useState('bug')
1659
+ const [feedbackTitle, setFeedbackTitle] = React.useState('')
1660
+ const [feedbackBody, setFeedbackBody] = React.useState('')
1661
+ const [feedbackContact, setFeedbackContact] = React.useState('')
1662
+ const [feedbackBusy, setFeedbackBusy] = React.useState(false)
1663
+ const [feedbackError, setFeedbackError] = React.useState('')
1664
+ const [feedbackSent, setFeedbackSent] = React.useState('')
1551
1665
  // F7: the pause menu (null-state boolean) and a 1s clock tick that exists
1552
1666
  // ONLY while a pause is live (its effect cleans up after itself). The
1553
1667
  // tick drives nothing but the remaining-time text; authority stays with
@@ -1877,6 +1991,84 @@ window.__ModuleLoader__.load({
1877
1991
  })
1878
1992
  }
1879
1993
 
1994
+ // ---- 反馈邮件 ----
1995
+
1996
+ function toggleFeedback() {
1997
+ const next = !feedbackOpen
1998
+ setFeedbackOpen(next)
1999
+ setFeedbackError('')
2000
+ if (!next) setFeedbackSent('')
2001
+ }
2002
+
2003
+ function clearFeedback() {
2004
+ setFeedbackType('bug')
2005
+ setFeedbackTitle('')
2006
+ setFeedbackBody('')
2007
+ setFeedbackContact('')
2008
+ setFeedbackError('')
2009
+ setFeedbackSent('')
2010
+ }
2011
+
2012
+ /**
2013
+ * Send one report. The host validates again, enforces its own ceiling and
2014
+ * a cool-down, and decides the recipient from configuration — the panel
2015
+ * only supplies text plus the presentation facts (locale / time zone /
2016
+ * user agent). The error log deliberately carries the same reason the
2017
+ * user sees: a send failure is a configuration or network problem, and
2018
+ * the console is where a maintainer will look for it.
2019
+ */
2020
+ async function submitFeedback() {
2021
+ if (feedbackBusy) return
2022
+ const verdict = feedbackVerdict(feedbackType, feedbackTitle, feedbackBody, feedbackContact, view !== null ? view.limits : null)
2023
+ if (!verdict.ok) {
2024
+ setFeedbackSent('')
2025
+ setFeedbackError(verdict.message)
2026
+ return
2027
+ }
2028
+ setFeedbackBusy(true)
2029
+ setFeedbackError('')
2030
+ setFeedbackSent('')
2031
+ const nav = typeof navigator !== 'undefined' && navigator !== null ? navigator : {}
2032
+ let timezone = ''
2033
+ try {
2034
+ timezone = typeof Intl === 'object' && Intl !== null && typeof Intl.DateTimeFormat === 'function'
2035
+ ? String(Intl.DateTimeFormat().resolvedOptions().timeZone || '')
2036
+ : ''
2037
+ } catch (err) {
2038
+ timezone = ''
2039
+ }
2040
+ try {
2041
+ const result = await callHost('send-feedback', {
2042
+ type: verdict.value.type,
2043
+ title: verdict.value.title,
2044
+ body: verdict.value.body,
2045
+ contact: verdict.value.contact,
2046
+ locale: typeof nav.language === 'string' ? nav.language : '',
2047
+ userAgent: typeof nav.userAgent === 'string' ? nav.userAgent : '',
2048
+ timezone,
2049
+ })
2050
+ if (result === null || typeof result !== 'object' || result.ok !== true) {
2051
+ const message = result !== null && typeof result === 'object' && typeof result.message === 'string' && result.message !== ''
2052
+ ? result.message
2053
+ : t('feedback.failed')
2054
+ setFeedbackError(message)
2055
+ console.error('[instruction-memory] feedback failed:', message)
2056
+ return
2057
+ }
2058
+ setFeedbackSent(t('feedback.sent'))
2059
+ setFeedbackType('bug')
2060
+ setFeedbackTitle('')
2061
+ setFeedbackBody('')
2062
+ setFeedbackContact('')
2063
+ } catch (err) {
2064
+ const detail = String(err && err.message ? err.message : err)
2065
+ setFeedbackError(t('feedback.failedDetail', { detail }))
2066
+ console.error('[instruction-memory] feedback request failed:', detail)
2067
+ } finally {
2068
+ setFeedbackBusy(false)
2069
+ }
2070
+ }
2071
+
1880
2072
  // ---- F16 source mode ----
1881
2073
 
1882
2074
  async function openSource() {
@@ -2142,6 +2334,14 @@ window.__ModuleLoader__.load({
2142
2334
  minBudget: 800, maxBudget: 40000, maxEntries: 200, maxProfiles: 50,
2143
2335
  maxSets: 20, maxSetName: 30, maxHistory: 20, maxBackupDir: 512,
2144
2336
  }
2337
+ // The feedback ceilings only exist on a Host that ships the feedback
2338
+ // route; an older Host hands over limits without them, so the form falls
2339
+ // back to the same defaults the validator uses.
2340
+ const feedbackMax = {
2341
+ title: typeof limits.maxFeedbackTitle === 'number' ? limits.maxFeedbackTitle : FEEDBACK_DEFAULTS.maxTitle,
2342
+ body: typeof limits.maxFeedbackBody === 'number' ? limits.maxFeedbackBody : FEEDBACK_DEFAULTS.maxBody,
2343
+ contact: typeof limits.maxFeedbackContact === 'number' ? limits.maxFeedbackContact : FEEDBACK_DEFAULTS.maxContact,
2344
+ }
2145
2345
  // Mirrored for the debounced source check (see its effect above): reading
2146
2346
  // it here keeps the effect's dependency list down to the text itself.
2147
2347
  sourceCeilingRef.current = typeof limits.maxBodyChars === 'number' ? limits.maxBodyChars : 0
@@ -2318,9 +2518,87 @@ window.__ModuleLoader__.load({
2318
2518
  void send('set-options', { disabledUntil: 0, baseRev: revOf() })
2319
2519
  }
2320
2520
 
2521
+ // Feedback is the first row of the settings card so it is immediately
2522
+ // visible; source mode keeps its own header and never reaches it.
2523
+ const feedbackCard = h('div', { key: 'feedback', className: 'im-feedback' },
2524
+ h('div', { className: 'im-feedback-head' },
2525
+ h('span', { className: 'im-section-title' }, t('feedback.section')),
2526
+ h('span', { className: 'im-spacer' }),
2527
+ h('button', {
2528
+ className: feedbackOpen ? 'im-btn' : 'im-btn im-btn-primary',
2529
+ disabled: busy,
2530
+ onClick: toggleFeedback,
2531
+ }, feedbackOpen ? t('feedback.hide') : t('feedback.show'))),
2532
+ feedbackOpen
2533
+ ? h('div', { className: 'im-feedback-form' },
2534
+ h('div', { className: 'im-meta' }, t('feedback.intro')),
2535
+ h('div', { className: 'im-grid2' },
2536
+ h('div', null,
2537
+ h('label', { className: 'im-label' }, t('feedback.typeLabel')),
2538
+ h('select', {
2539
+ className: 'im-select',
2540
+ value: feedbackType,
2541
+ disabled: feedbackBusy,
2542
+ onChange: (ev) => setFeedbackType(String(ev.target.value)),
2543
+ },
2544
+ h('option', { value: 'bug' }, t('feedback.typeBug')),
2545
+ h('option', { value: 'suggestion' }, t('feedback.typeSuggestion')),
2546
+ h('option', { value: 'other' }, t('feedback.typeOther')))),
2547
+ h('div', null,
2548
+ h('label', { className: 'im-label' }, t('feedback.contactLabel')),
2549
+ h('input', {
2550
+ className: 'im-input',
2551
+ type: 'text',
2552
+ value: feedbackContact,
2553
+ maxLength: feedbackMax.contact,
2554
+ placeholder: t('feedback.contactPlaceholder'),
2555
+ disabled: feedbackBusy,
2556
+ onChange: (ev) => setFeedbackContact(String(ev.target.value)),
2557
+ }))),
2558
+ h('div', null,
2559
+ h('label', { className: 'im-label' }, t('feedback.titleLabel')),
2560
+ h('input', {
2561
+ className: 'im-input',
2562
+ type: 'text',
2563
+ value: feedbackTitle,
2564
+ maxLength: feedbackMax.title,
2565
+ placeholder: t('feedback.titlePlaceholder'),
2566
+ disabled: feedbackBusy,
2567
+ onChange: (ev) => setFeedbackTitle(String(ev.target.value)),
2568
+ })),
2569
+ h('div', null,
2570
+ h('label', { className: 'im-label' }, t('feedback.bodyLabel')),
2571
+ h('textarea', {
2572
+ className: 'im-textarea',
2573
+ value: feedbackBody,
2574
+ maxLength: feedbackMax.body,
2575
+ placeholder: t('feedback.bodyPlaceholder'),
2576
+ disabled: feedbackBusy,
2577
+ onChange: (ev) => setFeedbackBody(String(ev.target.value)),
2578
+ })),
2579
+ h('div', { className: 'im-meta' }, t('feedback.hint')),
2580
+ // Send failures are shown next to the form and never replace the
2581
+ // panel-wide error line: a mail problem must not look like a
2582
+ // storage problem.
2583
+ feedbackError !== '' ? h('div', { className: 'im-err' }, feedbackError) : null,
2584
+ feedbackSent !== '' ? h('div', { className: 'im-ok' }, feedbackSent) : null,
2585
+ h('div', { className: 'im-feedback-actions' },
2586
+ h('button', {
2587
+ className: 'im-btn im-btn-primary',
2588
+ disabled: feedbackBusy,
2589
+ onClick: () => { void submitFeedback() },
2590
+ }, feedbackBusy ? t('feedback.sending') : t('feedback.send')),
2591
+ h('button', {
2592
+ className: 'im-btn',
2593
+ disabled: feedbackBusy,
2594
+ onClick: clearFeedback,
2595
+ }, t('feedback.clear'))))
2596
+ : null)
2597
+
2321
2598
  const header = h('div', { className: 'im-card', key: 'head' },
2322
- // F12: the non-loopback warning sits at the TOP of the panel — not
2323
- // inside the collapsible diagnostics card — so it cannot be missed.
2599
+ feedbackCard,
2600
+ // F12: the non-loopback warning stays high in the panel — directly
2601
+ // under the feedback entry — so it cannot be missed.
2324
2602
  security.hostGuarded === false
2325
2603
  ? h('div', { className: 'im-hostwarn', key: 'hostwarn' },
2326
2604
  t('diag.hostWarn', { host: security.boundHost !== '' ? security.boundHost : '?' }))
@@ -3140,6 +3418,7 @@ window.__ModuleLoader__.load({
3140
3418
  exports.__relevanceTerms = relevanceTerms
3141
3419
  exports.__relevancePreview = relevancePreview
3142
3420
  exports.__sourceParseVerdict = sourceParseVerdict
3421
+ exports.__feedbackVerdict = feedbackVerdict
3143
3422
  exports.__diagnosticsText = diagnosticsText
3144
3423
  exports.__strings = STRINGS
3145
3424
  exports.__ns = NS