Add per-agent skill workspaces and TraderView management

This commit is contained in:
2026-03-17 13:55:14 +08:00
parent 1f5ee3698e
commit 2daf5717ba
35 changed files with 4774 additions and 331 deletions

View File

@@ -0,0 +1,75 @@
# -*- coding: utf-8 -*-
"""Per-agent run-scoped workspace configuration helpers."""
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional
import yaml
@dataclass(frozen=True)
class AgentWorkspaceConfig:
"""Structured agent config loaded from runs/<config>/agents/<agent>/agent.yaml."""
values: Dict[str, Any] = field(default_factory=dict)
@property
def prompt_files(self) -> Optional[List[str]]:
raw = self.values.get("prompt_files")
if not isinstance(raw, list):
return None
files = [
str(item).strip()
for item in raw
if isinstance(item, str) and str(item).strip()
]
return files or None
@property
def enabled_skills(self) -> List[str]:
return _normalized_string_list(self.values.get("enabled_skills"))
@property
def disabled_skills(self) -> List[str]:
return _normalized_string_list(self.values.get("disabled_skills"))
@property
def active_tool_groups(self) -> Optional[List[str]]:
groups = _normalized_string_list(self.values.get("active_tool_groups"))
return groups or None
@property
def disabled_tool_groups(self) -> List[str]:
return _normalized_string_list(self.values.get("disabled_tool_groups"))
def get(self, key: str, default: Any = None) -> Any:
return self.values.get(key, default)
def _normalized_string_list(raw: Any) -> List[str]:
if not isinstance(raw, list):
return []
seen: List[str] = []
for item in raw:
if not isinstance(item, str):
continue
value = item.strip()
if value and value not in seen:
seen.append(value)
return seen
def load_agent_workspace_config(path: Path) -> AgentWorkspaceConfig:
"""Load agent.yaml if present."""
if not path.exists() or not path.is_file():
return AgentWorkspaceConfig()
raw = path.read_text(encoding="utf-8").strip()
if not raw:
return AgentWorkspaceConfig()
parsed = yaml.safe_load(raw) or {}
if not isinstance(parsed, dict):
parsed = {}
return AgentWorkspaceConfig(values=parsed)

View File

@@ -4,6 +4,7 @@
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
from .agent_workspace import load_agent_workspace_config
from backend.config.bootstrap_config import get_bootstrap_config_for_run from backend.config.bootstrap_config import get_bootstrap_config_for_run
from .prompt_loader import PromptLoader from .prompt_loader import PromptLoader
from .skills_manager import SkillsManager from .skills_manager import SkillsManager
@@ -23,6 +24,26 @@ def _append_section(parts: list[str], title: str, content: str) -> None:
parts.append(f"## {title}\n{content}") parts.append(f"## {title}\n{content}")
def _build_skill_metadata_summary(skills_manager: SkillsManager, config_name: str, agent_id: str) -> str:
"""Create a compact summary of active skills for prompt routing."""
metadata_items = skills_manager.list_active_skill_metadata(config_name, agent_id)
if not metadata_items:
return ""
lines: list[str] = [
"You can use the following active skills. Prefer the most relevant one, then read its SKILL.md if needed for detailed workflow:",
]
for item in metadata_items:
parts = [f"- `{item.skill_name}`"]
if item.description:
parts.append(item.description)
if item.version:
parts.append(f"version: {item.version}")
parts.append(f"path: {item.path}")
lines.append(" | ".join(parts))
return "\n".join(lines)
def build_agent_system_prompt( def build_agent_system_prompt(
agent_id: str, agent_id: str,
config_name: str, config_name: str,
@@ -31,6 +52,13 @@ def build_agent_system_prompt(
) -> str: ) -> str:
"""Build the final system prompt for an agent.""" """Build the final system prompt for an agent."""
sections: list[str] = [] sections: list[str] = []
canonical_agent_id = (
"portfolio_manager"
if "portfolio" in agent_id
else "risk_manager"
if "risk" in agent_id and not analyst_type
else agent_id
)
if analyst_type: if analyst_type:
personas_config = _prompt_loader.load_yaml_config( personas_config = _prompt_loader.load_yaml_config(
@@ -56,11 +84,21 @@ def build_agent_system_prompt(
"portfolio_manager", "portfolio_manager",
"system", "system",
) )
elif canonical_agent_id == "portfolio_manager":
base_prompt = _prompt_loader.load_prompt(
"portfolio_manager",
"system",
)
elif agent_id == "risk_manager": elif agent_id == "risk_manager":
base_prompt = _prompt_loader.load_prompt( base_prompt = _prompt_loader.load_prompt(
"risk_manager", "risk_manager",
"system", "system",
) )
elif canonical_agent_id == "risk_manager":
base_prompt = _prompt_loader.load_prompt(
"risk_manager",
"system",
)
else: else:
raise ValueError(f"Unsupported agent prompt build for: {agent_id}") raise ValueError(f"Unsupported agent prompt build for: {agent_id}")
@@ -69,6 +107,7 @@ def build_agent_system_prompt(
skills_manager = SkillsManager() skills_manager = SkillsManager()
asset_dir = skills_manager.get_agent_asset_dir(config_name, agent_id) asset_dir = skills_manager.get_agent_asset_dir(config_name, agent_id)
asset_dir.mkdir(parents=True, exist_ok=True) asset_dir.mkdir(parents=True, exist_ok=True)
agent_config = load_agent_workspace_config(asset_dir / "agent.yaml")
bootstrap_config = get_bootstrap_config_for_run( bootstrap_config = get_bootstrap_config_for_run(
skills_manager.project_root, skills_manager.project_root,
config_name, config_name,
@@ -80,26 +119,62 @@ def build_agent_system_prompt(
bootstrap_config.prompt_body, bootstrap_config.prompt_body,
) )
_append_section( prompt_files = agent_config.prompt_files or [
sections, "SOUL.md",
"Role", "PROFILE.md",
_read_file_if_exists(asset_dir / "ROLE.md"), "AGENTS.md",
) "POLICY.md",
_append_section( "MEMORY.md",
sections, ]
"Style", included_files = set(prompt_files)
_read_file_if_exists(asset_dir / "STYLE.md"), title_map = {
) "SOUL.md": "Soul",
_append_section( "PROFILE.md": "Profile",
sections, "AGENTS.md": "Agent Guide",
"Policy", "POLICY.md": "Policy",
_read_file_if_exists(asset_dir / "POLICY.md"), "MEMORY.md": "Memory",
) "HEARTBEAT.md": "Heartbeat",
"ROLE.md": "Role",
"STYLE.md": "Style",
}
for filename in prompt_files:
_append_section(
sections,
title_map.get(filename, filename),
_read_file_if_exists(asset_dir / filename),
)
if "ROLE.md" not in included_files:
_append_section(
sections,
"Role",
_read_file_if_exists(asset_dir / "ROLE.md"),
)
if "STYLE.md" not in included_files:
_append_section(
sections,
"Style",
_read_file_if_exists(asset_dir / "STYLE.md"),
)
if "POLICY.md" not in included_files:
_append_section(
sections,
"Policy",
_read_file_if_exists(asset_dir / "POLICY.md"),
)
skill_prompt = toolkit.get_agent_skill_prompt() skill_prompt = toolkit.get_agent_skill_prompt()
if skill_prompt: if skill_prompt:
_append_section(sections, "Skills", str(skill_prompt)) _append_section(sections, "Skills", str(skill_prompt))
metadata_summary = _build_skill_metadata_summary(
skills_manager=skills_manager,
config_name=config_name,
agent_id=agent_id,
)
if metadata_summary:
_append_section(sections, "Active Skill Catalog", metadata_summary)
activated_notes = toolkit.get_activated_notes() activated_notes = toolkit.get_activated_notes()
if activated_notes: if activated_notes:
_append_section(sections, "Tool Usage Notes", str(activated_notes)) _append_section(sections, "Tool Usage Notes", str(activated_notes))

View File

@@ -0,0 +1,79 @@
# -*- coding: utf-8 -*-
"""Skill metadata parsing helpers for SKILL.md files."""
from dataclasses import dataclass, field
from pathlib import Path
from typing import List
import yaml
@dataclass(frozen=True)
class SkillMetadata:
"""Parsed metadata for a skill package."""
skill_name: str
path: Path
source: str
name: str
description: str
version: str = ""
tools: List[str] = field(default_factory=list)
def parse_skill_metadata(skill_dir: Path, source: str) -> SkillMetadata:
"""Parse SKILL.md frontmatter with a forgiving schema."""
skill_name = skill_dir.name
skill_file = skill_dir / "SKILL.md"
if not skill_file.exists():
return SkillMetadata(
skill_name=skill_name,
path=skill_dir,
source=source,
name=skill_name,
description="",
)
raw = skill_file.read_text(encoding="utf-8").strip()
frontmatter = {}
body = raw
if raw.startswith("---"):
parts = raw.split("---", 2)
if len(parts) >= 3:
try:
frontmatter = yaml.safe_load(parts[1].strip()) or {}
except yaml.YAMLError:
frontmatter = {}
body = parts[2].strip()
if not isinstance(frontmatter, dict):
frontmatter = {}
description = str(frontmatter.get("description") or "").strip()
if not description and body:
description = body.splitlines()[0].strip().lstrip("#").strip()
return SkillMetadata(
skill_name=skill_name,
path=skill_dir,
source=source,
name=str(frontmatter.get("name") or skill_name).strip() or skill_name,
description=description,
version=str(frontmatter.get("version") or "").strip(),
tools=_string_list(frontmatter.get("tools")),
)
def _string_list(value) -> List[str]:
if isinstance(value, str):
item = value.strip()
return [item] if item else []
if not isinstance(value, list):
return []
seen: List[str] = []
for item in value:
if not isinstance(item, str):
continue
normalized = item.strip()
if normalized and normalized not in seen:
seen.append(normalized)
return seen

View File

@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""Manage builtin/customized/active skill directories for each run.""" """Manage agent-installed and run-active skill directories for each run."""
from pathlib import Path from pathlib import Path
import shutil import shutil
@@ -7,6 +7,8 @@ from typing import Dict, Iterable, List
import yaml import yaml
from backend.agents.agent_workspace import load_agent_workspace_config
from backend.agents.skill_metadata import SkillMetadata, parse_skill_metadata
from backend.config.bootstrap_config import get_bootstrap_config_for_run from backend.config.bootstrap_config import get_bootstrap_config_for_run
@@ -26,12 +28,283 @@ class SkillsManager:
def get_active_root(self, config_name: str) -> Path: def get_active_root(self, config_name: str) -> Path:
return self.runs_root / config_name / "skills" / "active" return self.runs_root / config_name / "skills" / "active"
def get_agent_skills_root(self, config_name: str, agent_id: str) -> Path:
return self.get_agent_asset_dir(config_name, agent_id) / "skills"
def get_agent_active_root(self, config_name: str, agent_id: str) -> Path:
return self.get_agent_skills_root(config_name, agent_id) / "active"
def get_agent_installed_root(self, config_name: str, agent_id: str) -> Path:
return self.get_agent_skills_root(config_name, agent_id) / "installed"
def get_agent_disabled_root(self, config_name: str, agent_id: str) -> Path:
return self.get_agent_skills_root(config_name, agent_id) / "disabled"
def get_agent_local_root(self, config_name: str, agent_id: str) -> Path:
return self.get_agent_skills_root(config_name, agent_id) / "local"
def get_activation_manifest_path(self, config_name: str) -> Path: def get_activation_manifest_path(self, config_name: str) -> Path:
return self.runs_root / config_name / "skills" / "activation.yaml" return self.runs_root / config_name / "skills" / "activation.yaml"
def get_agent_asset_dir(self, config_name: str, agent_id: str) -> Path: def get_agent_asset_dir(self, config_name: str, agent_id: str) -> Path:
return self.runs_root / config_name / "agents" / agent_id return self.runs_root / config_name / "agents" / agent_id
def list_skill_catalog(self) -> List[SkillMetadata]:
"""Return builtin/customized skills with parsed metadata."""
catalog: Dict[str, SkillMetadata] = {}
for source, root in (
("builtin", self.builtin_root),
("customized", self.customized_root),
):
if not root.exists():
continue
for skill_dir in sorted(root.iterdir(), key=lambda item: item.name):
if not skill_dir.is_dir():
continue
if not (skill_dir / "SKILL.md").exists():
continue
metadata = parse_skill_metadata(skill_dir, source=source)
catalog[metadata.skill_name] = metadata
return sorted(catalog.values(), key=lambda item: item.skill_name)
def list_agent_skill_catalog(
self,
config_name: str,
agent_id: str,
) -> List[SkillMetadata]:
"""Return shared plus agent-local skills for one agent."""
catalog = {
item.skill_name: item
for item in self.list_skill_catalog()
}
for item in self.list_agent_local_skills(config_name, agent_id):
catalog[item.skill_name] = item
return sorted(catalog.values(), key=lambda item: item.skill_name)
def list_active_skill_metadata(
self,
config_name: str,
agent_id: str,
) -> List[SkillMetadata]:
"""Return metadata for active skills synced for one agent."""
active_root = self.get_agent_active_root(config_name, agent_id)
if not active_root.exists():
return []
items: List[SkillMetadata] = []
for skill_dir in sorted(active_root.iterdir(), key=lambda item: item.name):
if not skill_dir.is_dir():
continue
if not (skill_dir / "SKILL.md").exists():
continue
items.append(parse_skill_metadata(skill_dir, source="active"))
return items
def list_agent_local_skills(
self,
config_name: str,
agent_id: str,
) -> List[SkillMetadata]:
"""Return metadata for agent-private local skills."""
local_root = self.get_agent_local_root(config_name, agent_id)
if not local_root.exists():
return []
items: List[SkillMetadata] = []
for skill_dir in sorted(local_root.iterdir(), key=lambda item: item.name):
if not skill_dir.is_dir():
continue
if not (skill_dir / "SKILL.md").exists():
continue
items.append(parse_skill_metadata(skill_dir, source="local"))
return items
def load_skill_document(self, skill_name: str) -> Dict[str, object]:
"""Return skill metadata plus markdown body for one skill."""
source_dir = self._resolve_source_dir(skill_name)
return self._load_skill_document_from_dir(
source_dir,
source="customized" if source_dir.parent == self.customized_root else "builtin",
)
def load_agent_skill_document(
self,
config_name: str,
agent_id: str,
skill_name: str,
) -> Dict[str, object]:
"""Return skill metadata plus markdown body for one agent-visible skill."""
source_dir = self._resolve_agent_skill_source_dir(
config_name=config_name,
agent_id=agent_id,
skill_name=skill_name,
)
source = "local"
if source_dir.parent == self.customized_root:
source = "customized"
elif source_dir.parent == self.builtin_root:
source = "builtin"
elif source_dir.parent == self.get_agent_installed_root(config_name, agent_id):
source = "installed"
return self._load_skill_document_from_dir(source_dir, source=source)
def create_agent_local_skill(
self,
config_name: str,
agent_id: str,
skill_name: str,
) -> Path:
"""Create a new local skill directory with a default SKILL.md."""
normalized = _normalize_skill_name(skill_name)
if not normalized:
raise ValueError("Skill name is required.")
local_root = self.get_agent_local_root(config_name, agent_id)
local_root.mkdir(parents=True, exist_ok=True)
skill_dir = local_root / normalized
if skill_dir.exists():
raise FileExistsError(f"Local skill already exists: {normalized}")
skill_dir.mkdir(parents=True, exist_ok=False)
(skill_dir / "SKILL.md").write_text(
"---\n"
f"name: {normalized}\n"
"description: 当用户提出与该本地技能相关的专门任务时,应使用此技能。\n"
"version: 1.0.0\n"
"---\n\n"
f"# {normalized}\n\n"
"在这里描述该交易员的专有分析流程、判断框架和可复用步骤。\n",
encoding="utf-8",
)
return skill_dir
def update_agent_local_skill(
self,
config_name: str,
agent_id: str,
skill_name: str,
content: str,
) -> Path:
"""Overwrite one agent-local SKILL.md."""
normalized = _normalize_skill_name(skill_name)
if not normalized:
raise ValueError("Skill name is required.")
skill_dir = self.get_agent_local_root(config_name, agent_id) / normalized
if not skill_dir.exists():
raise FileNotFoundError(f"Unknown local skill: {normalized}")
(skill_dir / "SKILL.md").write_text(content, encoding="utf-8")
return skill_dir
def delete_agent_local_skill(
self,
config_name: str,
agent_id: str,
skill_name: str,
) -> None:
"""Delete one agent-local skill directory."""
normalized = _normalize_skill_name(skill_name)
if not normalized:
raise ValueError("Skill name is required.")
skill_dir = self.get_agent_local_root(config_name, agent_id) / normalized
if not skill_dir.exists():
raise FileNotFoundError(f"Unknown local skill: {normalized}")
shutil.rmtree(skill_dir)
def _load_skill_document_from_dir(
self,
source_dir: Path,
*,
source: str,
) -> Dict[str, object]:
"""Return metadata plus markdown body for one resolved skill directory."""
metadata = parse_skill_metadata(
source_dir,
source=source,
)
skill_file = source_dir / "SKILL.md"
raw = skill_file.read_text(encoding="utf-8").strip() if skill_file.exists() else ""
body = raw
if raw.startswith("---"):
parts = raw.split("---", 2)
if len(parts) >= 3:
body = parts[2].strip()
return {
"skill_name": metadata.skill_name,
"name": metadata.name,
"description": metadata.description,
"version": metadata.version,
"tools": metadata.tools,
"source": metadata.source,
"content": body,
}
def update_agent_skill_overrides(
self,
config_name: str,
agent_id: str,
*,
enable: Iterable[str] | None = None,
disable: Iterable[str] | None = None,
) -> Dict[str, List[str]]:
"""Persist per-agent enabled/disabled skill overrides in agent.yaml."""
asset_dir = self.get_agent_asset_dir(config_name, agent_id)
asset_dir.mkdir(parents=True, exist_ok=True)
config_path = asset_dir / "agent.yaml"
current = load_agent_workspace_config(config_path)
values = dict(current.values)
enabled = _dedupe_preserve_order(current.enabled_skills)
disabled_set = set(current.disabled_skills)
for skill_name in enable or []:
if skill_name not in enabled:
enabled.append(skill_name)
disabled_set.discard(skill_name)
for skill_name in disable or []:
disabled_set.add(skill_name)
enabled = [item for item in enabled if item != skill_name]
values["enabled_skills"] = enabled
values["disabled_skills"] = sorted(disabled_set)
config_path.write_text(
yaml.safe_dump(values, allow_unicode=True, sort_keys=False),
encoding="utf-8",
)
return {
"enabled_skills": enabled,
"disabled_skills": sorted(disabled_set),
}
def forget_agent_skill_overrides(
self,
config_name: str,
agent_id: str,
skill_names: Iterable[str],
) -> Dict[str, List[str]]:
"""Remove skills from both enabled/disabled overrides in agent.yaml."""
asset_dir = self.get_agent_asset_dir(config_name, agent_id)
asset_dir.mkdir(parents=True, exist_ok=True)
config_path = asset_dir / "agent.yaml"
current = load_agent_workspace_config(config_path)
values = dict(current.values)
removed = set(skill_names)
enabled = [item for item in current.enabled_skills if item not in removed]
disabled = [item for item in current.disabled_skills if item not in removed]
values["enabled_skills"] = enabled
values["disabled_skills"] = disabled
config_path.write_text(
yaml.safe_dump(values, allow_unicode=True, sort_keys=False),
encoding="utf-8",
)
return {
"enabled_skills": enabled,
"disabled_skills": disabled,
}
def ensure_activation_manifest(self, config_name: str) -> Path: def ensure_activation_manifest(self, config_name: str) -> Path:
manifest_path = self.get_activation_manifest_path(config_name) manifest_path = self.get_activation_manifest_path(config_name)
manifest_path.parent.mkdir(parents=True, exist_ok=True) manifest_path.parent.mkdir(parents=True, exist_ok=True)
@@ -62,6 +335,34 @@ class SkillsManager:
raise FileNotFoundError(f"Unknown skill: {skill_name}") raise FileNotFoundError(f"Unknown skill: {skill_name}")
def _resolve_agent_skill_source_dir(
self,
config_name: str,
agent_id: str,
skill_name: str,
) -> Path:
"""Resolve one skill from the agent-local workspace or shared registry."""
for root in (
self.get_agent_local_root(config_name, agent_id),
self.get_agent_installed_root(config_name, agent_id),
):
candidate = root / skill_name
if candidate.exists() and (candidate / "SKILL.md").exists():
return candidate
return self._resolve_source_dir(skill_name)
def _skill_exists_for_agent(
self,
config_name: str,
agent_id: str,
skill_name: str,
) -> bool:
try:
self._resolve_agent_skill_source_dir(config_name, agent_id, skill_name)
except FileNotFoundError:
return False
return True
def _persist_runtime_edits( def _persist_runtime_edits(
self, self,
config_name: str, config_name: str,
@@ -125,6 +426,13 @@ class SkillsManager:
bootstrap = get_bootstrap_config_for_run(self.project_root, config_name) bootstrap = get_bootstrap_config_for_run(self.project_root, config_name)
override = bootstrap.agent_override(agent_id) override = bootstrap.agent_override(agent_id)
skills = list(override.get("skills", list(default_skills))) skills = list(override.get("skills", list(default_skills)))
agent_config = load_agent_workspace_config(
self.get_agent_asset_dir(config_name, agent_id) / "agent.yaml",
)
for skill_name in agent_config.enabled_skills:
if skill_name not in skills:
skills.append(skill_name)
manifest = self.load_activation_manifest(config_name) manifest = self.load_activation_manifest(config_name)
for skill_name in manifest.get("global_enabled_skills", []): for skill_name in manifest.get("global_enabled_skills", []):
@@ -139,51 +447,61 @@ class SkillsManager:
disabled.update( disabled.update(
manifest.get("agent_disabled_skills", {}).get(agent_id, []), manifest.get("agent_disabled_skills", {}).get(agent_id, []),
) )
disabled.update(agent_config.disabled_skills)
return [skill for skill in skills if skill not in disabled] for item in self.list_agent_local_skills(config_name, agent_id):
if item.skill_name not in skills:
skills.append(item.skill_name)
def sync_active_skills( return [
skill
for skill in skills
if skill not in disabled
and self._skill_exists_for_agent(config_name, agent_id, skill)
]
def sync_skill_dirs(
self, self,
config_name: str, target_root: Path,
skill_names: Iterable[str], skill_sources: Dict[str, Path],
) -> List[Path]: ) -> List[Path]:
"""Sync selected skills into the run workspace and return their paths.""" """Sync selected skill directories into one target root."""
active_root = self.get_active_root(config_name) target_root.mkdir(parents=True, exist_ok=True)
active_root.mkdir(parents=True, exist_ok=True)
synced_paths: List[Path] = [] synced_paths: List[Path] = []
wanted = set(skill_names) wanted = set(skill_sources)
for existing in active_root.iterdir(): for existing in target_root.iterdir():
if existing.is_dir() and existing.name not in wanted: if existing.is_dir() and existing.name not in wanted:
self._persist_runtime_edits(
config_name=config_name,
skill_name=existing.name,
active_dir=existing,
)
shutil.rmtree(existing) shutil.rmtree(existing)
for skill_name in skill_names: for skill_name, source_dir in skill_sources.items():
source_dir = self._resolve_source_dir(skill_name) target_dir = target_root / skill_name
target_dir = active_root / skill_name
if target_dir.exists(): if target_dir.exists():
self._persist_runtime_edits(
config_name=config_name,
skill_name=skill_name,
active_dir=target_dir,
)
shutil.rmtree(target_dir) shutil.rmtree(target_dir)
shutil.copytree(source_dir, target_dir) shutil.copytree(source_dir, target_dir)
synced_paths.append(target_dir) synced_paths.append(target_dir)
return synced_paths return synced_paths
def sync_active_skills(
self,
target_root: Path,
skill_names: Iterable[str],
) -> List[Path]:
"""Sync selected shared skills into one active directory."""
skill_sources = {
skill_name: self._resolve_source_dir(skill_name)
for skill_name in skill_names
}
return self.sync_skill_dirs(target_root, skill_sources)
def prepare_active_skills( def prepare_active_skills(
self, self,
config_name: str, config_name: str,
agent_defaults: Dict[str, Iterable[str]], agent_defaults: Dict[str, Iterable[str]],
) -> Dict[str, List[Path]]: ) -> Dict[str, List[Path]]:
"""Resolve all agent skills, sync the union once, and map paths per agent.""" """Resolve all agent skills into per-agent installed/active workspaces."""
resolved: Dict[str, List[str]] = {} resolved: Dict[str, List[str]] = {}
union: List[str] = [] union: List[str] = []
@@ -198,10 +516,112 @@ class SkillsManager:
if skill_name not in union: if skill_name not in union:
union.append(skill_name) union.append(skill_name)
self.sync_active_skills(config_name=config_name, skill_names=union) # Maintain the legacy union directory for compatibility/debugging.
active_root = self.get_active_root(config_name) # Agent-local skills remain private to the agent workspace.
self.sync_active_skills(
target_root=self.get_active_root(config_name),
skill_names=[
skill_name
for skill_name in union
if self._is_shared_skill(skill_name)
],
)
return { active_map: Dict[str, List[Path]] = {}
agent_id: [active_root / skill_name for skill_name in skill_names] for agent_id, skill_names in resolved.items():
for agent_id, skill_names in resolved.items() installed_sources = {
} skill_name: self._resolve_source_dir(skill_name)
for skill_name in skill_names
if (self.get_agent_local_root(config_name, agent_id) / skill_name).exists() is False
}
installed_paths = self.sync_skill_dirs(
target_root=self.get_agent_installed_root(config_name, agent_id),
skill_sources=installed_sources,
)
local_root = self.get_agent_local_root(config_name, agent_id)
local_sources = {
skill_name: local_root / skill_name
for skill_name in skill_names
if (local_root / skill_name).exists()
}
active_sources = {
path.name: path for path in installed_paths
}
active_sources.update(local_sources)
active_map[agent_id] = self.sync_skill_dirs(
target_root=self.get_agent_active_root(config_name, agent_id),
skill_sources=active_sources,
)
disabled_names = _dedupe_preserve_order(
self._resolve_disabled_skill_names(
config_name=config_name,
agent_id=agent_id,
default_skills=agent_defaults.get(agent_id, []),
),
)
disabled_sources = {
skill_name: self._resolve_agent_skill_source_dir(
config_name=config_name,
agent_id=agent_id,
skill_name=skill_name,
)
for skill_name in disabled_names
}
self.sync_skill_dirs(
target_root=self.get_agent_disabled_root(config_name, agent_id),
skill_sources=disabled_sources,
)
return active_map
def _is_shared_skill(self, skill_name: str) -> bool:
try:
self._resolve_source_dir(skill_name)
except FileNotFoundError:
return False
return True
def _resolve_disabled_skill_names(
self,
config_name: str,
agent_id: str,
default_skills: Iterable[str],
) -> List[str]:
"""Resolve explicit disabled skills for one agent."""
bootstrap = get_bootstrap_config_for_run(self.project_root, config_name)
override = bootstrap.agent_override(agent_id)
baseline = list(override.get("skills", list(default_skills)))
agent_config = load_agent_workspace_config(
self.get_agent_asset_dir(config_name, agent_id) / "agent.yaml",
)
manifest = self.load_activation_manifest(config_name)
disabled = list(manifest.get("global_disabled_skills", []))
disabled.extend(manifest.get("agent_disabled_skills", {}).get(agent_id, []))
disabled.extend(agent_config.disabled_skills)
for skill_name in baseline:
if skill_name in agent_config.disabled_skills and skill_name not in disabled:
disabled.append(skill_name)
for item in self.list_agent_local_skills(config_name, agent_id):
if item.skill_name in agent_config.disabled_skills and item.skill_name not in disabled:
disabled.append(item.skill_name)
return [
skill
for skill in disabled
if self._skill_exists_for_agent(config_name, agent_id, skill)
]
def _dedupe_preserve_order(items: Iterable[str]) -> List[str]:
result: List[str] = []
for item in items:
if item not in result:
result.append(item)
return result
def _normalize_skill_name(raw_name: str) -> str:
normalized = str(raw_name or "").strip().lower().replace(" ", "_").replace("-", "_")
allowed = [ch for ch in normalized if ch.isalnum() or ch == "_"]
return "".join(allowed).strip("_")

View File

@@ -3,6 +3,7 @@
from typing import Any, Dict, Iterable from typing import Any, Dict, Iterable
from .agent_workspace import load_agent_workspace_config
from backend.config.bootstrap_config import get_bootstrap_config_for_run from backend.config.bootstrap_config import get_bootstrap_config_for_run
import yaml import yaml
@@ -151,6 +152,9 @@ def create_agent_toolkit(
profiles = load_agent_profiles() profiles = load_agent_profiles()
profile = profiles.get(agent_id, {}) profile = profiles.get(agent_id, {})
skills_manager = SkillsManager() skills_manager = SkillsManager()
agent_config = load_agent_workspace_config(
skills_manager.get_agent_asset_dir(config_name, agent_id) / "agent.yaml",
)
bootstrap_config = get_bootstrap_config_for_run( bootstrap_config = get_bootstrap_config_for_run(
skills_manager.project_root, skills_manager.project_root,
config_name, config_name,
@@ -158,8 +162,16 @@ def create_agent_toolkit(
override = bootstrap_config.agent_override(agent_id) override = bootstrap_config.agent_override(agent_id)
active_groups = override.get( active_groups = override.get(
"active_tool_groups", "active_tool_groups",
profile.get("active_tool_groups", []), agent_config.active_tool_groups
or profile.get("active_tool_groups", []),
) )
disabled_groups = set(agent_config.disabled_tool_groups)
if disabled_groups:
active_groups = [
group_name
for group_name in active_groups
if group_name not in disabled_groups
]
toolkit = Toolkit( toolkit = Toolkit(
agent_skill_instruction=( agent_skill_instruction=(
@@ -184,7 +196,7 @@ def create_agent_toolkit(
default_skills=profile.get("skills", []), default_skills=profile.get("skills", []),
) )
active_skill_dirs = [ active_skill_dirs = [
skills_manager.get_active_root(config_name) / skill_name skills_manager.get_agent_active_root(config_name, agent_id) / skill_name
for skill_name in skill_names for skill_name in skill_names
] ]

View File

@@ -4,6 +4,8 @@
from pathlib import Path from pathlib import Path
from typing import Dict, Iterable, Optional from typing import Dict, Iterable, Optional
import yaml
from .skills_manager import SkillsManager from .skills_manager import SkillsManager
@@ -59,6 +61,10 @@ class WorkspaceManager:
agent_id, agent_id,
) )
asset_dir.mkdir(parents=True, exist_ok=True) asset_dir.mkdir(parents=True, exist_ok=True)
(asset_dir / "skills" / "installed").mkdir(parents=True, exist_ok=True)
(asset_dir / "skills" / "active").mkdir(parents=True, exist_ok=True)
(asset_dir / "skills" / "disabled").mkdir(parents=True, exist_ok=True)
(asset_dir / "skills" / "local").mkdir(parents=True, exist_ok=True)
self._ensure_file( self._ensure_file(
asset_dir / "ROLE.md", asset_dir / "ROLE.md",
@@ -81,6 +87,35 @@ class WorkspaceManager:
f"{policy_seed}".strip() f"{policy_seed}".strip()
+ "\n", + "\n",
) )
self._ensure_file(
asset_dir / "SOUL.md",
"# Soul\n\n"
"Describe the agent's temperament, reasoning posture, and voice.\n\n",
)
self._ensure_file(
asset_dir / "PROFILE.md",
"# Profile\n\n"
"Track this agent's long-lived investment style, preferences, and strengths.\n\n",
)
self._ensure_file(
asset_dir / "AGENTS.md",
"# Agent Guide\n\n"
"Document how this agent should work, collaborate, and choose tools or skills.\n\n",
)
self._ensure_file(
asset_dir / "MEMORY.md",
"# Memory\n\n"
"Store durable lessons, heuristics, and reminders for this agent.\n\n",
)
self._ensure_file(
asset_dir / "HEARTBEAT.md",
"# Heartbeat\n\n"
"Optional checklist for periodic review or self-reflection.\n\n",
)
self._ensure_agent_yaml(
asset_dir / "agent.yaml",
agent_id=agent_id,
)
return asset_dir return asset_dir
def initialize_default_assets( def initialize_default_assets(
@@ -138,3 +173,27 @@ class WorkspaceManager:
def _ensure_file(path: Path, content: str) -> None: def _ensure_file(path: Path, content: str) -> None:
if not path.exists(): if not path.exists():
path.write_text(content, encoding="utf-8") path.write_text(content, encoding="utf-8")
@staticmethod
def _ensure_agent_yaml(path: Path, agent_id: str) -> None:
if path.exists():
return
payload = {
"agent_id": agent_id,
"prompt_files": [
"SOUL.md",
"PROFILE.md",
"AGENTS.md",
"POLICY.md",
"MEMORY.md",
],
"enabled_skills": [],
"disabled_skills": [],
"active_tool_groups": [],
"disabled_tool_groups": [],
}
path.write_text(
yaml.safe_dump(payload, allow_unicode=True, sort_keys=False),
encoding="utf-8",
)

View File

@@ -24,7 +24,9 @@ from rich.prompt import Confirm
from rich.table import Table from rich.table import Table
from dotenv import load_dotenv from dotenv import load_dotenv
from backend.agents.agent_workspace import load_agent_workspace_config
from backend.agents.prompt_loader import PromptLoader from backend.agents.prompt_loader import PromptLoader
from backend.agents.skills_manager import SkillsManager
from backend.agents.workspace_manager import WorkspaceManager from backend.agents.workspace_manager import WorkspaceManager
from backend.data.market_ingest import ingest_symbols from backend.data.market_ingest import ingest_symbols
from backend.data.market_store import MarketStore from backend.data.market_store import MarketStore
@@ -38,12 +40,21 @@ app = typer.Typer(
) )
ingest_app = typer.Typer(help="Ingest Polygon market data into the research warehouse.") ingest_app = typer.Typer(help="Ingest Polygon market data into the research warehouse.")
app.add_typer(ingest_app, name="ingest") app.add_typer(ingest_app, name="ingest")
skills_app = typer.Typer(help="Inspect and manage per-agent skills.")
app.add_typer(skills_app, name="skills")
console = Console() console = Console()
_prompt_loader = PromptLoader() _prompt_loader = PromptLoader()
load_dotenv() load_dotenv()
def _normalize_typer_value(value, default):
"""Allow CLI command functions to be called directly in tests/internal code."""
if hasattr(value, "default"):
return value.default
return default if value is None else value
def get_project_root() -> Path: def get_project_root() -> Path:
"""Get the project root directory.""" """Get the project root directory."""
# Assuming cli.py is in backend/ # Assuming cli.py is in backend/
@@ -213,6 +224,19 @@ def initialize_workspace(config_name: str) -> Path:
return workspace_manager.get_run_dir(config_name) return workspace_manager.get_run_dir(config_name)
def _require_agent_asset_dir(config_name: str, agent_id: str) -> Path:
manager = WorkspaceManager(project_root=get_project_root())
manager.initialize_default_assets(
config_name=config_name,
agent_ids=[agent_id],
analyst_personas=_prompt_loader.load_yaml_config(
"analyst",
"personas",
),
)
return manager.skills_manager.get_agent_asset_dir(config_name, agent_id)
def _resolve_symbols(raw_tickers: Optional[str], config_name: Optional[str] = None) -> list[str]: def _resolve_symbols(raw_tickers: Optional[str], config_name: Optional[str] = None) -> list[str]:
"""Resolve symbols from explicit input or runtime bootstrap config.""" """Resolve symbols from explicit input or runtime bootstrap config."""
if raw_tickers and raw_tickers.strip(): if raw_tickers and raw_tickers.strip():
@@ -622,6 +646,137 @@ def ingest_report(
console.print(table) console.print(table)
@skills_app.command("list")
def skills_list(
config_name: str = typer.Option(
"default",
"--config-name",
"-c",
help="Run config name.",
),
agent_id: Optional[str] = typer.Option(
None,
"--agent-id",
"-a",
help="Optional agent id to show resolved status for.",
),
):
"""List available skills and optional agent-level enablement state."""
project_root = get_project_root()
skills_manager = SkillsManager(project_root=project_root)
catalog = (
skills_manager.list_agent_skill_catalog(config_name, agent_id)
if agent_id
else skills_manager.list_skill_catalog()
)
if not catalog:
console.print("[yellow]No skills found[/yellow]")
raise typer.Exit(0)
agent_config = None
resolved_skills = set()
if agent_id:
asset_dir = _require_agent_asset_dir(config_name, agent_id)
agent_config = load_agent_workspace_config(asset_dir / "agent.yaml")
resolved_skills = set(
skills_manager.resolve_agent_skill_names(
config_name=config_name,
agent_id=agent_id,
default_skills=[],
),
)
table = Table(title="Skill Catalog")
table.add_column("Skill", style="cyan")
table.add_column("Source")
table.add_column("Description")
if agent_id:
table.add_column("Status")
enabled = set(agent_config.enabled_skills) if agent_config else set()
disabled = set(agent_config.disabled_skills) if agent_config else set()
for skill in catalog:
row = [
skill.skill_name,
skill.source,
skill.description or "-",
]
if agent_id:
if skill.skill_name in disabled:
status = "disabled"
elif skill.skill_name in enabled:
status = "enabled"
elif skill.skill_name in resolved_skills:
status = "active"
else:
status = "-"
row.append(status)
table.add_row(*row)
console.print(table)
@skills_app.command("enable")
def skills_enable(
agent_id: str = typer.Option(..., "--agent-id", "-a", help="Agent id."),
skill: str = typer.Option(..., "--skill", "-s", help="Skill name."),
config_name: str = typer.Option(
"default",
"--config-name",
"-c",
help="Run config name.",
),
):
"""Enable a skill for one agent in agent.yaml."""
asset_dir = _require_agent_asset_dir(config_name, agent_id)
skills_manager = SkillsManager(project_root=get_project_root())
catalog = {
item.skill_name
for item in skills_manager.list_agent_skill_catalog(config_name, agent_id)
}
if skill not in catalog:
console.print(f"[red]Unknown skill: {skill}[/red]")
raise typer.Exit(1)
result = skills_manager.update_agent_skill_overrides(
config_name=config_name,
agent_id=agent_id,
enable=[skill],
)
console.print(
f"[green]Enabled[/green] `{skill}` for `{agent_id}` "
f"([{asset_dir / 'agent.yaml'}])",
)
console.print(f"Enabled skills: {', '.join(result['enabled_skills']) or '-'}")
console.print(f"Disabled skills: {', '.join(result['disabled_skills']) or '-'}")
@skills_app.command("disable")
def skills_disable(
agent_id: str = typer.Option(..., "--agent-id", "-a", help="Agent id."),
skill: str = typer.Option(..., "--skill", "-s", help="Skill name."),
config_name: str = typer.Option(
"default",
"--config-name",
"-c",
help="Run config name.",
),
):
"""Disable a skill for one agent in agent.yaml."""
asset_dir = _require_agent_asset_dir(config_name, agent_id)
skills_manager = SkillsManager(project_root=get_project_root())
result = skills_manager.update_agent_skill_overrides(
config_name=config_name,
agent_id=agent_id,
disable=[skill],
)
console.print(
f"[yellow]Disabled[/yellow] `{skill}` for `{agent_id}` "
f"([{asset_dir / 'agent.yaml'}])",
)
console.print(f"Enabled skills: {', '.join(result['enabled_skills']) or '-'}")
console.print(f"Disabled skills: {', '.join(result['disabled_skills']) or '-'}")
@app.command() @app.command()
def backtest( def backtest(
start: Optional[str] = typer.Option( start: Optional[str] = typer.Option(
@@ -684,6 +839,7 @@ def backtest(
border_style="cyan", border_style="cyan",
), ),
) )
poll_interval = int(_normalize_typer_value(poll_interval, 10))
# Validate dates - required for backtest # Validate dates - required for backtest
if not start or not end: if not start or not end:
@@ -801,12 +957,22 @@ def live(
"-p", "-p",
help="WebSocket server port", help="WebSocket server port",
), ),
schedule_mode: str = typer.Option(
"daily",
"--schedule-mode",
help="Scheduler mode: 'daily' or 'intraday'",
),
trigger_time: str = typer.Option( trigger_time: str = typer.Option(
"now", "now",
"--trigger-time", "--trigger-time",
"-t", "-t",
help="Trigger time in LOCAL timezone (HH:MM), or 'now' to run immediately", help="Trigger time in LOCAL timezone (HH:MM), or 'now' to run immediately",
), ),
interval_minutes: int = typer.Option(
60,
"--interval-minutes",
help="When schedule-mode=intraday, run every N minutes",
),
poll_interval: int = typer.Option( poll_interval: int = typer.Option(
10, 10,
"--poll-interval", "--poll-interval",
@@ -830,9 +996,12 @@ def live(
evotraders live # Run immediately (default) evotraders live # Run immediately (default)
evotraders live --mock # Mock mode evotraders live --mock # Mock mode
evotraders live -t 22:30 # Run at 22:30 local time daily evotraders live -t 22:30 # Run at 22:30 local time daily
evotraders live --schedule-mode intraday --interval-minutes 60
evotraders live --trigger-time now # Run immediately evotraders live --trigger-time now # Run immediately
evotraders live --clean # Clear historical data before starting evotraders live --clean # Clear historical data before starting
""" """
schedule_mode = str(_normalize_typer_value(schedule_mode, "daily"))
interval_minutes = int(_normalize_typer_value(interval_minutes, 60))
mode_name = "MOCK" if mock else "LIVE" mode_name = "MOCK" if mock else "LIVE"
console.print( console.print(
Panel.fit( Panel.fit(
@@ -864,6 +1033,16 @@ def live(
# Handle historical data cleanup # Handle historical data cleanup
handle_history_cleanup(config_name, auto_clean=clean) handle_history_cleanup(config_name, auto_clean=clean)
if schedule_mode not in {"daily", "intraday"}:
console.print(
f"[red]Error: unsupported schedule mode '{schedule_mode}'[/red]",
)
raise typer.Exit(1)
if interval_minutes <= 0:
console.print("[red]Error: --interval-minutes must be > 0[/red]")
raise typer.Exit(1)
# Convert local time to NYSE time # Convert local time to NYSE time
nyse_tz = ZoneInfo("America/New_York") nyse_tz = ZoneInfo("America/New_York")
local_tz = datetime.now().astimezone().tzinfo local_tz = datetime.now().astimezone().tzinfo
@@ -871,7 +1050,9 @@ def live(
nyse_now = datetime.now(nyse_tz) nyse_now = datetime.now(nyse_tz)
# Convert trigger time from local to NYSE # Convert trigger time from local to NYSE
if trigger_time.lower() == "now": if schedule_mode == "intraday":
nyse_trigger_time = "now"
elif trigger_time.lower() == "now":
nyse_trigger_time = "now" nyse_trigger_time = "now"
else: else:
local_trigger = datetime.strptime(trigger_time, "%H:%M") local_trigger = datetime.strptime(trigger_time, "%H:%M")
@@ -891,7 +1072,10 @@ def live(
console.print( console.print(
f" NYSE Time: {nyse_now.strftime('%Y-%m-%d %H:%M:%S %Z')}", f" NYSE Time: {nyse_now.strftime('%Y-%m-%d %H:%M:%S %Z')}",
) )
if nyse_trigger_time == "now": console.print(f" Schedule: {schedule_mode}")
if schedule_mode == "intraday":
console.print(f" Interval: every {interval_minutes} minute(s)")
elif nyse_trigger_time == "now":
console.print(" Trigger: [green]NOW (immediate)[/green]") console.print(" Trigger: [green]NOW (immediate)[/green]")
else: else:
console.print( console.print(
@@ -951,10 +1135,14 @@ def live(
host, host,
"--port", "--port",
str(port), str(port),
"--schedule-mode",
schedule_mode,
"--poll-interval", "--poll-interval",
str(poll_interval), str(poll_interval),
"--trigger-time", "--trigger-time",
nyse_trigger_time, nyse_trigger_time,
"--interval-minutes",
str(interval_minutes),
] ]
if mock: if mock:

View File

@@ -48,6 +48,9 @@ def _resolve_runtime_config(args) -> dict:
project_root=project_root, project_root=project_root,
config_name=args.config_name, config_name=args.config_name,
enable_memory=args.enable_memory, enable_memory=args.enable_memory,
schedule_mode=args.schedule_mode,
interval_minutes=args.interval_minutes,
trigger_time=args.trigger_time,
) )
@@ -261,6 +264,7 @@ async def run_with_gateway(args):
# Create scheduler callback # Create scheduler callback
scheduler_callback = None scheduler_callback = None
trading_dates = [] trading_dates = []
live_scheduler = None
if is_backtest: if is_backtest:
backtest_scheduler = BacktestScheduler( backtest_scheduler = BacktestScheduler(
@@ -276,10 +280,11 @@ async def run_with_gateway(args):
scheduler_callback = scheduler_callback_fn scheduler_callback = scheduler_callback_fn
else: else:
# Live mode: use daily scheduler with NYSE timezone # Live mode: use daily or intraday scheduler with NYSE timezone
live_scheduler = Scheduler( live_scheduler = Scheduler(
mode="daily", mode=runtime_config["schedule_mode"],
trigger_time=args.trigger_time, trigger_time=runtime_config["trigger_time"],
interval_minutes=runtime_config["interval_minutes"],
config={"config_name": config_name}, config={"config_name": config_name},
) )
@@ -300,11 +305,15 @@ async def run_with_gateway(args):
"backtest_mode": is_backtest, "backtest_mode": is_backtest,
"tickers": tickers, "tickers": tickers,
"config_name": config_name, "config_name": config_name,
"schedule_mode": runtime_config["schedule_mode"],
"interval_minutes": runtime_config["interval_minutes"],
"trigger_time": runtime_config["trigger_time"],
"initial_cash": initial_cash, "initial_cash": initial_cash,
"margin_requirement": margin_requirement, "margin_requirement": margin_requirement,
"max_comm_cycles": runtime_config["max_comm_cycles"], "max_comm_cycles": runtime_config["max_comm_cycles"],
"enable_memory": runtime_config["enable_memory"], "enable_memory": runtime_config["enable_memory"],
}, },
scheduler=live_scheduler if not is_backtest else None,
) )
if is_backtest: if is_backtest:
@@ -325,7 +334,13 @@ def main():
parser.add_argument("--config-name", default="mock") parser.add_argument("--config-name", default="mock")
parser.add_argument("--host", default="0.0.0.0") parser.add_argument("--host", default="0.0.0.0")
parser.add_argument("--port", type=int, default=8765) parser.add_argument("--port", type=int, default=8765)
parser.add_argument(
"--schedule-mode",
choices=["daily", "intraday"],
default="daily",
)
parser.add_argument("--trigger-time", default="09:30") # NYSE market open parser.add_argument("--trigger-time", default="09:30") # NYSE market open
parser.add_argument("--interval-minutes", type=int, default=60)
parser.add_argument("--poll-interval", type=int, default=10) parser.add_argument("--poll-interval", type=int, default=10)
parser.add_argument("--start-date") parser.add_argument("--start-date")
parser.add_argument("--end-date") parser.add_argument("--end-date")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
# -*- coding: utf-8 -*-

View File

@@ -0,0 +1 @@
# -*- coding: utf-8 -*-

View File

@@ -1,21 +1,22 @@
--- ---
name: fundamental_review name: 基本面分析
description: Review a company from a fundamentals-first perspective before issuing a trading signal. description: 当用户要求“基本面分析”“看财务质量”“分析盈利能力”“判断公司质量”或“评估长期盈利韧性”时,应使用此技能。
version: 1.0.0
--- ---
# Fundamental Review # 基本面分析
Use this skill when the task requires judging business quality, balance-sheet strength, profitability, or long-term earnings durability. 当用户希望从公司质量、资产负债表强度、盈利能力或长期盈利韧性出发判断标的时,使用这个技能。
## Workflow ## 工作流程
1. Check profitability, growth, financial health, and efficiency before forming a conclusion. 1. 在形成结论前,先检查盈利能力、成长性、财务健康度和经营效率。
2. Separate durable business quality from short-term noise. 2. 区分可持续的业务质量和短期噪音。
3. State what would invalidate the thesis. 3. 明确指出会推翻当前判断的条件。
4. End with a clear signal, confidence, and the main drivers behind that signal. 4. 最终给出清晰的信号、置信度和主要驱动因素。
## Guardrails ## 约束
- Do not rely on one metric in isolation. - 不要孤立依赖单一指标。
- Call out missing data explicitly. - 缺失数据要明确指出。
- Prefer conservative conclusions when financial quality is mixed. - 当财务质量优劣混杂时,优先给出保守结论。

View File

@@ -1,21 +1,22 @@
--- ---
name: portfolio_decisioning name: 组合决策
description: Synthesize analyst inputs and risk feedback into explicit portfolio decisions. description: 当用户要求“组合决策”“给出最终仓位”“整合分析结论”“输出交易决策”或“形成组合操作方案”时,应使用此技能。
version: 1.0.0
--- ---
# Portfolio Decisioning # 组合决策
Use this skill when you are responsible for converting team analysis into final trades. 当用户需要把团队分析转化为最终交易决策时,使用这个技能。
## Workflow ## 工作流程
1. Read analyst conclusions and risk warnings before acting. 1. 行动前先阅读分析师结论和风险警示。
2. Evaluate the current portfolio, cash, and margin constraints. 2. 评估当前组合、现金和保证金约束。
3. Record one explicit decision per ticker using the decision tool. 3. 使用决策工具为每个 ticker 记录一个明确决策。
4. Summarize the portfolio-level rationale after all decisions are recorded. 4. 在全部决策记录完成后,总结组合层面的整体理由。
## Guardrails ## 约束
- Position sizing must respect capital and margin limits. - 仓位大小必须遵守资金和保证金限制。
- Prefer smaller size when analyst conviction and risk signals disagree. - 当分析师信心与风险信号不一致时,优先采用更小仓位。
- Do not leave a ticker undecided when the task expects a full slate of decisions. - 当任务要求完整决策清单时,不要让任何 ticker 处于未决状态。

View File

@@ -1,21 +1,22 @@
--- ---
name: risk_review name: 风险审查
description: Assess portfolio and market risks before final position sizing and execution. description: 当用户要求“风险审查”“看组合风险”“检查集中度”“评估波动风险”或“确认仓位风险边界”时,应使用此技能。
version: 1.0.0
--- ---
# Risk Review # 风险审查
Use this skill when you must identify concentration, volatility, leverage, and scenario risks. 当用户需要识别集中度、波动率、杠杆和情景风险时,使用这个技能。
## Workflow ## 工作流程
1. Review the proposed exposure by ticker and theme. 1. 按 ticker 和主题检查拟议敞口。
2. Identify concentration, volatility, liquidity, and leverage concerns. 2. 识别集中度、波动率、流动性和杠杆方面的风险点。
3. Rank warnings by severity. 3. 按严重程度排序风险警示。
4. Translate risk findings into concrete limits or cautions for the portfolio manager. 4. 将风险结论转化为给投资经理的具体限制或注意事项。
## Guardrails ## 约束
- Focus on actionable risk controls. - 聚焦可执行的风险控制措施。
- Quantify limits when the available data supports it. - 当数据支持时尽量量化限制。
- Distinguish fatal blockers from manageable risks. - 明确区分致命阻断项和可管理风险。

View File

@@ -1,21 +1,22 @@
--- ---
name: sentiment_review name: 情绪分析
description: Analyze news flow, market psychology, and insider behavior for catalyst-driven signals. description: 当用户要求“情绪分析”“看新闻情绪”“分析市场心理”“判断事件驱动信号”或“检查内幕行为”时,应使用此技能。
version: 1.0.0
--- ---
# Sentiment Review # 情绪分析
Use this skill when the task depends on recent catalysts, news tone, or behavioral market signals. 当用户需要基于近期催化剂、新闻语气或行为层面的市场信号做判断时,使用这个技能。
## Workflow ## 工作流程
1. Review recent news and identify the dominant narrative. 1. 回顾近期新闻并识别主导叙事。
2. Check insider activity for confirming or conflicting signals. 2. 检查内幕活动,寻找确认或冲突信号。
3. Separate durable sentiment shifts from transient noise. 3. 区分可持续的情绪变化和短暂噪音。
4. Explain how sentiment changes the near-term trade outlook. 4. 说明情绪如何改变短期交易展望。
## Guardrails ## 约束
- Do not confuse attention with conviction. - 不要把注意力误判为真实信念。
- Highlight when sentiment is strong but unsupported by fundamentals. - 当情绪很强但缺乏基本面支持时,要明确指出。
- Be explicit about catalyst timing risk. - 对催化剂时间窗口风险要说清楚。

View File

@@ -1,21 +1,22 @@
--- ---
name: technical_review name: 技术分析
description: Evaluate price action, momentum, and volatility to judge timing and market regime. description: 当用户要求“技术分析”“看走势”“判断入场时机”“分析动量”“评估波动率”或“判断市场状态”时,应使用此技能。
version: 1.0.0
--- ---
# Technical Review # 技术分析
Use this skill when the task is sensitive to entry timing, trend quality, or short-term market structure. 当用户需要从入场时机、趋势质量或短期市场结构出发判断标的时,使用这个技能。
## Workflow ## 工作流程
1. Assess trend direction and strength. 1. 评估趋势方向和强度。
2. Check momentum and mean-reversion conditions. 2. 检查动量与均值回归条件。
3. Review volatility before making aggressive recommendations. 3. 在给出激进建议前先审视波动率。
4. Convert the setup into a trading view with explicit risk awareness. 4. 将当前形态转化为带有明确风险意识的交易观点。
## Guardrails ## 约束
- Distinguish trend continuation from overshoot. - 区分趋势延续和过度透支。
- Avoid strong conviction when signals conflict. - 当信号冲突时避免给出高确定性判断。
- Treat volatility as a sizing input, not only a directional input. - 将波动率视为仓位输入,而不仅仅是方向输入。

View File

@@ -1,21 +1,31 @@
--- ---
name: valuation_review name: 估值分析
description: Estimate fair value and margin of safety using multiple valuation lenses. description: 当用户要求“估值分析”“看合理价值”“判断高估低估”“测算安全边际”或“比较多种估值方法”时,应使用此技能。
version: 1.0.0
--- ---
# Valuation Review # 估值分析
Use this skill when the task requires determining whether a stock is cheap, expensive, or fairly priced. 当用户需要判断一只股票是低估、高估还是定价合理时,使用这个技能。
## Workflow ## 工作流程
1. Use more than one valuation method when possible. 1. 条件允许时,使用不止一种估值方法。
2. Compare intrinsic value estimates with current market pricing. 2. 对比内在价值估计与当前市场价格。
3. Explain the key assumptions behind the valuation view. 3. 解释估值判断背后的关键假设。
4. State the margin of safety and what could compress or expand it. 4. 明确安全边际,以及哪些因素会压缩或扩大它。
## Guardrails ## 可复用资源
- Treat valuation as a range, not a single precise number. - `scripts/dcf_report.py`
- Call out assumption sensitivity. 用于贴现现金流估值的确定性计算和报告生成。
- Avoid high-confidence calls when inputs are sparse or unstable. - `scripts/owner_earnings_report.py`
用于 owner earnings 估值的确定性计算和报告生成。
- `scripts/multiple_valuation_report.py`
用于 EV/EBITDA 和 Residual Income 两类估值报告生成。
## 约束
- 将估值视为区间,而不是一个精确点值。
- 明确说明假设敏感性。
- 当输入稀疏或不稳定时,避免给出高置信度判断。

View File

@@ -0,0 +1 @@
# -*- coding: utf-8 -*-

View File

@@ -0,0 +1 @@
# -*- coding: utf-8 -*-

View File

@@ -0,0 +1,71 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Deterministic DCF report helpers for the valuation_review skill."""
from __future__ import annotations
import json
from typing import Iterable
def build_dcf_report(rows: Iterable[dict], current_date: str) -> str:
"""Render a DCF valuation report from normalized row inputs."""
lines = [f"=== DCF Valuation Analysis ({current_date}) ===\n"]
for row in rows:
error = row.get("error")
ticker = row["ticker"]
if error:
lines.append(f"{ticker}: {error}\n")
continue
current_fcf = float(row["current_fcf"])
growth_rate = float(row["growth_rate"])
market_cap = float(row["market_cap"])
discount_rate = float(row.get("discount_rate", 0.10))
terminal_growth = float(row.get("terminal_growth", 0.03))
num_years = int(row.get("num_years", 5))
pv_fcf = sum(
current_fcf
* (1 + growth_rate) ** year
/ (1 + discount_rate) ** year
for year in range(1, num_years + 1)
)
terminal_fcf = (
current_fcf
* (1 + growth_rate) ** num_years
* (1 + terminal_growth)
)
terminal_value = terminal_fcf / (discount_rate - terminal_growth)
pv_terminal = terminal_value / (1 + discount_rate) ** num_years
enterprise_value = pv_fcf + pv_terminal
value_gap = (enterprise_value - market_cap) / market_cap * 100
if value_gap > 20:
assessment = "SIGNIFICANTLY UNDERVALUED"
elif value_gap > 0:
assessment = "POTENTIALLY UNDERVALUED"
elif value_gap > -20:
assessment = "POTENTIALLY OVERVALUED"
else:
assessment = "SIGNIFICANTLY OVERVALUED"
lines.append(f"{ticker}:")
lines.append(f" Current FCF: ${current_fcf:,.0f}")
lines.append(f" DCF Enterprise Value: ${enterprise_value:,.0f}")
lines.append(f" Market Cap: ${market_cap:,.0f}")
lines.append(f" Value Gap: {value_gap:+.1f}% -> {assessment}")
lines.append("")
return "\n".join(lines)
def main() -> None:
"""Read normalized rows from stdin and emit a text report."""
payload = json.load(__import__("sys").stdin)
print(build_dcf_report(payload["rows"], payload["current_date"]))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,115 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Deterministic multiple-based valuation helpers for the valuation_review skill."""
from __future__ import annotations
import json
from typing import Iterable
def build_ev_ebitda_report(rows: Iterable[dict], current_date: str) -> str:
"""Render an EV/EBITDA valuation report from normalized row inputs."""
lines = [f"=== EV/EBITDA Valuation ({current_date}) ===\n"]
for row in rows:
error = row.get("error")
ticker = row["ticker"]
if error:
lines.append(f"{ticker}: {error}\n")
continue
current_multiple = float(row["current_multiple"])
median_multiple = float(row["median_multiple"])
current_ebitda = float(row["current_ebitda"])
market_cap = float(row["market_cap"])
net_debt = float(row["net_debt"])
implied_ev = median_multiple * current_ebitda
implied_equity = max(implied_ev - net_debt, 0.0)
value_gap = (
(implied_equity - market_cap) / market_cap * 100
if market_cap > 0
else 0.0
)
multiple_discount = (
(median_multiple - current_multiple) / median_multiple * 100
)
if multiple_discount > 10:
assessment = "TRADING BELOW HISTORICAL MULTIPLE"
elif multiple_discount > -10:
assessment = "NEAR HISTORICAL AVERAGE"
else:
assessment = "TRADING ABOVE HISTORICAL MULTIPLE"
lines.append(f"{ticker}:")
lines.append(f" Current EV/EBITDA: {current_multiple:.1f}x")
lines.append(f" Historical Median: {median_multiple:.1f}x")
lines.append(f" Multiple vs History: {multiple_discount:+.1f}%")
lines.append(f" Implied Equity Value: ${implied_equity:,.0f}")
lines.append(f" Value Gap: {value_gap:+.1f}% -> {assessment}")
lines.append("")
return "\n".join(lines)
def build_residual_income_report(rows: Iterable[dict], current_date: str) -> str:
"""Render a residual income valuation report from normalized row inputs."""
lines = [f"=== Residual Income Valuation ({current_date}) ===\n"]
for row in rows:
error = row.get("error")
ticker = row["ticker"]
if error:
lines.append(f"{ticker}: {error}\n")
continue
book_value = float(row["book_value"])
initial_ri = float(row["initial_ri"])
market_cap = float(row["market_cap"])
cost_of_equity = float(row.get("cost_of_equity", 0.10))
bv_growth = float(row.get("bv_growth", 0.03))
terminal_growth = float(row.get("terminal_growth", 0.03))
num_years = int(row.get("num_years", 5))
margin_of_safety = float(row.get("margin_of_safety", 0.20))
pv_ri = sum(
initial_ri * (1 + bv_growth) ** year / (1 + cost_of_equity) ** year
for year in range(1, num_years + 1)
)
terminal_ri = initial_ri * (1 + bv_growth) ** (num_years + 1)
terminal_value = terminal_ri / (cost_of_equity - terminal_growth)
pv_terminal = terminal_value / (1 + cost_of_equity) ** num_years
intrinsic_value = (book_value + pv_ri + pv_terminal) * (
1 - margin_of_safety
)
value_gap = (intrinsic_value - market_cap) / market_cap * 100
lines.append(f"{ticker}:")
lines.append(f" Book Value: ${book_value:,.0f}")
lines.append(f" Residual Income: ${initial_ri:,.0f}")
lines.append(
f" Intrinsic Value (w/ 20% MoS): ${intrinsic_value:,.0f}",
)
lines.append(f" Value Gap: {value_gap:+.1f}%")
lines.append("")
return "\n".join(lines)
def main() -> None:
"""Read normalized rows from stdin and emit one selected text report."""
payload = json.load(__import__("sys").stdin)
mode = payload["mode"]
if mode == "ev_ebitda":
print(build_ev_ebitda_report(payload["rows"], payload["current_date"]))
return
if mode == "residual_income":
print(build_residual_income_report(payload["rows"], payload["current_date"]))
return
raise ValueError(f"Unsupported mode: {mode}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,76 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Deterministic owner earnings valuation helpers for the valuation_review skill."""
from __future__ import annotations
import json
from typing import Iterable
def build_owner_earnings_report(rows: Iterable[dict], current_date: str) -> str:
"""Render an owner earnings valuation report from normalized row inputs."""
lines = [f"=== Owner Earnings Valuation ({current_date}) ===\n"]
for row in rows:
error = row.get("error")
ticker = row["ticker"]
if error:
lines.append(f"{ticker}: {error}\n")
continue
owner_earnings = float(row["owner_earnings"])
growth_rate = float(row["growth_rate"])
market_cap = float(row["market_cap"])
required_return = float(row.get("required_return", 0.15))
margin_of_safety = float(row.get("margin_of_safety", 0.25))
num_years = int(row.get("num_years", 5))
pv_earnings = sum(
owner_earnings
* (1 + growth_rate) ** year
/ (1 + required_return) ** year
for year in range(1, num_years + 1)
)
terminal_growth = min(growth_rate, 0.03)
terminal_earnings = (
owner_earnings
* (1 + growth_rate) ** num_years
* (1 + terminal_growth)
)
terminal_value = terminal_earnings / (
required_return - terminal_growth
)
pv_terminal = terminal_value / (1 + required_return) ** num_years
intrinsic_value = (pv_earnings + pv_terminal) * (1 - margin_of_safety)
value_gap = (intrinsic_value - market_cap) / market_cap * 100
if value_gap > 20:
assessment = "SIGNIFICANTLY UNDERVALUED"
elif value_gap > 0:
assessment = "POTENTIALLY UNDERVALUED"
elif value_gap > -20:
assessment = "POTENTIALLY OVERVALUED"
else:
assessment = "SIGNIFICANTLY OVERVALUED"
lines.append(f"{ticker}:")
lines.append(f" Owner Earnings: ${owner_earnings:,.0f}")
lines.append(
f" Intrinsic Value (w/ 25% MoS): ${intrinsic_value:,.0f}",
)
lines.append(f" Market Cap: ${market_cap:,.0f}")
lines.append(f" Value Gap: {value_gap:+.1f}% -> {assessment}")
lines.append("")
return "\n".join(lines)
def main() -> None:
"""Read normalized rows from stdin and emit a text report."""
payload = json.load(__import__("sys").stdin)
print(build_owner_earnings_report(payload["rows"], payload["current_date"]))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,21 @@
---
name: 组合决策
description: 整合分析师观点与风险反馈,形成明确的组合层决策。
---
# 组合决策
当你负责把团队分析转化为最终交易决策时,使用这个技能。
## 工作流程
1. 行动前先阅读分析师结论和风险警示。
2. 评估当前组合、现金和保证金约束。
3. 使用决策工具为每个 ticker 记录一个明确决策。
4. 在全部决策记录完成后,总结组合层面的整体理由。
## 约束
- 仓位大小必须遵守资金和保证金限制。
- 当分析师信心与风险信号不一致时,优先采用更小仓位。
- 当任务要求完整决策清单时,不要让任何 ticker 处于未决状态。

View File

@@ -0,0 +1,21 @@
---
name: 风险审查
description: 在最终仓位和执行前,评估组合与市场风险。
---
# 风险审查
当你需要识别集中度、波动率、杠杆和情景风险时,使用这个技能。
## 工作流程
1. 按 ticker 和主题检查拟议敞口。
2. 识别集中度、波动率、流动性和杠杆方面的风险点。
3. 按严重程度排序风险警示。
4. 将风险结论转化为给投资经理的具体限制或注意事项。
## 约束
- 聚焦可执行的风险控制措施。
- 当数据支持时尽量量化限制。
- 明确区分致命阻断项和可管理风险。

View File

@@ -0,0 +1,21 @@
---
name: 情绪分析
description: 分析新闻流、市场心理和内幕行为,识别事件驱动型信号。
---
# 情绪分析
当任务依赖近期催化剂、新闻语气或行为层面的市场信号时,使用这个技能。
## 工作流程
1. 回顾近期新闻并识别主导叙事。
2. 检查内幕活动,寻找确认或冲突信号。
3. 区分可持续的情绪变化和短暂噪音。
4. 说明情绪如何改变短期交易展望。
## 约束
- 不要把注意力误判为真实信念。
- 当情绪很强但缺乏基本面支持时,要明确指出。
- 对催化剂时间窗口风险要说清楚。

View File

@@ -0,0 +1,21 @@
---
name: 技术分析
description: 评估价格行为、动量和波动率,用于判断时机和市场状态。
---
# 技术分析
当任务对入场时机、趋势质量或短期市场结构敏感时,使用这个技能。
## 工作流程
1. 评估趋势方向和强度。
2. 检查动量与均值回归条件。
3. 在给出激进建议前先审视波动率。
4. 将当前形态转化为带有明确风险意识的交易观点。
## 约束
- 区分趋势延续和过度透支。
- 当信号冲突时避免给出高确定性判断。
- 将波动率视为仓位输入,而不仅仅是方向输入。

View File

@@ -0,0 +1,21 @@
---
name: 估值分析
description: 使用多种估值视角评估合理价值和安全边际。
---
# 估值分析
当任务需要判断一只股票是低估、高估还是定价合理时,使用这个技能。
## 工作流程
1. 条件允许时,使用不止一种估值方法。
2. 对比内在价值估计与当前市场价格。
3. 解释估值判断背后的关键假设。
4. 明确安全边际,以及哪些因素会压缩或扩大它。
## 约束
- 将估值视为区间,而不是一个精确点值。
- 明确说明假设敏感性。
- 当输入稀疏或不稳定时,避免给出高置信度判断。

View File

@@ -0,0 +1,191 @@
# -*- coding: utf-8 -*-
from backend.agents.prompt_factory import build_agent_system_prompt
from backend.agents.skills_manager import SkillsManager
from backend.agents.workspace_manager import WorkspaceManager
class _DummyToolkit:
def get_agent_skill_prompt(self):
return ""
def get_activated_notes(self):
return ""
def test_workspace_manager_creates_extended_agent_files(tmp_path):
manager = WorkspaceManager(project_root=tmp_path)
manager.initialize_default_assets(
config_name="demo",
agent_ids=["risk_manager"],
analyst_personas={},
)
asset_dir = tmp_path / "runs" / "demo" / "agents" / "risk_manager"
assert (asset_dir / "SOUL.md").exists()
assert (asset_dir / "PROFILE.md").exists()
assert (asset_dir / "AGENTS.md").exists()
assert (asset_dir / "MEMORY.md").exists()
assert (asset_dir / "HEARTBEAT.md").exists()
assert (asset_dir / "agent.yaml").exists()
assert (asset_dir / "skills" / "installed").is_dir()
assert (asset_dir / "skills" / "active").is_dir()
assert (asset_dir / "skills" / "disabled").is_dir()
assert (asset_dir / "skills" / "local").is_dir()
def test_agent_workspace_config_controls_prompt_files(tmp_path, monkeypatch):
manager = WorkspaceManager(project_root=tmp_path)
manager.initialize_default_assets(
config_name="demo",
agent_ids=["risk_manager"],
analyst_personas={},
)
asset_dir = tmp_path / "runs" / "demo" / "agents" / "risk_manager"
(asset_dir / "SOUL.md").write_text("soul-line", encoding="utf-8")
(asset_dir / "PROFILE.md").write_text("profile-line", encoding="utf-8")
(asset_dir / "MEMORY.md").write_text("memory-line", encoding="utf-8")
(asset_dir / "agent.yaml").write_text(
"prompt_files:\n"
" - SOUL.md\n"
" - MEMORY.md\n",
encoding="utf-8",
)
from backend.agents import prompt_factory
monkeypatch.setattr(
prompt_factory,
"SkillsManager",
lambda: SkillsManager(project_root=tmp_path),
)
prompt = build_agent_system_prompt(
agent_id="risk_manager",
config_name="demo",
toolkit=_DummyToolkit(),
)
assert "soul-line" in prompt
assert "memory-line" in prompt
assert "profile-line" not in prompt
def test_skills_manager_applies_agent_level_skill_toggles(tmp_path):
builtin_root = tmp_path / "backend" / "skills" / "builtin"
for skill_name in ("risk_review", "extra_guard"):
skill_dir = builtin_root / skill_name
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(
f"# {skill_name}\n",
encoding="utf-8",
)
manager = WorkspaceManager(project_root=tmp_path)
manager.initialize_default_assets(
config_name="demo",
agent_ids=["risk_manager"],
analyst_personas={},
)
asset_dir = tmp_path / "runs" / "demo" / "agents" / "risk_manager"
(asset_dir / "agent.yaml").write_text(
"enabled_skills:\n"
" - extra_guard\n"
"disabled_skills:\n"
" - risk_review\n",
encoding="utf-8",
)
skills_manager = SkillsManager(project_root=tmp_path)
active_map = skills_manager.prepare_active_skills(
config_name="demo",
agent_defaults={"risk_manager": ["risk_review"]},
)
active_dirs = active_map["risk_manager"]
assert [path.name for path in active_dirs] == ["extra_guard"]
assert (asset_dir / "skills" / "installed" / "extra_guard" / "SKILL.md").exists()
assert (asset_dir / "skills" / "active" / "extra_guard" / "SKILL.md").exists()
assert (asset_dir / "skills" / "disabled" / "risk_review" / "SKILL.md").exists()
assert not (asset_dir / "skills" / "active" / "risk_review").exists()
def test_agent_local_skill_is_activated_from_agent_workspace(tmp_path):
manager = WorkspaceManager(project_root=tmp_path)
manager.initialize_default_assets(
config_name="demo",
agent_ids=["risk_manager"],
analyst_personas={},
)
asset_dir = tmp_path / "runs" / "demo" / "agents" / "risk_manager"
local_skill = asset_dir / "skills" / "local" / "local_guard"
local_skill.mkdir(parents=True, exist_ok=True)
(local_skill / "SKILL.md").write_text(
"---\nname: 本地风控\ndescription: local skill\nversion: 1.0.0\n---\n",
encoding="utf-8",
)
skills_manager = SkillsManager(project_root=tmp_path)
active_map = skills_manager.prepare_active_skills(
config_name="demo",
agent_defaults={"risk_manager": []},
)
assert [path.name for path in active_map["risk_manager"]] == ["local_guard"]
assert (asset_dir / "skills" / "active" / "local_guard" / "SKILL.md").exists()
def test_prompt_includes_active_skill_metadata_summary(tmp_path, monkeypatch):
builtin_root = tmp_path / "backend" / "skills" / "builtin"
skill_dir = builtin_root / "extra_guard"
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(
"---\n"
"name: extra_guard\n"
"description: This skill should be used when the user asks to \"run a risk check\".\n"
"version: 1.0.0\n"
"tools:\n"
" - risk_ops\n"
"---\n\n"
"# Extra Guard\n",
encoding="utf-8",
)
manager = WorkspaceManager(project_root=tmp_path)
manager.initialize_default_assets(
config_name="demo",
agent_ids=["risk_manager"],
analyst_personas={},
)
asset_dir = tmp_path / "runs" / "demo" / "agents" / "risk_manager"
(asset_dir / "agent.yaml").write_text(
"enabled_skills:\n"
" - extra_guard\n",
encoding="utf-8",
)
skills_manager = SkillsManager(project_root=tmp_path)
skills_manager.prepare_active_skills(
config_name="demo",
agent_defaults={"risk_manager": []},
)
from backend.agents import prompt_factory
monkeypatch.setattr(
prompt_factory,
"SkillsManager",
lambda: SkillsManager(project_root=tmp_path),
)
prompt = build_agent_system_prompt(
agent_id="risk_manager",
config_name="demo",
toolkit=_DummyToolkit(),
)
assert "Active Skill Catalog" in prompt
assert "This skill should be used when the user asks to \"run a risk check\"." in prompt
assert "version: 1.0.0" in prompt
assert "risk_ops" not in prompt

View File

@@ -382,3 +382,341 @@ async def test_refresh_market_store_for_watchlist_emits_system_messages(monkeypa
assert gateway.state_sync.system_messages[0] == "正在同步自选股市场数据: AAPL, MSFT" assert gateway.state_sync.system_messages[0] == "正在同步自选股市场数据: AAPL, MSFT"
assert "自选股市场数据已同步:" in gateway.state_sync.system_messages[1] assert "自选股市场数据已同步:" in gateway.state_sync.system_messages[1]
assert "AAPL prices=3 news=4" in gateway.state_sync.system_messages[1] assert "AAPL prices=3 news=4" in gateway.state_sync.system_messages[1]
@pytest.mark.asyncio
async def test_handle_get_agent_skills_returns_statuses(tmp_path):
builtin_root = tmp_path / "backend" / "skills" / "builtin"
for name in ("risk_review", "extra_guard"):
skill_dir = builtin_root / name
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(
f"---\nname: {name}\ndescription: {name} desc\n---\n",
encoding="utf-8",
)
agent_dir = tmp_path / "runs" / "demo" / "agents" / "risk_manager"
agent_dir.mkdir(parents=True, exist_ok=True)
(agent_dir / "agent.yaml").write_text(
"enabled_skills:\n"
" - extra_guard\n"
"disabled_skills:\n"
" - risk_review\n",
encoding="utf-8",
)
gateway = make_gateway()
gateway.config["config_name"] = "demo"
gateway._project_root = tmp_path
websocket = DummyWebSocket()
await gateway._handle_get_agent_skills(
websocket,
{"agent_id": "risk_manager"},
)
assert websocket.messages[-1]["type"] == "agent_skills_loaded"
statuses = {
row["skill_name"]: row["status"]
for row in websocket.messages[-1]["skills"]
}
assert statuses["extra_guard"] == "enabled"
assert statuses["risk_review"] == "disabled"
@pytest.mark.asyncio
async def test_handle_get_agent_profile_returns_model_and_tool_groups(monkeypatch, tmp_path):
agent_dir = tmp_path / "runs" / "demo" / "agents" / "risk_manager"
agent_dir.mkdir(parents=True, exist_ok=True)
(agent_dir / "agent.yaml").write_text(
"prompt_files:\n"
" - SOUL.md\n"
" - MEMORY.md\n"
"active_tool_groups:\n"
" - risk_ops\n"
"disabled_tool_groups:\n"
" - legacy_group\n",
encoding="utf-8",
)
gateway = make_gateway()
gateway.config["config_name"] = "demo"
gateway._project_root = tmp_path
websocket = DummyWebSocket()
monkeypatch.setattr(
gateway_module,
"load_agent_profiles",
lambda: {"risk_manager": {"skills": ["risk_review"], "active_tool_groups": ["risk_ops", "legacy_group"]}},
)
monkeypatch.setattr(
gateway_module,
"get_agent_model_info",
lambda agent_id: ("gpt-4o-mini", "OPENAI"),
)
class _Bootstrap:
@staticmethod
def agent_override(_agent_id):
return {}
monkeypatch.setattr(
gateway_module,
"get_bootstrap_config_for_run",
lambda project_root, config_name: _Bootstrap(),
)
await gateway._handle_get_agent_profile(
websocket,
{"agent_id": "risk_manager"},
)
assert websocket.messages[-1]["type"] == "agent_profile_loaded"
profile = websocket.messages[-1]["profile"]
assert profile["model_name"] == "gpt-4o-mini"
assert profile["model_provider"] == "OPENAI"
assert profile["prompt_files"] == ["SOUL.md", "MEMORY.md"]
assert profile["active_tool_groups"] == ["risk_ops"]
assert profile["disabled_tool_groups"] == ["legacy_group"]
@pytest.mark.asyncio
async def test_handle_get_skill_detail_returns_markdown_body(tmp_path):
skill_dir = tmp_path / "backend" / "skills" / "builtin" / "risk_review"
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(
"---\nname: 风险审查\ndescription: 说明\nversion: 1.0.0\n---\n# 风险审查\n\n完整正文\n",
encoding="utf-8",
)
gateway = make_gateway()
gateway._project_root = tmp_path
websocket = DummyWebSocket()
await gateway._handle_get_skill_detail(
websocket,
{"skill_name": "risk_review"},
)
assert websocket.messages[-1]["type"] == "skill_detail_loaded"
assert websocket.messages[-1]["skill"]["name"] == "风险审查"
assert websocket.messages[-1]["skill"]["version"] == "1.0.0"
assert websocket.messages[-1]["skill"]["content"] == "# 风险审查\n\n完整正文"
@pytest.mark.asyncio
async def test_handle_get_skill_detail_prefers_agent_local_skill(tmp_path):
skill_dir = tmp_path / "runs" / "demo" / "agents" / "risk_manager" / "skills" / "local" / "local_guard"
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(
"---\nname: 本地风控\ndescription: 本地说明\nversion: 1.0.0\n---\n# 本地风控\n\n本地正文\n",
encoding="utf-8",
)
gateway = make_gateway()
gateway.config["config_name"] = "demo"
gateway._project_root = tmp_path
websocket = DummyWebSocket()
await gateway._handle_get_skill_detail(
websocket,
{"agent_id": "risk_manager", "skill_name": "local_guard"},
)
assert websocket.messages[-1]["type"] == "skill_detail_loaded"
assert websocket.messages[-1]["agent_id"] == "risk_manager"
assert websocket.messages[-1]["skill"]["source"] == "local"
assert websocket.messages[-1]["skill"]["content"] == "# 本地风控\n\n本地正文"
@pytest.mark.asyncio
async def test_handle_update_agent_skill_persists_and_returns_refresh(monkeypatch, tmp_path):
skill_dir = tmp_path / "backend" / "skills" / "builtin" / "extra_guard"
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(
"---\nname: extra_guard\ndescription: desc\n---\n",
encoding="utf-8",
)
gateway = make_gateway()
gateway.config["config_name"] = "demo"
gateway._project_root = tmp_path
websocket = DummyWebSocket()
async def _noop_reload():
return None
monkeypatch.setattr(gateway, "_handle_reload_runtime_assets", _noop_reload)
await gateway._handle_update_agent_skill(
websocket,
{
"agent_id": "risk_manager",
"skill_name": "extra_guard",
"enabled": True,
},
)
assert websocket.messages[0]["type"] == "agent_skill_updated"
assert websocket.messages[-1]["type"] == "agent_skills_loaded"
agent_yaml = tmp_path / "runs" / "demo" / "agents" / "risk_manager" / "agent.yaml"
assert "extra_guard" in agent_yaml.read_text(encoding="utf-8")
@pytest.mark.asyncio
async def test_handle_create_and_update_agent_local_skill(monkeypatch, tmp_path):
gateway = make_gateway()
gateway.config["config_name"] = "demo"
gateway._project_root = tmp_path
websocket = DummyWebSocket()
async def _noop_reload():
return None
monkeypatch.setattr(gateway, "_handle_reload_runtime_assets", _noop_reload)
await gateway._handle_create_agent_local_skill(
websocket,
{"agent_id": "risk_manager", "skill_name": "local_guard"},
)
assert websocket.messages[0]["type"] == "agent_local_skill_created"
assert websocket.messages[1]["type"] == "agent_skills_loaded"
assert websocket.messages[2]["type"] == "skill_detail_loaded"
target = tmp_path / "runs" / "demo" / "agents" / "risk_manager" / "skills" / "local" / "local_guard" / "SKILL.md"
assert target.exists()
websocket.messages.clear()
await gateway._handle_update_agent_local_skill(
websocket,
{
"agent_id": "risk_manager",
"skill_name": "local_guard",
"content": "---\nname: 本地风控\ndescription: 更新后\nversion: 1.0.0\n---\n# 本地风控\n\n更新正文\n",
},
)
assert websocket.messages[0]["type"] == "agent_local_skill_updated"
assert websocket.messages[1]["type"] == "skill_detail_loaded"
assert "更新正文" in target.read_text(encoding="utf-8")
@pytest.mark.asyncio
async def test_handle_delete_agent_local_skill(monkeypatch, tmp_path):
skill_dir = tmp_path / "runs" / "demo" / "agents" / "risk_manager" / "skills" / "local" / "local_guard"
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(
"---\nname: 本地风控\ndescription: desc\nversion: 1.0.0\n---\n",
encoding="utf-8",
)
agent_yaml = tmp_path / "runs" / "demo" / "agents" / "risk_manager" / "agent.yaml"
agent_yaml.parent.mkdir(parents=True, exist_ok=True)
agent_yaml.write_text(
"enabled_skills:\n"
" - local_guard\n"
"disabled_skills:\n"
" - local_guard\n",
encoding="utf-8",
)
gateway = make_gateway()
gateway.config["config_name"] = "demo"
gateway._project_root = tmp_path
websocket = DummyWebSocket()
async def _noop_reload():
return None
monkeypatch.setattr(gateway, "_handle_reload_runtime_assets", _noop_reload)
await gateway._handle_delete_agent_local_skill(
websocket,
{"agent_id": "risk_manager", "skill_name": "local_guard"},
)
assert websocket.messages[0]["type"] == "agent_local_skill_deleted"
assert websocket.messages[1]["type"] == "agent_skills_loaded"
assert not skill_dir.exists()
assert "local_guard" not in agent_yaml.read_text(encoding="utf-8")
@pytest.mark.asyncio
async def test_handle_remove_agent_skill_marks_disabled(monkeypatch, tmp_path):
skill_dir = tmp_path / "backend" / "skills" / "builtin" / "risk_review"
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(
"---\nname: 风险审查\ndescription: desc\nversion: 1.0.0\n---\n",
encoding="utf-8",
)
gateway = make_gateway()
gateway.config["config_name"] = "demo"
gateway._project_root = tmp_path
websocket = DummyWebSocket()
async def _noop_reload():
return None
monkeypatch.setattr(gateway, "_handle_reload_runtime_assets", _noop_reload)
await gateway._handle_remove_agent_skill(
websocket,
{"agent_id": "risk_manager", "skill_name": "risk_review"},
)
assert websocket.messages[0]["type"] == "agent_skill_removed"
assert websocket.messages[1]["type"] == "agent_skills_loaded"
agent_yaml = tmp_path / "runs" / "demo" / "agents" / "risk_manager" / "agent.yaml"
assert "risk_review" in agent_yaml.read_text(encoding="utf-8")
@pytest.mark.asyncio
async def test_handle_get_agent_workspace_file_returns_content(tmp_path):
file_path = tmp_path / "runs" / "demo" / "agents" / "risk_manager" / "SOUL.md"
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text("soul content", encoding="utf-8")
gateway = make_gateway()
gateway.config["config_name"] = "demo"
gateway._project_root = tmp_path
websocket = DummyWebSocket()
await gateway._handle_get_agent_workspace_file(
websocket,
{"agent_id": "risk_manager", "filename": "SOUL.md"},
)
assert websocket.messages[-1] == {
"type": "agent_workspace_file_loaded",
"config_name": "demo",
"agent_id": "risk_manager",
"filename": "SOUL.md",
"content": "soul content",
}
@pytest.mark.asyncio
async def test_handle_update_agent_workspace_file_persists_and_returns_refresh(monkeypatch, tmp_path):
gateway = make_gateway()
gateway.config["config_name"] = "demo"
gateway._project_root = tmp_path
websocket = DummyWebSocket()
async def _noop_reload():
return None
monkeypatch.setattr(gateway, "_handle_reload_runtime_assets", _noop_reload)
await gateway._handle_update_agent_workspace_file(
websocket,
{
"agent_id": "risk_manager",
"filename": "SOUL.md",
"content": "updated soul",
},
)
assert websocket.messages[0]["type"] == "agent_workspace_file_updated"
assert websocket.messages[-1]["type"] == "agent_workspace_file_loaded"
target = tmp_path / "runs" / "demo" / "agents" / "risk_manager" / "SOUL.md"
assert target.read_text(encoding="utf-8") == "updated soul"

View File

@@ -0,0 +1,72 @@
# -*- coding: utf-8 -*-
from backend import cli
from backend.agents.skill_metadata import parse_skill_metadata
from backend.agents.skills_manager import SkillsManager
def test_parse_skill_metadata_extended_frontmatter(tmp_path):
skill_dir = tmp_path / "demo_skill"
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(
"---\n"
"name: demo_skill\n"
"description: Demo description\n"
"tools:\n"
" - technical\n"
"---\n\n"
"# Demo Skill\n",
encoding="utf-8",
)
parsed = parse_skill_metadata(skill_dir, source="builtin")
assert parsed.skill_name == "demo_skill"
assert parsed.description == "Demo description"
assert parsed.tools == ["technical"]
def test_update_agent_skill_overrides(tmp_path):
manager = SkillsManager(project_root=tmp_path)
asset_dir = manager.get_agent_asset_dir("demo", "risk_manager")
asset_dir.mkdir(parents=True, exist_ok=True)
(asset_dir / "agent.yaml").write_text(
"enabled_skills:\n"
" - risk_review\n"
"disabled_skills:\n"
" - old_skill\n",
encoding="utf-8",
)
result = manager.update_agent_skill_overrides(
config_name="demo",
agent_id="risk_manager",
enable=["extra_guard"],
disable=["risk_review"],
)
assert result["enabled_skills"] == ["extra_guard"]
assert result["disabled_skills"] == ["old_skill", "risk_review"]
def test_skills_enable_disable_and_list(monkeypatch, tmp_path):
builtin_root = tmp_path / "backend" / "skills" / "builtin"
for name in ("risk_review", "extra_guard"):
skill_dir = builtin_root / name
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(
f"---\nname: {name}\ndescription: {name} desc\n---\n",
encoding="utf-8",
)
printed = []
monkeypatch.setattr(cli, "get_project_root", lambda: tmp_path)
monkeypatch.setattr(cli.console, "print", lambda value: printed.append(value))
cli.skills_enable(agent_id="risk_manager", skill="extra_guard", config_name="demo")
cli.skills_disable(agent_id="risk_manager", skill="risk_review", config_name="demo")
cli.skills_list(config_name="demo", agent_id="risk_manager")
text_dump = "\n".join(str(item) for item in printed)
assert "Enabled" in text_dump
assert "Disabled" in text_dump
assert any(getattr(item, "title", None) == "Skill Catalog" for item in printed)

View File

@@ -0,0 +1,106 @@
# -*- coding: utf-8 -*-
from backend.agents.skills_manager import SkillsManager
from backend.skills.builtin.valuation_review.scripts.dcf_report import (
build_dcf_report,
)
from backend.skills.builtin.valuation_review.scripts.multiple_valuation_report import (
build_ev_ebitda_report,
build_residual_income_report,
)
from backend.skills.builtin.valuation_review.scripts.owner_earnings_report import (
build_owner_earnings_report,
)
def test_build_dcf_report_renders_assessment():
report = build_dcf_report(
[
{
"ticker": "AAPL",
"current_fcf": 100.0,
"growth_rate": 0.05,
"market_cap": 900.0,
"discount_rate": 0.10,
"terminal_growth": 0.03,
"num_years": 5,
},
],
"2026-03-17",
)
assert "DCF Valuation Analysis (2026-03-17)" in report
assert "AAPL:" in report
assert "Market Cap: $900" in report
assert "Value Gap:" in report
def test_build_owner_earnings_report_handles_errors():
report = build_owner_earnings_report(
[
{
"ticker": "MSFT",
"error": "Negative owner earnings ($-50)",
},
],
"2026-03-17",
)
assert "MSFT: Negative owner earnings ($-50)" in report
def test_multiple_valuation_reports_render_expected_sections():
ev_report = build_ev_ebitda_report(
[
{
"ticker": "NVDA",
"current_multiple": 18.0,
"median_multiple": 20.0,
"current_ebitda": 50.0,
"market_cap": 800.0,
"net_debt": 100.0,
},
],
"2026-03-17",
)
residual_report = build_residual_income_report(
[
{
"ticker": "META",
"book_value": 200.0,
"initial_ri": 30.0,
"market_cap": 300.0,
"cost_of_equity": 0.10,
"bv_growth": 0.03,
"terminal_growth": 0.03,
"num_years": 5,
"margin_of_safety": 0.20,
},
],
"2026-03-17",
)
assert "EV/EBITDA Valuation (2026-03-17)" in ev_report
assert "NVDA:" in ev_report
assert "Residual Income Valuation (2026-03-17)" in residual_report
assert "META:" in residual_report
def test_prepare_active_skills_copies_skill_scripts(tmp_path):
builtin_skill = tmp_path / "backend" / "skills" / "builtin" / "valuation_review"
scripts_dir = builtin_skill / "scripts"
scripts_dir.mkdir(parents=True, exist_ok=True)
(builtin_skill / "SKILL.md").write_text(
"---\nname: 估值分析\ndescription: desc\nversion: 1.0.0\n---\n",
encoding="utf-8",
)
(scripts_dir / "dcf_report.py").write_text("print('ok')\n", encoding="utf-8")
manager = SkillsManager(project_root=tmp_path)
active_map = manager.prepare_active_skills(
config_name="demo",
agent_defaults={"valuation_analyst": ["valuation_review"]},
)
active_dir = active_map["valuation_analyst"][0]
assert (active_dir / "scripts" / "dcf_report.py").exists()

View File

@@ -22,6 +22,16 @@ from agentscope.message import TextBlock
from agentscope.tool import ToolResponse from agentscope.tool import ToolResponse
from backend.data.provider_utils import normalize_symbol from backend.data.provider_utils import normalize_symbol
from backend.skills.builtin.valuation_review.scripts.dcf_report import (
build_dcf_report,
)
from backend.skills.builtin.valuation_review.scripts.multiple_valuation_report import (
build_ev_ebitda_report,
build_residual_income_report,
)
from backend.skills.builtin.valuation_review.scripts.owner_earnings_report import (
build_owner_earnings_report,
)
from backend.tools.data_tools import ( from backend.tools.data_tools import (
get_company_news, get_company_news,
get_financial_metrics, get_financial_metrics,
@@ -814,7 +824,7 @@ def dcf_valuation_analysis(
current_date = _resolved_date(current_date) current_date = _resolved_date(current_date)
tickers = _parse_tickers(tickers) tickers = _parse_tickers(tickers)
lines = [f"=== DCF Valuation Analysis ({current_date}) ===\n"] rows = []
for ticker in tickers: for ticker in tickers:
metrics = get_financial_metrics( metrics = get_financial_metrics(
@@ -823,7 +833,7 @@ def dcf_valuation_analysis(
limit=8, limit=8,
) )
if not metrics: if not metrics:
lines.append(f"{ticker}: No financial metrics\n") rows.append({"ticker": ticker, "error": "No financial metrics"})
continue continue
line_items = search_line_items( line_items = search_line_items(
@@ -838,56 +848,28 @@ def dcf_valuation_analysis(
or not line_items[0].free_cash_flow or not line_items[0].free_cash_flow
or line_items[0].free_cash_flow <= 0 or line_items[0].free_cash_flow <= 0
): ):
lines.append(f"{ticker}: Invalid free cash flow data\n") rows.append({"ticker": ticker, "error": "Invalid free cash flow data"})
continue continue
market_cap = get_market_cap(ticker, current_date) market_cap = get_market_cap(ticker, current_date)
if not market_cap: if not market_cap:
lines.append(f"{ticker}: Market cap unavailable\n") rows.append({"ticker": ticker, "error": "Market cap unavailable"})
continue continue
m = metrics[0] m = metrics[0]
current_fcf = line_items[0].free_cash_flow rows.append(
growth_rate = m.earnings_growth or 0.05 {
discount_rate = 0.10 "ticker": ticker,
terminal_growth = 0.03 "current_fcf": line_items[0].free_cash_flow,
num_years = 5 "growth_rate": m.earnings_growth or 0.05,
"market_cap": market_cap,
# DCF calculation "discount_rate": 0.10,
pv_fcf = sum( "terminal_growth": 0.03,
current_fcf "num_years": 5,
* (1 + growth_rate) ** year },
/ (1 + discount_rate) ** year
for year in range(1, num_years + 1)
) )
terminal_fcf = (
current_fcf
* (1 + growth_rate) ** num_years
* (1 + terminal_growth)
)
terminal_value = terminal_fcf / (discount_rate - terminal_growth)
pv_terminal = terminal_value / (1 + discount_rate) ** num_years
enterprise_value = pv_fcf + pv_terminal
value_gap = (enterprise_value - market_cap) / market_cap * 100
# Assessment return _to_text_response(build_dcf_report(rows, current_date))
if value_gap > 20:
assessment = "SIGNIFICANTLY UNDERVALUED"
elif value_gap > 0:
assessment = "POTENTIALLY UNDERVALUED"
elif value_gap > -20:
assessment = "POTENTIALLY OVERVALUED"
else:
assessment = "SIGNIFICANTLY OVERVALUED"
lines.append(f"{ticker}:")
lines.append(f" Current FCF: ${current_fcf:,.0f}")
lines.append(f" DCF Enterprise Value: ${enterprise_value:,.0f}")
lines.append(f" Market Cap: ${market_cap:,.0f}")
lines.append(f" Value Gap: {value_gap:+.1f}% -> {assessment}")
lines.append("")
return _to_text_response("\n".join(lines))
@safe @safe
@@ -911,7 +893,7 @@ def owner_earnings_valuation_analysis(
current_date = _resolved_date(current_date) current_date = _resolved_date(current_date)
tickers = _parse_tickers(tickers) tickers = _parse_tickers(tickers)
lines = [f"=== Owner Earnings Valuation ({current_date}) ===\n"] rows = []
for ticker in tickers: for ticker in tickers:
metrics = get_financial_metrics( metrics = get_financial_metrics(
@@ -920,7 +902,7 @@ def owner_earnings_valuation_analysis(
limit=8, limit=8,
) )
if not metrics: if not metrics:
lines.append(f"{ticker}: No financial metrics\n") rows.append({"ticker": ticker, "error": "No financial metrics"})
continue continue
line_items = search_line_items( line_items = search_line_items(
@@ -936,12 +918,12 @@ def owner_earnings_valuation_analysis(
limit=2, limit=2,
) )
if len(line_items) < 2: if len(line_items) < 2:
lines.append(f"{ticker}: Insufficient financial data\n") rows.append({"ticker": ticker, "error": "Insufficient financial data"})
continue continue
market_cap = get_market_cap(ticker, current_date) market_cap = get_market_cap(ticker, current_date)
if not market_cap: if not market_cap:
lines.append(f"{ticker}: Market cap unavailable\n") rows.append({"ticker": ticker, "error": "Market cap unavailable"})
continue continue
m = metrics[0] m = metrics[0]
@@ -956,57 +938,27 @@ def owner_earnings_valuation_analysis(
owner_earnings = net_income + depreciation - capex - wc_change owner_earnings = net_income + depreciation - capex - wc_change
if owner_earnings <= 0: if owner_earnings <= 0:
lines.append( rows.append(
f"{ticker}: Negative owner earnings (${owner_earnings:,.0f})\n", {
"ticker": ticker,
"error": f"Negative owner earnings (${owner_earnings:,.0f})",
},
) )
continue continue
# Valuation rows.append(
growth_rate = m.earnings_growth or 0.05 {
required_return = 0.15 "ticker": ticker,
margin_of_safety = 0.25 "owner_earnings": owner_earnings,
num_years = 5 "growth_rate": m.earnings_growth or 0.05,
"market_cap": market_cap,
pv_earnings = sum( "required_return": 0.15,
owner_earnings "margin_of_safety": 0.25,
* (1 + growth_rate) ** year "num_years": 5,
/ (1 + required_return) ** year },
for year in range(1, num_years + 1)
) )
terminal_growth = min(growth_rate, 0.03)
terminal_earnings = (
owner_earnings
* (1 + growth_rate) ** num_years
* (1 + terminal_growth)
)
terminal_value = terminal_earnings / (
required_return - terminal_growth
)
pv_terminal = terminal_value / (1 + required_return) ** num_years
intrinsic_value = (pv_earnings + pv_terminal) * (1 - margin_of_safety) return _to_text_response(build_owner_earnings_report(rows, current_date))
value_gap = (intrinsic_value - market_cap) / market_cap * 100
# Assessment
if value_gap > 20:
assessment = "SIGNIFICANTLY UNDERVALUED"
elif value_gap > 0:
assessment = "POTENTIALLY UNDERVALUED"
elif value_gap > -20:
assessment = "POTENTIALLY OVERVALUED"
else:
assessment = "SIGNIFICANTLY OVERVALUED"
lines.append(f"{ticker}:")
lines.append(f" Owner Earnings: ${owner_earnings:,.0f}")
lines.append(
f" Intrinsic Value (w/ 25% MoS): ${intrinsic_value:,.0f}",
)
lines.append(f" Market Cap: ${market_cap:,.0f}")
lines.append(f" Value Gap: {value_gap:+.1f}% -> {assessment}")
lines.append("")
return _to_text_response("\n".join(lines))
@safe @safe
@@ -1030,7 +982,7 @@ def ev_ebitda_valuation_analysis(
current_date = _resolved_date(current_date) current_date = _resolved_date(current_date)
tickers = _parse_tickers(tickers) tickers = _parse_tickers(tickers)
lines = [f"=== EV/EBITDA Valuation ({current_date}) ===\n"] rows = []
for ticker in tickers: for ticker in tickers:
metrics = get_financial_metrics( metrics = get_financial_metrics(
@@ -1039,7 +991,7 @@ def ev_ebitda_valuation_analysis(
limit=8, limit=8,
) )
if not metrics: if not metrics:
lines.append(f"{ticker}: No financial metrics\n") rows.append({"ticker": ticker, "error": "No financial metrics"})
continue continue
m = metrics[0] m = metrics[0]
@@ -1048,12 +1000,12 @@ def ev_ebitda_valuation_analysis(
or not m.enterprise_value_to_ebitda_ratio or not m.enterprise_value_to_ebitda_ratio
or m.enterprise_value_to_ebitda_ratio <= 0 or m.enterprise_value_to_ebitda_ratio <= 0
): ):
lines.append(f"{ticker}: Missing EV/EBITDA data\n") rows.append({"ticker": ticker, "error": "Missing EV/EBITDA data"})
continue continue
market_cap = get_market_cap(ticker, current_date) market_cap = get_market_cap(ticker, current_date)
if not market_cap: if not market_cap:
lines.append(f"{ticker}: Market cap unavailable\n") rows.append({"ticker": ticker, "error": "Market cap unavailable"})
continue continue
current_ebitda = ( current_ebitda = (
@@ -1067,42 +1019,21 @@ def ev_ebitda_valuation_analysis(
and x.enterprise_value_to_ebitda_ratio > 0 and x.enterprise_value_to_ebitda_ratio > 0
] ]
if len(valid_multiples) < 3: if len(valid_multiples) < 3:
lines.append(f"{ticker}: Insufficient historical data\n") rows.append({"ticker": ticker, "error": "Insufficient historical data"})
continue continue
median_multiple = median(valid_multiples) rows.append(
current_multiple = m.enterprise_value_to_ebitda_ratio {
"ticker": ticker,
implied_ev = median_multiple * current_ebitda "current_multiple": m.enterprise_value_to_ebitda_ratio,
net_debt = m.enterprise_value - market_cap "median_multiple": median(valid_multiples),
implied_equity = max(implied_ev - net_debt, 0) "current_ebitda": current_ebitda,
"market_cap": market_cap,
value_gap = ( "net_debt": m.enterprise_value - market_cap,
(implied_equity - market_cap) / market_cap * 100 },
if market_cap > 0
else 0
)
multiple_discount = (
(median_multiple - current_multiple) / median_multiple * 100
) )
# Assessment return _to_text_response(build_ev_ebitda_report(rows, current_date))
if multiple_discount > 10:
assessment = "TRADING BELOW HISTORICAL MULTIPLE"
elif multiple_discount > -10:
assessment = "NEAR HISTORICAL AVERAGE"
else:
assessment = "TRADING ABOVE HISTORICAL MULTIPLE"
lines.append(f"{ticker}:")
lines.append(f" Current EV/EBITDA: {current_multiple:.1f}x")
lines.append(f" Historical Median: {median_multiple:.1f}x")
lines.append(f" Multiple vs History: {multiple_discount:+.1f}%")
lines.append(f" Implied Equity Value: ${implied_equity:,.0f}")
lines.append(f" Value Gap: {value_gap:+.1f}% -> {assessment}")
lines.append("")
return _to_text_response("\n".join(lines))
@safe @safe
@@ -1126,7 +1057,7 @@ def residual_income_valuation_analysis(
current_date = _resolved_date(current_date) current_date = _resolved_date(current_date)
tickers = _parse_tickers(tickers) tickers = _parse_tickers(tickers)
lines = [f"=== Residual Income Valuation ({current_date}) ===\n"] rows = []
for ticker in tickers: for ticker in tickers:
metrics = get_financial_metrics( metrics = get_financial_metrics(
@@ -1135,7 +1066,7 @@ def residual_income_valuation_analysis(
limit=8, limit=8,
) )
if not metrics: if not metrics:
lines.append(f"{ticker}: No financial metrics\n") rows.append({"ticker": ticker, "error": "No financial metrics"})
continue continue
line_items = search_line_items( line_items = search_line_items(
@@ -1146,59 +1077,44 @@ def residual_income_valuation_analysis(
limit=1, limit=1,
) )
if not line_items or not line_items[0].net_income: if not line_items or not line_items[0].net_income:
lines.append(f"{ticker}: No net income data\n") rows.append({"ticker": ticker, "error": "No net income data"})
continue continue
market_cap = get_market_cap(ticker, current_date) market_cap = get_market_cap(ticker, current_date)
if not market_cap: if not market_cap:
lines.append(f"{ticker}: Market cap unavailable\n") rows.append({"ticker": ticker, "error": "Market cap unavailable"})
continue continue
m = metrics[0] m = metrics[0]
if not m.price_to_book_ratio or m.price_to_book_ratio <= 0: if not m.price_to_book_ratio or m.price_to_book_ratio <= 0:
lines.append(f"{ticker}: Invalid P/B ratio\n") rows.append({"ticker": ticker, "error": "Invalid P/B ratio"})
continue continue
net_income = line_items[0].net_income net_income = line_items[0].net_income
pb_ratio = m.price_to_book_ratio pb_ratio = m.price_to_book_ratio
book_value = market_cap / pb_ratio book_value = market_cap / pb_ratio
# Model parameters
cost_of_equity = 0.10 cost_of_equity = 0.10
bv_growth = m.book_value_growth or 0.03
terminal_growth = 0.03
num_years = 5
margin_of_safety = 0.20
initial_ri = net_income - cost_of_equity * book_value initial_ri = net_income - cost_of_equity * book_value
if initial_ri <= 0: if initial_ri <= 0:
lines.append(f"{ticker}: Negative residual income\n") rows.append({"ticker": ticker, "error": "Negative residual income"})
continue continue
# PV calculation rows.append(
pv_ri = sum( {
initial_ri * (1 + bv_growth) ** year / (1 + cost_of_equity) ** year "ticker": ticker,
for year in range(1, num_years + 1) "book_value": book_value,
"initial_ri": initial_ri,
"market_cap": market_cap,
"cost_of_equity": cost_of_equity,
"bv_growth": m.book_value_growth or 0.03,
"terminal_growth": 0.03,
"num_years": 5,
"margin_of_safety": 0.20,
},
) )
terminal_ri = initial_ri * (1 + bv_growth) ** (num_years + 1)
terminal_value = terminal_ri / (cost_of_equity - terminal_growth)
pv_terminal = terminal_value / (1 + cost_of_equity) ** num_years
intrinsic_value = (book_value + pv_ri + pv_terminal) * ( return _to_text_response(build_residual_income_report(rows, current_date))
1 - margin_of_safety
)
value_gap = (intrinsic_value - market_cap) / market_cap * 100
lines.append(f"{ticker}:")
lines.append(f" Book Value: ${book_value:,.0f}")
lines.append(f" Residual Income: ${initial_ri:,.0f}")
lines.append(
f" Intrinsic Value (w/ 20% MoS): ${intrinsic_value:,.0f}",
)
lines.append(f" Value Gap: {value_gap:+.1f}%")
lines.append("")
return _to_text_response("\n".join(lines))
# Tool Registry for dynamic toolkit creation # Tool Registry for dynamic toolkit creation

View File

@@ -17,6 +17,7 @@ import NetValueChart from './components/NetValueChart';
import StockLogo from './components/StockLogo'; import StockLogo from './components/StockLogo';
import Header from './components/Header.jsx'; import Header from './components/Header.jsx';
import WatchlistPanel from './components/WatchlistPanel.jsx'; import WatchlistPanel from './components/WatchlistPanel.jsx';
import RuntimeSettingsPanel from './components/RuntimeSettingsPanel.jsx';
// Utils // Utils
import { formatNumber, formatTickerPrice } from './utils/formatters'; import { formatNumber, formatTickerPrice } from './utils/formatters';
@@ -25,6 +26,8 @@ const RoomView = lazy(() => import('./components/RoomView'));
const AgentFeed = lazy(() => import('./components/AgentFeed')); const AgentFeed = lazy(() => import('./components/AgentFeed'));
const StatisticsView = lazy(() => import('./components/StatisticsView')); const StatisticsView = lazy(() => import('./components/StatisticsView'));
const StockExplainView = lazy(() => import('./components/StockExplainView.jsx')); const StockExplainView = lazy(() => import('./components/StockExplainView.jsx'));
const TraderView = lazy(() => import('./components/TraderView.jsx'));
const EDITABLE_AGENT_WORKSPACE_FILES = ['SOUL.md', 'PROFILE.md', 'AGENTS.md', 'MEMORY.md', 'POLICY.md', 'HEARTBEAT.md', 'ROLE.md', 'STYLE.md'];
function ViewLoadingFallback({ label = '加载中...' }) { function ViewLoadingFallback({ label = '加载中...' }) {
return ( return (
@@ -61,8 +64,8 @@ export default function LiveTradingApp() {
const [progress, setProgress] = useState({ current: 0, total: 0 }); const [progress, setProgress] = useState({ current: 0, total: 0 });
const [now, setNow] = useState(() => new Date()); const [now, setNow] = useState(() => new Date());
// View toggle: 'room' | 'explain' | 'chart' | 'statistics' // View toggle: 'traders' | 'room' | 'explain' | 'chart' | 'statistics'
const [currentView, setCurrentView] = useState('chart'); // Start with chart, then animate to room const [currentView, setCurrentView] = useState('traders');
const [isInitialAnimating, setIsInitialAnimating] = useState(true); const [isInitialAnimating, setIsInitialAnimating] = useState(true);
const [lastUpdate, setLastUpdate] = useState(new Date()); const [lastUpdate, setLastUpdate] = useState(new Date());
const [isUpdating, setIsUpdating] = useState(false); const [isUpdating, setIsUpdating] = useState(false);
@@ -112,15 +115,38 @@ export default function LiveTradingApp() {
const [dataSources, setDataSources] = useState(null); const [dataSources, setDataSources] = useState(null);
const [runtimeConfig, setRuntimeConfig] = useState(null); const [runtimeConfig, setRuntimeConfig] = useState(null);
const [isWatchlistPanelOpen, setIsWatchlistPanelOpen] = useState(false); const [isWatchlistPanelOpen, setIsWatchlistPanelOpen] = useState(false);
const [isRuntimeSettingsOpen, setIsRuntimeSettingsOpen] = useState(false);
const [watchlistDraftSymbols, setWatchlistDraftSymbols] = useState([]); const [watchlistDraftSymbols, setWatchlistDraftSymbols] = useState([]);
const [watchlistInputValue, setWatchlistInputValue] = useState(''); const [watchlistInputValue, setWatchlistInputValue] = useState('');
const [watchlistFeedback, setWatchlistFeedback] = useState(null); const [watchlistFeedback, setWatchlistFeedback] = useState(null);
const [isWatchlistSaving, setIsWatchlistSaving] = useState(false); const [isWatchlistSaving, setIsWatchlistSaving] = useState(false);
const [scheduleModeDraft, setScheduleModeDraft] = useState('daily');
const [intervalMinutesDraft, setIntervalMinutesDraft] = useState('60');
const [triggerTimeDraft, setTriggerTimeDraft] = useState('09:30');
const [maxCommCyclesDraft, setMaxCommCyclesDraft] = useState('2');
const [runtimeConfigFeedback, setRuntimeConfigFeedback] = useState(null);
const [isRuntimeConfigSaving, setIsRuntimeConfigSaving] = useState(false);
const [selectedSkillAgentId, setSelectedSkillAgentId] = useState(AGENTS[0]?.id || 'portfolio_manager');
const [agentProfilesByAgent, setAgentProfilesByAgent] = useState({});
const [agentSkillsByAgent, setAgentSkillsByAgent] = useState({});
const [skillDetailsByName, setSkillDetailsByName] = useState({});
const [localSkillDraftsByKey, setLocalSkillDraftsByKey] = useState({});
const [isAgentSkillsLoading, setIsAgentSkillsLoading] = useState(false);
const [skillDetailLoadingKey, setSkillDetailLoadingKey] = useState(null);
const [agentSkillsSavingKey, setAgentSkillsSavingKey] = useState(null);
const [agentSkillsFeedback, setAgentSkillsFeedback] = useState(null);
const [selectedWorkspaceFile, setSelectedWorkspaceFile] = useState(EDITABLE_AGENT_WORKSPACE_FILES[0]);
const [workspaceFilesByAgent, setWorkspaceFilesByAgent] = useState({});
const [workspaceDraftContent, setWorkspaceDraftContent] = useState('');
const [isWorkspaceFileLoading, setIsWorkspaceFileLoading] = useState(false);
const [workspaceFileSavingKey, setWorkspaceFileSavingKey] = useState(null);
const [workspaceFileFeedback, setWorkspaceFileFeedback] = useState(null);
const clientRef = useRef(null); const clientRef = useRef(null);
const containerRef = useRef(null); const containerRef = useRef(null);
const agentFeedRef = useRef(null); const agentFeedRef = useRef(null);
const isWatchlistSavingRef = useRef(false); const isWatchlistSavingRef = useRef(false);
const isRuntimeConfigSavingRef = useRef(false);
const requestedStockHistoryRef = useRef(new Set()); const requestedStockHistoryRef = useRef(new Set());
// Track last virtual time update to calculate increment // Track last virtual time update to calculate increment
@@ -220,6 +246,38 @@ export default function LiveTradingApp() {
.filter((symbol) => typeof symbol === 'string' && symbol.trim()); .filter((symbol) => typeof symbol === 'string' && symbol.trim());
}, [displayTickers, runtimeConfig]); }, [displayTickers, runtimeConfig]);
const runtimeSummaryLabel = useMemo(() => {
if (!runtimeConfig) {
return null;
}
const scheduleMode = String(runtimeConfig.schedule_mode || 'daily');
const intervalMinutes = Number(runtimeConfig.interval_minutes || 60);
const triggerTime = String(runtimeConfig.trigger_time || '09:30');
const maxCommCycles = Number(runtimeConfig.max_comm_cycles || 2);
if (scheduleMode === 'intraday') {
return `调度 intraday / ${intervalMinutes}m / 讨论 ${maxCommCycles}`;
}
return `调度 daily / ${triggerTime} ET / 讨论 ${maxCommCycles}`;
}, [runtimeConfig]);
const selectedAgentSkills = useMemo(
() => agentSkillsByAgent[selectedSkillAgentId] || [],
[agentSkillsByAgent, selectedSkillAgentId]
);
const selectedAgentProfile = useMemo(
() => agentProfilesByAgent[selectedSkillAgentId] || null,
[agentProfilesByAgent, selectedSkillAgentId]
);
const selectedWorkspaceContent = useMemo(
() => workspaceFilesByAgent[selectedSkillAgentId]?.[selectedWorkspaceFile] || '',
[selectedSkillAgentId, selectedWorkspaceFile, workspaceFilesByAgent]
);
useEffect(() => { useEffect(() => {
const symbols = displayTickers const symbols = displayTickers
.map((ticker) => ticker.symbol) .map((ticker) => ticker.symbol)
@@ -235,6 +293,17 @@ export default function LiveTradingApp() {
} }
}, [displayTickers, selectedExplainSymbol]); }, [displayTickers, selectedExplainSymbol]);
useEffect(() => {
if (!runtimeConfig) {
return;
}
setScheduleModeDraft(String(runtimeConfig.schedule_mode || 'daily'));
setIntervalMinutesDraft(String(runtimeConfig.interval_minutes || 60));
setTriggerTimeDraft(String(runtimeConfig.trigger_time || '09:30'));
setMaxCommCyclesDraft(String(runtimeConfig.max_comm_cycles || 2));
}, [runtimeConfig]);
const watchlistSuggestions = useMemo( const watchlistSuggestions = useMemo(
() => INITIAL_TICKERS.map((ticker) => ticker.symbol).filter((symbol, index, list) => list.indexOf(symbol) === index), () => INITIAL_TICKERS.map((ticker) => ticker.symbol).filter((symbol, index, list) => list.indexOf(symbol) === index),
[] []
@@ -350,6 +419,7 @@ export default function LiveTradingApp() {
}, [watchlistFeedback]); }, [watchlistFeedback]);
const handleWatchlistPanelToggle = useCallback(() => { const handleWatchlistPanelToggle = useCallback(() => {
setIsRuntimeSettingsOpen(false);
setIsWatchlistPanelOpen((open) => { setIsWatchlistPanelOpen((open) => {
const nextOpen = !open; const nextOpen = !open;
if (nextOpen) { if (nextOpen) {
@@ -425,6 +495,292 @@ export default function LiveTradingApp() {
} }
}, [parseWatchlistInput, watchlistDraftSymbols, watchlistInputValue]); }, [parseWatchlistInput, watchlistDraftSymbols, watchlistInputValue]);
const handleManualTrigger = useCallback(() => {
if (!clientRef.current) {
addSystemMessage('连接未就绪,无法手动触发');
return;
}
const success = clientRef.current.send({
type: 'trigger_strategy'
});
if (!success) {
addSystemMessage('手动触发发送失败,请检查连接状态');
return;
}
addSystemMessage('已发送手动触发请求');
}, [addSystemMessage]);
const handleRuntimeConfigSave = useCallback(() => {
if (!clientRef.current) {
setRuntimeConfigFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
return;
}
const interval = Number(intervalMinutesDraft);
const maxCommCycles = Number(maxCommCyclesDraft);
if (!Number.isInteger(interval) || interval <= 0) {
setRuntimeConfigFeedback({ type: 'error', text: '间隔必须是正整数分钟' });
return;
}
if (!Number.isInteger(maxCommCycles) || maxCommCycles <= 0) {
setRuntimeConfigFeedback({ type: 'error', text: '讨论轮数必须是正整数' });
return;
}
setIsRuntimeConfigSaving(true);
setRuntimeConfigFeedback(null);
const success = clientRef.current.send({
type: 'update_runtime_config',
schedule_mode: scheduleModeDraft,
interval_minutes: interval,
trigger_time: triggerTimeDraft,
max_comm_cycles: maxCommCycles
});
if (!success) {
setIsRuntimeConfigSaving(false);
setRuntimeConfigFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
}
}, [intervalMinutesDraft, maxCommCyclesDraft, scheduleModeDraft, triggerTimeDraft]);
const handleRuntimeDefaultsRestore = useCallback(() => {
setScheduleModeDraft('daily');
setIntervalMinutesDraft('60');
setTriggerTimeDraft('09:30');
setMaxCommCyclesDraft('2');
setRuntimeConfigFeedback(null);
}, []);
const handleRuntimeSettingsToggle = useCallback(() => {
setRuntimeConfigFeedback(null);
setAgentSkillsFeedback(null);
setWorkspaceFileFeedback(null);
setIsRuntimeSettingsOpen((prev) => !prev);
setIsWatchlistPanelOpen(false);
}, []);
const requestAgentSkills = useCallback((agentId) => {
const normalized = typeof agentId === 'string' ? agentId.trim() : '';
if (!normalized || !clientRef.current) {
return false;
}
setIsAgentSkillsLoading(true);
setAgentSkillsFeedback(null);
return clientRef.current.send({
type: 'get_agent_skills',
agent_id: normalized
});
}, []);
const requestAgentProfile = useCallback((agentId) => {
const normalized = typeof agentId === 'string' ? agentId.trim() : '';
if (!normalized || !clientRef.current) {
return false;
}
return clientRef.current.send({
type: 'get_agent_profile',
agent_id: normalized
});
}, []);
const requestSkillDetail = useCallback((skillName) => {
const normalized = typeof skillName === 'string' ? skillName.trim() : '';
if (!normalized || !clientRef.current) {
return false;
}
const detailKey = `${selectedSkillAgentId}:${normalized}`;
setSkillDetailLoadingKey(detailKey);
return clientRef.current.send({
type: 'get_skill_detail',
agent_id: selectedSkillAgentId,
skill_name: normalized
});
}, [selectedSkillAgentId]);
const handleCreateLocalSkill = useCallback((skillName) => {
const normalized = typeof skillName === 'string' ? skillName.trim() : '';
if (!normalized) {
setAgentSkillsFeedback({ type: 'error', text: '技能名称不能为空' });
return;
}
if (!clientRef.current) {
setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
return;
}
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${normalized}:create`);
setAgentSkillsFeedback(null);
const success = clientRef.current.send({
type: 'create_agent_local_skill',
agent_id: selectedSkillAgentId,
skill_name: normalized
});
if (!success) {
setAgentSkillsSavingKey(null);
setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
}
}, [selectedSkillAgentId]);
const handleLocalSkillDraftChange = useCallback((skillName, content) => {
const detailKey = `${selectedSkillAgentId}:${skillName}`;
setLocalSkillDraftsByKey((prev) => ({
...prev,
[detailKey]: content
}));
}, [selectedSkillAgentId]);
const handleLocalSkillSave = useCallback((skillName) => {
if (!clientRef.current) {
setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
return;
}
const detailKey = `${selectedSkillAgentId}:${skillName}`;
const content = localSkillDraftsByKey[detailKey];
if (typeof content !== 'string') {
return;
}
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:content`);
setAgentSkillsFeedback(null);
const success = clientRef.current.send({
type: 'update_agent_local_skill',
agent_id: selectedSkillAgentId,
skill_name: skillName,
content
});
if (!success) {
setAgentSkillsSavingKey(null);
setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
}
}, [localSkillDraftsByKey, selectedSkillAgentId]);
const handleLocalSkillDelete = useCallback((skillName) => {
if (!clientRef.current) {
setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
return;
}
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:delete`);
setAgentSkillsFeedback(null);
const success = clientRef.current.send({
type: 'delete_agent_local_skill',
agent_id: selectedSkillAgentId,
skill_name: skillName
});
if (!success) {
setAgentSkillsSavingKey(null);
setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
}
}, [selectedSkillAgentId]);
const handleRemoveSharedSkill = useCallback((skillName) => {
if (!clientRef.current) {
setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
return;
}
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:remove`);
setAgentSkillsFeedback(null);
const success = clientRef.current.send({
type: 'remove_agent_skill',
agent_id: selectedSkillAgentId,
skill_name: skillName
});
if (!success) {
setAgentSkillsSavingKey(null);
setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
}
}, [selectedSkillAgentId]);
const requestWorkspaceFile = useCallback((agentId, filename) => {
const normalizedAgentId = typeof agentId === 'string' ? agentId.trim() : '';
const normalizedFilename = typeof filename === 'string' ? filename.trim() : '';
if (!normalizedAgentId || !normalizedFilename || !clientRef.current) {
return false;
}
setIsWorkspaceFileLoading(true);
setWorkspaceFileFeedback(null);
return clientRef.current.send({
type: 'get_agent_workspace_file',
agent_id: normalizedAgentId,
filename: normalizedFilename
});
}, []);
const handleAgentSkillToggle = useCallback((skillName, enabled) => {
if (!clientRef.current) {
setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
return;
}
const agentId = selectedSkillAgentId;
setAgentSkillsSavingKey(`${agentId}:${skillName}`);
setAgentSkillsFeedback(null);
const success = clientRef.current.send({
type: 'update_agent_skill',
agent_id: agentId,
skill_name: skillName,
enabled
});
if (!success) {
setAgentSkillsSavingKey(null);
setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
}
}, [selectedSkillAgentId]);
const handleSkillAgentChange = useCallback((agentId) => {
setSelectedSkillAgentId(agentId);
requestAgentProfile(agentId);
requestAgentSkills(agentId);
requestWorkspaceFile(agentId, selectedWorkspaceFile);
}, [requestAgentProfile, requestAgentSkills, requestWorkspaceFile, selectedWorkspaceFile]);
const handleWorkspaceFileChange = useCallback((filename) => {
setSelectedWorkspaceFile(filename);
requestWorkspaceFile(selectedSkillAgentId, filename);
}, [requestWorkspaceFile, selectedSkillAgentId]);
const handleWorkspaceFileSave = useCallback(() => {
if (!clientRef.current) {
setWorkspaceFileFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
return;
}
const key = `${selectedSkillAgentId}:${selectedWorkspaceFile}`;
setWorkspaceFileSavingKey(key);
setWorkspaceFileFeedback(null);
const success = clientRef.current.send({
type: 'update_agent_workspace_file',
agent_id: selectedSkillAgentId,
filename: selectedWorkspaceFile,
content: workspaceDraftContent
});
if (!success) {
setWorkspaceFileSavingKey(null);
setWorkspaceFileFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
}
}, [selectedSkillAgentId, selectedWorkspaceFile, workspaceDraftContent]);
useEffect(() => {
setWorkspaceDraftContent(selectedWorkspaceContent);
}, [selectedWorkspaceContent]);
useEffect(() => {
if (currentView !== 'traders' || !isConnected) {
return;
}
AGENTS.forEach((agent) => {
if (!agentProfilesByAgent[agent.id]) {
requestAgentProfile(agent.id);
}
if (!agentSkillsByAgent[agent.id]) {
requestAgentSkills(agent.id);
}
if (!workspaceFilesByAgent[agent.id]?.['MEMORY.md']) {
requestWorkspaceFile(agent.id, 'MEMORY.md');
}
});
}, [agentProfilesByAgent, agentSkillsByAgent, currentView, isConnected, requestAgentProfile, requestAgentSkills, requestWorkspaceFile, workspaceFilesByAgent]);
const requestStockHistory = useCallback((symbol, { force = false } = {}) => { const requestStockHistory = useCallback((symbol, { force = false } = {}) => {
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : ''; const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
if (!normalized || !clientRef.current) { if (!normalized || !clientRef.current) {
@@ -604,6 +960,10 @@ export default function LiveTradingApp() {
isWatchlistSavingRef.current = isWatchlistSaving; isWatchlistSavingRef.current = isWatchlistSaving;
}, [isWatchlistSaving]); }, [isWatchlistSaving]);
useEffect(() => {
isRuntimeConfigSavingRef.current = isRuntimeConfigSaving;
}, [isRuntimeConfigSaving]);
useEffect(() => { useEffect(() => {
if (currentView !== 'explain' || !selectedExplainSymbol) { if (currentView !== 'explain' || !selectedExplainSymbol) {
return; return;
@@ -670,24 +1030,18 @@ export default function LiveTradingApp() {
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [holdings, stats, trades, portfolioData.netValue]); }, [holdings, stats, trades, portfolioData.netValue]);
// Initial animation: show room drawer sliding in // Initial animation flag for slider speed
useEffect(() => { useEffect(() => {
// Wait a bit after mount, then trigger slide to room
const slideTimer = setTimeout(() => {
setCurrentView('room');
}, 1200); // Wait 1200ms before starting animation (2x slower)
// Disable animation flag after animation completes
const completeTimer = setTimeout(() => { const completeTimer = setTimeout(() => {
setIsInitialAnimating(false); setIsInitialAnimating(false);
}, 5000); // 1200ms delay + 1600ms animation duration + 400ms buffer }, 1800);
return () => { return () => {
clearTimeout(slideTimer);
clearTimeout(completeTimer); clearTimeout(completeTimer);
}; };
}, []); }, []);
// Helper to check if bubble should still be visible // Helper to check if bubble should still be visible
// Bubbles persist until replaced by ANY new message (cross-role) // Bubbles persist until replaced by ANY new message (cross-role)
// When any agent sends a new message, all previous bubbles are cleared // When any agent sends a new message, all previous bubbles are cleared
@@ -769,21 +1123,38 @@ export default function LiveTradingApp() {
const handlers = { const handlers = {
// Error response (for fast forward errors) // Error response (for fast forward errors)
error: (e) => { error: (e) => {
console.error('[Error]', e.message); 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) { if (isWatchlistSavingRef.current) {
setIsWatchlistSaving(false); setIsWatchlistSaving(false);
setWatchlistFeedback({ type: 'error', text: e.message || '更新 watchlist 失败' }); setWatchlistFeedback({ type: 'error', text: message || '更新 watchlist 失败' });
}
if (isRuntimeConfigSavingRef.current) {
setIsRuntimeConfigSaving(false);
setRuntimeConfigFeedback({ type: 'error', text: message });
}
if (message.includes('skill') || message.includes('agent_id')) {
setAgentSkillsFeedback({ type: 'error', text: message || '更新技能失败' });
}
if (message.includes('workspace_file') || message.includes('filename')) {
setWorkspaceFileFeedback({ type: 'error', text: message || '更新工作区文件失败' });
} }
// Handle fast forward errors // Handle fast forward errors
if (e.message && e.message.includes('fast forward')) { if (message.includes('fast forward')) {
console.warn(`⚠️ ${e.message}`); console.warn(`⚠️ ${message}`);
handlePushEvent({ handlePushEvent({
type: 'system', type: 'system',
content: `⚠️ ${e.message}`, content: `⚠️ ${message}`,
timestamp: Date.now() timestamp: Date.now()
}); });
} }
addSystemMessage(message);
}, },
// Connection events // Connection events
@@ -930,9 +1301,163 @@ export default function LiveTradingApp() {
if (isWatchlistSavingRef.current) { if (isWatchlistSavingRef.current) {
setIsWatchlistSaving(false); 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('运行时配置已热更新'); addSystemMessage('运行时配置已热更新');
}, },
agent_skills_loaded: (e) => {
const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
if (!agentId) {
setIsAgentSkillsLoading(false);
return;
}
setAgentSkillsByAgent((prev) => ({
...prev,
[agentId]: Array.isArray(e.skills) ? e.skills : []
}));
setIsAgentSkillsLoading(false);
setAgentSkillsSavingKey(null);
},
agent_profile_loaded: (e) => {
const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
if (!agentId) {
return;
}
setAgentProfilesByAgent((prev) => ({
...prev,
[agentId]: e.profile && typeof e.profile === 'object' ? e.profile : {}
}));
},
skill_detail_loaded: (e) => {
const skillName = typeof e.skill?.skill_name === 'string' ? e.skill.skill_name.trim() : '';
const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : selectedSkillAgentId;
if (!skillName) {
setSkillDetailLoadingKey(null);
return;
}
const detailKey = `${agentId}:${skillName}`;
setSkillDetailsByName((prev) => ({
...prev,
[detailKey]: e.skill
}));
setLocalSkillDraftsByKey((prev) => ({
...prev,
[detailKey]: typeof e.skill?.content === 'string' ? e.skill.content : ''
}));
setSkillDetailLoadingKey(null);
},
agent_skill_updated: (e) => {
const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
const skillName = typeof e.skill_name === 'string' ? e.skill_name.trim() : '';
if (!agentId || !skillName) {
return;
}
setAgentSkillsFeedback({
type: 'success',
text: `${agentId} ${e.enabled ? '已启用' : '已禁用'} ${skillName}`
});
},
agent_local_skill_created: (e) => {
const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
const skillName = typeof e.skill_name === 'string' ? e.skill_name.trim() : '';
setAgentSkillsSavingKey(null);
if (!agentId || !skillName) {
return;
}
setAgentSkillsFeedback({
type: 'success',
text: `${agentId} 已创建本地技能 ${skillName}`
});
},
agent_local_skill_updated: (e) => {
const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
const skillName = typeof e.skill_name === 'string' ? e.skill_name.trim() : '';
setAgentSkillsSavingKey(null);
if (!agentId || !skillName) {
return;
}
setAgentSkillsFeedback({
type: 'success',
text: `${agentId} 的本地技能 ${skillName} 已保存`
});
},
agent_local_skill_deleted: (e) => {
const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
const skillName = typeof e.skill_name === 'string' ? e.skill_name.trim() : '';
setAgentSkillsSavingKey(null);
if (!agentId || !skillName) {
return;
}
setSkillDetailsByName((prev) => {
const next = { ...prev };
delete next[`${agentId}:${skillName}`];
return next;
});
setLocalSkillDraftsByKey((prev) => {
const next = { ...prev };
delete next[`${agentId}:${skillName}`];
return next;
});
setAgentSkillsFeedback({
type: 'success',
text: `${agentId} 的本地技能 ${skillName} 已删除`
});
},
agent_skill_removed: (e) => {
const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
const skillName = typeof e.skill_name === 'string' ? e.skill_name.trim() : '';
setAgentSkillsSavingKey(null);
if (!agentId || !skillName) {
return;
}
setAgentSkillsFeedback({
type: 'success',
text: `${agentId} 已移除共享技能 ${skillName}`
});
},
agent_workspace_file_loaded: (e) => {
const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
const filename = typeof e.filename === 'string' ? e.filename.trim() : '';
if (!agentId || !filename) {
setIsWorkspaceFileLoading(false);
return;
}
setWorkspaceFilesByAgent((prev) => ({
...prev,
[agentId]: {
...(prev[agentId] || {}),
[filename]: typeof e.content === 'string' ? e.content : ''
}
}));
setIsWorkspaceFileLoading(false);
setWorkspaceFileSavingKey(null);
},
agent_workspace_file_updated: (e) => {
const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
const filename = typeof e.filename === 'string' ? e.filename.trim() : '';
if (!agentId || !filename) {
return;
}
setWorkspaceFileFeedback({
type: 'success',
text: `${agentId}${filename} 已保存`
});
},
watchlist_updated: (e) => { watchlist_updated: (e) => {
if (Array.isArray(e.tickers)) { if (Array.isArray(e.tickers)) {
const normalizedTickers = e.tickers.map((symbol) => String(symbol).trim().toUpperCase()); const normalizedTickers = e.tickers.map((symbol) => String(symbol).trim().toUpperCase());
@@ -1713,10 +2238,41 @@ export default function LiveTradingApp() {
</span> </span>
</> </>
)} )}
{runtimeSummaryLabel && (
<>
<span className="status-sep">·</span>
<span className="market-text backtest" title="当前运行配置">
{runtimeSummaryLabel}
</span>
</>
)}
<span className="status-sep">·</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> <span className="time-text">{now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })}</span>
</div> </div>
{serverMode !== 'backtest' && (
<button
onClick={handleManualTrigger}
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>
)}
<WatchlistPanel <WatchlistPanel
isOpen={isWatchlistPanelOpen} isOpen={isWatchlistPanelOpen}
isConnected={isConnected} isConnected={isConnected}
@@ -1736,6 +2292,26 @@ export default function LiveTradingApp() {
onSuggestionClick={handleWatchlistSuggestionClick} onSuggestionClick={handleWatchlistSuggestionClick}
onSave={handleWatchlistSave} onSave={handleWatchlistSave}
/> />
<RuntimeSettingsPanel
isOpen={isRuntimeSettingsOpen}
isConnected={isConnected}
isSaving={isRuntimeConfigSaving}
feedback={runtimeConfigFeedback}
runtimeConfig={runtimeConfig}
scheduleMode={scheduleModeDraft}
intervalMinutes={intervalMinutesDraft}
triggerTime={triggerTimeDraft}
maxCommCycles={maxCommCyclesDraft}
onToggle={handleRuntimeSettingsToggle}
onClose={() => setIsRuntimeSettingsOpen(false)}
onScheduleModeChange={setScheduleModeDraft}
onIntervalMinutesChange={setIntervalMinutesDraft}
onTriggerTimeChange={setTriggerTimeDraft}
onMaxCommCyclesChange={setMaxCommCyclesDraft}
onSave={handleRuntimeConfigSave}
onRestoreDefaults={handleRuntimeDefaultsRestore}
/>
</div> </div>
</div> </div>
@@ -1783,6 +2359,13 @@ export default function LiveTradingApp() {
<div className="chart-section"> <div className="chart-section">
<div className="view-container"> <div className="view-container">
<div className="view-nav-bar"> <div className="view-nav-bar">
<button
className={`view-nav-btn ${currentView === 'traders' ? 'active' : ''}`}
onClick={() => setCurrentView('traders')}
>
交易员
</button>
<button <button
className={`view-nav-btn ${currentView === 'room' ? 'active' : ''}`} className={`view-nav-btn ${currentView === 'room' ? 'active' : ''}`}
onClick={() => setCurrentView('room')} onClick={() => setCurrentView('room')}
@@ -1812,9 +2395,10 @@ export default function LiveTradingApp() {
</button> </button>
</div> </div>
{/* Slider container with four views */} <div className={`view-slider-five ${
<div className={`view-slider-four ${ currentView === 'traders'
currentView === 'room' ? 'show-traders'
: currentView === 'room'
? 'show-room' ? 'show-room'
: currentView === 'explain' : currentView === 'explain'
? 'show-explain' ? 'show-explain'
@@ -1822,6 +2406,45 @@ export default function LiveTradingApp() {
? 'show-statistics' ? 'show-statistics'
: 'show-chart' : 'show-chart'
} ${!isInitialAnimating ? 'normal-speed' : ''}`}> } ${!isInitialAnimating ? 'normal-speed' : ''}`}>
<div className="view-panel">
<Suspense fallback={<ViewLoadingFallback label="加载交易员视图..." />}>
<TraderView
agents={AGENTS}
agentProfilesByAgent={agentProfilesByAgent}
agentSkillsByAgent={agentSkillsByAgent}
workspaceFilesByAgent={workspaceFilesByAgent}
selectedAgentId={selectedSkillAgentId}
selectedAgentProfile={selectedAgentProfile}
selectedAgentSkills={selectedAgentSkills}
skillDetailsByName={skillDetailsByName}
localSkillDraftsByKey={localSkillDraftsByKey}
skillDetailLoadingKey={skillDetailLoadingKey}
editableFiles={EDITABLE_AGENT_WORKSPACE_FILES}
selectedWorkspaceFile={selectedWorkspaceFile}
workspaceFileContent={selectedWorkspaceContent}
workspaceDraftContent={workspaceDraftContent}
isConnected={isConnected}
isAgentSkillsLoading={isAgentSkillsLoading}
agentSkillsSavingKey={agentSkillsSavingKey}
agentSkillsFeedback={agentSkillsFeedback}
isWorkspaceFileLoading={isWorkspaceFileLoading}
workspaceFileSavingKey={workspaceFileSavingKey}
workspaceFileFeedback={workspaceFileFeedback}
onAgentChange={handleSkillAgentChange}
onCreateLocalSkill={handleCreateLocalSkill}
onSkillDetailRequest={requestSkillDetail}
onLocalSkillDraftChange={handleLocalSkillDraftChange}
onLocalSkillDelete={handleLocalSkillDelete}
onLocalSkillSave={handleLocalSkillSave}
onRemoveSharedSkill={handleRemoveSharedSkill}
onSkillToggle={handleAgentSkillToggle}
onWorkspaceFileChange={handleWorkspaceFileChange}
onWorkspaceDraftChange={setWorkspaceDraftContent}
onWorkspaceFileSave={handleWorkspaceFileSave}
/>
</Suspense>
</div>
{/* Room View Panel */} {/* Room View Panel */}
<div className="view-panel"> <div className="view-panel">
<Suspense fallback={<ViewLoadingFallback label="加载交易室..." />}> <Suspense fallback={<ViewLoadingFallback label="加载交易室..." />}>

View File

@@ -0,0 +1,247 @@
import React from 'react';
export default function RuntimeSettingsPanel({
isOpen,
isConnected,
isSaving,
feedback,
runtimeConfig,
scheduleMode,
intervalMinutes,
triggerTime,
maxCommCycles,
onToggle,
onClose,
onScheduleModeChange,
onIntervalMinutesChange,
onTriggerTimeChange,
onMaxCommCyclesChange,
onSave,
onRestoreDefaults
}) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, position: 'relative' }}>
<button
onClick={onToggle}
style={{
padding: '6px 10px',
borderRadius: 4,
border: '1px solid #333333',
background: isOpen ? '#1E1E1E' : '#111111',
color: '#FFFFFF',
fontSize: '11px',
fontWeight: 700,
letterSpacing: '0.6px',
cursor: 'pointer',
whiteSpace: 'nowrap'
}}
>
运行设置
</button>
{isOpen && (
<div style={{
position: 'absolute',
top: 'calc(100% + 10px)',
right: 0,
width: 320,
maxWidth: 'min(320px, 92vw)',
padding: '14px',
borderRadius: 8,
border: '1px solid #D9D9D9',
background: '#FFFFFF',
boxShadow: '0 12px 36px rgba(0, 0, 0, 0.14)',
zIndex: 40,
display: 'grid',
gap: 12
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12 }}>
<div>
<div style={{ fontSize: '12px', fontWeight: 700, color: '#111111', letterSpacing: '0.3px' }}>
运行设置
</div>
<div style={{ fontSize: '11px', color: '#666666', marginTop: 2 }}>
保存后立即热更新当前运行中的调度参数
</div>
</div>
<button
onClick={onClose}
style={{
border: 'none',
background: 'transparent',
color: '#666666',
cursor: 'pointer',
fontSize: '14px',
lineHeight: 1
}}
>
×
</button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<label style={{ display: 'grid', gap: 4 }}>
<span style={{ fontSize: '10px', color: '#4B5563', fontWeight: 700 }}>调度模式</span>
<select
value={scheduleMode}
onChange={(e) => onScheduleModeChange(e.target.value)}
style={{
padding: '9px 10px',
borderRadius: 6,
border: '1px solid #D0D7DE',
background: '#FFFFFF',
color: '#111111',
fontSize: '12px'
}}
>
<option value="daily">daily</option>
<option value="intraday">intraday</option>
</select>
</label>
<label style={{ display: 'grid', gap: 4 }}>
<span style={{ fontSize: '10px', color: '#4B5563', fontWeight: 700 }}>间隔(分钟)</span>
<input
type="number"
min="1"
value={intervalMinutes}
onChange={(e) => onIntervalMinutesChange(e.target.value)}
style={{
padding: '9px 10px',
borderRadius: 6,
border: '1px solid #D0D7DE',
background: '#FFFFFF',
color: '#111111',
fontSize: '12px',
fontFamily: '"Courier New", monospace'
}}
/>
</label>
</div>
<label style={{ display: 'grid', gap: 4 }}>
<span style={{ fontSize: '10px', color: '#4B5563', fontWeight: 700 }}>Daily 时间 (NYSE)</span>
<input
type="time"
value={triggerTime}
onChange={(e) => onTriggerTimeChange(e.target.value)}
disabled={scheduleMode !== 'daily'}
style={{
padding: '9px 10px',
borderRadius: 6,
border: '1px solid #D0D7DE',
background: scheduleMode === 'daily' ? '#FFFFFF' : '#F3F4F6',
color: '#111111',
fontSize: '12px',
fontFamily: '"Courier New", monospace'
}}
/>
</label>
<label style={{ display: 'grid', gap: 4 }}>
<span style={{ fontSize: '10px', color: '#4B5563', fontWeight: 700 }}>讨论轮数上限</span>
<input
type="number"
min="1"
value={maxCommCycles}
onChange={(e) => onMaxCommCyclesChange(e.target.value)}
style={{
padding: '9px 10px',
borderRadius: 6,
border: '1px solid #D0D7DE',
background: '#FFFFFF',
color: '#111111',
fontSize: '12px',
fontFamily: '"Courier New", monospace'
}}
/>
</label>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8 }}>
<button
onClick={onRestoreDefaults}
style={{
padding: '9px 12px',
borderRadius: 6,
border: '1px solid #D0D7DE',
background: '#FFFFFF',
color: '#111111',
fontSize: '11px',
fontWeight: 700,
cursor: 'pointer'
}}
>
恢复默认
</button>
<button
onClick={onSave}
disabled={!isConnected || isSaving}
style={{
padding: '9px 14px',
borderRadius: 6,
border: '1px solid #1565C0',
background: isConnected && !isSaving ? '#0D47A1' : '#94A3B8',
color: '#FFFFFF',
fontSize: '11px',
fontWeight: 700,
letterSpacing: '0.4px',
cursor: isConnected && !isSaving ? 'pointer' : 'not-allowed'
}}
>
{isSaving ? '保存中' : '保存运行配置'}
</button>
</div>
{feedback && (
<span style={{
color: feedback.type === 'success' ? '#00C853' : '#FF5252',
fontSize: '11px',
fontFamily: '"Courier New", monospace'
}}>
{feedback.text}
</span>
)}
{runtimeConfig && (
<div style={{
borderTop: '1px solid #E5E7EB',
paddingTop: 12,
display: 'grid',
gap: 8
}}>
<div>
<div style={{ fontSize: '12px', fontWeight: 700, color: '#111111', letterSpacing: '0.3px' }}>
当前生效配置
</div>
<div style={{ fontSize: '11px', color: '#666666', marginTop: 2 }}>
这里显示当前 run 已加载并生效的参数
</div>
</div>
<div style={{
border: '1px solid #E5E7EB',
background: '#F8FAFC',
borderRadius: 6,
padding: '10px 12px',
display: 'grid',
gap: 6,
fontSize: '11px',
fontFamily: '"Courier New", monospace',
color: '#111111'
}}>
<div>tickers: {(runtimeConfig.tickers || []).join(', ') || '-'}</div>
<div>schedule_mode: {runtimeConfig.schedule_mode || '-'}</div>
<div>interval_minutes: {runtimeConfig.interval_minutes ?? '-'}</div>
<div>trigger_time: {runtimeConfig.trigger_time || '-'}</div>
<div>max_comm_cycles: {runtimeConfig.max_comm_cycles ?? '-'}</div>
<div>initial_cash: {runtimeConfig.initial_cash ?? '-'}</div>
<div>margin_requirement: {runtimeConfig.margin_requirement ?? '-'}</div>
<div>enable_memory: {String(runtimeConfig.enable_memory ?? false)}</div>
</div>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,765 @@
import React, { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { getModelIcon, getShortModelName } from '../utils/modelIcons';
export default function TraderView({
agents,
agentProfilesByAgent,
agentSkillsByAgent,
workspaceFilesByAgent,
selectedAgentId,
selectedAgentProfile,
selectedAgentSkills,
skillDetailsByName,
localSkillDraftsByKey,
skillDetailLoadingKey,
editableFiles,
selectedWorkspaceFile,
workspaceFileContent,
workspaceDraftContent,
isConnected,
isAgentSkillsLoading,
agentSkillsSavingKey,
agentSkillsFeedback,
isWorkspaceFileLoading,
workspaceFileSavingKey,
workspaceFileFeedback,
onAgentChange,
onCreateLocalSkill,
onSkillDetailRequest,
onLocalSkillDraftChange,
onLocalSkillDelete,
onLocalSkillSave,
onRemoveSharedSkill,
onSkillToggle,
onWorkspaceFileChange,
onWorkspaceDraftChange,
onWorkspaceFileSave
}) {
const [expandedSkillKey, setExpandedSkillKey] = useState(null);
const [newLocalSkillName, setNewLocalSkillName] = useState('');
const [isSkillPickerOpen, setIsSkillPickerOpen] = useState(false);
const selectedAgent = useMemo(
() => agents.find((agent) => agent.id === selectedAgentId) || agents[0] || null,
[agents, selectedAgentId]
);
useEffect(() => {
setExpandedSkillKey(null);
}, [selectedAgentId]);
if (!selectedAgent) {
return null;
}
const profile = selectedAgentProfile || {};
const modelInfo = getModelIcon(profile.model_name, profile.model_provider);
const activeSkills = selectedAgentSkills.filter((item) => item.status === 'enabled' || item.status === 'active');
const installedSkills = selectedAgentSkills.filter((item) => item.status !== 'available');
const availableSkills = selectedAgentSkills.filter((item) => item.status === 'available');
return (
<div style={{
height: '100%',
overflow: 'hidden',
padding: '18px',
background: 'linear-gradient(180deg, #ffffff 0%, #f4f7fb 100%)',
display: 'grid',
gridTemplateRows: 'auto minmax(0, 1fr)',
gap: 18
}}>
<div style={{ display: 'grid', gap: 4 }}>
<div style={{ fontSize: 12, fontWeight: 800, letterSpacing: '0.5px', color: '#111111' }}>
交易员档案
</div>
<div style={{ fontSize: 11, color: '#6B7280' }}>
聚焦查看每个 Agent 的模型工具组技能编排和工作区记忆不展示交易表现数据
</div>
</div>
<div style={{
display: 'grid',
gridTemplateColumns: '120px minmax(0, 1fr)',
gap: 16,
alignItems: 'stretch',
minHeight: 0
}}>
<div style={{
border: '1px solid #D9E0E7',
borderRadius: 14,
background: '#FFFFFF',
boxShadow: '0 10px 24px rgba(15, 23, 42, 0.06)',
padding: 12,
display: 'grid',
gap: 10,
minHeight: 0,
overflowY: 'auto',
alignContent: 'start'
}}>
{agents.map((agent) => {
const isSelected = agent.id === selectedAgentId;
return (
<button
key={agent.id}
type="button"
onClick={() => onAgentChange(agent.id)}
title={agent.name}
style={{
border: isSelected ? `2px solid ${agent.colors.accent}` : '1px solid #D9E0E7',
borderRadius: 16,
background: isSelected ? `${agent.colors.accent}10` : '#FFFFFF',
boxShadow: isSelected ? `0 10px 20px ${agent.colors.accent}18` : 'none',
padding: 8,
display: 'grid',
gap: 6,
justifyItems: 'center',
cursor: 'pointer'
}}
>
<img
src={agent.avatar}
alt={agent.name}
style={{
width: 56,
height: 56,
borderRadius: 14,
objectFit: 'cover',
border: `1px solid ${agent.colors.accent}33`
}}
/>
<div style={{
fontSize: 10,
fontWeight: 800,
color: isSelected ? agent.colors.accent : '#374151',
textAlign: 'center',
lineHeight: 1.4
}}>
{agent.name}
</div>
</button>
);
})}
</div>
<div style={{
border: '1px solid #D9E0E7',
borderRadius: 14,
background: '#FFFFFF',
boxShadow: '0 10px 24px rgba(15, 23, 42, 0.06)',
padding: 18,
display: 'grid',
gap: 16,
minHeight: 0,
overflowY: 'auto',
alignContent: 'start'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<img
src={selectedAgent.avatar}
alt={selectedAgent.name}
style={{
width: 58,
height: 58,
borderRadius: 12,
objectFit: 'cover',
border: `1px solid ${selectedAgent.colors.accent}33`
}}
/>
<div style={{ display: 'grid', gap: 4 }}>
<div style={{ fontSize: 15, fontWeight: 800, color: '#111111' }}>{selectedAgent.name}</div>
<div style={{ fontSize: 12, color: '#6B7280' }}>{selectedAgent.role}</div>
<div style={{ fontSize: 11, color: selectedAgent.colors.accent, fontWeight: 700 }}>
当前档案已展开
</div>
</div>
</div>
<div style={{
border: `1px solid ${modelInfo.color}2e`,
background: modelInfo.bgColor,
borderRadius: 12,
padding: '10px 12px',
display: 'flex',
alignItems: 'center',
gap: 10
}}>
{modelInfo.logoPath && (
<img
src={modelInfo.logoPath}
alt={modelInfo.provider}
style={{ width: 26, height: 26, borderRadius: 999 }}
/>
)}
<div style={{ display: 'grid', gap: 2 }}>
<div style={{ fontSize: 11, color: '#4B5563', fontWeight: 700 }}>模型</div>
<div style={{ fontSize: 12, color: '#111111', fontWeight: 800 }}>
{getShortModelName(profile.model_name)}
</div>
</div>
</div>
</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'minmax(300px, 420px) minmax(0, 1fr)',
gap: 16,
alignItems: 'start'
}}>
<div style={{ display: 'grid', gap: 10 }}>
<div style={{
border: '1px solid #E5EAF1',
borderRadius: 12,
background: '#FCFDFE',
padding: 14,
display: 'grid',
gap: 10
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 10, alignItems: 'center' }}>
<div style={{ display: 'grid', gap: 2 }}>
<div style={{ fontSize: 12, fontWeight: 800, color: '#111111' }}>技能</div>
<div style={{ fontSize: 10, color: '#6B7280', fontFamily: '"Courier New", monospace' }}>
已启用: {activeSkills.length} / 已安装: {installedSkills.length}
</div>
</div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<button
type="button"
onClick={() => setIsSkillPickerOpen(true)}
style={{
padding: '7px 10px',
borderRadius: 6,
border: '1px solid #1565C0',
background: '#EFF6FF',
color: '#1565C0',
fontSize: 10,
fontWeight: 700,
cursor: 'pointer',
whiteSpace: 'nowrap'
}}
aria-label="管理技能"
>
技能管理
</button>
</div>
</div>
<div style={{
border: '1px solid #E5E7EB',
background: '#F8FAFC',
borderRadius: 8,
padding: '10px 12px',
display: 'grid',
gap: 10,
maxHeight: 520,
overflowY: 'auto'
}}>
{isAgentSkillsLoading ? (
<div style={{ fontSize: 11, color: '#666666', fontFamily: '"Courier New", monospace' }}>加载技能中...</div>
) : installedSkills.length === 0 ? (
<div style={{ fontSize: 11, color: '#666666', fontFamily: '"Courier New", monospace' }}>暂无技能</div>
) : installedSkills.map((skill) => {
const isEnabled = skill.status === 'enabled' || skill.status === 'active';
const saving = agentSkillsSavingKey === `${selectedAgentId}:${skill.skill_name}` || agentSkillsSavingKey === `${selectedAgentId}:${skill.skill_name}:content` || agentSkillsSavingKey === `${selectedAgentId}:${skill.skill_name}:delete` || agentSkillsSavingKey === `${selectedAgentId}:${skill.skill_name}:remove`;
const isExpanded = expandedSkillKey === skill.skill_name;
const detailKey = `${selectedAgentId}:${skill.skill_name}`;
const skillDetail = skillDetailsByName?.[detailKey] || null;
const skillDraft = localSkillDraftsByKey?.[detailKey] ?? '';
const isDetailLoading = skillDetailLoadingKey === detailKey;
const isLocalSkill = skill.source === 'local';
return (
<div
key={skill.skill_name}
style={{
display: 'grid',
gap: 7,
paddingBottom: 10,
borderBottom: '1px dashed #D7DEE7'
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 10, alignItems: 'flex-start' }}>
<button
type="button"
onClick={() => {
if (!isExpanded && !skillDetail && onSkillDetailRequest) {
onSkillDetailRequest(skill.skill_name);
}
setExpandedSkillKey((prev) => (prev === skill.skill_name ? null : skill.skill_name));
}}
style={{
flex: 1,
minWidth: 0,
border: 'none',
background: 'transparent',
padding: 0,
textAlign: 'left',
cursor: 'pointer',
display: 'grid',
gap: 4
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 12, color: '#6B7280', fontWeight: 700 }}>
{isExpanded ? '▾' : '▸'}
</span>
<span style={{ fontSize: 12, fontWeight: 700, color: '#111111' }}>
{skill.name || '未命名技能'}
</span>
<span style={{
padding: '2px 6px',
borderRadius: 999,
border: `1px solid ${isLocalSkill ? selectedAgent.colors.accent : '#D0D7DE'}`,
color: isLocalSkill ? selectedAgent.colors.accent : '#6B7280',
fontSize: 9,
fontWeight: 700
}}>
{isLocalSkill ? '本地' : '共享'}
</span>
</div>
<div style={{ fontSize: 11, color: '#4B5563', marginLeft: 20 }}>
{skill.description || '-'}
</div>
<div style={{ fontSize: 10, color: '#6B7280', marginLeft: 20 }}>
{isExpanded ? '点击收起详情' : '点击展开详情'}
</div>
</button>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<button
type="button"
onClick={() => onSkillToggle(skill.skill_name, !isEnabled)}
disabled={!isConnected || saving}
style={{
padding: '7px 10px',
borderRadius: 6,
border: `1px solid ${isEnabled ? '#C62828' : '#1565C0'}`,
background: isConnected && !saving ? (isEnabled ? '#FFF5F5' : '#EFF6FF') : '#E5E7EB',
color: isEnabled ? '#C62828' : '#1565C0',
fontSize: 10,
fontWeight: 700,
cursor: isConnected && !saving ? 'pointer' : 'not-allowed',
whiteSpace: 'nowrap'
}}
>
{saving ? '处理中' : isEnabled ? '禁用' : '启用'}
</button>
{isLocalSkill ? (
<button
type="button"
onClick={() => onLocalSkillDelete(skill.skill_name)}
disabled={!isConnected || saving}
style={{
padding: '7px 10px',
borderRadius: 6,
border: '1px solid #C62828',
background: isConnected && !saving ? '#FFF5F5' : '#E5E7EB',
color: '#C62828',
fontSize: 10,
fontWeight: 700,
cursor: isConnected && !saving ? 'pointer' : 'not-allowed',
whiteSpace: 'nowrap'
}}
>
{saving ? '处理中' : '删除'}
</button>
) : (
<button
type="button"
onClick={() => onRemoveSharedSkill(skill.skill_name)}
disabled={!isConnected || saving}
style={{
padding: '7px 10px',
borderRadius: 6,
border: '1px solid #C62828',
background: isConnected && !saving ? '#FFF5F5' : '#E5E7EB',
color: '#C62828',
fontSize: 10,
fontWeight: 700,
cursor: isConnected && !saving ? 'pointer' : 'not-allowed',
whiteSpace: 'nowrap'
}}
>
{saving ? '处理中' : '移除'}
</button>
)}
</div>
</div>
{isExpanded && (
<div style={{
marginLeft: 20,
borderRadius: 8,
border: '1px solid #E5E7EB',
background: '#FFFFFF',
padding: '10px 12px',
display: 'grid',
gap: 8
}}>
<div style={{
fontSize: 11,
color: '#1F2937',
lineHeight: 1.6,
whiteSpace: 'pre-wrap',
fontFamily: '"Courier New", monospace'
}}>
{isDetailLoading
? '加载技能说明中...'
: (skillDetail?.content || '暂无更详细的技能说明')}
</div>
{isLocalSkill && !isDetailLoading && (
<div style={{ display: 'grid', gap: 8 }}>
<div style={{ fontSize: 10, color: '#6B7280', fontWeight: 700 }}>
本地技能 SKILL.md
</div>
<textarea
value={skillDraft}
onChange={(e) => onLocalSkillDraftChange(skill.skill_name, e.target.value)}
style={{
minHeight: 220,
resize: 'vertical',
borderRadius: 8,
border: '1px solid #D0D7DE',
background: '#FFFFFF',
color: '#111111',
padding: '10px 12px',
fontSize: 11,
lineHeight: 1.6,
fontFamily: '"Courier New", monospace'
}}
/>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button
type="button"
onClick={() => onLocalSkillSave(skill.skill_name)}
disabled={!isConnected || saving || skillDraft === (skillDetail?.content || '')}
style={{
padding: '8px 12px',
borderRadius: 6,
border: '1px solid #1565C0',
background: isConnected && !saving && skillDraft !== (skillDetail?.content || '') ? '#0D47A1' : '#94A3B8',
color: '#FFFFFF',
fontSize: 10,
fontWeight: 700,
cursor: isConnected && !saving && skillDraft !== (skillDetail?.content || '') ? 'pointer' : 'not-allowed'
}}
>
{saving ? '保存中' : '保存本地技能'}
</button>
</div>
</div>
)}
</div>
)}
</div>
);
})}
</div>
{agentSkillsFeedback && (
<span style={{
color: agentSkillsFeedback.type === 'success' ? '#00C853' : '#FF5252',
fontSize: 11,
fontFamily: '"Courier New", monospace'
}}>
{agentSkillsFeedback.text}
</span>
)}
</div>
</div>
<div style={{ display: 'grid', gap: 10 }}>
<div style={{
border: '1px solid #E5EAF1',
borderRadius: 12,
background: '#FCFDFE',
padding: 14,
display: 'grid',
gap: 10
}}>
<div style={{ display: 'grid', gap: 4 }}>
<div style={{ fontSize: 12, fontWeight: 800, color: '#111111' }}>工作区文件编辑</div>
<div style={{ fontSize: 11, color: '#6B7280' }}>
直接调整该交易员的人设协作方式和长期记忆文件
</div>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{editableFiles.map((filename) => {
const isActive = filename === selectedWorkspaceFile;
return (
<button
key={filename}
onClick={() => onWorkspaceFileChange(filename)}
style={{
padding: '7px 10px',
borderRadius: 999,
border: `1px solid ${isActive ? selectedAgent.colors.accent : '#D0D7DE'}`,
background: isActive ? `${selectedAgent.colors.accent}12` : '#FFFFFF',
color: isActive ? selectedAgent.colors.accent : '#4B5563',
fontSize: 10,
fontWeight: 700,
cursor: 'pointer',
fontFamily: '"Courier New", monospace'
}}
>
{filename}
</button>
);
})}
</div>
<textarea
value={workspaceDraftContent}
onChange={(e) => onWorkspaceDraftChange(e.target.value)}
placeholder={isWorkspaceFileLoading ? '加载中...' : '输入 markdown 内容'}
style={{
minHeight: 280,
resize: 'vertical',
borderRadius: 8,
border: '1px solid #D0D7DE',
background: '#FFFFFF',
color: '#111111',
padding: '12px 14px',
fontSize: 12,
lineHeight: 1.6,
fontFamily: '"Courier New", monospace'
}}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<span style={{ fontSize: 10, color: '#6B7280', fontFamily: '"Courier New", monospace' }}>
当前文件: {selectedWorkspaceFile}
</span>
<button
onClick={onWorkspaceFileSave}
disabled={!isConnected || isWorkspaceFileLoading || workspaceFileSavingKey !== null || workspaceDraftContent === workspaceFileContent}
style={{
padding: '9px 14px',
borderRadius: 6,
border: '1px solid #1565C0',
background: isConnected && !isWorkspaceFileLoading && workspaceFileSavingKey === null && workspaceDraftContent !== workspaceFileContent ? '#0D47A1' : '#94A3B8',
color: '#FFFFFF',
fontSize: 11,
fontWeight: 700,
cursor: isConnected && !isWorkspaceFileLoading && workspaceFileSavingKey === null && workspaceDraftContent !== workspaceFileContent ? 'pointer' : 'not-allowed'
}}
>
{workspaceFileSavingKey ? '保存中' : '保存文件'}
</button>
</div>
{workspaceFileFeedback && (
<span style={{
color: workspaceFileFeedback.type === 'success' ? '#00C853' : '#FF5252',
fontSize: 11,
fontFamily: '"Courier New", monospace'
}}>
{workspaceFileFeedback.text}
</span>
)}
</div>
</div>
</div>
</div>
</div>
{isSkillPickerOpen && createPortal((
<div
onClick={() => setIsSkillPickerOpen(false)}
style={{
position: 'fixed',
inset: 0,
background: 'rgba(15, 23, 42, 0.28)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 24,
zIndex: 9998
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
width: 'min(760px, 92vw)',
maxHeight: '80vh',
overflowY: 'auto',
borderRadius: 16,
border: '1px solid #D9E0E7',
background: '#FFFFFF',
boxShadow: '0 24px 60px rgba(15, 23, 42, 0.18)',
padding: 18,
paddingTop: 22,
display: 'grid',
gap: 16,
position: 'relative',
zIndex: 9999
}}
>
<button
type="button"
onClick={() => setIsSkillPickerOpen(false)}
style={{
position: 'absolute',
top: 16,
right: 16,
border: '1px solid #D0D7DE',
background: '#FFFFFF',
borderRadius: 999,
width: 40,
height: 40,
fontSize: 16,
lineHeight: 1,
color: '#111111',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
boxShadow: '0 4px 12px rgba(15, 23, 42, 0.08)'
}}
aria-label="关闭技能管理"
>
×
</button>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'center', paddingRight: 56 }}>
<div style={{ display: 'grid', gap: 4 }}>
<div style={{ fontSize: 14, fontWeight: 800, color: '#111111' }}>技能管理</div>
<div style={{ fontSize: 11, color: '#6B7280' }}>
{selectedAgent.name} 添加共享技能或创建本地技能
</div>
</div>
</div>
<div style={{
border: '1px solid #E5EAF1',
borderRadius: 12,
background: '#FCFDFE',
padding: 14,
display: 'grid',
gap: 10
}}>
<div style={{ fontSize: 12, fontWeight: 800, color: '#111111' }}>创建本地技能</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
value={newLocalSkillName}
onChange={(e) => setNewLocalSkillName(e.target.value)}
placeholder="输入技能名,例如 event_playbook"
style={{
flex: 1,
padding: '8px 10px',
borderRadius: 8,
border: '1px solid #D0D7DE',
background: '#FFFFFF',
color: '#111111',
fontSize: 11,
fontFamily: '"Courier New", monospace'
}}
/>
<button
type="button"
onClick={() => {
if (onCreateLocalSkill) {
onCreateLocalSkill(newLocalSkillName);
setNewLocalSkillName('');
}
}}
disabled={!isConnected || !newLocalSkillName.trim()}
style={{
padding: '8px 12px',
borderRadius: 8,
border: '1px solid #1565C0',
background: isConnected && newLocalSkillName.trim() ? '#EFF6FF' : '#E5E7EB',
color: '#1565C0',
fontSize: 11,
fontWeight: 700,
cursor: isConnected && newLocalSkillName.trim() ? 'pointer' : 'not-allowed',
whiteSpace: 'nowrap'
}}
>
创建
</button>
</div>
</div>
<div style={{
border: '1px solid #E5EAF1',
borderRadius: 12,
background: '#FCFDFE',
padding: 14,
display: 'grid',
gap: 10
}}>
<div style={{ fontSize: 12, fontWeight: 800, color: '#111111' }}>添加共享技能</div>
<div style={{
border: '1px solid #E5E7EB',
background: '#FFFFFF',
borderRadius: 8,
padding: '10px 12px',
display: 'grid',
gap: 10,
maxHeight: 360,
overflowY: 'auto'
}}>
{availableSkills.length === 0 ? (
<div style={{ fontSize: 11, color: '#6B7280' }}>没有可添加的共享技能</div>
) : availableSkills.map((skill) => {
const saving = agentSkillsSavingKey === `${selectedAgentId}:${skill.skill_name}`;
return (
<div
key={skill.skill_name}
style={{
display: 'flex',
justifyContent: 'space-between',
gap: 12,
alignItems: 'flex-start',
paddingBottom: 10,
borderBottom: '1px dashed #D7DEE7'
}}
>
<div style={{ display: 'grid', gap: 4 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 12, fontWeight: 700, color: '#111111' }}>
{skill.name || skill.skill_name}
</span>
<span style={{
padding: '2px 6px',
borderRadius: 999,
border: '1px solid #D0D7DE',
color: '#6B7280',
fontSize: 9,
fontWeight: 700
}}>
共享
</span>
</div>
<div style={{ fontSize: 11, color: '#4B5563' }}>
{skill.description || '-'}
</div>
</div>
<button
type="button"
onClick={() => onSkillToggle(skill.skill_name, true)}
disabled={!isConnected || saving}
style={{
padding: '7px 10px',
borderRadius: 6,
border: '1px solid #1565C0',
background: isConnected && !saving ? '#EFF6FF' : '#E5E7EB',
color: '#1565C0',
fontSize: 10,
fontWeight: 700,
cursor: isConnected && !saving ? 'pointer' : 'not-allowed',
whiteSpace: 'nowrap'
}}
>
{saving ? '处理中' : '添加'}
</button>
</div>
);
})}
</div>
</div>
</div>
</div>
), document.body)}
</div>
);
}