@oversk7/pi-web-dsh-style 0.1.0-alpha.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.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +90 -0
  3. package/THIRD_PARTY_NOTICES.md +90 -0
  4. package/abort-session.ts +150 -0
  5. package/attachment-store.ts +161 -0
  6. package/file-preview.ts +94 -0
  7. package/index.ts +177 -0
  8. package/local-session.ts +190 -0
  9. package/package.json +70 -0
  10. package/pi-rpc.ts +253 -0
  11. package/server.ts +3060 -0
  12. package/sse-channel.ts +72 -0
  13. package/state-store.ts +64 -0
  14. package/transcript.ts +328 -0
  15. package/web/app.css +535 -0
  16. package/web/app.js +4228 -0
  17. package/web/assets/fonts/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  18. package/web/assets/fonts/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  19. package/web/assets/fonts/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  20. package/web/assets/fonts/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  21. package/web/assets/fonts/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  22. package/web/assets/fonts/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  23. package/web/assets/fonts/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  24. package/web/assets/fonts/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  25. package/web/assets/fonts/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  26. package/web/assets/fonts/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  27. package/web/assets/fonts/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  28. package/web/assets/fonts/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  29. package/web/assets/fonts/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  30. package/web/assets/fonts/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  31. package/web/assets/fonts/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  32. package/web/assets/fonts/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  33. package/web/assets/fonts/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  34. package/web/assets/fonts/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  35. package/web/assets/fonts/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  36. package/web/assets/fonts/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  37. package/web/assets/fonts/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  38. package/web/assets/fonts/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  39. package/web/assets/fonts/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  40. package/web/assets/fonts/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  41. package/web/assets/fonts/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  42. package/web/assets/fonts/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  43. package/web/assets/fonts/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  44. package/web/assets/fonts/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  45. package/web/assets/fonts/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  46. package/web/assets/fonts/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  47. package/web/assets/fonts/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  48. package/web/assets/fonts/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  49. package/web/assets/fonts/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  50. package/web/assets/fonts/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  51. package/web/assets/fonts/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  52. package/web/assets/fonts/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  53. package/web/assets/fonts/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  54. package/web/assets/fonts/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  55. package/web/assets/fonts/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  56. package/web/assets/fonts/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  57. package/web/assets/fonts/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  58. package/web/assets/fonts/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  59. package/web/assets/fonts/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  60. package/web/assets/fonts/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  61. package/web/assets/fonts/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  62. package/web/assets/fonts/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  63. package/web/assets/fonts/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  64. package/web/assets/fonts/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  65. package/web/assets/fonts/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  66. package/web/assets/fonts/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  67. package/web/assets/fonts/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  68. package/web/assets/fonts/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  69. package/web/assets/fonts/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  70. package/web/assets/fonts/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  71. package/web/assets/fonts/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  72. package/web/assets/fonts/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  73. package/web/assets/fonts/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  74. package/web/assets/fonts/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  75. package/web/assets/fonts/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  76. package/web/favicon.svg +4 -0
  77. package/web/index.html +25 -0
  78. package/web/manifest.webmanifest +9 -0
  79. package/windows-clipboard.ts +81 -0
package/web/app.js ADDED
@@ -0,0 +1,4228 @@
1
+ "use strict";
2
+
3
+ const SESSION_PREVIEW_LIMIT = 5;
4
+ const MODEL_REFRESH_TTL_MS = 60_000;
5
+ const WORKSPACE_ORDER_STORAGE_KEY = "pi-web-workspace-order";
6
+
7
+ const S = {
8
+ workspaces: [],
9
+ workspaceDragId: null,
10
+ workspaceDragOverId: null,
11
+ workspaceDragPosition: null,
12
+ workspaceDragPending: false,
13
+ currentWorkspaceId: null,
14
+ currentSessionId: null,
15
+ snapshots: new Map(),
16
+ newSessionSnapshot: null,
17
+ collapsed: new Set(),
18
+ expandedSessionLists: new Set(),
19
+ sidebarCollapsed: localStorage.getItem("pi-web-sidebar-collapsed") === "1",
20
+ mobileSidebarOpen: false,
21
+ sidebarWidth: Number(localStorage.getItem("pi-web-sidebar-width")) || 280,
22
+ filePreview: null,
23
+ filePreviewWidth: Number(localStorage.getItem("pi-web-file-preview-width")) || 520,
24
+ filePreviewGeneration: 0,
25
+ expandedTools: new Set(),
26
+ expandedCompactions: new Set(),
27
+ search: "",
28
+ searchOpen: false,
29
+ loadingSession: null,
30
+ pendingSends: new Map(),
31
+ withdrawingQueueItems: new Set(),
32
+ dequeueSessions: new Set(),
33
+ drafts: new Map(),
34
+ failedDrafts: new Map(),
35
+ attachmentDrafts: new Map(),
36
+ failedAttachmentDrafts: new Map(),
37
+ openGeneration: 0,
38
+ draft: "",
39
+ draftAttachments: [],
40
+ commandMenuOpen: false,
41
+ commandLoading: false,
42
+ commandSelected: 0,
43
+ commandGeneration: 0,
44
+ modelRefreshes: new Map(),
45
+ modelRefreshedAt: new Map(),
46
+ conversationTab: "conversation",
47
+ historyView: null,
48
+ historyGeneration: 0,
49
+ clipboardBusy: false,
50
+ settingsOpen: false,
51
+ maintenanceBusy: null,
52
+ maintenanceMessage: null,
53
+ maintenanceGeneration: 0,
54
+ serverInstanceId: null,
55
+ theme: localStorage.getItem("pi-web-theme") || "system",
56
+ };
57
+
58
+ const $ = (sel, root = document) => root.querySelector(sel);
59
+ const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
60
+
61
+ function esc(s) {
62
+ return String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
63
+ }
64
+
65
+ function el(html) {
66
+ const t = document.createElement("template");
67
+ t.innerHTML = html.trim();
68
+ return t.content.firstElementChild;
69
+ }
70
+
71
+ async function api(path, options = {}) {
72
+ const res = await fetch(path, {
73
+ headers: { "content-type": "application/json", ...(options.headers || {}) },
74
+ ...options,
75
+ });
76
+ const data = await res.json().catch(() => ({}));
77
+ if (!res.ok) {
78
+ const error = new Error(data.error || `${res.status} ${res.statusText}`);
79
+ error.status = res.status;
80
+ error.data = data;
81
+ throw error;
82
+ }
83
+ return data;
84
+ }
85
+
86
+ function post(path, body) {
87
+ return api(path, { method: "POST", body: JSON.stringify(body || {}) });
88
+ }
89
+
90
+ function timeLabel(iso) {
91
+ if (!iso) return "";
92
+ const date = new Date(iso);
93
+ if (Number.isNaN(date.getTime())) return "";
94
+ const diff = Date.now() - date.getTime();
95
+ if (diff < 60_000) return "刚刚";
96
+ if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}分钟`;
97
+ if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}小时`;
98
+ if (diff < 7 * 86_400_000) return `${Math.floor(diff / 86_400_000)}天`;
99
+ return date.toLocaleDateString("zh-CN", { month: "short", day: "numeric" });
100
+ }
101
+
102
+ function sessionsByRecency(workspace) {
103
+ return [...(workspace?.sessions || [])].sort((a, b) =>
104
+ (Date.parse(b.updatedAt) || 0) - (Date.parse(a.updatedAt) || 0)
105
+ );
106
+ }
107
+
108
+ function clockLabel(timestamp) {
109
+ if (!timestamp) return "";
110
+ const date = new Date(timestamp);
111
+ if (Number.isNaN(date.getTime())) return "";
112
+ return date.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false });
113
+ }
114
+
115
+ function shortPath(path, max = 34) {
116
+ if (!path) return "";
117
+ return path.length > max ? `…${path.slice(path.length - max)}` : path;
118
+ }
119
+
120
+ function setTheme(theme) {
121
+ S.theme = theme;
122
+ localStorage.setItem("pi-web-theme", theme);
123
+ const dark = theme === "dark" || (theme === "system" && matchMedia("(prefers-color-scheme: dark)").matches);
124
+ document.documentElement.style.colorScheme = dark ? "dark" : "light";
125
+ document.body.toggleAttribute("data-ds-dark-theme", dark);
126
+ }
127
+
128
+ function toggleTheme() {
129
+ setTheme(document.body.hasAttribute("data-ds-dark-theme") ? "light" : "dark");
130
+ }
131
+
132
+ const ICONS = {
133
+ logo: `<svg width="30" height="30" viewBox="0 0 50 50" fill="none"><rect width="50" height="50" rx="12" fill="var(--dsw-alias-brand-primary, #3964fe)"/><text x="25" y="34" text-anchor="middle" font-family="Georgia, 'Times New Roman', serif" font-size="30" font-weight="700" fill="#fff">π</text></svg>`,
134
+ wordmark: `<span class="pi-brandLogo"><svg width="30" height="30" viewBox="0 0 50 50" fill="none" aria-hidden="true"><rect width="50" height="50" rx="12" fill="var(--dsw-alias-brand-primary, #3964fe)"/><text x="25" y="34" text-anchor="middle" font-family="Georgia, 'Times New Roman', serif" font-size="30" font-weight="700" fill="#fff">π</text></svg><span class="pi-brandName">Pi</span></span>`,
135
+ panel: `<svg class="hHd-Xa_panelIcon" width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M2 3.5A1.5 1.5 0 0 1 3.5 2h9A1.5 1.5 0 0 1 14 3.5v9a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 2 12.5v-9Z" stroke="currentColor" stroke-width="1.4"/><path d="M6 2v12" stroke="currentColor" stroke-width="1.4"/></svg>`,
136
+ plus: `<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M8 1.5V14.5M1.5 8H14.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>`,
137
+ search: `<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><circle cx="7" cy="7" r="5.4" stroke="currentColor" stroke-width="1.4"/><path d="M11 11L15 15" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>`,
138
+ folder: `<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M2.5 3.5C2.5 2.95 2.95 2.5 3.5 2.5H6L7.5 4.5H12.5C13.05 4.5 13.5 4.95 13.5 5.5V12.5C13.5 13.05 13.05 13.5 12.5 13.5H3.5C2.95 13.5 2.5 13.05 2.5 12.5V3.5Z" stroke="currentColor" stroke-width="1.2"/></svg>`,
139
+ grip: `<svg width="12" height="16" viewBox="0 0 12 16" fill="none" aria-hidden="true"><circle cx="3" cy="4" r="1" fill="currentColor"/><circle cx="9" cy="4" r="1" fill="currentColor"/><circle cx="3" cy="8" r="1" fill="currentColor"/><circle cx="9" cy="8" r="1" fill="currentColor"/><circle cx="3" cy="12" r="1" fill="currentColor"/><circle cx="9" cy="12" r="1" fill="currentColor"/></svg>`,
140
+ chevron: `<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M4.25 2.83V11.17C4.25 11.66 4.84 11.91 5.19 11.56L9.36 7.39C9.58 7.17 9.58 6.83 9.36 6.61L5.19 2.44C4.84 2.09 4.25 2.34 4.25 2.83Z" fill="currentColor"/></svg>`,
141
+ gear: `<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="2.2" stroke="currentColor" stroke-width="1.4"/><path d="M8 1.5V3M8 13V14.5M14.5 8H13M3 8H1.5M12.6 3.4L11.5 4.5M4.5 11.5L3.4 12.6M12.6 12.6L11.5 11.5M4.5 4.5L3.4 3.4" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>`,
142
+ send: `<svg viewBox="0 0 16 16" width="16" height="16" fill="none"><path d="M1.5 14.5L14.5 8L1.5 1.5L4.5 8L1.5 14.5Z" fill="currentColor"/></svg>`,
143
+ stop: `<svg viewBox="0 0 16 16" width="16" height="16" fill="none"><rect x="3" y="3" width="10" height="10" rx="1.5" fill="currentColor"/></svg>`,
144
+ down: `<svg viewBox="0 0 16 16" width="16" height="16" fill="none"><path d="M3 6L8 11L13 6M3 13.5H13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
145
+ copy: `<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><rect x="5" y="5" width="9" height="9" rx="1.5" stroke="currentColor" stroke-width="1.3"/><path d="M11 5V3.5C11 2.67 10.33 2 9.5 2H3.5C2.67 2 2 2.67 2 3.5V9.5C2 10.33 2.67 11 3.5 11H5" stroke="currentColor" stroke-width="1.3"/></svg>`,
146
+ dots: `<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><circle cx="3.5" cy="8" r="1.2" fill="currentColor"/><circle cx="8" cy="8" r="1.2" fill="currentColor"/><circle cx="12.5" cy="8" r="1.2" fill="currentColor"/></svg>`,
147
+ bash: `<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="1" y="1.5" width="12" height="11" rx="2.5" stroke="currentColor" stroke-width="1.3"/><path d="M4 5.5L6 7L4 8.5M7 9H10" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
148
+ think: `<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="6" stroke="currentColor" stroke-width="1.3"/><path d="M8 5.5V8.5L10.5 10" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>`,
149
+ compact: `<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M3 2.5V6H6.5M13 2.5V6H9.5M3 13.5V10H6.5M13 13.5V10H9.5" stroke="currentColor" stroke-width="1.35" stroke-linecap="round" stroke-linejoin="round"/><path d="M3.4 5.6L6.3 2.7M12.6 5.6L9.7 2.7M3.4 10.4L6.3 13.3M12.6 10.4L9.7 13.3" stroke="currentColor" stroke-width="1.15" stroke-linecap="round"/></svg>`,
150
+ check: `<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M2.5 8.5L6 12L13.5 4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>`,
151
+ edit: `<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M11.5 2.5L13.5 4.5L6 12L3 13L4 10L11.5 2.5Z" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/></svg>`,
152
+ image: `<svg width="15" height="15" viewBox="0 0 16 16" fill="none"><rect x="2" y="2.5" width="12" height="11" rx="2" stroke="currentColor" stroke-width="1.3"/><circle cx="5.25" cy="5.75" r="1.15" fill="currentColor"/><path d="M3.5 11L6.4 8.2L8.4 10L10.2 8.4L12.5 11" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
153
+ close: `<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M4 4L12 12M12 4L4 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>`,
154
+ history: `<svg width="15" height="15" viewBox="0 0 16 16" fill="none"><path d="M3 4.5h6.5a3.5 3.5 0 1 1 0 7H7" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><path d="M5 2.5L3 4.5l2 2M7 9.5l-2 2 2 2" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
155
+ refresh: `<svg width="15" height="15" viewBox="0 0 16 16" fill="none"><path d="M13.2 6.2A5.5 5.5 0 1 0 13 10.4" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/><path d="M10.4 3.2h3.2v3.2" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
156
+ folderOpen: `<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M2.5 3.5C2.5 2.95 2.95 2.5 3.5 2.5H6L7.5 4.5H12.5C13.05 4.5 13.5 4.95 13.5 5.5V6H3V5.5C3 4.67 3.67 4 4.5 4H5.2L3.5 2.5Z" stroke="currentColor" stroke-width="1.2"/><path d="M3 6H13.5C14.05 6 14.5 6.45 14.5 7V12.5C14.5 13.05 14.05 13.5 13.5 13.5H2.5C1.95 13.5 1.5 13.05 1.5 12.5V6.5C1.5 6.22 1.72 6 2 6H3Z" stroke="currentColor" stroke-width="1.2"/></svg>`,
157
+ };
158
+
159
+ function snapshotFor(id) {
160
+ if (!id && S.newSessionSnapshot) return S.newSessionSnapshot;
161
+ return S.snapshots.get(id) || { session: null, state: null, stats: null, models: [], thinkingLevels: [], thinkingLevelsByModel: {}, commands: [], extensionStatuses: {}, messages: [], pendingQueue: { steering: [], followUp: [] }, dialogs: [] };
162
+ }
163
+
164
+ function currentWorkspace() {
165
+ return S.workspaces.find((w) => w.id === S.currentWorkspaceId) || S.workspaces[0] || null;
166
+ }
167
+
168
+ function savedWorkspaceOrder() {
169
+ try {
170
+ const value = JSON.parse(localStorage.getItem(WORKSPACE_ORDER_STORAGE_KEY) || "[]");
171
+ return Array.isArray(value) ? value.filter((id) => typeof id === "string") : [];
172
+ } catch {
173
+ return [];
174
+ }
175
+ }
176
+
177
+ function applySavedWorkspaceOrder(workspaces) {
178
+ const incoming = Array.isArray(workspaces) ? workspaces : [];
179
+ const byId = new Map(incoming.map((workspace) => [workspace.id, workspace]));
180
+ const seen = new Set();
181
+ const ordered = [];
182
+ for (const id of savedWorkspaceOrder()) {
183
+ const workspace = byId.get(id);
184
+ if (!workspace || seen.has(id)) continue;
185
+ seen.add(id);
186
+ ordered.push(workspace);
187
+ }
188
+ for (const workspace of incoming) {
189
+ if (seen.has(workspace.id)) continue;
190
+ seen.add(workspace.id);
191
+ ordered.push(workspace);
192
+ }
193
+ return ordered;
194
+ }
195
+
196
+ function saveWorkspaceOrder(workspaces) {
197
+ localStorage.setItem(WORKSPACE_ORDER_STORAGE_KEY, JSON.stringify(workspaces.map((workspace) => workspace.id)));
198
+ }
199
+
200
+ function currentSnapshot() {
201
+ if (!S.currentSessionId) return snapshotFor(null);
202
+ return snapshotFor(S.currentSessionId);
203
+ }
204
+
205
+ function draftKey(sessionId = S.currentSessionId, workspaceId = S.currentWorkspaceId) {
206
+ return sessionId ? `session:${sessionId}` : `new:${workspaceId || "default"}`;
207
+ }
208
+
209
+ function rememberCurrentDraft() {
210
+ const key = draftKey();
211
+ if (S.draft) S.drafts.set(key, S.draft);
212
+ else S.drafts.delete(key);
213
+ if (S.draftAttachments.length) S.attachmentDrafts.set(key, [...S.draftAttachments]);
214
+ else S.attachmentDrafts.delete(key);
215
+ }
216
+
217
+ function setDraftValue(value) {
218
+ S.draft = String(value ?? "");
219
+ rememberCurrentDraft();
220
+ }
221
+
222
+ function setDraftAttachments(attachments) {
223
+ S.draftAttachments = Array.isArray(attachments) ? [...attachments] : [];
224
+ rememberCurrentDraft();
225
+ }
226
+
227
+ function loadCurrentDraftValue() {
228
+ const key = draftKey();
229
+ S.draftAttachments = [...(S.attachmentDrafts.get(key) || [])];
230
+ setDraftValue(S.drafts.get(key) || "");
231
+ }
232
+
233
+ function modelSupportsImages(model = currentSnapshot().state?.model) {
234
+ return Array.isArray(model?.input) && model.input.includes("image");
235
+ }
236
+
237
+ function modelLabel(model) {
238
+ const label = model?.name || model?.id;
239
+ if (!label) return "选择模型";
240
+ return model.provider ? `${model.provider} / ${label}` : label;
241
+ }
242
+
243
+ function thinkingLabel(level) {
244
+ if (!level) return "";
245
+ return { off: "Off", minimal: "Min", low: "Low", medium: "Med", high: "High", xhigh: "X-High", max: "Max" }[level] || level;
246
+ }
247
+
248
+ const WEB_SLASH_COMMANDS = [
249
+ { name: "settings", description: "打开界面与运行时设置", source: "builtin", local: true },
250
+ { name: "model", description: "选择当前模型", source: "builtin", local: true },
251
+ { name: "export", description: "将当前会话导出为 HTML", source: "builtin", local: true },
252
+ { name: "copy", description: "复制最后一条助手回复", source: "builtin", local: true },
253
+ { name: "name", description: "设置当前会话名称", source: "builtin", local: true },
254
+ { name: "session", description: "显示会话统计信息", source: "builtin", local: true },
255
+ { name: "new", description: "在当前工作区创建新会话", source: "builtin", local: true },
256
+ { name: "rewind", description: "回溯对话,可选择同步恢复文件", source: "builtin", local: true },
257
+ { name: "tree", description: "打开当前会话的分支树", source: "builtin", local: true },
258
+ { name: "compact", description: "手动压缩当前会话上下文", source: "builtin", local: true },
259
+ ];
260
+
261
+ function commandSourceLabel(source) {
262
+ return { builtin: "内置", prompt: "模板", extension: "扩展", skill: "技能" }[source] || source || "命令";
263
+ }
264
+
265
+ function slashCommandContext(value = S.draft) {
266
+ const firstLine = String(value ?? "").split("\n", 1)[0];
267
+ const match = /^\/([^\s/]*)$/.exec(firstLine);
268
+ return match && firstLine.length === String(value ?? "").length ? { query: match[1] } : null;
269
+ }
270
+
271
+ function fuzzyCommandScore(value, query) {
272
+ const text = String(value || "").toLowerCase();
273
+ const needle = String(query || "").toLowerCase();
274
+ if (!needle) return 0;
275
+ if (text === needle) return -100;
276
+ let score = 0;
277
+ let from = 0;
278
+ let previous = -2;
279
+ for (const char of needle) {
280
+ const index = text.indexOf(char, from);
281
+ if (index < 0) return null;
282
+ score += index;
283
+ if (index === previous + 1) score -= 4;
284
+ if (index === 0 || /[-_:.]/.test(text[index - 1])) score -= 3;
285
+ previous = index;
286
+ from = index + 1;
287
+ }
288
+ return score + text.length * 0.05;
289
+ }
290
+
291
+ function availableSlashCommands(snap = currentSnapshot()) {
292
+ const sourceOrder = { builtin: 0, prompt: 1, extension: 2, skill: 3 };
293
+ const combined = [...WEB_SLASH_COMMANDS, ...(snap.commands || [])];
294
+ const seen = new Set();
295
+ return combined
296
+ .filter((command) => command?.name && !seen.has(command.name) && seen.add(command.name))
297
+ .sort((a, b) => (sourceOrder[a.source] ?? 9) - (sourceOrder[b.source] ?? 9));
298
+ }
299
+
300
+ function commandSuggestions(snap = currentSnapshot()) {
301
+ const context = slashCommandContext();
302
+ if (!context) return [];
303
+ return availableSlashCommands(snap)
304
+ .map((command, order) => ({ command, order, score: fuzzyCommandScore(command.name, context.query) }))
305
+ .filter((item) => item.score != null)
306
+ .sort((a, b) => a.score - b.score || a.order - b.order)
307
+ .slice(0, 20)
308
+ .map((item) => item.command);
309
+ }
310
+
311
+ function clampPanelWidth(value, min, max) {
312
+ return Math.min(Math.max(Number(value) || min, min), Math.max(min, max));
313
+ }
314
+
315
+ function usesMobileSidebar() {
316
+ return matchMedia("(max-width: 640px)").matches;
317
+ }
318
+
319
+ function currentPanelWidths() {
320
+ const frame = $(".pI_x6G_frame");
321
+ const frameWidth = frame?.clientWidth || window.innerWidth;
322
+ const sidebarWidth = usesMobileSidebar()
323
+ ? 0
324
+ : S.sidebarCollapsed
325
+ ? 56
326
+ : clampPanelWidth(S.sidebarWidth, 220, Math.min(420, frameWidth - 420));
327
+ const maxPreviewWidth = Math.min(960, Math.max(320, frameWidth - sidebarWidth - 360));
328
+ const previewWidth = S.filePreview ? clampPanelWidth(S.filePreviewWidth, 320, maxPreviewWidth) : 0;
329
+ return { sidebarWidth, previewWidth };
330
+ }
331
+
332
+ function applyFrameLayout() {
333
+ const frame = $(".pI_x6G_frame");
334
+ if (!frame) return;
335
+ const mobileSidebar = usesMobileSidebar();
336
+ const { sidebarWidth, previewWidth } = currentPanelWidths();
337
+ frame.dataset.sidebarCollapsed = String(mobileSidebar || S.sidebarCollapsed);
338
+ frame.toggleAttribute("data-mobile-sidebar-open", mobileSidebar && S.mobileSidebarOpen);
339
+ frame.toggleAttribute("data-details-collapsed", !S.filePreview);
340
+ frame.style.setProperty("--pi-sidebar-width", `${sidebarWidth}px`);
341
+ frame.style.setProperty("--pi-details-width", `${previewWidth}px`);
342
+ frame.style.gridTemplateColumns = `${sidebarWidth}px minmax(0px, 1fr) ${previewWidth}px`;
343
+ const sidebarHandle = $(".pI_x6G_handle[data-side=sidebar]", frame);
344
+ const detailsHandle = $(".pI_x6G_handle[data-side=details]", frame);
345
+ if (sidebarHandle) {
346
+ sidebarHandle.hidden = mobileSidebar || S.sidebarCollapsed;
347
+ sidebarHandle.setAttribute("aria-valuenow", String(Math.round(sidebarWidth)));
348
+ }
349
+ if (detailsHandle) {
350
+ detailsHandle.hidden = !S.filePreview;
351
+ detailsHandle.setAttribute("aria-valuenow", String(Math.round(previewWidth)));
352
+ }
353
+ }
354
+
355
+ function fileNameFromPath(path) {
356
+ return String(path || "").split(/[\\/]/).filter(Boolean).pop() || "文件";
357
+ }
358
+
359
+ function formatFileBytes(size) {
360
+ const bytes = Number(size) || 0;
361
+ if (bytes < 1024) return `${bytes} B`;
362
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes >= 100 * 1024 ? 0 : 1)} KB`;
363
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
364
+ }
365
+
366
+ function renderFilePreview() {
367
+ const mount = $("#filePreviewMount");
368
+ if (!mount) return;
369
+ const preview = S.filePreview;
370
+ if (!preview) {
371
+ mount.innerHTML = "";
372
+ return;
373
+ }
374
+ const name = preview.name || fileNameFromPath(preview.path);
375
+ const metadata = preview.loading
376
+ ? "正在读取"
377
+ : preview.error
378
+ ? "无法预览"
379
+ : `${preview.language || "plaintext"} · ${preview.lineCount} 行 · ${formatFileBytes(preview.size)}`;
380
+ let body = `<div class="pi-filePreviewState"><span class="pi-filePreviewSpinner" aria-hidden="true"></span>正在加载文件…</div>`;
381
+ if (preview.error) {
382
+ body = `<div class="pi-filePreviewState pi-filePreviewError"><strong>文件预览失败</strong><span>${esc(preview.error)}</span></div>`;
383
+ } else if (!preview.loading) {
384
+ const lineCount = Math.max(1, Number(preview.lineCount) || 1);
385
+ const requestedLine = Number(preview.line);
386
+ const line = Number.isFinite(requestedLine) && requestedLine > 0
387
+ ? Math.min(Math.max(1, requestedLine), lineCount)
388
+ : null;
389
+ const numbers = Array.from({ length: lineCount }, (_, index) => index + 1).join("\n");
390
+ const digits = String(lineCount).length;
391
+ const sourceState = line
392
+ ? `data-active-line="${line}" style="--pi-active-line-top:${12 + (line - 1) * 21}px;--pi-line-digits:${digits}"`
393
+ : `style="--pi-line-digits:${digits}"`;
394
+ body = `<div class="pi-filePreviewScroll" tabindex="0" aria-label="${esc(name)} 源代码">
395
+ <div class="pi-filePreviewSource" ${sourceState}>
396
+ <pre class="pi-filePreviewNumbers" aria-hidden="true">${numbers}</pre>
397
+ <pre class="pi-filePreviewCode"><code class="hljs language-${esc(preview.language || "plaintext")}">${preview.highlightedHtml || " "}</code></pre>
398
+ </div>
399
+ </div>`;
400
+ }
401
+ mount.innerHTML = `<section class="pi-filePreview" aria-label="文件预览">
402
+ <header class="pi-filePreviewHeader">
403
+ <div class="pi-filePreviewHeading">
404
+ <strong title="${esc(preview.path)}">${esc(name)}</strong>
405
+ <span title="${esc(preview.path)}">${esc(preview.path)}</span>
406
+ </div>
407
+ <div class="pi-filePreviewActions">
408
+ <button type="button" class="pi-filePreviewIcon" data-action="file-preview-reveal" title="在系统文件管理器中定位" aria-label="在系统文件管理器中定位">${ICONS.folderOpen}</button>
409
+ <button type="button" class="pi-filePreviewIcon pi-filePreviewClose" data-action="file-preview-close" title="关闭预览" aria-label="关闭预览">×</button>
410
+ </div>
411
+ </header>
412
+ <div class="pi-filePreviewMeta">${esc(metadata)}${!preview.loading && !preview.error && !preview.highlighted ? " · 纯文本模式" : ""}</div>
413
+ <div class="pi-filePreviewBody">${body}</div>
414
+ </section>`;
415
+ }
416
+
417
+ function scrollFilePreviewToLine() {
418
+ const preview = S.filePreview;
419
+ const scroll = $(".pi-filePreviewScroll");
420
+ if (!preview || !scroll || !preview.line) return;
421
+ const lineTop = (Math.max(1, Number(preview.line)) - 1) * 21 + 12;
422
+ scroll.scrollTop = Math.max(0, lineTop - scroll.clientHeight / 3);
423
+ }
424
+
425
+ async function openFilePreview(path, line, href) {
426
+ const requestedLine = Number(line);
427
+ const normalizedLine = Number.isFinite(requestedLine) && requestedLine > 0 ? Math.max(1, requestedLine) : null;
428
+ if (S.filePreview?.path === path && !S.filePreview.error) {
429
+ S.filePreview = { ...S.filePreview, line: normalizedLine, href };
430
+ applyFrameLayout();
431
+ renderFilePreview();
432
+ requestAnimationFrame(scrollFilePreviewToLine);
433
+ return;
434
+ }
435
+
436
+ const generation = ++S.filePreviewGeneration;
437
+ S.filePreview = { path, line: normalizedLine, href, name: fileNameFromPath(path), loading: true };
438
+ applyFrameLayout();
439
+ renderFilePreview();
440
+ try {
441
+ const data = await api(`/api/fs/view?path=${encodeURIComponent(path)}&format=json`);
442
+ if (generation !== S.filePreviewGeneration) return;
443
+ const activeLine = S.filePreview?.path === path ? S.filePreview.line : normalizedLine;
444
+ S.filePreview = { ...data, path, line: activeLine, href, loading: false, error: null };
445
+ } catch (error) {
446
+ if (generation !== S.filePreviewGeneration) return;
447
+ S.filePreview = { ...S.filePreview, loading: false, error: error.message || String(error) };
448
+ }
449
+ renderFilePreview();
450
+ requestAnimationFrame(scrollFilePreviewToLine);
451
+ }
452
+
453
+ function closeFilePreview() {
454
+ ++S.filePreviewGeneration;
455
+ S.filePreview = null;
456
+ renderFilePreview();
457
+ applyFrameLayout();
458
+ }
459
+
460
+ function renderApp() {
461
+ const root = $("#root");
462
+ const { sidebarWidth, previewWidth } = currentPanelWidths();
463
+ root.innerHTML = `
464
+ <div class="pI_x6G_frame" ${S.filePreview ? "" : "data-details-collapsed"} data-sidebar-collapsed="${usesMobileSidebar() || S.sidebarCollapsed}" ${S.mobileSidebarOpen ? "data-mobile-sidebar-open" : ""} style="--pi-sidebar-width:${sidebarWidth}px;--pi-details-width:${previewWidth}px;grid-template-columns:${sidebarWidth}px minmax(0px,1fr) ${previewWidth}px">
465
+ <div class="pI_x6G_sidebarCol"><div id="sidebarMount"></div></div>
466
+ <button type="button" class="pi-mobileSidebarBackdrop" aria-label="关闭侧边栏" data-action="close-mobile-sidebar"></button>
467
+ <div class="pI_x6G_centerCol"><div id="conversationMount" class="wSkVaW_root"></div></div>
468
+ <div class="pI_x6G_detailsCol"><div id="filePreviewMount"></div></div>
469
+ <div class="pI_x6G_overlayLayer" id="overlayMount"></div>
470
+ <div class="pI_x6G_handle" data-side="sidebar" role="separator" aria-label="调整侧边栏宽度" aria-orientation="vertical" aria-valuemin="220" aria-valuemax="420" tabindex="0"></div>
471
+ <div class="pI_x6G_handle" data-side="details" role="separator" aria-label="调整文件预览宽度" aria-orientation="vertical" aria-valuemin="320" aria-valuemax="960" tabindex="0"></div>
472
+ </div>`;
473
+ applyFrameLayout();
474
+ renderSidebar();
475
+ renderConversation();
476
+ renderFilePreview();
477
+ renderOverlay();
478
+ }
479
+
480
+ function renderSidebar() {
481
+ const mount = $("#sidebarMount");
482
+ if (!mount) return;
483
+ const previousSearch = $("#sessionSearchInput");
484
+ const searchWasFocused = document.activeElement === previousSearch;
485
+ const searchSelectionStart = previousSearch?.selectionStart ?? null;
486
+ const searchSelectionEnd = previousSearch?.selectionEnd ?? null;
487
+ const expanded = usesMobileSidebar() || !S.sidebarCollapsed;
488
+ const query = S.search.trim().toLowerCase();
489
+ const searchOpen = Boolean(query) || S.searchOpen;
490
+ const activeWs = currentWorkspace();
491
+ const workspacesHtml = S.workspaces.map((ws) => {
492
+ const isDragging = S.workspaceDragId === ws.id;
493
+ const dragState = isDragging ? " YDXeBa_dragSource" : "";
494
+ const dragAttrs = query ? "" : ` draggable="true" data-workspace-draggable="1" tabindex="0" aria-label="${esc(ws.title)},可拖动排序"`;
495
+ const dragHandle = query ? "" : `<span class="YDXeBa_dragHandle" aria-hidden="true">${ICONS.grip}</span>`;
496
+ const blank = ws.id === activeWs?.id && !S.currentSessionId && !query
497
+ ? `<div class="YDXeBa_sessionRow YDXeBa_selected" role="treeitem" aria-selected="true" data-new-session="1" data-workspace="${esc(ws.id)}"><span class="YDXeBa_slot"></span><span class="YDXeBa_title">新会话</span></div>`
498
+ : "";
499
+ const allSessions = sessionsByRecency(ws);
500
+ const matchingSessions = allSessions.filter((s) => !query || `${s.title} ${s.id}`.toLowerCase().includes(query));
501
+ const sessionListExpanded = S.expandedSessionLists.has(ws.id);
502
+ const visibleSessions = query || sessionListExpanded
503
+ ? matchingSessions
504
+ : matchingSessions.slice(0, SESSION_PREVIEW_LIMIT);
505
+ const rows = visibleSessions.map((s) => {
506
+ const cls = ["YDXeBa_sessionRow"];
507
+ if (S.currentSessionId === s.id) cls.push("YDXeBa_selected");
508
+ if (s.waitingForUser || s.dialogCount) cls.push("YDXeBa_waiting");
509
+ else if (s.streaming) cls.push("YDXeBa_running");
510
+ else if (s.needsAttention && S.currentSessionId !== s.id) cls.push("YDXeBa_completed");
511
+ return `<div class="${cls.join(" ")}" role="treeitem" aria-selected="${S.currentSessionId === s.id}" data-session="${esc(s.id)}">
512
+ <span class="YDXeBa_slot"></span>
513
+ <span class="YDXeBa_title">${esc(s.title)}</span>
514
+ <span class="YDXeBa_time">${esc(timeLabel(s.updatedAt))}</span>
515
+ ${s.dialogCount ? `<span class="YDXeBa_pending">等待输入</span>` : ""}
516
+ <span class="YDXeBa_rowActions"><span class="_root_19372_1"><button type="button" class="YDXeBa_iconButton" data-action="session-menu" data-session="${esc(s.id)}" aria-label="会话操作">${ICONS.dots}</button></span></span>
517
+ </div>`;
518
+ }).join("");
519
+ const hiddenSessionCount = Math.max(0, allSessions.length - SESSION_PREVIEW_LIMIT);
520
+ const overflow = !query && hiddenSessionCount
521
+ ? `<button type="button" class="qDHVXG_sessionOverflowButton" data-action="toggle-session-list" data-workspace="${esc(ws.id)}" aria-expanded="${sessionListExpanded}">
522
+ <span>${sessionListExpanded ? `收起至最近 ${SESSION_PREVIEW_LIMIT} 个` : `展开其余 ${hiddenSessionCount} 个`}</span>
523
+ <span class="pi-sessionOverflowChevron ${sessionListExpanded ? "pi-sessionOverflowChevronExpanded" : ""}" aria-hidden="true">${ICONS.chevron}</span>
524
+ </button>`
525
+ : "";
526
+ const collapsed = !query && S.collapsed.has(ws.id);
527
+ const contents = blank + rows + overflow;
528
+ return `<div class="qDHVXG_groupSection" data-workspace-group="${esc(ws.id)}">
529
+ <div class="YDXeBa_projectRow${dragState}" role="treeitem" aria-expanded="${!collapsed}" data-workspace="${esc(ws.id)}"${dragAttrs}>
530
+ ${dragHandle}
531
+ <span class="YDXeBa_slot YDXeBa_folder ${S.currentWorkspaceId === ws.id ? "YDXeBa_folderActive" : ""}">${ICONS.folder}</span>
532
+ <span class="YDXeBa_slot YDXeBa_chevron"><svg width="14" height="14" class="YDXeBa_arrow ${collapsed ? "" : "YDXeBa_arrowOpen"}" viewBox="0 0 14 14" fill="none"><path d="M4.25 2.83V11.17C4.25 11.66 4.84 11.91 5.19 11.56L9.36 7.39C9.58 7.17 9.58 6.83 9.36 6.61L5.19 2.44C4.84 2.09 4.25 2.34 4.25 2.83Z" fill="currentColor"/></svg></span>
533
+ <span class="YDXeBa_projectText"><span class="YDXeBa_title">${esc(ws.title)}</span></span>
534
+ <span class="YDXeBa_rowActions">
535
+ <span class="_root_19372_1"><button type="button" class="YDXeBa_iconButton" data-action="workspace-menu" data-workspace="${esc(ws.id)}" aria-label="工作区操作">${ICONS.dots}</button></span>
536
+ <button type="button" class="YDXeBa_iconButton" data-action="new-session-in-workspace" data-workspace="${esc(ws.id)}" aria-label="新建会话">${ICONS.plus}</button>
537
+ </span>
538
+ </div>
539
+ ${collapsed ? "" : contents || `<div class="YDXeBa_empty">${query ? "没有匹配的会话" : "暂无会话"}</div>`}
540
+ </div>`;
541
+ }).join("");
542
+ const listHtml = workspacesHtml || `<div class="qDHVXG_searchWarning">没有工作区。点击 + 添加一个目录。</div>`;
543
+
544
+ const shellHtml = expanded ? `
545
+ <aside class="hHd-Xa_root hHd-Xa_quietBars" style="width:280px;">
546
+ <div class="hHd-Xa_logoRow">
547
+ <button class="hHd-Xa_brand hHd-Xa_wide" type="button" aria-label="Pi Web" data-action="brand">${ICONS.wordmark}</button>
548
+ <button class="hHd-Xa_iconButton hHd-Xa_toggle" type="button" aria-label="收起侧边栏" data-action="collapse-sidebar">${ICONS.panel}</button>
549
+ </div>
550
+ <button type="button" class="hHd-Xa_newSession" aria-label="新建会话" data-action="new-session">${ICONS.plus}<span class="hHd-Xa_newSessionLabel hHd-Xa_wide">新会话</span></button>
551
+ <div class="hHd-Xa_regionArea">
552
+ <div class="qDHVXG_root">
553
+ <div class="qDHVXG_sectionHeader">
554
+ <span class="qDHVXG_sectionLabel qDHVXG_wide">工作区</span>
555
+ <div class="qDHVXG_searchSlot ${searchOpen ? "qDHVXG_searchSlotExpanded" : ""}">
556
+ <div class="qDHVXG_search ${searchOpen ? "qDHVXG_searchExpanded" : ""}">
557
+ <button type="button" class="qDHVXG_searchButton" aria-label="搜索会话" data-action="focus-search">${ICONS.search}</button>
558
+ <input class="qDHVXG_searchInput" id="sessionSearchInput" type="text" placeholder="搜索会话…">
559
+ </div>
560
+ </div>
561
+ <div class="qDHVXG_headerActions"><span class="_root_19372_1"><button type="button" class="qDHVXG_iconButton qDHVXG_wide" aria-label="视图选项" data-action="view-options">${ICONS.dots}</button></span><button type="button" class="qDHVXG_iconButton" aria-label="添加工作区" data-action="add-workspace">${ICONS.plus}</button></div><span class="_root_19372_1"></span>
562
+ </div>
563
+ <div class="qDHVXG_listArea"><div class="qDHVXG_treeBody qDHVXG_wide"><div class="qDHVXG_list" id="sessionListMount" role="tree" aria-label="会话"></div><span class="qDHVXG_fade"></span></div></div>
564
+ </div>
565
+ </div>
566
+ <div class="hHd-Xa_footArea">
567
+ <div class="hHd-Xa_settingsArea">
568
+ <button type="button" class="VOzbGW_trigger" aria-haspopup="menu" aria-expanded="false" data-action="settings">${ICONS.gear}<span class="UQsH_q_triggerLabel">设置</span></button>
569
+ </div>
570
+ </div>
571
+ </aside>` : `
572
+ <aside class="hHd-Xa_root hHd-Xa_collapsed hHd-Xa_quietBars hHd-Xa_railIn" style="width:56px;">
573
+ <div class="hHd-Xa_logoRow">
574
+ <button class="hHd-Xa_iconButton hHd-Xa_toggle" type="button" aria-label="展开侧边栏" title="展开侧边栏" data-action="collapse-sidebar"><span class="hHd-Xa_railFish">${ICONS.logo}</span>${ICONS.panel}</button>
575
+ </div>
576
+ <button type="button" class="hHd-Xa_newSession" aria-label="新建会话" title="新建会话" data-action="new-session">${ICONS.plus}</button>
577
+ <div class="hHd-Xa_regionArea pi-sidebarRail">
578
+ <div class="pi-sidebarRailWorkspaces">
579
+ ${S.workspaces.map((ws) => `<button type="button" class="pi-sidebarRailButton ${ws.id === activeWs?.id ? "pi-sidebarRailButtonActive" : ""}" aria-label="${esc(ws.title)}" title="${esc(ws.title)}" data-action="select-workspace" data-workspace="${esc(ws.id)}">${ICONS.folder}</button>`).join("")}
580
+ <button type="button" class="pi-sidebarRailButton" aria-label="添加工作区" title="添加工作区" data-action="add-workspace">${ICONS.plus}</button>
581
+ </div>
582
+ </div>
583
+ <div class="hHd-Xa_footArea"><div class="hHd-Xa_settingsArea"><button type="button" class="VOzbGW_trigger VOzbGW_rail" aria-label="设置" title="设置" data-action="settings">${ICONS.gear}</button></div></div>
584
+ </aside>`;
585
+ // Keep the sidebar DOM stable: rebuild the shell only when it actually
586
+ // changes, and the session list only when its rows change. This avoids the
587
+ // startup flicker caused by repeated workspace broadcasts rebuilding the
588
+ // whole sidebar.
589
+ if (mount.dataset.shell !== shellHtml) {
590
+ mount.innerHTML = shellHtml;
591
+ mount.dataset.shell = shellHtml;
592
+ }
593
+ const listMount = $("#sessionListMount");
594
+ if (listMount && listMount.dataset.list !== listHtml) {
595
+ listMount.innerHTML = listHtml;
596
+ listMount.dataset.list = listHtml;
597
+ }
598
+ const searchInput = $("#sessionSearchInput");
599
+ if (searchInput && !searchInput.dataset.bound) {
600
+ searchInput.value = S.search;
601
+ searchInput.dataset.bound = "1";
602
+ searchInput.addEventListener("compositionstart", () => {
603
+ searchInput.dataset.composing = "1";
604
+ });
605
+ searchInput.addEventListener("compositionend", () => {
606
+ delete searchInput.dataset.composing;
607
+ S.search = searchInput.value;
608
+ renderSidebar();
609
+ });
610
+ searchInput.addEventListener("input", () => {
611
+ if (searchInput.dataset.composing) return;
612
+ S.search = searchInput.value;
613
+ renderSidebar();
614
+ });
615
+ }
616
+ if (searchWasFocused) {
617
+ searchInput?.focus({ preventScroll: true });
618
+ if (searchSelectionStart != null && searchSelectionEnd != null) {
619
+ searchInput.setSelectionRange(searchSelectionStart, searchSelectionEnd);
620
+ }
621
+ }
622
+ }
623
+
624
+ function renderWorkspacePicker() {
625
+ const ws = currentWorkspace();
626
+ return `<button type="button" class="pXSMma_workspace" aria-label="选择工作区" aria-haspopup="menu" data-action="hero-workspace">
627
+ ${ICONS.folder}<span class="pXSMma_workspaceLabel">${esc(ws?.title || "选择工作区")}</span>
628
+ <svg width="12" height="12" class="pXSMma_chevron" viewBox="0 0 14 14" fill="none"><path d="M11.85 5.5L11.42 5.92L8.7 8.65C8.44 8.91 8.22 9.13 8.01 9.3C7.8 9.47 7.56 9.62 7.25 9.67C7.08 9.69 6.92 9.69 6.75 9.67C6.44 9.62 6.2 9.47 5.99 9.3C5.78 9.13 5.56 8.91 5.3 8.65L2.58 5.92L2.15 5.5L3 4.65L3.42 5.08L6.15 7.8C6.43 8.08 6.6 8.25 6.74 8.36C6.87 8.47 6.92 8.48 6.94 8.48C6.98 8.49 7.02 8.49 7.06 8.48C7.08 8.48 7.13 8.47 7.26 8.36C7.4 8.25 7.57 8.08 7.85 7.8L10.58 5.08L11 4.65L11.85 5.5Z" fill="currentColor"/></svg>
629
+ </button>`;
630
+ }
631
+
632
+ function renderHero() {
633
+ const ws = currentWorkspace();
634
+ return `
635
+ <div class="wSkVaW_scrollBody" data-conversation-scroll="">
636
+ <div class="wSkVaW_composerSeat" data-composer-seat="">
637
+ <div class="wSkVaW_composerStack wSkVaW_composerHero">
638
+ <svg class="wSkVaW_heroGlow" viewBox="0 0 1051 468" fill="none" aria-hidden="true">
639
+ <defs><filter id="pi-empty-glow" x="0" y="0" width="1051" height="468" filterUnits="userSpaceOnUse"><feGaussianBlur stdDeviation="50"/></filter></defs>
640
+ <g filter="url(#pi-empty-glow)"><ellipse cx="525.5" cy="234" rx="425.5" ry="134" fill="#6187D8" fill-opacity="0.08"></ellipse></g>
641
+ </svg>
642
+ <div class="pXSMma_root"><div class="pXSMma_stack">
643
+ <div class="pXSMma_headline">
644
+ <span class="pXSMma_fishHitbox">${ICONS.logo}</span>
645
+ <span class="pXSMma_headlineText">和你的代码库对话</span>
646
+ <span class="pXSMma_previewBadge">Web 预览版</span>
647
+ </div>
648
+ <div class="pXSMma_body"></div>
649
+ </div></div>
650
+ <div class="wSkVaW_heroWorkspaceRow">
651
+ <div class="pXSMma_workspaceRow">${renderWorkspacePicker()}</div>
652
+ <button type="button" class="cubgiG_seat" aria-haspopup="menu" aria-expanded="false" title="这个会话由 pi coding agent 驱动" data-action="pi-agent-chip">
653
+ <svg width="16" height="16" class="cubgiG_seatIcon" viewBox="0 0 50 50" fill="none"><rect width="50" height="50" rx="12" fill="var(--dsw-alias-brand-primary,#3964fe)"/><text x="25" y="34" text-anchor="middle" font-family="Georgia,serif" font-size="30" font-weight="700" fill="#fff">π</text></svg>
654
+ Pi Agent
655
+ <svg width="14" height="14" class="cubgiG_chevron" viewBox="0 0 14 14" fill="none"><path d="M11.85 5.5L11.42 5.92L8.7 8.65C8.44 8.91 8.22 9.13 8.01 9.3C7.8 9.47 7.56 9.62 7.25 9.67C7.08 9.69 6.92 9.69 6.75 9.67C6.44 9.62 6.2 9.47 5.99 9.3C5.78 9.13 5.56 8.91 5.3 8.65L2.58 5.92L2.15 5.5L3 4.65L3.42 5.08L6.15 7.8C6.43 8.08 6.6 8.25 6.74 8.36C6.87 8.47 6.92 8.48 6.94 8.48C6.98 8.49 7.02 8.49 7.06 8.48C7.08 8.48 7.13 8.47 7.26 8.36C7.4 8.25 7.57 8.08 7.85 7.8L10.58 5.08L11 4.65L11.85 5.5Z" fill="currentColor"/></svg>
656
+ </button>
657
+ </div>
658
+ ${renderComposer(true)}
659
+ </div>
660
+ </div>
661
+ </div>`;
662
+ }
663
+
664
+ function renderCommandMenu(snap = currentSnapshot()) {
665
+ if (!S.commandMenuOpen || !slashCommandContext()) return "";
666
+ const commands = commandSuggestions(snap);
667
+ if (S.commandSelected >= commands.length) S.commandSelected = Math.max(0, commands.length - 1);
668
+ const rows = commands.map((command, index) => `<button type="button" role="option" aria-selected="${index === S.commandSelected}" class="_3e4SsG_item ${index === S.commandSelected ? "_3e4SsG_active" : ""}" data-action="choose-command" data-command="${esc(command.name)}">
669
+ <span class="_3e4SsG_itemIcon">/</span>
670
+ <span class="_3e4SsG_itemName">${esc(command.name)}</span>
671
+ <span class="_3e4SsG_itemDescription">${esc(command.description || commandSourceLabel(command.source))}</span>
672
+ <span class="pi-commandSource">${esc(commandSourceLabel(command.source))}</span>
673
+ </button>`).join("");
674
+ const status = S.commandLoading && commands.length === 0
675
+ ? `<div class="_3e4SsG_loading">正在加载命令…</div>`
676
+ : commands.length === 0 ? `<div class="_3e4SsG_loading">没有匹配的命令</div>` : "";
677
+ return `<div class="_3e4SsG_menu pi-commandMenu" role="listbox" aria-label="斜杠命令"><div class="_3e4SsG_viewport">${rows || status}</div></div>`;
678
+ }
679
+
680
+ function renderDraftAttachments(model) {
681
+ if (!S.draftAttachments.length) return "";
682
+ const items = S.draftAttachments.map((attachment, index) => `<div class="pi-attachmentItem">
683
+ <img src="${esc(attachment.url)}" alt="附件图片 ${index + 1}">
684
+ <span class="pi-attachmentMeta">${index + 1}</span>
685
+ <button type="button" class="pi-attachmentRemove" data-action="remove-attachment" data-attachment-id="${esc(attachment.id)}" aria-label="移除第 ${index + 1} 张图片" title="移除图片">${ICONS.close}</button>
686
+ </div>`).join("");
687
+ const warning = modelSupportsImages(model) ? "" : `<span class="pi-attachmentWarning">当前模型不支持图片输入</span>`;
688
+ return `<div class="pi-attachmentTray" aria-label="待发送图片">${items}${warning}</div>`;
689
+ }
690
+
691
+ function renderComposerBar(hero = false) {
692
+ const snap = currentSnapshot();
693
+ const state = snap.state || {};
694
+ const model = state.model || null;
695
+ const level = state.thinkingLevel || null;
696
+ const streaming = Boolean(snap.session?.streaming);
697
+ const waitingForDialog = Boolean(snap.dialogs?.length);
698
+ const disabled = (!S.currentSessionId && !hero) || Boolean(S.loadingSession && !hero) || waitingForDialog;
699
+ const inputPlaceholder = waitingForDialog ? "请先回答对话中的问题" : "给智能体发消息";
700
+ const rawContextPct = snap.stats?.contextUsage?.percent;
701
+ const hasContextPct = typeof rawContextPct === "number" && Number.isFinite(rawContextPct);
702
+ const contextPct = hasContextPct ? Math.max(0, Math.min(100, rawContextPct)) : 0;
703
+ const contextLabel = hasContextPct ? `上下文已用 ${Math.round(rawContextPct)}%` : "上下文占用待更新";
704
+ return `
705
+ <div class="uV2eYG_root ${hero ? "uV2eYG_hero" : ""}">
706
+ <div class="uV2eYG_card" data-composer-card="true">
707
+ ${renderCommandMenu(snap)}
708
+ ${renderDraftAttachments(model)}
709
+ <div class="uV2eYG_scroll" data-input-scroll="true"><div class="uV2eYG_grow">
710
+ <div aria-hidden="true" class="uV2eYG_backdrop"></div>
711
+ <textarea class="uV2eYG_input" id="composerInput" data-phase="plain" data-session-id="${esc(S.currentSessionId || "")}" placeholder="${inputPlaceholder}" rows="1" ${disabled ? "disabled" : ""}></textarea>
712
+ <div aria-hidden="true" class="uV2eYG_mirror"></div>
713
+ </div></div>
714
+ <div class="uV2eYG_row">
715
+ <div class="uV2eYG_tools">
716
+ <button type="button" class="uV2eYG_add" aria-label="命令" title="命令" data-action="commands" ${disabled ? "disabled" : ""}>${ICONS.plus}</button>
717
+ <button type="button" class="uV2eYG_add pi-imagePaste" aria-label="粘贴剪贴板图片" title="${modelSupportsImages(model) ? "粘贴剪贴板图片(Alt+V)" : "当前模型不支持图片输入"}" data-action="paste-image" ${disabled || S.clipboardBusy || !modelSupportsImages(model) ? "disabled" : ""}>${ICONS.image}</button>
718
+ </div>
719
+ <div class="uV2eYG_trailing">
720
+ <div class="_7KE1Ra_root pi-modelControl">
721
+ <button type="button" class="_7KE1Ra_trigger" aria-haspopup="menu" aria-expanded="false" title="选择模型" data-action="model-menu" ${disabled ? "disabled" : ""}>
722
+ <span class="_7KE1Ra_triggerLabel">${esc(modelLabel(model))}</span>
723
+ <svg width="14" height="14" class="_7KE1Ra_chevron" viewBox="0 0 14 14" fill="none"><path d="M11.85 5.5L11.42 5.92L8.7 8.65C8.44 8.91 8.22 9.13 8.01 9.3C7.8 9.47 7.56 9.62 7.25 9.67C7.08 9.69 6.92 9.69 6.75 9.67C6.44 9.62 6.2 9.47 5.99 9.3C5.78 9.13 5.56 8.91 5.3 8.65L2.58 5.92L2.15 5.5L3 4.65L3.42 5.08L6.15 7.8C6.43 8.08 6.6 8.25 6.74 8.36C6.87 8.47 6.92 8.48 6.94 8.48C6.98 8.49 7.02 8.49 7.06 8.48C7.08 8.48 7.13 8.47 7.26 8.36C7.4 8.25 7.57 8.08 7.85 7.8L10.58 5.08L11 4.65L11.85 5.5Z" fill="currentColor"/></svg>
724
+ </button>
725
+ </div>
726
+ <div class="_7KE1Ra_root pi-thinkingControl">
727
+ <button type="button" class="_7KE1Ra_trigger pi-thinkingTrigger" aria-haspopup="menu" aria-expanded="false" title="选择思考强度" data-action="thinking-menu" ${disabled ? "disabled" : ""}>
728
+ <span class="pi-thinkingIcon">${ICONS.think}</span>
729
+ <span class="_7KE1Ra_triggerLabel">${esc(thinkingLabel(level || "off"))}</span>
730
+ <svg width="14" height="14" class="_7KE1Ra_chevron" viewBox="0 0 14 14" fill="none"><path d="M11.85 5.5L11.42 5.92L8.7 8.65C8.44 8.91 8.22 9.13 8.01 9.3C7.8 9.47 7.56 9.62 7.25 9.67C7.08 9.69 6.92 9.69 6.75 9.67C6.44 9.62 6.2 9.47 5.99 9.3C5.78 9.13 5.56 8.91 5.3 8.65L2.58 5.92L2.15 5.5L3 4.65L3.42 5.08L6.15 7.8C6.43 8.08 6.6 8.25 6.74 8.36C6.87 8.47 6.92 8.48 6.94 8.48C6.98 8.49 7.02 8.49 7.06 8.48C7.08 8.48 7.13 8.47 7.26 8.36C7.4 8.25 7.57 8.08 7.85 7.8L10.58 5.08L11 4.65L11.85 5.5Z" fill="currentColor"/></svg>
731
+ </button>
732
+ </div>
733
+ ${hero ? "" : `<span class="JObwrW_root"><button type="button" class="JObwrW_trigger" aria-label="${contextLabel}" title="${contextLabel}" data-action="context-info"><svg viewBox="0 0 14 14" width="14" height="14" aria-hidden="true"><circle class="JObwrW_track" cx="7" cy="7" r="5.5"></circle><circle class="JObwrW_fill" cx="7" cy="7" r="5.5" stroke-dasharray="${(contextPct * 34.56 / 100).toFixed(2)} 34.56" transform="rotate(-90 7 7)"></circle></svg></button></span>`}
734
+ <span class="pi-composerActions">
735
+ ${streaming ? `<button type="button" class="pi-abortButton" aria-label="停止生成" title="停止生成" data-action="abort">${ICONS.stop}</button>` : ""}
736
+ <button type="button" class="uV2eYG_primary" aria-label="${streaming ? "插入消息" : "发送消息"}" title="${streaming ? "插入消息" : "发送消息"}" data-action="send" ${disabled ? "disabled" : ""}>${ICONS.send}</button>
737
+ </span>
738
+ </div>
739
+ </div>
740
+ </div>
741
+ </div>`;
742
+ }
743
+
744
+ function renderComposer(hero = false) {
745
+ const bar = renderComposerBar(hero);
746
+ if (hero) return bar;
747
+ const snap = currentSnapshot();
748
+ return `<div class="wSkVaW_composerStack">${bar}${renderStats(snap)}</div>`;
749
+ }
750
+
751
+ function extensionStatusClass(text) {
752
+ const value = String(text || "").toLowerCase();
753
+ if (/^(✓|✔)|\b(done|ready|complete|success)\b/.test(value)) return "FJxK0a_extensionStatus FJxK0a_statusSuccess";
754
+ if (/^(⚠|!)|\b(warn|waiting|pending)\b/.test(value)) return "FJxK0a_extensionStatus FJxK0a_statusWarning";
755
+ if (/^(✗|×)|\b(error|failed|failure)\b/.test(value)) return "FJxK0a_extensionStatus FJxK0a_statusError";
756
+ return "FJxK0a_extensionStatus";
757
+ }
758
+
759
+ function renderStats(snap) {
760
+ const stats = snap.stats || {};
761
+ const tokens = stats.tokens || {};
762
+ const cu = stats.contextUsage || {};
763
+ const parts = [];
764
+ const add = (text, className = "") => {
765
+ if (text !== "" && text != null) parts.push({ text: String(text), className });
766
+ };
767
+ if (stats.totalMessages != null) add(`消息 ${stats.totalMessages}`);
768
+ if (stats.toolCalls != null) add(`工具 ${stats.toolCalls}`);
769
+ const pendingMessages = Number(snap.state?.pendingMessageCount ?? 0);
770
+ if (pendingMessages > 0) add(`待处理 ${pendingMessages}`, "FJxK0a_statusWarning");
771
+ if (tokens.input != null || tokens.output != null) add(`输入 ${fmtNum(tokens.input ?? 0)} · 输出 ${fmtNum(tokens.output ?? 0)} tok`);
772
+ const cacheParts = [];
773
+ if (Number(tokens.cacheRead) > 0) cacheParts.push(`缓存读 ${fmtNum(tokens.cacheRead)}`);
774
+ if (Number(tokens.cacheWrite) > 0) cacheParts.push(`写 ${fmtNum(tokens.cacheWrite)}`);
775
+ if (cacheParts.length) add(cacheParts.join(" · "));
776
+ if (cu.tokens != null) add(`上下文 ${fmtNum(cu.tokens)}/${fmtNum(cu.contextWindow ?? 0)} (${Math.round(cu.percent ?? 0)}%)`, "FJxK0a_context");
777
+ if (typeof stats.cost === "number") add(`$${stats.cost.toFixed(3)}`);
778
+ for (const [, text] of Object.entries(snap.extensionStatuses || {}).sort(([a], [b]) => a.localeCompare(b))) {
779
+ add(text, extensionStatusClass(text));
780
+ }
781
+ if (!parts.length) return "";
782
+ return `<div class="FJxK0a_root" role="status">${parts.map((part) => `<span class="${part.className}" title="${esc(part.text)}">${esc(part.text)}</span>`).join('<span class="FJxK0a_sep" aria-hidden="true">|</span>')}</div>`;
783
+ }
784
+
785
+ function fmtNum(n) {
786
+ if (!Number.isFinite(Number(n))) return String(n ?? 0);
787
+ const v = Number(n);
788
+ if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
789
+ if (v >= 1_000) return `${(v / 1_000).toFixed(1)}K`;
790
+ return String(v);
791
+ }
792
+
793
+ function renderHeader() {
794
+ const snap = currentSnapshot();
795
+ const session = snap.session || {};
796
+ const historyActive = S.conversationTab === "history";
797
+ return `<header class="wSkVaW_header">
798
+ <button type="button" class="pi-mobileSidebarButton" aria-label="打开侧边栏" data-action="open-mobile-sidebar">${ICONS.panel}</button>
799
+ <div class="wSkVaW_titleRow"><div class="wSkVaW_titleCluster">
800
+ <nav class="wSkVaW_crumbs" aria-label="会话层级"><span class="wSkVaW_crumbSeg"><button type="button" class="wSkVaW_crumb wSkVaW_crumbCurrent" disabled>${esc(session.title || "新会话")}</button></span></nav>
801
+ </div></div>
802
+ <div class="wSkVaW_headerUtilities">
803
+ <button type="button" class="nL4_yW_sessionLogButton" data-action="export-current"><span>导出记录</span></button>
804
+ </div>
805
+ <div class="wSkVaW_tabs" role="tablist">
806
+ <button type="button" role="tab" aria-selected="${!historyActive}" class="wSkVaW_tab ${historyActive ? "" : "wSkVaW_tabActive"}" data-action="conversation-tab">对话</button>
807
+ <button type="button" role="tab" aria-selected="${historyActive}" class="wSkVaW_tab ${historyActive ? "wSkVaW_tabActive" : ""}" data-action="history-current">会话历史</button>
808
+ </div>
809
+ </header>`;
810
+ }
811
+
812
+ function flowRevision(value) {
813
+ const text = JSON.stringify(value) || "";
814
+ let hash = 2166136261;
815
+ for (let i = 0; i < text.length; i += 1) {
816
+ hash ^= text.charCodeAt(i);
817
+ hash = Math.imul(hash, 16777619);
818
+ }
819
+ return `${text.length}:${(hash >>> 0).toString(36)}`;
820
+ }
821
+
822
+ function queueItemBusyKey(sessionId, kind, index, message) {
823
+ return `${sessionId}:${kind}:${index}:${flowRevision(message)}`;
824
+ }
825
+
826
+ function pendingQueueEntries(snap = currentSnapshot()) {
827
+ const queue = snap.pendingQueue || {};
828
+ return [
829
+ ...(Array.isArray(queue.steering) ? queue.steering : []).map((message, index) => ({ kind: "steering", index, message })),
830
+ ...(Array.isArray(queue.followUp) ? queue.followUp : []).map((message, index) => ({ kind: "followUp", index, message })),
831
+ ];
832
+ }
833
+
834
+ function renderPendingQueueMessage(entry, busy) {
835
+ const label = entry.kind === "steering" ? "待插入" : "待后续处理";
836
+ return `<div class="Md3f7G_flowItem" data-chat-flow-kind="queued-user">
837
+ <div class="gdEzaW_userRow pi-queuedMessageRow"><div class="gdEzaW_userStack pi-queuedMessageStack">
838
+ <div class="gdEzaW_bubble pi-queuedMessageBubble"><div class="_text_1pfhk_1 pi-queuedMessageText">${esc(entry.message)}</div></div>
839
+ <div class="pi-queuedMessageMeta">
840
+ <span class="pi-queuedMessageLabel">${label}</span>
841
+ <button type="button" class="pi-queuedMessageWithdraw" data-action="withdraw-queued-message" data-queue-kind="${entry.kind}" data-queue-index="${entry.index}" aria-label="撤回并重新编辑这条消息" title="撤回并重新编辑" ${busy ? "disabled data-loading=true" : ""}>${ICONS.close}</button>
842
+ </div>
843
+ </div></div>
844
+ </div>`;
845
+ }
846
+
847
+ function messageFlowParts() {
848
+ const snap = currentSnapshot();
849
+ const messages = snap.messages || [];
850
+ const dialog = snap.dialogs?.[0];
851
+ const streaming = Boolean(snap.session?.streaming);
852
+ const waitingForUser = Boolean(snap.session?.waitingForUser || snap.dialogs?.length);
853
+ const loading = S.loadingSession === S.currentSessionId;
854
+ const connecting = loading || ((S.pendingSends.get(S.currentSessionId) || 0) > 0 && !streaming);
855
+ let conversationNumber = 0;
856
+ const parts = messages.map((message, index) => {
857
+ const key = `message:${index}`;
858
+ const revision = flowRevision(message);
859
+ const anchor = message.kind === "user"
860
+ ? ` data-conversation-anchor="${++conversationNumber}"`
861
+ : "";
862
+ return {
863
+ key,
864
+ revision,
865
+ render: () => `<div class="pi-messageGroup" data-flow-key="${key}" data-flow-revision="${revision}"${anchor}>${renderMessage(message, index)}</div>`,
866
+ };
867
+ });
868
+ for (const entry of pendingQueueEntries(snap)) {
869
+ const busyKey = queueItemBusyKey(S.currentSessionId, entry.kind, entry.index, entry.message);
870
+ const busy = S.dequeueSessions.has(S.currentSessionId) || S.withdrawingQueueItems.has(busyKey);
871
+ const key = `queue:${entry.kind}:${entry.index}:${flowRevision(entry.message)}`;
872
+ const revision = flowRevision({ ...entry, busy });
873
+ parts.push({
874
+ key,
875
+ revision,
876
+ render: () => `<div class="pi-messageGroup pi-queuedMessageGroup" data-flow-key="${key}" data-flow-revision="${revision}">${renderPendingQueueMessage(entry, busy)}</div>`,
877
+ });
878
+ }
879
+ if (parts.length === 0) {
880
+ parts.push({
881
+ key: "status:empty",
882
+ revision: "empty",
883
+ render: () => `<div class="Md3f7G_flowItem" data-flow-key="status:empty" data-flow-revision="empty"><div class="osXY9a_root"></div></div>`,
884
+ });
885
+ }
886
+ if (dialog) {
887
+ const key = `dialog:${dialog.id}`;
888
+ const revision = flowRevision(dialog);
889
+ parts.push({
890
+ key,
891
+ revision,
892
+ render: () => `<div class="Md3f7G_flowItem pi-dialogFlowItem" data-flow-key="${esc(key)}" data-flow-revision="${revision}">${renderDialogCard(dialog)}</div>`,
893
+ });
894
+ } else if (waitingForUser) {
895
+ parts.push({
896
+ key: "status:waiting",
897
+ revision: "waiting",
898
+ render: () => `<div class="Md3f7G_flowItem" data-flow-key="status:waiting" data-flow-revision="waiting"><div class="gdEzaW_retryRow"><span class="gdEzaW_retryText">等待你的输入…</span></div></div>`,
899
+ });
900
+ } else if (streaming) {
901
+ parts.push({
902
+ key: "status:streaming",
903
+ revision: "streaming",
904
+ render: () => `<div class="Md3f7G_flowItem" data-flow-key="status:streaming" data-flow-revision="streaming"><div class="gdEzaW_retryRow"><span class="gdEzaW_retryText">正在生成…</span></div></div>`,
905
+ });
906
+ }
907
+ if (connecting) {
908
+ const text = loading ? "正在加载会话…" : "正在启动 Pi…";
909
+ parts.push({
910
+ key: "status:connecting",
911
+ revision: text,
912
+ render: () => `<div class="Md3f7G_flowItem" data-flow-key="status:connecting" data-flow-revision="${text}"><div class="gdEzaW_retryRow"><span class="gdEzaW_retryText">${text}</span></div></div>`,
913
+ });
914
+ }
915
+ return parts;
916
+ }
917
+
918
+ function renderMessageFlow() {
919
+ const flow = messageFlowParts().map((part) => part.render()).join("");
920
+ return `<div class="Md3f7G_root"><div class="Md3f7G_scroll"><div class="Md3f7G_column" data-chat-flow="">${flow}</div></div></div>`;
921
+ }
922
+
923
+ function conversationTurns() {
924
+ return (currentSnapshot().messages || [])
925
+ .map((message, messageIndex) => ({ message, messageIndex }))
926
+ .filter(({ message }) => message.kind === "user")
927
+ .map(({ message, messageIndex }, index) => ({
928
+ number: index + 1,
929
+ key: `message:${messageIndex}`,
930
+ text: String(message.text || "").trim() || "(空消息)",
931
+ timestamp: message.timestamp,
932
+ }));
933
+ }
934
+
935
+ function conversationPreviewText(text, limit = 600) {
936
+ const value = String(text || "");
937
+ if (value.length <= limit) return value;
938
+ return `${value.slice(0, limit).trimEnd()}…`;
939
+ }
940
+
941
+ function renderConversationNavigator() {
942
+ const turns = conversationTurns();
943
+ if (turns.length < 2) return "";
944
+ const revision = flowRevision(turns.map(({ key, text, timestamp }) => ({ key, text, timestamp })));
945
+ const markers = turns.map((turn, index) => {
946
+ const fallbackPosition = turns.length === 1 ? 50 : (index / (turns.length - 1)) * 100;
947
+ const time = clockLabel(turn.timestamp);
948
+ const preview = conversationPreviewText(turn.text);
949
+ const label = `跳到第 ${turn.number} 次对话:${preview.replace(/\s+/g, " ").slice(0, 80)}`;
950
+ return `<button type="button" class="pi-turnNavMarker" data-action="jump-conversation" data-turn-target="${esc(turn.key)}" data-turn-number="${turn.number}" style="--turn-position:${fallbackPosition.toFixed(3)}%" aria-label="${esc(label)}">
951
+ <span class="pi-turnNavLine" aria-hidden="true"></span>
952
+ <span class="pi-turnTooltip" role="tooltip">
953
+ <span class="pi-turnTooltipMeta"><strong>第 ${turn.number} 次对话</strong>${time ? `<span>${esc(time)}</span>` : ""}</span>
954
+ <span class="pi-turnTooltipText">${esc(preview)}</span>
955
+ </span>
956
+ </button>`;
957
+ }).join("");
958
+ return `<nav class="pi-turnNav" data-conversation-nav="" data-turn-nav-revision="${revision}" aria-label="用户对话导航">
959
+ <div class="pi-turnNavTrack">${markers}</div>
960
+ </nav>`;
961
+ }
962
+
963
+ function renderScrollToBottomButton() {
964
+ return `<button type="button" class="pi-scrollBottom" data-action="scroll-bottom" data-scroll-bottom="" aria-label="回到对话底部" title="回到对话底部">${ICONS.down}</button>`;
965
+ }
966
+
967
+ function reconcileMessageFlow(activeView) {
968
+ const currentColumn = $("[data-chat-flow]", activeView);
969
+ if (!currentColumn) {
970
+ activeView.replaceChildren(el(renderMessageFlow()));
971
+ return;
972
+ }
973
+ const existingByKey = new Map(
974
+ [...currentColumn.children].map((node) => [node.dataset.flowKey, node]),
975
+ );
976
+ const retained = new Set();
977
+ let cursor = currentColumn.firstElementChild;
978
+ for (const part of messageFlowParts()) {
979
+ const existing = existingByKey.get(part.key);
980
+ const node = existing?.dataset.flowRevision === part.revision ? existing : el(part.render());
981
+ retained.add(node);
982
+ if (node === cursor) cursor = cursor.nextElementSibling;
983
+ else currentColumn.insertBefore(node, cursor);
984
+ }
985
+ for (const node of [...currentColumn.children]) {
986
+ if (!retained.has(node)) node.remove();
987
+ }
988
+ }
989
+
990
+ function reconcileConversationNavigator(stage) {
991
+ const current = $("[data-conversation-nav]", stage);
992
+ const html = renderConversationNavigator();
993
+ if (!html) {
994
+ current?.remove();
995
+ return;
996
+ }
997
+ const next = el(html);
998
+ if (current?.dataset.turnNavRevision === next.dataset.turnNavRevision) return;
999
+ if (current) current.replaceWith(next);
1000
+ else stage.append(next);
1001
+ }
1002
+
1003
+ let conversationNavFrame = 0;
1004
+ let conversationNavScroll = null;
1005
+ let conversationNavObserver = null;
1006
+
1007
+ function conversationTargetTop(scroll, target) {
1008
+ const scrollRect = scroll.getBoundingClientRect();
1009
+ const targetRect = target.getBoundingClientRect();
1010
+ return targetRect.top - scrollRect.top + scroll.scrollTop;
1011
+ }
1012
+
1013
+ function updateConversationNavigator() {
1014
+ const scroll = $("[data-conversation-scroll]");
1015
+ if (!scroll) return;
1016
+ const bottomButton = $("[data-scroll-bottom]");
1017
+ const distanceFromBottom = Math.max(0, scroll.scrollHeight - scroll.clientHeight - scroll.scrollTop);
1018
+ bottomButton?.toggleAttribute("data-visible", distanceFromBottom > 120);
1019
+ const nav = $("[data-conversation-nav]");
1020
+ if (!nav) return;
1021
+ const markers = $$(".pi-turnNavMarker", nav);
1022
+ const groupsByKey = new Map(
1023
+ $$('[data-conversation-anchor]', scroll).map((node) => [node.dataset.flowKey, node]),
1024
+ );
1025
+ const contentHeight = Math.max(1, scroll.scrollHeight);
1026
+ const activationLine = scroll.scrollTop + Math.min(scroll.clientHeight * 0.32, 200);
1027
+ let activeMarker = markers[0] || null;
1028
+
1029
+ for (const marker of markers) {
1030
+ const group = groupsByKey.get(marker.dataset.turnTarget);
1031
+ const target = group?.querySelector('[data-chat-flow-kind="user"]');
1032
+ if (!target) continue;
1033
+ const top = conversationTargetTop(scroll, target);
1034
+ const position = Math.min(0.985, Math.max(0.015, top / contentHeight));
1035
+ marker.style.setProperty("--turn-position", `${(position * 100).toFixed(3)}%`);
1036
+ if (position < 0.13) marker.dataset.tooltipEdge = "top";
1037
+ else if (position > 0.87) marker.dataset.tooltipEdge = "bottom";
1038
+ else delete marker.dataset.tooltipEdge;
1039
+ if (top <= activationLine) activeMarker = marker;
1040
+ }
1041
+
1042
+ for (const marker of markers) {
1043
+ const active = marker === activeMarker;
1044
+ marker.classList.toggle("pi-turnNavMarkerActive", active);
1045
+ if (active) marker.setAttribute("aria-current", "step");
1046
+ else marker.removeAttribute("aria-current");
1047
+ }
1048
+ }
1049
+
1050
+ function scheduleConversationNavigatorLayout() {
1051
+ if (conversationNavFrame) return;
1052
+ conversationNavFrame = requestAnimationFrame(() => {
1053
+ conversationNavFrame = 0;
1054
+ updateConversationNavigator();
1055
+ });
1056
+ }
1057
+
1058
+ function bindConversationNavigator() {
1059
+ const scroll = $("[data-conversation-scroll]");
1060
+ const nav = $("[data-conversation-nav]");
1061
+ const bottomButton = $("[data-scroll-bottom]");
1062
+ const nextScroll = nav || bottomButton ? scroll : null;
1063
+ if (conversationNavScroll !== nextScroll) {
1064
+ conversationNavScroll?.removeEventListener("scroll", scheduleConversationNavigatorLayout);
1065
+ conversationNavScroll = nextScroll;
1066
+ conversationNavScroll?.addEventListener("scroll", scheduleConversationNavigatorLayout, { passive: true });
1067
+ }
1068
+ conversationNavObserver?.disconnect();
1069
+ conversationNavObserver = null;
1070
+ if (!nextScroll) return;
1071
+ if (typeof ResizeObserver === "function") {
1072
+ conversationNavObserver = new ResizeObserver(scheduleConversationNavigatorLayout);
1073
+ conversationNavObserver.observe(scroll);
1074
+ const column = $("[data-chat-flow]", scroll);
1075
+ if (column) conversationNavObserver.observe(column);
1076
+ }
1077
+ scheduleConversationNavigatorLayout();
1078
+ }
1079
+
1080
+ function scrollConversationToBottom() {
1081
+ const scroll = $("[data-conversation-scroll]");
1082
+ if (!scroll) return;
1083
+ scroll.scrollTo({
1084
+ top: scroll.scrollHeight,
1085
+ behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth",
1086
+ });
1087
+ scheduleConversationNavigatorLayout();
1088
+ }
1089
+
1090
+ function jumpToConversationTurn(key) {
1091
+ const scroll = $("[data-conversation-scroll]");
1092
+ const group = scroll && $$('[data-conversation-anchor]', scroll)
1093
+ .find((node) => node.dataset.flowKey === key);
1094
+ const target = group?.querySelector('[data-chat-flow-kind="user"]');
1095
+ if (!scroll || !target) return;
1096
+ const top = Math.max(0, conversationTargetTop(scroll, target) - 20);
1097
+ scroll.scrollTo({
1098
+ top,
1099
+ behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth",
1100
+ });
1101
+ group.classList.remove("pi-turnTargetFlash");
1102
+ requestAnimationFrame(() => group.classList.add("pi-turnTargetFlash"));
1103
+ setTimeout(() => group.classList.remove("pi-turnTargetFlash"), 900);
1104
+ }
1105
+
1106
+ function patchComposerState(activeComposer) {
1107
+ const nextComposer = el(renderComposer(false));
1108
+ const replaceSlot = (selector) => {
1109
+ const current = $(selector, activeComposer);
1110
+ const next = $(selector, nextComposer);
1111
+ if (current && next && !current.isEqualNode(next)) current.replaceWith(next);
1112
+ else if (current && !next) current.remove();
1113
+ };
1114
+ replaceSlot(".pi-modelControl");
1115
+ replaceSlot(".pi-thinkingControl");
1116
+ replaceSlot(".JObwrW_root");
1117
+ replaceSlot(".pi-composerActions");
1118
+ const commandButton = $('[data-action="commands"]', activeComposer);
1119
+ const nextCommandButton = $('[data-action="commands"]', nextComposer);
1120
+ if (commandButton && nextCommandButton) commandButton.disabled = nextCommandButton.disabled;
1121
+ const imageButton = $('[data-action="paste-image"]', activeComposer);
1122
+ const nextImageButton = $('[data-action="paste-image"]', nextComposer);
1123
+ if (imageButton && nextImageButton) {
1124
+ imageButton.disabled = nextImageButton.disabled;
1125
+ imageButton.title = nextImageButton.title;
1126
+ }
1127
+ const currentTray = $(".pi-attachmentTray", activeComposer);
1128
+ const nextTray = $(".pi-attachmentTray", nextComposer);
1129
+ if (currentTray && nextTray && !currentTray.isEqualNode(nextTray)) currentTray.replaceWith(nextTray);
1130
+ else if (currentTray && !nextTray) currentTray.remove();
1131
+ else if (!currentTray && nextTray) {
1132
+ const card = $("[data-composer-card]", activeComposer);
1133
+ const inputScroll = $("[data-input-scroll]", activeComposer);
1134
+ if (card && inputScroll) card.insertBefore(nextTray, inputScroll);
1135
+ }
1136
+ const currentInput = $("#composerInput", activeComposer);
1137
+ const nextInput = $("#composerInput", nextComposer);
1138
+ if (currentInput && nextInput) {
1139
+ currentInput.disabled = nextInput.disabled;
1140
+ currentInput.placeholder = nextInput.placeholder;
1141
+ }
1142
+ const currentStats = $(".FJxK0a_root", activeComposer);
1143
+ const nextStats = $(".FJxK0a_root", nextComposer);
1144
+ if (currentStats && nextStats && !currentStats.isEqualNode(nextStats)) currentStats.replaceWith(nextStats);
1145
+ else if (currentStats && !nextStats) currentStats.remove();
1146
+ else if (!currentStats && nextStats) $(".wSkVaW_composerStack", activeComposer)?.append(nextStats);
1147
+ }
1148
+
1149
+ function renderActive() {
1150
+ if (S.conversationTab === "history") return renderHistoryView(S.historyView);
1151
+ return `
1152
+ <div class="wSkVaW_scrollBody">
1153
+ <div class="pi-conversationStage">
1154
+ <div class="wSkVaW_viewArea" data-conversation-scroll="">${renderMessageFlow()}</div>
1155
+ ${renderConversationNavigator()}
1156
+ ${renderScrollToBottomButton()}
1157
+ </div>
1158
+ <div class="wSkVaW_composerSeat" data-composer-seat="">${renderComposer(false)}</div>
1159
+ </div>`;
1160
+ }
1161
+
1162
+ let conversationRenderGeneration = 0;
1163
+ function renderConversation() {
1164
+ const renderGeneration = ++conversationRenderGeneration;
1165
+ const mount = $("#conversationMount");
1166
+ if (!mount) return;
1167
+ const previousInput = $("#composerInput");
1168
+ const previousSessionId = previousInput?.dataset.sessionId || "";
1169
+ const inputWasFocused = document.activeElement === previousInput;
1170
+ const selectionStart = previousInput?.selectionStart ?? null;
1171
+ const selectionEnd = previousInput?.selectionEnd ?? null;
1172
+ const previousScroll = $("[data-conversation-scroll]", mount);
1173
+ const distanceFromBottom = previousScroll
1174
+ ? Math.max(0, previousScroll.scrollHeight - previousScroll.clientHeight - previousScroll.scrollTop)
1175
+ : 0;
1176
+ const scrollState = previousScroll && previousSessionId === S.currentSessionId
1177
+ ? {
1178
+ top: previousScroll.scrollTop,
1179
+ distanceFromBottom,
1180
+ nearBottom: distanceFromBottom <= 96,
1181
+ }
1182
+ : null;
1183
+ const activeRoot = $('.wSkVaW_root[data-phase="active"]', mount);
1184
+ const activeHeader = activeRoot && $(".wSkVaW_header", activeRoot);
1185
+ const activeStage = activeRoot && $(".pi-conversationStage", activeRoot);
1186
+ const activeView = activeRoot && $(".wSkVaW_viewArea", activeRoot);
1187
+ const activeComposer = activeRoot && $("[data-composer-seat]", activeRoot);
1188
+ const canPatchActive = Boolean(
1189
+ S.currentSessionId
1190
+ && S.conversationTab === "conversation"
1191
+ && previousSessionId === S.currentSessionId
1192
+ && activeHeader
1193
+ && activeStage
1194
+ && activeView
1195
+ && activeComposer,
1196
+ );
1197
+
1198
+ let patchedActive = false;
1199
+ if (!S.currentSessionId) {
1200
+ mount.innerHTML = `<div class="wSkVaW_root" data-phase="hero">${renderHeaderPlaceholder()}${renderHero()}</div>`;
1201
+ } else if (canPatchActive) {
1202
+ const nextHeader = el(renderHeader());
1203
+ if (!activeHeader.isEqualNode(nextHeader)) activeHeader.replaceWith(nextHeader);
1204
+ reconcileMessageFlow(activeView);
1205
+ reconcileConversationNavigator(activeStage);
1206
+ patchComposerState(activeComposer);
1207
+ patchedActive = true;
1208
+ } else {
1209
+ mount.innerHTML = `<div class="wSkVaW_root" data-phase="active">${renderHeader()}${renderActive()}</div>`;
1210
+ }
1211
+ if (!patchedActive) {
1212
+ const nextInput = $("#composerInput");
1213
+ if (previousInput && nextInput && previousInput.dataset.sessionId === nextInput.dataset.sessionId) {
1214
+ nextInput.replaceWith(previousInput);
1215
+ }
1216
+ restoreDraft();
1217
+ bindComposerInput();
1218
+ if (inputWasFocused) {
1219
+ const input = $("#composerInput");
1220
+ input?.focus({ preventScroll: true });
1221
+ if (input && selectionStart != null && selectionEnd != null) {
1222
+ input.setSelectionRange(selectionStart, selectionEnd);
1223
+ }
1224
+ }
1225
+ }
1226
+ bindConversationNavigator();
1227
+ const renderedSessionId = S.currentSessionId;
1228
+ const restoreScroll = () => {
1229
+ const scroll = $("[data-conversation-scroll]", mount);
1230
+ if (!scroll || S.currentSessionId !== renderedSessionId || renderGeneration !== conversationRenderGeneration) return;
1231
+ const maxScrollTop = Math.max(0, scroll.scrollHeight - scroll.clientHeight);
1232
+ if (!scrollState) {
1233
+ scroll.scrollTop = maxScrollTop;
1234
+ } else {
1235
+ scroll.scrollTop = scrollState.nearBottom
1236
+ ? Math.max(0, maxScrollTop - scrollState.distanceFromBottom)
1237
+ : Math.min(scrollState.top, maxScrollTop);
1238
+ }
1239
+ scheduleConversationNavigatorLayout();
1240
+ };
1241
+ if (scrollState || (S.currentSessionId && S.conversationTab === "conversation")) {
1242
+ restoreScroll();
1243
+ requestAnimationFrame(restoreScroll);
1244
+ }
1245
+ }
1246
+
1247
+ function renderHeaderPlaceholder() {
1248
+ return `<header class="wSkVaW_header wSkVaW_headerHidden" aria-hidden="true"></header>`;
1249
+ }
1250
+
1251
+ function renderLoading() {
1252
+ return `<div class="wSkVaW_scrollBody"><div class="wSkVaW_composerSeat"><div class="wSkVaW_composerStack wSkVaW_composerHero"><div class="pXSMma_root"><div class="pXSMma_stack"><div class="pXSMma_headline"><span class="pXSMma_fishHitbox">${ICONS.logo}</span><span class="pXSMma_headlineText">正在启动 pi…</span></div></div></div></div></div></div>`;
1253
+ }
1254
+
1255
+ function compactionReasonLabel(reason) {
1256
+ return { manual: "手动触发", threshold: "达到上下文阈值", overflow: "上下文溢出恢复" }[reason] || "上下文维护";
1257
+ }
1258
+
1259
+ function compactionPreview(text) {
1260
+ return String(text || "")
1261
+ .replace(/<\/?(?:read|modified)-files>/gi, "")
1262
+ .replace(/^#{1,6}\s+/gm, "")
1263
+ .replace(/^\s*[-*]\s+(?:\[[ xX]\]\s*)?/gm, "")
1264
+ .replace(/[*_`]/g, "")
1265
+ .split(/\n+/)
1266
+ .map((line) => line.trim())
1267
+ .filter(Boolean)
1268
+ .slice(0, 2)
1269
+ .join(" · ")
1270
+ .slice(0, 180);
1271
+ }
1272
+
1273
+ function compactionUsageSummary(usage) {
1274
+ if (!usage || typeof usage !== "object") return null;
1275
+ const totalTokens = Number.isFinite(usage.totalTokens)
1276
+ ? usage.totalTokens
1277
+ : [usage.input, usage.output, usage.cacheRead, usage.cacheWrite]
1278
+ .filter(Number.isFinite)
1279
+ .reduce((total, value) => total + value, 0);
1280
+ const rawCost = typeof usage.cost === "number" ? usage.cost : usage.cost?.total;
1281
+ const cost = Number.isFinite(rawCost) ? rawCost : null;
1282
+ if (!totalTokens && cost == null) return null;
1283
+ return {
1284
+ totalTokens,
1285
+ cost,
1286
+ text: [`摘要用量 ${fmtNum(totalTokens)} tok`, cost == null ? "" : `$${cost.toFixed(4)}`].filter(Boolean).join(" · "),
1287
+ };
1288
+ }
1289
+
1290
+ function renderCompactionMessage(msg) {
1291
+ const status = msg.status || "complete";
1292
+ const compactionKey = `compaction:${msg.id || msg.timestamp || msg.tokensBefore || "context"}`;
1293
+ const detailText = status === "complete" ? msg.text || "" : msg.errorMessage || "";
1294
+ const expandable = status !== "running" && detailText !== "";
1295
+ const expanded = expandable && S.expandedCompactions.has(compactionKey);
1296
+ const reason = compactionReasonLabel(msg.reason);
1297
+ const title = status === "running"
1298
+ ? "正在压缩上下文"
1299
+ : status === "complete"
1300
+ ? "上下文已压缩"
1301
+ : status === "aborted" ? "上下文压缩已取消" : "上下文压缩失败";
1302
+ const metrics = [];
1303
+ const usage = compactionUsageSummary(msg.usage);
1304
+ if (status === "running") {
1305
+ metrics.push(reason, "正在生成会话摘要,请稍候");
1306
+ } else if (status === "complete") {
1307
+ if (Number.isFinite(msg.tokensBefore) && Number.isFinite(msg.estimatedTokensAfter)) {
1308
+ metrics.push(`${fmtNum(msg.tokensBefore)} → 约 ${fmtNum(msg.estimatedTokensAfter)} tok`);
1309
+ } else if (Number.isFinite(msg.tokensBefore)) {
1310
+ metrics.push(`压缩前 ${fmtNum(msg.tokensBefore)} tok`);
1311
+ }
1312
+ metrics.push(reason);
1313
+ if (usage) metrics.push(usage.text);
1314
+ if (msg.willRetry) metrics.push("压缩后自动重试");
1315
+ const preview = compactionPreview(msg.text);
1316
+ if (preview) metrics.push(preview);
1317
+ } else {
1318
+ metrics.push(reason, msg.errorMessage || "未返回错误详情");
1319
+ if (msg.willRetry) metrics.push("原请求仍会重试");
1320
+ }
1321
+ const meta = [];
1322
+ if (Number.isFinite(msg.tokensBefore)) meta.push(`<span>压缩前 <strong>${esc(fmtNum(msg.tokensBefore))}</strong> tok</span>`);
1323
+ if (Number.isFinite(msg.estimatedTokensAfter)) meta.push(`<span>压缩后约 <strong>${esc(fmtNum(msg.estimatedTokensAfter))}</strong> tok</span>`);
1324
+ if (usage) meta.push(`<span>摘要实际消耗 <strong>${esc(fmtNum(usage.totalTokens))}</strong> tok${usage.cost == null ? "" : ` · $${esc(usage.cost.toFixed(4))}`}</span>`);
1325
+ if (msg.reason) meta.push(`<span>${esc(reason)}</span>`);
1326
+ const detail = expandable ? `<div class="gdEzaW_compactionBody pi-compactionBody" ${expanded ? "" : "hidden"}>
1327
+ ${meta.length ? `<div class="pi-compactionMeta">${meta.join("")}</div>` : ""}
1328
+ ${status === "complete"
1329
+ ? `<div class="pi-compactionBodyTitle">会话摘要</div><div class="pi-compactionMarkdown _markdown_1nba0_5">${markdown(detailText)}</div>`
1330
+ : `<div class="pi-compactionErrorDetail">${esc(detailText)}</div>`}
1331
+ </div>` : "";
1332
+ return `<div class="Md3f7G_flowItem" data-chat-flow-kind="compaction">
1333
+ <div class="gdEzaW_compactionRow pi-compactionCard" data-state="${esc(status)}" data-compaction-key="${esc(compactionKey)}" role="status" aria-live="polite">
1334
+ <button type="button" class="gdEzaW_compactionButton" ${expandable ? `data-action="toggle-compaction" aria-expanded="${expanded}"` : "disabled"}>
1335
+ <span class="gdEzaW_compactionLeading">
1336
+ <span class="gdEzaW_compactionContextIcon">${ICONS.compact}</span>
1337
+ <span class="gdEzaW_compactionDisclosureIcon">${ICONS.chevron}</span>
1338
+ </span>
1339
+ <span class="gdEzaW_compactionTitle">${esc(title)}</span>
1340
+ <span class="gdEzaW_compactionSep"></span>
1341
+ <span class="gdEzaW_compactionSummary">${esc(metrics.filter(Boolean).join(" · "))}</span>
1342
+ ${status === "complete" ? `<span class="pi-compactionBadge">已完成</span>` : ""}
1343
+ </button>
1344
+ ${detail}
1345
+ </div>
1346
+ </div>`;
1347
+ }
1348
+
1349
+ function renderAssistantError(msg) {
1350
+ const message = msg.errorMessage || "模型调用失败,但 Pi 未返回错误详情";
1351
+ const source = [msg.provider, msg.model].filter(Boolean).join(" / ");
1352
+ return `<div class="Md3f7G_flowItem" data-chat-flow-kind="assistant-error">
1353
+ <div class="pi-assistantError" role="alert" aria-live="assertive">
1354
+ <span class="pi-assistantErrorIcon" aria-hidden="true">!</span>
1355
+ <div class="pi-assistantErrorCopy">
1356
+ <div class="pi-assistantErrorTitle">生成失败</div>
1357
+ <div class="pi-assistantErrorMessage">${esc(message)}</div>
1358
+ ${source ? `<div class="pi-assistantErrorMeta">${esc(source)}</div>` : ""}
1359
+ </div>
1360
+ </div>
1361
+ </div>`;
1362
+ }
1363
+
1364
+ function assistantMessageText(msg) {
1365
+ return (msg?.blocks || [])
1366
+ .filter((block) => block.type === "text")
1367
+ .map((block) => block.text || "")
1368
+ .join("\n")
1369
+ .trim();
1370
+ }
1371
+
1372
+ function renderMessage(msg, messageIndex) {
1373
+ if (msg.kind === "compaction") return renderCompactionMessage(msg);
1374
+ if (msg.kind === "user") {
1375
+ return `<div class="Md3f7G_flowItem" data-chat-flow-kind="user">
1376
+ <div class="gdEzaW_userRow" data-time-hover-root="true"><div class="gdEzaW_userStack">
1377
+ <div class="gdEzaW_bubble"><div class="_text_1pfhk_1">${esc(msg.text || "")}</div></div>
1378
+ </div>
1379
+ <div class="p-xYUq_actions"><span class="p-xYUq_timeStart">${esc(clockLabel(msg.timestamp))}</span><button type="button" class="p-xYUq_action" aria-label="复制" data-action="copy-text" data-text="${esc(msg.text || "")}">${ICONS.copy}</button></div>
1380
+ </div>
1381
+ </div>`;
1382
+ }
1383
+ if (msg.kind === "assistant") {
1384
+ const blocks = msg.blocks || [];
1385
+ const content = blocks.map((b, i) => renderBlock(b, msg, i)).join("");
1386
+ const error = msg.isError || msg.stopReason === "error" || msg.errorMessage
1387
+ ? renderAssistantError(msg)
1388
+ : "";
1389
+ const copyText = assistantMessageText(msg);
1390
+ const hasToolCall = blocks.some((block) => block.type === "toolCall");
1391
+ const actions = copyText && !hasToolCall && !msg.streaming
1392
+ ? `<div class="Md3f7G_flowItem pi-assistantMessageActions" data-chat-flow-kind="assistant-actions"><button type="button" class="p-xYUq_action pi-assistantCopyButton" aria-label="复制本条 AI 回复" title="复制本条 AI 回复" data-action="copy-assistant-message" data-message-index="${messageIndex}">${ICONS.copy}</button></div>`
1393
+ : "";
1394
+ return `<div class="pi-assistantMessageGroup">${content}${error}${actions}</div>`;
1395
+ }
1396
+ if (msg.kind === "toolResult") {
1397
+ const output = msg.text || "";
1398
+ const lineCount = output ? output.split("\n").length : 0;
1399
+ const hasDetail = output !== "";
1400
+ const toolKey = `result:${msg.toolCallId || msg.timestamp || msg.toolName || "tool"}`;
1401
+ const expanded = hasDetail && S.expandedTools.has(toolKey);
1402
+ return `<div class="Md3f7G_flowItem" data-chat-flow-kind="tool-result"><div class="ztWv_q_callRow" data-tool-key="${esc(toolKey)}"><div class="CY-8Ka_card">
1403
+ <div class="CY-8Ka_root" data-state="${msg.isError ? "error" : "ok"}" ${hasDetail ? `role="button" data-expandable aria-expanded="${expanded}" data-action="toggle-tool"` : ""}>
1404
+ <span class="CY-8Ka_leading">${ICONS.bash}</span><span class="CY-8Ka_title">${esc(msg.toolName || "Tool")}</span><span class="CY-8Ka_sep"></span>
1405
+ <span class="CY-8Ka_summary ${msg.isError ? "CY-8Ka_errorSummary" : ""}">${msg.isError ? "失败" : "完成"}${lineCount ? ` · ${lineCount} 行输出` : ""}</span>
1406
+ </div>
1407
+ ${hasDetail ? `<div class="Sxvs8a_root" ${expanded ? "" : "hidden"}><div class="Sxvs8a_body"><pre class="Y0dWHa_payload ${msg.isError ? "Y0dWHa_payloadError" : ""}">${esc(output)}</pre></div></div>` : ""}
1408
+ </div></div></div>`;
1409
+ }
1410
+ if (msg.kind === "bashExecution") {
1411
+ const output = msg.output || "";
1412
+ const lineCount = output ? output.split("\n").length : 0;
1413
+ const hasDetail = output !== "";
1414
+ const toolKey = `bash:${msg.timestamp || msg.command || "command"}`;
1415
+ const expanded = hasDetail && S.expandedTools.has(toolKey);
1416
+ return `<div class="Md3f7G_flowItem" data-chat-flow-kind="bash"><div class="ztWv_q_callRow" data-tool-key="${esc(toolKey)}"><div class="CY-8Ka_card"><div class="CY-8Ka_root" data-sample="bash" data-variant="bash" ${hasDetail ? `role="button" data-expandable aria-expanded="${expanded}" data-action="toggle-tool"` : ""}><span class="CY-8Ka_leading">${ICONS.bash}</span><span class="CY-8Ka_title">Bash</span><span class="CY-8Ka_sep"></span><span class="CY-8Ka_summary">${esc(msg.command || "")}${lineCount ? ` · ${lineCount} 行输出` : ""}</span></div>
1417
+ ${hasDetail ? `<div class="Sxvs8a_root" ${expanded ? "" : "hidden"}><div class="Sxvs8a_body"><pre class="Y0dWHa_payload">${esc(output)}</pre></div></div>` : ""}
1418
+ </div></div></div>`;
1419
+ }
1420
+ return "";
1421
+ }
1422
+
1423
+ function renderBlock(block, msg, idx) {
1424
+ if (block.type === "thinking") {
1425
+ const summary = (block.thinking || "").replace(/\s+/g, " ").slice(0, 160);
1426
+ return `<div class="Md3f7G_flowItem" data-chat-flow-kind="assistant-step">
1427
+ <div class="Sxvs8a_root"><div class="Sxvs8a_body">
1428
+ <div class="QWLzlG_root" data-variant="think" data-state="ok">
1429
+ <div class="_root_9cl6j_3"><div class="_row_9cl6j_10 QWLzlG_row" role="button" aria-expanded="false" data-action="toggle-thinking">
1430
+ <span class="_leading_9cl6j_23 QWLzlG_leading"><span class="_iconIdle_9cl6j_42">${ICONS.think}</span></span>
1431
+ <span class="_title_9cl6j_64 QWLzlG_title">Think</span><span class="QWLzlG_separator"></span>
1432
+ <span class="QWLzlG_summary">${esc(summary)}</span>
1433
+ </div><div class="QWLzlG_detail" hidden><div class="Y0dWHa_thinkingQuote"><pre class="Y0dWHa_payload">${esc(block.thinking || "")}</pre></div></div></div>
1434
+ </div>
1435
+ </div></div>
1436
+ </div>`;
1437
+ }
1438
+ if (block.type === "toolCall") {
1439
+ const result = block.result;
1440
+ const state = result ? (result.isError ? "error" : "ok") : block.running ? "running" : "ok";
1441
+ const argsText = block.argumentsText || "";
1442
+ const resultText = result?.content || "";
1443
+ const resultLines = resultText ? resultText.split("\n").length : 0;
1444
+ const hasDetail = argsText !== "" || resultText !== "";
1445
+ const resultHint = resultLines ? `${resultLines} 行输出` : state === "running" ? "运行中" : "";
1446
+ const summary = [argsText, resultHint].filter(Boolean).join(" · ");
1447
+ const toolKey = `call:${block.id || `${block.name || "tool"}:${idx}`}`;
1448
+ const expanded = hasDetail && S.expandedTools.has(toolKey);
1449
+ return `<div class="Md3f7G_flowItem" data-chat-flow-kind="tool-call">
1450
+ <div class="ztWv_q_callRow" data-call-id="${esc(block.id || "")}" data-tool-key="${esc(toolKey)}">
1451
+ <div class="CY-8Ka_card"><div class="CY-8Ka_root" data-sample="${esc(block.name || "tool")}" data-variant="${esc(block.name || "tool")}" data-state="${state}" ${hasDetail ? `role="button" data-expandable aria-expanded="${expanded}" data-action="toggle-tool"` : ""}>
1452
+ <span class="CY-8Ka_leading"><span class="CY-8Ka_iconIdle">${ICONS.bash}</span></span>
1453
+ <span class="CY-8Ka_visuallyHidden">${state === "error" ? "失败" : state === "running" ? "运行中" : "完成"}</span>
1454
+ <span class="CY-8Ka_title">${esc(block.name || "Tool")}</span><span class="CY-8Ka_sep"></span>
1455
+ <span class="CY-8Ka_summary ${result?.isError ? "CY-8Ka_errorSummary" : ""}">${esc(summary)}</span>
1456
+ </div>
1457
+ ${hasDetail ? `<div class="Sxvs8a_root" ${expanded ? "" : "hidden"}><div class="Sxvs8a_body">
1458
+ ${argsText ? `<pre class="Y0dWHa_payload">${esc(argsText)}</pre>` : ""}
1459
+ ${resultText ? `<pre class="Y0dWHa_payload ${result?.isError ? "Y0dWHa_payloadError" : ""}">${esc(resultText)}</pre>` : ""}
1460
+ </div></div>` : ""}
1461
+ </div>
1462
+ </div>
1463
+ </div>`;
1464
+ }
1465
+ if (block.type === "text") {
1466
+ return `<div class="Md3f7G_flowItem" data-chat-flow-kind="assistant-text">
1467
+ <div class="Sxvs8a_root"><div class="Sxvs8a_body"><div class="Y0dWHa_assistantOutput Y0dWHa_markdownPayload"><div class="_markdown_1nba0_5">${markdown(block.text || "")}</div></div></div></div>
1468
+ </div>`;
1469
+ }
1470
+ return "";
1471
+ }
1472
+
1473
+ function splitMarkdownTableRow(line) {
1474
+ const text = String(line ?? "").trim();
1475
+ const cells = [];
1476
+ let cell = "";
1477
+ let codeFence = 0;
1478
+ let separators = 0;
1479
+ for (let i = 0; i < text.length; i += 1) {
1480
+ const char = text[i];
1481
+ if (char === "\\" && text[i + 1] === "|") {
1482
+ cell += "|";
1483
+ i += 1;
1484
+ continue;
1485
+ }
1486
+ if (char === "`") {
1487
+ let count = 1;
1488
+ while (text[i + count] === "`") count += 1;
1489
+ codeFence = codeFence === 0 ? count : codeFence === count ? 0 : codeFence;
1490
+ cell += "`".repeat(count);
1491
+ i += count - 1;
1492
+ continue;
1493
+ }
1494
+ if (char === "|" && codeFence === 0) {
1495
+ cells.push(cell.trim());
1496
+ cell = "";
1497
+ separators += 1;
1498
+ continue;
1499
+ }
1500
+ cell += char;
1501
+ }
1502
+ cells.push(cell.trim());
1503
+ if (cells.length > 1 && cells[0] === "") cells.shift();
1504
+ if (cells.length > 1 && cells[cells.length - 1] === "") cells.pop();
1505
+ return { cells, separators };
1506
+ }
1507
+
1508
+ function markdownTableStart(lines, index) {
1509
+ if (index + 1 >= lines.length) return null;
1510
+ const header = splitMarkdownTableRow(lines[index]);
1511
+ if (header.separators === 0 || header.cells.length === 0) return null;
1512
+ const delimiter = splitMarkdownTableRow(lines[index + 1]);
1513
+ if (delimiter.separators === 0 || delimiter.cells.length !== header.cells.length) return null;
1514
+ if (!delimiter.cells.every((cell) => /^:?-{3,}:?$/.test(cell))) return null;
1515
+ const alignments = delimiter.cells.map((cell) => {
1516
+ const left = cell.startsWith(":");
1517
+ const right = cell.endsWith(":");
1518
+ if (left && right) return "center";
1519
+ if (right) return "right";
1520
+ if (left) return "left";
1521
+ return "";
1522
+ });
1523
+ return { headers: header.cells, alignments };
1524
+ }
1525
+
1526
+ function renderMarkdownTable(lines, index, table) {
1527
+ const rows = [];
1528
+ let nextIndex = index + 2;
1529
+ while (nextIndex < lines.length && lines[nextIndex].trim() !== "") {
1530
+ const parsed = splitMarkdownTableRow(lines[nextIndex]);
1531
+ if (parsed.separators === 0) break;
1532
+ const cells = parsed.cells.slice(0, table.headers.length);
1533
+ while (cells.length < table.headers.length) cells.push("");
1534
+ rows.push(cells);
1535
+ nextIndex += 1;
1536
+ }
1537
+ const cellHtml = (tag, value, column) => {
1538
+ const alignment = table.alignments[column];
1539
+ const alignAttribute = alignment ? ` data-align="${alignment}"` : "";
1540
+ return `<${tag}${alignAttribute}>${inlineMarkdown(value)}</${tag}>`;
1541
+ };
1542
+ const head = table.headers.map((cell, column) => cellHtml("th", cell, column)).join("");
1543
+ const body = rows.map((row) => `<tr>${row.map((cell, column) => cellHtml("td", cell, column)).join("")}</tr>`).join("");
1544
+ return {
1545
+ html: `<div class="md-table-wrap"><table><thead><tr>${head}</tr></thead>${body ? `<tbody>${body}</tbody>` : ""}</table></div>`,
1546
+ nextIndex,
1547
+ };
1548
+ }
1549
+
1550
+ function markdown(src) {
1551
+ const lines = String(src ?? "").replace(/\r\n/g, "\n").split("\n");
1552
+ let html = "";
1553
+ let i = 0;
1554
+ while (i < lines.length) {
1555
+ const line = lines[i];
1556
+ if (line.trim().startsWith("```")) {
1557
+ const lang = line.trim().slice(3).trim();
1558
+ const buf = [];
1559
+ i += 1;
1560
+ while (i < lines.length && !lines[i].trim().startsWith("```")) {
1561
+ buf.push(lines[i]);
1562
+ i += 1;
1563
+ }
1564
+ i += 1;
1565
+ const code = buf.join("\n");
1566
+ html += `<pre class="md-code-block"><code>${esc(code)}</code></pre>`;
1567
+ continue;
1568
+ }
1569
+ const table = markdownTableStart(lines, i);
1570
+ if (table) {
1571
+ const rendered = renderMarkdownTable(lines, i, table);
1572
+ html += rendered.html;
1573
+ i = rendered.nextIndex;
1574
+ continue;
1575
+ }
1576
+ if (/^#{1,6}\s+/.test(line)) {
1577
+ const m = line.match(/^(#{1,6})\s+(.*)$/);
1578
+ const level = m[1].length;
1579
+ html += `<h${level}>${inlineMarkdown(m[2])}</h${level}>`;
1580
+ i += 1;
1581
+ continue;
1582
+ }
1583
+ if (/^\s*[-*]\s+/.test(line)) {
1584
+ const items = [];
1585
+ while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
1586
+ items.push(`<li>${inlineMarkdown(lines[i].replace(/^\s*[-*]\s+/, ""))}</li>`);
1587
+ i += 1;
1588
+ }
1589
+ html += `<ul>${items.join("")}</ul>`;
1590
+ continue;
1591
+ }
1592
+ if (/^\s*\d+[.)]\s+/.test(line)) {
1593
+ const items = [];
1594
+ while (i < lines.length && /^\s*\d+[.)]\s+/.test(lines[i])) {
1595
+ items.push(`<li>${inlineMarkdown(lines[i].replace(/^\s*\d+[.)]\s+/, ""))}</li>`);
1596
+ i += 1;
1597
+ }
1598
+ html += `<ol>${items.join("")}</ol>`;
1599
+ continue;
1600
+ }
1601
+ if (/^\s*(---|\*\*\*)\s*$/.test(line)) {
1602
+ html += "<hr/>";
1603
+ i += 1;
1604
+ continue;
1605
+ }
1606
+ if (/^&gt;\s?/.test(line) || /^>\s?/.test(line)) {
1607
+ const buf = [];
1608
+ while (i < lines.length && (/^>\s?/.test(lines[i]) || /^&gt;\s?/.test(lines[i]))) {
1609
+ buf.push(lines[i].replace(/^(&gt;|>)\s?/, ""));
1610
+ i += 1;
1611
+ }
1612
+ html += `<blockquote>${inlineMarkdown(buf.join("\n"))}</blockquote>`;
1613
+ continue;
1614
+ }
1615
+ if (line.trim() === "") {
1616
+ i += 1;
1617
+ continue;
1618
+ }
1619
+ const para = [];
1620
+ while (
1621
+ i < lines.length
1622
+ && lines[i].trim() !== ""
1623
+ && !/^(#{1,6}\s+|```|\s*[-*]\s+|\s*\d+[.)]\s+|\s*(---|\*\*\*)\s*$|>\s?)/.test(lines[i])
1624
+ && !markdownTableStart(lines, i)
1625
+ ) {
1626
+ para.push(lines[i]);
1627
+ i += 1;
1628
+ }
1629
+ html += `<p>${inlineMarkdown(para.join("\n"))}</p>`;
1630
+ }
1631
+ return html;
1632
+ }
1633
+
1634
+ function localFileLink(target) {
1635
+ let value = String(target ?? "").trim();
1636
+ let line = null;
1637
+ const fragment = /#L(\d+)(?:C\d+)?$/i.exec(value);
1638
+ if (fragment) {
1639
+ line = Number(fragment[1]);
1640
+ value = value.slice(0, fragment.index);
1641
+ } else {
1642
+ const location = /:(\d+)(?::\d+)?$/.exec(value);
1643
+ if (location) {
1644
+ line = Number(location[1]);
1645
+ value = value.slice(0, location.index);
1646
+ }
1647
+ }
1648
+ if (/^file:\/\/\//i.test(value)) {
1649
+ try {
1650
+ value = decodeURIComponent(new URL(value).pathname).replace(/^\/([A-Za-z]:[\\/])/, "$1");
1651
+ } catch {
1652
+ return null;
1653
+ }
1654
+ }
1655
+ if (!/^[A-Za-z]:[\\/]/.test(value) && !/^\\\\[^\\]+\\[^\\]+/.test(value)) return null;
1656
+ const href = `/api/fs/view?path=${encodeURIComponent(value)}${line ? `#L${line}` : ""}`;
1657
+ return { path: value, line, href };
1658
+ }
1659
+
1660
+ function inlineMarkdown(src) {
1661
+ const links = [];
1662
+ const withLinkTokens = String(src ?? "").replace(
1663
+ /\[([^\]\n]+)\]\((<[^>\n]+>|[^)\s]+)\)/g,
1664
+ (match, label, wrappedTarget) => {
1665
+ const target = wrappedTarget.startsWith("<") ? wrappedTarget.slice(1, -1) : wrappedTarget;
1666
+ const local = localFileLink(target);
1667
+ if (local) {
1668
+ const location = `${local.path}${local.line ? `:${local.line}` : ""}`;
1669
+ links.push(`<a class="md-local-file" href="${esc(local.href)}" data-file-path="${esc(local.path)}" data-file-line="${local.line || ""}" title="${esc(location)}">${esc(label)}</a>`);
1670
+ } else if (/^https?:\/\//i.test(target)) {
1671
+ links.push(`<a href="${esc(target)}" target="_blank" rel="noreferrer">${esc(label)}</a>`);
1672
+ } else {
1673
+ return match;
1674
+ }
1675
+ return `\u0001${links.length - 1}\u0002`;
1676
+ },
1677
+ );
1678
+ return esc(withLinkTokens)
1679
+ .replace(/`([^`]+)`/g, "<code>$1</code>")
1680
+ .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
1681
+ .replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>")
1682
+ .replace(/\u0001(\d+)\u0002/g, (_, index) => links[Number(index)] || "")
1683
+ .replace(/\n/g, "<br/>");
1684
+ }
1685
+
1686
+ function renderWorkspaceMenu() {
1687
+ const ws = currentWorkspace();
1688
+ return `<div class="_7KE1Ra_menu pi-popover" role="menu"><div class="_7KE1Ra_groups scrollable">
1689
+ ${S.workspaces.map((w) => `<button type="button" role="menuitemradio" aria-checked="${w.id === ws?.id}" class="_7KE1Ra_option ${w.id === ws?.id ? "_7KE1Ra_selected" : ""}" data-action="select-workspace" data-workspace="${esc(w.id)}"><span class="_7KE1Ra_optionCopy"><span class="_7KE1Ra_modelName">${esc(w.title)}</span><span class="_7KE1Ra_modelPath">${esc(shortPath(w.path, 42))}</span></span><span class="_7KE1Ra_check">${w.id === ws?.id ? ICONS.check : ""}</span></button>`).join("")}
1690
+ <button type="button" role="menuitem" class="_7KE1Ra_cell" data-action="add-workspace"><span class="_7KE1Ra_cellLabel">添加工作区</span><span class="_7KE1Ra_cellValue"></span></button>
1691
+ </div></div>`;
1692
+ }
1693
+
1694
+
1695
+ function fuzzyMatchText(query, text) {
1696
+ const normalizedQuery = query.toLowerCase();
1697
+ const normalizedText = text.toLowerCase();
1698
+ const matchQuery = (candidate) => {
1699
+ if (!candidate) return { matches: true, score: 0 };
1700
+ if (candidate.length > normalizedText.length) return { matches: false, score: 0 };
1701
+
1702
+ let queryIndex = 0;
1703
+ let score = 0;
1704
+ let lastMatchIndex = -1;
1705
+ let consecutiveMatches = 0;
1706
+ for (let i = 0; i < normalizedText.length && queryIndex < candidate.length; i += 1) {
1707
+ if (normalizedText[i] !== candidate[queryIndex]) continue;
1708
+ const wordBoundary = i === 0 || /[\s\-_./:]/.test(normalizedText[i - 1]);
1709
+ if (lastMatchIndex === i - 1) {
1710
+ consecutiveMatches += 1;
1711
+ score -= consecutiveMatches * 5;
1712
+ } else {
1713
+ consecutiveMatches = 0;
1714
+ if (lastMatchIndex >= 0) score += (i - lastMatchIndex - 1) * 2;
1715
+ }
1716
+ if (wordBoundary) score -= 10;
1717
+ score += i * 0.1;
1718
+ lastMatchIndex = i;
1719
+ queryIndex += 1;
1720
+ }
1721
+ if (queryIndex < candidate.length) return { matches: false, score: 0 };
1722
+ if (candidate === normalizedText) score -= 100;
1723
+ return { matches: true, score };
1724
+ };
1725
+
1726
+ const primary = matchQuery(normalizedQuery);
1727
+ if (primary.matches) return primary;
1728
+ const alphaNumeric = normalizedQuery.match(/^([a-z]+)([0-9]+)$/);
1729
+ const numericAlpha = normalizedQuery.match(/^([0-9]+)([a-z]+)$/);
1730
+ const swapped = alphaNumeric
1731
+ ? `${alphaNumeric[2]}${alphaNumeric[1]}`
1732
+ : numericAlpha
1733
+ ? `${numericAlpha[2]}${numericAlpha[1]}`
1734
+ : "";
1735
+ if (!swapped) return primary;
1736
+ const fallback = matchQuery(swapped);
1737
+ return fallback.matches ? { matches: true, score: fallback.score + 5 } : primary;
1738
+ }
1739
+
1740
+ function modelSelectorSearchText(model) {
1741
+ const provider = model.provider || "";
1742
+ const id = model.id || "";
1743
+ const name = model.name ? ` ${model.name}` : "";
1744
+ return `${provider} ${provider}/${id} ${provider} ${id}${name}`;
1745
+ }
1746
+
1747
+ function fuzzyFilterModels(models, query) {
1748
+ const tokens = query.trim().split(/[\s/]+/).filter(Boolean);
1749
+ if (tokens.length === 0) return models;
1750
+ return models
1751
+ .map((model, index) => {
1752
+ const text = modelSelectorSearchText(model);
1753
+ let score = 0;
1754
+ for (const token of tokens) {
1755
+ const match = fuzzyMatchText(token, text);
1756
+ if (!match.matches) return null;
1757
+ score += match.score;
1758
+ }
1759
+ return { model, index, score };
1760
+ })
1761
+ .filter(Boolean)
1762
+ .sort((a, b) => a.score - b.score || a.index - b.index)
1763
+ .map((result) => result.model);
1764
+ }
1765
+
1766
+ function renderModelOption(model, state) {
1767
+ const selected = model.provider === state.model?.provider && model.id === state.model?.id;
1768
+ const label = model.name || model.id;
1769
+ const path = `${model.provider}/${model.id}`;
1770
+ return `<button type="button" role="option" aria-selected="false" aria-current="${selected}" tabindex="-1" title="${esc(path)}" class="_7KE1Ra_option ${selected ? "_7KE1Ra_selected" : ""}" data-action="choose-model" data-provider="${esc(model.provider)}" data-model="${esc(model.id)}"><span class="_7KE1Ra_optionCopy"><span class="_7KE1Ra_modelName">${esc(label)}</span>${label !== path ? `<span class="_7KE1Ra_description">${esc(path)}</span>` : ""}</span><span class="_7KE1Ra_check">${selected ? ICONS.check : ""}</span></button>`;
1771
+ }
1772
+
1773
+ function modelMenuMatches(snap, query) {
1774
+ const models = snap.models || [];
1775
+ return query.trim() ? fuzzyFilterModels(models, query) : models;
1776
+ }
1777
+
1778
+ function renderModelMenuResults(snap, query) {
1779
+ const models = modelMenuMatches(snap, query);
1780
+ if (models.length === 0) {
1781
+ return `<div class="_7KE1Ra_status">${(snap.models || []).length ? "没有匹配的模型" : "暂无可用模型"}</div>`;
1782
+ }
1783
+ if (query.trim()) return models.map((model) => renderModelOption(model, snap.state || {})).join("");
1784
+
1785
+ const groups = new Map();
1786
+ for (const model of models) {
1787
+ const key = model.provider || "Other";
1788
+ if (!groups.has(key)) groups.set(key, []);
1789
+ groups.get(key).push(model);
1790
+ }
1791
+ return [...groups.entries()].map(([provider, providerModels]) => {
1792
+ const name = providerModels[0]?.providerName || provider;
1793
+ return `<section role="group" aria-label="${esc(name)}" class="_7KE1Ra_group"><div class="_7KE1Ra_groupTitle">${esc(name)}</div>${providerModels.map((model) => renderModelOption(model, snap.state || {})).join("")}</section>`;
1794
+ }).join("");
1795
+ }
1796
+
1797
+ function renderModelMenu(snap, loading = false) {
1798
+ const state = snap.state || {};
1799
+ const results = loading
1800
+ ? `<div class="_7KE1Ra_status">正在加载模型…</div>`
1801
+ : renderModelMenuResults(snap, "");
1802
+ return `<div class="_7KE1Ra_menu pi-popover pi-modelMenu" role="dialog" aria-label="选择模型">
1803
+ <div class="_7KE1Ra_cell pi-modelMenuHeader"><span class="_7KE1Ra_cellLabel">模型</span><span class="_7KE1Ra_cellValue" title="${esc(modelLabel(state.model))}">${esc(modelLabel(state.model))}</span></div>
1804
+ <label class="pi-modelSearch">${ICONS.search}<input class="pi-modelSearchInput" type="search" role="combobox" aria-label="模糊搜索模型" aria-controls="pi-modelMenuResults" aria-expanded="true" aria-autocomplete="list" autocomplete="off" spellcheck="false" placeholder="模糊搜索模型…" ${loading ? "disabled" : ""}></label>
1805
+ <div class="_7KE1Ra_groups scrollable pi-modelMenuResults" id="pi-modelMenuResults" role="listbox">${results}</div>
1806
+ <div class="pi-modelSearchHint">↑↓ 选择 · Enter 确认 · Esc 关闭</div>
1807
+ </div>`;
1808
+ }
1809
+
1810
+ function bindModelMenu(popover, snap, anchor) {
1811
+ const input = $(".pi-modelSearchInput", popover);
1812
+ const results = $(".pi-modelMenuResults", popover);
1813
+ if (!input || !results || input.disabled) return null;
1814
+ let menuSnapshot = snap;
1815
+ let selectedIndex = 0;
1816
+ let matches = [];
1817
+
1818
+ const setActive = (index, scroll = true) => {
1819
+ const options = $$("[data-action=choose-model]", results);
1820
+ if (options.length === 0) {
1821
+ selectedIndex = -1;
1822
+ return;
1823
+ }
1824
+ selectedIndex = (index + options.length) % options.length;
1825
+ options.forEach((option, optionIndex) => {
1826
+ const active = optionIndex === selectedIndex;
1827
+ option.classList.toggle("pi-modelOptionActive", active);
1828
+ option.setAttribute("aria-selected", String(active));
1829
+ });
1830
+ if (scroll) options[selectedIndex].scrollIntoView({ block: "nearest" });
1831
+ };
1832
+
1833
+ const updateResults = () => {
1834
+ matches = modelMenuMatches(menuSnapshot, input.value);
1835
+ results.innerHTML = renderModelMenuResults(menuSnapshot, input.value);
1836
+ const currentIndex = matches.findIndex((model) => model.provider === menuSnapshot.state?.model?.provider && model.id === menuSnapshot.state?.model?.id);
1837
+ setActive(input.value.trim() ? 0 : Math.max(0, currentIndex), false);
1838
+ };
1839
+
1840
+ input.addEventListener("input", updateResults);
1841
+ input.addEventListener("keydown", (event) => {
1842
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
1843
+ event.preventDefault();
1844
+ event.stopPropagation();
1845
+ setActive(selectedIndex + (event.key === "ArrowDown" ? 1 : -1));
1846
+ } else if (event.key === "Enter") {
1847
+ const option = $$("[data-action=choose-model]", results)[selectedIndex];
1848
+ if (option) {
1849
+ event.preventDefault();
1850
+ event.stopPropagation();
1851
+ option.click();
1852
+ }
1853
+ } else if (event.key === "Escape") {
1854
+ event.preventDefault();
1855
+ event.stopPropagation();
1856
+ closePopovers();
1857
+ anchor?.focus({ preventScroll: true });
1858
+ }
1859
+ });
1860
+ results.addEventListener("pointermove", (event) => {
1861
+ const option = event.target.closest?.("[data-action=choose-model]");
1862
+ if (!option) return;
1863
+ const options = $$("[data-action=choose-model]", results);
1864
+ setActive(options.indexOf(option), false);
1865
+ });
1866
+ updateResults();
1867
+ requestAnimationFrame(() => input.focus({ preventScroll: true }));
1868
+
1869
+ return (nextSnapshot) => {
1870
+ menuSnapshot = nextSnapshot;
1871
+ const value = $(".pi-modelMenuHeader ._7KE1Ra_cellValue", popover);
1872
+ if (value) {
1873
+ value.textContent = modelLabel(nextSnapshot.state?.model);
1874
+ value.title = modelLabel(nextSnapshot.state?.model);
1875
+ }
1876
+ updateResults();
1877
+ };
1878
+ }
1879
+
1880
+ function renderThinkingMenu(snap, loading = false) {
1881
+ const state = snap.state || {};
1882
+ const levels = snap.thinkingLevels || [];
1883
+ const body = levels.length > 0
1884
+ ? levels.map((level) => `<button type="button" role="menuitemradio" aria-checked="${level === state.thinkingLevel}" class="_7KE1Ra_option ${level === state.thinkingLevel ? "_7KE1Ra_selected" : ""}" data-action="choose-thinking" data-level="${esc(level)}"><span class="_7KE1Ra_optionCopy"><span class="_7KE1Ra_modelName">${esc(thinkingLabel(level))}</span><span class="_7KE1Ra_description">${level === "off" ? "关闭额外推理" : `推理强度:${level}`}</span></span><span class="_7KE1Ra_check">${level === state.thinkingLevel ? ICONS.check : ""}</span></button>`).join("")
1885
+ : `<div class="_7KE1Ra_status">${loading ? "正在加载思考强度…" : "当前模型没有可用思考强度"}</div>`;
1886
+ return `<div class="_7KE1Ra_menu pi-popover pi-thinkingMenu" role="menu" aria-label="思考强度">
1887
+ <div class="_7KE1Ra_cell pi-modelMenuHeader"><span class="_7KE1Ra_cellLabel">思考强度</span><span class="_7KE1Ra_cellValue">${esc(thinkingLabel(state.thinkingLevel || "off"))}</span></div>
1888
+ <div class="_7KE1Ra_groups scrollable">${body}</div>
1889
+ </div>`;
1890
+ }
1891
+
1892
+ function renderSessionMenu(session) {
1893
+ return `<div class="_7KE1Ra_menu pi-popover pi-menu" role="menu">
1894
+ <button type="button" role="menuitem" class="_7KE1Ra_cell" data-action="history-open" data-session="${esc(session.id)}"><span class="_7KE1Ra_cellLabel">回溯与会话树</span></button>
1895
+ <button type="button" role="menuitem" class="_7KE1Ra_cell" data-action="rename-session" data-session="${esc(session.id)}"><span class="_7KE1Ra_cellLabel">重命名</span></button>
1896
+ <button type="button" role="menuitem" class="_7KE1Ra_cell" data-action="export-session" data-session="${esc(session.id)}"><span class="_7KE1Ra_cellLabel">导出 HTML</span></button>
1897
+ <button type="button" role="menuitem" class="_7KE1Ra_cell" data-action="remove-session" data-session="${esc(session.id)}"><span class="_7KE1Ra_cellLabel">删除会话</span></button>
1898
+ </div>`;
1899
+ }
1900
+
1901
+ function openPopover(anchor, html) {
1902
+ closePopovers();
1903
+ const pop = el(`<div class="pi-popoverAnchor">${html}</div>`);
1904
+ const menu = $(".pi-popover", pop);
1905
+ const rect = anchor.getBoundingClientRect();
1906
+ pop.style.zIndex = "120";
1907
+ document.body.appendChild(pop);
1908
+ pop.replaceChildren(menu);
1909
+
1910
+ menu.style.position = "fixed";
1911
+ menu.style.inset = "auto";
1912
+ menu.style.left = "0";
1913
+ menu.style.top = "0";
1914
+ menu.style.visibility = "hidden";
1915
+ const spaceAbove = Math.max(0, rect.top - 8);
1916
+ const spaceBelow = Math.max(0, innerHeight - rect.bottom - 8);
1917
+ menu.style.maxHeight = `${Math.max(120, Math.min(520, Math.max(spaceAbove, spaceBelow)))}px`;
1918
+
1919
+ const width = menu.offsetWidth || 320;
1920
+ const height = menu.offsetHeight || 120;
1921
+ let left = rect.left;
1922
+ if (left + width > innerWidth - 8) left = rect.right - width;
1923
+ left = Math.max(8, Math.min(left, innerWidth - width - 8));
1924
+ const openAbove = spaceAbove > spaceBelow && spaceAbove >= Math.min(height, 160);
1925
+ let top = openAbove ? rect.top - height - 6 : rect.bottom + 6;
1926
+ top = Math.max(8, Math.min(top, innerHeight - height - 8));
1927
+
1928
+ menu.style.left = `${left}px`;
1929
+ menu.style.top = `${top}px`;
1930
+ menu.style.visibility = "visible";
1931
+ return pop;
1932
+ }
1933
+
1934
+ function closePopovers() {
1935
+ $$(".pi-popoverAnchor").forEach((n) => n.remove());
1936
+ }
1937
+
1938
+ function renderCommandMenuInPlace() {
1939
+ const card = $("[data-composer-card]");
1940
+ if (!card) return;
1941
+ const current = $(".pi-commandMenu", card);
1942
+ const html = renderCommandMenu(currentSnapshot());
1943
+ if (!html) {
1944
+ current?.remove();
1945
+ return;
1946
+ }
1947
+ const next = el(html);
1948
+ if (current) current.replaceWith(next);
1949
+ else card.prepend(next);
1950
+ requestAnimationFrame(() => $("._3e4SsG_active", next)?.scrollIntoView({ block: "nearest" }));
1951
+ }
1952
+
1953
+ function closeCommandMenu() {
1954
+ S.commandMenuOpen = false;
1955
+ S.commandLoading = false;
1956
+ S.commandSelected = 0;
1957
+ S.commandGeneration += 1;
1958
+ $(".pi-commandMenu")?.remove();
1959
+ }
1960
+
1961
+ async function refreshCommandsForCurrent() {
1962
+ const sessionId = S.currentSessionId;
1963
+ if (!sessionId) return;
1964
+ const generation = ++S.commandGeneration;
1965
+ S.commandLoading = true;
1966
+ renderCommandMenuInPlace();
1967
+ try {
1968
+ const data = await post(`/api/sessions/${encodeURIComponent(sessionId)}/commands`);
1969
+ if (generation !== S.commandGeneration || sessionId !== S.currentSessionId) return;
1970
+ const snap = snapshotFor(sessionId);
1971
+ S.snapshots.set(sessionId, { ...snap, commands: data.commands || [] });
1972
+ } catch (error) {
1973
+ if (generation === S.commandGeneration) showToast(`命令加载失败:${error.message}`, "warning");
1974
+ } finally {
1975
+ if (generation === S.commandGeneration) {
1976
+ S.commandLoading = false;
1977
+ renderCommandMenuInPlace();
1978
+ }
1979
+ }
1980
+ }
1981
+
1982
+ function openCommandMenu() {
1983
+ if (!slashCommandContext()) return closeCommandMenu();
1984
+ S.commandMenuOpen = true;
1985
+ S.commandSelected = 0;
1986
+ renderCommandMenuInPlace();
1987
+ void refreshCommandsForCurrent();
1988
+ }
1989
+
1990
+ async function applyCommandSuggestion(command, submit = false) {
1991
+ if (!command) return;
1992
+ setDraftValue(`/${command.name}${submit ? "" : " "}`);
1993
+ const input = $("#composerInput");
1994
+ if (input) {
1995
+ input.value = S.draft;
1996
+ input.setSelectionRange(input.value.length, input.value.length);
1997
+ syncComposerInput();
1998
+ }
1999
+ closeCommandMenu();
2000
+ if (submit) await sendDraft();
2001
+ else input?.focus();
2002
+ }
2003
+
2004
+ function openModelPopover(anchor, snap, loading = false) {
2005
+ const popover = openPopover(anchor, renderModelMenu(snap, loading));
2006
+ const updateSnapshot = bindModelMenu(popover, snap, anchor);
2007
+ return { popover, updateSnapshot };
2008
+ }
2009
+
2010
+ function modelRefreshKey(sessionId, workspaceId) {
2011
+ return sessionId ? `session:${sessionId}` : `workspace:${workspaceId}`;
2012
+ }
2013
+
2014
+ function refreshModelCatalog(sessionId, workspaceId, snap) {
2015
+ const key = modelRefreshKey(sessionId, workspaceId);
2016
+ const existing = S.modelRefreshes.get(key);
2017
+ if (existing) return existing;
2018
+
2019
+ const path = sessionId
2020
+ ? `/api/sessions/${encodeURIComponent(sessionId)}/models`
2021
+ : `/api/workspaces/${encodeURIComponent(workspaceId)}/models`;
2022
+ const model = snap.state?.model;
2023
+ const request = post(path, {
2024
+ model: model?.provider && model?.id ? { provider: model.provider, id: model.id } : undefined,
2025
+ thinkingLevel: snap.state?.thinkingLevel,
2026
+ }).then((data) => {
2027
+ if (sessionId) {
2028
+ S.snapshots.set(sessionId, { ...snapshotFor(sessionId), ...data });
2029
+ } else if (!S.currentSessionId && S.currentWorkspaceId === workspaceId) {
2030
+ S.newSessionSnapshot = { ...snapshotFor(null), ...data };
2031
+ }
2032
+ S.modelRefreshedAt.set(key, Date.now());
2033
+ return data;
2034
+ }).finally(() => {
2035
+ S.modelRefreshes.delete(key);
2036
+ });
2037
+ S.modelRefreshes.set(key, request);
2038
+ return request;
2039
+ }
2040
+
2041
+ async function openModelSelector(anchor) {
2042
+ const snap = currentSnapshot();
2043
+ const sessionId = S.currentSessionId;
2044
+ const workspaceId = S.currentWorkspaceId || currentWorkspace()?.id;
2045
+ const hasCachedModels = (snap.models || []).length > 0;
2046
+ const menu = openModelPopover(anchor, snap, !hasCachedModels);
2047
+ if (!sessionId && !workspaceId) return;
2048
+
2049
+ const key = modelRefreshKey(sessionId, workspaceId);
2050
+ const refreshedAt = S.modelRefreshedAt.get(key) || 0;
2051
+ if (hasCachedModels && Date.now() - refreshedAt < MODEL_REFRESH_TTL_MS) return;
2052
+
2053
+ try {
2054
+ await refreshModelCatalog(sessionId, workspaceId, snap);
2055
+ const sameContext = sessionId
2056
+ ? S.currentSessionId === sessionId
2057
+ : !S.currentSessionId && S.currentWorkspaceId === workspaceId;
2058
+ if (sameContext && document.body.contains(menu.popover)) {
2059
+ const nextSnapshot = sessionId ? snapshotFor(sessionId) : snapshotFor(null);
2060
+ if (menu.updateSnapshot) {
2061
+ menu.updateSnapshot(nextSnapshot);
2062
+ } else {
2063
+ const nextAnchor = $('[data-action="model-menu"]');
2064
+ if (nextAnchor) openModelPopover(nextAnchor, nextSnapshot);
2065
+ }
2066
+ }
2067
+ } catch (error) {
2068
+ if (!document.body.contains(menu.popover)) return;
2069
+ if (hasCachedModels) {
2070
+ showToast(`模型刷新失败,继续使用缓存:${error.message}`, "warning");
2071
+ } else {
2072
+ const status = menu.popover.querySelector("._7KE1Ra_status");
2073
+ if (status) status.textContent = `模型加载失败:${error.message}`;
2074
+ }
2075
+ }
2076
+ }
2077
+
2078
+ async function openThinkingSelector(anchor) {
2079
+ const snap = currentSnapshot();
2080
+ const sessionId = S.currentSessionId;
2081
+ if ((snap.thinkingLevels || []).length > 0 || !sessionId) {
2082
+ openPopover(anchor, renderThinkingMenu(snap));
2083
+ return;
2084
+ }
2085
+ const loadingPopover = openPopover(anchor, renderThinkingMenu(snap, true));
2086
+ try {
2087
+ const data = await post(`/api/sessions/${encodeURIComponent(sessionId)}/refresh`);
2088
+ S.snapshots.set(sessionId, { ...snapshotFor(sessionId), ...data });
2089
+ if (document.body.contains(loadingPopover)) {
2090
+ const nextAnchor = $('[data-action="thinking-menu"]');
2091
+ if (nextAnchor) openPopover(nextAnchor, renderThinkingMenu(snapshotFor(sessionId)));
2092
+ }
2093
+ } catch (error) {
2094
+ if (document.body.contains(loadingPopover)) {
2095
+ loadingPopover.querySelector("._7KE1Ra_status").textContent = `思考强度加载失败:${error.message}`;
2096
+ }
2097
+ }
2098
+ }
2099
+
2100
+ function renderDialogCard(dialog) {
2101
+ if (!dialog) return "";
2102
+ const title = dialog.title || (dialog.method === "confirm" ? "请确认" : dialog.method === "input" ? "请输入" : "请选择");
2103
+ const msg = dialog.message || "";
2104
+ let body = "";
2105
+ if (dialog.method === "select") {
2106
+ body = `<div class="pi-dialogOptions">${(dialog.options || []).map((o) => `<button type="button" class="pi-dialogOption" data-action="dialog-select" data-value="${esc(o)}">${esc(o)}</button>`).join("")}</div>`;
2107
+ } else if (dialog.method === "confirm") {
2108
+ body = `<div class="pi-dialogOptions"><button type="button" class="pi-dialogOption" data-action="dialog-confirm" data-value="true">确认</button><button type="button" class="pi-dialogOption" data-action="dialog-confirm" data-value="false">取消</button></div>`;
2109
+ } else if (dialog.method === "input" || dialog.method === "editor") {
2110
+ body = `<textarea class="pXSMma_modalInput pi-dialogInput" placeholder="${esc(dialog.placeholder || "")}" rows="3">${esc(dialog.prefill || "")}</textarea>
2111
+ <div class="pi-dialogOptions"><button type="button" class="pi-dialogOption" data-action="dialog-input">确定</button><button type="button" class="pi-dialogOption" data-action="dialog-cancel">取消</button></div>`;
2112
+ }
2113
+ return `<section class="pi-dialogCard pi-dialogInline" role="region" aria-label="${esc(title)}" aria-live="polite">
2114
+ <div class="pi-dialogTitle">${esc(title)}</div>
2115
+ ${msg ? `<div class="pi-dialogMessage">${esc(msg)}</div>` : ""}
2116
+ ${body}
2117
+ </section>`;
2118
+ }
2119
+
2120
+ function historyNodeLabel(node) {
2121
+ if (node.customType === "pi-web-tree-navigation") return "回溯点";
2122
+ if (node.role === "user") return "用户";
2123
+ if (node.role === "assistant") return "助手";
2124
+ if (node.role === "toolResult") return "工具";
2125
+ return {
2126
+ compaction: "压缩",
2127
+ branch_summary: "分支摘要",
2128
+ model_change: "模型",
2129
+ thinking_level_change: "思考",
2130
+ session_info: "会话",
2131
+ custom_message: "扩展消息",
2132
+ }[node.type] || "节点";
2133
+ }
2134
+
2135
+ function visibleHistoryTreeNodes(nodes) {
2136
+ return nodes.filter((node) => (
2137
+ node.role === "user"
2138
+ || node.role === "assistant"
2139
+ || node.type === "compaction"
2140
+ || node.type === "branch_summary"
2141
+ || node.type === "model_change"
2142
+ || node.type === "thinking_level_change"
2143
+ || node.customType === "pi-web-tree-navigation"
2144
+ || node.label
2145
+ || node.current
2146
+ ));
2147
+ }
2148
+
2149
+ function renderHistoryView(view) {
2150
+ const dialog = view || { mode: "rewind", loading: true, nodes: [] };
2151
+ const nodes = dialog.nodes || [];
2152
+ let body = "";
2153
+ let decision = "";
2154
+ if (dialog.loading) {
2155
+ body = `<div class="pi-historyState">正在读取会话树…</div>`;
2156
+ } else if (dialog.error && nodes.length === 0) {
2157
+ body = `<div class="pi-historyState pi-historyError">${esc(dialog.error)}</div>`;
2158
+ } else if (dialog.mode === "rewind") {
2159
+ const prompts = nodes.filter((node) => node.rewindable).reverse();
2160
+ body = prompts.length > 0 ? prompts.map((node, index) => `
2161
+ <button type="button" class="pi-historyPrompt ${dialog.selectedEntryId === node.id ? "pi-historyPromptSelected" : ""}" data-action="history-choose-rewind" data-entry="${esc(node.id)}" data-role="${esc(node.role || "")}" aria-pressed="${dialog.selectedEntryId === node.id}" ${dialog.busyEntryId ? "disabled" : ""}>
2162
+ <span class="pi-historyPromptIndex">${index === 0 ? "最近" : `-${index}`}</span>
2163
+ <span class="pi-historyPromptCopy"><span class="pi-historyPromptText">${esc(node.text || "(空消息)")}</span><span class="pi-historyPromptMeta">${esc(clockLabel(node.timestamp))} · 从这里创建新分支并重新编辑</span></span>
2164
+ </button>`).join("") : `<div class="pi-historyState">当前分支还没有可回溯的用户消息</div>`;
2165
+ const selected = prompts.find((node) => node.id === dialog.selectedEntryId);
2166
+ if (selected) {
2167
+ decision = `<div class="pi-historyDecisionWrap"><div class="pi-historyDecision">
2168
+ <div class="pi-historyDecisionCopy"><div class="pi-historyDecisionTitle">选择回溯范围</div><div class="pi-historyDecisionText">${esc(selected.text || "(空消息)")}</div></div>
2169
+ <div class="pi-historyDecisionActions">
2170
+ <button type="button" class="pi-historyDecisionButton" data-action="history-rewind-mode" data-entry="${esc(selected.id)}" data-role="${esc(selected.role || "")}" data-restore-mode="conversation" ${dialog.busyEntryId ? "disabled" : ""}><span>仅回溯对话</span><small>保留工作区当前文件</small></button>
2171
+ <button type="button" class="pi-historyDecisionButton pi-historyDecisionPrimary" data-action="history-rewind-mode" data-entry="${esc(selected.id)}" data-role="${esc(selected.role || "")}" data-restore-mode="all" ${dialog.busyEntryId ? "disabled" : ""}><span>对话和文件一起回溯</span><small>文件恢复到该时点最近的 pi-rewind 检查点</small></button>
2172
+ <button type="button" class="pi-historyDecisionCancel" data-action="history-rewind-cancel" ${dialog.busyEntryId ? "disabled" : ""}>取消</button>
2173
+ </div>
2174
+ </div></div>`;
2175
+ }
2176
+ } else {
2177
+ const treeNodes = visibleHistoryTreeNodes(nodes);
2178
+ body = treeNodes.length > 0 ? treeNodes.map((node) => {
2179
+ const depth = Math.min(7, Math.max(0, Number(node.branchDepth) || 0));
2180
+ const disabled = !node.navigable || dialog.busyEntryId;
2181
+ return `<button type="button" class="pi-historyTreeRow ${node.active ? "pi-historyTreeActive" : ""} ${node.current ? "pi-historyTreeCurrent" : ""}" style="--history-indent:${12 + depth * 18}px" data-action="history-navigate" data-entry="${esc(node.id)}" data-role="${esc(node.role || "")}" ${disabled ? "disabled" : ""}>
2182
+ <span class="pi-historyRail"><span class="pi-historyDot"></span></span>
2183
+ <span class="pi-historyKind">${esc(historyNodeLabel(node))}</span>
2184
+ <span class="pi-historyTreeCopy"><span class="pi-historyTreeText">${esc(node.text || historyNodeLabel(node))}</span><span class="pi-historyTreeMeta">${esc(clockLabel(node.timestamp))}${node.branchCount > 1 ? ` · ${node.branchCount} 个分支` : ""}${node.current ? " · 当前位置" : node.active ? " · 当前路径" : ""}</span></span>
2185
+ ${node.label ? `<span class="pi-historyLabel">${esc(node.label)}</span>` : ""}
2186
+ </button>`;
2187
+ }).join("") : `<div class="pi-historyState">当前会话树为空</div>`;
2188
+ }
2189
+ const errorBanner = dialog.error && nodes.length > 0 ? `<div class="pi-historyErrorBanner" role="alert">${esc(dialog.error)}</div>` : "";
2190
+ const busyText = dialog.busyRestoreMode === "all" ? "正在回溯对话和文件…" : "正在切换会话分支…";
2191
+ return `<section class="pi-historyView" role="tabpanel" aria-label="会话历史">
2192
+ <div class="pi-historyHeader"><div><div class="pi-historyTitle">会话历史</div><div class="pi-historySubtitle">回溯不会删除记录,离开的内容仍保留在分支树中</div></div></div>
2193
+ <div class="pi-historyTabs" role="tablist">
2194
+ <button type="button" role="tab" aria-selected="${dialog.mode === "rewind"}" class="pi-historyTab ${dialog.mode === "rewind" ? "pi-historyTabActive" : ""}" data-action="history-mode" data-mode="rewind">回溯</button>
2195
+ <button type="button" role="tab" aria-selected="${dialog.mode === "tree"}" class="pi-historyTab ${dialog.mode === "tree" ? "pi-historyTabActive" : ""}" data-action="history-mode" data-mode="tree">会话树</button>
2196
+ </div>
2197
+ <div class="pi-historyBody"><div class="pi-historyBodyInner">${errorBanner}${body}</div></div>
2198
+ ${decision}
2199
+ ${dialog.busyEntryId ? `<div class="pi-historyBusy" role="status">${busyText}</div>` : ""}
2200
+ </section>`;
2201
+ }
2202
+
2203
+ async function openHistoryView(mode = "rewind") {
2204
+ const sessionId = S.currentSessionId;
2205
+ if (!sessionId) {
2206
+ showToast("请先打开一个会话", "warning");
2207
+ return;
2208
+ }
2209
+ const generation = ++S.historyGeneration;
2210
+ S.conversationTab = "history";
2211
+ S.historyView = { sessionId, mode, loading: true, nodes: [], error: null, busyEntryId: null };
2212
+ queueRender(true, { sidebar: false, conversation: true, overlay: false });
2213
+ try {
2214
+ const data = await post(`/api/sessions/${encodeURIComponent(sessionId)}/history`);
2215
+ if (generation !== S.historyGeneration || S.historyView?.sessionId !== sessionId) return;
2216
+ S.historyView = { ...S.historyView, loading: false, nodes: data.nodes || [], leafId: data.leafId || null };
2217
+ } catch (error) {
2218
+ if (generation !== S.historyGeneration || S.historyView?.sessionId !== sessionId) return;
2219
+ S.historyView = { ...S.historyView, loading: false, error: error.message || String(error) };
2220
+ }
2221
+ queueRender(true, { sidebar: false, conversation: true, overlay: false });
2222
+ }
2223
+
2224
+ function showConversationView() {
2225
+ S.conversationTab = "conversation";
2226
+ queueRender(true, { sidebar: false, conversation: true, overlay: false });
2227
+ requestAnimationFrame(() => $("#composerInput")?.focus({ preventScroll: true }));
2228
+ }
2229
+
2230
+ async function navigateHistory(entryId, role, restoreMode) {
2231
+ const view = S.historyView;
2232
+ const sessionId = view?.sessionId;
2233
+ if (!view || !sessionId || view.busyEntryId) return;
2234
+ if (role === "user" && S.draft.trim() && !confirm("当前输入框草稿将被历史消息替换,继续回溯?")) return;
2235
+ S.historyView = { ...view, busyEntryId: entryId, busyRestoreMode: restoreMode || null, error: null };
2236
+ renderConversation();
2237
+ try {
2238
+ const data = await post(`/api/sessions/${encodeURIComponent(sessionId)}/navigate_tree`, { entryId, restoreMode });
2239
+ if (S.currentSessionId !== sessionId) return;
2240
+ const snap = snapshotFor(sessionId);
2241
+ S.snapshots.set(sessionId, { ...snap, session: data.session, state: data.state, messages: data.messages || [], stats: data.stats });
2242
+ if (typeof data.editorText === "string") setDraftValue(data.editorText);
2243
+ ++S.historyGeneration;
2244
+ S.conversationTab = "conversation";
2245
+ S.historyView = null;
2246
+ queueRender(true);
2247
+ requestAnimationFrame(() => {
2248
+ const input = $("#composerInput");
2249
+ input?.focus({ preventScroll: true });
2250
+ input?.setSelectionRange(input.value.length, input.value.length);
2251
+ });
2252
+ if (restoreMode === "all") showToast("已回溯对话和文件,可编辑后重新发送");
2253
+ else if (restoreMode === "conversation") showToast("已回溯对话,工作区文件保持不变");
2254
+ else showToast(role === "user" ? "已回溯到所选消息,可编辑后重新发送" : "已切换到所选会话节点");
2255
+ } catch (error) {
2256
+ if (S.historyView?.sessionId === sessionId) {
2257
+ S.historyView = { ...S.historyView, busyEntryId: null, busyRestoreMode: null, error: error.message || String(error) };
2258
+ renderConversation();
2259
+ }
2260
+ }
2261
+ }
2262
+
2263
+ function renderSettings() {
2264
+ const busy = Boolean(S.maintenanceBusy);
2265
+ const themes = [
2266
+ ["system", "跟随系统"],
2267
+ ["light", "浅色"],
2268
+ ["dark", "深色"],
2269
+ ];
2270
+ const status = S.maintenanceMessage
2271
+ ? `<div class="pi-settingsStatus pi-settingsStatus-${esc(S.maintenanceMessage.type || "info")}" role="status">${esc(S.maintenanceMessage.text || "")}</div>`
2272
+ : "";
2273
+ return `<div class="pi-settingsBackdrop">
2274
+ <div class="pi-settingsPanel" role="dialog" aria-modal="true" aria-labelledby="pi-settings-title">
2275
+ <header class="pi-settingsHeader">
2276
+ <h2 id="pi-settings-title">设置</h2>
2277
+ <button type="button" class="pi-settingsClose" data-action="settings-close" aria-label="关闭设置" title="关闭" ${busy ? "disabled" : ""}>×</button>
2278
+ </header>
2279
+ <section class="pi-settingsSection" aria-labelledby="pi-settings-appearance">
2280
+ <h3 id="pi-settings-appearance">外观</h3>
2281
+ <div class="pi-settingsTheme" role="group" aria-label="界面主题">
2282
+ ${themes.map(([value, label]) => `<button type="button" data-action="choose-theme" data-theme="${value}" aria-pressed="${S.theme === value}" ${busy ? "disabled" : ""}>${label}</button>`).join("")}
2283
+ </div>
2284
+ </section>
2285
+ <section class="pi-settingsSection" aria-labelledby="pi-settings-maintenance">
2286
+ <h3 id="pi-settings-maintenance">Pi 维护</h3>
2287
+ <div class="pi-settingsActionRow">
2288
+ <div class="pi-settingsActionCopy"><strong>更新扩展并重载</strong><code>pi update --extensions</code></div>
2289
+ <button type="button" class="pi-settingsAction pi-settingsActionPrimary" data-action="maintenance-update-reload" ${busy ? "disabled" : ""}>${S.maintenanceBusy === "update" ? '<span class="pi-settingsSpinner"></span>更新中' : `${ICONS.refresh}<span>更新并重载</span>`}</button>
2290
+ </div>
2291
+ <div class="pi-settingsActionRow">
2292
+ <div class="pi-settingsActionCopy"><strong>重新加载运行时</strong><code>/reload</code></div>
2293
+ <button type="button" class="pi-settingsAction" data-action="maintenance-reload" ${busy ? "disabled" : ""}>${S.maintenanceBusy === "reload" ? '<span class="pi-settingsSpinner"></span>重载中' : `${ICONS.refresh}<span>重新加载</span>`}</button>
2294
+ </div>
2295
+ ${status}
2296
+ </section>
2297
+ </div>
2298
+ </div>`;
2299
+ }
2300
+
2301
+ function renderOverlay() {
2302
+ const overlay = $("#overlayMount");
2303
+ if (!overlay) return;
2304
+ if (S.settingsOpen) {
2305
+ overlay.innerHTML = renderSettings();
2306
+ return;
2307
+ }
2308
+ overlay.innerHTML = "";
2309
+ }
2310
+
2311
+ function syncComposerInput() {
2312
+ const input = $("#composerInput");
2313
+ if (!input) return;
2314
+ const grow = input.closest(".uV2eYG_grow");
2315
+ const mirror = grow?.querySelector(".uV2eYG_mirror");
2316
+ const backdrop = grow?.querySelector(".uV2eYG_backdrop");
2317
+ // The DSH composer paints the visible text in the backdrop (the textarea
2318
+ // itself is transparent) and sizes the row from the hidden mirror.
2319
+ if (mirror) mirror.textContent = `${input.value}\n`;
2320
+ if (backdrop) backdrop.textContent = input.value;
2321
+ // The mirror must keep the full draft height; the outer scrollport owns the 336px cap.
2322
+ if (grow) grow.style.removeProperty("height");
2323
+ }
2324
+
2325
+ function bindComposerInput() {
2326
+ const input = $("#composerInput");
2327
+ if (!input) return;
2328
+ if (!input.dataset.bound) {
2329
+ input.dataset.bound = "1";
2330
+ input.value = S.draft;
2331
+ input.addEventListener("input", () => {
2332
+ setDraftValue(input.value);
2333
+ syncComposerInput();
2334
+ if (slashCommandContext()) {
2335
+ if (!S.commandMenuOpen) openCommandMenu();
2336
+ else {
2337
+ S.commandSelected = 0;
2338
+ renderCommandMenuInPlace();
2339
+ }
2340
+ } else {
2341
+ closeCommandMenu();
2342
+ }
2343
+ });
2344
+ input.addEventListener("paste", (event) => {
2345
+ const hasImage = [...(event.clipboardData?.items || [])].some((item) => item.type.startsWith("image/"));
2346
+ if (!hasImage) return;
2347
+ event.preventDefault();
2348
+ void pasteSystemClipboard();
2349
+ });
2350
+ input.addEventListener("keydown", (event) => {
2351
+ if (event.isComposing) return;
2352
+ if (S.commandMenuOpen) {
2353
+ const commands = commandSuggestions();
2354
+ if ((event.key === "ArrowUp" || event.key === "ArrowDown") && commands.length > 0) {
2355
+ event.preventDefault();
2356
+ const delta = event.key === "ArrowDown" ? 1 : -1;
2357
+ S.commandSelected = (S.commandSelected + delta + commands.length) % commands.length;
2358
+ renderCommandMenuInPlace();
2359
+ return;
2360
+ }
2361
+ if (event.key === "Escape") {
2362
+ event.preventDefault();
2363
+ closeCommandMenu();
2364
+ return;
2365
+ }
2366
+ if ((event.key === "Tab" || (event.key === "Enter" && !event.shiftKey)) && commands.length > 0) {
2367
+ event.preventDefault();
2368
+ void applyCommandSuggestion(commands[S.commandSelected], event.key === "Enter");
2369
+ return;
2370
+ }
2371
+ }
2372
+ if (event.key === "Enter" && !event.shiftKey) {
2373
+ event.preventDefault();
2374
+ void sendDraft();
2375
+ }
2376
+ });
2377
+ }
2378
+ syncComposerInput();
2379
+ }
2380
+
2381
+ function restoreDraft() {
2382
+ const input = $("#composerInput");
2383
+ if (input) input.value = S.draft;
2384
+ }
2385
+
2386
+ let renderFrame = 0;
2387
+ let pendingRender = { sidebar: true, conversation: true, overlay: true };
2388
+ function queueRender(force = false, parts = null) {
2389
+ const requested = parts || { sidebar: true, conversation: true, overlay: true };
2390
+ pendingRender.sidebar ||= requested.sidebar;
2391
+ pendingRender.conversation ||= requested.conversation;
2392
+ pendingRender.overlay ||= requested.overlay;
2393
+
2394
+ const run = () => {
2395
+ renderFrame = 0;
2396
+ const current = pendingRender;
2397
+ pendingRender = { sidebar: false, conversation: false, overlay: false };
2398
+ if (current.sidebar) renderSidebar();
2399
+ if (current.conversation) renderConversation();
2400
+ if (current.overlay) renderOverlay();
2401
+ bindGlobalEvents();
2402
+ };
2403
+ if (force) {
2404
+ if (renderFrame) cancelAnimationFrame(renderFrame);
2405
+ run();
2406
+ } else if (!renderFrame) {
2407
+ renderFrame = requestAnimationFrame(run);
2408
+ }
2409
+ }
2410
+
2411
+ function updateSnapshotFromEvent(event) {
2412
+ const id = event.sessionId;
2413
+ if (!id) return;
2414
+ const existing = snapshotFor(id);
2415
+ if (event.type === "snapshot") {
2416
+ S.snapshots.set(id, { ...existing, ...event });
2417
+ } else if (event.type === "session_event") {
2418
+ const inner = event.event || {};
2419
+ if (inner.type === "notify") {
2420
+ const snap = snapshotFor(id);
2421
+ S.snapshots.set(id, { ...snap });
2422
+ }
2423
+ }
2424
+ }
2425
+
2426
+ async function loadBootstrap() {
2427
+ const boot = await api("/api/bootstrap");
2428
+ S.workspaces = applySavedWorkspaceOrder(boot.workspaces || []);
2429
+ S.collapsed = new Set(S.workspaces.map((workspace) => workspace.id));
2430
+ S.currentWorkspaceId = boot.currentWorkspaceId;
2431
+ S.currentSessionId = boot.currentSessionId;
2432
+ S.serverInstanceId = boot.instanceId || null;
2433
+ S.currentWorkspaceId = boot.currentWorkspaceId || S.workspaces[0]?.id || null;
2434
+ S.newSessionSnapshot = {
2435
+ session: null,
2436
+ state: boot.modelInfo?.state || null,
2437
+ stats: null,
2438
+ models: boot.modelInfo?.models || [],
2439
+ thinkingLevels: boot.modelInfo?.thinkingLevels || [],
2440
+ thinkingLevelsByModel: boot.modelInfo?.thinkingLevelsByModel || {},
2441
+ commands: boot.commands || [],
2442
+ extensionStatuses: {},
2443
+ messages: [],
2444
+ dialogs: [],
2445
+ };
2446
+ renderApp();
2447
+ if (S.currentSessionId) openSession(S.currentSessionId, { silent: true });
2448
+ }
2449
+
2450
+ async function openSession(id, opts = {}) {
2451
+ closeCommandMenu();
2452
+ ++S.historyGeneration;
2453
+ S.conversationTab = "conversation";
2454
+ S.historyView = null;
2455
+ const generation = ++S.openGeneration;
2456
+ if (!opts.skipRemember) rememberCurrentDraft();
2457
+ S.currentSessionId = id;
2458
+ S.loadingSession = id;
2459
+ if (!opts.silent) {
2460
+ const key = draftKey(id);
2461
+ const savedDraft = S.drafts.get(key) || "";
2462
+ const failedDrafts = S.failedDrafts.get(id) || [];
2463
+ const attachments = [...(S.attachmentDrafts.get(key) || []), ...(S.failedAttachmentDrafts.get(id) || [])];
2464
+ S.draftAttachments = [...new Map(attachments.map((attachment) => [attachment.id, attachment])).values()];
2465
+ setDraftValue([savedDraft, ...failedDrafts].filter(Boolean).join("\n\n"));
2466
+ S.failedDrafts.delete(id);
2467
+ S.failedAttachmentDrafts.delete(id);
2468
+ } else {
2469
+ setDraftValue(S.draft);
2470
+ }
2471
+ renderSidebar();
2472
+ renderConversation();
2473
+ try {
2474
+ const data = await post(`/api/sessions/${encodeURIComponent(id)}/open`);
2475
+ S.snapshots.set(id, data);
2476
+ if (generation !== S.openGeneration) return;
2477
+ const ws = data.session?.workspaceId;
2478
+ if (ws) S.currentWorkspaceId = ws;
2479
+ } catch (error) {
2480
+ S.snapshots.set(id, { ...snapshotFor(id), session: { id, title: "会话", status: "error" }, error: String(error) });
2481
+ } finally {
2482
+ if (generation === S.openGeneration) {
2483
+ S.loadingSession = null;
2484
+ queueRender(true);
2485
+ }
2486
+ }
2487
+ }
2488
+
2489
+ async function createAndOpenSession(workspaceId, title, options = {}) {
2490
+ try {
2491
+ const source = currentSnapshot();
2492
+ const model = source.state?.model;
2493
+ const data = await post(`/api/workspaces/${encodeURIComponent(workspaceId)}/sessions`, {
2494
+ title,
2495
+ model: model?.provider && model?.id ? { provider: model.provider, modelId: model.id } : undefined,
2496
+ thinkingLevel: source.state?.thinkingLevel || undefined,
2497
+ });
2498
+ const id = data.session?.id;
2499
+ if (!id) throw new Error("create session failed");
2500
+ S.snapshots.set(id, data);
2501
+ if (!options.keepDraft) {
2502
+ rememberCurrentDraft();
2503
+ S.draft = "";
2504
+ S.draftAttachments = [];
2505
+ }
2506
+ S.currentWorkspaceId = workspaceId;
2507
+ await openSession(id, { silent: true, skipRemember: !options.keepDraft });
2508
+ return id;
2509
+ } catch (error) {
2510
+ alert(`新建会话失败:${error.message}`);
2511
+ return null;
2512
+ }
2513
+ }
2514
+
2515
+ async function ensureWorkspaceThen(action) {
2516
+ let ws = currentWorkspace();
2517
+ if (!ws) {
2518
+ ws = await promptWorkspace();
2519
+ if (!ws) return null;
2520
+ }
2521
+ return action(ws);
2522
+ }
2523
+
2524
+ // ---------------------------------------------------------------------------
2525
+ // Workspace directory browser (port of DSH ui-directory-picker-browse)
2526
+ // ---------------------------------------------------------------------------
2527
+ let browser = null;
2528
+ let pickerResolve = null;
2529
+ let browserKeyHandler = null;
2530
+
2531
+ async function adoptWorkspacePath(path) {
2532
+ const added = await post("/api/workspaces", { path });
2533
+ S.workspaces = await refreshWorkspaces();
2534
+ return S.workspaces.find((w) => w.id === added.workspace?.id) || S.workspaces.find((w) => w.path === path) || null;
2535
+ }
2536
+
2537
+ async function promptWorkspace() {
2538
+ closePopovers();
2539
+ try {
2540
+ const data = await post("/api/fs/pick");
2541
+ if (data.error) throw new Error(data.error);
2542
+ if (!data.path) return null;
2543
+ return await adoptWorkspacePath(data.path);
2544
+ } catch {
2545
+ // Native picker unavailable (unsupported platform, missing command):
2546
+ // fall back to the in-app directory browser.
2547
+ return promptWorkspaceBrowse();
2548
+ }
2549
+ }
2550
+
2551
+ function promptWorkspaceBrowse() {
2552
+ closePopovers();
2553
+ return new Promise((resolve) => {
2554
+ pickerResolve = resolve;
2555
+ browser = {
2556
+ parent: null,
2557
+ selected: null,
2558
+ child: null,
2559
+ home: "",
2560
+ loading: true,
2561
+ error: null,
2562
+ pathDraft: null,
2563
+ showHidden: false,
2564
+ folderDraft: null,
2565
+ creatingFolder: false,
2566
+ createError: null,
2567
+ busy: false,
2568
+ };
2569
+ renderBrowser();
2570
+ browserKeyHandler = (event) => {
2571
+ if (event.key !== "Escape" || !browser) return;
2572
+ if (browser.folderDraft !== null && !browser.creatingFolder) {
2573
+ browser.folderDraft = null;
2574
+ browser.createError = null;
2575
+ renderBrowser();
2576
+ return;
2577
+ }
2578
+ if (browser.pathDraft !== null) {
2579
+ cancelPathEdit();
2580
+ return;
2581
+ }
2582
+ if (!browser.busy) {
2583
+ const resolvePicker = pickerResolve;
2584
+ closeWorkspacePicker();
2585
+ resolvePicker?.(null);
2586
+ }
2587
+ };
2588
+ document.addEventListener("keydown", browserKeyHandler);
2589
+ api("/api/fs/roots")
2590
+ .then((data) => {
2591
+ if (!browser) return;
2592
+ browser.home = data.home || "";
2593
+ navigate(browser.home || data.roots?.[0]?.path || "");
2594
+ })
2595
+ .catch((error) => {
2596
+ if (!browser) return;
2597
+ browser.loading = false;
2598
+ browser.error = error.message || String(error);
2599
+ renderBrowser();
2600
+ });
2601
+ });
2602
+ }
2603
+
2604
+ function closeWorkspacePicker() {
2605
+ if (browserKeyHandler) {
2606
+ document.removeEventListener("keydown", browserKeyHandler);
2607
+ browserKeyHandler = null;
2608
+ }
2609
+ browser = null;
2610
+ pickerResolve = null;
2611
+ $("#workspacePickerMount")?.remove();
2612
+ }
2613
+
2614
+ function separatorOf() {
2615
+ return browser?.home?.includes("\\") ? "\\" : "/";
2616
+ }
2617
+
2618
+ function levelDirectory(listing) {
2619
+ const sep = separatorOf();
2620
+ return listing.path.endsWith(sep) ? listing.path : `${listing.path}${sep}`;
2621
+ }
2622
+
2623
+ function displayCrumbs(listing) {
2624
+ const homeIndex = listing.crumbs.findIndex((crumb) => crumb.path === listing.home);
2625
+ if (homeIndex === -1) return listing.crumbs;
2626
+ return [{ name: "主页", path: listing.home, hidden: false }, ...listing.crumbs.slice(homeIndex + 1)];
2627
+ }
2628
+
2629
+ function draftDirectory(draft) {
2630
+ const sep = separatorOf();
2631
+ const cut = sep === "\\" ? Math.max(draft.lastIndexOf("\\"), draft.lastIndexOf("/")) : draft.lastIndexOf("/");
2632
+ return cut === -1 ? null : draft.slice(0, cut + 1);
2633
+ }
2634
+
2635
+ function readDraft(listing, draft) {
2636
+ const directory = draftDirectory(draft);
2637
+ if (directory === null) return { directory: null, tail: null };
2638
+ const answers = directory === levelDirectory(listing);
2639
+ return { directory, tail: answers ? draft.slice(directory.length) : null };
2640
+ }
2641
+
2642
+ function visibleEntries(entries, selectedPath, showHidden, filterPrefix) {
2643
+ const needle = filterPrefix === null ? "" : filterPrefix.toLowerCase();
2644
+ const displayable = (entry) => showHidden || !entry.hidden || needle.startsWith(".");
2645
+ const matches = (entry) => displayable(entry) && entry.name.toLowerCase().startsWith(needle);
2646
+ const narrowing = needle !== "" && entries.some(matches);
2647
+ return entries.filter((entry) => {
2648
+ if (entry.path === selectedPath) return true;
2649
+ if (narrowing) return matches(entry);
2650
+ return showHidden || !entry.hidden;
2651
+ });
2652
+ }
2653
+
2654
+ function renderRows(entries, selectedPath, action, filterPrefix) {
2655
+ const visible = visibleEntries(entries, selectedPath, browser.showHidden, filterPrefix);
2656
+ const parentInert = browser.busy || browser.folderDraft !== null;
2657
+ return visible
2658
+ .map((entry) => {
2659
+ const selected = entry.path === selectedPath;
2660
+ return `<span class="db-rowSeat" role="listitem">
2661
+ <button type="button" ${selected ? "aria-current=\"true\"" : ""} class="db-row ${selected ? "db-rowSelected" : ""}" data-action="${action}" data-path="${esc(entry.path)}" ${parentInert ? "disabled" : ""}>
2662
+ ${selected ? ICONS.folderOpen : ICONS.folder}<span class="db-rowName">${esc(entry.name)}</span>${ICONS.chevron}
2663
+ </button>
2664
+ </span>`;
2665
+ })
2666
+ .join("");
2667
+ }
2668
+
2669
+ function browserCrumbs() {
2670
+ const source = browser?.child ?? browser?.parent;
2671
+ return source ? displayCrumbs(source) : [];
2672
+ }
2673
+
2674
+ function browserTargetPath() {
2675
+ return browser?.selected?.path ?? browser?.parent?.path ?? null;
2676
+ }
2677
+
2678
+ async function navigate(path) {
2679
+ if (!browser || !path) return;
2680
+ browser.loading = true;
2681
+ browser.error = null;
2682
+ browser.selected = null;
2683
+ browser.child = null;
2684
+ renderBrowser();
2685
+ let target = null;
2686
+ try {
2687
+ const data = await api(`/api/fs/list?path=${encodeURIComponent(path)}`);
2688
+ if (!browser) return;
2689
+ if (data.error) throw new Error(data.error);
2690
+ target = data;
2691
+ browser.home = data.home || browser.home;
2692
+ browser.parent = data;
2693
+ browser.loading = false;
2694
+ renderBrowser();
2695
+ } catch (error) {
2696
+ if (!browser) return;
2697
+ browser.loading = false;
2698
+ browser.error = error.message || String(error);
2699
+ renderBrowser();
2700
+ return;
2701
+ }
2702
+ // Two-pane landing away from the display root: anchor the target on its
2703
+ // parent level (a crumb jump reads as stepping back one pane).
2704
+ const crumbs = displayCrumbs(target);
2705
+ const parentCrumb = crumbs[crumbs.length - 2];
2706
+ if (!parentCrumb) return;
2707
+ try {
2708
+ const data = await api(`/api/fs/list?path=${encodeURIComponent(parentCrumb.path)}`);
2709
+ if (!browser || data.error) return;
2710
+ const sep = separatorOf();
2711
+ const fold = (value) => (sep === "\\" ? value.toLowerCase() : value);
2712
+ const match = (data.entries || []).find((entry) => fold(entry.path) === fold(target.path));
2713
+ if (!match) return;
2714
+ browser.parent = data;
2715
+ browser.selected = match;
2716
+ browser.child = target;
2717
+ renderBrowser();
2718
+ } catch {
2719
+ // Parent leg failed: keep the single-pane landing.
2720
+ }
2721
+ }
2722
+
2723
+ async function selectEntry(entry) {
2724
+ if (!browser) return;
2725
+ browser.selected = entry;
2726
+ browser.child = null;
2727
+ browser.loading = true;
2728
+ browser.error = null;
2729
+ renderBrowser();
2730
+ try {
2731
+ const data = await api(`/api/fs/list?path=${encodeURIComponent(entry.path)}`);
2732
+ if (!browser) return;
2733
+ if (data.error) throw new Error(data.error);
2734
+ browser.child = data;
2735
+ browser.loading = false;
2736
+ renderBrowser();
2737
+ } catch (error) {
2738
+ if (!browser) return;
2739
+ browser.loading = false;
2740
+ browser.error = error.message || String(error);
2741
+ browser.selected = null;
2742
+ renderBrowser();
2743
+ }
2744
+ }
2745
+
2746
+ function advanceEntry(entry) {
2747
+ if (!browser?.child) return;
2748
+ browser.parent = browser.child;
2749
+ selectEntry(entry);
2750
+ }
2751
+
2752
+ function openPathEditor() {
2753
+ if (!browser?.parent) return;
2754
+ const sep = separatorOf();
2755
+ const base = browser.selected?.path ?? browser.parent.path;
2756
+ browser.pathDraft = base.endsWith(sep) ? base : `${base}${sep}`;
2757
+ browser.error = null;
2758
+ renderBrowser();
2759
+ }
2760
+
2761
+ function cancelPathEdit() {
2762
+ if (!browser) return;
2763
+ browser.pathDraft = null;
2764
+ browser.error = null;
2765
+ renderBrowser();
2766
+ }
2767
+
2768
+ async function createFolder() {
2769
+ if (!browser || browser.folderDraft === null || browser.creatingFolder) return;
2770
+ const name = browser.folderDraft;
2771
+ const target = browserTargetPath();
2772
+ if (!target || name.trim() === "") return;
2773
+ browser.creatingFolder = true;
2774
+ browser.createError = null;
2775
+ renderBrowser();
2776
+ try {
2777
+ const data = await post("/api/fs/mkdir", { path: target, name });
2778
+ if (!browser) return;
2779
+ if (data.error) throw new Error(data.error);
2780
+ const createdPath = data.path;
2781
+ browser.creatingFolder = false;
2782
+ browser.folderDraft = null;
2783
+ browser.selected = null;
2784
+ browser.child = null;
2785
+ browser.loading = true;
2786
+ renderBrowser();
2787
+ const level = await api(`/api/fs/list?path=${encodeURIComponent(target)}`);
2788
+ if (!browser) return;
2789
+ if (level.error) throw new Error(level.error);
2790
+ browser.parent = level;
2791
+ browser.home = level.home || browser.home;
2792
+ browser.loading = false;
2793
+ renderBrowser();
2794
+ await selectEntry({ name, path: createdPath, hidden: name.startsWith(".") });
2795
+ } catch (error) {
2796
+ if (!browser) return;
2797
+ browser.creatingFolder = false;
2798
+ browser.createError = error.message || String(error);
2799
+ renderBrowser();
2800
+ }
2801
+ }
2802
+
2803
+ async function adoptWorkspace() {
2804
+ const path = browserTargetPath();
2805
+ if (!browser || !path || browser.busy || browser.pathDraft !== null) return;
2806
+ const resolve = pickerResolve;
2807
+ pickerResolve = null;
2808
+ browser.busy = true;
2809
+ renderBrowser();
2810
+ try {
2811
+ const ws = await adoptWorkspacePath(path);
2812
+ closeWorkspacePicker();
2813
+ resolve?.(ws);
2814
+ } catch (error) {
2815
+ if (!browser) return;
2816
+ browser.busy = false;
2817
+ browser.error = error.message || String(error);
2818
+ renderBrowser();
2819
+ resolve?.(null);
2820
+ }
2821
+ }
2822
+
2823
+ function renderBrowser() {
2824
+ let mount = $("#workspacePickerMount");
2825
+ if (!mount) {
2826
+ mount = el(`<div id="workspacePickerMount"></div>`);
2827
+ document.body.appendChild(mount);
2828
+ }
2829
+ if (!browser) {
2830
+ mount.innerHTML = "";
2831
+ return;
2832
+ }
2833
+ const b = browser;
2834
+ const parentInert = b.busy || b.folderDraft !== null;
2835
+ const draftPending = b.pathDraft !== null;
2836
+ const twoPane = b.selected !== null;
2837
+ const crumbSource = b.child ?? b.parent;
2838
+ const crumbs = browserCrumbs();
2839
+ const typedPrefix = crumbSource === null || b.pathDraft === null
2840
+ ? null
2841
+ : readDraft(crumbSource, b.pathDraft).tail;
2842
+ const targetPath = browserTargetPath();
2843
+ const targetName = b.selected?.name ?? (b.parent === null ? "" : (crumbs[crumbs.length - 1]?.name ?? b.parent.path));
2844
+
2845
+ const crumbsHtml = b.pathDraft === null
2846
+ ? `<span class="db-crumbTrail" role="navigation">${crumbs.map((crumb, index) => `
2847
+ <span class="db-crumbSeat">${index > 0 ? `<span class="db-crumbChevron">${ICONS.chevron}</span>` : ""}
2848
+ <button type="button" class="db-crumb" data-action="db-crumb" data-path="${esc(crumb.path)}" ${parentInert ? "disabled" : ""}>${esc(crumb.name)}</button>
2849
+ </span>`).join("")}
2850
+ </span>
2851
+ <button type="button" class="db-crumbEditZone" data-action="db-edit" title="编辑路径" aria-label="编辑路径" ${parentInert ? "disabled" : ""}>${ICONS.edit}</button>`
2852
+ : `<input id="dbPathInput" class="db-pathInput" value="${esc(b.pathDraft)}" aria-label="编辑路径" spellcheck="false">`;
2853
+
2854
+ const leftFilter = twoPane ? null : typedPrefix;
2855
+ const rightFilter = twoPane ? typedPrefix : null;
2856
+ const leftHtml = `<div class="db-column">${b.parent === null ? "" : renderRows(b.parent.entries || [], b.selected?.path ?? null, "db-pick", leftFilter)}</div>`;
2857
+ const rightHtml = twoPane && b.child !== null
2858
+ ? `<span class="db-divider"></span><div class="db-column">${renderRows(b.child.entries || [], null, "db-advance", rightFilter)}</div>`
2859
+ : "";
2860
+
2861
+ const createDialog = b.folderDraft !== null ? `
2862
+ <div class="db-modal db-modalNested">
2863
+ <div class="db-modalMask" data-action="db-create-cancel"></div>
2864
+ <div class="db-modalCard db-createDialog" role="dialog" aria-modal="true">
2865
+ <div class="db-createBody">
2866
+ <h3 class="db-createTitle">新建文件夹</h3>
2867
+ <p class="db-createIn">在 ${esc(targetName)} 中创建</p>
2868
+ <input id="dbCreateInput" class="db-createInput" value="${esc(b.folderDraft)}" placeholder="未命名文件夹" ${b.creatingFolder ? "disabled" : ""}>
2869
+ ${b.createError ? `<div class="db-error" role="alert">${esc(b.createError)}</div>` : ""}
2870
+ <div class="db-createActions">
2871
+ <button type="button" class="db-btn db-btnOutline" data-action="db-create-cancel" ${b.creatingFolder ? "disabled" : ""}>取消</button>
2872
+ <button type="button" class="db-btn db-btnPrimary" data-action="db-create" ${b.creatingFolder || b.folderDraft.trim() === "" ? "disabled" : ""}>创建</button>
2873
+ </div>
2874
+ </div>
2875
+ </div>
2876
+ </div>` : "";
2877
+
2878
+ mount.innerHTML = `
2879
+ <div class="db-modal">
2880
+ <div class="db-modalMask" data-action="db-cancel"></div>
2881
+ <div class="db-modalCard db-dialog" role="dialog" aria-modal="true" aria-label="选择工作区目录">
2882
+ <div class="db-header">
2883
+ <h2 class="db-title">选择工作区目录</h2>
2884
+ <div class="db-crumbBar">${crumbsHtml}</div>
2885
+ </div>
2886
+ <div class="db-content">
2887
+ <div class="db-millerRow">${leftHtml}${rightHtml}</div>
2888
+ ${b.loading ? `<div class="db-status db-loadingFloat" role="status">加载中…</div>` : ""}
2889
+ ${b.error ? `<div class="db-error" role="alert">${esc(b.error)}</div>` : ""}
2890
+ </div>
2891
+ <div class="db-footerBar">
2892
+ <button type="button" class="db-btn db-btnOutline" data-action="db-new-folder" ${b.parent === null || b.loading || parentInert || draftPending ? "disabled" : ""}>${ICONS.plus}<span>新建文件夹</span></button>
2893
+ <button type="button" class="db-showHiddenToggle ${b.showHidden ? "db-showHiddenToggleActive" : ""}" aria-pressed="${b.showHidden}" data-action="db-toggle-hidden" ${parentInert ? "disabled" : ""}>显示隐藏文件${b.showHidden ? ICONS.check : ""}</button>
2894
+ <span class="db-footerGap"></span>
2895
+ <button type="button" class="db-btn db-btnOutline db-footerAction" data-action="db-cancel">取消</button>
2896
+ <button type="button" class="db-btn db-btnPrimary db-footerAction" data-action="db-open" ${targetPath === null || b.loading || parentInert || draftPending ? "disabled" : ""}>打开</button>
2897
+ </div>
2898
+ </div>
2899
+ </div>
2900
+ ${createDialog}`;
2901
+
2902
+ const pathInput = $("#dbPathInput");
2903
+ if (pathInput) {
2904
+ const start = pathInput.selectionStart;
2905
+ const end = pathInput.selectionEnd;
2906
+ pathInput.focus();
2907
+ if (start != null && end != null && start === end) pathInput.setSelectionRange(start, end);
2908
+ pathInput.addEventListener("keydown", (event) => {
2909
+ if (event.key === "Enter" && !event.isComposing) {
2910
+ event.preventDefault();
2911
+ const value = pathInput.value;
2912
+ if (value.trim() !== "") navigate(value);
2913
+ } else if (event.key === "Escape") {
2914
+ event.preventDefault();
2915
+ event.stopPropagation();
2916
+ cancelPathEdit();
2917
+ }
2918
+ });
2919
+ pathInput.addEventListener("input", () => {
2920
+ const caret = pathInput.selectionStart ?? pathInput.value.length;
2921
+ b.pathDraft = pathInput.value;
2922
+ renderBrowser();
2923
+ const next = $("#dbPathInput");
2924
+ if (next) next.setSelectionRange(caret, caret);
2925
+ });
2926
+ }
2927
+ const createInput = $("#dbCreateInput");
2928
+ if (createInput) {
2929
+ createInput.focus();
2930
+ createInput.addEventListener("input", () => {
2931
+ b.folderDraft = createInput.value;
2932
+ const createBtn = mount.querySelector('[data-action="db-create"]');
2933
+ if (createBtn) createBtn.disabled = b.creatingFolder || b.folderDraft.trim() === "";
2934
+ });
2935
+ createInput.addEventListener("keydown", (event) => {
2936
+ if (event.key === "Enter" && !event.isComposing) {
2937
+ event.preventDefault();
2938
+ createFolder();
2939
+ } else if (event.key === "Escape") {
2940
+ event.preventDefault();
2941
+ event.stopPropagation();
2942
+ if (!b.creatingFolder) {
2943
+ b.folderDraft = null;
2944
+ b.createError = null;
2945
+ renderBrowser();
2946
+ }
2947
+ }
2948
+ });
2949
+ }
2950
+ }
2951
+
2952
+ async function refreshWorkspaces() {
2953
+ const boot = await api("/api/bootstrap");
2954
+ S.workspaces = applySavedWorkspaceOrder(boot.workspaces || []);
2955
+ return S.workspaces;
2956
+ }
2957
+
2958
+ let toastTimer = 0;
2959
+ function showToast(message, type = "info") {
2960
+ $(".pi-toast")?.remove();
2961
+ const toast = el(`<div class="pi-toast pi-toast-${esc(type)}" role="status">${esc(message)}</div>`);
2962
+ document.body.appendChild(toast);
2963
+ clearTimeout(toastTimer);
2964
+ toastTimer = setTimeout(() => toast.remove(), 3600);
2965
+ }
2966
+
2967
+ function delay(ms) {
2968
+ return new Promise((resolve) => setTimeout(resolve, ms));
2969
+ }
2970
+
2971
+ async function waitForRuntimeReload(previousInstanceId, generation) {
2972
+ const deadline = Date.now() + 60_000;
2973
+ let sawOffline = false;
2974
+ while (Date.now() < deadline && generation === S.maintenanceGeneration) {
2975
+ await delay(300);
2976
+ try {
2977
+ const boot = await api(`/api/bootstrap?reload=${Date.now()}`);
2978
+ const changed = boot.instanceId && boot.instanceId !== previousInstanceId;
2979
+ if (changed || (sawOffline && !boot.instanceId)) {
2980
+ location.reload();
2981
+ return;
2982
+ }
2983
+ } catch {
2984
+ sawOffline = true;
2985
+ }
2986
+ }
2987
+ if (generation === S.maintenanceGeneration) throw new Error("重新加载超时,请刷新页面后重试");
2988
+ }
2989
+
2990
+ async function runMaintenance(kind) {
2991
+ if (S.maintenanceBusy) return;
2992
+ const generation = ++S.maintenanceGeneration;
2993
+ S.maintenanceBusy = kind;
2994
+ S.maintenanceMessage = {
2995
+ type: "info",
2996
+ text: kind === "update" ? "正在更新扩展,请勿关闭页面…" : "正在重新加载 Pi 运行时…",
2997
+ };
2998
+ renderOverlay();
2999
+ try {
3000
+ const endpoint = kind === "update"
3001
+ ? "/api/maintenance/update-and-reload"
3002
+ : "/api/maintenance/reload";
3003
+ const data = await post(endpoint);
3004
+ const previousInstanceId = data.instanceId || S.serverInstanceId;
3005
+ S.maintenanceMessage = {
3006
+ type: "success",
3007
+ text: kind === "update" ? "扩展更新完成,正在应用新版本…" : "重载已启动,正在恢复 Web UI…",
3008
+ };
3009
+ renderOverlay();
3010
+ await waitForRuntimeReload(previousInstanceId, generation);
3011
+ } catch (error) {
3012
+ if (generation !== S.maintenanceGeneration) return;
3013
+ S.maintenanceBusy = null;
3014
+ S.maintenanceMessage = { type: "error", text: error.message || String(error) };
3015
+ renderOverlay();
3016
+ }
3017
+ }
3018
+
3019
+ async function exportSessionLog(sessionId) {
3020
+ if (!sessionId) {
3021
+ showToast("当前没有可导出的会话", "warning");
3022
+ return;
3023
+ }
3024
+ const data = await post(`/api/sessions/${encodeURIComponent(sessionId)}/export_html`);
3025
+ if (data.downloadUrl) {
3026
+ const link = document.createElement("a");
3027
+ link.href = data.downloadUrl;
3028
+ link.download = data.filename || "pi-session.html";
3029
+ document.body.appendChild(link);
3030
+ link.click();
3031
+ link.remove();
3032
+ showToast(`已下载到浏览器下载目录:${data.filename || "pi-session.html"}`);
3033
+ } else if (data.path) {
3034
+ showToast(`已导出:${data.path}`);
3035
+ } else {
3036
+ showToast("会话导出失败", "error");
3037
+ }
3038
+ }
3039
+
3040
+ function insertComposerText(text) {
3041
+ if (!text) return;
3042
+ const input = $("#composerInput");
3043
+ const current = input?.value ?? S.draft;
3044
+ const start = input?.selectionStart ?? current.length;
3045
+ const end = input?.selectionEnd ?? start;
3046
+ const before = current.slice(0, start);
3047
+ const after = current.slice(end);
3048
+ const prefix = before && !/\s$/.test(before) ? " " : "";
3049
+ const suffix = after && !/^\s/.test(after) ? " " : "";
3050
+ const insertion = `${prefix}${text}${suffix}`;
3051
+ setDraftValue(`${before}${insertion}${after}`);
3052
+ closeCommandMenu();
3053
+ if (input) {
3054
+ input.value = S.draft;
3055
+ const cursor = before.length + insertion.length;
3056
+ input.setSelectionRange(cursor, cursor);
3057
+ input.focus({ preventScroll: true });
3058
+ syncComposerInput();
3059
+ }
3060
+ }
3061
+
3062
+ function setClipboardBusy(busy) {
3063
+ S.clipboardBusy = busy;
3064
+ const button = $('[data-action="paste-image"]');
3065
+ if (button) {
3066
+ button.disabled = busy;
3067
+ button.toggleAttribute("data-loading", busy);
3068
+ }
3069
+ }
3070
+
3071
+ async function deleteImageAttachment(id) {
3072
+ if (!id) return;
3073
+ await api(`/api/attachments/${encodeURIComponent(id)}`, { method: "DELETE" }).catch(() => {});
3074
+ }
3075
+
3076
+ async function pasteSystemClipboard() {
3077
+ if (S.clipboardBusy) return;
3078
+ if (!modelSupportsImages()) {
3079
+ showToast("当前模型不支持图片输入,请先切换视觉模型", "warning");
3080
+ return;
3081
+ }
3082
+ if (S.draftAttachments.length >= 4) {
3083
+ showToast("每条消息最多附加 4 张图片", "warning");
3084
+ return;
3085
+ }
3086
+ const sessionId = S.currentSessionId;
3087
+ setClipboardBusy(true);
3088
+ try {
3089
+ const data = await post("/api/clipboard/paste");
3090
+ if (S.currentSessionId !== sessionId) {
3091
+ if (data.kind === "image") await deleteImageAttachment(data.attachment?.id);
3092
+ showToast("会话已切换,本次粘贴已取消", "warning");
3093
+ return;
3094
+ }
3095
+ if (data.kind === "image") {
3096
+ if (!data.attachment?.id || !data.attachment?.url) throw new Error("服务端未返回有效图片附件");
3097
+ setDraftAttachments([...S.draftAttachments, data.attachment]);
3098
+ queueRender(true, { conversation: true });
3099
+ showToast("图片已附加到消息");
3100
+ } else {
3101
+ if (!data.text) throw new Error("剪贴板内容为空");
3102
+ insertComposerText(data.text);
3103
+ showToast("已粘贴剪贴板文本");
3104
+ }
3105
+ } catch (error) {
3106
+ showToast(error.message || String(error), "error");
3107
+ } finally {
3108
+ setClipboardBusy(false);
3109
+ }
3110
+ }
3111
+
3112
+ function restoreMessagesToDraft(sessionId, messages) {
3113
+ const restored = Array.isArray(messages)
3114
+ ? messages.filter((message) => typeof message === "string" && message.trim())
3115
+ : [];
3116
+ if (!restored.length) return 0;
3117
+ const restoredText = restored.join("\n\n");
3118
+ if (S.currentSessionId === sessionId) {
3119
+ setDraftValue([S.draft, restoredText].filter((value) => value?.trim()).join("\n\n"));
3120
+ const input = $("#composerInput");
3121
+ if (input) {
3122
+ input.value = S.draft;
3123
+ input.setSelectionRange(input.value.length, input.value.length);
3124
+ syncComposerInput();
3125
+ }
3126
+ } else {
3127
+ const failedDrafts = S.failedDrafts.get(sessionId) || [];
3128
+ failedDrafts.push(...restored);
3129
+ S.failedDrafts.set(sessionId, failedDrafts);
3130
+ }
3131
+ return restored.length;
3132
+ }
3133
+
3134
+ function applyPendingQueueSnapshot(sessionId, pendingQueue) {
3135
+ if (!sessionId || !pendingQueue) return;
3136
+ const snapshot = snapshotFor(sessionId);
3137
+ const steering = Array.isArray(pendingQueue.steering) ? pendingQueue.steering : [];
3138
+ const followUp = Array.isArray(pendingQueue.followUp) ? pendingQueue.followUp : [];
3139
+ S.snapshots.set(sessionId, {
3140
+ ...snapshot,
3141
+ pendingQueue: { steering, followUp },
3142
+ state: {
3143
+ ...(snapshot.state || {}),
3144
+ pendingMessageCount: steering.length + followUp.length,
3145
+ },
3146
+ });
3147
+ }
3148
+
3149
+ function focusComposerEnd() {
3150
+ requestAnimationFrame(() => {
3151
+ const input = $("#composerInput");
3152
+ input?.focus({ preventScroll: true });
3153
+ input?.setSelectionRange(input.value.length, input.value.length);
3154
+ });
3155
+ }
3156
+
3157
+ async function withdrawQueuedMessage(target) {
3158
+ const sessionId = S.currentSessionId;
3159
+ const kind = target.dataset.queueKind;
3160
+ const index = Number(target.dataset.queueIndex);
3161
+ const queue = snapshotFor(sessionId).pendingQueue?.[kind];
3162
+ const message = Array.isArray(queue) && Number.isInteger(index) ? queue[index] : undefined;
3163
+ if (!sessionId || (kind !== "steering" && kind !== "followUp") || typeof message !== "string") {
3164
+ showToast("队列已经变化,请重试", "warning");
3165
+ return;
3166
+ }
3167
+
3168
+ const busyKey = queueItemBusyKey(sessionId, kind, index, message);
3169
+ if (S.withdrawingQueueItems.has(busyKey) || S.dequeueSessions.has(sessionId)) return;
3170
+ S.withdrawingQueueItems.add(busyKey);
3171
+ queueRender(true, { sidebar: false, conversation: true, overlay: false });
3172
+ try {
3173
+ const result = await post(`/api/sessions/${encodeURIComponent(sessionId)}/withdraw_queue`, { kind, index, message });
3174
+ applyPendingQueueSnapshot(sessionId, result.pendingQueue);
3175
+ restoreMessagesToDraft(sessionId, [message]);
3176
+ showToast("消息已撤回到输入框");
3177
+ focusComposerEnd();
3178
+ } catch (error) {
3179
+ applyPendingQueueSnapshot(sessionId, error.data?.pendingQueue);
3180
+ const restored = [
3181
+ ...(error.data?.withdrawn ? [message] : []),
3182
+ ...(Array.isArray(error.data?.unrestoredMessages) ? error.data.unrestoredMessages : []),
3183
+ ];
3184
+ const restoredCount = restoreMessagesToDraft(sessionId, restored);
3185
+ showToast(
3186
+ restoredCount > 0 ? `${error.message};${restoredCount} 条消息已恢复到输入框` : error.message,
3187
+ "error",
3188
+ );
3189
+ if (restoredCount > 0) focusComposerEnd();
3190
+ } finally {
3191
+ S.withdrawingQueueItems.delete(busyKey);
3192
+ queueRender(true, { sidebar: false, conversation: true, overlay: false });
3193
+ }
3194
+ }
3195
+
3196
+ async function restoreAllQueuedMessages() {
3197
+ const sessionId = S.currentSessionId;
3198
+ if (!sessionId || S.dequeueSessions.has(sessionId)) return;
3199
+ if (pendingQueueEntries(snapshotFor(sessionId)).length === 0) {
3200
+ showToast("没有待恢复的队列消息", "warning");
3201
+ return;
3202
+ }
3203
+
3204
+ S.dequeueSessions.add(sessionId);
3205
+ queueRender(true, { sidebar: false, conversation: true, overlay: false });
3206
+ try {
3207
+ const result = await post(`/api/sessions/${encodeURIComponent(sessionId)}/dequeue`);
3208
+ applyPendingQueueSnapshot(sessionId, result.pendingQueue || { steering: [], followUp: [] });
3209
+ const restoredCount = restoreMessagesToDraft(sessionId, result.restoredMessages);
3210
+ showToast(`${restoredCount} 条队列消息已恢复到输入框`);
3211
+ focusComposerEnd();
3212
+ } catch (error) {
3213
+ showToast(error.message, "error");
3214
+ } finally {
3215
+ S.dequeueSessions.delete(sessionId);
3216
+ queueRender(true, { sidebar: false, conversation: true, overlay: false });
3217
+ }
3218
+ }
3219
+
3220
+ function clearComposerDraft(options = {}) {
3221
+ setDraftValue("");
3222
+ if (options.attachments) setDraftAttachments([]);
3223
+ closeCommandMenu();
3224
+ const input = $("#composerInput");
3225
+ if (input) {
3226
+ input.value = "";
3227
+ syncComposerInput();
3228
+ }
3229
+ }
3230
+
3231
+ async function executeLocalSlashCommand(text) {
3232
+ const match = /^\/([^\s]+)(?:\s+([\s\S]*))?$/.exec(text);
3233
+ if (!match) return false;
3234
+ const name = match[1];
3235
+ const args = (match[2] || "").trim();
3236
+ if (!WEB_SLASH_COMMANDS.some((command) => command.name === name) && name !== "thinking") return false;
3237
+
3238
+ const sessionId = S.currentSessionId;
3239
+ clearComposerDraft();
3240
+ if (name === "model") {
3241
+ await openModelSelector($('[data-action="model-menu"]'));
3242
+ } else if (name === "thinking") {
3243
+ await openThinkingSelector($('[data-action="thinking-menu"]'));
3244
+ } else if (name === "settings") {
3245
+ S.settingsOpen = true;
3246
+ closePopovers();
3247
+ renderOverlay();
3248
+ } else if (name === "new") {
3249
+ await ensureWorkspaceThen((ws) => createAndOpenSession(ws.id, ""));
3250
+ } else if (name === "rewind" || name === "tree") {
3251
+ await openHistoryView(name === "tree" ? "tree" : "rewind");
3252
+ } else if (name === "compact") {
3253
+ if (!sessionId) showToast("当前没有可压缩的会话", "warning");
3254
+ else {
3255
+ showToast("正在压缩当前会话…");
3256
+ await post(`/api/sessions/${encodeURIComponent(sessionId)}/compact`);
3257
+ showToast("上下文压缩完成");
3258
+ }
3259
+ } else if (name === "name") {
3260
+ if (!sessionId) showToast("请先创建会话", "warning");
3261
+ else {
3262
+ const value = args || prompt("会话名称:", snapshotFor(sessionId).session?.title || "");
3263
+ if (value?.trim()) {
3264
+ await post(`/api/sessions/${encodeURIComponent(sessionId)}/set_name`, { name: value.trim() });
3265
+ showToast("会话名称已更新");
3266
+ }
3267
+ }
3268
+ } else if (name === "export") {
3269
+ await exportSessionLog(sessionId);
3270
+ } else if (name === "copy") {
3271
+ const assistant = [...(currentSnapshot().messages || [])].reverse().find((message) => message.kind === "assistant");
3272
+ const value = assistantMessageText(assistant);
3273
+ if (!value) showToast("暂无可复制的助手回复", "warning");
3274
+ else {
3275
+ await navigator.clipboard.writeText(value);
3276
+ showToast("已复制最后一条助手回复");
3277
+ }
3278
+ } else if (name === "session") {
3279
+ const snap = currentSnapshot();
3280
+ const stats = snap.stats || {};
3281
+ const tokens = stats.tokens || {};
3282
+ const cu = stats.contextUsage || {};
3283
+ alert([
3284
+ `会话:${snap.session?.title || "新会话"}`,
3285
+ `路径:${snap.session?.cwd || "-"}`,
3286
+ `模型:${snap.state?.model?.provider || "-"}/${snap.state?.model?.id || "-"} · 思考 ${thinkingLabel(snap.state?.thinkingLevel || "off")}`,
3287
+ `上下文:${fmtNum(cu.tokens ?? 0)} / ${fmtNum(cu.contextWindow ?? 0)} tok(${Math.round(cu.percent ?? 0)}%)`,
3288
+ `输入 ${fmtNum(tokens.input ?? 0)} · 输出 ${fmtNum(tokens.output ?? 0)} tok · 成本 ${typeof stats.cost === "number" ? `$${stats.cost.toFixed(3)}` : "-"}`,
3289
+ ].join("\n"));
3290
+ }
3291
+ return true;
3292
+ }
3293
+
3294
+ async function sendDraft() {
3295
+ const text = S.draft.trim();
3296
+ const attachments = [...S.draftAttachments];
3297
+ if (!text && attachments.length === 0) return;
3298
+ if (attachments.length > 0) {
3299
+ if (currentSnapshot().session?.streaming) {
3300
+ showToast("生成过程中暂不支持排队图片,请停止当前生成后再发送", "warning");
3301
+ return;
3302
+ }
3303
+ if (!modelSupportsImages()) {
3304
+ showToast("当前模型不支持图片输入,请切换视觉模型", "warning");
3305
+ return;
3306
+ }
3307
+ if (text.startsWith("/")) {
3308
+ showToast("斜杠命令不能附带图片", "warning");
3309
+ return;
3310
+ }
3311
+ }
3312
+ try {
3313
+ if (attachments.length === 0 && await executeLocalSlashCommand(text)) return;
3314
+ } catch (error) {
3315
+ showToast(error.message || String(error), "error");
3316
+ return;
3317
+ }
3318
+ let sessionId = S.currentSessionId;
3319
+ if (!sessionId) {
3320
+ const ws = currentWorkspace();
3321
+ if (!ws) {
3322
+ const added = await promptWorkspace();
3323
+ if (!added) return;
3324
+ S.currentWorkspaceId = added.id;
3325
+ }
3326
+ const title = text.slice(0, 40) || "图片会话";
3327
+ sessionId = await createAndOpenSession(S.currentWorkspaceId || currentWorkspace().id, title, { keepDraft: true });
3328
+ if (!sessionId) return;
3329
+ }
3330
+ clearComposerDraft({ attachments: true });
3331
+ S.pendingSends.set(sessionId, (S.pendingSends.get(sessionId) || 0) + 1);
3332
+ queueRender(true);
3333
+ try {
3334
+ const result = await post(`/api/sessions/${encodeURIComponent(sessionId)}/prompt`, {
3335
+ message: text,
3336
+ attachmentIds: attachments.map((attachment) => attachment.id),
3337
+ });
3338
+ if (result?.queued && S.currentSessionId === sessionId) {
3339
+ showToast("消息已加入当前执行队列");
3340
+ }
3341
+ } catch (error) {
3342
+ const isCurrentSession = S.currentSessionId === sessionId;
3343
+ if (isCurrentSession) {
3344
+ const currentDraft = S.draft.trim();
3345
+ if (text) {
3346
+ if (!currentDraft) setDraftValue(text);
3347
+ else setDraftValue(`${S.draft}\n\n${text}`);
3348
+ }
3349
+ const knownIds = new Set(S.draftAttachments.map((attachment) => attachment.id));
3350
+ setDraftAttachments([...S.draftAttachments, ...attachments.filter((attachment) => !knownIds.has(attachment.id))]);
3351
+ const input = $("#composerInput");
3352
+ if (input) {
3353
+ input.value = S.draft;
3354
+ input.setSelectionRange(input.value.length, input.value.length);
3355
+ syncComposerInput();
3356
+ }
3357
+ } else {
3358
+ if (text) {
3359
+ const failedDrafts = S.failedDrafts.get(sessionId) || [];
3360
+ failedDrafts.push(text);
3361
+ S.failedDrafts.set(sessionId, failedDrafts);
3362
+ }
3363
+ const failedAttachments = S.failedAttachmentDrafts.get(sessionId) || [];
3364
+ failedAttachments.push(...attachments);
3365
+ S.failedAttachmentDrafts.set(sessionId, failedAttachments);
3366
+ }
3367
+ showToast(`发送失败,消息已保留${isCurrentSession ? "在输入框中" : "到原会话草稿"}:${error.message}`, "error");
3368
+ } finally {
3369
+ const pending = Math.max(0, (S.pendingSends.get(sessionId) || 1) - 1);
3370
+ if (pending > 0) S.pendingSends.set(sessionId, pending);
3371
+ else S.pendingSends.delete(sessionId);
3372
+ queueRender(true);
3373
+ }
3374
+ }
3375
+
3376
+ async function respondDialog(id, payload) {
3377
+ const sid = S.currentSessionId;
3378
+ if (!sid) return;
3379
+ try {
3380
+ await post(`/api/sessions/${encodeURIComponent(sid)}/extension_ui_response`, { id, ...payload });
3381
+ } catch (error) {
3382
+ alert(error.message);
3383
+ }
3384
+ queueRender(true);
3385
+ }
3386
+
3387
+ let globalClickHandler = null;
3388
+ let globalWarmHandler = null;
3389
+ let globalKeyHandler = null;
3390
+ let globalPointerHandler = null;
3391
+ let globalDragStartHandler = null;
3392
+ let globalDragOverHandler = null;
3393
+ let globalDropHandler = null;
3394
+ let globalDragEndHandler = null;
3395
+ function bindGlobalEvents() {
3396
+ if (globalClickHandler) document.removeEventListener("click", globalClickHandler, true);
3397
+ globalClickHandler = onClick;
3398
+ document.addEventListener("click", globalClickHandler, true);
3399
+ if (globalWarmHandler) document.removeEventListener("pointerover", globalWarmHandler, true);
3400
+ globalWarmHandler = onWarmHover;
3401
+ document.addEventListener("pointerover", globalWarmHandler, true);
3402
+ if (globalKeyHandler) document.removeEventListener("keydown", globalKeyHandler, true);
3403
+ globalKeyHandler = onGlobalKeydown;
3404
+ document.addEventListener("keydown", globalKeyHandler, true);
3405
+ if (globalPointerHandler) document.removeEventListener("pointerdown", globalPointerHandler, true);
3406
+ globalPointerHandler = onPanelResizeStart;
3407
+ document.addEventListener("pointerdown", globalPointerHandler, true);
3408
+ if (globalDragStartHandler) document.removeEventListener("dragstart", globalDragStartHandler, true);
3409
+ globalDragStartHandler = onWorkspaceDragStart;
3410
+ document.addEventListener("dragstart", globalDragStartHandler, true);
3411
+ if (globalDragOverHandler) document.removeEventListener("dragover", globalDragOverHandler, true);
3412
+ globalDragOverHandler = onWorkspaceDragOver;
3413
+ document.addEventListener("dragover", globalDragOverHandler, true);
3414
+ if (globalDropHandler) document.removeEventListener("drop", globalDropHandler, true);
3415
+ globalDropHandler = onWorkspaceDrop;
3416
+ document.addEventListener("drop", globalDropHandler, true);
3417
+ if (globalDragEndHandler) document.removeEventListener("dragend", globalDragEndHandler, true);
3418
+ globalDragEndHandler = onWorkspaceDragEnd;
3419
+ document.addEventListener("dragend", globalDragEndHandler, true);
3420
+ }
3421
+
3422
+ function persistPanelWidth(side) {
3423
+ if (side === "sidebar") localStorage.setItem("pi-web-sidebar-width", String(Math.round(S.sidebarWidth)));
3424
+ else localStorage.setItem("pi-web-file-preview-width", String(Math.round(S.filePreviewWidth)));
3425
+ }
3426
+
3427
+ function updatePanelWidth(side, value, persist = false) {
3428
+ const frame = $(".pI_x6G_frame");
3429
+ if (!frame) return;
3430
+ const frameWidth = frame.clientWidth || window.innerWidth;
3431
+ const { sidebarWidth, previewWidth } = currentPanelWidths();
3432
+ if (side === "sidebar") {
3433
+ const max = Math.min(420, Math.max(220, frameWidth - previewWidth - 360));
3434
+ S.sidebarWidth = clampPanelWidth(value, 220, max);
3435
+ } else {
3436
+ const max = Math.min(960, Math.max(320, frameWidth - sidebarWidth - 360));
3437
+ S.filePreviewWidth = clampPanelWidth(value, 320, max);
3438
+ }
3439
+ applyFrameLayout();
3440
+ if (persist) persistPanelWidth(side);
3441
+ }
3442
+
3443
+ function onPanelResizeStart(event) {
3444
+ const handle = event.target.closest(".pI_x6G_handle[data-side]");
3445
+ if (!handle || event.button !== 0 || matchMedia("(max-width: 900px)").matches) return;
3446
+ const side = handle.dataset.side;
3447
+ if ((side === "sidebar" && S.sidebarCollapsed) || (side === "details" && !S.filePreview)) return;
3448
+ const frame = handle.closest(".pI_x6G_frame");
3449
+ const rect = frame.getBoundingClientRect();
3450
+ event.preventDefault();
3451
+ frame.toggleAttribute("data-dragging", true);
3452
+ handle.dataset.dragging = "true";
3453
+ document.body.toggleAttribute("data-panel-resizing", true);
3454
+
3455
+ const move = (moveEvent) => {
3456
+ const width = side === "sidebar" ? moveEvent.clientX - rect.left : rect.right - moveEvent.clientX;
3457
+ updatePanelWidth(side, width);
3458
+ moveEvent.preventDefault();
3459
+ };
3460
+ const finish = () => {
3461
+ document.removeEventListener("pointermove", move, true);
3462
+ document.removeEventListener("pointerup", finish, true);
3463
+ document.removeEventListener("pointercancel", finish, true);
3464
+ frame.removeAttribute("data-dragging");
3465
+ delete handle.dataset.dragging;
3466
+ document.body.removeAttribute("data-panel-resizing");
3467
+ persistPanelWidth(side);
3468
+ };
3469
+ document.addEventListener("pointermove", move, true);
3470
+ document.addEventListener("pointerup", finish, true);
3471
+ document.addEventListener("pointercancel", finish, true);
3472
+ }
3473
+
3474
+ function onGlobalKeydown(event) {
3475
+ const workspaceRow = event.target.closest?.(".YDXeBa_projectRow[data-workspace][draggable=true]");
3476
+ if (workspaceRow && event.altKey && (event.key === "ArrowUp" || event.key === "ArrowDown")) {
3477
+ if (moveWorkspaceByOffset(workspaceRow.dataset.workspace, event.key === "ArrowUp" ? -1 : 1)) {
3478
+ event.preventDefault();
3479
+ }
3480
+ return;
3481
+ }
3482
+ const panelHandle = event.target.closest?.(".pI_x6G_handle[data-side]");
3483
+ if (panelHandle && (event.key === "ArrowLeft" || event.key === "ArrowRight")) {
3484
+ event.preventDefault();
3485
+ const side = panelHandle.dataset.side;
3486
+ const direction = event.key === "ArrowRight" ? 1 : -1;
3487
+ const current = side === "sidebar" ? S.sidebarWidth : S.filePreviewWidth;
3488
+ updatePanelWidth(side, current + (side === "sidebar" ? direction : -direction) * 24, true);
3489
+ return;
3490
+ }
3491
+ if (S.settingsOpen) {
3492
+ if (event.key === "Escape" && !S.maintenanceBusy) {
3493
+ event.preventDefault();
3494
+ S.settingsOpen = false;
3495
+ renderOverlay();
3496
+ }
3497
+ return;
3498
+ }
3499
+ if (S.historyView) {
3500
+ const activeTab = event.target?.closest?.(".pi-historyTab");
3501
+ if (activeTab && (event.key === "ArrowLeft" || event.key === "ArrowRight")) {
3502
+ event.preventDefault();
3503
+ const mode = activeTab.dataset.mode === "rewind" ? "tree" : "rewind";
3504
+ S.historyView = { ...S.historyView, mode, error: null };
3505
+ renderConversation();
3506
+ requestAnimationFrame(() => $(`.pi-historyTab[data-mode="${mode}"]`)?.focus());
3507
+ return;
3508
+ }
3509
+ return;
3510
+ }
3511
+ const composerInput = $("#composerInput");
3512
+ if (event.altKey && !event.ctrlKey && !event.metaKey && event.key.toLowerCase() === "q" && document.activeElement === composerInput) {
3513
+ event.preventDefault();
3514
+ event.stopPropagation();
3515
+ if (!event.repeat) void restoreAllQueuedMessages();
3516
+ return;
3517
+ }
3518
+ if (event.altKey && !event.ctrlKey && !event.metaKey && event.key.toLowerCase() === "v" && document.activeElement === composerInput && !currentSnapshot().dialogs?.length) {
3519
+ event.preventDefault();
3520
+ event.stopPropagation();
3521
+ void pasteSystemClipboard();
3522
+ }
3523
+ }
3524
+
3525
+ function onWarmHover(ev) {
3526
+ const row = ev.target?.closest?.(".YDXeBa_sessionRow[data-session]");
3527
+ if (!row || row.dataset.newSession) return;
3528
+ const id = row.dataset.session;
3529
+ if (!id || S.warmSent?.has(id)) return;
3530
+ S.warmSent = S.warmSent || new Set();
3531
+ S.warmSent.add(id);
3532
+ post(`/api/sessions/${encodeURIComponent(id)}/warm`).catch(() => {
3533
+ S.warmSent.delete(id);
3534
+ });
3535
+ }
3536
+
3537
+ async function onClick(ev) {
3538
+ if (S.searchOpen && !ev.target.closest(".qDHVXG_search")) {
3539
+ S.searchOpen = false;
3540
+ S.search = "";
3541
+ queueMicrotask(renderSidebar);
3542
+ }
3543
+ const fileLink = ev.target.closest("a.md-local-file");
3544
+ if (fileLink && !ev.ctrlKey && !ev.metaKey && !ev.shiftKey && !ev.altKey) {
3545
+ ev.preventDefault();
3546
+ closePopovers();
3547
+ await openFilePreview(
3548
+ fileLink.dataset.filePath,
3549
+ fileLink.dataset.fileLine ? Number(fileLink.dataset.fileLine) : null,
3550
+ fileLink.href,
3551
+ );
3552
+ return;
3553
+ }
3554
+ if (S.commandMenuOpen && !ev.target.closest(".pi-commandMenu, #composerInput, [data-action=commands]")) closeCommandMenu();
3555
+ const target = ev.target.closest("[data-action]");
3556
+ if (!target) {
3557
+ const newRow = ev.target.closest(".YDXeBa_sessionRow[data-new-session]");
3558
+ if (newRow) {
3559
+ closePopovers();
3560
+ await createAndOpenSession(newRow.dataset.workspace, "");
3561
+ return;
3562
+ }
3563
+ const sessionRow = ev.target.closest(".YDXeBa_sessionRow[data-session]");
3564
+ if (sessionRow) {
3565
+ closePopovers();
3566
+ await openSession(sessionRow.dataset.session);
3567
+ return;
3568
+ }
3569
+ const projectRow = ev.target.closest(".YDXeBa_projectRow[data-workspace]");
3570
+ if (projectRow) {
3571
+ const id = projectRow.dataset.workspace;
3572
+ if (S.collapsed.has(id)) S.collapsed.delete(id);
3573
+ else S.collapsed.add(id);
3574
+ queueRender(true);
3575
+ return;
3576
+ }
3577
+ if (!ev.target.closest(".pi-popover") && !ev.target.closest(".pi-popoverAnchor")) closePopovers();
3578
+ return;
3579
+ }
3580
+ const action = target.dataset.action;
3581
+ const sessionId = target.dataset.session || S.currentSessionId;
3582
+ if (action === "jump-conversation") {
3583
+ jumpToConversationTurn(target.dataset.turnTarget);
3584
+ } else if (action === "scroll-bottom") {
3585
+ scrollConversationToBottom();
3586
+ } else if (action === "file-preview-close") {
3587
+ closeFilePreview();
3588
+ } else if (action === "file-preview-reveal") {
3589
+ const path = S.filePreview?.path;
3590
+ if (!path) return;
3591
+ target.disabled = true;
3592
+ try {
3593
+ const result = await post("/api/fs/reveal", { path });
3594
+ showToast(`已在系统文件管理器中打开 ${result.path}`, "success");
3595
+ } catch (error) {
3596
+ showToast(error.message || String(error), "error");
3597
+ } finally {
3598
+ target.disabled = false;
3599
+ }
3600
+ } else if (action === "toggle-session-list") {
3601
+ const workspaceId = target.dataset.workspace;
3602
+ if (S.expandedSessionLists.has(workspaceId)) S.expandedSessionLists.delete(workspaceId);
3603
+ else S.expandedSessionLists.add(workspaceId);
3604
+ renderSidebar();
3605
+ } else if (action === "new-session") {
3606
+ await ensureWorkspaceThen(async (ws) => {
3607
+ await createAndOpenSession(ws.id, "");
3608
+ });
3609
+ } else if (action === "new-session-in-workspace") {
3610
+ const workspaceId = target.dataset.workspace;
3611
+ S.collapsed.delete(workspaceId);
3612
+ renderSidebar();
3613
+ await createAndOpenSession(workspaceId, "");
3614
+ } else if (action === "add-workspace" || action === "hero-workspace") {
3615
+ await promptWorkspace();
3616
+ } else if (action === "select-workspace") {
3617
+ rememberCurrentDraft();
3618
+ S.currentWorkspaceId = target.dataset.workspace;
3619
+ const ws = S.workspaces.find((w) => w.id === S.currentWorkspaceId);
3620
+ const first = sessionsByRecency(ws)[0];
3621
+ if (first) await openSession(first.id);
3622
+ else {
3623
+ ++S.openGeneration;
3624
+ S.currentSessionId = null;
3625
+ S.loadingSession = null;
3626
+ loadCurrentDraftValue();
3627
+ queueRender(true);
3628
+ }
3629
+ } else if (action === "brand") {
3630
+ if (S.currentSessionId) {
3631
+ rememberCurrentDraft();
3632
+ ++S.openGeneration;
3633
+ S.currentSessionId = null;
3634
+ S.loadingSession = null;
3635
+ loadCurrentDraftValue();
3636
+ }
3637
+ queueRender(true);
3638
+ } else if (action === "open-mobile-sidebar") {
3639
+ S.mobileSidebarOpen = true;
3640
+ applyFrameLayout();
3641
+ renderSidebar();
3642
+ } else if (action === "close-mobile-sidebar") {
3643
+ S.mobileSidebarOpen = false;
3644
+ applyFrameLayout();
3645
+ } else if (action === "collapse-sidebar") {
3646
+ if (usesMobileSidebar()) {
3647
+ S.mobileSidebarOpen = false;
3648
+ } else {
3649
+ S.sidebarCollapsed = !S.sidebarCollapsed;
3650
+ localStorage.setItem("pi-web-sidebar-collapsed", S.sidebarCollapsed ? "1" : "0");
3651
+ }
3652
+ applyFrameLayout();
3653
+ renderSidebar();
3654
+ } else if (action === "settings") {
3655
+ S.settingsOpen = true;
3656
+ closePopovers();
3657
+ renderOverlay();
3658
+ } else if (action === "settings-close") {
3659
+ if (!S.maintenanceBusy) {
3660
+ S.settingsOpen = false;
3661
+ S.maintenanceMessage = null;
3662
+ renderOverlay();
3663
+ }
3664
+ } else if (action === "choose-theme") {
3665
+ setTheme(target.dataset.theme || "system");
3666
+ renderOverlay();
3667
+ } else if (action === "maintenance-update-reload") {
3668
+ await runMaintenance("update");
3669
+ } else if (action === "maintenance-reload") {
3670
+ await runMaintenance("reload");
3671
+ } else if (action === "focus-search") {
3672
+ S.searchOpen = !S.searchOpen;
3673
+ if (!S.searchOpen) S.search = "";
3674
+ queueRender(true, { sidebar: true });
3675
+ if (S.searchOpen) {
3676
+ const input = $("#sessionSearchInput");
3677
+ input?.focus();
3678
+ }
3679
+ } else if (action === "send") {
3680
+ await sendDraft();
3681
+ } else if (action === "paste-image") {
3682
+ await pasteSystemClipboard();
3683
+ } else if (action === "remove-attachment") {
3684
+ const attachmentId = target.dataset.attachmentId;
3685
+ setDraftAttachments(S.draftAttachments.filter((attachment) => attachment.id !== attachmentId));
3686
+ await deleteImageAttachment(attachmentId);
3687
+ queueRender(true, { conversation: true });
3688
+ } else if (action === "withdraw-queued-message") {
3689
+ await withdrawQueuedMessage(target);
3690
+ } else if (action === "conversation-tab") {
3691
+ showConversationView();
3692
+ } else if (action === "history-current" || action === "history-open") {
3693
+ closePopovers();
3694
+ if (sessionId && sessionId !== S.currentSessionId) await openSession(sessionId);
3695
+ await openHistoryView("rewind");
3696
+ } else if (action === "history-mode") {
3697
+ if (S.historyView) {
3698
+ S.historyView = { ...S.historyView, mode: target.dataset.mode === "tree" ? "tree" : "rewind", selectedEntryId: null, error: null };
3699
+ renderConversation();
3700
+ }
3701
+ } else if (action === "history-choose-rewind") {
3702
+ if (S.historyView && !S.historyView.busyEntryId) {
3703
+ S.historyView = { ...S.historyView, selectedEntryId: target.dataset.entry, error: null };
3704
+ renderConversation();
3705
+ }
3706
+ } else if (action === "history-rewind-mode") {
3707
+ await navigateHistory(target.dataset.entry, target.dataset.role, target.dataset.restoreMode);
3708
+ } else if (action === "history-rewind-cancel") {
3709
+ if (S.historyView && !S.historyView.busyEntryId) {
3710
+ S.historyView = { ...S.historyView, selectedEntryId: null, error: null };
3711
+ renderConversation();
3712
+ }
3713
+ } else if (action === "history-navigate") {
3714
+ await navigateHistory(target.dataset.entry, target.dataset.role);
3715
+ } else if (action === "abort") {
3716
+ if (sessionId) {
3717
+ target.disabled = true;
3718
+ try {
3719
+ const result = await post(`/api/sessions/${encodeURIComponent(sessionId)}/abort`);
3720
+ const restoredCount = restoreMessagesToDraft(sessionId, result.restoredMessages);
3721
+ const message = restoredCount
3722
+ ? `已停止生成,${restoredCount} 条待处理消息已恢复到输入框`
3723
+ : result.forced ? result.warning || "生成未响应,已重启该会话" : "已请求停止生成";
3724
+ showToast(message, result.forced ? "warning" : "info");
3725
+ } catch (error) {
3726
+ showToast(error.message, "error");
3727
+ } finally {
3728
+ target.disabled = false;
3729
+ queueRender(true);
3730
+ }
3731
+ }
3732
+ } else if (action === "model-menu") {
3733
+ await openModelSelector(target);
3734
+ } else if (action === "thinking-menu") {
3735
+ await openThinkingSelector(target);
3736
+ } else if (action === "choose-model") {
3737
+ const snap = currentSnapshot();
3738
+ const selected = (snap.models || []).find(
3739
+ (model) => model.provider === target.dataset.provider && model.id === target.dataset.model,
3740
+ );
3741
+ const key = `${target.dataset.provider}/${target.dataset.model}`;
3742
+ const thinkingLevels = snap.thinkingLevelsByModel?.[key] || (selected?.reasoning === false ? ["off"] : snap.thinkingLevels || ["off"]);
3743
+ const thinkingLevel = thinkingLevels.includes(snap.state?.thinkingLevel) ? snap.state.thinkingLevel : thinkingLevels.includes("off") ? "off" : thinkingLevels[0] || "off";
3744
+ if (sessionId) {
3745
+ try {
3746
+ await post(`/api/sessions/${encodeURIComponent(sessionId)}/set_model`, { provider: target.dataset.provider, modelId: target.dataset.model });
3747
+ } catch (error) {
3748
+ showToast(error.message, "error");
3749
+ return;
3750
+ }
3751
+ if (selected) {
3752
+ S.snapshots.set(sessionId, { ...snap, state: { ...(snap.state || {}), model: selected, thinkingLevel }, thinkingLevels });
3753
+ }
3754
+ } else if (selected) {
3755
+ S.newSessionSnapshot = {
3756
+ ...snap,
3757
+ state: { ...(snap.state || {}), model: selected, thinkingLevel },
3758
+ thinkingLevels,
3759
+ };
3760
+ }
3761
+ closePopovers();
3762
+ queueRender(true);
3763
+ } else if (action === "choose-thinking") {
3764
+ if (sessionId) {
3765
+ try {
3766
+ await post(`/api/sessions/${encodeURIComponent(sessionId)}/set_thinking_level`, { level: target.dataset.level });
3767
+ } catch (error) {
3768
+ showToast(error.message, "error");
3769
+ return;
3770
+ }
3771
+ const snap = snapshotFor(sessionId);
3772
+ S.snapshots.set(sessionId, { ...snap, state: { ...(snap.state || {}), thinkingLevel: target.dataset.level } });
3773
+ } else if (S.newSessionSnapshot) {
3774
+ S.newSessionSnapshot = {
3775
+ ...S.newSessionSnapshot,
3776
+ state: { ...(S.newSessionSnapshot.state || {}), thinkingLevel: target.dataset.level },
3777
+ };
3778
+ }
3779
+ closePopovers();
3780
+ queueRender(true);
3781
+ } else if (action === "choose-command") {
3782
+ const command = availableSlashCommands().find((item) => item.name === target.dataset.command);
3783
+ await applyCommandSuggestion(command, false);
3784
+ } else if (action === "copy-text") {
3785
+ try {
3786
+ await navigator.clipboard.writeText(target.dataset.text || "");
3787
+ } catch {
3788
+ // ignore
3789
+ }
3790
+ } else if (action === "copy-assistant-message") {
3791
+ const messageIndex = Number.parseInt(target.dataset.messageIndex || "", 10);
3792
+ const message = currentSnapshot().messages?.[messageIndex];
3793
+ const value = message?.kind === "assistant" ? assistantMessageText(message) : "";
3794
+ if (!value) {
3795
+ showToast("这条 AI 回复没有可复制的正文", "warning");
3796
+ return;
3797
+ }
3798
+ try {
3799
+ await navigator.clipboard.writeText(value);
3800
+ showToast("已复制本条 AI 回复");
3801
+ } catch (error) {
3802
+ showToast(`复制失败:${error.message || String(error)}`, "error");
3803
+ }
3804
+ } else if (action === "toggle-tool") {
3805
+ const card = target.closest(".CY-8Ka_root");
3806
+ const row = target.closest(".ztWv_q_callRow");
3807
+ const detail = row?.querySelector(".Sxvs8a_root");
3808
+ const expanded = detail ? !detail.hidden : false;
3809
+ if (detail) detail.hidden = expanded;
3810
+ card?.setAttribute("aria-expanded", String(!expanded));
3811
+ const toolKey = row?.dataset.toolKey;
3812
+ if (toolKey) {
3813
+ if (expanded) S.expandedTools.delete(toolKey);
3814
+ else S.expandedTools.add(toolKey);
3815
+ }
3816
+ } else if (action === "toggle-thinking") {
3817
+ const detail = target.closest(".QWLzlG_root")?.querySelector(".QWLzlG_detail");
3818
+ const row = target.closest(".QWLzlG_row");
3819
+ if (detail) {
3820
+ detail.hidden = !detail.hidden;
3821
+ row?.setAttribute("aria-expanded", String(!detail.hidden));
3822
+ }
3823
+ } else if (action === "toggle-compaction") {
3824
+ const row = target.closest(".pi-compactionCard");
3825
+ const detail = row?.querySelector(".gdEzaW_compactionBody");
3826
+ if (detail) {
3827
+ detail.hidden = !detail.hidden;
3828
+ target.setAttribute("aria-expanded", String(!detail.hidden));
3829
+ const key = row.dataset.compactionKey;
3830
+ if (key) {
3831
+ if (detail.hidden) S.expandedCompactions.delete(key);
3832
+ else S.expandedCompactions.add(key);
3833
+ }
3834
+ }
3835
+ } else if (action === "session-menu") {
3836
+ const snap = snapshotFor(sessionId);
3837
+ openPopover(target, renderSessionMenu(snap.session || { id: sessionId }));
3838
+ } else if (action === "session-menu-main") {
3839
+ const snap = currentSnapshot();
3840
+ openPopover(target, renderSessionMenu(snap.session || { id: sessionId }));
3841
+ } else if (action === "export-current") {
3842
+ try {
3843
+ await exportSessionLog(sessionId);
3844
+ } catch (error) {
3845
+ showToast(error.message || String(error), "error");
3846
+ }
3847
+ } else if (action === "rename-session") {
3848
+ const name = prompt("会话名称:", snapshotFor(sessionId).session?.title || "");
3849
+ if (name && sessionId) {
3850
+ await post(`/api/sessions/${encodeURIComponent(sessionId)}/set_name`, { name });
3851
+ closePopovers();
3852
+ queueRender(true);
3853
+ }
3854
+ } else if (action === "export-session") {
3855
+ if (sessionId) {
3856
+ try {
3857
+ await exportSessionLog(sessionId);
3858
+ } catch (error) {
3859
+ showToast(error.message || String(error), "error");
3860
+ }
3861
+ closePopovers();
3862
+ }
3863
+ } else if (action === "remove-session") {
3864
+ if (!sessionId) return;
3865
+ if (!confirm("永久删除这个会话?磁盘上的 pi 会话文件也会被删除,此操作不可撤销。")) return;
3866
+ const ws = S.workspaces.find((w) => w.sessions?.some((s) => s.id === sessionId));
3867
+ if (ws) {
3868
+ try {
3869
+ await post(`/api/workspaces/${encodeURIComponent(ws.id)}/remove-session`, { sessionId });
3870
+ } catch (error) {
3871
+ showToast(`删除会话失败:${error.message || String(error)}`, "error");
3872
+ return;
3873
+ }
3874
+ if (S.currentSessionId === sessionId) {
3875
+ const nextSessionId = ws.sessions?.find((s) => s.id !== sessionId)?.id || null;
3876
+ if (nextSessionId) await openSession(nextSessionId);
3877
+ else {
3878
+ rememberCurrentDraft();
3879
+ S.currentSessionId = null;
3880
+ S.loadingSession = null;
3881
+ loadCurrentDraftValue();
3882
+ }
3883
+ }
3884
+ const sessionDraftKey = draftKey(sessionId);
3885
+ const orphanedAttachments = [
3886
+ ...(S.attachmentDrafts.get(sessionDraftKey) || []),
3887
+ ...(S.failedAttachmentDrafts.get(sessionId) || []),
3888
+ ];
3889
+ await Promise.all(orphanedAttachments.map((attachment) => deleteImageAttachment(attachment.id)));
3890
+ S.snapshots.delete(sessionId);
3891
+ S.drafts.delete(sessionDraftKey);
3892
+ S.failedDrafts.delete(sessionId);
3893
+ S.attachmentDrafts.delete(sessionDraftKey);
3894
+ S.failedAttachmentDrafts.delete(sessionId);
3895
+ S.pendingSends.delete(sessionId);
3896
+ closePopovers();
3897
+ queueRender(true);
3898
+ }
3899
+ } else if (action === "dialog-select") {
3900
+ await respondDialog(currentSnapshot().dialogs?.[0]?.id, { value: target.dataset.value });
3901
+ } else if (action === "dialog-confirm") {
3902
+ await respondDialog(currentSnapshot().dialogs?.[0]?.id, { confirmed: target.dataset.value === "true" });
3903
+ } else if (action === "dialog-input") {
3904
+ const value = $(".pi-dialogInput")?.value || "";
3905
+ await respondDialog(currentSnapshot().dialogs?.[0]?.id, { value });
3906
+ } else if (action === "dialog-cancel") {
3907
+ await respondDialog(currentSnapshot().dialogs?.[0]?.id, { cancelled: true });
3908
+ } else if (action === "db-pick") {
3909
+ const entry = browser?.parent?.entries?.find((e) => e.path === target.dataset.path);
3910
+ if (entry) selectEntry(entry);
3911
+ } else if (action === "db-advance") {
3912
+ const entry = browser?.child?.entries?.find((e) => e.path === target.dataset.path);
3913
+ if (entry) advanceEntry(entry);
3914
+ } else if (action === "db-crumb") {
3915
+ if (target.dataset.path) navigate(target.dataset.path);
3916
+ } else if (action === "db-edit") {
3917
+ openPathEditor();
3918
+ } else if (action === "db-new-folder") {
3919
+ if (browser) {
3920
+ browser.folderDraft = "";
3921
+ browser.createError = null;
3922
+ renderBrowser();
3923
+ }
3924
+ } else if (action === "db-create") {
3925
+ createFolder();
3926
+ } else if (action === "db-create-cancel") {
3927
+ if (browser && !browser.creatingFolder) {
3928
+ browser.folderDraft = null;
3929
+ browser.createError = null;
3930
+ renderBrowser();
3931
+ }
3932
+ } else if (action === "db-toggle-hidden") {
3933
+ if (browser) {
3934
+ browser.showHidden = !browser.showHidden;
3935
+ renderBrowser();
3936
+ }
3937
+ } else if (action === "db-open") {
3938
+ adoptWorkspace();
3939
+ } else if (action === "db-cancel") {
3940
+ const resolve = pickerResolve;
3941
+ closeWorkspacePicker();
3942
+ resolve?.(null);
3943
+ } else if (action === "context-info") {
3944
+ const stats = currentSnapshot().stats;
3945
+ if (!stats) {
3946
+ alert("暂无统计");
3947
+ return;
3948
+ }
3949
+ const tokens = stats.tokens || {};
3950
+ const cu = stats.contextUsage || {};
3951
+ const hasContextUsage = typeof cu.tokens === "number" && Number.isFinite(cu.tokens)
3952
+ && typeof cu.contextWindow === "number" && Number.isFinite(cu.contextWindow) && cu.contextWindow > 0;
3953
+ const contextLine = hasContextUsage
3954
+ ? `上下文 ${fmtNum(cu.tokens)} / ${fmtNum(cu.contextWindow)} tok(${Math.round(cu.percent ?? 0)}%)`
3955
+ : cu.contextWindow ? `上下文占用待更新 / ${fmtNum(cu.contextWindow)} tok` : "上下文占用暂无统计";
3956
+ const lines = [
3957
+ contextLine,
3958
+ `输入 ${fmtNum(tokens.input ?? 0)} · 输出 ${fmtNum(tokens.output ?? 0)} · 缓存读 ${fmtNum(tokens.cacheRead ?? 0)} · 缓存写 ${fmtNum(tokens.cacheWrite ?? 0)} tok`,
3959
+ `消息 ${stats.totalMessages ?? "?"}(用户 ${stats.userMessages ?? "?"} / 助手 ${stats.assistantMessages ?? "?"} / 工具 ${stats.toolCalls ?? "?"})`,
3960
+ typeof stats.cost === "number" ? `成本 $${stats.cost.toFixed(3)}` : "",
3961
+ ].filter(Boolean);
3962
+ alert(lines.join("\n"));
3963
+ } else if (action === "view-options") {
3964
+ const ws = currentWorkspace();
3965
+ openPopover(target, `<div class="_7KE1Ra_menu pi-popover pi-menu" role="menu">
3966
+ <button type="button" role="menuitem" class="_7KE1Ra_cell" data-action="add-workspace"><span class="_7KE1Ra_cellLabel">添加工作区</span></button>
3967
+ ${ws ? `<button type="button" role="menuitem" class="_7KE1Ra_cell" data-action="discover-workspace" data-workspace="${esc(ws.id)}"><span class="_7KE1Ra_cellLabel">扫描当前工作区的 pi 会话</span></button>
3968
+ <button type="button" role="menuitem" class="_7KE1Ra_cell" data-action="remove-workspace" data-workspace="${esc(ws.id)}"><span class="_7KE1Ra_cellLabel">移除当前工作区</span></button>` : ""}
3969
+ <button type="button" role="menuitem" class="_7KE1Ra_cell" data-action="settings"><span class="_7KE1Ra_cellLabel">设置</span></button>
3970
+ </div>`);
3971
+ } else if (action === "pi-agent-chip") {
3972
+ alert("该 Web UI 由 pi coding agent 的 RPC 会话驱动。");
3973
+ } else if (action === "commands") {
3974
+ const input = $("#composerInput");
3975
+ setDraftValue("/");
3976
+ if (input) {
3977
+ input.value = S.draft;
3978
+ input.focus();
3979
+ input.setSelectionRange(1, 1);
3980
+ syncComposerInput();
3981
+ }
3982
+ openCommandMenu();
3983
+ } else if (action === "workspace-menu") {
3984
+ const ws = S.workspaces.find((w) => w.id === target.dataset.workspace);
3985
+ if (ws) {
3986
+ openPopover(target, `<div class="_7KE1Ra_menu pi-popover pi-menu" role="menu">
3987
+ <button type="button" role="menuitem" class="_7KE1Ra_cell" data-action="discover-workspace" data-workspace="${esc(ws.id)}"><span class="_7KE1Ra_cellLabel">扫描 pi 会话</span></button>
3988
+ <button type="button" role="menuitem" class="_7KE1Ra_cell" data-action="remove-workspace" data-workspace="${esc(ws.id)}"><span class="_7KE1Ra_cellLabel">移除工作区</span></button>
3989
+ </div>`);
3990
+ }
3991
+ } else if (action === "discover-workspace") {
3992
+ await post(`/api/workspaces/${encodeURIComponent(target.dataset.workspace)}/discover`);
3993
+ await refreshWorkspaces();
3994
+ closePopovers();
3995
+ queueRender(true);
3996
+ } else if (action === "remove-workspace") {
3997
+ if (!confirm("从侧边栏移除该工作区?磁盘上的会话文件会保留。")) return;
3998
+ await api(`/api/workspaces/${encodeURIComponent(target.dataset.workspace)}`, { method: "DELETE" });
3999
+ await refreshWorkspaces();
4000
+ const currentStillExists = S.workspaces.some((w) => w.sessions?.some((s) => s.id === S.currentSessionId));
4001
+ if (!currentStillExists) {
4002
+ const nextWorkspace = S.workspaces.find((w) => w.sessions?.length) || S.workspaces[0] || null;
4003
+ const nextSessionId = nextWorkspace?.sessions?.[0]?.id || null;
4004
+ if (nextWorkspace) S.currentWorkspaceId = nextWorkspace.id;
4005
+ if (nextSessionId) await openSession(nextSessionId);
4006
+ else {
4007
+ rememberCurrentDraft();
4008
+ S.currentSessionId = null;
4009
+ S.loadingSession = null;
4010
+ loadCurrentDraftValue();
4011
+ queueRender(true);
4012
+ }
4013
+ } else {
4014
+ queueRender(true);
4015
+ }
4016
+ }
4017
+ }
4018
+
4019
+ function moveWorkspace(workspaceId, targetId, position) {
4020
+ if (!workspaceId || !targetId || workspaceId === targetId) return;
4021
+ const next = [...S.workspaces];
4022
+ const fromIndex = next.findIndex((workspace) => workspace.id === workspaceId);
4023
+ if (fromIndex < 0) return;
4024
+ const [moved] = next.splice(fromIndex, 1);
4025
+ const targetIndex = next.findIndex((workspace) => workspace.id === targetId);
4026
+ if (targetIndex < 0) return;
4027
+ next.splice(position === "after" ? targetIndex + 1 : targetIndex, 0, moved);
4028
+ const unchanged = next.every((workspace, index) => workspace.id === S.workspaces[index]?.id);
4029
+ return unchanged ? undefined : next;
4030
+ }
4031
+
4032
+ function clearWorkspaceDropIndicators() {
4033
+ $$(".qDHVXG_workspaceDropBefore,.qDHVXG_workspaceDropAfter").forEach((section) => {
4034
+ section.classList.remove("qDHVXG_workspaceDropBefore", "qDHVXG_workspaceDropAfter");
4035
+ });
4036
+ }
4037
+
4038
+ function resetWorkspaceDrag() {
4039
+ clearWorkspaceDropIndicators();
4040
+ $(".YDXeBa_dragSource")?.classList.remove("YDXeBa_dragSource");
4041
+ document.body.removeAttribute("data-workspace-dragging");
4042
+ S.workspaceDragId = null;
4043
+ S.workspaceDragOverId = null;
4044
+ S.workspaceDragPosition = null;
4045
+ }
4046
+
4047
+ function workspaceDropRow(target) {
4048
+ const list = target.closest?.("#sessionListMount");
4049
+ if (!list) return null;
4050
+ const section = target.closest?.(".qDHVXG_groupSection") || $$(".qDHVXG_groupSection", list).at(-1);
4051
+ return section?.querySelector(":scope > .YDXeBa_projectRow[data-workspace]") || null;
4052
+ }
4053
+
4054
+ async function commitWorkspaceOrder(next, focusWorkspaceId = null) {
4055
+ if (!next || S.workspaceDragPending) return;
4056
+ S.workspaceDragPending = true;
4057
+ S.workspaces = next;
4058
+ saveWorkspaceOrder(next);
4059
+ renderSidebar();
4060
+ if (focusWorkspaceId) {
4061
+ requestAnimationFrame(() => {
4062
+ $$(`.YDXeBa_projectRow[data-workspace]`).find((row) => row.dataset.workspace === focusWorkspaceId)?.focus();
4063
+ });
4064
+ }
4065
+ try {
4066
+ await post("/api/workspaces/reorder", { workspaceIds: next.map((workspace) => workspace.id) });
4067
+ } catch (error) {
4068
+ if (!/404|not found/i.test(error.message || String(error))) {
4069
+ showToast(`工作区顺序已保存在当前浏览器,服务端同步失败:${error.message || String(error)}`, "warning");
4070
+ }
4071
+ } finally {
4072
+ S.workspaceDragPending = false;
4073
+ }
4074
+ }
4075
+
4076
+ function moveWorkspaceByOffset(workspaceId, offset) {
4077
+ if (!workspaceId || S.search || S.workspaceDragPending) return false;
4078
+ const fromIndex = S.workspaces.findIndex((workspace) => workspace.id === workspaceId);
4079
+ const targetIndex = fromIndex + offset;
4080
+ if (fromIndex < 0 || targetIndex < 0 || targetIndex >= S.workspaces.length) return false;
4081
+ const next = [...S.workspaces];
4082
+ [next[fromIndex], next[targetIndex]] = [next[targetIndex], next[fromIndex]];
4083
+ void commitWorkspaceOrder(next, workspaceId);
4084
+ return true;
4085
+ }
4086
+
4087
+ function onWorkspaceDragStart(event) {
4088
+ const row = event.target.closest?.(".YDXeBa_projectRow[data-workspace][draggable=true]");
4089
+ if (!row || event.target.closest?.("button,input,textarea") || S.search || S.workspaceDragPending) return;
4090
+ S.workspaceDragId = row.dataset.workspace;
4091
+ S.workspaceDragOverId = null;
4092
+ S.workspaceDragPosition = null;
4093
+ event.dataTransfer.effectAllowed = "move";
4094
+ event.dataTransfer.setData("text/plain", row.dataset.workspace);
4095
+ row.classList.add("YDXeBa_dragSource");
4096
+ document.body.setAttribute("data-workspace-dragging", "");
4097
+ }
4098
+
4099
+ function onWorkspaceDragOver(event) {
4100
+ if (!S.workspaceDragId) return;
4101
+ const row = workspaceDropRow(event.target);
4102
+ if (!row) return;
4103
+ event.preventDefault();
4104
+ event.dataTransfer.dropEffect = "move";
4105
+ const list = row.closest("#sessionListMount");
4106
+ if (list) {
4107
+ const listRect = list.getBoundingClientRect();
4108
+ if (event.clientY < listRect.top + 32) list.scrollBy({ top: -20 });
4109
+ else if (event.clientY > listRect.bottom - 32) list.scrollBy({ top: 20 });
4110
+ }
4111
+ clearWorkspaceDropIndicators();
4112
+ if (row.dataset.workspace === S.workspaceDragId) {
4113
+ row.classList.add("YDXeBa_dragSource");
4114
+ S.workspaceDragOverId = null;
4115
+ S.workspaceDragPosition = null;
4116
+ document.body.setAttribute("data-workspace-dragging", "");
4117
+ return;
4118
+ }
4119
+ const rect = row.getBoundingClientRect();
4120
+ const position = event.clientY < rect.top + rect.height / 2 ? "before" : "after";
4121
+ const section = row.closest(".qDHVXG_groupSection");
4122
+ section?.classList.add(position === "before" ? "qDHVXG_workspaceDropBefore" : "qDHVXG_workspaceDropAfter");
4123
+ S.workspaceDragOverId = row.dataset.workspace;
4124
+ S.workspaceDragPosition = position;
4125
+ document.body.setAttribute("data-workspace-dragging", "");
4126
+ }
4127
+
4128
+ function onWorkspaceDrop(event) {
4129
+ if (!S.workspaceDragId) return;
4130
+ const row = workspaceDropRow(event.target);
4131
+ if (!row) return;
4132
+ event.preventDefault();
4133
+ const workspaceId = event.dataTransfer.getData("text/plain") || S.workspaceDragId;
4134
+ const targetId = S.workspaceDragOverId || row.dataset.workspace;
4135
+ const position = S.workspaceDragPosition || "after";
4136
+ const next = moveWorkspace(workspaceId, targetId, position);
4137
+ resetWorkspaceDrag();
4138
+ if (next) void commitWorkspaceOrder(next);
4139
+ }
4140
+
4141
+ function onWorkspaceDragEnd() {
4142
+ resetWorkspaceDrag();
4143
+ }
4144
+
4145
+ function connectEvents() {
4146
+ const es = new EventSource("/api/events");
4147
+ es.onmessage = (ev) => {
4148
+ try {
4149
+ const event = JSON.parse(ev.data);
4150
+ if (event.type === "heartbeat") return;
4151
+ if (event.type === "maintenance_error") {
4152
+ S.maintenanceGeneration += 1;
4153
+ S.maintenanceBusy = null;
4154
+ S.maintenanceMessage = { type: "error", text: event.message || "重新加载失败" };
4155
+ if (S.settingsOpen) renderOverlay();
4156
+ else showToast(S.maintenanceMessage.text, "error");
4157
+ return;
4158
+ }
4159
+ if (event.type === "extension_notify") {
4160
+ if (event.sessionId === S.currentSessionId) showToast(event.message || "", event.notifyType || "info");
4161
+ return;
4162
+ }
4163
+ if (event.type === "session_error") {
4164
+ if (event.sessionId === S.currentSessionId) showToast(event.message || "模型调用失败", "error");
4165
+ return;
4166
+ }
4167
+ if (event.type === "editor_text") {
4168
+ if (event.sessionId === S.currentSessionId) {
4169
+ setDraftValue(event.text || "");
4170
+ closeCommandMenu();
4171
+ restoreDraft();
4172
+ syncComposerInput();
4173
+ queueRender(true, { sidebar: false, conversation: true, overlay: false });
4174
+ requestAnimationFrame(() => {
4175
+ const input = $("#composerInput");
4176
+ input?.focus();
4177
+ input?.setSelectionRange(input.value.length, input.value.length);
4178
+ });
4179
+ }
4180
+ return;
4181
+ }
4182
+ if (event.type === "snapshot") {
4183
+ if (!event.sessionId) return;
4184
+ S.snapshots.set(event.sessionId, { ...snapshotFor(event.sessionId), ...event });
4185
+ const current = event.sessionId === S.currentSessionId;
4186
+ queueRender(false, { sidebar: false, conversation: current, overlay: current });
4187
+ return;
4188
+ }
4189
+ if (event.type === "workspaces") {
4190
+ const next = applySavedWorkspaceOrder(event.workspaces || []);
4191
+ const unchanged = JSON.stringify(next) === JSON.stringify(S.workspaces);
4192
+ S.workspaces = next;
4193
+ resetWorkspaceDrag();
4194
+ let conversationChanged = false;
4195
+ if (S.currentSessionId && !S.workspaces.some((w) => w.sessions?.some((s) => s.id === S.currentSessionId))) {
4196
+ rememberCurrentDraft();
4197
+ const nextSessionId = S.workspaces[0]?.sessions?.[0]?.id || null;
4198
+ if (nextSessionId) {
4199
+ void openSession(nextSessionId);
4200
+ } else {
4201
+ ++S.openGeneration;
4202
+ S.currentSessionId = null;
4203
+ S.loadingSession = null;
4204
+ loadCurrentDraftValue();
4205
+ conversationChanged = true;
4206
+ }
4207
+ }
4208
+ if (unchanged && !conversationChanged) return;
4209
+ queueRender(false, { sidebar: true, conversation: conversationChanged, overlay: conversationChanged });
4210
+ }
4211
+ } catch {
4212
+ // ignore malformed event
4213
+ }
4214
+ };
4215
+ es.onerror = () => {
4216
+ // EventSource reconnects automatically.
4217
+ };
4218
+ }
4219
+
4220
+ window.addEventListener("resize", applyFrameLayout);
4221
+ bindGlobalEvents();
4222
+ loadBootstrap().catch((error) => {
4223
+ document.body.insertAdjacentHTML(
4224
+ "beforeend",
4225
+ `<div class="pi-dialogBackdrop"><div class="pi-dialogCard"><div class="pi-dialogTitle">无法连接到 pi-web</div><div class="pi-dialogMessage">${esc(error.message)}</div></div></div>`,
4226
+ );
4227
+ });
4228
+ connectEvents();