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

144
src/stores/agents.ts Normal file
View File

@@ -0,0 +1,144 @@
import { useSyncExternalStore } from 'react';
import {
DEFAULT_AGENT_ID,
DEFAULT_MAIN_SESSION_SUFFIX,
buildMainSessionKey,
normalizeAgentId,
type AgentSummary,
type AgentsSnapshot,
} from '@runtime/lib/agents';
import { hostApiFetch } from '../lib/host-api';
export interface AgentsStoreState {
initialized: boolean;
loading: boolean;
error: string | null;
agents: AgentSummary[];
defaultAgentId: string;
defaultProviderAccountId: string | null;
defaultModelRef: string | null;
mainSessionSuffix: string;
}
const listeners = new Set<() => void>();
let loadAgentsInFlight: Promise<void> | null = null;
let state: AgentsStoreState = {
initialized: false,
loading: false,
error: null,
agents: [],
defaultAgentId: DEFAULT_AGENT_ID,
defaultProviderAccountId: null,
defaultModelRef: null,
mainSessionSuffix: DEFAULT_MAIN_SESSION_SUFFIX,
};
function emit(): void {
for (const listener of listeners) {
listener();
}
}
function patchState(patch: Partial<AgentsStoreState>): AgentsStoreState {
state = { ...state, ...patch };
emit();
return state;
}
function sanitizeAgent(agent: AgentSummary): AgentSummary {
const normalizedId = normalizeAgentId(agent.id);
const normalizedMainSessionKey = agent.mainSessionKey || buildMainSessionKey(normalizedId);
return {
id: normalizedId,
name: agent.name || normalizedId,
isDefault: Boolean(agent.isDefault),
providerAccountId: agent.providerAccountId ?? null,
modelRef: agent.modelRef ?? null,
modelDisplay: agent.modelDisplay || agent.modelRef || agent.name || normalizedId,
mainSessionKey: normalizedMainSessionKey,
vendorId: agent.vendorId ?? null,
source: agent.source,
};
}
async function loadAgents(): Promise<void> {
if (loadAgentsInFlight) {
await loadAgentsInFlight;
return;
}
loadAgentsInFlight = (async () => {
patchState({ loading: true, error: null });
try {
const snapshot = await hostApiFetch<AgentsSnapshot & { success?: boolean }>('/api/agents');
const agents = Array.isArray(snapshot?.agents)
? snapshot.agents.map((agent) => sanitizeAgent(agent))
: [];
patchState({
initialized: true,
loading: false,
error: null,
agents,
defaultAgentId: snapshot?.defaultAgentId ? normalizeAgentId(snapshot.defaultAgentId) : DEFAULT_AGENT_ID,
defaultProviderAccountId: snapshot?.defaultProviderAccountId ?? null,
defaultModelRef: snapshot?.defaultModelRef ?? null,
mainSessionSuffix: snapshot?.mainSessionSuffix || DEFAULT_MAIN_SESSION_SUFFIX,
});
} catch (error) {
patchState({
initialized: true,
loading: false,
error: error instanceof Error ? error.message : String(error),
});
}
})();
try {
await loadAgentsInFlight;
} finally {
loadAgentsInFlight = null;
}
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
function getSnapshot(): AgentsStoreState {
return state;
}
function getAgentById(agentId: string | null | undefined): AgentSummary | undefined {
const normalizedId = normalizeAgentId(agentId);
return state.agents.find((agent) => agent.id === normalizedId);
}
function resolveMainSessionKey(agentId: string | null | undefined): string {
const normalizedId = normalizeAgentId(agentId || state.defaultAgentId);
return getAgentById(normalizedId)?.mainSessionKey || buildMainSessionKey(normalizedId, state.mainSessionSuffix);
}
function resolveProviderAccountId(agentId: string | null | undefined): string | null {
const normalizedId = normalizeAgentId(agentId || state.defaultAgentId);
return getAgentById(normalizedId)?.providerAccountId ?? state.defaultProviderAccountId;
}
export const agentsStore = {
subscribe,
getSnapshot,
getState: () => state,
init: loadAgents,
load: loadAgents,
getAgentById,
resolveMainSessionKey,
resolveProviderAccountId,
};
export function useAgentsStore(): AgentsStoreState {
return useSyncExternalStore(agentsStore.subscribe, agentsStore.getSnapshot, agentsStore.getSnapshot);
}

View File

@@ -1,15 +1,24 @@
import { useSyncExternalStore } from 'react';
import {
DEFAULT_AGENT_ID as FALLBACK_AGENT_ID,
DEFAULT_MAIN_SESSION_SUFFIX,
buildAgentSessionKey,
buildMainSessionKey,
normalizeAgentId,
normalizeAgentSessionKey,
parseSessionKey,
} from '@runtime/lib/agents';
import type { ChatSession, RawMessage, ToolStatus } from '../shared/chat-model';
import { extractText, isToolOnlyMessage } from '../shared/chat-model';
import { gatewayRpc, onGatewayEvent } from '../lib/gateway-client';
import { hostApiFetch } from '../lib/host-api';
import type { GatewayEvent } from '../types/runtime';
import { agentsStore } from './agents';
const DEFAULT_SESSION_KEY = 'agent:main:main';
const DEFAULT_AGENT_ID = '1953462165250859011';
const SESSION_LOAD_MIN_INTERVAL_MS = 1200;
const HISTORY_LOAD_MIN_INTERVAL_MS = 800;
const CHAT_EVENT_DEDUPE_TTL_MS = 30000;
const FALLBACK_MAIN_SESSION_KEY = buildMainSessionKey(FALLBACK_AGENT_ID, DEFAULT_MAIN_SESSION_SUFFIX);
export interface StagedAttachment {
fileName: string;
@@ -19,6 +28,18 @@ export interface StagedAttachment {
preview: string | null;
}
interface TranscriptMessageLike extends Omit<RawMessage, 'timestamp'> {
timestamp?: string | number;
}
interface SessionTranscriptResponse {
sessionKey: string;
sessionId: string;
agentId: string;
transcriptPath: string;
messages: TranscriptMessageLike[];
}
export interface ChatStoreState {
initialized: boolean;
messages: RawMessage[];
@@ -58,8 +79,8 @@ let state: ChatStoreState = {
pendingFinal: false,
lastUserMessageAt: null,
sessions: [],
currentSessionKey: DEFAULT_SESSION_KEY,
currentAgentId: DEFAULT_AGENT_ID,
currentSessionKey: FALLBACK_MAIN_SESSION_KEY,
currentAgentId: FALLBACK_AGENT_ID,
sessionLabels: {},
sessionLastActivity: {},
gatewayStatus: 'disconnected',
@@ -78,9 +99,25 @@ function patchState(patch: Partial<ChatStoreState>): ChatStoreState {
}
function getAgentIdFromSessionKey(sessionKey: string): string {
if (!sessionKey.startsWith('agent:')) return DEFAULT_AGENT_ID;
const parts = sessionKey.split(':');
return parts[1] || DEFAULT_AGENT_ID;
const parsed = parseSessionKey(normalizeAgentSessionKey(sessionKey));
if (parsed.isAgentSession) return parsed.agentId;
return agentsStore.getState().defaultAgentId || FALLBACK_AGENT_ID;
}
function getDefaultAgentId(): string {
return agentsStore.getState().defaultAgentId || FALLBACK_AGENT_ID;
}
function getDefaultMainSessionKey(): string {
return agentsStore.resolveMainSessionKey(getDefaultAgentId()) || FALLBACK_MAIN_SESSION_KEY;
}
function resolveMainSessionKeyForAgent(agentId: string | null | undefined): string {
return agentsStore.resolveMainSessionKey(agentId || getDefaultAgentId()) || getDefaultMainSessionKey();
}
function buildNewSessionKey(agentId: string | null | undefined): string {
return buildAgentSessionKey(agentId || getDefaultAgentId(), `session-${Date.now()}`);
}
function clearSessionEntryFromMap<T extends Record<string, unknown>>(entries: T, sessionKey: string): T {
@@ -170,6 +207,14 @@ async function resolveDefaultAccountId(): Promise<string | null> {
}
}
async function resolveProviderAccountIdForAgent(agentId: string | null | undefined): Promise<string | null> {
const mappedAccountId = agentsStore.resolveProviderAccountId(agentId);
if (mappedAccountId) {
return mappedAccountId;
}
return resolveDefaultAccountId();
}
async function stageBuffer(base64: string, fileName: string, mimeType: string): Promise<StagedAttachment> {
try {
const result = await hostApiFetch<StagedAttachment>('/api/files/stage-buffer', {
@@ -194,6 +239,31 @@ async function stageBuffer(base64: string, fileName: string, mimeType: string):
};
}
function normalizeTranscriptMessage(message: TranscriptMessageLike): RawMessage {
const rawTimestamp = message.timestamp;
let timestamp: number | undefined;
if (typeof rawTimestamp === 'number') {
timestamp = rawTimestamp;
} else if (typeof rawTimestamp === 'string') {
const parsed = Date.parse(rawTimestamp);
timestamp = Number.isFinite(parsed) ? parsed : undefined;
}
return {
...message,
timestamp,
};
}
async function loadTranscriptHistory(sessionKey: string): Promise<RawMessage[]> {
const query = new URLSearchParams({ sessionKey });
const response = await hostApiFetch<SessionTranscriptResponse>(`/api/sessions/transcript?${query.toString()}`);
return Array.isArray(response?.messages)
? response.messages.map((message) => normalizeTranscriptMessage(message))
: [];
}
async function subscribeToGateway(): Promise<void> {
if (gatewaySubscribed) return;
@@ -213,6 +283,7 @@ async function subscribeToGateway(): Promise<void> {
}
async function loadSessions(): Promise<void> {
await agentsStore.init();
const now = Date.now();
if (loadSessionsInFlight) {
await loadSessionsInFlight;
@@ -225,19 +296,46 @@ async function loadSessions(): Promise<void> {
loadSessionsInFlight = (async () => {
try {
const localKeys = await gatewayRpc<string[]>('session.list', {});
let sessions: ChatSession[] = localKeys.map((key) => ({
const normalizedKeys = Array.from(
new Set(localKeys.map((key) => normalizeAgentSessionKey(key)).filter(Boolean)),
);
const canonicalBySuffix = new Map<string, string>();
for (const key of normalizedKeys) {
const parsed = parseSessionKey(key);
if (parsed.isAgentSession && parsed.sessionId && !canonicalBySuffix.has(parsed.sessionId)) {
canonicalBySuffix.set(parsed.sessionId, parsed.sessionKey);
}
}
let sessions: ChatSession[] = normalizedKeys.map((key) => ({
key,
displayName: state.sessionLabels[key] || 'New Chat',
updatedAt: state.sessionLastActivity[key] || Date.now(),
}));
const existingNonLocal = state.sessions.filter((session) => !session.key.startsWith('local:'));
sessions = [...existingNonLocal, ...sessions];
const existingTransient = state.sessions.filter((session) => {
const parsed = parseSessionKey(session.key);
return !parsed.isAgentSession;
});
sessions = [...existingTransient, ...sessions];
let nextSessionKey = state.currentSessionKey || DEFAULT_SESSION_KEY;
let nextSessionKey = state.currentSessionKey || getDefaultMainSessionKey();
const normalizedCurrentSessionKey = normalizeAgentSessionKey(nextSessionKey);
if (normalizedCurrentSessionKey.startsWith('agent:')) {
nextSessionKey = normalizedCurrentSessionKey;
}
if (!nextSessionKey.startsWith('agent:')) {
const canonicalMatch = canonicalBySuffix.get(nextSessionKey);
if (canonicalMatch) {
nextSessionKey = canonicalMatch;
}
}
if (!sessions.find((session) => session.key === nextSessionKey) && sessions.length > 0) {
nextSessionKey = sessions[0].key;
}
if (!nextSessionKey) {
nextSessionKey = getDefaultMainSessionKey();
}
const sessionsWithCurrent =
!sessions.find((session) => session.key === nextSessionKey) && nextSessionKey
@@ -261,7 +359,7 @@ async function loadSessions(): Promise<void> {
},
});
if (nextSessionKey && nextSessionKey !== DEFAULT_SESSION_KEY) {
if (nextSessionKey) {
await loadHistory(nextSessionKey, true);
}
@@ -316,7 +414,7 @@ async function loadSessions(): Promise<void> {
}
async function loadHistory(sessionKey = state.currentSessionKey, quiet = false): Promise<void> {
if (!sessionKey || sessionKey === DEFAULT_SESSION_KEY) {
if (!sessionKey) {
patchState({
messages: [],
loading: false,
@@ -341,10 +439,24 @@ async function loadHistory(sessionKey = state.currentSessionKey, quiet = false):
const loadPromise = (async () => {
try {
const messages = await gatewayRpc<RawMessage[]>('chat.history', {
sessionKey,
limit: 50,
});
let messages: RawMessage[] = [];
try {
messages = await gatewayRpc<RawMessage[]>('chat.history', {
sessionKey,
limit: 50,
});
} catch {
messages = [];
}
if (!Array.isArray(messages) || messages.length === 0) {
try {
messages = await loadTranscriptHistory(sessionKey);
} catch {
messages = [];
}
}
if (state.currentSessionKey !== sessionKey) return;
@@ -414,20 +526,14 @@ async function loadHistory(sessionKey = state.currentSessionKey, quiet = false):
}
}
async function newSession(): Promise<void> {
const defaultAccountId = await resolveDefaultAccountId();
if (!defaultAccountId) {
patchState({ error: '请先前往模型管理页面配置并设置一个默认模型' });
return;
}
async function newSession(agentId = state.currentAgentId): Promise<void> {
const leavingEmpty =
!state.currentSessionKey.endsWith(':main') &&
state.messages.length === 0 &&
!state.sessionLastActivity[state.currentSessionKey] &&
!state.sessionLabels[state.currentSessionKey];
const newKey = `local:${defaultAccountId}:${crypto.randomUUID()}`;
const newKey = buildNewSessionKey(agentId || getDefaultAgentId());
const nextSessions = leavingEmpty
? state.sessions.filter((session) => session.key !== state.currentSessionKey)
: state.sessions;
@@ -458,6 +564,16 @@ function switchSession(sessionKey: string): void {
void loadHistory(sessionKey);
}
function selectAgent(agentId: string): void {
const normalizedAgentId = normalizeAgentId(agentId);
const mainSessionKey = resolveMainSessionKeyForAgent(normalizedAgentId);
if (mainSessionKey === state.currentSessionKey) {
patchState({ currentAgentId: normalizedAgentId });
return;
}
switchSession(mainSessionKey);
}
async function deleteSession(sessionKey: string): Promise<void> {
try {
await gatewayRpc('session.delete', { sessionKey });
@@ -473,7 +589,7 @@ async function deleteSession(sessionKey: string): Promise<void> {
};
if (state.currentSessionKey === sessionKey) {
const nextSession = remaining[0]?.key ?? DEFAULT_SESSION_KEY;
const nextSession = remaining[0]?.key ?? getDefaultMainSessionKey();
patchState({
...basePatch,
currentSessionKey: nextSession,
@@ -487,7 +603,7 @@ async function deleteSession(sessionKey: string): Promise<void> {
lastUserMessageAt: null,
});
if (nextSession !== DEFAULT_SESSION_KEY) {
if (nextSession) {
await loadHistory(nextSession);
}
return;
@@ -512,17 +628,18 @@ async function sendMessage(text: string, attachments: StagedAttachment[] = []):
const trimmedText = text.trim();
if (!trimmedText && attachments.length === 0) return false;
const defaultAccountId = await resolveDefaultAccountId();
if (!defaultAccountId) {
let targetSessionKey = state.currentSessionKey || resolveMainSessionKeyForAgent(state.currentAgentId);
targetSessionKey = normalizeAgentSessionKey(targetSessionKey);
if (!targetSessionKey) {
targetSessionKey = getDefaultMainSessionKey();
}
const targetAgentId = getAgentIdFromSessionKey(targetSessionKey);
const providerAccountId = await resolveProviderAccountIdForAgent(targetAgentId);
if (!providerAccountId) {
patchState({ error: '请先前往模型管理页面配置并设置一个默认模型' });
return false;
}
let targetSessionKey = state.currentSessionKey;
if (!targetSessionKey || targetSessionKey === DEFAULT_SESSION_KEY) {
targetSessionKey = `local:${defaultAccountId}:${crypto.randomUUID()}`;
}
const nowMs = Date.now();
const userMessage: RawMessage = {
role: 'user',
@@ -559,7 +676,7 @@ async function sendMessage(text: string, attachments: StagedAttachment[] = []):
lastUserMessageAt: nowMs,
sessions: nextSessions,
currentSessionKey: targetSessionKey,
currentAgentId: getAgentIdFromSessionKey(targetSessionKey),
currentAgentId: targetAgentId,
sessionLabels: nextLabels,
sessionLastActivity: {
...state.sessionLastActivity,
@@ -583,7 +700,7 @@ async function sendMessage(text: string, attachments: StagedAttachment[] = []):
content: messageContent,
},
options: {
providerAccountId: defaultAccountId,
providerAccountId,
},
});
@@ -617,7 +734,7 @@ async function abortRun(): Promise<void> {
lastUserMessageAt: null,
});
if (!sessionKey || sessionKey === DEFAULT_SESSION_KEY) return;
if (!sessionKey) return;
try {
await gatewayRpc('chat.abort', { sessionKey });
@@ -733,6 +850,7 @@ function clearError(): void {
}
async function initChatStore(): Promise<void> {
await agentsStore.init();
await subscribeToGateway();
await loadSessions();
}
@@ -775,6 +893,7 @@ export const chatStore = {
loadHistory,
switchSession,
newSession,
selectAgent,
deleteSession,
renameSession,
sendMessage,

View File

@@ -1,4 +1,5 @@
export * from './settings';
export * from './agents';
export * from './chat';
export * from './task';
export * from './channel';