pi-web-ui 0.94.0 → 0.95.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.
Files changed (65) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +5 -6
  3. package/README.zh-CN.md +4 -5
  4. package/bin/pi-web-ui.mjs +751 -206
  5. package/dist/server/agent-service.js +1869 -85
  6. package/dist/server/approval-rules.js +550 -0
  7. package/dist/server/attachment-store.js +112 -0
  8. package/dist/server/attachments.js +62 -171
  9. package/dist/server/client-state.js +4 -0
  10. package/dist/server/compact-context-tool.js +125 -0
  11. package/dist/server/context-budget.js +317 -0
  12. package/dist/server/dangling-tools.js +162 -0
  13. package/dist/server/dsh/dsh-agent-service.js +50 -60
  14. package/dist/server/edit-soft-tool.js +70 -5
  15. package/dist/server/eval-tool.js +535 -0
  16. package/dist/server/files-service.js +35 -7
  17. package/dist/server/goal-service.js +240 -68
  18. package/dist/server/hashline-engine.js +665 -0
  19. package/dist/server/host-metrics.js +26 -2
  20. package/dist/server/http-proxy.js +11 -1
  21. package/dist/server/index.js +157 -9
  22. package/dist/server/lsp-tool.js +679 -0
  23. package/dist/server/model-admin.js +20 -0
  24. package/dist/server/office-parse.js +309 -0
  25. package/dist/server/patch-tool.js +91 -0
  26. package/dist/server/plan-manager.js +104 -0
  27. package/dist/server/plugin-api-catalog.js +296 -0
  28. package/dist/server/plugin-install-spec.js +196 -0
  29. package/dist/server/plugin-installer.js +73 -0
  30. package/dist/server/plugin-manifest-validate.js +305 -0
  31. package/dist/server/plugin-project.js +47 -11
  32. package/dist/server/plugin-tool-guard.js +120 -0
  33. package/dist/server/plugins.js +745 -161
  34. package/dist/server/process-utils.js +12 -6
  35. package/dist/server/scheduler-tasks.js +6 -0
  36. package/dist/server/serialize.js +13 -3
  37. package/dist/server/settings-service.js +108 -1
  38. package/dist/server/subagents.js +41 -0
  39. package/dist/server/terminals.js +69 -14
  40. package/dist/server/tool-approval.js +84 -0
  41. package/dist/server/tool-manager.js +214 -6
  42. package/dist/server/workspace-snapshot.js +113 -0
  43. package/package.json +3 -2
  44. package/plugin-sdk/README.md +25 -0
  45. package/plugin-sdk/index.d.ts +31 -38
  46. package/plugin-sdk/index.mjs +35 -19
  47. package/plugins/catalog.json +20 -0
  48. package/themes/ayu-light.css +6 -6
  49. package/themes/catppuccin-latte.css +6 -6
  50. package/themes/claude-code-dark.css +144 -0
  51. package/themes/codex.css +6 -6
  52. package/themes/dark-teal.css +20 -6
  53. package/themes/everforest-light.css +6 -6
  54. package/themes/geist.css +6 -6
  55. package/themes/gruvbox-light.css +6 -6
  56. package/themes/kanagawa-lotus.css +6 -6
  57. package/themes/rose-pine-dawn.css +6 -6
  58. package/themes/solarized-light.css +6 -6
  59. package/themes/vs-code-dark.css +146 -0
  60. package/web/dist/assets/{TerminalPanel-B-CMQuzm.js → TerminalPanel-DlGBF2MD.js} +1 -1
  61. package/web/dist/assets/index-BXnCQL9p.js +364 -0
  62. package/web/dist/assets/index-RpIGFyi_.css +1 -0
  63. package/web/dist/index.html +2 -2
  64. package/web/dist/assets/index-18jK6al2.js +0 -364
  65. package/web/dist/assets/index-B3S9MxnN.css +0 -1
@@ -0,0 +1,535 @@
1
+ /**
2
+ * eval-tool.ts — 受控的持久代码求值沙箱工具(eval)。
3
+ *
4
+ * 背景与设计:
5
+ * AI 在面对需要快速计算、复杂数据处理(如解析庞大的 CSV/JSON 数据、统计指标计算)、
6
+ * 算法推演或生成图表时,以往只能通过 `bash` 工具反复创建与删除临时脚本,且中间变量
7
+ * 无法跨调用持久化。
8
+ *
9
+ * 借鉴 oh-my-pi (omp) 的 eval 工具经验,同时彻底规避其两大缺陷:
10
+ * 1. 【工具挤占防范】:本工具默认关闭(defaultOn: false,opt-in),不挤占常规代码编写/检索;
11
+ * 2. 【IPC 死锁防范】:采用轻量独立子进程驱动,输出独立缓冲,配以跨平台进程树硬超时查杀
12
+ * (Windows 下 taskkill /F /T,Unix 下 SIGKILL),杜绝子进程挂起与假死。
13
+ *
14
+ * 支持语言:
15
+ * - "py": Python 3(使用内置 ast 解析,支持多语句执行、最后表达式求值与变量保持);
16
+ * - "js" / "ts": Node.js(基于 node:repl,支持 top-level await、变量保持与控制台捕获)。
17
+ */
18
+ import { spawn, execSync } from "node:child_process";
19
+ import { mkdtempSync, rmSync } from "node:fs";
20
+ import { tmpdir } from "node:os";
21
+ import { join } from "node:path";
22
+ import readline from "node:readline";
23
+ import { defineTool } from "@earendil-works/pi-coding-agent";
24
+ import { Type } from "typebox";
25
+ import { bilingual, pick } from "./i18n.js";
26
+ import { EVAL_TOOL_NAME } from "./tool-manager.js";
27
+ export { EVAL_TOOL_NAME };
28
+ const DEFAULT_TIMEOUT_SECONDS = 15;
29
+ const MAX_TIMEOUT_SECONDS = 120;
30
+ const MAX_OUTPUT_CHARS = 12000;
31
+ /** 跨平台递归查杀进程树。 */
32
+ export function killProcessTree(proc) {
33
+ if (!proc.pid)
34
+ return;
35
+ if (process.platform === "win32") {
36
+ try {
37
+ execSync(`taskkill /F /T /PID ${proc.pid}`, { stdio: "ignore" });
38
+ }
39
+ catch {
40
+ try {
41
+ proc.kill("SIGKILL");
42
+ }
43
+ catch { }
44
+ }
45
+ }
46
+ else {
47
+ try {
48
+ process.kill(-proc.pid, "SIGKILL");
49
+ }
50
+ catch {
51
+ try {
52
+ proc.kill("SIGKILL");
53
+ }
54
+ catch { }
55
+ }
56
+ }
57
+ }
58
+ /** 探测系统可用的 Python 命令。 */
59
+ function resolvePythonCmd() {
60
+ for (const cmd of ["python", "python3", "py"]) {
61
+ try {
62
+ const out = execSync(`${cmd} --version`, { stdio: "pipe", encoding: "utf8" });
63
+ if (out.toLowerCase().includes("python"))
64
+ return cmd;
65
+ }
66
+ catch { }
67
+ }
68
+ return null;
69
+ }
70
+ const PYTHON_DRIVER_SCRIPT = `
71
+ import sys, io, ast, json, traceback, os
72
+
73
+ WORKDIR = os.environ.get('PI_EVAL_WORKDIR', '')
74
+ PROJECT_DIR = os.environ.get('PI_EVAL_PROJECT_DIR', '')
75
+
76
+ _scope = {'__name__': '__main__', 'WORKDIR': WORKDIR, 'PROJECT_DIR': PROJECT_DIR}
77
+
78
+ def run_cell(code):
79
+ out = io.StringIO()
80
+ err = io.StringIO()
81
+ old_out, old_err = sys.stdout, sys.stderr
82
+ sys.stdout, sys.stderr = out, err
83
+ res = None
84
+ exc = None
85
+ try:
86
+ parsed = ast.parse(code)
87
+ if parsed.body and isinstance(parsed.body[-1], ast.Expr):
88
+ last = parsed.body.pop()
89
+ if parsed.body:
90
+ exec(compile(parsed, '<eval>', 'exec'), _scope)
91
+ res = eval(compile(ast.Expression(last.value), '<eval>', 'eval'), _scope)
92
+ else:
93
+ exec(compile(code, '<eval>', 'exec'), _scope)
94
+ except Exception:
95
+ exc = traceback.format_exc()
96
+ finally:
97
+ sys.stdout, sys.stderr = old_out, old_err
98
+ return {
99
+ 'stdout': out.getvalue(),
100
+ 'stderr': err.getvalue(),
101
+ 'result': repr(res) if res is not None else None,
102
+ 'error': exc
103
+ }
104
+
105
+ for line in sys.stdin:
106
+ if not line.strip():
107
+ continue
108
+ try:
109
+ req = json.loads(line)
110
+ if req.get('action') == 'reset':
111
+ _scope.clear()
112
+ _scope['__name__'] = '__main__'
113
+ _scope['WORKDIR'] = WORKDIR
114
+ _scope['PROJECT_DIR'] = PROJECT_DIR
115
+ resp = {'ok': True, 'stdout': '', 'stderr': '', 'result': None, 'error': None}
116
+ else:
117
+ resp = run_cell(req.get('code', ''))
118
+ resp['ok'] = resp['error'] is None
119
+ except Exception as e:
120
+ resp = {'ok': False, 'stdout': '', 'stderr': '', 'result': None, 'error': traceback.format_exc()}
121
+
122
+ sys.stdout.write('__PI_EVAL_RES__' + json.dumps(resp, ensure_ascii=False) + '\\n')
123
+ sys.stdout.flush()
124
+ `;
125
+ const NODE_DRIVER_SCRIPT = `
126
+ import repl from "node:repl";
127
+ import { PassThrough } from "node:stream";
128
+ import readline from "node:readline";
129
+
130
+ let server;
131
+ function createRepl() {
132
+ const inStream = new PassThrough();
133
+ const outStream = new PassThrough();
134
+ server = repl.start({
135
+ input: inStream,
136
+ output: outStream,
137
+ terminal: false,
138
+ useColors: false,
139
+ preview: false
140
+ });
141
+ server.context.console = console;
142
+ server.context.setTimeout = setTimeout;
143
+ server.context.clearTimeout = clearTimeout;
144
+ server.context.setInterval = setInterval;
145
+ server.context.clearInterval = clearInterval;
146
+ server.context.Buffer = Buffer;
147
+ server.context.URL = URL;
148
+ server.context.WORKDIR = process.env.PI_EVAL_WORKDIR || "";
149
+ server.context.PROJECT_DIR = process.env.PI_EVAL_PROJECT_DIR || "";
150
+ }
151
+ createRepl();
152
+
153
+ const rl = readline.createInterface({ input: process.stdin });
154
+ rl.on("line", (line) => {
155
+ if (!line.trim()) return;
156
+ try {
157
+ const req = JSON.parse(line);
158
+ if (req.action === "reset") {
159
+ createRepl();
160
+ process.stdout.write("__PI_EVAL_RES__" + JSON.stringify({ ok: true, stdout: "", stderr: "", result: null, error: null }) + "\\n");
161
+ return;
162
+ }
163
+ let capturedOut = "";
164
+ let capturedErr = "";
165
+ const origLog = server.context.console.log;
166
+ const origErr = server.context.console.error;
167
+ const origWarn = server.context.console.warn;
168
+ server.context.console.log = (...args) => {
169
+ capturedOut += args.map(a => typeof a === "string" ? a : JSON.stringify(a)).join(" ") + "\\n";
170
+ };
171
+ server.context.console.error = (...args) => {
172
+ capturedErr += args.map(a => typeof a === "string" ? a : JSON.stringify(a)).join(" ") + "\\n";
173
+ };
174
+ server.context.console.warn = (...args) => {
175
+ capturedErr += args.map(a => typeof a === "string" ? a : JSON.stringify(a)).join(" ") + "\\n";
176
+ };
177
+
178
+ server.eval(req.code, server.context, "eval", (err, result) => {
179
+ server.context.console.log = origLog;
180
+ server.context.console.error = origErr;
181
+ server.context.console.warn = origWarn;
182
+ const resp = {
183
+ ok: !err,
184
+ stdout: capturedOut,
185
+ stderr: capturedErr,
186
+ result: result !== undefined ? (typeof result === "string" ? result : JSON.stringify(result, null, 2)) : null,
187
+ error: err ? (err.stack || String(err)) : null
188
+ };
189
+ process.stdout.write("__PI_EVAL_RES__" + JSON.stringify(resp) + "\\n");
190
+ });
191
+ } catch (e) {
192
+ const resp = { ok: false, stdout: "", stderr: "", result: null, error: e.stack || String(e) };
193
+ process.stdout.write("__PI_EVAL_RES__" + JSON.stringify(resp) + "\\n");
194
+ }
195
+ });
196
+ `;
197
+ /** 单个持久内核工作进程包装。 */
198
+ class EvalKernel {
199
+ language;
200
+ cwd;
201
+ proc = null;
202
+ rl = null;
203
+ current = null;
204
+ queue = [];
205
+ stderrTail = "";
206
+ closed = false;
207
+ workdir;
208
+ constructor(language, cwd) {
209
+ this.language = language;
210
+ this.cwd = cwd;
211
+ this.workdir = mkdtempSync(join(tmpdir(), `pi-eval-${language}-`));
212
+ }
213
+ spawn() {
214
+ if (this.proc && !this.proc.killed && this.proc.exitCode === null) {
215
+ return;
216
+ }
217
+ // 沙箱边界:内核 cwd 是本会话的独立临时目录(不落在项目里),要读项目
218
+ // 文件时用驱动内注入的 PROJECT_DIR 显式拼绝对路径(两个 env 见下)。
219
+ const spawnOpts = {
220
+ cwd: this.workdir,
221
+ stdio: ["pipe", "pipe", "pipe"],
222
+ detached: process.platform !== "win32",
223
+ env: {
224
+ ...process.env,
225
+ PI_EVAL_WORKDIR: this.workdir,
226
+ PI_EVAL_PROJECT_DIR: this.cwd,
227
+ },
228
+ };
229
+ if (this.language === "py") {
230
+ const pyCmd = resolvePythonCmd();
231
+ if (!pyCmd) {
232
+ throw new Error("Python executable not found in PATH. Please install Python or use language: 'js'.");
233
+ }
234
+ this.proc = spawn(pyCmd, ["-u", "-c", PYTHON_DRIVER_SCRIPT], spawnOpts);
235
+ }
236
+ else {
237
+ // js / ts:独立 node -e 驱动,避免 eval 里的死循环卡住服务端事件循环。
238
+ this.proc = spawn(process.execPath, ["-e", NODE_DRIVER_SCRIPT], spawnOpts);
239
+ }
240
+ // stderr 必须持续排空:驱动只把用户的 print/console 劫进 StringIO,但
241
+ // os.write(2, …) / 原生扩展 / eval 里再 spawn 的子进程仍写真实 fd 2。
242
+ // piped stderr 无人读,写满 ~64KB 管道缓冲会把驱动挂住(表现为假超时)。
243
+ this.proc.stderr.on("data", (chunk) => {
244
+ this.stderrTail = (this.stderrTail + chunk.toString()).slice(-4000);
245
+ });
246
+ this.rl = readline.createInterface({ input: this.proc.stdout });
247
+ this.rl.on("line", (line) => {
248
+ if (!line.startsWith("__PI_EVAL_RES__"))
249
+ return;
250
+ const item = this.current;
251
+ if (!item)
252
+ return;
253
+ this.current = null;
254
+ try {
255
+ const data = JSON.parse(line.slice("__PI_EVAL_RES__".length));
256
+ this.settle(item, data);
257
+ }
258
+ catch {
259
+ this.settle(item, {
260
+ ok: false,
261
+ stdout: "",
262
+ stderr: this.stderrTail,
263
+ result: null,
264
+ error: "Kernel returned an unparsable response frame",
265
+ });
266
+ }
267
+ this.pump();
268
+ });
269
+ // 代际守卫:killProc 之后旧进程的 exit/error 是「迟到的」,那时 this.proc
270
+ // 已指向新进程(或 null),绝不能让它污染新内核的状态。
271
+ const proc = this.proc;
272
+ proc.on("error", (err) => {
273
+ if (this.proc !== proc)
274
+ return;
275
+ this.procDied(`Kernel process error: ${err.message}`);
276
+ });
277
+ proc.on("exit", (code) => {
278
+ if (this.proc !== proc)
279
+ return;
280
+ this.procDied(`Kernel process exited unexpectedly with code ${code}`);
281
+ });
282
+ }
283
+ /** 进程异常退出:结算在飞与排队中的请求(都当失败,不假装成功)。 */
284
+ procDied(message) {
285
+ if (this.current) {
286
+ const item = this.current;
287
+ this.current = null;
288
+ this.settle(item, {
289
+ ok: false,
290
+ stdout: "",
291
+ stderr: this.stderrTail,
292
+ result: null,
293
+ error: message,
294
+ });
295
+ }
296
+ const queued = this.queue.splice(0);
297
+ for (const item of queued) {
298
+ this.settle(item, { ok: false, stdout: "", stderr: "", result: null, error: message });
299
+ }
300
+ this.pump();
301
+ }
302
+ async execute(code, timeoutMs, reset = false) {
303
+ if (this.closed)
304
+ throw new Error("Eval kernel is closed");
305
+ if (reset) {
306
+ await this.sendAction({ action: "reset" }, 3000).catch(() => { });
307
+ }
308
+ return await this.sendAction({ action: "eval", code }, timeoutMs);
309
+ }
310
+ sendAction(req, timeoutMs) {
311
+ return new Promise((resolve, reject) => {
312
+ const item = {
313
+ req,
314
+ timeoutMs,
315
+ timer: setTimeout(() => { }, 0),
316
+ done: false,
317
+ resolve,
318
+ reject,
319
+ };
320
+ item.timer = setTimeout(() => {
321
+ if (item.done)
322
+ return;
323
+ if (this.current === item) {
324
+ this.current = null;
325
+ // 硬超时:杀进程树(不留孤儿),后续 pump 按需重启内核。
326
+ this.killProc();
327
+ this.pump();
328
+ }
329
+ item.reject(new Error(`Execution timed out after ${Math.round(item.timeoutMs / 1000)}s`));
330
+ }, timeoutMs);
331
+ this.queue.push(item);
332
+ this.pump();
333
+ });
334
+ }
335
+ /** 串行消费队列:驱动协议一次只发一条请求。 */
336
+ pump() {
337
+ if (this.current || this.queue.length === 0)
338
+ return;
339
+ try {
340
+ this.spawn();
341
+ }
342
+ catch (err) {
343
+ const item = this.queue.shift();
344
+ if (item) {
345
+ this.settle(item, { ok: false, stdout: "", stderr: "", result: null, error: err.message });
346
+ }
347
+ return;
348
+ }
349
+ const item = this.queue.shift();
350
+ this.current = item;
351
+ try {
352
+ this.proc.stdin.write(JSON.stringify(item.req) + "\n");
353
+ }
354
+ catch (err) {
355
+ this.current = null;
356
+ this.settle(item, {
357
+ ok: false,
358
+ stdout: "",
359
+ stderr: "",
360
+ result: null,
361
+ error: `Failed to write to kernel: ${err.message}`,
362
+ });
363
+ this.pump();
364
+ }
365
+ }
366
+ /** 结算一条请求(幂等;清定时器)。 */
367
+ settle(item, res) {
368
+ if (item.done)
369
+ return;
370
+ item.done = true;
371
+ clearTimeout(item.timer);
372
+ item.resolve(res);
373
+ }
374
+ /** 只杀进程(保留 workdir):超时路径用,之后可原样重启内核。 */
375
+ killProc() {
376
+ if (this.rl) {
377
+ this.rl.close();
378
+ this.rl = null;
379
+ }
380
+ if (this.proc) {
381
+ killProcessTree(this.proc);
382
+ this.proc = null;
383
+ }
384
+ this.stderrTail = "";
385
+ }
386
+ dispose() {
387
+ this.closed = true;
388
+ if (this.current) {
389
+ const item = this.current;
390
+ this.current = null;
391
+ this.settle(item, { ok: false, stdout: "", stderr: "", result: null, error: "Eval kernel disposed" });
392
+ }
393
+ const queued = this.queue.splice(0);
394
+ for (const item of queued) {
395
+ this.settle(item, { ok: false, stdout: "", stderr: "", result: null, error: "Eval kernel disposed" });
396
+ }
397
+ this.killProc();
398
+ try {
399
+ rmSync(this.workdir, { recursive: true, force: true });
400
+ }
401
+ catch { }
402
+ }
403
+ }
404
+ /** 会话级内核池(convId -> { py, js })。 */
405
+ const sessionKernels = new Map();
406
+ function getKernel(sessionId, language, cwd) {
407
+ let langMap = sessionKernels.get(sessionId);
408
+ if (!langMap) {
409
+ langMap = new Map();
410
+ sessionKernels.set(sessionId, langMap);
411
+ }
412
+ const normLang = language === "ts" ? "js" : language;
413
+ let kernel = langMap.get(normLang);
414
+ if (!kernel) {
415
+ kernel = new EvalKernel(normLang, cwd);
416
+ langMap.set(normLang, kernel);
417
+ }
418
+ return kernel;
419
+ }
420
+ /** 销毁指定会话的求值沙箱进程。 */
421
+ export function disposeEvalSession(sessionId) {
422
+ const langMap = sessionKernels.get(sessionId);
423
+ if (langMap) {
424
+ for (const kernel of langMap.values()) {
425
+ kernel.dispose();
426
+ }
427
+ sessionKernels.delete(sessionId);
428
+ }
429
+ }
430
+ /** 清理全部求值沙箱。 */
431
+ export function disposeAllEvalKernels() {
432
+ for (const langMap of sessionKernels.values()) {
433
+ for (const kernel of langMap.values()) {
434
+ kernel.dispose();
435
+ }
436
+ }
437
+ sessionKernels.clear();
438
+ }
439
+ export function makeEvalTool(opts) {
440
+ const getLang = opts.lang ?? (() => "en");
441
+ const sessionId = (opts.ownerId ?? "default").trim() || "default";
442
+ return defineTool({
443
+ name: EVAL_TOOL_NAME,
444
+ label: "Execute code in persistent sandbox",
445
+ description: bilingual("Execute Python or JavaScript/TypeScript code in an isolated evaluation sandbox. " +
446
+ "Variables and imported modules persist across calls within the conversation. " +
447
+ "Ideal for quick calculations, data transformations, algorithm verification, and inspecting outputs without creating temporary script files.", "在隔离的代码求值沙箱中执行 Python 或 JavaScript/TypeScript 代码。" +
448
+ "变量与导入的模块在会话内的多次调用间持续保留。" +
449
+ "适用于无需创建临时文件的即时计算、数据转换、算法验证与推演。"),
450
+ promptSnippet: bilingual("evaluate Python or JS/TS code with persistent state (default-off sandbox)", "在保持变量状态的沙箱中执行 Python 或 JS/TS 代码(默认关闭)"),
451
+ parameters: Type.Object({
452
+ code: Type.String({
453
+ description: bilingual("The code snippet to evaluate. Top-level variables and functions are preserved across calls.", "要执行的代码片段。顶层变量和函数会在后续调用中持续保留。"),
454
+ }),
455
+ language: Type.Optional(Type.Union([Type.Literal("py"), Type.Literal("js"), Type.Literal("ts")], {
456
+ description: bilingual('Target language: "py" for Python (default), "js" or "ts" for Node.js sandbox.', '目标语言:"py" 为 Python(默认),"js" 或 "ts" 为 Node.js 沙箱。'),
457
+ })),
458
+ title: Type.Optional(Type.String({
459
+ description: bilingual('Optional short label for this step (e.g. "Calculate metrics", "Parse payload").', '可选的简短标题(例如 "计算指标"、"解析数据")。'),
460
+ })),
461
+ timeout: Type.Optional(Type.Integer({
462
+ minimum: 1,
463
+ maximum: MAX_TIMEOUT_SECONDS,
464
+ description: bilingual(`Execution timeout in seconds (default: ${DEFAULT_TIMEOUT_SECONDS}, maximum: ${MAX_TIMEOUT_SECONDS}).`, `执行超时秒数(默认 ${DEFAULT_TIMEOUT_SECONDS},最大 ${MAX_TIMEOUT_SECONDS})。`),
465
+ })),
466
+ reset: Type.Optional(Type.Boolean({
467
+ description: bilingual("Whether to reset the sandbox environment before executing (clears all previous variables).", "是否在执行前重置沙箱环境(清空之前的所有变量)。"),
468
+ })),
469
+ }),
470
+ execute: async (_id, params) => {
471
+ const lang = getLang();
472
+ const language = params.language ?? "py";
473
+ const timeoutSec = Math.min(Math.max(1, params.timeout ?? DEFAULT_TIMEOUT_SECONDS), MAX_TIMEOUT_SECONDS);
474
+ const timeoutMs = timeoutSec * 1000;
475
+ const startTime = Date.now();
476
+ try {
477
+ const kernel = getKernel(sessionId, language, opts.cwd);
478
+ const res = await kernel.execute(params.code, timeoutMs, params.reset ?? false);
479
+ const durationMs = Date.now() - startTime;
480
+ let text = "";
481
+ const titlePart = params.title ? ` - ${params.title}` : "";
482
+ if (!res.ok) {
483
+ text = pick(lang, `[eval:${language} 出错 (${durationMs}ms)${titlePart}]\n${res.error || res.stderr || "执行异常"}`, `[eval:${language} error (${durationMs}ms)${titlePart}]\n${res.error || res.stderr || "Execution failed"}`);
484
+ }
485
+ else {
486
+ const parts = [];
487
+ if (res.stdout.trim()) {
488
+ parts.push(res.stdout.trim());
489
+ }
490
+ if (res.result !== null && res.result !== undefined) {
491
+ parts.push(`=> ${res.result}`);
492
+ }
493
+ if (parts.length === 0) {
494
+ parts.push(pick(lang, "(执行成功,无输出)", "(Completed with no output)"));
495
+ }
496
+ text = `[eval:${language} (${durationMs}ms)${titlePart}]\n` + parts.join("\n");
497
+ }
498
+ if (text.length > MAX_OUTPUT_CHARS) {
499
+ text = text.slice(0, MAX_OUTPUT_CHARS) + `\n… [${text.length - MAX_OUTPUT_CHARS} chars truncated]`;
500
+ }
501
+ return {
502
+ content: [{ type: "text", text }],
503
+ details: {
504
+ language,
505
+ durationMs,
506
+ ok: res.ok,
507
+ stdout: res.stdout,
508
+ stderr: res.stderr,
509
+ result: res.result,
510
+ error: res.error,
511
+ reset: params.reset ?? false,
512
+ },
513
+ };
514
+ }
515
+ catch (err) {
516
+ const durationMs = Date.now() - startTime;
517
+ const errMsg = err.message;
518
+ const text = `[eval:${language} error (${durationMs}ms)]\n${errMsg}`;
519
+ return {
520
+ content: [{ type: "text", text }],
521
+ details: {
522
+ language,
523
+ durationMs,
524
+ ok: false,
525
+ stdout: "",
526
+ stderr: "",
527
+ result: null,
528
+ error: errMsg,
529
+ reset: params.reset ?? false,
530
+ },
531
+ };
532
+ }
533
+ },
534
+ });
535
+ }
@@ -10,6 +10,7 @@ import { homedir } from "node:os";
10
10
  import { resolve, relative, sep } from "node:path";
11
11
  import { pick } from "./i18n.js";
12
12
  import { previewKind, looksLikeText, decodeText, hexDump, countLines } from "./text-sniff.js";
13
+ import { extractOfficeText, isOfficeFile, OFFICE_MAX_FILE_BYTES } from "./office-parse.js";
13
14
  import { gitDirOf, isNotRepoError, scmStatus, scmHistory, scmFileDiff, scmCommitDetail } from "./scm.js";
14
15
  export const IS_WIN32 = process.platform === "win32";
15
16
  /** 机器根虚拟路径:工作区「上一级」到达此处,列出所有盘符(Windows)/ "/"(posix)。
@@ -769,6 +770,31 @@ export class FilesService {
769
770
  return;
770
771
  }
771
772
  const name = relPath.split(/[\\/]/).pop() ?? relPath;
773
+ // Office 文档(docx/xlsx/xlsm):转 Markdown 文本下发,前端按 Markdown
774
+ // 渲染表格/段落——文件树、附件、预览弹窗随处可看。失败回落到底下旧分支。
775
+ if (isOfficeFile(name) && stat.size <= OFFICE_MAX_FILE_BYTES) {
776
+ try {
777
+ const data = await fs.readFile(abs);
778
+ const office = extractOfficeText(name, data);
779
+ if (office) {
780
+ this.host.emit({
781
+ type: "file_content",
782
+ path: rel,
783
+ name,
784
+ text: office.text,
785
+ truncated: office.truncated || data.length < stat.size,
786
+ binary: false,
787
+ kind: "text",
788
+ lines: countLines(Buffer.from(office.text)),
789
+ size: stat.size,
790
+ });
791
+ return;
792
+ }
793
+ }
794
+ catch {
795
+ /* 解析失败(加密/损坏/非预期结构)→ 回落旧的二进制嗅探分支 */
796
+ }
797
+ }
772
798
  const kind = previewKind(name);
773
799
  // Media previews stream over the /api/file HTTP endpoint, so only
774
800
  // metadata is sent here — the raw bytes never touch the socket.
@@ -1296,30 +1322,32 @@ export class FilesService {
1296
1322
  try {
1297
1323
  const fsp = await import("node:fs/promises");
1298
1324
  const { dirname } = await import("node:path");
1299
- const t = this.resolveOpTarget(path);
1325
+ const trimmed = path.trim();
1326
+ const t = !trimmed ? { abs: resolve(this.host.getCwd()) } : this.resolveOpTarget(trimmed);
1300
1327
  if (!t) {
1301
1328
  err("此处不可定位:" + path, "Cannot reveal here: " + path);
1302
1329
  return;
1303
1330
  }
1304
- const st = await fsp.stat(t.abs).catch(() => null);
1331
+ const abs = t.abs;
1332
+ const st = await fsp.stat(abs).catch(() => null);
1305
1333
  if (!st) {
1306
1334
  err("文件不存在:" + path, "Not found: " + path);
1307
1335
  return;
1308
1336
  }
1309
1337
  const isDir = st.isDirectory();
1310
- const segs = path.split("/");
1311
- const base = segs[segs.length - 1] ?? path;
1338
+ const segs = trimmed.split("/");
1339
+ const base = !trimmed ? t.abs.split(/[\\/]/).pop() || t.abs : (segs[segs.length - 1] ?? trimmed);
1312
1340
  if (process.platform === "win32") {
1313
1341
  // /select, 与路径分两个 argv 传(explorer 对此格式稳定支持,路径含空格也无碍)。
1314
1342
  // 目录传 /n, 强制打开新窗口,防止若该目录已在后台打开时被 Windows 静默复用且因反抢焦点机制不置顶。
1315
- await this.spawnDetached("explorer.exe", isDir ? ["/n,", t.abs] : ["/select,", t.abs], "已在资源管理器中显示:" + base, "Revealed in File Explorer: " + base);
1343
+ await this.spawnDetached("explorer.exe", isDir ? ["/n,", abs] : ["/select,", abs], "已在资源管理器中显示:" + base, "Revealed in File Explorer: " + base);
1316
1344
  }
1317
1345
  else if (process.platform === "darwin") {
1318
- await this.spawnDetached("open", isDir ? [t.abs] : ["-R", t.abs], "已在访达中显示:" + base, "Revealed in Finder: " + base);
1346
+ await this.spawnDetached("open", isDir ? [abs] : ["-R", abs], "已在访达中显示:" + base, "Revealed in Finder: " + base);
1319
1347
  }
1320
1348
  else {
1321
1349
  // Linux 无统一选中语义:打开其父目录(目录则打开自身)。
1322
- await this.spawnDetached("xdg-open", [isDir ? t.abs : dirname(t.abs)], "已打开所在目录:" + base, "Opened containing folder: " + base);
1350
+ await this.spawnDetached("xdg-open", [isDir ? abs : dirname(abs)], "已打开所在目录:" + base, "Opened containing folder: " + base);
1323
1351
  }
1324
1352
  }
1325
1353
  catch (e) {