diff --git a/frontend/README.md b/frontend/README.md index 61b9258..2a461da 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -24,7 +24,7 @@ That gives you: - trading service at `http://localhost:8001` - news service at `http://localhost:8002` - runtime service at `http://localhost:8003/api/runtime` -- gateway WebSocket at `ws://localhost:8765` +- gateway WebSocket at `ws://localhost:8765` started directly by `start-dev.sh` ## Frontend Environment Variables diff --git a/frontend/src/components/RuntimeSettingsPanel.jsx b/frontend/src/components/RuntimeSettingsPanel.jsx index 7273e76..5974d54 100644 --- a/frontend/src/components/RuntimeSettingsPanel.jsx +++ b/frontend/src/components/RuntimeSettingsPanel.jsx @@ -382,7 +382,7 @@ export default function RuntimeSettingsPanel({ }} > - + diff --git a/frontend/src/config/constants.js b/frontend/src/config/constants.js index 39cb882..eea5184 100644 --- a/frontend/src/config/constants.js +++ b/frontend/src/config/constants.js @@ -154,6 +154,32 @@ export const WS_URL = ? `${FALLBACK_WS_PROTOCOL}//${FALLBACK_WS_HOST}:8765` : `${FALLBACK_WS_PROTOCOL}//${FALLBACK_WS_HOST}${FALLBACK_WS_PORT}/ws`); +// Dynamic Team Management API +const DEFAULT_DYNAMIC_TEAM_API_BASE = isLocalDevHost() + ? "http://localhost:8003/api/dynamic-team" + : `${DEFAULT_CONTROL_API_BASE}/dynamic-team`; +export const DYNAMIC_TEAM_API_BASE = + trimTrailingSlash(import.meta.env.VITE_DYNAMIC_TEAM_API_BASE_URL || "") || + DEFAULT_DYNAMIC_TEAM_API_BASE; + +// Dynamic Team API Endpoints +export const DYNAMIC_TEAM_ENDPOINTS = { + // Get all available analyst types (builtin + runtime registered) + listTypes: () => `${DYNAMIC_TEAM_API_BASE}/types`, + // Get personas from personas.yaml + getPersonas: () => `${DYNAMIC_TEAM_API_BASE}/personas`, + // Create a new analyst + createAnalyst: (runId) => `${DYNAMIC_TEAM_API_BASE}/runs/${runId}/analysts`, + // Clone an existing analyst + cloneAnalyst: (runId) => `${DYNAMIC_TEAM_API_BASE}/runs/${runId}/analysts/clone`, + // Remove an analyst + removeAnalyst: (runId, agentId) => `${DYNAMIC_TEAM_API_BASE}/runs/${runId}/analysts/${agentId}`, + // Get analyst info + getAnalystInfo: (runId, agentId) => `${DYNAMIC_TEAM_API_BASE}/runs/${runId}/analysts/${agentId}`, + // Get team summary + getTeamSummary: (runId) => `${DYNAMIC_TEAM_API_BASE}/runs/${runId}/summary`, +}; + // Initial ticker symbols for the production watchlist export const INITIAL_TICKERS = [ { symbol: "AAPL", price: null, change: null }, @@ -170,3 +196,191 @@ export const INITIAL_TICKERS = [ { symbol: "COIN", price: null, change: null } ]; +// ============================================ +// Dynamic Analyst Team Management +// ============================================ + +/** + * Built-in analyst types that can be used as base for dynamic analysts + * + * IMPORTANT: When creating dynamic analysts, the agent_id MUST end with '_analyst' + * to receive analysis tool groups (fundamentals, technical, sentiment, valuation tools). + * Example: 'crypto_specialist_analyst' (correct) vs 'crypto_specialist' (incorrect) + */ +export const BUILTIN_ANALYST_TYPES = [ + { + typeId: "fundamentals_analyst", + name: "基本面分析师", + description: "Uses LLM to intelligently select analysis tools, focuses on financial data and company fundamental analysis", + icon: "fundamentals", + }, + { + typeId: "technical_analyst", + name: "技术分析师", + description: "Uses LLM to intelligently select analysis tools, focuses on technical indicators and chart analysis", + icon: "technical", + }, + { + typeId: "sentiment_analyst", + name: "情绪分析师", + description: "Uses LLM to intelligently select analysis tools, analyzes market sentiment and news sentiment", + icon: "sentiment", + }, + { + typeId: "valuation_analyst", + name: "估值分析师", + description: "Uses LLM to intelligently select analysis tools, focuses on company valuation and value assessment", + icon: "valuation", + }, +]; + +/** + * Default colors for dynamically created analysts + * Cycles through these colors for new analysts + */ +export const DYNAMIC_ANALYST_COLORS = [ + { bg: "#F9FDFF", text: "#1565C0", accent: "#1565C0" }, // Blue + { bg: "#FFF8F8", text: "#C62828", accent: "#C62828" }, // Red + { bg: "#FAFFFA", text: "#2E7D32", accent: "#2E7D32" }, // Green + { bg: "#FCFAFF", text: "#6A1B9A", accent: "#6A1B9A" }, // Purple + { bg: "#FFFCF7", text: "#E65100", accent: "#E65100" }, // Orange + { bg: "#F9FEFF", text: "#00838F", accent: "#00838F" }, // Cyan + { bg: "#FFF9F5", text: "#D84315", accent: "#D84315" }, // Deep Orange + { bg: "#F5F5FF", text: "#4527A0", accent: "#4527A0" }, // Deep Purple +]; + +/** + * Generate a color scheme for a dynamic analyst based on index + * @param {number} index - The index of the analyst + * @returns {Object} Color scheme object + */ +export const getDynamicAnalystColors = (index) => { + return DYNAMIC_ANALYST_COLORS[index % DYNAMIC_ANALYST_COLORS.length]; +}; + +/** + * Generate a default avatar URL for dynamic analysts + * Uses a hash of the agentId to select from available avatars + * @param {string} agentId - The agent ID + * @returns {string} Avatar URL + */ +export const getDynamicAnalystAvatar = (agentId) => { + const avatars = [ + CDN_ASSETS.companyRoom.agent_1, + CDN_ASSETS.companyRoom.agent_2, + CDN_ASSETS.companyRoom.agent_3, + CDN_ASSETS.companyRoom.agent_4, + CDN_ASSETS.companyRoom.agent_5, + CDN_ASSETS.companyRoom.agent_6, + ]; + // Simple hash function to consistently map agentId to an avatar + const hash = agentId.split("").reduce((acc, char) => { + return acc + char.charCodeAt(0); + }, 0); + return avatars[hash % avatars.length]; +}; + +/** + * Create a dynamic analyst configuration object + * @param {Object} config - Configuration object + * @param {string} config.agentId - Unique identifier + * @param {string} config.baseType - Base analyst type (e.g., "technical_analyst") + * @param {string} config.name - Display name + * @param {string[]} config.focus - Focus areas + * @param {string} config.description - Description + * @param {number} index - Index for color assignment + * @returns {Object} Complete agent configuration + */ +export const createDynamicAnalystConfig = ({ + agentId, + baseType, + name, + focus = [], + description = "", + index = 0, +}) => { + return { + id: agentId, + name: name || agentId, + role: name || agentId, + baseType, + focus, + description, + avatar: getDynamicAnalystAvatar(agentId), + colors: getDynamicAnalystColors(index), + isDynamic: true, + isCustom: true, + }; +}; + +/** + * Check if an agent is a dynamic analyst + * @param {Object} agent - Agent object + * @returns {boolean} + */ +export const isDynamicAnalyst = (agent) => { + return agent?.isDynamic === true || agent?.id?.includes("_"); +}; + +/** + * Validate agent ID format for dynamic analysts + * @param {string} agentId - Agent ID to validate + * @returns {Object} Validation result + */ +export const validateAgentId = (agentId) => { + const errors = []; + const warnings = []; + + if (!agentId) { + errors.push("Agent ID is required"); + } else if (typeof agentId !== "string") { + errors.push("Agent ID must be a string"); + } else { + if (agentId.length < 3) { + errors.push("Agent ID must be at least 3 characters"); + } + if (agentId.length > 50) { + errors.push("Agent ID must be at most 50 characters"); + } + if (!/^[a-zA-Z0-9_]+$/.test(agentId)) { + errors.push("Agent ID can only contain letters, numbers, and underscores"); + } + // Reserved IDs that cannot be used + const reservedIds = ["portfolio_manager", "risk_manager"]; + if (reservedIds.includes(agentId)) { + errors.push(`"${agentId}" is a reserved ID and cannot be used`); + } + // Warning: agent_id should end with '_analyst' to get analysis tools + if (!agentId.endsWith("_analyst")) { + warnings.push( + "Agent ID should end with '_analyst' to receive analysis tool groups" + ); + } + } + + return { + valid: errors.length === 0, + errors, + warnings, + }; +}; + +/** + * Generate a suggested agent ID from a name + * IMPORTANT: Agent ID must end with '_analyst' to receive analysis tools + * @param {string} name - Display name + * @param {string} baseType - Base analyst type + * @returns {string} Suggested agent ID (guaranteed to end with '_analyst') + */ +export const suggestAgentId = (name, baseType) => { + const timestamp = Date.now().toString(36).slice(-4); + const normalized = name + .toLowerCase() + .replace(/[^a-z0-9\s]/g, "") + .replace(/\s+/g, "_") + .replace(/_analyst$/, "") // Remove '_analyst' suffix if present to avoid duplication + .slice(0, 20); + // Must end with '_analyst' to get analysis tools registered + return `${normalized || baseType}_${timestamp}_analyst`; +}; + diff --git a/frontend/src/hooks/useAgentDataRequests.js b/frontend/src/hooks/useAgentDataRequests.js index 112142e..4cc3514 100644 --- a/frontend/src/hooks/useAgentDataRequests.js +++ b/frontend/src/hooks/useAgentDataRequests.js @@ -49,11 +49,24 @@ export function useAgentDataRequests(clientRef) { return runId; }, []); + const sendWs = useCallback((payload) => { + const client = clientRef.current; + if (!client) { + return false; + } + return client.send(payload); + }, [clientRef]); + const requestAgentSkills = useCallback((agentId) => { const normalized = typeof agentId === 'string' ? agentId.trim() : ''; if (!normalized) return false; setIsAgentSkillsLoading(true); setAgentSkillsFeedback(null); + const sent = sendWs({ type: 'get_agent_skills', agent_id: normalized }); + if (sent) { + return true; + } + void resolveRunId() .then((runId) => fetchAgentSkills(runId, normalized)) .then((payload) => { @@ -61,22 +74,19 @@ export function useAgentDataRequests(clientRef) { setIsAgentSkillsLoading(false); }) .catch(() => { - if (!clientRef.current) { - setIsAgentSkillsLoading(false); - return; - } - console.debug('REST agent skills request failed, falling back to websocket compatibility path'); - const success = clientRef.current.send({ type: 'get_agent_skills', agent_id: normalized }); - if (!success) { - setIsAgentSkillsLoading(false); - } + setIsAgentSkillsLoading(false); }); return true; - }, [clientRef, resolveRunId, setAgentSkillsByAgent, setIsAgentSkillsLoading, setAgentSkillsFeedback]); + }, [resolveRunId, sendWs, setAgentSkillsByAgent, setIsAgentSkillsLoading, setAgentSkillsFeedback]); const requestAgentProfile = useCallback((agentId) => { const normalized = typeof agentId === 'string' ? agentId.trim() : ''; if (!normalized) return false; + const sent = sendWs({ type: 'get_agent_profile', agent_id: normalized }); + if (sent) { + return true; + } + void resolveRunId() .then((runId) => fetchAgentProfile(runId, normalized)) .then((payload) => { @@ -85,20 +95,20 @@ export function useAgentDataRequests(clientRef) { [normalized]: payload?.profile && typeof payload.profile === 'object' ? payload.profile : {} })); }) - .catch(() => { - if (clientRef.current) { - console.debug('REST agent profile request failed, falling back to websocket compatibility path'); - clientRef.current.send({ type: 'get_agent_profile', agent_id: normalized }); - } - }); + .catch(() => {}); return true; - }, [clientRef, resolveRunId, setAgentProfilesByAgent]); + }, [resolveRunId, sendWs, setAgentProfilesByAgent]); const requestSkillDetail = useCallback((skillName) => { const normalized = typeof skillName === 'string' ? skillName.trim() : ''; if (!normalized) return false; const detailKey = `${selectedSkillAgentId}:${normalized}`; setSkillDetailLoadingKey(detailKey); + const sent = sendWs({ type: 'get_skill_detail', agent_id: selectedSkillAgentId, skill_name: normalized }); + if (sent) { + return true; + } + void resolveRunId() .then((runId) => fetchAgentSkillDetail(runId, selectedSkillAgentId, normalized)) .then((payload) => { @@ -110,18 +120,10 @@ export function useAgentDataRequests(clientRef) { setSkillDetailLoadingKey(null); }) .catch(() => { - if (!clientRef.current) { - setSkillDetailLoadingKey(null); - return; - } - console.debug('REST skill detail request failed, falling back to websocket compatibility path'); - const success = clientRef.current.send({ type: 'get_skill_detail', agent_id: selectedSkillAgentId, skill_name: normalized }); - if (!success) { - setSkillDetailLoadingKey(null); - } + setSkillDetailLoadingKey(null); }); return true; - }, [clientRef, resolveRunId, selectedSkillAgentId, setSkillDetailLoadingKey, setSkillDetailsByName]); + }, [resolveRunId, selectedSkillAgentId, sendWs, setSkillDetailLoadingKey, setSkillDetailsByName]); const handleCreateLocalSkill = useCallback((skillName) => { const normalized = typeof skillName === 'string' ? skillName.trim() : ''; @@ -131,6 +133,11 @@ export function useAgentDataRequests(clientRef) { } setAgentSkillsSavingKey(`${selectedSkillAgentId}:${normalized}:create`); setAgentSkillsFeedback(null); + const sent = sendWs({ type: 'create_agent_local_skill', agent_id: selectedSkillAgentId, skill_name: normalized }); + if (sent) { + return; + } + void resolveRunId() .then((runId) => createAgentLocalSkill(runId, selectedSkillAgentId, normalized)) .then(() => { @@ -140,19 +147,10 @@ export function useAgentDataRequests(clientRef) { requestSkillDetail(normalized); }) .catch(() => { - if (!clientRef.current) { - setAgentSkillsSavingKey(null); - setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' }); - return; - } - console.debug('REST local skill create failed, falling back to websocket compatibility path'); - const success = clientRef.current.send({ type: 'create_agent_local_skill', agent_id: selectedSkillAgentId, skill_name: normalized }); - if (!success) { - setAgentSkillsSavingKey(null); - setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' }); - } + setAgentSkillsSavingKey(null); + setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' }); }); - }, [clientRef, requestAgentSkills, requestSkillDetail, resolveRunId, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]); + }, [requestAgentSkills, requestSkillDetail, resolveRunId, selectedSkillAgentId, sendWs, setAgentSkillsFeedback, setAgentSkillsSavingKey]); const handleLocalSkillDraftChange = useCallback((skillName, content) => { const detailKey = `${selectedSkillAgentId}:${skillName}`; @@ -165,6 +163,11 @@ export function useAgentDataRequests(clientRef) { if (typeof content !== 'string') return; setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:content`); setAgentSkillsFeedback(null); + const sent = sendWs({ type: 'update_agent_local_skill', agent_id: selectedSkillAgentId, skill_name: skillName, content }); + if (sent) { + return; + } + void resolveRunId() .then((runId) => updateAgentLocalSkill(runId, selectedSkillAgentId, skillName, content)) .then(() => { @@ -173,23 +176,19 @@ export function useAgentDataRequests(clientRef) { requestSkillDetail(skillName); }) .catch(() => { - if (!clientRef.current) { - setAgentSkillsSavingKey(null); - setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' }); - return; - } - console.debug('REST local skill save failed, falling back to websocket compatibility path'); - const success = clientRef.current.send({ type: 'update_agent_local_skill', agent_id: selectedSkillAgentId, skill_name: skillName, content }); - if (!success) { - setAgentSkillsSavingKey(null); - setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' }); - } + setAgentSkillsSavingKey(null); + setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' }); }); - }, [clientRef, localSkillDraftsByKey, requestSkillDetail, resolveRunId, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]); + }, [localSkillDraftsByKey, requestSkillDetail, resolveRunId, selectedSkillAgentId, sendWs, setAgentSkillsFeedback, setAgentSkillsSavingKey]); const handleLocalSkillDelete = useCallback((skillName) => { setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:delete`); setAgentSkillsFeedback(null); + const sent = sendWs({ type: 'delete_agent_local_skill', agent_id: selectedSkillAgentId, skill_name: skillName }); + if (sent) { + return; + } + void resolveRunId() .then((runId) => deleteAgentLocalSkill(runId, selectedSkillAgentId, skillName)) .then(() => { @@ -198,23 +197,19 @@ export function useAgentDataRequests(clientRef) { requestAgentSkills(selectedSkillAgentId); }) .catch(() => { - if (!clientRef.current) { - setAgentSkillsSavingKey(null); - setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' }); - return; - } - console.debug('REST local skill delete failed, falling back to websocket compatibility path'); - const success = clientRef.current.send({ type: 'delete_agent_local_skill', agent_id: selectedSkillAgentId, skill_name: skillName }); - if (!success) { - setAgentSkillsSavingKey(null); - setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' }); - } + setAgentSkillsSavingKey(null); + setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' }); }); - }, [clientRef, requestAgentSkills, resolveRunId, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]); + }, [requestAgentSkills, resolveRunId, selectedSkillAgentId, sendWs, setAgentSkillsFeedback, setAgentSkillsSavingKey]); const handleRemoveSharedSkill = useCallback((skillName) => { setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:remove`); setAgentSkillsFeedback(null); + const sent = sendWs({ type: 'remove_agent_skill', agent_id: selectedSkillAgentId, skill_name: skillName }); + if (sent) { + return; + } + void resolveRunId() .then((runId) => disableAgentSkill(runId, selectedSkillAgentId, skillName)) .then(() => { @@ -223,24 +218,20 @@ export function useAgentDataRequests(clientRef) { requestAgentSkills(selectedSkillAgentId); }) .catch(() => { - if (!clientRef.current) { - setAgentSkillsSavingKey(null); - setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' }); - return; - } - console.debug('REST shared skill remove failed, falling back to websocket compatibility path'); - const success = clientRef.current.send({ type: 'remove_agent_skill', agent_id: selectedSkillAgentId, skill_name: skillName }); - if (!success) { - setAgentSkillsSavingKey(null); - setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' }); - } + setAgentSkillsSavingKey(null); + setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' }); }); - }, [clientRef, requestAgentSkills, resolveRunId, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]); + }, [requestAgentSkills, resolveRunId, selectedSkillAgentId, sendWs, setAgentSkillsFeedback, setAgentSkillsSavingKey]); const handleAgentSkillToggle = useCallback((skillName, enabled) => { const agentId = selectedSkillAgentId; setAgentSkillsSavingKey(`${agentId}:${skillName}`); setAgentSkillsFeedback(null); + const sent = sendWs({ type: 'update_agent_skill', agent_id: agentId, skill_name: skillName, enabled }); + if (sent) { + return; + } + void resolveRunId() .then((runId) => enabled ? enableAgentSkill(runId, agentId, skillName) @@ -251,19 +242,10 @@ export function useAgentDataRequests(clientRef) { requestAgentSkills(agentId); }) .catch(() => { - if (!clientRef.current) { - setAgentSkillsSavingKey(null); - setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' }); - return; - } - console.debug('REST skill toggle failed, falling back to websocket compatibility path'); - const success = clientRef.current.send({ type: 'update_agent_skill', agent_id: agentId, skill_name: skillName, enabled }); - if (!success) { - setAgentSkillsSavingKey(null); - setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' }); - } + setAgentSkillsSavingKey(null); + setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' }); }); - }, [clientRef, requestAgentSkills, resolveRunId, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]); + }, [requestAgentSkills, resolveRunId, selectedSkillAgentId, sendWs, setAgentSkillsFeedback, setAgentSkillsSavingKey]); const handleSkillAgentChange = useCallback((agentId) => { setSelectedSkillAgentId(agentId); @@ -278,6 +260,11 @@ export function useAgentDataRequests(clientRef) { if (!normalizedAgentId || !normalizedFilename) return false; setIsWorkspaceFileLoading(true); setWorkspaceFileFeedback(null); + const sent = sendWs({ type: 'get_agent_workspace_file', agent_id: normalizedAgentId, filename: normalizedFilename }); + if (sent) { + return true; + } + void resolveRunId() .then((runId) => fetchAgentWorkspaceFile(runId, normalizedAgentId, normalizedFilename)) .then((payload) => { @@ -292,18 +279,10 @@ export function useAgentDataRequests(clientRef) { setIsWorkspaceFileLoading(false); }) .catch(() => { - if (!clientRef.current) { - setIsWorkspaceFileLoading(false); - return; - } - console.debug('REST workspace file read failed, falling back to websocket compatibility path'); - const success = clientRef.current.send({ type: 'get_agent_workspace_file', agent_id: normalizedAgentId, filename: normalizedFilename }); - if (!success) { - setIsWorkspaceFileLoading(false); - } + setIsWorkspaceFileLoading(false); }); return true; - }, [clientRef, resolveRunId, setIsWorkspaceFileLoading, setWorkspaceDraftContent, setWorkspaceFileFeedback, setWorkspaceFilesByAgent]); + }, [resolveRunId, sendWs, setIsWorkspaceFileLoading, setWorkspaceDraftContent, setWorkspaceFileFeedback, setWorkspaceFilesByAgent]); const handleWorkspaceFileChange = useCallback((filename) => { useAgentStore.getState().setSelectedWorkspaceFile(filename); @@ -314,6 +293,16 @@ export function useAgentDataRequests(clientRef) { const key = `${selectedSkillAgentId}:${selectedWorkspaceFile}`; setWorkspaceFileSavingKey(key); setWorkspaceFileFeedback(null); + const sent = sendWs({ + type: 'update_agent_workspace_file', + agent_id: selectedSkillAgentId, + filename: selectedWorkspaceFile, + content: workspaceDraftContent + }); + if (sent) { + return; + } + void resolveRunId() .then((runId) => updateAgentWorkspaceFile(runId, selectedSkillAgentId, selectedWorkspaceFile, workspaceDraftContent)) .then((payload) => { @@ -328,24 +317,10 @@ export function useAgentDataRequests(clientRef) { })); }) .catch(() => { - if (!clientRef.current) { - setWorkspaceFileSavingKey(null); - setWorkspaceFileFeedback({ type: 'error', text: '连接未就绪,稍后重试' }); - return; - } - console.debug('REST workspace file save failed, falling back to websocket compatibility path'); - const success = clientRef.current.send({ - type: 'update_agent_workspace_file', - agent_id: selectedSkillAgentId, - filename: selectedWorkspaceFile, - content: workspaceDraftContent - }); - if (!success) { - setWorkspaceFileSavingKey(null); - setWorkspaceFileFeedback({ type: 'error', text: '发送失败,请检查连接状态' }); - } + setWorkspaceFileSavingKey(null); + setWorkspaceFileFeedback({ type: 'error', text: '连接未就绪,稍后重试' }); }); - }, [clientRef, resolveRunId, selectedSkillAgentId, selectedWorkspaceFile, setWorkspaceFileFeedback, setWorkspaceFileSavingKey, setWorkspaceFilesByAgent, workspaceDraftContent]); + }, [resolveRunId, selectedSkillAgentId, selectedWorkspaceFile, sendWs, setWorkspaceFileFeedback, setWorkspaceFileSavingKey, setWorkspaceFilesByAgent, workspaceDraftContent]); const handleUploadExternalSkill = useCallback(async (file) => { if (!(file instanceof File)) { diff --git a/frontend/src/hooks/useStockDataRequests.js b/frontend/src/hooks/useStockDataRequests.js index 05733a7..b931c02 100644 --- a/frontend/src/hooks/useStockDataRequests.js +++ b/frontend/src/hooks/useStockDataRequests.js @@ -26,6 +26,14 @@ export function useStockDataRequests(clientRef, { setRequestStockHistory, setReq const { setOhlcHistoryByTicker, setPriceHistoryByTicker, setHistorySourceByTicker, setNewsByTicker, setInsiderTradesByTicker } = useMarketStore(); + const sendWs = useCallback((payload) => { + const client = clientRef.current; + if (!client) { + return false; + } + return client.send(payload); + }, [clientRef]); + const requestStockHistory = useCallback((symbol, { force = false } = {}) => { const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : ''; if (!normalized) return false; @@ -40,6 +48,13 @@ export function useStockDataRequests(clientRef, { setRequestStockHistory, setReq start.setDate(start.getDate() - 120); const startDate = start.toISOString().slice(0, 10); + const wsPayload = { type: 'get_stock_history', ticker: normalized, lookback_days: 120 }; + const wsSent = sendWs(wsPayload); + if (wsSent) { + requestedStockHistoryRef.current.add(normalized); + return true; + } + if (hasDirectTradingService()) { void fetchStockHistoryDirect(normalized, startDate, endDate) .then((payload) => { @@ -59,42 +74,36 @@ export function useStockDataRequests(clientRef, { setRequestStockHistory, setReq setHistorySourceByTicker((prev) => ({ ...prev, [normalized]: 'trading_service' })); }) .catch((error) => { - console.error('Direct stock-history fetch failed, falling back to websocket:', error); - if (clientRef.current) { - const success = clientRef.current.send({ - type: 'get_stock_history', - ticker: normalized, - lookback_days: 120 - }); - if (success) requestedStockHistoryRef.current.add(normalized); - } + console.error('Direct stock-history fetch failed:', error); }); requestedStockHistoryRef.current.add(normalized); return true; } - if (!clientRef.current) return false; - const success = clientRef.current.send({ type: 'get_stock_history', ticker: normalized, lookback_days: 120 }); - if (success) requestedStockHistoryRef.current.add(normalized); - return success; - }, [clientRef, currentDate, setOhlcHistoryByTicker, setPriceHistoryByTicker, setHistorySourceByTicker]); + return false; + }, [currentDate, hasDirectTradingService, sendWs, setOhlcHistoryByTicker, setPriceHistoryByTicker, setHistorySourceByTicker]); const requestStockExplainEvents = useCallback((symbol) => { const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : ''; - if (!normalized || !clientRef.current) return false; - return clientRef.current.send({ type: 'get_stock_explain_events', ticker: normalized }); - }, [clientRef]); + if (!normalized) return false; + return sendWs({ type: 'get_stock_explain_events', ticker: normalized }); + }, [sendWs]); const requestStockNews = useCallback((symbol) => { const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : ''; - if (!normalized || !clientRef.current) return false; - return clientRef.current.send({ type: 'get_stock_news', ticker: normalized, lookback_days: 45, limit: 12 }); - }, [clientRef]); + if (!normalized) return false; + return sendWs({ type: 'get_stock_news', ticker: normalized, lookback_days: 45, limit: 12 }); + }, [sendWs]); const requestStockNewsForDate = useCallback((symbol, date) => { const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : ''; if (!normalized || !date) return false; + const wsSent = sendWs({ type: 'get_stock_news_for_date', ticker: normalized, date, limit: 20 }); + if (wsSent) { + return true; + } + if (hasDirectNewsService()) { void fetchNewsForDateDirect(normalized, date, 20) .then((payload) => { @@ -111,23 +120,19 @@ export function useStockDataRequests(clientRef, { setRequestStockHistory, setReq })); }) .catch((error) => { - console.error('Direct news-for-date fetch failed, falling back to websocket:', error); - if (clientRef.current) { - clientRef.current.send({ type: 'get_stock_news_for_date', ticker: normalized, date, limit: 20 }); - } + console.error('Direct news-for-date fetch failed:', error); }); return true; } - if (!clientRef.current) return false; - return clientRef.current.send({ type: 'get_stock_news_for_date', ticker: normalized, date, limit: 20 }); - }, [clientRef, setNewsByTicker]); + return false; + }, [hasDirectNewsService, sendWs, setNewsByTicker]); const requestStockNewsTimeline = useCallback((symbol) => { const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : ''; - if (!normalized || !clientRef.current) return false; - return clientRef.current.send({ type: 'get_stock_news_timeline', ticker: normalized, lookback_days: 90 }); - }, [clientRef]); + if (!normalized) return false; + return sendWs({ type: 'get_stock_news_timeline', ticker: normalized, lookback_days: 90 }); + }, [sendWs]); const requestStockNewsCategories = useCallback((symbol) => { const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : ''; @@ -141,6 +146,11 @@ export function useStockDataRequests(clientRef, { setRequestStockHistory, setReq start.setDate(start.getDate() - 90); const startDate = start.toISOString().slice(0, 10); + const wsSent = sendWs({ type: 'get_stock_news_categories', ticker: normalized, lookback_days: 90 }); + if (wsSent) { + return true; + } + if (hasDirectNewsService()) { void fetchNewsCategoriesDirect(normalized, startDate, endDate, 200) .then((payload) => { @@ -157,22 +167,23 @@ export function useStockDataRequests(clientRef, { setRequestStockHistory, setReq })); }) .catch((error) => { - console.error('Direct news-categories fetch failed, falling back to websocket:', error); - if (clientRef.current) { - clientRef.current.send({ type: 'get_stock_news_categories', ticker: normalized, lookback_days: 90 }); - } + console.error('Direct news-categories fetch failed:', error); }); return true; } - if (!clientRef.current) return false; - return clientRef.current.send({ type: 'get_stock_news_categories', ticker: normalized, lookback_days: 90 }); - }, [clientRef, currentDate, setNewsByTicker]); + return false; + }, [currentDate, hasDirectNewsService, sendWs, setNewsByTicker]); const requestStockInsiderTrades = useCallback((symbol, startDate = null, endDate = null) => { const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : ''; if (!normalized) return false; + const wsSent = sendWs({ type: 'get_stock_insider_trades', ticker: normalized, start_date: startDate, end_date: endDate, limit: 50 }); + if (wsSent) { + return true; + } + if (hasDirectTradingService()) { void fetchInsiderTradesDirect(normalized, startDate, endDate, 50) .then((payload) => { @@ -183,28 +194,29 @@ export function useStockDataRequests(clientRef, { setRequestStockHistory, setReq })); }) .catch((error) => { - console.error('Direct insider-trades fetch failed, falling back to websocket:', error); - if (clientRef.current) { - clientRef.current.send({ type: 'get_stock_insider_trades', ticker: normalized, start_date: startDate, end_date: endDate, limit: 50 }); - } + console.error('Direct insider-trades fetch failed:', error); }); return true; } - if (!clientRef.current) return false; - return clientRef.current.send({ type: 'get_stock_insider_trades', ticker: normalized, start_date: startDate, end_date: endDate, limit: 50 }); - }, [clientRef, setInsiderTradesByTicker]); + return false; + }, [hasDirectTradingService, sendWs, setInsiderTradesByTicker]); const requestStockTechnicalIndicators = useCallback((symbol) => { const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : ''; - if (!normalized || !clientRef.current) return false; - return clientRef.current.send({ type: 'get_stock_technical_indicators', ticker: normalized }); - }, [clientRef]); + if (!normalized) return false; + return sendWs({ type: 'get_stock_technical_indicators', ticker: normalized }); + }, [sendWs]); const requestStockRangeExplain = useCallback((symbol, startDate, endDate, articleIds = []) => { const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : ''; if (!normalized || !startDate || !endDate) return false; + const wsSent = sendWs({ type: 'get_stock_range_explain', ticker: normalized, start_date: startDate, end_date: endDate, article_ids: Array.isArray(articleIds) ? articleIds : [] }); + if (wsSent) { + return true; + } + if (hasDirectNewsService()) { void fetchRangeExplainDirect(normalized, startDate, endDate, articleIds) .then((payload) => { @@ -224,22 +236,23 @@ export function useStockDataRequests(clientRef, { setRequestStockHistory, setReq })); }) .catch((error) => { - console.error('Direct range explain fetch failed, falling back to websocket:', error); - if (clientRef.current) { - clientRef.current.send({ type: 'get_stock_range_explain', ticker: normalized, start_date: startDate, end_date: endDate, article_ids: Array.isArray(articleIds) ? articleIds : [] }); - } + console.error('Direct range explain fetch failed:', error); }); return true; } - if (!clientRef.current) return false; - return clientRef.current.send({ type: 'get_stock_range_explain', ticker: normalized, start_date: startDate, end_date: endDate, article_ids: Array.isArray(articleIds) ? articleIds : [] }); - }, [clientRef, setNewsByTicker]); + return false; + }, [hasDirectNewsService, sendWs, setNewsByTicker]); const requestStockStory = useCallback((symbol, asOfDate = null) => { const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : ''; if (!normalized) return false; + const wsSent = sendWs({ type: 'get_stock_story', ticker: normalized, as_of_date: asOfDate }); + if (wsSent) { + return true; + } + if (hasDirectNewsService()) { void fetchStockStoryDirect(normalized, asOfDate) .then((payload) => { @@ -258,22 +271,23 @@ export function useStockDataRequests(clientRef, { setRequestStockHistory, setReq })); }) .catch((error) => { - console.error('Direct story fetch failed, falling back to websocket:', error); - if (clientRef.current) { - clientRef.current.send({ type: 'get_stock_story', ticker: normalized, as_of_date: asOfDate }); - } + console.error('Direct story fetch failed:', error); }); return true; } - if (!clientRef.current) return false; - return clientRef.current.send({ type: 'get_stock_story', ticker: normalized, as_of_date: asOfDate }); - }, [clientRef, setNewsByTicker]); + return false; + }, [hasDirectNewsService, sendWs, setNewsByTicker]); const requestStockSimilarDays = useCallback((symbol, date, topK = 8) => { const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : ''; if (!normalized || !date) return false; + const wsSent = sendWs({ type: 'get_stock_similar_days', ticker: normalized, date, top_k: topK }); + if (wsSent) { + return true; + } + if (hasDirectNewsService()) { void fetchSimilarDaysDirect(normalized, date, topK) .then((payload) => { @@ -291,21 +305,17 @@ export function useStockDataRequests(clientRef, { setRequestStockHistory, setReq })); }) .catch((error) => { - console.error('Direct similar-days fetch failed, falling back to websocket:', error); - if (clientRef.current) { - clientRef.current.send({ type: 'get_stock_similar_days', ticker: normalized, date, top_k: topK }); - } + console.error('Direct similar-days fetch failed:', error); }); return true; } - if (!clientRef.current) return false; - return clientRef.current.send({ type: 'get_stock_similar_days', ticker: normalized, date, top_k: topK }); - }, [clientRef, setNewsByTicker]); + return false; + }, [hasDirectNewsService, sendWs, setNewsByTicker]); const requestStockEnrich = useCallback((symbol, options = {}) => { const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : ''; - if (!normalized || !clientRef.current) return false; + if (!normalized) return false; const startDate = typeof options.startDate === 'string' ? options.startDate.trim() : ''; const endDate = typeof options.endDate === 'string' ? options.endDate.trim() : ''; if (!startDate || !endDate) return false; @@ -316,7 +326,7 @@ export function useStockDataRequests(clientRef, { setRequestStockHistory, setReq maintenanceStatus: { running: true, error: null, updatedAt: new Date().toISOString(), stats: null } } })); - return clientRef.current.send({ + return sendWs({ type: 'run_stock_enrich', ticker: normalized, start_date: startDate, @@ -328,7 +338,7 @@ export function useStockDataRequests(clientRef, { setRequestStockHistory, setReq story_date: options.storyDate || null, target_date: options.targetDate || null }); - }, [clientRef, setNewsByTicker]); + }, [sendWs, setNewsByTicker]); // Register request functions with WebSocket connection hook if (setRequestStockHistory) setRequestStockHistory(requestStockHistory); diff --git a/frontend/src/hooks/useWebSocketConnection.js b/frontend/src/hooks/useWebSocketConnection.js index f29e988..defef39 100644 --- a/frontend/src/hooks/useWebSocketConnection.js +++ b/frontend/src/hooks/useWebSocketConnection.js @@ -652,6 +652,7 @@ export function useWebSocketConnection({ type: 'success', text: `${agentId} ${e.enabled ? '已启用' : '已禁用'} ${skillName}` }); + clientRef.current?.send({ type: 'get_agent_skills', agent_id: agentId }); }, agent_local_skill_created: (e) => { @@ -660,6 +661,8 @@ export function useWebSocketConnection({ setAgentSkillsSavingKey(null); if (!agentId || !skillName) return; setAgentSkillsFeedback({ type: 'success', text: `${agentId} 已创建本地技能 ${skillName}` }); + clientRef.current?.send({ type: 'get_agent_skills', agent_id: agentId }); + clientRef.current?.send({ type: 'get_skill_detail', agent_id: agentId, skill_name: skillName }); }, agent_local_skill_updated: (e) => { @@ -668,6 +671,7 @@ export function useWebSocketConnection({ setAgentSkillsSavingKey(null); if (!agentId || !skillName) return; setAgentSkillsFeedback({ type: 'success', text: `${agentId} 的本地技能 ${skillName} 已保存` }); + clientRef.current?.send({ type: 'get_skill_detail', agent_id: agentId, skill_name: skillName }); }, agent_local_skill_deleted: (e) => { @@ -686,6 +690,7 @@ export function useWebSocketConnection({ return next; }); setAgentSkillsFeedback({ type: 'success', text: `${agentId} 的本地技能 ${skillName} 已删除` }); + clientRef.current?.send({ type: 'get_agent_skills', agent_id: agentId }); }, agent_skill_removed: (e) => { @@ -694,6 +699,7 @@ export function useWebSocketConnection({ setAgentSkillsSavingKey(null); if (!agentId || !skillName) return; setAgentSkillsFeedback({ type: 'success', text: `${agentId} 已移除共享技能 ${skillName}` }); + clientRef.current?.send({ type: 'get_agent_skills', agent_id: agentId }); }, agent_workspace_file_loaded: (e) => { @@ -716,6 +722,7 @@ export function useWebSocketConnection({ const filename = typeof e.filename === 'string' ? e.filename.trim() : ''; if (!agentId || !filename) return; setWorkspaceFileFeedback({ type: 'success', text: `${agentId} 的 ${filename} 已保存` }); + clientRef.current?.send({ type: 'get_agent_workspace_file', agent_id: agentId, filename }); }, watchlist_updated: (e) => { diff --git a/frontend/src/services/dynamicTeamApi.js b/frontend/src/services/dynamicTeamApi.js new file mode 100644 index 0000000..c9ed413 --- /dev/null +++ b/frontend/src/services/dynamicTeamApi.js @@ -0,0 +1,147 @@ +/** + * Dynamic Team API Service + * + * Provides methods for managing analyst team dynamically: + * - Create new analysts with custom configuration + * - Clone existing analysts + * - Remove analysts + * - List available analyst types + * - Get analyst information + */ +import { DYNAMIC_TEAM_ENDPOINTS } from "../config/constants"; + +/** + * Fetch wrapper with error handling + */ +async function fetchJson(url, options = {}) { + const response = await fetch(url, { + headers: { + "Content-Type": "application/json", + }, + ...options, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`API error: ${response.status} - ${error}`); + } + + return response.json(); +} + +/** + * Get all available analyst types (builtin + runtime registered) + * @returns {Promise} List of analyst types + */ +export async function listAnalystTypes() { + return fetchJson(DYNAMIC_TEAM_ENDPOINTS.listTypes()); +} + +/** + * Get personas from personas.yaml + * @returns {Promise} Personas configuration + */ +export async function getPersonas() { + return fetchJson(DYNAMIC_TEAM_ENDPOINTS.getPersonas()); +} + +/** + * Create a new analyst + * @param {string} runId - The run configuration ID + * @param {Object} config - Analyst configuration + * @param {string} config.agent_id - Unique identifier + * @param {string} config.analyst_type - Base type or custom identifier + * @param {Object} [config.persona] - Custom persona definition + * @param {string} [config.soul_md] - Custom SOUL.md content + * @param {string} [config.agents_md] - Custom AGENTS.md content + * @param {string} [config.profile_md] - Custom PROFILE.md content + * @param {string} [config.model_name] - Override default model + * @param {string[]} [config.skills] - List of skill IDs + * @param {string[]} [config.tags] - Classification tags + * @returns {Promise} Creation result + */ +export async function createAnalyst(runId, config) { + return fetchJson(DYNAMIC_TEAM_ENDPOINTS.createAnalyst(runId), { + method: "POST", + body: JSON.stringify(config), + }); +} + +/** + * Clone an existing analyst + * @param {string} runId - The run configuration ID + * @param {Object} config - Clone configuration + * @param {string} config.source_id - Source analyst ID + * @param {string} config.new_id - New analyst ID + * @param {string} [config.name] - New display name + * @param {string[]} [config.focus_additions] - Additional focus areas + * @param {string} [config.description_override] - New description + * @param {string} [config.model_name] - Override model + * @returns {Promise} Clone result + */ +export async function cloneAnalyst(runId, config) { + return fetchJson(DYNAMIC_TEAM_ENDPOINTS.cloneAnalyst(runId), { + method: "POST", + body: JSON.stringify(config), + }); +} + +/** + * Remove a dynamically created analyst + * @param {string} runId - The run configuration ID + * @param {string} agentId - The analyst to remove + * @returns {Promise} Removal result + */ +export async function removeAnalyst(runId, agentId) { + return fetchJson(DYNAMIC_TEAM_ENDPOINTS.removeAnalyst(runId, agentId), { + method: "DELETE", + }); +} + +/** + * Get information about a specific analyst + * @param {string} runId - The run configuration ID + * @param {string} agentId - The analyst ID + * @returns {Promise} Analyst information + */ +export async function getAnalystInfo(runId, agentId) { + return fetchJson(DYNAMIC_TEAM_ENDPOINTS.getAnalystInfo(runId, agentId)); +} + +/** + * Get a summary of the current analyst team + * @param {string} runId - The run configuration ID + * @returns {Promise} Team summary + */ +export async function getTeamSummary(runId) { + return fetchJson(DYNAMIC_TEAM_ENDPOINTS.getTeamSummary(runId)); +} + +/** + * Hook for using dynamic team API in React components + * @param {string} runId - The run configuration ID + * @returns {Object} API methods + */ +export function useDynamicTeamApi(runId) { + return { + listTypes: () => listAnalystTypes(), + getPersonas: () => getPersonas(), + createAnalyst: (config) => createAnalyst(runId, config), + cloneAnalyst: (config) => cloneAnalyst(runId, config), + removeAnalyst: (agentId) => removeAnalyst(runId, agentId), + getAnalystInfo: (agentId) => getAnalystInfo(runId, agentId), + getTeamSummary: () => getTeamSummary(runId), + }; +} + +// Default export for convenience +export default { + listAnalystTypes, + getPersonas, + createAnalyst, + cloneAnalyst, + removeAnalyst, + getAnalystInfo, + getTeamSummary, + useDynamicTeamApi, +}; diff --git a/frontend/src/services/runtimeControls.js b/frontend/src/services/runtimeControls.js index 6c82c32..0b72c40 100644 --- a/frontend/src/services/runtimeControls.js +++ b/frontend/src/services/runtimeControls.js @@ -64,13 +64,14 @@ export const buildRuntimeSummaryLabel = (runtimeConfig) => { return null; } - const scheduleMode = String(runtimeConfig.schedule_mode || "daily"); + const rawScheduleMode = String(runtimeConfig.schedule_mode || "daily"); + const scheduleMode = rawScheduleMode === "intraday" ? "interval" : rawScheduleMode; const intervalMinutes = Number(runtimeConfig.interval_minutes || 60); const triggerTime = String(runtimeConfig.trigger_time || "now"); const maxCommCycles = Number(runtimeConfig.max_comm_cycles || 2); - if (scheduleMode === "intraday") { - return `调度 intraday / ${intervalMinutes}m / 讨论 ${maxCommCycles} 轮`; + if (scheduleMode === "interval") { + return `调度 interval / ${intervalMinutes}m / 讨论 ${maxCommCycles} 轮`; } if (triggerTime.toLowerCase() === "now") { diff --git a/frontend/src/services/runtimeControls.test.js b/frontend/src/services/runtimeControls.test.js index d9dadf6..64a9fef 100644 --- a/frontend/src/services/runtimeControls.test.js +++ b/frontend/src/services/runtimeControls.test.js @@ -51,9 +51,9 @@ describe("runtimeControls", () => { })).toBe("调度 daily / 09:30 ET / 讨论 3 轮"); expect(buildRuntimeSummaryLabel({ - schedule_mode: "intraday", + schedule_mode: "interval", interval_minutes: 15, max_comm_cycles: 2 - })).toBe("调度 intraday / 15m / 讨论 2 轮"); + })).toBe("调度 interval / 15m / 讨论 2 轮"); }); });