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 模式预设角色目录(web 模式下使用,host 模式下不用)
|
||||||
WEB_PRESET_ROLES_DIR = str(Path(RUNTIME_ROOT) / "web" / "mutiagents" / "agents")
|
WEB_PRESET_ROLES_DIR = str(Path(RUNTIME_ROOT) / "web" / "mutiagents" / "agents")
|
||||||
WORKSPACE_SKILLS_DIRNAME = ".astrion/skills"
|
WORKSPACE_SKILLS_DIRNAME = ".astrion/skills"
|
||||||
|
# 行业通用技能目录(Agent Skills 开放标准,工作区根目录下,扫描不同步复制)
|
||||||
|
PROJECT_AGENTS_SKILLS_DIRNAME = ".agents/skills"
|
||||||
WORKSPACE_MEMORY_DIRNAME = ".astrion/memory"
|
WORKSPACE_MEMORY_DIRNAME = ".astrion/memory"
|
||||||
WORKSPACE_REVIEW_DIRNAME = ".astrion/review"
|
WORKSPACE_REVIEW_DIRNAME = ".astrion/review"
|
||||||
HOST_WORKSPACES_FILE = _resolve_repo_path(
|
HOST_WORKSPACES_FILE = _resolve_repo_path(
|
||||||
@ -193,6 +195,7 @@ __all__ = [
|
|||||||
"LOGS_DIR",
|
"LOGS_DIR",
|
||||||
"AGENT_SKILLS_DIR",
|
"AGENT_SKILLS_DIR",
|
||||||
"WORKSPACE_SKILLS_DIRNAME",
|
"WORKSPACE_SKILLS_DIRNAME",
|
||||||
|
"PROJECT_AGENTS_SKILLS_DIRNAME",
|
||||||
"WORKSPACE_MEMORY_DIRNAME",
|
"WORKSPACE_MEMORY_DIRNAME",
|
||||||
"WORKSPACE_REVIEW_DIRNAME",
|
"WORKSPACE_REVIEW_DIRNAME",
|
||||||
"IS_HOST_MODE",
|
"IS_HOST_MODE",
|
||||||
|
|||||||
@ -88,18 +88,18 @@ logger = setup_logger(__name__)
|
|||||||
DISABLE_LENGTH_CHECK = True
|
DISABLE_LENGTH_CHECK = True
|
||||||
|
|
||||||
|
|
||||||
def _build_sub_agents_md_notice(sub_paths: List[str], total: int) -> str:
|
def _build_sub_md_notice(filename: str, sub_paths: List[str], total: int) -> str:
|
||||||
"""构建子目录 AGENTS.md 路径通知文案(只列相对路径,不注入内容)。无子目录文件时返回空串。"""
|
"""构建子目录指令文件路径通知文案(只列相对路径,不注入内容)。无子目录文件时返回空串。"""
|
||||||
if not sub_paths:
|
if not sub_paths:
|
||||||
return ""
|
return ""
|
||||||
lines = ["另外,工作区以下子目录也存在 AGENTS.md 规范文件:", ""]
|
lines = [f"另外,工作区以下子目录也存在 {filename} 规范文件:", ""]
|
||||||
for p in sub_paths:
|
for p in sub_paths:
|
||||||
lines.append(f"- `{p}`")
|
lines.append(f"- `{p}`")
|
||||||
if total > len(sub_paths):
|
if total > len(sub_paths):
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append(f"...共 {total} 个")
|
lines.append(f"...共 {total} 个")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("这些子目录规范仅适用于其所在目录范围。若你的操作涉及上述目录,请先自行读取对应 AGENTS.md 了解局部规范后再动手,不得将子目录规范当作全局规范套用。")
|
lines.append(f"这些子目录规范仅适用于其所在目录范围。若你的操作涉及上述目录,请先自行读取对应 {filename} 了解局部规范后再动手,不得将子目录规范当作全局规范套用。")
|
||||||
return "\n".join(lines)
|
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)
|
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
|
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(
|
permission_mode_message = self._get_or_init_frozen_mode_prompt(
|
||||||
@ -275,7 +275,7 @@ class MessagesMixin:
|
|||||||
return ""
|
return ""
|
||||||
# 子目录 AGENTS.md 只通知相对路径,不注入内容
|
# 子目录 AGENTS.md 只通知相对路径,不注入内容
|
||||||
sub_paths, sub_total = self._load_sub_agents_md_paths()
|
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()
|
agents_md_template = self.load_prompt("agents_md_inject").strip()
|
||||||
if agents_md_template and "{{AGENTS_MD_CONTENT}}" in agents_md_template:
|
if agents_md_template and "{{AGENTS_MD_CONTENT}}" in agents_md_template:
|
||||||
text = agents_md_template.replace("{{AGENTS_MD_CONTENT}}", agents_md_content)
|
text = agents_md_template.replace("{{AGENTS_MD_CONTENT}}", agents_md_content)
|
||||||
@ -300,8 +300,48 @@ class MessagesMixin:
|
|||||||
if agents_md_text:
|
if agents_md_text:
|
||||||
messages.append({"role": "system", "content": 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 列表
|
||||||
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(
|
enabled_skills = merge_enabled_skills(
|
||||||
personalization_config.get("enabled_skills") if isinstance(personalization_config, dict) else None,
|
personalization_config.get("enabled_skills") if isinstance(personalization_config, dict) else None,
|
||||||
skills_catalog,
|
skills_catalog,
|
||||||
|
|||||||
@ -101,23 +101,23 @@ _AGENTS_MD_SKIP_DIRS = {
|
|||||||
class PromptMixin:
|
class PromptMixin:
|
||||||
"""MainTerminalContextMixin prompt 能力 mixin。"""
|
"""MainTerminalContextMixin prompt 能力 mixin。"""
|
||||||
|
|
||||||
def _load_agents_md_content(self) -> Optional[str]:
|
def _load_root_md_content(self, filename: str) -> Optional[str]:
|
||||||
"""加载工作区根目录的 AGENTS.md 文件内容(仅根目录,子目录的不注入)。"""
|
"""加载工作区根目录的指定指令文件内容(仅根目录,子目录的不注入)。"""
|
||||||
try:
|
try:
|
||||||
project_path = Path(self.project_path)
|
project_path = Path(self.project_path)
|
||||||
root_file = project_path / "AGENTS.md"
|
root_file = project_path / filename
|
||||||
if not root_file.is_file():
|
if not root_file.is_file():
|
||||||
return None
|
return None
|
||||||
content = root_file.read_text(encoding='utf-8')
|
content = root_file.read_text(encoding='utf-8')
|
||||||
return content.strip() if content else None
|
return content.strip() if content else None
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(f"[AGENTS.md] 读取失败: {exc}")
|
logger.warning(f"[{filename}] 读取失败: {exc}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _load_agents_md_updated_at(self) -> str:
|
def _load_root_md_updated_at(self, filename: str) -> str:
|
||||||
"""返回根目录 AGENTS.md 的最后修改时间文案(如「(最后修改:2026-08-12 17:54)」,失败返回空串)。"""
|
"""返回根目录指定指令文件的最后修改时间文案(如「(最后修改:2026-08-12 17:54)」,失败返回空串)。"""
|
||||||
try:
|
try:
|
||||||
root_file = Path(self.project_path) / "AGENTS.md"
|
root_file = Path(self.project_path) / filename
|
||||||
if not root_file.is_file():
|
if not root_file.is_file():
|
||||||
return ""
|
return ""
|
||||||
mtime = root_file.stat().st_mtime
|
mtime = root_file.stat().st_mtime
|
||||||
@ -125,8 +125,8 @@ class PromptMixin:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
def _load_sub_agents_md_paths(self, max_notice: int = 20, timeout_seconds: float = 30.0) -> tuple:
|
def _load_sub_md_paths(self, filename: str, max_notice: int = 20, timeout_seconds: float = 30.0) -> tuple:
|
||||||
"""扫描工作区一级子目录中的 AGENTS.md,返回 (相对路径列表, 实际总数)。
|
"""扫描工作区一级子目录中的指定指令文件,返回 (相对路径列表, 实际总数)。
|
||||||
|
|
||||||
- 仅扫描根目录与一级子目录,不递归;跳过通用非源码目录(_AGENTS_MD_SKIP_DIRS)。
|
- 仅扫描根目录与一级子目录,不递归;跳过通用非源码目录(_AGENTS_MD_SKIP_DIRS)。
|
||||||
- 列表最多 max_notice 个(超出截断,总数仍在第二项返回)。
|
- 列表最多 max_notice 个(超出截断,总数仍在第二项返回)。
|
||||||
@ -141,19 +141,31 @@ class PromptMixin:
|
|||||||
start = time.monotonic()
|
start = time.monotonic()
|
||||||
for sub in project_path.iterdir():
|
for sub in project_path.iterdir():
|
||||||
if time.monotonic() - start >= timeout_seconds:
|
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
|
break
|
||||||
if not sub.is_dir() or sub.name in _AGENTS_MD_SKIP_DIRS:
|
if not sub.is_dir() or sub.name in _AGENTS_MD_SKIP_DIRS:
|
||||||
continue
|
continue
|
||||||
candidate = sub / "AGENTS.md"
|
candidate = sub / filename
|
||||||
if candidate.is_file():
|
if candidate.is_file():
|
||||||
total += 1
|
total += 1
|
||||||
if len(found) < max_notice:
|
if len(found) < max_notice:
|
||||||
found.append(candidate.relative_to(project_path).as_posix())
|
found.append(candidate.relative_to(project_path).as_posix())
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(f"[AGENTS.md] 子目录扫描失败: {exc}")
|
logger.warning(f"[{filename}] 子目录扫描失败: {exc}")
|
||||||
return found, total
|
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:
|
def load_prompt(self, name: str) -> str:
|
||||||
"""加载提示模板"""
|
"""加载提示模板"""
|
||||||
prompt_file = Path(PROMPTS_DIR) / f"{name}.txt"
|
prompt_file = Path(PROMPTS_DIR) / f"{name}.txt"
|
||||||
|
|||||||
@ -119,7 +119,7 @@ class ToolsDefinitionFileToolsMixin:
|
|||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "read_skill",
|
"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": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": self._inject_intent({
|
"properties": self._inject_intent({
|
||||||
|
|||||||
@ -21,6 +21,7 @@ try:
|
|||||||
CUSTOM_TOOLS_ENABLED,
|
CUSTOM_TOOLS_ENABLED,
|
||||||
WORKSPACE_SKILLS_DIRNAME,
|
WORKSPACE_SKILLS_DIRNAME,
|
||||||
WORKSPACE_MEMORY_DIRNAME,
|
WORKSPACE_MEMORY_DIRNAME,
|
||||||
|
PROJECT_AGENTS_SKILLS_DIRNAME,
|
||||||
)
|
)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
import sys
|
import sys
|
||||||
@ -43,6 +44,7 @@ except ImportError:
|
|||||||
CUSTOM_TOOLS_ENABLED,
|
CUSTOM_TOOLS_ENABLED,
|
||||||
WORKSPACE_SKILLS_DIRNAME,
|
WORKSPACE_SKILLS_DIRNAME,
|
||||||
WORKSPACE_MEMORY_DIRNAME,
|
WORKSPACE_MEMORY_DIRNAME,
|
||||||
|
PROJECT_AGENTS_SKILLS_DIRNAME,
|
||||||
)
|
)
|
||||||
|
|
||||||
from modules.file_manager import FileManager
|
from modules.file_manager import FileManager
|
||||||
@ -92,9 +94,10 @@ class MainTerminalToolsReadMixin:
|
|||||||
if not raw:
|
if not raw:
|
||||||
return ""
|
return ""
|
||||||
normalized = raw.replace("\\", "/").strip("/")
|
normalized = raw.replace("\\", "/").strip("/")
|
||||||
prefix = f"{WORKSPACE_SKILLS_DIRNAME}/"
|
for prefix in (f"{WORKSPACE_SKILLS_DIRNAME}/", f"{PROJECT_AGENTS_SKILLS_DIRNAME}/"):
|
||||||
if normalized.lower().startswith(prefix):
|
if normalized.lower().startswith(prefix):
|
||||||
normalized = normalized[len(prefix):]
|
normalized = normalized[len(prefix):]
|
||||||
|
break
|
||||||
if normalized.lower().endswith("/skill.md"):
|
if normalized.lower().endswith("/skill.md"):
|
||||||
normalized = normalized[: -len("/SKILL.md")]
|
normalized = normalized[: -len("/SKILL.md")]
|
||||||
return normalized.strip()
|
return normalized.strip()
|
||||||
@ -108,7 +111,16 @@ class MainTerminalToolsReadMixin:
|
|||||||
personalization = load_personalization_config(self.data_dir)
|
personalization = load_personalization_config(self.data_dir)
|
||||||
except Exception:
|
except Exception:
|
||||||
personalization = {}
|
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(
|
enabled_skills = merge_enabled_skills(
|
||||||
personalization.get("enabled_skills") if isinstance(personalization, dict) else None,
|
personalization.get("enabled_skills") if isinstance(personalization, dict) else None,
|
||||||
catalog,
|
catalog,
|
||||||
@ -119,10 +131,28 @@ class MainTerminalToolsReadMixin:
|
|||||||
|
|
||||||
normalized_lower = normalized_input.lower()
|
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 精确匹配(忽略大小写)
|
# 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:
|
if normalized_lower in id_map:
|
||||||
return {"success": True, "skill_id": id_map[normalized_lower]}
|
return _resolve_item(id_map[normalized_lower])
|
||||||
|
|
||||||
# 2) 再按 label 匹配(忽略大小写)
|
# 2) 再按 label 匹配(忽略大小写)
|
||||||
label_matches: List[str] = []
|
label_matches: List[str] = []
|
||||||
@ -131,7 +161,7 @@ class MainTerminalToolsReadMixin:
|
|||||||
if label and label == normalized_lower and item.get("id"):
|
if label and label == normalized_lower and item.get("id"):
|
||||||
label_matches.append(item["id"])
|
label_matches.append(item["id"])
|
||||||
if len(label_matches) == 1:
|
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:
|
if len(label_matches) > 1:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
@ -147,8 +177,9 @@ class MainTerminalToolsReadMixin:
|
|||||||
return resolved
|
return resolved
|
||||||
|
|
||||||
skill_id = resolved["skill_id"]
|
skill_id = resolved["skill_id"]
|
||||||
|
display_dir = str(resolved.get("display_dir") or WORKSPACE_SKILLS_DIRNAME).strip("/")
|
||||||
read_args = {
|
read_args = {
|
||||||
"path": f"{WORKSPACE_SKILLS_DIRNAME}/{skill_id}/SKILL.md",
|
"path": f"{display_dir}/{skill_id}/SKILL.md",
|
||||||
"type": "read",
|
"type": "read",
|
||||||
}
|
}
|
||||||
result = self._handle_read_tool(read_args)
|
result = self._handle_read_tool(read_args)
|
||||||
|
|||||||
@ -39,8 +39,10 @@ class WebTerminal(MainTerminal):
|
|||||||
对话级 terminal 只服务绑定对话,必须恢复该对话保存的模型
|
对话级 terminal 只服务绑定对话,必须恢复该对话保存的模型
|
||||||
(restore_model=True),否则重启后新建的对话级 terminal 会用默认
|
(restore_model=True),否则重启后新建的对话级 terminal 会用默认
|
||||||
模型跑任务/回写 metadata,把用户切换的模型覆盖掉。
|
模型跑任务/回写 metadata,把用户切换的模型覆盖掉。
|
||||||
工作区级 terminal(未绑定):保持原有“最近对话”行为,且刻意不恢复
|
工作区级 terminal(未绑定):保持原有“最近对话”行为(恢复焦点与模式),
|
||||||
模型(restore_model=False),避免 /new 页面显示旧对话的模型。
|
且刻意不恢复模型(restore_model=False),避免 /new 页面显示旧对话的模型。
|
||||||
|
经由 load_conversation 的 attach_history 分流,工作区级不挂载消息历史——
|
||||||
|
历史权威在磁盘 + 对话级实例(防 merge-on-save 旧内存写回污染源)。
|
||||||
"""
|
"""
|
||||||
if self.context_manager.current_conversation_id:
|
if self.context_manager.current_conversation_id:
|
||||||
return
|
return
|
||||||
@ -108,6 +110,15 @@ class WebTerminal(MainTerminal):
|
|||||||
usage_tracker=usage_tracker
|
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特有属性
|
# Web特有属性
|
||||||
self.message_callback = message_callback
|
self.message_callback = message_callback
|
||||||
self.web_mode = True
|
self.web_mode = True
|
||||||
@ -417,7 +428,12 @@ class WebTerminal(MainTerminal):
|
|||||||
Dict: 加载结果
|
Dict: 加载结果
|
||||||
"""
|
"""
|
||||||
try:
|
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:
|
if success:
|
||||||
# 根据对话元数据同步运行模式与推理强度
|
# 根据对话元数据同步运行模式与推理强度
|
||||||
try:
|
try:
|
||||||
@ -516,7 +532,8 @@ class WebTerminal(MainTerminal):
|
|||||||
"success": True,
|
"success": True,
|
||||||
"conversation_id": conversation_id,
|
"conversation_id": conversation_id,
|
||||||
"title": conversation_data.get("title", "未知对话"),
|
"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,
|
"run_mode": self.run_mode,
|
||||||
"thinking_mode": self.thinking_mode,
|
"thinking_mode": self.thinking_mode,
|
||||||
"model_key": getattr(self, "model_key", None),
|
"model_key": getattr(self, "model_key", None),
|
||||||
@ -630,6 +647,7 @@ class WebTerminal(MainTerminal):
|
|||||||
"context": {
|
"context": {
|
||||||
"usage_percent": context_status['usage_percent'],
|
"usage_percent": context_status['usage_percent'],
|
||||||
"total_size": context_status['sizes']['total'],
|
"total_size": context_status['sizes']['total'],
|
||||||
|
# 本实例内存上下文中的消息数;工作区级服务实例不挂载历史,恒为 0(前端不消费该字段)
|
||||||
"conversation_count": len(self.context_manager.conversation_history)
|
"conversation_count": len(self.context_manager.conversation_history)
|
||||||
},
|
},
|
||||||
"focused_files": focused_files_dict,
|
"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:
|
def _build_skills_section(workspace_path: str, data_dir: str = "") -> str:
|
||||||
"""构建可用 skill 列表段。"""
|
"""构建可用 skill 列表段。"""
|
||||||
try:
|
try:
|
||||||
|
from modules.personalization_manager import load_personalization_config
|
||||||
from modules.skills_manager import (
|
from modules.skills_manager import (
|
||||||
get_skills_catalog,
|
get_skills_catalog,
|
||||||
build_skills_list,
|
build_skills_list,
|
||||||
|
resolve_enabled_skills,
|
||||||
infer_private_skills_dir,
|
infer_private_skills_dir,
|
||||||
)
|
)
|
||||||
|
|
||||||
private_dir = infer_private_skills_dir(data_dir) if data_dir else None
|
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
|
# 子智能体不区分 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:
|
if not skills_list:
|
||||||
return ""
|
return ""
|
||||||
return (
|
return (
|
||||||
f"## 可用 AgentSkill\n\n"
|
"## 可用 AgentSkill\n\n"
|
||||||
f"agent skills 系统已启用,以下是可用的 skills(含简要说明):\n\n"
|
"agent skills 系统已启用,以下是可用的 skills(含简要说明):\n\n"
|
||||||
f"{skills_list}\n\n"
|
+ "\n".join(skills_list)
|
||||||
f"使用技能时,优先用 read_skill 通过技能名读取 SKILL.md;"
|
+ "\n\n使用技能时,优先用 read_skill 通过技能名读取 SKILL.md;"
|
||||||
f"需要阅读 skill 目录中的其他文件时再使用 read_file\n"
|
"需要阅读 skill 目录中的其他文件时再使用 read_file\n"
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
return ""
|
return ""
|
||||||
@ -234,6 +282,11 @@ def build_sub_agent_dynamic_context(
|
|||||||
if agents_md:
|
if agents_md:
|
||||||
sections.append(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
|
# 5. 可用 skill
|
||||||
skills = _build_skills_section(workspace_path, data_dir=data_dir)
|
skills = _build_skills_section(workspace_path, data_dir=data_dir)
|
||||||
if skills:
|
if skills:
|
||||||
|
|||||||
@ -112,6 +112,8 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = {
|
|||||||
"versioning_backup_mode": "shallow", # 文件备份方式:shallow-浅备份(只备份AI编辑的文件)/ full-完全备份(整个工作区)
|
"versioning_backup_mode": "shallow", # 文件备份方式:shallow-浅备份(只备份AI编辑的文件)/ full-完全备份(整个工作区)
|
||||||
"versioning_restore_mode": "overwrite", # 版本回溯模式固定为 overwrite
|
"versioning_restore_mode": "overwrite", # 版本回溯模式固定为 overwrite
|
||||||
"agents_md_auto_inject": False, # AGENTS.md 自动注入开关
|
"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-立即创建空对话
|
"new_chat_button_behavior": "route", # 新建对话按钮行为:route-跳转空白新对话页 / blank-立即创建空对话
|
||||||
"default_hide_workspace": False, # 默认隐藏工作区
|
"default_hide_workspace": False, # 默认隐藏工作区
|
||||||
"hide_quick_dock": False, # 隐藏快捷窗口(对话区右侧的待办/子智能体/后台指令/文件窗口列)
|
"hide_quick_dock": False, # 隐藏快捷窗口(对话区右侧的待办/子智能体/后台指令/文件窗口列)
|
||||||
@ -548,6 +550,18 @@ def sanitize_personalization_payload(
|
|||||||
else:
|
else:
|
||||||
base["agents_md_auto_inject"] = bool(base.get("agents_md_auto_inject", False))
|
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-立即创建空对话
|
# 新建对话按钮行为:route-跳转空白新对话页 / blank-立即创建空对话
|
||||||
new_chat_behavior = data.get("new_chat_button_behavior", base.get("new_chat_button_behavior"))
|
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"):
|
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 pathlib import Path
|
||||||
from typing import Dict, List, Optional, Sequence
|
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
|
from utils.logger import setup_logger
|
||||||
|
|
||||||
logger = setup_logger(__name__)
|
logger = setup_logger(__name__)
|
||||||
@ -282,8 +282,15 @@ def _scan_skills_catalog(root: Path) -> List[Dict[str, str]]:
|
|||||||
def get_skills_catalog(
|
def get_skills_catalog(
|
||||||
base_dir: Optional[str] = None,
|
base_dir: Optional[str] = None,
|
||||||
private_dir: Optional[str | Path] = None,
|
private_dir: Optional[str | Path] = None,
|
||||||
|
project_path: Optional[str | Path] = None,
|
||||||
|
scan_project_agents: bool = True,
|
||||||
) -> List[Dict[str, str]]:
|
) -> 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()]
|
roots: List[Path] = [Path(base_dir or AGENT_SKILLS_DIR).expanduser().resolve()]
|
||||||
if private_dir:
|
if private_dir:
|
||||||
private_root = Path(private_dir).expanduser().resolve()
|
private_root = Path(private_dir).expanduser().resolve()
|
||||||
@ -292,15 +299,43 @@ def get_skills_catalog(
|
|||||||
|
|
||||||
merged: Dict[str, Dict[str, str]] = {}
|
merged: Dict[str, Dict[str, str]] = {}
|
||||||
order: List[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):
|
for item in _scan_skills_catalog(root):
|
||||||
skill_id = item.get("id")
|
skill_id = item.get("id")
|
||||||
if not skill_id:
|
if not skill_id:
|
||||||
continue
|
continue
|
||||||
if skill_id not in merged:
|
if skill_id not in merged:
|
||||||
order.append(skill_id)
|
order.append(skill_id)
|
||||||
|
item["source"] = source
|
||||||
|
item["display_dir"] = WORKSPACE_SKILLS_DIRNAME
|
||||||
# Later roots (private) override metadata for same id.
|
# Later roots (private) override metadata for same id.
|
||||||
merged[skill_id] = item
|
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]
|
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]],
|
catalog: Sequence[Dict[str, str]],
|
||||||
enabled_skill_ids: Sequence[str],
|
enabled_skill_ids: Sequence[str],
|
||||||
) -> List[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:
|
if not enabled_skill_ids:
|
||||||
return []
|
return []
|
||||||
lookup = {item.get("id"): item for item in catalog if item.get("id")}
|
lookup = {item.get("id"): item for item in catalog if item.get("id")}
|
||||||
lines: List[str] = []
|
lines: List[str] = []
|
||||||
|
has_agents_source = False
|
||||||
|
has_conflict = False
|
||||||
for skill_id in enabled_skill_ids:
|
for skill_id in enabled_skill_ids:
|
||||||
meta = lookup.get(skill_id)
|
meta = lookup.get(skill_id)
|
||||||
if not meta:
|
if not meta:
|
||||||
continue
|
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()
|
description = (meta.get("description") or "").strip()
|
||||||
if description:
|
line = f"{display_dir}/{skill_id}:{description}" if description else f"{display_dir}/{skill_id}"
|
||||||
lines.append(f".astrion/skills/{skill_id}:{description}")
|
conflict_dir = (meta.get("conflict_dir") or "").strip("/")
|
||||||
else:
|
if conflict_dir:
|
||||||
lines.append(f".astrion/skills/{skill_id}")
|
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
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -163,7 +163,7 @@ SUB_AGENT_TOOLS: List[Dict[str, Any]] = [
|
|||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "read_skill",
|
"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": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"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"
|
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'])
|
@chat_bp.route('/api/thinking-mode', methods=['POST'])
|
||||||
@api_login_required
|
@api_login_required
|
||||||
@with_terminal
|
@with_terminal
|
||||||
@ -67,20 +84,19 @@ def update_thinking_mode(terminal: WebTerminal, workspace: UserWorkspace, userna
|
|||||||
terminal.set_run_mode(target_mode)
|
terminal.set_run_mode(target_mode)
|
||||||
session['thinking_mode'] = terminal.thinking_mode
|
session['thinking_mode'] = terminal.thinking_mode
|
||||||
session['run_mode'] = terminal.run_mode
|
session['run_mode'] = terminal.run_mode
|
||||||
# 更新当前对话的元数据
|
# 更新当前对话的元数据(服务实例不挂载历史:读磁盘回传,仅更新元数据)
|
||||||
ctx = terminal.context_manager
|
ctx = terminal.context_manager
|
||||||
if ctx.current_conversation_id:
|
if ctx.current_conversation_id:
|
||||||
try:
|
try:
|
||||||
ctx.conversation_manager.save_conversation(
|
_save_conversation_meta_via_disk(
|
||||||
conversation_id=ctx.current_conversation_id,
|
ctx,
|
||||||
messages=ctx.conversation_history,
|
ctx.current_conversation_id,
|
||||||
project_path=str(ctx.project_path),
|
project_path=str(ctx.project_path),
|
||||||
todo_list=ctx.todo_list,
|
|
||||||
thinking_mode=terminal.thinking_mode,
|
thinking_mode=terminal.thinking_mode,
|
||||||
run_mode=terminal.run_mode,
|
run_mode=terminal.run_mode,
|
||||||
model_key=getattr(terminal, "model_key", None),
|
model_key=getattr(terminal, "model_key", None),
|
||||||
has_images=getattr(ctx, "has_images", False),
|
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:
|
except Exception as exc:
|
||||||
logger.error(f"[API] 保存思考模式到对话失败: {exc}")
|
logger.error(f"[API] 保存思考模式到对话失败: {exc}")
|
||||||
@ -123,21 +139,20 @@ def update_reasoning_effort(terminal: WebTerminal, workspace: UserWorkspace, use
|
|||||||
if is_current_target:
|
if is_current_target:
|
||||||
terminal.set_reasoning_effort(effort)
|
terminal.set_reasoning_effort(effort)
|
||||||
save_conversation_id = target_conversation_id or ctx.current_conversation_id
|
save_conversation_id = target_conversation_id or ctx.current_conversation_id
|
||||||
# 更新对话的元数据
|
# 更新对话的元数据(服务实例不挂载历史:统一读磁盘回传,仅更新元数据)
|
||||||
if save_conversation_id:
|
if save_conversation_id:
|
||||||
try:
|
try:
|
||||||
if is_current_target:
|
if is_current_target:
|
||||||
ctx.conversation_manager.save_conversation(
|
_save_conversation_meta_via_disk(
|
||||||
conversation_id=save_conversation_id,
|
ctx,
|
||||||
messages=ctx.conversation_history,
|
save_conversation_id,
|
||||||
project_path=str(ctx.project_path),
|
project_path=str(ctx.project_path),
|
||||||
todo_list=ctx.todo_list,
|
|
||||||
thinking_mode=terminal.thinking_mode,
|
thinking_mode=terminal.thinking_mode,
|
||||||
run_mode=terminal.run_mode,
|
run_mode=terminal.run_mode,
|
||||||
reasoning_effort=effort,
|
reasoning_effort=effort,
|
||||||
model_key=getattr(terminal, "model_key", None),
|
model_key=getattr(terminal, "model_key", None),
|
||||||
has_images=getattr(ctx, "has_images", False),
|
has_images=getattr(ctx, "has_images", False),
|
||||||
has_videos=getattr(ctx, "has_videos", False)
|
has_videos=getattr(ctx, "has_videos", False),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# 所属对话已不是当前对话:只更新推理强度 meta。
|
# 所属对话已不是当前对话:只更新推理强度 meta。
|
||||||
@ -192,6 +207,21 @@ def update_model(terminal: WebTerminal, workspace: UserWorkspace, username: str)
|
|||||||
"message": "被管理员强制禁用"
|
"message": "被管理员强制禁用"
|
||||||
}), 403
|
}), 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)
|
terminal.set_model(model_key)
|
||||||
# fast-only 时 run_mode 可能被强制为 fast
|
# fast-only 时 run_mode 可能被强制为 fast
|
||||||
session["model_key"] = terminal.model_key
|
session["model_key"] = terminal.model_key
|
||||||
@ -204,25 +234,21 @@ def update_model(terminal: WebTerminal, workspace: UserWorkspace, username: str)
|
|||||||
# 盲目保存会把模型写到错误对话,或用陈旧 history 覆盖新消息。
|
# 盲目保存会把模型写到错误对话,或用陈旧 history 覆盖新消息。
|
||||||
# /new 页面(无 conversation_id)不持久化,新对话在首个任务时
|
# /new 页面(无 conversation_id)不持久化,新对话在首个任务时
|
||||||
# 由前端随任务传入的 model_key 落盘。
|
# 由前端随任务传入的 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
|
ctx = terminal.context_manager
|
||||||
current_cid = getattr(ctx, "current_conversation_id", None)
|
current_cid = getattr(ctx, "current_conversation_id", None)
|
||||||
if requested_cid and current_cid:
|
if requested_cid and current_cid:
|
||||||
if current_cid == requested_cid:
|
if current_cid == requested_cid:
|
||||||
try:
|
try:
|
||||||
ctx.conversation_manager.save_conversation(
|
_save_conversation_meta_via_disk(
|
||||||
conversation_id=current_cid,
|
ctx,
|
||||||
messages=ctx.conversation_history,
|
current_cid,
|
||||||
project_path=str(ctx.project_path),
|
project_path=str(ctx.project_path),
|
||||||
todo_list=ctx.todo_list,
|
|
||||||
thinking_mode=terminal.thinking_mode,
|
thinking_mode=terminal.thinking_mode,
|
||||||
run_mode=terminal.run_mode,
|
run_mode=terminal.run_mode,
|
||||||
reasoning_effort=terminal.reasoning_effort,
|
reasoning_effort=terminal.reasoning_effort,
|
||||||
model_key=terminal.model_key,
|
model_key=terminal.model_key,
|
||||||
has_images=getattr(ctx, "has_images", False),
|
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:
|
except Exception as exc:
|
||||||
logger.error(f"[API] 保存模型到对话失败: {exc}")
|
logger.error(f"[API] 保存模型到对话失败: {exc}")
|
||||||
@ -338,11 +364,10 @@ def update_personalization_settings(terminal: WebTerminal, workspace: UserWorksp
|
|||||||
ctx = getattr(terminal, 'context_manager', None)
|
ctx = getattr(terminal, 'context_manager', None)
|
||||||
if ctx and getattr(ctx, 'current_conversation_id', None):
|
if ctx and getattr(ctx, 'current_conversation_id', None):
|
||||||
try:
|
try:
|
||||||
ctx.conversation_manager.save_conversation(
|
_save_conversation_meta_via_disk(
|
||||||
conversation_id=ctx.current_conversation_id,
|
ctx,
|
||||||
messages=ctx.conversation_history,
|
ctx.current_conversation_id,
|
||||||
project_path=str(ctx.project_path),
|
project_path=str(ctx.project_path),
|
||||||
todo_list=ctx.todo_list,
|
|
||||||
thinking_mode=terminal.thinking_mode,
|
thinking_mode=terminal.thinking_mode,
|
||||||
run_mode=terminal.run_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
|
last_finish_reason = None
|
||||||
tool_call_stream_active = False
|
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):
|
async for chunk in web_terminal.api_client.chat(messages, tools, stream=True):
|
||||||
chunk_count += 1
|
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}"
|
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:
|
def _normalize_versioning_tracking_mode(value: Optional[str]) -> str:
|
||||||
return ConversationVersioningManager.normalize_tracking_mode(value)
|
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
|
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":
|
if restore_mode == "overwrite":
|
||||||
try:
|
_sync_restored_conversation_memory(target_conversation_id)
|
||||||
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)
|
terminal.load_conversation(target_conversation_id)
|
||||||
debug_log(
|
debug_log(
|
||||||
f"[Versioning][Restore] reload done conv={target_conversation_id} "
|
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
|
announced = terminal._announced_sub_agent_tasks
|
||||||
notified_from_history = set()
|
notified_from_history = set()
|
||||||
try:
|
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:
|
for msg in history:
|
||||||
meta = msg.get("metadata") or {}
|
meta = msg.get("metadata") or {}
|
||||||
task_id = meta.get("task_id")
|
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]:
|
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] = []
|
result: List[str] = []
|
||||||
for msg in messages:
|
for msg in messages:
|
||||||
if msg.get("role") != "user":
|
if msg.get("role") != "user":
|
||||||
continue
|
continue
|
||||||
metadata = msg.get("metadata") if isinstance(msg.get("metadata"), dict) else {}
|
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
|
continue
|
||||||
text = _extract_text_only(msg.get("content"))
|
text = _extract_text_only(msg.get("content"))
|
||||||
if text.strip():
|
if text.strip():
|
||||||
|
|||||||
@ -120,12 +120,16 @@ def api_activate_workflow(terminal, workspace, username):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# 激活提示消息将追加到历史末尾,阶段消息游标取当前长度 + 1。
|
# 激活提示消息将追加到历史末尾,阶段消息游标取当前长度 + 1。
|
||||||
# 新建对话:激活消息是第 1 条,游标固定为 1(服务 terminal 的 history 长度不可信)。
|
# 新建对话:激活消息是第 1 条,游标固定为 1。
|
||||||
|
# 服务实例(工作区级)不挂载历史:非新建时游标以磁盘消息数为准。
|
||||||
if created_new:
|
if created_new:
|
||||||
msg_index = 1
|
msg_index = 1
|
||||||
else:
|
else:
|
||||||
try:
|
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:
|
except Exception:
|
||||||
msg_index = 0
|
msg_index = 0
|
||||||
result = activate_workflow(
|
result = activate_workflow(
|
||||||
|
|||||||
@ -92,7 +92,8 @@ const appOptions = {
|
|||||||
chatAppendTextChunk: 'appendTextChunk',
|
chatAppendTextChunk: 'appendTextChunk',
|
||||||
chatCompleteTextAction: 'completeText',
|
chatCompleteTextAction: 'completeText',
|
||||||
chatAddSystemMessage: 'addSystemMessage',
|
chatAddSystemMessage: 'addSystemMessage',
|
||||||
chatEnsureAssistantMessage: 'ensureAssistantMessage'
|
chatEnsureAssistantMessage: 'ensureAssistantMessage',
|
||||||
|
chatClearStreamingResidualState: 'clearStreamingResidualState'
|
||||||
}),
|
}),
|
||||||
...mapActions(useInputStore, {
|
...mapActions(useInputStore, {
|
||||||
inputSetFocused: 'setInputFocused',
|
inputSetFocused: 'setInputFocused',
|
||||||
|
|||||||
@ -367,9 +367,11 @@ export const computed = {
|
|||||||
|
|
||||||
const intentEnabled = !!usePersonalizationStore().form?.tool_intent_enabled;
|
const intentEnabled = !!usePersonalizationStore().form?.tool_intent_enabled;
|
||||||
|
|
||||||
// 1) 思考中
|
// 1) 思考中(防御:仅在任务仍运行时采信流式字段——异常中断后字段可能残留,
|
||||||
|
// 虽有各终结路径的统一清理兑底,这里再加一层保险避免永久卡「思考中」)
|
||||||
const isThinking =
|
const isThinking =
|
||||||
!!lastAssistant &&
|
!!lastAssistant &&
|
||||||
|
(this.taskInProgress || this.streamingMessage) &&
|
||||||
(lastAssistant.currentStreamingType === 'thinking' || lastAssistant.activeThinkingId != null);
|
(lastAssistant.currentStreamingType === 'thinking' || lastAssistant.activeThinkingId != null);
|
||||||
|
|
||||||
// 2) 工具执行中(取最后一段并行工具,过滤未完成的)
|
// 2) 工具执行中(取最后一段并行工具,过滤未完成的)
|
||||||
@ -438,6 +440,11 @@ export const computed = {
|
|||||||
if (running) {
|
if (running) {
|
||||||
// 工作态:等 API / 后台运行 / 流式输出正文
|
// 工作态:等 API / 后台运行 / 流式输出正文
|
||||||
let text = bgText;
|
let text = bgText;
|
||||||
|
// 「等待 API 响应…」优先于随机等待文案:后端已发出请求、尚未开始回复
|
||||||
|
// (api_request_start 事件驱动,覆盖首轮与工具轮次间的每一次等待)
|
||||||
|
if (!text && this.apiRequestPending) {
|
||||||
|
return { mode: 'work', toolKeys: [], toolTexts: [], text: '等待 API 响应…', tracking: false, apiWaiting: true };
|
||||||
|
}
|
||||||
if (!text) {
|
if (!text) {
|
||||||
const awaitingMsg = lastAssistant && lastAssistant.awaitingFirstContent ? lastAssistant : null;
|
const awaitingMsg = lastAssistant && lastAssistant.awaitingFirstContent ? lastAssistant : null;
|
||||||
if (awaitingMsg) {
|
if (awaitingMsg) {
|
||||||
|
|||||||
@ -25,6 +25,11 @@ export const stateMethods = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 重置消息和流状态
|
// 重置消息和流状态
|
||||||
|
// 先清消息内残留的流式字段(currentStreamingType/activeThinkingId/streaming
|
||||||
|
// action),否则异常中断后状态头像会一直卡在「思考中」(avatarStatus 的
|
||||||
|
// isThinking 只认这两个字段,与任务标志无关)
|
||||||
|
this.chatClearStreamingResidualState();
|
||||||
|
this.apiRequestPending = false;
|
||||||
this.streamingMessage = false;
|
this.streamingMessage = false;
|
||||||
this.currentMessageIndex = -1;
|
this.currentMessageIndex = -1;
|
||||||
this.stopRequested = false;
|
this.stopRequested = false;
|
||||||
|
|||||||
@ -183,8 +183,28 @@ export const lifecycleMethods = {
|
|||||||
|
|
||||||
debugLog(`[TaskPolling] 处理事件 #${eventIdx}: ${eventType}`, eventData);
|
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) {
|
switch (eventType) {
|
||||||
|
case 'api_request_start':
|
||||||
|
// 状态已在上方统一维护,无其他处理
|
||||||
|
break;
|
||||||
|
|
||||||
case 'ai_message_start':
|
case 'ai_message_start':
|
||||||
this.handleAiMessageStart(eventData, eventIdx);
|
this.handleAiMessageStart(eventData, eventIdx);
|
||||||
break;
|
break;
|
||||||
@ -426,6 +446,9 @@ export const lifecycleMethods = {
|
|||||||
// 同步处理状态更新
|
// 同步处理状态更新
|
||||||
this.streamingMessage = false;
|
this.streamingMessage = false;
|
||||||
this.stopRequested = false;
|
this.stopRequested = false;
|
||||||
|
// 兜底清理可能残留的流式状态(正常流程 thinking_end/text_end 已清,此处幂等)
|
||||||
|
this.chatClearStreamingResidualState?.();
|
||||||
|
this.apiRequestPending = false;
|
||||||
if (!hasRunningSubAgents && !hasRunningMultiAgent) {
|
if (!hasRunningSubAgents && !hasRunningMultiAgent) {
|
||||||
this.markLatestUserWorkCompleted();
|
this.markLatestUserWorkCompleted();
|
||||||
}
|
}
|
||||||
@ -526,6 +549,8 @@ export const lifecycleMethods = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.cleanupTrailingEmptyAssistantPlaceholder('task_stopped');
|
this.cleanupTrailingEmptyAssistantPlaceholder('task_stopped');
|
||||||
|
this.chatClearStreamingResidualState?.();
|
||||||
|
this.apiRequestPending = false;
|
||||||
this.streamingMessage = false;
|
this.streamingMessage = false;
|
||||||
this.stopRequested = false;
|
this.stopRequested = false;
|
||||||
|
|
||||||
@ -668,7 +693,13 @@ export const lifecycleMethods = {
|
|||||||
duration: 8000
|
duration: 8000
|
||||||
});
|
});
|
||||||
|
|
||||||
// 清理状态
|
// 清理状态(顺序:先清依赖 awaitingFirstContent 判定的空占位,再清残留流式字段)
|
||||||
|
this.cleanupTrailingEmptyAssistantPlaceholder?.('task_error');
|
||||||
|
this.chatClearStreamingResidualState?.();
|
||||||
|
if (typeof this.clearPendingTools === 'function') {
|
||||||
|
this.clearPendingTools('task_error');
|
||||||
|
}
|
||||||
|
this.apiRequestPending = false;
|
||||||
this.markLatestUserWorkCompleted();
|
this.markLatestUserWorkCompleted();
|
||||||
this.streamingMessage = false;
|
this.streamingMessage = false;
|
||||||
this.taskInProgress = false;
|
this.taskInProgress = false;
|
||||||
|
|||||||
@ -146,6 +146,9 @@ export const probeMethods = {
|
|||||||
this.stopRequested = false;
|
this.stopRequested = false;
|
||||||
this.waitingForSubAgent = false;
|
this.waitingForSubAgent = false;
|
||||||
this.waitingForBackgroundCommand = false;
|
this.waitingForBackgroundCommand = false;
|
||||||
|
this.apiRequestPending = false;
|
||||||
|
// 同步清理消息内残留的流式状态(思考/文本),避免状态头像卡在「思考中」
|
||||||
|
this.chatClearStreamingResidualState?.();
|
||||||
if (typeof this.clearPendingTools === 'function') {
|
if (typeof this.clearPendingTools === 'function') {
|
||||||
this.clearPendingTools('reconcile_auto_clear');
|
this.clearPendingTools('reconcile_auto_clear');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -80,6 +80,10 @@ export function dataState() {
|
|||||||
toolStacks: new Map(),
|
toolStacks: new Map(),
|
||||||
// 当前任务是否仍在进行中(用于保持输入区的"停止"状态)
|
// 当前任务是否仍在进行中(用于保持输入区的"停止"状态)
|
||||||
taskInProgress: false,
|
taskInProgress: false,
|
||||||
|
// 等待 API 响应:对话运行期间后端已发出 API 请求、尚未收到首个响应事件
|
||||||
|
// (由后端 api_request_start 事件置位,thinking_start/text_start/tool_preparing/
|
||||||
|
// error/任务终结时清除),用于状态头像显示「等待 API 响应…」
|
||||||
|
apiRequestPending: false,
|
||||||
// 对话运行状态对账定时器(事件为主、2.5s 对账纠偏,冲突以对账为准)
|
// 对话运行状态对账定时器(事件为主、2.5s 对账纠偏,冲突以对账为准)
|
||||||
runningStateReconcileTimer: null,
|
runningStateReconcileTimer: null,
|
||||||
// 对账清理方向的连续空闲确认计数(防 notice/idle dispatch 间隙误清)
|
// 对账清理方向的连续空闲确认计数(防 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 shellRef = ref<HTMLElement | null>(null);
|
||||||
const virtualizerRef = ref<any>(null);
|
const virtualizerRef = ref<any>(null);
|
||||||
const quickNavActiveIndex = ref(-1);
|
const quickNavActiveIndex = ref(-1);
|
||||||
@ -2407,7 +2409,8 @@ const isRealUserNavMessage = (msg: any): boolean => {
|
|||||||
if (!msg || msg.role !== 'user') {
|
if (!msg || msg.role !== 'user') {
|
||||||
return false;
|
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 {
|
function extractUserFirstLine(msg: any): string {
|
||||||
|
|||||||
@ -818,6 +818,8 @@ const props = defineProps<{
|
|||||||
toolTexts?: string[];
|
toolTexts?: string[];
|
||||||
text: string;
|
text: string;
|
||||||
tracking?: boolean;
|
tracking?: boolean;
|
||||||
|
/** true 时表示当前文案是「等待 API 响应…」(work 模式下仅此类文案显示在头像旁) */
|
||||||
|
apiWaiting?: boolean;
|
||||||
} | null;
|
} | null;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
@ -1074,6 +1076,11 @@ const composerAvatarText = computed(() => {
|
|||||||
if (props.avatarStatus.mode === 'tool' || props.avatarStatus.mode === 'think') {
|
if (props.avatarStatus.mode === 'tool' || props.avatarStatus.mode === 'think') {
|
||||||
return props.avatarStatus.text || '';
|
return props.avatarStatus.text || '';
|
||||||
}
|
}
|
||||||
|
// work 模式仅显示「等待 API 响应…」(apiWaiting 标记);
|
||||||
|
// 后台计数/随机等待文案不在头像旁重复显示(后者已在消息区等待动画中体现)
|
||||||
|
if (props.avatarStatus.mode === 'work' && props.avatarStatus.apiWaiting) {
|
||||||
|
return props.avatarStatus.text || '';
|
||||||
|
}
|
||||||
return '';
|
return '';
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1083,6 +1090,7 @@ const composerAvatarTextKey = computed(() => {
|
|||||||
const s = props.avatarStatus;
|
const s = props.avatarStatus;
|
||||||
if (!s) return 'avatar-none';
|
if (!s) return 'avatar-none';
|
||||||
if (s.mode === 'tool') return `avatar-tool-${s.toolKeys?.[0] || ''}`;
|
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}`;
|
return `avatar-${s.mode}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -931,6 +931,38 @@
|
|||||||
})
|
})
|
||||||
" /><FancyCheck :checked="form.agents_md_auto_inject" /></label>
|
" /><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"
|
<label class="settings-toggle-row"
|
||||||
><span class="settings-row-copy"
|
><span class="settings-row-copy"
|
||||||
><span class="settings-row-title">文件修改留痕</span
|
><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) => {
|
ctx.socket.on('thinking_start', (data) => {
|
||||||
// 对话定向事件:只响应当前对话,避免运行中对话的流式输出渲染到 /new 等空白页。
|
// 对话定向事件:只响应当前对话,避免运行中对话的流式输出渲染到 /new 等空白页。
|
||||||
@ -1140,6 +1152,8 @@ export async function initializeLegacySocket(ctx: any) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
socketLog('思考开始');
|
socketLog('思考开始');
|
||||||
|
// 响应已开始,清除「等待 API 响应」状态(含 ignoreThinking 分支,故放最前)
|
||||||
|
ctx.apiRequestPending = false;
|
||||||
const ignoreThinking = ctx.runMode === 'fast' || ctx.thinkingMode === false;
|
const ignoreThinking = ctx.runMode === 'fast' || ctx.thinkingMode === false;
|
||||||
streamingState.ignoreThinking = ignoreThinking;
|
streamingState.ignoreThinking = ignoreThinking;
|
||||||
if (ignoreThinking) {
|
if (ignoreThinking) {
|
||||||
@ -1227,6 +1241,7 @@ export async function initializeLegacySocket(ctx: any) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
socketLog('文本开始');
|
socketLog('文本开始');
|
||||||
|
ctx.apiRequestPending = false;
|
||||||
logStreamingDebug('socket:text_start');
|
logStreamingDebug('socket:text_start');
|
||||||
finalizeStreamingText({ force: true });
|
finalizeStreamingText({ force: true });
|
||||||
resetStreamingBuffer();
|
resetStreamingBuffer();
|
||||||
@ -1354,6 +1369,8 @@ export async function initializeLegacySocket(ctx: any) {
|
|||||||
socketLog('跳过tool_preparing(对话不匹配)', data.conversation_id);
|
socketLog('跳过tool_preparing(对话不匹配)', data.conversation_id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 工具流式收集即 API 响应已开始,清除「等待 API 响应」状态
|
||||||
|
ctx.apiRequestPending = false;
|
||||||
const msg = ctx.chatEnsureAssistantMessage();
|
const msg = ctx.chatEnsureAssistantMessage();
|
||||||
if (!msg) {
|
if (!msg) {
|
||||||
return;
|
return;
|
||||||
@ -1861,7 +1878,9 @@ export async function initializeLegacySocket(ctx: any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (shouldRetry) {
|
if (shouldRetry) {
|
||||||
// 错误后保持停止按钮态,用户可手动停止或等待自动重试
|
// 错误后保持停止按钮态,用户可手动停止或等待自动重试;
|
||||||
|
// 重试间隔不算「等待 API 响应」,重试发起时后端会重新发 api_request_start
|
||||||
|
ctx.apiRequestPending = false;
|
||||||
ctx.stopRequested = false;
|
ctx.stopRequested = false;
|
||||||
ctx.taskInProgress = true;
|
ctx.taskInProgress = true;
|
||||||
ctx.streamingMessage = true;
|
ctx.streamingMessage = true;
|
||||||
@ -1874,15 +1893,13 @@ export async function initializeLegacySocket(ctx: any) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 最后一次报错:恢复输入状态并清理提示动画
|
// 最后一次报错:恢复输入状态并清理提示动画/流式残留
|
||||||
const msgIndex = typeof ctx.currentMessageIndex === 'number' ? ctx.currentMessageIndex : -1;
|
// (统一清理 currentStreamingType/activeThinkingId 等字段,
|
||||||
if (msgIndex >= 0 && Array.isArray(ctx.messages)) {
|
// 否则 avatarStatus 的 isThinking 永久为真,状态头像卡在「思考中」)
|
||||||
const currentMessage = ctx.messages[msgIndex];
|
if (typeof ctx.chatClearStreamingResidualState === 'function') {
|
||||||
if (currentMessage && currentMessage.role === 'assistant') {
|
ctx.chatClearStreamingResidualState();
|
||||||
currentMessage.awaitingFirstContent = false;
|
|
||||||
currentMessage.generatingLabel = '';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
ctx.apiRequestPending = false;
|
||||||
if (typeof ctx.chatClearThinkingLocks === 'function') {
|
if (typeof ctx.chatClearThinkingLocks === 'function') {
|
||||||
ctx.chatClearThinkingLocks();
|
ctx.chatClearThinkingLocks();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -388,6 +388,35 @@ export const useChatStore = defineStore('chat', {
|
|||||||
msg.currentStreamingType = null;
|
msg.currentStreamingType = null;
|
||||||
delete (msg as any).__splitByShowHtml;
|
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) {
|
addSystemMessage(content: string, meta: any = null) {
|
||||||
// 与历史重建保持一致:子智能体/后台完成通知作为独立 assistant 消息渲染,
|
// 与历史重建保持一致:子智能体/后台完成通知作为独立 assistant 消息渲染,
|
||||||
// 避免运行时与刷新后在垂直间距上不一致。
|
// 避免运行时与刷新后在垂直间距上不一致。
|
||||||
|
|||||||
@ -132,6 +132,8 @@ interface PersonalForm {
|
|||||||
deep_compress_trigger_tokens: number | null;
|
deep_compress_trigger_tokens: number | null;
|
||||||
deep_compress_form: 'file' | 'inject';
|
deep_compress_form: 'file' | 'inject';
|
||||||
agents_md_auto_inject: boolean;
|
agents_md_auto_inject: boolean;
|
||||||
|
claude_md_auto_inject: boolean;
|
||||||
|
agents_skills_scan_enabled: boolean;
|
||||||
new_chat_button_behavior: 'route' | 'blank';
|
new_chat_button_behavior: 'route' | 'blank';
|
||||||
group_sidebar_by_workspace: boolean;
|
group_sidebar_by_workspace: boolean;
|
||||||
sidebar_pinned_workspaces: string[];
|
sidebar_pinned_workspaces: string[];
|
||||||
@ -336,6 +338,8 @@ const defaultForm = (): PersonalForm => ({
|
|||||||
deep_compress_trigger_tokens: null,
|
deep_compress_trigger_tokens: null,
|
||||||
deep_compress_form: 'file',
|
deep_compress_form: 'file',
|
||||||
agents_md_auto_inject: false,
|
agents_md_auto_inject: false,
|
||||||
|
claude_md_auto_inject: false,
|
||||||
|
agents_skills_scan_enabled: true,
|
||||||
new_chat_button_behavior: 'route',
|
new_chat_button_behavior: 'route',
|
||||||
group_sidebar_by_workspace: false,
|
group_sidebar_by_workspace: false,
|
||||||
sidebar_pinned_workspaces: [],
|
sidebar_pinned_workspaces: [],
|
||||||
@ -600,6 +604,8 @@ export const usePersonalizationStore = defineStore('personalization', {
|
|||||||
),
|
),
|
||||||
deep_compress_form: data.deep_compress_form === 'inject' ? 'inject' : 'file',
|
deep_compress_form: data.deep_compress_form === 'inject' ? 'inject' : 'file',
|
||||||
agents_md_auto_inject: !!data.agents_md_auto_inject,
|
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',
|
new_chat_button_behavior: data.new_chat_button_behavior === 'blank' ? 'blank' : 'route',
|
||||||
group_sidebar_by_workspace: !!data.group_sidebar_by_workspace,
|
group_sidebar_by_workspace: !!data.group_sidebar_by_workspace,
|
||||||
sidebar_pinned_workspaces: Array.isArray(data.sidebar_pinned_workspaces)
|
sidebar_pinned_workspaces: Array.isArray(data.sidebar_pinned_workspaces)
|
||||||
|
|||||||
@ -179,12 +179,16 @@ class ConversationMixin:
|
|||||||
print(f"📝 开始新对话: {conversation_id}")
|
print(f"📝 开始新对话: {conversation_id}")
|
||||||
return 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:
|
Args:
|
||||||
conversation_id: 对话ID
|
conversation_id: 对话ID
|
||||||
|
attach_history: 是否把消息历史/元数据挂载到内存。工作区级服务实例应传 False
|
||||||
|
(仅恢复焦点 current_conversation_id 与运行模式):对话历史的权威载体是
|
||||||
|
磁盘 + 对话级 terminal,服务实例挂载历史只会成为 merge-on-save 的污染源
|
||||||
|
(版本回溯被旧内存“救回”覆盖即此机制事故)。
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: 加载是否成功
|
bool: 加载是否成功
|
||||||
@ -221,12 +225,18 @@ class ConversationMixin:
|
|||||||
|
|
||||||
# 更新当前状态
|
# 更新当前状态
|
||||||
self.current_conversation_id = conversation_id
|
self.current_conversation_id = conversation_id
|
||||||
|
if attach_history:
|
||||||
self.conversation_history = conversation_data.get("messages", [])
|
self.conversation_history = conversation_data.get("messages", [])
|
||||||
todo_data = conversation_data.get("todo_list")
|
todo_data = conversation_data.get("todo_list")
|
||||||
self.todo_list = deepcopy(todo_data) if todo_data else None
|
self.todo_list = deepcopy(todo_data) if todo_data else None
|
||||||
self.conversation_metadata = deepcopy(conversation_data.get("metadata", {}) or {})
|
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"):
|
if meta.get("project_file_tree"):
|
||||||
self.project_snapshot = {
|
self.project_snapshot = {
|
||||||
"file_tree": meta.get("project_file_tree"),
|
"file_tree": meta.get("project_file_tree"),
|
||||||
@ -264,7 +274,7 @@ class ConversationMixin:
|
|||||||
model_key = metadata.get("model_key")
|
model_key = metadata.get("model_key")
|
||||||
self.has_images = metadata.get("has_images", False)
|
self.has_images = metadata.get("has_images", False)
|
||||||
self.has_videos = metadata.get("has_videos", 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:
|
for msg in self.conversation_history:
|
||||||
if not isinstance(msg, dict):
|
if not isinstance(msg, dict):
|
||||||
continue
|
continue
|
||||||
@ -321,6 +331,13 @@ class ConversationMixin:
|
|||||||
|
|
||||||
return True
|
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:
|
def save_current_conversation(self) -> bool:
|
||||||
"""
|
"""
|
||||||
保存当前对话
|
保存当前对话
|
||||||
@ -328,6 +345,9 @@ class ConversationMixin:
|
|||||||
Returns:
|
Returns:
|
||||||
bool: 保存是否成功
|
bool: 保存是否成功
|
||||||
"""
|
"""
|
||||||
|
if self._is_service_instance_no_history():
|
||||||
|
# 服务实例不持有历史,保存无语义;直接跳过,防止任何路径把空/陈旧内存写回
|
||||||
|
return False
|
||||||
if not self.current_conversation_id:
|
if not self.current_conversation_id:
|
||||||
print("⚠️ 没有当前对话ID,无法保存")
|
print("⚠️ 没有当前对话ID,无法保存")
|
||||||
return False
|
return False
|
||||||
@ -362,6 +382,8 @@ class ConversationMixin:
|
|||||||
|
|
||||||
def auto_save_conversation(self, force: bool = False):
|
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:
|
if not self.auto_save_enabled or not self.current_conversation_id:
|
||||||
return
|
return
|
||||||
if not force and not self.conversation_history:
|
if not force and not self.conversation_history:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user