committed by
GitHub
Unverified
parent
3d804a9f5e
commit
2c5c82bb74
@@ -1,12 +1,14 @@
|
||||
/**
|
||||
* Gateway State Store
|
||||
* Manages Gateway connection state and communication
|
||||
* Uses Host API + SSE for lifecycle/status and a direct renderer WebSocket for runtime RPC.
|
||||
*/
|
||||
import { create } from 'zustand';
|
||||
import type { GatewayStatus } from '../types/gateway';
|
||||
import { createHostEventSource, hostApiFetch } from '@/lib/host-api';
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
import type { GatewayStatus } from '../types/gateway';
|
||||
|
||||
let gatewayInitPromise: Promise<void> | null = null;
|
||||
let gatewayEventSource: EventSource | null = null;
|
||||
|
||||
interface GatewayHealth {
|
||||
ok: boolean;
|
||||
@@ -19,8 +21,6 @@ interface GatewayState {
|
||||
health: GatewayHealth | null;
|
||||
isInitialized: boolean;
|
||||
lastError: string | null;
|
||||
|
||||
// Actions
|
||||
init: () => Promise<void>;
|
||||
start: () => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
@@ -31,6 +31,102 @@ interface GatewayState {
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
function handleGatewayNotification(notification: { method?: string; params?: Record<string, unknown> } | undefined): void {
|
||||
const payload = notification;
|
||||
if (!payload || payload.method !== 'agent' || !payload.params || typeof payload.params !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
const p = payload.params;
|
||||
const data = (p.data && typeof p.data === 'object') ? (p.data as Record<string, unknown>) : {};
|
||||
const phase = data.phase ?? p.phase;
|
||||
const hasChatData = (p.state ?? data.state) || (p.message ?? data.message);
|
||||
|
||||
if (hasChatData) {
|
||||
const normalizedEvent: Record<string, unknown> = {
|
||||
...data,
|
||||
runId: p.runId ?? data.runId,
|
||||
sessionKey: p.sessionKey ?? data.sessionKey,
|
||||
stream: p.stream ?? data.stream,
|
||||
seq: p.seq ?? data.seq,
|
||||
state: p.state ?? data.state,
|
||||
message: p.message ?? data.message,
|
||||
};
|
||||
import('./chat')
|
||||
.then(({ useChatStore }) => {
|
||||
useChatStore.getState().handleChatEvent(normalizedEvent);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
const runId = p.runId ?? data.runId;
|
||||
const sessionKey = p.sessionKey ?? data.sessionKey;
|
||||
if (phase === 'started' && runId != null && sessionKey != null) {
|
||||
import('./chat')
|
||||
.then(({ useChatStore }) => {
|
||||
useChatStore.getState().handleChatEvent({
|
||||
state: 'started',
|
||||
runId,
|
||||
sessionKey,
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
if (phase === 'completed' || phase === 'done' || phase === 'finished' || phase === 'end') {
|
||||
import('./chat')
|
||||
.then(({ useChatStore }) => {
|
||||
const state = useChatStore.getState();
|
||||
state.loadHistory(true);
|
||||
if (state.sending) {
|
||||
useChatStore.setState({
|
||||
sending: false,
|
||||
activeRunId: null,
|
||||
pendingFinal: false,
|
||||
lastUserMessageAt: null,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function handleGatewayChatMessage(data: unknown): void {
|
||||
import('./chat').then(({ useChatStore }) => {
|
||||
const chatData = data as Record<string, unknown>;
|
||||
const payload = ('message' in chatData && typeof chatData.message === 'object')
|
||||
? chatData.message as Record<string, unknown>
|
||||
: chatData;
|
||||
|
||||
if (payload.state) {
|
||||
useChatStore.getState().handleChatEvent(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
useChatStore.getState().handleChatEvent({
|
||||
state: 'final',
|
||||
message: payload,
|
||||
runId: chatData.runId ?? payload.runId,
|
||||
});
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function mapChannelStatus(status: string): 'connected' | 'connecting' | 'disconnected' | 'error' {
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
case 'running':
|
||||
return 'connected';
|
||||
case 'connecting':
|
||||
case 'starting':
|
||||
return 'connecting';
|
||||
case 'error':
|
||||
case 'failed':
|
||||
return 'error';
|
||||
default:
|
||||
return 'disconnected';
|
||||
}
|
||||
}
|
||||
|
||||
export const useGatewayStore = create<GatewayState>((set, get) => ({
|
||||
status: {
|
||||
state: 'stopped',
|
||||
@@ -49,141 +145,41 @@ export const useGatewayStore = create<GatewayState>((set, get) => ({
|
||||
|
||||
gatewayInitPromise = (async () => {
|
||||
try {
|
||||
// Get initial status first
|
||||
const status = await invokeIpc('gateway:status') as GatewayStatus;
|
||||
const status = await hostApiFetch<GatewayStatus>('/api/gateway/status');
|
||||
set({ status, isInitialized: true });
|
||||
|
||||
// Listen for status changes
|
||||
window.electron.ipcRenderer.on('gateway:status-changed', (newStatus) => {
|
||||
set({ status: newStatus as GatewayStatus });
|
||||
});
|
||||
|
||||
// Listen for errors
|
||||
window.electron.ipcRenderer.on('gateway:error', (error) => {
|
||||
set({ lastError: String(error) });
|
||||
});
|
||||
|
||||
// Some Gateway builds stream chat events via generic "agent" notifications.
|
||||
// Normalize and forward them to the chat store.
|
||||
// The Gateway may put event fields (state, message, etc.) either inside
|
||||
// params.data or directly on params — we must handle both layouts.
|
||||
window.electron.ipcRenderer.on('gateway:notification', (notification) => {
|
||||
const payload = notification as { method?: string; params?: Record<string, unknown> } | undefined;
|
||||
if (!payload || payload.method !== 'agent' || !payload.params || typeof payload.params !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
const p = payload.params;
|
||||
const data = (p.data && typeof p.data === 'object') ? (p.data as Record<string, unknown>) : {};
|
||||
const phase = data.phase ?? p.phase;
|
||||
|
||||
const hasChatData = (p.state ?? data.state) || (p.message ?? data.message);
|
||||
if (hasChatData) {
|
||||
const normalizedEvent: Record<string, unknown> = {
|
||||
...data,
|
||||
runId: p.runId ?? data.runId,
|
||||
sessionKey: p.sessionKey ?? data.sessionKey,
|
||||
stream: p.stream ?? data.stream,
|
||||
seq: p.seq ?? data.seq,
|
||||
state: p.state ?? data.state,
|
||||
message: p.message ?? data.message,
|
||||
};
|
||||
import('./chat')
|
||||
.then(({ useChatStore }) => {
|
||||
useChatStore.getState().handleChatEvent(normalizedEvent);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// When a run starts (e.g. user clicked Send on console), show loading in the app immediately.
|
||||
const runId = p.runId ?? data.runId;
|
||||
const sessionKey = p.sessionKey ?? data.sessionKey;
|
||||
if (phase === 'started' && runId != null && sessionKey != null) {
|
||||
import('./chat')
|
||||
.then(({ useChatStore }) => {
|
||||
useChatStore.getState().handleChatEvent({
|
||||
state: 'started',
|
||||
runId,
|
||||
sessionKey,
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// When the agent run completes, reload history to get the final response.
|
||||
if (phase === 'completed' || phase === 'done' || phase === 'finished' || phase === 'end') {
|
||||
import('./chat')
|
||||
.then(({ useChatStore }) => {
|
||||
const state = useChatStore.getState();
|
||||
// Always reload history on agent completion, regardless of
|
||||
// the `sending` flag. After a transient error the flag may
|
||||
// already be false, but the Gateway may have retried and
|
||||
// completed successfully in the background.
|
||||
state.loadHistory(true);
|
||||
if (state.sending) {
|
||||
useChatStore.setState({
|
||||
sending: false,
|
||||
activeRunId: null,
|
||||
pendingFinal: false,
|
||||
lastUserMessageAt: null,
|
||||
});
|
||||
if (!gatewayEventSource) {
|
||||
gatewayEventSource = createHostEventSource();
|
||||
gatewayEventSource.addEventListener('gateway:status', (event) => {
|
||||
set({ status: JSON.parse((event as MessageEvent).data) as GatewayStatus });
|
||||
});
|
||||
gatewayEventSource.addEventListener('gateway:error', (event) => {
|
||||
const payload = JSON.parse((event as MessageEvent).data) as { message?: string };
|
||||
set({ lastError: payload.message || 'Gateway error' });
|
||||
});
|
||||
gatewayEventSource.addEventListener('gateway:notification', (event) => {
|
||||
handleGatewayNotification(JSON.parse((event as MessageEvent).data) as {
|
||||
method?: string;
|
||||
params?: Record<string, unknown>;
|
||||
});
|
||||
});
|
||||
gatewayEventSource.addEventListener('gateway:chat-message', (event) => {
|
||||
handleGatewayChatMessage(JSON.parse((event as MessageEvent).data));
|
||||
});
|
||||
gatewayEventSource.addEventListener('gateway:channel-status', (event) => {
|
||||
import('./channels')
|
||||
.then(({ useChannelsStore }) => {
|
||||
const update = JSON.parse((event as MessageEvent).data) as { channelId?: string; status?: string };
|
||||
if (!update.channelId || !update.status) return;
|
||||
const state = useChannelsStore.getState();
|
||||
const channel = state.channels.find((item) => item.type === update.channelId);
|
||||
if (channel) {
|
||||
state.updateChannel(channel.id, { status: mapChannelStatus(update.status) });
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for chat events from the gateway and forward to chat store.
|
||||
// The data arrives as { message: payload } from handleProtocolEvent.
|
||||
// The payload may be a full event wrapper ({ state, runId, message })
|
||||
// or the raw chat message itself. We need to handle both.
|
||||
window.electron.ipcRenderer.on('gateway:chat-message', (data) => {
|
||||
try {
|
||||
import('./chat').then(({ useChatStore }) => {
|
||||
const chatData = data as Record<string, unknown>;
|
||||
const payload = ('message' in chatData && typeof chatData.message === 'object')
|
||||
? chatData.message as Record<string, unknown>
|
||||
: chatData;
|
||||
|
||||
if (payload.state) {
|
||||
useChatStore.getState().handleChatEvent(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
// Raw message without state wrapper — treat as final
|
||||
useChatStore.getState().handleChatEvent({
|
||||
state: 'final',
|
||||
message: payload,
|
||||
runId: chatData.runId ?? payload.runId,
|
||||
});
|
||||
}).catch(() => {});
|
||||
} catch {
|
||||
// Silently ignore forwarding failures
|
||||
}
|
||||
});
|
||||
|
||||
// Catch-all: handle unmatched gateway messages that fell through
|
||||
// all protocol/notification handlers in the main process.
|
||||
// This prevents events from being silently lost.
|
||||
window.electron.ipcRenderer.on('gateway:message', (data) => {
|
||||
if (!data || typeof data !== 'object') return;
|
||||
const msg = data as Record<string, unknown>;
|
||||
|
||||
// Try to detect if this is a chat-related event and forward it
|
||||
if (msg.state && msg.message) {
|
||||
import('./chat').then(({ useChatStore }) => {
|
||||
useChatStore.getState().handleChatEvent(msg);
|
||||
}).catch(() => {});
|
||||
} else if (msg.role && msg.content) {
|
||||
import('./chat').then(({ useChatStore }) => {
|
||||
useChatStore.getState().handleChatEvent({
|
||||
state: 'final',
|
||||
message: msg,
|
||||
});
|
||||
}).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize Gateway:', error);
|
||||
set({ lastError: String(error) });
|
||||
@@ -198,25 +194,26 @@ export const useGatewayStore = create<GatewayState>((set, get) => ({
|
||||
start: async () => {
|
||||
try {
|
||||
set({ status: { ...get().status, state: 'starting' }, lastError: null });
|
||||
const result = await invokeIpc('gateway:start') as { success: boolean; error?: string };
|
||||
|
||||
const result = await hostApiFetch<{ success: boolean; error?: string }>('/api/gateway/start', {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!result.success) {
|
||||
set({
|
||||
status: { ...get().status, state: 'error', error: result.error },
|
||||
lastError: result.error || 'Failed to start Gateway'
|
||||
lastError: result.error || 'Failed to start Gateway',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
set({
|
||||
status: { ...get().status, state: 'error', error: String(error) },
|
||||
lastError: String(error)
|
||||
lastError: String(error),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
stop: async () => {
|
||||
try {
|
||||
await invokeIpc('gateway:stop');
|
||||
await hostApiFetch('/api/gateway/stop', { method: 'POST' });
|
||||
set({ status: { ...get().status, state: 'stopped' }, lastError: null });
|
||||
} catch (error) {
|
||||
console.error('Failed to stop Gateway:', error);
|
||||
@@ -227,39 +224,28 @@ export const useGatewayStore = create<GatewayState>((set, get) => ({
|
||||
restart: async () => {
|
||||
try {
|
||||
set({ status: { ...get().status, state: 'starting' }, lastError: null });
|
||||
const result = await invokeIpc('gateway:restart') as { success: boolean; error?: string };
|
||||
|
||||
const result = await hostApiFetch<{ success: boolean; error?: string }>('/api/gateway/restart', {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!result.success) {
|
||||
set({
|
||||
status: { ...get().status, state: 'error', error: result.error },
|
||||
lastError: result.error || 'Failed to restart Gateway'
|
||||
lastError: result.error || 'Failed to restart Gateway',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
set({
|
||||
status: { ...get().status, state: 'error', error: String(error) },
|
||||
lastError: String(error)
|
||||
lastError: String(error),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
checkHealth: async () => {
|
||||
try {
|
||||
const result = await invokeIpc('gateway:health') as {
|
||||
success: boolean;
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
uptime?: number
|
||||
};
|
||||
|
||||
const health: GatewayHealth = {
|
||||
ok: result.ok,
|
||||
error: result.error,
|
||||
uptime: result.uptime,
|
||||
};
|
||||
|
||||
set({ health });
|
||||
return health;
|
||||
const result = await hostApiFetch<GatewayHealth>('/api/gateway/health');
|
||||
set({ health: result });
|
||||
return result;
|
||||
} catch (error) {
|
||||
const health: GatewayHealth = { ok: false, error: String(error) };
|
||||
set({ health });
|
||||
@@ -268,20 +254,17 @@ export const useGatewayStore = create<GatewayState>((set, get) => ({
|
||||
},
|
||||
|
||||
rpc: async <T>(method: string, params?: unknown, timeoutMs?: number): Promise<T> => {
|
||||
const result = await invokeIpc('gateway:rpc', method, params, timeoutMs) as {
|
||||
const response = await invokeIpc<{
|
||||
success: boolean;
|
||||
result?: T;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || `RPC call failed: ${method}`);
|
||||
}>('gateway:rpc', method, params, timeoutMs);
|
||||
if (!response.success) {
|
||||
throw new Error(response.error || `Gateway RPC failed: ${method}`);
|
||||
}
|
||||
|
||||
return result.result as T;
|
||||
return response.result as T;
|
||||
},
|
||||
|
||||
setStatus: (status) => set({ status }),
|
||||
|
||||
clearError: () => set({ lastError: null }),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user