@oversk7/pi-web-dsh-style 1.0.0 → 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
@@ -3,7 +3,7 @@ import { createServer } from "node:http";
3
3
  import { spawn } from "node:child_process";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { existsSync } from "node:fs";
6
- import { mkdir, readFile, readdir, stat, unlink } from "node:fs/promises";
6
+ import { chmod, mkdir, mkdtemp, readFile, readdir, rm, stat, unlink } from "node:fs/promises";
7
7
  import { homedir, platform, tmpdir } from "node:os";
8
8
  import { basename, dirname, extname, join, normalize, resolve, sep } from "node:path";
9
9
  import { fileURLToPath } from "node:url";
@@ -16,17 +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";
22
+ import { isLocalRequest, WebAccess } from "./network-access.ts";
23
+ import { FrpProcess } from "./frp-process.ts";
24
+ import { loadTerminalProxy, saveTerminalProxy, validateTerminalProxy, type TerminalProxySettings } from "./terminal-proxy.ts";
25
+ import { frpcConfig, loadNetworkSettings, proxyForNetwork, publicNetworkSettings, relayServerFiles, resolveFrpc, saveNetworkSettings, updateNetworkSettings, type NetworkMode, type NetworkSettings } from "./network-settings.ts";
20
26
  import { closeSseClient, createSseClient, sendSseEvent, type SseClient } from "./sse-channel.ts";
21
27
  import { closeAllPiRpcs, spawnPiRpc, type Json, type PiRpc } from "./pi-rpc.ts";
22
28
  import { loadLocalSession } from "./local-session.ts";
23
- import { loadJsonWithLegacyMigration, writeJsonAtomically } from "./state-store.ts";
29
+ import { loadJsonWithLegacyMigration, writeJsonAtomically, writeTextAtomically } from "./state-store.ts";
24
30
  import { buildFilePreview } from "./file-preview.ts";
31
+ import { collectBackgroundJobs, readBackgroundJobLog } from "./background-jobs.ts";
25
32
  import { readClipboardImage, readClipboardText } from "./windows-clipboard.ts";
26
33
  import {
27
34
  applyAssistantDelta,
28
35
  applyToolExecution,
29
36
  attachToolResults,
37
+ browserMessages,
30
38
  findLast,
31
39
  renderAssistantMessage,
32
40
  renderCompaction,
@@ -44,6 +52,9 @@ const DEFAULT_PORT = 18789;
44
52
  const STATE_DIR = process.env.PI_WEB_STATE_DIR || join(getAgentDir(), "pi-web");
45
53
  const STATE_FILE = resolve(process.env.PI_WEB_STATE_FILE || join(STATE_DIR, "state.json"));
46
54
  const LEGACY_STATE_FILE = resolve(process.env.PI_WEB_LEGACY_STATE_FILE || join(here, "state.json"));
55
+ const AUTH_FILE = join(dirname(STATE_FILE), "auth.json");
56
+ const NETWORK_FILE = join(dirname(STATE_FILE), "network.json");
57
+ const TERMINAL_PROXY_FILE = join(dirname(STATE_FILE), "terminal-proxy.json");
47
58
 
48
59
  interface Workspace {
49
60
  id: string;
@@ -181,6 +192,7 @@ interface RuntimeSession {
181
192
  operationChain?: Promise<void>;
182
193
  pendingTreeNavigation?: PendingTreeNavigation;
183
194
  compactionNotice?: RenderedMessage;
195
+ compactionNoticeIndex?: number;
184
196
  pendingQueue?: RpcQueueSnapshot;
185
197
  }
186
198
 
@@ -205,8 +217,19 @@ interface MaintenanceCommandResult {
205
217
  let server: ReturnType<typeof createServer> | undefined;
206
218
  let serverUrl: string | undefined;
207
219
  let serverInstanceId: string | undefined;
220
+ let webAccess = new WebAccess(false);
221
+ let networkSettings: NetworkSettings = { mode: "local" };
222
+ let terminalProxy: TerminalProxySettings = { mode: "inherit", url: "http://127.0.0.1:7987" };
223
+ let terminalProxySaving = false;
224
+ let networkApplying = false;
225
+ let networkChange: Promise<void> = Promise.resolve();
226
+ let networkError: string | undefined;
227
+ let frpRuntimeDir: string | undefined;
228
+ const frpProcess = new FrpProcess(() => broadcastEvent({ type: "network_changed" }));
208
229
  let updateExtensions: (() => Promise<MaintenanceCommandResult>) | undefined;
209
230
  let reloadRuntime: (() => Promise<void>) | undefined;
231
+ let extensionSettingsCwd = process.cwd();
232
+ let extensionSettingsSaving = false;
210
233
  let maintenanceRunning = false;
211
234
  let sessions = new Map<string, RuntimeSession>();
212
235
  let sseClients = new Set<SseClient>();
@@ -221,7 +244,7 @@ const modelInfoCache = new Map<string, ModelInfo>();
221
244
  const modelCatalogRefreshes = new Map<string, Promise<ModelCatalog>>();
222
245
  const commandCache = new Map<string, SlashCommandInfo[]>();
223
246
  const exportDownloads = new Map<string, ExportDownload>();
224
- const imageAttachments = new ImageAttachmentStore();
247
+ const attachments = new AttachmentStore();
225
248
  const ABORT_RPC_TIMEOUT_MS = 3_000;
226
249
  const INTERNAL_TREE_COMMAND = "pi-web-navigate-tree";
227
250
  const INTERNAL_RELOAD_COMMAND = "pi-web-reload-runtime";
@@ -240,8 +263,10 @@ const MIME: Record<string, string> = {
240
263
  ".woff2": "font/woff2",
241
264
  ".ttf": "font/ttf",
242
265
  ".png": "image/png",
266
+ ".webp": "image/webp",
243
267
  };
244
268
  const MAX_FILE_VIEW_BYTES = 2 * 1024 * 1024;
269
+ const MAX_IMAGE_VIEW_BYTES = 10 * 1024 * 1024;
245
270
 
246
271
  function nowIso(): string {
247
272
  return new Date().toISOString();
@@ -397,7 +422,7 @@ function historyEditorText(record: RuntimeSession, entryId: string): string | un
397
422
  async function pasteSystemClipboard(): Promise<Json> {
398
423
  const image = await readClipboardImage();
399
424
  if (image) {
400
- const attachment = await imageAttachments.create(image.bytes);
425
+ const attachment = await attachments.create(image.bytes);
401
426
  return {
402
427
  kind: "image",
403
428
  attachment: { ...attachment, url: `/api/attachments/${attachment.id}` },
@@ -618,7 +643,9 @@ function snapshotMessages(record: RuntimeSession): RenderedMessage[] {
618
643
  return merged;
619
644
  }
620
645
  }
621
- 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;
622
649
  }
623
650
 
624
651
  function pendingQueueSnapshot(record: RuntimeSession): RpcQueueSnapshot {
@@ -651,7 +678,7 @@ function broadcastSessionSnapshot(record: RuntimeSession): void {
651
678
  thinkingLevelsByModel: thinkingLevelsByModel(record.models),
652
679
  commands: record.commands,
653
680
  extensionStatuses: record.extensionStatuses,
654
- messages: snapshotMessages(record),
681
+ messages: browserMessages(snapshotMessages(record)),
655
682
  pendingQueue: pendingQueueSnapshot(record),
656
683
  dialogs: record.dialogs,
657
684
  });
@@ -864,7 +891,7 @@ async function discoverFreshModelCatalog(cwd: string): Promise<ModelCatalog> {
864
891
  const existing = modelCatalogRefreshes.get(key);
865
892
  if (existing) return existing;
866
893
  const task = (async () => {
867
- const probe = spawnPiRpc({ cwd: key, noSession: true });
894
+ const probe = spawnPiRpc({ cwd: key, noSession: true, proxy: terminalProxy });
868
895
  try {
869
896
  const [modelsResponse, stateResponse] = await Promise.all([
870
897
  probe.send<Json>({ type: "get_available_models" }),
@@ -902,7 +929,7 @@ function applyModelInfo(record: RuntimeSession, info: ModelInfo): void {
902
929
 
903
930
  async function refreshModelsFresh(record: RuntimeSession): Promise<void> {
904
931
  if (record.starting) await record.starting;
905
- if (record.streaming) {
932
+ if (record.streaming || hasRunningBackgroundJobs(record)) {
906
933
  await refreshModels(record);
907
934
  return;
908
935
  }
@@ -913,10 +940,15 @@ async function refreshModelsFresh(record: RuntimeSession): Promise<void> {
913
940
  const runtimeModels = (runtimeResponse.data?.models ?? []) as unknown[];
914
941
  if (!sameModelCatalog(runtimeModels, catalog.models)) {
915
942
  await Promise.allSettled([refreshState(record), refreshMessages(record)]);
943
+ if (record.streaming || hasRunningBackgroundJobs(record)) {
944
+ await refreshModels(record);
945
+ return;
946
+ }
916
947
  await stopSessionProcess(record);
917
948
  }
918
949
  } catch (error) {
919
950
  console.error("[pi-web] runtime model refresh failed:", error);
951
+ if (record.streaming || hasRunningBackgroundJobs(record)) return;
920
952
  await stopSessionProcess(record);
921
953
  }
922
954
  }
@@ -1220,6 +1252,7 @@ function bindSessionEvents(record: RuntimeSession, proc: PiRpc): void {
1220
1252
  } else if (event.type === "compaction_start") {
1221
1253
  const reason = typeof event.reason === "string" ? event.reason : undefined;
1222
1254
  record.state = { ...(record.state ?? {}), isCompacting: true };
1255
+ record.compactionNoticeIndex = record.messages.length;
1223
1256
  record.compactionNotice = {
1224
1257
  kind: "compaction",
1225
1258
  id: `compaction-running:${Date.now()}`,
@@ -1251,6 +1284,7 @@ function bindSessionEvents(record: RuntimeSession, proc: PiRpc): void {
1251
1284
  timestamp: Date.now(),
1252
1285
  };
1253
1286
  record.compactionNotice = notice;
1287
+ record.compactionNoticeIndex = record.messages.length;
1254
1288
  broadcastSessionSnapshot(record);
1255
1289
  if (result) {
1256
1290
  Promise.allSettled([refreshMessages(record), refreshStats(record)]).then(() => {
@@ -1296,10 +1330,15 @@ function bindSessionEvents(record: RuntimeSession, proc: PiRpc): void {
1296
1330
  // session lifecycle
1297
1331
  // ---------------------------------------------------------------------------
1298
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
+
1299
1338
  function evictIdleProcesses(): void {
1300
1339
  const idle: RuntimeSession[] = [];
1301
1340
  for (const record of sessions.values()) {
1302
- 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) {
1303
1342
  idle.push(record);
1304
1343
  }
1305
1344
  }
@@ -1361,6 +1400,7 @@ async function ensureProcess(record: RuntimeSession): Promise<void> {
1361
1400
  const task = (async () => {
1362
1401
  const proc = spawnPiRpc({
1363
1402
  cwd: record.cwd,
1403
+ proxy: terminalProxy,
1364
1404
  sessionId: record.sessionFile ? undefined : record.id,
1365
1405
  sessionFile: record.sessionFile,
1366
1406
  name: record.title === "新会话" ? undefined : record.title,
@@ -1707,28 +1747,6 @@ function readJsonBody(req: import("node:http").IncomingMessage, limit = 12 * 102
1707
1747
  });
1708
1748
  }
1709
1749
 
1710
- function isTrustedLocalMutation(req: import("node:http").IncomingMessage): boolean {
1711
- const host = req.headers.host ?? "";
1712
- let hostname = "";
1713
- try {
1714
- hostname = new URL(`http://${host}`).hostname.toLowerCase();
1715
- } catch {
1716
- return false;
1717
- }
1718
- if (hostname !== "127.0.0.1" && hostname !== "localhost" && hostname !== "[::1]") return false;
1719
- const fetchSite = req.headers["sec-fetch-site"];
1720
- if (fetchSite === "cross-site") return false;
1721
- const origin = req.headers.origin;
1722
- if (origin) {
1723
- try {
1724
- if (new URL(origin).host.toLowerCase() !== host.toLowerCase()) return false;
1725
- } catch {
1726
- return false;
1727
- }
1728
- }
1729
- return true;
1730
- }
1731
-
1732
1750
  function findRecord(id: string): RuntimeSession | undefined {
1733
1751
  return sessions.get(id);
1734
1752
  }
@@ -2083,8 +2101,116 @@ function scheduleRuntimeReload(): void {
2083
2101
  async function handleApi(req: import("node:http").IncomingMessage, res: ServerResponse, url: URL): Promise<boolean> {
2084
2102
  const path = url.pathname;
2085
2103
  const method = req.method ?? "GET";
2086
- if (method !== "GET" && method !== "HEAD" && !isTrustedLocalMutation(req)) {
2087
- return sendJson(res, 403, { error: "forbidden origin" });
2104
+ if (!webAccess.trusted(req)) return sendJson(res, 403, { error: "forbidden origin" });
2105
+ res.setHeader("cache-control", "no-store");
2106
+ if (method === "GET" && path === "/api/auth") {
2107
+ return sendJson(res, 200, { passwordConfigured: webAccess.passwordConfigured });
2108
+ }
2109
+ if (method === "POST" && path === "/api/auth") {
2110
+ const body = await readJsonBody(req, 2048);
2111
+ if (!webAccess.passwordConfigured) {
2112
+ return sendJson(res, 503, { error: "请先在电脑网页的设置中设置访问密码" });
2113
+ }
2114
+ if (!webAccess.remoteEnabled || !await webAccess.acceptsPassword(body.password)) {
2115
+ return sendJson(res, 401, { error: "密码不正确" });
2116
+ }
2117
+ res.setHeader("set-cookie", webAccess.cookie(req));
2118
+ return sendJson(res, 200, { ok: true });
2119
+ }
2120
+ if (!webAccess.authorized(req)) {
2121
+ return sendJson(res, 401, { error: "请输入访问密码" });
2122
+ }
2123
+ if (method === "POST" && path === "/api/auth/password") {
2124
+ if (!isLocalRequest(req)) return sendJson(res, 403, { error: "请在电脑本机网页设置密码" });
2125
+ if (networkApplying) return sendJson(res, 409, { error: "网络设置正在应用,请稍后保存密码" });
2126
+ networkApplying = true;
2127
+ try {
2128
+ const body = await readJsonBody(req, 2048);
2129
+ await webAccess.setPassword(body.password, AUTH_FILE);
2130
+ } catch (error) {
2131
+ return sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
2132
+ } finally {
2133
+ networkApplying = false;
2134
+ }
2135
+ for (const client of [...sseClients]) {
2136
+ closeSseClient(client);
2137
+ (client.res as ServerResponse).end();
2138
+ }
2139
+ sseClients.clear();
2140
+ return sendJson(res, 200, { ok: true });
2141
+ }
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
+
2183
+ if (path === "/api/network" || path === "/api/network/server-files") {
2184
+ if (!isLocalRequest(req)) return sendJson(res, 403, { error: "请在电脑本机网页配置网络访问" });
2185
+ if (method === "GET" && path === "/api/network/server-files") {
2186
+ if (!networkSettings.relay) return sendJson(res, 400, { error: "请先保存服务器中转配置" });
2187
+ return sendJson(res, 200, { files: relayServerFiles(networkSettings.relay) });
2188
+ }
2189
+ if (method === "GET" && path === "/api/network") return sendJson(res, 200, networkView());
2190
+ if (method === "POST" && path === "/api/network") {
2191
+ if (networkApplying) return sendJson(res, 409, { error: "网络设置正在应用" });
2192
+ networkApplying = true;
2193
+ try {
2194
+ const next = updateNetworkSettings(await readJsonBody(req, 16 * 1024), networkSettings);
2195
+ if (next.mode === "relay") await resolveFrpc(next.relay!.frpcPath, dirname(NETWORK_FILE));
2196
+ await saveNetworkSettings(NETWORK_FILE, next);
2197
+ networkSettings = next;
2198
+ networkError = undefined;
2199
+ sendJson(res, 200, networkView());
2200
+ networkChange = new Promise<void>((resolveChange) => setImmediate(resolveChange)).then(async () => {
2201
+ if (!shuttingDown) await applyNetworkSettings();
2202
+ }).catch((error) => {
2203
+ networkError = error instanceof Error ? error.message : String(error);
2204
+ }).finally(() => {
2205
+ networkApplying = false;
2206
+ broadcastEvent({ type: "network_changed" });
2207
+ });
2208
+ return true;
2209
+ } catch (error) {
2210
+ networkApplying = false;
2211
+ return sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
2212
+ }
2213
+ }
2088
2214
  }
2089
2215
 
2090
2216
  if (method === "GET" && path === "/api/bootstrap") {
@@ -2096,11 +2222,15 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
2096
2222
  commands: bootstrapCommands(),
2097
2223
  url: serverUrl,
2098
2224
  instanceId: serverInstanceId,
2225
+ localClient: isLocalRequest(req),
2226
+ lanEnabled: webAccess.remoteEnabled,
2227
+ passwordConfigured: webAccess.passwordConfigured,
2228
+ lanUrls: isLocalRequest(req) ? getWebLanUrls() : [],
2099
2229
  });
2100
2230
  }
2101
2231
 
2102
2232
  if (method === "POST" && (path === "/api/maintenance/update-and-reload" || path === "/api/maintenance/reload")) {
2103
- if (maintenanceRunning) return sendJson(res, 409, { error: "维护操作正在执行" });
2233
+ if (maintenanceRunning || extensionSettingsSaving) return sendJson(res, 409, { error: "维护操作或扩展配置正在执行" });
2104
2234
  if (!reloadRuntime || (path.endsWith("update-and-reload") && !updateExtensions)) {
2105
2235
  return sendJson(res, 503, { error: "当前 Pi 运行时不支持 Web 维护操作,请先执行一次 /reload" });
2106
2236
  }
@@ -2137,12 +2267,26 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
2137
2267
  }
2138
2268
  }
2139
2269
 
2270
+ if (method === "POST" && path === "/api/attachments") {
2271
+ const body = await readJsonBody(req, 21 * 1024 * 1024);
2272
+ if (typeof body.data !== "string" || !/^[A-Za-z0-9+/]+={0,2}$/.test(body.data)) {
2273
+ return sendJson(res, 400, { error: "文件数据无效" });
2274
+ }
2275
+ try {
2276
+ const attachment = await attachments.create(Buffer.from(body.data, "base64"), typeof body.name === "string" ? body.name : undefined);
2277
+ return sendJson(res, 200, { attachment: { ...attachment, url: `/api/attachments/${attachment.id}` } });
2278
+ } catch (error) {
2279
+ return sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
2280
+ }
2281
+ }
2282
+
2140
2283
  const attachmentMatch = /^\/api\/attachments\/([a-f0-9-]+)$/.exec(path);
2141
2284
  if (attachmentMatch && (method === "GET" || method === "HEAD")) {
2142
2285
  try {
2143
- const { meta, bytes } = await imageAttachments.read(attachmentMatch[1]);
2286
+ const { meta, bytes } = await attachments.read(attachmentMatch[1]);
2144
2287
  res.writeHead(200, {
2145
2288
  "content-type": meta.mimeType,
2289
+ ...(meta.kind === "document" ? { "content-disposition": `attachment; filename*=UTF-8''${encodeURIComponent(meta.name)}` } : {}),
2146
2290
  "content-length": bytes.byteLength,
2147
2291
  "cache-control": "private, no-store",
2148
2292
  "x-content-type-options": "nosniff",
@@ -2155,7 +2299,7 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
2155
2299
  }
2156
2300
 
2157
2301
  if (attachmentMatch && method === "DELETE") {
2158
- const removed = await imageAttachments.remove(attachmentMatch[1]);
2302
+ const removed = await attachments.remove(attachmentMatch[1]);
2159
2303
  return sendJson(res, removed ? 200 : 404, removed ? { ok: true } : { error: "attachment not found" });
2160
2304
  }
2161
2305
 
@@ -2191,8 +2335,20 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
2191
2335
  const target = normalizePath(input);
2192
2336
  const info = await stat(target);
2193
2337
  if (!info.isFile()) return sendJson(res, 400, { error: "path is not a file" });
2194
- 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" });
2195
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
+ }
2196
2352
  if (data.includes(0)) return sendJson(res, 415, { error: "binary files cannot be previewed" });
2197
2353
  if (url.searchParams.get("format") === "json") {
2198
2354
  return sendJson(res, 200, buildFilePreview(target, data));
@@ -2380,7 +2536,7 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
2380
2536
  return sendJson(res, 200, {
2381
2537
  session: sessionSummary(record),
2382
2538
  state: record.state,
2383
- messages: record.messages,
2539
+ messages: browserMessages(record.messages),
2384
2540
  pendingQueue: pendingQueueSnapshot(record),
2385
2541
  models: record.models,
2386
2542
  thinkingLevels: record.thinkingLevels,
@@ -2405,6 +2561,21 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
2405
2561
  const match = /^\/api\/sessions\/([^/]+)(?:\/([^/]+))?$/.exec(path);
2406
2562
  const action = match?.[2] ?? "";
2407
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
+
2408
2579
  if (method === "POST" && action === "models") {
2409
2580
  try {
2410
2581
  await refreshModelsFresh(session);
@@ -2425,7 +2596,7 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
2425
2596
  return sendJson(res, 200, {
2426
2597
  session: sessionSummary(session),
2427
2598
  state: session.state,
2428
- messages: snapshotMessages(session),
2599
+ messages: browserMessages(snapshotMessages(session)),
2429
2600
  pendingQueue: pendingQueueSnapshot(session),
2430
2601
  models: session.models,
2431
2602
  thinkingLevels: session.thinkingLevels,
@@ -2505,7 +2676,7 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
2505
2676
  history: nextHistory,
2506
2677
  session: sessionSummary(session),
2507
2678
  state: session.state,
2508
- messages: snapshotMessages(session),
2679
+ messages: browserMessages(snapshotMessages(session)),
2509
2680
  pendingQueue: pendingQueueSnapshot(session),
2510
2681
  stats: session.stats,
2511
2682
  });
@@ -2515,6 +2686,40 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
2515
2686
  }
2516
2687
  }
2517
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
+
2518
2723
  if (method === "POST" && action === "prompt") {
2519
2724
  const body = await readJsonBody(req);
2520
2725
  const message = typeof body.message === "string" ? body.message : "";
@@ -2522,35 +2727,47 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
2522
2727
  ? body.attachmentIds.filter((id): id is string => typeof id === "string")
2523
2728
  : [];
2524
2729
  if (!message.trim() && attachmentIds.length === 0) {
2525
- return sendJson(res, 400, { error: "message or image attachment is required" });
2730
+ return sendJson(res, 400, { error: "请输入消息或添加文件附件" });
2526
2731
  }
2732
+ const parsedCommand = parseSlashCommand(message);
2733
+ const builtinError = parsedCommand ? builtinCommandError(parsedCommand.name) : null;
2734
+ if (builtinError) return sendJson(res, 400, { error: builtinError });
2527
2735
  try {
2528
2736
  return await withSessionOperation(session, async () => {
2529
2737
  await ensureProcess(session);
2530
2738
  const wasStreaming = session.streaming;
2531
2739
  if (attachmentIds.length > 0 && wasStreaming) {
2532
- return sendJson(res, 409, { error: "生成过程中暂不支持排队图片,请停止当前生成后再发送" });
2740
+ return sendJson(res, 409, { error: "生成过程中暂不支持排队附件,请停止当前生成后再发送" });
2533
2741
  }
2534
2742
  if (attachmentIds.length > 0 && message.trimStart().startsWith("/")) {
2535
- return sendJson(res, 400, { error: "斜杠命令不能附带图片" });
2743
+ return sendJson(res, 400, { error: "斜杠命令不能附带附件" });
2536
2744
  }
2537
2745
  const model = session.state?.model;
2538
2746
  const modelInput = model && typeof model === "object" && Array.isArray((model as Json).input)
2539
2747
  ? (model as Json).input as unknown[]
2540
2748
  : [];
2541
- if (attachmentIds.length > 0 && !modelInput.includes("image")) {
2542
- return sendJson(res, 400, { error: "当前模型不支持图片输入,请切换视觉模型" });
2543
- }
2544
- let images: Array<{ type: "image"; data: string; mimeType: string }> = [];
2749
+ let prepared: Awaited<ReturnType<AttachmentStore["promptForRpc"]>>;
2545
2750
  try {
2546
- images = await imageAttachments.imagesForRpc(attachmentIds);
2751
+ prepared = await attachments.promptForRpc(attachmentIds, message);
2547
2752
  } catch (error) {
2548
2753
  return sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
2549
2754
  }
2550
- const promptMessage = message.trim() || "请分析附加图片。";
2551
- if (promptMessage.trimStart().startsWith("/")) await refreshCommands(session);
2552
- const optimisticStreaming = !wasStreaming && !isImmediateExtensionCommand(session, promptMessage);
2553
- 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 };
2554
2771
  if (images.length > 0) command.images = images;
2555
2772
  if (wasStreaming) command.streamingBehavior = "steer";
2556
2773
  if (optimisticStreaming) {
@@ -2560,15 +2777,15 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
2560
2777
  }
2561
2778
  try {
2562
2779
  const response = await session.proc!.send(command);
2563
- await Promise.all(attachmentIds.map((id) => imageAttachments.remove(id)));
2564
- if (!session.title || session.title === "新会话") {
2565
- 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) || "文件会话";
2566
2783
  session.title = nextTitle;
2567
2784
  updateStoredSession(session);
2568
2785
  session.proc?.send({ type: "set_session_name", name: nextTitle }).catch(() => {});
2569
2786
  broadcastWorkspaces();
2570
2787
  }
2571
- return sendJson(res, 200, { ok: true, response, queued: wasStreaming });
2788
+ return sendJson(res, 200, { ok: true, response, queued: wasStreaming && !immediateCommand });
2572
2789
  } catch (error) {
2573
2790
  if (optimisticStreaming) {
2574
2791
  session.streaming = false;
@@ -2809,24 +3026,37 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
2809
3026
  const body = await readJsonBody(req);
2810
3027
  const name = typeof body.name === "string" ? body.name.trim().replace(/\s+/g, " ").slice(0, 60) : "";
2811
3028
  if (!name) return sendJson(res, 400, { error: "name is required" });
2812
- session.title = name;
2813
- updateStoredSession(session);
2814
- if (session.proc) {
2815
- session.proc.send({ type: "set_session_name", name }).catch(() => {});
2816
- } else {
2817
- session.pendingName = name;
2818
- 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) });
2819
3043
  }
2820
- broadcastWorkspaces();
2821
- broadcastSessionSnapshot(session);
2822
- return sendJson(res, 200, { ok: true });
2823
3044
  }
2824
3045
 
2825
3046
  if (method === "POST" && action === "compact") {
2826
- await ensureProcess(session);
3047
+ const body = await readJsonBody(req);
3048
+ const customInstructions = typeof body.customInstructions === "string" ? body.customInstructions.trim() : "";
2827
3049
  try {
2828
- const response = await session.proc!.send({ type: "compact" });
2829
- 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
+ });
2830
3060
  } catch (error) {
2831
3061
  return sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
2832
3062
  }
@@ -2895,7 +3125,7 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
2895
3125
  return sendJson(res, 200, {
2896
3126
  session: sessionSummary(session),
2897
3127
  state: session.state,
2898
- messages: snapshotMessages(session),
3128
+ messages: browserMessages(snapshotMessages(session)),
2899
3129
  pendingQueue: pendingQueueSnapshot(session),
2900
3130
  models: session.models,
2901
3131
  thinkingLevels: session.thinkingLevels,
@@ -2922,19 +3152,77 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
2922
3152
  // server lifecycle
2923
3153
  // ---------------------------------------------------------------------------
2924
3154
 
2925
- function listenOn(app: ReturnType<typeof createServer>, preferredPort: number): Promise<number> {
3155
+ function networkView() {
3156
+ const status = networkError ? { state: "error", message: networkError }
3157
+ : networkSettings.mode === "relay" ? frpProcess.status
3158
+ : { state: networkSettings.mode === "lan" ? "connected" : "stopped", message: networkSettings.mode === "lan" ? "局域网访问已开启" : "仅本机访问" };
3159
+ return { settings: publicNetworkSettings(networkSettings), status, urls: getWebLanUrls(), applying: networkApplying };
3160
+ }
3161
+
3162
+ function disconnectWebClients(): void {
3163
+ for (const client of [...sseClients]) {
3164
+ closeSseClient(client);
3165
+ (client.res as ServerResponse).end();
3166
+ }
3167
+ sseClients.clear();
3168
+ }
3169
+
3170
+ async function stopRelay(): Promise<void> {
3171
+ await frpProcess.stop();
3172
+ if (frpRuntimeDir) await rm(frpRuntimeDir, { recursive: true, force: true });
3173
+ frpRuntimeDir = undefined;
3174
+ }
3175
+
3176
+ async function startRelay(): Promise<void> {
3177
+ if (networkSettings.mode !== "relay" || !networkSettings.relay || !serverUrl) return;
3178
+ const relay = networkSettings.relay;
3179
+ const executable = await resolveFrpc(relay.frpcPath, dirname(NETWORK_FILE));
3180
+ const runtimeRoot = join(dirname(NETWORK_FILE), "runtime");
3181
+ await mkdir(runtimeRoot, { recursive: true });
3182
+ frpRuntimeDir = await mkdtemp(join(runtimeRoot, "frpc-"));
3183
+ const configPath = join(frpRuntimeDir, "frpc.toml");
3184
+ await writeTextAtomically(configPath, frpcConfig(relay, Number(new URL(serverUrl).port)));
3185
+ await chmod(configPath, 0o600);
3186
+ await frpProcess.start({ executable, args: ["-c", configPath], cwd: dirname(NETWORK_FILE) });
3187
+ }
3188
+
3189
+ async function applyNetworkSettings(): Promise<void> {
3190
+ if (!server || !serverUrl) return;
3191
+ await stopRelay();
3192
+ const next = await WebAccess.load(networkSettings.mode === "lan", AUTH_FILE, proxyForNetwork(networkSettings));
3193
+ disconnectWebClients();
3194
+ if (next.lan !== webAccess.lan) {
3195
+ const port = Number(new URL(serverUrl).port);
3196
+ const previousHost = webAccess.lan ? "0.0.0.0" : HOST;
3197
+ const app = server;
3198
+ await new Promise<void>((resolveClose) => {
3199
+ app.close(() => resolveClose());
3200
+ app.closeAllConnections();
3201
+ });
3202
+ try {
3203
+ await listenOn(app, port, next.lan ? "0.0.0.0" : HOST, false);
3204
+ } catch (error) {
3205
+ await listenOn(app, port, previousHost, false);
3206
+ throw error;
3207
+ }
3208
+ }
3209
+ webAccess = next;
3210
+ await startRelay();
3211
+ }
3212
+
3213
+ function listenOn(app: ReturnType<typeof createServer>, preferredPort: number, host = HOST, allowFallback = true): Promise<number> {
2926
3214
  return new Promise((resolvePort, reject) => {
2927
3215
  const onError = (error: NodeJS.ErrnoException) => {
2928
- if (error.code === "EADDRINUSE" && preferredPort !== 0) {
3216
+ if (allowFallback && error.code === "EADDRINUSE" && preferredPort !== 0) {
2929
3217
  app.removeListener("error", onError);
2930
- listenOn(app, 0).then(resolvePort, reject);
3218
+ listenOn(app, 0, host).then(resolvePort, reject);
2931
3219
  return;
2932
3220
  }
2933
3221
  app.removeListener("error", onError);
2934
3222
  reject(error);
2935
3223
  };
2936
3224
  app.once("error", onError);
2937
- app.listen(preferredPort, HOST, () => {
3225
+ app.listen(preferredPort, host, () => {
2938
3226
  app.removeListener("error", onError);
2939
3227
  const address = app.address();
2940
3228
  resolvePort(address && typeof address === "object" ? address.port : preferredPort);
@@ -2950,6 +3238,7 @@ function openBrowser(url: string): void {
2950
3238
  }
2951
3239
 
2952
3240
  function exitHook(): void {
3241
+ frpProcess.kill();
2953
3242
  for (const record of sessions.values()) {
2954
3243
  try {
2955
3244
  record.proc?.close();
@@ -2957,7 +3246,7 @@ function exitHook(): void {
2957
3246
  // ignore
2958
3247
  }
2959
3248
  }
2960
- void imageAttachments.close();
3249
+ void attachments.close();
2961
3250
  }
2962
3251
 
2963
3252
  function sigintHook(): void {
@@ -2972,6 +3261,8 @@ function shutdown(): Promise<void> {
2972
3261
  }
2973
3262
 
2974
3263
  async function shutdownServer(): Promise<void> {
3264
+ await networkChange;
3265
+ await stopRelay();
2975
3266
  for (const client of [...sseClients]) {
2976
3267
  closeSseClient(client);
2977
3268
  try {
@@ -2985,7 +3276,7 @@ async function shutdownServer(): Promise<void> {
2985
3276
  await closeAllPiRpcs();
2986
3277
  for (const download of exportDownloads.values()) unlink(download.path).catch(() => {});
2987
3278
  exportDownloads.clear();
2988
- await imageAttachments.close();
3279
+ await attachments.close();
2989
3280
  if (heartbeat) clearInterval(heartbeat);
2990
3281
  heartbeat = undefined;
2991
3282
  await saveStateNow().catch(() => {});
@@ -3010,6 +3301,8 @@ export async function startWebServer(options: {
3010
3301
  defaultCwd?: string;
3011
3302
  port?: number;
3012
3303
  open?: boolean;
3304
+ lan?: boolean;
3305
+ mode?: NetworkMode;
3013
3306
  initialModelInfo?: ModelInfo;
3014
3307
  initialCommands?: unknown[];
3015
3308
  updateExtensions?: () => Promise<MaintenanceCommandResult>;
@@ -3017,9 +3310,19 @@ export async function startWebServer(options: {
3017
3310
  } = {}): Promise<string> {
3018
3311
  if (shutdownPromise) await shutdownPromise;
3019
3312
  const initialCwd = normalizePath(options.defaultCwd ?? process.cwd());
3313
+ extensionSettingsCwd = initialCwd;
3020
3314
  updateExtensions = options.updateExtensions;
3021
3315
  reloadRuntime = options.reloadRuntime;
3316
+ const mode = options.mode ?? (options.lan === undefined ? undefined : options.lan ? "lan" : "local");
3022
3317
  if (serverUrl && server) {
3318
+ await networkChange;
3319
+ if (mode !== undefined) {
3320
+ networkSettings = updateNetworkSettings({ mode }, networkSettings);
3321
+ await saveNetworkSettings(NETWORK_FILE, networkSettings);
3322
+ networkError = undefined;
3323
+ await applyNetworkSettings();
3324
+ broadcastEvent({ type: "network_changed" });
3325
+ }
3023
3326
  if (options.initialCommands?.length) commandCache.set(initialCwd, normalizeCommands(options.initialCommands));
3024
3327
  if (options.open !== false) openBrowser(serverUrl);
3025
3328
  return serverUrl;
@@ -3037,9 +3340,24 @@ export async function startWebServer(options: {
3037
3340
  }
3038
3341
  if (options.initialCommands?.length) commandCache.set(initialCwd, normalizeCommands(options.initialCommands));
3039
3342
  if (state.workspaces.length === 0 && options.defaultCwd) await addWorkspace(options.defaultCwd);
3040
- const requestedPort = options.port && Number.isFinite(options.port) ? options.port : DEFAULT_PORT;
3343
+ const requestedPort = options.port ?? DEFAULT_PORT;
3344
+ if (!Number.isInteger(requestedPort) || requestedPort < 0 || requestedPort > 65535) {
3345
+ throw new Error("端口必须是 0–65535 之间的整数");
3346
+ }
3347
+ terminalProxy = await loadTerminalProxy(TERMINAL_PROXY_FILE);
3348
+ networkSettings = await loadNetworkSettings(NETWORK_FILE);
3349
+ if (mode !== undefined) {
3350
+ networkSettings = updateNetworkSettings({ mode }, networkSettings);
3351
+ await saveNetworkSettings(NETWORK_FILE, networkSettings);
3352
+ }
3353
+ networkApplying = false;
3354
+ networkError = undefined;
3355
+ webAccess = await WebAccess.load(networkSettings.mode === "lan", AUTH_FILE, proxyForNetwork(networkSettings));
3041
3356
  const app = createServer(async (req, res) => {
3042
3357
  try {
3358
+ res.setHeader("referrer-policy", "no-referrer");
3359
+ res.setHeader("x-content-type-options", "nosniff");
3360
+ res.setHeader("x-frame-options", "DENY");
3043
3361
  if (shuttingDown) return sendJson(res, 503, { error: "pi-web 正在关闭" });
3044
3362
  const url = new URL(req.url ?? "/", `http://${req.headers.host ?? `${HOST}:${requestedPort}`}`);
3045
3363
  if (url.pathname.startsWith("/api/")) {
@@ -3053,7 +3371,7 @@ export async function startWebServer(options: {
3053
3371
  else res.end();
3054
3372
  }
3055
3373
  });
3056
- const port = await listenOn(app, requestedPort);
3374
+ const port = await listenOn(app, requestedPort, webAccess.lan ? "0.0.0.0" : HOST);
3057
3375
  server = app;
3058
3376
  serverUrl = `http://${HOST}:${port}`;
3059
3377
  serverInstanceId = randomUUID();
@@ -3061,6 +3379,9 @@ export async function startWebServer(options: {
3061
3379
  process.on("exit", exitHook);
3062
3380
  process.on("SIGINT", sigintHook);
3063
3381
  process.on("SIGTERM", sigintHook);
3382
+ try { await startRelay(); } catch (error) {
3383
+ networkError = error instanceof Error ? error.message : String(error);
3384
+ }
3064
3385
  if (options.open !== false) openBrowser(serverUrl);
3065
3386
  return serverUrl;
3066
3387
  }
@@ -3072,3 +3393,11 @@ export async function stopWebServer(): Promise<void> {
3072
3393
  export function getWebUrl(): string | undefined {
3073
3394
  return serverUrl;
3074
3395
  }
3396
+
3397
+ export function getWebLanUrls(): string[] {
3398
+ return serverUrl ? webAccess.urls(Number(new URL(serverUrl).port)) : [];
3399
+ }
3400
+
3401
+ export function getWebConnectionOptions(): { port: number; lan: boolean; mode: NetworkMode } | undefined {
3402
+ return serverUrl ? { port: Number(new URL(serverUrl).port), lan: webAccess.lan, mode: networkSettings.mode } : undefined;
3403
+ }