@oversk7/pi-web-dsh-style 1.1.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/package.json +9 -1
- package/server.ts +45 -34
- package/sse-channel.ts +37 -0
- package/web/app.js +111 -21
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# @oversk7/pi-web-dsh-style
|
|
2
2
|
|
|
3
|
+
[GitHub 源码](https://github.com/oversk7/pi-web-dsh-style) · [问题与建议](https://github.com/oversk7/pi-web-dsh-style/issues) · [npm](https://www.npmjs.com/package/@oversk7/pi-web-dsh-style) · [Pi 扩展目录](https://pi.dev/packages/@oversk7/pi-web-dsh-style)
|
|
4
|
+
|
|
3
5
|
采用 DeepSeek Harness(DSH)视觉风格、面向 Windows 的非官方 Pi Coding Agent 本地浏览器界面扩展。后端使用 Pi 的公开 RPC 模式,每个会话对应独立的 RPC 进程;历史会话可直接从 Pi 会话文件恢复和渲染。
|
|
4
6
|
|
|
5
7
|
> 仅承诺 Windows 10/11。首次启动只监听 `127.0.0.1`;电脑网页的“设置 → 手机访问”可配置仅本机、局域网和服务器中转。之后 `pi --web` 使用已保存的模式。
|
|
@@ -143,6 +145,12 @@ npm login --registry=https://registry.npmjs.org
|
|
|
143
145
|
npm publish --access public --tag latest
|
|
144
146
|
```
|
|
145
147
|
|
|
148
|
+
## 反馈与相关扩展
|
|
149
|
+
|
|
150
|
+
欢迎通过 [GitHub Issues](https://github.com/oversk7/pi-web-dsh-style/issues) 反馈问题、提出建议,或分享使用场景。报告问题时,请附上扩展版本、Pi 版本、Windows 版本和复现步骤;涉及手机访问时,请说明使用的是局域网还是服务器中转。
|
|
151
|
+
|
|
152
|
+
同一作者的 [pi-pwsh-notify](https://github.com/oversk7/pi-pwsh-notify) 为 Pi 提供 Windows PowerShell 7 工具,以及后台任务完成自动通知功能。
|
|
153
|
+
|
|
146
154
|
## 许可证
|
|
147
155
|
|
|
148
156
|
原创代码使用 MIT License。Web UI 的部分样式和设计 token 改编自 DeepSeek Harness,字体资源来自 KaTeX,代码高亮使用 highlight.js。完整归属与许可证见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。
|
package/package.json
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oversk7/pi-web-dsh-style",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "An unofficial DSH-styled Windows browser UI for pi coding agent with configurable LAN and native frp relay access",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "oversk7",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/oversk7/pi-web-dsh-style.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/oversk7/pi-web-dsh-style#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/oversk7/pi-web-dsh-style/issues"
|
|
15
|
+
},
|
|
8
16
|
"keywords": [
|
|
9
17
|
"pi-package",
|
|
10
18
|
"pi-coding-agent",
|
package/server.ts
CHANGED
|
@@ -23,7 +23,7 @@ import { isLocalRequest, WebAccess } from "./network-access.ts";
|
|
|
23
23
|
import { FrpProcess } from "./frp-process.ts";
|
|
24
24
|
import { loadTerminalProxy, saveTerminalProxy, validateTerminalProxy, type TerminalProxySettings } from "./terminal-proxy.ts";
|
|
25
25
|
import { frpcConfig, loadNetworkSettings, proxyForNetwork, publicNetworkSettings, relayServerFiles, resolveFrpc, saveNetworkSettings, updateNetworkSettings, type NetworkMode, type NetworkSettings } from "./network-settings.ts";
|
|
26
|
-
import { closeSseClient, createSseClient, sendSseEvent, type SseClient } from "./sse-channel.ts";
|
|
26
|
+
import { closeSseClient, createSseClient, createSnapshotScheduler, sendSseEvent, type SseClient } from "./sse-channel.ts";
|
|
27
27
|
import { closeAllPiRpcs, spawnPiRpc, type Json, type PiRpc } from "./pi-rpc.ts";
|
|
28
28
|
import { loadLocalSession } from "./local-session.ts";
|
|
29
29
|
import { loadJsonWithLegacyMigration, writeJsonAtomically, writeTextAtomically } from "./state-store.ts";
|
|
@@ -666,7 +666,14 @@ function setPendingQueue(record: RuntimeSession, queue: RpcQueueSnapshot): void
|
|
|
666
666
|
};
|
|
667
667
|
}
|
|
668
668
|
|
|
669
|
+
const snapshotScheduler = createSnapshotScheduler<RuntimeSession>(emitSessionSnapshot);
|
|
670
|
+
|
|
669
671
|
function broadcastSessionSnapshot(record: RuntimeSession): void {
|
|
672
|
+
if (shuttingDown) return;
|
|
673
|
+
snapshotScheduler.schedule(record.id, record, !record.streaming || record.dialogs.length > 0);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function emitSessionSnapshot(record: RuntimeSession): void {
|
|
670
677
|
broadcastEvent({
|
|
671
678
|
type: "snapshot",
|
|
672
679
|
sessionId: record.id,
|
|
@@ -1361,8 +1368,7 @@ async function applyPendingSettings(record: RuntimeSession): Promise<void> {
|
|
|
1361
1368
|
await proc.send({ type: "set_model", provider: pending.provider, modelId: pending.modelId });
|
|
1362
1369
|
record.pendingModel = undefined;
|
|
1363
1370
|
} catch (error) {
|
|
1364
|
-
|
|
1365
|
-
record.pendingModel = undefined;
|
|
1371
|
+
throw new Error(`无法使用模型 ${pending.provider}/${pending.modelId},请在模型菜单中选择当前可用的模型:${error instanceof Error ? error.message : String(error)}`);
|
|
1366
1372
|
}
|
|
1367
1373
|
}
|
|
1368
1374
|
if (record.pendingThinkingLevel) {
|
|
@@ -1420,9 +1426,16 @@ async function ensureProcess(record: RuntimeSession): Promise<void> {
|
|
|
1420
1426
|
broadcastWorkspaces();
|
|
1421
1427
|
evictIdleProcesses();
|
|
1422
1428
|
} catch (error) {
|
|
1429
|
+
if (record.proc === proc) record.proc = undefined;
|
|
1430
|
+
try {
|
|
1431
|
+
await proc.close(2500);
|
|
1432
|
+
} catch (closeError) {
|
|
1433
|
+
console.error("[pi-web] closing failed session process:", closeError);
|
|
1434
|
+
}
|
|
1423
1435
|
record.status = "error";
|
|
1424
1436
|
record.error = error instanceof Error ? error.message : String(error);
|
|
1425
1437
|
broadcastSessionSnapshot(record);
|
|
1438
|
+
throw error;
|
|
1426
1439
|
} finally {
|
|
1427
1440
|
record.starting = undefined;
|
|
1428
1441
|
}
|
|
@@ -1563,6 +1576,7 @@ async function deleteWorkspaceSession(workspace: Workspace, sessionId: string):
|
|
|
1563
1576
|
candidate.updatedAt = updatedAt;
|
|
1564
1577
|
}
|
|
1565
1578
|
state.sessions = state.sessions.filter((session) => session.id !== sessionId);
|
|
1579
|
+
snapshotScheduler.cancel(sessionId);
|
|
1566
1580
|
sessions.delete(sessionId);
|
|
1567
1581
|
if (state.currentSessionId === sessionId) state.currentSessionId = undefined;
|
|
1568
1582
|
scheduleSave();
|
|
@@ -1576,14 +1590,23 @@ async function createSession(
|
|
|
1576
1590
|
): Promise<RuntimeSession> {
|
|
1577
1591
|
const ws = state.workspaces.find((w) => w.id === workspaceId);
|
|
1578
1592
|
if (!ws) throw new Error("workspace not found");
|
|
1593
|
+
const info = modelInfoFromCatalog(
|
|
1594
|
+
await discoverFreshModelCatalog(ws.path),
|
|
1595
|
+
preferences.model,
|
|
1596
|
+
preferences.thinkingLevel,
|
|
1597
|
+
);
|
|
1598
|
+
const selected = info.defaultModel as Json | undefined;
|
|
1599
|
+
const model = selected && typeof selected.provider === "string" && typeof selected.id === "string"
|
|
1600
|
+
? { provider: selected.provider, modelId: selected.id }
|
|
1601
|
+
: undefined;
|
|
1579
1602
|
const id = randomUUID();
|
|
1580
1603
|
const cleanTitle = (title ?? "").trim().replace(/\s+/g, " ").slice(0, 60) || "新会话";
|
|
1581
1604
|
const stored: StoredSession = {
|
|
1582
1605
|
id,
|
|
1583
1606
|
workspaceId,
|
|
1584
1607
|
title: cleanTitle,
|
|
1585
|
-
model
|
|
1586
|
-
thinkingLevel:
|
|
1608
|
+
model,
|
|
1609
|
+
thinkingLevel: info.thinkingLevel,
|
|
1587
1610
|
needsAttention: false,
|
|
1588
1611
|
createdAt: nowIso(),
|
|
1589
1612
|
updatedAt: nowIso(),
|
|
@@ -1608,29 +1631,11 @@ async function createSession(
|
|
|
1608
1631
|
streaming: false,
|
|
1609
1632
|
needsAttention: false,
|
|
1610
1633
|
lastUsed: Date.now(),
|
|
1611
|
-
pendingModel:
|
|
1612
|
-
pendingThinkingLevel:
|
|
1634
|
+
pendingModel: model,
|
|
1635
|
+
pendingThinkingLevel: info.thinkingLevel,
|
|
1613
1636
|
};
|
|
1614
|
-
|
|
1637
|
+
applyModelInfo(record, info);
|
|
1615
1638
|
applyCachedCommands(record);
|
|
1616
|
-
if (preferences.model) {
|
|
1617
|
-
const full = (record.models as Json[]).find(
|
|
1618
|
-
(model) => model?.provider === preferences.model?.provider && model.id === preferences.model?.modelId,
|
|
1619
|
-
);
|
|
1620
|
-
record.state = {
|
|
1621
|
-
...(record.state ?? {}),
|
|
1622
|
-
model: full ?? { provider: preferences.model.provider, id: preferences.model.modelId, name: preferences.model.modelId },
|
|
1623
|
-
};
|
|
1624
|
-
if (full) record.thinkingLevels = supportedThinkingLevels(full);
|
|
1625
|
-
}
|
|
1626
|
-
if (preferences.thinkingLevel) {
|
|
1627
|
-
const effectiveLevel = record.thinkingLevels.includes(preferences.thinkingLevel)
|
|
1628
|
-
? preferences.thinkingLevel
|
|
1629
|
-
: record.thinkingLevels.includes("off") ? "off" : record.thinkingLevels[0] ?? "off";
|
|
1630
|
-
record.pendingThinkingLevel = effectiveLevel;
|
|
1631
|
-
stored.thinkingLevel = effectiveLevel;
|
|
1632
|
-
record.state = { ...(record.state ?? {}), thinkingLevel: effectiveLevel };
|
|
1633
|
-
}
|
|
1634
1639
|
sessions.set(id, record);
|
|
1635
1640
|
scheduleSave();
|
|
1636
1641
|
broadcastWorkspaces();
|
|
@@ -2521,14 +2526,19 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
|
|
|
2521
2526
|
&& typeof requestedModel.modelId === "string"
|
|
2522
2527
|
? { provider: requestedModel.provider, modelId: requestedModel.modelId }
|
|
2523
2528
|
: undefined;
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2529
|
+
let record: RuntimeSession;
|
|
2530
|
+
try {
|
|
2531
|
+
record = await createSession(
|
|
2532
|
+
ws.id,
|
|
2533
|
+
typeof body.title === "string" ? body.title : undefined,
|
|
2534
|
+
{
|
|
2535
|
+
model,
|
|
2536
|
+
thinkingLevel: typeof body.thinkingLevel === "string" ? body.thinkingLevel : undefined,
|
|
2537
|
+
},
|
|
2538
|
+
);
|
|
2539
|
+
} catch (error) {
|
|
2540
|
+
return sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
2541
|
+
}
|
|
2532
2542
|
state.currentWorkspaceId = ws.id;
|
|
2533
2543
|
state.currentSessionId = record.id;
|
|
2534
2544
|
scheduleSave();
|
|
@@ -3261,6 +3271,7 @@ function shutdown(): Promise<void> {
|
|
|
3261
3271
|
}
|
|
3262
3272
|
|
|
3263
3273
|
async function shutdownServer(): Promise<void> {
|
|
3274
|
+
snapshotScheduler.clear();
|
|
3264
3275
|
await networkChange;
|
|
3265
3276
|
await stopRelay();
|
|
3266
3277
|
for (const client of [...sseClients]) {
|
package/sse-channel.ts
CHANGED
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
export type SseEvent = Record<string, unknown>;
|
|
2
2
|
|
|
3
|
+
// Coalesce before serializing full histories, so a burst of tokens does not
|
|
4
|
+
// become a queue of obsolete snapshots on a slower relay connection.
|
|
5
|
+
export function createSnapshotScheduler<T>(emit: (value: T) => void, intervalMs = 100) {
|
|
6
|
+
const pending = new Map<string, { timer: ReturnType<typeof setTimeout>; latest?: T }>();
|
|
7
|
+
function cancel(key: string): void {
|
|
8
|
+
const entry = pending.get(key);
|
|
9
|
+
if (entry) clearTimeout(entry.timer);
|
|
10
|
+
pending.delete(key);
|
|
11
|
+
}
|
|
12
|
+
return {
|
|
13
|
+
schedule(key: string, value: T, immediate = false): void {
|
|
14
|
+
if (immediate) {
|
|
15
|
+
cancel(key);
|
|
16
|
+
emit(value);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const current = pending.get(key);
|
|
20
|
+
if (current) {
|
|
21
|
+
current.latest = value;
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const entry: { timer: ReturnType<typeof setTimeout>; latest?: T } = {
|
|
25
|
+
timer: setTimeout(() => {
|
|
26
|
+
pending.delete(key);
|
|
27
|
+
if (entry.latest !== undefined) emit(entry.latest);
|
|
28
|
+
}, intervalMs),
|
|
29
|
+
};
|
|
30
|
+
pending.set(key, entry);
|
|
31
|
+
emit(value);
|
|
32
|
+
},
|
|
33
|
+
cancel,
|
|
34
|
+
clear(): void {
|
|
35
|
+
for (const key of pending.keys()) cancel(key);
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
3
40
|
export interface SseWritable {
|
|
4
41
|
write(payload: string): boolean;
|
|
5
42
|
once(event: "drain", listener: () => void): unknown;
|
package/web/app.js
CHANGED
|
@@ -17,6 +17,7 @@ const S = {
|
|
|
17
17
|
currentSessionId: null,
|
|
18
18
|
snapshots: new Map(),
|
|
19
19
|
newSessionSnapshot: null,
|
|
20
|
+
newSessionPreferenceWorkspaceId: null,
|
|
20
21
|
collapsed: new Set(),
|
|
21
22
|
expandedSessionLists: new Set(),
|
|
22
23
|
sidebarCollapsed: localStorage.getItem("pi-web-sidebar-collapsed") === "1",
|
|
@@ -31,6 +32,7 @@ const S = {
|
|
|
31
32
|
search: "",
|
|
32
33
|
searchOpen: false,
|
|
33
34
|
loadingSession: null,
|
|
35
|
+
pendingCreation: null,
|
|
34
36
|
pendingSends: new Map(),
|
|
35
37
|
withdrawingQueueItems: new Set(),
|
|
36
38
|
dequeueSessions: new Set(),
|
|
@@ -313,6 +315,7 @@ function currentSnapshot() {
|
|
|
313
315
|
}
|
|
314
316
|
|
|
315
317
|
function draftKey(sessionId = S.currentSessionId, workspaceId = S.currentWorkspaceId) {
|
|
318
|
+
if (!sessionId && S.pendingCreation?.generation === S.openGeneration && workspaceId === S.pendingCreation.workspaceId) return S.pendingCreation.draftKey;
|
|
316
319
|
return sessionId ? `session:${sessionId}` : `new:${workspaceId || "default"}`;
|
|
317
320
|
}
|
|
318
321
|
|
|
@@ -806,7 +809,7 @@ function renderHero() {
|
|
|
806
809
|
<span class="pXSMma_headlineText">和你的代码库对话</span>
|
|
807
810
|
<span class="pXSMma_previewBadge">Web 预览版</span>
|
|
808
811
|
</div>
|
|
809
|
-
<div class="pXSMma_body"></div>
|
|
812
|
+
<div class="pXSMma_body">${S.pendingCreation?.generation === S.openGeneration ? '<div class="gdEzaW_retryRow" role="status"><span class="gdEzaW_retryText">正在创建会话…</span></div>' : ""}</div>
|
|
810
813
|
</div></div>
|
|
811
814
|
<div class="wSkVaW_heroWorkspaceRow">
|
|
812
815
|
<div class="pXSMma_workspaceRow">${renderWorkspacePicker()}</div>
|
|
@@ -864,7 +867,7 @@ function renderComposerBar(hero = false) {
|
|
|
864
867
|
const level = state.thinkingLevel || null;
|
|
865
868
|
const streaming = Boolean(snap.session?.streaming);
|
|
866
869
|
const waitingForDialog = Boolean(snap.dialogs?.length);
|
|
867
|
-
const disabled = (!S.currentSessionId && !hero) || Boolean(S.loadingSession && !hero) || waitingForDialog;
|
|
870
|
+
const disabled = (!S.currentSessionId && !hero) || Boolean(S.loadingSession && !hero) || S.pendingCreation?.generation === S.openGeneration || waitingForDialog;
|
|
868
871
|
const inputPlaceholder = waitingForDialog ? "请先回答对话中的问题" : "给智能体发消息";
|
|
869
872
|
const rawContextPct = snap.stats?.contextUsage?.percent;
|
|
870
873
|
const hasContextPct = typeof rawContextPct === "number" && Number.isFinite(rawContextPct);
|
|
@@ -2987,6 +2990,14 @@ async function loadBootstrap() {
|
|
|
2987
2990
|
messages: [],
|
|
2988
2991
|
dialogs: [],
|
|
2989
2992
|
};
|
|
2993
|
+
if (!S.currentSessionId && S.currentWorkspaceId) {
|
|
2994
|
+
try {
|
|
2995
|
+
const defaults = await post(`/api/workspaces/${encodeURIComponent(S.currentWorkspaceId)}/models`, {});
|
|
2996
|
+
S.newSessionSnapshot = { ...S.newSessionSnapshot, ...defaults };
|
|
2997
|
+
} catch (error) {
|
|
2998
|
+
console.error("[pi-web] loading new session defaults failed:", error);
|
|
2999
|
+
}
|
|
3000
|
+
}
|
|
2990
3001
|
renderApp();
|
|
2991
3002
|
if (S.currentSessionId) await openSession(S.currentSessionId, { silent: true });
|
|
2992
3003
|
}
|
|
@@ -2998,8 +3009,8 @@ async function openSession(id, opts = {}) {
|
|
|
2998
3009
|
++S.historyGeneration;
|
|
2999
3010
|
S.conversationTab = "conversation";
|
|
3000
3011
|
S.historyView = null;
|
|
3001
|
-
const generation = ++S.openGeneration;
|
|
3002
3012
|
if (!opts.skipRemember) rememberCurrentDraft();
|
|
3013
|
+
const generation = ++S.openGeneration;
|
|
3003
3014
|
S.currentSessionId = id;
|
|
3004
3015
|
S.loadingSession = id;
|
|
3005
3016
|
if (!opts.silent) {
|
|
@@ -3017,7 +3028,7 @@ async function openSession(id, opts = {}) {
|
|
|
3017
3028
|
renderSidebar();
|
|
3018
3029
|
renderConversation();
|
|
3019
3030
|
try {
|
|
3020
|
-
const data = await post(`/api/sessions/${encodeURIComponent(id)}/open`);
|
|
3031
|
+
const data = opts.initialSnapshot ?? await post(`/api/sessions/${encodeURIComponent(id)}/open`);
|
|
3021
3032
|
S.snapshots.set(id, data);
|
|
3022
3033
|
if (generation !== S.openGeneration) return;
|
|
3023
3034
|
const ws = data.session?.workspaceId;
|
|
@@ -3033,28 +3044,74 @@ async function openSession(id, opts = {}) {
|
|
|
3033
3044
|
}
|
|
3034
3045
|
|
|
3035
3046
|
async function createAndOpenSession(workspaceId, title, options = {}) {
|
|
3047
|
+
if (S.pendingCreation) return null;
|
|
3048
|
+
const source = S.newSessionPreferenceWorkspaceId === workspaceId && !S.currentSessionId
|
|
3049
|
+
? S.newSessionSnapshot : null;
|
|
3050
|
+
const previous = {
|
|
3051
|
+
sessionId: S.currentSessionId, workspaceId: S.currentWorkspaceId,
|
|
3052
|
+
conversationTab: S.conversationTab, historyView: S.historyView,
|
|
3053
|
+
draft: S.draft, attachments: [...S.draftAttachments],
|
|
3054
|
+
};
|
|
3055
|
+
rememberCurrentDraft();
|
|
3056
|
+
const generation = ++S.openGeneration;
|
|
3057
|
+
const pending = { generation, workspaceId, draftKey: `creating:${generation}` };
|
|
3058
|
+
S.pendingCreation = pending;
|
|
3059
|
+
closeMobileSidebar();
|
|
3060
|
+
closeCommandMenu();
|
|
3061
|
+
++S.historyGeneration;
|
|
3062
|
+
S.conversationTab = "conversation";
|
|
3063
|
+
S.historyView = null;
|
|
3064
|
+
S.currentSessionId = null;
|
|
3065
|
+
S.currentWorkspaceId = workspaceId;
|
|
3066
|
+
S.loadingSession = null;
|
|
3067
|
+
sessionStorage.removeItem("pi-web-current-session");
|
|
3068
|
+
if (!options.keepDraft) {
|
|
3069
|
+
S.draft = "";
|
|
3070
|
+
S.draftAttachments = [];
|
|
3071
|
+
}
|
|
3072
|
+
renderSidebar();
|
|
3073
|
+
renderConversation();
|
|
3036
3074
|
try {
|
|
3037
|
-
const
|
|
3038
|
-
const model = source.state?.model;
|
|
3075
|
+
const model = source?.state?.model;
|
|
3039
3076
|
const data = await post(`/api/workspaces/${encodeURIComponent(workspaceId)}/sessions`, {
|
|
3040
3077
|
title,
|
|
3041
3078
|
model: model?.provider && model?.id ? { provider: model.provider, modelId: model.id } : undefined,
|
|
3042
|
-
thinkingLevel: source
|
|
3079
|
+
thinkingLevel: source?.state?.thinkingLevel || undefined,
|
|
3043
3080
|
});
|
|
3044
3081
|
const id = data.session?.id;
|
|
3045
3082
|
if (!id) throw new Error("create session failed");
|
|
3046
3083
|
S.snapshots.set(id, data);
|
|
3047
|
-
if (
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3084
|
+
if (generation !== S.openGeneration) {
|
|
3085
|
+
// Navigation owns the composer now; retain any pending draft with the created session.
|
|
3086
|
+
const draft = S.drafts.get(pending.draftKey);
|
|
3087
|
+
const attachments = S.attachmentDrafts.get(pending.draftKey);
|
|
3088
|
+
if (draft) S.drafts.set(draftKey(id), draft);
|
|
3089
|
+
if (attachments) S.attachmentDrafts.set(draftKey(id), attachments);
|
|
3090
|
+
return null;
|
|
3051
3091
|
}
|
|
3052
|
-
S.
|
|
3053
|
-
await openSession(id, { silent: true, skipRemember:
|
|
3054
|
-
return id;
|
|
3092
|
+
S.newSessionPreferenceWorkspaceId = null;
|
|
3093
|
+
await openSession(id, { silent: true, skipRemember: true, initialSnapshot: data });
|
|
3094
|
+
return generation + 1 === S.openGeneration && S.currentSessionId === id ? id : null;
|
|
3055
3095
|
} catch (error) {
|
|
3056
|
-
|
|
3096
|
+
if (generation === S.openGeneration) {
|
|
3097
|
+
S.pendingCreation = null;
|
|
3098
|
+
S.currentSessionId = previous.sessionId;
|
|
3099
|
+
S.currentWorkspaceId = previous.workspaceId;
|
|
3100
|
+
S.loadingSession = null;
|
|
3101
|
+
S.conversationTab = previous.conversationTab;
|
|
3102
|
+
S.historyView = previous.historyView;
|
|
3103
|
+
S.draftAttachments = previous.attachments;
|
|
3104
|
+
setDraftValue(previous.draft);
|
|
3105
|
+
if (previous.sessionId) sessionStorage.setItem("pi-web-current-session", previous.sessionId);
|
|
3106
|
+
renderSidebar();
|
|
3107
|
+
renderConversation();
|
|
3108
|
+
alert(`新建会话失败:${error.message}`);
|
|
3109
|
+
}
|
|
3057
3110
|
return null;
|
|
3111
|
+
} finally {
|
|
3112
|
+
if (S.pendingCreation === pending) S.pendingCreation = null;
|
|
3113
|
+
S.drafts.delete(pending.draftKey);
|
|
3114
|
+
S.attachmentDrafts.delete(pending.draftKey);
|
|
3058
3115
|
}
|
|
3059
3116
|
}
|
|
3060
3117
|
|
|
@@ -3875,7 +3932,10 @@ async function selectModelFromCommand(reference) {
|
|
|
3875
3932
|
if (sessionId) await post(`/api/sessions/${encodeURIComponent(sessionId)}/set_model`, { provider: model.provider, modelId: model.id });
|
|
3876
3933
|
const next = { ...snapshotFor(sessionId), state: { ...(snapshotFor(sessionId).state || {}), model, thinkingLevel }, thinkingLevels };
|
|
3877
3934
|
if (sessionId) S.snapshots.set(sessionId, next);
|
|
3878
|
-
else if (!S.currentSessionId && S.currentWorkspaceId === workspaceId)
|
|
3935
|
+
else if (!S.currentSessionId && S.currentWorkspaceId === workspaceId) {
|
|
3936
|
+
S.newSessionSnapshot = next;
|
|
3937
|
+
S.newSessionPreferenceWorkspaceId = workspaceId;
|
|
3938
|
+
}
|
|
3879
3939
|
queueRender(true);
|
|
3880
3940
|
showToast(`模型已切换为 ${model.provider}/${model.id}`);
|
|
3881
3941
|
}
|
|
@@ -3894,7 +3954,10 @@ async function selectThinkingFromCommand(value) {
|
|
|
3894
3954
|
if (sessionId) await post(`/api/sessions/${encodeURIComponent(sessionId)}/set_thinking_level`, { level });
|
|
3895
3955
|
const next = { ...snapshotFor(sessionId), state: { ...(snapshotFor(sessionId).state || {}), thinkingLevel: level } };
|
|
3896
3956
|
if (sessionId) S.snapshots.set(sessionId, next);
|
|
3897
|
-
else
|
|
3957
|
+
else {
|
|
3958
|
+
S.newSessionSnapshot = next;
|
|
3959
|
+
S.newSessionPreferenceWorkspaceId = S.currentWorkspaceId;
|
|
3960
|
+
}
|
|
3898
3961
|
queueRender(true);
|
|
3899
3962
|
showToast(`思考强度已切换为 ${level}`);
|
|
3900
3963
|
}
|
|
@@ -4003,6 +4066,7 @@ async function executeLocalSlashCommand(text) {
|
|
|
4003
4066
|
}
|
|
4004
4067
|
|
|
4005
4068
|
async function sendDraft() {
|
|
4069
|
+
if (S.pendingCreation) return;
|
|
4006
4070
|
if (S.clipboardBusy) return showToast("正在上传并解析文件,请稍候", "warning");
|
|
4007
4071
|
const text = S.draft.trim();
|
|
4008
4072
|
const attachments = [...S.draftAttachments];
|
|
@@ -4027,6 +4091,7 @@ async function sendDraft() {
|
|
|
4027
4091
|
showToast(error.message || String(error), "error");
|
|
4028
4092
|
return;
|
|
4029
4093
|
}
|
|
4094
|
+
if (S.pendingCreation) return;
|
|
4030
4095
|
let sessionId = S.currentSessionId;
|
|
4031
4096
|
if (!sessionId) {
|
|
4032
4097
|
const ws = currentWorkspace();
|
|
@@ -4385,11 +4450,17 @@ async function onClick(ev) {
|
|
|
4385
4450
|
++S.openGeneration;
|
|
4386
4451
|
S.currentSessionId = null;
|
|
4387
4452
|
S.loadingSession = null;
|
|
4453
|
+
S.newSessionPreferenceWorkspaceId = null;
|
|
4388
4454
|
loadCurrentDraftValue();
|
|
4455
|
+
try {
|
|
4456
|
+
await refreshModelCatalog(null, S.currentWorkspaceId, { state: null });
|
|
4457
|
+
} catch (error) {
|
|
4458
|
+
console.error("[pi-web] loading workspace models failed:", error);
|
|
4459
|
+
}
|
|
4389
4460
|
queueRender(true);
|
|
4390
4461
|
}
|
|
4391
4462
|
} else if (action === "brand") {
|
|
4392
|
-
if (S.currentSessionId) {
|
|
4463
|
+
if (S.currentSessionId || S.pendingCreation?.generation === S.openGeneration) {
|
|
4393
4464
|
rememberCurrentDraft();
|
|
4394
4465
|
++S.openGeneration;
|
|
4395
4466
|
S.currentSessionId = null;
|
|
@@ -4528,6 +4599,7 @@ async function onClick(ev) {
|
|
|
4528
4599
|
state: { ...(snap.state || {}), model: selected, thinkingLevel },
|
|
4529
4600
|
thinkingLevels,
|
|
4530
4601
|
};
|
|
4602
|
+
S.newSessionPreferenceWorkspaceId = S.currentWorkspaceId;
|
|
4531
4603
|
}
|
|
4532
4604
|
closePopovers();
|
|
4533
4605
|
queueRender(true);
|
|
@@ -4546,6 +4618,7 @@ async function onClick(ev) {
|
|
|
4546
4618
|
...S.newSessionSnapshot,
|
|
4547
4619
|
state: { ...(S.newSessionSnapshot.state || {}), thinkingLevel: target.dataset.level },
|
|
4548
4620
|
};
|
|
4621
|
+
S.newSessionPreferenceWorkspaceId = S.currentWorkspaceId;
|
|
4549
4622
|
}
|
|
4550
4623
|
closePopovers();
|
|
4551
4624
|
queueRender(true);
|
|
@@ -4964,12 +5037,14 @@ function requireLogin() {
|
|
|
4964
5037
|
}
|
|
4965
5038
|
|
|
4966
5039
|
let eventSource;
|
|
5040
|
+
let eventWatchdog;
|
|
5041
|
+
let lastEventAt = 0;
|
|
4967
5042
|
let resyncPending;
|
|
4968
5043
|
|
|
4969
5044
|
async function resyncAfterReconnect() {
|
|
4970
5045
|
if (resyncPending) return resyncPending;
|
|
4971
5046
|
resyncPending = (async () => {
|
|
4972
|
-
const boot = await api("/api/bootstrap");
|
|
5047
|
+
const boot = await api("/api/bootstrap", { signal: AbortSignal.timeout(15_000) });
|
|
4973
5048
|
S.workspaces = applySavedWorkspaceOrder(boot.workspaces || []);
|
|
4974
5049
|
S.serverInstanceId = boot.instanceId || null;
|
|
4975
5050
|
S.lanEnabled = boot.lanEnabled === true;
|
|
@@ -4981,7 +5056,9 @@ async function resyncAfterReconnect() {
|
|
|
4981
5056
|
const generation = S.openGeneration;
|
|
4982
5057
|
if (id && S.workspaces.some((workspace) => workspace.sessions?.some((session) => session.id === id))) {
|
|
4983
5058
|
const before = S.snapshots.get(id);
|
|
4984
|
-
const data = await
|
|
5059
|
+
const data = await api(`/api/sessions/${encodeURIComponent(id)}/open`, {
|
|
5060
|
+
method: "POST", body: "{}", signal: AbortSignal.timeout(15_000),
|
|
5061
|
+
});
|
|
4985
5062
|
if (S.snapshots.get(id) === before) S.snapshots.set(id, data);
|
|
4986
5063
|
} else if (id && generation === S.openGeneration) {
|
|
4987
5064
|
rememberCurrentDraft();
|
|
@@ -5001,8 +5078,21 @@ function connectEvents() {
|
|
|
5001
5078
|
eventSource?.close();
|
|
5002
5079
|
const es = new EventSource("/api/events");
|
|
5003
5080
|
eventSource = es;
|
|
5004
|
-
|
|
5081
|
+
lastEventAt = Date.now();
|
|
5082
|
+
clearInterval(eventWatchdog);
|
|
5083
|
+
// Heartbeats arrive every 25s, including when the model is idle. Some mobile
|
|
5084
|
+
// networks leave a dead stream open without notifying EventSource.
|
|
5085
|
+
eventWatchdog = setInterval(() => {
|
|
5086
|
+
if (document.hidden || loginPromise || eventSource !== es) return;
|
|
5087
|
+
if (Date.now() - lastEventAt > 60_000) connectEvents();
|
|
5088
|
+
}, 5_000);
|
|
5089
|
+
es.onopen = () => {
|
|
5090
|
+
lastEventAt = Date.now();
|
|
5091
|
+
void resyncAfterReconnect();
|
|
5092
|
+
};
|
|
5005
5093
|
es.onmessage = (ev) => {
|
|
5094
|
+
if (eventSource !== es) return;
|
|
5095
|
+
lastEventAt = Date.now();
|
|
5006
5096
|
try {
|
|
5007
5097
|
const event = JSON.parse(ev.data);
|
|
5008
5098
|
if (event.type === "heartbeat") return;
|