@migor/cli 0.9.1 → 0.9.3
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 +187 -5
- package/dist/AGENTS.md +15 -2
- package/dist/dev-child.js +84 -0
- package/dist/dev-logic.js +46 -0
- package/dist/dev-machine.js +489 -0
- package/dist/dev-runner.js +8 -1
- package/dist/dev-watch.js +223 -0
- package/dist/dev.js +377 -626
- package/dist/inspector-page.html +295 -17
- package/dist/inspector-routes.js +530 -0
- package/dist/inspector.js +44 -418
- package/dist/markdown.js +262 -0
- package/dist/native-pick.js +168 -0
- package/dist/panel-logic.js +64 -0
- package/dist/templates/package.json +2 -2
- package/dist/templates/src/app.ts +3 -0
- package/dist/templates/src/dev.config.ts +5 -0
- package/dist/templates/src/registry.ts +5 -0
- package/dist/templates/src/session-store.ts +11 -2
- package/dist/templates/src/tools/read-file/index.ts +50 -3
- package/package.json +1 -1
package/dist/markdown.js
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 面板的 **Markdown 最小渲染器** —— 纯逻辑、零 DOM、零依赖。
|
|
3
|
+
*
|
|
4
|
+
* 为什么自己写而不引 `marked` / `markdown-it`:
|
|
5
|
+
* - **零运行时依赖是本 CLI 的硬承诺**(`package.json` 里没有 `dependencies`)。为了面板把一份
|
|
6
|
+
* 解析器 + 一份消毒器(DOMPurify 之类)塞进 dist,等于在 dev 链路里引入两个第三方供应链面,
|
|
7
|
+
* 而它们的产物**没法被本仓的单测验证**(我们只能验「我写的那部分」)。
|
|
8
|
+
* - 面板只需要**模型正文**这一种输入可读:标题 / 粗体 / 斜体 / 行内码 / 围栏码 / 列表 /
|
|
9
|
+
* 引用 / 分隔线 / 链接。这套子集手写不到 200 行,且**每条规则都能配一条单测**。
|
|
10
|
+
* - 最要紧的是**它按构造不产生 HTML**:返回值是一棵 token 树(`MdBlock` / `MdInline`),
|
|
11
|
+
* 没有任何「原样透传的 HTML」这一类。页面的渲染器只准用 `createElement` + `textContent`
|
|
12
|
+
* 把它变成 DOM ⇒ **模型输出里的 `<script>` 只会是字面文本**,不存在「忘了转义」这条路径。
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ 本文件**不许碰 DOM**(不出现 `document` / `window`)—— 它要在 Node 里被直接 import 做单测。
|
|
15
|
+
*
|
|
16
|
+
* 支持的子集(刻意不做的都在「不做」里):
|
|
17
|
+
* - 块:`#` 标题(1–6 级)、围栏码块(``` / ~~~,带语言标注)、无序/有序列表(**一层**)、
|
|
18
|
+
* 引用(合并成一段正文)、`---` 分隔线、段落
|
|
19
|
+
* - 行内:行内码、`**粗**`、`*斜*`、`[文字](链接)`、`<https://…>` 自动链接
|
|
20
|
+
*
|
|
21
|
+
* 不做(都是刻意的,不是漏):
|
|
22
|
+
* - **不做 HTML 透传**(安全前提,见上)
|
|
23
|
+
* - 不做表格 / 任务列表 / 嵌套列表 / 脚注 / 数学 —— 面板是调试视图,不是文档站
|
|
24
|
+
* - 不做 setext 标题(`===` 下划线式)—— 与分隔线歧义大、收益小
|
|
25
|
+
* - 不做图片(`![]()`)—— 面板**不去外网取图**:一次 run 的正文不该触发网络请求
|
|
26
|
+
* - 不做**词内** `_` 强调(`read_file_tool` 原样显示)—— 与 CommonMark 同口径,见 `parseInline`
|
|
27
|
+
*/
|
|
28
|
+
/**
|
|
29
|
+
* 链接协议白名单。**这是安全边界,不是风格选择**:
|
|
30
|
+
* 模型输出里的 `[点我](javascript:alert(1))` 若被渲染成可点链接,面板就成了 XSS 的跳板
|
|
31
|
+
* (`href` 是唯一一处「我们主动写进属性」的值 —— 文本全走 `textContent` 是安全的,
|
|
32
|
+
* 属性不是)。不合法 ⇒ 这一条**整个降级成字面文本**(连 `[]()` 都照原样显示),
|
|
33
|
+
* 使用者一眼看得出「这里有个链接我没渲染」,而不是看到一个空链接或点不动的链接。
|
|
34
|
+
*/
|
|
35
|
+
export function isSafeHref(href) {
|
|
36
|
+
// 允许 http/https/mailto;**不允许** javascript: / data: / vbscript: / file: 等
|
|
37
|
+
return /^(https?:|mailto:)/i.test(href.trim());
|
|
38
|
+
}
|
|
39
|
+
const FENCE = /^\s{0,3}(`{3,}|~{3,})\s*([^\s`]*)/;
|
|
40
|
+
const HEADING = /^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$/;
|
|
41
|
+
const HR = /^\s{0,3}([-*_])(?:\s*\1){2,}\s*$/;
|
|
42
|
+
const QUOTE = /^\s{0,3}>\s?(.*)$/;
|
|
43
|
+
const UL = /^(\s*)([-*+])\s+(.*)$/;
|
|
44
|
+
const OL = /^(\s*)(\d{1,9})[.)]\s+(.*)$/;
|
|
45
|
+
const BLANK = /^\s*$/;
|
|
46
|
+
/** 「单词字符」:`_` 的词内排除用它判边界(与 CommonMark 的 intraword 规则同口径) */
|
|
47
|
+
const WORD_CHAR = /[A-Za-z0-9_]/;
|
|
48
|
+
/** 下标可能越界(`text[-1]` / `text[len]` 都是 `undefined`)⇒ 越界一律当「不是单词字符」 */
|
|
49
|
+
function isWordChar(ch) {
|
|
50
|
+
return ch !== undefined && WORD_CHAR.test(ch);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* 把一段 Markdown 文本解析成块序列。
|
|
54
|
+
*
|
|
55
|
+
* 三条与「真 Markdown」不同但**刻意**的口径:
|
|
56
|
+
* - 缩进 ≥2 空格的续行归上一条列表项(列表只解析**一层**:更深一层当正文,不递归建树);
|
|
57
|
+
* - 未闭合的围栏码块吃掉到文末(**不静默丢内容** —— 模型被截断时最常见的就是这个形态);
|
|
58
|
+
* - 引用(`>`)合并成**一段正文**(面板里引用几乎总是模型在引日志,逐行建块只会更碎)。
|
|
59
|
+
*/
|
|
60
|
+
export function parseMarkdown(text) {
|
|
61
|
+
const src = String(text ?? '').replace(/\r\n?/g, '\n');
|
|
62
|
+
const lines = src.split('\n');
|
|
63
|
+
const blocks = [];
|
|
64
|
+
let i = 0;
|
|
65
|
+
let para = [];
|
|
66
|
+
const flushPara = () => {
|
|
67
|
+
if (para.length === 0)
|
|
68
|
+
return;
|
|
69
|
+
const joined = para.join('\n').trim();
|
|
70
|
+
para = [];
|
|
71
|
+
if (joined)
|
|
72
|
+
blocks.push({ type: 'p', inline: parseInline(joined, 0) });
|
|
73
|
+
};
|
|
74
|
+
while (i < lines.length) {
|
|
75
|
+
const line = lines[i];
|
|
76
|
+
// —— 围栏码块 ——
|
|
77
|
+
const fence = FENCE.exec(line);
|
|
78
|
+
if (fence) {
|
|
79
|
+
flushPara();
|
|
80
|
+
const marker = fence[1][0];
|
|
81
|
+
const min = fence[1].length;
|
|
82
|
+
const lang = fence[2] ?? '';
|
|
83
|
+
const body = [];
|
|
84
|
+
i++;
|
|
85
|
+
while (i < lines.length) {
|
|
86
|
+
const cur = lines[i];
|
|
87
|
+
const close = new RegExp(`^\\s{0,3}${marker === '`' ? '`' : '~'}{${min},}\\s*$`);
|
|
88
|
+
if (close.test(cur)) {
|
|
89
|
+
i++;
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
body.push(cur);
|
|
93
|
+
i++;
|
|
94
|
+
}
|
|
95
|
+
blocks.push({ type: 'code', lang, text: body.join('\n') });
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
// —— 空行:段落到此为止 ——
|
|
99
|
+
if (BLANK.test(line)) {
|
|
100
|
+
flushPara();
|
|
101
|
+
i++;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
// —— 分隔线(必须在列表之前判:`---` 也是无序列表的标记) ——
|
|
105
|
+
if (HR.test(line)) {
|
|
106
|
+
flushPara();
|
|
107
|
+
blocks.push({ type: 'hr' });
|
|
108
|
+
i++;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
// —— 标题 ——
|
|
112
|
+
const h = HEADING.exec(line);
|
|
113
|
+
if (h) {
|
|
114
|
+
flushPara();
|
|
115
|
+
blocks.push({
|
|
116
|
+
type: 'h',
|
|
117
|
+
level: h[1].length,
|
|
118
|
+
inline: parseInline(h[2].trim(), 0),
|
|
119
|
+
});
|
|
120
|
+
i++;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
// —— 引用:连续 `>` 行合成一段 ——
|
|
124
|
+
if (QUOTE.test(line)) {
|
|
125
|
+
flushPara();
|
|
126
|
+
const quoted = [];
|
|
127
|
+
while (i < lines.length && QUOTE.test(lines[i])) {
|
|
128
|
+
quoted.push(QUOTE.exec(lines[i])[1]);
|
|
129
|
+
i++;
|
|
130
|
+
}
|
|
131
|
+
const joined = quoted.join('\n').trim();
|
|
132
|
+
blocks.push({ type: 'quote', inline: parseInline(joined, 0) });
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
// —— 列表(无序 / 有序,各收一段连续的) ——
|
|
136
|
+
const ul = UL.exec(line);
|
|
137
|
+
const ol = olAt(line);
|
|
138
|
+
if (ul || ol) {
|
|
139
|
+
flushPara();
|
|
140
|
+
const ordered = ol !== null;
|
|
141
|
+
const items = [];
|
|
142
|
+
while (i < lines.length) {
|
|
143
|
+
const cur = lines[i];
|
|
144
|
+
const m = ordered ? olAt(cur) : UL.exec(cur);
|
|
145
|
+
if (m) {
|
|
146
|
+
items.push(parseInline(m[3].trim(), 0));
|
|
147
|
+
i++;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
// 续行:缩进 ≥2 且不是新的一条 ⇒ 并进上一项
|
|
151
|
+
if (items.length > 0 && /^\s{2,}\S/.test(cur) && !BLANK.test(cur)) {
|
|
152
|
+
const last = items[items.length - 1];
|
|
153
|
+
last.push(...parseInline(cur.trim(), 0));
|
|
154
|
+
i++;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
blocks.push({ type: 'list', ordered, items });
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
// —— 其余:段落(连续非空行合并,行内换行保留) ——
|
|
163
|
+
para.push(line.trim());
|
|
164
|
+
i++;
|
|
165
|
+
}
|
|
166
|
+
flushPara();
|
|
167
|
+
return blocks;
|
|
168
|
+
}
|
|
169
|
+
/** 有序列表标记(`1.` / `1)`),返回与 UL 同形的三组:缩进 / 标记 / 正文 */
|
|
170
|
+
function olAt(line) {
|
|
171
|
+
const m = OL.exec(line);
|
|
172
|
+
return m ? m : null;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* 行内解析。`depth` 是**递归上限**:`**a *b* c**` 这类嵌套只展开到第 3 层,
|
|
176
|
+
* 再深就按字面留着 —— 递归无上限时,一条构造出来的正文就能把面板卡死。
|
|
177
|
+
*/
|
|
178
|
+
export function parseInline(text, depth) {
|
|
179
|
+
const out = [];
|
|
180
|
+
let buf = '';
|
|
181
|
+
let i = 0;
|
|
182
|
+
const pushText = () => {
|
|
183
|
+
if (buf)
|
|
184
|
+
out.push({ type: 'text', text: buf });
|
|
185
|
+
buf = '';
|
|
186
|
+
};
|
|
187
|
+
while (i < text.length) {
|
|
188
|
+
const c = text[i];
|
|
189
|
+
// 行内码:`...`(不跨行;两端各去掉一个空格 —— 与 CommonMark 同款)
|
|
190
|
+
if (c === '`') {
|
|
191
|
+
const end = text.indexOf('`', i + 1);
|
|
192
|
+
if (end > i + 1) {
|
|
193
|
+
pushText();
|
|
194
|
+
let inner = text.slice(i + 1, end);
|
|
195
|
+
if (inner.startsWith(' ') && inner.endsWith(' ') && inner.trim())
|
|
196
|
+
inner = inner.slice(1, -1);
|
|
197
|
+
out.push({ type: 'code', text: inner });
|
|
198
|
+
i = end + 1;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// 链接:[文字](地址) —— 地址不过白名单就整条降级成字面文本(见 isSafeHref)
|
|
203
|
+
if (c === '[') {
|
|
204
|
+
const m = /^\[([^\]]*)\]\(([^)\s]*)\)/.exec(text.slice(i));
|
|
205
|
+
if (m) {
|
|
206
|
+
const raw = text.slice(i, i + m[0].length);
|
|
207
|
+
const href = m[2].trim();
|
|
208
|
+
if (isSafeHref(href)) {
|
|
209
|
+
pushText();
|
|
210
|
+
out.push({ type: 'link', href, text: m[1].trim() });
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
buf += raw; // 字面保留(连 []() 一起)—— 让使用者看见「这里有个链接我没渲染」
|
|
214
|
+
}
|
|
215
|
+
i += m[0].length;
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
// 自动链接:<https://…>(裸的 `<` 一律当文本 —— 这正是「不做 HTML 透传」的落点)
|
|
220
|
+
if (c === '<') {
|
|
221
|
+
const m = /^<(https?:\/\/[^\s>]+)>/.exec(text.slice(i));
|
|
222
|
+
if (m) {
|
|
223
|
+
pushText();
|
|
224
|
+
out.push({ type: 'link', href: m[1], text: m[1] });
|
|
225
|
+
i += m[0].length;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
// 强调:**粗** / *斜*(`__` / `_` 同义)
|
|
230
|
+
if (c === '*' || c === '_') {
|
|
231
|
+
const strong = text.startsWith(c + c, i);
|
|
232
|
+
const mark = strong ? c + c : c;
|
|
233
|
+
const end = text.indexOf(mark, i + mark.length);
|
|
234
|
+
if (end > i + mark.length) {
|
|
235
|
+
const inner = text.slice(i + mark.length, end);
|
|
236
|
+
/**
|
|
237
|
+
* `_` 的**词内排除**(CommonMark 的 intraword 规则):两侧只要有一边紧贴单词字符,
|
|
238
|
+
* 就不构成强调。
|
|
239
|
+
*
|
|
240
|
+
* 没有这条,`read_file_tool` 会被切成 `read` + <em>file</em> + `tool`、
|
|
241
|
+
* `src/my_dir/my_file.ts` 同理 —— 而面板渲染的正是**模型正文**,标识符是那里最常见的东西。
|
|
242
|
+
* 单下划线(`read_file`)本来就不配对、不受影响;真正被切开的是有两个及以上 `_` 的标识符。
|
|
243
|
+
*
|
|
244
|
+
* ⚠️ `*` **不做**这条限制:CommonMark 里 `a*b*c` **就是** `a<em>b</em>c`。
|
|
245
|
+
* 只修 `_`、不修 `*`,是照口径修,不是照直觉修。
|
|
246
|
+
*/
|
|
247
|
+
const intraword = c === '_' && (isWordChar(text[i - 1]) || isWordChar(text[end + mark.length]));
|
|
248
|
+
if (!intraword && inner.trim() && !inner.startsWith(' ') && !inner.endsWith(' ')) {
|
|
249
|
+
pushText();
|
|
250
|
+
const children = depth < 3 ? parseInline(inner, depth + 1) : [{ type: 'text', text: inner }];
|
|
251
|
+
out.push(strong ? { type: 'strong', children } : { type: 'em', children });
|
|
252
|
+
i = end + mark.length;
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
buf += c;
|
|
258
|
+
i++;
|
|
259
|
+
}
|
|
260
|
+
pushText();
|
|
261
|
+
return out;
|
|
262
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OS 原生文件夹选择器(dev 面板「工作目录」控件的「用系统选择器…」路径)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么存在:浏览器**拿不到**用户所选文件夹的绝对路径(`<input webkitdirectory>` 只给
|
|
5
|
+
* 相对路径、File System Access API 只给 handle.name),而 dev 环要的就是绝对路径。
|
|
6
|
+
* 但 CLI 是本机进程 —— 它可以替用户拉起 OS 原生的目录选择框,把选中的绝对路径拿回来。
|
|
7
|
+
* `/api/fs` 的「浏览…」目录浏览器是这条路的**降级**(命令缺失 / 平台不支持时用)。
|
|
8
|
+
*
|
|
9
|
+
* ⚠️ **绝不能在 e2e / CI 里真触发**:osascript / zenity 会弹出**真对话框**把流水线挂死。
|
|
10
|
+
* 所以本模块的「平台 → 命令候选」「取消判定」「输出归一化」全是纯函数(带单测),
|
|
11
|
+
* 真正 spawn 的 `pickFolderNative()` 只由 dev 钩子在用户点击时调用。
|
|
12
|
+
*
|
|
13
|
+
* 防孤儿:选择框是用户可能要放着想一会儿的东西(**不设**超时),但 dev 进程退出时
|
|
14
|
+
* 必须收掉 —— 否则留下一个没人看的 osascript 对话框孤儿。在飞的子进程登记在模块级
|
|
15
|
+
* `active` 里,dev.ts 收尾时调 `killActivePickers()`。
|
|
16
|
+
*/
|
|
17
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
18
|
+
const MACOS_PICKER = {
|
|
19
|
+
command: 'osascript',
|
|
20
|
+
args: ['-e', 'POSIX path of (choose folder with prompt "选择 agent 的工作目录")'],
|
|
21
|
+
};
|
|
22
|
+
const WINDOWS_PICKER = {
|
|
23
|
+
command: 'powershell',
|
|
24
|
+
args: [
|
|
25
|
+
'-NoProfile',
|
|
26
|
+
'-NonInteractive',
|
|
27
|
+
'-Command',
|
|
28
|
+
'Add-Type -AssemblyName System.Windows.Forms; ' +
|
|
29
|
+
'$d = New-Object System.Windows.Forms.FolderBrowserDialog; ' +
|
|
30
|
+
"if ($d.ShowDialog() -eq 'OK') { $d.SelectedPath }",
|
|
31
|
+
],
|
|
32
|
+
};
|
|
33
|
+
const LINUX_PICKERS = [
|
|
34
|
+
['zenity', { command: 'zenity', args: ['--file-selection', '--directory'] }],
|
|
35
|
+
['kdialog', { command: 'kdialog', args: ['--getexistingdirectory'] }],
|
|
36
|
+
];
|
|
37
|
+
/**
|
|
38
|
+
* 「平台 → 命令候选」的纯解析(**导出为单测用**;`has` = 该命令在 PATH 上可用)。
|
|
39
|
+
*
|
|
40
|
+
* darwin / win32 的候选是确定的(osascript / powershell 是系统自带),不需要探测;
|
|
41
|
+
* linux 没有系统级唯一答案,按 zenity → kdialog 顺序探测,都没有 ⇒ `null`
|
|
42
|
+
* (调用方翻译成 501 + 可读报错)。
|
|
43
|
+
*/
|
|
44
|
+
export function resolvePicker(platform, has) {
|
|
45
|
+
if (platform === 'darwin')
|
|
46
|
+
return MACOS_PICKER;
|
|
47
|
+
if (platform === 'win32')
|
|
48
|
+
return WINDOWS_PICKER;
|
|
49
|
+
if (platform === 'linux') {
|
|
50
|
+
for (const [cmd, candidate] of LINUX_PICKERS) {
|
|
51
|
+
if (has(cmd))
|
|
52
|
+
return candidate;
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* 「这次退出算不算用户取消」的纯判定(**导出为单测用**)。
|
|
60
|
+
*
|
|
61
|
+
* 三个平台三种取消信号:
|
|
62
|
+
* - darwin:osascript 取消 = 退出码非 0 + stderr 带 **错误码 `(-128)`** —— AppleScript 的
|
|
63
|
+
* 「用户取消」错误码恒为 -128、**不随系统语言本地化**;文案("User canceled")才是
|
|
64
|
+
* 本地化的那部分,只当兜底匹配(非英文系统上只认文案会把「取消」误判成失败 ⇒ 501)。
|
|
65
|
+
* - win32:`ShowDialog()` 不是 OK 就什么都不打印(退出码 0);powershell 自身出错会写
|
|
66
|
+
* stderr —— 所以判「**两个流都空**」而不是只看退出码,否则真错误会被误报成「取消」;
|
|
67
|
+
* - linux:zenity / kdialog 取消 = 退出码 1、**无输出**;而「没有 DISPLAY」这类真错误
|
|
68
|
+
* 会往 stderr 写一句 —— 同样落「两个流都空即取消」。
|
|
69
|
+
* - 被 signal 杀掉(dev 进程退出时 `killActivePickers` 收编)一律视同取消。
|
|
70
|
+
*/
|
|
71
|
+
export function isCancel(platform, code, signal, stderr, stdout) {
|
|
72
|
+
if (signal !== null)
|
|
73
|
+
return true;
|
|
74
|
+
if (platform === 'darwin') {
|
|
75
|
+
return code !== 0 && (stderr.includes('(-128)') || /user canceled/i.test(stderr));
|
|
76
|
+
}
|
|
77
|
+
return stdout.trim().length === 0 && stderr.trim().length === 0;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* 选中输出的归一化(**导出为单测用**):空输出 ⇒ `null`(取消)。
|
|
81
|
+
*
|
|
82
|
+
* 两个坑:
|
|
83
|
+
* - 尾部换行 / `\r\n`(powershell)要去掉,但**只去尾部** —— 路径含空格、非 ASCII
|
|
84
|
+
* 都要原样扛住;
|
|
85
|
+
* - macOS 的 osascript `POSIX path of` 输出**带尾斜杠**(`/Users/x/`),要归一掉
|
|
86
|
+
* (面板会拿它与 `/api/fs` 列出的路径拼接比对);根目录 `/` 是唯一例外,不能归一成空串。
|
|
87
|
+
*/
|
|
88
|
+
export function normalizePickedPath(platform, stdout) {
|
|
89
|
+
const trimmed = stdout.replace(/[\r\n]+$/, '');
|
|
90
|
+
if (trimmed.length === 0)
|
|
91
|
+
return null;
|
|
92
|
+
if (platform === 'darwin' && trimmed.endsWith('/')) {
|
|
93
|
+
return trimmed.replace(/\/+$/, '') || '/';
|
|
94
|
+
}
|
|
95
|
+
return trimmed;
|
|
96
|
+
}
|
|
97
|
+
/** 在飞的选择框子进程 —— dev 进程退出时由 `killActivePickers()` 收编,不留 osascript 孤儿 */
|
|
98
|
+
const active = new Set();
|
|
99
|
+
/**
|
|
100
|
+
* 收掉所有还挂着的原生选择框(dev 收尾 / 进程退出时调用)。
|
|
101
|
+
* 被杀掉的子进程在 `pickFolderNative` 那边走「视同取消」(resolve null)——
|
|
102
|
+
* 那时进程本就要没了,没人会读这个值,但**不许挂着一个永不 settle 的 Promise**。
|
|
103
|
+
*/
|
|
104
|
+
export function killActivePickers() {
|
|
105
|
+
for (const child of active) {
|
|
106
|
+
try {
|
|
107
|
+
child.kill();
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
/* 已经退出了 */
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/** linux 上的命令探测(`which` 是事实标准;探测本身失败就当没有) */
|
|
115
|
+
function hasCommand(cmd) {
|
|
116
|
+
try {
|
|
117
|
+
return spawnSync('which', [cmd], { stdio: 'ignore' }).status === 0;
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* 拉起 OS 原生目录选择框,返回用户选中的**绝对路径**;用户取消返回 `null`。
|
|
125
|
+
*
|
|
126
|
+
* 平台不支持或命令缺失时**抛错**(消息可读,并指向降级路径「浏览…」)——
|
|
127
|
+
* 调用方(dev 钩子)把它翻译成 501。
|
|
128
|
+
*
|
|
129
|
+
* **不设超时**:用户可能把对话框放着想一会儿;进程退出由 `killActivePickers()` 收编。
|
|
130
|
+
*/
|
|
131
|
+
export function pickFolderNative() {
|
|
132
|
+
const picker = resolvePicker(process.platform, hasCommand);
|
|
133
|
+
if (picker === null) {
|
|
134
|
+
return Promise.reject(new Error(process.platform === 'linux'
|
|
135
|
+
? '找不到 zenity / kdialog —— 原生文件夹选择器不可用,请改用面板的「浏览…」'
|
|
136
|
+
: `这个平台(${process.platform})没有原生文件夹选择器 —— 请改用面板的「浏览…」`));
|
|
137
|
+
}
|
|
138
|
+
return new Promise((resolve, reject) => {
|
|
139
|
+
const child = spawn(picker.command, picker.args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
140
|
+
active.add(child);
|
|
141
|
+
let stdout = '';
|
|
142
|
+
let stderr = '';
|
|
143
|
+
child.stdout.on('data', (c) => {
|
|
144
|
+
stdout += c.toString('utf8');
|
|
145
|
+
});
|
|
146
|
+
child.stderr.on('data', (c) => {
|
|
147
|
+
stderr += c.toString('utf8');
|
|
148
|
+
});
|
|
149
|
+
child.on('error', (e) => {
|
|
150
|
+
active.delete(child);
|
|
151
|
+
// 命令缺失(ENOENT)也走这条路 —— 与「平台不支持」同一条可读口径
|
|
152
|
+
reject(new Error(`原生文件夹选择器起不来(${picker.command}:${e.message})—— 请改用面板的「浏览…」`));
|
|
153
|
+
});
|
|
154
|
+
child.on('close', (code, signal) => {
|
|
155
|
+
active.delete(child);
|
|
156
|
+
if (isCancel(process.platform, code, signal, stderr, stdout)) {
|
|
157
|
+
resolve(null);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (code !== 0) {
|
|
161
|
+
reject(new Error(`原生文件夹选择器失败(${picker.command} 退出 code=${code}):` +
|
|
162
|
+
`${stderr.trim() || '无输出'} —— 请改用面板的「浏览…」`));
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
resolve(normalizePickedPath(process.platform, stdout));
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
}
|
package/dist/panel-logic.js
CHANGED
|
@@ -24,6 +24,23 @@ export function normalizeToolSources(selected, all) {
|
|
|
24
24
|
export function capabilityBadge(selected, all) {
|
|
25
25
|
return `${selected.length}/${all.length}`;
|
|
26
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* 渲染前丢掉**已失效**的 token(能力被删 / runner 重启后菜单变了)。不过滤徽标就说谎:
|
|
29
|
+
* 分子里混着菜单上已不存在的名字 ⇒ `4/3`。空集返回空数组(「回落全选」是**调用方**的
|
|
30
|
+
* 规则),且保留入参顺序 —— 过滤不重排,排序是 `normalizeToolSources` 发送前的活。
|
|
31
|
+
*/
|
|
32
|
+
export function filterSelected(selected, all) {
|
|
33
|
+
const known = new Set(all);
|
|
34
|
+
return [...new Set(selected)].filter((s) => known.has(s));
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* 一条 run 的「已选来源」显示口径(`null` = 全量,与 `RunNote.toolSources` 同义)。
|
|
38
|
+
* **两处共用一份**:run 列表第三行与 `run-start` 提示条 —— 各写一份就会出现「同一组
|
|
39
|
+
* 能力两个说法」,而面板的纪律是「显示的就是生效的那份」。
|
|
40
|
+
*/
|
|
41
|
+
export function formatToolSources(sources) {
|
|
42
|
+
return sources === null ? '全量' : sources.join(',');
|
|
43
|
+
}
|
|
27
44
|
/**
|
|
28
45
|
* 多轮开关的初始值。
|
|
29
46
|
*
|
|
@@ -187,6 +204,21 @@ export function mergeConversation(messages, runs, sessionId) {
|
|
|
187
204
|
}
|
|
188
205
|
return turns;
|
|
189
206
|
}
|
|
207
|
+
/**
|
|
208
|
+
* 对话视图出不出现。三条**都**满足才显示,不做空壳:① dev 环在场且可用(只读面板没有
|
|
209
|
+
* 输入条 ⇒ 没有多轮);② 多轮开着(单轮不写会话文件);③ 会话文件读得到(工程没配
|
|
210
|
+
* `SessionStore` 时它是 null)。缺一条就只剩空壳 —— 那比不显示更糟:用户以为对话跑丢了。
|
|
211
|
+
*/
|
|
212
|
+
export function chatViewVisible(dev, multiTurn, session) {
|
|
213
|
+
return dev?.available === true && multiTurn && session !== null;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* 对话轮折叠态的键:**必须混进 sessionId**。清空对话换 id 后下标从 0 重来,
|
|
217
|
+
* 只用下标当键会把旧会话第 0 轮的展开态套到新会话第 0 轮上(张冠李戴)。
|
|
218
|
+
*/
|
|
219
|
+
export function turnKey(sessionId, index) {
|
|
220
|
+
return `turn:${sessionId}:${index}`;
|
|
221
|
+
}
|
|
190
222
|
// ---------- CLI 侧记账(面板发出去的东西,CLI 自己记一笔) ----------
|
|
191
223
|
/**
|
|
192
224
|
* 往 notes 表里记一条,并**按插入序**把表压在 `max` 以内。
|
|
@@ -381,3 +413,35 @@ export function promptAfterFilePick(current, name) {
|
|
|
381
413
|
return { prompt: current, filled: false };
|
|
382
414
|
return { prompt: name, filled: true };
|
|
383
415
|
}
|
|
416
|
+
// ---------- ③ 消息折叠:长正文默认收起,短的不加控件 ----------
|
|
417
|
+
/**
|
|
418
|
+
* 超过这么多行就默认折叠。12 行是「一屏大致读得完」的量级 —— 面板右栏在 1223px 下正文行高
|
|
419
|
+
* 约 18px,12 行 ≈ 216px,正好是不把调用树挤出去的上限。
|
|
420
|
+
*/
|
|
421
|
+
export const COLLAPSE_MAX_LINES = 12;
|
|
422
|
+
/** 单行长正文的兜底阈值:一行 2000 字符(模型不换行时很常见)同样该收起来 */
|
|
423
|
+
export const COLLAPSE_MAX_CHARS = 1200;
|
|
424
|
+
/**
|
|
425
|
+
* 「这段正文要不要折叠」。**判定是纯逻辑** —— 渲染层只管按结果挂控件,
|
|
426
|
+
* 不自己量高度(量高度依赖布局与字体,单测里量不出来,等于把规则藏进浏览器)。
|
|
427
|
+
*
|
|
428
|
+
* 为什么不按像素量:像素阈值在字体/缩放/窄屏下会漂,同一个回复在不同机器上折叠与否都不一样;
|
|
429
|
+
* 行数 + 字符数是**输入本身的属性**,可复现、可断言。
|
|
430
|
+
*
|
|
431
|
+
* `hint` 是给展开控件用的文案(面板拿它当按钮标题,再加个 caret);不折叠时是空串 ——
|
|
432
|
+
* **短消息上挂一个「展开」是噪声**,所以控件只在真的长时出现。
|
|
433
|
+
*/
|
|
434
|
+
export function collapseDecision(text, opts = {}) {
|
|
435
|
+
const t = String(text ?? '');
|
|
436
|
+
const lines = t.length === 0 ? 0 : t.split('\n').length;
|
|
437
|
+
const chars = t.length;
|
|
438
|
+
const maxLines = opts.maxLines ?? COLLAPSE_MAX_LINES;
|
|
439
|
+
const maxChars = opts.maxChars ?? COLLAPSE_MAX_CHARS;
|
|
440
|
+
const collapsed = lines > maxLines || chars > maxChars;
|
|
441
|
+
const hint = collapsed
|
|
442
|
+
? lines > 1
|
|
443
|
+
? `展开全文(共 ${lines} 行)`
|
|
444
|
+
: `展开全文(共 ${chars} 字符)`
|
|
445
|
+
: '';
|
|
446
|
+
return { collapsed, lines, chars, hint };
|
|
447
|
+
}
|
|
@@ -9,10 +9,10 @@
|
|
|
9
9
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@migor/agentia": "^0.9.
|
|
12
|
+
"@migor/agentia": "^0.9.3"
|
|
13
13
|
},
|
|
14
14
|
"devDependencies": {
|
|
15
|
-
"@migor/cli": "^0.9.
|
|
15
|
+
"@migor/cli": "^0.9.3",
|
|
16
16
|
"tsx": "^4.19.0",
|
|
17
17
|
"typescript": "^7.0.2",
|
|
18
18
|
"@types/node": "^22.0.0"
|
|
@@ -78,6 +78,9 @@ export async function createAgentApp(opts: CreateAgentAppOptions = {}): Promise<
|
|
|
78
78
|
// 里声明 `{ provide: '<能力 token>', useClass: Xxx, deps: ['WORKDIR'] }`
|
|
79
79
|
// (discover 自动注册的 provider **没有 deps**,所以要注入的能力得从 discover 挪到显式
|
|
80
80
|
// providers;同一个 token 混用不会重复收集菜单)。
|
|
81
|
+
// ⚠️ 若同时用 src/registry.ts(那里也有一份 `WORKDIR = process.cwd()`):
|
|
82
|
+
// 同 token **后注册覆盖先注册**,registry 的 providers 必须排在**这份之前**,
|
|
83
|
+
// 否则面板喂进来的 workdir 会被 process.cwd() 静默顶掉。
|
|
81
84
|
{ provide: 'WORKDIR', useValue: opts.workdir ?? PROJECT_ROOT },
|
|
82
85
|
// 例子:把工作目录注入给 read-file 工具(脚手架自带的第二个能力,
|
|
83
86
|
// 也是「面板上的文件夹选择器真的生效」的那个接线)
|
|
@@ -36,5 +36,10 @@ export default {
|
|
|
36
36
|
* 面板上「点一下就跑一次真 agent」,而真 agent 真花钱 —— 一个跑偏的循环就是一张账单。
|
|
37
37
|
* 这里可以按工程调;调小更安全,调大更放得开。
|
|
38
38
|
* 例:budget: { maxCostUsd: 5, maxTotalTokens: 1_000_000 },
|
|
39
|
+
*
|
|
40
|
+
* ⚠️ 覆盖关系:runner 把这份预算以 **run 级选项**传给框架,而框架的规则是
|
|
41
|
+
* run 级**优先于** createApp 应用级 ⇒ 你在 `src/app.ts` 的 createApp 里自配的
|
|
42
|
+
* `maxCostUsd` / `maxTotalTokens` 在 dev 环会被这里(或缺省值)压回去。
|
|
43
|
+
* 想改 dev 环的预算就在这里的 `budget` 字段改,别去 app.ts 里找。
|
|
39
44
|
*/
|
|
40
45
|
};
|
|
@@ -7,6 +7,11 @@ import type { Provider } from '@migor/agentia';
|
|
|
7
7
|
export const providers: Provider[] = [
|
|
8
8
|
// 「工作目录」以**值**注入给需要它的能力(消费方式见 src/tools/read-file/index.ts)。
|
|
9
9
|
// 想改成固定路径 / 从环境变量取,改这一行即可 —— 这是显式装配路线的意义。
|
|
10
|
+
//
|
|
11
|
+
// ⚠️ 与 src/app.ts 里那份 `WORKDIR` 是**二选一**的关系,混用时**顺序承重**:
|
|
12
|
+
// createApp 对同 token 是「后注册覆盖先注册」—— 把这份 providers 拼在 app.ts 那份
|
|
13
|
+
// **之后**,dev 面板喂进来的 workdir 会被这里的 `process.cwd()` 静默顶掉。
|
|
14
|
+
// 要混用就让本注册表排在前(让 app.ts 的值赢),否则只留一边。
|
|
10
15
|
{ provide: 'WORKDIR', useValue: process.cwd() },
|
|
11
16
|
// @agentia:entries
|
|
12
17
|
// @agentia:entries-end
|
|
@@ -31,6 +31,9 @@ export class FileSessionStore implements SessionStore {
|
|
|
31
31
|
mkdirSync(dirname(this.file), { recursive: true });
|
|
32
32
|
// 原子写:先写同目录的临时文件再 rename。直接覆写的话,进程在写一半时被杀
|
|
33
33
|
// 会留下半截 JSON,下一次 load 直接抛 —— 整段历史因为一次崩溃而不可读。
|
|
34
|
+
// ⚠️ 这只保**单进程**内的写完整:tmp 路径固定是 `${file}.tmp`,两个进程同时
|
|
35
|
+
// append 会在 rename 上竞态、丢一边。dev 环是单进程 runner,无此问题;要多进程
|
|
36
|
+
// 共享这份文件,契约要求调用方自行串行化(与 SessionStore 的 append-only 约定一致)。
|
|
34
37
|
const tmp = `${this.file}.tmp`;
|
|
35
38
|
writeFileSync(tmp, JSON.stringify({ sessions: all }, null, 2), 'utf8');
|
|
36
39
|
renameSync(tmp, this.file);
|
|
@@ -38,8 +41,14 @@ export class FileSessionStore implements SessionStore {
|
|
|
38
41
|
|
|
39
42
|
/**
|
|
40
43
|
* 读整份文件。文件不存在 = 还没有历史(空)。
|
|
41
|
-
*
|
|
42
|
-
*
|
|
44
|
+
*
|
|
45
|
+
* 解析失败**抛错**而不是当作空历史 —— 静默当成空会把「历史读不出来」伪装成
|
|
46
|
+
* 「这是第一轮」,然后下一轮 append 再把它覆盖掉:整段对话无声消失。
|
|
47
|
+
*
|
|
48
|
+
* ⚠️ 但要如实说清楚这个错误会走到哪:框架在 run 期间会**刻意吞掉**会话侧的异常
|
|
49
|
+
* (辅助动作不击穿 run),所以这里的抛错不会把 run 打崩、也不会直接可见。
|
|
50
|
+
* 真正的可见性出口是 dev runner **启动期**的那次探测:它主动 `load()` 一次,
|
|
51
|
+
* 把原因送上 warning 通道(面板顶部可见)—— 所以别把这里的抛错改成静默。
|
|
43
52
|
*/
|
|
44
53
|
private readAll(): Record<string, MessageParam[]> {
|
|
45
54
|
let raw: string;
|