Compare commits
4 Commits
c6c8bf2d0c
...
4cf9c38137
| Author | SHA1 | Date | |
|---|---|---|---|
| 4cf9c38137 | |||
| 6fb5d10542 | |||
| d5a7c94c8e | |||
| 2d9b4eb5a6 |
@ -168,6 +168,8 @@ 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(
|
||||
@ -193,6 +195,7 @@ __all__ = [
|
||||
"LOGS_DIR",
|
||||
"AGENT_SKILLS_DIR",
|
||||
"WORKSPACE_SKILLS_DIRNAME",
|
||||
"PROJECT_AGENTS_SKILLS_DIRNAME",
|
||||
"WORKSPACE_MEMORY_DIRNAME",
|
||||
"WORKSPACE_REVIEW_DIRNAME",
|
||||
"IS_HOST_MODE",
|
||||
|
||||
@ -88,18 +88,18 @@ logger = setup_logger(__name__)
|
||||
DISABLE_LENGTH_CHECK = True
|
||||
|
||||
|
||||
def _build_sub_agents_md_notice(sub_paths: List[str], total: int) -> str:
|
||||
"""构建子目录 AGENTS.md 路径通知文案(只列相对路径,不注入内容)。无子目录文件时返回空串。"""
|
||||
def _build_sub_md_notice(filename: str, sub_paths: List[str], total: int) -> str:
|
||||
"""构建子目录指令文件路径通知文案(只列相对路径,不注入内容)。无子目录文件时返回空串。"""
|
||||
if not sub_paths:
|
||||
return ""
|
||||
lines = ["另外,工作区以下子目录也存在 AGENTS.md 规范文件:", ""]
|
||||
lines = [f"另外,工作区以下子目录也存在 {filename} 规范文件:", ""]
|
||||
for p in sub_paths:
|
||||
lines.append(f"- `{p}`")
|
||||
if total > len(sub_paths):
|
||||
lines.append("")
|
||||
lines.append(f"...共 {total} 个")
|
||||
lines.append("")
|
||||
lines.append("这些子目录规范仅适用于其所在目录范围。若你的操作涉及上述目录,请先自行读取对应 AGENTS.md 了解局部规范后再动手,不得将子目录规范当作全局规范套用。")
|
||||
lines.append(f"这些子目录规范仅适用于其所在目录范围。若你的操作涉及上述目录,请先自行读取对应 {filename} 了解局部规范后再动手,不得将子目录规范当作全局规范套用。")
|
||||
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 → skills → 记忆 → 自定义 → 禁用提示
|
||||
# 顺序:主prompt → 权限模式 → 执行环境 → 最近对话 → 个性化配置 → 工作区信息 → AGENTS.md → CLAUDE.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_agents_md_notice(sub_paths, sub_total)
|
||||
sub_notice = _build_sub_md_notice("AGENTS.md", 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,8 +300,48 @@ 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))
|
||||
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
|
||||
),
|
||||
)
|
||||
enabled_skills = merge_enabled_skills(
|
||||
personalization_config.get("enabled_skills") if isinstance(personalization_config, dict) else None,
|
||||
skills_catalog,
|
||||
|
||||
@ -101,23 +101,23 @@ _AGENTS_MD_SKIP_DIRS = {
|
||||
class PromptMixin:
|
||||
"""MainTerminalContextMixin prompt 能力 mixin。"""
|
||||
|
||||
def _load_agents_md_content(self) -> Optional[str]:
|
||||
"""加载工作区根目录的 AGENTS.md 文件内容(仅根目录,子目录的不注入)。"""
|
||||
def _load_root_md_content(self, filename: str) -> Optional[str]:
|
||||
"""加载工作区根目录的指定指令文件内容(仅根目录,子目录的不注入)。"""
|
||||
try:
|
||||
project_path = Path(self.project_path)
|
||||
root_file = project_path / "AGENTS.md"
|
||||
root_file = project_path / filename
|
||||
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"[AGENTS.md] 读取失败: {exc}")
|
||||
logger.warning(f"[{filename}] 读取失败: {exc}")
|
||||
return None
|
||||
|
||||
def _load_agents_md_updated_at(self) -> str:
|
||||
"""返回根目录 AGENTS.md 的最后修改时间文案(如「(最后修改:2026-08-12 17:54)」,失败返回空串)。"""
|
||||
def _load_root_md_updated_at(self, filename: str) -> str:
|
||||
"""返回根目录指定指令文件的最后修改时间文案(如「(最后修改:2026-08-12 17:54)」,失败返回空串)。"""
|
||||
try:
|
||||
root_file = Path(self.project_path) / "AGENTS.md"
|
||||
root_file = Path(self.project_path) / filename
|
||||
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_agents_md_paths(self, max_notice: int = 20, timeout_seconds: float = 30.0) -> tuple:
|
||||
"""扫描工作区一级子目录中的 AGENTS.md,返回 (相对路径列表, 实际总数)。
|
||||
def _load_sub_md_paths(self, filename: str, max_notice: int = 20, timeout_seconds: float = 30.0) -> tuple:
|
||||
"""扫描工作区一级子目录中的指定指令文件,返回 (相对路径列表, 实际总数)。
|
||||
|
||||
- 仅扫描根目录与一级子目录,不递归;跳过通用非源码目录(_AGENTS_MD_SKIP_DIRS)。
|
||||
- 列表最多 max_notice 个(超出截断,总数仍在第二项返回)。
|
||||
@ -141,19 +141,31 @@ class PromptMixin:
|
||||
start = time.monotonic()
|
||||
for sub in project_path.iterdir():
|
||||
if time.monotonic() - start >= timeout_seconds:
|
||||
logger.warning(f"[AGENTS.md] 子目录扫描超过 {timeout_seconds}s,返回已扫描到的 {len(found)} 个")
|
||||
logger.warning(f"[{filename}] 子目录扫描超过 {timeout_seconds}s,返回已扫描到的 {len(found)} 个")
|
||||
break
|
||||
if not sub.is_dir() or sub.name in _AGENTS_MD_SKIP_DIRS:
|
||||
continue
|
||||
candidate = sub / "AGENTS.md"
|
||||
candidate = sub / filename
|
||||
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"[AGENTS.md] 子目录扫描失败: {exc}")
|
||||
logger.warning(f"[{filename}] 子目录扫描失败: {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"
|
||||
|
||||
@ -119,7 +119,7 @@ class ToolsDefinitionFileToolsMixin:
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_skill",
|
||||
"description": "按 skill 名称读取 .astrion/skills/<name>/SKILL.md 内容;内部等价于 read_file 的 read 模式,并返回解析后的 path。",
|
||||
"description": "按 skill 名称读取 SKILL.md 内容(.astrion/skills/<name>/ 或 .agents/skills/<name>/);内部等价于 read_file 的 read 模式,并返回解析后的 path。若技能在 .astrion/skills/ 与 .agents/skills/ 同名重复,会报错并提示改用 read_file 按具体路径读取。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": self._inject_intent({
|
||||
|
||||
@ -21,6 +21,7 @@ try:
|
||||
CUSTOM_TOOLS_ENABLED,
|
||||
WORKSPACE_SKILLS_DIRNAME,
|
||||
WORKSPACE_MEMORY_DIRNAME,
|
||||
PROJECT_AGENTS_SKILLS_DIRNAME,
|
||||
)
|
||||
except ImportError:
|
||||
import sys
|
||||
@ -43,6 +44,7 @@ except ImportError:
|
||||
CUSTOM_TOOLS_ENABLED,
|
||||
WORKSPACE_SKILLS_DIRNAME,
|
||||
WORKSPACE_MEMORY_DIRNAME,
|
||||
PROJECT_AGENTS_SKILLS_DIRNAME,
|
||||
)
|
||||
|
||||
from modules.file_manager import FileManager
|
||||
@ -92,9 +94,10 @@ class MainTerminalToolsReadMixin:
|
||||
if not raw:
|
||||
return ""
|
||||
normalized = raw.replace("\\", "/").strip("/")
|
||||
prefix = f"{WORKSPACE_SKILLS_DIRNAME}/"
|
||||
if normalized.lower().startswith(prefix):
|
||||
normalized = normalized[len(prefix):]
|
||||
for prefix in (f"{WORKSPACE_SKILLS_DIRNAME}/", f"{PROJECT_AGENTS_SKILLS_DIRNAME}/"):
|
||||
if normalized.lower().startswith(prefix):
|
||||
normalized = normalized[len(prefix):]
|
||||
break
|
||||
if normalized.lower().endswith("/skill.md"):
|
||||
normalized = normalized[: -len("/SKILL.md")]
|
||||
return normalized.strip()
|
||||
@ -108,7 +111,16 @@ class MainTerminalToolsReadMixin:
|
||||
personalization = load_personalization_config(self.data_dir)
|
||||
except Exception:
|
||||
personalization = {}
|
||||
catalog = get_skills_catalog(private_dir=infer_private_skills_dir(self.data_dir))
|
||||
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,
|
||||
)
|
||||
enabled_skills = merge_enabled_skills(
|
||||
personalization.get("enabled_skills") if isinstance(personalization, dict) else None,
|
||||
catalog,
|
||||
@ -119,10 +131,28 @@ 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.get("id") for item in filtered_catalog if item.get("id")}
|
||||
id_map = {str(item.get("id", "")).lower(): item for item in filtered_catalog if item.get("id")}
|
||||
if normalized_lower in id_map:
|
||||
return {"success": True, "skill_id": id_map[normalized_lower]}
|
||||
return _resolve_item(id_map[normalized_lower])
|
||||
|
||||
# 2) 再按 label 匹配(忽略大小写)
|
||||
label_matches: List[str] = []
|
||||
@ -131,7 +161,7 @@ class MainTerminalToolsReadMixin:
|
||||
if label and label == normalized_lower and item.get("id"):
|
||||
label_matches.append(item["id"])
|
||||
if len(label_matches) == 1:
|
||||
return {"success": True, "skill_id": label_matches[0]}
|
||||
return _resolve_item(id_map[label_matches[0].lower()])
|
||||
if len(label_matches) > 1:
|
||||
return {
|
||||
"success": False,
|
||||
@ -147,8 +177,9 @@ 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"{WORKSPACE_SKILLS_DIRNAME}/{skill_id}/SKILL.md",
|
||||
"path": f"{display_dir}/{skill_id}/SKILL.md",
|
||||
"type": "read",
|
||||
}
|
||||
result = self._handle_read_tool(read_args)
|
||||
|
||||
@ -39,8 +39,10 @@ class WebTerminal(MainTerminal):
|
||||
对话级 terminal 只服务绑定对话,必须恢复该对话保存的模型
|
||||
(restore_model=True),否则重启后新建的对话级 terminal 会用默认
|
||||
模型跑任务/回写 metadata,把用户切换的模型覆盖掉。
|
||||
工作区级 terminal(未绑定):保持原有“最近对话”行为,且刻意不恢复
|
||||
模型(restore_model=False),避免 /new 页面显示旧对话的模型。
|
||||
工作区级 terminal(未绑定):保持原有“最近对话”行为(恢复焦点与模式),
|
||||
且刻意不恢复模型(restore_model=False),避免 /new 页面显示旧对话的模型。
|
||||
经由 load_conversation 的 attach_history 分流,工作区级不挂载消息历史——
|
||||
历史权威在磁盘 + 对话级实例(防 merge-on-save 旧内存写回污染源)。
|
||||
"""
|
||||
if self.context_manager.current_conversation_id:
|
||||
return
|
||||
@ -107,6 +109,15 @@ class WebTerminal(MainTerminal):
|
||||
container_session=container_session,
|
||||
usage_tracker=usage_tracker
|
||||
)
|
||||
|
||||
# 工作区级服务实例标记:不持有/不回写对话消息历史(历史权威在磁盘 + 对话级实例)。
|
||||
# 工作区级挂载历史只会成为 merge-on-save 的污染源(版本回溯被旧内存“救回”覆盖的事故根因)。
|
||||
# 注意必须在 super().__init__ 之后设置(context_manager 由父类创建);
|
||||
# 而 load 分流不依赖此标记(用 _bound_conversation_id,super 之前已设),初始化时序安全。
|
||||
try:
|
||||
self.context_manager._service_instance_no_history = conversation_id is None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Web特有属性
|
||||
self.message_callback = message_callback
|
||||
@ -417,7 +428,12 @@ class WebTerminal(MainTerminal):
|
||||
Dict: 加载结果
|
||||
"""
|
||||
try:
|
||||
success = self.context_manager.load_conversation_by_id(conversation_id)
|
||||
# 工作区级服务实例(_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)
|
||||
if success:
|
||||
# 根据对话元数据同步运行模式与推理强度
|
||||
try:
|
||||
@ -516,7 +532,8 @@ class WebTerminal(MainTerminal):
|
||||
"success": True,
|
||||
"conversation_id": conversation_id,
|
||||
"title": conversation_data.get("title", "未知对话"),
|
||||
"messages_count": len(self.context_manager.conversation_history),
|
||||
# 消息数以磁盘数据为准:服务实例不挂载历史,len(conversation_history) 恒为 0
|
||||
"messages_count": len(conversation_data.get("messages") or []),
|
||||
"run_mode": self.run_mode,
|
||||
"thinking_mode": self.thinking_mode,
|
||||
"model_key": getattr(self, "model_key", None),
|
||||
@ -630,6 +647,7 @@ 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,
|
||||
|
||||
@ -131,27 +131,75 @@ 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
|
||||
catalog = get_skills_catalog(private_dir=private_dir)
|
||||
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,
|
||||
)
|
||||
# 子智能体不区分 enabled/disabled,列出全部可用 skill
|
||||
skills_list = build_skills_list(catalog, enabled=None)
|
||||
skills_list = build_skills_list(catalog, resolve_enabled_skills(None, catalog))
|
||||
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"
|
||||
"## 可用 AgentSkill\n\n"
|
||||
"agent skills 系统已启用,以下是可用的 skills(含简要说明):\n\n"
|
||||
+ "\n".join(skills_list)
|
||||
+ "\n\n使用技能时,优先用 read_skill 通过技能名读取 SKILL.md;"
|
||||
"需要阅读 skill 目录中的其他文件时再使用 read_file\n"
|
||||
)
|
||||
except Exception:
|
||||
return ""
|
||||
@ -234,6 +282,11 @@ 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:
|
||||
|
||||
@ -112,6 +112,8 @@ 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, # 隐藏快捷窗口(对话区右侧的待办/子智能体/后台指令/文件窗口列)
|
||||
@ -548,6 +550,18 @@ 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"):
|
||||
|
||||
@ -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, WORKSPACE_SKILLS_DIRNAME
|
||||
from config import AGENT_SKILLS_DIR, CUSTOM_SKILLS_DIR, IS_HOST_MODE, PROJECT_AGENTS_SKILLS_DIRNAME, WORKSPACE_SKILLS_DIRNAME
|
||||
from utils.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
@ -282,8 +282,15 @@ 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."""
|
||||
"""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 报错引导。
|
||||
"""
|
||||
roots: List[Path] = [Path(base_dir or AGENT_SKILLS_DIR).expanduser().resolve()]
|
||||
if private_dir:
|
||||
private_root = Path(private_dir).expanduser().resolve()
|
||||
@ -292,15 +299,43 @@ def get_skills_catalog(
|
||||
|
||||
merged: Dict[str, Dict[str, str]] = {}
|
||||
order: List[str] = []
|
||||
for root in roots:
|
||||
for index, root in enumerate(roots):
|
||||
source = "global" if index == 0 else "private"
|
||||
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]
|
||||
|
||||
|
||||
@ -353,20 +388,41 @@ def build_skills_list(
|
||||
catalog: Sequence[Dict[str, str]],
|
||||
enabled_skill_ids: Sequence[str],
|
||||
) -> List[str]:
|
||||
"""Build skills list lines / 生成 skills 列表行。"""
|
||||
"""Build skills list lines / 生成 skills 列表行。
|
||||
|
||||
列表行直接使用该 skill 在工作区内的真实路径前缀(.astrion/skills/ 或 .agents/skills/);
|
||||
.agents/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()
|
||||
if description:
|
||||
lines.append(f".astrion/skills/{skill_id}:{description}")
|
||||
else:
|
||||
lines.append(f".astrion/skills/{skill_id}")
|
||||
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 读取具体路径查看。"
|
||||
)
|
||||
return lines
|
||||
|
||||
|
||||
|
||||
@ -163,7 +163,7 @@ SUB_AGENT_TOOLS: List[Dict[str, Any]] = [
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_skill",
|
||||
"description": "按 skill 名称读取 .astrion/skills/<name>/SKILL.md 内容;内部等价于 read_file 的 read 模式,并返回解析后的 path。",
|
||||
"description": "按 skill 名称读取 SKILL.md 内容(.astrion/skills/<name>/ 或 .agents/skills/<name>/);内部等价于 read_file 的 read 模式,并返回解析后的 path。若技能在 .astrion/skills/ 与 .agents/skills/ 同名重复,会报错并提示改用 read_file 按具体路径读取。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
10
prompts/claude_md_inject.txt
Normal file
10
prompts/claude_md_inject.txt
Normal file
@ -0,0 +1,10 @@
|
||||
【CLAUDE.md 项目规范】
|
||||
|
||||
以下是工作区根目录 CLAUDE.md 文件的内容{{CLAUDE_MD_UPDATED_AT}},请在回答时参考这些项目规范:
|
||||
|
||||
{{CLAUDE_MD_CONTENT}}
|
||||
|
||||
---
|
||||
注意:以上规范来自工作区根目录的 CLAUDE.md 文件,若与未来代码冲突,以实际代码为准。
|
||||
|
||||
{{SUB_CLAUDE_MD_NOTICE}}
|
||||
@ -47,6 +47,23 @@ 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
|
||||
@ -67,20 +84,19 @@ 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:
|
||||
ctx.conversation_manager.save_conversation(
|
||||
conversation_id=ctx.current_conversation_id,
|
||||
messages=ctx.conversation_history,
|
||||
_save_conversation_meta_via_disk(
|
||||
ctx,
|
||||
ctx.current_conversation_id,
|
||||
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}")
|
||||
@ -123,21 +139,20 @@ 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:
|
||||
ctx.conversation_manager.save_conversation(
|
||||
conversation_id=save_conversation_id,
|
||||
messages=ctx.conversation_history,
|
||||
_save_conversation_meta_via_disk(
|
||||
ctx,
|
||||
save_conversation_id,
|
||||
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。
|
||||
@ -192,6 +207,21 @@ 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
|
||||
@ -204,25 +234,21 @@ 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:
|
||||
ctx.conversation_manager.save_conversation(
|
||||
conversation_id=current_cid,
|
||||
messages=ctx.conversation_history,
|
||||
_save_conversation_meta_via_disk(
|
||||
ctx,
|
||||
current_cid,
|
||||
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}")
|
||||
@ -338,11 +364,10 @@ def update_personalization_settings(terminal: WebTerminal, workspace: UserWorksp
|
||||
ctx = getattr(terminal, 'context_manager', None)
|
||||
if ctx and getattr(ctx, 'current_conversation_id', None):
|
||||
try:
|
||||
ctx.conversation_manager.save_conversation(
|
||||
conversation_id=ctx.current_conversation_id,
|
||||
messages=ctx.conversation_history,
|
||||
_save_conversation_meta_via_disk(
|
||||
ctx,
|
||||
ctx.current_conversation_id,
|
||||
project_path=str(ctx.project_path),
|
||||
todo_list=ctx.todo_list,
|
||||
thinking_mode=terminal.thinking_mode,
|
||||
run_mode=terminal.run_mode
|
||||
)
|
||||
|
||||
@ -41,6 +41,14 @@ 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
|
||||
|
||||
@ -214,6 +214,41 @@ 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)
|
||||
|
||||
@ -1632,16 +1667,13 @@ def restore_conversation_versioning_checkpoint(conversation_id, terminal: WebTer
|
||||
terminal, workspace, normalized_id, target_conversation_id, seq, tracking_mode, host_mode, backup_mode
|
||||
)
|
||||
|
||||
# overwrite 场景需要清空内存历史避免反向覆写;copy 场景目标是新对话,无需处理。
|
||||
# overwrite 场景:回溯已裁短磁盘,必须同步所有持有该对话的内存实例(对话级
|
||||
# terminal 常驻 24h),否则旧内存后续保存经 merge 会把被裁消息「救回」,回溯被撤销。
|
||||
# copy 场景目标是新对话,无缓存实例,无需处理。
|
||||
if restore_mode == "overwrite":
|
||||
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}")
|
||||
_sync_restored_conversation_memory(target_conversation_id)
|
||||
|
||||
# 恢复模式与焦点到当前(工作区级)terminal;服务实例不挂载历史,仅读磁盘元数据
|
||||
terminal.load_conversation(target_conversation_id)
|
||||
debug_log(
|
||||
f"[Versioning][Restore] reload done conv={target_conversation_id} "
|
||||
@ -1898,7 +1930,12 @@ def list_sub_agents(terminal: WebTerminal, workspace: UserWorkspace, username: s
|
||||
announced = terminal._announced_sub_agent_tasks
|
||||
notified_from_history = set()
|
||||
try:
|
||||
history = getattr(terminal.context_manager, "conversation_history", []) or []
|
||||
# 服务实例(工作区级)不挂载历史:通知去重标记以磁盘对话为准
|
||||
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 []
|
||||
for msg in history:
|
||||
meta = msg.get("metadata") or {}
|
||||
task_id = meta.get("task_id")
|
||||
|
||||
@ -340,12 +340,16 @@ 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 等运行期注入消息不计入。
|
||||
# 与前端左侧输入导航 isRealUserNavMessage(ChatArea.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 {}
|
||||
if str(metadata.get("message_source") or "").strip().lower() != "user":
|
||||
source = str(metadata.get("message_source") or "user").strip().lower()
|
||||
if source not in ("user", "presend"):
|
||||
continue
|
||||
text = _extract_text_only(msg.get("content"))
|
||||
if text.strip():
|
||||
|
||||
@ -120,12 +120,16 @@ def api_activate_workflow(terminal, workspace, username):
|
||||
|
||||
try:
|
||||
# 激活提示消息将追加到历史末尾,阶段消息游标取当前长度 + 1。
|
||||
# 新建对话:激活消息是第 1 条,游标固定为 1(服务 terminal 的 history 长度不可信)。
|
||||
# 新建对话:激活消息是第 1 条,游标固定为 1。
|
||||
# 服务实例(工作区级)不挂载历史:非新建时游标以磁盘消息数为准。
|
||||
if created_new:
|
||||
msg_index = 1
|
||||
else:
|
||||
try:
|
||||
msg_index = len(getattr(terminal.context_manager, "conversation_history", []) or []) + 1
|
||||
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
|
||||
except Exception:
|
||||
msg_index = 0
|
||||
result = activate_workflow(
|
||||
|
||||
@ -92,7 +92,8 @@ const appOptions = {
|
||||
chatAppendTextChunk: 'appendTextChunk',
|
||||
chatCompleteTextAction: 'completeText',
|
||||
chatAddSystemMessage: 'addSystemMessage',
|
||||
chatEnsureAssistantMessage: 'ensureAssistantMessage'
|
||||
chatEnsureAssistantMessage: 'ensureAssistantMessage',
|
||||
chatClearStreamingResidualState: 'clearStreamingResidualState'
|
||||
}),
|
||||
...mapActions(useInputStore, {
|
||||
inputSetFocused: 'setInputFocused',
|
||||
|
||||
@ -367,9 +367,11 @@ 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) 工具执行中(取最后一段并行工具,过滤未完成的)
|
||||
@ -438,6 +440,11 @@ 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) {
|
||||
|
||||
@ -25,6 +25,11 @@ export const stateMethods = {
|
||||
});
|
||||
|
||||
// 重置消息和流状态
|
||||
// 先清消息内残留的流式字段(currentStreamingType/activeThinkingId/streaming
|
||||
// action),否则异常中断后状态头像会一直卡在「思考中」(avatarStatus 的
|
||||
// isThinking 只认这两个字段,与任务标志无关)
|
||||
this.chatClearStreamingResidualState();
|
||||
this.apiRequestPending = false;
|
||||
this.streamingMessage = false;
|
||||
this.currentMessageIndex = -1;
|
||||
this.stopRequested = false;
|
||||
|
||||
@ -183,8 +183,28 @@ 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;
|
||||
@ -426,6 +446,9 @@ export const lifecycleMethods = {
|
||||
// 同步处理状态更新
|
||||
this.streamingMessage = false;
|
||||
this.stopRequested = false;
|
||||
// 兜底清理可能残留的流式状态(正常流程 thinking_end/text_end 已清,此处幂等)
|
||||
this.chatClearStreamingResidualState?.();
|
||||
this.apiRequestPending = false;
|
||||
if (!hasRunningSubAgents && !hasRunningMultiAgent) {
|
||||
this.markLatestUserWorkCompleted();
|
||||
}
|
||||
@ -526,6 +549,8 @@ export const lifecycleMethods = {
|
||||
});
|
||||
|
||||
this.cleanupTrailingEmptyAssistantPlaceholder('task_stopped');
|
||||
this.chatClearStreamingResidualState?.();
|
||||
this.apiRequestPending = false;
|
||||
this.streamingMessage = false;
|
||||
this.stopRequested = false;
|
||||
|
||||
@ -668,7 +693,13 @@ 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;
|
||||
|
||||
@ -146,6 +146,9 @@ 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');
|
||||
}
|
||||
|
||||
@ -80,6 +80,10 @@ 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 间隙误清)
|
||||
|
||||
@ -2397,7 +2397,9 @@ function getGeneratingLetters(message: any) {
|
||||
}
|
||||
|
||||
// ===== 左侧用户输入快捷跳转导航 =====
|
||||
// 数据口径与深度压缩一致:role === 'user' 且 metadata.message_source === 'user'(引导/通知/压缩等不计入)
|
||||
// 口径:用户亲手输入 = metadata.message_source 为 'user'(直接发送)或 'presend'(提前输入排队后转正),
|
||||
// 字段缺失的老消息视为 'user';guidance(手动引导)/notify/compression 等运行期注入消息不计入。
|
||||
// 与深度压缩 _collect_user_texts(server/deep_compression.py)口径保持一致。
|
||||
const shellRef = ref<HTMLElement | null>(null);
|
||||
const virtualizerRef = ref<any>(null);
|
||||
const quickNavActiveIndex = ref(-1);
|
||||
@ -2407,7 +2409,8 @@ const isRealUserNavMessage = (msg: any): boolean => {
|
||||
if (!msg || msg.role !== 'user') {
|
||||
return false;
|
||||
}
|
||||
return String(msg?.metadata?.message_source || 'user').trim().toLowerCase() === 'user';
|
||||
const source = String(msg?.metadata?.message_source || 'user').trim().toLowerCase();
|
||||
return source === 'user' || source === 'presend';
|
||||
};
|
||||
|
||||
function extractUserFirstLine(msg: any): string {
|
||||
|
||||
@ -818,6 +818,8 @@ const props = defineProps<{
|
||||
toolTexts?: string[];
|
||||
text: string;
|
||||
tracking?: boolean;
|
||||
/** true 时表示当前文案是「等待 API 响应…」(work 模式下仅此类文案显示在头像旁) */
|
||||
apiWaiting?: boolean;
|
||||
} | null;
|
||||
}>();
|
||||
|
||||
@ -1074,6 +1076,11 @@ 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 '';
|
||||
});
|
||||
|
||||
@ -1083,6 +1090,7 @@ 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}`;
|
||||
});
|
||||
|
||||
|
||||
@ -931,6 +931,38 @@
|
||||
})
|
||||
" /><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
|
||||
|
||||
@ -1127,6 +1127,18 @@ 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 等空白页。
|
||||
@ -1140,6 +1152,8 @@ 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) {
|
||||
@ -1227,6 +1241,7 @@ export async function initializeLegacySocket(ctx: any) {
|
||||
return;
|
||||
}
|
||||
socketLog('文本开始');
|
||||
ctx.apiRequestPending = false;
|
||||
logStreamingDebug('socket:text_start');
|
||||
finalizeStreamingText({ force: true });
|
||||
resetStreamingBuffer();
|
||||
@ -1354,6 +1369,8 @@ 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;
|
||||
@ -1861,7 +1878,9 @@ export async function initializeLegacySocket(ctx: any) {
|
||||
}
|
||||
}
|
||||
if (shouldRetry) {
|
||||
// 错误后保持停止按钮态,用户可手动停止或等待自动重试
|
||||
// 错误后保持停止按钮态,用户可手动停止或等待自动重试;
|
||||
// 重试间隔不算「等待 API 响应」,重试发起时后端会重新发 api_request_start
|
||||
ctx.apiRequestPending = false;
|
||||
ctx.stopRequested = false;
|
||||
ctx.taskInProgress = true;
|
||||
ctx.streamingMessage = true;
|
||||
@ -1874,15 +1893,13 @@ export async function initializeLegacySocket(ctx: any) {
|
||||
);
|
||||
}
|
||||
|
||||
// 最后一次报错:恢复输入状态并清理提示动画
|
||||
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 = '';
|
||||
}
|
||||
// 最后一次报错:恢复输入状态并清理提示动画/流式残留
|
||||
// (统一清理 currentStreamingType/activeThinkingId 等字段,
|
||||
// 否则 avatarStatus 的 isThinking 永久为真,状态头像卡在「思考中」)
|
||||
if (typeof ctx.chatClearStreamingResidualState === 'function') {
|
||||
ctx.chatClearStreamingResidualState();
|
||||
}
|
||||
ctx.apiRequestPending = false;
|
||||
if (typeof ctx.chatClearThinkingLocks === 'function') {
|
||||
ctx.chatClearThinkingLocks();
|
||||
}
|
||||
|
||||
@ -388,6 +388,35 @@ 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 消息渲染,
|
||||
// 避免运行时与刷新后在垂直间距上不一致。
|
||||
|
||||
@ -132,6 +132,8 @@ 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[];
|
||||
@ -336,6 +338,8 @@ 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: [],
|
||||
@ -600,6 +604,8 @@ 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)
|
||||
|
||||
@ -179,12 +179,16 @@ class ConversationMixin:
|
||||
print(f"📝 开始新对话: {conversation_id}")
|
||||
return conversation_id
|
||||
|
||||
def load_conversation_by_id(self, conversation_id: str) -> bool:
|
||||
def load_conversation_by_id(self, conversation_id: str, attach_history: bool = True) -> bool:
|
||||
"""
|
||||
加载指定对话
|
||||
|
||||
Args:
|
||||
conversation_id: 对话ID
|
||||
attach_history: 是否把消息历史/元数据挂载到内存。工作区级服务实例应传 False
|
||||
(仅恢复焦点 current_conversation_id 与运行模式):对话历史的权威载体是
|
||||
磁盘 + 对话级 terminal,服务实例挂载历史只会成为 merge-on-save 的污染源
|
||||
(版本回溯被旧内存“救回”覆盖即此机制事故)。
|
||||
|
||||
Returns:
|
||||
bool: 加载是否成功
|
||||
@ -221,12 +225,18 @@ class ConversationMixin:
|
||||
|
||||
# 更新当前状态
|
||||
self.current_conversation_id = conversation_id
|
||||
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 {})
|
||||
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 = {}
|
||||
# 恢复项目文件树快照(如已存在)
|
||||
meta = self.conversation_metadata
|
||||
meta = deepcopy(conversation_data.get("metadata", {}) or {})
|
||||
if meta.get("project_file_tree"):
|
||||
self.project_snapshot = {
|
||||
"file_tree": meta.get("project_file_tree"),
|
||||
@ -264,7 +274,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 not self.has_images or not self.has_videos:
|
||||
if attach_history and (not self.has_images or not self.has_videos):
|
||||
for msg in self.conversation_history:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
@ -321,6 +331,13 @@ 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:
|
||||
"""
|
||||
保存当前对话
|
||||
@ -328,6 +345,9 @@ class ConversationMixin:
|
||||
Returns:
|
||||
bool: 保存是否成功
|
||||
"""
|
||||
if self._is_service_instance_no_history():
|
||||
# 服务实例不持有历史,保存无语义;直接跳过,防止任何路径把空/陈旧内存写回
|
||||
return False
|
||||
if not self.current_conversation_id:
|
||||
print("⚠️ 没有当前对话ID,无法保存")
|
||||
return False
|
||||
@ -362,6 +382,8 @@ 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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user