feat: update frontend runtime team controls

This commit is contained in:
2026-04-03 13:48:39 +08:00
parent ecfbd87244
commit a399384e07
9 changed files with 546 additions and 192 deletions

View File

@@ -382,7 +382,7 @@ export default function RuntimeSettingsPanel({
}}
>
<option value="daily">每日定时</option>
<option value="intraday">盘中轮询</option>
<option value="interval">间隔轮询</option>
</select>
</label>

View File

@@ -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`;
};

View File

@@ -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)) {

View File

@@ -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);

View File

@@ -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) => {

View File

@@ -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<Array>} List of analyst types
*/
export async function listAnalystTypes() {
return fetchJson(DYNAMIC_TEAM_ENDPOINTS.listTypes());
}
/**
* Get personas from personas.yaml
* @returns {Promise<Object>} 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<Object>} 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<Object>} 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<Object>} 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<Object>} 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<Object>} 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,
};

View File

@@ -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") {

View File

@@ -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 轮");
});
});