Skip to content

Commit ac645f1

Browse files
snomiaoclaude
andcommitted
feat(agent): local bridge composable + pairing UI in settings
- useLocalBridge: WS singleton connects to ws://127.0.0.1:7437/spa on AgentRoot mount, relays send/eval/abort/ping from CLI to SPA runtime - useBridgeStatus: read-only bridge state (connected, activePairCode) readable from any component without lifecycle side-effects - AgentSettings: 'Local agent bridge' section shows connection dot + status text; 'Generate pair code' button sends pair-request to daemon and shows the comfy-ai pair ... command to run in terminal; clears automatically when CLI claims the code Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 39c98c2 commit ac645f1

4 files changed

Lines changed: 226 additions & 1 deletion

File tree

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import { onMounted, onUnmounted, ref } from 'vue'
2+
3+
import { useAgentSession } from './useAgentSession'
4+
5+
const DAEMON_WS = 'ws://127.0.0.1:7437/spa'
6+
const PROTOCOL_VERSION = 1
7+
const SESSION_ID = crypto.randomUUID()
8+
9+
type SpaToDaemon =
10+
| { v: number; type: 'hello'; sessionId: string; title?: string }
11+
| {
12+
v: number
13+
type: 'evalResult'
14+
sessionId: string
15+
opId: string
16+
stdout: string
17+
stderr?: string
18+
exitCode: number
19+
}
20+
| { v: number; type: 'pair-request'; sessionId: string; code: string }
21+
| { v: number; type: 'pong'; sessionId: string }
22+
23+
type DaemonToSpa =
24+
| { v: number; type: 'send'; text: string }
25+
| { v: number; type: 'eval'; opId: string; script: string }
26+
| { v: number; type: 'abort' }
27+
| { v: number; type: 'paired'; code: string }
28+
| { v: number; type: 'ping' }
29+
30+
// Singleton state — shared across all callers of useLocalBridge()
31+
const connected = ref(false)
32+
const activePairCode = ref<string | null>(null)
33+
34+
let ws: WebSocket | null = null
35+
let refCount = 0
36+
let sendFn: ((text: string) => void) | null = null
37+
let evalFn:
38+
| ((
39+
opId: string,
40+
script: string
41+
) => Promise<{ stdout: string; stderr?: string; exitCode: number }>)
42+
| null = null
43+
let stopFn: (() => void) | null = null
44+
45+
function sendMsg(msg: SpaToDaemon) {
46+
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg))
47+
}
48+
49+
function connect(
50+
onSend: typeof sendFn,
51+
onEval: typeof evalFn,
52+
onStop: typeof stopFn
53+
) {
54+
sendFn = onSend
55+
evalFn = onEval
56+
stopFn = onStop
57+
if (ws && ws.readyState !== WebSocket.CLOSED) return
58+
59+
ws = new WebSocket(DAEMON_WS)
60+
61+
ws.addEventListener('open', () => {
62+
connected.value = true
63+
sendMsg({
64+
v: PROTOCOL_VERSION,
65+
type: 'hello',
66+
sessionId: SESSION_ID,
67+
title: 'ComfyUI'
68+
})
69+
})
70+
71+
ws.addEventListener('message', async (ev) => {
72+
let msg: DaemonToSpa
73+
try {
74+
msg = JSON.parse(ev.data as string) as DaemonToSpa
75+
} catch {
76+
return
77+
}
78+
if (msg.v !== PROTOCOL_VERSION) return
79+
80+
switch (msg.type) {
81+
case 'ping':
82+
sendMsg({ v: PROTOCOL_VERSION, type: 'pong', sessionId: SESSION_ID })
83+
break
84+
case 'send':
85+
sendFn?.(msg.text)
86+
break
87+
case 'eval': {
88+
const result = (await evalFn?.(msg.opId, msg.script)) ?? {
89+
stdout: '',
90+
exitCode: 0
91+
}
92+
sendMsg({
93+
v: PROTOCOL_VERSION,
94+
type: 'evalResult',
95+
sessionId: SESSION_ID,
96+
opId: msg.opId,
97+
stdout: result.stdout,
98+
stderr: result.stderr,
99+
exitCode: result.exitCode
100+
})
101+
break
102+
}
103+
case 'abort':
104+
stopFn?.()
105+
break
106+
case 'paired':
107+
if (activePairCode.value === msg.code) activePairCode.value = null
108+
break
109+
}
110+
})
111+
112+
ws.addEventListener('close', () => {
113+
connected.value = false
114+
ws = null
115+
// Reconnect after 3s if still mounted
116+
if (refCount > 0)
117+
setTimeout(() => {
118+
if (refCount > 0) connect(sendFn, evalFn, stopFn)
119+
}, 3000)
120+
})
121+
122+
ws.addEventListener('error', () => {
123+
connected.value = false
124+
})
125+
}
126+
127+
function disconnect() {
128+
refCount--
129+
if (refCount <= 0) {
130+
ws?.close()
131+
ws = null
132+
refCount = 0
133+
connected.value = false
134+
}
135+
}
136+
137+
/** Mount in the root component (AgentRoot) to manage the WS lifecycle. */
138+
export function useLocalBridge() {
139+
const { send, stop, execShell } = useAgentSession()
140+
141+
onMounted(() => {
142+
refCount++
143+
connect(
144+
(text) => void send(text, []),
145+
(_opId, script) => execShell(script),
146+
() => stop()
147+
)
148+
})
149+
150+
onUnmounted(disconnect)
151+
}
152+
153+
function requestPair(): void {
154+
const code = Math.random().toString(36).slice(2, 8).toUpperCase()
155+
activePairCode.value = code
156+
sendMsg({
157+
v: PROTOCOL_VERSION,
158+
type: 'pair-request',
159+
sessionId: SESSION_ID,
160+
code
161+
})
162+
}
163+
164+
/** Read bridge state from any component — no lifecycle side-effects. */
165+
export function useBridgeStatus() {
166+
return { connected, activePairCode, requestPair }
167+
}

src/agent/ui/AgentRoot.vue

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,13 @@ import { KeybindingImpl } from '@/platform/keybindings/keybinding'
1212
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
1313
import { useCommandStore } from '@/stores/commandStore'
1414
15+
import { useLocalBridge } from '../composables/useLocalBridge'
1516
import { useAgentStore } from '../stores/agentStore'
1617
import AgentFab from './AgentFab.vue'
1718
import FoldablePanel from './FoldablePanel.vue'
1819
20+
useLocalBridge()
21+
1922
onMounted(() => {
2023
const commandStore = useCommandStore()
2124
const keybindingStore = useKeybindingStore()

src/agent/ui/AgentSettings.vue

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,51 @@
9191
</p>
9292
</section>
9393

94+
<!-- Local agent bridge -->
95+
<section class="border-default border-t pt-3">
96+
<p class="mb-1.5 text-xs font-medium text-muted-foreground">
97+
{{ t('agent.settings.localBridge') }}
98+
</p>
99+
<div class="flex items-center gap-2">
100+
<span
101+
:class="
102+
cn(
103+
'inline-flex size-2 shrink-0 rounded-full',
104+
connected ? 'bg-emerald-400' : 'bg-muted-foreground/40'
105+
)
106+
"
107+
/>
108+
<span class="text-xs text-muted-foreground">
109+
{{
110+
connected
111+
? t('agent.settings.bridgeConnected')
112+
: t('agent.settings.bridgeDisconnected')
113+
}}
114+
</span>
115+
<button
116+
v-if="connected && !activePairCode"
117+
class="ml-auto rounded-sm border border-azure-600/40 bg-azure-600/10 px-2 py-0.5 text-xs text-azure-400 hover:bg-azure-600/20"
118+
@click="requestPair()"
119+
>
120+
{{ t('agent.settings.bridgePair') }}
121+
</button>
122+
</div>
123+
<div
124+
v-if="activePairCode"
125+
class="border-default mt-2 rounded-sm border bg-secondary-background/60 p-2 text-xs"
126+
>
127+
<p class="mb-1 text-muted-foreground">
128+
{{ t('agent.settings.bridgePairHint') }}
129+
</p>
130+
<code class="block font-mono break-all text-azure-300 select-all"
131+
>comfy-ai pair http://127.0.0.1:7437/pair/{{ activePairCode }}</code
132+
>
133+
<p class="mt-1.5 text-muted-foreground/70">
134+
{{ t('agent.settings.bridgePairWaiting') }}
135+
</p>
136+
</div>
137+
</section>
138+
94139
<details class="border-default border-t pt-3 text-sm">
95140
<summary
96141
class="cursor-pointer text-xs font-medium text-muted-foreground select-none"
@@ -152,11 +197,15 @@
152197
import { computed } from 'vue'
153198
import { useI18n } from 'vue-i18n'
154199
200+
import { cn } from '@comfyorg/tailwind-utils'
201+
202+
import { useBridgeStatus } from '../composables/useLocalBridge'
155203
import { useAgentSession } from '../composables/useAgentSession'
156204
157205
const { t } = useI18n()
158206
const { apiKey, baseURL, model, reasoningEffort, systemPromptAppend } =
159207
useAgentSession()
208+
const { connected, activePairCode, requestPair } = useBridgeStatus()
160209
161210
const apiKeyPlaceholder = computed(() =>
162211
apiKey.value ? '•••••• (stored)' : 'sk-... or sk-or-...'

src/locales/en/main.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3917,7 +3917,13 @@
39173917
"reasoningMinimal": "minimal",
39183918
"reasoningLow": "low",
39193919
"reasoningMedium": "medium",
3920-
"reasoningHigh": "high"
3920+
"reasoningHigh": "high",
3921+
"localBridge": "Local agent bridge",
3922+
"bridgeConnected": "Daemon connected (ws://127.0.0.1:7437)",
3923+
"bridgeDisconnected": "Daemon not running — start with: comfy-ai serve",
3924+
"bridgePair": "Generate pair code",
3925+
"bridgePairHint": "Run in your terminal:",
3926+
"bridgePairWaiting": "Waiting for CLI to claim the code…"
39213927
}
39223928
}
39233929
}

0 commit comments

Comments
 (0)