@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/README.md +52 -3
- package/THIRD_PARTY_NOTICES.md +4 -0
- package/attachment-parser.ts +210 -0
- package/attachment-store.ts +67 -24
- package/background-jobs.ts +52 -0
- package/extension-settings.ts +50 -0
- package/file-preview.ts +7 -1
- package/frp-process.ts +135 -0
- package/index.ts +35 -3
- package/local-session.ts +10 -2
- package/network-access.ts +171 -0
- package/network-settings.ts +146 -0
- package/package.json +17 -4
- package/pi-rpc.ts +7 -5
- package/server.ts +408 -79
- package/terminal-proxy.ts +50 -0
- package/transcript.ts +13 -0
- package/web/app.css +253 -44
- package/web/app.js +5097 -4226
- package/web/assets/markdown.LICENSE.txt +200 -0
- package/web/assets/markdown.js +25 -0
- package/web/assets/themes/sakura.webp +0 -0
- package/web/assets/themes/starlight.webp +0 -0
- package/web/index.html +6 -2
- package/web/markdown.ts +118 -0
- package/web/slash-commands.d.ts +11 -0
- package/web/slash-commands.js +46 -0
- package/web/themes.css +158 -0
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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
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 (
|
|
2087
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2551
|
-
if (
|
|
2552
|
-
|
|
2553
|
-
|
|
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) =>
|
|
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
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
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
|
|
3047
|
+
const body = await readJsonBody(req);
|
|
3048
|
+
const customInstructions = typeof body.customInstructions === "string" ? body.customInstructions.trim() : "";
|
|
2827
3049
|
try {
|
|
2828
|
-
|
|
2829
|
-
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|