chore: normalize structure and split ipc handlers (#590)

Co-authored-by: Haze <709547807@qq.com>
This commit is contained in:
GASOT-GIT
2026-03-23 17:18:40 +08:00
committed by GitHub
Unverified
parent 884aa7c7f1
commit 6b82c6ccb4
7 changed files with 136 additions and 219 deletions

1
.gitignore vendored
View File

@@ -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/

View File

@@ -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 () => {

View 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),
},
};
}
});
}

View 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';
}

View File

@@ -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