From c5eaf2b5ade6538a44fbb462c3d9bdd053232e3b Mon Sep 17 00:00:00 2001 From: cillin Date: Tue, 24 Mar 2026 10:58:41 +0800 Subject: [PATCH] Fix runtime logging and frontend app regressions --- .env.example | 41 + backend/api/runtime.py | 89 +- backend/apps/cors.py | 30 + backend/config/data_config.py | 30 +- backend/data/polling_price_manager.py | 35 +- backend/gateway_server.py | 71 +- backend/llm/models.py | 106 +- backend/services/market.py | 13 +- backend/tests/test_market_service.py | 77 +- frontend/src/App.jsx | 3349 ++--------------- frontend/src/components/AppShell.jsx | 532 +++ frontend/src/components/RuntimeLogsModal.jsx | 136 + frontend/src/components/RuntimeView.jsx | 18 + frontend/src/components/TraderView.jsx | 30 + frontend/src/components/WatchlistPanel.jsx | 18 + .../explain/ExplainPriceSection.jsx | 56 +- frontend/src/hooks/useAgentDataRequests.js | 211 ++ frontend/src/hooks/useAgentWorkspacePanel.js | 385 ++ frontend/src/hooks/useRuntimeControls.js | 538 +++ frontend/src/hooks/useStockDataRequests.js | 352 ++ frontend/src/hooks/useStockExplainData.js | 546 +++ frontend/src/hooks/useWebSocketConnection.js | 875 +++++ frontend/src/hooks/useWebsocketSessionSync.js | 29 + frontend/src/services/runtimeApi.js | 4 + frontend/src/services/runtimeControls.js | 81 + frontend/src/services/runtimeControls.test.js | 59 + frontend/src/store/agentStore.js | 34 +- frontend/src/store/marketStore.js | 24 +- frontend/src/store/portfolioStore.js | 14 +- frontend/src/store/runtimeStore.js | 68 +- frontend/src/store/uiStore.js | 22 +- frontend/src/styles/GlobalStyles.jsx | 14 +- start-dev.sh | 7 - 33 files changed, 4763 insertions(+), 3131 deletions(-) create mode 100644 .env.example create mode 100644 backend/apps/cors.py create mode 100644 frontend/src/components/AppShell.jsx create mode 100644 frontend/src/components/RuntimeLogsModal.jsx create mode 100644 frontend/src/hooks/useAgentDataRequests.js create mode 100644 frontend/src/hooks/useAgentWorkspacePanel.js create mode 100644 frontend/src/hooks/useRuntimeControls.js create mode 100644 frontend/src/hooks/useStockDataRequests.js create mode 100644 frontend/src/hooks/useStockExplainData.js create mode 100644 frontend/src/hooks/useWebSocketConnection.js create mode 100644 frontend/src/hooks/useWebsocketSessionSync.js create mode 100644 frontend/src/services/runtimeControls.js create mode 100644 frontend/src/services/runtimeControls.test.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..372283b --- /dev/null +++ b/.env.example @@ -0,0 +1,41 @@ +# Copy this file to `.env` for local development. +# Keep `.env` untracked and never paste real secrets into tracked files. + +# ================== General Configuration | 通用配置 ================== +TICKERS=AAPL,MSFT,GOOGL,NVDA,TSLA,META,AMZN + +# Financial Data API +# At least `FINANCIAL_DATASETS_API_KEY` is required when using `FIN_DATA_SOURCE=financial_datasets`. +# `FINNHUB_API_KEY` is recommended for `FIN_DATA_SOURCE=finnhub` and required for live mode. +FIN_DATA_SOURCE=finnhub +ENABLED_DATA_SOURCES=financial_datasets,finnhub,yfinance,local_csv +FINANCIAL_DATASETS_API_KEY= +FINNHUB_API_KEY= +POLYGON_API_KEY= +MARKET_DB_PATH= + +# Model API +OPENAI_API_KEY= +OPENAI_BASE_URL= +MODEL_NAME=qwen3-max-preview +EXPLAIN_ENRICH_USE_LLM=false +EXPLAIN_ENRICH_MODEL_PROVIDER= +EXPLAIN_ENRICH_MODEL_NAME= +EXPLAIN_RANGE_USE_LLM= + +# Memory module +MEMORY_API_KEY= + +# ================== Agent-Specific Model Configuration | Agent特定模型配置 ================== +AGENT_SENTIMENT_ANALYST_MODEL_NAME=deepseek-v3.2-exp +AGENT_TECHNICAL_ANALYST_MODEL_NAME=glm-4.6 +AGENT_FUNDAMENTALS_ANALYST_MODEL_NAME=qwen3-max-preview +AGENT_VALUATION_ANALYST_MODEL_NAME=Moonshot-Kimi-K2-Instruct +AGENT_RISK_MANAGER_MODEL_NAME=qwen3-max-preview +AGENT_PORTFOLIO_MANAGER_MODEL_NAME=qwen3-max-preview + +# ================== Advanced Configuration | 高阶配置 ================== +MAX_COMM_CYCLES=2 +MARGIN_REQUIREMENT=0.5 +DATA_START_DATE=2022-01-01 +AUTO_UPDATE_DATA=true diff --git a/backend/api/runtime.py b/backend/api/runtime.py index 61d94f7..ec153c2 100644 --- a/backend/api/runtime.py +++ b/backend/api/runtime.py @@ -38,12 +38,13 @@ class RuntimeState: """ _instance: Optional["RuntimeState"] = None - _lock: asyncio.Lock = asyncio.Lock() + _lock: "threading.Lock" = __import__("threading").Lock() def __new__(cls) -> "RuntimeState": - if cls._instance is None: - cls._instance = super().__new__(cls) - cls._instance._initialized = False + with cls._lock: + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._initialized = False return cls._instance def __init__(self) -> None: @@ -207,6 +208,13 @@ class RuntimeConfigResponse(BaseModel): resolved: Dict[str, Any] +class RuntimeLogResponse(BaseModel): + run_id: Optional[str] = None + is_running: bool + log_path: Optional[str] = None + content: str = "" + + class UpdateRuntimeConfigRequest(BaseModel): schedule_mode: Optional[str] = None interval_minutes: Optional[int] = Field(default=None, ge=1) @@ -288,14 +296,20 @@ def _start_gateway_process( "--bootstrap", json.dumps(bootstrap) ] - # Start process - process = subprocess.Popen( - cmd, - env=env, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - cwd=PROJECT_ROOT - ) + log_path = run_dir / "logs" / "gateway.log" + log_path.parent.mkdir(parents=True, exist_ok=True) + + log_file = log_path.open("ab") + try: + process = subprocess.Popen( + cmd, + env=env, + stdout=log_file, + stderr=subprocess.STDOUT, + cwd=PROJECT_ROOT + ) + finally: + log_file.close() return process @@ -390,6 +404,26 @@ async def get_gateway_port(request: Request) -> Dict[str, Any]: } +@router.get("/logs", response_model=RuntimeLogResponse) +async def get_runtime_logs() -> RuntimeLogResponse: + """Return current runtime log tail, or the latest run log if runtime is stopped.""" + try: + context = _get_runtime_context_from_latest_snapshot() + except HTTPException: + return RuntimeLogResponse(is_running=False, content="") + + run_id = str(context.get("config_name") or "").strip() or None + log_path = _get_gateway_log_path_for_run(run_id) if run_id else None + content = _read_log_tail(log_path) if log_path else "" + + return RuntimeLogResponse( + run_id=run_id, + is_running=_is_gateway_running(), + log_path=str(log_path) if log_path else None, + content=content, + ) + + def _build_gateway_ws_url(request: Request, port: int) -> str: """Build a proxy-safe Gateway WebSocket URL.""" forwarded_proto = request.headers.get("x-forwarded-proto", "").split(",")[0].strip() @@ -416,10 +450,8 @@ def _load_latest_runtime_snapshot() -> Dict[str, Any]: return json.loads(snapshots[0].read_text(encoding="utf-8")) -def _get_current_runtime_context() -> Dict[str, Any]: - """Return the active runtime context from the latest snapshot.""" - if not _is_gateway_running(): - raise HTTPException(status_code=404, detail="No runtime is currently running") +def _get_runtime_context_from_latest_snapshot() -> Dict[str, Any]: + """Return the latest persisted runtime context regardless of active process state.""" latest = _load_latest_runtime_snapshot() context = latest.get("context") or {} if not context.get("config_name"): @@ -427,6 +459,26 @@ def _get_current_runtime_context() -> Dict[str, Any]: return context +def _get_gateway_log_path_for_run(run_id: str) -> Path: + return _get_run_dir(run_id) / "logs" / "gateway.log" + + +def _read_log_tail(path: Path, max_chars: int = 120_000) -> str: + if not path.exists() or not path.is_file(): + return "" + text = path.read_text(encoding="utf-8", errors="replace") + if len(text) <= max_chars: + return text + return text[-max_chars:] + + +def _get_current_runtime_context() -> Dict[str, Any]: + """Return the active runtime context from the latest snapshot.""" + if not _is_gateway_running(): + raise HTTPException(status_code=404, detail="No runtime is currently running") + return _get_runtime_context_from_latest_snapshot() + + def _resolve_runtime_response(run_id: str) -> RuntimeConfigResponse: """Build a normalized runtime config response for the active run.""" context = _get_current_runtime_context() @@ -567,11 +619,12 @@ async def start_runtime( await asyncio.sleep(2) if not _is_gateway_running(): - stdout, stderr = process.communicate(timeout=1) _runtime_state.gateway_process = None + log_path = _get_gateway_log_path_for_run(run_id) + log_tail = _read_log_tail(log_path, max_chars=4000) raise HTTPException( status_code=500, - detail=f"Gateway failed to start: {stderr.decode() if stderr else 'Unknown error'}" + detail=f"Gateway failed to start: {log_tail or 'Unknown error'}" ) except Exception as e: diff --git a/backend/apps/cors.py b/backend/apps/cors.py new file mode 100644 index 0000000..e6ea85f --- /dev/null +++ b/backend/apps/cors.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +"""Shared CORS configuration for all microservice apps.""" + +import os +from typing import Sequence + +from fastapi.middleware.cors import CORSMiddleware + + +def get_cors_origins() -> Sequence[str]: + """Get allowed CORS origins from environment variable. + + Defaults to ["*"] for backward compatibility. + Set CORS_ALLOWED_ORIGINS env var (comma-separated) in production. + """ + origins = os.getenv("CORS_ALLOWED_ORIGINS", "").strip() + if not origins: + return ["*"] + return [o.strip() for o in origins.split(",") if o.strip()] + + +def add_cors_middleware(app: "FastAPI") -> None: + """Add CORS middleware to app with environment-configured origins.""" + app.add_middleware( + CORSMiddleware, + allow_origins=get_cors_origins(), + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) diff --git a/backend/config/data_config.py b/backend/config/data_config.py index c16723b..9b5060f 100644 --- a/backend/config/data_config.py +++ b/backend/config/data_config.py @@ -76,27 +76,19 @@ def _resolve_config() -> DataSourceConfig: """ Resolve data source configuration based on available API keys. - Priority: - 1. FINNHUB_API_KEY (if set) - 2. FINANCIAL_DATASETS_API_KEY (if set) - 3. Raises error if neither is available + The effective source should always match the first item in the resolved + ordered source list. """ sources = _ordered_sources() - if "finnhub" in sources: - return DataSourceConfig( - source="finnhub", - api_key=os.getenv("FINNHUB_API_KEY", "").strip(), - sources=sources, - ) - if "financial_datasets" in sources: - return DataSourceConfig( - source="financial_datasets", - api_key=os.getenv("FINANCIAL_DATASETS_API_KEY", "").strip(), - sources=sources, - ) - if "yfinance" in sources: - return DataSourceConfig(source="yfinance", api_key="", sources=sources) - return DataSourceConfig(source="local_csv", api_key="", sources=sources) + source = sources[0] if sources else "local_csv" + + api_key = "" + if source == "finnhub": + api_key = os.getenv("FINNHUB_API_KEY", "").strip() + elif source == "financial_datasets": + api_key = os.getenv("FINANCIAL_DATASETS_API_KEY", "").strip() + + return DataSourceConfig(source=source, api_key=api_key, sources=sources) def get_config() -> DataSourceConfig: diff --git a/backend/data/polling_price_manager.py b/backend/data/polling_price_manager.py index 1b93db9..4f4a7cf 100644 --- a/backend/data/polling_price_manager.py +++ b/backend/data/polling_price_manager.py @@ -15,6 +15,9 @@ from backend.data.provider_utils import normalize_symbol logger = logging.getLogger(__name__) +_SUPPRESSED_LOG_EVERY = 20 + + class PollingPriceManager: """Polling-based price manager using Finnhub or yfinance.""" @@ -43,6 +46,7 @@ class PollingPriceManager: self.latest_prices: Dict[str, float] = {} self.open_prices: Dict[str, float] = {} self.price_callbacks: List[Callable] = [] + self._failure_counts: Dict[str, int] = {} self.running = False self._thread: Optional[threading.Thread] = None @@ -77,6 +81,8 @@ class PollingPriceManager: for symbol in self.subscribed_symbols: try: quote_data = self._fetch_quote(symbol) + if not isinstance(quote_data, dict): + raise ValueError(f"{symbol}: Empty quote payload") current_price = quote_data.get("c") open_price = quote_data.get("o") @@ -103,6 +109,13 @@ class PollingPriceManager: ) self.latest_prices[symbol] = current_price + previous_failures = self._failure_counts.pop(symbol, 0) + if previous_failures > 0: + logger.info( + "%s quote polling recovered after %d consecutive failures", + symbol, + previous_failures, + ) price_data = { "symbol": symbol, @@ -128,7 +141,20 @@ class PollingPriceManager: ) except Exception as e: - logger.error(f"Failed to fetch {symbol} price: {e}") + failure_count = self._failure_counts.get(symbol, 0) + 1 + self._failure_counts[symbol] = failure_count + message = f"Failed to fetch {symbol} price: {e}" + + if failure_count == 1: + logger.warning(message) + elif failure_count % _SUPPRESSED_LOG_EVERY == 0: + logger.warning( + "%s (repeated %d times; suppressing intermediate failures)", + message, + failure_count, + ) + else: + logger.debug(message) def _fetch_quote(self, symbol: str) -> Dict[str, float]: """Fetch a normalized quote payload from the configured provider.""" @@ -136,7 +162,10 @@ class PollingPriceManager: return self._fetch_yfinance_quote(symbol) if not self.finnhub_client: raise ValueError("Finnhub API key required for finnhub polling") - return self.finnhub_client.quote(symbol) + quote = self.finnhub_client.quote(symbol) + if not isinstance(quote, dict): + raise ValueError(f"{symbol}: Invalid Finnhub quote payload") + return quote def _fetch_yfinance_quote(self, symbol: str) -> Dict[str, float]: """Fetch quote data from yfinance and normalize to Finnhub-like keys.""" @@ -162,6 +191,8 @@ class PollingPriceManager: if current_price is None: history = ticker.history(period="1d", interval="1m", auto_adjust=False) + if history is None: + raise ValueError(f"{symbol}: yfinance returned no history frame") if history.empty: raise ValueError(f"{symbol}: No yfinance quote data") latest = history.iloc[-1] diff --git a/backend/gateway_server.py b/backend/gateway_server.py index d75e5ab..60cb019 100644 --- a/backend/gateway_server.py +++ b/backend/gateway_server.py @@ -43,6 +43,71 @@ logger = logging.getLogger(__name__) _prompt_loader = get_prompt_loader() +INFO_LOGGER_PREFIXES = ( + "backend.agents", + "backend.core.pipeline", + "backend.core.scheduler", + "backend.services.gateway_cycle_support", + "backend.utils.terminal_dashboard", +) + +NOISY_LOGGER_LEVELS = { + "aiohttp": logging.WARNING, + "asyncio": logging.WARNING, + "dashscope": logging.WARNING, + "finnhub": logging.WARNING, + "httpcore": logging.WARNING, + "httpx": logging.WARNING, + "urllib3": logging.WARNING, + "websockets": logging.WARNING, + "yfinance": logging.WARNING, + "backend.data.polling_price_manager": logging.WARNING, + "backend.services.gateway": logging.WARNING, + "backend.services.market": logging.WARNING, + "backend.services.storage": logging.WARNING, +} + + +class SuppressNoisyInfoFilter(logging.Filter): + """Filter out low-signal library INFO logs while keeping warnings/errors.""" + + def filter(self, record: logging.LogRecord) -> bool: + if record.levelno >= logging.WARNING: + return True + + message = record.getMessage() + if record.name == "httpx" and message.startswith("HTTP Request:"): + return False + if record.name.startswith("websockets") and "connection open" in message: + return False + + return True + + +def configure_gateway_logging(verbose: bool = False) -> None: + """Configure gateway logging with low-noise defaults for runtime logs.""" + root_level = logging.DEBUG if verbose else logging.WARNING + logging.basicConfig( + level=root_level, + format="%(asctime)s | %(levelname)-7s | %(name)s:%(lineno)d - %(message)s", + force=True, + ) + + if not verbose: + suppress_filter = SuppressNoisyInfoFilter() + for handler in logging.getLogger().handlers: + handler.addFilter(suppress_filter) + + for logger_name, level in NOISY_LOGGER_LEVELS.items(): + logging.getLogger(logger_name).setLevel(logging.DEBUG if verbose else level) + + if not verbose: + for prefix in INFO_LOGGER_PREFIXES: + logging.getLogger(prefix).setLevel(logging.INFO) + + logging.getLogger(__name__).setLevel(logging.INFO if not verbose else logging.DEBUG) + + async def run_gateway( run_id: str, run_dir: Path, @@ -222,11 +287,7 @@ def main(): args = parser.parse_args() # Setup logging - level = logging.DEBUG if args.verbose else logging.INFO - logging.basicConfig( - level=level, - format="%(asctime)s | %(levelname)-7s | %(name)s:%(lineno)d - %(message)s", - ) + configure_gateway_logging(verbose=args.verbose) # Parse bootstrap bootstrap = json.loads(args.bootstrap) diff --git a/backend/llm/models.py b/backend/llm/models.py index 202c130..b895c67 100644 --- a/backend/llm/models.py +++ b/backend/llm/models.py @@ -3,6 +3,8 @@ AgentScope Native Model Factory Uses native AgentScope model classes for LLM calls """ +import asyncio +import inspect import os import time import logging @@ -34,6 +36,27 @@ logger = logging.getLogger(__name__) T = TypeVar("T") +def _usage_value(usage: Any, key: str, default: Any = 0) -> Any: + """Read usage fields from both object-style and dict-style usage payloads.""" + if usage is None: + return default + if isinstance(usage, dict): + return usage.get(key, default) + try: + return getattr(usage, key) + except (AttributeError, KeyError): + return default + + +def _usage_total_tokens(usage: Any) -> int: + total = _usage_value(usage, "total_tokens", None) + if total is not None: + return int(total or 0) + input_tokens = _usage_value(usage, "input_tokens", 0) + output_tokens = _usage_value(usage, "output_tokens", 0) + return int((input_tokens or 0) + (output_tokens or 0)) + + class RetryChatModel: """Wraps an AgentScope model with automatic retry for transient errors. @@ -55,6 +78,7 @@ class RetryChatModel: "502", "504", "connection", + "disconnected", "temporary", "overloaded", "too_many_requests", @@ -150,8 +174,8 @@ class RetryChatModel: # Track usage if available if hasattr(result, "usage") and result.usage: usage = result.usage - self._total_tokens_used += getattr(usage, "total_tokens", 0) - self._total_cost += getattr(usage, "cost", 0.0) + self._total_tokens_used += _usage_total_tokens(usage) + self._total_cost += float(_usage_value(usage, "cost", 0.0) or 0.0) return result @@ -192,9 +216,66 @@ class RetryChatModel: raise last_error raise RuntimeError("RetryChatModel: Unexpected state, no error but no result") + async def _call_with_retry_async(self, func: Callable[..., T], *args, **kwargs) -> T: + """Call an async function with retry logic for transient errors.""" + last_error: Optional[Exception] = None + + for attempt in range(1, self._max_retries + 1): + try: + result = await func(*args, **kwargs) + + if hasattr(result, "usage") and result.usage: + usage = result.usage + self._total_tokens_used += _usage_total_tokens(usage) + self._total_cost += float(_usage_value(usage, "cost", 0.0) or 0.0) + + return result + + except Exception as e: + last_error = e + + if attempt >= self._max_retries: + logger.error( + "RetryChatModel: Max retries (%d) exhausted for %s", + self._max_retries, + self.model_name, + ) + break + + if not self._is_transient_error(e): + logger.warning( + "RetryChatModel: Non-transient error, not retrying: %s", + str(e), + ) + break + + delay = self._calculate_delay(attempt) + logger.warning( + "RetryChatModel: Transient async error on attempt %d/%d, " + "retrying in %.1fs: %s", + attempt, + self._max_retries, + delay, + str(e)[:200], + ) + + if self._on_retry: + self._on_retry(attempt, e, delay) + + await asyncio.sleep(delay) + + if last_error is not None: + raise last_error + raise RuntimeError("RetryChatModel: Unexpected async state, no error but no result") + def __call__(self, *args, **kwargs) -> Any: """Forward calls to the wrapped model with retry logic.""" - return self._call_with_retry(self._model, *args, **kwargs) + model_call = getattr(self._model, "__call__", None) + if inspect.iscoroutinefunction(self._model) or inspect.iscoroutinefunction(model_call): + return self._call_with_retry_async(self._model, *args, **kwargs) + + result = self._model(*args, **kwargs) + return result def __getattr__(self, name: str) -> Any: """Proxy attribute access to the wrapped model.""" @@ -248,10 +329,18 @@ class TokenRecordingModelWrapper: if usage is None: return - self._prompt_tokens += getattr(usage, "prompt_tokens", 0) - self._completion_tokens += getattr(usage, "completion_tokens", 0) - self._total_tokens += getattr(usage, "total_tokens", 0) - self._total_cost += getattr(usage, "cost", 0.0) + prompt_tokens = _usage_value(usage, "prompt_tokens", None) + completion_tokens = _usage_value(usage, "completion_tokens", None) + + if prompt_tokens is None: + prompt_tokens = _usage_value(usage, "input_tokens", 0) + if completion_tokens is None: + completion_tokens = _usage_value(usage, "output_tokens", 0) + + self._prompt_tokens += int(prompt_tokens or 0) + self._completion_tokens += int(completion_tokens or 0) + self._total_tokens += _usage_total_tokens(usage) + self._total_cost += float(_usage_value(usage, "cost", 0.0) or 0.0) def __call__(self, *args, **kwargs) -> Any: """Forward calls and record usage.""" @@ -401,7 +490,8 @@ def create_model( if host: model_kwargs["host"] = host - return model_class(**model_kwargs) + model = model_class(**model_kwargs) + return RetryChatModel(model) def get_agent_model(agent_id: str, stream: bool = False): diff --git a/backend/services/market.py b/backend/services/market.py index f523808..2749f76 100644 --- a/backend/services/market.py +++ b/backend/services/market.py @@ -10,7 +10,7 @@ from typing import Any, Callable, Dict, List, Optional from zoneinfo import ZoneInfo import pandas_market_calendars as mcal -from backend.config.data_config import get_data_source +from backend.config.data_config import get_data_sources from backend.data.provider_utils import normalize_symbol logger = logging.getLogger(__name__) @@ -142,9 +142,7 @@ class MarketService: def _start_real_mode(self): from backend.data.polling_price_manager import PollingPriceManager - provider = get_data_source() - if provider == "local_csv": - provider = "yfinance" + provider = self._resolve_live_quote_provider() if provider == "finnhub" and not self.api_key: raise ValueError("API key required for live mode") @@ -157,6 +155,13 @@ class MarketService: self._price_manager.subscribe(self.tickers) self._price_manager.start() + def _resolve_live_quote_provider(self) -> str: + """Pick the first configured provider that supports live quote polling.""" + for provider in get_data_sources(): + if provider in {"finnhub", "yfinance"}: + return provider + return "yfinance" + def _start_backtest_mode(self): from backend.data.historical_price_manager import ( HistoricalPriceManager, diff --git a/backend/tests/test_market_service.py b/backend/tests/test_market_service.py index 5742989..e376f54 100644 --- a/backend/tests/test_market_service.py +++ b/backend/tests/test_market_service.py @@ -2,11 +2,13 @@ # pylint: disable=W0212 import asyncio import time +import logging from unittest.mock import MagicMock, AsyncMock, patch import pytest from backend.services.market import MarketService from backend.data.mock_price_manager import MockPriceManager from backend.data.polling_price_manager import PollingPriceManager +from backend.llm.models import RetryChatModel class TestMockPriceManager: @@ -231,6 +233,59 @@ class TestPollingPriceManager: assert len(manager.open_prices) == 0 + def test_fetch_prices_suppresses_repeated_failures(self, caplog): + manager = PollingPriceManager(provider="yfinance", poll_interval=10) + manager.subscribe(["AAPL"]) + + with patch.object(manager, "_fetch_quote", side_effect=ValueError("empty quote")): + with caplog.at_level(logging.DEBUG): + for _ in range(3): + manager._fetch_prices() + + assert manager._failure_counts["AAPL"] == 3 + warning_messages = [record.message for record in caplog.records if record.levelno >= logging.WARNING] + assert any("Failed to fetch AAPL price: empty quote" in message for message in warning_messages) + + def test_fetch_prices_logs_recovery_after_failure(self, caplog): + manager = PollingPriceManager(provider="yfinance", poll_interval=10) + manager.subscribe(["AAPL"]) + + with patch.object( + manager, + "_fetch_quote", + side_effect=[ + ValueError("temporary outage"), + {"c": 100.0, "o": 99.0, "h": 101.0, "l": 98.0, "pc": 99.5, "d": 0.5, "dp": 0.5, "t": 1}, + ], + ): + with caplog.at_level(logging.INFO): + manager._fetch_prices() + manager._fetch_prices() + + assert "AAPL" not in manager._failure_counts + assert any("recovered after 1 consecutive failures" in record.message for record in caplog.records) + + +class TestRetryChatModel: + @pytest.mark.asyncio + async def test_async_retry_recovers_from_disconnect(self): + attempts = {"count": 0} + + class FakeAsyncModel: + model_name = "fake-async-model" + + async def __call__(self, *args, **kwargs): + attempts["count"] += 1 + if attempts["count"] < 2: + raise RuntimeError("Server disconnected") + return {"ok": True} + + wrapped = RetryChatModel(FakeAsyncModel(), max_retries=2, initial_delay=0.01) + result = await wrapped("hello") + + assert result == {"ok": True} + assert attempts["count"] == 2 + class TestMarketService: def test_init_mock_mode(self): @@ -255,9 +310,23 @@ class TestMarketService: assert service.mock_mode is False assert service.api_key == "test_key" - @patch("backend.services.market.get_data_source", return_value="yfinance") + @patch("backend.services.market.get_data_sources", return_value=["yfinance", "local_csv"]) @patch.object(PollingPriceManager, "start") - def test_start_real_mode_with_yfinance(self, _mock_start, _mock_source): + def test_start_real_mode_with_yfinance(self, _mock_start, _mock_sources): + service = MarketService( + tickers=["AAPL"], + poll_interval=10, + mock_mode=False, + ) + + service._start_real_mode() + + assert isinstance(service._price_manager, PollingPriceManager) + assert service._price_manager.provider == "yfinance" + + @patch("backend.services.market.get_data_sources", return_value=["financial_datasets", "yfinance", "local_csv"]) + @patch.object(PollingPriceManager, "start") + def test_start_real_mode_uses_first_supported_live_provider(self, _mock_start, _mock_sources): service = MarketService( tickers=["AAPL"], poll_interval=10, @@ -287,9 +356,9 @@ class TestMarketService: service.stop() - @patch("backend.services.market.get_data_source", return_value="finnhub") + @patch("backend.services.market.get_data_sources", return_value=["finnhub", "yfinance"]) @pytest.mark.asyncio - async def test_start_real_mode_without_api_key(self, _mock_source): + async def test_start_real_mode_without_api_key(self, _mock_sources): service = MarketService( tickers=["AAPL"], mock_mode=False, diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 766eb3a..8040b9c 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,283 +1,201 @@ -import React, { Suspense, lazy, useEffect, useMemo, useRef, useState, useCallback } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -// Configuration and constants -import { AGENTS, INITIAL_TICKERS } from './config/constants'; - -// Services -import { ReadOnlyClient } from './services/websocket'; -import { startRuntime, uploadAgentSkillZip } from './services/runtimeApi'; -import { - fetchNewsCategoriesDirect, - fetchNewsForDateDirect, - fetchRangeExplainDirect, - fetchSimilarDaysDirect, - fetchStockStoryDirect, - hasDirectNewsService -} from './services/newsApi'; -import { - fetchInsiderTradesDirect, - fetchStockHistoryDirect, - hasDirectTradingService -} from './services/tradingApi'; - -// Hooks +import AppShell from './components/AppShell.jsx'; +import RuntimeLogsModal from './components/RuntimeLogsModal.jsx'; +import { AGENTS } from './config/constants'; +import { useAgentDataRequests } from './hooks/useAgentDataRequests'; import { useFeedProcessor } from './hooks/useFeedProcessor'; -import { useRuntimeStore } from './store/runtimeStore'; +import { useRuntimeControls } from './hooks/useRuntimeControls'; +import { useStockDataRequests } from './hooks/useStockDataRequests'; +import { useWebSocketConnection } from './hooks/useWebSocketConnection'; +import { fetchRuntimeLogs } from './services/runtimeApi'; +import { useAgentStore } from './store/agentStore'; import { useMarketStore } from './store/marketStore'; import { usePortfolioStore } from './store/portfolioStore'; -import { useAgentStore } from './store/agentStore'; +import { useRuntimeStore } from './store/runtimeStore'; import { useUIStore } from './store/uiStore'; -// Styles -import GlobalStyles from './styles/GlobalStyles'; - -// Components -import NetValueChart from './components/NetValueChart'; -import StockLogo from './components/StockLogo'; -import Header from './components/Header.jsx'; -import RuntimeSettingsPanel from './components/RuntimeSettingsPanel.jsx'; - -// Utils -import { formatNumber, formatTickerPrice } from './utils/formatters'; - -const RoomView = lazy(() => import('./components/RoomView')); -const AgentFeed = lazy(() => import('./components/AgentFeed')); -const StatisticsView = lazy(() => import('./components/StatisticsView')); -const StockExplainView = lazy(() => import('./components/StockExplainView.jsx')); -const TraderView = lazy(() => import('./components/TraderView.jsx')); -const EDITABLE_AGENT_WORKSPACE_FILES = ['SOUL.md', 'PROFILE.md', 'AGENTS.md', 'MEMORY.md', 'POLICY.md', 'HEARTBEAT.md', 'ROLE.md', 'STYLE.md']; - -function ViewLoadingFallback({ label = '加载中...' }) { - return ( -
- {label} -
- ); -} - -/** - * Live Trading Intelligence Platform - Read-Only Dashboard - * Geek Style - Terminal-inspired, minimal, monochrome - * - */ +const EDITABLE_AGENT_WORKSPACE_FILES = [ + 'SOUL.md', + 'PROFILE.md', + 'AGENTS.md', + 'MEMORY.md', + 'POLICY.md', + 'HEARTBEAT.md', + 'ROLE.md', + 'STYLE.md' +]; export default function LiveTradingApp() { - // Connection & system state - from runtimeStore - const { isConnected, setIsConnected, connectionStatus, setConnectionStatus, systemStatus, setSystemStatus, currentDate, setCurrentDate, progress, setProgress } = useRuntimeStore(); - - // View toggle: 'traders' | 'room' | 'explain' | 'chart' | 'statistics' | 'runtime' - const { currentView, setCurrentView, chartTab, setChartTab, isInitialAnimating, setIsInitialAnimating, lastUpdate, setLastUpdate, isUpdating, setIsUpdating, now, setNow } = useUIStore(); - - // Chart data - from portfolioStore - const { portfolioData, setPortfolioData, holdings, setHoldings, trades, setTrades, stats, setStats, leaderboard, setLeaderboard } = usePortfolioStore(); - - // Feed data (using hook for simplified processing) - const { feed, processHistoricalFeed, processFeedEvent, addSystemMessage } = useFeedProcessor(); - - // Ticker prices - keep local state with INITIAL_TICKERS - const [tickers, setTickers] = useState(INITIAL_TICKERS); - const { rollingTickers, setRollingTickers, priceHistoryByTicker, setPriceHistoryByTicker, ohlcHistoryByTicker, setOhlcHistoryByTicker, explainEventsByTicker, setExplainEventsByTicker, newsByTicker, setNewsByTicker, insiderTradesByTicker, setInsiderTradesByTicker, technicalIndicatorsByTicker, setTechnicalIndicatorsByTicker, selectedExplainSymbol, setSelectedExplainSymbol, historySourceByTicker, setHistorySourceByTicker } = useMarketStore(); - - // Room bubbles - from uiStore - const { bubbles, setBubbles, leftWidth, setLeftWidth, isResizing, setIsResizing } = useUIStore(); - - // Market status & runtime config - from runtimeStore const { - serverMode, setServerMode, - marketStatus, setMarketStatus, - virtualTime, setVirtualTime, - dataSources, setDataSources, - 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, - lastDayHistory, setLastDayHistory + isConnected, + connectionStatus, + serverMode, + marketStatus, + virtualTime, + dataSources, + currentDate, + runtimeConfig, } = useRuntimeStore(); - // Agent state - from agentStore const { - selectedSkillAgentId, setSelectedSkillAgentId, - agentProfilesByAgent, setAgentProfilesByAgent, - agentSkillsByAgent, setAgentSkillsByAgent, - skillDetailsByName, setSkillDetailsByName, - localSkillDraftsByKey, setLocalSkillDraftsByKey, - isAgentSkillsLoading, setIsAgentSkillsLoading, - skillDetailLoadingKey, setSkillDetailLoadingKey, - agentSkillsSavingKey, setAgentSkillsSavingKey, - agentSkillsFeedback, setAgentSkillsFeedback, - selectedWorkspaceFile, setSelectedWorkspaceFile, - workspaceFilesByAgent, setWorkspaceFilesByAgent, - workspaceDraftContent, setWorkspaceDraftContent, - isWorkspaceFileLoading, setIsWorkspaceFileLoading, - workspaceFileSavingKey, setWorkspaceFileSavingKey, - workspaceFileFeedback, setWorkspaceFileFeedback + currentView, + chartTab, + isInitialAnimating, + lastUpdate, + isUpdating, + now, + setNow, + setLastUpdate, + setIsUpdating, + leftWidth, + isResizing, + bubbles, + } = useUIStore(); + + const { + tickers, + rollingTickers, + priceHistoryByTicker, + ohlcHistoryByTicker, + explainEventsByTicker, + newsByTicker, + insiderTradesByTicker, + technicalIndicatorsByTicker, + selectedExplainSymbol, + historySourceByTicker, + setSelectedExplainSymbol, + } = useMarketStore(); + + const { + portfolioData, + holdings, + trades, + stats, + leaderboard, + } = usePortfolioStore(); + + const { + selectedSkillAgentId, + agentProfilesByAgent, + agentSkillsByAgent, + skillDetailsByName, + localSkillDraftsByKey, + isAgentSkillsLoading, + skillDetailLoadingKey, + agentSkillsSavingKey, + agentSkillsFeedback, + selectedWorkspaceFile, + workspaceFilesByAgent, + workspaceDraftContent, + isWorkspaceFileLoading, + workspaceFileSavingKey, + workspaceFileFeedback, + setSelectedWorkspaceFile, + setSelectedSkillAgentId, + setWorkspaceDraftContent, } = useAgentStore(); - const clientRef = useRef(null); - const containerRef = useRef(null); + const { feed, processHistoricalFeed, processFeedEvent, addSystemMessage } = useFeedProcessor(); + const { + clientRef, + setRequestStockHistory, + setRequestStockNewsTimeline, + setRequestStockNewsCategories, + } = useWebSocketConnection({ + processHistoricalFeed, + processFeedEvent, + addSystemMessage, + }); + + const runtimeControls = useRuntimeControls({ + clientRef, + currentTickers: tickers, + addSystemMessage, + }); + + const stockRequests = useStockDataRequests(clientRef, { + setRequestStockHistory, + setRequestStockNewsTimeline, + setRequestStockNewsCategories, + }); + const { + requestAgentSkills, + requestAgentProfile, + requestSkillDetail, + handleCreateLocalSkill, + handleLocalSkillDraftChange, + handleLocalSkillSave, + handleLocalSkillDelete, + handleRemoveSharedSkill, + handleAgentSkillToggle, + handleSkillAgentChange, + requestWorkspaceFile, + handleWorkspaceFileChange, + handleWorkspaceFileSave, + handleUploadExternalSkill, + } = useAgentDataRequests(clientRef); + + const [isRuntimeLogsOpen, setIsRuntimeLogsOpen] = useState(false); + const [isRuntimeLogsLoading, setIsRuntimeLogsLoading] = useState(false); + const [runtimeLogsPayload, setRuntimeLogsPayload] = useState(null); + const [runtimeLogsError, setRuntimeLogsError] = useState(null); const agentFeedRef = useRef(null); - const isWatchlistSavingRef = useRef(false); - const isRuntimeConfigSavingRef = useRef(false); - const requestedStockHistoryRef = useRef(new Set()); + const isSocketReady = isConnected && connectionStatus === 'connected'; - // Track last virtual time update to calculate increment - const lastVirtualTimeRef = useRef(null); - const virtualTimeOffsetRef = useRef(0); - - const buildTickersFromSymbols = useCallback((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 - }; - }); - }, []); - - const normalizePriceHistory = useCallback((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; - }, []); - - // Determine if LIVE tab should be enabled - const isLiveEnabled = useMemo(() => { - if (!marketStatus) return false; - return marketStatus.status === 'open'; - }, [marketStatus]); - - const displayTickers = useMemo(() => { - const symbols = runtimeConfig?.tickers; - if (Array.isArray(symbols) && symbols.length > 0) { - return buildTickersFromSymbols(symbols, tickers); - } - return tickers; - }, [buildTickersFromSymbols, runtimeConfig, tickers]); - - const runtimeWatchlistSymbols = useMemo(() => { - const symbols = runtimeConfig?.tickers; - if (Array.isArray(symbols) && symbols.length > 0) { - return symbols - .filter((symbol) => typeof symbol === 'string' && symbol.trim()) - .map((symbol) => symbol.trim().toUpperCase()); - } - - return displayTickers - .map((ticker) => ticker.symbol) - .filter((symbol) => typeof symbol === 'string' && symbol.trim()); - }, [displayTickers, runtimeConfig]); - - const runtimeSummaryLabel = useMemo(() => { - if (!runtimeConfig) { - return null; - } - - const scheduleMode = String(runtimeConfig.schedule_mode || 'daily'); - const intervalMinutes = Number(runtimeConfig.interval_minutes || 60); - const triggerTime = String(runtimeConfig.trigger_time || '09:30'); - const maxCommCycles = Number(runtimeConfig.max_comm_cycles || 2); - - if (scheduleMode === 'intraday') { - return `调度 intraday / ${intervalMinutes}m / 讨论 ${maxCommCycles} 轮`; - } - - return `调度 daily / ${triggerTime} ET / 讨论 ${maxCommCycles} 轮`; - }, [runtimeConfig]); - - const selectedAgentSkills = useMemo( - () => agentSkillsByAgent[selectedSkillAgentId] || [], - [agentSkillsByAgent, selectedSkillAgentId] - ); - - const selectedAgentProfile = useMemo( - () => agentProfilesByAgent[selectedSkillAgentId] || null, - [agentProfilesByAgent, selectedSkillAgentId] - ); - - const selectedWorkspaceContent = useMemo( - () => workspaceFilesByAgent[selectedSkillAgentId]?.[selectedWorkspaceFile] || '', - [selectedSkillAgentId, selectedWorkspaceFile, workspaceFilesByAgent] - ); + const selectedAgentId = selectedSkillAgentId || AGENTS[0]?.id || null; + const selectedAgentProfile = selectedAgentId ? (agentProfilesByAgent[selectedAgentId] || null) : null; + const selectedAgentSkills = selectedAgentId ? (agentSkillsByAgent[selectedAgentId] || []) : []; + const selectedWorkspaceContent = selectedAgentId && selectedWorkspaceFile + ? (workspaceFilesByAgent[selectedAgentId]?.[selectedWorkspaceFile] || '') + : ''; useEffect(() => { - const symbols = displayTickers + if (!selectedSkillAgentId && AGENTS.length > 0) { + setSelectedSkillAgentId(AGENTS[0].id); + } + }, [selectedSkillAgentId, setSelectedSkillAgentId]); + + useEffect(() => { + if (!selectedWorkspaceFile) { + setSelectedWorkspaceFile('MEMORY.md'); + } + }, [selectedWorkspaceFile, setSelectedWorkspaceFile]); + + useEffect(() => { + if (!isSocketReady || !selectedAgentId || !clientRef.current) { + return; + } + + if (!agentProfilesByAgent[selectedAgentId]) { + requestAgentProfile(selectedAgentId); + } + + if (!Array.isArray(agentSkillsByAgent[selectedAgentId])) { + requestAgentSkills(selectedAgentId); + } + + if ( + selectedWorkspaceFile + && workspaceFilesByAgent[selectedAgentId]?.[selectedWorkspaceFile] === undefined + ) { + requestWorkspaceFile(selectedAgentId, selectedWorkspaceFile); + } + }, [ + agentProfilesByAgent, + agentSkillsByAgent, + clientRef, + isSocketReady, + requestAgentProfile, + requestAgentSkills, + requestWorkspaceFile, + selectedAgentId, + selectedWorkspaceFile, + workspaceFilesByAgent, + ]); + + useEffect(() => { + const symbols = runtimeControls.displayTickers .map((ticker) => ticker.symbol) .filter((symbol) => typeof symbol === 'string' && symbol.trim()); @@ -289,44 +207,28 @@ export default function LiveTradingApp() { if (!selectedExplainSymbol || !symbols.includes(selectedExplainSymbol)) { setSelectedExplainSymbol(symbols[0]); } - }, [displayTickers, selectedExplainSymbol]); + }, [runtimeControls.displayTickers, selectedExplainSymbol, setSelectedExplainSymbol]); useEffect(() => { - if (!runtimeConfig) { - return; + if (virtualTime) { + setNow(new Date(virtualTime)); + const id = setInterval(() => setNow(new Date()), 1000); + return () => clearInterval(id); } - setScheduleModeDraft(String(runtimeConfig.schedule_mode || 'daily')); - setIntervalMinutesDraft(String(runtimeConfig.interval_minutes || 60)); - setTriggerTimeDraft(String(runtimeConfig.trigger_time || '09:30')); - setMaxCommCyclesDraft(String(runtimeConfig.max_comm_cycles || 2)); - setInitialCashDraft(String(runtimeConfig.initial_cash ?? 100000)); - setMarginRequirementDraft(String(runtimeConfig.margin_requirement ?? 0)); - setEnableMemoryDraft(Boolean(runtimeConfig.enable_memory ?? false)); - }, [runtimeConfig]); + const id = setInterval(() => setNow(new Date()), 1000); + return () => clearInterval(id); + }, [setNow, virtualTime]); - 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(() => { + setLastUpdate(new Date()); + setIsUpdating(true); + const timer = setTimeout(() => setIsUpdating(false), 500); + return () => clearTimeout(timer); + }, [holdings, stats, trades, portfolioData.netValue, setIsUpdating, setLastUpdate]); const marketStatusLabel = useMemo(() => { - if (!marketStatus) { - return null; - } - + if (!marketStatus) return null; const raw = typeof marketStatus.status_text === 'string' ? marketStatus.status_text.trim() : ''; const normalized = raw.toLowerCase(); const byStatus = { @@ -344,2732 +246,201 @@ export default function LiveTradingApp() { 'after hours': '盘后', 'backtest mode': '回测模式', }; - if (normalized && byText[normalized]) { - return byText[normalized]; - } - if (marketStatus.status && byStatus[marketStatus.status]) { - return byStatus[marketStatus.status]; - } + if (normalized && byText[normalized]) return byText[normalized]; + if (marketStatus.status && byStatus[marketStatus.status]) return byStatus[marketStatus.status]; return raw || '状态未知'; }, [marketStatus]); const providerLabelMap = useMemo(() => ({ - yfinance: 'YFinance', - finnhub: 'Finnhub', - financial_datasets: 'Financial Datasets', + yfinance: 'YFINANCE', + finnhub: 'FINNHUB', + financial_datasets: 'FINANCIAL DATASETS', local_csv: 'CSV', - polygon: 'Polygon', - mock: 'Mock', - backtest: 'Backtest' + polygon: 'POLYGON', + mock: 'MOCK', + backtest: 'BACKTEST', }), []); - const livePriceSourceLabel = useMemo(() => { - const source = marketStatus?.live_quote_provider; - if (!source) { - return null; - } - + const dataSourceLabel = useMemo(() => { + const source = dataSources?.last_success?.prices + || marketStatus?.live_quote_provider + || (Array.isArray(dataSources?.preferred) ? dataSources.preferred[0] : null); + if (!source) return null; const normalized = String(source).trim().toLowerCase(); - return `实时 ${providerLabelMap[normalized] || String(source).trim()}`; - }, [marketStatus, providerLabelMap]); + return `数据源 ${providerLabelMap[normalized] || String(source).trim()}`; + }, [dataSources, marketStatus, providerLabelMap]); - const historicalPriceSourceLabel = useMemo(() => { - const source = dataSources?.last_success?.prices; - if (!source) { - return null; + const bubbleFor = useCallback((idOrName) => { + let bubble = bubbles[idOrName]; + if (bubble) return bubble; + const agent = AGENTS.find((item) => item.name === idOrName || item.id === idOrName); + if (agent) { + bubble = bubbles[agent.id]; + if (bubble) return bubble; } - - const normalized = String(source).trim().toLowerCase(); - return `历史 ${providerLabelMap[normalized] || String(source).trim()}`; - }, [dataSources, providerLabelMap]); - - const parseWatchlistInput = useCallback((value) => { - if (typeof value !== 'string') { - return []; - } - - return Array.from( - new Set( - value - .split(/[\s,]+/) - .map((symbol) => symbol.trim().toUpperCase()) - .filter(Boolean) - ) - ); - }, []); - - 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; - }, [parseWatchlistInput, watchlistFeedback]); - - const handleWatchlistRemove = useCallback((symbolToRemove) => { - setWatchlistDraftSymbols((prev) => prev.filter((symbol) => symbol !== symbolToRemove)); - if (watchlistFeedback) { - setWatchlistFeedback(null); - } - }, [watchlistFeedback]); - - const handleWatchlistPanelToggle = useCallback(() => { - setIsRuntimeSettingsOpen(false); - setIsWatchlistPanelOpen((open) => { - const nextOpen = !open; - if (nextOpen) { - setWatchlistDraftSymbols(runtimeWatchlistSymbols); - setWatchlistInputValue(''); - setWatchlistFeedback(null); - } - return nextOpen; - }); - }, [runtimeWatchlistSymbols]); - - const handleWatchlistInputChange = useCallback((value) => { - setWatchlistInputValue(value); - if (watchlistFeedback) { - setWatchlistFeedback(null); - } - }, [watchlistFeedback]); - - const handleWatchlistInputKeyDown = useCallback((e) => { - if (e.key === 'Enter' || e.key === ',') { - e.preventDefault(); - commitWatchlistInput(watchlistInputValue); - } - }, [commitWatchlistInput, watchlistInputValue]); - - const handleWatchlistSuggestionClick = useCallback((symbol) => { - if (watchlistDraftSymbols.includes(symbol)) { - return; - } - setWatchlistDraftSymbols((prev) => [...prev, symbol]); - if (watchlistFeedback) { - setWatchlistFeedback(null); - } - }, [watchlistDraftSymbols, watchlistFeedback]); - - const handleWatchlistRestoreCurrent = useCallback(() => { - setWatchlistDraftSymbols(runtimeWatchlistSymbols); - setWatchlistInputValue(''); - setWatchlistFeedback(null); - }, [runtimeWatchlistSymbols]); - - const handleWatchlistRestoreDefault = useCallback(() => { - setWatchlistDraftSymbols(watchlistSuggestions); - setWatchlistInputValue(''); - setWatchlistFeedback(null); - }, [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: '发送失败,请检查连接状态' }); - } - }, [parseWatchlistInput, watchlistDraftSymbols, watchlistInputValue]); + return null; + }, [bubbles]); const handleManualTrigger = useCallback(() => { - if (!clientRef.current) { + if (!isSocketReady || !clientRef.current) { addSystemMessage('连接未就绪,无法手动触发'); return; } - - const success = clientRef.current.send({ - type: 'trigger_strategy' - }); - + const success = clientRef.current.send({ type: 'trigger_strategy' }); if (!success) { addSystemMessage('手动触发发送失败,请检查连接状态'); return; } - addSystemMessage('已发送手动触发请求'); - }, [addSystemMessage]); - - 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: '发送失败,请检查连接状态' }); - } - }, [enableMemoryDraft, initialCashDraft, intervalMinutesDraft, marginRequirementDraft, maxCommCyclesDraft, scheduleModeDraft, 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(''); + }, [addSystemMessage, clientRef, isSocketReady]); + const loadRuntimeLogs = useCallback(async () => { + setIsRuntimeLogsLoading(true); + setRuntimeLogsError(null); try { - // Call API to start new runtime with timestamp-based run directory - 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 || 'live', - poll_interval: Number(pollIntervalDraft) || 10, - 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}`); + const payload = await fetchRuntimeLogs(); + setRuntimeLogsPayload(payload); } catch (error) { - setIsRuntimeConfigSaving(false); - setIsWatchlistSaving(false); - setRuntimeConfigFeedback({ - type: 'error', - text: `启动失败: ${error.message}` - }); - } - }, [ - intervalMinutesDraft, - maxCommCyclesDraft, - parseWatchlistInput, - scheduleModeDraft, - triggerTimeDraft, - initialCashDraft, - marginRequirementDraft, - enableMemoryDraft, - modeDraft, - pollIntervalDraft, - startDateDraft, - endDateDraft, - enableMockDraft, - watchlistDraftSymbols, - watchlistInputValue, - addSystemMessage - ]); - - const handleRuntimeDefaultsRestore = useCallback(() => { - setScheduleModeDraft('daily'); - setIntervalMinutesDraft('60'); - setTriggerTimeDraft('09:30'); - setMaxCommCyclesDraft('2'); - setInitialCashDraft('100000'); - setMarginRequirementDraft('0'); - setEnableMemoryDraft(false); - setModeDraft('live'); - setPollIntervalDraft('10'); - setStartDateDraft(''); - setEndDateDraft(''); - setEnableMockDraft(false); - setRuntimeConfigFeedback(null); - }, []); - - const handleRuntimeSettingsToggle = useCallback(() => { - setRuntimeConfigFeedback(null); - setAgentSkillsFeedback(null); - setWorkspaceFileFeedback(null); - setIsRuntimeSettingsOpen((prev) => { - const nextOpen = !prev; - if (nextOpen) { - // Initialize watchlist draft when opening settings - setWatchlistDraftSymbols(runtimeWatchlistSymbols); - setWatchlistInputValue(''); - setWatchlistFeedback(null); - } - return nextOpen; - }); - setIsWatchlistPanelOpen(false); - }, [runtimeWatchlistSymbols]); - - 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 - }); - }, []); - - 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 - }); - }, []); - - 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 - }); - }, [selectedSkillAgentId]); - - 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: '发送失败,请检查连接状态' }); - } - }, [selectedSkillAgentId]); - - const handleLocalSkillDraftChange = useCallback((skillName, content) => { - const detailKey = `${selectedSkillAgentId}:${skillName}`; - 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: '发送失败,请检查连接状态' }); - } - }, [localSkillDraftsByKey, selectedSkillAgentId]); - - 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: '发送失败,请检查连接状态' }); - } - }, [selectedSkillAgentId]); - - 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: '发送失败,请检查连接状态' }); - } - }, [selectedSkillAgentId]); - - 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 - }); - }, []); - - 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: '发送失败,请检查连接状态' }); - } - }, [selectedSkillAgentId]); - - const handleSkillAgentChange = useCallback((agentId) => { - setSelectedSkillAgentId(agentId); - requestAgentProfile(agentId); - requestAgentSkills(agentId); - requestWorkspaceFile(agentId, selectedWorkspaceFile); - }, [requestAgentProfile, requestAgentSkills, requestWorkspaceFile, selectedWorkspaceFile]); - - const handleWorkspaceFileChange = useCallback((filename) => { - 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: '发送失败,请检查连接状态' }); - } - }, [selectedSkillAgentId, selectedWorkspaceFile, 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 || '未知错误'}` - }); + setRuntimeLogsError(error.message || '无法读取运行日志'); } finally { - setAgentSkillsSavingKey(null); + setIsRuntimeLogsLoading(false); } - }, [requestAgentSkills, selectedSkillAgentId]); - - useEffect(() => { - setWorkspaceDraftContent(selectedWorkspaceContent); - }, [selectedWorkspaceContent]); - - useEffect(() => { - if (currentView !== 'traders' || !isConnected) { - return; - } - 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'); - } - }); - }, [agentProfilesByAgent, agentSkillsByAgent, currentView, isConnected, requestAgentProfile, requestAgentSkills, requestWorkspaceFile, workspaceFilesByAgent]); - - 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; - }, [currentDate]); - - 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 - }); }, []); - 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 - }); - }, []); - - 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 - }); - }, []); - - 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 - }); - }, []); - - 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 - }); - }, [currentDate]); - - 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 - }); - }, []); - - 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 - }); - }, []); - - 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 : [] - }); - }, []); - - 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 - }); - }, []); - - 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 - }); - }, []); - - 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 - }); - }, []); - - // Switch away from LIVE tab when market closes - useEffect(() => { - if (!isLiveEnabled && chartTab === 'live') { - setChartTab('all'); - } - }, [isLiveEnabled, chartTab]); - - useEffect(() => { - // Only reset when watchlist panel is closed AND runtime settings is also closed - // This prevents reset when user is editing in RuntimeSettingsPanel - if ((!isWatchlistPanelOpen && !isRuntimeSettingsOpen) || !isWatchlistDraftDirty) { - setWatchlistDraftSymbols(runtimeWatchlistSymbols); - if (!isWatchlistPanelOpen && !isRuntimeSettingsOpen) { - setWatchlistInputValue(''); - } - } - }, [isWatchlistDraftDirty, isWatchlistPanelOpen, isRuntimeSettingsOpen, runtimeWatchlistSymbols]); - - useEffect(() => { - isWatchlistSavingRef.current = isWatchlistSaving; - }, [isWatchlistSaving]); - - useEffect(() => { - isRuntimeConfigSavingRef.current = isRuntimeConfigSaving; - }, [isRuntimeConfigSaving]); - - 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, - requestStockInsiderTrades, - requestStockTechnicalIndicators, - requestStockStory, - selectedExplainSymbol - ]); - - // Clock - use virtual time if available (for mock mode) - useEffect(() => { - if (virtualTime) { - // In mock mode, calculate offset from real time - const virtualTimeMs = new Date(virtualTime).getTime(); - const realTimeMs = Date.now(); - virtualTimeOffsetRef.current = virtualTimeMs - realTimeMs; - lastVirtualTimeRef.current = virtualTimeMs; - setNow(new Date(virtualTime)); - - // Update clock every second based on offset - const id = setInterval(() => { - const currentRealTime = Date.now(); - const currentVirtualTime = currentRealTime + virtualTimeOffsetRef.current; - setNow(new Date(currentVirtualTime)); - }, 1000); - - return () => clearInterval(id); - } else { - // In live mode, use real time - const id = setInterval(() => setNow(new Date()), 1000); - return () => clearInterval(id); - } - }, [virtualTime]); - - // Update clock when virtual time changes (recalculate offset) - useEffect(() => { - if (virtualTime) { - const virtualTimeMs = new Date(virtualTime).getTime(); - const realTimeMs = Date.now(); - virtualTimeOffsetRef.current = virtualTimeMs - realTimeMs; - lastVirtualTimeRef.current = virtualTimeMs; - setNow(new Date(virtualTime)); - } - }, [virtualTime]); - - // Track updates with visual feedback - useEffect(() => { - setLastUpdate(new Date()); - setIsUpdating(true); - const timer = setTimeout(() => setIsUpdating(false), 500); - return () => clearTimeout(timer); - }, [holdings, stats, trades, portfolioData.netValue]); - - // Initial animation flag for slider speed - useEffect(() => { - const completeTimer = setTimeout(() => { - setIsInitialAnimating(false); - }, 1800); - - return () => { - clearTimeout(completeTimer); - }; - }, []); - - - // Helper to check if bubble should still be visible - // Bubbles persist until replaced by ANY new message (cross-role) - // When any agent sends a new message, all previous bubbles are cleared - // Can search by agentId or agentName - const bubbleFor = (idOrName) => { - // First try direct lookup by id - let b = bubbles[idOrName]; - if (b) { - return b; - } - - // If not found, search by agentName - const agent = AGENTS.find(a => a.name === idOrName || a.id === idOrName); - if (agent) { - b = bubbles[agent.id]; - if (b) { - return b; - } - } - - return null; + const agentRequests = { + agents: AGENTS, + agentProfilesByAgent, + agentSkillsByAgent, + workspaceFilesByAgent, + selectedAgentId, + selectedAgentProfile, + selectedAgentSkills, + skillDetailsByName, + localSkillDraftsByKey, + skillDetailLoadingKey, + editableFiles: EDITABLE_AGENT_WORKSPACE_FILES, + selectedWorkspaceFile, + workspaceFileContent: selectedWorkspaceContent, + workspaceDraftContent, + isConnected, + isAgentSkillsLoading, + agentSkillsSavingKey, + agentSkillsFeedback, + isWorkspaceFileLoading, + workspaceFileSavingKey, + workspaceFileFeedback, + onAgentChange: handleSkillAgentChange, + onCreateLocalSkill: handleCreateLocalSkill, + onSkillDetailRequest: requestSkillDetail, + onLocalSkillDraftChange: handleLocalSkillDraftChange, + onLocalSkillDelete: handleLocalSkillDelete, + onLocalSkillSave: handleLocalSkillSave, + onRemoveSharedSkill: handleRemoveSharedSkill, + onSkillToggle: handleAgentSkillToggle, + onWorkspaceFileChange: handleWorkspaceFileChange, + onWorkspaceDraftChange: setWorkspaceDraftContent, + onWorkspaceFileSave: handleWorkspaceFileSave, + onUploadExternalSkill: handleUploadExternalSkill, + clientRef, }; - // Handle jump to message in feed - const handleJumpToMessage = useCallback((bubble) => { - // Switch to room tab (if not already there) for better context - // Then scroll AgentFeed to the message - if (agentFeedRef.current && agentFeedRef.current.scrollToMessage) { - agentFeedRef.current.scrollToMessage(bubble); - } - }, []); - - // Auto-connect to server on mount - useEffect(() => { - // Define pushEvent inside useEffect to avoid dependency issues - 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); - } - - // Helper: Update tickers from realtime prices - const updateTickersFromPrices = (realtimePrices) => { - try { - setTickers(prevTickers => { - return prevTickers.map(ticker => { - const realtimeData = realtimePrices[ticker.symbol]; - if (realtimeData && realtimeData.price !== null && realtimeData.price !== undefined) { - // Use 'ret' from realtime data (relative to open price) if available - 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); - } - }; - - const handlers = { - // Error response (for fast forward errors) - 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 || '更新工作区文件失败' }); - } - - // Handle fast forward errors - if (message.includes('fast forward')) { - console.warn(`⚠️ ${message}`); - handlePushEvent({ - type: 'system', - content: `⚠️ ${message}`, - timestamp: Date.now() - }); - } - addSystemMessage(message); - }, - - // Connection events - 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 response from server - pong: (e) => { - console.log('[Heartbeat] Pong received'); - }, - - // Initial state from server - 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)); - } - // 检查是否是mock模式 - const isMockMode = state.is_mock_mode === true; - if (state.market_status) { - setMarketStatus(state.market_status); - // 只在Mock模式下,如果市场状态包含虚拟时间,才保存它 - if (isMockMode && state.market_status.current_time) { - try { - const virtualTimeDate = new Date(state.market_status.current_time); - setVirtualTime(virtualTimeDate); - } catch (error) { - console.error('Error parsing virtual time from market_status:', error); - } - } else { - // 非Mock模式下清除virtualTime - 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)); - } - - // Load and process historical feed data - if (state.feed_history && Array.isArray(state.feed_history)) { - console.log(`✅ Loading ${state.feed_history.length} historical events`); - processHistoricalFeed(state.feed_history); - } - - // Load last day history for replay - 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 - 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)); - setWatchlistDraftSymbols(e.runtime_config_applied.tickers.map((symbol) => String(symbol).trim().toUpperCase())); - 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() : selectedSkillAgentId; - 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()); - requestedStockHistoryRef.current = new Set( - Array.from(requestedStockHistoryRef.current).filter((symbol) => normalizedTickers.includes(symbol)) - ); - setRuntimeConfig((prev) => ({ - ...(prev || {}), - tickers: normalizedTickers - })); - setTickers((prevTickers) => buildTickersFromSymbols(normalizedTickers, prevTickers)); - setWatchlistDraftSymbols(normalizedTickers); - setWatchlistInputValue(''); - } - 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 - } - })); - requestStockNewsTimeline(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) { - requestStockNews(symbol); - requestStockNewsTimeline(symbol); - requestStockNewsCategories(symbol); - } - }, - - // Real-time price updates - 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: now.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) - }; - }); - - // Update ticker price with animation - setTickers(prevTickers => { - return prevTickers.map(ticker => { - if (ticker.symbol === symbol) { - const oldPrice = ticker.price; - - // Use 'ret' from server (relative to open price) if available - // Otherwise fallback to calculating change from previous price - let newChange = ticker.change; - if (ret !== null && ret !== undefined) { - // Use server-provided ret (relative to open price) - newChange = ret; - } else if (oldPrice !== null && oldPrice !== undefined && isFinite(oldPrice)) { - // Fallback: calculate change from previous price - const priceChange = ((price - oldPrice) / oldPrice) * 100; - newChange = (newChange !== null && newChange !== undefined) - ? newChange + priceChange - : priceChange; - } else { - // First price received, set change to 0 - newChange = 0; - } - - // Trigger rolling animation only if price actually changed - if (oldPrice !== price) { - setRollingTickers(prev => ({ ...prev, [symbol]: true })); - setTimeout(() => { - setRollingTickers(prev => ({ ...prev, [symbol]: false })); - }, 500); - } - - return { - ...ticker, - price: price, - change: newChange, - open: open || ticker.open // Store open price - }; - } - return ticker; - }); - }); - - // Update all tickers from realtime_prices if provided - if (realtime_prices) { - updateTickersFromPrices(realtime_prices); - } - - // Update portfolio value if provided - if (portfolio && portfolio.total_value) { - setPortfolioData(prev => ({ - ...prev, - netValue: portfolio.total_value, - pnl: portfolio.pnl_percent || 0, - equity: portfolio.equity || prev.equity // Update equity curve - })); - } - } catch (error) { - console.error('[Price Update] Error:', error); - } - }, - - // Day progress events - 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) => { - // Update from day result - const result = e.result; - if (result && typeof result === 'object') { - // Update portfolio equity if available - if (result.portfolio_summary) { - const summary = result.portfolio_summary; - setPortfolioData(prev => { - const newEquity = [...prev.equity]; - // Add new data point - 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(a => a.id === e.agentId); - - // Update bubbles for room view - 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(a => a.id === e.agentId); - - // Update bubbles for room view - 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) => { - // Update portfolio data silently without creating feed messages - 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 - })); - - // Portfolio updates are shown in the ticker bar, no need for feed messages - }, - - team_portfolio: (e) => { - if (e.holdings) setHoldings(e.holdings); - }, - - // ✅ 监听 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); - console.log(`✅ Trades updated (full): ${e.data.length} trades`); - } 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); - console.log('✅ Stats updated'); - } else if (e.stats) { - setStats(e.stats); - } - }, - - team_leaderboard: (e) => { - // 服务器发送的格式: { type: 'team_leaderboard', data: [...], timestamp: ... } - if (Array.isArray(e.data)) { - setLeaderboard(e.data); - console.log('✅ Leaderboard updated:', e.data.length, 'agents'); - } else if (Array.isArray(e.rows)) { - setLeaderboard(e.rows); - } else if (Array.isArray(e.leaderboard)) { - setLeaderboard(e.leaderboard); - } - }, - - // 虚拟时间更新(Mock模式下的时间广播) - 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); - - // 只在Mock模式下保存虚拟时间(用于图表过滤和UI显示) - if (isMockMode && e.beijing_time) { - try { - const virtualTimeDate = new Date(e.beijing_time); - setVirtualTime(virtualTimeDate); - } catch (error) { - console.error('Error parsing virtual time:', error); - } - } else { - // 非Mock模式下清除virtualTime - setVirtualTime(null); - } - } - - // 更新市场状态(如果包含在time_update中) - if (e.market_status) { - setMarketStatus(e.market_status); - } - }, - - // 时间快进事件(Mock模式) - time_fast_forwarded: (e) => { - console.log(`⏩ 时间已快进 ${e.minutes} 分钟: ${e.old_time_str} → ${e.new_time_str}`); - - // 更新虚拟时间 - if (e.new_time) { - try { - const virtualTimeDate = new Date(e.new_time); - setVirtualTime(virtualTimeDate); - - // 添加到feed显示 - 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}`); - }, - }; - - // Call handler or do nothing - 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'); - - return () => { - // Cleanup on unmount - if (clientRef.current) { - clientRef.current.disconnect(); - } - }; - }, [ - addSystemMessage, - buildTickersFromSymbols, - processFeedEvent, - processHistoricalFeed, - requestStockNewsCategories, - requestStockNewsTimeline - ]); // Only reconnect if handlers change - - // Resizing handlers - const handleMouseDown = (e) => { - e.preventDefault(); - setIsResizing(true); - }; - - useEffect(() => { - if (!isResizing) return; - - const handleMouseMove = (e) => { - if (!containerRef.current) return; - const containerRect = containerRef.current.getBoundingClientRect(); - const newLeftWidth = ((e.clientX - containerRect.left) / containerRect.width) * 100; - - // Limit between 30% and 85% - if (newLeftWidth >= 30 && newLeftWidth <= 85) { - setLeftWidth(newLeftWidth); - } - }; - - const handleMouseUp = () => { - setIsResizing(false); - }; - - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - - return () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - }, [isResizing]); - return ( -
- + <> + { + setIsRuntimeLogsOpen(true); + void loadRuntimeLogs(); + }} + onRuntimeSettingsToggle={runtimeControls.handleRuntimeSettingsToggle} + isRuntimeSettingsOpen={runtimeControls.isRuntimeSettingsOpen} + isRuntimeConfigSaving={runtimeControls.isRuntimeConfigSaving} + isWatchlistSaving={runtimeControls.isWatchlistSaving} + runtimeConfigFeedback={runtimeControls.runtimeConfigFeedback} + watchlistFeedback={runtimeControls.watchlistFeedback} + scheduleModeDraft={runtimeControls.scheduleModeDraft} + intervalMinutesDraft={runtimeControls.intervalMinutesDraft} + triggerTimeDraft={runtimeControls.triggerTimeDraft} + maxCommCyclesDraft={runtimeControls.maxCommCyclesDraft} + initialCashDraft={runtimeControls.initialCashDraft} + marginRequirementDraft={runtimeControls.marginRequirementDraft} + enableMemoryDraft={runtimeControls.enableMemoryDraft} + modeDraft={runtimeControls.modeDraft} + pollIntervalDraft={runtimeControls.pollIntervalDraft} + startDateDraft={runtimeControls.startDateDraft} + endDateDraft={runtimeControls.endDateDraft} + enableMockDraft={runtimeControls.enableMockDraft} + watchlistDraftSymbols={runtimeControls.watchlistDraftSymbols} + watchlistInputValue={runtimeControls.watchlistInputValue} + watchlistSuggestions={runtimeControls.watchlistSuggestions} + onScheduleModeChange={runtimeControls.setScheduleModeDraft} + onIntervalMinutesChange={runtimeControls.setIntervalMinutesDraft} + onTriggerTimeChange={runtimeControls.setTriggerTimeDraft} + onMaxCommCyclesChange={runtimeControls.setMaxCommCyclesDraft} + onInitialCashChange={runtimeControls.setInitialCashDraft} + onMarginRequirementChange={runtimeControls.setMarginRequirementDraft} + onEnableMemoryChange={runtimeControls.setEnableMemoryDraft} + onModeChange={runtimeControls.setModeDraft} + onPollIntervalChange={runtimeControls.setPollIntervalDraft} + onStartDateChange={runtimeControls.setStartDateDraft} + onEndDateChange={runtimeControls.setEndDateDraft} + onEnableMockChange={runtimeControls.setEnableMockDraft} + onWatchlistInputChange={runtimeControls.handleWatchlistInputChange} + onWatchlistInputKeyDown={runtimeControls.handleWatchlistInputKeyDown} + onWatchlistAdd={runtimeControls.handleWatchlistAdd} + onWatchlistRemove={runtimeControls.handleWatchlistRemove} + onWatchlistRestoreCurrent={runtimeControls.handleWatchlistRestoreCurrent} + onWatchlistRestoreDefault={runtimeControls.handleWatchlistRestoreDefault} + onWatchlistSuggestionClick={runtimeControls.handleWatchlistSuggestionClick} + onLaunchConfigSave={runtimeControls.handleLaunchConfigSave} + onRestoreDefaults={runtimeControls.handleRuntimeDefaultsRestore} + displayTickers={runtimeControls.displayTickers} + portfolioData={portfolioData} + rollingTickers={rollingTickers} + feed={feed} + bubbles={bubbles} + bubbleFor={bubbleFor} + leaderboard={leaderboard} + currentView={currentView} + chartTab={chartTab} + holdings={holdings} + trades={trades} + stats={stats} + priceHistoryByTicker={priceHistoryByTicker} + ohlcHistoryByTicker={ohlcHistoryByTicker} + selectedExplainSymbol={selectedExplainSymbol} + onSelectedExplainSymbolChange={setSelectedExplainSymbol} + historySourceByTicker={historySourceByTicker} + explainEventsByTicker={explainEventsByTicker} + newsByTicker={newsByTicker} + insiderTradesByTicker={insiderTradesByTicker} + technicalIndicatorsByTicker={technicalIndicatorsByTicker} + currentDate={currentDate} + stockRequests={stockRequests} + agentRequests={agentRequests} + leftWidth={leftWidth} + isResizing={isResizing} + onMouseDown={() => useUIStore.getState().setIsResizing(true)} + agentFeedRef={agentFeedRef} + /> - {/* Header */} -
-
- -
- {/* Mock Mode Indicator */} - {virtualTime && ( -
- - - 模拟模式 - -
- )} - - - {/* Clock Display (only in Mock mode) */} - {virtualTime && ( -
-
- - 虚拟时间 - - - {now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })} - - - {now.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })} - -
- - {/* Fast Forward Button (only in Mock mode) */} - -
- )} - - {/* Unified Status Indicator */} -
- - - {isConnected ? (isUpdating ? '同步中' : '在线') : '离线'} - - {marketStatus && ( - <> - · - - {marketStatusLabel} - - - )} - {livePriceSourceLabel && ( - <> - · - - {livePriceSourceLabel} - - - )} - {historicalPriceSourceLabel && ( - <> - · - - {historicalPriceSourceLabel} - - - )} - {runtimeSummaryLabel && ( - <> - · - - {runtimeSummaryLabel} - - - )} - · - {now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })} -
- - {serverMode !== 'backtest' && ( - - )} - - setIsRuntimeSettingsOpen(false)} - onScheduleModeChange={setScheduleModeDraft} - onIntervalMinutesChange={setIntervalMinutesDraft} - onTriggerTimeChange={setTriggerTimeDraft} - onMaxCommCyclesChange={setMaxCommCyclesDraft} - onInitialCashChange={setInitialCashDraft} - onMarginRequirementChange={setMarginRequirementDraft} - onEnableMemoryChange={setEnableMemoryDraft} - onModeChange={setModeDraft} - onPollIntervalChange={setPollIntervalDraft} - onStartDateChange={setStartDateDraft} - onEndDateChange={setEndDateDraft} - onEnableMockChange={setEnableMockDraft} - onWatchlistInputChange={handleWatchlistInputChange} - onWatchlistInputKeyDown={handleWatchlistInputKeyDown} - onWatchlistAdd={() => commitWatchlistInput(watchlistInputValue)} - onWatchlistRemove={handleWatchlistRemove} - onWatchlistRestoreCurrent={handleWatchlistRestoreCurrent} - onWatchlistRestoreDefault={handleWatchlistRestoreDefault} - onWatchlistSuggestionClick={handleWatchlistSuggestionClick} - onSave={handleLaunchConfigSave} - onRestoreDefaults={handleRuntimeDefaultsRestore} - /> -
-
- - {/* Main Content */} - <> - {/* Ticker Bar */} -
-
- {[0, 1].map((groupIdx) => ( -
- {displayTickers.map(ticker => ( -
- - {ticker.symbol} - - - {ticker.price !== null && ticker.price !== undefined - ? `$${formatTickerPrice(ticker.price)}` - : '-'} - - - = 0 ? 'positive' : 'negative' - }`}> - {ticker.change !== null && ticker.change !== undefined - ? `${ticker.change >= 0 ? '+' : ''}${ticker.change.toFixed(2)}%` - : '-'} - -
- ))} -
- ))} -
-
- 投资组合 - ${formatNumber(portfolioData.netValue)} -
-
- -
- {/* Left Panel: Three-View Toggle (Room/Chart/Statistics) */} -
-
-
-
- - - - - - - - - -
- -
-
- }> - - -
- - {/* Room View Panel */} -
- }> - setIsRuntimeSettingsOpen(true)} - /> - -
- - {/* Stock Explain View Panel */} -
- }> - - -
- - {/* Chart View Panel */} -
-
- {/* Floating Timeframe Tabs */} -
- - {/* */} -
- - -
-
- - {/* Statistics View Panel */} -
- }> - - -
-
-
-
-
- - {/* Resizer */} -
- - {/* Right Panel: Agent Feed */} -
- }> - - -
-
- -
+ setIsRuntimeLogsOpen(false)} + onRefresh={loadRuntimeLogs} + /> + ); } diff --git a/frontend/src/components/AppShell.jsx b/frontend/src/components/AppShell.jsx new file mode 100644 index 0000000..1c4b280 --- /dev/null +++ b/frontend/src/components/AppShell.jsx @@ -0,0 +1,532 @@ +import React, { Suspense, lazy, useRef, useEffect, useMemo } from 'react'; +import GlobalStyles from '../styles/GlobalStyles'; +import Header from './Header.jsx'; +import RuntimeSettingsPanel from './RuntimeSettingsPanel.jsx'; +import StockLogo from './StockLogo.jsx'; +import NetValueChart from './NetValueChart.jsx'; +import { AGENTS } from '../config/constants'; +import { useRuntimeStore } from '../store/runtimeStore'; +import { useUIStore } from '../store/uiStore'; +import { formatNumber, formatTickerPrice } from '../utils/formatters'; + +const RoomView = lazy(() => import('./RoomView')); +const AgentFeed = lazy(() => import('./AgentFeed')); +const StatisticsView = lazy(() => import('./StatisticsView')); +const StockExplainView = lazy(() => import('./StockExplainView.jsx')); +const TraderView = lazy(() => import('./TraderView.jsx')); + +function ViewLoadingFallback({ label = '加载中...' }) { + return ( +
+ {label} +
+ ); +} + +/** + * AppShell - Layout shell containing Header, TickerBar, ViewNavBar, View container, and AgentFeed + */ +export default function AppShell({ + // Connection & status + isConnected, + virtualTime, + now, + marketStatus, + serverMode, + marketStatusLabel, + dataSourceLabel, + runtimeSummaryLabel, + isUpdating, + // Handlers + onManualTrigger, + onOpenRuntimeLogs, + onRuntimeSettingsToggle, + // Runtime settings panel props + isRuntimeSettingsOpen, + isRuntimeConfigSaving, + isWatchlistSaving, + runtimeConfigFeedback, + watchlistFeedback, + scheduleModeDraft, + intervalMinutesDraft, + triggerTimeDraft, + maxCommCyclesDraft, + initialCashDraft, + marginRequirementDraft, + enableMemoryDraft, + modeDraft, + pollIntervalDraft, + startDateDraft, + endDateDraft, + enableMockDraft, + watchlistDraftSymbols, + watchlistInputValue, + watchlistSuggestions, + onScheduleModeChange, + onIntervalMinutesChange, + onTriggerTimeChange, + onMaxCommCyclesChange, + onInitialCashChange, + onMarginRequirementChange, + onEnableMemoryChange, + onModeChange, + onPollIntervalChange, + onStartDateChange, + onEndDateChange, + onEnableMockChange, + onWatchlistInputChange, + onWatchlistInputKeyDown, + onWatchlistAdd, + onWatchlistRemove, + onWatchlistRestoreCurrent, + onWatchlistRestoreDefault, + onWatchlistSuggestionClick, + onLaunchConfigSave, + onRestoreDefaults, + // Ticker and portfolio data + displayTickers, + portfolioData, + rollingTickers, + // Feed data + feed, + bubbles, + bubbleFor, + leaderboard, + // Views data + currentView, + chartTab, + holdings, + trades, + stats, + priceHistoryByTicker, + ohlcHistoryByTicker, + selectedExplainSymbol, + onSelectedExplainSymbolChange, + historySourceByTicker, + explainEventsByTicker, + newsByTicker, + insiderTradesByTicker, + technicalIndicatorsByTicker, + currentDate, + // Stock request handlers + stockRequests, + // Agent request handlers + agentRequests, + // Layout + leftWidth, + isResizing, + onMouseDown, + agentFeedRef +}) { + const containerRef = useRef(null); + const { setIsRuntimeSettingsOpen, setIsWatchlistPanelOpen } = useRuntimeStore(); + const { setChartTab, setCurrentView, setIsResizing, setLeftWidth } = useUIStore(); + + // Resize handler + useEffect(() => { + if (!isResizing) return; + + const handleMouseMove = (e) => { + if (!containerRef.current) return; + const containerRect = containerRef.current.getBoundingClientRect(); + const newLeftWidth = ((e.clientX - containerRect.left) / containerRect.width) * 100; + if (newLeftWidth >= 30 && newLeftWidth <= 85) { + setLeftWidth(newLeftWidth); + } + }; + + const handleMouseUp = () => setIsResizing(false); + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, [isResizing, setIsResizing, setLeftWidth]); + + const handleJumpToMessage = (bubble) => { + if (agentFeedRef.current && agentFeedRef.current.scrollToMessage) { + agentFeedRef.current.scrollToMessage(bubble); + } + }; + + const viewClassName = useMemo(() => { + const base = `view-slider-five ${currentView === 'traders' ? 'show-traders' : + currentView === 'room' ? 'show-room' : + currentView === 'explain' ? 'show-explain' : + currentView === 'statistics' ? 'show-statistics' : 'show-chart'}`; + return base; + }, [currentView]); + + return ( +
+ + + {/* Header */} +
+
+ +
+ {/* Mock Mode Indicator */} + {virtualTime && ( +
+ + + 模拟模式 + +
+ )} + + {/* Clock Display (only in Mock mode) */} + {virtualTime && ( +
+
+ 虚拟时间 + + {now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })} + + + {now.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })} + +
+ + {/* Fast Forward Button (only in Mock mode) */} + +
+ )} + + {/* Unified Status Indicator */} +
+ + + {isConnected ? (isUpdating ? '同步中' : '在线') : '离线'} + + {marketStatus && ( + <> + · + + {marketStatusLabel} + + + )} + {dataSourceLabel && ( + <> + · + {dataSourceLabel} + + )} + {runtimeSummaryLabel && ( + <> + · + {runtimeSummaryLabel} + + )} + · + {now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })} +
+ + {serverMode !== 'backtest' && ( +
+ {onOpenRuntimeLogs && ( + + )} + +
+ )} + + setIsRuntimeSettingsOpen(false)} + onScheduleModeChange={onScheduleModeChange} + onIntervalMinutesChange={onIntervalMinutesChange} + onTriggerTimeChange={onTriggerTimeChange} + onMaxCommCyclesChange={onMaxCommCyclesChange} + onInitialCashChange={onInitialCashChange} + onMarginRequirementChange={onMarginRequirementChange} + onEnableMemoryChange={onEnableMemoryChange} + onModeChange={onModeChange} + onPollIntervalChange={onPollIntervalChange} + onStartDateChange={onStartDateChange} + onEndDateChange={onEndDateChange} + onEnableMockChange={onEnableMockChange} + onWatchlistInputChange={onWatchlistInputChange} + onWatchlistInputKeyDown={onWatchlistInputKeyDown} + onWatchlistAdd={onWatchlistAdd} + onWatchlistRemove={onWatchlistRemove} + onWatchlistRestoreCurrent={onWatchlistRestoreCurrent} + onWatchlistRestoreDefault={onWatchlistRestoreDefault} + onWatchlistSuggestionClick={onWatchlistSuggestionClick} + onSave={onLaunchConfigSave} + onRestoreDefaults={onRestoreDefaults} + /> +
+
+ + {/* Main Content */} + <> + {/* Ticker Bar */} +
+
+ {[0, 1].map((groupIdx) => ( +
+ {displayTickers.map(ticker => ( +
+ + {ticker.symbol} + + + {ticker.price !== null && ticker.price !== undefined + ? `$${formatTickerPrice(ticker.price)}` : '-'} + + + = 0 ? 'positive' : 'negative' + }`}> + {ticker.change !== null && ticker.change !== undefined + ? `${ticker.change >= 0 ? '+' : ''}${ticker.change.toFixed(2)}%` : '-'} + +
+ ))} +
+ ))} +
+
+ 投资组合 + ${formatNumber(portfolioData.netValue)} +
+
+ +
+ {/* Left Panel */} +
+
+
+
+ + + + + +
+ +
+ {/* Traders View */} +
+ }> + + +
+ + {/* Room View Panel */} +
+ }> + setIsRuntimeSettingsOpen(true)} + /> + +
+ + {/* Stock Explain View Panel */} +
+ }> + + +
+ + {/* Chart View Panel */} +
+
+
+ +
+ {currentView === 'chart' ? ( + + ) : ( +
+ )} +
+
+ + {/* Statistics View Panel */} +
+ }> + + +
+
+
+
+
+ + {/* Resizer */} +
+ + {/* Right Panel: Agent Feed */} +
+ }> + + +
+
+ +
+ ); +} diff --git a/frontend/src/components/RuntimeLogsModal.jsx b/frontend/src/components/RuntimeLogsModal.jsx new file mode 100644 index 0000000..77aa615 --- /dev/null +++ b/frontend/src/components/RuntimeLogsModal.jsx @@ -0,0 +1,136 @@ +import React from 'react'; +import { createPortal } from 'react-dom'; + +export default function RuntimeLogsModal({ + isOpen, + isLoading, + logPayload, + error, + onClose, + onRefresh +}) { + if (!isOpen) { + return null; + } + + return createPortal( +
+
event.stopPropagation()} + style={{ + width: 'min(980px, 94vw)', + maxHeight: '82vh', + overflow: 'hidden', + borderRadius: 16, + border: '1px solid #D9E0E7', + background: '#FFFFFF', + boxShadow: '0 24px 60px rgba(15, 23, 42, 0.18)', + display: 'grid', + gridTemplateRows: 'auto auto minmax(0, 1fr)' + }} + > +
+
+
运行日志
+
+ {logPayload?.run_id ? `任务 ${logPayload.run_id}` : '当前运行任务'} +
+
+
+ + +
+
+ +
+
+ {logPayload?.log_path || '未找到日志文件'} +
+ {isLoading ? ( +
加载中...
+ ) : error ? ( +
{error}
+ ) : null} +
+ +
+
+            {logPayload?.content || '暂无日志输出'}
+          
+
+
+
, + document.body + ); +} diff --git a/frontend/src/components/RuntimeView.jsx b/frontend/src/components/RuntimeView.jsx index f8fbd63..fa1bcd1 100644 --- a/frontend/src/components/RuntimeView.jsx +++ b/frontend/src/components/RuntimeView.jsx @@ -34,6 +34,18 @@ const EVENT_FILTER_OPTIONS = [ { value: 'approval', label: '审批事件' } ]; +const SR_ONLY_STYLE = { + position: 'absolute', + width: 1, + height: 1, + padding: 0, + margin: -1, + overflow: 'hidden', + clip: 'rect(0, 0, 0, 0)', + whiteSpace: 'nowrap', + border: 0 +}; + function metricCard(label, value, accent, helper = null) { return (
@@ -722,6 +734,9 @@ export default function RuntimeView() { {sectionTitle( '近期事件', )} +