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
深入追踪 B9 (Agent 创建 502): - SaaS create_agent_from_template 端点代码不可能产生 502 (SaasError::Relay 是唯一 502 来源,仅 relay 模块使用) - 前端 createFromTemplate 双层 try-catch + fallback 已足够健壮 - 结论: B9 不可复现,可能因当时 SaaS 未运行或 token 过期导致 改进: - handlers.rs: 添加 create_agent_from_template 请求/响应日志 - agentStore.ts: outer catch 记录 status + message 便于未来诊断
476 lines
16 KiB
TypeScript
476 lines
16 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 { getGatewayVersion } from './connectionStore';
|
|
import { useSaaSStore } from './saasStore';
|
|
import { createLogger } from '../lib/logger';
|
|
|
|
const log = createLogger('AgentStore');
|
|
|
|
// === Error Classification ===
|
|
|
|
/**
|
|
* Extract HTTP status code from typed errors or Tauri invoke errors.
|
|
* Falls back to substring matching only for untyped error strings.
|
|
*/
|
|
function classifyAgentError(err: unknown, prefix = '操作失败'): string {
|
|
// Typed error paths — no false positives
|
|
if (err && typeof err === 'object') {
|
|
const status = (err as { status?: number }).status;
|
|
if (typeof status === 'number') {
|
|
if (status === 502) return `${prefix}:后端服务暂时不可用,请稍后重试。如果问题持续,请检查 Provider Key 是否已激活。`;
|
|
if (status === 503) return `${prefix}:服务暂不可用,请稍后重试。`;
|
|
if (status === 401) return `${prefix}:登录已过期,请重新登录后重试。`;
|
|
if (status === 403) return `${prefix}:权限不足,请检查账户权限。`;
|
|
if (status === 429) return `${prefix}:请求过于频繁,请稍后重试。`;
|
|
if (status === 500) return `${prefix}:服务器内部错误,请稍后重试。`;
|
|
}
|
|
}
|
|
// Fallback: generic message, no internal details leaked
|
|
return `${prefix}:发生未知错误,请稍后重试。`;
|
|
}
|
|
|
|
// === 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 () => {
|
|
let client = getClient();
|
|
|
|
// Retry up to 3 times with short delay if client isn't ready yet.
|
|
// This handles the race where connected fires before coordinator
|
|
// injects the client into the store.
|
|
for (let attempt = 0; attempt < 3 && !client; attempt++) {
|
|
await new Promise((r) => setTimeout(r, 300));
|
|
client = getClient();
|
|
}
|
|
|
|
if (!client) {
|
|
log.warn('[AgentStore] Client not initialized after retries, 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) {
|
|
log.error('[AgentStore] createClone error:', err);
|
|
const userMsg = classifyAgentError(err, '创建失败');
|
|
set({ error: userMsg, 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)
|
|
// Fallback to template data directly if SaaS is unreachable
|
|
let config;
|
|
try {
|
|
config = await saasClient.createAgentFromTemplate(template.id);
|
|
} catch (saasErr) {
|
|
log.warn('[AgentStore] SaaS createAgentFromTemplate failed, using template directly:', saasErr);
|
|
// Fallback: build config from template data without server-side tools merge
|
|
config = {
|
|
name: template.name,
|
|
model: template.model,
|
|
system_prompt: template.system_prompt,
|
|
tools: template.tools || [],
|
|
soul_content: template.soul_content,
|
|
welcome_message: template.welcome_message,
|
|
quick_commands: template.quick_commands,
|
|
temperature: template.temperature,
|
|
max_tokens: template.max_tokens,
|
|
personality: template.personality,
|
|
communication_style: template.communication_style,
|
|
emoji: template.emoji,
|
|
};
|
|
}
|
|
|
|
// Resolve model: template model > first available SaaS model > 'default'
|
|
const resolvedModel = config.model
|
|
?? useSaaSStore.getState().availableModels[0]?.id
|
|
?? 'default';
|
|
|
|
// Step 2: Create clone with merged data from backend
|
|
// In saas-relay mode the local Kernel may not be running,
|
|
// so wrap createClone in a try-catch and skip gracefully.
|
|
let cloneId: string | undefined;
|
|
let freshClone: Clone | undefined;
|
|
try {
|
|
const result = await client.createClone({
|
|
name: config.name,
|
|
emoji: config.emoji,
|
|
personality: config.personality,
|
|
scenarios: template.scenarios,
|
|
communicationStyle: config.communication_style,
|
|
model: resolvedModel,
|
|
});
|
|
cloneId = result?.clone?.id;
|
|
} catch (cloneErr) {
|
|
log.warn('[AgentStore] createClone failed (likely saas-relay mode without local kernel):', cloneErr);
|
|
// In SaaS relay mode, the agent was already created server-side in Step 1.
|
|
// Just refresh the clone list from the server.
|
|
await get().loadClones();
|
|
freshClone = get().clones.find(c => c.name === config.name);
|
|
if (freshClone) {
|
|
cloneId = freshClone.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 storedClone = get().clones.find((c) => c.id === cloneId);
|
|
if (storedClone) {
|
|
return {
|
|
...storedClone,
|
|
...(config.welcome_message ? { welcomeMessage: config.welcome_message } : {}),
|
|
...(config.quick_commands?.length ? { quickCommands: config.quick_commands } : {}),
|
|
};
|
|
}
|
|
// Fallback: if clone was found by name earlier in the catch path
|
|
if (freshClone) {
|
|
return {
|
|
...freshClone,
|
|
...(config.welcome_message ? { welcomeMessage: config.welcome_message } : {}),
|
|
...(config.quick_commands?.length ? { quickCommands: config.quick_commands } : {}),
|
|
};
|
|
}
|
|
return undefined;
|
|
} catch (error) {
|
|
const status = error && typeof error === 'object' ? (error as { status?: number }).status : undefined;
|
|
log.error('[AgentStore] createFromTemplate error:', { status, message: error instanceof Error ? error.message : String(error) });
|
|
const userMsg = classifyAgentError(error, '创建失败');
|
|
set({ error: userMsg });
|
|
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) {
|
|
log.error('[AgentStore] updateClone error:', err);
|
|
set({ error: classifyAgentError(err, '更新失败'), 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) {
|
|
log.error('[AgentStore] deleteClone error:', err);
|
|
set({ error: classifyAgentError(err, '删除失败'), 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: {},
|
|
};
|
|
|
|
// P2-10 修复: saas-relay 模式下从服务端获取真实用量
|
|
const gwVersion = getGatewayVersion();
|
|
if (gwVersion === 'saas-relay') {
|
|
try {
|
|
const sub = await saasClient.getSubscription();
|
|
if (sub?.usage) {
|
|
const serverTokens = (sub.usage.input_tokens ?? 0) + (sub.usage.output_tokens ?? 0);
|
|
if (serverTokens > 0) {
|
|
stats.totalTokens = serverTokens;
|
|
}
|
|
}
|
|
} catch {
|
|
// 降级到本地计数器
|
|
}
|
|
}
|
|
|
|
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;
|