Compare commits

..

No commits in common. "4cf9c381371e99cc3ce373c56d222d6dab1345c0" and "c6c8bf2d0c02c2be5cbc7c686f811833c10912b1" have entirely different histories.

29 changed files with 105 additions and 588 deletions

View File

@ -168,8 +168,6 @@ 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"
# 行业通用技能目录Agent Skills 开放标准,工作区根目录下,扫描不同步复制)
PROJECT_AGENTS_SKILLS_DIRNAME = ".agents/skills"
WORKSPACE_MEMORY_DIRNAME = ".astrion/memory"
WORKSPACE_REVIEW_DIRNAME = ".astrion/review"
HOST_WORKSPACES_FILE = _resolve_repo_path(
@ -195,7 +193,6 @@ __all__ = [
"LOGS_DIR",
"AGENT_SKILLS_DIR",
"WORKSPACE_SKILLS_DIRNAME",
"PROJECT_AGENTS_SKILLS_DIRNAME",
"WORKSPACE_MEMORY_DIRNAME",
"WORKSPACE_REVIEW_DIRNAME",
"IS_HOST_MODE",

View File

@ -88,18 +88,18 @@ logger = setup_logger(__name__)
DISABLE_LENGTH_CHECK = True
def _build_sub_md_notice(filename: str, sub_paths: List[str], total: int) -> str:
"""构建子目录指令文件路径通知文案(只列相对路径,不注入内容)。无子目录文件时返回空串。"""
def _build_sub_agents_md_notice(sub_paths: List[str], total: int) -> str:
"""构建子目录 AGENTS.md 路径通知文案(只列相对路径,不注入内容)。无子目录文件时返回空串。"""
if not sub_paths:
return ""
lines = [f"另外,工作区以下子目录也存在 {filename} 规范文件:", ""]
lines = ["另外,工作区以下子目录也存在 AGENTS.md 规范文件:", ""]
for p in sub_paths:
lines.append(f"- `{p}`")
if total > len(sub_paths):
lines.append("")
lines.append(f"...共 {total}")
lines.append("")
lines.append(f"这些子目录规范仅适用于其所在目录范围。若你的操作涉及上述目录,请先自行读取对应 {filename} 了解局部规范后再动手,不得将子目录规范当作全局规范套用。")
lines.append("这些子目录规范仅适用于其所在目录范围。若你的操作涉及上述目录,请先自行读取对应 AGENTS.md 了解局部规范后再动手,不得将子目录规范当作全局规范套用。")
return "\n".join(lines)
@ -181,7 +181,7 @@ class MessagesMixin:
personalization_config = getattr(self.context_manager, "custom_personalization_config", None) or load_personalization_config(self.data_dir)
shallow_replace_enabled = bool(personalization_config.get("auto_shallow_compress_enabled", False)) if isinstance(personalization_config, dict) else False
# 顺序主prompt → 权限模式 → 执行环境 → 最近对话 → 个性化配置 → 工作区信息 → AGENTS.md → CLAUDE.md → skills → 记忆 → 自定义 → 禁用提示
# 顺序主prompt → 权限模式 → 执行环境 → 最近对话 → 个性化配置 → 工作区信息 → AGENTS.md → skills → 记忆 → 自定义 → 禁用提示
# 权限模式
permission_mode_message = self._get_or_init_frozen_mode_prompt(
@ -275,7 +275,7 @@ class MessagesMixin:
return ""
# 子目录 AGENTS.md 只通知相对路径,不注入内容
sub_paths, sub_total = self._load_sub_agents_md_paths()
sub_notice = _build_sub_md_notice("AGENTS.md", sub_paths, sub_total)
sub_notice = _build_sub_agents_md_notice(sub_paths, sub_total)
agents_md_template = self.load_prompt("agents_md_inject").strip()
if agents_md_template and "{{AGENTS_MD_CONTENT}}" in agents_md_template:
text = agents_md_template.replace("{{AGENTS_MD_CONTENT}}", agents_md_content)
@ -300,48 +300,8 @@ class MessagesMixin:
if agents_md_text:
messages.append({"role": "system", "content": agents_md_text})
# CLAUDE.md默认关闭开启后与 AGENTS.md 并列注入)
def _build_claude_md_prompt() -> str:
claude_md_content = self._load_root_md_content("CLAUDE.md")
if not claude_md_content:
return ""
# 子目录 CLAUDE.md 只通知相对路径,不注入内容
sub_paths, sub_total = self._load_sub_md_paths("CLAUDE.md")
sub_notice = _build_sub_md_notice("CLAUDE.md", sub_paths, sub_total)
claude_md_template = self.load_prompt("claude_md_inject").strip()
if claude_md_template and "{{CLAUDE_MD_CONTENT}}" in claude_md_template:
text = claude_md_template.replace("{{CLAUDE_MD_CONTENT}}", claude_md_content)
text = text.replace("{{CLAUDE_MD_UPDATED_AT}}", self._load_root_md_updated_at("CLAUDE.md"))
text = text.replace("{{SUB_CLAUDE_MD_NOTICE}}", sub_notice)
return text
# 模板缺失/无占位符时的兜底格式
text = f"【CLAUDE.md 项目规范】{self._load_root_md_updated_at('CLAUDE.md')}\n\n{claude_md_content}\n\n---\n请注意:以上规范来自工作区根目录的 CLAUDE.md 文件,若有冲突请以 CLAUDE.md 为准。"
return f"{text}\n\n{sub_notice}" if sub_notice else text
# 与 AGENTS.md 同理,只在开启时生成并缓存
claude_md_inject_enabled = (
bool(personalization_config.get("claude_md_auto_inject", False))
if isinstance(personalization_config, dict)
else False
)
if claude_md_inject_enabled:
claude_md_text = self._get_or_init_frozen_prompt(
"frozen_claude_md_prompt",
_build_claude_md_prompt,
)
if claude_md_text:
messages.append({"role": "system", "content": claude_md_text})
# skills 列表
skills_catalog = get_skills_catalog(
private_dir=infer_private_skills_dir(self.data_dir),
project_path=self.project_path,
scan_project_agents=(
bool(personalization_config.get("agents_skills_scan_enabled", True))
if isinstance(personalization_config, dict)
else True
),
)
skills_catalog = get_skills_catalog(private_dir=infer_private_skills_dir(self.data_dir))
enabled_skills = merge_enabled_skills(
personalization_config.get("enabled_skills") if isinstance(personalization_config, dict) else None,
skills_catalog,

View File

@ -101,23 +101,23 @@ _AGENTS_MD_SKIP_DIRS = {
class PromptMixin:
"""MainTerminalContextMixin prompt 能力 mixin。"""
def _load_root_md_content(self, filename: str) -> Optional[str]:
"""加载工作区根目录的指定指令文件内容(仅根目录,子目录的不注入)。"""
def _load_agents_md_content(self) -> Optional[str]:
"""加载工作区根目录的 AGENTS.md 文件内容(仅根目录,子目录的不注入)。"""
try:
project_path = Path(self.project_path)
root_file = project_path / filename
root_file = project_path / "AGENTS.md"
if not root_file.is_file():
return None
content = root_file.read_text(encoding='utf-8')
return content.strip() if content else None
except Exception as exc:
logger.warning(f"[{filename}] 读取失败: {exc}")
logger.warning(f"[AGENTS.md] 读取失败: {exc}")
return None
def _load_root_md_updated_at(self, filename: str) -> str:
"""返回根目录指定指令文件的最后修改时间文案如「最后修改2026-08-12 17:54失败返回空串"""
def _load_agents_md_updated_at(self) -> str:
"""返回根目录 AGENTS.md 的最后修改时间文案如「最后修改2026-08-12 17:54失败返回空串"""
try:
root_file = Path(self.project_path) / filename
root_file = Path(self.project_path) / "AGENTS.md"
if not root_file.is_file():
return ""
mtime = root_file.stat().st_mtime
@ -125,8 +125,8 @@ class PromptMixin:
except Exception:
return ""
def _load_sub_md_paths(self, filename: str, max_notice: int = 20, timeout_seconds: float = 30.0) -> tuple:
"""扫描工作区一级子目录中的指定指令文件,返回 (相对路径列表, 实际总数)。
def _load_sub_agents_md_paths(self, max_notice: int = 20, timeout_seconds: float = 30.0) -> tuple:
"""扫描工作区一级子目录中的 AGENTS.md,返回 (相对路径列表, 实际总数)。
- 仅扫描根目录与一级子目录不递归跳过通用非源码目录_AGENTS_MD_SKIP_DIRS
- 列表最多 max_notice 超出截断总数仍在第二项返回
@ -141,31 +141,19 @@ class PromptMixin:
start = time.monotonic()
for sub in project_path.iterdir():
if time.monotonic() - start >= timeout_seconds:
logger.warning(f"[{filename}] 子目录扫描超过 {timeout_seconds}s返回已扫描到的 {len(found)}")
logger.warning(f"[AGENTS.md] 子目录扫描超过 {timeout_seconds}s返回已扫描到的 {len(found)}")
break
if not sub.is_dir() or sub.name in _AGENTS_MD_SKIP_DIRS:
continue
candidate = sub / filename
candidate = sub / "AGENTS.md"
if candidate.is_file():
total += 1
if len(found) < max_notice:
found.append(candidate.relative_to(project_path).as_posix())
except Exception as exc:
logger.warning(f"[{filename}] 子目录扫描失败: {exc}")
logger.warning(f"[AGENTS.md] 子目录扫描失败: {exc}")
return found, total
def _load_agents_md_content(self) -> Optional[str]:
"""加载工作区根目录的 AGENTS.md 文件内容(仅根目录,子目录的不注入)。"""
return self._load_root_md_content("AGENTS.md")
def _load_agents_md_updated_at(self) -> str:
"""返回根目录 AGENTS.md 的最后修改时间文案如「最后修改2026-08-12 17:54失败返回空串"""
return self._load_root_md_updated_at("AGENTS.md")
def _load_sub_agents_md_paths(self, max_notice: int = 20, timeout_seconds: float = 30.0) -> tuple:
"""扫描工作区一级子目录中的 AGENTS.md返回 (相对路径列表, 实际总数)。"""
return self._load_sub_md_paths("AGENTS.md", max_notice=max_notice, timeout_seconds=timeout_seconds)
def load_prompt(self, name: str) -> str:
"""加载提示模板"""
prompt_file = Path(PROMPTS_DIR) / f"{name}.txt"

View File

@ -119,7 +119,7 @@ class ToolsDefinitionFileToolsMixin:
"type": "function",
"function": {
"name": "read_skill",
"description": "按 skill 名称读取 SKILL.md 内容(.astrion/skills/<name>/ 或 .agents/skills/<name>/;内部等价于 read_file 的 read 模式,并返回解析后的 path。若技能在 .astrion/skills/ 与 .agents/skills/ 同名重复,会报错并提示改用 read_file 按具体路径读取",
"description": "按 skill 名称读取 .astrion/skills/<name>/SKILL.md 内容;内部等价于 read_file 的 read 模式,并返回解析后的 path。",
"parameters": {
"type": "object",
"properties": self._inject_intent({

View File

@ -21,7 +21,6 @@ try:
CUSTOM_TOOLS_ENABLED,
WORKSPACE_SKILLS_DIRNAME,
WORKSPACE_MEMORY_DIRNAME,
PROJECT_AGENTS_SKILLS_DIRNAME,
)
except ImportError:
import sys
@ -44,7 +43,6 @@ except ImportError:
CUSTOM_TOOLS_ENABLED,
WORKSPACE_SKILLS_DIRNAME,
WORKSPACE_MEMORY_DIRNAME,
PROJECT_AGENTS_SKILLS_DIRNAME,
)
from modules.file_manager import FileManager
@ -94,10 +92,9 @@ class MainTerminalToolsReadMixin:
if not raw:
return ""
normalized = raw.replace("\\", "/").strip("/")
for prefix in (f"{WORKSPACE_SKILLS_DIRNAME}/", f"{PROJECT_AGENTS_SKILLS_DIRNAME}/"):
if normalized.lower().startswith(prefix):
normalized = normalized[len(prefix):]
break
prefix = f"{WORKSPACE_SKILLS_DIRNAME}/"
if normalized.lower().startswith(prefix):
normalized = normalized[len(prefix):]
if normalized.lower().endswith("/skill.md"):
normalized = normalized[: -len("/SKILL.md")]
return normalized.strip()
@ -111,16 +108,7 @@ class MainTerminalToolsReadMixin:
personalization = load_personalization_config(self.data_dir)
except Exception:
personalization = {}
scan_project_agents = (
bool(personalization.get("agents_skills_scan_enabled", True))
if isinstance(personalization, dict)
else True
)
catalog = get_skills_catalog(
private_dir=infer_private_skills_dir(self.data_dir),
project_path=self.project_path,
scan_project_agents=scan_project_agents,
)
catalog = get_skills_catalog(private_dir=infer_private_skills_dir(self.data_dir))
enabled_skills = merge_enabled_skills(
personalization.get("enabled_skills") if isinstance(personalization, dict) else None,
catalog,
@ -131,28 +119,10 @@ class MainTerminalToolsReadMixin:
normalized_lower = normalized_input.lower()
def _resolve_item(item: Dict[str, str]) -> Dict[str, Any]:
sid = item.get("id")
primary_dir = str(item.get("display_dir") or WORKSPACE_SKILLS_DIRNAME).strip("/")
conflict_dir = str(item.get("conflict_dir") or "").strip("/")
if conflict_dir:
# 同名冲突:报错并引导改用 read_file 按具体路径查看
return {
"success": False,
"error": (
f"技能 '{sid}' 存在同名重复:{primary_dir}/{sid}/SKILL.md 与 "
f"{conflict_dir}/{sid}/SKILL.md 均存在read_skill 无法确定读取哪一份。\n"
f"请改用 read_file 工具按具体路径读取查看(可分别读取 "
f"{primary_dir}/{sid}/SKILL.md 与 {conflict_dir}/{sid}/SKILL.md 对比),"
f"或告知用户删除/重命名其中一份以消除重复。"
),
}
return {"success": True, "skill_id": sid, "display_dir": primary_dir}
# 1) 优先按 skill id 精确匹配(忽略大小写)
id_map = {str(item.get("id", "")).lower(): item for item in filtered_catalog if item.get("id")}
id_map = {str(item.get("id", "")).lower(): item.get("id") for item in filtered_catalog if item.get("id")}
if normalized_lower in id_map:
return _resolve_item(id_map[normalized_lower])
return {"success": True, "skill_id": id_map[normalized_lower]}
# 2) 再按 label 匹配(忽略大小写)
label_matches: List[str] = []
@ -161,7 +131,7 @@ class MainTerminalToolsReadMixin:
if label and label == normalized_lower and item.get("id"):
label_matches.append(item["id"])
if len(label_matches) == 1:
return _resolve_item(id_map[label_matches[0].lower()])
return {"success": True, "skill_id": label_matches[0]}
if len(label_matches) > 1:
return {
"success": False,
@ -177,9 +147,8 @@ class MainTerminalToolsReadMixin:
return resolved
skill_id = resolved["skill_id"]
display_dir = str(resolved.get("display_dir") or WORKSPACE_SKILLS_DIRNAME).strip("/")
read_args = {
"path": f"{display_dir}/{skill_id}/SKILL.md",
"path": f"{WORKSPACE_SKILLS_DIRNAME}/{skill_id}/SKILL.md",
"type": "read",
}
result = self._handle_read_tool(read_args)

View File

@ -39,10 +39,8 @@ class WebTerminal(MainTerminal):
对话级 terminal 只服务绑定对话必须恢复该对话保存的模型
restore_model=True否则重启后新建的对话级 terminal 会用默认
模型跑任务/回写 metadata把用户切换的模型覆盖掉
工作区级 terminal未绑定保持原有最近对话行为恢复焦点与模式
且刻意不恢复模型restore_model=False避免 /new 页面显示旧对话的模型
经由 load_conversation attach_history 分流工作区级不挂载消息历史
历史权威在磁盘 + 对话级实例 merge-on-save 旧内存写回污染源
工作区级 terminal未绑定保持原有最近对话行为且刻意不恢复
模型restore_model=False避免 /new 页面显示旧对话的模型
"""
if self.context_manager.current_conversation_id:
return
@ -109,15 +107,6 @@ class WebTerminal(MainTerminal):
container_session=container_session,
usage_tracker=usage_tracker
)
# 工作区级服务实例标记:不持有/不回写对话消息历史(历史权威在磁盘 + 对话级实例)。
# 工作区级挂载历史只会成为 merge-on-save 的污染源(版本回溯被旧内存“救回”覆盖的事故根因)。
# 注意必须在 super().__init__ 之后设置context_manager 由父类创建);
# 而 load 分流不依赖此标记(用 _bound_conversation_idsuper 之前已设),初始化时序安全。
try:
self.context_manager._service_instance_no_history = conversation_id is None
except Exception:
pass
# Web特有属性
self.message_callback = message_callback
@ -428,12 +417,7 @@ class WebTerminal(MainTerminal):
Dict: 加载结果
"""
try:
# 工作区级服务实例_bound_conversation_id 为空):仅恢复会话焦点
# current_conversation_id与运行模式不挂载消息历史——历史权威在磁盘
# 与对话级实例,服务实例持历史只会成为 merge-on-save 的写回污染源。
# 对话级实例(绑定对话):挂载历史,供任务执行链路使用。
attach_history = bool(getattr(self, "_bound_conversation_id", None))
success = self.context_manager.load_conversation_by_id(conversation_id, attach_history=attach_history)
success = self.context_manager.load_conversation_by_id(conversation_id)
if success:
# 根据对话元数据同步运行模式与推理强度
try:
@ -532,8 +516,7 @@ class WebTerminal(MainTerminal):
"success": True,
"conversation_id": conversation_id,
"title": conversation_data.get("title", "未知对话"),
# 消息数以磁盘数据为准服务实例不挂载历史len(conversation_history) 恒为 0
"messages_count": len(conversation_data.get("messages") or []),
"messages_count": len(self.context_manager.conversation_history),
"run_mode": self.run_mode,
"thinking_mode": self.thinking_mode,
"model_key": getattr(self, "model_key", None),
@ -647,7 +630,6 @@ class WebTerminal(MainTerminal):
"context": {
"usage_percent": context_status['usage_percent'],
"total_size": context_status['sizes']['total'],
# 本实例内存上下文中的消息数;工作区级服务实例不挂载历史,恒为 0前端不消费该字段
"conversation_count": len(self.context_manager.conversation_history)
},
"focused_files": focused_files_dict,

View File

@ -131,75 +131,27 @@ def _build_agents_md_section(workspace_path: str) -> str:
)
def _build_claude_md_section(workspace_path: str, data_dir: str = "") -> str:
"""构建 CLAUDE.md 项目规范段(遵循 claude_md_auto_inject 开关,默认关闭)。"""
try:
from modules.personalization_manager import load_personalization_config
try:
personalization = load_personalization_config(data_dir) if data_dir else {}
except Exception:
personalization = {}
enabled = (
bool(personalization.get("claude_md_auto_inject", False))
if isinstance(personalization, dict)
else False
)
if not enabled:
return ""
claude_file = Path(workspace_path) / "CLAUDE.md"
if not claude_file.is_file():
return ""
content = claude_file.read_text(encoding="utf-8").strip()
if not content:
return ""
return (
f"## CLAUDE.md 项目规范\n\n"
f"> 以下是工作区根目录的 CLAUDE.md 文件内容,请在回答时参考这些项目规范:\n\n"
f"{content}\n\n"
f"---\n"
f"注意:以上规范来自工作区根目录的 CLAUDE.md 文件,若与未来代码冲突,以实际代码为准。\n"
)
except Exception:
return ""
def _build_skills_section(workspace_path: str, data_dir: str = "") -> str:
"""构建可用 skill 列表段。"""
try:
from modules.personalization_manager import load_personalization_config
from modules.skills_manager import (
get_skills_catalog,
build_skills_list,
resolve_enabled_skills,
infer_private_skills_dir,
)
private_dir = infer_private_skills_dir(data_dir) if data_dir else None
try:
personalization = load_personalization_config(data_dir) if data_dir else {}
except Exception:
personalization = {}
scan_project_agents = (
bool(personalization.get("agents_skills_scan_enabled", True))
if isinstance(personalization, dict)
else True
)
catalog = get_skills_catalog(
private_dir=private_dir,
project_path=workspace_path,
scan_project_agents=scan_project_agents,
)
catalog = get_skills_catalog(private_dir=private_dir)
# 子智能体不区分 enabled/disabled列出全部可用 skill
skills_list = build_skills_list(catalog, resolve_enabled_skills(None, catalog))
skills_list = build_skills_list(catalog, enabled=None)
if not skills_list:
return ""
return (
"## 可用 AgentSkill\n\n"
"agent skills 系统已启用,以下是可用的 skills含简要说明\n\n"
+ "\n".join(skills_list)
+ "\n\n使用技能时,优先用 read_skill 通过技能名读取 SKILL.md"
"需要阅读 skill 目录中的其他文件时再使用 read_file\n"
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 ""
@ -282,11 +234,6 @@ def build_sub_agent_dynamic_context(
if agents_md:
sections.append(agents_md)
# 4.5 CLAUDE.md遵循 claude_md_auto_inject 开关,默认关闭)
claude_md = _build_claude_md_section(workspace_path, data_dir=data_dir)
if claude_md:
sections.append(claude_md)
# 5. 可用 skill
skills = _build_skills_section(workspace_path, data_dir=data_dir)
if skills:

View File

@ -112,8 +112,6 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = {
"versioning_backup_mode": "shallow", # 文件备份方式shallow-浅备份只备份AI编辑的文件/ full-完全备份(整个工作区)
"versioning_restore_mode": "overwrite", # 版本回溯模式固定为 overwrite
"agents_md_auto_inject": False, # AGENTS.md 自动注入开关
"claude_md_auto_inject": False, # CLAUDE.md 自动注入开关(默认关闭)
"agents_skills_scan_enabled": True, # 自动扫描工作区 .agents/skills/ 行业通用技能目录(默认开启)
"new_chat_button_behavior": "route", # 新建对话按钮行为route-跳转空白新对话页 / blank-立即创建空对话
"default_hide_workspace": False, # 默认隐藏工作区
"hide_quick_dock": False, # 隐藏快捷窗口(对话区右侧的待办/子智能体/后台指令/文件窗口列)
@ -550,18 +548,6 @@ def sanitize_personalization_payload(
else:
base["agents_md_auto_inject"] = bool(base.get("agents_md_auto_inject", False))
# CLAUDE.md 自动注入开关(默认关闭)
if "claude_md_auto_inject" in data:
base["claude_md_auto_inject"] = bool(data.get("claude_md_auto_inject"))
else:
base["claude_md_auto_inject"] = bool(base.get("claude_md_auto_inject", False))
# .agents/skills/ 行业通用技能目录扫描开关(默认开启)
if "agents_skills_scan_enabled" in data:
base["agents_skills_scan_enabled"] = bool(data.get("agents_skills_scan_enabled"))
else:
base["agents_skills_scan_enabled"] = bool(base.get("agents_skills_scan_enabled", True))
# 新建对话按钮行为route-跳转空白新对话页 / blank-立即创建空对话
new_chat_behavior = data.get("new_chat_button_behavior", base.get("new_chat_button_behavior"))
if isinstance(new_chat_behavior, str) and new_chat_behavior.strip().lower() in ("route", "blank"):

View File

@ -12,7 +12,7 @@ import time
from pathlib import Path
from typing import Dict, List, Optional, Sequence
from config import AGENT_SKILLS_DIR, CUSTOM_SKILLS_DIR, IS_HOST_MODE, PROJECT_AGENTS_SKILLS_DIRNAME, WORKSPACE_SKILLS_DIRNAME
from config import AGENT_SKILLS_DIR, CUSTOM_SKILLS_DIR, IS_HOST_MODE, WORKSPACE_SKILLS_DIRNAME
from utils.logger import setup_logger
logger = setup_logger(__name__)
@ -282,15 +282,8 @@ def _scan_skills_catalog(root: Path) -> List[Dict[str, str]]:
def get_skills_catalog(
base_dir: Optional[str] = None,
private_dir: Optional[str | Path] = None,
project_path: Optional[str | Path] = None,
scan_project_agents: bool = True,
) -> List[Dict[str, str]]:
"""List available skills from global library plus optional private library.
来源标注item["source"] = global仓库 agentskills// private用户私有/ agents工作区 .agents/skills/
item["display_dir"] 为该 skill 在工作区内的可读路径前缀global/private 经同步复制后在 .astrion/skills/
agents 原地读取在 .agents/skills/同名冲突时保留已有条目并打 conflict_dir 标记 read_skill 报错引导
"""
"""List available skills from global library plus optional private library."""
roots: List[Path] = [Path(base_dir or AGENT_SKILLS_DIR).expanduser().resolve()]
if private_dir:
private_root = Path(private_dir).expanduser().resolve()
@ -299,43 +292,15 @@ def get_skills_catalog(
merged: Dict[str, Dict[str, str]] = {}
order: List[str] = []
for index, root in enumerate(roots):
source = "global" if index == 0 else "private"
for root in roots:
for item in _scan_skills_catalog(root):
skill_id = item.get("id")
if not skill_id:
continue
if skill_id not in merged:
order.append(skill_id)
item["source"] = source
item["display_dir"] = WORKSPACE_SKILLS_DIRNAME
# Later roots (private) override metadata for same id.
merged[skill_id] = item
# 行业通用目录:工作区 .agents/skills/Agent Skills 开放标准路径;原地读取,不参与同步复制)
if project_path and scan_project_agents:
try:
agents_root = (Path(project_path).expanduser().resolve() / PROJECT_AGENTS_SKILLS_DIRNAME).resolve()
except Exception:
agents_root = None
if agents_root and agents_root not in roots:
for item in _scan_skills_catalog(agents_root):
skill_id = item.get("id")
if not skill_id:
continue
if skill_id in merged:
# 同名冲突:保留已有(.astrion/skills条目仅打冲突标记
# prompt 列表与 read_skill 据此提示用户用 read_file 按具体路径查看
merged[skill_id]["conflict_dir"] = PROJECT_AGENTS_SKILLS_DIRNAME
logger.warning(
"[skills] 技能 %s 同时存在于 %s%s,已标记同名冲突",
skill_id, WORKSPACE_SKILLS_DIRNAME, PROJECT_AGENTS_SKILLS_DIRNAME,
)
continue
item["source"] = "agents"
item["display_dir"] = PROJECT_AGENTS_SKILLS_DIRNAME
order.append(skill_id)
merged[skill_id] = item
return [merged[skill_id] for skill_id in order if skill_id in merged]
@ -388,41 +353,20 @@ def build_skills_list(
catalog: Sequence[Dict[str, str]],
enabled_skill_ids: Sequence[str],
) -> List[str]:
"""Build skills list lines / 生成 skills 列表行。
列表行直接使用该 skill 在工作区内的真实路径前缀.astrion/skills/ .agents/skills/
.agents/skills/ 来源与同名冲突会在列表尾部附说明行
"""
"""Build skills list lines / 生成 skills 列表行。"""
if not enabled_skill_ids:
return []
lookup = {item.get("id"): item for item in catalog if item.get("id")}
lines: List[str] = []
has_agents_source = False
has_conflict = False
for skill_id in enabled_skill_ids:
meta = lookup.get(skill_id)
if not meta:
continue
display_dir = (meta.get("display_dir") or WORKSPACE_SKILLS_DIRNAME).strip("/")
if meta.get("source") == "agents":
has_agents_source = True
description = (meta.get("description") or "").strip()
line = f"{display_dir}/{skill_id}{description}" if description else f"{display_dir}/{skill_id}"
conflict_dir = (meta.get("conflict_dir") or "").strip("/")
if conflict_dir:
has_conflict = True
line += f"(⚠️ 同名重复:{conflict_dir}/{skill_id} 也存在read_skill 会报错,请改用 read_file 按具体路径读取)"
lines.append(line)
if has_agents_source:
lines.append(
f"注:以 {PROJECT_AGENTS_SKILLS_DIRNAME}/ 开头的技能来自工作区 {PROJECT_AGENTS_SKILLS_DIRNAME}/ 目录"
"(行业通用技能目录,由项目仓库提供,非 Astrion 管理),同样可用 read_skill 读取。"
)
if has_conflict:
lines.append(
f"注:标注同名重复的技能在 {WORKSPACE_SKILLS_DIRNAME}/ 与 {PROJECT_AGENTS_SKILLS_DIRNAME}/ 各有一份,"
"内容可能不同read_skill 对重复名会报错,此时请改用 read_file 读取具体路径查看。"
)
if description:
lines.append(f".astrion/skills/{skill_id}{description}")
else:
lines.append(f".astrion/skills/{skill_id}")
return lines

View File

@ -163,7 +163,7 @@ SUB_AGENT_TOOLS: List[Dict[str, Any]] = [
"type": "function",
"function": {
"name": "read_skill",
"description": "按 skill 名称读取 SKILL.md 内容(.astrion/skills/<name>/ 或 .agents/skills/<name>/;内部等价于 read_file 的 read 模式,并返回解析后的 path。若技能在 .astrion/skills/ 与 .agents/skills/ 同名重复,会报错并提示改用 read_file 按具体路径读取",
"description": "按 skill 名称读取 .astrion/skills/<name>/SKILL.md 内容;内部等价于 read_file 的 read 模式,并返回解析后的 path。",
"parameters": {
"type": "object",
"properties": {

View File

@ -1,10 +0,0 @@
【CLAUDE.md 项目规范】
以下是工作区根目录 CLAUDE.md 文件的内容{{CLAUDE_MD_UPDATED_AT}},请在回答时参考这些项目规范:
{{CLAUDE_MD_CONTENT}}
---
注意:以上规范来自工作区根目录的 CLAUDE.md 文件,若与未来代码冲突,以实际代码为准。
{{SUB_CLAUDE_MD_NOTICE}}

View File

@ -47,23 +47,6 @@ from server.monitor import get_cached_monitor_snapshot
UPLOAD_FOLDER_NAME = ".astrion/user_upload"
def _save_conversation_meta_via_disk(ctx, conversation_id: str, **meta_kwargs) -> None:
"""设置类端点保存对话元数据的统一入口。
工作区级服务实例不挂载消息历史conversation_history 恒空直接拿内存保存会把
/陈旧历史写回这里读磁盘消息回传merge 语义下消息保持不变仅更新元数据
"""
if not conversation_id:
return
manager = ctx._get_conversation_manager_for_id(conversation_id)
existing = manager.load_conversation(conversation_id) or {}
manager.save_conversation(
conversation_id=conversation_id,
messages=existing.get("messages") or [],
**meta_kwargs,
)
@chat_bp.route('/api/thinking-mode', methods=['POST'])
@api_login_required
@with_terminal
@ -84,19 +67,20 @@ def update_thinking_mode(terminal: WebTerminal, workspace: UserWorkspace, userna
terminal.set_run_mode(target_mode)
session['thinking_mode'] = terminal.thinking_mode
session['run_mode'] = terminal.run_mode
# 更新当前对话的元数据(服务实例不挂载历史:读磁盘回传,仅更新元数据)
# 更新当前对话的元数据
ctx = terminal.context_manager
if ctx.current_conversation_id:
try:
_save_conversation_meta_via_disk(
ctx,
ctx.current_conversation_id,
ctx.conversation_manager.save_conversation(
conversation_id=ctx.current_conversation_id,
messages=ctx.conversation_history,
project_path=str(ctx.project_path),
todo_list=ctx.todo_list,
thinking_mode=terminal.thinking_mode,
run_mode=terminal.run_mode,
model_key=getattr(terminal, "model_key", None),
has_images=getattr(ctx, "has_images", False),
has_videos=getattr(ctx, "has_videos", False),
has_videos=getattr(ctx, "has_videos", False)
)
except Exception as exc:
logger.error(f"[API] 保存思考模式到对话失败: {exc}")
@ -139,20 +123,21 @@ def update_reasoning_effort(terminal: WebTerminal, workspace: UserWorkspace, use
if is_current_target:
terminal.set_reasoning_effort(effort)
save_conversation_id = target_conversation_id or ctx.current_conversation_id
# 更新对话的元数据(服务实例不挂载历史:统一读磁盘回传,仅更新元数据)
# 更新对话的元数据
if save_conversation_id:
try:
if is_current_target:
_save_conversation_meta_via_disk(
ctx,
save_conversation_id,
ctx.conversation_manager.save_conversation(
conversation_id=save_conversation_id,
messages=ctx.conversation_history,
project_path=str(ctx.project_path),
todo_list=ctx.todo_list,
thinking_mode=terminal.thinking_mode,
run_mode=terminal.run_mode,
reasoning_effort=effort,
model_key=getattr(terminal, "model_key", None),
has_images=getattr(ctx, "has_images", False),
has_videos=getattr(ctx, "has_videos", False),
has_videos=getattr(ctx, "has_videos", False)
)
else:
# 所属对话已不是当前对话:只更新推理强度 meta。
@ -207,21 +192,6 @@ def update_model(terminal: WebTerminal, workspace: UserWorkspace, username: str)
"message": "被管理员强制禁用"
}), 403
# /new 页面语义:请求未携带 conversation_id 且为工作区级 terminal 时,
# 用户处于“待创建干净新对话”状态。此时 terminal 上的 has_images/has_videos
# 是启动时自动加载最近对话留下的残留焦点状态attach_history=False不持历史
# 不代表任何用户正在编辑的对话,不应拦截模型切换——与 create_new_conversation
# “新对话视为干净会话,清除图片限制”的语义一致。清除是安全的:
# 之后显式打开对话时 load_conversation_by_id 会从 metadata 重新恢复。
requested_cid = str(data.get("conversation_id") or "").strip()
if requested_cid and not requested_cid.startswith("conv_"):
requested_cid = f"conv_{requested_cid}"
if not requested_cid and not getattr(terminal, "_bound_conversation_id", None):
_new_ctx = getattr(terminal, "context_manager", None)
if _new_ctx is not None:
_new_ctx.has_images = False
_new_ctx.has_videos = False
terminal.set_model(model_key)
# fast-only 时 run_mode 可能被强制为 fast
session["model_key"] = terminal.model_key
@ -234,21 +204,25 @@ def update_model(terminal: WebTerminal, workspace: UserWorkspace, username: str)
# 盲目保存会把模型写到错误对话,或用陈旧 history 覆盖新消息。
# /new 页面(无 conversation_id不持久化新对话在首个任务时
# 由前端随任务传入的 model_key 落盘。
requested_cid = str(data.get("conversation_id") or "").strip()
if requested_cid and not requested_cid.startswith("conv_"):
requested_cid = f"conv_{requested_cid}"
ctx = terminal.context_manager
current_cid = getattr(ctx, "current_conversation_id", None)
if requested_cid and current_cid:
if current_cid == requested_cid:
try:
_save_conversation_meta_via_disk(
ctx,
current_cid,
ctx.conversation_manager.save_conversation(
conversation_id=current_cid,
messages=ctx.conversation_history,
project_path=str(ctx.project_path),
todo_list=ctx.todo_list,
thinking_mode=terminal.thinking_mode,
run_mode=terminal.run_mode,
reasoning_effort=terminal.reasoning_effort,
model_key=terminal.model_key,
has_images=getattr(ctx, "has_images", False),
has_videos=getattr(ctx, "has_videos", False),
has_videos=getattr(ctx, "has_videos", False)
)
except Exception as exc:
logger.error(f"[API] 保存模型到对话失败: {exc}")
@ -364,10 +338,11 @@ def update_personalization_settings(terminal: WebTerminal, workspace: UserWorksp
ctx = getattr(terminal, 'context_manager', None)
if ctx and getattr(ctx, 'current_conversation_id', None):
try:
_save_conversation_meta_via_disk(
ctx,
ctx.current_conversation_id,
ctx.conversation_manager.save_conversation(
conversation_id=ctx.current_conversation_id,
messages=ctx.conversation_history,
project_path=str(ctx.project_path),
todo_list=ctx.todo_list,
thinking_mode=terminal.thinking_mode,
run_mode=terminal.run_mode
)

View File

@ -41,14 +41,6 @@ async def run_streaming_attempts(*, web_terminal, messages, tools, sender, clien
last_finish_reason = None
tool_call_stream_active = False
# 通知前端API 请求已发出、尚未收到首个响应(每次重试前都会重新触发)。
# 前端据此驱动状态形象的「等待 API 响应…」文案响应开始thinking_start/
# text_start/tool_preparing或 error/任务终结时由前端清除。
sender('api_request_start', {
'attempt': api_attempt + 1,
'max_attempts': max_api_retries + 1,
})
# 收集流式响应
async for chunk in web_terminal.api_client.chat(messages, tools, stream=True):
chunk_count += 1

View File

@ -214,41 +214,6 @@ def _normalize_conv_id(conversation_id: str) -> str:
return conv if conv.startswith("conv_") else f"conv_{conv}"
def _sync_restored_conversation_memory(conversation_id: str) -> None:
"""版本回溯后同步内存实例:把绑定该对话的对话级 terminal 内存替换为磁盘最新。
回溯用 allow_shrink 覆写裁短磁盘若持有旧更长历史的对话级实例之后保存
merge-on-save 会把被裁消息当作内存独有追加救回回溯随即被撤销
此前用户只能回溯后立刻重启进程的根因工作区级服务实例不挂载历史
天然无需处理 WebTerminal.load_conversation attach_history 分流
"""
from copy import deepcopy
from server import state as server_state
normalized = _normalize_conv_id(conversation_id)
user_terminals = getattr(server_state, "user_terminals", None) or {}
for term_key, term in list(user_terminals.items()):
try:
bound = getattr(term, "_bound_conversation_id", None)
if not bound or _normalize_conv_id(str(bound)) != normalized:
continue
ctx = getattr(term, "context_manager", None)
if ctx is None:
continue
target_manager = ctx._get_conversation_manager_for_id(normalized)
data = target_manager.load_conversation(normalized) or {}
ctx.conversation_history = list(data.get("messages") or [])
ctx.conversation_metadata = deepcopy(data.get("metadata") or {})
todo_data = data.get("todo_list")
ctx.todo_list = deepcopy(todo_data) if todo_data else None
debug_log(
f"[Versioning][Restore] synced in-memory history term={term_key} "
f"messages={len(ctx.conversation_history)}"
)
except Exception as exc:
debug_log(f"[Versioning][Restore] sync memory failed for term={term_key}: {exc}")
def _normalize_versioning_tracking_mode(value: Optional[str]) -> str:
return ConversationVersioningManager.normalize_tracking_mode(value)
@ -1667,13 +1632,16 @@ def restore_conversation_versioning_checkpoint(conversation_id, terminal: WebTer
terminal, workspace, normalized_id, target_conversation_id, seq, tracking_mode, host_mode, backup_mode
)
# overwrite 场景:回溯已裁短磁盘,必须同步所有持有该对话的内存实例(对话级
# terminal 常驻 24h否则旧内存后续保存经 merge 会把被裁消息「救回」,回溯被撤销。
# copy 场景目标是新对话,无缓存实例,无需处理。
# overwrite 场景需要清空内存历史避免反向覆写copy 场景目标是新对话,无需处理。
if restore_mode == "overwrite":
_sync_restored_conversation_memory(target_conversation_id)
try:
current_loaded_id = getattr(terminal.context_manager, "current_conversation_id", None)
if current_loaded_id == normalized_id:
terminal.context_manager.conversation_history = []
debug_log(f"[Versioning][Restore] cleared in-memory history before reload conv={normalized_id}")
except Exception as exc:
debug_log(f"[Versioning][Restore] clear in-memory history failed: {exc}")
# 恢复模式与焦点到当前工作区级terminal服务实例不挂载历史仅读磁盘元数据
terminal.load_conversation(target_conversation_id)
debug_log(
f"[Versioning][Restore] reload done conv={target_conversation_id} "
@ -1930,12 +1898,7 @@ def list_sub_agents(terminal: WebTerminal, workspace: UserWorkspace, username: s
announced = terminal._announced_sub_agent_tasks
notified_from_history = set()
try:
# 服务实例(工作区级)不挂载历史:通知去重标记以磁盘对话为准
history = []
if conversation_id:
notify_manager = terminal.context_manager._get_conversation_manager_for_id(conversation_id)
notify_conv_data = notify_manager.load_conversation(conversation_id) or {}
history = notify_conv_data.get("messages") or []
history = getattr(terminal.context_manager, "conversation_history", []) or []
for msg in history:
meta = msg.get("metadata") or {}
task_id = meta.get("task_id")

View File

@ -340,16 +340,12 @@ def _extract_text_only(content: Any) -> str:
def _collect_user_texts(messages: List[Dict[str, Any]]) -> List[str]:
# 口径:用户亲手输入 = message_source 为 'user'(直接发送)或 'presend'(提前输入排队后转正),
# 字段缺失的老消息视为 'user'guidance手动引导/notify/compression 等运行期注入消息不计入。
# 与前端左侧输入导航 isRealUserNavMessageChatArea.vue口径保持一致。
result: List[str] = []
for msg in messages:
if msg.get("role") != "user":
continue
metadata = msg.get("metadata") if isinstance(msg.get("metadata"), dict) else {}
source = str(metadata.get("message_source") or "user").strip().lower()
if source not in ("user", "presend"):
if str(metadata.get("message_source") or "").strip().lower() != "user":
continue
text = _extract_text_only(msg.get("content"))
if text.strip():

View File

@ -120,16 +120,12 @@ def api_activate_workflow(terminal, workspace, username):
try:
# 激活提示消息将追加到历史末尾,阶段消息游标取当前长度 + 1。
# 新建对话:激活消息是第 1 条,游标固定为 1。
# 服务实例(工作区级)不挂载历史:非新建时游标以磁盘消息数为准。
# 新建对话:激活消息是第 1 条,游标固定为 1服务 terminal 的 history 长度不可信)。
if created_new:
msg_index = 1
else:
try:
cm_router = getattr(terminal, "context_manager", None)
wf_manager = cm_router._get_conversation_manager_for_id(conversation_id) if cm_router else None
conv_data = wf_manager.load_conversation(conversation_id) if wf_manager else None
msg_index = len((conv_data or {}).get("messages") or []) + 1
msg_index = len(getattr(terminal.context_manager, "conversation_history", []) or []) + 1
except Exception:
msg_index = 0
result = activate_workflow(

View File

@ -92,8 +92,7 @@ const appOptions = {
chatAppendTextChunk: 'appendTextChunk',
chatCompleteTextAction: 'completeText',
chatAddSystemMessage: 'addSystemMessage',
chatEnsureAssistantMessage: 'ensureAssistantMessage',
chatClearStreamingResidualState: 'clearStreamingResidualState'
chatEnsureAssistantMessage: 'ensureAssistantMessage'
}),
...mapActions(useInputStore, {
inputSetFocused: 'setInputFocused',

View File

@ -367,11 +367,9 @@ export const computed = {
const intentEnabled = !!usePersonalizationStore().form?.tool_intent_enabled;
// 1) 思考中(防御:仅在任务仍运行时采信流式字段——异常中断后字段可能残留,
// 虽有各终结路径的统一清理兑底,这里再加一层保险避免永久卡「思考中」)
// 1) 思考中
const isThinking =
!!lastAssistant &&
(this.taskInProgress || this.streamingMessage) &&
(lastAssistant.currentStreamingType === 'thinking' || lastAssistant.activeThinkingId != null);
// 2) 工具执行中(取最后一段并行工具,过滤未完成的)
@ -440,11 +438,6 @@ export const computed = {
if (running) {
// 工作态:等 API / 后台运行 / 流式输出正文
let text = bgText;
// 「等待 API 响应…」优先于随机等待文案:后端已发出请求、尚未开始回复
// api_request_start 事件驱动,覆盖首轮与工具轮次间的每一次等待)
if (!text && this.apiRequestPending) {
return { mode: 'work', toolKeys: [], toolTexts: [], text: '等待 API 响应…', tracking: false, apiWaiting: true };
}
if (!text) {
const awaitingMsg = lastAssistant && lastAssistant.awaitingFirstContent ? lastAssistant : null;
if (awaitingMsg) {

View File

@ -25,11 +25,6 @@ export const stateMethods = {
});
// 重置消息和流状态
// 先清消息内残留的流式字段currentStreamingType/activeThinkingId/streaming
// action否则异常中断后状态头像会一直卡在「思考中」avatarStatus 的
// isThinking 只认这两个字段,与任务标志无关)
this.chatClearStreamingResidualState();
this.apiRequestPending = false;
this.streamingMessage = false;
this.currentMessageIndex = -1;
this.stopRequested = false;

View File

@ -183,28 +183,8 @@ export const lifecycleMethods = {
debugLog(`[TaskPolling] 处理事件 #${eventIdx}: ${eventType}`, eventData);
// 「等待 API 响应」状态维护api_request_start 置位;任何「响应开始」
// thinking_start/text_start/tool_preparing或任务终结信号都清除。
// 历史事件重放时按序经过此处,最终状态自然收敛正确。
if (eventType === 'api_request_start') {
this.apiRequestPending = true;
} else if (
eventType === 'thinking_start' ||
eventType === 'text_start' ||
eventType === 'tool_preparing' ||
eventType === 'task_complete' ||
eventType === 'task_stopped' ||
eventType === 'error'
) {
this.apiRequestPending = false;
}
// 根据事件类型调用对应的处理方法
switch (eventType) {
case 'api_request_start':
// 状态已在上方统一维护,无其他处理
break;
case 'ai_message_start':
this.handleAiMessageStart(eventData, eventIdx);
break;
@ -446,9 +426,6 @@ export const lifecycleMethods = {
// 同步处理状态更新
this.streamingMessage = false;
this.stopRequested = false;
// 兜底清理可能残留的流式状态(正常流程 thinking_end/text_end 已清,此处幂等)
this.chatClearStreamingResidualState?.();
this.apiRequestPending = false;
if (!hasRunningSubAgents && !hasRunningMultiAgent) {
this.markLatestUserWorkCompleted();
}
@ -549,8 +526,6 @@ export const lifecycleMethods = {
});
this.cleanupTrailingEmptyAssistantPlaceholder('task_stopped');
this.chatClearStreamingResidualState?.();
this.apiRequestPending = false;
this.streamingMessage = false;
this.stopRequested = false;
@ -693,13 +668,7 @@ export const lifecycleMethods = {
duration: 8000
});
// 清理状态(顺序:先清依赖 awaitingFirstContent 判定的空占位,再清残留流式字段)
this.cleanupTrailingEmptyAssistantPlaceholder?.('task_error');
this.chatClearStreamingResidualState?.();
if (typeof this.clearPendingTools === 'function') {
this.clearPendingTools('task_error');
}
this.apiRequestPending = false;
// 清理状态
this.markLatestUserWorkCompleted();
this.streamingMessage = false;
this.taskInProgress = false;

View File

@ -146,9 +146,6 @@ export const probeMethods = {
this.stopRequested = false;
this.waitingForSubAgent = false;
this.waitingForBackgroundCommand = false;
this.apiRequestPending = false;
// 同步清理消息内残留的流式状态(思考/文本),避免状态头像卡在「思考中」
this.chatClearStreamingResidualState?.();
if (typeof this.clearPendingTools === 'function') {
this.clearPendingTools('reconcile_auto_clear');
}

View File

@ -80,10 +80,6 @@ export function dataState() {
toolStacks: new Map(),
// 当前任务是否仍在进行中(用于保持输入区的"停止"状态)
taskInProgress: false,
// 等待 API 响应:对话运行期间后端已发出 API 请求、尚未收到首个响应事件
// (由后端 api_request_start 事件置位thinking_start/text_start/tool_preparing/
// error/任务终结时清除),用于状态头像显示「等待 API 响应…」
apiRequestPending: false,
// 对话运行状态对账定时器事件为主、2.5s 对账纠偏,冲突以对账为准)
runningStateReconcileTimer: null,
// 对账清理方向的连续空闲确认计数(防 notice/idle dispatch 间隙误清)

View File

@ -2397,9 +2397,7 @@ function getGeneratingLetters(message: any) {
}
// ===== =====
// = metadata.message_source 'user' 'presend'
// 'user'guidance/notify/compression
// _collect_user_textsserver/deep_compression.py
// role === 'user' metadata.message_source === 'user'//
const shellRef = ref<HTMLElement | null>(null);
const virtualizerRef = ref<any>(null);
const quickNavActiveIndex = ref(-1);
@ -2409,8 +2407,7 @@ const isRealUserNavMessage = (msg: any): boolean => {
if (!msg || msg.role !== 'user') {
return false;
}
const source = String(msg?.metadata?.message_source || 'user').trim().toLowerCase();
return source === 'user' || source === 'presend';
return String(msg?.metadata?.message_source || 'user').trim().toLowerCase() === 'user';
};
function extractUserFirstLine(msg: any): string {

View File

@ -818,8 +818,6 @@ const props = defineProps<{
toolTexts?: string[];
text: string;
tracking?: boolean;
/** true 时表示当前文案是「等待 API 响应…」work 模式下仅此类文案显示在头像旁) */
apiWaiting?: boolean;
} | null;
}>();
@ -1076,11 +1074,6 @@ const composerAvatarText = computed(() => {
if (props.avatarStatus.mode === 'tool' || props.avatarStatus.mode === 'think') {
return props.avatarStatus.text || '';
}
// work API apiWaiting
// /
if (props.avatarStatus.mode === 'work' && props.avatarStatus.apiWaiting) {
return props.avatarStatus.text || '';
}
return '';
});
@ -1090,7 +1083,6 @@ const composerAvatarTextKey = computed(() => {
const s = props.avatarStatus;
if (!s) return 'avatar-none';
if (s.mode === 'tool') return `avatar-tool-${s.toolKeys?.[0] || ''}`;
if (s.mode === 'work' && s.apiWaiting) return 'avatar-work-api-waiting';
return `avatar-${s.mode}`;
});

View File

@ -931,38 +931,6 @@
})
" /><FancyCheck :checked="form.agents_md_auto_inject" /></label>
<label class="settings-toggle-row"
><span class="settings-row-copy"
><span class="settings-row-title">CLAUDE.md 自动注入</span
><span class="settings-row-desc"
>工作区根目录存在 CLAUDE.md 时自动注入系统提示词默认关闭 AGENTS.md 并列注入</span
></span
><input
type="checkbox"
:checked="form.claude_md_auto_inject"
@change="
personalization.updateField({
key: 'claude_md_auto_inject',
value: $event.target.checked
})
" /><FancyCheck :checked="form.claude_md_auto_inject" /></label>
<label class="settings-toggle-row"
><span class="settings-row-copy"
><span class="settings-row-title">扫描 .agents/skills/ 技能目录</span
><span class="settings-row-desc"
>自动扫描工作区 .agents/skills/ 下的行业通用技能Agent Skills 开放标准路径 .astrion/skills/ 技能一并列出同名重复时 read_skill 会报错并提示按具体路径读取</span
></span
><input
type="checkbox"
:checked="form.agents_skills_scan_enabled"
@change="
personalization.updateField({
key: 'agents_skills_scan_enabled',
value: $event.target.checked
})
" /><FancyCheck :checked="form.agents_skills_scan_enabled" /></label>
<label class="settings-toggle-row"
><span class="settings-row-copy"
><span class="settings-row-title">文件修改留痕</span

View File

@ -1127,18 +1127,6 @@ export async function initializeLegacySocket(ctx: any) {
}
});
// API 请求已发出、尚未收到首个响应:状态头像显示「等待 API 响应…」
ctx.socket.on('api_request_start', (data) => {
if (data?.conversation_id && data.conversation_id !== ctx.currentConversationId) {
return;
}
// 轮询模式下该状态由任务事件流维护
if (ctx.usePollingMode && !ctx.waitingForSubAgent) {
return;
}
ctx.apiRequestPending = true;
});
// 思考流开始
ctx.socket.on('thinking_start', (data) => {
// 对话定向事件:只响应当前对话,避免运行中对话的流式输出渲染到 /new 等空白页。
@ -1152,8 +1140,6 @@ export async function initializeLegacySocket(ctx: any) {
return;
}
socketLog('思考开始');
// 响应已开始,清除「等待 API 响应」状态(含 ignoreThinking 分支,故放最前)
ctx.apiRequestPending = false;
const ignoreThinking = ctx.runMode === 'fast' || ctx.thinkingMode === false;
streamingState.ignoreThinking = ignoreThinking;
if (ignoreThinking) {
@ -1241,7 +1227,6 @@ export async function initializeLegacySocket(ctx: any) {
return;
}
socketLog('文本开始');
ctx.apiRequestPending = false;
logStreamingDebug('socket:text_start');
finalizeStreamingText({ force: true });
resetStreamingBuffer();
@ -1369,8 +1354,6 @@ export async function initializeLegacySocket(ctx: any) {
socketLog('跳过tool_preparing(对话不匹配)', data.conversation_id);
return;
}
// 工具流式收集即 API 响应已开始,清除「等待 API 响应」状态
ctx.apiRequestPending = false;
const msg = ctx.chatEnsureAssistantMessage();
if (!msg) {
return;
@ -1878,9 +1861,7 @@ export async function initializeLegacySocket(ctx: any) {
}
}
if (shouldRetry) {
// 错误后保持停止按钮态,用户可手动停止或等待自动重试;
// 重试间隔不算「等待 API 响应」,重试发起时后端会重新发 api_request_start
ctx.apiRequestPending = false;
// 错误后保持停止按钮态,用户可手动停止或等待自动重试
ctx.stopRequested = false;
ctx.taskInProgress = true;
ctx.streamingMessage = true;
@ -1893,13 +1874,15 @@ export async function initializeLegacySocket(ctx: any) {
);
}
// 最后一次报错:恢复输入状态并清理提示动画/流式残留
// (统一清理 currentStreamingType/activeThinkingId 等字段,
// 否则 avatarStatus 的 isThinking 永久为真,状态头像卡在「思考中」)
if (typeof ctx.chatClearStreamingResidualState === 'function') {
ctx.chatClearStreamingResidualState();
// 最后一次报错:恢复输入状态并清理提示动画
const msgIndex = typeof ctx.currentMessageIndex === 'number' ? ctx.currentMessageIndex : -1;
if (msgIndex >= 0 && Array.isArray(ctx.messages)) {
const currentMessage = ctx.messages[msgIndex];
if (currentMessage && currentMessage.role === 'assistant') {
currentMessage.awaitingFirstContent = false;
currentMessage.generatingLabel = '';
}
}
ctx.apiRequestPending = false;
if (typeof ctx.chatClearThinkingLocks === 'function') {
ctx.chatClearThinkingLocks();
}

View File

@ -388,35 +388,6 @@ export const useChatStore = defineStore('chat', {
msg.currentStreamingType = null;
delete (msg as any).__splitByShowHtml;
},
// 清理异常中断(断网 / API 错误 / 任务停止)残留的流式状态。
// 正常流程由 completeThinking/completeText 收尾;异常路径不会有
// thinking_end/text_end残留的 currentStreamingType/activeThinkingId
// 会让状态头像avatarStatus的 isThinking 永久为真,卡在「思考中」。
// 注意:若需同时清理空占位 assistant 消息,必须先调
// cleanupTrailingEmptyAssistantPlaceholder它依赖 awaitingFirstContent
// 判定占位),再调本方法。
clearStreamingResidualState() {
for (const msg of this.messages) {
if (!msg || msg.role !== 'assistant') continue;
msg.currentStreamingType = null;
msg.activeThinkingId = null;
msg.streamingThinking = '';
msg.streamingText = '';
msg.awaitingFirstContent = false;
msg.generatingLabel = '';
if (Array.isArray(msg.actions)) {
for (const action of msg.actions) {
if (
action &&
(action.type === 'thinking' || action.type === 'text') &&
action.streaming === true
) {
action.streaming = false;
}
}
}
}
},
addSystemMessage(content: string, meta: any = null) {
// 与历史重建保持一致:子智能体/后台完成通知作为独立 assistant 消息渲染,
// 避免运行时与刷新后在垂直间距上不一致。

View File

@ -132,8 +132,6 @@ interface PersonalForm {
deep_compress_trigger_tokens: number | null;
deep_compress_form: 'file' | 'inject';
agents_md_auto_inject: boolean;
claude_md_auto_inject: boolean;
agents_skills_scan_enabled: boolean;
new_chat_button_behavior: 'route' | 'blank';
group_sidebar_by_workspace: boolean;
sidebar_pinned_workspaces: string[];
@ -338,8 +336,6 @@ const defaultForm = (): PersonalForm => ({
deep_compress_trigger_tokens: null,
deep_compress_form: 'file',
agents_md_auto_inject: false,
claude_md_auto_inject: false,
agents_skills_scan_enabled: true,
new_chat_button_behavior: 'route',
group_sidebar_by_workspace: false,
sidebar_pinned_workspaces: [],
@ -604,8 +600,6 @@ export const usePersonalizationStore = defineStore('personalization', {
),
deep_compress_form: data.deep_compress_form === 'inject' ? 'inject' : 'file',
agents_md_auto_inject: !!data.agents_md_auto_inject,
claude_md_auto_inject: !!data.claude_md_auto_inject,
agents_skills_scan_enabled: data.agents_skills_scan_enabled === undefined ? true : !!data.agents_skills_scan_enabled,
new_chat_button_behavior: data.new_chat_button_behavior === 'blank' ? 'blank' : 'route',
group_sidebar_by_workspace: !!data.group_sidebar_by_workspace,
sidebar_pinned_workspaces: Array.isArray(data.sidebar_pinned_workspaces)

View File

@ -179,16 +179,12 @@ class ConversationMixin:
print(f"📝 开始新对话: {conversation_id}")
return conversation_id
def load_conversation_by_id(self, conversation_id: str, attach_history: bool = True) -> bool:
def load_conversation_by_id(self, conversation_id: str) -> bool:
"""
加载指定对话
Args:
conversation_id: 对话ID
attach_history: 是否把消息历史/元数据挂载到内存工作区级服务实例应传 False
仅恢复焦点 current_conversation_id 与运行模式对话历史的权威载体是
磁盘 + 对话级 terminal服务实例挂载历史只会成为 merge-on-save 的污染源
版本回溯被旧内存救回覆盖即此机制事故
Returns:
bool: 加载是否成功
@ -225,18 +221,12 @@ class ConversationMixin:
# 更新当前状态
self.current_conversation_id = conversation_id
if attach_history:
self.conversation_history = conversation_data.get("messages", [])
todo_data = conversation_data.get("todo_list")
self.todo_list = deepcopy(todo_data) if todo_data else None
self.conversation_metadata = deepcopy(conversation_data.get("metadata", {}) or {})
else:
# 服务实例:仅焦点,不挂载消息/元数据保持空内存merge 语义下任何保存都不会回写消息)
self.conversation_history = []
self.todo_list = None
self.conversation_metadata = {}
self.conversation_history = conversation_data.get("messages", [])
todo_data = conversation_data.get("todo_list")
self.todo_list = deepcopy(todo_data) if todo_data else None
self.conversation_metadata = deepcopy(conversation_data.get("metadata", {}) or {})
# 恢复项目文件树快照(如已存在)
meta = deepcopy(conversation_data.get("metadata", {}) or {})
meta = self.conversation_metadata
if meta.get("project_file_tree"):
self.project_snapshot = {
"file_tree": meta.get("project_file_tree"),
@ -274,7 +264,7 @@ class ConversationMixin:
model_key = metadata.get("model_key")
self.has_images = metadata.get("has_images", False)
self.has_videos = metadata.get("has_videos", False)
if attach_history and (not self.has_images or not self.has_videos):
if not self.has_images or not self.has_videos:
for msg in self.conversation_history:
if not isinstance(msg, dict):
continue
@ -331,13 +321,6 @@ class ConversationMixin:
return True
def _is_service_instance_no_history(self) -> bool:
"""是否为「不持有对话历史」的工作区级服务实例(由 WebTerminal 初始化时打标)。
服务实例不挂载/不回写消息历史历史权威在磁盘与对话级 terminal
"""
return bool(getattr(self, "_service_instance_no_history", False))
def save_current_conversation(self) -> bool:
"""
保存当前对话
@ -345,9 +328,6 @@ class ConversationMixin:
Returns:
bool: 保存是否成功
"""
if self._is_service_instance_no_history():
# 服务实例不持有历史,保存无语义;直接跳过,防止任何路径把空/陈旧内存写回
return False
if not self.current_conversation_id:
print("⚠️ 没有当前对话ID无法保存")
return False
@ -382,8 +362,6 @@ class ConversationMixin:
def auto_save_conversation(self, force: bool = False):
"""自动保存对话(静默模式,减少日志输出)"""
if self._is_service_instance_no_history():
return
if not self.auto_save_enabled or not self.current_conversation_id:
return
if not force and not self.conversation_history: