llm-api-gateway-cli 1.0.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/.env.example +10 -0
- package/README.md +1127 -0
- package/cli-agent.js +666 -0
- package/cli-anthropic.js +236 -0
- package/cli-claude-code.js +317 -0
- package/cli-openai.js +212 -0
- package/completions/_llm-api-gateway-cli +65 -0
- package/completions/llm-api-gateway-cli.bash +64 -0
- package/completions/llm-api-gateway-cli.fish +43 -0
- package/images/chat.png +0 -0
- package/images/settings.png +0 -0
- package/images/task.png +0 -0
- package/lib/agent.js +607 -0
- package/lib/commands.js +468 -0
- package/lib/common.js +196 -0
- package/lib/config.js +70 -0
- package/lib/configcmd.js +230 -0
- package/lib/hub.js +1494 -0
- package/lib/jsonstore.js +49 -0
- package/lib/mcp.js +375 -0
- package/lib/memory.js +109 -0
- package/lib/plandoc.js +178 -0
- package/lib/pricing.js +52 -0
- package/lib/runner.js +234 -0
- package/lib/runstore.js +96 -0
- package/lib/secrets.js +198 -0
- package/lib/sessionstore.js +269 -0
- package/lib/settings.js +517 -0
- package/lib/tasksession.js +594 -0
- package/lib/taskstore.js +740 -0
- package/lib/tools.js +927 -0
- package/package.json +55 -0
- package/public/app.js +1055 -0
- package/public/index.html +167 -0
- package/public/manual.css +215 -0
- package/public/manual.html +381 -0
- package/public/manual.js +186 -0
- package/public/models.js +121 -0
- package/public/render.js +250 -0
- package/public/styles.css +955 -0
- package/public/task-slash.js +493 -0
- package/public/task.css +739 -0
- package/public/task.html +220 -0
- package/public/task.js +3127 -0
- package/public/theme.js +91 -0
- package/public/tint.js +261 -0
- package/scripts/install.ps1 +537 -0
- package/scripts/install.sh +510 -0
- package/server.js +14 -0
- package/task-server.js +15 -0
package/lib/secrets.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 密钥的唯一实现:`<数据根目录>/credentials.json`
|
|
3
|
+
*
|
|
4
|
+
* 为什么单开一个文件而不是塞进 config.json:
|
|
5
|
+
* config.json 是要被同步、被备份、被 `cat`、被贴进 issue 的东西;密钥进去等于迟早泄露。
|
|
6
|
+
* 单独一个文件 + 仅属主可读,既能做到「装完在界面里配一次就长期可用」(不用再手写 .env),
|
|
7
|
+
* 又把爆炸半径限制在「本机上能读这个文件的账户」。这条口径与 20260916 计划里写下的
|
|
8
|
+
* 「若日后确实要存,必须单文件、仅属主可读、并在启动时明确告知」一致。
|
|
9
|
+
*
|
|
10
|
+
* 优先级(自高到低):`--key` > 环境变量 SK / GATEWAY_KEY / … > `.env` > 本文件。
|
|
11
|
+
* 也就是说:**环境里给了密钥时,这个文件不参与**(resolveKey 已经把它们都收进去了,
|
|
12
|
+
* 本模块只在「前三者都没有」时兜底)。
|
|
13
|
+
*
|
|
14
|
+
* 平台差异:POSIX 上写入后 chmod 0600,并在启动时检查有没有「同机其他用户可读」;
|
|
15
|
+
* Windows 上 chmod 只影响只读位、管不了 ACL —— 那里靠用户目录本身的 ACL
|
|
16
|
+
* (`C:\Users\<你>` 默认只有本人与管理员可读)。这一点在 README 与手册里如实写出,不假装做了权限控制。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { existsSync, readFileSync, chmodSync, statSync, unlinkSync, mkdirSync } from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import os from 'node:os';
|
|
22
|
+
|
|
23
|
+
import { writeJsonAtomic } from './jsonstore.js';
|
|
24
|
+
import { defaultStoreRoot } from './taskstore.js';
|
|
25
|
+
import { resolveKey, strArg, maskKey } from './common.js';
|
|
26
|
+
|
|
27
|
+
/** 想单独把密钥文件放别处时用它(整体换数据根用 LLM_GATEWAY_DATA_DIR) */
|
|
28
|
+
export const SECRET_ENV = 'LLM_GATEWAY_SECRET_FILE';
|
|
29
|
+
export const SECRET_FILE_NAME = 'credentials.json';
|
|
30
|
+
/** 只接受网关那种 sk- 形状的密钥:写错一个字符在这里拦住,比在页面上猜「为什么 401」强 */
|
|
31
|
+
export const KEY_SHAPE = /^sk-\S{8,}$/;
|
|
32
|
+
|
|
33
|
+
/** 密钥文件路径:默认跟任务/配置同一个数据根 */
|
|
34
|
+
export function secretFilePath(env = process.env, home = os.homedir()) {
|
|
35
|
+
const custom = strArg(env?.[SECRET_ENV]);
|
|
36
|
+
if (custom) return path.resolve(custom.replace(/^~(?=[\\/]|$)/, home));
|
|
37
|
+
return path.join(defaultStoreRoot(env, home), SECRET_FILE_NAME);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 读密钥。
|
|
42
|
+
* 文件不存在 / 内容坏了都**不抛**:前者是「还没配」,后者要让页面与命令行把话说清楚,
|
|
43
|
+
* 而不是让服务起不来 —— 启动不起来的话用户连界面都进不去,只能去手改文件。
|
|
44
|
+
*
|
|
45
|
+
* @returns {{key:string, exists:boolean, file:string, warning:string}}
|
|
46
|
+
*/
|
|
47
|
+
export function readSecret({ file = secretFilePath(), env = process.env } = {}) {
|
|
48
|
+
if (!existsSync(file)) return { key: '', exists: false, file, warning: '' };
|
|
49
|
+
let raw;
|
|
50
|
+
try {
|
|
51
|
+
raw = JSON.parse(readFileSync(file, 'utf8'));
|
|
52
|
+
} catch (e) {
|
|
53
|
+
return {
|
|
54
|
+
key: '',
|
|
55
|
+
exists: true,
|
|
56
|
+
file,
|
|
57
|
+
warning: `密钥文件读不了(${e.message}):请修好或删掉 ${file},然后在设置里重新填一次密钥。`,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
const key = strArg(raw?.key);
|
|
61
|
+
if (!key) {
|
|
62
|
+
return { key: '', exists: true, file, warning: `密钥文件里没有 key 字段:${file}(可以在设置里重新填一次)。` };
|
|
63
|
+
}
|
|
64
|
+
return { key, exists: true, file, warning: '' };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 只要密钥本身(调用方不关心告警时用) */
|
|
68
|
+
export function readSecretKey(opts = {}) {
|
|
69
|
+
return readSecret(opts).key;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 写入密钥:校验形状 → 原子写(tmp → rename)→ 收权限。
|
|
74
|
+
* @returns {{file:string, mode:number|null, warning:string}}
|
|
75
|
+
*/
|
|
76
|
+
export function writeSecret(key, { file = secretFilePath() } = {}) {
|
|
77
|
+
const value = strArg(key);
|
|
78
|
+
if (!value) throw new Error('密钥不能为空');
|
|
79
|
+
if (!KEY_SHAPE.test(value)) {
|
|
80
|
+
throw new Error(`密钥形状不对:需要以 sk- 开头且长度足够(收到 ${value.length} 个字符,前缀 ${value.slice(0, 3)})`);
|
|
81
|
+
}
|
|
82
|
+
// 数据根目录可能还不存在(全新安装、或 LLM_GATEWAY_DATA_DIR 指到新位置):
|
|
83
|
+
// writeJsonAtomic 只写文件不建目录,缺目录会直接 ENOENT,用户看到的就是「保存密钥失败」
|
|
84
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
85
|
+
writeJsonAtomic(file, { version: 1, key: value }, { pretty: true });
|
|
86
|
+
return { file, ...restrictPermissions(file) };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 收紧权限。Windows 上 chmod 只切只读位、不设 ACL,所以如实返回 mode=null 并由调用方说明。
|
|
91
|
+
* @returns {{mode:number|null, warning:string}}
|
|
92
|
+
*/
|
|
93
|
+
export function restrictPermissions(file) {
|
|
94
|
+
if (process.platform === 'win32') return { mode: null, warning: '' };
|
|
95
|
+
try {
|
|
96
|
+
chmodSync(file, 0o600);
|
|
97
|
+
return { mode: 0o600, warning: '' };
|
|
98
|
+
} catch (e) {
|
|
99
|
+
return { mode: null, warning: `无法把 ${file} 的权限收到 600(${e.message}):请手工 chmod 600,或改用环境变量提供密钥。` };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 检查现有文件的权限位。
|
|
105
|
+
* @returns {{mode:number|null, groupOrWorldReadable:boolean, warning:string}}
|
|
106
|
+
*/
|
|
107
|
+
export function checkPermissions(file) {
|
|
108
|
+
if (process.platform === 'win32' || !existsSync(file)) {
|
|
109
|
+
return { mode: null, groupOrWorldReadable: false, warning: '' };
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
const mode = statSync(file).mode & 0o777;
|
|
113
|
+
const loose = (mode & 0o077) !== 0;
|
|
114
|
+
return {
|
|
115
|
+
mode,
|
|
116
|
+
groupOrWorldReadable: loose,
|
|
117
|
+
warning: loose
|
|
118
|
+
? `${file} 对同机其他用户可读(权限 ${mode.toString(8)}):建议 chmod 600,或改用环境变量提供密钥。`
|
|
119
|
+
: '',
|
|
120
|
+
};
|
|
121
|
+
} catch (e) {
|
|
122
|
+
return { mode: null, groupOrWorldReadable: false, warning: `读不到 ${file} 的权限位:${e.message}` };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** 删除密钥文件(`config unset key`)。文件本来就不在也算成功 */
|
|
127
|
+
export function clearSecret({ file = secretFilePath() } = {}) {
|
|
128
|
+
const existed = existsSync(file);
|
|
129
|
+
if (existed) unlinkSync(file);
|
|
130
|
+
return { file, removed: existed };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 运行时密钥解析:把 flag / env / `.env` 与密钥文件合成一个答案,并说清「这一次的密钥是谁给的」。
|
|
135
|
+
*
|
|
136
|
+
* `resolveKey`(lib/common.js)已经把 `--key`、环境变量、`.env` 一起认了 —— 它是同一个进程里
|
|
137
|
+
* 唯一的「非文件来源」。所以这里用「resolveKey 有值 = 文件不生效」来表达优先级,
|
|
138
|
+
* 不去重复实现一遍 .env 解析。
|
|
139
|
+
*
|
|
140
|
+
* @returns {{key:string, source:'flag|env|file'|'', file:string, warning:string}}
|
|
141
|
+
*/
|
|
142
|
+
export function resolveSecretKey({ args = null, env = process.env, file = secretFilePath(env) } = {}) {
|
|
143
|
+
const external = resolveKey(args, env);
|
|
144
|
+
if (external) {
|
|
145
|
+
// 区分 --key 与其它:args.key 存在就是命令行显式给的
|
|
146
|
+
return { key: external, source: strArg(args?.key) ? 'flag' : 'env', file, warning: '' };
|
|
147
|
+
}
|
|
148
|
+
const read = readSecret({ file, env });
|
|
149
|
+
return { key: read.key, source: read.key ? 'file' : '', file, warning: read.warning };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export const SECRET_SOURCE_LABELS = {
|
|
153
|
+
flag: '启动参数 --key',
|
|
154
|
+
env: '环境变量',
|
|
155
|
+
file: '密钥文件',
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
/** 人话来源:给启动横幅与 config list 用(`.env` 走的是 env 层,见 resolveSecretKey 注释) */
|
|
159
|
+
export function secretSourceText(source) {
|
|
160
|
+
return SECRET_SOURCE_LABELS[source] || '未配置';
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 配置行:`/api/settings` 的 `rows` 与 `config list` **共用这一行**,所以页面与命令行看到的是同一份。
|
|
165
|
+
*
|
|
166
|
+
* 形状与 `lib/settings.js` 的 `listSettings()` 对齐(key/value/source/def/env/flag/desc/restart/type/…),
|
|
167
|
+
* 只是多带 `secret: true`、`hasKey`、`mask`、`file` 四个字段:
|
|
168
|
+
* · `value` 永远是空串 —— 密钥**不回显**,页面拿 `mask` 显示「已设置 sk-ab****yz」;
|
|
169
|
+
* · 它不在 `SETTINGS_SCHEMA` 里,因此不会被写进 config.json,也不会出现在补全脚本的键清单里。
|
|
170
|
+
*/
|
|
171
|
+
export function keyRow({ env = process.env, file = secretFilePath(env), args = null } = {}) {
|
|
172
|
+
const resolved = resolveSecretKey({ args, env, file });
|
|
173
|
+
const perms = checkPermissions(file);
|
|
174
|
+
const fromFile = resolved.source === 'file';
|
|
175
|
+
return {
|
|
176
|
+
key: 'key',
|
|
177
|
+
value: '',
|
|
178
|
+
source: resolved.source || 'default',
|
|
179
|
+
def: '',
|
|
180
|
+
env: 'SK / GATEWAY_KEY',
|
|
181
|
+
flag: '--key',
|
|
182
|
+
desc: '网关密钥(存 credentials.json,仅本用户可读;写进 config.json 是不允许的)',
|
|
183
|
+
restart: false,
|
|
184
|
+
type: 'secret',
|
|
185
|
+
values: null,
|
|
186
|
+
min: null,
|
|
187
|
+
max: null,
|
|
188
|
+
secret: true,
|
|
189
|
+
hasKey: Boolean(resolved.key),
|
|
190
|
+
mask: maskKey(resolved.key),
|
|
191
|
+
file,
|
|
192
|
+
fileMode: perms.mode,
|
|
193
|
+
// 来源是人话,页面直接显示;命令行同源
|
|
194
|
+
sourceText: secretSourceText(resolved.source),
|
|
195
|
+
warning: resolved.warning || perms.warning || '',
|
|
196
|
+
fromFile,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话落盘(M2)
|
|
3
|
+
*
|
|
4
|
+
* 在此之前 createSession 只活在一个进程里:CLI 一退,上下文就没了,
|
|
5
|
+
* 想问「接着刚才那个任务继续」只能把话重新说一遍。
|
|
6
|
+
* 这里把会话(消息、用量、待批准态)落到磁盘,支撑 cli-agent 的 --continue / --resume。
|
|
7
|
+
*
|
|
8
|
+
* 目录布局:<dir>/<sessionId>.json
|
|
9
|
+
* 目录选择复刻 taskstore 的策略(主目录下的 .llm-api-gateway-cli/sessions → 旧位置 → 临时目录),
|
|
10
|
+
* 这样「任务」与「会话」落在同一个应用数据根下,用户要清理就一起清理。
|
|
11
|
+
*
|
|
12
|
+
* 关键词:isSafeId(id 必须是 UUID 形状,防目录穿越)、writeJsonAtomic(防半截文件)、
|
|
13
|
+
* 体积上限(会话含完整工具输出,不设限迟早把磁盘写满)。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import os from 'node:os';
|
|
18
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, readdirSync, statSync } from 'node:fs';
|
|
19
|
+
import { isSafeId, defaultStoreRoot, isWritable, migrateLegacyData, legacyLocations } from './taskstore.js';
|
|
20
|
+
import { writeJsonAtomic } from './jsonstore.js';
|
|
21
|
+
|
|
22
|
+
export const SESSION_TTL_DAYS = 15;
|
|
23
|
+
export const SESSION_TTL_MS = SESSION_TTL_DAYS * 24 * 60 * 60 * 1000; // 滑动 15 天,与任务存储一致
|
|
24
|
+
export const MAX_SESSIONS = 50;
|
|
25
|
+
export const MAX_SESSION_MESSAGES = 400; // 超出就丢最老的非 system 消息
|
|
26
|
+
export const MAX_SESSION_BYTES = 1024 * 1024; // 单文件 1MB 上限
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 挑一个能写的会话目录。与 taskstore.resolveStoreDir 同一套候选顺序:
|
|
30
|
+
* 1. 用户显式指定的 --session-dir(指定了必须能写,否则报错,不偷偷换地方)
|
|
31
|
+
* 2. 用户主目录下 `.llm-api-gateway-cli/sessions`(新家;顺手把旧位置的会话搬过来)
|
|
32
|
+
* 3. 旧位置(平台数据目录 / 脚本目录 `.sessions`)—— 主目录不可写时的保命通道
|
|
33
|
+
* 4. 系统临时目录
|
|
34
|
+
*/
|
|
35
|
+
export function resolveSessionDir(explicit, scriptDir, env = process.env, home = os.homedir()) {
|
|
36
|
+
if (explicit) {
|
|
37
|
+
const dir = path.resolve(explicit);
|
|
38
|
+
if (!isWritable(dir)) throw new Error(`指定的会话目录不可写:${dir}`);
|
|
39
|
+
return { dir, source: 'explicit', warning: null, notice: null };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const dataRoot = defaultStoreRoot(env, home);
|
|
43
|
+
const preferred = path.join(dataRoot, 'sessions');
|
|
44
|
+
if (isWritable(preferred)) {
|
|
45
|
+
// 模式六可能只用会话、不碰任务存储,所以搬家在这里也要触发一次(不覆盖,幂等)
|
|
46
|
+
const moved = migrateLegacyData(dataRoot, { scriptDir, env, home });
|
|
47
|
+
const notice = moved.length
|
|
48
|
+
? `已把旧位置的数据搬到 ${dataRoot}:${moved.map((m) => `${m.from}(${m.files} 个文件)`).join('、')};确认无误后旧目录可以自己删掉。`
|
|
49
|
+
: null;
|
|
50
|
+
return { dir: preferred, source: 'user-data', warning: null, notice };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 主目录不可写:旧位置里有会话就继续用那儿,别让用户以为会话丢了
|
|
54
|
+
for (const loc of legacyLocations(scriptDir, dataRoot, env, home)) {
|
|
55
|
+
const candidate = loc.kind === 'sessions' ? loc.from : path.join(loc.from, 'sessions');
|
|
56
|
+
if (!isWritable(candidate)) continue;
|
|
57
|
+
let has = false;
|
|
58
|
+
try {
|
|
59
|
+
has = readdirSync(candidate).some((f) => f.endsWith('.json'));
|
|
60
|
+
} catch {
|
|
61
|
+
has = false;
|
|
62
|
+
}
|
|
63
|
+
if (!has) continue;
|
|
64
|
+
return {
|
|
65
|
+
dir: candidate,
|
|
66
|
+
source: 'legacy',
|
|
67
|
+
warning: `用户主目录下的会话目录不可写(${preferred}),继续使用旧位置:${candidate}。想固定下来请用 --session-dir 指定。`,
|
|
68
|
+
notice: null,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const tmp = path.join(os.tmpdir(), 'llm-api-gateway-cli-sessions');
|
|
73
|
+
if (isWritable(tmp)) {
|
|
74
|
+
return {
|
|
75
|
+
dir: tmp,
|
|
76
|
+
source: 'temp',
|
|
77
|
+
warning: `主目录与旧位置都不可写,会话已改用临时目录(重启后可能被清理):${tmp}`,
|
|
78
|
+
notice: null,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
throw new Error(`找不到可写的会话目录,试过:${preferred} / ${tmp}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const str = (v, max) => (typeof v === 'string' && v ? v.slice(0, max) : undefined);
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 落盘前的清洗:只留认识的字段,按体积上限裁掉最老的消息。
|
|
89
|
+
* 返回 { record, dropped } —— dropped 是本次为压体积丢掉的用户/助手消息条数。
|
|
90
|
+
*/
|
|
91
|
+
export function sanitizeSession(session, { maxMessages = MAX_SESSION_MESSAGES, maxBytes = MAX_SESSION_BYTES } = {}) {
|
|
92
|
+
if (!session || typeof session !== 'object' || !isSafeId(session.id)) return null;
|
|
93
|
+
const messages = Array.isArray(session.messages) ? session.messages.filter((m) => m && typeof m === 'object') : [];
|
|
94
|
+
// 消息也可能大到离谱(工具结果里全是文件正文),逐条截断
|
|
95
|
+
const trimmed = messages.slice(-maxMessages).map((m) => {
|
|
96
|
+
const copy = { ...m };
|
|
97
|
+
if (typeof copy.content === 'string' && copy.content.length > 200000) {
|
|
98
|
+
copy.content = `${copy.content.slice(0, 200000)}\n…(落盘时截断)`;
|
|
99
|
+
}
|
|
100
|
+
return copy;
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const usage = {
|
|
104
|
+
prompt: Number(session.usage?.prompt) || 0,
|
|
105
|
+
completion: Number(session.usage?.completion) || 0,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const record = {
|
|
109
|
+
version: 1,
|
|
110
|
+
id: session.id,
|
|
111
|
+
workingDir: str(session.workingDir, 4096) || '',
|
|
112
|
+
model: str(session.model, 200) || '',
|
|
113
|
+
mode: str(session.mode, 20) || 'manual',
|
|
114
|
+
temperature: Number.isFinite(session.temperature) ? session.temperature : undefined,
|
|
115
|
+
maxTokens: Number.isFinite(session.maxTokens) ? session.maxTokens : undefined,
|
|
116
|
+
messages: trimmed,
|
|
117
|
+
usage,
|
|
118
|
+
pending: session.pending && typeof session.pending === 'object' ? session.pending : null,
|
|
119
|
+
pendingPreview: session.pendingPreview && typeof session.pendingPreview === 'object' ? session.pendingPreview : null,
|
|
120
|
+
touchedAt: Number(session.touchedAt) || Date.now(),
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// 体积兜底:先丢正文最长的工具结果,再丢最老的非 system 消息,直到低于上限
|
|
124
|
+
let dropped = 0;
|
|
125
|
+
const size = () => Buffer.byteLength(JSON.stringify(record));
|
|
126
|
+
while (size() > maxBytes && record.messages.length > 1) {
|
|
127
|
+
// 保留 system(messages[0]),从第 1 条开始丢
|
|
128
|
+
const idx = record.messages.length > 1 ? 1 : 0;
|
|
129
|
+
record.messages.splice(idx, 1);
|
|
130
|
+
dropped++;
|
|
131
|
+
if (record.messages.length <= 1) break;
|
|
132
|
+
}
|
|
133
|
+
return { record, dropped };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 通用「消息存储」:文件布局 `<dir>/<id>.json`,负责 UUID 校验、原子写、TTL 清理、
|
|
138
|
+
* 「最多留 N 条」的裁剪。CLI 会话(`--continue` / `--resume`)与任务会话(模型态消息,
|
|
139
|
+
* 见 `lib/tasksession.js`)都建在它上面 ——
|
|
140
|
+
* 两边的**记录字段**不同,但**落盘机制**一模一样,各抄一份必然漂移。
|
|
141
|
+
*
|
|
142
|
+
* @param {object} opts
|
|
143
|
+
* @param {string} opts.dir 目录(不存在会建)
|
|
144
|
+
* @param {Function} opts.sanitize 记录清洗:返回 { record, dropped } 或 null(非法)
|
|
145
|
+
* @param {Function} opts.idOf 从清洗后的记录里取 id(CLI 会话取 id,任务会话取 taskId)
|
|
146
|
+
* @param {number=} opts.maxItems prune 时最多保留几条(0 = 不裁剪)
|
|
147
|
+
* @param {string=} opts.label 报错文案里的名字
|
|
148
|
+
*/
|
|
149
|
+
export function createMessageStore({ dir, sanitize, idOf, maxItems = 0, label = '会话' }) {
|
|
150
|
+
mkdirSync(dir, { recursive: true });
|
|
151
|
+
const file = (id) => path.join(dir, `${id}.json`);
|
|
152
|
+
|
|
153
|
+
const write = (session) => {
|
|
154
|
+
const clean = sanitize(session);
|
|
155
|
+
const id = clean ? idOf(clean.record) : null;
|
|
156
|
+
if (!clean || !isSafeId(id)) throw new Error(`${label} id 非法(必须是 UUID)`);
|
|
157
|
+
writeJsonAtomic(file(id), clean.record);
|
|
158
|
+
return { dropped: clean.dropped, bytes: statSync(file(id)).size };
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const read = (id) => {
|
|
162
|
+
if (!isSafeId(id)) return null;
|
|
163
|
+
const f = file(id);
|
|
164
|
+
if (!existsSync(f)) return null;
|
|
165
|
+
try {
|
|
166
|
+
const data = JSON.parse(readFileSync(f, 'utf8'));
|
|
167
|
+
return data && typeof data === 'object' && isSafeId(idOf(data) || id) ? data : null;
|
|
168
|
+
} catch {
|
|
169
|
+
// 损坏的记录读不回来就没有保留价值,清掉免得每次都报错
|
|
170
|
+
try {
|
|
171
|
+
unlinkSync(f);
|
|
172
|
+
} catch {
|
|
173
|
+
/* 忽略 */
|
|
174
|
+
}
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const drop = (id) => {
|
|
180
|
+
if (!isSafeId(id)) return false;
|
|
181
|
+
try {
|
|
182
|
+
unlinkSync(file(id));
|
|
183
|
+
return true;
|
|
184
|
+
} catch {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
/** 列出会话(新的在前);带 ttlMs 时顺带清理过期与杂物 */
|
|
190
|
+
const list = (ttlMs) => {
|
|
191
|
+
const out = [];
|
|
192
|
+
let names = [];
|
|
193
|
+
try {
|
|
194
|
+
names = readdirSync(dir);
|
|
195
|
+
} catch {
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
const now = Date.now();
|
|
199
|
+
for (const n of names) {
|
|
200
|
+
if (n.endsWith('.tmp')) {
|
|
201
|
+
try {
|
|
202
|
+
unlinkSync(path.join(dir, n));
|
|
203
|
+
} catch {
|
|
204
|
+
/* 忽略 */
|
|
205
|
+
}
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (!n.endsWith('.json')) continue;
|
|
209
|
+
const id = n.slice(0, -'.json'.length);
|
|
210
|
+
if (!isSafeId(id)) {
|
|
211
|
+
try {
|
|
212
|
+
unlinkSync(path.join(dir, n));
|
|
213
|
+
} catch {
|
|
214
|
+
/* 忽略 */
|
|
215
|
+
}
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
const rec = read(id);
|
|
219
|
+
if (!rec) continue;
|
|
220
|
+
if (ttlMs && now - (rec.touchedAt || 0) > ttlMs) {
|
|
221
|
+
drop(id);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
out.push(rec);
|
|
225
|
+
}
|
|
226
|
+
out.sort((a, b) => (b.touchedAt || 0) - (a.touchedAt || 0));
|
|
227
|
+
return out;
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
/** 最近一次(--continue 用) */
|
|
231
|
+
const latest = (ttlMs = SESSION_TTL_MS) => list(ttlMs)[0] || null;
|
|
232
|
+
|
|
233
|
+
/** 只保留最近的 maxItems 条,返回被清掉的 id */
|
|
234
|
+
const prune = () => {
|
|
235
|
+
if (!maxItems) return [];
|
|
236
|
+
const all = list();
|
|
237
|
+
const removed = all.slice(maxItems);
|
|
238
|
+
for (const r of removed) drop(idOf(r));
|
|
239
|
+
return removed.map((r) => idOf(r));
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
/** 条数与占用字节(/api/store 用它把「会话占了多大」告诉用户) */
|
|
243
|
+
const stats = () => {
|
|
244
|
+
let count = 0;
|
|
245
|
+
let bytes = 0;
|
|
246
|
+
for (const r of list()) {
|
|
247
|
+
count++;
|
|
248
|
+
try {
|
|
249
|
+
bytes += statSync(file(idOf(r))).size;
|
|
250
|
+
} catch {
|
|
251
|
+
/* 读不到大小就不计 */
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return { dir, count, bytes };
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
return { dir, write, read, drop, list, latest, prune, stats };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** CLI 会话存储(`--continue` / `--resume`):行为与抽公共实现之前完全一致 */
|
|
261
|
+
export function createSessionStore(dir) {
|
|
262
|
+
return createMessageStore({
|
|
263
|
+
dir,
|
|
264
|
+
sanitize: sanitizeSession,
|
|
265
|
+
idOf: (r) => r.id,
|
|
266
|
+
maxItems: MAX_SESSIONS,
|
|
267
|
+
label: '会话',
|
|
268
|
+
});
|
|
269
|
+
}
|