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