feat(multi-agent): 子智能体系统升级
- prompt改造:子智能体注入AGENTS.md/执行环境/工作区信息/skills/项目记忆 - 工具增加:read_skill/recall_project_memory/todo_create/todo_update_task/save_webpage - 上下文压缩:深度压缩机制,记录current_context_tokens,默认150k阈值可配置 - 模型升级:sub_agent_models.json支持thinkmode_status/extra_parameter,与主智能体对齐 - 角色管理三层结构:源码树预设multi_agent_roles/ + 运行态host/web预设 + web按用户隔离 - 启动同步:initialize_system调用sync_preset_roles同步预设到host和web运行态 - 模式判断:API用session.host_mode,工具用data_dir路径判断,不依赖IS_HOST_MODE - 前端:个人空间新增子智能体管理页(角色CRUD/压缩阈值/模型选择),复用个人空间样式 - 入口改造:登录页移除多智能体按钮,QuickMenu加模式切换项,运行中对话禁止切换 - 工具调整:多智能体模式create_sub_agent去掉timeout/deliverables_dir参数 - skill禁止:sub-agent-guide在多智能体模式下禁止阅读 - .agents/统一为.astrion/路径修复
This commit is contained in:
parent
3c4ae917b2
commit
4fcd0ccc4d
@ -1,6 +1,6 @@
|
||||
---
|
||||
name: sub-agent-guide
|
||||
description: 子智能体使用指南。创建子智能体之前必须阅读此技能。子智能体仅用于并行处理独立的“非代码编写”任务(如项目初步探索、网络搜索与信息收集、程序测试执行)。关键限制:子智能体之间无法通信,看不到主对话历史。严格禁止将子智能体用于项目代码编写、代码修改、重构或实现功能。
|
||||
description: 子智能体使用指南。创建子智能体之前必须阅读此技能。子智能体仅用于并行处理独立的“非代码编写”任务(如项目初步探索、网络搜索与信息收集、程序测试执行)。关键限制:子智能体之间无法通信,看不到主对话历史。严格禁止将子智能体用于项目代码编写、代码修改、重构或实现功能。**如果运行模式为多智能体模式,禁止阅读并参考该 skill。**
|
||||
---
|
||||
|
||||
# 子智能体使用指南
|
||||
|
||||
@ -161,6 +161,12 @@ HOST_PROJECT_PATH = _resolve_repo_path(os.environ.get("HOST_PROJECT_PATH", ""),
|
||||
# ── 源码树内的配置与资源(不随运行态根目录迁移)──
|
||||
PROMPTS_DIR = _resolve_repo_path(os.environ.get("PROMPTS_DIR", ""), "./prompts")
|
||||
AGENT_SKILLS_DIR = _resolve_repo_path(os.environ.get("AGENT_SKILLS_DIR", ""), "./agentskills")
|
||||
# 多智能体预设角色目录(源码树内,随版本分发)
|
||||
PRESET_ROLES_DIR = _resolve_repo_path(os.environ.get("PRESET_ROLES_DIR", ""), "./multi_agent_roles")
|
||||
# 运行态预设/自定义角色目录(host 模式:预设+自定义同目录;web 模式:仅预设)
|
||||
CUSTOM_ROLES_DIR = str(Path(RUNTIME_ROOT) / _MODE / "mutiagents" / "agents")
|
||||
# web 模式预设角色目录(web 模式下使用,host 模式下不用)
|
||||
WEB_PRESET_ROLES_DIR = str(Path(RUNTIME_ROOT) / "web" / "mutiagents" / "agents")
|
||||
WORKSPACE_SKILLS_DIRNAME = ".astrion/skills"
|
||||
WORKSPACE_MEMORY_DIRNAME = ".astrion/memory"
|
||||
WORKSPACE_REVIEW_DIRNAME = ".astrion/review"
|
||||
@ -198,6 +204,9 @@ __all__ = [
|
||||
"ADMIN_POLICY_FILE",
|
||||
"API_USER_SPACE_DIR",
|
||||
"CUSTOM_SKILLS_DIR",
|
||||
"PRESET_ROLES_DIR",
|
||||
"CUSTOM_ROLES_DIR",
|
||||
"WEB_PRESET_ROLES_DIR",
|
||||
"API_USERS_DB_FILE",
|
||||
"API_TOKENS_FILE",
|
||||
"API_USAGE_FILE",
|
||||
|
||||
@ -1740,11 +1740,18 @@ class MainTerminalToolsExecutionMixin:
|
||||
role_id = arguments.get("role_id")
|
||||
if not role_id:
|
||||
result = {"success": False, "error": "多智能体模式下 create_sub_agent 必须指定 role_id"}
|
||||
elif arguments.get("deliverables_dir"):
|
||||
result = {"success": False, "error": "多智能体模式下不支持交付目录参数"}
|
||||
else:
|
||||
try:
|
||||
from modules.multi_agent.role_store import load_preset_role
|
||||
from modules.multi_agent.role_store import load_preset_role, infer_custom_roles_dir
|
||||
from modules.multi_agent.prompts import build_multi_agent_sub_agent_prompt
|
||||
role = load_preset_role(role_id)
|
||||
_data_dir = str(getattr(self, "data_dir", ""))
|
||||
_custom_dir = infer_custom_roles_dir(_data_dir)
|
||||
# host模式下 custom_dir 就是 runtime_dir;web模式下 custom_dir 是用户个人目录
|
||||
_is_web = '/web/users/' in _data_dir
|
||||
_runtime_dir = None if _is_web else _custom_dir
|
||||
role = load_preset_role(role_id, runtime_dir=_runtime_dir, custom_dir=_custom_dir)
|
||||
if not role:
|
||||
result = {"success": False, "error": f"角色不存在: {role_id}"}
|
||||
else:
|
||||
@ -1756,21 +1763,41 @@ class MainTerminalToolsExecutionMixin:
|
||||
agent_id = multi_agent_state.next_agent_id_for_role(role_id)
|
||||
# 构造显示名
|
||||
display_name = role.display_name(int(agent_id))
|
||||
# 构造多智能体版系统提示词
|
||||
# 构造多智能体版系统提示词(含动态上下文注入)
|
||||
workspace_path = str(getattr(self, "project_path", ""))
|
||||
system_prompt = build_multi_agent_sub_agent_prompt(role.body_prompt, display_name, workspace_path)
|
||||
data_dir = str(getattr(self, "data_dir", ""))
|
||||
# 获取当前沙箱模式
|
||||
sandbox_mode = ""
|
||||
try:
|
||||
if hasattr(self, "get_execution_mode_state"):
|
||||
state = self.get_execution_mode_state() or {}
|
||||
sandbox_mode = str(state.get("mode") or "")
|
||||
except Exception:
|
||||
pass
|
||||
system_prompt = build_multi_agent_sub_agent_prompt(
|
||||
role.body_prompt, display_name, workspace_path,
|
||||
data_dir=data_dir,
|
||||
sandbox_mode=sandbox_mode,
|
||||
)
|
||||
# 构造 task_message(作为 Team Leader 的任务发布)
|
||||
from modules.multi_agent.state import build_master_dispatch_text
|
||||
task_message = build_master_dispatch_text(arguments.get("task", ""))
|
||||
summary_text = (arguments.get("summary") or f"{role.name}作业")[:80]
|
||||
thinking_mode = arguments.get("thinking_mode") or role.thinking_mode or "fast"
|
||||
# 读取子智能体压缩阈值配置
|
||||
_compress_threshold = 150_000
|
||||
try:
|
||||
from modules.personalization_manager import load_personalization_config
|
||||
_prefs = load_personalization_config(data_dir) or {}
|
||||
_compress_threshold = int(_prefs.get("sub_agent_compress_threshold_tokens", 150_000))
|
||||
except Exception:
|
||||
pass
|
||||
# 走原行 发事件创建(避免后期重建提供重复工能重费,直接使用 multi_agent_mode=True 调用)
|
||||
result = self.sub_agent_manager.create_sub_agent(
|
||||
agent_id=int(agent_id),
|
||||
summary=summary_text,
|
||||
task=arguments.get("task", ""),
|
||||
run_in_background=False,
|
||||
timeout_seconds=arguments.get("timeout_seconds"),
|
||||
conversation_id=conv_id,
|
||||
model_key=role.model_key,
|
||||
thinking_mode=thinking_mode,
|
||||
@ -1779,6 +1806,7 @@ class MainTerminalToolsExecutionMixin:
|
||||
display_name=display_name,
|
||||
system_prompt=system_prompt,
|
||||
task_message=task_message,
|
||||
compress_threshold_tokens=_compress_threshold,
|
||||
)
|
||||
# 在多智能体模式下,子智能体是团队协作成员,不是传统后台任务。
|
||||
# run_in_background=False 避免触发后台完成通知轮询,保持主对话输入区可用。
|
||||
@ -1972,23 +2000,33 @@ class MainTerminalToolsExecutionMixin:
|
||||
body_prompt = arguments.get("body_prompt", "").strip()
|
||||
description = arguments.get("description", "").strip()
|
||||
thinking_mode_arg = arguments.get("thinking_mode", "fast")
|
||||
model_key_arg = arguments.get("model_key", "").strip() or None
|
||||
if not role_id or not name or not body_prompt:
|
||||
result = {"success": False, "error": "role_id/name/body_prompt 必填"}
|
||||
else:
|
||||
existing_ids = {r.role_id for r in list_roles()}
|
||||
_data_dir = str(getattr(self, "data_dir", ""))
|
||||
from modules.multi_agent.role_store import infer_custom_roles_dir
|
||||
_custom_dir = infer_custom_roles_dir(_data_dir)
|
||||
_is_web = '/web/users/' in _data_dir
|
||||
_runtime_dir = None if _is_web else _custom_dir
|
||||
existing_ids = {r.role_id for r in list_roles(runtime_dir=_runtime_dir, custom_dir=_custom_dir)}
|
||||
if role_id in existing_ids:
|
||||
result = {"success": False, "error": f"角色 {role_id} 已存在"}
|
||||
else:
|
||||
role = RoleConfig(role_id=role_id, name=name, description=description, body_prompt=body_prompt, thinking_mode=thinking_mode_arg)
|
||||
f = save_custom_role(role)
|
||||
role = RoleConfig(role_id=role_id, name=name, description=description, body_prompt=body_prompt, thinking_mode=thinking_mode_arg, model_key=model_key_arg, is_custom=True)
|
||||
f = save_custom_role(role, custom_dir=_custom_dir)
|
||||
result = {"success": True, "role_id": role_id, "file": str(f)}
|
||||
except Exception as exc:
|
||||
result = {"success": False, "error": str(exc)}
|
||||
|
||||
elif tool_name == "list_agents":
|
||||
try:
|
||||
from modules.multi_agent.role_store import list_roles
|
||||
roles = list_roles()
|
||||
from modules.multi_agent.role_store import list_roles, infer_custom_roles_dir
|
||||
_data_dir = str(getattr(self, "data_dir", ""))
|
||||
_custom_dir = infer_custom_roles_dir(_data_dir)
|
||||
_is_web = '/web/users/' in _data_dir
|
||||
_runtime_dir = None if _is_web else _custom_dir
|
||||
roles = list_roles(runtime_dir=_runtime_dir, custom_dir=_custom_dir)
|
||||
result = {"success": True, "roles": [r.to_dict() for r in roles]}
|
||||
except Exception as exc:
|
||||
result = {"success": False, "error": str(exc)}
|
||||
|
||||
@ -123,8 +123,8 @@ class ApiUserManager:
|
||||
project_path = work_root / "project"
|
||||
data_dir = work_root / "data"
|
||||
logs_dir = work_root / "logs"
|
||||
uploads_dir = project_path / ".agents" / "user_upload"
|
||||
skills_dir = project_path / ".agents" / "skills"
|
||||
uploads_dir = project_path / ".astrion" / "user_upload"
|
||||
skills_dir = project_path / ".astrion" / "skills"
|
||||
|
||||
for path in (project_path, data_dir, logs_dir, uploads_dir, skills_dir, shared_dir, prompts_dir, personalization_dir):
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@ -64,13 +64,41 @@ def build_multi_agent_master_prompt(workspace_path: str, base: str = "") -> str:
|
||||
return f"{body}\n"
|
||||
|
||||
|
||||
def build_multi_agent_sub_agent_prompt(role_body: str, display_name: str, workspace_path: str) -> str:
|
||||
def build_multi_agent_sub_agent_prompt(
|
||||
role_body: str,
|
||||
display_name: str,
|
||||
workspace_path: str,
|
||||
*,
|
||||
data_dir: str = "",
|
||||
sandbox_mode: str = "",
|
||||
) -> str:
|
||||
"""构造子智能体的系统提示词。
|
||||
|
||||
`role_body` 为该角色 Markdown 文件 frontmatter 之后的自定义 prompt。
|
||||
`display_name` 为该实例的显示名(如 `UI Operator_1`)。
|
||||
`workspace_path` 为工作区路径,用于注入动态上下文。
|
||||
`data_dir` 为数据目录路径,用于推断 private skills 目录。
|
||||
`sandbox_mode` 为沙箱模式(sandbox/direct/host/docker)。
|
||||
|
||||
所有动态信息在子智能体创建那一刻快照写入,后续冻结不更新。
|
||||
"""
|
||||
return _format_template("sub_agent", display_name=display_name, role_body=role_body.strip())
|
||||
# 角色专属 prompt(模板正文)
|
||||
role_prompt = _format_template("sub_agent", display_name=display_name, role_body=role_body.strip())
|
||||
|
||||
# 动态上下文(AGENTS.md / 执行环境 / 工作区信息 / skills / 项目记忆等)
|
||||
try:
|
||||
from modules.multi_agent.sub_agent_context import build_sub_agent_dynamic_context
|
||||
dynamic_context = build_sub_agent_dynamic_context(
|
||||
workspace_path,
|
||||
data_dir=data_dir,
|
||||
sandbox_mode=sandbox_mode,
|
||||
)
|
||||
except Exception:
|
||||
dynamic_context = ""
|
||||
|
||||
if dynamic_context:
|
||||
return f"{role_prompt.rstrip()}\n\n---\n\n{dynamic_context}\n"
|
||||
return f"{role_prompt}\n"
|
||||
|
||||
|
||||
def build_available_agents_prompt() -> str:
|
||||
|
||||
@ -1,62 +1,136 @@
|
||||
"""多智能体角色存储与解析。
|
||||
|
||||
角色定义文件格式(Markdown + Frontmatter):
|
||||
三层结构:
|
||||
|
||||
---
|
||||
id: ui-operator
|
||||
name: UI Operator
|
||||
description: 界面设计与前端实现
|
||||
model: ""
|
||||
thinking_mode: fast
|
||||
skills:
|
||||
- frontend-design
|
||||
---
|
||||
1. **源码树预设**:``multi_agent_roles/``(随版本分发,只读种子)
|
||||
2. **运行态预设**:
|
||||
- host 模式 = ``host/mutiagents/agents/``(同时也是自定义目录)
|
||||
- web 模式 = ``web/mutiagents/agents/``
|
||||
3. **用户自定义**(仅 web 模式按用户隔离):
|
||||
- web 模式 = ``web/users/<user>/personal/mutiagents/agents/``
|
||||
- host 模式 = 和预设同目录
|
||||
|
||||
自定义 prompt body...
|
||||
启动时调用 `sync_preset_roles()` 把源码树预设同步到 host 和 web 两个运行态预设目录。
|
||||
|
||||
角色目录:
|
||||
- ~/.astrion/astrion/host/mutiagents/agents/<role_id>.md
|
||||
|
||||
存储约定见 .astrion/memory/multi_agent_mode_design.md。
|
||||
**重要**:本模块不再依赖 `IS_HOST_MODE` 判断运行模式。
|
||||
调用方(API / tools_execution)通过参数显式传入 `runtime_dir` 和 `custom_dir`:
|
||||
- 不传则仅扫描源码树预设(最小安全回退)
|
||||
- host 模式:`runtime_dir` 和 `custom_dir` 指向同一目录
|
||||
- web 模式:`runtime_dir` 指向 web 预设目录,`custom_dir` 指向用户个人目录
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
try:
|
||||
from config.paths import IS_HOST_MODE, RUNTIME_ROOT
|
||||
from config.paths import RUNTIME_ROOT, PRESET_ROLES_DIR, CUSTOM_ROLES_DIR, WEB_PRESET_ROLES_DIR
|
||||
except ImportError:
|
||||
# 防止直接 import 时 config 尚未加载,提供回退值
|
||||
IS_HOST_MODE = True
|
||||
RUNTIME_ROOT = str(Path.home() / ".astrion" / "astrion")
|
||||
|
||||
# 复用项目的 MUTIAGENTS 数据目录(保留原拼写 "mutiagents",见项目记忆)
|
||||
DEFAULT_MUTIAGENTS_DIR = Path(RUNTIME_ROOT) / ("host" if IS_HOST_MODE else "web") / "mutiagents"
|
||||
AGENTS_DIR_NAME = "agents"
|
||||
PRESET_ROLES_DIR = ""
|
||||
CUSTOM_ROLES_DIR = ""
|
||||
WEB_PRESET_ROLES_DIR = ""
|
||||
|
||||
FRONTMATTER_RE = re.compile(r"^---\s*\n(?P<body>.*?)\n---\s*\n(?P<rest>.*)$", re.S)
|
||||
|
||||
|
||||
def _agents_dir() -> Path:
|
||||
"""返回角色根目录(按运行模式自动选择 host/web)。"""
|
||||
base = Path(DEFAULT_MUTIAGENTS_DIR)
|
||||
out = base / AGENTS_DIR_NAME
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
return out
|
||||
# ----- 目录推断 -----
|
||||
|
||||
def _source_preset_dir() -> Path:
|
||||
"""源码树预设角色目录(只读种子)。"""
|
||||
return Path(PRESET_ROLES_DIR).expanduser().resolve()
|
||||
|
||||
|
||||
def _host_runtime_dir() -> Path:
|
||||
"""host 模式运行态目录(预设+自定义同目录)。"""
|
||||
return Path(CUSTOM_ROLES_DIR).expanduser().resolve()
|
||||
|
||||
|
||||
def _web_runtime_dir() -> Path:
|
||||
"""web 模式运行态预设目录。"""
|
||||
return Path(WEB_PRESET_ROLES_DIR).expanduser().resolve()
|
||||
|
||||
|
||||
def infer_custom_roles_dir(data_dir: str | Path | None) -> Optional[Path]:
|
||||
"""从工作区 data_dir 推断用户自定义角色目录。
|
||||
|
||||
- data_dir 包含 ``/web/users/`` → web 模式,返回 ``users/<user>/personal/mutiagents/agents``
|
||||
- 其他情况 → host 模式,返回 host 运行态目录
|
||||
"""
|
||||
if not data_dir:
|
||||
return _host_runtime_dir()
|
||||
data_path = str(Path(data_dir).expanduser().resolve())
|
||||
if "/web/users/" in data_path:
|
||||
# web 模式:按用户隔离
|
||||
dp = Path(data_path).resolve()
|
||||
# users/<user>/projects/<project_id>/data -> users/<user>/personal/mutiagents/agents
|
||||
if dp.name == "data" and dp.parent.parent.name == "projects":
|
||||
user_root = dp.parent.parent.parent
|
||||
return (user_root / "personal" / "mutiagents" / "agents").resolve()
|
||||
# 兼容旧/其他布局
|
||||
if dp.name == "data" and dp.parent.parent.name == "project":
|
||||
user_root = dp.parent.parent.parent
|
||||
return (user_root / "personal" / "mutiagents" / "agents").resolve()
|
||||
return (dp.parent / "mutiagents" / "agents").resolve()
|
||||
# host 模式
|
||||
return _host_runtime_dir()
|
||||
|
||||
|
||||
def _preset_role_ids() -> Set[str]:
|
||||
"""返回源码树预设角色 id 集合(用于区分自定义角色)。"""
|
||||
src = _source_preset_dir()
|
||||
if not src.exists():
|
||||
return set()
|
||||
return {p.stem for p in src.glob("*.md") if p.is_file()}
|
||||
|
||||
|
||||
def _ensure_dir(path: Path) -> Path:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
# ----- 启动同步 -----
|
||||
|
||||
def sync_preset_roles() -> None:
|
||||
"""启动时把源码树预设角色同步到 host 和 web 两个运行态预设目录。
|
||||
|
||||
- 目录不存在则创建并复制全部预设角色。
|
||||
- 文件已存在则不覆盖(可能是用户已改过的运行态副本)。
|
||||
"""
|
||||
src = _source_preset_dir()
|
||||
if not src.exists() or not src.is_dir():
|
||||
return
|
||||
|
||||
preset_files = {p: p.name for p in src.glob("*.md") if p.is_file()}
|
||||
if not preset_files:
|
||||
return
|
||||
|
||||
rt_root = Path(RUNTIME_ROOT).expanduser().resolve()
|
||||
for target_dir in [_host_runtime_dir(), _web_runtime_dir()]:
|
||||
target = target_dir.expanduser().resolve()
|
||||
# 安全检查:目标必须在运行态根下
|
||||
try:
|
||||
target.relative_to(rt_root)
|
||||
except ValueError:
|
||||
continue
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
for src_file, name in preset_files.items():
|
||||
dst = target / name
|
||||
if not dst.exists():
|
||||
shutil.copy2(src_file, dst)
|
||||
|
||||
|
||||
# ----- Frontmatter 解析 -----
|
||||
|
||||
def _parse_frontmatter(text: str) -> tuple[Dict[str, Any], str]:
|
||||
"""解析 Markdown frontmatter,返回 (元数据 dict, body)。"""
|
||||
m = FRONTMATTER_RE.match(text)
|
||||
if not m:
|
||||
return {}, text
|
||||
raw_meta = m.group("body") or ""
|
||||
body = m.group("rest") or ""
|
||||
meta: Dict[str, Any] = {}
|
||||
# 简易解析(k: v 与 k: 列表项,不引入 yaml 依赖)
|
||||
current_key: Optional[str] = None
|
||||
for line in raw_meta.splitlines():
|
||||
if not line.strip():
|
||||
@ -74,14 +148,74 @@ def _parse_frontmatter(text: str) -> tuple[Dict[str, Any], str]:
|
||||
meta[k] = v
|
||||
current_key = None
|
||||
else:
|
||||
# 可能是列表块
|
||||
meta[k] = []
|
||||
current_key = k
|
||||
return meta, body.strip()
|
||||
|
||||
|
||||
def load_role_from_file(file_path: Path) -> Optional["RoleConfig"]:
|
||||
"""从 Markdown 文件加载角色定义。"""
|
||||
# ----- RoleConfig -----
|
||||
|
||||
class RoleConfig:
|
||||
"""一个多智能体角色配置。"""
|
||||
|
||||
__slots__ = (
|
||||
"role_id",
|
||||
"name",
|
||||
"description",
|
||||
"body_prompt",
|
||||
"model_key",
|
||||
"thinking_mode",
|
||||
"skills",
|
||||
"source_file",
|
||||
"is_custom",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
role_id: str,
|
||||
name: str,
|
||||
description: str = "",
|
||||
body_prompt: str = "",
|
||||
model_key: Optional[str] = None,
|
||||
thinking_mode: str = "fast",
|
||||
skills: Optional[List[str]] = None,
|
||||
source_file: str = "",
|
||||
is_custom: bool = False,
|
||||
):
|
||||
self.role_id = role_id
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.body_prompt = body_prompt
|
||||
self.model_key = model_key
|
||||
self.thinking_mode = thinking_mode or "fast"
|
||||
self.skills = list(skills or [])
|
||||
self.source_file = source_file
|
||||
self.is_custom = is_custom
|
||||
|
||||
def display_name(self, agent_id: int) -> str:
|
||||
return f"{self.name}_{agent_id}"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"role_id": self.role_id,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"model_key": self.model_key,
|
||||
"thinking_mode": self.thinking_mode,
|
||||
"skills": self.skills,
|
||||
"body_prompt": self.body_prompt,
|
||||
"source_file": self.source_file,
|
||||
"is_custom": self.is_custom,
|
||||
}
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<RoleConfig id={self.role_id} name={self.name} custom={self.is_custom}>"
|
||||
|
||||
|
||||
# ----- 文件级加载 -----
|
||||
|
||||
def load_role_from_file(file_path: Path, is_custom: bool = False) -> Optional[RoleConfig]:
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
return None
|
||||
text = file_path.read_text(encoding="utf-8")
|
||||
@ -97,43 +231,17 @@ def load_role_from_file(file_path: Path) -> Optional["RoleConfig"]:
|
||||
thinking_mode=str(meta.get("thinking_mode") or "fast"),
|
||||
skills=list(meta.get("skills") or []),
|
||||
source_file=str(file_path),
|
||||
is_custom=is_custom,
|
||||
)
|
||||
|
||||
|
||||
def load_preset_role(role_id: str) -> Optional["RoleConfig"]:
|
||||
"""从预设/自定义角色目录加载一个角色。"""
|
||||
f = _agents_dir() / f"{role_id}.md"
|
||||
return load_role_from_file(f)
|
||||
# ----- 序列化 -----
|
||||
|
||||
|
||||
def list_roles() -> List["RoleConfig"]:
|
||||
"""列出全部角色(预设 + 用户自定义)。"""
|
||||
agents_dir = _agents_dir()
|
||||
if not agents_dir.exists():
|
||||
return []
|
||||
roles: List[RoleConfig] = []
|
||||
for p in sorted(agents_dir.glob("*.md")):
|
||||
r = load_role_from_file(p)
|
||||
if r and r.role_id:
|
||||
roles.append(r)
|
||||
return roles
|
||||
|
||||
|
||||
def save_custom_role(role: "RoleConfig") -> Path:
|
||||
"""把自定义角色保存到 agents 目录。"""
|
||||
f = _agents_dir() / f"{role.role_id}.md"
|
||||
f.parent.mkdir(parents=True, exist_ok=True)
|
||||
f.write_text(_serialize_role(role), encoding="utf-8")
|
||||
return f
|
||||
|
||||
|
||||
def _serialize_role(role: "RoleConfig") -> str:
|
||||
"""把 RoleConfig 序列化为 Markdown frontmatter 字符串。"""
|
||||
def _serialize_role(role: RoleConfig) -> str:
|
||||
lines = ["---"]
|
||||
lines.append(f"id: {role.role_id}")
|
||||
lines.append(f"name: {role.name}")
|
||||
if role.description:
|
||||
# 单行描述转义
|
||||
desc = role.description.replace('"', '\\"')
|
||||
lines.append(f'description: "{desc}"')
|
||||
if role.model_key:
|
||||
@ -149,60 +257,142 @@ def _serialize_role(role: "RoleConfig") -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_role_system_prompt(role: "RoleConfig") -> str:
|
||||
# ----- 目录级加载 -----
|
||||
|
||||
def _scan_roles_dir(dir_path: Path, is_custom: bool = False) -> List[RoleConfig]:
|
||||
if not dir_path.exists() or not dir_path.is_dir():
|
||||
return []
|
||||
roles: List[RoleConfig] = []
|
||||
for p in sorted(dir_path.glob("*.md")):
|
||||
r = load_role_from_file(p, is_custom=is_custom)
|
||||
if r and r.role_id:
|
||||
roles.append(r)
|
||||
return roles
|
||||
|
||||
|
||||
def _merge_roles(roles_lists: List[List[RoleConfig]]) -> List[RoleConfig]:
|
||||
"""合并多组角色,后者覆盖前者(同名 role_id 后者优先)。"""
|
||||
merged: Dict[str, RoleConfig] = {}
|
||||
order: List[str] = []
|
||||
for roles in roles_lists:
|
||||
for r in roles:
|
||||
if r.role_id not in merged:
|
||||
order.append(r.role_id)
|
||||
merged[r.role_id] = r
|
||||
return [merged[rid] for rid in order if rid in merged]
|
||||
|
||||
|
||||
def list_roles(
|
||||
runtime_dir: Optional[str | Path] = None,
|
||||
custom_dir: Optional[str | Path] = None,
|
||||
) -> List[RoleConfig]:
|
||||
"""列出全部角色。
|
||||
|
||||
合并顺序(后者覆盖前者):
|
||||
1. 运行态预设目录
|
||||
2. 用户自定义目录(web 模式才有;host 模式与预设同目录,传一个即可)
|
||||
|
||||
``is_custom`` 标记:不在源码树预设名单中的角色 = 自定义。
|
||||
|
||||
调用方应显式传入 ``custom_dir``:
|
||||
- host 模式:传 `_host_runtime_dir()`(或省略,自动回退)
|
||||
- web 模式:传入用户个人目录
|
||||
"""
|
||||
# host 模式回退:不传任何目录时使用 host 运行态目录
|
||||
if not runtime_dir and not custom_dir:
|
||||
runtime_dir = _host_runtime_dir()
|
||||
|
||||
rt_dir = Path(runtime_dir).expanduser().resolve() if runtime_dir else None
|
||||
c_dir = Path(custom_dir).expanduser().resolve() if custom_dir else None
|
||||
|
||||
preset_ids = _preset_role_ids()
|
||||
|
||||
# 只有一个目录(host 模式或 web 模式只传了 runtime_dir)
|
||||
if not c_dir or (rt_dir and rt_dir == c_dir):
|
||||
scan_dir = rt_dir or c_dir
|
||||
if scan_dir:
|
||||
all_roles = _scan_roles_dir(scan_dir)
|
||||
else:
|
||||
all_roles = []
|
||||
for r in all_roles:
|
||||
r.is_custom = r.role_id not in preset_ids
|
||||
return all_roles
|
||||
|
||||
# web 模式:合并运行态预设 + 用户自定义
|
||||
preset_roles = _scan_roles_dir(rt_dir, is_custom=False) if rt_dir else []
|
||||
custom_roles = _scan_roles_dir(c_dir, is_custom=True)
|
||||
merged = _merge_roles([preset_roles, custom_roles])
|
||||
return merged
|
||||
|
||||
|
||||
def load_preset_role(
|
||||
role_id: str,
|
||||
runtime_dir: Optional[str | Path] = None,
|
||||
custom_dir: Optional[str | Path] = None,
|
||||
) -> Optional[RoleConfig]:
|
||||
"""加载一个角色。先查用户自定义目录,再查运行态预设目录。"""
|
||||
rt_dir = Path(runtime_dir).expanduser().resolve() if runtime_dir else None
|
||||
c_dir = Path(custom_dir).expanduser().resolve() if custom_dir else None
|
||||
preset_ids = _preset_role_ids()
|
||||
|
||||
# host 模式回退
|
||||
if not rt_dir and not c_dir:
|
||||
rt_dir = _host_runtime_dir()
|
||||
|
||||
# 先查自定义目录(web 模式且与 runtime_dir 不同)
|
||||
if c_dir and rt_dir and c_dir != rt_dir:
|
||||
f = c_dir / f"{role_id}.md"
|
||||
r = load_role_from_file(f, is_custom=True)
|
||||
if r:
|
||||
return r
|
||||
|
||||
# 再查运行态/混合目录
|
||||
scan_dir = rt_dir or c_dir
|
||||
if scan_dir:
|
||||
f = scan_dir / f"{role_id}.md"
|
||||
r = load_role_from_file(f)
|
||||
if r:
|
||||
r.is_custom = r.role_id not in preset_ids
|
||||
return r
|
||||
return None
|
||||
|
||||
|
||||
def save_custom_role(
|
||||
role: RoleConfig,
|
||||
custom_dir: Optional[str | Path] = None,
|
||||
) -> Path:
|
||||
"""保存角色到自定义目录。不传 custom_dir 时回退到 host 运行态目录。"""
|
||||
if custom_dir:
|
||||
c_dir = Path(custom_dir).expanduser().resolve()
|
||||
else:
|
||||
c_dir = _host_runtime_dir()
|
||||
_ensure_dir(c_dir)
|
||||
f = c_dir / f"{role.role_id}.md"
|
||||
f.write_text(_serialize_role(role), encoding="utf-8")
|
||||
return f
|
||||
|
||||
|
||||
def delete_custom_role(
|
||||
role_id: str,
|
||||
custom_dir: Optional[str | Path] = None,
|
||||
) -> bool:
|
||||
"""删除用户自定义角色。不传 custom_dir 时回退到 host 运行态目录。"""
|
||||
if custom_dir:
|
||||
c_dir = Path(custom_dir).expanduser().resolve()
|
||||
else:
|
||||
c_dir = _host_runtime_dir()
|
||||
f = c_dir / f"{role_id}.md"
|
||||
if not f.exists():
|
||||
return False
|
||||
f.unlink(missing_ok=True)
|
||||
return True
|
||||
|
||||
|
||||
def is_preset_role(role_id: str) -> bool:
|
||||
"""判断角色是否为预设角色(源码树种子中存在)。"""
|
||||
return role_id in _preset_role_ids()
|
||||
|
||||
|
||||
def build_role_system_prompt(role: RoleConfig) -> str:
|
||||
"""生成子智能体的「专属 prompt」部分(frontmatter 之后的 body)。"""
|
||||
return role.body_prompt or ""
|
||||
|
||||
|
||||
class RoleConfig:
|
||||
"""一个多智能体角色配置。"""
|
||||
|
||||
__slots__ = (
|
||||
"role_id",
|
||||
"name",
|
||||
"description",
|
||||
"body_prompt",
|
||||
"model_key",
|
||||
"thinking_mode",
|
||||
"skills",
|
||||
"source_file",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
role_id: str,
|
||||
name: str,
|
||||
description: str = "",
|
||||
body_prompt: str = "",
|
||||
model_key: Optional[str] = None,
|
||||
thinking_mode: str = "fast",
|
||||
skills: Optional[List[str]] = None,
|
||||
source_file: str = "",
|
||||
):
|
||||
self.role_id = role_id
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.body_prompt = body_prompt
|
||||
self.model_key = model_key
|
||||
self.thinking_mode = thinking_mode or "fast"
|
||||
self.skills = list(skills or [])
|
||||
self.source_file = source_file
|
||||
|
||||
def display_name(self, agent_id: int) -> str:
|
||||
"""根据实例 id 生成显示名。"""
|
||||
return f"{self.name}_{agent_id}"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"role_id": self.role_id,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"model_key": self.model_key,
|
||||
"thinking_mode": self.thinking_mode,
|
||||
"skills": self.skills,
|
||||
"source_file": self.source_file,
|
||||
}
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<RoleConfig id={self.role_id} name={self.name}>"
|
||||
return role.body_prompt or ""
|
||||
260
modules/multi_agent/sub_agent_context.py
Normal file
260
modules/multi_agent/sub_agent_context.py
Normal file
@ -0,0 +1,260 @@
|
||||
"""多智能体子智能体动态上下文构建。
|
||||
|
||||
在子智能体创建时,为其系统提示词注入工作区相关信息:
|
||||
- 执行环境(宿主机/docker + 沙箱/直接)
|
||||
- AGENTS.md 项目规范
|
||||
- 工作区信息(项目名称、运行环境、当前时间,不含 file_tree / recent_uploads)
|
||||
- .astrion/ 目录结构说明(skills / memory / review / user_upload)
|
||||
- 可用 skill 列表
|
||||
- 项目记忆索引
|
||||
|
||||
所有信息在子智能体创建那一刻快照写入 system_prompt.txt,后续冻结不更新。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import platform
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
try:
|
||||
from config import (
|
||||
PROMPTS_DIR,
|
||||
WORKSPACE_SKILLS_DIRNAME,
|
||||
WORKSPACE_MEMORY_DIRNAME,
|
||||
WORKSPACE_REVIEW_DIRNAME,
|
||||
TERMINAL_SANDBOX_MODE,
|
||||
)
|
||||
from config.paths import IS_HOST_MODE
|
||||
except ImportError:
|
||||
import sys
|
||||
|
||||
project_root = Path(__file__).resolve().parents[2]
|
||||
if str(project_root) not in sys.path:
|
||||
sys.path.insert(0, str(project_root))
|
||||
from config import (
|
||||
PROMPTS_DIR,
|
||||
WORKSPACE_SKILLS_DIRNAME,
|
||||
WORKSPACE_MEMORY_DIRNAME,
|
||||
WORKSPACE_REVIEW_DIRNAME,
|
||||
TERMINAL_SANDBOX_MODE,
|
||||
)
|
||||
from config.paths import IS_HOST_MODE
|
||||
|
||||
|
||||
def _load_agents_md(workspace_path: str) -> Optional[str]:
|
||||
"""加载工作区根目录的 AGENTS.md 文件内容。"""
|
||||
try:
|
||||
project_path = Path(workspace_path)
|
||||
if not project_path.exists():
|
||||
return None
|
||||
agents_md_files = list(project_path.rglob("AGENTS.md"))
|
||||
if not agents_md_files:
|
||||
return None
|
||||
latest_file = max(agents_md_files, key=lambda p: p.stat().st_mtime)
|
||||
content = latest_file.read_text(encoding="utf-8")
|
||||
return content.strip() if content else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _build_execution_env_section(workspace_path: str, *, sandbox_mode: str = "") -> str:
|
||||
"""构建执行环境信息段(精简版,只含宿主机/docker + 沙箱/直接)。"""
|
||||
mode = (sandbox_mode or TERMINAL_SANDBOX_MODE or "").lower()
|
||||
if IS_HOST_MODE:
|
||||
runtime_label = "宿主机模式"
|
||||
# host 模式下区分 sandbox / direct
|
||||
if mode == "direct":
|
||||
exec_label = "直接执行(完全访问权限)"
|
||||
rules = (
|
||||
"- 当前为宿主机直接执行模式\n"
|
||||
"- 仅在必须时执行高权限操作,保持最小化命令范围\n"
|
||||
"- 涉及删除/覆盖/系统级变更前,先说明风险再执行"
|
||||
)
|
||||
else:
|
||||
exec_label = "沙箱执行"
|
||||
rules = (
|
||||
"- 所有命令默认在系统 OS 沙箱中执行\n"
|
||||
"- 若操作受系统权限限制:先提供安全替代方案;若仍无法满足,说明需要更高权限\n"
|
||||
"- 不要通过复杂绕过手段规避沙箱限制"
|
||||
)
|
||||
else:
|
||||
runtime_label = "Docker 容器模式"
|
||||
exec_label = "容器内执行"
|
||||
rules = (
|
||||
"- 所有命令在 Docker 容器内执行\n"
|
||||
"- 工作区挂载在容器内 /workspace 路径下\n"
|
||||
"- 网络可能受限,仅允许 localhost"
|
||||
)
|
||||
return (
|
||||
f"## 执行环境\n\n"
|
||||
f"- 运行环境:{runtime_label}\n"
|
||||
f"- 执行方式:{exec_label}\n\n"
|
||||
f"### 当前规则\n{rules}\n"
|
||||
)
|
||||
|
||||
|
||||
def _build_workspace_section(workspace_path: str) -> str:
|
||||
"""构建工作区信息段(精简版,不含 file_tree 和 recent_uploads)。"""
|
||||
project_path = Path(workspace_path)
|
||||
project_name = project_path.name or "未命名项目"
|
||||
system_info = f"{platform.system()} {platform.release()}"
|
||||
current_time = datetime.now().strftime("%Y年%m月%d日 %H点(24小时制)")
|
||||
|
||||
if IS_HOST_MODE:
|
||||
resource_limit = "宿主机模式无限制"
|
||||
else:
|
||||
resource_limit = "容器模式,受 Docker 资源限制"
|
||||
|
||||
return (
|
||||
f"## 工作区信息\n\n"
|
||||
f"- 项目名称:{project_name}\n"
|
||||
f"- 运行环境:{system_info}\n"
|
||||
f"- 资源限制:{resource_limit}\n"
|
||||
f"- 当前时间:{current_time}\n"
|
||||
)
|
||||
|
||||
|
||||
def _build_astrion_dir_section(workspace_path: str) -> str:
|
||||
"""构建 .astrion/ 目录结构说明段。"""
|
||||
return (
|
||||
f"## 工作区数据目录(.astrion/)\n\n"
|
||||
f"工作区内的 `.astrion/` 目录存储以下内容:\n"
|
||||
f"- `{WORKSPACE_SKILLS_DIRNAME}/`:可用技能(skill)目录,每个 skill 含 SKILL.md 说明\n"
|
||||
f"- `{WORKSPACE_MEMORY_DIRNAME}/`:项目记忆,以 Markdown 文件形式存放项目约定/决策/调试坑\n"
|
||||
f"- `{WORKSPACE_REVIEW_DIRNAME}/`:对话回顾文件\n"
|
||||
f"- `user_upload/`:用户上传的文件\n\n"
|
||||
f"用户上传的文件位于 `.astrion/user_upload/` 目录下。"
|
||||
f"项目记忆以文件形式存放在 `{WORKSPACE_MEMORY_DIRNAME}/` 下。"
|
||||
f"技能(skill)存放在 `{WORKSPACE_SKILLS_DIRNAME}/` 下。\n"
|
||||
)
|
||||
|
||||
|
||||
def _build_agents_md_section(workspace_path: str) -> str:
|
||||
"""构建 AGENTS.md 项目规范段。"""
|
||||
content = _load_agents_md(workspace_path)
|
||||
if not content:
|
||||
return ""
|
||||
return (
|
||||
f"## AGENTS.md 项目规范\n\n"
|
||||
f"> 以下是工作区根目录的 AGENTS.md 文件内容,请在回答时参考这些项目规范:\n\n"
|
||||
f"{content}\n\n"
|
||||
f"---\n"
|
||||
f"注意:以上规范来自工作区根目录的 AGENTS.md 文件,若与未来代码冲突,以实际代码为准。\n"
|
||||
)
|
||||
|
||||
|
||||
def _build_skills_section(workspace_path: str, data_dir: str = "") -> str:
|
||||
"""构建可用 skill 列表段。"""
|
||||
try:
|
||||
from modules.skills_manager import (
|
||||
get_skills_catalog,
|
||||
build_skills_list,
|
||||
infer_private_skills_dir,
|
||||
)
|
||||
|
||||
private_dir = infer_private_skills_dir(data_dir) if data_dir else None
|
||||
catalog = get_skills_catalog(private_dir=private_dir)
|
||||
# 子智能体不区分 enabled/disabled,列出全部可用 skill
|
||||
skills_list = build_skills_list(catalog, enabled=None)
|
||||
if not skills_list:
|
||||
return ""
|
||||
return (
|
||||
f"## 可用 AgentSkill\n\n"
|
||||
f"agent skills 系统已启用,以下是可用的 skills(含简要说明):\n\n"
|
||||
f"{skills_list}\n\n"
|
||||
f"使用技能时,优先用 read_skill 通过技能名读取 SKILL.md;"
|
||||
f"需要阅读 skill 目录中的其他文件时再使用 read_file\n"
|
||||
)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _build_project_memory_section(workspace_path: str) -> str:
|
||||
"""构建项目记忆索引段(只列索引,不展开内容)。"""
|
||||
try:
|
||||
memory_dir = Path(workspace_path) / WORKSPACE_MEMORY_DIRNAME
|
||||
if not memory_dir.exists() or not memory_dir.is_dir():
|
||||
return ""
|
||||
entries: List[str] = []
|
||||
for md_file in sorted(memory_dir.glob("*.md")):
|
||||
try:
|
||||
text = md_file.read_text(encoding="utf-8")
|
||||
name = None
|
||||
description = None
|
||||
if text.startswith("---"):
|
||||
end = text.find("---", 3)
|
||||
if end > 0:
|
||||
frontmatter = text[3:end]
|
||||
for line in frontmatter.strip().split("\n"):
|
||||
ls = line.strip()
|
||||
if ls.startswith("name:"):
|
||||
name = ls.split(":", 1)[1].strip()
|
||||
elif ls.startswith("description:"):
|
||||
description = ls.split(":", 1)[1].strip()
|
||||
file_name = md_file.name
|
||||
display_name = name or md_file.stem
|
||||
desc = description or ""
|
||||
if desc:
|
||||
entries.append(f".astrion/memory/{file_name}:{desc}")
|
||||
else:
|
||||
entries.append(f".astrion/memory/{file_name}:当需要{display_name}时,应该索引本记忆")
|
||||
except Exception:
|
||||
pass
|
||||
if not entries:
|
||||
return ""
|
||||
return (
|
||||
f"## 项目记忆\n\n"
|
||||
f"项目记忆以文件形式存放在 `.astrion/memory/` 下。\n"
|
||||
f"开始涉及项目约定、历史决策或已知问题的任务前,先检查项目记忆索引,"
|
||||
f"如有相关记忆则用 recall_project_memory 读取。\n\n"
|
||||
f"### 项目记忆索引\n\n"
|
||||
+ "\n".join(entries)
|
||||
+ "\n"
|
||||
)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def build_sub_agent_dynamic_context(
|
||||
workspace_path: str,
|
||||
*,
|
||||
data_dir: str = "",
|
||||
sandbox_mode: str = "",
|
||||
) -> str:
|
||||
"""构建子智能体的动态上下文信息(拼接到 system prompt 末尾)。
|
||||
|
||||
所有信息在子智能体创建那一刻快照,后续冻结不更新。
|
||||
|
||||
Args:
|
||||
workspace_path: 工作区路径
|
||||
data_dir: 数据目录路径(用于推断 private skills 目录)
|
||||
sandbox_mode: 沙箱模式(sandbox/direct/host/docker)
|
||||
"""
|
||||
sections: List[str] = []
|
||||
|
||||
# 1. 执行环境
|
||||
sections.append(_build_execution_env_section(workspace_path, sandbox_mode=sandbox_mode))
|
||||
|
||||
# 2. 工作区信息(精简版)
|
||||
sections.append(_build_workspace_section(workspace_path))
|
||||
|
||||
# 3. .astrion/ 目录说明
|
||||
sections.append(_build_astrion_dir_section(workspace_path))
|
||||
|
||||
# 4. AGENTS.md
|
||||
agents_md = _build_agents_md_section(workspace_path)
|
||||
if agents_md:
|
||||
sections.append(agents_md)
|
||||
|
||||
# 5. 可用 skill
|
||||
skills = _build_skills_section(workspace_path, data_dir=data_dir)
|
||||
if skills:
|
||||
sections.append(skills)
|
||||
|
||||
# 6. 项目记忆索引
|
||||
memory = _build_project_memory_section(workspace_path)
|
||||
if memory:
|
||||
sections.append(memory)
|
||||
|
||||
return "\n".join(sections)
|
||||
@ -53,10 +53,6 @@ def _master_tool_create_sub_agent() -> Dict[str, Any]:
|
||||
"type": "integer",
|
||||
"description": "(可选)手动指定实例编号;不传时自动递增。",
|
||||
},
|
||||
"timeout_seconds": {
|
||||
"type": "integer",
|
||||
"description": "(可选)超时秒数。不填表示该子智能体不会被时间终结,适用于可能多轮长期任务的子智能体;一般情况下推荐创建永久子智能体。",
|
||||
},
|
||||
"thinking_mode": {
|
||||
"type": "string",
|
||||
"enum": ["fast", "thinking"],
|
||||
|
||||
@ -19,9 +19,7 @@ from config import (
|
||||
SUB_AGENT_DEFAULT_TIMEOUT,
|
||||
SUB_AGENT_MAX_ACTIVE,
|
||||
SUB_AGENT_MODELS_CONFIG_FILE,
|
||||
SUB_AGENT_STATE_FILE,
|
||||
SUB_AGENT_STATUS_POLL_INTERVAL,
|
||||
SUB_AGENT_TASKS_BASE_DIR,
|
||||
)
|
||||
from utils.logger import setup_logger
|
||||
from modules.sub_agent.task import SubAgentTask
|
||||
@ -51,8 +49,9 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi
|
||||
):
|
||||
self.project_path = Path(project_path).resolve()
|
||||
self.data_dir = Path(data_dir).resolve()
|
||||
self.base_dir = Path(SUB_AGENT_TASKS_BASE_DIR).resolve()
|
||||
self.state_file = Path(SUB_AGENT_STATE_FILE).resolve()
|
||||
# 子智能体任务和状态按 data_dir 隔离(web 模式下按用户/工作区自动隔离)
|
||||
self.base_dir = self.data_dir / "sub_agent_tasks"
|
||||
self.state_file = self.data_dir / "sub_agents.json"
|
||||
self.models_config_file = SUB_AGENT_MODELS_CONFIG_FILE
|
||||
self.container_session: Optional["ContainerHandle"] = container_session
|
||||
self.host_execution_mode: str = "sandbox"
|
||||
@ -153,6 +152,7 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi
|
||||
display_name: Optional[str] = None,
|
||||
system_prompt: Optional[str] = None,
|
||||
task_message: Optional[str] = None,
|
||||
compress_threshold_tokens: Optional[int] = None,
|
||||
) -> Dict:
|
||||
"""创建子智能体任务并启动协程。
|
||||
|
||||
@ -239,6 +239,7 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi
|
||||
"role_id": role_id,
|
||||
"display_name": display_name,
|
||||
"execution_mode": "in_process",
|
||||
"compress_threshold_tokens": compress_threshold_tokens,
|
||||
"container_name": None,
|
||||
}
|
||||
self.tasks[task_id] = task_record
|
||||
|
||||
@ -73,6 +73,14 @@ class SubAgentTask:
|
||||
self.stats_file = Path(task_record["stats_file"])
|
||||
self.progress_file = Path(task_record["progress_file"])
|
||||
self.conversation_file = Path(task_record["conversation_file"])
|
||||
# system_prompt.txt 路径(冻结的 system prompt,压缩后从此重建)
|
||||
self.system_prompt_file = Path(task_record.get("task_root", "")) / "system_prompt.txt"
|
||||
|
||||
# 上下文压缩配置
|
||||
# 默认阈值 150k tokens,可由外部覆盖(如个人空间子智能体管理配置)
|
||||
self.compress_threshold_tokens: int = int(task_record.get("compress_threshold_tokens") or 150_000)
|
||||
self.current_context_tokens: int = 0
|
||||
self._compress_round: int = 0
|
||||
|
||||
self.messages: List[Dict[str, Any]] = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
@ -89,6 +97,8 @@ class SubAgentTask:
|
||||
"commands": 0,
|
||||
"api_calls": 0,
|
||||
"token_usage": {"prompt": 0, "completion": 0, "total": 0},
|
||||
"current_context_tokens": 0,
|
||||
"compress_round": 0,
|
||||
}
|
||||
self._stdout_lines: List[str] = []
|
||||
self._cancelled = False
|
||||
@ -222,6 +232,21 @@ class SubAgentTask:
|
||||
if usage:
|
||||
self._apply_usage(usage)
|
||||
|
||||
# 上下文压缩检查:超过阈值时触发深度压缩
|
||||
if self.current_context_tokens > 0 and self.current_context_tokens >= self.compress_threshold_tokens:
|
||||
compressed = self._deep_compress_messages()
|
||||
if compressed:
|
||||
ma_debug(
|
||||
"sub_agent_deep_compressed",
|
||||
task_id=self.task_id,
|
||||
agent_id=self.agent_id,
|
||||
display_name=self.display_name,
|
||||
tokens_before=self.current_context_tokens,
|
||||
messages_before=len(self.messages) + 1, # +1 因为本转 assistant 还没 append
|
||||
messages_after=len(self.messages),
|
||||
compress_round=self._compress_round,
|
||||
)
|
||||
|
||||
# 多智能体模式:把 assistant 文本输出作为进度/完成 output 转发到主对话
|
||||
if self.multi_agent_mode and self.multi_agent_state and assistant_message.strip():
|
||||
self._forward_output_to_master(assistant_message, is_final=not tool_calls)
|
||||
@ -591,7 +616,7 @@ class SubAgentTask:
|
||||
self.stats["edit_files"] += 1
|
||||
elif name == "search_workspace":
|
||||
self.stats["searches"] += 1
|
||||
elif name in ("web_search", "extract_webpage"):
|
||||
elif name in ("web_search", "extract_webpage", "save_webpage"):
|
||||
self.stats["web_pages"] += 1
|
||||
elif name == "run_command":
|
||||
self.stats["commands"] += 1
|
||||
@ -605,9 +630,156 @@ class SubAgentTask:
|
||||
self.stats["token_usage"]["prompt"] += int(prompt)
|
||||
self.stats["token_usage"]["completion"] += int(completion)
|
||||
self.stats["token_usage"]["total"] += int(total)
|
||||
# prompt_tokens 即为当前上下文占用的 tokens
|
||||
self.current_context_tokens = int(prompt)
|
||||
self.stats["current_context_tokens"] = int(prompt)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _rebuild_system_prompt(self) -> str:
|
||||
"""从冻结的 system_prompt.txt 重新读取 system prompt(压缩后重建用)。"""
|
||||
try:
|
||||
if self.system_prompt_file.exists():
|
||||
return self.system_prompt_file.read_text(encoding="utf-8")
|
||||
except Exception as exc:
|
||||
logger.warning(f"[SubAgentTask] 重建 system prompt 失败: {exc}")
|
||||
# 兜底用内存中的
|
||||
return self.system_prompt
|
||||
|
||||
def _deep_compress_messages(self) -> bool:
|
||||
"""深度压缩:把旧消息总结成一条 system 消息,重建 system prompt。
|
||||
|
||||
策略(参考主智能体 compress_conversation):
|
||||
1. 保留冻结的 system prompt(从 system_prompt.txt 读取)
|
||||
2. 把 system 之后、最近 N 条消息之前的所有消息压缩成一条 system 摘要
|
||||
3. 保留最近的若干条消息(含 tool_calls 配对)不动
|
||||
4. 重置 current_context_tokens 估算值
|
||||
|
||||
返回 True 表示执行了压缩。
|
||||
"""
|
||||
if len(self.messages) < 6:
|
||||
# 消息太少不压缩
|
||||
return False
|
||||
|
||||
self._compress_round += 1
|
||||
self.stats["compress_round"] = self._compress_round
|
||||
|
||||
# 保留最近 8 条消息不压缩(确保 tool_calls 和 tool 结果配对完整)
|
||||
keep_recent = 8
|
||||
# 找到一个安全的切割点:不能在 assistant.tool_calls 和其 tool 响应之间切
|
||||
cut_index = len(self.messages) - keep_recent
|
||||
# 向前调整,确保不在 tool_calls 配对中间切割
|
||||
while cut_index > 1:
|
||||
msg = self.messages[cut_index]
|
||||
role = msg.get("role")
|
||||
# 如果切点是 tool 消息,往前找到对应的 assistant
|
||||
if role == "tool":
|
||||
cut_index -= 1
|
||||
continue
|
||||
# 如果切点的 assistant 有 tool_calls,需要保留它和后续 tool
|
||||
if role == "assistant" and msg.get("tool_calls"):
|
||||
break
|
||||
break
|
||||
if cut_index <= 1:
|
||||
return False
|
||||
|
||||
# 提取要压缩的消息(索引 1 到 cut_index-1,跳过索引 0 的 system prompt)
|
||||
old_messages = self.messages[1:cut_index]
|
||||
if not old_messages:
|
||||
return False
|
||||
|
||||
# 生成压缩摘要
|
||||
summary_lines: List[str] = []
|
||||
summary_lines.append("系统提示:以下是根据之前的对话记录生成的压缩摘要,请在此基础上继续工作。")
|
||||
summary_lines.append(f"(压缩轮次:第 {self._compress_round} 次,压缩前消息数:{len(self.messages)})")
|
||||
summary_lines.append("")
|
||||
|
||||
tool_buffer: List[str] = []
|
||||
seen_tool_call_ids: Set[str] = set()
|
||||
|
||||
def flush_tools():
|
||||
if not tool_buffer:
|
||||
return
|
||||
if summary_lines and summary_lines[-1] != "":
|
||||
summary_lines.append("")
|
||||
summary_lines.append("已执行的工具:")
|
||||
summary_lines.extend(f"- {entry}" for entry in tool_buffer)
|
||||
tool_buffer.clear()
|
||||
|
||||
for message in old_messages:
|
||||
role = message.get("role")
|
||||
if role == "user":
|
||||
flush_tools()
|
||||
content = str(message.get("content") or "")[:500] # 截断长内容
|
||||
summary_lines.append(f"user:{content}")
|
||||
continue
|
||||
if role == "assistant":
|
||||
content = str(message.get("content") or "")
|
||||
if content.strip():
|
||||
flush_tools()
|
||||
summary_lines.append(f"assistant:{content[:500]}")
|
||||
tool_calls = message.get("tool_calls") or []
|
||||
for tc in tool_calls:
|
||||
tc_id = tc.get("id") or tc.get("tool_call_id")
|
||||
if tc_id:
|
||||
seen_tool_call_ids.add(tc_id)
|
||||
func = tc.get("function") or {}
|
||||
arguments = func.get("arguments")
|
||||
args_obj = {}
|
||||
if isinstance(arguments, str):
|
||||
try:
|
||||
args_obj = json.loads(arguments)
|
||||
except Exception:
|
||||
args_obj = {}
|
||||
elif isinstance(arguments, dict):
|
||||
args_obj = arguments
|
||||
intent = args_obj.get("intent") if isinstance(args_obj, dict) else None
|
||||
name = func.get("name") or "unknown_tool"
|
||||
entry = intent.strip() if isinstance(intent, str) and intent.strip() else name
|
||||
tool_buffer.append(entry)
|
||||
continue
|
||||
if role == "tool":
|
||||
tc_id = message.get("tool_call_id") or message.get("id")
|
||||
if tc_id and tc_id in seen_tool_call_ids:
|
||||
continue
|
||||
name = message.get("name") or "unknown_tool"
|
||||
tool_buffer.append(name)
|
||||
continue
|
||||
# 其他角色
|
||||
flush_tools()
|
||||
content = str(message.get("content") or "")[:300]
|
||||
summary_lines.append(f"{role}:{content}" if role else content)
|
||||
|
||||
flush_tools()
|
||||
summary_text = "\n".join(summary_lines)
|
||||
|
||||
# 重建消息列表:冻结的 system prompt + 压缩摘要 system + 保留的最近消息
|
||||
frozen_system = self._rebuild_system_prompt()
|
||||
compressed_system = {
|
||||
"role": "system",
|
||||
"content": summary_text,
|
||||
"metadata": {
|
||||
"compression": {
|
||||
"round": self._compress_round,
|
||||
"compressed_count": len(old_messages),
|
||||
"created_at": datetime.now().isoformat(),
|
||||
}
|
||||
},
|
||||
}
|
||||
recent_messages = self.messages[cut_index:]
|
||||
self.messages = [frozen_system, compressed_system] + recent_messages
|
||||
|
||||
# 重置上下文 tokens 估算(压缩后无法精确知道,设为一个保守值)
|
||||
self.current_context_tokens = 0
|
||||
self.stats["current_context_tokens"] = 0
|
||||
|
||||
logger.info(
|
||||
f"[SubAgentTask] task={self.task_id} 深度压缩完成: "
|
||||
f"压缩 {len(old_messages)} 条消息,保留 {len(recent_messages)} 条,"
|
||||
f"压缩轮次={self._compress_round}"
|
||||
)
|
||||
return True
|
||||
|
||||
def _build_media_tool_content(self, result: Dict[str, Any]) -> Any:
|
||||
"""把 read_mediafile 结果转成 OpenAI 多模态 content。"""
|
||||
b64 = result.get("b64")
|
||||
|
||||
@ -182,6 +182,98 @@ SUB_AGENT_TOOLS: List[Dict[str, Any]] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_skill",
|
||||
"description": "按 skill 名称读取 .astrion/skills/<name>/SKILL.md 内容;内部等价于 read_file 的 read 模式,并返回解析后的 path。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"skill_name": {"type": "string", "description": "skill 名称(支持 skill id 或技能名称)"},
|
||||
},
|
||||
"required": ["skill_name"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "recall_project_memory",
|
||||
"description": "读取指定的项目记忆文件,返回完整内容(含 frontmatter)。项目记忆存储在 .astrion/memory/ 目录下。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "description": "记忆名称,对应 .astrion/memory/{name}.md 文件名"},
|
||||
},
|
||||
"required": ["name"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "todo_create",
|
||||
"description": "创建待办列表,最多 8 条任务;若已有列表将被覆盖。当任务复杂、预计需要超过 3 个步骤时,应积极创建待办事项。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"overview": {"type": "string", "description": "一句话概述待办清单要完成的目标,50 字以内。"},
|
||||
"tasks": {
|
||||
"type": "array",
|
||||
"description": "任务列表,1~8 条,每条写清“动词+对象+目标”。",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string", "description": "单个任务描述,写成可执行的步骤"}
|
||||
},
|
||||
"required": ["title"]
|
||||
},
|
||||
"minItems": 1,
|
||||
"maxItems": 8
|
||||
},
|
||||
},
|
||||
"required": ["overview", "tasks"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "todo_update_task",
|
||||
"description": "批量勾选或取消任务(支持单个或多个任务);全部勾选时提示所有任务已完成。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task_index": {"type": "integer", "description": "任务序号(1-8),兼容旧参数"},
|
||||
"task_indices": {
|
||||
"type": "array",
|
||||
"items": {"type": "integer"},
|
||||
"minItems": 1,
|
||||
"maxItems": 8,
|
||||
"description": "要更新的任务序号列表(1-8),可一次勾选多个"
|
||||
},
|
||||
"completed": {"type": "boolean", "description": "true=打勾,false=取消"},
|
||||
},
|
||||
"required": ["completed"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "save_webpage",
|
||||
"description": "提取网页内容并保存为纯文本文件,适合需要长期留存的长文档。请提供网址与目标路径(含 .txt 后缀),落地后请通过终端命令查看。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {"type": "string", "description": "要保存的网页URL"},
|
||||
"target_path": {"type": "string", "description": "保存位置,包含文件名,相对于项目根目录"},
|
||||
},
|
||||
"required": ["url", "target_path"],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
FINISH_TOOL: Dict[str, Any] = {
|
||||
@ -263,6 +355,24 @@ def _format_tool_result(name: str, raw: Any) -> str:
|
||||
return ""
|
||||
if name == "read_mediafile":
|
||||
return raw.get("message") or "媒体已读取"
|
||||
if name == "read_skill":
|
||||
# read_skill 结果结构与 read_file(read) 类似
|
||||
if raw.get("content"):
|
||||
return raw.get("content")
|
||||
return raw.get("message") or "skill 已读取"
|
||||
if name == "recall_project_memory":
|
||||
# recall_project_memory 返回记忆文件完整内容
|
||||
if raw.get("content"):
|
||||
return raw.get("content")
|
||||
return raw.get("message") or raw.get("error") or "记忆已读取"
|
||||
if name == "todo_create":
|
||||
return raw.get("message") or "待办列表已创建"
|
||||
if name == "todo_update_task":
|
||||
return raw.get("message") or "任务状态已更新"
|
||||
if name == "save_webpage":
|
||||
if raw.get("path"):
|
||||
return f"已保存: {raw.get('path')}"
|
||||
return raw.get("message") or "网页已保存"
|
||||
return json.dumps(raw, ensure_ascii=False)
|
||||
|
||||
|
||||
@ -275,8 +385,8 @@ def _build_sub_agent_profile(model_raw: Dict[str, Any]) -> Optional[Dict[str, An
|
||||
return None
|
||||
|
||||
modes_text = str(model_raw.get("modes") or model_raw.get("mode") or model_raw.get("supported_modes") or "").lower()
|
||||
supports_thinking = "thinking" in modes_text
|
||||
fast_only = modes_text == "fast"
|
||||
supports_thinking = "thinking" in modes_text or "思考" in modes_text
|
||||
fast_only = modes_text == "fast" or modes_text == "快速"
|
||||
|
||||
multimodal_text = str(model_raw.get("multimodal") or model_raw.get("multi_modal") or model_raw.get("multi") or "none").lower()
|
||||
multimodal = "none"
|
||||
@ -290,8 +400,20 @@ def _build_sub_agent_profile(model_raw: Dict[str, Any]) -> Optional[Dict[str, An
|
||||
|
||||
model_id = str(model_raw.get("model_id") or name).strip()
|
||||
extra = _pick_dict(model_raw, ["extra_parameter", "extra_params", "extra"])
|
||||
# 优先从顶层查找 fast/thinking extra_parameter;如果没有,从 thinkmode_status 中查找
|
||||
fast_extra = _pick_dict(model_raw, ["fast_extra_parameter", "fast_extra_params", "fast_extra"])
|
||||
thinking_extra = _pick_dict(model_raw, ["thinking_extra_parameter", "thinking_extra_params", "thinking_extra"])
|
||||
# 从 thinkmode_status 中提取(与主智能体 custom_models.json 结构对齐)
|
||||
thinkmode_status = model_raw.get("thinkmode_status") or {}
|
||||
if isinstance(thinkmode_status, dict):
|
||||
if not fast_extra:
|
||||
fast_extra = _pick_dict(thinkmode_status, ["fast_extra_parameter", "fast_extra_params", "fast_extra"])
|
||||
if not thinking_extra:
|
||||
thinking_extra = _pick_dict(thinkmode_status, ["thinking_extra_parameter", "thinking_extra_params", "thinking_extra"])
|
||||
# thinkmode_status 内部的 model_id 可以覆盖默认 model_id
|
||||
ts_model_id = str(thinkmode_status.get("model_id") or "").strip()
|
||||
if ts_model_id:
|
||||
model_id = ts_model_id
|
||||
|
||||
profile: Dict[str, Any] = {
|
||||
"name": name,
|
||||
@ -318,6 +440,8 @@ def _build_sub_agent_profile(model_raw: Dict[str, Any]) -> Optional[Dict[str, An
|
||||
"extra_params": {**extra, **thinking_extra},
|
||||
}
|
||||
else:
|
||||
# 即使不支持 thinking,也保留 fast 的 extra_params(含 disabled 参数)
|
||||
# 避免不支持 thinking 的模型在 fast 模式下漏掉 thinking.type=disabled 参数
|
||||
profile["thinking"] = None
|
||||
return profile
|
||||
|
||||
|
||||
@ -362,8 +362,8 @@ class UserManager:
|
||||
project_path = workspace_root / "project"
|
||||
data_dir = workspace_root / "data"
|
||||
logs_dir = workspace_root / "logs"
|
||||
uploads_dir = project_path / ".agents" / "user_upload"
|
||||
skills_dir = project_path / ".agents" / "skills"
|
||||
uploads_dir = project_path / ".astrion" / "user_upload"
|
||||
skills_dir = project_path / ".astrion" / "skills"
|
||||
|
||||
for path in [project_path, data_dir, logs_dir, uploads_dir, skills_dir]:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
23
multi_agent_roles/code-reviewer.md
Normal file
23
multi_agent_roles/code-reviewer.md
Normal file
@ -0,0 +1,23 @@
|
||||
---
|
||||
id: code-reviewer
|
||||
name: Code Reviewer
|
||||
description: 代码审查专家,只读分析代码质量、安全性、可维护性并给出建议
|
||||
model: ""
|
||||
thinking_mode: thinking
|
||||
---
|
||||
|
||||
你是团队中的 **代码审查员**,只做分析,不直接修改代码。
|
||||
|
||||
## 你的职责
|
||||
|
||||
1. 审查代码质量、命名规范、错误处理、可读性
|
||||
2. 检查安全漏洞与潜在风险
|
||||
3. 给出可执行的重构建议(含 before/after 示例片段)
|
||||
4. 检查是否符合项目 AGENTS.md 中的硬性规范
|
||||
|
||||
## 工作原则
|
||||
|
||||
- **只读**,不调用 write_file / edit_file 修改代码
|
||||
- 输出结构化报告:问题列表(级别/位置/原因/建议)
|
||||
- 引用代码时给出文件路径和行号范围
|
||||
- 不重复指出同类问题
|
||||
23
multi_agent_roles/full-stack-engineer.md
Normal file
23
multi_agent_roles/full-stack-engineer.md
Normal file
@ -0,0 +1,23 @@
|
||||
---
|
||||
id: full-stack-engineer
|
||||
name: Full-Stack Engineer
|
||||
description: 全栈工程师,负责前后端代码实现、接口设计、调试和联调
|
||||
model: ""
|
||||
thinking_mode: thinking
|
||||
---
|
||||
|
||||
你是团队中的 **全栈工程师**,负责把方案落到代码上。
|
||||
|
||||
## 你的职责
|
||||
|
||||
1. 编写、修改、重构前后端代码(Python / TypeScript / Vue 等)
|
||||
2. 设计 API 接口、数据模型与存储
|
||||
3. 排查 bug、调试错误、验证功能
|
||||
4. 运行测试命令并修复失败用例
|
||||
|
||||
## 工作原则
|
||||
|
||||
- 任何改动前先阅读相关代码再下手,不凭记忆改
|
||||
- 遵循项目 AGENTS.md 中的代码规范(最小改动、文件拆分、风格指南等)
|
||||
- 修改后做最小验证(运行命令、构建、类型检查)
|
||||
- 不要频繁输出大段代码注释,只在关键步骤展示
|
||||
23
multi_agent_roles/researcher.md
Normal file
23
multi_agent_roles/researcher.md
Normal file
@ -0,0 +1,23 @@
|
||||
---
|
||||
id: researcher
|
||||
name: Researcher
|
||||
description: 调研员,负责竞品调研、信息收集、文档汇总与对比分析
|
||||
model: ""
|
||||
thinking_mode: fast
|
||||
---
|
||||
|
||||
你是团队中的 **调研员**,负责信息搜集与文档整理。
|
||||
|
||||
## 你的职责
|
||||
|
||||
1. 用 web_search / extract_webpage 调研外部资料
|
||||
2. 读取项目里相关代码与文档,整理出结构化分析报告
|
||||
3. 对比多个方案/产品,给出优劣表格
|
||||
4. 必要时把报告落到 Markdown 文件中供团队其他成员复用
|
||||
|
||||
## 工作原则
|
||||
|
||||
- 调研前先做一到两次浅层搜索,摸清关键词再深入
|
||||
- 引用具体来源(链接、文件路径、行号)
|
||||
- 不要堆砌大段原文,要点提炼为 5-10 条结论
|
||||
- 报告结构:摘要 → 关键发现 → 详表/对比 → 行动建议
|
||||
25
multi_agent_roles/ui-operator.md
Normal file
25
multi_agent_roles/ui-operator.md
Normal file
@ -0,0 +1,25 @@
|
||||
---
|
||||
id: ui-operator
|
||||
name: UI Operator
|
||||
description: 专注界面视觉设计、配色、布局与前端组件实现
|
||||
model: ""
|
||||
thinking_mode: fast
|
||||
skills:
|
||||
- frontend-design
|
||||
- ui-aesthetic-design
|
||||
---
|
||||
|
||||
你是团队中的 **UI 操作员**,专注于前端视觉与界面实现。
|
||||
|
||||
## 你的职责
|
||||
|
||||
1. 设计界面配色、排版、布局方案
|
||||
2. 编写 HTML/CSS/Vue 组件实现界面
|
||||
3. 评审视觉一致性、对齐、间距、可读性
|
||||
4. 必要时输出可直接预览的 HTML 卡片或独立页面
|
||||
|
||||
## 工作原则
|
||||
|
||||
- 优先使用 `<show_html>` / 内嵌预览而非长篇文字说明
|
||||
- 改动遵循项目 AGENTS.md 中关于 UI 美学的硬性规则(不写死颜色、无圆角套娃、图标对齐等)
|
||||
- 不修改与界面无关的后端或业务代码
|
||||
@ -1342,6 +1342,13 @@ def initialize_system(path: str, thinking_mode: bool = False):
|
||||
print(f"[Init] 调试日志: {DEBUG_LOG_FILE}")
|
||||
app.config['DEFAULT_THINKING_MODE'] = thinking_mode
|
||||
app.config['DEFAULT_RUN_MODE'] = "thinking" if thinking_mode else "fast"
|
||||
# 同步预设子智能体角色到运行态目录
|
||||
try:
|
||||
from modules.multi_agent.role_store import sync_preset_roles
|
||||
sync_preset_roles()
|
||||
print(f"{OUTPUT_FORMATS['success']} 预设子智能体角色同步完成")
|
||||
except Exception as _e:
|
||||
print(f"{OUTPUT_FORMATS['warning']} 预设子智能体角色同步失败: {_e}")
|
||||
print(f"{OUTPUT_FORMATS['success']} Web系统初始化完成(多用户模式)")
|
||||
|
||||
|
||||
|
||||
@ -224,7 +224,7 @@ def host_login():
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
logs_dir = Path(LOGS_DIR).expanduser().resolve()
|
||||
logs_dir.mkdir(parents=True, exist_ok=True)
|
||||
uploads_dir = host_path / ".agents" / "user_upload"
|
||||
uploads_dir = host_path / ".astrion" / "user_upload"
|
||||
uploads_dir.mkdir(parents=True, exist_ok=True)
|
||||
quarantine_root = Path(UPLOAD_QUARANTINE_SUBDIR).expanduser()
|
||||
if not quarantine_root.is_absolute():
|
||||
|
||||
@ -180,9 +180,9 @@ def get_user_resources(
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
logs_dir = Path(LOGS_DIR).expanduser().resolve()
|
||||
logs_dir.mkdir(parents=True, exist_ok=True)
|
||||
uploads_dir = project_path / ".agents" / "user_upload"
|
||||
uploads_dir = project_path / ".astrion" / "user_upload"
|
||||
uploads_dir.mkdir(parents=True, exist_ok=True)
|
||||
skills_dir = project_path / ".agents" / "skills"
|
||||
skills_dir = project_path / ".astrion" / "skills"
|
||||
skills_dir.mkdir(parents=True, exist_ok=True)
|
||||
quarantine_root = Path(UPLOAD_QUARANTINE_SUBDIR).expanduser()
|
||||
if not quarantine_root.is_absolute():
|
||||
|
||||
@ -2064,7 +2064,7 @@ def review_conversation(conversation_id, terminal: WebTerminal, workspace: UserW
|
||||
content = "\n".join(lines) + "\n"
|
||||
char_count = len(content)
|
||||
|
||||
review_dir = workspace.project_path / ".agents" / "review"
|
||||
review_dir = workspace.project_path / ".astrion" / "review"
|
||||
review_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
title = conversation_data.get("title") or "untitled"
|
||||
|
||||
@ -246,7 +246,7 @@ def _write_compact_file(
|
||||
latest_user_input: str,
|
||||
previous_records: List[Dict[str, Any]],
|
||||
) -> str:
|
||||
compact_dir = project_path / ".agents" / "compact_result"
|
||||
compact_dir = project_path / ".astrion" / "compact_result"
|
||||
compact_dir.mkdir(parents=True, exist_ok=True)
|
||||
filename = f"compact_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{compression_index:03d}.md"
|
||||
file_path = compact_dir / filename
|
||||
|
||||
@ -15,6 +15,16 @@ from flask import Blueprint, current_app, jsonify, request, session
|
||||
|
||||
from server.auth_helpers import api_login_required, login_required, get_current_username
|
||||
from server.context import get_user_resources
|
||||
from modules.multi_agent.role_store import (
|
||||
RoleConfig,
|
||||
list_roles,
|
||||
load_preset_role,
|
||||
save_custom_role,
|
||||
delete_custom_role,
|
||||
is_preset_role,
|
||||
sync_preset_roles,
|
||||
)
|
||||
from config.paths import CUSTOM_ROLES_DIR, WEB_PRESET_ROLES_DIR
|
||||
|
||||
multi_agent_bp = Blueprint("multi_agent", __name__)
|
||||
|
||||
@ -57,13 +67,35 @@ def rebuild_conversation_index_api():
|
||||
return jsonify({"success": False, "error": str(exc)}), 500
|
||||
|
||||
|
||||
def _get_role_dirs() -> tuple:
|
||||
"""根据 session 判断当前登录模式,返回 (runtime_dir, custom_dir)。
|
||||
|
||||
- host 模式:runtime_dir = custom_dir = host/mutiagents/agents
|
||||
- web/docker 模式:runtime_dir = web/mutiagents/agents, custom_dir = users/<user>/personal/mutiagents/agents
|
||||
"""
|
||||
from flask import session as _session
|
||||
is_host_session = bool(_session.get('host_mode'))
|
||||
if is_host_session:
|
||||
return CUSTOM_ROLES_DIR, CUSTOM_ROLES_DIR
|
||||
# web 模式
|
||||
username = get_current_username()
|
||||
if not username:
|
||||
return WEB_PRESET_ROLES_DIR, None
|
||||
_, workspace = get_user_resources(username)
|
||||
if not workspace or not workspace.data_dir:
|
||||
return WEB_PRESET_ROLES_DIR, None
|
||||
from modules.multi_agent.role_store import infer_custom_roles_dir
|
||||
custom = infer_custom_roles_dir(workspace.data_dir)
|
||||
return WEB_PRESET_ROLES_DIR, str(custom) if custom else None
|
||||
|
||||
|
||||
@multi_agent_bp.route("/api/multiagent/roles", methods=["GET"])
|
||||
@api_login_required
|
||||
def list_roles_api():
|
||||
"""列出全部可用角色(预置+自定义)。"""
|
||||
"""列出全部可用角色(预置+用户自定义)。"""
|
||||
try:
|
||||
from modules.multi_agent.role_store import list_roles
|
||||
roles = list_roles()
|
||||
runtime_dir, custom_dir = _get_role_dirs()
|
||||
roles = list_roles(runtime_dir=runtime_dir, custom_dir=custom_dir)
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"roles": [r.to_dict() for r in roles],
|
||||
@ -77,19 +109,23 @@ def list_roles_api():
|
||||
def create_role_api():
|
||||
"""创建自定义角色。body: { role_id, name, description?, body_prompt, thinking_mode? }"""
|
||||
try:
|
||||
from modules.multi_agent.role_store import RoleConfig, save_custom_role, list_roles
|
||||
data = request.get_json() or {}
|
||||
role_id = str(data.get("role_id") or "").strip()
|
||||
name = str(data.get("name") or "").strip()
|
||||
body_prompt = str(data.get("body_prompt") or "").strip()
|
||||
description = str(data.get("description") or "").strip()
|
||||
thinking_mode = str(data.get("thinking_mode") or "fast").strip()
|
||||
model_key = str(data.get("model_key") or "").strip() or None
|
||||
if not role_id or not name or not body_prompt:
|
||||
return jsonify({"success": False, "error": "role_id/name/body_prompt 必填"}), 400
|
||||
if thinking_mode not in {"fast", "thinking"}:
|
||||
thinking_mode = "fast"
|
||||
# 不允许覆盖已存在的同名角色
|
||||
existing = {r.role_id for r in list_roles()}
|
||||
# 不允许覆盖预设角色
|
||||
if is_preset_role(role_id):
|
||||
return jsonify({"success": False, "error": f"不能覆盖预设角色 {role_id}"}), 409
|
||||
# 不允许覆盖已存在的用户自定义角色
|
||||
runtime_dir, custom_dir = _get_role_dirs()
|
||||
existing = {r.role_id for r in list_roles(runtime_dir=runtime_dir, custom_dir=custom_dir)}
|
||||
if role_id in existing:
|
||||
return jsonify({"success": False, "error": f"角色 {role_id} 已存在"}), 409
|
||||
role = RoleConfig(
|
||||
@ -98,13 +134,117 @@ def create_role_api():
|
||||
description=description,
|
||||
body_prompt=body_prompt,
|
||||
thinking_mode=thinking_mode,
|
||||
model_key=model_key,
|
||||
is_custom=True,
|
||||
)
|
||||
saved = save_custom_role(role)
|
||||
saved = save_custom_role(role, custom_dir=custom_dir)
|
||||
return jsonify({"success": True, "role_id": role_id, "file": str(saved)})
|
||||
except Exception as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 500
|
||||
|
||||
|
||||
@multi_agent_bp.route("/api/multiagent/roles/<role_id>", methods=["PUT"])
|
||||
@api_login_required
|
||||
def update_role_api(role_id: str):
|
||||
"""更新用户自定义角色。不能更新预设角色。"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
runtime_dir, custom_dir = _get_role_dirs()
|
||||
# 加载现有角色(先查自定义覆盖,再查预设)
|
||||
role = load_preset_role(role_id, runtime_dir=runtime_dir, custom_dir=custom_dir)
|
||||
if not role:
|
||||
return jsonify({"success": False, "error": f"角色 {role_id} 不存在"}), 404
|
||||
# 更新字段
|
||||
if "name" in data:
|
||||
role.name = str(data["name"]).strip() or role.name
|
||||
if "description" in data:
|
||||
role.description = str(data["description"] or "").strip()
|
||||
if "body_prompt" in data:
|
||||
role.body_prompt = str(data["body_prompt"] or "").strip()
|
||||
if "thinking_mode" in data:
|
||||
tm = str(data["thinking_mode"] or "fast").strip()
|
||||
if tm in {"fast", "thinking"}:
|
||||
role.thinking_mode = tm
|
||||
if "model_key" in data:
|
||||
role.model_key = str(data["model_key"] or "").strip() or None
|
||||
if "skills" in data:
|
||||
role.skills = list(data["skills"] or [])
|
||||
role.is_custom = True
|
||||
saved = save_custom_role(role, custom_dir=custom_dir)
|
||||
return jsonify({"success": True, "role_id": role_id, "file": str(saved)})
|
||||
except Exception as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 500
|
||||
|
||||
|
||||
@multi_agent_bp.route("/api/multiagent/roles/<role_id>", methods=["DELETE"])
|
||||
@api_login_required
|
||||
def delete_role_api(role_id: str):
|
||||
"""删除用户自定义角色。不能删除预设角色。"""
|
||||
try:
|
||||
if is_preset_role(role_id):
|
||||
return jsonify({"success": False, "error": f"不能删除预设角色 {role_id}"}), 403
|
||||
_, custom_dir = _get_role_dirs()
|
||||
deleted = delete_custom_role(role_id, custom_dir=custom_dir)
|
||||
if not deleted:
|
||||
return jsonify({"success": False, "error": f"角色 {role_id} 不存在或无法删除"}), 404
|
||||
return jsonify({"success": True, "role_id": role_id})
|
||||
except Exception as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 500
|
||||
|
||||
|
||||
@multi_agent_bp.route("/api/multiagent/settings", methods=["GET"])
|
||||
@api_login_required
|
||||
def get_multi_agent_settings_api():
|
||||
"""获取多智能体模式设置(子智能体压缩阈值等)。"""
|
||||
try:
|
||||
username = get_current_username()
|
||||
if not username:
|
||||
return jsonify({"success": False, "error": "未登录"}), 401
|
||||
_, workspace = get_user_resources(username)
|
||||
if not workspace:
|
||||
return jsonify({"success": False, "error": "工作区未就绪"}), 503
|
||||
# 从个人化配置中读取子智能体压缩阈值
|
||||
from modules.personalization_manager import load_personalization_config
|
||||
prefs = load_personalization_config(workspace.data_dir) or {}
|
||||
compress_threshold = prefs.get("sub_agent_compress_threshold_tokens", 150000)
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"settings": {
|
||||
"sub_agent_compress_threshold_tokens": compress_threshold,
|
||||
},
|
||||
})
|
||||
except Exception as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 500
|
||||
|
||||
|
||||
@multi_agent_bp.route("/api/multiagent/settings", methods=["PUT"])
|
||||
@api_login_required
|
||||
def update_multi_agent_settings_api():
|
||||
"""更新多智能体模式设置。"""
|
||||
try:
|
||||
username = get_current_username()
|
||||
if not username:
|
||||
return jsonify({"success": False, "error": "未登录"}), 401
|
||||
_, workspace = get_user_resources(username)
|
||||
if not workspace:
|
||||
return jsonify({"success": False, "error": "工作区未就绪"}), 503
|
||||
data = request.get_json() or {}
|
||||
settings = data.get("settings") or {}
|
||||
# 更新子智能体压缩阈值
|
||||
threshold = settings.get("sub_agent_compress_threshold_tokens")
|
||||
if threshold is not None:
|
||||
threshold = int(threshold)
|
||||
if threshold < 10000:
|
||||
return jsonify({"success": False, "error": "压缩阈值不能小于 10000"}), 400
|
||||
from modules.personalization_manager import load_personalization_config, save_personalization_config
|
||||
prefs = load_personalization_config(workspace.data_dir) or {}
|
||||
prefs["sub_agent_compress_threshold_tokens"] = threshold
|
||||
save_personalization_config(workspace.data_dir, prefs)
|
||||
return jsonify({"success": True})
|
||||
except Exception as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 500
|
||||
|
||||
|
||||
@multi_agent_bp.route("/api/multiagent/conversations", methods=["POST"])
|
||||
@api_login_required
|
||||
def create_multi_agent_conversation():
|
||||
@ -206,6 +346,41 @@ def create_multi_agent_conversation():
|
||||
}), 201
|
||||
|
||||
|
||||
@multi_agent_bp.route("/api/multiagent/models", methods=["GET"])
|
||||
@api_login_required
|
||||
def list_sub_agent_models_api():
|
||||
"""列出子智能体可用的模型列表(从 sub_agent_models.json 读取,脱敏后返回)。"""
|
||||
try:
|
||||
from config.sub_agent import SUB_AGENT_MODELS_CONFIG_FILE
|
||||
from pathlib import Path as _Path
|
||||
import json as _json
|
||||
config_path = SUB_AGENT_MODELS_CONFIG_FILE
|
||||
if not _Path(config_path).exists():
|
||||
return jsonify({"success": True, "models": [], "default_model": ""})
|
||||
raw = _json.loads(_Path(config_path).read_text(encoding="utf-8"))
|
||||
models_raw = raw.get("models", []) if isinstance(raw, dict) else (raw if isinstance(raw, list) else [])
|
||||
default_model = str(raw.get("default_model", "")) if isinstance(raw, dict) else ""
|
||||
# 脱敏:只返回 name / modes / multimodal / max_output / max_context
|
||||
safe_models = []
|
||||
for m in models_raw:
|
||||
if not isinstance(m, dict):
|
||||
continue
|
||||
safe_models.append({
|
||||
"name": m.get("name") or m.get("model_name") or "",
|
||||
"modes": m.get("modes") or m.get("mode") or "",
|
||||
"multimodal": m.get("multimodal") or "none",
|
||||
"max_output": m.get("max_output") or m.get("max_tokens") or 0,
|
||||
"max_context": m.get("max_context") or m.get("context_window") or 0,
|
||||
})
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"models": safe_models,
|
||||
"default_model": default_model,
|
||||
})
|
||||
except Exception as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 500
|
||||
|
||||
|
||||
@multi_agent_bp.route("/api/multiagent/active_sub_agents", methods=["GET"])
|
||||
@api_login_required
|
||||
def list_active_sub_agents_api():
|
||||
|
||||
@ -328,6 +328,8 @@
|
||||
:goal-mode-armed="goalModeArmed"
|
||||
:goal-running="goalRunning"
|
||||
:goal-progress="goalProgress"
|
||||
:multi-agent-mode="multiAgentMode"
|
||||
:has-running-task="composerBusy || anyWorkspaceHasRunningTask"
|
||||
:avatar-status="showStatusAvatar && messages.length > 0 ? avatarStatus : null"
|
||||
@restore-user-question="restoreUserQuestionDialog"
|
||||
@update:input-message="inputSetMessage"
|
||||
@ -366,6 +368,7 @@
|
||||
@delete-runtime-message="handleDeleteRuntimeMessage"
|
||||
@composer-height-change="handleComposerHeightChange"
|
||||
@toggle-goal-mode="handleToggleGoalMode"
|
||||
@toggle-agent-mode="handleToggleAgentMode"
|
||||
@open-goal-dialog="goalDialogOpen = true"
|
||||
@open-git-changes-panel="openGitChangesPanel"
|
||||
/>
|
||||
|
||||
@ -231,6 +231,13 @@ export const computed = {
|
||||
);
|
||||
});
|
||||
},
|
||||
anyWorkspaceHasRunningTask() {
|
||||
const tasks = Array.isArray(this.runningWorkspaceTasks) ? this.runningWorkspaceTasks : [];
|
||||
return tasks.some((task: any) => {
|
||||
const status = String(task?.status || '');
|
||||
return ['pending', 'running', 'cancel_requested'].includes(status);
|
||||
});
|
||||
},
|
||||
streamingUi() {
|
||||
return this.streamingMessage || this.hasPendingToolActions();
|
||||
},
|
||||
|
||||
@ -65,6 +65,19 @@ export const modeMethods = {
|
||||
this.modeMenuOpen = false;
|
||||
this.modelMenuOpen = false;
|
||||
},
|
||||
handleToggleAgentMode() {
|
||||
if (!this.isConnected) {
|
||||
return;
|
||||
}
|
||||
// 有运行中对话时不允许切换
|
||||
if (this.composerBusy || this.anyWorkspaceHasRunningTask) {
|
||||
return;
|
||||
}
|
||||
this.inputCloseMenus();
|
||||
// 多智能体模式 → 传统模式;传统模式 → 多智能体模式
|
||||
const target = this.multiAgentMode ? '/new' : '/multiagent/new';
|
||||
window.location.href = target;
|
||||
},
|
||||
toggleModeMenu() {
|
||||
if (!this.isConnected || this.streamingMessage) {
|
||||
return;
|
||||
|
||||
@ -38,14 +38,6 @@
|
||||
宿主机模式(免登录)
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="auth-secondary-button auth-tertiary-button"
|
||||
@click="enterMultiAgent"
|
||||
title="多智能体协作 beta"
|
||||
>
|
||||
多智能体模式(beta)
|
||||
</button>
|
||||
|
||||
<div class="auth-error">{{ error }}</div>
|
||||
<div class="auth-link">还没有账号?<a href="/register">点击注册</a></div>
|
||||
</section>
|
||||
@ -133,18 +125,6 @@ const doHostLogin = async (redirectUrl = '/') => {
|
||||
|
||||
const hostLogin = () => doHostLogin('/');
|
||||
|
||||
const enterMultiAgent = () => {
|
||||
if (hostModeEnabled.value) {
|
||||
doHostLogin('/multiagent/new');
|
||||
return;
|
||||
}
|
||||
if (!email.value || !password.value) {
|
||||
error.value = '请输入邮箱和密码,或选择宿主机模式';
|
||||
return;
|
||||
}
|
||||
doLogin('/multiagent/new');
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
applyTheme(loadTheme());
|
||||
|
||||
|
||||
@ -353,6 +353,8 @@
|
||||
:execution-mode-enabled="executionModeEnabled"
|
||||
:goal-mode-armed="goalModeArmed"
|
||||
:goal-running="goalRunning"
|
||||
:multi-agent-mode="multiAgentMode"
|
||||
:has-running-task="hasRunningTask"
|
||||
@quick-upload="triggerQuickUpload"
|
||||
@pick-images="$emit('pick-images')"
|
||||
@pick-video="$emit('pick-video')"
|
||||
@ -371,6 +373,7 @@
|
||||
@open-review="$emit('open-review')"
|
||||
@open-path-authorization="$emit('open-path-authorization')"
|
||||
@toggle-goal-mode="$emit('toggle-goal-mode')"
|
||||
@toggle-agent-mode="$emit('toggle-agent-mode')"
|
||||
/>
|
||||
<div class="permission-switcher" @click.stop>
|
||||
<button
|
||||
@ -545,7 +548,8 @@ const emit = defineEmits([
|
||||
'restore-user-question',
|
||||
'toggle-goal-mode',
|
||||
'open-goal-dialog',
|
||||
'open-git-changes-panel'
|
||||
'open-git-changes-panel',
|
||||
'toggle-agent-mode'
|
||||
]);
|
||||
|
||||
const props = defineProps<{
|
||||
@ -614,6 +618,8 @@ const props = defineProps<{
|
||||
goalModeArmed?: boolean;
|
||||
goalRunning?: boolean;
|
||||
goalProgress?: Record<string, any> | null;
|
||||
multiAgentMode?: boolean;
|
||||
hasRunningTask?: boolean;
|
||||
hasPendingRuntimeGuidance?: boolean;
|
||||
terminalCount?: number;
|
||||
hostMode?: boolean;
|
||||
|
||||
@ -63,6 +63,20 @@
|
||||
<template v-else>{{ goalModeArmed ? '已就绪' : '' }}</template>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="menu-entry"
|
||||
:class="{ 'mode-entry-active': multiAgentMode }"
|
||||
@click.stop="$emit('toggle-agent-mode')"
|
||||
:disabled="!isConnected || hasRunningTask"
|
||||
:title="hasRunningTask ? '有运行中的对话,无法切换模式' : ''"
|
||||
>
|
||||
{{ multiAgentMode ? '返回传统模式' : '多智能体模式' }}
|
||||
<span class="entry-arrow">
|
||||
<template v-if="hasRunningTask">有任务运行中</template>
|
||||
<template v-else-if="multiAgentMode">当前模式</template>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="menu-entry has-submenu"
|
||||
@ -200,6 +214,8 @@ const props = defineProps<{
|
||||
goalModeArmed?: boolean;
|
||||
goalRunning?: boolean;
|
||||
goalProgress?: Record<string, any> | null;
|
||||
multiAgentMode?: boolean;
|
||||
hasRunningTask?: boolean;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
@ -219,6 +235,7 @@ defineEmits<{
|
||||
(event: 'pick-video'): void;
|
||||
(event: 'open-path-authorization'): void;
|
||||
(event: 'toggle-goal-mode'): void;
|
||||
(event: 'toggle-agent-mode'): void;
|
||||
}>();
|
||||
|
||||
const getIconStyle = (key: string) => (props.iconStyle ? props.iconStyle(key) : {});
|
||||
@ -250,4 +267,11 @@ const goalCompleted = computed(() => String(props.goalProgress?.status || '').to
|
||||
color: var(--claude-accent);
|
||||
font-size: 12px;
|
||||
}
|
||||
.menu-entry.mode-entry-active {
|
||||
color: var(--claude-accent);
|
||||
}
|
||||
.menu-entry.mode-entry-active .entry-arrow {
|
||||
color: var(--claude-accent);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1736,7 +1736,252 @@
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-else-if="activeTab === 'admin'"
|
||||
v-else-if="activeTab === 'sub-agents'"
|
||||
key="sub-agents"
|
||||
class="settings-page"
|
||||
>
|
||||
<div class="settings-section-desc" style="margin: 0 0 16px; color: var(--claude-text-secondary); font-size: 13px; line-height: 1.6">
|
||||
管理多智能体模式下的子智能体角色。预设角色可编辑(创建自定义覆盖),自定义角色可创建/编辑/删除。
|
||||
</div>
|
||||
|
||||
<!-- 压缩阈值配置 -->
|
||||
<div class="settings-action-row" style="margin-bottom: 16px">
|
||||
<span class="settings-row-copy">
|
||||
<span class="settings-row-title">上下文压缩阈值</span>
|
||||
<span class="settings-row-desc">
|
||||
子智能体上下文 tokens 超过此值时触发深度压缩(默认 150000)
|
||||
</span>
|
||||
</span>
|
||||
<div style="display: flex; gap: 6px; align-items: center">
|
||||
<input
|
||||
type="number"
|
||||
class="settings-number-input"
|
||||
v-model.number="subAgentCompressThreshold"
|
||||
:min="10000"
|
||||
:step="10000"
|
||||
style="width: 120px"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="settings-secondary-button"
|
||||
:disabled="subAgentSettingsSaving"
|
||||
@click="saveSubAgentSettings"
|
||||
>
|
||||
{{ subAgentSettingsSaving ? '保存中...' : '保存' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 角色列表 -->
|
||||
<div class="settings-section-header" style="margin-bottom: 8px">
|
||||
<span class="settings-section-title">角色列表</span>
|
||||
<button
|
||||
type="button"
|
||||
class="settings-secondary-button"
|
||||
@click="openRoleEditor()"
|
||||
>
|
||||
+ 新建角色
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="subAgentRolesLoading" class="settings-empty-hint">加载中...</div>
|
||||
<div v-else-if="subAgentRoles.length === 0" class="settings-empty-hint">
|
||||
暂无角色
|
||||
</div>
|
||||
|
||||
<div v-else class="sub-agent-role-list">
|
||||
<div
|
||||
v-for="role in subAgentRoles"
|
||||
:key="role.role_id"
|
||||
class="sub-agent-role-card"
|
||||
>
|
||||
<div class="sub-agent-role-info">
|
||||
<div class="sub-agent-role-name">
|
||||
{{ role.name }}
|
||||
<span
|
||||
class="sub-agent-role-tag"
|
||||
:class="role.is_custom ? 'tag-custom' : 'tag-preset'"
|
||||
>
|
||||
{{ role.is_custom ? '自定义' : '预设' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="sub-agent-role-desc">{{ role.description || '无描述' }}</div>
|
||||
<div class="sub-agent-role-meta">
|
||||
ID: {{ role.role_id }} · 思考模式: {{ role.thinking_mode }}<template v-if="role.model_key"> · 模型: {{ role.model_key }}</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sub-agent-role-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="settings-secondary-button"
|
||||
@click="openRoleEditor(role)"
|
||||
>编辑</button>
|
||||
<button
|
||||
v-if="role.is_custom"
|
||||
type="button"
|
||||
class="settings-secondary-button danger"
|
||||
@click="deleteRole(role)"
|
||||
>删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 角色编辑弹窗(与个人空间同尺寸) -->
|
||||
<transition name="personal-page-fade" appear>
|
||||
<div
|
||||
v-if="roleEditorOpen"
|
||||
class="role-editor-drawer-overlay"
|
||||
@click="closeDropdown"
|
||||
@click.self="closeRoleEditor"
|
||||
>
|
||||
<div class="role-editor-drawer-card settings-redesign-card" style="padding: 30px 42px 24px; border-radius: 32px;" @click.stop>
|
||||
<div class="role-editor-header">
|
||||
<h2>{{ editingRole ? '编辑角色' : '新建角色' }}</h2>
|
||||
<button type="button" class="settings-secondary-button" @click="closeRoleEditor">关闭</button>
|
||||
</div>
|
||||
<div class="role-editor-body" @click="closeDropdown">
|
||||
<label class="settings-input-row">
|
||||
<span class="settings-row-title">角色 ID</span>
|
||||
<input
|
||||
v-if="!editingRole"
|
||||
type="text"
|
||||
:value="roleForm.role_id"
|
||||
maxlength="40"
|
||||
placeholder="英文小写下划线,如 api-designer"
|
||||
@input="roleForm.role_id = ($event.target as HTMLInputElement).value"
|
||||
/>
|
||||
<span v-else class="settings-row-desc" style="text-align: right; font-size: 14px;">{{ roleForm.role_id }}</span>
|
||||
</label>
|
||||
<label class="settings-input-row">
|
||||
<span class="settings-row-title">显示名</span>
|
||||
<input
|
||||
type="text"
|
||||
:value="roleForm.name"
|
||||
maxlength="40"
|
||||
placeholder="如 API Designer"
|
||||
@input="roleForm.name = ($event.target as HTMLInputElement).value"
|
||||
/>
|
||||
</label>
|
||||
<label class="settings-input-row">
|
||||
<span class="settings-row-title">描述</span>
|
||||
<input
|
||||
type="text"
|
||||
:value="roleForm.description"
|
||||
maxlength="100"
|
||||
placeholder="一句话描述职责"
|
||||
@input="roleForm.description = ($event.target as HTMLInputElement).value"
|
||||
/>
|
||||
</label>
|
||||
<div class="settings-select-row">
|
||||
<span class="settings-row-copy">
|
||||
<span class="settings-row-title">思考模式</span>
|
||||
<span class="settings-row-desc">fast = 快速响应,thinking = 深度推理</span>
|
||||
</span>
|
||||
<div
|
||||
class="settings-select-wrap"
|
||||
:class="{ open: activeDropdown === 'role-thinking' }"
|
||||
@click.stop
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="settings-select-button"
|
||||
@click="toggleDropdown('role-thinking')"
|
||||
>
|
||||
{{ roleForm.thinking_mode === 'thinking' ? 'thinking' : 'fast' }}
|
||||
<span class="select-chevron" aria-hidden="true"></span>
|
||||
</button>
|
||||
<div
|
||||
:class="['settings-floating-menu', { dark: activeTheme === 'dark' }]"
|
||||
:style="activeDropdown === 'role-thinking' ? floatingMenuStyle : undefined"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="settings-menu-option"
|
||||
:class="{ selected: roleForm.thinking_mode === 'fast' }"
|
||||
@click="roleForm.thinking_mode = 'fast'; closeDropdown()"
|
||||
>
|
||||
<strong>fast</strong><span>快速响应模式</span><svg viewBox="0 0 24 24"><path d="M5 12.5 9.5 17 19 7" /></svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="settings-menu-option"
|
||||
:class="{ selected: roleForm.thinking_mode === 'thinking' }"
|
||||
@click="roleForm.thinking_mode = 'thinking'; closeDropdown()"
|
||||
>
|
||||
<strong>thinking</strong><span>深度推理模式</span><svg viewBox="0 0 24 24"><path d="M5 12.5 9.5 17 19 7" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-select-row">
|
||||
<span class="settings-row-copy">
|
||||
<span class="settings-row-title">模型</span>
|
||||
<span class="settings-row-desc">留空则使用默认模型</span>
|
||||
</span>
|
||||
<div
|
||||
class="settings-select-wrap"
|
||||
:class="{ open: activeDropdown === 'role-model' }"
|
||||
@click.stop
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="settings-select-button"
|
||||
@click="toggleDropdown('role-model')"
|
||||
>
|
||||
{{ roleForm.model_key || '默认模型' }}
|
||||
<span class="select-chevron" aria-hidden="true"></span>
|
||||
</button>
|
||||
<div
|
||||
:class="['settings-floating-menu', { dark: activeTheme === 'dark' }]"
|
||||
:style="activeDropdown === 'role-model' ? floatingMenuStyle : undefined"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="settings-menu-option"
|
||||
:class="{ selected: !roleForm.model_key }"
|
||||
@click="roleForm.model_key = ''; closeDropdown()"
|
||||
>
|
||||
<strong>默认模型</strong><span>使用配置文件中的 default_model</span><svg viewBox="0 0 24 24"><path d="M5 12.5 9.5 17 19 7" /></svg>
|
||||
</button>
|
||||
<button
|
||||
v-for="m in subAgentModels"
|
||||
:key="m.name"
|
||||
type="button"
|
||||
class="settings-menu-option"
|
||||
:class="{ selected: roleForm.model_key === m.name }"
|
||||
@click="roleForm.model_key = m.name; closeDropdown()"
|
||||
>
|
||||
<strong>{{ m.name }}</strong><span>{{ m.modes }} · {{ m.multimodal || '纯文本' }}</span><svg viewBox="0 0 24 24"><path d="M5 12.5 9.5 17 19 7" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-textarea-row">
|
||||
<span class="settings-row-title">Prompt Body(Markdown)</span>
|
||||
<textarea
|
||||
:value="roleForm.body_prompt"
|
||||
placeholder="角色的自定义 prompt body..."
|
||||
@input="roleForm.body_prompt = ($event.target as HTMLTextAreaElement).value"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="role-editor-footer">
|
||||
<button type="button" class="settings-secondary-button" @click="closeRoleEditor">取消</button>
|
||||
<button
|
||||
type="button"
|
||||
class="settings-primary-button"
|
||||
:disabled="roleSaving || !roleForm.role_id || !roleForm.name || !roleForm.body_prompt"
|
||||
@click="saveRole"
|
||||
>
|
||||
{{ roleSaving ? '保存中...' : '保存' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</section>
|
||||
|
||||
<section
|
||||
key="admin"
|
||||
class="settings-page admin-monitor-page"
|
||||
>
|
||||
@ -1888,6 +2133,8 @@ type PersonalTab =
|
||||
| 'tools'
|
||||
| 'files'
|
||||
| 'data'
|
||||
| 'voice'
|
||||
| 'sub-agents'
|
||||
| 'admin';
|
||||
|
||||
const baseTabs = [
|
||||
@ -1900,7 +2147,8 @@ const baseTabs = [
|
||||
{ id: 'tools', label: '工具与 Skills', icon: 'wrench' },
|
||||
{ id: 'files', label: '文件与图片', icon: 'file' },
|
||||
{ id: 'data', label: '数据管理', icon: 'layers' },
|
||||
{ id: 'voice', label: '语音模型', icon: 'mic' }
|
||||
{ id: 'voice', label: '语音模型', icon: 'mic' },
|
||||
{ id: 'sub-agents', label: '子智能体', icon: 'bot' }
|
||||
] as const satisfies ReadonlyArray<{ id: PersonalTab; label: string; icon: IconKey }>;
|
||||
|
||||
const sessionRole = ref('');
|
||||
@ -2498,6 +2746,11 @@ watch(
|
||||
if (isVisible && tab === 'data') {
|
||||
fetchUsageSummary();
|
||||
}
|
||||
if (isVisible && tab === 'sub-agents') {
|
||||
loadSubAgentRoles();
|
||||
loadSubAgentSettings();
|
||||
loadSubAgentModels();
|
||||
}
|
||||
if (
|
||||
isVisible &&
|
||||
tab === 'general' &&
|
||||
@ -2510,6 +2763,145 @@ watch(
|
||||
}
|
||||
);
|
||||
|
||||
// ----- 子智能体管理 -----
|
||||
const subAgentRoles = ref<any[]>([]);
|
||||
const subAgentRolesLoading = ref(false);
|
||||
const subAgentCompressThreshold = ref(150000);
|
||||
const subAgentSettingsSaving = ref(false);
|
||||
const subAgentModels = ref<any[]>([]);
|
||||
const roleEditorOpen = ref(false);
|
||||
const editingRole = ref<any>(null);
|
||||
const roleSaving = ref(false);
|
||||
const roleForm = ref({
|
||||
role_id: '',
|
||||
name: '',
|
||||
description: '',
|
||||
body_prompt: '',
|
||||
thinking_mode: 'fast',
|
||||
model_key: ''
|
||||
});
|
||||
|
||||
const loadSubAgentRoles = async () => {
|
||||
subAgentRolesLoading.value = true;
|
||||
try {
|
||||
const resp = await fetch('/api/multiagent/roles', { credentials: 'same-origin' });
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
subAgentRoles.value = data.roles || [];
|
||||
}
|
||||
} catch (e) {
|
||||
// 静默处理
|
||||
} finally {
|
||||
subAgentRolesLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadSubAgentModels = async () => {
|
||||
try {
|
||||
const resp = await fetch('/api/multiagent/models', { credentials: 'same-origin' });
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
subAgentModels.value = data.models || [];
|
||||
}
|
||||
} catch (e) {
|
||||
// 静默处理
|
||||
}
|
||||
};
|
||||
|
||||
const loadSubAgentSettings = async () => {
|
||||
try {
|
||||
const resp = await fetch('/api/multiagent/settings', { credentials: 'same-origin' });
|
||||
const data = await resp.json();
|
||||
if (data.success && data.settings) {
|
||||
subAgentCompressThreshold.value = data.settings.sub_agent_compress_threshold_tokens || 150000;
|
||||
}
|
||||
} catch (e) {
|
||||
// 静默处理
|
||||
}
|
||||
};
|
||||
|
||||
const saveSubAgentSettings = async () => {
|
||||
subAgentSettingsSaving.value = true;
|
||||
try {
|
||||
await fetch('/api/multiagent/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ settings: { sub_agent_compress_threshold_tokens: subAgentCompressThreshold.value } })
|
||||
});
|
||||
} finally {
|
||||
subAgentSettingsSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openRoleEditor = (role?: any) => {
|
||||
if (role) {
|
||||
editingRole.value = role;
|
||||
roleForm.value = {
|
||||
role_id: role.role_id || '',
|
||||
name: role.name || '',
|
||||
description: role.description || '',
|
||||
body_prompt: role.body_prompt || '',
|
||||
thinking_mode: role.thinking_mode || 'fast',
|
||||
model_key: role.model_key || ''
|
||||
};
|
||||
} else {
|
||||
editingRole.value = null;
|
||||
roleForm.value = { role_id: '', name: '', description: '', body_prompt: '', thinking_mode: 'fast', model_key: '' };
|
||||
}
|
||||
closeDropdown();
|
||||
roleEditorOpen.value = true;
|
||||
};
|
||||
|
||||
const closeRoleEditor = () => {
|
||||
roleEditorOpen.value = false;
|
||||
editingRole.value = null;
|
||||
closeDropdown();
|
||||
};
|
||||
|
||||
const saveRole = async () => {
|
||||
roleSaving.value = true;
|
||||
try {
|
||||
const isEdit = !!editingRole.value;
|
||||
const url = isEdit
|
||||
? `/api/multiagent/roles/${roleForm.value.role_id}`
|
||||
: '/api/multiagent/roles';
|
||||
const method = isEdit ? 'PUT' : 'POST';
|
||||
const resp = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({
|
||||
...roleForm.value,
|
||||
model_key: roleForm.value.model_key || null
|
||||
})
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
closeRoleEditor();
|
||||
await loadSubAgentRoles();
|
||||
}
|
||||
} finally {
|
||||
roleSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteRole = async (role: any) => {
|
||||
if (!confirm(`确认删除角色「${role.name}」?`)) return;
|
||||
try {
|
||||
const resp = await fetch(`/api/multiagent/roles/${role.role_id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
await loadSubAgentRoles();
|
||||
}
|
||||
} catch (e) {
|
||||
// 静默处理
|
||||
}
|
||||
};
|
||||
|
||||
const setActiveTab = (tab: PersonalTab) => {
|
||||
activeTab.value = tab;
|
||||
closeDropdown();
|
||||
@ -2886,6 +3278,123 @@ const applyDefaultHideWorkspaceOption = async (hidden: boolean) => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sub-agent-role-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.sub-agent-role-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 11px 0;
|
||||
border-bottom: 1px solid var(--theme-control-border);
|
||||
}
|
||||
.sub-agent-role-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.sub-agent-role-name {
|
||||
font-size: 14px;
|
||||
font-weight: 550;
|
||||
color: var(--claude-text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.sub-agent-role-tag {
|
||||
font-size: 10px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.sub-agent-role-tag.tag-preset {
|
||||
background: color-mix(in srgb, var(--claude-text-secondary) 15%, transparent);
|
||||
color: var(--claude-text-secondary);
|
||||
}
|
||||
.sub-agent-role-tag.tag-custom {
|
||||
background: color-mix(in srgb, var(--claude-accent) 15%, transparent);
|
||||
color: var(--claude-accent);
|
||||
}
|
||||
.sub-agent-role-desc {
|
||||
font-size: 12.5px;
|
||||
color: var(--claude-text-secondary);
|
||||
margin-top: 3px;
|
||||
line-height: 1.38;
|
||||
}
|
||||
.sub-agent-role-meta {
|
||||
font-size: 11px;
|
||||
color: var(--claude-text-secondary);
|
||||
opacity: 0.7;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.sub-agent-role-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 角色编辑弹窗 — 复用 settings-redesign-card */
|
||||
.role-editor-drawer-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: var(--overlay-scrim);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2147483647;
|
||||
}
|
||||
.role-editor-drawer-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: visible;
|
||||
}
|
||||
.role-editor-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.role-editor-header h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--claude-text);
|
||||
margin: 0;
|
||||
}
|
||||
.role-editor-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: visible;
|
||||
min-height: 0;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
.role-editor-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding-top: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.settings-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.settings-section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 550;
|
||||
color: var(--claude-text);
|
||||
}
|
||||
.settings-empty-hint {
|
||||
color: var(--claude-text-secondary);
|
||||
font-size: 13px;
|
||||
padding: 24px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.settings-redesign-card {
|
||||
--settings-floating-menu-bg: color-mix(in srgb, var(--surface-soft) 90%, var(--surface-raised));
|
||||
--settings-floating-menu-hover: var(--hover-bg);
|
||||
@ -3063,7 +3572,8 @@ const applyDefaultHideWorkspaceOption = async (hidden: boolean) => {
|
||||
.settings-redesign-tabs::-webkit-scrollbar,
|
||||
.settings-redesign-scroll::-webkit-scrollbar,
|
||||
.settings-floating-menu::-webkit-scrollbar,
|
||||
.app-update-changelog-content::-webkit-scrollbar {
|
||||
.app-update-changelog-content::-webkit-scrollbar,
|
||||
.role-editor-body::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
@ -84,7 +84,7 @@ class MessageMixin:
|
||||
"""扫描工作区 .astrion/user_upload 目录,按修改时间倒序生成最近上传文件列表。"""
|
||||
try:
|
||||
project_path = Path(getattr(self, "project_path", ".") or ".").expanduser().resolve()
|
||||
uploads_dir = project_path / ".agents" / "user_upload"
|
||||
uploads_dir = project_path / ".astrion" / "user_upload"
|
||||
if not uploads_dir.is_dir():
|
||||
return ""
|
||||
files = []
|
||||
|
||||
@ -218,7 +218,7 @@ class ProjectMixin:
|
||||
add_entry(entry)
|
||||
|
||||
# 根目录浅扫会跳过点目录;内部文件迁移到 .agents 后,单独展示这几个对模型有意义的目录。
|
||||
agents_dir = self.project_path / ".agents"
|
||||
agents_dir = self.project_path / ".astrion"
|
||||
for internal_name in ("skills", "user_upload", "compact_result", "review"):
|
||||
internal_dir = agents_dir / internal_name
|
||||
if internal_dir.exists() and internal_dir.is_dir():
|
||||
|
||||
Loading…
Reference in New Issue
Block a user