pi-web-ui 0.92.0 → 0.93.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/CHANGELOG.md +58 -2
- package/README.md +69 -14
- package/README.zh-CN.md +36 -10
- package/bin/pi-web-ui.mjs +18 -6
- package/dist/server/agent-service.js +497 -120
- package/dist/server/auth-cookie.js +26 -0
- package/dist/server/claim-files-tool.js +6 -7
- package/dist/server/client-state.js +65 -6
- package/dist/server/conversation-read-tool.js +2 -1
- package/dist/server/delegate-task.js +10 -3
- package/dist/server/dsh/dsh-agent-service.js +43 -1
- package/dist/server/files-service.js +78 -26
- package/dist/server/http-proxy.js +72 -0
- package/dist/server/index.js +62 -7
- package/dist/server/patch-node-pty.js +11 -0
- package/dist/server/plugins.js +1 -0
- package/dist/server/protocol-version.js +1 -1
- package/dist/server/read-tool.js +37 -3
- package/dist/server/resolve-global-sdk.js +72 -0
- package/dist/server/sdk-origin.js +64 -0
- package/dist/server/serialize.js +59 -21
- package/dist/server/settings-service.js +12 -1
- package/dist/server/skill-tool.js +2 -1
- package/dist/server/subagents.js +30 -13
- package/dist/server/terminals.js +38 -18
- package/dist/server/themes.js +15 -0
- package/dist/server/tool-info.js +89 -0
- package/dist/server/tool-manager.js +107 -14
- package/package.json +2 -2
- package/plugins/catalog.json +10 -0
- package/themes/ayu-light.css +136 -0
- package/themes/catppuccin-latte.css +4 -0
- package/themes/catppuccin.css +4 -0
- package/themes/codex.css +136 -0
- package/themes/cyberpunk.css +4 -0
- package/themes/dazzle.css +4 -0
- package/themes/everforest-light.css +136 -0
- package/themes/geist.css +136 -0
- package/themes/gruvbox-light.css +136 -0
- package/themes/kanagawa-lotus.css +136 -0
- package/themes/md-preview.css +4 -0
- package/themes/mist.css +4 -0
- package/themes/nord.css +4 -0
- package/themes/one-dark.css +4 -0
- package/themes/paper.css +4 -0
- package/themes/rose-pine-dawn.css +136 -0
- package/themes/sakura.css +4 -0
- package/themes/solarized-light.css +4 -0
- package/themes/tokyo-night.css +4 -0
- package/themes/white.css +4 -0
- package/web/dist/assets/{TerminalPanel-RACpk6AX.js → TerminalPanel-C1A23aI-.js} +1 -1
- package/web/dist/assets/index-B48QWf0I.js +364 -0
- package/web/dist/assets/index-DGL5OInG.css +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-BgrnP9Jz.js +0 -364
- package/web/dist/assets/index-Cy8dSTZt.css +0 -1
|
@@ -21,3 +21,29 @@ export function isTlsRequest(req) {
|
|
|
21
21
|
export function buildPiWebTokenCookie(encodedValue, maxAge, secure) {
|
|
22
22
|
return `pi_web_token=${encodedValue}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAge}${secure ? "; Secure" : ""}`;
|
|
23
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* `pi_web_token` cookie 值 → 原始口令。
|
|
26
|
+
*
|
|
27
|
+
* 下发时按 `encodeURIComponent` 编码(RFC 6265 的 cookie-value 只允许 ASCII,
|
|
28
|
+
* `=` / `+` / 空格 / 非 ASCII 都必须转义),所以**比对前必须先解码** —— 否则
|
|
29
|
+
* 含这些字符的口令永远匹配不上,表现为「`?token=` 那一次 200,之后所有资源与
|
|
30
|
+
* WS 全 401」(issue #261:base64 口令尾巴上的 `=` 被存成 `%3D`,服务端却拿
|
|
31
|
+
* `%3D` 去和 `=` 比)。
|
|
32
|
+
*
|
|
33
|
+
* 浏览器回来的一定是编码值,但手写 cookie / 旧客户端可能是明文(`=` 在值里
|
|
34
|
+
* 合法,我们按首个 `=` 切分),所以调用方应把「原样」与「解码后」两种候选都
|
|
35
|
+
* 试一遍(见 index.ts 的 requestTokens)。
|
|
36
|
+
*
|
|
37
|
+
* 不是合法百分号编码时原样返回:`decodeURIComponent` 会抛 `URIError`,
|
|
38
|
+
* 而一个手写的脏 cookie 绝不该把请求打成 500。
|
|
39
|
+
*/
|
|
40
|
+
export function decodeCookieToken(raw) {
|
|
41
|
+
if (!raw)
|
|
42
|
+
return "";
|
|
43
|
+
try {
|
|
44
|
+
return decodeURIComponent(raw);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return raw;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -6,18 +6,17 @@
|
|
|
6
6
|
// 强制锁会死锁(见 claim-store.ts 文件头)。先到先得;自己的可刷新,
|
|
7
7
|
// 别人的动不了;过期自动释放 + 发 prompt 心跳续期 + 对话关闭释放。
|
|
8
8
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// 设置行等 web/src 空出来后补(follow-up)。DSH 引擎无 customTool 注册面,
|
|
13
|
-
// 只有 pi 引擎对话能认领(提醒是服务端算的,DSH 照样能看到别人的认领)。
|
|
9
|
+
// 开关走统一工具 tab(AGENT_TOOL_CATALOG 目录 + 设置页开关行;ActiveSet 门控,
|
|
10
|
+
// live 生效):认领是纯 advisory(关掉只会少提醒),默认开。DSH 引擎无 customTool
|
|
11
|
+
// 注册面,只有 pi 引擎对话能认领(提醒是服务端算的,DSH 照样能看到别人的认领)。
|
|
14
12
|
// ---------------------------------------------------------------------------
|
|
15
13
|
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
16
14
|
import { Type } from "typebox";
|
|
17
15
|
import { bilingual, pick } from "./i18n.js";
|
|
16
|
+
import { CLAIM_FILES_TOOL_NAME } from "./tool-manager.js";
|
|
18
17
|
import { CLAIM_TTL_MS, resolveClaimPath } from "./claim-store.js";
|
|
19
|
-
/**
|
|
20
|
-
export
|
|
18
|
+
/** 工具名(唯一定义在 tool-manager.ts,本模块 re-export 供单测沿用,模式同 present-files-tool.ts)。 */
|
|
19
|
+
export { CLAIM_FILES_TOOL_NAME };
|
|
21
20
|
function fmtTtl(ms) {
|
|
22
21
|
const m = Math.round(ms / 60000);
|
|
23
22
|
return m >= 60 ? `${Math.floor(m / 60)}h${m % 60 ? `${m % 60}m` : ""}` : `${m}m`;
|
|
@@ -12,6 +12,40 @@ import { normalizeSoftCapByModel, normalizeSoftCapTokens } from "./soft-cap.js";
|
|
|
12
12
|
import { deriveLegacy, legacyToDisabled, normalizeDisabledAgentTools } from "./tool-manager.js";
|
|
13
13
|
/** 大模型 API 出错自动重试次数的默认值(SDK 默认 3)。 */
|
|
14
14
|
export const DEFAULT_RETRY_MAX_ATTEMPTS = 6;
|
|
15
|
+
/** 工具执行看门狗超时的默认值(默认 20 分钟;环境变量 PI_WEB_TOOL_TIMEOUT_MS 覆盖)。 */
|
|
16
|
+
export const DEFAULT_TOOL_WATCHDOG_TIMEOUT_MS = (() => {
|
|
17
|
+
const v = Number(process.env.PI_WEB_TOOL_TIMEOUT_MS);
|
|
18
|
+
return Number.isFinite(v) && v >= 0 ? v : 20 * 60_000;
|
|
19
|
+
})();
|
|
20
|
+
/** 归一化工具看门狗超时(毫秒):0 = 禁用;非数值、负数或空值回落默认值,非负整数保留。 */
|
|
21
|
+
export function normalizeToolWatchdogTimeoutMs(v) {
|
|
22
|
+
if (v === null || v === undefined || v === "")
|
|
23
|
+
return DEFAULT_TOOL_WATCHDOG_TIMEOUT_MS;
|
|
24
|
+
const n = Math.floor(Number(v));
|
|
25
|
+
if (!Number.isFinite(n) || n < 0)
|
|
26
|
+
return DEFAULT_TOOL_WATCHDOG_TIMEOUT_MS;
|
|
27
|
+
return n;
|
|
28
|
+
}
|
|
29
|
+
/** 工具自己声明超时时留给它的余量(毫秒):让工具先自己超时并返回错误,
|
|
30
|
+
* 而不是看门狗先下手把整轮对话 abort 掉。 */
|
|
31
|
+
export const TOOL_WATCHDOG_EXPLICIT_GRACE_MS = 5_000;
|
|
32
|
+
/** 计算单次工具调用的有效看门狗超时(毫秒;≤0 = 不布看门狗)。
|
|
33
|
+
* 基础值来自设置/环境变量(见 DEFAULT_TOOL_WATCHDOG_TIMEOUT_MS);**若工具调用
|
|
34
|
+
* 自己声明了更长的超时**(目前只有 bash 的 `args.timeout`,单位秒),自动顺延到
|
|
35
|
+
* 「工具超时 + 余量」—— 否则 AI 明确要求跑 90 分钟的命令,会被 20 分钟的看门狗
|
|
36
|
+
* 连同整轮对话一起剁掉。纯函数,可单测。 */
|
|
37
|
+
export function effectiveToolWatchdogMs(baseMs, toolName, args) {
|
|
38
|
+
if (!Number.isFinite(baseMs) || baseMs <= 0)
|
|
39
|
+
return 0;
|
|
40
|
+
let ms = baseMs;
|
|
41
|
+
if (toolName === "bash" && args && typeof args === "object") {
|
|
42
|
+
const raw = args.timeout;
|
|
43
|
+
const sec = typeof raw === "number" ? raw : Number(raw);
|
|
44
|
+
if (Number.isFinite(sec) && sec > 0)
|
|
45
|
+
ms = Math.max(ms, sec * 1000 + TOOL_WATCHDOG_EXPLICIT_GRACE_MS);
|
|
46
|
+
}
|
|
47
|
+
return ms;
|
|
48
|
+
}
|
|
15
49
|
/** 归一化插件 AI 工具禁用名单:只收非空字符串(去重,上限 256 个)。
|
|
16
50
|
* 与 disabledAgentTools 不同——插件工具名是动态的(注册才知道),不能按
|
|
17
51
|
* 固定目录校验;未知/已卸载插件的条目刻意保留(重装后仍保持关闭)。
|
|
@@ -285,15 +319,20 @@ export class ClientStateStore {
|
|
|
285
319
|
state.lastCwd = cwd;
|
|
286
320
|
const now = Date.now();
|
|
287
321
|
state.projects = [{ path: cwd, lastUsed: now }, ...state.projects.filter((p) => p.path !== cwd)].slice(0, 30);
|
|
288
|
-
// Opening the workspace again clears its removal tombstone.
|
|
289
|
-
|
|
290
|
-
|
|
322
|
+
// Opening the workspace again clears its removal tombstone across all clients and global settings.
|
|
323
|
+
for (const cState of Object.values(all)) {
|
|
324
|
+
if (cState.removedProjects?.length) {
|
|
325
|
+
cState.removedProjects = cState.removedProjects.filter((p) => p !== cwd);
|
|
326
|
+
}
|
|
291
327
|
}
|
|
292
328
|
this.save();
|
|
293
329
|
}
|
|
294
330
|
/** Drop one workspace from the recent-project list (user-requested removal).
|
|
295
331
|
* Records a tombstone too: pushProjects() re-discovers cwds from session
|
|
296
|
-
* files on every listing, so without it the entry would instantly reappear.
|
|
332
|
+
* files on every listing, so without it the entry would instantly reappear.
|
|
333
|
+
* Recorded in both the requesting client and GLOBAL_SETTINGS_KEY so the
|
|
334
|
+
* removal stays across all browser tabs and server restarts until the user
|
|
335
|
+
* explicitly opens that project again. */
|
|
297
336
|
removeProject(clientId, cwd) {
|
|
298
337
|
const all = this.load();
|
|
299
338
|
const state = (all[clientId] ??= { projects: [] });
|
|
@@ -303,12 +342,28 @@ export class ClientStateStore {
|
|
|
303
342
|
const removed = new Set(state.removedProjects ?? []);
|
|
304
343
|
removed.add(cwd);
|
|
305
344
|
state.removedProjects = [...removed];
|
|
345
|
+
const globalState = (all[ClientStateStore.GLOBAL_SETTINGS_KEY] ??= { projects: [] });
|
|
346
|
+
const globalRemoved = new Set(globalState.removedProjects ?? []);
|
|
347
|
+
globalRemoved.add(cwd);
|
|
348
|
+
globalState.removedProjects = [...globalRemoved];
|
|
349
|
+
for (const [id, cState] of Object.entries(all)) {
|
|
350
|
+
if (id !== ClientStateStore.GLOBAL_SETTINGS_KEY && cState.projects) {
|
|
351
|
+
cState.projects = cState.projects.filter((p) => p.path !== cwd);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
306
354
|
this.save();
|
|
307
355
|
}
|
|
308
356
|
/** Tombstoned projects (explicitly removed by the user) for filtering the
|
|
309
|
-
* merged recent-project list. */
|
|
357
|
+
* merged recent-project list across clients. */
|
|
310
358
|
getRemovedProjects(clientId) {
|
|
311
|
-
|
|
359
|
+
const all = this.load();
|
|
360
|
+
const clientRemoved = all[clientId]?.removedProjects ?? [];
|
|
361
|
+
const globalRemoved = all[ClientStateStore.GLOBAL_SETTINGS_KEY]?.removedProjects ?? [];
|
|
362
|
+
if (globalRemoved.length === 0)
|
|
363
|
+
return clientRemoved;
|
|
364
|
+
if (clientRemoved.length === 0)
|
|
365
|
+
return globalRemoved;
|
|
366
|
+
return [...new Set([...clientRemoved, ...globalRemoved])];
|
|
312
367
|
}
|
|
313
368
|
/** Last-used goal/review prefs for a client, or undefined if never set. */
|
|
314
369
|
getGoalPrefs(clientId) {
|
|
@@ -419,6 +474,7 @@ export class ClientStateStore {
|
|
|
419
474
|
: (stored?.terminalToolsEnabled ?? false),
|
|
420
475
|
terminalBash: stored?.terminalBash ?? false,
|
|
421
476
|
terminalBashIdleMs: stored?.terminalBashIdleMs ?? 15_000,
|
|
477
|
+
toolWatchdogTimeoutMs: normalizeToolWatchdogTimeoutMs(stored?.toolWatchdogTimeoutMs),
|
|
422
478
|
readDirEnabled: stored?.readDirEnabled ?? true,
|
|
423
479
|
editSoftEnabled: stored?.disabledAgentTools !== undefined
|
|
424
480
|
? deriveLegacy(legacyToDisabled(stored)).editSoftEnabled
|
|
@@ -432,6 +488,7 @@ export class ClientStateStore {
|
|
|
432
488
|
devNoCache: stored?.devNoCache,
|
|
433
489
|
autoReload: stored?.autoReload,
|
|
434
490
|
toolsWrap: stored?.toolsWrap ?? true,
|
|
491
|
+
toolImagesEnabled: stored?.toolImagesEnabled ?? true,
|
|
435
492
|
skillsFullText: normalizeSkillList(stored?.skillsFullText),
|
|
436
493
|
visionBridgeEnabled: stored?.visionBridgeEnabled ?? true,
|
|
437
494
|
visionBridgeModel: stored?.visionBridgeModel ?? null,
|
|
@@ -474,6 +531,7 @@ export class ClientStateStore {
|
|
|
474
531
|
terminalToolsEnabled: settings.terminalToolsEnabled ?? cur.terminalToolsEnabled ?? false,
|
|
475
532
|
terminalBash: settings.terminalBash ?? cur.terminalBash ?? false,
|
|
476
533
|
terminalBashIdleMs: settings.terminalBashIdleMs ?? cur.terminalBashIdleMs ?? 15_000,
|
|
534
|
+
toolWatchdogTimeoutMs: normalizeToolWatchdogTimeoutMs(settings.toolWatchdogTimeoutMs ?? cur.toolWatchdogTimeoutMs ?? DEFAULT_TOOL_WATCHDOG_TIMEOUT_MS),
|
|
477
535
|
readDirEnabled: settings.readDirEnabled ?? cur.readDirEnabled ?? true,
|
|
478
536
|
editSoftEnabled: settings.editSoftEnabled ?? cur.editSoftEnabled ?? false,
|
|
479
537
|
questionnaireEnabled: settings.questionnaireEnabled ?? cur.questionnaireEnabled ?? true,
|
|
@@ -483,6 +541,7 @@ export class ClientStateStore {
|
|
|
483
541
|
devNoCache: settings.devNoCache ?? cur.devNoCache,
|
|
484
542
|
autoReload: settings.autoReload ?? cur.autoReload,
|
|
485
543
|
toolsWrap: settings.toolsWrap ?? cur.toolsWrap ?? true,
|
|
544
|
+
toolImagesEnabled: settings.toolImagesEnabled ?? cur.toolImagesEnabled ?? true,
|
|
486
545
|
skillsFullText: normalizeSkillList(settings.skillsFullText ?? cur.skillsFullText),
|
|
487
546
|
visionBridgeEnabled: settings.visionBridgeEnabled ?? cur.visionBridgeEnabled ?? true,
|
|
488
547
|
visionBridgeModel: settings.visionBridgeModel ?? cur.visionBridgeModel ?? null,
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
23
23
|
import { Type } from "typebox";
|
|
24
24
|
import { bilingual, pick } from "./i18n.js";
|
|
25
|
+
import { CONVERSATION_READ_TOOL_NAME } from "./tool-manager.js";
|
|
25
26
|
import { extractTouches, formatTouchEntry, formatTouchesCompact, toolCallRefsOfContent, unionTouchLists, } from "./conversation-touches.js";
|
|
26
27
|
/** 由 content 块提 toolCalls 字段(additive;格式化输出不受影响,见接口注释)。 */
|
|
27
28
|
function fillToolCalls(target, content) {
|
|
@@ -366,7 +367,7 @@ export function makeConversationReadTool(host, lang, extras) {
|
|
|
366
367
|
details,
|
|
367
368
|
});
|
|
368
369
|
return defineTool({
|
|
369
|
-
name:
|
|
370
|
+
name: CONVERSATION_READ_TOOL_NAME,
|
|
370
371
|
label: "Read another conversation",
|
|
371
372
|
description: bilingual('Read ANOTHER conversation: a running conversation of this client (including subagents — use its conversation id like "c3", see action=list) or a persisted history session transcript (use its file path). ' +
|
|
372
373
|
'Use it when the user references another chat (a quoted conversation, a pasted conversation id, or "see the other chat about X"). ' +
|
|
@@ -17,9 +17,10 @@
|
|
|
17
17
|
import { Type } from "typebox";
|
|
18
18
|
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
19
19
|
import { bilingual, pick } from "./i18n.js";
|
|
20
|
+
import { DELEGATE_TASK_TOOL_NAME } from "./tool-manager.js";
|
|
20
21
|
import { subagentTitle } from "./subagents.js";
|
|
21
|
-
/**
|
|
22
|
-
export const DELEGATE_TOOL_NAME =
|
|
22
|
+
/** 工具名(唯一值在 tool-manager.ts;本别名保留:前端派单卡片与旧导入沿用它)。 */
|
|
23
|
+
export const DELEGATE_TOOL_NAME = DELEGATE_TASK_TOOL_NAME;
|
|
23
24
|
/** 各段最小长度(trim 后字符数):TASK 必须具体,OUTCOME 必须可验收,其余段不许空着。 */
|
|
24
25
|
const MIN_TASK = 20;
|
|
25
26
|
const MIN_OUTCOME = 10;
|
|
@@ -40,6 +41,7 @@ export function normalizeDelegation(params) {
|
|
|
40
41
|
must_not_do: str(p.must_not_do),
|
|
41
42
|
context: str(p.context),
|
|
42
43
|
model: str(p.model).trim() || undefined,
|
|
44
|
+
persist: typeof p.persist === "boolean" ? p.persist : undefined,
|
|
43
45
|
};
|
|
44
46
|
}
|
|
45
47
|
/**
|
|
@@ -122,6 +124,11 @@ const delegateSchema = Type.Object({
|
|
|
122
124
|
model: Type.Optional(Type.String({
|
|
123
125
|
description: bilingual('Optional model "provider/id" for this delegation; omit = template model → panel default → follow the main conversation model.', "可选:本次派单的子代理模型(provider/id);不传 = 模板模型 → 面板默认 → 跟随主对话模型。"),
|
|
124
126
|
})),
|
|
127
|
+
persist: Type.Optional(Type.Boolean({
|
|
128
|
+
description: bilingual("Optional: persist this conversation to disk as a regular session (saved in history, resumable). " +
|
|
129
|
+
"Default false (lightweight in-memory subagent). Use true for tasks that need long-term retention.", "可选:是否将该对话持久化落盘为普通对话(保存在历史会话中,可随时回顾与继续)。" +
|
|
130
|
+
"默认 false(轻量内存子代理)。需要长期留存的任务建议设为 true。"),
|
|
131
|
+
})),
|
|
125
132
|
});
|
|
126
133
|
/** 结构化派单工具:校验六段 → 拼装标准 prompt → 走 host.spawnSubagent 真子代理。 */
|
|
127
134
|
export function makeDelegateTaskTool(host, lang) {
|
|
@@ -163,7 +170,7 @@ export function makeDelegateTaskTool(host, lang) {
|
|
|
163
170
|
// 创建失败都经由 host 抛错),转成返回文本让 AI 能读到原因并修正重试。
|
|
164
171
|
let convId;
|
|
165
172
|
try {
|
|
166
|
-
convId = await host.spawnSubagent(prompt, "delegate", ctx.cwd, input.agent, input.model);
|
|
173
|
+
convId = await host.spawnSubagent(prompt, "delegate", ctx.cwd, input.agent, input.model, undefined, input.persist);
|
|
167
174
|
}
|
|
168
175
|
catch (err) {
|
|
169
176
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -29,12 +29,14 @@ import { basename, dirname, join, resolve, sep } from "node:path";
|
|
|
29
29
|
import { randomUUID } from "node:crypto";
|
|
30
30
|
import { homedir } from "node:os";
|
|
31
31
|
import { BgServerTracker } from "../bg-servers.js";
|
|
32
|
-
import { ClientStateStore, DEFAULT_RETRY_MAX_ATTEMPTS } from "../client-state.js";
|
|
32
|
+
import { ClientStateStore, DEFAULT_RETRY_MAX_ATTEMPTS, normalizeToolWatchdogTimeoutMs } from "../client-state.js";
|
|
33
33
|
import { normalizeUiLayout } from "../client-state.js";
|
|
34
34
|
import { FilesService, workspacePath, desktopDirWire } from "../files-service.js";
|
|
35
35
|
import { QuiesceRejectedError } from "../agent-service.js";
|
|
36
36
|
import { NATIVE_COMMANDS, parseSlash } from "../slash-commands.js";
|
|
37
37
|
import { bilingual, pick, resolveServerLang } from "../i18n.js";
|
|
38
|
+
// 工具定义说明的归一化(工具卡右键 → 「显示工具详细信息」,见 getToolInfo)。
|
|
39
|
+
import { normalizeToolInfo, unsupportedToolInfo } from "../tool-info.js";
|
|
38
40
|
import { TerminalManager, loadCommands, saveCommandsFile } from "../terminals.js";
|
|
39
41
|
import { saveUpload } from "../uploads.js";
|
|
40
42
|
import { checkAll as checkAllUpdates, collectTargets, resolveNpmRegistry } from "../update-check.js";
|
|
@@ -122,12 +124,14 @@ const DEFAULT_SETTINGS = {
|
|
|
122
124
|
terminalToolsEnabled: false,
|
|
123
125
|
terminalBash: false,
|
|
124
126
|
terminalBashIdleMs: 15_000,
|
|
127
|
+
toolWatchdogTimeoutMs: 20 * 60_000,
|
|
125
128
|
editSoftEnabled: false,
|
|
126
129
|
questionnaireEnabled: true,
|
|
127
130
|
goalModeEnabled: true,
|
|
128
131
|
parallelReminderEnabled: true,
|
|
129
132
|
thinkingWrap: false,
|
|
130
133
|
toolsWrap: true,
|
|
134
|
+
toolImagesEnabled: true,
|
|
131
135
|
disabledPlugins: [],
|
|
132
136
|
uiLayout: {},
|
|
133
137
|
reviewPrompt: "",
|
|
@@ -267,12 +271,14 @@ export class DshClientSession {
|
|
|
267
271
|
terminalToolsEnabled: savedSettings.terminalToolsEnabled,
|
|
268
272
|
terminalBash: savedSettings.terminalBash,
|
|
269
273
|
terminalBashIdleMs: savedSettings.terminalBashIdleMs,
|
|
274
|
+
toolWatchdogTimeoutMs: savedSettings.toolWatchdogTimeoutMs ?? 20 * 60_000,
|
|
270
275
|
editSoftEnabled: savedSettings.editSoftEnabled,
|
|
271
276
|
questionnaireEnabled: savedSettings.questionnaireEnabled ?? true,
|
|
272
277
|
goalModeEnabled: savedSettings.goalModeEnabled ?? true,
|
|
273
278
|
parallelReminderEnabled: savedSettings.parallelReminderEnabled ?? true,
|
|
274
279
|
thinkingWrap: savedSettings.thinkingWrap,
|
|
275
280
|
toolsWrap: savedSettings.toolsWrap,
|
|
281
|
+
toolImagesEnabled: savedSettings.toolImagesEnabled ?? true,
|
|
276
282
|
disabledPlugins: savedSettings.disabledPlugins ?? [],
|
|
277
283
|
uiLayout: normalizeUiLayout(savedSettings.uiLayout),
|
|
278
284
|
reviewPrompt: savedSettings.reviewPrompt,
|
|
@@ -563,16 +569,22 @@ export class DshClientSession {
|
|
|
563
569
|
// 记下待答问卷:`question_pending` 只推给「当时在线」的连接,刷新页面
|
|
564
570
|
// /WS 重连后靠快照(UiState.pendingQuestion)把对话框恢复出来。
|
|
565
571
|
// DSH 的提问桥是 runtime 级的(无 conversationId),故不分对话。
|
|
572
|
+
const dshConv = this.convs.get(this.activeId);
|
|
573
|
+
const conversationTitle = dshConv?.title;
|
|
566
574
|
this.pendingQuestion = {
|
|
567
575
|
id: params0.id,
|
|
568
576
|
...(typeof params0.deadline === "number" ? { deadline: params0.deadline } : {}),
|
|
569
577
|
questions: mapped,
|
|
578
|
+
...(this.activeId ? { conversationId: this.activeId } : {}),
|
|
579
|
+
...(conversationTitle ? { conversationTitle } : {}),
|
|
570
580
|
};
|
|
571
581
|
this.emit({
|
|
572
582
|
type: "question_pending",
|
|
573
583
|
id: params0.id,
|
|
574
584
|
...(typeof params0.deadline === "number" ? { deadline: params0.deadline } : {}),
|
|
575
585
|
questions: mapped,
|
|
586
|
+
...(this.activeId ? { conversationId: this.activeId } : {}),
|
|
587
|
+
...(conversationTitle ? { conversationTitle } : {}),
|
|
576
588
|
});
|
|
577
589
|
}
|
|
578
590
|
else if (method === "tools.call.request") {
|
|
@@ -658,6 +670,28 @@ export class DshClientSession {
|
|
|
658
670
|
console.error(`[dsh] syncPluginTools 失败 (client=${this.clientId}):`, err);
|
|
659
671
|
}
|
|
660
672
|
}
|
|
673
|
+
/**
|
|
674
|
+
* 取一条工具的**定义说明**(工具卡右键 → 「显示工具详细信息」)→ `tool_info`。
|
|
675
|
+
*
|
|
676
|
+
* DSH 侧的定义来自运行时(`tools/list`:DSH 原生工具 + 桥接进来的插件工具)。
|
|
677
|
+
* 拿不到(运行时没起来 / RPC 报错)回 `unsupported`,让前端说「当前引擎不支持」
|
|
678
|
+
* 而不是「未找到工具定义」—— 后者会让用户以为工具名写错了。
|
|
679
|
+
*/
|
|
680
|
+
async getToolInfo(name) {
|
|
681
|
+
if (!this.runtime.alive) {
|
|
682
|
+
this.emit({ type: "tool_info", ...unsupportedToolInfo(name) });
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
try {
|
|
686
|
+
const res = await this.runtime.listTools();
|
|
687
|
+
const found = (res.tools ?? []).find((t) => t.name === name);
|
|
688
|
+
this.emit({ type: "tool_info", ...normalizeToolInfo(name, found) });
|
|
689
|
+
}
|
|
690
|
+
catch (err) {
|
|
691
|
+
console.error(`[dsh] listTools 失败 (client=${this.clientId}):`, err);
|
|
692
|
+
this.emit({ type: "tool_info", ...unsupportedToolInfo(name) });
|
|
693
|
+
}
|
|
694
|
+
}
|
|
661
695
|
/** 从运行时拉取技能清单 → 缓存 → 重推 settings_state(enabled 由 disabledSkills 推导)。 */
|
|
662
696
|
async refreshSkillsFromRuntime() {
|
|
663
697
|
if (!this.runtime.alive)
|
|
@@ -2590,6 +2624,7 @@ export class DshClientSession {
|
|
|
2590
2624
|
terminalToolsEnabled: this.settings.terminalToolsEnabled,
|
|
2591
2625
|
terminalBash: this.settings.terminalBash,
|
|
2592
2626
|
terminalBashIdleMs: this.settings.terminalBashIdleMs,
|
|
2627
|
+
toolWatchdogTimeoutMs: this.settings.toolWatchdogTimeoutMs,
|
|
2593
2628
|
// DSH 引擎无 customTool 注册面(工具来自 shipped preset),read 目录覆盖面不存在。
|
|
2594
2629
|
readDirEnabled: true,
|
|
2595
2630
|
editSoftEnabled: this.settings.editSoftEnabled,
|
|
@@ -2603,6 +2638,7 @@ export class DshClientSession {
|
|
|
2603
2638
|
parallelReminderEnabled: this.settings.parallelReminderEnabled,
|
|
2604
2639
|
thinkingWrap: this.settings.thinkingWrap,
|
|
2605
2640
|
toolsWrap: this.settings.toolsWrap,
|
|
2641
|
+
toolImagesEnabled: this.settings.toolImagesEnabled,
|
|
2606
2642
|
// DSH 无 skill 全文注入概念,给空保协议完整。
|
|
2607
2643
|
skillsFullText: [],
|
|
2608
2644
|
visionBridgeEnabled: false,
|
|
@@ -2659,6 +2695,8 @@ export class DshClientSession {
|
|
|
2659
2695
|
this.settings.terminalBash = partial.terminalBash;
|
|
2660
2696
|
if (partial.terminalBashIdleMs !== undefined)
|
|
2661
2697
|
this.settings.terminalBashIdleMs = partial.terminalBashIdleMs;
|
|
2698
|
+
if (partial.toolWatchdogTimeoutMs !== undefined)
|
|
2699
|
+
this.settings.toolWatchdogTimeoutMs = normalizeToolWatchdogTimeoutMs(partial.toolWatchdogTimeoutMs);
|
|
2662
2700
|
if (partial.editSoftEnabled !== undefined)
|
|
2663
2701
|
this.settings.editSoftEnabled = partial.editSoftEnabled;
|
|
2664
2702
|
if (partial.questionnaireEnabled !== undefined)
|
|
@@ -2671,6 +2709,8 @@ export class DshClientSession {
|
|
|
2671
2709
|
this.settings.thinkingWrap = partial.thinkingWrap;
|
|
2672
2710
|
if (partial.toolsWrap !== undefined)
|
|
2673
2711
|
this.settings.toolsWrap = partial.toolsWrap;
|
|
2712
|
+
if (partial.toolImagesEnabled !== undefined)
|
|
2713
|
+
this.settings.toolImagesEnabled = partial.toolImagesEnabled;
|
|
2674
2714
|
if (partial.disabledPlugins !== undefined)
|
|
2675
2715
|
this.settings.disabledPlugins = partial.disabledPlugins;
|
|
2676
2716
|
if (partial.uiLayout !== undefined)
|
|
@@ -2706,6 +2746,7 @@ export class DshClientSession {
|
|
|
2706
2746
|
parallelReminderEnabled: this.settings.parallelReminderEnabled,
|
|
2707
2747
|
thinkingWrap: this.settings.thinkingWrap,
|
|
2708
2748
|
toolsWrap: this.settings.toolsWrap,
|
|
2749
|
+
toolImagesEnabled: this.settings.toolImagesEnabled,
|
|
2709
2750
|
disabledPlugins: this.settings.disabledPlugins,
|
|
2710
2751
|
uiLayout: normalizeUiLayout(this.settings.uiLayout),
|
|
2711
2752
|
reviewPrompt: this.settings.reviewPrompt,
|
|
@@ -3071,6 +3112,7 @@ export class DshClientSession {
|
|
|
3071
3112
|
editSoftEnabled: this.settings.editSoftEnabled,
|
|
3072
3113
|
thinkingWrap: this.settings.thinkingWrap,
|
|
3073
3114
|
toolsWrap: this.settings.toolsWrap,
|
|
3115
|
+
toolImagesEnabled: this.settings.toolImagesEnabled,
|
|
3074
3116
|
disabledPlugins: this.settings.disabledPlugins,
|
|
3075
3117
|
uiLayout: normalizeUiLayout(this.settings.uiLayout),
|
|
3076
3118
|
reviewPrompt: this.settings.reviewPrompt,
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* 全部为无状态 fs 操作 + 两个自持的 watcher(当前列出目录、git dir),
|
|
6
6
|
* 经 FilesHost 回调与 ClientSession 解耦。
|
|
7
7
|
*/
|
|
8
|
-
import { mkdirSync, readFileSync, statSync, writeFileSync, watch } from "node:fs";
|
|
8
|
+
import { Dirent, mkdirSync, readFileSync, statSync, writeFileSync, watch } from "node:fs";
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
10
|
import { resolve, relative, sep } from "node:path";
|
|
11
11
|
import { pick } from "./i18n.js";
|
|
@@ -57,7 +57,7 @@ export function isAbsoluteWirePath(p) {
|
|
|
57
57
|
* 不产生结尾斜杠,这里只对补全/直接输入做防御性清理。 */
|
|
58
58
|
export function normWirePath(p) {
|
|
59
59
|
if (p.endsWith("/") && p !== "/")
|
|
60
|
-
return p.
|
|
60
|
+
return p.replace(/\/+$/, "") || "/";
|
|
61
61
|
return p;
|
|
62
62
|
}
|
|
63
63
|
/** wire 绝对路径("C:/Users/x" / "/Users/x" / "C:")→ 原生绝对路径。
|
|
@@ -74,7 +74,7 @@ export function wireToAbs(wire) {
|
|
|
74
74
|
/** 机器浏览模式下某目录的父级 wire 路径:盘符根("C:")→ 机器根;posix "/" 无父级。 */
|
|
75
75
|
export function absoluteParent(wire) {
|
|
76
76
|
const s = normWirePath(wire);
|
|
77
|
-
if (s === "" || s === MACHINE_ROOT)
|
|
77
|
+
if (s === "" || s === MACHINE_ROOT || s === "/")
|
|
78
78
|
return null;
|
|
79
79
|
const i = s.lastIndexOf("/");
|
|
80
80
|
if (i < 0)
|
|
@@ -150,8 +150,9 @@ export function workspacePath(root, raw) {
|
|
|
150
150
|
* system dirs (C:\$Recycle.Bin, Program Files internals, OneDrive placeholders)
|
|
151
151
|
* throw EPERM/EACCES on open — that must not kill the panel, so it degrades
|
|
152
152
|
* to an empty listing plus a warning. Directory symlinks/junctions are
|
|
153
|
-
* followed
|
|
154
|
-
*
|
|
153
|
+
* followed (all platforms — Android/Termux's ~/storage/* entries are symlinks
|
|
154
|
+
* too) so folder links stay navigable; broken links still show as files
|
|
155
|
+
* instead of vanishing. The cap is 4x posix and truncation is reported
|
|
155
156
|
* via `truncated` instead of happening silently.
|
|
156
157
|
*/
|
|
157
158
|
async function readDirForUI(abs, rel) {
|
|
@@ -178,10 +179,10 @@ async function readDirForUI(abs, rel) {
|
|
|
178
179
|
if (ignored.has(d.name))
|
|
179
180
|
continue;
|
|
180
181
|
let type;
|
|
181
|
-
if (
|
|
182
|
-
// mklink /D
|
|
183
|
-
//
|
|
184
|
-
//
|
|
182
|
+
if (d.isSymbolicLink()) {
|
|
183
|
+
// Symlinks (mklink /D reparse points on win32, but also the directory
|
|
184
|
+
// links under Termux's ~/storage) are followed on ALL platforms so
|
|
185
|
+
// folder links stay navigable; broken links still show as files.
|
|
185
186
|
try {
|
|
186
187
|
const st = await fs.stat(join(abs, d.name));
|
|
187
188
|
type = st.isDirectory() ? "dir" : "file";
|
|
@@ -210,6 +211,26 @@ async function readDirForUI(abs, rel) {
|
|
|
210
211
|
out.length = MAX;
|
|
211
212
|
return { entries: out, truncated };
|
|
212
213
|
}
|
|
214
|
+
/** dirent → FileEntry classification: directory symlinks are resolved via
|
|
215
|
+
* stat() on ALL platforms (#262 — Termux's ~/storage/* entries are symlinks;
|
|
216
|
+
* the directory-only cwd picker renders an empty list if they type as files). */
|
|
217
|
+
async function classifyDirent(d, absDir) {
|
|
218
|
+
const fs = await import("node:fs/promises");
|
|
219
|
+
const { join } = await import("node:path");
|
|
220
|
+
let type;
|
|
221
|
+
if (d.isSymbolicLink()) {
|
|
222
|
+
try {
|
|
223
|
+
type = (await fs.stat(join(absDir, d.name))).isDirectory() ? "dir" : "file";
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
type = "file"; // broken link
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
type = d.isDirectory() ? "dir" : "file";
|
|
231
|
+
}
|
|
232
|
+
return { name: d.name, type };
|
|
233
|
+
}
|
|
213
234
|
export class FilesService {
|
|
214
235
|
host;
|
|
215
236
|
// ---- 当前列出目录的 watcher ----
|
|
@@ -248,6 +269,24 @@ export class FilesService {
|
|
|
248
269
|
}
|
|
249
270
|
return out;
|
|
250
271
|
}
|
|
272
|
+
// Android/Termux: "/" itself is not listable (even `ls /` is denied),
|
|
273
|
+
// so fall back to $HOME + shared storage as the machine-root landing
|
|
274
|
+
// spots — otherwise machine browsing is a dead end on Android.
|
|
275
|
+
try {
|
|
276
|
+
await fsp.readdir("/");
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
const home = homedir();
|
|
280
|
+
const out = [{ name: home, path: home, type: "dir" }];
|
|
281
|
+
try {
|
|
282
|
+
await fsp.readdir("/storage/emulated/0");
|
|
283
|
+
out.push({ name: "/storage/emulated/0", path: "/storage/emulated/0", type: "dir" });
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
// no shared storage
|
|
287
|
+
}
|
|
288
|
+
return out;
|
|
289
|
+
}
|
|
251
290
|
return [{ name: "/", path: "/", type: "dir" }];
|
|
252
291
|
}
|
|
253
292
|
/** 单字段错误文本的语言(host 未接线时英文默认)。 */
|
|
@@ -286,7 +325,13 @@ export class FilesService {
|
|
|
286
325
|
async listFiles(relPath) {
|
|
287
326
|
const { resolve, sep, relative } = await import("node:path");
|
|
288
327
|
const root = resolve(this.host.getCwd());
|
|
289
|
-
|
|
328
|
+
let raw = relPath ?? "";
|
|
329
|
+
// Expand a leading "~/" (path-bar input) to the home directory — same
|
|
330
|
+
// rules as completePath/makeDir. The result is absolute, so it lands in
|
|
331
|
+
// the machine-browse branch below; wire paths always use "/".
|
|
332
|
+
if (raw === "~" || raw.startsWith("~/") || raw.startsWith("~\\")) {
|
|
333
|
+
raw = (raw === "~" ? homedir() : homedir() + raw.slice(1)).split(sep).join("/");
|
|
334
|
+
}
|
|
290
335
|
// ---- 机器根(此电脑:盘符列表)—— 工作区之上的虚拟层 ----
|
|
291
336
|
if (raw === MACHINE_ROOT || raw === MACHINE_ROOT + "/") {
|
|
292
337
|
const entries = await this.machineRootEntries();
|
|
@@ -342,11 +387,12 @@ export class FilesService {
|
|
|
342
387
|
else {
|
|
343
388
|
this.watchDir(target, rel);
|
|
344
389
|
}
|
|
390
|
+
const rootWire = normWirePath(root.split(sep).join("/"));
|
|
345
391
|
this.host.emit({
|
|
346
392
|
type: "files",
|
|
347
393
|
path: rel === "" ? "" : rel,
|
|
348
|
-
// 工作区根也允许「上一级」→
|
|
349
|
-
parent: rel === "" ?
|
|
394
|
+
// 工作区根也允许「上一级」→ 项目文件夹的上一级(盘符根则到机器根,posix 根无父级)。
|
|
395
|
+
parent: rel === "" ? absoluteParent(rootWire) : rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : "",
|
|
350
396
|
entries,
|
|
351
397
|
truncated,
|
|
352
398
|
});
|
|
@@ -399,14 +445,25 @@ export class FilesService {
|
|
|
399
445
|
if (ignored.has(d.name))
|
|
400
446
|
continue;
|
|
401
447
|
const childRel = rel ? `${rel}/${d.name}` : d.name;
|
|
448
|
+
let isDir = d.isDirectory();
|
|
449
|
+
if (d.isSymbolicLink()) {
|
|
450
|
+
// Follow directory symlinks (Termux's ~/storage/*); the depth
|
|
451
|
+
// cap doubles as a symlink-cycle guard, budgets bound the cost.
|
|
452
|
+
try {
|
|
453
|
+
isDir = (await fsp.stat(join(abs, d.name))).isDirectory();
|
|
454
|
+
}
|
|
455
|
+
catch {
|
|
456
|
+
isDir = false; // broken link — treat as file
|
|
457
|
+
}
|
|
458
|
+
}
|
|
402
459
|
if (d.name.toLowerCase().includes(q)) {
|
|
403
460
|
results.push({
|
|
404
461
|
path: childRel,
|
|
405
462
|
name: d.name,
|
|
406
|
-
type:
|
|
463
|
+
type: isDir ? "dir" : "file",
|
|
407
464
|
});
|
|
408
465
|
}
|
|
409
|
-
if (
|
|
466
|
+
if (isDir) {
|
|
410
467
|
await walk(join(abs, d.name), childRel, depth + 1);
|
|
411
468
|
}
|
|
412
469
|
}
|
|
@@ -1344,13 +1401,8 @@ export class FilesService {
|
|
|
1344
1401
|
empty();
|
|
1345
1402
|
return;
|
|
1346
1403
|
}
|
|
1347
|
-
const items = dirents
|
|
1348
|
-
.
|
|
1349
|
-
.map((d) => ({
|
|
1350
|
-
name: d.name,
|
|
1351
|
-
path: `${drive}/${d.name}`,
|
|
1352
|
-
type: (d.isDirectory() ? "dir" : "file"),
|
|
1353
|
-
}))
|
|
1404
|
+
const items = (await Promise.all(dirents.filter((d) => !ignoredEntries().has(d.name)).map((d) => classifyDirent(d, `${drive}\\`))))
|
|
1405
|
+
.map((e) => ({ ...e, path: `${drive}/${e.name}` }))
|
|
1354
1406
|
.sort((a, b) => {
|
|
1355
1407
|
const aHidden = a.name.startsWith(".");
|
|
1356
1408
|
const bHidden = b.name.startsWith(".");
|
|
@@ -1391,14 +1443,14 @@ export class FilesService {
|
|
|
1391
1443
|
return;
|
|
1392
1444
|
}
|
|
1393
1445
|
const { join } = await import("node:path");
|
|
1394
|
-
const completions = dirents
|
|
1446
|
+
const completions = (await Promise.all(dirents
|
|
1395
1447
|
.filter((d) => d.name.startsWith(prefix) && !ignoredEntries().has(d.name))
|
|
1396
|
-
.map((d) => (
|
|
1397
|
-
|
|
1448
|
+
.map((d) => classifyDirent(d, dirPart))))
|
|
1449
|
+
.map((e) => ({
|
|
1450
|
+
...e,
|
|
1398
1451
|
// Windows users type backslashes — normalize the completion to the
|
|
1399
1452
|
// wire format ("/") so the picked path round-trips cleanly.
|
|
1400
|
-
path: IS_WIN32 ? join(dirPart,
|
|
1401
|
-
type: (d.isDirectory() ? "dir" : "file"),
|
|
1453
|
+
path: IS_WIN32 ? join(dirPart, e.name).split(sep).join("/") : dirPart + e.name,
|
|
1402
1454
|
}))
|
|
1403
1455
|
.sort((a, b) => {
|
|
1404
1456
|
const aHidden = a.name.startsWith(".");
|