@oversk7/pi-web-dsh-style 1.0.1 → 1.1.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/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
26
  import { closeSseClient, createSseClient, 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 {
@@ -663,7 +678,7 @@ function broadcastSessionSnapshot(record: RuntimeSession): void {
663
678
  thinkingLevelsByModel: thinkingLevelsByModel(record.models),
664
679
  commands: record.commands,
665
680
  extensionStatuses: record.extensionStatuses,
666
- messages: snapshotMessages(record),
681
+ messages: browserMessages(snapshotMessages(record)),
667
682
  pendingQueue: pendingQueueSnapshot(record),
668
683
  dialogs: record.dialogs,
669
684
  });
@@ -876,7 +891,7 @@ async function discoverFreshModelCatalog(cwd: string): Promise<ModelCatalog> {
876
891
  const existing = modelCatalogRefreshes.get(key);
877
892
  if (existing) return existing;
878
893
  const task = (async () => {
879
- const probe = spawnPiRpc({ cwd: key, noSession: true });
894
+ const probe = spawnPiRpc({ cwd: key, noSession: true, proxy: terminalProxy });
880
895
  try {
881
896
  const [modelsResponse, stateResponse] = await Promise.all([
882
897
  probe.send<Json>({ type: "get_available_models" }),
@@ -914,7 +929,7 @@ function applyModelInfo(record: RuntimeSession, info: ModelInfo): void {
914
929
 
915
930
  async function refreshModelsFresh(record: RuntimeSession): Promise<void> {
916
931
  if (record.starting) await record.starting;
917
- if (record.streaming) {
932
+ if (record.streaming || hasRunningBackgroundJobs(record)) {
918
933
  await refreshModels(record);
919
934
  return;
920
935
  }
@@ -925,10 +940,15 @@ async function refreshModelsFresh(record: RuntimeSession): Promise<void> {
925
940
  const runtimeModels = (runtimeResponse.data?.models ?? []) as unknown[];
926
941
  if (!sameModelCatalog(runtimeModels, catalog.models)) {
927
942
  await Promise.allSettled([refreshState(record), refreshMessages(record)]);
943
+ if (record.streaming || hasRunningBackgroundJobs(record)) {
944
+ await refreshModels(record);
945
+ return;
946
+ }
928
947
  await stopSessionProcess(record);
929
948
  }
930
949
  } catch (error) {
931
950
  console.error("[pi-web] runtime model refresh failed:", error);
951
+ if (record.streaming || hasRunningBackgroundJobs(record)) return;
932
952
  await stopSessionProcess(record);
933
953
  }
934
954
  }
@@ -1232,6 +1252,7 @@ function bindSessionEvents(record: RuntimeSession, proc: PiRpc): void {
1232
1252
  } else if (event.type === "compaction_start") {
1233
1253
  const reason = typeof event.reason === "string" ? event.reason : undefined;
1234
1254
  record.state = { ...(record.state ?? {}), isCompacting: true };
1255
+ record.compactionNoticeIndex = record.messages.length;
1235
1256
  record.compactionNotice = {
1236
1257
  kind: "compaction",
1237
1258
  id: `compaction-running:${Date.now()}`,
@@ -1263,6 +1284,7 @@ function bindSessionEvents(record: RuntimeSession, proc: PiRpc): void {
1263
1284
  timestamp: Date.now(),
1264
1285
  };
1265
1286
  record.compactionNotice = notice;
1287
+ record.compactionNoticeIndex = record.messages.length;
1266
1288
  broadcastSessionSnapshot(record);
1267
1289
  if (result) {
1268
1290
  Promise.allSettled([refreshMessages(record), refreshStats(record)]).then(() => {
@@ -1308,10 +1330,15 @@ function bindSessionEvents(record: RuntimeSession, proc: PiRpc): void {
1308
1330
  // session lifecycle
1309
1331
  // ---------------------------------------------------------------------------
1310
1332
 
1333
+ function hasRunningBackgroundJobs(record: RuntimeSession): boolean {
1334
+ // pi-pwsh-notify clears this live status only after its last background job exits.
1335
+ return Boolean(record.extensionStatuses["pwsh-bg"]);
1336
+ }
1337
+
1311
1338
  function evictIdleProcesses(): void {
1312
1339
  const idle: RuntimeSession[] = [];
1313
1340
  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) {
1341
+ if (record.proc && !record.streaming && !hasRunningBackgroundJobs(record) && record.status !== "running" && record.status !== "starting" && record.dialogs.length === 0 && record.id !== state.currentSessionId) {
1315
1342
  idle.push(record);
1316
1343
  }
1317
1344
  }
@@ -1373,6 +1400,7 @@ async function ensureProcess(record: RuntimeSession): Promise<void> {
1373
1400
  const task = (async () => {
1374
1401
  const proc = spawnPiRpc({
1375
1402
  cwd: record.cwd,
1403
+ proxy: terminalProxy,
1376
1404
  sessionId: record.sessionFile ? undefined : record.id,
1377
1405
  sessionFile: record.sessionFile,
1378
1406
  name: record.title === "新会话" ? undefined : record.title,
@@ -2112,6 +2140,46 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
2112
2140
  return sendJson(res, 200, { ok: true });
2113
2141
  }
2114
2142
 
2143
+ if (path === "/api/extensions" && (method === "GET" || method === "POST")) {
2144
+ if (!isLocalRequest(req)) return sendJson(res, 403, { error: "请在电脑本机网页管理扩展" });
2145
+ if (extensionSettingsSaving || maintenanceRunning) return sendJson(res, 409, { error: "扩展配置或维护操作正在执行,请稍后重试" });
2146
+ extensionSettingsSaving = true;
2147
+ try {
2148
+ let change: { scope: "global" | "project"; source: string; enabled: boolean } | undefined;
2149
+ if (method === "POST") {
2150
+ const body = await readJsonBody(req, 16 * 1024);
2151
+ if ((body.scope !== "global" && body.scope !== "project") || typeof body.source !== "string" || !body.source || typeof body.enabled !== "boolean") {
2152
+ return sendJson(res, 400, { error: "扩展配置参数无效" });
2153
+ }
2154
+ change = { scope: body.scope, source: body.source, enabled: body.enabled };
2155
+ }
2156
+ return sendJson(res, 200, await extensionSettings(extensionSettingsCwd, change));
2157
+ } catch (error) {
2158
+ return sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
2159
+ } finally {
2160
+ extensionSettingsSaving = false;
2161
+ }
2162
+ }
2163
+
2164
+ if (path === "/api/terminal-proxy") {
2165
+ if (!isLocalRequest(req)) return sendJson(res, 403, { error: "请在电脑本机网页配置终端代理" });
2166
+ if (method === "GET") return sendJson(res, 200, { settings: terminalProxy });
2167
+ if (method === "POST") {
2168
+ if (terminalProxySaving) return sendJson(res, 409, { error: "终端代理正在保存,请稍后重试" });
2169
+ terminalProxySaving = true;
2170
+ try {
2171
+ const next = validateTerminalProxy(await readJsonBody(req, 4096));
2172
+ await saveTerminalProxy(TERMINAL_PROXY_FILE, next);
2173
+ terminalProxy = next;
2174
+ return sendJson(res, 200, { settings: terminalProxy });
2175
+ } catch (error) {
2176
+ return sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
2177
+ } finally {
2178
+ terminalProxySaving = false;
2179
+ }
2180
+ }
2181
+ }
2182
+
2115
2183
  if (path === "/api/network" || path === "/api/network/server-files") {
2116
2184
  if (!isLocalRequest(req)) return sendJson(res, 403, { error: "请在电脑本机网页配置网络访问" });
2117
2185
  if (method === "GET" && path === "/api/network/server-files") {
@@ -2162,7 +2230,7 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
2162
2230
  }
2163
2231
 
2164
2232
  if (method === "POST" && (path === "/api/maintenance/update-and-reload" || path === "/api/maintenance/reload")) {
2165
- if (maintenanceRunning) return sendJson(res, 409, { error: "维护操作正在执行" });
2233
+ if (maintenanceRunning || extensionSettingsSaving) return sendJson(res, 409, { error: "维护操作或扩展配置正在执行" });
2166
2234
  if (!reloadRuntime || (path.endsWith("update-and-reload") && !updateExtensions)) {
2167
2235
  return sendJson(res, 503, { error: "当前 Pi 运行时不支持 Web 维护操作,请先执行一次 /reload" });
2168
2236
  }
@@ -2202,10 +2270,10 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
2202
2270
  if (method === "POST" && path === "/api/attachments") {
2203
2271
  const body = await readJsonBody(req, 21 * 1024 * 1024);
2204
2272
  if (typeof body.data !== "string" || !/^[A-Za-z0-9+/]+={0,2}$/.test(body.data)) {
2205
- return sendJson(res, 400, { error: "图片数据无效" });
2273
+ return sendJson(res, 400, { error: "文件数据无效" });
2206
2274
  }
2207
2275
  try {
2208
- const attachment = await imageAttachments.create(Buffer.from(body.data, "base64"));
2276
+ const attachment = await attachments.create(Buffer.from(body.data, "base64"), typeof body.name === "string" ? body.name : undefined);
2209
2277
  return sendJson(res, 200, { attachment: { ...attachment, url: `/api/attachments/${attachment.id}` } });
2210
2278
  } catch (error) {
2211
2279
  return sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
@@ -2215,9 +2283,10 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
2215
2283
  const attachmentMatch = /^\/api\/attachments\/([a-f0-9-]+)$/.exec(path);
2216
2284
  if (attachmentMatch && (method === "GET" || method === "HEAD")) {
2217
2285
  try {
2218
- const { meta, bytes } = await imageAttachments.read(attachmentMatch[1]);
2286
+ const { meta, bytes } = await attachments.read(attachmentMatch[1]);
2219
2287
  res.writeHead(200, {
2220
2288
  "content-type": meta.mimeType,
2289
+ ...(meta.kind === "document" ? { "content-disposition": `attachment; filename*=UTF-8''${encodeURIComponent(meta.name)}` } : {}),
2221
2290
  "content-length": bytes.byteLength,
2222
2291
  "cache-control": "private, no-store",
2223
2292
  "x-content-type-options": "nosniff",
@@ -2230,7 +2299,7 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
2230
2299
  }
2231
2300
 
2232
2301
  if (attachmentMatch && method === "DELETE") {
2233
- const removed = await imageAttachments.remove(attachmentMatch[1]);
2302
+ const removed = await attachments.remove(attachmentMatch[1]);
2234
2303
  return sendJson(res, removed ? 200 : 404, removed ? { ok: true } : { error: "attachment not found" });
2235
2304
  }
2236
2305
 
@@ -2266,8 +2335,20 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
2266
2335
  const target = normalizePath(input);
2267
2336
  const info = await stat(target);
2268
2337
  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" });
2338
+ const image = url.searchParams.get("format") === "image";
2339
+ if (info.size > (image ? MAX_IMAGE_VIEW_BYTES : MAX_FILE_VIEW_BYTES)) return sendJson(res, 413, { error: "file is too large to preview" });
2270
2340
  const data = await readFile(target);
2341
+ if (image) {
2342
+ const mimeType = await detectImageMimeType(target, data);
2343
+ if (!mimeType) return sendJson(res, 415, { error: "file is not a supported image" });
2344
+ res.writeHead(200, {
2345
+ "content-type": mimeType,
2346
+ "cache-control": "private, no-store",
2347
+ "x-content-type-options": "nosniff",
2348
+ });
2349
+ res.end(data);
2350
+ return true;
2351
+ }
2271
2352
  if (data.includes(0)) return sendJson(res, 415, { error: "binary files cannot be previewed" });
2272
2353
  if (url.searchParams.get("format") === "json") {
2273
2354
  return sendJson(res, 200, buildFilePreview(target, data));
@@ -2455,7 +2536,7 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
2455
2536
  return sendJson(res, 200, {
2456
2537
  session: sessionSummary(record),
2457
2538
  state: record.state,
2458
- messages: record.messages,
2539
+ messages: browserMessages(record.messages),
2459
2540
  pendingQueue: pendingQueueSnapshot(record),
2460
2541
  models: record.models,
2461
2542
  thinkingLevels: record.thinkingLevels,
@@ -2480,6 +2561,21 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
2480
2561
  const match = /^\/api\/sessions\/([^/]+)(?:\/([^/]+))?$/.exec(path);
2481
2562
  const action = match?.[2] ?? "";
2482
2563
 
2564
+ if (method === "GET" && action === "background-jobs") {
2565
+ const jobs = collectBackgroundJobs(session.messages);
2566
+ const requested = url.searchParams.get("job");
2567
+ const job = requested ? jobs.find((item) => item.id === requested) : jobs[0];
2568
+ if (requested && !job) return sendJson(res, 404, { error: "当前会话中找不到此后台任务" });
2569
+ const visible = ({ logPath: _logPath, ...item }: typeof jobs[number]) => item;
2570
+ const data = { jobs: jobs.map(visible), job: job ? visible(job) : null };
2571
+ if (!job) return sendJson(res, 200, { ...data, output: "", truncated: false });
2572
+ try {
2573
+ return sendJson(res, 200, { ...data, ...await readBackgroundJobLog(job.logPath) });
2574
+ } catch (error) {
2575
+ return sendJson(res, 200, { ...data, output: "", truncated: false, error: (error as NodeJS.ErrnoException).code === "ENOENT" ? "日志文件已被清理或尚未创建" : "无法读取任务日志" });
2576
+ }
2577
+ }
2578
+
2483
2579
  if (method === "POST" && action === "models") {
2484
2580
  try {
2485
2581
  await refreshModelsFresh(session);
@@ -2500,7 +2596,7 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
2500
2596
  return sendJson(res, 200, {
2501
2597
  session: sessionSummary(session),
2502
2598
  state: session.state,
2503
- messages: snapshotMessages(session),
2599
+ messages: browserMessages(snapshotMessages(session)),
2504
2600
  pendingQueue: pendingQueueSnapshot(session),
2505
2601
  models: session.models,
2506
2602
  thinkingLevels: session.thinkingLevels,
@@ -2580,7 +2676,7 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
2580
2676
  history: nextHistory,
2581
2677
  session: sessionSummary(session),
2582
2678
  state: session.state,
2583
- messages: snapshotMessages(session),
2679
+ messages: browserMessages(snapshotMessages(session)),
2584
2680
  pendingQueue: pendingQueueSnapshot(session),
2585
2681
  stats: session.stats,
2586
2682
  });
@@ -2590,6 +2686,40 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
2590
2686
  }
2591
2687
  }
2592
2688
 
2689
+ if (method === "POST" && action === "reload") {
2690
+ try {
2691
+ return await withSessionOperation(session, async () => {
2692
+ await ensureProcess(session);
2693
+ if (session.streaming || session.state?.isCompacting || session.dialogs.length > 0) {
2694
+ return sendJson(res, 409, { error: "请等待当前会话运行结束并处理完交互后再重载" });
2695
+ }
2696
+ const proc = session.proc!;
2697
+ const commandsResponse = await proc.send<Json>({ type: "get_commands" });
2698
+ const commands = commandsResponse.data?.commands;
2699
+ if (!Array.isArray(commands) || !commands.some((command) => command?.name === INTERNAL_RELOAD_COMMAND)) {
2700
+ return sendJson(res, 409, { error: "当前会话未加载 Web 重载命令,请重启 Pi Web 后重试" });
2701
+ }
2702
+ let commandError: string | undefined;
2703
+ const unsubscribe = proc.onEvent((event) => {
2704
+ if (event.type === "extension_error" && event.extensionPath === `command:${INTERNAL_RELOAD_COMMAND}`) {
2705
+ commandError = String(event.error || "会话重载失败");
2706
+ }
2707
+ });
2708
+ try {
2709
+ await proc.send({ type: "prompt", message: `/${INTERNAL_RELOAD_COMMAND}` });
2710
+ if (commandError) throw new Error(commandError);
2711
+ } finally {
2712
+ unsubscribe();
2713
+ }
2714
+ await Promise.all([refreshState(session), refreshCommands(session), refreshModels(session)]);
2715
+ broadcastSessionSnapshot(session);
2716
+ return sendJson(res, 200, { ok: true, reloaded: true });
2717
+ });
2718
+ } catch (error) {
2719
+ return sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
2720
+ }
2721
+ }
2722
+
2593
2723
  if (method === "POST" && action === "prompt") {
2594
2724
  const body = await readJsonBody(req);
2595
2725
  const message = typeof body.message === "string" ? body.message : "";
@@ -2597,35 +2727,47 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
2597
2727
  ? body.attachmentIds.filter((id): id is string => typeof id === "string")
2598
2728
  : [];
2599
2729
  if (!message.trim() && attachmentIds.length === 0) {
2600
- return sendJson(res, 400, { error: "message or image attachment is required" });
2730
+ return sendJson(res, 400, { error: "请输入消息或添加文件附件" });
2601
2731
  }
2732
+ const parsedCommand = parseSlashCommand(message);
2733
+ const builtinError = parsedCommand ? builtinCommandError(parsedCommand.name) : null;
2734
+ if (builtinError) return sendJson(res, 400, { error: builtinError });
2602
2735
  try {
2603
2736
  return await withSessionOperation(session, async () => {
2604
2737
  await ensureProcess(session);
2605
2738
  const wasStreaming = session.streaming;
2606
2739
  if (attachmentIds.length > 0 && wasStreaming) {
2607
- return sendJson(res, 409, { error: "生成过程中暂不支持排队图片,请停止当前生成后再发送" });
2740
+ return sendJson(res, 409, { error: "生成过程中暂不支持排队附件,请停止当前生成后再发送" });
2608
2741
  }
2609
2742
  if (attachmentIds.length > 0 && message.trimStart().startsWith("/")) {
2610
- return sendJson(res, 400, { error: "斜杠命令不能附带图片" });
2743
+ return sendJson(res, 400, { error: "斜杠命令不能附带附件" });
2611
2744
  }
2612
2745
  const model = session.state?.model;
2613
2746
  const modelInput = model && typeof model === "object" && Array.isArray((model as Json).input)
2614
2747
  ? (model as Json).input as unknown[]
2615
2748
  : [];
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 }> = [];
2749
+ let prepared: Awaited<ReturnType<AttachmentStore["promptForRpc"]>>;
2620
2750
  try {
2621
- images = await imageAttachments.imagesForRpc(attachmentIds);
2751
+ prepared = await attachments.promptForRpc(attachmentIds, message);
2622
2752
  } catch (error) {
2623
2753
  return sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
2624
2754
  }
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 };
2755
+ const { images, message: promptMessage } = prepared;
2756
+ if (images.length > 0 && !modelInput.includes("image")) {
2757
+ return sendJson(res, 400, { error: "当前模型不支持图片输入,请切换视觉模型" });
2758
+ }
2759
+ const slash = /^\/([^\s]+)(?:\s|$)/.exec(promptMessage.trim());
2760
+ if (promptMessage.trimStart().startsWith("/")) {
2761
+ const response = await session.proc!.send<Json>({ type: "get_commands" });
2762
+ session.commands = normalizeCommands(response.data?.commands);
2763
+ commandCache.set(session.cwd, session.commands);
2764
+ if (!slash || !session.commands.some((command) => command.name === slash[1])) {
2765
+ return sendJson(res, 400, { error: `未知或不可用的命令:${slash ? `/${slash[1]}` : "/"}。请输入 / 查看可用命令;普通消息请勿以 / 开头。` });
2766
+ }
2767
+ }
2768
+ const immediateCommand = isImmediateExtensionCommand(session, promptMessage);
2769
+ const optimisticStreaming = !wasStreaming && !immediateCommand;
2770
+ const command: Json = { type: "prompt", message: slash ? promptMessage.trim() : promptMessage };
2629
2771
  if (images.length > 0) command.images = images;
2630
2772
  if (wasStreaming) command.streamingBehavior = "steer";
2631
2773
  if (optimisticStreaming) {
@@ -2635,15 +2777,15 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
2635
2777
  }
2636
2778
  try {
2637
2779
  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) || "图片会话";
2780
+ await Promise.all(attachmentIds.map((id) => attachments.remove(id)));
2781
+ if (!immediateCommand && (!session.title || session.title === "新会话")) {
2782
+ const nextTitle = message.trim().replace(/\s+/g, " ").slice(0, 60) || "文件会话";
2641
2783
  session.title = nextTitle;
2642
2784
  updateStoredSession(session);
2643
2785
  session.proc?.send({ type: "set_session_name", name: nextTitle }).catch(() => {});
2644
2786
  broadcastWorkspaces();
2645
2787
  }
2646
- return sendJson(res, 200, { ok: true, response, queued: wasStreaming });
2788
+ return sendJson(res, 200, { ok: true, response, queued: wasStreaming && !immediateCommand });
2647
2789
  } catch (error) {
2648
2790
  if (optimisticStreaming) {
2649
2791
  session.streaming = false;
@@ -2884,24 +3026,37 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
2884
3026
  const body = await readJsonBody(req);
2885
3027
  const name = typeof body.name === "string" ? body.name.trim().replace(/\s+/g, " ").slice(0, 60) : "";
2886
3028
  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();
3029
+ try {
3030
+ if (session.proc) {
3031
+ await session.proc.send({ type: "set_session_name", name });
3032
+ session.pendingName = undefined;
3033
+ } else {
3034
+ session.pendingName = name;
3035
+ }
3036
+ session.title = name;
3037
+ updateStoredSession(session);
3038
+ broadcastWorkspaces();
3039
+ broadcastSessionSnapshot(session);
3040
+ return sendJson(res, 200, { ok: true });
3041
+ } catch (error) {
3042
+ return sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
2894
3043
  }
2895
- broadcastWorkspaces();
2896
- broadcastSessionSnapshot(session);
2897
- return sendJson(res, 200, { ok: true });
2898
3044
  }
2899
3045
 
2900
3046
  if (method === "POST" && action === "compact") {
2901
- await ensureProcess(session);
3047
+ const body = await readJsonBody(req);
3048
+ const customInstructions = typeof body.customInstructions === "string" ? body.customInstructions.trim() : "";
2902
3049
  try {
2903
- const response = await session.proc!.send({ type: "compact" });
2904
- return sendJson(res, 200, { ok: true, response });
3050
+ return await withSessionOperation(session, async () => {
3051
+ await ensureProcess(session);
3052
+ if (session.streaming || session.state?.isCompacting || session.dialogs.length > 0) {
3053
+ return sendJson(res, 409, { error: "请等待当前会话运行结束并处理完交互后再压缩" });
3054
+ }
3055
+ const response = await session.proc!.send({ type: "compact", ...(customInstructions ? { customInstructions } : {}) });
3056
+ await Promise.all([refreshState(session), refreshMessages(session), refreshStats(session)]);
3057
+ broadcastSessionSnapshot(session);
3058
+ return sendJson(res, 200, { ok: true, response });
3059
+ });
2905
3060
  } catch (error) {
2906
3061
  return sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
2907
3062
  }
@@ -2970,7 +3125,7 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
2970
3125
  return sendJson(res, 200, {
2971
3126
  session: sessionSummary(session),
2972
3127
  state: session.state,
2973
- messages: snapshotMessages(session),
3128
+ messages: browserMessages(snapshotMessages(session)),
2974
3129
  pendingQueue: pendingQueueSnapshot(session),
2975
3130
  models: session.models,
2976
3131
  thinkingLevels: session.thinkingLevels,
@@ -3091,7 +3246,7 @@ function exitHook(): void {
3091
3246
  // ignore
3092
3247
  }
3093
3248
  }
3094
- void imageAttachments.close();
3249
+ void attachments.close();
3095
3250
  }
3096
3251
 
3097
3252
  function sigintHook(): void {
@@ -3121,7 +3276,7 @@ async function shutdownServer(): Promise<void> {
3121
3276
  await closeAllPiRpcs();
3122
3277
  for (const download of exportDownloads.values()) unlink(download.path).catch(() => {});
3123
3278
  exportDownloads.clear();
3124
- await imageAttachments.close();
3279
+ await attachments.close();
3125
3280
  if (heartbeat) clearInterval(heartbeat);
3126
3281
  heartbeat = undefined;
3127
3282
  await saveStateNow().catch(() => {});
@@ -3155,6 +3310,7 @@ export async function startWebServer(options: {
3155
3310
  } = {}): Promise<string> {
3156
3311
  if (shutdownPromise) await shutdownPromise;
3157
3312
  const initialCwd = normalizePath(options.defaultCwd ?? process.cwd());
3313
+ extensionSettingsCwd = initialCwd;
3158
3314
  updateExtensions = options.updateExtensions;
3159
3315
  reloadRuntime = options.reloadRuntime;
3160
3316
  const mode = options.mode ?? (options.lan === undefined ? undefined : options.lan ? "lan" : "local");
@@ -3188,6 +3344,7 @@ export async function startWebServer(options: {
3188
3344
  if (!Number.isInteger(requestedPort) || requestedPort < 0 || requestedPort > 65535) {
3189
3345
  throw new Error("端口必须是 0–65535 之间的整数");
3190
3346
  }
3347
+ terminalProxy = await loadTerminalProxy(TERMINAL_PROXY_FILE);
3191
3348
  networkSettings = await loadNetworkSettings(NETWORK_FILE);
3192
3349
  if (mode !== undefined) {
3193
3350
  networkSettings = updateNetworkSettings({ mode }, networkSettings);
@@ -0,0 +1,50 @@
1
+ import { loadJsonWithLegacyMigration, writeJsonAtomically } from "./state-store.ts";
2
+
3
+ export interface TerminalProxySettings {
4
+ mode: "inherit" | "manual" | "direct";
5
+ url: string;
6
+ }
7
+
8
+ export function validateTerminalProxy(input: unknown): TerminalProxySettings {
9
+ if (!input || typeof input !== "object") throw new Error("终端代理配置格式不正确");
10
+ const data = input as Record<string, unknown>;
11
+ if (data.mode !== "inherit" && data.mode !== "manual" && data.mode !== "direct") throw new Error("请选择跟随启动环境、手动代理或直连");
12
+ if (typeof data.url !== "string" || data.url.length > 2048 || /[\s\x00-\x1f\x7f]/.test(data.url)) throw new Error("代理地址格式不正确");
13
+ const url = data.url;
14
+ if (url || data.mode === "manual") {
15
+ let parsed: URL;
16
+ try { parsed = new URL(url); } catch { throw new Error("请填写完整代理地址,例如 http://127.0.0.1:7987"); }
17
+ if (!["http:", "https:"].includes(parsed.protocol)) throw new Error("Pi 支持 HTTP/HTTPS 代理,请使用 VPN 的 HTTP 或混合端口");
18
+ if (!parsed.hostname || parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash || parsed.port === "0") {
19
+ throw new Error("代理地址只需协议、主机和端口,不包含用户名、密码或路径");
20
+ }
21
+ }
22
+ return { mode: data.mode, url };
23
+ }
24
+
25
+ export async function loadTerminalProxy(path: string): Promise<TerminalProxySettings> {
26
+ return await loadJsonWithLegacyMigration({ stateFile: path, validate: (value): value is TerminalProxySettings => {
27
+ try { validateTerminalProxy(value); return true; } catch { return false; }
28
+ } }) ?? { mode: "inherit", url: "http://127.0.0.1:7987" };
29
+ }
30
+
31
+ export async function saveTerminalProxy(path: string, settings: TerminalProxySettings): Promise<void> {
32
+ await writeJsonAtomically(path, settings);
33
+ }
34
+
35
+ export function terminalProxyEnv(settings: TerminalProxySettings, source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
36
+ const env = { ...source };
37
+ if (settings.mode === "inherit") return env;
38
+ for (const key of Object.keys(env)) {
39
+ if (/^(http_proxy|https_proxy|all_proxy|no_proxy)$/i.test(key)) delete env[key];
40
+ }
41
+ if (settings.mode === "manual") {
42
+ for (const key of ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"]) env[key] = settings.url;
43
+ env.NO_PROXY = env.no_proxy = "localhost,127.0.0.1,::1";
44
+ env.NODE_USE_ENV_PROXY = "1";
45
+ } else {
46
+ env.NO_PROXY = env.no_proxy = "*";
47
+ env.NODE_USE_ENV_PROXY = "0";
48
+ }
49
+ return env;
50
+ }
package/transcript.ts CHANGED
@@ -36,6 +36,19 @@ export interface RenderedMessage {
36
36
  willRetry?: boolean;
37
37
  }
38
38
 
39
+ export function browserMessages(messages: RenderedMessage[]): RenderedMessage[] {
40
+ return messages.map((message) => !message.blocks ? message : {
41
+ ...message,
42
+ blocks: message.blocks.map((block) => {
43
+ if (block.type !== "toolCall") return block;
44
+ const { arguments: _arguments, result, ...visible } = block;
45
+ return result
46
+ ? { ...visible, result: { content: result.content, isError: result.isError } }
47
+ : visible;
48
+ }),
49
+ });
50
+ }
51
+
39
52
  function timestampValue(value: unknown): string | number | undefined {
40
53
  return typeof value === "string" || typeof value === "number" ? value : undefined;
41
54
  }