Files
zclaw_openfang/desktop/src/store/agentStore.ts
iven edecd4c81f
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
fix(saas): deep audit round industry template system - critical fixes
C1: Use backend createAgentFromTemplate API + tools forwarding
C3: seed source='builtin' instead of 'custom'
C4: immutable clone data handling (return fresh from store) + spread)
H3: assignTemplate error propagation (try/catch)
H4: input validation for name/fields
H5: assign_template account existence check
H6: remove dead route get_full_template
H7: model fallback gpt-4o-mini (hardcoded constant)
H8: logout clears template state
H9: console.warn -> structured logger
C2: restoreSession fetches assignedTemplate
2026-04-03 19:45:25 +08:00

374 lines
11 KiB
TypeScript

/**
* Agent Store - Manages clones/agents, usage statistics, and plugin status
*
* Extracted from gatewayStore.ts for Phase 11 Store Refactoring.
* This store focuses on agent/clone CRUD operations and related metadata.
*/
import { create } from 'zustand';
import type { GatewayClient } from '../lib/gateway-client';
import type { AgentTemplateFull } from '../lib/saas-client';
import { saasClient } from '../lib/saas-client';
import { useChatStore } from './chatStore';
import { useConversationStore } from './chat/conversationStore';
import { createLogger } from '../lib/logger';
const log = createLogger('AgentStore');
// === Types ===
export interface Clone {
id: string;
name: string;
role?: string;
nickname?: string;
scenarios?: string[];
model?: string;
workspaceDir?: string;
workspaceResolvedPath?: string;
restrictFiles?: boolean;
privacyOptIn?: boolean;
userName?: string;
userRole?: string;
createdAt: string;
bootstrapReady?: boolean;
bootstrapFiles?: Array<{ name: string; path: string; exists: boolean }>;
updatedAt?: string;
// 人格相关字段
emoji?: string; // Agent emoji, e.g., "🦞", "🤖", "💻"
personality?: string; // 人格风格: professional, friendly, creative, concise
communicationStyle?: string; // 沟通风格描述
notes?: string; // 用户备注
onboardingCompleted?: boolean; // 是否完成首次引导
source_template_id?: string; // 模板来源 ID
welcomeMessage?: string; // 模板预设欢迎语
quickCommands?: Array<{ label: string; command: string }>; // 模板预设快捷命令
}
export interface UsageStats {
totalSessions: number;
totalMessages: number;
totalTokens: number;
byModel: Record<string, { messages: number; inputTokens: number; outputTokens: number }>;
}
export interface PluginStatus {
id: string;
name?: string;
status: 'active' | 'inactive' | 'error' | 'loading';
version?: string;
description?: string;
}
export interface CloneCreateOptions {
name: string;
role?: string;
nickname?: string;
scenarios?: string[];
model?: string;
workspaceDir?: string;
restrictFiles?: boolean;
privacyOptIn?: boolean;
userName?: string;
userRole?: string;
// 人格相关字段
emoji?: string;
personality?: string;
communicationStyle?: string;
notes?: string;
}
// === Store State ===
export interface AgentStateSlice {
clones: Clone[];
usageStats: UsageStats | null;
pluginStatus: PluginStatus[];
isLoading: boolean;
error: string | null;
}
// === Store Actions ===
export interface AgentActionsSlice {
loadClones: () => Promise<void>;
createClone: (opts: CloneCreateOptions) => Promise<Clone | undefined>;
createFromTemplate: (template: AgentTemplateFull) => Promise<Clone | undefined>;
updateClone: (id: string, updates: Partial<Clone>) => Promise<Clone | undefined>;
deleteClone: (id: string) => Promise<void>;
loadUsageStats: () => Promise<void>;
loadPluginStatus: () => Promise<void>;
setError: (error: string | null) => void;
clearError: () => void;
}
// === Store Interface ===
export type AgentStore = AgentStateSlice & AgentActionsSlice;
// === Client Injection ===
// For coordinator to inject client - avoids direct import coupling
let _client: GatewayClient | null = null;
/**
* Sets the gateway client for the agent store.
* Called by the coordinator during initialization.
*/
export const setAgentStoreClient = (client: unknown): void => {
_client = client as GatewayClient;
};
/**
* Gets the gateway client.
* Returns null if not set (coordinator must initialize first).
*/
const getClient = (): GatewayClient | null => _client;
// === Store Implementation ===
export const useAgentStore = create<AgentStore>((set, get) => ({
// Initial state
clones: [],
usageStats: null,
pluginStatus: [],
isLoading: false,
error: null,
// Actions
loadClones: async () => {
const client = getClient();
if (!client) {
log.warn('[AgentStore] Client not initialized, skipping loadClones');
return;
}
try {
set({ isLoading: true, error: null });
const result = await client.listClones();
const clones = result?.clones || result?.agents || [];
set({ clones, isLoading: false });
// Set default agent ID if we have agents and none is set
if (clones.length > 0 && clones[0].id) {
const currentDefault = client.getDefaultAgentId();
// Only set if the default doesn't exist in the list
const defaultExists = clones.some((c: Clone) => c.id === currentDefault);
if (!defaultExists) {
client.setDefaultAgentId(clones[0].id);
}
}
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
set({ error: errorMessage, isLoading: false });
// Don't throw - clone loading is non-critical
}
},
createClone: async (opts: CloneCreateOptions) => {
const client = getClient();
if (!client) {
log.warn('[AgentStore] Client not initialized');
return undefined;
}
try {
set({ isLoading: true, error: null });
const result = await client.createClone(opts);
await get().loadClones(); // Refresh the list
return result?.clone;
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
set({ error: errorMessage, isLoading: false });
return undefined;
}
},
createFromTemplate: async (template: AgentTemplateFull) => {
const client = getClient();
if (!client) {
set({ error: 'Client not initialized' });
return undefined;
}
set({ isLoading: true, error: null });
try {
// Step 1: Call backend to get server-processed config (tools merge, model fallback)
const config = await saasClient.createAgentFromTemplate(template.id);
// Step 2: Create clone with merged data from backend
const result = await client.createClone({
name: config.name,
emoji: config.emoji,
personality: config.personality,
scenarios: template.scenarios,
communicationStyle: config.communication_style,
model: config.model,
});
const cloneId = result?.clone?.id;
if (cloneId) {
// Persist SOUL.md via identity system
if (config.soul_content) {
try {
const { intelligenceClient } = await import('../lib/intelligence-client');
await intelligenceClient.identity.updateFile(cloneId, 'soul', config.soul_content);
} catch (e) {
log.warn('Failed to persist soul_content:', e);
}
}
// Persist system_prompt via identity system
if (config.system_prompt) {
try {
const { intelligenceClient } = await import('../lib/intelligence-client');
await intelligenceClient.identity.updateFile(cloneId, 'system', config.system_prompt);
} catch (e) {
log.warn('Failed to persist system_prompt:', e);
}
}
// Persist temperature / max_tokens / tools / source_template_id / welcomeMessage / quickCommands
const metadata: Record<string, unknown> = {};
if (config.temperature != null) metadata.temperature = config.temperature;
if (config.max_tokens != null) metadata.maxTokens = config.max_tokens;
if (config.tools?.length) metadata.tools = config.tools;
metadata.source_template_id = template.id;
if (config.welcome_message) metadata.welcomeMessage = config.welcome_message;
if (config.quick_commands?.length) metadata.quickCommands = config.quick_commands;
if (Object.keys(metadata).length > 0) {
try {
await client.updateClone(cloneId, metadata);
} catch (e) {
log.warn('Failed to persist clone metadata:', e);
}
}
}
await get().loadClones();
// Return a fresh clone from the store (immutable — no in-place mutation)
const freshClone = get().clones.find((c) => c.id === cloneId);
if (freshClone) {
return {
...freshClone,
...(config.welcome_message ? { welcomeMessage: config.welcome_message } : {}),
...(config.quick_commands?.length ? { quickCommands: config.quick_commands } : {}),
};
}
return result?.clone as Clone | undefined;
} catch (error) {
set({ error: String(error) });
return undefined;
} finally {
set({ isLoading: false });
}
},
updateClone: async (id: string, updates: Partial<Clone>) => {
const client = getClient();
if (!client) {
log.warn('[AgentStore] Client not initialized');
return undefined;
}
try {
set({ isLoading: true, error: null });
const result = await client.updateClone(id, updates);
await get().loadClones(); // Refresh the list
return result?.clone;
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
set({ error: errorMessage, isLoading: false });
return undefined;
}
},
deleteClone: async (id: string) => {
const client = getClient();
if (!client) {
log.warn('[AgentStore] Client not initialized');
return;
}
try {
set({ isLoading: true, error: null });
await client.deleteClone(id);
await get().loadClones(); // Refresh the list
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
set({ error: errorMessage, isLoading: false });
}
},
loadUsageStats: async () => {
try {
const { conversations } = useConversationStore.getState();
const tokenData = useChatStore.getState().getTotalTokens();
let totalMessages = 0;
for (const conversation of conversations) {
for (const message of conversation.messages) {
if (message.role === 'user' || message.role === 'assistant') {
totalMessages += 1;
}
}
}
const stats: UsageStats = {
totalSessions: conversations.length,
totalMessages,
totalTokens: tokenData.total,
byModel: {},
};
set({ usageStats: stats });
} catch {
// Usage stats are non-critical, ignore errors silently
}
},
loadPluginStatus: async () => {
const client = getClient();
if (!client) {
log.warn('[AgentStore] Client not initialized, skipping loadPluginStatus');
return;
}
try {
const result = await client.getPluginStatus();
set({ pluginStatus: result?.plugins || [] });
} catch {
// Plugin status is non-critical, ignore errors silently
}
},
setError: (error: string | null) => {
set({ error });
},
clearError: () => {
set({ error: null });
},
}));
// === Selectors ===
/**
* Get a clone by ID
*/
export const selectCloneById = (id: string) => (state: AgentStore): Clone | undefined =>
state.clones.find((clone) => clone.id === id);
/**
* Get all active plugins
*/
export const selectActivePlugins = (state: AgentStore): PluginStatus[] =>
state.pluginStatus.filter((plugin) => plugin.status === 'active');
/**
* Check if any operation is in progress
*/
export const selectIsLoading = (state: AgentStore): boolean => state.isLoading;