pi-web-ui 0.31.0 → 0.34.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/bin/pi-web-ui.mjs +291 -2
- package/dist/server/agent-service.js +245 -126
- package/dist/server/client-state.js +29 -0
- package/dist/server/index.js +96 -48
- package/dist/server/model-admin.js +94 -0
- package/dist/server/plugins.js +368 -0
- package/dist/server/protocol-version.js +1 -1
- package/dist/server/settings-service.js +19 -0
- package/dist/server/terminals.js +485 -6
- package/package.json +96 -96
- package/themes/md-preview.css +7026 -6911
- package/themes/white.css +7087 -6972
- package/web/dist/assets/{TerminalPanel-CVOwLwwF.js → TerminalPanel-DXxlbXLf.js} +1 -1
- package/web/dist/assets/index-8y0w43os.css +10 -0
- package/web/dist/assets/index-CNrY4Azz.js +19 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-BYW5b0ZE.js +0 -19
- package/web/dist/assets/index-C9LigvHH.css +0 -10
|
@@ -11,12 +11,13 @@
|
|
|
11
11
|
* so reconnects just re-request a snapshot.
|
|
12
12
|
*/
|
|
13
13
|
import { spawn } from "node:child_process";
|
|
14
|
-
import { existsSync, readFileSync, statSync, writeFileSync, mkdirSync, watch, } from "node:fs";
|
|
14
|
+
import { existsSync, readFileSync, rmSync, statSync, writeFileSync, mkdirSync, watch, } from "node:fs";
|
|
15
15
|
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
16
16
|
import { fileURLToPath } from "node:url";
|
|
17
17
|
import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createBashTool, createLocalBashOperations, defineTool, getAgentDir, ModelRuntime, SessionManager, VERSION, } from "@earendil-works/pi-coding-agent";
|
|
18
18
|
import { Type } from "typebox";
|
|
19
19
|
import { BgServerTracker } from "./bg-servers.js";
|
|
20
|
+
import { syncPluginToolsIntoSession } from "./plugins.js";
|
|
20
21
|
import { SettingsService } from "./settings-service.js";
|
|
21
22
|
import { GoalService } from "./goal-service.js";
|
|
22
23
|
import { SlashCommandsService, parseSlash } from "./slash-commands.js";
|
|
@@ -24,7 +25,7 @@ import { ModelAdminService } from "./model-admin.js";
|
|
|
24
25
|
import { FilesService, workspacePath } from "./files-service.js";
|
|
25
26
|
import { isExtensionDisabled, ClientStateStore, } from "./client-state.js";
|
|
26
27
|
import { saveUpload } from "./uploads.js";
|
|
27
|
-
import { makePersistentTerminalTools, TERMINAL_TOOLS_GUIDANCE, TERMINAL_TOOL_NAMES, } from "./terminals.js";
|
|
28
|
+
import { makePersistentTerminalTools, makeTerminalBashTool, stripAnsi, TERMINAL_TOOLS_GUIDANCE, TERMINAL_TOOL_NAMES, } from "./terminals.js";
|
|
28
29
|
import { WebUIContext } from "./webui-context.js";
|
|
29
30
|
import { buildAttachmentMessages, parseModelSpec, } from "./attachments.js";
|
|
30
31
|
import { serializeMessage, serializeStreamingMessage, } from "./serialize.js";
|
|
@@ -126,6 +127,48 @@ function makeKillableBashTool(cwd, kills) {
|
|
|
126
127
|
execute: (toolCallId, params, signal, onUpdate) => tool.execute(toolCallId, params, signal, onUpdate),
|
|
127
128
|
};
|
|
128
129
|
}
|
|
130
|
+
/**
|
|
131
|
+
* 动态分流 bash:调用时按设置决定走哪套实现——「终端接管 bash」开关因此
|
|
132
|
+
* 即时生效(customTools 在 runtime 创建时固定,不能在创建时二选一)。
|
|
133
|
+
*/
|
|
134
|
+
function makeAdaptiveBashTool(killable, terminalBacked, useTerminal) {
|
|
135
|
+
return {
|
|
136
|
+
...killable,
|
|
137
|
+
execute: (id, params, signal, onUpdate, ctx) => (useTerminal() ? terminalBacked : killable).execute(id, params, signal, onUpdate, ctx),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* 插件结构化工具 → SDK ToolDefinition。
|
|
142
|
+
* execute 返回值宽容处理:{content,details} 原样收编;字符串/对象包成文本块。
|
|
143
|
+
*/
|
|
144
|
+
function pluginToolToDefinition(tool) {
|
|
145
|
+
const normalize = (result) => {
|
|
146
|
+
if (result &&
|
|
147
|
+
typeof result === "object" &&
|
|
148
|
+
Array.isArray(result.content)) {
|
|
149
|
+
return result;
|
|
150
|
+
}
|
|
151
|
+
const text = typeof result === "string" ? result : JSON.stringify(result ?? null, null, 2);
|
|
152
|
+
return { content: [{ type: "text", text }] };
|
|
153
|
+
};
|
|
154
|
+
return {
|
|
155
|
+
name: tool.name,
|
|
156
|
+
label: tool.label ?? tool.name,
|
|
157
|
+
description: tool.description,
|
|
158
|
+
promptSnippet: tool.promptSnippet,
|
|
159
|
+
promptGuidelines: tool.promptGuidelines,
|
|
160
|
+
parameters: (tool.parameters ?? {
|
|
161
|
+
type: "object",
|
|
162
|
+
properties: {},
|
|
163
|
+
}),
|
|
164
|
+
execute: async (toolCallId, params, signal, onUpdate) => {
|
|
165
|
+
const raw = await tool.execute(toolCallId, params, signal, onUpdate
|
|
166
|
+
? (partial) => onUpdate(normalize(partial))
|
|
167
|
+
: undefined);
|
|
168
|
+
return normalize(raw);
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|
|
129
172
|
/**
|
|
130
173
|
* Cheap per-message discriminator for the serialization cache key. Persisted
|
|
131
174
|
* message content never changes, so this is stable across snapshots, while
|
|
@@ -272,6 +315,13 @@ export class ClientSession {
|
|
|
272
315
|
flushSnapshot: () => this.flushSnapshot(),
|
|
273
316
|
isDisposed: () => this.disposed,
|
|
274
317
|
});
|
|
318
|
+
/** index.ts 注入(经 AgentService 拷贝到每个新会话):把 SDK 工具执行事件转发给
|
|
319
|
+
* 插件(PluginManager.emitToolEvent)。未设置时不做任何事。 */
|
|
320
|
+
onToolEvent = undefined;
|
|
321
|
+
/** index.ts 注入:读取插件当前注册的 AI 工具(attach 时拷贝到每个新会话)。 */
|
|
322
|
+
pluginToolsProvider = undefined;
|
|
323
|
+
/** 上一轮注入会话的插件工具名集合(用于检测注销/移除)。 */
|
|
324
|
+
appliedPluginToolNames = new Set();
|
|
275
325
|
/** The active conversation (all session operations target it). */
|
|
276
326
|
get conv() {
|
|
277
327
|
const conv = this.convs.get(this.activeId);
|
|
@@ -299,7 +349,68 @@ export class ClientSession {
|
|
|
299
349
|
return (conversationId ? this.convs.get(conversationId) : this.conv)?.cwd ?? this.cwd;
|
|
300
350
|
}
|
|
301
351
|
makeTerminalManager(conversationId, cwd) {
|
|
302
|
-
|
|
352
|
+
const mgr = new TerminalManager((msg) => this.emitTerminal(conversationId, msg), cwd);
|
|
353
|
+
// 终端活力检测:AI 触碰过的终端静默 ≥ 阈值(PI_WEB_TERMINAL_IDLE_MS,
|
|
354
|
+
// 默认 15s)且该对话正在运行时,注入一条 steer 消息唤醒 AI 去检查。
|
|
355
|
+
mgr.onAgentIdle = (terminalId, idleMs, title) => this.notifyTerminalIdle(conversationId, terminalId, idleMs, title);
|
|
356
|
+
return mgr;
|
|
357
|
+
}
|
|
358
|
+
/** 终端活力提醒:仅在该对话正在流式运行时注入(sendUserMessage 在流式中
|
|
359
|
+
* 即 steer 语义——当前回合结算后送达,agent 立即响应);空闲时不打扰。
|
|
360
|
+
* 一次性语义由 TerminalManager 保证(触发后解除武装,agent 再次触碰才
|
|
361
|
+
* 重新计时),不会反复刷屏。 */
|
|
362
|
+
notifyTerminalIdle(conversationId, terminalId, idleMs, title) {
|
|
363
|
+
const conv = this.convs.get(conversationId);
|
|
364
|
+
if (!conv || this.disposed)
|
|
365
|
+
return;
|
|
366
|
+
if (!conv.runtime.session.isStreaming)
|
|
367
|
+
return;
|
|
368
|
+
const seconds = Math.max(1, Math.round(idleMs / 1000));
|
|
369
|
+
void conv.runtime.session
|
|
370
|
+
.sendUserMessage(`(系统自动提醒:你启动的终端「${title}」已连续 ${seconds} 秒没有任何新输出。` +
|
|
371
|
+
`进程可能在等待输入、卡住或已挂起。请用 terminal_read 查看它的当前状态;` +
|
|
372
|
+
`若在等交互就用 terminal_input / terminal_key 回应;确认不再需要就 terminal_close 关掉它。)`)
|
|
373
|
+
.catch(() => {
|
|
374
|
+
// best effort —— 注入失败不影响终端本身
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* 终端接管的 bash 静默转后台后的完成通知:命令真正结束时主动告诉 AI。
|
|
379
|
+
* 流式中 → sendUserMessage(steer,立即唤醒处理);空闲时 → sendCustomMessage
|
|
380
|
+
* nextTurn 排队(不唤醒 agent、不耗 token,下次对话自动带上)。
|
|
381
|
+
*/
|
|
382
|
+
notifyTerminalBashDone(terminals, info) {
|
|
383
|
+
const conv = [...this.convs.values()].find((c) => c.terminals === terminals);
|
|
384
|
+
if (!conv || this.disposed)
|
|
385
|
+
return;
|
|
386
|
+
let tail = "";
|
|
387
|
+
try {
|
|
388
|
+
const end = terminals.endCursor(info.terminalId);
|
|
389
|
+
if (end !== null) {
|
|
390
|
+
tail = terminals.read(info.terminalId, Math.max(0, end - 4000))?.data ?? "";
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
// 终端可能已被关闭
|
|
395
|
+
}
|
|
396
|
+
const exitText = info.exitCode === null ? "终端已关闭" : `退出码 ${info.exitCode}`;
|
|
397
|
+
const cmdShort = info.command.length > 120 ? `${info.command.slice(0, 120)}…` : info.command;
|
|
398
|
+
const text = `(系统:你之前在终端 ${info.terminalId} 后台运行的命令已结束(${exitText}):${cmdShort}\n` +
|
|
399
|
+
`最后输出:\n${stripAnsi(tail).trim() || "(无输出)"})`;
|
|
400
|
+
const session = conv.runtime.session;
|
|
401
|
+
if (session.isStreaming) {
|
|
402
|
+
void session.sendUserMessage(text).catch(() => { });
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
// 空闲时不唤醒 agent——排队为 nextTurn 上下文,下次对话自动可见。
|
|
406
|
+
void session
|
|
407
|
+
.sendCustomMessage({
|
|
408
|
+
customType: "terminal-bash-done",
|
|
409
|
+
content: [{ type: "text", text }],
|
|
410
|
+
display: true,
|
|
411
|
+
})
|
|
412
|
+
.catch(() => { });
|
|
413
|
+
}
|
|
303
414
|
}
|
|
304
415
|
emitTerminal(conversationId, msg) {
|
|
305
416
|
// Background conversations keep collecting output in their own PTY buffer.
|
|
@@ -356,6 +467,20 @@ export class ClientSession {
|
|
|
356
467
|
}
|
|
357
468
|
return "";
|
|
358
469
|
}
|
|
470
|
+
/** The FULL system prompt actually in effect right now (AgentSession getter,
|
|
471
|
+
* includes the append/replace override + auto-appended sections like
|
|
472
|
+
* project context, skills and tool guidance). Read-only view source for
|
|
473
|
+
* the settings panel. */
|
|
474
|
+
effectiveSystemPrompt() {
|
|
475
|
+
try {
|
|
476
|
+
const sp = this.session.systemPrompt;
|
|
477
|
+
return typeof sp === "string" ? sp : "";
|
|
478
|
+
}
|
|
479
|
+
catch {
|
|
480
|
+
// Session not ready yet.
|
|
481
|
+
return "";
|
|
482
|
+
}
|
|
483
|
+
}
|
|
359
484
|
/** Web-facing extension UI context (widgets, notifications). */
|
|
360
485
|
webUi = new WebUIContext((msg) => this.emit(msg));
|
|
361
486
|
widgetsTimer = null;
|
|
@@ -375,6 +500,11 @@ export class ClientSession {
|
|
|
375
500
|
/** Messages array as of the last emitted snapshot/delta — identity-walked
|
|
376
501
|
* against the current array to detect append-only growth. */
|
|
377
502
|
emittedMessages = null;
|
|
503
|
+
/** Conversation whose messages emittedMessages belongs to. A conversation
|
|
504
|
+
* switch (set_cwd / new_chat / switch_*) must fall back to a FULL snapshot:
|
|
505
|
+
* two empty conversations have identical (empty) arrays, so the identity
|
|
506
|
+
* walk alone would misread the switch as "nothing changed" → delta. */
|
|
507
|
+
emittedConvId = null;
|
|
378
508
|
/** snapRev value at which emittedMessages was captured. */
|
|
379
509
|
emittedRev = 0;
|
|
380
510
|
/**
|
|
@@ -420,6 +550,7 @@ export class ClientSession {
|
|
|
420
550
|
await this.pushSlashCommands();
|
|
421
551
|
},
|
|
422
552
|
effectiveDefaultSystemPrompt: () => this.effectiveDefaultSystemPrompt(),
|
|
553
|
+
effectiveSystemPrompt: () => this.effectiveSystemPrompt(),
|
|
423
554
|
});
|
|
424
555
|
this.goalSvc = new GoalService({
|
|
425
556
|
clientId,
|
|
@@ -550,8 +681,21 @@ export class ClientSession {
|
|
|
550
681
|
// 覆盖),执行时把自己的 AbortController 注册进客户端集合——
|
|
551
682
|
// abortBash() 只杀这些命令,agent run 与对话继续。
|
|
552
683
|
customTools: [
|
|
553
|
-
|
|
684
|
+
// bash 双实现动态分流:「终端接管」开启时命令跑进持久可见终端
|
|
685
|
+
// (保留 shell 状态、静默自动转后台),关闭时是原生 killable bash。
|
|
686
|
+
makeAdaptiveBashTool(makeKillableBashTool(effectiveCwd, this.bashKills), makeTerminalBashTool(terminals, {
|
|
687
|
+
cwd: effectiveCwd,
|
|
688
|
+
idleMs: () => this.settingsSvc.current.terminalBash
|
|
689
|
+
? Math.max(0, Math.floor(this.settingsSvc.current.terminalBashIdleMs) ||
|
|
690
|
+
0)
|
|
691
|
+
: 0,
|
|
692
|
+
kills: this.bashKills,
|
|
693
|
+
notifyBackgroundDone: (info) => this.notifyTerminalBashDone(terminals, info),
|
|
694
|
+
}), () => this.settingsSvc.current.terminalBash),
|
|
554
695
|
...makePersistentTerminalTools(terminals, effectiveCwd),
|
|
696
|
+
// 插件注册的 AI 工具(创建时刻的实时快照;后续注册经
|
|
697
|
+
// refreshPluginTools 动态补入已有会话)。
|
|
698
|
+
...(this.pluginToolsProvider?.() ?? []).map(pluginToolToDefinition),
|
|
555
699
|
],
|
|
556
700
|
});
|
|
557
701
|
// 终端工具开关从创建起就生效(工具始终注册进注册表,只调活跃集)。
|
|
@@ -797,6 +941,8 @@ export class ClientSession {
|
|
|
797
941
|
this.bg.snapshotBefore();
|
|
798
942
|
}
|
|
799
943
|
this.armToolWatchdog(conv, event.toolCallId);
|
|
944
|
+
// 插件扩展点:工具开始执行(异常由 emitToolEvent 隔离)。
|
|
945
|
+
this.onToolEvent?.({ phase: "start", toolName: event.toolName, conversationId: conv.id });
|
|
800
946
|
break;
|
|
801
947
|
}
|
|
802
948
|
case "tool_execution_end": {
|
|
@@ -808,6 +954,14 @@ export class ClientSession {
|
|
|
808
954
|
if (event.toolName === "bash")
|
|
809
955
|
void this.bg.trackAfterBash();
|
|
810
956
|
const durationMs = startedAt !== undefined ? Date.now() - startedAt : undefined;
|
|
957
|
+
// 插件扩展点:工具结束执行(带耗时与错误标志)。
|
|
958
|
+
this.onToolEvent?.({
|
|
959
|
+
phase: "end",
|
|
960
|
+
toolName: event.toolName,
|
|
961
|
+
conversationId: conv.id,
|
|
962
|
+
...(durationMs !== undefined ? { durationMs } : {}),
|
|
963
|
+
isError: event.isError,
|
|
964
|
+
});
|
|
811
965
|
// The bash tool does not put its exit code in result.details — on
|
|
812
966
|
// failure it throws "Command exited with code N" and the agent
|
|
813
967
|
// wraps that into the error result text. Try details first (future
|
|
@@ -1107,7 +1261,10 @@ export class ClientSession {
|
|
|
1107
1261
|
return;
|
|
1108
1262
|
const cur = this.currentMessages();
|
|
1109
1263
|
const prev = this.emittedMessages;
|
|
1110
|
-
let incremental = !forceFull &&
|
|
1264
|
+
let incremental = !forceFull &&
|
|
1265
|
+
prev !== null &&
|
|
1266
|
+
this.emittedConvId === this.activeId &&
|
|
1267
|
+
prev.length <= cur.length;
|
|
1111
1268
|
if (incremental && prev) {
|
|
1112
1269
|
for (let i = 0; i < prev.length; i++) {
|
|
1113
1270
|
if (prev[i] !== cur[i]) {
|
|
@@ -1120,6 +1277,7 @@ export class ClientSession {
|
|
|
1120
1277
|
if (incremental && prev) {
|
|
1121
1278
|
const baseRev = this.emittedRev;
|
|
1122
1279
|
this.emittedMessages = cur;
|
|
1280
|
+
this.emittedConvId = this.activeId;
|
|
1123
1281
|
this.emittedRev = rev;
|
|
1124
1282
|
this.emit({
|
|
1125
1283
|
type: "snapshot_delta",
|
|
@@ -1132,6 +1290,7 @@ export class ClientSession {
|
|
|
1132
1290
|
}
|
|
1133
1291
|
else {
|
|
1134
1292
|
this.emittedMessages = cur;
|
|
1293
|
+
this.emittedConvId = this.activeId;
|
|
1135
1294
|
this.emittedRev = rev;
|
|
1136
1295
|
this.emit({
|
|
1137
1296
|
type: "snapshot",
|
|
@@ -1238,13 +1397,6 @@ export class ClientSession {
|
|
|
1238
1397
|
}
|
|
1239
1398
|
return 0;
|
|
1240
1399
|
}
|
|
1241
|
-
/** True once updateApp succeeded — the process must restart to run new code. */
|
|
1242
|
-
pendingRestart = false;
|
|
1243
|
-
/**
|
|
1244
|
-
* Set by index.ts: called after a successful self-update; returns whether
|
|
1245
|
-
* the process is going to restart itself (so the notice can say so).
|
|
1246
|
-
*/
|
|
1247
|
-
onUpdateReady = undefined;
|
|
1248
1400
|
/** Set by index.ts: called when /pi-web-ui:quit is invoked. */
|
|
1249
1401
|
onQuit = undefined;
|
|
1250
1402
|
/** Ask the npm registry for the latest pi-web-ui version and report it. */
|
|
@@ -1269,7 +1421,6 @@ export class ClientSession {
|
|
|
1269
1421
|
latest,
|
|
1270
1422
|
latestPublishedAt,
|
|
1271
1423
|
upToDate,
|
|
1272
|
-
pendingRestart: this.pendingRestart,
|
|
1273
1424
|
});
|
|
1274
1425
|
}
|
|
1275
1426
|
catch (err) {
|
|
@@ -1279,115 +1430,10 @@ export class ClientSession {
|
|
|
1279
1430
|
latest: null,
|
|
1280
1431
|
latestPublishedAt: null,
|
|
1281
1432
|
upToDate: false,
|
|
1282
|
-
pendingRestart: this.pendingRestart,
|
|
1283
1433
|
error: `检查更新失败:${err.message}`,
|
|
1284
1434
|
});
|
|
1285
1435
|
}
|
|
1286
1436
|
}
|
|
1287
|
-
/**
|
|
1288
|
-
* After `npm i -g`, confirm the on-disk package this process serves from
|
|
1289
|
-
* actually changed to the new version and is complete. Windows npm updates
|
|
1290
|
-
* can fail partway (locked files / Defender / npm rollback) and leave the
|
|
1291
|
-
* global install without its bin links — restarting into that is a silent
|
|
1292
|
-
* crash (web/dist missing + `pi-web-ui` no longer on PATH). Returns null
|
|
1293
|
-
* when OK, else a human-readable problem description.
|
|
1294
|
-
*/
|
|
1295
|
-
static verifyGlobalInstall() {
|
|
1296
|
-
try {
|
|
1297
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
1298
|
-
const pkgRoot = resolve(here, "..", "..");
|
|
1299
|
-
const pkg = JSON.parse(readFileSync(join(pkgRoot, "package.json"), "utf8"));
|
|
1300
|
-
if (!pkg.version || pkg.version === ClientSession.currentAppVersion()) {
|
|
1301
|
-
return `安装目录版本未变化(${pkg.version ?? "未知"})`;
|
|
1302
|
-
}
|
|
1303
|
-
if (!existsSync(join(pkgRoot, "web", "dist", "index.html"))) {
|
|
1304
|
-
return "web/dist/index.html 缺失(前端产物未安装完整)";
|
|
1305
|
-
}
|
|
1306
|
-
if (!existsSync(join(pkgRoot, "bin", "pi-web-ui.mjs"))) {
|
|
1307
|
-
return "bin/pi-web-ui.mjs 缺失";
|
|
1308
|
-
}
|
|
1309
|
-
if (process.platform === "win32") {
|
|
1310
|
-
const prefix = dirname(process.execPath);
|
|
1311
|
-
const hasShim = existsSync(join(prefix, "pi-web-ui.cmd")) ||
|
|
1312
|
-
existsSync(join(prefix, "pi-web-ui.ps1"));
|
|
1313
|
-
if (!hasShim)
|
|
1314
|
-
return "pi-web-ui 命令入口(bin 链接)未生成";
|
|
1315
|
-
}
|
|
1316
|
-
return null;
|
|
1317
|
-
}
|
|
1318
|
-
catch (err) {
|
|
1319
|
-
return `读取安装目录失败:${err.message}`;
|
|
1320
|
-
}
|
|
1321
|
-
}
|
|
1322
|
-
/** npm i -g pi-web-ui@latest — the new code only takes effect after a restart. */
|
|
1323
|
-
async updateApp() {
|
|
1324
|
-
try {
|
|
1325
|
-
this.emit({
|
|
1326
|
-
type: "notice",
|
|
1327
|
-
level: "info",
|
|
1328
|
-
text: "正在更新 pi-web-ui(npm i -g pi-web-ui@latest)…",
|
|
1329
|
-
});
|
|
1330
|
-
const { code, out } = await this.runAsync("npm", ["i", "-g", "pi-web-ui@latest"], 180_000);
|
|
1331
|
-
if (code !== 0) {
|
|
1332
|
-
this.emit({
|
|
1333
|
-
type: "update_result",
|
|
1334
|
-
ok: false,
|
|
1335
|
-
detail: `npm i 失败(${code ?? "timeout"}):${out.slice(0, 400)}`,
|
|
1336
|
-
});
|
|
1337
|
-
this.emit({
|
|
1338
|
-
type: "notice",
|
|
1339
|
-
level: "error",
|
|
1340
|
-
text: `更新 pi-web-ui 失败(${code ?? "timeout"}):${out.slice(0, 300)}`,
|
|
1341
|
-
});
|
|
1342
|
-
return;
|
|
1343
|
-
}
|
|
1344
|
-
// npm reported success, but on Windows the replacement can be partial
|
|
1345
|
-
// (locked files, rollback) — restarting into a broken install is a
|
|
1346
|
-
// crash with no hint. Verify before handing over.
|
|
1347
|
-
const problem = ClientSession.verifyGlobalInstall();
|
|
1348
|
-
if (problem) {
|
|
1349
|
-
this.emit({
|
|
1350
|
-
type: "update_result",
|
|
1351
|
-
ok: false,
|
|
1352
|
-
detail: `npm i 成功但安装不完整(${problem})。请手动执行 npm i -g pi-web-ui@latest 修复后再重启服务。`,
|
|
1353
|
-
});
|
|
1354
|
-
this.emit({
|
|
1355
|
-
type: "notice",
|
|
1356
|
-
level: "error",
|
|
1357
|
-
text: `更新未完整生效(${problem})。请手动执行 npm i -g pi-web-ui@latest 修复`,
|
|
1358
|
-
});
|
|
1359
|
-
return;
|
|
1360
|
-
}
|
|
1361
|
-
this.pendingRestart = true;
|
|
1362
|
-
this.emit({
|
|
1363
|
-
type: "update_result",
|
|
1364
|
-
ok: true,
|
|
1365
|
-
detail: out.slice(0, 400),
|
|
1366
|
-
});
|
|
1367
|
-
const autoRestart = this.onUpdateReady?.() ?? false;
|
|
1368
|
-
this.emit({
|
|
1369
|
-
type: "notice",
|
|
1370
|
-
level: "info",
|
|
1371
|
-
text: autoRestart
|
|
1372
|
-
? "✅ 已更新 pi-web-ui,正在自动重启…"
|
|
1373
|
-
: "✅ 已更新 pi-web-ui,重启服务后生效(pi-web-ui server restart)",
|
|
1374
|
-
});
|
|
1375
|
-
}
|
|
1376
|
-
catch (err) {
|
|
1377
|
-
this.emit({
|
|
1378
|
-
type: "update_result",
|
|
1379
|
-
ok: false,
|
|
1380
|
-
detail: String(err),
|
|
1381
|
-
});
|
|
1382
|
-
this.emit({
|
|
1383
|
-
type: "notice",
|
|
1384
|
-
level: "error",
|
|
1385
|
-
text: `更新 pi-web-ui 失败:${err.message}`,
|
|
1386
|
-
});
|
|
1387
|
-
}
|
|
1388
|
-
// Re-check so the UI reflects the new state (pendingRestart included).
|
|
1389
|
-
void this.checkUpdate();
|
|
1390
|
-
}
|
|
1391
1437
|
async installPiAgent() {
|
|
1392
1438
|
try {
|
|
1393
1439
|
mkdirSync(this.agentDir, { recursive: true });
|
|
@@ -1491,6 +1537,12 @@ export class ClientSession {
|
|
|
1491
1537
|
refreshProviderModels(providerId, reqId) {
|
|
1492
1538
|
return this.modelAdmin.refreshProviderModels(providerId, reqId);
|
|
1493
1539
|
}
|
|
1540
|
+
/** Copy a built-in provider into an editable custom-provider draft
|
|
1541
|
+
* (clone_provider_result) — lets the user run a second API key without
|
|
1542
|
+
* overwriting the built-in one. */
|
|
1543
|
+
cloneProvider(providerId, reqId) {
|
|
1544
|
+
return this.modelAdmin.cloneProvider(providerId, reqId);
|
|
1545
|
+
}
|
|
1494
1546
|
saveModelConfig(providerId, config) {
|
|
1495
1547
|
return this.modelAdmin.saveModelConfig(providerId, config);
|
|
1496
1548
|
}
|
|
@@ -1551,6 +1603,24 @@ export class ClientSession {
|
|
|
1551
1603
|
// Session 未就绪——下次创建/reload 会再应用。
|
|
1552
1604
|
}
|
|
1553
1605
|
}
|
|
1606
|
+
/** 把插件 AI 工具同步进一个已存在的会话(新增/更新/移除)。
|
|
1607
|
+
* 实际 diff 逻辑在 plugins.ts 的 syncPluginToolsIntoSession(可单测)。 */
|
|
1608
|
+
syncPluginTools(session) {
|
|
1609
|
+
try {
|
|
1610
|
+
const defs = (this.pluginToolsProvider?.() ?? []).map(pluginToolToDefinition);
|
|
1611
|
+
const next = syncPluginToolsIntoSession(session, defs, this.appliedPluginToolNames);
|
|
1612
|
+
if (next)
|
|
1613
|
+
this.appliedPluginToolNames = new Set(next);
|
|
1614
|
+
}
|
|
1615
|
+
catch (err) {
|
|
1616
|
+
console.error("[plugins] sync tools to session failed:", err);
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
/** index.ts 经 pluginMgr.onAgentToolsChanged 触发:把插件 AI 工具推入全部会话。 */
|
|
1620
|
+
refreshPluginTools() {
|
|
1621
|
+
for (const conv of this.convs.values())
|
|
1622
|
+
this.syncPluginTools(conv.session);
|
|
1623
|
+
}
|
|
1554
1624
|
async applySettingsReload() {
|
|
1555
1625
|
// 兼容旧入口:reload + 刷目录在宿主回调里完成
|
|
1556
1626
|
return this.settingsSvc.applyRuntime();
|
|
@@ -2040,6 +2110,48 @@ export class ClientSession {
|
|
|
2040
2110
|
this.emit({ type: "sessions", sessions: [] });
|
|
2041
2111
|
}
|
|
2042
2112
|
}
|
|
2113
|
+
/** Remove an entry from the client's recent-project list (UI state only). */
|
|
2114
|
+
async removeProject(path) {
|
|
2115
|
+
this.stateStore.removeProject(this.clientId, path);
|
|
2116
|
+
await this.pushProjects();
|
|
2117
|
+
}
|
|
2118
|
+
/** Permanently delete a persisted session transcript file (history list ✕). */
|
|
2119
|
+
async deleteSession(path) {
|
|
2120
|
+
try {
|
|
2121
|
+
const abs = resolve(path);
|
|
2122
|
+
// Guardrail: only transcripts under the shared sessions root
|
|
2123
|
+
// (<agentDir>/sessions/) may be deleted — never arbitrary files.
|
|
2124
|
+
const sessionsRoot = resolve(this.agentDir, "sessions");
|
|
2125
|
+
if (!abs.startsWith(sessionsRoot + sep)) {
|
|
2126
|
+
this.emit({
|
|
2127
|
+
type: "notice",
|
|
2128
|
+
level: "error",
|
|
2129
|
+
text: "只能删除会话目录中的对话记录",
|
|
2130
|
+
});
|
|
2131
|
+
return;
|
|
2132
|
+
}
|
|
2133
|
+
// Refuse to pull the file out from under a live conversation.
|
|
2134
|
+
for (const conv of this.convs.values()) {
|
|
2135
|
+
if (conv.session.sessionFile === abs) {
|
|
2136
|
+
this.emit({
|
|
2137
|
+
type: "notice",
|
|
2138
|
+
level: "warning",
|
|
2139
|
+
text: "该对话正在使用中,请先切换到其他对话再删除",
|
|
2140
|
+
});
|
|
2141
|
+
return;
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
rmSync(abs, { force: true });
|
|
2145
|
+
await this.refreshSessions();
|
|
2146
|
+
}
|
|
2147
|
+
catch (err) {
|
|
2148
|
+
this.emit({
|
|
2149
|
+
type: "notice",
|
|
2150
|
+
level: "error",
|
|
2151
|
+
text: `删除会话失败:${err.message}`,
|
|
2152
|
+
});
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2043
2155
|
/** Switch the active session to a persisted one (from listSessions). */
|
|
2044
2156
|
async switchSession(path) {
|
|
2045
2157
|
if (this.quiesceBlocked())
|
|
@@ -2160,6 +2272,7 @@ export class ClientSession {
|
|
|
2160
2272
|
async pushProjects() {
|
|
2161
2273
|
try {
|
|
2162
2274
|
const saved = this.stateStore.get(this.clientId);
|
|
2275
|
+
const removedProjects = new Set(this.stateStore.getRemovedProjects(this.clientId));
|
|
2163
2276
|
const map = new Map();
|
|
2164
2277
|
for (const p of saved.projects)
|
|
2165
2278
|
map.set(p.path, p.lastUsed);
|
|
@@ -2173,9 +2286,10 @@ export class ClientSession {
|
|
|
2173
2286
|
}
|
|
2174
2287
|
}
|
|
2175
2288
|
// Only keep directories that still exist — a deleted/unmounted workspace
|
|
2176
|
-
// is useless in the picker.
|
|
2289
|
+
// is useless in the picker. Tombstoned entries (explicitly removed by
|
|
2290
|
+
// the user) stay hidden even though session files still mention them.
|
|
2177
2291
|
const projects = [...map.entries()]
|
|
2178
|
-
.filter(([path]) => existsSync(path))
|
|
2292
|
+
.filter(([path]) => !removedProjects.has(path) && existsSync(path))
|
|
2179
2293
|
.map(([path, lastUsed]) => ({ path, lastUsed }))
|
|
2180
2294
|
.sort((a, b) => b.lastUsed - a.lastUsed)
|
|
2181
2295
|
.slice(0, 20);
|
|
@@ -2473,6 +2587,10 @@ export class ClientSession {
|
|
|
2473
2587
|
}
|
|
2474
2588
|
export class AgentService {
|
|
2475
2589
|
cwd;
|
|
2590
|
+
/** index.ts 注入:SDK 工具执行事件的插件转发钩子,attach 时拷贝到每个新会话。 */
|
|
2591
|
+
onToolEvent = undefined;
|
|
2592
|
+
/** index.ts 注入:读取插件当前注册的 AI 工具(attach 时拷贝到每个新会话)。 */
|
|
2593
|
+
pluginToolsProvider = undefined;
|
|
2476
2594
|
clients = new Map();
|
|
2477
2595
|
/** Quiesce (draining) state — the service refuses NEW work (prompts, forks,
|
|
2478
2596
|
* session resumes, new clients) so a deploy/upgrade/backup can stop cleanly
|
|
@@ -2485,11 +2603,6 @@ export class AgentService {
|
|
|
2485
2603
|
socketCount = 0;
|
|
2486
2604
|
pending = new Map();
|
|
2487
2605
|
stateStore;
|
|
2488
|
-
/**
|
|
2489
|
-
* Set by index.ts: called by a client session after a successful
|
|
2490
|
-
* self-update; returns whether the process will restart itself.
|
|
2491
|
-
*/
|
|
2492
|
-
onUpdateReady = undefined;
|
|
2493
2606
|
/** Set by index.ts: called when /pi-web-ui:quit is invoked. */
|
|
2494
2607
|
onQuit = undefined;
|
|
2495
2608
|
constructor(cwd, stateFile) {
|
|
@@ -2603,11 +2716,17 @@ export class AgentService {
|
|
|
2603
2716
|
cs.notifyInterrupted(this.stateStore.takeInterrupted(clientId));
|
|
2604
2717
|
cs.attachSink(send);
|
|
2605
2718
|
// Forward hooks (set once by index.ts) to every session.
|
|
2606
|
-
cs.onUpdateReady = this.onUpdateReady;
|
|
2607
2719
|
cs.onQuit = this.onQuit;
|
|
2720
|
+
cs.onToolEvent = this.onToolEvent;
|
|
2721
|
+
cs.pluginToolsProvider = this.pluginToolsProvider;
|
|
2608
2722
|
cs.isQuiesced = () => this.quiesced;
|
|
2609
2723
|
return cs;
|
|
2610
2724
|
}
|
|
2725
|
+
/** 插件 AI 工具集合变化(注册/注销)时由 index.ts 触发:推送到所有客户端的全部会话。 */
|
|
2726
|
+
applyPluginAgentTools() {
|
|
2727
|
+
for (const cs of this.clients.values())
|
|
2728
|
+
cs.refreshPluginTools();
|
|
2729
|
+
}
|
|
2611
2730
|
/** Remove a socket from a client's broadcast set (called on socket close). */
|
|
2612
2731
|
detach(clientId, send) {
|
|
2613
2732
|
this.clients.get(clientId)?.detachSink(send);
|
|
@@ -97,8 +97,31 @@ export class ClientStateStore {
|
|
|
97
97
|
{ path: cwd, lastUsed: now },
|
|
98
98
|
...state.projects.filter((p) => p.path !== cwd),
|
|
99
99
|
].slice(0, 30);
|
|
100
|
+
// Opening the workspace again clears its removal tombstone.
|
|
101
|
+
if (state.removedProjects?.length) {
|
|
102
|
+
state.removedProjects = state.removedProjects.filter((p) => p !== cwd);
|
|
103
|
+
}
|
|
104
|
+
this.save();
|
|
105
|
+
}
|
|
106
|
+
/** Drop one workspace from the recent-project list (user-requested removal).
|
|
107
|
+
* Records a tombstone too: pushProjects() re-discovers cwds from session
|
|
108
|
+
* files on every listing, so without it the entry would instantly reappear. */
|
|
109
|
+
removeProject(clientId, cwd) {
|
|
110
|
+
const all = this.load();
|
|
111
|
+
const state = (all[clientId] ??= { projects: [] });
|
|
112
|
+
state.projects = state.projects.filter((p) => p.path !== cwd);
|
|
113
|
+
if (state.lastCwd === cwd)
|
|
114
|
+
delete state.lastCwd;
|
|
115
|
+
const removed = new Set(state.removedProjects ?? []);
|
|
116
|
+
removed.add(cwd);
|
|
117
|
+
state.removedProjects = [...removed];
|
|
100
118
|
this.save();
|
|
101
119
|
}
|
|
120
|
+
/** Tombstoned projects (explicitly removed by the user) for filtering the
|
|
121
|
+
* merged recent-project list. */
|
|
122
|
+
getRemovedProjects(clientId) {
|
|
123
|
+
return this.load()[clientId]?.removedProjects ?? [];
|
|
124
|
+
}
|
|
102
125
|
/** Last-used goal/review prefs for a client, or undefined if never set. */
|
|
103
126
|
getGoalPrefs(clientId) {
|
|
104
127
|
const s = this.load()[clientId];
|
|
@@ -152,12 +175,15 @@ export class ClientStateStore {
|
|
|
152
175
|
disabledSkills: s?.settings?.disabledSkills ?? [],
|
|
153
176
|
disabledExtensions: s?.settings?.disabledExtensions ?? [],
|
|
154
177
|
terminalToolsEnabled: s?.settings?.terminalToolsEnabled ?? true,
|
|
178
|
+
terminalBash: s?.settings?.terminalBash ?? false,
|
|
179
|
+
terminalBashIdleMs: s?.settings?.terminalBashIdleMs ?? 15_000,
|
|
155
180
|
visionBridgeEnabled: s?.settings?.visionBridgeEnabled ?? true,
|
|
156
181
|
visionBridgeModel: s?.settings?.visionBridgeModel ?? null,
|
|
157
182
|
visionBridgePromptMode: s?.settings?.visionBridgePromptMode === "replace" ? "replace" : "append",
|
|
158
183
|
visionBridgePrompt: s?.settings?.visionBridgePrompt ?? "",
|
|
159
184
|
reviewPrompt: s?.settings?.reviewPrompt ?? "",
|
|
160
185
|
reviewDisabledSkills: s?.settings?.reviewDisabledSkills ?? [],
|
|
186
|
+
disabledPlugins: s?.settings?.disabledPlugins ?? [],
|
|
161
187
|
};
|
|
162
188
|
}
|
|
163
189
|
/** Persist the client's settings-panel state (partial merge). */
|
|
@@ -171,6 +197,8 @@ export class ClientStateStore {
|
|
|
171
197
|
disabledSkills: settings.disabledSkills ?? cur.disabledSkills ?? [],
|
|
172
198
|
disabledExtensions: settings.disabledExtensions ?? cur.disabledExtensions ?? [],
|
|
173
199
|
terminalToolsEnabled: settings.terminalToolsEnabled ?? cur.terminalToolsEnabled ?? true,
|
|
200
|
+
terminalBash: settings.terminalBash ?? cur.terminalBash ?? false,
|
|
201
|
+
terminalBashIdleMs: settings.terminalBashIdleMs ?? cur.terminalBashIdleMs ?? 15_000,
|
|
174
202
|
visionBridgeEnabled: settings.visionBridgeEnabled ?? cur.visionBridgeEnabled ?? true,
|
|
175
203
|
visionBridgeModel: settings.visionBridgeModel ?? cur.visionBridgeModel ?? null,
|
|
176
204
|
visionBridgePromptMode: settings.visionBridgePromptMode ??
|
|
@@ -179,6 +207,7 @@ export class ClientStateStore {
|
|
|
179
207
|
visionBridgePrompt: settings.visionBridgePrompt ?? cur.visionBridgePrompt ?? "",
|
|
180
208
|
reviewPrompt: settings.reviewPrompt ?? cur.reviewPrompt ?? "",
|
|
181
209
|
reviewDisabledSkills: settings.reviewDisabledSkills ?? cur.reviewDisabledSkills ?? [],
|
|
210
|
+
disabledPlugins: settings.disabledPlugins ?? cur.disabledPlugins ?? [],
|
|
182
211
|
};
|
|
183
212
|
this.save();
|
|
184
213
|
}
|