feat(desktop): implement local chat with direct IPC and mode switching
Add LocalChat component using useLocalChat hook that communicates with the Hub via IPC (no Gateway required). Fix streamId extraction to use event.message.id matching Hub behavior. Fix history to return raw AgentMessageItem[] instead of flattened strings. Add exec approval forwarding over IPC. Use conditional rendering for LocalChat to prevent event leaking from remote sessions. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
7a21686505
commit
607adeb667
12 changed files with 445 additions and 437 deletions
|
|
@ -53,5 +53,15 @@ export function useDevices(): UseDevicesReturn {
|
|||
refresh()
|
||||
}, [refresh])
|
||||
|
||||
// Subscribe to device list changes pushed from main process
|
||||
useEffect(() => {
|
||||
window.electronAPI?.hub.onDevicesChanged(() => {
|
||||
refresh()
|
||||
})
|
||||
return () => {
|
||||
window.electronAPI?.hub.offDevicesChanged()
|
||||
}
|
||||
}, [refresh])
|
||||
|
||||
return { devices, loading, refresh, revokeDevice }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,6 +88,17 @@ export function useHub(): UseHubReturn {
|
|||
initHub()
|
||||
}, [initHub])
|
||||
|
||||
// Subscribe to connection state changes pushed from main process
|
||||
useEffect(() => {
|
||||
const handler = (state: string) => {
|
||||
setHubInfo((prev) => prev ? { ...prev, connectionState: state as HubInfo['connectionState'] } : prev)
|
||||
}
|
||||
window.electronAPI?.hub.onConnectionStateChanged(handler)
|
||||
return () => {
|
||||
window.electronAPI?.hub.offConnectionStateChanged()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Refresh Hub info and agents
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,277 +1,111 @@
|
|||
/**
|
||||
* Hook for local direct chat with agent via IPC (no Gateway required).
|
||||
*
|
||||
* Returns UseChatReturn-compatible shape so it can be plugged directly
|
||||
* into the shared <ChatView> component. All state is local (useState),
|
||||
* no Zustand store involved.
|
||||
*/
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { v7 as uuidv7 } from 'uuid'
|
||||
import type { ContentBlock } from '@multica/sdk'
|
||||
import type { UseChatReturn, Message, ToolStatus, ChatError } from '@multica/hooks/use-chat'
|
||||
import type { ApprovalDecision } from '@multica/sdk'
|
||||
import { useChat } from '@multica/hooks/use-chat'
|
||||
import type {
|
||||
StreamPayload,
|
||||
ExecApprovalRequestPayload,
|
||||
ApprovalDecision,
|
||||
} from '@multica/sdk'
|
||||
|
||||
// Stable empty array to avoid re-renders in consumers
|
||||
const EMPTY_APPROVALS: never[] = []
|
||||
|
||||
function toContentBlocks(content: unknown): ContentBlock[] {
|
||||
if (typeof content === 'string') {
|
||||
return content ? [{ type: 'text', text: content }] : []
|
||||
}
|
||||
if (Array.isArray(content)) return content as ContentBlock[]
|
||||
return []
|
||||
}
|
||||
|
||||
function extractContent(event: { message?: { content?: unknown } }): ContentBlock[] {
|
||||
if (!event.message?.content) return []
|
||||
return Array.isArray(event.message.content)
|
||||
? (event.message.content as ContentBlock[])
|
||||
: []
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides local IPC chat returning the same UseChatReturn shape as
|
||||
* the gateway-based useChat hook.
|
||||
*
|
||||
* Agent ID is fetched internally from hub.getStatus() — no parameters needed.
|
||||
*/
|
||||
export function useLocalChat(): UseChatReturn {
|
||||
const [messages, setMessages] = useState<Message[]>([])
|
||||
const [streamingIds, setStreamingIds] = useState<Set<string>>(new Set())
|
||||
export function useLocalChat() {
|
||||
const chat = useChat()
|
||||
const [agentId, setAgentId] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isLoadingHistory, setIsLoadingHistory] = useState(true)
|
||||
const [error, setError] = useState<ChatError | null>(null)
|
||||
|
||||
const agentIdRef = useRef<string | null>(null)
|
||||
const [initError, setInitError] = useState<string | null>(null)
|
||||
const initRef = useRef(false)
|
||||
|
||||
// Initialize hub and get default agent ID
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
if (initRef.current) return
|
||||
initRef.current = true
|
||||
|
||||
async function init() {
|
||||
// 1. Discover agentId from hub
|
||||
let agentId: string
|
||||
try {
|
||||
const status = await window.electronAPI.hub.getStatus()
|
||||
if (!status.defaultAgent?.agentId) {
|
||||
if (!cancelled) {
|
||||
setError({ code: 'NO_AGENT', message: 'No local agent available' })
|
||||
setIsLoadingHistory(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
agentId = status.defaultAgent.agentId
|
||||
agentIdRef.current = agentId
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setError({ code: 'HUB_ERROR', message: 'Failed to connect to hub' })
|
||||
window.electronAPI.hub.init()
|
||||
.then((result) => {
|
||||
const r = result as { defaultAgentId?: string }
|
||||
console.log('[LocalChat] hub.init → defaultAgentId:', r.defaultAgentId)
|
||||
if (r.defaultAgentId) {
|
||||
setAgentId(r.defaultAgentId)
|
||||
} else {
|
||||
setInitError('No default agent available')
|
||||
setIsLoadingHistory(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Subscribe to agent events
|
||||
const subResult = await window.electronAPI.localChat.subscribe(agentId)
|
||||
if (cancelled) return
|
||||
if (subResult.error) {
|
||||
setError({ code: 'SUBSCRIBE_FAILED', message: subResult.error })
|
||||
setIsLoadingHistory(false)
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Load history
|
||||
try {
|
||||
const result = await window.electronAPI.localChat.getHistory(agentId)
|
||||
if (!cancelled && result.messages?.length > 0) {
|
||||
setMessages(
|
||||
result.messages.map((m) => ({
|
||||
id: m.id ?? uuidv7(),
|
||||
role: m.role as Message['role'],
|
||||
content: toContentBlocks(m.content),
|
||||
agentId,
|
||||
})),
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// History load is best-effort
|
||||
}
|
||||
|
||||
if (!cancelled) setIsLoadingHistory(false)
|
||||
|
||||
// 4. Listen for streaming events
|
||||
window.electronAPI.localChat.onEvent((ev) => {
|
||||
if (cancelled || ev.agentId !== agentIdRef.current) return
|
||||
|
||||
// Error event
|
||||
if (ev.type === 'error') {
|
||||
setError({
|
||||
code: 'AGENT_ERROR',
|
||||
message: ev.content ?? 'Unknown error',
|
||||
})
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const agentEvent = ev.event
|
||||
const streamId = ev.streamId
|
||||
if (!agentEvent || !streamId) return
|
||||
|
||||
switch (agentEvent.type) {
|
||||
case 'message_start': {
|
||||
const content = extractContent(agentEvent)
|
||||
const newMsg: Message = {
|
||||
id: streamId,
|
||||
role: 'assistant',
|
||||
content: content.length ? content : [],
|
||||
agentId: ev.agentId,
|
||||
}
|
||||
setMessages((prev) => [...prev, newMsg])
|
||||
setStreamingIds((prev) => new Set(prev).add(streamId))
|
||||
setIsLoading(true)
|
||||
break
|
||||
}
|
||||
case 'message_update': {
|
||||
const content = extractContent(agentEvent)
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.id === streamId ? { ...m, content } : m)),
|
||||
)
|
||||
break
|
||||
}
|
||||
case 'message_end': {
|
||||
const content = extractContent(agentEvent)
|
||||
const stopReason =
|
||||
'message' in agentEvent
|
||||
? (agentEvent.message as { stopReason?: string })?.stopReason
|
||||
: undefined
|
||||
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => {
|
||||
if (m.id === streamId) return { ...m, content, stopReason }
|
||||
// Interrupt running tools belonging to the same agent
|
||||
if (
|
||||
m.role === 'toolResult' &&
|
||||
m.toolStatus === 'running' &&
|
||||
m.agentId === ev.agentId
|
||||
) {
|
||||
return { ...m, toolStatus: 'interrupted' as ToolStatus }
|
||||
}
|
||||
return m
|
||||
}),
|
||||
)
|
||||
setStreamingIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(streamId)
|
||||
return next
|
||||
})
|
||||
setIsLoading(false)
|
||||
break
|
||||
}
|
||||
case 'tool_execution_start': {
|
||||
const toolEvent = agentEvent as {
|
||||
type: 'tool_execution_start'
|
||||
toolCallId?: string
|
||||
toolName?: string
|
||||
args?: Record<string, unknown>
|
||||
}
|
||||
const toolMsg: Message = {
|
||||
id: uuidv7(),
|
||||
role: 'toolResult',
|
||||
content: [],
|
||||
agentId: ev.agentId,
|
||||
toolCallId: toolEvent.toolCallId,
|
||||
toolName: toolEvent.toolName,
|
||||
toolArgs: toolEvent.args,
|
||||
toolStatus: 'running',
|
||||
isError: false,
|
||||
}
|
||||
setMessages((prev) => [...prev, toolMsg])
|
||||
break
|
||||
}
|
||||
case 'tool_execution_end': {
|
||||
const toolEvent = agentEvent as {
|
||||
type: 'tool_execution_end'
|
||||
toolCallId?: string
|
||||
result?: unknown
|
||||
isError?: boolean
|
||||
}
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.role === 'toolResult' && m.toolCallId === toolEvent.toolCallId
|
||||
? {
|
||||
...m,
|
||||
toolStatus: (toolEvent.isError ? 'error' : 'success') as ToolStatus,
|
||||
isError: toolEvent.isError ?? false,
|
||||
content:
|
||||
toolEvent.result != null
|
||||
? [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text:
|
||||
typeof toolEvent.result === 'string'
|
||||
? toolEvent.result
|
||||
: JSON.stringify(toolEvent.result),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}
|
||||
: m,
|
||||
),
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
init()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.electronAPI.localChat.offEvent()
|
||||
const id = agentIdRef.current
|
||||
if (id) window.electronAPI.localChat.unsubscribe(id)
|
||||
}
|
||||
.catch((err: Error) => {
|
||||
setInitError(err.message)
|
||||
setIsLoadingHistory(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const sendMessage = useCallback((text: string) => {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) return
|
||||
|
||||
const agentId = agentIdRef.current
|
||||
// Subscribe to events + fetch history once agentId is available
|
||||
useEffect(() => {
|
||||
if (!agentId) return
|
||||
|
||||
// Add user message locally
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: uuidv7(),
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: trimmed }],
|
||||
agentId,
|
||||
},
|
||||
])
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
// Subscribe to agent events
|
||||
window.electronAPI.localChat.subscribe(agentId).catch(() => {})
|
||||
|
||||
// Send via IPC
|
||||
window.electronAPI.localChat.send(agentId, trimmed).then((result) => {
|
||||
if (result.error) {
|
||||
setError({ code: 'SEND_FAILED', message: result.error })
|
||||
setIsLoading(false)
|
||||
}
|
||||
// Listen for stream events
|
||||
window.electronAPI.localChat.onEvent((data) => {
|
||||
// Cast IPC event to StreamPayload (same shape: { agentId, streamId, event })
|
||||
const payload = data as unknown as StreamPayload
|
||||
if (!payload.event) return
|
||||
|
||||
chat.handleStream(payload)
|
||||
if (payload.event.type === 'message_start') setIsLoading(true)
|
||||
if (payload.event.type === 'message_end') setIsLoading(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const resolveApproval = useCallback((_approvalId: string, _decision: ApprovalDecision) => {
|
||||
// Exec approvals not supported on local IPC yet — no-op
|
||||
}, [])
|
||||
// Listen for exec approval requests
|
||||
window.electronAPI.localChat.onApproval((approval) => {
|
||||
chat.addApproval(approval as ExecApprovalRequestPayload)
|
||||
})
|
||||
|
||||
// Fetch history
|
||||
window.electronAPI.localChat.getHistory(agentId)
|
||||
.then((result) => {
|
||||
console.log('[LocalChat] getHistory result:', result.messages?.length, 'messages, sample:', result.messages?.[0])
|
||||
if (result.messages?.length) {
|
||||
chat.setHistory(result.messages as never[], agentId)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setIsLoadingHistory(false))
|
||||
|
||||
return () => {
|
||||
window.electronAPI.localChat.offEvent()
|
||||
window.electronAPI.localChat.offApproval()
|
||||
window.electronAPI.localChat.unsubscribe(agentId).catch(() => {})
|
||||
}
|
||||
}, [agentId])
|
||||
|
||||
const sendMessage = useCallback(
|
||||
(text: string) => {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed || !agentId) return
|
||||
chat.addUserMessage(trimmed, agentId)
|
||||
chat.setError(null)
|
||||
window.electronAPI.localChat.send(agentId, trimmed).catch(() => {})
|
||||
setIsLoading(true)
|
||||
},
|
||||
[agentId],
|
||||
)
|
||||
|
||||
const resolveApproval = useCallback(
|
||||
(approvalId: string, decision: ApprovalDecision) => {
|
||||
chat.removeApproval(approvalId)
|
||||
window.electronAPI.localChat.resolveExecApproval(approvalId, decision).catch(() => {})
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
return {
|
||||
messages,
|
||||
streamingIds,
|
||||
agentId,
|
||||
initError,
|
||||
messages: chat.messages,
|
||||
streamingIds: chat.streamingIds,
|
||||
isLoading,
|
||||
isLoadingHistory,
|
||||
error,
|
||||
pendingApprovals: EMPTY_APPROVALS,
|
||||
error: chat.error,
|
||||
pendingApprovals: chat.pendingApprovals,
|
||||
sendMessage,
|
||||
resolveApproval,
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue