/ proof-of-concept / cymsimweb
Sign in
proof-of-concept / cymsimweb
cymsimweb / src / chat / client.ts
101 lines · 4.8 KBBlameHistoryRaw
1// WebSocket client of the local Claude Code bridge (bridge/server.mjs): sends the user's
2// messages, receives streamed replies, and executes the tools Claude calls against the app.
4export interface AppApi {
5 getState(): Promise<unknown>;
6 setYaml(yaml: string): { issues: { path: string; message: string }[] };
7 selectPreset(id: string): string;
8 start(): Promise<string>;
9 pause(): string;
10 stop(): string;
11 play(fromSeconds: number): string;
12 setSignal(signal: string): string;
13 playMovie(factor: number, fromSeconds: number): string;
14 setSpectrogramAxis(scale: string, maxHz?: number): string;
15 setMovieFps(fps: number): string;
18export type ChatEvent =
19 | { type: "connection"; state: "connecting" | "open" | "closed" }
20 | { type: "hello"; model: string; cwd: string }
21 | { type: "init"; session_id: string; model: string }
22 | { type: "status"; state: "working" | "idle" }
23 | { type: "block_start" }
24 | { type: "delta"; text: string }
25 | { type: "tool_use"; name: string; input: string }
26 | { type: "tool_call"; name: string }
27 | { type: "result"; ok: boolean; error?: string; cost: number; turns: number }
28 | { type: "reset" }
29 | { type: "error"; message: string };
31export class ChatClient {
32 private ws: WebSocket | null = null;
33 private wanted = false;
34 private retry = 1000;
35 readonly url: string; private readonly api: AppApi; private readonly onEvent: (e: ChatEvent) => void;
37 constructor(url: string, api: AppApi, onEvent: (e: ChatEvent) => void) { this.url = url; this.api = api; this.onEvent = onEvent; }
39 get connected() { return this.ws?.readyState === WebSocket.OPEN; }
41 connect() {
42 this.wanted = true;
43 if (this.ws && this.ws.readyState <= WebSocket.OPEN) return;
44 this.onEvent({ type: "connection", state: "connecting" });
45 const ws = new WebSocket(this.url);
46 this.ws = ws;
47 ws.onopen = () => { this.retry = 1000; this.onEvent({ type: "connection", state: "open" }); };
48 ws.onmessage = (ev) => void this.handle(String(ev.data));
49 ws.onclose = () => {
50 this.onEvent({ type: "connection", state: "closed" });
51 if (this.wanted) { setTimeout(() => this.connect(), this.retry); this.retry = Math.min(this.retry * 2, 15000); }
52 };
53 ws.onerror = () => { /* onclose follows */ };
54 }
56 disconnect() { this.wanted = false; this.ws?.close(); this.ws = null; }
58 private send(obj: unknown) { if (this.connected) this.ws!.send(JSON.stringify(obj)); }
59 sendUser(text: string) { this.send({ type: "user", text }); }
60 interrupt() { this.send({ type: "interrupt" }); }
61 reset() { this.send({ type: "reset" }); }
63 private async handle(raw: string) {
64 let msg: any;
65 try { msg = JSON.parse(raw); } catch { return; }
66 if (msg.type === "tool_call") {
67 this.onEvent({ type: "tool_call", name: msg.name });
68 const { text, isError } = await this.execute(msg.name, msg.args ?? {});
69 this.send({ type: "tool_result", id: msg.id, text, isError });
70 return;
71 }
72 this.onEvent(msg as ChatEvent);
73 }
75 /** Run a tool against the app; every result is a string for Claude. */
76 async execute(name: string, args: Record<string, unknown>): Promise<{ text: string; isError: boolean }> {
77 try {
78 switch (name) {
79 case "get_app_state": return { text: JSON.stringify(await this.api.getState()), isError: false };
80 case "set_yaml": {
81 const r = this.api.setYaml(String(args.yaml ?? ""));
82 return r.issues.length
83 ? { text: `configuration set, but it has issues:\n${r.issues.map((i) => `- ${i.path || "document"}: ${i.message}`).join("\n")}`, isError: true }
84 : { text: "configuration set; it is valid", isError: false };
85 }
86 case "select_preset": return { text: this.api.selectPreset(String(args.id)), isError: false };
87 case "start_run": return { text: await this.api.start(), isError: false };
88 case "pause_run": return { text: this.api.pause(), isError: false };
89 case "stop_run": return { text: this.api.stop(), isError: false };
90 case "play_audio": return { text: this.api.play(Number(args.from_seconds ?? 0)), isError: false };
91 case "set_signal": return { text: this.api.setSignal(String(args.signal)), isError: false };
92 case "play_movie": return { text: this.api.playMovie(Number(args.slowdown ?? 0), Number(args.from_seconds ?? 0)), isError: false };
93 case "set_spectrogram_axis": return { text: this.api.setSpectrogramAxis(String(args.scale ?? "linear"), args.max_frequency_hz === undefined ? undefined : Number(args.max_frequency_hz)), isError: false };
94 case "set_movie_fps": return { text: this.api.setMovieFps(Number(args.fps ?? 0)), isError: false };
95 default: return { text: `unknown tool ${name}`, isError: true };
96 }
97 } catch (err) {
98 return { text: `tool ${name} failed: ${(err as Error).message}`, isError: true };
99 }
100 }
↑↓ move↵openescclose