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,12 +38,13 @@ class RuntimeState:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
_instance: Optional["RuntimeState"] = None
|
_instance: Optional["RuntimeState"] = None
|
||||||
_lock: asyncio.Lock = asyncio.Lock()
|
_lock: "threading.Lock" = __import__("threading").Lock()
|
||||||
|
|
||||||
def __new__(cls) -> "RuntimeState":
|
def __new__(cls) -> "RuntimeState":
|
||||||
if cls._instance is None:
|
with cls._lock:
|
||||||
cls._instance = super().__new__(cls)
|
if cls._instance is None:
|
||||||
cls._instance._initialized = False
|
cls._instance = super().__new__(cls)
|
||||||
|
cls._instance._initialized = False
|
||||||
return cls._instance
|
return cls._instance
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
@@ -207,6 +208,13 @@ class RuntimeConfigResponse(BaseModel):
|
|||||||
resolved: Dict[str, Any]
|
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):
|
class UpdateRuntimeConfigRequest(BaseModel):
|
||||||
schedule_mode: Optional[str] = None
|
schedule_mode: Optional[str] = None
|
||||||
interval_minutes: Optional[int] = Field(default=None, ge=1)
|
interval_minutes: Optional[int] = Field(default=None, ge=1)
|
||||||
@@ -288,14 +296,20 @@ def _start_gateway_process(
|
|||||||
"--bootstrap", json.dumps(bootstrap)
|
"--bootstrap", json.dumps(bootstrap)
|
||||||
]
|
]
|
||||||
|
|
||||||
# Start process
|
log_path = run_dir / "logs" / "gateway.log"
|
||||||
process = subprocess.Popen(
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
cmd,
|
|
||||||
env=env,
|
log_file = log_path.open("ab")
|
||||||
stdout=subprocess.PIPE,
|
try:
|
||||||
stderr=subprocess.PIPE,
|
process = subprocess.Popen(
|
||||||
cwd=PROJECT_ROOT
|
cmd,
|
||||||
)
|
env=env,
|
||||||
|
stdout=log_file,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
cwd=PROJECT_ROOT
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
log_file.close()
|
||||||
|
|
||||||
return process
|
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:
|
def _build_gateway_ws_url(request: Request, port: int) -> str:
|
||||||
"""Build a proxy-safe Gateway WebSocket URL."""
|
"""Build a proxy-safe Gateway WebSocket URL."""
|
||||||
forwarded_proto = request.headers.get("x-forwarded-proto", "").split(",")[0].strip()
|
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"))
|
return json.loads(snapshots[0].read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
def _get_current_runtime_context() -> Dict[str, Any]:
|
def _get_runtime_context_from_latest_snapshot() -> Dict[str, Any]:
|
||||||
"""Return the active runtime context from the latest snapshot."""
|
"""Return the latest persisted runtime context regardless of active process state."""
|
||||||
if not _is_gateway_running():
|
|
||||||
raise HTTPException(status_code=404, detail="No runtime is currently running")
|
|
||||||
latest = _load_latest_runtime_snapshot()
|
latest = _load_latest_runtime_snapshot()
|
||||||
context = latest.get("context") or {}
|
context = latest.get("context") or {}
|
||||||
if not context.get("config_name"):
|
if not context.get("config_name"):
|
||||||
@@ -427,6 +459,26 @@ def _get_current_runtime_context() -> Dict[str, Any]:
|
|||||||
return context
|
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:
|
def _resolve_runtime_response(run_id: str) -> RuntimeConfigResponse:
|
||||||
"""Build a normalized runtime config response for the active run."""
|
"""Build a normalized runtime config response for the active run."""
|
||||||
context = _get_current_runtime_context()
|
context = _get_current_runtime_context()
|
||||||
@@ -567,11 +619,12 @@ async def start_runtime(
|
|||||||
await asyncio.sleep(2)
|
await asyncio.sleep(2)
|
||||||
|
|
||||||
if not _is_gateway_running():
|
if not _is_gateway_running():
|
||||||
stdout, stderr = process.communicate(timeout=1)
|
|
||||||
_runtime_state.gateway_process = None
|
_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(
|
raise HTTPException(
|
||||||
status_code=500,
|
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:
|
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.
|
Resolve data source configuration based on available API keys.
|
||||||
|
|
||||||
Priority:
|
The effective source should always match the first item in the resolved
|
||||||
1. FINNHUB_API_KEY (if set)
|
ordered source list.
|
||||||
2. FINANCIAL_DATASETS_API_KEY (if set)
|
|
||||||
3. Raises error if neither is available
|
|
||||||
"""
|
"""
|
||||||
sources = _ordered_sources()
|
sources = _ordered_sources()
|
||||||
if "finnhub" in sources:
|
source = sources[0] if sources else "local_csv"
|
||||||
return DataSourceConfig(
|
|
||||||
source="finnhub",
|
api_key = ""
|
||||||
api_key=os.getenv("FINNHUB_API_KEY", "").strip(),
|
if source == "finnhub":
|
||||||
sources=sources,
|
api_key = os.getenv("FINNHUB_API_KEY", "").strip()
|
||||||
)
|
elif source == "financial_datasets":
|
||||||
if "financial_datasets" in sources:
|
api_key = os.getenv("FINANCIAL_DATASETS_API_KEY", "").strip()
|
||||||
return DataSourceConfig(
|
|
||||||
source="financial_datasets",
|
return DataSourceConfig(source=source, api_key=api_key, sources=sources)
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
def get_config() -> DataSourceConfig:
|
def get_config() -> DataSourceConfig:
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ from backend.data.provider_utils import normalize_symbol
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
_SUPPRESSED_LOG_EVERY = 20
|
||||||
|
|
||||||
|
|
||||||
class PollingPriceManager:
|
class PollingPriceManager:
|
||||||
"""Polling-based price manager using Finnhub or yfinance."""
|
"""Polling-based price manager using Finnhub or yfinance."""
|
||||||
|
|
||||||
@@ -43,6 +46,7 @@ class PollingPriceManager:
|
|||||||
self.latest_prices: Dict[str, float] = {}
|
self.latest_prices: Dict[str, float] = {}
|
||||||
self.open_prices: Dict[str, float] = {}
|
self.open_prices: Dict[str, float] = {}
|
||||||
self.price_callbacks: List[Callable] = []
|
self.price_callbacks: List[Callable] = []
|
||||||
|
self._failure_counts: Dict[str, int] = {}
|
||||||
|
|
||||||
self.running = False
|
self.running = False
|
||||||
self._thread: Optional[threading.Thread] = None
|
self._thread: Optional[threading.Thread] = None
|
||||||
@@ -77,6 +81,8 @@ class PollingPriceManager:
|
|||||||
for symbol in self.subscribed_symbols:
|
for symbol in self.subscribed_symbols:
|
||||||
try:
|
try:
|
||||||
quote_data = self._fetch_quote(symbol)
|
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")
|
current_price = quote_data.get("c")
|
||||||
open_price = quote_data.get("o")
|
open_price = quote_data.get("o")
|
||||||
@@ -103,6 +109,13 @@ class PollingPriceManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.latest_prices[symbol] = current_price
|
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 = {
|
price_data = {
|
||||||
"symbol": symbol,
|
"symbol": symbol,
|
||||||
@@ -128,7 +141,20 @@ class PollingPriceManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
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]:
|
def _fetch_quote(self, symbol: str) -> Dict[str, float]:
|
||||||
"""Fetch a normalized quote payload from the configured provider."""
|
"""Fetch a normalized quote payload from the configured provider."""
|
||||||
@@ -136,7 +162,10 @@ class PollingPriceManager:
|
|||||||
return self._fetch_yfinance_quote(symbol)
|
return self._fetch_yfinance_quote(symbol)
|
||||||
if not self.finnhub_client:
|
if not self.finnhub_client:
|
||||||
raise ValueError("Finnhub API key required for finnhub polling")
|
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]:
|
def _fetch_yfinance_quote(self, symbol: str) -> Dict[str, float]:
|
||||||
"""Fetch quote data from yfinance and normalize to Finnhub-like keys."""
|
"""Fetch quote data from yfinance and normalize to Finnhub-like keys."""
|
||||||
@@ -162,6 +191,8 @@ class PollingPriceManager:
|
|||||||
|
|
||||||
if current_price is None:
|
if current_price is None:
|
||||||
history = ticker.history(period="1d", interval="1m", auto_adjust=False)
|
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:
|
if history.empty:
|
||||||
raise ValueError(f"{symbol}: No yfinance quote data")
|
raise ValueError(f"{symbol}: No yfinance quote data")
|
||||||
latest = history.iloc[-1]
|
latest = history.iloc[-1]
|
||||||
|
|||||||
@@ -43,6 +43,71 @@ logger = logging.getLogger(__name__)
|
|||||||
_prompt_loader = get_prompt_loader()
|
_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(
|
async def run_gateway(
|
||||||
run_id: str,
|
run_id: str,
|
||||||
run_dir: Path,
|
run_dir: Path,
|
||||||
@@ -222,11 +287,7 @@ def main():
|
|||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
# Setup logging
|
# Setup logging
|
||||||
level = logging.DEBUG if args.verbose else logging.INFO
|
configure_gateway_logging(verbose=args.verbose)
|
||||||
logging.basicConfig(
|
|
||||||
level=level,
|
|
||||||
format="%(asctime)s | %(levelname)-7s | %(name)s:%(lineno)d - %(message)s",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Parse bootstrap
|
# Parse bootstrap
|
||||||
bootstrap = json.loads(args.bootstrap)
|
bootstrap = json.loads(args.bootstrap)
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
AgentScope Native Model Factory
|
AgentScope Native Model Factory
|
||||||
Uses native AgentScope model classes for LLM calls
|
Uses native AgentScope model classes for LLM calls
|
||||||
"""
|
"""
|
||||||
|
import asyncio
|
||||||
|
import inspect
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
@@ -34,6 +36,27 @@ logger = logging.getLogger(__name__)
|
|||||||
T = TypeVar("T")
|
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:
|
class RetryChatModel:
|
||||||
"""Wraps an AgentScope model with automatic retry for transient errors.
|
"""Wraps an AgentScope model with automatic retry for transient errors.
|
||||||
|
|
||||||
@@ -55,6 +78,7 @@ class RetryChatModel:
|
|||||||
"502",
|
"502",
|
||||||
"504",
|
"504",
|
||||||
"connection",
|
"connection",
|
||||||
|
"disconnected",
|
||||||
"temporary",
|
"temporary",
|
||||||
"overloaded",
|
"overloaded",
|
||||||
"too_many_requests",
|
"too_many_requests",
|
||||||
@@ -150,8 +174,8 @@ class RetryChatModel:
|
|||||||
# Track usage if available
|
# Track usage if available
|
||||||
if hasattr(result, "usage") and result.usage:
|
if hasattr(result, "usage") and result.usage:
|
||||||
usage = result.usage
|
usage = result.usage
|
||||||
self._total_tokens_used += getattr(usage, "total_tokens", 0)
|
self._total_tokens_used += _usage_total_tokens(usage)
|
||||||
self._total_cost += getattr(usage, "cost", 0.0)
|
self._total_cost += float(_usage_value(usage, "cost", 0.0) or 0.0)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -192,9 +216,66 @@ class RetryChatModel:
|
|||||||
raise last_error
|
raise last_error
|
||||||
raise RuntimeError("RetryChatModel: Unexpected state, no error but no result")
|
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:
|
def __call__(self, *args, **kwargs) -> Any:
|
||||||
"""Forward calls to the wrapped model with retry logic."""
|
"""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:
|
def __getattr__(self, name: str) -> Any:
|
||||||
"""Proxy attribute access to the wrapped model."""
|
"""Proxy attribute access to the wrapped model."""
|
||||||
@@ -248,10 +329,18 @@ class TokenRecordingModelWrapper:
|
|||||||
if usage is None:
|
if usage is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
self._prompt_tokens += getattr(usage, "prompt_tokens", 0)
|
prompt_tokens = _usage_value(usage, "prompt_tokens", None)
|
||||||
self._completion_tokens += getattr(usage, "completion_tokens", 0)
|
completion_tokens = _usage_value(usage, "completion_tokens", None)
|
||||||
self._total_tokens += getattr(usage, "total_tokens", 0)
|
|
||||||
self._total_cost += getattr(usage, "cost", 0.0)
|
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:
|
def __call__(self, *args, **kwargs) -> Any:
|
||||||
"""Forward calls and record usage."""
|
"""Forward calls and record usage."""
|
||||||
@@ -401,7 +490,8 @@ def create_model(
|
|||||||
if host:
|
if host:
|
||||||
model_kwargs["host"] = 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):
|
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
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
import pandas_market_calendars as mcal
|
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
|
from backend.data.provider_utils import normalize_symbol
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -142,9 +142,7 @@ class MarketService:
|
|||||||
def _start_real_mode(self):
|
def _start_real_mode(self):
|
||||||
from backend.data.polling_price_manager import PollingPriceManager
|
from backend.data.polling_price_manager import PollingPriceManager
|
||||||
|
|
||||||
provider = get_data_source()
|
provider = self._resolve_live_quote_provider()
|
||||||
if provider == "local_csv":
|
|
||||||
provider = "yfinance"
|
|
||||||
|
|
||||||
if provider == "finnhub" and not self.api_key:
|
if provider == "finnhub" and not self.api_key:
|
||||||
raise ValueError("API key required for live mode")
|
raise ValueError("API key required for live mode")
|
||||||
@@ -157,6 +155,13 @@ class MarketService:
|
|||||||
self._price_manager.subscribe(self.tickers)
|
self._price_manager.subscribe(self.tickers)
|
||||||
self._price_manager.start()
|
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):
|
def _start_backtest_mode(self):
|
||||||
from backend.data.historical_price_manager import (
|
from backend.data.historical_price_manager import (
|
||||||
HistoricalPriceManager,
|
HistoricalPriceManager,
|
||||||
|
|||||||
@@ -2,11 +2,13 @@
|
|||||||
# pylint: disable=W0212
|
# pylint: disable=W0212
|
||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import time
|
||||||
|
import logging
|
||||||
from unittest.mock import MagicMock, AsyncMock, patch
|
from unittest.mock import MagicMock, AsyncMock, patch
|
||||||
import pytest
|
import pytest
|
||||||
from backend.services.market import MarketService
|
from backend.services.market import MarketService
|
||||||
from backend.data.mock_price_manager import MockPriceManager
|
from backend.data.mock_price_manager import MockPriceManager
|
||||||
from backend.data.polling_price_manager import PollingPriceManager
|
from backend.data.polling_price_manager import PollingPriceManager
|
||||||
|
from backend.llm.models import RetryChatModel
|
||||||
|
|
||||||
|
|
||||||
class TestMockPriceManager:
|
class TestMockPriceManager:
|
||||||
@@ -231,6 +233,59 @@ class TestPollingPriceManager:
|
|||||||
|
|
||||||
assert len(manager.open_prices) == 0
|
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:
|
class TestMarketService:
|
||||||
def test_init_mock_mode(self):
|
def test_init_mock_mode(self):
|
||||||
@@ -255,9 +310,23 @@ class TestMarketService:
|
|||||||
assert service.mock_mode is False
|
assert service.mock_mode is False
|
||||||
assert service.api_key == "test_key"
|
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")
|
@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(
|
service = MarketService(
|
||||||
tickers=["AAPL"],
|
tickers=["AAPL"],
|
||||||
poll_interval=10,
|
poll_interval=10,
|
||||||
@@ -287,9 +356,9 @@ class TestMarketService:
|
|||||||
|
|
||||||
service.stop()
|
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
|
@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(
|
service = MarketService(
|
||||||
tickers=["AAPL"],
|
tickers=["AAPL"],
|
||||||
mock_mode=False,
|
mock_mode=False,
|
||||||
|
|||||||
3349
frontend/src/App.jsx
3349
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: '审批事件' }
|
{ 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) {
|
function metricCard(label, value, accent, helper = null) {
|
||||||
return (
|
return (
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
@@ -722,6 +734,9 @@ export default function RuntimeView() {
|
|||||||
{sectionTitle(
|
{sectionTitle(
|
||||||
'近期事件',
|
'近期事件',
|
||||||
<select
|
<select
|
||||||
|
id="runtime-event-filter"
|
||||||
|
name="runtime_event_filter"
|
||||||
|
aria-label="筛选近期事件"
|
||||||
value={eventFilter}
|
value={eventFilter}
|
||||||
onChange={(event) => setEventFilter(event.target.value)}
|
onChange={(event) => setEventFilter(event.target.value)}
|
||||||
style={{
|
style={{
|
||||||
@@ -739,6 +754,9 @@ export default function RuntimeView() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
|
<label htmlFor="runtime-event-filter" style={SR_ONLY_STYLE}>
|
||||||
|
筛选近期事件
|
||||||
|
</label>
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gap: 8,
|
gap: 8,
|
||||||
|
|||||||
@@ -38,6 +38,18 @@ export default function TraderView({
|
|||||||
onWorkspaceFileSave,
|
onWorkspaceFileSave,
|
||||||
onUploadExternalSkill
|
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 [expandedSkillKey, setExpandedSkillKey] = useState(null);
|
||||||
const [newLocalSkillName, setNewLocalSkillName] = useState('');
|
const [newLocalSkillName, setNewLocalSkillName] = useState('');
|
||||||
const [externalSkillFile, setExternalSkillFile] = useState(null);
|
const [externalSkillFile, setExternalSkillFile] = useState(null);
|
||||||
@@ -460,6 +472,9 @@ export default function TraderView({
|
|||||||
本地技能 SKILL.md
|
本地技能 SKILL.md
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
|
id={`local-skill-${selectedAgentId}-${skill.skill_name}`}
|
||||||
|
name={`local_skill_${selectedAgentId}_${skill.skill_name}`}
|
||||||
|
aria-label={`${skill.skill_name} 本地技能内容`}
|
||||||
value={skillDraft}
|
value={skillDraft}
|
||||||
onChange={(e) => onLocalSkillDraftChange(skill.skill_name, e.target.value)}
|
onChange={(e) => onLocalSkillDraftChange(skill.skill_name, e.target.value)}
|
||||||
style={{
|
style={{
|
||||||
@@ -557,6 +572,9 @@ export default function TraderView({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
|
id={`workspace-editor-${selectedAgentId}-${selectedWorkspaceFile || 'file'}`}
|
||||||
|
name={`workspace_editor_${selectedAgentId}_${selectedWorkspaceFile || 'file'}`}
|
||||||
|
aria-label={`编辑 ${selectedWorkspaceFile || '工作区文件'} 内容`}
|
||||||
value={workspaceDraftContent}
|
value={workspaceDraftContent}
|
||||||
onChange={(e) => onWorkspaceDraftChange(e.target.value)}
|
onChange={(e) => onWorkspaceDraftChange(e.target.value)}
|
||||||
placeholder={isWorkspaceFileLoading ? '加载中...' : '输入 markdown 内容'}
|
placeholder={isWorkspaceFileLoading ? '加载中...' : '输入 markdown 内容'}
|
||||||
@@ -687,7 +705,13 @@ export default function TraderView({
|
|||||||
}}>
|
}}>
|
||||||
<div style={{ fontSize: 12, fontWeight: 800, color: '#111111' }}>创建本地技能</div>
|
<div style={{ fontSize: 12, fontWeight: 800, color: '#111111' }}>创建本地技能</div>
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
<label htmlFor="new-local-skill-name" style={srOnlyStyle}>
|
||||||
|
输入本地技能名称
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="new-local-skill-name"
|
||||||
|
name="new_local_skill_name"
|
||||||
|
aria-label="输入本地技能名称"
|
||||||
value={newLocalSkillName}
|
value={newLocalSkillName}
|
||||||
onChange={(e) => setNewLocalSkillName(e.target.value)}
|
onChange={(e) => setNewLocalSkillName(e.target.value)}
|
||||||
placeholder="输入技能名,例如 event_playbook"
|
placeholder="输入技能名,例如 event_playbook"
|
||||||
@@ -741,7 +765,13 @@ export default function TraderView({
|
|||||||
支持上传 .zip(包内需包含一个技能目录及 SKILL.md)
|
支持上传 .zip(包内需包含一个技能目录及 SKILL.md)
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<label htmlFor="external-skill-zip" style={srOnlyStyle}>
|
||||||
|
上传外部技能 zip 包
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="external-skill-zip"
|
||||||
|
name="external_skill_zip"
|
||||||
|
aria-label="上传外部技能 zip 包"
|
||||||
type="file"
|
type="file"
|
||||||
accept=".zip,application/zip"
|
accept=".zip,application/zip"
|
||||||
onChange={async (e) => {
|
onChange={async (e) => {
|
||||||
|
|||||||
@@ -19,6 +19,18 @@ export default function WatchlistPanel({
|
|||||||
onSuggestionClick,
|
onSuggestionClick,
|
||||||
onSave
|
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 (
|
return (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, position: 'relative', marginLeft: -6 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, position: 'relative', marginLeft: -6 }}>
|
||||||
<button
|
<button
|
||||||
@@ -117,7 +129,13 @@ export default function WatchlistPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<label htmlFor="watchlist-symbol-input" style={srOnlyStyle}>
|
||||||
|
输入股票代码
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="watchlist-symbol-input"
|
||||||
|
name="watchlist_symbol"
|
||||||
|
aria-label="输入股票代码"
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={(e) => onInputChange(e.target.value)}
|
onChange={(e) => onInputChange(e.target.value)}
|
||||||
onKeyDown={onInputKeyDown}
|
onKeyDown={onInputKeyDown}
|
||||||
|
|||||||
@@ -11,6 +11,37 @@ export default function ExplainPriceSection({
|
|||||||
isOpen,
|
isOpen,
|
||||||
onToggle,
|
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 (
|
return (
|
||||||
<div className="section">
|
<div className="section">
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
@@ -66,12 +97,35 @@ export default function ExplainPriceSection({
|
|||||||
strokeWidth="1"
|
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) => {
|
{chartModel.candles.length > 1 ? chartModel.candles.map((candle) => {
|
||||||
const rising = candle.close >= candle.open;
|
const rising = candle.close >= candle.open;
|
||||||
const stroke = rising ? '#00C853' : '#FF1744';
|
const stroke = rising ? '#00C853' : '#FF1744';
|
||||||
const fill = rising ? 'rgba(0, 200, 83, 0.16)' : 'rgba(255, 23, 68, 0.16)';
|
const fill = rising ? 'rgba(0, 200, 83, 0.16)' : 'rgba(255, 23, 68, 0.16)';
|
||||||
return (
|
return (
|
||||||
<g key={candle.id}>
|
<g key={candle.id}>
|
||||||
|
<title>{`${candle.startLabel || candle.time || candle.date || ''} → ${candle.endLabel || candle.time || candle.date || ''}`}</title>
|
||||||
<line
|
<line
|
||||||
x1={candle.centerX}
|
x1={candle.centerX}
|
||||||
y1={candle.highY}
|
y1={candle.highY}
|
||||||
@@ -123,7 +177,7 @@ export default function ExplainPriceSection({
|
|||||||
stroke={marker.isSelected ? '#111111' : '#ffffff'}
|
stroke={marker.isSelected ? '#111111' : '#ffffff'}
|
||||||
strokeWidth={marker.isSelected ? '2.5' : '2'}
|
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>
|
</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');
|
return safeFetch(RUNTIME_API_BASE, '/current');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchRuntimeLogs() {
|
||||||
|
return safeFetch(RUNTIME_API_BASE, '/logs');
|
||||||
|
}
|
||||||
|
|
||||||
export async function uploadAgentSkillZip({
|
export async function uploadAgentSkillZip({
|
||||||
agentId,
|
agentId,
|
||||||
file,
|
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';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
const resolveValue = (updater, currentValue) => (
|
||||||
|
typeof updater === 'function' ? updater(currentValue) : updater
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agent Store - Agent skills, profiles, workspaces
|
* Agent Store - Agent skills, profiles, workspaces
|
||||||
*/
|
*/
|
||||||
export const useAgentStore = create((set) => ({
|
export const useAgentStore = create((set) => ({
|
||||||
// Selected agent for skill/workspace editing
|
// Selected agent for skill/workspace editing
|
||||||
selectedSkillAgentId: null,
|
selectedSkillAgentId: null,
|
||||||
setSelectedSkillAgentId: (selectedSkillAgentId) => set({ selectedSkillAgentId }),
|
setSelectedSkillAgentId: (selectedSkillAgentId) => set((state) => ({ selectedSkillAgentId: resolveValue(selectedSkillAgentId, state.selectedSkillAgentId) })),
|
||||||
|
|
||||||
// Agent profiles
|
// Agent profiles
|
||||||
agentProfilesByAgent: {},
|
agentProfilesByAgent: {},
|
||||||
setAgentProfilesByAgent: (agentProfilesByAgent) => set({ agentProfilesByAgent }),
|
setAgentProfilesByAgent: (agentProfilesByAgent) => set((state) => ({ agentProfilesByAgent: resolveValue(agentProfilesByAgent, state.agentProfilesByAgent) })),
|
||||||
|
|
||||||
// Agent skills
|
// Agent skills
|
||||||
agentSkillsByAgent: {},
|
agentSkillsByAgent: {},
|
||||||
setAgentSkillsByAgent: (agentSkillsByAgent) => set({ agentSkillsByAgent }),
|
setAgentSkillsByAgent: (agentSkillsByAgent) => set((state) => ({ agentSkillsByAgent: resolveValue(agentSkillsByAgent, state.agentSkillsByAgent) })),
|
||||||
|
|
||||||
// Skill details
|
// Skill details
|
||||||
skillDetailsByName: {},
|
skillDetailsByName: {},
|
||||||
setSkillDetailsByName: (skillDetailsByName) => set({ skillDetailsByName }),
|
setSkillDetailsByName: (skillDetailsByName) => set((state) => ({ skillDetailsByName: resolveValue(skillDetailsByName, state.skillDetailsByName) })),
|
||||||
|
|
||||||
// Local skill drafts
|
// Local skill drafts
|
||||||
localSkillDraftsByKey: {},
|
localSkillDraftsByKey: {},
|
||||||
setLocalSkillDraftsByKey: (localSkillDraftsByKey) => set({ localSkillDraftsByKey }),
|
setLocalSkillDraftsByKey: (localSkillDraftsByKey) => set((state) => ({ localSkillDraftsByKey: resolveValue(localSkillDraftsByKey, state.localSkillDraftsByKey) })),
|
||||||
|
|
||||||
// Loading states
|
// Loading states
|
||||||
isAgentSkillsLoading: false,
|
isAgentSkillsLoading: false,
|
||||||
setIsAgentSkillsLoading: (isAgentSkillsLoading) => set({ isAgentSkillsLoading }),
|
setIsAgentSkillsLoading: (isAgentSkillsLoading) => set((state) => ({ isAgentSkillsLoading: resolveValue(isAgentSkillsLoading, state.isAgentSkillsLoading) })),
|
||||||
|
|
||||||
skillDetailLoadingKey: null,
|
skillDetailLoadingKey: null,
|
||||||
setSkillDetailLoadingKey: (skillDetailLoadingKey) => set({ skillDetailLoadingKey }),
|
setSkillDetailLoadingKey: (skillDetailLoadingKey) => set((state) => ({ skillDetailLoadingKey: resolveValue(skillDetailLoadingKey, state.skillDetailLoadingKey) })),
|
||||||
|
|
||||||
agentSkillsSavingKey: null,
|
agentSkillsSavingKey: null,
|
||||||
setAgentSkillsSavingKey: (agentSkillsSavingKey) => set({ agentSkillsSavingKey }),
|
setAgentSkillsSavingKey: (agentSkillsSavingKey) => set((state) => ({ agentSkillsSavingKey: resolveValue(agentSkillsSavingKey, state.agentSkillsSavingKey) })),
|
||||||
|
|
||||||
agentSkillsFeedback: null,
|
agentSkillsFeedback: null,
|
||||||
setAgentSkillsFeedback: (agentSkillsFeedback) => set({ agentSkillsFeedback }),
|
setAgentSkillsFeedback: (agentSkillsFeedback) => set((state) => ({ agentSkillsFeedback: resolveValue(agentSkillsFeedback, state.agentSkillsFeedback) })),
|
||||||
|
|
||||||
// Workspace files
|
// Workspace files
|
||||||
selectedWorkspaceFile: null,
|
selectedWorkspaceFile: null,
|
||||||
setSelectedWorkspaceFile: (selectedWorkspaceFile) => set({ selectedWorkspaceFile }),
|
setSelectedWorkspaceFile: (selectedWorkspaceFile) => set((state) => ({ selectedWorkspaceFile: resolveValue(selectedWorkspaceFile, state.selectedWorkspaceFile) })),
|
||||||
|
|
||||||
workspaceFilesByAgent: {},
|
workspaceFilesByAgent: {},
|
||||||
setWorkspaceFilesByAgent: (workspaceFilesByAgent) => set({ workspaceFilesByAgent }),
|
setWorkspaceFilesByAgent: (workspaceFilesByAgent) => set((state) => ({ workspaceFilesByAgent: resolveValue(workspaceFilesByAgent, state.workspaceFilesByAgent) })),
|
||||||
|
|
||||||
workspaceDraftContent: '',
|
workspaceDraftContent: '',
|
||||||
setWorkspaceDraftContent: (workspaceDraftContent) => set({ workspaceDraftContent }),
|
setWorkspaceDraftContent: (workspaceDraftContent) => set((state) => ({ workspaceDraftContent: resolveValue(workspaceDraftContent, state.workspaceDraftContent) })),
|
||||||
|
|
||||||
isWorkspaceFileLoading: false,
|
isWorkspaceFileLoading: false,
|
||||||
setIsWorkspaceFileLoading: (isWorkspaceFileLoading) => set({ isWorkspaceFileLoading }),
|
setIsWorkspaceFileLoading: (isWorkspaceFileLoading) => set((state) => ({ isWorkspaceFileLoading: resolveValue(isWorkspaceFileLoading, state.isWorkspaceFileLoading) })),
|
||||||
|
|
||||||
workspaceFileSavingKey: null,
|
workspaceFileSavingKey: null,
|
||||||
setWorkspaceFileSavingKey: (workspaceFileSavingKey) => set({ workspaceFileSavingKey }),
|
setWorkspaceFileSavingKey: (workspaceFileSavingKey) => set((state) => ({ workspaceFileSavingKey: resolveValue(workspaceFileSavingKey, state.workspaceFileSavingKey) })),
|
||||||
|
|
||||||
workspaceFileFeedback: null,
|
workspaceFileFeedback: null,
|
||||||
setWorkspaceFileFeedback: (workspaceFileFeedback) => set({ workspaceFileFeedback }),
|
setWorkspaceFileFeedback: (workspaceFileFeedback) => set((state) => ({ workspaceFileFeedback: resolveValue(workspaceFileFeedback, state.workspaceFileFeedback) })),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,44 +1,48 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
const resolveValue = (updater, currentValue) => (
|
||||||
|
typeof updater === 'function' ? updater(currentValue) : updater
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Market Store - Market data, stock prices, news
|
* Market Store - Market data, stock prices, news
|
||||||
*/
|
*/
|
||||||
export const useMarketStore = create((set) => ({
|
export const useMarketStore = create((set) => ({
|
||||||
// Ticker prices
|
// Ticker prices
|
||||||
tickers: [],
|
tickers: [],
|
||||||
setTickers: (tickers) => set({ tickers }),
|
setTickers: (tickers) => set((state) => ({ tickers: resolveValue(tickers, state.tickers) })),
|
||||||
rollingTickers: {},
|
rollingTickers: {},
|
||||||
setRollingTickers: (rollingTickers) => set({ rollingTickers }),
|
setRollingTickers: (rollingTickers) => set((state) => ({ rollingTickers: resolveValue(rollingTickers, state.rollingTickers) })),
|
||||||
|
|
||||||
// Price history
|
// Price history
|
||||||
priceHistoryByTicker: {},
|
priceHistoryByTicker: {},
|
||||||
setPriceHistoryByTicker: (priceHistoryByTicker) => set({ priceHistoryByTicker }),
|
setPriceHistoryByTicker: (priceHistoryByTicker) => set((state) => ({ priceHistoryByTicker: resolveValue(priceHistoryByTicker, state.priceHistoryByTicker) })),
|
||||||
|
|
||||||
// OHLC history
|
// OHLC history
|
||||||
ohlcHistoryByTicker: {},
|
ohlcHistoryByTicker: {},
|
||||||
setOhlcHistoryByTicker: (ohlcHistoryByTicker) => set({ ohlcHistoryByTicker }),
|
setOhlcHistoryByTicker: (ohlcHistoryByTicker) => set((state) => ({ ohlcHistoryByTicker: resolveValue(ohlcHistoryByTicker, state.ohlcHistoryByTicker) })),
|
||||||
|
|
||||||
// History source tracking
|
// History source tracking
|
||||||
historySourceByTicker: {},
|
historySourceByTicker: {},
|
||||||
setHistorySourceByTicker: (historySourceByTicker) => set({ historySourceByTicker }),
|
setHistorySourceByTicker: (historySourceByTicker) => set((state) => ({ historySourceByTicker: resolveValue(historySourceByTicker, state.historySourceByTicker) })),
|
||||||
|
|
||||||
// Explain events
|
// Explain events
|
||||||
explainEventsByTicker: {},
|
explainEventsByTicker: {},
|
||||||
setExplainEventsByTicker: (explainEventsByTicker) => set({ explainEventsByTicker }),
|
setExplainEventsByTicker: (explainEventsByTicker) => set((state) => ({ explainEventsByTicker: resolveValue(explainEventsByTicker, state.explainEventsByTicker) })),
|
||||||
|
|
||||||
// Selected explain symbol
|
// Selected explain symbol
|
||||||
selectedExplainSymbol: '',
|
selectedExplainSymbol: '',
|
||||||
setSelectedExplainSymbol: (selectedExplainSymbol) => set({ selectedExplainSymbol }),
|
setSelectedExplainSymbol: (selectedExplainSymbol) => set((state) => ({ selectedExplainSymbol: resolveValue(selectedExplainSymbol, state.selectedExplainSymbol) })),
|
||||||
|
|
||||||
// News by ticker
|
// News by ticker
|
||||||
newsByTicker: {},
|
newsByTicker: {},
|
||||||
setNewsByTicker: (newsByTicker) => set({ newsByTicker }),
|
setNewsByTicker: (newsByTicker) => set((state) => ({ newsByTicker: resolveValue(newsByTicker, state.newsByTicker) })),
|
||||||
|
|
||||||
// Insider trades
|
// Insider trades
|
||||||
insiderTradesByTicker: {},
|
insiderTradesByTicker: {},
|
||||||
setInsiderTradesByTicker: (insiderTradesByTicker) => set({ insiderTradesByTicker }),
|
setInsiderTradesByTicker: (insiderTradesByTicker) => set((state) => ({ insiderTradesByTicker: resolveValue(insiderTradesByTicker, state.insiderTradesByTicker) })),
|
||||||
|
|
||||||
// Technical indicators
|
// Technical indicators
|
||||||
technicalIndicatorsByTicker: {},
|
technicalIndicatorsByTicker: {},
|
||||||
setTechnicalIndicatorsByTicker: (technicalIndicatorsByTicker) => set({ technicalIndicatorsByTicker }),
|
setTechnicalIndicatorsByTicker: (technicalIndicatorsByTicker) => set((state) => ({ technicalIndicatorsByTicker: resolveValue(technicalIndicatorsByTicker, state.technicalIndicatorsByTicker) })),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
const resolveValue = (updater, currentValue) => (
|
||||||
|
typeof updater === 'function' ? updater(currentValue) : updater
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Portfolio Store - Portfolio data, holdings, trades, statistics
|
* Portfolio Store - Portfolio data, holdings, trades, statistics
|
||||||
*/
|
*/
|
||||||
@@ -18,21 +22,21 @@ export const usePortfolioStore = create((set) => ({
|
|||||||
baseline_vw_return: 0,
|
baseline_vw_return: 0,
|
||||||
momentum_return: 0,
|
momentum_return: 0,
|
||||||
},
|
},
|
||||||
setPortfolioData: (portfolioData) => set({ portfolioData }),
|
setPortfolioData: (portfolioData) => set((state) => ({ portfolioData: resolveValue(portfolioData, state.portfolioData) })),
|
||||||
|
|
||||||
// Holdings
|
// Holdings
|
||||||
holdings: [],
|
holdings: [],
|
||||||
setHoldings: (holdings) => set({ holdings }),
|
setHoldings: (holdings) => set((state) => ({ holdings: resolveValue(holdings, state.holdings) })),
|
||||||
|
|
||||||
// Trades
|
// Trades
|
||||||
trades: [],
|
trades: [],
|
||||||
setTrades: (trades) => set({ trades }),
|
setTrades: (trades) => set((state) => ({ trades: resolveValue(trades, state.trades) })),
|
||||||
|
|
||||||
// Statistics
|
// Statistics
|
||||||
stats: null,
|
stats: null,
|
||||||
setStats: (stats) => set({ stats }),
|
setStats: (stats) => set((state) => ({ stats: resolveValue(stats, state.stats) })),
|
||||||
|
|
||||||
// Leaderboard
|
// Leaderboard
|
||||||
leaderboard: [],
|
leaderboard: [],
|
||||||
setLeaderboard: (leaderboard) => set({ leaderboard }),
|
setLeaderboard: (leaderboard) => set((state) => ({ leaderboard: resolveValue(leaderboard, state.leaderboard) })),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
const resolveValue = (updater, currentValue) => (
|
||||||
|
typeof updater === 'function' ? updater(currentValue) : updater
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runtime Store - Connection state and runtime configuration
|
* Runtime Store - Connection state and runtime configuration
|
||||||
*/
|
*/
|
||||||
@@ -7,59 +11,59 @@ export const useRuntimeStore = create((set) => ({
|
|||||||
// Connection state
|
// Connection state
|
||||||
isConnected: false,
|
isConnected: false,
|
||||||
connectionStatus: 'connecting', // 'connecting' | 'connected' | 'disconnected'
|
connectionStatus: 'connecting', // 'connecting' | 'connected' | 'disconnected'
|
||||||
setIsConnected: (isConnected) => set({ isConnected }),
|
setIsConnected: (isConnected) => set((state) => ({ isConnected: resolveValue(isConnected, state.isConnected) })),
|
||||||
setConnectionStatus: (connectionStatus) => set({ connectionStatus }),
|
setConnectionStatus: (connectionStatus) => set((state) => ({ connectionStatus: resolveValue(connectionStatus, state.connectionStatus) })),
|
||||||
|
|
||||||
// System state
|
// System state
|
||||||
systemStatus: 'initializing', // 'initializing' | 'running' | 'completed'
|
systemStatus: 'initializing', // 'initializing' | 'running' | 'completed'
|
||||||
currentDate: null,
|
currentDate: null,
|
||||||
setSystemStatus: (systemStatus) => set({ systemStatus }),
|
setSystemStatus: (systemStatus) => set((state) => ({ systemStatus: resolveValue(systemStatus, state.systemStatus) })),
|
||||||
setCurrentDate: (currentDate) => set({ currentDate }),
|
setCurrentDate: (currentDate) => set((state) => ({ currentDate: resolveValue(currentDate, state.currentDate) })),
|
||||||
|
|
||||||
// Progress
|
// Progress
|
||||||
progress: { current: 0, total: 0 },
|
progress: { current: 0, total: 0 },
|
||||||
setProgress: (progress) => set({ progress }),
|
setProgress: (progress) => set((state) => ({ progress: resolveValue(progress, state.progress) })),
|
||||||
|
|
||||||
// Server mode
|
// Server mode
|
||||||
serverMode: null, // 'live' | 'backtest' | null
|
serverMode: null, // 'live' | 'backtest' | null
|
||||||
setServerMode: (serverMode) => set({ serverMode }),
|
setServerMode: (serverMode) => set((state) => ({ serverMode: resolveValue(serverMode, state.serverMode) })),
|
||||||
|
|
||||||
// Market status
|
// Market status
|
||||||
marketStatus: null,
|
marketStatus: null,
|
||||||
virtualTime: null,
|
virtualTime: null,
|
||||||
setMarketStatus: (marketStatus) => set({ marketStatus }),
|
setMarketStatus: (marketStatus) => set((state) => ({ marketStatus: resolveValue(marketStatus, state.marketStatus) })),
|
||||||
setVirtualTime: (virtualTime) => set({ virtualTime }),
|
setVirtualTime: (virtualTime) => set((state) => ({ virtualTime: resolveValue(virtualTime, state.virtualTime) })),
|
||||||
|
|
||||||
// Data sources
|
// Data sources
|
||||||
dataSources: null,
|
dataSources: null,
|
||||||
setDataSources: (dataSources) => set({ dataSources }),
|
setDataSources: (dataSources) => set((state) => ({ dataSources: resolveValue(dataSources, state.dataSources) })),
|
||||||
|
|
||||||
// Runtime config
|
// Runtime config
|
||||||
runtimeConfig: null,
|
runtimeConfig: null,
|
||||||
setRuntimeConfig: (runtimeConfig) => set({ runtimeConfig }),
|
setRuntimeConfig: (runtimeConfig) => set((state) => ({ runtimeConfig: resolveValue(runtimeConfig, state.runtimeConfig) })),
|
||||||
|
|
||||||
// Watchlist panel
|
// Watchlist panel
|
||||||
isWatchlistPanelOpen: false,
|
isWatchlistPanelOpen: false,
|
||||||
setIsWatchlistPanelOpen: (isWatchlistPanelOpen) => set({ isWatchlistPanelOpen }),
|
setIsWatchlistPanelOpen: (isWatchlistPanelOpen) => set((state) => ({ isWatchlistPanelOpen: resolveValue(isWatchlistPanelOpen, state.isWatchlistPanelOpen) })),
|
||||||
|
|
||||||
// Watchlist draft
|
// Watchlist draft
|
||||||
watchlistDraftSymbols: [],
|
watchlistDraftSymbols: [],
|
||||||
watchlistInputValue: '',
|
watchlistInputValue: '',
|
||||||
watchlistFeedback: null,
|
watchlistFeedback: null,
|
||||||
isWatchlistSaving: false,
|
isWatchlistSaving: false,
|
||||||
setWatchlistDraftSymbols: (watchlistDraftSymbols) => set({ watchlistDraftSymbols }),
|
setWatchlistDraftSymbols: (watchlistDraftSymbols) => set((state) => ({ watchlistDraftSymbols: resolveValue(watchlistDraftSymbols, state.watchlistDraftSymbols) })),
|
||||||
setWatchlistInputValue: (watchlistInputValue) => set({ watchlistInputValue }),
|
setWatchlistInputValue: (watchlistInputValue) => set((state) => ({ watchlistInputValue: resolveValue(watchlistInputValue, state.watchlistInputValue) })),
|
||||||
setWatchlistFeedback: (watchlistFeedback) => set({ watchlistFeedback }),
|
setWatchlistFeedback: (watchlistFeedback) => set((state) => ({ watchlistFeedback: resolveValue(watchlistFeedback, state.watchlistFeedback) })),
|
||||||
setIsWatchlistSaving: (isWatchlistSaving) => set({ isWatchlistSaving }),
|
setIsWatchlistSaving: (isWatchlistSaving) => set((state) => ({ isWatchlistSaving: resolveValue(isWatchlistSaving, state.isWatchlistSaving) })),
|
||||||
|
|
||||||
// Runtime settings panel
|
// Runtime settings panel
|
||||||
isRuntimeSettingsOpen: false,
|
isRuntimeSettingsOpen: false,
|
||||||
setIsRuntimeSettingsOpen: (isRuntimeSettingsOpen) => set({ isRuntimeSettingsOpen }),
|
setIsRuntimeSettingsOpen: (isRuntimeSettingsOpen) => set((state) => ({ isRuntimeSettingsOpen: resolveValue(isRuntimeSettingsOpen, state.isRuntimeSettingsOpen) })),
|
||||||
|
|
||||||
// Runtime config drafts
|
// Runtime config drafts
|
||||||
scheduleModeDraft: 'daily',
|
scheduleModeDraft: 'daily',
|
||||||
intervalMinutesDraft: '60',
|
intervalMinutesDraft: '60',
|
||||||
triggerTimeDraft: '09:30',
|
triggerTimeDraft: 'now',
|
||||||
maxCommCyclesDraft: '2',
|
maxCommCyclesDraft: '2',
|
||||||
initialCashDraft: '100000',
|
initialCashDraft: '100000',
|
||||||
marginRequirementDraft: '0',
|
marginRequirementDraft: '0',
|
||||||
@@ -69,26 +73,26 @@ export const useRuntimeStore = create((set) => ({
|
|||||||
startDateDraft: '',
|
startDateDraft: '',
|
||||||
endDateDraft: '',
|
endDateDraft: '',
|
||||||
enableMockDraft: false,
|
enableMockDraft: false,
|
||||||
setScheduleModeDraft: (scheduleModeDraft) => set({ scheduleModeDraft }),
|
setScheduleModeDraft: (scheduleModeDraft) => set((state) => ({ scheduleModeDraft: resolveValue(scheduleModeDraft, state.scheduleModeDraft) })),
|
||||||
setIntervalMinutesDraft: (intervalMinutesDraft) => set({ intervalMinutesDraft }),
|
setIntervalMinutesDraft: (intervalMinutesDraft) => set((state) => ({ intervalMinutesDraft: resolveValue(intervalMinutesDraft, state.intervalMinutesDraft) })),
|
||||||
setTriggerTimeDraft: (triggerTimeDraft) => set({ triggerTimeDraft }),
|
setTriggerTimeDraft: (triggerTimeDraft) => set((state) => ({ triggerTimeDraft: resolveValue(triggerTimeDraft, state.triggerTimeDraft) })),
|
||||||
setMaxCommCyclesDraft: (maxCommCyclesDraft) => set({ maxCommCyclesDraft }),
|
setMaxCommCyclesDraft: (maxCommCyclesDraft) => set((state) => ({ maxCommCyclesDraft: resolveValue(maxCommCyclesDraft, state.maxCommCyclesDraft) })),
|
||||||
setInitialCashDraft: (initialCashDraft) => set({ initialCashDraft }),
|
setInitialCashDraft: (initialCashDraft) => set((state) => ({ initialCashDraft: resolveValue(initialCashDraft, state.initialCashDraft) })),
|
||||||
setMarginRequirementDraft: (marginRequirementDraft) => set({ marginRequirementDraft }),
|
setMarginRequirementDraft: (marginRequirementDraft) => set((state) => ({ marginRequirementDraft: resolveValue(marginRequirementDraft, state.marginRequirementDraft) })),
|
||||||
setEnableMemoryDraft: (enableMemoryDraft) => set({ enableMemoryDraft }),
|
setEnableMemoryDraft: (enableMemoryDraft) => set((state) => ({ enableMemoryDraft: resolveValue(enableMemoryDraft, state.enableMemoryDraft) })),
|
||||||
setModeDraft: (modeDraft) => set({ modeDraft }),
|
setModeDraft: (modeDraft) => set((state) => ({ modeDraft: resolveValue(modeDraft, state.modeDraft) })),
|
||||||
setPollIntervalDraft: (pollIntervalDraft) => set({ pollIntervalDraft }),
|
setPollIntervalDraft: (pollIntervalDraft) => set((state) => ({ pollIntervalDraft: resolveValue(pollIntervalDraft, state.pollIntervalDraft) })),
|
||||||
setStartDateDraft: (startDateDraft) => set({ startDateDraft }),
|
setStartDateDraft: (startDateDraft) => set((state) => ({ startDateDraft: resolveValue(startDateDraft, state.startDateDraft) })),
|
||||||
setEndDateDraft: (endDateDraft) => set({ endDateDraft }),
|
setEndDateDraft: (endDateDraft) => set((state) => ({ endDateDraft: resolveValue(endDateDraft, state.endDateDraft) })),
|
||||||
setEnableMockDraft: (enableMockDraft) => set({ enableMockDraft }),
|
setEnableMockDraft: (enableMockDraft) => set((state) => ({ enableMockDraft: resolveValue(enableMockDraft, state.enableMockDraft) })),
|
||||||
|
|
||||||
// Runtime config feedback
|
// Runtime config feedback
|
||||||
runtimeConfigFeedback: null,
|
runtimeConfigFeedback: null,
|
||||||
isRuntimeConfigSaving: false,
|
isRuntimeConfigSaving: false,
|
||||||
setRuntimeConfigFeedback: (runtimeConfigFeedback) => set({ runtimeConfigFeedback }),
|
setRuntimeConfigFeedback: (runtimeConfigFeedback) => set((state) => ({ runtimeConfigFeedback: resolveValue(runtimeConfigFeedback, state.runtimeConfigFeedback) })),
|
||||||
setIsRuntimeConfigSaving: (isRuntimeConfigSaving) => set({ isRuntimeConfigSaving }),
|
setIsRuntimeConfigSaving: (isRuntimeConfigSaving) => set((state) => ({ isRuntimeConfigSaving: resolveValue(isRuntimeConfigSaving, state.isRuntimeConfigSaving) })),
|
||||||
|
|
||||||
// Last day history (for replay)
|
// Last day history (for replay)
|
||||||
lastDayHistory: [],
|
lastDayHistory: [],
|
||||||
setLastDayHistory: (lastDayHistory) => set({ lastDayHistory }),
|
setLastDayHistory: (lastDayHistory) => set((state) => ({ lastDayHistory: resolveValue(lastDayHistory, state.lastDayHistory) })),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,40 +1,44 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
const resolveValue = (updater, currentValue) => (
|
||||||
|
typeof updater === 'function' ? updater(currentValue) : updater
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UI Store - UI state, view management, layout
|
* UI Store - UI state, view management, layout
|
||||||
*/
|
*/
|
||||||
export const useUIStore = create((set) => ({
|
export const useUIStore = create((set) => ({
|
||||||
// Current view
|
// Current view
|
||||||
currentView: 'traders', // 'traders' | 'room' | 'explain' | 'chart' | 'statistics' | 'runtime'
|
currentView: 'traders', // 'traders' | 'room' | 'explain' | 'chart' | 'statistics' | 'runtime'
|
||||||
setCurrentView: (currentView) => set({ currentView }),
|
setCurrentView: (currentView) => set((state) => ({ currentView: resolveValue(currentView, state.currentView) })),
|
||||||
|
|
||||||
// Chart tab
|
// Chart tab
|
||||||
chartTab: 'all',
|
chartTab: 'all',
|
||||||
setChartTab: (chartTab) => set({ chartTab }),
|
setChartTab: (chartTab) => set((state) => ({ chartTab: resolveValue(chartTab, state.chartTab) })),
|
||||||
|
|
||||||
// Initial animation
|
// Initial animation
|
||||||
isInitialAnimating: true,
|
isInitialAnimating: true,
|
||||||
setIsInitialAnimating: (isInitialAnimating) => set({ isInitialAnimating }),
|
setIsInitialAnimating: (isInitialAnimating) => set((state) => ({ isInitialAnimating: resolveValue(isInitialAnimating, state.isInitialAnimating) })),
|
||||||
|
|
||||||
// Last update timestamp
|
// Last update timestamp
|
||||||
lastUpdate: new Date(),
|
lastUpdate: new Date(),
|
||||||
setLastUpdate: (lastUpdate) => set({ lastUpdate }),
|
setLastUpdate: (lastUpdate) => set((state) => ({ lastUpdate: resolveValue(lastUpdate, state.lastUpdate) })),
|
||||||
|
|
||||||
// Is updating
|
// Is updating
|
||||||
isUpdating: false,
|
isUpdating: false,
|
||||||
setIsUpdating: (isUpdating) => set({ isUpdating }),
|
setIsUpdating: (isUpdating) => set((state) => ({ isUpdating: resolveValue(isUpdating, state.isUpdating) })),
|
||||||
|
|
||||||
// Room bubbles
|
// Room bubbles
|
||||||
bubbles: {},
|
bubbles: {},
|
||||||
setBubbles: (bubbles) => set({ bubbles }),
|
setBubbles: (bubbles) => set((state) => ({ bubbles: resolveValue(bubbles, state.bubbles) })),
|
||||||
|
|
||||||
// Resizable panels
|
// Resizable panels
|
||||||
leftWidth: 70,
|
leftWidth: 70,
|
||||||
setLeftWidth: (leftWidth) => set({ leftWidth }),
|
setLeftWidth: (leftWidth) => set((state) => ({ leftWidth: resolveValue(leftWidth, state.leftWidth) })),
|
||||||
isResizing: false,
|
isResizing: false,
|
||||||
setIsResizing: (isResizing) => set({ isResizing }),
|
setIsResizing: (isResizing) => set((state) => ({ isResizing: resolveValue(isResizing, state.isResizing) })),
|
||||||
|
|
||||||
// Now timestamp (for current time display)
|
// Now timestamp (for current time display)
|
||||||
now: new Date(),
|
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;
|
background: #ffffff;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1000;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.agent-indicator {
|
.agent-indicator {
|
||||||
@@ -583,6 +583,7 @@ export default function GlobalStyles() {
|
|||||||
|
|
||||||
.room-scene-wrapper {
|
.room-scene-wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
@@ -646,7 +647,7 @@ export default function GlobalStyles() {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
@@ -656,6 +657,7 @@ export default function GlobalStyles() {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.room-canvas {
|
.room-canvas {
|
||||||
@@ -666,7 +668,8 @@ export default function GlobalStyles() {
|
|||||||
|
|
||||||
.room-bubble {
|
.room-bubble {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
max-width: 300px;
|
max-width: 320px;
|
||||||
|
max-height: 260px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
color: #000000;
|
color: #000000;
|
||||||
@@ -676,6 +679,8 @@ export default function GlobalStyles() {
|
|||||||
font-family: 'IBM Plex Mono', monospace;
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
animation: bubbleAppear 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
|
animation: bubbleAppear 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 30;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes bubbleAppear {
|
@keyframes bubbleAppear {
|
||||||
@@ -786,6 +791,9 @@ export default function GlobalStyles() {
|
|||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
max-height: 180px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble-expand-btn {
|
.bubble-expand-btn {
|
||||||
|
|||||||
@@ -29,13 +29,6 @@ else
|
|||||||
echo -e "${YELLOW}Warning: .env file not found${NC}"
|
echo -e "${YELLOW}Warning: .env file not found${NC}"
|
||||||
fi
|
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
|
cd /Users/cillin/workspeace/evotraders
|
||||||
PIDS=()
|
PIDS=()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user