From 398ee6e760f09b672372cc9e3804433bbc5f5e47 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Wed, 27 May 2026 03:09:42 +0800 Subject: [PATCH] feat(memory): refactor proactive memory and conversation recall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor conversation persistence into workspace-scoped storage with host workspace legacy migration, current workspace filtering, and active host workspace state protection. Add proactive recent-conversation prompt injection with personal-space controls, configurable item count, empty-conversation filtering, and frozen per-conversation prompt metadata to keep request caches stable. Introduce conversation_search and conversation_review tools for current-workspace history: multi-keyword search, list mode, current conversation exclusion, message/tool counts, formatted model output, read/save review modes, long-review fallback, and .agents/review storage. Move internal workspace artifacts under .agents, including uploads, skills, compact results, and reviews; update prompt file-tree visibility, compression output, upload paths, and hidden versioning exclusions. Add create_skill workflow and a Skills tool category, move read_skill into Skills, validate/归档 skill folders, support host global skills and Docker/API private skills, and sync private skills into .agents/skills and skill prompts. Update frontend tool icons, statuses, and expanded renderers for conversation recall and skills workflows; add workspace conversation and skills-manager tests. --- agentskills/frontend-design/SKILL.md | 1 + config/paths.py | 2 +- core/main_terminal.py | 33 ++- core/main_terminal_parts/context.py | 69 ++++- core/main_terminal_parts/tools_definition.py | 61 ++++- core/main_terminal_parts/tools_execution.py | 194 +++++++++++++- core/main_terminal_parts/tools_policy.py | 3 +- core/main_terminal_parts/tools_read.py | 3 +- core/tool_config.py | 7 +- modules/api_user_manager.py | 8 +- modules/personalization_manager.py | 19 ++ modules/skills_manager.py | 148 ++++++++++- modules/user_manager.py | 4 +- modules/versioning_manager.py | 2 +- prompts/recent_conversations.txt | 10 + server/app_legacy.py | 2 +- server/auth.py | 2 +- server/chat.py | 21 +- server/context.py | 25 +- server/conversation.py | 8 +- server/conversation_stats.py | 2 +- server/deep_compression.py | 2 +- server/state.py | 8 + server/status.py | 8 + .../components/chat/actions/ToolAction.vue | 92 ++++++- .../components/chat/actions/toolRenderers.ts | 92 ++++++- .../personalization/PersonalizationDrawer.vue | 94 +++++++ static/src/stores/personalization.ts | 46 ++++ static/src/utils/chatDisplay.ts | 7 +- static/src/utils/icons.ts | 4 + test/test_conversation_workspace_storage.py | 151 +++++++++++ test/test_skills_manager.py | 97 +++++++ utils/context_manager.py | 43 ++- utils/conversation_manager.py | 249 +++++++++++++++++- utils/host_workspace_debug.py | 1 - utils/tool_result_formatter.py | 90 +++++++ 36 files changed, 1545 insertions(+), 63 deletions(-) create mode 100644 prompts/recent_conversations.txt create mode 100644 test/test_conversation_workspace_storage.py create mode 100644 test/test_skills_manager.py diff --git a/agentskills/frontend-design/SKILL.md b/agentskills/frontend-design/SKILL.md index 2ec2a42..c9d6c1a 100644 --- a/agentskills/frontend-design/SKILL.md +++ b/agentskills/frontend-design/SKILL.md @@ -72,6 +72,7 @@ license: Complete terms in LICENSE.txt - 如果信息很多,优先使用摘要、分组、折叠、分页或“查看详情”结构,而不是把所有内容无差别堆进卡片。 - 卡片可以有明确审美与细节,例如精致边框、轻量纹理、局部 SVG、状态色、微动效,但必须服务于信息表达。 - 对于报告、检测、审核、论文、AIGC 率、风险分析等场景,卡片应优先体现可信、清晰、可读和结论明确,而不是浮夸科技感。 +- 卡片可以包含精美优雅的动画交互,但不要包含任何元素的初始动画或出场动画效果 **重要**:让实现复杂度与审美愿景相匹配。极繁主义设计需要更复杂的代码、大量动画与效果。极简或精致设计则需要克制、精准,以及对间距、字体排印和细微细节的谨慎关注。优雅来自于对愿景的高质量执行。 diff --git a/config/paths.py b/config/paths.py index 17f7c68..01ac8f8 100644 --- a/config/paths.py +++ b/config/paths.py @@ -26,7 +26,7 @@ PROMPTS_DIR = _resolve_repo_path(os.environ.get("PROMPTS_DIR", ""), "./prompts") DATA_DIR = _resolve_repo_path(os.environ.get("DATA_DIR", ""), "./data") LOGS_DIR = _resolve_repo_path(os.environ.get("LOGS_DIR", ""), "./logs") AGENT_SKILLS_DIR = _resolve_repo_path(os.environ.get("AGENT_SKILLS_DIR", ""), "./agentskills") -WORKSPACE_SKILLS_DIRNAME = "skills" +WORKSPACE_SKILLS_DIRNAME = ".agents/skills" # 多用户空间 USER_SPACE_DIR = _resolve_repo_path(os.environ.get("USER_SPACE_DIR", ""), "./users") diff --git a/core/main_terminal.py b/core/main_terminal.py index f88c78d..1bedb4b 100644 --- a/core/main_terminal.py +++ b/core/main_terminal.py @@ -62,6 +62,7 @@ except ImportError: from core.tool_config import TOOL_CATEGORIES from utils.api_client import DeepSeekClient from utils.context_manager import ContextManager +from utils.conversation_manager import ConversationManager from utils.host_workspace_debug import write_host_workspace_debug from config.model_profiles import get_model_profile @@ -448,6 +449,24 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi current_context_resolved = str( getattr(getattr(self, "context_manager", None), "project_path", "") ) + try: + current_conversation_scope = str( + Path( + getattr( + getattr(getattr(self, "context_manager", None), "conversation_manager", None), + "current_project_path", + "", + ) + ).expanduser().resolve() + ) + except Exception: + current_conversation_scope = str( + getattr( + getattr(getattr(self, "context_manager", None), "conversation_manager", None), + "current_project_path", + "", + ) + ) write_host_workspace_debug( "terminal.update_project_path.begin", @@ -456,7 +475,11 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi target_project_path=resolved_path, ) - if resolved_path == current_resolved and current_context_resolved == resolved_path: + if ( + resolved_path == current_resolved + and current_context_resolved == resolved_path + and current_conversation_scope == resolved_path + ): write_host_workspace_debug( "terminal.update_project_path.skip_same_path", terminal_id=id(self), @@ -481,6 +504,13 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi self.context_manager.initial_project_path = new_path self.context_manager.project_snapshot = None self.context_manager._host_runtime_cache = None + self.context_manager.conversation_manager = ConversationManager( + base_dir=str(getattr(self.context_manager, "data_dir", DATA_DIR)), + project_path=str(new_path), + ) + self.context_manager.current_conversation_id = None + self.context_manager.conversation_history = [] + self.context_manager.todo_list = None metadata = getattr(self.context_manager, "conversation_metadata", None) if isinstance(metadata, dict): metadata["project_path"] = str(new_path) @@ -495,6 +525,7 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi metadata.pop("project_file_tree", None) metadata.pop("project_statistics", None) metadata.pop("project_snapshot_at", None) + self._ensure_conversation() except Exception: pass diff --git a/core/main_terminal_parts/context.py b/core/main_terminal_parts/context.py index c57e1c0..527c05e 100644 --- a/core/main_terminal_parts/context.py +++ b/core/main_terminal_parts/context.py @@ -54,12 +54,16 @@ from modules.easter_egg_manager import EasterEggManager from modules.personalization_manager import ( load_personalization_config, build_personalization_prompt, + RECENT_CONVERSATIONS_PROMPT_LIMIT_MIN, + RECENT_CONVERSATIONS_PROMPT_LIMIT_MAX, + RECENT_CONVERSATIONS_PROMPT_LIMIT_DEFAULT, ) from modules.skills_manager import ( get_skills_catalog, build_skills_list, merge_enabled_skills, build_skills_prompt, + infer_private_skills_dir, ) from modules.custom_tool_registry import CustomToolRegistry, build_default_tool_category from modules.custom_tool_executor import CustomToolExecutor @@ -193,6 +197,9 @@ class MainTerminalContextMixin: ) def _get_or_init_frozen_mode_prompt(self, key: str, builder) -> Optional[str]: + return self._get_or_init_frozen_prompt(key, builder) + + def _get_or_init_frozen_prompt(self, key: str, builder) -> Optional[str]: cm = getattr(self, "context_manager", None) meta = getattr(cm, "conversation_metadata", {}) if cm else {} cached = meta.get(key) if isinstance(meta, dict) else None @@ -218,6 +225,40 @@ class MainTerminalContextMixin: # 构建上下文 return self.context_manager.build_main_context(memory) + def _build_recent_conversations_message(self, limit: int = 10) -> Optional[str]: + """构建最近对话提示(仅当前工作区)。""" + try: + manager = getattr(self.context_manager, "conversation_manager", None) + if not manager: + return None + current_id = getattr(self.context_manager, "current_conversation_id", None) + items = manager.get_recent_conversation_summaries( + limit=limit, + exclude_conversation_id=current_id, + first_message_max_chars=100, + ) + if not items: + return None + lines: List[str] = [] + for index, item in enumerate(items, start=1): + title = str(item.get("title") or "未命名对话").strip() + conv_id = str(item.get("id") or "").strip() + first_message = str(item.get("first_user_message") or "").strip() or "(无首条用户消息)" + updated_at = str(item.get("updated_at") or "").strip() + lines.append(f"{index}. [{conv_id}] 标题:{title}") + lines.append(f" 首条用户消息:{first_message}") + if updated_at: + lines.append(f" 更新时间:{updated_at}") + recent_text = "\n".join(lines) + template = self.load_prompt("recent_conversations").strip() + if template and "{recent_conversations}" in template: + return template.format(recent_conversations=recent_text) + if template: + return f"{template}\n\n{recent_text}" + return f"## 最近对话(仅当前工作区)\n\n{recent_text}" + except Exception: + return None + def _tool_calls_followed_by_tools(self, conversation: List[Dict], start_idx: int, tool_calls: List[Dict]) -> bool: """判断指定助手消息的工具调用是否拥有后续的工具响应。""" if not tool_calls: @@ -287,7 +328,7 @@ class MainTerminalContextMixin: 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 - skills_catalog = get_skills_catalog() + skills_catalog = get_skills_catalog(private_dir=infer_private_skills_dir(self.data_dir)) enabled_skills = merge_enabled_skills( personalization_config.get("enabled_skills") if isinstance(personalization_config, dict) else None, skills_catalog, @@ -303,6 +344,32 @@ class MainTerminalContextMixin: if workspace_system: messages.append({"role": "system", "content": workspace_system}) + recent_conversations_enabled = ( + bool(personalization_config.get("recent_conversations_prompt_enabled", False)) + if isinstance(personalization_config, dict) + else False + ) + if recent_conversations_enabled: + try: + recent_limit = int( + personalization_config.get( + "recent_conversations_prompt_limit", + RECENT_CONVERSATIONS_PROMPT_LIMIT_DEFAULT, + ) + ) + except Exception: + recent_limit = RECENT_CONVERSATIONS_PROMPT_LIMIT_DEFAULT + recent_limit = max( + RECENT_CONVERSATIONS_PROMPT_LIMIT_MIN, + min(RECENT_CONVERSATIONS_PROMPT_LIMIT_MAX, recent_limit), + ) + recent_conversations_prompt = self._get_or_init_frozen_prompt( + "frozen_recent_conversations_prompt", + lambda: self._build_recent_conversations_message(limit=recent_limit), + ) + if recent_conversations_prompt: + messages.append({"role": "system", "content": recent_conversations_prompt}) + # 注入权限模式说明(根据当前模式动态生成) permission_mode_message = self._get_or_init_frozen_mode_prompt( "frozen_permission_prompt", diff --git a/core/main_terminal_parts/tools_definition.py b/core/main_terminal_parts/tools_definition.py index 32cc5b7..5867691 100644 --- a/core/main_terminal_parts/tools_definition.py +++ b/core/main_terminal_parts/tools_definition.py @@ -436,6 +436,23 @@ class MainTerminalToolsDefinitionMixin: } } }, + { + "type": "function", + "function": { + "name": "create_skill", + "description": "验证并归档一个已创建好的 skill 文件夹。当你觉得本次工作的流程、经验或踩坑值得沉淀为 skill,便于以后面对类似问题时高效工作,或用户主动要求“把今天/本次经历转化为 skill”时,应先阅读 skill-creator,按规范创建对应 skill 文件夹与 SKILL.md,再调用本工具验证并归档。source_dir 可为当前工作区相对路径、工作区内绝对路径,宿主机模式也支持电脑上其它位置的绝对路径。验证通过后移动到正式 skills 库;不覆盖已存在 skill。", + "parameters": { + "type": "object", + "properties": self._inject_intent({ + "source_dir": { + "type": "string", + "description": "要归档的 skill 文件夹路径;skill 名称使用该文件夹名称。" + } + }), + "required": ["source_dir"] + } + } + }, { "type": "function", "function": { @@ -780,7 +797,7 @@ class MainTerminalToolsDefinitionMixin: "type": "function", "function": { "name": "update_memory", - "description": "按条目更新记忆列表(自动编号)。append 追加新条目;replace 用序号替换;delete 用序号删除。", + "description": "按条目更新记忆列表(自动编号)。append 追加新条目;replace 用序号替换;delete 用序号删除。应积极记录长期偏好、项目事实、已验证经验、用户明确要求记住的内容;不要记录密钥、隐私或未确认猜测。", "parameters": { "type": "object", "properties": self._inject_intent({ @@ -792,6 +809,48 @@ class MainTerminalToolsDefinitionMixin: } } }, + { + "type": "function", + "function": { + "name": "conversation_search", + "description": "在当前工作区的历史对话中搜索或列出对话,返回对话标题与 id。最多提供 3 个关键词,命中任一关键词即可;如果不提供关键词,则按时间列出最近的历史对话。可提供日期范围和最大返回数量。不能跨工作区搜索,也不会搜索当前对话。", + "parameters": { + "type": "object", + "properties": self._inject_intent({ + "keywords": { + "type": "array", + "description": "搜索关键词列表,最多3个;仅搜索标题和首条用户消息。可为空。", + "items": {"type": "string"}, + "maxItems": 3 + }, + "query": {"type": "string", "description": "兼容字段:单个搜索关键词,可为空;优先使用 keywords"}, + "start_date": {"type": "string", "description": "开始日期 YYYY-MM-DD,可选"}, + "end_date": {"type": "string", "description": "结束日期 YYYY-MM-DD,可选"}, + "limit": {"type": "integer", "description": "最大返回数量,默认10,最大100"} + }), + "required": [] + } + } + }, + { + "type": "function", + "function": { + "name": "conversation_review", + "description": "按 id 回顾当前工作区内的历史对话。mode=read 时直接返回回顾内容;若内容超过 50000 字符,将自动保存到 .agents/review/ 并提示分段或查找阅读。mode=save 时保存 Markdown 文件到 .agents/review/ 并返回路径。若 id 不属于当前工作区,将返回不存在或不属于当前工作区。", + "parameters": { + "type": "object", + "properties": self._inject_intent({ + "conversation_id": {"type": "string", "description": "要回顾的对话 id,例如 conv_20250924_210942_114"}, + "mode": { + "type": "string", + "enum": ["read", "save"], + "description": "必要参数。read=直接返回回顾内容;save=保存为 .agents/review/ 下的 Markdown 文件并返回路径。" + } + }), + "required": ["conversation_id", "mode"] + } + } + }, { "type": "function", "function": { diff --git a/core/main_terminal_parts/tools_execution.py b/core/main_terminal_parts/tools_execution.py index 2b2b03a..51b5dd1 100644 --- a/core/main_terminal_parts/tools_execution.py +++ b/core/main_terminal_parts/tools_execution.py @@ -23,6 +23,7 @@ try: READONLY_RUN_COMMAND_ALLOWED, READONLY_RUN_COMMAND_ALLOWED_GIT_SUBCOMMANDS, READONLY_RUN_COMMAND_BLOCKED_TOKENS, + AGENT_SKILLS_DIR, ) except ImportError: import sys @@ -46,6 +47,7 @@ except ImportError: READONLY_RUN_COMMAND_ALLOWED, READONLY_RUN_COMMAND_ALLOWED_GIT_SUBCOMMANDS, READONLY_RUN_COMMAND_BLOCKED_TOKENS, + AGENT_SKILLS_DIR, ) from modules.file_manager import FileManager @@ -72,6 +74,9 @@ from modules.skills_manager import ( build_skills_list, merge_enabled_skills, build_skills_prompt, + archive_skill_directory, + sync_workspace_skills, + infer_private_skills_dir, ) from modules.custom_tool_registry import CustomToolRegistry, build_default_tool_category from modules.custom_tool_executor import CustomToolExecutor @@ -178,6 +183,8 @@ class MainTerminalToolsExecutionMixin: "vlm_analyze", "ocr_image", "update_memory", + "conversation_search", + "conversation_review", "todo_create", "todo_update_task", "todo_get", @@ -195,6 +202,7 @@ class MainTerminalToolsExecutionMixin: "rename_file", "save_webpage", "terminal_session", + "create_skill", } # 扩展的只读命令白名单(包含常用管道命令) @@ -466,6 +474,69 @@ class MainTerminalToolsExecutionMixin: except Exception: return raw.lstrip("./").lower() + def _resolve_create_skill_source_dir(self, source_dir: Any) -> Optional[Path]: + raw = str(source_dir or "").strip() + if not raw: + return None + project_root = Path(self.context_manager.project_path).expanduser().resolve() + if raw == "/workspace": + return project_root + if raw.startswith("/workspace/"): + return (project_root / raw.split("/workspace/", 1)[1]).resolve() + candidate = Path(raw).expanduser() + if candidate.is_absolute(): + return candidate.resolve() + return (project_root / candidate).resolve() + + def _get_create_skill_target_root(self) -> Path: + if self._is_host_mode(): + return Path(AGENT_SKILLS_DIR).expanduser().resolve() + + data_dir = Path(self.data_dir).expanduser().resolve() + # 普通 Docker 用户: users//data -> users//agentskills + # API 工作区用户: users_api//workspaces//data -> users_api//agentskills + if data_dir.name == "data" and data_dir.parent.parent.name == "workspaces": + user_root = data_dir.parent.parent.parent + else: + user_root = data_dir.parent + return (user_root / "agentskills").resolve() + + def _handle_create_skill_tool(self, arguments: Dict[str, Any]) -> Dict[str, Any]: + source = self._resolve_create_skill_source_dir(arguments.get("source_dir")) + if source is None: + return {"success": False, "error": "source_dir 不能为空"} + + target_root = self._get_create_skill_target_root() + result = archive_skill_directory(source, target_root) + + if result.get("success"): + try: + config = load_personalization_config(self.data_dir) + catalog = get_skills_catalog(private_dir=infer_private_skills_dir(self.data_dir)) + enabled = merge_enabled_skills( + config.get("enabled_skills") if isinstance(config, dict) else None, + catalog, + config.get("skills_catalog_snapshot") if isinstance(config, dict) else None, + ) + sync_result = sync_workspace_skills( + self.project_path, + enabled, + private_dir=infer_private_skills_dir(self.data_dir), + ) + if not sync_result.get("success"): + result["sync_warning"] = "同步当前工作区 skills 失败" + except Exception as exc: + result["sync_warning"] = "同步当前工作区 skills 失败" + + if result.get("success"): + result["summary"] = ( + f"已归档 skill:{result.get('skill_name')}" + ) + # 成功时不向模型/前端暴露宿主机或 Docker 内部路径。 + result.pop("source_dir", None) + result.pop("target_dir", None) + return result + def _mark_skill_read_from_result(self, tool_name: str, arguments: Dict[str, Any], result: Dict[str, Any]) -> None: if tool_name not in {"read_file", "read_skill"}: return @@ -581,7 +652,7 @@ class MainTerminalToolsExecutionMixin: return skill_id in set(enabled_skill_ids) try: config = load_personalization_config(self.data_dir) - catalog = get_skills_catalog() + catalog = get_skills_catalog(private_dir=infer_private_skills_dir(self.data_dir)) enabled = merge_enabled_skills( config.get("enabled_skills") if isinstance(config, dict) else None, catalog, @@ -809,6 +880,8 @@ class MainTerminalToolsExecutionMixin: result = self._handle_read_skill_tool(arguments) self._mark_skill_read_from_result(tool_name, arguments, result) self._mark_file_read_from_result(tool_name, arguments, result) + elif tool_name == "create_skill": + result = self._handle_create_skill_tool(arguments) elif tool_name in {"vlm_analyze", "ocr_image"}: path = arguments.get("path") prompt = arguments.get("prompt") @@ -1427,6 +1500,17 @@ class MainTerminalToolsExecutionMixin: operation = arguments["operation"] content = arguments.get("content") index = arguments.get("index") + enriched_content = content + if operation in {"append", "replace"} and content and str(content).strip(): + current_conversation_id = getattr( + getattr(self, "context_manager", None), + "current_conversation_id", + None, + ) or "unknown" + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M") + text = str(content).strip() + if not text.startswith("["): + enriched_content = f"[{timestamp}][{current_conversation_id}] {text}" # 参数校验 if operation == "append" and (not content or not str(content).strip()): @@ -1440,10 +1524,116 @@ class MainTerminalToolsExecutionMixin: result = self.memory_manager.update_entries( memory_type="main", operation=operation, - content=content, + content=enriched_content, index=index ) + elif tool_name == "conversation_search": + query = str(arguments.get("query") or "").strip() + raw_keywords = arguments.get("keywords") + if isinstance(raw_keywords, list): + keywords = [ + str(item).strip() + for item in raw_keywords + if str(item or "").strip() + ][:3] + else: + keywords = [] + start_date = str(arguments.get("start_date") or "").strip() or None + end_date = str(arguments.get("end_date") or "").strip() or None + try: + limit = int(arguments.get("limit") or 10) + except Exception: + limit = 10 + limit = max(1, min(limit, 100)) + manager = getattr(self.context_manager, "conversation_manager", None) + current_conversation_id = getattr(self.context_manager, "current_conversation_id", None) + if not current_conversation_id and manager: + current_conversation_id = getattr(manager, "current_conversation_id", None) + items = manager.search_conversation_summaries( + query=query, + keywords=keywords, + start_date=start_date, + end_date=end_date, + limit=limit, + first_message_max_chars=100, + exclude_conversation_id=current_conversation_id, + ) if manager else [] + result = { + "success": True, + "query": query, + "keywords": keywords, + "start_date": start_date, + "end_date": end_date, + "limit": limit, + "excluded_conversation_id": current_conversation_id, + "results": items, + "count": len(items), + "summary": f"找到 {len(items)} 个当前工作区内的历史对话", + } + + elif tool_name == "conversation_review": + conversation_id = str(arguments.get("conversation_id") or "").strip() + review_mode = str(arguments.get("mode") or "").strip().lower() + if not conversation_id: + result = {"success": False, "error": "conversation_id 不能为空"} + elif review_mode not in {"read", "save"}: + result = {"success": False, "error": "mode 必须为 read 或 save", "conversation_id": conversation_id} + else: + manager = getattr(self.context_manager, "conversation_manager", None) + conversation_data = manager.load_conversation(conversation_id) if manager else None + if not conversation_data: + result = { + "success": False, + "error": "对话不存在或不属于当前工作区", + "conversation_id": conversation_id, + } + else: + from server.utils_common import build_review_lines, _sanitize_filename_component + + messages = conversation_data.get("messages", []) + content = "\n".join(build_review_lines(messages)) + "\n" + title = conversation_data.get("title") or "untitled" + char_count = len(content) + + def save_review_file() -> str: + safe_title = _sanitize_filename_component(title) + timestamp = datetime.now().strftime("%Y%m%d%H%M%S") + review_dir = Path(self.project_path) / ".agents" / "review" + review_dir.mkdir(parents=True, exist_ok=True) + filename = f"review_{safe_title}_{timestamp}.md" + target = review_dir / filename + target.write_text(content, encoding="utf-8") + return f".agents/review/{filename}" + + if review_mode == "read" and char_count <= 50000: + result = { + "success": True, + "mode": "read", + "conversation_id": conversation_id, + "title": title, + "content": content, + "char_count": char_count, + "summary": f"已直接返回对话回顾内容({char_count} 字符)", + } + else: + rel_path = save_review_file() + too_long = review_mode == "read" and char_count > 50000 + result = { + "success": True, + "mode": review_mode, + "conversation_id": conversation_id, + "title": title, + "path": rel_path, + "char_count": char_count, + "too_long": too_long, + "summary": ( + f"对话回顾内容太长({char_count} 字符),已保存到文件: {rel_path},请分段或查找阅读。" + if too_long + else f"已生成对话回顾文件: {rel_path}" + ), + } + elif tool_name == "todo_create": result = self.todo_manager.create_todo_list( overview=arguments.get("overview", ""), diff --git a/core/main_terminal_parts/tools_policy.py b/core/main_terminal_parts/tools_policy.py index 6640bfe..b0652df 100644 --- a/core/main_terminal_parts/tools_policy.py +++ b/core/main_terminal_parts/tools_policy.py @@ -60,6 +60,7 @@ from modules.skills_manager import ( build_skills_list, merge_enabled_skills, build_skills_prompt, + infer_private_skills_dir, ) from modules.custom_tool_registry import CustomToolRegistry, build_default_tool_category from modules.custom_tool_executor import CustomToolExecutor @@ -136,7 +137,7 @@ class MainTerminalToolsPolicyMixin: # 解析当前启用的 skills(用于强约束“仅对已启用 skill 生效”) try: - skills_catalog = get_skills_catalog() + skills_catalog = get_skills_catalog(private_dir=infer_private_skills_dir(self.data_dir)) enabled_skills = merge_enabled_skills( effective_config.get("enabled_skills") if isinstance(effective_config, dict) else None, skills_catalog, diff --git a/core/main_terminal_parts/tools_read.py b/core/main_terminal_parts/tools_read.py index c92d798..8d4d506 100644 --- a/core/main_terminal_parts/tools_read.py +++ b/core/main_terminal_parts/tools_read.py @@ -60,6 +60,7 @@ from modules.skills_manager import ( build_skills_list, merge_enabled_skills, build_skills_prompt, + infer_private_skills_dir, ) from modules.custom_tool_registry import CustomToolRegistry, build_default_tool_category from modules.custom_tool_executor import CustomToolExecutor @@ -106,7 +107,7 @@ class MainTerminalToolsReadMixin: personalization = load_personalization_config(self.data_dir) except Exception: personalization = {} - catalog = get_skills_catalog() + catalog = get_skills_catalog(private_dir=infer_private_skills_dir(self.data_dir)) enabled_skills = merge_enabled_skills( personalization.get("enabled_skills") if isinstance(personalization, dict) else None, catalog, diff --git a/core/tool_config.py b/core/tool_config.py index 27313f2..cadefab 100644 --- a/core/tool_config.py +++ b/core/tool_config.py @@ -45,13 +45,16 @@ TOOL_CATEGORIES: Dict[str, ToolCategory] = { "read_focus": ToolCategory( label="阅读聚焦", tools=[ - "read_skill", "read_file", "vlm_analyze", "ocr_image", "view_image", ], ), + "skills": ToolCategory( + label="Skills", + tools=["read_skill", "create_skill"], + ), "terminal_realtime": ToolCategory( label="实时终端", tools=[ @@ -67,7 +70,7 @@ TOOL_CATEGORIES: Dict[str, ToolCategory] = { ), "memory": ToolCategory( label="记忆", - tools=["update_memory"], + tools=["update_memory", "conversation_search", "conversation_review"], ), "todo": ToolCategory( label="待办事项", diff --git a/modules/api_user_manager.py b/modules/api_user_manager.py index 3cd1b31..60306bb 100644 --- a/modules/api_user_manager.py +++ b/modules/api_user_manager.py @@ -46,7 +46,7 @@ class ApiUserWorkspace: project_path: Path data_dir: Path # 会话/备份等落盘到这里(每个工作区独立) logs_dir: Path - uploads_dir: Path # project/user_upload + uploads_dir: Path # project/.agents/user_upload quarantine_dir: Path # 上传隔离区(按用户/工作区划分) shared_dir: Path # 用户级共享目录(prompts/personalization) prompts_dir: Path # 实际使用的 prompts 目录(指向 shared_dir/prompt) @@ -103,7 +103,7 @@ class ApiUserManager: personalization/ workspaces// # 单个工作区 project/ - user_upload/ + .agents/user_upload/ data/ conversations/ backups/ @@ -123,8 +123,8 @@ class ApiUserManager: project_path = work_root / "project" data_dir = work_root / "data" logs_dir = work_root / "logs" - uploads_dir = project_path / "user_upload" - skills_dir = project_path / "skills" + uploads_dir = project_path / ".agents" / "user_upload" + skills_dir = project_path / ".agents" / "skills" for path in (project_path, data_dir, logs_dir, uploads_dir, skills_dir, shared_dir, prompts_dir, personalization_dir): path.mkdir(parents=True, exist_ok=True) diff --git a/modules/personalization_manager.py b/modules/personalization_manager.py index 49e6e35..cc58b56 100644 --- a/modules/personalization_manager.py +++ b/modules/personalization_manager.py @@ -26,6 +26,9 @@ MAX_CONSIDERATION_ITEMS = 10 TONE_PRESETS = ["健谈", "幽默", "直言不讳", "鼓励性", "诗意", "企业商务", "打破常规", "同理心"] THINKING_INTERVAL_MIN = 1 THINKING_INTERVAL_MAX = 50 +RECENT_CONVERSATIONS_PROMPT_LIMIT_MIN = 1 +RECENT_CONVERSATIONS_PROMPT_LIMIT_MAX = 30 +RECENT_CONVERSATIONS_PROMPT_LIMIT_DEFAULT = 10 DEFAULT_SHALLOW_COMPRESS_TRIGGER_TOKENS = 80_000 DEFAULT_SHALLOW_COMPRESS_KEEP_RECENT_TOOLS = 15 DEFAULT_SHALLOW_COMPRESS_MAX_REPLACE_PER_ROUND = 10 @@ -59,6 +62,8 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = { "default_run_mode": None, "default_permission_mode": "unrestricted", "auto_generate_title": True, + "recent_conversations_prompt_enabled": False, + "recent_conversations_prompt_limit": RECENT_CONVERSATIONS_PROMPT_LIMIT_DEFAULT, "tool_intent_enabled": True, "skill_hints_enabled": False, # Skill 提示系统开关(默认关闭) "skill_strict_terminal_enabled": False, # 强约束:terminal 系列工具需先阅读 terminal-guide @@ -88,6 +93,9 @@ __all__ = [ "DEFAULT_PERSONALIZATION_CONFIG", "TONE_PRESETS", "MAX_CONSIDERATION_ITEMS", + "RECENT_CONVERSATIONS_PROMPT_LIMIT_MIN", + "RECENT_CONVERSATIONS_PROMPT_LIMIT_MAX", + "RECENT_CONVERSATIONS_PROMPT_LIMIT_DEFAULT", "load_personalization_config", "save_personalization_config", "ensure_personalization_config", @@ -169,6 +177,17 @@ def sanitize_personalization_payload( _comm_style = data.get("communication_style", base.get("communication_style", "default")) base["communication_style"] = "human_like" if _comm_style == "human_like" else "default" base["auto_generate_title"] = bool(data.get("auto_generate_title", base["auto_generate_title"])) + base["recent_conversations_prompt_enabled"] = bool( + data.get("recent_conversations_prompt_enabled", base.get("recent_conversations_prompt_enabled", False)) + ) + base["recent_conversations_prompt_limit"] = ( + _sanitize_optional_int( + data.get("recent_conversations_prompt_limit", base.get("recent_conversations_prompt_limit")), + min_value=RECENT_CONVERSATIONS_PROMPT_LIMIT_MIN, + max_value=RECENT_CONVERSATIONS_PROMPT_LIMIT_MAX, + ) + or RECENT_CONVERSATIONS_PROMPT_LIMIT_DEFAULT + ) base["self_identify"] = _resolve_short_field("self_identify") base["user_name"] = _resolve_short_field("user_name") base["profession"] = _resolve_short_field("profession") diff --git a/modules/skills_manager.py b/modules/skills_manager.py index 891a3b0..c975b92 100644 --- a/modules/skills_manager.py +++ b/modules/skills_manager.py @@ -47,6 +47,94 @@ def ensure_workspace_skills_dir(project_path: str | Path) -> Path: return skills_dir +def infer_private_skills_dir(data_dir: str | Path | None) -> Optional[Path]: + """Infer per-user private skills dir from workspace data_dir.""" + if not data_dir: + return None + try: + data_path = Path(data_dir).expanduser().resolve() + if data_path.name == "data" and data_path.parent.parent.name == "workspaces": + return (data_path.parent.parent.parent / "agentskills").resolve() + return (data_path.parent / "agentskills").resolve() + except Exception: + return None + + +def validate_skill_directory(source_dir: str | Path) -> Dict[str, object]: + """Validate a skill folder with minimal required SKILL.md frontmatter.""" + source = Path(source_dir).expanduser().resolve() + if not source.exists() or not source.is_dir(): + return {"success": False, "error": "source_dir 不是目录", "source_dir": str(source)} + + skill_id = source.name + if not _is_valid_skill_id(skill_id): + return {"success": False, "error": "skill 文件夹名称不合法", "skill_name": skill_id} + + skill_file = source / SKILL_FILE_NAME + if not skill_file.exists() or not skill_file.is_file(): + return {"success": False, "error": "缺少 SKILL.md", "skill_name": skill_id} + + try: + content = skill_file.read_text(encoding="utf-8") + except Exception as exc: + return {"success": False, "error": f"读取 SKILL.md 失败: {exc}", "skill_name": skill_id} + + if not re.search(r"(?m)^\s*name\s*:", content): + return {"success": False, "error": "缺少 name:", "skill_name": skill_id} + if not re.search(r"(?m)^\s*description\s*:", content): + return {"success": False, "error": "缺少 description:", "skill_name": skill_id} + + return { + "success": True, + "skill_name": skill_id, + "source_dir": str(source), + "skill_file": str(skill_file), + } + + +def archive_skill_directory(source_dir: str | Path, target_root: str | Path) -> Dict[str, object]: + """Move a validated skill folder into a skills library root.""" + validation = validate_skill_directory(source_dir) + if not validation.get("success"): + return validation + + source = Path(str(validation["source_dir"])).expanduser().resolve() + target_base = Path(target_root).expanduser().resolve() + target_base.mkdir(parents=True, exist_ok=True) + target = (target_base / source.name).resolve() + + try: + target.relative_to(target_base) + except ValueError: + return {"success": False, "error": "目标路径不合法", "skill_name": source.name} + + if target.exists(): + return { + "success": False, + "error": "目标 skill 已存在", + "skill_name": source.name, + "target_dir": str(target), + } + + try: + shutil.move(str(source), str(target)) + except Exception as exc: + return { + "success": False, + "error": f"移动 skill 失败: {exc}", + "skill_name": source.name, + "source_dir": str(source), + "target_dir": str(target), + } + + return { + "success": True, + "skill_name": source.name, + "source_dir": str(source), + "target_dir": str(target), + } + + def _parse_frontmatter(text: str) -> Dict[str, str]: """Parse simple YAML frontmatter / 解析简单 YAML 头信息。""" lines = text.splitlines() @@ -76,9 +164,8 @@ def _is_valid_skill_id(name: str) -> bool: return bool(name and SKILL_ID_PATTERN.match(name)) -def get_skills_catalog(base_dir: Optional[str] = None) -> List[Dict[str, str]]: - """List available skills from global library / 扫描全局技能库。""" - root = Path(base_dir or AGENT_SKILLS_DIR).expanduser().resolve() +def _scan_skills_catalog(root: Path) -> List[Dict[str, str]]: + """Scan one skills root.""" if not root.exists() or not root.is_dir(): return [] catalog: List[Dict[str, str]] = [] @@ -105,6 +192,52 @@ def get_skills_catalog(base_dir: Optional[str] = None) -> List[Dict[str, str]]: return catalog +def get_skills_catalog( + base_dir: Optional[str] = None, + private_dir: Optional[str | Path] = None, +) -> List[Dict[str, str]]: + """List available skills from global library plus optional private library.""" + roots: List[Path] = [Path(base_dir or AGENT_SKILLS_DIR).expanduser().resolve()] + if private_dir: + private_root = Path(private_dir).expanduser().resolve() + if private_root not in roots: + roots.append(private_root) + + merged: Dict[str, Dict[str, str]] = {} + order: List[str] = [] + for root in roots: + for item in _scan_skills_catalog(root): + skill_id = item.get("id") + if not skill_id: + continue + if skill_id not in merged: + order.append(skill_id) + # Later roots (private) override metadata for same id. + merged[skill_id] = item + return [merged[skill_id] for skill_id in order if skill_id in merged] + + +def _get_skill_source_map( + base_dir: Optional[str] = None, + private_dir: Optional[str | Path] = None, +) -> Dict[str, Path]: + roots: List[Path] = [Path(base_dir or AGENT_SKILLS_DIR).expanduser().resolve()] + if private_dir: + private_root = Path(private_dir).expanduser().resolve() + if private_root not in roots: + roots.append(private_root) + + sources: Dict[str, Path] = {} + for root in roots: + if not root.exists() or not root.is_dir(): + continue + for item in _scan_skills_catalog(root): + skill_id = item.get("id") + if skill_id: + sources[skill_id] = root / skill_id + return sources + + def resolve_enabled_skills( enabled_skills: Optional[Sequence[str]], catalog: Sequence[Dict[str, str]], @@ -190,6 +323,7 @@ def sync_workspace_skills( project_path: str | Path, enabled_skills: Optional[Sequence[str]] = None, base_dir: Optional[str] = None, + private_dir: Optional[str | Path] = None, ) -> Dict[str, object]: """Sync global skills into workspace / 将全局 skills 同步到工作区。""" root = Path(project_path).expanduser().resolve() @@ -200,8 +334,9 @@ def sync_workspace_skills( return {"success": False, "error": "skills 目录不在项目路径内"} ensure_agent_skills_dir(base_dir) - catalog = get_skills_catalog(base_dir) + catalog = get_skills_catalog(base_dir, private_dir=private_dir) resolved = resolve_enabled_skills(enabled_skills, catalog) + sources = _get_skill_source_map(base_dir, private_dir=private_dir) lock = _get_sync_lock(skills_dir) try: @@ -209,10 +344,9 @@ def sync_workspace_skills( if skills_dir.exists(): shutil.rmtree(skills_dir, ignore_errors=True) skills_dir.mkdir(parents=True, exist_ok=True) - global_root = Path(base_dir or AGENT_SKILLS_DIR).expanduser().resolve() for skill_id in resolved: - src = global_root / skill_id - if not src.exists() or not src.is_dir(): + src = sources.get(skill_id) + if src is None or not src.exists() or not src.is_dir(): continue dst = skills_dir / skill_id if dst.exists() and not dst.is_dir(): diff --git a/modules/user_manager.py b/modules/user_manager.py index b7e04c7..d6ad6f2 100644 --- a/modules/user_manager.py +++ b/modules/user_manager.py @@ -125,8 +125,8 @@ class UserManager: project_path = root / "project" data_dir = root / "data" logs_dir = root / "logs" - uploads_dir = project_path / "user_upload" - skills_dir = project_path / "skills" + uploads_dir = project_path / ".agents" / "user_upload" + skills_dir = project_path / ".agents" / "skills" for path in [project_path, data_dir, logs_dir, uploads_dir, skills_dir]: path.mkdir(parents=True, exist_ok=True) diff --git a/modules/versioning_manager.py b/modules/versioning_manager.py index e2040ab..03d5f75 100644 --- a/modules/versioning_manager.py +++ b/modules/versioning_manager.py @@ -29,7 +29,7 @@ class ConversationVersioningManager: """Manage hidden git snapshots bound to a conversation.""" TRACKING_MODE_WORKSPACE_AND_CONVERSATION = "workspace_and_conversation" TRACKING_MODE_CONVERSATION_ONLY = "conversation_only" - SYSTEM_AUTO_DIRS = ("compact_result", "skills", "user_upload") + SYSTEM_AUTO_DIRS = (".agents/compact_result", ".agents/skills", ".agents/user_upload") def __init__(self, project_path: Path | str, data_dir: Path | str, conversation_id: str): self.project_path = Path(project_path).expanduser().resolve() diff --git a/prompts/recent_conversations.txt b/prompts/recent_conversations.txt new file mode 100644 index 0000000..c24726b --- /dev/null +++ b/prompts/recent_conversations.txt @@ -0,0 +1,10 @@ +## 最近对话(仅当前工作区) + +以下是当前工作区最近的历史对话摘要,供你判断是否需要使用 `conversation_search` 或 `conversation_review` 主动回顾历史上下文。 + +{recent_conversations} + +注意: +- 这里只展示标题和首条用户消息,不包含完整内容。 +- 如需查看完整上下文,请使用 `conversation_review` 生成回顾文件后再读取。 +- 不要假设其他工作区的对话可用;历史对话工具只允许访问当前工作区。 diff --git a/server/app_legacy.py b/server/app_legacy.py index 7f849ff..f98a7ec 100644 --- a/server/app_legacy.py +++ b/server/app_legacy.py @@ -774,7 +774,7 @@ DEBUG_LOG_FILE = Path(LOGS_DIR).expanduser().resolve() / "debug_stream.log" CHUNK_BACKEND_LOG_FILE = Path(LOGS_DIR).expanduser().resolve() / "chunk_backend.log" CHUNK_FRONTEND_LOG_FILE = Path(LOGS_DIR).expanduser().resolve() / "chunk_frontend.log" STREAMING_DEBUG_LOG_FILE = Path(LOGS_DIR).expanduser().resolve() / "streaming_debug.log" -UPLOAD_FOLDER_NAME = "user_upload" +UPLOAD_FOLDER_NAME = ".agents/user_upload" def is_logged_in() -> bool: diff --git a/server/auth.py b/server/auth.py index 31c37bc..1712007 100644 --- a/server/auth.py +++ b/server/auth.py @@ -218,7 +218,7 @@ def host_login(): data_dir.mkdir(parents=True, exist_ok=True) logs_dir = Path(LOGS_DIR).expanduser().resolve() logs_dir.mkdir(parents=True, exist_ok=True) - uploads_dir = host_path / "user_upload" + uploads_dir = host_path / ".agents" / "user_upload" uploads_dir.mkdir(parents=True, exist_ok=True) quarantine_root = Path(UPLOAD_QUARANTINE_SUBDIR).expanduser() if not quarantine_root.is_absolute(): diff --git a/server/chat.py b/server/chat.py index 0d0256c..0f0f5a6 100644 --- a/server/chat.py +++ b/server/chat.py @@ -19,9 +19,12 @@ from modules.personalization_manager import ( save_personalization_config, THINKING_INTERVAL_MIN, THINKING_INTERVAL_MAX, + RECENT_CONVERSATIONS_PROMPT_LIMIT_MIN, + RECENT_CONVERSATIONS_PROMPT_LIMIT_MAX, ) from modules.skills_manager import ( get_skills_catalog, + infer_private_skills_dir, merge_enabled_skills, sync_workspace_skills, ) @@ -41,7 +44,7 @@ from .extensions import socketio from .monitor import get_cached_monitor_snapshot from .files import sanitize_filename_preserve_unicode -UPLOAD_FOLDER_NAME = "user_upload" +UPLOAD_FOLDER_NAME = ".agents/user_upload" chat_bp = Blueprint('chat', __name__) @@ -213,7 +216,8 @@ def get_personalization_settings(terminal: WebTerminal, workspace: UserWorkspace if policy.get("ui_blocks", {}).get("block_personal_space"): return jsonify({"success": False, "error": "个人空间已被管理员禁用"}), 403 data = load_personalization_config(workspace.data_dir) - skills_catalog = get_skills_catalog() + private_skills_dir = infer_private_skills_dir(workspace.data_dir) + skills_catalog = get_skills_catalog(private_dir=private_skills_dir) enabled_skills = merge_enabled_skills( data.get("enabled_skills"), skills_catalog, @@ -242,6 +246,10 @@ def get_personalization_settings(terminal: WebTerminal, workspace: UserWorkspace "thinking_interval_range": { "min": THINKING_INTERVAL_MIN, "max": THINKING_INTERVAL_MAX + }, + "recent_conversations_prompt_limit_range": { + "min": RECENT_CONVERSATIONS_PROMPT_LIMIT_MIN, + "max": RECENT_CONVERSATIONS_PROMPT_LIMIT_MAX, } }) except Exception as exc: @@ -260,7 +268,8 @@ def update_personalization_settings(terminal: WebTerminal, workspace: UserWorksp if policy.get("ui_blocks", {}).get("block_personal_space"): return jsonify({"success": False, "error": "个人空间已被管理员禁用"}), 403 config = save_personalization_config(workspace.data_dir, payload) - skills_catalog = get_skills_catalog() + private_skills_dir = infer_private_skills_dir(workspace.data_dir) + skills_catalog = get_skills_catalog(private_dir=private_skills_dir) enabled_skills = merge_enabled_skills( config.get("enabled_skills"), skills_catalog, @@ -274,7 +283,7 @@ def update_personalization_settings(terminal: WebTerminal, workspace: UserWorksp config["skills_catalog_snapshot"] = catalog_snapshot config = save_personalization_config(workspace.data_dir, config) try: - sync_workspace_skills(workspace.project_path, enabled_skills) + sync_workspace_skills(workspace.project_path, enabled_skills, private_dir=private_skills_dir) except Exception as sync_exc: debug_log(f"[Skills] 同步失败: {sync_exc}") try: @@ -324,6 +333,10 @@ def update_personalization_settings(terminal: WebTerminal, workspace: UserWorksp "thinking_interval_range": { "min": THINKING_INTERVAL_MIN, "max": THINKING_INTERVAL_MAX + }, + "recent_conversations_prompt_limit_range": { + "min": RECENT_CONVERSATIONS_PROMPT_LIMIT_MIN, + "max": RECENT_CONVERSATIONS_PROMPT_LIMIT_MAX, } }) except ValueError as exc: diff --git a/server/context.py b/server/context.py index 5c28262..1c12720 100644 --- a/server/context.py +++ b/server/context.py @@ -8,7 +8,7 @@ from core.web_terminal import WebTerminal from modules.gui_file_manager import GuiFileManager from modules.upload_security import UploadQuarantineManager, UploadSecurityError from modules.personalization_manager import load_personalization_config -from modules.skills_manager import sync_workspace_skills +from modules.skills_manager import infer_private_skills_dir, sync_workspace_skills from modules.host_workspace_manager import resolve_host_workspace from utils.host_workspace_debug import write_host_workspace_debug import json @@ -66,7 +66,11 @@ def _ensure_workspace_skills_synced(terminal: WebTerminal, workspace) -> None: try: config = load_personalization_config(workspace.data_dir) enabled_skills = config.get("enabled_skills") if isinstance(config, dict) else None - result = sync_workspace_skills(workspace.project_path, enabled_skills) + result = sync_workspace_skills( + workspace.project_path, + enabled_skills, + private_dir=infer_private_skills_dir(workspace.data_dir), + ) if not result.get("success"): debug_log(f"[Skills] 工作区同步失败: {result.get('error')}") return @@ -87,11 +91,24 @@ def get_user_resources(username: Optional[str] = None, workspace_id: Optional[st sandbox_is_host = (TERMINAL_SANDBOX_MODE or "host").lower() == "host" if host_mode_session and sandbox_is_host: selected_workspace_id = session.get("host_workspace_id") if has_request_context() else None + with state.HOST_ACTIVE_WORKSPACE_LOCK: + active_workspace_id = state.HOST_ACTIVE_WORKSPACE_ID + active_workspace_path = state.HOST_ACTIVE_WORKSPACE_PATH + active_workspace_version = state.HOST_ACTIVE_WORKSPACE_VERSION + if active_workspace_id: + selected_workspace_id = active_workspace_id _, host_workspace = resolve_host_workspace(selected_workspace_id) + if active_workspace_id and active_workspace_path: + host_workspace = dict(host_workspace) + host_workspace["workspace_id"] = active_workspace_id + host_workspace["path"] = active_workspace_path project_path = Path(host_workspace.get("path") or "").expanduser().resolve() write_host_workspace_debug( "context.get_user_resources.host.selected_workspace", selected_workspace_id=selected_workspace_id, + active_workspace_id=active_workspace_id, + active_workspace_path=active_workspace_path, + active_workspace_version=active_workspace_version, resolved_workspace_id=host_workspace.get("workspace_id"), project_path=str(project_path), username=username, @@ -101,9 +118,9 @@ def get_user_resources(username: Optional[str] = None, workspace_id: Optional[st data_dir.mkdir(parents=True, exist_ok=True) logs_dir = Path(LOGS_DIR).expanduser().resolve() logs_dir.mkdir(parents=True, exist_ok=True) - uploads_dir = project_path / "user_upload" + uploads_dir = project_path / ".agents" / "user_upload" uploads_dir.mkdir(parents=True, exist_ok=True) - skills_dir = project_path / "skills" + skills_dir = project_path / ".agents" / "skills" skills_dir.mkdir(parents=True, exist_ok=True) quarantine_root = Path(UPLOAD_QUARANTINE_SUBDIR).expanduser() if not quarantine_root.is_absolute(): diff --git a/server/conversation.py b/server/conversation.py index b8bd74f..18399d5 100644 --- a/server/conversation.py +++ b/server/conversation.py @@ -1613,21 +1613,21 @@ def review_conversation(conversation_id, terminal: WebTerminal, workspace: UserW content = "\n".join(lines) + "\n" char_count = len(content) - uploads_dir = workspace.uploads_dir / "review" - uploads_dir.mkdir(parents=True, exist_ok=True) + review_dir = workspace.project_path / ".agents" / "review" + review_dir.mkdir(parents=True, exist_ok=True) title = conversation_data.get("title") or "untitled" safe_title = _sanitize_filename_component(title) timestamp = datetime.now().strftime("%Y%m%d%H%M%S") filename = f"review_{safe_title}_{timestamp}.md" - target = uploads_dir / filename + target = review_dir / filename target.write_text(content, encoding='utf-8') return jsonify({ "success": True, "data": { - "path": f"user_upload/review/{filename}", + "path": f".agents/review/{filename}", "char_count": char_count } }) diff --git a/server/conversation_stats.py b/server/conversation_stats.py index eaf8285..d7be6b8 100644 --- a/server/conversation_stats.py +++ b/server/conversation_stats.py @@ -136,7 +136,7 @@ def _read_token_totals_file(workspace: UserWorkspace) -> Dict[str, int]: def _collect_conversation_token_totals(workspace: UserWorkspace) -> Dict[str, int]: try: - manager = ConversationManager(base_dir=workspace.data_dir) + manager = ConversationManager(base_dir=workspace.data_dir, project_path=str(workspace.project_path)) stats = manager.get_statistics() or {} token_stats = stats.get("token_statistics") or {} input_tokens = int(token_stats.get("total_input_tokens") or 0) diff --git a/server/deep_compression.py b/server/deep_compression.py index 141517b..1134c89 100644 --- a/server/deep_compression.py +++ b/server/deep_compression.py @@ -189,7 +189,7 @@ def _write_compact_file( last_tools: List[Dict[str, Any]], latest_user_input: str, ) -> str: - compact_dir = project_path / "compact_result" + compact_dir = project_path / ".agents" / "compact_result" compact_dir.mkdir(parents=True, exist_ok=True) filename = f"compact_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{compression_index:03d}.md" file_path = compact_dir / filename diff --git a/server/state.py b/server/state.py index 647374c..85872cf 100644 --- a/server/state.py +++ b/server/state.py @@ -27,6 +27,10 @@ container_manager = UserContainerManager() user_terminals: Dict[str, WebTerminal] = {} terminal_rooms: Dict[str, set] = {} connection_users: Dict[str, str] = {} +HOST_ACTIVE_WORKSPACE_LOCK = threading.RLock() +HOST_ACTIVE_WORKSPACE_ID: Optional[str] = None +HOST_ACTIVE_WORKSPACE_PATH: Optional[str] = None +HOST_ACTIVE_WORKSPACE_VERSION: int = 0 RECENT_UPLOAD_EVENT_LIMIT = 150 RECENT_UPLOAD_FEED_LIMIT = 60 stop_flags: Dict[str, Dict[str, Any]] = {} @@ -86,6 +90,10 @@ __all__ = [ "user_terminals", "terminal_rooms", "connection_users", + "HOST_ACTIVE_WORKSPACE_LOCK", + "HOST_ACTIVE_WORKSPACE_ID", + "HOST_ACTIVE_WORKSPACE_PATH", + "HOST_ACTIVE_WORKSPACE_VERSION", "stop_flags", "MONITOR_FILE_TOOLS", "MONITOR_MEMORY_TOOLS", diff --git a/server/status.py b/server/status.py index 85f4ea6..03adee4 100644 --- a/server/status.py +++ b/server/status.py @@ -249,6 +249,10 @@ def select_host_workspace(): ) if current_id == workspace_id: + with state.HOST_ACTIVE_WORKSPACE_LOCK: + state.HOST_ACTIVE_WORKSPACE_ID = workspace_id + state.HOST_ACTIVE_WORKSPACE_PATH = target_path + state.HOST_ACTIVE_WORKSPACE_VERSION += 1 session['workspace_id'] = workspace_id try: container_handle = state.container_manager.ensure_container( @@ -299,6 +303,10 @@ def select_host_workspace(): pass previous_workspace_id = session.get("workspace_id") + with state.HOST_ACTIVE_WORKSPACE_LOCK: + state.HOST_ACTIVE_WORKSPACE_ID = workspace_id + state.HOST_ACTIVE_WORKSPACE_PATH = target_path + state.HOST_ACTIVE_WORKSPACE_VERSION += 1 session['host_workspace_id'] = workspace_id session['workspace_id'] = workspace_id diff --git a/static/src/components/chat/actions/ToolAction.vue b/static/src/components/chat/actions/ToolAction.vue index 11d641d..129b6fe 100644 --- a/static/src/components/chat/actions/ToolAction.vue +++ b/static/src/components/chat/actions/ToolAction.vue @@ -129,9 +129,11 @@ function renderEnhancedToolResult(): string { return renderRenameFile(result, args); } - // 阅读聚焦类 + // 阅读聚焦 / Skills 类 else if (name === 'read_file' || name === 'read_skill') { return renderReadFile(result, args); + } else if (name === 'create_skill') { + return renderCreateSkill(result, args); } else if (name === 'vlm_analyze') { return renderVlmAnalyze(result, args); } else if (name === 'ocr_image') { @@ -161,6 +163,10 @@ function renderEnhancedToolResult(): string { // 记忆类 else if (name === 'update_memory') { return renderUpdateMemory(result, args); + } else if (name === 'conversation_search') { + return renderConversationSearch(result, args); + } else if (name === 'conversation_review') { + return renderConversationReview(result, args); } // 个性化管理类 @@ -724,6 +730,90 @@ function renderUpdateMemory(result: any, args: any): string { return html; } +function renderConversationSearch(result: any, args: any): string { + const results = Array.isArray(result?.results) ? result.results : []; + const keywords = Array.isArray(result?.keywords) + ? result.keywords + : (Array.isArray(args?.keywords) ? args.keywords : []); + const keywordText = keywords.length + ? keywords.join(' / ') + : (result?.query || args?.query || '(未指定)'); + let html = '
'; + html += `
关键词:${escapeHtml(keywordText)}
`; + html += `
日期范围:${escapeHtml(result?.start_date || args?.start_date || '不限')} ~ ${escapeHtml(result?.end_date || args?.end_date || '不限')}
`; + html += '
范围:当前工作区历史对话(已排除当前对话)
'; + html += `
结果数量:${results.length}
`; + html += '
'; + if (!results.length) { + html += '
未找到匹配对话。
'; + return html; + } + html += '
'; + results.forEach((item: any) => { + html += '
'; + html += `
${escapeHtml(item.title || '未命名对话')}
`; + html += `
ID:${escapeHtml(item.id || '')}
`; + html += `
规模:${escapeHtml(String(item.total_messages || 0))} 条消息,${escapeHtml(String(item.total_tools || 0))} 个工具
`; + if (item.first_user_message) { + html += `
首条用户消息:${escapeHtml(item.first_user_message)}
`; + } + if (item.updated_at) { + html += `
更新时间:${escapeHtml(item.updated_at)}
`; + } + html += '
'; + }); + html += '
'; + return html; +} + +function renderConversationReview(result: any, args: any): string { + const status = formatToolStatusLabel(result, result?.mode === 'read' ? '✓ 已返回' : '✓ 已生成'); + let html = '
'; + html += `
对话 ID:${escapeHtml(result?.conversation_id || args?.conversation_id || '')}
`; + html += `
状态:${status}
`; + html += `
模式:${escapeHtml(result?.mode || args?.mode || '')}
`; + if (result?.title) { + html += `
标题:${escapeHtml(result.title)}
`; + } + if (result?.path) { + html += `
回顾文件:${escapeHtml(result.path)}
`; + } + if (result?.char_count !== undefined) { + html += `
字符数:${escapeHtml(String(result.char_count))}
`; + } + html += '
'; + if (!result?.success && result?.error) { + html += `
${escapeHtml(result.error)}
`; + } + if (result?.too_long && result?.path) { + html += '
内容太长,已保存到文件,请分段或查找阅读。
'; + } + if (result?.content) { + html += '
'; + html += ''; + html += `
${escapeHtml(result.content)}
`; + html += '
'; + } + return html; +} + +function renderCreateSkill(result: any, args: any): string { + const status = formatToolStatusLabel(result, '✓ 已归档'); + let html = '
'; + html += `
状态:${status}
`; + if (result?.skill_name) { + html += `
Skill:${escapeHtml(result.skill_name)}
`; + } + html += '
'; + if (!result?.success && result?.error) { + html += `
${escapeHtml(result.error)}
`; + } + if (result?.sync_warning) { + html += `
${escapeHtml(result.sync_warning)}
`; + } + return html; +} + function formatPersonalizationFieldLabel(field: string): string { const labelMap: Record = { enabled: '个性化功能', diff --git a/static/src/components/chat/actions/toolRenderers.ts b/static/src/components/chat/actions/toolRenderers.ts index b39b26e..20eb2e2 100644 --- a/static/src/components/chat/actions/toolRenderers.ts +++ b/static/src/components/chat/actions/toolRenderers.ts @@ -65,9 +65,11 @@ export function renderEnhancedToolResult( return renderRenameFile(result, args); } - // 阅读聚焦类 + // 阅读聚焦 / Skills 类 else if (name === 'read_file' || name === 'read_skill') { return renderReadFile(result, args); + } else if (name === 'create_skill') { + return renderCreateSkill(result, args); } else if (name === 'vlm_analyze') { return renderVlmAnalyze(result, args); } else if (name === 'ocr_image') { @@ -97,6 +99,10 @@ export function renderEnhancedToolResult( // 记忆类 else if (name === 'update_memory') { return renderUpdateMemory(result, args); + } else if (name === 'conversation_search') { + return renderConversationSearch(result, args); + } else if (name === 'conversation_review') { + return renderConversationReview(result, args); } // 个性化管理类 else if (name === 'manage_personalization') { @@ -647,6 +653,90 @@ function renderUpdateMemory(result: any, args: any): string { return html; } +function renderConversationSearch(result: any, args: any): string { + const results = Array.isArray(result?.results) ? result.results : []; + const keywords = Array.isArray(result?.keywords) + ? result.keywords + : (Array.isArray(args?.keywords) ? args.keywords : []); + const keywordText = keywords.length + ? keywords.join(' / ') + : (result?.query || args?.query || '(未指定)'); + let html = '
'; + html += `
关键词:${escapeHtml(keywordText)}
`; + html += `
日期范围:${escapeHtml(result?.start_date || args?.start_date || '不限')} ~ ${escapeHtml(result?.end_date || args?.end_date || '不限')}
`; + html += '
范围:当前工作区历史对话(已排除当前对话)
'; + html += `
结果数量:${results.length}
`; + html += '
'; + if (!results.length) { + html += '
未找到匹配对话。
'; + return html; + } + html += '
'; + results.forEach((item: any) => { + html += '
'; + html += `
${escapeHtml(item.title || '未命名对话')}
`; + html += `
ID:${escapeHtml(item.id || '')}
`; + html += `
规模:${escapeHtml(String(item.total_messages || 0))} 条消息,${escapeHtml(String(item.total_tools || 0))} 个工具
`; + if (item.first_user_message) { + html += `
首条用户消息:${escapeHtml(item.first_user_message)}
`; + } + if (item.updated_at) { + html += `
更新时间:${escapeHtml(item.updated_at)}
`; + } + html += '
'; + }); + html += '
'; + return html; +} + +function renderConversationReview(result: any, args: any): string { + const status = formatToolStatusLabel(result, result?.mode === 'read' ? '✓ 已返回' : '✓ 已生成'); + let html = '
'; + html += `
对话 ID:${escapeHtml(result?.conversation_id || args?.conversation_id || '')}
`; + html += `
状态:${status}
`; + html += `
模式:${escapeHtml(result?.mode || args?.mode || '')}
`; + if (result?.title) { + html += `
标题:${escapeHtml(result.title)}
`; + } + if (result?.path) { + html += `
回顾文件:${escapeHtml(result.path)}
`; + } + if (result?.char_count !== undefined) { + html += `
字符数:${escapeHtml(String(result.char_count))}
`; + } + html += '
'; + if (!result?.success && result?.error) { + html += `
${escapeHtml(result.error)}
`; + } + if (result?.too_long && result?.path) { + html += '
内容太长,已保存到文件,请分段或查找阅读。
'; + } + if (result?.content) { + html += '
'; + html += ''; + html += `
${escapeHtml(result.content)}
`; + html += '
'; + } + return html; +} + +function renderCreateSkill(result: any, args: any): string { + const status = formatToolStatusLabel(result, '✓ 已归档'); + let html = '
'; + html += `
状态:${status}
`; + if (result?.skill_name) { + html += `
Skill:${escapeHtml(result.skill_name)}
`; + } + html += '
'; + if (!result?.success && result?.error) { + html += `
${escapeHtml(result.error)}
`; + } + if (result?.sync_warning) { + html += `
${escapeHtml(result.sync_warning)}
`; + } + return html; +} + function formatPersonalizationFieldLabel(field: string): string { const labelMap: Record = { self_identify: 'AI 自称', diff --git a/static/src/components/personalization/PersonalizationDrawer.vue b/static/src/components/personalization/PersonalizationDrawer.vue index 7587254..ca4cf93 100644 --- a/static/src/components/personalization/PersonalizationDrawer.vue +++ b/static/src/components/personalization/PersonalizationDrawer.vue @@ -723,6 +723,74 @@ 自动注入 AGENTS.md 到系统提示词 +
+
+ 最近对话提示 +

+ 开启后,系统会把当前工作区最近若干个非空对话的标题和首条用户消息注入到系统提示词中,帮助智能体主动判断是否需要回顾历史。 +

+
+ +
+ +

+ 范围 {{ recentConversationsPromptLimitRange.min }}~{{ + recentConversationsPromptLimitRange.max + }} + 个;空对话不会注入。 +

+
+
+ +
+
允许根目录创建文件 @@ -1528,6 +1596,7 @@ const { skillsCatalog, thinkingIntervalDefault, thinkingIntervalRange, + recentConversationsPromptLimitRange, experiments } = storeToRefs(personalization); @@ -1985,6 +2054,31 @@ const restoreThinkingInterval = () => { personalization.setThinkingInterval(null); }; +const handleRecentConversationsPromptLimitInput = (event: Event) => { + const target = event.target as HTMLInputElement | null; + if (!target) { + return; + } + personalization.updateField({ + key: 'recent_conversations_prompt_limit', + value: target.value + }); +}; + +const commitRecentConversationsPromptLimitInput = (event: Event) => { + const target = event.target as HTMLInputElement | null; + if (!target || !target.value) { + personalization.setRecentConversationsPromptLimit(null); + return; + } + const parsed = Number(target.value); + personalization.setRecentConversationsPromptLimit(Number.isNaN(parsed) ? null : parsed); +}; + +const restoreRecentConversationsPromptLimit = () => { + personalization.setRecentConversationsPromptLimit(null); +}; + const handleCompressionNumberInput = (key: CompressionField, event: Event) => { const target = event.target as HTMLInputElement | null; if (!target || !target.value) { diff --git a/static/src/stores/personalization.ts b/static/src/stores/personalization.ts index 15f8546..05e46bd 100644 --- a/static/src/stores/personalization.ts +++ b/static/src/stores/personalization.ts @@ -9,6 +9,8 @@ interface PersonalForm { enabled: boolean; communication_style: 'default' | 'human_like'; auto_generate_title: boolean; + recent_conversations_prompt_enabled: boolean; + recent_conversations_prompt_limit: number | string; tool_intent_enabled: boolean; skill_hints_enabled: boolean; skill_strict_terminal_enabled: boolean; @@ -67,11 +69,14 @@ interface PersonalizationState { skillsCatalog: Array<{ id: string; label: string; description?: string }>; thinkingIntervalDefault: number; thinkingIntervalRange: { min: number; max: number }; + recentConversationsPromptLimitRange: { min: number; max: number }; experiments: ExperimentState; } const DEFAULT_INTERVAL = 10; const DEFAULT_INTERVAL_RANGE = { min: 1, max: 50 }; +const DEFAULT_RECENT_CONVERSATIONS_PROMPT_LIMIT = 10; +const DEFAULT_RECENT_CONVERSATIONS_PROMPT_LIMIT_RANGE = { min: 1, max: 30 }; const DEFAULT_SHALLOW_COMPRESS_TRIGGER_TOKENS = 80000; const DEFAULT_DEEP_COMPRESS_TRIGGER_TOKENS = 150000; const RUN_MODE_OPTIONS: RunMode[] = ['fast', 'thinking', 'deep']; @@ -90,6 +95,8 @@ const defaultForm = (): PersonalForm => ({ enabled: false, communication_style: 'default', auto_generate_title: true, + recent_conversations_prompt_enabled: false, + recent_conversations_prompt_limit: DEFAULT_RECENT_CONVERSATIONS_PROMPT_LIMIT, tool_intent_enabled: true, skill_hints_enabled: false, skill_strict_terminal_enabled: false, @@ -181,6 +188,7 @@ export const usePersonalizationStore = defineStore('personalization', { skillsCatalog: [], thinkingIntervalDefault: DEFAULT_INTERVAL, thinkingIntervalRange: { ...DEFAULT_INTERVAL_RANGE }, + recentConversationsPromptLimitRange: { ...DEFAULT_RECENT_CONVERSATIONS_PROMPT_LIMIT_RANGE }, experiments: loadExperimentState() }), actions: { @@ -244,6 +252,9 @@ export const usePersonalizationStore = defineStore('personalization', { enabled: !!data.enabled, communication_style: data.communication_style === 'human_like' ? 'human_like' : 'default', auto_generate_title: data.auto_generate_title !== false, + recent_conversations_prompt_enabled: !!data.recent_conversations_prompt_enabled, + recent_conversations_prompt_limit: + this.normalizeRecentConversationsPromptLimit(data.recent_conversations_prompt_limit), tool_intent_enabled: !!data.tool_intent_enabled, skill_hints_enabled: !!data.skill_hints_enabled, skill_strict_terminal_enabled: !!data.skill_strict_terminal_enabled, @@ -343,6 +354,19 @@ export const usePersonalizationStore = defineStore('personalization', { } return rounded; }, + normalizeRecentConversationsPromptLimit(value: any): number { + const parsed = Number(value); + const min = + this.recentConversationsPromptLimitRange.min ?? + DEFAULT_RECENT_CONVERSATIONS_PROMPT_LIMIT_RANGE.min; + const max = + this.recentConversationsPromptLimitRange.max ?? + DEFAULT_RECENT_CONVERSATIONS_PROMPT_LIMIT_RANGE.max; + if (!Number.isFinite(parsed)) { + return DEFAULT_RECENT_CONVERSATIONS_PROMPT_LIMIT; + } + return Math.max(min, Math.min(max, Math.round(parsed))); + }, applyPersonalizationMeta(payload: any) { if (payload && typeof payload.thinking_interval_default === 'number') { this.thinkingIntervalDefault = payload.thinking_interval_default; @@ -358,6 +382,17 @@ export const usePersonalizationStore = defineStore('personalization', { } else { this.thinkingIntervalRange = { ...DEFAULT_INTERVAL_RANGE }; } + if (payload && payload.recent_conversations_prompt_limit_range) { + const { min, max } = payload.recent_conversations_prompt_limit_range; + this.recentConversationsPromptLimitRange = { + min: typeof min === 'number' ? min : DEFAULT_RECENT_CONVERSATIONS_PROMPT_LIMIT_RANGE.min, + max: typeof max === 'number' ? max : DEFAULT_RECENT_CONVERSATIONS_PROMPT_LIMIT_RANGE.max + }; + } else { + this.recentConversationsPromptLimitRange = { + ...DEFAULT_RECENT_CONVERSATIONS_PROMPT_LIMIT_RANGE + }; + } if (payload && Array.isArray(payload.tool_categories)) { this.toolCategories = payload.tool_categories .map((item: { id?: string; label?: string } = {}) => ({ @@ -497,6 +532,17 @@ export const usePersonalizationStore = defineStore('personalization', { }; this.clearFeedback(); }, + setRecentConversationsPromptLimit(value: number | null) { + const target = + value === null || typeof value === 'undefined' + ? DEFAULT_RECENT_CONVERSATIONS_PROMPT_LIMIT + : this.normalizeRecentConversationsPromptLimit(value); + this.form = { + ...this.form, + recent_conversations_prompt_limit: target + }; + this.clearFeedback(); + }, toggleDefaultToolCategory(categoryId: string) { if (!categoryId) { return; diff --git a/static/src/utils/chatDisplay.ts b/static/src/utils/chatDisplay.ts index ee38bef..cd09b41 100644 --- a/static/src/utils/chatDisplay.ts +++ b/static/src/utils/chatDisplay.ts @@ -6,6 +6,7 @@ const RUNNING_ANIMATIONS: Record = { create_file: 'file-animation', read_file: 'read-animation', read_skill: 'read-animation', + create_skill: 'file-animation', delete_file: 'file-animation', rename_file: 'file-animation', write_file: 'file-animation', @@ -44,7 +45,8 @@ const RUNNING_STATUS_TEXTS: Record = { terminal_session: '正在管理终端会话...', terminal_input: '调用 terminal_input', terminal_snapshot: '正在获取终端快照...', - read_skill: '正在读取技能...' + read_skill: '正在读取技能...', + create_skill: '正在归档技能...' }; const COMPLETED_STATUS_TEXTS: Record = { @@ -65,7 +67,8 @@ const COMPLETED_STATUS_TEXTS: Record = { terminal_session: '终端操作完成', terminal_input: '终端输入完成', terminal_snapshot: '终端快照已返回', - read_skill: '技能读取完成' + read_skill: '技能读取完成', + create_skill: '技能归档完成' }; const LANGUAGE_CLASS_MAP: Record = { diff --git a/static/src/utils/icons.ts b/static/src/utils/icons.ts index a05a2b3..ca0d628 100644 --- a/static/src/utils/icons.ts +++ b/static/src/utils/icons.ts @@ -47,6 +47,7 @@ export const ICONS = Object.freeze({ export const TOOL_ICON_MAP = Object.freeze({ close_sub_agent: 'bot', create_file: 'file', + create_skill: 'sparkles', manage_personalization: 'userPen', create_folder: 'folder', create_sub_agent: 'bot', @@ -70,6 +71,8 @@ export const TOOL_ICON_MAP = Object.freeze({ terminal_snapshot: 'clipboard', unfocus_file: 'eye', update_memory: 'brain', + conversation_search: 'search', + conversation_review: 'book', get_sub_agent_status: 'bot', web_search: 'search', trigger_easter_egg: 'sparkles', @@ -84,6 +87,7 @@ export const TOOL_CATEGORY_ICON_MAP = Object.freeze({ file_edit: 'pencil', personalization: 'userPen', read_focus: 'eye', + skills: 'sparkles', terminal_realtime: 'monitor', terminal_command: 'terminal', memory: 'brain', diff --git a/test/test_conversation_workspace_storage.py b/test/test_conversation_workspace_storage.py new file mode 100644 index 0000000..ed173f5 --- /dev/null +++ b/test/test_conversation_workspace_storage.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +import json +import tempfile +import time +import unittest +from pathlib import Path +from unittest.mock import patch + +from utils.conversation_manager import ConversationManager + + +def _write_conversation(path: Path, conv_id: str, project_path: str) -> None: + payload = { + "id": conv_id, + "title": f"title {conv_id}", + "created_at": "2026-05-26T10:00:00", + "updated_at": "2026-05-26T10:00:00", + "messages": [{"role": "user", "content": "hello"}], + "todo_list": None, + "metadata": { + "project_path": project_path, + "project_relative_path": None, + "thinking_mode": False, + "run_mode": "fast", + "model_key": None, + "has_images": False, + "has_videos": False, + "total_messages": 1, + "total_tools": 0, + "status": "active", + }, + "token_statistics": { + "total_input_tokens": 0, + "total_output_tokens": 0, + "total_tokens": 0, + "current_context_tokens": 0, + "updated_at": "2026-05-26T10:00:00", + }, + } + path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") + + +class ConversationWorkspaceStorageTest(unittest.TestCase): + def test_legacy_conversations_are_migrated_and_scoped_by_workspace(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + data_dir = root / "data" + conversations_dir = data_dir / "conversations" + conversations_dir.mkdir(parents=True) + ws1_path = root / "workspace1" + ws1_child = ws1_path / "child" + ws2_path = root / "workspace2" + other_path = root / "outside" + ws1_child.mkdir(parents=True) + ws2_path.mkdir(parents=True) + other_path.mkdir(parents=True) + + host_workspaces = root / "host_workspaces.json" + host_workspaces.write_text( + json.dumps( + { + "workspaces": [ + {"workspace_id": "workspace1", "label": "W1", "path": str(ws1_path)}, + {"workspace_id": "workspace2", "label": "W2", "path": str(ws2_path)}, + ] + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + + _write_conversation(conversations_dir / "conv_ws1.json", "conv_ws1", str(ws1_child)) + _write_conversation(conversations_dir / "conv_ws2.json", "conv_ws2", str(ws2_path)) + _write_conversation(conversations_dir / "conv_unmatched.json", "conv_unmatched", str(other_path)) + + with patch("utils.conversation_manager.HOST_WORKSPACES_FILE", str(host_workspaces)): + manager_ws1 = ConversationManager(base_dir=str(data_dir), project_path=str(ws1_path)) + + self.assertTrue((conversations_dir / "workspace1" / "conv_ws1.json").exists()) + self.assertTrue((conversations_dir / "workspace2" / "conv_ws2.json").exists()) + self.assertTrue((conversations_dir / "conv_unmatched.json").exists()) + + listed_ids = {item["id"] for item in manager_ws1.get_conversation_list(limit=20)["conversations"]} + self.assertEqual(listed_ids, {"conv_ws1"}) + self.assertIsNotNone(manager_ws1.load_conversation("conv_ws1")) + self.assertIsNone(manager_ws1.load_conversation("conv_ws2")) + self.assertIsNone(manager_ws1.load_conversation("conv_unmatched")) + + created_id = manager_ws1.create_conversation(project_path=str(ws1_path), initial_messages=[]) + self.assertTrue((conversations_dir / "workspace1" / f"{created_id}.json").exists()) + self.assertFalse((conversations_dir / f"{created_id}.json").exists()) + + def test_search_accepts_three_keywords_and_excludes_current_conversation(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + data_dir = root / "data" + workspace = root / "workspace" + workspace.mkdir(parents=True) + manager = ConversationManager(base_dir=str(data_dir), project_path=str(workspace)) + + empty_id = manager.create_conversation(project_path=str(workspace), initial_messages=[]) + time.sleep(0.002) + current_id = manager.create_conversation( + project_path=str(workspace), + initial_messages=[{"role": "user", "content": "当前对话包含 alpha"}], + ) + time.sleep(0.002) + beta_id = manager.create_conversation( + project_path=str(workspace), + initial_messages=[{"role": "user", "content": "旧对话包含 beta"}], + ) + time.sleep(0.002) + gamma_id = manager.create_conversation( + project_path=str(workspace), + initial_messages=[{"role": "user", "content": "旧对话包含 gamma"}], + ) + time.sleep(0.002) + delta_id = manager.create_conversation( + project_path=str(workspace), + initial_messages=[{"role": "user", "content": "旧对话包含 delta"}], + ) + + results = manager.search_conversation_summaries( + keywords=["alpha", "beta", "gamma", "delta"], + exclude_conversation_id=current_id, + limit=10, + ) + result_ids = {item["id"] for item in results} + + self.assertNotIn(current_id, result_ids) + self.assertIn(beta_id, result_ids) + self.assertIn(gamma_id, result_ids) + self.assertNotIn(delta_id, result_ids) + self.assertNotIn(empty_id, result_ids) + beta_result = next(item for item in results if item["id"] == beta_id) + self.assertEqual(beta_result.get("total_messages"), 1) + self.assertEqual(beta_result.get("total_tools"), 0) + + listed = manager.search_conversation_summaries(limit=10) + listed_ids = {item["id"] for item in listed} + self.assertNotIn(empty_id, listed_ids) + self.assertIn(beta_id, listed_ids) + + recent = manager.get_recent_conversation_summaries(limit=10) + recent_ids = {item["id"] for item in recent} + self.assertNotIn(empty_id, recent_ids) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_skills_manager.py b/test/test_skills_manager.py new file mode 100644 index 0000000..237ae61 --- /dev/null +++ b/test/test_skills_manager.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from modules.skills_manager import ( + archive_skill_directory, + get_skills_catalog, + infer_private_skills_dir, + sync_workspace_skills, + validate_skill_directory, +) + + +class SkillsManagerTest(unittest.TestCase): + def test_validate_skill_directory_requires_skill_md_and_frontmatter_fields(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + skill_dir = root / "sample-skill" + skill_dir.mkdir() + + missing_file = validate_skill_directory(skill_dir) + self.assertFalse(missing_file.get("success")) + self.assertEqual(missing_file.get("error"), "缺少 SKILL.md") + + (skill_dir / "SKILL.md").write_text("---\nname: Sample\n---\n", encoding="utf-8") + missing_description = validate_skill_directory(skill_dir) + self.assertFalse(missing_description.get("success")) + self.assertEqual(missing_description.get("error"), "缺少 description:") + + (skill_dir / "SKILL.md").write_text( + "---\nname: Sample\ndescription: Demo skill\n---\n", + encoding="utf-8", + ) + valid = validate_skill_directory(skill_dir) + self.assertTrue(valid.get("success")) + self.assertEqual(valid.get("skill_name"), "sample-skill") + + def test_archive_skill_directory_moves_without_overwriting(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + skill_dir = root / "sample-skill" + target_root = root / "agentskills" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text( + "---\nname: Sample\ndescription: Demo skill\n---\n", + encoding="utf-8", + ) + + archived = archive_skill_directory(skill_dir, target_root) + self.assertTrue(archived.get("success")) + self.assertFalse(skill_dir.exists()) + self.assertTrue((target_root / "sample-skill" / "SKILL.md").exists()) + + duplicate_src = root / "sample-skill" + duplicate_src.mkdir() + (duplicate_src / "SKILL.md").write_text( + "---\nname: Sample\ndescription: Demo skill\n---\n", + encoding="utf-8", + ) + duplicate = archive_skill_directory(duplicate_src, target_root) + self.assertFalse(duplicate.get("success")) + self.assertEqual(duplicate.get("error"), "目标 skill 已存在") + + def test_private_skills_are_cataloged_and_synced(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + global_root = root / "global" + private_root = root / "users" / "jojo" / "agentskills" + data_dir = root / "users" / "jojo" / "data" + project = root / "project" + private_skill = private_root / "private-skill" + private_skill.mkdir(parents=True) + data_dir.mkdir(parents=True) + project.mkdir(parents=True) + (private_skill / "SKILL.md").write_text( + "---\nname: Private Skill\ndescription: Private demo\n---\n", + encoding="utf-8", + ) + + self.assertEqual(infer_private_skills_dir(data_dir), private_root.resolve()) + catalog = get_skills_catalog(base_dir=str(global_root), private_dir=private_root) + self.assertEqual([item["id"] for item in catalog], ["private-skill"]) + + synced = sync_workspace_skills( + project, + enabled_skills=["private-skill"], + base_dir=str(global_root), + private_dir=private_root, + ) + self.assertTrue(synced.get("success")) + self.assertTrue((project / ".agents" / "skills" / "private-skill" / "SKILL.md").exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/utils/context_manager.py b/utils/context_manager.py index 84c9c7e..e9c7b35 100644 --- a/utils/context_manager.py +++ b/utils/context_manager.py @@ -97,7 +97,7 @@ class ContextManager: self._shallow_compact_round: int = 0 # 新增:对话持久化管理器 - self.conversation_manager = ConversationManager(base_dir=self.data_dir) + self.conversation_manager = ConversationManager(base_dir=self.data_dir, project_path=str(self.project_path)) self.media_store = MediaStore(self.data_dir) self.current_conversation_id: Optional[str] = None self.auto_save_enabled = True @@ -749,12 +749,16 @@ class ContextManager: # 同步 skills(每次新对话覆盖镜像) try: from modules.personalization_manager import load_personalization_config - from modules.skills_manager import sync_workspace_skills + from modules.skills_manager import infer_private_skills_dir, sync_workspace_skills personalization_config = getattr(self, "custom_personalization_config", None) or load_personalization_config(self.data_dir) enabled_skills = None if isinstance(personalization_config, dict): enabled_skills = personalization_config.get("enabled_skills") - sync_workspace_skills(self.project_path, enabled_skills) + sync_workspace_skills( + self.project_path, + enabled_skills, + private_dir=infer_private_skills_dir(self.data_dir), + ) except Exception as exc: print(f"[Skills] 同步失败: {exc}") @@ -997,18 +1001,29 @@ class ContextManager: files.sort(key=lambda p: p.name.lower()) selected = (folders + files)[:max(0, limit)] - for entry in selected: + def add_entry(entry: Path, *, display_path: Optional[str] = None): relative_path = str(entry.relative_to(self.project_path)) + if display_path: + relative_path = display_path if entry.is_dir(): structure["folders"].append({ "name": entry.name, "path": relative_path }) - structure["tree"][entry.name] = { - "type": "folder", - "path": relative_path, - "children": {} - } + parts = relative_path.split("/") + parent = structure["tree"] + for idx, part in enumerate(parts): + is_leaf = idx == len(parts) - 1 + parent.setdefault(part, { + "type": "folder", + "path": "/".join(parts[:idx + 1]), + "children": {} + }) + if is_leaf: + parent[part]["type"] = "folder" + parent[part]["path"] = relative_path + parent[part].setdefault("children", {}) + parent = parent[part].setdefault("children", {}) else: try: size = entry.stat().st_size @@ -1029,6 +1044,16 @@ class ContextManager: "size": size, "annotation": file_info["annotation"] } + + for entry in selected: + add_entry(entry) + + # 根目录浅扫会跳过点目录;内部文件迁移到 .agents 后,单独展示这几个对模型有意义的目录。 + agents_dir = self.project_path / ".agents" + for internal_name in ("skills", "user_upload", "compact_result", "review"): + internal_dir = agents_dir / internal_name + if internal_dir.exists() and internal_dir.is_dir(): + add_entry(internal_dir, display_path=f".agents/{internal_name}") return structure def save_current_conversation(self) -> bool: diff --git a/utils/conversation_manager.py b/utils/conversation_manager.py index fa96df8..126dff0 100644 --- a/utils/conversation_manager.py +++ b/utils/conversation_manager.py @@ -10,14 +10,14 @@ from pathlib import Path from typing import Dict, List, Optional, Any from dataclasses import dataclass try: - from config import DATA_DIR + from config import DATA_DIR, HOST_WORKSPACES_FILE except ImportError: import sys from pathlib import Path project_root = Path(__file__).resolve().parents[1] if str(project_root) not in sys.path: sys.path.insert(0, str(project_root)) - from config import DATA_DIR + from config import DATA_DIR, HOST_WORKSPACES_FILE @dataclass class ConversationMetadata: @@ -40,26 +40,124 @@ class ConversationMetadata: class ConversationManager: """对话持久化管理器""" - def __init__(self, base_dir: Optional[str] = None): + def __init__(self, base_dir: Optional[str] = None, project_path: Optional[str] = None): self.base_dir = Path(base_dir).expanduser().resolve() if base_dir else Path(DATA_DIR).resolve() - self.conversations_dir = self.base_dir / "conversations" - self.index_file = self.conversations_dir / "index.json" + self.conversations_root = self.base_dir / "conversations" self._io_lock = threading.RLock() self.current_conversation_id: Optional[str] = None self.workspace_root = Path(__file__).resolve().parents[1] + self.host_workspaces = self._load_host_workspaces() + self.current_project_path = self._normalize_path(project_path) if project_path else None + self.current_workspace_id = self._resolve_workspace_id(self.current_project_path) + # 当前管理器只暴露“当前工作区”的对话目录;未匹配到工作区时保留根目录作为兜底写入位置。 + self.conversations_dir = self._conversation_dir_for_workspace(self.current_workspace_id) + self.index_file = self.conversations_dir / "index.json" self._ensure_directories() self._index_verified = False # 首次加载索引仅重建最近 20 条,降低启动开销;后续按需扩展 self._load_index(ensure_integrity=True, max_rebuild=20) - + + def _normalize_path(self, value: Optional[str]) -> Optional[Path]: + if not value: + return None + try: + return Path(str(value)).expanduser().resolve() + except Exception: + return None + + def _load_host_workspaces(self) -> List[Dict[str, str]]: + """读取宿主机工作区配置,用于把对话归入 workspace 子目录。""" + try: + path = Path(HOST_WORKSPACES_FILE).expanduser().resolve() + if not path.exists(): + return [] + raw = json.loads(path.read_text(encoding="utf-8")) + workspaces = raw.get("workspaces") if isinstance(raw, dict) else None + if not isinstance(workspaces, list): + return [] + result: List[Dict[str, str]] = [] + for item in workspaces: + if not isinstance(item, dict): + continue + workspace_id = str(item.get("workspace_id") or "").strip() + workspace_path = self._normalize_path(item.get("path")) + if workspace_id and workspace_path: + result.append({"workspace_id": workspace_id, "path": str(workspace_path)}) + return result + except Exception as exc: + print(f"⚠️ 读取宿主机工作区配置失败: {exc}") + return [] + + @staticmethod + def _path_is_relative_to(path: Path, parent: Path) -> bool: + try: + path.relative_to(parent) + return True + except ValueError: + return False + + def _resolve_workspace_id(self, project_path: Optional[Path]) -> Optional[str]: + """按 host_workspaces.json 用最长路径前缀匹配 workspace_id。""" + if not project_path: + return None + matches = [] + for item in self.host_workspaces: + workspace_path = self._normalize_path(item.get("path")) + if not workspace_path: + continue + if project_path == workspace_path or self._path_is_relative_to(project_path, workspace_path): + matches.append((len(str(workspace_path)), item.get("workspace_id"))) + if not matches: + # Docker/无工作区配置场景:只有一个工作区时使用 default;宿主机配置存在但不匹配时返回 None。 + return "default" if not self.host_workspaces else None + matches.sort(reverse=True) + return str(matches[0][1]) + + def _conversation_dir_for_workspace(self, workspace_id: Optional[str]) -> Path: + return self.conversations_root / workspace_id if workspace_id else self.conversations_root + def _ensure_directories(self): - """确保必要的目录存在""" + """确保必要的目录存在,并迁移可归类的旧版根目录对话。""" self.base_dir.mkdir(parents=True, exist_ok=True) + self.conversations_root.mkdir(parents=True, exist_ok=True) + self._migrate_legacy_conversation_files() self.conversations_dir.mkdir(parents=True, exist_ok=True) - - # 如果索引文件不存在,创建空索引 + + # 如果当前工作区索引文件不存在,创建空索引 if not self.index_file.exists(): self._save_index({}) + + def _migrate_legacy_conversation_files(self): + """把 data/conversations/conv_*.json 按 metadata.project_path 移入对应 workspace 子目录。""" + try: + legacy_files = [ + p for p in self.conversations_root.glob("conv_*.json") + if p.is_file() and p.parent == self.conversations_root + ] + except Exception: + return + + moved_count = 0 + for file_path in legacy_files: + try: + data = json.loads(file_path.read_text(encoding="utf-8")) + metadata = data.get("metadata", {}) if isinstance(data, dict) else {} + project_path = self._normalize_path(metadata.get("project_path")) + workspace_id = self._resolve_workspace_id(project_path) + if not workspace_id: + continue + target_dir = self._conversation_dir_for_workspace(workspace_id) + target_dir.mkdir(parents=True, exist_ok=True) + target = target_dir / file_path.name + if target.exists(): + print(f"⚠️ legacy 对话迁移跳过,目标已存在: {target}") + continue + file_path.replace(target) + moved_count += 1 + except Exception as exc: + print(f"⚠️ legacy 对话迁移失败 {file_path.name}: {exc}") + if moved_count: + print(f"🔄 已迁移 {moved_count} 个 legacy 对话到工作区目录") def _iter_conversation_files(self, sort_by_mtime: bool = True): """遍历对话文件(排除索引文件),可按修改时间降序排序。""" @@ -268,6 +366,38 @@ class ConversationManager: title += "..." return title return "新对话" + + def _extract_text_content(self, content: Any) -> str: + """从字符串/多模态列表/字典中提取纯文本。""" + if isinstance(content, str): + return content + if isinstance(content, list): + parts: List[str] = [] + for item in content: + if isinstance(item, str): + parts.append(item) + elif isinstance(item, dict): + if item.get("type") == "text": + parts.append(str(item.get("text") or "")) + elif isinstance(item.get("text"), str): + parts.append(item.get("text") or "") + return "".join(parts) + if isinstance(content, dict): + return str(content.get("text") or "") + return "" + + def _extract_first_user_message(self, messages: List[Dict], max_chars: int = 100) -> str: + """提取首条用户消息纯文本,用于最近对话/搜索展示。""" + for msg in messages or []: + if msg.get("role") != "user": + continue + text = " ".join(self._extract_text_content(msg.get("content")).split()) + if not text: + continue + if len(text) > max_chars: + return text[:max_chars] + "..." + return text + return "" def _count_tools_in_messages(self, messages: List[Dict]) -> int: """统计消息中的工具调用数量(仅统计 assistant.tool_calls)。""" @@ -913,6 +1043,107 @@ class ConversationManager: "has_more": False } + def get_recent_conversation_summaries( + self, + limit: int = 10, + *, + exclude_conversation_id: Optional[str] = None, + first_message_max_chars: int = 100, + ) -> List[Dict]: + """返回当前工作区最近对话摘要:标题 + 首条用户消息。""" + try: + target_limit = max(1, int(limit)) + listing = self.get_conversation_list(limit=10000, offset=0) + summaries: List[Dict] = [] + for item in listing.get("conversations") or []: + conv_id = item.get("id") + if not conv_id or conv_id == exclude_conversation_id: + continue + data = self.load_conversation(conv_id) or {} + first_user_message = self._extract_first_user_message( + data.get("messages") or [], + max_chars=max(1, int(first_message_max_chars or 100)), + ) + if not first_user_message: + continue + summaries.append({ + "id": conv_id, + "title": item.get("title") or data.get("title") or "未命名对话", + "created_at": item.get("created_at") or data.get("created_at"), + "updated_at": item.get("updated_at") or data.get("updated_at"), + "total_messages": item.get("total_messages") or (data.get("metadata") or {}).get("total_messages", 0), + "total_tools": item.get("total_tools") or (data.get("metadata") or {}).get("total_tools", 0), + "first_user_message": first_user_message, + }) + if len(summaries) >= target_limit: + break + return summaries + except Exception as e: + print(f"⌘ 获取最近对话摘要失败: {e}") + return [] + + def search_conversation_summaries( + self, + query: str = "", + *, + keywords: Optional[List[str]] = None, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + limit: int = 10, + first_message_max_chars: int = 100, + exclude_conversation_id: Optional[str] = None, + ) -> List[Dict]: + """在当前工作区内搜索标题 + 首条用户消息,可按创建日期过滤。""" + try: + raw_keywords = keywords if isinstance(keywords, list) else [] + normalized_keywords = [str(item).strip().lower() for item in raw_keywords if str(item or "").strip()] + fallback_query = (query or "").strip().lower() + if fallback_query: + normalized_keywords.append(fallback_query) + normalized_keywords = normalized_keywords[:3] + start_key = (start_date or "").strip() + end_key = (end_date or "").strip() + listing = self.get_conversation_list(limit=10000, offset=0) + results: List[Dict] = [] + for item in listing.get("conversations") or []: + conv_id = item.get("id") + if not conv_id or conv_id == exclude_conversation_id: + continue + created_at = str(item.get("created_at") or "") + date_key = created_at[:10] + if start_key and date_key and date_key < start_key: + continue + if end_key and date_key and date_key > end_key: + continue + data = self.load_conversation(conv_id) or {} + first_message = self._extract_first_user_message( + data.get("messages") or [], + max_chars=max(1, int(first_message_max_chars or 100)), + ) + # 空对话(没有任何用户文本)不应出现在搜索/列表结果中, + # 否则无关键词 list 最近对话时会出现大量“新对话”。 + if not first_message: + continue + title = item.get("title") or data.get("title") or "未命名对话" + haystack = f"{title} {first_message}".lower() + if normalized_keywords and not any(keyword in haystack for keyword in normalized_keywords): + continue + results.append({ + "id": conv_id, + "title": title, + "first_user_message": first_message, + "created_at": item.get("created_at") or data.get("created_at"), + "updated_at": item.get("updated_at") or data.get("updated_at"), + "total_messages": item.get("total_messages") or (data.get("metadata") or {}).get("total_messages", 0), + "total_tools": item.get("total_tools") or (data.get("metadata") or {}).get("total_tools", 0), + }) + if len(results) >= max(1, int(limit or 10)): + break + return results + except Exception as e: + print(f"⌘ 搜索对话摘要失败: {e}") + return [] + def delete_conversation(self, conversation_id: str) -> bool: """ 删除对话 diff --git a/utils/host_workspace_debug.py b/utils/host_workspace_debug.py index 38bd5c8..0996eb9 100644 --- a/utils/host_workspace_debug.py +++ b/utils/host_workspace_debug.py @@ -34,4 +34,3 @@ def write_host_workspace_debug(event: str, **payload: Any) -> None: def get_host_workspace_debug_log_path() -> str: return str(_LOG_FILE) - diff --git a/utils/tool_result_formatter.py b/utils/tool_result_formatter.py index 84709a5..2618a9d 100644 --- a/utils/tool_result_formatter.py +++ b/utils/tool_result_formatter.py @@ -280,6 +280,12 @@ def format_tool_result_for_context(function_name: str, result_data: Any, raw_tex if function_name == "write_file_diff" and isinstance(result_data, dict): return _format_write_file_diff(result_data, raw_text) + if function_name == "conversation_search" and isinstance(result_data, dict): + return _format_conversation_search(result_data) + + if function_name == "conversation_review" and isinstance(result_data, dict): + return _format_conversation_review(result_data) + if not isinstance(result_data, dict): return raw_text @@ -382,6 +388,89 @@ def _format_write_file_diff(result_data: Dict[str, Any], raw_text: str) -> str: return formatted or raw_text +def _format_conversation_search(result_data: Dict[str, Any]) -> str: + if not result_data.get("success"): + return _format_failure("conversation_search", result_data) + results = result_data.get("results") or [] + header = f"找到 {len(results)} 个当前工作区内的历史对话" + filters: List[str] = [] + keywords = result_data.get("keywords") + if isinstance(keywords, list): + normalized_keywords = [str(item).strip() for item in keywords if str(item or "").strip()] + else: + normalized_keywords = [] + if normalized_keywords: + filters.append(f"关键词:{' / '.join(normalized_keywords)}") + elif result_data.get("query"): + filters.append(f"关键词:{result_data.get('query')}") + if result_data.get("start_date") or result_data.get("end_date"): + filters.append(f"日期:{result_data.get('start_date') or '不限'} ~ {result_data.get('end_date') or '不限'}") + if result_data.get("excluded_conversation_id"): + filters.append("已排除当前对话") + if filters: + header += "(" + ";".join(filters) + ")" + if not results: + return header + "\n未找到匹配对话。" + lines = [header] + for idx, item in enumerate(results, start=1): + lines.append(f"{idx}. {item.get('id')}") + lines.append(f" 标题:{item.get('title') or '未命名对话'}") + if item.get("total_messages") is not None or item.get("total_tools") is not None: + lines.append( + f" 规模:{int(item.get('total_messages') or 0)} 条消息,{int(item.get('total_tools') or 0)} 个工具" + ) + if item.get("first_user_message"): + lines.append(f" 首条用户消息:{item.get('first_user_message')}") + if item.get("created_at"): + lines.append(f" 创建时间:{item.get('created_at')}") + if item.get("updated_at"): + lines.append(f" 更新时间:{item.get('updated_at')}") + return "\n".join(lines) + + +def _format_conversation_review(result_data: Dict[str, Any]) -> str: + if not result_data.get("success"): + return _format_failure("conversation_review", result_data) + if result_data.get("mode") == "read" and result_data.get("content"): + lines = ["对话回顾内容:"] + if result_data.get("title"): + lines.append(f"标题:{result_data.get('title')}") + if result_data.get("char_count") is not None: + lines.append(f"字符数:{result_data.get('char_count')}") + lines.append("") + lines.append(str(result_data.get("content") or "")) + return "\n".join(lines) + path = result_data.get("path") or "" + if result_data.get("too_long"): + lines = [ + f"对话回顾内容太长({result_data.get('char_count')} 字符),已保存到文件:", + str(path), + "请使用 read_file 分段或查找阅读该文件。", + ] + if result_data.get("title"): + lines.insert(1, f"标题:{result_data.get('title')}") + return "\n".join(lines) + lines = [ + "已生成对话回顾文件:", + str(path), + ] + if result_data.get("title"): + lines.append(f"标题:{result_data.get('title')}") + if result_data.get("char_count") is not None: + lines.append(f"字符数:{result_data.get('char_count')}") + lines.append("请使用 read_file 读取该文件。") + return "\n".join(lines) + + +def _format_create_skill(result_data: Dict[str, Any]) -> str: + if not result_data.get("success"): + return _format_failure("create_skill", result_data) + lines = [ + f"已归档 skill:{result_data.get('skill_name') or '未命名'}", + ] + return "\n".join(lines) + + def _format_create_file(result_data: Dict[str, Any]) -> str: if not result_data.get("success"): return _format_failure("create_file", result_data) @@ -1060,6 +1149,7 @@ TOOL_FORMATTERS = { "todo_create": _format_todo_create, "todo_update_task": _format_todo_update_task, "update_memory": _format_update_memory, + "create_skill": _format_create_skill, "manage_personalization": _format_manage_personalization, "create_sub_agent": _format_create_sub_agent, "close_sub_agent": _format_close_sub_agent,