From 3fd2214da04c6c15c6a1e45ad46b2a3c568958ff Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Sat, 30 May 2026 20:56:15 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat(compression):=20=E6=B7=B1=E5=8E=8B?= =?UTF-8?q?=E7=BC=A9=E6=94=B9=E4=B8=BA=20in-place=20=E6=A0=87=E8=AE=B0?= =?UTF-8?q?=E5=89=8D=E7=BC=80=EF=BC=8C=E4=B8=8D=E5=86=8D=E5=88=87=E6=8D=A2?= =?UTF-8?q?=E6=96=B0=E5=AF=B9=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 深压缩从"建新对话+切过去"重构为标记当前对话历史前缀(deep_compacted): 原文保留并照常显示,仅在 build_messages 构建请求时排除。conversation_id 不变,避免任务状态/目标模式/前端对话切换的大量适配带来的 bug。 - deep_compression.py: _mark_history_compacted 打标 + 重置 current_context_tokens 防自动续接死循环;总结请求传入正常 tools 以 100% 命中前缀缓存 - context.py build_messages: 跳过 deep_compacted 消息 - conversation.py /compress: 去掉切对话,按 compress_behavior 决定续接/等待 - 新增个性化设置 deep_compress_form(file/inject) 与 deep_compress_behavior(continue/wait,仅手动压缩生效) - 前端去掉强制切换对话,改为重载当前对话刷新展示 - AGENTS.md/CLAUDE.md: 补充默认中文交流约定等说明 Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 2 + CLAUDE.md | 136 +++++++-- core/main_terminal_parts/context.py | 9 + modules/personalization_manager.py | 16 + server/conversation.py | 43 ++- server/deep_compression.py | 276 +++++++++++------- static/src/app/methods/message.ts | 9 +- static/src/app/methods/taskPolling.ts | 10 +- .../personalization/PersonalizationDrawer.vue | 44 +++ static/src/stores/personalization.ts | 6 + 10 files changed, 403 insertions(+), 148 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e0ea02b..286cfdd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,6 +5,8 @@ 这份文档基于当前仓库实际代码重写。若与未来代码冲突,以代码为准并及时更新本文档。 +> 和用户交流时默认使用中文。 + ## 1) 当前项目结构(按代码现状) - **后端入口** diff --git a/CLAUDE.md b/CLAUDE.md index ac2788a..6546d8c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +> 和用户交流时默认使用中文。 + ## Project Overview 多用户 AI Agent 系统,为每个登录用户提供独立的 Docker 容器环境。支持终端交互、文件操作、对话管理和实时监控。主要用于学习和实验"智能体 + 真实 Dev Workflow",代码大量由 AI 生成。 @@ -9,14 +11,22 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Core Architecture ### Entry Points -- **CLI模式**: `python main.py` - 启动命令行交互式 Agent -- **Web模式**: `python web_server.py` - 启动 Flask Web 服务器(主链路为 REST 任务轮询,默认端口 8091) +- **统一入口**: `python main.py` - 当前实现默认进入 Web 启动流程(thinking_mode=True) +- **推荐 Web 入口**: `python -m server.app` - 封装并转发到 `server/app_legacy.py`,主链路为 REST 任务轮询,默认端口 8091 +- **兼容入口**: `python web_server.py` - 已标记 deprecated,但仍可启动 ### Key Components +**server/** (Flask 业务主线,chat/task 以 REST 任务轮询为主,Socket.IO 用于兼容与实时辅助通道) +- `app.py` / `app_legacy.py` - Web 服务入口与遗留实现 +- `chat.py` / `chat_flow*.py` - 对话与任务流编排(runner / tool_loop / stream_loop / task_main 等拆分) +- `goal_flow.py` - 自主目标循环 +- `deep_compression.py` - 上下文深度压缩 +- `tasks.py` / `monitor.py` / `conversation.py` / `files.py` / `auth.py` / `admin.py` - 任务、监控、对话、文件、认证、管理接口 + **core/** -- `MainTerminal` - CLI 主终端,处理用户输入和 AI 对话循环 -- `WebTerminal` - Web 终端,继承 MainTerminal,支持轮询任务与实时通道兼容能力 +- `MainTerminal` (`main_terminal.py`) - CLI 主终端,处理用户输入和 AI 对话循环;逻辑拆分到 `core/main_terminal_parts/*`(commands / context / tools / tools_execution / tools_policy / tools_read 等) +- `WebTerminal` (`web_terminal.py`) - Web 终端,继承 MainTerminal,支持轮询任务与实时通道兼容能力 - `tool_config.py` - 定义所有工具的配置和分类 **modules/** @@ -37,7 +47,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - `terminal_factory.py` - 终端类型工厂 **static/** -- Vue 前端(聊天主链路使用 REST 任务轮询,保留 Socket.IO 兼容能力),包含对话流、终端输出、资源监控面板 +- Vue 3 + TS 前端(`static/src/`,分层为 `app/` `stores/` `components/` `composables/`),聊天主链路使用 REST 任务轮询,保留 Socket.IO 兼容能力,包含对话流、终端输出、资源监控面板 +- 监控动画核心文件:`static/src/components/chat/monitor/MonitorDirector.ts`(动画与场景执行)、`static/src/stores/monitor.ts`(事件队列与状态机) + +**cli/** +- React 19 + Ink 6 + TypeScript CLI 前端(正在重写中),连接现有本地 Web API(默认 `127.0.0.1:8091`),不是独立 agent runtime +- 关键文件:`cli/src/App.tsx`、`cli/src/components.tsx`、`cli/src/eventMapper.ts`、`cli/src/api.ts` **docker/** - `terminal.Dockerfile` - 终端容器镜像构建配置 @@ -46,22 +61,62 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ### Build & Run ```bash -# 安装依赖(需要在项目根目录创建 requirements.txt) -pip install flask flask-socketio flask-cors openai python-dotenv tiktoken lxml +# 安装 Python 依赖 +pip install -r requirements.txt # 构建终端镜像 docker build -f docker/terminal.Dockerfile -t my-agent-shell:latest . -# CLI 模式 +# 统一入口(默认进入 Web 启动流程) python main.py -# Web 模式 +# 推荐 Web 启动 +python -m server.app +# 可选参数 +python -m server.app --path ./project --port 8091 --debug --thinking-mode + +# 兼容启动方式(deprecated) python web_server.py -# 或指定端口和思考模式 -python web_server.py --port 8091 --thinking-mode ``` -### Testing & Debugging +### Frontend (根目录 Vue) +```bash +npm install +# 构建(默认只看最后 5 行,减少 Vite 构建资源列表等无关输出) +npm run build --silent 2>&1 | tail -n 5 +# 开发监听(当前脚本是 build watch) +npm run dev +# Lint +npm run lint +``` + +### CLI (React / Ink) +```bash +npm --prefix cli install +# 开发启动 +npm run cli # 或 npm --prefix cli run dev +# 构建 +npm run cli:build +# 类型检查 +npm run cli:typecheck +# 可执行命令名(构建后):agents / agents-cli +``` + +### Testing +```bash +# 主要离线冒烟用例(unittest) +python -m unittest test.test_server_refactor_smoke + +# 改动后端接口适配(如 server/chat.py)时补充 +python3 -m py_compile server/chat.py + +# CLI 最小可复现验证 +npm --prefix cli run typecheck +npm --prefix cli run build +``` +> 仓库未发现 `pytest.ini`/`pyproject.toml`/`tox.ini`,不要默认要求 `pytest` 作为唯一入口。`test/test_system_message.py` 依赖外部 `MOONSHOT_API_KEY` 与网络,不属于离线稳定用例。 + +### Debugging ```bash # 查看容器状态 docker ps -a | grep agent-term @@ -139,9 +194,18 @@ tail -f logs/container_stats.log ### Adding New Tools -1. 在 `MainTerminal` 添加 `handle_` 方法 -2. 在 `core/tool_config.py` 的 `define_tools()` 中注册工具 -3. 如果需要在流式输出中处理,在 `web_server.py` 添加特殊逻辑 +1. 在 `core/main_terminal_parts/tools_execution.py`(及相关 `tools_*.py`)实现执行逻辑 +2. 在 `core/tool_config.py` / `core/main_terminal_parts/tools_definition.py` 中注册工具定义 +3. 如需在任务流/流式输出中特殊处理,在 `server/chat_flow*.py` 对应环节添加逻辑 + +### 代码修改约定 + +- **最小改动原则**:只改与需求直接相关文件,避免顺手重构。 +- **文件编辑方式**:优先使用原生文件编辑工具,尽量不要用 `bash`/`python` 脚本批量改文件。 +- **后端改动优先级**:先改 `modules/`、`server/` 内对应模块,最后才动入口。 +- **前端改动优先级**:按 `static/src` 现有分层改(`app/` `stores/` `components/` `composables/`)。 +- **CLI 改动优先级**:优先在 `cli/src/App.tsx`、`cli/src/components.tsx`、`cli/src/eventMapper.ts`、`cli/src/api.ts` 内做最小闭环修改。 +- 涉及 monitor 动画/事件联动时,至少同步检查 `MonitorDirector.ts` 与 `stores/monitor.ts`。 ### Working with Containers @@ -192,16 +256,21 @@ terminal.delete_conversation(conversation_id) ``` agents/ -├── core/ # 核心终端和工具配置 +├── server/ # Flask 业务主线(chat/task/goal/monitor/auth ...) +├── core/ # 核心终端与工具编排(main_terminal_parts/*) ├── modules/ # 独立功能模块 ├── utils/ # 辅助工具 -├── config/ # 配置文件 -├── static/ # Web 前端 +├── config/ # 配置拆分(api/limits/terminal/paths ...,由 __init__ 聚合) +├── static/ # Vue 3 + TS Web 前端 +├── cli/ # React + Ink CLI 前端(重写中) ├── docker/ # 容器镜像 ├── prompts/ # 系统提示词 -├── users/ # 用户工作区 -├── data/ # 全局数据 -└── logs/ # 日志文件 +├── android-webview-app/ # Android WebView 客户端工程 +├── test/ # 测试用例 +├── docs/ / doc/ # 设计与发布文档 +├── users/ # 用户工作区(运行态,gitignored) +├── data/ # 全局数据(运行态,gitignored) +└── logs/ # 日志文件(运行态,gitignored) ``` ## Development Tips @@ -209,9 +278,32 @@ agents/ - 修改系统提示词:编辑 `prompts/main_system.txt` - 调整工具限制:修改 `config/limits.py` - 容器镜像定制:编辑 `docker/terminal.Dockerfile` 并重新构建 -- 前端开发:`npm install && npm run dev` (在 `static/` 目录) +- 前端开发:在项目根目录运行 `npm install && npm run dev`(build watch) - 查看详细执行流程:监控 `logs/debug_stream.log` +## Permission Mode & Sandbox Execution (2026-05) + +完整说明文档:`docs/host_sandbox_and_permission_model.md` + +系统有两套独立但叠加的控制: + +### 权限模式 (Permission Mode) +- `readonly` - `run_command` 在只读沙箱执行;`write_file`/`edit_file` 等写入类工具直接拒绝 +- `approval` - `run_command` 先走只读沙箱,权限拒绝(如 `Operation not permitted`/`Permission denied`)时触发前端审批,通过后仅该次命令以可写沙箱重试 +- `auto_approval` - 工作区内写入直接执行,工作区外写入进入审批;`run_command` 权限拒绝由后台审批智能体自动审核 +- `unrestricted` - 不做权限模式拦截,是否沙箱由执行环境决定 + +### 执行环境 (Execution Mode) +- `sandbox`(默认)- OS 沙箱执行(macOS: `sandbox-exec`,Linux: `bwrap + seccomp`,Windows: `WSL2`),支持会话级 TTL 自动回退 `direct -> sandbox` +- `direct` - 宿主机直接执行(高风险,仅建议短时使用) + +### 路径授权语义 +- 前端路径授权分「可读可写」与「仅可读」:可读集合 = 可读可写 + 仅可读,可写集合 = 可读可写 + +### 自动审批智能体 +- 配置文件:`config/auto_approval.json`(`name`/`url`/`key`/`model`/`extra_params`,及 `timeout_seconds`/`max_rounds`/`max_command_timeout`) +- 调试开关:`modules/approval_agent.py` 中 `DEBUG_SAVE_APPROVAL_AGENT_TRANSCRIPT`,开启后写入 `logs/approval_agent/` + ## Android Release Notes (Required for frontend update) 当改动前端并准备发 Android WebView 壳版本时,必须同步完成: diff --git a/core/main_terminal_parts/context.py b/core/main_terminal_parts/context.py index d6d0d81..8c2efd3 100644 --- a/core/main_terminal_parts/context.py +++ b/core/main_terminal_parts/context.py @@ -445,8 +445,15 @@ class MainTerminalContextMixin: # 添加对话历史(保留完整结构,包括tool_calls和tool消息) conversation = context["conversation"] replaced_tool_count = 0 + deep_compacted_skipped = 0 for idx, conv in enumerate(conversation): metadata = conv.get("metadata") or {} + # 深压缩:被标记为 deep_compacted 的消息(整段已压缩前缀)原文保留在历史中用于展示, + # 但构建请求时整体跳过,不纳入上下文。由于按连续前缀整体标记, + # 不会出现 assistant.tool_calls 与其 tool 响应被拆散导致的配对悬空。 + if metadata.get("deep_compacted"): + deep_compacted_skipped += 1 + continue if conv["role"] == "assistant": # Assistant消息可能包含工具调用 message = { @@ -523,6 +530,8 @@ class MainTerminalContextMixin: # 当前用户输入已经在conversation中了,不需要重复添加 if shallow_replace_enabled: print(f"[ContextCompression] build_messages 替换tool占位符: {replaced_tool_count} 条") + if deep_compacted_skipped: + print(f"[ContextCompression] build_messages 跳过已深压缩消息: {deep_compacted_skipped} 条") return messages def _load_agents_md_content(self) -> Optional[str]: diff --git a/modules/personalization_manager.py b/modules/personalization_manager.py index 26d9e6c..4eda971 100644 --- a/modules/personalization_manager.py +++ b/modules/personalization_manager.py @@ -87,6 +87,8 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = { "shallow_compress_trigger_tool_calls_interval": None, "shallow_compress_keep_user_turn_tools": None, # 保留最近N次用户输入后的工具不压缩(默认3) "deep_compress_trigger_tokens": None, + "deep_compress_form": "file", # 深压缩形式:file-生成文件 / inject-直接注入文件全文 + "deep_compress_behavior": "continue", # 手动压缩行为:continue-注入并触发请求 / wait-仅插入等待用户 "silent_tool_disable": True, # 禁用工具时不向模型插入提示(默认开启) "enhanced_tool_display": True, # 增强工具显示 "versioning_restore_mode": "overwrite", # 版本回溯模式固定为 overwrite @@ -371,6 +373,20 @@ def sanitize_personalization_payload( max_value=MAX_COMPRESSION_TRIGGER_TOKENS, ) + # 深压缩形式:file(生成文件,引导语提示位置)/ inject(直接注入文件全文) + deep_form = data.get("deep_compress_form", base.get("deep_compress_form")) + if isinstance(deep_form, str) and deep_form.strip().lower() in ("file", "inject"): + base["deep_compress_form"] = deep_form.strip().lower() + else: + base["deep_compress_form"] = "file" + + # 手动压缩行为:continue(注入并触发请求)/ wait(仅插入等待用户) + deep_behavior = data.get("deep_compress_behavior", base.get("deep_compress_behavior")) + if isinstance(deep_behavior, str) and deep_behavior.strip().lower() in ("continue", "wait"): + base["deep_compress_behavior"] = deep_behavior.strip().lower() + else: + base["deep_compress_behavior"] = "continue" + if "shallow_compress_trigger_tool_calls_interval" in data: base["shallow_compress_trigger_tool_calls_interval"] = _sanitize_optional_int( data.get("shallow_compress_trigger_tool_calls_interval"), diff --git a/server/conversation.py b/server/conversation.py index 803c483..5322b94 100644 --- a/server/conversation.py +++ b/server/conversation.py @@ -1147,7 +1147,7 @@ def restore_conversation_versioning_checkpoint(conversation_id, terminal: WebTer @api_login_required @with_terminal def compress_conversation(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str): - """深层压缩指定对话,生成 compact 文件并切换到新对话。""" + """深层压缩指定对话(in-place):生成 compact 文件、标记历史前缀为已压缩,按设置决定是否续接。""" try: policy = resolve_admin_policy(get_current_user_record()) if policy.get("ui_blocks", {}).get("block_compress_conversation"): @@ -1167,36 +1167,34 @@ def compress_conversation(conversation_id, terminal: WebTerminal, workspace: Use status_code = 404 if "不存在" in result.get("error", "") else (409 if result.get("in_progress") else 400) return jsonify(result), status_code - new_conversation_id = result["compressed_conversation_id"] - - load_result = terminal.load_conversation(new_conversation_id) - + # in-place 压缩:对话 id 不变。通知前端当前对话内容已变化(历史前缀被标记),刷新展示。 + load_result = terminal.load_conversation(normalized_id) if load_result.get("success"): socketio.emit('conversation_list_update', { 'action': 'compressed', - 'conversation_id': new_conversation_id - }, room=f"user_{username}") - socketio.emit('conversation_changed', { - 'conversation_id': new_conversation_id, - 'title': load_result.get('title', '压缩后的对话'), - 'messages_count': load_result.get('messages_count', 0) + 'conversation_id': normalized_id }, room=f"user_{username}") socketio.emit('conversation_loaded', { - 'conversation_id': new_conversation_id, + 'conversation_id': normalized_id, 'clear_ui': True }, room=f"user_{username}") + compress_behavior = str(result.get("compress_behavior") or "continue").strip().lower() response_payload = { "success": True, - "compressed_conversation_id": new_conversation_id, + "in_place": True, + "compressed_conversation_id": normalized_id, "compact_file": result.get("compact_file"), "summary_failed": result.get("summary_failed", False), "guide_message": result.get("guide_message"), + "compress_form": result.get("compress_form"), + "compress_behavior": compress_behavior, "load_result": load_result } guide_message = (result.get("guide_message") or "").strip() - if guide_message: + if guide_message and compress_behavior == "continue": + # 继续工作:创建续接任务,自动发送引导语并触发请求(同一对话)。 try: from .tasks import task_manager workspace_id = session.get("workspace_id") or "default" @@ -1205,7 +1203,7 @@ def compress_conversation(conversation_id, terminal: WebTerminal, workspace: Use workspace_id, guide_message, images=[], - conversation_id=new_conversation_id, + conversation_id=normalized_id, videos=[], message_source="compression_handoff", ) @@ -1217,6 +1215,21 @@ def compress_conversation(conversation_id, terminal: WebTerminal, workspace: Use debug_log(f"[Compression] 自动发送引导消息失败: {exc}") response_payload["auto_task_started"] = False response_payload["auto_task_error"] = str(exc) + elif guide_message and compress_behavior == "wait": + # 等待用户:只把引导语作为 user 消息追加进历史,不触发请求。 + try: + terminal.context_manager.add_conversation( + role="user", + content=guide_message, + metadata={"message_source": "compression_handoff"}, + ) + response_payload["auto_task_started"] = False + response_payload["guide_inserted"] = True + except Exception as exc: + debug_log(f"[Compression] 追加引导语消息失败: {exc}") + response_payload["auto_task_started"] = False + response_payload["guide_inserted"] = False + response_payload["guide_insert_error"] = str(exc) else: response_payload["auto_task_started"] = False diff --git a/server/deep_compression.py b/server/deep_compression.py index 96210dc..b263f93 100644 --- a/server/deep_compression.py +++ b/server/deep_compression.py @@ -6,8 +6,6 @@ from datetime import datetime from pathlib import Path from typing import Any, Dict, List, Optional, Tuple -from modules.versioning_manager import ConversationVersioningManager - SUMMARY_PROMPT = ( "由于当前对话过长,系统正在自动压缩。请你基于已有上下文输出一份可继续执行的工作总结,要求:\n" @@ -18,7 +16,9 @@ SUMMARY_PROMPT = ( "5) 工具调用中的重要结果/错误与修复\n" "6) 当前未完成事项与下一步计划(可直接执行)\n" "7) 风险与注意事项\n" - "请使用中文,结构清晰,尽量具体,不要省略关键上下文。" + "请使用中文,结构清晰,尽量具体,不要省略关键上下文。\n" + "不要考虑过往对话,只考虑当前对话任务。\n" + "禁止调用任何工具,必须直接输出总结内容。" ) GUIDE_USER_MESSAGE_TEMPLATE = "当前对话已经被自动压缩(第{count}次)。请阅读{path}并继续工作" @@ -68,6 +68,7 @@ def _normalize_deep_compression_records(metadata: Dict[str, Any]) -> List[Dict[s def _build_guide_message(*, compression_index: int, compact_file: str, previous_records: List[Dict[str, Any]]) -> str: + """生成文件模式的引导语:仅提示压缩文件位置,由模型自行阅读。""" base = GUIDE_USER_MESSAGE_TEMPLATE.format(count=compression_index, path=compact_file) if not previous_records: return base @@ -81,6 +82,41 @@ def _build_guide_message(*, compression_index: int, compact_file: str, previous_ return "\n".join(lines).strip() +def _read_compact_file_content(project_path: Path, relative_path: str) -> str: + """读取 compact 文件全文,读取失败时返回空串。""" + rel = str(relative_path or "").strip() + if not rel: + return "" + try: + target = (Path(project_path) / rel).resolve() + return target.read_text(encoding="utf-8").strip() + except Exception: + return "" + + +def _build_inject_guide_message( + *, + project_path: Path, + compression_index: int, + current_record: Dict[str, Any], + previous_records: List[Dict[str, Any]], +) -> str: + """生成直接注入模式的引导语:把历次压缩文件全文按顺序拼入正文,不提及文件位置。""" + lines: List[str] = [f"当前对话已被第{compression_index}次压缩,以下为历次压缩的完整工作总结,请据此继续工作。"] + all_records = list(previous_records) + [current_record] + for rec in all_records: + count = int(rec.get("count") or 0) + rel_path = str(rec.get("compact_file") or "").strip() + if count <= 0 or not rel_path: + continue + content = _read_compact_file_content(project_path, rel_path) + lines.append("") + lines.append(f"第{count}次压缩:") + lines.append(content or "(压缩文件内容读取失败)") + return "\n".join(lines).strip() + + + def _extract_text_only(content: Any) -> str: if content is None: return "" @@ -162,10 +198,14 @@ async def _generate_summary(web_terminal, prompt: str, retries: int = 5) -> Tupl # build_messages 不会自动附加 user_input,压缩总结必须显式注入 if prompt and isinstance(prompt, str): messages = list(messages) + [{"role": "user", "content": prompt}] + # 关键:总结请求必须与"用户正常发一条消息"完全无差别,才能 100% 命中前缀缓存。 + # 因此 tools 必须照常传入(缺失 tools 字段会改变请求前缀、破坏缓存)。 + # 模型即便返回 tool_calls 也会被忽略——我们只取 content;prompt 已要求其直接输出总结。 + tools = web_terminal.define_tools() for _ in range(max(1, retries)): try: response_text = "" - async for chunk in web_terminal.api_client.chat(messages, tools=None, stream=False): + async for chunk in web_terminal.api_client.chat(messages, tools=tools, stream=False): if not isinstance(chunk, dict): continue choices = chunk.get("choices") or [] @@ -236,6 +276,33 @@ def _write_compact_file( return str(file_path.relative_to(project_path)) +def _mark_history_compacted(history: List[Dict[str, Any]], *, round_index: int, now: str) -> int: + """把当前对话历史中所有尚未标记的消息打上 deep_compacted 标记(in-place)。 + + 深压缩按"整段前缀"处理:本次压缩时,历史里所有还没被标记的消息整体视为已压缩前缀。 + 这天然保证 assistant.tool_calls 与其 tool 响应被一并标记/排除,不会出现配对悬空。 + 原文保留在 metadata 中,持久化与重新加载时照常显示,仅在 build_messages 构建请求时被跳过。 + 返回本次新标记的消息条数。 + """ + if not isinstance(history, list): + return 0 + marked = 0 + for msg in history: + if not isinstance(msg, dict): + continue + metadata = msg.get("metadata") + if not isinstance(metadata, dict): + metadata = {} + if metadata.get("deep_compacted"): + continue + metadata["deep_compacted"] = True + metadata["deep_compacted_round"] = round_index + metadata["deep_compacted_at"] = now + msg["metadata"] = metadata + marked += 1 + return marked + + async def run_deep_compression( *, web_terminal, @@ -253,12 +320,33 @@ async def run_deep_compression( if metadata.get("compression_in_progress"): return {"success": False, "error": "对话正在压缩中", "in_progress": True} - old_title = conv_data.get("title") or "未命名" - source_versioning = metadata.get("versioning") if isinstance(metadata.get("versioning"), dict) else {} - source_versioning_enabled = bool(source_versioning.get("enabled")) - source_tracking_mode = ConversationVersioningManager.normalize_tracking_mode( - source_versioning.get("tracking_mode") - ) + # 读取个性化压缩设置: + # - compress_form: file(生成文件,引导语提示位置) / inject(把历次压缩文件全文注入引导语) + # - compress_behavior: continue(注入引导语并触发请求) / wait(仅注入引导语,等待用户) + # compress_behavior 仅作用于手动压缩;自动深压缩永远继续工作。 + try: + from modules.personalization_manager import load_personalization_config + pconfig = load_personalization_config(workspace.data_dir) or {} + except Exception: + pconfig = {} + compress_form = str(pconfig.get("deep_compress_form") or "file").strip().lower() + if compress_form not in ("file", "inject"): + compress_form = "file" + compress_behavior = str(pconfig.get("deep_compress_behavior") or "continue").strip().lower() + if compress_behavior not in ("continue", "wait"): + compress_behavior = "continue" + if mode != "manual": + compress_behavior = "continue" + + # in-place 压缩全程操作"当前对话"的内存历史:总结、标记、状态写入都依赖 + # cm.conversation_history / cm.conversation_metadata。若当前对话不是目标对话, + # 必须先切换过去,否则总结会基于错误历史、压缩状态也会写错对话。 + if getattr(cm, "current_conversation_id", None) != conversation_id: + try: + web_terminal.load_conversation(conversation_id) + except Exception as exc: + return {"success": False, "error": f"加载目标对话失败: {exc}"} + compression_count = int(metadata.get("compression_count", 0) or 0) previous_records = _normalize_deep_compression_records(metadata) previous_max_count = max([int(item.get("count") or 0) for item in previous_records], default=0) @@ -313,124 +401,102 @@ async def run_deep_compression( cm.set_compression_state( in_progress=True, mode=mode, - stage="creating_new_conversation", + stage="marking_history", job_id=job_id, ) - new_conv_id = cm.conversation_manager.create_conversation( - project_path=str(workspace.project_path), - thinking_mode=bool(metadata.get("thinking_mode", False)), - run_mode=metadata.get("run_mode") or ("thinking" if metadata.get("thinking_mode") else "fast"), - initial_messages=[], - model_key=metadata.get("model_key"), - has_images=False, - has_videos=False, - metadata_overrides={ - "compression_count": target_count, - "title_locked": True, - "skip_auto_title_generation": True, - } + # === in-place 压缩:不创建/切换新对话,只把当前对话历史前缀打上 deep_compacted 标记 === + # 当前对话已在函数开头对齐为目标对话,这里直接对内存历史打标。 + now_iso = datetime.now().isoformat() + marked_count = _mark_history_compacted( + cm.conversation_history or [], + round_index=target_count, + now=now_iso, ) + # 标记后立即持久化历史(标记写在每条消息的 metadata 中)。 + try: + cm.save_current_conversation() + except Exception as exc: + _emit(sender, "system_message", {"content": f"压缩标记保存失败:{exc}"}) + + # 关键:重置 current_context_tokens,避免自动压缩续接后阈值判断仍读到压缩前的大值而陷入死循环。 + # 真实上下文长度会在下一次 API 响应后被重新写入。 + try: + cm.conversation_manager.update_token_statistics( + conversation_id, + input_tokens=0, + output_tokens=0, + total_tokens=0, + current_context_tokens=0, + ) + except Exception as exc: + _emit(sender, "system_message", {"content": f"压缩后重置上下文统计失败:{exc}"}) + current_record = { "count": target_count, "compact_file": relative_compact_path, - "created_at": datetime.now().isoformat(), + "created_at": now_iso, "source_conversation_id": conversation_id, - "compressed_conversation_id": new_conv_id, + "compressed_conversation_id": conversation_id, } all_records = previous_records + [current_record] - cm.conversation_manager.update_conversation_metadata( - new_conv_id, - { - "compression_count": target_count, - "deep_compression_records": all_records, - "last_deep_compression_record": current_record, - }, - ) - if source_versioning_enabled: - try: - vm = ConversationVersioningManager( - project_path=workspace.project_path, - data_dir=workspace.data_dir, - conversation_id=new_conv_id, - ) - vm_meta = vm.set_enabled( - enabled=True, - mode="overwrite", - tracking_mode=source_tracking_mode, - ) - new_conv_data = cm.conversation_manager.load_conversation(new_conv_id) or {} - snapshot_payload = { - "conversation_id": new_conv_id, - "title": new_conv_data.get("title"), - "metadata": new_conv_data.get("metadata") or {}, - "messages": new_conv_data.get("messages") or [], - "message_index": -1, - "run_status": "initial", - } - init_result = vm.ensure_initial_checkpoint( - workspace_path=str(workspace.project_path), - conversation_snapshot=snapshot_payload, - tracking_mode=source_tracking_mode, - ) - init_row = init_result.get("row") or {} - last_commit = init_row.get("commit") or vm_meta.get("last_commit") - cm.conversation_manager.update_conversation_metadata( - new_conv_id, - { - "versioning": { - "enabled": True, - "mode": "overwrite", - "tracking_mode": source_tracking_mode, - "updated_at": datetime.now().isoformat(), - "last_commit": last_commit, - "last_input_seq": int(vm_meta.get("last_input_seq") or 0), - } - }, - ) - except Exception: - pass + # 构建引导语(按压缩形式)。inject 模式读取历次压缩文件全文,文件仍会生成,只是不提及位置。 + if compress_form == "inject": + guide_message = _build_inject_guide_message( + project_path=Path(workspace.project_path), + compression_index=target_count, + current_record=current_record, + previous_records=previous_records, + ) + else: + guide_message = _build_guide_message( + compression_index=target_count, + compact_file=relative_compact_path, + previous_records=previous_records, + ) - cm.conversation_manager.update_conversation_title(new_conv_id, f"{old_title}对话 压缩后") - web_terminal.load_conversation(new_conv_id) - guide_message = _build_guide_message( - compression_index=target_count, - compact_file=relative_compact_path, - previous_records=previous_records, - ) + # 更新对话 metadata:压缩记录 + 清理压缩状态标记(同一对话,无切换)。 + meta_updates = { + "compression_count": target_count, + "deep_compression_records": all_records, + "last_deep_compression_record": current_record, + "compression_in_progress": False, + "compression_mode": None, + "compression_stage": None, + "compression_job_id": None, + "compression_error": summary_fail_reason, + "compression_resume_payload": None, + "is_ultra_long_conversation": False, + } + cm.conversation_manager.update_conversation_metadata(conversation_id, meta_updates) + # 同步内存中的 metadata,保证后续在同一对话内的判断读到最新值。 + try: + if getattr(cm, "current_conversation_id", None) == conversation_id and isinstance(cm.conversation_metadata, dict): + cm.conversation_metadata.update(meta_updates) + except Exception: + pass - # 清理旧对话压缩状态 - cm.conversation_manager.update_conversation_metadata( - conversation_id, - { - "compression_in_progress": False, - "compression_mode": None, - "compression_stage": None, - "compression_job_id": None, - "compression_error": summary_fail_reason, - "compression_resume_payload": None, - "is_ultra_long_conversation": True, - }, - ) - _emit(sender, "conversation_resolved", { - "source_conversation_id": conversation_id, - "conversation_id": new_conv_id, - "title": f"{old_title}对话 压缩后", - "created": True, - }) _emit(sender, "compression_finished", { "source_conversation_id": conversation_id, - "conversation_id": new_conv_id, + "conversation_id": conversation_id, "in_progress": False, "compact_file": relative_compact_path, + "marked_count": marked_count, + "compress_form": compress_form, + "compress_behavior": compress_behavior, "job_id": job_id, }) return { "success": True, - "compressed_conversation_id": new_conv_id, + "in_place": True, + "compressed_conversation_id": conversation_id, "compact_file": relative_compact_path, + "marked_count": marked_count, + "compress_form": compress_form, + "compress_behavior": compress_behavior, "summary_failed": bool(summary_fail_reason), "guide_message": guide_message, } + diff --git a/static/src/app/methods/message.ts b/static/src/app/methods/message.ts index d3d4c11..b0123a4 100644 --- a/static/src/app/methods/message.ts +++ b/static/src/app/methods/message.ts @@ -1057,6 +1057,7 @@ export const messageMethods = { if (response.ok && result.success) { this.compressionStage = 'switching'; + // in-place 压缩:对话 id 不变,重新加载以刷新“已压缩历史”展示。 const newId = result.compressed_conversation_id; if (newId) { await this.loadConversation(newId, { force: true }); @@ -1064,6 +1065,7 @@ export const messageMethods = { const guideMessage = (result.guide_message || '').trim(); const autoTaskStarted = !!result.auto_task_started; const autoTaskId = result.auto_task_id; + const compressBehavior = String(result.compress_behavior || 'continue'); if (autoTaskStarted && autoTaskId) { const { useTaskStore } = await import('../../stores/task'); const taskStore = useTaskStore(); @@ -1079,6 +1081,11 @@ export const messageMethods = { if (typeof this.monitorShowPendingReply === 'function') { this.monitorShowPendingReply(); } + } else if (compressBehavior === 'wait') { + // 等待用户:后端已把引导语作为 user 消息追加进历史,这里仅刷新展示,不触发请求。 + if (newId) { + await this.loadConversation(newId, { force: true }); + } } else if (guideMessage) { await this.sendAutoUserMessage(guideMessage); } @@ -1089,7 +1096,7 @@ export const messageMethods = { debugLog('对话压缩完成:', result); this.uiPushToast({ title: '压缩完成', - message: '已生成压缩后的新对话', + message: '已压缩较早的对话内容', type: 'success', duration: 2200 }); diff --git a/static/src/app/methods/taskPolling.ts b/static/src/app/methods/taskPolling.ts index 8453bab..95a2f2c 100644 --- a/static/src/app/methods/taskPolling.ts +++ b/static/src/app/methods/taskPolling.ts @@ -2427,8 +2427,10 @@ export const taskPollingMethods = { this.compressionMode = ''; this.compressionStage = ''; this.compressionError = ''; + // in-place 压缩:对话 id 不变。重新加载该对话以刷新“已压缩历史”的展示; + // 若返回的 id 与当前不一致(兼容旧行为)也按其加载。 const newId = data?.conversation_id; - if (newId && newId !== this.currentConversationId) { + if (newId) { await this.loadConversation(newId, { force: true }); } this.conversationsOffset = 0; @@ -2436,13 +2438,11 @@ export const taskPollingMethods = { await this.loadConversationsList(); } await this.refreshRunningWorkspaceTasks?.(); - // 压缩会把同一个后台任务从旧 conversation 迁移到新 conversation。 - // 切换对话时本地轮询会被清理,这里必须立即按新会话恢复轮询, - // 否则后续进度需要手动刷新页面才会继续显示。 + // 续接任务仍在同一对话内运行,恢复轮询以持续显示进度。 await this.restoreTaskState?.(); this.uiPushToast({ title: '压缩完成', - message: '已自动切换到压缩后的新对话', + message: '已压缩较早的对话内容', type: 'success', duration: 2400 }); diff --git a/static/src/components/personalization/PersonalizationDrawer.vue b/static/src/components/personalization/PersonalizationDrawer.vue index df8dcbc..f04b44c 100644 --- a/static/src/components/personalization/PersonalizationDrawer.vue +++ b/static/src/components/personalization/PersonalizationDrawer.vue @@ -995,6 +995,50 @@ " /> + +
@@ -57,28 +57,28 @@ const modes = [ id: 'fast', badge: '极速', title: '快速回答', - desc: '快速回答您的问题,会上网搜索,不会写文件。', + desc: '快速回答您的问题,会上网搜索,不会写文件', icon: `` }, { id: 'thinking', badge: '思考', title: '思考后再答', - desc: '经过思考后完成任务,会上网搜索,不会写文件,回复时间较长。', + desc: '经过思考后完成任务,会上网搜索,不会写文件,回复时间较长', icon: `` }, { id: 'research', badge: '研究', title: '多步骤研究', - desc: '多步骤思考与 20+ 工具完成复杂任务,回复时间可能很长。', + desc: '多步骤思考与 20+ 工具完成复杂任务,回复时间可能很长', icon: `` }, { id: 'expert', badge: '专家', title: '全功能探索', - desc: '自由探索所有配置选项,适合智能体专家与从业者。', + desc: '自由探索所有配置选项,适合智能体专家与从业者', icon: `` } ] as const; diff --git a/static/src/components/personalization/PersonalizationDrawer.vue b/static/src/components/personalization/PersonalizationDrawer.vue index f04b44c..7f967b2 100644 --- a/static/src/components/personalization/PersonalizationDrawer.vue +++ b/static/src/components/personalization/PersonalizationDrawer.vue @@ -90,7 +90,7 @@ 自动生成对话标题 默认开启;关闭后标题将沿用首条消息。默认开启;关闭后标题将沿用首条消息 新手教程 通过高亮与说明窗了解主要功能入口和常用设置。通过高亮与说明窗了解主要功能入口和常用设置 @@ -378,7 +378,7 @@ 默认模型为新对话/登录后首次请求选择首选模型。为新对话/登录后首次请求选择首选模型
默认运行模式登录或新建任务时的初始运行模式。登录或新建任务时的初始运行模式
思考频率连续快速模式运行多少轮后,系统强制切换到思考模型。连续快速模式运行多少轮后,系统强制切换到思考模型
@@ -501,7 +501,7 @@
主题切换在经典、明亮和夜间之间切换。在经典、明亮和夜间之间切换
使用自定义称呼对话区域使用个性化设置中的自称和称呼。对话区域使用个性化设置中的自称和称呼增强工具显示工具块显示格式化内容,关闭则显示原始 JSON。工具块显示格式化内容,关闭则显示原始 JSON 堆叠块显示模式选择思考/工具块的显示方式。选择思考/工具块的显示方式
简略消息显示审核、子智能体等紧凑系统消息的显示形式。审核、子智能体等紧凑系统消息的显示形式
默认权限新建对话时的默认权限模式。新建对话时的默认权限模式
允许根目录创建文件关闭则禁止在工作区根目录创建文件。关闭则禁止在工作区根目录创建文件AGENTS.md 自动注入工作区根目录存在 AGENTS.md 时自动注入系统提示词。工作区根目录存在 AGENTS.md 时自动注入系统提示词审核智能体主动取证开启后审核智能体可运行只读命令(查看文件、跑测试)来核实目标是否达成;关闭则仅依据对话内容判断。开启后可运行只读命令核实目标,关闭则仅依据对话判断最大自动续命轮数目标模式下,主模型停下后最多自动继续的轮数(1-100)。主模型停下后最多自动继续 1-100 轮 启用累计 token 上限开启后,目标模式累计消耗(输入+输出)超过下方上限即停止。累计输入输出超过上限时停止目标模式 累计 token 上限达到该累计消耗即停止目标模式。达到该累计消耗即停止目标模式 最近对话提示把当前工作区最近若干个非空对话注入系统提示词。把当前工作区最近若干个非空对话注入系统提示词 上下文压缩策略可分别控制自动浅层压缩与自动深层压缩。可分别控制自动浅层压缩与自动深层压缩
@@ -1230,7 +1230,7 @@
默认禁用工具类别选择后,这些类别在新任务中保持关闭。选择后,这些类别在新任务中保持关闭
@@ -1266,7 +1266,7 @@ 图片压缩发送图片或调用 view_image 时自动等比缩放。发送图片或调用 view_image 时自动等比缩放
对话用量

用量统计

- 累计统计所有对话的输入/输出 Token、对话数量、用户消息与工具调用。 + 累计统计所有对话的输入/输出 Token、对话数量、用户消息与工具调用

@@ -1378,7 +1378,7 @@ MCP 服务器管理统一配置 MCP Server,集中管理工具扩展能力。统一配置 MCP Server,集中管理工具扩展能力