refactor(apps): adopt conversation-first IPC and API surfaces

This commit is contained in:
Jiayuan Zhang 2026-02-17 09:17:16 +08:00
parent 5af7aa7840
commit 218e6da544
19 changed files with 202 additions and 349 deletions

View file

@ -100,10 +100,9 @@ interface ProfileData {
interface LocalChatEvent {
agentId: string
conversationId?: string
sessionId?: string
conversationId: string
streamId?: string
type?: 'error'
type?: 'error'
content?: string
event?: {
type: 'message_start' | 'message_update' | 'message_end' | 'tool_execution_start' | 'tool_execution_update' | 'tool_execution_end' | 'compaction_start' | 'compaction_end'
@ -119,10 +118,9 @@ interface LocalChatEvent {
interface LocalChatApproval {
approvalId: string
agentId: string
conversationId?: string
sessionId?: string
conversationId: string
command: string
cwd?: string
cwd?: string
riskLevel: 'safe' | 'needs-review' | 'dangerous'
riskReasons: string[]
expiresAtMs: number
@ -180,18 +178,14 @@ interface ElectronAPI {
hub: {
init: () => Promise<unknown>
getStatus: () => Promise<HubStatus>
getAgentInfo: () => Promise<AgentInfo | null>
getAgentInfo: () => Promise<AgentInfo | null>
info: () => Promise<unknown>
reconnect: (url: string) => Promise<unknown>
listAgents: () => Promise<unknown>
listConversations: () => Promise<unknown>
createAgent: (id?: string) => Promise<unknown>
createConversation: (id?: string) => Promise<unknown>
getAgent: (id: string) => Promise<unknown>
getConversation: (id: string) => Promise<unknown>
closeAgent: (id: string) => Promise<unknown>
closeConversation: (id: string) => Promise<unknown>
sendMessage: (agentId: string, content: string, conversationId?: string) => Promise<unknown>
sendMessage: (agentId: string, content: string, conversationId: string) => Promise<unknown>
registerToken: (token: string, agentId: string, conversationId: string, expiresAt: number) => Promise<unknown>
onDeviceConfirmRequest: (callback: (deviceId: string, agentId: string, conversationId: string, meta?: DeviceMeta) => void) => void
offDeviceConfirmRequest: () => void
@ -257,13 +251,13 @@ interface ElectronAPI {
last: () => Promise<unknown>
setEnabled: (enabled: boolean) => Promise<{ ok: boolean; enabled?: boolean; error?: string }>
wake: (reason?: string) => Promise<{ ok: boolean; result?: unknown; error?: string }>
}
}
localChat: {
subscribe: (agentId: string) => Promise<{ ok?: boolean; error?: string; alreadySubscribed?: boolean }>
unsubscribe: (agentId: string) => Promise<{ ok: boolean }>
getHistory: (agentId: string, options?: { offset?: number; limit?: number; conversationId?: string }) => Promise<{ messages: unknown[]; total: number; offset: number; limit: number; contextWindowTokens?: number }>
send: (agentId: string, content: string, conversationId?: string) => Promise<{ ok?: boolean; error?: string }>
abort: (agentId: string, conversationId?: string) => Promise<{ ok?: boolean; error?: string }>
subscribe: (conversationId: string) => Promise<{ ok?: boolean; error?: string; alreadySubscribed?: boolean }>
unsubscribe: (conversationId: string) => Promise<{ ok: boolean }>
getHistory: (conversationId: string, options?: { offset?: number; limit?: number }) => Promise<{ messages: unknown[]; total: number; offset: number; limit: number; contextWindowTokens?: number }>
send: (conversationId: string, content: string) => Promise<{ ok?: boolean; error?: string }>
abort: (conversationId: string) => Promise<{ ok?: boolean; error?: string }>
resolveExecApproval: (approvalId: string, decision: string) => Promise<{ ok: boolean }>
onEvent: (callback: (event: LocalChatEvent) => void) => void
offEvent: () => void

View file

@ -47,10 +47,10 @@ function getDefaultAgent() {
const hub = getCurrentHub()
if (!hub) return null
const agentIds = hub.listAgents()
if (agentIds.length === 0) return null
const conversationIds = hub.listConversations()
if (conversationIds.length === 0) return null
return hub.getAgent(agentIds[0]) ?? null
return hub.getConversation(conversationIds[0]) ?? null
}
/**

View file

@ -10,7 +10,7 @@ import type { ConnectionState } from '@multica/sdk'
// Singleton Hub instance
let hub: Hub | null = null
let defaultAgentId: string | null = null
let defaultConversationId: string | null = null
let mainWindowRef: BrowserWindow | null = null
// Track which agents have active IPC subscriptions (for local direct chat)
@ -35,7 +35,7 @@ function safeLog(...args: unknown[]): void {
/**
* Initialize Hub on app startup.
* Creates Hub and a default Agent automatically.
* Creates Hub and a default conversation automatically.
*/
export async function initializeHub(): Promise<void> {
if (hub) {
@ -47,16 +47,16 @@ export async function initializeHub(): Promise<void> {
hub = new Hub(gatewayUrl)
// Create default agent if none exists
const agents = hub.listAgents()
if (agents.length === 0) {
safeLog('[Desktop] Creating default agent...')
const agent = hub.createAgent()
defaultAgentId = agent.sessionId
safeLog(`[Desktop] Default agent created: ${defaultAgentId}`)
// Create default conversation if none exists
const conversations = hub.listConversations()
if (conversations.length === 0) {
safeLog('[Desktop] Creating default conversation...')
const conversation = hub.createConversation()
defaultConversationId = conversation.sessionId
safeLog(`[Desktop] Default conversation created: ${defaultConversationId}`)
} else {
defaultAgentId = agents[0]
safeLog(`[Desktop] Using existing agent: ${defaultAgentId}`)
defaultConversationId = conversations[0]
safeLog(`[Desktop] Using existing conversation: ${defaultConversationId}`)
}
}
@ -75,8 +75,8 @@ function getHub(): Hub {
* Get the default agent.
*/
export function getDefaultAgent(): AsyncAgent | null {
if (!hub || !defaultAgentId) return null
return hub.getAgent(defaultAgentId) ?? null
if (!hub || !defaultConversationId) return null
return hub.getConversation(defaultConversationId) ?? null
}
/**
@ -107,14 +107,10 @@ export function registerHubIpcHandlers(): void {
ipcMain.handle('hub:init', async () => {
await initializeHub()
const h = getHub()
const defaultConversationId = defaultAgentId
? (h.getAgentMainConversationId(defaultAgentId) ?? defaultAgentId)
: null
return {
hubId: h.hubId,
url: h.url,
connectionState: h.connectionState,
defaultAgentId,
defaultConversationId,
}
})
@ -128,7 +124,7 @@ export function registerHubIpcHandlers(): void {
hubId: h.hubId,
url: h.url,
connectionState: h.connectionState,
agentCount: h.listAgents().length,
agentCount: h.listConversations().length,
}
})
@ -142,12 +138,12 @@ export function registerHubIpcHandlers(): void {
return {
hubId: h.hubId,
status: h.connectionState === 'connected' ? 'ready' : h.connectionState,
agentCount: h.listAgents().length,
agentCount: h.listConversations().length,
gatewayConnected: h.connectionState === 'connected',
gatewayUrl: h.url,
defaultAgent: agent
? {
agentId: defaultAgentId ?? agent.sessionId,
agentId: defaultConversationId ?? agent.sessionId,
status: agent.closed ? 'closed' : 'idle',
}
: null,
@ -163,7 +159,7 @@ export function registerHubIpcHandlers(): void {
return null
}
return {
agentId: defaultAgentId ?? agent.sessionId,
agentId: defaultConversationId ?? agent.sessionId,
status: agent.closed ? 'closed' : 'idle',
}
})
@ -178,22 +174,7 @@ export function registerHubIpcHandlers(): void {
})
/**
* List all agents.
*/
ipcMain.handle('hub:listAgents', async (): Promise<AgentInfo[]> => {
const h = getHub()
const agentIds = h.listAgents()
return agentIds.map((id) => {
const agent = h.getAgent(id)
return {
id,
closed: agent?.closed ?? true,
}
})
})
/**
* List all conversations (alias of listAgents for clearer semantics).
* List all conversations.
*/
ipcMain.handle('hub:listConversations', async (): Promise<AgentInfo[]> => {
const h = getHub()
@ -208,19 +189,7 @@ export function registerHubIpcHandlers(): void {
})
/**
* Create a new agent.
*/
ipcMain.handle('hub:createAgent', async (_event, id?: string) => {
const h = getHub()
const agent = h.createAgent(id)
return {
id: agent.sessionId,
closed: agent.closed,
}
})
/**
* Create a new conversation (alias of createAgent).
* Create a new conversation.
*/
ipcMain.handle('hub:createConversation', async (_event, id?: string) => {
const h = getHub()
@ -232,22 +201,7 @@ export function registerHubIpcHandlers(): void {
})
/**
* Get a specific agent.
*/
ipcMain.handle('hub:getAgent', async (_event, id: string) => {
const h = getHub()
const agent = h.getAgent(id)
if (!agent) {
return { error: `Agent not found: ${id}` }
}
return {
id: agent.sessionId,
closed: agent.closed,
}
})
/**
* Get a specific conversation (alias of getAgent).
* Get a specific conversation.
*/
ipcMain.handle('hub:getConversation', async (_event, id: string) => {
const h = getHub()
@ -262,16 +216,7 @@ export function registerHubIpcHandlers(): void {
})
/**
* Close/delete an agent.
*/
ipcMain.handle('hub:closeAgent', async (_event, id: string) => {
const h = getHub()
const result = h.closeAgent(id)
return { ok: result }
})
/**
* Close/delete a conversation (alias of closeAgent).
* Close/delete a conversation.
*/
ipcMain.handle('hub:closeConversation', async (_event, id: string) => {
const h = getHub()
@ -283,10 +228,10 @@ export function registerHubIpcHandlers(): void {
* Send a message to an agent (for remote clients via Gateway).
* Note: For local direct chat, use 'localChat:send' instead.
*/
ipcMain.handle('hub:sendMessage', async (_event, agentId: string, content: string, conversationId?: string) => {
ipcMain.handle('hub:sendMessage', async (_event, agentId: string, content: string, conversationId: string) => {
const h = getHub()
const resolvedConversationId = conversationId ?? agentId
const agent = h.getAgent(resolvedConversationId)
const resolvedConversationId = conversationId
const agent = h.getConversation(resolvedConversationId)
if (!agent) {
return { error: `Conversation not found: ${resolvedConversationId}` }
}
@ -302,15 +247,14 @@ export function registerHubIpcHandlers(): void {
* Subscribe to local agent events (for direct IPC chat without Gateway).
* Uses agent.subscribe() which supports multiple subscribers.
*/
ipcMain.handle('localChat:subscribe', async (_event, agentId: string) => {
ipcMain.handle('localChat:subscribe', async (_event, conversationId: string) => {
const h = getHub()
const conversationId = agentId
const conversation = h.getConversation(conversationId)
if (!conversation) {
return { error: `Agent not found: ${conversationId}` }
return { error: `Conversation not found: ${conversationId}` }
}
if (conversation.closed) {
return { error: `Agent is closed: ${conversationId}` }
return { error: `Conversation is closed: ${conversationId}` }
}
const logicalAgentId = h.getConversationAgentId(conversationId) ?? conversationId
@ -336,7 +280,6 @@ export function registerHubIpcHandlers(): void {
mainWindowRef.webContents.send('localChat:event', {
agentId: logicalAgentId,
conversationId,
streamId: null,
event,
})
return
@ -392,14 +335,14 @@ export function registerHubIpcHandlers(): void {
/**
* Unsubscribe from local agent events.
*/
ipcMain.handle('localChat:unsubscribe', async (_event, agentId: string) => {
const unsubscribe = ipcAgentSubscriptions.get(agentId)
ipcMain.handle('localChat:unsubscribe', async (_event, conversationId: string) => {
const unsubscribe = ipcAgentSubscriptions.get(conversationId)
if (unsubscribe) {
unsubscribe()
}
ipcAgentSubscriptions.delete(agentId)
getHub().removeLocalApprovalHandler(agentId)
safeLog(`[IPC] Local chat unsubscribed from agent: ${agentId}`)
ipcAgentSubscriptions.delete(conversationId)
getHub().removeLocalApprovalHandler(conversationId)
safeLog(`[IPC] Local chat unsubscribed from conversation: ${conversationId}`)
return { ok: true }
})
@ -413,12 +356,11 @@ export function registerHubIpcHandlers(): void {
*/
ipcMain.handle('localChat:getHistory', async (
_event,
agentId: string,
options?: { offset?: number; limit?: number; conversationId?: string },
conversationId: string,
options?: { offset?: number; limit?: number },
) => {
const h = getHub()
const conversationId = options?.conversationId ?? agentId
const agent = h.getAgent(conversationId)
const agent = h.getConversation(conversationId)
if (!agent) {
return { messages: [], total: 0, offset: 0, limit: 0, contextWindowTokens: undefined }
}
@ -442,10 +384,10 @@ export function registerHubIpcHandlers(): void {
* Send a message via local direct IPC (no Gateway).
* Events will be pushed to renderer via 'localChat:event' channel.
*/
ipcMain.handle('localChat:send', async (_event, agentId: string, content: string, conversationId?: string) => {
ipcMain.handle('localChat:send', async (_event, conversationId: string, content: string) => {
const h = getHub()
const resolvedConversationId = conversationId ?? agentId
const agent = h.getAgent(resolvedConversationId)
const resolvedConversationId = conversationId
const agent = h.getConversation(resolvedConversationId)
if (!agent) {
return { error: `Conversation not found: ${resolvedConversationId}` }
}
@ -455,14 +397,14 @@ export function registerHubIpcHandlers(): void {
// Must be subscribed first to receive events
if (!ipcAgentSubscriptions.has(resolvedConversationId)) {
return { error: 'Not subscribed to agent events. Call subscribe first.' }
return { error: 'Not subscribed to conversation events. Call subscribe first.' }
}
h.channelManager.clearLastRoute()
const source = { type: 'local' as const }
// Broadcast as local source (for consistency, though UI already knows)
h.broadcastInbound({
agentId: h.getConversationAgentId(resolvedConversationId) ?? agentId,
agentId: h.getConversationAgentId(resolvedConversationId) ?? resolvedConversationId,
conversationId: resolvedConversationId,
content,
source,
@ -476,10 +418,10 @@ export function registerHubIpcHandlers(): void {
/**
* Abort the current agent run for local chat.
*/
ipcMain.handle('localChat:abort', async (_event, agentId: string, conversationId?: string) => {
ipcMain.handle('localChat:abort', async (_event, conversationId: string) => {
const h = getHub()
const resolvedConversationId = conversationId ?? agentId
const agent = h.getAgent(resolvedConversationId)
const resolvedConversationId = conversationId
const agent = h.getConversation(resolvedConversationId)
if (!agent) {
return { error: `Conversation not found: ${resolvedConversationId}` }
}

View file

@ -13,10 +13,10 @@ function getDefaultAgent() {
const hub = getCurrentHub()
if (!hub) return null
const agentIds = hub.listAgents()
if (agentIds.length === 0) return null
const conversationIds = hub.listConversations()
if (conversationIds.length === 0) return null
return hub.getAgent(agentIds[0]) ?? null
return hub.getConversation(conversationIds[0]) ?? null
}
/**

View file

@ -53,10 +53,10 @@ function getDefaultAgent() {
const hub = getCurrentHub()
if (!hub) return null
const agentIds = hub.listAgents()
if (agentIds.length === 0) return null
const conversationIds = hub.listConversations()
if (conversationIds.length === 0) return null
return hub.getAgent(agentIds[0]) ?? null
return hub.getConversation(conversationIds[0]) ?? null
}
/**

View file

@ -27,10 +27,10 @@ function getDefaultAgent() {
const hub = getCurrentHub()
if (!hub) return null
const agentIds = hub.listAgents()
if (agentIds.length === 0) return null
const conversationIds = hub.listConversations()
if (conversationIds.length === 0) return null
return hub.getAgent(agentIds[0]) ?? null
return hub.getConversation(conversationIds[0]) ?? null
}
/**

View file

@ -67,21 +67,20 @@ export interface CurrentProviderInfo {
// Local chat event types (for direct IPC communication without Gateway)
export interface LocalChatEvent {
agentId: string
conversationId?: string
sessionId?: string
conversationId: string
streamId?: string
type?: 'error'
content?: string
content?: string
event?: {
type: 'message_start' | 'message_update' | 'message_end' | 'tool_execution_start' | 'tool_execution_update' | 'tool_execution_end' | 'compaction_start' | 'compaction_end'
id?: string
message?: {
role: string
content?: Array<{ type: string; text?: string }>
}
[key: string]: unknown
}
}
content?: Array<{ type: string; text?: string }>
}
[key: string]: unknown
}
}
// Inbound message event (from any source: local, gateway, channel)
export type MessageSource =
@ -101,10 +100,9 @@ export interface InboundMessageEvent {
export interface LocalChatApproval {
approvalId: string
agentId: string
conversationId?: string
sessionId?: string
conversationId: string
command: string
cwd?: string
cwd?: string
riskLevel: 'safe' | 'needs-review' | 'dangerous'
riskReasons: string[]
expiresAtMs: number
@ -159,18 +157,14 @@ const electronAPI = {
hub: {
init: () => ipcRenderer.invoke('hub:init'),
getStatus: (): Promise<HubStatus> => ipcRenderer.invoke('hub:getStatus'),
getAgentInfo: (): Promise<AgentInfo | null> => ipcRenderer.invoke('hub:getAgentInfo'),
info: () => ipcRenderer.invoke('hub:info'),
getAgentInfo: (): Promise<AgentInfo | null> => ipcRenderer.invoke('hub:getAgentInfo'),
info: () => ipcRenderer.invoke('hub:info'),
reconnect: (url: string) => ipcRenderer.invoke('hub:reconnect', url),
listAgents: () => ipcRenderer.invoke('hub:listAgents'),
listConversations: () => ipcRenderer.invoke('hub:listConversations'),
createAgent: (id?: string) => ipcRenderer.invoke('hub:createAgent', id),
createConversation: (id?: string) => ipcRenderer.invoke('hub:createConversation', id),
getAgent: (id: string) => ipcRenderer.invoke('hub:getAgent', id),
getConversation: (id: string) => ipcRenderer.invoke('hub:getConversation', id),
closeAgent: (id: string) => ipcRenderer.invoke('hub:closeAgent', id),
closeConversation: (id: string) => ipcRenderer.invoke('hub:closeConversation', id),
sendMessage: (agentId: string, content: string, conversationId?: string) =>
sendMessage: (agentId: string, content: string, conversationId: string) =>
ipcRenderer.invoke('hub:sendMessage', agentId, content, conversationId),
registerToken: (token: string, agentId: string, conversationId: string, expiresAt: number) =>
ipcRenderer.invoke('hub:registerToken', token, agentId, conversationId, expiresAt),
@ -335,21 +329,21 @@ const electronAPI = {
},
},
// Local chat (direct IPC, no Gateway required)
localChat: {
/** Subscribe to agent events for local direct chat */
subscribe: (agentId: string) => ipcRenderer.invoke('localChat:subscribe', agentId),
/** Unsubscribe from agent events */
unsubscribe: (agentId: string) => ipcRenderer.invoke('localChat:unsubscribe', agentId),
/** Get message history for local chat with pagination (returns raw AgentMessageItem[]) */
getHistory: (agentId: string, options?: { offset?: number; limit?: number; conversationId?: string }) =>
ipcRenderer.invoke('localChat:getHistory', agentId, options),
/** Send message to agent via direct IPC (no Gateway) */
send: (agentId: string, content: string, conversationId?: string) =>
ipcRenderer.invoke('localChat:send', agentId, content, conversationId),
// Local chat (direct IPC, no Gateway required)
localChat: {
/** Subscribe to conversation events for local direct chat */
subscribe: (conversationId: string) => ipcRenderer.invoke('localChat:subscribe', conversationId),
/** Unsubscribe from conversation events */
unsubscribe: (conversationId: string) => ipcRenderer.invoke('localChat:unsubscribe', conversationId),
/** Get message history for local chat with pagination (returns raw AgentMessageItem[]) */
getHistory: (conversationId: string, options?: { offset?: number; limit?: number }) =>
ipcRenderer.invoke('localChat:getHistory', conversationId, options),
/** Send message to conversation via direct IPC (no Gateway) */
send: (conversationId: string, content: string) =>
ipcRenderer.invoke('localChat:send', conversationId, content),
/** Abort the current agent run */
abort: (agentId: string, conversationId?: string) =>
ipcRenderer.invoke('localChat:abort', agentId, conversationId),
abort: (conversationId: string) =>
ipcRenderer.invoke('localChat:abort', conversationId),
/** Resolve an exec approval request */
resolveExecApproval: (approvalId: string, decision: string) =>
ipcRenderer.invoke('localChat:resolveExecApproval', approvalId, decision),

View file

@ -20,7 +20,7 @@ export interface ConnectionQRCodeProps {
gateway: string
hubId: string
agentId: string
conversationId?: string
conversationId: string
expirySeconds?: number
size?: number
}
@ -131,8 +131,7 @@ export function ConnectionQRCode({
expirySeconds = 30,
size = 200,
}: ConnectionQRCodeProps) {
const resolvedConversationId = conversationId ?? agentId
const { token, expiresAt, refresh } = useQRToken(agentId, resolvedConversationId, expirySeconds)
const { token, expiresAt, refresh } = useQRToken(agentId, conversationId, expirySeconds)
const remaining = useCountdown(expiresAt, refresh)
// Derive QR data and URL from current token (computed during render)
@ -142,11 +141,11 @@ export function ConnectionQRCode({
gateway,
hubId,
agentId,
conversationId: resolvedConversationId,
conversationId,
token,
expires: expiresAt,
}),
[gateway, hubId, agentId, resolvedConversationId, token, expiresAt]
[gateway, hubId, agentId, conversationId, token, expiresAt]
)
const connectionUrl = useMemo(() => {
@ -154,12 +153,12 @@ export function ConnectionQRCode({
gateway,
hub: hubId,
agent: agentId,
conversation: resolvedConversationId,
conversation: conversationId,
token,
exp: expiresAt.toString(),
})
return `multica://connect?${params.toString()}`
}, [gateway, hubId, agentId, resolvedConversationId, token, expiresAt])
}, [gateway, hubId, agentId, conversationId, token, expiresAt])
return (
<div className="flex flex-col items-center gap-4">

View file

@ -8,7 +8,7 @@ export interface TelegramConnectQRProps {
gateway: string
hubId: string
agentId: string
conversationId?: string
conversationId: string
expirySeconds?: number
size?: number
}
@ -28,8 +28,7 @@ export function TelegramConnectQR({
expirySeconds = 30,
size = 200,
}: TelegramConnectQRProps) {
const resolvedConversationId = conversationId ?? agentId
const { token, expiresAt, refresh } = useQRToken(agentId, resolvedConversationId, expirySeconds)
const { token, expiresAt, refresh } = useQRToken(agentId, conversationId, expirySeconds)
const remaining = useCountdown(expiresAt, refresh)
const [deepLink, setDeepLink] = useState<string | null>(null)
@ -51,7 +50,7 @@ export function TelegramConnectQR({
gateway,
hubId,
agentId,
conversationId: resolvedConversationId,
conversationId,
token,
expires: expiresAt,
}),
@ -82,7 +81,7 @@ export function TelegramConnectQR({
fetchCode()
return () => { cancelled = true }
}, [token, expiresAt, gateway, hubId, agentId, resolvedConversationId])
}, [token, expiresAt, gateway, hubId, agentId, conversationId])
if (loading) {
return (

View file

@ -47,8 +47,8 @@ export function useLocalChat(options: UseLocalChatOptions = {}) {
window.electronAPI.hub.init()
.then((result) => {
const r = result as { defaultAgentId?: string; defaultConversationId?: string }
const defaultConversationId = r.defaultConversationId ?? r.defaultAgentId
const r = result as { defaultConversationId?: string }
const defaultConversationId = r.defaultConversationId
console.log('[LocalChat] hub.init → defaultConversationId:', defaultConversationId)
if (defaultConversationId) {
setAgentId(defaultConversationId)
@ -68,7 +68,6 @@ export function useLocalChat(options: UseLocalChatOptions = {}) {
// Subscribe to events + fetch history once conversation is available
useEffect(() => {
if (!activeConversationId) return
const resolvedAgentId = agentId ?? activeConversationId
setQueuedMessages([])
offsetRef.current = null
setIsLoading(false)
@ -125,33 +124,32 @@ export function useLocalChat(options: UseLocalChatOptions = {}) {
chatRef.current.addUserMessage(
event.content,
event.agentId,
event.source as MessageSource,
eventConversationId,
event.source as MessageSource,
)
setIsLoading(true)
}
})
// Fetch history with pagination
window.electronAPI.localChat.getHistory(resolvedAgentId, {
window.electronAPI.localChat.getHistory(activeConversationId, {
limit: DEFAULT_MESSAGES_LIMIT,
conversationId: activeConversationId,
})
.then((result) => {
console.log('[LocalChat] getHistory result:', result.messages?.length, 'messages, total:', result.total)
if (result.messages?.length) {
chatRef.current.setHistory(result.messages as AgentMessageItem[], resolvedAgentId, {
chatRef.current.setHistory(result.messages as AgentMessageItem[], activeConversationId, activeConversationId, {
total: result.total,
offset: result.offset,
contextWindowTokens: result.contextWindowTokens,
}, activeConversationId)
})
offsetRef.current = result.offset
} else {
chatRef.current.setHistory([], resolvedAgentId, {
chatRef.current.setHistory([], activeConversationId, activeConversationId, {
total: 0,
offset: 0,
contextWindowTokens: result.contextWindowTokens,
}, activeConversationId)
})
}
})
.catch(() => {})
@ -163,7 +161,7 @@ export function useLocalChat(options: UseLocalChatOptions = {}) {
window.electronAPI.hub.offInboundMessage()
window.electronAPI.localChat.unsubscribe(activeConversationId).catch(() => {})
}
}, [agentId, activeConversationId])
}, [activeConversationId])
useEffect(() => {
isLoadingRef.current = isLoading
@ -172,11 +170,10 @@ export function useLocalChat(options: UseLocalChatOptions = {}) {
const dispatchMessageNow = useCallback((text: string) => {
const trimmed = text.trim()
if (!trimmed || !activeConversationId) return
const resolvedAgentId = agentId ?? activeConversationId
chatRef.current.addUserMessage(trimmed, resolvedAgentId, { type: 'local' }, activeConversationId)
chatRef.current.addUserMessage(trimmed, activeConversationId, activeConversationId, { type: 'local' })
chatRef.current.setError(null)
setIsLoading(true)
window.electronAPI.localChat.send(resolvedAgentId, trimmed, activeConversationId)
window.electronAPI.localChat.send(activeConversationId, trimmed)
.then((result) => {
const response = result as { ok?: boolean; error?: string } | undefined
if (response?.error) {
@ -186,7 +183,7 @@ export function useLocalChat(options: UseLocalChatOptions = {}) {
.catch(() => {
setIsLoading(false)
})
}, [agentId, activeConversationId])
}, [activeConversationId])
const sendMessage = useCallback((text: string) => {
const trimmed = text.trim()
@ -225,10 +222,9 @@ export function useLocalChat(options: UseLocalChatOptions = {}) {
const abortGeneration = useCallback(() => {
if (!activeConversationId) return
const resolvedAgentId = agentId ?? activeConversationId
window.electronAPI.localChat.abort(resolvedAgentId, activeConversationId).catch(() => {})
window.electronAPI.localChat.abort(activeConversationId).catch(() => {})
setIsLoading(false)
}, [agentId, activeConversationId])
}, [activeConversationId])
const loadMore = useCallback(async () => {
const currentOffset = offsetRef.current
@ -237,20 +233,18 @@ export function useLocalChat(options: UseLocalChatOptions = {}) {
isLoadingMoreRef.current = true
setIsLoadingMore(true)
try {
const resolvedAgentId = agentId ?? activeConversationId
const newOffset = Math.max(0, currentOffset - DEFAULT_MESSAGES_LIMIT)
const limit = currentOffset - newOffset
const result = await window.electronAPI.localChat.getHistory(resolvedAgentId, {
const result = await window.electronAPI.localChat.getHistory(activeConversationId, {
offset: newOffset,
limit,
conversationId: activeConversationId,
})
if (result.messages?.length) {
chatRef.current.prependHistory(result.messages as AgentMessageItem[], resolvedAgentId, {
chatRef.current.prependHistory(result.messages as AgentMessageItem[], activeConversationId, activeConversationId, {
total: result.total,
offset: result.offset,
contextWindowTokens: result.contextWindowTokens,
}, activeConversationId)
})
offsetRef.current = result.offset
}
} catch {
@ -259,7 +253,7 @@ export function useLocalChat(options: UseLocalChatOptions = {}) {
isLoadingMoreRef.current = false
setIsLoadingMore(false)
}
}, [agentId, activeConversationId])
}, [activeConversationId])
const resolveApproval = useCallback(
(approvalId: string, decision: ApprovalDecision) => {

View file

@ -118,6 +118,7 @@ export default function HomePage() {
gateway={hubInfo?.url ?? 'http://localhost:3000'}
hubId={hubInfo?.hubId ?? 'unknown'}
agentId={primaryAgent?.id}
conversationId={primaryAgent?.id}
expirySeconds={30}
size={180}
/>

View file

@ -34,6 +34,7 @@ function ChannelsContent() {
gateway={hubInfo?.url ?? 'http://localhost:3000'}
hubId={hubInfo?.hubId ?? 'unknown'}
agentId={primaryAgent?.id ?? 'unknown'}
conversationId={primaryAgent?.id ?? 'unknown'}
expirySeconds={30}
size={200}
/>

View file

@ -124,6 +124,7 @@ export default function ConnectStep({ onNext, onBack }: ConnectStepProps) {
gateway={hubInfo?.url ?? 'http://localhost:3000'}
hubId={hubInfo?.hubId ?? 'unknown'}
agentId={primaryAgent?.id ?? 'unknown'}
conversationId={primaryAgent?.id ?? 'unknown'}
expirySeconds={30}
size={180}
/>

View file

@ -726,7 +726,7 @@
from: deviceId,
to: targetDeviceId,
action: 'message',
payload: { agentId: targetAgentId, conversationId: targetAgentId, sessionId: targetAgentId, content: text },
payload: { agentId: targetAgentId, conversationId: targetAgentId, content: text },
});
appendMsg('self', text);

View file

@ -33,8 +33,7 @@ export class TelegramController {
gateway: string;
hubId: string;
agentId: string;
conversationId?: string;
sessionId?: string;
conversationId: string;
token: string;
expires: number;
},
@ -53,7 +52,7 @@ export class TelegramController {
gateway: body.gateway,
hubId: body.hubId,
agentId: body.agentId,
...((body.sessionId ?? body.conversationId) ? { conversationId: body.sessionId ?? body.conversationId } : {}),
conversationId: body.conversationId,
token: body.token,
expires: body.expires,
};

View file

@ -92,14 +92,6 @@ interface GenerateChannelWelcomeResult {
text: string;
}
interface ListAgentsResult {
agents: Array<{ id: string; closed: boolean }>;
}
interface CreateAgentResult {
id: string;
}
interface ListConversationsResult {
conversations: Array<{ id: string; closed: boolean }>;
}
@ -611,7 +603,7 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy {
`<b>Welcome back!</b>\n\n` +
`${statusEmoji} Status: <b>${statusText}</b>\n` +
`Agent: <code>${user.agentId}</code>\n` +
`Session: <code>${user.conversationId ?? user.agentId}</code>\n\n` +
`Conversation: <code>${user.conversationId ?? user.agentId}</code>\n\n` +
(online
? `Your agent is ready. Just send a message to start chatting.`
: `Your Hub is offline. Make sure the Multica Desktop app is running.`);
@ -646,7 +638,7 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy {
`${statusEmoji} <b>${statusLabel}</b>\n\n` +
`Hub: <code>${user.hubId}</code>\n` +
`Agent: <code>${user.agentId}</code>\n` +
`Session: <code>${user.conversationId ?? user.agentId}</code>\n\n` +
`Conversation: <code>${user.conversationId ?? user.agentId}</code>\n\n` +
(online
? `Your Hub is online and ready to receive messages.`
: `Your Hub is offline. Make sure the Multica Desktop app is running.`);
@ -668,9 +660,9 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy {
`<b>Commands</b>\n` +
` /start \u2014 Connect your account or see welcome\n` +
` /status \u2014 Check connection status\n` +
` /new \u2014 Start a new isolated session\n` +
` /session [id] \u2014 Show or switch current session\n` +
` /sessions \u2014 List available sessions\n` +
` /new \u2014 Start a new isolated conversation\n` +
` /session [id] \u2014 Show or switch current conversation\n` +
` /sessions \u2014 List available conversations\n` +
` /help \u2014 Show this message\n\n` +
`<b>How to connect</b>\n` +
` <b>1.</b> Open Multica Desktop app\n` +
@ -696,9 +688,9 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy {
await this.bot.api.setMyCommands([
{ command: "start", description: "Connect or show welcome" },
{ command: "status", description: "Check connection status" },
{ command: "new", description: "Create a new session" },
{ command: "session", description: "Show/switch current session" },
{ command: "sessions", description: "List sessions" },
{ command: "new", description: "Create a new conversation" },
{ command: "session", description: "Show/switch current conversation" },
{ command: "sessions", description: "List conversations" },
{ command: "help", description: "Show help and instructions" },
]);
@ -765,59 +757,28 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy {
await ctx.reply(welcome.text, { parse_mode: "HTML", reply_markup: welcome.keyboard });
}
private isMethodNotFoundError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return message.includes("METHOD_NOT_FOUND") || message.includes("Unknown RPC method");
}
private async createConversationViaRpc(deviceId: string, hubId: string, agentId?: string): Promise<{ id: string }> {
try {
const created = await this.sendRpc<{ agentId?: string }, CreateConversationResult>(
deviceId,
hubId,
"createConversation",
agentId ? { agentId } : {},
VERIFY_TIMEOUT_MS,
"Create session request timed out",
);
return { id: created.id };
} catch (error) {
if (!this.isMethodNotFoundError(error)) throw error;
const created = await this.sendRpc<Record<string, never>, CreateAgentResult>(
deviceId,
hubId,
"createAgent",
{},
VERIFY_TIMEOUT_MS,
"Create session request timed out",
);
return { id: created.id };
}
const created = await this.sendRpc<{ agentId?: string }, CreateConversationResult>(
deviceId,
hubId,
"createConversation",
agentId ? { agentId } : {},
VERIFY_TIMEOUT_MS,
"Create conversation request timed out",
);
return { id: created.id };
}
private async listConversationsViaRpc(deviceId: string, hubId: string): Promise<Array<{ id: string; closed: boolean }>> {
try {
const result = await this.sendRpc<Record<string, never>, ListConversationsResult>(
deviceId,
hubId,
"listConversations",
{},
VERIFY_TIMEOUT_MS,
"List sessions request timed out",
);
return result.conversations;
} catch (error) {
if (!this.isMethodNotFoundError(error)) throw error;
const result = await this.sendRpc<Record<string, never>, ListAgentsResult>(
deviceId,
hubId,
"listAgents",
{},
VERIFY_TIMEOUT_MS,
"List sessions request timed out",
);
return result.agents;
}
const result = await this.sendRpc<Record<string, never>, ListConversationsResult>(
deviceId,
hubId,
"listConversations",
{},
VERIFY_TIMEOUT_MS,
"List conversations request timed out",
);
return result.conversations;
}
private async handleNewConversationCommand(ctx: Context): Promise<void> {
@ -856,14 +817,14 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy {
await this.bindConversationToContext(user, ctx, created.id);
await ctx.reply(
`<b>\u2705 New session created</b>\n\n` +
`Session: <code>${created.id}</code>\n\n` +
`All next messages in this Telegram thread will use this session.`,
`<b>\u2705 New conversation created</b>\n\n` +
`Conversation: <code>${created.id}</code>\n\n` +
`All next messages in this Telegram thread will use this conversation.`,
{ parse_mode: "HTML" },
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await ctx.reply(`Failed to create session: ${message}`);
await ctx.reply(`Failed to create conversation: ${message}`);
}
}
@ -886,28 +847,28 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy {
try {
const conversations = await this.listConversationsViaRpc(user.deviceId, user.hubId);
const sessions = conversations.filter((item) => !item.closed).map((item) => item.id);
if (sessions.length === 0) {
await ctx.reply("No sessions found.");
const conversationIds = conversations.filter((item) => !item.closed).map((item) => item.id);
if (conversationIds.length === 0) {
await ctx.reply("No conversations found.");
return;
}
const current = await this.resolveConversationForContext(user, ctx);
const lines = sessions.slice(0, 20).map((id) => {
const lines = conversationIds.slice(0, 20).map((id) => {
const marker = id === current ? "\u2022 current" : "";
return `<code>${id}</code>${marker ? ` ${marker}` : ""}`;
});
const extra = sessions.length > 20 ? `\n...and ${sessions.length - 20} more` : "";
const extra = conversationIds.length > 20 ? `\n...and ${conversationIds.length - 20} more` : "";
await ctx.reply(
`<b>Available sessions</b>\n\n` +
`<b>Available conversations</b>\n\n` +
`${lines.join("\n")}${extra}\n\n` +
`Use <code>/session &lt;id&gt;</code> to switch.`,
{ parse_mode: "HTML" },
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await ctx.reply(`Failed to load sessions: ${message}`);
await ctx.reply(`Failed to load conversations: ${message}`);
}
}
@ -923,7 +884,7 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy {
const current = await this.resolveConversationForContext(user, ctx);
if (!target) {
await ctx.reply(
`<b>Current session</b>\n\n` +
`<b>Current conversation</b>\n\n` +
`<code>${current}</code>\n\n` +
`Use <code>/session &lt;id&gt;</code> to switch.`,
{ parse_mode: "HTML" },
@ -945,7 +906,7 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy {
const exists = conversations.some((item) => item.id === target && !item.closed);
if (!exists) {
await ctx.reply(
`Session not found: <code>${target}</code>\n\nUse /sessions to list available sessions.`,
`Conversation not found: <code>${target}</code>\n\nUse /sessions to list available conversations.`,
{ parse_mode: "HTML" },
);
return;
@ -964,13 +925,13 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy {
await this.bindConversationToContext(user, ctx, target);
await ctx.reply(
`<b>\u2705 Session switched</b>\n\n` +
`Current session: <code>${target}</code>`,
`<b>\u2705 Conversation switched</b>\n\n` +
`Current conversation: <code>${target}</code>`,
{ parse_mode: "HTML" },
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await ctx.reply(`Failed to switch session: ${message}`);
await ctx.reply(`Failed to switch conversation: ${message}`);
}
}
@ -1372,19 +1333,14 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy {
? `Telegram @${msg.from.username}`
: `Telegram ${msg?.from?.first_name ?? telegramUserId}`,
});
const sessionId =
connectionInfo.conversationId
?? result.sessionId
?? result.conversationId
?? result.mainConversationId
?? result.agentId;
const conversationId = connectionInfo.conversationId ?? result.conversationId;
// 5. Save to DB
await this.userStore.upsert({
telegramUserId,
hubId: connectionInfo.hubId,
agentId: connectionInfo.agentId,
conversationId: sessionId,
conversationId,
deviceId,
telegramUsername: msg?.from?.username,
telegramFirstName: msg?.from?.first_name,
@ -1395,7 +1351,7 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy {
await this.userStore.setThreadConversation(
telegramUserId,
threadRoute.chatId,
sessionId,
conversationId,
threadRoute.threadId,
);
}
@ -1408,7 +1364,7 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy {
`<b>\u2705 Connected successfully!</b>\n\n` +
`Hub: <code>${result.hubId}</code>\n` +
`Agent: <code>${result.agentId}</code>\n` +
`Session: <code>${sessionId}</code>\n\n` +
`Conversation: <code>${conversationId}</code>\n\n` +
`You can now send messages to interact with your agent.`,
{ parse_mode: "HTML", reply_markup: successKeyboard },
);
@ -1626,7 +1582,7 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy {
from: user.deviceId,
to: user.hubId,
action: "message",
payload: { agentId: user.agentId, conversationId, sessionId: conversationId, content: text },
payload: { agentId: user.agentId, conversationId, content: text },
};
const sent = this.eventsGateway.routeFromVirtualDevice(message);
@ -1677,7 +1633,7 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy {
const streamPayload = msg.payload as StreamPayload;
const event = streamPayload?.event;
if (!event || !("type" in event)) return;
const conversationId = streamPayload?.sessionId ?? streamPayload?.conversationId;
const conversationId = streamPayload?.conversationId;
const contextKey = this.makeConversationContextKey(deviceId, conversationId);
// Start typing when LLM begins generating
@ -1745,10 +1701,9 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy {
caption?: string;
filename?: string;
conversationId?: string;
sessionId?: string;
};
if (payload?.data) {
const conversationId = payload.sessionId ?? payload.conversationId;
const conversationId = payload.conversationId;
const contextKey = this.makeConversationContextKey(deviceId, conversationId);
void this.sendFileToTelegram(
deviceId,
@ -1769,10 +1724,9 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy {
content?: string;
agentId?: string;
conversationId?: string;
sessionId?: string;
};
if (payload?.content) {
const conversationId = payload.sessionId ?? payload.conversationId;
const conversationId = payload.conversationId;
const contextKey = this.makeConversationContextKey(deviceId, conversationId);
void this.sendToTelegram(deviceId, payload.content, conversationId).then(() => {
void this.clearMessageContext(contextKey);
@ -1787,9 +1741,8 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy {
message?: string;
code?: string;
conversationId?: string;
sessionId?: string;
};
const conversationId = payload.sessionId ?? payload.conversationId;
const conversationId = payload.conversationId;
const contextKey = this.makeConversationContextKey(deviceId, conversationId);
this.stopTyping(contextKey);
void this.clearMessageContext(contextKey);

View file

@ -20,7 +20,7 @@ export class AppController {
hubId: this.hub.hubId,
url: this.hub.url,
connectionState: this.hub.connectionState,
agentCount: this.hub.listAgents().length,
agentCount: this.hub.listConversations().length,
};
}
@ -33,26 +33,6 @@ export class AppController {
};
}
@Get("agents")
listAgents() {
return this.hub.listAgents().map((id) => {
const agent = this.hub.getAgent(id);
return { id, closed: agent?.closed ?? true };
});
}
@Post("agents")
createAgent(@Body() body?: { id?: string }) {
const agent = this.hub.createAgent(body?.id);
return { id: agent.sessionId };
}
@Delete("agents/:id")
deleteAgent(@Param("id") id: string) {
const ok = this.hub.closeAgent(id);
return { ok };
}
@Get("conversations")
listConversations() {
return this.hub.listConversations().map((id) => {

View file

@ -232,7 +232,7 @@
color: var(--green);
}
/* ── Agents section ── */
/* ── Conversations section ── */
.section-header {
display: flex;
align-items: center;
@ -347,7 +347,7 @@
<div class="stat-value" id="stat-state"></div>
</div>
<div class="stat-card">
<div class="stat-label">Agents</div>
<div class="stat-label">Conversations</div>
<div class="stat-value" id="stat-agents"></div>
</div>
</div>
@ -359,17 +359,17 @@
<button class="btn btn-accent" onclick="updateGateway()">Connect</button>
</div>
<!-- Agents -->
<!-- Conversations -->
<div class="section-header">
<span class="section-title">Agents</span>
<button class="btn btn-accent" onclick="createAgent()">
<span class="section-title">Conversations</span>
<button class="btn btn-accent" onclick="createConversation()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Create Agent
Create Conversation
</button>
</div>
<div class="agent-table" id="agent-table">
<div class="agent-table-head">
<span>Agent ID</span>
<span>Conversation ID</span>
<span>Actions</span>
</div>
<div id="agent-list"></div>
@ -419,9 +419,9 @@
}
async function refresh() {
const [hubRes, agentsRes] = await Promise.all([
const [hubRes, conversationsRes] = await Promise.all([
fetch(`${API}/hub`).then(r => r.json()),
fetch(`${API}/agents`).then(r => r.json()),
fetch(`${API}/conversations`).then(r => r.json()),
]);
// Stats
@ -446,19 +446,19 @@
input.value = hubRes.url || '';
}
// Agent list
// Conversation list
const list = document.getElementById('agent-list');
if (agentsRes.length === 0) {
list.innerHTML = '<div class="empty-state">No agents yet. Create one to get started.</div>';
if (conversationsRes.length === 0) {
list.innerHTML = '<div class="empty-state">No conversations yet. Create one to get started.</div>';
} else {
list.innerHTML = agentsRes.map(a =>
list.innerHTML = conversationsRes.map(a =>
`<div class="agent-row">` +
`<div class="agent-id">` +
`<span title="${a.id}">${a.id}</span>` +
`<button class="copy-btn" onclick="copyText('${a.id}', this)">${copyIcon()}</button>` +
`</div>` +
`<div class="agent-actions">` +
`<button class="btn-danger-ghost btn" onclick="deleteAgent('${a.id}')" title="Delete">${trashIcon()}</button>` +
`<button class="btn-danger-ghost btn" onclick="deleteConversation('${a.id}')" title="Delete">${trashIcon()}</button>` +
`</div>` +
`</div>`
).join('');
@ -482,13 +482,13 @@
refresh();
}
async function createAgent() {
await fetch(`${API}/agents`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
async function createConversation() {
await fetch(`${API}/conversations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
refresh();
}
async function deleteAgent(id) {
await fetch(`${API}/agents/${id}`, { method: 'DELETE' });
async function deleteConversation(id) {
await fetch(`${API}/conversations/${id}`, { method: 'DELETE' });
refresh();
}

View file

@ -56,17 +56,13 @@ pnpm dev:local:archive
- `agentId`: logical owner identity (capabilities/profile scope).
- `conversationId`: isolated runtime thread under an agent.
- `sessionId`: runtime/storage id for a conversation (currently same as `conversationId`).
- `sessionId`: internal runner/storage identifier for a conversation. External protocols use `conversationId`.
Compatibility behavior:
Protocol rules:
- If only `agentId` is provided, runtime resolves to that agent's `mainConversationId`.
- Legacy fallback is still supported: when no mapping exists, `conversationId = agentId`.
- New integrations should pass `conversationId` explicitly.
- Hub RPC supports both naming sets:
- Legacy: `createAgent/listAgents/deleteAgent`
- Conversation-first aliases: `createConversation/listConversations/deleteConversation`
- `createConversation` supports optional `agentId` to create a new thread under a specific agent.
- Hub RPC is conversation-first: `createConversation/listConversations/deleteConversation`.
- All message, stream, and verify payloads use `conversationId` (no `sessionId` alias fields).
- New integrations should always pass `conversationId` explicitly.
Telegram behavior: