diff --git a/backend/agents/base/evo_agent.py b/backend/agents/base/evo_agent.py index 7913bff..3705ad6 100644 --- a/backend/agents/base/evo_agent.py +++ b/backend/agents/base/evo_agent.py @@ -310,11 +310,12 @@ class EvoAgent(ToolGuardMixin, ReActAgent): ) logger.debug("Registered workspace watch hook") - async def _reasoning(self, **kwargs) -> Msg: + async def _reasoning(self, tool_choice: Optional[str] = None, **kwargs) -> Msg: """Override reasoning to execute pre-reasoning hooks. Args: - **kwargs: Arguments for reasoning + tool_choice: Optional tool choice for structured output + **kwargs: Additional arguments for reasoning Returns: Response message @@ -327,7 +328,7 @@ class EvoAgent(ToolGuardMixin, ReActAgent): ) # Call parent (which may be ToolGuardMixin's _reasoning) - return await super()._reasoning(**kwargs) + return await super()._reasoning(tool_choice=tool_choice, **kwargs) def reload_runtime_assets(self, active_skill_dirs: Optional[List[Path]] = None) -> None: """Reload toolkit and system prompt from current run assets. @@ -579,7 +580,7 @@ class EvoAgent(ToolGuardMixin, ReActAgent): return try: - self._messenger = AgentMessenger(agent_id=self.agent_id) + self._messenger = AgentMessenger() self._task_delegator = TaskDelegator(agent=self) logger.debug( "Team infrastructure initialized for agent: %s", diff --git a/backend/agents/workspace_manager.py b/backend/agents/workspace_manager.py index e5fa6a5..e4e1e0b 100644 --- a/backend/agents/workspace_manager.py +++ b/backend/agents/workspace_manager.py @@ -312,12 +312,21 @@ class RunWorkspaceManager: "- 审阅分析以理解市场观点\n" "- 在做决策前先考虑风险警告\n" "- 评估当前投资组合持仓、现金与保证金占用\n" + "- 在做最终决策前,先判断当前团队是否足以覆盖任务;如果覆盖不足,不要勉强给结论,先扩编团队\n" + "- 当现有团队覆盖不足、观点分歧过大、或出现新的专业分析需求时,优先考虑动态创建合适的分析师,再继续讨论\n" "- 决策必须与整体投资目标和风险约束一致\n\n" + "动态扩编触发条件:\n" + "- 出现当前团队未覆盖的研究领域:期权、宏观、行业专项、事件驱动、监管冲击、加密资产、商品链、特殊市场结构\n" + "- 关键 ticker 的结论依赖某种专业知识,但现有 analyst 无法提供直接证据链\n" + "- 分析师之间存在明显冲突,且仅靠风险经理无法完成裁决\n" + "- 你需要第二个同类型但不同风格的 analyst 来验证一个高风险假设\n\n" "决策类型:\n" '- `long`:看涨,建议买入\n' '- `short`:看跌,建议卖出或做空\n' '- `hold`:中性,维持当前持仓\n\n' "输出要求:\n" + "- 触发扩编条件时,必须先使用动态团队工具创建分析师,并在继续决策前吸收其分析输入\n" + "- 不允许口头声称“需要更多分析”但不实际调用创建工具\n" "- 使用 `make_decision` 工具记录每个股票的最终决策\n" "- 记录完成后给出投资逻辑总结\n" "- 最终总结必须使用简体中文\n" @@ -327,6 +336,10 @@ class RunWorkspaceManager: "- 在决定数量时考虑可用现金,不要超出现金允许范围\n" "- 考虑做空头寸的保证金要求\n" "- 仓位规模相对于组合总资产保持保守\n" + "- 当任务涉及当前团队未覆盖的领域(如期权、宏观、行业专项、事件驱动、加密资产等)时,应优先创建或克隆对应分析师,而不是勉强用现有团队输出低质量结论\n" + "- 当分析师之间长期存在高冲突且缺乏裁决信息时,应考虑增加一个补充视角的分析师\n" + "- 如果你已经识别出覆盖缺口,却没有调用动态团队工具补齐团队,就不应直接输出高置信度交易决策\n" + "- 对新创建分析师的输出必须纳入本轮决策依据,不能创建后忽略\n" "- 始终为决策提供清晰理由\n" "- 不要输出英文投资报告或英文结论\n" ) diff --git a/backend/api/runtime.py b/backend/api/runtime.py index da1a0c1..405f3c4 100644 --- a/backend/api/runtime.py +++ b/backend/api/runtime.py @@ -7,6 +7,7 @@ import asyncio import json import logging import os +import re import shutil import subprocess import sys @@ -154,6 +155,7 @@ class RunContextResponse(BaseModel): class RuntimeAgentState(BaseModel): agent_id: str + display_name: Optional[str] = None status: str last_session: Optional[str] = None last_updated: str @@ -300,6 +302,70 @@ def _load_run_server_state(run_dir: Path) -> Dict[str, Any]: return {} +def _resolve_runtime_agent_display_name(run_id: str, agent_id: str) -> Optional[str]: + """Best-effort display name for one runtime agent. + + Priority: + 1. PROFILE.md line like `角色定位:中文名` + 2. PROFILE.md YAML frontmatter field `name` + """ + asset_dir = PROJECT_ROOT / "runs" / run_id / "agents" / agent_id + profile_path = asset_dir / "PROFILE.md" + if not profile_path.exists(): + return None + + try: + raw = profile_path.read_text(encoding="utf-8").strip() + except Exception: + return None + + if not raw: + return None + + frontmatter_name: Optional[str] = None + if raw.startswith("---"): + parts = raw.split("---", 2) + if len(parts) >= 3: + try: + import yaml + parsed = yaml.safe_load(parts[1].strip()) or {} + if isinstance(parsed, dict): + value = parsed.get("name") + if isinstance(value, str) and value.strip(): + frontmatter_name = value.strip() + except Exception: + pass + raw = parts[2].strip() + + for line in raw.splitlines(): + normalized = line.strip() + if normalized.startswith("角色定位:"): + value = normalized.split(":", 1)[1].strip() + if value: + return value + if normalized.lower().startswith("role:"): + value = normalized.split(":", 1)[1].strip() + if value: + return value + + return frontmatter_name + + +def _enrich_runtime_agents(run_id: Optional[str], agents: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + if not run_id: + return agents + + enriched: List[Dict[str, Any]] = [] + for item in agents: + payload = dict(item) + display_name = payload.get("display_name") + agent_id = str(payload.get("agent_id") or "").strip() + if agent_id and (not isinstance(display_name, str) or not display_name.strip()): + payload["display_name"] = _resolve_runtime_agent_display_name(run_id, agent_id) + enriched.append(payload) + return enriched + + def _extract_history_metrics(run_dir: Path) -> tuple[int, Optional[float]]: """Prefer runtime state files over dashboard exports for history summaries.""" server_state = _load_run_server_state(run_dir) @@ -566,10 +632,11 @@ def _validate_gateway_config(bootstrap: Dict[str, Any]) -> List[str]: # Check LLM configuration model_name = os.getenv("MODEL_NAME") openai_key = os.getenv("OPENAI_API_KEY") + dashscope_key = os.getenv("DASHSCOPE_API_KEY") if not model_name: errors.append("MODEL_NAME environment variable is not set") - if not openai_key: - errors.append("OPENAI_API_KEY environment variable is not set") + if not openai_key and not dashscope_key: + errors.append("Either OPENAI_API_KEY or DASHSCOPE_API_KEY environment variable must be set") # Validate tickers tickers = bootstrap.get("tickers", []) @@ -722,7 +789,8 @@ async def get_run_context() -> RunContextResponse: async def get_runtime_agents() -> RuntimeAgentsResponse: """Return agent states from the active runtime, or latest persisted run.""" snapshot = _get_active_runtime_snapshot() if _is_gateway_running() else _load_latest_runtime_snapshot() - agents = snapshot.get("agents", []) + run_id = snapshot.get("context", {}).get("config_name") + agents = _enrich_runtime_agents(run_id, snapshot.get("agents", [])) return RuntimeAgentsResponse( agents=[RuntimeAgentState(**a) for a in agents] @@ -869,11 +937,24 @@ def _load_latest_runtime_snapshot() -> Dict[str, Any]: def _get_active_runtime_snapshot() -> Dict[str, Any]: - """Return the active runtime snapshot, preferring in-memory manager state.""" + """Return the active runtime snapshot. + + For a running Gateway, the canonical runtime source of truth is the + run-scoped snapshot file under `runs//state/runtime_state.json`, + because the Gateway subprocess mutates it directly while the parent + runtime_service process may still hold a stale in-memory manager snapshot. + """ if not _is_gateway_running(): raise HTTPException(status_code=404, detail="No runtime is currently running") manager = _runtime_state.runtime_manager + if manager is not None: + run_id = str(getattr(manager, "config_name", "") or "").strip() + if run_id: + snapshot_path = _get_run_dir(run_id) / "state" / "runtime_state.json" + if snapshot_path.exists(): + return json.loads(snapshot_path.read_text(encoding="utf-8")) + if manager is not None and hasattr(manager, "build_snapshot"): snapshot = manager.build_snapshot() context = snapshot.get("context") or {} @@ -900,11 +981,32 @@ 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") + text = _sanitize_runtime_log_text(text) if len(text) <= max_chars: return text return text[-max_chars:] +def _sanitize_runtime_log_text(text: str) -> str: + if not text: + return "" + + # Drop repetitive development-only warnings for unsandboxed skill execution. + text = re.sub( + r"(?:^|\n)=+\n" + r"⚠️\s+\[安全警告\]\s+技能在无沙盒模式下运行\s+\(SKILL_SANDBOX_MODE=none\)\n" + r"\s+技能脚本将直接在当前进程中执行,无隔离保护。\n" + r"\s+建议:生产环境请设置\s+SKILL_SANDBOX_MODE=docker\n" + r"=+\n?", + "\n", + text, + flags=re.MULTILINE, + ) + + text = re.sub(r"\n{3,}", "\n\n", text) + return text.strip() + + def _get_current_runtime_context() -> Dict[str, Any]: """Return the active runtime context from the latest snapshot.""" if not _is_gateway_running(): diff --git a/backend/config/agent_profiles.yaml b/backend/config/agent_profiles.yaml index fa06ec8..b2a0a14 100644 --- a/backend/config/agent_profiles.yaml +++ b/backend/config/agent_profiles.yaml @@ -27,8 +27,10 @@ valuation_analyst: portfolio_manager: skills: - portfolio_decisioning + - dynamic_team_management active_tool_groups: - portfolio_ops + - dynamic_team risk_manager: skills: diff --git a/backend/core/pipeline.py b/backend/core/pipeline.py index 54631d3..a3c49eb 100644 --- a/backend/core/pipeline.py +++ b/backend/core/pipeline.py @@ -6,11 +6,13 @@ Core Pipeline - Orchestrates multi-agent analysis and decision-making # flake8: noqa: E501 # pylint: disable=W0613,C0301 +import asyncio import json import logging import os import re from contextlib import nullcontext +from datetime import datetime from pathlib import Path from typing import Any, Awaitable, Callable, Dict, List, Optional @@ -32,7 +34,7 @@ from backend.agents.toolkit_factory import create_agent_toolkit from backend.agents.workspace_manager import WorkspaceManager from backend.agents.prompt_loader import get_prompt_loader from backend.llm.models import get_agent_formatter, get_agent_model -from backend.config.constants import ANALYST_TYPES +from backend.config.constants import ANALYST_TYPES, AGENT_CONFIG from backend.agents.dynamic_team_types import AnalystConfig from backend.tools.dynamic_team_tools import DynamicTeamController, set_controller @@ -230,8 +232,25 @@ class TradingPipeline: "system", ), ): - # Phase 1.1: Analysts + # Phase 1.0: PM assesses team coverage and expands if needed if not last_phase or last_phase == "cleared": + _log("Phase 1.0: Team gap assessment") + await self._run_team_gap_assessment( + tickers=tickers, + date=date, + prices=prices, + ) + active_analysts = self._get_active_analysts() + if self.runtime_manager: + self._runtime_batch_status(active_analysts, "analysis_in_progress") + self._save_checkpoint(session_key, "team_assessment", { + "prices": prices, + "close_prices": close_prices, + }) + last_phase = "team_assessment" + + # Phase 1.1: Analysts + if last_phase == "team_assessment": _log("Phase 1.1: Analyst analysis (parallel)") analyst_results = await self._run_analysts_parallel( tickers, @@ -754,6 +773,7 @@ class TradingPipeline: await self.state_sync.on_agent_complete( agent_id="Daily Log", content=reflection_content, + agent_name="每日记录", ) # Phase 6: APO (Autonomous Policy Optimization) @@ -1020,12 +1040,13 @@ class TradingPipeline: pm_msg = Msg(name="system", content=pm_prompt, role="user") pm_response = await self.pm.reply(pm_msg) + pm_content = self._extract_text_content(pm_response.content) if self.state_sync: - pm_content = self._extract_text_content(pm_response.content) await self.state_sync.on_conference_message( agent_id="portfolio_manager", content=pm_content, + agent_name=self._resolve_agent_display_name("portfolio_manager"), ) # Analysts share perspectives (supports per-round active team updates) @@ -1050,6 +1071,7 @@ class TradingPipeline: await self.state_sync.on_conference_message( agent_id=analyst.name, content=analyst_content, + agent_name=self._resolve_agent_display_name(analyst.name), ) if self.state_sync: @@ -1082,6 +1104,7 @@ class TradingPipeline: await self.state_sync.on_conference_message( agent_id="conference summary", content=conference_summary, + agent_name="会议总结", ) await self.state_sync.on_conference_end() @@ -1139,6 +1162,116 @@ class TradingPipeline: f"and any remaining concerns about {', '.join(tickers)}." ) + async def _run_team_gap_assessment( + self, + *, + tickers: List[str], + date: str, + prices: Optional[Dict[str, float]], + ) -> str: + active_analysts = self._get_active_analysts() + team_summary = [ + { + "agent_id": analyst.name, + "display_name": self._resolve_agent_display_name(analyst.name), + } + for analyst in active_analysts + ] + prompt = ( + f"As Portfolio Manager, perform a team coverage assessment before analysis for {date}.\n\n" + f"Tickers: {', '.join(tickers)}\n" + f"Current team: {json.dumps(team_summary, ensure_ascii=False, indent=2)}\n" + f"Current prices snapshot: {json.dumps(prices, ensure_ascii=False, indent=2) if prices else 'N/A'}\n\n" + "Your job in this phase is not to make investment decisions. " + "First decide whether the current team has enough domain coverage. " + "If the team is insufficient, immediately call dynamic team tools to create or clone the needed analysts now. " + "Before creating any analyst, explicitly check whether an existing analyst already covers that role. " + "Do not create duplicate roles with different IDs but the same responsibilities. " + "If the current team is sufficient, explicitly say the current team is sufficient and explain why." + ) + msg = Msg(name="system", content=prompt, role="user") + response = await self.pm.reply(msg) + pm_content = self._extract_text_content(response.content) + enforced_pm_content = await self._enforce_pm_team_expansion_if_needed( + tickers=tickers, + date=date, + pm_content=pm_content, + ) + if enforced_pm_content: + pm_content = enforced_pm_content + + if self.state_sync: + await self.state_sync.on_agent_complete( + agent_id="portfolio_manager", + agent_name=self._resolve_agent_display_name("portfolio_manager"), + content=pm_content, + ) + + return pm_content + + def _pm_requests_team_expansion(self, text: str) -> bool: + normalized = (text or "").strip().lower() + if not normalized: + return False + + phrases = [ + "创建", + "新增分析师", + "补充分析师", + "扩编团队", + "需要行业分析师", + "需要量化分析师", + "需要宏观分析师", + "需要补充", + "先扩编", + "create analyst", + "create a new analyst", + "add analyst", + "expand the team", + "need a specialist", + "need another analyst", + ] + return any(phrase in normalized for phrase in phrases) + + async def _enforce_pm_team_expansion_if_needed( + self, + *, + tickers: List[str], + date: str, + pm_content: str, + ) -> Optional[str]: + if not self._pm_requests_team_expansion(pm_content): + return None + + before_ids = {agent.name for agent in self._get_active_analysts()} + + followup_prompt = ( + f"You identified a team coverage gap for {date} across {', '.join(tickers)}. " + "This is still the pre-analysis team assessment phase. " + "Do not merely recommend adding analysts. If additional analysts are needed, " + "you must now call the dynamic team tools (`create_analyst` or `clone_analyst`) " + "to add the required specialists before analyst analysis begins. " + "Only after the tool call succeeds may you explain why the new analysts were added. " + "If you truly believe the current team is sufficient, explicitly say the current team is sufficient." + ) + followup_msg = Msg(name="system", content=followup_prompt, role="user") + followup_response = await self.pm.reply(followup_msg) + followup_content = self._extract_text_content(followup_response.content) + after_ids = {agent.name for agent in self._get_active_analysts()} + + if after_ids != before_ids: + created = sorted(after_ids - before_ids) + logger.info( + "PM team expansion enforced successfully before analysis; added analysts=%s", + created, + ) + else: + logger.info( + "PM mentioned expansion in team assessment but did not add analysts after enforcement prompt", + ) + + return followup_content + def _build_analyst_discussion_prompt( self, cycle: int, @@ -1152,6 +1285,88 @@ class TradingPipeline: f"Do not use tools - focus on sharing your professional opinion." ) + def _resolve_agent_display_name(self, agent_id: str) -> str: + runtime_name = None + if self.runtime_manager: + state = self.runtime_manager.get_agent_state(agent_id) + runtime_name = getattr(state, "display_name", None) if state else None + if isinstance(runtime_name, str) and runtime_name.strip(): + return runtime_name.strip() + + static_name = AGENT_CONFIG.get(agent_id, {}).get("name") + if isinstance(static_name, str) and static_name.strip(): + return static_name.strip() + + profile_path = Path(__file__).resolve().parents[2] / "runs" / self.runtime_manager.config_name / "agents" / agent_id / "PROFILE.md" if self.runtime_manager else None + if profile_path and profile_path.exists(): + try: + raw = profile_path.read_text(encoding="utf-8") + for line in raw.splitlines(): + text = line.strip() + if text.startswith("角色定位:"): + value = text.split(":", 1)[1].strip() + if value: + return value + except Exception: + pass + + return agent_id + + @staticmethod + def _normalize_role_key(value: str) -> str: + normalized = (value or "").strip().lower() + normalized = normalized.replace("_", "") + normalized = normalized.replace(" ", "") + replacements = { + "analyst": "分析师", + "macro": "宏观", + "technical": "技术", + "tech": "技术", + "sentiment": "情绪", + "fundamentals": "基本面", + "fundamental": "基本面", + "valuation": "估值", + "crypto": "加密", + "cryptocurrency": "加密", + "semiconductor": "半导体", + "industry": "行业", + "sector": "行业", + "risk": "风险", + } + for src, target in replacements.items(): + normalized = normalized.replace(src, target) + return normalized + + @staticmethod + def _contains_cjk(value: str) -> bool: + text = (value or "").strip() + return any("\u4e00" <= ch <= "\u9fff" for ch in text) + + def _find_similar_existing_analyst( + self, + *, + agent_id: str, + analyst_type: str, + custom_config: Optional[AnalystConfig], + ) -> Optional[str]: + requested_names = {self._normalize_role_key(agent_id)} + if custom_config and custom_config.persona and custom_config.persona.name: + requested_names.add(self._normalize_role_key(custom_config.persona.name)) + + for agent in self._all_analysts(): + existing_id = getattr(agent, "name", None) or getattr(agent, "agent_id", None) + if not existing_id or existing_id == agent_id: + continue + + existing_names = { + self._normalize_role_key(existing_id), + self._normalize_role_key(self._resolve_agent_display_name(existing_id)), + } + if requested_names & existing_names: + return existing_id + + return None + async def _collect_final_predictions( self, tickers: List[str], @@ -1300,6 +1515,7 @@ class TradingPipeline: await self.state_sync.on_agent_complete( agent_id=analyst.name, content=text_content, + agent_name=self._resolve_agent_display_name(analyst.name), ) return results @@ -1375,6 +1591,7 @@ class TradingPipeline: await self.state_sync.on_agent_complete( agent_id=analyst.name, content=text_content, + agent_name=self._resolve_agent_display_name(analyst.name), ) else: logger.warning( @@ -1456,6 +1673,7 @@ class TradingPipeline: await self.state_sync.on_agent_complete( agent_id="risk_manager", content=text_content, + agent_name=self._resolve_agent_display_name("risk_manager"), ) return extracted @@ -1542,6 +1760,7 @@ class TradingPipeline: await self.state_sync.on_agent_complete( agent_id="portfolio_manager", content=text_content, + agent_name=self._resolve_agent_display_name("portfolio_manager"), ) return extracted @@ -1776,8 +1995,29 @@ class TradingPipeline: f"Available: {', '.join(ANALYST_TYPES.keys())}. " f"Or provide custom_config to create a custom analyst." ) + display_name = ( + custom_config.persona.name + if custom_config and custom_config.persona and custom_config.persona.name + else "" + ) + if not self._contains_cjk(display_name): + return ( + f"Analyst '{agent_id}' requires a Chinese display name. " + "Please provide `name` in Chinese characters when creating dynamic analysts." + ) if agent_id in {agent.name for agent in self._all_analysts()}: return f"Analyst '{agent_id}' already exists." + similar_existing = self._find_similar_existing_analyst( + agent_id=agent_id, + analyst_type=analyst_type, + custom_config=custom_config, + ) + if similar_existing: + return ( + f"Analyst '{agent_id}' is too similar to existing analyst " + f"'{similar_existing}'. Reuse or clone the existing analyst instead of " + f"creating a duplicate role." + ) config_name = getattr(self.pm, "config", {}).get("config_name", "default") project_root = Path(__file__).resolve().parents[2] @@ -1860,6 +2100,48 @@ class TradingPipeline: setattr(agent, "workspace_id", config_name) self._dynamic_analysts[agent_id] = agent + if self.runtime_manager: + display_name = None + if custom_config and custom_config.persona and custom_config.persona.name: + display_name = custom_config.persona.name + self.runtime_manager.register_agent( + agent_id, + display_name=display_name, + ) + self.runtime_manager.log_event( + "agent:created", + { + "agent_id": agent_id, + "analyst_type": analyst_type, + "display_name": display_name, + }, + ) + logger.info( + "Dynamic analyst created: agent_id=%s analyst_type=%s custom=%s", + agent_id, + analyst_type, + bool(custom_config), + ) + if self.state_sync: + try: + asyncio.create_task( + self.state_sync.emit( + { + "type": "runtime_agents_updated", + "action": "created", + "agentId": agent_id, + "agentName": display_name or self._resolve_agent_display_name(agent_id), + }, + persist=False, + ) + ) + except Exception as exc: + logger.warning( + "Failed to broadcast runtime_agents_updated(create) for %s: %s", + agent_id, + exc, + ) + # Store custom config for future reference (e.g., cloning) if custom_config: self._dynamic_analyst_configs[agent_id] = custom_config @@ -1879,6 +2161,31 @@ class TradingPipeline: self._dynamic_analysts.pop(agent_id, None) # Also remove stored config if exists self._dynamic_analyst_configs.pop(agent_id, None) + if self.runtime_manager: + self.runtime_manager.unregister_agent(agent_id) + self.runtime_manager.log_event( + "agent:removed", + {"agent_id": agent_id}, + ) + logger.info("Dynamic analyst removed: agent_id=%s", agent_id) + if self.state_sync: + try: + asyncio.create_task( + self.state_sync.emit( + { + "type": "runtime_agents_updated", + "action": "removed", + "agentId": agent_id, + }, + persist=False, + ) + ) + except Exception as exc: + logger.warning( + "Failed to broadcast runtime_agents_updated(remove) for %s: %s", + agent_id, + exc, + ) config_name = getattr(self.pm, "config", {}).get("config_name", "default") project_root = Path(__file__).resolve().parents[2] update_active_analysts( diff --git a/backend/core/state_sync.py b/backend/core/state_sync.py index 1e6e259..1f4c3c6 100644 --- a/backend/core/state_sync.py +++ b/backend/core/state_sync.py @@ -135,6 +135,7 @@ class StateSync: self, agent_id: str, content: str, + agent_name: Optional[str] = None, **extra, ): """ @@ -151,6 +152,7 @@ class StateSync: { "type": "agent_message", "agentId": agent_id, + "agentName": agent_name, "content": content, "ts": ts_ms, **extra, @@ -212,7 +214,12 @@ class StateSync: persist=False, ) - async def on_conference_message(self, agent_id: str, content: str): + async def on_conference_message( + self, + agent_id: str, + content: str, + agent_name: Optional[str] = None, + ): """Called when an agent speaks during conference""" ts_ms = self._get_timestamp_ms() @@ -220,6 +227,7 @@ class StateSync: { "type": "conference_message", "agentId": agent_id, + "agentName": agent_name, "content": content, "ts": ts_ms, }, diff --git a/backend/runtime/agent_runtime.py b/backend/runtime/agent_runtime.py index 3fd28c2..ec41074 100644 --- a/backend/runtime/agent_runtime.py +++ b/backend/runtime/agent_runtime.py @@ -8,6 +8,7 @@ from typing import Any, Dict @dataclass class AgentRuntimeState: agent_id: str + display_name: str | None = None status: str = "idle" last_session: str | None = None last_updated: datetime = field(default_factory=lambda: datetime.now(UTC)) @@ -20,6 +21,7 @@ class AgentRuntimeState: def to_dict(self) -> Dict[str, Any]: return { "agent_id": self.agent_id, + "display_name": self.display_name, "status": self.status, "last_session": self.last_session, "last_updated": self.last_updated.isoformat(), diff --git a/backend/runtime/manager.py b/backend/runtime/manager.py index f14b947..79c1c11 100644 --- a/backend/runtime/manager.py +++ b/backend/runtime/manager.py @@ -102,12 +102,22 @@ class TradingRuntimeManager: self._persist_snapshot() return entry - def register_agent(self, agent_id: str) -> AgentRuntimeState: - state = AgentRuntimeState(agent_id=agent_id) + def register_agent( + self, + agent_id: str, + display_name: Optional[str] = None, + ) -> AgentRuntimeState: + state = AgentRuntimeState(agent_id=agent_id, display_name=display_name) self.registry.register(agent_id, state) self._persist_snapshot() return state + def unregister_agent(self, agent_id: str) -> Optional[AgentRuntimeState]: + state = self.registry.unregister(agent_id) + if state is not None: + self._persist_snapshot() + return state + def register_pending_approval(self, approval_id: str, payload: Dict[str, Any]) -> None: payload.setdefault("status", "pending") payload.setdefault("created_at", datetime.now(UTC).isoformat()) diff --git a/backend/runtime/registry.py b/backend/runtime/registry.py index f2c2868..1aa1b11 100644 --- a/backend/runtime/registry.py +++ b/backend/runtime/registry.py @@ -13,6 +13,9 @@ class RuntimeRegistry: def get(self, agent_id: str) -> Optional["AgentRuntimeState"]: return self._states.get(agent_id) + def unregister(self, agent_id: str) -> Optional["AgentRuntimeState"]: + return self._states.pop(agent_id, None) + def list_agents(self) -> list[str]: return list(self._states.keys()) diff --git a/backend/skills/builtin/portfolio_decisioning/SKILL.md b/backend/skills/builtin/portfolio_decisioning/SKILL.md index 5d3c31b..6cff4df 100644 --- a/backend/skills/builtin/portfolio_decisioning/SKILL.md +++ b/backend/skills/builtin/portfolio_decisioning/SKILL.md @@ -23,15 +23,17 @@ version: 1.0.0 ## 3) Decision procedure 1. 汇总并比较 analyst 信号,识别共识与分歧。 -2. 将风险警示映射到仓位上限与禁开条件。 -3. 在资金与保证金约束下,为每个 ticker 生成候选动作与数量。 -4. 对冲突信号执行保守仲裁:降低仓位、提高触发门槛或改为 `hold`。 -5. 逐个 ticker 记录最终决策,并给出组合级理由。 +2. 先判断当前团队是否覆盖了本轮任务所需的专业能力;若未覆盖,优先扩编团队而不是直接仲裁。 +3. 将风险警示映射到仓位上限与禁开条件。 +4. 在资金与保证金约束下,为每个 ticker 生成候选动作与数量。 +5. 对冲突信号执行保守仲裁:降低仓位、提高触发门槛、补充 analyst,或改为 `hold`。 +6. 逐个 ticker 记录最终决策,并给出组合级理由。 ## 4) Tool call policy - 必须使用决策工具记录每个 ticker 的最终 `action/quantity`。 -- 在讨论阶段如发现当前团队能力不足,可使用团队工具动态创建或移除 analyst(再继续讨论)。 +- 在讨论阶段如发现当前团队能力不足、证据链断裂、或观点冲突无法裁决,必须优先使用团队工具动态创建或克隆 analyst(再继续讨论)。 +- 如果已经判断“需要更多专业分析”,但没有调用动态团队工具补齐团队,则不得输出高置信度最终决策。 - 若风险工具提示阻断项,优先遵循阻断,不得绕过。 - 工具调用失败时:重试一次;仍失败则输出结构化“未完成决策清单”和人工处理建议。 @@ -46,5 +48,6 @@ version: 1.0.0 ## 6) Failure fallback - 当分析师信号与风险结论显著冲突时,默认采用更小仓位或 `hold`。 +- 当任务明显超出当前团队能力边界时,优先扩编团队;如果扩编失败,再降级为 `hold` 或条件决策草案。 - 当约束校验失败(现金/保证金不足)时,自动下调数量,不输出不可执行指令。 - 当任务要求完整清单时,不允许遗漏 ticker;无法决策时必须显式标记 `hold` 并说明原因。 diff --git a/backend/skills/customized/portfolio_decisioning/SKILL.md b/backend/skills/customized/portfolio_decisioning/SKILL.md index 444cc21..bb4de34 100644 --- a/backend/skills/customized/portfolio_decisioning/SKILL.md +++ b/backend/skills/customized/portfolio_decisioning/SKILL.md @@ -10,12 +10,15 @@ description: 整合分析师观点与风险反馈,形成明确的组合层决 ## 工作流程 1. 行动前先阅读分析师结论和风险警示。 -2. 评估当前组合、现金和保证金约束。 -3. 使用决策工具为每个 ticker 记录一个明确决策。 -4. 在全部决策记录完成后,总结组合层面的整体理由。 +2. 先判断当前团队是否足以覆盖本轮任务;如果不够,先扩编团队再继续。 +3. 评估当前组合、现金和保证金约束。 +4. 使用决策工具为每个 ticker 记录一个明确决策。 +5. 在全部决策记录完成后,总结组合层面的整体理由。 ## 约束 - 仓位大小必须遵守资金和保证金限制。 - 当分析师信心与风险信号不一致时,优先采用更小仓位。 +- 当任务超出当前团队能力边界时,应优先使用动态团队工具创建或克隆分析师。 +- 如果已经识别出覆盖缺口,不应跳过扩编步骤直接给出高置信度结论。 - 当任务要求完整决策清单时,不要让任何 ticker 处于未决状态。 diff --git a/backend/tools/dynamic_team_tools.py b/backend/tools/dynamic_team_tools.py index 990f4d8..9a0aa8e 100644 --- a/backend/tools/dynamic_team_tools.py +++ b/backend/tools/dynamic_team_tools.py @@ -13,8 +13,11 @@ as described in the Dynamic Team Architecture. """ from __future__ import annotations +import json from typing import Any, Dict, List, Optional, Callable -from dataclasses import asdict + +from agentscope.message import TextBlock +from agentscope.tool import ToolResponse from backend.agents.dynamic_team_types import ( AnalystPersona, @@ -22,7 +25,7 @@ from backend.agents.dynamic_team_types import ( CreateAnalystResult, AnalystTypeInfo, ) -from backend.config.constants import ANALYST_TYPES +from backend.config.constants import ANALYST_TYPES, AGENT_CONFIG # Type alias for callbacks set by pipeline @@ -30,6 +33,14 @@ CreateAnalystCallback = Callable[[str, str, Optional[AnalystConfig]], str] RemoveAnalystCallback = Callable[[str], str] +def _to_tool_response(payload: Any) -> ToolResponse: + if isinstance(payload, str): + text = payload + else: + text = json.dumps(payload, ensure_ascii=False, indent=2, default=str) + return ToolResponse(content=[TextBlock(type="text", text=text)]) + + class DynamicTeamController: """Controller for dynamic analyst team management. @@ -296,6 +307,23 @@ class DynamicTeamController: Dict with analyst configuration and status """ config = self._instance_configs.get(agent_id) + current_analysts = self._get_analysts_callback() if self._get_analysts_callback else [] + analyst_map = { + (getattr(agent, "name", None) or getattr(agent, "agent_id", None)): agent + for agent in current_analysts + } + if agent_id in analyst_map and not config: + builtin_meta = AGENT_CONFIG.get(agent_id, {}) + return { + "found": True, + "agent_id": agent_id, + "name": builtin_meta.get("name") or agent_id, + "type": agent_id, + "is_custom": False, + "is_clone": False, + "is_builtin": True, + "message": f"Built-in analyst '{agent_id}' is active", + } if not config: return { "found": False, @@ -310,6 +338,7 @@ class DynamicTeamController: "is_custom": config.persona is not None, "is_clone": config.parent_id is not None, "parent_id": config.parent_id, + "is_builtin": False, } def register_analyst_type( @@ -372,13 +401,26 @@ class DynamicTeamController: Dict with team composition information """ analysts = [] - for agent_id, config in self._instance_configs.items(): + current_analysts = self._get_analysts_callback() if self._get_analysts_callback else [] + instance_configs = self._instance_configs + + for agent in current_analysts: + agent_id = getattr(agent, "name", None) or getattr(agent, "agent_id", None) + if not agent_id: + continue + config = instance_configs.get(agent_id) + builtin_meta = AGENT_CONFIG.get(agent_id, {}) analysts.append({ "agent_id": agent_id, - "name": config.persona.name if config.persona else agent_id, - "type": config.analyst_type, - "is_custom": config.persona is not None, - "is_clone": config.parent_id is not None, + "name": ( + config.persona.name + if config and config.persona and config.persona.name + else builtin_meta.get("name") or agent_id + ), + "type": config.analyst_type if config else agent_id, + "is_custom": bool(config and config.persona is not None), + "is_clone": bool(config and config.parent_id is not None), + "is_builtin": config is None, }) return { @@ -418,91 +460,95 @@ def get_controller() -> Optional[DynamicTeamController]: def create_analyst( agent_id: str, analyst_type: str, - name: Optional[str] = None, - focus: Optional[str] = None, - description: Optional[str] = None, - soul_md: Optional[str] = None, - agents_md: Optional[str] = None, - model_name: Optional[str] = None, -) -> Dict[str, Any]: + name: str = "", + focus: str = "", + description: str = "", + soul_md: str = "", + agents_md: str = "", + model_name: str = "", +) -> ToolResponse: """Tool wrapper for create_analyst. Note: focus parameter accepts comma-separated string for tool compatibility. """ controller = get_controller() if not controller: - return {"success": False, "error": "Dynamic team controller not available"} + return _to_tool_response({"success": False, "error": "Dynamic team controller not available"}) focus_list = [f.strip() for f in focus.split(",")] if focus else None - return controller.create_analyst( - agent_id=agent_id, - analyst_type=analyst_type, - name=name, - focus=focus_list, - description=description, - soul_md=soul_md, - agents_md=agents_md, - model_name=model_name, + return _to_tool_response( + controller.create_analyst( + agent_id=agent_id, + analyst_type=analyst_type, + name=name, + focus=focus_list, + description=description, + soul_md=soul_md, + agents_md=agents_md, + model_name=model_name, + ) ) def clone_analyst( source_id: str, new_id: str, - name: Optional[str] = None, - focus_additions: Optional[str] = None, - description_override: Optional[str] = None, - model_name: Optional[str] = None, -) -> Dict[str, Any]: + name: str = "", + focus_additions: str = "", + description_override: str = "", + model_name: str = "", +) -> ToolResponse: """Tool wrapper for clone_analyst. Note: focus_additions accepts comma-separated string. """ controller = get_controller() if not controller: - return {"success": False, "error": "Dynamic team controller not available"} + return _to_tool_response({"success": False, "error": "Dynamic team controller not available"}) additions_list = [f.strip() for f in focus_additions.split(",")] if focus_additions else None - return controller.clone_analyst( - source_id=source_id, - new_id=new_id, - name=name, - focus_additions=additions_list, - description_override=description_override, - model_name=model_name, + return _to_tool_response( + controller.clone_analyst( + source_id=source_id, + new_id=new_id, + name=name, + focus_additions=additions_list, + description_override=description_override, + model_name=model_name, + ) ) -def remove_analyst(agent_id: str) -> Dict[str, Any]: +def remove_analyst(agent_id: str) -> ToolResponse: """Tool wrapper for remove_analyst.""" controller = get_controller() if not controller: - return {"success": False, "error": "Dynamic team controller not available"} - return controller.remove_analyst(agent_id) + return _to_tool_response({"success": False, "error": "Dynamic team controller not available"}) + return _to_tool_response(controller.remove_analyst(agent_id)) -def list_analyst_types() -> List[Dict[str, Any]]: +def list_analyst_types() -> ToolResponse: """Tool wrapper for list_analyst_types.""" controller = get_controller() if not controller: - return [] - return controller.list_analyst_types() + return _to_tool_response([]) + return _to_tool_response(controller.list_analyst_types()) -def get_analyst_info(agent_id: str) -> Dict[str, Any]: +def get_analyst_info(agent_id: str) -> ToolResponse: """Tool wrapper for get_analyst_info.""" controller = get_controller() if not controller: - return {"found": False, "error": "Controller not available"} - return controller.get_analyst_info(agent_id) + return _to_tool_response({"found": False, "error": "Controller not available"}) + return _to_tool_response(controller.get_analyst_info(agent_id)) -def get_team_summary() -> Dict[str, Any]: +def get_team_summary() -> ToolResponse: """Tool wrapper for get_team_summary.""" controller = get_controller() if not controller: - return {"error": "Controller not available"} - return controller.get_team_summary() + return _to_tool_response({"error": "Controller not available"}) + return _to_tool_response(controller.get_team_summary()) __all__ = [ diff --git a/backend/tools/sandboxed_executor.py b/backend/tools/sandboxed_executor.py index 11a38eb..dec17f3 100644 --- a/backend/tools/sandboxed_executor.py +++ b/backend/tools/sandboxed_executor.py @@ -19,7 +19,6 @@ import json import logging import os -import warnings from abc import ABC, abstractmethod from typing import Any @@ -71,7 +70,6 @@ class NoSandboxBackend(SandboxBackend): def __init__(self): self._module_cache = {} - self._warning_shown = False def _get_script_name(self, function_name: str) -> str: """ @@ -96,19 +94,6 @@ class NoSandboxBackend(SandboxBackend): ) -> dict: """直接导入模块并执行函数""" - # 首次使用时显示安全警告 - if not self._warning_shown: - warnings.warn( - "\n" + "=" * 60 + "\n" - "⚠️ [安全警告] 技能在无沙盒模式下运行 (SKILL_SANDBOX_MODE=none)\n" - " 技能脚本将直接在当前进程中执行,无隔离保护。\n" - " 建议:生产环境请设置 SKILL_SANDBOX_MODE=docker\n" - "=" * 60, - RuntimeWarning, - stacklevel=2, - ) - self._warning_shown = True - logger.debug(f"[NoSandbox] 执行技能: {skill_name}.{function_name}") try: @@ -345,13 +330,13 @@ class SkillSandbox: self._backend = self._create_backend() self._initialized = True - logger.info(f"SkillSandbox 初始化完成,模式: {self.mode}") + logger.debug(f"SkillSandbox 初始化完成,模式: {self.mode}") def _create_backend(self) -> SandboxBackend: """根据模式创建对应后端""" if self.mode == "none": - logger.info("使用无沙盒模式(直接执行)") + logger.debug("使用无沙盒模式(直接执行)") return NoSandboxBackend() elif self.mode == "docker": diff --git a/deploy/install-production.sh b/deploy/install-production.sh new file mode 100644 index 0000000..318a900 --- /dev/null +++ b/deploy/install-production.sh @@ -0,0 +1,602 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +CYAN='\033[0;36m' +NC='\033[0m' + +NON_INTERACTIVE=false +AUTO_INSTALL_DEPS="" +AUTO_INSTALL_SYSTEMD="" +AUTO_START_SYSTEMD="" +AUTO_INSTALL_NGINX="" +AUTO_RELOAD_NGINX="" +AUTO_USE_TLS="" +AUTO_USE_DOCKER="" + +log() { + echo -e "${GREEN}[bigtime]${NC} $*" +} + +warn() { + echo -e "${YELLOW}[bigtime]${NC} $*" +} + +fail() { + echo -e "${RED}[bigtime]${NC} $*" >&2 + exit 1 +} + +ask() { + local prompt="$1" + local default="${2:-}" + if ${NON_INTERACTIVE}; then + printf '%s' "${default}" + return + fi + local value + if [[ -n "${default}" ]]; then + read -r -p "${prompt} [${default}]: " value + printf '%s' "${value:-$default}" + else + read -r -p "${prompt}: " value + printf '%s' "${value}" + fi +} + +ask_required() { + local prompt="$1" + local default="${2:-}" + local value="" + while [[ -z "${value}" ]]; do + value="$(ask "${prompt}" "${default}")" + if [[ -z "${value}" ]]; then + warn "该项不能为空,请重新输入。" + fi + done + printf '%s' "${value}" +} + +validate_domain_like() { + local value="$1" + [[ -z "${value}" ]] && return 1 + [[ "${value}" =~ ^[A-Za-z0-9.-]+$ ]] +} + +validate_file_parent_exists_or_rootable() { + local value="$1" + local parent + parent="$(dirname "${value}")" + [[ -d "${parent}" ]] || [[ "${parent}" == "/etc/bigtime" ]] || [[ "${parent}" == "/etc/nginx/conf.d" ]] +} + +validate_numeric() { + local value="$1" + [[ "${value}" =~ ^[0-9]+([.][0-9]+)?$ ]] +} + +confirm() { + local prompt="$1" + local default="${2:-Y}" + local override="${3:-}" + if [[ -n "${override}" ]]; then + [[ "${override}" =~ ^[Yy]([Ee][Ss])?$|^true$|^1$ ]] + return + fi + if ${NON_INTERACTIVE}; then + [[ "${default}" == "Y" ]] + return + fi + local suffix="[Y/n]" + [[ "${default}" == "N" ]] && suffix="[y/N]" + local value + read -r -p "${prompt} ${suffix}: " value + value="${value:-$default}" + [[ "${value}" =~ ^[Yy]$ ]] +} + +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +detect_pkg_manager() { + if command_exists apt-get; then + echo "apt" + return + fi + if command_exists dnf; then + echo "dnf" + return + fi + if command_exists yum; then + echo "yum" + return + fi + echo "" +} + +install_packages() { + local pkg_manager="$1" + case "${pkg_manager}" in + apt) + sudo apt-get update + sudo apt-get install -y python3 python3-venv python3-pip nginx curl git build-essential nodejs npm + ;; + dnf) + sudo dnf install -y python3 python3-pip nginx curl git gcc-c++ make nodejs npm + ;; + yum) + sudo yum install -y python3 python3-pip nginx curl git gcc-c++ make nodejs npm + ;; + *) + warn "未识别包管理器,跳过依赖安装。请手动安装 python3、venv、pip、nginx、node、npm。" + ;; + esac +} + +render_systemd_unit() { + local service_name="$1" + local app_module="$2" + local port="$3" + local workers="$4" + local memory_max="$5" + local unit_path="$6" + + sudo tee "${unit_path}" >/dev/null </dev/null </dev/null </dev/null </dev/null 2>&1 || warn "用户 ${SERVICE_USER} 当前不存在,请确认后续 systemd 配置。" + + SERVICE_GROUP="${SERVICE_GROUP:-$(ask_required 'systemd 运行用户组' "$(id -gn)")}" + + DOMAIN="${DOMAIN:-$(ask_required '部署域名(可填写 IP 或 localhost)' 'localhost')}" + validate_domain_like "${DOMAIN}" || warn "域名/IP 形态看起来不标准,请再次确认: ${DOMAIN}" + + ENV_FILE="${ENV_FILE:-$(ask_required '环境变量文件路径' '/etc/bigtime/bigtime.env')}" + validate_file_parent_exists_or_rootable "${ENV_FILE}" || warn "环境文件父目录当前不存在,脚本会尝试创建: $(dirname "${ENV_FILE}")" + + PYTHON_BIN="${PYTHON_BIN:-$(ask 'Python 可执行文件路径' "${APP_DIR}/.venv/bin/python")}" + [[ -n "${PYTHON_BIN}" ]] || fail "Python 路径不能为空" + + echo "" + echo -e "${CYAN}运行参数${NC}" + TICKERS="${TICKERS:-$(ask '默认股票池(逗号分隔)' 'AAPL,MSFT,GOOGL,AMZN,NVDA,META,TSLA,AMD,NFLX,AVGO,PLTR,COIN')}" + FIN_DATA_SOURCE="${FIN_DATA_SOURCE:-$(ask '行情数据源(finnhub/yfinance/financial_datasets)' 'finnhub')}" + MODEL_NAME="${MODEL_NAME:-$(ask '默认模型名' 'qwen3-max')}" + MAX_COMM_CYCLES="${MAX_COMM_CYCLES:-$(ask_required '最大讨论轮数' '2')}" + validate_numeric "${MAX_COMM_CYCLES}" || fail "最大讨论轮数必须是数字: ${MAX_COMM_CYCLES}" + MARGIN_REQUIREMENT="${MARGIN_REQUIREMENT:-$(ask_required '保证金比例' '0.5')}" + validate_numeric "${MARGIN_REQUIREMENT}" || fail "保证金比例必须是数字: ${MARGIN_REQUIREMENT}" + + echo "" + echo -e "${CYAN}密钥配置${NC}" + FINANCIAL_DATASETS_API_KEY="${FINANCIAL_DATASETS_API_KEY:-$(ask 'FINANCIAL_DATASETS_API_KEY(可留空)' '')}" + FINNHUB_API_KEY="${FINNHUB_API_KEY:-$(ask 'FINNHUB_API_KEY(live 模式建议填写)' '')}" + POLYGON_API_KEY="${POLYGON_API_KEY:-$(ask 'POLYGON_API_KEY(可留空)' '')}" + OPENAI_API_KEY="${OPENAI_API_KEY:-$(ask 'OPENAI_API_KEY(可留空)' '')}" + OPENAI_BASE_URL="${OPENAI_BASE_URL:-$(ask 'OPENAI_BASE_URL(可留空)' '')}" + DASHSCOPE_API_KEY="${DASHSCOPE_API_KEY:-$(ask 'DASHSCOPE_API_KEY(可留空)' '')}" + MEMORY_API_KEY="${MEMORY_API_KEY:-$(ask 'MEMORY_API_KEY(可留空)' '')}" + + if [[ "${FIN_DATA_SOURCE}" == "finnhub" && -z "${FINNHUB_API_KEY}" ]]; then + warn "你选择了 finnhub 作为数据源,但 FINNHUB_API_KEY 为空。live 模式下通常会失败。" + fi + if [[ -z "${OPENAI_API_KEY}" && -z "${DASHSCOPE_API_KEY}" ]]; then + warn "OPENAI_API_KEY 和 DASHSCOPE_API_KEY 都为空,模型调用可能无法工作。" + fi + + if confirm "使用 Docker 沙盒执行技能?" "N" "${AUTO_USE_DOCKER}"; then + SKILL_SANDBOX_MODE="docker" + else + SKILL_SANDBOX_MODE="none" + fi + + echo "" + echo -e "${CYAN}当前部署摘要${NC}" + echo " 应用目录: ${APP_DIR}" + echo " 运行用户: ${SERVICE_USER}:${SERVICE_GROUP}" + echo " 域名: ${DOMAIN}" + echo " 环境文件: ${ENV_FILE}" + echo " Python: ${PYTHON_BIN}" + echo " 数据源: ${FIN_DATA_SOURCE}" + echo " 模型: ${MODEL_NAME}" + echo " 沙盒模式: ${SKILL_SANDBOX_MODE}" + echo "" + + if ! confirm "确认以上配置并继续写入系统文件?" "Y"; then + fail "用户取消部署。" + fi + + if [[ ! -x "${PYTHON_BIN}" ]]; then + warn "未找到 ${PYTHON_BIN},准备创建虚拟环境。" + python3 -m venv "${APP_DIR}/.venv" + "${APP_DIR}/.venv/bin/python" -m pip install --upgrade pip + PYTHON_BIN="${APP_DIR}/.venv/bin/python" + fi + + log "安装后端依赖" + "${PYTHON_BIN}" -m pip install -e "${APP_DIR}" + + log "构建前端" + (cd "${APP_DIR}/frontend" && npm ci && npm run build) + + log "写入环境变量文件 ${ENV_FILE}" + write_env_file + + if confirm "生成并安装 systemd unit?" "Y" "${AUTO_INSTALL_SYSTEMD}"; then + render_systemd_unit "Agent Service" "backend.apps.agent_service:app" "8000" "1" "1024M" "/etc/systemd/system/bigtime-agent.service" + render_systemd_unit "Trading Service" "backend.apps.trading_service:app" "8001" "1" "768M" "/etc/systemd/system/bigtime-trading.service" + render_systemd_unit "News Service" "backend.apps.news_service:app" "8002" "1" "768M" "/etc/systemd/system/bigtime-news.service" + render_systemd_unit "Runtime Service" "backend.apps.runtime_service:app" "8003" "1" "1536M" "/etc/systemd/system/bigtime-runtime.service" + sudo systemctl daemon-reload + if confirm "立即启用并启动 bigtime-* 服务?" "Y" "${AUTO_START_SYSTEMD}"; then + sudo systemctl enable --now bigtime-agent.service + sudo systemctl enable --now bigtime-trading.service + sudo systemctl enable --now bigtime-news.service + sudo systemctl enable --now bigtime-runtime.service + fi + fi + + if confirm "生成并安装 nginx 配置?" "Y" "${AUTO_INSTALL_NGINX}"; then + local use_tls="no" + if confirm "使用 HTTPS/Let's Encrypt 证书路径?" "N" "${AUTO_USE_TLS}"; then + use_tls="yes" + SSL_CERT_PATH="${SSL_CERT_PATH:-$(ask_required 'SSL 证书 fullchain.pem 路径' "/etc/letsencrypt/live/${DOMAIN}/fullchain.pem")}" + SSL_KEY_PATH="${SSL_KEY_PATH:-$(ask_required 'SSL 私钥 privkey.pem 路径' "/etc/letsencrypt/live/${DOMAIN}/privkey.pem")}" + [[ -f "${SSL_CERT_PATH}" ]] || warn "证书文件当前不存在: ${SSL_CERT_PATH}" + [[ -f "${SSL_KEY_PATH}" ]] || warn "私钥文件当前不存在: ${SSL_KEY_PATH}" + else + SSL_CERT_PATH="" + SSL_KEY_PATH="" + fi + NGINX_TARGET="/etc/nginx/conf.d/bigtime.conf" + render_nginx_conf "${NGINX_TARGET}" "${use_tls}" + if confirm "立即执行 nginx -t 并 reload?" "Y" "${AUTO_RELOAD_NGINX}"; then + sudo nginx -t + sudo systemctl reload nginx + fi + fi + + echo "" + log "部署向导完成" + echo "应用目录: ${APP_DIR}" + echo "环境文件: ${ENV_FILE}" + echo "Python: ${PYTHON_BIN}" + echo "沙盒模式: ${SKILL_SANDBOX_MODE}" + echo "" + echo "验证建议:" + echo " curl http://127.0.0.1:8003/health" + echo " curl http://127.0.0.1:8003/api/runtime/current" + echo " sudo systemctl status bigtime-runtime.service" + echo " tail -f ${APP_DIR}/runs//logs/gateway.log" +} + +main "$@" diff --git a/deploy/nginx/bigtime.cillinn.com.conf b/deploy/nginx/bigtime.cillinn.com.conf index c4b9db8..230c5dc 100644 --- a/deploy/nginx/bigtime.cillinn.com.conf +++ b/deploy/nginx/bigtime.cillinn.com.conf @@ -2,8 +2,9 @@ server { listen 80; server_name bigtime.cillinn.com; + root /opt/bigtime/app/frontend/dist; + location /.well-known/acme-challenge/ { - root /var/www/bigtime/current; allow all; } @@ -16,7 +17,7 @@ server { listen 443 ssl http2; server_name bigtime.cillinn.com; - root /var/www/bigtime/current; + root /opt/bigtime/app/frontend/dist; index index.html; ssl_certificate /etc/letsencrypt/live/bigtime.cillinn.com/fullchain.pem; @@ -36,6 +37,56 @@ server { proxy_read_timeout 300s; } + location /api/runtime/ { + proxy_pass http://127.0.0.1:8003; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 300s; + } + + location /api/dynamic-team/ { + proxy_pass http://127.0.0.1:8003; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 300s; + } + + location /api/trading/ { + proxy_pass http://127.0.0.1:8001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 300s; + } + + location /api/news/ { + proxy_pass http://127.0.0.1:8002; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 300s; + } + + location /api/ { + proxy_pass http://127.0.0.1:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 300s; + } + location / { try_files $uri $uri/ /index.html; } diff --git a/deploy/nginx/bigtime.cillinn.com.http.conf b/deploy/nginx/bigtime.cillinn.com.http.conf index 7724fda..8cba2c5 100644 --- a/deploy/nginx/bigtime.cillinn.com.http.conf +++ b/deploy/nginx/bigtime.cillinn.com.http.conf @@ -2,13 +2,75 @@ server { listen 80; server_name bigtime.cillinn.com; - root /var/www/bigtime/current; + root /opt/bigtime/app/frontend/dist; index index.html; location /.well-known/acme-challenge/ { allow all; } + location /ws { + proxy_pass http://127.0.0.1:8765; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 300s; + } + + location /api/runtime/ { + proxy_pass http://127.0.0.1:8003; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 300s; + } + + location /api/dynamic-team/ { + proxy_pass http://127.0.0.1:8003; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 300s; + } + + location /api/trading/ { + proxy_pass http://127.0.0.1:8001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 300s; + } + + location /api/news/ { + proxy_pass http://127.0.0.1:8002; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 300s; + } + + location /api/ { + proxy_pass http://127.0.0.1:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 300s; + } + location / { try_files $uri $uri/ /index.html; } diff --git a/deploy/production-deployment.md b/deploy/production-deployment.md new file mode 100644 index 0000000..3e5cf53 --- /dev/null +++ b/deploy/production-deployment.md @@ -0,0 +1,134 @@ +# Production Deployment + +This is the recommended production deployment mode for the current repository. + +## Recommendation + +Use: + +- split FastAPI services +- `systemd` as the process supervisor +- `nginx` as TLS terminator and reverse proxy +- static frontend build served by `nginx` +- Docker-based skill sandbox + +This matches the current architecture better than a monolithic process and is +lower-risk than introducing Kubernetes at the current stage. + +## Why This Mode Fits Best + +1. The repository already uses a split-service runtime model. +2. `runtime_service` is the correct control-plane entrypoint for starting and + stopping Gateway subprocesses. +3. The Gateway is run-scoped and ephemeral, which fits `systemd` + subprocess + management better than forcing everything into a single service binary. +4. Skill execution has security requirements; Docker sandboxing is the practical + production default. + +## Service Layout + +| Component | Bind | +|----------|------| +| `agent_service` | `127.0.0.1:8000` | +| `trading_service` | `127.0.0.1:8001` | +| `news_service` | `127.0.0.1:8002` | +| `runtime_service` | `127.0.0.1:8003` | +| gateway websocket | spawned by `runtime_service` | +| `nginx` | `:80` / `:443` | + +## Frontend + +Recommended frontend mode: + +```bash +cd frontend +npm ci +npm run build +``` + +Then point `nginx` root at: + +```text +/opt/bigtime/app/frontend/dist +``` + +This is preferred over running `backend.apps.frontend_service` in production, +because static serving via `nginx` is simpler and more reliable. + +## Environment + +Create a shared environment file, for example: + +```bash +sudo mkdir -p /etc/bigtime +sudo cp .env /etc/bigtime/bigtime.env +``` + +Required production settings: + +```bash +AGENT_SERVICE_URL=http://127.0.0.1:8000 +TRADING_SERVICE_URL=http://127.0.0.1:8001 +NEWS_SERVICE_URL=http://127.0.0.1:8002 +RUNTIME_SERVICE_URL=http://127.0.0.1:8003 + +SKILL_SANDBOX_MODE=docker +SKILL_SANDBOX_MEMORY_LIMIT=512m +SKILL_SANDBOX_CPU_LIMIT=1.0 +SKILL_SANDBOX_NETWORK=none +SKILL_SANDBOX_TIMEOUT=60 +``` + +Also supply the required market/model API keys in the same environment file or +through your secret-management system. + +## Data Persistence + +Persist these paths on durable storage: + +- `runs/` +- `logs/` if you keep service logs on disk +- optional `.env`-backed secrets should not live inside the repo working tree + +The key runtime source of truth is: + +- `runs//state/runtime_state.json` +- `runs//state/server_state.json` +- `runs//logs/gateway.log` + +## nginx Pattern + +Recommended routing: + +- `/` -> static frontend +- `/api/runtime/*` -> `127.0.0.1:8003` +- `/api/dynamic-team/*` -> `127.0.0.1:8003` +- `/api/trading/*` -> `127.0.0.1:8001` +- `/api/news/*` -> `127.0.0.1:8002` +- `/api/*` -> `127.0.0.1:8000` +- `/ws` -> gateway websocket + +The checked-in nginx config should be treated as a starting point, not a full +multi-service production config. + +## Operational Notes + +- Use `workers=1` for `runtime_service` unless you deliberately redesign the + runtime manager around multi-process coordination. +- Keep the other API services stateless and scale them separately if needed. +- Monitor: + - `runtime_service` + - run-scoped `gateway.log` + - Docker daemon health +- Rotate logs outside the app, e.g. with journald or logrotate. + +## Best Next Step + +Deploy with: + +- `systemd` units from [deploy/systemd](/Users/cillin/workspeace/evotraders/deploy/systemd) +- `nginx` in front +- one VM first + +Only move to containers/orchestration after the runtime/gateway operational +behavior is stable in that simpler topology. diff --git a/deploy/systemd/README.md b/deploy/systemd/README.md new file mode 100644 index 0000000..5b1a6e9 --- /dev/null +++ b/deploy/systemd/README.md @@ -0,0 +1,47 @@ +# systemd Units + +This directory contains recommended `systemd` unit templates for the current +split-service production topology. + +## Recommended Topology + +- `agent_service` on `127.0.0.1:8000` +- `trading_service` on `127.0.0.1:8001` +- `news_service` on `127.0.0.1:8002` +- `runtime_service` on `127.0.0.1:8003` +- `nginx` serves `frontend/dist` and proxies `/api/*` + `/ws` +- `runtime_service` spawns the run-scoped Gateway subprocess on demand +- skill execution runs with `SKILL_SANDBOX_MODE=docker` + +## Install + +Adjust these placeholders before installing: + +- `/opt/bigtime/app` -> repository root on the server +- `/opt/bigtime/app/.venv/bin/python` -> Python interpreter +- `/etc/bigtime/bigtime.env` -> shared environment file +- `bigtime` -> service user + +Then copy units: + +```bash +sudo mkdir -p /etc/bigtime +sudo cp .env /etc/bigtime/bigtime.env + +sudo cp deploy/systemd/bigtime-*.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now bigtime-agent.service +sudo systemctl enable --now bigtime-trading.service +sudo systemctl enable --now bigtime-news.service +sudo systemctl enable --now bigtime-runtime.service +``` + +## Frontend + +Recommended production frontend mode: + +- build with `cd frontend && npm ci && npm run build` +- let `nginx` serve `frontend/dist` directly + +The repository also contains `backend.apps.frontend_service`, but for +production the lower-complexity path is static hosting via `nginx`. diff --git a/deploy/systemd/bigtime-agent.service b/deploy/systemd/bigtime-agent.service new file mode 100644 index 0000000..36aba98 --- /dev/null +++ b/deploy/systemd/bigtime-agent.service @@ -0,0 +1,25 @@ +[Unit] +Description=BigTime Agent Service +After=network.target + +[Service] +Type=simple +User=bigtime +Group=bigtime +WorkingDirectory=/opt/bigtime/app +EnvironmentFile=/etc/bigtime/bigtime.env +ExecStart=/opt/bigtime/app/.venv/bin/python -m uvicorn backend.apps.agent_service:app --host 127.0.0.1 --port 8000 --workers 1 --log-level warning --no-access-log +Restart=always +RestartSec=3 +TimeoutStopSec=20 +KillMode=mixed +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=full +ProtectHome=true +LimitNOFILE=65535 +TasksMax=4096 +MemoryMax=1024M + +[Install] +WantedBy=multi-user.target diff --git a/deploy/systemd/bigtime-news.service b/deploy/systemd/bigtime-news.service new file mode 100644 index 0000000..b4f5f46 --- /dev/null +++ b/deploy/systemd/bigtime-news.service @@ -0,0 +1,25 @@ +[Unit] +Description=BigTime News Service +After=network.target + +[Service] +Type=simple +User=bigtime +Group=bigtime +WorkingDirectory=/opt/bigtime/app +EnvironmentFile=/etc/bigtime/bigtime.env +ExecStart=/opt/bigtime/app/.venv/bin/python -m uvicorn backend.apps.news_service:app --host 127.0.0.1 --port 8002 --workers 1 --log-level warning --no-access-log +Restart=always +RestartSec=3 +TimeoutStopSec=20 +KillMode=mixed +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=full +ProtectHome=true +LimitNOFILE=65535 +TasksMax=4096 +MemoryMax=768M + +[Install] +WantedBy=multi-user.target diff --git a/deploy/systemd/bigtime-runtime.service b/deploy/systemd/bigtime-runtime.service new file mode 100644 index 0000000..3d3f2f2 --- /dev/null +++ b/deploy/systemd/bigtime-runtime.service @@ -0,0 +1,25 @@ +[Unit] +Description=BigTime Runtime Service +After=network.target + +[Service] +Type=simple +User=bigtime +Group=bigtime +WorkingDirectory=/opt/bigtime/app +EnvironmentFile=/etc/bigtime/bigtime.env +ExecStart=/opt/bigtime/app/.venv/bin/python -m uvicorn backend.apps.runtime_service:app --host 127.0.0.1 --port 8003 --workers 1 --log-level warning --no-access-log +Restart=always +RestartSec=3 +TimeoutStopSec=30 +KillMode=mixed +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=full +ProtectHome=true +LimitNOFILE=65535 +TasksMax=4096 +MemoryMax=1536M + +[Install] +WantedBy=multi-user.target diff --git a/deploy/systemd/bigtime-trading.service b/deploy/systemd/bigtime-trading.service new file mode 100644 index 0000000..a183bdb --- /dev/null +++ b/deploy/systemd/bigtime-trading.service @@ -0,0 +1,25 @@ +[Unit] +Description=BigTime Trading Service +After=network.target + +[Service] +Type=simple +User=bigtime +Group=bigtime +WorkingDirectory=/opt/bigtime/app +EnvironmentFile=/etc/bigtime/bigtime.env +ExecStart=/opt/bigtime/app/.venv/bin/python -m uvicorn backend.apps.trading_service:app --host 127.0.0.1 --port 8001 --workers 1 --log-level warning --no-access-log +Restart=always +RestartSec=3 +TimeoutStopSec=20 +KillMode=mixed +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=full +ProtectHome=true +LimitNOFILE=65535 +TasksMax=4096 +MemoryMax=768M + +[Install] +WantedBy=multi-user.target diff --git a/deploy/uninstall-production.sh b/deploy/uninstall-production.sh new file mode 100644 index 0000000..25eacd3 --- /dev/null +++ b/deploy/uninstall-production.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +log() { + echo -e "${GREEN}[bigtime]${NC} $*" +} + +warn() { + echo -e "${YELLOW}[bigtime]${NC} $*" +} + +SYSTEMD_UNITS=( + bigtime-agent.service + bigtime-trading.service + bigtime-news.service + bigtime-runtime.service +) + +NGINX_CONF="/etc/nginx/conf.d/bigtime.conf" +ENV_FILE="/etc/bigtime/bigtime.env" + +for unit in "${SYSTEMD_UNITS[@]}"; do + if systemctl list-unit-files "${unit}" >/dev/null 2>&1; then + warn "Stopping ${unit}" + sudo systemctl disable --now "${unit}" || true + sudo rm -f "/etc/systemd/system/${unit}" + fi +done + +sudo systemctl daemon-reload || true + +if [[ -f "${NGINX_CONF}" ]]; then + warn "Removing nginx config ${NGINX_CONF}" + sudo rm -f "${NGINX_CONF}" + sudo nginx -t && sudo systemctl reload nginx || true +fi + +if [[ -f "${ENV_FILE}" ]]; then + warn "Keeping env file ${ENV_FILE}" + warn "Delete it manually if you want a full cleanup." +fi + +log "BigTime production service uninstall finished." diff --git a/frontend/public/media/0.png b/frontend/public/media/0.png new file mode 100644 index 0000000..716034e Binary files /dev/null and b/frontend/public/media/0.png differ diff --git a/frontend/public/media/1.png b/frontend/public/media/1.png new file mode 100644 index 0000000..a355f5f Binary files /dev/null and b/frontend/public/media/1.png differ diff --git a/frontend/public/media/10.png b/frontend/public/media/10.png new file mode 100644 index 0000000..74097a3 Binary files /dev/null and b/frontend/public/media/10.png differ diff --git a/frontend/public/media/11.png b/frontend/public/media/11.png new file mode 100644 index 0000000..006fa5e Binary files /dev/null and b/frontend/public/media/11.png differ diff --git a/frontend/public/media/2.png b/frontend/public/media/2.png new file mode 100644 index 0000000..38bc43c Binary files /dev/null and b/frontend/public/media/2.png differ diff --git a/frontend/public/media/3.png b/frontend/public/media/3.png new file mode 100644 index 0000000..88d4d81 Binary files /dev/null and b/frontend/public/media/3.png differ diff --git a/frontend/public/media/4.png b/frontend/public/media/4.png new file mode 100644 index 0000000..90a9dd6 Binary files /dev/null and b/frontend/public/media/4.png differ diff --git a/frontend/public/media/5.png b/frontend/public/media/5.png new file mode 100644 index 0000000..0d3fe39 Binary files /dev/null and b/frontend/public/media/5.png differ diff --git a/frontend/public/media/6.png b/frontend/public/media/6.png new file mode 100644 index 0000000..7746cf9 Binary files /dev/null and b/frontend/public/media/6.png differ diff --git a/frontend/public/media/7.png b/frontend/public/media/7.png new file mode 100644 index 0000000..3cf4bc8 Binary files /dev/null and b/frontend/public/media/7.png differ diff --git a/frontend/public/media/8.png b/frontend/public/media/8.png new file mode 100644 index 0000000..bc20c40 Binary files /dev/null and b/frontend/public/media/8.png differ diff --git a/frontend/public/media/9.png b/frontend/public/media/9.png new file mode 100644 index 0000000..f7d6859 Binary files /dev/null and b/frontend/public/media/9.png differ diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d912e8b..8978d9e 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -8,12 +8,17 @@ import { useFeedProcessor } from './hooks/useFeedProcessor'; import { useRuntimeControls } from './hooks/useRuntimeControls'; import { useStockDataRequests } from './hooks/useStockDataRequests'; import { useWebSocketConnection } from './hooks/useWebSocketConnection'; -import { fetchRuntimeLogs } from './services/runtimeApi'; +import { fetchRuntimeAgents, fetchRuntimeLogs } from './services/runtimeApi'; import { useAgentRunFileState, useAgentStore } from './store/agentStore'; import { useMarketStore } from './store/marketStore'; import { usePortfolioStore } from './store/portfolioStore'; import { useRuntimeStore } from './store/runtimeStore'; import { useUIStore } from './store/uiStore'; +import { + buildRuntimeAgentMeta, + findAgentByIdOrName, + sortRuntimeAgents, +} from './utils/agentDisplay'; const EDITABLE_AGENT_WORKSPACE_FILES = [ 'SOUL.md', @@ -174,21 +179,57 @@ export default function LiveTradingApp() { const [isRuntimeLogsLoading, setIsRuntimeLogsLoading] = useState(false); const [runtimeLogsPayload, setRuntimeLogsPayload] = useState(null); const [runtimeLogsError, setRuntimeLogsError] = useState(null); + const [runtimeAgents, setRuntimeAgents] = useState([]); const agentFeedRef = useRef(null); const isSocketReady = isConnected && connectionStatus === 'connected'; - const selectedAgentId = selectedSkillAgentId || AGENTS[0]?.id || null; + const resolvedAgents = useMemo(() => { + if (!Array.isArray(runtimeAgents) || runtimeAgents.length === 0) { + return AGENTS; + } + + return sortRuntimeAgents(runtimeAgents).map((agentState, index) => { + const agentId = String(agentState?.agent_id || agentState?.id || '').trim(); + const base = buildRuntimeAgentMeta(agentId, index); + const displayName = typeof agentState?.display_name === 'string' ? agentState.display_name.trim() : ''; + return { + ...base, + id: agentId, + name: displayName || base.name, + runtimeStatus: agentState?.status || null, + lastSession: agentState?.last_session || null, + lastUpdated: agentState?.last_updated || null, + }; + }).filter((agent) => agent.id); + }, [runtimeAgents]); + + const selectedAgentId = selectedSkillAgentId || resolvedAgents[0]?.id || null; const selectedAgentProfile = selectedAgentId ? (agentProfilesByAgent[selectedAgentId] || null) : null; const selectedAgentSkills = selectedAgentId ? (agentSkillsByAgent[selectedAgentId] || []) : []; const selectedRunFileContent = selectedAgentId && selectedRunFile ? (runFilesByAgent[selectedAgentId]?.[selectedRunFile] || '') : ''; - useEffect(() => { - if (!selectedSkillAgentId && AGENTS.length > 0) { - setSelectedSkillAgentId(AGENTS[0].id); + const loadRuntimeAgentsList = useCallback(async () => { + try { + const payload = await fetchRuntimeAgents(); + setRuntimeAgents(Array.isArray(payload?.agents) ? payload.agents : []); + } catch { + setRuntimeAgents([]); } - }, [selectedSkillAgentId, setSelectedSkillAgentId]); + }, []); + + useEffect(() => { + if (!selectedSkillAgentId && resolvedAgents.length > 0) { + setSelectedSkillAgentId(resolvedAgents[0].id); + } + }, [resolvedAgents, selectedSkillAgentId, setSelectedSkillAgentId]); + + useEffect(() => { + if (selectedSkillAgentId && !resolvedAgents.some((agent) => agent.id === selectedSkillAgentId)) { + setSelectedSkillAgentId(resolvedAgents[0]?.id || null); + } + }, [resolvedAgents, selectedSkillAgentId, setSelectedSkillAgentId]); useEffect(() => { if (!selectedRunFile) { @@ -196,6 +237,37 @@ export default function LiveTradingApp() { } }, [selectedRunFile, setSelectedWorkspaceFile]); + useEffect(() => { + void loadRuntimeAgentsList(); + }, [loadRuntimeAgentsList]); + + useEffect(() => { + const handleRuntimeAgentsUpdated = () => { + void loadRuntimeAgentsList(); + }; + window.addEventListener('runtime-agents-updated', handleRuntimeAgentsUpdated); + return () => { + window.removeEventListener('runtime-agents-updated', handleRuntimeAgentsUpdated); + }; + }, [loadRuntimeAgentsList]); + + useEffect(() => { + if (!isSocketReady) { + return; + } + void loadRuntimeAgentsList(); + }, [isSocketReady, loadRuntimeAgentsList]); + + useEffect(() => { + if (!selectedAgentId || !selectedRunFile) { + setRunDraftContent(''); + return; + } + + const cachedContent = runFilesByAgent[selectedAgentId]?.[selectedRunFile]; + setRunDraftContent(typeof cachedContent === 'string' ? cachedContent : ''); + }, [runFilesByAgent, selectedAgentId, selectedRunFile, setRunDraftContent]); + useEffect(() => { if (!isSocketReady || !selectedAgentId || !clientRef.current) { return; @@ -233,7 +305,7 @@ export default function LiveTradingApp() { return; } - AGENTS.forEach((agent) => { + resolvedAgents.forEach((agent) => { if (!agent?.id) { return; } @@ -246,6 +318,7 @@ export default function LiveTradingApp() { clientRef, isSocketReady, requestAgentProfile, + resolvedAgents, ]); useEffect(() => { @@ -326,13 +399,13 @@ export default function LiveTradingApp() { const bubbleFor = useCallback((idOrName) => { let bubble = bubbles[idOrName]; if (bubble) return bubble; - const agent = AGENTS.find((item) => item.name === idOrName || item.id === idOrName); + const agent = findAgentByIdOrName(resolvedAgents, idOrName); if (agent) { bubble = bubbles[agent.id]; if (bubble) return bubble; } return null; - }, [bubbles]); + }, [bubbles, resolvedAgents]); const handleManualTrigger = useCallback(() => { if (!isSocketReady || !clientRef.current) { @@ -361,7 +434,7 @@ export default function LiveTradingApp() { }, []); const agentRequests = { - agents: AGENTS, + agents: resolvedAgents, agentProfilesByAgent, agentSkillsByAgent, runFilesByAgent, diff --git a/frontend/src/components/AgentFeed.jsx b/frontend/src/components/AgentFeed.jsx index 9dc212f..df34262 100644 --- a/frontend/src/components/AgentFeed.jsx +++ b/frontend/src/components/AgentFeed.jsx @@ -1,9 +1,10 @@ import React, { useState, useRef, useImperativeHandle, forwardRef } from 'react'; import { formatTime } from '../utils/formatters'; -import { MESSAGE_COLORS, getAgentColors, AGENTS, ASSETS } from '../config/constants'; +import { MESSAGE_COLORS, getAgentColors, ASSETS } from '../config/constants'; import { getModelIcon } from '../utils/modelIcons'; import MarkdownModal from './MarkdownModal'; import LobeModelLogo from './LobeModelLogo.jsx'; +import { findAgentByIdOrName, humanizeAgentId } from '../utils/agentDisplay'; const isAnalyst = (agentId, agentName) => { if (agentId && agentId.includes('analyst')) return true; @@ -36,7 +37,7 @@ const stripMarkdown = (text) => { .replace(/^[-=]+$/gm, ''); }; -const AgentFeed = forwardRef(({ feed, leaderboard, agentProfilesByAgent }, ref) => { +const AgentFeed = forwardRef(({ agents = [], feed, leaderboard, agentProfilesByAgent }, ref) => { const feedContentRef = useRef(null); const [highlightedId, setHighlightedId] = useState(null); const [selectedAgent, setSelectedAgent] = useState('all'); @@ -62,7 +63,7 @@ const AgentFeed = forwardRef(({ feed, leaderboard, agentProfilesByAgent }, ref) // Get agent info by name const getAgentInfoByName = (agentName) => { if (!agentName) return null; - const agentConfig = AGENTS.find((agent) => agent.name === agentName); + const agentConfig = findAgentByIdOrName(agents, agentName); const profile = agentConfig ? agentProfilesByAgent?.[agentConfig.id] : null; if (agentConfig && profile?.model_name) { return { @@ -81,7 +82,7 @@ const AgentFeed = forwardRef(({ feed, leaderboard, agentProfilesByAgent }, ref) }; }; - // Get unique agent names from feed (only registered agents in AGENTS) + // Get unique agent names from feed using the current runtime agent list. const getUniqueAgents = () => { const agentNamesInFeed = new Set(); @@ -98,9 +99,10 @@ const AgentFeed = forwardRef(({ feed, leaderboard, agentProfilesByAgent }, ref) } }); - // Filter to only include registered agents and sort by AGENTS array order - const registeredAgentNames = AGENTS.map(a => a.name); - return registeredAgentNames.filter(name => agentNamesInFeed.has(name)); + const orderedRuntimeNames = agents.map((agent) => agent.name); + const knownNames = orderedRuntimeNames.filter(name => agentNamesInFeed.has(name)); + const extraNames = [...agentNamesInFeed].filter(name => !orderedRuntimeNames.includes(name)); + return [...knownNames, ...extraNames]; }; // Filter feed based on selected agent @@ -177,6 +179,12 @@ const AgentFeed = forwardRef(({ feed, leaderboard, agentProfilesByAgent }, ref) const currentSelection = getCurrentSelectionInfo(); + const resolveAgentDisplayName = (name, agentId) => { + if (name) return name; + const agent = findAgentByIdOrName(agents, agentId); + return agent?.name || humanizeAgentId(agentId); + }; + return (
@@ -241,7 +249,7 @@ const AgentFeed = forwardRef(({ feed, leaderboard, agentProfilesByAgent }, ref) type="color" /> )} - {agent} + {resolveAgentDisplayName(agent, agentInfo?.agentId)}
); })} @@ -255,7 +263,7 @@ const AgentFeed = forwardRef(({ feed, leaderboard, agentProfilesByAgent }, ref)
{selectedAgent === 'all' ? '等待系统更新...' - : `${selectedAgent} 没有消息`} + : `${resolveAgentDisplayName(selectedAgent, currentSelection.agentInfo?.agentId)} 没有消息`}
)} diff --git a/frontend/src/components/AppShell.jsx b/frontend/src/components/AppShell.jsx index 7c7fab2..0ab295a 100644 --- a/frontend/src/components/AppShell.jsx +++ b/frontend/src/components/AppShell.jsx @@ -3,7 +3,6 @@ import GlobalStyles from '../styles/GlobalStyles'; import Header from './Header.jsx'; import RuntimeSettingsPanel from './RuntimeSettingsPanel.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'; @@ -401,6 +400,7 @@ export default function AppShell({
}> }> - +
diff --git a/frontend/src/components/RoomView.jsx b/frontend/src/components/RoomView.jsx index 4f5b840..69813c9 100644 --- a/frontend/src/components/RoomView.jsx +++ b/frontend/src/components/RoomView.jsx @@ -1,8 +1,9 @@ import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'; -import { ASSETS, SCENE_NATIVE, AGENT_SEATS, AGENTS } from '../config/constants'; +import { ASSETS, SCENE_NATIVE, AGENT_SEATS } from '../config/constants'; import AgentCard from './AgentCard'; import { getModelIcon } from '../utils/modelIcons'; import LobeModelLogo from './LobeModelLogo.jsx'; +import { findAgentByIdOrName } from '../utils/agentDisplay'; /** * Custom hook to load an image @@ -48,7 +49,22 @@ function getRankMedal(rank) { * Supports click and hover (1.5s) to show agent performance cards * Supports replay mode - completely independent from live mode */ -export default function RoomView({ bubbles, bubbleFor, leaderboard, agentProfilesByAgent, feed, onJumpToMessage, onOpenLaunchConfig }) { +function getSeatPosition(index) { + if (AGENT_SEATS[index]) { + return AGENT_SEATS[index]; + } + + const overflowIndex = index - AGENT_SEATS.length; + const columns = 3; + const row = Math.floor(overflowIndex / columns); + const column = overflowIndex % columns; + return { + x: 0.18 + (column * 0.18), + y: Math.max(0.14, 0.22 - (row * 0.1)), + }; +} + +export default function RoomView({ agents = [], bubbles, bubbleFor, leaderboard, agentProfilesByAgent, feed, onJumpToMessage, onOpenLaunchConfig }) { const canvasRef = useRef(null); const containerRef = useRef(null); @@ -152,16 +168,16 @@ export default function RoomView({ bubbles, bubbleFor, leaderboard, agentProfile // Determine which agents are speaking const speakingAgents = useMemo(() => { const speaking = {}; - AGENTS.forEach(agent => { + agents.forEach(agent => { const bubble = bubbleFor(agent.name); speaking[agent.id] = !!bubble; }); return speaking; - }, [bubbles, bubbleFor]); + }, [agents, bubbleFor, bubbles]); // Find agent data from leaderboard const getAgentData = (agentId) => { - const agent = AGENTS.find(a => a.id === agentId); + const agent = agents.find(a => a.id === agentId); if (!agent) return null; const profile = agentProfilesByAgent?.[agentId] || null; @@ -195,7 +211,7 @@ export default function RoomView({ bubbles, bubbleFor, leaderboard, agentProfile }; } - // Merge data but preserve the correct avatar from AGENTS config + // Merge data but preserve the configured visual metadata from frontend. return { ...agent, ...leaderboardData, @@ -317,10 +333,7 @@ export default function RoomView({ bubbles, bubbleFor, leaderboard, agentProfile // Skip system messages if (msg.agent === 'System') return; // Find matching agent - const agent = AGENTS.find(a => - a.id === msg.agentId || - a.name === msg.agent - ); + const agent = findAgentByIdOrName(agents, msg.agentId || msg.agent); if (agent) { messages.push({ feedItemId: item.id, @@ -333,10 +346,7 @@ export default function RoomView({ bubbles, bubbleFor, leaderboard, agentProfile } else if (item.type === 'conference' && item.data?.messages) { item.data.messages.forEach((msg, msgIndex) => { if (msg.agent === 'System') return; - const agent = AGENTS.find(a => - a.id === msg.agentId || - a.name === msg.agent - ); + const agent = findAgentByIdOrName(agents, msg.agentId || msg.agent); if (agent) { messages.push({ feedItemId: item.id, @@ -479,7 +489,7 @@ export default function RoomView({ bubbles, bubbleFor, leaderboard, agentProfile if (isReplaying) { // Find replay bubble for this agent const bubble = Object.values(replayBubbles).find(b => { - const agent = AGENTS.find(a => a.id === b.agentId); + const agent = agents.find(a => a.id === b.agentId); return agent && agent.name === agentName; }); return bubble || null; @@ -487,13 +497,13 @@ export default function RoomView({ bubbles, bubbleFor, leaderboard, agentProfile // Use normal bubbleFor function return bubbleFor(agentName); } - }, [isReplaying, replayBubbles, bubbleFor]); + }, [agents, isReplaying, replayBubbles, bubbleFor]); return (
{/* Agents Indicator Bar */}
- {AGENTS.map((agent, index) => { + {agents.map((agent, index) => { const rank = getAgentRank(agent.id); const medal = rank ? getRankMedal(rank) : null; const agentData = getAgentData(agent.id); @@ -572,7 +582,7 @@ export default function RoomView({ bubbles, bubbleFor, leaderboard, agentProfile {/* Speech Bubbles */} - {AGENTS.map((agent, idx) => { + {agents.map((agent, idx) => { const bubble = getBubbleForAgent(agent.name); if (!bubble) return null; @@ -581,7 +591,7 @@ export default function RoomView({ bubbles, bubbleFor, leaderboard, agentProfile // Check if bubble is hidden if (hiddenBubbles[bubbleKey]) return null; - const pos = AGENT_SEATS[idx]; + const pos = getSeatPosition(idx); const scaledWidth = SCENE_NATIVE.width * scale; const scaledHeight = SCENE_NATIVE.height * scale; diff --git a/frontend/src/config/constants.js b/frontend/src/config/constants.js index eea5184..3580b0d 100644 --- a/frontend/src/config/constants.js +++ b/frontend/src/config/constants.js @@ -3,6 +3,7 @@ */ const trimTrailingSlash = (value) => value.replace(/\/+$/, ""); +const mediaAsset = (filename) => `/media/${filename}`; const isLocalDevHost = () => { if (typeof window === "undefined") { return false; @@ -14,12 +15,12 @@ const isLocalDevHost = () => { // Centralized CDN asset URLs export const CDN_ASSETS = { companyRoom: { - agent_1: "https://img.alicdn.com/imgextra/i4/O1CN01Lr7SOl1lSExV0tOwv_!!6000000004817-2-tps-370-320.png", - agent_2: "https://img.alicdn.com/imgextra/i3/O1CN017Kb8cY1VQNUmuK47o_!!6000000002647-2-tps-368-312.png", - agent_3: "https://img.alicdn.com/imgextra/i3/O1CN010Fp55w1YqtGpVjgsS_!!6000000003111-2-tps-370-320.png", - agent_4: "https://img.alicdn.com/imgextra/i3/O1CN01VnUsML1Dkq6fHw3ks_!!6000000000255-2-tps-366-316.png", - agent_5: "https://img.alicdn.com/imgextra/i4/O1CN01o0kCQw1kyvbulBSl7_!!6000000004753-2-tps-370-314.png", - agent_6: "https://img.alicdn.com/imgextra/i2/O1CN01cLV0zl1FI6ULAunTp_!!6000000000463-2-tps-368-320.png", + agent_1: mediaAsset("0.png"), + agent_2: mediaAsset("1.png"), + agent_3: mediaAsset("2.png"), + agent_4: mediaAsset("3.png"), + agent_5: mediaAsset("4.png"), + agent_6: mediaAsset("5.png"), team_logo: "https://img.alicdn.com/imgextra/i2/O1CN01n2S8aV25hcZhhNH95_!!6000000007558-2-tps-616-700.png", reme_logo: "https://img.alicdn.com/imgextra/i2/O1CN01FhncuT1Tqp8LfCaft_!!6000000002434-2-tps-915-250.png", full_room_dark: "https://img.alicdn.com/imgextra/i2/O1CN014sOgzK28re5haGC3X_!!6000000007986-2-tps-1248-832.png", @@ -45,6 +46,14 @@ export const ASSETS = { remeLogo: CDN_ASSETS.companyRoom.reme_logo, }; +export const NON_MANAGER_AVATAR_POOL = Array.from({ length: 10 }, (_, index) => ( + mediaAsset(`${index + 2}.png`) +)); + +export const DYNAMIC_ANALYST_AVATAR_POOL = Array.from({ length: 6 }, (_, index) => ( + mediaAsset(`${index + 6}.png`) +)); + // Scene dimensions (actual image size) export const SCENE_NATIVE = { width: 1248, height: 832 }; @@ -383,4 +392,3 @@ export const suggestAgentId = (name, baseType) => { // Must end with '_analyst' to get analysis tools registered return `${normalized || baseType}_${timestamp}_analyst`; }; - diff --git a/frontend/src/hooks/useFeedProcessor.js b/frontend/src/hooks/useFeedProcessor.js index 6226263..16d9c63 100644 --- a/frontend/src/hooks/useFeedProcessor.js +++ b/frontend/src/hooks/useFeedProcessor.js @@ -1,5 +1,6 @@ import { useState, useCallback, useRef } from "react"; import { AGENTS } from "../config/constants"; +import { humanizeAgentId } from "../utils/agentDisplay"; const MAX_FEED_ITEMS = 200; @@ -108,7 +109,7 @@ const eventToMessage = (evt) => { id: generateId("msg"), timestamp, agentId: evt.agentId, - agent: normalizeAgentLabel(agent?.name || evt.agentName || evt.agentId || "Agent", evt.agentId), + agent: normalizeAgentLabel(agent?.name || evt.agentName || humanizeAgentId(evt.agentId) || "Agent", evt.agentId), role: agent?.role || evt.role || "Agent", content: evt.content }; @@ -118,7 +119,7 @@ const eventToMessage = (evt) => { id: generateId("memory"), timestamp, agentId: evt.agentId, - agent: agent?.name || evt.agentId || "Memory", + agent: agent?.name || humanizeAgentId(evt.agentId) || "Memory", role: "Memory", content: evt.content || evt.text || "" }; diff --git a/frontend/src/hooks/useWebSocketConnection.js b/frontend/src/hooks/useWebSocketConnection.js index defef39..157145d 100644 --- a/frontend/src/hooks/useWebSocketConnection.js +++ b/frontend/src/hooks/useWebSocketConnection.js @@ -1,5 +1,6 @@ import { useEffect, useRef, useCallback } from 'react'; import { AGENTS } from '../config/constants'; +import { fetchRuntimeAgents } from '../services/runtimeApi'; import { ReadOnlyClient } from '../services/websocket'; import { useRuntimeStore } from '../store/runtimeStore'; import { useOpenClawStore } from '../store/openclawStore'; @@ -8,6 +9,7 @@ import { usePortfolioStore } from '../store/portfolioStore'; import { useAgentStore } from '../store/agentStore'; import { useUIStore } from '../store/uiStore'; import { normalizeTickerSymbols } from '../services/runtimeControls'; +import { humanizeAgentId } from '../utils/agentDisplay'; /** * Normalize price history from server format @@ -401,7 +403,7 @@ export function useWebSocketConnection({ setLocalSkillDraftsByKey, setIsAgentSkillsLoading, setSkillDetailLoadingKey, setAgentSkillsSavingKey, setAgentSkillsFeedback, setIsWorkspaceFileLoading, setWorkspaceFileSavingKey, setWorkspaceFilesByAgent, setWorkspaceFileFeedback, - selectedSkillAgentId } = useAgentStore(); + setWorkspaceDraftContent, selectedSkillAgentId } = useAgentStore(); const { setBubbles } = useUIStore(); @@ -705,14 +707,19 @@ export function useWebSocketConnection({ 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() : ''; + const content = typeof e.content === 'string' ? e.content : ''; if (!agentId || !filename) { setIsWorkspaceFileLoading(false); return; } setWorkspaceFilesByAgent((prev) => ({ ...prev, - [agentId]: { ...(prev[agentId] || {}), [filename]: typeof e.content === 'string' ? e.content : '' } + [agentId]: { ...(prev[agentId] || {}), [filename]: content } })); + const { selectedSkillAgentId: currentAgentId, selectedWorkspaceFile: currentFilename } = useAgentStore.getState(); + if (currentAgentId === agentId && currentFilename === filename) { + setWorkspaceDraftContent(content); + } setIsWorkspaceFileLoading(false); setWorkspaceFileSavingKey(null); }, @@ -1018,16 +1025,25 @@ export function useWebSocketConnection({ 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 } }); + setBubbles({ [e.agentId]: { text: e.content, ts: Date.now(), agentName: agent?.name || e.agentName || humanizeAgentId(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 } }); + setBubbles({ [e.agentId]: { text: e.content, ts: Date.now(), agentName: agent?.name || e.agentName || humanizeAgentId(e.agentId) } }); processFeedEvent(e); }, + runtime_agents_updated: async () => { + try { + await fetchRuntimeAgents(); + window.dispatchEvent(new CustomEvent('runtime-agents-updated')); + } catch { + // Ignore refresh failures; next manual/runtime refresh will recover. + } + }, + memory: (e) => processFeedEvent(e), team_summary: (e) => { diff --git a/frontend/src/store/agentStore.js b/frontend/src/store/agentStore.js index 87bb91d..30ec8a1 100644 --- a/frontend/src/store/agentStore.js +++ b/frontend/src/store/agentStore.js @@ -1,4 +1,5 @@ import { create } from 'zustand'; +import { useShallow } from 'zustand/react/shallow'; const resolveValue = (updater, currentValue) => ( typeof updater === 'function' ? updater(currentValue) : updater @@ -66,13 +67,15 @@ export const useAgentStore = create((set) => ({ * Run-scoped file editing state currently reuses legacy `workspace*` field * names inside the store. Prefer this selector for new runtime UI code. */ -export const useAgentRunFileState = () => useAgentStore((state) => ({ - selectedRunFile: state.selectedWorkspaceFile, - runFilesByAgent: state.workspaceFilesByAgent, - runDraftContent: state.workspaceDraftContent, - isRunFileLoading: state.isWorkspaceFileLoading, - runFileSavingKey: state.workspaceFileSavingKey, - runFileFeedback: state.workspaceFileFeedback, - setSelectedRunFile: state.setSelectedWorkspaceFile, - setRunDraftContent: state.setWorkspaceDraftContent, -})); +export const useAgentRunFileState = () => useAgentStore( + useShallow((state) => ({ + selectedRunFile: state.selectedWorkspaceFile, + runFilesByAgent: state.workspaceFilesByAgent, + runDraftContent: state.workspaceDraftContent, + isRunFileLoading: state.isWorkspaceFileLoading, + runFileSavingKey: state.workspaceFileSavingKey, + runFileFeedback: state.workspaceFileFeedback, + setSelectedRunFile: state.setSelectedWorkspaceFile, + setRunDraftContent: state.setWorkspaceDraftContent, + })) +); diff --git a/frontend/src/utils/agentDisplay.js b/frontend/src/utils/agentDisplay.js new file mode 100644 index 0000000..d7c5810 --- /dev/null +++ b/frontend/src/utils/agentDisplay.js @@ -0,0 +1,77 @@ +import { AGENTS, DYNAMIC_ANALYST_AVATAR_POOL } from '../config/constants'; + +export const STATIC_AGENT_INDEX = new Map(AGENTS.map((agent, index) => [agent.id, { agent, index }])); +const ANALYST_COLOR_PALETTE = AGENTS.filter((agent) => agent.id.endsWith('_analyst')).map((agent) => agent.colors); + +const FALLBACK_AGENT_VISUALS = DYNAMIC_ANALYST_AVATAR_POOL.map((avatar, index) => { + return { + avatar, + colors: ANALYST_COLOR_PALETTE[index % Math.max(ANALYST_COLOR_PALETTE.length, 1)] || AGENTS[0].colors, + }; +}); + +export function humanizeAgentId(agentId) { + if (!agentId) return '未知 Agent'; + const normalized = String(agentId).trim(); + const staticAgent = STATIC_AGENT_INDEX.get(normalized)?.agent; + if (staticAgent?.name) { + return staticAgent.name; + } + const label = normalized + .replace(/_/g, ' ') + .replace(/\b\w/g, (char) => char.toUpperCase()); + return label || normalized; +} + +export function inferAgentRole(agentId) { + const normalized = String(agentId || '').trim(); + const staticAgent = STATIC_AGENT_INDEX.get(normalized)?.agent; + if (staticAgent?.role) { + return staticAgent.role; + } + if (normalized === 'portfolio_manager') return '投资经理'; + if (normalized === 'risk_manager') return '风控经理'; + if (normalized.endsWith('_analyst')) return '分析师'; + return 'Agent'; +} + +export function buildRuntimeAgentMeta(agentId, runtimeIndex = 0) { + const normalized = String(agentId || '').trim(); + const staticAgent = STATIC_AGENT_INDEX.get(normalized)?.agent; + if (staticAgent) { + return staticAgent; + } + + const fallback = FALLBACK_AGENT_VISUALS[runtimeIndex % Math.max(FALLBACK_AGENT_VISUALS.length, 1)] || { + avatar: AGENTS[0].avatar, + colors: AGENTS[0].colors, + }; + return { + id: normalized, + name: humanizeAgentId(normalized), + role: inferAgentRole(normalized), + avatar: fallback.avatar, + colors: fallback.colors, + }; +} + +export function sortRuntimeAgents(agents) { + const staticOrder = new Map(AGENTS.map((agent, index) => [agent.id, index])); + return [...agents].sort((left, right) => { + const leftId = String(left?.agent_id || left?.id || '').trim(); + const rightId = String(right?.agent_id || right?.id || '').trim(); + const leftStatic = staticOrder.has(leftId) ? staticOrder.get(leftId) : Number.MAX_SAFE_INTEGER; + const rightStatic = staticOrder.has(rightId) ? staticOrder.get(rightId) : Number.MAX_SAFE_INTEGER; + if (leftStatic !== rightStatic) { + return leftStatic - rightStatic; + } + return leftId.localeCompare(rightId); + }); +} + +export function findAgentByIdOrName(agents, idOrName) { + if (!Array.isArray(agents) || !idOrName) { + return null; + } + return agents.find((agent) => agent.id === idOrName || agent.name === idOrName) || null; +} diff --git a/start.sh b/start.sh index aa44370..89da56d 100755 --- a/start.sh +++ b/start.sh @@ -1,9 +1,9 @@ #!/usr/bin/env bash # ============================================================ -# 大时代 生产环境启动脚本 +# 大时代 单机启动脚本 # # 用法: -# ./start.sh # 构建前端 + 后台启动全部服务 (默认) +# ./start.sh # 构建前端 + 后台启动全部服务 (单机模式) # ./start.sh --no-build # 跳过前端构建 # ./start.sh --no-daemon # 前台运行 (不使用 nohup) # ./start.sh stop # 停止所有后台服务 @@ -13,7 +13,7 @@ # WORKERS=2 # uvicorn worker 数 (默认: 2) # GATEWAY_HOST=0.0.0.0 # Gateway 绑定地址 # GATEWAY_PORT=8765 # Gateway 端口 -# FRONTEND_PORT=80 # 前端服务端口 (默认: 80) +# FRONTEND_PORT=8080 # 前端服务端口 (默认: 8080) # ============================================================ set -euo pipefail @@ -29,7 +29,7 @@ cd "${SCRIPT_DIR}" WORKERS="${WORKERS:-2}" GATEWAY_HOST="${GATEWAY_HOST:-0.0.0.0}" GATEWAY_PORT="${GATEWAY_PORT:-8765}" -FRONTEND_PORT="${FRONTEND_PORT:-80}" +FRONTEND_PORT="${FRONTEND_PORT:-8080}" PID_DIR="${SCRIPT_DIR}/.pids" LOG_DIR="${SCRIPT_DIR}/logs" FRONTEND_DIST="${SCRIPT_DIR}/frontend/dist" @@ -294,9 +294,12 @@ do_start() { echo "" echo -e "${CYAN}══════════════════════════════════════════${NC}" - echo -e "${CYAN} 大时代 · 生产环境启动${NC}" + echo -e "${CYAN} 大时代 · 单机启动${NC}" echo -e "${CYAN}══════════════════════════════════════════${NC}" echo "" + echo -e "${YELLOW}说明:${NC} 当前脚本适合单机运行或演示环境。" + echo -e "${YELLOW}正式生产部署请优先使用 deploy/systemd + nginx 静态前端方案。${NC}" + echo "" if ${DAEMON}; then start_daemon