@poncho-ai/cli 0.14.1 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +6 -6
- package/CHANGELOG.md +26 -0
- package/dist/{chunk-AIEVSNGF.js → chunk-XT5HPFIW.js} +1025 -353
- package/dist/cli.js +1 -1
- package/dist/index.js +1 -1
- package/dist/{run-interactive-ink-7ULE5JJI.js → run-interactive-ink-2JQJDP7W.js} +1 -1
- package/package.json +4 -4
- package/src/index.ts +168 -5
- package/src/init-onboarding.ts +2 -4
- package/src/web-ui-client.ts +2605 -0
- package/src/web-ui-store.ts +340 -0
- package/src/web-ui-styles.ts +1340 -0
- package/src/web-ui.ts +64 -3809
- package/test/init-onboarding.contract.test.ts +2 -3
|
@@ -0,0 +1,2605 @@
|
|
|
1
|
+
export const getWebUiClientScript = (markedSource: string): string => `
|
|
2
|
+
// Marked library (inlined)
|
|
3
|
+
${markedSource}
|
|
4
|
+
|
|
5
|
+
// Configure marked for GitHub Flavored Markdown (tables, etc.)
|
|
6
|
+
marked.setOptions({
|
|
7
|
+
gfm: true,
|
|
8
|
+
breaks: true
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const state = {
|
|
12
|
+
csrfToken: "",
|
|
13
|
+
conversations: [],
|
|
14
|
+
activeConversationId: null,
|
|
15
|
+
activeMessages: [],
|
|
16
|
+
isStreaming: false,
|
|
17
|
+
activeStreamAbortController: null,
|
|
18
|
+
activeStreamConversationId: null,
|
|
19
|
+
activeStreamRunId: null,
|
|
20
|
+
isMessagesPinnedToBottom: true,
|
|
21
|
+
confirmDeleteId: null,
|
|
22
|
+
approvalRequestsInFlight: {},
|
|
23
|
+
pendingFiles: [],
|
|
24
|
+
contextTokens: 0,
|
|
25
|
+
contextWindow: 0,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const agentInitial = document.body.dataset.agentInitial || "A";
|
|
29
|
+
const $ = (id) => document.getElementById(id);
|
|
30
|
+
const elements = {
|
|
31
|
+
auth: $("auth"),
|
|
32
|
+
app: $("app"),
|
|
33
|
+
loginForm: $("login-form"),
|
|
34
|
+
passphrase: $("passphrase"),
|
|
35
|
+
loginError: $("login-error"),
|
|
36
|
+
list: $("conversation-list"),
|
|
37
|
+
newChat: $("new-chat"),
|
|
38
|
+
topbarNewChat: $("topbar-new-chat"),
|
|
39
|
+
messages: $("messages"),
|
|
40
|
+
chatTitle: $("chat-title"),
|
|
41
|
+
logout: $("logout"),
|
|
42
|
+
composer: $("composer"),
|
|
43
|
+
prompt: $("prompt"),
|
|
44
|
+
send: $("send"),
|
|
45
|
+
shell: $("app"),
|
|
46
|
+
sidebarToggle: $("sidebar-toggle"),
|
|
47
|
+
sidebarBackdrop: $("sidebar-backdrop"),
|
|
48
|
+
attachBtn: $("attach-btn"),
|
|
49
|
+
fileInput: $("file-input"),
|
|
50
|
+
attachmentPreview: $("attachment-preview"),
|
|
51
|
+
dragOverlay: $("drag-overlay"),
|
|
52
|
+
lightbox: $("lightbox"),
|
|
53
|
+
contextRingFill: $("context-ring-fill"),
|
|
54
|
+
contextTooltip: $("context-tooltip"),
|
|
55
|
+
sendBtnWrapper: $("send-btn-wrapper"),
|
|
56
|
+
browserPanel: $("browser-panel"),
|
|
57
|
+
browserPanelResize: $("browser-panel-resize"),
|
|
58
|
+
browserPanelFrame: $("browser-panel-frame"),
|
|
59
|
+
browserPanelUrl: $("browser-panel-url"),
|
|
60
|
+
browserPanelPlaceholder: $("browser-panel-placeholder"),
|
|
61
|
+
browserPanelClose: $("browser-panel-close"),
|
|
62
|
+
browserNavBack: $("browser-nav-back"),
|
|
63
|
+
browserNavForward: $("browser-nav-forward"),
|
|
64
|
+
};
|
|
65
|
+
const sendIconMarkup =
|
|
66
|
+
'<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M8 12V4M4 7l4-4 4 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
67
|
+
const stopIconMarkup =
|
|
68
|
+
'<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="4" y="4" width="8" height="8" rx="2" fill="currentColor"/></svg>';
|
|
69
|
+
|
|
70
|
+
const CONTEXT_RING_CIRCUMFERENCE = 2 * Math.PI * 14.5;
|
|
71
|
+
const formatTokenCount = (n) => {
|
|
72
|
+
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1).replace(/\\.0$/, "") + "M";
|
|
73
|
+
if (n >= 1_000) return (n / 1_000).toFixed(1).replace(/\\.0$/, "") + "k";
|
|
74
|
+
return String(n);
|
|
75
|
+
};
|
|
76
|
+
const updateContextRing = () => {
|
|
77
|
+
const ring = elements.contextRingFill;
|
|
78
|
+
const tooltip = elements.contextTooltip;
|
|
79
|
+
if (!ring || !tooltip) return;
|
|
80
|
+
if (state.contextWindow <= 0) {
|
|
81
|
+
ring.style.strokeDasharray = String(CONTEXT_RING_CIRCUMFERENCE);
|
|
82
|
+
ring.style.strokeDashoffset = String(CONTEXT_RING_CIRCUMFERENCE);
|
|
83
|
+
tooltip.textContent = "";
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const ratio = Math.min(state.contextTokens / state.contextWindow, 1);
|
|
87
|
+
const offset = CONTEXT_RING_CIRCUMFERENCE * (1 - ratio);
|
|
88
|
+
ring.style.strokeDasharray = String(CONTEXT_RING_CIRCUMFERENCE);
|
|
89
|
+
ring.style.strokeDashoffset = String(offset);
|
|
90
|
+
ring.classList.toggle("warning", ratio >= 0.7 && ratio < 0.9);
|
|
91
|
+
ring.classList.toggle("critical", ratio >= 0.9);
|
|
92
|
+
const pct = (ratio * 100).toFixed(1).replace(/\\.0$/, "");
|
|
93
|
+
tooltip.textContent = formatTokenCount(state.contextTokens) + " / " + formatTokenCount(state.contextWindow) + " tokens (" + pct + "%)";
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const pushConversationUrl = (conversationId) => {
|
|
97
|
+
const target = conversationId ? "/c/" + encodeURIComponent(conversationId) : "/";
|
|
98
|
+
if (window.location.pathname !== target) {
|
|
99
|
+
history.pushState({ conversationId: conversationId || null }, "", target);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const replaceConversationUrl = (conversationId) => {
|
|
104
|
+
const target = conversationId ? "/c/" + encodeURIComponent(conversationId) : "/";
|
|
105
|
+
if (window.location.pathname !== target) {
|
|
106
|
+
history.replaceState({ conversationId: conversationId || null }, "", target);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const getConversationIdFromUrl = () => {
|
|
111
|
+
const match = window.location.pathname.match(/^\\/c\\/([^\\/]+)/);
|
|
112
|
+
return match ? decodeURIComponent(match[1]) : null;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const mutatingMethods = new Set(["POST", "PATCH", "PUT", "DELETE"]);
|
|
116
|
+
|
|
117
|
+
const api = async (path, options = {}) => {
|
|
118
|
+
const method = (options.method || "GET").toUpperCase();
|
|
119
|
+
const headers = { ...(options.headers || {}) };
|
|
120
|
+
if (mutatingMethods.has(method) && state.csrfToken) {
|
|
121
|
+
headers["x-csrf-token"] = state.csrfToken;
|
|
122
|
+
}
|
|
123
|
+
if (options.body && !headers["Content-Type"]) {
|
|
124
|
+
headers["Content-Type"] = "application/json";
|
|
125
|
+
}
|
|
126
|
+
const response = await fetch(path, { credentials: "include", ...options, method, headers });
|
|
127
|
+
if (!response.ok) {
|
|
128
|
+
let payload = {};
|
|
129
|
+
try { payload = await response.json(); } catch {}
|
|
130
|
+
const error = new Error(payload.message || ("Request failed: " + response.status));
|
|
131
|
+
error.status = response.status;
|
|
132
|
+
error.payload = payload;
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
const contentType = response.headers.get("content-type") || "";
|
|
136
|
+
if (contentType.includes("application/json")) {
|
|
137
|
+
return await response.json();
|
|
138
|
+
}
|
|
139
|
+
return await response.text();
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const escapeHtml = (value) =>
|
|
143
|
+
String(value || "")
|
|
144
|
+
.replace(/&/g, "&")
|
|
145
|
+
.replace(/</g, "<")
|
|
146
|
+
.replace(/>/g, ">")
|
|
147
|
+
.replace(/"/g, """)
|
|
148
|
+
.replace(/'/g, "'");
|
|
149
|
+
|
|
150
|
+
const renderAssistantMarkdown = (value) => {
|
|
151
|
+
const source = String(value || "").trim();
|
|
152
|
+
if (!source) return "<p></p>";
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
return marked.parse(source);
|
|
156
|
+
} catch (error) {
|
|
157
|
+
console.error("Markdown parsing error:", error);
|
|
158
|
+
// Fallback to escaped text
|
|
159
|
+
return "<p>" + escapeHtml(source) + "</p>";
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const extractToolActivity = (value) => {
|
|
164
|
+
const source = String(value || "");
|
|
165
|
+
let markerIndex = source.lastIndexOf("\\n### Tool activity\\n");
|
|
166
|
+
if (markerIndex < 0 && source.startsWith("### Tool activity\\n")) {
|
|
167
|
+
markerIndex = 0;
|
|
168
|
+
}
|
|
169
|
+
if (markerIndex < 0) {
|
|
170
|
+
return { content: source, activities: [] };
|
|
171
|
+
}
|
|
172
|
+
const content = markerIndex === 0 ? "" : source.slice(0, markerIndex).trimEnd();
|
|
173
|
+
const rawSection = markerIndex === 0 ? source : source.slice(markerIndex + 1);
|
|
174
|
+
const afterHeading = rawSection.replace(/^### Tool activity\\s*\\n?/, "");
|
|
175
|
+
const activities = afterHeading
|
|
176
|
+
.split("\\n")
|
|
177
|
+
.map((line) => line.trim())
|
|
178
|
+
.filter((line) => line.startsWith("- "))
|
|
179
|
+
.map((line) => line.slice(2).trim())
|
|
180
|
+
.filter(Boolean);
|
|
181
|
+
return { content, activities };
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const renderApprovalRequests = (requests) => {
|
|
185
|
+
if (!Array.isArray(requests) || requests.length === 0) {
|
|
186
|
+
return "";
|
|
187
|
+
}
|
|
188
|
+
const rows = requests
|
|
189
|
+
.map((req) => {
|
|
190
|
+
const approvalId = typeof req.approvalId === "string" ? req.approvalId : "";
|
|
191
|
+
const tool = typeof req.tool === "string" ? req.tool : "tool";
|
|
192
|
+
const input = req.input != null ? req.input : {};
|
|
193
|
+
const submitting = req.state === "submitting";
|
|
194
|
+
const approveLabel = submitting && req.pendingDecision === "approve" ? "Approving..." : "Approve";
|
|
195
|
+
const denyLabel = submitting && req.pendingDecision === "deny" ? "Denying..." : "Deny";
|
|
196
|
+
const errorHtml = req._error
|
|
197
|
+
? '<div style="color: var(--deny); font-size: 11px; margin-top: 4px;">Submit failed: ' + escapeHtml(req._error) + "</div>"
|
|
198
|
+
: "";
|
|
199
|
+
return (
|
|
200
|
+
'<div class="approval-request-item">' +
|
|
201
|
+
'<div class="approval-requests-label">Approval required: <code>' +
|
|
202
|
+
escapeHtml(tool) +
|
|
203
|
+
"</code></div>" +
|
|
204
|
+
renderInputTable(input) +
|
|
205
|
+
errorHtml +
|
|
206
|
+
'<div class="approval-request-actions">' +
|
|
207
|
+
'<button class="approval-action-btn approve" data-approval-id="' +
|
|
208
|
+
escapeHtml(approvalId) +
|
|
209
|
+
'" data-approval-decision="approve" ' +
|
|
210
|
+
(submitting ? "disabled" : "") +
|
|
211
|
+
">" +
|
|
212
|
+
approveLabel +
|
|
213
|
+
"</button>" +
|
|
214
|
+
'<button class="approval-action-btn deny" data-approval-id="' +
|
|
215
|
+
escapeHtml(approvalId) +
|
|
216
|
+
'" data-approval-decision="deny" ' +
|
|
217
|
+
(submitting ? "disabled" : "") +
|
|
218
|
+
">" +
|
|
219
|
+
denyLabel +
|
|
220
|
+
"</button>" +
|
|
221
|
+
"</div>" +
|
|
222
|
+
"</div>"
|
|
223
|
+
);
|
|
224
|
+
})
|
|
225
|
+
.join("");
|
|
226
|
+
return (
|
|
227
|
+
'<div class="approval-requests">' +
|
|
228
|
+
rows +
|
|
229
|
+
"</div>"
|
|
230
|
+
);
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const extractToolImages = (output) => {
|
|
234
|
+
const images = [];
|
|
235
|
+
if (!output || typeof output !== "object") return images;
|
|
236
|
+
const check = (val) => {
|
|
237
|
+
if (val && typeof val === "object" && val.type === "file" && val.data && typeof val.mediaType === "string" && val.mediaType.startsWith("image/")) {
|
|
238
|
+
images.push(val);
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
if (Array.isArray(output)) { output.forEach(check); } else { Object.values(output).forEach(check); }
|
|
242
|
+
return images;
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const renderToolActivity = (items, approvalRequests = [], toolImages = []) => {
|
|
246
|
+
const hasItems = Array.isArray(items) && items.length > 0;
|
|
247
|
+
const hasApprovals = Array.isArray(approvalRequests) && approvalRequests.length > 0;
|
|
248
|
+
if (!hasItems && !hasApprovals) {
|
|
249
|
+
return "";
|
|
250
|
+
}
|
|
251
|
+
const chips = hasItems
|
|
252
|
+
? items
|
|
253
|
+
.map((item) => '<div class="tool-activity-item">' + escapeHtml(item) + "</div>")
|
|
254
|
+
.join("")
|
|
255
|
+
: "";
|
|
256
|
+
const disclosure = hasItems
|
|
257
|
+
? (
|
|
258
|
+
'<details class="tool-activity-disclosure">' +
|
|
259
|
+
'<summary class="tool-activity-summary">' +
|
|
260
|
+
'<span class="tool-activity-label">Tool activity</span>' +
|
|
261
|
+
'<span class="tool-activity-caret" aria-hidden="true"><svg viewBox="0 0 12 12" fill="none"><path d="M4.5 2.75L8 6L4.5 9.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></svg></span>' +
|
|
262
|
+
"</summary>" +
|
|
263
|
+
'<div class="tool-activity-list">' +
|
|
264
|
+
chips +
|
|
265
|
+
"</div>" +
|
|
266
|
+
"</details>"
|
|
267
|
+
)
|
|
268
|
+
: "";
|
|
269
|
+
const hasImages = Array.isArray(toolImages) && toolImages.length > 0;
|
|
270
|
+
const imagesHtml = hasImages
|
|
271
|
+
? '<div class="tool-images">' + toolImages.map((img) =>
|
|
272
|
+
'<img class="tool-screenshot" src="data:' + escapeHtml(img.mediaType) + ';base64,' + img.data + '" alt="' + escapeHtml(img.filename || "screenshot") + '" />'
|
|
273
|
+
).join("") + "</div>"
|
|
274
|
+
: "";
|
|
275
|
+
const cls = "tool-activity" + (hasApprovals ? " has-approvals" : "");
|
|
276
|
+
return (
|
|
277
|
+
'<div class="' + cls + '">' +
|
|
278
|
+
imagesHtml +
|
|
279
|
+
disclosure +
|
|
280
|
+
renderApprovalRequests(approvalRequests) +
|
|
281
|
+
"</div>"
|
|
282
|
+
);
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const renderInputTable = (input) => {
|
|
286
|
+
if (!input || typeof input !== "object") {
|
|
287
|
+
return '<div class="av-complex">' + escapeHtml(String(input ?? "{}")) + "</div>";
|
|
288
|
+
}
|
|
289
|
+
const keys = Object.keys(input);
|
|
290
|
+
if (keys.length === 0) {
|
|
291
|
+
return '<div class="av-complex">{}</div>';
|
|
292
|
+
}
|
|
293
|
+
const formatValue = (val) => {
|
|
294
|
+
if (val === null || val === undefined) return escapeHtml("null");
|
|
295
|
+
if (typeof val === "boolean" || typeof val === "number") return escapeHtml(String(val));
|
|
296
|
+
if (typeof val === "string") return escapeHtml(val);
|
|
297
|
+
try {
|
|
298
|
+
const replacer = (_, v) => typeof v === "bigint" ? String(v) : v;
|
|
299
|
+
return escapeHtml(JSON.stringify(val, replacer, 2));
|
|
300
|
+
} catch {
|
|
301
|
+
return escapeHtml("[unserializable]");
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
const rows = keys.map((key) => {
|
|
305
|
+
const val = input[key];
|
|
306
|
+
const isComplex = val !== null && typeof val === "object";
|
|
307
|
+
const cls = isComplex ? "av-complex" : "av";
|
|
308
|
+
return (
|
|
309
|
+
"<tr>" +
|
|
310
|
+
'<td class="ak">' + escapeHtml(key) + "</td>" +
|
|
311
|
+
'<td><div class="' + cls + '">' + formatValue(val) + "</div></td>" +
|
|
312
|
+
"</tr>"
|
|
313
|
+
);
|
|
314
|
+
}).join("");
|
|
315
|
+
return '<table class="approval-request-table">' + rows + "</table>";
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
const updatePendingApproval = (approvalId, updater) => {
|
|
319
|
+
if (!approvalId || typeof updater !== "function") {
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
const messages = state.activeMessages || [];
|
|
323
|
+
for (const message of messages) {
|
|
324
|
+
if (!message || !Array.isArray(message._pendingApprovals)) {
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
const idx = message._pendingApprovals.findIndex((req) => req.approvalId === approvalId);
|
|
328
|
+
if (idx < 0) {
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
const next = updater(message._pendingApprovals[idx], message._pendingApprovals);
|
|
332
|
+
if (next === null) {
|
|
333
|
+
message._pendingApprovals.splice(idx, 1);
|
|
334
|
+
} else if (next && typeof next === "object") {
|
|
335
|
+
message._pendingApprovals[idx] = next;
|
|
336
|
+
}
|
|
337
|
+
return true;
|
|
338
|
+
}
|
|
339
|
+
return false;
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
const toUiPendingApprovals = (pendingApprovals) => {
|
|
343
|
+
if (!Array.isArray(pendingApprovals)) {
|
|
344
|
+
return [];
|
|
345
|
+
}
|
|
346
|
+
return pendingApprovals
|
|
347
|
+
.map((item) => {
|
|
348
|
+
const approvalId =
|
|
349
|
+
item && typeof item.approvalId === "string" ? item.approvalId : "";
|
|
350
|
+
if (!approvalId) {
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
const toolName = item && typeof item.tool === "string" ? item.tool : "tool";
|
|
354
|
+
return {
|
|
355
|
+
approvalId,
|
|
356
|
+
tool: toolName,
|
|
357
|
+
input: item?.input ?? {},
|
|
358
|
+
state: "pending",
|
|
359
|
+
};
|
|
360
|
+
})
|
|
361
|
+
.filter(Boolean);
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
const hydratePendingApprovals = (messages, pendingApprovals) => {
|
|
365
|
+
const nextMessages = Array.isArray(messages) ? [...messages] : [];
|
|
366
|
+
const pending = toUiPendingApprovals(pendingApprovals);
|
|
367
|
+
if (pending.length === 0) {
|
|
368
|
+
return nextMessages;
|
|
369
|
+
}
|
|
370
|
+
const toolLines = pending.map((request) => "- approval required \\x60" + request.tool + "\\x60");
|
|
371
|
+
for (let idx = nextMessages.length - 1; idx >= 0; idx -= 1) {
|
|
372
|
+
const message = nextMessages[idx];
|
|
373
|
+
if (!message || message.role !== "assistant") {
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
const metadata = message.metadata && typeof message.metadata === "object" ? message.metadata : {};
|
|
377
|
+
const existingTimeline = Array.isArray(metadata.toolActivity) ? metadata.toolActivity : [];
|
|
378
|
+
const mergedTimeline = [...existingTimeline];
|
|
379
|
+
toolLines.forEach((line) => {
|
|
380
|
+
if (!mergedTimeline.includes(line)) {
|
|
381
|
+
mergedTimeline.push(line);
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
nextMessages[idx] = {
|
|
385
|
+
...message,
|
|
386
|
+
metadata: {
|
|
387
|
+
...metadata,
|
|
388
|
+
toolActivity: mergedTimeline,
|
|
389
|
+
},
|
|
390
|
+
_pendingApprovals: pending,
|
|
391
|
+
};
|
|
392
|
+
return nextMessages;
|
|
393
|
+
}
|
|
394
|
+
nextMessages.push({
|
|
395
|
+
role: "assistant",
|
|
396
|
+
content: "",
|
|
397
|
+
metadata: { toolActivity: toolLines },
|
|
398
|
+
_pendingApprovals: pending,
|
|
399
|
+
});
|
|
400
|
+
return nextMessages;
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
const formatDate = (epoch) => {
|
|
404
|
+
try {
|
|
405
|
+
const date = new Date(epoch);
|
|
406
|
+
const now = new Date();
|
|
407
|
+
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
|
408
|
+
const startOfDate = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
|
|
409
|
+
const dayDiff = Math.floor((startOfToday - startOfDate) / 86400000);
|
|
410
|
+
if (dayDiff === 0) {
|
|
411
|
+
return "Today";
|
|
412
|
+
}
|
|
413
|
+
if (dayDiff === 1) {
|
|
414
|
+
return "Yesterday";
|
|
415
|
+
}
|
|
416
|
+
if (dayDiff < 7 && dayDiff > 1) {
|
|
417
|
+
return date.toLocaleDateString(undefined, { weekday: "short" });
|
|
418
|
+
}
|
|
419
|
+
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
|
420
|
+
} catch {
|
|
421
|
+
return "";
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
const isMobile = () => window.matchMedia("(max-width: 900px)").matches;
|
|
426
|
+
|
|
427
|
+
const setSidebarOpen = (open) => {
|
|
428
|
+
if (!isMobile()) {
|
|
429
|
+
elements.shell.classList.remove("sidebar-open");
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
elements.shell.classList.toggle("sidebar-open", open);
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
const buildConversationItem = (c) => {
|
|
436
|
+
const item = document.createElement("div");
|
|
437
|
+
item.className = "conversation-item" + (c.conversationId === state.activeConversationId ? " active" : "");
|
|
438
|
+
|
|
439
|
+
if (c.hasPendingApprovals) {
|
|
440
|
+
const dot = document.createElement("span");
|
|
441
|
+
dot.className = "approval-dot";
|
|
442
|
+
item.appendChild(dot);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const titleSpan = document.createElement("span");
|
|
446
|
+
titleSpan.textContent = c.title;
|
|
447
|
+
item.appendChild(titleSpan);
|
|
448
|
+
|
|
449
|
+
const isConfirming = state.confirmDeleteId === c.conversationId;
|
|
450
|
+
const deleteBtn = document.createElement("button");
|
|
451
|
+
deleteBtn.className = "delete-btn" + (isConfirming ? " confirming" : "");
|
|
452
|
+
deleteBtn.textContent = isConfirming ? "sure?" : "\\u00d7";
|
|
453
|
+
deleteBtn.onclick = async (e) => {
|
|
454
|
+
e.stopPropagation();
|
|
455
|
+
if (!isConfirming) {
|
|
456
|
+
state.confirmDeleteId = c.conversationId;
|
|
457
|
+
renderConversationList();
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
await api("/api/conversations/" + c.conversationId, { method: "DELETE" });
|
|
461
|
+
if (state.activeConversationId === c.conversationId) {
|
|
462
|
+
state.activeConversationId = null;
|
|
463
|
+
state.activeMessages = [];
|
|
464
|
+
state.contextTokens = 0;
|
|
465
|
+
state.contextWindow = 0;
|
|
466
|
+
updateContextRing();
|
|
467
|
+
pushConversationUrl(null);
|
|
468
|
+
elements.chatTitle.textContent = "";
|
|
469
|
+
renderMessages([]);
|
|
470
|
+
}
|
|
471
|
+
state.confirmDeleteId = null;
|
|
472
|
+
await loadConversations();
|
|
473
|
+
};
|
|
474
|
+
item.appendChild(deleteBtn);
|
|
475
|
+
|
|
476
|
+
item.onclick = async () => {
|
|
477
|
+
if (state.confirmDeleteId) {
|
|
478
|
+
state.confirmDeleteId = null;
|
|
479
|
+
}
|
|
480
|
+
state.activeConversationId = c.conversationId;
|
|
481
|
+
pushConversationUrl(c.conversationId);
|
|
482
|
+
renderConversationList();
|
|
483
|
+
await loadConversation(c.conversationId);
|
|
484
|
+
if (isMobile()) setSidebarOpen(false);
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
return item;
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
const renderConversationList = () => {
|
|
491
|
+
elements.list.innerHTML = "";
|
|
492
|
+
const pending = state.conversations.filter(c => c.hasPendingApprovals);
|
|
493
|
+
const rest = state.conversations.filter(c => !c.hasPendingApprovals);
|
|
494
|
+
|
|
495
|
+
if (pending.length > 0) {
|
|
496
|
+
const label = document.createElement("div");
|
|
497
|
+
label.className = "sidebar-section-label";
|
|
498
|
+
label.textContent = "Awaiting approval";
|
|
499
|
+
elements.list.appendChild(label);
|
|
500
|
+
for (const c of pending) {
|
|
501
|
+
elements.list.appendChild(buildConversationItem(c));
|
|
502
|
+
}
|
|
503
|
+
if (rest.length > 0) {
|
|
504
|
+
const divider = document.createElement("div");
|
|
505
|
+
divider.className = "sidebar-section-divider";
|
|
506
|
+
elements.list.appendChild(divider);
|
|
507
|
+
const recentLabel = document.createElement("div");
|
|
508
|
+
recentLabel.className = "sidebar-section-label";
|
|
509
|
+
recentLabel.textContent = "Recent";
|
|
510
|
+
elements.list.appendChild(recentLabel);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
for (const c of rest) {
|
|
515
|
+
elements.list.appendChild(buildConversationItem(c));
|
|
516
|
+
}
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
const isNearBottom = (element, threshold = 64) => {
|
|
520
|
+
if (!element) return true;
|
|
521
|
+
return (
|
|
522
|
+
element.scrollHeight - element.clientHeight - element.scrollTop <= threshold
|
|
523
|
+
);
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
const renderMessages = (messages, isStreaming = false, options = {}) => {
|
|
527
|
+
const previousScrollTop = elements.messages.scrollTop;
|
|
528
|
+
const shouldStickToBottom =
|
|
529
|
+
options.forceScrollBottom === true || state.isMessagesPinnedToBottom;
|
|
530
|
+
|
|
531
|
+
const createThinkingIndicator = (label) => {
|
|
532
|
+
const status = document.createElement("div");
|
|
533
|
+
status.className = "thinking-status";
|
|
534
|
+
const spinner = document.createElement("span");
|
|
535
|
+
spinner.className = "thinking-indicator";
|
|
536
|
+
const starFrames = ["✶", "✸", "✹", "✺", "✹", "✷"];
|
|
537
|
+
let frame = 0;
|
|
538
|
+
spinner.textContent = starFrames[0];
|
|
539
|
+
spinner._interval = setInterval(() => {
|
|
540
|
+
frame = (frame + 1) % starFrames.length;
|
|
541
|
+
spinner.textContent = starFrames[frame];
|
|
542
|
+
}, 70);
|
|
543
|
+
status.appendChild(spinner);
|
|
544
|
+
if (label) {
|
|
545
|
+
const text = document.createElement("span");
|
|
546
|
+
text.className = "thinking-status-label";
|
|
547
|
+
text.textContent = label;
|
|
548
|
+
status.appendChild(text);
|
|
549
|
+
}
|
|
550
|
+
return status;
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
elements.messages.innerHTML = "";
|
|
554
|
+
if (!messages || !messages.length) {
|
|
555
|
+
elements.messages.innerHTML = '<div class="empty-state"><div class="assistant-avatar">' + agentInitial + '</div><div>How can I help you today?</div></div>';
|
|
556
|
+
elements.messages.scrollTop = 0;
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
const col = document.createElement("div");
|
|
560
|
+
col.className = "messages-column";
|
|
561
|
+
messages.forEach((m, i) => {
|
|
562
|
+
const row = document.createElement("div");
|
|
563
|
+
row.className = "message-row " + m.role;
|
|
564
|
+
if (m.role === "assistant") {
|
|
565
|
+
const wrap = document.createElement("div");
|
|
566
|
+
wrap.className = "assistant-wrap";
|
|
567
|
+
wrap.innerHTML = '<div class="assistant-avatar">' + agentInitial + '</div>';
|
|
568
|
+
const content = document.createElement("div");
|
|
569
|
+
content.className = "assistant-content";
|
|
570
|
+
const text = String(m.content || "");
|
|
571
|
+
const isLastAssistant = i === messages.length - 1;
|
|
572
|
+
const hasPendingApprovals =
|
|
573
|
+
Array.isArray(m._pendingApprovals) && m._pendingApprovals.length > 0;
|
|
574
|
+
const shouldRenderEmptyStreamingIndicator =
|
|
575
|
+
isStreaming &&
|
|
576
|
+
isLastAssistant &&
|
|
577
|
+
!text &&
|
|
578
|
+
(!Array.isArray(m._sections) || m._sections.length === 0) &&
|
|
579
|
+
(!Array.isArray(m._currentTools) || m._currentTools.length === 0) &&
|
|
580
|
+
!hasPendingApprovals;
|
|
581
|
+
|
|
582
|
+
if (m._error) {
|
|
583
|
+
const errorEl = document.createElement("div");
|
|
584
|
+
errorEl.className = "message-error";
|
|
585
|
+
errorEl.innerHTML = "<strong>Error</strong><br>" + escapeHtml(m._error);
|
|
586
|
+
content.appendChild(errorEl);
|
|
587
|
+
} else if (shouldRenderEmptyStreamingIndicator) {
|
|
588
|
+
content.appendChild(createThinkingIndicator(getThinkingStatusLabel(m)));
|
|
589
|
+
} else {
|
|
590
|
+
// Merge stored sections (persisted) with live sections (from
|
|
591
|
+
// an active stream). For normal messages only one source
|
|
592
|
+
// exists; for liveOnly reconnects both contribute.
|
|
593
|
+
const storedSections = (m.metadata && m.metadata.sections) || [];
|
|
594
|
+
const liveSections = m._sections || [];
|
|
595
|
+
const sections = liveSections.length > 0 && storedSections.length > 0
|
|
596
|
+
? storedSections.concat(liveSections)
|
|
597
|
+
: liveSections.length > 0 ? liveSections : (storedSections.length > 0 ? storedSections : null);
|
|
598
|
+
const pendingApprovals = Array.isArray(m._pendingApprovals) ? m._pendingApprovals : [];
|
|
599
|
+
|
|
600
|
+
if (sections && sections.length > 0) {
|
|
601
|
+
let lastToolsSectionIndex = -1;
|
|
602
|
+
for (let sectionIdx = sections.length - 1; sectionIdx >= 0; sectionIdx -= 1) {
|
|
603
|
+
if (sections[sectionIdx] && sections[sectionIdx].type === "tools") {
|
|
604
|
+
lastToolsSectionIndex = sectionIdx;
|
|
605
|
+
break;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
// Render sections interleaved
|
|
609
|
+
sections.forEach((section, sectionIdx) => {
|
|
610
|
+
if (section.type === "text") {
|
|
611
|
+
const textDiv = document.createElement("div");
|
|
612
|
+
textDiv.innerHTML = renderAssistantMarkdown(section.content);
|
|
613
|
+
content.appendChild(textDiv);
|
|
614
|
+
} else if (section.type === "tools") {
|
|
615
|
+
const sectionApprovals =
|
|
616
|
+
!isStreaming &&
|
|
617
|
+
pendingApprovals.length > 0 &&
|
|
618
|
+
sectionIdx === lastToolsSectionIndex
|
|
619
|
+
? pendingApprovals
|
|
620
|
+
: [];
|
|
621
|
+
const sectionImages =
|
|
622
|
+
sectionIdx === lastToolsSectionIndex
|
|
623
|
+
? (m._toolImages || [])
|
|
624
|
+
: [];
|
|
625
|
+
content.insertAdjacentHTML(
|
|
626
|
+
"beforeend",
|
|
627
|
+
renderToolActivity(section.content, sectionApprovals, sectionImages),
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
});
|
|
631
|
+
// While streaming, show current tools if any
|
|
632
|
+
if (isStreaming && i === messages.length - 1 && m._currentTools && m._currentTools.length > 0) {
|
|
633
|
+
content.insertAdjacentHTML(
|
|
634
|
+
"beforeend",
|
|
635
|
+
renderToolActivity(m._currentTools, m._pendingApprovals || [], m._toolImages || []),
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
// When reloading with unresolved approvals, show them even when not streaming
|
|
639
|
+
if (!isStreaming && pendingApprovals.length > 0 && lastToolsSectionIndex < 0) {
|
|
640
|
+
content.insertAdjacentHTML("beforeend", renderToolActivity([], m._pendingApprovals));
|
|
641
|
+
}
|
|
642
|
+
// Show current text being typed
|
|
643
|
+
if (isStreaming && i === messages.length - 1 && m._currentText) {
|
|
644
|
+
const textDiv = document.createElement("div");
|
|
645
|
+
textDiv.innerHTML = renderAssistantMarkdown(m._currentText);
|
|
646
|
+
content.appendChild(textDiv);
|
|
647
|
+
}
|
|
648
|
+
} else {
|
|
649
|
+
// Fallback: render text and tools the old way (for old messages without sections)
|
|
650
|
+
if (text) {
|
|
651
|
+
const parsed = extractToolActivity(text);
|
|
652
|
+
content.innerHTML = renderAssistantMarkdown(parsed.content);
|
|
653
|
+
}
|
|
654
|
+
const metadataToolActivity =
|
|
655
|
+
m.metadata && Array.isArray(m.metadata.toolActivity)
|
|
656
|
+
? m.metadata.toolActivity
|
|
657
|
+
: [];
|
|
658
|
+
if (metadataToolActivity.length > 0 || pendingApprovals.length > 0) {
|
|
659
|
+
content.insertAdjacentHTML(
|
|
660
|
+
"beforeend",
|
|
661
|
+
renderToolActivity(metadataToolActivity, pendingApprovals),
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
if (isStreaming && isLastAssistant && !hasPendingApprovals) {
|
|
666
|
+
const waitIndicator = document.createElement("div");
|
|
667
|
+
waitIndicator.appendChild(createThinkingIndicator(getThinkingStatusLabel(m)));
|
|
668
|
+
content.appendChild(waitIndicator);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
wrap.appendChild(content);
|
|
672
|
+
row.appendChild(wrap);
|
|
673
|
+
} else {
|
|
674
|
+
const bubble = document.createElement("div");
|
|
675
|
+
bubble.className = "user-bubble";
|
|
676
|
+
if (typeof m.content === "string") {
|
|
677
|
+
bubble.textContent = m.content;
|
|
678
|
+
} else if (Array.isArray(m.content)) {
|
|
679
|
+
const textParts = m.content.filter(p => p.type === "text").map(p => p.text).join("");
|
|
680
|
+
if (textParts) {
|
|
681
|
+
const textEl = document.createElement("div");
|
|
682
|
+
textEl.textContent = textParts;
|
|
683
|
+
bubble.appendChild(textEl);
|
|
684
|
+
}
|
|
685
|
+
const fileParts = m.content.filter(p => p.type === "file");
|
|
686
|
+
if (fileParts.length > 0) {
|
|
687
|
+
const filesEl = document.createElement("div");
|
|
688
|
+
filesEl.className = "user-file-attachments";
|
|
689
|
+
fileParts.forEach(fp => {
|
|
690
|
+
if (fp.mediaType && fp.mediaType.startsWith("image/")) {
|
|
691
|
+
const img = document.createElement("img");
|
|
692
|
+
if (fp._localBlob) {
|
|
693
|
+
if (!fp._cachedUrl) fp._cachedUrl = URL.createObjectURL(fp._localBlob);
|
|
694
|
+
img.src = fp._cachedUrl;
|
|
695
|
+
} else if (fp.data && fp.data.startsWith("poncho-upload://")) {
|
|
696
|
+
img.src = "/api/uploads/" + encodeURIComponent(fp.data.replace("poncho-upload://", ""));
|
|
697
|
+
} else if (fp.data && (fp.data.startsWith("http://") || fp.data.startsWith("https://"))) {
|
|
698
|
+
img.src = fp.data;
|
|
699
|
+
} else if (fp.data) {
|
|
700
|
+
img.src = "data:" + fp.mediaType + ";base64," + fp.data;
|
|
701
|
+
}
|
|
702
|
+
img.alt = fp.filename || "image";
|
|
703
|
+
filesEl.appendChild(img);
|
|
704
|
+
} else {
|
|
705
|
+
const badge = document.createElement("span");
|
|
706
|
+
badge.className = "user-file-badge";
|
|
707
|
+
badge.textContent = "📎 " + (fp.filename || "file");
|
|
708
|
+
filesEl.appendChild(badge);
|
|
709
|
+
}
|
|
710
|
+
});
|
|
711
|
+
bubble.appendChild(filesEl);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
row.appendChild(bubble);
|
|
715
|
+
}
|
|
716
|
+
col.appendChild(row);
|
|
717
|
+
});
|
|
718
|
+
elements.messages.appendChild(col);
|
|
719
|
+
if (shouldStickToBottom) {
|
|
720
|
+
elements.messages.scrollTop = elements.messages.scrollHeight;
|
|
721
|
+
state.isMessagesPinnedToBottom = true;
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
if (options.preserveScroll !== false) {
|
|
725
|
+
elements.messages.scrollTop = previousScrollTop;
|
|
726
|
+
}
|
|
727
|
+
};
|
|
728
|
+
|
|
729
|
+
const loadConversations = async () => {
|
|
730
|
+
const payload = await api("/api/conversations");
|
|
731
|
+
state.conversations = payload.conversations || [];
|
|
732
|
+
renderConversationList();
|
|
733
|
+
};
|
|
734
|
+
|
|
735
|
+
const loadConversation = async (conversationId) => {
|
|
736
|
+
if (window._resetBrowserPanel) window._resetBrowserPanel();
|
|
737
|
+
const payload = await api("/api/conversations/" + encodeURIComponent(conversationId));
|
|
738
|
+
elements.chatTitle.textContent = payload.conversation.title;
|
|
739
|
+
state.activeMessages = hydratePendingApprovals(
|
|
740
|
+
payload.conversation.messages || [],
|
|
741
|
+
payload.conversation.pendingApprovals || payload.pendingApprovals || [],
|
|
742
|
+
);
|
|
743
|
+
state.contextTokens = 0;
|
|
744
|
+
state.contextWindow = 0;
|
|
745
|
+
updateContextRing();
|
|
746
|
+
renderMessages(state.activeMessages, false, { forceScrollBottom: true });
|
|
747
|
+
elements.prompt.focus();
|
|
748
|
+
if (payload.hasActiveRun && !state.isStreaming) {
|
|
749
|
+
setStreaming(true);
|
|
750
|
+
streamConversationEvents(conversationId, { liveOnly: true }).finally(() => {
|
|
751
|
+
if (state.activeConversationId === conversationId) {
|
|
752
|
+
setStreaming(false);
|
|
753
|
+
renderMessages(state.activeMessages, false);
|
|
754
|
+
}
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
};
|
|
758
|
+
|
|
759
|
+
const renameConversation = async (conversationId, title) => {
|
|
760
|
+
const payload = await api("/api/conversations/" + encodeURIComponent(conversationId), {
|
|
761
|
+
method: "PATCH",
|
|
762
|
+
body: JSON.stringify({ title }),
|
|
763
|
+
});
|
|
764
|
+
elements.chatTitle.textContent = payload.conversation.title;
|
|
765
|
+
const entry = state.conversations.find(c => c.conversationId === conversationId);
|
|
766
|
+
if (entry) entry.title = payload.conversation.title;
|
|
767
|
+
renderConversationList();
|
|
768
|
+
};
|
|
769
|
+
|
|
770
|
+
const beginTitleEdit = () => {
|
|
771
|
+
if (!state.activeConversationId) return;
|
|
772
|
+
if (elements.chatTitle.querySelector("input")) return;
|
|
773
|
+
|
|
774
|
+
const current = elements.chatTitle.textContent || "";
|
|
775
|
+
elements.chatTitle.textContent = "";
|
|
776
|
+
|
|
777
|
+
const input = document.createElement("input");
|
|
778
|
+
input.type = "text";
|
|
779
|
+
input.className = "topbar-title-input";
|
|
780
|
+
input.value = current;
|
|
781
|
+
|
|
782
|
+
const sizer = document.createElement("span");
|
|
783
|
+
sizer.style.cssText = "position:absolute;visibility:hidden;white-space:pre;font:inherit;font-weight:inherit;letter-spacing:inherit;padding:0 6px;";
|
|
784
|
+
const autoSize = () => {
|
|
785
|
+
sizer.textContent = input.value || " ";
|
|
786
|
+
elements.chatTitle.appendChild(sizer);
|
|
787
|
+
input.style.width = sizer.offsetWidth + 12 + "px";
|
|
788
|
+
sizer.remove();
|
|
789
|
+
};
|
|
790
|
+
|
|
791
|
+
elements.chatTitle.appendChild(input);
|
|
792
|
+
autoSize();
|
|
793
|
+
input.focus();
|
|
794
|
+
input.select();
|
|
795
|
+
input.addEventListener("input", autoSize);
|
|
796
|
+
|
|
797
|
+
const commit = async () => {
|
|
798
|
+
const newTitle = input.value.trim();
|
|
799
|
+
if (input._committed) return;
|
|
800
|
+
input._committed = true;
|
|
801
|
+
|
|
802
|
+
if (newTitle && newTitle !== current) {
|
|
803
|
+
try {
|
|
804
|
+
await renameConversation(state.activeConversationId, newTitle);
|
|
805
|
+
} catch {
|
|
806
|
+
elements.chatTitle.textContent = current;
|
|
807
|
+
}
|
|
808
|
+
} else {
|
|
809
|
+
elements.chatTitle.textContent = current;
|
|
810
|
+
}
|
|
811
|
+
};
|
|
812
|
+
|
|
813
|
+
input.addEventListener("blur", commit);
|
|
814
|
+
input.addEventListener("keydown", (e) => {
|
|
815
|
+
if (e.key === "Enter") { e.preventDefault(); input.blur(); }
|
|
816
|
+
if (e.key === "Escape") { input.value = current; input.blur(); }
|
|
817
|
+
});
|
|
818
|
+
};
|
|
819
|
+
|
|
820
|
+
const streamConversationEvents = (conversationId, options) => {
|
|
821
|
+
const liveOnly = options && options.liveOnly;
|
|
822
|
+
return new Promise((resolve) => {
|
|
823
|
+
const localMessages = state.activeMessages || [];
|
|
824
|
+
const renderIfActiveConversation = (streaming) => {
|
|
825
|
+
if (state.activeConversationId !== conversationId) {
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
state.activeMessages = localMessages;
|
|
829
|
+
renderMessages(localMessages, streaming);
|
|
830
|
+
};
|
|
831
|
+
let assistantMessage = localMessages[localMessages.length - 1];
|
|
832
|
+
if (!assistantMessage || assistantMessage.role !== "assistant") {
|
|
833
|
+
assistantMessage = {
|
|
834
|
+
role: "assistant",
|
|
835
|
+
content: "",
|
|
836
|
+
_sections: [],
|
|
837
|
+
_currentText: "",
|
|
838
|
+
_currentTools: [],
|
|
839
|
+
_toolImages: [],
|
|
840
|
+
_pendingApprovals: [],
|
|
841
|
+
_activeActivities: [],
|
|
842
|
+
metadata: { toolActivity: [] },
|
|
843
|
+
};
|
|
844
|
+
localMessages.push(assistantMessage);
|
|
845
|
+
state.activeMessages = localMessages;
|
|
846
|
+
}
|
|
847
|
+
if (liveOnly) {
|
|
848
|
+
// Live-only mode: keep metadata.sections intact (the stored
|
|
849
|
+
// base content) and start _sections empty so it only collects
|
|
850
|
+
// NEW sections from live events. The renderer merges both.
|
|
851
|
+
assistantMessage._sections = [];
|
|
852
|
+
assistantMessage._currentText = "";
|
|
853
|
+
assistantMessage._currentTools = [];
|
|
854
|
+
if (!assistantMessage._activeActivities) assistantMessage._activeActivities = [];
|
|
855
|
+
if (!assistantMessage._pendingApprovals) assistantMessage._pendingApprovals = [];
|
|
856
|
+
if (!assistantMessage.metadata) assistantMessage.metadata = {};
|
|
857
|
+
if (!assistantMessage.metadata.toolActivity) assistantMessage.metadata.toolActivity = [];
|
|
858
|
+
} else {
|
|
859
|
+
// Full replay mode: reset transient state so replayed events
|
|
860
|
+
// rebuild from scratch (the buffer has the full event history).
|
|
861
|
+
assistantMessage.content = "";
|
|
862
|
+
assistantMessage._sections = [];
|
|
863
|
+
assistantMessage._currentText = "";
|
|
864
|
+
assistantMessage._currentTools = [];
|
|
865
|
+
assistantMessage._activeActivities = [];
|
|
866
|
+
assistantMessage._pendingApprovals = [];
|
|
867
|
+
assistantMessage.metadata = { toolActivity: [] };
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
const url = "/api/conversations/" + encodeURIComponent(conversationId) + "/events" + (liveOnly ? "?live_only=true" : "");
|
|
871
|
+
fetch(url, { credentials: "include" }).then((response) => {
|
|
872
|
+
if (!response.ok || !response.body) {
|
|
873
|
+
resolve(undefined);
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
const reader = response.body.getReader();
|
|
877
|
+
const decoder = new TextDecoder();
|
|
878
|
+
let buffer = "";
|
|
879
|
+
const processChunks = async () => {
|
|
880
|
+
while (true) {
|
|
881
|
+
const { value, done } = await reader.read();
|
|
882
|
+
if (done) {
|
|
883
|
+
break;
|
|
884
|
+
}
|
|
885
|
+
buffer += decoder.decode(value, { stream: true });
|
|
886
|
+
buffer = parseSseChunk(buffer, (eventName, payload) => {
|
|
887
|
+
try {
|
|
888
|
+
if (eventName === "stream:end") {
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
if (eventName === "run:started") {
|
|
892
|
+
if (typeof payload.contextWindow === "number" && payload.contextWindow > 0) {
|
|
893
|
+
state.contextWindow = payload.contextWindow;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
if (eventName === "model:chunk") {
|
|
897
|
+
const chunk = String(payload.content || "");
|
|
898
|
+
if (assistantMessage._currentTools.length > 0 && chunk.length > 0) {
|
|
899
|
+
assistantMessage._sections.push({
|
|
900
|
+
type: "tools",
|
|
901
|
+
content: assistantMessage._currentTools,
|
|
902
|
+
});
|
|
903
|
+
assistantMessage._currentTools = [];
|
|
904
|
+
}
|
|
905
|
+
assistantMessage.content += chunk;
|
|
906
|
+
assistantMessage._currentText += chunk;
|
|
907
|
+
renderIfActiveConversation(true);
|
|
908
|
+
}
|
|
909
|
+
if (eventName === "model:response") {
|
|
910
|
+
if (typeof payload.usage?.input === "number") {
|
|
911
|
+
state.contextTokens = payload.usage.input;
|
|
912
|
+
updateContextRing();
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
if (eventName === "tool:started") {
|
|
916
|
+
const toolName = payload.tool || "tool";
|
|
917
|
+
const startedActivity = addActiveActivityFromToolStart(
|
|
918
|
+
assistantMessage,
|
|
919
|
+
payload,
|
|
920
|
+
);
|
|
921
|
+
if (assistantMessage._currentText.length > 0) {
|
|
922
|
+
assistantMessage._sections.push({
|
|
923
|
+
type: "text",
|
|
924
|
+
content: assistantMessage._currentText,
|
|
925
|
+
});
|
|
926
|
+
assistantMessage._currentText = "";
|
|
927
|
+
}
|
|
928
|
+
const detail =
|
|
929
|
+
startedActivity && typeof startedActivity.detail === "string"
|
|
930
|
+
? startedActivity.detail.trim()
|
|
931
|
+
: "";
|
|
932
|
+
const toolText =
|
|
933
|
+
"- start \\x60" +
|
|
934
|
+
toolName +
|
|
935
|
+
"\\x60" +
|
|
936
|
+
(detail ? " (" + detail + ")" : "");
|
|
937
|
+
assistantMessage._currentTools.push(toolText);
|
|
938
|
+
assistantMessage.metadata.toolActivity.push(toolText);
|
|
939
|
+
renderIfActiveConversation(true);
|
|
940
|
+
}
|
|
941
|
+
if (eventName === "tool:completed") {
|
|
942
|
+
const toolName = payload.tool || "tool";
|
|
943
|
+
const activeActivity = removeActiveActivityForTool(
|
|
944
|
+
assistantMessage,
|
|
945
|
+
toolName,
|
|
946
|
+
);
|
|
947
|
+
const duration =
|
|
948
|
+
typeof payload.duration === "number" ? payload.duration : null;
|
|
949
|
+
const detail =
|
|
950
|
+
activeActivity && typeof activeActivity.detail === "string"
|
|
951
|
+
? activeActivity.detail.trim()
|
|
952
|
+
: "";
|
|
953
|
+
const meta = [];
|
|
954
|
+
if (duration !== null) meta.push(duration + "ms");
|
|
955
|
+
if (detail) meta.push(detail);
|
|
956
|
+
const toolText =
|
|
957
|
+
"- done \\x60" +
|
|
958
|
+
toolName +
|
|
959
|
+
"\\x60" +
|
|
960
|
+
(meta.length > 0 ? " (" + meta.join(", ") + ")" : "");
|
|
961
|
+
assistantMessage._currentTools.push(toolText);
|
|
962
|
+
assistantMessage.metadata.toolActivity.push(toolText);
|
|
963
|
+
renderIfActiveConversation(true);
|
|
964
|
+
}
|
|
965
|
+
if (eventName === "tool:error") {
|
|
966
|
+
const toolName = payload.tool || "tool";
|
|
967
|
+
const activeActivity = removeActiveActivityForTool(
|
|
968
|
+
assistantMessage,
|
|
969
|
+
toolName,
|
|
970
|
+
);
|
|
971
|
+
const errorMsg = payload.error || "unknown error";
|
|
972
|
+
const detail =
|
|
973
|
+
activeActivity && typeof activeActivity.detail === "string"
|
|
974
|
+
? activeActivity.detail.trim()
|
|
975
|
+
: "";
|
|
976
|
+
const toolText =
|
|
977
|
+
"- error \\x60" +
|
|
978
|
+
toolName +
|
|
979
|
+
"\\x60" +
|
|
980
|
+
(detail ? " (" + detail + ")" : "") +
|
|
981
|
+
": " +
|
|
982
|
+
errorMsg;
|
|
983
|
+
assistantMessage._currentTools.push(toolText);
|
|
984
|
+
assistantMessage.metadata.toolActivity.push(toolText);
|
|
985
|
+
renderIfActiveConversation(true);
|
|
986
|
+
}
|
|
987
|
+
if (eventName === "browser:status" && payload.active) {
|
|
988
|
+
if (window._connectBrowserStream) window._connectBrowserStream();
|
|
989
|
+
}
|
|
990
|
+
if (eventName === "tool:approval:required") {
|
|
991
|
+
const toolName = payload.tool || "tool";
|
|
992
|
+
const activeActivity = removeActiveActivityForTool(
|
|
993
|
+
assistantMessage,
|
|
994
|
+
toolName,
|
|
995
|
+
);
|
|
996
|
+
const detailFromPayload = describeToolStart(payload);
|
|
997
|
+
const detail =
|
|
998
|
+
(activeActivity && typeof activeActivity.detail === "string"
|
|
999
|
+
? activeActivity.detail.trim()
|
|
1000
|
+
: "") ||
|
|
1001
|
+
(detailFromPayload && typeof detailFromPayload.detail === "string"
|
|
1002
|
+
? detailFromPayload.detail.trim()
|
|
1003
|
+
: "");
|
|
1004
|
+
const toolText =
|
|
1005
|
+
"- approval required \\x60" +
|
|
1006
|
+
toolName +
|
|
1007
|
+
"\\x60" +
|
|
1008
|
+
(detail ? " (" + detail + ")" : "");
|
|
1009
|
+
assistantMessage._currentTools.push(toolText);
|
|
1010
|
+
assistantMessage.metadata.toolActivity.push(toolText);
|
|
1011
|
+
const approvalId =
|
|
1012
|
+
typeof payload.approvalId === "string" ? payload.approvalId : "";
|
|
1013
|
+
if (approvalId) {
|
|
1014
|
+
if (!Array.isArray(assistantMessage._pendingApprovals)) {
|
|
1015
|
+
assistantMessage._pendingApprovals = [];
|
|
1016
|
+
}
|
|
1017
|
+
const exists = assistantMessage._pendingApprovals.some(
|
|
1018
|
+
(req) => req.approvalId === approvalId,
|
|
1019
|
+
);
|
|
1020
|
+
if (!exists) {
|
|
1021
|
+
assistantMessage._pendingApprovals.push({
|
|
1022
|
+
approvalId,
|
|
1023
|
+
tool: toolName,
|
|
1024
|
+
input: payload.input ?? {},
|
|
1025
|
+
state: "pending",
|
|
1026
|
+
});
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
renderIfActiveConversation(true);
|
|
1030
|
+
}
|
|
1031
|
+
if (eventName === "tool:approval:granted") {
|
|
1032
|
+
const toolText = "- approval granted";
|
|
1033
|
+
assistantMessage._currentTools.push(toolText);
|
|
1034
|
+
assistantMessage.metadata.toolActivity.push(toolText);
|
|
1035
|
+
const approvalId =
|
|
1036
|
+
typeof payload.approvalId === "string" ? payload.approvalId : "";
|
|
1037
|
+
if (approvalId && Array.isArray(assistantMessage._pendingApprovals)) {
|
|
1038
|
+
assistantMessage._pendingApprovals = assistantMessage._pendingApprovals.filter(
|
|
1039
|
+
(req) => req.approvalId !== approvalId,
|
|
1040
|
+
);
|
|
1041
|
+
}
|
|
1042
|
+
renderIfActiveConversation(true);
|
|
1043
|
+
}
|
|
1044
|
+
if (eventName === "tool:approval:denied") {
|
|
1045
|
+
const toolText = "- approval denied";
|
|
1046
|
+
assistantMessage._currentTools.push(toolText);
|
|
1047
|
+
assistantMessage.metadata.toolActivity.push(toolText);
|
|
1048
|
+
const approvalId =
|
|
1049
|
+
typeof payload.approvalId === "string" ? payload.approvalId : "";
|
|
1050
|
+
if (approvalId && Array.isArray(assistantMessage._pendingApprovals)) {
|
|
1051
|
+
assistantMessage._pendingApprovals = assistantMessage._pendingApprovals.filter(
|
|
1052
|
+
(req) => req.approvalId !== approvalId,
|
|
1053
|
+
);
|
|
1054
|
+
}
|
|
1055
|
+
renderIfActiveConversation(true);
|
|
1056
|
+
}
|
|
1057
|
+
if (eventName === "run:completed") {
|
|
1058
|
+
assistantMessage._activeActivities = [];
|
|
1059
|
+
if (
|
|
1060
|
+
!assistantMessage.content ||
|
|
1061
|
+
assistantMessage.content.length === 0
|
|
1062
|
+
) {
|
|
1063
|
+
assistantMessage.content = String(
|
|
1064
|
+
payload.result?.response || "",
|
|
1065
|
+
);
|
|
1066
|
+
}
|
|
1067
|
+
if (assistantMessage._currentTools.length > 0) {
|
|
1068
|
+
assistantMessage._sections.push({
|
|
1069
|
+
type: "tools",
|
|
1070
|
+
content: assistantMessage._currentTools,
|
|
1071
|
+
});
|
|
1072
|
+
assistantMessage._currentTools = [];
|
|
1073
|
+
}
|
|
1074
|
+
if (assistantMessage._currentText.length > 0) {
|
|
1075
|
+
assistantMessage._sections.push({
|
|
1076
|
+
type: "text",
|
|
1077
|
+
content: assistantMessage._currentText,
|
|
1078
|
+
});
|
|
1079
|
+
assistantMessage._currentText = "";
|
|
1080
|
+
}
|
|
1081
|
+
renderIfActiveConversation(false);
|
|
1082
|
+
}
|
|
1083
|
+
if (eventName === "run:cancelled") {
|
|
1084
|
+
assistantMessage._activeActivities = [];
|
|
1085
|
+
if (assistantMessage._currentTools.length > 0) {
|
|
1086
|
+
assistantMessage._sections.push({
|
|
1087
|
+
type: "tools",
|
|
1088
|
+
content: assistantMessage._currentTools,
|
|
1089
|
+
});
|
|
1090
|
+
assistantMessage._currentTools = [];
|
|
1091
|
+
}
|
|
1092
|
+
if (assistantMessage._currentText.length > 0) {
|
|
1093
|
+
assistantMessage._sections.push({
|
|
1094
|
+
type: "text",
|
|
1095
|
+
content: assistantMessage._currentText,
|
|
1096
|
+
});
|
|
1097
|
+
assistantMessage._currentText = "";
|
|
1098
|
+
}
|
|
1099
|
+
renderIfActiveConversation(false);
|
|
1100
|
+
}
|
|
1101
|
+
if (eventName === "run:error") {
|
|
1102
|
+
assistantMessage._activeActivities = [];
|
|
1103
|
+
if (assistantMessage._currentTools.length > 0) {
|
|
1104
|
+
assistantMessage._sections.push({
|
|
1105
|
+
type: "tools",
|
|
1106
|
+
content: assistantMessage._currentTools,
|
|
1107
|
+
});
|
|
1108
|
+
assistantMessage._currentTools = [];
|
|
1109
|
+
}
|
|
1110
|
+
if (assistantMessage._currentText.length > 0) {
|
|
1111
|
+
assistantMessage._sections.push({
|
|
1112
|
+
type: "text",
|
|
1113
|
+
content: assistantMessage._currentText,
|
|
1114
|
+
});
|
|
1115
|
+
assistantMessage._currentText = "";
|
|
1116
|
+
}
|
|
1117
|
+
const errMsg =
|
|
1118
|
+
payload.error?.message || "Something went wrong";
|
|
1119
|
+
assistantMessage._error = errMsg;
|
|
1120
|
+
renderIfActiveConversation(false);
|
|
1121
|
+
}
|
|
1122
|
+
} catch (error) {
|
|
1123
|
+
console.error("SSE reconnect event error:", eventName, error);
|
|
1124
|
+
}
|
|
1125
|
+
});
|
|
1126
|
+
}
|
|
1127
|
+
};
|
|
1128
|
+
processChunks().finally(() => {
|
|
1129
|
+
if (state.activeConversationId === conversationId) {
|
|
1130
|
+
state.activeMessages = localMessages;
|
|
1131
|
+
}
|
|
1132
|
+
resolve(undefined);
|
|
1133
|
+
});
|
|
1134
|
+
}).catch(() => {
|
|
1135
|
+
resolve(undefined);
|
|
1136
|
+
});
|
|
1137
|
+
});
|
|
1138
|
+
};
|
|
1139
|
+
|
|
1140
|
+
const createConversation = async (title, options = {}) => {
|
|
1141
|
+
if (window._resetBrowserPanel) window._resetBrowserPanel();
|
|
1142
|
+
const shouldLoadConversation = options.loadConversation !== false;
|
|
1143
|
+
const payload = await api("/api/conversations", {
|
|
1144
|
+
method: "POST",
|
|
1145
|
+
body: JSON.stringify(title ? { title } : {})
|
|
1146
|
+
});
|
|
1147
|
+
state.activeConversationId = payload.conversation.conversationId;
|
|
1148
|
+
state.confirmDeleteId = null;
|
|
1149
|
+
pushConversationUrl(state.activeConversationId);
|
|
1150
|
+
await loadConversations();
|
|
1151
|
+
if (shouldLoadConversation) {
|
|
1152
|
+
await loadConversation(state.activeConversationId);
|
|
1153
|
+
} else {
|
|
1154
|
+
elements.chatTitle.textContent = payload.conversation.title || "New conversation";
|
|
1155
|
+
}
|
|
1156
|
+
return state.activeConversationId;
|
|
1157
|
+
};
|
|
1158
|
+
|
|
1159
|
+
const parseSseChunk = (buffer, onEvent) => {
|
|
1160
|
+
let rest = buffer;
|
|
1161
|
+
while (true) {
|
|
1162
|
+
const index = rest.indexOf("\\n\\n");
|
|
1163
|
+
if (index < 0) {
|
|
1164
|
+
return rest;
|
|
1165
|
+
}
|
|
1166
|
+
const raw = rest.slice(0, index);
|
|
1167
|
+
rest = rest.slice(index + 2);
|
|
1168
|
+
const lines = raw.split("\\n");
|
|
1169
|
+
let eventName = "message";
|
|
1170
|
+
let data = "";
|
|
1171
|
+
for (const line of lines) {
|
|
1172
|
+
if (line.startsWith("event:")) {
|
|
1173
|
+
eventName = line.slice(6).trim();
|
|
1174
|
+
} else if (line.startsWith("data:")) {
|
|
1175
|
+
data += line.slice(5).trim();
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
if (data) {
|
|
1179
|
+
try {
|
|
1180
|
+
onEvent(eventName, JSON.parse(data));
|
|
1181
|
+
} catch {}
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
};
|
|
1185
|
+
|
|
1186
|
+
const setStreaming = (value) => {
|
|
1187
|
+
state.isStreaming = value;
|
|
1188
|
+
const canStop = value && !!state.activeStreamRunId;
|
|
1189
|
+
elements.send.disabled = value ? !canStop : false;
|
|
1190
|
+
elements.send.innerHTML = value ? stopIconMarkup : sendIconMarkup;
|
|
1191
|
+
elements.send.classList.toggle("stop-mode", value);
|
|
1192
|
+
if (elements.sendBtnWrapper) {
|
|
1193
|
+
elements.sendBtnWrapper.classList.toggle("stop-mode", value);
|
|
1194
|
+
}
|
|
1195
|
+
elements.send.setAttribute("aria-label", value ? "Stop response" : "Send message");
|
|
1196
|
+
elements.send.setAttribute(
|
|
1197
|
+
"title",
|
|
1198
|
+
value ? (canStop ? "Stop response" : "Starting response...") : "Send message",
|
|
1199
|
+
);
|
|
1200
|
+
};
|
|
1201
|
+
|
|
1202
|
+
const pushToolActivity = (assistantMessage, line) => {
|
|
1203
|
+
if (!line) {
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
if (
|
|
1207
|
+
!assistantMessage.metadata ||
|
|
1208
|
+
!Array.isArray(assistantMessage.metadata.toolActivity)
|
|
1209
|
+
) {
|
|
1210
|
+
assistantMessage.metadata = {
|
|
1211
|
+
...(assistantMessage.metadata || {}),
|
|
1212
|
+
toolActivity: [],
|
|
1213
|
+
};
|
|
1214
|
+
}
|
|
1215
|
+
assistantMessage.metadata.toolActivity.push(line);
|
|
1216
|
+
};
|
|
1217
|
+
|
|
1218
|
+
const ensureActiveActivities = (assistantMessage) => {
|
|
1219
|
+
if (!Array.isArray(assistantMessage._activeActivities)) {
|
|
1220
|
+
assistantMessage._activeActivities = [];
|
|
1221
|
+
}
|
|
1222
|
+
return assistantMessage._activeActivities;
|
|
1223
|
+
};
|
|
1224
|
+
|
|
1225
|
+
const getStringInputField = (input, key) => {
|
|
1226
|
+
if (!input || typeof input !== "object") {
|
|
1227
|
+
return "";
|
|
1228
|
+
}
|
|
1229
|
+
const value = input[key];
|
|
1230
|
+
return typeof value === "string" ? value.trim() : "";
|
|
1231
|
+
};
|
|
1232
|
+
|
|
1233
|
+
const describeToolStart = (payload) => {
|
|
1234
|
+
const toolName = payload && typeof payload.tool === "string" ? payload.tool : "tool";
|
|
1235
|
+
const input = payload && payload.input && typeof payload.input === "object" ? payload.input : {};
|
|
1236
|
+
|
|
1237
|
+
if (toolName === "activate_skill") {
|
|
1238
|
+
const skillName = getStringInputField(input, "name") || "skill";
|
|
1239
|
+
return {
|
|
1240
|
+
kind: "skill",
|
|
1241
|
+
tool: toolName,
|
|
1242
|
+
label: "Activating " + skillName + " skill",
|
|
1243
|
+
detail: "skill: " + skillName,
|
|
1244
|
+
};
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
if (toolName === "run_skill_script") {
|
|
1248
|
+
const scriptPath = getStringInputField(input, "script");
|
|
1249
|
+
const skillName = getStringInputField(input, "skill");
|
|
1250
|
+
if (scriptPath && skillName) {
|
|
1251
|
+
return {
|
|
1252
|
+
kind: "tool",
|
|
1253
|
+
tool: toolName,
|
|
1254
|
+
label: "Running script " + scriptPath + " in " + skillName + " skill",
|
|
1255
|
+
detail: "script: " + scriptPath + ", skill: " + skillName,
|
|
1256
|
+
};
|
|
1257
|
+
}
|
|
1258
|
+
if (scriptPath) {
|
|
1259
|
+
return {
|
|
1260
|
+
kind: "tool",
|
|
1261
|
+
tool: toolName,
|
|
1262
|
+
label: "Running script " + scriptPath,
|
|
1263
|
+
detail: "script: " + scriptPath,
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
if (toolName === "read_skill_resource") {
|
|
1269
|
+
const resourcePath = getStringInputField(input, "path");
|
|
1270
|
+
const skillName = getStringInputField(input, "skill");
|
|
1271
|
+
if (resourcePath && skillName) {
|
|
1272
|
+
return {
|
|
1273
|
+
kind: "tool",
|
|
1274
|
+
tool: toolName,
|
|
1275
|
+
label: "Reading " + resourcePath + " from " + skillName + " skill",
|
|
1276
|
+
detail: "path: " + resourcePath + ", skill: " + skillName,
|
|
1277
|
+
};
|
|
1278
|
+
}
|
|
1279
|
+
if (resourcePath) {
|
|
1280
|
+
return {
|
|
1281
|
+
kind: "tool",
|
|
1282
|
+
tool: toolName,
|
|
1283
|
+
label: "Reading " + resourcePath,
|
|
1284
|
+
detail: "path: " + resourcePath,
|
|
1285
|
+
};
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
if (toolName === "read_file") {
|
|
1290
|
+
const path = getStringInputField(input, "path");
|
|
1291
|
+
if (path) {
|
|
1292
|
+
return {
|
|
1293
|
+
kind: "tool",
|
|
1294
|
+
tool: toolName,
|
|
1295
|
+
label: "Reading " + path,
|
|
1296
|
+
detail: "path: " + path,
|
|
1297
|
+
};
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
return {
|
|
1302
|
+
kind: "tool",
|
|
1303
|
+
tool: toolName,
|
|
1304
|
+
label: "Running " + toolName + " tool",
|
|
1305
|
+
detail: "",
|
|
1306
|
+
};
|
|
1307
|
+
};
|
|
1308
|
+
|
|
1309
|
+
const addActiveActivityFromToolStart = (assistantMessage, payload) => {
|
|
1310
|
+
const activities = ensureActiveActivities(assistantMessage);
|
|
1311
|
+
const activity = describeToolStart(payload);
|
|
1312
|
+
activities.push(activity);
|
|
1313
|
+
return activity;
|
|
1314
|
+
};
|
|
1315
|
+
|
|
1316
|
+
const removeActiveActivityForTool = (assistantMessage, toolName) => {
|
|
1317
|
+
if (!toolName || !Array.isArray(assistantMessage._activeActivities)) {
|
|
1318
|
+
return null;
|
|
1319
|
+
}
|
|
1320
|
+
const activities = assistantMessage._activeActivities;
|
|
1321
|
+
const idx = activities.findIndex((item) => item && item.tool === toolName);
|
|
1322
|
+
if (idx >= 0) {
|
|
1323
|
+
return activities.splice(idx, 1)[0] || null;
|
|
1324
|
+
}
|
|
1325
|
+
return null;
|
|
1326
|
+
};
|
|
1327
|
+
|
|
1328
|
+
const getThinkingStatusLabel = (assistantMessage) => {
|
|
1329
|
+
const activities = Array.isArray(assistantMessage?._activeActivities)
|
|
1330
|
+
? assistantMessage._activeActivities
|
|
1331
|
+
: [];
|
|
1332
|
+
const labels = [];
|
|
1333
|
+
activities.forEach((item) => {
|
|
1334
|
+
if (!item || typeof item.label !== "string") {
|
|
1335
|
+
return;
|
|
1336
|
+
}
|
|
1337
|
+
const label = item.label.trim();
|
|
1338
|
+
if (!label || labels.includes(label)) {
|
|
1339
|
+
return;
|
|
1340
|
+
}
|
|
1341
|
+
labels.push(label);
|
|
1342
|
+
});
|
|
1343
|
+
if (labels.length === 1) {
|
|
1344
|
+
return labels[0];
|
|
1345
|
+
}
|
|
1346
|
+
if (labels.length === 2) {
|
|
1347
|
+
return labels[0] + ", " + labels[1];
|
|
1348
|
+
}
|
|
1349
|
+
if (labels.length > 2) {
|
|
1350
|
+
return labels[0] + ", " + labels[1] + " +" + (labels.length - 2) + " more";
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
if (Array.isArray(assistantMessage?._currentTools)) {
|
|
1354
|
+
const tick = String.fromCharCode(96);
|
|
1355
|
+
const startPrefix = "- start " + tick;
|
|
1356
|
+
for (let idx = assistantMessage._currentTools.length - 1; idx >= 0; idx -= 1) {
|
|
1357
|
+
const item = String(assistantMessage._currentTools[idx] || "");
|
|
1358
|
+
if (item.startsWith(startPrefix)) {
|
|
1359
|
+
const rest = item.slice(startPrefix.length);
|
|
1360
|
+
const endIdx = rest.indexOf(tick);
|
|
1361
|
+
const toolName = (endIdx >= 0 ? rest.slice(0, endIdx) : rest).trim();
|
|
1362
|
+
if (toolName) {
|
|
1363
|
+
return "Running " + toolName + " tool";
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
return "";
|
|
1369
|
+
};
|
|
1370
|
+
|
|
1371
|
+
const autoResizePrompt = () => {
|
|
1372
|
+
const el = elements.prompt;
|
|
1373
|
+
el.style.height = "auto";
|
|
1374
|
+
const scrollHeight = el.scrollHeight;
|
|
1375
|
+
const nextHeight = Math.min(scrollHeight, 200);
|
|
1376
|
+
el.style.height = nextHeight + "px";
|
|
1377
|
+
el.style.overflowY = scrollHeight > 200 ? "auto" : "hidden";
|
|
1378
|
+
};
|
|
1379
|
+
|
|
1380
|
+
const stopActiveRun = async () => {
|
|
1381
|
+
const stopRunId = state.activeStreamRunId;
|
|
1382
|
+
if (!stopRunId) return;
|
|
1383
|
+
const conversationId = state.activeStreamConversationId || state.activeConversationId;
|
|
1384
|
+
if (!conversationId) return;
|
|
1385
|
+
// Disable the stop button immediately so the user sees feedback.
|
|
1386
|
+
state.activeStreamRunId = null;
|
|
1387
|
+
setStreaming(state.isStreaming);
|
|
1388
|
+
// Signal the server to cancel the run. The server will emit
|
|
1389
|
+
// run:cancelled through the still-open SSE stream, which
|
|
1390
|
+
// sendMessage() processes naturally – the stream ends on its own
|
|
1391
|
+
// and cleanup happens in one finally block. No fetch abort needed.
|
|
1392
|
+
try {
|
|
1393
|
+
await api(
|
|
1394
|
+
"/api/conversations/" + encodeURIComponent(conversationId) + "/stop",
|
|
1395
|
+
{
|
|
1396
|
+
method: "POST",
|
|
1397
|
+
body: JSON.stringify({ runId: stopRunId }),
|
|
1398
|
+
},
|
|
1399
|
+
);
|
|
1400
|
+
} catch (e) {
|
|
1401
|
+
console.warn("Failed to stop conversation run:", e);
|
|
1402
|
+
// Fallback: abort the local fetch so the UI at least stops.
|
|
1403
|
+
const abortController = state.activeStreamAbortController;
|
|
1404
|
+
if (abortController && !abortController.signal.aborted) {
|
|
1405
|
+
abortController.abort();
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
};
|
|
1409
|
+
|
|
1410
|
+
const renderAttachmentPreview = () => {
|
|
1411
|
+
const el = elements.attachmentPreview;
|
|
1412
|
+
if (state.pendingFiles.length === 0) {
|
|
1413
|
+
el.style.display = "none";
|
|
1414
|
+
el.innerHTML = "";
|
|
1415
|
+
return;
|
|
1416
|
+
}
|
|
1417
|
+
el.style.display = "flex";
|
|
1418
|
+
el.innerHTML = state.pendingFiles.map((f, i) => {
|
|
1419
|
+
const isImage = f.type.startsWith("image/");
|
|
1420
|
+
const thumbHtml = isImage
|
|
1421
|
+
? '<img src="' + URL.createObjectURL(f) + '" alt="" />'
|
|
1422
|
+
: '<span class="file-icon">📎</span>';
|
|
1423
|
+
return '<div class="attachment-chip" data-idx="' + i + '">'
|
|
1424
|
+
+ thumbHtml
|
|
1425
|
+
+ '<span class="filename">' + escapeHtml(f.name) + '</span>'
|
|
1426
|
+
+ '<span class="remove-attachment" data-idx="' + i + '">×</span>'
|
|
1427
|
+
+ '</div>';
|
|
1428
|
+
}).join("");
|
|
1429
|
+
};
|
|
1430
|
+
|
|
1431
|
+
const addFiles = (fileList) => {
|
|
1432
|
+
for (const f of fileList) {
|
|
1433
|
+
if (f.size > 25 * 1024 * 1024) {
|
|
1434
|
+
alert("File too large: " + f.name + " (max 25MB)");
|
|
1435
|
+
continue;
|
|
1436
|
+
}
|
|
1437
|
+
state.pendingFiles.push(f);
|
|
1438
|
+
}
|
|
1439
|
+
renderAttachmentPreview();
|
|
1440
|
+
};
|
|
1441
|
+
|
|
1442
|
+
const sendMessage = async (text) => {
|
|
1443
|
+
const messageText = (text || "").trim();
|
|
1444
|
+
if (!messageText || state.isStreaming) {
|
|
1445
|
+
return;
|
|
1446
|
+
}
|
|
1447
|
+
const filesToSend = [...state.pendingFiles];
|
|
1448
|
+
state.pendingFiles = [];
|
|
1449
|
+
renderAttachmentPreview();
|
|
1450
|
+
let userContent;
|
|
1451
|
+
if (filesToSend.length > 0) {
|
|
1452
|
+
userContent = [{ type: "text", text: messageText }];
|
|
1453
|
+
for (const f of filesToSend) {
|
|
1454
|
+
userContent.push({
|
|
1455
|
+
type: "file",
|
|
1456
|
+
data: URL.createObjectURL(f),
|
|
1457
|
+
mediaType: f.type,
|
|
1458
|
+
filename: f.name,
|
|
1459
|
+
_localBlob: f,
|
|
1460
|
+
});
|
|
1461
|
+
}
|
|
1462
|
+
} else {
|
|
1463
|
+
userContent = messageText;
|
|
1464
|
+
}
|
|
1465
|
+
const localMessages = [...(state.activeMessages || []), { role: "user", content: userContent }];
|
|
1466
|
+
let assistantMessage = {
|
|
1467
|
+
role: "assistant",
|
|
1468
|
+
content: "",
|
|
1469
|
+
_sections: [],
|
|
1470
|
+
_currentText: "",
|
|
1471
|
+
_currentTools: [],
|
|
1472
|
+
_toolImages: [],
|
|
1473
|
+
_activeActivities: [],
|
|
1474
|
+
_pendingApprovals: [],
|
|
1475
|
+
metadata: { toolActivity: [] }
|
|
1476
|
+
};
|
|
1477
|
+
localMessages.push(assistantMessage);
|
|
1478
|
+
state.activeMessages = localMessages;
|
|
1479
|
+
renderMessages(localMessages, true, { forceScrollBottom: true });
|
|
1480
|
+
let conversationId = state.activeConversationId;
|
|
1481
|
+
const streamAbortController = new AbortController();
|
|
1482
|
+
state.activeStreamAbortController = streamAbortController;
|
|
1483
|
+
state.activeStreamRunId = null;
|
|
1484
|
+
setStreaming(true);
|
|
1485
|
+
try {
|
|
1486
|
+
if (!conversationId) {
|
|
1487
|
+
conversationId = await createConversation(messageText, { loadConversation: false });
|
|
1488
|
+
}
|
|
1489
|
+
state.activeStreamConversationId = conversationId;
|
|
1490
|
+
const streamConversationId = conversationId;
|
|
1491
|
+
const renderIfActiveConversation = (streaming) => {
|
|
1492
|
+
if (state.activeConversationId !== streamConversationId) {
|
|
1493
|
+
return;
|
|
1494
|
+
}
|
|
1495
|
+
state.activeMessages = localMessages;
|
|
1496
|
+
renderMessages(localMessages, streaming);
|
|
1497
|
+
};
|
|
1498
|
+
const finalizeAssistantMessage = () => {
|
|
1499
|
+
assistantMessage._activeActivities = [];
|
|
1500
|
+
if (assistantMessage._currentTools.length > 0) {
|
|
1501
|
+
assistantMessage._sections.push({ type: "tools", content: assistantMessage._currentTools });
|
|
1502
|
+
assistantMessage._currentTools = [];
|
|
1503
|
+
}
|
|
1504
|
+
if (assistantMessage._currentText.length > 0) {
|
|
1505
|
+
assistantMessage._sections.push({ type: "text", content: assistantMessage._currentText });
|
|
1506
|
+
assistantMessage._currentText = "";
|
|
1507
|
+
}
|
|
1508
|
+
};
|
|
1509
|
+
let _continuationMessage = messageText;
|
|
1510
|
+
let _totalSteps = 0;
|
|
1511
|
+
let _maxSteps = 0;
|
|
1512
|
+
while (_continuationMessage) {
|
|
1513
|
+
let _shouldContinue = false;
|
|
1514
|
+
let fetchOpts;
|
|
1515
|
+
if (filesToSend.length > 0 && _continuationMessage === messageText) {
|
|
1516
|
+
const formData = new FormData();
|
|
1517
|
+
formData.append("message", _continuationMessage);
|
|
1518
|
+
for (const f of filesToSend) {
|
|
1519
|
+
formData.append("files", f, f.name);
|
|
1520
|
+
}
|
|
1521
|
+
fetchOpts = {
|
|
1522
|
+
method: "POST",
|
|
1523
|
+
credentials: "include",
|
|
1524
|
+
headers: { "x-csrf-token": state.csrfToken },
|
|
1525
|
+
body: formData,
|
|
1526
|
+
signal: streamAbortController.signal,
|
|
1527
|
+
};
|
|
1528
|
+
} else {
|
|
1529
|
+
fetchOpts = {
|
|
1530
|
+
method: "POST",
|
|
1531
|
+
credentials: "include",
|
|
1532
|
+
headers: { "Content-Type": "application/json", "x-csrf-token": state.csrfToken },
|
|
1533
|
+
body: JSON.stringify({ message: _continuationMessage }),
|
|
1534
|
+
signal: streamAbortController.signal,
|
|
1535
|
+
};
|
|
1536
|
+
}
|
|
1537
|
+
const response = await fetch(
|
|
1538
|
+
"/api/conversations/" + encodeURIComponent(conversationId) + "/messages",
|
|
1539
|
+
fetchOpts,
|
|
1540
|
+
);
|
|
1541
|
+
if (!response.ok || !response.body) {
|
|
1542
|
+
throw new Error("Failed to stream response");
|
|
1543
|
+
}
|
|
1544
|
+
const reader = response.body.getReader();
|
|
1545
|
+
const decoder = new TextDecoder();
|
|
1546
|
+
let buffer = "";
|
|
1547
|
+
while (true) {
|
|
1548
|
+
const { value, done } = await reader.read();
|
|
1549
|
+
if (done) {
|
|
1550
|
+
break;
|
|
1551
|
+
}
|
|
1552
|
+
buffer += decoder.decode(value, { stream: true });
|
|
1553
|
+
buffer = parseSseChunk(buffer, (eventName, payload) => {
|
|
1554
|
+
try {
|
|
1555
|
+
if (eventName === "model:chunk") {
|
|
1556
|
+
const chunk = String(payload.content || "");
|
|
1557
|
+
// If we have tools accumulated and text starts again, push tools as a section
|
|
1558
|
+
if (assistantMessage._currentTools.length > 0 && chunk.length > 0) {
|
|
1559
|
+
assistantMessage._sections.push({ type: "tools", content: assistantMessage._currentTools });
|
|
1560
|
+
assistantMessage._currentTools = [];
|
|
1561
|
+
}
|
|
1562
|
+
assistantMessage.content += chunk;
|
|
1563
|
+
assistantMessage._currentText += chunk;
|
|
1564
|
+
renderIfActiveConversation(true);
|
|
1565
|
+
}
|
|
1566
|
+
if (eventName === "run:started") {
|
|
1567
|
+
state.activeStreamRunId = typeof payload.runId === "string" ? payload.runId : null;
|
|
1568
|
+
if (typeof payload.contextWindow === "number" && payload.contextWindow > 0) {
|
|
1569
|
+
state.contextWindow = payload.contextWindow;
|
|
1570
|
+
}
|
|
1571
|
+
setStreaming(state.isStreaming);
|
|
1572
|
+
}
|
|
1573
|
+
if (eventName === "model:response") {
|
|
1574
|
+
if (typeof payload.usage?.input === "number") {
|
|
1575
|
+
state.contextTokens = payload.usage.input;
|
|
1576
|
+
updateContextRing();
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
if (eventName === "tool:started") {
|
|
1580
|
+
const toolName = payload.tool || "tool";
|
|
1581
|
+
const startedActivity = addActiveActivityFromToolStart(
|
|
1582
|
+
assistantMessage,
|
|
1583
|
+
payload,
|
|
1584
|
+
);
|
|
1585
|
+
// If we have text accumulated, push it as a text section
|
|
1586
|
+
if (assistantMessage._currentText.length > 0) {
|
|
1587
|
+
assistantMessage._sections.push({ type: "text", content: assistantMessage._currentText });
|
|
1588
|
+
assistantMessage._currentText = "";
|
|
1589
|
+
}
|
|
1590
|
+
const detail =
|
|
1591
|
+
startedActivity && typeof startedActivity.detail === "string"
|
|
1592
|
+
? startedActivity.detail.trim()
|
|
1593
|
+
: "";
|
|
1594
|
+
const toolText =
|
|
1595
|
+
"- start \\x60" + toolName + "\\x60" + (detail ? " (" + detail + ")" : "");
|
|
1596
|
+
assistantMessage._currentTools.push(toolText);
|
|
1597
|
+
if (!assistantMessage.metadata) assistantMessage.metadata = {};
|
|
1598
|
+
if (!assistantMessage.metadata.toolActivity) assistantMessage.metadata.toolActivity = [];
|
|
1599
|
+
assistantMessage.metadata.toolActivity.push(toolText);
|
|
1600
|
+
renderIfActiveConversation(true);
|
|
1601
|
+
}
|
|
1602
|
+
if (eventName === "tool:completed") {
|
|
1603
|
+
const toolName = payload.tool || "tool";
|
|
1604
|
+
const activeActivity = removeActiveActivityForTool(
|
|
1605
|
+
assistantMessage,
|
|
1606
|
+
toolName,
|
|
1607
|
+
);
|
|
1608
|
+
const duration = typeof payload.duration === "number" ? payload.duration : null;
|
|
1609
|
+
const detail =
|
|
1610
|
+
activeActivity && typeof activeActivity.detail === "string"
|
|
1611
|
+
? activeActivity.detail.trim()
|
|
1612
|
+
: "";
|
|
1613
|
+
const meta = [];
|
|
1614
|
+
if (duration !== null) meta.push(duration + "ms");
|
|
1615
|
+
if (detail) meta.push(detail);
|
|
1616
|
+
const toolText =
|
|
1617
|
+
"- done \\x60" + toolName + "\\x60" + (meta.length > 0 ? " (" + meta.join(", ") + ")" : "");
|
|
1618
|
+
assistantMessage._currentTools.push(toolText);
|
|
1619
|
+
if (!assistantMessage.metadata) assistantMessage.metadata = {};
|
|
1620
|
+
if (!assistantMessage.metadata.toolActivity) assistantMessage.metadata.toolActivity = [];
|
|
1621
|
+
assistantMessage.metadata.toolActivity.push(toolText);
|
|
1622
|
+
renderIfActiveConversation(true);
|
|
1623
|
+
}
|
|
1624
|
+
if (eventName === "tool:error") {
|
|
1625
|
+
const toolName = payload.tool || "tool";
|
|
1626
|
+
const activeActivity = removeActiveActivityForTool(
|
|
1627
|
+
assistantMessage,
|
|
1628
|
+
toolName,
|
|
1629
|
+
);
|
|
1630
|
+
const errorMsg = payload.error || "unknown error";
|
|
1631
|
+
const detail =
|
|
1632
|
+
activeActivity && typeof activeActivity.detail === "string"
|
|
1633
|
+
? activeActivity.detail.trim()
|
|
1634
|
+
: "";
|
|
1635
|
+
const toolText =
|
|
1636
|
+
"- error \\x60" +
|
|
1637
|
+
toolName +
|
|
1638
|
+
"\\x60" +
|
|
1639
|
+
(detail ? " (" + detail + ")" : "") +
|
|
1640
|
+
": " +
|
|
1641
|
+
errorMsg;
|
|
1642
|
+
assistantMessage._currentTools.push(toolText);
|
|
1643
|
+
if (!assistantMessage.metadata) assistantMessage.metadata = {};
|
|
1644
|
+
if (!assistantMessage.metadata.toolActivity) assistantMessage.metadata.toolActivity = [];
|
|
1645
|
+
assistantMessage.metadata.toolActivity.push(toolText);
|
|
1646
|
+
renderIfActiveConversation(true);
|
|
1647
|
+
}
|
|
1648
|
+
if (eventName === "browser:status" && payload.active) {
|
|
1649
|
+
if (window._connectBrowserStream) window._connectBrowserStream();
|
|
1650
|
+
}
|
|
1651
|
+
if (eventName === "tool:approval:required") {
|
|
1652
|
+
const toolName = payload.tool || "tool";
|
|
1653
|
+
const activeActivity = removeActiveActivityForTool(
|
|
1654
|
+
assistantMessage,
|
|
1655
|
+
toolName,
|
|
1656
|
+
);
|
|
1657
|
+
const detailFromPayload = describeToolStart(payload);
|
|
1658
|
+
const detail =
|
|
1659
|
+
(activeActivity && typeof activeActivity.detail === "string"
|
|
1660
|
+
? activeActivity.detail.trim()
|
|
1661
|
+
: "") ||
|
|
1662
|
+
(detailFromPayload && typeof detailFromPayload.detail === "string"
|
|
1663
|
+
? detailFromPayload.detail.trim()
|
|
1664
|
+
: "");
|
|
1665
|
+
const toolText =
|
|
1666
|
+
"- approval required \\x60" +
|
|
1667
|
+
toolName +
|
|
1668
|
+
"\\x60" +
|
|
1669
|
+
(detail ? " (" + detail + ")" : "");
|
|
1670
|
+
assistantMessage._currentTools.push(toolText);
|
|
1671
|
+
if (!assistantMessage.metadata) assistantMessage.metadata = {};
|
|
1672
|
+
if (!assistantMessage.metadata.toolActivity) assistantMessage.metadata.toolActivity = [];
|
|
1673
|
+
assistantMessage.metadata.toolActivity.push(toolText);
|
|
1674
|
+
const approvalId =
|
|
1675
|
+
typeof payload.approvalId === "string" ? payload.approvalId : "";
|
|
1676
|
+
if (approvalId) {
|
|
1677
|
+
if (!Array.isArray(assistantMessage._pendingApprovals)) {
|
|
1678
|
+
assistantMessage._pendingApprovals = [];
|
|
1679
|
+
}
|
|
1680
|
+
const exists = assistantMessage._pendingApprovals.some(
|
|
1681
|
+
(req) => req.approvalId === approvalId,
|
|
1682
|
+
);
|
|
1683
|
+
if (!exists) {
|
|
1684
|
+
assistantMessage._pendingApprovals.push({
|
|
1685
|
+
approvalId,
|
|
1686
|
+
tool: toolName,
|
|
1687
|
+
input: payload.input ?? {},
|
|
1688
|
+
state: "pending",
|
|
1689
|
+
});
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
renderIfActiveConversation(true);
|
|
1693
|
+
}
|
|
1694
|
+
if (eventName === "tool:approval:granted") {
|
|
1695
|
+
const toolText = "- approval granted";
|
|
1696
|
+
assistantMessage._currentTools.push(toolText);
|
|
1697
|
+
if (!assistantMessage.metadata) assistantMessage.metadata = {};
|
|
1698
|
+
if (!assistantMessage.metadata.toolActivity) assistantMessage.metadata.toolActivity = [];
|
|
1699
|
+
assistantMessage.metadata.toolActivity.push(toolText);
|
|
1700
|
+
const approvalId =
|
|
1701
|
+
typeof payload.approvalId === "string" ? payload.approvalId : "";
|
|
1702
|
+
if (approvalId && Array.isArray(assistantMessage._pendingApprovals)) {
|
|
1703
|
+
assistantMessage._pendingApprovals = assistantMessage._pendingApprovals.filter(
|
|
1704
|
+
(req) => req.approvalId !== approvalId,
|
|
1705
|
+
);
|
|
1706
|
+
}
|
|
1707
|
+
renderIfActiveConversation(true);
|
|
1708
|
+
}
|
|
1709
|
+
if (eventName === "tool:approval:denied") {
|
|
1710
|
+
const toolText = "- approval denied";
|
|
1711
|
+
assistantMessage._currentTools.push(toolText);
|
|
1712
|
+
if (!assistantMessage.metadata) assistantMessage.metadata = {};
|
|
1713
|
+
if (!assistantMessage.metadata.toolActivity) assistantMessage.metadata.toolActivity = [];
|
|
1714
|
+
assistantMessage.metadata.toolActivity.push(toolText);
|
|
1715
|
+
const approvalId =
|
|
1716
|
+
typeof payload.approvalId === "string" ? payload.approvalId : "";
|
|
1717
|
+
if (approvalId && Array.isArray(assistantMessage._pendingApprovals)) {
|
|
1718
|
+
assistantMessage._pendingApprovals = assistantMessage._pendingApprovals.filter(
|
|
1719
|
+
(req) => req.approvalId !== approvalId,
|
|
1720
|
+
);
|
|
1721
|
+
}
|
|
1722
|
+
renderIfActiveConversation(true);
|
|
1723
|
+
}
|
|
1724
|
+
if (eventName === "run:completed") {
|
|
1725
|
+
_totalSteps += typeof payload.result?.steps === "number" ? payload.result.steps : 0;
|
|
1726
|
+
if (typeof payload.result?.maxSteps === "number") _maxSteps = payload.result.maxSteps;
|
|
1727
|
+
if (payload.result?.continuation === true && (_maxSteps <= 0 || _totalSteps < _maxSteps)) {
|
|
1728
|
+
_shouldContinue = true;
|
|
1729
|
+
} else {
|
|
1730
|
+
finalizeAssistantMessage();
|
|
1731
|
+
if (!assistantMessage.content || assistantMessage.content.length === 0) {
|
|
1732
|
+
assistantMessage.content = String(payload.result?.response || "");
|
|
1733
|
+
}
|
|
1734
|
+
renderIfActiveConversation(false);
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
if (eventName === "run:cancelled") {
|
|
1738
|
+
finalizeAssistantMessage();
|
|
1739
|
+
renderIfActiveConversation(false);
|
|
1740
|
+
}
|
|
1741
|
+
if (eventName === "run:error") {
|
|
1742
|
+
finalizeAssistantMessage();
|
|
1743
|
+
const errMsg = payload.error?.message || "Something went wrong";
|
|
1744
|
+
assistantMessage._error = errMsg;
|
|
1745
|
+
renderIfActiveConversation(false);
|
|
1746
|
+
}
|
|
1747
|
+
} catch (error) {
|
|
1748
|
+
console.error("SSE event handling error:", eventName, error);
|
|
1749
|
+
}
|
|
1750
|
+
});
|
|
1751
|
+
}
|
|
1752
|
+
if (!_shouldContinue) break;
|
|
1753
|
+
_continuationMessage = "Continue";
|
|
1754
|
+
}
|
|
1755
|
+
// Update active state only if user is still on this conversation.
|
|
1756
|
+
if (state.activeConversationId === streamConversationId) {
|
|
1757
|
+
state.activeMessages = localMessages;
|
|
1758
|
+
}
|
|
1759
|
+
await loadConversations();
|
|
1760
|
+
// Don't reload the conversation - we already have the latest state with tool chips
|
|
1761
|
+
} catch (error) {
|
|
1762
|
+
if (streamAbortController.signal.aborted) {
|
|
1763
|
+
assistantMessage._activeActivities = [];
|
|
1764
|
+
if (assistantMessage._currentTools.length > 0) {
|
|
1765
|
+
assistantMessage._sections.push({ type: "tools", content: assistantMessage._currentTools });
|
|
1766
|
+
assistantMessage._currentTools = [];
|
|
1767
|
+
}
|
|
1768
|
+
if (assistantMessage._currentText.length > 0) {
|
|
1769
|
+
assistantMessage._sections.push({ type: "text", content: assistantMessage._currentText });
|
|
1770
|
+
assistantMessage._currentText = "";
|
|
1771
|
+
}
|
|
1772
|
+
renderMessages(localMessages, false);
|
|
1773
|
+
} else {
|
|
1774
|
+
assistantMessage._activeActivities = [];
|
|
1775
|
+
assistantMessage._error = error instanceof Error ? error.message : "Something went wrong";
|
|
1776
|
+
renderMessages(localMessages, false);
|
|
1777
|
+
}
|
|
1778
|
+
} finally {
|
|
1779
|
+
if (state.activeStreamAbortController === streamAbortController) {
|
|
1780
|
+
state.activeStreamAbortController = null;
|
|
1781
|
+
}
|
|
1782
|
+
if (state.activeStreamConversationId === conversationId) {
|
|
1783
|
+
state.activeStreamConversationId = null;
|
|
1784
|
+
}
|
|
1785
|
+
state.activeStreamRunId = null;
|
|
1786
|
+
setStreaming(false);
|
|
1787
|
+
elements.prompt.focus();
|
|
1788
|
+
}
|
|
1789
|
+
};
|
|
1790
|
+
|
|
1791
|
+
const requireAuth = async () => {
|
|
1792
|
+
try {
|
|
1793
|
+
const session = await api("/api/auth/session");
|
|
1794
|
+
if (!session.authenticated) {
|
|
1795
|
+
elements.auth.classList.remove("hidden");
|
|
1796
|
+
elements.app.classList.add("hidden");
|
|
1797
|
+
return false;
|
|
1798
|
+
}
|
|
1799
|
+
state.csrfToken = session.csrfToken || "";
|
|
1800
|
+
elements.auth.classList.add("hidden");
|
|
1801
|
+
elements.app.classList.remove("hidden");
|
|
1802
|
+
return true;
|
|
1803
|
+
} catch {
|
|
1804
|
+
elements.auth.classList.remove("hidden");
|
|
1805
|
+
elements.app.classList.add("hidden");
|
|
1806
|
+
return false;
|
|
1807
|
+
}
|
|
1808
|
+
};
|
|
1809
|
+
|
|
1810
|
+
elements.loginForm.addEventListener("submit", async (event) => {
|
|
1811
|
+
event.preventDefault();
|
|
1812
|
+
elements.loginError.textContent = "";
|
|
1813
|
+
try {
|
|
1814
|
+
const result = await api("/api/auth/login", {
|
|
1815
|
+
method: "POST",
|
|
1816
|
+
body: JSON.stringify({ passphrase: elements.passphrase.value || "" })
|
|
1817
|
+
});
|
|
1818
|
+
state.csrfToken = result.csrfToken || "";
|
|
1819
|
+
elements.passphrase.value = "";
|
|
1820
|
+
elements.auth.classList.add("hidden");
|
|
1821
|
+
elements.app.classList.remove("hidden");
|
|
1822
|
+
await loadConversations();
|
|
1823
|
+
const urlConversationId = getConversationIdFromUrl();
|
|
1824
|
+
if (urlConversationId) {
|
|
1825
|
+
state.activeConversationId = urlConversationId;
|
|
1826
|
+
renderConversationList();
|
|
1827
|
+
try {
|
|
1828
|
+
await loadConversation(urlConversationId);
|
|
1829
|
+
} catch {
|
|
1830
|
+
state.activeConversationId = null;
|
|
1831
|
+
state.activeMessages = [];
|
|
1832
|
+
replaceConversationUrl(null);
|
|
1833
|
+
renderMessages([]);
|
|
1834
|
+
renderConversationList();
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
} catch (error) {
|
|
1838
|
+
elements.loginError.textContent = error.message || "Login failed";
|
|
1839
|
+
}
|
|
1840
|
+
});
|
|
1841
|
+
|
|
1842
|
+
const startNewChat = () => {
|
|
1843
|
+
if (window._resetBrowserPanel) window._resetBrowserPanel();
|
|
1844
|
+
state.activeConversationId = null;
|
|
1845
|
+
state.activeMessages = [];
|
|
1846
|
+
state.confirmDeleteId = null;
|
|
1847
|
+
state.contextTokens = 0;
|
|
1848
|
+
state.contextWindow = 0;
|
|
1849
|
+
updateContextRing();
|
|
1850
|
+
pushConversationUrl(null);
|
|
1851
|
+
elements.chatTitle.textContent = "";
|
|
1852
|
+
renderMessages([]);
|
|
1853
|
+
renderConversationList();
|
|
1854
|
+
elements.prompt.focus();
|
|
1855
|
+
if (isMobile()) {
|
|
1856
|
+
setSidebarOpen(false);
|
|
1857
|
+
}
|
|
1858
|
+
};
|
|
1859
|
+
|
|
1860
|
+
elements.newChat.addEventListener("click", startNewChat);
|
|
1861
|
+
elements.topbarNewChat.addEventListener("click", startNewChat);
|
|
1862
|
+
|
|
1863
|
+
elements.chatTitle.addEventListener("dblclick", beginTitleEdit);
|
|
1864
|
+
|
|
1865
|
+
elements.prompt.addEventListener("input", () => {
|
|
1866
|
+
autoResizePrompt();
|
|
1867
|
+
});
|
|
1868
|
+
|
|
1869
|
+
elements.prompt.addEventListener("keydown", (event) => {
|
|
1870
|
+
if (event.key === "Enter" && !event.shiftKey) {
|
|
1871
|
+
event.preventDefault();
|
|
1872
|
+
elements.composer.requestSubmit();
|
|
1873
|
+
}
|
|
1874
|
+
});
|
|
1875
|
+
|
|
1876
|
+
elements.sidebarToggle.addEventListener("click", () => {
|
|
1877
|
+
if (isMobile()) setSidebarOpen(!elements.shell.classList.contains("sidebar-open"));
|
|
1878
|
+
});
|
|
1879
|
+
|
|
1880
|
+
elements.sidebarBackdrop.addEventListener("click", () => setSidebarOpen(false));
|
|
1881
|
+
|
|
1882
|
+
elements.logout.addEventListener("click", async () => {
|
|
1883
|
+
await api("/api/auth/logout", { method: "POST" });
|
|
1884
|
+
state.activeConversationId = null;
|
|
1885
|
+
state.activeMessages = [];
|
|
1886
|
+
state.confirmDeleteId = null;
|
|
1887
|
+
state.conversations = [];
|
|
1888
|
+
state.csrfToken = "";
|
|
1889
|
+
state.contextTokens = 0;
|
|
1890
|
+
state.contextWindow = 0;
|
|
1891
|
+
updateContextRing();
|
|
1892
|
+
await requireAuth();
|
|
1893
|
+
});
|
|
1894
|
+
|
|
1895
|
+
elements.composer.addEventListener("submit", async (event) => {
|
|
1896
|
+
event.preventDefault();
|
|
1897
|
+
if (state.isStreaming) {
|
|
1898
|
+
if (!state.activeStreamRunId) {
|
|
1899
|
+
return;
|
|
1900
|
+
}
|
|
1901
|
+
await stopActiveRun();
|
|
1902
|
+
return;
|
|
1903
|
+
}
|
|
1904
|
+
const value = elements.prompt.value;
|
|
1905
|
+
elements.prompt.value = "";
|
|
1906
|
+
autoResizePrompt();
|
|
1907
|
+
await sendMessage(value);
|
|
1908
|
+
});
|
|
1909
|
+
|
|
1910
|
+
elements.attachBtn.addEventListener("click", () => elements.fileInput.click());
|
|
1911
|
+
elements.fileInput.addEventListener("change", () => {
|
|
1912
|
+
if (elements.fileInput.files && elements.fileInput.files.length > 0) {
|
|
1913
|
+
addFiles(elements.fileInput.files);
|
|
1914
|
+
elements.fileInput.value = "";
|
|
1915
|
+
}
|
|
1916
|
+
});
|
|
1917
|
+
elements.attachmentPreview.addEventListener("click", (e) => {
|
|
1918
|
+
const rm = e.target.closest(".remove-attachment");
|
|
1919
|
+
if (rm) {
|
|
1920
|
+
const idx = parseInt(rm.dataset.idx, 10);
|
|
1921
|
+
state.pendingFiles.splice(idx, 1);
|
|
1922
|
+
renderAttachmentPreview();
|
|
1923
|
+
}
|
|
1924
|
+
});
|
|
1925
|
+
|
|
1926
|
+
let dragCounter = 0;
|
|
1927
|
+
document.addEventListener("dragenter", (e) => {
|
|
1928
|
+
e.preventDefault();
|
|
1929
|
+
dragCounter++;
|
|
1930
|
+
if (dragCounter === 1) elements.dragOverlay.classList.add("active");
|
|
1931
|
+
});
|
|
1932
|
+
document.addEventListener("dragleave", (e) => {
|
|
1933
|
+
e.preventDefault();
|
|
1934
|
+
dragCounter--;
|
|
1935
|
+
if (dragCounter <= 0) { dragCounter = 0; elements.dragOverlay.classList.remove("active"); }
|
|
1936
|
+
});
|
|
1937
|
+
document.addEventListener("dragover", (e) => e.preventDefault());
|
|
1938
|
+
document.addEventListener("drop", (e) => {
|
|
1939
|
+
e.preventDefault();
|
|
1940
|
+
dragCounter = 0;
|
|
1941
|
+
elements.dragOverlay.classList.remove("active");
|
|
1942
|
+
if (e.dataTransfer && e.dataTransfer.files.length > 0) {
|
|
1943
|
+
addFiles(e.dataTransfer.files);
|
|
1944
|
+
}
|
|
1945
|
+
});
|
|
1946
|
+
|
|
1947
|
+
// Paste files/images from clipboard
|
|
1948
|
+
elements.prompt.addEventListener("paste", (e) => {
|
|
1949
|
+
const items = e.clipboardData && e.clipboardData.items;
|
|
1950
|
+
if (!items) return;
|
|
1951
|
+
const files = [];
|
|
1952
|
+
for (let i = 0; i < items.length; i++) {
|
|
1953
|
+
if (items[i].kind === "file") {
|
|
1954
|
+
const f = items[i].getAsFile();
|
|
1955
|
+
if (f) files.push(f);
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
if (files.length > 0) {
|
|
1959
|
+
e.preventDefault();
|
|
1960
|
+
addFiles(files);
|
|
1961
|
+
}
|
|
1962
|
+
});
|
|
1963
|
+
|
|
1964
|
+
// Lightbox: open/close helpers
|
|
1965
|
+
const lightboxImg = elements.lightbox.querySelector("img");
|
|
1966
|
+
const openLightbox = (src) => {
|
|
1967
|
+
lightboxImg.src = src;
|
|
1968
|
+
elements.lightbox.style.display = "flex";
|
|
1969
|
+
requestAnimationFrame(() => {
|
|
1970
|
+
requestAnimationFrame(() => elements.lightbox.classList.add("active"));
|
|
1971
|
+
});
|
|
1972
|
+
};
|
|
1973
|
+
const closeLightbox = () => {
|
|
1974
|
+
elements.lightbox.classList.remove("active");
|
|
1975
|
+
elements.lightbox.addEventListener("transitionend", function handler() {
|
|
1976
|
+
elements.lightbox.removeEventListener("transitionend", handler);
|
|
1977
|
+
elements.lightbox.style.display = "none";
|
|
1978
|
+
lightboxImg.src = "";
|
|
1979
|
+
});
|
|
1980
|
+
};
|
|
1981
|
+
elements.lightbox.addEventListener("click", closeLightbox);
|
|
1982
|
+
document.addEventListener("keydown", (e) => {
|
|
1983
|
+
if (e.key === "Escape" && elements.lightbox.style.display !== "none") closeLightbox();
|
|
1984
|
+
});
|
|
1985
|
+
|
|
1986
|
+
// Lightbox from message images and tool screenshots
|
|
1987
|
+
elements.messages.addEventListener("click", (e) => {
|
|
1988
|
+
const img = e.target;
|
|
1989
|
+
if (!(img instanceof HTMLImageElement)) return;
|
|
1990
|
+
if (img.closest(".user-file-attachments") || img.classList.contains("tool-screenshot")) {
|
|
1991
|
+
openLightbox(img.src);
|
|
1992
|
+
}
|
|
1993
|
+
});
|
|
1994
|
+
|
|
1995
|
+
// Lightbox from attachment preview chips
|
|
1996
|
+
elements.attachmentPreview.addEventListener("click", (e) => {
|
|
1997
|
+
if (e.target.closest(".remove-attachment")) return;
|
|
1998
|
+
const chip = e.target.closest(".attachment-chip");
|
|
1999
|
+
if (!chip) return;
|
|
2000
|
+
const img = chip.querySelector("img");
|
|
2001
|
+
if (!img) return;
|
|
2002
|
+
e.stopPropagation();
|
|
2003
|
+
openLightbox(img.src);
|
|
2004
|
+
});
|
|
2005
|
+
|
|
2006
|
+
elements.messages.addEventListener("click", async (event) => {
|
|
2007
|
+
const target = event.target;
|
|
2008
|
+
if (!(target instanceof Element)) {
|
|
2009
|
+
return;
|
|
2010
|
+
}
|
|
2011
|
+
const button = target.closest(".approval-action-btn");
|
|
2012
|
+
if (!button) {
|
|
2013
|
+
return;
|
|
2014
|
+
}
|
|
2015
|
+
const approvalId = button.getAttribute("data-approval-id") || "";
|
|
2016
|
+
const decision = button.getAttribute("data-approval-decision") || "";
|
|
2017
|
+
if (!approvalId || (decision !== "approve" && decision !== "deny")) {
|
|
2018
|
+
return;
|
|
2019
|
+
}
|
|
2020
|
+
if (state.approvalRequestsInFlight[approvalId]) {
|
|
2021
|
+
return;
|
|
2022
|
+
}
|
|
2023
|
+
state.approvalRequestsInFlight[approvalId] = true;
|
|
2024
|
+
const wasStreaming = state.isStreaming;
|
|
2025
|
+
if (!wasStreaming) {
|
|
2026
|
+
setStreaming(true);
|
|
2027
|
+
}
|
|
2028
|
+
updatePendingApproval(approvalId, (request) => ({
|
|
2029
|
+
...request,
|
|
2030
|
+
state: "submitting",
|
|
2031
|
+
pendingDecision: decision,
|
|
2032
|
+
}));
|
|
2033
|
+
renderMessages(state.activeMessages, state.isStreaming);
|
|
2034
|
+
try {
|
|
2035
|
+
await api("/api/approvals/" + encodeURIComponent(approvalId), {
|
|
2036
|
+
method: "POST",
|
|
2037
|
+
body: JSON.stringify({ approved: decision === "approve" }),
|
|
2038
|
+
});
|
|
2039
|
+
updatePendingApproval(approvalId, () => null);
|
|
2040
|
+
renderMessages(state.activeMessages, state.isStreaming);
|
|
2041
|
+
loadConversations();
|
|
2042
|
+
if (!wasStreaming && state.activeConversationId) {
|
|
2043
|
+
await streamConversationEvents(state.activeConversationId, { liveOnly: true });
|
|
2044
|
+
}
|
|
2045
|
+
} catch (error) {
|
|
2046
|
+
const isStale = error && error.payload && error.payload.code === "APPROVAL_NOT_FOUND";
|
|
2047
|
+
if (isStale) {
|
|
2048
|
+
updatePendingApproval(approvalId, () => null);
|
|
2049
|
+
} else {
|
|
2050
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
2051
|
+
updatePendingApproval(approvalId, (request) => ({
|
|
2052
|
+
...request,
|
|
2053
|
+
state: "pending",
|
|
2054
|
+
pendingDecision: null,
|
|
2055
|
+
_error: errMsg,
|
|
2056
|
+
}));
|
|
2057
|
+
}
|
|
2058
|
+
renderMessages(state.activeMessages, state.isStreaming);
|
|
2059
|
+
} finally {
|
|
2060
|
+
if (!wasStreaming) {
|
|
2061
|
+
setStreaming(false);
|
|
2062
|
+
renderMessages(state.activeMessages, false);
|
|
2063
|
+
}
|
|
2064
|
+
delete state.approvalRequestsInFlight[approvalId];
|
|
2065
|
+
}
|
|
2066
|
+
});
|
|
2067
|
+
|
|
2068
|
+
elements.messages.addEventListener("scroll", () => {
|
|
2069
|
+
state.isMessagesPinnedToBottom = isNearBottom(elements.messages);
|
|
2070
|
+
}, { passive: true });
|
|
2071
|
+
|
|
2072
|
+
document.addEventListener("click", (event) => {
|
|
2073
|
+
if (!(event.target instanceof Node)) {
|
|
2074
|
+
return;
|
|
2075
|
+
}
|
|
2076
|
+
if (!event.target.closest(".conversation-item") && state.confirmDeleteId) {
|
|
2077
|
+
state.confirmDeleteId = null;
|
|
2078
|
+
renderConversationList();
|
|
2079
|
+
}
|
|
2080
|
+
});
|
|
2081
|
+
|
|
2082
|
+
window.addEventListener("resize", () => {
|
|
2083
|
+
setSidebarOpen(false);
|
|
2084
|
+
});
|
|
2085
|
+
|
|
2086
|
+
const navigateToConversation = async (conversationId) => {
|
|
2087
|
+
if (conversationId) {
|
|
2088
|
+
state.activeConversationId = conversationId;
|
|
2089
|
+
renderConversationList();
|
|
2090
|
+
try {
|
|
2091
|
+
await loadConversation(conversationId);
|
|
2092
|
+
} catch {
|
|
2093
|
+
// Conversation not found – fall back to empty state
|
|
2094
|
+
state.activeConversationId = null;
|
|
2095
|
+
state.activeMessages = [];
|
|
2096
|
+
replaceConversationUrl(null);
|
|
2097
|
+
elements.chatTitle.textContent = "";
|
|
2098
|
+
renderMessages([]);
|
|
2099
|
+
renderConversationList();
|
|
2100
|
+
}
|
|
2101
|
+
} else {
|
|
2102
|
+
state.activeConversationId = null;
|
|
2103
|
+
state.activeMessages = [];
|
|
2104
|
+
state.contextTokens = 0;
|
|
2105
|
+
state.contextWindow = 0;
|
|
2106
|
+
updateContextRing();
|
|
2107
|
+
elements.chatTitle.textContent = "";
|
|
2108
|
+
renderMessages([]);
|
|
2109
|
+
renderConversationList();
|
|
2110
|
+
}
|
|
2111
|
+
};
|
|
2112
|
+
|
|
2113
|
+
window.addEventListener("popstate", async () => {
|
|
2114
|
+
if (state.isStreaming) return;
|
|
2115
|
+
const conversationId = getConversationIdFromUrl();
|
|
2116
|
+
await navigateToConversation(conversationId);
|
|
2117
|
+
});
|
|
2118
|
+
|
|
2119
|
+
(async () => {
|
|
2120
|
+
const authenticated = await requireAuth();
|
|
2121
|
+
if (!authenticated) {
|
|
2122
|
+
return;
|
|
2123
|
+
}
|
|
2124
|
+
await loadConversations();
|
|
2125
|
+
const urlConversationId = getConversationIdFromUrl();
|
|
2126
|
+
if (urlConversationId) {
|
|
2127
|
+
state.activeConversationId = urlConversationId;
|
|
2128
|
+
replaceConversationUrl(urlConversationId);
|
|
2129
|
+
renderConversationList();
|
|
2130
|
+
try {
|
|
2131
|
+
await loadConversation(urlConversationId);
|
|
2132
|
+
} catch {
|
|
2133
|
+
// URL pointed to a conversation that no longer exists
|
|
2134
|
+
state.activeConversationId = null;
|
|
2135
|
+
state.activeMessages = [];
|
|
2136
|
+
replaceConversationUrl(null);
|
|
2137
|
+
elements.chatTitle.textContent = "";
|
|
2138
|
+
renderMessages([]);
|
|
2139
|
+
renderConversationList();
|
|
2140
|
+
if (state.conversations.length === 0) {
|
|
2141
|
+
await createConversation();
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
} else if (state.conversations.length === 0) {
|
|
2145
|
+
await createConversation();
|
|
2146
|
+
}
|
|
2147
|
+
autoResizePrompt();
|
|
2148
|
+
updateContextRing();
|
|
2149
|
+
elements.prompt.focus();
|
|
2150
|
+
})();
|
|
2151
|
+
|
|
2152
|
+
if ("serviceWorker" in navigator) {
|
|
2153
|
+
navigator.serviceWorker.register("/sw.js").catch(() => {});
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
// Detect iOS standalone mode and add class for CSS targeting
|
|
2157
|
+
if (window.navigator.standalone === true || window.matchMedia("(display-mode: standalone)").matches) {
|
|
2158
|
+
document.documentElement.classList.add("standalone");
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
// iOS viewport and keyboard handling
|
|
2162
|
+
(function() {
|
|
2163
|
+
var shell = document.querySelector(".shell");
|
|
2164
|
+
var pinScroll = function() { if (window.scrollY !== 0) window.scrollTo(0, 0); };
|
|
2165
|
+
|
|
2166
|
+
// Track the "full" height when keyboard is not open
|
|
2167
|
+
var fullHeight = window.innerHeight;
|
|
2168
|
+
|
|
2169
|
+
// Resize shell when iOS keyboard opens/closes
|
|
2170
|
+
var resizeForKeyboard = function() {
|
|
2171
|
+
if (!shell || !window.visualViewport) return;
|
|
2172
|
+
var vvHeight = window.visualViewport.height;
|
|
2173
|
+
|
|
2174
|
+
// Update fullHeight if viewport grew (keyboard closed)
|
|
2175
|
+
if (vvHeight > fullHeight) {
|
|
2176
|
+
fullHeight = vvHeight;
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
// Only apply height override if keyboard appears to be open
|
|
2180
|
+
// (viewport significantly smaller than full height)
|
|
2181
|
+
if (vvHeight < fullHeight - 100) {
|
|
2182
|
+
shell.style.height = vvHeight + "px";
|
|
2183
|
+
} else {
|
|
2184
|
+
// Keyboard closed - remove override, let CSS handle it
|
|
2185
|
+
shell.style.height = "";
|
|
2186
|
+
}
|
|
2187
|
+
pinScroll();
|
|
2188
|
+
};
|
|
2189
|
+
|
|
2190
|
+
if (window.visualViewport) {
|
|
2191
|
+
window.visualViewport.addEventListener("scroll", pinScroll);
|
|
2192
|
+
window.visualViewport.addEventListener("resize", resizeForKeyboard);
|
|
2193
|
+
}
|
|
2194
|
+
document.addEventListener("scroll", pinScroll);
|
|
2195
|
+
|
|
2196
|
+
// Draggable sidebar from left edge (mobile only)
|
|
2197
|
+
(function() {
|
|
2198
|
+
var sidebar = document.querySelector(".sidebar");
|
|
2199
|
+
var backdrop = document.querySelector(".sidebar-backdrop");
|
|
2200
|
+
var shell = document.querySelector(".shell");
|
|
2201
|
+
if (!sidebar || !backdrop || !shell) return;
|
|
2202
|
+
|
|
2203
|
+
var sidebarWidth = 260;
|
|
2204
|
+
var edgeThreshold = 50; // px from left edge to start drag
|
|
2205
|
+
var velocityThreshold = 0.3; // px/ms to trigger open/close
|
|
2206
|
+
|
|
2207
|
+
var dragging = false;
|
|
2208
|
+
var startX = 0;
|
|
2209
|
+
var startY = 0;
|
|
2210
|
+
var currentX = 0;
|
|
2211
|
+
var startTime = 0;
|
|
2212
|
+
var isOpen = false;
|
|
2213
|
+
var directionLocked = false;
|
|
2214
|
+
var isHorizontal = false;
|
|
2215
|
+
|
|
2216
|
+
function getProgress() {
|
|
2217
|
+
// Returns 0 (closed) to 1 (open)
|
|
2218
|
+
if (isOpen) {
|
|
2219
|
+
return Math.max(0, Math.min(1, 1 + currentX / sidebarWidth));
|
|
2220
|
+
} else {
|
|
2221
|
+
return Math.max(0, Math.min(1, currentX / sidebarWidth));
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
function updatePosition(progress) {
|
|
2226
|
+
var offset = (progress - 1) * sidebarWidth;
|
|
2227
|
+
sidebar.style.transform = "translateX(" + offset + "px)";
|
|
2228
|
+
backdrop.style.opacity = progress;
|
|
2229
|
+
if (progress > 0) {
|
|
2230
|
+
backdrop.style.pointerEvents = "auto";
|
|
2231
|
+
} else {
|
|
2232
|
+
backdrop.style.pointerEvents = "none";
|
|
2233
|
+
}
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2236
|
+
function onTouchStart(e) {
|
|
2237
|
+
if (window.innerWidth > 768) return;
|
|
2238
|
+
|
|
2239
|
+
// Don't intercept touches on interactive elements
|
|
2240
|
+
var target = e.target;
|
|
2241
|
+
if (target.closest("button") || target.closest("a") || target.closest("input") || target.closest("textarea")) {
|
|
2242
|
+
return;
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
var touch = e.touches[0];
|
|
2246
|
+
isOpen = shell.classList.contains("sidebar-open");
|
|
2247
|
+
|
|
2248
|
+
// When sidebar is closed: only respond to edge swipes
|
|
2249
|
+
// When sidebar is open: only respond to backdrop touches (not sidebar content)
|
|
2250
|
+
var fromEdge = touch.clientX < edgeThreshold;
|
|
2251
|
+
var onBackdrop = e.target === backdrop;
|
|
2252
|
+
|
|
2253
|
+
if (!isOpen && !fromEdge) return;
|
|
2254
|
+
if (isOpen && !onBackdrop) return;
|
|
2255
|
+
|
|
2256
|
+
// Prevent Safari back gesture when starting from edge
|
|
2257
|
+
if (fromEdge) {
|
|
2258
|
+
e.preventDefault();
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2261
|
+
startX = touch.clientX;
|
|
2262
|
+
startY = touch.clientY;
|
|
2263
|
+
currentX = 0;
|
|
2264
|
+
startTime = Date.now();
|
|
2265
|
+
directionLocked = false;
|
|
2266
|
+
isHorizontal = false;
|
|
2267
|
+
dragging = true;
|
|
2268
|
+
sidebar.classList.add("dragging");
|
|
2269
|
+
backdrop.classList.add("dragging");
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
function onTouchMove(e) {
|
|
2273
|
+
if (!dragging) return;
|
|
2274
|
+
var touch = e.touches[0];
|
|
2275
|
+
var dx = touch.clientX - startX;
|
|
2276
|
+
var dy = touch.clientY - startY;
|
|
2277
|
+
|
|
2278
|
+
// Lock direction after some movement
|
|
2279
|
+
if (!directionLocked && (Math.abs(dx) > 10 || Math.abs(dy) > 10)) {
|
|
2280
|
+
directionLocked = true;
|
|
2281
|
+
isHorizontal = Math.abs(dx) > Math.abs(dy);
|
|
2282
|
+
if (!isHorizontal) {
|
|
2283
|
+
// Vertical scroll, cancel drag
|
|
2284
|
+
dragging = false;
|
|
2285
|
+
sidebar.classList.remove("dragging");
|
|
2286
|
+
backdrop.classList.remove("dragging");
|
|
2287
|
+
return;
|
|
2288
|
+
}
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
if (!directionLocked) return;
|
|
2292
|
+
|
|
2293
|
+
// Prevent scrolling while dragging sidebar
|
|
2294
|
+
e.preventDefault();
|
|
2295
|
+
|
|
2296
|
+
currentX = dx;
|
|
2297
|
+
updatePosition(getProgress());
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
function onTouchEnd(e) {
|
|
2301
|
+
if (!dragging) return;
|
|
2302
|
+
dragging = false;
|
|
2303
|
+
sidebar.classList.remove("dragging");
|
|
2304
|
+
backdrop.classList.remove("dragging");
|
|
2305
|
+
|
|
2306
|
+
var touch = e.changedTouches[0];
|
|
2307
|
+
var dx = touch.clientX - startX;
|
|
2308
|
+
var dt = Date.now() - startTime;
|
|
2309
|
+
var velocity = dx / dt; // px/ms
|
|
2310
|
+
|
|
2311
|
+
var progress = getProgress();
|
|
2312
|
+
var shouldOpen;
|
|
2313
|
+
|
|
2314
|
+
// Use velocity if fast enough, otherwise use position threshold
|
|
2315
|
+
if (Math.abs(velocity) > velocityThreshold) {
|
|
2316
|
+
shouldOpen = velocity > 0;
|
|
2317
|
+
} else {
|
|
2318
|
+
shouldOpen = progress > 0.5;
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
// Reset inline styles and let CSS handle the animation
|
|
2322
|
+
sidebar.style.transform = "";
|
|
2323
|
+
backdrop.style.opacity = "";
|
|
2324
|
+
backdrop.style.pointerEvents = "";
|
|
2325
|
+
|
|
2326
|
+
if (shouldOpen) {
|
|
2327
|
+
shell.classList.add("sidebar-open");
|
|
2328
|
+
} else {
|
|
2329
|
+
shell.classList.remove("sidebar-open");
|
|
2330
|
+
}
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
document.addEventListener("touchstart", onTouchStart, { passive: false });
|
|
2334
|
+
document.addEventListener("touchmove", onTouchMove, { passive: false });
|
|
2335
|
+
document.addEventListener("touchend", onTouchEnd, { passive: true });
|
|
2336
|
+
document.addEventListener("touchcancel", onTouchEnd, { passive: true });
|
|
2337
|
+
})();
|
|
2338
|
+
|
|
2339
|
+
// Prevent Safari back/forward navigation by manipulating history
|
|
2340
|
+
// This doesn't stop the gesture animation but prevents actual navigation
|
|
2341
|
+
if (window.navigator.standalone || window.matchMedia("(display-mode: standalone)").matches) {
|
|
2342
|
+
history.pushState(null, "", location.href);
|
|
2343
|
+
window.addEventListener("popstate", function() {
|
|
2344
|
+
history.pushState(null, "", location.href);
|
|
2345
|
+
});
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2348
|
+
// Right edge blocker - intercept touch events to prevent forward navigation
|
|
2349
|
+
var rightBlocker = document.querySelector(".edge-blocker-right");
|
|
2350
|
+
if (rightBlocker) {
|
|
2351
|
+
rightBlocker.addEventListener("touchstart", function(e) {
|
|
2352
|
+
e.preventDefault();
|
|
2353
|
+
}, { passive: false });
|
|
2354
|
+
rightBlocker.addEventListener("touchmove", function(e) {
|
|
2355
|
+
e.preventDefault();
|
|
2356
|
+
}, { passive: false });
|
|
2357
|
+
}
|
|
2358
|
+
|
|
2359
|
+
// Browser viewport panel
|
|
2360
|
+
(function initBrowserPanel() {
|
|
2361
|
+
var panel = elements.browserPanel;
|
|
2362
|
+
var frameImg = elements.browserPanelFrame;
|
|
2363
|
+
var urlLabel = elements.browserPanelUrl;
|
|
2364
|
+
var placeholder = elements.browserPanelPlaceholder;
|
|
2365
|
+
var closeBtn = elements.browserPanelClose;
|
|
2366
|
+
if (!panel || !frameImg) return;
|
|
2367
|
+
|
|
2368
|
+
var resizeHandle = elements.browserPanelResize;
|
|
2369
|
+
var mainEl = document.querySelector(".main-chat");
|
|
2370
|
+
var abortController = null;
|
|
2371
|
+
var panelHiddenByUser = false;
|
|
2372
|
+
|
|
2373
|
+
var showPanel = function(show) {
|
|
2374
|
+
var visible = show && !panelHiddenByUser;
|
|
2375
|
+
panel.style.display = visible ? "flex" : "none";
|
|
2376
|
+
if (resizeHandle) resizeHandle.style.display = visible ? "block" : "none";
|
|
2377
|
+
if (mainEl) {
|
|
2378
|
+
if (visible) mainEl.classList.add("has-browser");
|
|
2379
|
+
else mainEl.classList.remove("has-browser");
|
|
2380
|
+
}
|
|
2381
|
+
};
|
|
2382
|
+
|
|
2383
|
+
|
|
2384
|
+
closeBtn && closeBtn.addEventListener("click", function() {
|
|
2385
|
+
panelHiddenByUser = true;
|
|
2386
|
+
showPanel(false);
|
|
2387
|
+
});
|
|
2388
|
+
|
|
2389
|
+
var navBack = elements.browserNavBack;
|
|
2390
|
+
var navFwd = elements.browserNavForward;
|
|
2391
|
+
var sendBrowserNav = function(action) {
|
|
2392
|
+
var headers = { "Content-Type": "application/json" };
|
|
2393
|
+
if (state.csrfToken) headers["x-csrf-token"] = state.csrfToken;
|
|
2394
|
+
if (state.authToken) headers["authorization"] = "Bearer " + state.authToken;
|
|
2395
|
+
fetch("/api/browser/navigate", {
|
|
2396
|
+
method: "POST",
|
|
2397
|
+
headers: headers,
|
|
2398
|
+
body: JSON.stringify({ action: action, conversationId: state.activeConversationId }),
|
|
2399
|
+
});
|
|
2400
|
+
};
|
|
2401
|
+
navBack && navBack.addEventListener("click", function() { sendBrowserNav("back"); });
|
|
2402
|
+
navFwd && navFwd.addEventListener("click", function() { sendBrowserNav("forward"); });
|
|
2403
|
+
|
|
2404
|
+
window._resetBrowserPanel = function() {
|
|
2405
|
+
if (abortController) { abortController.abort(); abortController = null; }
|
|
2406
|
+
streamConversationId = null;
|
|
2407
|
+
panelHiddenByUser = false;
|
|
2408
|
+
showPanel(false);
|
|
2409
|
+
frameImg.style.display = "none";
|
|
2410
|
+
if (placeholder) {
|
|
2411
|
+
placeholder.textContent = "No active browser session";
|
|
2412
|
+
placeholder.style.display = "flex";
|
|
2413
|
+
}
|
|
2414
|
+
if (urlLabel) urlLabel.textContent = "";
|
|
2415
|
+
if (navBack) navBack.disabled = true;
|
|
2416
|
+
if (navFwd) navFwd.disabled = true;
|
|
2417
|
+
var headers = {};
|
|
2418
|
+
if (state.csrfToken) headers["x-csrf-token"] = state.csrfToken;
|
|
2419
|
+
if (state.authToken) headers["authorization"] = "Bearer " + state.authToken;
|
|
2420
|
+
var cid = state.activeConversationId;
|
|
2421
|
+
if (!cid) return;
|
|
2422
|
+
fetch("/api/browser/status?conversationId=" + encodeURIComponent(cid), { headers: headers }).then(function(r) {
|
|
2423
|
+
return r.json();
|
|
2424
|
+
}).then(function(s) {
|
|
2425
|
+
if (s && s.active && cid === state.activeConversationId) {
|
|
2426
|
+
if (urlLabel && s.url) urlLabel.textContent = s.url;
|
|
2427
|
+
if (navBack) navBack.disabled = false;
|
|
2428
|
+
if (navFwd) navFwd.disabled = false;
|
|
2429
|
+
connectBrowserStream();
|
|
2430
|
+
showPanel(true);
|
|
2431
|
+
}
|
|
2432
|
+
}).catch(function() {});
|
|
2433
|
+
};
|
|
2434
|
+
|
|
2435
|
+
// Drag-to-resize between conversation and browser panel
|
|
2436
|
+
if (resizeHandle && mainEl) {
|
|
2437
|
+
var dragging = false;
|
|
2438
|
+
resizeHandle.addEventListener("mousedown", function(e) {
|
|
2439
|
+
e.preventDefault();
|
|
2440
|
+
dragging = true;
|
|
2441
|
+
resizeHandle.classList.add("dragging");
|
|
2442
|
+
document.body.style.cursor = "col-resize";
|
|
2443
|
+
document.body.style.userSelect = "none";
|
|
2444
|
+
});
|
|
2445
|
+
document.addEventListener("mousemove", function(e) {
|
|
2446
|
+
if (!dragging) return;
|
|
2447
|
+
var body = mainEl.parentElement;
|
|
2448
|
+
if (!body) return;
|
|
2449
|
+
var bodyRect = body.getBoundingClientRect();
|
|
2450
|
+
var available = bodyRect.width - 1;
|
|
2451
|
+
var chatW = e.clientX - bodyRect.left;
|
|
2452
|
+
chatW = Math.max(280, Math.min(chatW, available - 280));
|
|
2453
|
+
var browserW = available - chatW;
|
|
2454
|
+
mainEl.style.flex = "0 0 " + chatW + "px";
|
|
2455
|
+
panel.style.flex = "0 0 " + browserW + "px";
|
|
2456
|
+
});
|
|
2457
|
+
document.addEventListener("mouseup", function() {
|
|
2458
|
+
if (!dragging) return;
|
|
2459
|
+
dragging = false;
|
|
2460
|
+
resizeHandle.classList.remove("dragging");
|
|
2461
|
+
document.body.style.cursor = "";
|
|
2462
|
+
document.body.style.userSelect = "";
|
|
2463
|
+
});
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2466
|
+
// --- Browser viewport interaction ---
|
|
2467
|
+
var browserViewportW = 1280;
|
|
2468
|
+
var browserViewportH = 720;
|
|
2469
|
+
var sendBrowserInput = function(kind, event) {
|
|
2470
|
+
var headers = { "Content-Type": "application/json" };
|
|
2471
|
+
if (state.csrfToken) headers["x-csrf-token"] = state.csrfToken;
|
|
2472
|
+
if (state.authToken) headers["authorization"] = "Bearer " + state.authToken;
|
|
2473
|
+
fetch("/api/browser/input", {
|
|
2474
|
+
method: "POST",
|
|
2475
|
+
headers: headers,
|
|
2476
|
+
body: JSON.stringify({ kind: kind, event: event, conversationId: state.activeConversationId }),
|
|
2477
|
+
}).catch(function() {});
|
|
2478
|
+
};
|
|
2479
|
+
var toViewportCoords = function(e) {
|
|
2480
|
+
var rect = frameImg.getBoundingClientRect();
|
|
2481
|
+
var scaleX = browserViewportW / rect.width;
|
|
2482
|
+
var scaleY = browserViewportH / rect.height;
|
|
2483
|
+
return { x: Math.round((e.clientX - rect.left) * scaleX), y: Math.round((e.clientY - rect.top) * scaleY) };
|
|
2484
|
+
};
|
|
2485
|
+
|
|
2486
|
+
frameImg.style.cursor = "default";
|
|
2487
|
+
frameImg.setAttribute("tabindex", "0");
|
|
2488
|
+
|
|
2489
|
+
frameImg.addEventListener("click", function(e) {
|
|
2490
|
+
var coords = toViewportCoords(e);
|
|
2491
|
+
sendBrowserInput("mouse", { type: "mousePressed", x: coords.x, y: coords.y, button: "left", clickCount: 1 });
|
|
2492
|
+
setTimeout(function() {
|
|
2493
|
+
sendBrowserInput("mouse", { type: "mouseReleased", x: coords.x, y: coords.y, button: "left", clickCount: 1 });
|
|
2494
|
+
}, 50);
|
|
2495
|
+
frameImg.focus();
|
|
2496
|
+
});
|
|
2497
|
+
|
|
2498
|
+
frameImg.addEventListener("wheel", function(e) {
|
|
2499
|
+
e.preventDefault();
|
|
2500
|
+
var coords = toViewportCoords(e);
|
|
2501
|
+
sendBrowserInput("scroll", { deltaX: e.deltaX, deltaY: e.deltaY, x: coords.x, y: coords.y });
|
|
2502
|
+
}, { passive: false });
|
|
2503
|
+
|
|
2504
|
+
frameImg.addEventListener("keydown", function(e) {
|
|
2505
|
+
e.preventDefault();
|
|
2506
|
+
e.stopPropagation();
|
|
2507
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "v") {
|
|
2508
|
+
navigator.clipboard.readText().then(function(clip) {
|
|
2509
|
+
if (clip) sendBrowserInput("paste", { text: clip });
|
|
2510
|
+
}).catch(function() {});
|
|
2511
|
+
return;
|
|
2512
|
+
}
|
|
2513
|
+
var text = e.key.length === 1 ? e.key : undefined;
|
|
2514
|
+
sendBrowserInput("keyboard", { type: "keyDown", key: e.key, code: e.code, text: text, keyCode: e.keyCode });
|
|
2515
|
+
});
|
|
2516
|
+
frameImg.addEventListener("keyup", function(e) {
|
|
2517
|
+
e.preventDefault();
|
|
2518
|
+
e.stopPropagation();
|
|
2519
|
+
sendBrowserInput("keyboard", { type: "keyUp", key: e.key, code: e.code, keyCode: e.keyCode });
|
|
2520
|
+
});
|
|
2521
|
+
|
|
2522
|
+
var streamConversationId = null;
|
|
2523
|
+
|
|
2524
|
+
var connectBrowserStream = function() {
|
|
2525
|
+
var cid = state.activeConversationId;
|
|
2526
|
+
if (!cid) return;
|
|
2527
|
+
if (streamConversationId === cid && abortController) return;
|
|
2528
|
+
if (abortController) abortController.abort();
|
|
2529
|
+
streamConversationId = cid;
|
|
2530
|
+
abortController = new AbortController();
|
|
2531
|
+
var headers = {};
|
|
2532
|
+
if (state.csrfToken) headers["x-csrf-token"] = state.csrfToken;
|
|
2533
|
+
if (state.authToken) headers["authorization"] = "Bearer " + state.authToken;
|
|
2534
|
+
fetch("/api/browser/stream?conversationId=" + encodeURIComponent(cid), { headers: headers, signal: abortController.signal })
|
|
2535
|
+
.then(function(res) {
|
|
2536
|
+
if (!res.ok || !res.body) return;
|
|
2537
|
+
var reader = res.body.getReader();
|
|
2538
|
+
var decoder = new TextDecoder();
|
|
2539
|
+
var buf = "";
|
|
2540
|
+
var sseEvent = "";
|
|
2541
|
+
var sseData = "";
|
|
2542
|
+
var pump = function() {
|
|
2543
|
+
reader.read().then(function(result) {
|
|
2544
|
+
if (result.done) return;
|
|
2545
|
+
buf += decoder.decode(result.value, { stream: true });
|
|
2546
|
+
var lines = buf.split("\\n");
|
|
2547
|
+
buf = lines.pop() || "";
|
|
2548
|
+
var eventName = sseEvent;
|
|
2549
|
+
var data = sseData;
|
|
2550
|
+
for (var i = 0; i < lines.length; i++) {
|
|
2551
|
+
var line = lines[i];
|
|
2552
|
+
if (line.startsWith("event: ")) {
|
|
2553
|
+
eventName = line.slice(7).trim();
|
|
2554
|
+
} else if (line.startsWith("data: ")) {
|
|
2555
|
+
data += line.slice(6);
|
|
2556
|
+
} else if (line === "" && eventName && data) {
|
|
2557
|
+
try {
|
|
2558
|
+
var payload = JSON.parse(data);
|
|
2559
|
+
if (streamConversationId !== state.activeConversationId) { eventName = ""; data = ""; continue; }
|
|
2560
|
+
if (eventName === "browser:frame") {
|
|
2561
|
+
frameImg.src = "data:image/jpeg;base64," + payload.data;
|
|
2562
|
+
frameImg.style.display = "block";
|
|
2563
|
+
if (payload.width) browserViewportW = payload.width;
|
|
2564
|
+
if (payload.height) browserViewportH = payload.height;
|
|
2565
|
+
if (placeholder) placeholder.style.display = "none";
|
|
2566
|
+
showPanel(true);
|
|
2567
|
+
void panel.offsetHeight;
|
|
2568
|
+
}
|
|
2569
|
+
if (eventName === "browser:status") {
|
|
2570
|
+
if (payload.url && urlLabel) urlLabel.textContent = payload.url;
|
|
2571
|
+
if (navBack) navBack.disabled = !payload.active;
|
|
2572
|
+
if (navFwd) navFwd.disabled = !payload.active;
|
|
2573
|
+
if (payload.active) {
|
|
2574
|
+
showPanel(true);
|
|
2575
|
+
} else {
|
|
2576
|
+
if (abortController) { abortController.abort(); abortController = null; }
|
|
2577
|
+
streamConversationId = null;
|
|
2578
|
+
frameImg.style.display = "none";
|
|
2579
|
+
if (placeholder) {
|
|
2580
|
+
placeholder.textContent = "Browser closed";
|
|
2581
|
+
placeholder.style.display = "flex";
|
|
2582
|
+
}
|
|
2583
|
+
showPanel(false);
|
|
2584
|
+
}
|
|
2585
|
+
}
|
|
2586
|
+
} catch(e) {}
|
|
2587
|
+
eventName = "";
|
|
2588
|
+
data = "";
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2591
|
+
sseEvent = eventName;
|
|
2592
|
+
sseData = data;
|
|
2593
|
+
pump();
|
|
2594
|
+
}).catch(function() {});
|
|
2595
|
+
};
|
|
2596
|
+
pump();
|
|
2597
|
+
})
|
|
2598
|
+
.catch(function() {});
|
|
2599
|
+
};
|
|
2600
|
+
|
|
2601
|
+
window._connectBrowserStream = connectBrowserStream;
|
|
2602
|
+
})();
|
|
2603
|
+
})();
|
|
2604
|
+
|
|
2605
|
+
`;
|