Fix runtime logging and frontend app regressions
This commit is contained in:
41
.env.example
Normal file
41
.env.example
Normal file
@@ -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
|
||||
@@ -38,9 +38,10 @@ class RuntimeState:
|
||||
"""
|
||||
|
||||
_instance: Optional["RuntimeState"] = None
|
||||
_lock: asyncio.Lock = asyncio.Lock()
|
||||
_lock: "threading.Lock" = __import__("threading").Lock()
|
||||
|
||||
def __new__(cls) -> "RuntimeState":
|
||||
with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._initialized = False
|
||||
@@ -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
|
||||
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=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
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:
|
||||
|
||||
30
backend/apps/cors.py
Normal file
30
backend/apps/cors.py
Normal file
@@ -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=["*"],
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
3305
frontend/src/App.jsx
3305
frontend/src/App.jsx
File diff suppressed because it is too large
Load Diff
532
frontend/src/components/AppShell.jsx
Normal file
532
frontend/src/components/AppShell.jsx
Normal file
@@ -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 (
|
||||
<div style={{
|
||||
minHeight: 240,
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
border: '1px solid #000000',
|
||||
background: '#ffffff',
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.4
|
||||
}}>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div className="app">
|
||||
<GlobalStyles />
|
||||
|
||||
{/* Header */}
|
||||
<div className="header">
|
||||
<Header />
|
||||
|
||||
<div className="header-right" style={{ display: 'flex', alignItems: 'center', gap: 24, marginLeft: 'auto', flexWrap: 'wrap', minWidth: 0 }}>
|
||||
{/* Mock Mode Indicator */}
|
||||
{virtualTime && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 10px', borderRadius: 4, background: '#FF9800', border: '1px solid #FFB74D' }}>
|
||||
<span style={{ fontSize: '14px' }}></span>
|
||||
<span style={{ fontSize: '11px', fontWeight: 600, color: '#FFFFFF', fontFamily: '"Courier New", monospace', letterSpacing: '0.5px' }}>
|
||||
模拟模式
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Clock Display (only in Mock mode) */}
|
||||
{virtualTime && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 2, padding: '4px 12px', borderRadius: 4, background: '#1A237E', border: '1px solid #3F51B5' }}>
|
||||
<span style={{ fontSize: '11px', color: '#999', fontFamily: '"Courier New", monospace', textTransform: 'uppercase', letterSpacing: '0.5px' }}>虚拟时间</span>
|
||||
<span style={{ fontSize: '14px', fontWeight: 700, color: '#FFFFFF', fontFamily: '"Courier New", monospace', letterSpacing: '1px' }}>
|
||||
{now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })}
|
||||
</span>
|
||||
<span style={{ fontSize: '10px', color: '#999', fontFamily: '"Courier New", monospace' }}>
|
||||
{now.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Fast Forward Button (only in Mock mode) */}
|
||||
<button
|
||||
onClick={() => {
|
||||
if (agentRequests?.clientRef?.current) {
|
||||
agentRequests.clientRef.current.send({ type: 'fast_forward_time', minutes: 30 });
|
||||
}
|
||||
}}
|
||||
style={{ padding: '6px 12px', borderRadius: 4, background: '#3F51B5', border: '1px solid #5C6BC0', color: '#FFFFFF', fontSize: '12px', fontFamily: '"Courier New", monospace', fontWeight: 600, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4, textTransform: 'uppercase', letterSpacing: '0.5px' }}
|
||||
title="快进30分钟 (Mock模式)"
|
||||
>
|
||||
⏩ +30min
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Unified Status Indicator */}
|
||||
<div className="header-status-inline">
|
||||
<span className={`status-dot ${isConnected ? (isUpdating ? 'updating' : 'live') : 'offline'}`} />
|
||||
<span className={`status-text ${isConnected ? 'live' : 'offline'}`}>
|
||||
{isConnected ? (isUpdating ? '同步中' : '在线') : '离线'}
|
||||
</span>
|
||||
{marketStatus && (
|
||||
<>
|
||||
<span className="status-sep">·</span>
|
||||
<span className={`market-text ${serverMode === 'backtest' ? 'backtest' : (marketStatus.status === 'open' ? 'open' : 'closed')}`}>
|
||||
{marketStatusLabel}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{dataSourceLabel && (
|
||||
<>
|
||||
<span className="status-sep">·</span>
|
||||
<span className="market-text backtest">{dataSourceLabel}</span>
|
||||
</>
|
||||
)}
|
||||
{runtimeSummaryLabel && (
|
||||
<>
|
||||
<span className="status-sep">·</span>
|
||||
<span className="market-text backtest" title="当前运行配置">{runtimeSummaryLabel}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="status-sep">·</span>
|
||||
<span className="time-text">{now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })}</span>
|
||||
</div>
|
||||
|
||||
{serverMode !== 'backtest' && (
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
{onOpenRuntimeLogs && (
|
||||
<button
|
||||
onClick={onOpenRuntimeLogs}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
borderRadius: 4,
|
||||
background: '#FFFFFF',
|
||||
border: '1px solid #111111',
|
||||
color: '#111111',
|
||||
fontSize: '11px',
|
||||
fontFamily: '"Courier New", monospace',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
letterSpacing: '0.4px',
|
||||
textTransform: 'uppercase'
|
||||
}}
|
||||
title="查看当前运行日志"
|
||||
>
|
||||
运行日志
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onManualTrigger}
|
||||
disabled={!isConnected}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
borderRadius: 4,
|
||||
background: isConnected ? '#111111' : '#8a8a8a',
|
||||
border: '1px solid #111111',
|
||||
color: '#FFFFFF',
|
||||
fontSize: '11px',
|
||||
fontFamily: '"Courier New", monospace',
|
||||
fontWeight: 700,
|
||||
cursor: isConnected ? 'pointer' : 'not-allowed',
|
||||
letterSpacing: '0.4px',
|
||||
textTransform: 'uppercase'
|
||||
}}
|
||||
title="手动触发一轮分析与交易决策"
|
||||
>
|
||||
手动运行
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RuntimeSettingsPanel
|
||||
showTrigger={false}
|
||||
isOpen={isRuntimeSettingsOpen}
|
||||
isConnected={isConnected}
|
||||
isSaving={isRuntimeConfigSaving || isWatchlistSaving}
|
||||
feedback={runtimeConfigFeedback || watchlistFeedback}
|
||||
scheduleMode={scheduleModeDraft}
|
||||
intervalMinutes={intervalMinutesDraft}
|
||||
triggerTime={triggerTimeDraft}
|
||||
maxCommCycles={maxCommCyclesDraft}
|
||||
initialCash={initialCashDraft}
|
||||
marginRequirement={marginRequirementDraft}
|
||||
enableMemory={enableMemoryDraft}
|
||||
mode={modeDraft}
|
||||
pollInterval={pollIntervalDraft}
|
||||
startDate={startDateDraft}
|
||||
endDate={endDateDraft}
|
||||
enableMock={enableMockDraft}
|
||||
watchlistSymbols={watchlistDraftSymbols}
|
||||
watchlistInputValue={watchlistInputValue}
|
||||
watchlistSuggestions={watchlistSuggestions}
|
||||
onToggle={onRuntimeSettingsToggle}
|
||||
onClose={() => 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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<>
|
||||
{/* Ticker Bar */}
|
||||
<div className="ticker-bar">
|
||||
<div className="ticker-track">
|
||||
{[0, 1].map((groupIdx) => (
|
||||
<div key={groupIdx} className="ticker-group">
|
||||
{displayTickers.map(ticker => (
|
||||
<div key={`${ticker.symbol}-${groupIdx}`} className="ticker-item">
|
||||
<StockLogo ticker={ticker.symbol} size={16} />
|
||||
<span className="ticker-symbol">{ticker.symbol}</span>
|
||||
<span className="ticker-price">
|
||||
<span className={`ticker-price-value ${rollingTickers[ticker.symbol] ? 'rolling' : ''}`}>
|
||||
{ticker.price !== null && ticker.price !== undefined
|
||||
? `$${formatTickerPrice(ticker.price)}` : '-'}
|
||||
</span>
|
||||
</span>
|
||||
<span className={`ticker-change ${
|
||||
ticker.change === null || ticker.change === undefined
|
||||
? '' : ticker.change >= 0 ? 'positive' : 'negative'
|
||||
}`}>
|
||||
{ticker.change !== null && ticker.change !== undefined
|
||||
? `${ticker.change >= 0 ? '+' : ''}${ticker.change.toFixed(2)}%` : '-'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="portfolio-value">
|
||||
<span className="portfolio-label">投资组合</span>
|
||||
<span className="portfolio-amount">${formatNumber(portfolioData.netValue)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="main-container" ref={containerRef}>
|
||||
{/* Left Panel */}
|
||||
<div className="left-panel" style={{ width: `${leftWidth}%` }}>
|
||||
<div className="chart-section">
|
||||
<div className="view-container">
|
||||
<div className="view-nav-bar">
|
||||
<button
|
||||
className={`view-nav-btn ${currentView === 'traders' ? 'active' : ''}`}
|
||||
onClick={() => setCurrentView('traders')}
|
||||
>
|
||||
交易员
|
||||
</button>
|
||||
<button
|
||||
className={`view-nav-btn ${currentView === 'room' ? 'active' : ''}`}
|
||||
onClick={() => setCurrentView('room')}
|
||||
>
|
||||
交易室
|
||||
</button>
|
||||
<button
|
||||
className={`view-nav-btn ${currentView === 'explain' ? 'active' : ''}`}
|
||||
onClick={() => setCurrentView('explain')}
|
||||
>
|
||||
个股分析
|
||||
</button>
|
||||
<button
|
||||
className={`view-nav-btn ${currentView === 'chart' ? 'active' : ''}`}
|
||||
onClick={() => setCurrentView('chart')}
|
||||
>
|
||||
业绩图表
|
||||
</button>
|
||||
<button
|
||||
className={`view-nav-btn ${currentView === 'statistics' ? 'active' : ''}`}
|
||||
onClick={() => setCurrentView('statistics')}
|
||||
>
|
||||
统计
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={viewClassName}>
|
||||
{/* Traders View */}
|
||||
<div className="view-panel">
|
||||
<Suspense fallback={<ViewLoadingFallback label="加载交易员视图..." />}>
|
||||
<TraderView {...agentRequests} />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
{/* Room View Panel */}
|
||||
<div className="view-panel">
|
||||
<Suspense fallback={<ViewLoadingFallback label="加载交易室..." />}>
|
||||
<RoomView
|
||||
bubbles={bubbles}
|
||||
bubbleFor={bubbleFor}
|
||||
leaderboard={leaderboard}
|
||||
feed={feed}
|
||||
onJumpToMessage={handleJumpToMessage}
|
||||
onOpenLaunchConfig={() => setIsRuntimeSettingsOpen(true)}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
{/* Stock Explain View Panel */}
|
||||
<div className="view-panel">
|
||||
<Suspense fallback={<ViewLoadingFallback label="加载个股分析..." />}>
|
||||
<StockExplainView
|
||||
tickers={displayTickers}
|
||||
holdings={holdings}
|
||||
trades={trades}
|
||||
leaderboard={leaderboard}
|
||||
feed={feed}
|
||||
priceHistoryByTicker={priceHistoryByTicker}
|
||||
ohlcHistoryByTicker={ohlcHistoryByTicker}
|
||||
selectedSymbol={selectedExplainSymbol}
|
||||
onSelectedSymbolChange={onSelectedExplainSymbolChange}
|
||||
selectedHistorySource={historySourceByTicker[selectedExplainSymbol] || null}
|
||||
explainEventsSnapshot={explainEventsByTicker[selectedExplainSymbol] || null}
|
||||
newsSnapshot={newsByTicker[selectedExplainSymbol] || null}
|
||||
insiderTradesSnapshot={insiderTradesByTicker[selectedExplainSymbol] || null}
|
||||
technicalIndicatorsSnapshot={technicalIndicatorsByTicker[selectedExplainSymbol] || null}
|
||||
onRequestRangeExplain={stockRequests?.requestStockRangeExplain}
|
||||
onRequestNewsForDate={stockRequests?.requestStockNewsForDate}
|
||||
onRequestStory={stockRequests?.requestStockStory}
|
||||
onRequestInsiderTrades={stockRequests?.requestStockInsiderTrades}
|
||||
onRequestTechnicalIndicators={stockRequests?.requestStockTechnicalIndicators}
|
||||
currentDate={currentDate}
|
||||
onRequestSimilarDays={stockRequests?.requestStockSimilarDays}
|
||||
onRequestStockEnrich={stockRequests?.requestStockEnrich}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
{/* Chart View Panel */}
|
||||
<div className="view-panel">
|
||||
<div className="chart-container">
|
||||
<div className="chart-tabs-floating">
|
||||
<button
|
||||
className={`chart-tab ${chartTab === 'all' ? 'active' : ''}`}
|
||||
onClick={() => setChartTab('all')}
|
||||
>
|
||||
日线
|
||||
</button>
|
||||
</div>
|
||||
{currentView === 'chart' ? (
|
||||
<NetValueChart
|
||||
equity={portfolioData.equity}
|
||||
baseline={portfolioData.baseline}
|
||||
baseline_vw={portfolioData.baseline_vw}
|
||||
momentum={portfolioData.momentum}
|
||||
strategies={portfolioData.strategies}
|
||||
equity_return={portfolioData.equity_return}
|
||||
baseline_return={portfolioData.baseline_return}
|
||||
baseline_vw_return={portfolioData.baseline_vw_return}
|
||||
momentum_return={portfolioData.momentum_return}
|
||||
chartTab={chartTab}
|
||||
virtualTime={virtualTime}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ height: '100%', minHeight: 320 }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Statistics View Panel */}
|
||||
<div className="view-panel">
|
||||
<Suspense fallback={<ViewLoadingFallback label="加载统计视图..." />}>
|
||||
<StatisticsView
|
||||
trades={trades}
|
||||
holdings={holdings}
|
||||
stats={stats}
|
||||
baseline_vw={portfolioData.baseline_vw}
|
||||
equity={portfolioData.equity}
|
||||
leaderboard={leaderboard}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resizer */}
|
||||
<div className={`resizer ${isResizing ? 'resizing' : ''}`} onMouseDown={onMouseDown} />
|
||||
|
||||
{/* Right Panel: Agent Feed */}
|
||||
<div className="right-panel" style={{ width: `${100 - leftWidth}%` }}>
|
||||
<Suspense fallback={<ViewLoadingFallback label="加载消息流..." />}>
|
||||
<AgentFeed ref={agentFeedRef} feed={feed} leaderboard={leaderboard} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
136
frontend/src/components/RuntimeLogsModal.jsx
Normal file
136
frontend/src/components/RuntimeLogsModal.jsx
Normal file
@@ -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(
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(15, 23, 42, 0.32)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 24,
|
||||
zIndex: 10000
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(event) => 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)'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
padding: '18px 20px 10px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 12
|
||||
}}>
|
||||
<div style={{ display: 'grid', gap: 4 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 800, color: '#111111' }}>运行日志</div>
|
||||
<div style={{ fontSize: 11, color: '#6B7280' }}>
|
||||
{logPayload?.run_id ? `任务 ${logPayload.run_id}` : '当前运行任务'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
style={{
|
||||
padding: '7px 10px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#FFFFFF',
|
||||
color: '#111111',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: '7px 10px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #111111',
|
||||
background: '#111111',
|
||||
color: '#FFFFFF',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
padding: '0 20px 12px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
<div style={{ fontSize: 11, color: '#6B7280', fontFamily: '"Courier New", monospace' }}>
|
||||
{logPayload?.log_path || '未找到日志文件'}
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div style={{ fontSize: 11, color: '#2563EB', fontWeight: 700 }}>加载中...</div>
|
||||
) : error ? (
|
||||
<div style={{ fontSize: 11, color: '#B91C1C', fontWeight: 700 }}>{error}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '0 20px 20px', minHeight: 0 }}>
|
||||
<pre style={{
|
||||
margin: 0,
|
||||
height: '100%',
|
||||
minHeight: 320,
|
||||
maxHeight: 'calc(82vh - 140px)',
|
||||
overflow: 'auto',
|
||||
borderRadius: 12,
|
||||
border: '1px solid #D9E0E7',
|
||||
background: '#0F172A',
|
||||
color: '#E2E8F0',
|
||||
padding: 16,
|
||||
fontSize: 11,
|
||||
lineHeight: 1.6,
|
||||
fontFamily: '"SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word'
|
||||
}}>
|
||||
{logPayload?.content || '暂无日志输出'}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="stat-card">
|
||||
@@ -722,6 +734,9 @@ export default function RuntimeView() {
|
||||
{sectionTitle(
|
||||
'近期事件',
|
||||
<select
|
||||
id="runtime-event-filter"
|
||||
name="runtime_event_filter"
|
||||
aria-label="筛选近期事件"
|
||||
value={eventFilter}
|
||||
onChange={(event) => setEventFilter(event.target.value)}
|
||||
style={{
|
||||
@@ -739,6 +754,9 @@ export default function RuntimeView() {
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<label htmlFor="runtime-event-filter" style={SR_ONLY_STYLE}>
|
||||
筛选近期事件
|
||||
</label>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gap: 8,
|
||||
|
||||
@@ -38,6 +38,18 @@ export default function TraderView({
|
||||
onWorkspaceFileSave,
|
||||
onUploadExternalSkill
|
||||
}) {
|
||||
const srOnlyStyle = {
|
||||
position: 'absolute',
|
||||
width: 1,
|
||||
height: 1,
|
||||
padding: 0,
|
||||
margin: -1,
|
||||
overflow: 'hidden',
|
||||
clip: 'rect(0, 0, 0, 0)',
|
||||
whiteSpace: 'nowrap',
|
||||
border: 0
|
||||
};
|
||||
|
||||
const [expandedSkillKey, setExpandedSkillKey] = useState(null);
|
||||
const [newLocalSkillName, setNewLocalSkillName] = useState('');
|
||||
const [externalSkillFile, setExternalSkillFile] = useState(null);
|
||||
@@ -460,6 +472,9 @@ export default function TraderView({
|
||||
本地技能 SKILL.md
|
||||
</div>
|
||||
<textarea
|
||||
id={`local-skill-${selectedAgentId}-${skill.skill_name}`}
|
||||
name={`local_skill_${selectedAgentId}_${skill.skill_name}`}
|
||||
aria-label={`${skill.skill_name} 本地技能内容`}
|
||||
value={skillDraft}
|
||||
onChange={(e) => onLocalSkillDraftChange(skill.skill_name, e.target.value)}
|
||||
style={{
|
||||
@@ -557,6 +572,9 @@ export default function TraderView({
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
id={`workspace-editor-${selectedAgentId}-${selectedWorkspaceFile || 'file'}`}
|
||||
name={`workspace_editor_${selectedAgentId}_${selectedWorkspaceFile || 'file'}`}
|
||||
aria-label={`编辑 ${selectedWorkspaceFile || '工作区文件'} 内容`}
|
||||
value={workspaceDraftContent}
|
||||
onChange={(e) => onWorkspaceDraftChange(e.target.value)}
|
||||
placeholder={isWorkspaceFileLoading ? '加载中...' : '输入 markdown 内容'}
|
||||
@@ -687,7 +705,13 @@ export default function TraderView({
|
||||
}}>
|
||||
<div style={{ fontSize: 12, fontWeight: 800, color: '#111111' }}>创建本地技能</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<label htmlFor="new-local-skill-name" style={srOnlyStyle}>
|
||||
输入本地技能名称
|
||||
</label>
|
||||
<input
|
||||
id="new-local-skill-name"
|
||||
name="new_local_skill_name"
|
||||
aria-label="输入本地技能名称"
|
||||
value={newLocalSkillName}
|
||||
onChange={(e) => setNewLocalSkillName(e.target.value)}
|
||||
placeholder="输入技能名,例如 event_playbook"
|
||||
@@ -741,7 +765,13 @@ export default function TraderView({
|
||||
支持上传 .zip(包内需包含一个技能目录及 SKILL.md)
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<label htmlFor="external-skill-zip" style={srOnlyStyle}>
|
||||
上传外部技能 zip 包
|
||||
</label>
|
||||
<input
|
||||
id="external-skill-zip"
|
||||
name="external_skill_zip"
|
||||
aria-label="上传外部技能 zip 包"
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
onChange={async (e) => {
|
||||
|
||||
@@ -19,6 +19,18 @@ export default function WatchlistPanel({
|
||||
onSuggestionClick,
|
||||
onSave
|
||||
}) {
|
||||
const srOnlyStyle = {
|
||||
position: 'absolute',
|
||||
width: 1,
|
||||
height: 1,
|
||||
padding: 0,
|
||||
margin: -1,
|
||||
overflow: 'hidden',
|
||||
clip: 'rect(0, 0, 0, 0)',
|
||||
whiteSpace: 'nowrap',
|
||||
border: 0
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, position: 'relative', marginLeft: -6 }}>
|
||||
<button
|
||||
@@ -117,7 +129,13 @@ export default function WatchlistPanel({
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<label htmlFor="watchlist-symbol-input" style={srOnlyStyle}>
|
||||
输入股票代码
|
||||
</label>
|
||||
<input
|
||||
id="watchlist-symbol-input"
|
||||
name="watchlist_symbol"
|
||||
aria-label="输入股票代码"
|
||||
value={inputValue}
|
||||
onChange={(e) => onInputChange(e.target.value)}
|
||||
onKeyDown={onInputKeyDown}
|
||||
|
||||
@@ -11,6 +11,37 @@ export default function ExplainPriceSection({
|
||||
isOpen,
|
||||
onToggle,
|
||||
}) {
|
||||
const timeTicks = (() => {
|
||||
const candles = Array.isArray(chartModel?.candles) ? chartModel.candles : [];
|
||||
if (!candles.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const targetCount = Math.min(4, candles.length);
|
||||
const step = Math.max(1, Math.floor((candles.length - 1) / Math.max(targetCount - 1, 1)));
|
||||
const ticks = [];
|
||||
|
||||
for (let index = 0; index < candles.length; index += step) {
|
||||
const candle = candles[index];
|
||||
const rawLabel = candle.startLabel || candle.time || candle.date || '';
|
||||
ticks.push({
|
||||
x: candle.centerX,
|
||||
label: String(rawLabel).slice(5, 16).replace('T', ' '),
|
||||
});
|
||||
}
|
||||
|
||||
const lastCandle = candles[candles.length - 1];
|
||||
const lastLabel = String(lastCandle.endLabel || lastCandle.time || lastCandle.date || '').slice(5, 16).replace('T', ' ');
|
||||
if (ticks.length === 0 || ticks[ticks.length - 1]?.x !== lastCandle.centerX) {
|
||||
ticks.push({
|
||||
x: lastCandle.centerX,
|
||||
label: lastLabel,
|
||||
});
|
||||
}
|
||||
|
||||
return ticks;
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="section">
|
||||
<div className="section-header">
|
||||
@@ -66,12 +97,35 @@ export default function ExplainPriceSection({
|
||||
strokeWidth="1"
|
||||
/>
|
||||
|
||||
{timeTicks.map((tick) => (
|
||||
<g key={`${tick.x}-${tick.label}`}>
|
||||
<line
|
||||
x1={tick.x}
|
||||
y1={chartModel.height - chartModel.padding}
|
||||
x2={tick.x}
|
||||
y2={chartModel.height - chartModel.padding + 4}
|
||||
stroke="#666666"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<text
|
||||
x={tick.x}
|
||||
y={chartModel.height - chartModel.padding + 16}
|
||||
fontSize="10"
|
||||
fill="#666666"
|
||||
textAnchor="middle"
|
||||
>
|
||||
{tick.label}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{chartModel.candles.length > 1 ? chartModel.candles.map((candle) => {
|
||||
const rising = candle.close >= candle.open;
|
||||
const stroke = rising ? '#00C853' : '#FF1744';
|
||||
const fill = rising ? 'rgba(0, 200, 83, 0.16)' : 'rgba(255, 23, 68, 0.16)';
|
||||
return (
|
||||
<g key={candle.id}>
|
||||
<title>{`${candle.startLabel || candle.time || candle.date || ''} → ${candle.endLabel || candle.time || candle.date || ''}`}</title>
|
||||
<line
|
||||
x1={candle.centerX}
|
||||
y1={candle.highY}
|
||||
@@ -123,7 +177,7 @@ export default function ExplainPriceSection({
|
||||
stroke={marker.isSelected ? '#111111' : '#ffffff'}
|
||||
strokeWidth={marker.isSelected ? '2.5' : '2'}
|
||||
/>
|
||||
<title>{`${marker.title} · ${marker.dateKey || ''}${marker.count ? ` · ${marker.count} 条新闻` : ''}`}</title>
|
||||
<title>{`${marker.title} · ${marker.timestamp || marker.dateKey || ''}${marker.count ? ` · ${marker.count} 条新闻` : ''}`}</title>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
211
frontend/src/hooks/useAgentDataRequests.js
Normal file
211
frontend/src/hooks/useAgentDataRequests.js
Normal file
@@ -0,0 +1,211 @@
|
||||
import { useCallback } from 'react';
|
||||
import { uploadAgentSkillZip } from '../services/runtimeApi';
|
||||
import { useAgentStore } from '../store/agentStore';
|
||||
|
||||
/**
|
||||
* Custom hook for agent operation callbacks.
|
||||
* Takes clientRef, uses agentStore.
|
||||
*/
|
||||
export function useAgentDataRequests(clientRef) {
|
||||
const {
|
||||
selectedSkillAgentId,
|
||||
setSelectedSkillAgentId,
|
||||
setIsAgentSkillsLoading,
|
||||
setAgentSkillsFeedback,
|
||||
setAgentSkillsSavingKey,
|
||||
setSkillDetailLoadingKey,
|
||||
localSkillDraftsByKey,
|
||||
selectedWorkspaceFile,
|
||||
setWorkspaceDraftContent,
|
||||
workspaceDraftContent,
|
||||
setWorkspaceFileFeedback,
|
||||
setWorkspaceFileSavingKey,
|
||||
setIsWorkspaceFileLoading
|
||||
} = useAgentStore();
|
||||
|
||||
const requestAgentSkills = useCallback((agentId) => {
|
||||
const normalized = typeof agentId === 'string' ? agentId.trim() : '';
|
||||
if (!normalized || !clientRef.current) return false;
|
||||
setIsAgentSkillsLoading(true);
|
||||
setAgentSkillsFeedback(null);
|
||||
return clientRef.current.send({ type: 'get_agent_skills', agent_id: normalized });
|
||||
}, [clientRef, setIsAgentSkillsLoading, setAgentSkillsFeedback]);
|
||||
|
||||
const requestAgentProfile = useCallback((agentId) => {
|
||||
const normalized = typeof agentId === 'string' ? agentId.trim() : '';
|
||||
if (!normalized || !clientRef.current) return false;
|
||||
return clientRef.current.send({ type: 'get_agent_profile', agent_id: normalized });
|
||||
}, [clientRef]);
|
||||
|
||||
const requestSkillDetail = useCallback((skillName) => {
|
||||
const normalized = typeof skillName === 'string' ? skillName.trim() : '';
|
||||
if (!normalized || !clientRef.current) return false;
|
||||
const detailKey = `${selectedSkillAgentId}:${normalized}`;
|
||||
setSkillDetailLoadingKey(detailKey);
|
||||
return clientRef.current.send({ type: 'get_skill_detail', agent_id: selectedSkillAgentId, skill_name: normalized });
|
||||
}, [clientRef, selectedSkillAgentId, setSkillDetailLoadingKey]);
|
||||
|
||||
const handleCreateLocalSkill = useCallback((skillName) => {
|
||||
const normalized = typeof skillName === 'string' ? skillName.trim() : '';
|
||||
if (!normalized) {
|
||||
setAgentSkillsFeedback({ type: 'error', text: '技能名称不能为空' });
|
||||
return;
|
||||
}
|
||||
if (!clientRef.current) {
|
||||
setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
|
||||
return;
|
||||
}
|
||||
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${normalized}:create`);
|
||||
setAgentSkillsFeedback(null);
|
||||
const success = clientRef.current.send({ type: 'create_agent_local_skill', agent_id: selectedSkillAgentId, skill_name: normalized });
|
||||
if (!success) {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
|
||||
}
|
||||
}, [clientRef, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
|
||||
|
||||
const handleLocalSkillDraftChange = useCallback((skillName, content) => {
|
||||
const detailKey = `${selectedSkillAgentId}:${skillName}`;
|
||||
useAgentStore.getState().setLocalSkillDraftsByKey((prev) => ({ ...prev, [detailKey]: content }));
|
||||
}, [selectedSkillAgentId]);
|
||||
|
||||
const handleLocalSkillSave = useCallback((skillName) => {
|
||||
if (!clientRef.current) {
|
||||
setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
|
||||
return;
|
||||
}
|
||||
const detailKey = `${selectedSkillAgentId}:${skillName}`;
|
||||
const content = localSkillDraftsByKey[detailKey];
|
||||
if (typeof content !== 'string') return;
|
||||
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:content`);
|
||||
setAgentSkillsFeedback(null);
|
||||
const success = clientRef.current.send({ type: 'update_agent_local_skill', agent_id: selectedSkillAgentId, skill_name: skillName, content });
|
||||
if (!success) {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
|
||||
}
|
||||
}, [clientRef, localSkillDraftsByKey, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
|
||||
|
||||
const handleLocalSkillDelete = useCallback((skillName) => {
|
||||
if (!clientRef.current) {
|
||||
setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
|
||||
return;
|
||||
}
|
||||
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:delete`);
|
||||
setAgentSkillsFeedback(null);
|
||||
const success = clientRef.current.send({ type: 'delete_agent_local_skill', agent_id: selectedSkillAgentId, skill_name: skillName });
|
||||
if (!success) {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
|
||||
}
|
||||
}, [clientRef, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
|
||||
|
||||
const handleRemoveSharedSkill = useCallback((skillName) => {
|
||||
if (!clientRef.current) {
|
||||
setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
|
||||
return;
|
||||
}
|
||||
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:remove`);
|
||||
setAgentSkillsFeedback(null);
|
||||
const success = clientRef.current.send({ type: 'remove_agent_skill', agent_id: selectedSkillAgentId, skill_name: skillName });
|
||||
if (!success) {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
|
||||
}
|
||||
}, [clientRef, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
|
||||
|
||||
const handleAgentSkillToggle = useCallback((skillName, enabled) => {
|
||||
if (!clientRef.current) {
|
||||
setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
|
||||
return;
|
||||
}
|
||||
const agentId = selectedSkillAgentId;
|
||||
setAgentSkillsSavingKey(`${agentId}:${skillName}`);
|
||||
setAgentSkillsFeedback(null);
|
||||
const success = clientRef.current.send({ type: 'update_agent_skill', agent_id: agentId, skill_name: skillName, enabled });
|
||||
if (!success) {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
|
||||
}
|
||||
}, [clientRef, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
|
||||
|
||||
const handleSkillAgentChange = useCallback((agentId) => {
|
||||
setSelectedSkillAgentId(agentId);
|
||||
requestAgentProfile(agentId);
|
||||
requestAgentSkills(agentId);
|
||||
requestWorkspaceFile(agentId, selectedWorkspaceFile);
|
||||
}, [requestAgentProfile, requestAgentSkills, setSelectedSkillAgentId, selectedWorkspaceFile]);
|
||||
|
||||
const requestWorkspaceFile = useCallback((agentId, filename) => {
|
||||
const normalizedAgentId = typeof agentId === 'string' ? agentId.trim() : '';
|
||||
const normalizedFilename = typeof filename === 'string' ? filename.trim() : '';
|
||||
if (!normalizedAgentId || !normalizedFilename || !clientRef.current) return false;
|
||||
setIsWorkspaceFileLoading(true);
|
||||
setWorkspaceFileFeedback(null);
|
||||
return clientRef.current.send({ type: 'get_agent_workspace_file', agent_id: normalizedAgentId, filename: normalizedFilename });
|
||||
}, [clientRef, setIsWorkspaceFileLoading, setWorkspaceFileFeedback]);
|
||||
|
||||
const handleWorkspaceFileChange = useCallback((filename) => {
|
||||
useAgentStore.getState().setSelectedWorkspaceFile(filename);
|
||||
requestWorkspaceFile(selectedSkillAgentId, filename);
|
||||
}, [requestWorkspaceFile, selectedSkillAgentId]);
|
||||
|
||||
const handleWorkspaceFileSave = useCallback(() => {
|
||||
if (!clientRef.current) {
|
||||
setWorkspaceFileFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
|
||||
return;
|
||||
}
|
||||
const key = `${selectedSkillAgentId}:${selectedWorkspaceFile}`;
|
||||
setWorkspaceFileSavingKey(key);
|
||||
setWorkspaceFileFeedback(null);
|
||||
const success = clientRef.current.send({
|
||||
type: 'update_agent_workspace_file',
|
||||
agent_id: selectedSkillAgentId,
|
||||
filename: selectedWorkspaceFile,
|
||||
content: workspaceDraftContent
|
||||
});
|
||||
if (!success) {
|
||||
setWorkspaceFileSavingKey(null);
|
||||
setWorkspaceFileFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
|
||||
}
|
||||
}, [clientRef, selectedSkillAgentId, selectedWorkspaceFile, setWorkspaceFileFeedback, setWorkspaceFileSavingKey, workspaceDraftContent]);
|
||||
|
||||
const handleUploadExternalSkill = useCallback(async (file) => {
|
||||
if (!(file instanceof File)) {
|
||||
setAgentSkillsFeedback({ type: 'error', text: '请选择 zip 文件后再上传' });
|
||||
return;
|
||||
}
|
||||
if (!selectedSkillAgentId) {
|
||||
setAgentSkillsFeedback({ type: 'error', text: '未选择目标 Agent' });
|
||||
return;
|
||||
}
|
||||
setAgentSkillsSavingKey(`${selectedSkillAgentId}:__upload__`);
|
||||
setAgentSkillsFeedback(null);
|
||||
try {
|
||||
const result = await uploadAgentSkillZip({ agentId: selectedSkillAgentId, file, activate: true });
|
||||
setAgentSkillsFeedback({ type: 'success', text: `已上传并安装技能 ${result.skill_name || ''}`.trim() });
|
||||
requestAgentSkills(selectedSkillAgentId);
|
||||
} catch (error) {
|
||||
setAgentSkillsFeedback({ type: 'error', text: `上传失败: ${error.message || '未知错误'}` });
|
||||
} finally {
|
||||
setAgentSkillsSavingKey(null);
|
||||
}
|
||||
}, [requestAgentSkills, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
|
||||
|
||||
return {
|
||||
requestAgentSkills,
|
||||
requestAgentProfile,
|
||||
requestSkillDetail,
|
||||
handleCreateLocalSkill,
|
||||
handleLocalSkillDraftChange,
|
||||
handleLocalSkillSave,
|
||||
handleLocalSkillDelete,
|
||||
handleRemoveSharedSkill,
|
||||
handleAgentSkillToggle,
|
||||
handleSkillAgentChange,
|
||||
requestWorkspaceFile,
|
||||
handleWorkspaceFileChange,
|
||||
handleWorkspaceFileSave,
|
||||
handleUploadExternalSkill
|
||||
};
|
||||
}
|
||||
385
frontend/src/hooks/useAgentWorkspacePanel.js
Normal file
385
frontend/src/hooks/useAgentWorkspacePanel.js
Normal file
@@ -0,0 +1,385 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
|
||||
import { AGENTS } from "../config/constants";
|
||||
import { uploadAgentSkillZip } from "../services/runtimeApi";
|
||||
|
||||
export function useAgentWorkspacePanel({
|
||||
clientRef,
|
||||
currentView,
|
||||
isConnected,
|
||||
connectionStatus,
|
||||
selectedSkillAgentId,
|
||||
selectedWorkspaceFile,
|
||||
selectedWorkspaceContent,
|
||||
localSkillDraftsByKey,
|
||||
agentProfilesByAgent,
|
||||
agentSkillsByAgent,
|
||||
workspaceFilesByAgent,
|
||||
workspaceDraftContent,
|
||||
setSelectedSkillAgentId,
|
||||
setSelectedWorkspaceFile,
|
||||
setWorkspaceDraftContent,
|
||||
setIsAgentSkillsLoading,
|
||||
setAgentSkillsFeedback,
|
||||
setSkillDetailLoadingKey,
|
||||
setAgentSkillsSavingKey,
|
||||
setLocalSkillDraftsByKey,
|
||||
setIsWorkspaceFileLoading,
|
||||
setWorkspaceFileFeedback,
|
||||
setWorkspaceFileSavingKey
|
||||
}) {
|
||||
const sendWithRetry = useCallback((payload, retries = 3, delayMs = 250) => {
|
||||
const attemptSend = (remaining) => {
|
||||
const client = clientRef.current;
|
||||
if (!client) {
|
||||
return false;
|
||||
}
|
||||
const sent = client.send(payload);
|
||||
if (sent || remaining <= 0) {
|
||||
return sent;
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
attemptSend(remaining - 1);
|
||||
}, delayMs);
|
||||
return false;
|
||||
};
|
||||
|
||||
return attemptSend(retries);
|
||||
}, [clientRef]);
|
||||
|
||||
const requestAgentSkills = useCallback((agentId) => {
|
||||
const normalized = typeof agentId === "string" ? agentId.trim() : "";
|
||||
if (!normalized || !clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
setIsAgentSkillsLoading(true);
|
||||
setAgentSkillsFeedback(null);
|
||||
return sendWithRetry({
|
||||
type: "get_agent_skills",
|
||||
agent_id: normalized
|
||||
});
|
||||
}, [clientRef, sendWithRetry, setAgentSkillsFeedback, setIsAgentSkillsLoading]);
|
||||
|
||||
const requestAgentProfile = useCallback((agentId) => {
|
||||
const normalized = typeof agentId === "string" ? agentId.trim() : "";
|
||||
if (!normalized || !clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
return sendWithRetry({
|
||||
type: "get_agent_profile",
|
||||
agent_id: normalized
|
||||
});
|
||||
}, [clientRef, sendWithRetry]);
|
||||
|
||||
const requestSkillDetail = useCallback((skillName) => {
|
||||
const normalized = typeof skillName === "string" ? skillName.trim() : "";
|
||||
if (!normalized || !clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
const detailKey = `${selectedSkillAgentId}:${normalized}`;
|
||||
setSkillDetailLoadingKey(detailKey);
|
||||
return sendWithRetry({
|
||||
type: "get_skill_detail",
|
||||
agent_id: selectedSkillAgentId,
|
||||
skill_name: normalized
|
||||
});
|
||||
}, [clientRef, selectedSkillAgentId, sendWithRetry, setSkillDetailLoadingKey]);
|
||||
|
||||
const handleCreateLocalSkill = useCallback((skillName) => {
|
||||
const normalized = typeof skillName === "string" ? skillName.trim() : "";
|
||||
if (!normalized) {
|
||||
setAgentSkillsFeedback({ type: "error", text: "技能名称不能为空" });
|
||||
return;
|
||||
}
|
||||
if (!clientRef.current) {
|
||||
setAgentSkillsFeedback({ type: "error", text: "连接未就绪,稍后重试" });
|
||||
return;
|
||||
}
|
||||
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${normalized}:create`);
|
||||
setAgentSkillsFeedback(null);
|
||||
const success = sendWithRetry({
|
||||
type: "create_agent_local_skill",
|
||||
agent_id: selectedSkillAgentId,
|
||||
skill_name: normalized
|
||||
});
|
||||
if (!success) {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: "error", text: "发送失败,请检查连接状态" });
|
||||
}
|
||||
}, [clientRef, selectedSkillAgentId, sendWithRetry, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
|
||||
|
||||
const handleLocalSkillDraftChange = useCallback((skillName, content) => {
|
||||
const detailKey = `${selectedSkillAgentId}:${skillName}`;
|
||||
setLocalSkillDraftsByKey((prev) => ({
|
||||
...prev,
|
||||
[detailKey]: content
|
||||
}));
|
||||
}, [selectedSkillAgentId, setLocalSkillDraftsByKey]);
|
||||
|
||||
const handleLocalSkillSave = useCallback((skillName) => {
|
||||
if (!clientRef.current) {
|
||||
setAgentSkillsFeedback({ type: "error", text: "连接未就绪,稍后重试" });
|
||||
return;
|
||||
}
|
||||
const detailKey = `${selectedSkillAgentId}:${skillName}`;
|
||||
const content = localSkillDraftsByKey[detailKey];
|
||||
if (typeof content !== "string") {
|
||||
return;
|
||||
}
|
||||
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:content`);
|
||||
setAgentSkillsFeedback(null);
|
||||
const success = sendWithRetry({
|
||||
type: "update_agent_local_skill",
|
||||
agent_id: selectedSkillAgentId,
|
||||
skill_name: skillName,
|
||||
content
|
||||
});
|
||||
if (!success) {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: "error", text: "发送失败,请检查连接状态" });
|
||||
}
|
||||
}, [
|
||||
clientRef,
|
||||
localSkillDraftsByKey,
|
||||
selectedSkillAgentId,
|
||||
setAgentSkillsFeedback,
|
||||
setAgentSkillsSavingKey
|
||||
]);
|
||||
|
||||
const handleLocalSkillDelete = useCallback((skillName) => {
|
||||
if (!clientRef.current) {
|
||||
setAgentSkillsFeedback({ type: "error", text: "连接未就绪,稍后重试" });
|
||||
return;
|
||||
}
|
||||
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:delete`);
|
||||
setAgentSkillsFeedback(null);
|
||||
const success = sendWithRetry({
|
||||
type: "delete_agent_local_skill",
|
||||
agent_id: selectedSkillAgentId,
|
||||
skill_name: skillName
|
||||
});
|
||||
if (!success) {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: "error", text: "发送失败,请检查连接状态" });
|
||||
}
|
||||
}, [clientRef, selectedSkillAgentId, sendWithRetry, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
|
||||
|
||||
const handleRemoveSharedSkill = useCallback((skillName) => {
|
||||
if (!clientRef.current) {
|
||||
setAgentSkillsFeedback({ type: "error", text: "连接未就绪,稍后重试" });
|
||||
return;
|
||||
}
|
||||
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:remove`);
|
||||
setAgentSkillsFeedback(null);
|
||||
const success = sendWithRetry({
|
||||
type: "remove_agent_skill",
|
||||
agent_id: selectedSkillAgentId,
|
||||
skill_name: skillName
|
||||
});
|
||||
if (!success) {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: "error", text: "发送失败,请检查连接状态" });
|
||||
}
|
||||
}, [clientRef, selectedSkillAgentId, sendWithRetry, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
|
||||
|
||||
const requestWorkspaceFile = useCallback((agentId, filename) => {
|
||||
const normalizedAgentId = typeof agentId === "string" ? agentId.trim() : "";
|
||||
const normalizedFilename = typeof filename === "string" ? filename.trim() : "";
|
||||
if (!normalizedAgentId || !normalizedFilename || !clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
setIsWorkspaceFileLoading(true);
|
||||
setWorkspaceFileFeedback(null);
|
||||
return sendWithRetry({
|
||||
type: "get_agent_workspace_file",
|
||||
agent_id: normalizedAgentId,
|
||||
filename: normalizedFilename
|
||||
});
|
||||
}, [clientRef, sendWithRetry, setIsWorkspaceFileLoading, setWorkspaceFileFeedback]);
|
||||
|
||||
const handleAgentSkillToggle = useCallback((skillName, enabled) => {
|
||||
if (!clientRef.current) {
|
||||
setAgentSkillsFeedback({ type: "error", text: "连接未就绪,稍后重试" });
|
||||
return;
|
||||
}
|
||||
|
||||
const agentId = selectedSkillAgentId;
|
||||
setAgentSkillsSavingKey(`${agentId}:${skillName}`);
|
||||
setAgentSkillsFeedback(null);
|
||||
const success = sendWithRetry({
|
||||
type: "update_agent_skill",
|
||||
agent_id: agentId,
|
||||
skill_name: skillName,
|
||||
enabled
|
||||
});
|
||||
|
||||
if (!success) {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: "error", text: "发送失败,请检查连接状态" });
|
||||
}
|
||||
}, [clientRef, selectedSkillAgentId, sendWithRetry, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
|
||||
|
||||
const handleSkillAgentChange = useCallback((agentId) => {
|
||||
setSelectedSkillAgentId(agentId);
|
||||
requestAgentProfile(agentId);
|
||||
requestAgentSkills(agentId);
|
||||
requestWorkspaceFile(agentId, selectedWorkspaceFile);
|
||||
}, [
|
||||
requestAgentProfile,
|
||||
requestAgentSkills,
|
||||
requestWorkspaceFile,
|
||||
selectedWorkspaceFile,
|
||||
setSelectedSkillAgentId
|
||||
]);
|
||||
|
||||
const handleWorkspaceFileChange = useCallback((filename) => {
|
||||
setSelectedWorkspaceFile(filename);
|
||||
requestWorkspaceFile(selectedSkillAgentId, filename);
|
||||
}, [requestWorkspaceFile, selectedSkillAgentId, setSelectedWorkspaceFile]);
|
||||
|
||||
const handleWorkspaceFileSave = useCallback(() => {
|
||||
if (!clientRef.current) {
|
||||
setWorkspaceFileFeedback({ type: "error", text: "连接未就绪,稍后重试" });
|
||||
return;
|
||||
}
|
||||
const key = `${selectedSkillAgentId}:${selectedWorkspaceFile}`;
|
||||
setWorkspaceFileSavingKey(key);
|
||||
setWorkspaceFileFeedback(null);
|
||||
const success = sendWithRetry({
|
||||
type: "update_agent_workspace_file",
|
||||
agent_id: selectedSkillAgentId,
|
||||
filename: selectedWorkspaceFile,
|
||||
content: workspaceDraftContent
|
||||
});
|
||||
if (!success) {
|
||||
setWorkspaceFileSavingKey(null);
|
||||
setWorkspaceFileFeedback({ type: "error", text: "发送失败,请检查连接状态" });
|
||||
}
|
||||
}, [
|
||||
clientRef,
|
||||
selectedSkillAgentId,
|
||||
selectedWorkspaceFile,
|
||||
sendWithRetry,
|
||||
setWorkspaceFileFeedback,
|
||||
setWorkspaceFileSavingKey,
|
||||
workspaceDraftContent
|
||||
]);
|
||||
|
||||
const handleUploadExternalSkill = useCallback(async (file) => {
|
||||
if (!(file instanceof File)) {
|
||||
setAgentSkillsFeedback({ type: "error", text: "请选择 zip 文件后再上传" });
|
||||
return;
|
||||
}
|
||||
if (!selectedSkillAgentId) {
|
||||
setAgentSkillsFeedback({ type: "error", text: "未选择目标 Agent" });
|
||||
return;
|
||||
}
|
||||
setAgentSkillsSavingKey(`${selectedSkillAgentId}:__upload__`);
|
||||
setAgentSkillsFeedback(null);
|
||||
try {
|
||||
const result = await uploadAgentSkillZip({
|
||||
agentId: selectedSkillAgentId,
|
||||
file,
|
||||
activate: true
|
||||
});
|
||||
setAgentSkillsFeedback({
|
||||
type: "success",
|
||||
text: `已上传并安装技能 ${result.skill_name || ""}`.trim()
|
||||
});
|
||||
requestAgentSkills(selectedSkillAgentId);
|
||||
} catch (error) {
|
||||
setAgentSkillsFeedback({
|
||||
type: "error",
|
||||
text: `上传失败: ${error.message || "未知错误"}`
|
||||
});
|
||||
} finally {
|
||||
setAgentSkillsSavingKey(null);
|
||||
}
|
||||
}, [
|
||||
requestAgentSkills,
|
||||
selectedSkillAgentId,
|
||||
setAgentSkillsFeedback,
|
||||
setAgentSkillsSavingKey
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
setWorkspaceDraftContent(selectedWorkspaceContent);
|
||||
}, [selectedWorkspaceContent, setWorkspaceDraftContent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentView !== "traders") {
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => {
|
||||
AGENTS.forEach((agent) => {
|
||||
if (!agentProfilesByAgent[agent.id]) {
|
||||
requestAgentProfile(agent.id);
|
||||
}
|
||||
if (!agentSkillsByAgent[agent.id]) {
|
||||
requestAgentSkills(agent.id);
|
||||
}
|
||||
if (!workspaceFilesByAgent[agent.id]?.["MEMORY.md"]) {
|
||||
requestWorkspaceFile(agent.id, "MEMORY.md");
|
||||
}
|
||||
});
|
||||
}, 300);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [
|
||||
agentProfilesByAgent,
|
||||
agentSkillsByAgent,
|
||||
connectionStatus,
|
||||
currentView,
|
||||
isConnected,
|
||||
requestAgentProfile,
|
||||
requestAgentSkills,
|
||||
requestWorkspaceFile,
|
||||
workspaceFilesByAgent
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentView !== "traders" || !selectedSkillAgentId) {
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => {
|
||||
if (!agentProfilesByAgent[selectedSkillAgentId]) {
|
||||
requestAgentProfile(selectedSkillAgentId);
|
||||
}
|
||||
if (!agentSkillsByAgent[selectedSkillAgentId]) {
|
||||
requestAgentSkills(selectedSkillAgentId);
|
||||
}
|
||||
if (selectedWorkspaceFile && !workspaceFilesByAgent[selectedSkillAgentId]?.[selectedWorkspaceFile]) {
|
||||
requestWorkspaceFile(selectedSkillAgentId, selectedWorkspaceFile);
|
||||
}
|
||||
}, 300);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [
|
||||
agentProfilesByAgent,
|
||||
agentSkillsByAgent,
|
||||
connectionStatus,
|
||||
currentView,
|
||||
isConnected,
|
||||
requestAgentProfile,
|
||||
requestAgentSkills,
|
||||
requestWorkspaceFile,
|
||||
selectedSkillAgentId,
|
||||
selectedWorkspaceFile,
|
||||
workspaceFilesByAgent
|
||||
]);
|
||||
|
||||
return {
|
||||
requestAgentSkills,
|
||||
requestAgentProfile,
|
||||
requestSkillDetail,
|
||||
requestWorkspaceFile,
|
||||
handleCreateLocalSkill,
|
||||
handleLocalSkillDraftChange,
|
||||
handleLocalSkillSave,
|
||||
handleLocalSkillDelete,
|
||||
handleRemoveSharedSkill,
|
||||
handleAgentSkillToggle,
|
||||
handleSkillAgentChange,
|
||||
handleWorkspaceFileChange,
|
||||
handleWorkspaceFileSave,
|
||||
handleUploadExternalSkill
|
||||
};
|
||||
}
|
||||
538
frontend/src/hooks/useRuntimeControls.js
Normal file
538
frontend/src/hooks/useRuntimeControls.js
Normal file
@@ -0,0 +1,538 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { INITIAL_TICKERS } from "../config/constants";
|
||||
import { startRuntime } from "../services/runtimeApi";
|
||||
import {
|
||||
buildRuntimeSummaryLabel,
|
||||
normalizeTickerSymbols,
|
||||
normalizeRuntimeWatchlistSymbols,
|
||||
parseWatchlistInput
|
||||
} from "../services/runtimeControls";
|
||||
import { useAgentStore } from "../store/agentStore";
|
||||
import { useRuntimeStore } from "../store/runtimeStore";
|
||||
|
||||
const DEFAULT_SCHEDULE_MODE = "daily";
|
||||
const DEFAULT_INTERVAL_MINUTES = "60";
|
||||
const DEFAULT_TRIGGER_TIME = "now";
|
||||
const DEFAULT_MAX_COMM_CYCLES = "2";
|
||||
const DEFAULT_INITIAL_CASH = "100000";
|
||||
const DEFAULT_MARGIN_REQUIREMENT = "0";
|
||||
const DEFAULT_MODE = "live";
|
||||
const DEFAULT_POLL_INTERVAL = "10";
|
||||
|
||||
export function useRuntimeControls({ clientRef, currentTickers, addSystemMessage }) {
|
||||
const {
|
||||
runtimeConfig,
|
||||
setRuntimeConfig,
|
||||
isWatchlistPanelOpen,
|
||||
setIsWatchlistPanelOpen,
|
||||
isRuntimeSettingsOpen,
|
||||
setIsRuntimeSettingsOpen,
|
||||
watchlistDraftSymbols,
|
||||
setWatchlistDraftSymbols,
|
||||
watchlistInputValue,
|
||||
setWatchlistInputValue,
|
||||
watchlistFeedback,
|
||||
setWatchlistFeedback,
|
||||
isWatchlistSaving,
|
||||
setIsWatchlistSaving,
|
||||
scheduleModeDraft,
|
||||
setScheduleModeDraft,
|
||||
intervalMinutesDraft,
|
||||
setIntervalMinutesDraft,
|
||||
triggerTimeDraft,
|
||||
setTriggerTimeDraft,
|
||||
maxCommCyclesDraft,
|
||||
setMaxCommCyclesDraft,
|
||||
initialCashDraft,
|
||||
setInitialCashDraft,
|
||||
marginRequirementDraft,
|
||||
setMarginRequirementDraft,
|
||||
enableMemoryDraft,
|
||||
setEnableMemoryDraft,
|
||||
modeDraft,
|
||||
setModeDraft,
|
||||
pollIntervalDraft,
|
||||
setPollIntervalDraft,
|
||||
startDateDraft,
|
||||
setStartDateDraft,
|
||||
endDateDraft,
|
||||
setEndDateDraft,
|
||||
enableMockDraft,
|
||||
setEnableMockDraft,
|
||||
runtimeConfigFeedback,
|
||||
setRuntimeConfigFeedback,
|
||||
isRuntimeConfigSaving,
|
||||
setIsRuntimeConfigSaving
|
||||
} = useRuntimeStore();
|
||||
|
||||
const {
|
||||
setAgentSkillsFeedback,
|
||||
setWorkspaceFileFeedback
|
||||
} = useAgentStore();
|
||||
|
||||
const isWatchlistSavingRef = useRef(false);
|
||||
const isRuntimeConfigSavingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
isWatchlistSavingRef.current = isWatchlistSaving;
|
||||
}, [isWatchlistSaving]);
|
||||
|
||||
useEffect(() => {
|
||||
isRuntimeConfigSavingRef.current = isRuntimeConfigSaving;
|
||||
}, [isRuntimeConfigSaving]);
|
||||
|
||||
const displayTickers = useMemo(
|
||||
() => normalizeTickerSymbols(runtimeConfig?.tickers, currentTickers),
|
||||
[currentTickers, runtimeConfig]
|
||||
);
|
||||
|
||||
const runtimeWatchlistSymbols = useMemo(
|
||||
() => normalizeRuntimeWatchlistSymbols(runtimeConfig, currentTickers),
|
||||
[currentTickers, runtimeConfig]
|
||||
);
|
||||
|
||||
const runtimeSummaryLabel = useMemo(
|
||||
() => buildRuntimeSummaryLabel(runtimeConfig),
|
||||
[runtimeConfig]
|
||||
);
|
||||
|
||||
const watchlistSuggestions = useMemo(
|
||||
() => INITIAL_TICKERS.map((ticker) => ticker.symbol).filter((symbol, index, list) => list.indexOf(symbol) === index),
|
||||
[]
|
||||
);
|
||||
|
||||
const isWatchlistDraftDirty = useMemo(() => {
|
||||
if (watchlistInputValue.trim()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (watchlistDraftSymbols.length !== runtimeWatchlistSymbols.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return watchlistDraftSymbols.some((symbol, index) => symbol !== runtimeWatchlistSymbols[index]);
|
||||
}, [runtimeWatchlistSymbols, watchlistDraftSymbols, watchlistInputValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if ((!isWatchlistPanelOpen && !isRuntimeSettingsOpen) || !isWatchlistDraftDirty) {
|
||||
setWatchlistDraftSymbols(runtimeWatchlistSymbols);
|
||||
if (!isWatchlistPanelOpen && !isRuntimeSettingsOpen) {
|
||||
setWatchlistInputValue("");
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isWatchlistDraftDirty,
|
||||
isWatchlistPanelOpen,
|
||||
isRuntimeSettingsOpen,
|
||||
runtimeWatchlistSymbols,
|
||||
setWatchlistDraftSymbols,
|
||||
setWatchlistInputValue
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!runtimeConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
setScheduleModeDraft(String(runtimeConfig.schedule_mode || DEFAULT_SCHEDULE_MODE));
|
||||
setIntervalMinutesDraft(String(runtimeConfig.interval_minutes || DEFAULT_INTERVAL_MINUTES));
|
||||
setTriggerTimeDraft(String(runtimeConfig.trigger_time || DEFAULT_TRIGGER_TIME));
|
||||
setMaxCommCyclesDraft(String(runtimeConfig.max_comm_cycles || DEFAULT_MAX_COMM_CYCLES));
|
||||
setInitialCashDraft(String(runtimeConfig.initial_cash ?? DEFAULT_INITIAL_CASH));
|
||||
setMarginRequirementDraft(String(runtimeConfig.margin_requirement ?? DEFAULT_MARGIN_REQUIREMENT));
|
||||
setEnableMemoryDraft(Boolean(runtimeConfig.enable_memory ?? false));
|
||||
}, [
|
||||
runtimeConfig,
|
||||
setEnableMemoryDraft,
|
||||
setInitialCashDraft,
|
||||
setIntervalMinutesDraft,
|
||||
setMarginRequirementDraft,
|
||||
setMaxCommCyclesDraft,
|
||||
setScheduleModeDraft,
|
||||
setTriggerTimeDraft
|
||||
]);
|
||||
|
||||
const commitWatchlistInput = useCallback((value) => {
|
||||
const parsed = parseWatchlistInput(value);
|
||||
if (parsed.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
setWatchlistDraftSymbols((prev) => Array.from(new Set([...prev, ...parsed])));
|
||||
setWatchlistInputValue("");
|
||||
if (watchlistFeedback) {
|
||||
setWatchlistFeedback(null);
|
||||
}
|
||||
return parsed;
|
||||
}, [setWatchlistDraftSymbols, setWatchlistInputValue, setWatchlistFeedback, watchlistFeedback]);
|
||||
|
||||
const handleWatchlistRemove = useCallback((symbolToRemove) => {
|
||||
setWatchlistDraftSymbols((prev) => prev.filter((symbol) => symbol !== symbolToRemove));
|
||||
if (watchlistFeedback) {
|
||||
setWatchlistFeedback(null);
|
||||
}
|
||||
}, [setWatchlistDraftSymbols, setWatchlistFeedback, watchlistFeedback]);
|
||||
|
||||
const handleWatchlistPanelToggle = useCallback(() => {
|
||||
setIsRuntimeSettingsOpen(false);
|
||||
setIsWatchlistPanelOpen((open) => {
|
||||
const nextOpen = !open;
|
||||
if (nextOpen) {
|
||||
setWatchlistDraftSymbols(runtimeWatchlistSymbols);
|
||||
setWatchlistInputValue("");
|
||||
setWatchlistFeedback(null);
|
||||
}
|
||||
return nextOpen;
|
||||
});
|
||||
}, [
|
||||
runtimeWatchlistSymbols,
|
||||
setIsRuntimeSettingsOpen,
|
||||
setIsWatchlistPanelOpen,
|
||||
setWatchlistDraftSymbols,
|
||||
setWatchlistFeedback,
|
||||
setWatchlistInputValue
|
||||
]);
|
||||
|
||||
const handleWatchlistInputChange = useCallback((value) => {
|
||||
setWatchlistInputValue(value);
|
||||
if (watchlistFeedback) {
|
||||
setWatchlistFeedback(null);
|
||||
}
|
||||
}, [setWatchlistInputValue, setWatchlistFeedback, watchlistFeedback]);
|
||||
|
||||
const handleWatchlistInputKeyDown = useCallback((event) => {
|
||||
if (event.key === "Enter" || event.key === ",") {
|
||||
event.preventDefault();
|
||||
commitWatchlistInput(watchlistInputValue);
|
||||
}
|
||||
}, [commitWatchlistInput, watchlistInputValue]);
|
||||
|
||||
const handleWatchlistSuggestionClick = useCallback((symbol) => {
|
||||
if (watchlistDraftSymbols.includes(symbol)) {
|
||||
return;
|
||||
}
|
||||
setWatchlistDraftSymbols((prev) => [...prev, symbol]);
|
||||
if (watchlistFeedback) {
|
||||
setWatchlistFeedback(null);
|
||||
}
|
||||
}, [setWatchlistDraftSymbols, watchlistDraftSymbols, setWatchlistFeedback, watchlistFeedback]);
|
||||
|
||||
const handleWatchlistRestoreCurrent = useCallback(() => {
|
||||
setWatchlistDraftSymbols(runtimeWatchlistSymbols);
|
||||
setWatchlistInputValue("");
|
||||
setWatchlistFeedback(null);
|
||||
}, [runtimeWatchlistSymbols, setWatchlistDraftSymbols, setWatchlistInputValue, setWatchlistFeedback]);
|
||||
|
||||
const handleWatchlistRestoreDefault = useCallback(() => {
|
||||
setWatchlistDraftSymbols(watchlistSuggestions);
|
||||
setWatchlistInputValue("");
|
||||
setWatchlistFeedback(null);
|
||||
}, [setWatchlistDraftSymbols, setWatchlistInputValue, setWatchlistFeedback, watchlistSuggestions]);
|
||||
|
||||
const handleWatchlistSave = useCallback(() => {
|
||||
const pendingTickers = parseWatchlistInput(watchlistInputValue);
|
||||
const nextTickers = Array.from(new Set([...watchlistDraftSymbols, ...pendingTickers]));
|
||||
if (nextTickers.length === 0) {
|
||||
setWatchlistFeedback({ type: "error", text: "至少输入 1 个有效股票代码" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!clientRef.current) {
|
||||
setWatchlistFeedback({ type: "error", text: "连接未就绪,稍后重试" });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsWatchlistSaving(true);
|
||||
setWatchlistFeedback(null);
|
||||
setWatchlistDraftSymbols(nextTickers);
|
||||
setWatchlistInputValue("");
|
||||
const success = clientRef.current.send({
|
||||
type: "update_watchlist",
|
||||
tickers: nextTickers
|
||||
});
|
||||
|
||||
if (!success) {
|
||||
setIsWatchlistSaving(false);
|
||||
setWatchlistFeedback({ type: "error", text: "发送失败,请检查连接状态" });
|
||||
}
|
||||
}, [
|
||||
clientRef,
|
||||
setIsWatchlistSaving,
|
||||
setWatchlistDraftSymbols,
|
||||
setWatchlistFeedback,
|
||||
setWatchlistInputValue,
|
||||
watchlistDraftSymbols,
|
||||
watchlistInputValue
|
||||
]);
|
||||
|
||||
const handleRuntimeConfigSave = useCallback(() => {
|
||||
if (!clientRef.current) {
|
||||
setRuntimeConfigFeedback({ type: "error", text: "连接未就绪,稍后重试" });
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = Number(intervalMinutesDraft);
|
||||
const maxCommCycles = Number(maxCommCyclesDraft);
|
||||
if (!Number.isInteger(interval) || interval <= 0) {
|
||||
setRuntimeConfigFeedback({ type: "error", text: "间隔必须是正整数分钟" });
|
||||
return;
|
||||
}
|
||||
if (!Number.isInteger(maxCommCycles) || maxCommCycles <= 0) {
|
||||
setRuntimeConfigFeedback({ type: "error", text: "讨论轮数必须是正整数" });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRuntimeConfigSaving(true);
|
||||
setRuntimeConfigFeedback(null);
|
||||
const success = clientRef.current.send({
|
||||
type: "update_runtime_config",
|
||||
schedule_mode: scheduleModeDraft,
|
||||
interval_minutes: interval,
|
||||
trigger_time: triggerTimeDraft,
|
||||
max_comm_cycles: maxCommCycles,
|
||||
initial_cash: Number(initialCashDraft),
|
||||
margin_requirement: Number(marginRequirementDraft),
|
||||
enable_memory: Boolean(enableMemoryDraft)
|
||||
});
|
||||
|
||||
if (!success) {
|
||||
setIsRuntimeConfigSaving(false);
|
||||
setRuntimeConfigFeedback({ type: "error", text: "发送失败,请检查连接状态" });
|
||||
}
|
||||
}, [
|
||||
clientRef,
|
||||
enableMemoryDraft,
|
||||
initialCashDraft,
|
||||
intervalMinutesDraft,
|
||||
marginRequirementDraft,
|
||||
maxCommCyclesDraft,
|
||||
scheduleModeDraft,
|
||||
setIsRuntimeConfigSaving,
|
||||
setRuntimeConfigFeedback,
|
||||
triggerTimeDraft
|
||||
]);
|
||||
|
||||
const handleLaunchConfigSave = useCallback(async () => {
|
||||
const pendingTickers = parseWatchlistInput(watchlistInputValue);
|
||||
const nextTickers = Array.from(new Set([...watchlistDraftSymbols, ...pendingTickers]));
|
||||
if (nextTickers.length === 0) {
|
||||
setRuntimeConfigFeedback({ type: "error", text: "至少输入 1 个有效股票代码" });
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = Number(intervalMinutesDraft);
|
||||
const maxCommCycles = Number(maxCommCyclesDraft);
|
||||
const initialCash = Number(initialCashDraft);
|
||||
const marginRequirement = Number(marginRequirementDraft);
|
||||
if (!Number.isInteger(interval) || interval <= 0) {
|
||||
setRuntimeConfigFeedback({ type: "error", text: "间隔必须是正整数分钟" });
|
||||
return;
|
||||
}
|
||||
if (!Number.isInteger(maxCommCycles) || maxCommCycles <= 0) {
|
||||
setRuntimeConfigFeedback({ type: "error", text: "讨论轮数必须是正整数" });
|
||||
return;
|
||||
}
|
||||
if (!Number.isFinite(initialCash) || initialCash <= 0) {
|
||||
setRuntimeConfigFeedback({ type: "error", text: "初始资金必须是正数" });
|
||||
return;
|
||||
}
|
||||
if (!Number.isFinite(marginRequirement) || marginRequirement < 0) {
|
||||
setRuntimeConfigFeedback({ type: "error", text: "保证金要求不能为负数" });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRuntimeConfigSaving(true);
|
||||
setIsWatchlistSaving(true);
|
||||
setRuntimeConfigFeedback(null);
|
||||
setWatchlistFeedback(null);
|
||||
setWatchlistDraftSymbols(nextTickers);
|
||||
setWatchlistInputValue("");
|
||||
|
||||
try {
|
||||
const result = await startRuntime({
|
||||
tickers: nextTickers,
|
||||
schedule_mode: scheduleModeDraft,
|
||||
interval_minutes: interval,
|
||||
trigger_time: triggerTimeDraft,
|
||||
max_comm_cycles: maxCommCycles,
|
||||
initial_cash: initialCash,
|
||||
margin_requirement: marginRequirement,
|
||||
enable_memory: Boolean(enableMemoryDraft),
|
||||
mode: modeDraft || DEFAULT_MODE,
|
||||
poll_interval: Number(pollIntervalDraft) || Number(DEFAULT_POLL_INTERVAL),
|
||||
start_date: startDateDraft || null,
|
||||
end_date: endDateDraft || null,
|
||||
enable_mock: Boolean(enableMockDraft)
|
||||
});
|
||||
|
||||
setIsRuntimeConfigSaving(false);
|
||||
setIsWatchlistSaving(false);
|
||||
setIsRuntimeSettingsOpen(false);
|
||||
setRuntimeConfigFeedback({
|
||||
type: "success",
|
||||
text: `任务已启动: ${result.run_id}`
|
||||
});
|
||||
addSystemMessage(`新任务已启动: ${result.run_id}`);
|
||||
} catch (error) {
|
||||
setIsRuntimeConfigSaving(false);
|
||||
setIsWatchlistSaving(false);
|
||||
setRuntimeConfigFeedback({
|
||||
type: "error",
|
||||
text: `启动失败: ${error.message}`
|
||||
});
|
||||
}
|
||||
}, [
|
||||
addSystemMessage,
|
||||
clientRef,
|
||||
enableMemoryDraft,
|
||||
enableMockDraft,
|
||||
endDateDraft,
|
||||
initialCashDraft,
|
||||
intervalMinutesDraft,
|
||||
marginRequirementDraft,
|
||||
maxCommCyclesDraft,
|
||||
modeDraft,
|
||||
pollIntervalDraft,
|
||||
scheduleModeDraft,
|
||||
setIsRuntimeConfigSaving,
|
||||
setIsRuntimeSettingsOpen,
|
||||
setIsWatchlistSaving,
|
||||
setRuntimeConfigFeedback,
|
||||
setWatchlistDraftSymbols,
|
||||
setWatchlistFeedback,
|
||||
setWatchlistInputValue,
|
||||
startDateDraft,
|
||||
triggerTimeDraft,
|
||||
watchlistDraftSymbols,
|
||||
watchlistInputValue
|
||||
]);
|
||||
|
||||
const handleRuntimeDefaultsRestore = useCallback(() => {
|
||||
setScheduleModeDraft(DEFAULT_SCHEDULE_MODE);
|
||||
setIntervalMinutesDraft(DEFAULT_INTERVAL_MINUTES);
|
||||
setTriggerTimeDraft(DEFAULT_TRIGGER_TIME);
|
||||
setMaxCommCyclesDraft(DEFAULT_MAX_COMM_CYCLES);
|
||||
setInitialCashDraft(DEFAULT_INITIAL_CASH);
|
||||
setMarginRequirementDraft(DEFAULT_MARGIN_REQUIREMENT);
|
||||
setEnableMemoryDraft(false);
|
||||
setModeDraft(DEFAULT_MODE);
|
||||
setPollIntervalDraft(DEFAULT_POLL_INTERVAL);
|
||||
setStartDateDraft("");
|
||||
setEndDateDraft("");
|
||||
setEnableMockDraft(false);
|
||||
setRuntimeConfigFeedback(null);
|
||||
}, [
|
||||
setEnableMemoryDraft,
|
||||
setEnableMockDraft,
|
||||
setEndDateDraft,
|
||||
setInitialCashDraft,
|
||||
setIntervalMinutesDraft,
|
||||
setMarginRequirementDraft,
|
||||
setMaxCommCyclesDraft,
|
||||
setModeDraft,
|
||||
setPollIntervalDraft,
|
||||
setRuntimeConfigFeedback,
|
||||
setScheduleModeDraft,
|
||||
setStartDateDraft,
|
||||
setTriggerTimeDraft
|
||||
]);
|
||||
|
||||
const handleRuntimeSettingsToggle = useCallback(() => {
|
||||
setRuntimeConfigFeedback(null);
|
||||
setAgentSkillsFeedback(null);
|
||||
setWorkspaceFileFeedback(null);
|
||||
setIsRuntimeSettingsOpen((prev) => {
|
||||
const nextOpen = !prev;
|
||||
if (nextOpen) {
|
||||
setWatchlistDraftSymbols(runtimeWatchlistSymbols);
|
||||
setWatchlistInputValue("");
|
||||
setWatchlistFeedback(null);
|
||||
}
|
||||
return nextOpen;
|
||||
});
|
||||
setIsWatchlistPanelOpen(false);
|
||||
}, [
|
||||
runtimeWatchlistSymbols,
|
||||
setAgentSkillsFeedback,
|
||||
setIsRuntimeSettingsOpen,
|
||||
setIsWatchlistPanelOpen,
|
||||
setRuntimeConfigFeedback,
|
||||
setWatchlistDraftSymbols,
|
||||
setWatchlistFeedback,
|
||||
setWatchlistInputValue,
|
||||
setWorkspaceFileFeedback
|
||||
]);
|
||||
|
||||
const handleRuntimeSettingsClose = useCallback(() => {
|
||||
setIsRuntimeSettingsOpen(false);
|
||||
}, [setIsRuntimeSettingsOpen]);
|
||||
|
||||
const handleWatchlistAdd = useCallback(() => commitWatchlistInput(watchlistInputValue), [commitWatchlistInput, watchlistInputValue]);
|
||||
|
||||
return {
|
||||
runtimeConfig,
|
||||
displayTickers,
|
||||
runtimeWatchlistSymbols,
|
||||
runtimeSummaryLabel,
|
||||
watchlistSuggestions,
|
||||
isWatchlistDraftDirty,
|
||||
isWatchlistPanelOpen,
|
||||
isRuntimeSettingsOpen,
|
||||
watchlistDraftSymbols,
|
||||
watchlistInputValue,
|
||||
watchlistFeedback,
|
||||
isWatchlistSaving,
|
||||
scheduleModeDraft,
|
||||
intervalMinutesDraft,
|
||||
triggerTimeDraft,
|
||||
maxCommCyclesDraft,
|
||||
initialCashDraft,
|
||||
marginRequirementDraft,
|
||||
enableMemoryDraft,
|
||||
modeDraft,
|
||||
pollIntervalDraft,
|
||||
startDateDraft,
|
||||
endDateDraft,
|
||||
enableMockDraft,
|
||||
runtimeConfigFeedback,
|
||||
isRuntimeConfigSaving,
|
||||
isWatchlistSavingRef,
|
||||
isRuntimeConfigSavingRef,
|
||||
commitWatchlistInput,
|
||||
handleWatchlistRemove,
|
||||
handleWatchlistPanelToggle,
|
||||
handleWatchlistInputChange,
|
||||
handleWatchlistInputKeyDown,
|
||||
handleWatchlistSuggestionClick,
|
||||
handleWatchlistRestoreCurrent,
|
||||
handleWatchlistRestoreDefault,
|
||||
handleWatchlistSave,
|
||||
handleWatchlistAdd,
|
||||
handleRuntimeConfigSave,
|
||||
handleLaunchConfigSave,
|
||||
handleRuntimeDefaultsRestore,
|
||||
handleRuntimeSettingsToggle,
|
||||
handleRuntimeSettingsClose,
|
||||
setRuntimeConfig,
|
||||
setWatchlistDraftSymbols,
|
||||
setWatchlistInputValue,
|
||||
setWatchlistFeedback,
|
||||
setRuntimeConfigFeedback,
|
||||
setIsWatchlistPanelOpen,
|
||||
setIsRuntimeSettingsOpen,
|
||||
setScheduleModeDraft,
|
||||
setIntervalMinutesDraft,
|
||||
setTriggerTimeDraft,
|
||||
setMaxCommCyclesDraft,
|
||||
setInitialCashDraft,
|
||||
setMarginRequirementDraft,
|
||||
setEnableMemoryDraft,
|
||||
setModeDraft,
|
||||
setPollIntervalDraft,
|
||||
setStartDateDraft,
|
||||
setEndDateDraft,
|
||||
setEnableMockDraft,
|
||||
setIsWatchlistSaving,
|
||||
setIsRuntimeConfigSaving
|
||||
};
|
||||
}
|
||||
352
frontend/src/hooks/useStockDataRequests.js
Normal file
352
frontend/src/hooks/useStockDataRequests.js
Normal file
@@ -0,0 +1,352 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useMarketStore } from '../store/marketStore';
|
||||
import { useRuntimeStore } from '../store/runtimeStore';
|
||||
import {
|
||||
fetchNewsCategoriesDirect,
|
||||
fetchNewsForDateDirect,
|
||||
fetchRangeExplainDirect,
|
||||
fetchSimilarDaysDirect,
|
||||
fetchStockStoryDirect,
|
||||
hasDirectNewsService
|
||||
} from '../services/newsApi';
|
||||
import {
|
||||
fetchInsiderTradesDirect,
|
||||
fetchStockHistoryDirect,
|
||||
hasDirectTradingService
|
||||
} from '../services/tradingApi';
|
||||
|
||||
/**
|
||||
* Custom hook for stock data request callbacks.
|
||||
* Takes clientRef, calls store setters directly.
|
||||
*/
|
||||
export function useStockDataRequests(clientRef, { setRequestStockHistory, setRequestStockNewsTimeline, setRequestStockNewsCategories }) {
|
||||
const requestedStockHistoryRef = useRef(new Set());
|
||||
|
||||
const { currentDate } = useRuntimeStore();
|
||||
const { setOhlcHistoryByTicker, setPriceHistoryByTicker, setHistorySourceByTicker,
|
||||
setNewsByTicker, setInsiderTradesByTicker } = useMarketStore();
|
||||
|
||||
const requestStockHistory = useCallback((symbol, { force = false } = {}) => {
|
||||
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||
if (!normalized) return false;
|
||||
|
||||
if (!force && requestedStockHistoryRef.current.has(normalized)) return false;
|
||||
|
||||
const endDate = currentDate
|
||||
? String(currentDate).slice(0, 10)
|
||||
: new Date().toISOString().slice(0, 10);
|
||||
const end = new Date(`${endDate}T00:00:00`);
|
||||
const start = new Date(end);
|
||||
start.setDate(start.getDate() - 120);
|
||||
const startDate = start.toISOString().slice(0, 10);
|
||||
|
||||
if (hasDirectTradingService()) {
|
||||
void fetchStockHistoryDirect(normalized, startDate, endDate)
|
||||
.then((payload) => {
|
||||
const prices = Array.isArray(payload?.prices) ? payload.prices : [];
|
||||
setOhlcHistoryByTicker((prev) => ({ ...prev, [normalized]: prices }));
|
||||
setPriceHistoryByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: prices
|
||||
.map((point) => {
|
||||
const price = Number(point?.close);
|
||||
const timestamp = point?.time;
|
||||
if (!timestamp || !Number.isFinite(price)) return null;
|
||||
return { timestamp: String(timestamp), label: String(timestamp), price };
|
||||
})
|
||||
.filter(Boolean)
|
||||
}));
|
||||
setHistorySourceByTicker((prev) => ({ ...prev, [normalized]: 'trading_service' }));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Direct stock-history fetch failed, falling back to websocket:', error);
|
||||
if (clientRef.current) {
|
||||
const success = clientRef.current.send({
|
||||
type: 'get_stock_history',
|
||||
ticker: normalized,
|
||||
lookback_days: 120
|
||||
});
|
||||
if (success) requestedStockHistoryRef.current.add(normalized);
|
||||
}
|
||||
});
|
||||
requestedStockHistoryRef.current.add(normalized);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) return false;
|
||||
const success = clientRef.current.send({ type: 'get_stock_history', ticker: normalized, lookback_days: 120 });
|
||||
if (success) requestedStockHistoryRef.current.add(normalized);
|
||||
return success;
|
||||
}, [clientRef, currentDate, setOhlcHistoryByTicker, setPriceHistoryByTicker, setHistorySourceByTicker]);
|
||||
|
||||
const requestStockExplainEvents = useCallback((symbol) => {
|
||||
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||
if (!normalized || !clientRef.current) return false;
|
||||
return clientRef.current.send({ type: 'get_stock_explain_events', ticker: normalized });
|
||||
}, [clientRef]);
|
||||
|
||||
const requestStockNews = useCallback((symbol) => {
|
||||
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||
if (!normalized || !clientRef.current) return false;
|
||||
return clientRef.current.send({ type: 'get_stock_news', ticker: normalized, lookback_days: 45, limit: 12 });
|
||||
}, [clientRef]);
|
||||
|
||||
const requestStockNewsForDate = useCallback((symbol, date) => {
|
||||
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||
if (!normalized || !date) return false;
|
||||
|
||||
if (hasDirectNewsService()) {
|
||||
void fetchNewsForDateDirect(normalized, date, 20)
|
||||
.then((payload) => {
|
||||
const targetDate = typeof payload?.date === 'string' ? payload.date.trim() : date;
|
||||
const news = Array.isArray(payload?.news) ? payload.news : [];
|
||||
const freshness = payload?.freshness || null;
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: {
|
||||
...(prev[normalized] || {}),
|
||||
byDate: { ...((prev[normalized] && prev[normalized].byDate) || {}), [targetDate]: news },
|
||||
byDateFreshness: { ...((prev[normalized] && prev[normalized].byDateFreshness) || {}), [targetDate]: freshness }
|
||||
}
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Direct news-for-date fetch failed, falling back to websocket:', error);
|
||||
if (clientRef.current) {
|
||||
clientRef.current.send({ type: 'get_stock_news_for_date', ticker: normalized, date, limit: 20 });
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) return false;
|
||||
return clientRef.current.send({ type: 'get_stock_news_for_date', ticker: normalized, date, limit: 20 });
|
||||
}, [clientRef, setNewsByTicker]);
|
||||
|
||||
const requestStockNewsTimeline = useCallback((symbol) => {
|
||||
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||
if (!normalized || !clientRef.current) return false;
|
||||
return clientRef.current.send({ type: 'get_stock_news_timeline', ticker: normalized, lookback_days: 90 });
|
||||
}, [clientRef]);
|
||||
|
||||
const requestStockNewsCategories = useCallback((symbol) => {
|
||||
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||
if (!normalized) return false;
|
||||
|
||||
const endDate = currentDate
|
||||
? String(currentDate).slice(0, 10)
|
||||
: new Date().toISOString().slice(0, 10);
|
||||
const end = new Date(`${endDate}T00:00:00`);
|
||||
const start = new Date(end);
|
||||
start.setDate(start.getDate() - 90);
|
||||
const startDate = start.toISOString().slice(0, 10);
|
||||
|
||||
if (hasDirectNewsService()) {
|
||||
void fetchNewsCategoriesDirect(normalized, startDate, endDate, 200)
|
||||
.then((payload) => {
|
||||
const freshness = payload?.freshness || null;
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: {
|
||||
...(prev[normalized] || {}),
|
||||
categories: payload?.categories || {},
|
||||
categoriesStartDate: startDate,
|
||||
categoriesEndDate: endDate,
|
||||
categoriesFreshness: freshness
|
||||
}
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Direct news-categories fetch failed, falling back to websocket:', error);
|
||||
if (clientRef.current) {
|
||||
clientRef.current.send({ type: 'get_stock_news_categories', ticker: normalized, lookback_days: 90 });
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) return false;
|
||||
return clientRef.current.send({ type: 'get_stock_news_categories', ticker: normalized, lookback_days: 90 });
|
||||
}, [clientRef, currentDate, setNewsByTicker]);
|
||||
|
||||
const requestStockInsiderTrades = useCallback((symbol, startDate = null, endDate = null) => {
|
||||
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||
if (!normalized) return false;
|
||||
|
||||
if (hasDirectTradingService()) {
|
||||
void fetchInsiderTradesDirect(normalized, startDate, endDate, 50)
|
||||
.then((payload) => {
|
||||
const rows = Array.isArray(payload?.insider_trades) ? payload.insider_trades : [];
|
||||
setInsiderTradesByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: { ticker: normalized, startDate, endDate, trades: rows }
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Direct insider-trades fetch failed, falling back to websocket:', error);
|
||||
if (clientRef.current) {
|
||||
clientRef.current.send({ type: 'get_stock_insider_trades', ticker: normalized, start_date: startDate, end_date: endDate, limit: 50 });
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) return false;
|
||||
return clientRef.current.send({ type: 'get_stock_insider_trades', ticker: normalized, start_date: startDate, end_date: endDate, limit: 50 });
|
||||
}, [clientRef, setInsiderTradesByTicker]);
|
||||
|
||||
const requestStockTechnicalIndicators = useCallback((symbol) => {
|
||||
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||
if (!normalized || !clientRef.current) return false;
|
||||
return clientRef.current.send({ type: 'get_stock_technical_indicators', ticker: normalized });
|
||||
}, [clientRef]);
|
||||
|
||||
const requestStockRangeExplain = useCallback((symbol, startDate, endDate, articleIds = []) => {
|
||||
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||
if (!normalized || !startDate || !endDate) return false;
|
||||
|
||||
if (hasDirectNewsService()) {
|
||||
void fetchRangeExplainDirect(normalized, startDate, endDate, articleIds)
|
||||
.then((payload) => {
|
||||
const result = payload?.result && typeof payload.result === 'object' ? payload.result : null;
|
||||
const freshness = payload?.freshness || null;
|
||||
if (!result?.start_date || !result?.end_date) return;
|
||||
const cacheKey = `${result.start_date}:${result.end_date}`;
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: {
|
||||
...(prev[normalized] || {}),
|
||||
rangeExplainCache: {
|
||||
...((prev[normalized] && prev[normalized].rangeExplainCache) || {}),
|
||||
[cacheKey]: { ...result, freshness }
|
||||
}
|
||||
}
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Direct range explain fetch failed, falling back to websocket:', error);
|
||||
if (clientRef.current) {
|
||||
clientRef.current.send({ type: 'get_stock_range_explain', ticker: normalized, start_date: startDate, end_date: endDate, article_ids: Array.isArray(articleIds) ? articleIds : [] });
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) return false;
|
||||
return clientRef.current.send({ type: 'get_stock_range_explain', ticker: normalized, start_date: startDate, end_date: endDate, article_ids: Array.isArray(articleIds) ? articleIds : [] });
|
||||
}, [clientRef, setNewsByTicker]);
|
||||
|
||||
const requestStockStory = useCallback((symbol, asOfDate = null) => {
|
||||
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||
if (!normalized) return false;
|
||||
|
||||
if (hasDirectNewsService()) {
|
||||
void fetchStockStoryDirect(normalized, asOfDate)
|
||||
.then((payload) => {
|
||||
const storyDate = typeof payload?.as_of_date === 'string' ? payload.as_of_date.trim() : '';
|
||||
const freshness = payload?.freshness || null;
|
||||
if (!storyDate) return;
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: {
|
||||
...(prev[normalized] || {}),
|
||||
storyCache: {
|
||||
...((prev[normalized] && prev[normalized].storyCache) || {}),
|
||||
[storyDate]: { story: payload.story || '', source: payload.source || 'news_service', asOfDate: storyDate, freshness }
|
||||
}
|
||||
}
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Direct story fetch failed, falling back to websocket:', error);
|
||||
if (clientRef.current) {
|
||||
clientRef.current.send({ type: 'get_stock_story', ticker: normalized, as_of_date: asOfDate });
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) return false;
|
||||
return clientRef.current.send({ type: 'get_stock_story', ticker: normalized, as_of_date: asOfDate });
|
||||
}, [clientRef, setNewsByTicker]);
|
||||
|
||||
const requestStockSimilarDays = useCallback((symbol, date, topK = 8) => {
|
||||
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||
if (!normalized || !date) return false;
|
||||
|
||||
if (hasDirectNewsService()) {
|
||||
void fetchSimilarDaysDirect(normalized, date, topK)
|
||||
.then((payload) => {
|
||||
const targetDate = typeof payload?.target_date === 'string' ? payload.target_date.trim() : date;
|
||||
if (!targetDate) return;
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: {
|
||||
...(prev[normalized] || {}),
|
||||
similarDaysCache: {
|
||||
...((prev[normalized] && prev[normalized].similarDaysCache) || {}),
|
||||
[targetDate]: payload
|
||||
}
|
||||
}
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Direct similar-days fetch failed, falling back to websocket:', error);
|
||||
if (clientRef.current) {
|
||||
clientRef.current.send({ type: 'get_stock_similar_days', ticker: normalized, date, top_k: topK });
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) return false;
|
||||
return clientRef.current.send({ type: 'get_stock_similar_days', ticker: normalized, date, top_k: topK });
|
||||
}, [clientRef, setNewsByTicker]);
|
||||
|
||||
const requestStockEnrich = useCallback((symbol, options = {}) => {
|
||||
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||
if (!normalized || !clientRef.current) return false;
|
||||
const startDate = typeof options.startDate === 'string' ? options.startDate.trim() : '';
|
||||
const endDate = typeof options.endDate === 'string' ? options.endDate.trim() : '';
|
||||
if (!startDate || !endDate) return false;
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: {
|
||||
...(prev[normalized] || {}),
|
||||
maintenanceStatus: { running: true, error: null, updatedAt: new Date().toISOString(), stats: null }
|
||||
}
|
||||
}));
|
||||
return clientRef.current.send({
|
||||
type: 'run_stock_enrich',
|
||||
ticker: normalized,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
force: Boolean(options.force),
|
||||
only_local_to_llm: Boolean(options.onlyLocalToLlm),
|
||||
rebuild_story: Boolean(options.rebuildStory),
|
||||
rebuild_similar_days: Boolean(options.rebuildSimilarDays),
|
||||
story_date: options.storyDate || null,
|
||||
target_date: options.targetDate || null
|
||||
});
|
||||
}, [clientRef, setNewsByTicker]);
|
||||
|
||||
// Register request functions with WebSocket connection hook
|
||||
if (setRequestStockHistory) setRequestStockHistory(requestStockHistory);
|
||||
if (setRequestStockNewsTimeline) setRequestStockNewsTimeline(requestStockNewsTimeline);
|
||||
if (setRequestStockNewsCategories) setRequestStockNewsCategories(requestStockNewsCategories);
|
||||
|
||||
return {
|
||||
requestStockHistory,
|
||||
requestStockExplainEvents,
|
||||
requestStockNews,
|
||||
requestStockNewsForDate,
|
||||
requestStockNewsTimeline,
|
||||
requestStockNewsCategories,
|
||||
requestStockInsiderTrades,
|
||||
requestStockTechnicalIndicators,
|
||||
requestStockRangeExplain,
|
||||
requestStockStory,
|
||||
requestStockSimilarDays,
|
||||
requestStockEnrich
|
||||
};
|
||||
}
|
||||
546
frontend/src/hooks/useStockExplainData.js
Normal file
546
frontend/src/hooks/useStockExplainData.js
Normal file
@@ -0,0 +1,546 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
|
||||
import {
|
||||
fetchNewsCategoriesDirect,
|
||||
fetchNewsForDateDirect,
|
||||
fetchRangeExplainDirect,
|
||||
fetchSimilarDaysDirect,
|
||||
fetchStockStoryDirect,
|
||||
hasDirectNewsService
|
||||
} from "../services/newsApi";
|
||||
import {
|
||||
fetchInsiderTradesDirect,
|
||||
fetchStockHistoryDirect,
|
||||
hasDirectTradingService
|
||||
} from "../services/tradingApi";
|
||||
|
||||
export function useStockExplainData({
|
||||
clientRef,
|
||||
currentDate,
|
||||
currentView,
|
||||
selectedExplainSymbol,
|
||||
requestedStockHistoryRef,
|
||||
setOhlcHistoryByTicker,
|
||||
setPriceHistoryByTicker,
|
||||
setHistorySourceByTicker,
|
||||
setNewsByTicker,
|
||||
setInsiderTradesByTicker
|
||||
}) {
|
||||
const requestStockHistory = useCallback((symbol, { force = false } = {}) => {
|
||||
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!force && requestedStockHistoryRef.current.has(normalized)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const endDate = currentDate
|
||||
? String(currentDate).slice(0, 10)
|
||||
: new Date().toISOString().slice(0, 10);
|
||||
const end = new Date(`${endDate}T00:00:00`);
|
||||
const start = new Date(end);
|
||||
start.setDate(start.getDate() - 120);
|
||||
const startDate = start.toISOString().slice(0, 10);
|
||||
|
||||
if (hasDirectTradingService()) {
|
||||
void fetchStockHistoryDirect(normalized, startDate, endDate)
|
||||
.then((payload) => {
|
||||
const prices = Array.isArray(payload?.prices) ? payload.prices : [];
|
||||
setOhlcHistoryByTicker((prev) => ({ ...prev, [normalized]: prices }));
|
||||
setPriceHistoryByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: prices
|
||||
.map((point) => {
|
||||
const price = Number(point?.close);
|
||||
const timestamp = point?.time;
|
||||
if (!timestamp || !Number.isFinite(price)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
timestamp: String(timestamp),
|
||||
label: String(timestamp),
|
||||
price
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
}));
|
||||
setHistorySourceByTicker((prev) => ({ ...prev, [normalized]: "trading_service" }));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Direct stock-history fetch failed, falling back to websocket:", error);
|
||||
if (clientRef.current) {
|
||||
const success = clientRef.current.send({
|
||||
type: "get_stock_history",
|
||||
ticker: normalized,
|
||||
lookback_days: 120
|
||||
});
|
||||
if (success) {
|
||||
requestedStockHistoryRef.current.add(normalized);
|
||||
}
|
||||
}
|
||||
});
|
||||
requestedStockHistoryRef.current.add(normalized);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const success = clientRef.current.send({
|
||||
type: "get_stock_history",
|
||||
ticker: normalized,
|
||||
lookback_days: 120
|
||||
});
|
||||
|
||||
if (success) {
|
||||
requestedStockHistoryRef.current.add(normalized);
|
||||
}
|
||||
|
||||
return success;
|
||||
}, [
|
||||
clientRef,
|
||||
currentDate,
|
||||
requestedStockHistoryRef,
|
||||
setHistorySourceByTicker,
|
||||
setOhlcHistoryByTicker,
|
||||
setPriceHistoryByTicker
|
||||
]);
|
||||
|
||||
const requestStockExplainEvents = useCallback((symbol) => {
|
||||
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||
if (!normalized || !clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
return clientRef.current.send({
|
||||
type: "get_stock_explain_events",
|
||||
ticker: normalized
|
||||
});
|
||||
}, [clientRef]);
|
||||
|
||||
const requestStockNews = useCallback((symbol) => {
|
||||
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||
if (!normalized || !clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
return clientRef.current.send({
|
||||
type: "get_stock_news",
|
||||
ticker: normalized,
|
||||
lookback_days: 45,
|
||||
limit: 12
|
||||
});
|
||||
}, [clientRef]);
|
||||
|
||||
const requestStockNewsForDate = useCallback((symbol, date) => {
|
||||
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||
if (!normalized || !date) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasDirectNewsService()) {
|
||||
void fetchNewsForDateDirect(normalized, date, 20)
|
||||
.then((payload) => {
|
||||
const targetDate = typeof payload?.date === "string" ? payload.date.trim() : date;
|
||||
const news = Array.isArray(payload?.news) ? payload.news : [];
|
||||
const freshness = payload?.freshness || null;
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: {
|
||||
...(prev[normalized] || {}),
|
||||
byDate: {
|
||||
...((prev[normalized] && prev[normalized].byDate) || {}),
|
||||
[targetDate]: news
|
||||
},
|
||||
byDateFreshness: {
|
||||
...((prev[normalized] && prev[normalized].byDateFreshness) || {}),
|
||||
[targetDate]: freshness
|
||||
}
|
||||
}
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Direct news-for-date fetch failed, falling back to websocket:", error);
|
||||
if (clientRef.current) {
|
||||
clientRef.current.send({
|
||||
type: "get_stock_news_for_date",
|
||||
ticker: normalized,
|
||||
date,
|
||||
limit: 20
|
||||
});
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return clientRef.current.send({
|
||||
type: "get_stock_news_for_date",
|
||||
ticker: normalized,
|
||||
date,
|
||||
limit: 20
|
||||
});
|
||||
}, [clientRef, setNewsByTicker]);
|
||||
|
||||
const requestStockNewsTimeline = useCallback((symbol) => {
|
||||
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||
if (!normalized || !clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
return clientRef.current.send({
|
||||
type: "get_stock_news_timeline",
|
||||
ticker: normalized,
|
||||
lookback_days: 90
|
||||
});
|
||||
}, [clientRef]);
|
||||
|
||||
const requestStockNewsCategories = useCallback((symbol) => {
|
||||
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const endDate = currentDate
|
||||
? String(currentDate).slice(0, 10)
|
||||
: new Date().toISOString().slice(0, 10);
|
||||
const end = new Date(`${endDate}T00:00:00`);
|
||||
const start = new Date(end);
|
||||
start.setDate(start.getDate() - 90);
|
||||
const startDate = start.toISOString().slice(0, 10);
|
||||
|
||||
if (hasDirectNewsService()) {
|
||||
void fetchNewsCategoriesDirect(normalized, startDate, endDate, 200)
|
||||
.then((payload) => {
|
||||
const freshness = payload?.freshness || null;
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: {
|
||||
...(prev[normalized] || {}),
|
||||
categories: payload?.categories || {},
|
||||
categoriesStartDate: startDate,
|
||||
categoriesEndDate: endDate,
|
||||
categoriesFreshness: freshness
|
||||
}
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Direct news-categories fetch failed, falling back to websocket:", error);
|
||||
if (clientRef.current) {
|
||||
clientRef.current.send({
|
||||
type: "get_stock_news_categories",
|
||||
ticker: normalized,
|
||||
lookback_days: 90
|
||||
});
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return clientRef.current.send({
|
||||
type: "get_stock_news_categories",
|
||||
ticker: normalized,
|
||||
lookback_days: 90
|
||||
});
|
||||
}, [clientRef, currentDate, setNewsByTicker]);
|
||||
|
||||
const requestStockInsiderTrades = useCallback((symbol, startDate = null, endDate = null) => {
|
||||
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasDirectTradingService()) {
|
||||
void fetchInsiderTradesDirect(normalized, startDate, endDate, 50)
|
||||
.then((payload) => {
|
||||
const rows = Array.isArray(payload?.insider_trades) ? payload.insider_trades : [];
|
||||
setInsiderTradesByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: {
|
||||
ticker: normalized,
|
||||
startDate: startDate || null,
|
||||
endDate: endDate || null,
|
||||
trades: rows
|
||||
}
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Direct insider-trades fetch failed, falling back to websocket:", error);
|
||||
if (clientRef.current) {
|
||||
clientRef.current.send({
|
||||
type: "get_stock_insider_trades",
|
||||
ticker: normalized,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
limit: 50
|
||||
});
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return clientRef.current.send({
|
||||
type: "get_stock_insider_trades",
|
||||
ticker: normalized,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
limit: 50
|
||||
});
|
||||
}, [clientRef, setInsiderTradesByTicker]);
|
||||
|
||||
const requestStockTechnicalIndicators = useCallback((symbol) => {
|
||||
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||
if (!normalized || !clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
return clientRef.current.send({
|
||||
type: "get_stock_technical_indicators",
|
||||
ticker: normalized
|
||||
});
|
||||
}, [clientRef]);
|
||||
|
||||
const requestStockRangeExplain = useCallback((symbol, startDate, endDate, articleIds = []) => {
|
||||
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||
if (!normalized || !startDate || !endDate) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasDirectNewsService()) {
|
||||
void fetchRangeExplainDirect(normalized, startDate, endDate, articleIds)
|
||||
.then((payload) => {
|
||||
const result = payload?.result && typeof payload.result === "object" ? payload.result : null;
|
||||
const freshness = payload?.freshness || null;
|
||||
if (!result?.start_date || !result?.end_date) {
|
||||
return;
|
||||
}
|
||||
const cacheKey = `${result.start_date}:${result.end_date}`;
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: {
|
||||
...(prev[normalized] || {}),
|
||||
rangeExplainCache: {
|
||||
...((prev[normalized] && prev[normalized].rangeExplainCache) || {}),
|
||||
[cacheKey]: {
|
||||
...result,
|
||||
freshness
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Direct range explain fetch failed, falling back to websocket:", error);
|
||||
if (clientRef.current) {
|
||||
clientRef.current.send({
|
||||
type: "get_stock_range_explain",
|
||||
ticker: normalized,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
article_ids: Array.isArray(articleIds) ? articleIds : []
|
||||
});
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return clientRef.current.send({
|
||||
type: "get_stock_range_explain",
|
||||
ticker: normalized,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
article_ids: Array.isArray(articleIds) ? articleIds : []
|
||||
});
|
||||
}, [clientRef, setNewsByTicker]);
|
||||
|
||||
const requestStockStory = useCallback((symbol, asOfDate = null) => {
|
||||
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasDirectNewsService()) {
|
||||
void fetchStockStoryDirect(normalized, asOfDate)
|
||||
.then((payload) => {
|
||||
const storyDate = typeof payload?.as_of_date === "string" ? payload.as_of_date.trim() : "";
|
||||
const freshness = payload?.freshness || null;
|
||||
if (!storyDate) {
|
||||
return;
|
||||
}
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: {
|
||||
...(prev[normalized] || {}),
|
||||
storyCache: {
|
||||
...((prev[normalized] && prev[normalized].storyCache) || {}),
|
||||
[storyDate]: {
|
||||
story: payload.story || "",
|
||||
source: payload.source || "news_service",
|
||||
asOfDate: storyDate,
|
||||
freshness
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Direct story fetch failed, falling back to websocket:", error);
|
||||
if (clientRef.current) {
|
||||
clientRef.current.send({
|
||||
type: "get_stock_story",
|
||||
ticker: normalized,
|
||||
as_of_date: asOfDate
|
||||
});
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return clientRef.current.send({
|
||||
type: "get_stock_story",
|
||||
ticker: normalized,
|
||||
as_of_date: asOfDate
|
||||
});
|
||||
}, [clientRef, setNewsByTicker]);
|
||||
|
||||
const requestStockSimilarDays = useCallback((symbol, date, topK = 8) => {
|
||||
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||
if (!normalized || !date) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasDirectNewsService()) {
|
||||
void fetchSimilarDaysDirect(normalized, date, topK)
|
||||
.then((payload) => {
|
||||
const targetDate = typeof payload?.target_date === "string" ? payload.target_date.trim() : date;
|
||||
if (!targetDate) {
|
||||
return;
|
||||
}
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: {
|
||||
...(prev[normalized] || {}),
|
||||
similarDaysCache: {
|
||||
...((prev[normalized] && prev[normalized].similarDaysCache) || {}),
|
||||
[targetDate]: payload
|
||||
}
|
||||
}
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Direct similar-days fetch failed, falling back to websocket:", error);
|
||||
if (clientRef.current) {
|
||||
clientRef.current.send({
|
||||
type: "get_stock_similar_days",
|
||||
ticker: normalized,
|
||||
date,
|
||||
top_k: topK
|
||||
});
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return clientRef.current.send({
|
||||
type: "get_stock_similar_days",
|
||||
ticker: normalized,
|
||||
date,
|
||||
top_k: topK
|
||||
});
|
||||
}, [clientRef, setNewsByTicker]);
|
||||
|
||||
const requestStockEnrich = useCallback((symbol, options = {}) => {
|
||||
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||
if (!normalized || !clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
const startDate = typeof options.startDate === "string" ? options.startDate.trim() : "";
|
||||
const endDate = typeof options.endDate === "string" ? options.endDate.trim() : "";
|
||||
if (!startDate || !endDate) {
|
||||
return false;
|
||||
}
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: {
|
||||
...(prev[normalized] || {}),
|
||||
maintenanceStatus: {
|
||||
running: true,
|
||||
error: null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
stats: null
|
||||
}
|
||||
}
|
||||
}));
|
||||
return clientRef.current.send({
|
||||
type: "run_stock_enrich",
|
||||
ticker: normalized,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
force: Boolean(options.force),
|
||||
only_local_to_llm: Boolean(options.onlyLocalToLlm),
|
||||
rebuild_story: Boolean(options.rebuildStory),
|
||||
rebuild_similar_days: Boolean(options.rebuildSimilarDays),
|
||||
story_date: options.storyDate || null,
|
||||
target_date: options.targetDate || null
|
||||
});
|
||||
}, [clientRef, setNewsByTicker]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentView !== "explain" || !selectedExplainSymbol) {
|
||||
return;
|
||||
}
|
||||
requestStockHistory(selectedExplainSymbol);
|
||||
requestStockExplainEvents(selectedExplainSymbol);
|
||||
requestStockNews(selectedExplainSymbol);
|
||||
requestStockNewsTimeline(selectedExplainSymbol);
|
||||
requestStockNewsCategories(selectedExplainSymbol);
|
||||
requestStockStory(selectedExplainSymbol, currentDate);
|
||||
}, [
|
||||
currentDate,
|
||||
currentView,
|
||||
requestStockExplainEvents,
|
||||
requestStockHistory,
|
||||
requestStockNews,
|
||||
requestStockNewsCategories,
|
||||
requestStockNewsTimeline,
|
||||
requestStockStory,
|
||||
selectedExplainSymbol
|
||||
]);
|
||||
|
||||
return {
|
||||
requestStockHistory,
|
||||
requestStockExplainEvents,
|
||||
requestStockNews,
|
||||
requestStockNewsForDate,
|
||||
requestStockNewsTimeline,
|
||||
requestStockNewsCategories,
|
||||
requestStockInsiderTrades,
|
||||
requestStockTechnicalIndicators,
|
||||
requestStockRangeExplain,
|
||||
requestStockStory,
|
||||
requestStockSimilarDays,
|
||||
requestStockEnrich
|
||||
};
|
||||
}
|
||||
875
frontend/src/hooks/useWebSocketConnection.js
Normal file
875
frontend/src/hooks/useWebSocketConnection.js
Normal file
@@ -0,0 +1,875 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { AGENTS } from '../config/constants';
|
||||
import { ReadOnlyClient } from '../services/websocket';
|
||||
import { useRuntimeStore } from '../store/runtimeStore';
|
||||
import { useMarketStore } from '../store/marketStore';
|
||||
import { usePortfolioStore } from '../store/portfolioStore';
|
||||
import { useAgentStore } from '../store/agentStore';
|
||||
import { useUIStore } from '../store/uiStore';
|
||||
import { normalizeTickerSymbols } from '../services/runtimeControls';
|
||||
|
||||
/**
|
||||
* Normalize price history from server format
|
||||
*/
|
||||
function normalizePriceHistory(payload) {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return {};
|
||||
}
|
||||
|
||||
const normalized = {};
|
||||
Object.entries(payload).forEach(([symbol, points]) => {
|
||||
const ticker = String(symbol || '').trim().toUpperCase();
|
||||
if (!ticker || !Array.isArray(points)) {
|
||||
return;
|
||||
}
|
||||
|
||||
normalized[ticker] = points
|
||||
.map((point) => {
|
||||
if (Array.isArray(point) && point.length >= 2) {
|
||||
const [label, value] = point;
|
||||
const price = Number(value);
|
||||
if (!label || !Number.isFinite(price)) return null;
|
||||
return { timestamp: String(label), label: String(label), price };
|
||||
}
|
||||
|
||||
if (point && typeof point === 'object') {
|
||||
const rawTimestamp = point.timestamp ?? point.t ?? point.date ?? point.label;
|
||||
const price = Number(point.price ?? point.v ?? point.value ?? point.close);
|
||||
if (!rawTimestamp || !Number.isFinite(price)) return null;
|
||||
return { timestamp: String(rawTimestamp), label: String(rawTimestamp), price };
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.slice(-120);
|
||||
});
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build tickers from symbols array
|
||||
*/
|
||||
function buildTickersFromSymbols(symbols, previousTickers = []) {
|
||||
if (!Array.isArray(symbols) || symbols.length === 0) {
|
||||
return previousTickers;
|
||||
}
|
||||
|
||||
return symbols
|
||||
.filter((symbol) => typeof symbol === 'string' && symbol.trim())
|
||||
.map((symbol) => {
|
||||
const normalized = symbol.trim().toUpperCase();
|
||||
const existing = previousTickers.find((ticker) => ticker.symbol === normalized);
|
||||
return existing || { symbol: normalized, price: null, change: null };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for WebSocket connection lifecycle and event handling.
|
||||
* Manages clientRef, connection, and ALL event handlers.
|
||||
* Feeds directly into stores (no props drilling).
|
||||
*/
|
||||
export function useWebSocketConnection({
|
||||
processHistoricalFeed,
|
||||
processFeedEvent,
|
||||
addSystemMessage
|
||||
}) {
|
||||
const clientRef = useRef(null);
|
||||
const isWatchlistSavingRef = useRef(false);
|
||||
const isRuntimeConfigSavingRef = useRef(false);
|
||||
const selectedSkillAgentIdRef = useRef(null);
|
||||
const requestedStockHistoryRef = useRef(new Set());
|
||||
|
||||
// Store state
|
||||
const { setIsConnected, setConnectionStatus, setSystemStatus, setCurrentDate,
|
||||
setServerMode, setDataSources, setRuntimeConfig, setMarketStatus,
|
||||
setVirtualTime, setProgress, watchlistDraftSymbols, setWatchlistInputValue,
|
||||
setIsWatchlistSaving, setWatchlistFeedback, setIsRuntimeConfigSaving,
|
||||
setRuntimeConfigFeedback, isWatchlistSaving, isRuntimeConfigSaving,
|
||||
setLastDayHistory } = useRuntimeStore();
|
||||
|
||||
const { tickers, setTickers, setRollingTickers, setPriceHistoryByTicker,
|
||||
setExplainEventsByTicker, setNewsByTicker, setInsiderTradesByTicker,
|
||||
setTechnicalIndicatorsByTicker, setHistorySourceByTicker,
|
||||
setOhlcHistoryByTicker } = useMarketStore();
|
||||
|
||||
const { setPortfolioData, setHoldings, setTrades, setStats, setLeaderboard } = usePortfolioStore();
|
||||
|
||||
const { setAgentSkillsByAgent, setAgentProfilesByAgent, setSkillDetailsByName,
|
||||
setLocalSkillDraftsByKey, setIsAgentSkillsLoading, setSkillDetailLoadingKey,
|
||||
setAgentSkillsSavingKey, setAgentSkillsFeedback, setIsWorkspaceFileLoading,
|
||||
setWorkspaceFileSavingKey, setWorkspaceFilesByAgent, setWorkspaceFileFeedback,
|
||||
selectedSkillAgentId } = useAgentStore();
|
||||
|
||||
const { setBubbles } = useUIStore();
|
||||
|
||||
// Helper: Update tickers from realtime prices
|
||||
const updateTickersFromPrices = useCallback((realtimePrices) => {
|
||||
try {
|
||||
setTickers((prevTickers) => prevTickers.map((ticker) => {
|
||||
const realtimeData = realtimePrices[ticker.symbol];
|
||||
if (realtimeData && realtimeData.price !== null && realtimeData.price !== undefined) {
|
||||
const newChange = (realtimeData.ret !== null && realtimeData.ret !== undefined)
|
||||
? realtimeData.ret
|
||||
: (ticker.change !== null && ticker.change !== undefined ? ticker.change : 0);
|
||||
|
||||
return {
|
||||
...ticker,
|
||||
price: realtimeData.price,
|
||||
change: newChange,
|
||||
open: realtimeData.open || ticker.open
|
||||
};
|
||||
}
|
||||
return ticker;
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error updating tickers from prices:', error);
|
||||
}
|
||||
}, [setTickers]);
|
||||
|
||||
// Stock request callbacks (these will be provided by useStockDataRequests)
|
||||
const requestStockHistoryRef = useRef(null);
|
||||
const requestStockNewsTimelineRef = useRef(null);
|
||||
const requestStockNewsCategoriesRef = useRef(null);
|
||||
|
||||
const setRequestStockHistory = useCallback((fn) => {
|
||||
requestStockHistoryRef.current = fn;
|
||||
}, []);
|
||||
|
||||
const setRequestStockNewsTimeline = useCallback((fn) => {
|
||||
requestStockNewsTimelineRef.current = fn;
|
||||
}, []);
|
||||
|
||||
const setRequestStockNewsCategories = useCallback((fn) => {
|
||||
requestStockNewsCategoriesRef.current = fn;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePushEvent = (evt) => {
|
||||
if (!evt) return;
|
||||
|
||||
try {
|
||||
handleEventInternal(evt);
|
||||
} catch (error) {
|
||||
console.error('[Event Handler] Error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEventInternal = (evt) => {
|
||||
if (evt?.type && evt.type !== 'pong') {
|
||||
setConnectionStatus('connected');
|
||||
setIsConnected(true);
|
||||
}
|
||||
|
||||
const handlers = {
|
||||
error: (e) => {
|
||||
const message = typeof e.message === 'string' ? e.message : '请求失败';
|
||||
console.error('[Error]', message);
|
||||
setIsAgentSkillsLoading(false);
|
||||
setSkillDetailLoadingKey(null);
|
||||
setAgentSkillsSavingKey(null);
|
||||
setIsWorkspaceFileLoading(false);
|
||||
setWorkspaceFileSavingKey(null);
|
||||
if (isWatchlistSavingRef.current) {
|
||||
setIsWatchlistSaving(false);
|
||||
setWatchlistFeedback({ type: 'error', text: message || '更新 watchlist 失败' });
|
||||
}
|
||||
if (isRuntimeConfigSavingRef.current) {
|
||||
setIsRuntimeConfigSaving(false);
|
||||
setRuntimeConfigFeedback({ type: 'error', text: message });
|
||||
}
|
||||
if (message.includes('skill') || message.includes('agent_id')) {
|
||||
setAgentSkillsFeedback({ type: 'error', text: message || '更新技能失败' });
|
||||
}
|
||||
if (message.includes('workspace_file') || message.includes('filename')) {
|
||||
setWorkspaceFileFeedback({ type: 'error', text: message || '更新工作区文件失败' });
|
||||
}
|
||||
if (message.includes('fast forward')) {
|
||||
console.warn(`⚠️ ${message}`);
|
||||
handlePushEvent({ type: 'system', content: `⚠️ ${message}`, timestamp: Date.now() });
|
||||
}
|
||||
addSystemMessage(message);
|
||||
},
|
||||
|
||||
system: (e) => {
|
||||
console.log('[System]', e.content);
|
||||
if (e.content.includes('Connected') || e.content.includes('已连接')) {
|
||||
setConnectionStatus('connected');
|
||||
setIsConnected(true);
|
||||
} else if (e.content.includes('Disconnected') || e.content.includes('断开')) {
|
||||
setConnectionStatus('disconnected');
|
||||
setIsConnected(false);
|
||||
}
|
||||
processFeedEvent(e);
|
||||
},
|
||||
|
||||
pong: () => {
|
||||
console.log('[Heartbeat] Pong received');
|
||||
},
|
||||
|
||||
initial_state: (e) => {
|
||||
try {
|
||||
const state = e.state;
|
||||
if (!state) return;
|
||||
|
||||
setConnectionStatus('connected');
|
||||
setIsConnected(true);
|
||||
setSystemStatus(state.status || 'initializing');
|
||||
setCurrentDate(state.current_date);
|
||||
|
||||
if (state.server_mode) setServerMode(state.server_mode);
|
||||
if (state.data_sources) setDataSources(state.data_sources);
|
||||
if (state.runtime_config) setRuntimeConfig(state.runtime_config);
|
||||
if (Array.isArray(state.tickers) && state.tickers.length > 0) {
|
||||
setTickers((prevTickers) => buildTickersFromSymbols(state.tickers, prevTickers));
|
||||
}
|
||||
|
||||
const isMockMode = state.is_mock_mode === true;
|
||||
if (state.market_status) {
|
||||
setMarketStatus(state.market_status);
|
||||
if (isMockMode && state.market_status.current_time) {
|
||||
try {
|
||||
setVirtualTime(new Date(state.market_status.current_time));
|
||||
} catch (error) {
|
||||
console.error('Error parsing virtual time from market_status:', error);
|
||||
}
|
||||
} else {
|
||||
setVirtualTime(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (state.trading_days_total) {
|
||||
setProgress({
|
||||
current: state.trading_days_completed || 0,
|
||||
total: state.trading_days_total
|
||||
});
|
||||
}
|
||||
|
||||
if (state.portfolio) {
|
||||
setPortfolioData((prev) => ({
|
||||
...prev,
|
||||
netValue: state.portfolio.total_value || prev.netValue,
|
||||
pnl: state.portfolio.pnl_percent || 0,
|
||||
equity: state.portfolio.equity || prev.equity,
|
||||
baseline: state.portfolio.baseline || prev.baseline,
|
||||
baseline_vw: state.portfolio.baseline_vw || prev.baseline_vw,
|
||||
momentum: state.portfolio.momentum || prev.momentum,
|
||||
strategies: state.portfolio.strategies || prev.strategies,
|
||||
equity_return: state.portfolio.equity_return || prev.equity_return,
|
||||
baseline_return: state.portfolio.baseline_return || prev.baseline_return,
|
||||
baseline_vw_return: state.portfolio.baseline_vw_return || prev.baseline_vw_return,
|
||||
momentum_return: state.portfolio.momentum_return || prev.momentum_return
|
||||
}));
|
||||
}
|
||||
|
||||
if (state.dashboard) {
|
||||
if (state.dashboard.holdings) setHoldings(state.dashboard.holdings);
|
||||
if (state.dashboard.trades) setTrades(state.dashboard.trades);
|
||||
if (state.dashboard.stats) setStats(state.dashboard.stats);
|
||||
if (state.dashboard.leaderboard) setLeaderboard(state.dashboard.leaderboard);
|
||||
}
|
||||
if (state.realtime_prices) updateTickersFromPrices(state.realtime_prices);
|
||||
if (state.price_history) {
|
||||
setPriceHistoryByTicker(normalizePriceHistory(state.price_history));
|
||||
}
|
||||
|
||||
if (state.feed_history && Array.isArray(state.feed_history)) {
|
||||
console.log(`✅ Loading ${state.feed_history.length} historical events`);
|
||||
processHistoricalFeed(state.feed_history);
|
||||
}
|
||||
|
||||
if (state.last_day_history && Array.isArray(state.last_day_history)) {
|
||||
setLastDayHistory(state.last_day_history);
|
||||
console.log(`✅ Loaded ${state.last_day_history.length} last day events for replay`);
|
||||
}
|
||||
|
||||
console.log('Initial state loaded');
|
||||
} catch (error) {
|
||||
console.error('Error loading initial state:', error);
|
||||
}
|
||||
},
|
||||
|
||||
market_status_update: (e) => {
|
||||
if (e.market_status) setMarketStatus(e.market_status);
|
||||
},
|
||||
|
||||
data_sources_update: (e) => {
|
||||
if (e.data_sources) setDataSources(e.data_sources);
|
||||
},
|
||||
|
||||
runtime_assets_reloaded: (e) => {
|
||||
if (e.runtime_config_applied) setRuntimeConfig(e.runtime_config_applied);
|
||||
if (Array.isArray(e.runtime_config_applied?.tickers)) {
|
||||
setTickers((prevTickers) =>
|
||||
buildTickersFromSymbols(e.runtime_config_applied.tickers, prevTickers)
|
||||
);
|
||||
setWatchlistInputValue('');
|
||||
}
|
||||
if (isWatchlistSavingRef.current) setIsWatchlistSaving(false);
|
||||
if (isRuntimeConfigSavingRef.current) {
|
||||
setIsRuntimeConfigSaving(false);
|
||||
setRuntimeConfigFeedback({ type: 'success', text: '运行配置已保存并生效' });
|
||||
}
|
||||
const warnings = Array.isArray(e.runtime_config_warnings) ? e.runtime_config_warnings : [];
|
||||
warnings.forEach((warning) => addSystemMessage(warning));
|
||||
addSystemMessage('运行时配置已热更新');
|
||||
},
|
||||
|
||||
agent_skills_loaded: (e) => {
|
||||
const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
|
||||
if (!agentId) {
|
||||
setIsAgentSkillsLoading(false);
|
||||
return;
|
||||
}
|
||||
setAgentSkillsByAgent((prev) => ({ ...prev, [agentId]: Array.isArray(e.skills) ? e.skills : [] }));
|
||||
setIsAgentSkillsLoading(false);
|
||||
setAgentSkillsSavingKey(null);
|
||||
},
|
||||
|
||||
agent_profile_loaded: (e) => {
|
||||
const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
|
||||
if (!agentId) return;
|
||||
setAgentProfilesByAgent((prev) => ({
|
||||
...prev,
|
||||
[agentId]: e.profile && typeof e.profile === 'object' ? e.profile : {}
|
||||
}));
|
||||
},
|
||||
|
||||
skill_detail_loaded: (e) => {
|
||||
const skillName = typeof e.skill?.skill_name === 'string' ? e.skill.skill_name.trim() : '';
|
||||
const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : selectedSkillAgentIdRef.current;
|
||||
if (!skillName) {
|
||||
setSkillDetailLoadingKey(null);
|
||||
return;
|
||||
}
|
||||
const detailKey = `${agentId}:${skillName}`;
|
||||
setSkillDetailsByName((prev) => ({ ...prev, [detailKey]: e.skill }));
|
||||
setLocalSkillDraftsByKey((prev) => ({
|
||||
...prev,
|
||||
[detailKey]: typeof e.skill?.content === 'string' ? e.skill.content : ''
|
||||
}));
|
||||
setSkillDetailLoadingKey(null);
|
||||
},
|
||||
|
||||
agent_skill_updated: (e) => {
|
||||
const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
|
||||
const skillName = typeof e.skill_name === 'string' ? e.skill_name.trim() : '';
|
||||
if (!agentId || !skillName) return;
|
||||
setAgentSkillsFeedback({
|
||||
type: 'success',
|
||||
text: `${agentId} ${e.enabled ? '已启用' : '已禁用'} ${skillName}`
|
||||
});
|
||||
},
|
||||
|
||||
agent_local_skill_created: (e) => {
|
||||
const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
|
||||
const skillName = typeof e.skill_name === 'string' ? e.skill_name.trim() : '';
|
||||
setAgentSkillsSavingKey(null);
|
||||
if (!agentId || !skillName) return;
|
||||
setAgentSkillsFeedback({ type: 'success', text: `${agentId} 已创建本地技能 ${skillName}` });
|
||||
},
|
||||
|
||||
agent_local_skill_updated: (e) => {
|
||||
const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
|
||||
const skillName = typeof e.skill_name === 'string' ? e.skill_name.trim() : '';
|
||||
setAgentSkillsSavingKey(null);
|
||||
if (!agentId || !skillName) return;
|
||||
setAgentSkillsFeedback({ type: 'success', text: `${agentId} 的本地技能 ${skillName} 已保存` });
|
||||
},
|
||||
|
||||
agent_local_skill_deleted: (e) => {
|
||||
const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
|
||||
const skillName = typeof e.skill_name === 'string' ? e.skill_name.trim() : '';
|
||||
setAgentSkillsSavingKey(null);
|
||||
if (!agentId || !skillName) return;
|
||||
setSkillDetailsByName((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[`${agentId}:${skillName}`];
|
||||
return next;
|
||||
});
|
||||
setLocalSkillDraftsByKey((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[`${agentId}:${skillName}`];
|
||||
return next;
|
||||
});
|
||||
setAgentSkillsFeedback({ type: 'success', text: `${agentId} 的本地技能 ${skillName} 已删除` });
|
||||
},
|
||||
|
||||
agent_skill_removed: (e) => {
|
||||
const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
|
||||
const skillName = typeof e.skill_name === 'string' ? e.skill_name.trim() : '';
|
||||
setAgentSkillsSavingKey(null);
|
||||
if (!agentId || !skillName) return;
|
||||
setAgentSkillsFeedback({ type: 'success', text: `${agentId} 已移除共享技能 ${skillName}` });
|
||||
},
|
||||
|
||||
agent_workspace_file_loaded: (e) => {
|
||||
const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
|
||||
const filename = typeof e.filename === 'string' ? e.filename.trim() : '';
|
||||
if (!agentId || !filename) {
|
||||
setIsWorkspaceFileLoading(false);
|
||||
return;
|
||||
}
|
||||
setWorkspaceFilesByAgent((prev) => ({
|
||||
...prev,
|
||||
[agentId]: { ...(prev[agentId] || {}), [filename]: typeof e.content === 'string' ? e.content : '' }
|
||||
}));
|
||||
setIsWorkspaceFileLoading(false);
|
||||
setWorkspaceFileSavingKey(null);
|
||||
},
|
||||
|
||||
agent_workspace_file_updated: (e) => {
|
||||
const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
|
||||
const filename = typeof e.filename === 'string' ? e.filename.trim() : '';
|
||||
if (!agentId || !filename) return;
|
||||
setWorkspaceFileFeedback({ type: 'success', text: `${agentId} 的 ${filename} 已保存` });
|
||||
},
|
||||
|
||||
watchlist_updated: (e) => {
|
||||
if (Array.isArray(e.tickers)) {
|
||||
const normalizedTickers = e.tickers.map((symbol) => String(symbol).trim().toUpperCase());
|
||||
setRuntimeConfig((prev) => ({ ...(prev || {}), tickers: normalizedTickers }));
|
||||
setTickers((prevTickers) => buildTickersFromSymbols(normalizedTickers, prevTickers));
|
||||
}
|
||||
setIsWatchlistSaving(false);
|
||||
setWatchlistFeedback({
|
||||
type: 'success',
|
||||
text: `已更新为 ${Array.isArray(e.tickers) ? e.tickers.join(', ') : '最新列表'}`
|
||||
});
|
||||
},
|
||||
|
||||
stock_history_loaded: (e) => {
|
||||
const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
|
||||
if (!symbol) return;
|
||||
if (Array.isArray(e.prices)) {
|
||||
setOhlcHistoryByTicker((prev) => ({ ...prev, [symbol]: e.prices }));
|
||||
setHistorySourceByTicker((prev) => ({ ...prev, [symbol]: e.source || null }));
|
||||
}
|
||||
},
|
||||
|
||||
stock_explain_events_loaded: (e) => {
|
||||
const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
|
||||
if (!symbol) return;
|
||||
setExplainEventsByTicker((prev) => ({
|
||||
...prev,
|
||||
[symbol]: {
|
||||
events: Array.isArray(e.events) ? e.events : [],
|
||||
signals: Array.isArray(e.signals) ? e.signals : [],
|
||||
trades: Array.isArray(e.trades) ? e.trades : []
|
||||
}
|
||||
}));
|
||||
},
|
||||
|
||||
stock_news_loaded: (e) => {
|
||||
const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
|
||||
if (!symbol) return;
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[symbol]: {
|
||||
...(prev[symbol] || {}),
|
||||
items: Array.isArray(e.news) ? e.news : [],
|
||||
source: e.source || null,
|
||||
startDate: e.start_date || null,
|
||||
endDate: e.end_date || null,
|
||||
freshness: e.freshness || null
|
||||
}
|
||||
}));
|
||||
if (requestStockNewsTimelineRef.current) requestStockNewsTimelineRef.current(symbol);
|
||||
},
|
||||
|
||||
stock_news_for_date_loaded: (e) => {
|
||||
const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
|
||||
const date = typeof e.date === 'string' ? e.date.trim() : '';
|
||||
if (!symbol || !date) return;
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[symbol]: {
|
||||
...(prev[symbol] || {}),
|
||||
byDate: { ...((prev[symbol] && prev[symbol].byDate) || {}), [date]: Array.isArray(e.news) ? e.news : [] },
|
||||
byDateFreshness: { ...((prev[symbol] && prev[symbol].byDateFreshness) || {}), [date]: e.freshness || null }
|
||||
}
|
||||
}));
|
||||
},
|
||||
|
||||
stock_news_timeline_loaded: (e) => {
|
||||
const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
|
||||
if (!symbol) return;
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[symbol]: {
|
||||
...(prev[symbol] || {}),
|
||||
timeline: Array.isArray(e.timeline) ? e.timeline : [],
|
||||
timelineStartDate: e.start_date || null,
|
||||
timelineEndDate: e.end_date || null,
|
||||
timelineFreshness: e.freshness || null
|
||||
}
|
||||
}));
|
||||
},
|
||||
|
||||
stock_news_categories_loaded: (e) => {
|
||||
const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
|
||||
if (!symbol) return;
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[symbol]: {
|
||||
...(prev[symbol] || {}),
|
||||
categories: e.categories || {},
|
||||
categoriesStartDate: e.start_date || null,
|
||||
categoriesEndDate: e.end_date || null,
|
||||
categoriesFreshness: e.freshness || null
|
||||
}
|
||||
}));
|
||||
},
|
||||
|
||||
stock_insider_trades_loaded: (e) => {
|
||||
const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
|
||||
if (!symbol) return;
|
||||
setInsiderTradesByTicker((prev) => ({
|
||||
...prev,
|
||||
[symbol]: { trades: Array.isArray(e.trades) ? e.trades : [], startDate: e.start_date || null, endDate: e.end_date || null }
|
||||
}));
|
||||
},
|
||||
|
||||
stock_technical_indicators_loaded: (e) => {
|
||||
const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
|
||||
if (!symbol) return;
|
||||
setTechnicalIndicatorsByTicker((prev) => ({ ...prev, [symbol]: e.indicators || null }));
|
||||
},
|
||||
|
||||
stock_range_explain_loaded: (e) => {
|
||||
const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
|
||||
if (!symbol) return;
|
||||
const result = e.result && typeof e.result === 'object' ? e.result : null;
|
||||
if (!result?.start_date || !result?.end_date) return;
|
||||
const cacheKey = `${result.start_date}:${result.end_date}`;
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[symbol]: {
|
||||
...(prev[symbol] || {}),
|
||||
rangeExplainCache: {
|
||||
...((prev[symbol] && prev[symbol].rangeExplainCache) || {}),
|
||||
[cacheKey]: { ...result, freshness: e.freshness || null }
|
||||
}
|
||||
}
|
||||
}));
|
||||
},
|
||||
|
||||
stock_story_loaded: (e) => {
|
||||
const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
|
||||
const asOfDate = typeof e.as_of_date === 'string' ? e.as_of_date.trim() : '';
|
||||
if (!symbol || !asOfDate) return;
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[symbol]: {
|
||||
...(prev[symbol] || {}),
|
||||
storyCache: {
|
||||
...((prev[symbol] && prev[symbol].storyCache) || {}),
|
||||
[asOfDate]: { story: e.story || '', source: e.source || null, asOfDate, freshness: e.freshness || null }
|
||||
}
|
||||
}
|
||||
}));
|
||||
},
|
||||
|
||||
stock_similar_days_loaded: (e) => {
|
||||
const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
|
||||
const date = typeof e.target_date === 'string' ? e.target_date.trim() : typeof e.date === 'string' ? e.date.trim() : '';
|
||||
if (!symbol || !date) return;
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[symbol]: {
|
||||
...(prev[symbol] || {}),
|
||||
similarDaysCache: {
|
||||
...((prev[symbol] && prev[symbol].similarDaysCache) || {}),
|
||||
[date]: {
|
||||
target_features: e.target_features || {},
|
||||
items: Array.isArray(e.items) ? e.items : [],
|
||||
error: e.error || null,
|
||||
freshness: e.freshness || null
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
},
|
||||
|
||||
stock_enrich_completed: (e) => {
|
||||
const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
|
||||
if (!symbol) return;
|
||||
const completedAt = new Date().toISOString();
|
||||
const historyEntry = {
|
||||
timestamp: completedAt,
|
||||
startDate: e.start_date || '',
|
||||
endDate: e.end_date || '',
|
||||
force: Boolean(e.force),
|
||||
onlyLocalToLlm: Boolean(e.only_local_to_llm),
|
||||
error: e.error || null,
|
||||
stats: e.stats || null,
|
||||
storyStatus: e.story_status || null,
|
||||
similarStatus: e.similar_status || null
|
||||
};
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[symbol]: {
|
||||
...(prev[symbol] || {}),
|
||||
items: [], byDate: {}, timeline: [], categories: {},
|
||||
rangeExplainCache: {}, storyCache: {}, similarDaysCache: {},
|
||||
maintenanceStatus: { running: false, error: e.error || null, updatedAt: completedAt, stats: e.stats || null, storyStatus: e.story_status || null, similarStatus: e.similar_status || null },
|
||||
maintenanceHistory: [historyEntry, ...(((prev[symbol] && prev[symbol].maintenanceHistory) || []).slice(0, 7))]
|
||||
}
|
||||
}));
|
||||
if (!e.error) {
|
||||
if (requestStockHistoryRef.current) requestStockHistoryRef.current(symbol);
|
||||
if (requestStockNewsTimelineRef.current) requestStockNewsTimelineRef.current(symbol);
|
||||
if (requestStockNewsCategoriesRef.current) requestStockNewsCategoriesRef.current(symbol);
|
||||
}
|
||||
},
|
||||
|
||||
price_update: (e) => {
|
||||
try {
|
||||
const { symbol, price, ret, open, portfolio, realtime_prices } = e;
|
||||
if (!symbol || !price) {
|
||||
console.warn('[Price Update] Missing symbol or price:', e);
|
||||
return;
|
||||
}
|
||||
|
||||
setConnectionStatus('connected');
|
||||
setIsConnected(true);
|
||||
console.log(`[Price Update] ${symbol}: $${price} (ret: ${ret !== undefined ? ret.toFixed(2) : 'N/A'}%)`);
|
||||
|
||||
setPriceHistoryByTicker((prev) => {
|
||||
const ticker = String(symbol).trim().toUpperCase();
|
||||
const nextPoint = { timestamp: new Date().toISOString(), label: new Date().toISOString(), price: Number(price) };
|
||||
const existing = Array.isArray(prev[ticker]) ? prev[ticker] : [];
|
||||
const lastPoint = existing[existing.length - 1];
|
||||
if (lastPoint && Number(lastPoint.price) === Number(nextPoint.price)) return prev;
|
||||
return { ...prev, [ticker]: [...existing, nextPoint].slice(-120) };
|
||||
});
|
||||
|
||||
const normalizedSymbol = String(symbol).trim().toUpperCase();
|
||||
let shouldAnimateTicker = false;
|
||||
setTickers((prevTickers) => prevTickers.map((ticker) => {
|
||||
if (ticker.symbol === symbol) {
|
||||
const oldPrice = ticker.price;
|
||||
let newChange = ticker.change;
|
||||
if (ret !== null && ret !== undefined) {
|
||||
newChange = ret;
|
||||
} else if (oldPrice !== null && oldPrice !== undefined && isFinite(oldPrice)) {
|
||||
const priceChange = ((price - oldPrice) / oldPrice) * 100;
|
||||
newChange = (newChange !== null && newChange !== undefined) ? newChange + priceChange : priceChange;
|
||||
} else {
|
||||
newChange = 0;
|
||||
}
|
||||
if (oldPrice !== price) shouldAnimateTicker = true;
|
||||
return { ...ticker, price, change: newChange, open: open || ticker.open };
|
||||
}
|
||||
return ticker;
|
||||
}));
|
||||
|
||||
if (shouldAnimateTicker) {
|
||||
setRollingTickers((prev) => ({ ...prev, [normalizedSymbol]: true }));
|
||||
setTimeout(() => setRollingTickers((prev) => ({ ...prev, [normalizedSymbol]: false })), 500);
|
||||
}
|
||||
|
||||
if (realtime_prices) updateTickersFromPrices(realtime_prices);
|
||||
if (portfolio && portfolio.total_value) {
|
||||
setPortfolioData((prev) => ({
|
||||
...prev,
|
||||
netValue: portfolio.total_value,
|
||||
pnl: portfolio.pnl_percent || 0,
|
||||
equity: portfolio.equity || prev.equity
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Price Update] Error:', error);
|
||||
}
|
||||
},
|
||||
|
||||
day_start: (e) => {
|
||||
setCurrentDate(e.date);
|
||||
if (e.progress !== undefined) {
|
||||
setProgress((prev) => ({ ...prev, current: Math.floor(e.progress * (prev.total || 1)) }));
|
||||
}
|
||||
setSystemStatus('running');
|
||||
processFeedEvent(e);
|
||||
},
|
||||
|
||||
day_complete: (e) => {
|
||||
const result = e.result;
|
||||
if (result && typeof result === 'object') {
|
||||
if (result.portfolio_summary) {
|
||||
const summary = result.portfolio_summary;
|
||||
setPortfolioData((prev) => {
|
||||
const newEquity = [...prev.equity];
|
||||
const dateObj = new Date(e.date);
|
||||
newEquity.push({ t: dateObj.getTime(), v: summary.total_value || summary.cash || prev.netValue });
|
||||
return { ...prev, netValue: summary.total_value || summary.cash || prev.netValue, pnl: summary.pnl_percent || 0, equity: newEquity };
|
||||
});
|
||||
}
|
||||
}
|
||||
processFeedEvent(e);
|
||||
},
|
||||
|
||||
day_error: (e) => {
|
||||
console.error('Day error:', e.date, e.error);
|
||||
processFeedEvent(e);
|
||||
},
|
||||
|
||||
conference_start: (e) => processFeedEvent(e),
|
||||
conference_end: (e) => processFeedEvent(e),
|
||||
|
||||
agent_message: (e) => {
|
||||
const agent = AGENTS.find((item) => item.id === e.agentId);
|
||||
setBubbles({ [e.agentId]: { text: e.content, ts: Date.now(), agentName: agent?.name || e.agentName || e.agentId } });
|
||||
processFeedEvent(e);
|
||||
},
|
||||
|
||||
conference_message: (e) => {
|
||||
const agent = AGENTS.find((item) => item.id === e.agentId);
|
||||
setBubbles({ [e.agentId]: { text: e.content, ts: Date.now(), agentName: agent?.name || e.agentName || e.agentId } });
|
||||
processFeedEvent(e);
|
||||
},
|
||||
|
||||
memory: (e) => processFeedEvent(e),
|
||||
|
||||
team_summary: (e) => {
|
||||
setPortfolioData((prev) => ({
|
||||
...prev,
|
||||
netValue: e.balance || prev.netValue,
|
||||
pnl: e.pnlPct || 0,
|
||||
equity: e.equity || prev.equity,
|
||||
baseline: e.baseline || prev.baseline,
|
||||
baseline_vw: e.baseline_vw || prev.baseline_vw,
|
||||
momentum: e.momentum || prev.momentum,
|
||||
equity_return: e.equity_return || prev.equity_return,
|
||||
baseline_return: e.baseline_return || prev.baseline_return,
|
||||
baseline_vw_return: e.baseline_vw_return || prev.baseline_vw_return,
|
||||
momentum_return: e.momentum_return || prev.momentum_return
|
||||
}));
|
||||
},
|
||||
|
||||
team_portfolio: (e) => {
|
||||
if (e.holdings) setHoldings(e.holdings);
|
||||
},
|
||||
|
||||
team_holdings: (e) => {
|
||||
if (e.data && Array.isArray(e.data)) {
|
||||
setHoldings(e.data);
|
||||
console.log(`✅ Holdings updated: ${e.data.length} positions`);
|
||||
}
|
||||
},
|
||||
|
||||
team_trades: (e) => {
|
||||
if (e.mode === 'full' && e.data && Array.isArray(e.data)) {
|
||||
setTrades(e.data);
|
||||
} else if (Array.isArray(e.trades)) {
|
||||
setTrades(e.trades);
|
||||
} else if (e.trade) {
|
||||
setTrades((prev) => [e.trade, ...prev].slice(0, 100));
|
||||
}
|
||||
},
|
||||
|
||||
team_stats: (e) => {
|
||||
if (e.data) setStats(e.data);
|
||||
else if (e.stats) setStats(e.stats);
|
||||
},
|
||||
|
||||
team_leaderboard: (e) => {
|
||||
if (Array.isArray(e.data)) setLeaderboard(e.data);
|
||||
else if (Array.isArray(e.rows)) setLeaderboard(e.rows);
|
||||
else if (Array.isArray(e.leaderboard)) setLeaderboard(e.leaderboard);
|
||||
},
|
||||
|
||||
time_update: (e) => {
|
||||
if (e.beijing_time_str) {
|
||||
const statusEmoji = { market_open: '📊', off_market: '⏸️', non_trading_day: '📅', trade_execution: '💼' };
|
||||
const emoji = statusEmoji[e.status] || '⏰';
|
||||
const isMockMode = e.is_mock_mode === true;
|
||||
let logMessage = `${emoji} ${isMockMode ? '虚拟时间' : '时间'}: ${e.beijing_time_str} | 状态: ${e.status}`;
|
||||
if (e.hours_to_open !== undefined) logMessage += ` | 距离开盘: ${e.hours_to_open}小时`;
|
||||
if (e.hours_to_trade !== undefined) logMessage += ` | 距离交易: ${e.hours_to_trade}小时`;
|
||||
if (e.trading_date) logMessage += ` | 交易日: ${e.trading_date}`;
|
||||
console.log(logMessage);
|
||||
|
||||
if (isMockMode && e.beijing_time) {
|
||||
try { setVirtualTime(new Date(e.beijing_time)); } catch (error) { console.error('Error parsing virtual time:', error); }
|
||||
} else {
|
||||
setVirtualTime(null);
|
||||
}
|
||||
}
|
||||
if (e.market_status) setMarketStatus(e.market_status);
|
||||
},
|
||||
|
||||
time_fast_forwarded: (e) => {
|
||||
console.log(`⏩ 时间已快进 ${e.minutes} 分钟: ${e.old_time_str} → ${e.new_time_str}`);
|
||||
if (e.new_time) {
|
||||
try {
|
||||
setVirtualTime(new Date(e.new_time));
|
||||
handlePushEvent({ type: 'system', content: `⏩ 时间快进 ${e.minutes} 分钟: ${e.old_time_str} → ${e.new_time_str}`, timestamp: Date.now() });
|
||||
} catch (error) { console.error('Error parsing fast forwarded time:', error); }
|
||||
}
|
||||
},
|
||||
|
||||
fast_forward_success: (e) => {
|
||||
console.log(`✅ ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const handler = handlers[evt.type];
|
||||
if (handler) handler(evt);
|
||||
else console.log('[handleEvent] Unknown event type:', evt.type);
|
||||
} catch (error) {
|
||||
console.error('[handleEvent] Error handling event:', evt.type, error);
|
||||
}
|
||||
};
|
||||
|
||||
// Create and connect WebSocket client
|
||||
const client = new ReadOnlyClient(handlePushEvent);
|
||||
clientRef.current = client;
|
||||
client.connect();
|
||||
setConnectionStatus('connecting');
|
||||
|
||||
// Sync refs with store state
|
||||
isWatchlistSavingRef.current = isWatchlistSaving;
|
||||
isRuntimeConfigSavingRef.current = isRuntimeConfigSaving;
|
||||
selectedSkillAgentIdRef.current = selectedSkillAgentId;
|
||||
|
||||
return () => {
|
||||
if (clientRef.current) {
|
||||
clientRef.current.disconnect();
|
||||
}
|
||||
};
|
||||
}, [
|
||||
addSystemMessage, processFeedEvent,
|
||||
processHistoricalFeed, setAgentProfilesByAgent,
|
||||
setAgentSkillsByAgent, setAgentSkillsFeedback, setAgentSkillsSavingKey,
|
||||
setBubbles, setConnectionStatus, setCurrentDate, setDataSources,
|
||||
setExplainEventsByTicker, setHistorySourceByTicker, setHoldings,
|
||||
setInsiderTradesByTicker, setIsAgentSkillsLoading, setIsConnected,
|
||||
setIsRuntimeConfigSaving, setIsWatchlistSaving, setIsWorkspaceFileLoading,
|
||||
setLastDayHistory, setLeaderboard, setLocalSkillDraftsByKey,
|
||||
setMarketStatus, setNewsByTicker, setOhlcHistoryByTicker,
|
||||
setPortfolioData, setPriceHistoryByTicker, setProgress,
|
||||
setRollingTickers, setRuntimeConfig, setRuntimeConfigFeedback,
|
||||
setServerMode, setSkillDetailLoadingKey, setSkillDetailsByName,
|
||||
setStats, setSystemStatus, setTechnicalIndicatorsByTicker,
|
||||
setTickers, setTrades, setVirtualTime, setWatchlistFeedback,
|
||||
setWatchlistInputValue, setWorkspaceFileFeedback, setWorkspaceFileSavingKey,
|
||||
setWorkspaceFilesByAgent, updateTickersFromPrices
|
||||
]);
|
||||
|
||||
// Sync refs
|
||||
useEffect(() => {
|
||||
isWatchlistSavingRef.current = isWatchlistSaving;
|
||||
}, [isWatchlistSaving]);
|
||||
|
||||
useEffect(() => {
|
||||
isRuntimeConfigSavingRef.current = isRuntimeConfigSaving;
|
||||
}, [isRuntimeConfigSaving]);
|
||||
|
||||
useEffect(() => {
|
||||
selectedSkillAgentIdRef.current = selectedSkillAgentId;
|
||||
}, [selectedSkillAgentId]);
|
||||
|
||||
return { clientRef, setRequestStockHistory, setRequestStockNewsTimeline, setRequestStockNewsCategories };
|
||||
}
|
||||
29
frontend/src/hooks/useWebsocketSessionSync.js
Normal file
29
frontend/src/hooks/useWebsocketSessionSync.js
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* useWebsocketSessionSync - DEPRECATED
|
||||
*
|
||||
* This hook is deprecated. WebSocket connection and event handling is now managed
|
||||
* by useWebSocketConnection.js. This file is kept for backwards compatibility
|
||||
* but will be removed in a future version.
|
||||
*
|
||||
* All functionality has been consolidated into:
|
||||
* - useWebSocketConnection.js: WebSocket lifecycle and event handlers
|
||||
* - useStockDataRequests.js: Stock data request callbacks
|
||||
* - useAgentDataRequests.js: Agent operation callbacks
|
||||
*/
|
||||
|
||||
import { useWebSocketConnection } from './useWebSocketConnection';
|
||||
|
||||
/**
|
||||
* @deprecated Use useWebSocketConnection directly instead.
|
||||
* This hook is a thin wrapper that delegates to useWebSocketConnection
|
||||
* for backwards compatibility.
|
||||
*/
|
||||
export function useWebsocketSessionSync(props) {
|
||||
// Delegate to useWebSocketConnection
|
||||
const { clientRef } = useWebSocketConnection();
|
||||
|
||||
// Return clientRef so existing code can still access it
|
||||
return { clientRef };
|
||||
}
|
||||
|
||||
export default useWebsocketSessionSync;
|
||||
@@ -121,6 +121,10 @@ export function fetchCurrentRuntime() {
|
||||
return safeFetch(RUNTIME_API_BASE, '/current');
|
||||
}
|
||||
|
||||
export function fetchRuntimeLogs() {
|
||||
return safeFetch(RUNTIME_API_BASE, '/logs');
|
||||
}
|
||||
|
||||
export async function uploadAgentSkillZip({
|
||||
agentId,
|
||||
file,
|
||||
|
||||
81
frontend/src/services/runtimeControls.js
Normal file
81
frontend/src/services/runtimeControls.js
Normal file
@@ -0,0 +1,81 @@
|
||||
const normalizeSymbol = (symbol) => {
|
||||
if (typeof symbol !== "string") {
|
||||
return "";
|
||||
}
|
||||
return symbol.trim().toUpperCase();
|
||||
};
|
||||
|
||||
export const normalizeTickerSymbols = (symbols, previousTickers = []) => {
|
||||
if (!Array.isArray(symbols) || symbols.length === 0) {
|
||||
return previousTickers;
|
||||
}
|
||||
|
||||
return symbols
|
||||
.map(normalizeSymbol)
|
||||
.filter(Boolean)
|
||||
.reduce((acc, symbol) => {
|
||||
const existing = acc.find((ticker) => ticker.symbol === symbol);
|
||||
if (existing) {
|
||||
return acc;
|
||||
}
|
||||
const prior = previousTickers.find((ticker) => ticker.symbol === symbol);
|
||||
acc.push(
|
||||
prior || {
|
||||
symbol,
|
||||
price: null,
|
||||
change: null
|
||||
}
|
||||
);
|
||||
return acc;
|
||||
}, []);
|
||||
};
|
||||
|
||||
export const normalizeRuntimeWatchlistSymbols = (runtimeConfig, fallbackTickers = []) => {
|
||||
const runtimeSymbols = Array.isArray(runtimeConfig?.tickers)
|
||||
? runtimeConfig.tickers.map(normalizeSymbol).filter(Boolean)
|
||||
: [];
|
||||
|
||||
if (runtimeSymbols.length > 0) {
|
||||
return runtimeSymbols;
|
||||
}
|
||||
|
||||
return fallbackTickers
|
||||
.map((ticker) => normalizeSymbol(ticker?.symbol))
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
export const parseWatchlistInput = (value) => {
|
||||
if (typeof value !== "string") {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.from(
|
||||
new Set(
|
||||
value
|
||||
.split(/[\s,]+/)
|
||||
.map(normalizeSymbol)
|
||||
.filter(Boolean)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export const buildRuntimeSummaryLabel = (runtimeConfig) => {
|
||||
if (!runtimeConfig) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scheduleMode = String(runtimeConfig.schedule_mode || "daily");
|
||||
const intervalMinutes = Number(runtimeConfig.interval_minutes || 60);
|
||||
const triggerTime = String(runtimeConfig.trigger_time || "now");
|
||||
const maxCommCycles = Number(runtimeConfig.max_comm_cycles || 2);
|
||||
|
||||
if (scheduleMode === "intraday") {
|
||||
return `调度 intraday / ${intervalMinutes}m / 讨论 ${maxCommCycles} 轮`;
|
||||
}
|
||||
|
||||
if (triggerTime.toLowerCase() === "now") {
|
||||
return `调度 daily / 立即执行 / 讨论 ${maxCommCycles} 轮`;
|
||||
}
|
||||
|
||||
return `调度 daily / ${triggerTime} ET / 讨论 ${maxCommCycles} 轮`;
|
||||
};
|
||||
59
frontend/src/services/runtimeControls.test.js
Normal file
59
frontend/src/services/runtimeControls.test.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildRuntimeSummaryLabel,
|
||||
normalizeRuntimeWatchlistSymbols,
|
||||
normalizeTickerSymbols,
|
||||
parseWatchlistInput
|
||||
} from "./runtimeControls";
|
||||
|
||||
describe("runtimeControls", () => {
|
||||
it("normalizes ticker symbols while preserving existing entries", () => {
|
||||
const previous = [
|
||||
{ symbol: "AAPL", price: 10, change: 1 },
|
||||
{ symbol: "MSFT", price: 20, change: 2 }
|
||||
];
|
||||
|
||||
expect(normalizeTickerSymbols(["aapl", "nvda", "MSFT"], previous)).toEqual([
|
||||
{ symbol: "AAPL", price: 10, change: 1 },
|
||||
{ symbol: "NVDA", price: null, change: null },
|
||||
{ symbol: "MSFT", price: 20, change: 2 }
|
||||
]);
|
||||
});
|
||||
|
||||
it("derives runtime watchlist symbols from runtime config or fallback tickers", () => {
|
||||
const runtimeConfig = { tickers: ["tsla", "meta", "tsla"] };
|
||||
const fallbackTickers = [{ symbol: "AAPL" }, { symbol: "MSFT" }];
|
||||
|
||||
expect(normalizeRuntimeWatchlistSymbols(runtimeConfig, fallbackTickers)).toEqual([
|
||||
"TSLA",
|
||||
"META",
|
||||
"TSLA"
|
||||
]);
|
||||
expect(normalizeRuntimeWatchlistSymbols({}, fallbackTickers)).toEqual([
|
||||
"AAPL",
|
||||
"MSFT"
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses watchlist input tokens and removes duplicates", () => {
|
||||
expect(parseWatchlistInput(" aapl, msft nvda\nNVDA ")).toEqual([
|
||||
"AAPL",
|
||||
"MSFT",
|
||||
"NVDA"
|
||||
]);
|
||||
});
|
||||
|
||||
it("builds runtime summary labels", () => {
|
||||
expect(buildRuntimeSummaryLabel({
|
||||
schedule_mode: "daily",
|
||||
trigger_time: "09:30",
|
||||
max_comm_cycles: 3
|
||||
})).toBe("调度 daily / 09:30 ET / 讨论 3 轮");
|
||||
|
||||
expect(buildRuntimeSummaryLabel({
|
||||
schedule_mode: "intraday",
|
||||
interval_minutes: 15,
|
||||
max_comm_cycles: 2
|
||||
})).toBe("调度 intraday / 15m / 讨论 2 轮");
|
||||
});
|
||||
});
|
||||
@@ -1,58 +1,62 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
const resolveValue = (updater, currentValue) => (
|
||||
typeof updater === 'function' ? updater(currentValue) : updater
|
||||
);
|
||||
|
||||
/**
|
||||
* Agent Store - Agent skills, profiles, workspaces
|
||||
*/
|
||||
export const useAgentStore = create((set) => ({
|
||||
// Selected agent for skill/workspace editing
|
||||
selectedSkillAgentId: null,
|
||||
setSelectedSkillAgentId: (selectedSkillAgentId) => set({ selectedSkillAgentId }),
|
||||
setSelectedSkillAgentId: (selectedSkillAgentId) => set((state) => ({ selectedSkillAgentId: resolveValue(selectedSkillAgentId, state.selectedSkillAgentId) })),
|
||||
|
||||
// Agent profiles
|
||||
agentProfilesByAgent: {},
|
||||
setAgentProfilesByAgent: (agentProfilesByAgent) => set({ agentProfilesByAgent }),
|
||||
setAgentProfilesByAgent: (agentProfilesByAgent) => set((state) => ({ agentProfilesByAgent: resolveValue(agentProfilesByAgent, state.agentProfilesByAgent) })),
|
||||
|
||||
// Agent skills
|
||||
agentSkillsByAgent: {},
|
||||
setAgentSkillsByAgent: (agentSkillsByAgent) => set({ agentSkillsByAgent }),
|
||||
setAgentSkillsByAgent: (agentSkillsByAgent) => set((state) => ({ agentSkillsByAgent: resolveValue(agentSkillsByAgent, state.agentSkillsByAgent) })),
|
||||
|
||||
// Skill details
|
||||
skillDetailsByName: {},
|
||||
setSkillDetailsByName: (skillDetailsByName) => set({ skillDetailsByName }),
|
||||
setSkillDetailsByName: (skillDetailsByName) => set((state) => ({ skillDetailsByName: resolveValue(skillDetailsByName, state.skillDetailsByName) })),
|
||||
|
||||
// Local skill drafts
|
||||
localSkillDraftsByKey: {},
|
||||
setLocalSkillDraftsByKey: (localSkillDraftsByKey) => set({ localSkillDraftsByKey }),
|
||||
setLocalSkillDraftsByKey: (localSkillDraftsByKey) => set((state) => ({ localSkillDraftsByKey: resolveValue(localSkillDraftsByKey, state.localSkillDraftsByKey) })),
|
||||
|
||||
// Loading states
|
||||
isAgentSkillsLoading: false,
|
||||
setIsAgentSkillsLoading: (isAgentSkillsLoading) => set({ isAgentSkillsLoading }),
|
||||
setIsAgentSkillsLoading: (isAgentSkillsLoading) => set((state) => ({ isAgentSkillsLoading: resolveValue(isAgentSkillsLoading, state.isAgentSkillsLoading) })),
|
||||
|
||||
skillDetailLoadingKey: null,
|
||||
setSkillDetailLoadingKey: (skillDetailLoadingKey) => set({ skillDetailLoadingKey }),
|
||||
setSkillDetailLoadingKey: (skillDetailLoadingKey) => set((state) => ({ skillDetailLoadingKey: resolveValue(skillDetailLoadingKey, state.skillDetailLoadingKey) })),
|
||||
|
||||
agentSkillsSavingKey: null,
|
||||
setAgentSkillsSavingKey: (agentSkillsSavingKey) => set({ agentSkillsSavingKey }),
|
||||
setAgentSkillsSavingKey: (agentSkillsSavingKey) => set((state) => ({ agentSkillsSavingKey: resolveValue(agentSkillsSavingKey, state.agentSkillsSavingKey) })),
|
||||
|
||||
agentSkillsFeedback: null,
|
||||
setAgentSkillsFeedback: (agentSkillsFeedback) => set({ agentSkillsFeedback }),
|
||||
setAgentSkillsFeedback: (agentSkillsFeedback) => set((state) => ({ agentSkillsFeedback: resolveValue(agentSkillsFeedback, state.agentSkillsFeedback) })),
|
||||
|
||||
// Workspace files
|
||||
selectedWorkspaceFile: null,
|
||||
setSelectedWorkspaceFile: (selectedWorkspaceFile) => set({ selectedWorkspaceFile }),
|
||||
setSelectedWorkspaceFile: (selectedWorkspaceFile) => set((state) => ({ selectedWorkspaceFile: resolveValue(selectedWorkspaceFile, state.selectedWorkspaceFile) })),
|
||||
|
||||
workspaceFilesByAgent: {},
|
||||
setWorkspaceFilesByAgent: (workspaceFilesByAgent) => set({ workspaceFilesByAgent }),
|
||||
setWorkspaceFilesByAgent: (workspaceFilesByAgent) => set((state) => ({ workspaceFilesByAgent: resolveValue(workspaceFilesByAgent, state.workspaceFilesByAgent) })),
|
||||
|
||||
workspaceDraftContent: '',
|
||||
setWorkspaceDraftContent: (workspaceDraftContent) => set({ workspaceDraftContent }),
|
||||
setWorkspaceDraftContent: (workspaceDraftContent) => set((state) => ({ workspaceDraftContent: resolveValue(workspaceDraftContent, state.workspaceDraftContent) })),
|
||||
|
||||
isWorkspaceFileLoading: false,
|
||||
setIsWorkspaceFileLoading: (isWorkspaceFileLoading) => set({ isWorkspaceFileLoading }),
|
||||
setIsWorkspaceFileLoading: (isWorkspaceFileLoading) => set((state) => ({ isWorkspaceFileLoading: resolveValue(isWorkspaceFileLoading, state.isWorkspaceFileLoading) })),
|
||||
|
||||
workspaceFileSavingKey: null,
|
||||
setWorkspaceFileSavingKey: (workspaceFileSavingKey) => set({ workspaceFileSavingKey }),
|
||||
setWorkspaceFileSavingKey: (workspaceFileSavingKey) => set((state) => ({ workspaceFileSavingKey: resolveValue(workspaceFileSavingKey, state.workspaceFileSavingKey) })),
|
||||
|
||||
workspaceFileFeedback: null,
|
||||
setWorkspaceFileFeedback: (workspaceFileFeedback) => set({ workspaceFileFeedback }),
|
||||
setWorkspaceFileFeedback: (workspaceFileFeedback) => set((state) => ({ workspaceFileFeedback: resolveValue(workspaceFileFeedback, state.workspaceFileFeedback) })),
|
||||
}));
|
||||
|
||||
@@ -1,44 +1,48 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
const resolveValue = (updater, currentValue) => (
|
||||
typeof updater === 'function' ? updater(currentValue) : updater
|
||||
);
|
||||
|
||||
/**
|
||||
* Market Store - Market data, stock prices, news
|
||||
*/
|
||||
export const useMarketStore = create((set) => ({
|
||||
// Ticker prices
|
||||
tickers: [],
|
||||
setTickers: (tickers) => set({ tickers }),
|
||||
setTickers: (tickers) => set((state) => ({ tickers: resolveValue(tickers, state.tickers) })),
|
||||
rollingTickers: {},
|
||||
setRollingTickers: (rollingTickers) => set({ rollingTickers }),
|
||||
setRollingTickers: (rollingTickers) => set((state) => ({ rollingTickers: resolveValue(rollingTickers, state.rollingTickers) })),
|
||||
|
||||
// Price history
|
||||
priceHistoryByTicker: {},
|
||||
setPriceHistoryByTicker: (priceHistoryByTicker) => set({ priceHistoryByTicker }),
|
||||
setPriceHistoryByTicker: (priceHistoryByTicker) => set((state) => ({ priceHistoryByTicker: resolveValue(priceHistoryByTicker, state.priceHistoryByTicker) })),
|
||||
|
||||
// OHLC history
|
||||
ohlcHistoryByTicker: {},
|
||||
setOhlcHistoryByTicker: (ohlcHistoryByTicker) => set({ ohlcHistoryByTicker }),
|
||||
setOhlcHistoryByTicker: (ohlcHistoryByTicker) => set((state) => ({ ohlcHistoryByTicker: resolveValue(ohlcHistoryByTicker, state.ohlcHistoryByTicker) })),
|
||||
|
||||
// History source tracking
|
||||
historySourceByTicker: {},
|
||||
setHistorySourceByTicker: (historySourceByTicker) => set({ historySourceByTicker }),
|
||||
setHistorySourceByTicker: (historySourceByTicker) => set((state) => ({ historySourceByTicker: resolveValue(historySourceByTicker, state.historySourceByTicker) })),
|
||||
|
||||
// Explain events
|
||||
explainEventsByTicker: {},
|
||||
setExplainEventsByTicker: (explainEventsByTicker) => set({ explainEventsByTicker }),
|
||||
setExplainEventsByTicker: (explainEventsByTicker) => set((state) => ({ explainEventsByTicker: resolveValue(explainEventsByTicker, state.explainEventsByTicker) })),
|
||||
|
||||
// Selected explain symbol
|
||||
selectedExplainSymbol: '',
|
||||
setSelectedExplainSymbol: (selectedExplainSymbol) => set({ selectedExplainSymbol }),
|
||||
setSelectedExplainSymbol: (selectedExplainSymbol) => set((state) => ({ selectedExplainSymbol: resolveValue(selectedExplainSymbol, state.selectedExplainSymbol) })),
|
||||
|
||||
// News by ticker
|
||||
newsByTicker: {},
|
||||
setNewsByTicker: (newsByTicker) => set({ newsByTicker }),
|
||||
setNewsByTicker: (newsByTicker) => set((state) => ({ newsByTicker: resolveValue(newsByTicker, state.newsByTicker) })),
|
||||
|
||||
// Insider trades
|
||||
insiderTradesByTicker: {},
|
||||
setInsiderTradesByTicker: (insiderTradesByTicker) => set({ insiderTradesByTicker }),
|
||||
setInsiderTradesByTicker: (insiderTradesByTicker) => set((state) => ({ insiderTradesByTicker: resolveValue(insiderTradesByTicker, state.insiderTradesByTicker) })),
|
||||
|
||||
// Technical indicators
|
||||
technicalIndicatorsByTicker: {},
|
||||
setTechnicalIndicatorsByTicker: (technicalIndicatorsByTicker) => set({ technicalIndicatorsByTicker }),
|
||||
setTechnicalIndicatorsByTicker: (technicalIndicatorsByTicker) => set((state) => ({ technicalIndicatorsByTicker: resolveValue(technicalIndicatorsByTicker, state.technicalIndicatorsByTicker) })),
|
||||
}));
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
const resolveValue = (updater, currentValue) => (
|
||||
typeof updater === 'function' ? updater(currentValue) : updater
|
||||
);
|
||||
|
||||
/**
|
||||
* Portfolio Store - Portfolio data, holdings, trades, statistics
|
||||
*/
|
||||
@@ -18,21 +22,21 @@ export const usePortfolioStore = create((set) => ({
|
||||
baseline_vw_return: 0,
|
||||
momentum_return: 0,
|
||||
},
|
||||
setPortfolioData: (portfolioData) => set({ portfolioData }),
|
||||
setPortfolioData: (portfolioData) => set((state) => ({ portfolioData: resolveValue(portfolioData, state.portfolioData) })),
|
||||
|
||||
// Holdings
|
||||
holdings: [],
|
||||
setHoldings: (holdings) => set({ holdings }),
|
||||
setHoldings: (holdings) => set((state) => ({ holdings: resolveValue(holdings, state.holdings) })),
|
||||
|
||||
// Trades
|
||||
trades: [],
|
||||
setTrades: (trades) => set({ trades }),
|
||||
setTrades: (trades) => set((state) => ({ trades: resolveValue(trades, state.trades) })),
|
||||
|
||||
// Statistics
|
||||
stats: null,
|
||||
setStats: (stats) => set({ stats }),
|
||||
setStats: (stats) => set((state) => ({ stats: resolveValue(stats, state.stats) })),
|
||||
|
||||
// Leaderboard
|
||||
leaderboard: [],
|
||||
setLeaderboard: (leaderboard) => set({ leaderboard }),
|
||||
setLeaderboard: (leaderboard) => set((state) => ({ leaderboard: resolveValue(leaderboard, state.leaderboard) })),
|
||||
}));
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
const resolveValue = (updater, currentValue) => (
|
||||
typeof updater === 'function' ? updater(currentValue) : updater
|
||||
);
|
||||
|
||||
/**
|
||||
* Runtime Store - Connection state and runtime configuration
|
||||
*/
|
||||
@@ -7,59 +11,59 @@ export const useRuntimeStore = create((set) => ({
|
||||
// Connection state
|
||||
isConnected: false,
|
||||
connectionStatus: 'connecting', // 'connecting' | 'connected' | 'disconnected'
|
||||
setIsConnected: (isConnected) => set({ isConnected }),
|
||||
setConnectionStatus: (connectionStatus) => set({ connectionStatus }),
|
||||
setIsConnected: (isConnected) => set((state) => ({ isConnected: resolveValue(isConnected, state.isConnected) })),
|
||||
setConnectionStatus: (connectionStatus) => set((state) => ({ connectionStatus: resolveValue(connectionStatus, state.connectionStatus) })),
|
||||
|
||||
// System state
|
||||
systemStatus: 'initializing', // 'initializing' | 'running' | 'completed'
|
||||
currentDate: null,
|
||||
setSystemStatus: (systemStatus) => set({ systemStatus }),
|
||||
setCurrentDate: (currentDate) => set({ currentDate }),
|
||||
setSystemStatus: (systemStatus) => set((state) => ({ systemStatus: resolveValue(systemStatus, state.systemStatus) })),
|
||||
setCurrentDate: (currentDate) => set((state) => ({ currentDate: resolveValue(currentDate, state.currentDate) })),
|
||||
|
||||
// Progress
|
||||
progress: { current: 0, total: 0 },
|
||||
setProgress: (progress) => set({ progress }),
|
||||
setProgress: (progress) => set((state) => ({ progress: resolveValue(progress, state.progress) })),
|
||||
|
||||
// Server mode
|
||||
serverMode: null, // 'live' | 'backtest' | null
|
||||
setServerMode: (serverMode) => set({ serverMode }),
|
||||
setServerMode: (serverMode) => set((state) => ({ serverMode: resolveValue(serverMode, state.serverMode) })),
|
||||
|
||||
// Market status
|
||||
marketStatus: null,
|
||||
virtualTime: null,
|
||||
setMarketStatus: (marketStatus) => set({ marketStatus }),
|
||||
setVirtualTime: (virtualTime) => set({ virtualTime }),
|
||||
setMarketStatus: (marketStatus) => set((state) => ({ marketStatus: resolveValue(marketStatus, state.marketStatus) })),
|
||||
setVirtualTime: (virtualTime) => set((state) => ({ virtualTime: resolveValue(virtualTime, state.virtualTime) })),
|
||||
|
||||
// Data sources
|
||||
dataSources: null,
|
||||
setDataSources: (dataSources) => set({ dataSources }),
|
||||
setDataSources: (dataSources) => set((state) => ({ dataSources: resolveValue(dataSources, state.dataSources) })),
|
||||
|
||||
// Runtime config
|
||||
runtimeConfig: null,
|
||||
setRuntimeConfig: (runtimeConfig) => set({ runtimeConfig }),
|
||||
setRuntimeConfig: (runtimeConfig) => set((state) => ({ runtimeConfig: resolveValue(runtimeConfig, state.runtimeConfig) })),
|
||||
|
||||
// Watchlist panel
|
||||
isWatchlistPanelOpen: false,
|
||||
setIsWatchlistPanelOpen: (isWatchlistPanelOpen) => set({ isWatchlistPanelOpen }),
|
||||
setIsWatchlistPanelOpen: (isWatchlistPanelOpen) => set((state) => ({ isWatchlistPanelOpen: resolveValue(isWatchlistPanelOpen, state.isWatchlistPanelOpen) })),
|
||||
|
||||
// Watchlist draft
|
||||
watchlistDraftSymbols: [],
|
||||
watchlistInputValue: '',
|
||||
watchlistFeedback: null,
|
||||
isWatchlistSaving: false,
|
||||
setWatchlistDraftSymbols: (watchlistDraftSymbols) => set({ watchlistDraftSymbols }),
|
||||
setWatchlistInputValue: (watchlistInputValue) => set({ watchlistInputValue }),
|
||||
setWatchlistFeedback: (watchlistFeedback) => set({ watchlistFeedback }),
|
||||
setIsWatchlistSaving: (isWatchlistSaving) => set({ isWatchlistSaving }),
|
||||
setWatchlistDraftSymbols: (watchlistDraftSymbols) => set((state) => ({ watchlistDraftSymbols: resolveValue(watchlistDraftSymbols, state.watchlistDraftSymbols) })),
|
||||
setWatchlistInputValue: (watchlistInputValue) => set((state) => ({ watchlistInputValue: resolveValue(watchlistInputValue, state.watchlistInputValue) })),
|
||||
setWatchlistFeedback: (watchlistFeedback) => set((state) => ({ watchlistFeedback: resolveValue(watchlistFeedback, state.watchlistFeedback) })),
|
||||
setIsWatchlistSaving: (isWatchlistSaving) => set((state) => ({ isWatchlistSaving: resolveValue(isWatchlistSaving, state.isWatchlistSaving) })),
|
||||
|
||||
// Runtime settings panel
|
||||
isRuntimeSettingsOpen: false,
|
||||
setIsRuntimeSettingsOpen: (isRuntimeSettingsOpen) => set({ isRuntimeSettingsOpen }),
|
||||
setIsRuntimeSettingsOpen: (isRuntimeSettingsOpen) => set((state) => ({ isRuntimeSettingsOpen: resolveValue(isRuntimeSettingsOpen, state.isRuntimeSettingsOpen) })),
|
||||
|
||||
// Runtime config drafts
|
||||
scheduleModeDraft: 'daily',
|
||||
intervalMinutesDraft: '60',
|
||||
triggerTimeDraft: '09:30',
|
||||
triggerTimeDraft: 'now',
|
||||
maxCommCyclesDraft: '2',
|
||||
initialCashDraft: '100000',
|
||||
marginRequirementDraft: '0',
|
||||
@@ -69,26 +73,26 @@ export const useRuntimeStore = create((set) => ({
|
||||
startDateDraft: '',
|
||||
endDateDraft: '',
|
||||
enableMockDraft: false,
|
||||
setScheduleModeDraft: (scheduleModeDraft) => set({ scheduleModeDraft }),
|
||||
setIntervalMinutesDraft: (intervalMinutesDraft) => set({ intervalMinutesDraft }),
|
||||
setTriggerTimeDraft: (triggerTimeDraft) => set({ triggerTimeDraft }),
|
||||
setMaxCommCyclesDraft: (maxCommCyclesDraft) => set({ maxCommCyclesDraft }),
|
||||
setInitialCashDraft: (initialCashDraft) => set({ initialCashDraft }),
|
||||
setMarginRequirementDraft: (marginRequirementDraft) => set({ marginRequirementDraft }),
|
||||
setEnableMemoryDraft: (enableMemoryDraft) => set({ enableMemoryDraft }),
|
||||
setModeDraft: (modeDraft) => set({ modeDraft }),
|
||||
setPollIntervalDraft: (pollIntervalDraft) => set({ pollIntervalDraft }),
|
||||
setStartDateDraft: (startDateDraft) => set({ startDateDraft }),
|
||||
setEndDateDraft: (endDateDraft) => set({ endDateDraft }),
|
||||
setEnableMockDraft: (enableMockDraft) => set({ enableMockDraft }),
|
||||
setScheduleModeDraft: (scheduleModeDraft) => set((state) => ({ scheduleModeDraft: resolveValue(scheduleModeDraft, state.scheduleModeDraft) })),
|
||||
setIntervalMinutesDraft: (intervalMinutesDraft) => set((state) => ({ intervalMinutesDraft: resolveValue(intervalMinutesDraft, state.intervalMinutesDraft) })),
|
||||
setTriggerTimeDraft: (triggerTimeDraft) => set((state) => ({ triggerTimeDraft: resolveValue(triggerTimeDraft, state.triggerTimeDraft) })),
|
||||
setMaxCommCyclesDraft: (maxCommCyclesDraft) => set((state) => ({ maxCommCyclesDraft: resolveValue(maxCommCyclesDraft, state.maxCommCyclesDraft) })),
|
||||
setInitialCashDraft: (initialCashDraft) => set((state) => ({ initialCashDraft: resolveValue(initialCashDraft, state.initialCashDraft) })),
|
||||
setMarginRequirementDraft: (marginRequirementDraft) => set((state) => ({ marginRequirementDraft: resolveValue(marginRequirementDraft, state.marginRequirementDraft) })),
|
||||
setEnableMemoryDraft: (enableMemoryDraft) => set((state) => ({ enableMemoryDraft: resolveValue(enableMemoryDraft, state.enableMemoryDraft) })),
|
||||
setModeDraft: (modeDraft) => set((state) => ({ modeDraft: resolveValue(modeDraft, state.modeDraft) })),
|
||||
setPollIntervalDraft: (pollIntervalDraft) => set((state) => ({ pollIntervalDraft: resolveValue(pollIntervalDraft, state.pollIntervalDraft) })),
|
||||
setStartDateDraft: (startDateDraft) => set((state) => ({ startDateDraft: resolveValue(startDateDraft, state.startDateDraft) })),
|
||||
setEndDateDraft: (endDateDraft) => set((state) => ({ endDateDraft: resolveValue(endDateDraft, state.endDateDraft) })),
|
||||
setEnableMockDraft: (enableMockDraft) => set((state) => ({ enableMockDraft: resolveValue(enableMockDraft, state.enableMockDraft) })),
|
||||
|
||||
// Runtime config feedback
|
||||
runtimeConfigFeedback: null,
|
||||
isRuntimeConfigSaving: false,
|
||||
setRuntimeConfigFeedback: (runtimeConfigFeedback) => set({ runtimeConfigFeedback }),
|
||||
setIsRuntimeConfigSaving: (isRuntimeConfigSaving) => set({ isRuntimeConfigSaving }),
|
||||
setRuntimeConfigFeedback: (runtimeConfigFeedback) => set((state) => ({ runtimeConfigFeedback: resolveValue(runtimeConfigFeedback, state.runtimeConfigFeedback) })),
|
||||
setIsRuntimeConfigSaving: (isRuntimeConfigSaving) => set((state) => ({ isRuntimeConfigSaving: resolveValue(isRuntimeConfigSaving, state.isRuntimeConfigSaving) })),
|
||||
|
||||
// Last day history (for replay)
|
||||
lastDayHistory: [],
|
||||
setLastDayHistory: (lastDayHistory) => set({ lastDayHistory }),
|
||||
setLastDayHistory: (lastDayHistory) => set((state) => ({ lastDayHistory: resolveValue(lastDayHistory, state.lastDayHistory) })),
|
||||
}));
|
||||
|
||||
@@ -1,40 +1,44 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
const resolveValue = (updater, currentValue) => (
|
||||
typeof updater === 'function' ? updater(currentValue) : updater
|
||||
);
|
||||
|
||||
/**
|
||||
* UI Store - UI state, view management, layout
|
||||
*/
|
||||
export const useUIStore = create((set) => ({
|
||||
// Current view
|
||||
currentView: 'traders', // 'traders' | 'room' | 'explain' | 'chart' | 'statistics' | 'runtime'
|
||||
setCurrentView: (currentView) => set({ currentView }),
|
||||
setCurrentView: (currentView) => set((state) => ({ currentView: resolveValue(currentView, state.currentView) })),
|
||||
|
||||
// Chart tab
|
||||
chartTab: 'all',
|
||||
setChartTab: (chartTab) => set({ chartTab }),
|
||||
setChartTab: (chartTab) => set((state) => ({ chartTab: resolveValue(chartTab, state.chartTab) })),
|
||||
|
||||
// Initial animation
|
||||
isInitialAnimating: true,
|
||||
setIsInitialAnimating: (isInitialAnimating) => set({ isInitialAnimating }),
|
||||
setIsInitialAnimating: (isInitialAnimating) => set((state) => ({ isInitialAnimating: resolveValue(isInitialAnimating, state.isInitialAnimating) })),
|
||||
|
||||
// Last update timestamp
|
||||
lastUpdate: new Date(),
|
||||
setLastUpdate: (lastUpdate) => set({ lastUpdate }),
|
||||
setLastUpdate: (lastUpdate) => set((state) => ({ lastUpdate: resolveValue(lastUpdate, state.lastUpdate) })),
|
||||
|
||||
// Is updating
|
||||
isUpdating: false,
|
||||
setIsUpdating: (isUpdating) => set({ isUpdating }),
|
||||
setIsUpdating: (isUpdating) => set((state) => ({ isUpdating: resolveValue(isUpdating, state.isUpdating) })),
|
||||
|
||||
// Room bubbles
|
||||
bubbles: {},
|
||||
setBubbles: (bubbles) => set({ bubbles }),
|
||||
setBubbles: (bubbles) => set((state) => ({ bubbles: resolveValue(bubbles, state.bubbles) })),
|
||||
|
||||
// Resizable panels
|
||||
leftWidth: 70,
|
||||
setLeftWidth: (leftWidth) => set({ leftWidth }),
|
||||
setLeftWidth: (leftWidth) => set((state) => ({ leftWidth: resolveValue(leftWidth, state.leftWidth) })),
|
||||
isResizing: false,
|
||||
setIsResizing: (isResizing) => set({ isResizing }),
|
||||
setIsResizing: (isResizing) => set((state) => ({ isResizing: resolveValue(isResizing, state.isResizing) })),
|
||||
|
||||
// Now timestamp (for current time display)
|
||||
now: new Date(),
|
||||
setNow: (now) => set({ now }),
|
||||
setNow: (now) => set((state) => ({ now: resolveValue(now, state.now) })),
|
||||
}));
|
||||
|
||||
@@ -478,7 +478,7 @@ export default function GlobalStyles() {
|
||||
background: #ffffff;
|
||||
flex-wrap: wrap;
|
||||
position: relative;
|
||||
z-index: 1000;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.agent-indicator {
|
||||
@@ -583,6 +583,7 @@ export default function GlobalStyles() {
|
||||
|
||||
.room-scene-wrapper {
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
@@ -646,7 +647,7 @@ export default function GlobalStyles() {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
}
|
||||
@@ -656,6 +657,7 @@ export default function GlobalStyles() {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.room-canvas {
|
||||
@@ -666,7 +668,8 @@ export default function GlobalStyles() {
|
||||
|
||||
.room-bubble {
|
||||
position: absolute;
|
||||
max-width: 300px;
|
||||
max-width: 320px;
|
||||
max-height: 260px;
|
||||
font-size: 11px;
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
@@ -676,6 +679,8 @@ export default function GlobalStyles() {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
line-height: 1.5;
|
||||
animation: bubbleAppear 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
overflow: hidden;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
@keyframes bubbleAppear {
|
||||
@@ -786,6 +791,9 @@ export default function GlobalStyles() {
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
position: relative;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.bubble-expand-btn {
|
||||
|
||||
@@ -29,13 +29,6 @@ else
|
||||
echo -e "${YELLOW}Warning: .env file not found${NC}"
|
||||
fi
|
||||
|
||||
# Check required environment variables
|
||||
if [ -z "$OPENAI_API_KEY" ]; then
|
||||
echo -e "${RED}Error: OPENAI_API_KEY not set${NC}"
|
||||
echo "Please set it in .env file or environment"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd /Users/cillin/workspeace/evotraders
|
||||
PIDS=()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user