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:
JOJO 2026-07-14 02:28:45 +08:00
parent 3c4ae917b2
commit 4fcd0ccc4d
31 changed files with 1823 additions and 186 deletions

View File

@ -1,6 +1,6 @@
--- ---
name: sub-agent-guide name: sub-agent-guide
description: 子智能体使用指南。创建子智能体之前必须阅读此技能。子智能体仅用于并行处理独立的“非代码编写”任务(如项目初步探索、网络搜索与信息收集、程序测试执行)。关键限制:子智能体之间无法通信,看不到主对话历史。严格禁止将子智能体用于项目代码编写、代码修改、重构或实现功能。 description: 子智能体使用指南。创建子智能体之前必须阅读此技能。子智能体仅用于并行处理独立的“非代码编写”任务(如项目初步探索、网络搜索与信息收集、程序测试执行)。关键限制:子智能体之间无法通信,看不到主对话历史。严格禁止将子智能体用于项目代码编写、代码修改、重构或实现功能。**如果运行模式为多智能体模式,禁止阅读并参考该 skill。**
--- ---
# 子智能体使用指南 # 子智能体使用指南

View File

@ -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") PROMPTS_DIR = _resolve_repo_path(os.environ.get("PROMPTS_DIR", ""), "./prompts")
AGENT_SKILLS_DIR = _resolve_repo_path(os.environ.get("AGENT_SKILLS_DIR", ""), "./agentskills") 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_SKILLS_DIRNAME = ".astrion/skills"
WORKSPACE_MEMORY_DIRNAME = ".astrion/memory" WORKSPACE_MEMORY_DIRNAME = ".astrion/memory"
WORKSPACE_REVIEW_DIRNAME = ".astrion/review" WORKSPACE_REVIEW_DIRNAME = ".astrion/review"
@ -198,6 +204,9 @@ __all__ = [
"ADMIN_POLICY_FILE", "ADMIN_POLICY_FILE",
"API_USER_SPACE_DIR", "API_USER_SPACE_DIR",
"CUSTOM_SKILLS_DIR", "CUSTOM_SKILLS_DIR",
"PRESET_ROLES_DIR",
"CUSTOM_ROLES_DIR",
"WEB_PRESET_ROLES_DIR",
"API_USERS_DB_FILE", "API_USERS_DB_FILE",
"API_TOKENS_FILE", "API_TOKENS_FILE",
"API_USAGE_FILE", "API_USAGE_FILE",

View File

@ -1740,11 +1740,18 @@ class MainTerminalToolsExecutionMixin:
role_id = arguments.get("role_id") role_id = arguments.get("role_id")
if not role_id: if not role_id:
result = {"success": False, "error": "多智能体模式下 create_sub_agent 必须指定 role_id"} result = {"success": False, "error": "多智能体模式下 create_sub_agent 必须指定 role_id"}
elif arguments.get("deliverables_dir"):
result = {"success": False, "error": "多智能体模式下不支持交付目录参数"}
else: else:
try: 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 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_dirweb模式下 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: if not role:
result = {"success": False, "error": f"角色不存在: {role_id}"} result = {"success": False, "error": f"角色不存在: {role_id}"}
else: else:
@ -1756,21 +1763,41 @@ class MainTerminalToolsExecutionMixin:
agent_id = multi_agent_state.next_agent_id_for_role(role_id) agent_id = multi_agent_state.next_agent_id_for_role(role_id)
# 构造显示名 # 构造显示名
display_name = role.display_name(int(agent_id)) display_name = role.display_name(int(agent_id))
# 构造多智能体版系统提示词 # 构造多智能体版系统提示词(含动态上下文注入)
workspace_path = str(getattr(self, "project_path", "")) 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 的任务发布) # 构造 task_message作为 Team Leader 的任务发布)
from modules.multi_agent.state import build_master_dispatch_text from modules.multi_agent.state import build_master_dispatch_text
task_message = build_master_dispatch_text(arguments.get("task", "")) task_message = build_master_dispatch_text(arguments.get("task", ""))
summary_text = (arguments.get("summary") or f"{role.name}作业")[:80] summary_text = (arguments.get("summary") or f"{role.name}作业")[:80]
thinking_mode = arguments.get("thinking_mode") or role.thinking_mode or "fast" 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 调用) # 走原行 发事件创建(避免后期重建提供重复工能重费,直接使用 multi_agent_mode=True 调用)
result = self.sub_agent_manager.create_sub_agent( result = self.sub_agent_manager.create_sub_agent(
agent_id=int(agent_id), agent_id=int(agent_id),
summary=summary_text, summary=summary_text,
task=arguments.get("task", ""), task=arguments.get("task", ""),
run_in_background=False, run_in_background=False,
timeout_seconds=arguments.get("timeout_seconds"),
conversation_id=conv_id, conversation_id=conv_id,
model_key=role.model_key, model_key=role.model_key,
thinking_mode=thinking_mode, thinking_mode=thinking_mode,
@ -1779,6 +1806,7 @@ class MainTerminalToolsExecutionMixin:
display_name=display_name, display_name=display_name,
system_prompt=system_prompt, system_prompt=system_prompt,
task_message=task_message, task_message=task_message,
compress_threshold_tokens=_compress_threshold,
) )
# 在多智能体模式下,子智能体是团队协作成员,不是传统后台任务。 # 在多智能体模式下,子智能体是团队协作成员,不是传统后台任务。
# run_in_background=False 避免触发后台完成通知轮询,保持主对话输入区可用。 # run_in_background=False 避免触发后台完成通知轮询,保持主对话输入区可用。
@ -1972,23 +2000,33 @@ class MainTerminalToolsExecutionMixin:
body_prompt = arguments.get("body_prompt", "").strip() body_prompt = arguments.get("body_prompt", "").strip()
description = arguments.get("description", "").strip() description = arguments.get("description", "").strip()
thinking_mode_arg = arguments.get("thinking_mode", "fast") 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: if not role_id or not name or not body_prompt:
result = {"success": False, "error": "role_id/name/body_prompt 必填"} result = {"success": False, "error": "role_id/name/body_prompt 必填"}
else: 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: if role_id in existing_ids:
result = {"success": False, "error": f"角色 {role_id} 已存在"} result = {"success": False, "error": f"角色 {role_id} 已存在"}
else: else:
role = RoleConfig(role_id=role_id, name=name, description=description, body_prompt=body_prompt, thinking_mode=thinking_mode_arg) 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) f = save_custom_role(role, custom_dir=_custom_dir)
result = {"success": True, "role_id": role_id, "file": str(f)} result = {"success": True, "role_id": role_id, "file": str(f)}
except Exception as exc: except Exception as exc:
result = {"success": False, "error": str(exc)} result = {"success": False, "error": str(exc)}
elif tool_name == "list_agents": elif tool_name == "list_agents":
try: try:
from modules.multi_agent.role_store import list_roles from modules.multi_agent.role_store import list_roles, infer_custom_roles_dir
roles = list_roles() _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]} result = {"success": True, "roles": [r.to_dict() for r in roles]}
except Exception as exc: except Exception as exc:
result = {"success": False, "error": str(exc)} result = {"success": False, "error": str(exc)}

View File

@ -123,8 +123,8 @@ class ApiUserManager:
project_path = work_root / "project" project_path = work_root / "project"
data_dir = work_root / "data" data_dir = work_root / "data"
logs_dir = work_root / "logs" logs_dir = work_root / "logs"
uploads_dir = project_path / ".agents" / "user_upload" uploads_dir = project_path / ".astrion" / "user_upload"
skills_dir = project_path / ".agents" / "skills" 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): 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) path.mkdir(parents=True, exist_ok=True)

View File

@ -64,13 +64,41 @@ def build_multi_agent_master_prompt(workspace_path: str, base: str = "") -> str:
return f"{body}\n" 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 `role_body` 为该角色 Markdown 文件 frontmatter 之后的自定义 prompt
`display_name` 为该实例的显示名 `UI Operator_1` `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: def build_available_agents_prompt() -> str:

View File

@ -1,62 +1,136 @@
"""多智能体角色存储与解析。 """多智能体角色存储与解析。
角色定义文件格式Markdown + Frontmatter: 三层结构
--- 1. **源码树预设**``multi_agent_roles/``随版本分发只读种子
id: ui-operator 2. **运行态预设**
name: UI Operator - host 模式 = ``host/mutiagents/agents/``同时也是自定义目录
description: 界面设计与前端实现 - web 模式 = ``web/mutiagents/agents/``
model: "" 3. **用户自定义** web 模式按用户隔离
thinking_mode: fast - web 模式 = ``web/users/<user>/personal/mutiagents/agents/``
skills: - host 模式 = 和预设同目录
- frontend-design
---
自定义 prompt body... 启动时调用 `sync_preset_roles()` 把源码树预设同步到 host web 两个运行态预设目录
角色目录 **重要**本模块不再依赖 `IS_HOST_MODE` 判断运行模式
- ~/.astrion/astrion/host/mutiagents/agents/<role_id>.md 调用方API / tools_execution通过参数显式传入 `runtime_dir` `custom_dir`
- 不传则仅扫描源码树预设最小安全回退
存储约定见 .astrion/memory/multi_agent_mode_design.md - host 模式`runtime_dir` `custom_dir` 指向同一目录
- web 模式`runtime_dir` 指向 web 预设目录`custom_dir` 指向用户个人目录
""" """
from __future__ import annotations from __future__ import annotations
import json
import re import re
import shutil
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional, Set
try: 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: except ImportError:
# 防止直接 import 时 config 尚未加载,提供回退值
IS_HOST_MODE = True
RUNTIME_ROOT = str(Path.home() / ".astrion" / "astrion") RUNTIME_ROOT = str(Path.home() / ".astrion" / "astrion")
PRESET_ROLES_DIR = ""
# 复用项目的 MUTIAGENTS 数据目录(保留原拼写 "mutiagents",见项目记忆) CUSTOM_ROLES_DIR = ""
DEFAULT_MUTIAGENTS_DIR = Path(RUNTIME_ROOT) / ("host" if IS_HOST_MODE else "web") / "mutiagents" WEB_PRESET_ROLES_DIR = ""
AGENTS_DIR_NAME = "agents"
FRONTMATTER_RE = re.compile(r"^---\s*\n(?P<body>.*?)\n---\s*\n(?P<rest>.*)$", re.S) 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]: def _parse_frontmatter(text: str) -> tuple[Dict[str, Any], str]:
"""解析 Markdown frontmatter返回 (元数据 dict, body)。"""
m = FRONTMATTER_RE.match(text) m = FRONTMATTER_RE.match(text)
if not m: if not m:
return {}, text return {}, text
raw_meta = m.group("body") or "" raw_meta = m.group("body") or ""
body = m.group("rest") or "" body = m.group("rest") or ""
meta: Dict[str, Any] = {} meta: Dict[str, Any] = {}
# 简易解析k: v 与 k: 列表项,不引入 yaml 依赖)
current_key: Optional[str] = None current_key: Optional[str] = None
for line in raw_meta.splitlines(): for line in raw_meta.splitlines():
if not line.strip(): if not line.strip():
@ -74,14 +148,74 @@ def _parse_frontmatter(text: str) -> tuple[Dict[str, Any], str]:
meta[k] = v meta[k] = v
current_key = None current_key = None
else: else:
# 可能是列表块
meta[k] = [] meta[k] = []
current_key = k current_key = k
return meta, body.strip() return meta, body.strip()
def load_role_from_file(file_path: Path) -> Optional["RoleConfig"]: # ----- RoleConfig -----
"""从 Markdown 文件加载角色定义。"""
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(): if not file_path.exists() or not file_path.is_file():
return None return None
text = file_path.read_text(encoding="utf-8") 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"), thinking_mode=str(meta.get("thinking_mode") or "fast"),
skills=list(meta.get("skills") or []), skills=list(meta.get("skills") or []),
source_file=str(file_path), 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 _serialize_role(role: RoleConfig) -> str:
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 字符串。"""
lines = ["---"] lines = ["---"]
lines.append(f"id: {role.role_id}") lines.append(f"id: {role.role_id}")
lines.append(f"name: {role.name}") lines.append(f"name: {role.name}")
if role.description: if role.description:
# 单行描述转义
desc = role.description.replace('"', '\\"') desc = role.description.replace('"', '\\"')
lines.append(f'description: "{desc}"') lines.append(f'description: "{desc}"')
if role.model_key: if role.model_key:
@ -149,60 +257,142 @@ def _serialize_role(role: "RoleConfig") -> str:
return "\n".join(lines) 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""" """生成子智能体的「专属 prompt」部分frontmatter 之后的 body"""
return role.body_prompt or "" 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}>"

View 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)

View File

@ -53,10 +53,6 @@ def _master_tool_create_sub_agent() -> Dict[str, Any]:
"type": "integer", "type": "integer",
"description": "(可选)手动指定实例编号;不传时自动递增。", "description": "(可选)手动指定实例编号;不传时自动递增。",
}, },
"timeout_seconds": {
"type": "integer",
"description": "(可选)超时秒数。不填表示该子智能体不会被时间终结,适用于可能多轮长期任务的子智能体;一般情况下推荐创建永久子智能体。",
},
"thinking_mode": { "thinking_mode": {
"type": "string", "type": "string",
"enum": ["fast", "thinking"], "enum": ["fast", "thinking"],

View File

@ -19,9 +19,7 @@ from config import (
SUB_AGENT_DEFAULT_TIMEOUT, SUB_AGENT_DEFAULT_TIMEOUT,
SUB_AGENT_MAX_ACTIVE, SUB_AGENT_MAX_ACTIVE,
SUB_AGENT_MODELS_CONFIG_FILE, SUB_AGENT_MODELS_CONFIG_FILE,
SUB_AGENT_STATE_FILE,
SUB_AGENT_STATUS_POLL_INTERVAL, SUB_AGENT_STATUS_POLL_INTERVAL,
SUB_AGENT_TASKS_BASE_DIR,
) )
from utils.logger import setup_logger from utils.logger import setup_logger
from modules.sub_agent.task import SubAgentTask from modules.sub_agent.task import SubAgentTask
@ -51,8 +49,9 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi
): ):
self.project_path = Path(project_path).resolve() self.project_path = Path(project_path).resolve()
self.data_dir = Path(data_dir).resolve() self.data_dir = Path(data_dir).resolve()
self.base_dir = Path(SUB_AGENT_TASKS_BASE_DIR).resolve() # 子智能体任务和状态按 data_dir 隔离web 模式下按用户/工作区自动隔离)
self.state_file = Path(SUB_AGENT_STATE_FILE).resolve() 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.models_config_file = SUB_AGENT_MODELS_CONFIG_FILE
self.container_session: Optional["ContainerHandle"] = container_session self.container_session: Optional["ContainerHandle"] = container_session
self.host_execution_mode: str = "sandbox" self.host_execution_mode: str = "sandbox"
@ -153,6 +152,7 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi
display_name: Optional[str] = None, display_name: Optional[str] = None,
system_prompt: Optional[str] = None, system_prompt: Optional[str] = None,
task_message: Optional[str] = None, task_message: Optional[str] = None,
compress_threshold_tokens: Optional[int] = None,
) -> Dict: ) -> Dict:
"""创建子智能体任务并启动协程。 """创建子智能体任务并启动协程。
@ -239,6 +239,7 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi
"role_id": role_id, "role_id": role_id,
"display_name": display_name, "display_name": display_name,
"execution_mode": "in_process", "execution_mode": "in_process",
"compress_threshold_tokens": compress_threshold_tokens,
"container_name": None, "container_name": None,
} }
self.tasks[task_id] = task_record self.tasks[task_id] = task_record

View File

@ -73,6 +73,14 @@ class SubAgentTask:
self.stats_file = Path(task_record["stats_file"]) self.stats_file = Path(task_record["stats_file"])
self.progress_file = Path(task_record["progress_file"]) self.progress_file = Path(task_record["progress_file"])
self.conversation_file = Path(task_record["conversation_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]] = [ self.messages: List[Dict[str, Any]] = [
{"role": "system", "content": system_prompt}, {"role": "system", "content": system_prompt},
@ -89,6 +97,8 @@ class SubAgentTask:
"commands": 0, "commands": 0,
"api_calls": 0, "api_calls": 0,
"token_usage": {"prompt": 0, "completion": 0, "total": 0}, "token_usage": {"prompt": 0, "completion": 0, "total": 0},
"current_context_tokens": 0,
"compress_round": 0,
} }
self._stdout_lines: List[str] = [] self._stdout_lines: List[str] = []
self._cancelled = False self._cancelled = False
@ -222,6 +232,21 @@ class SubAgentTask:
if usage: if usage:
self._apply_usage(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 转发到主对话 # 多智能体模式:把 assistant 文本输出作为进度/完成 output 转发到主对话
if self.multi_agent_mode and self.multi_agent_state and assistant_message.strip(): 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) self._forward_output_to_master(assistant_message, is_final=not tool_calls)
@ -591,7 +616,7 @@ class SubAgentTask:
self.stats["edit_files"] += 1 self.stats["edit_files"] += 1
elif name == "search_workspace": elif name == "search_workspace":
self.stats["searches"] += 1 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 self.stats["web_pages"] += 1
elif name == "run_command": elif name == "run_command":
self.stats["commands"] += 1 self.stats["commands"] += 1
@ -605,9 +630,156 @@ class SubAgentTask:
self.stats["token_usage"]["prompt"] += int(prompt) self.stats["token_usage"]["prompt"] += int(prompt)
self.stats["token_usage"]["completion"] += int(completion) self.stats["token_usage"]["completion"] += int(completion)
self.stats["token_usage"]["total"] += int(total) 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: except Exception:
pass 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: def _build_media_tool_content(self, result: Dict[str, Any]) -> Any:
"""把 read_mediafile 结果转成 OpenAI 多模态 content。""" """把 read_mediafile 结果转成 OpenAI 多模态 content。"""
b64 = result.get("b64") b64 = result.get("b64")

View File

@ -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] = { FINISH_TOOL: Dict[str, Any] = {
@ -263,6 +355,24 @@ def _format_tool_result(name: str, raw: Any) -> str:
return "" return ""
if name == "read_mediafile": if name == "read_mediafile":
return raw.get("message") or "媒体已读取" 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) 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 return None
modes_text = str(model_raw.get("modes") or model_raw.get("mode") or model_raw.get("supported_modes") or "").lower() 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 supports_thinking = "thinking" in modes_text or "思考" in modes_text
fast_only = modes_text == "fast" 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_text = str(model_raw.get("multimodal") or model_raw.get("multi_modal") or model_raw.get("multi") or "none").lower()
multimodal = "none" 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() model_id = str(model_raw.get("model_id") or name).strip()
extra = _pick_dict(model_raw, ["extra_parameter", "extra_params", "extra"]) 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"]) 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"]) 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] = { profile: Dict[str, Any] = {
"name": name, "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}, "extra_params": {**extra, **thinking_extra},
} }
else: else:
# 即使不支持 thinking也保留 fast 的 extra_params含 disabled 参数)
# 避免不支持 thinking 的模型在 fast 模式下漏掉 thinking.type=disabled 参数
profile["thinking"] = None profile["thinking"] = None
return profile return profile

View File

@ -362,8 +362,8 @@ class UserManager:
project_path = workspace_root / "project" project_path = workspace_root / "project"
data_dir = workspace_root / "data" data_dir = workspace_root / "data"
logs_dir = workspace_root / "logs" logs_dir = workspace_root / "logs"
uploads_dir = project_path / ".agents" / "user_upload" uploads_dir = project_path / ".astrion" / "user_upload"
skills_dir = project_path / ".agents" / "skills" skills_dir = project_path / ".astrion" / "skills"
for path in [project_path, data_dir, logs_dir, uploads_dir, skills_dir]: for path in [project_path, data_dir, logs_dir, uploads_dir, skills_dir]:
path.mkdir(parents=True, exist_ok=True) path.mkdir(parents=True, exist_ok=True)

View 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 修改代码
- 输出结构化报告:问题列表(级别/位置/原因/建议)
- 引用代码时给出文件路径和行号范围
- 不重复指出同类问题

View 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 中的代码规范(最小改动、文件拆分、风格指南等)
- 修改后做最小验证(运行命令、构建、类型检查)
- 不要频繁输出大段代码注释,只在关键步骤展示

View 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 条结论
- 报告结构:摘要 → 关键发现 → 详表/对比 → 行动建议

View 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 美学的硬性规则(不写死颜色、无圆角套娃、图标对齐等)
- 不修改与界面无关的后端或业务代码

View File

@ -1342,6 +1342,13 @@ def initialize_system(path: str, thinking_mode: bool = False):
print(f"[Init] 调试日志: {DEBUG_LOG_FILE}") print(f"[Init] 调试日志: {DEBUG_LOG_FILE}")
app.config['DEFAULT_THINKING_MODE'] = thinking_mode app.config['DEFAULT_THINKING_MODE'] = thinking_mode
app.config['DEFAULT_RUN_MODE'] = "thinking" if thinking_mode else "fast" 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系统初始化完成多用户模式") print(f"{OUTPUT_FORMATS['success']} Web系统初始化完成多用户模式")

View File

@ -224,7 +224,7 @@ def host_login():
data_dir.mkdir(parents=True, exist_ok=True) data_dir.mkdir(parents=True, exist_ok=True)
logs_dir = Path(LOGS_DIR).expanduser().resolve() logs_dir = Path(LOGS_DIR).expanduser().resolve()
logs_dir.mkdir(parents=True, exist_ok=True) 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) uploads_dir.mkdir(parents=True, exist_ok=True)
quarantine_root = Path(UPLOAD_QUARANTINE_SUBDIR).expanduser() quarantine_root = Path(UPLOAD_QUARANTINE_SUBDIR).expanduser()
if not quarantine_root.is_absolute(): if not quarantine_root.is_absolute():

View File

@ -180,9 +180,9 @@ def get_user_resources(
data_dir.mkdir(parents=True, exist_ok=True) data_dir.mkdir(parents=True, exist_ok=True)
logs_dir = Path(LOGS_DIR).expanduser().resolve() logs_dir = Path(LOGS_DIR).expanduser().resolve()
logs_dir.mkdir(parents=True, exist_ok=True) 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) 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) skills_dir.mkdir(parents=True, exist_ok=True)
quarantine_root = Path(UPLOAD_QUARANTINE_SUBDIR).expanduser() quarantine_root = Path(UPLOAD_QUARANTINE_SUBDIR).expanduser()
if not quarantine_root.is_absolute(): if not quarantine_root.is_absolute():

View File

@ -2064,7 +2064,7 @@ def review_conversation(conversation_id, terminal: WebTerminal, workspace: UserW
content = "\n".join(lines) + "\n" content = "\n".join(lines) + "\n"
char_count = len(content) 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) review_dir.mkdir(parents=True, exist_ok=True)
title = conversation_data.get("title") or "untitled" title = conversation_data.get("title") or "untitled"

View File

@ -246,7 +246,7 @@ def _write_compact_file(
latest_user_input: str, latest_user_input: str,
previous_records: List[Dict[str, Any]], previous_records: List[Dict[str, Any]],
) -> str: ) -> str:
compact_dir = project_path / ".agents" / "compact_result" compact_dir = project_path / ".astrion" / "compact_result"
compact_dir.mkdir(parents=True, exist_ok=True) compact_dir.mkdir(parents=True, exist_ok=True)
filename = f"compact_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{compression_index:03d}.md" filename = f"compact_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{compression_index:03d}.md"
file_path = compact_dir / filename file_path = compact_dir / filename

View File

@ -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.auth_helpers import api_login_required, login_required, get_current_username
from server.context import get_user_resources 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__) multi_agent_bp = Blueprint("multi_agent", __name__)
@ -57,13 +67,35 @@ def rebuild_conversation_index_api():
return jsonify({"success": False, "error": str(exc)}), 500 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"]) @multi_agent_bp.route("/api/multiagent/roles", methods=["GET"])
@api_login_required @api_login_required
def list_roles_api(): def list_roles_api():
"""列出全部可用角色(预置+自定义)。""" """列出全部可用角色(预置+用户自定义)。"""
try: try:
from modules.multi_agent.role_store import list_roles runtime_dir, custom_dir = _get_role_dirs()
roles = list_roles() roles = list_roles(runtime_dir=runtime_dir, custom_dir=custom_dir)
return jsonify({ return jsonify({
"success": True, "success": True,
"roles": [r.to_dict() for r in roles], "roles": [r.to_dict() for r in roles],
@ -77,19 +109,23 @@ def list_roles_api():
def create_role_api(): def create_role_api():
"""创建自定义角色。body: { role_id, name, description?, body_prompt, thinking_mode? }""" """创建自定义角色。body: { role_id, name, description?, body_prompt, thinking_mode? }"""
try: try:
from modules.multi_agent.role_store import RoleConfig, save_custom_role, list_roles
data = request.get_json() or {} data = request.get_json() or {}
role_id = str(data.get("role_id") or "").strip() role_id = str(data.get("role_id") or "").strip()
name = str(data.get("name") or "").strip() name = str(data.get("name") or "").strip()
body_prompt = str(data.get("body_prompt") or "").strip() body_prompt = str(data.get("body_prompt") or "").strip()
description = str(data.get("description") or "").strip() description = str(data.get("description") or "").strip()
thinking_mode = str(data.get("thinking_mode") or "fast").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: if not role_id or not name or not body_prompt:
return jsonify({"success": False, "error": "role_id/name/body_prompt 必填"}), 400 return jsonify({"success": False, "error": "role_id/name/body_prompt 必填"}), 400
if thinking_mode not in {"fast", "thinking"}: if thinking_mode not in {"fast", "thinking"}:
thinking_mode = "fast" 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: if role_id in existing:
return jsonify({"success": False, "error": f"角色 {role_id} 已存在"}), 409 return jsonify({"success": False, "error": f"角色 {role_id} 已存在"}), 409
role = RoleConfig( role = RoleConfig(
@ -98,13 +134,117 @@ def create_role_api():
description=description, description=description,
body_prompt=body_prompt, body_prompt=body_prompt,
thinking_mode=thinking_mode, 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)}) return jsonify({"success": True, "role_id": role_id, "file": str(saved)})
except Exception as exc: except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 500 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"]) @multi_agent_bp.route("/api/multiagent/conversations", methods=["POST"])
@api_login_required @api_login_required
def create_multi_agent_conversation(): def create_multi_agent_conversation():
@ -206,6 +346,41 @@ def create_multi_agent_conversation():
}), 201 }), 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"]) @multi_agent_bp.route("/api/multiagent/active_sub_agents", methods=["GET"])
@api_login_required @api_login_required
def list_active_sub_agents_api(): def list_active_sub_agents_api():

View File

@ -328,6 +328,8 @@
:goal-mode-armed="goalModeArmed" :goal-mode-armed="goalModeArmed"
:goal-running="goalRunning" :goal-running="goalRunning"
:goal-progress="goalProgress" :goal-progress="goalProgress"
:multi-agent-mode="multiAgentMode"
:has-running-task="composerBusy || anyWorkspaceHasRunningTask"
:avatar-status="showStatusAvatar && messages.length > 0 ? avatarStatus : null" :avatar-status="showStatusAvatar && messages.length > 0 ? avatarStatus : null"
@restore-user-question="restoreUserQuestionDialog" @restore-user-question="restoreUserQuestionDialog"
@update:input-message="inputSetMessage" @update:input-message="inputSetMessage"
@ -366,6 +368,7 @@
@delete-runtime-message="handleDeleteRuntimeMessage" @delete-runtime-message="handleDeleteRuntimeMessage"
@composer-height-change="handleComposerHeightChange" @composer-height-change="handleComposerHeightChange"
@toggle-goal-mode="handleToggleGoalMode" @toggle-goal-mode="handleToggleGoalMode"
@toggle-agent-mode="handleToggleAgentMode"
@open-goal-dialog="goalDialogOpen = true" @open-goal-dialog="goalDialogOpen = true"
@open-git-changes-panel="openGitChangesPanel" @open-git-changes-panel="openGitChangesPanel"
/> />

View File

@ -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() { streamingUi() {
return this.streamingMessage || this.hasPendingToolActions(); return this.streamingMessage || this.hasPendingToolActions();
}, },

View File

@ -65,6 +65,19 @@ export const modeMethods = {
this.modeMenuOpen = false; this.modeMenuOpen = false;
this.modelMenuOpen = 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() { toggleModeMenu() {
if (!this.isConnected || this.streamingMessage) { if (!this.isConnected || this.streamingMessage) {
return; return;

View File

@ -38,14 +38,6 @@
宿主机模式免登录 宿主机模式免登录
</button> </button>
<button
class="auth-secondary-button auth-tertiary-button"
@click="enterMultiAgent"
title="多智能体协作 beta"
>
多智能体模式beta
</button>
<div class="auth-error">{{ error }}</div> <div class="auth-error">{{ error }}</div>
<div class="auth-link">还没有账号<a href="/register">点击注册</a></div> <div class="auth-link">还没有账号<a href="/register">点击注册</a></div>
</section> </section>
@ -133,18 +125,6 @@ const doHostLogin = async (redirectUrl = '/') => {
const hostLogin = () => doHostLogin('/'); 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 () => { onMounted(async () => {
applyTheme(loadTheme()); applyTheme(loadTheme());

View File

@ -353,6 +353,8 @@
:execution-mode-enabled="executionModeEnabled" :execution-mode-enabled="executionModeEnabled"
:goal-mode-armed="goalModeArmed" :goal-mode-armed="goalModeArmed"
:goal-running="goalRunning" :goal-running="goalRunning"
:multi-agent-mode="multiAgentMode"
:has-running-task="hasRunningTask"
@quick-upload="triggerQuickUpload" @quick-upload="triggerQuickUpload"
@pick-images="$emit('pick-images')" @pick-images="$emit('pick-images')"
@pick-video="$emit('pick-video')" @pick-video="$emit('pick-video')"
@ -371,6 +373,7 @@
@open-review="$emit('open-review')" @open-review="$emit('open-review')"
@open-path-authorization="$emit('open-path-authorization')" @open-path-authorization="$emit('open-path-authorization')"
@toggle-goal-mode="$emit('toggle-goal-mode')" @toggle-goal-mode="$emit('toggle-goal-mode')"
@toggle-agent-mode="$emit('toggle-agent-mode')"
/> />
<div class="permission-switcher" @click.stop> <div class="permission-switcher" @click.stop>
<button <button
@ -545,7 +548,8 @@ const emit = defineEmits([
'restore-user-question', 'restore-user-question',
'toggle-goal-mode', 'toggle-goal-mode',
'open-goal-dialog', 'open-goal-dialog',
'open-git-changes-panel' 'open-git-changes-panel',
'toggle-agent-mode'
]); ]);
const props = defineProps<{ const props = defineProps<{
@ -614,6 +618,8 @@ const props = defineProps<{
goalModeArmed?: boolean; goalModeArmed?: boolean;
goalRunning?: boolean; goalRunning?: boolean;
goalProgress?: Record<string, any> | null; goalProgress?: Record<string, any> | null;
multiAgentMode?: boolean;
hasRunningTask?: boolean;
hasPendingRuntimeGuidance?: boolean; hasPendingRuntimeGuidance?: boolean;
terminalCount?: number; terminalCount?: number;
hostMode?: boolean; hostMode?: boolean;

View File

@ -63,6 +63,20 @@
<template v-else>{{ goalModeArmed ? '已就绪' : '' }}</template> <template v-else>{{ goalModeArmed ? '已就绪' : '' }}</template>
</span> </span>
</button> </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 <button
type="button" type="button"
class="menu-entry has-submenu" class="menu-entry has-submenu"
@ -200,6 +214,8 @@ const props = defineProps<{
goalModeArmed?: boolean; goalModeArmed?: boolean;
goalRunning?: boolean; goalRunning?: boolean;
goalProgress?: Record<string, any> | null; goalProgress?: Record<string, any> | null;
multiAgentMode?: boolean;
hasRunningTask?: boolean;
}>(); }>();
defineEmits<{ defineEmits<{
@ -219,6 +235,7 @@ defineEmits<{
(event: 'pick-video'): void; (event: 'pick-video'): void;
(event: 'open-path-authorization'): void; (event: 'open-path-authorization'): void;
(event: 'toggle-goal-mode'): void; (event: 'toggle-goal-mode'): void;
(event: 'toggle-agent-mode'): void;
}>(); }>();
const getIconStyle = (key: string) => (props.iconStyle ? props.iconStyle(key) : {}); 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); color: var(--claude-accent);
font-size: 12px; 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> </style>

View File

@ -1736,7 +1736,252 @@
</section> </section>
<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 BodyMarkdown</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" key="admin"
class="settings-page admin-monitor-page" class="settings-page admin-monitor-page"
> >
@ -1888,6 +2133,8 @@ type PersonalTab =
| 'tools' | 'tools'
| 'files' | 'files'
| 'data' | 'data'
| 'voice'
| 'sub-agents'
| 'admin'; | 'admin';
const baseTabs = [ const baseTabs = [
@ -1900,7 +2147,8 @@ const baseTabs = [
{ id: 'tools', label: '工具与 Skills', icon: 'wrench' }, { id: 'tools', label: '工具与 Skills', icon: 'wrench' },
{ id: 'files', label: '文件与图片', icon: 'file' }, { id: 'files', label: '文件与图片', icon: 'file' },
{ id: 'data', label: '数据管理', icon: 'layers' }, { 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 }>; ] as const satisfies ReadonlyArray<{ id: PersonalTab; label: string; icon: IconKey }>;
const sessionRole = ref(''); const sessionRole = ref('');
@ -2498,6 +2746,11 @@ watch(
if (isVisible && tab === 'data') { if (isVisible && tab === 'data') {
fetchUsageSummary(); fetchUsageSummary();
} }
if (isVisible && tab === 'sub-agents') {
loadSubAgentRoles();
loadSubAgentSettings();
loadSubAgentModels();
}
if ( if (
isVisible && isVisible &&
tab === 'general' && 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) => { const setActiveTab = (tab: PersonalTab) => {
activeTab.value = tab; activeTab.value = tab;
closeDropdown(); closeDropdown();
@ -2886,6 +3278,123 @@ const applyDefaultHideWorkspaceOption = async (hidden: boolean) => {
</script> </script>
<style scoped> <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-redesign-card {
--settings-floating-menu-bg: color-mix(in srgb, var(--surface-soft) 90%, var(--surface-raised)); --settings-floating-menu-bg: color-mix(in srgb, var(--surface-soft) 90%, var(--surface-raised));
--settings-floating-menu-hover: var(--hover-bg); --settings-floating-menu-hover: var(--hover-bg);
@ -3063,7 +3572,8 @@ const applyDefaultHideWorkspaceOption = async (hidden: boolean) => {
.settings-redesign-tabs::-webkit-scrollbar, .settings-redesign-tabs::-webkit-scrollbar,
.settings-redesign-scroll::-webkit-scrollbar, .settings-redesign-scroll::-webkit-scrollbar,
.settings-floating-menu::-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; display: none;
} }

View File

@ -84,7 +84,7 @@ class MessageMixin:
"""扫描工作区 .astrion/user_upload 目录,按修改时间倒序生成最近上传文件列表。""" """扫描工作区 .astrion/user_upload 目录,按修改时间倒序生成最近上传文件列表。"""
try: try:
project_path = Path(getattr(self, "project_path", ".") or ".").expanduser().resolve() 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(): if not uploads_dir.is_dir():
return "" return ""
files = [] files = []

View File

@ -218,7 +218,7 @@ class ProjectMixin:
add_entry(entry) add_entry(entry)
# 根目录浅扫会跳过点目录;内部文件迁移到 .agents 后,单独展示这几个对模型有意义的目录。 # 根目录浅扫会跳过点目录;内部文件迁移到 .agents 后,单独展示这几个对模型有意义的目录。
agents_dir = self.project_path / ".agents" agents_dir = self.project_path / ".astrion"
for internal_name in ("skills", "user_upload", "compact_result", "review"): for internal_name in ("skills", "user_upload", "compact_result", "review"):
internal_dir = agents_dir / internal_name internal_dir = agents_dir / internal_name
if internal_dir.exists() and internal_dir.is_dir(): if internal_dir.exists() and internal_dir.is_dir():