@oversk7/pi-web-dsh-style 1.0.1 → 1.1.1

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/server.ts CHANGED
@@ -16,20 +16,25 @@ import {
16
16
  type RpcQueueKind,
17
17
  type RpcQueueSnapshot,
18
18
  } from "./abort-session.ts";
19
- import { ImageAttachmentStore } from "./attachment-store.ts";
19
+ import { AttachmentStore, detectImageMimeType } from "./attachment-store.ts";
20
+ import { builtinCommandError, parseSlashCommand } from "./web/slash-commands.js";
21
+ import { extensionSettings } from "./extension-settings.ts";
20
22
  import { isLocalRequest, WebAccess } from "./network-access.ts";
21
23
  import { FrpProcess } from "./frp-process.ts";
24
+ import { loadTerminalProxy, saveTerminalProxy, validateTerminalProxy, type TerminalProxySettings } from "./terminal-proxy.ts";
22
25
  import { frpcConfig, loadNetworkSettings, proxyForNetwork, publicNetworkSettings, relayServerFiles, resolveFrpc, saveNetworkSettings, updateNetworkSettings, type NetworkMode, type NetworkSettings } from "./network-settings.ts";
23
- import { closeSseClient, createSseClient, sendSseEvent, type SseClient } from "./sse-channel.ts";
26
+ import { closeSseClient, createSseClient, createSnapshotScheduler, sendSseEvent, type SseClient } from "./sse-channel.ts";
24
27
  import { closeAllPiRpcs, spawnPiRpc, type Json, type PiRpc } from "./pi-rpc.ts";
25
28
  import { loadLocalSession } from "./local-session.ts";
26
29
  import { loadJsonWithLegacyMigration, writeJsonAtomically, writeTextAtomically } from "./state-store.ts";
27
30
  import { buildFilePreview } from "./file-preview.ts";
31
+ import { collectBackgroundJobs, readBackgroundJobLog } from "./background-jobs.ts";
28
32
  import { readClipboardImage, readClipboardText } from "./windows-clipboard.ts";
29
33
  import {
30
34
  applyAssistantDelta,
31
35
  applyToolExecution,
32
36
  attachToolResults,
37
+ browserMessages,
33
38
  findLast,
34
39
  renderAssistantMessage,
35
40
  renderCompaction,
@@ -49,6 +54,7 @@ const STATE_FILE = resolve(process.env.PI_WEB_STATE_FILE || join(STATE_DIR, "sta
49
54
  const LEGACY_STATE_FILE = resolve(process.env.PI_WEB_LEGACY_STATE_FILE || join(here, "state.json"));
50
55
  const AUTH_FILE = join(dirname(STATE_FILE), "auth.json");
51
56
  const NETWORK_FILE = join(dirname(STATE_FILE), "network.json");
57
+ const TERMINAL_PROXY_FILE = join(dirname(STATE_FILE), "terminal-proxy.json");
52
58
 
53
59
  interface Workspace {
54
60
  id: string;
@@ -186,6 +192,7 @@ interface RuntimeSession {
186
192
  operationChain?: Promise<void>;
187
193
  pendingTreeNavigation?: PendingTreeNavigation;
188
194
  compactionNotice?: RenderedMessage;
195
+ compactionNoticeIndex?: number;
189
196
  pendingQueue?: RpcQueueSnapshot;
190
197
  }
191
198
 
@@ -212,6 +219,8 @@ let serverUrl: string | undefined;
212
219
  let serverInstanceId: string | undefined;
213
220
  let webAccess = new WebAccess(false);
214
221
  let networkSettings: NetworkSettings = { mode: "local" };
222
+ let terminalProxy: TerminalProxySettings = { mode: "inherit", url: "http://127.0.0.1:7987" };
223
+ let terminalProxySaving = false;
215
224
  let networkApplying = false;
216
225
  let networkChange: Promise<void> = Promise.resolve();
217
226
  let networkError: string | undefined;
@@ -219,6 +228,8 @@ let frpRuntimeDir: string | undefined;
219
228
  const frpProcess = new FrpProcess(() => broadcastEvent({ type: "network_changed" }));
220
229
  let updateExtensions: (() => Promise<MaintenanceCommandResult>) | undefined;
221
230
  let reloadRuntime: (() => Promise<void>) | undefined;
231
+ let extensionSettingsCwd = process.cwd();
232
+ let extensionSettingsSaving = false;
222
233
  let maintenanceRunning = false;
223
234
  let sessions = new Map<string, RuntimeSession>();
224
235
  let sseClients = new Set<SseClient>();
@@ -233,7 +244,7 @@ const modelInfoCache = new Map<string, ModelInfo>();
233
244
  const modelCatalogRefreshes = new Map<string, Promise<ModelCatalog>>();
234
245
  const commandCache = new Map<string, SlashCommandInfo[]>();
235
246
  const exportDownloads = new Map<string, ExportDownload>();
236
- const imageAttachments = new ImageAttachmentStore();
247
+ const attachments = new AttachmentStore();
237
248
  const ABORT_RPC_TIMEOUT_MS = 3_000;
238
249
  const INTERNAL_TREE_COMMAND = "pi-web-navigate-tree";
239
250
  const INTERNAL_RELOAD_COMMAND = "pi-web-reload-runtime";
@@ -252,8 +263,10 @@ const MIME: Record<string, string> = {
252
263
  ".woff2": "font/woff2",
253
264
  ".ttf": "font/ttf",
254
265
  ".png": "image/png",
266
+ ".webp": "image/webp",
255
267
  };
256
268
  const MAX_FILE_VIEW_BYTES = 2 * 1024 * 1024;
269
+ const MAX_IMAGE_VIEW_BYTES = 10 * 1024 * 1024;
257
270
 
258
271
  function nowIso(): string {
259
272
  return new Date().toISOString();
@@ -409,7 +422,7 @@ function historyEditorText(record: RuntimeSession, entryId: string): string | un
409
422
  async function pasteSystemClipboard(): Promise<Json> {
410
423
  const image = await readClipboardImage();
411
424
  if (image) {
412
- const attachment = await imageAttachments.create(image.bytes);
425
+ const attachment = await attachments.create(image.bytes);
413
426
  return {
414
427
  kind: "image",
415
428
  attachment: { ...attachment, url: `/api/attachments/${attachment.id}` },
@@ -630,7 +643,9 @@ function snapshotMessages(record: RuntimeSession): RenderedMessage[] {
630
643
  return merged;
631
644
  }
632
645
  }
633
- return [...record.messages, notice];
646
+ const messages = [...record.messages];
647
+ messages.splice(Math.min(record.compactionNoticeIndex ?? messages.length, messages.length), 0, notice);
648
+ return messages;
634
649
  }
635
650
 
636
651
  function pendingQueueSnapshot(record: RuntimeSession): RpcQueueSnapshot {
@@ -651,7 +666,14 @@ function setPendingQueue(record: RuntimeSession, queue: RpcQueueSnapshot): void
651
666
  };
652
667
  }
653
668
 
669
+ const snapshotScheduler = createSnapshotScheduler<RuntimeSession>(emitSessionSnapshot);
670
+
654
671
  function broadcastSessionSnapshot(record: RuntimeSession): void {
672
+ if (shuttingDown) return;
673
+ snapshotScheduler.schedule(record.id, record, !record.streaming || record.dialogs.length > 0);
674
+ }
675
+
676
+ function emitSessionSnapshot(record: RuntimeSession): void {
655
677
  broadcastEvent({
656
678
  type: "snapshot",
657
679
  sessionId: record.id,
@@ -663,7 +685,7 @@ function broadcastSessionSnapshot(record: RuntimeSession): void {
663
685
  thinkingLevelsByModel: thinkingLevelsByModel(record.models),
664
686
  commands: record.commands,
665
687
  extensionStatuses: record.extensionStatuses,
666
- messages: snapshotMessages(record),
688
+ messages: browserMessages(snapshotMessages(record)),
667
689
  pendingQueue: pendingQueueSnapshot(record),
668
690
  dialogs: record.dialogs,
669
691
  });
@@ -876,7 +898,7 @@ async function discoverFreshModelCatalog(cwd: string): Promise<ModelCatalog> {
876
898
  const existing = modelCatalogRefreshes.get(key);
877
899
  if (existing) return existing;
878
900
  const task = (async () => {
879
- const probe = spawnPiRpc({ cwd: key, noSession: true });
901
+ const probe = spawnPiRpc({ cwd: key, noSession: true, proxy: terminalProxy });
880
902
  try {
881
903
  const [modelsResponse, stateResponse] = await Promise.all([
882
904
  probe.send<Json>({ type: "get_available_models" }),
@@ -914,7 +936,7 @@ function applyModelInfo(record: RuntimeSession, info: ModelInfo): void {
914
936
 
915
937
  async function refreshModelsFresh(record: RuntimeSession): Promise<void> {
916
938
  if (record.starting) await record.starting;
917
- if (record.streaming) {
939
+ if (record.streaming || hasRunningBackgroundJobs(record)) {
918
940
  await refreshModels(record);
919
941
  return;
920
942
  }
@@ -925,10 +947,15 @@ async function refreshModelsFresh(record: RuntimeSession): Promise<void> {
925
947
  const runtimeModels = (runtimeResponse.data?.models ?? []) as unknown[];
926
948
  if (!sameModelCatalog(runtimeModels, catalog.models)) {
927
949
  await Promise.allSettled([refreshState(record), refreshMessages(record)]);
950
+ if (record.streaming || hasRunningBackgroundJobs(record)) {
951
+ await refreshModels(record);
952
+ return;
953
+ }
928
954
  await stopSessionProcess(record);
929
955
  }
930
956
  } catch (error) {
931
957
  console.error("[pi-web] runtime model refresh failed:", error);
958
+ if (record.streaming || hasRunningBackgroundJobs(record)) return;
932
959
  await stopSessionProcess(record);
933
960
  }
934
961
  }
@@ -1232,6 +1259,7 @@ function bindSessionEvents(record: RuntimeSession, proc: PiRpc): void {
1232
1259
  } else if (event.type === "compaction_start") {
1233
1260
  const reason = typeof event.reason === "string" ? event.reason : undefined;
1234
1261
  record.state = { ...(record.state ?? {}), isCompacting: true };
1262
+ record.compactionNoticeIndex = record.messages.length;
1235
1263
  record.compactionNotice = {
1236
1264
  kind: "compaction",
1237
1265
  id: `compaction-running:${Date.now()}`,
@@ -1263,6 +1291,7 @@ function bindSessionEvents(record: RuntimeSession, proc: PiRpc): void {
1263
1291
  timestamp: Date.now(),
1264
1292
  };
1265
1293
  record.compactionNotice = notice;
1294
+ record.compactionNoticeIndex = record.messages.length;
1266
1295
  broadcastSessionSnapshot(record);
1267
1296
  if (result) {
1268
1297
  Promise.allSettled([refreshMessages(record), refreshStats(record)]).then(() => {
@@ -1308,10 +1337,15 @@ function bindSessionEvents(record: RuntimeSession, proc: PiRpc): void {
1308
1337
  // session lifecycle
1309
1338
  // ---------------------------------------------------------------------------
1310
1339
 
1340
+ function hasRunningBackgroundJobs(record: RuntimeSession): boolean {
1341
+ // pi-pwsh-notify clears this live status only after its last background job exits.
1342
+ return Boolean(record.extensionStatuses["pwsh-bg"]);
1343
+ }
1344
+
1311
1345
  function evictIdleProcesses(): void {
1312
1346
  const idle: RuntimeSession[] = [];
1313
1347
  for (const record of sessions.values()) {
1314
- if (record.proc && !record.streaming && record.status !== "running" && record.status !== "starting" && record.dialogs.length === 0 && record.id !== state.currentSessionId) {
1348
+ if (record.proc && !record.streaming && !hasRunningBackgroundJobs(record) && record.status !== "running" && record.status !== "starting" && record.dialogs.length === 0 && record.id !== state.currentSessionId) {
1315
1349
  idle.push(record);
1316
1350
  }
1317
1351
  }
@@ -1334,8 +1368,7 @@ async function applyPendingSettings(record: RuntimeSession): Promise<void> {
1334
1368
  await proc.send({ type: "set_model", provider: pending.provider, modelId: pending.modelId });
1335
1369
  record.pendingModel = undefined;
1336
1370
  } catch (error) {
1337
- console.error("[pi-web] applying pending model failed:", error);
1338
- record.pendingModel = undefined;
1371
+ throw new Error(`无法使用模型 ${pending.provider}/${pending.modelId},请在模型菜单中选择当前可用的模型:${error instanceof Error ? error.message : String(error)}`);
1339
1372
  }
1340
1373
  }
1341
1374
  if (record.pendingThinkingLevel) {
@@ -1373,6 +1406,7 @@ async function ensureProcess(record: RuntimeSession): Promise<void> {
1373
1406
  const task = (async () => {
1374
1407
  const proc = spawnPiRpc({
1375
1408
  cwd: record.cwd,
1409
+ proxy: terminalProxy,
1376
1410
  sessionId: record.sessionFile ? undefined : record.id,
1377
1411
  sessionFile: record.sessionFile,
1378
1412
  name: record.title === "新会话" ? undefined : record.title,
@@ -1392,9 +1426,16 @@ async function ensureProcess(record: RuntimeSession): Promise<void> {
1392
1426
  broadcastWorkspaces();
1393
1427
  evictIdleProcesses();
1394
1428
  } catch (error) {
1429
+ if (record.proc === proc) record.proc = undefined;
1430
+ try {
1431
+ await proc.close(2500);
1432
+ } catch (closeError) {
1433
+ console.error("[pi-web] closing failed session process:", closeError);
1434
+ }
1395
1435
  record.status = "error";
1396
1436
  record.error = error instanceof Error ? error.message : String(error);
1397
1437
  broadcastSessionSnapshot(record);
1438
+ throw error;
1398
1439
  } finally {
1399
1440
  record.starting = undefined;
1400
1441
  }
@@ -1535,6 +1576,7 @@ async function deleteWorkspaceSession(workspace: Workspace, sessionId: string):
1535
1576
  candidate.updatedAt = updatedAt;
1536
1577
  }
1537
1578
  state.sessions = state.sessions.filter((session) => session.id !== sessionId);
1579
+ snapshotScheduler.cancel(sessionId);
1538
1580
  sessions.delete(sessionId);
1539
1581
  if (state.currentSessionId === sessionId) state.currentSessionId = undefined;
1540
1582
  scheduleSave();
@@ -1548,14 +1590,23 @@ async function createSession(
1548
1590
  ): Promise<RuntimeSession> {
1549
1591
  const ws = state.workspaces.find((w) => w.id === workspaceId);
1550
1592
  if (!ws) throw new Error("workspace not found");
1593
+ const info = modelInfoFromCatalog(
1594
+ await discoverFreshModelCatalog(ws.path),
1595
+ preferences.model,
1596
+ preferences.thinkingLevel,
1597
+ );
1598
+ const selected = info.defaultModel as Json | undefined;
1599
+ const model = selected && typeof selected.provider === "string" && typeof selected.id === "string"
1600
+ ? { provider: selected.provider, modelId: selected.id }
1601
+ : undefined;
1551
1602
  const id = randomUUID();
1552
1603
  const cleanTitle = (title ?? "").trim().replace(/\s+/g, " ").slice(0, 60) || "新会话";
1553
1604
  const stored: StoredSession = {
1554
1605
  id,
1555
1606
  workspaceId,
1556
1607
  title: cleanTitle,
1557
- model: preferences.model,
1558
- thinkingLevel: preferences.thinkingLevel,
1608
+ model,
1609
+ thinkingLevel: info.thinkingLevel,
1559
1610
  needsAttention: false,
1560
1611
  createdAt: nowIso(),
1561
1612
  updatedAt: nowIso(),
@@ -1580,29 +1631,11 @@ async function createSession(
1580
1631
  streaming: false,
1581
1632
  needsAttention: false,
1582
1633
  lastUsed: Date.now(),
1583
- pendingModel: preferences.model,
1584
- pendingThinkingLevel: preferences.thinkingLevel,
1634
+ pendingModel: model,
1635
+ pendingThinkingLevel: info.thinkingLevel,
1585
1636
  };
1586
- applyCachedModelInfo(record);
1637
+ applyModelInfo(record, info);
1587
1638
  applyCachedCommands(record);
1588
- if (preferences.model) {
1589
- const full = (record.models as Json[]).find(
1590
- (model) => model?.provider === preferences.model?.provider && model.id === preferences.model?.modelId,
1591
- );
1592
- record.state = {
1593
- ...(record.state ?? {}),
1594
- model: full ?? { provider: preferences.model.provider, id: preferences.model.modelId, name: preferences.model.modelId },
1595
- };
1596
- if (full) record.thinkingLevels = supportedThinkingLevels(full);
1597
- }
1598
- if (preferences.thinkingLevel) {
1599
- const effectiveLevel = record.thinkingLevels.includes(preferences.thinkingLevel)
1600
- ? preferences.thinkingLevel
1601
- : record.thinkingLevels.includes("off") ? "off" : record.thinkingLevels[0] ?? "off";
1602
- record.pendingThinkingLevel = effectiveLevel;
1603
- stored.thinkingLevel = effectiveLevel;
1604
- record.state = { ...(record.state ?? {}), thinkingLevel: effectiveLevel };
1605
- }
1606
1639
  sessions.set(id, record);
1607
1640
  scheduleSave();
1608
1641
  broadcastWorkspaces();
@@ -2112,6 +2145,46 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
2112
2145
  return sendJson(res, 200, { ok: true });
2113
2146
  }
2114
2147
 
2148
+ if (path === "/api/extensions" && (method === "GET" || method === "POST")) {
2149
+ if (!isLocalRequest(req)) return sendJson(res, 403, { error: "请在电脑本机网页管理扩展" });
2150
+ if (extensionSettingsSaving || maintenanceRunning) return sendJson(res, 409, { error: "扩展配置或维护操作正在执行,请稍后重试" });
2151
+ extensionSettingsSaving = true;
2152
+ try {
2153
+ let change: { scope: "global" | "project"; source: string; enabled: boolean } | undefined;
2154
+ if (method === "POST") {
2155
+ const body = await readJsonBody(req, 16 * 1024);
2156
+ if ((body.scope !== "global" && body.scope !== "project") || typeof body.source !== "string" || !body.source || typeof body.enabled !== "boolean") {
2157
+ return sendJson(res, 400, { error: "扩展配置参数无效" });
2158
+ }
2159
+ change = { scope: body.scope, source: body.source, enabled: body.enabled };
2160
+ }
2161
+ return sendJson(res, 200, await extensionSettings(extensionSettingsCwd, change));
2162
+ } catch (error) {
2163
+ return sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
2164
+ } finally {
2165
+ extensionSettingsSaving = false;
2166
+ }
2167
+ }
2168
+
2169
+ if (path === "/api/terminal-proxy") {
2170
+ if (!isLocalRequest(req)) return sendJson(res, 403, { error: "请在电脑本机网页配置终端代理" });
2171
+ if (method === "GET") return sendJson(res, 200, { settings: terminalProxy });
2172
+ if (method === "POST") {
2173
+ if (terminalProxySaving) return sendJson(res, 409, { error: "终端代理正在保存,请稍后重试" });
2174
+ terminalProxySaving = true;
2175
+ try {
2176
+ const next = validateTerminalProxy(await readJsonBody(req, 4096));
2177
+ await saveTerminalProxy(TERMINAL_PROXY_FILE, next);
2178
+ terminalProxy = next;
2179
+ return sendJson(res, 200, { settings: terminalProxy });
2180
+ } catch (error) {
2181
+ return sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
2182
+ } finally {
2183
+ terminalProxySaving = false;
2184
+ }
2185
+ }
2186
+ }
2187
+
2115
2188
  if (path === "/api/network" || path === "/api/network/server-files") {
2116
2189
  if (!isLocalRequest(req)) return sendJson(res, 403, { error: "请在电脑本机网页配置网络访问" });
2117
2190
  if (method === "GET" && path === "/api/network/server-files") {
@@ -2162,7 +2235,7 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
2162
2235
  }
2163
2236
 
2164
2237
  if (method === "POST" && (path === "/api/maintenance/update-and-reload" || path === "/api/maintenance/reload")) {
2165
- if (maintenanceRunning) return sendJson(res, 409, { error: "维护操作正在执行" });
2238
+ if (maintenanceRunning || extensionSettingsSaving) return sendJson(res, 409, { error: "维护操作或扩展配置正在执行" });
2166
2239
  if (!reloadRuntime || (path.endsWith("update-and-reload") && !updateExtensions)) {
2167
2240
  return sendJson(res, 503, { error: "当前 Pi 运行时不支持 Web 维护操作,请先执行一次 /reload" });
2168
2241
  }
@@ -2202,10 +2275,10 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
2202
2275
  if (method === "POST" && path === "/api/attachments") {
2203
2276
  const body = await readJsonBody(req, 21 * 1024 * 1024);
2204
2277
  if (typeof body.data !== "string" || !/^[A-Za-z0-9+/]+={0,2}$/.test(body.data)) {
2205
- return sendJson(res, 400, { error: "图片数据无效" });
2278
+ return sendJson(res, 400, { error: "文件数据无效" });
2206
2279
  }
2207
2280
  try {
2208
- const attachment = await imageAttachments.create(Buffer.from(body.data, "base64"));
2281
+ const attachment = await attachments.create(Buffer.from(body.data, "base64"), typeof body.name === "string" ? body.name : undefined);
2209
2282
  return sendJson(res, 200, { attachment: { ...attachment, url: `/api/attachments/${attachment.id}` } });
2210
2283
  } catch (error) {
2211
2284
  return sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
@@ -2215,9 +2288,10 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
2215
2288
  const attachmentMatch = /^\/api\/attachments\/([a-f0-9-]+)$/.exec(path);
2216
2289
  if (attachmentMatch && (method === "GET" || method === "HEAD")) {
2217
2290
  try {
2218
- const { meta, bytes } = await imageAttachments.read(attachmentMatch[1]);
2291
+ const { meta, bytes } = await attachments.read(attachmentMatch[1]);
2219
2292
  res.writeHead(200, {
2220
2293
  "content-type": meta.mimeType,
2294
+ ...(meta.kind === "document" ? { "content-disposition": `attachment; filename*=UTF-8''${encodeURIComponent(meta.name)}` } : {}),
2221
2295
  "content-length": bytes.byteLength,
2222
2296
  "cache-control": "private, no-store",
2223
2297
  "x-content-type-options": "nosniff",
@@ -2230,7 +2304,7 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
2230
2304
  }
2231
2305
 
2232
2306
  if (attachmentMatch && method === "DELETE") {
2233
- const removed = await imageAttachments.remove(attachmentMatch[1]);
2307
+ const removed = await attachments.remove(attachmentMatch[1]);
2234
2308
  return sendJson(res, removed ? 200 : 404, removed ? { ok: true } : { error: "attachment not found" });
2235
2309
  }
2236
2310
 
@@ -2266,8 +2340,20 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
2266
2340
  const target = normalizePath(input);
2267
2341
  const info = await stat(target);
2268
2342
  if (!info.isFile()) return sendJson(res, 400, { error: "path is not a file" });
2269
- if (info.size > MAX_FILE_VIEW_BYTES) return sendJson(res, 413, { error: "file is too large to preview" });
2343
+ const image = url.searchParams.get("format") === "image";
2344
+ if (info.size > (image ? MAX_IMAGE_VIEW_BYTES : MAX_FILE_VIEW_BYTES)) return sendJson(res, 413, { error: "file is too large to preview" });
2270
2345
  const data = await readFile(target);
2346
+ if (image) {
2347
+ const mimeType = await detectImageMimeType(target, data);
2348
+ if (!mimeType) return sendJson(res, 415, { error: "file is not a supported image" });
2349
+ res.writeHead(200, {
2350
+ "content-type": mimeType,
2351
+ "cache-control": "private, no-store",
2352
+ "x-content-type-options": "nosniff",
2353
+ });
2354
+ res.end(data);
2355
+ return true;
2356
+ }
2271
2357
  if (data.includes(0)) return sendJson(res, 415, { error: "binary files cannot be previewed" });
2272
2358
  if (url.searchParams.get("format") === "json") {
2273
2359
  return sendJson(res, 200, buildFilePreview(target, data));
@@ -2440,14 +2526,19 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
2440
2526
  && typeof requestedModel.modelId === "string"
2441
2527
  ? { provider: requestedModel.provider, modelId: requestedModel.modelId }
2442
2528
  : undefined;
2443
- const record = await createSession(
2444
- ws.id,
2445
- typeof body.title === "string" ? body.title : undefined,
2446
- {
2447
- model,
2448
- thinkingLevel: typeof body.thinkingLevel === "string" ? body.thinkingLevel : undefined,
2449
- },
2450
- );
2529
+ let record: RuntimeSession;
2530
+ try {
2531
+ record = await createSession(
2532
+ ws.id,
2533
+ typeof body.title === "string" ? body.title : undefined,
2534
+ {
2535
+ model,
2536
+ thinkingLevel: typeof body.thinkingLevel === "string" ? body.thinkingLevel : undefined,
2537
+ },
2538
+ );
2539
+ } catch (error) {
2540
+ return sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
2541
+ }
2451
2542
  state.currentWorkspaceId = ws.id;
2452
2543
  state.currentSessionId = record.id;
2453
2544
  scheduleSave();
@@ -2455,7 +2546,7 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
2455
2546
  return sendJson(res, 200, {
2456
2547
  session: sessionSummary(record),
2457
2548
  state: record.state,
2458
- messages: record.messages,
2549
+ messages: browserMessages(record.messages),
2459
2550
  pendingQueue: pendingQueueSnapshot(record),
2460
2551
  models: record.models,
2461
2552
  thinkingLevels: record.thinkingLevels,
@@ -2480,6 +2571,21 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
2480
2571
  const match = /^\/api\/sessions\/([^/]+)(?:\/([^/]+))?$/.exec(path);
2481
2572
  const action = match?.[2] ?? "";
2482
2573
 
2574
+ if (method === "GET" && action === "background-jobs") {
2575
+ const jobs = collectBackgroundJobs(session.messages);
2576
+ const requested = url.searchParams.get("job");
2577
+ const job = requested ? jobs.find((item) => item.id === requested) : jobs[0];
2578
+ if (requested && !job) return sendJson(res, 404, { error: "当前会话中找不到此后台任务" });
2579
+ const visible = ({ logPath: _logPath, ...item }: typeof jobs[number]) => item;
2580
+ const data = { jobs: jobs.map(visible), job: job ? visible(job) : null };
2581
+ if (!job) return sendJson(res, 200, { ...data, output: "", truncated: false });
2582
+ try {
2583
+ return sendJson(res, 200, { ...data, ...await readBackgroundJobLog(job.logPath) });
2584
+ } catch (error) {
2585
+ return sendJson(res, 200, { ...data, output: "", truncated: false, error: (error as NodeJS.ErrnoException).code === "ENOENT" ? "日志文件已被清理或尚未创建" : "无法读取任务日志" });
2586
+ }
2587
+ }
2588
+
2483
2589
  if (method === "POST" && action === "models") {
2484
2590
  try {
2485
2591
  await refreshModelsFresh(session);
@@ -2500,7 +2606,7 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
2500
2606
  return sendJson(res, 200, {
2501
2607
  session: sessionSummary(session),
2502
2608
  state: session.state,
2503
- messages: snapshotMessages(session),
2609
+ messages: browserMessages(snapshotMessages(session)),
2504
2610
  pendingQueue: pendingQueueSnapshot(session),
2505
2611
  models: session.models,
2506
2612
  thinkingLevels: session.thinkingLevels,
@@ -2580,7 +2686,7 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
2580
2686
  history: nextHistory,
2581
2687
  session: sessionSummary(session),
2582
2688
  state: session.state,
2583
- messages: snapshotMessages(session),
2689
+ messages: browserMessages(snapshotMessages(session)),
2584
2690
  pendingQueue: pendingQueueSnapshot(session),
2585
2691
  stats: session.stats,
2586
2692
  });
@@ -2590,6 +2696,40 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
2590
2696
  }
2591
2697
  }
2592
2698
 
2699
+ if (method === "POST" && action === "reload") {
2700
+ try {
2701
+ return await withSessionOperation(session, async () => {
2702
+ await ensureProcess(session);
2703
+ if (session.streaming || session.state?.isCompacting || session.dialogs.length > 0) {
2704
+ return sendJson(res, 409, { error: "请等待当前会话运行结束并处理完交互后再重载" });
2705
+ }
2706
+ const proc = session.proc!;
2707
+ const commandsResponse = await proc.send<Json>({ type: "get_commands" });
2708
+ const commands = commandsResponse.data?.commands;
2709
+ if (!Array.isArray(commands) || !commands.some((command) => command?.name === INTERNAL_RELOAD_COMMAND)) {
2710
+ return sendJson(res, 409, { error: "当前会话未加载 Web 重载命令,请重启 Pi Web 后重试" });
2711
+ }
2712
+ let commandError: string | undefined;
2713
+ const unsubscribe = proc.onEvent((event) => {
2714
+ if (event.type === "extension_error" && event.extensionPath === `command:${INTERNAL_RELOAD_COMMAND}`) {
2715
+ commandError = String(event.error || "会话重载失败");
2716
+ }
2717
+ });
2718
+ try {
2719
+ await proc.send({ type: "prompt", message: `/${INTERNAL_RELOAD_COMMAND}` });
2720
+ if (commandError) throw new Error(commandError);
2721
+ } finally {
2722
+ unsubscribe();
2723
+ }
2724
+ await Promise.all([refreshState(session), refreshCommands(session), refreshModels(session)]);
2725
+ broadcastSessionSnapshot(session);
2726
+ return sendJson(res, 200, { ok: true, reloaded: true });
2727
+ });
2728
+ } catch (error) {
2729
+ return sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
2730
+ }
2731
+ }
2732
+
2593
2733
  if (method === "POST" && action === "prompt") {
2594
2734
  const body = await readJsonBody(req);
2595
2735
  const message = typeof body.message === "string" ? body.message : "";
@@ -2597,35 +2737,47 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
2597
2737
  ? body.attachmentIds.filter((id): id is string => typeof id === "string")
2598
2738
  : [];
2599
2739
  if (!message.trim() && attachmentIds.length === 0) {
2600
- return sendJson(res, 400, { error: "message or image attachment is required" });
2740
+ return sendJson(res, 400, { error: "请输入消息或添加文件附件" });
2601
2741
  }
2742
+ const parsedCommand = parseSlashCommand(message);
2743
+ const builtinError = parsedCommand ? builtinCommandError(parsedCommand.name) : null;
2744
+ if (builtinError) return sendJson(res, 400, { error: builtinError });
2602
2745
  try {
2603
2746
  return await withSessionOperation(session, async () => {
2604
2747
  await ensureProcess(session);
2605
2748
  const wasStreaming = session.streaming;
2606
2749
  if (attachmentIds.length > 0 && wasStreaming) {
2607
- return sendJson(res, 409, { error: "生成过程中暂不支持排队图片,请停止当前生成后再发送" });
2750
+ return sendJson(res, 409, { error: "生成过程中暂不支持排队附件,请停止当前生成后再发送" });
2608
2751
  }
2609
2752
  if (attachmentIds.length > 0 && message.trimStart().startsWith("/")) {
2610
- return sendJson(res, 400, { error: "斜杠命令不能附带图片" });
2753
+ return sendJson(res, 400, { error: "斜杠命令不能附带附件" });
2611
2754
  }
2612
2755
  const model = session.state?.model;
2613
2756
  const modelInput = model && typeof model === "object" && Array.isArray((model as Json).input)
2614
2757
  ? (model as Json).input as unknown[]
2615
2758
  : [];
2616
- if (attachmentIds.length > 0 && !modelInput.includes("image")) {
2617
- return sendJson(res, 400, { error: "当前模型不支持图片输入,请切换视觉模型" });
2618
- }
2619
- let images: Array<{ type: "image"; data: string; mimeType: string }> = [];
2759
+ let prepared: Awaited<ReturnType<AttachmentStore["promptForRpc"]>>;
2620
2760
  try {
2621
- images = await imageAttachments.imagesForRpc(attachmentIds);
2761
+ prepared = await attachments.promptForRpc(attachmentIds, message);
2622
2762
  } catch (error) {
2623
2763
  return sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
2624
2764
  }
2625
- const promptMessage = message.trim() || "请分析附加图片。";
2626
- if (promptMessage.trimStart().startsWith("/")) await refreshCommands(session);
2627
- const optimisticStreaming = !wasStreaming && !isImmediateExtensionCommand(session, promptMessage);
2628
- const command: Json = { type: "prompt", message: promptMessage };
2765
+ const { images, message: promptMessage } = prepared;
2766
+ if (images.length > 0 && !modelInput.includes("image")) {
2767
+ return sendJson(res, 400, { error: "当前模型不支持图片输入,请切换视觉模型" });
2768
+ }
2769
+ const slash = /^\/([^\s]+)(?:\s|$)/.exec(promptMessage.trim());
2770
+ if (promptMessage.trimStart().startsWith("/")) {
2771
+ const response = await session.proc!.send<Json>({ type: "get_commands" });
2772
+ session.commands = normalizeCommands(response.data?.commands);
2773
+ commandCache.set(session.cwd, session.commands);
2774
+ if (!slash || !session.commands.some((command) => command.name === slash[1])) {
2775
+ return sendJson(res, 400, { error: `未知或不可用的命令:${slash ? `/${slash[1]}` : "/"}。请输入 / 查看可用命令;普通消息请勿以 / 开头。` });
2776
+ }
2777
+ }
2778
+ const immediateCommand = isImmediateExtensionCommand(session, promptMessage);
2779
+ const optimisticStreaming = !wasStreaming && !immediateCommand;
2780
+ const command: Json = { type: "prompt", message: slash ? promptMessage.trim() : promptMessage };
2629
2781
  if (images.length > 0) command.images = images;
2630
2782
  if (wasStreaming) command.streamingBehavior = "steer";
2631
2783
  if (optimisticStreaming) {
@@ -2635,15 +2787,15 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
2635
2787
  }
2636
2788
  try {
2637
2789
  const response = await session.proc!.send(command);
2638
- await Promise.all(attachmentIds.map((id) => imageAttachments.remove(id)));
2639
- if (!session.title || session.title === "新会话") {
2640
- const nextTitle = message.trim().replace(/\s+/g, " ").slice(0, 60) || "图片会话";
2790
+ await Promise.all(attachmentIds.map((id) => attachments.remove(id)));
2791
+ if (!immediateCommand && (!session.title || session.title === "新会话")) {
2792
+ const nextTitle = message.trim().replace(/\s+/g, " ").slice(0, 60) || "文件会话";
2641
2793
  session.title = nextTitle;
2642
2794
  updateStoredSession(session);
2643
2795
  session.proc?.send({ type: "set_session_name", name: nextTitle }).catch(() => {});
2644
2796
  broadcastWorkspaces();
2645
2797
  }
2646
- return sendJson(res, 200, { ok: true, response, queued: wasStreaming });
2798
+ return sendJson(res, 200, { ok: true, response, queued: wasStreaming && !immediateCommand });
2647
2799
  } catch (error) {
2648
2800
  if (optimisticStreaming) {
2649
2801
  session.streaming = false;
@@ -2884,24 +3036,37 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
2884
3036
  const body = await readJsonBody(req);
2885
3037
  const name = typeof body.name === "string" ? body.name.trim().replace(/\s+/g, " ").slice(0, 60) : "";
2886
3038
  if (!name) return sendJson(res, 400, { error: "name is required" });
2887
- session.title = name;
2888
- updateStoredSession(session);
2889
- if (session.proc) {
2890
- session.proc.send({ type: "set_session_name", name }).catch(() => {});
2891
- } else {
2892
- session.pendingName = name;
2893
- scheduleSave();
3039
+ try {
3040
+ if (session.proc) {
3041
+ await session.proc.send({ type: "set_session_name", name });
3042
+ session.pendingName = undefined;
3043
+ } else {
3044
+ session.pendingName = name;
3045
+ }
3046
+ session.title = name;
3047
+ updateStoredSession(session);
3048
+ broadcastWorkspaces();
3049
+ broadcastSessionSnapshot(session);
3050
+ return sendJson(res, 200, { ok: true });
3051
+ } catch (error) {
3052
+ return sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
2894
3053
  }
2895
- broadcastWorkspaces();
2896
- broadcastSessionSnapshot(session);
2897
- return sendJson(res, 200, { ok: true });
2898
3054
  }
2899
3055
 
2900
3056
  if (method === "POST" && action === "compact") {
2901
- await ensureProcess(session);
3057
+ const body = await readJsonBody(req);
3058
+ const customInstructions = typeof body.customInstructions === "string" ? body.customInstructions.trim() : "";
2902
3059
  try {
2903
- const response = await session.proc!.send({ type: "compact" });
2904
- return sendJson(res, 200, { ok: true, response });
3060
+ return await withSessionOperation(session, async () => {
3061
+ await ensureProcess(session);
3062
+ if (session.streaming || session.state?.isCompacting || session.dialogs.length > 0) {
3063
+ return sendJson(res, 409, { error: "请等待当前会话运行结束并处理完交互后再压缩" });
3064
+ }
3065
+ const response = await session.proc!.send({ type: "compact", ...(customInstructions ? { customInstructions } : {}) });
3066
+ await Promise.all([refreshState(session), refreshMessages(session), refreshStats(session)]);
3067
+ broadcastSessionSnapshot(session);
3068
+ return sendJson(res, 200, { ok: true, response });
3069
+ });
2905
3070
  } catch (error) {
2906
3071
  return sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
2907
3072
  }
@@ -2970,7 +3135,7 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
2970
3135
  return sendJson(res, 200, {
2971
3136
  session: sessionSummary(session),
2972
3137
  state: session.state,
2973
- messages: snapshotMessages(session),
3138
+ messages: browserMessages(snapshotMessages(session)),
2974
3139
  pendingQueue: pendingQueueSnapshot(session),
2975
3140
  models: session.models,
2976
3141
  thinkingLevels: session.thinkingLevels,
@@ -3091,7 +3256,7 @@ function exitHook(): void {
3091
3256
  // ignore
3092
3257
  }
3093
3258
  }
3094
- void imageAttachments.close();
3259
+ void attachments.close();
3095
3260
  }
3096
3261
 
3097
3262
  function sigintHook(): void {
@@ -3106,6 +3271,7 @@ function shutdown(): Promise<void> {
3106
3271
  }
3107
3272
 
3108
3273
  async function shutdownServer(): Promise<void> {
3274
+ snapshotScheduler.clear();
3109
3275
  await networkChange;
3110
3276
  await stopRelay();
3111
3277
  for (const client of [...sseClients]) {
@@ -3121,7 +3287,7 @@ async function shutdownServer(): Promise<void> {
3121
3287
  await closeAllPiRpcs();
3122
3288
  for (const download of exportDownloads.values()) unlink(download.path).catch(() => {});
3123
3289
  exportDownloads.clear();
3124
- await imageAttachments.close();
3290
+ await attachments.close();
3125
3291
  if (heartbeat) clearInterval(heartbeat);
3126
3292
  heartbeat = undefined;
3127
3293
  await saveStateNow().catch(() => {});
@@ -3155,6 +3321,7 @@ export async function startWebServer(options: {
3155
3321
  } = {}): Promise<string> {
3156
3322
  if (shutdownPromise) await shutdownPromise;
3157
3323
  const initialCwd = normalizePath(options.defaultCwd ?? process.cwd());
3324
+ extensionSettingsCwd = initialCwd;
3158
3325
  updateExtensions = options.updateExtensions;
3159
3326
  reloadRuntime = options.reloadRuntime;
3160
3327
  const mode = options.mode ?? (options.lan === undefined ? undefined : options.lan ? "lan" : "local");
@@ -3188,6 +3355,7 @@ export async function startWebServer(options: {
3188
3355
  if (!Number.isInteger(requestedPort) || requestedPort < 0 || requestedPort > 65535) {
3189
3356
  throw new Error("端口必须是 0–65535 之间的整数");
3190
3357
  }
3358
+ terminalProxy = await loadTerminalProxy(TERMINAL_PROXY_FILE);
3191
3359
  networkSettings = await loadNetworkSettings(NETWORK_FILE);
3192
3360
  if (mode !== undefined) {
3193
3361
  networkSettings = updateNetworkSettings({ mode }, networkSettings);