@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
package/src/web-ui.ts
CHANGED
|
@@ -1,356 +1,28 @@
|
|
|
1
|
-
import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
1
|
import { readFileSync } from "node:fs";
|
|
4
|
-
import {
|
|
5
|
-
import { homedir } from "node:os";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
6
3
|
import { createRequire } from "node:module";
|
|
7
|
-
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
8
|
-
import type { Message } from "@poncho-ai/sdk";
|
|
9
4
|
|
|
10
|
-
|
|
5
|
+
import { WEB_UI_STYLES } from "./web-ui-styles.js";
|
|
6
|
+
import { getWebUiClientScript } from "./web-ui-client.js";
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
FileConversationStore,
|
|
10
|
+
LoginRateLimiter,
|
|
11
|
+
SessionStore,
|
|
12
|
+
getRequestIp,
|
|
13
|
+
inferConversationTitle,
|
|
14
|
+
parseCookies,
|
|
15
|
+
setCookie,
|
|
16
|
+
verifyPassphrase,
|
|
17
|
+
} from "./web-ui-store.js";
|
|
18
|
+
|
|
19
|
+
export type { WebUiConversation } from "./web-ui-store.js";
|
|
20
|
+
|
|
11
21
|
const require = createRequire(import.meta.url);
|
|
12
22
|
const markedPackagePath = require.resolve("marked");
|
|
13
23
|
const markedDir = dirname(markedPackagePath);
|
|
14
24
|
const markedSource = readFileSync(join(markedDir, "marked.umd.js"), "utf-8");
|
|
15
25
|
|
|
16
|
-
export interface WebUiConversation {
|
|
17
|
-
conversationId: string;
|
|
18
|
-
title: string;
|
|
19
|
-
messages: Message[];
|
|
20
|
-
runtimeRunId?: string;
|
|
21
|
-
ownerId: string;
|
|
22
|
-
tenantId: string | null;
|
|
23
|
-
createdAt: number;
|
|
24
|
-
updatedAt: number;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
type ConversationStoreFile = {
|
|
28
|
-
conversations: WebUiConversation[];
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
const DEFAULT_OWNER = "local-owner";
|
|
32
|
-
|
|
33
|
-
const getStateDirectory = (): string => {
|
|
34
|
-
const cwd = process.cwd();
|
|
35
|
-
const home = homedir();
|
|
36
|
-
// On serverless platforms (Vercel, AWS Lambda), only /tmp is writable
|
|
37
|
-
const isServerless =
|
|
38
|
-
process.env.VERCEL === "1" ||
|
|
39
|
-
process.env.VERCEL_ENV !== undefined ||
|
|
40
|
-
process.env.VERCEL_URL !== undefined ||
|
|
41
|
-
process.env.AWS_LAMBDA_FUNCTION_NAME !== undefined ||
|
|
42
|
-
process.env.AWS_EXECUTION_ENV?.includes("AWS_Lambda") === true ||
|
|
43
|
-
process.env.LAMBDA_TASK_ROOT !== undefined ||
|
|
44
|
-
process.env.NOW_REGION !== undefined ||
|
|
45
|
-
cwd.startsWith("/var/task") ||
|
|
46
|
-
home.startsWith("/var/task") ||
|
|
47
|
-
process.env.SERVERLESS === "1";
|
|
48
|
-
if (isServerless) {
|
|
49
|
-
return "/tmp/.poncho/state";
|
|
50
|
-
}
|
|
51
|
-
return resolve(homedir(), ".poncho", "state");
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
export class FileConversationStore {
|
|
55
|
-
private readonly filePath: string;
|
|
56
|
-
private readonly conversations = new Map<string, WebUiConversation>();
|
|
57
|
-
private loaded = false;
|
|
58
|
-
private writing = Promise.resolve();
|
|
59
|
-
|
|
60
|
-
constructor(workingDir: string) {
|
|
61
|
-
const projectName = basename(workingDir).replace(/[^a-zA-Z0-9_-]+/g, "-") || "project";
|
|
62
|
-
const projectHash = createHash("sha256")
|
|
63
|
-
.update(workingDir)
|
|
64
|
-
.digest("hex")
|
|
65
|
-
.slice(0, 12);
|
|
66
|
-
this.filePath = resolve(
|
|
67
|
-
getStateDirectory(),
|
|
68
|
-
`${projectName}-${projectHash}-web-ui-state.json`,
|
|
69
|
-
);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
private async ensureLoaded(): Promise<void> {
|
|
73
|
-
if (this.loaded) {
|
|
74
|
-
return;
|
|
75
|
-
}
|
|
76
|
-
this.loaded = true;
|
|
77
|
-
try {
|
|
78
|
-
const content = await readFile(this.filePath, "utf8");
|
|
79
|
-
const parsed = JSON.parse(content) as ConversationStoreFile;
|
|
80
|
-
for (const conversation of parsed.conversations ?? []) {
|
|
81
|
-
this.conversations.set(conversation.conversationId, conversation);
|
|
82
|
-
}
|
|
83
|
-
} catch {
|
|
84
|
-
// File does not exist yet or contains invalid JSON.
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
private async persist(): Promise<void> {
|
|
89
|
-
const payload: ConversationStoreFile = {
|
|
90
|
-
conversations: Array.from(this.conversations.values()),
|
|
91
|
-
};
|
|
92
|
-
this.writing = this.writing.then(async () => {
|
|
93
|
-
await mkdir(dirname(this.filePath), { recursive: true });
|
|
94
|
-
await writeFile(this.filePath, JSON.stringify(payload, null, 2), "utf8");
|
|
95
|
-
});
|
|
96
|
-
await this.writing;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
async list(ownerId = DEFAULT_OWNER): Promise<WebUiConversation[]> {
|
|
100
|
-
await this.ensureLoaded();
|
|
101
|
-
return Array.from(this.conversations.values())
|
|
102
|
-
.filter((conversation) => conversation.ownerId === ownerId)
|
|
103
|
-
.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
async get(conversationId: string): Promise<WebUiConversation | undefined> {
|
|
107
|
-
await this.ensureLoaded();
|
|
108
|
-
return this.conversations.get(conversationId);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
async create(ownerId = DEFAULT_OWNER, title?: string): Promise<WebUiConversation> {
|
|
112
|
-
await this.ensureLoaded();
|
|
113
|
-
const now = Date.now();
|
|
114
|
-
const conversation: WebUiConversation = {
|
|
115
|
-
conversationId: randomUUID(),
|
|
116
|
-
title: title && title.trim().length > 0 ? title.trim() : "New conversation",
|
|
117
|
-
messages: [],
|
|
118
|
-
ownerId,
|
|
119
|
-
tenantId: null,
|
|
120
|
-
createdAt: now,
|
|
121
|
-
updatedAt: now,
|
|
122
|
-
};
|
|
123
|
-
this.conversations.set(conversation.conversationId, conversation);
|
|
124
|
-
await this.persist();
|
|
125
|
-
return conversation;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
async update(conversation: WebUiConversation): Promise<void> {
|
|
129
|
-
await this.ensureLoaded();
|
|
130
|
-
this.conversations.set(conversation.conversationId, {
|
|
131
|
-
...conversation,
|
|
132
|
-
updatedAt: Date.now(),
|
|
133
|
-
});
|
|
134
|
-
await this.persist();
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
async rename(conversationId: string, title: string): Promise<WebUiConversation | undefined> {
|
|
138
|
-
await this.ensureLoaded();
|
|
139
|
-
const existing = this.conversations.get(conversationId);
|
|
140
|
-
if (!existing) {
|
|
141
|
-
return undefined;
|
|
142
|
-
}
|
|
143
|
-
const updated = {
|
|
144
|
-
...existing,
|
|
145
|
-
title: title.trim().length > 0 ? title.trim() : existing.title,
|
|
146
|
-
updatedAt: Date.now(),
|
|
147
|
-
};
|
|
148
|
-
this.conversations.set(conversationId, updated);
|
|
149
|
-
await this.persist();
|
|
150
|
-
return updated;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
async delete(conversationId: string): Promise<boolean> {
|
|
154
|
-
await this.ensureLoaded();
|
|
155
|
-
const removed = this.conversations.delete(conversationId);
|
|
156
|
-
if (removed) {
|
|
157
|
-
await this.persist();
|
|
158
|
-
}
|
|
159
|
-
return removed;
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
type SessionRecord = {
|
|
164
|
-
sessionId: string;
|
|
165
|
-
ownerId: string;
|
|
166
|
-
csrfToken: string;
|
|
167
|
-
createdAt: number;
|
|
168
|
-
expiresAt: number;
|
|
169
|
-
lastSeenAt: number;
|
|
170
|
-
};
|
|
171
|
-
|
|
172
|
-
export class SessionStore {
|
|
173
|
-
private readonly sessions = new Map<string, SessionRecord>();
|
|
174
|
-
private readonly ttlMs: number;
|
|
175
|
-
|
|
176
|
-
constructor(ttlMs = 1000 * 60 * 60 * 8) {
|
|
177
|
-
this.ttlMs = ttlMs;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
create(ownerId = DEFAULT_OWNER): SessionRecord {
|
|
181
|
-
const now = Date.now();
|
|
182
|
-
const session: SessionRecord = {
|
|
183
|
-
sessionId: randomUUID(),
|
|
184
|
-
ownerId,
|
|
185
|
-
csrfToken: randomUUID(),
|
|
186
|
-
createdAt: now,
|
|
187
|
-
expiresAt: now + this.ttlMs,
|
|
188
|
-
lastSeenAt: now,
|
|
189
|
-
};
|
|
190
|
-
this.sessions.set(session.sessionId, session);
|
|
191
|
-
return session;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
get(sessionId: string): SessionRecord | undefined {
|
|
195
|
-
const session = this.sessions.get(sessionId);
|
|
196
|
-
if (!session) {
|
|
197
|
-
return undefined;
|
|
198
|
-
}
|
|
199
|
-
if (Date.now() > session.expiresAt) {
|
|
200
|
-
this.sessions.delete(sessionId);
|
|
201
|
-
return undefined;
|
|
202
|
-
}
|
|
203
|
-
session.lastSeenAt = Date.now();
|
|
204
|
-
return session;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
delete(sessionId: string): void {
|
|
208
|
-
this.sessions.delete(sessionId);
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
type LoginAttemptState = {
|
|
213
|
-
count: number;
|
|
214
|
-
firstFailureAt: number;
|
|
215
|
-
lockedUntil?: number;
|
|
216
|
-
};
|
|
217
|
-
|
|
218
|
-
export class LoginRateLimiter {
|
|
219
|
-
private readonly attempts = new Map<string, LoginAttemptState>();
|
|
220
|
-
|
|
221
|
-
constructor(
|
|
222
|
-
private readonly maxAttempts = 5,
|
|
223
|
-
private readonly windowMs = 1000 * 60 * 5,
|
|
224
|
-
private readonly lockoutMs = 1000 * 60 * 10,
|
|
225
|
-
) {}
|
|
226
|
-
|
|
227
|
-
canAttempt(key: string): { allowed: boolean; retryAfterSeconds?: number } {
|
|
228
|
-
const current = this.attempts.get(key);
|
|
229
|
-
if (!current) {
|
|
230
|
-
return { allowed: true };
|
|
231
|
-
}
|
|
232
|
-
if (current.lockedUntil && Date.now() < current.lockedUntil) {
|
|
233
|
-
return {
|
|
234
|
-
allowed: false,
|
|
235
|
-
retryAfterSeconds: Math.ceil((current.lockedUntil - Date.now()) / 1000),
|
|
236
|
-
};
|
|
237
|
-
}
|
|
238
|
-
return { allowed: true };
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
registerFailure(key: string): { locked: boolean; retryAfterSeconds?: number } {
|
|
242
|
-
const now = Date.now();
|
|
243
|
-
const current = this.attempts.get(key);
|
|
244
|
-
if (!current || now - current.firstFailureAt > this.windowMs) {
|
|
245
|
-
this.attempts.set(key, { count: 1, firstFailureAt: now });
|
|
246
|
-
return { locked: false };
|
|
247
|
-
}
|
|
248
|
-
const count = current.count + 1;
|
|
249
|
-
const next: LoginAttemptState = {
|
|
250
|
-
...current,
|
|
251
|
-
count,
|
|
252
|
-
};
|
|
253
|
-
if (count >= this.maxAttempts) {
|
|
254
|
-
next.lockedUntil = now + this.lockoutMs;
|
|
255
|
-
this.attempts.set(key, next);
|
|
256
|
-
return { locked: true, retryAfterSeconds: Math.ceil(this.lockoutMs / 1000) };
|
|
257
|
-
}
|
|
258
|
-
this.attempts.set(key, next);
|
|
259
|
-
return { locked: false };
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
registerSuccess(key: string): void {
|
|
263
|
-
this.attempts.delete(key);
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
export const parseCookies = (request: IncomingMessage): Record<string, string> => {
|
|
268
|
-
const cookieHeader = request.headers.cookie ?? "";
|
|
269
|
-
const pairs = cookieHeader
|
|
270
|
-
.split(";")
|
|
271
|
-
.map((part) => part.trim())
|
|
272
|
-
.filter(Boolean);
|
|
273
|
-
const cookies: Record<string, string> = {};
|
|
274
|
-
for (const pair of pairs) {
|
|
275
|
-
const index = pair.indexOf("=");
|
|
276
|
-
if (index <= 0) {
|
|
277
|
-
continue;
|
|
278
|
-
}
|
|
279
|
-
const key = pair.slice(0, index);
|
|
280
|
-
const value = pair.slice(index + 1);
|
|
281
|
-
try {
|
|
282
|
-
cookies[key] = decodeURIComponent(value);
|
|
283
|
-
} catch {
|
|
284
|
-
// Ignore malformed cookie encoding instead of throwing.
|
|
285
|
-
cookies[key] = value;
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
return cookies;
|
|
289
|
-
};
|
|
290
|
-
|
|
291
|
-
export const setCookie = (
|
|
292
|
-
response: ServerResponse,
|
|
293
|
-
name: string,
|
|
294
|
-
value: string,
|
|
295
|
-
options: {
|
|
296
|
-
httpOnly?: boolean;
|
|
297
|
-
secure?: boolean;
|
|
298
|
-
sameSite?: "Lax" | "Strict" | "None";
|
|
299
|
-
path?: string;
|
|
300
|
-
maxAge?: number;
|
|
301
|
-
},
|
|
302
|
-
): void => {
|
|
303
|
-
const segments = [`${name}=${encodeURIComponent(value)}`];
|
|
304
|
-
segments.push(`Path=${options.path ?? "/"}`);
|
|
305
|
-
if (typeof options.maxAge === "number") {
|
|
306
|
-
segments.push(`Max-Age=${Math.max(0, Math.floor(options.maxAge))}`);
|
|
307
|
-
}
|
|
308
|
-
if (options.httpOnly) {
|
|
309
|
-
segments.push("HttpOnly");
|
|
310
|
-
}
|
|
311
|
-
if (options.secure) {
|
|
312
|
-
segments.push("Secure");
|
|
313
|
-
}
|
|
314
|
-
if (options.sameSite) {
|
|
315
|
-
segments.push(`SameSite=${options.sameSite}`);
|
|
316
|
-
}
|
|
317
|
-
const previous = response.getHeader("Set-Cookie");
|
|
318
|
-
const serialized = segments.join("; ");
|
|
319
|
-
if (!previous) {
|
|
320
|
-
response.setHeader("Set-Cookie", serialized);
|
|
321
|
-
return;
|
|
322
|
-
}
|
|
323
|
-
if (Array.isArray(previous)) {
|
|
324
|
-
response.setHeader("Set-Cookie", [...previous, serialized]);
|
|
325
|
-
return;
|
|
326
|
-
}
|
|
327
|
-
response.setHeader("Set-Cookie", [String(previous), serialized]);
|
|
328
|
-
};
|
|
329
|
-
|
|
330
|
-
export const verifyPassphrase = (provided: string, expected: string): boolean => {
|
|
331
|
-
const providedBuffer = Buffer.from(provided);
|
|
332
|
-
const expectedBuffer = Buffer.from(expected);
|
|
333
|
-
if (providedBuffer.length !== expectedBuffer.length) {
|
|
334
|
-
const zero = Buffer.alloc(expectedBuffer.length);
|
|
335
|
-
return timingSafeEqual(expectedBuffer, zero) && false;
|
|
336
|
-
}
|
|
337
|
-
return timingSafeEqual(providedBuffer, expectedBuffer);
|
|
338
|
-
};
|
|
339
|
-
|
|
340
|
-
export const getRequestIp = (request: IncomingMessage): string => {
|
|
341
|
-
// Trust direct socket peer by default to avoid spoofable forwarded headers.
|
|
342
|
-
// Reverse-proxy deployments can map trusted client IPs before this layer.
|
|
343
|
-
return request.socket.remoteAddress ?? "unknown";
|
|
344
|
-
};
|
|
345
|
-
|
|
346
|
-
export const inferConversationTitle = (text: string): string => {
|
|
347
|
-
const normalized = text.trim().replace(/\s+/g, " ");
|
|
348
|
-
if (!normalized) {
|
|
349
|
-
return "New conversation";
|
|
350
|
-
}
|
|
351
|
-
return normalized.length <= 48 ? normalized : `${normalized.slice(0, 48)}...`;
|
|
352
|
-
};
|
|
353
|
-
|
|
354
26
|
// ---------------------------------------------------------------------------
|
|
355
27
|
// PWA assets
|
|
356
28
|
// ---------------------------------------------------------------------------
|
|
@@ -405,7 +77,6 @@ self.addEventListener("activate", (event) => {
|
|
|
405
77
|
|
|
406
78
|
self.addEventListener("fetch", (event) => {
|
|
407
79
|
const url = new URL(event.request.url);
|
|
408
|
-
// Only cache GET requests for the app shell; let API calls pass through
|
|
409
80
|
if (event.request.method !== "GET" || url.pathname.startsWith("/api/")) {
|
|
410
81
|
return;
|
|
411
82
|
}
|
|
@@ -440,1194 +111,7 @@ export const renderWebUiHtml = (options?: { agentName?: string }): string => {
|
|
|
440
111
|
<title>${agentName}</title>
|
|
441
112
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Inconsolata:400,700">
|
|
442
113
|
<style>
|
|
443
|
-
|
|
444
|
-
color-scheme: light dark;
|
|
445
|
-
|
|
446
|
-
--bg: #000;
|
|
447
|
-
--bg-alt: #0a0a0a;
|
|
448
|
-
--bg-elevated: #111;
|
|
449
|
-
|
|
450
|
-
--fg: #ededed;
|
|
451
|
-
--fg-strong: #fff;
|
|
452
|
-
--fg-2: #888;
|
|
453
|
-
--fg-3: #999;
|
|
454
|
-
--fg-4: #777;
|
|
455
|
-
--fg-5: #666;
|
|
456
|
-
--fg-6: #555;
|
|
457
|
-
--fg-7: #444;
|
|
458
|
-
--fg-8: #333;
|
|
459
|
-
|
|
460
|
-
--fg-tool: #8a8a8a;
|
|
461
|
-
--fg-tool-code: #bcbcbc;
|
|
462
|
-
--fg-tool-item: #d6d6d6;
|
|
463
|
-
--fg-approval-label: #b0b0b0;
|
|
464
|
-
--fg-approval-input: #cfcfcf;
|
|
465
|
-
--fg-approval-btn: #f0f0f0;
|
|
466
|
-
|
|
467
|
-
--accent: #ededed;
|
|
468
|
-
--accent-fg: #000;
|
|
469
|
-
--accent-hover: #fff;
|
|
470
|
-
|
|
471
|
-
--stop-bg: #4a4a4a;
|
|
472
|
-
--stop-fg: #fff;
|
|
473
|
-
--stop-hover: #565656;
|
|
474
|
-
|
|
475
|
-
--border-1: rgba(255,255,255,0.06);
|
|
476
|
-
--border-2: rgba(255,255,255,0.08);
|
|
477
|
-
--border-3: rgba(255,255,255,0.1);
|
|
478
|
-
--border-4: rgba(255,255,255,0.12);
|
|
479
|
-
--border-5: rgba(255,255,255,0.18);
|
|
480
|
-
--border-focus: rgba(255,255,255,0.2);
|
|
481
|
-
--border-hover: rgba(255,255,255,0.25);
|
|
482
|
-
--border-drag: rgba(255,255,255,0.4);
|
|
483
|
-
|
|
484
|
-
--surface-1: rgba(255,255,255,0.02);
|
|
485
|
-
--surface-2: rgba(255,255,255,0.03);
|
|
486
|
-
--surface-3: rgba(255,255,255,0.04);
|
|
487
|
-
--surface-4: rgba(255,255,255,0.06);
|
|
488
|
-
--surface-5: rgba(255,255,255,0.08);
|
|
489
|
-
--surface-6: rgba(255,255,255,0.1);
|
|
490
|
-
--surface-7: rgba(255,255,255,0.12);
|
|
491
|
-
--surface-8: rgba(255,255,255,0.14);
|
|
492
|
-
|
|
493
|
-
--chip-bg: rgba(0,0,0,0.6);
|
|
494
|
-
--chip-bg-hover: rgba(0,0,0,0.75);
|
|
495
|
-
--backdrop: rgba(0,0,0,0.6);
|
|
496
|
-
--lightbox-bg: rgba(0,0,0,0.85);
|
|
497
|
-
--inset-1: rgba(0,0,0,0.16);
|
|
498
|
-
--inset-2: rgba(0,0,0,0.25);
|
|
499
|
-
|
|
500
|
-
--file-badge-bg: rgba(0,0,0,0.2);
|
|
501
|
-
--file-badge-fg: rgba(255,255,255,0.8);
|
|
502
|
-
|
|
503
|
-
--error: #ff4444;
|
|
504
|
-
--error-soft: #ff6b6b;
|
|
505
|
-
--error-alt: #ff6666;
|
|
506
|
-
--error-bg: rgba(255,68,68,0.08);
|
|
507
|
-
--error-border: rgba(255,68,68,0.25);
|
|
508
|
-
|
|
509
|
-
--tool-done: #6a9955;
|
|
510
|
-
--tool-error: #f48771;
|
|
511
|
-
|
|
512
|
-
--warning: #e8a735;
|
|
513
|
-
|
|
514
|
-
--approve: #78e7a6;
|
|
515
|
-
--approve-border: rgba(58,208,122,0.45);
|
|
516
|
-
--deny: #f59b9b;
|
|
517
|
-
--deny-border: rgba(224,95,95,0.45);
|
|
518
|
-
|
|
519
|
-
--scrollbar: rgba(255,255,255,0.1);
|
|
520
|
-
--scrollbar-hover: rgba(255,255,255,0.16);
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
@media (prefers-color-scheme: light) {
|
|
524
|
-
:root {
|
|
525
|
-
--bg: #ffffff;
|
|
526
|
-
--bg-alt: #f5f5f5;
|
|
527
|
-
--bg-elevated: #e8e8e8;
|
|
528
|
-
|
|
529
|
-
--fg: #1a1a1a;
|
|
530
|
-
--fg-strong: #000;
|
|
531
|
-
--fg-2: #666;
|
|
532
|
-
--fg-3: #555;
|
|
533
|
-
--fg-4: #777;
|
|
534
|
-
--fg-5: #888;
|
|
535
|
-
--fg-6: #888;
|
|
536
|
-
--fg-7: #aaa;
|
|
537
|
-
--fg-8: #bbb;
|
|
538
|
-
|
|
539
|
-
--fg-tool: #666;
|
|
540
|
-
--fg-tool-code: #444;
|
|
541
|
-
--fg-tool-item: #333;
|
|
542
|
-
--fg-approval-label: #666;
|
|
543
|
-
--fg-approval-input: #444;
|
|
544
|
-
--fg-approval-btn: #1a1a1a;
|
|
545
|
-
|
|
546
|
-
--accent: #1a1a1a;
|
|
547
|
-
--accent-fg: #fff;
|
|
548
|
-
--accent-hover: #000;
|
|
549
|
-
|
|
550
|
-
--stop-bg: #d4d4d4;
|
|
551
|
-
--stop-fg: #333;
|
|
552
|
-
--stop-hover: #c4c4c4;
|
|
553
|
-
|
|
554
|
-
--border-1: rgba(0,0,0,0.06);
|
|
555
|
-
--border-2: rgba(0,0,0,0.08);
|
|
556
|
-
--border-3: rgba(0,0,0,0.1);
|
|
557
|
-
--border-4: rgba(0,0,0,0.1);
|
|
558
|
-
--border-5: rgba(0,0,0,0.15);
|
|
559
|
-
--border-focus: rgba(0,0,0,0.2);
|
|
560
|
-
--border-hover: rgba(0,0,0,0.2);
|
|
561
|
-
--border-drag: rgba(0,0,0,0.3);
|
|
562
|
-
|
|
563
|
-
--surface-1: rgba(0,0,0,0.02);
|
|
564
|
-
--surface-2: rgba(0,0,0,0.03);
|
|
565
|
-
--surface-3: rgba(0,0,0,0.03);
|
|
566
|
-
--surface-4: rgba(0,0,0,0.04);
|
|
567
|
-
--surface-5: rgba(0,0,0,0.05);
|
|
568
|
-
--surface-6: rgba(0,0,0,0.07);
|
|
569
|
-
--surface-7: rgba(0,0,0,0.08);
|
|
570
|
-
--surface-8: rgba(0,0,0,0.1);
|
|
571
|
-
|
|
572
|
-
--chip-bg: rgba(255,255,255,0.8);
|
|
573
|
-
--chip-bg-hover: rgba(255,255,255,0.9);
|
|
574
|
-
--backdrop: rgba(0,0,0,0.3);
|
|
575
|
-
--lightbox-bg: rgba(0,0,0,0.75);
|
|
576
|
-
--inset-1: rgba(0,0,0,0.04);
|
|
577
|
-
--inset-2: rgba(0,0,0,0.06);
|
|
578
|
-
|
|
579
|
-
--file-badge-bg: rgba(0,0,0,0.05);
|
|
580
|
-
--file-badge-fg: rgba(0,0,0,0.7);
|
|
581
|
-
|
|
582
|
-
--error: #dc2626;
|
|
583
|
-
--error-soft: #ef4444;
|
|
584
|
-
--error-alt: #ef4444;
|
|
585
|
-
--error-bg: rgba(220,38,38,0.06);
|
|
586
|
-
--error-border: rgba(220,38,38,0.2);
|
|
587
|
-
|
|
588
|
-
--tool-done: #16a34a;
|
|
589
|
-
--tool-error: #dc2626;
|
|
590
|
-
|
|
591
|
-
--warning: #ca8a04;
|
|
592
|
-
|
|
593
|
-
--approve: #16a34a;
|
|
594
|
-
--approve-border: rgba(22,163,74,0.35);
|
|
595
|
-
--deny: #dc2626;
|
|
596
|
-
--deny-border: rgba(220,38,38,0.3);
|
|
597
|
-
|
|
598
|
-
--scrollbar: rgba(0,0,0,0.12);
|
|
599
|
-
--scrollbar-hover: rgba(0,0,0,0.2);
|
|
600
|
-
}
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
604
|
-
html, body { height: 100vh; overflow: hidden; overscroll-behavior: none; touch-action: pan-y; }
|
|
605
|
-
body {
|
|
606
|
-
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Helvetica Neue", sans-serif;
|
|
607
|
-
background: var(--bg);
|
|
608
|
-
color: var(--fg);
|
|
609
|
-
font-size: 14px;
|
|
610
|
-
line-height: 1.5;
|
|
611
|
-
-webkit-font-smoothing: antialiased;
|
|
612
|
-
-moz-osx-font-smoothing: grayscale;
|
|
613
|
-
}
|
|
614
|
-
button, input, textarea { font: inherit; color: inherit; }
|
|
615
|
-
.hidden { display: none !important; }
|
|
616
|
-
a { color: var(--fg); }
|
|
617
|
-
|
|
618
|
-
/* Auth */
|
|
619
|
-
.auth {
|
|
620
|
-
min-height: 100vh;
|
|
621
|
-
display: grid;
|
|
622
|
-
place-items: center;
|
|
623
|
-
padding: 20px;
|
|
624
|
-
background: var(--bg);
|
|
625
|
-
}
|
|
626
|
-
.auth-card {
|
|
627
|
-
width: min(400px, 90vw);
|
|
628
|
-
}
|
|
629
|
-
.auth-shell {
|
|
630
|
-
background: var(--bg-alt);
|
|
631
|
-
border: 1px solid var(--border-3);
|
|
632
|
-
border-radius: 9999px;
|
|
633
|
-
display: flex;
|
|
634
|
-
align-items: center;
|
|
635
|
-
padding: 4px 6px 4px 18px;
|
|
636
|
-
transition: border-color 0.15s;
|
|
637
|
-
}
|
|
638
|
-
.auth-shell:focus-within { border-color: var(--border-focus); }
|
|
639
|
-
.auth-input {
|
|
640
|
-
flex: 1;
|
|
641
|
-
background: transparent;
|
|
642
|
-
border: 0;
|
|
643
|
-
outline: none;
|
|
644
|
-
color: var(--fg);
|
|
645
|
-
padding: 10px 0 8px;
|
|
646
|
-
font-size: 14px;
|
|
647
|
-
margin-top: -2px;
|
|
648
|
-
}
|
|
649
|
-
.auth-input::placeholder { color: var(--fg-7); }
|
|
650
|
-
.auth-submit {
|
|
651
|
-
width: 32px;
|
|
652
|
-
height: 32px;
|
|
653
|
-
background: var(--accent);
|
|
654
|
-
border: 0;
|
|
655
|
-
border-radius: 50%;
|
|
656
|
-
color: var(--accent-fg);
|
|
657
|
-
cursor: pointer;
|
|
658
|
-
display: grid;
|
|
659
|
-
place-items: center;
|
|
660
|
-
flex-shrink: 0;
|
|
661
|
-
margin-bottom: 2px;
|
|
662
|
-
transition: background 0.15s;
|
|
663
|
-
}
|
|
664
|
-
.auth-submit:hover { background: var(--accent-hover); }
|
|
665
|
-
.error { color: var(--error); font-size: 13px; min-height: 16px; }
|
|
666
|
-
.message-error {
|
|
667
|
-
background: var(--error-bg);
|
|
668
|
-
border: 1px solid var(--error-border);
|
|
669
|
-
border-radius: 10px;
|
|
670
|
-
color: var(--error-soft);
|
|
671
|
-
padding: 12px 16px;
|
|
672
|
-
font-size: 13px;
|
|
673
|
-
line-height: 1.5;
|
|
674
|
-
max-width: 600px;
|
|
675
|
-
}
|
|
676
|
-
.message-error strong { color: var(--error); }
|
|
677
|
-
|
|
678
|
-
/* Layout - use fixed positioning with explicit dimensions */
|
|
679
|
-
.shell {
|
|
680
|
-
position: fixed;
|
|
681
|
-
top: 0;
|
|
682
|
-
left: 0;
|
|
683
|
-
width: 100vw;
|
|
684
|
-
height: 100vh;
|
|
685
|
-
height: 100dvh; /* Dynamic viewport height for normal browsers */
|
|
686
|
-
display: flex;
|
|
687
|
-
overflow: hidden;
|
|
688
|
-
}
|
|
689
|
-
/* PWA standalone mode: use 100vh which works correctly */
|
|
690
|
-
@media (display-mode: standalone) {
|
|
691
|
-
.shell {
|
|
692
|
-
height: 100vh;
|
|
693
|
-
}
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
/* Edge swipe blocker - invisible touch target to intercept right edge gestures */
|
|
697
|
-
.edge-blocker-right {
|
|
698
|
-
position: fixed;
|
|
699
|
-
top: 0;
|
|
700
|
-
bottom: 0;
|
|
701
|
-
right: 0;
|
|
702
|
-
width: 20px;
|
|
703
|
-
z-index: 9999;
|
|
704
|
-
touch-action: none;
|
|
705
|
-
}
|
|
706
|
-
.sidebar {
|
|
707
|
-
width: 260px;
|
|
708
|
-
background: var(--bg);
|
|
709
|
-
border-right: 1px solid var(--border-1);
|
|
710
|
-
display: flex;
|
|
711
|
-
flex-direction: column;
|
|
712
|
-
padding: 12px 8px;
|
|
713
|
-
}
|
|
714
|
-
.new-chat-btn {
|
|
715
|
-
background: transparent;
|
|
716
|
-
border: 0;
|
|
717
|
-
color: var(--fg-2);
|
|
718
|
-
border-radius: 12px;
|
|
719
|
-
height: 36px;
|
|
720
|
-
padding: 0 10px;
|
|
721
|
-
display: flex;
|
|
722
|
-
align-items: center;
|
|
723
|
-
gap: 8px;
|
|
724
|
-
font-size: 13px;
|
|
725
|
-
cursor: pointer;
|
|
726
|
-
transition: background 0.15s, color 0.15s;
|
|
727
|
-
}
|
|
728
|
-
.new-chat-btn:hover { color: var(--fg); }
|
|
729
|
-
.new-chat-btn svg { width: 16px; height: 16px; }
|
|
730
|
-
.conversation-list {
|
|
731
|
-
flex: 1;
|
|
732
|
-
overflow-y: auto;
|
|
733
|
-
margin-top: 12px;
|
|
734
|
-
display: flex;
|
|
735
|
-
flex-direction: column;
|
|
736
|
-
gap: 2px;
|
|
737
|
-
}
|
|
738
|
-
.sidebar-section-label {
|
|
739
|
-
font-size: 11px;
|
|
740
|
-
font-weight: 600;
|
|
741
|
-
color: var(--fg-7);
|
|
742
|
-
text-transform: uppercase;
|
|
743
|
-
letter-spacing: 0.04em;
|
|
744
|
-
padding: 10px 10px 4px;
|
|
745
|
-
}
|
|
746
|
-
.sidebar-section-label:first-child { padding-top: 4px; }
|
|
747
|
-
.sidebar-section-divider {
|
|
748
|
-
height: 1px;
|
|
749
|
-
background: var(--border);
|
|
750
|
-
margin: 6px 10px;
|
|
751
|
-
}
|
|
752
|
-
.conversation-item {
|
|
753
|
-
height: 36px;
|
|
754
|
-
min-height: 36px;
|
|
755
|
-
max-height: 36px;
|
|
756
|
-
flex-shrink: 0;
|
|
757
|
-
padding: 0 16px 0 10px;
|
|
758
|
-
border-radius: 12px;
|
|
759
|
-
cursor: pointer;
|
|
760
|
-
font-size: 13px;
|
|
761
|
-
line-height: 36px;
|
|
762
|
-
color: var(--fg-6);
|
|
763
|
-
white-space: nowrap;
|
|
764
|
-
overflow: hidden;
|
|
765
|
-
text-overflow: ellipsis;
|
|
766
|
-
position: relative;
|
|
767
|
-
transition: color 0.15s;
|
|
768
|
-
}
|
|
769
|
-
.conversation-item .approval-dot {
|
|
770
|
-
display: inline-block;
|
|
771
|
-
width: 6px;
|
|
772
|
-
height: 6px;
|
|
773
|
-
border-radius: 50%;
|
|
774
|
-
background: var(--warning, #e8a735);
|
|
775
|
-
margin-right: 6px;
|
|
776
|
-
flex-shrink: 0;
|
|
777
|
-
vertical-align: middle;
|
|
778
|
-
}
|
|
779
|
-
.conversation-item:hover { color: var(--fg-3); }
|
|
780
|
-
.conversation-item.active {
|
|
781
|
-
color: var(--fg);
|
|
782
|
-
}
|
|
783
|
-
.conversation-item .delete-btn {
|
|
784
|
-
position: absolute;
|
|
785
|
-
right: 0;
|
|
786
|
-
top: 0;
|
|
787
|
-
bottom: 0;
|
|
788
|
-
opacity: 0;
|
|
789
|
-
background: var(--bg);
|
|
790
|
-
border: 0;
|
|
791
|
-
color: var(--fg-7);
|
|
792
|
-
padding: 0 8px;
|
|
793
|
-
border-radius: 0 4px 4px 0;
|
|
794
|
-
cursor: pointer;
|
|
795
|
-
font-size: 16px;
|
|
796
|
-
line-height: 1;
|
|
797
|
-
display: grid;
|
|
798
|
-
place-items: center;
|
|
799
|
-
transition: opacity 0.15s, color 0.15s;
|
|
800
|
-
}
|
|
801
|
-
.conversation-item:hover .delete-btn { opacity: 1; }
|
|
802
|
-
.conversation-item.active .delete-btn { background: var(--bg); }
|
|
803
|
-
.conversation-item .delete-btn::before {
|
|
804
|
-
content: "";
|
|
805
|
-
position: absolute;
|
|
806
|
-
right: 100%;
|
|
807
|
-
top: 0;
|
|
808
|
-
bottom: 0;
|
|
809
|
-
width: 24px;
|
|
810
|
-
background: linear-gradient(to right, transparent, var(--bg));
|
|
811
|
-
pointer-events: none;
|
|
812
|
-
}
|
|
813
|
-
.conversation-item.active .delete-btn::before {
|
|
814
|
-
background: linear-gradient(to right, transparent, var(--bg));
|
|
815
|
-
}
|
|
816
|
-
.conversation-item .delete-btn:hover { color: var(--fg-2); }
|
|
817
|
-
.conversation-item .delete-btn.confirming {
|
|
818
|
-
opacity: 1;
|
|
819
|
-
width: auto;
|
|
820
|
-
padding: 0 8px;
|
|
821
|
-
font-size: 11px;
|
|
822
|
-
color: var(--error);
|
|
823
|
-
border-radius: 3px;
|
|
824
|
-
}
|
|
825
|
-
.conversation-item .delete-btn.confirming:hover {
|
|
826
|
-
color: var(--error-alt);
|
|
827
|
-
}
|
|
828
|
-
.sidebar-footer {
|
|
829
|
-
margin-top: auto;
|
|
830
|
-
padding-top: 8px;
|
|
831
|
-
}
|
|
832
|
-
.logout-btn {
|
|
833
|
-
background: transparent;
|
|
834
|
-
border: 0;
|
|
835
|
-
color: var(--fg-6);
|
|
836
|
-
width: 100%;
|
|
837
|
-
padding: 8px 10px;
|
|
838
|
-
text-align: left;
|
|
839
|
-
border-radius: 6px;
|
|
840
|
-
cursor: pointer;
|
|
841
|
-
font-size: 13px;
|
|
842
|
-
transition: color 0.15s, background 0.15s;
|
|
843
|
-
}
|
|
844
|
-
.logout-btn:hover { color: var(--fg-2); }
|
|
845
|
-
|
|
846
|
-
/* Main */
|
|
847
|
-
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; max-width: 100%; background: var(--bg); overflow: hidden; }
|
|
848
|
-
.topbar {
|
|
849
|
-
height: calc(52px + env(safe-area-inset-top, 0px));
|
|
850
|
-
padding-top: env(safe-area-inset-top, 0px);
|
|
851
|
-
display: flex;
|
|
852
|
-
align-items: center;
|
|
853
|
-
justify-content: center;
|
|
854
|
-
font-size: 13px;
|
|
855
|
-
font-weight: 500;
|
|
856
|
-
color: var(--fg-2);
|
|
857
|
-
border-bottom: 1px solid var(--border-1);
|
|
858
|
-
position: relative;
|
|
859
|
-
flex-shrink: 0;
|
|
860
|
-
}
|
|
861
|
-
.topbar-title {
|
|
862
|
-
max-width: calc(100% - 100px);
|
|
863
|
-
overflow: hidden;
|
|
864
|
-
text-overflow: ellipsis;
|
|
865
|
-
white-space: nowrap;
|
|
866
|
-
letter-spacing: -0.01em;
|
|
867
|
-
padding: 0 50px;
|
|
868
|
-
}
|
|
869
|
-
.sidebar-toggle {
|
|
870
|
-
display: none;
|
|
871
|
-
position: absolute;
|
|
872
|
-
left: 12px;
|
|
873
|
-
bottom: 4px; /* Position from bottom of topbar content area */
|
|
874
|
-
background: transparent;
|
|
875
|
-
border: 0;
|
|
876
|
-
color: var(--fg-5);
|
|
877
|
-
width: 44px;
|
|
878
|
-
height: 44px;
|
|
879
|
-
border-radius: 6px;
|
|
880
|
-
cursor: pointer;
|
|
881
|
-
transition: color 0.15s, background 0.15s;
|
|
882
|
-
font-size: 18px;
|
|
883
|
-
z-index: 10;
|
|
884
|
-
-webkit-tap-highlight-color: transparent;
|
|
885
|
-
}
|
|
886
|
-
.sidebar-toggle:hover { color: var(--fg); }
|
|
887
|
-
.topbar-new-chat {
|
|
888
|
-
display: none;
|
|
889
|
-
position: absolute;
|
|
890
|
-
right: 12px;
|
|
891
|
-
bottom: 4px;
|
|
892
|
-
background: transparent;
|
|
893
|
-
border: 0;
|
|
894
|
-
color: var(--fg-5);
|
|
895
|
-
width: 44px;
|
|
896
|
-
height: 44px;
|
|
897
|
-
border-radius: 6px;
|
|
898
|
-
cursor: pointer;
|
|
899
|
-
transition: color 0.15s, background 0.15s;
|
|
900
|
-
z-index: 10;
|
|
901
|
-
-webkit-tap-highlight-color: transparent;
|
|
902
|
-
}
|
|
903
|
-
.topbar-new-chat:hover { color: var(--fg); }
|
|
904
|
-
.topbar-new-chat svg { width: 16px; height: 16px; }
|
|
905
|
-
|
|
906
|
-
/* Messages */
|
|
907
|
-
.messages { flex: 1; overflow-y: auto; overflow-x: hidden; padding: 24px 24px; }
|
|
908
|
-
.messages-column { max-width: 680px; margin: 0 auto; }
|
|
909
|
-
.message-row { margin-bottom: 24px; display: flex; max-width: 100%; }
|
|
910
|
-
.message-row.user { justify-content: flex-end; }
|
|
911
|
-
.assistant-wrap { display: flex; gap: 12px; max-width: 100%; min-width: 0; }
|
|
912
|
-
.assistant-avatar {
|
|
913
|
-
width: 24px;
|
|
914
|
-
height: 24px;
|
|
915
|
-
background: var(--accent);
|
|
916
|
-
color: var(--accent-fg);
|
|
917
|
-
border-radius: 6px;
|
|
918
|
-
display: grid;
|
|
919
|
-
place-items: center;
|
|
920
|
-
font-size: 11px;
|
|
921
|
-
font-weight: 600;
|
|
922
|
-
flex-shrink: 0;
|
|
923
|
-
margin-top: 2px;
|
|
924
|
-
}
|
|
925
|
-
.assistant-content {
|
|
926
|
-
line-height: 1.65;
|
|
927
|
-
color: var(--fg);
|
|
928
|
-
font-size: 14px;
|
|
929
|
-
min-width: 0;
|
|
930
|
-
max-width: 100%;
|
|
931
|
-
overflow-wrap: break-word;
|
|
932
|
-
word-break: break-word;
|
|
933
|
-
margin-top: 2px;
|
|
934
|
-
}
|
|
935
|
-
.assistant-content p { margin: 0 0 12px; }
|
|
936
|
-
.assistant-content p:last-child { margin-bottom: 0; }
|
|
937
|
-
.assistant-content ul, .assistant-content ol { margin: 8px 0; padding-left: 20px; }
|
|
938
|
-
.assistant-content li { margin: 4px 0; }
|
|
939
|
-
.assistant-content strong { font-weight: 600; color: var(--fg-strong); }
|
|
940
|
-
.assistant-content h2 {
|
|
941
|
-
font-size: 16px;
|
|
942
|
-
font-weight: 600;
|
|
943
|
-
letter-spacing: -0.02em;
|
|
944
|
-
margin: 20px 0 8px;
|
|
945
|
-
color: var(--fg-strong);
|
|
946
|
-
}
|
|
947
|
-
.assistant-content h3 {
|
|
948
|
-
font-size: 14px;
|
|
949
|
-
font-weight: 600;
|
|
950
|
-
letter-spacing: -0.01em;
|
|
951
|
-
margin: 16px 0 6px;
|
|
952
|
-
color: var(--fg-strong);
|
|
953
|
-
}
|
|
954
|
-
.assistant-content code {
|
|
955
|
-
background: var(--surface-4);
|
|
956
|
-
border: 1px solid var(--border-1);
|
|
957
|
-
padding: 2px 5px;
|
|
958
|
-
border-radius: 4px;
|
|
959
|
-
font-family: ui-monospace, "SF Mono", "Fira Code", monospace;
|
|
960
|
-
font-size: 0.88em;
|
|
961
|
-
}
|
|
962
|
-
.assistant-content pre {
|
|
963
|
-
background: var(--bg-alt);
|
|
964
|
-
border: 1px solid var(--border-1);
|
|
965
|
-
padding: 14px 16px;
|
|
966
|
-
border-radius: 8px;
|
|
967
|
-
overflow-x: auto;
|
|
968
|
-
margin: 14px 0;
|
|
969
|
-
}
|
|
970
|
-
.assistant-content pre code {
|
|
971
|
-
background: none;
|
|
972
|
-
border: 0;
|
|
973
|
-
padding: 0;
|
|
974
|
-
font-size: 13px;
|
|
975
|
-
line-height: 1.5;
|
|
976
|
-
}
|
|
977
|
-
.tool-activity-inline {
|
|
978
|
-
margin: 8px 0;
|
|
979
|
-
font-size: 12px;
|
|
980
|
-
line-height: 1.45;
|
|
981
|
-
color: var(--fg-tool);
|
|
982
|
-
}
|
|
983
|
-
.tool-activity-inline code {
|
|
984
|
-
font-family: ui-monospace, "SF Mono", "Fira Code", monospace;
|
|
985
|
-
background: var(--surface-3);
|
|
986
|
-
border: 1px solid var(--border-2);
|
|
987
|
-
padding: 4px 8px;
|
|
988
|
-
border-radius: 6px;
|
|
989
|
-
color: var(--fg-tool-code);
|
|
990
|
-
font-size: 11px;
|
|
991
|
-
}
|
|
992
|
-
.tool-status {
|
|
993
|
-
color: var(--fg-tool);
|
|
994
|
-
font-style: italic;
|
|
995
|
-
}
|
|
996
|
-
.tool-done {
|
|
997
|
-
color: var(--tool-done);
|
|
998
|
-
}
|
|
999
|
-
.tool-error {
|
|
1000
|
-
color: var(--tool-error);
|
|
1001
|
-
}
|
|
1002
|
-
.assistant-content table:not(.approval-request-table) {
|
|
1003
|
-
border-collapse: collapse;
|
|
1004
|
-
width: 100%;
|
|
1005
|
-
margin: 14px 0;
|
|
1006
|
-
font-size: 13px;
|
|
1007
|
-
border: 1px solid var(--border-2);
|
|
1008
|
-
border-radius: 8px;
|
|
1009
|
-
overflow: hidden;
|
|
1010
|
-
display: block;
|
|
1011
|
-
max-width: 100%;
|
|
1012
|
-
overflow-x: auto;
|
|
1013
|
-
white-space: nowrap;
|
|
1014
|
-
}
|
|
1015
|
-
.assistant-content table:not(.approval-request-table) th {
|
|
1016
|
-
background: var(--surface-4);
|
|
1017
|
-
padding: 10px 12px;
|
|
1018
|
-
text-align: left;
|
|
1019
|
-
font-weight: 600;
|
|
1020
|
-
border-bottom: 1px solid var(--border-4);
|
|
1021
|
-
color: var(--fg-strong);
|
|
1022
|
-
min-width: 100px;
|
|
1023
|
-
}
|
|
1024
|
-
.assistant-content table:not(.approval-request-table) td {
|
|
1025
|
-
padding: 10px 12px;
|
|
1026
|
-
border-bottom: 1px solid var(--border-1);
|
|
1027
|
-
width: 100%;
|
|
1028
|
-
min-width: 100px;
|
|
1029
|
-
}
|
|
1030
|
-
.assistant-content table:not(.approval-request-table) tr:last-child td {
|
|
1031
|
-
border-bottom: none;
|
|
1032
|
-
}
|
|
1033
|
-
.assistant-content table:not(.approval-request-table) tbody tr:hover {
|
|
1034
|
-
background: var(--surface-1);
|
|
1035
|
-
}
|
|
1036
|
-
.assistant-content hr {
|
|
1037
|
-
border: 0;
|
|
1038
|
-
border-top: 1px solid var(--border-3);
|
|
1039
|
-
margin: 20px 0;
|
|
1040
|
-
}
|
|
1041
|
-
.tool-activity {
|
|
1042
|
-
margin-top: 12px;
|
|
1043
|
-
margin-bottom: 12px;
|
|
1044
|
-
border: 1px solid var(--border-2);
|
|
1045
|
-
background: var(--surface-2);
|
|
1046
|
-
border-radius: 10px;
|
|
1047
|
-
font-size: 12px;
|
|
1048
|
-
line-height: 1.45;
|
|
1049
|
-
color: var(--fg-tool-code);
|
|
1050
|
-
width: 300px;
|
|
1051
|
-
transition: width 0.2s ease;
|
|
1052
|
-
}
|
|
1053
|
-
.tool-activity.has-approvals {
|
|
1054
|
-
width: 100%;
|
|
1055
|
-
}
|
|
1056
|
-
.assistant-content > .tool-activity:first-child {
|
|
1057
|
-
margin-top: 0;
|
|
1058
|
-
}
|
|
1059
|
-
.tool-activity-disclosure {
|
|
1060
|
-
display: block;
|
|
1061
|
-
}
|
|
1062
|
-
.tool-activity-summary {
|
|
1063
|
-
list-style: none;
|
|
1064
|
-
display: flex;
|
|
1065
|
-
align-items: center;
|
|
1066
|
-
gap: 8px;
|
|
1067
|
-
cursor: pointer;
|
|
1068
|
-
padding: 10px 12px;
|
|
1069
|
-
user-select: none;
|
|
1070
|
-
}
|
|
1071
|
-
.tool-activity-summary::-webkit-details-marker {
|
|
1072
|
-
display: none;
|
|
1073
|
-
}
|
|
1074
|
-
.tool-activity-label {
|
|
1075
|
-
font-size: 11px;
|
|
1076
|
-
text-transform: uppercase;
|
|
1077
|
-
letter-spacing: 0.06em;
|
|
1078
|
-
color: var(--fg-tool);
|
|
1079
|
-
font-weight: 600;
|
|
1080
|
-
}
|
|
1081
|
-
.tool-activity-caret {
|
|
1082
|
-
margin-left: auto;
|
|
1083
|
-
color: var(--fg-tool);
|
|
1084
|
-
display: inline-flex;
|
|
1085
|
-
align-items: center;
|
|
1086
|
-
justify-content: center;
|
|
1087
|
-
transition: transform 120ms ease;
|
|
1088
|
-
transform: rotate(0deg);
|
|
1089
|
-
}
|
|
1090
|
-
.tool-activity-caret svg {
|
|
1091
|
-
width: 14px;
|
|
1092
|
-
height: 14px;
|
|
1093
|
-
display: block;
|
|
1094
|
-
}
|
|
1095
|
-
.tool-activity-disclosure[open] .tool-activity-caret {
|
|
1096
|
-
transform: rotate(90deg);
|
|
1097
|
-
}
|
|
1098
|
-
.tool-activity-list {
|
|
1099
|
-
display: grid;
|
|
1100
|
-
gap: 6px;
|
|
1101
|
-
padding: 0 12px 10px;
|
|
1102
|
-
}
|
|
1103
|
-
.tool-activity-item {
|
|
1104
|
-
font-family: ui-monospace, "SF Mono", "Fira Code", monospace;
|
|
1105
|
-
background: var(--surface-3);
|
|
1106
|
-
border-radius: 6px;
|
|
1107
|
-
padding: 4px 7px;
|
|
1108
|
-
color: var(--fg-tool-item);
|
|
1109
|
-
}
|
|
1110
|
-
.approval-requests {
|
|
1111
|
-
border-top: 1px solid var(--border-2);
|
|
1112
|
-
padding: 10px 12px 12px;
|
|
1113
|
-
display: grid;
|
|
1114
|
-
gap: 10px;
|
|
1115
|
-
}
|
|
1116
|
-
.approval-requests-label {
|
|
1117
|
-
font-size: 12px;
|
|
1118
|
-
text-transform: uppercase;
|
|
1119
|
-
letter-spacing: 0.06em;
|
|
1120
|
-
color: var(--fg-approval-label);
|
|
1121
|
-
font-weight: 600;
|
|
1122
|
-
}
|
|
1123
|
-
.approval-requests-label code {
|
|
1124
|
-
font-family: ui-monospace, "SF Mono", "Fira Code", monospace;
|
|
1125
|
-
text-transform: none;
|
|
1126
|
-
letter-spacing: 0;
|
|
1127
|
-
color: var(--fg-strong);
|
|
1128
|
-
}
|
|
1129
|
-
.approval-request-item {
|
|
1130
|
-
display: grid;
|
|
1131
|
-
gap: 8px;
|
|
1132
|
-
}
|
|
1133
|
-
.approval-request-table {
|
|
1134
|
-
width: 100%;
|
|
1135
|
-
border-collapse: collapse;
|
|
1136
|
-
border: none;
|
|
1137
|
-
font-size: 14px;
|
|
1138
|
-
line-height: 1.5;
|
|
1139
|
-
}
|
|
1140
|
-
.approval-request-table tr,
|
|
1141
|
-
.approval-request-table td {
|
|
1142
|
-
border: none;
|
|
1143
|
-
background: none;
|
|
1144
|
-
}
|
|
1145
|
-
.approval-request-table td {
|
|
1146
|
-
padding: 4px 0;
|
|
1147
|
-
vertical-align: top;
|
|
1148
|
-
}
|
|
1149
|
-
.approval-request-table .ak {
|
|
1150
|
-
font-weight: 600;
|
|
1151
|
-
color: var(--fg-approval-label);
|
|
1152
|
-
white-space: nowrap;
|
|
1153
|
-
width: 1%;
|
|
1154
|
-
padding-right: 20px;
|
|
1155
|
-
}
|
|
1156
|
-
.approval-request-table .av,
|
|
1157
|
-
.approval-request-table .av-complex {
|
|
1158
|
-
color: var(--fg);
|
|
1159
|
-
overflow-wrap: anywhere;
|
|
1160
|
-
white-space: pre-wrap;
|
|
1161
|
-
max-height: 200px;
|
|
1162
|
-
overflow-y: auto;
|
|
1163
|
-
display: block;
|
|
1164
|
-
}
|
|
1165
|
-
.approval-request-table .av-complex {
|
|
1166
|
-
font-family: ui-monospace, "SF Mono", "Fira Code", monospace;
|
|
1167
|
-
font-size: 12px;
|
|
1168
|
-
}
|
|
1169
|
-
.approval-request-actions {
|
|
1170
|
-
display: flex;
|
|
1171
|
-
gap: 6px;
|
|
1172
|
-
}
|
|
1173
|
-
.approval-action-btn {
|
|
1174
|
-
border-radius: 6px;
|
|
1175
|
-
border: 1px solid var(--border-5);
|
|
1176
|
-
background: var(--surface-4);
|
|
1177
|
-
color: var(--fg-approval-btn);
|
|
1178
|
-
font-size: 11px;
|
|
1179
|
-
font-weight: 600;
|
|
1180
|
-
padding: 4px 8px;
|
|
1181
|
-
cursor: pointer;
|
|
1182
|
-
}
|
|
1183
|
-
.approval-action-btn:hover {
|
|
1184
|
-
background: var(--surface-7);
|
|
1185
|
-
}
|
|
1186
|
-
.approval-action-btn.approve {
|
|
1187
|
-
border-color: var(--approve-border);
|
|
1188
|
-
color: var(--approve);
|
|
1189
|
-
}
|
|
1190
|
-
.approval-action-btn.deny {
|
|
1191
|
-
border-color: var(--deny-border);
|
|
1192
|
-
color: var(--deny);
|
|
1193
|
-
}
|
|
1194
|
-
.approval-action-btn[disabled] {
|
|
1195
|
-
opacity: 0.55;
|
|
1196
|
-
cursor: not-allowed;
|
|
1197
|
-
}
|
|
1198
|
-
.user-bubble {
|
|
1199
|
-
background: var(--bg-elevated);
|
|
1200
|
-
border: 1px solid var(--border-2);
|
|
1201
|
-
padding: 10px 16px;
|
|
1202
|
-
border-radius: 18px;
|
|
1203
|
-
max-width: 70%;
|
|
1204
|
-
font-size: 14px;
|
|
1205
|
-
line-height: 1.5;
|
|
1206
|
-
overflow-wrap: break-word;
|
|
1207
|
-
word-break: break-word;
|
|
1208
|
-
white-space: pre-wrap;
|
|
1209
|
-
}
|
|
1210
|
-
.empty-state {
|
|
1211
|
-
display: flex;
|
|
1212
|
-
flex-direction: column;
|
|
1213
|
-
align-items: center;
|
|
1214
|
-
justify-content: center;
|
|
1215
|
-
height: 100%;
|
|
1216
|
-
gap: 16px;
|
|
1217
|
-
color: var(--fg-6);
|
|
1218
|
-
}
|
|
1219
|
-
.empty-state .assistant-avatar {
|
|
1220
|
-
width: 36px;
|
|
1221
|
-
height: 36px;
|
|
1222
|
-
font-size: 14px;
|
|
1223
|
-
border-radius: 8px;
|
|
1224
|
-
}
|
|
1225
|
-
.empty-state-text {
|
|
1226
|
-
font-size: 14px;
|
|
1227
|
-
color: var(--fg-6);
|
|
1228
|
-
}
|
|
1229
|
-
.thinking-indicator {
|
|
1230
|
-
display: inline-block;
|
|
1231
|
-
font-family: Inconsolata, monospace;
|
|
1232
|
-
font-size: 20px;
|
|
1233
|
-
line-height: 1;
|
|
1234
|
-
vertical-align: middle;
|
|
1235
|
-
color: var(--fg);
|
|
1236
|
-
opacity: 0.5;
|
|
1237
|
-
}
|
|
1238
|
-
.thinking-status {
|
|
1239
|
-
display: inline-flex;
|
|
1240
|
-
align-items: center;
|
|
1241
|
-
gap: 8px;
|
|
1242
|
-
margin-top: 2px;
|
|
1243
|
-
color: var(--fg-tool);
|
|
1244
|
-
font-size: 14px;
|
|
1245
|
-
line-height: 1.65;
|
|
1246
|
-
font-weight: 400;
|
|
1247
|
-
}
|
|
1248
|
-
.thinking-status-label {
|
|
1249
|
-
color: var(--fg-tool);
|
|
1250
|
-
font-size: 14px;
|
|
1251
|
-
line-height: 1.65;
|
|
1252
|
-
font-weight: 400;
|
|
1253
|
-
white-space: nowrap;
|
|
1254
|
-
}
|
|
1255
|
-
|
|
1256
|
-
/* Composer */
|
|
1257
|
-
.composer {
|
|
1258
|
-
padding: 12px 24px 24px;
|
|
1259
|
-
position: relative;
|
|
1260
|
-
}
|
|
1261
|
-
/* PWA standalone mode - extra bottom padding */
|
|
1262
|
-
@media (display-mode: standalone), (-webkit-touch-callout: none) {
|
|
1263
|
-
.composer {
|
|
1264
|
-
padding-bottom: 32px;
|
|
1265
|
-
}
|
|
1266
|
-
}
|
|
1267
|
-
@supports (-webkit-touch-callout: none) {
|
|
1268
|
-
/* iOS Safari standalone check via JS class */
|
|
1269
|
-
.standalone .composer {
|
|
1270
|
-
padding-bottom: 32px;
|
|
1271
|
-
}
|
|
1272
|
-
}
|
|
1273
|
-
.composer::before {
|
|
1274
|
-
content: "";
|
|
1275
|
-
position: absolute;
|
|
1276
|
-
left: 0;
|
|
1277
|
-
right: 0;
|
|
1278
|
-
bottom: 100%;
|
|
1279
|
-
height: 48px;
|
|
1280
|
-
background: linear-gradient(to top, var(--bg) 0%, transparent 100%);
|
|
1281
|
-
pointer-events: none;
|
|
1282
|
-
}
|
|
1283
|
-
.composer-inner { max-width: 680px; margin: 0 auto; }
|
|
1284
|
-
.composer-shell {
|
|
1285
|
-
background: var(--bg-alt);
|
|
1286
|
-
border: 1px solid var(--border-3);
|
|
1287
|
-
border-radius: 24px;
|
|
1288
|
-
display: flex;
|
|
1289
|
-
align-items: end;
|
|
1290
|
-
padding: 4px 6px 4px 6px;
|
|
1291
|
-
transition: border-color 0.15s;
|
|
1292
|
-
}
|
|
1293
|
-
.composer-shell:focus-within { border-color: var(--border-focus); }
|
|
1294
|
-
.composer-input {
|
|
1295
|
-
flex: 1;
|
|
1296
|
-
background: transparent;
|
|
1297
|
-
border: 0;
|
|
1298
|
-
outline: none;
|
|
1299
|
-
color: var(--fg);
|
|
1300
|
-
min-height: 40px;
|
|
1301
|
-
max-height: 200px;
|
|
1302
|
-
resize: none;
|
|
1303
|
-
padding: 11px 0 8px;
|
|
1304
|
-
font-size: 14px;
|
|
1305
|
-
line-height: 1.5;
|
|
1306
|
-
margin-top: -4px;
|
|
1307
|
-
}
|
|
1308
|
-
.composer-input::placeholder { color: var(--fg-7); }
|
|
1309
|
-
.send-btn {
|
|
1310
|
-
width: 32px;
|
|
1311
|
-
height: 32px;
|
|
1312
|
-
background: var(--accent);
|
|
1313
|
-
border: 0;
|
|
1314
|
-
border-radius: 50%;
|
|
1315
|
-
color: var(--accent-fg);
|
|
1316
|
-
cursor: pointer;
|
|
1317
|
-
display: grid;
|
|
1318
|
-
place-items: center;
|
|
1319
|
-
flex-shrink: 0;
|
|
1320
|
-
margin-bottom: 2px;
|
|
1321
|
-
transition: background 0.15s, opacity 0.15s;
|
|
1322
|
-
}
|
|
1323
|
-
.send-btn:hover { background: var(--accent-hover); }
|
|
1324
|
-
.send-btn.stop-mode {
|
|
1325
|
-
background: var(--stop-bg);
|
|
1326
|
-
color: var(--stop-fg);
|
|
1327
|
-
}
|
|
1328
|
-
.send-btn.stop-mode:hover { background: var(--stop-hover); }
|
|
1329
|
-
.send-btn:disabled { opacity: 0.2; cursor: default; }
|
|
1330
|
-
.send-btn:disabled:hover { background: var(--accent); }
|
|
1331
|
-
.send-btn-wrapper {
|
|
1332
|
-
position: relative;
|
|
1333
|
-
width: 36px;
|
|
1334
|
-
height: 36px;
|
|
1335
|
-
display: grid;
|
|
1336
|
-
place-items: center;
|
|
1337
|
-
flex-shrink: 0;
|
|
1338
|
-
margin-bottom: 0;
|
|
1339
|
-
}
|
|
1340
|
-
.send-btn-wrapper .send-btn {
|
|
1341
|
-
margin-bottom: 0;
|
|
1342
|
-
}
|
|
1343
|
-
.context-ring {
|
|
1344
|
-
position: absolute;
|
|
1345
|
-
inset: 0;
|
|
1346
|
-
width: 36px;
|
|
1347
|
-
height: 36px;
|
|
1348
|
-
pointer-events: none;
|
|
1349
|
-
transform: rotate(-90deg);
|
|
1350
|
-
}
|
|
1351
|
-
.context-ring-fill {
|
|
1352
|
-
fill: none;
|
|
1353
|
-
stroke: var(--bg-alt);
|
|
1354
|
-
stroke-width: 3;
|
|
1355
|
-
stroke-linecap: butt;
|
|
1356
|
-
transition: stroke-dashoffset 0.4s ease, stroke 0.3s ease;
|
|
1357
|
-
}
|
|
1358
|
-
.send-btn-wrapper.stop-mode .context-ring-fill {
|
|
1359
|
-
stroke: var(--fg-3);
|
|
1360
|
-
}
|
|
1361
|
-
.context-ring-fill.warning {
|
|
1362
|
-
stroke: #e5a33d;
|
|
1363
|
-
}
|
|
1364
|
-
.context-ring-fill.critical {
|
|
1365
|
-
stroke: #e55d4a;
|
|
1366
|
-
}
|
|
1367
|
-
.context-tooltip {
|
|
1368
|
-
position: absolute;
|
|
1369
|
-
bottom: calc(100% + 8px);
|
|
1370
|
-
right: 0;
|
|
1371
|
-
background: var(--bg-elevated);
|
|
1372
|
-
border: 1px solid var(--border-3);
|
|
1373
|
-
border-radius: 8px;
|
|
1374
|
-
padding: 6px 10px;
|
|
1375
|
-
font-size: 12px;
|
|
1376
|
-
color: var(--fg-2);
|
|
1377
|
-
white-space: nowrap;
|
|
1378
|
-
pointer-events: none;
|
|
1379
|
-
opacity: 0;
|
|
1380
|
-
transform: translateY(4px);
|
|
1381
|
-
transition: opacity 0.15s, transform 0.15s;
|
|
1382
|
-
z-index: 10;
|
|
1383
|
-
}
|
|
1384
|
-
.send-btn-wrapper:hover .context-tooltip,
|
|
1385
|
-
.send-btn-wrapper:focus-within .context-tooltip {
|
|
1386
|
-
opacity: 1;
|
|
1387
|
-
transform: translateY(0);
|
|
1388
|
-
}
|
|
1389
|
-
.attach-btn {
|
|
1390
|
-
width: 32px;
|
|
1391
|
-
height: 32px;
|
|
1392
|
-
background: var(--surface-5);
|
|
1393
|
-
border: 0;
|
|
1394
|
-
border-radius: 50%;
|
|
1395
|
-
color: var(--fg-3);
|
|
1396
|
-
cursor: pointer;
|
|
1397
|
-
display: grid;
|
|
1398
|
-
place-items: center;
|
|
1399
|
-
flex-shrink: 0;
|
|
1400
|
-
margin-bottom: 2px;
|
|
1401
|
-
margin-right: 8px;
|
|
1402
|
-
transition: color 0.15s, background 0.15s;
|
|
1403
|
-
}
|
|
1404
|
-
.attach-btn:hover { color: var(--fg); background: var(--surface-8); }
|
|
1405
|
-
.attachment-preview {
|
|
1406
|
-
display: flex;
|
|
1407
|
-
gap: 8px;
|
|
1408
|
-
padding: 8px 0;
|
|
1409
|
-
flex-wrap: wrap;
|
|
1410
|
-
}
|
|
1411
|
-
.attachment-chip {
|
|
1412
|
-
display: inline-flex;
|
|
1413
|
-
align-items: center;
|
|
1414
|
-
gap: 6px;
|
|
1415
|
-
background: var(--chip-bg);
|
|
1416
|
-
border: 1px solid var(--border-4);
|
|
1417
|
-
border-radius: 9999px;
|
|
1418
|
-
padding: 4px 10px 4px 6px;
|
|
1419
|
-
font-size: 11px;
|
|
1420
|
-
color: var(--fg-4);
|
|
1421
|
-
max-width: 200px;
|
|
1422
|
-
cursor: pointer;
|
|
1423
|
-
backdrop-filter: blur(6px);
|
|
1424
|
-
-webkit-backdrop-filter: blur(6px);
|
|
1425
|
-
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
|
1426
|
-
}
|
|
1427
|
-
.attachment-chip:hover {
|
|
1428
|
-
color: var(--fg);
|
|
1429
|
-
border-color: var(--border-hover);
|
|
1430
|
-
background: var(--chip-bg-hover);
|
|
1431
|
-
}
|
|
1432
|
-
.attachment-chip img {
|
|
1433
|
-
width: 20px;
|
|
1434
|
-
height: 20px;
|
|
1435
|
-
object-fit: cover;
|
|
1436
|
-
border-radius: 50%;
|
|
1437
|
-
flex-shrink: 0;
|
|
1438
|
-
cursor: pointer;
|
|
1439
|
-
}
|
|
1440
|
-
.attachment-chip .file-icon {
|
|
1441
|
-
width: 20px;
|
|
1442
|
-
height: 20px;
|
|
1443
|
-
border-radius: 50%;
|
|
1444
|
-
background: var(--surface-6);
|
|
1445
|
-
display: grid;
|
|
1446
|
-
place-items: center;
|
|
1447
|
-
font-size: 11px;
|
|
1448
|
-
flex-shrink: 0;
|
|
1449
|
-
}
|
|
1450
|
-
.attachment-chip .remove-attachment {
|
|
1451
|
-
cursor: pointer;
|
|
1452
|
-
color: var(--fg-6);
|
|
1453
|
-
font-size: 14px;
|
|
1454
|
-
margin-left: 2px;
|
|
1455
|
-
line-height: 1;
|
|
1456
|
-
transition: color 0.15s;
|
|
1457
|
-
}
|
|
1458
|
-
.attachment-chip .remove-attachment:hover { color: var(--fg-strong); }
|
|
1459
|
-
.attachment-chip .filename { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100px; }
|
|
1460
|
-
.user-bubble .user-file-attachments {
|
|
1461
|
-
display: flex;
|
|
1462
|
-
gap: 6px;
|
|
1463
|
-
flex-wrap: wrap;
|
|
1464
|
-
margin-top: 8px;
|
|
1465
|
-
}
|
|
1466
|
-
.user-file-attachments img {
|
|
1467
|
-
max-width: 200px;
|
|
1468
|
-
max-height: 160px;
|
|
1469
|
-
border-radius: 8px;
|
|
1470
|
-
object-fit: cover;
|
|
1471
|
-
cursor: pointer;
|
|
1472
|
-
transition: opacity 0.15s;
|
|
1473
|
-
}
|
|
1474
|
-
.user-file-attachments img:hover { opacity: 0.85; }
|
|
1475
|
-
.lightbox {
|
|
1476
|
-
position: fixed;
|
|
1477
|
-
inset: 0;
|
|
1478
|
-
z-index: 9999;
|
|
1479
|
-
display: flex;
|
|
1480
|
-
align-items: center;
|
|
1481
|
-
justify-content: center;
|
|
1482
|
-
background: rgba(0,0,0,0);
|
|
1483
|
-
backdrop-filter: blur(0px);
|
|
1484
|
-
cursor: zoom-out;
|
|
1485
|
-
transition: background 0.25s ease, backdrop-filter 0.25s ease;
|
|
1486
|
-
}
|
|
1487
|
-
.lightbox.active {
|
|
1488
|
-
background: var(--lightbox-bg);
|
|
1489
|
-
backdrop-filter: blur(8px);
|
|
1490
|
-
}
|
|
1491
|
-
.lightbox img {
|
|
1492
|
-
max-width: 90vw;
|
|
1493
|
-
max-height: 90vh;
|
|
1494
|
-
border-radius: 8px;
|
|
1495
|
-
object-fit: contain;
|
|
1496
|
-
transform: scale(0.4);
|
|
1497
|
-
opacity: 0;
|
|
1498
|
-
transition: transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94), opacity 0.25s ease;
|
|
1499
|
-
}
|
|
1500
|
-
.lightbox.active img {
|
|
1501
|
-
transform: scale(1);
|
|
1502
|
-
opacity: 1;
|
|
1503
|
-
}
|
|
1504
|
-
.user-file-badge {
|
|
1505
|
-
display: inline-flex;
|
|
1506
|
-
align-items: center;
|
|
1507
|
-
gap: 4px;
|
|
1508
|
-
background: var(--file-badge-bg);
|
|
1509
|
-
border-radius: 6px;
|
|
1510
|
-
padding: 4px 8px;
|
|
1511
|
-
font-size: 12px;
|
|
1512
|
-
color: var(--file-badge-fg);
|
|
1513
|
-
}
|
|
1514
|
-
.drag-overlay {
|
|
1515
|
-
position: fixed;
|
|
1516
|
-
inset: 0;
|
|
1517
|
-
background: var(--backdrop);
|
|
1518
|
-
z-index: 9999;
|
|
1519
|
-
display: none;
|
|
1520
|
-
align-items: center;
|
|
1521
|
-
justify-content: center;
|
|
1522
|
-
pointer-events: none;
|
|
1523
|
-
}
|
|
1524
|
-
.drag-overlay.active { display: flex; }
|
|
1525
|
-
.drag-overlay-inner {
|
|
1526
|
-
border: 2px dashed var(--border-drag);
|
|
1527
|
-
border-radius: 16px;
|
|
1528
|
-
padding: 40px 60px;
|
|
1529
|
-
color: var(--fg-strong);
|
|
1530
|
-
font-size: 16px;
|
|
1531
|
-
}
|
|
1532
|
-
.disclaimer {
|
|
1533
|
-
text-align: center;
|
|
1534
|
-
color: var(--fg-8);
|
|
1535
|
-
font-size: 12px;
|
|
1536
|
-
margin-top: 10px;
|
|
1537
|
-
}
|
|
1538
|
-
.poncho-badge {
|
|
1539
|
-
position: absolute;
|
|
1540
|
-
right: 12px;
|
|
1541
|
-
top: 50%;
|
|
1542
|
-
transform: translateY(-50%);
|
|
1543
|
-
margin-top: calc(env(safe-area-inset-top, 0px) / 2);
|
|
1544
|
-
z-index: 10;
|
|
1545
|
-
display: inline-flex;
|
|
1546
|
-
align-items: center;
|
|
1547
|
-
gap: 6px;
|
|
1548
|
-
font-size: 11px;
|
|
1549
|
-
color: var(--fg-4);
|
|
1550
|
-
text-decoration: none;
|
|
1551
|
-
background: var(--chip-bg);
|
|
1552
|
-
border: 1px solid var(--border-4);
|
|
1553
|
-
border-radius: 9999px;
|
|
1554
|
-
padding: 4px 10px 4px 6px;
|
|
1555
|
-
backdrop-filter: blur(6px);
|
|
1556
|
-
-webkit-backdrop-filter: blur(6px);
|
|
1557
|
-
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
|
1558
|
-
}
|
|
1559
|
-
.poncho-badge:hover {
|
|
1560
|
-
color: var(--fg);
|
|
1561
|
-
border-color: var(--border-hover);
|
|
1562
|
-
background: var(--chip-bg-hover);
|
|
1563
|
-
}
|
|
1564
|
-
.poncho-badge-avatar {
|
|
1565
|
-
width: 16px;
|
|
1566
|
-
height: 16px;
|
|
1567
|
-
border-radius: 50%;
|
|
1568
|
-
object-fit: cover;
|
|
1569
|
-
display: block;
|
|
1570
|
-
flex-shrink: 0;
|
|
1571
|
-
}
|
|
1572
|
-
|
|
1573
|
-
/* Scrollbar */
|
|
1574
|
-
::-webkit-scrollbar { width: 6px; }
|
|
1575
|
-
::-webkit-scrollbar-track { background: transparent; }
|
|
1576
|
-
::-webkit-scrollbar-thumb { background: var(--scrollbar); border-radius: 3px; }
|
|
1577
|
-
::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-hover); }
|
|
1578
|
-
|
|
1579
|
-
/* Mobile */
|
|
1580
|
-
@media (max-width: 768px) {
|
|
1581
|
-
.sidebar {
|
|
1582
|
-
position: fixed;
|
|
1583
|
-
inset: 0 auto 0 0;
|
|
1584
|
-
z-index: 100;
|
|
1585
|
-
transform: translateX(-100%);
|
|
1586
|
-
padding-top: calc(env(safe-area-inset-top, 0px) + 12px);
|
|
1587
|
-
will-change: transform;
|
|
1588
|
-
}
|
|
1589
|
-
.sidebar.dragging { transition: none; }
|
|
1590
|
-
.sidebar:not(.dragging) { transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1); }
|
|
1591
|
-
.shell.sidebar-open .sidebar { transform: translateX(0); }
|
|
1592
|
-
.sidebar-toggle { display: grid; place-items: center; }
|
|
1593
|
-
.topbar-new-chat { display: grid; place-items: center; }
|
|
1594
|
-
.poncho-badge {
|
|
1595
|
-
display: none;
|
|
1596
|
-
position: fixed;
|
|
1597
|
-
top: calc(12px + env(safe-area-inset-top, 0px));
|
|
1598
|
-
right: 12px;
|
|
1599
|
-
transform: none;
|
|
1600
|
-
margin-top: 0;
|
|
1601
|
-
z-index: 200;
|
|
1602
|
-
}
|
|
1603
|
-
.shell.sidebar-open .poncho-badge { display: inline-flex; }
|
|
1604
|
-
.sidebar-backdrop {
|
|
1605
|
-
position: fixed;
|
|
1606
|
-
inset: 0;
|
|
1607
|
-
background: var(--backdrop);
|
|
1608
|
-
z-index: 50;
|
|
1609
|
-
backdrop-filter: blur(2px);
|
|
1610
|
-
-webkit-backdrop-filter: blur(2px);
|
|
1611
|
-
opacity: 0;
|
|
1612
|
-
pointer-events: none;
|
|
1613
|
-
will-change: opacity;
|
|
1614
|
-
}
|
|
1615
|
-
.sidebar-backdrop:not(.dragging) { transition: opacity 0.25s cubic-bezier(0.4, 0, 0.2, 1); }
|
|
1616
|
-
.sidebar-backdrop.dragging { transition: none; }
|
|
1617
|
-
.shell.sidebar-open .sidebar-backdrop { opacity: 1; pointer-events: auto; }
|
|
1618
|
-
.messages { padding: 16px; }
|
|
1619
|
-
.composer { padding: 8px 16px 16px; }
|
|
1620
|
-
/* Always show delete button on mobile (no hover) */
|
|
1621
|
-
.conversation-item .delete-btn { opacity: 1; }
|
|
1622
|
-
}
|
|
1623
|
-
|
|
1624
|
-
/* Reduced motion */
|
|
1625
|
-
@media (prefers-reduced-motion: reduce) {
|
|
1626
|
-
*, *::before, *::after {
|
|
1627
|
-
animation-duration: 0.01ms !important;
|
|
1628
|
-
transition-duration: 0.01ms !important;
|
|
1629
|
-
}
|
|
1630
|
-
}
|
|
114
|
+
${WEB_UI_STYLES}
|
|
1631
115
|
</style>
|
|
1632
116
|
</head>
|
|
1633
117
|
<body data-agent-initial="${agentInitial}" data-agent-name="${agentName}">
|
|
@@ -1664,2290 +148,61 @@ export const renderWebUiHtml = (options?: { agentName?: string }): string => {
|
|
|
1664
148
|
</button>
|
|
1665
149
|
<a class="poncho-badge" href="https://github.com/cesr/poncho-ai" target="_blank" rel="noopener noreferrer"><img class="poncho-badge-avatar" src="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAASABIAAD/4QCARXhpZgAATU0AKgAAAAgABAEaAAUAAAABAAAAPgEbAAUAAAABAAAARgEoAAMAAAABAAIAAIdpAAQAAAABAAAATgAAAAAAAABIAAAAAQAAAEgAAAABAAOgAQADAAAAAQABAACgAgAEAAAAAQAAACCgAwAEAAAAAQAAACAAAAAA/+0AOFBob3Rvc2hvcCAzLjAAOEJJTQQEAAAAAAAAOEJJTQQlAAAAAAAQ1B2M2Y8AsgTpgAmY7PhCfv/AABEIACAAIAMBIgACEQEDEQH/xAAfAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgv/xAC1EAACAQMDAgQDBQUEBAAAAX0BAgMABBEFEiExQQYTUWEHInEUMoGRoQgjQrHBFVLR8CQzYnKCCQoWFxgZGiUmJygpKjQ1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4eLj5OXm5+jp6vHy8/T19vf4+fr/xAAfAQADAQEBAQEBAQEBAAAAAAAAAQIDBAUGBwgJCgv/xAC1EQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery8/T19vf4+fr/2wBDAAICAgICAgMCAgMFAwMDBQYFBQUFBggGBgYGBggKCAgICAgICgoKCgoKCgoMDAwMDAwODg4ODg8PDw8PDw8PDw//2wBDAQICAgQEBAcEBAcQCwkLEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/3QAEAAL/2gAMAwEAAhEDEQA/APbdF8VaTH8KJPDrSGO/gnjeeSZ8b0IYl2J4UKQvf0rxz4mftd+FPgsLPQV03UPF2tXtuLqIeZ9jsRG7MqsHILuCVI+6QccGum8SfCLVtF0qLxnYXUF34X0xUkNtJMizb+Yw00Zx5jbvuheMdBnNeA/tZp8QtL1z4fftAeBrDfcW2hyaTdGO1Fx/Z06zzsmY9rLG3lTjyyVwCpxyK/OMV4d4fN8Sq2bX5afuqK91NLW7a1s7u1mttW+nrYTiStl2WxoYC0pSXM3/AHn9nZ2skr6P0MvTf29/i1rt8IrLwn4XsLV1PymKeS5KKNxXz8Nzx1KYB5OOtbXizV9SuNIg8SyWVtbrqSxTwJZo5SdJkDo+x2fBCtyQ2A2RivjLwzrnxTn1mTxH8MPD2tReL755zc3NhanyAkzFnWGJIQId3Ab5toAIGFYiv2T+CtnJ8Pfgj4T0D4jwW2pT30QS8tnWOaOMwxs7JkZGRK6rwMfLxkYNfWUuCMBh+RYFezSd2lezXZ3b+/ddD5nEcQYivSqrHQUrqyb3Ur6tWS+WuvVdD//Q0PC/ijxR49s2scaeI0uTCy7pWkm2LxJt2hCBnJBwDjrzX0ZpvhKVnm0LQtat7u5hgjeV4U+xypuJHyS2xxwVyFkR+K+EPhv4utPCOuReIrGXfZzSSxSRbAdoJBLBeSwBGCoGdhyMkAV70v7RsHiXU9esfDUM48VQRRW2gw2EMbxTuWw013K0ZDxgnhWZQq4x8zZr+fOPIZnj83c8vqex9nT+NvlV4vVSe63tp8z9H4RwMMLlEFiKfOqkr8tr25krP7ld+p3L6B4lHjE+D7jxRdeIDCjXeoC6ma4j0+0RQQnloIo3mkJwvmKQAQdvevNfiV4l8XeBZhZalbWEizzIsc0U023ynbYrglGXbjBOM4wR1Br6e8B+HNK8AeHb7zZjqmuawGudUvpPmluZFDF+eyDJCr0x718GfGr4ma7PqFj8P9YtxLomjRmNAgUStPkESM5BYKFx8owD1POMcvhzx3j8VmU6Kk6sHFKUpaPS/vR7K+ijpo03Z3vzcYcLYd4NVPdg4N26J31s7vy3/Q//2Q==" alt="" aria-hidden="true">Built with Poncho</a>
|
|
1666
150
|
</div>
|
|
1667
|
-
<div
|
|
1668
|
-
<div class="
|
|
1669
|
-
<div class="
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
<form id="composer" class="composer">
|
|
1674
|
-
<div class="composer-inner">
|
|
1675
|
-
<div id="attachment-preview" class="attachment-preview" style="display:none"></div>
|
|
1676
|
-
<div class="composer-shell">
|
|
1677
|
-
<button id="attach-btn" class="attach-btn" type="button" title="Attach files">
|
|
1678
|
-
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M8 3v10M3 8h10" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
|
1679
|
-
</button>
|
|
1680
|
-
<input id="file-input" type="file" multiple accept="image/*,video/*,application/pdf,.txt,.csv,.json,.html" style="display:none" />
|
|
1681
|
-
<textarea id="prompt" class="composer-input" placeholder="Send a message..." rows="1"></textarea>
|
|
1682
|
-
<div class="send-btn-wrapper" id="send-btn-wrapper">
|
|
1683
|
-
<svg class="context-ring" viewBox="0 0 36 36">
|
|
1684
|
-
<circle class="context-ring-fill" id="context-ring-fill" cx="18" cy="18" r="14.5" />
|
|
1685
|
-
</svg>
|
|
1686
|
-
<button id="send" class="send-btn" type="submit">
|
|
1687
|
-
<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>
|
|
1688
|
-
</button>
|
|
1689
|
-
<div class="context-tooltip" id="context-tooltip"></div>
|
|
151
|
+
<div class="main-body">
|
|
152
|
+
<div class="main-chat">
|
|
153
|
+
<div id="messages" class="messages">
|
|
154
|
+
<div class="empty-state">
|
|
155
|
+
<div class="assistant-avatar">${agentInitial}</div>
|
|
156
|
+
<div class="empty-state-text">How can I help you today?</div>
|
|
1690
157
|
</div>
|
|
1691
158
|
</div>
|
|
159
|
+
<form id="composer" class="composer">
|
|
160
|
+
<div class="composer-inner">
|
|
161
|
+
<div id="attachment-preview" class="attachment-preview" style="display:none"></div>
|
|
162
|
+
<div class="composer-shell">
|
|
163
|
+
<button id="attach-btn" class="attach-btn" type="button" title="Attach files">
|
|
164
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M8 3v10M3 8h10" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
|
165
|
+
</button>
|
|
166
|
+
<input id="file-input" type="file" multiple accept="image/*,video/*,application/pdf,.txt,.csv,.json,.html" style="display:none" />
|
|
167
|
+
<textarea id="prompt" class="composer-input" placeholder="Send a message..." rows="1"></textarea>
|
|
168
|
+
<div class="send-btn-wrapper" id="send-btn-wrapper">
|
|
169
|
+
<svg class="context-ring" viewBox="0 0 36 36">
|
|
170
|
+
<circle class="context-ring-fill" id="context-ring-fill" cx="18" cy="18" r="14.5" />
|
|
171
|
+
</svg>
|
|
172
|
+
<button id="send" class="send-btn" type="submit">
|
|
173
|
+
<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>
|
|
174
|
+
</button>
|
|
175
|
+
<div class="context-tooltip" id="context-tooltip"></div>
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
</form>
|
|
1692
180
|
</div>
|
|
1693
|
-
|
|
181
|
+
<div id="browser-panel-resize" class="browser-panel-resize" style="display:none"></div>
|
|
182
|
+
<aside id="browser-panel" class="browser-panel" style="display:none">
|
|
183
|
+
<div class="browser-panel-header">
|
|
184
|
+
<button id="browser-nav-back" class="browser-nav-btn" title="Go back" disabled>
|
|
185
|
+
<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M10 3L5 8l5 5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
186
|
+
</button>
|
|
187
|
+
<button id="browser-nav-forward" class="browser-nav-btn" title="Go forward" disabled>
|
|
188
|
+
<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M6 3l5 5-5 5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
189
|
+
</button>
|
|
190
|
+
<span id="browser-panel-url" class="browser-panel-url"></span>
|
|
191
|
+
<button id="browser-panel-close" class="browser-panel-close" title="Hide panel">×</button>
|
|
192
|
+
</div>
|
|
193
|
+
<div class="browser-panel-viewport">
|
|
194
|
+
<img id="browser-panel-frame" alt="Browser viewport" />
|
|
195
|
+
<div id="browser-panel-placeholder" class="browser-panel-placeholder">No active browser session</div>
|
|
196
|
+
</div>
|
|
197
|
+
</aside>
|
|
198
|
+
</div>
|
|
1694
199
|
</main>
|
|
1695
200
|
</div>
|
|
1696
201
|
<div id="drag-overlay" class="drag-overlay"><div class="drag-overlay-inner">Drop files to attach</div></div>
|
|
1697
202
|
<div id="lightbox" class="lightbox" style="display:none"><img /></div>
|
|
1698
203
|
|
|
1699
204
|
<script>
|
|
1700
|
-
|
|
1701
|
-
${markedSource}
|
|
1702
|
-
|
|
1703
|
-
// Configure marked for GitHub Flavored Markdown (tables, etc.)
|
|
1704
|
-
marked.setOptions({
|
|
1705
|
-
gfm: true,
|
|
1706
|
-
breaks: true
|
|
1707
|
-
});
|
|
1708
|
-
|
|
1709
|
-
const state = {
|
|
1710
|
-
csrfToken: "",
|
|
1711
|
-
conversations: [],
|
|
1712
|
-
activeConversationId: null,
|
|
1713
|
-
activeMessages: [],
|
|
1714
|
-
isStreaming: false,
|
|
1715
|
-
activeStreamAbortController: null,
|
|
1716
|
-
activeStreamConversationId: null,
|
|
1717
|
-
activeStreamRunId: null,
|
|
1718
|
-
isMessagesPinnedToBottom: true,
|
|
1719
|
-
confirmDeleteId: null,
|
|
1720
|
-
approvalRequestsInFlight: {},
|
|
1721
|
-
pendingFiles: [],
|
|
1722
|
-
contextTokens: 0,
|
|
1723
|
-
contextWindow: 0,
|
|
1724
|
-
};
|
|
1725
|
-
|
|
1726
|
-
const agentInitial = document.body.dataset.agentInitial || "A";
|
|
1727
|
-
const $ = (id) => document.getElementById(id);
|
|
1728
|
-
const elements = {
|
|
1729
|
-
auth: $("auth"),
|
|
1730
|
-
app: $("app"),
|
|
1731
|
-
loginForm: $("login-form"),
|
|
1732
|
-
passphrase: $("passphrase"),
|
|
1733
|
-
loginError: $("login-error"),
|
|
1734
|
-
list: $("conversation-list"),
|
|
1735
|
-
newChat: $("new-chat"),
|
|
1736
|
-
topbarNewChat: $("topbar-new-chat"),
|
|
1737
|
-
messages: $("messages"),
|
|
1738
|
-
chatTitle: $("chat-title"),
|
|
1739
|
-
logout: $("logout"),
|
|
1740
|
-
composer: $("composer"),
|
|
1741
|
-
prompt: $("prompt"),
|
|
1742
|
-
send: $("send"),
|
|
1743
|
-
shell: $("app"),
|
|
1744
|
-
sidebarToggle: $("sidebar-toggle"),
|
|
1745
|
-
sidebarBackdrop: $("sidebar-backdrop"),
|
|
1746
|
-
attachBtn: $("attach-btn"),
|
|
1747
|
-
fileInput: $("file-input"),
|
|
1748
|
-
attachmentPreview: $("attachment-preview"),
|
|
1749
|
-
dragOverlay: $("drag-overlay"),
|
|
1750
|
-
lightbox: $("lightbox"),
|
|
1751
|
-
contextRingFill: $("context-ring-fill"),
|
|
1752
|
-
contextTooltip: $("context-tooltip"),
|
|
1753
|
-
sendBtnWrapper: $("send-btn-wrapper"),
|
|
1754
|
-
};
|
|
1755
|
-
const sendIconMarkup =
|
|
1756
|
-
'<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>';
|
|
1757
|
-
const stopIconMarkup =
|
|
1758
|
-
'<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>';
|
|
1759
|
-
|
|
1760
|
-
const CONTEXT_RING_CIRCUMFERENCE = 2 * Math.PI * 14.5;
|
|
1761
|
-
const formatTokenCount = (n) => {
|
|
1762
|
-
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1).replace(/\\.0$/, "") + "M";
|
|
1763
|
-
if (n >= 1_000) return (n / 1_000).toFixed(1).replace(/\\.0$/, "") + "k";
|
|
1764
|
-
return String(n);
|
|
1765
|
-
};
|
|
1766
|
-
const updateContextRing = () => {
|
|
1767
|
-
const ring = elements.contextRingFill;
|
|
1768
|
-
const tooltip = elements.contextTooltip;
|
|
1769
|
-
if (!ring || !tooltip) return;
|
|
1770
|
-
if (state.contextWindow <= 0) {
|
|
1771
|
-
ring.style.strokeDasharray = String(CONTEXT_RING_CIRCUMFERENCE);
|
|
1772
|
-
ring.style.strokeDashoffset = String(CONTEXT_RING_CIRCUMFERENCE);
|
|
1773
|
-
tooltip.textContent = "";
|
|
1774
|
-
return;
|
|
1775
|
-
}
|
|
1776
|
-
const ratio = Math.min(state.contextTokens / state.contextWindow, 1);
|
|
1777
|
-
const offset = CONTEXT_RING_CIRCUMFERENCE * (1 - ratio);
|
|
1778
|
-
ring.style.strokeDasharray = String(CONTEXT_RING_CIRCUMFERENCE);
|
|
1779
|
-
ring.style.strokeDashoffset = String(offset);
|
|
1780
|
-
ring.classList.toggle("warning", ratio >= 0.7 && ratio < 0.9);
|
|
1781
|
-
ring.classList.toggle("critical", ratio >= 0.9);
|
|
1782
|
-
const pct = (ratio * 100).toFixed(1).replace(/\\.0$/, "");
|
|
1783
|
-
tooltip.textContent = formatTokenCount(state.contextTokens) + " / " + formatTokenCount(state.contextWindow) + " tokens (" + pct + "%)";
|
|
1784
|
-
};
|
|
1785
|
-
|
|
1786
|
-
const pushConversationUrl = (conversationId) => {
|
|
1787
|
-
const target = conversationId ? "/c/" + encodeURIComponent(conversationId) : "/";
|
|
1788
|
-
if (window.location.pathname !== target) {
|
|
1789
|
-
history.pushState({ conversationId: conversationId || null }, "", target);
|
|
1790
|
-
}
|
|
1791
|
-
};
|
|
1792
|
-
|
|
1793
|
-
const replaceConversationUrl = (conversationId) => {
|
|
1794
|
-
const target = conversationId ? "/c/" + encodeURIComponent(conversationId) : "/";
|
|
1795
|
-
if (window.location.pathname !== target) {
|
|
1796
|
-
history.replaceState({ conversationId: conversationId || null }, "", target);
|
|
1797
|
-
}
|
|
1798
|
-
};
|
|
1799
|
-
|
|
1800
|
-
const getConversationIdFromUrl = () => {
|
|
1801
|
-
const match = window.location.pathname.match(/^\\/c\\/([^\\/]+)/);
|
|
1802
|
-
return match ? decodeURIComponent(match[1]) : null;
|
|
1803
|
-
};
|
|
1804
|
-
|
|
1805
|
-
const mutatingMethods = new Set(["POST", "PATCH", "PUT", "DELETE"]);
|
|
1806
|
-
|
|
1807
|
-
const api = async (path, options = {}) => {
|
|
1808
|
-
const method = (options.method || "GET").toUpperCase();
|
|
1809
|
-
const headers = { ...(options.headers || {}) };
|
|
1810
|
-
if (mutatingMethods.has(method) && state.csrfToken) {
|
|
1811
|
-
headers["x-csrf-token"] = state.csrfToken;
|
|
1812
|
-
}
|
|
1813
|
-
if (options.body && !headers["Content-Type"]) {
|
|
1814
|
-
headers["Content-Type"] = "application/json";
|
|
1815
|
-
}
|
|
1816
|
-
const response = await fetch(path, { credentials: "include", ...options, method, headers });
|
|
1817
|
-
if (!response.ok) {
|
|
1818
|
-
let payload = {};
|
|
1819
|
-
try { payload = await response.json(); } catch {}
|
|
1820
|
-
const error = new Error(payload.message || ("Request failed: " + response.status));
|
|
1821
|
-
error.status = response.status;
|
|
1822
|
-
error.payload = payload;
|
|
1823
|
-
throw error;
|
|
1824
|
-
}
|
|
1825
|
-
const contentType = response.headers.get("content-type") || "";
|
|
1826
|
-
if (contentType.includes("application/json")) {
|
|
1827
|
-
return await response.json();
|
|
1828
|
-
}
|
|
1829
|
-
return await response.text();
|
|
1830
|
-
};
|
|
1831
|
-
|
|
1832
|
-
const escapeHtml = (value) =>
|
|
1833
|
-
String(value || "")
|
|
1834
|
-
.replace(/&/g, "&")
|
|
1835
|
-
.replace(/</g, "<")
|
|
1836
|
-
.replace(/>/g, ">")
|
|
1837
|
-
.replace(/"/g, """)
|
|
1838
|
-
.replace(/'/g, "'");
|
|
1839
|
-
|
|
1840
|
-
const renderAssistantMarkdown = (value) => {
|
|
1841
|
-
const source = String(value || "").trim();
|
|
1842
|
-
if (!source) return "<p></p>";
|
|
1843
|
-
|
|
1844
|
-
try {
|
|
1845
|
-
return marked.parse(source);
|
|
1846
|
-
} catch (error) {
|
|
1847
|
-
console.error("Markdown parsing error:", error);
|
|
1848
|
-
// Fallback to escaped text
|
|
1849
|
-
return "<p>" + escapeHtml(source) + "</p>";
|
|
1850
|
-
}
|
|
1851
|
-
};
|
|
1852
|
-
|
|
1853
|
-
const extractToolActivity = (value) => {
|
|
1854
|
-
const source = String(value || "");
|
|
1855
|
-
let markerIndex = source.lastIndexOf("\\n### Tool activity\\n");
|
|
1856
|
-
if (markerIndex < 0 && source.startsWith("### Tool activity\\n")) {
|
|
1857
|
-
markerIndex = 0;
|
|
1858
|
-
}
|
|
1859
|
-
if (markerIndex < 0) {
|
|
1860
|
-
return { content: source, activities: [] };
|
|
1861
|
-
}
|
|
1862
|
-
const content = markerIndex === 0 ? "" : source.slice(0, markerIndex).trimEnd();
|
|
1863
|
-
const rawSection = markerIndex === 0 ? source : source.slice(markerIndex + 1);
|
|
1864
|
-
const afterHeading = rawSection.replace(/^### Tool activity\\s*\\n?/, "");
|
|
1865
|
-
const activities = afterHeading
|
|
1866
|
-
.split("\\n")
|
|
1867
|
-
.map((line) => line.trim())
|
|
1868
|
-
.filter((line) => line.startsWith("- "))
|
|
1869
|
-
.map((line) => line.slice(2).trim())
|
|
1870
|
-
.filter(Boolean);
|
|
1871
|
-
return { content, activities };
|
|
1872
|
-
};
|
|
1873
|
-
|
|
1874
|
-
const renderApprovalRequests = (requests) => {
|
|
1875
|
-
if (!Array.isArray(requests) || requests.length === 0) {
|
|
1876
|
-
return "";
|
|
1877
|
-
}
|
|
1878
|
-
const rows = requests
|
|
1879
|
-
.map((req) => {
|
|
1880
|
-
const approvalId = typeof req.approvalId === "string" ? req.approvalId : "";
|
|
1881
|
-
const tool = typeof req.tool === "string" ? req.tool : "tool";
|
|
1882
|
-
const input = req.input != null ? req.input : {};
|
|
1883
|
-
const submitting = req.state === "submitting";
|
|
1884
|
-
const approveLabel = submitting && req.pendingDecision === "approve" ? "Approving..." : "Approve";
|
|
1885
|
-
const denyLabel = submitting && req.pendingDecision === "deny" ? "Denying..." : "Deny";
|
|
1886
|
-
const errorHtml = req._error
|
|
1887
|
-
? '<div style="color: var(--deny); font-size: 11px; margin-top: 4px;">Submit failed: ' + escapeHtml(req._error) + "</div>"
|
|
1888
|
-
: "";
|
|
1889
|
-
return (
|
|
1890
|
-
'<div class="approval-request-item">' +
|
|
1891
|
-
'<div class="approval-requests-label">Approval required: <code>' +
|
|
1892
|
-
escapeHtml(tool) +
|
|
1893
|
-
"</code></div>" +
|
|
1894
|
-
renderInputTable(input) +
|
|
1895
|
-
errorHtml +
|
|
1896
|
-
'<div class="approval-request-actions">' +
|
|
1897
|
-
'<button class="approval-action-btn approve" data-approval-id="' +
|
|
1898
|
-
escapeHtml(approvalId) +
|
|
1899
|
-
'" data-approval-decision="approve" ' +
|
|
1900
|
-
(submitting ? "disabled" : "") +
|
|
1901
|
-
">" +
|
|
1902
|
-
approveLabel +
|
|
1903
|
-
"</button>" +
|
|
1904
|
-
'<button class="approval-action-btn deny" data-approval-id="' +
|
|
1905
|
-
escapeHtml(approvalId) +
|
|
1906
|
-
'" data-approval-decision="deny" ' +
|
|
1907
|
-
(submitting ? "disabled" : "") +
|
|
1908
|
-
">" +
|
|
1909
|
-
denyLabel +
|
|
1910
|
-
"</button>" +
|
|
1911
|
-
"</div>" +
|
|
1912
|
-
"</div>"
|
|
1913
|
-
);
|
|
1914
|
-
})
|
|
1915
|
-
.join("");
|
|
1916
|
-
return (
|
|
1917
|
-
'<div class="approval-requests">' +
|
|
1918
|
-
rows +
|
|
1919
|
-
"</div>"
|
|
1920
|
-
);
|
|
1921
|
-
};
|
|
1922
|
-
|
|
1923
|
-
const renderToolActivity = (items, approvalRequests = []) => {
|
|
1924
|
-
const hasItems = Array.isArray(items) && items.length > 0;
|
|
1925
|
-
const hasApprovals = Array.isArray(approvalRequests) && approvalRequests.length > 0;
|
|
1926
|
-
if (!hasItems && !hasApprovals) {
|
|
1927
|
-
return "";
|
|
1928
|
-
}
|
|
1929
|
-
const chips = hasItems
|
|
1930
|
-
? items
|
|
1931
|
-
.map((item) => '<div class="tool-activity-item">' + escapeHtml(item) + "</div>")
|
|
1932
|
-
.join("")
|
|
1933
|
-
: "";
|
|
1934
|
-
const disclosure = hasItems
|
|
1935
|
-
? (
|
|
1936
|
-
'<details class="tool-activity-disclosure">' +
|
|
1937
|
-
'<summary class="tool-activity-summary">' +
|
|
1938
|
-
'<span class="tool-activity-label">Tool activity</span>' +
|
|
1939
|
-
'<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>' +
|
|
1940
|
-
"</summary>" +
|
|
1941
|
-
'<div class="tool-activity-list">' +
|
|
1942
|
-
chips +
|
|
1943
|
-
"</div>" +
|
|
1944
|
-
"</details>"
|
|
1945
|
-
)
|
|
1946
|
-
: "";
|
|
1947
|
-
const cls = "tool-activity" + (hasApprovals ? " has-approvals" : "");
|
|
1948
|
-
return (
|
|
1949
|
-
'<div class="' + cls + '">' +
|
|
1950
|
-
disclosure +
|
|
1951
|
-
renderApprovalRequests(approvalRequests) +
|
|
1952
|
-
"</div>"
|
|
1953
|
-
);
|
|
1954
|
-
};
|
|
1955
|
-
|
|
1956
|
-
const renderInputTable = (input) => {
|
|
1957
|
-
if (!input || typeof input !== "object") {
|
|
1958
|
-
return '<div class="av-complex">' + escapeHtml(String(input ?? "{}")) + "</div>";
|
|
1959
|
-
}
|
|
1960
|
-
const keys = Object.keys(input);
|
|
1961
|
-
if (keys.length === 0) {
|
|
1962
|
-
return '<div class="av-complex">{}</div>';
|
|
1963
|
-
}
|
|
1964
|
-
const formatValue = (val) => {
|
|
1965
|
-
if (val === null || val === undefined) return escapeHtml("null");
|
|
1966
|
-
if (typeof val === "boolean" || typeof val === "number") return escapeHtml(String(val));
|
|
1967
|
-
if (typeof val === "string") return escapeHtml(val);
|
|
1968
|
-
try {
|
|
1969
|
-
const replacer = (_, v) => typeof v === "bigint" ? String(v) : v;
|
|
1970
|
-
return escapeHtml(JSON.stringify(val, replacer, 2));
|
|
1971
|
-
} catch {
|
|
1972
|
-
return escapeHtml("[unserializable]");
|
|
1973
|
-
}
|
|
1974
|
-
};
|
|
1975
|
-
const rows = keys.map((key) => {
|
|
1976
|
-
const val = input[key];
|
|
1977
|
-
const isComplex = val !== null && typeof val === "object";
|
|
1978
|
-
const cls = isComplex ? "av-complex" : "av";
|
|
1979
|
-
return (
|
|
1980
|
-
"<tr>" +
|
|
1981
|
-
'<td class="ak">' + escapeHtml(key) + "</td>" +
|
|
1982
|
-
'<td><div class="' + cls + '">' + formatValue(val) + "</div></td>" +
|
|
1983
|
-
"</tr>"
|
|
1984
|
-
);
|
|
1985
|
-
}).join("");
|
|
1986
|
-
return '<table class="approval-request-table">' + rows + "</table>";
|
|
1987
|
-
};
|
|
1988
|
-
|
|
1989
|
-
const updatePendingApproval = (approvalId, updater) => {
|
|
1990
|
-
if (!approvalId || typeof updater !== "function") {
|
|
1991
|
-
return false;
|
|
1992
|
-
}
|
|
1993
|
-
const messages = state.activeMessages || [];
|
|
1994
|
-
for (const message of messages) {
|
|
1995
|
-
if (!message || !Array.isArray(message._pendingApprovals)) {
|
|
1996
|
-
continue;
|
|
1997
|
-
}
|
|
1998
|
-
const idx = message._pendingApprovals.findIndex((req) => req.approvalId === approvalId);
|
|
1999
|
-
if (idx < 0) {
|
|
2000
|
-
continue;
|
|
2001
|
-
}
|
|
2002
|
-
const next = updater(message._pendingApprovals[idx], message._pendingApprovals);
|
|
2003
|
-
if (next === null) {
|
|
2004
|
-
message._pendingApprovals.splice(idx, 1);
|
|
2005
|
-
} else if (next && typeof next === "object") {
|
|
2006
|
-
message._pendingApprovals[idx] = next;
|
|
2007
|
-
}
|
|
2008
|
-
return true;
|
|
2009
|
-
}
|
|
2010
|
-
return false;
|
|
2011
|
-
};
|
|
2012
|
-
|
|
2013
|
-
const toUiPendingApprovals = (pendingApprovals) => {
|
|
2014
|
-
if (!Array.isArray(pendingApprovals)) {
|
|
2015
|
-
return [];
|
|
2016
|
-
}
|
|
2017
|
-
return pendingApprovals
|
|
2018
|
-
.map((item) => {
|
|
2019
|
-
const approvalId =
|
|
2020
|
-
item && typeof item.approvalId === "string" ? item.approvalId : "";
|
|
2021
|
-
if (!approvalId) {
|
|
2022
|
-
return null;
|
|
2023
|
-
}
|
|
2024
|
-
const toolName = item && typeof item.tool === "string" ? item.tool : "tool";
|
|
2025
|
-
return {
|
|
2026
|
-
approvalId,
|
|
2027
|
-
tool: toolName,
|
|
2028
|
-
input: item?.input ?? {},
|
|
2029
|
-
state: "pending",
|
|
2030
|
-
};
|
|
2031
|
-
})
|
|
2032
|
-
.filter(Boolean);
|
|
2033
|
-
};
|
|
2034
|
-
|
|
2035
|
-
const hydratePendingApprovals = (messages, pendingApprovals) => {
|
|
2036
|
-
const nextMessages = Array.isArray(messages) ? [...messages] : [];
|
|
2037
|
-
const pending = toUiPendingApprovals(pendingApprovals);
|
|
2038
|
-
if (pending.length === 0) {
|
|
2039
|
-
return nextMessages;
|
|
2040
|
-
}
|
|
2041
|
-
const toolLines = pending.map((request) => "- approval required \\x60" + request.tool + "\\x60");
|
|
2042
|
-
for (let idx = nextMessages.length - 1; idx >= 0; idx -= 1) {
|
|
2043
|
-
const message = nextMessages[idx];
|
|
2044
|
-
if (!message || message.role !== "assistant") {
|
|
2045
|
-
continue;
|
|
2046
|
-
}
|
|
2047
|
-
const metadata = message.metadata && typeof message.metadata === "object" ? message.metadata : {};
|
|
2048
|
-
const existingTimeline = Array.isArray(metadata.toolActivity) ? metadata.toolActivity : [];
|
|
2049
|
-
const mergedTimeline = [...existingTimeline];
|
|
2050
|
-
toolLines.forEach((line) => {
|
|
2051
|
-
if (!mergedTimeline.includes(line)) {
|
|
2052
|
-
mergedTimeline.push(line);
|
|
2053
|
-
}
|
|
2054
|
-
});
|
|
2055
|
-
nextMessages[idx] = {
|
|
2056
|
-
...message,
|
|
2057
|
-
metadata: {
|
|
2058
|
-
...metadata,
|
|
2059
|
-
toolActivity: mergedTimeline,
|
|
2060
|
-
},
|
|
2061
|
-
_pendingApprovals: pending,
|
|
2062
|
-
};
|
|
2063
|
-
return nextMessages;
|
|
2064
|
-
}
|
|
2065
|
-
nextMessages.push({
|
|
2066
|
-
role: "assistant",
|
|
2067
|
-
content: "",
|
|
2068
|
-
metadata: { toolActivity: toolLines },
|
|
2069
|
-
_pendingApprovals: pending,
|
|
2070
|
-
});
|
|
2071
|
-
return nextMessages;
|
|
2072
|
-
};
|
|
2073
|
-
|
|
2074
|
-
const formatDate = (epoch) => {
|
|
2075
|
-
try {
|
|
2076
|
-
const date = new Date(epoch);
|
|
2077
|
-
const now = new Date();
|
|
2078
|
-
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
|
2079
|
-
const startOfDate = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
|
|
2080
|
-
const dayDiff = Math.floor((startOfToday - startOfDate) / 86400000);
|
|
2081
|
-
if (dayDiff === 0) {
|
|
2082
|
-
return "Today";
|
|
2083
|
-
}
|
|
2084
|
-
if (dayDiff === 1) {
|
|
2085
|
-
return "Yesterday";
|
|
2086
|
-
}
|
|
2087
|
-
if (dayDiff < 7 && dayDiff > 1) {
|
|
2088
|
-
return date.toLocaleDateString(undefined, { weekday: "short" });
|
|
2089
|
-
}
|
|
2090
|
-
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
|
2091
|
-
} catch {
|
|
2092
|
-
return "";
|
|
2093
|
-
}
|
|
2094
|
-
};
|
|
2095
|
-
|
|
2096
|
-
const isMobile = () => window.matchMedia("(max-width: 900px)").matches;
|
|
2097
|
-
|
|
2098
|
-
const setSidebarOpen = (open) => {
|
|
2099
|
-
if (!isMobile()) {
|
|
2100
|
-
elements.shell.classList.remove("sidebar-open");
|
|
2101
|
-
return;
|
|
2102
|
-
}
|
|
2103
|
-
elements.shell.classList.toggle("sidebar-open", open);
|
|
2104
|
-
};
|
|
2105
|
-
|
|
2106
|
-
const buildConversationItem = (c) => {
|
|
2107
|
-
const item = document.createElement("div");
|
|
2108
|
-
item.className = "conversation-item" + (c.conversationId === state.activeConversationId ? " active" : "");
|
|
2109
|
-
|
|
2110
|
-
if (c.hasPendingApprovals) {
|
|
2111
|
-
const dot = document.createElement("span");
|
|
2112
|
-
dot.className = "approval-dot";
|
|
2113
|
-
item.appendChild(dot);
|
|
2114
|
-
}
|
|
2115
|
-
|
|
2116
|
-
const titleSpan = document.createElement("span");
|
|
2117
|
-
titleSpan.textContent = c.title;
|
|
2118
|
-
item.appendChild(titleSpan);
|
|
2119
|
-
|
|
2120
|
-
const isConfirming = state.confirmDeleteId === c.conversationId;
|
|
2121
|
-
const deleteBtn = document.createElement("button");
|
|
2122
|
-
deleteBtn.className = "delete-btn" + (isConfirming ? " confirming" : "");
|
|
2123
|
-
deleteBtn.textContent = isConfirming ? "sure?" : "\\u00d7";
|
|
2124
|
-
deleteBtn.onclick = async (e) => {
|
|
2125
|
-
e.stopPropagation();
|
|
2126
|
-
if (!isConfirming) {
|
|
2127
|
-
state.confirmDeleteId = c.conversationId;
|
|
2128
|
-
renderConversationList();
|
|
2129
|
-
return;
|
|
2130
|
-
}
|
|
2131
|
-
await api("/api/conversations/" + c.conversationId, { method: "DELETE" });
|
|
2132
|
-
if (state.activeConversationId === c.conversationId) {
|
|
2133
|
-
state.activeConversationId = null;
|
|
2134
|
-
state.activeMessages = [];
|
|
2135
|
-
state.contextTokens = 0;
|
|
2136
|
-
state.contextWindow = 0;
|
|
2137
|
-
updateContextRing();
|
|
2138
|
-
pushConversationUrl(null);
|
|
2139
|
-
elements.chatTitle.textContent = "";
|
|
2140
|
-
renderMessages([]);
|
|
2141
|
-
}
|
|
2142
|
-
state.confirmDeleteId = null;
|
|
2143
|
-
await loadConversations();
|
|
2144
|
-
};
|
|
2145
|
-
item.appendChild(deleteBtn);
|
|
2146
|
-
|
|
2147
|
-
item.onclick = async () => {
|
|
2148
|
-
if (state.confirmDeleteId) {
|
|
2149
|
-
state.confirmDeleteId = null;
|
|
2150
|
-
}
|
|
2151
|
-
state.activeConversationId = c.conversationId;
|
|
2152
|
-
pushConversationUrl(c.conversationId);
|
|
2153
|
-
renderConversationList();
|
|
2154
|
-
await loadConversation(c.conversationId);
|
|
2155
|
-
if (isMobile()) setSidebarOpen(false);
|
|
2156
|
-
};
|
|
2157
|
-
|
|
2158
|
-
return item;
|
|
2159
|
-
};
|
|
2160
|
-
|
|
2161
|
-
const renderConversationList = () => {
|
|
2162
|
-
elements.list.innerHTML = "";
|
|
2163
|
-
const pending = state.conversations.filter(c => c.hasPendingApprovals);
|
|
2164
|
-
const rest = state.conversations.filter(c => !c.hasPendingApprovals);
|
|
2165
|
-
|
|
2166
|
-
if (pending.length > 0) {
|
|
2167
|
-
const label = document.createElement("div");
|
|
2168
|
-
label.className = "sidebar-section-label";
|
|
2169
|
-
label.textContent = "Awaiting approval";
|
|
2170
|
-
elements.list.appendChild(label);
|
|
2171
|
-
for (const c of pending) {
|
|
2172
|
-
elements.list.appendChild(buildConversationItem(c));
|
|
2173
|
-
}
|
|
2174
|
-
if (rest.length > 0) {
|
|
2175
|
-
const divider = document.createElement("div");
|
|
2176
|
-
divider.className = "sidebar-section-divider";
|
|
2177
|
-
elements.list.appendChild(divider);
|
|
2178
|
-
const recentLabel = document.createElement("div");
|
|
2179
|
-
recentLabel.className = "sidebar-section-label";
|
|
2180
|
-
recentLabel.textContent = "Recent";
|
|
2181
|
-
elements.list.appendChild(recentLabel);
|
|
2182
|
-
}
|
|
2183
|
-
}
|
|
2184
|
-
|
|
2185
|
-
for (const c of rest) {
|
|
2186
|
-
elements.list.appendChild(buildConversationItem(c));
|
|
2187
|
-
}
|
|
2188
|
-
};
|
|
2189
|
-
|
|
2190
|
-
const isNearBottom = (element, threshold = 64) => {
|
|
2191
|
-
if (!element) return true;
|
|
2192
|
-
return (
|
|
2193
|
-
element.scrollHeight - element.clientHeight - element.scrollTop <= threshold
|
|
2194
|
-
);
|
|
2195
|
-
};
|
|
2196
|
-
|
|
2197
|
-
const renderMessages = (messages, isStreaming = false, options = {}) => {
|
|
2198
|
-
const previousScrollTop = elements.messages.scrollTop;
|
|
2199
|
-
const shouldStickToBottom =
|
|
2200
|
-
options.forceScrollBottom === true || state.isMessagesPinnedToBottom;
|
|
2201
|
-
|
|
2202
|
-
const createThinkingIndicator = (label) => {
|
|
2203
|
-
const status = document.createElement("div");
|
|
2204
|
-
status.className = "thinking-status";
|
|
2205
|
-
const spinner = document.createElement("span");
|
|
2206
|
-
spinner.className = "thinking-indicator";
|
|
2207
|
-
const starFrames = ["✶", "✸", "✹", "✺", "✹", "✷"];
|
|
2208
|
-
let frame = 0;
|
|
2209
|
-
spinner.textContent = starFrames[0];
|
|
2210
|
-
spinner._interval = setInterval(() => {
|
|
2211
|
-
frame = (frame + 1) % starFrames.length;
|
|
2212
|
-
spinner.textContent = starFrames[frame];
|
|
2213
|
-
}, 70);
|
|
2214
|
-
status.appendChild(spinner);
|
|
2215
|
-
if (label) {
|
|
2216
|
-
const text = document.createElement("span");
|
|
2217
|
-
text.className = "thinking-status-label";
|
|
2218
|
-
text.textContent = label;
|
|
2219
|
-
status.appendChild(text);
|
|
2220
|
-
}
|
|
2221
|
-
return status;
|
|
2222
|
-
};
|
|
2223
|
-
|
|
2224
|
-
elements.messages.innerHTML = "";
|
|
2225
|
-
if (!messages || !messages.length) {
|
|
2226
|
-
elements.messages.innerHTML = '<div class="empty-state"><div class="assistant-avatar">' + agentInitial + '</div><div>How can I help you today?</div></div>';
|
|
2227
|
-
elements.messages.scrollTop = 0;
|
|
2228
|
-
return;
|
|
2229
|
-
}
|
|
2230
|
-
const col = document.createElement("div");
|
|
2231
|
-
col.className = "messages-column";
|
|
2232
|
-
messages.forEach((m, i) => {
|
|
2233
|
-
const row = document.createElement("div");
|
|
2234
|
-
row.className = "message-row " + m.role;
|
|
2235
|
-
if (m.role === "assistant") {
|
|
2236
|
-
const wrap = document.createElement("div");
|
|
2237
|
-
wrap.className = "assistant-wrap";
|
|
2238
|
-
wrap.innerHTML = '<div class="assistant-avatar">' + agentInitial + '</div>';
|
|
2239
|
-
const content = document.createElement("div");
|
|
2240
|
-
content.className = "assistant-content";
|
|
2241
|
-
const text = String(m.content || "");
|
|
2242
|
-
const isLastAssistant = i === messages.length - 1;
|
|
2243
|
-
const hasPendingApprovals =
|
|
2244
|
-
Array.isArray(m._pendingApprovals) && m._pendingApprovals.length > 0;
|
|
2245
|
-
const shouldRenderEmptyStreamingIndicator =
|
|
2246
|
-
isStreaming &&
|
|
2247
|
-
isLastAssistant &&
|
|
2248
|
-
!text &&
|
|
2249
|
-
(!Array.isArray(m._sections) || m._sections.length === 0) &&
|
|
2250
|
-
(!Array.isArray(m._currentTools) || m._currentTools.length === 0) &&
|
|
2251
|
-
!hasPendingApprovals;
|
|
2252
|
-
|
|
2253
|
-
if (m._error) {
|
|
2254
|
-
const errorEl = document.createElement("div");
|
|
2255
|
-
errorEl.className = "message-error";
|
|
2256
|
-
errorEl.innerHTML = "<strong>Error</strong><br>" + escapeHtml(m._error);
|
|
2257
|
-
content.appendChild(errorEl);
|
|
2258
|
-
} else if (shouldRenderEmptyStreamingIndicator) {
|
|
2259
|
-
content.appendChild(createThinkingIndicator(getThinkingStatusLabel(m)));
|
|
2260
|
-
} else {
|
|
2261
|
-
// Merge stored sections (persisted) with live sections (from
|
|
2262
|
-
// an active stream). For normal messages only one source
|
|
2263
|
-
// exists; for liveOnly reconnects both contribute.
|
|
2264
|
-
const storedSections = (m.metadata && m.metadata.sections) || [];
|
|
2265
|
-
const liveSections = m._sections || [];
|
|
2266
|
-
const sections = liveSections.length > 0 && storedSections.length > 0
|
|
2267
|
-
? storedSections.concat(liveSections)
|
|
2268
|
-
: liveSections.length > 0 ? liveSections : (storedSections.length > 0 ? storedSections : null);
|
|
2269
|
-
const pendingApprovals = Array.isArray(m._pendingApprovals) ? m._pendingApprovals : [];
|
|
2270
|
-
|
|
2271
|
-
if (sections && sections.length > 0) {
|
|
2272
|
-
let lastToolsSectionIndex = -1;
|
|
2273
|
-
for (let sectionIdx = sections.length - 1; sectionIdx >= 0; sectionIdx -= 1) {
|
|
2274
|
-
if (sections[sectionIdx] && sections[sectionIdx].type === "tools") {
|
|
2275
|
-
lastToolsSectionIndex = sectionIdx;
|
|
2276
|
-
break;
|
|
2277
|
-
}
|
|
2278
|
-
}
|
|
2279
|
-
// Render sections interleaved
|
|
2280
|
-
sections.forEach((section, sectionIdx) => {
|
|
2281
|
-
if (section.type === "text") {
|
|
2282
|
-
const textDiv = document.createElement("div");
|
|
2283
|
-
textDiv.innerHTML = renderAssistantMarkdown(section.content);
|
|
2284
|
-
content.appendChild(textDiv);
|
|
2285
|
-
} else if (section.type === "tools") {
|
|
2286
|
-
const sectionApprovals =
|
|
2287
|
-
!isStreaming &&
|
|
2288
|
-
pendingApprovals.length > 0 &&
|
|
2289
|
-
sectionIdx === lastToolsSectionIndex
|
|
2290
|
-
? pendingApprovals
|
|
2291
|
-
: [];
|
|
2292
|
-
content.insertAdjacentHTML(
|
|
2293
|
-
"beforeend",
|
|
2294
|
-
renderToolActivity(section.content, sectionApprovals),
|
|
2295
|
-
);
|
|
2296
|
-
}
|
|
2297
|
-
});
|
|
2298
|
-
// While streaming, show current tools if any
|
|
2299
|
-
if (isStreaming && i === messages.length - 1 && m._currentTools && m._currentTools.length > 0) {
|
|
2300
|
-
content.insertAdjacentHTML(
|
|
2301
|
-
"beforeend",
|
|
2302
|
-
renderToolActivity(m._currentTools, m._pendingApprovals || []),
|
|
2303
|
-
);
|
|
2304
|
-
}
|
|
2305
|
-
// When reloading with unresolved approvals, show them even when not streaming
|
|
2306
|
-
if (!isStreaming && pendingApprovals.length > 0 && lastToolsSectionIndex < 0) {
|
|
2307
|
-
content.insertAdjacentHTML("beforeend", renderToolActivity([], m._pendingApprovals));
|
|
2308
|
-
}
|
|
2309
|
-
// Show current text being typed
|
|
2310
|
-
if (isStreaming && i === messages.length - 1 && m._currentText) {
|
|
2311
|
-
const textDiv = document.createElement("div");
|
|
2312
|
-
textDiv.innerHTML = renderAssistantMarkdown(m._currentText);
|
|
2313
|
-
content.appendChild(textDiv);
|
|
2314
|
-
}
|
|
2315
|
-
} else {
|
|
2316
|
-
// Fallback: render text and tools the old way (for old messages without sections)
|
|
2317
|
-
if (text) {
|
|
2318
|
-
const parsed = extractToolActivity(text);
|
|
2319
|
-
content.innerHTML = renderAssistantMarkdown(parsed.content);
|
|
2320
|
-
}
|
|
2321
|
-
const metadataToolActivity =
|
|
2322
|
-
m.metadata && Array.isArray(m.metadata.toolActivity)
|
|
2323
|
-
? m.metadata.toolActivity
|
|
2324
|
-
: [];
|
|
2325
|
-
if (metadataToolActivity.length > 0 || pendingApprovals.length > 0) {
|
|
2326
|
-
content.insertAdjacentHTML(
|
|
2327
|
-
"beforeend",
|
|
2328
|
-
renderToolActivity(metadataToolActivity, pendingApprovals),
|
|
2329
|
-
);
|
|
2330
|
-
}
|
|
2331
|
-
}
|
|
2332
|
-
if (isStreaming && isLastAssistant && !hasPendingApprovals) {
|
|
2333
|
-
const waitIndicator = document.createElement("div");
|
|
2334
|
-
waitIndicator.appendChild(createThinkingIndicator(getThinkingStatusLabel(m)));
|
|
2335
|
-
content.appendChild(waitIndicator);
|
|
2336
|
-
}
|
|
2337
|
-
}
|
|
2338
|
-
wrap.appendChild(content);
|
|
2339
|
-
row.appendChild(wrap);
|
|
2340
|
-
} else {
|
|
2341
|
-
const bubble = document.createElement("div");
|
|
2342
|
-
bubble.className = "user-bubble";
|
|
2343
|
-
if (typeof m.content === "string") {
|
|
2344
|
-
bubble.textContent = m.content;
|
|
2345
|
-
} else if (Array.isArray(m.content)) {
|
|
2346
|
-
const textParts = m.content.filter(p => p.type === "text").map(p => p.text).join("");
|
|
2347
|
-
if (textParts) {
|
|
2348
|
-
const textEl = document.createElement("div");
|
|
2349
|
-
textEl.textContent = textParts;
|
|
2350
|
-
bubble.appendChild(textEl);
|
|
2351
|
-
}
|
|
2352
|
-
const fileParts = m.content.filter(p => p.type === "file");
|
|
2353
|
-
if (fileParts.length > 0) {
|
|
2354
|
-
const filesEl = document.createElement("div");
|
|
2355
|
-
filesEl.className = "user-file-attachments";
|
|
2356
|
-
fileParts.forEach(fp => {
|
|
2357
|
-
if (fp.mediaType && fp.mediaType.startsWith("image/")) {
|
|
2358
|
-
const img = document.createElement("img");
|
|
2359
|
-
if (fp._localBlob) {
|
|
2360
|
-
if (!fp._cachedUrl) fp._cachedUrl = URL.createObjectURL(fp._localBlob);
|
|
2361
|
-
img.src = fp._cachedUrl;
|
|
2362
|
-
} else if (fp.data && fp.data.startsWith("poncho-upload://")) {
|
|
2363
|
-
img.src = "/api/uploads/" + encodeURIComponent(fp.data.replace("poncho-upload://", ""));
|
|
2364
|
-
} else if (fp.data && (fp.data.startsWith("http://") || fp.data.startsWith("https://"))) {
|
|
2365
|
-
img.src = fp.data;
|
|
2366
|
-
} else if (fp.data) {
|
|
2367
|
-
img.src = "data:" + fp.mediaType + ";base64," + fp.data;
|
|
2368
|
-
}
|
|
2369
|
-
img.alt = fp.filename || "image";
|
|
2370
|
-
filesEl.appendChild(img);
|
|
2371
|
-
} else {
|
|
2372
|
-
const badge = document.createElement("span");
|
|
2373
|
-
badge.className = "user-file-badge";
|
|
2374
|
-
badge.textContent = "📎 " + (fp.filename || "file");
|
|
2375
|
-
filesEl.appendChild(badge);
|
|
2376
|
-
}
|
|
2377
|
-
});
|
|
2378
|
-
bubble.appendChild(filesEl);
|
|
2379
|
-
}
|
|
2380
|
-
}
|
|
2381
|
-
row.appendChild(bubble);
|
|
2382
|
-
}
|
|
2383
|
-
col.appendChild(row);
|
|
2384
|
-
});
|
|
2385
|
-
elements.messages.appendChild(col);
|
|
2386
|
-
if (shouldStickToBottom) {
|
|
2387
|
-
elements.messages.scrollTop = elements.messages.scrollHeight;
|
|
2388
|
-
state.isMessagesPinnedToBottom = true;
|
|
2389
|
-
return;
|
|
2390
|
-
}
|
|
2391
|
-
if (options.preserveScroll !== false) {
|
|
2392
|
-
elements.messages.scrollTop = previousScrollTop;
|
|
2393
|
-
}
|
|
2394
|
-
};
|
|
2395
|
-
|
|
2396
|
-
const loadConversations = async () => {
|
|
2397
|
-
const payload = await api("/api/conversations");
|
|
2398
|
-
state.conversations = payload.conversations || [];
|
|
2399
|
-
renderConversationList();
|
|
2400
|
-
};
|
|
2401
|
-
|
|
2402
|
-
const loadConversation = async (conversationId) => {
|
|
2403
|
-
const payload = await api("/api/conversations/" + encodeURIComponent(conversationId));
|
|
2404
|
-
elements.chatTitle.textContent = payload.conversation.title;
|
|
2405
|
-
state.activeMessages = hydratePendingApprovals(
|
|
2406
|
-
payload.conversation.messages || [],
|
|
2407
|
-
payload.conversation.pendingApprovals || payload.pendingApprovals || [],
|
|
2408
|
-
);
|
|
2409
|
-
state.contextTokens = 0;
|
|
2410
|
-
state.contextWindow = 0;
|
|
2411
|
-
updateContextRing();
|
|
2412
|
-
renderMessages(state.activeMessages, false, { forceScrollBottom: true });
|
|
2413
|
-
elements.prompt.focus();
|
|
2414
|
-
if (payload.hasActiveRun && !state.isStreaming) {
|
|
2415
|
-
setStreaming(true);
|
|
2416
|
-
streamConversationEvents(conversationId, { liveOnly: true }).finally(() => {
|
|
2417
|
-
if (state.activeConversationId === conversationId) {
|
|
2418
|
-
setStreaming(false);
|
|
2419
|
-
renderMessages(state.activeMessages, false);
|
|
2420
|
-
}
|
|
2421
|
-
});
|
|
2422
|
-
}
|
|
2423
|
-
};
|
|
2424
|
-
|
|
2425
|
-
const streamConversationEvents = (conversationId, options) => {
|
|
2426
|
-
const liveOnly = options && options.liveOnly;
|
|
2427
|
-
return new Promise((resolve) => {
|
|
2428
|
-
const localMessages = state.activeMessages || [];
|
|
2429
|
-
const renderIfActiveConversation = (streaming) => {
|
|
2430
|
-
if (state.activeConversationId !== conversationId) {
|
|
2431
|
-
return;
|
|
2432
|
-
}
|
|
2433
|
-
state.activeMessages = localMessages;
|
|
2434
|
-
renderMessages(localMessages, streaming);
|
|
2435
|
-
};
|
|
2436
|
-
let assistantMessage = localMessages[localMessages.length - 1];
|
|
2437
|
-
if (!assistantMessage || assistantMessage.role !== "assistant") {
|
|
2438
|
-
assistantMessage = {
|
|
2439
|
-
role: "assistant",
|
|
2440
|
-
content: "",
|
|
2441
|
-
_sections: [],
|
|
2442
|
-
_currentText: "",
|
|
2443
|
-
_currentTools: [],
|
|
2444
|
-
_pendingApprovals: [],
|
|
2445
|
-
_activeActivities: [],
|
|
2446
|
-
metadata: { toolActivity: [] },
|
|
2447
|
-
};
|
|
2448
|
-
localMessages.push(assistantMessage);
|
|
2449
|
-
state.activeMessages = localMessages;
|
|
2450
|
-
}
|
|
2451
|
-
if (liveOnly) {
|
|
2452
|
-
// Live-only mode: keep metadata.sections intact (the stored
|
|
2453
|
-
// base content) and start _sections empty so it only collects
|
|
2454
|
-
// NEW sections from live events. The renderer merges both.
|
|
2455
|
-
assistantMessage._sections = [];
|
|
2456
|
-
assistantMessage._currentText = "";
|
|
2457
|
-
assistantMessage._currentTools = [];
|
|
2458
|
-
if (!assistantMessage._activeActivities) assistantMessage._activeActivities = [];
|
|
2459
|
-
if (!assistantMessage._pendingApprovals) assistantMessage._pendingApprovals = [];
|
|
2460
|
-
if (!assistantMessage.metadata) assistantMessage.metadata = {};
|
|
2461
|
-
if (!assistantMessage.metadata.toolActivity) assistantMessage.metadata.toolActivity = [];
|
|
2462
|
-
} else {
|
|
2463
|
-
// Full replay mode: reset transient state so replayed events
|
|
2464
|
-
// rebuild from scratch (the buffer has the full event history).
|
|
2465
|
-
assistantMessage.content = "";
|
|
2466
|
-
assistantMessage._sections = [];
|
|
2467
|
-
assistantMessage._currentText = "";
|
|
2468
|
-
assistantMessage._currentTools = [];
|
|
2469
|
-
assistantMessage._activeActivities = [];
|
|
2470
|
-
assistantMessage._pendingApprovals = [];
|
|
2471
|
-
assistantMessage.metadata = { toolActivity: [] };
|
|
2472
|
-
}
|
|
2473
|
-
|
|
2474
|
-
const url = "/api/conversations/" + encodeURIComponent(conversationId) + "/events" + (liveOnly ? "?live_only=true" : "");
|
|
2475
|
-
fetch(url, { credentials: "include" }).then((response) => {
|
|
2476
|
-
if (!response.ok || !response.body) {
|
|
2477
|
-
resolve(undefined);
|
|
2478
|
-
return;
|
|
2479
|
-
}
|
|
2480
|
-
const reader = response.body.getReader();
|
|
2481
|
-
const decoder = new TextDecoder();
|
|
2482
|
-
let buffer = "";
|
|
2483
|
-
const processChunks = async () => {
|
|
2484
|
-
while (true) {
|
|
2485
|
-
const { value, done } = await reader.read();
|
|
2486
|
-
if (done) {
|
|
2487
|
-
break;
|
|
2488
|
-
}
|
|
2489
|
-
buffer += decoder.decode(value, { stream: true });
|
|
2490
|
-
buffer = parseSseChunk(buffer, (eventName, payload) => {
|
|
2491
|
-
try {
|
|
2492
|
-
if (eventName === "stream:end") {
|
|
2493
|
-
return;
|
|
2494
|
-
}
|
|
2495
|
-
if (eventName === "run:started") {
|
|
2496
|
-
if (typeof payload.contextWindow === "number" && payload.contextWindow > 0) {
|
|
2497
|
-
state.contextWindow = payload.contextWindow;
|
|
2498
|
-
}
|
|
2499
|
-
}
|
|
2500
|
-
if (eventName === "model:chunk") {
|
|
2501
|
-
const chunk = String(payload.content || "");
|
|
2502
|
-
if (assistantMessage._currentTools.length > 0 && chunk.length > 0) {
|
|
2503
|
-
assistantMessage._sections.push({
|
|
2504
|
-
type: "tools",
|
|
2505
|
-
content: assistantMessage._currentTools,
|
|
2506
|
-
});
|
|
2507
|
-
assistantMessage._currentTools = [];
|
|
2508
|
-
}
|
|
2509
|
-
assistantMessage.content += chunk;
|
|
2510
|
-
assistantMessage._currentText += chunk;
|
|
2511
|
-
renderIfActiveConversation(true);
|
|
2512
|
-
}
|
|
2513
|
-
if (eventName === "model:response") {
|
|
2514
|
-
if (typeof payload.usage?.input === "number") {
|
|
2515
|
-
state.contextTokens = payload.usage.input;
|
|
2516
|
-
updateContextRing();
|
|
2517
|
-
}
|
|
2518
|
-
}
|
|
2519
|
-
if (eventName === "tool:started") {
|
|
2520
|
-
const toolName = payload.tool || "tool";
|
|
2521
|
-
const startedActivity = addActiveActivityFromToolStart(
|
|
2522
|
-
assistantMessage,
|
|
2523
|
-
payload,
|
|
2524
|
-
);
|
|
2525
|
-
if (assistantMessage._currentText.length > 0) {
|
|
2526
|
-
assistantMessage._sections.push({
|
|
2527
|
-
type: "text",
|
|
2528
|
-
content: assistantMessage._currentText,
|
|
2529
|
-
});
|
|
2530
|
-
assistantMessage._currentText = "";
|
|
2531
|
-
}
|
|
2532
|
-
const detail =
|
|
2533
|
-
startedActivity && typeof startedActivity.detail === "string"
|
|
2534
|
-
? startedActivity.detail.trim()
|
|
2535
|
-
: "";
|
|
2536
|
-
const toolText =
|
|
2537
|
-
"- start \\x60" +
|
|
2538
|
-
toolName +
|
|
2539
|
-
"\\x60" +
|
|
2540
|
-
(detail ? " (" + detail + ")" : "");
|
|
2541
|
-
assistantMessage._currentTools.push(toolText);
|
|
2542
|
-
assistantMessage.metadata.toolActivity.push(toolText);
|
|
2543
|
-
renderIfActiveConversation(true);
|
|
2544
|
-
}
|
|
2545
|
-
if (eventName === "tool:completed") {
|
|
2546
|
-
const toolName = payload.tool || "tool";
|
|
2547
|
-
const activeActivity = removeActiveActivityForTool(
|
|
2548
|
-
assistantMessage,
|
|
2549
|
-
toolName,
|
|
2550
|
-
);
|
|
2551
|
-
const duration =
|
|
2552
|
-
typeof payload.duration === "number" ? payload.duration : null;
|
|
2553
|
-
const detail =
|
|
2554
|
-
activeActivity && typeof activeActivity.detail === "string"
|
|
2555
|
-
? activeActivity.detail.trim()
|
|
2556
|
-
: "";
|
|
2557
|
-
const meta = [];
|
|
2558
|
-
if (duration !== null) meta.push(duration + "ms");
|
|
2559
|
-
if (detail) meta.push(detail);
|
|
2560
|
-
const toolText =
|
|
2561
|
-
"- done \\x60" +
|
|
2562
|
-
toolName +
|
|
2563
|
-
"\\x60" +
|
|
2564
|
-
(meta.length > 0 ? " (" + meta.join(", ") + ")" : "");
|
|
2565
|
-
assistantMessage._currentTools.push(toolText);
|
|
2566
|
-
assistantMessage.metadata.toolActivity.push(toolText);
|
|
2567
|
-
renderIfActiveConversation(true);
|
|
2568
|
-
}
|
|
2569
|
-
if (eventName === "tool:error") {
|
|
2570
|
-
const toolName = payload.tool || "tool";
|
|
2571
|
-
const activeActivity = removeActiveActivityForTool(
|
|
2572
|
-
assistantMessage,
|
|
2573
|
-
toolName,
|
|
2574
|
-
);
|
|
2575
|
-
const errorMsg = payload.error || "unknown error";
|
|
2576
|
-
const detail =
|
|
2577
|
-
activeActivity && typeof activeActivity.detail === "string"
|
|
2578
|
-
? activeActivity.detail.trim()
|
|
2579
|
-
: "";
|
|
2580
|
-
const toolText =
|
|
2581
|
-
"- error \\x60" +
|
|
2582
|
-
toolName +
|
|
2583
|
-
"\\x60" +
|
|
2584
|
-
(detail ? " (" + detail + ")" : "") +
|
|
2585
|
-
": " +
|
|
2586
|
-
errorMsg;
|
|
2587
|
-
assistantMessage._currentTools.push(toolText);
|
|
2588
|
-
assistantMessage.metadata.toolActivity.push(toolText);
|
|
2589
|
-
renderIfActiveConversation(true);
|
|
2590
|
-
}
|
|
2591
|
-
if (eventName === "tool:approval:required") {
|
|
2592
|
-
const toolName = payload.tool || "tool";
|
|
2593
|
-
const activeActivity = removeActiveActivityForTool(
|
|
2594
|
-
assistantMessage,
|
|
2595
|
-
toolName,
|
|
2596
|
-
);
|
|
2597
|
-
const detailFromPayload = describeToolStart(payload);
|
|
2598
|
-
const detail =
|
|
2599
|
-
(activeActivity && typeof activeActivity.detail === "string"
|
|
2600
|
-
? activeActivity.detail.trim()
|
|
2601
|
-
: "") ||
|
|
2602
|
-
(detailFromPayload && typeof detailFromPayload.detail === "string"
|
|
2603
|
-
? detailFromPayload.detail.trim()
|
|
2604
|
-
: "");
|
|
2605
|
-
const toolText =
|
|
2606
|
-
"- approval required \\x60" +
|
|
2607
|
-
toolName +
|
|
2608
|
-
"\\x60" +
|
|
2609
|
-
(detail ? " (" + detail + ")" : "");
|
|
2610
|
-
assistantMessage._currentTools.push(toolText);
|
|
2611
|
-
assistantMessage.metadata.toolActivity.push(toolText);
|
|
2612
|
-
const approvalId =
|
|
2613
|
-
typeof payload.approvalId === "string" ? payload.approvalId : "";
|
|
2614
|
-
if (approvalId) {
|
|
2615
|
-
if (!Array.isArray(assistantMessage._pendingApprovals)) {
|
|
2616
|
-
assistantMessage._pendingApprovals = [];
|
|
2617
|
-
}
|
|
2618
|
-
const exists = assistantMessage._pendingApprovals.some(
|
|
2619
|
-
(req) => req.approvalId === approvalId,
|
|
2620
|
-
);
|
|
2621
|
-
if (!exists) {
|
|
2622
|
-
assistantMessage._pendingApprovals.push({
|
|
2623
|
-
approvalId,
|
|
2624
|
-
tool: toolName,
|
|
2625
|
-
input: payload.input ?? {},
|
|
2626
|
-
state: "pending",
|
|
2627
|
-
});
|
|
2628
|
-
}
|
|
2629
|
-
}
|
|
2630
|
-
renderIfActiveConversation(true);
|
|
2631
|
-
}
|
|
2632
|
-
if (eventName === "tool:approval:granted") {
|
|
2633
|
-
const toolText = "- approval granted";
|
|
2634
|
-
assistantMessage._currentTools.push(toolText);
|
|
2635
|
-
assistantMessage.metadata.toolActivity.push(toolText);
|
|
2636
|
-
const approvalId =
|
|
2637
|
-
typeof payload.approvalId === "string" ? payload.approvalId : "";
|
|
2638
|
-
if (approvalId && Array.isArray(assistantMessage._pendingApprovals)) {
|
|
2639
|
-
assistantMessage._pendingApprovals = assistantMessage._pendingApprovals.filter(
|
|
2640
|
-
(req) => req.approvalId !== approvalId,
|
|
2641
|
-
);
|
|
2642
|
-
}
|
|
2643
|
-
renderIfActiveConversation(true);
|
|
2644
|
-
}
|
|
2645
|
-
if (eventName === "tool:approval:denied") {
|
|
2646
|
-
const toolText = "- approval denied";
|
|
2647
|
-
assistantMessage._currentTools.push(toolText);
|
|
2648
|
-
assistantMessage.metadata.toolActivity.push(toolText);
|
|
2649
|
-
const approvalId =
|
|
2650
|
-
typeof payload.approvalId === "string" ? payload.approvalId : "";
|
|
2651
|
-
if (approvalId && Array.isArray(assistantMessage._pendingApprovals)) {
|
|
2652
|
-
assistantMessage._pendingApprovals = assistantMessage._pendingApprovals.filter(
|
|
2653
|
-
(req) => req.approvalId !== approvalId,
|
|
2654
|
-
);
|
|
2655
|
-
}
|
|
2656
|
-
renderIfActiveConversation(true);
|
|
2657
|
-
}
|
|
2658
|
-
if (eventName === "run:completed") {
|
|
2659
|
-
assistantMessage._activeActivities = [];
|
|
2660
|
-
if (
|
|
2661
|
-
!assistantMessage.content ||
|
|
2662
|
-
assistantMessage.content.length === 0
|
|
2663
|
-
) {
|
|
2664
|
-
assistantMessage.content = String(
|
|
2665
|
-
payload.result?.response || "",
|
|
2666
|
-
);
|
|
2667
|
-
}
|
|
2668
|
-
if (assistantMessage._currentTools.length > 0) {
|
|
2669
|
-
assistantMessage._sections.push({
|
|
2670
|
-
type: "tools",
|
|
2671
|
-
content: assistantMessage._currentTools,
|
|
2672
|
-
});
|
|
2673
|
-
assistantMessage._currentTools = [];
|
|
2674
|
-
}
|
|
2675
|
-
if (assistantMessage._currentText.length > 0) {
|
|
2676
|
-
assistantMessage._sections.push({
|
|
2677
|
-
type: "text",
|
|
2678
|
-
content: assistantMessage._currentText,
|
|
2679
|
-
});
|
|
2680
|
-
assistantMessage._currentText = "";
|
|
2681
|
-
}
|
|
2682
|
-
renderIfActiveConversation(false);
|
|
2683
|
-
}
|
|
2684
|
-
if (eventName === "run:cancelled") {
|
|
2685
|
-
assistantMessage._activeActivities = [];
|
|
2686
|
-
if (assistantMessage._currentTools.length > 0) {
|
|
2687
|
-
assistantMessage._sections.push({
|
|
2688
|
-
type: "tools",
|
|
2689
|
-
content: assistantMessage._currentTools,
|
|
2690
|
-
});
|
|
2691
|
-
assistantMessage._currentTools = [];
|
|
2692
|
-
}
|
|
2693
|
-
if (assistantMessage._currentText.length > 0) {
|
|
2694
|
-
assistantMessage._sections.push({
|
|
2695
|
-
type: "text",
|
|
2696
|
-
content: assistantMessage._currentText,
|
|
2697
|
-
});
|
|
2698
|
-
assistantMessage._currentText = "";
|
|
2699
|
-
}
|
|
2700
|
-
renderIfActiveConversation(false);
|
|
2701
|
-
}
|
|
2702
|
-
if (eventName === "run:error") {
|
|
2703
|
-
assistantMessage._activeActivities = [];
|
|
2704
|
-
if (assistantMessage._currentTools.length > 0) {
|
|
2705
|
-
assistantMessage._sections.push({
|
|
2706
|
-
type: "tools",
|
|
2707
|
-
content: assistantMessage._currentTools,
|
|
2708
|
-
});
|
|
2709
|
-
assistantMessage._currentTools = [];
|
|
2710
|
-
}
|
|
2711
|
-
if (assistantMessage._currentText.length > 0) {
|
|
2712
|
-
assistantMessage._sections.push({
|
|
2713
|
-
type: "text",
|
|
2714
|
-
content: assistantMessage._currentText,
|
|
2715
|
-
});
|
|
2716
|
-
assistantMessage._currentText = "";
|
|
2717
|
-
}
|
|
2718
|
-
const errMsg =
|
|
2719
|
-
payload.error?.message || "Something went wrong";
|
|
2720
|
-
assistantMessage._error = errMsg;
|
|
2721
|
-
renderIfActiveConversation(false);
|
|
2722
|
-
}
|
|
2723
|
-
} catch (error) {
|
|
2724
|
-
console.error("SSE reconnect event error:", eventName, error);
|
|
2725
|
-
}
|
|
2726
|
-
});
|
|
2727
|
-
}
|
|
2728
|
-
};
|
|
2729
|
-
processChunks().finally(() => {
|
|
2730
|
-
if (state.activeConversationId === conversationId) {
|
|
2731
|
-
state.activeMessages = localMessages;
|
|
2732
|
-
}
|
|
2733
|
-
resolve(undefined);
|
|
2734
|
-
});
|
|
2735
|
-
}).catch(() => {
|
|
2736
|
-
resolve(undefined);
|
|
2737
|
-
});
|
|
2738
|
-
});
|
|
2739
|
-
};
|
|
2740
|
-
|
|
2741
|
-
const createConversation = async (title, options = {}) => {
|
|
2742
|
-
const shouldLoadConversation = options.loadConversation !== false;
|
|
2743
|
-
const payload = await api("/api/conversations", {
|
|
2744
|
-
method: "POST",
|
|
2745
|
-
body: JSON.stringify(title ? { title } : {})
|
|
2746
|
-
});
|
|
2747
|
-
state.activeConversationId = payload.conversation.conversationId;
|
|
2748
|
-
state.confirmDeleteId = null;
|
|
2749
|
-
pushConversationUrl(state.activeConversationId);
|
|
2750
|
-
await loadConversations();
|
|
2751
|
-
if (shouldLoadConversation) {
|
|
2752
|
-
await loadConversation(state.activeConversationId);
|
|
2753
|
-
} else {
|
|
2754
|
-
elements.chatTitle.textContent = payload.conversation.title || "New conversation";
|
|
2755
|
-
}
|
|
2756
|
-
return state.activeConversationId;
|
|
2757
|
-
};
|
|
2758
|
-
|
|
2759
|
-
const parseSseChunk = (buffer, onEvent) => {
|
|
2760
|
-
let rest = buffer;
|
|
2761
|
-
while (true) {
|
|
2762
|
-
const index = rest.indexOf("\\n\\n");
|
|
2763
|
-
if (index < 0) {
|
|
2764
|
-
return rest;
|
|
2765
|
-
}
|
|
2766
|
-
const raw = rest.slice(0, index);
|
|
2767
|
-
rest = rest.slice(index + 2);
|
|
2768
|
-
const lines = raw.split("\\n");
|
|
2769
|
-
let eventName = "message";
|
|
2770
|
-
let data = "";
|
|
2771
|
-
for (const line of lines) {
|
|
2772
|
-
if (line.startsWith("event:")) {
|
|
2773
|
-
eventName = line.slice(6).trim();
|
|
2774
|
-
} else if (line.startsWith("data:")) {
|
|
2775
|
-
data += line.slice(5).trim();
|
|
2776
|
-
}
|
|
2777
|
-
}
|
|
2778
|
-
if (data) {
|
|
2779
|
-
try {
|
|
2780
|
-
onEvent(eventName, JSON.parse(data));
|
|
2781
|
-
} catch {}
|
|
2782
|
-
}
|
|
2783
|
-
}
|
|
2784
|
-
};
|
|
2785
|
-
|
|
2786
|
-
const setStreaming = (value) => {
|
|
2787
|
-
state.isStreaming = value;
|
|
2788
|
-
const canStop = value && !!state.activeStreamRunId;
|
|
2789
|
-
elements.send.disabled = value ? !canStop : false;
|
|
2790
|
-
elements.send.innerHTML = value ? stopIconMarkup : sendIconMarkup;
|
|
2791
|
-
elements.send.classList.toggle("stop-mode", value);
|
|
2792
|
-
if (elements.sendBtnWrapper) {
|
|
2793
|
-
elements.sendBtnWrapper.classList.toggle("stop-mode", value);
|
|
2794
|
-
}
|
|
2795
|
-
elements.send.setAttribute("aria-label", value ? "Stop response" : "Send message");
|
|
2796
|
-
elements.send.setAttribute(
|
|
2797
|
-
"title",
|
|
2798
|
-
value ? (canStop ? "Stop response" : "Starting response...") : "Send message",
|
|
2799
|
-
);
|
|
2800
|
-
};
|
|
2801
|
-
|
|
2802
|
-
const pushToolActivity = (assistantMessage, line) => {
|
|
2803
|
-
if (!line) {
|
|
2804
|
-
return;
|
|
2805
|
-
}
|
|
2806
|
-
if (
|
|
2807
|
-
!assistantMessage.metadata ||
|
|
2808
|
-
!Array.isArray(assistantMessage.metadata.toolActivity)
|
|
2809
|
-
) {
|
|
2810
|
-
assistantMessage.metadata = {
|
|
2811
|
-
...(assistantMessage.metadata || {}),
|
|
2812
|
-
toolActivity: [],
|
|
2813
|
-
};
|
|
2814
|
-
}
|
|
2815
|
-
assistantMessage.metadata.toolActivity.push(line);
|
|
2816
|
-
};
|
|
2817
|
-
|
|
2818
|
-
const ensureActiveActivities = (assistantMessage) => {
|
|
2819
|
-
if (!Array.isArray(assistantMessage._activeActivities)) {
|
|
2820
|
-
assistantMessage._activeActivities = [];
|
|
2821
|
-
}
|
|
2822
|
-
return assistantMessage._activeActivities;
|
|
2823
|
-
};
|
|
2824
|
-
|
|
2825
|
-
const getStringInputField = (input, key) => {
|
|
2826
|
-
if (!input || typeof input !== "object") {
|
|
2827
|
-
return "";
|
|
2828
|
-
}
|
|
2829
|
-
const value = input[key];
|
|
2830
|
-
return typeof value === "string" ? value.trim() : "";
|
|
2831
|
-
};
|
|
2832
|
-
|
|
2833
|
-
const describeToolStart = (payload) => {
|
|
2834
|
-
const toolName = payload && typeof payload.tool === "string" ? payload.tool : "tool";
|
|
2835
|
-
const input = payload && payload.input && typeof payload.input === "object" ? payload.input : {};
|
|
2836
|
-
|
|
2837
|
-
if (toolName === "activate_skill") {
|
|
2838
|
-
const skillName = getStringInputField(input, "name") || "skill";
|
|
2839
|
-
return {
|
|
2840
|
-
kind: "skill",
|
|
2841
|
-
tool: toolName,
|
|
2842
|
-
label: "Activating " + skillName + " skill",
|
|
2843
|
-
detail: "skill: " + skillName,
|
|
2844
|
-
};
|
|
2845
|
-
}
|
|
2846
|
-
|
|
2847
|
-
if (toolName === "run_skill_script") {
|
|
2848
|
-
const scriptPath = getStringInputField(input, "script");
|
|
2849
|
-
const skillName = getStringInputField(input, "skill");
|
|
2850
|
-
if (scriptPath && skillName) {
|
|
2851
|
-
return {
|
|
2852
|
-
kind: "tool",
|
|
2853
|
-
tool: toolName,
|
|
2854
|
-
label: "Running script " + scriptPath + " in " + skillName + " skill",
|
|
2855
|
-
detail: "script: " + scriptPath + ", skill: " + skillName,
|
|
2856
|
-
};
|
|
2857
|
-
}
|
|
2858
|
-
if (scriptPath) {
|
|
2859
|
-
return {
|
|
2860
|
-
kind: "tool",
|
|
2861
|
-
tool: toolName,
|
|
2862
|
-
label: "Running script " + scriptPath,
|
|
2863
|
-
detail: "script: " + scriptPath,
|
|
2864
|
-
};
|
|
2865
|
-
}
|
|
2866
|
-
}
|
|
2867
|
-
|
|
2868
|
-
if (toolName === "read_skill_resource") {
|
|
2869
|
-
const resourcePath = getStringInputField(input, "path");
|
|
2870
|
-
const skillName = getStringInputField(input, "skill");
|
|
2871
|
-
if (resourcePath && skillName) {
|
|
2872
|
-
return {
|
|
2873
|
-
kind: "tool",
|
|
2874
|
-
tool: toolName,
|
|
2875
|
-
label: "Reading " + resourcePath + " from " + skillName + " skill",
|
|
2876
|
-
detail: "path: " + resourcePath + ", skill: " + skillName,
|
|
2877
|
-
};
|
|
2878
|
-
}
|
|
2879
|
-
if (resourcePath) {
|
|
2880
|
-
return {
|
|
2881
|
-
kind: "tool",
|
|
2882
|
-
tool: toolName,
|
|
2883
|
-
label: "Reading " + resourcePath,
|
|
2884
|
-
detail: "path: " + resourcePath,
|
|
2885
|
-
};
|
|
2886
|
-
}
|
|
2887
|
-
}
|
|
2888
|
-
|
|
2889
|
-
if (toolName === "read_file") {
|
|
2890
|
-
const path = getStringInputField(input, "path");
|
|
2891
|
-
if (path) {
|
|
2892
|
-
return {
|
|
2893
|
-
kind: "tool",
|
|
2894
|
-
tool: toolName,
|
|
2895
|
-
label: "Reading " + path,
|
|
2896
|
-
detail: "path: " + path,
|
|
2897
|
-
};
|
|
2898
|
-
}
|
|
2899
|
-
}
|
|
2900
|
-
|
|
2901
|
-
return {
|
|
2902
|
-
kind: "tool",
|
|
2903
|
-
tool: toolName,
|
|
2904
|
-
label: "Running " + toolName + " tool",
|
|
2905
|
-
detail: "",
|
|
2906
|
-
};
|
|
2907
|
-
};
|
|
2908
|
-
|
|
2909
|
-
const addActiveActivityFromToolStart = (assistantMessage, payload) => {
|
|
2910
|
-
const activities = ensureActiveActivities(assistantMessage);
|
|
2911
|
-
const activity = describeToolStart(payload);
|
|
2912
|
-
activities.push(activity);
|
|
2913
|
-
return activity;
|
|
2914
|
-
};
|
|
2915
|
-
|
|
2916
|
-
const removeActiveActivityForTool = (assistantMessage, toolName) => {
|
|
2917
|
-
if (!toolName || !Array.isArray(assistantMessage._activeActivities)) {
|
|
2918
|
-
return null;
|
|
2919
|
-
}
|
|
2920
|
-
const activities = assistantMessage._activeActivities;
|
|
2921
|
-
const idx = activities.findIndex((item) => item && item.tool === toolName);
|
|
2922
|
-
if (idx >= 0) {
|
|
2923
|
-
return activities.splice(idx, 1)[0] || null;
|
|
2924
|
-
}
|
|
2925
|
-
return null;
|
|
2926
|
-
};
|
|
2927
|
-
|
|
2928
|
-
const getThinkingStatusLabel = (assistantMessage) => {
|
|
2929
|
-
const activities = Array.isArray(assistantMessage?._activeActivities)
|
|
2930
|
-
? assistantMessage._activeActivities
|
|
2931
|
-
: [];
|
|
2932
|
-
const labels = [];
|
|
2933
|
-
activities.forEach((item) => {
|
|
2934
|
-
if (!item || typeof item.label !== "string") {
|
|
2935
|
-
return;
|
|
2936
|
-
}
|
|
2937
|
-
const label = item.label.trim();
|
|
2938
|
-
if (!label || labels.includes(label)) {
|
|
2939
|
-
return;
|
|
2940
|
-
}
|
|
2941
|
-
labels.push(label);
|
|
2942
|
-
});
|
|
2943
|
-
if (labels.length === 1) {
|
|
2944
|
-
return labels[0];
|
|
2945
|
-
}
|
|
2946
|
-
if (labels.length === 2) {
|
|
2947
|
-
return labels[0] + ", " + labels[1];
|
|
2948
|
-
}
|
|
2949
|
-
if (labels.length > 2) {
|
|
2950
|
-
return labels[0] + ", " + labels[1] + " +" + (labels.length - 2) + " more";
|
|
2951
|
-
}
|
|
2952
|
-
|
|
2953
|
-
if (Array.isArray(assistantMessage?._currentTools)) {
|
|
2954
|
-
const tick = String.fromCharCode(96);
|
|
2955
|
-
const startPrefix = "- start " + tick;
|
|
2956
|
-
for (let idx = assistantMessage._currentTools.length - 1; idx >= 0; idx -= 1) {
|
|
2957
|
-
const item = String(assistantMessage._currentTools[idx] || "");
|
|
2958
|
-
if (item.startsWith(startPrefix)) {
|
|
2959
|
-
const rest = item.slice(startPrefix.length);
|
|
2960
|
-
const endIdx = rest.indexOf(tick);
|
|
2961
|
-
const toolName = (endIdx >= 0 ? rest.slice(0, endIdx) : rest).trim();
|
|
2962
|
-
if (toolName) {
|
|
2963
|
-
return "Running " + toolName + " tool";
|
|
2964
|
-
}
|
|
2965
|
-
}
|
|
2966
|
-
}
|
|
2967
|
-
}
|
|
2968
|
-
return "";
|
|
2969
|
-
};
|
|
2970
|
-
|
|
2971
|
-
const autoResizePrompt = () => {
|
|
2972
|
-
const el = elements.prompt;
|
|
2973
|
-
el.style.height = "auto";
|
|
2974
|
-
const scrollHeight = el.scrollHeight;
|
|
2975
|
-
const nextHeight = Math.min(scrollHeight, 200);
|
|
2976
|
-
el.style.height = nextHeight + "px";
|
|
2977
|
-
el.style.overflowY = scrollHeight > 200 ? "auto" : "hidden";
|
|
2978
|
-
};
|
|
2979
|
-
|
|
2980
|
-
const stopActiveRun = async () => {
|
|
2981
|
-
const stopRunId = state.activeStreamRunId;
|
|
2982
|
-
if (!stopRunId) return;
|
|
2983
|
-
const conversationId = state.activeStreamConversationId || state.activeConversationId;
|
|
2984
|
-
if (!conversationId) return;
|
|
2985
|
-
// Disable the stop button immediately so the user sees feedback.
|
|
2986
|
-
state.activeStreamRunId = null;
|
|
2987
|
-
setStreaming(state.isStreaming);
|
|
2988
|
-
// Signal the server to cancel the run. The server will emit
|
|
2989
|
-
// run:cancelled through the still-open SSE stream, which
|
|
2990
|
-
// sendMessage() processes naturally – the stream ends on its own
|
|
2991
|
-
// and cleanup happens in one finally block. No fetch abort needed.
|
|
2992
|
-
try {
|
|
2993
|
-
await api(
|
|
2994
|
-
"/api/conversations/" + encodeURIComponent(conversationId) + "/stop",
|
|
2995
|
-
{
|
|
2996
|
-
method: "POST",
|
|
2997
|
-
body: JSON.stringify({ runId: stopRunId }),
|
|
2998
|
-
},
|
|
2999
|
-
);
|
|
3000
|
-
} catch (e) {
|
|
3001
|
-
console.warn("Failed to stop conversation run:", e);
|
|
3002
|
-
// Fallback: abort the local fetch so the UI at least stops.
|
|
3003
|
-
const abortController = state.activeStreamAbortController;
|
|
3004
|
-
if (abortController && !abortController.signal.aborted) {
|
|
3005
|
-
abortController.abort();
|
|
3006
|
-
}
|
|
3007
|
-
}
|
|
3008
|
-
};
|
|
3009
|
-
|
|
3010
|
-
const renderAttachmentPreview = () => {
|
|
3011
|
-
const el = elements.attachmentPreview;
|
|
3012
|
-
if (state.pendingFiles.length === 0) {
|
|
3013
|
-
el.style.display = "none";
|
|
3014
|
-
el.innerHTML = "";
|
|
3015
|
-
return;
|
|
3016
|
-
}
|
|
3017
|
-
el.style.display = "flex";
|
|
3018
|
-
el.innerHTML = state.pendingFiles.map((f, i) => {
|
|
3019
|
-
const isImage = f.type.startsWith("image/");
|
|
3020
|
-
const thumbHtml = isImage
|
|
3021
|
-
? '<img src="' + URL.createObjectURL(f) + '" alt="" />'
|
|
3022
|
-
: '<span class="file-icon">📎</span>';
|
|
3023
|
-
return '<div class="attachment-chip" data-idx="' + i + '">'
|
|
3024
|
-
+ thumbHtml
|
|
3025
|
-
+ '<span class="filename">' + escapeHtml(f.name) + '</span>'
|
|
3026
|
-
+ '<span class="remove-attachment" data-idx="' + i + '">×</span>'
|
|
3027
|
-
+ '</div>';
|
|
3028
|
-
}).join("");
|
|
3029
|
-
};
|
|
3030
|
-
|
|
3031
|
-
const addFiles = (fileList) => {
|
|
3032
|
-
for (const f of fileList) {
|
|
3033
|
-
if (f.size > 25 * 1024 * 1024) {
|
|
3034
|
-
alert("File too large: " + f.name + " (max 25MB)");
|
|
3035
|
-
continue;
|
|
3036
|
-
}
|
|
3037
|
-
state.pendingFiles.push(f);
|
|
3038
|
-
}
|
|
3039
|
-
renderAttachmentPreview();
|
|
3040
|
-
};
|
|
3041
|
-
|
|
3042
|
-
const sendMessage = async (text) => {
|
|
3043
|
-
const messageText = (text || "").trim();
|
|
3044
|
-
if (!messageText || state.isStreaming) {
|
|
3045
|
-
return;
|
|
3046
|
-
}
|
|
3047
|
-
const filesToSend = [...state.pendingFiles];
|
|
3048
|
-
state.pendingFiles = [];
|
|
3049
|
-
renderAttachmentPreview();
|
|
3050
|
-
let userContent;
|
|
3051
|
-
if (filesToSend.length > 0) {
|
|
3052
|
-
userContent = [{ type: "text", text: messageText }];
|
|
3053
|
-
for (const f of filesToSend) {
|
|
3054
|
-
userContent.push({
|
|
3055
|
-
type: "file",
|
|
3056
|
-
data: URL.createObjectURL(f),
|
|
3057
|
-
mediaType: f.type,
|
|
3058
|
-
filename: f.name,
|
|
3059
|
-
_localBlob: f,
|
|
3060
|
-
});
|
|
3061
|
-
}
|
|
3062
|
-
} else {
|
|
3063
|
-
userContent = messageText;
|
|
3064
|
-
}
|
|
3065
|
-
const localMessages = [...(state.activeMessages || []), { role: "user", content: userContent }];
|
|
3066
|
-
let assistantMessage = {
|
|
3067
|
-
role: "assistant",
|
|
3068
|
-
content: "",
|
|
3069
|
-
_sections: [], // Array of {type: 'text'|'tools', content: string|array}
|
|
3070
|
-
_currentText: "",
|
|
3071
|
-
_currentTools: [],
|
|
3072
|
-
_activeActivities: [],
|
|
3073
|
-
_pendingApprovals: [],
|
|
3074
|
-
metadata: { toolActivity: [] }
|
|
3075
|
-
};
|
|
3076
|
-
localMessages.push(assistantMessage);
|
|
3077
|
-
state.activeMessages = localMessages;
|
|
3078
|
-
renderMessages(localMessages, true, { forceScrollBottom: true });
|
|
3079
|
-
let conversationId = state.activeConversationId;
|
|
3080
|
-
const streamAbortController = new AbortController();
|
|
3081
|
-
state.activeStreamAbortController = streamAbortController;
|
|
3082
|
-
state.activeStreamRunId = null;
|
|
3083
|
-
setStreaming(true);
|
|
3084
|
-
try {
|
|
3085
|
-
if (!conversationId) {
|
|
3086
|
-
conversationId = await createConversation(messageText, { loadConversation: false });
|
|
3087
|
-
}
|
|
3088
|
-
state.activeStreamConversationId = conversationId;
|
|
3089
|
-
const streamConversationId = conversationId;
|
|
3090
|
-
const renderIfActiveConversation = (streaming) => {
|
|
3091
|
-
if (state.activeConversationId !== streamConversationId) {
|
|
3092
|
-
return;
|
|
3093
|
-
}
|
|
3094
|
-
state.activeMessages = localMessages;
|
|
3095
|
-
renderMessages(localMessages, streaming);
|
|
3096
|
-
};
|
|
3097
|
-
const finalizeAssistantMessage = () => {
|
|
3098
|
-
assistantMessage._activeActivities = [];
|
|
3099
|
-
if (assistantMessage._currentTools.length > 0) {
|
|
3100
|
-
assistantMessage._sections.push({ type: "tools", content: assistantMessage._currentTools });
|
|
3101
|
-
assistantMessage._currentTools = [];
|
|
3102
|
-
}
|
|
3103
|
-
if (assistantMessage._currentText.length > 0) {
|
|
3104
|
-
assistantMessage._sections.push({ type: "text", content: assistantMessage._currentText });
|
|
3105
|
-
assistantMessage._currentText = "";
|
|
3106
|
-
}
|
|
3107
|
-
};
|
|
3108
|
-
let _continuationMessage = messageText;
|
|
3109
|
-
let _totalSteps = 0;
|
|
3110
|
-
let _maxSteps = 0;
|
|
3111
|
-
while (_continuationMessage) {
|
|
3112
|
-
let _shouldContinue = false;
|
|
3113
|
-
let fetchOpts;
|
|
3114
|
-
if (filesToSend.length > 0 && _continuationMessage === messageText) {
|
|
3115
|
-
const formData = new FormData();
|
|
3116
|
-
formData.append("message", _continuationMessage);
|
|
3117
|
-
for (const f of filesToSend) {
|
|
3118
|
-
formData.append("files", f, f.name);
|
|
3119
|
-
}
|
|
3120
|
-
fetchOpts = {
|
|
3121
|
-
method: "POST",
|
|
3122
|
-
credentials: "include",
|
|
3123
|
-
headers: { "x-csrf-token": state.csrfToken },
|
|
3124
|
-
body: formData,
|
|
3125
|
-
signal: streamAbortController.signal,
|
|
3126
|
-
};
|
|
3127
|
-
} else {
|
|
3128
|
-
fetchOpts = {
|
|
3129
|
-
method: "POST",
|
|
3130
|
-
credentials: "include",
|
|
3131
|
-
headers: { "Content-Type": "application/json", "x-csrf-token": state.csrfToken },
|
|
3132
|
-
body: JSON.stringify({ message: _continuationMessage }),
|
|
3133
|
-
signal: streamAbortController.signal,
|
|
3134
|
-
};
|
|
3135
|
-
}
|
|
3136
|
-
const response = await fetch(
|
|
3137
|
-
"/api/conversations/" + encodeURIComponent(conversationId) + "/messages",
|
|
3138
|
-
fetchOpts,
|
|
3139
|
-
);
|
|
3140
|
-
if (!response.ok || !response.body) {
|
|
3141
|
-
throw new Error("Failed to stream response");
|
|
3142
|
-
}
|
|
3143
|
-
const reader = response.body.getReader();
|
|
3144
|
-
const decoder = new TextDecoder();
|
|
3145
|
-
let buffer = "";
|
|
3146
|
-
while (true) {
|
|
3147
|
-
const { value, done } = await reader.read();
|
|
3148
|
-
if (done) {
|
|
3149
|
-
break;
|
|
3150
|
-
}
|
|
3151
|
-
buffer += decoder.decode(value, { stream: true });
|
|
3152
|
-
buffer = parseSseChunk(buffer, (eventName, payload) => {
|
|
3153
|
-
try {
|
|
3154
|
-
if (eventName === "model:chunk") {
|
|
3155
|
-
const chunk = String(payload.content || "");
|
|
3156
|
-
// If we have tools accumulated and text starts again, push tools as a section
|
|
3157
|
-
if (assistantMessage._currentTools.length > 0 && chunk.length > 0) {
|
|
3158
|
-
assistantMessage._sections.push({ type: "tools", content: assistantMessage._currentTools });
|
|
3159
|
-
assistantMessage._currentTools = [];
|
|
3160
|
-
}
|
|
3161
|
-
assistantMessage.content += chunk;
|
|
3162
|
-
assistantMessage._currentText += chunk;
|
|
3163
|
-
renderIfActiveConversation(true);
|
|
3164
|
-
}
|
|
3165
|
-
if (eventName === "run:started") {
|
|
3166
|
-
state.activeStreamRunId = typeof payload.runId === "string" ? payload.runId : null;
|
|
3167
|
-
if (typeof payload.contextWindow === "number" && payload.contextWindow > 0) {
|
|
3168
|
-
state.contextWindow = payload.contextWindow;
|
|
3169
|
-
}
|
|
3170
|
-
setStreaming(state.isStreaming);
|
|
3171
|
-
}
|
|
3172
|
-
if (eventName === "model:response") {
|
|
3173
|
-
if (typeof payload.usage?.input === "number") {
|
|
3174
|
-
state.contextTokens = payload.usage.input;
|
|
3175
|
-
updateContextRing();
|
|
3176
|
-
}
|
|
3177
|
-
}
|
|
3178
|
-
if (eventName === "tool:started") {
|
|
3179
|
-
const toolName = payload.tool || "tool";
|
|
3180
|
-
const startedActivity = addActiveActivityFromToolStart(
|
|
3181
|
-
assistantMessage,
|
|
3182
|
-
payload,
|
|
3183
|
-
);
|
|
3184
|
-
// If we have text accumulated, push it as a text section
|
|
3185
|
-
if (assistantMessage._currentText.length > 0) {
|
|
3186
|
-
assistantMessage._sections.push({ type: "text", content: assistantMessage._currentText });
|
|
3187
|
-
assistantMessage._currentText = "";
|
|
3188
|
-
}
|
|
3189
|
-
const detail =
|
|
3190
|
-
startedActivity && typeof startedActivity.detail === "string"
|
|
3191
|
-
? startedActivity.detail.trim()
|
|
3192
|
-
: "";
|
|
3193
|
-
const toolText =
|
|
3194
|
-
"- start \\x60" + toolName + "\\x60" + (detail ? " (" + detail + ")" : "");
|
|
3195
|
-
assistantMessage._currentTools.push(toolText);
|
|
3196
|
-
if (!assistantMessage.metadata) assistantMessage.metadata = {};
|
|
3197
|
-
if (!assistantMessage.metadata.toolActivity) assistantMessage.metadata.toolActivity = [];
|
|
3198
|
-
assistantMessage.metadata.toolActivity.push(toolText);
|
|
3199
|
-
renderIfActiveConversation(true);
|
|
3200
|
-
}
|
|
3201
|
-
if (eventName === "tool:completed") {
|
|
3202
|
-
const toolName = payload.tool || "tool";
|
|
3203
|
-
const activeActivity = removeActiveActivityForTool(
|
|
3204
|
-
assistantMessage,
|
|
3205
|
-
toolName,
|
|
3206
|
-
);
|
|
3207
|
-
const duration = typeof payload.duration === "number" ? payload.duration : null;
|
|
3208
|
-
const detail =
|
|
3209
|
-
activeActivity && typeof activeActivity.detail === "string"
|
|
3210
|
-
? activeActivity.detail.trim()
|
|
3211
|
-
: "";
|
|
3212
|
-
const meta = [];
|
|
3213
|
-
if (duration !== null) meta.push(duration + "ms");
|
|
3214
|
-
if (detail) meta.push(detail);
|
|
3215
|
-
const toolText =
|
|
3216
|
-
"- done \\x60" + toolName + "\\x60" + (meta.length > 0 ? " (" + meta.join(", ") + ")" : "");
|
|
3217
|
-
assistantMessage._currentTools.push(toolText);
|
|
3218
|
-
if (!assistantMessage.metadata) assistantMessage.metadata = {};
|
|
3219
|
-
if (!assistantMessage.metadata.toolActivity) assistantMessage.metadata.toolActivity = [];
|
|
3220
|
-
assistantMessage.metadata.toolActivity.push(toolText);
|
|
3221
|
-
renderIfActiveConversation(true);
|
|
3222
|
-
}
|
|
3223
|
-
if (eventName === "tool:error") {
|
|
3224
|
-
const toolName = payload.tool || "tool";
|
|
3225
|
-
const activeActivity = removeActiveActivityForTool(
|
|
3226
|
-
assistantMessage,
|
|
3227
|
-
toolName,
|
|
3228
|
-
);
|
|
3229
|
-
const errorMsg = payload.error || "unknown error";
|
|
3230
|
-
const detail =
|
|
3231
|
-
activeActivity && typeof activeActivity.detail === "string"
|
|
3232
|
-
? activeActivity.detail.trim()
|
|
3233
|
-
: "";
|
|
3234
|
-
const toolText =
|
|
3235
|
-
"- error \\x60" +
|
|
3236
|
-
toolName +
|
|
3237
|
-
"\\x60" +
|
|
3238
|
-
(detail ? " (" + detail + ")" : "") +
|
|
3239
|
-
": " +
|
|
3240
|
-
errorMsg;
|
|
3241
|
-
assistantMessage._currentTools.push(toolText);
|
|
3242
|
-
if (!assistantMessage.metadata) assistantMessage.metadata = {};
|
|
3243
|
-
if (!assistantMessage.metadata.toolActivity) assistantMessage.metadata.toolActivity = [];
|
|
3244
|
-
assistantMessage.metadata.toolActivity.push(toolText);
|
|
3245
|
-
renderIfActiveConversation(true);
|
|
3246
|
-
}
|
|
3247
|
-
if (eventName === "tool:approval:required") {
|
|
3248
|
-
const toolName = payload.tool || "tool";
|
|
3249
|
-
const activeActivity = removeActiveActivityForTool(
|
|
3250
|
-
assistantMessage,
|
|
3251
|
-
toolName,
|
|
3252
|
-
);
|
|
3253
|
-
const detailFromPayload = describeToolStart(payload);
|
|
3254
|
-
const detail =
|
|
3255
|
-
(activeActivity && typeof activeActivity.detail === "string"
|
|
3256
|
-
? activeActivity.detail.trim()
|
|
3257
|
-
: "") ||
|
|
3258
|
-
(detailFromPayload && typeof detailFromPayload.detail === "string"
|
|
3259
|
-
? detailFromPayload.detail.trim()
|
|
3260
|
-
: "");
|
|
3261
|
-
const toolText =
|
|
3262
|
-
"- approval required \\x60" +
|
|
3263
|
-
toolName +
|
|
3264
|
-
"\\x60" +
|
|
3265
|
-
(detail ? " (" + detail + ")" : "");
|
|
3266
|
-
assistantMessage._currentTools.push(toolText);
|
|
3267
|
-
if (!assistantMessage.metadata) assistantMessage.metadata = {};
|
|
3268
|
-
if (!assistantMessage.metadata.toolActivity) assistantMessage.metadata.toolActivity = [];
|
|
3269
|
-
assistantMessage.metadata.toolActivity.push(toolText);
|
|
3270
|
-
const approvalId =
|
|
3271
|
-
typeof payload.approvalId === "string" ? payload.approvalId : "";
|
|
3272
|
-
if (approvalId) {
|
|
3273
|
-
if (!Array.isArray(assistantMessage._pendingApprovals)) {
|
|
3274
|
-
assistantMessage._pendingApprovals = [];
|
|
3275
|
-
}
|
|
3276
|
-
const exists = assistantMessage._pendingApprovals.some(
|
|
3277
|
-
(req) => req.approvalId === approvalId,
|
|
3278
|
-
);
|
|
3279
|
-
if (!exists) {
|
|
3280
|
-
assistantMessage._pendingApprovals.push({
|
|
3281
|
-
approvalId,
|
|
3282
|
-
tool: toolName,
|
|
3283
|
-
input: payload.input ?? {},
|
|
3284
|
-
state: "pending",
|
|
3285
|
-
});
|
|
3286
|
-
}
|
|
3287
|
-
}
|
|
3288
|
-
renderIfActiveConversation(true);
|
|
3289
|
-
}
|
|
3290
|
-
if (eventName === "tool:approval:granted") {
|
|
3291
|
-
const toolText = "- approval granted";
|
|
3292
|
-
assistantMessage._currentTools.push(toolText);
|
|
3293
|
-
if (!assistantMessage.metadata) assistantMessage.metadata = {};
|
|
3294
|
-
if (!assistantMessage.metadata.toolActivity) assistantMessage.metadata.toolActivity = [];
|
|
3295
|
-
assistantMessage.metadata.toolActivity.push(toolText);
|
|
3296
|
-
const approvalId =
|
|
3297
|
-
typeof payload.approvalId === "string" ? payload.approvalId : "";
|
|
3298
|
-
if (approvalId && Array.isArray(assistantMessage._pendingApprovals)) {
|
|
3299
|
-
assistantMessage._pendingApprovals = assistantMessage._pendingApprovals.filter(
|
|
3300
|
-
(req) => req.approvalId !== approvalId,
|
|
3301
|
-
);
|
|
3302
|
-
}
|
|
3303
|
-
renderIfActiveConversation(true);
|
|
3304
|
-
}
|
|
3305
|
-
if (eventName === "tool:approval:denied") {
|
|
3306
|
-
const toolText = "- approval denied";
|
|
3307
|
-
assistantMessage._currentTools.push(toolText);
|
|
3308
|
-
if (!assistantMessage.metadata) assistantMessage.metadata = {};
|
|
3309
|
-
if (!assistantMessage.metadata.toolActivity) assistantMessage.metadata.toolActivity = [];
|
|
3310
|
-
assistantMessage.metadata.toolActivity.push(toolText);
|
|
3311
|
-
const approvalId =
|
|
3312
|
-
typeof payload.approvalId === "string" ? payload.approvalId : "";
|
|
3313
|
-
if (approvalId && Array.isArray(assistantMessage._pendingApprovals)) {
|
|
3314
|
-
assistantMessage._pendingApprovals = assistantMessage._pendingApprovals.filter(
|
|
3315
|
-
(req) => req.approvalId !== approvalId,
|
|
3316
|
-
);
|
|
3317
|
-
}
|
|
3318
|
-
renderIfActiveConversation(true);
|
|
3319
|
-
}
|
|
3320
|
-
if (eventName === "run:completed") {
|
|
3321
|
-
_totalSteps += typeof payload.result?.steps === "number" ? payload.result.steps : 0;
|
|
3322
|
-
if (typeof payload.result?.maxSteps === "number") _maxSteps = payload.result.maxSteps;
|
|
3323
|
-
if (payload.result?.continuation === true && (_maxSteps <= 0 || _totalSteps < _maxSteps)) {
|
|
3324
|
-
_shouldContinue = true;
|
|
3325
|
-
} else {
|
|
3326
|
-
finalizeAssistantMessage();
|
|
3327
|
-
if (!assistantMessage.content || assistantMessage.content.length === 0) {
|
|
3328
|
-
assistantMessage.content = String(payload.result?.response || "");
|
|
3329
|
-
}
|
|
3330
|
-
renderIfActiveConversation(false);
|
|
3331
|
-
}
|
|
3332
|
-
}
|
|
3333
|
-
if (eventName === "run:cancelled") {
|
|
3334
|
-
finalizeAssistantMessage();
|
|
3335
|
-
renderIfActiveConversation(false);
|
|
3336
|
-
}
|
|
3337
|
-
if (eventName === "run:error") {
|
|
3338
|
-
finalizeAssistantMessage();
|
|
3339
|
-
const errMsg = payload.error?.message || "Something went wrong";
|
|
3340
|
-
assistantMessage._error = errMsg;
|
|
3341
|
-
renderIfActiveConversation(false);
|
|
3342
|
-
}
|
|
3343
|
-
} catch (error) {
|
|
3344
|
-
console.error("SSE event handling error:", eventName, error);
|
|
3345
|
-
}
|
|
3346
|
-
});
|
|
3347
|
-
}
|
|
3348
|
-
if (!_shouldContinue) break;
|
|
3349
|
-
_continuationMessage = "Continue";
|
|
3350
|
-
}
|
|
3351
|
-
// Update active state only if user is still on this conversation.
|
|
3352
|
-
if (state.activeConversationId === streamConversationId) {
|
|
3353
|
-
state.activeMessages = localMessages;
|
|
3354
|
-
}
|
|
3355
|
-
await loadConversations();
|
|
3356
|
-
// Don't reload the conversation - we already have the latest state with tool chips
|
|
3357
|
-
} catch (error) {
|
|
3358
|
-
if (streamAbortController.signal.aborted) {
|
|
3359
|
-
assistantMessage._activeActivities = [];
|
|
3360
|
-
if (assistantMessage._currentTools.length > 0) {
|
|
3361
|
-
assistantMessage._sections.push({ type: "tools", content: assistantMessage._currentTools });
|
|
3362
|
-
assistantMessage._currentTools = [];
|
|
3363
|
-
}
|
|
3364
|
-
if (assistantMessage._currentText.length > 0) {
|
|
3365
|
-
assistantMessage._sections.push({ type: "text", content: assistantMessage._currentText });
|
|
3366
|
-
assistantMessage._currentText = "";
|
|
3367
|
-
}
|
|
3368
|
-
renderMessages(localMessages, false);
|
|
3369
|
-
} else {
|
|
3370
|
-
assistantMessage._activeActivities = [];
|
|
3371
|
-
assistantMessage._error = error instanceof Error ? error.message : "Something went wrong";
|
|
3372
|
-
renderMessages(localMessages, false);
|
|
3373
|
-
}
|
|
3374
|
-
} finally {
|
|
3375
|
-
if (state.activeStreamAbortController === streamAbortController) {
|
|
3376
|
-
state.activeStreamAbortController = null;
|
|
3377
|
-
}
|
|
3378
|
-
if (state.activeStreamConversationId === conversationId) {
|
|
3379
|
-
state.activeStreamConversationId = null;
|
|
3380
|
-
}
|
|
3381
|
-
state.activeStreamRunId = null;
|
|
3382
|
-
setStreaming(false);
|
|
3383
|
-
elements.prompt.focus();
|
|
3384
|
-
}
|
|
3385
|
-
};
|
|
3386
|
-
|
|
3387
|
-
const requireAuth = async () => {
|
|
3388
|
-
try {
|
|
3389
|
-
const session = await api("/api/auth/session");
|
|
3390
|
-
if (!session.authenticated) {
|
|
3391
|
-
elements.auth.classList.remove("hidden");
|
|
3392
|
-
elements.app.classList.add("hidden");
|
|
3393
|
-
return false;
|
|
3394
|
-
}
|
|
3395
|
-
state.csrfToken = session.csrfToken || "";
|
|
3396
|
-
elements.auth.classList.add("hidden");
|
|
3397
|
-
elements.app.classList.remove("hidden");
|
|
3398
|
-
return true;
|
|
3399
|
-
} catch {
|
|
3400
|
-
elements.auth.classList.remove("hidden");
|
|
3401
|
-
elements.app.classList.add("hidden");
|
|
3402
|
-
return false;
|
|
3403
|
-
}
|
|
3404
|
-
};
|
|
3405
|
-
|
|
3406
|
-
elements.loginForm.addEventListener("submit", async (event) => {
|
|
3407
|
-
event.preventDefault();
|
|
3408
|
-
elements.loginError.textContent = "";
|
|
3409
|
-
try {
|
|
3410
|
-
const result = await api("/api/auth/login", {
|
|
3411
|
-
method: "POST",
|
|
3412
|
-
body: JSON.stringify({ passphrase: elements.passphrase.value || "" })
|
|
3413
|
-
});
|
|
3414
|
-
state.csrfToken = result.csrfToken || "";
|
|
3415
|
-
elements.passphrase.value = "";
|
|
3416
|
-
elements.auth.classList.add("hidden");
|
|
3417
|
-
elements.app.classList.remove("hidden");
|
|
3418
|
-
await loadConversations();
|
|
3419
|
-
const urlConversationId = getConversationIdFromUrl();
|
|
3420
|
-
if (urlConversationId) {
|
|
3421
|
-
state.activeConversationId = urlConversationId;
|
|
3422
|
-
renderConversationList();
|
|
3423
|
-
try {
|
|
3424
|
-
await loadConversation(urlConversationId);
|
|
3425
|
-
} catch {
|
|
3426
|
-
state.activeConversationId = null;
|
|
3427
|
-
state.activeMessages = [];
|
|
3428
|
-
replaceConversationUrl(null);
|
|
3429
|
-
renderMessages([]);
|
|
3430
|
-
renderConversationList();
|
|
3431
|
-
}
|
|
3432
|
-
}
|
|
3433
|
-
} catch (error) {
|
|
3434
|
-
elements.loginError.textContent = error.message || "Login failed";
|
|
3435
|
-
}
|
|
3436
|
-
});
|
|
3437
|
-
|
|
3438
|
-
const startNewChat = () => {
|
|
3439
|
-
state.activeConversationId = null;
|
|
3440
|
-
state.activeMessages = [];
|
|
3441
|
-
state.confirmDeleteId = null;
|
|
3442
|
-
state.contextTokens = 0;
|
|
3443
|
-
state.contextWindow = 0;
|
|
3444
|
-
updateContextRing();
|
|
3445
|
-
pushConversationUrl(null);
|
|
3446
|
-
elements.chatTitle.textContent = "";
|
|
3447
|
-
renderMessages([]);
|
|
3448
|
-
renderConversationList();
|
|
3449
|
-
elements.prompt.focus();
|
|
3450
|
-
if (isMobile()) {
|
|
3451
|
-
setSidebarOpen(false);
|
|
3452
|
-
}
|
|
3453
|
-
};
|
|
3454
|
-
|
|
3455
|
-
elements.newChat.addEventListener("click", startNewChat);
|
|
3456
|
-
elements.topbarNewChat.addEventListener("click", startNewChat);
|
|
3457
|
-
|
|
3458
|
-
elements.prompt.addEventListener("input", () => {
|
|
3459
|
-
autoResizePrompt();
|
|
3460
|
-
});
|
|
3461
|
-
|
|
3462
|
-
elements.prompt.addEventListener("keydown", (event) => {
|
|
3463
|
-
if (event.key === "Enter" && !event.shiftKey) {
|
|
3464
|
-
event.preventDefault();
|
|
3465
|
-
elements.composer.requestSubmit();
|
|
3466
|
-
}
|
|
3467
|
-
});
|
|
3468
|
-
|
|
3469
|
-
elements.sidebarToggle.addEventListener("click", () => {
|
|
3470
|
-
if (isMobile()) setSidebarOpen(!elements.shell.classList.contains("sidebar-open"));
|
|
3471
|
-
});
|
|
3472
|
-
|
|
3473
|
-
elements.sidebarBackdrop.addEventListener("click", () => setSidebarOpen(false));
|
|
3474
|
-
|
|
3475
|
-
elements.logout.addEventListener("click", async () => {
|
|
3476
|
-
await api("/api/auth/logout", { method: "POST" });
|
|
3477
|
-
state.activeConversationId = null;
|
|
3478
|
-
state.activeMessages = [];
|
|
3479
|
-
state.confirmDeleteId = null;
|
|
3480
|
-
state.conversations = [];
|
|
3481
|
-
state.csrfToken = "";
|
|
3482
|
-
state.contextTokens = 0;
|
|
3483
|
-
state.contextWindow = 0;
|
|
3484
|
-
updateContextRing();
|
|
3485
|
-
await requireAuth();
|
|
3486
|
-
});
|
|
3487
|
-
|
|
3488
|
-
elements.composer.addEventListener("submit", async (event) => {
|
|
3489
|
-
event.preventDefault();
|
|
3490
|
-
if (state.isStreaming) {
|
|
3491
|
-
if (!state.activeStreamRunId) {
|
|
3492
|
-
return;
|
|
3493
|
-
}
|
|
3494
|
-
await stopActiveRun();
|
|
3495
|
-
return;
|
|
3496
|
-
}
|
|
3497
|
-
const value = elements.prompt.value;
|
|
3498
|
-
elements.prompt.value = "";
|
|
3499
|
-
autoResizePrompt();
|
|
3500
|
-
await sendMessage(value);
|
|
3501
|
-
});
|
|
3502
|
-
|
|
3503
|
-
elements.attachBtn.addEventListener("click", () => elements.fileInput.click());
|
|
3504
|
-
elements.fileInput.addEventListener("change", () => {
|
|
3505
|
-
if (elements.fileInput.files && elements.fileInput.files.length > 0) {
|
|
3506
|
-
addFiles(elements.fileInput.files);
|
|
3507
|
-
elements.fileInput.value = "";
|
|
3508
|
-
}
|
|
3509
|
-
});
|
|
3510
|
-
elements.attachmentPreview.addEventListener("click", (e) => {
|
|
3511
|
-
const rm = e.target.closest(".remove-attachment");
|
|
3512
|
-
if (rm) {
|
|
3513
|
-
const idx = parseInt(rm.dataset.idx, 10);
|
|
3514
|
-
state.pendingFiles.splice(idx, 1);
|
|
3515
|
-
renderAttachmentPreview();
|
|
3516
|
-
}
|
|
3517
|
-
});
|
|
3518
|
-
|
|
3519
|
-
let dragCounter = 0;
|
|
3520
|
-
document.addEventListener("dragenter", (e) => {
|
|
3521
|
-
e.preventDefault();
|
|
3522
|
-
dragCounter++;
|
|
3523
|
-
if (dragCounter === 1) elements.dragOverlay.classList.add("active");
|
|
3524
|
-
});
|
|
3525
|
-
document.addEventListener("dragleave", (e) => {
|
|
3526
|
-
e.preventDefault();
|
|
3527
|
-
dragCounter--;
|
|
3528
|
-
if (dragCounter <= 0) { dragCounter = 0; elements.dragOverlay.classList.remove("active"); }
|
|
3529
|
-
});
|
|
3530
|
-
document.addEventListener("dragover", (e) => e.preventDefault());
|
|
3531
|
-
document.addEventListener("drop", (e) => {
|
|
3532
|
-
e.preventDefault();
|
|
3533
|
-
dragCounter = 0;
|
|
3534
|
-
elements.dragOverlay.classList.remove("active");
|
|
3535
|
-
if (e.dataTransfer && e.dataTransfer.files.length > 0) {
|
|
3536
|
-
addFiles(e.dataTransfer.files);
|
|
3537
|
-
}
|
|
3538
|
-
});
|
|
3539
|
-
|
|
3540
|
-
// Paste files/images from clipboard
|
|
3541
|
-
elements.prompt.addEventListener("paste", (e) => {
|
|
3542
|
-
const items = e.clipboardData && e.clipboardData.items;
|
|
3543
|
-
if (!items) return;
|
|
3544
|
-
const files = [];
|
|
3545
|
-
for (let i = 0; i < items.length; i++) {
|
|
3546
|
-
if (items[i].kind === "file") {
|
|
3547
|
-
const f = items[i].getAsFile();
|
|
3548
|
-
if (f) files.push(f);
|
|
3549
|
-
}
|
|
3550
|
-
}
|
|
3551
|
-
if (files.length > 0) {
|
|
3552
|
-
e.preventDefault();
|
|
3553
|
-
addFiles(files);
|
|
3554
|
-
}
|
|
3555
|
-
});
|
|
3556
|
-
|
|
3557
|
-
// Lightbox: open/close helpers
|
|
3558
|
-
const lightboxImg = elements.lightbox.querySelector("img");
|
|
3559
|
-
const openLightbox = (src) => {
|
|
3560
|
-
lightboxImg.src = src;
|
|
3561
|
-
elements.lightbox.style.display = "flex";
|
|
3562
|
-
requestAnimationFrame(() => {
|
|
3563
|
-
requestAnimationFrame(() => elements.lightbox.classList.add("active"));
|
|
3564
|
-
});
|
|
3565
|
-
};
|
|
3566
|
-
const closeLightbox = () => {
|
|
3567
|
-
elements.lightbox.classList.remove("active");
|
|
3568
|
-
elements.lightbox.addEventListener("transitionend", function handler() {
|
|
3569
|
-
elements.lightbox.removeEventListener("transitionend", handler);
|
|
3570
|
-
elements.lightbox.style.display = "none";
|
|
3571
|
-
lightboxImg.src = "";
|
|
3572
|
-
});
|
|
3573
|
-
};
|
|
3574
|
-
elements.lightbox.addEventListener("click", closeLightbox);
|
|
3575
|
-
document.addEventListener("keydown", (e) => {
|
|
3576
|
-
if (e.key === "Escape" && elements.lightbox.style.display !== "none") closeLightbox();
|
|
3577
|
-
});
|
|
3578
|
-
|
|
3579
|
-
// Lightbox from message images
|
|
3580
|
-
elements.messages.addEventListener("click", (e) => {
|
|
3581
|
-
const img = e.target;
|
|
3582
|
-
if (!(img instanceof HTMLImageElement) || !img.closest(".user-file-attachments")) return;
|
|
3583
|
-
openLightbox(img.src);
|
|
3584
|
-
});
|
|
3585
|
-
|
|
3586
|
-
// Lightbox from attachment preview chips
|
|
3587
|
-
elements.attachmentPreview.addEventListener("click", (e) => {
|
|
3588
|
-
if (e.target.closest(".remove-attachment")) return;
|
|
3589
|
-
const chip = e.target.closest(".attachment-chip");
|
|
3590
|
-
if (!chip) return;
|
|
3591
|
-
const img = chip.querySelector("img");
|
|
3592
|
-
if (!img) return;
|
|
3593
|
-
e.stopPropagation();
|
|
3594
|
-
openLightbox(img.src);
|
|
3595
|
-
});
|
|
3596
|
-
|
|
3597
|
-
elements.messages.addEventListener("click", async (event) => {
|
|
3598
|
-
const target = event.target;
|
|
3599
|
-
if (!(target instanceof Element)) {
|
|
3600
|
-
return;
|
|
3601
|
-
}
|
|
3602
|
-
const button = target.closest(".approval-action-btn");
|
|
3603
|
-
if (!button) {
|
|
3604
|
-
return;
|
|
3605
|
-
}
|
|
3606
|
-
const approvalId = button.getAttribute("data-approval-id") || "";
|
|
3607
|
-
const decision = button.getAttribute("data-approval-decision") || "";
|
|
3608
|
-
if (!approvalId || (decision !== "approve" && decision !== "deny")) {
|
|
3609
|
-
return;
|
|
3610
|
-
}
|
|
3611
|
-
if (state.approvalRequestsInFlight[approvalId]) {
|
|
3612
|
-
return;
|
|
3613
|
-
}
|
|
3614
|
-
state.approvalRequestsInFlight[approvalId] = true;
|
|
3615
|
-
const wasStreaming = state.isStreaming;
|
|
3616
|
-
if (!wasStreaming) {
|
|
3617
|
-
setStreaming(true);
|
|
3618
|
-
}
|
|
3619
|
-
updatePendingApproval(approvalId, (request) => ({
|
|
3620
|
-
...request,
|
|
3621
|
-
state: "submitting",
|
|
3622
|
-
pendingDecision: decision,
|
|
3623
|
-
}));
|
|
3624
|
-
renderMessages(state.activeMessages, state.isStreaming);
|
|
3625
|
-
try {
|
|
3626
|
-
await api("/api/approvals/" + encodeURIComponent(approvalId), {
|
|
3627
|
-
method: "POST",
|
|
3628
|
-
body: JSON.stringify({ approved: decision === "approve" }),
|
|
3629
|
-
});
|
|
3630
|
-
updatePendingApproval(approvalId, () => null);
|
|
3631
|
-
renderMessages(state.activeMessages, state.isStreaming);
|
|
3632
|
-
loadConversations();
|
|
3633
|
-
if (!wasStreaming && state.activeConversationId) {
|
|
3634
|
-
await streamConversationEvents(state.activeConversationId, { liveOnly: true });
|
|
3635
|
-
}
|
|
3636
|
-
} catch (error) {
|
|
3637
|
-
const isStale = error && error.payload && error.payload.code === "APPROVAL_NOT_FOUND";
|
|
3638
|
-
if (isStale) {
|
|
3639
|
-
updatePendingApproval(approvalId, () => null);
|
|
3640
|
-
} else {
|
|
3641
|
-
const errMsg = error instanceof Error ? error.message : String(error);
|
|
3642
|
-
updatePendingApproval(approvalId, (request) => ({
|
|
3643
|
-
...request,
|
|
3644
|
-
state: "pending",
|
|
3645
|
-
pendingDecision: null,
|
|
3646
|
-
_error: errMsg,
|
|
3647
|
-
}));
|
|
3648
|
-
}
|
|
3649
|
-
renderMessages(state.activeMessages, state.isStreaming);
|
|
3650
|
-
} finally {
|
|
3651
|
-
if (!wasStreaming) {
|
|
3652
|
-
setStreaming(false);
|
|
3653
|
-
renderMessages(state.activeMessages, false);
|
|
3654
|
-
}
|
|
3655
|
-
delete state.approvalRequestsInFlight[approvalId];
|
|
3656
|
-
}
|
|
3657
|
-
});
|
|
3658
|
-
|
|
3659
|
-
elements.messages.addEventListener("scroll", () => {
|
|
3660
|
-
state.isMessagesPinnedToBottom = isNearBottom(elements.messages);
|
|
3661
|
-
}, { passive: true });
|
|
3662
|
-
|
|
3663
|
-
document.addEventListener("click", (event) => {
|
|
3664
|
-
if (!(event.target instanceof Node)) {
|
|
3665
|
-
return;
|
|
3666
|
-
}
|
|
3667
|
-
if (!event.target.closest(".conversation-item") && state.confirmDeleteId) {
|
|
3668
|
-
state.confirmDeleteId = null;
|
|
3669
|
-
renderConversationList();
|
|
3670
|
-
}
|
|
3671
|
-
});
|
|
3672
|
-
|
|
3673
|
-
window.addEventListener("resize", () => {
|
|
3674
|
-
setSidebarOpen(false);
|
|
3675
|
-
});
|
|
3676
|
-
|
|
3677
|
-
const navigateToConversation = async (conversationId) => {
|
|
3678
|
-
if (conversationId) {
|
|
3679
|
-
state.activeConversationId = conversationId;
|
|
3680
|
-
renderConversationList();
|
|
3681
|
-
try {
|
|
3682
|
-
await loadConversation(conversationId);
|
|
3683
|
-
} catch {
|
|
3684
|
-
// Conversation not found – fall back to empty state
|
|
3685
|
-
state.activeConversationId = null;
|
|
3686
|
-
state.activeMessages = [];
|
|
3687
|
-
replaceConversationUrl(null);
|
|
3688
|
-
elements.chatTitle.textContent = "";
|
|
3689
|
-
renderMessages([]);
|
|
3690
|
-
renderConversationList();
|
|
3691
|
-
}
|
|
3692
|
-
} else {
|
|
3693
|
-
state.activeConversationId = null;
|
|
3694
|
-
state.activeMessages = [];
|
|
3695
|
-
state.contextTokens = 0;
|
|
3696
|
-
state.contextWindow = 0;
|
|
3697
|
-
updateContextRing();
|
|
3698
|
-
elements.chatTitle.textContent = "";
|
|
3699
|
-
renderMessages([]);
|
|
3700
|
-
renderConversationList();
|
|
3701
|
-
}
|
|
3702
|
-
};
|
|
3703
|
-
|
|
3704
|
-
window.addEventListener("popstate", async () => {
|
|
3705
|
-
if (state.isStreaming) return;
|
|
3706
|
-
const conversationId = getConversationIdFromUrl();
|
|
3707
|
-
await navigateToConversation(conversationId);
|
|
3708
|
-
});
|
|
3709
|
-
|
|
3710
|
-
(async () => {
|
|
3711
|
-
const authenticated = await requireAuth();
|
|
3712
|
-
if (!authenticated) {
|
|
3713
|
-
return;
|
|
3714
|
-
}
|
|
3715
|
-
await loadConversations();
|
|
3716
|
-
const urlConversationId = getConversationIdFromUrl();
|
|
3717
|
-
if (urlConversationId) {
|
|
3718
|
-
state.activeConversationId = urlConversationId;
|
|
3719
|
-
replaceConversationUrl(urlConversationId);
|
|
3720
|
-
renderConversationList();
|
|
3721
|
-
try {
|
|
3722
|
-
await loadConversation(urlConversationId);
|
|
3723
|
-
} catch {
|
|
3724
|
-
// URL pointed to a conversation that no longer exists
|
|
3725
|
-
state.activeConversationId = null;
|
|
3726
|
-
state.activeMessages = [];
|
|
3727
|
-
replaceConversationUrl(null);
|
|
3728
|
-
elements.chatTitle.textContent = "";
|
|
3729
|
-
renderMessages([]);
|
|
3730
|
-
renderConversationList();
|
|
3731
|
-
if (state.conversations.length === 0) {
|
|
3732
|
-
await createConversation();
|
|
3733
|
-
}
|
|
3734
|
-
}
|
|
3735
|
-
} else if (state.conversations.length === 0) {
|
|
3736
|
-
await createConversation();
|
|
3737
|
-
}
|
|
3738
|
-
autoResizePrompt();
|
|
3739
|
-
updateContextRing();
|
|
3740
|
-
elements.prompt.focus();
|
|
3741
|
-
})();
|
|
3742
|
-
|
|
3743
|
-
if ("serviceWorker" in navigator) {
|
|
3744
|
-
navigator.serviceWorker.register("/sw.js").catch(() => {});
|
|
3745
|
-
}
|
|
3746
|
-
|
|
3747
|
-
// Detect iOS standalone mode and add class for CSS targeting
|
|
3748
|
-
if (window.navigator.standalone === true || window.matchMedia("(display-mode: standalone)").matches) {
|
|
3749
|
-
document.documentElement.classList.add("standalone");
|
|
3750
|
-
}
|
|
3751
|
-
|
|
3752
|
-
// iOS viewport and keyboard handling
|
|
3753
|
-
(function() {
|
|
3754
|
-
var shell = document.querySelector(".shell");
|
|
3755
|
-
var pinScroll = function() { if (window.scrollY !== 0) window.scrollTo(0, 0); };
|
|
3756
|
-
|
|
3757
|
-
// Track the "full" height when keyboard is not open
|
|
3758
|
-
var fullHeight = window.innerHeight;
|
|
3759
|
-
|
|
3760
|
-
// Resize shell when iOS keyboard opens/closes
|
|
3761
|
-
var resizeForKeyboard = function() {
|
|
3762
|
-
if (!shell || !window.visualViewport) return;
|
|
3763
|
-
var vvHeight = window.visualViewport.height;
|
|
3764
|
-
|
|
3765
|
-
// Update fullHeight if viewport grew (keyboard closed)
|
|
3766
|
-
if (vvHeight > fullHeight) {
|
|
3767
|
-
fullHeight = vvHeight;
|
|
3768
|
-
}
|
|
3769
|
-
|
|
3770
|
-
// Only apply height override if keyboard appears to be open
|
|
3771
|
-
// (viewport significantly smaller than full height)
|
|
3772
|
-
if (vvHeight < fullHeight - 100) {
|
|
3773
|
-
shell.style.height = vvHeight + "px";
|
|
3774
|
-
} else {
|
|
3775
|
-
// Keyboard closed - remove override, let CSS handle it
|
|
3776
|
-
shell.style.height = "";
|
|
3777
|
-
}
|
|
3778
|
-
pinScroll();
|
|
3779
|
-
};
|
|
3780
|
-
|
|
3781
|
-
if (window.visualViewport) {
|
|
3782
|
-
window.visualViewport.addEventListener("scroll", pinScroll);
|
|
3783
|
-
window.visualViewport.addEventListener("resize", resizeForKeyboard);
|
|
3784
|
-
}
|
|
3785
|
-
document.addEventListener("scroll", pinScroll);
|
|
3786
|
-
|
|
3787
|
-
// Draggable sidebar from left edge (mobile only)
|
|
3788
|
-
(function() {
|
|
3789
|
-
var sidebar = document.querySelector(".sidebar");
|
|
3790
|
-
var backdrop = document.querySelector(".sidebar-backdrop");
|
|
3791
|
-
var shell = document.querySelector(".shell");
|
|
3792
|
-
if (!sidebar || !backdrop || !shell) return;
|
|
3793
|
-
|
|
3794
|
-
var sidebarWidth = 260;
|
|
3795
|
-
var edgeThreshold = 50; // px from left edge to start drag
|
|
3796
|
-
var velocityThreshold = 0.3; // px/ms to trigger open/close
|
|
3797
|
-
|
|
3798
|
-
var dragging = false;
|
|
3799
|
-
var startX = 0;
|
|
3800
|
-
var startY = 0;
|
|
3801
|
-
var currentX = 0;
|
|
3802
|
-
var startTime = 0;
|
|
3803
|
-
var isOpen = false;
|
|
3804
|
-
var directionLocked = false;
|
|
3805
|
-
var isHorizontal = false;
|
|
3806
|
-
|
|
3807
|
-
function getProgress() {
|
|
3808
|
-
// Returns 0 (closed) to 1 (open)
|
|
3809
|
-
if (isOpen) {
|
|
3810
|
-
return Math.max(0, Math.min(1, 1 + currentX / sidebarWidth));
|
|
3811
|
-
} else {
|
|
3812
|
-
return Math.max(0, Math.min(1, currentX / sidebarWidth));
|
|
3813
|
-
}
|
|
3814
|
-
}
|
|
3815
|
-
|
|
3816
|
-
function updatePosition(progress) {
|
|
3817
|
-
var offset = (progress - 1) * sidebarWidth;
|
|
3818
|
-
sidebar.style.transform = "translateX(" + offset + "px)";
|
|
3819
|
-
backdrop.style.opacity = progress;
|
|
3820
|
-
if (progress > 0) {
|
|
3821
|
-
backdrop.style.pointerEvents = "auto";
|
|
3822
|
-
} else {
|
|
3823
|
-
backdrop.style.pointerEvents = "none";
|
|
3824
|
-
}
|
|
3825
|
-
}
|
|
3826
|
-
|
|
3827
|
-
function onTouchStart(e) {
|
|
3828
|
-
if (window.innerWidth > 768) return;
|
|
3829
|
-
|
|
3830
|
-
// Don't intercept touches on interactive elements
|
|
3831
|
-
var target = e.target;
|
|
3832
|
-
if (target.closest("button") || target.closest("a") || target.closest("input") || target.closest("textarea")) {
|
|
3833
|
-
return;
|
|
3834
|
-
}
|
|
3835
|
-
|
|
3836
|
-
var touch = e.touches[0];
|
|
3837
|
-
isOpen = shell.classList.contains("sidebar-open");
|
|
3838
|
-
|
|
3839
|
-
// When sidebar is closed: only respond to edge swipes
|
|
3840
|
-
// When sidebar is open: only respond to backdrop touches (not sidebar content)
|
|
3841
|
-
var fromEdge = touch.clientX < edgeThreshold;
|
|
3842
|
-
var onBackdrop = e.target === backdrop;
|
|
3843
|
-
|
|
3844
|
-
if (!isOpen && !fromEdge) return;
|
|
3845
|
-
if (isOpen && !onBackdrop) return;
|
|
3846
|
-
|
|
3847
|
-
// Prevent Safari back gesture when starting from edge
|
|
3848
|
-
if (fromEdge) {
|
|
3849
|
-
e.preventDefault();
|
|
3850
|
-
}
|
|
3851
|
-
|
|
3852
|
-
startX = touch.clientX;
|
|
3853
|
-
startY = touch.clientY;
|
|
3854
|
-
currentX = 0;
|
|
3855
|
-
startTime = Date.now();
|
|
3856
|
-
directionLocked = false;
|
|
3857
|
-
isHorizontal = false;
|
|
3858
|
-
dragging = true;
|
|
3859
|
-
sidebar.classList.add("dragging");
|
|
3860
|
-
backdrop.classList.add("dragging");
|
|
3861
|
-
}
|
|
3862
|
-
|
|
3863
|
-
function onTouchMove(e) {
|
|
3864
|
-
if (!dragging) return;
|
|
3865
|
-
var touch = e.touches[0];
|
|
3866
|
-
var dx = touch.clientX - startX;
|
|
3867
|
-
var dy = touch.clientY - startY;
|
|
3868
|
-
|
|
3869
|
-
// Lock direction after some movement
|
|
3870
|
-
if (!directionLocked && (Math.abs(dx) > 10 || Math.abs(dy) > 10)) {
|
|
3871
|
-
directionLocked = true;
|
|
3872
|
-
isHorizontal = Math.abs(dx) > Math.abs(dy);
|
|
3873
|
-
if (!isHorizontal) {
|
|
3874
|
-
// Vertical scroll, cancel drag
|
|
3875
|
-
dragging = false;
|
|
3876
|
-
sidebar.classList.remove("dragging");
|
|
3877
|
-
backdrop.classList.remove("dragging");
|
|
3878
|
-
return;
|
|
3879
|
-
}
|
|
3880
|
-
}
|
|
3881
|
-
|
|
3882
|
-
if (!directionLocked) return;
|
|
3883
|
-
|
|
3884
|
-
// Prevent scrolling while dragging sidebar
|
|
3885
|
-
e.preventDefault();
|
|
3886
|
-
|
|
3887
|
-
currentX = dx;
|
|
3888
|
-
updatePosition(getProgress());
|
|
3889
|
-
}
|
|
3890
|
-
|
|
3891
|
-
function onTouchEnd(e) {
|
|
3892
|
-
if (!dragging) return;
|
|
3893
|
-
dragging = false;
|
|
3894
|
-
sidebar.classList.remove("dragging");
|
|
3895
|
-
backdrop.classList.remove("dragging");
|
|
3896
|
-
|
|
3897
|
-
var touch = e.changedTouches[0];
|
|
3898
|
-
var dx = touch.clientX - startX;
|
|
3899
|
-
var dt = Date.now() - startTime;
|
|
3900
|
-
var velocity = dx / dt; // px/ms
|
|
3901
|
-
|
|
3902
|
-
var progress = getProgress();
|
|
3903
|
-
var shouldOpen;
|
|
3904
|
-
|
|
3905
|
-
// Use velocity if fast enough, otherwise use position threshold
|
|
3906
|
-
if (Math.abs(velocity) > velocityThreshold) {
|
|
3907
|
-
shouldOpen = velocity > 0;
|
|
3908
|
-
} else {
|
|
3909
|
-
shouldOpen = progress > 0.5;
|
|
3910
|
-
}
|
|
3911
|
-
|
|
3912
|
-
// Reset inline styles and let CSS handle the animation
|
|
3913
|
-
sidebar.style.transform = "";
|
|
3914
|
-
backdrop.style.opacity = "";
|
|
3915
|
-
backdrop.style.pointerEvents = "";
|
|
3916
|
-
|
|
3917
|
-
if (shouldOpen) {
|
|
3918
|
-
shell.classList.add("sidebar-open");
|
|
3919
|
-
} else {
|
|
3920
|
-
shell.classList.remove("sidebar-open");
|
|
3921
|
-
}
|
|
3922
|
-
}
|
|
3923
|
-
|
|
3924
|
-
document.addEventListener("touchstart", onTouchStart, { passive: false });
|
|
3925
|
-
document.addEventListener("touchmove", onTouchMove, { passive: false });
|
|
3926
|
-
document.addEventListener("touchend", onTouchEnd, { passive: true });
|
|
3927
|
-
document.addEventListener("touchcancel", onTouchEnd, { passive: true });
|
|
3928
|
-
})();
|
|
3929
|
-
|
|
3930
|
-
// Prevent Safari back/forward navigation by manipulating history
|
|
3931
|
-
// This doesn't stop the gesture animation but prevents actual navigation
|
|
3932
|
-
if (window.navigator.standalone || window.matchMedia("(display-mode: standalone)").matches) {
|
|
3933
|
-
history.pushState(null, "", location.href);
|
|
3934
|
-
window.addEventListener("popstate", function() {
|
|
3935
|
-
history.pushState(null, "", location.href);
|
|
3936
|
-
});
|
|
3937
|
-
}
|
|
3938
|
-
|
|
3939
|
-
// Right edge blocker - intercept touch events to prevent forward navigation
|
|
3940
|
-
var rightBlocker = document.querySelector(".edge-blocker-right");
|
|
3941
|
-
if (rightBlocker) {
|
|
3942
|
-
rightBlocker.addEventListener("touchstart", function(e) {
|
|
3943
|
-
e.preventDefault();
|
|
3944
|
-
}, { passive: false });
|
|
3945
|
-
rightBlocker.addEventListener("touchmove", function(e) {
|
|
3946
|
-
e.preventDefault();
|
|
3947
|
-
}, { passive: false });
|
|
3948
|
-
}
|
|
3949
|
-
})();
|
|
3950
|
-
|
|
205
|
+
${getWebUiClientScript(markedSource)}
|
|
3951
206
|
</script>
|
|
3952
207
|
</body>
|
|
3953
208
|
</html>`;
|