Compare commits

...

1 Commits

Author SHA1 Message Date
b7fe5f2b02 feat(memory): improve workspace memory and skill workflows
Implement workspace-scoped conversation storage with legacy migration based on host workspace metadata, active host workspace state protection, and frontend/backend conversation list scoping.

Add proactive recent-conversation prompt support with a configurable personal-space limit, frozen per conversation metadata, empty-conversation filtering, and a dedicated prompt template.

Add conversation_search and conversation_review tools with readable formatted results, workspace-only access, current-conversation exclusion, multi-keyword search, list mode, message/tool counts, read/save review modes, long-review fallback to .agents/review, and frontend detail renderers.

Move internal workspace artifacts under .agents, update upload/skills/compact/review paths, show .agents internal directories in prompt file trees, and adjust versioning ignores.

Add create_skill and a Skills tool category, move read_skill into that category, validate and archive skill folders, support host global skills and Docker/API private skills, and sync private skills into .agents/skills and prompts.

Update memory stamping with conversation/time source metadata and include frontend icon/status/display updates plus tests for workspace conversation storage and skills management.
2026-05-27 03:04:43 +08:00
36 changed files with 1545 additions and 63 deletions

View File

@ -72,6 +72,7 @@ license: Complete terms in LICENSE.txt
- 如果信息很多,优先使用摘要、分组、折叠、分页或“查看详情”结构,而不是把所有内容无差别堆进卡片。 - 如果信息很多,优先使用摘要、分组、折叠、分页或“查看详情”结构,而不是把所有内容无差别堆进卡片。
- 卡片可以有明确审美与细节,例如精致边框、轻量纹理、局部 SVG、状态色、微动效但必须服务于信息表达。 - 卡片可以有明确审美与细节,例如精致边框、轻量纹理、局部 SVG、状态色、微动效但必须服务于信息表达。
- 对于报告、检测、审核、论文、AIGC 率、风险分析等场景,卡片应优先体现可信、清晰、可读和结论明确,而不是浮夸科技感。 - 对于报告、检测、审核、论文、AIGC 率、风险分析等场景,卡片应优先体现可信、清晰、可读和结论明确,而不是浮夸科技感。
- 卡片可以包含精美优雅的动画交互,但不要包含任何元素的初始动画或出场动画效果
**重要**:让实现复杂度与审美愿景相匹配。极繁主义设计需要更复杂的代码、大量动画与效果。极简或精致设计则需要克制、精准,以及对间距、字体排印和细微细节的谨慎关注。优雅来自于对愿景的高质量执行。 **重要**:让实现复杂度与审美愿景相匹配。极繁主义设计需要更复杂的代码、大量动画与效果。极简或精致设计则需要克制、精准,以及对间距、字体排印和细微细节的谨慎关注。优雅来自于对愿景的高质量执行。

View File

@ -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") DATA_DIR = _resolve_repo_path(os.environ.get("DATA_DIR", ""), "./data")
LOGS_DIR = _resolve_repo_path(os.environ.get("LOGS_DIR", ""), "./logs") LOGS_DIR = _resolve_repo_path(os.environ.get("LOGS_DIR", ""), "./logs")
AGENT_SKILLS_DIR = _resolve_repo_path(os.environ.get("AGENT_SKILLS_DIR", ""), "./agentskills") AGENT_SKILLS_DIR = _resolve_repo_path(os.environ.get("AGENT_SKILLS_DIR", ""), "./agentskills")
WORKSPACE_SKILLS_DIRNAME = "skills" WORKSPACE_SKILLS_DIRNAME = ".agents/skills"
# 多用户空间 # 多用户空间
USER_SPACE_DIR = _resolve_repo_path(os.environ.get("USER_SPACE_DIR", ""), "./users") USER_SPACE_DIR = _resolve_repo_path(os.environ.get("USER_SPACE_DIR", ""), "./users")

View File

@ -62,6 +62,7 @@ except ImportError:
from core.tool_config import TOOL_CATEGORIES from core.tool_config import TOOL_CATEGORIES
from utils.api_client import DeepSeekClient from utils.api_client import DeepSeekClient
from utils.context_manager import ContextManager from utils.context_manager import ContextManager
from utils.conversation_manager import ConversationManager
from utils.host_workspace_debug import write_host_workspace_debug from utils.host_workspace_debug import write_host_workspace_debug
from config.model_profiles import get_model_profile from config.model_profiles import get_model_profile
@ -448,6 +449,24 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
current_context_resolved = str( current_context_resolved = str(
getattr(getattr(self, "context_manager", None), "project_path", "") 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( write_host_workspace_debug(
"terminal.update_project_path.begin", "terminal.update_project_path.begin",
@ -456,7 +475,11 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
target_project_path=resolved_path, 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( write_host_workspace_debug(
"terminal.update_project_path.skip_same_path", "terminal.update_project_path.skip_same_path",
terminal_id=id(self), terminal_id=id(self),
@ -481,6 +504,13 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
self.context_manager.initial_project_path = new_path self.context_manager.initial_project_path = new_path
self.context_manager.project_snapshot = None self.context_manager.project_snapshot = None
self.context_manager._host_runtime_cache = 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) metadata = getattr(self.context_manager, "conversation_metadata", None)
if isinstance(metadata, dict): if isinstance(metadata, dict):
metadata["project_path"] = str(new_path) metadata["project_path"] = str(new_path)
@ -495,6 +525,7 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
metadata.pop("project_file_tree", None) metadata.pop("project_file_tree", None)
metadata.pop("project_statistics", None) metadata.pop("project_statistics", None)
metadata.pop("project_snapshot_at", None) metadata.pop("project_snapshot_at", None)
self._ensure_conversation()
except Exception: except Exception:
pass pass

View File

@ -54,12 +54,16 @@ from modules.easter_egg_manager import EasterEggManager
from modules.personalization_manager import ( from modules.personalization_manager import (
load_personalization_config, load_personalization_config,
build_personalization_prompt, build_personalization_prompt,
RECENT_CONVERSATIONS_PROMPT_LIMIT_MIN,
RECENT_CONVERSATIONS_PROMPT_LIMIT_MAX,
RECENT_CONVERSATIONS_PROMPT_LIMIT_DEFAULT,
) )
from modules.skills_manager import ( from modules.skills_manager import (
get_skills_catalog, get_skills_catalog,
build_skills_list, build_skills_list,
merge_enabled_skills, merge_enabled_skills,
build_skills_prompt, build_skills_prompt,
infer_private_skills_dir,
) )
from modules.custom_tool_registry import CustomToolRegistry, build_default_tool_category from modules.custom_tool_registry import CustomToolRegistry, build_default_tool_category
from modules.custom_tool_executor import CustomToolExecutor 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]: 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) cm = getattr(self, "context_manager", None)
meta = getattr(cm, "conversation_metadata", {}) if cm else {} meta = getattr(cm, "conversation_metadata", {}) if cm else {}
cached = meta.get(key) if isinstance(meta, dict) else None cached = meta.get(key) if isinstance(meta, dict) else None
@ -218,6 +225,40 @@ class MainTerminalContextMixin:
# 构建上下文 # 构建上下文
return self.context_manager.build_main_context(memory) 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: def _tool_calls_followed_by_tools(self, conversation: List[Dict], start_idx: int, tool_calls: List[Dict]) -> bool:
"""判断指定助手消息的工具调用是否拥有后续的工具响应。""" """判断指定助手消息的工具调用是否拥有后续的工具响应。"""
if not tool_calls: 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) personalization_config = getattr(self.context_manager, "custom_personalization_config", None) or load_personalization_config(self.data_dir)
shallow_replace_enabled = bool(personalization_config.get("auto_shallow_compress_enabled", False)) if isinstance(personalization_config, dict) else False shallow_replace_enabled = bool(personalization_config.get("auto_shallow_compress_enabled", False)) if isinstance(personalization_config, dict) else False
skills_catalog = get_skills_catalog() skills_catalog = get_skills_catalog(private_dir=infer_private_skills_dir(self.data_dir))
enabled_skills = merge_enabled_skills( enabled_skills = merge_enabled_skills(
personalization_config.get("enabled_skills") if isinstance(personalization_config, dict) else None, personalization_config.get("enabled_skills") if isinstance(personalization_config, dict) else None,
skills_catalog, skills_catalog,
@ -303,6 +344,32 @@ class MainTerminalContextMixin:
if workspace_system: if workspace_system:
messages.append({"role": "system", "content": 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( permission_mode_message = self._get_or_init_frozen_mode_prompt(
"frozen_permission_prompt", "frozen_permission_prompt",

View File

@ -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", "type": "function",
"function": { "function": {
@ -780,7 +797,7 @@ class MainTerminalToolsDefinitionMixin:
"type": "function", "type": "function",
"function": { "function": {
"name": "update_memory", "name": "update_memory",
"description": "按条目更新记忆列表自动编号。append 追加新条目replace 用序号替换delete 用序号删除。", "description": "按条目更新记忆列表自动编号。append 追加新条目replace 用序号替换delete 用序号删除。应积极记录长期偏好、项目事实、已验证经验、用户明确要求记住的内容;不要记录密钥、隐私或未确认猜测。",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": self._inject_intent({ "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", "type": "function",
"function": { "function": {

View File

@ -23,6 +23,7 @@ try:
READONLY_RUN_COMMAND_ALLOWED, READONLY_RUN_COMMAND_ALLOWED,
READONLY_RUN_COMMAND_ALLOWED_GIT_SUBCOMMANDS, READONLY_RUN_COMMAND_ALLOWED_GIT_SUBCOMMANDS,
READONLY_RUN_COMMAND_BLOCKED_TOKENS, READONLY_RUN_COMMAND_BLOCKED_TOKENS,
AGENT_SKILLS_DIR,
) )
except ImportError: except ImportError:
import sys import sys
@ -46,6 +47,7 @@ except ImportError:
READONLY_RUN_COMMAND_ALLOWED, READONLY_RUN_COMMAND_ALLOWED,
READONLY_RUN_COMMAND_ALLOWED_GIT_SUBCOMMANDS, READONLY_RUN_COMMAND_ALLOWED_GIT_SUBCOMMANDS,
READONLY_RUN_COMMAND_BLOCKED_TOKENS, READONLY_RUN_COMMAND_BLOCKED_TOKENS,
AGENT_SKILLS_DIR,
) )
from modules.file_manager import FileManager from modules.file_manager import FileManager
@ -72,6 +74,9 @@ from modules.skills_manager import (
build_skills_list, build_skills_list,
merge_enabled_skills, merge_enabled_skills,
build_skills_prompt, 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_registry import CustomToolRegistry, build_default_tool_category
from modules.custom_tool_executor import CustomToolExecutor from modules.custom_tool_executor import CustomToolExecutor
@ -178,6 +183,8 @@ class MainTerminalToolsExecutionMixin:
"vlm_analyze", "vlm_analyze",
"ocr_image", "ocr_image",
"update_memory", "update_memory",
"conversation_search",
"conversation_review",
"todo_create", "todo_create",
"todo_update_task", "todo_update_task",
"todo_get", "todo_get",
@ -195,6 +202,7 @@ class MainTerminalToolsExecutionMixin:
"rename_file", "rename_file",
"save_webpage", "save_webpage",
"terminal_session", "terminal_session",
"create_skill",
} }
# 扩展的只读命令白名单(包含常用管道命令) # 扩展的只读命令白名单(包含常用管道命令)
@ -466,6 +474,69 @@ class MainTerminalToolsExecutionMixin:
except Exception: except Exception:
return raw.lstrip("./").lower() 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/<username>/data -> users/<username>/agentskills
# API 工作区用户: users_api/<username>/workspaces/<ws>/data -> users_api/<username>/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: 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"}: if tool_name not in {"read_file", "read_skill"}:
return return
@ -581,7 +652,7 @@ class MainTerminalToolsExecutionMixin:
return skill_id in set(enabled_skill_ids) return skill_id in set(enabled_skill_ids)
try: try:
config = load_personalization_config(self.data_dir) 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( enabled = merge_enabled_skills(
config.get("enabled_skills") if isinstance(config, dict) else None, config.get("enabled_skills") if isinstance(config, dict) else None,
catalog, catalog,
@ -809,6 +880,8 @@ class MainTerminalToolsExecutionMixin:
result = self._handle_read_skill_tool(arguments) result = self._handle_read_skill_tool(arguments)
self._mark_skill_read_from_result(tool_name, arguments, result) self._mark_skill_read_from_result(tool_name, arguments, result)
self._mark_file_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"}: elif tool_name in {"vlm_analyze", "ocr_image"}:
path = arguments.get("path") path = arguments.get("path")
prompt = arguments.get("prompt") prompt = arguments.get("prompt")
@ -1427,6 +1500,17 @@ class MainTerminalToolsExecutionMixin:
operation = arguments["operation"] operation = arguments["operation"]
content = arguments.get("content") content = arguments.get("content")
index = arguments.get("index") 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()): if operation == "append" and (not content or not str(content).strip()):
@ -1440,10 +1524,116 @@ class MainTerminalToolsExecutionMixin:
result = self.memory_manager.update_entries( result = self.memory_manager.update_entries(
memory_type="main", memory_type="main",
operation=operation, operation=operation,
content=content, content=enriched_content,
index=index 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": elif tool_name == "todo_create":
result = self.todo_manager.create_todo_list( result = self.todo_manager.create_todo_list(
overview=arguments.get("overview", ""), overview=arguments.get("overview", ""),

View File

@ -60,6 +60,7 @@ from modules.skills_manager import (
build_skills_list, build_skills_list,
merge_enabled_skills, merge_enabled_skills,
build_skills_prompt, build_skills_prompt,
infer_private_skills_dir,
) )
from modules.custom_tool_registry import CustomToolRegistry, build_default_tool_category from modules.custom_tool_registry import CustomToolRegistry, build_default_tool_category
from modules.custom_tool_executor import CustomToolExecutor from modules.custom_tool_executor import CustomToolExecutor
@ -136,7 +137,7 @@ class MainTerminalToolsPolicyMixin:
# 解析当前启用的 skills用于强约束“仅对已启用 skill 生效”) # 解析当前启用的 skills用于强约束“仅对已启用 skill 生效”)
try: 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( enabled_skills = merge_enabled_skills(
effective_config.get("enabled_skills") if isinstance(effective_config, dict) else None, effective_config.get("enabled_skills") if isinstance(effective_config, dict) else None,
skills_catalog, skills_catalog,

View File

@ -60,6 +60,7 @@ from modules.skills_manager import (
build_skills_list, build_skills_list,
merge_enabled_skills, merge_enabled_skills,
build_skills_prompt, build_skills_prompt,
infer_private_skills_dir,
) )
from modules.custom_tool_registry import CustomToolRegistry, build_default_tool_category from modules.custom_tool_registry import CustomToolRegistry, build_default_tool_category
from modules.custom_tool_executor import CustomToolExecutor from modules.custom_tool_executor import CustomToolExecutor
@ -106,7 +107,7 @@ class MainTerminalToolsReadMixin:
personalization = load_personalization_config(self.data_dir) personalization = load_personalization_config(self.data_dir)
except Exception: except Exception:
personalization = {} personalization = {}
catalog = get_skills_catalog() catalog = get_skills_catalog(private_dir=infer_private_skills_dir(self.data_dir))
enabled_skills = merge_enabled_skills( enabled_skills = merge_enabled_skills(
personalization.get("enabled_skills") if isinstance(personalization, dict) else None, personalization.get("enabled_skills") if isinstance(personalization, dict) else None,
catalog, catalog,

View File

@ -45,13 +45,16 @@ TOOL_CATEGORIES: Dict[str, ToolCategory] = {
"read_focus": ToolCategory( "read_focus": ToolCategory(
label="阅读聚焦", label="阅读聚焦",
tools=[ tools=[
"read_skill",
"read_file", "read_file",
"vlm_analyze", "vlm_analyze",
"ocr_image", "ocr_image",
"view_image", "view_image",
], ],
), ),
"skills": ToolCategory(
label="Skills",
tools=["read_skill", "create_skill"],
),
"terminal_realtime": ToolCategory( "terminal_realtime": ToolCategory(
label="实时终端", label="实时终端",
tools=[ tools=[
@ -67,7 +70,7 @@ TOOL_CATEGORIES: Dict[str, ToolCategory] = {
), ),
"memory": ToolCategory( "memory": ToolCategory(
label="记忆", label="记忆",
tools=["update_memory"], tools=["update_memory", "conversation_search", "conversation_review"],
), ),
"todo": ToolCategory( "todo": ToolCategory(
label="待办事项", label="待办事项",

View File

@ -46,7 +46,7 @@ class ApiUserWorkspace:
project_path: Path project_path: Path
data_dir: Path # 会话/备份等落盘到这里(每个工作区独立) data_dir: Path # 会话/备份等落盘到这里(每个工作区独立)
logs_dir: Path logs_dir: Path
uploads_dir: Path # project/user_upload uploads_dir: Path # project/.agents/user_upload
quarantine_dir: Path # 上传隔离区(按用户/工作区划分) quarantine_dir: Path # 上传隔离区(按用户/工作区划分)
shared_dir: Path # 用户级共享目录prompts/personalization shared_dir: Path # 用户级共享目录prompts/personalization
prompts_dir: Path # 实际使用的 prompts 目录(指向 shared_dir/prompt prompts_dir: Path # 实际使用的 prompts 目录(指向 shared_dir/prompt
@ -103,7 +103,7 @@ class ApiUserManager:
personalization/ personalization/
workspaces/<ws>/ # 单个工作区 workspaces/<ws>/ # 单个工作区
project/ project/
user_upload/ .agents/user_upload/
data/ data/
conversations/ conversations/
backups/ backups/
@ -123,8 +123,8 @@ class ApiUserManager:
project_path = work_root / "project" project_path = work_root / "project"
data_dir = work_root / "data" data_dir = work_root / "data"
logs_dir = work_root / "logs" logs_dir = work_root / "logs"
uploads_dir = project_path / "user_upload" uploads_dir = project_path / ".agents" / "user_upload"
skills_dir = project_path / "skills" 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): for path in (project_path, data_dir, logs_dir, uploads_dir, skills_dir, shared_dir, prompts_dir, personalization_dir):
path.mkdir(parents=True, exist_ok=True) path.mkdir(parents=True, exist_ok=True)

View File

@ -26,6 +26,9 @@ MAX_CONSIDERATION_ITEMS = 10
TONE_PRESETS = ["健谈", "幽默", "直言不讳", "鼓励性", "诗意", "企业商务", "打破常规", "同理心"] TONE_PRESETS = ["健谈", "幽默", "直言不讳", "鼓励性", "诗意", "企业商务", "打破常规", "同理心"]
THINKING_INTERVAL_MIN = 1 THINKING_INTERVAL_MIN = 1
THINKING_INTERVAL_MAX = 50 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_TRIGGER_TOKENS = 80_000
DEFAULT_SHALLOW_COMPRESS_KEEP_RECENT_TOOLS = 15 DEFAULT_SHALLOW_COMPRESS_KEEP_RECENT_TOOLS = 15
DEFAULT_SHALLOW_COMPRESS_MAX_REPLACE_PER_ROUND = 10 DEFAULT_SHALLOW_COMPRESS_MAX_REPLACE_PER_ROUND = 10
@ -59,6 +62,8 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = {
"default_run_mode": None, "default_run_mode": None,
"default_permission_mode": "unrestricted", "default_permission_mode": "unrestricted",
"auto_generate_title": True, "auto_generate_title": True,
"recent_conversations_prompt_enabled": False,
"recent_conversations_prompt_limit": RECENT_CONVERSATIONS_PROMPT_LIMIT_DEFAULT,
"tool_intent_enabled": True, "tool_intent_enabled": True,
"skill_hints_enabled": False, # Skill 提示系统开关(默认关闭) "skill_hints_enabled": False, # Skill 提示系统开关(默认关闭)
"skill_strict_terminal_enabled": False, # 强约束terminal 系列工具需先阅读 terminal-guide "skill_strict_terminal_enabled": False, # 强约束terminal 系列工具需先阅读 terminal-guide
@ -88,6 +93,9 @@ __all__ = [
"DEFAULT_PERSONALIZATION_CONFIG", "DEFAULT_PERSONALIZATION_CONFIG",
"TONE_PRESETS", "TONE_PRESETS",
"MAX_CONSIDERATION_ITEMS", "MAX_CONSIDERATION_ITEMS",
"RECENT_CONVERSATIONS_PROMPT_LIMIT_MIN",
"RECENT_CONVERSATIONS_PROMPT_LIMIT_MAX",
"RECENT_CONVERSATIONS_PROMPT_LIMIT_DEFAULT",
"load_personalization_config", "load_personalization_config",
"save_personalization_config", "save_personalization_config",
"ensure_personalization_config", "ensure_personalization_config",
@ -169,6 +177,17 @@ def sanitize_personalization_payload(
_comm_style = data.get("communication_style", base.get("communication_style", "default")) _comm_style = data.get("communication_style", base.get("communication_style", "default"))
base["communication_style"] = "human_like" if _comm_style == "human_like" else "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["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["self_identify"] = _resolve_short_field("self_identify")
base["user_name"] = _resolve_short_field("user_name") base["user_name"] = _resolve_short_field("user_name")
base["profession"] = _resolve_short_field("profession") base["profession"] = _resolve_short_field("profession")

View File

@ -47,6 +47,94 @@ def ensure_workspace_skills_dir(project_path: str | Path) -> Path:
return skills_dir 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]: def _parse_frontmatter(text: str) -> Dict[str, str]:
"""Parse simple YAML frontmatter / 解析简单 YAML 头信息。""" """Parse simple YAML frontmatter / 解析简单 YAML 头信息。"""
lines = text.splitlines() lines = text.splitlines()
@ -76,9 +164,8 @@ def _is_valid_skill_id(name: str) -> bool:
return bool(name and SKILL_ID_PATTERN.match(name)) return bool(name and SKILL_ID_PATTERN.match(name))
def get_skills_catalog(base_dir: Optional[str] = None) -> List[Dict[str, str]]: def _scan_skills_catalog(root: Path) -> List[Dict[str, str]]:
"""List available skills from global library / 扫描全局技能库。""" """Scan one skills root."""
root = Path(base_dir or AGENT_SKILLS_DIR).expanduser().resolve()
if not root.exists() or not root.is_dir(): if not root.exists() or not root.is_dir():
return [] return []
catalog: List[Dict[str, str]] = [] catalog: List[Dict[str, str]] = []
@ -105,6 +192,52 @@ def get_skills_catalog(base_dir: Optional[str] = None) -> List[Dict[str, str]]:
return catalog 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( def resolve_enabled_skills(
enabled_skills: Optional[Sequence[str]], enabled_skills: Optional[Sequence[str]],
catalog: Sequence[Dict[str, str]], catalog: Sequence[Dict[str, str]],
@ -190,6 +323,7 @@ def sync_workspace_skills(
project_path: str | Path, project_path: str | Path,
enabled_skills: Optional[Sequence[str]] = None, enabled_skills: Optional[Sequence[str]] = None,
base_dir: Optional[str] = None, base_dir: Optional[str] = None,
private_dir: Optional[str | Path] = None,
) -> Dict[str, object]: ) -> Dict[str, object]:
"""Sync global skills into workspace / 将全局 skills 同步到工作区。""" """Sync global skills into workspace / 将全局 skills 同步到工作区。"""
root = Path(project_path).expanduser().resolve() root = Path(project_path).expanduser().resolve()
@ -200,8 +334,9 @@ def sync_workspace_skills(
return {"success": False, "error": "skills 目录不在项目路径内"} return {"success": False, "error": "skills 目录不在项目路径内"}
ensure_agent_skills_dir(base_dir) 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) resolved = resolve_enabled_skills(enabled_skills, catalog)
sources = _get_skill_source_map(base_dir, private_dir=private_dir)
lock = _get_sync_lock(skills_dir) lock = _get_sync_lock(skills_dir)
try: try:
@ -209,10 +344,9 @@ def sync_workspace_skills(
if skills_dir.exists(): if skills_dir.exists():
shutil.rmtree(skills_dir, ignore_errors=True) shutil.rmtree(skills_dir, ignore_errors=True)
skills_dir.mkdir(parents=True, exist_ok=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: for skill_id in resolved:
src = global_root / skill_id src = sources.get(skill_id)
if not src.exists() or not src.is_dir(): if src is None or not src.exists() or not src.is_dir():
continue continue
dst = skills_dir / skill_id dst = skills_dir / skill_id
if dst.exists() and not dst.is_dir(): if dst.exists() and not dst.is_dir():

View File

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

View File

@ -29,7 +29,7 @@ class ConversationVersioningManager:
"""Manage hidden git snapshots bound to a conversation.""" """Manage hidden git snapshots bound to a conversation."""
TRACKING_MODE_WORKSPACE_AND_CONVERSATION = "workspace_and_conversation" TRACKING_MODE_WORKSPACE_AND_CONVERSATION = "workspace_and_conversation"
TRACKING_MODE_CONVERSATION_ONLY = "conversation_only" 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): def __init__(self, project_path: Path | str, data_dir: Path | str, conversation_id: str):
self.project_path = Path(project_path).expanduser().resolve() self.project_path = Path(project_path).expanduser().resolve()

View File

@ -0,0 +1,10 @@
## 最近对话(仅当前工作区)
以下是当前工作区最近的历史对话摘要,供你判断是否需要使用 `conversation_search` 或 `conversation_review` 主动回顾历史上下文。
{recent_conversations}
注意:
- 这里只展示标题和首条用户消息,不包含完整内容。
- 如需查看完整上下文,请使用 `conversation_review` 生成回顾文件后再读取。
- 不要假设其他工作区的对话可用;历史对话工具只允许访问当前工作区。

View File

@ -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_BACKEND_LOG_FILE = Path(LOGS_DIR).expanduser().resolve() / "chunk_backend.log"
CHUNK_FRONTEND_LOG_FILE = Path(LOGS_DIR).expanduser().resolve() / "chunk_frontend.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" 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: def is_logged_in() -> bool:

View File

@ -218,7 +218,7 @@ def host_login():
data_dir.mkdir(parents=True, exist_ok=True) data_dir.mkdir(parents=True, exist_ok=True)
logs_dir = Path(LOGS_DIR).expanduser().resolve() logs_dir = Path(LOGS_DIR).expanduser().resolve()
logs_dir.mkdir(parents=True, exist_ok=True) logs_dir.mkdir(parents=True, exist_ok=True)
uploads_dir = host_path / "user_upload" uploads_dir = host_path / ".agents" / "user_upload"
uploads_dir.mkdir(parents=True, exist_ok=True) uploads_dir.mkdir(parents=True, exist_ok=True)
quarantine_root = Path(UPLOAD_QUARANTINE_SUBDIR).expanduser() quarantine_root = Path(UPLOAD_QUARANTINE_SUBDIR).expanduser()
if not quarantine_root.is_absolute(): if not quarantine_root.is_absolute():

View File

@ -19,9 +19,12 @@ from modules.personalization_manager import (
save_personalization_config, save_personalization_config,
THINKING_INTERVAL_MIN, THINKING_INTERVAL_MIN,
THINKING_INTERVAL_MAX, THINKING_INTERVAL_MAX,
RECENT_CONVERSATIONS_PROMPT_LIMIT_MIN,
RECENT_CONVERSATIONS_PROMPT_LIMIT_MAX,
) )
from modules.skills_manager import ( from modules.skills_manager import (
get_skills_catalog, get_skills_catalog,
infer_private_skills_dir,
merge_enabled_skills, merge_enabled_skills,
sync_workspace_skills, sync_workspace_skills,
) )
@ -41,7 +44,7 @@ from .extensions import socketio
from .monitor import get_cached_monitor_snapshot from .monitor import get_cached_monitor_snapshot
from .files import sanitize_filename_preserve_unicode from .files import sanitize_filename_preserve_unicode
UPLOAD_FOLDER_NAME = "user_upload" UPLOAD_FOLDER_NAME = ".agents/user_upload"
chat_bp = Blueprint('chat', __name__) 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"): if policy.get("ui_blocks", {}).get("block_personal_space"):
return jsonify({"success": False, "error": "个人空间已被管理员禁用"}), 403 return jsonify({"success": False, "error": "个人空间已被管理员禁用"}), 403
data = load_personalization_config(workspace.data_dir) 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( enabled_skills = merge_enabled_skills(
data.get("enabled_skills"), data.get("enabled_skills"),
skills_catalog, skills_catalog,
@ -242,6 +246,10 @@ def get_personalization_settings(terminal: WebTerminal, workspace: UserWorkspace
"thinking_interval_range": { "thinking_interval_range": {
"min": THINKING_INTERVAL_MIN, "min": THINKING_INTERVAL_MIN,
"max": THINKING_INTERVAL_MAX "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: 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"): if policy.get("ui_blocks", {}).get("block_personal_space"):
return jsonify({"success": False, "error": "个人空间已被管理员禁用"}), 403 return jsonify({"success": False, "error": "个人空间已被管理员禁用"}), 403
config = save_personalization_config(workspace.data_dir, payload) 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( enabled_skills = merge_enabled_skills(
config.get("enabled_skills"), config.get("enabled_skills"),
skills_catalog, skills_catalog,
@ -274,7 +283,7 @@ def update_personalization_settings(terminal: WebTerminal, workspace: UserWorksp
config["skills_catalog_snapshot"] = catalog_snapshot config["skills_catalog_snapshot"] = catalog_snapshot
config = save_personalization_config(workspace.data_dir, config) config = save_personalization_config(workspace.data_dir, config)
try: 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: except Exception as sync_exc:
debug_log(f"[Skills] 同步失败: {sync_exc}") debug_log(f"[Skills] 同步失败: {sync_exc}")
try: try:
@ -324,6 +333,10 @@ def update_personalization_settings(terminal: WebTerminal, workspace: UserWorksp
"thinking_interval_range": { "thinking_interval_range": {
"min": THINKING_INTERVAL_MIN, "min": THINKING_INTERVAL_MIN,
"max": THINKING_INTERVAL_MAX "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: except ValueError as exc:

View File

@ -8,7 +8,7 @@ from core.web_terminal import WebTerminal
from modules.gui_file_manager import GuiFileManager from modules.gui_file_manager import GuiFileManager
from modules.upload_security import UploadQuarantineManager, UploadSecurityError from modules.upload_security import UploadQuarantineManager, UploadSecurityError
from modules.personalization_manager import load_personalization_config 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 modules.host_workspace_manager import resolve_host_workspace
from utils.host_workspace_debug import write_host_workspace_debug from utils.host_workspace_debug import write_host_workspace_debug
import json import json
@ -66,7 +66,11 @@ def _ensure_workspace_skills_synced(terminal: WebTerminal, workspace) -> None:
try: try:
config = load_personalization_config(workspace.data_dir) config = load_personalization_config(workspace.data_dir)
enabled_skills = config.get("enabled_skills") if isinstance(config, dict) else None 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"): if not result.get("success"):
debug_log(f"[Skills] 工作区同步失败: {result.get('error')}") debug_log(f"[Skills] 工作区同步失败: {result.get('error')}")
return 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" sandbox_is_host = (TERMINAL_SANDBOX_MODE or "host").lower() == "host"
if host_mode_session and sandbox_is_host: if host_mode_session and sandbox_is_host:
selected_workspace_id = session.get("host_workspace_id") if has_request_context() else None 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) _, 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() project_path = Path(host_workspace.get("path") or "").expanduser().resolve()
write_host_workspace_debug( write_host_workspace_debug(
"context.get_user_resources.host.selected_workspace", "context.get_user_resources.host.selected_workspace",
selected_workspace_id=selected_workspace_id, 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"), resolved_workspace_id=host_workspace.get("workspace_id"),
project_path=str(project_path), project_path=str(project_path),
username=username, 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) data_dir.mkdir(parents=True, exist_ok=True)
logs_dir = Path(LOGS_DIR).expanduser().resolve() logs_dir = Path(LOGS_DIR).expanduser().resolve()
logs_dir.mkdir(parents=True, exist_ok=True) logs_dir.mkdir(parents=True, exist_ok=True)
uploads_dir = project_path / "user_upload" uploads_dir = project_path / ".agents" / "user_upload"
uploads_dir.mkdir(parents=True, exist_ok=True) 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) skills_dir.mkdir(parents=True, exist_ok=True)
quarantine_root = Path(UPLOAD_QUARANTINE_SUBDIR).expanduser() quarantine_root = Path(UPLOAD_QUARANTINE_SUBDIR).expanduser()
if not quarantine_root.is_absolute(): if not quarantine_root.is_absolute():

View File

@ -1613,21 +1613,21 @@ def review_conversation(conversation_id, terminal: WebTerminal, workspace: UserW
content = "\n".join(lines) + "\n" content = "\n".join(lines) + "\n"
char_count = len(content) char_count = len(content)
uploads_dir = workspace.uploads_dir / "review" review_dir = workspace.project_path / ".agents" / "review"
uploads_dir.mkdir(parents=True, exist_ok=True) review_dir.mkdir(parents=True, exist_ok=True)
title = conversation_data.get("title") or "untitled" title = conversation_data.get("title") or "untitled"
safe_title = _sanitize_filename_component(title) safe_title = _sanitize_filename_component(title)
timestamp = datetime.now().strftime("%Y%m%d%H%M%S") timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
filename = f"review_{safe_title}_{timestamp}.md" filename = f"review_{safe_title}_{timestamp}.md"
target = uploads_dir / filename target = review_dir / filename
target.write_text(content, encoding='utf-8') target.write_text(content, encoding='utf-8')
return jsonify({ return jsonify({
"success": True, "success": True,
"data": { "data": {
"path": f"user_upload/review/{filename}", "path": f".agents/review/{filename}",
"char_count": char_count "char_count": char_count
} }
}) })

View File

@ -136,7 +136,7 @@ def _read_token_totals_file(workspace: UserWorkspace) -> Dict[str, int]:
def _collect_conversation_token_totals(workspace: UserWorkspace) -> Dict[str, int]: def _collect_conversation_token_totals(workspace: UserWorkspace) -> Dict[str, int]:
try: 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 {} stats = manager.get_statistics() or {}
token_stats = stats.get("token_statistics") or {} token_stats = stats.get("token_statistics") or {}
input_tokens = int(token_stats.get("total_input_tokens") or 0) input_tokens = int(token_stats.get("total_input_tokens") or 0)

View File

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

View File

@ -27,6 +27,10 @@ container_manager = UserContainerManager()
user_terminals: Dict[str, WebTerminal] = {} user_terminals: Dict[str, WebTerminal] = {}
terminal_rooms: Dict[str, set] = {} terminal_rooms: Dict[str, set] = {}
connection_users: Dict[str, str] = {} 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_EVENT_LIMIT = 150
RECENT_UPLOAD_FEED_LIMIT = 60 RECENT_UPLOAD_FEED_LIMIT = 60
stop_flags: Dict[str, Dict[str, Any]] = {} stop_flags: Dict[str, Dict[str, Any]] = {}
@ -86,6 +90,10 @@ __all__ = [
"user_terminals", "user_terminals",
"terminal_rooms", "terminal_rooms",
"connection_users", "connection_users",
"HOST_ACTIVE_WORKSPACE_LOCK",
"HOST_ACTIVE_WORKSPACE_ID",
"HOST_ACTIVE_WORKSPACE_PATH",
"HOST_ACTIVE_WORKSPACE_VERSION",
"stop_flags", "stop_flags",
"MONITOR_FILE_TOOLS", "MONITOR_FILE_TOOLS",
"MONITOR_MEMORY_TOOLS", "MONITOR_MEMORY_TOOLS",

View File

@ -249,6 +249,10 @@ def select_host_workspace():
) )
if current_id == workspace_id: 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 session['workspace_id'] = workspace_id
try: try:
container_handle = state.container_manager.ensure_container( container_handle = state.container_manager.ensure_container(
@ -299,6 +303,10 @@ def select_host_workspace():
pass pass
previous_workspace_id = session.get("workspace_id") 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['host_workspace_id'] = workspace_id
session['workspace_id'] = workspace_id session['workspace_id'] = workspace_id

View File

@ -129,9 +129,11 @@ function renderEnhancedToolResult(): string {
return renderRenameFile(result, args); return renderRenameFile(result, args);
} }
// // / Skills
else if (name === 'read_file' || name === 'read_skill') { else if (name === 'read_file' || name === 'read_skill') {
return renderReadFile(result, args); return renderReadFile(result, args);
} else if (name === 'create_skill') {
return renderCreateSkill(result, args);
} else if (name === 'vlm_analyze') { } else if (name === 'vlm_analyze') {
return renderVlmAnalyze(result, args); return renderVlmAnalyze(result, args);
} else if (name === 'ocr_image') { } else if (name === 'ocr_image') {
@ -161,6 +163,10 @@ function renderEnhancedToolResult(): string {
// //
else if (name === 'update_memory') { else if (name === 'update_memory') {
return renderUpdateMemory(result, args); 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; 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 = '<div class="tool-result-meta">';
html += `<div><strong>关键词:</strong>${escapeHtml(keywordText)}</div>`;
html += `<div><strong>日期范围:</strong>${escapeHtml(result?.start_date || args?.start_date || '不限')} ~ ${escapeHtml(result?.end_date || args?.end_date || '不限')}</div>`;
html += '<div><strong>范围:</strong>当前工作区历史对话(已排除当前对话)</div>';
html += `<div><strong>结果数量:</strong>${results.length}</div>`;
html += '</div>';
if (!results.length) {
html += '<div class="tool-result-empty">未找到匹配对话。</div>';
return html;
}
html += '<div class="search-result-list">';
results.forEach((item: any) => {
html += '<div class="search-result-item">';
html += `<div class="search-result-title">${escapeHtml(item.title || '未命名对话')}</div>`;
html += `<div><strong>ID</strong>${escapeHtml(item.id || '')}</div>`;
html += `<div><strong>规模:</strong>${escapeHtml(String(item.total_messages || 0))} 条消息,${escapeHtml(String(item.total_tools || 0))} 个工具</div>`;
if (item.first_user_message) {
html += `<div><strong>首条用户消息:</strong>${escapeHtml(item.first_user_message)}</div>`;
}
if (item.updated_at) {
html += `<div><strong>更新时间:</strong>${escapeHtml(item.updated_at)}</div>`;
}
html += '</div>';
});
html += '</div>';
return html;
}
function renderConversationReview(result: any, args: any): string {
const status = formatToolStatusLabel(result, result?.mode === 'read' ? '✓ 已返回' : '✓ 已生成');
let html = '<div class="tool-result-meta">';
html += `<div><strong>对话 ID</strong>${escapeHtml(result?.conversation_id || args?.conversation_id || '')}</div>`;
html += `<div><strong>状态:</strong>${status}</div>`;
html += `<div><strong>模式:</strong>${escapeHtml(result?.mode || args?.mode || '')}</div>`;
if (result?.title) {
html += `<div><strong>标题:</strong>${escapeHtml(result.title)}</div>`;
}
if (result?.path) {
html += `<div><strong>回顾文件:</strong>${escapeHtml(result.path)}</div>`;
}
if (result?.char_count !== undefined) {
html += `<div><strong>字符数:</strong>${escapeHtml(String(result.char_count))}</div>`;
}
html += '</div>';
if (!result?.success && result?.error) {
html += `<div class="tool-result-error">${escapeHtml(result.error)}</div>`;
}
if (result?.too_long && result?.path) {
html += '<div class="tool-result-warning">内容太长,已保存到文件,请分段或查找阅读。</div>';
}
if (result?.content) {
html += '<div class="tool-result-content scrollable">';
html += '<div class="content-label">回顾内容:</div>';
html += `<pre>${escapeHtml(result.content)}</pre>`;
html += '</div>';
}
return html;
}
function renderCreateSkill(result: any, args: any): string {
const status = formatToolStatusLabel(result, '✓ 已归档');
let html = '<div class="tool-result-meta">';
html += `<div><strong>状态:</strong>${status}</div>`;
if (result?.skill_name) {
html += `<div><strong>Skill</strong>${escapeHtml(result.skill_name)}</div>`;
}
html += '</div>';
if (!result?.success && result?.error) {
html += `<div class="tool-result-error">${escapeHtml(result.error)}</div>`;
}
if (result?.sync_warning) {
html += `<div class="tool-result-warning">${escapeHtml(result.sync_warning)}</div>`;
}
return html;
}
function formatPersonalizationFieldLabel(field: string): string { function formatPersonalizationFieldLabel(field: string): string {
const labelMap: Record<string, string> = { const labelMap: Record<string, string> = {
enabled: '个性化功能', enabled: '个性化功能',

View File

@ -65,9 +65,11 @@ export function renderEnhancedToolResult(
return renderRenameFile(result, args); return renderRenameFile(result, args);
} }
// 阅读聚焦 // 阅读聚焦 / Skills
else if (name === 'read_file' || name === 'read_skill') { else if (name === 'read_file' || name === 'read_skill') {
return renderReadFile(result, args); return renderReadFile(result, args);
} else if (name === 'create_skill') {
return renderCreateSkill(result, args);
} else if (name === 'vlm_analyze') { } else if (name === 'vlm_analyze') {
return renderVlmAnalyze(result, args); return renderVlmAnalyze(result, args);
} else if (name === 'ocr_image') { } else if (name === 'ocr_image') {
@ -97,6 +99,10 @@ export function renderEnhancedToolResult(
// 记忆类 // 记忆类
else if (name === 'update_memory') { else if (name === 'update_memory') {
return renderUpdateMemory(result, args); 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') { else if (name === 'manage_personalization') {
@ -647,6 +653,90 @@ function renderUpdateMemory(result: any, args: any): string {
return html; 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 = '<div class="tool-result-meta">';
html += `<div><strong>关键词:</strong>${escapeHtml(keywordText)}</div>`;
html += `<div><strong>日期范围:</strong>${escapeHtml(result?.start_date || args?.start_date || '不限')} ~ ${escapeHtml(result?.end_date || args?.end_date || '不限')}</div>`;
html += '<div><strong>范围:</strong>当前工作区历史对话(已排除当前对话)</div>';
html += `<div><strong>结果数量:</strong>${results.length}</div>`;
html += '</div>';
if (!results.length) {
html += '<div class="tool-result-empty">未找到匹配对话。</div>';
return html;
}
html += '<div class="search-result-list">';
results.forEach((item: any) => {
html += '<div class="search-result-item">';
html += `<div class="search-result-title">${escapeHtml(item.title || '未命名对话')}</div>`;
html += `<div><strong>ID</strong>${escapeHtml(item.id || '')}</div>`;
html += `<div><strong>规模:</strong>${escapeHtml(String(item.total_messages || 0))} 条消息,${escapeHtml(String(item.total_tools || 0))} 个工具</div>`;
if (item.first_user_message) {
html += `<div><strong>首条用户消息:</strong>${escapeHtml(item.first_user_message)}</div>`;
}
if (item.updated_at) {
html += `<div><strong>更新时间:</strong>${escapeHtml(item.updated_at)}</div>`;
}
html += '</div>';
});
html += '</div>';
return html;
}
function renderConversationReview(result: any, args: any): string {
const status = formatToolStatusLabel(result, result?.mode === 'read' ? '✓ 已返回' : '✓ 已生成');
let html = '<div class="tool-result-meta">';
html += `<div><strong>对话 ID</strong>${escapeHtml(result?.conversation_id || args?.conversation_id || '')}</div>`;
html += `<div><strong>状态:</strong>${status}</div>`;
html += `<div><strong>模式:</strong>${escapeHtml(result?.mode || args?.mode || '')}</div>`;
if (result?.title) {
html += `<div><strong>标题:</strong>${escapeHtml(result.title)}</div>`;
}
if (result?.path) {
html += `<div><strong>回顾文件:</strong>${escapeHtml(result.path)}</div>`;
}
if (result?.char_count !== undefined) {
html += `<div><strong>字符数:</strong>${escapeHtml(String(result.char_count))}</div>`;
}
html += '</div>';
if (!result?.success && result?.error) {
html += `<div class="tool-result-error">${escapeHtml(result.error)}</div>`;
}
if (result?.too_long && result?.path) {
html += '<div class="tool-result-warning">内容太长,已保存到文件,请分段或查找阅读。</div>';
}
if (result?.content) {
html += '<div class="tool-result-content scrollable">';
html += '<div class="content-label">回顾内容:</div>';
html += `<pre>${escapeHtml(result.content)}</pre>`;
html += '</div>';
}
return html;
}
function renderCreateSkill(result: any, args: any): string {
const status = formatToolStatusLabel(result, '✓ 已归档');
let html = '<div class="tool-result-meta">';
html += `<div><strong>状态:</strong>${status}</div>`;
if (result?.skill_name) {
html += `<div><strong>Skill</strong>${escapeHtml(result.skill_name)}</div>`;
}
html += '</div>';
if (!result?.success && result?.error) {
html += `<div class="tool-result-error">${escapeHtml(result.error)}</div>`;
}
if (result?.sync_warning) {
html += `<div class="tool-result-warning">${escapeHtml(result.sync_warning)}</div>`;
}
return html;
}
function formatPersonalizationFieldLabel(field: string): string { function formatPersonalizationFieldLabel(field: string): string {
const labelMap: Record<string, string> = { const labelMap: Record<string, string> = {
self_identify: 'AI 自称', self_identify: 'AI 自称',

View File

@ -723,6 +723,74 @@
<span>自动注入 AGENTS.md 到系统提示词</span> <span>自动注入 AGENTS.md 到系统提示词</span>
</label> </label>
</div> </div>
<div class="behavior-field">
<div class="behavior-field-header">
<span class="field-title">最近对话提示</span>
<p class="field-desc">
开启后系统会把当前工作区最近若干个非空对话的标题和首条用户消息注入到系统提示词中帮助智能体主动判断是否需要回顾历史
</p>
</div>
<label class="toggle-row">
<input
type="checkbox"
:checked="form.recent_conversations_prompt_enabled"
@change="
personalization.updateField({
key: 'recent_conversations_prompt_enabled',
value: $event.target.checked
})
"
/>
<span class="fancy-check" aria-hidden="true">
<svg viewBox="0 0 64 64">
<path
d="M 0 16 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 16 L 32 48 L 64 16 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 16"
pathLength="575.0541381835938"
class="fancy-path"
></path>
</svg>
</span>
<span>在提示词中加入最近对话</span>
</label>
<div
v-if="form.recent_conversations_prompt_enabled"
class="compression-custom-grid"
>
<label class="compression-input-row">
<span>注入数量</span>
<input
type="number"
:min="recentConversationsPromptLimitRange.min"
:max="recentConversationsPromptLimitRange.max"
step="1"
placeholder="默认 10"
:value="form.recent_conversations_prompt_limit"
@input="handleRecentConversationsPromptLimitInput"
@change="commitRecentConversationsPromptLimitInput"
@blur="commitRecentConversationsPromptLimitInput"
@focus="personalization.clearFeedback()"
/>
</label>
<p class="field-desc compression-custom-hint">
范围 {{ recentConversationsPromptLimitRange.min }}~{{
recentConversationsPromptLimitRange.max
}}
空对话不会注入
</p>
</div>
<div
v-if="form.recent_conversations_prompt_enabled"
class="compression-actions-row"
>
<button
type="button"
class="compression-reset-btn"
@click.prevent="restoreRecentConversationsPromptLimit"
>
恢复默认
</button>
</div>
</div>
<div class="behavior-field"> <div class="behavior-field">
<div class="behavior-field-header"> <div class="behavior-field-header">
<span class="field-title">允许根目录创建文件</span> <span class="field-title">允许根目录创建文件</span>
@ -1528,6 +1596,7 @@ const {
skillsCatalog, skillsCatalog,
thinkingIntervalDefault, thinkingIntervalDefault,
thinkingIntervalRange, thinkingIntervalRange,
recentConversationsPromptLimitRange,
experiments experiments
} = storeToRefs(personalization); } = storeToRefs(personalization);
@ -1985,6 +2054,31 @@ const restoreThinkingInterval = () => {
personalization.setThinkingInterval(null); 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 handleCompressionNumberInput = (key: CompressionField, event: Event) => {
const target = event.target as HTMLInputElement | null; const target = event.target as HTMLInputElement | null;
if (!target || !target.value) { if (!target || !target.value) {

View File

@ -9,6 +9,8 @@ interface PersonalForm {
enabled: boolean; enabled: boolean;
communication_style: 'default' | 'human_like'; communication_style: 'default' | 'human_like';
auto_generate_title: boolean; auto_generate_title: boolean;
recent_conversations_prompt_enabled: boolean;
recent_conversations_prompt_limit: number | string;
tool_intent_enabled: boolean; tool_intent_enabled: boolean;
skill_hints_enabled: boolean; skill_hints_enabled: boolean;
skill_strict_terminal_enabled: boolean; skill_strict_terminal_enabled: boolean;
@ -67,11 +69,14 @@ interface PersonalizationState {
skillsCatalog: Array<{ id: string; label: string; description?: string }>; skillsCatalog: Array<{ id: string; label: string; description?: string }>;
thinkingIntervalDefault: number; thinkingIntervalDefault: number;
thinkingIntervalRange: { min: number; max: number }; thinkingIntervalRange: { min: number; max: number };
recentConversationsPromptLimitRange: { min: number; max: number };
experiments: ExperimentState; experiments: ExperimentState;
} }
const DEFAULT_INTERVAL = 10; const DEFAULT_INTERVAL = 10;
const DEFAULT_INTERVAL_RANGE = { min: 1, max: 50 }; 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_SHALLOW_COMPRESS_TRIGGER_TOKENS = 80000;
const DEFAULT_DEEP_COMPRESS_TRIGGER_TOKENS = 150000; const DEFAULT_DEEP_COMPRESS_TRIGGER_TOKENS = 150000;
const RUN_MODE_OPTIONS: RunMode[] = ['fast', 'thinking', 'deep']; const RUN_MODE_OPTIONS: RunMode[] = ['fast', 'thinking', 'deep'];
@ -90,6 +95,8 @@ const defaultForm = (): PersonalForm => ({
enabled: false, enabled: false,
communication_style: 'default', communication_style: 'default',
auto_generate_title: true, auto_generate_title: true,
recent_conversations_prompt_enabled: false,
recent_conversations_prompt_limit: DEFAULT_RECENT_CONVERSATIONS_PROMPT_LIMIT,
tool_intent_enabled: true, tool_intent_enabled: true,
skill_hints_enabled: false, skill_hints_enabled: false,
skill_strict_terminal_enabled: false, skill_strict_terminal_enabled: false,
@ -181,6 +188,7 @@ export const usePersonalizationStore = defineStore('personalization', {
skillsCatalog: [], skillsCatalog: [],
thinkingIntervalDefault: DEFAULT_INTERVAL, thinkingIntervalDefault: DEFAULT_INTERVAL,
thinkingIntervalRange: { ...DEFAULT_INTERVAL_RANGE }, thinkingIntervalRange: { ...DEFAULT_INTERVAL_RANGE },
recentConversationsPromptLimitRange: { ...DEFAULT_RECENT_CONVERSATIONS_PROMPT_LIMIT_RANGE },
experiments: loadExperimentState() experiments: loadExperimentState()
}), }),
actions: { actions: {
@ -244,6 +252,9 @@ export const usePersonalizationStore = defineStore('personalization', {
enabled: !!data.enabled, enabled: !!data.enabled,
communication_style: data.communication_style === 'human_like' ? 'human_like' : 'default', communication_style: data.communication_style === 'human_like' ? 'human_like' : 'default',
auto_generate_title: data.auto_generate_title !== false, 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, tool_intent_enabled: !!data.tool_intent_enabled,
skill_hints_enabled: !!data.skill_hints_enabled, skill_hints_enabled: !!data.skill_hints_enabled,
skill_strict_terminal_enabled: !!data.skill_strict_terminal_enabled, skill_strict_terminal_enabled: !!data.skill_strict_terminal_enabled,
@ -343,6 +354,19 @@ export const usePersonalizationStore = defineStore('personalization', {
} }
return rounded; 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) { applyPersonalizationMeta(payload: any) {
if (payload && typeof payload.thinking_interval_default === 'number') { if (payload && typeof payload.thinking_interval_default === 'number') {
this.thinkingIntervalDefault = payload.thinking_interval_default; this.thinkingIntervalDefault = payload.thinking_interval_default;
@ -358,6 +382,17 @@ export const usePersonalizationStore = defineStore('personalization', {
} else { } else {
this.thinkingIntervalRange = { ...DEFAULT_INTERVAL_RANGE }; 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)) { if (payload && Array.isArray(payload.tool_categories)) {
this.toolCategories = payload.tool_categories this.toolCategories = payload.tool_categories
.map((item: { id?: string; label?: string } = {}) => ({ .map((item: { id?: string; label?: string } = {}) => ({
@ -497,6 +532,17 @@ export const usePersonalizationStore = defineStore('personalization', {
}; };
this.clearFeedback(); 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) { toggleDefaultToolCategory(categoryId: string) {
if (!categoryId) { if (!categoryId) {
return; return;

View File

@ -6,6 +6,7 @@ const RUNNING_ANIMATIONS: Record<string, string> = {
create_file: 'file-animation', create_file: 'file-animation',
read_file: 'read-animation', read_file: 'read-animation',
read_skill: 'read-animation', read_skill: 'read-animation',
create_skill: 'file-animation',
delete_file: 'file-animation', delete_file: 'file-animation',
rename_file: 'file-animation', rename_file: 'file-animation',
write_file: 'file-animation', write_file: 'file-animation',
@ -44,7 +45,8 @@ const RUNNING_STATUS_TEXTS: Record<string, string> = {
terminal_session: '正在管理终端会话...', terminal_session: '正在管理终端会话...',
terminal_input: '调用 terminal_input', terminal_input: '调用 terminal_input',
terminal_snapshot: '正在获取终端快照...', terminal_snapshot: '正在获取终端快照...',
read_skill: '正在读取技能...' read_skill: '正在读取技能...',
create_skill: '正在归档技能...'
}; };
const COMPLETED_STATUS_TEXTS: Record<string, string> = { const COMPLETED_STATUS_TEXTS: Record<string, string> = {
@ -65,7 +67,8 @@ const COMPLETED_STATUS_TEXTS: Record<string, string> = {
terminal_session: '终端操作完成', terminal_session: '终端操作完成',
terminal_input: '终端输入完成', terminal_input: '终端输入完成',
terminal_snapshot: '终端快照已返回', terminal_snapshot: '终端快照已返回',
read_skill: '技能读取完成' read_skill: '技能读取完成',
create_skill: '技能归档完成'
}; };
const LANGUAGE_CLASS_MAP: Record<string, string> = { const LANGUAGE_CLASS_MAP: Record<string, string> = {

View File

@ -47,6 +47,7 @@ export const ICONS = Object.freeze({
export const TOOL_ICON_MAP = Object.freeze({ export const TOOL_ICON_MAP = Object.freeze({
close_sub_agent: 'bot', close_sub_agent: 'bot',
create_file: 'file', create_file: 'file',
create_skill: 'sparkles',
manage_personalization: 'userPen', manage_personalization: 'userPen',
create_folder: 'folder', create_folder: 'folder',
create_sub_agent: 'bot', create_sub_agent: 'bot',
@ -70,6 +71,8 @@ export const TOOL_ICON_MAP = Object.freeze({
terminal_snapshot: 'clipboard', terminal_snapshot: 'clipboard',
unfocus_file: 'eye', unfocus_file: 'eye',
update_memory: 'brain', update_memory: 'brain',
conversation_search: 'search',
conversation_review: 'book',
get_sub_agent_status: 'bot', get_sub_agent_status: 'bot',
web_search: 'search', web_search: 'search',
trigger_easter_egg: 'sparkles', trigger_easter_egg: 'sparkles',
@ -84,6 +87,7 @@ export const TOOL_CATEGORY_ICON_MAP = Object.freeze({
file_edit: 'pencil', file_edit: 'pencil',
personalization: 'userPen', personalization: 'userPen',
read_focus: 'eye', read_focus: 'eye',
skills: 'sparkles',
terminal_realtime: 'monitor', terminal_realtime: 'monitor',
terminal_command: 'terminal', terminal_command: 'terminal',
memory: 'brain', memory: 'brain',

View File

@ -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()

View File

@ -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()

View File

@ -97,7 +97,7 @@ class ContextManager:
self._shallow_compact_round: int = 0 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.media_store = MediaStore(self.data_dir)
self.current_conversation_id: Optional[str] = None self.current_conversation_id: Optional[str] = None
self.auto_save_enabled = True self.auto_save_enabled = True
@ -749,12 +749,16 @@ class ContextManager:
# 同步 skills每次新对话覆盖镜像 # 同步 skills每次新对话覆盖镜像
try: try:
from modules.personalization_manager import load_personalization_config 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) personalization_config = getattr(self, "custom_personalization_config", None) or load_personalization_config(self.data_dir)
enabled_skills = None enabled_skills = None
if isinstance(personalization_config, dict): if isinstance(personalization_config, dict):
enabled_skills = personalization_config.get("enabled_skills") 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: except Exception as exc:
print(f"[Skills] 同步失败: {exc}") print(f"[Skills] 同步失败: {exc}")
@ -997,18 +1001,29 @@ class ContextManager:
files.sort(key=lambda p: p.name.lower()) files.sort(key=lambda p: p.name.lower())
selected = (folders + files)[:max(0, limit)] 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)) relative_path = str(entry.relative_to(self.project_path))
if display_path:
relative_path = display_path
if entry.is_dir(): if entry.is_dir():
structure["folders"].append({ structure["folders"].append({
"name": entry.name, "name": entry.name,
"path": relative_path "path": relative_path
}) })
structure["tree"][entry.name] = { parts = relative_path.split("/")
parent = structure["tree"]
for idx, part in enumerate(parts):
is_leaf = idx == len(parts) - 1
parent.setdefault(part, {
"type": "folder", "type": "folder",
"path": relative_path, "path": "/".join(parts[:idx + 1]),
"children": {} "children": {}
} })
if is_leaf:
parent[part]["type"] = "folder"
parent[part]["path"] = relative_path
parent[part].setdefault("children", {})
parent = parent[part].setdefault("children", {})
else: else:
try: try:
size = entry.stat().st_size size = entry.stat().st_size
@ -1029,6 +1044,16 @@ class ContextManager:
"size": size, "size": size,
"annotation": file_info["annotation"] "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 return structure
def save_current_conversation(self) -> bool: def save_current_conversation(self) -> bool:

View File

@ -10,14 +10,14 @@ from pathlib import Path
from typing import Dict, List, Optional, Any from typing import Dict, List, Optional, Any
from dataclasses import dataclass from dataclasses import dataclass
try: try:
from config import DATA_DIR from config import DATA_DIR, HOST_WORKSPACES_FILE
except ImportError: except ImportError:
import sys import sys
from pathlib import Path from pathlib import Path
project_root = Path(__file__).resolve().parents[1] project_root = Path(__file__).resolve().parents[1]
if str(project_root) not in sys.path: if str(project_root) not in sys.path:
sys.path.insert(0, str(project_root)) sys.path.insert(0, str(project_root))
from config import DATA_DIR from config import DATA_DIR, HOST_WORKSPACES_FILE
@dataclass @dataclass
class ConversationMetadata: class ConversationMetadata:
@ -40,27 +40,125 @@ class ConversationMetadata:
class ConversationManager: 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.base_dir = Path(base_dir).expanduser().resolve() if base_dir else Path(DATA_DIR).resolve()
self.conversations_dir = self.base_dir / "conversations" self.conversations_root = self.base_dir / "conversations"
self.index_file = self.conversations_dir / "index.json"
self._io_lock = threading.RLock() self._io_lock = threading.RLock()
self.current_conversation_id: Optional[str] = None self.current_conversation_id: Optional[str] = None
self.workspace_root = Path(__file__).resolve().parents[1] 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._ensure_directories()
self._index_verified = False self._index_verified = False
# 首次加载索引仅重建最近 20 条,降低启动开销;后续按需扩展 # 首次加载索引仅重建最近 20 条,降低启动开销;后续按需扩展
self._load_index(ensure_integrity=True, max_rebuild=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): def _ensure_directories(self):
"""确保必要的目录存在""" """确保必要的目录存在,并迁移可归类的旧版根目录对话。"""
self.base_dir.mkdir(parents=True, exist_ok=True) 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) self.conversations_dir.mkdir(parents=True, exist_ok=True)
# 如果索引文件不存在,创建空索引 # 如果当前工作区索引文件不存在,创建空索引
if not self.index_file.exists(): if not self.index_file.exists():
self._save_index({}) 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): def _iter_conversation_files(self, sort_by_mtime: bool = True):
"""遍历对话文件(排除索引文件),可按修改时间降序排序。""" """遍历对话文件(排除索引文件),可按修改时间降序排序。"""
files = [] files = []
@ -269,6 +367,38 @@ class ConversationManager:
return title return title
return "新对话" 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: def _count_tools_in_messages(self, messages: List[Dict]) -> int:
"""统计消息中的工具调用数量(仅统计 assistant.tool_calls""" """统计消息中的工具调用数量(仅统计 assistant.tool_calls"""
tool_count = 0 tool_count = 0
@ -913,6 +1043,107 @@ class ConversationManager:
"has_more": False "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: def delete_conversation(self, conversation_id: str) -> bool:
""" """
删除对话 删除对话

View File

@ -34,4 +34,3 @@ def write_host_workspace_debug(event: str, **payload: Any) -> None:
def get_host_workspace_debug_log_path() -> str: def get_host_workspace_debug_log_path() -> str:
return str(_LOG_FILE) return str(_LOG_FILE)

View File

@ -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): if function_name == "write_file_diff" and isinstance(result_data, dict):
return _format_write_file_diff(result_data, raw_text) 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): if not isinstance(result_data, dict):
return raw_text 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 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: def _format_create_file(result_data: Dict[str, Any]) -> str:
if not result_data.get("success"): if not result_data.get("success"):
return _format_failure("create_file", result_data) return _format_failure("create_file", result_data)
@ -1060,6 +1149,7 @@ TOOL_FORMATTERS = {
"todo_create": _format_todo_create, "todo_create": _format_todo_create,
"todo_update_task": _format_todo_update_task, "todo_update_task": _format_todo_update_task,
"update_memory": _format_update_memory, "update_memory": _format_update_memory,
"create_skill": _format_create_skill,
"manage_personalization": _format_manage_personalization, "manage_personalization": _format_manage_personalization,
"create_sub_agent": _format_create_sub_agent, "create_sub_agent": _format_create_sub_agent,
"close_sub_agent": _format_close_sub_agent, "close_sub_agent": _format_close_sub_agent,