diff --git a/.gitignore b/.gitignore index a2dcb7eba..710c17300 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ yarn-error.log* # OS files .DS_Store Thumbs.db +desktop.ini # Test coverage coverage/ diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 2b50aa920..19c0b8df0 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -60,27 +60,14 @@ import { } from '../services/providers/provider-runtime-sync'; import { validateApiKeyWithProvider } from '../services/providers/provider-validation'; import { appUpdater } from './updater'; -import { PORTS } from '../utils/config'; - -type AppRequest = { - id?: string; - module: string; - action: string; - payload?: unknown; -}; - -type AppErrorCode = 'VALIDATION' | 'PERMISSION' | 'TIMEOUT' | 'GATEWAY' | 'INTERNAL' | 'UNSUPPORTED'; - -type AppResponse = { - id?: string; - ok: boolean; - data?: unknown; - error?: { - code: AppErrorCode; - message: string; - details?: unknown; - }; -}; +import { registerHostApiProxyHandlers } from './ipc/host-api-proxy'; +import { + isLaunchAtStartupKey, + isProxyKey, + mapAppErrorCode, + type AppRequest, + type AppResponse, +} from './ipc/request-helpers'; /** * Register all IPC handlers @@ -151,92 +138,6 @@ export function registerIpcHandlers( registerFileHandlers(); } -type HostApiFetchRequest = { - path: string; - method?: string; - headers?: Record; - body?: unknown; -}; - -function registerHostApiProxyHandlers(): void { - ipcMain.handle('hostapi:fetch', async (_, request: HostApiFetchRequest) => { - try { - const path = typeof request?.path === 'string' ? request.path : ''; - if (!path || !path.startsWith('/')) { - throw new Error(`Invalid host API path: ${String(request?.path)}`); - } - - const method = (request.method || 'GET').toUpperCase(); - const headers: Record = { ...(request.headers || {}) }; - let body: string | undefined; - - if (request.body !== undefined && request.body !== null) { - if (typeof request.body === 'string') { - body = request.body; - } else { - body = JSON.stringify(request.body); - if (!headers['Content-Type'] && !headers['content-type']) { - headers['Content-Type'] = 'application/json'; - } - } - } - - const response = await proxyAwareFetch(`http://127.0.0.1:${PORTS.CLAWX_HOST_API}${path}`, { - method, - headers, - body, - }); - - const data: { status: number; ok: boolean; json?: unknown; text?: string } = { - status: response.status, - ok: response.ok, - }; - - if (response.status !== 204) { - const contentType = response.headers.get('content-type') || ''; - if (contentType.includes('application/json')) { - data.json = await response.json().catch(() => undefined); - } else { - data.text = await response.text().catch(() => ''); - } - } - - return { ok: true, data }; - } catch (error) { - return { - ok: false, - error: { - message: error instanceof Error ? error.message : String(error), - }, - }; - } - }); -} - -function mapAppErrorCode(error: unknown): AppErrorCode { - const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase(); - if (msg.includes('timeout')) return 'TIMEOUT'; - if (msg.includes('permission') || msg.includes('denied') || msg.includes('forbidden')) return 'PERMISSION'; - if (msg.includes('gateway')) return 'GATEWAY'; - if (msg.includes('invalid') || msg.includes('required')) return 'VALIDATION'; - return 'INTERNAL'; -} - -function isProxyKey(key: keyof AppSettings): boolean { - return ( - key === 'proxyEnabled' || - key === 'proxyServer' || - key === 'proxyHttpServer' || - key === 'proxyHttpsServer' || - key === 'proxyAllServer' || - key === 'proxyBypassRules' - ); -} - -function isLaunchAtStartupKey(key: keyof AppSettings): boolean { - return key === 'launchAtStartup'; -} - function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void { const providerService = getProviderService(); const handleProxySettingsChange = async () => { diff --git a/electron/main/ipc/host-api-proxy.ts b/electron/main/ipc/host-api-proxy.ts new file mode 100644 index 000000000..40b71ff7c --- /dev/null +++ b/electron/main/ipc/host-api-proxy.ts @@ -0,0 +1,65 @@ +import { ipcMain } from 'electron'; +import { proxyAwareFetch } from '../../utils/proxy-fetch'; +import { PORTS } from '../../utils/config'; + +type HostApiFetchRequest = { + path: string; + method?: string; + headers?: Record; + body?: unknown; +}; + +export function registerHostApiProxyHandlers(): void { + ipcMain.handle('hostapi:fetch', async (_, request: HostApiFetchRequest) => { + try { + const path = typeof request?.path === 'string' ? request.path : ''; + if (!path || !path.startsWith('/')) { + throw new Error(`Invalid host API path: ${String(request?.path)}`); + } + + const method = (request.method || 'GET').toUpperCase(); + const headers: Record = { ...(request.headers || {}) }; + let body: string | undefined; + + if (request.body !== undefined && request.body !== null) { + if (typeof request.body === 'string') { + body = request.body; + } else { + body = JSON.stringify(request.body); + if (!headers['Content-Type'] && !headers['content-type']) { + headers['Content-Type'] = 'application/json'; + } + } + } + + const response = await proxyAwareFetch(`http://127.0.0.1:${PORTS.CLAWX_HOST_API}${path}`, { + method, + headers, + body, + }); + + const data: { status: number; ok: boolean; json?: unknown; text?: string } = { + status: response.status, + ok: response.ok, + }; + + if (response.status !== 204) { + const contentType = response.headers.get('content-type') || ''; + if (contentType.includes('application/json')) { + data.json = await response.json().catch(() => undefined); + } else { + data.text = await response.text().catch(() => ''); + } + } + + return { ok: true, data }; + } catch (error) { + return { + ok: false, + error: { + message: error instanceof Error ? error.message : String(error), + }, + }; + } + }); +} diff --git a/electron/main/ipc/request-helpers.ts b/electron/main/ipc/request-helpers.ts new file mode 100644 index 000000000..349a12843 --- /dev/null +++ b/electron/main/ipc/request-helpers.ts @@ -0,0 +1,45 @@ +import type { AppSettings } from '../../utils/store'; + +export type AppRequest = { + id?: string; + module: string; + action: string; + payload?: unknown; +}; + +export type AppErrorCode = 'VALIDATION' | 'PERMISSION' | 'TIMEOUT' | 'GATEWAY' | 'INTERNAL' | 'UNSUPPORTED'; + +export type AppResponse = { + id?: string; + ok: boolean; + data?: unknown; + error?: { + code: AppErrorCode; + message: string; + details?: unknown; + }; +}; + +export function mapAppErrorCode(error: unknown): AppErrorCode { + const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase(); + if (msg.includes('timeout')) return 'TIMEOUT'; + if (msg.includes('permission') || msg.includes('denied') || msg.includes('forbidden')) return 'PERMISSION'; + if (msg.includes('gateway')) return 'GATEWAY'; + if (msg.includes('invalid') || msg.includes('required')) return 'VALIDATION'; + return 'INTERNAL'; +} + +export function isProxyKey(key: keyof AppSettings): boolean { + return ( + key === 'proxyEnabled' || + key === 'proxyServer' || + key === 'proxyHttpServer' || + key === 'proxyHttpsServer' || + key === 'proxyAllServer' || + key === 'proxyBypassRules' + ); +} + +export function isLaunchAtStartupKey(key: keyof AppSettings): boolean { + return key === 'launchAtStartup'; +} diff --git a/test-anthropic-url.js b/scripts/manual/test-anthropic-url.js similarity index 100% rename from test-anthropic-url.js rename to scripts/manual/test-anthropic-url.js diff --git a/test-anthropic.js b/scripts/manual/test-anthropic.js similarity index 100% rename from test-anthropic.js rename to scripts/manual/test-anthropic.js diff --git a/src/stores/chat.ts b/src/stores/chat.ts index aa7b36f30..748e35f42 100644 --- a/src/stores/chat.ts +++ b/src/stores/chat.ts @@ -8,116 +8,24 @@ import { hostApiFetch } from '@/lib/host-api'; import { useGatewayStore } from './gateway'; import { useAgentsStore } from './agents'; import { buildCronSessionHistoryPath, isCronSessionKey } from './chat/cron-session-utils'; +import { + DEFAULT_CANONICAL_PREFIX, + DEFAULT_SESSION_KEY, + type AttachedFileMeta, + type ChatSession, + type ChatState, + type ContentBlock, + type RawMessage, + type ToolStatus, +} from './chat/types'; -// ── Types ──────────────────────────────────────────────────────── - -/** Metadata for locally-attached files (not from Gateway) */ -export interface AttachedFileMeta { - fileName: string; - mimeType: string; - fileSize: number; - preview: string | null; - filePath?: string; -} - -/** Raw message from OpenClaw chat.history */ -export interface RawMessage { - role: 'user' | 'assistant' | 'system' | 'toolresult'; - content: unknown; // string | ContentBlock[] - timestamp?: number; - id?: string; - toolCallId?: string; - toolName?: string; - details?: unknown; - isError?: boolean; - /** Local-only: file metadata for user-uploaded attachments (not sent to/from Gateway) */ - _attachedFiles?: AttachedFileMeta[]; -} - -/** Content block inside a message */ -export interface ContentBlock { - type: 'text' | 'image' | 'thinking' | 'tool_use' | 'tool_result' | 'toolCall' | 'toolResult'; - text?: string; - thinking?: string; - source?: { type: string; media_type?: string; data?: string; url?: string }; - /** Flat image format from Gateway tool results (no source wrapper) */ - data?: string; - mimeType?: string; - id?: string; - name?: string; - input?: unknown; - arguments?: unknown; - content?: unknown; -} - -/** Session from sessions.list */ -export interface ChatSession { - key: string; - label?: string; - displayName?: string; - thinkingLevel?: string; - model?: string; - updatedAt?: number; -} - -export interface ToolStatus { - id?: string; - toolCallId?: string; - name: string; - status: 'running' | 'completed' | 'error'; - durationMs?: number; - summary?: string; - updatedAt: number; -} - -interface ChatState { - // Messages - messages: RawMessage[]; - loading: boolean; - error: string | null; - - // Streaming - sending: boolean; - activeRunId: string | null; - streamingText: string; - streamingMessage: unknown | null; - streamingTools: ToolStatus[]; - pendingFinal: boolean; - lastUserMessageAt: number | null; - /** Images collected from tool results, attached to the next assistant message */ - pendingToolImages: AttachedFileMeta[]; - - // Sessions - sessions: ChatSession[]; - currentSessionKey: string; - currentAgentId: string; - /** First user message text per session key, used as display label */ - sessionLabels: Record; - /** Last message timestamp (ms) per session key, used for sorting */ - sessionLastActivity: Record; - - // Thinking - showThinking: boolean; - thinkingLevel: string | null; - - // Actions - loadSessions: () => Promise; - switchSession: (key: string) => void; - newSession: () => void; - deleteSession: (key: string) => Promise; - cleanupEmptySession: () => void; - loadHistory: (quiet?: boolean) => Promise; - sendMessage: ( - text: string, - attachments?: Array<{ fileName: string; mimeType: string; fileSize: number; stagedPath: string; preview: string | null }>, - targetAgentId?: string | null, - ) => Promise; - abortRun: () => Promise; - handleChatEvent: (event: Record) => void; - toggleThinking: () => void; - refresh: () => Promise; - clearError: () => void; -} +export type { + AttachedFileMeta, + ChatSession, + ContentBlock, + RawMessage, + ToolStatus, +} from './chat/types'; // Module-level timestamp tracking the last chat event received. // Used by the safety timeout to avoid false-positive "no response" errors @@ -204,9 +112,6 @@ function isDuplicateChatEvent(eventState: string, event: Record return false; } -const DEFAULT_CANONICAL_PREFIX = 'agent:main'; -const DEFAULT_SESSION_KEY = `${DEFAULT_CANONICAL_PREFIX}:main`; - // ── Local image cache ───────────────────────────────────────── // The Gateway doesn't store image attachments in session content blocks, // so we cache them locally keyed by staged file path (which appears in the