@aiti-ai/aiti-draft-keeper 0.0.0-stage → 0.1.8
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/AGENTS.md +28 -0
- package/LICENSE +21 -0
- package/README.md +91 -2
- package/check-client.mjs +273 -0
- package/client/src/aiti-draft-keeper-client.mjs +128 -0
- package/client/src/core.mjs +437 -0
- package/client/src/core.test.mjs +467 -0
- package/deploy-commit.mjs +52 -0
- package/dev-pack.mjs +246 -0
- package/dist/aiti-draft-keeper-client.mjs +128 -0
- package/dist/core.mjs +437 -0
- package/dist/core.test.mjs +467 -0
- package/index.ts +22 -0
- package/package.json +19 -4
- package/reload-instances.mjs +60 -0
- package/tools/deploy.mjs +53 -0
package/AGENTS.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# aiti-draft-keeper 扩展(部署目录直改指南)
|
|
2
|
+
|
|
3
|
+
## 结构
|
|
4
|
+
- 运行时文件平铺在本目录根(index.ts / core.cjs …,宿主 loader 按相对路径加载——**禁手改运行时平铺文件**,改 src/ 再 build)
|
|
5
|
+
- `src/` 源码(client/src + 服务端 ts/cjs)· `dist/` 构建产物(禁手改)· `tools/` 构建/部署脚本
|
|
6
|
+
- npm 包形态的扩展:本目录 package.json 含 version 与 aitiConfigs 声明
|
|
7
|
+
|
|
8
|
+
## 小改现场流程(改文案/样式/小逻辑)
|
|
9
|
+
1. 改 `src/` 下对应源码
|
|
10
|
+
2. `node tools/build.mjs`(client 有构建产物的扩展;纯服务端改动跳过)
|
|
11
|
+
3. `curl -X POST http://127.0.0.1:3001/api/extensions/reload`(dev;prod 3002;外部经 nginx 带 basePath:/dev、/mac、/mdev、/win、/wdev → `/xxx/api/extensions/reload`)
|
|
12
|
+
4. 浏览器刷新页面验证
|
|
13
|
+
|
|
14
|
+
## 大改回源码仓
|
|
15
|
+
- 源码仓:`extensions/aiti-draft-keeper/`(git 管理,deploy 同步回本目录)
|
|
16
|
+
- 改完在源码仓跑 `node tools/deploy.mjs`(或仓库根 `node scripts/deploy-all.mjs --only aiti-draft-keeper`)
|
|
17
|
+
|
|
18
|
+
## 数据目录(数据分层:config / cache)
|
|
19
|
+
- `ctx.configDir` 根级 = 配置与关键小状态(aiti-configs 备份范围;小、珍贵、人可读)
|
|
20
|
+
- `ctx.cacheDir`(= `<configDir>/cache/`)= 可删缓存:db/venv/日志/大文件;rm -rf 后扩展下次访问自动重建
|
|
21
|
+
- `ctx.dataDir` 已移除(访问即抛)——新代码禁用
|
|
22
|
+
- 本扩展白名单(永不为 cache 的配置文件)见 package.json `aitiConfigs.configFiles`
|
|
23
|
+
|
|
24
|
+
## 红线
|
|
25
|
+
- 禁轮询(setInterval 拉取);需要等待用事件/回调/SSE(桥的 SSE、aiti-event-bus topics)
|
|
26
|
+
- 禁重启生产服务(pi-web 3002 等 restart 须用户明确同意)
|
|
27
|
+
- 禁手改 dist/ 与运行时平铺文件;跨扩展通信走 ctx.services(禁 globalThis.__ext 直穿)
|
|
28
|
+
- 服务端:SQLite 一律 WAL + 分批小事务(批间 setImmediate 让步);禁 execFileSync 调 >1s 外部程序;>8MB 文件禁同步读
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 aiti-ai contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,92 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Draft Keeper(aiti-draft-keeper)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
## 来源 · Origin
|
|
4
|
+
|
|
5
|
+
本包是 **aiti** 生态的组成部分——以 [pi](https://pi.dev) 为引擎的个人 AI 开发工作台与本地调试注入生态:**aiti-agent**(自托管 Web 工作台,基于开源项目 [pi-web](https://github.com/aiti-ai/pi-web) 构建,浏览器为主操作面)+ 各开发工具内的调试注入(aiti-devtools)+ 丰富的扩展体系。
|
|
6
|
+
|
|
7
|
+
This package is part of the **aiti** ecosystem — a personal AI development workbench powered by [pi](https://pi.dev): **aiti-agent** (a self-hosted web workbench built on the open-source [pi-web](https://github.com/aiti-ai/pi-web)) plus debug injection into mainstream dev tools (aiti-devtools) and a rich extension system.
|
|
8
|
+
|
|
9
|
+
**English**: Draft persistence and crash recovery for the composer — the sole owner of the composer draft state machine (v2 intent contract), shared slots across tabs, 7-day stale pruning.
|
|
10
|
+
|
|
11
|
+
- 工作台 / workbench: <https://github.com/aiti-ai/pi-web>
|
|
12
|
+
- 生态仓库 / monorepo: <https://github.com/aiti-ai/aiti-ai>
|
|
13
|
+
- 官网 / site: <https://aiti.pw>
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
草稿持久化 + 恢复接管扩展——pi-web 去 fork 化 P2 的产物(`docs/pi-web-defork-roadmap.md` §3.B)。
|
|
17
|
+
core 的草稿逻辑(原 `lib/draft-store.ts` + ChatInput/ChatWindow 接线)已全部迁出,
|
|
18
|
+
**core 零 draft-store 依赖**;core 经 `lib/composer/draft-api.ts` 注册表访问本扩展
|
|
19
|
+
提供的存储 API,经 composer 增强契约广播 key/值变化。
|
|
20
|
+
|
|
21
|
+
## 架构(v2:状态机唯一所有者)
|
|
22
|
+
|
|
23
|
+
- `client/core.mjs` = **草稿状态机纯逻辑**(storage/timers/onRestore 注入可测,
|
|
24
|
+
`core.test.mjs` 17 用例钉死语义);`client/aiti-draft-keeper-client.mjs` =
|
|
25
|
+
薄壳(window/localStorage 适配 + hydration 门控 + DraftApi 注册)。
|
|
26
|
+
- **core(pi-web)唯一写入口是意图**(`applyDraftIntent`,见 pi-web
|
|
27
|
+
`docs/composer-enhancement-contract.md` v2 节):
|
|
28
|
+
- `sessionCreated {provisionalKey, sessionId}`:promote 时 provisional 草稿
|
|
29
|
+
作废;promote 窗口(key prop 切换前)的新输入重定向落 sid 槽位;
|
|
30
|
+
- `rejected {key, text, images}`:发送失败合并回草稿,当前框则写回。
|
|
31
|
+
- `slotKeyOf` 按**最后一个冒号**取 cwd 段——draftId 可能自身含冒号
|
|
32
|
+
(?cwd= 引导的 `initial:<cwd>` 形式),不得按首个冒号切分。
|
|
33
|
+
- `pruneStale()`:启动一次性清理 >7 天未写入槽位(含旧 per-tab 残留、无 ts 旧格式)。
|
|
34
|
+
- 已修「恢复即删」:scan 恢复后同一轮 onChange 直接返回(恢复把 known 指向
|
|
35
|
+
恢复值后,空快照继续走清空分支会在确认延时后删掉刚恢复的草稿)。
|
|
36
|
+
|
|
37
|
+
## 模型(共享槽位 + 无贡献不回写)
|
|
38
|
+
|
|
39
|
+
- **每会话一个共享槽位** `crashrestore-<key>`(无 tabId)——多标签同会话天然共享
|
|
40
|
+
同一份草稿;新建视图 = `crashrestore-new:<cwd>`(cwd 族自然 key,draftId 抖动无感)。
|
|
41
|
+
- **known** = 本标签页最后显示/写入的值。
|
|
42
|
+
- **onChange(真实输入才写)**:
|
|
43
|
+
- `snap === known` → **不写**(无贡献不回写★——防震荡的核心);
|
|
44
|
+
- `snap === ""` → 清槽位(**清空/发走再清**★;延迟 300ms 确认,防陈旧实例
|
|
45
|
+
空快照误清;打开时空框不清);
|
|
46
|
+
- 其他 → 写新值(有贡献才写;last-write-wins)。
|
|
47
|
+
- **scan(只读,永不写 → 无震荡源)**:
|
|
48
|
+
- 框空 + 有存储 → 静默恢复进框(**恢复不清暂存**;known = 恢复值);
|
|
49
|
+
- 框有内容 + 存储 ⊇ known(别的标签页扩展了草稿)→ 同步进框;
|
|
50
|
+
- 分叉 → 不动(写方写新值)。
|
|
51
|
+
- **恢复等宿主水合完成**(`pi-web:hydrated` 信号 + stage 重放二道防线)——
|
|
52
|
+
水合前落框会被 hydration 失配重渲染按服务端 HTML(空框)抹掉。
|
|
53
|
+
|
|
54
|
+
## 多标签语义
|
|
55
|
+
|
|
56
|
+
共享草稿 + last-write-wins + 被动同步:
|
|
57
|
+
- 两个标签同会话各自打字 → 各自刷新各自恢复(共享槽位,无 own/foreign 之分);
|
|
58
|
+
- 一个标签扩展了草稿 → 另一标签被动同步进框;
|
|
59
|
+
- 同时打字 → last-write-wins(共享草稿语义的固有属性);
|
|
60
|
+
- 「正在输入本页最大」:框有内容时绝不弹框/恢复,包含关系自动分流
|
|
61
|
+
(框 ⊇ 草稿丢弃;草稿 ⊇ 框静默覆盖;互不包含等下次框空恢复)。
|
|
62
|
+
|
|
63
|
+
## 契约依赖(core 侧,worktree defork/composer-contract 分支)
|
|
64
|
+
|
|
65
|
+
- `ctx.registerComposerEnhancement`(值观测/key 切换/发送前钩子)
|
|
66
|
+
- `ctx.restoreComposerDraft` / `ctx.writeComposerValue`(受控写值,tracker 同步)
|
|
67
|
+
- `ctx.registerDraftApi`(core 经 `getDraftApi()` 访问存储:**applyIntent**/
|
|
68
|
+
getDraft/clearAllDrafts/flushAll 为 v2 面;saveDraft/clearDraft/rekeyDraft/
|
|
69
|
+
restoreSubmission 为 deprecated,仅供未升级旧 core 回退)
|
|
70
|
+
- `window.__piWebHydrated` + `pi-web:hydrated` 事件(水合信号)
|
|
71
|
+
- ChatInput 增强器集合变化补发(晚注册补偿)
|
|
72
|
+
|
|
73
|
+
## 已知边界
|
|
74
|
+
|
|
75
|
+
- extman 关掉 keeper(或重开后关闭)→ 草稿功能停(core 无兜底,keeper 是
|
|
76
|
+
唯一所有者;运行时开关立即生效)。
|
|
77
|
+
- 双标签**同时**打字 = last-write-wins(共享草稿语义固有)。
|
|
78
|
+
- 旧 per-tab 槽位(crashrestore-<key>-<tabId>)首次读取时自动迁移到共享槽位;
|
|
79
|
+
从未访问的会话的旧槽位残留(无害)。
|
|
80
|
+
|
|
81
|
+
## 部署
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
node tools/deploy.mjs # 免构建;拷贝到 ~/.pi/web{,-dev}/extensions/
|
|
85
|
+
```
|
|
86
|
+
生效:POST /api/extensions/reload;刷新页面即新 client。
|
|
87
|
+
|
|
88
|
+
## 文档 · Documentation
|
|
89
|
+
|
|
90
|
+
- 中文完整文档(aiti-docs): <https://aiti.pw/extensions/aiti-draft-keeper.html>
|
|
91
|
+
- English docs: <https://aiti.pw/en/extensions/aiti-draft-keeper.html>
|
|
92
|
+
- 扩展注册表 / registry: <https://aiti.pw/extensions.html> · machine-readable <https://aiti.pw/extensions.json>
|
package/check-client.mjs
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* check-client —— 扩展 client .mjs/.js 的 TS 残留 tripwire(零依赖,纯 node)。
|
|
3
|
+
*
|
|
4
|
+
* 背景:aiti-plan-review 事故(docs/aiti-ext-client-hardening-plan.md §1)——
|
|
5
|
+
* `useState<any>(null)` 解析为合法 JS 比较表达式 `useState < any > (null)`,
|
|
6
|
+
* node --check / TS 解析器 / babel 全不报错,只有运行时才炸。纯语法检查
|
|
7
|
+
* 原理性无效,只能启发式规则 + 渲染期错误边界兜底。
|
|
8
|
+
*
|
|
9
|
+
* 用法:
|
|
10
|
+
* CLI node check-client.mjs <file|dir>... FAIL → exit 1
|
|
11
|
+
* 自测 node check-client.mjs --selftest fixtures 全过 → exit 0
|
|
12
|
+
* API lintClientSource(src) → violations[] { line, level, rule, text }
|
|
13
|
+
* lintPath(path) → { files, violations }(文件或目录,递归)
|
|
14
|
+
* checkClientDir(dir,{log}) FAIL → throw(deploy 脚本用)
|
|
15
|
+
*
|
|
16
|
+
* 词法:规则匹配前先剥离注释/字符串/模板字面量(${} 插值内代码保留检查)——
|
|
17
|
+
* aiti-editor 的 Monaco TS stub 模板串、JSDoc @return 都是合法内容,不剥全误报。
|
|
18
|
+
* 已知极限:regex 字面量含引号等 lexer 歧义允许漏报(tripwire 定位非编译器)。
|
|
19
|
+
*
|
|
20
|
+
* 豁免:目标行或其上一行含 `check-client:allow` 即跳过该行。
|
|
21
|
+
* 权威源:本文件;aiti-extman 部署副本由其 tools/deploy.mjs 拷贝(改动两处同步)。
|
|
22
|
+
*/
|
|
23
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
24
|
+
import { join, resolve } from "node:path";
|
|
25
|
+
|
|
26
|
+
// ── 规则(分级)──────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
const RULES = [
|
|
29
|
+
// FAIL:合法 JS 但运行时必炸 / 纯 TS 语法
|
|
30
|
+
{ level: "FAIL", re: /\b(useState|useRef|useMemo|useCallback|useReducer|useImperativeHandle)\s*</, why: "React hook 泛型(运行时必炸)" },
|
|
31
|
+
// 注:含「any/unknown/string」字样的规则用字符串构 RegExp——本文件自身也
|
|
32
|
+
// 会被扫,正则字面量里的 (?:any 会被自己命中(词法器不处理 regex 字面量),
|
|
33
|
+
// 字符串字面量则会被词法遮蔽。
|
|
34
|
+
{ level: "FAIL", re: new RegExp("\\bas\\s+(?:" + "any|unknown|const)\\b"), why: "as 断言" },
|
|
35
|
+
{ level: "FAIL", re: new RegExp(":\\s*(?:" + "any|unknown)\\b"), why: ": any/: unknown 注解" },
|
|
36
|
+
{ level: "FAIL", re: /\b(?:import|export)\s+type\s/, why: "import/export type" },
|
|
37
|
+
{ level: "FAIL", re: /\b(?:interface|enum)\s+[A-Za-z_$]/, why: "interface/enum 声明" },
|
|
38
|
+
// warn:疑似残留,人工确认,不阻断
|
|
39
|
+
{ level: "warn", re: new RegExp(":\\s*(?:" + "string|number|boolean|void)\\b(?!\\s*[._\\w])"), why: "疑似类型注解" },
|
|
40
|
+
{ level: "warn", re: /\bsatisfies\s/, why: "satisfies" },
|
|
41
|
+
{ level: "warn", re: /\b(?:Record|Partial|Readonly|Omit|Pick)\s*</, why: "TS 工具类型" },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
// ── 词法剥离:注释/字符串/模板字面量 → 空格(保留换行,行号不漂移)─────────
|
|
45
|
+
export function stripLiterals(src) {
|
|
46
|
+
const out = src.split("");
|
|
47
|
+
const n = src.length;
|
|
48
|
+
const mask = (from, to) => {
|
|
49
|
+
for (let k = from; k < to && k < n; k++) if (out[k] !== "\n") out[k] = " ";
|
|
50
|
+
};
|
|
51
|
+
let mode = "code";
|
|
52
|
+
let depth = 0;
|
|
53
|
+
const stack = []; // 模板 ${ 插值进入时的 brace 深度(支持嵌套模板)
|
|
54
|
+
let i = 0;
|
|
55
|
+
while (i < n) {
|
|
56
|
+
const c = src[i];
|
|
57
|
+
const d = i + 1 < n ? src[i + 1] : "";
|
|
58
|
+
if (mode === "code") {
|
|
59
|
+
if (c === "/" && d === "/") {
|
|
60
|
+
const j = src.indexOf("\n", i);
|
|
61
|
+
const end = j === -1 ? n : j;
|
|
62
|
+
mask(i, end);
|
|
63
|
+
i = end;
|
|
64
|
+
} else if (c === "/" && d === "*") {
|
|
65
|
+
const j = src.indexOf("*/", i + 2);
|
|
66
|
+
const end = j === -1 ? n : j + 2;
|
|
67
|
+
mask(i, end);
|
|
68
|
+
i = end;
|
|
69
|
+
} else if (c === "'" || c === '"') {
|
|
70
|
+
let j = i + 1;
|
|
71
|
+
while (j < n && src[j] !== c && src[j] !== "\n") j += src[j] === "\\" ? 2 : 1;
|
|
72
|
+
if (j < n && src[j] === c) {
|
|
73
|
+
mask(i, j + 1);
|
|
74
|
+
i = j + 1;
|
|
75
|
+
} else {
|
|
76
|
+
// 未闭合到行尾 → 可能是 regex 字面量歧义,只遮开引号,按代码继续
|
|
77
|
+
out[i] = " ";
|
|
78
|
+
i++;
|
|
79
|
+
}
|
|
80
|
+
} else if (c === "`") {
|
|
81
|
+
mode = "tpl";
|
|
82
|
+
out[i] = " ";
|
|
83
|
+
i++;
|
|
84
|
+
} else if (c === "{") {
|
|
85
|
+
depth++;
|
|
86
|
+
i++;
|
|
87
|
+
} else if (c === "}") {
|
|
88
|
+
if (stack.length && depth === stack[stack.length - 1]) {
|
|
89
|
+
stack.pop();
|
|
90
|
+
mode = "tpl"; // 插值结束回模板
|
|
91
|
+
out[i] = " ";
|
|
92
|
+
} else {
|
|
93
|
+
depth = Math.max(0, depth - 1);
|
|
94
|
+
}
|
|
95
|
+
i++;
|
|
96
|
+
} else i++;
|
|
97
|
+
} else if (mode === "tpl") {
|
|
98
|
+
if (c === "\\") {
|
|
99
|
+
mask(i, Math.min(i + 2, n));
|
|
100
|
+
i += 2;
|
|
101
|
+
} else if (c === "`") {
|
|
102
|
+
mode = "code";
|
|
103
|
+
out[i] = " ";
|
|
104
|
+
i++;
|
|
105
|
+
} else if (c === "$" && d === "{") {
|
|
106
|
+
stack.push(depth);
|
|
107
|
+
mode = "code";
|
|
108
|
+
mask(i, i + 2);
|
|
109
|
+
i += 2;
|
|
110
|
+
} else {
|
|
111
|
+
if (c !== "\n") out[i] = " ";
|
|
112
|
+
i++;
|
|
113
|
+
}
|
|
114
|
+
} else {
|
|
115
|
+
// 不可达:line/block/字符串均在 code 分支内联处理
|
|
116
|
+
i++;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return out.join("");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── 单源检查 ────────────────────────────────────────────────────────────────
|
|
123
|
+
export function lintClientSource(src) {
|
|
124
|
+
const masked = stripLiterals(src);
|
|
125
|
+
const origLines = src.split("\n");
|
|
126
|
+
const maskedLines = masked.split("\n");
|
|
127
|
+
const violations = [];
|
|
128
|
+
for (let i = 0; i < maskedLines.length; i++) {
|
|
129
|
+
const prev = i > 0 ? origLines[i - 1] : "";
|
|
130
|
+
if (origLines[i].includes("check-client:allow") || prev.includes("check-client:allow")) continue;
|
|
131
|
+
for (const { level, re, why } of RULES) {
|
|
132
|
+
if (re.test(maskedLines[i])) {
|
|
133
|
+
violations.push({ line: i + 1, level, rule: why, text: origLines[i].trim().slice(0, 90) });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return violations;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── 文件/目录检查 ───────────────────────────────────────────────────────────
|
|
141
|
+
const SKIP_DIR = new Set(["node_modules", ".git"]);
|
|
142
|
+
/** 超过此体积视为 vendored bundle(如 Monaco ts.worker,合法含 interface 字样),不查。 */
|
|
143
|
+
const MAX_BYTES = 512 * 1024;
|
|
144
|
+
const isClientFile = (name) => /\.(mjs|js|cjs)$/.test(name) && !/\.min\./.test(name) && !/\.test\./.test(name);
|
|
145
|
+
|
|
146
|
+
export function lintPath(p, out = { files: 0, violations: [] }) {
|
|
147
|
+
const st = statSync(p);
|
|
148
|
+
if (st.isDirectory()) {
|
|
149
|
+
for (const name of readdirSync(p)) {
|
|
150
|
+
if (SKIP_DIR.has(name)) continue;
|
|
151
|
+
lintPath(join(p, name), out);
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
const base = p.split("/").pop();
|
|
156
|
+
if (!isClientFile(base)) return out;
|
|
157
|
+
if (st.size > MAX_BYTES) return out; // vendored bundle 豁免
|
|
158
|
+
out.files++;
|
|
159
|
+
for (const v of lintClientSource(readFileSync(p, "utf8"))) out.violations.push({ file: p, ...v });
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function formatViolations(violations) {
|
|
164
|
+
return violations
|
|
165
|
+
.map((v) => ` ${v.level} ${v.file}:${v.line} [${v.rule}] ${v.text}`)
|
|
166
|
+
.join("\n");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** deploy 脚本用:检查目录,FAIL 抛错(消息含违规清单),warn 只打日志不阻断。 */
|
|
170
|
+
export function checkClientDir(dir, { log = console.log } = {}) {
|
|
171
|
+
const { files, violations } = lintPath(resolve(dir));
|
|
172
|
+
const fails = violations.filter((v) => v.level === "FAIL");
|
|
173
|
+
const warns = violations.filter((v) => v.level === "warn");
|
|
174
|
+
if (warns.length) log(`[check-client] ${warns.length} 处疑似 TS 残留(不阻断,建议人工确认):\n${formatViolations(warns)}`);
|
|
175
|
+
if (fails.length) {
|
|
176
|
+
throw new Error(
|
|
177
|
+
`client 检查未通过:${files} 个文件中 ${fails.length} 处 TS 残留(.mjs 里是合法 JS,语法检查不报,运行时必炸)。\n` +
|
|
178
|
+
formatViolations(fails) +
|
|
179
|
+
`\n修复或在该行加注释 // check-client:allow 豁免。详见 docs/aiti-ext-client-hardening-plan.md`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
if (files) log(`[check-client] ${files} 个 client 文件检查通过`);
|
|
183
|
+
return { files, fails: fails.length, warns: warns.length };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* extman 安装用:扫整个扩展目录(含第三方,不遵循 client/ 约定)。
|
|
188
|
+
* 在 SKIP_DIR 之上额外跳过构建产物/vendored 目录(Monaco ts.worker、语言包等
|
|
189
|
+
* 合法包含 TS 字样,词法器对 minified 代码不提供保证);体积上限同上。
|
|
190
|
+
*/
|
|
191
|
+
export function lintExtensionDir(dir) {
|
|
192
|
+
const out = { files: 0, violations: [] };
|
|
193
|
+
const walk = (p) => {
|
|
194
|
+
const st = statSync(p);
|
|
195
|
+
if (st.isDirectory()) {
|
|
196
|
+
for (const name of readdirSync(p)) {
|
|
197
|
+
if (SKIP_DIR.has(name) || VENDORED_DIRS.has(name)) continue;
|
|
198
|
+
walk(join(p, name));
|
|
199
|
+
}
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const base = p.split("/").pop();
|
|
203
|
+
if (!isClientFile(base) || st.size > MAX_BYTES) return;
|
|
204
|
+
out.files++;
|
|
205
|
+
for (const v of lintClientSource(readFileSync(p, "utf8"))) out.violations.push({ file: p, ...v });
|
|
206
|
+
};
|
|
207
|
+
walk(resolve(dir));
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
const VENDORED_DIRS = new Set(["vendor", "monaco", "dist", "build", "assets", "static", "bundles", "chunks"]);
|
|
211
|
+
|
|
212
|
+
// ── CLI ─────────────────────────────────────────────────────────────────────
|
|
213
|
+
function selftest() {
|
|
214
|
+
const MUST_FAIL = [
|
|
215
|
+
"const [st, setSt] = useState<any>(null);",
|
|
216
|
+
"const r = useRef<number>(null);",
|
|
217
|
+
"const x = v as any;",
|
|
218
|
+
"function f(a: unknown) {}",
|
|
219
|
+
'import type { Foo } from "./x";',
|
|
220
|
+
"interface Foo { a: string }",
|
|
221
|
+
"enum Color { Red }",
|
|
222
|
+
"const y = v as const;",
|
|
223
|
+
];
|
|
224
|
+
const MUST_WARN = [
|
|
225
|
+
"const opts = { mode: string };",
|
|
226
|
+
"x satisfies Y;",
|
|
227
|
+
"const m: Record<string, any> = {};",
|
|
228
|
+
];
|
|
229
|
+
const MUST_PASS = [
|
|
230
|
+
'const REACT_TYPE_STUB = `\ndeclare module "react" {\n export function useState<T>(init: T): [T];\n export const Fragment: any;\n}\n`;',
|
|
231
|
+
"/** @return {{chars1: string, chars2: string}} */",
|
|
232
|
+
"const [s] = useState(null);",
|
|
233
|
+
"useState<any>(null); // check-client:allow",
|
|
234
|
+
"// check-client:allow\nuseState<never>(0);",
|
|
235
|
+
"const cmp = a < b && c > (d);",
|
|
236
|
+
"if (a // 注释里 as any 不算\n ) run();",
|
|
237
|
+
];
|
|
238
|
+
let bad = 0;
|
|
239
|
+
const check = (src, expect, desc) => {
|
|
240
|
+
const v = lintClientSource(src);
|
|
241
|
+
const fails = v.filter((x) => x.level === "FAIL");
|
|
242
|
+
const ok = expect === "FAIL" ? fails.length > 0 : expect === "PASS" ? v.length === 0 : v.length > 0 && fails.length === 0;
|
|
243
|
+
if (!ok) {
|
|
244
|
+
bad++;
|
|
245
|
+
console.log(`SELFTEST FAIL [${desc}] 期望 ${expect},实际 ${JSON.stringify(v)}`);
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
MUST_FAIL.forEach((s) => check(s, "FAIL", s.slice(0, 40)));
|
|
249
|
+
MUST_WARN.forEach((s) => check(s, "WARN", s.slice(0, 40)));
|
|
250
|
+
MUST_PASS.forEach((s) => check(s, "PASS", s.replace(/\n/g, "⏎").slice(0, 40)));
|
|
251
|
+
if (bad) {
|
|
252
|
+
console.log(`selftest: ${bad} 处未过`);
|
|
253
|
+
process.exit(1);
|
|
254
|
+
}
|
|
255
|
+
console.log("selftest: 全部通过(8 FAIL / 3 WARN / 7 PASS)");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
|
|
259
|
+
if (process.argv.includes("--selftest")) {
|
|
260
|
+
selftest();
|
|
261
|
+
} else if (process.argv.length > 2) {
|
|
262
|
+
const all = { files: 0, violations: [] };
|
|
263
|
+
for (const p of process.argv.slice(2)) lintPath(resolve(p), all);
|
|
264
|
+
const fails = all.violations.filter((v) => v.level === "FAIL");
|
|
265
|
+
const warns = all.violations.filter((v) => v.level === "warn");
|
|
266
|
+
if (all.violations.length) console.log(formatViolations(all.violations));
|
|
267
|
+
console.log(`\n${all.files} 个文件:FAIL=${fails.length} warn=${warns.length}`);
|
|
268
|
+
process.exit(fails.length ? 1 : 0);
|
|
269
|
+
} else {
|
|
270
|
+
console.log("用法:node check-client.mjs <file|dir>... | --selftest");
|
|
271
|
+
process.exit(2);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Draft Keeper(aiti-draft-keeper)client 薄壳。
|
|
3
|
+
*
|
|
4
|
+
* 职责只剩装配:window/localStorage 适配、hydration 门控、ctx 接线、
|
|
5
|
+
* DraftApi 注册。全部草稿语义在 ./core.mjs 的状态机(唯一所有者)——
|
|
6
|
+
* core(pi-web)只发意图(applyIntent)或经 enhancement 回调喂快照,
|
|
7
|
+
* 不再直接 clearDraft/rekeyDraft(竞态源头已收敛进状态机)。
|
|
8
|
+
*
|
|
9
|
+
* 兼容:DraftApi 保留 v1 方法(saveDraft/clearDraft/rekeyDraft/restoreSubmission,
|
|
10
|
+
* 标记 deprecated)——部署顺序无关:旧 core + 新 keeper 仍可工作;新 core 的
|
|
11
|
+
* applyDraftIntent 检测 applyIntent 存在即走新路径。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createKeeperCore } from "./core.mjs";
|
|
15
|
+
|
|
16
|
+
const PREFIX = "crashrestore-";
|
|
17
|
+
|
|
18
|
+
function dbg(msg) {
|
|
19
|
+
try {
|
|
20
|
+
if (localStorage.getItem("__keeperDebugOn") !== "1") return;
|
|
21
|
+
const logArr = JSON.parse(localStorage.getItem("__keeperDebug") || "[]");
|
|
22
|
+
logArr.push({ at: Math.round(performance.now()), msg: String(msg).slice(0, 140) });
|
|
23
|
+
localStorage.setItem("__keeperDebug", JSON.stringify(logArr.slice(-40)));
|
|
24
|
+
} catch { /* ignore */ }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 等宿主水合完成(pi-web AppShell 挂载后置 __piWebHydrated + 派发事件)。
|
|
28
|
+
* 水合前恢复进框会被 hydration 重渲染按服务端 HTML(空框)抹掉。8s 兜底放行。 */
|
|
29
|
+
function whenHydrated(cb) {
|
|
30
|
+
if (typeof window === "undefined") { cb(); return; }
|
|
31
|
+
if (window.__piWebHydrated) { cb(); return; }
|
|
32
|
+
const h = () => { window.removeEventListener("pi-web:hydrated", h); cb(); };
|
|
33
|
+
window.addEventListener("pi-web:hydrated", h);
|
|
34
|
+
setTimeout(() => { window.removeEventListener("pi-web:hydrated", h); cb(); }, 8000);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export default function register(ctx) {
|
|
38
|
+
// 契约防御:宿主 core 缺少 composer 契约(未同步的旧 core)→ 不启用,避免崩溃
|
|
39
|
+
if (typeof ctx.registerComposerEnhancement !== "function") return;
|
|
40
|
+
// 接管语义:hydration 失配后扩展客户端会重新执行 → 旧实例状态过期。新实例先
|
|
41
|
+
// 解散旧实例,以全新状态接管;ChatInput 的增强器集合变化补发会让新实例追上。
|
|
42
|
+
if (typeof window !== "undefined" && window.__draftKeeperDisposer) {
|
|
43
|
+
try { window.__draftKeeperDisposer(); } catch { /* ignore */ }
|
|
44
|
+
window.__draftKeeperDisposer = null;
|
|
45
|
+
}
|
|
46
|
+
return setup(ctx);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function setup(ctx) {
|
|
50
|
+
const storage = {
|
|
51
|
+
getItem: (k) => localStorage.getItem(k),
|
|
52
|
+
setItem: (k, v) => localStorage.setItem(k, v),
|
|
53
|
+
removeItem: (k) => localStorage.removeItem(k),
|
|
54
|
+
keys: () => {
|
|
55
|
+
const out = [];
|
|
56
|
+
for (let i = localStorage.length - 1; i >= 0; i--) out.push(localStorage.key(i));
|
|
57
|
+
return out;
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// 写框(hydration 门控内):优先受控恢复(含图片),退化为纯文本写值
|
|
62
|
+
let hydrated = typeof window !== "undefined" && !!window.__piWebHydrated;
|
|
63
|
+
const writeBox = (draft) => {
|
|
64
|
+
let ok = false;
|
|
65
|
+
if (typeof ctx.restoreComposerDraft === "function") {
|
|
66
|
+
ok = ctx.restoreComposerDraft({ value: draft.value, images: draft.images }) === true;
|
|
67
|
+
} else if (typeof ctx.writeComposerValue === "function") {
|
|
68
|
+
ok = ctx.writeComposerValue(draft.value) === true;
|
|
69
|
+
}
|
|
70
|
+
return ok;
|
|
71
|
+
};
|
|
72
|
+
const onRestore = (draft) => {
|
|
73
|
+
if (hydrated) return writeBox(draft);
|
|
74
|
+
whenHydrated(() => { hydrated = true; writeBox(draft); });
|
|
75
|
+
return true; // 已受理(挂起等水合),staged 补偿由状态机经 onChange 快照负责
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const core = createKeeperCore({ storage, onRestore, log: dbg });
|
|
79
|
+
core.pruneStale(); // 启动时一次性清理过期残留(非轮询)
|
|
80
|
+
|
|
81
|
+
// 页面关闭/隐藏前强制落盘未 debounce(300ms 内)的草稿
|
|
82
|
+
const flush = () => core.flushAll();
|
|
83
|
+
const onVisibility = () => { if (document.visibilityState === "hidden") flush(); };
|
|
84
|
+
if (typeof window !== "undefined") {
|
|
85
|
+
window.addEventListener("beforeunload", flush);
|
|
86
|
+
window.addEventListener("pagehide", flush);
|
|
87
|
+
document.addEventListener("visibilitychange", onVisibility);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const dispose = () => {
|
|
91
|
+
core.dispose();
|
|
92
|
+
if (typeof window !== "undefined") {
|
|
93
|
+
window.removeEventListener("beforeunload", flush);
|
|
94
|
+
window.removeEventListener("pagehide", flush);
|
|
95
|
+
document.removeEventListener("visibilitychange", onVisibility);
|
|
96
|
+
}
|
|
97
|
+
if (typeof ctx.registerDraftApi === "function") ctx.registerDraftApi(null);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const offEnh = ctx.registerComposerEnhancement({
|
|
101
|
+
id: "aiti-draft-keeper",
|
|
102
|
+
onChange: (snap) => {
|
|
103
|
+
try { core.onChange(snap); } catch (e) { console.error("[draft-keeper] onChange error:", e); }
|
|
104
|
+
},
|
|
105
|
+
onKeyChange: (prev, next) => {
|
|
106
|
+
try { core.onKeyChange(prev, next); } catch (e) { console.error("[draft-keeper] onKeyChange error:", e); }
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// 草稿存储 API:core 经 getDraftApi() 访问。applyIntent 是唯一写入口;
|
|
111
|
+
// v1 方法 deprecated(部署顺序兼容,仅未升级的旧 core 会调)。
|
|
112
|
+
if (typeof ctx.registerDraftApi === "function") {
|
|
113
|
+
ctx.registerDraftApi({
|
|
114
|
+
getDraft: (key) => core.getDraft(key),
|
|
115
|
+
clearAllDrafts: (sid) => core.clearAllDrafts(sid),
|
|
116
|
+
flushAll: () => core.flushAll(),
|
|
117
|
+
applyIntent: (intent) => core.applyIntent(intent),
|
|
118
|
+
// ---- deprecated v1 ----
|
|
119
|
+
saveDraft: (key, draft) => core.saveDraft(key, draft),
|
|
120
|
+
clearDraft: (key) => core.clearDraft(key),
|
|
121
|
+
rekeyDraft: (prev, next, currentDraft) => core.rekeyDraft(prev, next, currentDraft),
|
|
122
|
+
restoreSubmission: (key, text, images) => core.restoreSubmission(key, text, images),
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (typeof window !== "undefined") window.__draftKeeperDisposer = dispose;
|
|
127
|
+
return dispose;
|
|
128
|
+
}
|