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:
duanshuwen
2026-04-17 21:32:06 +08:00
parent eca70425cf
commit e9f3a29886
33 changed files with 1526 additions and 2428 deletions

9
electron/api/context.ts Normal file
View 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;
}

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

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

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

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

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

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