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 }> {
|
async checkHealth(): Promise<{ ok: boolean; error?: string; uptime?: number }> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`http://localhost:${this.status.port}/health`, {
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||||
signal: AbortSignal.timeout(5000),
|
const uptime = this.status.connectedAt
|
||||||
});
|
? Math.floor((Date.now() - this.status.connectedAt) / 1000)
|
||||||
|
: undefined;
|
||||||
if (response.ok) {
|
return { ok: true, uptime };
|
||||||
const data = await response.json() as { uptime?: number };
|
|
||||||
return { ok: true, uptime: data.uptime };
|
|
||||||
}
|
}
|
||||||
|
return { ok: false, error: 'WebSocket not connected' };
|
||||||
return { ok: false, error: `Health check returned ${response.status}` };
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { ok: false, error: String(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> {
|
private async findExistingGateway(): Promise<{ port: number } | null> {
|
||||||
try {
|
try {
|
||||||
// Try to connect to default port
|
|
||||||
const port = PORTS.OPENCLAW_GATEWAY;
|
const port = PORTS.OPENCLAW_GATEWAY;
|
||||||
const response = await fetch(`http://localhost:${port}/health`, {
|
// Try a quick WebSocket connection to check if gateway is listening
|
||||||
signal: AbortSignal.timeout(2000),
|
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 });
|
||||||
|
});
|
||||||
|
|
||||||
|
testWs.on('error', () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve(null);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
return { port };
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// Gateway not running
|
// 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> {
|
private async waitForReady(retries = 30, interval = 1000): Promise<void> {
|
||||||
for (let i = 0; i < retries; i++) {
|
for (let i = 0; i < retries; i++) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`http://localhost:${this.status.port}/health`, {
|
// Try a quick WebSocket connection to see if the gateway is listening
|
||||||
signal: AbortSignal.timeout(1000),
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWs.on('error', () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (ready) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -37,19 +37,26 @@ export const useChannelsStore = create<ChannelsState>((set, get) => ({
|
|||||||
set({ loading: true, error: null });
|
set({ loading: true, error: null });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// OpenClaw uses channels.status to get channel information
|
||||||
const result = await window.electron.ipcRenderer.invoke(
|
const result = await window.electron.ipcRenderer.invoke(
|
||||||
'gateway:rpc',
|
'gateway:rpc',
|
||||||
'channels.list'
|
'channels.status',
|
||||||
) as { success: boolean; result?: Channel[]; error?: string };
|
{}
|
||||||
|
) as { success: boolean; result?: { channels?: Channel[] } | Channel[]; error?: string };
|
||||||
|
|
||||||
if (result.success && result.result) {
|
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 {
|
} 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 });
|
set({ channels: [], loading: false });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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 });
|
set({ channels: [], loading: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ interface ChatState {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
sending: boolean;
|
sending: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
// Track active run for streaming
|
||||||
|
activeRunId: string | null;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
fetchHistory: (limit?: number) => Promise<void>;
|
fetchHistory: (limit?: number) => Promise<void>;
|
||||||
@@ -40,6 +42,7 @@ interface ChatState {
|
|||||||
addMessage: (message: ChatMessage) => void;
|
addMessage: (message: ChatMessage) => void;
|
||||||
updateMessage: (messageId: string, updates: Partial<ChatMessage>) => void;
|
updateMessage: (messageId: string, updates: Partial<ChatMessage>) => void;
|
||||||
setMessages: (messages: ChatMessage[]) => void;
|
setMessages: (messages: ChatMessage[]) => void;
|
||||||
|
handleChatEvent: (event: Record<string, unknown>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useChatStore = create<ChatState>((set, get) => ({
|
export const useChatStore = create<ChatState>((set, get) => ({
|
||||||
@@ -47,28 +50,47 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
loading: false,
|
loading: false,
|
||||||
sending: false,
|
sending: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
activeRunId: null,
|
||||||
|
|
||||||
fetchHistory: async (limit = 50) => {
|
fetchHistory: async (limit = 50) => {
|
||||||
set({ loading: true, error: null });
|
set({ loading: true, error: null });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// OpenClaw chat.history requires: { sessionKey, limit? }
|
||||||
|
// Response format: { sessionKey, sessionId, messages, thinkingLevel, verboseLevel }
|
||||||
const result = await window.electron.ipcRenderer.invoke(
|
const result = await window.electron.ipcRenderer.invoke(
|
||||||
'gateway:rpc',
|
'gateway:rpc',
|
||||||
'chat.history',
|
'chat.history',
|
||||||
{ limit, offset: 0 }
|
{ sessionKey: 'main', limit }
|
||||||
) as { success: boolean; result?: ChatMessage[]; error?: string };
|
) as { success: boolean; result?: { messages?: unknown[] } | unknown; error?: string };
|
||||||
|
|
||||||
if (result.success && result.result) {
|
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 {
|
} 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) {
|
} 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();
|
const { addMessage } = get();
|
||||||
|
|
||||||
// Add user message immediately
|
// Add user message immediately
|
||||||
@@ -77,28 +99,34 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
role: 'user',
|
role: 'user',
|
||||||
content,
|
content,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
channel: channelId,
|
|
||||||
};
|
};
|
||||||
addMessage(userMessage);
|
addMessage(userMessage);
|
||||||
|
|
||||||
set({ sending: true, error: null });
|
set({ sending: true, error: null });
|
||||||
|
|
||||||
try {
|
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(
|
const result = await window.electron.ipcRenderer.invoke(
|
||||||
'gateway:rpc',
|
'gateway:rpc',
|
||||||
'chat.send',
|
'chat.send',
|
||||||
{ content, channelId }
|
{ sessionKey: 'main', message: content, idempotencyKey }
|
||||||
) as { success: boolean; result?: ChatMessage; error?: string };
|
) as { success: boolean; result?: { runId?: string; status?: string }; error?: string };
|
||||||
|
|
||||||
if (result.success && result.result) {
|
if (!result.success) {
|
||||||
addMessage(result.result);
|
set({ error: result.error || 'Failed to send message', sending: false });
|
||||||
} else {
|
} 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) {
|
} catch (error) {
|
||||||
set({ error: String(error) });
|
set({ error: String(error), sending: false });
|
||||||
} finally {
|
|
||||||
set({ sending: false });
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -126,4 +154,63 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
setMessages: (messages) => set({ messages }),
|
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
|
// Listen for notifications
|
||||||
window.electron.ipcRenderer.on('gateway:notification', (notification) => {
|
window.electron.ipcRenderer.on('gateway:notification', (notification) => {
|
||||||
console.log('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) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -27,18 +27,27 @@ export const useSkillsStore = create<SkillsState>((set, get) => ({
|
|||||||
set({ loading: true, error: null });
|
set({ loading: true, error: null });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// OpenClaw uses skills.status to get skill information
|
||||||
const result = await window.electron.ipcRenderer.invoke(
|
const result = await window.electron.ipcRenderer.invoke(
|
||||||
'gateway:rpc',
|
'gateway:rpc',
|
||||||
'skills.list'
|
'skills.status',
|
||||||
) as { success: boolean; result?: Skill[]; error?: string };
|
{}
|
||||||
|
) as { success: boolean; result?: { skills?: Skill[] } | Skill[]; error?: string };
|
||||||
|
|
||||||
if (result.success && result.result) {
|
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 {
|
} 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) {
|
} 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