@openbmb/clawxrouter 1.0.4

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.
@@ -0,0 +1,3402 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { getGlobalCollector, onTokenUpdate } from "./token-stats.js";
3
+ import { getLiveConfig, updateLiveConfig } from "./live-config.js";
4
+ import { getAllSessionStates, getSessionState, getLastInputEstimate, onDetection, getLoopMeta, clearAllSessionStates } from "./session-state.js";
5
+ import { loadPrompt, readPromptFromDisk, writePrompt } from "./prompt-loader.js";
6
+ import { DEFAULT_JUDGE_PROMPT } from "./routers/token-saver.js";
7
+ import { DEFAULT_DETECTION_SYSTEM_PROMPT, DEFAULT_PII_EXTRACTION_PROMPT } from "./local-model.js";
8
+ import type { RouterPipeline } from "./router-pipeline.js";
9
+ import { createConfigurableRouter } from "./routers/configurable.js";
10
+ import { saveClawXrouterConfig } from "./dashboard-config-io.js";
11
+
12
+ export type DashboardDeps = {
13
+ pluginId: string;
14
+ pluginConfig: Record<string, unknown>;
15
+ pipeline: RouterPipeline | null;
16
+ };
17
+
18
+ let deps: DashboardDeps | null = null;
19
+
20
+ export function initDashboard(d: DashboardDeps): void {
21
+ deps = d;
22
+ }
23
+
24
+ const MAX_BODY_BYTES = 1024 * 1024; // 1 MB
25
+
26
+ function readBody(req: IncomingMessage): Promise<string> {
27
+ return new Promise((resolve, reject) => {
28
+ const chunks: Buffer[] = [];
29
+ let totalBytes = 0;
30
+ req.on("data", (c: Buffer) => {
31
+ totalBytes += c.length;
32
+ if (totalBytes > MAX_BODY_BYTES) {
33
+ req.destroy();
34
+ reject(new Error("Request body too large"));
35
+ return;
36
+ }
37
+ chunks.push(c);
38
+ });
39
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
40
+ req.on("error", reject);
41
+ });
42
+ }
43
+
44
+ function json(res: ServerResponse, data: unknown, status = 200): void {
45
+ res.writeHead(status, { "Content-Type": "application/json" });
46
+ res.end(JSON.stringify(data));
47
+ }
48
+
49
+ function html(res: ServerResponse, body: string): void {
50
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache, no-store, must-revalidate" });
51
+ res.end(body);
52
+ }
53
+
54
+ export async function statsHttpHandler(
55
+ req: IncomingMessage,
56
+ res: ServerResponse,
57
+ ): Promise<boolean> {
58
+ const url = req.url ?? "";
59
+ const reqPath = url.split("?")[0];
60
+ const base = `/plugins/${deps?.pluginId ?? "clawxrouter"}/stats`;
61
+
62
+ if (!reqPath.startsWith(base)) return false;
63
+
64
+ const sub = reqPath.slice(base.length) || "/";
65
+
66
+ if (req.method === "GET" && sub === "/") {
67
+ html(res, dashboardHtml());
68
+ return true;
69
+ }
70
+
71
+ if (req.method === "GET" && sub === "/api/summary") {
72
+ const collector = getGlobalCollector();
73
+ if (!collector) { json(res, { error: "not initialized" }, 503); return true; }
74
+ json(res, collector.getSummary());
75
+ return true;
76
+ }
77
+
78
+ if (req.method === "GET" && sub === "/api/hourly") {
79
+ const collector = getGlobalCollector();
80
+ if (!collector) { json(res, { error: "not initialized" }, 503); return true; }
81
+ json(res, collector.getHourly());
82
+ return true;
83
+ }
84
+
85
+ if (req.method === "GET" && sub === "/api/sessions") {
86
+ const collector = getGlobalCollector();
87
+ if (!collector) { json(res, { error: "not initialized" }, 503); return true; }
88
+ const sessions = collector.getSessionStats().map((s: any) => {
89
+ if (s.loopId) {
90
+ const meta = getLoopMeta(s.loopId);
91
+ if (meta) {
92
+ s.routingTier = meta.routingTier;
93
+ s.routedModel = meta.routedModel;
94
+ s.routerAction = meta.routerAction;
95
+ }
96
+ }
97
+ return s;
98
+ });
99
+ json(res, sessions);
100
+ return true;
101
+ }
102
+
103
+ if (req.method === "POST" && sub === "/api/reset") {
104
+ const collector = getGlobalCollector();
105
+ if (!collector) { json(res, { error: "not initialized" }, 503); return true; }
106
+ await collector.reset();
107
+ clearAllSessionStates();
108
+ json(res, { ok: true });
109
+ return true;
110
+ }
111
+
112
+ if (req.method === "GET" && sub === "/api/detections") {
113
+ const states = getAllSessionStates();
114
+ const events: Array<{
115
+ sessionKey: string;
116
+ level: string;
117
+ checkpoint: string;
118
+ reason?: string;
119
+ timestamp: number;
120
+ routerId?: string;
121
+ action?: string;
122
+ target?: string;
123
+ }> = [];
124
+ states.forEach((state) => {
125
+ for (const d of state.detectionHistory) {
126
+ events.push({
127
+ sessionKey: state.sessionKey,
128
+ level: d.level,
129
+ checkpoint: d.checkpoint,
130
+ reason: d.reason,
131
+ timestamp: d.timestamp,
132
+ routerId: d.routerId,
133
+ action: d.action,
134
+ target: d.target,
135
+ });
136
+ }
137
+ });
138
+ events.sort((a, b) => b.timestamp - a.timestamp);
139
+ json(res, events.slice(0, 500));
140
+ return true;
141
+ }
142
+
143
+ if (req.method === "GET" && sub === "/api/session-detections") {
144
+ const params = new URL(url, "http://localhost").searchParams;
145
+ const key = params.get("key") ?? "";
146
+ const state = getSessionState(key);
147
+ json(res, state?.detectionHistory ?? []);
148
+ return true;
149
+ }
150
+
151
+ if (req.method === "GET" && sub === "/api/session-detections/stream") {
152
+ const params = new URL(url, "http://localhost").searchParams;
153
+ const key = params.get("key") ?? "";
154
+ const filterLoopId = params.get("loopId") ?? "";
155
+ console.log(`[ClawXrouter] SSE session-detections/stream connected for key=${key} loopId=${filterLoopId || "(all)"}`);
156
+ res.writeHead(200, {
157
+ "Content-Type": "text/event-stream",
158
+ "Cache-Control": "no-cache",
159
+ "Connection": "keep-alive",
160
+ "X-Accel-Buffering": "no",
161
+ });
162
+ (res as any).flushHeaders?.();
163
+ const state = getSessionState(key);
164
+ const history = state?.detectionHistory ?? [];
165
+ const filtered = filterLoopId ? history.filter((d: any) => d.loopId === filterLoopId) : history;
166
+ res.write(`event: snapshot\ndata: ${JSON.stringify(filtered)}\n\n`);
167
+ const collector = getGlobalCollector();
168
+ if (collector && filterLoopId) {
169
+ const compoundKey = `${key}::${filterLoopId}`;
170
+ const allSessions = collector.getSessionStats();
171
+ const sessStats = allSessions.find((s: any) => s.loopId === filterLoopId && s.sessionKey === key);
172
+ if (sessStats) {
173
+ const meta = getLoopMeta(filterLoopId);
174
+ if (meta) { (sessStats as any).routingTier = meta.routingTier; (sessStats as any).routedModel = meta.routedModel; (sessStats as any).routerAction = meta.routerAction; }
175
+ try { res.write(`event: token_update\ndata: ${JSON.stringify(sessStats)}\n\n`); } catch { /* */ }
176
+ }
177
+ }
178
+ if (filterLoopId) {
179
+ const loopMeta = getLoopMeta(filterLoopId);
180
+ if (loopMeta?.routingTier) {
181
+ const routingEvt = {
182
+ sessionKey: key, timestamp: Date.now(), level: "S1" as const,
183
+ checkpoint: "onUserMessage", phase: "generating",
184
+ routerId: "token-saver", action: loopMeta.routerAction ?? "redirect",
185
+ target: loopMeta.routedModel, reason: `tier=${loopMeta.routingTier}`,
186
+ loopId: filterLoopId,
187
+ };
188
+ try { res.write(`event: generating\ndata: ${JSON.stringify(routingEvt)}\n\n`); } catch { /* */ }
189
+ }
190
+ }
191
+ const lastEstimate = getLastInputEstimate(key);
192
+ if (lastEstimate && (!filterLoopId || lastEstimate.loopId === filterLoopId)) {
193
+ try { res.write(`event: input_estimate\ndata: ${JSON.stringify(lastEstimate)}\n\n`); } catch { /* */ }
194
+ }
195
+ const unsubDetection = onDetection((evt) => {
196
+ if (evt.sessionKey !== key) return;
197
+ if (filterLoopId && evt.loopId !== filterLoopId) return;
198
+ if (evt.phase === "input_estimate") {
199
+ try { res.write(`event: input_estimate\ndata: ${JSON.stringify(evt)}\n\n`); } catch { /* */ }
200
+ return;
201
+ }
202
+ const phaseMap: Record<string, string> = { start: "detection_start", complete: "detection", generating: "generating", llm_complete: "llm_complete" };
203
+ const eventName = phaseMap[evt.phase ?? "complete"] ?? "detection";
204
+ try { res.write(`event: ${eventName}\ndata: ${JSON.stringify(evt)}\n\n`); } catch { /* connection may be closed */ }
205
+ if (evt.phase === "complete" && filterLoopId) {
206
+ const c = getGlobalCollector();
207
+ if (c) {
208
+ const ss = c.getSessionStats().find((s: any) => s.loopId === filterLoopId && s.sessionKey === key);
209
+ if (ss) { try { res.write(`event: token_update\ndata: ${JSON.stringify(ss)}\n\n`); } catch { /* */ } }
210
+ }
211
+ }
212
+ });
213
+ const unsubToken = onTokenUpdate((evt) => {
214
+ if (evt.sessionKey !== key) return;
215
+ if (filterLoopId && evt.loopId !== filterLoopId) return;
216
+ const payload = { ...evt.stats } as any;
217
+ if (evt.loopId) {
218
+ const m = getLoopMeta(evt.loopId);
219
+ if (m) { payload.routingTier = m.routingTier; payload.routedModel = m.routedModel; payload.routerAction = m.routerAction; }
220
+ }
221
+ try { res.write(`event: token_update\ndata: ${JSON.stringify(payload)}\n\n`); } catch { /* connection may be closed */ }
222
+ });
223
+ req.on("close", () => { unsubDetection(); unsubToken(); });
224
+ return true;
225
+ }
226
+
227
+ if (req.method === "GET" && sub === "/api/activity-stream") {
228
+ console.log("[ClawXrouter] SSE activity-stream connected");
229
+ res.writeHead(200, {
230
+ "Content-Type": "text/event-stream",
231
+ "Cache-Control": "no-cache",
232
+ "Connection": "keep-alive",
233
+ "X-Accel-Buffering": "no",
234
+ });
235
+ (res as any).flushHeaders?.();
236
+ res.write(`event: ping\ndata: {}\n\n`);
237
+ const unsub = onDetection((evt) => {
238
+ const meta = evt.loopId ? getLoopMeta(evt.loopId) : undefined;
239
+ try { res.write(`event: activity\ndata: ${JSON.stringify({ sessionKey: evt.sessionKey, loopId: evt.loopId, userMessagePreview: meta?.userMessagePreview, phase: evt.phase ?? "complete" })}\n\n`); } catch { /* ignore */ }
240
+ });
241
+ const unsubTok = onTokenUpdate((evt) => {
242
+ try { res.write(`event: activity\ndata: ${JSON.stringify({ sessionKey: evt.sessionKey, loopId: evt.loopId, userMessagePreview: evt.stats?.userMessagePreview, phase: "token_update" })}\n\n`); } catch { /* ignore */ }
243
+ });
244
+ req.on("close", () => { unsub(); unsubTok(); });
245
+ return true;
246
+ }
247
+
248
+ if (req.method === "GET" && sub === "/api/config") {
249
+ const liveConfig = getLiveConfig();
250
+ const cfgAny = liveConfig as Record<string, unknown>;
251
+ json(res, {
252
+ privacy: {
253
+ enabled: liveConfig.enabled,
254
+ localModel: liveConfig.localModel,
255
+ guardAgent: liveConfig.guardAgent,
256
+ s2Policy: liveConfig.s2Policy,
257
+ proxyPort: liveConfig.proxyPort,
258
+ checkpoints: liveConfig.checkpoints,
259
+ rules: liveConfig.rules,
260
+ localProviders: liveConfig.localProviders,
261
+ modelPricing: liveConfig.modelPricing,
262
+ session: liveConfig.session,
263
+ routers: cfgAny.routers,
264
+ pipeline: cfgAny.pipeline,
265
+ },
266
+ });
267
+ return true;
268
+ }
269
+
270
+ if (req.method === "POST" && sub === "/api/config") {
271
+ if (!deps) { json(res, { error: "dashboard not initialized" }, 503); return true; }
272
+ try {
273
+ const body = JSON.parse(await readBody(req));
274
+
275
+ if (body.privacy) {
276
+ updateLiveConfig(body.privacy);
277
+
278
+ const existingPrivacy = ((deps.pluginConfig as Record<string, unknown>).privacy ?? {}) as Record<string, unknown>;
279
+ const mergedPrivacy = { ...existingPrivacy, ...body.privacy } as Record<string, unknown>;
280
+ // Deep-merge routers so saving one router doesn't wipe others
281
+ if (body.privacy.routers && existingPrivacy.routers) {
282
+ mergedPrivacy.routers = {
283
+ ...(existingPrivacy.routers as Record<string, unknown>),
284
+ ...(body.privacy.routers as Record<string, unknown>),
285
+ };
286
+ }
287
+ // Deep-merge pipeline so saving one section doesn't wipe others
288
+ if (body.privacy.pipeline && existingPrivacy.pipeline) {
289
+ mergedPrivacy.pipeline = {
290
+ ...(existingPrivacy.pipeline as Record<string, unknown>),
291
+ ...(body.privacy.pipeline as Record<string, unknown>),
292
+ };
293
+ }
294
+
295
+ // Persist to clawxrouter.json (does NOT touch openclaw.json → no restart)
296
+ saveClawXrouterConfig(mergedPrivacy);
297
+
298
+ // Dynamically register/update configurable routers in the pipeline
299
+ if (body.privacy.routers && deps.pipeline) {
300
+ const routers = body.privacy.routers as Record<string, { type?: string; enabled?: boolean }>;
301
+ for (const [id, reg] of Object.entries(routers)) {
302
+ if (reg.type === "configurable" && !deps.pipeline.hasRouter(id)) {
303
+ deps.pipeline.register(
304
+ createConfigurableRouter(id),
305
+ reg as Parameters<typeof deps.pipeline.register>[1],
306
+ );
307
+ }
308
+ }
309
+ deps.pipeline.configure({
310
+ routers: mergedPrivacy.routers as Record<string, Parameters<typeof deps.pipeline.register>[1]>,
311
+ pipeline: mergedPrivacy.pipeline as Record<string, string[]>,
312
+ });
313
+ // Update deps.pluginConfig so test-classify picks up new options
314
+ (deps.pluginConfig as Record<string, unknown>).privacy = mergedPrivacy;
315
+ }
316
+ }
317
+
318
+ json(res, { ok: true });
319
+ } catch (err) {
320
+ json(res, { error: String(err) }, 400);
321
+ }
322
+ return true;
323
+ }
324
+
325
+ // ── Prompts API ──
326
+
327
+ const EDITABLE_PROMPTS: Record<string, { label: string; defaultContent: string }> = {
328
+ "detection-system": { label: "Privacy Detection (S1/S2/S3 Classifier)", defaultContent: DEFAULT_DETECTION_SYSTEM_PROMPT },
329
+ "token-saver-judge": { label: "Token-Saver (Task Complexity Judge)", defaultContent: DEFAULT_JUDGE_PROMPT },
330
+ "pii-extraction": { label: "PII Extraction Engine", defaultContent: DEFAULT_PII_EXTRACTION_PROMPT },
331
+ };
332
+
333
+ if (req.method === "GET" && sub === "/api/prompts") {
334
+ const result: Record<string, { label: string; content: string; isCustom: boolean; defaultContent: string }> = {};
335
+ for (const [name, meta] of Object.entries(EDITABLE_PROMPTS)) {
336
+ const fromDisk = readPromptFromDisk(name);
337
+ result[name] = {
338
+ label: meta.label,
339
+ content: fromDisk ?? meta.defaultContent,
340
+ isCustom: fromDisk !== null,
341
+ defaultContent: meta.defaultContent,
342
+ };
343
+ }
344
+ json(res, result);
345
+ return true;
346
+ }
347
+
348
+ if (req.method === "POST" && sub === "/api/prompts") {
349
+ try {
350
+ const body = JSON.parse(await readBody(req)) as { name: string; content: string };
351
+ if (!body.name || typeof body.content !== "string") {
352
+ json(res, { error: "name and content required" }, 400);
353
+ return true;
354
+ }
355
+ // Allow both built-in prompts and custom router prompts (custom-*)
356
+ if (!EDITABLE_PROMPTS[body.name] && !body.name.startsWith("custom-")) {
357
+ json(res, { error: `Unknown prompt: ${body.name}` }, 400);
358
+ return true;
359
+ }
360
+ writePrompt(body.name, body.content);
361
+ json(res, { ok: true });
362
+ } catch (err) {
363
+ json(res, { error: String(err) }, 400);
364
+ }
365
+ return true;
366
+ }
367
+
368
+ // ── Test Classify API ──
369
+
370
+ if (req.method === "POST" && sub === "/api/test-classify") {
371
+ if (!deps?.pipeline) { json(res, { error: "pipeline not initialized" }, 503); return true; }
372
+ try {
373
+ const body = JSON.parse(await readBody(req)) as { message: string; checkpoint?: string; router?: string };
374
+ if (!body.message?.trim()) {
375
+ json(res, { error: "message required" }, 400);
376
+ return true;
377
+ }
378
+ const checkpoint = (body.checkpoint ?? "onUserMessage") as "onUserMessage" | "onToolCallProposed" | "onToolCallExecuted";
379
+
380
+ if (body.router) {
381
+ const decision = await deps.pipeline.runSingle(
382
+ body.router,
383
+ { checkpoint, message: body.message, sessionKey: "__test__" },
384
+ deps.pluginConfig,
385
+ );
386
+ if (!decision) {
387
+ json(res, { error: `Router not found: ${body.router}` }, 404);
388
+ return true;
389
+ }
390
+ json(res, {
391
+ level: decision.level,
392
+ action: decision.action,
393
+ target: decision.target,
394
+ reason: decision.reason,
395
+ confidence: decision.confidence,
396
+ routerId: decision.routerId,
397
+ });
398
+ } else {
399
+ // Full pipeline test
400
+ const decision = await deps.pipeline.run(
401
+ checkpoint,
402
+ { checkpoint, message: body.message, sessionKey: "__test__" },
403
+ deps.pluginConfig,
404
+ );
405
+ json(res, {
406
+ level: decision.level,
407
+ action: decision.action,
408
+ target: decision.target,
409
+ reason: decision.reason,
410
+ confidence: decision.confidence,
411
+ routerId: decision.routerId,
412
+ });
413
+ }
414
+ } catch (err) {
415
+ json(res, { error: String(err) }, 500);
416
+ }
417
+ return true;
418
+ }
419
+
420
+ return false;
421
+ }
422
+
423
+ // ── Dashboard HTML ──
424
+
425
+ function dashboardHtml(): string {
426
+ return `<!DOCTYPE html>
427
+ <html lang="zh-CN">
428
+ <head>
429
+ <meta charset="utf-8">
430
+ <meta name="viewport" content="width=device-width, initial-scale=1">
431
+ <title>ClawXrouter Dashboard</title>
432
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
433
+ <style>
434
+ :root{--bg-body:#ffffff;--bg-surface:#f9f9fa;--bg-card:#ffffff;--bg-input:#eff1f5;--text-primary:#1a1a1a;--text-secondary:#6e6e80;--text-tertiary:#9ca3af;--border-subtle:#e5e5e5;--accent:#2563eb;--accent-hover:#1d4ed8;--radius-sm:6px;--radius-md:12px;--radius-lg:16px;--shadow-sm:0 1px 2px 0 rgba(0,0,0,.05);--shadow-card:0 2px 8px rgba(0,0,0,.04);--shadow-float:0 10px 15px -3px rgba(0,0,0,.08),0 4px 6px -2px rgba(0,0,0,.04);--font-sans:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;--font-mono:'JetBrains Mono','SFMono-Regular',ui-monospace,monospace}
435
+ *{margin:0;padding:0;box-sizing:border-box}
436
+ body{font-family:var(--font-sans);background:var(--bg-surface);color:var(--text-primary);min-height:100vh;-webkit-font-smoothing:antialiased;line-height:1.6}
437
+
438
+ .header{padding:12px 24px;background:rgba(255,255,255,.85);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-bottom:1px solid var(--border-subtle);display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:50}
439
+ .header-left{display:flex;align-items:center;gap:14px}
440
+ .header h1{font-size:18px;font-weight:700;letter-spacing:-.01em;color:var(--text-primary)}
441
+ .header-right{display:flex;align-items:center;gap:14px;font-size:12px;color:var(--text-tertiary)}
442
+ .status-dot{width:8px;height:8px;border-radius:50%;background:#22c55e;display:inline-block;flex-shrink:0;box-shadow:0 0 0 2px rgba(34,197,94,.2)}
443
+ .status-dot.err{background:#ef4444;box-shadow:0 0 0 2px rgba(239,68,68,.2)}
444
+ .status-dot.warn{background:#f59e0b;box-shadow:0 0 0 2px rgba(245,158,11,.2)}
445
+
446
+ .tabs{display:flex;gap:0;padding:0 24px;background:var(--bg-card);border-bottom:1px solid var(--border-subtle);overflow-x:auto}
447
+ .tab{padding:12px 20px;cursor:pointer;border-bottom:2px solid transparent;color:var(--text-secondary);font-size:13px;font-weight:500;white-space:nowrap;transition:color .15s,border-color .15s}
448
+ .tab.active{color:var(--accent);border-bottom-color:var(--accent)}
449
+ .tab:hover{color:var(--text-primary)}
450
+
451
+ .panel{display:none;padding:24px}
452
+ .panel.active{display:block}
453
+
454
+ .cards{display:grid;grid-template-columns:repeat(5,1fr);gap:12px;margin-bottom:20px}
455
+ @media(max-width:1000px){.cards{grid-template-columns:repeat(3,1fr)}}
456
+ @media(max-width:700px){.cards{grid-template-columns:repeat(2,1fr)}}
457
+ .card{background:var(--bg-card);border:1px solid var(--border-subtle);border-radius:var(--radius-md);padding:16px 18px;box-shadow:var(--shadow-sm);transition:box-shadow .2s,transform .2s}
458
+ .card:hover{box-shadow:var(--shadow-card);transform:translateY(-1px)}
459
+ .card-label{font-size:11px;color:var(--text-tertiary);text-transform:uppercase;letter-spacing:.05em;font-weight:600;margin-bottom:6px}
460
+ .card-value{font-size:24px;font-weight:700;letter-spacing:-.02em;color:var(--text-primary)}
461
+ .card-sub{font-size:11px;color:var(--text-tertiary);margin-top:4px}
462
+ .card.cloud .card-value{color:#2563eb}
463
+ .card.local .card-value{color:#059669}
464
+ .card.proxy .card-value{color:#d97706}
465
+ .card.privacy .card-value{color:#7c3aed}
466
+ .card.cost .card-value{color:#dc2626}
467
+
468
+ .chart-wrap{background:var(--bg-card);border:1px solid var(--border-subtle);border-radius:var(--radius-md);padding:16px 18px;margin-bottom:20px;box-shadow:var(--shadow-sm)}
469
+ .chart-wrap h3{font-size:12px;color:var(--text-secondary);font-weight:600;margin-bottom:10px}
470
+
471
+ .data-table{width:100%;border-collapse:collapse;background:var(--bg-card);border:1px solid var(--border-subtle);border-radius:var(--radius-md);overflow:hidden}
472
+ .data-table th,.data-table td{padding:10px 14px;font-size:13px;text-align:right}
473
+ .data-table th{background:var(--bg-surface);color:var(--text-secondary);font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:.05em}
474
+ .data-table th:first-child,.data-table td:first-child{text-align:left}
475
+ .data-table tr:not(:last-child) td{border-bottom:1px solid var(--border-subtle)}
476
+ .data-table tbody tr:hover{background:rgba(37,99,235,.02)}
477
+ #detections-panel .data-table th,#detections-panel .data-table td{text-align:left}
478
+ .action-tag{display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;background:var(--bg-surface);color:var(--text-secondary)}
479
+ .action-tag.action-redirect{background:rgba(37,99,235,.1);color:#2563eb}
480
+ .action-tag.action-block{background:rgba(220,38,38,.1);color:#dc2626}
481
+
482
+ .info-bar{display:flex;gap:24px;padding:14px 0;font-size:12px;color:var(--text-tertiary)}
483
+
484
+ .level-tag{display:inline-block;font-size:11px;font-weight:600;padding:3px 10px;border-radius:99px}
485
+ .level-S1{background:rgba(37,99,235,.08);color:#2563eb}
486
+ .level-S2{background:rgba(217,119,6,.08);color:#d97706}
487
+ .level-S3{background:rgba(5,150,105,.08);color:#059669}
488
+ .checkpoint-tag{font-size:11px;padding:3px 8px;border-radius:99px;background:var(--bg-input);color:var(--text-secondary);font-weight:500}
489
+ .session-key{font-family:var(--font-mono);font-size:12px;color:var(--text-secondary)}
490
+
491
+ .empty-state{text-align:center;color:var(--text-tertiary);padding:48px 0;font-size:14px}
492
+
493
+ .filter-bar{display:flex;gap:8px;margin-bottom:18px}
494
+ .filter-btn{padding:7px 16px;border-radius:99px;border:1px solid var(--border-subtle);background:var(--bg-card);color:var(--text-secondary);cursor:pointer;font-size:12px;font-weight:500;transition:all .15s}
495
+ .filter-btn.active{background:var(--text-primary);color:#fff;border-color:var(--text-primary)}
496
+ .filter-btn:hover{border-color:#d1d5db;color:var(--text-primary)}
497
+
498
+ .config-section{background:var(--bg-card);border:1px solid var(--border-subtle);border-radius:var(--radius-md);padding:18px 20px;margin-bottom:14px;box-shadow:var(--shadow-sm)}
499
+ .config-section h3{font-size:11px;color:var(--text-secondary);margin-bottom:14px;text-transform:uppercase;letter-spacing:.05em;font-weight:700}
500
+ .field{margin-bottom:16px}
501
+ .field label{display:block;font-size:12px;color:var(--text-secondary);margin-bottom:6px;font-weight:500}
502
+ .field input,.field select{width:100%;padding:10px 14px;background:var(--bg-input);border:1px solid transparent;border-radius:var(--radius-sm);color:var(--text-primary);font-size:13px;outline:none;transition:all .15s}
503
+ .field select{appearance:none;-webkit-appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%236e6e80' d='M2 4l4 4 4-4'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 14px center;padding-right:36px}
504
+ .field input:hover,.field select:hover{background:#eaecf1}
505
+ .field input:focus,.field select:focus{background:#fff;border-color:transparent;box-shadow:0 0 0 3px rgba(37,99,235,.15)}
506
+
507
+ .tag-list{display:flex;flex-wrap:wrap;gap:8px;margin-top:8px;min-height:32px}
508
+ .tag{background:var(--bg-input);color:var(--text-primary);padding:5px 12px;border-radius:99px;font-size:12px;font-weight:500;display:flex;align-items:center;gap:6px;border:1px solid var(--border-subtle)}
509
+ .tag button{background:none;border:none;color:var(--text-tertiary);cursor:pointer;font-size:14px;line-height:1;transition:color .15s}
510
+ .tag button:hover{color:#ef4444}
511
+ .add-row{display:flex;gap:10px;margin-top:10px;align-items:center}
512
+ .add-row input{flex:1;min-width:0}
513
+
514
+ .btn{padding:10px 20px;border-radius:var(--radius-sm);border:none;cursor:pointer;font-size:13px;font-weight:500;transition:all .15s;white-space:nowrap;flex-shrink:0}
515
+ .btn-primary{background:var(--text-primary);color:#fff}
516
+ .btn-primary:hover{background:#333}
517
+ .btn-sm{padding:8px 16px;font-size:12px}
518
+ .btn-outline{background:var(--bg-card);border:1px solid var(--border-subtle);color:var(--text-primary)}
519
+ .btn-outline:hover{border-color:#d1d5db;background:var(--bg-surface)}
520
+ .save-bar{display:flex;justify-content:flex-end;gap:10px;padding-top:14px;margin-top:10px}
521
+
522
+ .badge{display:inline-block;font-size:10px;padding:3px 8px;border-radius:99px;margin-left:8px;vertical-align:middle;font-weight:600}
523
+ .badge-hot{background:rgba(5,150,105,.1);color:#059669}
524
+
525
+ .toast{position:fixed;bottom:24px;right:24px;background:var(--text-primary);color:#fff;padding:14px 22px;border-radius:var(--radius-md);font-size:13px;font-weight:500;display:none;z-index:100;box-shadow:0 12px 40px rgba(0,0,0,.15)}
526
+ .toast.error{background:#dc2626}
527
+
528
+ /* Session detail & timeline */
529
+ /* session detail handled by JS max-height on timeline */
530
+ .session-detail-toolbar{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;flex-shrink:0}
531
+ .back-btn{display:inline-flex;align-items:center;gap:6px;padding:8px 16px;border-radius:var(--radius-sm);border:1px solid var(--border-subtle);background:var(--bg-card);color:var(--text-primary);cursor:pointer;font-size:13px;font-weight:500;transition:all .15s}
532
+ .back-btn:hover{border-color:#d1d5db;background:var(--bg-surface)}
533
+ .back-arrow{font-size:16px;line-height:1}
534
+ .sse-indicator{display:flex;align-items:center;gap:6px;font-size:12px;color:#22c55e;font-weight:500}
535
+ .sse-dot{width:8px;height:8px;border-radius:50%;background:#22c55e;display:inline-block;animation:sse-pulse 2s ease-in-out infinite}
536
+ @keyframes sse-pulse{0%,100%{opacity:1;box-shadow:0 0 0 0 rgba(34,197,94,.4)}50%{opacity:.7;box-shadow:0 0 0 4px rgba(34,197,94,0)}}
537
+
538
+ .session-detail-header{display:flex;flex-wrap:wrap;gap:16px;align-items:center;padding:16px 20px;background:var(--bg-card);border:1px solid var(--border-subtle);border-radius:var(--radius-md);margin-bottom:20px;box-shadow:var(--shadow-sm);flex-shrink:0}
539
+ .sd-key{font-family:var(--font-mono);font-size:13px;color:var(--text-primary);font-weight:600;word-break:break-all;flex:1;min-width:200px}
540
+ .sd-stat{display:flex;flex-direction:column;align-items:center;padding:0 14px;border-left:1px solid var(--border-subtle)}
541
+ .sd-stat:first-of-type{border-left:none}
542
+ .sd-stat-value{font-size:18px;font-weight:700;color:var(--text-primary);letter-spacing:-.02em;transition:color .3s}
543
+ .sd-stat-label{font-size:10px;color:var(--text-tertiary);text-transform:uppercase;letter-spacing:.05em;font-weight:600;margin-top:2px}
544
+ .sd-stat.flash .sd-stat-value{color:#22c55e}
545
+ .sd-stat.flash{animation:stat-flash .8s ease}
546
+ @keyframes stat-flash{0%{background:rgba(34,197,94,.12)}100%{background:transparent}}
547
+
548
+ .timeline{position:relative;padding:8px 0 8px 28px;margin-left:12px;flex:1;overflow-y:auto;min-height:0}
549
+ .timeline::before{content:'';position:absolute;left:11px;top:0;bottom:0;width:2px;background:var(--border-subtle);border-radius:1px}
550
+ .timeline-item{position:relative;padding-bottom:24px;opacity:0;animation:tl-fade-in .35s ease forwards}
551
+ .timeline-item:last-child{padding-bottom:0}
552
+ .timeline-dot{position:absolute;left:-23px;top:4px;width:12px;height:12px;border-radius:50%;border:2px solid var(--bg-card);box-shadow:0 0 0 2px var(--border-subtle);z-index:1}
553
+ .timeline-dot.dot-S1{background:#2563eb;box-shadow:0 0 0 2px rgba(37,99,235,.25)}
554
+ .timeline-dot.dot-S2{background:#d97706;box-shadow:0 0 0 2px rgba(217,119,6,.25)}
555
+ .timeline-dot.dot-S3{background:#059669;box-shadow:0 0 0 2px rgba(5,150,105,.25)}
556
+ .timeline-card{background:var(--bg-card);border:1px solid var(--border-subtle);border-radius:var(--radius-sm);padding:12px 16px;box-shadow:var(--shadow-sm);transition:box-shadow .2s}
557
+ .timeline-card:hover{box-shadow:var(--shadow-card)}
558
+ .timeline-top{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:6px}
559
+ .timeline-time{font-size:11px;color:var(--text-tertiary);font-family:var(--font-mono);font-weight:500}
560
+ .timeline-meta{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
561
+ .timeline-reason{font-size:12px;color:var(--text-secondary);margin-top:6px;line-height:1.5;padding-left:2px}
562
+ .timeline-target{font-family:var(--font-mono);font-size:11px;color:var(--accent);background:rgba(37,99,235,.06);padding:2px 8px;border-radius:4px;font-weight:500}
563
+ .timeline-router{font-size:11px;color:var(--text-tertiary);font-weight:500}
564
+ .action-tag.action-passthrough{background:rgba(156,163,175,.1);color:#6b7280}
565
+ .action-tag.action-transform{background:rgba(124,58,237,.1);color:#7c3aed}
566
+ .tier-badge{display:inline-flex;align-items:center;gap:4px;padding:2px 10px;border-radius:6px;font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase}
567
+ .tier-SIMPLE{background:rgba(34,197,94,.1);color:#16a34a}
568
+ .tier-MEDIUM{background:rgba(59,130,246,.1);color:#2563eb}
569
+ .tier-COMPLEX{background:rgba(245,158,11,.1);color:#d97706}
570
+ .tier-RESEARCH{background:rgba(6,182,212,.1);color:#0891b2}
571
+ .tier-REASONING{background:rgba(168,85,247,.1);color:#9333ea}
572
+ .tier-badge:not(.tier-SIMPLE):not(.tier-MEDIUM):not(.tier-COMPLEX):not(.tier-RESEARCH):not(.tier-REASONING){background:rgba(107,114,128,.1);color:#6b7280}
573
+
574
+ #sessions-panel .data-table tbody tr{cursor:pointer;transition:background .15s}
575
+ #sessions-panel .data-table tbody tr:hover{background:rgba(37,99,235,.04)}
576
+
577
+ @keyframes tl-fade-in{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
578
+
579
+ .timeline-item.pending .timeline-dot{background:var(--text-tertiary);animation:dot-pulse 1.5s ease-in-out infinite}
580
+ .timeline-item.pending .timeline-card{border-style:dashed;opacity:.85}
581
+ .timeline-item.pending .detecting-label{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--text-secondary);font-weight:500}
582
+ .detecting-spinner{width:14px;height:14px;border:2px solid var(--border-subtle);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite;display:inline-block}
583
+ @keyframes spin{to{transform:rotate(360deg)}}
584
+ @keyframes dot-pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.4;transform:scale(.8)}}
585
+ .generating-indicator{display:inline-flex;align-items:center;gap:6px;font-size:11px;color:var(--accent);font-weight:500;margin-top:6px;padding:4px 10px;background:rgba(37,99,235,.06);border-radius:6px;animation:fade-in .3s ease}
586
+ .generating-indicator .gen-dots{display:inline-flex;gap:3px}
587
+ .generating-indicator .gen-dots span{width:4px;height:4px;border-radius:50%;background:var(--accent);animation:gen-bounce .8s ease-in-out infinite}
588
+ .generating-indicator .gen-dots span:nth-child(2){animation-delay:.15s}
589
+ .generating-indicator .gen-dots span:nth-child(3){animation-delay:.3s}
590
+ @keyframes gen-bounce{0%,100%{opacity:.3;transform:translateY(0)}50%{opacity:1;transform:translateY(-3px)}}
591
+ .timeline-card .complete-badge{display:inline-flex;align-items:center;gap:4px;font-size:10px;color:#16a34a;font-weight:600;margin-top:6px;padding:3px 8px;background:rgba(34,197,94,.08);border-radius:5px;animation:fade-in .3s ease}
592
+ @keyframes fade-in{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}
593
+
594
+ .rules-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}
595
+ @media(max-width:700px){.rules-grid{grid-template-columns:1fr}}
596
+ .rules-col{background:var(--bg-surface);border:1px solid var(--border-subtle);border-radius:var(--radius-sm);padding:14px}
597
+ .rules-col h4{font-size:11px;color:var(--text-tertiary);margin-bottom:10px;text-transform:uppercase;letter-spacing:.05em;font-weight:700;border-bottom:1px solid var(--border-subtle);padding-bottom:8px}
598
+
599
+ .toggle-bar{display:flex;align-items:center;justify-content:space-between;background:var(--bg-card);border:1px solid var(--border-subtle);border-radius:var(--radius-md);padding:14px 18px;margin-bottom:14px;box-shadow:var(--shadow-sm)}
600
+ .toggle-bar label{font-size:13px;color:var(--text-primary);font-weight:500}
601
+ .toggle{position:relative;display:inline-block;width:44px;height:24px;flex-shrink:0}
602
+ .toggle input{opacity:0;width:0;height:0}
603
+ .toggle .slider{position:absolute;inset:0;background:#d1d5db;border-radius:12px;cursor:pointer;transition:.2s}
604
+ .toggle .slider::before{content:'';position:absolute;width:18px;height:18px;left:3px;top:3px;background:#fff;border-radius:50%;transition:.2s;box-shadow:0 1px 3px rgba(0,0,0,.2)}
605
+ .toggle input:checked+.slider{background:var(--accent)}
606
+ .toggle input:checked+.slider::before{transform:translateX(20px)}
607
+
608
+ .chip-group{display:flex;flex-wrap:wrap;gap:8px;margin-top:8px}
609
+ .chip{padding:7px 14px;border-radius:99px;font-size:12px;cursor:pointer;border:1px solid var(--border-subtle);background:var(--bg-card);color:var(--text-secondary);font-weight:500;transition:all .15s}
610
+ .chip.active{background:var(--text-primary);color:#fff;border-color:var(--text-primary)}
611
+ .chip:hover{border-color:#d1d5db;color:var(--text-primary)}
612
+
613
+ .router-card{background:var(--bg-surface);border:1px solid var(--border-subtle);border-radius:var(--radius-md);padding:16px;margin-bottom:10px;transition:border-color .15s}
614
+ .router-card:hover{border-color:#d1d5db}
615
+ .router-card .rc-head{display:flex;align-items:center;gap:8px}
616
+ .router-card .rc-name{font-size:13px;color:var(--text-primary);font-weight:600}
617
+ .router-card .rc-type{font-size:11px;color:var(--text-tertiary)}
618
+ .router-card .rc-del{margin-left:auto;background:none;border:none;color:var(--text-tertiary);cursor:pointer;font-size:16px;line-height:1;transition:color .15s}
619
+ .router-card .rc-del:hover{color:#ef4444}
620
+ .router-card .rc-module{font-size:11px;color:var(--text-tertiary);margin-top:4px}
621
+
622
+ .field-toggle{display:flex;align-items:center;gap:12px;margin-bottom:14px}
623
+ .field-toggle>label{font-size:13px;color:var(--text-secondary);margin-bottom:0}
624
+ .hint{font-size:11px;color:var(--text-tertiary);margin-top:4px}
625
+
626
+ .prompt-editor{width:100%;min-height:200px;padding:16px 18px;background:var(--bg-input);border:1px solid transparent;border-radius:var(--radius-md);color:var(--text-primary);font-family:var(--font-mono);font-size:12px;line-height:1.6;resize:vertical;outline:none;tab-size:2;transition:all .15s}
627
+ .prompt-editor:hover{background:#eaecf1}
628
+ .prompt-editor:focus{background:#fff;box-shadow:0 0 0 3px rgba(37,99,235,.15)}
629
+ .prompt-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:14px}
630
+ .prompt-header h4{font-size:13px;color:var(--text-primary);font-weight:600}
631
+ .prompt-actions{display:flex;gap:6px}
632
+ .custom-badge{font-size:10px;padding:3px 8px;border-radius:99px;background:rgba(37,99,235,.08);color:var(--accent);font-weight:600;margin-left:8px}
633
+
634
+ .test-panel{background:var(--bg-card);border:1px solid var(--border-subtle);border-radius:var(--radius-md);padding:18px 20px;margin-bottom:14px;box-shadow:var(--shadow-sm)}
635
+ .test-input{width:100%;min-height:80px;padding:14px 16px;background:var(--bg-input);border:1px solid transparent;border-radius:var(--radius-md);color:var(--text-primary);font-size:13px;resize:vertical;outline:none;transition:all .15s}
636
+ .test-input:hover{background:#eaecf1}
637
+ .test-input:focus{background:#fff;box-shadow:0 0 0 3px rgba(37,99,235,.15)}
638
+ .test-result{margin-top:18px;padding:18px 20px;background:var(--bg-surface);border-radius:var(--radius-md);border:1px solid var(--border-subtle);display:none}
639
+ .test-result.visible{display:block}
640
+ .test-result-row{display:flex;justify-content:space-between;padding:10px 0;font-size:13px;border-bottom:1px solid var(--border-subtle)}
641
+ .test-result-row:last-child{border-bottom:none}
642
+ .test-result-label{color:var(--text-secondary)}
643
+ .test-result-value{color:var(--text-primary);font-weight:600}
644
+ .test-loading{color:var(--text-secondary);font-size:13px;padding:14px 0}
645
+
646
+ .tier-grid{display:grid;grid-template-columns:110px 1fr 1fr 1.5fr;gap:10px;align-items:center}
647
+ .tier-grid .tier-label{font-size:12px;color:var(--text-secondary);font-weight:600}
648
+ .btn-sm{padding:4px 12px;font-size:11px;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--bg-input);cursor:pointer;color:var(--text-primary)}
649
+ .tier-grid input{padding:9px 14px;background:var(--bg-input);border:1px solid transparent;border-radius:var(--radius-sm);color:var(--text-primary);font-size:12px;outline:none;transition:all .15s}
650
+ .tier-grid input:hover{background:#eaecf1}
651
+ .tier-grid input:focus{background:#fff;box-shadow:0 0 0 3px rgba(37,99,235,.15)}
652
+ .tier-grid-header{font-size:11px;color:var(--text-tertiary);text-transform:uppercase;letter-spacing:.06em;font-weight:700;padding-bottom:8px}
653
+
654
+ .section-collapse{cursor:pointer;user-select:none}
655
+ .section-collapse::before{content:'\\25BC';display:inline-block;margin-right:8px;font-size:10px;transition:transform .2s;color:var(--text-tertiary)}
656
+ .section-collapse.collapsed::before{transform:rotate(-90deg)}
657
+ .section-body{overflow:hidden;transition:max-height .3s ease}
658
+ .section-body.collapsed{max-height:0 !important;padding:0;overflow:hidden}
659
+
660
+ .router-section{background:var(--bg-card);border-radius:var(--radius-md);margin-bottom:14px;border:1px solid var(--border-subtle);overflow:hidden;box-shadow:var(--shadow-sm)}
661
+ .router-section-header{display:flex;align-items:center;gap:10px;padding:14px 18px;cursor:pointer;user-select:none;transition:background .15s}
662
+ .router-section-header:hover{background:var(--bg-surface)}
663
+ .router-section-header h3{font-size:14px;color:var(--text-primary);font-weight:600;margin:0}
664
+ .router-section-header .section-arrow{font-size:10px;color:var(--text-tertiary);transition:transform .2s;display:inline-block}
665
+ .router-section-header.collapsed .section-arrow{transform:rotate(-90deg)}
666
+ .router-id-badge{font-size:11px;padding:3px 10px;border-radius:99px;background:var(--bg-input);color:var(--text-secondary);font-family:var(--font-mono);font-weight:500}
667
+ .router-section-body{padding:0 18px 18px}
668
+ .router-section-body.collapsed{display:none}
669
+ .subsection{margin-bottom:18px;padding-bottom:16px;border-bottom:1px solid var(--border-subtle)}
670
+ .subsection:last-of-type{border-bottom:none;margin-bottom:0;padding-bottom:0}
671
+ .subsection>h4{font-size:11px;color:var(--text-secondary);margin-bottom:10px;text-transform:uppercase;letter-spacing:.05em;font-weight:700}
672
+ .add-custom-router{background:var(--bg-card);border:2px dashed var(--border-subtle);border-radius:var(--radius-md);padding:18px 20px;margin-bottom:14px;transition:border-color .15s}
673
+ .add-custom-router:hover{border-color:#d1d5db}
674
+ .btn-danger{background:#fef2f2;color:#dc2626;border:1px solid #fecaca}
675
+ .btn-danger:hover{background:#fee2e2}
676
+ .pipe-picker{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}
677
+ .pipe-pick-btn{padding:6px 14px;border-radius:99px;font-size:12px;cursor:pointer;border:1px dashed var(--border-subtle);background:var(--bg-card);color:var(--text-secondary);transition:all .15s;font-family:var(--font-mono);font-weight:500}
678
+ .pipe-pick-btn:hover{border-color:var(--accent);color:var(--accent)}
679
+ .pipe-pick-btn.in-use{opacity:.35;cursor:default;border-style:solid}
680
+ .pipe-pick-btn.in-use:hover{border-color:var(--border-subtle);color:var(--text-secondary)}
681
+ .tag.pipe-tag{cursor:grab;user-select:none}
682
+ .tag.pipe-tag.dragging{opacity:.4}
683
+ .adv-toggle{display:flex;align-items:center;gap:6px;cursor:pointer;user-select:none;font-size:12px;color:var(--text-tertiary);margin:18px 0 10px;padding:6px 0;font-weight:500}
684
+ .adv-toggle:hover{color:var(--text-secondary)}
685
+ .adv-toggle .adv-arrow{font-size:10px;transition:transform .2s;display:inline-block}
686
+ .adv-toggle.open .adv-arrow{transform:rotate(90deg)}
687
+ .adv-body{display:none}
688
+ .adv-body.open{display:block}
689
+
690
+ ::-webkit-scrollbar{width:6px;height:6px}
691
+ ::-webkit-scrollbar-track{background:transparent}
692
+ ::-webkit-scrollbar-thumb{background:#d1d5db;border-radius:3px}
693
+ ::-webkit-scrollbar-thumb:hover{background:#9ca3af}
694
+ </style>
695
+ </head>
696
+ <body>
697
+
698
+ <div class="header">
699
+ <div class="header-left">
700
+ <h1 data-i18n="header.title">ClawXrouter Dashboard</h1>
701
+ </div>
702
+ <div class="header-right">
703
+ <span class="status-dot warn" id="status-dot"></span>
704
+ <span id="status-text" data-i18n="header.connecting">Connecting...</span>
705
+ <span id="last-updated"></span>
706
+ <button class="btn btn-sm btn-outline" onclick="refreshAll()" data-i18n="header.refresh">Refresh</button>
707
+ <button class="btn btn-sm btn-outline" id="lang-toggle" onclick="setLang(LANG==='en'?'zh':'en')">中文</button>
708
+ </div>
709
+ </div>
710
+
711
+ <div class="tabs">
712
+ <div class="tab active" data-tab="stats" data-i18n="tab.overview">Overview</div>
713
+ <div class="tab" data-tab="sessions" data-i18n="tab.sessions">Sessions</div>
714
+ <div class="tab" data-tab="detections" data-i18n="tab.detections">Detection Log</div>
715
+ <div class="tab" data-tab="rules"><span data-i18n="tab.rules">Router Rules</span> <span class="badge badge-hot">live</span></div>
716
+ <div class="tab" data-tab="config"><span data-i18n="tab.config">Configuration</span> <span class="badge badge-hot">live</span></div>
717
+ </div>
718
+
719
+ <!-- Overview -->
720
+ <div id="stats-panel" class="panel active">
721
+ <div class="cards">
722
+ <div class="card cloud">
723
+ <div class="card-label" data-i18n="overview.cloud">Cloud Tokens</div>
724
+ <div class="card-value" id="cloud-tokens">-</div>
725
+ <div class="card-sub" id="cloud-reqs">0 requests</div>
726
+ </div>
727
+ <div class="card local">
728
+ <div class="card-label" data-i18n="overview.local">Local Tokens</div>
729
+ <div class="card-value" id="local-tokens">-</div>
730
+ <div class="card-sub" id="local-reqs">0 requests</div>
731
+ </div>
732
+ <div class="card proxy">
733
+ <div class="card-label" data-i18n="overview.redacted">Redacted Tokens</div>
734
+ <div class="card-value" id="proxy-tokens">-</div>
735
+ <div class="card-sub" id="proxy-reqs">0 requests</div>
736
+ </div>
737
+ <div class="card privacy">
738
+ <div class="card-label" data-i18n="overview.protection">Data Protection Rate</div>
739
+ <div class="card-value" id="privacy-rate">-</div>
740
+ <div class="card-sub" id="privacy-sub" data-i18n="overview.sub">of total tokens protected</div>
741
+ </div>
742
+ <div class="card cost">
743
+ <div class="card-label" data-i18n="overview.cost">Cloud Cost</div>
744
+ <div class="card-value" id="cloud-cost">-</div>
745
+ <div class="card-sub" id="cloud-cost-sub" data-i18n="overview.cost_sub">estimated cloud API cost</div>
746
+ </div>
747
+ </div>
748
+ <div class="chart-wrap">
749
+ <h3 data-i18n="overview.chart">Hourly Token Usage</h3>
750
+ <canvas id="hourlyChart" height="80"></canvas>
751
+ </div>
752
+ <table class="data-table">
753
+ <thead><tr><th data-i18n="table.category">Category</th><th data-i18n="table.input">Input</th><th data-i18n="table.output">Output</th><th data-i18n="table.cache">Cache Read</th><th data-i18n="table.total">Total</th><th data-i18n="table.requests">Requests</th><th data-i18n="table.cost">Cost</th></tr></thead>
754
+ <tbody id="detail-body"></tbody>
755
+ </table>
756
+ <h4 style="margin-top:18px;margin-bottom:6px;color:var(--text-secondary);" data-i18n="table.by_source">By Source (Router vs Task)</h4>
757
+ <table class="data-table">
758
+ <thead><tr><th data-i18n="table.source">Source</th><th data-i18n="table.input">Input</th><th data-i18n="table.output">Output</th><th data-i18n="table.cache">Cache Read</th><th data-i18n="table.total">Total</th><th data-i18n="table.requests">Requests</th><th data-i18n="table.cost">Cost</th></tr></thead>
759
+ <tbody id="source-body"></tbody>
760
+ </table>
761
+ <div class="info-bar" id="info-bar"></div>
762
+ <div style="text-align:right;margin-top:8px;">
763
+ <button class="btn btn-sm btn-outline" onclick="resetStats()" data-i18n="overview.reset_btn">Reset Stats</button>
764
+ </div>
765
+ </div>
766
+
767
+ <!-- Sessions -->
768
+ <div id="sessions-panel" class="panel">
769
+ <div id="session-list">
770
+ <table class="data-table">
771
+ <thead><tr><th data-i18n="sessions.message">Message</th><th data-i18n="sessions.level">Level</th><th data-i18n="sessions.input">Input</th><th data-i18n="sessions.output">Output</th><th data-i18n="sessions.cache">Cache</th><th data-i18n="sessions.cloud_cost">Cloud Model Cost</th><th data-i18n="sessions.requests">Requests</th><th data-i18n="sessions.time">Time</th></tr></thead>
772
+ <tbody id="sessions-body"><tr><td colspan="8" class="empty-state" data-i18n="sessions.empty">No session data yet</td></tr></tbody>
773
+ </table>
774
+ </div>
775
+ <div id="session-detail" style="display:none">
776
+ <div class="session-detail-toolbar">
777
+ <button class="back-btn" onclick="hideSessionDetail()"><span class="back-arrow">&#8592;</span> <span data-i18n="sd.back">Back to Sessions</span></button>
778
+ <span class="sse-indicator"><span class="sse-dot"></span> <span data-i18n="sd.live">Live</span></span>
779
+ </div>
780
+ <div class="session-detail-header" id="sd-header"></div>
781
+ <div class="timeline" id="session-timeline">
782
+ <div class="empty-state" data-i18n="sd.timeline_empty">No routing decisions yet</div>
783
+ </div>
784
+ </div>
785
+ </div>
786
+
787
+ <!-- Detection Log -->
788
+ <div id="detections-panel" class="panel">
789
+ <div class="filter-bar">
790
+ <button class="filter-btn active" onclick="filterDetections('all',this)" data-i18n="det.all">All</button>
791
+ <button class="filter-btn" onclick="filterDetections('S1',this)">S1</button>
792
+ <button class="filter-btn" onclick="filterDetections('S2',this)">S2</button>
793
+ <button class="filter-btn" onclick="filterDetections('S3',this)">S3</button>
794
+ <span style="border-left:1px solid var(--border);height:16px;margin:0 6px"></span>
795
+ <button class="filter-btn" onclick="filterDetectionRouter('all',this)" data-i18n="det.all_routers" id="router-filter-all" style="font-size:11px">All Routers</button>
796
+ <button class="filter-btn" onclick="filterDetectionRouter('privacy',this)" style="font-size:11px">Privacy</button>
797
+ <button class="filter-btn" onclick="filterDetectionRouter('token-saver',this)" style="font-size:11px">Cost-Opt</button>
798
+ </div>
799
+ <table class="data-table">
800
+ <thead><tr><th data-i18n="det.time">Time</th><th data-i18n="det.session">Session</th><th data-i18n="det.level">Level</th><th data-i18n="det.checkpoint">Checkpoint</th><th data-i18n="det.router">Router</th><th data-i18n="det.action">Action</th><th data-i18n="det.target">Target</th><th data-i18n="det.reason">Reason</th></tr></thead>
801
+ <tbody id="detections-body"><tr><td colspan="8" class="empty-state" data-i18n="det.empty">No detections yet</td></tr></tbody>
802
+ </table>
803
+ </div>
804
+
805
+ <!-- Router Rules -->
806
+ <div id="rules-panel" class="panel">
807
+
808
+ <!-- Pipeline Test (full pipeline) -->
809
+ <div class="test-panel">
810
+ <h3 style="font-size:12px;color:var(--text-secondary);margin-bottom:14px;text-transform:uppercase;letter-spacing:.06em;font-weight:700" data-i18n="test.title">Test Classification</h3>
811
+ <div class="hint" style="margin-bottom:10px" data-i18n="test.hint">Test how the router pipeline would classify a message (no changes applied).</div>
812
+ <textarea class="test-input" id="test-message" data-i18n-ph="test.placeholder" placeholder="e.g. &quot;帮我分析一下这个月的工资单&quot; or &quot;write a poem about spring&quot;"></textarea>
813
+ <div style="display:flex;gap:8px;margin-top:10px;align-items:center">
814
+ <select id="test-checkpoint" style="padding:10px 36px 10px 14px;background:var(--bg-input) url(&quot;data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%236e6e80' d='M2 4l4 4 4-4'/%3E%3C/svg%3E&quot;) no-repeat right 14px center;border:1px solid transparent;border-radius:6px;color:var(--text-primary);font-size:12px;appearance:none;-webkit-appearance:none">
815
+ <option value="onUserMessage" data-i18n-opt="ck.user_message">User Message</option>
816
+ <option value="onToolCallProposed" data-i18n-opt="ck.before_tool">Before Tool Runs</option>
817
+ <option value="onToolCallExecuted" data-i18n-opt="ck.after_tool">After Tool Runs</option>
818
+ </select>
819
+ <button class="btn btn-primary btn-sm" onclick="runTestClassify()" data-i18n="test.run">Run Test</button>
820
+ </div>
821
+ <div class="test-result" id="test-result">
822
+ <div style="font-size:11px;text-transform:uppercase;color:var(--text-tertiary);letter-spacing:.06em;font-weight:700;margin-bottom:10px" data-i18n="test.merged">Merged Result</div>
823
+ <div class="test-result-row"><span class="test-result-label" data-i18n="test.level">Level</span><span class="test-result-value" id="tr-level">-</span></div>
824
+ <div class="test-result-row"><span class="test-result-label" data-i18n="test.action">Action</span><span class="test-result-value" id="tr-action">-</span></div>
825
+ <div class="test-result-row"><span class="test-result-label" data-i18n="test.target">Target</span><span class="test-result-value" id="tr-target">-</span></div>
826
+ <div class="test-result-row"><span class="test-result-label" data-i18n="test.deciding">Deciding Router</span><span class="test-result-value" id="tr-router">-</span></div>
827
+ <div class="test-result-row"><span class="test-result-label" data-i18n="test.reason">Reason</span><span class="test-result-value" id="tr-reason">-</span></div>
828
+ <div class="test-result-row"><span class="test-result-label" data-i18n="test.confidence">Confidence</span><span class="test-result-value" id="tr-confidence">-</span></div>
829
+ <div id="tr-per-router"></div>
830
+ </div>
831
+ <div class="test-loading" id="test-loading" style="display:none" data-i18n="test.classifying">Classifying...</div>
832
+ </div>
833
+
834
+ <!-- Pipeline Order (Advanced) -->
835
+ <div class="adv-toggle" onclick="toggleAdv(this)">
836
+ <span class="adv-arrow">&#9654;</span> <span data-i18n="pipe.title">Router Execution Order (Advanced)</span>
837
+ </div>
838
+ <div class="adv-body">
839
+ <div class="config-section">
840
+ <div class="hint" style="margin-bottom:12px" data-i18n="pipe.hint">Click a router to add it to a stage. Drag tags to reorder. Click &times; to remove.</div>
841
+ <div class="field">
842
+ <label data-i18n="ck.user_message">User Message</label>
843
+ <div class="tag-list" id="cfg-tags-pipe-um"></div>
844
+ <div class="pipe-picker" id="pipe-picker-um"></div>
845
+ </div>
846
+ <div class="field">
847
+ <label data-i18n="ck.before_tool">Before Tool Runs</label>
848
+ <div class="tag-list" id="cfg-tags-pipe-tcp"></div>
849
+ <div class="pipe-picker" id="pipe-picker-tcp"></div>
850
+ </div>
851
+ <div class="field">
852
+ <label data-i18n="ck.after_tool">After Tool Runs</label>
853
+ <div class="tag-list" id="cfg-tags-pipe-tce"></div>
854
+ <div class="pipe-picker" id="pipe-picker-tce"></div>
855
+ </div>
856
+ <div class="save-bar"><button class="btn btn-primary btn-sm" onclick="savePipelineOrder()" data-i18n="pipe.save">Save Execution Order</button></div>
857
+ </div>
858
+ </div>
859
+
860
+ <!-- ═══ Privacy Router Card ═══ -->
861
+ <div class="router-section">
862
+ <div class="router-section-header" onclick="toggleSection(this)">
863
+ <span class="section-arrow">&#9660;</span>
864
+ <h3 data-i18n="priv.title">Privacy Router</h3>
865
+ <span class="router-id-badge">privacy</span>
866
+ </div>
867
+ <div class="router-section-body">
868
+ <div class="hint" style="margin-bottom:14px" data-i18n="priv.desc">Detects sensitive data in messages and routes to local or redacted cloud models.</div>
869
+
870
+ <div class="field-toggle" style="margin-bottom:18px">
871
+ <label data-i18n="common.enabled">Enabled</label>
872
+ <label class="toggle"><input type="checkbox" id="cfg-privacy-enabled" checked><span class="slider"></span></label>
873
+ </div>
874
+
875
+ <!-- Keywords (always visible) -->
876
+ <div class="subsection">
877
+ <h4 data-i18n="priv.keywords">Keywords</h4>
878
+ <div class="rules-grid">
879
+ <div class="rules-col">
880
+ <h4 data-i18n-html="priv.s2">S2 &mdash; Sensitive (Redact &rarr; Cloud)</h4>
881
+ <div class="field">
882
+ <label data-i18n="priv.keywords">Keywords</label>
883
+ <div class="tag-list" id="cfg-tags-kw-s2"></div>
884
+ <div class="add-row">
885
+ <input id="cfg-tags-kw-s2-input" placeholder="e.g. salary, phone number" onkeydown="if(event.key==='Enter'){event.preventDefault();addTag('kw-s2')}">
886
+ <button class="btn btn-sm btn-outline" onclick="addTag('kw-s2')">Add</button>
887
+ </div>
888
+ </div>
889
+ </div>
890
+ <div class="rules-col">
891
+ <h4 data-i18n-html="priv.s3">S3 &mdash; Confidential (Local Model Only)</h4>
892
+ <div class="field">
893
+ <label data-i18n="priv.keywords">Keywords</label>
894
+ <div class="tag-list" id="cfg-tags-kw-s3"></div>
895
+ <div class="add-row">
896
+ <input id="cfg-tags-kw-s3-input" placeholder="e.g. SSN, bank account" onkeydown="if(event.key==='Enter'){event.preventDefault();addTag('kw-s3')}">
897
+ <button class="btn btn-sm btn-outline" onclick="addTag('kw-s3')">Add</button>
898
+ </div>
899
+ </div>
900
+ </div>
901
+ </div>
902
+ </div>
903
+
904
+ <!-- LLM Prompts (privacy-specific) -->
905
+ <div class="subsection">
906
+ <h4 data-i18n="priv.llm_prompt">LLM Prompt</h4>
907
+ <div class="hint" style="margin-bottom:12px" data-i18n="priv.llm_hint">Prompt used by the local LLM to classify data sensitivity (S1/S2/S3).</div>
908
+ <div id="privacy-prompt-main"></div>
909
+ </div>
910
+
911
+ <!-- Per-router Test -->
912
+ <div class="subsection">
913
+ <h4 data-i18n="priv.test_title">Test (Privacy Router Only)</h4>
914
+ <textarea class="test-input" id="test-privacy-message" data-i18n-ph="priv.test_ph" placeholder="Enter a message to test the privacy router alone..."></textarea>
915
+ <div style="display:flex;gap:8px;margin-top:10px;align-items:center">
916
+ <button class="btn btn-primary btn-sm" onclick="runRouterTest('privacy')" data-i18n="priv.test_btn">Test Privacy Router</button>
917
+ </div>
918
+ <div class="test-result" id="test-privacy-result">
919
+ <div class="test-result-row"><span class="test-result-label" data-i18n="test.level">Level</span><span class="test-result-value" id="tr-privacy-level">-</span></div>
920
+ <div class="test-result-row"><span class="test-result-label" data-i18n="test.action">Action</span><span class="test-result-value" id="tr-privacy-action">-</span></div>
921
+ <div class="test-result-row"><span class="test-result-label" data-i18n="test.target">Target</span><span class="test-result-value" id="tr-privacy-target">-</span></div>
922
+ <div class="test-result-row"><span class="test-result-label" data-i18n="test.reason">Reason</span><span class="test-result-value" id="tr-privacy-reason">-</span></div>
923
+ <div class="test-result-row"><span class="test-result-label" data-i18n="test.confidence">Confidence</span><span class="test-result-value" id="tr-privacy-confidence">-</span></div>
924
+ </div>
925
+ <div class="test-loading" id="test-privacy-loading" style="display:none" data-i18n="test.testing">Testing...</div>
926
+ </div>
927
+
928
+ <!-- Advanced Configuration -->
929
+ <div class="adv-toggle" onclick="toggleAdv(this)">
930
+ <span class="adv-arrow">&#9654;</span> <span data-i18n="priv.adv">Advanced Configuration</span>
931
+ </div>
932
+ <div class="adv-body">
933
+
934
+ <!-- When to Run -->
935
+ <div class="subsection">
936
+ <h4 data-i18n="priv.when">When to Run</h4>
937
+ <div class="hint" style="margin-bottom:10px" data-i18n="priv.when_hint">Select which detectors run at each stage for the privacy router.</div>
938
+ <div class="field">
939
+ <label data-i18n="ck.user_message">User Message</label>
940
+ <div class="chip-group" id="ck-um">
941
+ <button class="chip" data-ck="um" data-det="ruleDetector" onclick="toggleChip(this)" data-i18n="priv.kw_regex">Keyword &amp; Regex</button>
942
+ <button class="chip" data-ck="um" data-det="localModelDetector" onclick="toggleChip(this)" data-i18n="priv.llm_cls">LLM Classifier</button>
943
+ </div>
944
+ </div>
945
+ <div class="field">
946
+ <label data-i18n="ck.before_tool">Before Tool Runs</label>
947
+ <div class="chip-group" id="ck-tcp">
948
+ <button class="chip" data-ck="tcp" data-det="ruleDetector" onclick="toggleChip(this)" data-i18n="priv.kw_regex">Keyword &amp; Regex</button>
949
+ <button class="chip" data-ck="tcp" data-det="localModelDetector" onclick="toggleChip(this)" data-i18n="priv.llm_cls">LLM Classifier</button>
950
+ </div>
951
+ </div>
952
+ <div class="field">
953
+ <label data-i18n="ck.after_tool">After Tool Runs</label>
954
+ <div class="chip-group" id="ck-tce">
955
+ <button class="chip" data-ck="tce" data-det="ruleDetector" onclick="toggleChip(this)" data-i18n="priv.kw_regex">Keyword &amp; Regex</button>
956
+ <button class="chip" data-ck="tce" data-det="localModelDetector" onclick="toggleChip(this)" data-i18n="priv.llm_cls">LLM Classifier</button>
957
+ </div>
958
+ </div>
959
+ </div>
960
+
961
+ <!-- Regex Patterns, Sensitive Tool Names, Sensitive File Paths -->
962
+ <div class="subsection">
963
+ <h4 data-i18n="priv.det_rules">Detection Rules (Regex &amp; Tool Filters)</h4>
964
+ <div class="rules-grid">
965
+ <div class="rules-col">
966
+ <h4 data-i18n-html="priv.s2">S2 &mdash; Sensitive (Redact &rarr; Cloud)</h4>
967
+ <div class="field">
968
+ <label data-i18n="priv.regex">Regex Patterns</label>
969
+ <div class="tag-list" id="cfg-tags-pat-s2"></div>
970
+ <div class="add-row">
971
+ <input id="cfg-tags-pat-s2-input" placeholder="e.g. \\d{3}-\\d{4}" onkeydown="if(event.key==='Enter'){event.preventDefault();addTag('pat-s2')}">
972
+ <button class="btn btn-sm btn-outline" onclick="addTag('pat-s2')">Add</button>
973
+ </div>
974
+ </div>
975
+ <div class="field">
976
+ <label data-i18n="priv.tools">Sensitive Tool Names</label>
977
+ <div class="tag-list" id="cfg-tags-tool-s2"></div>
978
+ <div class="add-row">
979
+ <input id="cfg-tags-tool-s2-input" placeholder="e.g. read_file, execute_sql" onkeydown="if(event.key==='Enter'){event.preventDefault();addTag('tool-s2')}">
980
+ <button class="btn btn-sm btn-outline" onclick="addTag('tool-s2')">Add</button>
981
+ </div>
982
+ </div>
983
+ <div class="field">
984
+ <label data-i18n="priv.paths">Sensitive File Paths</label>
985
+ <div class="tag-list" id="cfg-tags-toolpath-s2"></div>
986
+ <div class="add-row">
987
+ <input id="cfg-tags-toolpath-s2-input" placeholder="e.g. /secrets/, *.env" onkeydown="if(event.key==='Enter'){event.preventDefault();addTag('toolpath-s2')}">
988
+ <button class="btn btn-sm btn-outline" onclick="addTag('toolpath-s2')">Add</button>
989
+ </div>
990
+ </div>
991
+ </div>
992
+ <div class="rules-col">
993
+ <h4 data-i18n-html="priv.s3">S3 &mdash; Confidential (Local Model Only)</h4>
994
+ <div class="field">
995
+ <label data-i18n="priv.regex">Regex Patterns</label>
996
+ <div class="tag-list" id="cfg-tags-pat-s3"></div>
997
+ <div class="add-row">
998
+ <input id="cfg-tags-pat-s3-input" placeholder="e.g. \\b\\d{3}-\\d{2}-\\d{4}\\b" onkeydown="if(event.key==='Enter'){event.preventDefault();addTag('pat-s3')}">
999
+ <button class="btn btn-sm btn-outline" onclick="addTag('pat-s3')">Add</button>
1000
+ </div>
1001
+ </div>
1002
+ <div class="field">
1003
+ <label data-i18n="priv.tools">Sensitive Tool Names</label>
1004
+ <div class="tag-list" id="cfg-tags-tool-s3"></div>
1005
+ <div class="add-row">
1006
+ <input id="cfg-tags-tool-s3-input" placeholder="e.g. execute_command" onkeydown="if(event.key==='Enter'){event.preventDefault();addTag('tool-s3')}">
1007
+ <button class="btn btn-sm btn-outline" onclick="addTag('tool-s3')">Add</button>
1008
+ </div>
1009
+ </div>
1010
+ <div class="field">
1011
+ <label data-i18n="priv.paths">Sensitive File Paths</label>
1012
+ <div class="tag-list" id="cfg-tags-toolpath-s3"></div>
1013
+ <div class="add-row">
1014
+ <input id="cfg-tags-toolpath-s3-input" placeholder="e.g. /credentials/" onkeydown="if(event.key==='Enter'){event.preventDefault();addTag('toolpath-s3')}">
1015
+ <button class="btn btn-sm btn-outline" onclick="addTag('toolpath-s3')">Add</button>
1016
+ </div>
1017
+ </div>
1018
+ </div>
1019
+ </div>
1020
+ </div>
1021
+
1022
+ <!-- Personal Info Redaction Prompt -->
1023
+ <div class="subsection">
1024
+ <h4 data-i18n="priv.pii">Personal Info Redaction Prompt</h4>
1025
+ <div class="hint" style="margin-bottom:12px" data-i18n="priv.pii_hint">Prompt used by the local LLM to extract and redact personal info.</div>
1026
+ <div id="privacy-prompt-adv"></div>
1027
+ </div>
1028
+
1029
+ </div>
1030
+
1031
+ <div class="save-bar"><button class="btn btn-primary" onclick="savePrivacyRouter()" data-i18n="priv.save">Save Privacy Router</button></div>
1032
+ </div>
1033
+ </div>
1034
+
1035
+ <!-- ═══ Token-Saver Router Card ═══ -->
1036
+ <div class="router-section">
1037
+ <div class="router-section-header" onclick="toggleSection(this)">
1038
+ <span class="section-arrow">&#9660;</span>
1039
+ <h3 data-i18n="co.title">Cost-Optimizer Router</h3>
1040
+ <span class="router-id-badge">token-saver</span>
1041
+ </div>
1042
+ <div class="router-section-body">
1043
+ <div class="hint" style="margin-bottom:14px" data-i18n="co.desc">Classifies task complexity and routes to the most cost-effective model.</div>
1044
+
1045
+ <div class="field-toggle" style="margin-bottom:18px">
1046
+ <label data-i18n="common.enabled">Enabled</label>
1047
+ <label class="toggle"><input type="checkbox" id="cfg-ts-enabled"><span class="slider"></span></label>
1048
+ </div>
1049
+
1050
+ <!-- Judge Model -->
1051
+ <div class="subsection">
1052
+ <h4 data-i18n-html="co.tier">Complexity Level &rarr; Model</h4>
1053
+ <div class="tier-grid" id="ts-tier-grid">
1054
+ <div class="tier-grid-header" data-i18n="co.complexity">Complexity</div>
1055
+ <div class="tier-grid-header" data-i18n="co.provider">Provider</div>
1056
+ <div class="tier-grid-header" data-i18n="co.model">Model</div>
1057
+ <div class="tier-grid-header">Description</div>
1058
+ <!-- rows populated dynamically by loadTokenSaverConfig -->
1059
+ </div>
1060
+ <div style="margin-top:8px;display:flex;gap:8px">
1061
+ <button class="btn btn-sm" onclick="addTierRow()" data-i18n="co.add_tier">+ Add Tier</button>
1062
+ <button class="btn btn-sm" onclick="removeTierRow()" style="color:var(--danger)" data-i18n="co.remove_tier">&minus; Remove Last</button>
1063
+ </div>
1064
+ </div>
1065
+
1066
+ <!-- LLM Prompt (token-saver-specific) -->
1067
+ <div class="subsection">
1068
+ <h4 data-i18n="co.llm_prompt">LLM Prompt</h4>
1069
+ <div class="hint" style="margin-bottom:12px" data-i18n="co.llm_hint">Prompt used by the classifier LLM to determine task complexity.</div>
1070
+ <div id="tokensaver-prompt-editors"></div>
1071
+ </div>
1072
+
1073
+ <!-- Per-router Test -->
1074
+ <div class="subsection">
1075
+ <h4 data-i18n="co.test_title">Test (Cost-Optimizer Only)</h4>
1076
+ <textarea class="test-input" id="test-token-saver-message" data-i18n-ph="co.test_ph" placeholder="Enter a message to test the cost-optimizer router alone..."></textarea>
1077
+ <div style="display:flex;gap:8px;margin-top:10px;align-items:center">
1078
+ <button class="btn btn-primary btn-sm" onclick="runRouterTest('token-saver')" data-i18n="co.test_btn">Test Cost-Optimizer</button>
1079
+ </div>
1080
+ <div class="test-result" id="test-token-saver-result">
1081
+ <div class="test-result-row"><span class="test-result-label" data-i18n="test.level">Level</span><span class="test-result-value" id="tr-token-saver-level">-</span></div>
1082
+ <div class="test-result-row"><span class="test-result-label" data-i18n="test.action">Action</span><span class="test-result-value" id="tr-token-saver-action">-</span></div>
1083
+ <div class="test-result-row"><span class="test-result-label" data-i18n="test.target">Target</span><span class="test-result-value" id="tr-token-saver-target">-</span></div>
1084
+ <div class="test-result-row"><span class="test-result-label" data-i18n="test.reason">Reason</span><span class="test-result-value" id="tr-token-saver-reason">-</span></div>
1085
+ <div class="test-result-row"><span class="test-result-label" data-i18n="test.confidence">Confidence</span><span class="test-result-value" id="tr-token-saver-confidence">-</span></div>
1086
+ </div>
1087
+ <div class="test-loading" id="test-token-saver-loading" style="display:none" data-i18n="test.testing">Testing...</div>
1088
+ </div>
1089
+
1090
+ <!-- Advanced Configuration -->
1091
+ <div class="adv-toggle" onclick="toggleAdv(this)">
1092
+ <span class="adv-arrow">&#9654;</span> <span data-i18n="co.adv">Advanced Configuration</span>
1093
+ </div>
1094
+ <div class="adv-body">
1095
+
1096
+ <!-- Cache Duration -->
1097
+ <div class="subsection">
1098
+ <h4 data-i18n="co.cache">Cache</h4>
1099
+ <div class="field">
1100
+ <label data-i18n="co.cache_dur">Cache Duration (ms)</label>
1101
+ <input id="cfg-ts-cachettl" type="number" placeholder="300000" style="max-width:180px">
1102
+ </div>
1103
+ </div>
1104
+
1105
+ </div>
1106
+
1107
+ <div class="save-bar"><button class="btn btn-primary" onclick="saveTokenSaverConfig()" data-i18n="co.save">Save Cost-Optimizer</button></div>
1108
+ </div>
1109
+ </div>
1110
+
1111
+ <!-- ═══ Custom Router Cards (rendered dynamically) ═══ -->
1112
+ <div id="custom-router-cards"></div>
1113
+
1114
+ <!-- Add Custom Router -->
1115
+ <div class="add-custom-router">
1116
+ <div style="display:flex;gap:10px;align-items:center">
1117
+ <input id="new-router-id" data-i18n-ph="cr.add_ph" placeholder="Router ID (e.g. content-filter)" style="flex:1;padding:10px 14px;background:var(--bg-input);border:1px solid transparent;border-radius:8px;color:var(--text-primary);font-size:13px;outline:none">
1118
+ <button class="btn btn-primary" onclick="addCustomRouter()" data-i18n="cr.add_btn">+ Add Custom Router</button>
1119
+ </div>
1120
+ <div class="hint" style="margin-top:8px" data-i18n="cr.add_hint">Create a new router with keyword rules and an optional LLM classification prompt. Added routers appear above and can be included in Router Execution Order.</div>
1121
+ </div>
1122
+
1123
+ </div>
1124
+
1125
+ <!-- Configuration -->
1126
+ <div id="config-panel" class="panel">
1127
+
1128
+ <div class="toggle-bar">
1129
+ <label data-i18n="cfg.enabled">ClawXrouter Enabled</label>
1130
+ <label class="toggle"><input type="checkbox" id="cfg-enabled" checked><span class="slider"></span></label>
1131
+ </div>
1132
+
1133
+ <div class="config-section">
1134
+ <h3><span data-i18n="cfg.lm">Local Model</span> <span class="badge badge-hot">instant</span></h3>
1135
+ <div class="hint" style="margin-bottom:14px" data-i18n="cfg.lm_desc">Configure the LLM used locally for privacy classification and PII redaction.</div>
1136
+ <div class="field-toggle">
1137
+ <label data-i18n="cfg.lm_enabled">Enabled</label>
1138
+ <label class="toggle"><input type="checkbox" id="cfg-lm-enabled" checked><span class="slider"></span></label>
1139
+ </div>
1140
+ <div class="field">
1141
+ <label data-i18n="cfg.api_proto">API Protocol</label>
1142
+ <select id="cfg-lm-type">
1143
+ <option value="openai-compatible">openai-compatible (Ollama, vLLM, LMStudio ...)</option>
1144
+ <option value="ollama-native">ollama-native (Ollama /api/chat)</option>
1145
+ <option value="custom">custom (user module)</option>
1146
+ </select>
1147
+ </div>
1148
+ <div class="field"><label data-i18n="cfg.provider">Provider</label><input id="cfg-lm-provider" placeholder="ollama"></div>
1149
+ <div class="field"><label data-i18n="cfg.endpoint">Endpoint</label><input id="cfg-lm-endpoint" placeholder="http://localhost:11434"></div>
1150
+ <div class="field"><label data-i18n="cfg.model">Model</label><input id="cfg-lm-model" placeholder="openbmb/minicpm4.1"></div>
1151
+ <div class="field"><label data-i18n="cfg.api_key">API Key</label><input id="cfg-lm-apikey" type="password" placeholder="sk-..."></div>
1152
+ <div class="field" id="cfg-lm-module-wrap" style="display:none"><label data-i18n="cfg.custom_mod">Custom Module Path</label><input id="cfg-lm-module" placeholder="./my-provider.js"></div>
1153
+ </div>
1154
+
1155
+ <div class="config-section">
1156
+ <h3><span data-i18n="cfg.cls">Cost-Optimizer Classifier</span> <span class="badge badge-hot">instant</span></h3>
1157
+ <div class="hint" style="margin-bottom:14px" data-i18n="cfg.cls_desc">LLM used by the Cost-Optimizer to determine task complexity. Falls back to the Local Model settings above if empty.</div>
1158
+ <div class="field"><label data-i18n="cfg.endpoint">Endpoint</label><input id="cfg-ts-endpoint" placeholder="(inherits from Local Model)"></div>
1159
+ <div class="field"><label data-i18n="cfg.model">Model</label><input id="cfg-ts-model" placeholder="(inherits from Local Model)"></div>
1160
+ <div class="field">
1161
+ <label data-i18n="cfg.api_proto">API Protocol</label>
1162
+ <select id="cfg-ts-providertype">
1163
+ <option value="openai-compatible">openai-compatible</option>
1164
+ <option value="ollama-native">ollama-native</option>
1165
+ <option value="custom">custom</option>
1166
+ </select>
1167
+ </div>
1168
+ </div>
1169
+
1170
+ <div class="adv-toggle" onclick="toggleAdv(this)">
1171
+ <span class="adv-arrow">&#9654;</span> <span data-i18n="cfg.adv">Advanced Settings</span>
1172
+ </div>
1173
+ <div class="adv-body">
1174
+
1175
+ <div class="config-section">
1176
+ <h3><span data-i18n="cfg.guard">Privacy Guard Agent</span> <span class="badge badge-hot">instant</span></h3>
1177
+ <div class="hint" style="margin-bottom:14px" data-i18n="cfg.guard_desc">A local agent that handles sensitive tasks entirely on-device.</div>
1178
+ <div class="field"><label data-i18n="cfg.agent_id">Agent ID</label><input id="cfg-ga-id" placeholder="guard"></div>
1179
+ <div class="field"><label data-i18n="cfg.workspace">Workspace</label><input id="cfg-ga-workspace" placeholder="~/.openclaw/workspace-guard"></div>
1180
+ <div class="field"><label data-i18n="cfg.model_prov">Model (provider/model)</label><input id="cfg-ga-model" placeholder="ollama/qwen3.5-27b"></div>
1181
+ </div>
1182
+
1183
+ <div class="config-section">
1184
+ <h3><span data-i18n="cfg.routing">Routing Policy</span> <span class="badge badge-hot">instant</span></h3>
1185
+ <div class="hint" style="margin-bottom:14px" data-i18n="cfg.routing_desc">How S2-level sensitive data is handled before reaching the cloud.</div>
1186
+ <div class="field">
1187
+ <label data-i18n="cfg.sens_route">Sensitive Data Routing</label>
1188
+ <select id="cfg-s2policy">
1189
+ <option value="proxy" data-i18n-opt="cfg.s2_proxy">Proxy (redact personal info before sending)</option>
1190
+ <option value="local" data-i18n-opt="cfg.s2_local">Local only (process on-device, no cloud)</option>
1191
+ </select>
1192
+ </div>
1193
+ <div class="field">
1194
+ <label data-i18n="cfg.proxy_port">Proxy Port</label>
1195
+ <input id="cfg-proxyport" type="number" placeholder="8403" style="max-width:160px">
1196
+ <div class="hint" data-i18n="cfg.restart_hint">Requires restart to take effect</div>
1197
+ </div>
1198
+ </div>
1199
+
1200
+ <div class="config-section">
1201
+ <h3><span data-i18n="cfg.session">Session Settings</span> <span class="badge badge-hot">instant</span></h3>
1202
+ <div class="hint" style="margin-bottom:14px" data-i18n="cfg.session_desc">Manage isolation and storage of guard-related session data.</div>
1203
+ <div class="field-toggle">
1204
+ <label data-i18n="cfg.isolate">Separate Guard Chat History</label>
1205
+ <label class="toggle"><input type="checkbox" id="cfg-sess-isolate" checked><span class="slider"></span></label>
1206
+ </div>
1207
+ <div class="field"><label data-i18n="cfg.base_dir">Base Directory</label><input id="cfg-sess-basedir" placeholder="~/.openclaw"></div>
1208
+ </div>
1209
+
1210
+ <div class="config-section">
1211
+ <h3><span data-i18n="cfg.redaction">Rule-based Redaction</span> <span class="badge badge-hot">instant</span></h3>
1212
+ <div class="hint" style="margin-bottom:14px" data-i18n="cfg.redaction_desc">Toggle individual PII pattern rules. Off by default to reduce false positives.</div>
1213
+ <div class="field-toggle"><label data-i18n="cfg.rd_ip">Internal IP Addresses (10.x, 172.x, 192.168.x)</label><label class="toggle"><input type="checkbox" id="cfg-rd-internalIp"><span class="slider"></span></label></div>
1214
+ <div class="field-toggle"><label data-i18n="cfg.rd_email">Email Addresses</label><label class="toggle"><input type="checkbox" id="cfg-rd-email"><span class="slider"></span></label></div>
1215
+ <div class="field-toggle"><label data-i18n="cfg.rd_env">Environment Variables (.env KEY=VALUE)</label><label class="toggle"><input type="checkbox" id="cfg-rd-envVar"><span class="slider"></span></label></div>
1216
+ <div class="field-toggle"><label data-i18n="cfg.rd_card">Credit Card Numbers (13-19 digits)</label><label class="toggle"><input type="checkbox" id="cfg-rd-creditCard"><span class="slider"></span></label></div>
1217
+ <div class="field-toggle"><label data-i18n="cfg.rd_phone">Chinese Mobile Phone (1[3-9]x)</label><label class="toggle"><input type="checkbox" id="cfg-rd-chinesePhone"><span class="slider"></span></label></div>
1218
+ <div class="field-toggle"><label data-i18n="cfg.rd_id">Chinese ID Card (18 digits)</label><label class="toggle"><input type="checkbox" id="cfg-rd-chineseId"><span class="slider"></span></label></div>
1219
+ <div class="field-toggle"><label data-i18n="cfg.rd_addr">Chinese Addresses</label><label class="toggle"><input type="checkbox" id="cfg-rd-chineseAddress"><span class="slider"></span></label></div>
1220
+ <div class="field-toggle"><label data-i18n="cfg.rd_pin">PIN / Pin Code</label><label class="toggle"><input type="checkbox" id="cfg-rd-pin"><span class="slider"></span></label></div>
1221
+ </div>
1222
+
1223
+ <div class="config-section">
1224
+ <h3><span data-i18n="cfg.local_prov">Local Providers</span> <span class="badge badge-hot">instant</span></h3>
1225
+ <div class="field">
1226
+ <label data-i18n="cfg.local_prov_hint">Additional providers treated as &quot;local&quot; (safe for confidential data routing)</label>
1227
+ <div class="tag-list" id="cfg-tags-lp"></div>
1228
+ <div class="add-row">
1229
+ <input id="cfg-tags-lp-input" placeholder="e.g. my-inference-server" onkeydown="if(event.key==='Enter'){event.preventDefault();addTag('lp')}">
1230
+ <button class="btn btn-sm btn-outline" onclick="addTag('lp')">Add</button>
1231
+ </div>
1232
+ </div>
1233
+ </div>
1234
+
1235
+ </div>
1236
+
1237
+ <div class="config-section">
1238
+ <h3><span data-i18n="cfg.pricing">Model Pricing</span> <span class="badge badge-hot">instant</span></h3>
1239
+ <div class="hint" style="margin-bottom:14px" data-i18n="cfg.pricing_desc">Configure per-model pricing for cloud API cost estimation (USD per 1M tokens). Only cloud models are tracked.</div>
1240
+ <table class="data-table" id="pricing-table">
1241
+ <thead><tr><th data-i18n="cfg.pricing_model">Model</th><th data-i18n="cfg.pricing_input">Input $/1M</th><th data-i18n="cfg.pricing_output">Output $/1M</th><th style="width:40px"></th></tr></thead>
1242
+ <tbody id="pricing-body"></tbody>
1243
+ </table>
1244
+ <div class="add-row" style="margin-top:12px">
1245
+ <input id="pricing-new-model" placeholder="e.g. gpt-4o" style="flex:2;padding:10px 14px;background:var(--bg-input);border:1px solid transparent;border-radius:var(--radius-sm);color:var(--text-primary);font-size:13px;outline:none">
1246
+ <input id="pricing-new-input" type="number" step="0.01" placeholder="Input $/1M" style="flex:1;padding:10px 14px;background:var(--bg-input);border:1px solid transparent;border-radius:var(--radius-sm);color:var(--text-primary);font-size:13px;outline:none">
1247
+ <input id="pricing-new-output" type="number" step="0.01" placeholder="Output $/1M" style="flex:1;padding:10px 14px;background:var(--bg-input);border:1px solid transparent;border-radius:var(--radius-sm);color:var(--text-primary);font-size:13px;outline:none">
1248
+ <button class="btn btn-sm btn-outline" onclick="addPricingRow()" data-i18n="cfg.pricing_add">Add Model</button>
1249
+ </div>
1250
+ <div style="margin-top:10px">
1251
+ <button class="btn btn-sm btn-outline" onclick="loadDefaultPricing()" data-i18n="cfg.pricing_load">Load Defaults</button>
1252
+ </div>
1253
+ </div>
1254
+
1255
+ <div class="save-bar">
1256
+ <button class="btn btn-primary" onclick="saveConfig()" data-i18n="cfg.save">Save Configuration</button>
1257
+ </div>
1258
+ </div>
1259
+
1260
+ <div class="toast" id="toast"></div>
1261
+
1262
+ <script>
1263
+ var BASE = '/plugins/${deps?.pluginId ?? "clawxrouter"}/stats/api';
1264
+ var hourlyChart = null;
1265
+ var _detections = [];
1266
+ var _detectionFilter = 'all';
1267
+ var _routerFilter = 'all';
1268
+
1269
+ // ── i18n ──
1270
+ var LANG = localStorage.getItem('gc-lang') || 'en';
1271
+ var T = {
1272
+ 'tab.overview':{en:'Overview',zh:'概览'},
1273
+ 'tab.sessions':{en:'Sessions',zh:'会话'},
1274
+ 'tab.detections':{en:'Detection Log',zh:'检测日志'},
1275
+ 'tab.rules':{en:'Router Rules',zh:'路由规则'},
1276
+ 'tab.config':{en:'Configuration',zh:'配置'},
1277
+ 'header.title':{en:'ClawXrouter Dashboard',zh:'ClawXrouter 控制台'},
1278
+ 'header.connecting':{en:'Connecting...',zh:'连接中...'},
1279
+ 'header.refresh':{en:'Refresh',zh:'刷新'},
1280
+ 'header.online':{en:'Online',zh:'在线'},
1281
+ 'overview.cloud':{en:'Cloud Tokens',zh:'云端 Tokens'},
1282
+ 'overview.local':{en:'Local Tokens',zh:'本地 Tokens'},
1283
+ 'overview.redacted':{en:'Redacted Tokens',zh:'脱敏 Tokens'},
1284
+ 'overview.protection':{en:'Data Protection Rate',zh:'数据保护率'},
1285
+ 'overview.cost':{en:'Cloud Cost',zh:'云端费用'},
1286
+ 'overview.cost_sub':{en:'estimated cloud API cost',zh:'估算云端 API 费用'},
1287
+ 'overview.sub':{en:'of total tokens protected',zh:'受保护的 Token 占比'},
1288
+ 'overview.chart':{en:'Hourly Token Usage',zh:'每小时 Token 用量'},
1289
+ 'overview.requests':{en:'requests',zh:'请求'},
1290
+ 'overview.no_data':{en:'No data yet',zh:'暂无数据'},
1291
+ 'overview.reset_btn':{en:'Reset Stats',zh:'重置统计'},
1292
+ 'overview.reset_confirm':{en:'Reset all token statistics? This cannot be undone.',zh:'确定要重置所有 Token 统计数据吗?此操作不可撤销。'},
1293
+ 'overview.reset_ok':{en:'Stats reset successfully',zh:'统计数据已重置'},
1294
+ 'overview.reset_fail':{en:'Failed to reset stats: ',zh:'重置统计失败:'},
1295
+ 'table.category':{en:'Category',zh:'分类'},
1296
+ 'table.input':{en:'Input',zh:'输入'},
1297
+ 'table.output':{en:'Output',zh:'输出'},
1298
+ 'table.cache':{en:'Cache Read',zh:'缓存读取'},
1299
+ 'table.total':{en:'Total',zh:'总计'},
1300
+ 'table.requests':{en:'Requests',zh:'请求数'},
1301
+ 'table.cost':{en:'Cost',zh:'费用'},
1302
+ 'table.by_source':{en:'By Source (Router vs Task)',zh:'按来源(路由开销 vs 任务执行)'},
1303
+ 'table.source':{en:'Source',zh:'来源'},
1304
+ 'sessions.session':{en:'Session',zh:'会话'},
1305
+ 'sessions.message':{en:'Message',zh:'消息'},
1306
+ 'sessions.level':{en:'Level',zh:'等级'},
1307
+ 'sessions.cloud':{en:'Cloud',zh:'云端'},
1308
+ 'sessions.input':{en:'Input',zh:'输入'},
1309
+ 'sessions.output':{en:'Output',zh:'输出'},
1310
+ 'sessions.cache':{en:'Cache',zh:'缓存'},
1311
+ 'sessions.cloud_cost':{en:'Cloud Model Cost',zh:'云端模型费用'},
1312
+ 'sessions.local':{en:'Local',zh:'本地'},
1313
+ 'sessions.redacted':{en:'Redacted',zh:'脱敏'},
1314
+ 'sessions.cost':{en:'Cost',zh:'费用'},
1315
+ 'sessions.total':{en:'Total',zh:'总计'},
1316
+ 'sessions.requests':{en:'Requests',zh:'请求数'},
1317
+ 'sessions.time':{en:'Time',zh:'时间'},
1318
+ 'sessions.last_active':{en:'Last Active',zh:'最近活跃'},
1319
+ 'sessions.empty':{en:'No session data yet',zh:'暂无会话数据'},
1320
+ 'sd.back':{en:'Back to Sessions',zh:'返回会话列表'},
1321
+ 'sd.live':{en:'Live',zh:'实时'},
1322
+ 'sd.timeline_empty':{en:'No routing decisions yet',zh:'暂无路由决策记录'},
1323
+ 'sd.detecting':{en:'Detecting…',zh:'识别中…'},
1324
+ 'sd.generating':{en:'Generating response…',zh:'生成回复中…'},
1325
+ 'sd.complete':{en:'Complete',zh:'完成'},
1326
+ 'sd.checkpoint.onUserMessage':{en:'User Message',zh:'用户消息'},
1327
+ 'sd.checkpoint.onToolCallProposed':{en:'Tool Call',zh:'工具调用'},
1328
+ 'sd.checkpoint.onToolCallExecuted':{en:'Tool Result',zh:'工具结果'},
1329
+ 'sd.checkpoint.onLlmOutput':{en:'LLM Output',zh:'模型输出'},
1330
+ 'sd.router':{en:'Router',zh:'路由'},
1331
+ 'sd.target':{en:'Target',zh:'目标'},
1332
+ 'sd.reason':{en:'Reason',zh:'原因'},
1333
+ 'sd.highest_level':{en:'Highest Level',zh:'最高等级'},
1334
+ 'sd.routing':{en:'Routing',zh:'路由决策'},
1335
+ 'sd.total_steps':{en:'Steps',zh:'步数'},
1336
+ 'sd.total_tokens':{en:'Tokens',zh:'Tokens'},
1337
+ 'sd.requests':{en:'Requests',zh:'请求数'},
1338
+ 'det.time':{en:'Time',zh:'时间'},
1339
+ 'det.session':{en:'Session',zh:'会话'},
1340
+ 'det.level':{en:'Level',zh:'等级'},
1341
+ 'det.checkpoint':{en:'Checkpoint',zh:'检查点'},
1342
+ 'det.reason':{en:'Reason',zh:'原因'},
1343
+ 'det.router':{en:'Router',zh:'路由器'},
1344
+ 'det.action':{en:'Action',zh:'动作'},
1345
+ 'det.target':{en:'Target',zh:'目标'},
1346
+ 'det.all_routers':{en:'All Routers',zh:'全部路由'},
1347
+ 'det.empty':{en:'No detections yet',zh:'暂无检测记录'},
1348
+ 'det.empty_for':{en:'No detections for ',zh:'暂无检测记录:'},
1349
+ 'det.all':{en:'All',zh:'全部'},
1350
+ 'test.title':{en:'Test Classification',zh:'分类测试'},
1351
+ 'test.hint':{en:'Test how the router pipeline would classify a message (no changes applied).',zh:'测试路由管道如何对消息进行分类(不会实际生效)。'},
1352
+ 'test.placeholder':{en:'e.g. "帮我分析一下这个月的工资单" or "write a poem about spring"',zh:'例如 "帮我分析一下这个月的工资单" 或 "write a poem about spring"'},
1353
+ 'test.run':{en:'Run Test',zh:'运行测试'},
1354
+ 'test.merged':{en:'Merged Result',zh:'合并结果'},
1355
+ 'test.level':{en:'Level',zh:'等级'},
1356
+ 'test.action':{en:'Action',zh:'动作'},
1357
+ 'test.target':{en:'Target',zh:'目标'},
1358
+ 'test.deciding':{en:'Deciding Router',zh:'决策路由'},
1359
+ 'test.reason':{en:'Reason',zh:'原因'},
1360
+ 'test.confidence':{en:'Confidence',zh:'置信度'},
1361
+ 'test.classifying':{en:'Classifying...',zh:'分类中...'},
1362
+ 'test.testing':{en:'Testing...',zh:'测试中...'},
1363
+ 'test.individual':{en:'Individual Router Results',zh:'各路由独立结果'},
1364
+ 'test.enter_msg':{en:'Enter a test message',zh:'请输入测试消息'},
1365
+ 'test.failed':{en:'Test failed: ',zh:'测试失败:'},
1366
+ 'ck.user_message':{en:'User Message',zh:'用户消息'},
1367
+ 'ck.before_tool':{en:'Before Tool Runs',zh:'工具执行前'},
1368
+ 'ck.after_tool':{en:'After Tool Runs',zh:'工具执行后'},
1369
+ 'pipe.title':{en:'Router Execution Order (Advanced)',zh:'路由执行顺序(高级)'},
1370
+ 'pipe.hint':{en:'Click a router to add it to a stage. Drag tags to reorder. Click \\u00d7 to remove.',zh:'点击路由添加到对应阶段。拖拽标签调整顺序,点击 \\u00d7 移除。'},
1371
+ 'pipe.save':{en:'Save Execution Order',zh:'保存执行顺序'},
1372
+ 'pipe.saved':{en:'Execution order saved',zh:'执行顺序已保存'},
1373
+ 'priv.title':{en:'Privacy Router',zh:'隐私路由'},
1374
+ 'priv.desc':{en:'Detects sensitive data in messages and routes to local or redacted cloud models.',zh:'检测消息中的敏感数据,路由到本地模型或脱敏后发送云端。'},
1375
+ 'priv.keywords':{en:'Keywords',zh:'关键词'},
1376
+ 'priv.s2':{en:'S2 \\u2014 Sensitive (Redact \\u2192 Cloud)',zh:'S2 \\u2014 敏感(脱敏后走云端)'},
1377
+ 'priv.s3':{en:'S3 \\u2014 Confidential (Local Model Only)',zh:'S3 \\u2014 机密(仅本地模型)'},
1378
+ 'priv.llm_prompt':{en:'LLM Prompt',zh:'LLM 提示词'},
1379
+ 'priv.llm_hint':{en:'Prompt used by the local LLM to classify data sensitivity (S1/S2/S3).',zh:'本地 LLM 用于分类数据敏感等级(S1/S2/S3)的提示词。'},
1380
+ 'priv.test_title':{en:'Test (Privacy Router Only)',zh:'测试(仅隐私路由)'},
1381
+ 'priv.test_ph':{en:'Enter a message to test the privacy router alone...',zh:'输入消息以单独测试隐私路由...'},
1382
+ 'priv.test_btn':{en:'Test Privacy Router',zh:'测试隐私路由'},
1383
+ 'priv.save':{en:'Save Privacy Router',zh:'保存隐私路由'},
1384
+ 'priv.saved':{en:'Privacy Router saved',zh:'隐私路由已保存'},
1385
+ 'priv.adv':{en:'Advanced Configuration',zh:'高级配置'},
1386
+ 'priv.when':{en:'When to Run',zh:'何时运行'},
1387
+ 'priv.when_hint':{en:'Select which detectors run at each stage for the privacy router.',zh:'选择隐私路由在每个阶段运行的检测器。'},
1388
+ 'priv.kw_regex':{en:'Keyword \\u0026 Regex',zh:'关键词和正则'},
1389
+ 'priv.llm_cls':{en:'LLM Classifier',zh:'LLM 分类器'},
1390
+ 'priv.det_rules':{en:'Detection Rules (Regex \\u0026 Tool Filters)',zh:'检测规则(正则和工具过滤)'},
1391
+ 'priv.regex':{en:'Regex Patterns',zh:'正则表达式'},
1392
+ 'priv.tools':{en:'Sensitive Tool Names',zh:'敏感工具名'},
1393
+ 'priv.paths':{en:'Sensitive File Paths',zh:'敏感文件路径'},
1394
+ 'priv.pii':{en:'Personal Info Redaction Prompt',zh:'个人信息脱敏提示词'},
1395
+ 'priv.pii_hint':{en:'Prompt used by the local LLM to extract and redact personal info.',zh:'本地 LLM 用于提取和脱敏个人信息的提示词。'},
1396
+ 'co.title':{en:'Cost-Optimizer Router',zh:'成本优化路由'},
1397
+ 'co.desc':{en:'Classifies task complexity and routes to the most cost-effective model.',zh:'判断任务复杂度,自动选择性价比最高的模型。'},
1398
+ 'co.tier':{en:'Complexity Level \\u2192 Model',zh:'复杂度等级 \\u2192 模型'},
1399
+ 'co.complexity':{en:'Complexity',zh:'复杂度'},
1400
+ 'co.provider':{en:'Provider',zh:'供应商'},
1401
+ 'co.model':{en:'Model',zh:'模型'},
1402
+ 'co.llm_prompt':{en:'LLM Prompt',zh:'LLM 提示词'},
1403
+ 'co.llm_hint':{en:'Prompt used by the classifier LLM to determine task complexity.',zh:'分类 LLM 用于判断任务复杂度的提示词。'},
1404
+ 'co.test_title':{en:'Test (Cost-Optimizer Only)',zh:'测试(仅成本优化)'},
1405
+ 'co.test_ph':{en:'Enter a message to test the cost-optimizer router alone...',zh:'输入消息以单独测试成本优化路由...'},
1406
+ 'co.test_btn':{en:'Test Cost-Optimizer',zh:'测试成本优化'},
1407
+ 'co.save':{en:'Save Cost-Optimizer',zh:'保存成本优化'},
1408
+ 'co.saved':{en:'Cost-Optimizer config saved',zh:'成本优化配置已保存'},
1409
+ 'co.adv':{en:'Advanced Configuration',zh:'高级配置'},
1410
+ 'co.cache':{en:'Cache',zh:'缓存'},
1411
+ 'co.cache_dur':{en:'Cache Duration (ms)',zh:'缓存时长(毫秒)'},
1412
+ 'cr.add_ph':{en:'Router ID (e.g. content-filter)',zh:'路由 ID(如 content-filter)'},
1413
+ 'cr.add_btn':{en:'+ Add Custom Router',zh:'+ 添加自定义路由'},
1414
+ 'cr.add_hint':{en:'Create a new router with keyword rules and an optional LLM classification prompt. Added routers appear above and can be included in Router Execution Order.',zh:'创建一个带有关键词规则和可选 LLM 分类提示词的新路由。添加后显示在上方,可加入路由执行顺序。'},
1415
+ 'cr.kw_rules':{en:'Keyword Rules',zh:'关键词规则'},
1416
+ 'cr.s2_kw':{en:'S2 \\u2014 Sensitive Keywords',zh:'S2 \\u2014 敏感关键词'},
1417
+ 'cr.s3_kw':{en:'S3 \\u2014 Confidential Keywords',zh:'S3 \\u2014 机密关键词'},
1418
+ 'cr.s2_pat':{en:'S2 \\u2014 Sensitive Patterns (regex)',zh:'S2 \\u2014 敏感模式(正则)'},
1419
+ 'cr.s3_pat':{en:'S3 \\u2014 Confidential Patterns (regex)',zh:'S3 \\u2014 机密模式(正则)'},
1420
+ 'cr.cls_prompt':{en:'Classification Prompt',zh:'分类提示词'},
1421
+ 'cr.cls_hint':{en:'If set, the local LLM will classify messages using this prompt. Should output JSON with {level, reason}.',zh:'如果设置,本地 LLM 将使用此提示词分类消息。应输出包含 {level, reason} 的 JSON。'},
1422
+ 'cr.enter_id':{en:'Enter a router ID',zh:'请输入路由 ID'},
1423
+ 'cr.exists':{en:'" already exists',zh:'" 已存在'},
1424
+ 'cr.created':{en:'" created \\u2014 configure and save it below',zh:'" 已创建 \\u2014 请在下方配置并保存'},
1425
+ 'cr.del_pre':{en:'Delete router "',zh:'确认删除路由 "'},
1426
+ 'cr.del_suf':{en:'"? This cannot be undone.',zh:'"?此操作不可撤销。'},
1427
+ 'cr.deleted':{en:'" deleted',zh:'" 已删除'},
1428
+ 'cr.saved':{en:'" saved',zh:'" 已保存'},
1429
+ 'cfg.enabled':{en:'ClawXrouter Enabled',zh:'ClawXrouter 启用'},
1430
+ 'cfg.lm':{en:'Local Model',zh:'本地模型'},
1431
+ 'cfg.lm_desc':{en:'Configure the LLM used locally for privacy classification and PII redaction.',zh:'配置用于隐私分类和个人信息脱敏的本地 LLM。'},
1432
+ 'cfg.lm_enabled':{en:'Enabled',zh:'启用'},
1433
+ 'cfg.api_proto':{en:'API Protocol',zh:'API 协议'},
1434
+ 'cfg.provider':{en:'Provider',zh:'供应商'},
1435
+ 'cfg.endpoint':{en:'Endpoint',zh:'端点'},
1436
+ 'cfg.model':{en:'Model',zh:'模型'},
1437
+ 'cfg.api_key':{en:'API Key',zh:'API 密钥'},
1438
+ 'cfg.custom_mod':{en:'Custom Module Path',zh:'自定义模块路径'},
1439
+ 'cfg.cls':{en:'Cost-Optimizer Classifier',zh:'成本优化分类器'},
1440
+ 'cfg.cls_desc':{en:'LLM used by the Cost-Optimizer to determine task complexity. Falls back to the Local Model settings above if empty.',zh:'成本优化路由用于判断任务复杂度的 LLM。留空则使用上方本地模型配置。'},
1441
+ 'cfg.adv':{en:'Advanced Settings',zh:'高级设置'},
1442
+ 'cfg.guard':{en:'Privacy Guard Agent',zh:'隐私守护 Agent'},
1443
+ 'cfg.guard_desc':{en:'A local agent that handles sensitive tasks entirely on-device.',zh:'完全在本地运行的隐私守护 Agent。'},
1444
+ 'cfg.agent_id':{en:'Agent ID',zh:'Agent ID'},
1445
+ 'cfg.workspace':{en:'Workspace',zh:'工作目录'},
1446
+ 'cfg.model_prov':{en:'Model (provider/model)',zh:'模型(供应商/模型)'},
1447
+ 'cfg.routing':{en:'Routing Policy',zh:'路由策略'},
1448
+ 'cfg.routing_desc':{en:'How S2-level sensitive data is handled before reaching the cloud.',zh:'S2 级敏感数据发送云端前的处理策略。'},
1449
+ 'cfg.sens_route':{en:'Sensitive Data Routing',zh:'敏感数据路由'},
1450
+ 'cfg.s2_proxy':{en:'Proxy (redact personal info before sending)',zh:'代理(发送前脱敏个人信息)'},
1451
+ 'cfg.s2_local':{en:'Local only (process on-device, no cloud)',zh:'仅本地(设备端处理,不上云)'},
1452
+ 'cfg.proxy_port':{en:'Proxy Port',zh:'代理端口'},
1453
+ 'cfg.restart_hint':{en:'Requires restart to take effect',zh:'需要重启生效'},
1454
+ 'cfg.session':{en:'Session Settings',zh:'会话设置'},
1455
+ 'cfg.session_desc':{en:'Manage isolation and storage of guard-related session data.',zh:'管理隔离与存储守护相关的会话数据。'},
1456
+ 'cfg.isolate':{en:'Separate Guard Chat History',zh:'隔离守护聊天记录'},
1457
+ 'cfg.base_dir':{en:'Base Directory',zh:'基础目录'},
1458
+ 'cfg.local_prov':{en:'Local Providers',zh:'本地供应商'},
1459
+ 'cfg.local_prov_hint':{en:'Additional providers treated as "local" (safe for confidential data routing)',zh:'额外视为"本地"的供应商(可安全路由机密数据)'},
1460
+ 'cfg.pricing':{en:'Model Pricing',zh:'模型定价'},
1461
+ 'cfg.pricing_desc':{en:'Configure per-model pricing for cloud API cost estimation (USD per 1M tokens). Only cloud models are tracked.',zh:'配置云端模型的单价用于费用估算(美元/百万 Token)。仅统计云端模型。'},
1462
+ 'cfg.pricing_model':{en:'Model',zh:'模型'},
1463
+ 'cfg.pricing_input':{en:'Input $/1M',zh:'输入 $/1M'},
1464
+ 'cfg.pricing_output':{en:'Output $/1M',zh:'输出 $/1M'},
1465
+ 'cfg.pricing_add':{en:'Add Model',zh:'添加模型'},
1466
+ 'cfg.pricing_load':{en:'Load Defaults',zh:'加载默认'},
1467
+ 'cfg.redaction':{en:'Rule-based Redaction',zh:'规则脱敏'},
1468
+ 'cfg.redaction_desc':{en:'Toggle individual PII pattern rules. Off by default to reduce false positives.',zh:'控制各条 PII 正则规则的启停,默认关闭以减少误报。'},
1469
+ 'cfg.rd_ip':{en:'Internal IP Addresses (10.x, 172.x, 192.168.x)',zh:'内网 IP 地址 (10.x, 172.x, 192.168.x)'},
1470
+ 'cfg.rd_email':{en:'Email Addresses',zh:'电子邮箱'},
1471
+ 'cfg.rd_env':{en:'Environment Variables (.env KEY=VALUE)',zh:'环境变量 (.env KEY=VALUE)'},
1472
+ 'cfg.rd_card':{en:'Credit Card Numbers (13-19 digits)',zh:'信用卡号 (13-19 位)'},
1473
+ 'cfg.rd_phone':{en:'Chinese Mobile Phone (1[3-9]x)',zh:'中国手机号 (1[3-9]x)'},
1474
+ 'cfg.rd_id':{en:'Chinese ID Card (18 digits)',zh:'中国身份证 (18 位)'},
1475
+ 'cfg.rd_addr':{en:'Chinese Addresses',zh:'中国地址'},
1476
+ 'cfg.rd_pin':{en:'PIN / Pin Code',zh:'PIN 码'},
1477
+ 'cfg.save':{en:'Save Configuration',zh:'保存配置'},
1478
+ 'cfg.saved':{en:'Configuration saved',zh:'配置已保存'},
1479
+ 'common.add':{en:'Add',zh:'添加'},
1480
+ 'common.save':{en:'Save',zh:'保存'},
1481
+ 'common.delete':{en:'Delete',zh:'删除'},
1482
+ 'common.test':{en:'Test',zh:'测试'},
1483
+ 'common.enabled':{en:'Enabled',zh:'启用'},
1484
+ 'common.optional':{en:'(optional)',zh:'(可选)'},
1485
+ 'common.none':{en:'(none)',zh:'(无)'},
1486
+ 'common.customized':{en:'customized',zh:'已自定义'},
1487
+ 'common.reset':{en:'Reset Default',zh:'恢复默认'},
1488
+ 'common.save_failed':{en:'Save failed: ',zh:'保存失败:'},
1489
+ 'common.loading':{en:'Loading prompts...',zh:'加载提示词中...'},
1490
+ 'common.prompt_saved':{en:'" saved & applied',zh:'" 已保存并生效'},
1491
+ 'chart.cloud':{en:'Cloud',zh:'云端'},
1492
+ 'chart.local':{en:'Local',zh:'本地'},
1493
+ 'chart.redacted':{en:'Redacted',zh:'脱敏'},
1494
+ 'status.uptime':{en:'Uptime: ',zh:'运行时间:'},
1495
+ 'status.activity':{en:'Last activity: ',zh:'最近活动:'},
1496
+ 'status.updated':{en:'Updated ',zh:'已更新 '},
1497
+ 'status.error':{en:'Error: ',zh:'错误:'},
1498
+ };
1499
+ function t(k){return(T[k]&&T[k][LANG])||k;}
1500
+
1501
+ function setLang(lang){
1502
+ LANG=lang;
1503
+ localStorage.setItem('gc-lang',lang);
1504
+ document.querySelectorAll('[data-i18n]').forEach(function(el){
1505
+ var k=el.getAttribute('data-i18n');
1506
+ if(T[k]) el.textContent=t(k);
1507
+ });
1508
+ document.querySelectorAll('[data-i18n-html]').forEach(function(el){
1509
+ var k=el.getAttribute('data-i18n-html');
1510
+ if(T[k]) el.innerHTML=t(k);
1511
+ });
1512
+ document.querySelectorAll('[data-i18n-ph]').forEach(function(el){
1513
+ var k=el.getAttribute('data-i18n-ph');
1514
+ if(T[k]) el.placeholder=t(k);
1515
+ });
1516
+ document.querySelectorAll('[data-i18n-opt]').forEach(function(el){
1517
+ var k=el.getAttribute('data-i18n-opt');
1518
+ if(T[k]) el.textContent=t(k);
1519
+ });
1520
+ document.getElementById('lang-toggle').textContent=lang==='en'?'中文':'EN';
1521
+ document.querySelectorAll('.add-row .btn-outline').forEach(function(el){el.textContent=t('common.add');});
1522
+ if (hourlyChart) { hourlyChart.destroy(); hourlyChart=null; }
1523
+ refreshAll();
1524
+ renderCustomRouterCards();
1525
+ updateAvailableRouters();
1526
+ loadPrompts();
1527
+ }
1528
+ // ── Generic tag management ──
1529
+ var _tags = {
1530
+ 'kw-s2': [], 'kw-s3': [], 'pat-s2': [], 'pat-s3': [],
1531
+ 'tool-s2': [], 'tool-s3': [], 'toolpath-s2': [], 'toolpath-s3': [],
1532
+ 'lp': [],
1533
+ 'pipe-um': [], 'pipe-tcp': [], 'pipe-tce': []
1534
+ };
1535
+
1536
+ var _checkpoints = { um: [], tcp: [], tce: [] };
1537
+ var _routers = {};
1538
+
1539
+ function escHtml(s) {
1540
+ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
1541
+ }
1542
+
1543
+ function renderTags(key) {
1544
+ var c = document.getElementById('cfg-tags-' + key);
1545
+ if (!c) return;
1546
+ c.innerHTML = _tags[key].map(function(v, i) {
1547
+ return '<span class="tag">' + escHtml(v) +
1548
+ ' <button data-key="' + key + '" data-idx="' + i + '" onclick="removeTag(this)">&times;</button></span>';
1549
+ }).join('');
1550
+ }
1551
+
1552
+ function addTag(key) {
1553
+ var input = document.getElementById('cfg-tags-' + key + '-input');
1554
+ if (!input) return;
1555
+ var val = input.value.trim();
1556
+ if (val && _tags[key].indexOf(val) === -1) {
1557
+ _tags[key].push(val);
1558
+ renderTags(key);
1559
+ }
1560
+ input.value = '';
1561
+ input.focus();
1562
+ }
1563
+
1564
+ function removeTag(el) {
1565
+ var key = el.getAttribute('data-key');
1566
+ var idx = parseInt(el.getAttribute('data-idx'));
1567
+ if (key && _tags[key]) {
1568
+ _tags[key].splice(idx, 1);
1569
+ renderTags(key);
1570
+ }
1571
+ }
1572
+
1573
+ // ── Checkpoint chips ──
1574
+ function toggleChip(el) {
1575
+ var ck = el.getAttribute('data-ck');
1576
+ var det = el.getAttribute('data-det');
1577
+ if (!ck || !det || !_checkpoints[ck]) return;
1578
+ var arr = _checkpoints[ck];
1579
+ var idx = arr.indexOf(det);
1580
+ if (idx === -1) { arr.push(det); el.classList.add('active'); }
1581
+ else { arr.splice(idx, 1); el.classList.remove('active'); }
1582
+ }
1583
+
1584
+ function syncChips() {
1585
+ document.querySelectorAll('.chip[data-ck]').forEach(function(el) {
1586
+ var ck = el.getAttribute('data-ck');
1587
+ var det = el.getAttribute('data-det');
1588
+ if (_checkpoints[ck] && _checkpoints[ck].indexOf(det) !== -1) {
1589
+ el.classList.add('active');
1590
+ } else {
1591
+ el.classList.remove('active');
1592
+ }
1593
+ });
1594
+ }
1595
+
1596
+ // ── Router management ──
1597
+ function renderRouters() {
1598
+ var c = document.getElementById('cfg-routers-list');
1599
+ if (!c) return;
1600
+ var ids = Object.keys(_routers);
1601
+ if (!ids.length) {
1602
+ c.innerHTML = '<div style="color:var(--text-tertiary);font-size:13px;padding:8px 0">No routers configured</div>';
1603
+ return;
1604
+ }
1605
+ c.innerHTML = ids.map(function(id) {
1606
+ var r = _routers[id];
1607
+ var checked = r.enabled !== false ? ' checked' : '';
1608
+ return '<div class="router-card"><div class="rc-head">' +
1609
+ '<label class="toggle"><input type="checkbox"' + checked +
1610
+ ' data-rid="' + escHtml(id) + '" onchange="toggleRouter(this)"><span class="slider"></span></label>' +
1611
+ '<span class="rc-name">' + escHtml(id) + '</span>' +
1612
+ '<span class="rc-type">[' + escHtml(r.type || 'builtin') + ']</span>' +
1613
+ '<button class="rc-del" data-rid="' + escHtml(id) + '" onclick="removeRouter(this)">&times;</button>' +
1614
+ '</div>' +
1615
+ (r.module ? '<div class="rc-module">Module: ' + escHtml(r.module) + '</div>' : '') +
1616
+ '</div>';
1617
+ }).join('');
1618
+ }
1619
+
1620
+ function toggleRouter(el) {
1621
+ var id = el.getAttribute('data-rid');
1622
+ if (id && _routers[id]) _routers[id].enabled = el.checked;
1623
+ }
1624
+
1625
+ function removeRouter(el) {
1626
+ var id = el.getAttribute('data-rid');
1627
+ if (id) { delete _routers[id]; renderRouters(); }
1628
+ }
1629
+
1630
+ function addRouter() {
1631
+ var idInput = document.getElementById('cfg-router-id-input');
1632
+ var typeInput = document.getElementById('cfg-router-type-input');
1633
+ var moduleInput = document.getElementById('cfg-router-module-input');
1634
+ var id = idInput.value.trim();
1635
+ if (!id) return;
1636
+ _routers[id] = {
1637
+ enabled: true,
1638
+ type: typeInput.value || 'builtin',
1639
+ module: typeInput.value === 'custom' ? (moduleInput.value.trim() || undefined) : undefined
1640
+ };
1641
+ renderRouters();
1642
+ idInput.value = '';
1643
+ moduleInput.value = '';
1644
+ }
1645
+
1646
+ // ── Model Pricing ──
1647
+ var _pricing = {};
1648
+
1649
+ var DEFAULT_PRICING = {
1650
+ 'claude-sonnet-4.6': { inputPer1M: 3, outputPer1M: 15 },
1651
+ 'claude-3.5-sonnet': { inputPer1M: 3, outputPer1M: 15 },
1652
+ 'claude-3.5-haiku': { inputPer1M: 0.8, outputPer1M: 4 },
1653
+ 'gpt-4o': { inputPer1M: 2.5, outputPer1M: 10 },
1654
+ 'gpt-4o-mini': { inputPer1M: 0.15, outputPer1M: 0.6 },
1655
+ 'o4-mini': { inputPer1M: 1.1, outputPer1M: 4.4 },
1656
+ 'gemini-2.0-flash': { inputPer1M: 0.1, outputPer1M: 0.4 },
1657
+ 'deepseek-chat': { inputPer1M: 0.27, outputPer1M: 1.1 },
1658
+ 'gpt-5.4': { inputPer1M: 7.5, outputPer1M: 60 }
1659
+ };
1660
+
1661
+ function renderPricing() {
1662
+ var tbody = document.getElementById('pricing-body');
1663
+ if (!tbody) return;
1664
+ var keys = Object.keys(_pricing);
1665
+ if (!keys.length) {
1666
+ tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;color:var(--text-tertiary);font-size:13px;padding:14px 0">No pricing configured</td></tr>';
1667
+ return;
1668
+ }
1669
+ tbody.innerHTML = keys.sort().map(function(model) {
1670
+ var p = _pricing[model];
1671
+ var eid = escHtml(model);
1672
+ return '<tr>' +
1673
+ '<td style="font-family:var(--font-mono);font-size:12px">' + eid + '</td>' +
1674
+ '<td><input type="number" step="0.01" min="0" value="' + (p.inputPer1M ?? 0) + '" data-pricing-model="' + eid + '" data-pricing-field="inputPer1M" onchange="updatePricing(this)" style="width:80px;padding:6px 8px;background:var(--bg-input);border:1px solid transparent;border-radius:4px;font-size:12px;color:var(--text-primary);outline:none"></td>' +
1675
+ '<td><input type="number" step="0.01" min="0" value="' + (p.outputPer1M ?? 0) + '" data-pricing-model="' + eid + '" data-pricing-field="outputPer1M" onchange="updatePricing(this)" style="width:80px;padding:6px 8px;background:var(--bg-input);border:1px solid transparent;border-radius:4px;font-size:12px;color:var(--text-primary);outline:none"></td>' +
1676
+ '<td><button style="background:none;border:none;color:var(--text-tertiary);cursor:pointer;font-size:14px" onclick="removePricing(\\'' + eid + '\\')">&times;</button></td>' +
1677
+ '</tr>';
1678
+ }).join('');
1679
+ }
1680
+
1681
+ function updatePricing(el) {
1682
+ var model = el.getAttribute('data-pricing-model');
1683
+ var field = el.getAttribute('data-pricing-field');
1684
+ if (!model || !field || !_pricing[model]) return;
1685
+ _pricing[model][field] = parseFloat(el.value) || 0;
1686
+ }
1687
+
1688
+ function addPricingRow() {
1689
+ var modelEl = document.getElementById('pricing-new-model');
1690
+ var inputEl = document.getElementById('pricing-new-input');
1691
+ var outputEl = document.getElementById('pricing-new-output');
1692
+ var model = modelEl.value.trim();
1693
+ if (!model) return;
1694
+ _pricing[model] = {
1695
+ inputPer1M: parseFloat(inputEl.value) || 0,
1696
+ outputPer1M: parseFloat(outputEl.value) || 0
1697
+ };
1698
+ modelEl.value = '';
1699
+ inputEl.value = '';
1700
+ outputEl.value = '';
1701
+ modelEl.focus();
1702
+ renderPricing();
1703
+ }
1704
+
1705
+ function removePricing(model) {
1706
+ delete _pricing[model];
1707
+ renderPricing();
1708
+ }
1709
+
1710
+ function loadDefaultPricing() {
1711
+ Object.keys(DEFAULT_PRICING).forEach(function(k) {
1712
+ if (!_pricing[k]) _pricing[k] = Object.assign({}, DEFAULT_PRICING[k]);
1713
+ });
1714
+ renderPricing();
1715
+ }
1716
+
1717
+ // ── Tabs ──
1718
+ document.querySelectorAll('.tab').forEach(function(t) {
1719
+ t.addEventListener('click', function() {
1720
+ if (_activeSessionKey && t.dataset.tab !== 'sessions') hideSessionDetail();
1721
+ document.querySelectorAll('.tab').forEach(function(x) { x.classList.remove('active'); });
1722
+ document.querySelectorAll('.panel').forEach(function(x) { x.classList.remove('active'); });
1723
+ t.classList.add('active');
1724
+ document.getElementById(t.dataset.tab + '-panel').classList.add('active');
1725
+ });
1726
+ });
1727
+
1728
+ // ── Formatters ──
1729
+ function fmt(n) {
1730
+ if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
1731
+ if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K';
1732
+ return String(n);
1733
+ }
1734
+ function fmtCost(c) {
1735
+ if (c <= 0) return '$0.00';
1736
+ if (c < 0.01) return '<$0.01';
1737
+ return '$' + c.toFixed(2);
1738
+ }
1739
+
1740
+ var _counterAnims = {};
1741
+ function animateCounter(id, target, formatter, useFloat) {
1742
+ var el = document.getElementById(id);
1743
+ if (!el) return;
1744
+ var wrap = el.parentElement;
1745
+ var key = id;
1746
+ if (_counterAnims[key]) cancelAnimationFrame(_counterAnims[key]);
1747
+ var startVal = parseFloat(el.dataset.val || '0') || 0;
1748
+ if (startVal === target) return;
1749
+ var duration = 600;
1750
+ var startTime = null;
1751
+ if (wrap) { wrap.classList.remove('flash'); void wrap.offsetWidth; wrap.classList.add('flash'); }
1752
+ function tick(now) {
1753
+ if (!startTime) startTime = now;
1754
+ var progress = Math.min((now - startTime) / duration, 1);
1755
+ var ease = 1 - Math.pow(1 - progress, 3);
1756
+ var current = startVal + (target - startVal) * ease;
1757
+ el.textContent = formatter(useFloat ? current : Math.round(current));
1758
+ if (progress < 1) {
1759
+ _counterAnims[key] = requestAnimationFrame(tick);
1760
+ } else {
1761
+ el.textContent = formatter(target);
1762
+ el.dataset.val = String(target);
1763
+ delete _counterAnims[key];
1764
+ setTimeout(function() { if (wrap) wrap.classList.remove('flash'); }, 800);
1765
+ }
1766
+ }
1767
+ _counterAnims[key] = requestAnimationFrame(tick);
1768
+ }
1769
+
1770
+ function timeAgo(ts) {
1771
+ var diff = Date.now() - ts;
1772
+ if (diff < 60000) return Math.floor(diff / 1000) + 's ago';
1773
+ if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago';
1774
+ if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago';
1775
+ return Math.floor(diff / 86400000) + 'd ago';
1776
+ }
1777
+
1778
+ function fmtTime(ts) {
1779
+ var d = new Date(ts);
1780
+ var hh = String(d.getHours()).padStart(2, '0');
1781
+ var mm = String(d.getMinutes()).padStart(2, '0');
1782
+ var ss = String(d.getSeconds()).padStart(2, '0');
1783
+ return hh + ':' + mm + ':' + ss;
1784
+ }
1785
+
1786
+
1787
+ function fillRow(cat, b) {
1788
+ var cost = b.estimatedCost || 0;
1789
+ return '<tr><td>' + cat + '</td><td>' + fmt(b.inputTokens) + '</td><td>' + fmt(b.outputTokens) +
1790
+ '</td><td>' + fmt(b.cacheReadTokens) + '</td><td>' + fmt(b.totalTokens) + '</td><td>' + b.requestCount + '</td><td>' + fmtCost(cost) + '</td></tr>';
1791
+ }
1792
+
1793
+ // ── Overview ──
1794
+ async function refreshStats() {
1795
+ try {
1796
+ var results = await Promise.all([
1797
+ fetch(BASE + '/summary').then(function(r) { return r.json(); }),
1798
+ fetch(BASE + '/hourly').then(function(r) { return r.json(); }),
1799
+ ]);
1800
+ var summary = results[0];
1801
+ var hourly = results[1];
1802
+ if (summary.error) throw new Error(summary.error);
1803
+
1804
+ var lt = summary.lifetime;
1805
+ document.getElementById('cloud-tokens').textContent = fmt(lt.cloud.totalTokens);
1806
+ document.getElementById('cloud-reqs').textContent = lt.cloud.requestCount + ' ' + t('overview.requests');
1807
+ document.getElementById('local-tokens').textContent = fmt(lt.local.totalTokens);
1808
+ document.getElementById('local-reqs').textContent = lt.local.requestCount + ' ' + t('overview.requests');
1809
+ document.getElementById('proxy-tokens').textContent = fmt(lt.proxy.totalTokens);
1810
+ document.getElementById('proxy-reqs').textContent = lt.proxy.requestCount + ' ' + t('overview.requests');
1811
+
1812
+ var total = lt.cloud.totalTokens + lt.local.totalTokens + lt.proxy.totalTokens;
1813
+ var prot = lt.local.totalTokens + lt.proxy.totalTokens;
1814
+ var rate = total > 0 ? (prot / total * 100).toFixed(1) + '%' : '--';
1815
+ document.getElementById('privacy-rate').textContent = rate;
1816
+ document.getElementById('privacy-sub').textContent = total > 0
1817
+ ? fmt(prot) + ' / ' + fmt(total) + ' ' + t('overview.sub')
1818
+ : t('overview.no_data');
1819
+
1820
+ var cloudCost = (lt.cloud.estimatedCost || 0) + (lt.proxy.estimatedCost || 0);
1821
+ document.getElementById('cloud-cost').textContent = fmtCost(cloudCost);
1822
+ document.getElementById('cloud-cost-sub').textContent = t('overview.cost_sub');
1823
+
1824
+ document.getElementById('detail-body').innerHTML =
1825
+ fillRow(t('chart.cloud'), lt.cloud) + fillRow(t('chart.local'), lt.local) + fillRow(t('chart.redacted'), lt.proxy);
1826
+
1827
+ var bs = summary.bySource || {};
1828
+ var routerB = bs.router || {inputTokens:0,outputTokens:0,cacheReadTokens:0,totalTokens:0,requestCount:0};
1829
+ var taskB = bs.task || {inputTokens:0,outputTokens:0,cacheReadTokens:0,totalTokens:0,requestCount:0};
1830
+ document.getElementById('source-body').innerHTML =
1831
+ fillRow('🔀 Router (overhead)', routerB) + fillRow('⚡ Task (execution)', taskB);
1832
+
1833
+ var infoHtml = '';
1834
+ if (summary.startedAt) infoHtml += t('status.uptime') + timeAgo(summary.startedAt);
1835
+ if (summary.lastUpdatedAt) infoHtml += ' &middot; ' + t('status.activity') + timeAgo(summary.lastUpdatedAt);
1836
+ document.getElementById('info-bar').innerHTML = infoHtml;
1837
+
1838
+ document.getElementById('status-dot').className = 'status-dot';
1839
+ document.getElementById('status-text').textContent = t('header.online');
1840
+ document.getElementById('last-updated').textContent = t('status.updated') + fmtTime(Date.now());
1841
+
1842
+ updateChart(hourly);
1843
+ } catch (e) {
1844
+ document.getElementById('status-dot').className = 'status-dot err';
1845
+ document.getElementById('status-text').textContent = t('status.error') + (e.message || 'unavailable');
1846
+ }
1847
+ }
1848
+
1849
+ async function resetStats() {
1850
+ if (!confirm(t('overview.reset_confirm'))) return;
1851
+ try {
1852
+ var r = await fetch(BASE + '/reset', { method: 'POST' });
1853
+ var body = await r.json();
1854
+ if (body.error) throw new Error(body.error);
1855
+ _sessionsList = [];
1856
+ _timelineDetections = [];
1857
+ _pendingLoops.clear();
1858
+ _sessionsResetFlag = true;
1859
+ hideSessionDetail();
1860
+ showToast(t('overview.reset_ok'));
1861
+ refreshStats();
1862
+ refreshSessions();
1863
+ } catch (e) {
1864
+ showToast(t('overview.reset_fail') + (e.message || ''), true);
1865
+ }
1866
+ }
1867
+
1868
+ function updateChart(hourly) {
1869
+ var labels = hourly.map(function(h) { return h.hour.slice(5).replace('T', ' ') + ':00'; });
1870
+ var cloudData = hourly.map(function(h) { return h.cloud.totalTokens; });
1871
+ var localData = hourly.map(function(h) { return h.local.totalTokens; });
1872
+ var proxyData = hourly.map(function(h) { return h.proxy.totalTokens; });
1873
+ if (hourlyChart) {
1874
+ hourlyChart.data.labels = labels;
1875
+ hourlyChart.data.datasets[0].data = cloudData;
1876
+ hourlyChart.data.datasets[1].data = localData;
1877
+ hourlyChart.data.datasets[2].data = proxyData;
1878
+ hourlyChart.update('none');
1879
+ } else {
1880
+ var ctx = document.getElementById('hourlyChart');
1881
+ if (!ctx) return;
1882
+ hourlyChart = new Chart(ctx, {
1883
+ type: 'line',
1884
+ data: {
1885
+ labels: labels,
1886
+ datasets: [
1887
+ { label: t('chart.cloud'), data: cloudData, borderColor: '#2563eb', backgroundColor: 'rgba(37,99,235,0.06)', fill: true, tension: 0.4, borderWidth: 2 },
1888
+ { label: t('chart.local'), data: localData, borderColor: '#059669', backgroundColor: 'rgba(5,150,105,0.06)', fill: true, tension: 0.4, borderWidth: 2 },
1889
+ { label: t('chart.redacted'), data: proxyData, borderColor: '#d97706', backgroundColor: 'rgba(217,119,6,0.06)', fill: true, tension: 0.4, borderWidth: 2 },
1890
+ ],
1891
+ },
1892
+ options: {
1893
+ responsive: true,
1894
+ plugins: { legend: { labels: { color: '#6e6e80', usePointStyle: true, pointStyle: 'circle', padding: 20, font: { size: 12, weight: 500 } } } },
1895
+ scales: {
1896
+ x: { ticks: { color: '#9ca3af', maxTicksLimit: 12, font: { size: 11 } }, grid: { color: 'rgba(0,0,0,.04)' } },
1897
+ y: { ticks: { color: '#9ca3af', font: { size: 11 } }, grid: { color: 'rgba(0,0,0,.04)' } },
1898
+ },
1899
+ },
1900
+ });
1901
+ }
1902
+ }
1903
+
1904
+ // ── Sessions ──
1905
+ function totalForSession(s) {
1906
+ return s.cloud.totalTokens + s.proxy.totalTokens;
1907
+ }
1908
+ function totalReqsForSession(s) {
1909
+ return s.cloud.requestCount + s.proxy.requestCount;
1910
+ }
1911
+
1912
+ var _sessionsResetFlag = false;
1913
+
1914
+ async function refreshSessions() {
1915
+ try {
1916
+ var sessions = await fetch(BASE + '/sessions').then(function(r) { return r.json(); });
1917
+ var tbody = document.getElementById('sessions-body');
1918
+ if (!sessions || !sessions.length) {
1919
+ if (_sessionsResetFlag || _sessionsList.length === 0) {
1920
+ _sessionsList = [];
1921
+ _sessionsResetFlag = false;
1922
+ tbody.innerHTML = '<tr><td colspan="8" class="empty-state">' + t('sessions.empty') + '</td></tr>';
1923
+ }
1924
+ return;
1925
+ }
1926
+ _sessionsResetFlag = false;
1927
+ var apiKeys = {};
1928
+ for (var i = 0; i < sessions.length; i++) {
1929
+ var k = sessions[i].loopId ? (sessions[i].sessionKey + '::' + sessions[i].loopId) : sessions[i].sessionKey;
1930
+ apiKeys[k] = true;
1931
+ }
1932
+ for (var j = 0; j < _sessionsList.length; j++) {
1933
+ var ek = _sessionsList[j].loopId ? (_sessionsList[j].sessionKey + '::' + _sessionsList[j].loopId) : _sessionsList[j].sessionKey;
1934
+ if (!apiKeys[ek]) sessions.push(_sessionsList[j]);
1935
+ }
1936
+ _sessionsList = sessions;
1937
+ var loadingCell = '<span class="detecting-spinner" style="margin-right:4px"></span>';
1938
+ tbody.innerHTML = sessions.map(function(s) {
1939
+ var label = s.userMessagePreview || s.sessionKey;
1940
+ var shortLabel = label.length > 40 ? label.slice(0, 40) + '...' : label;
1941
+ var lid = s.loopId || '';
1942
+ var onclick = "showSessionDetail('" + escHtml(s.sessionKey).replace(/'/g, "\\\\'") + "','" + escHtml(lid).replace(/'/g, "\\\\'") + "')";
1943
+ var loopKey = s.loopId ? (s.sessionKey + '::' + s.loopId) : s.sessionKey;
1944
+ var isPending = _pendingLoops.has(loopKey);
1945
+ var inputTok = s.cloud ? s.cloud.inputTokens || 0 : 0;
1946
+ var outputTok = s.cloud ? s.cloud.outputTokens || 0 : 0;
1947
+ var cacheTok = s.cloud ? s.cloud.cacheReadTokens || 0 : 0;
1948
+ var cloudCost = (s.cloud ? s.cloud.estimatedCost || 0 : 0) + (s.proxy ? s.proxy.estimatedCost || 0 : 0);
1949
+ if (isPending) {
1950
+ return '<tr onclick="' + onclick + '">' +
1951
+ '<td><span class="session-key" title="' + escHtml(label) + '">' + escHtml(shortLabel) + '</span></td>' +
1952
+ '<td><span class="level-tag level-' + s.highestLevel + '">' + s.highestLevel + '</span></td>' +
1953
+ '<td colspan="5" style="color:var(--text-tertiary);font-style:italic">' + loadingCell + t('sd.generating') + '</td>' +
1954
+ '<td>' + fmtTime(s.firstSeenAt) + '</td>' +
1955
+ '</tr>';
1956
+ }
1957
+ return '<tr onclick="' + onclick + '">' +
1958
+ '<td><span class="session-key" title="' + escHtml(label) + '">' + escHtml(shortLabel) + '</span></td>' +
1959
+ '<td><span class="level-tag level-' + s.highestLevel + '">' + s.highestLevel + '</span></td>' +
1960
+ '<td>' + fmt(inputTok) + '</td>' +
1961
+ '<td>' + fmt(outputTok) + '</td>' +
1962
+ '<td>' + fmt(cacheTok) + '</td>' +
1963
+ '<td>' + fmtCost(cloudCost) + '</td>' +
1964
+ '<td>' + totalReqsForSession(s) + '</td>' +
1965
+ '<td>' + fmtTime(s.firstSeenAt) + '</td>' +
1966
+ '</tr>';
1967
+ }).join('');
1968
+ if (_activeSessionKey) {
1969
+ var activeS = null;
1970
+ for (var ai = 0; ai < _sessionsList.length; ai++) {
1971
+ var as = _sessionsList[ai];
1972
+ if (_activeLoopId) {
1973
+ if (as.sessionKey === _activeSessionKey && as.loopId === _activeLoopId) { activeS = as; break; }
1974
+ } else {
1975
+ if (as.sessionKey === _activeSessionKey) { activeS = as; break; }
1976
+ }
1977
+ }
1978
+ if (activeS) {
1979
+ renderSessionHeader(activeS.userMessagePreview || activeS.sessionKey);
1980
+ }
1981
+ }
1982
+ } catch (e) { /* non-critical */ }
1983
+ }
1984
+
1985
+ // ── Session Detail Timeline (SSE real-time) ──
1986
+ var _activeSessionKey = null;
1987
+ var _activeLoopId = null;
1988
+ var _sessionEventSource = null;
1989
+ var _sessionPollTimer = null;
1990
+ var _timelineDetections = [];
1991
+ var _sessionsList = [];
1992
+ var _pendingLoops = new Set();
1993
+ var _levelRank = { S1: 1, S2: 2, S3: 3 };
1994
+ var _sessionHighest = 'S1';
1995
+
1996
+ function fmtTime(ts) {
1997
+ if (!ts) return '-';
1998
+ var d = new Date(ts);
1999
+ var pad = function(n) { return n < 10 ? '0' + n : '' + n; };
2000
+ return pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
2001
+ }
2002
+
2003
+ function checkpointLabel(cp) {
2004
+ return t('sd.checkpoint.' + cp) || cp;
2005
+ }
2006
+
2007
+ function checkpointIcon(cp) {
2008
+ if (cp === 'onUserMessage') return '\\u{1F4AC}';
2009
+ if (cp === 'onToolCallProposed') return '\\u{1F527}';
2010
+ if (cp === 'onToolCallExecuted') return '\\u{1F4CB}';
2011
+ if (cp === 'onLlmOutput') return '\\u{2728}';
2012
+ return '\\u{25CF}';
2013
+ }
2014
+
2015
+ function buildTimelineItemHtml(d, idx) {
2016
+ var actionClass = '';
2017
+ if (d.action === 'redirect') actionClass = 'action-redirect';
2018
+ else if (d.action === 'block') actionClass = 'action-block';
2019
+ else if (d.action === 'transform') actionClass = 'action-transform';
2020
+ else if (d.action === 'passthrough') actionClass = 'action-passthrough';
2021
+
2022
+ var targetHtml = d.target ? '<span class="timeline-target">' + escHtml(d.target) + '</span>' : '';
2023
+ var routerHtml = d.routerId ? '<span class="timeline-router">' + escHtml(d.routerId) + '</span>' : '';
2024
+
2025
+ var isTokenSaver = d.routerId === 'token-saver';
2026
+ var reasonText = d.reason || '';
2027
+ if (isTokenSaver && reasonText.indexOf('tier=') === 0) {
2028
+ reasonText = '';
2029
+ }
2030
+ var reasonHtml = reasonText ? '<div class="timeline-reason">' + escHtml(reasonText) + '</div>' : '';
2031
+
2032
+ return '<div class="timeline-item" style="animation-delay:' + (idx * 0.04) + 's">' +
2033
+ '<div class="timeline-dot dot-' + d.level + '"></div>' +
2034
+ '<div class="timeline-card">' +
2035
+ '<div class="timeline-top">' +
2036
+ '<span class="timeline-time">' + fmtTime(d.timestamp) + '</span>' +
2037
+ '<span class="level-tag level-' + d.level + '">' + d.level + '</span>' +
2038
+ '<span class="checkpoint-tag">' + checkpointIcon(d.checkpoint) + ' ' + checkpointLabel(d.checkpoint) + '</span>' +
2039
+ '</div>' +
2040
+ (isTokenSaver ? '' :
2041
+ '<div class="timeline-meta">' +
2042
+ routerHtml +
2043
+ (d.action ? '<span class="action-tag ' + actionClass + '">' + escHtml(d.action) + '</span>' : '') +
2044
+ targetHtml +
2045
+ '</div>') +
2046
+ reasonHtml +
2047
+ '</div>' +
2048
+ '</div>';
2049
+ }
2050
+
2051
+ function renderTimeline(detections) {
2052
+ var el = document.getElementById('session-timeline');
2053
+ if (!detections || !detections.length) {
2054
+ el.innerHTML = '<div class="empty-state">' + t('sd.timeline_empty') + '</div>';
2055
+ return;
2056
+ }
2057
+ var sorted = detections.slice().sort(function(a, b) { return a.timestamp - b.timestamp; });
2058
+ el.innerHTML = sorted.map(function(d, i) { return buildTimelineItemHtml(d, i); }).join('');
2059
+ }
2060
+
2061
+ function buildPendingItemHtml(d) {
2062
+ return '<div class="timeline-item pending" id="pending-' + d.checkpoint + '">' +
2063
+ '<div class="timeline-dot"></div>' +
2064
+ '<div class="timeline-card">' +
2065
+ '<div class="timeline-top">' +
2066
+ '<span class="timeline-time">' + fmtTime(d.timestamp) + '</span>' +
2067
+ '<span class="checkpoint-tag">' + checkpointIcon(d.checkpoint) + ' ' + checkpointLabel(d.checkpoint) + '</span>' +
2068
+ '</div>' +
2069
+ '<div class="timeline-meta">' +
2070
+ '<span class="detecting-label"><span class="detecting-spinner"></span> ' + t('sd.detecting') + '</span>' +
2071
+ '</div>' +
2072
+ '</div>' +
2073
+ '</div>';
2074
+ }
2075
+
2076
+ function appendTimelineItem(d) {
2077
+ var el = document.getElementById('session-timeline');
2078
+ if (_timelineDetections.length === 1 && !el.querySelector('.pending')) {
2079
+ el.innerHTML = '';
2080
+ }
2081
+ var div = document.createElement('div');
2082
+ div.innerHTML = buildTimelineItemHtml(d, _timelineDetections.length - 1);
2083
+ var node = div.firstChild;
2084
+ el.appendChild(node);
2085
+ node.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
2086
+ }
2087
+
2088
+ function appendPendingItem(d) {
2089
+ var el = document.getElementById('session-timeline');
2090
+ var empty = el.querySelector('.empty-state');
2091
+ if (empty) empty.remove();
2092
+ var div = document.createElement('div');
2093
+ div.innerHTML = buildPendingItemHtml(d);
2094
+ var node = div.firstChild;
2095
+ el.appendChild(node);
2096
+ node.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
2097
+ }
2098
+
2099
+ function removeAllGeneratingIndicators() {
2100
+ var wrap = document.getElementById('sd-stat-status-wrap');
2101
+ if (wrap) wrap.style.display = 'none';
2102
+ var el = document.getElementById('sd-stat-status');
2103
+ if (el) el.innerHTML = '';
2104
+ }
2105
+
2106
+ function resolvePendingItem(d) {
2107
+ var el = document.getElementById('session-timeline');
2108
+ var pending = document.getElementById('pending-' + d.checkpoint);
2109
+ var div = document.createElement('div');
2110
+ div.innerHTML = buildTimelineItemHtml(d, _timelineDetections.length - 1);
2111
+ var node = div.firstChild;
2112
+ removeAllGeneratingIndicators();
2113
+ if (pending) {
2114
+ el.replaceChild(node, pending);
2115
+ } else {
2116
+ var empties = el.querySelectorAll('.empty-state');
2117
+ for (var i = 0; i < empties.length; i++) empties[i].remove();
2118
+ el.appendChild(node);
2119
+ }
2120
+ node.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
2121
+ }
2122
+
2123
+ function addGeneratingIndicator(checkpoint) {
2124
+ var wrap = document.getElementById('sd-stat-status-wrap');
2125
+ var el = document.getElementById('sd-stat-status');
2126
+ if (!wrap || !el) return;
2127
+ el.innerHTML = '<span class="generating-indicator" style="margin:0;padding:0;background:none"><span class="gen-dots"><span></span><span></span><span></span></span> ' + t('sd.generating') + '</span>';
2128
+ wrap.style.display = '';
2129
+ }
2130
+
2131
+ function resolveGeneratingIndicator() {
2132
+ var wrap = document.getElementById('sd-stat-status-wrap');
2133
+ var el = document.getElementById('sd-stat-status');
2134
+ if (!wrap || !el) return;
2135
+ el.innerHTML = '<span class="complete-badge" style="margin:0">\u2713 ' + t('sd.complete') + '</span>';
2136
+ setTimeout(function() { wrap.style.display = 'none'; el.innerHTML = ''; }, 3000);
2137
+ }
2138
+
2139
+ function renderSessionHeader(label) {
2140
+ var hdr = document.getElementById('sd-header');
2141
+ var s = null;
2142
+ for (var i = 0; i < _sessionsList.length; i++) {
2143
+ var si = _sessionsList[i];
2144
+ if (_activeLoopId) {
2145
+ if (si.sessionKey === _activeSessionKey && si.loopId === _activeLoopId) { s = si; break; }
2146
+ } else {
2147
+ if (si.sessionKey === _activeSessionKey) { s = si; break; }
2148
+ }
2149
+ }
2150
+ var level = (s && s.highestLevel) ? s.highestLevel : 'S1';
2151
+ var tierHtml = '';
2152
+ if (s && s.routingTier) {
2153
+ tierHtml = '<div class="sd-stat" id="sd-stat-tier-wrap"><div class="sd-stat-value" id="sd-stat-tier">' +
2154
+ '<span class="tier-badge tier-' + escHtml(s.routingTier) + '">\u26A1 ' + escHtml(s.routingTier) + '</span>' +
2155
+ (s.routedModel ? '<div style="font-size:11px;color:var(--text-secondary);margin-top:2px">\u2192 ' + escHtml(s.routedModel) + '</div>' : '') +
2156
+ '</div><div class="sd-stat-label">' + t('sd.routing') + '</div></div>';
2157
+ }
2158
+ var loopKey = _activeLoopId ? (_activeSessionKey + '::' + _activeLoopId) : _activeSessionKey;
2159
+ var isPending = _pendingLoops.has(loopKey);
2160
+ var loadingHtml = '<span class="detecting-spinner" style="width:12px;height:12px"></span>';
2161
+ var inputTok = s && s.cloud ? s.cloud.inputTokens || 0 : 0;
2162
+ var outputTok = s && s.cloud ? s.cloud.outputTokens || 0 : 0;
2163
+ var cacheTok = s && s.cloud ? s.cloud.cacheReadTokens || 0 : 0;
2164
+ var totalReqs = s ? totalReqsForSession(s) : 0;
2165
+ var totalCost = s ? ((s.cloud ? s.cloud.estimatedCost || 0 : 0) + (s.proxy ? s.proxy.estimatedCost || 0 : 0)) : 0;
2166
+ hdr.innerHTML =
2167
+ '<div class="sd-key">' + escHtml(label) + '</div>' +
2168
+ '<div class="sd-stat" id="sd-stat-level"><div class="sd-stat-value"><span class="level-tag level-' + level + '">' + level + '</span></div><div class="sd-stat-label">' + t('sd.highest_level') + '</div></div>' +
2169
+ tierHtml +
2170
+ '<div class="sd-stat" id="sd-stat-input-wrap"><div class="sd-stat-value" id="sd-stat-input">' + (isPending ? loadingHtml : fmt(inputTok)) + '</div><div class="sd-stat-label">' + t('sessions.input') + '</div></div>' +
2171
+ '<div class="sd-stat" id="sd-stat-output-wrap"><div class="sd-stat-value" id="sd-stat-output">' + (isPending ? loadingHtml : fmt(outputTok)) + '</div><div class="sd-stat-label">' + t('sessions.output') + '</div></div>' +
2172
+ '<div class="sd-stat" id="sd-stat-cache-wrap"><div class="sd-stat-value" id="sd-stat-cache">' + (isPending ? loadingHtml : fmt(cacheTok)) + '</div><div class="sd-stat-label">' + t('sessions.cache') + '</div></div>' +
2173
+ '<div class="sd-stat" id="sd-stat-cost-wrap"><div class="sd-stat-value" id="sd-stat-cost">' + (isPending ? loadingHtml : fmtCost(totalCost)) + '</div><div class="sd-stat-label">' + t('sessions.cloud_cost') + '</div></div>' +
2174
+ '<div class="sd-stat" id="sd-stat-reqs-wrap"><div class="sd-stat-value" id="sd-stat-reqs">' + (isPending ? loadingHtml : totalReqs) + '</div><div class="sd-stat-label">' + t('sd.requests') + '</div></div>' +
2175
+ '<div class="sd-stat" id="sd-stat-status-wrap" style="display:none"><div class="sd-stat-value" id="sd-stat-status"></div></div>';
2176
+ }
2177
+
2178
+ function closeSessionStream() {
2179
+ if (_sessionEventSource) {
2180
+ _sessionEventSource.close();
2181
+ _sessionEventSource = null;
2182
+ }
2183
+ if (_sessionPollTimer) {
2184
+ clearInterval(_sessionPollTimer);
2185
+ _sessionPollTimer = null;
2186
+ }
2187
+ }
2188
+
2189
+ function showSessionDetail(sessionKey, loopId) {
2190
+ _activeSessionKey = sessionKey;
2191
+ _activeLoopId = loopId || null;
2192
+ _timelineDetections = [];
2193
+ var existingSession = null;
2194
+ for (var i = 0; i < _sessionsList.length; i++) {
2195
+ var s = _sessionsList[i];
2196
+ if (loopId) {
2197
+ if (s.sessionKey === sessionKey && s.loopId === loopId) { existingSession = s; break; }
2198
+ } else {
2199
+ if (s.sessionKey === sessionKey) { existingSession = s; break; }
2200
+ }
2201
+ }
2202
+ _sessionHighest = (existingSession && existingSession.highestLevel) || 'S1';
2203
+ document.getElementById('session-list').style.display = 'none';
2204
+ var sd = document.getElementById('session-detail');
2205
+ sd.style.display = 'block';
2206
+ var headerLabel = (existingSession && existingSession.userMessagePreview) || sessionKey;
2207
+ renderSessionHeader(headerLabel);
2208
+ document.getElementById('session-timeline').innerHTML = '<div class="empty-state">' + t('sd.timeline_empty') + '</div>';
2209
+ requestAnimationFrame(function() {
2210
+ var tl = document.getElementById('session-timeline');
2211
+ if (tl) {
2212
+ var rect = tl.getBoundingClientRect();
2213
+ var available = window.innerHeight - rect.top - 24;
2214
+ if (available > 200) {
2215
+ tl.style.maxHeight = available + 'px';
2216
+ tl.style.overflowY = 'auto';
2217
+ }
2218
+ }
2219
+ });
2220
+
2221
+ closeSessionStream();
2222
+ var streamUrl = BASE + '/session-detections/stream?key=' + encodeURIComponent(sessionKey);
2223
+ if (loopId) streamUrl += '&loopId=' + encodeURIComponent(loopId);
2224
+ var es = new EventSource(streamUrl);
2225
+ _sessionEventSource = es;
2226
+
2227
+ es.addEventListener('snapshot', function(e) {
2228
+ if (_activeSessionKey !== sessionKey) return;
2229
+ var data = JSON.parse(e.data);
2230
+ _timelineDetections = data;
2231
+ renderTimeline(data);
2232
+ });
2233
+
2234
+ es.addEventListener('detection_start', function(e) {
2235
+ if (_activeSessionKey !== sessionKey) return;
2236
+ var d = JSON.parse(e.data);
2237
+ appendPendingItem(d);
2238
+ });
2239
+
2240
+ es.addEventListener('detection', function(e) {
2241
+ if (_activeSessionKey !== sessionKey) return;
2242
+ var d = JSON.parse(e.data);
2243
+ _timelineDetections.push(d);
2244
+ resolvePendingItem(d);
2245
+ if (d.level && (_levelRank[d.level] || 0) > (_levelRank[_sessionHighest] || 0)) {
2246
+ _sessionHighest = d.level;
2247
+ var lEl = document.getElementById('sd-stat-level');
2248
+ if (lEl) lEl.innerHTML = '<div class="sd-stat-value"><span class="level-tag level-' + d.level + '">' + d.level + '</span></div><div class="sd-stat-label">' + t('sd.highest_level') + '</div>';
2249
+ }
2250
+ });
2251
+
2252
+ es.addEventListener('generating', function(e) {
2253
+ if (_activeSessionKey !== sessionKey) return;
2254
+ var d = JSON.parse(e.data);
2255
+ addGeneratingIndicator(d.checkpoint);
2256
+ if (d.routerId === 'token-saver' && d.action && !document.getElementById('sd-stat-tier-wrap')) {
2257
+ var tier = '';
2258
+ if (d.target) {
2259
+ var parts = (d.target || '').split('/');
2260
+ var model = parts.length > 1 ? parts.slice(1).join('/') : d.target;
2261
+ }
2262
+ var reason = d.reason || '';
2263
+ if (reason.indexOf('tier=') === 0) tier = reason.split('=')[1];
2264
+ if (!tier) tier = d.action.toUpperCase();
2265
+ var tierEl = document.createElement('div');
2266
+ tierEl.className = 'sd-stat';
2267
+ tierEl.id = 'sd-stat-tier-wrap';
2268
+ tierEl.innerHTML = '<div class="sd-stat-value" id="sd-stat-tier">' +
2269
+ '<span class="tier-badge tier-' + escHtml(tier) + '">\u26A1 ' + escHtml(tier) + '</span>' +
2270
+ (d.target ? '<div style="font-size:11px;color:var(--text-secondary);margin-top:2px">\u2192 ' + escHtml(d.target) + '</div>' : '') +
2271
+ '</div><div class="sd-stat-label">' + t('sd.routing') + '</div>';
2272
+ var levelWrap = document.getElementById('sd-stat-level');
2273
+ if (levelWrap) levelWrap.parentNode.insertBefore(tierEl, levelWrap.nextSibling);
2274
+ }
2275
+ });
2276
+
2277
+ es.addEventListener('llm_complete', function(e) {
2278
+ if (_activeSessionKey !== sessionKey) return;
2279
+ var lk = loopId ? (sessionKey + '::' + loopId) : sessionKey;
2280
+ _pendingLoops.delete(lk);
2281
+ resolveGeneratingIndicator();
2282
+ renderSessionHeader(existingSession ? (existingSession.userMessagePreview || sessionKey) : sessionKey);
2283
+ });
2284
+
2285
+ es.addEventListener('input_estimate', function(e) {
2286
+ if (_activeSessionKey !== sessionKey) return;
2287
+ var lk = _activeLoopId ? (_activeSessionKey + '::' + _activeLoopId) : _activeSessionKey;
2288
+ if (_pendingLoops.has(lk)) return;
2289
+ var d = JSON.parse(e.data);
2290
+ var costEl = document.getElementById('sd-stat-cost');
2291
+ if (costEl) {
2292
+ var curCost = parseFloat(costEl.dataset.val || '0');
2293
+ if (d.estimatedCost > curCost) {
2294
+ costEl.textContent = fmtCost(d.estimatedCost);
2295
+ costEl.dataset.val = String(d.estimatedCost);
2296
+ var cw = costEl.parentElement;
2297
+ if (cw) { cw.classList.remove('flash'); void cw.offsetWidth; cw.classList.add('flash'); }
2298
+ }
2299
+ }
2300
+ var tokEl = document.getElementById('sd-stat-input');
2301
+ if (tokEl) {
2302
+ var curTok = parseFloat(tokEl.dataset.val || '0');
2303
+ if (d.estimatedInputTokens > curTok) {
2304
+ tokEl.textContent = fmt(d.estimatedInputTokens);
2305
+ tokEl.dataset.val = String(d.estimatedInputTokens);
2306
+ var tw = tokEl.parentElement;
2307
+ if (tw) { tw.classList.remove('flash'); void tw.offsetWidth; tw.classList.add('flash'); }
2308
+ }
2309
+ }
2310
+ });
2311
+
2312
+ es.addEventListener('token_update', function(e) {
2313
+ if (_activeSessionKey !== sessionKey) return;
2314
+ var s = JSON.parse(e.data);
2315
+ var inputTok = s.cloud ? s.cloud.inputTokens || 0 : 0;
2316
+ var outputTok = s.cloud ? s.cloud.outputTokens || 0 : 0;
2317
+ var cacheTok = s.cloud ? s.cloud.cacheReadTokens || 0 : 0;
2318
+ var totalCost = (s.cloud ? s.cloud.estimatedCost || 0 : 0) + (s.proxy ? s.proxy.estimatedCost || 0 : 0);
2319
+ function setStatMonotonic(id, val, formatter) {
2320
+ var el = document.getElementById(id);
2321
+ if (!el) return;
2322
+ var cur = parseFloat(el.dataset.val || '0');
2323
+ if (val < cur) return;
2324
+ var formatted = formatter(val);
2325
+ if (el.textContent === formatted) return;
2326
+ el.textContent = formatted;
2327
+ el.dataset.val = String(val);
2328
+ var w = el.parentElement;
2329
+ if (w) { w.classList.remove('flash'); void w.offsetWidth; w.classList.add('flash'); }
2330
+ }
2331
+ setStatMonotonic('sd-stat-input', inputTok, fmt);
2332
+ setStatMonotonic('sd-stat-output', outputTok, fmt);
2333
+ setStatMonotonic('sd-stat-cache', cacheTok, fmt);
2334
+ setStatMonotonic('sd-stat-cost', totalCost, fmtCost);
2335
+ var reqEl = document.getElementById('sd-stat-reqs');
2336
+ if (reqEl) {
2337
+ var fmtReqs = String(totalReqs);
2338
+ if (reqEl.textContent !== fmtReqs) {
2339
+ reqEl.textContent = fmtReqs;
2340
+ reqEl.dataset.val = String(totalReqs);
2341
+ var rw = reqEl.parentElement;
2342
+ if (rw) { rw.classList.remove('flash'); void rw.offsetWidth; rw.classList.add('flash'); }
2343
+ }
2344
+ }
2345
+ if (s.highestLevel && (_levelRank[s.highestLevel] || 0) > (_levelRank[_sessionHighest] || 0)) {
2346
+ _sessionHighest = s.highestLevel;
2347
+ var lEl = document.getElementById('sd-stat-level');
2348
+ if (lEl) lEl.innerHTML = '<div class="sd-stat-value"><span class="level-tag level-' + s.highestLevel + '">' + s.highestLevel + '</span></div><div class="sd-stat-label">' + t('sd.highest_level') + '</div>';
2349
+ }
2350
+ if (s.routingTier && !document.getElementById('sd-stat-tier-wrap')) {
2351
+ var tierEl = document.createElement('div');
2352
+ tierEl.className = 'sd-stat';
2353
+ tierEl.id = 'sd-stat-tier-wrap';
2354
+ tierEl.innerHTML = '<div class="sd-stat-value" id="sd-stat-tier">' +
2355
+ '<span class="tier-badge tier-' + escHtml(s.routingTier) + '">\u26A1 ' + escHtml(s.routingTier) + '</span>' +
2356
+ (s.routedModel ? '<div style="font-size:11px;color:var(--text-secondary);margin-top:2px">\u2192 ' + escHtml(s.routedModel) + '</div>' : '') +
2357
+ '</div><div class="sd-stat-label">' + t('sd.routing') + '</div>';
2358
+ var levelWrap = document.getElementById('sd-stat-level');
2359
+ if (levelWrap) levelWrap.parentNode.insertBefore(tierEl, levelWrap.nextSibling);
2360
+ }
2361
+ });
2362
+
2363
+ es.onerror = function(ev) {
2364
+ console.warn('[ClawXrouter SSE] connection error, readyState=' + es.readyState, ev);
2365
+ };
2366
+
2367
+ var _pollLoopId = loopId || null;
2368
+ _sessionPollTimer = setInterval(function() {
2369
+ if (_activeSessionKey !== sessionKey) return;
2370
+ fetch(BASE + '/sessions').then(function(r) { return r.json(); }).then(function(list) {
2371
+ if (_activeSessionKey !== sessionKey) return;
2372
+ var s = null;
2373
+ for (var i = 0; i < list.length; i++) {
2374
+ if (_pollLoopId) {
2375
+ if (list[i].sessionKey === sessionKey && list[i].loopId === _pollLoopId) { s = list[i]; break; }
2376
+ } else {
2377
+ if (list[i].sessionKey === sessionKey) { s = list[i]; break; }
2378
+ }
2379
+ }
2380
+ if (!s) return;
2381
+ var inputTok = s.cloud ? s.cloud.inputTokens || 0 : 0;
2382
+ var outputTok = s.cloud ? s.cloud.outputTokens || 0 : 0;
2383
+ var cacheTok = s.cloud ? s.cloud.cacheReadTokens || 0 : 0;
2384
+ var totalCost = (s.cloud ? s.cloud.estimatedCost || 0 : 0) + (s.proxy ? s.proxy.estimatedCost || 0 : 0);
2385
+ var totalReqs = (s.cloud ? s.cloud.requestCount : 0) + (s.proxy ? s.proxy.requestCount : 0);
2386
+ var elI = document.getElementById('sd-stat-input');
2387
+ if (elI && inputTok >= parseFloat(elI.dataset.val || '0')) { elI.textContent = fmt(inputTok); elI.dataset.val = String(inputTok); }
2388
+ var elO = document.getElementById('sd-stat-output');
2389
+ if (elO && outputTok >= parseFloat(elO.dataset.val || '0')) { elO.textContent = fmt(outputTok); elO.dataset.val = String(outputTok); }
2390
+ var elCa = document.getElementById('sd-stat-cache');
2391
+ if (elCa && cacheTok >= parseFloat(elCa.dataset.val || '0')) { elCa.textContent = fmt(cacheTok); elCa.dataset.val = String(cacheTok); }
2392
+ var el2c = document.getElementById('sd-stat-cost');
2393
+ if (el2c && totalCost >= parseFloat(el2c.dataset.val || '0')) { el2c.textContent = fmtCost(totalCost); el2c.dataset.val = String(totalCost); }
2394
+ var el2r = document.getElementById('sd-stat-reqs');
2395
+ if (el2r) { el2r.textContent = String(totalReqs); el2r.dataset.val = String(totalReqs); }
2396
+ if (s.highestLevel && (_levelRank[s.highestLevel] || 0) > (_levelRank[_sessionHighest] || 0)) {
2397
+ _sessionHighest = s.highestLevel;
2398
+ var lEl = document.getElementById('sd-stat-level');
2399
+ if (lEl) lEl.innerHTML = '<div class="sd-stat-value"><span class="level-tag level-' + s.highestLevel + '">' + s.highestLevel + '</span></div><div class="sd-stat-label">' + t('sd.highest_level') + '</div>';
2400
+ }
2401
+ }).catch(function() {});
2402
+ }, 5000);
2403
+ }
2404
+
2405
+ function hideSessionDetail() {
2406
+ _activeSessionKey = null;
2407
+ _activeLoopId = null;
2408
+ closeSessionStream();
2409
+ var sd = document.getElementById('session-detail');
2410
+ sd.style.display = 'none';
2411
+ var tl = document.getElementById('session-timeline');
2412
+ if (tl) { tl.style.maxHeight = ''; tl.style.overflowY = ''; }
2413
+ document.getElementById('session-list').style.display = 'block';
2414
+ }
2415
+
2416
+ // ── Global activity stream for session list live updates ──
2417
+ var _activityStream = null;
2418
+ var _activityDebounce = null;
2419
+ function ensureSessionRow(sessionKey, loopId, msgPreview) {
2420
+ var matchKey = loopId ? (sessionKey + '::' + loopId) : sessionKey;
2421
+ var found = false;
2422
+ for (var i = 0; i < _sessionsList.length; i++) {
2423
+ var ek = _sessionsList[i].loopId ? (_sessionsList[i].sessionKey + '::' + _sessionsList[i].loopId) : _sessionsList[i].sessionKey;
2424
+ if (ek === matchKey) { found = true; break; }
2425
+ }
2426
+ if (found) return;
2427
+ _pendingLoops.add(matchKey);
2428
+ var placeholder = {
2429
+ sessionKey: sessionKey,
2430
+ loopId: loopId || undefined,
2431
+ userMessagePreview: msgPreview || undefined,
2432
+ highestLevel: 'S1',
2433
+ cloud: { totalTokens: 0, requestCount: 0, estimatedCost: 0 },
2434
+ local: { totalTokens: 0, requestCount: 0 },
2435
+ proxy: { totalTokens: 0, requestCount: 0, estimatedCost: 0 },
2436
+ bySource: {},
2437
+ firstSeenAt: Date.now(),
2438
+ lastActiveAt: Date.now()
2439
+ };
2440
+ _sessionsList.unshift(placeholder);
2441
+ var tbody = document.getElementById('sessions-body');
2442
+ if (tbody) {
2443
+ var label = msgPreview || sessionKey;
2444
+ var shortLabel = label.length > 40 ? label.slice(0, 40) + '...' : label;
2445
+ var lid = loopId || '';
2446
+ var onclick = "showSessionDetail('" + escHtml(sessionKey).replace(/'/g, "\\'") + "','" + escHtml(lid).replace(/'/g, "\\'") + "')";
2447
+ var row = document.createElement('tr');
2448
+ row.setAttribute('onclick', onclick);
2449
+ row.innerHTML =
2450
+ '<td><span class="session-key" title="' + escHtml(label) + '">' + escHtml(shortLabel) + '</span></td>' +
2451
+ '<td><span class="level-tag level-S1">S1</span></td>' +
2452
+ '<td colspan="6" style="color:var(--text-tertiary);font-style:italic"><span class="detecting-spinner" style="margin-right:6px"></span>' + t('sd.detecting') + '</td>';
2453
+ var empty = tbody.querySelector('.empty-state');
2454
+ if (empty) empty.parentNode.remove();
2455
+ tbody.insertBefore(row, tbody.firstChild);
2456
+ }
2457
+ }
2458
+ function startActivityStream() {
2459
+ if (_activityStream) return;
2460
+ _activityStream = new EventSource(BASE + '/activity-stream');
2461
+ _activityStream.addEventListener('activity', function(e) {
2462
+ try {
2463
+ var data = JSON.parse(e.data);
2464
+ if (data.sessionKey && (data.phase === 'start' || data.phase === 'generating' || data.phase === 'token_update')) {
2465
+ ensureSessionRow(data.sessionKey, data.loopId, data.userMessagePreview);
2466
+ }
2467
+ if (data.phase === 'llm_complete' && data.sessionKey) {
2468
+ var doneKey = data.loopId ? (data.sessionKey + '::' + data.loopId) : data.sessionKey;
2469
+ _pendingLoops.delete(doneKey);
2470
+ refreshSessions();
2471
+ refreshDetections();
2472
+ return;
2473
+ }
2474
+ } catch(ex) {}
2475
+ if (_activityDebounce) clearTimeout(_activityDebounce);
2476
+ _activityDebounce = setTimeout(function() {
2477
+ refreshSessions();
2478
+ refreshDetections();
2479
+ }, 300);
2480
+ });
2481
+ _activityStream.onerror = function() { /* auto-reconnect */ };
2482
+ }
2483
+ function stopActivityStream() {
2484
+ if (_activityStream) { _activityStream.close(); _activityStream = null; }
2485
+ }
2486
+ startActivityStream();
2487
+
2488
+ // ── Detection Log ──
2489
+ async function refreshDetections() {
2490
+ try {
2491
+ _detections = await fetch(BASE + '/detections').then(function(r) { return r.json(); });
2492
+ renderDetections();
2493
+ } catch (e) { /* non-critical */ }
2494
+ }
2495
+
2496
+ function filterDetections(level, el) {
2497
+ _detectionFilter = level;
2498
+ var bar = el ? el.parentNode : null;
2499
+ if (bar) {
2500
+ var btns = bar.querySelectorAll('.filter-btn');
2501
+ var sep = bar.querySelector('span');
2502
+ var beforeSep = true;
2503
+ btns.forEach(function(b) {
2504
+ if (sep && b === sep.nextElementSibling) beforeSep = false;
2505
+ if (beforeSep) b.classList.remove('active');
2506
+ });
2507
+ }
2508
+ if (el) el.classList.add('active');
2509
+ renderDetections();
2510
+ }
2511
+
2512
+ function filterDetectionRouter(router, el) {
2513
+ _routerFilter = router;
2514
+ var bar = el ? el.parentNode : null;
2515
+ if (bar) {
2516
+ var btns = bar.querySelectorAll('.filter-btn');
2517
+ var sep = bar.querySelector('span');
2518
+ var afterSep = false;
2519
+ btns.forEach(function(b) {
2520
+ if (sep && b === sep.nextElementSibling) afterSep = true;
2521
+ if (afterSep) b.classList.remove('active');
2522
+ });
2523
+ }
2524
+ if (el) el.classList.add('active');
2525
+ renderDetections();
2526
+ }
2527
+
2528
+ function renderDetections() {
2529
+ var tbody = document.getElementById('detections-body');
2530
+ var filtered = _detections;
2531
+ if (_detectionFilter !== 'all') {
2532
+ filtered = filtered.filter(function(d) { return d.level === _detectionFilter; });
2533
+ }
2534
+ if (_routerFilter !== 'all') {
2535
+ filtered = filtered.filter(function(d) { return d.routerId === _routerFilter; });
2536
+ }
2537
+ if (!filtered || !filtered.length) {
2538
+ var label = '';
2539
+ if (_detectionFilter !== 'all') label += _detectionFilter;
2540
+ if (_routerFilter !== 'all') label += (label ? ' / ' : '') + _routerFilter;
2541
+ tbody.innerHTML = '<tr><td colspan="8" class="empty-state">' +
2542
+ (label ? t('det.empty_for') + label : t('det.empty')) + '</td></tr>';
2543
+ return;
2544
+ }
2545
+ tbody.innerHTML = filtered.slice(0, 100).map(function(d) {
2546
+ var shortKey = d.sessionKey.length > 16 ? d.sessionKey.slice(0, 16) + '...' : d.sessionKey;
2547
+ var actionClass = d.action === 'redirect' ? 'action-redirect' : (d.action === 'block' ? 'action-block' : '');
2548
+ return '<tr>' +
2549
+ '<td>' + fmtTime(d.timestamp) + '</td>' +
2550
+ '<td><span class="session-key" title="' + escHtml(d.sessionKey) + '">' + escHtml(shortKey) + '</span></td>' +
2551
+ '<td><span class="level-tag level-' + d.level + '">' + d.level + '</span></td>' +
2552
+ '<td><span class="checkpoint-tag">' + escHtml(d.checkpoint || '--') + '</span></td>' +
2553
+ '<td>' + escHtml(d.routerId || '--') + '</td>' +
2554
+ '<td>' + (d.action ? '<span class="action-tag ' + actionClass + '">' + escHtml(d.action) + '</span>' : '--') + '</td>' +
2555
+ '<td>' + escHtml(d.target || '--') + '</td>' +
2556
+ '<td>' + escHtml(d.reason || '--') + '</td>' +
2557
+ '</tr>';
2558
+ }).join('');
2559
+ }
2560
+
2561
+ // ── Config ──
2562
+ function toggleModuleField() {
2563
+ var wrap = document.getElementById('cfg-lm-module-wrap');
2564
+ wrap.style.display = document.getElementById('cfg-lm-type').value === 'custom' ? 'block' : 'none';
2565
+ }
2566
+
2567
+ async function loadConfig() {
2568
+ try {
2569
+ var cfg = await fetch(BASE + '/config').then(function(r) { return r.json(); });
2570
+ var p = cfg.privacy || {};
2571
+ var lm = p.localModel || {};
2572
+ var ga = p.guardAgent || {};
2573
+ var rules = p.rules || {};
2574
+ var sess = p.session || {};
2575
+ var ck = p.checkpoints || {};
2576
+ var routers = p.routers || {};
2577
+ var pipeline = p.pipeline || {};
2578
+
2579
+ document.getElementById('cfg-enabled').checked = p.enabled !== false;
2580
+ document.getElementById('cfg-lm-enabled').checked = lm.enabled !== false;
2581
+ document.getElementById('cfg-lm-type').value = lm.type || 'openai-compatible';
2582
+ document.getElementById('cfg-lm-provider').value = lm.provider || '';
2583
+ document.getElementById('cfg-lm-endpoint').value = lm.endpoint || '';
2584
+ document.getElementById('cfg-lm-model').value = lm.model || '';
2585
+ document.getElementById('cfg-lm-apikey').value = lm.apiKey || '';
2586
+ document.getElementById('cfg-lm-module').value = lm.module || '';
2587
+
2588
+ document.getElementById('cfg-ga-id').value = ga.id || '';
2589
+ document.getElementById('cfg-ga-workspace').value = ga.workspace || '';
2590
+ document.getElementById('cfg-ga-model').value = ga.model || '';
2591
+
2592
+ document.getElementById('cfg-s2policy').value = p.s2Policy || 'proxy';
2593
+ document.getElementById('cfg-proxyport').value = p.proxyPort || '';
2594
+
2595
+ document.getElementById('cfg-sess-isolate').checked = sess.isolateGuardHistory !== false;
2596
+ document.getElementById('cfg-sess-basedir').value = sess.baseDir || '';
2597
+
2598
+ var rd = p.redaction || {};
2599
+ ['internalIp','email','envVar','creditCard','chinesePhone','chineseId','chineseAddress','pin'].forEach(function(k) {
2600
+ var el = document.getElementById('cfg-rd-' + k);
2601
+ if (el) el.checked = !!rd[k];
2602
+ });
2603
+
2604
+ _checkpoints.um = Array.isArray(ck.onUserMessage) ? ck.onUserMessage.slice() : [];
2605
+ _checkpoints.tcp = Array.isArray(ck.onToolCallProposed) ? ck.onToolCallProposed.slice() : [];
2606
+ _checkpoints.tce = Array.isArray(ck.onToolCallExecuted) ? ck.onToolCallExecuted.slice() : [];
2607
+ syncChips();
2608
+
2609
+ _tags['kw-s2'] = (rules.keywords && rules.keywords.S2) ? rules.keywords.S2.slice() : [];
2610
+ _tags['kw-s3'] = (rules.keywords && rules.keywords.S3) ? rules.keywords.S3.slice() : [];
2611
+ _tags['pat-s2'] = (rules.patterns && rules.patterns.S2) ? rules.patterns.S2.slice() : [];
2612
+ _tags['pat-s3'] = (rules.patterns && rules.patterns.S3) ? rules.patterns.S3.slice() : [];
2613
+ var toolRules = rules.tools || {};
2614
+ _tags['tool-s2'] = (toolRules.S2 && toolRules.S2.tools) ? toolRules.S2.tools.slice() : [];
2615
+ _tags['tool-s3'] = (toolRules.S3 && toolRules.S3.tools) ? toolRules.S3.tools.slice() : [];
2616
+ _tags['toolpath-s2'] = (toolRules.S2 && toolRules.S2.paths) ? toolRules.S2.paths.slice() : [];
2617
+ _tags['toolpath-s3'] = (toolRules.S3 && toolRules.S3.paths) ? toolRules.S3.paths.slice() : [];
2618
+ _tags['lp'] = Array.isArray(p.localProviders) ? p.localProviders.slice() : [];
2619
+
2620
+ _pricing = {};
2621
+ if (p.modelPricing && typeof p.modelPricing === 'object') {
2622
+ Object.keys(p.modelPricing).forEach(function(k) {
2623
+ _pricing[k] = Object.assign({}, p.modelPricing[k]);
2624
+ });
2625
+ }
2626
+ renderPricing();
2627
+
2628
+ _tags['pipe-um'] = Array.isArray(pipeline.onUserMessage) ? pipeline.onUserMessage.slice() : [];
2629
+ _tags['pipe-tcp'] = Array.isArray(pipeline.onToolCallProposed) ? pipeline.onToolCallProposed.slice() : [];
2630
+ _tags['pipe-tce'] = Array.isArray(pipeline.onToolCallExecuted) ? pipeline.onToolCallExecuted.slice() : [];
2631
+
2632
+ _routers = {};
2633
+ if (routers && typeof routers === 'object') {
2634
+ Object.keys(routers).forEach(function(k) { _routers[k] = Object.assign({}, routers[k]); });
2635
+ }
2636
+
2637
+ // Privacy router enable toggle
2638
+ var privacyReg = _routers['privacy'] || {};
2639
+ var privacyEl = document.getElementById('cfg-privacy-enabled');
2640
+ if (privacyEl) privacyEl.checked = privacyReg.enabled !== false;
2641
+
2642
+ Object.keys(_tags).forEach(function(k) {
2643
+ if (k.indexOf('pipe-') === 0) return;
2644
+ renderTags(k);
2645
+ });
2646
+ toggleModuleField();
2647
+ loadTokenSaverConfig();
2648
+ renderCustomRouterCards();
2649
+ updateAvailableRouters();
2650
+ } catch (e) { /* non-critical, fields stay at defaults */ }
2651
+ }
2652
+
2653
+ document.getElementById('cfg-lm-type').addEventListener('change', toggleModuleField);
2654
+
2655
+ async function saveConfig() {
2656
+ try {
2657
+ var typeVal = document.getElementById('cfg-lm-type').value;
2658
+ var portVal = document.getElementById('cfg-proxyport').value;
2659
+
2660
+ var payload = {
2661
+ privacy: {
2662
+ enabled: document.getElementById('cfg-enabled').checked,
2663
+ localModel: {
2664
+ enabled: document.getElementById('cfg-lm-enabled').checked,
2665
+ type: typeVal || undefined,
2666
+ provider: document.getElementById('cfg-lm-provider').value || undefined,
2667
+ endpoint: document.getElementById('cfg-lm-endpoint').value || undefined,
2668
+ model: document.getElementById('cfg-lm-model').value || undefined,
2669
+ apiKey: document.getElementById('cfg-lm-apikey').value || undefined,
2670
+ module: typeVal === 'custom' ? (document.getElementById('cfg-lm-module').value || undefined) : undefined,
2671
+ },
2672
+ guardAgent: {
2673
+ id: document.getElementById('cfg-ga-id').value || undefined,
2674
+ workspace: document.getElementById('cfg-ga-workspace').value || undefined,
2675
+ model: document.getElementById('cfg-ga-model').value || undefined,
2676
+ },
2677
+ s2Policy: document.getElementById('cfg-s2policy').value,
2678
+ proxyPort: portVal ? parseInt(portVal) : undefined,
2679
+ localProviders: _tags['lp'].length > 0 ? _tags['lp'] : [],
2680
+ modelPricing: Object.keys(_pricing).length > 0 ? _pricing : undefined,
2681
+ session: {
2682
+ isolateGuardHistory: document.getElementById('cfg-sess-isolate').checked,
2683
+ baseDir: document.getElementById('cfg-sess-basedir').value || undefined,
2684
+ },
2685
+ redaction: (function() {
2686
+ var rd = {};
2687
+ ['internalIp','email','envVar','creditCard','chinesePhone','chineseId','chineseAddress','pin'].forEach(function(k) {
2688
+ var el = document.getElementById('cfg-rd-' + k);
2689
+ if (el) rd[k] = el.checked;
2690
+ });
2691
+ return rd;
2692
+ })(),
2693
+ },
2694
+ };
2695
+ var res = await fetch(BASE + '/config', {
2696
+ method: 'POST',
2697
+ headers: { 'Content-Type': 'application/json' },
2698
+ body: JSON.stringify(payload),
2699
+ });
2700
+ var result = await res.json();
2701
+ if (result.ok) {
2702
+ showToast(t('cfg.saved'));
2703
+ } else {
2704
+ showToast(t('common.save_failed') + (result.error || 'unknown'), true);
2705
+ }
2706
+ } catch (e) {
2707
+ showToast(t('common.save_failed') + e.message, true);
2708
+ }
2709
+ }
2710
+
2711
+ function showToast(msg, isError) {
2712
+ var el = document.getElementById('toast');
2713
+ el.textContent = msg;
2714
+ el.className = 'toast' + (isError ? ' error' : '');
2715
+ el.style.display = 'block';
2716
+ setTimeout(function() { el.style.display = 'none'; }, 3000);
2717
+ }
2718
+
2719
+ function refreshAll() {
2720
+ refreshStats();
2721
+ refreshSessions();
2722
+ refreshDetections();
2723
+ }
2724
+
2725
+ // ── Prompt Editors ──
2726
+
2727
+ var _prompts = {};
2728
+
2729
+ async function loadPrompts() {
2730
+ try {
2731
+ _prompts = await fetch(BASE + '/prompts').then(function(r) { return r.json(); });
2732
+ renderRouterPrompts('privacy-prompt-main', PRIVACY_PROMPTS_MAIN);
2733
+ renderRouterPrompts('privacy-prompt-adv', PRIVACY_PROMPTS_ADV);
2734
+ renderRouterPrompts('tokensaver-prompt-editors', TOKENSAVER_PROMPTS);
2735
+ } catch (e) { /* non-critical */ }
2736
+ }
2737
+
2738
+ async function savePrompt(name) {
2739
+ var el = document.getElementById('prompt-' + name);
2740
+ if (!el) return;
2741
+ try {
2742
+ var res = await fetch(BASE + '/prompts', {
2743
+ method: 'POST',
2744
+ headers: { 'Content-Type': 'application/json' },
2745
+ body: JSON.stringify({ name: name, content: el.value }),
2746
+ });
2747
+ var result = await res.json();
2748
+ if (result.ok) {
2749
+ showToast('"' + name + t('common.prompt_saved'));
2750
+ loadPrompts();
2751
+ } else {
2752
+ showToast(t('common.save_failed') + (result.error || 'unknown'), true);
2753
+ }
2754
+ } catch (e) {
2755
+ showToast(t('common.save_failed') + e.message, true);
2756
+ }
2757
+ }
2758
+
2759
+ function resetPrompt(name) {
2760
+ if (!_prompts[name]) return;
2761
+ var el = document.getElementById('prompt-' + name);
2762
+ if (el) el.value = _prompts[name].defaultContent;
2763
+ }
2764
+
2765
+ // ── Test Classify ──
2766
+
2767
+ async function runTestClassify() {
2768
+ var msg = document.getElementById('test-message').value.trim();
2769
+ if (!msg) { showToast(t('test.enter_msg'), true); return; }
2770
+ var checkpoint = document.getElementById('test-checkpoint').value;
2771
+ var resultEl = document.getElementById('test-result');
2772
+ var loadingEl = document.getElementById('test-loading');
2773
+ resultEl.classList.remove('visible');
2774
+ loadingEl.style.display = 'block';
2775
+ try {
2776
+ var res = await fetch(BASE + '/test-classify', {
2777
+ method: 'POST',
2778
+ headers: { 'Content-Type': 'application/json' },
2779
+ body: JSON.stringify({ message: msg, checkpoint: checkpoint }),
2780
+ });
2781
+ var data = await res.json();
2782
+ loadingEl.style.display = 'none';
2783
+ if (data.error) {
2784
+ showToast(t('test.failed') + data.error, true);
2785
+ return;
2786
+ }
2787
+ document.getElementById('tr-level').innerHTML = '<span class="level-tag level-' + data.level + '">' + data.level + '</span>';
2788
+ document.getElementById('tr-action').textContent = data.action || 'passthrough';
2789
+ document.getElementById('tr-target').textContent = data.target ? (data.target.provider + '/' + data.target.model) : t('common.none');
2790
+ document.getElementById('tr-router').textContent = data.routerId || t('common.none');
2791
+ document.getElementById('tr-reason').textContent = data.reason || t('common.none');
2792
+ document.getElementById('tr-confidence').textContent = data.confidence != null ? (data.confidence * 100).toFixed(0) + '%' : '-';
2793
+ var perEl = document.getElementById('tr-per-router');
2794
+ if (data.routers && data.routers.length > 0) {
2795
+ var html = '<div style="margin-top:14px;padding-top:12px;border-top:1px solid var(--border-subtle)">' +
2796
+ '<div style="font-size:11px;text-transform:uppercase;color:var(--text-tertiary);letter-spacing:.06em;font-weight:700;margin-bottom:10px">' + t('test.individual') + '</div>';
2797
+ data.routers.forEach(function(r) {
2798
+ html += '<div style="background:var(--bg-surface);border:1px solid var(--border-subtle);border-radius:8px;padding:12px 16px;margin-bottom:6px">' +
2799
+ '<div style="display:flex;justify-content:space-between;align-items:center">' +
2800
+ '<span style="font-weight:600;color:var(--text-primary);font-size:13px">' + (r.routerId || '?') + '</span>' +
2801
+ '<span class="level-tag level-' + r.level + '">' + r.level + '</span></div>' +
2802
+ '<div style="font-size:12px;color:var(--text-secondary);margin-top:4px">' +
2803
+ (r.action || 'passthrough') +
2804
+ (r.target ? ' → ' + r.target.provider + '/' + r.target.model : '') +
2805
+ '</div>' +
2806
+ '<div style="font-size:12px;color:var(--text-tertiary);margin-top:2px">' + (r.reason || '-') + '</div>' +
2807
+ '</div>';
2808
+ });
2809
+ html += '</div>';
2810
+ perEl.innerHTML = html;
2811
+ } else {
2812
+ perEl.innerHTML = '';
2813
+ }
2814
+ resultEl.classList.add('visible');
2815
+ } catch (e) {
2816
+ loadingEl.style.display = 'none';
2817
+ showToast(t('test.failed') + e.message, true);
2818
+ }
2819
+ }
2820
+
2821
+ // ── Section Collapse ──
2822
+
2823
+ function toggleSection(el) {
2824
+ el.classList.toggle('collapsed');
2825
+ var body = el.nextElementSibling;
2826
+ if (body) body.classList.toggle('collapsed');
2827
+ }
2828
+
2829
+ function toggleAdv(el) {
2830
+ el.classList.toggle('open');
2831
+ var body = el.nextElementSibling;
2832
+ if (body) body.classList.toggle('open');
2833
+ }
2834
+
2835
+ // ── Per-Router Prompt Rendering ──
2836
+
2837
+ var PRIVACY_PROMPTS_MAIN = ['detection-system'];
2838
+ var PRIVACY_PROMPTS_ADV = ['pii-extraction'];
2839
+ var TOKENSAVER_PROMPTS = ['token-saver-judge'];
2840
+
2841
+ function renderRouterPrompts(containerId, promptNames) {
2842
+ var c = document.getElementById(containerId);
2843
+ if (!c) return;
2844
+ var html = '';
2845
+ promptNames.forEach(function(name) {
2846
+ var p = _prompts[name];
2847
+ if (!p) return;
2848
+ var customBadge = p.isCustom ? '<span class="custom-badge">' + t('common.customized') + '</span>' : '';
2849
+ html += '<div style="margin-bottom:16px">' +
2850
+ '<div class="prompt-header">' +
2851
+ '<h4>' + escHtml(p.label) + customBadge + '</h4>' +
2852
+ '<div class="prompt-actions">' +
2853
+ '<button class="btn btn-sm btn-outline" onclick="resetPrompt(\\'' + escHtml(name) + '\\')">' + t('common.reset') + '</button>' +
2854
+ '<button class="btn btn-sm btn-primary" onclick="savePrompt(\\'' + escHtml(name) + '\\')">' + t('common.save') + '</button>' +
2855
+ '</div>' +
2856
+ '</div>' +
2857
+ '<textarea class="prompt-editor" id="prompt-' + escHtml(name) + '">' + escHtml(p.content) + '</textarea>' +
2858
+ '</div>';
2859
+ });
2860
+ c.innerHTML = html || '<div style="color:var(--text-tertiary);font-size:13px">' + t('common.loading') + '</div>';
2861
+ }
2862
+
2863
+ // ── Per-Router Test ──
2864
+
2865
+ async function runRouterTest(routerId) {
2866
+ var msgEl = document.getElementById('test-' + routerId + '-message');
2867
+ var msg = msgEl ? msgEl.value.trim() : '';
2868
+ if (!msg) { showToast(t('test.enter_msg'), true); return; }
2869
+ var resultEl = document.getElementById('test-' + routerId + '-result');
2870
+ var loadingEl = document.getElementById('test-' + routerId + '-loading');
2871
+ resultEl.classList.remove('visible');
2872
+ loadingEl.style.display = 'block';
2873
+ try {
2874
+ var res = await fetch(BASE + '/test-classify', {
2875
+ method: 'POST',
2876
+ headers: { 'Content-Type': 'application/json' },
2877
+ body: JSON.stringify({ message: msg, router: routerId }),
2878
+ });
2879
+ var data = await res.json();
2880
+ loadingEl.style.display = 'none';
2881
+ if (data.error) {
2882
+ showToast(t('test.failed') + data.error, true);
2883
+ return;
2884
+ }
2885
+ document.getElementById('tr-' + routerId + '-level').innerHTML = '<span class="level-tag level-' + data.level + '">' + data.level + '</span>';
2886
+ document.getElementById('tr-' + routerId + '-action').textContent = data.action || 'passthrough';
2887
+ document.getElementById('tr-' + routerId + '-target').textContent = data.target ? (data.target.provider + '/' + data.target.model) : t('common.none');
2888
+ document.getElementById('tr-' + routerId + '-reason').textContent = data.reason || t('common.none');
2889
+ document.getElementById('tr-' + routerId + '-confidence').textContent = data.confidence != null ? (data.confidence * 100).toFixed(0) + '%' : '-';
2890
+ resultEl.classList.add('visible');
2891
+ } catch (e) {
2892
+ loadingEl.style.display = 'none';
2893
+ showToast(t('test.failed') + e.message, true);
2894
+ }
2895
+ }
2896
+
2897
+ // ── Save Privacy Router ──
2898
+
2899
+ async function savePrivacyRouter() {
2900
+ try {
2901
+ var privacyEnabled = document.getElementById('cfg-privacy-enabled').checked;
2902
+ var payload = {
2903
+ privacy: {
2904
+ routers: {
2905
+ privacy: { enabled: privacyEnabled },
2906
+ },
2907
+ checkpoints: {
2908
+ onUserMessage: _checkpoints.um.length ? _checkpoints.um : undefined,
2909
+ onToolCallProposed: _checkpoints.tcp.length ? _checkpoints.tcp : undefined,
2910
+ onToolCallExecuted: _checkpoints.tce.length ? _checkpoints.tce : undefined,
2911
+ },
2912
+ rules: {
2913
+ keywords: { S2: _tags['kw-s2'], S3: _tags['kw-s3'] },
2914
+ patterns: { S2: _tags['pat-s2'], S3: _tags['pat-s3'] },
2915
+ tools: {
2916
+ S2: { tools: _tags['tool-s2'], paths: _tags['toolpath-s2'] },
2917
+ S3: { tools: _tags['tool-s3'], paths: _tags['toolpath-s3'] },
2918
+ },
2919
+ },
2920
+ },
2921
+ };
2922
+ var res = await fetch(BASE + '/config', {
2923
+ method: 'POST',
2924
+ headers: { 'Content-Type': 'application/json' },
2925
+ body: JSON.stringify(payload),
2926
+ });
2927
+ var result = await res.json();
2928
+ if (result.ok) {
2929
+ showToast(t('priv.saved'));
2930
+ } else {
2931
+ showToast(t('common.save_failed') + (result.error || 'unknown'), true);
2932
+ }
2933
+ } catch (e) {
2934
+ showToast(t('common.save_failed') + e.message, true);
2935
+ }
2936
+ }
2937
+
2938
+ // ── Save Pipeline Order ──
2939
+
2940
+ async function savePipelineOrder() {
2941
+ try {
2942
+ var payload = {
2943
+ privacy: {
2944
+ pipeline: {
2945
+ onUserMessage: _tags['pipe-um'].length ? _tags['pipe-um'] : undefined,
2946
+ onToolCallProposed: _tags['pipe-tcp'].length ? _tags['pipe-tcp'] : undefined,
2947
+ onToolCallExecuted: _tags['pipe-tce'].length ? _tags['pipe-tce'] : undefined,
2948
+ },
2949
+ },
2950
+ };
2951
+ var res = await fetch(BASE + '/config', {
2952
+ method: 'POST',
2953
+ headers: { 'Content-Type': 'application/json' },
2954
+ body: JSON.stringify(payload),
2955
+ });
2956
+ var result = await res.json();
2957
+ if (result.ok) {
2958
+ showToast(t('pipe.saved'));
2959
+ } else {
2960
+ showToast(t('common.save_failed') + (result.error || 'unknown'), true);
2961
+ }
2962
+ } catch (e) {
2963
+ showToast(t('common.save_failed') + e.message, true);
2964
+ }
2965
+ }
2966
+
2967
+ // ── Token-Saver Config ──
2968
+
2969
+ var _tierRowCount = 0;
2970
+
2971
+ function _renderTierRow(name, provider, model, desc) {
2972
+ var idx = _tierRowCount++;
2973
+ return '<input class="ts-tier-name" data-idx="' + idx + '" value="' + escHtml(name) + '" placeholder="TIER_NAME" style="text-transform:uppercase;font-weight:600">' +
2974
+ '<input class="ts-tier-provider" data-idx="' + idx + '" value="' + escHtml(provider) + '" placeholder="provider">' +
2975
+ '<input class="ts-tier-model" data-idx="' + idx + '" value="' + escHtml(model) + '" placeholder="model">' +
2976
+ '<input class="ts-tier-desc" data-idx="' + idx + '" value="' + escHtml(desc) + '" placeholder="description (optional)" style="font-size:11px">';
2977
+ }
2978
+
2979
+ function addTierRow(name, provider, model, desc) {
2980
+ var grid = document.getElementById('ts-tier-grid');
2981
+ if (!grid) return;
2982
+ var html = _renderTierRow(name || '', provider || '', model || '', desc || '');
2983
+ grid.insertAdjacentHTML('beforeend', html);
2984
+ }
2985
+
2986
+ function removeTierRow() {
2987
+ var grid = document.getElementById('ts-tier-grid');
2988
+ if (!grid || _tierRowCount <= 0) return;
2989
+ _tierRowCount--;
2990
+ for (var i = 0; i < 4; i++) {
2991
+ var last = grid.lastElementChild;
2992
+ if (last && !last.classList.contains('tier-grid-header')) grid.removeChild(last);
2993
+ }
2994
+ }
2995
+
2996
+ function loadTokenSaverConfig() {
2997
+ var tsReg = _routers['token-saver'] || {};
2998
+ var opts = tsReg.options || {};
2999
+ var tiers = opts.tiers || {};
3000
+ document.getElementById('cfg-ts-enabled').checked = tsReg.enabled === true;
3001
+
3002
+ var grid = document.getElementById('ts-tier-grid');
3003
+ if (grid) {
3004
+ var headers = grid.querySelectorAll('.tier-grid-header');
3005
+ grid.innerHTML = '';
3006
+ for (var h = 0; h < headers.length; h++) grid.appendChild(headers[h]);
3007
+ }
3008
+ _tierRowCount = 0;
3009
+
3010
+ var tierNames = Object.keys(tiers);
3011
+ if (tierNames.length === 0) tierNames = ['SIMPLE', 'MEDIUM', 'COMPLEX', 'REASONING'];
3012
+ for (var i = 0; i < tierNames.length; i++) {
3013
+ var tn = tierNames[i];
3014
+ var cfg = tiers[tn] || {};
3015
+ addTierRow(tn, cfg.provider || '', cfg.model || '', cfg.description || '');
3016
+ }
3017
+
3018
+ var cacheEl = document.getElementById('cfg-ts-cachettl');
3019
+ if (cacheEl) cacheEl.value = opts.cacheTtlMs || '';
3020
+ }
3021
+
3022
+ async function saveTokenSaverConfig() {
3023
+ try {
3024
+ var enabled = document.getElementById('cfg-ts-enabled').checked;
3025
+ var tiers = {};
3026
+ var nameEls = document.querySelectorAll('.ts-tier-name');
3027
+ for (var i = 0; i < nameEls.length; i++) {
3028
+ var name = (nameEls[i].value || '').trim().toUpperCase();
3029
+ if (!name) continue;
3030
+ var idx = nameEls[i].getAttribute('data-idx');
3031
+ var provider = (document.querySelector('.ts-tier-provider[data-idx="' + idx + '"]') || {}).value || '';
3032
+ var model = (document.querySelector('.ts-tier-model[data-idx="' + idx + '"]') || {}).value || '';
3033
+ var desc = (document.querySelector('.ts-tier-desc[data-idx="' + idx + '"]') || {}).value || '';
3034
+ var entry = { provider: provider, model: model };
3035
+ if (desc) entry.description = desc;
3036
+ tiers[name] = entry;
3037
+ }
3038
+ var cacheTtl = document.getElementById('cfg-ts-cachettl').value;
3039
+ var options = {};
3040
+ if (Object.keys(tiers).length > 0) options.tiers = tiers;
3041
+ if (cacheTtl) options.cacheTtlMs = parseInt(cacheTtl);
3042
+
3043
+ var routerEntry = { enabled: enabled, type: 'builtin', options: options };
3044
+ _routers['token-saver'] = routerEntry;
3045
+
3046
+ var pipelineUpdate = {};
3047
+ var pipeKeys = ['pipe-um', 'pipe-tcp', 'pipe-tce'];
3048
+ var pipeFields = ['onUserMessage', 'onToolCallProposed', 'onToolCallExecuted'];
3049
+ for (var i = 0; i < pipeKeys.length; i++) {
3050
+ var arr = _tags[pipeKeys[i]] ? _tags[pipeKeys[i]].slice() : [];
3051
+ var idx = arr.indexOf('token-saver');
3052
+ if (enabled && idx === -1) {
3053
+ arr.push('token-saver');
3054
+ _tags[pipeKeys[i]] = arr;
3055
+ }
3056
+ if (!enabled && idx !== -1) {
3057
+ arr.splice(idx, 1);
3058
+ _tags[pipeKeys[i]] = arr;
3059
+ }
3060
+ pipelineUpdate[pipeFields[i]] = arr.length ? arr : undefined;
3061
+ }
3062
+
3063
+ var payload = {
3064
+ privacy: {
3065
+ routers: { 'token-saver': routerEntry },
3066
+ pipeline: pipelineUpdate,
3067
+ },
3068
+ };
3069
+ var res = await fetch(BASE + '/config', {
3070
+ method: 'POST',
3071
+ headers: { 'Content-Type': 'application/json' },
3072
+ body: JSON.stringify(payload),
3073
+ });
3074
+ var result = await res.json();
3075
+ if (result.ok) {
3076
+ showToast(t('co.saved'));
3077
+ updateAvailableRouters();
3078
+ } else {
3079
+ showToast(t('common.save_failed') + (result.error || 'unknown'), true);
3080
+ }
3081
+ } catch (e) {
3082
+ showToast(t('common.save_failed') + e.message, true);
3083
+ }
3084
+ }
3085
+
3086
+ // ── Custom Routers ──
3087
+
3088
+ var BUILTIN_ROUTERS = ['privacy', 'token-saver'];
3089
+ var _customRouterData = {};
3090
+
3091
+ function getCustomRouterIds() {
3092
+ return Object.keys(_routers).filter(function(id) {
3093
+ return BUILTIN_ROUTERS.indexOf(id) === -1 && _routers[id].type === 'configurable';
3094
+ });
3095
+ }
3096
+
3097
+ function renderCustomRouterCards() {
3098
+ var container = document.getElementById('custom-router-cards');
3099
+ if (!container) return;
3100
+ var ids = getCustomRouterIds();
3101
+ if (!ids.length) { container.innerHTML = ''; return; }
3102
+
3103
+ container.innerHTML = ids.map(function(id) {
3104
+ var r = _routers[id] || {};
3105
+ var opts = r.options || {};
3106
+ var checked = r.enabled !== false ? ' checked' : '';
3107
+ var kwS2 = (opts.keywords && opts.keywords.S2) ? opts.keywords.S2 : [];
3108
+ var kwS3 = (opts.keywords && opts.keywords.S3) ? opts.keywords.S3 : [];
3109
+ var patS2 = (opts.patterns && opts.patterns.S2) ? opts.patterns.S2 : [];
3110
+ var patS3 = (opts.patterns && opts.patterns.S3) ? opts.patterns.S3 : [];
3111
+ var prompt = opts.prompt || '';
3112
+
3113
+ // init tag arrays for this custom router
3114
+ _tags['cr-kw-s2-' + id] = kwS2.slice();
3115
+ _tags['cr-kw-s3-' + id] = kwS3.slice();
3116
+ _tags['cr-pat-s2-' + id] = patS2.slice();
3117
+ _tags['cr-pat-s3-' + id] = patS3.slice();
3118
+
3119
+ return '<div class="router-section" id="cr-card-' + escHtml(id) + '">' +
3120
+ '<div class="router-section-header" onclick="toggleSection(this)">' +
3121
+ '<span class="section-arrow">&#9660;</span>' +
3122
+ '<h3>' + escHtml(id) + '</h3>' +
3123
+ '<span class="router-id-badge">configurable</span>' +
3124
+ '<button class="btn btn-sm btn-danger" style="margin-left:auto" onclick="event.stopPropagation();removeCustomRouter(\\'' + escHtml(id) + '\\')">' + t('common.delete') + '</button>' +
3125
+ '</div>' +
3126
+ '<div class="router-section-body">' +
3127
+ '<div class="field-toggle" style="margin-bottom:18px">' +
3128
+ '<label>' + t('common.enabled') + '</label>' +
3129
+ '<label class="toggle"><input type="checkbox" id="cfg-cr-enabled-' + escHtml(id) + '"' + checked + '><span class="slider"></span></label>' +
3130
+ '</div>' +
3131
+
3132
+ '<div class="subsection">' +
3133
+ '<h4>' + t('cr.kw_rules') + '</h4>' +
3134
+ '<div class="rules-grid">' +
3135
+ '<div class="rules-col">' +
3136
+ '<h4>' + t('cr.s2_kw') + '</h4>' +
3137
+ '<div class="tag-list" id="cfg-tags-cr-kw-s2-' + escHtml(id) + '"></div>' +
3138
+ '<div class="add-row">' +
3139
+ '<input id="cfg-tags-cr-kw-s2-' + escHtml(id) + '-input" placeholder="Add S2 keyword" onkeydown="if(event.key===\\'Enter\\'){event.preventDefault();addTag(\\'cr-kw-s2-' + escHtml(id) + '\\')}"><button class="btn btn-sm btn-outline" onclick="addTag(\\'cr-kw-s2-' + escHtml(id) + '\\')">Add</button>' +
3140
+ '</div>' +
3141
+ '<div style="margin-top:14px"><h4 style="font-size:11px;color:var(--text-tertiary);margin-bottom:8px;text-transform:uppercase;letter-spacing:.06em;font-weight:700">' + t('cr.s2_pat') + '</h4></div>' +
3142
+ '<div class="tag-list" id="cfg-tags-cr-pat-s2-' + escHtml(id) + '"></div>' +
3143
+ '<div class="add-row">' +
3144
+ '<input id="cfg-tags-cr-pat-s2-' + escHtml(id) + '-input" placeholder="Add S2 pattern" onkeydown="if(event.key===\\'Enter\\'){event.preventDefault();addTag(\\'cr-pat-s2-' + escHtml(id) + '\\')}"><button class="btn btn-sm btn-outline" onclick="addTag(\\'cr-pat-s2-' + escHtml(id) + '\\')">Add</button>' +
3145
+ '</div>' +
3146
+ '</div>' +
3147
+ '<div class="rules-col">' +
3148
+ '<h4>' + t('cr.s3_kw') + '</h4>' +
3149
+ '<div class="tag-list" id="cfg-tags-cr-kw-s3-' + escHtml(id) + '"></div>' +
3150
+ '<div class="add-row">' +
3151
+ '<input id="cfg-tags-cr-kw-s3-' + escHtml(id) + '-input" placeholder="Add S3 keyword" onkeydown="if(event.key===\\'Enter\\'){event.preventDefault();addTag(\\'cr-kw-s3-' + escHtml(id) + '\\')}"><button class="btn btn-sm btn-outline" onclick="addTag(\\'cr-kw-s3-' + escHtml(id) + '\\')">Add</button>' +
3152
+ '</div>' +
3153
+ '<div style="margin-top:14px"><h4 style="font-size:11px;color:var(--text-tertiary);margin-bottom:8px;text-transform:uppercase;letter-spacing:.06em;font-weight:700">' + t('cr.s3_pat') + '</h4></div>' +
3154
+ '<div class="tag-list" id="cfg-tags-cr-pat-s3-' + escHtml(id) + '"></div>' +
3155
+ '<div class="add-row">' +
3156
+ '<input id="cfg-tags-cr-pat-s3-' + escHtml(id) + '-input" placeholder="Add S3 pattern" onkeydown="if(event.key===\\'Enter\\'){event.preventDefault();addTag(\\'cr-pat-s3-' + escHtml(id) + '\\')}"><button class="btn btn-sm btn-outline" onclick="addTag(\\'cr-pat-s3-' + escHtml(id) + '\\')">Add</button>' +
3157
+ '</div>' +
3158
+ '</div>' +
3159
+ '</div>' +
3160
+ '</div>' +
3161
+
3162
+ '<div class="subsection">' +
3163
+ '<h4>' + t('cr.cls_prompt') + ' <span style="font-size:11px;color:var(--text-tertiary);text-transform:none;letter-spacing:0;font-weight:400">' + t('common.optional') + '</span></h4>' +
3164
+ '<div class="hint" style="margin-bottom:10px">' + t('cr.cls_hint') + '</div>' +
3165
+ '<textarea class="prompt-editor" id="cr-prompt-' + escHtml(id) + '">' + escHtml(prompt) + '</textarea>' +
3166
+ '</div>' +
3167
+
3168
+ '<div class="subsection">' +
3169
+ '<h4>' + t('common.test') + ' (' + escHtml(id) + ')</h4>' +
3170
+ '<textarea class="test-input" id="test-' + escHtml(id) + '-message" placeholder="' + escHtml(t('test.enter_msg')) + '..."></textarea>' +
3171
+ '<div style="display:flex;gap:8px;margin-top:10px;align-items:center">' +
3172
+ '<button class="btn btn-primary btn-sm" onclick="runRouterTest(\\'' + escHtml(id) + '\\')">' + t('common.test') + '</button>' +
3173
+ '</div>' +
3174
+ '<div class="test-result" id="test-' + escHtml(id) + '-result">' +
3175
+ '<div class="test-result-row"><span class="test-result-label">' + t('test.level') + '</span><span class="test-result-value" id="tr-' + escHtml(id) + '-level">-</span></div>' +
3176
+ '<div class="test-result-row"><span class="test-result-label">' + t('test.action') + '</span><span class="test-result-value" id="tr-' + escHtml(id) + '-action">-</span></div>' +
3177
+ '<div class="test-result-row"><span class="test-result-label">' + t('test.target') + '</span><span class="test-result-value" id="tr-' + escHtml(id) + '-target">-</span></div>' +
3178
+ '<div class="test-result-row"><span class="test-result-label">' + t('test.reason') + '</span><span class="test-result-value" id="tr-' + escHtml(id) + '-reason">-</span></div>' +
3179
+ '<div class="test-result-row"><span class="test-result-label">' + t('test.confidence') + '</span><span class="test-result-value" id="tr-' + escHtml(id) + '-confidence">-</span></div>' +
3180
+ '</div>' +
3181
+ '<div class="test-loading" id="test-' + escHtml(id) + '-loading" style="display:none">' + t('test.testing') + '</div>' +
3182
+ '</div>' +
3183
+
3184
+ '<div class="save-bar"><button class="btn btn-primary" onclick="saveCustomRouter(\\'' + escHtml(id) + '\\')">' + t('common.save') + ' ' + escHtml(id) + '</button></div>' +
3185
+ '</div>' +
3186
+ '</div>';
3187
+ }).join('');
3188
+
3189
+ // render tags for custom routers after DOM is built
3190
+ ids.forEach(function(id) {
3191
+ renderTags('cr-kw-s2-' + id);
3192
+ renderTags('cr-kw-s3-' + id);
3193
+ renderTags('cr-pat-s2-' + id);
3194
+ renderTags('cr-pat-s3-' + id);
3195
+ });
3196
+ }
3197
+
3198
+ function getAllRouterIds() {
3199
+ var allIds = Object.keys(_routers);
3200
+ if (!allIds.length) allIds = BUILTIN_ROUTERS.slice();
3201
+ BUILTIN_ROUTERS.forEach(function(b) {
3202
+ if (allIds.indexOf(b) === -1) allIds.unshift(b);
3203
+ });
3204
+ return allIds;
3205
+ }
3206
+
3207
+ function renderPipePicker(pipeKey) {
3208
+ var suffix = pipeKey.replace('pipe-', '');
3209
+ var container = document.getElementById('pipe-picker-' + suffix);
3210
+ if (!container) return;
3211
+ var current = _tags[pipeKey] || [];
3212
+ var allIds = getAllRouterIds();
3213
+ container.innerHTML = allIds.map(function(id) {
3214
+ var inUse = current.indexOf(id) !== -1;
3215
+ return '<button class="pipe-pick-btn' + (inUse ? ' in-use' : '') + '" onclick="togglePipeRouter(\\'' + escHtml(pipeKey) + '\\',\\'' + escHtml(id) + '\\')">' +
3216
+ '+ ' + escHtml(id) + '</button>';
3217
+ }).join('');
3218
+ }
3219
+
3220
+ function renderPipeTags(pipeKey) {
3221
+ var c = document.getElementById('cfg-tags-' + pipeKey);
3222
+ if (!c) return;
3223
+ c.innerHTML = _tags[pipeKey].map(function(v, i) {
3224
+ return '<span class="tag pipe-tag" draggable="true" data-pipe="' + pipeKey + '" data-idx="' + i + '">' +
3225
+ '<span style="color:var(--text-tertiary);font-size:10px;margin-right:4px;font-weight:600">' + (i + 1) + '</span>' +
3226
+ escHtml(v) +
3227
+ ' <button data-key="' + pipeKey + '" data-idx="' + i + '" onclick="removePipeTag(this)">&times;</button></span>';
3228
+ }).join('');
3229
+ initPipeDrag(pipeKey);
3230
+ renderPipePicker(pipeKey);
3231
+ }
3232
+
3233
+ function togglePipeRouter(pipeKey, routerId) {
3234
+ var arr = _tags[pipeKey];
3235
+ var idx = arr.indexOf(routerId);
3236
+ if (idx !== -1) return;
3237
+ arr.push(routerId);
3238
+ renderPipeTags(pipeKey);
3239
+ }
3240
+
3241
+ function removePipeTag(el) {
3242
+ var key = el.getAttribute('data-key');
3243
+ var idx = parseInt(el.getAttribute('data-idx'));
3244
+ if (key && _tags[key]) {
3245
+ _tags[key].splice(idx, 1);
3246
+ renderPipeTags(key);
3247
+ }
3248
+ }
3249
+
3250
+ function initPipeDrag(pipeKey) {
3251
+ var container = document.getElementById('cfg-tags-' + pipeKey);
3252
+ if (!container) return;
3253
+ var tags = container.querySelectorAll('.pipe-tag');
3254
+ tags.forEach(function(tag) {
3255
+ tag.addEventListener('dragstart', function(e) {
3256
+ e.dataTransfer.setData('text/plain', tag.getAttribute('data-idx'));
3257
+ e.dataTransfer.effectAllowed = 'move';
3258
+ tag.classList.add('dragging');
3259
+ });
3260
+ tag.addEventListener('dragend', function() {
3261
+ tag.classList.remove('dragging');
3262
+ });
3263
+ tag.addEventListener('dragover', function(e) {
3264
+ e.preventDefault();
3265
+ e.dataTransfer.dropEffect = 'move';
3266
+ });
3267
+ tag.addEventListener('drop', function(e) {
3268
+ e.preventDefault();
3269
+ var fromIdx = parseInt(e.dataTransfer.getData('text/plain'));
3270
+ var toIdx = parseInt(tag.getAttribute('data-idx'));
3271
+ if (isNaN(fromIdx) || isNaN(toIdx) || fromIdx === toIdx) return;
3272
+ var arr = _tags[pipeKey];
3273
+ var item = arr.splice(fromIdx, 1)[0];
3274
+ arr.splice(toIdx, 0, item);
3275
+ renderPipeTags(pipeKey);
3276
+ });
3277
+ });
3278
+ }
3279
+
3280
+ function updateAvailableRouters() {
3281
+ renderPipeTags('pipe-um');
3282
+ renderPipeTags('pipe-tcp');
3283
+ renderPipeTags('pipe-tce');
3284
+ }
3285
+
3286
+ function addCustomRouter() {
3287
+ var idInput = document.getElementById('new-router-id');
3288
+ var id = idInput.value.trim().toLowerCase().replace(/[^a-z0-9_-]/g, '-');
3289
+ if (!id) { showToast(t('cr.enter_id'), true); return; }
3290
+ if (_routers[id]) { showToast('"' + id + t('cr.exists'), true); return; }
3291
+ _routers[id] = {
3292
+ enabled: true,
3293
+ type: 'configurable',
3294
+ options: { keywords: { S2: [], S3: [] }, patterns: { S2: [], S3: [] }, prompt: '' }
3295
+ };
3296
+ idInput.value = '';
3297
+ renderCustomRouterCards();
3298
+ updateAvailableRouters();
3299
+ showToast('"' + id + t('cr.created'));
3300
+ }
3301
+
3302
+ function removeCustomRouter(id) {
3303
+ if (!confirm(t('cr.del_pre') + id + t('cr.del_suf'))) return;
3304
+ delete _routers[id];
3305
+ // Clean up tag arrays
3306
+ delete _tags['cr-kw-s2-' + id];
3307
+ delete _tags['cr-kw-s3-' + id];
3308
+ delete _tags['cr-pat-s2-' + id];
3309
+ delete _tags['cr-pat-s3-' + id];
3310
+ // Clean up pipeline arrays so deleted router doesn't linger in execution order
3311
+ ['pipe-um', 'pipe-tcp', 'pipe-tce'].forEach(function(pk) {
3312
+ var idx = _tags[pk].indexOf(id);
3313
+ if (idx !== -1) _tags[pk].splice(idx, 1);
3314
+ });
3315
+
3316
+ // Save the removal to config (include cleaned pipeline so server is in sync)
3317
+ var currentRouters = Object.assign({}, _routers);
3318
+ var payload = { privacy: {
3319
+ routers: currentRouters,
3320
+ pipeline: {
3321
+ onUserMessage: _tags['pipe-um'].length ? _tags['pipe-um'] : undefined,
3322
+ onToolCallProposed: _tags['pipe-tcp'].length ? _tags['pipe-tcp'] : undefined,
3323
+ onToolCallExecuted: _tags['pipe-tce'].length ? _tags['pipe-tce'] : undefined,
3324
+ },
3325
+ } };
3326
+ fetch(BASE + '/config', {
3327
+ method: 'POST',
3328
+ headers: { 'Content-Type': 'application/json' },
3329
+ body: JSON.stringify(payload),
3330
+ }).then(function(r) { return r.json(); }).then(function(result) {
3331
+ if (result.ok) {
3332
+ showToast('"' + id + t('cr.deleted'));
3333
+ renderCustomRouterCards();
3334
+ updateAvailableRouters();
3335
+ } else {
3336
+ showToast(t('common.save_failed') + (result.error || 'unknown'), true);
3337
+ }
3338
+ }).catch(function(e) {
3339
+ showToast(t('common.save_failed') + e.message, true);
3340
+ });
3341
+ }
3342
+
3343
+ async function saveCustomRouter(id) {
3344
+ try {
3345
+ var kwS2 = _tags['cr-kw-s2-' + id] || [];
3346
+ var kwS3 = _tags['cr-kw-s3-' + id] || [];
3347
+ var patS2 = _tags['cr-pat-s2-' + id] || [];
3348
+ var patS3 = _tags['cr-pat-s3-' + id] || [];
3349
+ var promptEl = document.getElementById('cr-prompt-' + id);
3350
+ var prompt = promptEl ? promptEl.value.trim() : '';
3351
+ var enabledEl = document.getElementById('cfg-cr-enabled-' + id);
3352
+ var enabled = enabledEl ? enabledEl.checked : true;
3353
+
3354
+ var options = {
3355
+ keywords: { S2: kwS2, S3: kwS3 },
3356
+ patterns: { S2: patS2, S3: patS3 },
3357
+ };
3358
+ if (prompt) options.prompt = prompt;
3359
+
3360
+ var currentRouters = Object.assign({}, _routers);
3361
+ currentRouters[id] = {
3362
+ enabled: enabled,
3363
+ type: 'configurable',
3364
+ options: options,
3365
+ };
3366
+ _routers[id] = currentRouters[id];
3367
+
3368
+ var payload = { privacy: { routers: currentRouters } };
3369
+ var res = await fetch(BASE + '/config', {
3370
+ method: 'POST',
3371
+ headers: { 'Content-Type': 'application/json' },
3372
+ body: JSON.stringify(payload),
3373
+ });
3374
+ var result = await res.json();
3375
+ if (result.ok) {
3376
+ showToast('"' + id + t('cr.saved'));
3377
+ } else {
3378
+ showToast(t('common.save_failed') + (result.error || 'unknown'), true);
3379
+ }
3380
+ } catch (e) {
3381
+ showToast(t('common.save_failed') + e.message, true);
3382
+ }
3383
+ }
3384
+
3385
+ // ── Init ──
3386
+ refreshAll();
3387
+ loadConfig();
3388
+ loadPrompts();
3389
+ setInterval(refreshAll, 10000);
3390
+ window.addEventListener('resize', function() {
3391
+ var tl = document.getElementById('session-timeline');
3392
+ if (tl && tl.style.maxHeight) {
3393
+ var rect = tl.getBoundingClientRect();
3394
+ var available = window.innerHeight - rect.top - 24;
3395
+ tl.style.maxHeight = Math.max(200, available) + 'px';
3396
+ }
3397
+ });
3398
+ if (LANG !== 'en') setLang(LANG);
3399
+ </script>
3400
+ </body>
3401
+ </html>`;
3402
+ }