chore: normalize structure and split ipc handlers (#590)
Co-authored-by: Haze <709547807@qq.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
884aa7c7f1
commit
6b82c6ccb4
1
.gitignore
vendored
1
.gitignore
vendored
@@ -35,6 +35,7 @@ yarn-error.log*
|
|||||||
# OS files
|
# OS files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
desktop.ini
|
||||||
|
|
||||||
# Test coverage
|
# Test coverage
|
||||||
coverage/
|
coverage/
|
||||||
|
|||||||
@@ -60,27 +60,14 @@ import {
|
|||||||
} from '../services/providers/provider-runtime-sync';
|
} from '../services/providers/provider-runtime-sync';
|
||||||
import { validateApiKeyWithProvider } from '../services/providers/provider-validation';
|
import { validateApiKeyWithProvider } from '../services/providers/provider-validation';
|
||||||
import { appUpdater } from './updater';
|
import { appUpdater } from './updater';
|
||||||
import { PORTS } from '../utils/config';
|
import { registerHostApiProxyHandlers } from './ipc/host-api-proxy';
|
||||||
|
import {
|
||||||
type AppRequest = {
|
isLaunchAtStartupKey,
|
||||||
id?: string;
|
isProxyKey,
|
||||||
module: string;
|
mapAppErrorCode,
|
||||||
action: string;
|
type AppRequest,
|
||||||
payload?: unknown;
|
type AppResponse,
|
||||||
};
|
} from './ipc/request-helpers';
|
||||||
|
|
||||||
type AppErrorCode = 'VALIDATION' | 'PERMISSION' | 'TIMEOUT' | 'GATEWAY' | 'INTERNAL' | 'UNSUPPORTED';
|
|
||||||
|
|
||||||
type AppResponse = {
|
|
||||||
id?: string;
|
|
||||||
ok: boolean;
|
|
||||||
data?: unknown;
|
|
||||||
error?: {
|
|
||||||
code: AppErrorCode;
|
|
||||||
message: string;
|
|
||||||
details?: unknown;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register all IPC handlers
|
* Register all IPC handlers
|
||||||
@@ -151,92 +138,6 @@ export function registerIpcHandlers(
|
|||||||
registerFileHandlers();
|
registerFileHandlers();
|
||||||
}
|
}
|
||||||
|
|
||||||
type HostApiFetchRequest = {
|
|
||||||
path: string;
|
|
||||||
method?: string;
|
|
||||||
headers?: Record<string, string>;
|
|
||||||
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<string, string> = { ...(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 {
|
function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
|
||||||
const providerService = getProviderService();
|
const providerService = getProviderService();
|
||||||
const handleProxySettingsChange = async () => {
|
const handleProxySettingsChange = async () => {
|
||||||
|
|||||||
65
electron/main/ipc/host-api-proxy.ts
Normal file
65
electron/main/ipc/host-api-proxy.ts
Normal file
@@ -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<string, string>;
|
||||||
|
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<string, string> = { ...(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),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
45
electron/main/ipc/request-helpers.ts
Normal file
45
electron/main/ipc/request-helpers.ts
Normal file
@@ -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';
|
||||||
|
}
|
||||||
@@ -8,116 +8,24 @@ import { hostApiFetch } from '@/lib/host-api';
|
|||||||
import { useGatewayStore } from './gateway';
|
import { useGatewayStore } from './gateway';
|
||||||
import { useAgentsStore } from './agents';
|
import { useAgentsStore } from './agents';
|
||||||
import { buildCronSessionHistoryPath, isCronSessionKey } from './chat/cron-session-utils';
|
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 ────────────────────────────────────────────────────────
|
export type {
|
||||||
|
AttachedFileMeta,
|
||||||
/** Metadata for locally-attached files (not from Gateway) */
|
ChatSession,
|
||||||
export interface AttachedFileMeta {
|
ContentBlock,
|
||||||
fileName: string;
|
RawMessage,
|
||||||
mimeType: string;
|
ToolStatus,
|
||||||
fileSize: number;
|
} from './chat/types';
|
||||||
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<string, string>;
|
|
||||||
/** Last message timestamp (ms) per session key, used for sorting */
|
|
||||||
sessionLastActivity: Record<string, number>;
|
|
||||||
|
|
||||||
// Thinking
|
|
||||||
showThinking: boolean;
|
|
||||||
thinkingLevel: string | null;
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
loadSessions: () => Promise<void>;
|
|
||||||
switchSession: (key: string) => void;
|
|
||||||
newSession: () => void;
|
|
||||||
deleteSession: (key: string) => Promise<void>;
|
|
||||||
cleanupEmptySession: () => void;
|
|
||||||
loadHistory: (quiet?: boolean) => Promise<void>;
|
|
||||||
sendMessage: (
|
|
||||||
text: string,
|
|
||||||
attachments?: Array<{ fileName: string; mimeType: string; fileSize: number; stagedPath: string; preview: string | null }>,
|
|
||||||
targetAgentId?: string | null,
|
|
||||||
) => Promise<void>;
|
|
||||||
abortRun: () => Promise<void>;
|
|
||||||
handleChatEvent: (event: Record<string, unknown>) => void;
|
|
||||||
toggleThinking: () => void;
|
|
||||||
refresh: () => Promise<void>;
|
|
||||||
clearError: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Module-level timestamp tracking the last chat event received.
|
// Module-level timestamp tracking the last chat event received.
|
||||||
// Used by the safety timeout to avoid false-positive "no response" errors
|
// Used by the safety timeout to avoid false-positive "no response" errors
|
||||||
@@ -204,9 +112,6 @@ function isDuplicateChatEvent(eventState: string, event: Record<string, unknown>
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_CANONICAL_PREFIX = 'agent:main';
|
|
||||||
const DEFAULT_SESSION_KEY = `${DEFAULT_CANONICAL_PREFIX}:main`;
|
|
||||||
|
|
||||||
// ── Local image cache ─────────────────────────────────────────
|
// ── Local image cache ─────────────────────────────────────────
|
||||||
// The Gateway doesn't store image attachments in session content blocks,
|
// 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
|
// so we cache them locally keyed by staged file path (which appears in the
|
||||||
|
|||||||
Reference in New Issue
Block a user