onInputChange(e.target.value)}
onKeyDown={onInputKeyDown}
diff --git a/frontend/src/components/explain/ExplainPriceSection.jsx b/frontend/src/components/explain/ExplainPriceSection.jsx
index 3e50d86..43e8547 100644
--- a/frontend/src/components/explain/ExplainPriceSection.jsx
+++ b/frontend/src/components/explain/ExplainPriceSection.jsx
@@ -11,6 +11,37 @@ export default function ExplainPriceSection({
isOpen,
onToggle,
}) {
+ const timeTicks = (() => {
+ const candles = Array.isArray(chartModel?.candles) ? chartModel.candles : [];
+ if (!candles.length) {
+ return [];
+ }
+
+ const targetCount = Math.min(4, candles.length);
+ const step = Math.max(1, Math.floor((candles.length - 1) / Math.max(targetCount - 1, 1)));
+ const ticks = [];
+
+ for (let index = 0; index < candles.length; index += step) {
+ const candle = candles[index];
+ const rawLabel = candle.startLabel || candle.time || candle.date || '';
+ ticks.push({
+ x: candle.centerX,
+ label: String(rawLabel).slice(5, 16).replace('T', ' '),
+ });
+ }
+
+ const lastCandle = candles[candles.length - 1];
+ const lastLabel = String(lastCandle.endLabel || lastCandle.time || lastCandle.date || '').slice(5, 16).replace('T', ' ');
+ if (ticks.length === 0 || ticks[ticks.length - 1]?.x !== lastCandle.centerX) {
+ ticks.push({
+ x: lastCandle.centerX,
+ label: lastLabel,
+ });
+ }
+
+ return ticks;
+ })();
+
return (
@@ -66,12 +97,35 @@ export default function ExplainPriceSection({
strokeWidth="1"
/>
+ {timeTicks.map((tick) => (
+
+
+
+ {tick.label}
+
+
+ ))}
+
{chartModel.candles.length > 1 ? chartModel.candles.map((candle) => {
const rising = candle.close >= candle.open;
const stroke = rising ? '#00C853' : '#FF1744';
const fill = rising ? 'rgba(0, 200, 83, 0.16)' : 'rgba(255, 23, 68, 0.16)';
return (
+ {`${candle.startLabel || candle.time || candle.date || ''} → ${candle.endLabel || candle.time || candle.date || ''}`}
- {`${marker.title} · ${marker.dateKey || ''}${marker.count ? ` · ${marker.count} 条新闻` : ''}`}
+ {`${marker.title} · ${marker.timestamp || marker.dateKey || ''}${marker.count ? ` · ${marker.count} 条新闻` : ''}`}
);
})}
diff --git a/frontend/src/hooks/useAgentDataRequests.js b/frontend/src/hooks/useAgentDataRequests.js
new file mode 100644
index 0000000..28c2f0b
--- /dev/null
+++ b/frontend/src/hooks/useAgentDataRequests.js
@@ -0,0 +1,211 @@
+import { useCallback } from 'react';
+import { uploadAgentSkillZip } from '../services/runtimeApi';
+import { useAgentStore } from '../store/agentStore';
+
+/**
+ * Custom hook for agent operation callbacks.
+ * Takes clientRef, uses agentStore.
+ */
+export function useAgentDataRequests(clientRef) {
+ const {
+ selectedSkillAgentId,
+ setSelectedSkillAgentId,
+ setIsAgentSkillsLoading,
+ setAgentSkillsFeedback,
+ setAgentSkillsSavingKey,
+ setSkillDetailLoadingKey,
+ localSkillDraftsByKey,
+ selectedWorkspaceFile,
+ setWorkspaceDraftContent,
+ workspaceDraftContent,
+ setWorkspaceFileFeedback,
+ setWorkspaceFileSavingKey,
+ setIsWorkspaceFileLoading
+ } = useAgentStore();
+
+ const requestAgentSkills = useCallback((agentId) => {
+ const normalized = typeof agentId === 'string' ? agentId.trim() : '';
+ if (!normalized || !clientRef.current) return false;
+ setIsAgentSkillsLoading(true);
+ setAgentSkillsFeedback(null);
+ return clientRef.current.send({ type: 'get_agent_skills', agent_id: normalized });
+ }, [clientRef, setIsAgentSkillsLoading, setAgentSkillsFeedback]);
+
+ const requestAgentProfile = useCallback((agentId) => {
+ const normalized = typeof agentId === 'string' ? agentId.trim() : '';
+ if (!normalized || !clientRef.current) return false;
+ return clientRef.current.send({ type: 'get_agent_profile', agent_id: normalized });
+ }, [clientRef]);
+
+ const requestSkillDetail = useCallback((skillName) => {
+ const normalized = typeof skillName === 'string' ? skillName.trim() : '';
+ if (!normalized || !clientRef.current) return false;
+ const detailKey = `${selectedSkillAgentId}:${normalized}`;
+ setSkillDetailLoadingKey(detailKey);
+ return clientRef.current.send({ type: 'get_skill_detail', agent_id: selectedSkillAgentId, skill_name: normalized });
+ }, [clientRef, selectedSkillAgentId, setSkillDetailLoadingKey]);
+
+ const handleCreateLocalSkill = useCallback((skillName) => {
+ const normalized = typeof skillName === 'string' ? skillName.trim() : '';
+ if (!normalized) {
+ setAgentSkillsFeedback({ type: 'error', text: '技能名称不能为空' });
+ return;
+ }
+ if (!clientRef.current) {
+ setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
+ return;
+ }
+ setAgentSkillsSavingKey(`${selectedSkillAgentId}:${normalized}:create`);
+ setAgentSkillsFeedback(null);
+ const success = clientRef.current.send({ type: 'create_agent_local_skill', agent_id: selectedSkillAgentId, skill_name: normalized });
+ if (!success) {
+ setAgentSkillsSavingKey(null);
+ setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
+ }
+ }, [clientRef, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
+
+ const handleLocalSkillDraftChange = useCallback((skillName, content) => {
+ const detailKey = `${selectedSkillAgentId}:${skillName}`;
+ useAgentStore.getState().setLocalSkillDraftsByKey((prev) => ({ ...prev, [detailKey]: content }));
+ }, [selectedSkillAgentId]);
+
+ const handleLocalSkillSave = useCallback((skillName) => {
+ if (!clientRef.current) {
+ setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
+ return;
+ }
+ const detailKey = `${selectedSkillAgentId}:${skillName}`;
+ const content = localSkillDraftsByKey[detailKey];
+ if (typeof content !== 'string') return;
+ setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:content`);
+ setAgentSkillsFeedback(null);
+ 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: '发送失败,请检查连接状态' });
+ }
+ }, [clientRef, localSkillDraftsByKey, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
+
+ const handleLocalSkillDelete = useCallback((skillName) => {
+ if (!clientRef.current) {
+ setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
+ return;
+ }
+ setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:delete`);
+ setAgentSkillsFeedback(null);
+ const success = clientRef.current.send({ type: 'delete_agent_local_skill', agent_id: selectedSkillAgentId, skill_name: skillName });
+ if (!success) {
+ setAgentSkillsSavingKey(null);
+ setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
+ }
+ }, [clientRef, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
+
+ const handleRemoveSharedSkill = useCallback((skillName) => {
+ if (!clientRef.current) {
+ setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
+ return;
+ }
+ setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:remove`);
+ setAgentSkillsFeedback(null);
+ const success = clientRef.current.send({ type: 'remove_agent_skill', agent_id: selectedSkillAgentId, skill_name: skillName });
+ if (!success) {
+ setAgentSkillsSavingKey(null);
+ setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
+ }
+ }, [clientRef, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
+
+ const handleAgentSkillToggle = useCallback((skillName, enabled) => {
+ if (!clientRef.current) {
+ setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
+ return;
+ }
+ const agentId = selectedSkillAgentId;
+ setAgentSkillsSavingKey(`${agentId}:${skillName}`);
+ setAgentSkillsFeedback(null);
+ const success = clientRef.current.send({ type: 'update_agent_skill', agent_id: agentId, skill_name: skillName, enabled });
+ if (!success) {
+ setAgentSkillsSavingKey(null);
+ setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
+ }
+ }, [clientRef, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
+
+ const handleSkillAgentChange = useCallback((agentId) => {
+ setSelectedSkillAgentId(agentId);
+ requestAgentProfile(agentId);
+ requestAgentSkills(agentId);
+ requestWorkspaceFile(agentId, selectedWorkspaceFile);
+ }, [requestAgentProfile, requestAgentSkills, setSelectedSkillAgentId, selectedWorkspaceFile]);
+
+ const requestWorkspaceFile = useCallback((agentId, filename) => {
+ const normalizedAgentId = typeof agentId === 'string' ? agentId.trim() : '';
+ const normalizedFilename = typeof filename === 'string' ? filename.trim() : '';
+ if (!normalizedAgentId || !normalizedFilename || !clientRef.current) return false;
+ setIsWorkspaceFileLoading(true);
+ setWorkspaceFileFeedback(null);
+ return clientRef.current.send({ type: 'get_agent_workspace_file', agent_id: normalizedAgentId, filename: normalizedFilename });
+ }, [clientRef, setIsWorkspaceFileLoading, setWorkspaceFileFeedback]);
+
+ const handleWorkspaceFileChange = useCallback((filename) => {
+ useAgentStore.getState().setSelectedWorkspaceFile(filename);
+ requestWorkspaceFile(selectedSkillAgentId, filename);
+ }, [requestWorkspaceFile, selectedSkillAgentId]);
+
+ const handleWorkspaceFileSave = useCallback(() => {
+ if (!clientRef.current) {
+ setWorkspaceFileFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
+ return;
+ }
+ const key = `${selectedSkillAgentId}:${selectedWorkspaceFile}`;
+ setWorkspaceFileSavingKey(key);
+ setWorkspaceFileFeedback(null);
+ 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: '发送失败,请检查连接状态' });
+ }
+ }, [clientRef, selectedSkillAgentId, selectedWorkspaceFile, setWorkspaceFileFeedback, setWorkspaceFileSavingKey, workspaceDraftContent]);
+
+ const handleUploadExternalSkill = useCallback(async (file) => {
+ if (!(file instanceof File)) {
+ setAgentSkillsFeedback({ type: 'error', text: '请选择 zip 文件后再上传' });
+ return;
+ }
+ if (!selectedSkillAgentId) {
+ setAgentSkillsFeedback({ type: 'error', text: '未选择目标 Agent' });
+ return;
+ }
+ setAgentSkillsSavingKey(`${selectedSkillAgentId}:__upload__`);
+ setAgentSkillsFeedback(null);
+ try {
+ const result = await uploadAgentSkillZip({ agentId: selectedSkillAgentId, file, activate: true });
+ setAgentSkillsFeedback({ type: 'success', text: `已上传并安装技能 ${result.skill_name || ''}`.trim() });
+ requestAgentSkills(selectedSkillAgentId);
+ } catch (error) {
+ setAgentSkillsFeedback({ type: 'error', text: `上传失败: ${error.message || '未知错误'}` });
+ } finally {
+ setAgentSkillsSavingKey(null);
+ }
+ }, [requestAgentSkills, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
+
+ return {
+ requestAgentSkills,
+ requestAgentProfile,
+ requestSkillDetail,
+ handleCreateLocalSkill,
+ handleLocalSkillDraftChange,
+ handleLocalSkillSave,
+ handleLocalSkillDelete,
+ handleRemoveSharedSkill,
+ handleAgentSkillToggle,
+ handleSkillAgentChange,
+ requestWorkspaceFile,
+ handleWorkspaceFileChange,
+ handleWorkspaceFileSave,
+ handleUploadExternalSkill
+ };
+}
diff --git a/frontend/src/hooks/useAgentWorkspacePanel.js b/frontend/src/hooks/useAgentWorkspacePanel.js
new file mode 100644
index 0000000..179a733
--- /dev/null
+++ b/frontend/src/hooks/useAgentWorkspacePanel.js
@@ -0,0 +1,385 @@
+import { useCallback, useEffect } from "react";
+
+import { AGENTS } from "../config/constants";
+import { uploadAgentSkillZip } from "../services/runtimeApi";
+
+export function useAgentWorkspacePanel({
+ clientRef,
+ currentView,
+ isConnected,
+ connectionStatus,
+ selectedSkillAgentId,
+ selectedWorkspaceFile,
+ selectedWorkspaceContent,
+ localSkillDraftsByKey,
+ agentProfilesByAgent,
+ agentSkillsByAgent,
+ workspaceFilesByAgent,
+ workspaceDraftContent,
+ setSelectedSkillAgentId,
+ setSelectedWorkspaceFile,
+ setWorkspaceDraftContent,
+ setIsAgentSkillsLoading,
+ setAgentSkillsFeedback,
+ setSkillDetailLoadingKey,
+ setAgentSkillsSavingKey,
+ setLocalSkillDraftsByKey,
+ setIsWorkspaceFileLoading,
+ setWorkspaceFileFeedback,
+ setWorkspaceFileSavingKey
+}) {
+ const sendWithRetry = useCallback((payload, retries = 3, delayMs = 250) => {
+ const attemptSend = (remaining) => {
+ const client = clientRef.current;
+ if (!client) {
+ return false;
+ }
+ const sent = client.send(payload);
+ if (sent || remaining <= 0) {
+ return sent;
+ }
+ window.setTimeout(() => {
+ attemptSend(remaining - 1);
+ }, delayMs);
+ return false;
+ };
+
+ return attemptSend(retries);
+ }, [clientRef]);
+
+ const requestAgentSkills = useCallback((agentId) => {
+ const normalized = typeof agentId === "string" ? agentId.trim() : "";
+ if (!normalized || !clientRef.current) {
+ return false;
+ }
+ setIsAgentSkillsLoading(true);
+ setAgentSkillsFeedback(null);
+ return sendWithRetry({
+ type: "get_agent_skills",
+ agent_id: normalized
+ });
+ }, [clientRef, sendWithRetry, setAgentSkillsFeedback, setIsAgentSkillsLoading]);
+
+ const requestAgentProfile = useCallback((agentId) => {
+ const normalized = typeof agentId === "string" ? agentId.trim() : "";
+ if (!normalized || !clientRef.current) {
+ return false;
+ }
+ return sendWithRetry({
+ type: "get_agent_profile",
+ agent_id: normalized
+ });
+ }, [clientRef, sendWithRetry]);
+
+ const requestSkillDetail = useCallback((skillName) => {
+ const normalized = typeof skillName === "string" ? skillName.trim() : "";
+ if (!normalized || !clientRef.current) {
+ return false;
+ }
+ const detailKey = `${selectedSkillAgentId}:${normalized}`;
+ setSkillDetailLoadingKey(detailKey);
+ return sendWithRetry({
+ type: "get_skill_detail",
+ agent_id: selectedSkillAgentId,
+ skill_name: normalized
+ });
+ }, [clientRef, selectedSkillAgentId, sendWithRetry, setSkillDetailLoadingKey]);
+
+ const handleCreateLocalSkill = useCallback((skillName) => {
+ const normalized = typeof skillName === "string" ? skillName.trim() : "";
+ if (!normalized) {
+ setAgentSkillsFeedback({ type: "error", text: "技能名称不能为空" });
+ return;
+ }
+ if (!clientRef.current) {
+ setAgentSkillsFeedback({ type: "error", text: "连接未就绪,稍后重试" });
+ return;
+ }
+ setAgentSkillsSavingKey(`${selectedSkillAgentId}:${normalized}:create`);
+ setAgentSkillsFeedback(null);
+ const success = sendWithRetry({
+ type: "create_agent_local_skill",
+ agent_id: selectedSkillAgentId,
+ skill_name: normalized
+ });
+ if (!success) {
+ setAgentSkillsSavingKey(null);
+ setAgentSkillsFeedback({ type: "error", text: "发送失败,请检查连接状态" });
+ }
+ }, [clientRef, selectedSkillAgentId, sendWithRetry, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
+
+ const handleLocalSkillDraftChange = useCallback((skillName, content) => {
+ const detailKey = `${selectedSkillAgentId}:${skillName}`;
+ setLocalSkillDraftsByKey((prev) => ({
+ ...prev,
+ [detailKey]: content
+ }));
+ }, [selectedSkillAgentId, setLocalSkillDraftsByKey]);
+
+ const handleLocalSkillSave = useCallback((skillName) => {
+ if (!clientRef.current) {
+ setAgentSkillsFeedback({ type: "error", text: "连接未就绪,稍后重试" });
+ return;
+ }
+ const detailKey = `${selectedSkillAgentId}:${skillName}`;
+ const content = localSkillDraftsByKey[detailKey];
+ if (typeof content !== "string") {
+ return;
+ }
+ setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:content`);
+ setAgentSkillsFeedback(null);
+ const success = sendWithRetry({
+ type: "update_agent_local_skill",
+ agent_id: selectedSkillAgentId,
+ skill_name: skillName,
+ content
+ });
+ if (!success) {
+ setAgentSkillsSavingKey(null);
+ setAgentSkillsFeedback({ type: "error", text: "发送失败,请检查连接状态" });
+ }
+ }, [
+ clientRef,
+ localSkillDraftsByKey,
+ selectedSkillAgentId,
+ setAgentSkillsFeedback,
+ setAgentSkillsSavingKey
+ ]);
+
+ const handleLocalSkillDelete = useCallback((skillName) => {
+ if (!clientRef.current) {
+ setAgentSkillsFeedback({ type: "error", text: "连接未就绪,稍后重试" });
+ return;
+ }
+ setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:delete`);
+ setAgentSkillsFeedback(null);
+ const success = sendWithRetry({
+ type: "delete_agent_local_skill",
+ agent_id: selectedSkillAgentId,
+ skill_name: skillName
+ });
+ if (!success) {
+ setAgentSkillsSavingKey(null);
+ setAgentSkillsFeedback({ type: "error", text: "发送失败,请检查连接状态" });
+ }
+ }, [clientRef, selectedSkillAgentId, sendWithRetry, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
+
+ const handleRemoveSharedSkill = useCallback((skillName) => {
+ if (!clientRef.current) {
+ setAgentSkillsFeedback({ type: "error", text: "连接未就绪,稍后重试" });
+ return;
+ }
+ setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:remove`);
+ setAgentSkillsFeedback(null);
+ const success = sendWithRetry({
+ type: "remove_agent_skill",
+ agent_id: selectedSkillAgentId,
+ skill_name: skillName
+ });
+ if (!success) {
+ setAgentSkillsSavingKey(null);
+ setAgentSkillsFeedback({ type: "error", text: "发送失败,请检查连接状态" });
+ }
+ }, [clientRef, selectedSkillAgentId, sendWithRetry, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
+
+ const requestWorkspaceFile = useCallback((agentId, filename) => {
+ const normalizedAgentId = typeof agentId === "string" ? agentId.trim() : "";
+ const normalizedFilename = typeof filename === "string" ? filename.trim() : "";
+ if (!normalizedAgentId || !normalizedFilename || !clientRef.current) {
+ return false;
+ }
+ setIsWorkspaceFileLoading(true);
+ setWorkspaceFileFeedback(null);
+ return sendWithRetry({
+ type: "get_agent_workspace_file",
+ agent_id: normalizedAgentId,
+ filename: normalizedFilename
+ });
+ }, [clientRef, sendWithRetry, setIsWorkspaceFileLoading, setWorkspaceFileFeedback]);
+
+ const handleAgentSkillToggle = useCallback((skillName, enabled) => {
+ if (!clientRef.current) {
+ setAgentSkillsFeedback({ type: "error", text: "连接未就绪,稍后重试" });
+ return;
+ }
+
+ const agentId = selectedSkillAgentId;
+ setAgentSkillsSavingKey(`${agentId}:${skillName}`);
+ setAgentSkillsFeedback(null);
+ const success = sendWithRetry({
+ type: "update_agent_skill",
+ agent_id: agentId,
+ skill_name: skillName,
+ enabled
+ });
+
+ if (!success) {
+ setAgentSkillsSavingKey(null);
+ setAgentSkillsFeedback({ type: "error", text: "发送失败,请检查连接状态" });
+ }
+ }, [clientRef, selectedSkillAgentId, sendWithRetry, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
+
+ const handleSkillAgentChange = useCallback((agentId) => {
+ setSelectedSkillAgentId(agentId);
+ requestAgentProfile(agentId);
+ requestAgentSkills(agentId);
+ requestWorkspaceFile(agentId, selectedWorkspaceFile);
+ }, [
+ requestAgentProfile,
+ requestAgentSkills,
+ requestWorkspaceFile,
+ selectedWorkspaceFile,
+ setSelectedSkillAgentId
+ ]);
+
+ const handleWorkspaceFileChange = useCallback((filename) => {
+ setSelectedWorkspaceFile(filename);
+ requestWorkspaceFile(selectedSkillAgentId, filename);
+ }, [requestWorkspaceFile, selectedSkillAgentId, setSelectedWorkspaceFile]);
+
+ const handleWorkspaceFileSave = useCallback(() => {
+ if (!clientRef.current) {
+ setWorkspaceFileFeedback({ type: "error", text: "连接未就绪,稍后重试" });
+ return;
+ }
+ const key = `${selectedSkillAgentId}:${selectedWorkspaceFile}`;
+ setWorkspaceFileSavingKey(key);
+ setWorkspaceFileFeedback(null);
+ const success = sendWithRetry({
+ type: "update_agent_workspace_file",
+ agent_id: selectedSkillAgentId,
+ filename: selectedWorkspaceFile,
+ content: workspaceDraftContent
+ });
+ if (!success) {
+ setWorkspaceFileSavingKey(null);
+ setWorkspaceFileFeedback({ type: "error", text: "发送失败,请检查连接状态" });
+ }
+ }, [
+ clientRef,
+ selectedSkillAgentId,
+ selectedWorkspaceFile,
+ sendWithRetry,
+ setWorkspaceFileFeedback,
+ setWorkspaceFileSavingKey,
+ workspaceDraftContent
+ ]);
+
+ const handleUploadExternalSkill = useCallback(async (file) => {
+ if (!(file instanceof File)) {
+ setAgentSkillsFeedback({ type: "error", text: "请选择 zip 文件后再上传" });
+ return;
+ }
+ if (!selectedSkillAgentId) {
+ setAgentSkillsFeedback({ type: "error", text: "未选择目标 Agent" });
+ return;
+ }
+ setAgentSkillsSavingKey(`${selectedSkillAgentId}:__upload__`);
+ setAgentSkillsFeedback(null);
+ try {
+ const result = await uploadAgentSkillZip({
+ agentId: selectedSkillAgentId,
+ file,
+ activate: true
+ });
+ setAgentSkillsFeedback({
+ type: "success",
+ text: `已上传并安装技能 ${result.skill_name || ""}`.trim()
+ });
+ requestAgentSkills(selectedSkillAgentId);
+ } catch (error) {
+ setAgentSkillsFeedback({
+ type: "error",
+ text: `上传失败: ${error.message || "未知错误"}`
+ });
+ } finally {
+ setAgentSkillsSavingKey(null);
+ }
+ }, [
+ requestAgentSkills,
+ selectedSkillAgentId,
+ setAgentSkillsFeedback,
+ setAgentSkillsSavingKey
+ ]);
+
+ useEffect(() => {
+ setWorkspaceDraftContent(selectedWorkspaceContent);
+ }, [selectedWorkspaceContent, setWorkspaceDraftContent]);
+
+ useEffect(() => {
+ if (currentView !== "traders") {
+ return;
+ }
+ const timer = window.setTimeout(() => {
+ AGENTS.forEach((agent) => {
+ if (!agentProfilesByAgent[agent.id]) {
+ requestAgentProfile(agent.id);
+ }
+ if (!agentSkillsByAgent[agent.id]) {
+ requestAgentSkills(agent.id);
+ }
+ if (!workspaceFilesByAgent[agent.id]?.["MEMORY.md"]) {
+ requestWorkspaceFile(agent.id, "MEMORY.md");
+ }
+ });
+ }, 300);
+ return () => window.clearTimeout(timer);
+ }, [
+ agentProfilesByAgent,
+ agentSkillsByAgent,
+ connectionStatus,
+ currentView,
+ isConnected,
+ requestAgentProfile,
+ requestAgentSkills,
+ requestWorkspaceFile,
+ workspaceFilesByAgent
+ ]);
+
+ useEffect(() => {
+ if (currentView !== "traders" || !selectedSkillAgentId) {
+ return;
+ }
+ const timer = window.setTimeout(() => {
+ if (!agentProfilesByAgent[selectedSkillAgentId]) {
+ requestAgentProfile(selectedSkillAgentId);
+ }
+ if (!agentSkillsByAgent[selectedSkillAgentId]) {
+ requestAgentSkills(selectedSkillAgentId);
+ }
+ if (selectedWorkspaceFile && !workspaceFilesByAgent[selectedSkillAgentId]?.[selectedWorkspaceFile]) {
+ requestWorkspaceFile(selectedSkillAgentId, selectedWorkspaceFile);
+ }
+ }, 300);
+ return () => window.clearTimeout(timer);
+ }, [
+ agentProfilesByAgent,
+ agentSkillsByAgent,
+ connectionStatus,
+ currentView,
+ isConnected,
+ requestAgentProfile,
+ requestAgentSkills,
+ requestWorkspaceFile,
+ selectedSkillAgentId,
+ selectedWorkspaceFile,
+ workspaceFilesByAgent
+ ]);
+
+ return {
+ requestAgentSkills,
+ requestAgentProfile,
+ requestSkillDetail,
+ requestWorkspaceFile,
+ handleCreateLocalSkill,
+ handleLocalSkillDraftChange,
+ handleLocalSkillSave,
+ handleLocalSkillDelete,
+ handleRemoveSharedSkill,
+ handleAgentSkillToggle,
+ handleSkillAgentChange,
+ handleWorkspaceFileChange,
+ handleWorkspaceFileSave,
+ handleUploadExternalSkill
+ };
+}
diff --git a/frontend/src/hooks/useRuntimeControls.js b/frontend/src/hooks/useRuntimeControls.js
new file mode 100644
index 0000000..bce10ef
--- /dev/null
+++ b/frontend/src/hooks/useRuntimeControls.js
@@ -0,0 +1,538 @@
+import { useCallback, useEffect, useMemo, useRef } from "react";
+import { INITIAL_TICKERS } from "../config/constants";
+import { startRuntime } from "../services/runtimeApi";
+import {
+ buildRuntimeSummaryLabel,
+ normalizeTickerSymbols,
+ normalizeRuntimeWatchlistSymbols,
+ parseWatchlistInput
+} from "../services/runtimeControls";
+import { useAgentStore } from "../store/agentStore";
+import { useRuntimeStore } from "../store/runtimeStore";
+
+const DEFAULT_SCHEDULE_MODE = "daily";
+const DEFAULT_INTERVAL_MINUTES = "60";
+const DEFAULT_TRIGGER_TIME = "now";
+const DEFAULT_MAX_COMM_CYCLES = "2";
+const DEFAULT_INITIAL_CASH = "100000";
+const DEFAULT_MARGIN_REQUIREMENT = "0";
+const DEFAULT_MODE = "live";
+const DEFAULT_POLL_INTERVAL = "10";
+
+export function useRuntimeControls({ clientRef, currentTickers, addSystemMessage }) {
+ const {
+ runtimeConfig,
+ setRuntimeConfig,
+ isWatchlistPanelOpen,
+ setIsWatchlistPanelOpen,
+ isRuntimeSettingsOpen,
+ setIsRuntimeSettingsOpen,
+ watchlistDraftSymbols,
+ setWatchlistDraftSymbols,
+ watchlistInputValue,
+ setWatchlistInputValue,
+ watchlistFeedback,
+ setWatchlistFeedback,
+ isWatchlistSaving,
+ setIsWatchlistSaving,
+ scheduleModeDraft,
+ setScheduleModeDraft,
+ intervalMinutesDraft,
+ setIntervalMinutesDraft,
+ triggerTimeDraft,
+ setTriggerTimeDraft,
+ maxCommCyclesDraft,
+ setMaxCommCyclesDraft,
+ initialCashDraft,
+ setInitialCashDraft,
+ marginRequirementDraft,
+ setMarginRequirementDraft,
+ enableMemoryDraft,
+ setEnableMemoryDraft,
+ modeDraft,
+ setModeDraft,
+ pollIntervalDraft,
+ setPollIntervalDraft,
+ startDateDraft,
+ setStartDateDraft,
+ endDateDraft,
+ setEndDateDraft,
+ enableMockDraft,
+ setEnableMockDraft,
+ runtimeConfigFeedback,
+ setRuntimeConfigFeedback,
+ isRuntimeConfigSaving,
+ setIsRuntimeConfigSaving
+ } = useRuntimeStore();
+
+ const {
+ setAgentSkillsFeedback,
+ setWorkspaceFileFeedback
+ } = useAgentStore();
+
+ const isWatchlistSavingRef = useRef(false);
+ const isRuntimeConfigSavingRef = useRef(false);
+
+ useEffect(() => {
+ isWatchlistSavingRef.current = isWatchlistSaving;
+ }, [isWatchlistSaving]);
+
+ useEffect(() => {
+ isRuntimeConfigSavingRef.current = isRuntimeConfigSaving;
+ }, [isRuntimeConfigSaving]);
+
+ const displayTickers = useMemo(
+ () => normalizeTickerSymbols(runtimeConfig?.tickers, currentTickers),
+ [currentTickers, runtimeConfig]
+ );
+
+ const runtimeWatchlistSymbols = useMemo(
+ () => normalizeRuntimeWatchlistSymbols(runtimeConfig, currentTickers),
+ [currentTickers, runtimeConfig]
+ );
+
+ const runtimeSummaryLabel = useMemo(
+ () => buildRuntimeSummaryLabel(runtimeConfig),
+ [runtimeConfig]
+ );
+
+ const watchlistSuggestions = useMemo(
+ () => INITIAL_TICKERS.map((ticker) => ticker.symbol).filter((symbol, index, list) => list.indexOf(symbol) === index),
+ []
+ );
+
+ const isWatchlistDraftDirty = useMemo(() => {
+ if (watchlistInputValue.trim()) {
+ return true;
+ }
+
+ if (watchlistDraftSymbols.length !== runtimeWatchlistSymbols.length) {
+ return true;
+ }
+
+ return watchlistDraftSymbols.some((symbol, index) => symbol !== runtimeWatchlistSymbols[index]);
+ }, [runtimeWatchlistSymbols, watchlistDraftSymbols, watchlistInputValue]);
+
+ useEffect(() => {
+ if ((!isWatchlistPanelOpen && !isRuntimeSettingsOpen) || !isWatchlistDraftDirty) {
+ setWatchlistDraftSymbols(runtimeWatchlistSymbols);
+ if (!isWatchlistPanelOpen && !isRuntimeSettingsOpen) {
+ setWatchlistInputValue("");
+ }
+ }
+ }, [
+ isWatchlistDraftDirty,
+ isWatchlistPanelOpen,
+ isRuntimeSettingsOpen,
+ runtimeWatchlistSymbols,
+ setWatchlistDraftSymbols,
+ setWatchlistInputValue
+ ]);
+
+ useEffect(() => {
+ if (!runtimeConfig) {
+ return;
+ }
+
+ setScheduleModeDraft(String(runtimeConfig.schedule_mode || DEFAULT_SCHEDULE_MODE));
+ setIntervalMinutesDraft(String(runtimeConfig.interval_minutes || DEFAULT_INTERVAL_MINUTES));
+ setTriggerTimeDraft(String(runtimeConfig.trigger_time || DEFAULT_TRIGGER_TIME));
+ setMaxCommCyclesDraft(String(runtimeConfig.max_comm_cycles || DEFAULT_MAX_COMM_CYCLES));
+ setInitialCashDraft(String(runtimeConfig.initial_cash ?? DEFAULT_INITIAL_CASH));
+ setMarginRequirementDraft(String(runtimeConfig.margin_requirement ?? DEFAULT_MARGIN_REQUIREMENT));
+ setEnableMemoryDraft(Boolean(runtimeConfig.enable_memory ?? false));
+ }, [
+ runtimeConfig,
+ setEnableMemoryDraft,
+ setInitialCashDraft,
+ setIntervalMinutesDraft,
+ setMarginRequirementDraft,
+ setMaxCommCyclesDraft,
+ setScheduleModeDraft,
+ setTriggerTimeDraft
+ ]);
+
+ const commitWatchlistInput = useCallback((value) => {
+ const parsed = parseWatchlistInput(value);
+ if (parsed.length === 0) {
+ return [];
+ }
+
+ setWatchlistDraftSymbols((prev) => Array.from(new Set([...prev, ...parsed])));
+ setWatchlistInputValue("");
+ if (watchlistFeedback) {
+ setWatchlistFeedback(null);
+ }
+ return parsed;
+ }, [setWatchlistDraftSymbols, setWatchlistInputValue, setWatchlistFeedback, watchlistFeedback]);
+
+ const handleWatchlistRemove = useCallback((symbolToRemove) => {
+ setWatchlistDraftSymbols((prev) => prev.filter((symbol) => symbol !== symbolToRemove));
+ if (watchlistFeedback) {
+ setWatchlistFeedback(null);
+ }
+ }, [setWatchlistDraftSymbols, setWatchlistFeedback, watchlistFeedback]);
+
+ const handleWatchlistPanelToggle = useCallback(() => {
+ setIsRuntimeSettingsOpen(false);
+ setIsWatchlistPanelOpen((open) => {
+ const nextOpen = !open;
+ if (nextOpen) {
+ setWatchlistDraftSymbols(runtimeWatchlistSymbols);
+ setWatchlistInputValue("");
+ setWatchlistFeedback(null);
+ }
+ return nextOpen;
+ });
+ }, [
+ runtimeWatchlistSymbols,
+ setIsRuntimeSettingsOpen,
+ setIsWatchlistPanelOpen,
+ setWatchlistDraftSymbols,
+ setWatchlistFeedback,
+ setWatchlistInputValue
+ ]);
+
+ const handleWatchlistInputChange = useCallback((value) => {
+ setWatchlistInputValue(value);
+ if (watchlistFeedback) {
+ setWatchlistFeedback(null);
+ }
+ }, [setWatchlistInputValue, setWatchlistFeedback, watchlistFeedback]);
+
+ const handleWatchlistInputKeyDown = useCallback((event) => {
+ if (event.key === "Enter" || event.key === ",") {
+ event.preventDefault();
+ commitWatchlistInput(watchlistInputValue);
+ }
+ }, [commitWatchlistInput, watchlistInputValue]);
+
+ const handleWatchlistSuggestionClick = useCallback((symbol) => {
+ if (watchlistDraftSymbols.includes(symbol)) {
+ return;
+ }
+ setWatchlistDraftSymbols((prev) => [...prev, symbol]);
+ if (watchlistFeedback) {
+ setWatchlistFeedback(null);
+ }
+ }, [setWatchlistDraftSymbols, watchlistDraftSymbols, setWatchlistFeedback, watchlistFeedback]);
+
+ const handleWatchlistRestoreCurrent = useCallback(() => {
+ setWatchlistDraftSymbols(runtimeWatchlistSymbols);
+ setWatchlistInputValue("");
+ setWatchlistFeedback(null);
+ }, [runtimeWatchlistSymbols, setWatchlistDraftSymbols, setWatchlistInputValue, setWatchlistFeedback]);
+
+ const handleWatchlistRestoreDefault = useCallback(() => {
+ setWatchlistDraftSymbols(watchlistSuggestions);
+ setWatchlistInputValue("");
+ setWatchlistFeedback(null);
+ }, [setWatchlistDraftSymbols, setWatchlistInputValue, setWatchlistFeedback, watchlistSuggestions]);
+
+ const handleWatchlistSave = useCallback(() => {
+ const pendingTickers = parseWatchlistInput(watchlistInputValue);
+ const nextTickers = Array.from(new Set([...watchlistDraftSymbols, ...pendingTickers]));
+ if (nextTickers.length === 0) {
+ setWatchlistFeedback({ type: "error", text: "至少输入 1 个有效股票代码" });
+ return;
+ }
+
+ if (!clientRef.current) {
+ setWatchlistFeedback({ type: "error", text: "连接未就绪,稍后重试" });
+ return;
+ }
+
+ setIsWatchlistSaving(true);
+ setWatchlistFeedback(null);
+ setWatchlistDraftSymbols(nextTickers);
+ setWatchlistInputValue("");
+ const success = clientRef.current.send({
+ type: "update_watchlist",
+ tickers: nextTickers
+ });
+
+ if (!success) {
+ setIsWatchlistSaving(false);
+ setWatchlistFeedback({ type: "error", text: "发送失败,请检查连接状态" });
+ }
+ }, [
+ clientRef,
+ setIsWatchlistSaving,
+ setWatchlistDraftSymbols,
+ setWatchlistFeedback,
+ setWatchlistInputValue,
+ watchlistDraftSymbols,
+ watchlistInputValue
+ ]);
+
+ const handleRuntimeConfigSave = useCallback(() => {
+ if (!clientRef.current) {
+ setRuntimeConfigFeedback({ type: "error", text: "连接未就绪,稍后重试" });
+ return;
+ }
+
+ const interval = Number(intervalMinutesDraft);
+ const maxCommCycles = Number(maxCommCyclesDraft);
+ if (!Number.isInteger(interval) || interval <= 0) {
+ setRuntimeConfigFeedback({ type: "error", text: "间隔必须是正整数分钟" });
+ return;
+ }
+ if (!Number.isInteger(maxCommCycles) || maxCommCycles <= 0) {
+ setRuntimeConfigFeedback({ type: "error", text: "讨论轮数必须是正整数" });
+ return;
+ }
+
+ setIsRuntimeConfigSaving(true);
+ setRuntimeConfigFeedback(null);
+ const success = clientRef.current.send({
+ type: "update_runtime_config",
+ schedule_mode: scheduleModeDraft,
+ interval_minutes: interval,
+ trigger_time: triggerTimeDraft,
+ max_comm_cycles: maxCommCycles,
+ initial_cash: Number(initialCashDraft),
+ margin_requirement: Number(marginRequirementDraft),
+ enable_memory: Boolean(enableMemoryDraft)
+ });
+
+ if (!success) {
+ setIsRuntimeConfigSaving(false);
+ setRuntimeConfigFeedback({ type: "error", text: "发送失败,请检查连接状态" });
+ }
+ }, [
+ clientRef,
+ enableMemoryDraft,
+ initialCashDraft,
+ intervalMinutesDraft,
+ marginRequirementDraft,
+ maxCommCyclesDraft,
+ scheduleModeDraft,
+ setIsRuntimeConfigSaving,
+ setRuntimeConfigFeedback,
+ triggerTimeDraft
+ ]);
+
+ const handleLaunchConfigSave = useCallback(async () => {
+ const pendingTickers = parseWatchlistInput(watchlistInputValue);
+ const nextTickers = Array.from(new Set([...watchlistDraftSymbols, ...pendingTickers]));
+ if (nextTickers.length === 0) {
+ setRuntimeConfigFeedback({ type: "error", text: "至少输入 1 个有效股票代码" });
+ return;
+ }
+
+ const interval = Number(intervalMinutesDraft);
+ const maxCommCycles = Number(maxCommCyclesDraft);
+ const initialCash = Number(initialCashDraft);
+ const marginRequirement = Number(marginRequirementDraft);
+ if (!Number.isInteger(interval) || interval <= 0) {
+ setRuntimeConfigFeedback({ type: "error", text: "间隔必须是正整数分钟" });
+ return;
+ }
+ if (!Number.isInteger(maxCommCycles) || maxCommCycles <= 0) {
+ setRuntimeConfigFeedback({ type: "error", text: "讨论轮数必须是正整数" });
+ return;
+ }
+ if (!Number.isFinite(initialCash) || initialCash <= 0) {
+ setRuntimeConfigFeedback({ type: "error", text: "初始资金必须是正数" });
+ return;
+ }
+ if (!Number.isFinite(marginRequirement) || marginRequirement < 0) {
+ setRuntimeConfigFeedback({ type: "error", text: "保证金要求不能为负数" });
+ return;
+ }
+
+ setIsRuntimeConfigSaving(true);
+ setIsWatchlistSaving(true);
+ setRuntimeConfigFeedback(null);
+ setWatchlistFeedback(null);
+ setWatchlistDraftSymbols(nextTickers);
+ setWatchlistInputValue("");
+
+ try {
+ const result = await startRuntime({
+ tickers: nextTickers,
+ schedule_mode: scheduleModeDraft,
+ interval_minutes: interval,
+ trigger_time: triggerTimeDraft,
+ max_comm_cycles: maxCommCycles,
+ initial_cash: initialCash,
+ margin_requirement: marginRequirement,
+ enable_memory: Boolean(enableMemoryDraft),
+ mode: modeDraft || DEFAULT_MODE,
+ poll_interval: Number(pollIntervalDraft) || Number(DEFAULT_POLL_INTERVAL),
+ start_date: startDateDraft || null,
+ end_date: endDateDraft || null,
+ enable_mock: Boolean(enableMockDraft)
+ });
+
+ setIsRuntimeConfigSaving(false);
+ setIsWatchlistSaving(false);
+ setIsRuntimeSettingsOpen(false);
+ setRuntimeConfigFeedback({
+ type: "success",
+ text: `任务已启动: ${result.run_id}`
+ });
+ addSystemMessage(`新任务已启动: ${result.run_id}`);
+ } catch (error) {
+ setIsRuntimeConfigSaving(false);
+ setIsWatchlistSaving(false);
+ setRuntimeConfigFeedback({
+ type: "error",
+ text: `启动失败: ${error.message}`
+ });
+ }
+ }, [
+ addSystemMessage,
+ clientRef,
+ enableMemoryDraft,
+ enableMockDraft,
+ endDateDraft,
+ initialCashDraft,
+ intervalMinutesDraft,
+ marginRequirementDraft,
+ maxCommCyclesDraft,
+ modeDraft,
+ pollIntervalDraft,
+ scheduleModeDraft,
+ setIsRuntimeConfigSaving,
+ setIsRuntimeSettingsOpen,
+ setIsWatchlistSaving,
+ setRuntimeConfigFeedback,
+ setWatchlistDraftSymbols,
+ setWatchlistFeedback,
+ setWatchlistInputValue,
+ startDateDraft,
+ triggerTimeDraft,
+ watchlistDraftSymbols,
+ watchlistInputValue
+ ]);
+
+ const handleRuntimeDefaultsRestore = useCallback(() => {
+ setScheduleModeDraft(DEFAULT_SCHEDULE_MODE);
+ setIntervalMinutesDraft(DEFAULT_INTERVAL_MINUTES);
+ setTriggerTimeDraft(DEFAULT_TRIGGER_TIME);
+ setMaxCommCyclesDraft(DEFAULT_MAX_COMM_CYCLES);
+ setInitialCashDraft(DEFAULT_INITIAL_CASH);
+ setMarginRequirementDraft(DEFAULT_MARGIN_REQUIREMENT);
+ setEnableMemoryDraft(false);
+ setModeDraft(DEFAULT_MODE);
+ setPollIntervalDraft(DEFAULT_POLL_INTERVAL);
+ setStartDateDraft("");
+ setEndDateDraft("");
+ setEnableMockDraft(false);
+ setRuntimeConfigFeedback(null);
+ }, [
+ setEnableMemoryDraft,
+ setEnableMockDraft,
+ setEndDateDraft,
+ setInitialCashDraft,
+ setIntervalMinutesDraft,
+ setMarginRequirementDraft,
+ setMaxCommCyclesDraft,
+ setModeDraft,
+ setPollIntervalDraft,
+ setRuntimeConfigFeedback,
+ setScheduleModeDraft,
+ setStartDateDraft,
+ setTriggerTimeDraft
+ ]);
+
+ const handleRuntimeSettingsToggle = useCallback(() => {
+ setRuntimeConfigFeedback(null);
+ setAgentSkillsFeedback(null);
+ setWorkspaceFileFeedback(null);
+ setIsRuntimeSettingsOpen((prev) => {
+ const nextOpen = !prev;
+ if (nextOpen) {
+ setWatchlistDraftSymbols(runtimeWatchlistSymbols);
+ setWatchlistInputValue("");
+ setWatchlistFeedback(null);
+ }
+ return nextOpen;
+ });
+ setIsWatchlistPanelOpen(false);
+ }, [
+ runtimeWatchlistSymbols,
+ setAgentSkillsFeedback,
+ setIsRuntimeSettingsOpen,
+ setIsWatchlistPanelOpen,
+ setRuntimeConfigFeedback,
+ setWatchlistDraftSymbols,
+ setWatchlistFeedback,
+ setWatchlistInputValue,
+ setWorkspaceFileFeedback
+ ]);
+
+ const handleRuntimeSettingsClose = useCallback(() => {
+ setIsRuntimeSettingsOpen(false);
+ }, [setIsRuntimeSettingsOpen]);
+
+ const handleWatchlistAdd = useCallback(() => commitWatchlistInput(watchlistInputValue), [commitWatchlistInput, watchlistInputValue]);
+
+ return {
+ runtimeConfig,
+ displayTickers,
+ runtimeWatchlistSymbols,
+ runtimeSummaryLabel,
+ watchlistSuggestions,
+ isWatchlistDraftDirty,
+ isWatchlistPanelOpen,
+ isRuntimeSettingsOpen,
+ watchlistDraftSymbols,
+ watchlistInputValue,
+ watchlistFeedback,
+ isWatchlistSaving,
+ scheduleModeDraft,
+ intervalMinutesDraft,
+ triggerTimeDraft,
+ maxCommCyclesDraft,
+ initialCashDraft,
+ marginRequirementDraft,
+ enableMemoryDraft,
+ modeDraft,
+ pollIntervalDraft,
+ startDateDraft,
+ endDateDraft,
+ enableMockDraft,
+ runtimeConfigFeedback,
+ isRuntimeConfigSaving,
+ isWatchlistSavingRef,
+ isRuntimeConfigSavingRef,
+ commitWatchlistInput,
+ handleWatchlistRemove,
+ handleWatchlistPanelToggle,
+ handleWatchlistInputChange,
+ handleWatchlistInputKeyDown,
+ handleWatchlistSuggestionClick,
+ handleWatchlistRestoreCurrent,
+ handleWatchlistRestoreDefault,
+ handleWatchlistSave,
+ handleWatchlistAdd,
+ handleRuntimeConfigSave,
+ handleLaunchConfigSave,
+ handleRuntimeDefaultsRestore,
+ handleRuntimeSettingsToggle,
+ handleRuntimeSettingsClose,
+ setRuntimeConfig,
+ setWatchlistDraftSymbols,
+ setWatchlistInputValue,
+ setWatchlistFeedback,
+ setRuntimeConfigFeedback,
+ setIsWatchlistPanelOpen,
+ setIsRuntimeSettingsOpen,
+ setScheduleModeDraft,
+ setIntervalMinutesDraft,
+ setTriggerTimeDraft,
+ setMaxCommCyclesDraft,
+ setInitialCashDraft,
+ setMarginRequirementDraft,
+ setEnableMemoryDraft,
+ setModeDraft,
+ setPollIntervalDraft,
+ setStartDateDraft,
+ setEndDateDraft,
+ setEnableMockDraft,
+ setIsWatchlistSaving,
+ setIsRuntimeConfigSaving
+ };
+}
diff --git a/frontend/src/hooks/useStockDataRequests.js b/frontend/src/hooks/useStockDataRequests.js
new file mode 100644
index 0000000..05733a7
--- /dev/null
+++ b/frontend/src/hooks/useStockDataRequests.js
@@ -0,0 +1,352 @@
+import { useCallback, useRef } from 'react';
+import { useMarketStore } from '../store/marketStore';
+import { useRuntimeStore } from '../store/runtimeStore';
+import {
+ fetchNewsCategoriesDirect,
+ fetchNewsForDateDirect,
+ fetchRangeExplainDirect,
+ fetchSimilarDaysDirect,
+ fetchStockStoryDirect,
+ hasDirectNewsService
+} from '../services/newsApi';
+import {
+ fetchInsiderTradesDirect,
+ fetchStockHistoryDirect,
+ hasDirectTradingService
+} from '../services/tradingApi';
+
+/**
+ * Custom hook for stock data request callbacks.
+ * Takes clientRef, calls store setters directly.
+ */
+export function useStockDataRequests(clientRef, { setRequestStockHistory, setRequestStockNewsTimeline, setRequestStockNewsCategories }) {
+ const requestedStockHistoryRef = useRef(new Set());
+
+ const { currentDate } = useRuntimeStore();
+ const { setOhlcHistoryByTicker, setPriceHistoryByTicker, setHistorySourceByTicker,
+ setNewsByTicker, setInsiderTradesByTicker } = useMarketStore();
+
+ const requestStockHistory = useCallback((symbol, { force = false } = {}) => {
+ const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
+ if (!normalized) return false;
+
+ if (!force && requestedStockHistoryRef.current.has(normalized)) return false;
+
+ const endDate = currentDate
+ ? String(currentDate).slice(0, 10)
+ : new Date().toISOString().slice(0, 10);
+ const end = new Date(`${endDate}T00:00:00`);
+ const start = new Date(end);
+ start.setDate(start.getDate() - 120);
+ const startDate = start.toISOString().slice(0, 10);
+
+ if (hasDirectTradingService()) {
+ void fetchStockHistoryDirect(normalized, startDate, endDate)
+ .then((payload) => {
+ const prices = Array.isArray(payload?.prices) ? payload.prices : [];
+ setOhlcHistoryByTicker((prev) => ({ ...prev, [normalized]: prices }));
+ setPriceHistoryByTicker((prev) => ({
+ ...prev,
+ [normalized]: prices
+ .map((point) => {
+ const price = Number(point?.close);
+ const timestamp = point?.time;
+ if (!timestamp || !Number.isFinite(price)) return null;
+ return { timestamp: String(timestamp), label: String(timestamp), price };
+ })
+ .filter(Boolean)
+ }));
+ 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);
+ }
+ });
+ 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]);
+
+ 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]);
+
+ 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]);
+
+ const requestStockNewsForDate = useCallback((symbol, date) => {
+ const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
+ if (!normalized || !date) return false;
+
+ if (hasDirectNewsService()) {
+ void fetchNewsForDateDirect(normalized, date, 20)
+ .then((payload) => {
+ const targetDate = typeof payload?.date === 'string' ? payload.date.trim() : date;
+ const news = Array.isArray(payload?.news) ? payload.news : [];
+ const freshness = payload?.freshness || null;
+ setNewsByTicker((prev) => ({
+ ...prev,
+ [normalized]: {
+ ...(prev[normalized] || {}),
+ byDate: { ...((prev[normalized] && prev[normalized].byDate) || {}), [targetDate]: news },
+ byDateFreshness: { ...((prev[normalized] && prev[normalized].byDateFreshness) || {}), [targetDate]: freshness }
+ }
+ }));
+ })
+ .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 });
+ }
+ });
+ return true;
+ }
+
+ if (!clientRef.current) return false;
+ return clientRef.current.send({ type: 'get_stock_news_for_date', ticker: normalized, date, limit: 20 });
+ }, [clientRef, 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]);
+
+ const requestStockNewsCategories = useCallback((symbol) => {
+ const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
+ if (!normalized) return false;
+
+ const endDate = currentDate
+ ? String(currentDate).slice(0, 10)
+ : new Date().toISOString().slice(0, 10);
+ const end = new Date(`${endDate}T00:00:00`);
+ const start = new Date(end);
+ start.setDate(start.getDate() - 90);
+ const startDate = start.toISOString().slice(0, 10);
+
+ if (hasDirectNewsService()) {
+ void fetchNewsCategoriesDirect(normalized, startDate, endDate, 200)
+ .then((payload) => {
+ const freshness = payload?.freshness || null;
+ setNewsByTicker((prev) => ({
+ ...prev,
+ [normalized]: {
+ ...(prev[normalized] || {}),
+ categories: payload?.categories || {},
+ categoriesStartDate: startDate,
+ categoriesEndDate: endDate,
+ categoriesFreshness: freshness
+ }
+ }));
+ })
+ .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 });
+ }
+ });
+ return true;
+ }
+
+ if (!clientRef.current) return false;
+ return clientRef.current.send({ type: 'get_stock_news_categories', ticker: normalized, lookback_days: 90 });
+ }, [clientRef, currentDate, setNewsByTicker]);
+
+ const requestStockInsiderTrades = useCallback((symbol, startDate = null, endDate = null) => {
+ const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
+ if (!normalized) return false;
+
+ if (hasDirectTradingService()) {
+ void fetchInsiderTradesDirect(normalized, startDate, endDate, 50)
+ .then((payload) => {
+ const rows = Array.isArray(payload?.insider_trades) ? payload.insider_trades : [];
+ setInsiderTradesByTicker((prev) => ({
+ ...prev,
+ [normalized]: { ticker: normalized, startDate, endDate, trades: rows }
+ }));
+ })
+ .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 });
+ }
+ });
+ 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]);
+
+ 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]);
+
+ const requestStockRangeExplain = useCallback((symbol, startDate, endDate, articleIds = []) => {
+ const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
+ if (!normalized || !startDate || !endDate) return false;
+
+ if (hasDirectNewsService()) {
+ void fetchRangeExplainDirect(normalized, startDate, endDate, articleIds)
+ .then((payload) => {
+ const result = payload?.result && typeof payload.result === 'object' ? payload.result : null;
+ const freshness = payload?.freshness || null;
+ if (!result?.start_date || !result?.end_date) return;
+ const cacheKey = `${result.start_date}:${result.end_date}`;
+ setNewsByTicker((prev) => ({
+ ...prev,
+ [normalized]: {
+ ...(prev[normalized] || {}),
+ rangeExplainCache: {
+ ...((prev[normalized] && prev[normalized].rangeExplainCache) || {}),
+ [cacheKey]: { ...result, freshness }
+ }
+ }
+ }));
+ })
+ .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 : [] });
+ }
+ });
+ 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]);
+
+ const requestStockStory = useCallback((symbol, asOfDate = null) => {
+ const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
+ if (!normalized) return false;
+
+ if (hasDirectNewsService()) {
+ void fetchStockStoryDirect(normalized, asOfDate)
+ .then((payload) => {
+ const storyDate = typeof payload?.as_of_date === 'string' ? payload.as_of_date.trim() : '';
+ const freshness = payload?.freshness || null;
+ if (!storyDate) return;
+ setNewsByTicker((prev) => ({
+ ...prev,
+ [normalized]: {
+ ...(prev[normalized] || {}),
+ storyCache: {
+ ...((prev[normalized] && prev[normalized].storyCache) || {}),
+ [storyDate]: { story: payload.story || '', source: payload.source || 'news_service', asOfDate: storyDate, freshness }
+ }
+ }
+ }));
+ })
+ .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 });
+ }
+ });
+ return true;
+ }
+
+ if (!clientRef.current) return false;
+ return clientRef.current.send({ type: 'get_stock_story', ticker: normalized, as_of_date: asOfDate });
+ }, [clientRef, setNewsByTicker]);
+
+ const requestStockSimilarDays = useCallback((symbol, date, topK = 8) => {
+ const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
+ if (!normalized || !date) return false;
+
+ if (hasDirectNewsService()) {
+ void fetchSimilarDaysDirect(normalized, date, topK)
+ .then((payload) => {
+ const targetDate = typeof payload?.target_date === 'string' ? payload.target_date.trim() : date;
+ if (!targetDate) return;
+ setNewsByTicker((prev) => ({
+ ...prev,
+ [normalized]: {
+ ...(prev[normalized] || {}),
+ similarDaysCache: {
+ ...((prev[normalized] && prev[normalized].similarDaysCache) || {}),
+ [targetDate]: payload
+ }
+ }
+ }));
+ })
+ .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 });
+ }
+ });
+ return true;
+ }
+
+ if (!clientRef.current) return false;
+ return clientRef.current.send({ type: 'get_stock_similar_days', ticker: normalized, date, top_k: topK });
+ }, [clientRef, setNewsByTicker]);
+
+ const requestStockEnrich = useCallback((symbol, options = {}) => {
+ const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
+ if (!normalized || !clientRef.current) 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;
+ setNewsByTicker((prev) => ({
+ ...prev,
+ [normalized]: {
+ ...(prev[normalized] || {}),
+ maintenanceStatus: { running: true, error: null, updatedAt: new Date().toISOString(), stats: null }
+ }
+ }));
+ return clientRef.current.send({
+ type: 'run_stock_enrich',
+ ticker: normalized,
+ start_date: startDate,
+ end_date: endDate,
+ force: Boolean(options.force),
+ only_local_to_llm: Boolean(options.onlyLocalToLlm),
+ rebuild_story: Boolean(options.rebuildStory),
+ rebuild_similar_days: Boolean(options.rebuildSimilarDays),
+ story_date: options.storyDate || null,
+ target_date: options.targetDate || null
+ });
+ }, [clientRef, setNewsByTicker]);
+
+ // Register request functions with WebSocket connection hook
+ if (setRequestStockHistory) setRequestStockHistory(requestStockHistory);
+ if (setRequestStockNewsTimeline) setRequestStockNewsTimeline(requestStockNewsTimeline);
+ if (setRequestStockNewsCategories) setRequestStockNewsCategories(requestStockNewsCategories);
+
+ return {
+ requestStockHistory,
+ requestStockExplainEvents,
+ requestStockNews,
+ requestStockNewsForDate,
+ requestStockNewsTimeline,
+ requestStockNewsCategories,
+ requestStockInsiderTrades,
+ requestStockTechnicalIndicators,
+ requestStockRangeExplain,
+ requestStockStory,
+ requestStockSimilarDays,
+ requestStockEnrich
+ };
+}
diff --git a/frontend/src/hooks/useStockExplainData.js b/frontend/src/hooks/useStockExplainData.js
new file mode 100644
index 0000000..ed2898e
--- /dev/null
+++ b/frontend/src/hooks/useStockExplainData.js
@@ -0,0 +1,546 @@
+import { useCallback, useEffect } from "react";
+
+import {
+ fetchNewsCategoriesDirect,
+ fetchNewsForDateDirect,
+ fetchRangeExplainDirect,
+ fetchSimilarDaysDirect,
+ fetchStockStoryDirect,
+ hasDirectNewsService
+} from "../services/newsApi";
+import {
+ fetchInsiderTradesDirect,
+ fetchStockHistoryDirect,
+ hasDirectTradingService
+} from "../services/tradingApi";
+
+export function useStockExplainData({
+ clientRef,
+ currentDate,
+ currentView,
+ selectedExplainSymbol,
+ requestedStockHistoryRef,
+ setOhlcHistoryByTicker,
+ setPriceHistoryByTicker,
+ setHistorySourceByTicker,
+ setNewsByTicker,
+ setInsiderTradesByTicker
+}) {
+ const requestStockHistory = useCallback((symbol, { force = false } = {}) => {
+ const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
+ if (!normalized) {
+ return false;
+ }
+
+ if (!force && requestedStockHistoryRef.current.has(normalized)) {
+ return false;
+ }
+
+ const endDate = currentDate
+ ? String(currentDate).slice(0, 10)
+ : new Date().toISOString().slice(0, 10);
+ const end = new Date(`${endDate}T00:00:00`);
+ const start = new Date(end);
+ start.setDate(start.getDate() - 120);
+ const startDate = start.toISOString().slice(0, 10);
+
+ if (hasDirectTradingService()) {
+ void fetchStockHistoryDirect(normalized, startDate, endDate)
+ .then((payload) => {
+ const prices = Array.isArray(payload?.prices) ? payload.prices : [];
+ setOhlcHistoryByTicker((prev) => ({ ...prev, [normalized]: prices }));
+ setPriceHistoryByTicker((prev) => ({
+ ...prev,
+ [normalized]: prices
+ .map((point) => {
+ const price = Number(point?.close);
+ const timestamp = point?.time;
+ if (!timestamp || !Number.isFinite(price)) {
+ return null;
+ }
+ return {
+ timestamp: String(timestamp),
+ label: String(timestamp),
+ price
+ };
+ })
+ .filter(Boolean)
+ }));
+ 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);
+ }
+ }
+ });
+ 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,
+ requestedStockHistoryRef,
+ setHistorySourceByTicker,
+ setOhlcHistoryByTicker,
+ setPriceHistoryByTicker
+ ]);
+
+ 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]);
+
+ 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]);
+
+ const requestStockNewsForDate = useCallback((symbol, date) => {
+ const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
+ if (!normalized || !date) {
+ return false;
+ }
+
+ if (hasDirectNewsService()) {
+ void fetchNewsForDateDirect(normalized, date, 20)
+ .then((payload) => {
+ const targetDate = typeof payload?.date === "string" ? payload.date.trim() : date;
+ const news = Array.isArray(payload?.news) ? payload.news : [];
+ const freshness = payload?.freshness || null;
+ setNewsByTicker((prev) => ({
+ ...prev,
+ [normalized]: {
+ ...(prev[normalized] || {}),
+ byDate: {
+ ...((prev[normalized] && prev[normalized].byDate) || {}),
+ [targetDate]: news
+ },
+ byDateFreshness: {
+ ...((prev[normalized] && prev[normalized].byDateFreshness) || {}),
+ [targetDate]: freshness
+ }
+ }
+ }));
+ })
+ .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
+ });
+ }
+ });
+ return true;
+ }
+
+ if (!clientRef.current) {
+ return false;
+ }
+
+ return clientRef.current.send({
+ type: "get_stock_news_for_date",
+ ticker: normalized,
+ date,
+ limit: 20
+ });
+ }, [clientRef, 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]);
+
+ const requestStockNewsCategories = useCallback((symbol) => {
+ const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
+ if (!normalized) {
+ return false;
+ }
+
+ const endDate = currentDate
+ ? String(currentDate).slice(0, 10)
+ : new Date().toISOString().slice(0, 10);
+ const end = new Date(`${endDate}T00:00:00`);
+ const start = new Date(end);
+ start.setDate(start.getDate() - 90);
+ const startDate = start.toISOString().slice(0, 10);
+
+ if (hasDirectNewsService()) {
+ void fetchNewsCategoriesDirect(normalized, startDate, endDate, 200)
+ .then((payload) => {
+ const freshness = payload?.freshness || null;
+ setNewsByTicker((prev) => ({
+ ...prev,
+ [normalized]: {
+ ...(prev[normalized] || {}),
+ categories: payload?.categories || {},
+ categoriesStartDate: startDate,
+ categoriesEndDate: endDate,
+ categoriesFreshness: freshness
+ }
+ }));
+ })
+ .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
+ });
+ }
+ });
+ return true;
+ }
+
+ if (!clientRef.current) {
+ return false;
+ }
+
+ return clientRef.current.send({
+ type: "get_stock_news_categories",
+ ticker: normalized,
+ lookback_days: 90
+ });
+ }, [clientRef, currentDate, setNewsByTicker]);
+
+ const requestStockInsiderTrades = useCallback((symbol, startDate = null, endDate = null) => {
+ const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
+ if (!normalized) {
+ return false;
+ }
+
+ if (hasDirectTradingService()) {
+ void fetchInsiderTradesDirect(normalized, startDate, endDate, 50)
+ .then((payload) => {
+ const rows = Array.isArray(payload?.insider_trades) ? payload.insider_trades : [];
+ setInsiderTradesByTicker((prev) => ({
+ ...prev,
+ [normalized]: {
+ ticker: normalized,
+ startDate: startDate || null,
+ endDate: endDate || null,
+ trades: rows
+ }
+ }));
+ })
+ .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
+ });
+ }
+ });
+ 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]);
+
+ 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]);
+
+ const requestStockRangeExplain = useCallback((symbol, startDate, endDate, articleIds = []) => {
+ const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
+ if (!normalized || !startDate || !endDate) {
+ return false;
+ }
+
+ if (hasDirectNewsService()) {
+ void fetchRangeExplainDirect(normalized, startDate, endDate, articleIds)
+ .then((payload) => {
+ const result = payload?.result && typeof payload.result === "object" ? payload.result : null;
+ const freshness = payload?.freshness || null;
+ if (!result?.start_date || !result?.end_date) {
+ return;
+ }
+ const cacheKey = `${result.start_date}:${result.end_date}`;
+ setNewsByTicker((prev) => ({
+ ...prev,
+ [normalized]: {
+ ...(prev[normalized] || {}),
+ rangeExplainCache: {
+ ...((prev[normalized] && prev[normalized].rangeExplainCache) || {}),
+ [cacheKey]: {
+ ...result,
+ freshness
+ }
+ }
+ }
+ }));
+ })
+ .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 : []
+ });
+ }
+ });
+ 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]);
+
+ const requestStockStory = useCallback((symbol, asOfDate = null) => {
+ const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
+ if (!normalized) {
+ return false;
+ }
+
+ if (hasDirectNewsService()) {
+ void fetchStockStoryDirect(normalized, asOfDate)
+ .then((payload) => {
+ const storyDate = typeof payload?.as_of_date === "string" ? payload.as_of_date.trim() : "";
+ const freshness = payload?.freshness || null;
+ if (!storyDate) {
+ return;
+ }
+ setNewsByTicker((prev) => ({
+ ...prev,
+ [normalized]: {
+ ...(prev[normalized] || {}),
+ storyCache: {
+ ...((prev[normalized] && prev[normalized].storyCache) || {}),
+ [storyDate]: {
+ story: payload.story || "",
+ source: payload.source || "news_service",
+ asOfDate: storyDate,
+ freshness
+ }
+ }
+ }
+ }));
+ })
+ .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
+ });
+ }
+ });
+ return true;
+ }
+
+ if (!clientRef.current) {
+ return false;
+ }
+
+ return clientRef.current.send({
+ type: "get_stock_story",
+ ticker: normalized,
+ as_of_date: asOfDate
+ });
+ }, [clientRef, setNewsByTicker]);
+
+ const requestStockSimilarDays = useCallback((symbol, date, topK = 8) => {
+ const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
+ if (!normalized || !date) {
+ return false;
+ }
+
+ if (hasDirectNewsService()) {
+ void fetchSimilarDaysDirect(normalized, date, topK)
+ .then((payload) => {
+ const targetDate = typeof payload?.target_date === "string" ? payload.target_date.trim() : date;
+ if (!targetDate) {
+ return;
+ }
+ setNewsByTicker((prev) => ({
+ ...prev,
+ [normalized]: {
+ ...(prev[normalized] || {}),
+ similarDaysCache: {
+ ...((prev[normalized] && prev[normalized].similarDaysCache) || {}),
+ [targetDate]: payload
+ }
+ }
+ }));
+ })
+ .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
+ });
+ }
+ });
+ return true;
+ }
+
+ if (!clientRef.current) {
+ return false;
+ }
+
+ return clientRef.current.send({
+ type: "get_stock_similar_days",
+ ticker: normalized,
+ date,
+ top_k: topK
+ });
+ }, [clientRef, setNewsByTicker]);
+
+ const requestStockEnrich = useCallback((symbol, options = {}) => {
+ const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
+ if (!normalized || !clientRef.current) {
+ 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;
+ }
+ setNewsByTicker((prev) => ({
+ ...prev,
+ [normalized]: {
+ ...(prev[normalized] || {}),
+ maintenanceStatus: {
+ running: true,
+ error: null,
+ updatedAt: new Date().toISOString(),
+ stats: null
+ }
+ }
+ }));
+ return clientRef.current.send({
+ type: "run_stock_enrich",
+ ticker: normalized,
+ start_date: startDate,
+ end_date: endDate,
+ force: Boolean(options.force),
+ only_local_to_llm: Boolean(options.onlyLocalToLlm),
+ rebuild_story: Boolean(options.rebuildStory),
+ rebuild_similar_days: Boolean(options.rebuildSimilarDays),
+ story_date: options.storyDate || null,
+ target_date: options.targetDate || null
+ });
+ }, [clientRef, setNewsByTicker]);
+
+ useEffect(() => {
+ if (currentView !== "explain" || !selectedExplainSymbol) {
+ return;
+ }
+ requestStockHistory(selectedExplainSymbol);
+ requestStockExplainEvents(selectedExplainSymbol);
+ requestStockNews(selectedExplainSymbol);
+ requestStockNewsTimeline(selectedExplainSymbol);
+ requestStockNewsCategories(selectedExplainSymbol);
+ requestStockStory(selectedExplainSymbol, currentDate);
+ }, [
+ currentDate,
+ currentView,
+ requestStockExplainEvents,
+ requestStockHistory,
+ requestStockNews,
+ requestStockNewsCategories,
+ requestStockNewsTimeline,
+ requestStockStory,
+ selectedExplainSymbol
+ ]);
+
+ return {
+ requestStockHistory,
+ requestStockExplainEvents,
+ requestStockNews,
+ requestStockNewsForDate,
+ requestStockNewsTimeline,
+ requestStockNewsCategories,
+ requestStockInsiderTrades,
+ requestStockTechnicalIndicators,
+ requestStockRangeExplain,
+ requestStockStory,
+ requestStockSimilarDays,
+ requestStockEnrich
+ };
+}
diff --git a/frontend/src/hooks/useWebSocketConnection.js b/frontend/src/hooks/useWebSocketConnection.js
new file mode 100644
index 0000000..d20f1b5
--- /dev/null
+++ b/frontend/src/hooks/useWebSocketConnection.js
@@ -0,0 +1,875 @@
+import { useEffect, useRef, useCallback } from 'react';
+import { AGENTS } from '../config/constants';
+import { ReadOnlyClient } from '../services/websocket';
+import { useRuntimeStore } from '../store/runtimeStore';
+import { useMarketStore } from '../store/marketStore';
+import { usePortfolioStore } from '../store/portfolioStore';
+import { useAgentStore } from '../store/agentStore';
+import { useUIStore } from '../store/uiStore';
+import { normalizeTickerSymbols } from '../services/runtimeControls';
+
+/**
+ * Normalize price history from server format
+ */
+function normalizePriceHistory(payload) {
+ if (!payload || typeof payload !== 'object') {
+ return {};
+ }
+
+ const normalized = {};
+ Object.entries(payload).forEach(([symbol, points]) => {
+ const ticker = String(symbol || '').trim().toUpperCase();
+ if (!ticker || !Array.isArray(points)) {
+ return;
+ }
+
+ normalized[ticker] = points
+ .map((point) => {
+ if (Array.isArray(point) && point.length >= 2) {
+ const [label, value] = point;
+ const price = Number(value);
+ if (!label || !Number.isFinite(price)) return null;
+ return { timestamp: String(label), label: String(label), price };
+ }
+
+ if (point && typeof point === 'object') {
+ const rawTimestamp = point.timestamp ?? point.t ?? point.date ?? point.label;
+ const price = Number(point.price ?? point.v ?? point.value ?? point.close);
+ if (!rawTimestamp || !Number.isFinite(price)) return null;
+ return { timestamp: String(rawTimestamp), label: String(rawTimestamp), price };
+ }
+
+ return null;
+ })
+ .filter(Boolean)
+ .slice(-120);
+ });
+
+ return normalized;
+}
+
+/**
+ * Build tickers from symbols array
+ */
+function buildTickersFromSymbols(symbols, previousTickers = []) {
+ if (!Array.isArray(symbols) || symbols.length === 0) {
+ return previousTickers;
+ }
+
+ return symbols
+ .filter((symbol) => typeof symbol === 'string' && symbol.trim())
+ .map((symbol) => {
+ const normalized = symbol.trim().toUpperCase();
+ const existing = previousTickers.find((ticker) => ticker.symbol === normalized);
+ return existing || { symbol: normalized, price: null, change: null };
+ });
+}
+
+/**
+ * Custom hook for WebSocket connection lifecycle and event handling.
+ * Manages clientRef, connection, and ALL event handlers.
+ * Feeds directly into stores (no props drilling).
+ */
+export function useWebSocketConnection({
+ processHistoricalFeed,
+ processFeedEvent,
+ addSystemMessage
+}) {
+ const clientRef = useRef(null);
+ const isWatchlistSavingRef = useRef(false);
+ const isRuntimeConfigSavingRef = useRef(false);
+ const selectedSkillAgentIdRef = useRef(null);
+ const requestedStockHistoryRef = useRef(new Set());
+
+ // Store state
+ const { setIsConnected, setConnectionStatus, setSystemStatus, setCurrentDate,
+ setServerMode, setDataSources, setRuntimeConfig, setMarketStatus,
+ setVirtualTime, setProgress, watchlistDraftSymbols, setWatchlistInputValue,
+ setIsWatchlistSaving, setWatchlistFeedback, setIsRuntimeConfigSaving,
+ setRuntimeConfigFeedback, isWatchlistSaving, isRuntimeConfigSaving,
+ setLastDayHistory } = useRuntimeStore();
+
+ const { tickers, setTickers, setRollingTickers, setPriceHistoryByTicker,
+ setExplainEventsByTicker, setNewsByTicker, setInsiderTradesByTicker,
+ setTechnicalIndicatorsByTicker, setHistorySourceByTicker,
+ setOhlcHistoryByTicker } = useMarketStore();
+
+ const { setPortfolioData, setHoldings, setTrades, setStats, setLeaderboard } = usePortfolioStore();
+
+ const { setAgentSkillsByAgent, setAgentProfilesByAgent, setSkillDetailsByName,
+ setLocalSkillDraftsByKey, setIsAgentSkillsLoading, setSkillDetailLoadingKey,
+ setAgentSkillsSavingKey, setAgentSkillsFeedback, setIsWorkspaceFileLoading,
+ setWorkspaceFileSavingKey, setWorkspaceFilesByAgent, setWorkspaceFileFeedback,
+ selectedSkillAgentId } = useAgentStore();
+
+ const { setBubbles } = useUIStore();
+
+ // Helper: Update tickers from realtime prices
+ const updateTickersFromPrices = useCallback((realtimePrices) => {
+ try {
+ setTickers((prevTickers) => prevTickers.map((ticker) => {
+ const realtimeData = realtimePrices[ticker.symbol];
+ if (realtimeData && realtimeData.price !== null && realtimeData.price !== undefined) {
+ const newChange = (realtimeData.ret !== null && realtimeData.ret !== undefined)
+ ? realtimeData.ret
+ : (ticker.change !== null && ticker.change !== undefined ? ticker.change : 0);
+
+ return {
+ ...ticker,
+ price: realtimeData.price,
+ change: newChange,
+ open: realtimeData.open || ticker.open
+ };
+ }
+ return ticker;
+ }));
+ } catch (error) {
+ console.error('Error updating tickers from prices:', error);
+ }
+ }, [setTickers]);
+
+ // Stock request callbacks (these will be provided by useStockDataRequests)
+ const requestStockHistoryRef = useRef(null);
+ const requestStockNewsTimelineRef = useRef(null);
+ const requestStockNewsCategoriesRef = useRef(null);
+
+ const setRequestStockHistory = useCallback((fn) => {
+ requestStockHistoryRef.current = fn;
+ }, []);
+
+ const setRequestStockNewsTimeline = useCallback((fn) => {
+ requestStockNewsTimelineRef.current = fn;
+ }, []);
+
+ const setRequestStockNewsCategories = useCallback((fn) => {
+ requestStockNewsCategoriesRef.current = fn;
+ }, []);
+
+ useEffect(() => {
+ const handlePushEvent = (evt) => {
+ if (!evt) return;
+
+ try {
+ handleEventInternal(evt);
+ } catch (error) {
+ console.error('[Event Handler] Error:', error);
+ }
+ };
+
+ const handleEventInternal = (evt) => {
+ if (evt?.type && evt.type !== 'pong') {
+ setConnectionStatus('connected');
+ setIsConnected(true);
+ }
+
+ const handlers = {
+ error: (e) => {
+ const message = typeof e.message === 'string' ? e.message : '请求失败';
+ console.error('[Error]', message);
+ setIsAgentSkillsLoading(false);
+ setSkillDetailLoadingKey(null);
+ setAgentSkillsSavingKey(null);
+ setIsWorkspaceFileLoading(false);
+ setWorkspaceFileSavingKey(null);
+ if (isWatchlistSavingRef.current) {
+ setIsWatchlistSaving(false);
+ setWatchlistFeedback({ type: 'error', text: message || '更新 watchlist 失败' });
+ }
+ if (isRuntimeConfigSavingRef.current) {
+ setIsRuntimeConfigSaving(false);
+ setRuntimeConfigFeedback({ type: 'error', text: message });
+ }
+ if (message.includes('skill') || message.includes('agent_id')) {
+ setAgentSkillsFeedback({ type: 'error', text: message || '更新技能失败' });
+ }
+ if (message.includes('workspace_file') || message.includes('filename')) {
+ setWorkspaceFileFeedback({ type: 'error', text: message || '更新工作区文件失败' });
+ }
+ if (message.includes('fast forward')) {
+ console.warn(`⚠️ ${message}`);
+ handlePushEvent({ type: 'system', content: `⚠️ ${message}`, timestamp: Date.now() });
+ }
+ addSystemMessage(message);
+ },
+
+ system: (e) => {
+ console.log('[System]', e.content);
+ if (e.content.includes('Connected') || e.content.includes('已连接')) {
+ setConnectionStatus('connected');
+ setIsConnected(true);
+ } else if (e.content.includes('Disconnected') || e.content.includes('断开')) {
+ setConnectionStatus('disconnected');
+ setIsConnected(false);
+ }
+ processFeedEvent(e);
+ },
+
+ pong: () => {
+ console.log('[Heartbeat] Pong received');
+ },
+
+ initial_state: (e) => {
+ try {
+ const state = e.state;
+ if (!state) return;
+
+ setConnectionStatus('connected');
+ setIsConnected(true);
+ setSystemStatus(state.status || 'initializing');
+ setCurrentDate(state.current_date);
+
+ if (state.server_mode) setServerMode(state.server_mode);
+ if (state.data_sources) setDataSources(state.data_sources);
+ if (state.runtime_config) setRuntimeConfig(state.runtime_config);
+ if (Array.isArray(state.tickers) && state.tickers.length > 0) {
+ setTickers((prevTickers) => buildTickersFromSymbols(state.tickers, prevTickers));
+ }
+
+ const isMockMode = state.is_mock_mode === true;
+ if (state.market_status) {
+ setMarketStatus(state.market_status);
+ if (isMockMode && state.market_status.current_time) {
+ try {
+ setVirtualTime(new Date(state.market_status.current_time));
+ } catch (error) {
+ console.error('Error parsing virtual time from market_status:', error);
+ }
+ } else {
+ setVirtualTime(null);
+ }
+ }
+
+ if (state.trading_days_total) {
+ setProgress({
+ current: state.trading_days_completed || 0,
+ total: state.trading_days_total
+ });
+ }
+
+ if (state.portfolio) {
+ setPortfolioData((prev) => ({
+ ...prev,
+ netValue: state.portfolio.total_value || prev.netValue,
+ pnl: state.portfolio.pnl_percent || 0,
+ equity: state.portfolio.equity || prev.equity,
+ baseline: state.portfolio.baseline || prev.baseline,
+ baseline_vw: state.portfolio.baseline_vw || prev.baseline_vw,
+ momentum: state.portfolio.momentum || prev.momentum,
+ strategies: state.portfolio.strategies || prev.strategies,
+ equity_return: state.portfolio.equity_return || prev.equity_return,
+ baseline_return: state.portfolio.baseline_return || prev.baseline_return,
+ baseline_vw_return: state.portfolio.baseline_vw_return || prev.baseline_vw_return,
+ momentum_return: state.portfolio.momentum_return || prev.momentum_return
+ }));
+ }
+
+ if (state.dashboard) {
+ if (state.dashboard.holdings) setHoldings(state.dashboard.holdings);
+ if (state.dashboard.trades) setTrades(state.dashboard.trades);
+ if (state.dashboard.stats) setStats(state.dashboard.stats);
+ if (state.dashboard.leaderboard) setLeaderboard(state.dashboard.leaderboard);
+ }
+ if (state.realtime_prices) updateTickersFromPrices(state.realtime_prices);
+ if (state.price_history) {
+ setPriceHistoryByTicker(normalizePriceHistory(state.price_history));
+ }
+
+ if (state.feed_history && Array.isArray(state.feed_history)) {
+ console.log(`✅ Loading ${state.feed_history.length} historical events`);
+ processHistoricalFeed(state.feed_history);
+ }
+
+ if (state.last_day_history && Array.isArray(state.last_day_history)) {
+ setLastDayHistory(state.last_day_history);
+ console.log(`✅ Loaded ${state.last_day_history.length} last day events for replay`);
+ }
+
+ console.log('Initial state loaded');
+ } catch (error) {
+ console.error('Error loading initial state:', error);
+ }
+ },
+
+ market_status_update: (e) => {
+ if (e.market_status) setMarketStatus(e.market_status);
+ },
+
+ data_sources_update: (e) => {
+ if (e.data_sources) setDataSources(e.data_sources);
+ },
+
+ runtime_assets_reloaded: (e) => {
+ if (e.runtime_config_applied) setRuntimeConfig(e.runtime_config_applied);
+ if (Array.isArray(e.runtime_config_applied?.tickers)) {
+ setTickers((prevTickers) =>
+ buildTickersFromSymbols(e.runtime_config_applied.tickers, prevTickers)
+ );
+ setWatchlistInputValue('');
+ }
+ if (isWatchlistSavingRef.current) setIsWatchlistSaving(false);
+ if (isRuntimeConfigSavingRef.current) {
+ setIsRuntimeConfigSaving(false);
+ setRuntimeConfigFeedback({ type: 'success', text: '运行配置已保存并生效' });
+ }
+ const warnings = Array.isArray(e.runtime_config_warnings) ? e.runtime_config_warnings : [];
+ warnings.forEach((warning) => addSystemMessage(warning));
+ addSystemMessage('运行时配置已热更新');
+ },
+
+ agent_skills_loaded: (e) => {
+ const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
+ if (!agentId) {
+ setIsAgentSkillsLoading(false);
+ return;
+ }
+ setAgentSkillsByAgent((prev) => ({ ...prev, [agentId]: Array.isArray(e.skills) ? e.skills : [] }));
+ setIsAgentSkillsLoading(false);
+ setAgentSkillsSavingKey(null);
+ },
+
+ agent_profile_loaded: (e) => {
+ const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
+ if (!agentId) return;
+ setAgentProfilesByAgent((prev) => ({
+ ...prev,
+ [agentId]: e.profile && typeof e.profile === 'object' ? e.profile : {}
+ }));
+ },
+
+ skill_detail_loaded: (e) => {
+ const skillName = typeof e.skill?.skill_name === 'string' ? e.skill.skill_name.trim() : '';
+ const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : selectedSkillAgentIdRef.current;
+ if (!skillName) {
+ setSkillDetailLoadingKey(null);
+ return;
+ }
+ const detailKey = `${agentId}:${skillName}`;
+ setSkillDetailsByName((prev) => ({ ...prev, [detailKey]: e.skill }));
+ setLocalSkillDraftsByKey((prev) => ({
+ ...prev,
+ [detailKey]: typeof e.skill?.content === 'string' ? e.skill.content : ''
+ }));
+ setSkillDetailLoadingKey(null);
+ },
+
+ agent_skill_updated: (e) => {
+ const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
+ const skillName = typeof e.skill_name === 'string' ? e.skill_name.trim() : '';
+ if (!agentId || !skillName) return;
+ setAgentSkillsFeedback({
+ type: 'success',
+ text: `${agentId} ${e.enabled ? '已启用' : '已禁用'} ${skillName}`
+ });
+ },
+
+ agent_local_skill_created: (e) => {
+ const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
+ const skillName = typeof e.skill_name === 'string' ? e.skill_name.trim() : '';
+ setAgentSkillsSavingKey(null);
+ if (!agentId || !skillName) return;
+ setAgentSkillsFeedback({ type: 'success', text: `${agentId} 已创建本地技能 ${skillName}` });
+ },
+
+ agent_local_skill_updated: (e) => {
+ const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
+ const skillName = typeof e.skill_name === 'string' ? e.skill_name.trim() : '';
+ setAgentSkillsSavingKey(null);
+ if (!agentId || !skillName) return;
+ setAgentSkillsFeedback({ type: 'success', text: `${agentId} 的本地技能 ${skillName} 已保存` });
+ },
+
+ agent_local_skill_deleted: (e) => {
+ const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
+ const skillName = typeof e.skill_name === 'string' ? e.skill_name.trim() : '';
+ setAgentSkillsSavingKey(null);
+ if (!agentId || !skillName) return;
+ setSkillDetailsByName((prev) => {
+ const next = { ...prev };
+ delete next[`${agentId}:${skillName}`];
+ return next;
+ });
+ setLocalSkillDraftsByKey((prev) => {
+ const next = { ...prev };
+ delete next[`${agentId}:${skillName}`];
+ return next;
+ });
+ setAgentSkillsFeedback({ type: 'success', text: `${agentId} 的本地技能 ${skillName} 已删除` });
+ },
+
+ agent_skill_removed: (e) => {
+ const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
+ const skillName = typeof e.skill_name === 'string' ? e.skill_name.trim() : '';
+ setAgentSkillsSavingKey(null);
+ if (!agentId || !skillName) return;
+ setAgentSkillsFeedback({ type: 'success', text: `${agentId} 已移除共享技能 ${skillName}` });
+ },
+
+ agent_workspace_file_loaded: (e) => {
+ const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
+ const filename = typeof e.filename === 'string' ? e.filename.trim() : '';
+ if (!agentId || !filename) {
+ setIsWorkspaceFileLoading(false);
+ return;
+ }
+ setWorkspaceFilesByAgent((prev) => ({
+ ...prev,
+ [agentId]: { ...(prev[agentId] || {}), [filename]: typeof e.content === 'string' ? e.content : '' }
+ }));
+ setIsWorkspaceFileLoading(false);
+ setWorkspaceFileSavingKey(null);
+ },
+
+ agent_workspace_file_updated: (e) => {
+ const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
+ const filename = typeof e.filename === 'string' ? e.filename.trim() : '';
+ if (!agentId || !filename) return;
+ setWorkspaceFileFeedback({ type: 'success', text: `${agentId} 的 ${filename} 已保存` });
+ },
+
+ watchlist_updated: (e) => {
+ if (Array.isArray(e.tickers)) {
+ const normalizedTickers = e.tickers.map((symbol) => String(symbol).trim().toUpperCase());
+ setRuntimeConfig((prev) => ({ ...(prev || {}), tickers: normalizedTickers }));
+ setTickers((prevTickers) => buildTickersFromSymbols(normalizedTickers, prevTickers));
+ }
+ setIsWatchlistSaving(false);
+ setWatchlistFeedback({
+ type: 'success',
+ text: `已更新为 ${Array.isArray(e.tickers) ? e.tickers.join(', ') : '最新列表'}`
+ });
+ },
+
+ stock_history_loaded: (e) => {
+ const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
+ if (!symbol) return;
+ if (Array.isArray(e.prices)) {
+ setOhlcHistoryByTicker((prev) => ({ ...prev, [symbol]: e.prices }));
+ setHistorySourceByTicker((prev) => ({ ...prev, [symbol]: e.source || null }));
+ }
+ },
+
+ stock_explain_events_loaded: (e) => {
+ const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
+ if (!symbol) return;
+ setExplainEventsByTicker((prev) => ({
+ ...prev,
+ [symbol]: {
+ events: Array.isArray(e.events) ? e.events : [],
+ signals: Array.isArray(e.signals) ? e.signals : [],
+ trades: Array.isArray(e.trades) ? e.trades : []
+ }
+ }));
+ },
+
+ stock_news_loaded: (e) => {
+ const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
+ if (!symbol) return;
+ setNewsByTicker((prev) => ({
+ ...prev,
+ [symbol]: {
+ ...(prev[symbol] || {}),
+ items: Array.isArray(e.news) ? e.news : [],
+ source: e.source || null,
+ startDate: e.start_date || null,
+ endDate: e.end_date || null,
+ freshness: e.freshness || null
+ }
+ }));
+ if (requestStockNewsTimelineRef.current) requestStockNewsTimelineRef.current(symbol);
+ },
+
+ stock_news_for_date_loaded: (e) => {
+ const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
+ const date = typeof e.date === 'string' ? e.date.trim() : '';
+ if (!symbol || !date) return;
+ setNewsByTicker((prev) => ({
+ ...prev,
+ [symbol]: {
+ ...(prev[symbol] || {}),
+ byDate: { ...((prev[symbol] && prev[symbol].byDate) || {}), [date]: Array.isArray(e.news) ? e.news : [] },
+ byDateFreshness: { ...((prev[symbol] && prev[symbol].byDateFreshness) || {}), [date]: e.freshness || null }
+ }
+ }));
+ },
+
+ stock_news_timeline_loaded: (e) => {
+ const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
+ if (!symbol) return;
+ setNewsByTicker((prev) => ({
+ ...prev,
+ [symbol]: {
+ ...(prev[symbol] || {}),
+ timeline: Array.isArray(e.timeline) ? e.timeline : [],
+ timelineStartDate: e.start_date || null,
+ timelineEndDate: e.end_date || null,
+ timelineFreshness: e.freshness || null
+ }
+ }));
+ },
+
+ stock_news_categories_loaded: (e) => {
+ const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
+ if (!symbol) return;
+ setNewsByTicker((prev) => ({
+ ...prev,
+ [symbol]: {
+ ...(prev[symbol] || {}),
+ categories: e.categories || {},
+ categoriesStartDate: e.start_date || null,
+ categoriesEndDate: e.end_date || null,
+ categoriesFreshness: e.freshness || null
+ }
+ }));
+ },
+
+ stock_insider_trades_loaded: (e) => {
+ const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
+ if (!symbol) return;
+ setInsiderTradesByTicker((prev) => ({
+ ...prev,
+ [symbol]: { trades: Array.isArray(e.trades) ? e.trades : [], startDate: e.start_date || null, endDate: e.end_date || null }
+ }));
+ },
+
+ stock_technical_indicators_loaded: (e) => {
+ const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
+ if (!symbol) return;
+ setTechnicalIndicatorsByTicker((prev) => ({ ...prev, [symbol]: e.indicators || null }));
+ },
+
+ stock_range_explain_loaded: (e) => {
+ const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
+ if (!symbol) return;
+ const result = e.result && typeof e.result === 'object' ? e.result : null;
+ if (!result?.start_date || !result?.end_date) return;
+ const cacheKey = `${result.start_date}:${result.end_date}`;
+ setNewsByTicker((prev) => ({
+ ...prev,
+ [symbol]: {
+ ...(prev[symbol] || {}),
+ rangeExplainCache: {
+ ...((prev[symbol] && prev[symbol].rangeExplainCache) || {}),
+ [cacheKey]: { ...result, freshness: e.freshness || null }
+ }
+ }
+ }));
+ },
+
+ stock_story_loaded: (e) => {
+ const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
+ const asOfDate = typeof e.as_of_date === 'string' ? e.as_of_date.trim() : '';
+ if (!symbol || !asOfDate) return;
+ setNewsByTicker((prev) => ({
+ ...prev,
+ [symbol]: {
+ ...(prev[symbol] || {}),
+ storyCache: {
+ ...((prev[symbol] && prev[symbol].storyCache) || {}),
+ [asOfDate]: { story: e.story || '', source: e.source || null, asOfDate, freshness: e.freshness || null }
+ }
+ }
+ }));
+ },
+
+ stock_similar_days_loaded: (e) => {
+ const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
+ const date = typeof e.target_date === 'string' ? e.target_date.trim() : typeof e.date === 'string' ? e.date.trim() : '';
+ if (!symbol || !date) return;
+ setNewsByTicker((prev) => ({
+ ...prev,
+ [symbol]: {
+ ...(prev[symbol] || {}),
+ similarDaysCache: {
+ ...((prev[symbol] && prev[symbol].similarDaysCache) || {}),
+ [date]: {
+ target_features: e.target_features || {},
+ items: Array.isArray(e.items) ? e.items : [],
+ error: e.error || null,
+ freshness: e.freshness || null
+ }
+ }
+ }
+ }));
+ },
+
+ stock_enrich_completed: (e) => {
+ const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
+ if (!symbol) return;
+ const completedAt = new Date().toISOString();
+ const historyEntry = {
+ timestamp: completedAt,
+ startDate: e.start_date || '',
+ endDate: e.end_date || '',
+ force: Boolean(e.force),
+ onlyLocalToLlm: Boolean(e.only_local_to_llm),
+ error: e.error || null,
+ stats: e.stats || null,
+ storyStatus: e.story_status || null,
+ similarStatus: e.similar_status || null
+ };
+ setNewsByTicker((prev) => ({
+ ...prev,
+ [symbol]: {
+ ...(prev[symbol] || {}),
+ items: [], byDate: {}, timeline: [], categories: {},
+ rangeExplainCache: {}, storyCache: {}, similarDaysCache: {},
+ maintenanceStatus: { running: false, error: e.error || null, updatedAt: completedAt, stats: e.stats || null, storyStatus: e.story_status || null, similarStatus: e.similar_status || null },
+ maintenanceHistory: [historyEntry, ...(((prev[symbol] && prev[symbol].maintenanceHistory) || []).slice(0, 7))]
+ }
+ }));
+ if (!e.error) {
+ if (requestStockHistoryRef.current) requestStockHistoryRef.current(symbol);
+ if (requestStockNewsTimelineRef.current) requestStockNewsTimelineRef.current(symbol);
+ if (requestStockNewsCategoriesRef.current) requestStockNewsCategoriesRef.current(symbol);
+ }
+ },
+
+ price_update: (e) => {
+ try {
+ const { symbol, price, ret, open, portfolio, realtime_prices } = e;
+ if (!symbol || !price) {
+ console.warn('[Price Update] Missing symbol or price:', e);
+ return;
+ }
+
+ setConnectionStatus('connected');
+ setIsConnected(true);
+ console.log(`[Price Update] ${symbol}: $${price} (ret: ${ret !== undefined ? ret.toFixed(2) : 'N/A'}%)`);
+
+ setPriceHistoryByTicker((prev) => {
+ const ticker = String(symbol).trim().toUpperCase();
+ const nextPoint = { timestamp: new Date().toISOString(), label: new Date().toISOString(), price: Number(price) };
+ const existing = Array.isArray(prev[ticker]) ? prev[ticker] : [];
+ const lastPoint = existing[existing.length - 1];
+ if (lastPoint && Number(lastPoint.price) === Number(nextPoint.price)) return prev;
+ return { ...prev, [ticker]: [...existing, nextPoint].slice(-120) };
+ });
+
+ const normalizedSymbol = String(symbol).trim().toUpperCase();
+ let shouldAnimateTicker = false;
+ setTickers((prevTickers) => prevTickers.map((ticker) => {
+ if (ticker.symbol === symbol) {
+ const oldPrice = ticker.price;
+ let newChange = ticker.change;
+ if (ret !== null && ret !== undefined) {
+ newChange = ret;
+ } else if (oldPrice !== null && oldPrice !== undefined && isFinite(oldPrice)) {
+ const priceChange = ((price - oldPrice) / oldPrice) * 100;
+ newChange = (newChange !== null && newChange !== undefined) ? newChange + priceChange : priceChange;
+ } else {
+ newChange = 0;
+ }
+ if (oldPrice !== price) shouldAnimateTicker = true;
+ return { ...ticker, price, change: newChange, open: open || ticker.open };
+ }
+ return ticker;
+ }));
+
+ if (shouldAnimateTicker) {
+ setRollingTickers((prev) => ({ ...prev, [normalizedSymbol]: true }));
+ setTimeout(() => setRollingTickers((prev) => ({ ...prev, [normalizedSymbol]: false })), 500);
+ }
+
+ if (realtime_prices) updateTickersFromPrices(realtime_prices);
+ if (portfolio && portfolio.total_value) {
+ setPortfolioData((prev) => ({
+ ...prev,
+ netValue: portfolio.total_value,
+ pnl: portfolio.pnl_percent || 0,
+ equity: portfolio.equity || prev.equity
+ }));
+ }
+ } catch (error) {
+ console.error('[Price Update] Error:', error);
+ }
+ },
+
+ day_start: (e) => {
+ setCurrentDate(e.date);
+ if (e.progress !== undefined) {
+ setProgress((prev) => ({ ...prev, current: Math.floor(e.progress * (prev.total || 1)) }));
+ }
+ setSystemStatus('running');
+ processFeedEvent(e);
+ },
+
+ day_complete: (e) => {
+ const result = e.result;
+ if (result && typeof result === 'object') {
+ if (result.portfolio_summary) {
+ const summary = result.portfolio_summary;
+ setPortfolioData((prev) => {
+ const newEquity = [...prev.equity];
+ const dateObj = new Date(e.date);
+ newEquity.push({ t: dateObj.getTime(), v: summary.total_value || summary.cash || prev.netValue });
+ return { ...prev, netValue: summary.total_value || summary.cash || prev.netValue, pnl: summary.pnl_percent || 0, equity: newEquity };
+ });
+ }
+ }
+ processFeedEvent(e);
+ },
+
+ day_error: (e) => {
+ console.error('Day error:', e.date, e.error);
+ processFeedEvent(e);
+ },
+
+ conference_start: (e) => processFeedEvent(e),
+ conference_end: (e) => processFeedEvent(e),
+
+ agent_message: (e) => {
+ const agent = AGENTS.find((item) => item.id === e.agentId);
+ setBubbles({ [e.agentId]: { text: e.content, ts: Date.now(), agentName: agent?.name || e.agentName || e.agentId } });
+ processFeedEvent(e);
+ },
+
+ conference_message: (e) => {
+ const agent = AGENTS.find((item) => item.id === e.agentId);
+ setBubbles({ [e.agentId]: { text: e.content, ts: Date.now(), agentName: agent?.name || e.agentName || e.agentId } });
+ processFeedEvent(e);
+ },
+
+ memory: (e) => processFeedEvent(e),
+
+ team_summary: (e) => {
+ setPortfolioData((prev) => ({
+ ...prev,
+ netValue: e.balance || prev.netValue,
+ pnl: e.pnlPct || 0,
+ equity: e.equity || prev.equity,
+ baseline: e.baseline || prev.baseline,
+ baseline_vw: e.baseline_vw || prev.baseline_vw,
+ momentum: e.momentum || prev.momentum,
+ equity_return: e.equity_return || prev.equity_return,
+ baseline_return: e.baseline_return || prev.baseline_return,
+ baseline_vw_return: e.baseline_vw_return || prev.baseline_vw_return,
+ momentum_return: e.momentum_return || prev.momentum_return
+ }));
+ },
+
+ team_portfolio: (e) => {
+ if (e.holdings) setHoldings(e.holdings);
+ },
+
+ team_holdings: (e) => {
+ if (e.data && Array.isArray(e.data)) {
+ setHoldings(e.data);
+ console.log(`✅ Holdings updated: ${e.data.length} positions`);
+ }
+ },
+
+ team_trades: (e) => {
+ if (e.mode === 'full' && e.data && Array.isArray(e.data)) {
+ setTrades(e.data);
+ } else if (Array.isArray(e.trades)) {
+ setTrades(e.trades);
+ } else if (e.trade) {
+ setTrades((prev) => [e.trade, ...prev].slice(0, 100));
+ }
+ },
+
+ team_stats: (e) => {
+ if (e.data) setStats(e.data);
+ else if (e.stats) setStats(e.stats);
+ },
+
+ team_leaderboard: (e) => {
+ if (Array.isArray(e.data)) setLeaderboard(e.data);
+ else if (Array.isArray(e.rows)) setLeaderboard(e.rows);
+ else if (Array.isArray(e.leaderboard)) setLeaderboard(e.leaderboard);
+ },
+
+ time_update: (e) => {
+ if (e.beijing_time_str) {
+ const statusEmoji = { market_open: '📊', off_market: '⏸️', non_trading_day: '📅', trade_execution: '💼' };
+ const emoji = statusEmoji[e.status] || '⏰';
+ const isMockMode = e.is_mock_mode === true;
+ let logMessage = `${emoji} ${isMockMode ? '虚拟时间' : '时间'}: ${e.beijing_time_str} | 状态: ${e.status}`;
+ if (e.hours_to_open !== undefined) logMessage += ` | 距离开盘: ${e.hours_to_open}小时`;
+ if (e.hours_to_trade !== undefined) logMessage += ` | 距离交易: ${e.hours_to_trade}小时`;
+ if (e.trading_date) logMessage += ` | 交易日: ${e.trading_date}`;
+ console.log(logMessage);
+
+ if (isMockMode && e.beijing_time) {
+ try { setVirtualTime(new Date(e.beijing_time)); } catch (error) { console.error('Error parsing virtual time:', error); }
+ } else {
+ setVirtualTime(null);
+ }
+ }
+ if (e.market_status) setMarketStatus(e.market_status);
+ },
+
+ time_fast_forwarded: (e) => {
+ console.log(`⏩ 时间已快进 ${e.minutes} 分钟: ${e.old_time_str} → ${e.new_time_str}`);
+ if (e.new_time) {
+ try {
+ setVirtualTime(new Date(e.new_time));
+ handlePushEvent({ type: 'system', content: `⏩ 时间快进 ${e.minutes} 分钟: ${e.old_time_str} → ${e.new_time_str}`, timestamp: Date.now() });
+ } catch (error) { console.error('Error parsing fast forwarded time:', error); }
+ }
+ },
+
+ fast_forward_success: (e) => {
+ console.log(`✅ ${e.message}`);
+ }
+ };
+
+ try {
+ const handler = handlers[evt.type];
+ if (handler) handler(evt);
+ else console.log('[handleEvent] Unknown event type:', evt.type);
+ } catch (error) {
+ console.error('[handleEvent] Error handling event:', evt.type, error);
+ }
+ };
+
+ // Create and connect WebSocket client
+ const client = new ReadOnlyClient(handlePushEvent);
+ clientRef.current = client;
+ client.connect();
+ setConnectionStatus('connecting');
+
+ // Sync refs with store state
+ isWatchlistSavingRef.current = isWatchlistSaving;
+ isRuntimeConfigSavingRef.current = isRuntimeConfigSaving;
+ selectedSkillAgentIdRef.current = selectedSkillAgentId;
+
+ return () => {
+ if (clientRef.current) {
+ clientRef.current.disconnect();
+ }
+ };
+ }, [
+ addSystemMessage, processFeedEvent,
+ processHistoricalFeed, setAgentProfilesByAgent,
+ setAgentSkillsByAgent, setAgentSkillsFeedback, setAgentSkillsSavingKey,
+ setBubbles, setConnectionStatus, setCurrentDate, setDataSources,
+ setExplainEventsByTicker, setHistorySourceByTicker, setHoldings,
+ setInsiderTradesByTicker, setIsAgentSkillsLoading, setIsConnected,
+ setIsRuntimeConfigSaving, setIsWatchlistSaving, setIsWorkspaceFileLoading,
+ setLastDayHistory, setLeaderboard, setLocalSkillDraftsByKey,
+ setMarketStatus, setNewsByTicker, setOhlcHistoryByTicker,
+ setPortfolioData, setPriceHistoryByTicker, setProgress,
+ setRollingTickers, setRuntimeConfig, setRuntimeConfigFeedback,
+ setServerMode, setSkillDetailLoadingKey, setSkillDetailsByName,
+ setStats, setSystemStatus, setTechnicalIndicatorsByTicker,
+ setTickers, setTrades, setVirtualTime, setWatchlistFeedback,
+ setWatchlistInputValue, setWorkspaceFileFeedback, setWorkspaceFileSavingKey,
+ setWorkspaceFilesByAgent, updateTickersFromPrices
+ ]);
+
+ // Sync refs
+ useEffect(() => {
+ isWatchlistSavingRef.current = isWatchlistSaving;
+ }, [isWatchlistSaving]);
+
+ useEffect(() => {
+ isRuntimeConfigSavingRef.current = isRuntimeConfigSaving;
+ }, [isRuntimeConfigSaving]);
+
+ useEffect(() => {
+ selectedSkillAgentIdRef.current = selectedSkillAgentId;
+ }, [selectedSkillAgentId]);
+
+ return { clientRef, setRequestStockHistory, setRequestStockNewsTimeline, setRequestStockNewsCategories };
+}
diff --git a/frontend/src/hooks/useWebsocketSessionSync.js b/frontend/src/hooks/useWebsocketSessionSync.js
new file mode 100644
index 0000000..3eb0938
--- /dev/null
+++ b/frontend/src/hooks/useWebsocketSessionSync.js
@@ -0,0 +1,29 @@
+/**
+ * useWebsocketSessionSync - DEPRECATED
+ *
+ * This hook is deprecated. WebSocket connection and event handling is now managed
+ * by useWebSocketConnection.js. This file is kept for backwards compatibility
+ * but will be removed in a future version.
+ *
+ * All functionality has been consolidated into:
+ * - useWebSocketConnection.js: WebSocket lifecycle and event handlers
+ * - useStockDataRequests.js: Stock data request callbacks
+ * - useAgentDataRequests.js: Agent operation callbacks
+ */
+
+import { useWebSocketConnection } from './useWebSocketConnection';
+
+/**
+ * @deprecated Use useWebSocketConnection directly instead.
+ * This hook is a thin wrapper that delegates to useWebSocketConnection
+ * for backwards compatibility.
+ */
+export function useWebsocketSessionSync(props) {
+ // Delegate to useWebSocketConnection
+ const { clientRef } = useWebSocketConnection();
+
+ // Return clientRef so existing code can still access it
+ return { clientRef };
+}
+
+export default useWebsocketSessionSync;
diff --git a/frontend/src/services/runtimeApi.js b/frontend/src/services/runtimeApi.js
index 16e9204..78eef1d 100644
--- a/frontend/src/services/runtimeApi.js
+++ b/frontend/src/services/runtimeApi.js
@@ -121,6 +121,10 @@ export function fetchCurrentRuntime() {
return safeFetch(RUNTIME_API_BASE, '/current');
}
+export function fetchRuntimeLogs() {
+ return safeFetch(RUNTIME_API_BASE, '/logs');
+}
+
export async function uploadAgentSkillZip({
agentId,
file,
diff --git a/frontend/src/services/runtimeControls.js b/frontend/src/services/runtimeControls.js
new file mode 100644
index 0000000..6c82c32
--- /dev/null
+++ b/frontend/src/services/runtimeControls.js
@@ -0,0 +1,81 @@
+const normalizeSymbol = (symbol) => {
+ if (typeof symbol !== "string") {
+ return "";
+ }
+ return symbol.trim().toUpperCase();
+};
+
+export const normalizeTickerSymbols = (symbols, previousTickers = []) => {
+ if (!Array.isArray(symbols) || symbols.length === 0) {
+ return previousTickers;
+ }
+
+ return symbols
+ .map(normalizeSymbol)
+ .filter(Boolean)
+ .reduce((acc, symbol) => {
+ const existing = acc.find((ticker) => ticker.symbol === symbol);
+ if (existing) {
+ return acc;
+ }
+ const prior = previousTickers.find((ticker) => ticker.symbol === symbol);
+ acc.push(
+ prior || {
+ symbol,
+ price: null,
+ change: null
+ }
+ );
+ return acc;
+ }, []);
+};
+
+export const normalizeRuntimeWatchlistSymbols = (runtimeConfig, fallbackTickers = []) => {
+ const runtimeSymbols = Array.isArray(runtimeConfig?.tickers)
+ ? runtimeConfig.tickers.map(normalizeSymbol).filter(Boolean)
+ : [];
+
+ if (runtimeSymbols.length > 0) {
+ return runtimeSymbols;
+ }
+
+ return fallbackTickers
+ .map((ticker) => normalizeSymbol(ticker?.symbol))
+ .filter(Boolean);
+};
+
+export const parseWatchlistInput = (value) => {
+ if (typeof value !== "string") {
+ return [];
+ }
+
+ return Array.from(
+ new Set(
+ value
+ .split(/[\s,]+/)
+ .map(normalizeSymbol)
+ .filter(Boolean)
+ )
+ );
+};
+
+export const buildRuntimeSummaryLabel = (runtimeConfig) => {
+ if (!runtimeConfig) {
+ return null;
+ }
+
+ const scheduleMode = String(runtimeConfig.schedule_mode || "daily");
+ 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 (triggerTime.toLowerCase() === "now") {
+ return `调度 daily / 立即执行 / 讨论 ${maxCommCycles} 轮`;
+ }
+
+ return `调度 daily / ${triggerTime} ET / 讨论 ${maxCommCycles} 轮`;
+};
diff --git a/frontend/src/services/runtimeControls.test.js b/frontend/src/services/runtimeControls.test.js
new file mode 100644
index 0000000..d9dadf6
--- /dev/null
+++ b/frontend/src/services/runtimeControls.test.js
@@ -0,0 +1,59 @@
+import { describe, expect, it } from "vitest";
+import {
+ buildRuntimeSummaryLabel,
+ normalizeRuntimeWatchlistSymbols,
+ normalizeTickerSymbols,
+ parseWatchlistInput
+} from "./runtimeControls";
+
+describe("runtimeControls", () => {
+ it("normalizes ticker symbols while preserving existing entries", () => {
+ const previous = [
+ { symbol: "AAPL", price: 10, change: 1 },
+ { symbol: "MSFT", price: 20, change: 2 }
+ ];
+
+ expect(normalizeTickerSymbols(["aapl", "nvda", "MSFT"], previous)).toEqual([
+ { symbol: "AAPL", price: 10, change: 1 },
+ { symbol: "NVDA", price: null, change: null },
+ { symbol: "MSFT", price: 20, change: 2 }
+ ]);
+ });
+
+ it("derives runtime watchlist symbols from runtime config or fallback tickers", () => {
+ const runtimeConfig = { tickers: ["tsla", "meta", "tsla"] };
+ const fallbackTickers = [{ symbol: "AAPL" }, { symbol: "MSFT" }];
+
+ expect(normalizeRuntimeWatchlistSymbols(runtimeConfig, fallbackTickers)).toEqual([
+ "TSLA",
+ "META",
+ "TSLA"
+ ]);
+ expect(normalizeRuntimeWatchlistSymbols({}, fallbackTickers)).toEqual([
+ "AAPL",
+ "MSFT"
+ ]);
+ });
+
+ it("parses watchlist input tokens and removes duplicates", () => {
+ expect(parseWatchlistInput(" aapl, msft nvda\nNVDA ")).toEqual([
+ "AAPL",
+ "MSFT",
+ "NVDA"
+ ]);
+ });
+
+ it("builds runtime summary labels", () => {
+ expect(buildRuntimeSummaryLabel({
+ schedule_mode: "daily",
+ trigger_time: "09:30",
+ max_comm_cycles: 3
+ })).toBe("调度 daily / 09:30 ET / 讨论 3 轮");
+
+ expect(buildRuntimeSummaryLabel({
+ schedule_mode: "intraday",
+ interval_minutes: 15,
+ max_comm_cycles: 2
+ })).toBe("调度 intraday / 15m / 讨论 2 轮");
+ });
+});
diff --git a/frontend/src/store/agentStore.js b/frontend/src/store/agentStore.js
index 6f2d3ac..a5193e1 100644
--- a/frontend/src/store/agentStore.js
+++ b/frontend/src/store/agentStore.js
@@ -1,58 +1,62 @@
import { create } from 'zustand';
+const resolveValue = (updater, currentValue) => (
+ typeof updater === 'function' ? updater(currentValue) : updater
+);
+
/**
* Agent Store - Agent skills, profiles, workspaces
*/
export const useAgentStore = create((set) => ({
// Selected agent for skill/workspace editing
selectedSkillAgentId: null,
- setSelectedSkillAgentId: (selectedSkillAgentId) => set({ selectedSkillAgentId }),
+ setSelectedSkillAgentId: (selectedSkillAgentId) => set((state) => ({ selectedSkillAgentId: resolveValue(selectedSkillAgentId, state.selectedSkillAgentId) })),
// Agent profiles
agentProfilesByAgent: {},
- setAgentProfilesByAgent: (agentProfilesByAgent) => set({ agentProfilesByAgent }),
+ setAgentProfilesByAgent: (agentProfilesByAgent) => set((state) => ({ agentProfilesByAgent: resolveValue(agentProfilesByAgent, state.agentProfilesByAgent) })),
// Agent skills
agentSkillsByAgent: {},
- setAgentSkillsByAgent: (agentSkillsByAgent) => set({ agentSkillsByAgent }),
+ setAgentSkillsByAgent: (agentSkillsByAgent) => set((state) => ({ agentSkillsByAgent: resolveValue(agentSkillsByAgent, state.agentSkillsByAgent) })),
// Skill details
skillDetailsByName: {},
- setSkillDetailsByName: (skillDetailsByName) => set({ skillDetailsByName }),
+ setSkillDetailsByName: (skillDetailsByName) => set((state) => ({ skillDetailsByName: resolveValue(skillDetailsByName, state.skillDetailsByName) })),
// Local skill drafts
localSkillDraftsByKey: {},
- setLocalSkillDraftsByKey: (localSkillDraftsByKey) => set({ localSkillDraftsByKey }),
+ setLocalSkillDraftsByKey: (localSkillDraftsByKey) => set((state) => ({ localSkillDraftsByKey: resolveValue(localSkillDraftsByKey, state.localSkillDraftsByKey) })),
// Loading states
isAgentSkillsLoading: false,
- setIsAgentSkillsLoading: (isAgentSkillsLoading) => set({ isAgentSkillsLoading }),
+ setIsAgentSkillsLoading: (isAgentSkillsLoading) => set((state) => ({ isAgentSkillsLoading: resolveValue(isAgentSkillsLoading, state.isAgentSkillsLoading) })),
skillDetailLoadingKey: null,
- setSkillDetailLoadingKey: (skillDetailLoadingKey) => set({ skillDetailLoadingKey }),
+ setSkillDetailLoadingKey: (skillDetailLoadingKey) => set((state) => ({ skillDetailLoadingKey: resolveValue(skillDetailLoadingKey, state.skillDetailLoadingKey) })),
agentSkillsSavingKey: null,
- setAgentSkillsSavingKey: (agentSkillsSavingKey) => set({ agentSkillsSavingKey }),
+ setAgentSkillsSavingKey: (agentSkillsSavingKey) => set((state) => ({ agentSkillsSavingKey: resolveValue(agentSkillsSavingKey, state.agentSkillsSavingKey) })),
agentSkillsFeedback: null,
- setAgentSkillsFeedback: (agentSkillsFeedback) => set({ agentSkillsFeedback }),
+ setAgentSkillsFeedback: (agentSkillsFeedback) => set((state) => ({ agentSkillsFeedback: resolveValue(agentSkillsFeedback, state.agentSkillsFeedback) })),
// Workspace files
selectedWorkspaceFile: null,
- setSelectedWorkspaceFile: (selectedWorkspaceFile) => set({ selectedWorkspaceFile }),
+ setSelectedWorkspaceFile: (selectedWorkspaceFile) => set((state) => ({ selectedWorkspaceFile: resolveValue(selectedWorkspaceFile, state.selectedWorkspaceFile) })),
workspaceFilesByAgent: {},
- setWorkspaceFilesByAgent: (workspaceFilesByAgent) => set({ workspaceFilesByAgent }),
+ setWorkspaceFilesByAgent: (workspaceFilesByAgent) => set((state) => ({ workspaceFilesByAgent: resolveValue(workspaceFilesByAgent, state.workspaceFilesByAgent) })),
workspaceDraftContent: '',
- setWorkspaceDraftContent: (workspaceDraftContent) => set({ workspaceDraftContent }),
+ setWorkspaceDraftContent: (workspaceDraftContent) => set((state) => ({ workspaceDraftContent: resolveValue(workspaceDraftContent, state.workspaceDraftContent) })),
isWorkspaceFileLoading: false,
- setIsWorkspaceFileLoading: (isWorkspaceFileLoading) => set({ isWorkspaceFileLoading }),
+ setIsWorkspaceFileLoading: (isWorkspaceFileLoading) => set((state) => ({ isWorkspaceFileLoading: resolveValue(isWorkspaceFileLoading, state.isWorkspaceFileLoading) })),
workspaceFileSavingKey: null,
- setWorkspaceFileSavingKey: (workspaceFileSavingKey) => set({ workspaceFileSavingKey }),
+ setWorkspaceFileSavingKey: (workspaceFileSavingKey) => set((state) => ({ workspaceFileSavingKey: resolveValue(workspaceFileSavingKey, state.workspaceFileSavingKey) })),
workspaceFileFeedback: null,
- setWorkspaceFileFeedback: (workspaceFileFeedback) => set({ workspaceFileFeedback }),
+ setWorkspaceFileFeedback: (workspaceFileFeedback) => set((state) => ({ workspaceFileFeedback: resolveValue(workspaceFileFeedback, state.workspaceFileFeedback) })),
}));
diff --git a/frontend/src/store/marketStore.js b/frontend/src/store/marketStore.js
index 250e9c3..05381f6 100644
--- a/frontend/src/store/marketStore.js
+++ b/frontend/src/store/marketStore.js
@@ -1,44 +1,48 @@
import { create } from 'zustand';
+const resolveValue = (updater, currentValue) => (
+ typeof updater === 'function' ? updater(currentValue) : updater
+);
+
/**
* Market Store - Market data, stock prices, news
*/
export const useMarketStore = create((set) => ({
// Ticker prices
tickers: [],
- setTickers: (tickers) => set({ tickers }),
+ setTickers: (tickers) => set((state) => ({ tickers: resolveValue(tickers, state.tickers) })),
rollingTickers: {},
- setRollingTickers: (rollingTickers) => set({ rollingTickers }),
+ setRollingTickers: (rollingTickers) => set((state) => ({ rollingTickers: resolveValue(rollingTickers, state.rollingTickers) })),
// Price history
priceHistoryByTicker: {},
- setPriceHistoryByTicker: (priceHistoryByTicker) => set({ priceHistoryByTicker }),
+ setPriceHistoryByTicker: (priceHistoryByTicker) => set((state) => ({ priceHistoryByTicker: resolveValue(priceHistoryByTicker, state.priceHistoryByTicker) })),
// OHLC history
ohlcHistoryByTicker: {},
- setOhlcHistoryByTicker: (ohlcHistoryByTicker) => set({ ohlcHistoryByTicker }),
+ setOhlcHistoryByTicker: (ohlcHistoryByTicker) => set((state) => ({ ohlcHistoryByTicker: resolveValue(ohlcHistoryByTicker, state.ohlcHistoryByTicker) })),
// History source tracking
historySourceByTicker: {},
- setHistorySourceByTicker: (historySourceByTicker) => set({ historySourceByTicker }),
+ setHistorySourceByTicker: (historySourceByTicker) => set((state) => ({ historySourceByTicker: resolveValue(historySourceByTicker, state.historySourceByTicker) })),
// Explain events
explainEventsByTicker: {},
- setExplainEventsByTicker: (explainEventsByTicker) => set({ explainEventsByTicker }),
+ setExplainEventsByTicker: (explainEventsByTicker) => set((state) => ({ explainEventsByTicker: resolveValue(explainEventsByTicker, state.explainEventsByTicker) })),
// Selected explain symbol
selectedExplainSymbol: '',
- setSelectedExplainSymbol: (selectedExplainSymbol) => set({ selectedExplainSymbol }),
+ setSelectedExplainSymbol: (selectedExplainSymbol) => set((state) => ({ selectedExplainSymbol: resolveValue(selectedExplainSymbol, state.selectedExplainSymbol) })),
// News by ticker
newsByTicker: {},
- setNewsByTicker: (newsByTicker) => set({ newsByTicker }),
+ setNewsByTicker: (newsByTicker) => set((state) => ({ newsByTicker: resolveValue(newsByTicker, state.newsByTicker) })),
// Insider trades
insiderTradesByTicker: {},
- setInsiderTradesByTicker: (insiderTradesByTicker) => set({ insiderTradesByTicker }),
+ setInsiderTradesByTicker: (insiderTradesByTicker) => set((state) => ({ insiderTradesByTicker: resolveValue(insiderTradesByTicker, state.insiderTradesByTicker) })),
// Technical indicators
technicalIndicatorsByTicker: {},
- setTechnicalIndicatorsByTicker: (technicalIndicatorsByTicker) => set({ technicalIndicatorsByTicker }),
+ setTechnicalIndicatorsByTicker: (technicalIndicatorsByTicker) => set((state) => ({ technicalIndicatorsByTicker: resolveValue(technicalIndicatorsByTicker, state.technicalIndicatorsByTicker) })),
}));
diff --git a/frontend/src/store/portfolioStore.js b/frontend/src/store/portfolioStore.js
index c185dc4..b3c11d9 100644
--- a/frontend/src/store/portfolioStore.js
+++ b/frontend/src/store/portfolioStore.js
@@ -1,5 +1,9 @@
import { create } from 'zustand';
+const resolveValue = (updater, currentValue) => (
+ typeof updater === 'function' ? updater(currentValue) : updater
+);
+
/**
* Portfolio Store - Portfolio data, holdings, trades, statistics
*/
@@ -18,21 +22,21 @@ export const usePortfolioStore = create((set) => ({
baseline_vw_return: 0,
momentum_return: 0,
},
- setPortfolioData: (portfolioData) => set({ portfolioData }),
+ setPortfolioData: (portfolioData) => set((state) => ({ portfolioData: resolveValue(portfolioData, state.portfolioData) })),
// Holdings
holdings: [],
- setHoldings: (holdings) => set({ holdings }),
+ setHoldings: (holdings) => set((state) => ({ holdings: resolveValue(holdings, state.holdings) })),
// Trades
trades: [],
- setTrades: (trades) => set({ trades }),
+ setTrades: (trades) => set((state) => ({ trades: resolveValue(trades, state.trades) })),
// Statistics
stats: null,
- setStats: (stats) => set({ stats }),
+ setStats: (stats) => set((state) => ({ stats: resolveValue(stats, state.stats) })),
// Leaderboard
leaderboard: [],
- setLeaderboard: (leaderboard) => set({ leaderboard }),
+ setLeaderboard: (leaderboard) => set((state) => ({ leaderboard: resolveValue(leaderboard, state.leaderboard) })),
}));
diff --git a/frontend/src/store/runtimeStore.js b/frontend/src/store/runtimeStore.js
index 0e58097..19a17cd 100644
--- a/frontend/src/store/runtimeStore.js
+++ b/frontend/src/store/runtimeStore.js
@@ -1,5 +1,9 @@
import { create } from 'zustand';
+const resolveValue = (updater, currentValue) => (
+ typeof updater === 'function' ? updater(currentValue) : updater
+);
+
/**
* Runtime Store - Connection state and runtime configuration
*/
@@ -7,59 +11,59 @@ export const useRuntimeStore = create((set) => ({
// Connection state
isConnected: false,
connectionStatus: 'connecting', // 'connecting' | 'connected' | 'disconnected'
- setIsConnected: (isConnected) => set({ isConnected }),
- setConnectionStatus: (connectionStatus) => set({ connectionStatus }),
+ setIsConnected: (isConnected) => set((state) => ({ isConnected: resolveValue(isConnected, state.isConnected) })),
+ setConnectionStatus: (connectionStatus) => set((state) => ({ connectionStatus: resolveValue(connectionStatus, state.connectionStatus) })),
// System state
systemStatus: 'initializing', // 'initializing' | 'running' | 'completed'
currentDate: null,
- setSystemStatus: (systemStatus) => set({ systemStatus }),
- setCurrentDate: (currentDate) => set({ currentDate }),
+ setSystemStatus: (systemStatus) => set((state) => ({ systemStatus: resolveValue(systemStatus, state.systemStatus) })),
+ setCurrentDate: (currentDate) => set((state) => ({ currentDate: resolveValue(currentDate, state.currentDate) })),
// Progress
progress: { current: 0, total: 0 },
- setProgress: (progress) => set({ progress }),
+ setProgress: (progress) => set((state) => ({ progress: resolveValue(progress, state.progress) })),
// Server mode
serverMode: null, // 'live' | 'backtest' | null
- setServerMode: (serverMode) => set({ serverMode }),
+ setServerMode: (serverMode) => set((state) => ({ serverMode: resolveValue(serverMode, state.serverMode) })),
// Market status
marketStatus: null,
virtualTime: null,
- setMarketStatus: (marketStatus) => set({ marketStatus }),
- setVirtualTime: (virtualTime) => set({ virtualTime }),
+ setMarketStatus: (marketStatus) => set((state) => ({ marketStatus: resolveValue(marketStatus, state.marketStatus) })),
+ setVirtualTime: (virtualTime) => set((state) => ({ virtualTime: resolveValue(virtualTime, state.virtualTime) })),
// Data sources
dataSources: null,
- setDataSources: (dataSources) => set({ dataSources }),
+ setDataSources: (dataSources) => set((state) => ({ dataSources: resolveValue(dataSources, state.dataSources) })),
// Runtime config
runtimeConfig: null,
- setRuntimeConfig: (runtimeConfig) => set({ runtimeConfig }),
+ setRuntimeConfig: (runtimeConfig) => set((state) => ({ runtimeConfig: resolveValue(runtimeConfig, state.runtimeConfig) })),
// Watchlist panel
isWatchlistPanelOpen: false,
- setIsWatchlistPanelOpen: (isWatchlistPanelOpen) => set({ isWatchlistPanelOpen }),
+ setIsWatchlistPanelOpen: (isWatchlistPanelOpen) => set((state) => ({ isWatchlistPanelOpen: resolveValue(isWatchlistPanelOpen, state.isWatchlistPanelOpen) })),
// Watchlist draft
watchlistDraftSymbols: [],
watchlistInputValue: '',
watchlistFeedback: null,
isWatchlistSaving: false,
- setWatchlistDraftSymbols: (watchlistDraftSymbols) => set({ watchlistDraftSymbols }),
- setWatchlistInputValue: (watchlistInputValue) => set({ watchlistInputValue }),
- setWatchlistFeedback: (watchlistFeedback) => set({ watchlistFeedback }),
- setIsWatchlistSaving: (isWatchlistSaving) => set({ isWatchlistSaving }),
+ setWatchlistDraftSymbols: (watchlistDraftSymbols) => set((state) => ({ watchlistDraftSymbols: resolveValue(watchlistDraftSymbols, state.watchlistDraftSymbols) })),
+ setWatchlistInputValue: (watchlistInputValue) => set((state) => ({ watchlistInputValue: resolveValue(watchlistInputValue, state.watchlistInputValue) })),
+ setWatchlistFeedback: (watchlistFeedback) => set((state) => ({ watchlistFeedback: resolveValue(watchlistFeedback, state.watchlistFeedback) })),
+ setIsWatchlistSaving: (isWatchlistSaving) => set((state) => ({ isWatchlistSaving: resolveValue(isWatchlistSaving, state.isWatchlistSaving) })),
// Runtime settings panel
isRuntimeSettingsOpen: false,
- setIsRuntimeSettingsOpen: (isRuntimeSettingsOpen) => set({ isRuntimeSettingsOpen }),
+ setIsRuntimeSettingsOpen: (isRuntimeSettingsOpen) => set((state) => ({ isRuntimeSettingsOpen: resolveValue(isRuntimeSettingsOpen, state.isRuntimeSettingsOpen) })),
// Runtime config drafts
scheduleModeDraft: 'daily',
intervalMinutesDraft: '60',
- triggerTimeDraft: '09:30',
+ triggerTimeDraft: 'now',
maxCommCyclesDraft: '2',
initialCashDraft: '100000',
marginRequirementDraft: '0',
@@ -69,26 +73,26 @@ export const useRuntimeStore = create((set) => ({
startDateDraft: '',
endDateDraft: '',
enableMockDraft: false,
- setScheduleModeDraft: (scheduleModeDraft) => set({ scheduleModeDraft }),
- setIntervalMinutesDraft: (intervalMinutesDraft) => set({ intervalMinutesDraft }),
- setTriggerTimeDraft: (triggerTimeDraft) => set({ triggerTimeDraft }),
- setMaxCommCyclesDraft: (maxCommCyclesDraft) => set({ maxCommCyclesDraft }),
- setInitialCashDraft: (initialCashDraft) => set({ initialCashDraft }),
- setMarginRequirementDraft: (marginRequirementDraft) => set({ marginRequirementDraft }),
- setEnableMemoryDraft: (enableMemoryDraft) => set({ enableMemoryDraft }),
- setModeDraft: (modeDraft) => set({ modeDraft }),
- setPollIntervalDraft: (pollIntervalDraft) => set({ pollIntervalDraft }),
- setStartDateDraft: (startDateDraft) => set({ startDateDraft }),
- setEndDateDraft: (endDateDraft) => set({ endDateDraft }),
- setEnableMockDraft: (enableMockDraft) => set({ enableMockDraft }),
+ setScheduleModeDraft: (scheduleModeDraft) => set((state) => ({ scheduleModeDraft: resolveValue(scheduleModeDraft, state.scheduleModeDraft) })),
+ setIntervalMinutesDraft: (intervalMinutesDraft) => set((state) => ({ intervalMinutesDraft: resolveValue(intervalMinutesDraft, state.intervalMinutesDraft) })),
+ setTriggerTimeDraft: (triggerTimeDraft) => set((state) => ({ triggerTimeDraft: resolveValue(triggerTimeDraft, state.triggerTimeDraft) })),
+ setMaxCommCyclesDraft: (maxCommCyclesDraft) => set((state) => ({ maxCommCyclesDraft: resolveValue(maxCommCyclesDraft, state.maxCommCyclesDraft) })),
+ setInitialCashDraft: (initialCashDraft) => set((state) => ({ initialCashDraft: resolveValue(initialCashDraft, state.initialCashDraft) })),
+ setMarginRequirementDraft: (marginRequirementDraft) => set((state) => ({ marginRequirementDraft: resolveValue(marginRequirementDraft, state.marginRequirementDraft) })),
+ setEnableMemoryDraft: (enableMemoryDraft) => set((state) => ({ enableMemoryDraft: resolveValue(enableMemoryDraft, state.enableMemoryDraft) })),
+ setModeDraft: (modeDraft) => set((state) => ({ modeDraft: resolveValue(modeDraft, state.modeDraft) })),
+ setPollIntervalDraft: (pollIntervalDraft) => set((state) => ({ pollIntervalDraft: resolveValue(pollIntervalDraft, state.pollIntervalDraft) })),
+ setStartDateDraft: (startDateDraft) => set((state) => ({ startDateDraft: resolveValue(startDateDraft, state.startDateDraft) })),
+ setEndDateDraft: (endDateDraft) => set((state) => ({ endDateDraft: resolveValue(endDateDraft, state.endDateDraft) })),
+ setEnableMockDraft: (enableMockDraft) => set((state) => ({ enableMockDraft: resolveValue(enableMockDraft, state.enableMockDraft) })),
// Runtime config feedback
runtimeConfigFeedback: null,
isRuntimeConfigSaving: false,
- setRuntimeConfigFeedback: (runtimeConfigFeedback) => set({ runtimeConfigFeedback }),
- setIsRuntimeConfigSaving: (isRuntimeConfigSaving) => set({ isRuntimeConfigSaving }),
+ setRuntimeConfigFeedback: (runtimeConfigFeedback) => set((state) => ({ runtimeConfigFeedback: resolveValue(runtimeConfigFeedback, state.runtimeConfigFeedback) })),
+ setIsRuntimeConfigSaving: (isRuntimeConfigSaving) => set((state) => ({ isRuntimeConfigSaving: resolveValue(isRuntimeConfigSaving, state.isRuntimeConfigSaving) })),
// Last day history (for replay)
lastDayHistory: [],
- setLastDayHistory: (lastDayHistory) => set({ lastDayHistory }),
+ setLastDayHistory: (lastDayHistory) => set((state) => ({ lastDayHistory: resolveValue(lastDayHistory, state.lastDayHistory) })),
}));
diff --git a/frontend/src/store/uiStore.js b/frontend/src/store/uiStore.js
index daf7f75..721b377 100644
--- a/frontend/src/store/uiStore.js
+++ b/frontend/src/store/uiStore.js
@@ -1,40 +1,44 @@
import { create } from 'zustand';
+const resolveValue = (updater, currentValue) => (
+ typeof updater === 'function' ? updater(currentValue) : updater
+);
+
/**
* UI Store - UI state, view management, layout
*/
export const useUIStore = create((set) => ({
// Current view
currentView: 'traders', // 'traders' | 'room' | 'explain' | 'chart' | 'statistics' | 'runtime'
- setCurrentView: (currentView) => set({ currentView }),
+ setCurrentView: (currentView) => set((state) => ({ currentView: resolveValue(currentView, state.currentView) })),
// Chart tab
chartTab: 'all',
- setChartTab: (chartTab) => set({ chartTab }),
+ setChartTab: (chartTab) => set((state) => ({ chartTab: resolveValue(chartTab, state.chartTab) })),
// Initial animation
isInitialAnimating: true,
- setIsInitialAnimating: (isInitialAnimating) => set({ isInitialAnimating }),
+ setIsInitialAnimating: (isInitialAnimating) => set((state) => ({ isInitialAnimating: resolveValue(isInitialAnimating, state.isInitialAnimating) })),
// Last update timestamp
lastUpdate: new Date(),
- setLastUpdate: (lastUpdate) => set({ lastUpdate }),
+ setLastUpdate: (lastUpdate) => set((state) => ({ lastUpdate: resolveValue(lastUpdate, state.lastUpdate) })),
// Is updating
isUpdating: false,
- setIsUpdating: (isUpdating) => set({ isUpdating }),
+ setIsUpdating: (isUpdating) => set((state) => ({ isUpdating: resolveValue(isUpdating, state.isUpdating) })),
// Room bubbles
bubbles: {},
- setBubbles: (bubbles) => set({ bubbles }),
+ setBubbles: (bubbles) => set((state) => ({ bubbles: resolveValue(bubbles, state.bubbles) })),
// Resizable panels
leftWidth: 70,
- setLeftWidth: (leftWidth) => set({ leftWidth }),
+ setLeftWidth: (leftWidth) => set((state) => ({ leftWidth: resolveValue(leftWidth, state.leftWidth) })),
isResizing: false,
- setIsResizing: (isResizing) => set({ isResizing }),
+ setIsResizing: (isResizing) => set((state) => ({ isResizing: resolveValue(isResizing, state.isResizing) })),
// Now timestamp (for current time display)
now: new Date(),
- setNow: (now) => set({ now }),
+ setNow: (now) => set((state) => ({ now: resolveValue(now, state.now) })),
}));
diff --git a/frontend/src/styles/GlobalStyles.jsx b/frontend/src/styles/GlobalStyles.jsx
index ecea6e9..040b0fb 100644
--- a/frontend/src/styles/GlobalStyles.jsx
+++ b/frontend/src/styles/GlobalStyles.jsx
@@ -478,7 +478,7 @@ export default function GlobalStyles() {
background: #ffffff;
flex-wrap: wrap;
position: relative;
- z-index: 1000;
+ z-index: 10;
}
.agent-indicator {
@@ -583,6 +583,7 @@ export default function GlobalStyles() {
.room-scene-wrapper {
position: relative;
+ overflow: visible;
}
@keyframes pulse {
@@ -646,7 +647,7 @@ export default function GlobalStyles() {
display: flex;
align-items: center;
justify-content: center;
- overflow: hidden;
+ overflow: visible;
padding: 24px;
position: relative;
}
@@ -656,6 +657,7 @@ export default function GlobalStyles() {
display: flex;
align-items: center;
justify-content: center;
+ overflow: visible;
}
.room-canvas {
@@ -666,7 +668,8 @@ export default function GlobalStyles() {
.room-bubble {
position: absolute;
- max-width: 300px;
+ max-width: 320px;
+ max-height: 260px;
font-size: 11px;
background: #ffffff;
color: #000000;
@@ -676,6 +679,8 @@ export default function GlobalStyles() {
font-family: 'IBM Plex Mono', monospace;
line-height: 1.5;
animation: bubbleAppear 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
+ overflow: hidden;
+ z-index: 30;
}
@keyframes bubbleAppear {
@@ -786,6 +791,9 @@ export default function GlobalStyles() {
word-wrap: break-word;
white-space: pre-wrap;
position: relative;
+ max-height: 180px;
+ overflow-y: auto;
+ padding-right: 4px;
}
.bubble-expand-btn {
diff --git a/start-dev.sh b/start-dev.sh
index 2528e6d..c053527 100755
--- a/start-dev.sh
+++ b/start-dev.sh
@@ -29,13 +29,6 @@ else
echo -e "${YELLOW}Warning: .env file not found${NC}"
fi
-# Check required environment variables
-if [ -z "$OPENAI_API_KEY" ]; then
- echo -e "${RED}Error: OPENAI_API_KEY not set${NC}"
- echo "Please set it in .env file or environment"
- exit 1
-fi
-
cd /Users/cillin/workspeace/evotraders
PIDS=()