pi-web-ui 0.44.2 → 0.46.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/LICENSE +21 -21
- package/README.md +458 -388
- package/README.zh-CN.md +380 -359
- package/bin/pi-web-ui.mjs +1818 -1818
- package/deploy/com.xingshuyin.pi-web-ui.plist +48 -48
- package/deploy/nginx-subpath.conf +88 -88
- package/deploy/pi-web-ui-task.xml +71 -71
- package/deploy/pi-web-ui.service +31 -31
- package/dist/server/agent-service.js +184 -36
- package/dist/server/attachments.js +3 -3
- package/dist/server/client-state.js +2 -0
- package/dist/server/index.js +6 -0
- package/dist/server/protocol-version.js +1 -1
- package/dist/server/settings-service.js +105 -11
- package/dist/server/terminals.js +5 -5
- package/electron/main.mjs +427 -0
- package/extensions/webui.ts +192 -192
- package/package.json +19 -5
- package/themes/md-preview.css +7328 -7253
- package/themes/white.css +7389 -7314
- package/web/dist/assets/{TerminalPanel-BuG4RDBE.js → TerminalPanel-BoLQmU5b.js} +1 -1
- package/web/dist/assets/index-B71Da7xx.js +19 -0
- package/web/dist/assets/index-CoufKLTQ.css +10 -0
- package/web/dist/favicon.svg +8 -8
- package/web/dist/index.html +15 -15
- package/web/public/favicon.svg +8 -8
- package/web/dist/assets/index-B0NAJgJX.js +0 -19
- package/web/dist/assets/index-BByVm30o.css +0 -10
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* snapshots. The frontend is snapshot-driven (server is the source of truth),
|
|
11
11
|
* so reconnects just re-request a snapshot.
|
|
12
12
|
*/
|
|
13
|
-
import { spawn } from "node:child_process";
|
|
13
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
14
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";
|
|
@@ -80,14 +80,14 @@ export class QuiesceRejectedError extends Error {
|
|
|
80
80
|
* programs wait for input that never comes. Legacy Chinese files are often
|
|
81
81
|
* GBK/GB2312 — read them with the right encoding, never paste mojibake into
|
|
82
82
|
* reasoning/answers. */
|
|
83
|
-
const WINDOWS_PERSONA = `You are a coding agent running on Windows. The bash tool runs Git Bash (bash.exe), not PowerShell. Follow these rules to avoid hanging the session:
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
- ALWAYS pass a timeout parameter to the bash tool (in seconds). There is NO default timeout — a command that never finishes (servers, watchers, infinite loops, slow downloads/installs) will hang the entire conversation indefinitely. Pick a generous timeout for long-running work, but never omit it.
|
|
88
|
-
- NEVER run interactive or foreground long-running commands through the bash tool (vi, less, top, python -, node -, npm run dev, sleep 10000). For servers/daemons use background execution with output redirected to a log file, then poll the log; stop them when done.
|
|
89
|
-
- In the interactive terminal (TTY) — which is Git Bash too, not PowerShell — NEVER use heredocs (<<'EOF' ... EOF) or here-strings, and NEVER start interactive programs (vi, less, python -, node -, npm init): they wait for keyboard input that never arrives and hang the terminal forever. Prefer writing a temp script file (e.g. .pi-tmp.sh) and running it non-interactively. ALWAYS pass a timeout to long-running commands (e.g. \`timeout 120 npm run dev\`).
|
|
90
|
-
|
|
83
|
+
const WINDOWS_PERSONA = `You are a coding agent running on Windows. The bash tool runs Git Bash (bash.exe), not PowerShell. Follow these rules to avoid hanging the session:
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
- ALWAYS pass a timeout parameter to the bash tool (in seconds). There is NO default timeout — a command that never finishes (servers, watchers, infinite loops, slow downloads/installs) will hang the entire conversation indefinitely. Pick a generous timeout for long-running work, but never omit it.
|
|
88
|
+
- NEVER run interactive or foreground long-running commands through the bash tool (vi, less, top, python -, node -, npm run dev, sleep 10000). For servers/daemons use background execution with output redirected to a log file, then poll the log; stop them when done.
|
|
89
|
+
- In the interactive terminal (TTY) — which is Git Bash too, not PowerShell — NEVER use heredocs (<<'EOF' ... EOF) or here-strings, and NEVER start interactive programs (vi, less, python -, node -, npm init): they wait for keyboard input that never arrives and hang the terminal forever. Prefer writing a temp script file (e.g. .pi-tmp.sh) and running it non-interactively. ALWAYS pass a timeout to long-running commands (e.g. \`timeout 120 npm run dev\`).
|
|
90
|
+
|
|
91
91
|
Many legacy Chinese text files (.html/.txt/.md/.log, exported documents) are GBK/GB2312 encoded: the read tool decodes UTF-8 only and will show mojibake (乱码) for them. If a file's content looks garbled, read it through the terminal instead: in Git Bash use \`cat file | iconv -f GBK -t UTF-8\` (or \`iconv -f GBK -t UTF-8 file\`); in cmd use \`chcp 65001 && type file\`; in PowerShell use \`Get-Content -Encoding Default file\`. Never paste mojibake into your reasoning or answer — describe the decoded content instead.`;
|
|
92
92
|
/**
|
|
93
93
|
* Killable bash tool: wraps the SDK bash tool with operations that register
|
|
@@ -550,6 +550,8 @@ export class ClientSession {
|
|
|
550
550
|
flushSnapshot: () => this.flushSnapshot(),
|
|
551
551
|
isDisposed: () => this.disposed,
|
|
552
552
|
getSession: () => this.session,
|
|
553
|
+
cwd: () => this.cwd,
|
|
554
|
+
agentDir: () => this.agentDir,
|
|
553
555
|
isStreaming: () => this.session.isStreaming,
|
|
554
556
|
reloadSession: async () => {
|
|
555
557
|
await this.session.reload();
|
|
@@ -1028,6 +1030,7 @@ export class ClientSession {
|
|
|
1028
1030
|
// (new chat + first message, completed turns, compaction, etc.).
|
|
1029
1031
|
case "agent_end": {
|
|
1030
1032
|
this.scheduleSessionsRefresh();
|
|
1033
|
+
this.refreshConversationTitle(conv);
|
|
1031
1034
|
// Manual interrupt (Stop button / abort): the last assistant message
|
|
1032
1035
|
// carries stopReason "aborted". A half-finished run should NOT be
|
|
1033
1036
|
// reviewed (it would fail and inject a revision, only to be stopped
|
|
@@ -1057,6 +1060,7 @@ export class ClientSession {
|
|
|
1057
1060
|
}
|
|
1058
1061
|
case "entry_appended":
|
|
1059
1062
|
this.scheduleSessionsRefresh();
|
|
1063
|
+
this.refreshConversationTitle(conv);
|
|
1060
1064
|
break;
|
|
1061
1065
|
case "message_update": {
|
|
1062
1066
|
// Live assistant-message increment, deliberately OUTSIDE the snapshot
|
|
@@ -1123,6 +1127,21 @@ export class ClientSession {
|
|
|
1123
1127
|
}, 800);
|
|
1124
1128
|
// pushSessions no-ops unless the client opted in via list_sessions.
|
|
1125
1129
|
}
|
|
1130
|
+
/** Refresh a conversation's title from its persisted first user message
|
|
1131
|
+
* while it is still unnamed. Runs off the event stream (entry_appended /
|
|
1132
|
+
* agent_end) rather than the prompt() call site, so ANY entry path that
|
|
1133
|
+
* lands a message names the chat the moment it is persisted — a rename
|
|
1134
|
+
* skipped by the prompt-start fast path (e.g. a concurrent switch) is
|
|
1135
|
+
* recovered here instead of leaving a permanent “新对话”. */
|
|
1136
|
+
refreshConversationTitle(conv) {
|
|
1137
|
+
if (conv.title !== DEFAULT_CONV_TITLE)
|
|
1138
|
+
return;
|
|
1139
|
+
const title = conversationTitle(conv.session);
|
|
1140
|
+
if (title === DEFAULT_CONV_TITLE)
|
|
1141
|
+
return;
|
|
1142
|
+
conv.title = title;
|
|
1143
|
+
this.emitConversations();
|
|
1144
|
+
}
|
|
1126
1145
|
/** Serialize a persisted message with a STABLE id + cached object reference. */
|
|
1127
1146
|
serializeCached(m) {
|
|
1128
1147
|
const conv = this.conv;
|
|
@@ -1251,6 +1270,7 @@ export class ClientSession {
|
|
|
1251
1270
|
tools: state.tools.map((t) => t.name),
|
|
1252
1271
|
version: ++this.version,
|
|
1253
1272
|
piConfigured: this.isPiConfigured(),
|
|
1273
|
+
piAgentInstalled: this.isPiCliInstalled(),
|
|
1254
1274
|
stats,
|
|
1255
1275
|
};
|
|
1256
1276
|
}
|
|
@@ -1336,6 +1356,38 @@ export class ClientSession {
|
|
|
1336
1356
|
this.piCheckCache = { at: now, configured };
|
|
1337
1357
|
return configured;
|
|
1338
1358
|
}
|
|
1359
|
+
/**
|
|
1360
|
+
* Whether the pi CLI binary is installed and runnable (`pi --version`
|
|
1361
|
+
* probe). Cached machine-wide (same binary for every client) for 10s —
|
|
1362
|
+
* the check is only rerun after install or when the cache expires.
|
|
1363
|
+
*/
|
|
1364
|
+
static piCliProbe = null;
|
|
1365
|
+
static PI_CLI_PROBE_TTL_MS = 10_000;
|
|
1366
|
+
isPiCliInstalled() {
|
|
1367
|
+
const now = Date.now();
|
|
1368
|
+
const cached = ClientSession.piCliProbe;
|
|
1369
|
+
if (cached && now - cached.at < ClientSession.PI_CLI_PROBE_TTL_MS)
|
|
1370
|
+
return cached.installed;
|
|
1371
|
+
let installed = false;
|
|
1372
|
+
try {
|
|
1373
|
+
const res = spawnSync("pi", ["--version"], {
|
|
1374
|
+
timeout: 5000,
|
|
1375
|
+
stdio: "ignore",
|
|
1376
|
+
// Windows: `pi` resolves to a pi.cmd shim — spawnSync can only
|
|
1377
|
+
// exec those through a shell (else ENOENT).
|
|
1378
|
+
shell: process.platform === "win32",
|
|
1379
|
+
});
|
|
1380
|
+
installed = !res.error && res.status === 0;
|
|
1381
|
+
}
|
|
1382
|
+
catch {
|
|
1383
|
+
installed = false;
|
|
1384
|
+
}
|
|
1385
|
+
ClientSession.piCliProbe = { at: now, installed };
|
|
1386
|
+
return installed;
|
|
1387
|
+
}
|
|
1388
|
+
static invalidatePiCliProbe() {
|
|
1389
|
+
ClientSession.piCliProbe = null;
|
|
1390
|
+
}
|
|
1339
1391
|
/**
|
|
1340
1392
|
* Run a command async, collecting stdout+stderr; kills on timeout.
|
|
1341
1393
|
* Never throws / never crashes the server: spawn errors (ENOENT etc.)
|
|
@@ -1483,6 +1535,9 @@ export class ClientSession {
|
|
|
1483
1535
|
text: `pi agent 安装失败:${err.message}`,
|
|
1484
1536
|
});
|
|
1485
1537
|
}
|
|
1538
|
+
// The CLI may just have landed on PATH (or the install may have failed) —
|
|
1539
|
+
// drop the probe cache so the next snapshot re-checks.
|
|
1540
|
+
ClientSession.invalidatePiCliProbe();
|
|
1486
1541
|
this.flushSnapshot();
|
|
1487
1542
|
}
|
|
1488
1543
|
/** Send a snapshot immediately (cancels any pending throttled one).
|
|
@@ -1707,6 +1762,11 @@ export class ClientSession {
|
|
|
1707
1762
|
* after the current turn settles, skipping remaining planned tool calls.
|
|
1708
1763
|
*/
|
|
1709
1764
|
queue = false) {
|
|
1765
|
+
// Captured at the START (before any await): the conversation being
|
|
1766
|
+
// addressed by this prompt. See the naming block below — a concurrent
|
|
1767
|
+
// switch/new_chat while prompt() is in flight must never target a
|
|
1768
|
+
// different conversation.
|
|
1769
|
+
const conv = this.conv;
|
|
1710
1770
|
try {
|
|
1711
1771
|
const s = this.session;
|
|
1712
1772
|
// Native slash commands (see NATIVE_COMMANDS) are executed here and
|
|
@@ -1722,6 +1782,19 @@ export class ClientSession {
|
|
|
1722
1782
|
// is refused until admission reopens.
|
|
1723
1783
|
if (this.quiesceBlocked())
|
|
1724
1784
|
return;
|
|
1785
|
+
// Name the conversation from its FIRST prompt immediately, before any
|
|
1786
|
+
// await: the typed text IS the name. The `conv` reference was captured
|
|
1787
|
+
// before the try block, so a concurrent switch/new_chat while prompt()
|
|
1788
|
+
// is in flight can never rename a DIFFERENT conversation — or miss the
|
|
1789
|
+
// rename entirely. A failed send still leaves the name, which matches
|
|
1790
|
+
// what the user typed intent-wise; the entry_appended fallback below
|
|
1791
|
+
// re-derives it from the persisted transcript when needed.
|
|
1792
|
+
if (conv.title === DEFAULT_CONV_TITLE && text.trim()) {
|
|
1793
|
+
const trimmed = text.trim().replace(/\s+/g, " ");
|
|
1794
|
+
conv.title =
|
|
1795
|
+
trimmed.length > 30 ? `${trimmed.slice(0, 30)}…` : trimmed;
|
|
1796
|
+
this.emitConversations();
|
|
1797
|
+
}
|
|
1725
1798
|
// Attach files as independent nextTurn context messages (asides) so the
|
|
1726
1799
|
// user message stays clean; they render as separate attachment cards.
|
|
1727
1800
|
const asides = await buildAttachmentMessages({
|
|
@@ -1760,16 +1833,10 @@ export class ClientSession {
|
|
|
1760
1833
|
text: `提示发送失败:${err.message}`,
|
|
1761
1834
|
});
|
|
1762
1835
|
}
|
|
1763
|
-
//
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
conv.title = trimmed.length > 30 ? `${trimmed.slice(0, 30)}…` : trimmed;
|
|
1768
|
-
this.emitConversations();
|
|
1769
|
-
}
|
|
1770
|
-
// The active conversation has been continued since it was opened — it
|
|
1771
|
-
// must not be dismissed when the user switches away. (Also bumps the
|
|
1772
|
-
// per-project "most recently active" order used by set_cwd.)
|
|
1836
|
+
// The active conversation (captured at prompt start — see above) has been
|
|
1837
|
+
// continued since it was opened — it must not be dismissed when the user
|
|
1838
|
+
// switches away. (Also bumps the per-project "most recently active"
|
|
1839
|
+
// order used by set_cwd.)
|
|
1773
1840
|
conv.promptedSinceActive = true;
|
|
1774
1841
|
conv.lastActiveAt = Date.now();
|
|
1775
1842
|
// Fresh run — restart the stall watchdog window.
|
|
@@ -2095,9 +2162,12 @@ export class ClientSession {
|
|
|
2095
2162
|
return;
|
|
2096
2163
|
const displaced = this.displaceActive();
|
|
2097
2164
|
this.activeId = id;
|
|
2098
|
-
|
|
2099
|
-
//
|
|
2100
|
-
//
|
|
2165
|
+
const newCwd = this.conv.cwd;
|
|
2166
|
+
// A listed conversation may belong to ANOTHER project (cross-project
|
|
2167
|
+
// running list). Switching to it must also switch the active workspace
|
|
2168
|
+
// — otherwise the file tree / session history / recent-projects order
|
|
2169
|
+
// would keep showing the OLD project while the chat shows the new one.
|
|
2170
|
+
const cwdChanged = newCwd !== this.cwd;
|
|
2101
2171
|
if (displaced)
|
|
2102
2172
|
this.removeConversation(displaced.id);
|
|
2103
2173
|
this.conv.promptedSinceActive = false;
|
|
@@ -2108,15 +2178,33 @@ export class ClientSession {
|
|
|
2108
2178
|
this.pushTerminals();
|
|
2109
2179
|
// The switched-to conversation has its own runtime (own resource cache).
|
|
2110
2180
|
void this.pushSlashCommands();
|
|
2181
|
+
if (cwdChanged) {
|
|
2182
|
+
this.cwd = newCwd;
|
|
2183
|
+
// Mirror set_cwd's project-switch side-effects so the whole UI follows
|
|
2184
|
+
// the new workspace, not just the chat pane.
|
|
2185
|
+
try {
|
|
2186
|
+
this.onCwdChanged?.(newCwd);
|
|
2187
|
+
}
|
|
2188
|
+
catch {
|
|
2189
|
+
/* hook failure must not break the switch */
|
|
2190
|
+
}
|
|
2191
|
+
this.stateStore.remember(this.clientId, newCwd);
|
|
2192
|
+
void this.pushProjects();
|
|
2193
|
+
void this.refreshSessions();
|
|
2194
|
+
void this.listFiles(undefined);
|
|
2195
|
+
void this.listCommands();
|
|
2196
|
+
}
|
|
2111
2197
|
this.flushSnapshot();
|
|
2112
2198
|
}
|
|
2113
|
-
/** Push
|
|
2199
|
+
/** Push every running conversation across ALL projects to the client. The
|
|
2200
|
+
* running-conversation list is global so a background run from another
|
|
2201
|
+
* workspace stays visible; clicking one switches both the conversation and
|
|
2202
|
+
* its project (see switchConversation). The client groups the list by cwd. */
|
|
2114
2203
|
emitConversations() {
|
|
2115
2204
|
const conversations = [];
|
|
2116
2205
|
for (const conv of this.convs.values()) {
|
|
2117
|
-
//
|
|
2118
|
-
|
|
2119
|
-
if (conv.cwd !== this.cwd || !conv.listed)
|
|
2206
|
+
// Only conversations that were displaced to the background while running.
|
|
2207
|
+
if (!conv.listed)
|
|
2120
2208
|
continue;
|
|
2121
2209
|
let messageCount = 0;
|
|
2122
2210
|
let isStreaming = false;
|
|
@@ -2223,26 +2311,86 @@ export class ClientSession {
|
|
|
2223
2311
|
});
|
|
2224
2312
|
}
|
|
2225
2313
|
}
|
|
2226
|
-
/**
|
|
2314
|
+
/** Open a persisted session as the active conversation (from listSessions).
|
|
2315
|
+
*
|
|
2316
|
+
* A persisted-session click must follow the same ownership rule as
|
|
2317
|
+
* new_chat/switch_conversation: every open conversation keeps its own
|
|
2318
|
+
* runtime. AgentSessionRuntime.switchSession() tears down (and aborts) the
|
|
2319
|
+
* current runtime, which would otherwise stop a response merely because the
|
|
2320
|
+
* user opened history while it was streaming.
|
|
2321
|
+
*/
|
|
2227
2322
|
async switchSession(path) {
|
|
2228
2323
|
if (this.quiesceBlocked())
|
|
2229
2324
|
return;
|
|
2325
|
+
let openedRuntime = null;
|
|
2326
|
+
let openedTerminals = null;
|
|
2230
2327
|
try {
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
//
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2328
|
+
const targetPath = resolve(path);
|
|
2329
|
+
// A session may already be open in the running-conversation map. Reuse it
|
|
2330
|
+
// instead of creating a second writer for the same JSONL transcript.
|
|
2331
|
+
for (const conv of this.convs.values()) {
|
|
2332
|
+
const sessionFile = conv.session.sessionFile;
|
|
2333
|
+
if (sessionFile && resolve(sessionFile) === targetPath) {
|
|
2334
|
+
await this.switchConversation(conv.id);
|
|
2335
|
+
return;
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
const sessionManager = SessionManager.open(targetPath);
|
|
2339
|
+
const targetCwd = sessionManager.getCwd();
|
|
2340
|
+
const conversationId = this.nextConversationId();
|
|
2341
|
+
openedTerminals = this.makeTerminalManager(conversationId, targetCwd);
|
|
2342
|
+
openedRuntime = await createAgentSessionRuntime(this.makeRuntimeFactory(openedTerminals), {
|
|
2343
|
+
cwd: targetCwd,
|
|
2344
|
+
agentDir: this.agentDir,
|
|
2345
|
+
sessionManager,
|
|
2346
|
+
});
|
|
2347
|
+
// Only displace the old active conversation after the replacement runtime
|
|
2348
|
+
// is known-good. This keeps a failed history open entirely non-destructive.
|
|
2349
|
+
const oldListed = this.conv.listed;
|
|
2350
|
+
const displaced = this.displaceActive();
|
|
2351
|
+
const openInProject = [...this.convs.values()].filter((c) => c.cwd === targetCwd).length +
|
|
2352
|
+
1 -
|
|
2353
|
+
(displaced?.cwd === targetCwd ? 1 : 0);
|
|
2354
|
+
if (openInProject > MAX_OPEN_CONVERSATIONS) {
|
|
2355
|
+
// displaceActive() may have promoted a streaming conversation into the
|
|
2356
|
+
// running list. Roll that presentation-only mutation back because no
|
|
2357
|
+
// switch will take place.
|
|
2358
|
+
this.conv.listed = oldListed;
|
|
2359
|
+
openedTerminals.killAll();
|
|
2360
|
+
await openedRuntime.dispose();
|
|
2361
|
+
openedRuntime = null;
|
|
2362
|
+
openedTerminals = null;
|
|
2363
|
+
this.emit({
|
|
2364
|
+
type: "notice",
|
|
2365
|
+
level: "warning",
|
|
2366
|
+
text: `当前项目运行的对话已达上限(${MAX_OPEN_CONVERSATIONS} 个),请先打开某个对话并离开(不继续对话)以移出列表`,
|
|
2367
|
+
});
|
|
2368
|
+
return;
|
|
2369
|
+
}
|
|
2370
|
+
const conv = this.makeConversation(openedRuntime, conversationId, openedTerminals);
|
|
2238
2371
|
// Deliberately resumed — must not be dismissed when the user later
|
|
2239
2372
|
// switches away without sending a new message.
|
|
2240
|
-
|
|
2373
|
+
conv.promptedSinceActive = true;
|
|
2374
|
+
this.convs.set(conv.id, conv);
|
|
2375
|
+
this.activeId = conv.id;
|
|
2376
|
+
openedRuntime = null;
|
|
2377
|
+
openedTerminals = null;
|
|
2378
|
+
if (displaced)
|
|
2379
|
+
this.removeConversation(displaced.id);
|
|
2380
|
+
await this.bindSession();
|
|
2381
|
+
this.cwd = targetCwd;
|
|
2382
|
+
this.conv.lastActiveAt = Date.now();
|
|
2383
|
+
this.webUi.refresh();
|
|
2241
2384
|
this.emitConversations();
|
|
2242
|
-
|
|
2385
|
+
this.goalSvc.emitGoalStatus();
|
|
2386
|
+
this.pushTerminals();
|
|
2387
|
+
// The restored conversation has a fresh project-bound resource cache.
|
|
2243
2388
|
void this.pushSlashCommands();
|
|
2244
2389
|
}
|
|
2245
2390
|
catch (err) {
|
|
2391
|
+
openedTerminals?.killAll();
|
|
2392
|
+
if (openedRuntime)
|
|
2393
|
+
await openedRuntime.dispose().catch(() => { });
|
|
2246
2394
|
this.emit({
|
|
2247
2395
|
type: "notice",
|
|
2248
2396
|
level: "error",
|
|
@@ -448,9 +448,9 @@ export async function buildAttachmentMessages(ctx, attachments) {
|
|
|
448
448
|
content: [
|
|
449
449
|
{
|
|
450
450
|
type: "text",
|
|
451
|
-
text: `
|
|
452
|
-
<vision-bridge>
|
|
453
|
-
${transcript}
|
|
451
|
+
text: `
|
|
452
|
+
<vision-bridge>
|
|
453
|
+
${transcript}
|
|
454
454
|
</vision-bridge>`,
|
|
455
455
|
},
|
|
456
456
|
...(pathImg
|
|
@@ -178,6 +178,7 @@ export class ClientStateStore {
|
|
|
178
178
|
terminalBash: s?.settings?.terminalBash ?? false,
|
|
179
179
|
terminalBashIdleMs: s?.settings?.terminalBashIdleMs ?? 15_000,
|
|
180
180
|
thinkingWrap: s?.settings?.thinkingWrap ?? false,
|
|
181
|
+
toolsWrap: s?.settings?.toolsWrap ?? true,
|
|
181
182
|
visionBridgeEnabled: s?.settings?.visionBridgeEnabled ?? true,
|
|
182
183
|
visionBridgeModel: s?.settings?.visionBridgeModel ?? null,
|
|
183
184
|
visionBridgePromptMode: s?.settings?.visionBridgePromptMode === "replace" ? "replace" : "append",
|
|
@@ -201,6 +202,7 @@ export class ClientStateStore {
|
|
|
201
202
|
terminalBash: settings.terminalBash ?? cur.terminalBash ?? false,
|
|
202
203
|
terminalBashIdleMs: settings.terminalBashIdleMs ?? cur.terminalBashIdleMs ?? 15_000,
|
|
203
204
|
thinkingWrap: settings.thinkingWrap ?? cur.thinkingWrap ?? false,
|
|
205
|
+
toolsWrap: settings.toolsWrap ?? cur.toolsWrap ?? true,
|
|
204
206
|
visionBridgeEnabled: settings.visionBridgeEnabled ?? cur.visionBridgeEnabled ?? true,
|
|
205
207
|
visionBridgeModel: settings.visionBridgeModel ?? cur.visionBridgeModel ?? null,
|
|
206
208
|
visionBridgePromptMode: settings.visionBridgePromptMode ??
|
package/dist/server/index.js
CHANGED
|
@@ -179,6 +179,11 @@ const here = dirname(fileURLToPath(import.meta.url)); // <pkg>/dist/server or <p
|
|
|
179
179
|
// Resolve the package root robustly: dev runs from <repo>/server (tsx), prod
|
|
180
180
|
// from <pkg>/dist/server — the ancestor that actually has package.json wins.
|
|
181
181
|
function resolvePkgRoot() {
|
|
182
|
+
// PI_WEB_PKG_ROOT: Electron 桌面版打包后,server 子进程从 extraResources 目录
|
|
183
|
+
//(process.resourcesPath)加载 web/dist 和 themes。通过这个 env var 告诉
|
|
184
|
+
// server 去哪里找 pkgRoot,避免 resolvePkgRoot 的候选路径找不到 package.json。
|
|
185
|
+
if (process.env.PI_WEB_PKG_ROOT)
|
|
186
|
+
return process.env.PI_WEB_PKG_ROOT;
|
|
182
187
|
const candidates = [
|
|
183
188
|
resolve(here, ".."),
|
|
184
189
|
resolve(here, "..", ".."),
|
|
@@ -717,6 +722,7 @@ wss.on("connection", (ws) => {
|
|
|
717
722
|
terminalBash: msg.terminalBash,
|
|
718
723
|
terminalBashIdleMs: msg.terminalBashIdleMs,
|
|
719
724
|
thinkingWrap: msg.thinkingWrap,
|
|
725
|
+
toolsWrap: msg.toolsWrap,
|
|
720
726
|
visionBridgeEnabled: msg.visionBridgeEnabled,
|
|
721
727
|
visionBridgeModel: msg.visionBridgeModel,
|
|
722
728
|
visionBridgePromptMode: msg.visionBridgePromptMode,
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* 预设存取 + 何时需要 reload」,真正动 runtime 的 session.reload() 走宿主回调
|
|
7
7
|
* (reloadSession 里还会刷新斜杠命令目录)。
|
|
8
8
|
*/
|
|
9
|
-
import {
|
|
9
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
10
|
+
import { basename, dirname, join } from "node:path";
|
|
10
11
|
import { extensionKey } from "./client-state.js";
|
|
11
12
|
import { findVisionModels, SYSTEM_PROMPT } from "./vision-bridge.js";
|
|
12
13
|
export class SettingsService {
|
|
@@ -39,20 +40,88 @@ export class SettingsService {
|
|
|
39
40
|
this.pendingReload = false;
|
|
40
41
|
return v;
|
|
41
42
|
}
|
|
43
|
+
/** 判断某个 skill 名是否仍存在于磁盘任何来源(agent 区 / 项目 .pi / 祖先
|
|
44
|
+
* .agents/skills / npm 包内 skills)。被禁用且文件已删除的名字不应再
|
|
45
|
+
* 出现在设置面板,也不应留在持久化记录里。 */
|
|
46
|
+
skillStillOnDisk(name) {
|
|
47
|
+
const cwd = this.host.cwd();
|
|
48
|
+
const agentDir = this.host.agentDir();
|
|
49
|
+
const check = (base) => existsSync(join(base, name)) || existsSync(join(base, `${name}.md`));
|
|
50
|
+
// ① 用户区 <agentDir>/skills ② 项目 .pi/skills
|
|
51
|
+
if (check(join(agentDir, "skills")))
|
|
52
|
+
return true;
|
|
53
|
+
if (check(join(cwd, ".pi", "skills")))
|
|
54
|
+
return true;
|
|
55
|
+
// ③ 祖先链 .agents/skills(SDK collectAncestorAgentsSkillDirs 语义,最多上溯 6 层)
|
|
56
|
+
let dir = cwd;
|
|
57
|
+
for (let i = 0; i < 6 && dir !== dirname(dir); i++, dir = dirname(dir)) {
|
|
58
|
+
if (check(join(dir, ".agents", "skills")))
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
// ④ npm 包内 skills(agent 级 + 项目级,含 @scope 两级子包)
|
|
62
|
+
for (const npmRoot of [
|
|
63
|
+
join(agentDir, "npm", "node_modules"),
|
|
64
|
+
join(cwd, ".pi", "npm", "node_modules"),
|
|
65
|
+
]) {
|
|
66
|
+
try {
|
|
67
|
+
for (const entry of readdirSync(npmRoot, { withFileTypes: true })) {
|
|
68
|
+
if (!entry.isDirectory())
|
|
69
|
+
continue;
|
|
70
|
+
if (!entry.name.startsWith("@")) {
|
|
71
|
+
if (check(join(npmRoot, entry.name, "skills")))
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
for (const sub of readdirSync(join(npmRoot, entry.name), { withFileTypes: true })) {
|
|
76
|
+
if (sub.isDirectory() && check(join(npmRoot, entry.name, sub.name, "skills"))) {
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// npm 目录不存在/不可读 → 不是来源
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
42
89
|
push() {
|
|
43
90
|
const disabledSkills = new Set(this.settings.disabledSkills);
|
|
44
91
|
const reviewDisabledSkills = new Set(this.settings.reviewDisabledSkills);
|
|
45
92
|
const disabledExts = new Set(this.settings.disabledExtensions);
|
|
93
|
+
let loadedSkillNames = null;
|
|
46
94
|
try {
|
|
47
|
-
|
|
48
|
-
|
|
95
|
+
const loadedSkills = this.host.getSession().resourceLoader.getSkills().skills;
|
|
96
|
+
const loadedExts = this.host.getSession().resourceLoader.getExtensions().extensions;
|
|
97
|
+
loadedSkillNames = new Set(loadedSkills.map((s) => s.name));
|
|
98
|
+
// Prune entries that no longer exist on disk AND aren't disabled
|
|
99
|
+
// (e.g. a skill/extension file was deleted). Disabled entries are
|
|
100
|
+
// kept so they can be re-enabled even when filtered out of the loader.
|
|
101
|
+
const keepSkills = new Set([
|
|
102
|
+
...loadedSkills.map((s) => s.name),
|
|
103
|
+
...this.settings.disabledSkills,
|
|
104
|
+
]);
|
|
105
|
+
const keepExts = new Set([
|
|
106
|
+
...loadedExts.map((e) => extensionKey(e)),
|
|
107
|
+
...this.settings.disabledExtensions,
|
|
108
|
+
]);
|
|
109
|
+
for (const name of [...this.knownSkills.keys()]) {
|
|
110
|
+
if (!keepSkills.has(name))
|
|
111
|
+
this.knownSkills.delete(name);
|
|
112
|
+
}
|
|
113
|
+
for (const id of [...this.knownExtensions.keys()]) {
|
|
114
|
+
if (!keepExts.has(id))
|
|
115
|
+
this.knownExtensions.delete(id);
|
|
116
|
+
}
|
|
117
|
+
for (const s of loadedSkills) {
|
|
49
118
|
this.knownSkills.set(s.name, {
|
|
50
119
|
name: s.name,
|
|
51
120
|
description: s.description,
|
|
52
121
|
enabled: true,
|
|
53
122
|
});
|
|
54
123
|
}
|
|
55
|
-
for (const e of
|
|
124
|
+
for (const e of loadedExts) {
|
|
56
125
|
const id = extensionKey(e);
|
|
57
126
|
const p = e.sourceInfo?.path ?? e.path;
|
|
58
127
|
this.knownExtensions.set(id, {
|
|
@@ -68,13 +137,33 @@ export class SettingsService {
|
|
|
68
137
|
catch {
|
|
69
138
|
// Session not ready yet — keep whatever we already know.
|
|
70
139
|
}
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
140
|
+
// 清理“源文件已删除”的禁用残留记录:磁盘上已不存在的技能名从
|
|
141
|
+
// disabledSkills / reviewDisabledSkills 持久化记录中移除——否则每次
|
|
142
|
+
// 推送都会把已删除的 skill 以灰条形式永恒地补回面板(“关闭过的
|
|
143
|
+
// skill 被一直记录”)。session 未就绪时保守跳过。
|
|
144
|
+
if (loadedSkillNames !== null) {
|
|
145
|
+
const stale = [
|
|
146
|
+
...new Set([...this.settings.disabledSkills, ...this.settings.reviewDisabledSkills]),
|
|
147
|
+
].filter((name) => !loadedSkillNames.has(name) && !this.skillStillOnDisk(name));
|
|
148
|
+
if (stale.length > 0) {
|
|
149
|
+
this.settings.disabledSkills = this.settings.disabledSkills.filter((n) => !stale.includes(n));
|
|
150
|
+
this.settings.reviewDisabledSkills = this.settings.reviewDisabledSkills.filter((n) => !stale.includes(n));
|
|
151
|
+
this.host.stateStore.saveSettings(this.host.clientId, {
|
|
152
|
+
disabledSkills: this.settings.disabledSkills,
|
|
153
|
+
reviewDisabledSkills: this.settings.reviewDisabledSkills,
|
|
154
|
+
});
|
|
76
155
|
}
|
|
77
156
|
}
|
|
157
|
+
// Disabled entries that still exist on disk are re-added (with the
|
|
158
|
+
// last-known description) so they can be re-enabled; entries whose
|
|
159
|
+
// source file was deleted are dropped instead of being resurrected.
|
|
160
|
+
for (const name of this.settings.disabledSkills) {
|
|
161
|
+
if (this.knownSkills.has(name))
|
|
162
|
+
continue;
|
|
163
|
+
if (!this.skillStillOnDisk(name))
|
|
164
|
+
continue;
|
|
165
|
+
this.knownSkills.set(name, { name, description: "", enabled: false });
|
|
166
|
+
}
|
|
78
167
|
for (const id of this.settings.disabledExtensions) {
|
|
79
168
|
if (!this.knownExtensions.has(id)) {
|
|
80
169
|
this.knownExtensions.set(id, {
|
|
@@ -103,6 +192,7 @@ export class SettingsService {
|
|
|
103
192
|
terminalBash: this.settings.terminalBash,
|
|
104
193
|
terminalBashIdleMs: this.settings.terminalBashIdleMs,
|
|
105
194
|
thinkingWrap: this.settings.thinkingWrap,
|
|
195
|
+
toolsWrap: this.settings.toolsWrap,
|
|
106
196
|
visionBridgeEnabled: this.settings.visionBridgeEnabled,
|
|
107
197
|
visionBridgeModel: this.settings.visionBridgeModel,
|
|
108
198
|
visionBridgePromptMode: this.settings.visionBridgePromptMode,
|
|
@@ -174,6 +264,9 @@ export class SettingsService {
|
|
|
174
264
|
if (partial.thinkingWrap !== undefined) {
|
|
175
265
|
this.settings.thinkingWrap = partial.thinkingWrap;
|
|
176
266
|
}
|
|
267
|
+
if (partial.toolsWrap !== undefined) {
|
|
268
|
+
this.settings.toolsWrap = partial.toolsWrap;
|
|
269
|
+
}
|
|
177
270
|
if (partial.visionBridgeEnabled !== undefined) {
|
|
178
271
|
this.settings.visionBridgeEnabled = partial.visionBridgeEnabled;
|
|
179
272
|
}
|
|
@@ -245,10 +338,11 @@ export class SettingsService {
|
|
|
245
338
|
reviewDisabledSkills: [
|
|
246
339
|
...(p.reviewDisabledSkills ?? this.settings.reviewDisabledSkills),
|
|
247
340
|
],
|
|
248
|
-
// Presets don't capture vision-bridge prefs — keep the current ones.
|
|
249
|
-
visionBridgeEnabled: this.settings.visionBridgeEnabled,
|
|
250
341
|
// 纯 UI 偏好不进预设——保留当前值。
|
|
251
342
|
thinkingWrap: this.settings.thinkingWrap,
|
|
343
|
+
toolsWrap: this.settings.toolsWrap,
|
|
344
|
+
// Presets don't capture vision-bridge prefs — keep the current ones.
|
|
345
|
+
visionBridgeEnabled: this.settings.visionBridgeEnabled,
|
|
252
346
|
visionBridgeModel: this.settings.visionBridgeModel,
|
|
253
347
|
visionBridgePromptMode: this.settings.visionBridgePromptMode,
|
|
254
348
|
visionBridgePrompt: this.settings.visionBridgePrompt,
|
package/dist/server/terminals.js
CHANGED
|
@@ -1230,11 +1230,11 @@ export const TERMINAL_TOOL_NAMES = [
|
|
|
1230
1230
|
/** System-prompt guidance teaching the model WHEN to prefer the terminal tools
|
|
1231
1231
|
* over one-shot bash. Without it models almost never pick them — bash returns
|
|
1232
1232
|
* complete output in a single call, so it always wins on convenience. */
|
|
1233
|
-
export const TERMINAL_TOOLS_GUIDANCE = `Persistent interactive terminal tools are available (terminal_create / terminal_list / terminal_close / terminal_input / terminal_key / terminal_read / terminal_wait). The one-shot bash tool stays the DEFAULT for ordinary commands - it runs once and returns the full output. Switch to the terminal tools only when:
|
|
1234
|
-
- The program is interactive or TUI-based (REPLs like python/node, vim/htop, installers asking y/n, anything waiting on stdin).
|
|
1235
|
-
- You start a long-running server or watcher and want to keep watching its output (terminal_read with waitMs), send keys to it later (e.g. interrupt via terminal_key with Ctrl+c), or block until a backgrounded command finishes without polling (terminal_wait).
|
|
1236
|
-
- The user explicitly asks you to work in the visible terminal panel.
|
|
1237
|
-
Liveness watchdog: terminals you touched (create/input/key) are monitored - if one goes silent with no new output while you are working (default 15s), an automatic system reminder is injected into the conversation. Treat it as a prompt to check that terminal (terminal_read), respond to an input prompt (terminal_input / terminal_key), or close it (terminal_close) if it is no longer needed.
|
|
1233
|
+
export const TERMINAL_TOOLS_GUIDANCE = `Persistent interactive terminal tools are available (terminal_create / terminal_list / terminal_close / terminal_input / terminal_key / terminal_read / terminal_wait). The one-shot bash tool stays the DEFAULT for ordinary commands - it runs once and returns the full output. Switch to the terminal tools only when:
|
|
1234
|
+
- The program is interactive or TUI-based (REPLs like python/node, vim/htop, installers asking y/n, anything waiting on stdin).
|
|
1235
|
+
- You start a long-running server or watcher and want to keep watching its output (terminal_read with waitMs), send keys to it later (e.g. interrupt via terminal_key with Ctrl+c), or block until a backgrounded command finishes without polling (terminal_wait).
|
|
1236
|
+
- The user explicitly asks you to work in the visible terminal panel.
|
|
1237
|
+
Liveness watchdog: terminals you touched (create/input/key) are monitored - if one goes silent with no new output while you are working (default 15s), an automatic system reminder is injected into the conversation. Treat it as a prompt to check that terminal (terminal_read), respond to an input prompt (terminal_input / terminal_key), or close it (terminal_close) if it is no longer needed.
|
|
1238
1238
|
Do NOT use them for simple one-shot commands; bash remains cheaper and simpler there.`;
|
|
1239
1239
|
/** Build the agent-facing persistent terminal tools for one conversation. */
|
|
1240
1240
|
export function makePersistentTerminalTools(terminals, cwd) {
|