@elvatis_com/openclaw-cli-bridge-elvatis 2.10.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +113 -0
- package/README.md +15 -1
- package/SKILL.md +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/cli-runner.ts +13 -6
- package/src/config.ts +8 -1
- package/src/debug-log.ts +47 -1
- package/src/proxy-server.ts +65 -5
- package/src/status-template.ts +344 -104
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# OpenClaw CLI Bridge
|
|
2
|
+
|
|
3
|
+
## Project Overview
|
|
4
|
+
|
|
5
|
+
OpenClaw plugin that bridges AI CLIs (Claude, Gemini, Codex, Grok, ChatGPT) as model providers via a local OpenAI-compatible HTTP proxy on `127.0.0.1:31337`. The gateway routes `vllm/` model requests here; the bridge spawns CLI subprocesses and translates between OpenAI protocol and CLI text I/O.
|
|
6
|
+
|
|
7
|
+
## Architecture
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
OpenClaw Gateway ──(HTTP)──> proxy-server.ts ──(spawn)──> claude/gemini/codex CLI
|
|
11
|
+
:31337 │
|
|
12
|
+
├── cli-runner.ts (subprocess spawn, stdin prompt, timeout, stale-output detection)
|
|
13
|
+
├── tool-protocol.ts (tool schema injection, JSON response parsing, tool_calls rescue)
|
|
14
|
+
├── config.ts (all timeouts, thresholds, paths — single source of truth)
|
|
15
|
+
├── debug-log.ts (file-based log: ~/.openclaw/cli-bridge/debug.log)
|
|
16
|
+
├── metrics.ts (request metrics, persisted to ~/.openclaw/cli-bridge/metrics.json)
|
|
17
|
+
├── provider-sessions.ts (persistent session registry per model)
|
|
18
|
+
├── status-template.ts (HTML dashboard at GET /status)
|
|
19
|
+
├── session-manager.ts (long-running session spawn/poll/kill)
|
|
20
|
+
├── *-browser.ts (Playwright web-session providers: Grok, Gemini, Claude, ChatGPT)
|
|
21
|
+
└── gemini-api-runner.ts (native Gemini API via @google/genai SDK)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Key Design Decisions
|
|
25
|
+
|
|
26
|
+
- **Prompt via stdin, never CLI args** — avoids E2BIG on large sessions
|
|
27
|
+
- **Tool calls via text injection** — CLI tools don't support native tool_use, so tool schemas are injected into the prompt text and responses are parsed for JSON
|
|
28
|
+
- **JSON reminder sandwich** — tool instructions appear at start AND end of prompt; models (especially Haiku) forget format instructions after long conversations
|
|
29
|
+
- **Stale-output detection** — if a CLI subprocess produces zero stdout for 30s, SIGTERM early instead of waiting full timeout. Claude Sonnet intermittently hangs silently on large tool prompts (API-side issue, not RAM — confirmed 28GB free, zero swap)
|
|
30
|
+
- **Smart fallback** — Sonnet tries first (better tool selection), 30s stale timeout kills it fast, Haiku takes over (~10s, reliable but picks wrong tools sometimes)
|
|
31
|
+
- **Compact tool schema** — when >8 tools, only send name+params (skip descriptions/full JSON schema), cuts prompt ~60%
|
|
32
|
+
- **Exit 143 = our SIGTERM** — not OOM, not crash. The bridge's timeout/stale-output detector sends SIGTERM, Claude CLI exits 143
|
|
33
|
+
|
|
34
|
+
## Build & Test
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
npm run build # tsc — always has 5 pre-existing errors (openclaw/plugin-sdk not found at compile time, only at runtime). Dist output is still generated correctly.
|
|
38
|
+
npx vitest run # 278+ tests across 19 files. All must pass.
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Deploy Workflow
|
|
42
|
+
|
|
43
|
+
The gateway loads plugins from `~/.openclaw/extensions/`, NOT from this workspace:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
npm run build
|
|
47
|
+
rsync -a --exclude node_modules --exclude .git ./ ~/.openclaw/extensions/openclaw-cli-bridge-elvatis/
|
|
48
|
+
openclaw gateway restart
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Version Bump Checklist
|
|
52
|
+
|
|
53
|
+
Bump version string in 4 files before every release:
|
|
54
|
+
1. `package.json`
|
|
55
|
+
2. `openclaw.plugin.json`
|
|
56
|
+
3. `README.md` (line 5: "Current version")
|
|
57
|
+
4. `SKILL.md` (last line: "Version:")
|
|
58
|
+
|
|
59
|
+
Then: `git commit && git push && gh release create vX.Y.Z`
|
|
60
|
+
|
|
61
|
+
## Config (src/config.ts)
|
|
62
|
+
|
|
63
|
+
All magic numbers live here. Key values:
|
|
64
|
+
|
|
65
|
+
| Constant | Value | Purpose |
|
|
66
|
+
|----------|-------|---------|
|
|
67
|
+
| `MAX_EFFECTIVE_TIMEOUT_MS` | 580s | Must stay UNDER gateway's `idleTimeoutSeconds` (600s) |
|
|
68
|
+
| `STALE_OUTPUT_TIMEOUT_MS` | 30s | Kill silent CLI processes fast |
|
|
69
|
+
| `TOOL_HEAVY_THRESHOLD` | 10 | Reduce MAX_MESSAGES from 20 to 12 when tools exceed this |
|
|
70
|
+
| `COMPACT_TOOL_THRESHOLD` | 8 | Switch to compact tool schema (name+params only) |
|
|
71
|
+
| `TOOL_ROUTING_THRESHOLD` | 8 | (in proxy-server) Was used for Haiku routing, now Sonnet-first with fast fallback |
|
|
72
|
+
|
|
73
|
+
## Tool Protocol (src/tool-protocol.ts)
|
|
74
|
+
|
|
75
|
+
Models receive tool schemas as text and must respond with:
|
|
76
|
+
- `{"tool_calls":[{"name":"...","arguments":{...}}]}` — to call a tool
|
|
77
|
+
- `{"content":"..."}` — to respond with text
|
|
78
|
+
|
|
79
|
+
Parser tries 5 strategies: Claude JSON wrapper, direct JSON, code blocks, embedded JSON, rescue from content string. Debug logging on every path.
|
|
80
|
+
|
|
81
|
+
## Known Issues
|
|
82
|
+
|
|
83
|
+
- **Sonnet intermittent hangs** — `claude -p` with Sonnet goes completely silent (~50% of the time) on large tool prompts (20KB+). First call often works, subsequent calls hang. NOT RAM-related. Likely API-side rate limiting or request dedup. Workaround: 30s stale-output detection + Haiku fallback.
|
|
84
|
+
- **Haiku empty responses** — occasionally returns zero stdout (len:0). Cause unclear. The JSON reminder at prompt end helps but doesn't fully solve it.
|
|
85
|
+
- **Pre-existing tsc errors** — 5 errors about `openclaw/plugin-sdk` module not found. These are expected — the SDK is injected at runtime by the gateway. Dist output is still generated.
|
|
86
|
+
|
|
87
|
+
## Testing
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
npx vitest run # full suite (278+ tests)
|
|
91
|
+
npx vitest run test/config.test.ts # just config
|
|
92
|
+
npx vitest run test/proxy-e2e.test.ts # proxy integration
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Tests that mock `config.js` must use `importOriginal` spread pattern — the mock must include all exports since `debug-log.ts` and `cli-runner.ts` import from config at module level.
|
|
96
|
+
|
|
97
|
+
## Debug
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
tail -f ~/.openclaw/cli-bridge/debug.log # real-time request lifecycle
|
|
101
|
+
curl http://127.0.0.1:31337/status # dashboard (auto-refresh 10s)
|
|
102
|
+
cat ~/.openclaw/cli-bridge/metrics.json # persisted metrics
|
|
103
|
+
cat ~/.openclaw/cli-bridge/sessions.json # provider session state
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Roadmap (v3.0)
|
|
107
|
+
|
|
108
|
+
- [ ] Dashboard v2: sidebar navigation, live log viewer, model config editor, routing visualization
|
|
109
|
+
- [ ] User-configurable routing engine via dashboard UI
|
|
110
|
+
- [ ] Multi-model fallback chains: Claude → Gemini → Codex → Haiku
|
|
111
|
+
- [ ] Per-tool routing: write/exec → fast model, search/analyze → smart model
|
|
112
|
+
- [ ] Model health scoring: track success rates, auto-demote unreliable models
|
|
113
|
+
- [ ] Session resume: use `claude --resume` for conversation continuity instead of fresh `-p` each time
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> OpenClaw plugin that bridges locally installed AI CLIs (Codex, Gemini, Claude Code, OpenCode, Pi) as model providers — with slash commands for instant model switching, restore, health testing, and model listing.
|
|
4
4
|
|
|
5
|
-
**Current version:** `
|
|
5
|
+
**Current version:** `3.0.0`
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -406,6 +406,20 @@ npm run ci # lint + typecheck + test
|
|
|
406
406
|
|
|
407
407
|
## Changelog
|
|
408
408
|
|
|
409
|
+
### v3.0.0
|
|
410
|
+
- **feat:** dashboard v2 — sidebar navigation with 9 sections (Overview, Providers, Active, Requests, Fallbacks, Sessions, Live Logs, Timeouts, Models)
|
|
411
|
+
- **feat:** live log viewer — SSE-powered real-time log streaming with color-coded categories, auto-scroll, pause/resume, 500-line client buffer
|
|
412
|
+
- **feat:** AJAX polling — replaces full-page meta-refresh with incremental section updates every 10s (preserves scroll position and active section)
|
|
413
|
+
- **feat:** `/api/dashboard-data` endpoint — returns pre-rendered HTML sections as JSON for client-side updates
|
|
414
|
+
- **feat:** `/api/logs/stream` SSE endpoint — streams debug.log in real-time with initial tail of last 100 lines
|
|
415
|
+
- **feat:** mobile-responsive sidebar with hamburger toggle
|
|
416
|
+
- **feat:** CLAUDE.md added with full project documentation
|
|
417
|
+
|
|
418
|
+
### v2.10.1
|
|
419
|
+
- **feat:** smart tool-routing — tool-heavy requests (>8 tools) auto-route to Haiku instead of Sonnet. Haiku handles tool calls in ~11s vs Sonnet's 80-120s (with intermittent hangs). Sonnet is preserved for reasoning/text responses.
|
|
420
|
+
- **fix:** reduce stale-output timeout 120s→60s — faster fallback when Sonnet goes silent
|
|
421
|
+
- **feat:** per-model spawn logging with prompt size for debugging
|
|
422
|
+
|
|
409
423
|
### v2.10.0
|
|
410
424
|
- **fix:** cap effective timeout at 580s (under gateway's 600s `idleTimeoutSeconds`) so bridge fallback fires BEFORE gateway kills the request — eliminates the race condition where both compete to handle the timeout
|
|
411
425
|
- **fix:** reduce Sonnet base timeout 420s→300s, Opus 420s→360s — ensures fallback triggers faster for stuck CLI sessions
|
package/SKILL.md
CHANGED
package/openclaw.plugin.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "openclaw-cli-bridge-elvatis",
|
|
3
3
|
"slug": "openclaw-cli-bridge-elvatis",
|
|
4
4
|
"name": "OpenClaw CLI Bridge",
|
|
5
|
-
"version": "
|
|
5
|
+
"version": "3.0.0",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"description": "Phase 1: openai-codex auth bridge. Phase 2: local HTTP proxy routing model calls through gemini/claude CLIs (vllm provider).",
|
|
8
8
|
"providers": [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elvatis_com/openclaw-cli-bridge-elvatis",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "Bridges gemini, claude, and codex CLI tools as OpenClaw model providers. Reads existing CLI auth without re-login.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"openclaw": {
|
package/src/cli-runner.ts
CHANGED
|
@@ -508,9 +508,11 @@ export async function runGemini(
|
|
|
508
508
|
const args = ["-m", model, "-p", "", "--approval-mode", "yolo"];
|
|
509
509
|
const cwd = workdir ?? tmpdir();
|
|
510
510
|
|
|
511
|
-
// When tools are present,
|
|
511
|
+
// When tools are present, sandwich the conversation between tool instructions.
|
|
512
|
+
// The reminder at the end ensures models (especially Haiku) remember the JSON format
|
|
513
|
+
// after processing a long conversation history.
|
|
512
514
|
const effectivePrompt = opts?.tools?.length
|
|
513
|
-
? buildToolPromptBlock(opts.tools) + "\n\n" + prompt
|
|
515
|
+
? buildToolPromptBlock(opts.tools) + "\n\n" + prompt + "\n\nREMINDER: You MUST respond with ONLY valid JSON — either {\"tool_calls\":[...]} or {\"content\":\"...\"}. Nothing else."
|
|
514
516
|
: prompt;
|
|
515
517
|
|
|
516
518
|
const result = await runCli("gemini", args, effectivePrompt, timeoutMs, { cwd, log: opts?.log });
|
|
@@ -560,12 +562,15 @@ export async function runClaude(
|
|
|
560
562
|
"--model", model,
|
|
561
563
|
];
|
|
562
564
|
|
|
563
|
-
// When tools are present,
|
|
565
|
+
// When tools are present, sandwich the conversation between tool instructions.
|
|
566
|
+
// The reminder at the end ensures models (especially Haiku) remember the JSON format
|
|
567
|
+
// after processing a long conversation history.
|
|
564
568
|
const effectivePrompt = opts?.tools?.length
|
|
565
|
-
? buildToolPromptBlock(opts.tools) + "\n\n" + prompt
|
|
569
|
+
? buildToolPromptBlock(opts.tools) + "\n\n" + prompt + "\n\nREMINDER: You MUST respond with ONLY valid JSON — either {\"tool_calls\":[...]} or {\"content\":\"...\"}. Nothing else."
|
|
566
570
|
: prompt;
|
|
567
571
|
|
|
568
572
|
const cwd = workdir ?? homedir();
|
|
573
|
+
debugLog("CLAUDE", `spawn ${model}`, { promptLen: effectivePrompt.length, promptKB: Math.round(effectivePrompt.length / 1024), cwd, timeoutMs: Math.round(timeoutMs / 1000) });
|
|
569
574
|
const result = await runCli("claude", args, effectivePrompt, timeoutMs, { cwd, log: opts?.log });
|
|
570
575
|
|
|
571
576
|
// On 401: attempt one token refresh + retry before giving up.
|
|
@@ -640,9 +645,11 @@ export async function runCodex(
|
|
|
640
645
|
// Codex requires a git repo in the working directory
|
|
641
646
|
ensureGitRepo(cwd);
|
|
642
647
|
|
|
643
|
-
// When tools are present,
|
|
648
|
+
// When tools are present, sandwich the conversation between tool instructions.
|
|
649
|
+
// The reminder at the end ensures models (especially Haiku) remember the JSON format
|
|
650
|
+
// after processing a long conversation history.
|
|
644
651
|
const effectivePrompt = opts?.tools?.length
|
|
645
|
-
? buildToolPromptBlock(opts.tools) + "\n\n" + prompt
|
|
652
|
+
? buildToolPromptBlock(opts.tools) + "\n\n" + prompt + "\n\nREMINDER: You MUST respond with ONLY valid JSON — either {\"tool_calls\":[...]} or {\"content\":\"...\"}. Nothing else."
|
|
646
653
|
: prompt;
|
|
647
654
|
|
|
648
655
|
const result = await runCli("codex", args, effectivePrompt, timeoutMs, { cwd, log: opts?.log });
|
package/src/config.ts
CHANGED
|
@@ -57,7 +57,7 @@ export const TIMEOUT_GRACE_MS = 5_000;
|
|
|
57
57
|
* assume it's stuck and SIGTERM early. 0 = disabled.
|
|
58
58
|
* Prevents waiting the full timeout when Claude CLI hangs silently.
|
|
59
59
|
*/
|
|
60
|
-
export const STALE_OUTPUT_TIMEOUT_MS =
|
|
60
|
+
export const STALE_OUTPUT_TIMEOUT_MS = 30_000; // 30s of silence → kill. Sonnet either starts producing within 30s or it's hung.
|
|
61
61
|
|
|
62
62
|
/** Max messages to include in the prompt sent to CLI subprocesses. */
|
|
63
63
|
export const MAX_MESSAGES = 20;
|
|
@@ -71,6 +71,13 @@ export const MAX_MESSAGES_HEAVY_TOOLS = 12;
|
|
|
71
71
|
/** Tool count threshold that triggers reduced message limit. */
|
|
72
72
|
export const TOOL_HEAVY_THRESHOLD = 10;
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Tool count threshold that triggers smart routing to a faster model.
|
|
76
|
+
* When Sonnet receives a request with this many tools, route to Haiku instead.
|
|
77
|
+
* Haiku handles tool calls in ~11s vs Sonnet's 80-120s (and Sonnet hangs intermittently).
|
|
78
|
+
*/
|
|
79
|
+
export const TOOL_ROUTING_THRESHOLD = 8;
|
|
80
|
+
|
|
74
81
|
/** Max characters per message content before truncation. */
|
|
75
82
|
export const MAX_MSG_CHARS = 4_000;
|
|
76
83
|
|
package/src/debug-log.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* tail -f ~/.openclaw/cli-bridge/debug.log
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { appendFileSync, statSync, renameSync, mkdirSync } from "node:fs";
|
|
11
|
+
import { appendFileSync, readFileSync, openSync, readSync, closeSync, statSync, renameSync, mkdirSync, watchFile, unwatchFile } from "node:fs";
|
|
12
12
|
import { join } from "node:path";
|
|
13
13
|
import { homedir } from "node:os";
|
|
14
14
|
|
|
@@ -53,3 +53,49 @@ export function debugLog(category: string, message: string, data?: Record<string
|
|
|
53
53
|
|
|
54
54
|
/** Log path for display on status page / startup messages. */
|
|
55
55
|
export const DEBUG_LOG_PATH = LOG_FILE;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Read the last N lines from the log file.
|
|
59
|
+
* Returns null if the file doesn't exist.
|
|
60
|
+
*/
|
|
61
|
+
export function getLogTail(lines = 100): string | null {
|
|
62
|
+
try {
|
|
63
|
+
const content = readFileSync(LOG_FILE, "utf8");
|
|
64
|
+
const allLines = content.split("\n").filter(Boolean);
|
|
65
|
+
return allLines.slice(-lines).join("\n");
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Watch the log file for new content and call the callback for each new line.
|
|
73
|
+
* Returns an unwatch function to stop watching.
|
|
74
|
+
*/
|
|
75
|
+
export function watchLogFile(onLine: (line: string) => void): () => void {
|
|
76
|
+
let lastSize = 0;
|
|
77
|
+
try { lastSize = statSync(LOG_FILE).size; } catch { /* file doesn't exist yet */ }
|
|
78
|
+
|
|
79
|
+
const listener = () => {
|
|
80
|
+
try {
|
|
81
|
+
const stat = statSync(LOG_FILE);
|
|
82
|
+
if (stat.size <= lastSize) {
|
|
83
|
+
// File was rotated or truncated — reset
|
|
84
|
+
lastSize = 0;
|
|
85
|
+
}
|
|
86
|
+
if (stat.size > lastSize) {
|
|
87
|
+
const buf = Buffer.alloc(stat.size - lastSize);
|
|
88
|
+
const fd = openSync(LOG_FILE, "r");
|
|
89
|
+
readSync(fd, buf, 0, buf.length, lastSize);
|
|
90
|
+
closeSync(fd);
|
|
91
|
+
const newContent = buf.toString("utf8");
|
|
92
|
+
const lines = newContent.split("\n").filter(Boolean);
|
|
93
|
+
for (const line of lines) onLine(line);
|
|
94
|
+
lastSize = stat.size;
|
|
95
|
+
}
|
|
96
|
+
} catch { /* best effort */ }
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
watchFile(LOG_FILE, { interval: 1000 }, listener);
|
|
100
|
+
return () => { unwatchFile(LOG_FILE, listener); };
|
|
101
|
+
}
|
package/src/proxy-server.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { claudeComplete, claudeCompleteStream, type ChatMessage as ClaudeBrowser
|
|
|
18
18
|
import { chatgptComplete, chatgptCompleteStream, type ChatMessage as ChatGPTBrowserChatMessage } from "./chatgpt-browser.js";
|
|
19
19
|
import { geminiApiComplete, geminiApiCompleteStream, type GeminiApiResult, type ContentPart } from "./gemini-api-runner.js";
|
|
20
20
|
import type { BrowserContext } from "playwright";
|
|
21
|
-
import { renderStatusPage, type StatusProvider } from "./status-template.js";
|
|
21
|
+
import { renderStatusPage, renderDashboardData, type StatusProvider } from "./status-template.js";
|
|
22
22
|
import { sessionManager } from "./session-manager.js";
|
|
23
23
|
import { metrics, estimateTokens } from "./metrics.js";
|
|
24
24
|
import { providerSessions } from "./provider-sessions.js";
|
|
@@ -32,8 +32,9 @@ import {
|
|
|
32
32
|
BITNET_MAX_MESSAGES,
|
|
33
33
|
BITNET_SYSTEM_PROMPT,
|
|
34
34
|
DEFAULT_MODEL_TIMEOUTS,
|
|
35
|
+
TOOL_ROUTING_THRESHOLD,
|
|
35
36
|
} from "./config.js";
|
|
36
|
-
import { debugLog, DEBUG_LOG_PATH } from "./debug-log.js";
|
|
37
|
+
import { debugLog, DEBUG_LOG_PATH, getLogTail, watchLogFile } from "./debug-log.js";
|
|
37
38
|
|
|
38
39
|
// ── Active request tracking ─────────────────────────────────────────────────
|
|
39
40
|
|
|
@@ -315,6 +316,58 @@ async function handleRequest(
|
|
|
315
316
|
return;
|
|
316
317
|
}
|
|
317
318
|
|
|
319
|
+
// Dashboard data API — returns pre-rendered HTML sections for AJAX polling
|
|
320
|
+
if (url === "/api/dashboard-data" && req.method === "GET") {
|
|
321
|
+
const expiry = opts.getExpiryInfo?.() ?? { grok: null, gemini: null, claude: null, chatgpt: null };
|
|
322
|
+
const version = opts.version ?? "?";
|
|
323
|
+
const providers: StatusProvider[] = [
|
|
324
|
+
{ name: "Grok", icon: "\uD835\uDD4F", expiry: expiry.grok, loginCmd: "/grok-login", ctx: opts.getGrokContext?.() ?? null },
|
|
325
|
+
{ name: "Gemini", icon: "\u2726", expiry: expiry.gemini, loginCmd: "/gemini-login", ctx: opts.getGeminiContext?.() ?? null },
|
|
326
|
+
{ name: "Claude", icon: "\u25C6", expiry: expiry.claude, loginCmd: "/claude-login", ctx: opts.getClaudeContext?.() ?? null },
|
|
327
|
+
{ name: "ChatGPT", icon: "\u25C9", expiry: expiry.chatgpt, loginCmd: "/chatgpt-login", ctx: opts.getChatGPTContext?.() ?? null },
|
|
328
|
+
];
|
|
329
|
+
const sections = renderDashboardData({
|
|
330
|
+
version, port: opts.port, providers, models: CLI_MODELS,
|
|
331
|
+
modelCommands: opts.modelCommands,
|
|
332
|
+
metrics: metrics.getMetrics(),
|
|
333
|
+
activeRequests: getActiveRequests(),
|
|
334
|
+
providerSessionsList: providerSessions.listSessions(),
|
|
335
|
+
timeoutConfig: {
|
|
336
|
+
defaults: { ...DEFAULT_MODEL_TIMEOUTS, ...(opts.modelTimeouts ?? {}) },
|
|
337
|
+
baseDefault: opts.timeoutMs ?? DEFAULT_PROXY_TIMEOUT_MS,
|
|
338
|
+
maxEffective: MAX_EFFECTIVE_TIMEOUT_MS,
|
|
339
|
+
perExtraMsg: TIMEOUT_PER_EXTRA_MSG_MS,
|
|
340
|
+
perTool: TIMEOUT_PER_TOOL_MS,
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
res.writeHead(200, { "Content-Type": "application/json", ...corsHeaders() });
|
|
344
|
+
res.end(JSON.stringify(sections));
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Live log streaming via SSE
|
|
349
|
+
if (url === "/api/logs/stream" && req.method === "GET") {
|
|
350
|
+
res.writeHead(200, {
|
|
351
|
+
"Content-Type": "text/event-stream",
|
|
352
|
+
"Cache-Control": "no-cache",
|
|
353
|
+
Connection: "keep-alive",
|
|
354
|
+
...corsHeaders(),
|
|
355
|
+
});
|
|
356
|
+
// Send initial tail
|
|
357
|
+
const tail = getLogTail(100);
|
|
358
|
+
if (tail) res.write(`data: ${tail.replace(/\n/g, "\ndata: ")}\n\n`);
|
|
359
|
+
// Watch for new lines
|
|
360
|
+
const unwatch = watchLogFile((line) => {
|
|
361
|
+
try { res.write(`data: ${line}\n\n`); } catch { /* client disconnected */ }
|
|
362
|
+
});
|
|
363
|
+
// Keepalive
|
|
364
|
+
const ka = setInterval(() => {
|
|
365
|
+
try { res.write(": keepalive\n\n"); } catch { /* client disconnected */ }
|
|
366
|
+
}, 15_000);
|
|
367
|
+
req.on("close", () => { unwatch(); clearInterval(ka); });
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
|
|
318
371
|
// Model list
|
|
319
372
|
if (url === "/v1/models" && req.method === "GET") {
|
|
320
373
|
const now = Math.floor(Date.now() / 1000);
|
|
@@ -790,6 +843,13 @@ async function handleRequest(
|
|
|
790
843
|
// ── CLI runner routing (Gemini / Claude Code / Codex) ──────────────────────
|
|
791
844
|
let result: CliToolResult;
|
|
792
845
|
let usedModel = model;
|
|
846
|
+
|
|
847
|
+
// ── Smart tool routing: Sonnet first (better reasoning), fast fallback to Haiku ──
|
|
848
|
+
// Sonnet picks the right tools but intermittently hangs on large prompts.
|
|
849
|
+
// Strategy: let Sonnet try first — if it responds, great (better tool selection).
|
|
850
|
+
// The stale-output detector (60s) kills it fast if it hangs, then fallback to Haiku.
|
|
851
|
+
// This preserves Sonnet's intelligence for tool selection while keeping Haiku as safety net.
|
|
852
|
+
|
|
793
853
|
const routeOpts = { workdir, tools: hasTools ? tools : undefined, mediaFiles: mediaFiles.length ? mediaFiles : undefined, log: opts.log };
|
|
794
854
|
|
|
795
855
|
// ── Provider session: ensure a persistent session for this model ────────
|
|
@@ -843,12 +903,12 @@ async function handleRequest(
|
|
|
843
903
|
|
|
844
904
|
const cliStart = Date.now();
|
|
845
905
|
try {
|
|
846
|
-
result = await routeToCliRunner(
|
|
906
|
+
result = await routeToCliRunner(usedModel, cleanMessages, effectiveTimeout, routeOpts);
|
|
847
907
|
const latencyMs = Date.now() - cliStart;
|
|
848
908
|
const estCompletionTokens = estimateTokens(result.content ?? "");
|
|
849
|
-
metrics.recordRequest(
|
|
909
|
+
metrics.recordRequest(usedModel, latencyMs, true, estPromptTokens, estCompletionTokens, promptPreview);
|
|
850
910
|
providerSessions.recordRun(session.id, false);
|
|
851
|
-
debugLog("OK", `${
|
|
911
|
+
debugLog("OK", `${usedModel} completed in ${(latencyMs / 1000).toFixed(1)}s`, { toolCalls: result.tool_calls?.length ?? 0, contentLen: result.content?.length ?? 0 });
|
|
852
912
|
} catch (err) {
|
|
853
913
|
const primaryDuration = Date.now() - cliStart;
|
|
854
914
|
const msg = (err as Error).message;
|
package/src/status-template.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* status-template.ts
|
|
3
3
|
*
|
|
4
4
|
* Generates the HTML dashboard for the /status endpoint.
|
|
5
|
-
*
|
|
5
|
+
* v3.0: Sidebar navigation, section-based layout, JS polling, live log viewer.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { BrowserContext } from "playwright";
|
|
@@ -39,11 +39,11 @@ export interface StatusTemplateOptions {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
function statusBadge(p: StatusProvider): { label: string; color: string; dot: string } {
|
|
42
|
-
if (p.ctx !== null) return { label: "Connected", color: "#22c55e", dot: "
|
|
43
|
-
if (!p.expiry) return { label: "Never logged in", color: "#6b7280", dot: "
|
|
44
|
-
if (p.expiry.startsWith("
|
|
45
|
-
if (p.expiry.startsWith("
|
|
46
|
-
return { label: "Logged in", color: "#3b82f6", dot: "
|
|
42
|
+
if (p.ctx !== null) return { label: "Connected", color: "#22c55e", dot: "\u{1F7E2}" };
|
|
43
|
+
if (!p.expiry) return { label: "Never logged in", color: "#6b7280", dot: "\u26AA" };
|
|
44
|
+
if (p.expiry.startsWith("\u26A0\uFE0F EXPIRED")) return { label: "Expired", color: "#ef4444", dot: "\u{1F534}" };
|
|
45
|
+
if (p.expiry.startsWith("\u{1F6A8}")) return { label: "Expiring soon", color: "#f59e0b", dot: "\u{1F7E1}" };
|
|
46
|
+
return { label: "Logged in", color: "#3b82f6", dot: "\u{1F535}" };
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
// ── Formatting helpers ──────────────────────────────────────────────────────
|
|
@@ -91,9 +91,46 @@ function truncateId(id: string): string {
|
|
|
91
91
|
return id.slice(0, 8) + "\u2026" + id.slice(-8);
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
// ──
|
|
94
|
+
// ── Section renderers (each returns an HTML fragment) ──────────────────────
|
|
95
95
|
|
|
96
|
-
function
|
|
96
|
+
export function renderProviders(providers: StatusProvider[]): string {
|
|
97
|
+
const rows = providers.map(p => {
|
|
98
|
+
const badge = statusBadge(p);
|
|
99
|
+
const expiryText = p.expiry
|
|
100
|
+
? p.expiry.replace(/[\u26A0\uFE0F\u{1F6A8}\u2705\u{1F550}]/gu, "").trim()
|
|
101
|
+
: `Not logged in \u2014 run <code>${p.loginCmd}</code>`;
|
|
102
|
+
return `
|
|
103
|
+
<tr>
|
|
104
|
+
<td style="padding:12px 16px;font-weight:600;font-size:15px">${p.icon} ${p.name}</td>
|
|
105
|
+
<td style="padding:12px 16px">
|
|
106
|
+
<span style="background:${badge.color}22;color:${badge.color};border:1px solid ${badge.color}44;
|
|
107
|
+
border-radius:6px;padding:3px 10px;font-size:13px;font-weight:600">
|
|
108
|
+
${badge.dot} ${badge.label}
|
|
109
|
+
</span>
|
|
110
|
+
</td>
|
|
111
|
+
<td style="padding:12px 16px;color:#9ca3af;font-size:13px">${expiryText}</td>
|
|
112
|
+
<td style="padding:12px 16px;color:#6b7280;font-size:12px;font-family:monospace">${p.loginCmd}</td>
|
|
113
|
+
</tr>`;
|
|
114
|
+
}).join("");
|
|
115
|
+
|
|
116
|
+
return `
|
|
117
|
+
<div class="card">
|
|
118
|
+
<div class="card-header">Web Session Providers</div>
|
|
119
|
+
<table>
|
|
120
|
+
<thead>
|
|
121
|
+
<tr class="table-head">
|
|
122
|
+
<th style="padding:10px 16px;text-align:left;font-size:12px;color:#4b5563;font-weight:600">Provider</th>
|
|
123
|
+
<th style="padding:10px 16px;text-align:left;font-size:12px;color:#4b5563;font-weight:600">Status</th>
|
|
124
|
+
<th style="padding:10px 16px;text-align:left;font-size:12px;color:#4b5563;font-weight:600">Session</th>
|
|
125
|
+
<th style="padding:10px 16px;text-align:left;font-size:12px;color:#4b5563;font-weight:600">Login</th>
|
|
126
|
+
</tr>
|
|
127
|
+
</thead>
|
|
128
|
+
<tbody>${rows}</tbody>
|
|
129
|
+
</table>
|
|
130
|
+
</div>`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function renderActiveRequests(active: ActiveRequest[]): string {
|
|
97
134
|
if (active.length === 0) {
|
|
98
135
|
return `
|
|
99
136
|
<div class="card">
|
|
@@ -135,9 +172,7 @@ function renderActiveRequests(active: ActiveRequest[]): string {
|
|
|
135
172
|
</div>`;
|
|
136
173
|
}
|
|
137
174
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
function renderRecentRequestLog(entries: RequestLogEntry[]): string {
|
|
175
|
+
export function renderRecentRequestLog(entries: RequestLogEntry[]): string {
|
|
141
176
|
if (entries.length === 0) {
|
|
142
177
|
return `
|
|
143
178
|
<div class="card">
|
|
@@ -180,9 +215,7 @@ function renderRecentRequestLog(entries: RequestLogEntry[]): string {
|
|
|
180
215
|
</div>`;
|
|
181
216
|
}
|
|
182
217
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
function renderFallbackHistory(events: FallbackEvent[]): string {
|
|
218
|
+
export function renderFallbackHistory(events: FallbackEvent[]): string {
|
|
186
219
|
if (events.length === 0) {
|
|
187
220
|
return `
|
|
188
221
|
<div class="card">
|
|
@@ -228,9 +261,7 @@ function renderFallbackHistory(events: FallbackEvent[]): string {
|
|
|
228
261
|
</div>`;
|
|
229
262
|
}
|
|
230
263
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
function renderProviderSessions(sessions: ProviderSession[]): string {
|
|
264
|
+
export function renderProviderSessions(sessions: ProviderSession[]): string {
|
|
234
265
|
if (sessions.length === 0) {
|
|
235
266
|
return `
|
|
236
267
|
<div class="card">
|
|
@@ -272,9 +303,7 @@ function renderProviderSessions(sessions: ProviderSession[]): string {
|
|
|
272
303
|
</div>`;
|
|
273
304
|
}
|
|
274
305
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
function renderTimeoutConfig(config: TimeoutConfigInfo): string {
|
|
306
|
+
export function renderTimeoutConfig(config: TimeoutConfigInfo): string {
|
|
278
307
|
const entries = Object.entries(config.defaults).sort(([a], [b]) => a.localeCompare(b));
|
|
279
308
|
const rows = entries.map(([model, ms]) => {
|
|
280
309
|
return `
|
|
@@ -303,13 +332,10 @@ function renderTimeoutConfig(config: TimeoutConfigInfo): string {
|
|
|
303
332
|
</div>`;
|
|
304
333
|
}
|
|
305
334
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
function renderMetricsSection(m: MetricsSnapshot): string {
|
|
335
|
+
export function renderMetricsSection(m: MetricsSnapshot): string {
|
|
309
336
|
const errorRate = m.totalRequests > 0 ? ((m.totalErrors / m.totalRequests) * 100).toFixed(1) : "0.0";
|
|
310
337
|
const totalTokens = m.models.reduce((sum, mod) => sum + mod.promptTokens + mod.completionTokens, 0);
|
|
311
338
|
|
|
312
|
-
// Summary cards
|
|
313
339
|
const summaryCards = `
|
|
314
340
|
<div class="summary-grid">
|
|
315
341
|
<div class="summary-card">
|
|
@@ -330,7 +356,6 @@ function renderMetricsSection(m: MetricsSnapshot): string {
|
|
|
330
356
|
</div>
|
|
331
357
|
</div>`;
|
|
332
358
|
|
|
333
|
-
// Per-model stats table
|
|
334
359
|
let modelRows: string;
|
|
335
360
|
if (m.models.length === 0) {
|
|
336
361
|
modelRows = `<tr><td colspan="6" style="padding:16px;color:#6b7280;text-align:center;font-style:italic">No requests recorded yet.</td></tr>`;
|
|
@@ -371,33 +396,15 @@ function renderMetricsSection(m: MetricsSnapshot): string {
|
|
|
371
396
|
return summaryCards + modelTable;
|
|
372
397
|
}
|
|
373
398
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
const badge = statusBadge(p);
|
|
379
|
-
const expiryText = p.expiry
|
|
380
|
-
? p.expiry.replace(/[⚠️🚨✅🕐]/gu, "").trim()
|
|
381
|
-
: `Not logged in \u2014 run <code>${p.loginCmd}</code>`;
|
|
382
|
-
return `
|
|
383
|
-
<tr>
|
|
384
|
-
<td style="padding:12px 16px;font-weight:600;font-size:15px">${p.icon} ${p.name}</td>
|
|
385
|
-
<td style="padding:12px 16px">
|
|
386
|
-
<span style="background:${badge.color}22;color:${badge.color};border:1px solid ${badge.color}44;
|
|
387
|
-
border-radius:6px;padding:3px 10px;font-size:13px;font-weight:600">
|
|
388
|
-
${badge.dot} ${badge.label}
|
|
389
|
-
</span>
|
|
390
|
-
</td>
|
|
391
|
-
<td style="padding:12px 16px;color:#9ca3af;font-size:13px">${expiryText}</td>
|
|
392
|
-
<td style="padding:12px 16px;color:#6b7280;font-size:12px;font-family:monospace">${p.loginCmd}</td>
|
|
393
|
-
</tr>`;
|
|
394
|
-
}).join("");
|
|
395
|
-
|
|
399
|
+
function renderModels(
|
|
400
|
+
models: StatusTemplateOptions["models"],
|
|
401
|
+
modelCommands?: Record<string, string>,
|
|
402
|
+
): string {
|
|
396
403
|
const cliModels = models.filter(m => m.id.startsWith("cli-"));
|
|
397
404
|
const codexModels = models.filter(m => m.id.startsWith("openai-codex/"));
|
|
398
405
|
const webModels = models.filter(m => m.id.startsWith("web-"));
|
|
399
406
|
const localModels = models.filter(m => m.id.startsWith("local-"));
|
|
400
|
-
const cmds =
|
|
407
|
+
const cmds = modelCommands ?? {};
|
|
401
408
|
const modelList = (items: typeof models) =>
|
|
402
409
|
items.map(m => {
|
|
403
410
|
const cmd = cmds[m.id];
|
|
@@ -405,27 +412,114 @@ export function renderStatusPage(opts: StatusTemplateOptions): string {
|
|
|
405
412
|
return `<li style="margin:2px 0;font-size:13px;color:#d1d5db"><code class="model-id">${m.id}</code>${cmdBadge}</li>`;
|
|
406
413
|
}).join("");
|
|
407
414
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
415
|
+
return `
|
|
416
|
+
<div class="models">
|
|
417
|
+
<div class="card">
|
|
418
|
+
<div class="card-header">CLI Models (${cliModels.length})</div>
|
|
419
|
+
<ul>${modelList(cliModels)}</ul>
|
|
420
|
+
<div class="card-header">Codex Models (${codexModels.length})</div>
|
|
421
|
+
<ul>${modelList(codexModels)}</ul>
|
|
422
|
+
</div>
|
|
423
|
+
<div class="card">
|
|
424
|
+
<div class="card-header">Web Session Models (${webModels.length})</div>
|
|
425
|
+
<ul>${modelList(webModels)}</ul>
|
|
426
|
+
</div>
|
|
427
|
+
<div class="card">
|
|
428
|
+
<div class="card-header">Local Models (${localModels.length})</div>
|
|
429
|
+
<ul>${modelList(localModels)}</ul>
|
|
430
|
+
</div>
|
|
431
|
+
</div>`;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function renderLogsSection(): string {
|
|
435
|
+
return `
|
|
436
|
+
<div class="card" style="height:calc(100vh - 160px);display:flex;flex-direction:column">
|
|
437
|
+
<div class="card-header" style="flex-shrink:0;display:flex;justify-content:space-between;align-items:center">
|
|
438
|
+
<span>Live Logs <span id="log-status" class="badge badge-ok" style="margin-left:8px">connecting...</span></span>
|
|
439
|
+
<span>
|
|
440
|
+
<button onclick="toggleLogPause()" id="log-pause-btn" style="background:#1e2130;color:#9ca3af;border:1px solid #2d3148;border-radius:6px;padding:3px 10px;font-size:11px;cursor:pointer;margin-right:4px">Pause</button>
|
|
441
|
+
<button onclick="clearLogs()" style="background:#1e2130;color:#9ca3af;border:1px solid #2d3148;border-radius:6px;padding:3px 10px;font-size:11px;cursor:pointer">Clear</button>
|
|
442
|
+
</span>
|
|
443
|
+
</div>
|
|
444
|
+
<pre id="log-output" style="flex:1;overflow-y:auto;padding:12px 16px;font-size:12px;line-height:1.6;color:#9ca3af;margin:0;white-space:pre-wrap;word-break:break-all"></pre>
|
|
445
|
+
</div>`;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ── Dashboard data (for AJAX polling) ──────────────────────────────────────
|
|
449
|
+
|
|
450
|
+
export interface DashboardSections {
|
|
451
|
+
providers: string;
|
|
452
|
+
metrics: string;
|
|
453
|
+
active: string;
|
|
454
|
+
recent: string;
|
|
455
|
+
fallbacks: string;
|
|
456
|
+
sessions: string;
|
|
457
|
+
timeouts: string;
|
|
458
|
+
models: string;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
export function renderDashboardData(opts: StatusTemplateOptions): DashboardSections {
|
|
462
|
+
return {
|
|
463
|
+
providers: renderProviders(opts.providers),
|
|
464
|
+
metrics: opts.metrics ? renderMetricsSection(opts.metrics) : "",
|
|
465
|
+
active: opts.activeRequests ? renderActiveRequests(opts.activeRequests) : "",
|
|
466
|
+
recent: opts.metrics ? renderRecentRequestLog(opts.metrics.recentRequests) : "",
|
|
467
|
+
fallbacks: opts.metrics ? renderFallbackHistory(opts.metrics.fallbackHistory) : "",
|
|
468
|
+
sessions: opts.providerSessionsList ? renderProviderSessions(opts.providerSessionsList) : "",
|
|
469
|
+
timeouts: opts.timeoutConfig ? renderTimeoutConfig(opts.timeoutConfig) : "",
|
|
470
|
+
models: renderModels(opts.models, opts.modelCommands),
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// ── Navigation definitions ────────────────────────────────────────────────
|
|
475
|
+
|
|
476
|
+
const NAV_ITEMS = [
|
|
477
|
+
{ id: "overview", label: "Overview", icon: "\u25C9" },
|
|
478
|
+
{ id: "providers", label: "Providers", icon: "\u26A1" },
|
|
479
|
+
{ id: "active", label: "Active", icon: "\u25CF" },
|
|
480
|
+
{ id: "recent", label: "Requests", icon: "\u2630" },
|
|
481
|
+
{ id: "fallbacks", label: "Fallbacks", icon: "\u21C4" },
|
|
482
|
+
{ id: "sessions", label: "Sessions", icon: "\u29BF" },
|
|
483
|
+
{ id: "logs", label: "Live Logs", icon: "\u276F" },
|
|
484
|
+
{ id: "timeouts", label: "Timeouts", icon: "\u23F1" },
|
|
485
|
+
{ id: "models", label: "Models", icon: "\u2726" },
|
|
486
|
+
] as const;
|
|
487
|
+
|
|
488
|
+
// ── Full page render ──────────────────────────────────────────────────────
|
|
489
|
+
|
|
490
|
+
export function renderStatusPage(opts: StatusTemplateOptions): string {
|
|
491
|
+
const { version, port } = opts;
|
|
492
|
+
const sections = renderDashboardData(opts);
|
|
493
|
+
|
|
494
|
+
const navHtml = NAV_ITEMS.map(n =>
|
|
495
|
+
`<a href="#${n.id}" class="nav-item" data-nav="${n.id}" onclick="showSection('${n.id}')">${n.icon} ${n.label}</a>`
|
|
496
|
+
).join("\n ");
|
|
414
497
|
|
|
415
498
|
return `<!DOCTYPE html>
|
|
416
499
|
<html lang="en">
|
|
417
500
|
<head>
|
|
418
501
|
<meta charset="UTF-8">
|
|
419
502
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
420
|
-
<title>CLI Bridge
|
|
421
|
-
<meta http-equiv="refresh" content="10">
|
|
503
|
+
<title>CLI Bridge Dashboard</title>
|
|
422
504
|
<style>
|
|
423
505
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
424
|
-
body { background: #0f1117; color: #e5e7eb; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; min-height: 100vh;
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
.
|
|
428
|
-
.
|
|
506
|
+
body { background: #0f1117; color: #e5e7eb; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; min-height: 100vh; display: grid; grid-template-columns: 200px 1fr; }
|
|
507
|
+
|
|
508
|
+
/* ── Sidebar ── */
|
|
509
|
+
.sidebar { background: #13151f; border-right: 1px solid #2d3148; padding: 20px 0; position: fixed; top: 0; left: 0; bottom: 0; width: 200px; overflow-y: auto; z-index: 10; }
|
|
510
|
+
.sidebar-title { padding: 0 16px 16px; font-size: 16px; font-weight: 700; color: #f9fafb; border-bottom: 1px solid #2d3148; margin-bottom: 8px; }
|
|
511
|
+
.sidebar-version { display: block; font-size: 11px; color: #6b7280; font-weight: 400; margin-top: 2px; }
|
|
512
|
+
.nav-item { display: flex; align-items: center; gap: 8px; padding: 8px 16px; color: #9ca3af; text-decoration: none; font-size: 13px; transition: all 0.15s; border-left: 3px solid transparent; }
|
|
513
|
+
.nav-item:hover { color: #e5e7eb; background: #1a1d27; }
|
|
514
|
+
.nav-item.active { color: #3b82f6; background: #1e2130; border-left-color: #3b82f6; font-weight: 600; }
|
|
515
|
+
|
|
516
|
+
/* ── Main content ── */
|
|
517
|
+
.main { grid-column: 2; padding: 24px; min-height: 100vh; }
|
|
518
|
+
.section { display: none; }
|
|
519
|
+
.section.active { display: block; }
|
|
520
|
+
.section-title { font-size: 18px; font-weight: 700; color: #f9fafb; margin-bottom: 16px; }
|
|
521
|
+
|
|
522
|
+
/* ── Cards & tables ── */
|
|
429
523
|
.card { background: #1a1d27; border: 1px solid #2d3148; border-radius: 12px; overflow: hidden; margin-bottom: 24px; }
|
|
430
524
|
.card-header { padding: 14px 16px; border-bottom: 1px solid #2d3148; font-size: 12px; font-weight: 600; color: #6b7280; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
431
525
|
table { width: 100%; border-collapse: collapse; }
|
|
@@ -433,7 +527,6 @@ export function renderStatusPage(opts: StatusTemplateOptions): string {
|
|
|
433
527
|
.table-head { background: #13151f; }
|
|
434
528
|
.models { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
|
435
529
|
ul { list-style: none; padding: 12px 16px; }
|
|
436
|
-
.footer { color: #374151; font-size: 12px; text-align: center; margin-top: 16px; }
|
|
437
530
|
code { background: #1e2130; padding: 1px 5px; border-radius: 4px; }
|
|
438
531
|
.model-id { color: #93c5fd; }
|
|
439
532
|
.summary-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 24px; }
|
|
@@ -453,62 +546,209 @@ export function renderStatusPage(opts: StatusTemplateOptions): string {
|
|
|
453
546
|
.pulse-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #22c55e; animation: pulse 1.5s ease-in-out infinite; }
|
|
454
547
|
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
|
|
455
548
|
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
|
549
|
+
.footer { color: #374151; font-size: 12px; text-align: center; margin-top: 16px; }
|
|
550
|
+
|
|
551
|
+
/* ── Log colors ── */
|
|
552
|
+
.log-fail { color: #ef4444; }
|
|
553
|
+
.log-ok { color: #22c55e; }
|
|
554
|
+
.log-warn { color: #f59e0b; }
|
|
555
|
+
.log-kill { color: #f97316; }
|
|
556
|
+
.log-route { color: #8b5cf6; }
|
|
557
|
+
.log-dim { color: #6b7280; }
|
|
558
|
+
|
|
559
|
+
/* ── Mobile ── */
|
|
560
|
+
.mobile-toggle { display: none; position: fixed; top: 8px; left: 8px; z-index: 20; background: #1a1d27; border: 1px solid #2d3148; border-radius: 8px; padding: 6px 10px; color: #e5e7eb; font-size: 18px; cursor: pointer; }
|
|
456
561
|
@media (max-width: 768px) {
|
|
562
|
+
body { grid-template-columns: 1fr; }
|
|
563
|
+
.sidebar { transform: translateX(-100%); transition: transform 0.2s; }
|
|
564
|
+
.sidebar.open { transform: translateX(0); }
|
|
565
|
+
.main { grid-column: 1; }
|
|
566
|
+
.mobile-toggle { display: block; }
|
|
457
567
|
.summary-grid { grid-template-columns: repeat(2, 1fr); }
|
|
458
568
|
.models, .two-col { grid-template-columns: 1fr; }
|
|
459
569
|
}
|
|
460
570
|
</style>
|
|
461
571
|
</head>
|
|
462
572
|
<body>
|
|
463
|
-
<
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
<
|
|
469
|
-
<
|
|
470
|
-
<
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
573
|
+
<button class="mobile-toggle" onclick="document.querySelector('.sidebar').classList.toggle('open')">\u2630</button>
|
|
574
|
+
|
|
575
|
+
<nav class="sidebar">
|
|
576
|
+
<div class="sidebar-title">CLI Bridge<span class="sidebar-version">v${version} \u00b7 :${port}</span></div>
|
|
577
|
+
${navHtml}
|
|
578
|
+
<div style="padding:16px;margin-top:auto">
|
|
579
|
+
<div style="font-size:11px;color:#374151">
|
|
580
|
+
<a href="/v1/models" style="color:#4b5563;text-decoration:none">/v1/models</a> \u00b7
|
|
581
|
+
<a href="/healthz" style="color:#4b5563;text-decoration:none">/healthz</a>
|
|
582
|
+
</div>
|
|
583
|
+
</div>
|
|
584
|
+
</nav>
|
|
585
|
+
|
|
586
|
+
<main class="main">
|
|
587
|
+
<section data-section="overview" class="section active">
|
|
588
|
+
<h2 class="section-title">Overview</h2>
|
|
589
|
+
<div id="s-metrics">${sections.metrics}</div>
|
|
590
|
+
<div id="s-active">${sections.active}</div>
|
|
591
|
+
</section>
|
|
592
|
+
|
|
593
|
+
<section data-section="providers" class="section">
|
|
594
|
+
<h2 class="section-title">Providers</h2>
|
|
595
|
+
<div id="s-providers">${sections.providers}</div>
|
|
596
|
+
</section>
|
|
597
|
+
|
|
598
|
+
<section data-section="active" class="section">
|
|
599
|
+
<h2 class="section-title">Active Requests</h2>
|
|
600
|
+
<div id="s-active2">${sections.active}</div>
|
|
601
|
+
</section>
|
|
602
|
+
|
|
603
|
+
<section data-section="recent" class="section">
|
|
604
|
+
<h2 class="section-title">Recent Requests</h2>
|
|
605
|
+
<div id="s-recent">${sections.recent}</div>
|
|
606
|
+
</section>
|
|
607
|
+
|
|
608
|
+
<section data-section="fallbacks" class="section">
|
|
609
|
+
<h2 class="section-title">Fallbacks & Sessions</h2>
|
|
610
|
+
<div class="two-col">
|
|
611
|
+
<div id="s-fallbacks">${sections.fallbacks}</div>
|
|
612
|
+
<div id="s-sessions">${sections.sessions}</div>
|
|
613
|
+
</div>
|
|
614
|
+
</section>
|
|
615
|
+
|
|
616
|
+
<section data-section="sessions" class="section">
|
|
617
|
+
<h2 class="section-title">Provider Sessions</h2>
|
|
618
|
+
<div id="s-sessions2">${sections.sessions}</div>
|
|
619
|
+
</section>
|
|
620
|
+
|
|
621
|
+
<section data-section="logs" class="section">
|
|
622
|
+
<h2 class="section-title">Live Logs</h2>
|
|
623
|
+
${renderLogsSection()}
|
|
624
|
+
</section>
|
|
625
|
+
|
|
626
|
+
<section data-section="timeouts" class="section">
|
|
627
|
+
<h2 class="section-title">Timeout Configuration</h2>
|
|
628
|
+
<div id="s-timeouts">${sections.timeouts}</div>
|
|
629
|
+
</section>
|
|
630
|
+
|
|
631
|
+
<section data-section="models" class="section">
|
|
632
|
+
<h2 class="section-title">Models</h2>
|
|
633
|
+
<div id="s-models">${sections.models}</div>
|
|
634
|
+
</section>
|
|
635
|
+
|
|
636
|
+
<p class="footer">openclaw-cli-bridge-elvatis v${version}</p>
|
|
637
|
+
</main>
|
|
638
|
+
|
|
639
|
+
<script>
|
|
640
|
+
// ── Section switching ──
|
|
641
|
+
function showSection(id) {
|
|
642
|
+
document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
|
|
643
|
+
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
|
644
|
+
const sec = document.querySelector('[data-section="' + id + '"]');
|
|
645
|
+
const nav = document.querySelector('[data-nav="' + id + '"]');
|
|
646
|
+
if (sec) sec.classList.add('active');
|
|
647
|
+
if (nav) nav.classList.add('active');
|
|
648
|
+
location.hash = id;
|
|
649
|
+
// Close mobile sidebar
|
|
650
|
+
document.querySelector('.sidebar').classList.remove('open');
|
|
651
|
+
}
|
|
480
652
|
|
|
481
|
-
|
|
653
|
+
// Init from hash
|
|
654
|
+
(function() {
|
|
655
|
+
var hash = location.hash.slice(1) || 'overview';
|
|
656
|
+
showSection(hash);
|
|
657
|
+
})();
|
|
658
|
+
|
|
659
|
+
// ── AJAX polling (replaces meta-refresh) ──
|
|
660
|
+
setInterval(function() {
|
|
661
|
+
fetch('/api/dashboard-data').then(function(r) { return r.json(); }).then(function(data) {
|
|
662
|
+
var map = {
|
|
663
|
+
's-metrics': 'metrics', 's-active': 'active', 's-active2': 'active',
|
|
664
|
+
's-providers': 'providers', 's-recent': 'recent',
|
|
665
|
+
's-fallbacks': 'fallbacks', 's-sessions': 'sessions', 's-sessions2': 'sessions',
|
|
666
|
+
's-timeouts': 'timeouts', 's-models': 'models'
|
|
667
|
+
};
|
|
668
|
+
for (var elId in map) {
|
|
669
|
+
var el = document.getElementById(elId);
|
|
670
|
+
if (el && data[map[elId]]) el.innerHTML = data[map[elId]];
|
|
671
|
+
}
|
|
672
|
+
}).catch(function() { /* silent fail on poll */ });
|
|
673
|
+
}, 10000);
|
|
674
|
+
|
|
675
|
+
// ── Live log viewer ──
|
|
676
|
+
var logOutput = document.getElementById('log-output');
|
|
677
|
+
var logStatus = document.getElementById('log-status');
|
|
678
|
+
var logPaused = false;
|
|
679
|
+
var logSource = null;
|
|
680
|
+
var logLineCount = 0;
|
|
681
|
+
var MAX_LOG_LINES = 500;
|
|
682
|
+
var autoScroll = true;
|
|
683
|
+
|
|
684
|
+
function colorLogLine(line) {
|
|
685
|
+
if (line.indexOf('[FAIL]') !== -1 || line.indexOf('[ERROR]') !== -1) return '<span class="log-fail">' + line + '</span>';
|
|
686
|
+
if (line.indexOf('[OK]') !== -1) return '<span class="log-ok">' + line + '</span>';
|
|
687
|
+
if (line.indexOf('[FALLBACK]') !== -1 || line.indexOf('[WARN]') !== -1) return '<span class="log-warn">' + line + '</span>';
|
|
688
|
+
if (line.indexOf('[KILL]') !== -1) return '<span class="log-kill">' + line + '</span>';
|
|
689
|
+
if (line.indexOf('[TOOL-ROUTE]') !== -1 || line.indexOf('[TASK-ROUTE]') !== -1) return '<span class="log-route">' + line + '</span>';
|
|
690
|
+
if (line.indexOf('[TIMEOUT]') !== -1 || line.indexOf('[CLAUDE]') !== -1) return '<span class="log-dim">' + line + '</span>';
|
|
691
|
+
return line;
|
|
692
|
+
}
|
|
482
693
|
|
|
483
|
-
|
|
694
|
+
function appendLog(text) {
|
|
695
|
+
if (!logOutput) return;
|
|
696
|
+
var lines = text.split('\\n').filter(function(l) { return l.trim(); });
|
|
697
|
+
lines.forEach(function(line) {
|
|
698
|
+
logOutput.innerHTML += colorLogLine(line.replace(/</g, '<').replace(/>/g, '>')) + '\\n';
|
|
699
|
+
logLineCount++;
|
|
700
|
+
});
|
|
701
|
+
// Trim old lines
|
|
702
|
+
while (logLineCount > MAX_LOG_LINES) {
|
|
703
|
+
var idx = logOutput.innerHTML.indexOf('\\n');
|
|
704
|
+
if (idx === -1) break;
|
|
705
|
+
logOutput.innerHTML = logOutput.innerHTML.slice(idx + 1);
|
|
706
|
+
logLineCount--;
|
|
707
|
+
}
|
|
708
|
+
if (autoScroll) logOutput.scrollTop = logOutput.scrollHeight;
|
|
709
|
+
}
|
|
484
710
|
|
|
485
|
-
|
|
711
|
+
function connectLog() {
|
|
712
|
+
if (logSource) logSource.close();
|
|
713
|
+
logSource = new EventSource('/api/logs/stream');
|
|
714
|
+
logSource.onopen = function() {
|
|
715
|
+
if (logStatus) { logStatus.textContent = 'connected'; logStatus.className = 'badge badge-ok'; }
|
|
716
|
+
};
|
|
717
|
+
logSource.onmessage = function(e) { appendLog(e.data); };
|
|
718
|
+
logSource.onerror = function() {
|
|
719
|
+
if (logStatus) { logStatus.textContent = 'disconnected'; logStatus.className = 'badge badge-error'; }
|
|
720
|
+
// Reconnect after 3s
|
|
721
|
+
setTimeout(function() { if (!logPaused) connectLog(); }, 3000);
|
|
722
|
+
};
|
|
723
|
+
}
|
|
486
724
|
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
725
|
+
function toggleLogPause() {
|
|
726
|
+
logPaused = !logPaused;
|
|
727
|
+
var btn = document.getElementById('log-pause-btn');
|
|
728
|
+
if (logPaused) {
|
|
729
|
+
if (logSource) logSource.close();
|
|
730
|
+
if (btn) btn.textContent = 'Resume';
|
|
731
|
+
if (logStatus) { logStatus.textContent = 'paused'; logStatus.className = 'badge badge-warn'; }
|
|
732
|
+
} else {
|
|
733
|
+
connectLog();
|
|
734
|
+
if (btn) btn.textContent = 'Pause';
|
|
735
|
+
}
|
|
736
|
+
}
|
|
491
737
|
|
|
492
|
-
|
|
738
|
+
function clearLogs() {
|
|
739
|
+
if (logOutput) { logOutput.innerHTML = ''; logLineCount = 0; }
|
|
740
|
+
}
|
|
493
741
|
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
</div>
|
|
501
|
-
<div class="card">
|
|
502
|
-
<div class="card-header">Web Session Models (${webModels.length})</div>
|
|
503
|
-
<ul>${modelList(webModels)}</ul>
|
|
504
|
-
</div>
|
|
505
|
-
<div class="card">
|
|
506
|
-
<div class="card-header">Local Models (${localModels.length})</div>
|
|
507
|
-
<ul>${modelList(localModels)}</ul>
|
|
508
|
-
</div>
|
|
509
|
-
</div>
|
|
742
|
+
// Auto-scroll detection
|
|
743
|
+
if (logOutput) {
|
|
744
|
+
logOutput.addEventListener('scroll', function() {
|
|
745
|
+
autoScroll = (logOutput.scrollTop + logOutput.clientHeight >= logOutput.scrollHeight - 50);
|
|
746
|
+
});
|
|
747
|
+
}
|
|
510
748
|
|
|
511
|
-
|
|
749
|
+
// Start log connection
|
|
750
|
+
connectLog();
|
|
751
|
+
</script>
|
|
512
752
|
</body>
|
|
513
753
|
</html>`;
|
|
514
754
|
}
|