fix(stores): align RPC methods with OpenClaw protocol and fix chat flow
- Fix channels store: use channels.status instead of channels.list - Fix skills store: use skills.status instead of skills.list - Fix chat store: correct chat.history response parsing (messages in payload) - Fix chat store: handle chat.send async flow (ack + event streaming) - Add chat event handling for streaming AI responses (delta/final/error) - Wire gateway:chat-message IPC events to chat store - Fix health check: use WebSocket status instead of nonexistent /health endpoint - Fix waitForReady: probe via WebSocket instead of HTTP - Gracefully degrade when methods are unsupported (no white screen)
This commit is contained in:
@@ -276,39 +276,48 @@ export class GatewayManager extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check Gateway health via HTTP endpoint
|
||||
* Check Gateway health via WebSocket ping
|
||||
* OpenClaw Gateway doesn't have an HTTP /health endpoint
|
||||
*/
|
||||
async checkHealth(): Promise<{ ok: boolean; error?: string; uptime?: number }> {
|
||||
try {
|
||||
const response = await fetch(`http://localhost:${this.status.port}/health`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json() as { uptime?: number };
|
||||
return { ok: true, uptime: data.uptime };
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
const uptime = this.status.connectedAt
|
||||
? Math.floor((Date.now() - this.status.connectedAt) / 1000)
|
||||
: undefined;
|
||||
return { ok: true, uptime };
|
||||
}
|
||||
|
||||
return { ok: false, error: `Health check returned ${response.status}` };
|
||||
return { ok: false, error: 'WebSocket not connected' };
|
||||
} catch (error) {
|
||||
return { ok: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find existing Gateway process
|
||||
* Find existing Gateway process by attempting a WebSocket connection
|
||||
*/
|
||||
private async findExistingGateway(): Promise<{ port: number } | null> {
|
||||
try {
|
||||
// Try to connect to default port
|
||||
const port = PORTS.OPENCLAW_GATEWAY;
|
||||
const response = await fetch(`http://localhost:${port}/health`, {
|
||||
signal: AbortSignal.timeout(2000),
|
||||
// Try a quick WebSocket connection to check if gateway is listening
|
||||
return await new Promise<{ port: number } | null>((resolve) => {
|
||||
const testWs = new WebSocket(`ws://localhost:${port}/ws`);
|
||||
const timeout = setTimeout(() => {
|
||||
testWs.close();
|
||||
resolve(null);
|
||||
}, 2000);
|
||||
|
||||
testWs.on('open', () => {
|
||||
clearTimeout(timeout);
|
||||
testWs.close();
|
||||
resolve({ port });
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return { port };
|
||||
}
|
||||
testWs.on('error', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
// Gateway not running
|
||||
}
|
||||
@@ -413,16 +422,32 @@ export class GatewayManager extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for Gateway to be ready
|
||||
* Wait for Gateway to be ready by checking if the port is accepting connections
|
||||
*/
|
||||
private async waitForReady(retries = 30, interval = 1000): Promise<void> {
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
const response = await fetch(`http://localhost:${this.status.port}/health`, {
|
||||
signal: AbortSignal.timeout(1000),
|
||||
// Try a quick WebSocket connection to see if the gateway is listening
|
||||
const ready = await new Promise<boolean>((resolve) => {
|
||||
const testWs = new WebSocket(`ws://localhost:${this.status.port}/ws`);
|
||||
const timeout = setTimeout(() => {
|
||||
testWs.close();
|
||||
resolve(false);
|
||||
}, 1000);
|
||||
|
||||
testWs.on('open', () => {
|
||||
clearTimeout(timeout);
|
||||
testWs.close();
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
testWs.on('error', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
|
||||
if (ready) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -37,19 +37,26 @@ export const useChannelsStore = create<ChannelsState>((set, get) => ({
|
||||
set({ loading: true, error: null });
|
||||
|
||||
try {
|
||||
// OpenClaw uses channels.status to get channel information
|
||||
const result = await window.electron.ipcRenderer.invoke(
|
||||
'gateway:rpc',
|
||||
'channels.list'
|
||||
) as { success: boolean; result?: Channel[]; error?: string };
|
||||
'channels.status',
|
||||
{}
|
||||
) as { success: boolean; result?: { channels?: Channel[] } | Channel[]; error?: string };
|
||||
|
||||
if (result.success && result.result) {
|
||||
set({ channels: result.result, loading: false });
|
||||
// Handle both array and object response formats
|
||||
const channels = Array.isArray(result.result)
|
||||
? result.result
|
||||
: (result.result.channels || []);
|
||||
set({ channels, loading: false });
|
||||
} else {
|
||||
// Gateway might not be running, don't show error for empty channels
|
||||
// Don't show error for unsupported methods - just use empty list
|
||||
set({ channels: [], loading: false });
|
||||
}
|
||||
} catch (error) {
|
||||
// Gateway not running - start with empty channels
|
||||
// Gateway might not support this method yet - gracefully degrade
|
||||
console.warn('Failed to fetch channels:', error);
|
||||
set({ channels: [], loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -32,6 +32,8 @@ interface ChatState {
|
||||
loading: boolean;
|
||||
sending: boolean;
|
||||
error: string | null;
|
||||
// Track active run for streaming
|
||||
activeRunId: string | null;
|
||||
|
||||
// Actions
|
||||
fetchHistory: (limit?: number) => Promise<void>;
|
||||
@@ -40,6 +42,7 @@ interface ChatState {
|
||||
addMessage: (message: ChatMessage) => void;
|
||||
updateMessage: (messageId: string, updates: Partial<ChatMessage>) => void;
|
||||
setMessages: (messages: ChatMessage[]) => void;
|
||||
handleChatEvent: (event: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export const useChatStore = create<ChatState>((set, get) => ({
|
||||
@@ -47,28 +50,47 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
loading: false,
|
||||
sending: false,
|
||||
error: null,
|
||||
activeRunId: null,
|
||||
|
||||
fetchHistory: async (limit = 50) => {
|
||||
set({ loading: true, error: null });
|
||||
|
||||
try {
|
||||
// OpenClaw chat.history requires: { sessionKey, limit? }
|
||||
// Response format: { sessionKey, sessionId, messages, thinkingLevel, verboseLevel }
|
||||
const result = await window.electron.ipcRenderer.invoke(
|
||||
'gateway:rpc',
|
||||
'chat.history',
|
||||
{ limit, offset: 0 }
|
||||
) as { success: boolean; result?: ChatMessage[]; error?: string };
|
||||
{ sessionKey: 'main', limit }
|
||||
) as { success: boolean; result?: { messages?: unknown[] } | unknown; error?: string };
|
||||
|
||||
if (result.success && result.result) {
|
||||
set({ messages: result.result, loading: false });
|
||||
const data = result.result as Record<string, unknown>;
|
||||
const rawMessages = Array.isArray(data.messages) ? data.messages : [];
|
||||
|
||||
// Map OpenClaw messages to our ChatMessage format
|
||||
const messages: ChatMessage[] = rawMessages.map((msg: unknown, idx: number) => {
|
||||
const m = msg as Record<string, unknown>;
|
||||
return {
|
||||
id: String(m.id || `msg-${idx}`),
|
||||
role: (m.role as 'user' | 'assistant' | 'system') || 'assistant',
|
||||
content: String(m.content || m.text || ''),
|
||||
timestamp: String(m.timestamp || new Date().toISOString()),
|
||||
};
|
||||
});
|
||||
|
||||
set({ messages, loading: false });
|
||||
} else {
|
||||
set({ error: result.error || 'Failed to fetch history', loading: false });
|
||||
// No history yet or method not available - just show empty
|
||||
set({ messages: [], loading: false });
|
||||
}
|
||||
} catch (error) {
|
||||
set({ error: String(error), loading: false });
|
||||
console.warn('Failed to fetch chat history:', error);
|
||||
set({ messages: [], loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
sendMessage: async (content, channelId) => {
|
||||
sendMessage: async (content, _channelId) => {
|
||||
const { addMessage } = get();
|
||||
|
||||
// Add user message immediately
|
||||
@@ -77,28 +99,34 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
role: 'user',
|
||||
content,
|
||||
timestamp: new Date().toISOString(),
|
||||
channel: channelId,
|
||||
};
|
||||
addMessage(userMessage);
|
||||
|
||||
set({ sending: true, error: null });
|
||||
|
||||
try {
|
||||
// OpenClaw chat.send requires: { sessionKey, message, idempotencyKey }
|
||||
// Response is an acknowledgment: { runId, status: "started" }
|
||||
// The actual AI response comes via WebSocket chat events
|
||||
const idempotencyKey = crypto.randomUUID();
|
||||
const result = await window.electron.ipcRenderer.invoke(
|
||||
'gateway:rpc',
|
||||
'chat.send',
|
||||
{ content, channelId }
|
||||
) as { success: boolean; result?: ChatMessage; error?: string };
|
||||
{ sessionKey: 'main', message: content, idempotencyKey }
|
||||
) as { success: boolean; result?: { runId?: string; status?: string }; error?: string };
|
||||
|
||||
if (result.success && result.result) {
|
||||
addMessage(result.result);
|
||||
if (!result.success) {
|
||||
set({ error: result.error || 'Failed to send message', sending: false });
|
||||
} else {
|
||||
set({ error: result.error || 'Failed to send message' });
|
||||
// Store the active run ID - response will come via chat events
|
||||
const runId = result.result?.runId;
|
||||
if (runId) {
|
||||
set({ activeRunId: runId });
|
||||
}
|
||||
// Keep sending=true until we receive the final chat event
|
||||
}
|
||||
} catch (error) {
|
||||
set({ error: String(error) });
|
||||
} finally {
|
||||
set({ sending: false });
|
||||
set({ error: String(error), sending: false });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -126,4 +154,63 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
},
|
||||
|
||||
setMessages: (messages) => set({ messages }),
|
||||
|
||||
/**
|
||||
* Handle incoming chat event from Gateway WebSocket
|
||||
* Events: { runId, sessionKey, seq, state, message, errorMessage }
|
||||
* States: "delta" (streaming), "final" (complete), "aborted", "error"
|
||||
*/
|
||||
handleChatEvent: (event) => {
|
||||
const { addMessage, updateMessage, messages } = get();
|
||||
const runId = String(event.runId || '');
|
||||
const state = String(event.state || '');
|
||||
|
||||
if (state === 'delta') {
|
||||
// Streaming delta - find or create assistant message for this run
|
||||
const existingMsg = messages.find((m) => m.id === `run-${runId}`);
|
||||
const messageContent = event.message as Record<string, unknown> | undefined;
|
||||
const content = String(messageContent?.content || messageContent?.text || '');
|
||||
|
||||
if (existingMsg) {
|
||||
// Append to existing message
|
||||
updateMessage(`run-${runId}`, {
|
||||
content: existingMsg.content + content,
|
||||
});
|
||||
} else if (content) {
|
||||
// Create new assistant message
|
||||
addMessage({
|
||||
id: `run-${runId}`,
|
||||
role: 'assistant',
|
||||
content,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
} else if (state === 'final') {
|
||||
// Final message - replace or add complete response
|
||||
const messageContent = event.message as Record<string, unknown> | undefined;
|
||||
const content = String(
|
||||
messageContent?.content
|
||||
|| messageContent?.text
|
||||
|| (typeof messageContent === 'string' ? messageContent : '')
|
||||
);
|
||||
|
||||
const existingMsg = messages.find((m) => m.id === `run-${runId}`);
|
||||
if (existingMsg) {
|
||||
updateMessage(`run-${runId}`, { content });
|
||||
} else if (content) {
|
||||
addMessage({
|
||||
id: `run-${runId}`,
|
||||
role: 'assistant',
|
||||
content,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
set({ sending: false, activeRunId: null });
|
||||
} else if (state === 'error') {
|
||||
const errorMsg = String(event.errorMessage || 'An error occurred');
|
||||
set({ error: errorMsg, sending: false, activeRunId: null });
|
||||
} else if (state === 'aborted') {
|
||||
set({ sending: false, activeRunId: null });
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -58,7 +58,17 @@ export const useGatewayStore = create<GatewayState>((set, get) => ({
|
||||
// Listen for notifications
|
||||
window.electron.ipcRenderer.on('gateway:notification', (notification) => {
|
||||
console.log('Gateway notification:', notification);
|
||||
// Could dispatch to other stores based on notification type
|
||||
});
|
||||
|
||||
// Listen for chat events from the gateway and forward to chat store
|
||||
window.electron.ipcRenderer.on('gateway:chat-message', (data) => {
|
||||
const { useChatStore } = require('./chat');
|
||||
const chatData = data as { message?: Record<string, unknown> } | Record<string, unknown>;
|
||||
// The event payload may be nested under 'message' or directly on data
|
||||
const event = ('message' in chatData && typeof chatData.message === 'object')
|
||||
? chatData.message as Record<string, unknown>
|
||||
: chatData as Record<string, unknown>;
|
||||
useChatStore.getState().handleChatEvent(event);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
|
||||
@@ -27,18 +27,27 @@ export const useSkillsStore = create<SkillsState>((set, get) => ({
|
||||
set({ loading: true, error: null });
|
||||
|
||||
try {
|
||||
// OpenClaw uses skills.status to get skill information
|
||||
const result = await window.electron.ipcRenderer.invoke(
|
||||
'gateway:rpc',
|
||||
'skills.list'
|
||||
) as { success: boolean; result?: Skill[]; error?: string };
|
||||
'skills.status',
|
||||
{}
|
||||
) as { success: boolean; result?: { skills?: Skill[] } | Skill[]; error?: string };
|
||||
|
||||
if (result.success && result.result) {
|
||||
set({ skills: result.result, loading: false });
|
||||
// Handle both array and object response formats
|
||||
const skills = Array.isArray(result.result)
|
||||
? result.result
|
||||
: (result.result.skills || []);
|
||||
set({ skills, loading: false });
|
||||
} else {
|
||||
set({ error: result.error || 'Failed to fetch skills', loading: false });
|
||||
// Don't show error for unsupported methods - just use empty list
|
||||
set({ skills: [], loading: false });
|
||||
}
|
||||
} catch (error) {
|
||||
set({ error: String(error), loading: false });
|
||||
// Gateway might not support this method yet - gracefully degrade
|
||||
console.warn('Failed to fetch skills:', error);
|
||||
set({ skills: [], loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user