agentgui 1.0.934 → 1.0.935
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/lib/codec.js +13 -4
- package/lib/ws-handlers-util.js +103 -1
- package/package.json +1 -2
- package/scripts/smoke-ws-chat.mjs +62 -0
- package/server.js +3 -0
- package/site/app/js/backend.js +187 -45
- package/site/app/js/codec.js +8 -0
package/lib/codec.js
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
// agentgui WS wire codec — plain JSON (UTF-8 text frames).
|
|
2
|
+
// Browser-compatible (no msgpackr). encode() returns a string the ws library
|
|
3
|
+
// sends as a text frame; decode() handles both Buffer/Uint8Array (Node) and
|
|
4
|
+
// string (browser) inputs.
|
|
5
|
+
|
|
6
|
+
export function encode(obj) { return JSON.stringify(obj); }
|
|
7
|
+
|
|
8
|
+
export function decode(buf) {
|
|
9
|
+
if (typeof buf === 'string') return JSON.parse(buf);
|
|
10
|
+
if (buf instanceof Uint8Array) return JSON.parse(new TextDecoder().decode(buf));
|
|
11
|
+
if (buf && typeof buf.toString === 'function') return JSON.parse(buf.toString('utf8'));
|
|
12
|
+
return JSON.parse(String(buf));
|
|
13
|
+
}
|
package/lib/ws-handlers-util.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import os from 'os';
|
|
3
3
|
import path from 'path';
|
|
4
|
+
import crypto from 'crypto';
|
|
4
5
|
import { execSync, spawnSync } from 'child_process';
|
|
6
|
+
import { runClaudeWithStreaming } from './claude-runner-run.js';
|
|
7
|
+
import { registry } from './claude-runner-agents.js';
|
|
5
8
|
|
|
6
9
|
function err(code, message) { const e = new Error(message); e.code = code; throw e; }
|
|
7
10
|
|
|
@@ -13,7 +16,106 @@ const SUB_AGENT_MAP = {
|
|
|
13
16
|
};
|
|
14
17
|
|
|
15
18
|
export function register(router, deps) {
|
|
16
|
-
const { queries, wsOptimizer, broadcastSync, getProviderConfigs, saveProviderConfig, STARTUP_CWD, discoveredAgents } = deps;
|
|
19
|
+
const { queries, wsOptimizer, broadcastSync, getProviderConfigs, saveProviderConfig, STARTUP_CWD, discoveredAgents, subscriptionIndex, activeChats } = deps;
|
|
20
|
+
|
|
21
|
+
// --- agents.list: enumerate registered ACP agents + claude-code ---
|
|
22
|
+
router.handle('agents.list', () => {
|
|
23
|
+
const agents = registry.list().map(a => ({
|
|
24
|
+
id: a.id,
|
|
25
|
+
name: a.name,
|
|
26
|
+
protocol: a.protocol,
|
|
27
|
+
supportsStdin: !!a.supportsStdin,
|
|
28
|
+
features: a.supportedFeatures || [],
|
|
29
|
+
}));
|
|
30
|
+
return { agents };
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// --- conversation.subscribe: register this ws for sessionId broadcasts ---
|
|
34
|
+
router.handle('conversation.subscribe', (p, ws) => {
|
|
35
|
+
const sid = p?.sessionId;
|
|
36
|
+
if (!sid || typeof sid !== 'string') err(400, 'sessionId required');
|
|
37
|
+
if (!subscriptionIndex.has(sid)) subscriptionIndex.set(sid, new Set());
|
|
38
|
+
subscriptionIndex.get(sid).add(ws);
|
|
39
|
+
ws.subscriptions = ws.subscriptions || new Set();
|
|
40
|
+
ws.subscriptions.add(sid);
|
|
41
|
+
return { subscribed: true, sessionId: sid };
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// --- chat.sendMessage: start a one-shot streaming chat with an agent.
|
|
45
|
+
// Bypasses the gutted db-queries layer entirely; calls runClaudeWithStreaming
|
|
46
|
+
// directly and broadcasts streaming_* events scoped to an ephemeral sessionId.
|
|
47
|
+
router.handle('chat.sendMessage', async (p, ws) => {
|
|
48
|
+
const content = (p?.content || '').toString();
|
|
49
|
+
if (!content) err(400, 'content required');
|
|
50
|
+
const agentId = p?.agentId || 'claude-code';
|
|
51
|
+
const model = p?.model || undefined;
|
|
52
|
+
const subAgent = p?.subAgent || undefined;
|
|
53
|
+
const cwd = p?.cwd || STARTUP_CWD;
|
|
54
|
+
if (!registry.has(agentId)) err(404, `Unknown agentId: ${agentId}`);
|
|
55
|
+
|
|
56
|
+
const sessionId = 'chat-' + crypto.randomBytes(8).toString('hex');
|
|
57
|
+
// Auto-subscribe the originating ws so it receives its own broadcasts.
|
|
58
|
+
if (!subscriptionIndex.has(sessionId)) subscriptionIndex.set(sessionId, new Set());
|
|
59
|
+
subscriptionIndex.get(sessionId).add(ws);
|
|
60
|
+
ws.subscriptions = ws.subscriptions || new Set();
|
|
61
|
+
ws.subscriptions.add(sessionId);
|
|
62
|
+
|
|
63
|
+
const ctrl = { aborted: false, proc: null };
|
|
64
|
+
activeChats.set(sessionId, ctrl);
|
|
65
|
+
|
|
66
|
+
// Fire-and-forget. Errors broadcast as streaming_error.
|
|
67
|
+
(async () => {
|
|
68
|
+
let eventCount = 0;
|
|
69
|
+
broadcastSync({ type: 'streaming_start', sessionId, agentId, timestamp: Date.now() });
|
|
70
|
+
const onEvent = (parsed) => {
|
|
71
|
+
eventCount++;
|
|
72
|
+
if (parsed?.type === 'assistant' && parsed.message?.content) {
|
|
73
|
+
for (const block of parsed.message.content) {
|
|
74
|
+
broadcastSync({ type: 'streaming_progress', sessionId, block, blockRole: 'assistant', seq: eventCount, timestamp: Date.now() });
|
|
75
|
+
}
|
|
76
|
+
} else if (parsed?.type === 'user' && parsed.message?.content) {
|
|
77
|
+
const blocks = Array.isArray(parsed.message.content) ? parsed.message.content : [];
|
|
78
|
+
for (const block of blocks) {
|
|
79
|
+
if (block?.type === 'tool_result') {
|
|
80
|
+
broadcastSync({ type: 'streaming_progress', sessionId, block, blockRole: 'tool_result', seq: eventCount, timestamp: Date.now() });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} else if (parsed?.type === 'result') {
|
|
84
|
+
const block = { type: 'result', result: parsed.result, subtype: parsed.subtype, duration_ms: parsed.duration_ms, total_cost_usd: parsed.total_cost_usd, is_error: !!parsed.is_error };
|
|
85
|
+
broadcastSync({ type: 'streaming_progress', sessionId, block, blockRole: 'result', seq: eventCount, isResult: true, timestamp: Date.now() });
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
try {
|
|
89
|
+
const config = {
|
|
90
|
+
verbose: true, outputFormat: 'stream-json', timeout: 1800000, print: true,
|
|
91
|
+
model, subAgent, onEvent,
|
|
92
|
+
onPid: () => {}, onProcess: (proc) => { ctrl.proc = proc; },
|
|
93
|
+
};
|
|
94
|
+
await runClaudeWithStreaming(content, cwd, agentId, config);
|
|
95
|
+
broadcastSync({ type: 'streaming_complete', sessionId, agentId, eventCount, timestamp: Date.now() });
|
|
96
|
+
} catch (e) {
|
|
97
|
+
broadcastSync({ type: 'streaming_error', sessionId, agentId, error: e.message || String(e), recoverable: false, timestamp: Date.now() });
|
|
98
|
+
} finally {
|
|
99
|
+
activeChats.delete(sessionId);
|
|
100
|
+
}
|
|
101
|
+
})();
|
|
102
|
+
|
|
103
|
+
return { sessionId, started: true };
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// --- chat.cancel: abort an in-flight chat ---
|
|
107
|
+
router.handle('chat.cancel', (p) => {
|
|
108
|
+
const sid = p?.sessionId;
|
|
109
|
+
if (!sid) err(400, 'sessionId required');
|
|
110
|
+
const ctrl = activeChats.get(sid);
|
|
111
|
+
if (!ctrl) return { cancelled: false, reason: 'not-found' };
|
|
112
|
+
ctrl.aborted = true;
|
|
113
|
+
try { ctrl.proc?.kill?.(); } catch {}
|
|
114
|
+
activeChats.delete(sid);
|
|
115
|
+
return { cancelled: true };
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
|
|
17
119
|
|
|
18
120
|
router.handle('home', () => ({ home: os.homedir(), cwd: STARTUP_CWD }));
|
|
19
121
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentgui",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.935",
|
|
4
4
|
"description": "Multi-agent ACP client with real-time communication",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "electron/main.js",
|
|
@@ -37,7 +37,6 @@
|
|
|
37
37
|
"form-data": "^4.0.5",
|
|
38
38
|
"fsbrowse": "latest",
|
|
39
39
|
"lru-cache": "^11.2.7",
|
|
40
|
-
"msgpackr": "^1.11.8",
|
|
41
40
|
"opencode-ai": "^1.2.15",
|
|
42
41
|
"p-retry": "^7.1.1",
|
|
43
42
|
"puppeteer-core": "^24.37.5",
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Smoke-test the WS chat protocol added in this session.
|
|
2
|
+
// Boots no server — assumes one is already running at ws://localhost:$PORT/sync.
|
|
3
|
+
//
|
|
4
|
+
// Usage: PORT=3990 node scripts/smoke-ws-chat.mjs
|
|
5
|
+
//
|
|
6
|
+
// Checks:
|
|
7
|
+
// 1. WS connects, receives sync_connected.
|
|
8
|
+
// 2. agents.list returns a non-empty array.
|
|
9
|
+
// 3. (optional) conversation.subscribe returns subscribed:true.
|
|
10
|
+
|
|
11
|
+
import WebSocket from 'ws';
|
|
12
|
+
|
|
13
|
+
const PORT = process.env.PORT || 3000;
|
|
14
|
+
const URL = `ws://localhost:${PORT}/sync`;
|
|
15
|
+
|
|
16
|
+
const ws = new WebSocket(URL);
|
|
17
|
+
let reqId = 0;
|
|
18
|
+
const pending = new Map();
|
|
19
|
+
|
|
20
|
+
const call = (method, params) => new Promise((resolve, reject) => {
|
|
21
|
+
const r = ++reqId;
|
|
22
|
+
pending.set(r, { resolve, reject });
|
|
23
|
+
ws.send(JSON.stringify({ m: method, r, p: params || {} }));
|
|
24
|
+
setTimeout(() => { if (pending.has(r)) { pending.delete(r); reject(new Error('timeout: ' + method)); } }, 5000);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
ws.on('open', async () => {
|
|
28
|
+
console.log('WS open:', URL);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
ws.on('message', async (data) => {
|
|
32
|
+
let msg;
|
|
33
|
+
try { msg = JSON.parse(data.toString('utf8')); } catch { console.log('decode fail:', data); return; }
|
|
34
|
+
const items = Array.isArray(msg) ? msg : [msg];
|
|
35
|
+
for (const m of items) {
|
|
36
|
+
if (m && m.r !== undefined && (m.d !== undefined || m.e !== undefined)) {
|
|
37
|
+
const p = pending.get(m.r);
|
|
38
|
+
if (!p) continue;
|
|
39
|
+
pending.delete(m.r);
|
|
40
|
+
if (m.e) p.reject(new Error(m.e.m));
|
|
41
|
+
else p.resolve(m.d);
|
|
42
|
+
} else if (m?.type === 'sync_connected') {
|
|
43
|
+
console.log('sync_connected, clientId =', m.clientId);
|
|
44
|
+
try {
|
|
45
|
+
const r1 = await call('agents.list');
|
|
46
|
+
console.log('agents.list OK, count =', r1.agents.length, '— first =', r1.agents[0]?.id);
|
|
47
|
+
const r2 = await call('conversation.subscribe', { sessionId: 'smoke-test-sid' });
|
|
48
|
+
console.log('conversation.subscribe OK =', r2);
|
|
49
|
+
console.log('PASS');
|
|
50
|
+
process.exit(0);
|
|
51
|
+
} catch (e) {
|
|
52
|
+
console.error('FAIL:', e.message);
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
ws.on('error', (e) => { console.error('ws error:', e.message); process.exit(1); });
|
|
60
|
+
ws.on('close', (code, reason) => { console.log('ws close:', code, reason?.toString()); });
|
|
61
|
+
|
|
62
|
+
setTimeout(() => { console.error('overall timeout'); process.exit(1); }, 15000);
|
package/server.js
CHANGED
|
@@ -11,6 +11,7 @@ import { runClaudeWithStreaming } from './lib/claude-runner-run.js';
|
|
|
11
11
|
import { initializeDescriptors, getAgentDescriptor } from './lib/agent-descriptors.js';
|
|
12
12
|
import { discoverExternalACPServers, initializeAgentDiscovery } from './lib/agent-discovery.js';
|
|
13
13
|
import { createRegistry } from './lib/routes-registry.js';
|
|
14
|
+
import { register as registerWsHandlers } from './lib/ws-handlers-util.js';
|
|
14
15
|
import { BROADCAST_TYPES } from './lib/broadcast.js';
|
|
15
16
|
import { WSOptimizer } from './lib/ws-optimizer.js';
|
|
16
17
|
import { WsRouter } from './lib/ws-protocol.js';
|
|
@@ -123,8 +124,10 @@ const { processMessageWithStreaming } = createProcessMessage({
|
|
|
123
124
|
scheduleRetry, drainMessageQueue, createEventHandler
|
|
124
125
|
});
|
|
125
126
|
|
|
127
|
+
const activeChats = new Map();
|
|
126
128
|
const wsRouter = new WsRouter();
|
|
127
129
|
createRegistry(wsRouter, { queries, sendJSON, parseBody, broadcastSync, debugLog, PORT, BASE_URL, rootDir, STARTUP_CWD, PKG_VERSION, processMessageWithStreaming, activeExecutions, activeProcessesByRunId, activeScripts, messageQueues, rateLimitState, cleanupExecution, discoveredAgents, getACPStatus, modelCache, getModelsForAgent, logError, syncClients, wsOptimizer, errLogPath, getJsonlWatcher: () => getJsonlWatcher(), routes: _routes });
|
|
130
|
+
registerWsHandlers(wsRouter, { queries, wsOptimizer, broadcastSync, getProviderConfigs, saveProviderConfig, STARTUP_CWD, discoveredAgents, subscriptionIndex, activeChats });
|
|
128
131
|
|
|
129
132
|
|
|
130
133
|
const { wss, hotReloadClients } = createWsSetup(server, {
|
package/site/app/js/backend.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
// agentgui backend client.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// agentgui backend client. Same-origin by default. Talks to:
|
|
2
|
+
// - HTTP: /health, /v1/history/* (served by ccsniff)
|
|
3
|
+
// - WS : /sync (JSON envelope: requests {m, r, p}; replies {r, d|e};
|
|
4
|
+
// broadcasts {type, sessionId, ...})
|
|
5
|
+
// No external acptoapi dependency. Chat + agent listing flow over the WS.
|
|
6
|
+
|
|
7
|
+
import { encode, decode } from './codec.js';
|
|
8
|
+
|
|
4
9
|
const KEY = 'agentgui.backend';
|
|
5
10
|
const DEFAULT_BACKEND = '';
|
|
6
11
|
|
|
@@ -11,27 +16,19 @@ export function getBackend() {
|
|
|
11
16
|
return localStorage.getItem(KEY) || DEFAULT_BACKEND;
|
|
12
17
|
}
|
|
13
18
|
|
|
14
|
-
export function setBackend(url) {
|
|
15
|
-
localStorage.setItem(KEY, url);
|
|
16
|
-
}
|
|
19
|
+
export function setBackend(url) { localStorage.setItem(KEY, url); }
|
|
17
20
|
|
|
18
21
|
export async function probeBackend(base) {
|
|
19
22
|
try {
|
|
20
23
|
const r = await fetch(base + '/health', { method: 'GET' });
|
|
21
24
|
if (!r.ok) return { ok: false, status: r.status };
|
|
22
|
-
|
|
23
|
-
return { ok: true, info: j };
|
|
25
|
+
return { ok: true, info: await r.json() };
|
|
24
26
|
} catch (e) {
|
|
25
27
|
return { ok: false, error: e.message };
|
|
26
28
|
}
|
|
27
29
|
}
|
|
28
30
|
|
|
29
|
-
|
|
30
|
-
const r = await fetch(base + '/v1/models');
|
|
31
|
-
if (!r.ok) throw new Error('models: ' + r.status);
|
|
32
|
-
const j = await r.json();
|
|
33
|
-
return j.data || [];
|
|
34
|
-
}
|
|
31
|
+
// ---------- History (HTTP, served by ccsniff) ----------
|
|
35
32
|
|
|
36
33
|
export async function listSessions(base) {
|
|
37
34
|
const r = await fetch(base + '/v1/history/sessions');
|
|
@@ -63,39 +60,184 @@ export function streamHistory(base, onEvent) {
|
|
|
63
60
|
return es;
|
|
64
61
|
}
|
|
65
62
|
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
63
|
+
// ---------- WebSocket client (/sync) ----------
|
|
64
|
+
|
|
65
|
+
const SYNC_PATH = '/sync';
|
|
66
|
+
let _ws = null;
|
|
67
|
+
let _wsReady = null; // Promise that resolves when ws is OPEN
|
|
68
|
+
let _nextReqId = 1;
|
|
69
|
+
const _pending = new Map(); // requestId → { resolve, reject }
|
|
70
|
+
const _sessionListeners = new Map(); // sessionId → Set<(event)=>void>
|
|
71
|
+
|
|
72
|
+
function wsUrl(base) {
|
|
73
|
+
if (base) {
|
|
74
|
+
// Absolute base like http://host:port → ws(s)://host:port/sync
|
|
75
|
+
try {
|
|
76
|
+
const u = new URL(base);
|
|
77
|
+
const proto = u.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
78
|
+
return proto + '//' + u.host + SYNC_PATH;
|
|
79
|
+
} catch {}
|
|
77
80
|
}
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
81
|
+
// Same-origin
|
|
82
|
+
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
83
|
+
return proto + '//' + location.host + SYNC_PATH;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function ensureWs(base) {
|
|
87
|
+
if (_ws && _ws.readyState === 1) return _wsReady;
|
|
88
|
+
if (_ws && _ws.readyState === 0) return _wsReady;
|
|
89
|
+
_ws = new WebSocket(wsUrl(base));
|
|
90
|
+
_wsReady = new Promise((resolve, reject) => {
|
|
91
|
+
_ws.addEventListener('open', () => resolve(_ws));
|
|
92
|
+
_ws.addEventListener('error', (e) => reject(e));
|
|
93
|
+
_ws.addEventListener('close', () => {
|
|
94
|
+
// Reject all pending requests on close so callers can recover.
|
|
95
|
+
for (const [, p] of _pending) p.reject(new Error('ws closed'));
|
|
96
|
+
_pending.clear();
|
|
97
|
+
_ws = null;
|
|
98
|
+
_wsReady = null;
|
|
99
|
+
});
|
|
100
|
+
_ws.addEventListener('message', (ev) => {
|
|
101
|
+
let msg;
|
|
93
102
|
try {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
103
|
+
// Server sends text frames (JSON via codec). ev.data is string.
|
|
104
|
+
msg = typeof ev.data === 'string' ? JSON.parse(ev.data) : decode(ev.data);
|
|
105
|
+
} catch { return; }
|
|
106
|
+
// Reply to a prior request?
|
|
107
|
+
if (msg && msg.r !== undefined && (msg.d !== undefined || msg.e !== undefined)) {
|
|
108
|
+
const p = _pending.get(msg.r);
|
|
109
|
+
if (!p) return;
|
|
110
|
+
_pending.delete(msg.r);
|
|
111
|
+
if (msg.e) p.reject(new Error(msg.e.m || ('ws error ' + msg.e.c)));
|
|
112
|
+
else p.resolve(msg.d);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
// Unsolicited broadcast — route by sessionId to subscribers.
|
|
116
|
+
// Server may send a single event or a batch (array) per ws-optimizer.
|
|
117
|
+
const items = Array.isArray(msg) ? msg : [msg];
|
|
118
|
+
for (const item of items) {
|
|
119
|
+
const sid = item?.sessionId;
|
|
120
|
+
if (!sid) continue;
|
|
121
|
+
const subs = _sessionListeners.get(sid);
|
|
122
|
+
if (!subs) continue;
|
|
123
|
+
for (const fn of subs) { try { fn(item); } catch {} }
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
return _wsReady;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function wsCall(base, method, params) {
|
|
131
|
+
return ensureWs(base).then(() => new Promise((resolve, reject) => {
|
|
132
|
+
const r = _nextReqId++;
|
|
133
|
+
_pending.set(r, { resolve, reject });
|
|
134
|
+
_ws.send(encode({ m: method, r, p: params || {} }));
|
|
135
|
+
}));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function addSessionListener(sessionId, fn) {
|
|
139
|
+
if (!_sessionListeners.has(sessionId)) _sessionListeners.set(sessionId, new Set());
|
|
140
|
+
_sessionListeners.get(sessionId).add(fn);
|
|
141
|
+
return () => {
|
|
142
|
+
const s = _sessionListeners.get(sessionId);
|
|
143
|
+
if (s) { s.delete(fn); if (s.size === 0) _sessionListeners.delete(sessionId); }
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---------- Agents / models (WS) ----------
|
|
148
|
+
|
|
149
|
+
export async function listModels(base) {
|
|
150
|
+
const { agents } = await wsCall(base, 'agents.list', {});
|
|
151
|
+
// Compatibility shape: app.js expects an array of {id, name?, ...}
|
|
152
|
+
return agents || [];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ---------- Streaming chat (WS) ----------
|
|
156
|
+
//
|
|
157
|
+
// Yields events of shape:
|
|
158
|
+
// { type: 'text', text: '...' } — assistant text deltas
|
|
159
|
+
// { type: 'tool', block: {...} } — tool_use blocks
|
|
160
|
+
// { type: 'result', block: {...} } — terminal result block
|
|
161
|
+
// { type: 'error', error: '...' }
|
|
162
|
+
//
|
|
163
|
+
// Caller signature kept compatible with the previous HTTP/SSE impl.
|
|
164
|
+
export async function* streamChat(base, { model, messages, signal, agentId }) {
|
|
165
|
+
// The last user message is the prompt; agentgui's claude-runner doesn't
|
|
166
|
+
// accept a full message list — it spawns the agent for a single prompt.
|
|
167
|
+
// For multi-turn, the agent's own session/resume handles continuity.
|
|
168
|
+
const last = messages[messages.length - 1];
|
|
169
|
+
const content = last?.content || '';
|
|
170
|
+
if (!content) return;
|
|
171
|
+
|
|
172
|
+
// app.js treats the "model" picker as the agent picker (selects from
|
|
173
|
+
// agents.list ids). If no explicit agentId is given, model IS the agent.
|
|
174
|
+
// If `model` looks like a real model id (has a slash), keep it as model
|
|
175
|
+
// and default agent to claude-code.
|
|
176
|
+
let resolvedAgentId = agentId;
|
|
177
|
+
let resolvedModel = model;
|
|
178
|
+
if (!resolvedAgentId) {
|
|
179
|
+
if (!model || /^[a-z][a-z0-9-]*$/.test(model)) {
|
|
180
|
+
// Bare slug — treat as agentId.
|
|
181
|
+
resolvedAgentId = model || 'claude-code';
|
|
182
|
+
resolvedModel = undefined;
|
|
183
|
+
} else {
|
|
184
|
+
resolvedAgentId = 'claude-code';
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Queue events here; the async iterator pulls from it.
|
|
189
|
+
const queue = [];
|
|
190
|
+
let resolveWait = null;
|
|
191
|
+
let done = false;
|
|
192
|
+
let errored = null;
|
|
193
|
+
const push = (ev) => { queue.push(ev); if (resolveWait) { resolveWait(); resolveWait = null; } };
|
|
194
|
+
|
|
195
|
+
// Kick off the chat on the server.
|
|
196
|
+
let started;
|
|
197
|
+
try {
|
|
198
|
+
started = await wsCall(base, 'chat.sendMessage', { content, agentId: resolvedAgentId, model: resolvedModel });
|
|
199
|
+
} catch (e) {
|
|
200
|
+
yield { type: 'error', error: e.message };
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const sessionId = started?.sessionId;
|
|
204
|
+
if (!sessionId) { yield { type: 'error', error: 'no sessionId from server' }; return; }
|
|
205
|
+
|
|
206
|
+
const unsub = addSessionListener(sessionId, (ev) => {
|
|
207
|
+
if (ev.type === 'streaming_progress') {
|
|
208
|
+
const block = ev.block;
|
|
209
|
+
if (block?.type === 'text' && block.text) push({ type: 'text', text: block.text });
|
|
210
|
+
else if (block?.type === 'tool_use') push({ type: 'tool', block });
|
|
211
|
+
else if (block?.type === 'tool_result') push({ type: 'tool', block });
|
|
212
|
+
else if (block?.type === 'result') push({ type: 'result', block });
|
|
213
|
+
} else if (ev.type === 'streaming_complete') {
|
|
214
|
+
done = true;
|
|
215
|
+
if (resolveWait) { resolveWait(); resolveWait = null; }
|
|
216
|
+
} else if (ev.type === 'streaming_error') {
|
|
217
|
+
errored = ev.error || 'streaming error';
|
|
218
|
+
done = true;
|
|
219
|
+
if (resolveWait) { resolveWait(); resolveWait = null; }
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// Wire AbortSignal to chat.cancel.
|
|
224
|
+
const onAbort = () => { wsCall(base, 'chat.cancel', { sessionId }).catch(() => {}); };
|
|
225
|
+
if (signal) {
|
|
226
|
+
if (signal.aborted) onAbort();
|
|
227
|
+
else signal.addEventListener('abort', onAbort, { once: true });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
while (!done || queue.length > 0) {
|
|
232
|
+
if (queue.length === 0) {
|
|
233
|
+
await new Promise(r => { resolveWait = r; });
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
yield queue.shift();
|
|
99
237
|
}
|
|
238
|
+
if (errored) yield { type: 'error', error: errored };
|
|
239
|
+
} finally {
|
|
240
|
+
unsub();
|
|
241
|
+
if (signal) signal.removeEventListener?.('abort', onAbort);
|
|
100
242
|
}
|
|
101
243
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Browser mirror of lib/codec.js. JSON over WS text frames.
|
|
2
|
+
export function encode(obj) { return JSON.stringify(obj); }
|
|
3
|
+
export function decode(buf) {
|
|
4
|
+
if (typeof buf === 'string') return JSON.parse(buf);
|
|
5
|
+
if (buf instanceof ArrayBuffer) return JSON.parse(new TextDecoder().decode(new Uint8Array(buf)));
|
|
6
|
+
if (buf instanceof Uint8Array) return JSON.parse(new TextDecoder().decode(buf));
|
|
7
|
+
return JSON.parse(String(buf));
|
|
8
|
+
}
|