dsh-instruction-memory 1.5.4 → 1.5.5
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 +40 -0
- package/README.md +35 -1
- package/docs/demo.html +671 -0
- package/lib/client.js +114 -10
- package/lib/index.js +60 -5
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,46 @@
|
|
|
7
7
|
> 1.0.13 起由 CI 经 OIDC Trusted Publishing 发布(首个带 provenance 的版本)。
|
|
8
8
|
> 这些历史 tag **不应回填**——npm 上的版本号已被占用。
|
|
9
9
|
|
|
10
|
+
## [1.5.5] — 2026-11-12
|
|
11
|
+
|
|
12
|
+
DSH Go(Android / WebView)适配。**没有真机验证**(本机 `adb devices` 为空),所以这一版只做
|
|
13
|
+
"插件侧能自救的部分":把所有**静默失败**变成可见的降级路径,其余列给平台侧。存储格式、注入文本
|
|
14
|
+
与面板在浏览器/桌面上的行为均不变。
|
|
15
|
+
|
|
16
|
+
### 修复(Android / WebView 静默失败)
|
|
17
|
+
|
|
18
|
+
- **导出在 WebView 上点了没反应**(P1):`<a download>` 只在宿主实现 `DownloadListener` 时才有
|
|
19
|
+
意义,且 Chromium WebView 拿不到 `blob:` 的字节。现在识别 WebView 后不再点那个必然失败的链接,
|
|
20
|
+
直接把完整 JSON 渲染在面板里(可全选复制);非 WebView 路径补上「已开始下载」反馈。
|
|
21
|
+
- **导入 / 从备份恢复依赖文件选择器**(P1):WebView 未实现 `onShowFileChooser` 时点击完全无反应。
|
|
22
|
+
面板在 WebView 上常驻一行提示,指向不依赖任何原生能力的「源码模式」粘贴通道。
|
|
23
|
+
- **批量加标签被误判成"用户取消"**(P1):这段判定当初是为 Electron(`prompt` 抛错)写的;Android
|
|
24
|
+
WebView 是第四种形态——`typeof === 'function'`、不抛错、但返回 `null` 且不弹框。现在 WebView 上
|
|
25
|
+
把 `null` 判为"问不了",直接走内联输入框。
|
|
26
|
+
- **SSE 断流后静默陈旧**(P2):连续 6 次(≈30 秒)订阅失败后显示「实时更新已断开,请重新载入」,
|
|
27
|
+
收到下一帧自动清除。此前只有手动「重新载入」能救。同时给事件流 socket 打开 TCP keepalive 与
|
|
28
|
+
`setNoDelay`,让被系统杀掉的 WebView 能被 OS 更快发现(半开连接占满 32 条上限的窗口收窄)。
|
|
29
|
+
|
|
30
|
+
### 加固
|
|
31
|
+
|
|
32
|
+
- **新增 fail-closed 开关** `DSH_INSTRUCTION_MEMORY_REQUIRE_AUTH_FENCE=1`:缺少宿主 `connection`
|
|
33
|
+
服务时直接 503,而不是退回"仅回环"。Android 的回环由整机共享,"仅回环"在手机上不构成边界。
|
|
34
|
+
- **备份目录的提示在 Linux / Android 上不再给死指令**:原来一律让用户"设置环境变量后重启宿主",
|
|
35
|
+
而 DSH Go 用 `/usr/bin/env -i` 启动引擎,那条路根本走不通;现在补一句"该变量设不了,请改用
|
|
36
|
+
「导出」复制 JSON"。
|
|
37
|
+
|
|
38
|
+
### 其它
|
|
39
|
+
|
|
40
|
+
- 面板 `-webkit-text-size-adjust:100%`(挡住 Android 系统字号把固定 px 卡片排成乱行)、挂载失败
|
|
41
|
+
提示避开 `env(safe-area-inset-bottom)`。
|
|
42
|
+
- `docs/demo.html` 进入 npm 发布物(手机上可直接打开预演布局)。
|
|
43
|
+
|
|
44
|
+
### 测试
|
|
45
|
+
|
|
46
|
+
- `contract-test.mjs`:新增 fail-closed 三连断言(开开关 503 / 文案点名开关 / 关掉同一 handler 恢复
|
|
47
|
+
200),确保"503 是开关造成的"而不是别的原因。
|
|
48
|
+
- `ui-test.mjs`:新增「6 次流错误后必须出现重新载入提示」,并在同一渲染里验证订阅的是 `?lite=1`。
|
|
49
|
+
|
|
10
50
|
## [1.5.4] — 2026-11-12
|
|
11
51
|
|
|
12
52
|
2026-11-12 的三方评审(架构 / 安全 / 验收,桌面端 0.2.2-rc.1 + 网页端同进程)落地的一轮修复。
|
package/README.md
CHANGED
|
@@ -67,7 +67,7 @@ profile 的解析顺序是 **`$DSH_PROFILE_DIR` → `--profile <名字>` → `$D
|
|
|
67
67
|
| `host-range-test.mjs` | 宿主版本范围契约测试:固定矩阵 × 两种 semver 判定模式,并把 `peerDependencies`(**被执行的那个**)、`dsh.engines.dsh`、`dsh.compatibility.dsh` 三处声明锁到同一个生成值 |
|
|
68
68
|
| `lint.mjs` | 静态门禁,已并入 `npm test`:`node --check` 解析所有随包发布与本仓库运行的 JS 文件,并断言 `lib/` 里不出现 `@deepseek-ai/*` 的包名导入(插件只该通过宿主传进来的上下文对象接触宿主,按包名 import 会把插件钉死在某个安装位置,在 headless / CI / 检出目录里直接解析失败) |
|
|
69
69
|
| `live-host-smoke.mjs` | 双端冒烟(**1.4.8 起宿主有鉴权门,需 `--token <启动 token>` 换会话 cookie**,否则全 401):对**正在运行**的 web / 桌面宿主各测 `state` p50/p95、SSE 首事件、注入注册与字符数,并比对两端快照(除 `injection.pulls` 外须逐字段相同);`npm run smoke:hosts`,`--require-pulls` 可把「从未经历真实 Agent 回合」判为失败。单宿主拓扑(桌面应用自己服务 `11685`)用 `--base http://127.0.0.1:11685`,parity 会**显式跳过**而不是假装比过;`--web` / `--desktop` 各选一端,同时给直接报错、零探测时打印 `NO HOST PROBED` 而不是 `ALL CHECKS PASSED`。**刻意不进 `npm test`**——CI 没有桌面端。请求走 Node `http` 直连,绕开 `http_proxy`(否则回环请求也会被代理转发,读数虚高) |
|
|
70
|
-
| `docs/demo.html` | 离线演示页:桩掉 DSH 外壳、加载真实 Client
|
|
70
|
+
| `docs/demo.html` | 离线演示页:桩掉 DSH 外壳、加载真实 Client 半渲染设置页(1.5.5 起随 npm 包发布,手机上也能直接打开预演布局) |
|
|
71
71
|
| `memory.schema.json` | 手改存储文件用的 JSON Schema(随包发布,并被宿主复制到 `memory.json` 旁边) |
|
|
72
72
|
|
|
73
73
|
## 生效方式
|
|
@@ -457,6 +457,9 @@ DSH 就会得到第二份空记忆,看起来像"记忆消失"。相对的 `$DS
|
|
|
457
457
|
把这些设置放在别的盘(比如 Windows 上的 D:)就得显式打开:
|
|
458
458
|
`DSH_INSTRUCTION_MEMORY_ALLOW_EXTERNAL_BACKUP=1`(重启宿主生效)。导出目录受同一条限制;
|
|
459
459
|
手改存储文件把 `backupDir` 指到外面也同样被拒——这条路不能变成创建任意目录的通道。
|
|
460
|
+
**在 Linux / Android 上这句话会说全**:DSH Go 用 `/usr/bin/env -i` 启动引擎,用户根本没有
|
|
461
|
+
设置该变量的途径,因此提示会补上"该变量设不了,请改用「导出」把 JSON 复制出来备份",
|
|
462
|
+
而不是给一条无法执行的指令(1.5.5 起)。
|
|
460
463
|
这条规则在**写入时**也执行,不只在校验时:设置页、导出、以及每次保存附带的 `.bak` 与历史
|
|
461
464
|
快照都走同一个判断(1.5.1 起)。检测到越界的外来 `backupDir` 会被忽略并**在面板上披露**,
|
|
462
465
|
备份与快照写回默认位置,而不是在外目录里悄悄留下文件。
|
|
@@ -787,6 +790,10 @@ nginx / Caddy 等反向代理之后远程访问,需要注意:
|
|
|
787
790
|
5. **限制来源**:至少用代理层做 IP 白名单或加 Basic Auth——插件本身不带任何鉴权,它只会
|
|
788
791
|
转用宿主发布的 `connection` 服务(没有该服务时非回环绑定直接 503)。
|
|
789
792
|
|
|
793
|
+
> **手机 / 多租户环境请打开 fail-closed**:设 `DSH_INSTRUCTION_MEMORY_REQUIRE_AUTH_FENCE=1`
|
|
794
|
+
> 后,"没有 `connection` 服务"直接变成 **503**,而不再退回"仅回环"。桌面单用户机器上回环还能
|
|
795
|
+
> 当边界用;Android 上不能——整机共享回环命名空间,任何装了 App 的程序都能连 127.0.0.1。
|
|
796
|
+
|
|
790
797
|
> **桌面端渲染器 origin 的一个已知边界**(2026-11-12 实测):宿主对 `Origin: dsh-app://app`
|
|
791
798
|
> 的请求返回 **403**,本插件在没有 `connection` 服务时的自判(`requestOriginVerdict`)同样拒绝
|
|
792
799
|
> 该 origin(它按 Origin ↔ Host 同源比较)。当前桌面窗口发的是 `http://127.0.0.1:<port>`,
|
|
@@ -970,6 +977,33 @@ DSH Go(Android 客户端)内置的引擎就是**真实 DSH**,锁定 `@deep
|
|
|
970
977
|
- **双端记忆不共享**:手机与"遥控 PC"各自维护自己的 `memory.json`,没有同步——这是当前形态,
|
|
971
978
|
不是缺陷。
|
|
972
979
|
|
|
980
|
+
#### Android / WebView 适配(1.5.5 起)
|
|
981
|
+
|
|
982
|
+
WebView 不是浏览器:它能把 `window.prompt` 保留成函数却永不弹框,能暴露 `<a download>` 却没有
|
|
983
|
+
下载管线,也要宿主自己实现文件选择器。这三处以前都是**点了没反应、也不报错**,而它们恰好是手机上
|
|
984
|
+
唯一的数据进出口。现在插件侧这样处理:
|
|
985
|
+
|
|
986
|
+
| 场景 | WebView 上的行为 | 依据 |
|
|
987
|
+
| --- | --- | --- |
|
|
988
|
+
| 导出 | 不再点击一个必然失败的链接,直接把完整 JSON 渲染在面板里(全选即可复制) | `client.js` 的 `isAndroidWebView()` + `exportFallback` 分支 |
|
|
989
|
+
| 导入 / 从备份恢复 | 面板常驻一行提示:文件选择器不弹时改用「源码模式」粘贴 JSON(这条通道不依赖任何原生能力) | `import.webviewHint` |
|
|
990
|
+
| 批量加标签 | 不再调用 `window.prompt`:WebView 返回的 `null` 被判为"问不了",直接走内联输入框 | `batchTagOutcome(..., { nullMeansUnavailable: true })` |
|
|
991
|
+
| 实时更新 | 连续 6 次(≈30 秒)订阅失败后显示「实时更新已断开,请重新载入」;收到下一帧自动清除 | `source.onerror` 计数 |
|
|
992
|
+
| 字号 / 手势条 | `text-size-adjust:100%` 挡住系统字号重排;挂载失败提示避开安全区 | CSS |
|
|
993
|
+
|
|
994
|
+
**插件侧做不到、必须平台提供的**:`DownloadListener`(WebView 拿不到 `blob:` 的字节,
|
|
995
|
+
插件无法自救)、`WebChromeClient.onShowFileChooser`、`onJsPrompt`、`env -i` 白名单
|
|
996
|
+
(否则备份目录那个开关永远设不上)、以及 WebView 销毁/后台时显式关闭 SSE
|
|
997
|
+
(否则半开连接会占满 32 条上限)。
|
|
998
|
+
|
|
999
|
+
**纵深开关**:Android 的回环是全机共享的,没有宿主鉴权门时"仅回环"不构成边界。设置
|
|
1000
|
+
`DSH_INSTRUCTION_MEMORY_REQUIRE_AUTH_FENCE=1` 后,缺少 `connection` 服务的 profile **直接
|
|
1001
|
+
503**(fail-closed),宁可设置页打不开,也不留一个可被同机任何 App 改写的长期指令入口。
|
|
1002
|
+
|
|
1003
|
+
> **证据等级**:以上为代码级适配,`adb devices` 在本机为空,**没有任何真机验证**。
|
|
1004
|
+
> 真机验收顺序见 `dsh-memory-verification/DSH-VERSION-COMPAT.md`;第一条永远先确认平台门在位
|
|
1005
|
+
> (无 cookie 请求必须返回 `401` 且带 `x-dsh-go-guard: reject`),否则后续结论都不成立。
|
|
1006
|
+
|
|
973
1007
|
## 发布(维护者)
|
|
974
1008
|
|
|
975
1009
|
**正规流程是推 tag,由 CI 通过 OIDC(Trusted Publishing)发布**,不需要任何长期令牌:
|
package/docs/demo.html
ADDED
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>dsh-instruction-memory 设置页演示</title>
|
|
7
|
+
<script src="vendor/react.production.min.js"></script>
|
|
8
|
+
<script src="vendor/react-dom.production.min.js"></script>
|
|
9
|
+
<style>
|
|
10
|
+
body { margin:0; background:#f5f6f8; font-family:system-ui,-apple-system,'Segoe UI','PingFang SC','Microsoft YaHei',sans-serif; }
|
|
11
|
+
#root { max-width:920px; margin:28px auto 0; padding:0 16px; }
|
|
12
|
+
</style>
|
|
13
|
+
</head>
|
|
14
|
+
<body>
|
|
15
|
+
<div id="root"></div>
|
|
16
|
+
|
|
17
|
+
<script>
|
|
18
|
+
// Minimal stand-ins for the DSH web shell: module loader + require.
|
|
19
|
+
window.__ModuleLoader__ = { load: (spec) => { window.__dshSpec = spec } }
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<script>
|
|
23
|
+
// In-memory host that speaks the exact route envelope of lib/index.js, so
|
|
24
|
+
// this page exercises the REAL client module (loaded below) against
|
|
25
|
+
// realistic data — no backend needed.
|
|
26
|
+
var DEMO_STORE = {
|
|
27
|
+
version: 1,
|
|
28
|
+
enabled: true,
|
|
29
|
+
// Deliberately tight so the budget meter and the "本轮不注入" marks are
|
|
30
|
+
// visible immediately; raising it in the input clears them live.
|
|
31
|
+
budgetChars: 800,
|
|
32
|
+
entries: [
|
|
33
|
+
{
|
|
34
|
+
id: 'im-demo-lang',
|
|
35
|
+
title: '回答语言',
|
|
36
|
+
content: '所有回答默认使用简体中文;技术名词首次出现时在括号里附英文原文。',
|
|
37
|
+
mode: 'always', when: '', priority: 2, enabled: true, tags: ['沟通'],
|
|
38
|
+
updatedAt: Date.now() - 86400000 * 3,
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: 'im-demo-confirm',
|
|
42
|
+
title: '先确认再动手',
|
|
43
|
+
content: '需求不明确或存在多种合理实现时,先列出候选方案向用户确认,再开始修改代码。',
|
|
44
|
+
mode: 'always', when: '', priority: 1, enabled: true, tags: ['沟通', '工作流'],
|
|
45
|
+
updatedAt: Date.now() - 86400000 * 2,
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: 'im-demo-style',
|
|
49
|
+
title: '代码风格',
|
|
50
|
+
content: '修改代码时遵循该文件既有的缩进、命名密度与注释风格,不顺手重构无关代码;\n变量命名优先使用业务领域词汇而不是数据结构词汇,例如用「订单金额」而不是「data1」;\n新增依赖前先检查 package.json 中是否已有能力等价的依赖,避免重复引入;\n公共函数需要补充输入输出说明注释,但不为改动本身添加解释性注释;\n改动完成后运行项目现有的测试与 lint 检查,若有失败先修复再交付,不留下只改了一半的状态;\n涉及对外接口或数据结构变更时,先在回复中列出受影响的调用方,确认迁移路径后再提交。\n日志打印需带关键上下文(对象标识、入参摘要),不要只输出 "error" 这类无信息内容;\n异步任务必须设置超时与重试上限,不允许无限等待外部资源;\n对外错误信息要区分用户可操作的提示与内部诊断细节,不把堆栈直接暴露给调用方;\n代码评审先核对行为变化与测试覆盖,再讨论风格偏好,意见必须可执行、可验证;\n以上要求同样适用于纯重构类提交,重构前后行为必须完全一致。',
|
|
51
|
+
mode: 'auto', when: '编写或修改代码时', priority: 1, enabled: true, tags: ['代码规范'],
|
|
52
|
+
updatedAt: Date.now() - 86400000,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: 'im-demo-reply',
|
|
56
|
+
title: '回复结构',
|
|
57
|
+
content: '较长的回答先给一句结论,再展开细节;方案对比先列差异再给推荐。',
|
|
58
|
+
mode: 'auto', when: '解释性问题与方案对比', priority: 0, enabled: false, tags: ['沟通'],
|
|
59
|
+
updatedAt: Date.now() - 3600000,
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
profiles: [
|
|
63
|
+
{
|
|
64
|
+
id: 'pf-demo-bg',
|
|
65
|
+
name: '职业背景',
|
|
66
|
+
content: '有 8 年后端开发经验,熟悉 Java 与分布式系统;技术问题可以跳过入门概念,直接讲权衡与实现细节。',
|
|
67
|
+
enabled: true,
|
|
68
|
+
updatedAt: Date.now() - 86400000,
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: 'pf-demo-style',
|
|
72
|
+
name: '沟通偏好',
|
|
73
|
+
content: '喜欢先给结论、再给可执行步骤,少寒暄少铺陈。',
|
|
74
|
+
enabled: true,
|
|
75
|
+
updatedAt: Date.now() - 7200000,
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: 'pf-demo-goal',
|
|
79
|
+
name: '长期追求(停用示例)',
|
|
80
|
+
content: '希望代码评审意见都具体可执行,不要泛泛而谈。',
|
|
81
|
+
enabled: false,
|
|
82
|
+
updatedAt: Date.now() - 3600000,
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// F13 配置集:演示数据里放两套,好让「切换」真的改变注入内容。所有既有条目
|
|
88
|
+
// 归入「默认」,另加一条只属于「工作(示例)」的条目。
|
|
89
|
+
;(function seedSets() {
|
|
90
|
+
DEMO_STORE.version = 2
|
|
91
|
+
DEMO_STORE.$schema = './memory.schema.json'
|
|
92
|
+
DEMO_STORE.backupDir = ''
|
|
93
|
+
DEMO_STORE.disabledUntil = 0
|
|
94
|
+
DEMO_STORE.activeSetId = 'st-demo-default'
|
|
95
|
+
DEMO_STORE.sets = [
|
|
96
|
+
{ id: 'st-demo-default', name: '默认' },
|
|
97
|
+
{ id: 'st-demo-work', name: '工作(示例)' },
|
|
98
|
+
]
|
|
99
|
+
DEMO_STORE.entries.forEach(function (e) { e.setId = 'st-demo-default' })
|
|
100
|
+
;(DEMO_STORE.profiles || []).forEach(function (p) { p.setId = 'st-demo-default' })
|
|
101
|
+
DEMO_STORE.entries.push({
|
|
102
|
+
id: 'im-demo-work',
|
|
103
|
+
title: '工作集专属',
|
|
104
|
+
content: '这条只属于「工作(示例)」配置集。切换到它时,上面几条指令与用户形象都不会被注入——和真实宿主一样,注入只渲染当前激活的那一套。',
|
|
105
|
+
mode: 'always', when: '', priority: 1, enabled: true, tags: ['演示'],
|
|
106
|
+
setId: 'st-demo-work', updatedAt: Date.now() - 3600000,
|
|
107
|
+
})
|
|
108
|
+
})()
|
|
109
|
+
|
|
110
|
+
/** 与宿主 activeScope/inScope 同义:'' 归属当前集合。 */
|
|
111
|
+
function demoInScope(item) {
|
|
112
|
+
var own = item && typeof item.setId === 'string' && item.setId !== '' ? item.setId : DEMO_STORE.activeSetId
|
|
113
|
+
return own === DEMO_STORE.activeSetId
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function demoScopedEntries() {
|
|
117
|
+
return DEMO_STORE.entries.filter(demoInScope)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function demoScopedProfiles() {
|
|
121
|
+
return (DEMO_STORE.profiles || []).filter(demoInScope)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// A compact, faithful copy of the Host's injection planning (see
|
|
125
|
+
// planInjection in lib/index.js): it generates both the preview text and
|
|
126
|
+
// the per-entry included/omitted plan the Client's F1 budget meter renders,
|
|
127
|
+
// so changing the budget input updates the marks live in this demo.
|
|
128
|
+
var DEMO_HEADER = [
|
|
129
|
+
'## 用户长期指令记忆(Instruction Memory)',
|
|
130
|
+
'以下条目由用户在 DSH 设置 →「指令记忆」中长期保存,属于本次及后续所有对话的既有要求,请在回答、推理与执行任务时遵守;它们不覆盖系统与安全规则。',
|
|
131
|
+
'· [始终] 条目必须无条件遵守。',
|
|
132
|
+
'· [按需] 条目仅在与当前问题或任务相关时生效;不相关时直接忽略,不要强行套用。',
|
|
133
|
+
'· 条目之间冲突时,优先遵守优先级更高或更具体的一条;无法判断时向用户确认。',
|
|
134
|
+
'',
|
|
135
|
+
].join('\n')
|
|
136
|
+
|
|
137
|
+
function demoFooter(count) {
|
|
138
|
+
return count === 0
|
|
139
|
+
? ''
|
|
140
|
+
: '\n\n(另有 ' + count + ' 条指令因注入字数上限未包含,可在「设置 → 指令记忆」中提高上限或调低其优先级。)'
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Faithful copy of the Host's persona header (lib/index.js PERSONA_HEADER).
|
|
144
|
+
var DEMO_PERSONA_HEADER = [
|
|
145
|
+
'## 关于用户(用户形象)',
|
|
146
|
+
'以下是用户本人提供的个人形象信息,描述其身份背景、喜好、性格与追求,不是要求你扮演的角色。请在保证回答正确、完整、客观且紧扣问题本身的前提下,结合这些信息调整解释的深浅、详略、举例与建议的侧重;不得为了迎合这些信息而改变事实、省略必要的风险提示或偏离问题核心;与当前问题无关时直接忽略。',
|
|
147
|
+
'',
|
|
148
|
+
].join('\n')
|
|
149
|
+
|
|
150
|
+
function demoProfilesBlock() {
|
|
151
|
+
var active = demoScopedProfiles()
|
|
152
|
+
.filter(function (p) { return p && p.enabled === true && p.content })
|
|
153
|
+
if (active.length === 0) return { text: '', profiles: [], chars: 0 }
|
|
154
|
+
var blocks = active.map(function (p) {
|
|
155
|
+
var name = p.name || (p.content || '').slice(0, 20)
|
|
156
|
+
return { id: p.id, name: name, block: '### ' + name + '\n' + p.content }
|
|
157
|
+
})
|
|
158
|
+
var text = DEMO_PERSONA_HEADER + '\n' + blocks.map(function (b) { return b.block }).join('\n\n')
|
|
159
|
+
return {
|
|
160
|
+
text: text,
|
|
161
|
+
chars: text.length,
|
|
162
|
+
profiles: blocks.map(function (b) { return { id: b.id, name: b.name, chars: b.block.length } }),
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function demoFormatEntry(entry, tag) {
|
|
167
|
+
var prio = entry.priority === 2 ? '高' : (entry.priority === 0 ? '低' : '普通')
|
|
168
|
+
var lines = ['[' + tag + '|优先级 ' + prio + '] ' + entry.title]
|
|
169
|
+
if (entry.mode === 'auto' && entry.when) lines.push('适用场景:' + entry.when)
|
|
170
|
+
lines.push(entry.content)
|
|
171
|
+
return lines.join('\n')
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function demoPlan() {
|
|
175
|
+
var budget = DEMO_STORE.budgetChars
|
|
176
|
+
if (typeof budget !== 'number' || !isFinite(budget)) budget = 4000
|
|
177
|
+
if (budget < 800) budget = 800
|
|
178
|
+
if (budget > 40000) budget = 40000
|
|
179
|
+
var blank = function () {
|
|
180
|
+
return { text: '', budget: budget, activeCount: 0, included: [], omitted: [], profiles: [], profileChars: 0, entryChars: 0 }
|
|
181
|
+
}
|
|
182
|
+
if (DEMO_STORE.enabled !== true) return blank()
|
|
183
|
+
// F7 临时静音:与真实宿主同义——暂停中的计划与 enabled:false 逐字节相同。
|
|
184
|
+
if (typeof DEMO_STORE.disabledUntil === 'number' && DEMO_STORE.disabledUntil > Date.now()) return blank()
|
|
185
|
+
var persona = demoProfilesBlock()
|
|
186
|
+
var active = demoScopedEntries().filter(function (e) { return e.enabled && e.content !== '' })
|
|
187
|
+
|
|
188
|
+
var blocks = []
|
|
189
|
+
var included = []
|
|
190
|
+
var omittedPlan = []
|
|
191
|
+
var used = DEMO_HEADER.length
|
|
192
|
+
var omitted = 0
|
|
193
|
+
|
|
194
|
+
function consider(entry, tag) {
|
|
195
|
+
var block = demoFormatEntry(entry, tag)
|
|
196
|
+
var reserve = demoFooter(omitted + 1).length
|
|
197
|
+
if (used + block.length + 2 + reserve <= budget) {
|
|
198
|
+
used += block.length + 2
|
|
199
|
+
blocks.push(block)
|
|
200
|
+
included.push({ id: entry.id, chars: block.length, truncated: false })
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
if (blocks.length === 0) {
|
|
204
|
+
var room = budget - used - 2 - reserve
|
|
205
|
+
var note = '\n…(本条因注入字数上限被截断)'
|
|
206
|
+
if (room > note.length + 20) {
|
|
207
|
+
var head = block.slice(0, room - note.length)
|
|
208
|
+
var body = head + note
|
|
209
|
+
blocks.push(body)
|
|
210
|
+
used += body.length + 2
|
|
211
|
+
included.push({ id: entry.id, chars: body.length, truncated: true })
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
omitted += 1
|
|
216
|
+
omittedPlan.push({ id: entry.id, chars: block.length })
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
active.forEach(function (entry) { if (entry.mode === 'always') consider(entry, '始终') })
|
|
220
|
+
active.forEach(function (entry) { if (entry.mode !== 'always') consider(entry, '按需') })
|
|
221
|
+
|
|
222
|
+
var entryText = blocks.length === 0
|
|
223
|
+
? ''
|
|
224
|
+
: DEMO_HEADER + blocks.join('\n\n') + demoFooter(omitted)
|
|
225
|
+
var parts = []
|
|
226
|
+
if (persona.text !== '') parts.push(persona.text)
|
|
227
|
+
if (entryText !== '') parts.push(entryText)
|
|
228
|
+
return {
|
|
229
|
+
text: parts.join('\n\n'),
|
|
230
|
+
budget: budget,
|
|
231
|
+
activeCount: active.length,
|
|
232
|
+
included: included,
|
|
233
|
+
omitted: omittedPlan,
|
|
234
|
+
profiles: persona.profiles,
|
|
235
|
+
profileChars: persona.chars,
|
|
236
|
+
entryChars: entryText.length,
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Where a fresh set id comes from (mirrors the Host's st- prefix). */
|
|
241
|
+
function demoSetId() {
|
|
242
|
+
return 'st-demo-' + Math.random().toString(36).slice(2, 8)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function demoSnapshot() {
|
|
246
|
+
var plan = demoPlan()
|
|
247
|
+
// The panel edits and lists ONLY the active set (exactly like the real
|
|
248
|
+
// Host's snapshot), so a demo switch really empties the list.
|
|
249
|
+
// sets.total is the WHOLE-STORE count (same rule as the real host), so it
|
|
250
|
+
// is captured from DEMO_STORE BEFORE the scoped arrays replace these
|
|
251
|
+
// fields below — `data` aliases `full`, and counting after the reassign
|
|
252
|
+
// used to report the current-set numbers as the store-wide totals.
|
|
253
|
+
var totalEntries = (DEMO_STORE.entries || []).length
|
|
254
|
+
var totalProfiles = (DEMO_STORE.profiles || []).length
|
|
255
|
+
var full = JSON.parse(JSON.stringify(DEMO_STORE))
|
|
256
|
+
var data = full
|
|
257
|
+
data.entries = demoScopedEntries().map(function (e) { return JSON.parse(JSON.stringify(e)) })
|
|
258
|
+
data.profiles = demoScopedProfiles().map(function (p) { return JSON.parse(JSON.stringify(p)) })
|
|
259
|
+
var belongsTo = function (item, id) {
|
|
260
|
+
var own = item && typeof item.setId === 'string' && item.setId !== '' ? item.setId : DEMO_STORE.activeSetId
|
|
261
|
+
return own === id
|
|
262
|
+
}
|
|
263
|
+
var setList = (DEMO_STORE.sets || []).map(function (s) {
|
|
264
|
+
return {
|
|
265
|
+
id: s.id,
|
|
266
|
+
name: s.name,
|
|
267
|
+
active: s.id === DEMO_STORE.activeSetId,
|
|
268
|
+
entries: (DEMO_STORE.entries || []).filter(function (e) { return belongsTo(e, s.id) }).length,
|
|
269
|
+
profiles: (DEMO_STORE.profiles || []).filter(function (p) { return belongsTo(p, s.id) }).length,
|
|
270
|
+
}
|
|
271
|
+
})
|
|
272
|
+
return {
|
|
273
|
+
data: data,
|
|
274
|
+
sets: {
|
|
275
|
+
activeId: DEMO_STORE.activeSetId,
|
|
276
|
+
scoped: true,
|
|
277
|
+
total: { entries: totalEntries, profiles: totalProfiles },
|
|
278
|
+
list: setList,
|
|
279
|
+
},
|
|
280
|
+
storage: {
|
|
281
|
+
// The store file NEVER moves with the backup location — only the
|
|
282
|
+
// backup copies do (same rule as the real host).
|
|
283
|
+
path: 'C:\\Users\\demo\\.dsh\\instruction-memory\\memory.json',
|
|
284
|
+
error: null,
|
|
285
|
+
warning: null,
|
|
286
|
+
savedAt: Date.now() - 5 * 60000,
|
|
287
|
+
migratedFrom: null,
|
|
288
|
+
onDisk: true,
|
|
289
|
+
backupDir: DEMO_STORE.backupDir || '',
|
|
290
|
+
isDefaultBackupDir: !DEMO_STORE.backupDir,
|
|
291
|
+
backupPath: (DEMO_STORE.backupDir || 'C:\\Users\\demo\\.dsh\\instruction-memory') + '\\memory.json.bak',
|
|
292
|
+
historyDir: (DEMO_STORE.backupDir || 'C:\\Users\\demo\\.dsh\\instruction-memory') + '\\history',
|
|
293
|
+
historyCount: 0,
|
|
294
|
+
historyError: null,
|
|
295
|
+
schemaFile: 'C:\\Users\\demo\\.dsh\\instruction-memory\\memory.schema.json',
|
|
296
|
+
},
|
|
297
|
+
injection: {
|
|
298
|
+
registered: plan.text !== '',
|
|
299
|
+
available: true,
|
|
300
|
+
error: null,
|
|
301
|
+
muted: DEMO_STORE.enabled === true && typeof DEMO_STORE.disabledUntil === 'number' && DEMO_STORE.disabledUntil > Date.now(),
|
|
302
|
+
chars: plan.text.length,
|
|
303
|
+
budget: plan.budget,
|
|
304
|
+
activeCount: plan.activeCount,
|
|
305
|
+
included: plan.included,
|
|
306
|
+
omitted: plan.omitted,
|
|
307
|
+
profiles: plan.profiles,
|
|
308
|
+
profileChars: plan.profileChars,
|
|
309
|
+
entryChars: plan.entryChars,
|
|
310
|
+
pulls: 2,
|
|
311
|
+
},
|
|
312
|
+
// F12:演示桩里路由绑定在回环地址,安全面板不告警。
|
|
313
|
+
security: { hostGuarded: true, boundHost: '127.0.0.1' },
|
|
314
|
+
diagnostics: { loadedFrom: 'demo(内存桩宿主)', rev: data.rev || 0 },
|
|
315
|
+
preview: plan.text,
|
|
316
|
+
limits: {
|
|
317
|
+
minBudget: 800, maxBudget: 40000, maxEntries: 200,
|
|
318
|
+
maxProfiles: 50, maxProfileName: 40, maxProfileContent: 4000,
|
|
319
|
+
maxSets: 20, maxSetName: 30, maxHistory: 20, maxBackupDir: 512,
|
|
320
|
+
maxBodyChars: 3218000,
|
|
321
|
+
},
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** A no-op answer: ok, nothing written (mirrors the real envelope). */
|
|
326
|
+
function demoNoop() {
|
|
327
|
+
return {
|
|
328
|
+
ok: true,
|
|
329
|
+
json: async function () {
|
|
330
|
+
return { ok: true, saved: false, message: null, snapshot: demoSnapshot() }
|
|
331
|
+
},
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Uniform route envelope for the demo host (the real one answers the same shape). */
|
|
336
|
+
function demoReply(ok, message) {
|
|
337
|
+
return {
|
|
338
|
+
ok: true,
|
|
339
|
+
json: async function () {
|
|
340
|
+
return {
|
|
341
|
+
ok: ok === true,
|
|
342
|
+
saved: ok === true,
|
|
343
|
+
message: message === undefined ? null : message,
|
|
344
|
+
snapshot: demoSnapshot(),
|
|
345
|
+
}
|
|
346
|
+
},
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Adopt a whole store object (源码模式 / 整体替换). Mirrors the parts of the
|
|
352
|
+
* Host's loader a demo needs to stay believable: sets are kept, every item is
|
|
353
|
+
* stamped onto an existing set, missing ids are minted.
|
|
354
|
+
*/
|
|
355
|
+
function demoAdoptStore(parsed) {
|
|
356
|
+
DEMO_STORE.version = 2
|
|
357
|
+
DEMO_STORE.enabled = parsed.enabled !== false
|
|
358
|
+
DEMO_STORE.disabledUntil = typeof parsed.disabledUntil === 'number' && isFinite(parsed.disabledUntil) && parsed.disabledUntil > 0
|
|
359
|
+
? parsed.disabledUntil
|
|
360
|
+
: 0
|
|
361
|
+
DEMO_STORE.budgetChars = typeof parsed.budgetChars === 'number' ? parsed.budgetChars : 4000
|
|
362
|
+
DEMO_STORE.sets = Array.isArray(parsed.sets) && parsed.sets.length > 0
|
|
363
|
+
? parsed.sets.map(function (s) { return { id: String(s.id), name: String(s.name).slice(0, 30) } })
|
|
364
|
+
: [{ id: 'st-demo-default', name: '默认' }]
|
|
365
|
+
DEMO_STORE.activeSetId = DEMO_STORE.sets.some(function (s) { return s.id === parsed.activeSetId })
|
|
366
|
+
? parsed.activeSetId
|
|
367
|
+
: DEMO_STORE.sets[0].id
|
|
368
|
+
DEMO_STORE.entries = (Array.isArray(parsed.entries) ? parsed.entries : []).map(function (e) {
|
|
369
|
+
var entry = JSON.parse(JSON.stringify(e))
|
|
370
|
+
entry.id = entry.id || ('im-demo-' + Math.random().toString(36).slice(2, 8))
|
|
371
|
+
entry.setId = DEMO_STORE.sets.some(function (s) { return s.id === entry.setId })
|
|
372
|
+
? entry.setId
|
|
373
|
+
: DEMO_STORE.activeSetId
|
|
374
|
+
entry.tags = Array.isArray(entry.tags) ? entry.tags : []
|
|
375
|
+
return entry
|
|
376
|
+
})
|
|
377
|
+
DEMO_STORE.profiles = (Array.isArray(parsed.profiles) ? parsed.profiles : []).map(function (p) {
|
|
378
|
+
var profile = JSON.parse(JSON.stringify(p))
|
|
379
|
+
profile.id = profile.id || ('pf-demo-' + Math.random().toString(36).slice(2, 8))
|
|
380
|
+
profile.setId = DEMO_STORE.sets.some(function (s) { return s.id === profile.setId })
|
|
381
|
+
? profile.setId
|
|
382
|
+
: DEMO_STORE.activeSetId
|
|
383
|
+
return profile
|
|
384
|
+
})
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
window.fetch = async function (url, options) {
|
|
388
|
+
var payload = JSON.parse(options.body)
|
|
389
|
+
switch (payload.method) {
|
|
390
|
+
case 'save-entry': {
|
|
391
|
+
var entry = Object.assign({}, payload.args.entry)
|
|
392
|
+
if (!entry.id) entry.id = 'im-demo-' + Math.random().toString(36).slice(2, 8)
|
|
393
|
+
entry.updatedAt = Date.now()
|
|
394
|
+
// The interactive path always files into the ACTIVE set (same rule as
|
|
395
|
+
// the real Host — an edit can never move an item between sets), and an
|
|
396
|
+
// id owned by another set is re-minted rather than replaced.
|
|
397
|
+
entry.setId = DEMO_STORE.activeSetId
|
|
398
|
+
var foreignId = DEMO_STORE.entries.some(function (e) {
|
|
399
|
+
return e.id === entry.id && e.setId !== DEMO_STORE.activeSetId
|
|
400
|
+
})
|
|
401
|
+
if (foreignId) entry.id = 'im-demo-' + Math.random().toString(36).slice(2, 8)
|
|
402
|
+
var list = DEMO_STORE.entries
|
|
403
|
+
var index = -1
|
|
404
|
+
for (var i = 0; i < list.length; i += 1) if (list[i].id === entry.id) { index = i; break }
|
|
405
|
+
if (index >= 0) list[index] = entry
|
|
406
|
+
else list.push(entry)
|
|
407
|
+
break
|
|
408
|
+
}
|
|
409
|
+
case 'delete-entry': {
|
|
410
|
+
var doomed = payload.args.id
|
|
411
|
+
var owned = DEMO_STORE.entries.some(function (e) {
|
|
412
|
+
return e.id === doomed && demoInScope(e)
|
|
413
|
+
})
|
|
414
|
+
if (!owned) return demoNoop()
|
|
415
|
+
DEMO_STORE.entries = DEMO_STORE.entries.filter(function (e) { return e.id !== doomed })
|
|
416
|
+
break
|
|
417
|
+
}
|
|
418
|
+
case 'set-options': {
|
|
419
|
+
if (typeof payload.args.enabled === 'boolean') {
|
|
420
|
+
DEMO_STORE.enabled = payload.args.enabled
|
|
421
|
+
DEMO_STORE.disabledUntil = 0
|
|
422
|
+
}
|
|
423
|
+
if (typeof payload.args.disabledUntil === 'number' && isFinite(payload.args.disabledUntil)
|
|
424
|
+
&& payload.args.disabledUntil >= 0 && typeof payload.args.enabled !== 'boolean') {
|
|
425
|
+
var wantedMute = Math.round(payload.args.disabledUntil)
|
|
426
|
+
var muteClamped = false
|
|
427
|
+
if (wantedMute <= Date.now()) wantedMute = 0
|
|
428
|
+
else if (wantedMute > Date.now() + 7 * 86400000) { wantedMute = Date.now() + 7 * 86400000; muteClamped = true }
|
|
429
|
+
DEMO_STORE.disabledUntil = wantedMute
|
|
430
|
+
if (wantedMute > 0) {
|
|
431
|
+
// 演示桩也到期自动恢复:真实宿主由插件内的定时器负责,这里用一个页内
|
|
432
|
+
// 定时器模拟同一行为(下一个快照即恢复正常态)。
|
|
433
|
+
setTimeout(function () { DEMO_STORE.disabledUntil = 0 }, wantedMute - Date.now() + 50)
|
|
434
|
+
}
|
|
435
|
+
return demoReply(true, wantedMute === 0
|
|
436
|
+
? '已恢复注入。'
|
|
437
|
+
: '已暂停注入至 ' + new Date(wantedMute).toLocaleString() + '(到期自动恢复;暂停期间不注入任何指令或用户形象)'
|
|
438
|
+
+ (muteClamped ? ';暂停时长上限为 7 天,已按上限记录' : ''))
|
|
439
|
+
}
|
|
440
|
+
if (typeof payload.args.budgetChars === 'number') DEMO_STORE.budgetChars = payload.args.budgetChars
|
|
441
|
+
if (typeof payload.args.backupDir === 'string') {
|
|
442
|
+
DEMO_STORE.backupDir = payload.args.backupDir.trim()
|
|
443
|
+
return demoReply(true, DEMO_STORE.backupDir === ''
|
|
444
|
+
? '已恢复默认备份位置(版本历史与导出与存储文件同目录)'
|
|
445
|
+
: '备份位置已更新:' + DEMO_STORE.backupDir + '(版本历史与「导出到备份目录」随之改变;存储文件本身不移动)')
|
|
446
|
+
}
|
|
447
|
+
break
|
|
448
|
+
}
|
|
449
|
+
case 'batch': {
|
|
450
|
+
// Demo stand-in for the Host's single-transaction batch route.
|
|
451
|
+
var b = payload.args || {}
|
|
452
|
+
var batchIds = Array.isArray(b.ids) ? b.ids : []
|
|
453
|
+
if (b.op === 'delete') {
|
|
454
|
+
DEMO_STORE.entries = DEMO_STORE.entries.filter(function (e) {
|
|
455
|
+
return batchIds.indexOf(e.id) < 0
|
|
456
|
+
})
|
|
457
|
+
} else {
|
|
458
|
+
DEMO_STORE.entries.forEach(function (e) {
|
|
459
|
+
if (batchIds.indexOf(e.id) < 0) return
|
|
460
|
+
if (b.op === 'set-enabled') {
|
|
461
|
+
e.enabled = b.value
|
|
462
|
+
} else if (b.op === 'set-priority') {
|
|
463
|
+
e.priority = b.value
|
|
464
|
+
} else if (b.op === 'add-tags') {
|
|
465
|
+
var cur = Array.isArray(e.tags) ? e.tags.slice() : []
|
|
466
|
+
;(b.value || []).forEach(function (tg) { if (cur.indexOf(tg) < 0) cur.push(tg) })
|
|
467
|
+
e.tags = cur.slice(0, 8)
|
|
468
|
+
} else if (b.op === 'remove-tags') {
|
|
469
|
+
e.tags = (e.tags || []).filter(function (tg) { return (b.value || []).indexOf(tg) < 0 })
|
|
470
|
+
}
|
|
471
|
+
})
|
|
472
|
+
}
|
|
473
|
+
break
|
|
474
|
+
}
|
|
475
|
+
case 'save-profile': {
|
|
476
|
+
// Demo stand-in for the Host's save-profile route. Mirror
|
|
477
|
+
// sanitizeProfile: a blank name falls back to the first 20 single-line
|
|
478
|
+
// characters of content, so the card never shows an empty name (the
|
|
479
|
+
// real Host stores this fallback; it is not a client-only label).
|
|
480
|
+
var p = Object.assign({}, payload.args.profile)
|
|
481
|
+
if (!p.id) p.id = 'pf-demo-' + Math.random().toString(36).slice(2, 8)
|
|
482
|
+
if (typeof p.enabled !== 'boolean') p.enabled = true
|
|
483
|
+
if (typeof p.content !== 'string') p.content = ''
|
|
484
|
+
var oneLine = function (s, max) {
|
|
485
|
+
return String(s).split(/\r?\n/).map(function (x) { return x.trim() })
|
|
486
|
+
.filter(Boolean).join(' ').slice(0, max)
|
|
487
|
+
}
|
|
488
|
+
var trimmedName = oneLine(p.name || '', 40)
|
|
489
|
+
p.name = trimmedName !== '' ? trimmedName : oneLine(p.content, 20)
|
|
490
|
+
p.updatedAt = Date.now()
|
|
491
|
+
if (!Array.isArray(DEMO_STORE.profiles)) DEMO_STORE.profiles = []
|
|
492
|
+
var pIndex = -1
|
|
493
|
+
for (var pi = 0; pi < DEMO_STORE.profiles.length; pi += 1) {
|
|
494
|
+
if (DEMO_STORE.profiles[pi].id === p.id) { pIndex = pi; break }
|
|
495
|
+
}
|
|
496
|
+
if (pIndex >= 0) DEMO_STORE.profiles[pIndex] = p
|
|
497
|
+
else DEMO_STORE.profiles.push(p)
|
|
498
|
+
break
|
|
499
|
+
}
|
|
500
|
+
case 'delete-profile':
|
|
501
|
+
DEMO_STORE.profiles = (DEMO_STORE.profiles || [])
|
|
502
|
+
.filter(function (p) { return p.id !== payload.args.id })
|
|
503
|
+
break
|
|
504
|
+
// ---- F13 配置集 ----
|
|
505
|
+
case 'save-set': {
|
|
506
|
+
var wanted = Object.assign({}, payload.args.set)
|
|
507
|
+
var wantedName = typeof wanted.name === 'string'
|
|
508
|
+
? wanted.name.split(/\r?\n/).join(' ').trim().slice(0, 30)
|
|
509
|
+
: ''
|
|
510
|
+
if (wantedName === '') return demoReply(false, '配置集名称不能为空(单行,最多 30 字符)')
|
|
511
|
+
var sameName = function (s) { return s.name === wantedName && s.id !== wanted.id }
|
|
512
|
+
if (DEMO_STORE.sets.some(sameName)) return demoReply(false, '已存在同名配置集「' + wantedName + '」')
|
|
513
|
+
if (!wanted.id) {
|
|
514
|
+
if (DEMO_STORE.sets.length >= 20) return demoReply(false, '最多创建 20 个配置集')
|
|
515
|
+
DEMO_STORE.sets.push({ id: demoSetId(), name: wantedName })
|
|
516
|
+
return demoReply(true, '已创建配置集「' + wantedName + '」;它是空的,点击「切换」后才会注入它')
|
|
517
|
+
}
|
|
518
|
+
var renameAt = -1
|
|
519
|
+
for (var si = 0; si < DEMO_STORE.sets.length; si += 1) {
|
|
520
|
+
if (DEMO_STORE.sets[si].id === wanted.id) { renameAt = si; break }
|
|
521
|
+
}
|
|
522
|
+
if (renameAt < 0) return demoReply(false, '没有该配置集(可能已被其他窗口删除)')
|
|
523
|
+
DEMO_STORE.sets[renameAt].name = wantedName
|
|
524
|
+
return demoReply(true, '已重命名为「' + wantedName + '」')
|
|
525
|
+
}
|
|
526
|
+
case 'delete-set': {
|
|
527
|
+
var deleteId = payload.args.id
|
|
528
|
+
var targetSet = null
|
|
529
|
+
DEMO_STORE.sets.forEach(function (s) { if (s.id === deleteId) targetSet = s })
|
|
530
|
+
if (targetSet === null) return demoReply(false, '没有该配置集')
|
|
531
|
+
if (DEMO_STORE.sets.length <= 1) return demoReply(false, '至少要保留一个配置集')
|
|
532
|
+
if (deleteId === DEMO_STORE.activeSetId) return demoReply(false, '不能删除当前激活的配置集;请先切换到其他配置集')
|
|
533
|
+
DEMO_STORE.sets = DEMO_STORE.sets.filter(function (s) { return s.id !== deleteId })
|
|
534
|
+
DEMO_STORE.entries = DEMO_STORE.entries.filter(function (e) { return e.setId !== deleteId })
|
|
535
|
+
DEMO_STORE.profiles = (DEMO_STORE.profiles || []).filter(function (p) { return p.setId !== deleteId })
|
|
536
|
+
return demoReply(true, '已删除配置集「' + targetSet.name + '」及其中的条目与形象')
|
|
537
|
+
}
|
|
538
|
+
case 'set-active-set': {
|
|
539
|
+
var activeId = payload.args.id
|
|
540
|
+
var found = DEMO_STORE.sets.some(function (s) { return s.id === activeId })
|
|
541
|
+
if (!found) return demoReply(false, '没有该配置集(可能已被其他窗口删除)')
|
|
542
|
+
if (activeId === DEMO_STORE.activeSetId) return demoNoop()
|
|
543
|
+
DEMO_STORE.activeSetId = activeId
|
|
544
|
+
var activeName = ''
|
|
545
|
+
DEMO_STORE.sets.forEach(function (s) { if (s.id === activeId) activeName = s.name })
|
|
546
|
+
return demoReply(true, '已切换到配置集「' + activeName + '」,注入内容已随之更新')
|
|
547
|
+
}
|
|
548
|
+
case 'duplicate-set': {
|
|
549
|
+
var copyFrom = null
|
|
550
|
+
DEMO_STORE.sets.forEach(function (s) { if (s.id === payload.args.id) copyFrom = s })
|
|
551
|
+
if (copyFrom === null) return demoReply(false, '没有该配置集')
|
|
552
|
+
if (DEMO_STORE.sets.length >= 20) return demoReply(false, '最多 20 个配置集')
|
|
553
|
+
var copyName = (payload.args.name || copyFrom.name + ' 副本').trim().slice(0, 30)
|
|
554
|
+
if (DEMO_STORE.sets.some(function (s) { return s.name === copyName })) return demoReply(false, '已存在同名配置集「' + copyName + '」')
|
|
555
|
+
var copyId = demoSetId()
|
|
556
|
+
var stamp = Date.now()
|
|
557
|
+
var copiedEntries = DEMO_STORE.entries.filter(function (e) { return e.setId === copyFrom.id })
|
|
558
|
+
.map(function (e) {
|
|
559
|
+
var copy = JSON.parse(JSON.stringify(e))
|
|
560
|
+
copy.id = 'im-demo-' + Math.random().toString(36).slice(2, 8)
|
|
561
|
+
copy.setId = copyId
|
|
562
|
+
copy.updatedAt = stamp
|
|
563
|
+
return copy
|
|
564
|
+
})
|
|
565
|
+
DEMO_STORE.sets.push({ id: copyId, name: copyName })
|
|
566
|
+
Array.prototype.push.apply(DEMO_STORE.entries, copiedEntries)
|
|
567
|
+
return demoReply(true, '已复制为「' + copyName + '」(' + copiedEntries.length + ' 条指令);点击「切换」后才会注入它')
|
|
568
|
+
}
|
|
569
|
+
// ---- F14 版本历史(演示页面不落盘,因此没有快照可列) ----
|
|
570
|
+
case 'history-list':
|
|
571
|
+
return {
|
|
572
|
+
ok: true,
|
|
573
|
+
json: async function () {
|
|
574
|
+
return {
|
|
575
|
+
ok: true, saved: false, message: null, snapshot: demoSnapshot(),
|
|
576
|
+
history: { dir: '(演示页面不写历史快照;真实宿主会在存储目录的 history/ 子目录里保留最近 20 份)', items: [] },
|
|
577
|
+
}
|
|
578
|
+
},
|
|
579
|
+
}
|
|
580
|
+
case 'history-restore':
|
|
581
|
+
case 'history-delete':
|
|
582
|
+
return demoReply(false, '演示页面没有历史快照')
|
|
583
|
+
// ---- F16 源码模式 ----
|
|
584
|
+
case 'read-source':
|
|
585
|
+
return {
|
|
586
|
+
ok: true,
|
|
587
|
+
json: async function () {
|
|
588
|
+
return { ok: true, saved: false, message: null, source: JSON.stringify(DEMO_STORE, null, 2) + '\n', snapshot: demoSnapshot() }
|
|
589
|
+
},
|
|
590
|
+
}
|
|
591
|
+
case 'write-source': {
|
|
592
|
+
var text = payload.args.text
|
|
593
|
+
if (typeof text !== 'string' || text.trim() === '') return demoReply(false, '源码不能为空')
|
|
594
|
+
var parsedSource = null
|
|
595
|
+
try {
|
|
596
|
+
parsedSource = JSON.parse(text)
|
|
597
|
+
} catch (err) {
|
|
598
|
+
return demoReply(false, '源码不是有效的 JSON:' + (err && err.message ? err.message : err))
|
|
599
|
+
}
|
|
600
|
+
if (parsedSource === null || typeof parsedSource !== 'object' || Array.isArray(parsedSource)) {
|
|
601
|
+
return demoReply(false, '顶层必须是一个 JSON 对象')
|
|
602
|
+
}
|
|
603
|
+
if (typeof parsedSource.version === 'number' && parsedSource.version !== 2) {
|
|
604
|
+
return demoReply(false, '内容声明为格式版本 ' + parsedSource.version + ',本插件支持的格式版本为 1 / 2')
|
|
605
|
+
}
|
|
606
|
+
demoAdoptStore(parsedSource)
|
|
607
|
+
return demoReply(true, '已按源码保存(演示页面只改内存,不写文件):'
|
|
608
|
+
+ DEMO_STORE.entries.length + ' 条指令 / ' + (DEMO_STORE.profiles || []).length + ' 个用户形象 / '
|
|
609
|
+
+ DEMO_STORE.sets.length + ' 个配置集')
|
|
610
|
+
}
|
|
611
|
+
// ---- F14 整体替换恢复 ----
|
|
612
|
+
case 'restore-data': {
|
|
613
|
+
var restorePayload = payload.args.payload
|
|
614
|
+
if (restorePayload === null || typeof restorePayload !== 'object') return demoReply(false, '恢复内容不是有效的 JSON 对象')
|
|
615
|
+
var restoreInner = restorePayload.data !== null && typeof restorePayload.data === 'object' ? restorePayload.data : restorePayload
|
|
616
|
+
var looksLikeStore = ['version', 'enabled', 'budgetChars', 'activeSetId', 'sets', 'entries', 'profiles']
|
|
617
|
+
.some(function (key) { return key in restoreInner })
|
|
618
|
+
if (!looksLikeStore) return demoReply(false, '内容不像本插件的存储文件')
|
|
619
|
+
demoAdoptStore(restoreInner)
|
|
620
|
+
return demoReply(true, '已用备份整体替换当前记忆(演示页面只改内存,不写文件)')
|
|
621
|
+
}
|
|
622
|
+
// ---- F18 备份位置 ----
|
|
623
|
+
case 'export-to-dir':
|
|
624
|
+
return demoReply(true, '演示页面不会真的写文件;真实宿主会把导出写入「'
|
|
625
|
+
+ (DEMO_STORE.backupDir || '存储目录') + '」,文件名形如 dsh-instruction-memory-20261015-152355Z.json')
|
|
626
|
+
case 'export-data':
|
|
627
|
+
return {
|
|
628
|
+
ok: true,
|
|
629
|
+
json: async function () {
|
|
630
|
+
return {
|
|
631
|
+
ok: true, saved: false, message: null,
|
|
632
|
+
// 导出的是整份存储(所有配置集),不是当前激活的那一套。
|
|
633
|
+
export: { app: 'dsh-instruction-memory', version: 2, exportedAt: new Date().toISOString(), data: JSON.parse(JSON.stringify(DEMO_STORE)) },
|
|
634
|
+
snapshot: demoSnapshot(),
|
|
635
|
+
}
|
|
636
|
+
},
|
|
637
|
+
}
|
|
638
|
+
default:
|
|
639
|
+
break
|
|
640
|
+
}
|
|
641
|
+
return {
|
|
642
|
+
ok: true,
|
|
643
|
+
json: async function () {
|
|
644
|
+
return { ok: true, saved: true, message: null, snapshot: demoSnapshot() }
|
|
645
|
+
},
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
</script>
|
|
649
|
+
|
|
650
|
+
<!-- The real client module, unmodified. -->
|
|
651
|
+
<script src="../lib/client.js"></script>
|
|
652
|
+
|
|
653
|
+
<script>
|
|
654
|
+
var plugin = window.__dshSpec.factory(function (id) {
|
|
655
|
+
if (id === 'react') return window.React
|
|
656
|
+
throw new Error('unexpected require: ' + id)
|
|
657
|
+
})
|
|
658
|
+
|
|
659
|
+
var registered = []
|
|
660
|
+
plugin.apply({
|
|
661
|
+
slots: {
|
|
662
|
+
inject: function (key, callback) { callback(); return function () {} },
|
|
663
|
+
register: function (options, render) { registered.push(render); return function () {} },
|
|
664
|
+
},
|
|
665
|
+
effect: function (fn) { fn(); return function () {} },
|
|
666
|
+
})
|
|
667
|
+
|
|
668
|
+
ReactDOM.createRoot(document.getElementById('root')).render(registered[0]())
|
|
669
|
+
</script>
|
|
670
|
+
</body>
|
|
671
|
+
</html>
|
package/lib/client.js
CHANGED
|
@@ -69,7 +69,10 @@ window.__ModuleLoader__.load({
|
|
|
69
69
|
|
|
70
70
|
const CSS = `
|
|
71
71
|
.im-root { display:flex; flex-direction:column; gap:16px; padding:6px 2px 56px; max-width:860px;
|
|
72
|
-
color:var(--dsw-alias-label-primary,#1f2329); font-size:13px; line-height:1.6;
|
|
72
|
+
color:var(--dsw-alias-label-primary,#1f2329); font-size:13px; line-height:1.6;
|
|
73
|
+
/* Android WebView honours the system text zoom unless this is pinned: at
|
|
74
|
+
150% the fixed-px cards reflow into unreadable ribbons. */
|
|
75
|
+
-webkit-text-size-adjust:100%; text-size-adjust:100%; }
|
|
73
76
|
.im-h { margin:0; font-size:14px; font-weight:600; }
|
|
74
77
|
.im-sub { margin:3px 0 0; font-size:12px; color:var(--dsw-alias-label-secondary,#8a9099); }
|
|
75
78
|
.im-card { display:flex; flex-direction:column; gap:11px; padding:15px; border-radius:12px;
|
|
@@ -362,6 +365,11 @@ window.__ModuleLoader__.load({
|
|
|
362
365
|
'profiles.truncatedTitle': '这张形象超过注入字数上限,只有开头进入本轮提示',
|
|
363
366
|
'profiles.omitted': '本轮未包含',
|
|
364
367
|
'profiles.omittedTitle': '这张形象因注入字数上限未进入本轮提示',
|
|
368
|
+
'export.saved': '已开始下载;若没有文件出现,请检查浏览器的下载列表',
|
|
369
|
+
'export.fallbackHint': '当前环境无法触发文件下载,完整 JSON 已放在下面:全选复制后自行保存',
|
|
370
|
+
'export.fallbackClose': '关闭',
|
|
371
|
+
'import.webviewHint': 'Android / WebView 上若文件选择器不弹出,请改用「源码模式」粘贴 JSON',
|
|
372
|
+
'sse.down': '实时更新已断开(连续重试仍失败),数据可能是旧的:请点「重新载入」',
|
|
365
373
|
'profiles.saving': '保存中…',
|
|
366
374
|
'action.new': '+ 新增指令',
|
|
367
375
|
'action.reload': '重新载入',
|
|
@@ -654,6 +662,11 @@ window.__ModuleLoader__.load({
|
|
|
654
662
|
'profiles.truncatedTitle': 'This persona exceeds the injection limit; only its head reached this turn',
|
|
655
663
|
'profiles.omitted': 'Not in this turn',
|
|
656
664
|
'profiles.omittedTitle': 'This persona was dropped by the injection limit and did not reach this turn',
|
|
665
|
+
'export.saved': 'Download started — if no file appears, check your browser’s download list',
|
|
666
|
+
'export.fallbackHint': 'This environment cannot start a file download; the full JSON is below — select all and copy it',
|
|
667
|
+
'export.fallbackClose': 'Close',
|
|
668
|
+
'import.webviewHint': 'On Android / WebView, if the file picker does not open, paste the JSON into “Source mode” instead',
|
|
669
|
+
'sse.down': 'Live updates stopped (retries kept failing) and the data may be stale — click “Reload”',
|
|
657
670
|
'profiles.saving': 'Saving…',
|
|
658
671
|
'action.new': '+ New instruction',
|
|
659
672
|
'action.reload': 'Reload',
|
|
@@ -1037,7 +1050,7 @@ window.__ModuleLoader__.load({
|
|
|
1037
1050
|
* @param {string} message
|
|
1038
1051
|
* @returns {{ kind: 'unavailable' } | { kind: 'cancel' } | { kind: 'answer', text: string }}
|
|
1039
1052
|
*/
|
|
1040
|
-
function batchTagOutcome(ask, message) {
|
|
1053
|
+
function batchTagOutcome(ask, message, options) {
|
|
1041
1054
|
if (typeof ask !== 'function') return { kind: 'unavailable' }
|
|
1042
1055
|
let answer
|
|
1043
1056
|
try {
|
|
@@ -1045,11 +1058,16 @@ window.__ModuleLoader__.load({
|
|
|
1045
1058
|
} catch (err) {
|
|
1046
1059
|
return { kind: 'unavailable' }
|
|
1047
1060
|
}
|
|
1048
|
-
//
|
|
1049
|
-
//
|
|
1050
|
-
//
|
|
1051
|
-
//
|
|
1052
|
-
|
|
1061
|
+
// Android WebView is a FOURTH shape this helper did not know about: it
|
|
1062
|
+
// keeps `window.prompt` as a function and does not throw, but with no
|
|
1063
|
+
// WebChromeClient.onJsPrompt it returns `null` immediately — never
|
|
1064
|
+
// showing a dialog. Treating that as "the user pressed Cancel" turned the
|
|
1065
|
+
// bulk-tag action into a silent no-op, and the inline fallback form below
|
|
1066
|
+
// was unreachable. The caller passes `nullMeansUnavailable` when it knows
|
|
1067
|
+
// the runtime cannot show a dialog.
|
|
1068
|
+
const nullIsUnavailable = options !== null && typeof options === 'object'
|
|
1069
|
+
&& options.nullMeansUnavailable === true
|
|
1070
|
+
if (answer === null) return nullIsUnavailable ? { kind: 'unavailable' } : { kind: 'cancel' }
|
|
1053
1071
|
if (typeof answer !== 'string') return { kind: 'unavailable' }
|
|
1054
1072
|
return { kind: 'answer', text: answer }
|
|
1055
1073
|
}
|
|
@@ -2005,6 +2023,10 @@ window.__ModuleLoader__.load({
|
|
|
2005
2023
|
// pretending the copy happened.
|
|
2006
2024
|
const [diagOpen, setDiagOpen] = React.useState(false)
|
|
2007
2025
|
const [diagFallback, setDiagFallback] = React.useState(null)
|
|
2026
|
+
/** Export JSON rendered in-panel when the runtime has no download path. */
|
|
2027
|
+
const [exportFallback, setExportFallback] = React.useState(null)
|
|
2028
|
+
/** True once the event stream has failed long enough to stop pretending. */
|
|
2029
|
+
const [liveDown, setLiveDown] = React.useState(false)
|
|
2008
2030
|
// Feedback form: closed by default so the settings card stays compact.
|
|
2009
2031
|
// The draft lives in the panel (not in a child) because the send handler
|
|
2010
2032
|
// needs the same values the inputs show, and keeping them here survives
|
|
@@ -2276,7 +2298,11 @@ window.__ModuleLoader__.load({
|
|
|
2276
2298
|
const ask = typeof window.prompt === 'function'
|
|
2277
2299
|
? (message, initial) => window.prompt(message, initial)
|
|
2278
2300
|
: null
|
|
2279
|
-
const outcome = batchTagOutcome(ask, t('batch.tagPrompt', { count: ids.length })
|
|
2301
|
+
const outcome = batchTagOutcome(ask, t('batch.tagPrompt', { count: ids.length }), {
|
|
2302
|
+
// A WebView without onJsPrompt answers `null` instantly and shows no
|
|
2303
|
+
// dialog: that is "cannot ask", not "the user said no".
|
|
2304
|
+
nullMeansUnavailable: isAndroidWebView(),
|
|
2305
|
+
})
|
|
2280
2306
|
if (outcome.kind === 'cancel') return
|
|
2281
2307
|
if (outcome.kind === 'answer') {
|
|
2282
2308
|
if (commitBatchTags(outcome.text)) closeBatchTags()
|
|
@@ -2686,6 +2712,30 @@ window.__ModuleLoader__.load({
|
|
|
2686
2712
|
}
|
|
2687
2713
|
}
|
|
2688
2714
|
|
|
2715
|
+
/**
|
|
2716
|
+
* Is this an Android WebView (as opposed to a normal browser or Electron)?
|
|
2717
|
+
*
|
|
2718
|
+
* The distinction matters because a WebView can answer `typeof
|
|
2719
|
+
* window.prompt === 'function'` and still never show a dialog, and it can
|
|
2720
|
+
* expose `<a download>` while having no download pipeline behind it. Both
|
|
2721
|
+
* fail silently, so the panel has to detect the environment and take the
|
|
2722
|
+
* in-page route instead of pretending the browser feature exists.
|
|
2723
|
+
*/
|
|
2724
|
+
function isAndroidWebView() {
|
|
2725
|
+
try {
|
|
2726
|
+
const ua = typeof navigator !== 'undefined' && typeof navigator.userAgent === 'string'
|
|
2727
|
+
? navigator.userAgent
|
|
2728
|
+
: ''
|
|
2729
|
+
// `; wv)` is the canonical WebView marker; DSH Go's webview island may
|
|
2730
|
+
// also report a stock Chromium UA, so fall back to "Android without a
|
|
2731
|
+
// full browser brand".
|
|
2732
|
+
return /;\s*wv\)/i.test(ua)
|
|
2733
|
+
|| (/Android/i.test(ua) && !/Chrome\/\d+/i.test(ua))
|
|
2734
|
+
} catch {
|
|
2735
|
+
return false
|
|
2736
|
+
}
|
|
2737
|
+
}
|
|
2738
|
+
|
|
2689
2739
|
async function doExport() {
|
|
2690
2740
|
try {
|
|
2691
2741
|
const result = await callHost('export-data', null)
|
|
@@ -2693,7 +2743,21 @@ window.__ModuleLoader__.load({
|
|
|
2693
2743
|
setError(t('export.failed'))
|
|
2694
2744
|
return
|
|
2695
2745
|
}
|
|
2696
|
-
const
|
|
2746
|
+
const text = JSON.stringify(result.export, null, 2) + '\n'
|
|
2747
|
+
/**
|
|
2748
|
+
* A WebView can expose `<a download>` and still have nowhere to put
|
|
2749
|
+
* the bytes: Android only saves when the embedding app implements a
|
|
2750
|
+
* DownloadListener, and Chromium's WebView cannot hand it a `blob:`.
|
|
2751
|
+
* The click then produces no file, no error, and no second chance (the
|
|
2752
|
+
* object URL is revoked a second later), so Android takes the in-panel
|
|
2753
|
+
* route unconditionally.
|
|
2754
|
+
*/
|
|
2755
|
+
if (isAndroidWebView() || typeof URL === 'undefined' || typeof URL.createObjectURL !== 'function') {
|
|
2756
|
+
setExportFallback(text)
|
|
2757
|
+
setNotice(t('export.fallbackHint'))
|
|
2758
|
+
return
|
|
2759
|
+
}
|
|
2760
|
+
const blob = new Blob([text], { type: 'application/json' })
|
|
2697
2761
|
const url = URL.createObjectURL(blob)
|
|
2698
2762
|
const link = document.createElement('a')
|
|
2699
2763
|
link.href = url
|
|
@@ -2702,6 +2766,7 @@ window.__ModuleLoader__.load({
|
|
|
2702
2766
|
link.click()
|
|
2703
2767
|
if (link.parentNode !== null) link.parentNode.removeChild(link)
|
|
2704
2768
|
setTimeout(() => { URL.revokeObjectURL(url) }, 1000)
|
|
2769
|
+
setNotice(t('export.saved'))
|
|
2705
2770
|
} catch (err) {
|
|
2706
2771
|
setError(String(err && err.message ? err.message : err))
|
|
2707
2772
|
}
|
|
@@ -2769,6 +2834,9 @@ window.__ModuleLoader__.load({
|
|
|
2769
2834
|
let alive = true
|
|
2770
2835
|
let refreshing = false
|
|
2771
2836
|
let missed = false
|
|
2837
|
+
/** Consecutive stream errors; reset by any successful frame. */
|
|
2838
|
+
let failures = 0
|
|
2839
|
+
let down = false
|
|
2772
2840
|
/**
|
|
2773
2841
|
* Monotonic frame counter carried by lite frames. `rev` only moves when
|
|
2774
2842
|
* the store is WRITTEN, so a snapshot that changed without a write (the
|
|
@@ -2797,6 +2865,11 @@ window.__ModuleLoader__.load({
|
|
|
2797
2865
|
})
|
|
2798
2866
|
}
|
|
2799
2867
|
source.onmessage = (ev) => {
|
|
2868
|
+
failures = 0
|
|
2869
|
+
if (down) {
|
|
2870
|
+
down = false
|
|
2871
|
+
setLiveDown(false)
|
|
2872
|
+
}
|
|
2800
2873
|
try {
|
|
2801
2874
|
const pushed = JSON.parse(ev.data)
|
|
2802
2875
|
if (pushed !== null && typeof pushed === 'object' && pushed.lite === true) {
|
|
@@ -2820,6 +2893,21 @@ window.__ModuleLoader__.load({
|
|
|
2820
2893
|
// A malformed frame is dropped; the next frame, or 重新载入, catches up.
|
|
2821
2894
|
}
|
|
2822
2895
|
}
|
|
2896
|
+
/**
|
|
2897
|
+
* Android kills the WebView renderer, expires the session cookie or
|
|
2898
|
+
* freezes the page in Doze; EventSource then retries every 5 s forever
|
|
2899
|
+
* and the panel shows stale data as if it were live. Six consecutive
|
|
2900
|
+
* failures (≈30 s) is past any transient blip, so say so and let the
|
|
2901
|
+
* user reload — the stream keeps retrying, and the next frame that
|
|
2902
|
+
* lands clears the warning.
|
|
2903
|
+
*/
|
|
2904
|
+
source.onerror = () => {
|
|
2905
|
+
failures += 1
|
|
2906
|
+
if (!down && failures >= 6) {
|
|
2907
|
+
down = true
|
|
2908
|
+
setLiveDown(true)
|
|
2909
|
+
}
|
|
2910
|
+
}
|
|
2823
2911
|
return () => { alive = false; source.close() }
|
|
2824
2912
|
}, [])
|
|
2825
2913
|
|
|
@@ -3585,6 +3673,21 @@ window.__ModuleLoader__.load({
|
|
|
3585
3673
|
}, batchMode ? t('batch.exit') : t('action.batch')),
|
|
3586
3674
|
h('span', { className: 'im-spacer' }),
|
|
3587
3675
|
h('span', { className: 'im-meta' }, t('stats.count', { total: entries.length, active: activeCount }))),
|
|
3676
|
+
exportFallback !== null
|
|
3677
|
+
? h('div', { className: 'im-diag-fallback' },
|
|
3678
|
+
h('div', { className: 'im-meta' }, t('export.fallbackHint')),
|
|
3679
|
+
h('textarea', {
|
|
3680
|
+
className: 'im-textarea',
|
|
3681
|
+
readOnly: true,
|
|
3682
|
+
value: exportFallback,
|
|
3683
|
+
onFocus: (ev) => { if (typeof ev.target.select === 'function') ev.target.select() },
|
|
3684
|
+
}),
|
|
3685
|
+
h('div', { className: 'im-row-wrap' },
|
|
3686
|
+
h('button', { className: 'im-btn', onClick: () => setExportFallback(null) }, t('export.fallbackClose'))))
|
|
3687
|
+
: null,
|
|
3688
|
+
isAndroidWebView()
|
|
3689
|
+
? h('div', { className: 'im-meta' }, t('import.webviewHint'))
|
|
3690
|
+
: null,
|
|
3588
3691
|
h('div', { className: 'im-divider' }),
|
|
3589
3692
|
h('div', { className: 'im-meta' },
|
|
3590
3693
|
storage.path ? t('storage.file', { path: storage.path }) : t('storage.noFile')),
|
|
@@ -3705,6 +3808,7 @@ window.__ModuleLoader__.load({
|
|
|
3705
3808
|
}))
|
|
3706
3809
|
: null)
|
|
3707
3810
|
: null),
|
|
3811
|
+
liveDown ? h('div', { className: 'im-meta', role: 'status' }, t('sse.down')) : null,
|
|
3708
3812
|
error !== '' ? h('div', { className: 'im-err', role: 'alert' }, error) : null,
|
|
3709
3813
|
error === '' && notice !== '' ? h('div', { className: 'im-ok', role: 'status', 'aria-live': 'polite' }, notice) : null,
|
|
3710
3814
|
error === '' && notice === '' && storage.error ? h('div', { className: 'im-err', role: 'alert' }, storage.error) : null,
|
|
@@ -4070,7 +4174,7 @@ window.__ModuleLoader__.load({
|
|
|
4070
4174
|
box.id = id
|
|
4071
4175
|
box.setAttribute('role', 'status')
|
|
4072
4176
|
box.style.cssText = [
|
|
4073
|
-
'position:fixed', 'right:16px', 'bottom:16px', 'z-index:2147483000',
|
|
4177
|
+
'position:fixed', 'right:16px', 'bottom:calc(16px + env(safe-area-inset-bottom,0px))', 'z-index:2147483000',
|
|
4074
4178
|
'max-width:min(380px,calc(100vw - 32px))',
|
|
4075
4179
|
'background:#3a1d1d', 'color:#ffe8e8', 'border:1px solid #7a3a3a',
|
|
4076
4180
|
'border-radius:10px', 'padding:12px 14px',
|
package/lib/index.js
CHANGED
|
@@ -182,6 +182,24 @@ const TRUSTED_HOSTS_ENV = 'DSH_INSTRUCTION_MEMORY_TRUSTED_HOSTS'
|
|
|
182
182
|
* volume.
|
|
183
183
|
*/
|
|
184
184
|
const ALLOW_EXTERNAL_BACKUP_ENV = 'DSH_INSTRUCTION_MEMORY_ALLOW_EXTERNAL_BACKUP'
|
|
185
|
+
/**
|
|
186
|
+
* Opt-in "no fence, no route" switch.
|
|
187
|
+
*
|
|
188
|
+
* Without a `connection` service the plugin falls back to its own loopback /
|
|
189
|
+
* same-origin rule. On a desktop that is a real boundary; on Android it is not,
|
|
190
|
+
* because loopback lives in a namespace every app on the device shares — any
|
|
191
|
+
* app with INTERNET can then rewrite the instructions of every later turn.
|
|
192
|
+
* DSH Go's build-time route guard normally closes that, but the guard is a
|
|
193
|
+
* platform property: a `dsh web` started without `--patch` silently loses it.
|
|
194
|
+
* Setting this to 1 makes the plugin refuse instead of degrade, so the failure
|
|
195
|
+
* mode becomes a settings page that cannot load rather than a writable one.
|
|
196
|
+
*/
|
|
197
|
+
const REQUIRE_AUTH_FENCE_ENV = 'DSH_INSTRUCTION_MEMORY_REQUIRE_AUTH_FENCE'
|
|
198
|
+
/** Is the fail-closed switch on? Read per request so a restart is not needed. */
|
|
199
|
+
const requireAuthFence = () => {
|
|
200
|
+
const raw = process.env[REQUIRE_AUTH_FENCE_ENV]
|
|
201
|
+
return typeof raw === 'string' && raw.trim() === '1'
|
|
202
|
+
}
|
|
185
203
|
/**
|
|
186
204
|
* How often the store file is stat-ed as a fallback for `fs.watch` (F19).
|
|
187
205
|
*
|
|
@@ -2532,6 +2550,17 @@ export function apply(ctx, deps = {}) {
|
|
|
2532
2550
|
const raw = process.env[ALLOW_EXTERNAL_BACKUP_ENV]
|
|
2533
2551
|
return typeof raw === 'string' && raw.trim() === '1'
|
|
2534
2552
|
}
|
|
2553
|
+
/**
|
|
2554
|
+
* The "set this env var and restart" footer shared by the three backup paths.
|
|
2555
|
+
*
|
|
2556
|
+
* On Linux — and therefore on every Android build, DSH Go included — the
|
|
2557
|
+
* engine is started through `/usr/bin/env -i`, which leaves no way for the
|
|
2558
|
+
* user to set it. Telling them to export a variable they cannot export is a
|
|
2559
|
+
* dead instruction; point at the route that does work instead.
|
|
2560
|
+
*/
|
|
2561
|
+
const externalBackupHint = () => (process.platform === 'linux'
|
|
2562
|
+
? '要使用其他磁盘请设置 ' + ALLOW_EXTERNAL_BACKUP_ENV + '=1 后重启宿主(DSH Go / Android 用 env -i 启动引擎,该变量设不了,请改用「导出」把 JSON 复制出来备份)'
|
|
2563
|
+
: '要使用其他磁盘请设置 ' + ALLOW_EXTERNAL_BACKUP_ENV + '=1 后重启宿主')
|
|
2535
2564
|
/** `path`'s real location, or null when it (or a parent) is unavailable. */
|
|
2536
2565
|
const realPathOrNull = (path) => {
|
|
2537
2566
|
try {
|
|
@@ -2597,7 +2626,7 @@ export function apply(ctx, deps = {}) {
|
|
|
2597
2626
|
if (refusedBackupDir !== configured) {
|
|
2598
2627
|
refusedBackupDir = configured
|
|
2599
2628
|
state.storageWarning = '备份位置 ' + displayPath(configured) + ' 不在 DSH 主目录内,已忽略(备份与历史快照一律写回默认位置);'
|
|
2600
|
-
+
|
|
2629
|
+
+ externalBackupHint()
|
|
2601
2630
|
logInfo('ignoring backupDir outside the harness home: ' + configured)
|
|
2602
2631
|
}
|
|
2603
2632
|
return ''
|
|
@@ -4007,8 +4036,7 @@ export function apply(ctx, deps = {}) {
|
|
|
4007
4036
|
return {
|
|
4008
4037
|
ok: false,
|
|
4009
4038
|
saved: false,
|
|
4010
|
-
message: '备份位置必须位于 DSH 主目录内(' + displayPath(dshRootPath()) + '
|
|
4011
|
-
+ ALLOW_EXTERNAL_BACKUP_ENV + '=1 后重启宿主',
|
|
4039
|
+
message: '备份位置必须位于 DSH 主目录内(' + displayPath(dshRootPath()) + ');' + externalBackupHint(),
|
|
4012
4040
|
snapshot: snapshot(),
|
|
4013
4041
|
}
|
|
4014
4042
|
}
|
|
@@ -4641,8 +4669,7 @@ export function apply(ctx, deps = {}) {
|
|
|
4641
4669
|
return {
|
|
4642
4670
|
ok: false,
|
|
4643
4671
|
saved: false,
|
|
4644
|
-
message: '导出目录必须位于 DSH 主目录内(' + displayPath(dshRootPath()) + '
|
|
4645
|
-
+ ALLOW_EXTERNAL_BACKUP_ENV + '=1 后重启宿主',
|
|
4672
|
+
message: '导出目录必须位于 DSH 主目录内(' + displayPath(dshRootPath()) + ');' + externalBackupHint(),
|
|
4646
4673
|
snapshot: snapshot(),
|
|
4647
4674
|
}
|
|
4648
4675
|
}
|
|
@@ -5388,6 +5415,16 @@ export function apply(ctx, deps = {}) {
|
|
|
5388
5415
|
'cache-control': 'no-store',
|
|
5389
5416
|
connection: 'keep-alive',
|
|
5390
5417
|
})
|
|
5418
|
+
// Mobile renderers are killed without a FIN (Android LMK) or lose their
|
|
5419
|
+
// network mid-stream (Wi-Fi → cellular). The heartbeat cannot notice a peer
|
|
5420
|
+
// that never errors — every write succeeds into the socket buffer — so at
|
|
5421
|
+
// least let the OS probe for a dead peer, and never sit on Nagle.
|
|
5422
|
+
try {
|
|
5423
|
+
if (res.socket && typeof res.socket.setKeepAlive === 'function') res.socket.setKeepAlive(true, 30_000)
|
|
5424
|
+
if (res.socket && typeof res.socket.setNoDelay === 'function') res.socket.setNoDelay(true)
|
|
5425
|
+
} catch {
|
|
5426
|
+
// A double without a socket (tests) or a closed one: nothing to tune.
|
|
5427
|
+
}
|
|
5391
5428
|
// Ask the browser to retry politely if the stream drops (e.g. the host was
|
|
5392
5429
|
// restarted) instead of hammering the route.
|
|
5393
5430
|
res.write('retry: 5000\n\n')
|
|
@@ -5484,6 +5521,24 @@ export function apply(ctx, deps = {}) {
|
|
|
5484
5521
|
}
|
|
5485
5522
|
return 'gate'
|
|
5486
5523
|
}
|
|
5524
|
+
/**
|
|
5525
|
+
* Deeper defence for deployments where loopback is not a boundary: on
|
|
5526
|
+
* Android every app shares the loopback namespace, so "reachable only from
|
|
5527
|
+
* this machine" stops meaning "reachable only from this user". With the
|
|
5528
|
+
* switch set, a profile without a fence gets NO route at all — a settings
|
|
5529
|
+
* page that fails to load is a far better outcome than one that can be
|
|
5530
|
+
* rewritten by any installed app.
|
|
5531
|
+
*/
|
|
5532
|
+
if (requireAuthFence()) {
|
|
5533
|
+
logInfo('route refused a request: no connection fence and ' + REQUIRE_AUTH_FENCE_ENV + '=1')
|
|
5534
|
+
res.writeHead(503, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
5535
|
+
res.end(JSON.stringify({
|
|
5536
|
+
ok: false,
|
|
5537
|
+
message: '该 profile 没有宿主的 connection 服务,已按 ' + REQUIRE_AUTH_FENCE_ENV
|
|
5538
|
+
+ '=1 拒绝请求(fail-closed);如需在无鉴权宿主上使用,请移除该环境变量。',
|
|
5539
|
+
}))
|
|
5540
|
+
return 'rejected'
|
|
5541
|
+
}
|
|
5487
5542
|
if (!guardHost) {
|
|
5488
5543
|
logInfo('route refused a request: non-loopback bind without an authentication fence')
|
|
5489
5544
|
res.writeHead(503, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-instruction-memory",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.5",
|
|
4
4
|
"description": "User-only instruction memory for DSH: long-term instructions maintained in the settings UI and auto-injected into every conversation — the model has no write access. 指令记忆:只由用户在设置页维护的长期指令,自动注入此后每轮对话;模型没有任何写入口。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,7 +31,8 @@
|
|
|
31
31
|
"CHANGELOG.md",
|
|
32
32
|
"LICENSE",
|
|
33
33
|
"memory.schema.json",
|
|
34
|
-
"docs/screenshot.png"
|
|
34
|
+
"docs/screenshot.png",
|
|
35
|
+
"docs/demo.html"
|
|
35
36
|
],
|
|
36
37
|
"keywords": [
|
|
37
38
|
"dsh",
|