feat: implement OpenClaw process owner and runtime path utilities
- Add OpenClawProcessOwner class to manage the lifecycle of the OpenClaw process. - Introduce utility functions for managing OpenClaw runtime paths. - Update session store to normalize agent session keys and migrate existing keys. - Refactor main process to handle local provider API routing through a new dispatch function. - Enhance token usage writer to utilize a new session key parsing function. - Create agents management store to handle agent data and interactions. - Update chat store to integrate agent selection and session management. - Introduce AgentsSection component for displaying agent information in the UI. - Refactor HomePage to support agent selection and display current agent. - Update routing to reflect new agents page structure.
This commit is contained in:
9
electron/api/context.ts
Normal file
9
electron/api/context.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import type { gatewayManager } from '@electron/gateway/manager';
|
||||
import type { providerApiService } from '@electron/service/provider-api-service';
|
||||
|
||||
export interface HostApiContext {
|
||||
gatewayManager: typeof gatewayManager;
|
||||
providerApiService: typeof providerApiService;
|
||||
mainWindow: BrowserWindow | null;
|
||||
}
|
||||
63
electron/api/route-utils.ts
Normal file
63
electron/api/route-utils.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { HostApiResult } from '@src/types/runtime';
|
||||
|
||||
export interface HostApiRequest {
|
||||
path: string;
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: unknown;
|
||||
}
|
||||
|
||||
export interface NormalizedHostApiRequest {
|
||||
path: string;
|
||||
pathname: string;
|
||||
method: string;
|
||||
headers: Record<string, string>;
|
||||
body: unknown;
|
||||
url: URL;
|
||||
}
|
||||
|
||||
export function normalizeRequest(request: HostApiRequest): NormalizedHostApiRequest {
|
||||
const path = String(request.path || '/').trim() || '/';
|
||||
|
||||
return {
|
||||
path,
|
||||
pathname: new URL(path, 'http://127.0.0.1').pathname,
|
||||
method: String(request.method || 'GET').trim().toUpperCase(),
|
||||
headers: request.headers || {},
|
||||
body: request.body ?? null,
|
||||
url: new URL(path, 'http://127.0.0.1'),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseJsonBody<T>(body: unknown): T {
|
||||
if (body == null || body === '') {
|
||||
return {} as T;
|
||||
}
|
||||
|
||||
if (typeof body === 'string') {
|
||||
return JSON.parse(body) as T;
|
||||
}
|
||||
|
||||
return body as T;
|
||||
}
|
||||
|
||||
export function ok<T>(data: T, status = 200): HostApiResult<T> {
|
||||
return {
|
||||
success: true,
|
||||
ok: true,
|
||||
status,
|
||||
json: data,
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
export function fail<T = unknown>(status: number, error: string, data?: T): HostApiResult<T> {
|
||||
return {
|
||||
success: false,
|
||||
ok: false,
|
||||
status,
|
||||
error,
|
||||
text: error,
|
||||
data,
|
||||
};
|
||||
}
|
||||
46
electron/api/router.ts
Normal file
46
electron/api/router.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { BrowserWindow } from 'electron';
|
||||
import { gatewayManager } from '@electron/gateway/manager';
|
||||
import { providerApiService } from '@electron/service/provider-api-service';
|
||||
import type { HostApiContext } from './context';
|
||||
import type { HostApiRequest } from './route-utils';
|
||||
import { normalizeRequest } from './route-utils';
|
||||
import { handleAgentRoutes } from './routes/agents';
|
||||
import { handleFileRoutes } from './routes/files';
|
||||
import { handleGatewayRoutes } from './routes/gateway';
|
||||
import { handleProviderRoutes } from './routes/providers';
|
||||
import { handleSessionRoutes } from './routes/sessions';
|
||||
|
||||
type RouteHandler = (
|
||||
request: ReturnType<typeof normalizeRequest>,
|
||||
ctx: HostApiContext,
|
||||
) => Promise<any | null>;
|
||||
|
||||
const routeHandlers: RouteHandler[] = [
|
||||
handleProviderRoutes,
|
||||
handleAgentRoutes,
|
||||
handleGatewayRoutes,
|
||||
handleFileRoutes,
|
||||
handleSessionRoutes,
|
||||
];
|
||||
|
||||
function createContext(): HostApiContext {
|
||||
return {
|
||||
gatewayManager,
|
||||
providerApiService,
|
||||
mainWindow: BrowserWindow.getAllWindows()[0] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function dispatchLocalHostApi(request: HostApiRequest) {
|
||||
const normalized = normalizeRequest(request);
|
||||
const ctx = createContext();
|
||||
|
||||
for (const handler of routeHandlers) {
|
||||
const result = await handler(normalized, ctx);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
91
electron/api/routes/agents.ts
Normal file
91
electron/api/routes/agents.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { ProviderAccount } from '@runtime/lib/providers';
|
||||
import {
|
||||
DEFAULT_AGENT_ID,
|
||||
DEFAULT_MAIN_SESSION_SUFFIX,
|
||||
buildMainSessionKey,
|
||||
normalizeAgentId,
|
||||
type AgentSummary,
|
||||
} from '@runtime/lib/agents';
|
||||
import type { HostApiContext } from '../context';
|
||||
import type { NormalizedHostApiRequest } from '../route-utils';
|
||||
import { ok } from '../route-utils';
|
||||
|
||||
function formatModelDisplay(modelRef: string | null | undefined, fallbackLabel: string): string {
|
||||
const trimmed = String(modelRef ?? '').trim();
|
||||
if (!trimmed) return fallbackLabel;
|
||||
|
||||
const parts = trimmed.split('/');
|
||||
return parts[parts.length - 1] || trimmed;
|
||||
}
|
||||
|
||||
function buildMainAgent(defaultAccount: ProviderAccount | null): AgentSummary {
|
||||
return {
|
||||
id: DEFAULT_AGENT_ID,
|
||||
name: 'Main Agent',
|
||||
isDefault: true,
|
||||
providerAccountId: defaultAccount?.id ?? null,
|
||||
modelRef: defaultAccount?.model ?? null,
|
||||
modelDisplay: formatModelDisplay(defaultAccount?.model, defaultAccount?.label || 'Unassigned'),
|
||||
mainSessionKey: buildMainSessionKey(DEFAULT_AGENT_ID, DEFAULT_MAIN_SESSION_SUFFIX),
|
||||
vendorId: defaultAccount?.vendorId ?? null,
|
||||
source: 'synthetic-main',
|
||||
};
|
||||
}
|
||||
|
||||
function buildProviderBackedAgents(accounts: ProviderAccount[]): AgentSummary[] {
|
||||
const seen = new Set<string>();
|
||||
const summaries: AgentSummary[] = [];
|
||||
|
||||
for (const account of accounts) {
|
||||
const agentId = normalizeAgentId(account.id);
|
||||
if (seen.has(agentId) || agentId === DEFAULT_AGENT_ID) continue;
|
||||
seen.add(agentId);
|
||||
|
||||
summaries.push({
|
||||
id: agentId,
|
||||
name: account.label || agentId,
|
||||
isDefault: false,
|
||||
providerAccountId: account.id,
|
||||
modelRef: account.model ?? null,
|
||||
modelDisplay: formatModelDisplay(account.model, account.label || agentId),
|
||||
mainSessionKey: buildMainSessionKey(agentId, DEFAULT_MAIN_SESSION_SUFFIX),
|
||||
vendorId: account.vendorId,
|
||||
source: 'provider-account',
|
||||
});
|
||||
}
|
||||
|
||||
return summaries;
|
||||
}
|
||||
|
||||
export async function handleAgentRoutes(
|
||||
request: NormalizedHostApiRequest,
|
||||
ctx: HostApiContext,
|
||||
) {
|
||||
const { pathname, method } = request;
|
||||
if (pathname !== '/api/agents' || method !== 'GET') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const accounts = ctx.providerApiService
|
||||
.getAccounts()
|
||||
.filter((account) => account.enabled !== false);
|
||||
const defaultAccountId = ctx.providerApiService.getDefault().accountId;
|
||||
const defaultAccount = accounts.find((account) => account.id === defaultAccountId) ?? accounts[0] ?? null;
|
||||
|
||||
const agents = [
|
||||
buildMainAgent(defaultAccount),
|
||||
...buildProviderBackedAgents(accounts),
|
||||
];
|
||||
|
||||
return ok({
|
||||
success: true,
|
||||
agents,
|
||||
defaultAgentId: DEFAULT_AGENT_ID,
|
||||
defaultProviderAccountId: defaultAccount?.id ?? null,
|
||||
defaultModelRef: defaultAccount?.model ?? null,
|
||||
mainSessionSuffix: DEFAULT_MAIN_SESSION_SUFFIX,
|
||||
configuredChannelTypes: [],
|
||||
channelOwners: {},
|
||||
channelAccountOwners: {},
|
||||
});
|
||||
}
|
||||
115
electron/api/routes/files.ts
Normal file
115
electron/api/routes/files.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { app, nativeImage } from 'electron';
|
||||
import { extname, join } from 'node:path';
|
||||
import type { HostApiContext } from '../context';
|
||||
import type { NormalizedHostApiRequest } from '../route-utils';
|
||||
import { ok, parseJsonBody } from '../route-utils';
|
||||
|
||||
const EXT_MIME_MAP: Record<string, string> = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.bmp': 'image/bmp',
|
||||
'.txt': 'text/plain',
|
||||
'.json': 'application/json',
|
||||
'.pdf': 'application/pdf',
|
||||
};
|
||||
|
||||
function getMimeType(ext: string): string {
|
||||
return EXT_MIME_MAP[ext.toLowerCase()] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
function mimeToExt(mimeType: string): string {
|
||||
for (const [ext, mime] of Object.entries(EXT_MIME_MAP)) {
|
||||
if (mime === mimeType) return ext;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
const OUTBOUND_DIR = join(app.getPath('userData'), 'openclaw-media', 'outbound');
|
||||
|
||||
async function generateImagePreview(filePath: string, mimeType: string): Promise<string | null> {
|
||||
try {
|
||||
const image = nativeImage.createFromPath(filePath);
|
||||
if (image.isEmpty()) return null;
|
||||
|
||||
const size = image.getSize();
|
||||
const maxDim = 512;
|
||||
const normalized = size.width > maxDim || size.height > maxDim
|
||||
? (size.width >= size.height ? image.resize({ width: maxDim }) : image.resize({ height: maxDim }))
|
||||
: image;
|
||||
|
||||
return `data:${mimeType};base64,${normalized.toPNG().toString('base64')}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleFileRoutes(
|
||||
request: NormalizedHostApiRequest,
|
||||
_ctx: HostApiContext,
|
||||
) {
|
||||
const { pathname, method } = request;
|
||||
|
||||
if (pathname === '/api/files/stage-buffer' && method === 'POST') {
|
||||
const body = parseJsonBody<{ base64: string; fileName: string; mimeType: string }>(request.body);
|
||||
const fsP = await import('node:fs/promises');
|
||||
await fsP.mkdir(OUTBOUND_DIR, { recursive: true });
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const ext = extname(body.fileName) || mimeToExt(body.mimeType);
|
||||
const stagedPath = join(OUTBOUND_DIR, `${id}${ext}`);
|
||||
const buffer = Buffer.from(body.base64 || '', 'base64');
|
||||
await fsP.writeFile(stagedPath, buffer);
|
||||
|
||||
const mimeType = body.mimeType || getMimeType(ext);
|
||||
const preview = mimeType.startsWith('image/')
|
||||
? await generateImagePreview(stagedPath, mimeType)
|
||||
: null;
|
||||
|
||||
return ok({
|
||||
id,
|
||||
fileName: body.fileName,
|
||||
mimeType,
|
||||
fileSize: buffer.length,
|
||||
stagedPath,
|
||||
preview,
|
||||
});
|
||||
}
|
||||
|
||||
if (pathname === '/api/files/stage-paths' && method === 'POST') {
|
||||
const body = parseJsonBody<{ filePaths: string[] }>(request.body);
|
||||
const fsP = await import('node:fs/promises');
|
||||
await fsP.mkdir(OUTBOUND_DIR, { recursive: true });
|
||||
|
||||
const results = [];
|
||||
for (const filePath of body.filePaths || []) {
|
||||
const id = crypto.randomUUID();
|
||||
const ext = extname(filePath);
|
||||
const stagedPath = join(OUTBOUND_DIR, `${id}${ext}`);
|
||||
await fsP.copyFile(filePath, stagedPath);
|
||||
const stats = await fsP.stat(stagedPath);
|
||||
const mimeType = getMimeType(ext);
|
||||
const fileName = filePath.split(/[\\/]/).pop() || 'file';
|
||||
const preview = mimeType.startsWith('image/')
|
||||
? await generateImagePreview(stagedPath, mimeType)
|
||||
: null;
|
||||
|
||||
results.push({
|
||||
id,
|
||||
fileName,
|
||||
mimeType,
|
||||
fileSize: stats.size,
|
||||
stagedPath,
|
||||
preview,
|
||||
});
|
||||
}
|
||||
|
||||
return ok(results);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
49
electron/api/routes/gateway.ts
Normal file
49
electron/api/routes/gateway.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { HostApiContext } from '../context';
|
||||
import type { NormalizedHostApiRequest } from '../route-utils';
|
||||
import { fail, ok } from '../route-utils';
|
||||
|
||||
export async function handleGatewayRoutes(
|
||||
request: NormalizedHostApiRequest,
|
||||
ctx: HostApiContext,
|
||||
) {
|
||||
const { pathname, method } = request;
|
||||
|
||||
if (pathname === '/api/app/gateway-info' && method === 'GET') {
|
||||
const status = ctx.gatewayManager.getStatus();
|
||||
return ok({
|
||||
transport: 'ipc-bridge',
|
||||
rpcChannel: 'gateway:rpc',
|
||||
eventChannel: 'gateway:event',
|
||||
...status,
|
||||
});
|
||||
}
|
||||
|
||||
if (pathname === '/api/gateway/status' && method === 'GET') {
|
||||
return ok(ctx.gatewayManager.getStatus());
|
||||
}
|
||||
|
||||
if (pathname === '/api/gateway/health' && method === 'GET') {
|
||||
return ok(await ctx.gatewayManager.checkHealth());
|
||||
}
|
||||
|
||||
if (pathname === '/api/gateway/start' && method === 'POST') {
|
||||
await ctx.gatewayManager.start();
|
||||
return ok({ success: true });
|
||||
}
|
||||
|
||||
if (pathname === '/api/gateway/stop' && method === 'POST') {
|
||||
await ctx.gatewayManager.stop();
|
||||
return ok({ success: true });
|
||||
}
|
||||
|
||||
if (pathname === '/api/gateway/restart' && method === 'POST') {
|
||||
try {
|
||||
await ctx.gatewayManager.restart();
|
||||
return ok({ success: true });
|
||||
} catch (error) {
|
||||
return fail(500, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
74
electron/api/routes/providers.ts
Normal file
74
electron/api/routes/providers.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { HostApiContext } from '../context';
|
||||
import type { NormalizedHostApiRequest } from '../route-utils';
|
||||
import { ok } from '../route-utils';
|
||||
|
||||
export async function handleProviderRoutes(
|
||||
request: NormalizedHostApiRequest,
|
||||
ctx: HostApiContext,
|
||||
) {
|
||||
const { pathname, method } = request;
|
||||
const parsedBody = request.body && typeof request.body === 'string'
|
||||
? JSON.parse(request.body)
|
||||
: request.body;
|
||||
|
||||
if (pathname === '/api/provider-vendors' && method === 'GET') {
|
||||
return ok(ctx.providerApiService.getVendors());
|
||||
}
|
||||
if (pathname === '/api/provider-accounts' && method === 'GET') {
|
||||
return ok(ctx.providerApiService.getAccounts());
|
||||
}
|
||||
if (pathname === '/api/providers' && method === 'GET') {
|
||||
return ok(ctx.providerApiService.getProviders());
|
||||
}
|
||||
if (pathname === '/api/provider-accounts/default' && method === 'GET') {
|
||||
return ok(ctx.providerApiService.getDefault());
|
||||
}
|
||||
if (pathname === '/api/provider-accounts' && method === 'POST') {
|
||||
return ok(ctx.providerApiService.createAccount(parsedBody || {}));
|
||||
}
|
||||
if (pathname === '/api/provider-accounts/default' && method === 'PUT') {
|
||||
const result = ctx.providerApiService.setDefault(parsedBody || {});
|
||||
return ok(result, result.success ? 200 : 400);
|
||||
}
|
||||
if (pathname.startsWith('/api/provider-accounts/') && method === 'PUT') {
|
||||
const id = decodeURIComponent(pathname.replace('/api/provider-accounts/', ''));
|
||||
const result = ctx.providerApiService.updateAccount(id, parsedBody || {});
|
||||
return ok(result, result.success ? 200 : 404);
|
||||
}
|
||||
if (pathname.startsWith('/api/provider-accounts/') && method === 'DELETE') {
|
||||
const id = decodeURIComponent(pathname.replace('/api/provider-accounts/', ''));
|
||||
return ok(ctx.providerApiService.deleteAccount(id));
|
||||
}
|
||||
if (pathname === '/api/providers/default' && method === 'PUT') {
|
||||
const result = ctx.providerApiService.setDefault({ accountId: (parsedBody as any)?.providerId });
|
||||
return ok(result, result.success ? 200 : 400);
|
||||
}
|
||||
if (pathname.startsWith('/api/providers/') && pathname.endsWith('/api-key') && method === 'GET') {
|
||||
const id = decodeURIComponent(pathname.replace('/api/providers/', '').replace('/api-key', ''));
|
||||
return ok(ctx.providerApiService.getApiKey(id));
|
||||
}
|
||||
if (pathname.startsWith('/api/providers/') && method === 'PUT') {
|
||||
const id = decodeURIComponent(pathname.replace('/api/providers/', ''));
|
||||
const result = ctx.providerApiService.updateAccount(id, parsedBody || {});
|
||||
return ok(result, result.success ? 200 : 404);
|
||||
}
|
||||
if (pathname.startsWith('/api/providers/') && method === 'DELETE') {
|
||||
const rawPath = request.path.replace('/api/providers/', '');
|
||||
const [rawId, query] = rawPath.split('?');
|
||||
const id = decodeURIComponent(rawId);
|
||||
if (query && query.includes('apiKeyOnly=1')) {
|
||||
return ok(ctx.providerApiService.deleteApiKey(id));
|
||||
}
|
||||
return ok(ctx.providerApiService.deleteAccount(id));
|
||||
}
|
||||
if (pathname === '/api/providers/validate' && method === 'POST') {
|
||||
return ok(await ctx.providerApiService.validateApiKey(parsedBody || {}));
|
||||
}
|
||||
if (pathname === '/api/usage/recent-token-history' && method === 'GET') {
|
||||
const limitRaw = request.url.searchParams.get('limit');
|
||||
const limit = limitRaw ? Number(limitRaw) : undefined;
|
||||
return ok(await ctx.providerApiService.getUsageHistory(limit));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
113
electron/api/routes/sessions.ts
Normal file
113
electron/api/routes/sessions.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { sessionStore } from '@electron/gateway/session-store';
|
||||
import { getTranscriptFilePath } from '@electron/utils/token-usage-writer';
|
||||
import { buildAgentSessionKey, normalizeAgentSessionKey, parseSessionKey } from '@runtime/lib/agents';
|
||||
import type { HostApiContext } from '../context';
|
||||
import type { NormalizedHostApiRequest } from '../route-utils';
|
||||
import { fail, ok, parseJsonBody } from '../route-utils';
|
||||
|
||||
function parseSessionIdentity(request: NormalizedHostApiRequest): { agentId: string; sessionId: string; sessionKey: string } | null {
|
||||
const sessionKey = normalizeAgentSessionKey(request.url.searchParams.get('sessionKey')?.trim() || '');
|
||||
const parsed = parseSessionKey(sessionKey);
|
||||
if (sessionKey && parsed.isAgentSession) {
|
||||
return {
|
||||
agentId: parsed.agentId,
|
||||
sessionId: parsed.sessionId,
|
||||
sessionKey: parsed.sessionKey,
|
||||
};
|
||||
}
|
||||
|
||||
const agentId = request.url.searchParams.get('agentId')?.trim() || '';
|
||||
const sessionId = request.url.searchParams.get('sessionId')?.trim() || '';
|
||||
if (!agentId || !sessionId) return null;
|
||||
|
||||
return {
|
||||
agentId: parseSessionKey(buildAgentSessionKey(agentId, sessionId)).agentId,
|
||||
sessionId: parseSessionKey(buildAgentSessionKey(agentId, sessionId)).sessionId,
|
||||
sessionKey: buildAgentSessionKey(agentId, sessionId),
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleSessionRoutes(
|
||||
request: NormalizedHostApiRequest,
|
||||
_ctx: HostApiContext,
|
||||
) {
|
||||
const { pathname, method } = request;
|
||||
|
||||
if (pathname === '/api/sessions/transcript' && method === 'GET') {
|
||||
const identity = parseSessionIdentity(request);
|
||||
if (!identity) {
|
||||
return fail(400, 'sessionKey or agentId/sessionId is required');
|
||||
}
|
||||
|
||||
try {
|
||||
const fsP = await import('node:fs/promises');
|
||||
const transcriptPath = getTranscriptFilePath(identity.sessionKey);
|
||||
let raw: string;
|
||||
|
||||
try {
|
||||
raw = await fsP.readFile(transcriptPath, 'utf8');
|
||||
} catch (error: any) {
|
||||
const requestedSessionKey = request.url.searchParams.get('sessionKey')?.trim() || '';
|
||||
if (error?.code === 'ENOENT' && requestedSessionKey && requestedSessionKey !== identity.sessionKey) {
|
||||
raw = await fsP.readFile(getTranscriptFilePath(requestedSessionKey), 'utf8');
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const lines = raw.split(/\r?\n/).filter(Boolean);
|
||||
const messages = lines.flatMap((line) => {
|
||||
try {
|
||||
const entry = JSON.parse(line) as { type?: string; message?: unknown; timestamp?: string };
|
||||
return entry.type === 'message' && entry.message ? [{ ...entry.message, timestamp: entry.timestamp }] : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
return ok({
|
||||
agentId: identity.agentId,
|
||||
sessionId: identity.sessionId,
|
||||
sessionKey: identity.sessionKey,
|
||||
transcriptPath,
|
||||
messages,
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error?.code === 'ENOENT') {
|
||||
return fail(404, 'Transcript not found');
|
||||
}
|
||||
return fail(500, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
if (pathname === '/api/sessions/delete' && method === 'POST') {
|
||||
const body = parseJsonBody<{ sessionKey: string }>(request.body);
|
||||
const rawSessionKey = String(body?.sessionKey || '').trim();
|
||||
const sessionKey = normalizeAgentSessionKey(rawSessionKey);
|
||||
if (!sessionKey) {
|
||||
return fail(400, 'sessionKey is required');
|
||||
}
|
||||
|
||||
sessionStore.deleteSession(sessionKey);
|
||||
|
||||
const transcriptPath = getTranscriptFilePath(sessionKey);
|
||||
try {
|
||||
const fsP = await import('node:fs/promises');
|
||||
await fsP.rename(transcriptPath, transcriptPath.replace(/\.jsonl$/, '.deleted.jsonl'));
|
||||
} catch {
|
||||
if (rawSessionKey && rawSessionKey !== sessionKey) {
|
||||
try {
|
||||
const fsP = await import('node:fs/promises');
|
||||
const legacyTranscriptPath = getTranscriptFilePath(rawSessionKey);
|
||||
await fsP.rename(legacyTranscriptPath, legacyTranscriptPath.replace(/\.jsonl$/, '.deleted.jsonl'));
|
||||
} catch {
|
||||
// Best effort: transcript may not exist yet.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ok({ success: true });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { createProvider } from '@electron/providers';
|
||||
import type { BaseProvider } from '@electron/providers/BaseProvider';
|
||||
import { providerApiService } from '@electron/service/provider-api-service';
|
||||
import logManager from '@electron/service/logger';
|
||||
import { normalizeAgentSessionKey } from '@runtime/lib/agents';
|
||||
import type { RawMessage } from '@runtime/shared/chat-model';
|
||||
import { sessionStore } from '../session-store';
|
||||
import type { GatewayEvent, GatewayRpcParams, GatewayRpcReturns } from '../types';
|
||||
@@ -110,7 +111,8 @@ export function handleChatSend(
|
||||
params: GatewayRpcParams['chat.send'],
|
||||
broadcast: (event: GatewayEvent) => void
|
||||
): GatewayRpcReturns['chat.send'] {
|
||||
const { sessionKey, message, options } = params;
|
||||
const sessionKey = normalizeAgentSessionKey(params.sessionKey);
|
||||
const { message, options } = params;
|
||||
const runId = randomUUID();
|
||||
|
||||
// 1. Append user message
|
||||
@@ -175,20 +177,21 @@ export function handleChatSend(
|
||||
export function handleChatHistory(
|
||||
params: GatewayRpcParams['chat.history']
|
||||
): GatewayRpcReturns['chat.history'] {
|
||||
return sessionStore.getMessages(params.sessionKey, params.limit ?? 50);
|
||||
return sessionStore.getMessages(normalizeAgentSessionKey(params.sessionKey), params.limit ?? 50);
|
||||
}
|
||||
|
||||
export function handleChatAbort(
|
||||
params: GatewayRpcParams['chat.abort'],
|
||||
broadcast: (event: GatewayEvent) => void
|
||||
): GatewayRpcReturns['chat.abort'] {
|
||||
const activeRun = sessionStore.getActiveRun(params.sessionKey);
|
||||
const sessionKey = normalizeAgentSessionKey(params.sessionKey);
|
||||
const activeRun = sessionStore.getActiveRun(sessionKey);
|
||||
if (activeRun) {
|
||||
activeRun.abortController.abort();
|
||||
sessionStore.clearActiveRun(params.sessionKey);
|
||||
sessionStore.clearActiveRun(sessionKey);
|
||||
broadcast({
|
||||
type: 'chat:aborted',
|
||||
sessionKey: params.sessionKey,
|
||||
sessionKey,
|
||||
runId: activeRun.runId,
|
||||
});
|
||||
}
|
||||
@@ -201,6 +204,6 @@ export function handleSessionList(): GatewayRpcReturns['session.list'] {
|
||||
export function handleSessionDelete(
|
||||
params: GatewayRpcParams['session.delete']
|
||||
): GatewayRpcReturns['session.delete'] {
|
||||
sessionStore.deleteSession(params.sessionKey);
|
||||
sessionStore.deleteSession(normalizeAgentSessionKey(params.sessionKey));
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -7,14 +7,60 @@ import * as providerHandlers from './handlers/provider';
|
||||
|
||||
class GatewayManager {
|
||||
private initialized = false;
|
||||
private status: 'connected' | 'disconnected' | 'reconnecting' = 'disconnected';
|
||||
|
||||
private setStatus(status: 'connected' | 'disconnected' | 'reconnecting'): void {
|
||||
this.status = status;
|
||||
this.broadcast({ type: 'gateway:status', status });
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
this.initialized = true;
|
||||
this.status = 'connected';
|
||||
logManager.info('GatewayManager initialized');
|
||||
this.broadcast({ type: 'gateway:status', status: 'connected' });
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
await this.init();
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.initialized = false;
|
||||
this.setStatus('disconnected');
|
||||
}
|
||||
|
||||
async restart(): Promise<void> {
|
||||
this.initialized = false;
|
||||
this.setStatus('reconnecting');
|
||||
await this.init();
|
||||
}
|
||||
|
||||
getStatus(): {
|
||||
status: 'connected' | 'disconnected' | 'reconnecting';
|
||||
initialized: boolean;
|
||||
mode: 'in-process';
|
||||
} {
|
||||
return {
|
||||
status: this.status,
|
||||
initialized: this.initialized,
|
||||
mode: 'in-process',
|
||||
};
|
||||
}
|
||||
|
||||
async checkHealth(): Promise<{
|
||||
ok: boolean;
|
||||
status: 'connected' | 'disconnected' | 'reconnecting';
|
||||
initialized: boolean;
|
||||
mode: 'in-process';
|
||||
}> {
|
||||
return {
|
||||
ok: this.initialized && this.status === 'connected',
|
||||
...this.getStatus(),
|
||||
};
|
||||
}
|
||||
|
||||
async rpc(method: string, params: any): Promise<any> {
|
||||
if (!this.initialized) {
|
||||
await this.init();
|
||||
|
||||
112
electron/gateway/openclaw-process-owner.ts
Normal file
112
electron/gateway/openclaw-process-owner.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
ensureOpenClawRuntimeLayout,
|
||||
getOpenClawRuntimePaths,
|
||||
type OpenClawRuntimePaths,
|
||||
} from '@electron/utils/paths';
|
||||
|
||||
export type OpenClawProcessOwnerState =
|
||||
| 'idle'
|
||||
| 'preparing'
|
||||
| 'running'
|
||||
| 'stopping'
|
||||
| 'stopped'
|
||||
| 'failed';
|
||||
|
||||
export interface OpenClawProcessOwnerStatus {
|
||||
state: OpenClawProcessOwnerState;
|
||||
prepared: boolean;
|
||||
runtimePaths: OpenClawRuntimePaths;
|
||||
lastError?: string;
|
||||
}
|
||||
|
||||
export interface OpenClawProcessOwnerOptions {
|
||||
runtimePaths?: Partial<OpenClawRuntimePaths>;
|
||||
}
|
||||
|
||||
export interface OpenClawProcessOwnerLike {
|
||||
prepare(): Promise<void>;
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
restart(): Promise<void>;
|
||||
getStatus(): OpenClawProcessOwnerStatus;
|
||||
getRuntimePaths(): OpenClawRuntimePaths;
|
||||
}
|
||||
|
||||
function mergeRuntimePaths(
|
||||
base: OpenClawRuntimePaths,
|
||||
override?: Partial<OpenClawRuntimePaths>,
|
||||
): OpenClawRuntimePaths {
|
||||
if (!override) {
|
||||
return base;
|
||||
}
|
||||
|
||||
return {
|
||||
configDir: override.configDir ?? base.configDir,
|
||||
runtimeDir: override.runtimeDir ?? base.runtimeDir,
|
||||
dir: override.dir ?? base.dir,
|
||||
resolvedDir: override.resolvedDir ?? base.resolvedDir,
|
||||
entryPath: override.entryPath ?? base.entryPath,
|
||||
};
|
||||
}
|
||||
|
||||
export class OpenClawProcessOwner implements OpenClawProcessOwnerLike {
|
||||
private status: OpenClawProcessOwnerStatus;
|
||||
|
||||
constructor(options?: OpenClawProcessOwnerOptions) {
|
||||
const runtimePaths = mergeRuntimePaths(
|
||||
getOpenClawRuntimePaths(),
|
||||
options?.runtimePaths,
|
||||
);
|
||||
|
||||
this.status = {
|
||||
state: 'idle',
|
||||
prepared: false,
|
||||
runtimePaths,
|
||||
};
|
||||
}
|
||||
|
||||
async prepare(): Promise<void> {
|
||||
if (this.status.prepared) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.status.state = 'preparing';
|
||||
ensureOpenClawRuntimeLayout(this.status.runtimePaths);
|
||||
this.status.prepared = true;
|
||||
this.status.state = 'idle';
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.status.state === 'running') {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.prepare();
|
||||
this.status.state = 'running';
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (this.status.state === 'idle' || this.status.state === 'stopped') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.status.state = 'stopping';
|
||||
this.status.state = 'stopped';
|
||||
}
|
||||
|
||||
async restart(): Promise<void> {
|
||||
await this.stop();
|
||||
await this.start();
|
||||
}
|
||||
|
||||
getStatus(): OpenClawProcessOwnerStatus {
|
||||
return {
|
||||
...this.status,
|
||||
runtimePaths: { ...this.status.runtimePaths },
|
||||
};
|
||||
}
|
||||
|
||||
getRuntimePaths(): OpenClawRuntimePaths {
|
||||
return { ...this.status.runtimePaths };
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { app } from 'electron';
|
||||
import logManager from '@electron/service/logger';
|
||||
import { normalizeAgentSessionKey } from '@runtime/lib/agents';
|
||||
import type { RawMessage } from '@runtime/shared/chat-model';
|
||||
|
||||
let sessionsFilePath: string | null = null;
|
||||
@@ -41,12 +42,21 @@ class SessionStore {
|
||||
string,
|
||||
Omit<SessionEntry, 'activeRun'>
|
||||
>;
|
||||
let migrated = false;
|
||||
for (const [key, entry] of Object.entries(data)) {
|
||||
this.sessions.set(key, {
|
||||
const normalizedKey = normalizeAgentSessionKey(key);
|
||||
if (normalizedKey !== key) {
|
||||
migrated = true;
|
||||
}
|
||||
this.sessions.set(normalizedKey, {
|
||||
...entry,
|
||||
key: normalizedKey,
|
||||
activeRun: undefined,
|
||||
});
|
||||
}
|
||||
if (migrated) {
|
||||
this.saveToDisk();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logManager.error('Failed to load sessions from disk:', e);
|
||||
@@ -73,21 +83,22 @@ class SessionStore {
|
||||
|
||||
getOrCreate(key: string): SessionEntry {
|
||||
this.ensureLoaded();
|
||||
let session = this.sessions.get(key);
|
||||
const normalizedKey = normalizeAgentSessionKey(key);
|
||||
let session = this.sessions.get(normalizedKey);
|
||||
if (!session) {
|
||||
session = {
|
||||
key,
|
||||
key: normalizedKey,
|
||||
messages: [],
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
this.sessions.set(key, session);
|
||||
this.sessions.set(normalizedKey, session);
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
get(key: string): SessionEntry | undefined {
|
||||
this.ensureLoaded();
|
||||
return this.sessions.get(key);
|
||||
return this.sessions.get(normalizeAgentSessionKey(key));
|
||||
}
|
||||
|
||||
getAllKeys(): string[] {
|
||||
@@ -114,18 +125,18 @@ class SessionStore {
|
||||
}
|
||||
|
||||
clearActiveRun(key: string): void {
|
||||
const session = this.sessions.get(key);
|
||||
const session = this.sessions.get(normalizeAgentSessionKey(key));
|
||||
if (session) {
|
||||
session.activeRun = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
getActiveRun(key: string): { runId: string; abortController: AbortController } | undefined {
|
||||
return this.sessions.get(key)?.activeRun;
|
||||
return this.sessions.get(normalizeAgentSessionKey(key))?.activeRun;
|
||||
}
|
||||
|
||||
deleteSession(key: string): void {
|
||||
this.sessions.delete(key);
|
||||
this.sessions.delete(normalizeAgentSessionKey(key));
|
||||
this.saveToDisk();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,9 @@ import log from 'electron-log';
|
||||
import 'bytenode'; // Ensure bytenode is bundled/externalized correctly
|
||||
import { appUpdater } from '@electron/service/updater';
|
||||
import axios from 'axios';
|
||||
import { providerApiService, onProviderChange } from '@electron/service/provider-api-service';
|
||||
import { onProviderChange } from '@electron/service/provider-api-service';
|
||||
import { gatewayManager } from '@electron/gateway/manager';
|
||||
import { dispatchLocalHostApi } from '@electron/api/router';
|
||||
|
||||
// 初始化 updater,确保在 app ready 之前或者之中注册好 IPC
|
||||
appUpdater.init();
|
||||
@@ -20,78 +21,14 @@ appUpdater.init();
|
||||
// 模型管理相关接口在本地处理(对齐 ClawX),其余接口代理到远端后端
|
||||
const HOST_API_BASE_URL = process.env.VITE_SERVICE_URL || 'http://8.138.234.141/ingress';
|
||||
|
||||
async function handleLocalProviderApi(path: string, method: string, body: any) {
|
||||
const parsedBody = typeof body === 'string' && body ? JSON.parse(body) : body;
|
||||
|
||||
if (path === '/api/provider-vendors' && method === 'GET') {
|
||||
return { success: true, ok: true, json: providerApiService.getVendors(), data: providerApiService.getVendors() };
|
||||
}
|
||||
if (path === '/api/provider-accounts' && method === 'GET') {
|
||||
return { success: true, ok: true, json: providerApiService.getAccounts(), data: providerApiService.getAccounts() };
|
||||
}
|
||||
if (path === '/api/providers' && method === 'GET') {
|
||||
return { success: true, ok: true, json: providerApiService.getProviders(), data: providerApiService.getProviders() };
|
||||
}
|
||||
if (path === '/api/provider-accounts/default' && method === 'GET') {
|
||||
return { success: true, ok: true, json: providerApiService.getDefault(), data: providerApiService.getDefault() };
|
||||
}
|
||||
if (path === '/api/provider-accounts' && method === 'POST') {
|
||||
const result = providerApiService.createAccount(parsedBody || {});
|
||||
return { success: true, ok: true, json: result, data: result };
|
||||
}
|
||||
if (path === '/api/provider-accounts/default' && method === 'PUT') {
|
||||
const result = providerApiService.setDefault(parsedBody || {});
|
||||
return { success: result.success, ok: result.success, json: result, data: result };
|
||||
}
|
||||
if (path.startsWith('/api/provider-accounts/') && method === 'PUT') {
|
||||
const id = decodeURIComponent(path.replace('/api/provider-accounts/', ''));
|
||||
const result = providerApiService.updateAccount(id, parsedBody || {});
|
||||
return { success: result.success, ok: result.success, json: result, data: result };
|
||||
}
|
||||
if (path.startsWith('/api/provider-accounts/') && method === 'DELETE') {
|
||||
const id = decodeURIComponent(path.replace('/api/provider-accounts/', ''));
|
||||
const result = providerApiService.deleteAccount(id);
|
||||
return { success: result.success, ok: result.success, json: result, data: result };
|
||||
}
|
||||
if (path === '/api/providers/default' && method === 'PUT') {
|
||||
const result = providerApiService.setDefault({ accountId: parsedBody?.providerId });
|
||||
return { success: result.success, ok: result.success, json: result, data: result };
|
||||
}
|
||||
if (path.startsWith('/api/providers/') && path.endsWith('/api-key') && method === 'GET') {
|
||||
const id = decodeURIComponent(path.replace('/api/providers/', '').replace('/api-key', ''));
|
||||
const result = providerApiService.getApiKey(id);
|
||||
return { success: true, ok: true, json: result, data: result };
|
||||
}
|
||||
if (path.startsWith('/api/providers/') && method === 'PUT') {
|
||||
// Provider updates are mapped to account updates for local storage
|
||||
const id = decodeURIComponent(path.replace('/api/providers/', ''));
|
||||
const result = providerApiService.updateAccount(id, parsedBody || {});
|
||||
return { success: result.success, ok: result.success, json: result, data: result };
|
||||
}
|
||||
if (path.startsWith('/api/providers/') && method === 'DELETE') {
|
||||
const [rawId, query] = path.replace('/api/providers/', '').split('?');
|
||||
const id = decodeURIComponent(rawId);
|
||||
if (query && query.includes('apiKeyOnly=1')) {
|
||||
const result = providerApiService.deleteApiKey(id);
|
||||
return { success: result.success, ok: result.success, json: result, data: result };
|
||||
}
|
||||
const result = providerApiService.deleteAccount(id);
|
||||
return { success: result.success, ok: result.success, json: result, data: result };
|
||||
}
|
||||
if (path === '/api/providers/validate' && method === 'POST') {
|
||||
const result = await providerApiService.validateApiKey(parsedBody || {});
|
||||
return { success: true, ok: true, json: result, data: result };
|
||||
}
|
||||
if (path === '/api/usage/recent-token-history' && method === 'GET') {
|
||||
const usageHistory = await providerApiService.getUsageHistory();
|
||||
return { success: true, ok: true, json: usageHistory, data: usageHistory };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
ipcMain.handle('hostapi:fetch', async (_event, { path, method, headers, body }) => {
|
||||
// 1. 优先本地处理模型管理接口
|
||||
const localResult = await handleLocalProviderApi(path, method || 'GET', body);
|
||||
// 1. 优先本地处理 Host API 路由(逐步对齐 ClawX)
|
||||
const localResult = await dispatchLocalHostApi({
|
||||
path,
|
||||
method: method || 'GET',
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
if (localResult) return localResult;
|
||||
|
||||
// 2. 其余接口代理到远端后端
|
||||
|
||||
74
electron/utils/paths.ts
Normal file
74
electron/utils/paths.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { app } from 'electron';
|
||||
import { existsSync, mkdirSync, realpathSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export const OPENCLAW_CONFIG_DIR_NAME = '.openclaw';
|
||||
export const OPENCLAW_RUNTIME_DIR_NAME = 'runtime';
|
||||
export const OPENCLAW_PACKAGE_DIR_NAME = 'openclaw';
|
||||
export const OPENCLAW_ENTRY_FILE_NAME = 'openclaw.mjs';
|
||||
|
||||
export interface OpenClawRuntimePaths {
|
||||
configDir: string;
|
||||
runtimeDir: string;
|
||||
dir: string;
|
||||
resolvedDir: string;
|
||||
entryPath: string;
|
||||
}
|
||||
|
||||
export function getOpenClawConfigDir(): string {
|
||||
return join(homedir(), OPENCLAW_CONFIG_DIR_NAME);
|
||||
}
|
||||
|
||||
export function getOpenClawRuntimeDir(): string {
|
||||
return join(getOpenClawConfigDir(), OPENCLAW_RUNTIME_DIR_NAME);
|
||||
}
|
||||
|
||||
export function getOpenClawDir(): string {
|
||||
if (app.isPackaged) {
|
||||
return join(process.resourcesPath, OPENCLAW_PACKAGE_DIR_NAME);
|
||||
}
|
||||
return join(app.getAppPath(), 'node_modules', OPENCLAW_PACKAGE_DIR_NAME);
|
||||
}
|
||||
|
||||
export function getOpenClawResolvedDir(): string {
|
||||
const dir = getOpenClawDir();
|
||||
if (!existsSync(dir)) {
|
||||
return dir;
|
||||
}
|
||||
|
||||
try {
|
||||
return realpathSync(dir);
|
||||
} catch {
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
|
||||
export function getOpenClawEntryPath(): string {
|
||||
return join(getOpenClawDir(), OPENCLAW_ENTRY_FILE_NAME);
|
||||
}
|
||||
|
||||
export function ensureDir(dir: string): string {
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
export function ensureOpenClawRuntimeLayout(
|
||||
paths: OpenClawRuntimePaths = getOpenClawRuntimePaths(),
|
||||
): OpenClawRuntimePaths {
|
||||
ensureDir(paths.configDir);
|
||||
ensureDir(paths.runtimeDir);
|
||||
return paths;
|
||||
}
|
||||
|
||||
export function getOpenClawRuntimePaths(): OpenClawRuntimePaths {
|
||||
return {
|
||||
configDir: getOpenClawConfigDir(),
|
||||
runtimeDir: getOpenClawRuntimeDir(),
|
||||
dir: getOpenClawDir(),
|
||||
resolvedDir: getOpenClawResolvedDir(),
|
||||
entryPath: getOpenClawEntryPath(),
|
||||
};
|
||||
}
|
||||
@@ -1,23 +1,12 @@
|
||||
import { app } from 'electron';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { parseSessionKey } from '@runtime/lib/agents';
|
||||
|
||||
export function getTranscriptFilePath(sessionKey: string): string {
|
||||
let agentId: string;
|
||||
let sessionId: string;
|
||||
|
||||
if (sessionKey.startsWith('agent:')) {
|
||||
const parts = sessionKey.split(':');
|
||||
agentId = parts[1] ?? 'default';
|
||||
sessionId = parts.slice(2).join(':') || sessionKey;
|
||||
} else if (sessionKey.startsWith('local:')) {
|
||||
const parts = sessionKey.split(':');
|
||||
agentId = parts[1] ?? 'local';
|
||||
sessionId = parts.slice(2).join(':') || sessionKey;
|
||||
} else {
|
||||
agentId = 'default';
|
||||
sessionId = sessionKey;
|
||||
}
|
||||
const parsed = parseSessionKey(sessionKey);
|
||||
let agentId = parsed.isAgentSession ? parsed.agentId : 'default';
|
||||
let sessionId = parsed.isAgentSession ? parsed.sessionId : sessionKey;
|
||||
|
||||
if (!sessionId) {
|
||||
sessionId = 'unknown';
|
||||
|
||||
Reference in New Issue
Block a user