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