feat(compression): 深压缩改为 in-place 标记前缀,不再切换新对话

深压缩从"建新对话+切过去"重构为标记当前对话历史前缀(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) <noreply@anthropic.com>
This commit is contained in:
JOJO 2026-05-30 20:56:15 +08:00
parent f5e4541d83
commit 3fd2214da0
10 changed files with 403 additions and 148 deletions

View File

@ -5,6 +5,8 @@
这份文档基于当前仓库实际代码重写。若与未来代码冲突,以代码为准并及时更新本文档。
> 和用户交流时默认使用中文。
## 1) 当前项目结构(按代码现状)
- **后端入口**

136
CLAUDE.md
View File

@ -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_<tool_name>` 方法
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 壳版本时,必须同步完成:

View File

@ -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]:

View File

@ -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"),

View File

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

View File

@ -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 也会被忽略——我们只取 contentprompt 已要求其直接输出总结。
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,
}

View File

@ -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
});

View File

@ -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
});

View File

@ -995,6 +995,50 @@
"
/></label>
</div>
<label class="settings-toggle-row inner"
><span class="settings-row-copy"
><span class="settings-row-title">深压缩直接注入</span
><span class="settings-row-desc"
>关闭生成压缩文件引导语提示文件位置由模型自行阅读开启把历次压缩文件全文直接注入引导语文件仍会生成</span
></span
><input
type="checkbox"
:checked="form.deep_compress_form === 'inject'"
@change="
personalization.updateField({
key: 'deep_compress_form',
value: $event.target.checked ? 'inject' : 'file'
})
" /><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
></label>
<label class="settings-toggle-row inner"
><span class="settings-row-copy"
><span class="settings-row-title">手动压缩后等待用户</span
><span class="settings-row-desc"
>仅作用于手动压缩关闭压缩后自动注入引导语并继续工作开启压缩后只插入引导语等待你下一步输入自动深压缩始终继续工作</span
></span
><input
type="checkbox"
:checked="form.deep_compress_behavior === 'wait'"
@change="
personalization.updateField({
key: 'deep_compress_behavior',
value: $event.target.checked ? 'wait' : 'continue'
})
" /><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
></label>
<div class="settings-inline-actions right">
<button
type="button"

View File

@ -43,6 +43,8 @@ interface PersonalForm {
shallow_compress_max_replace_per_round: number | null;
shallow_compress_trigger_tool_calls_interval: number | null;
deep_compress_trigger_tokens: number | null;
deep_compress_form: 'file' | 'inject';
deep_compress_behavior: 'continue' | 'wait';
agents_md_auto_inject: boolean;
allow_root_file_creation: boolean;
theme: 'classic' | 'light' | 'dark';
@ -134,6 +136,8 @@ const defaultForm = (): PersonalForm => ({
shallow_compress_max_replace_per_round: null,
shallow_compress_trigger_tool_calls_interval: null,
deep_compress_trigger_tokens: null,
deep_compress_form: 'file',
deep_compress_behavior: 'continue',
agents_md_auto_inject: false,
allow_root_file_creation: false,
theme: loadCachedTheme(),
@ -339,6 +343,8 @@ export const usePersonalizationStore = defineStore('personalization', {
deep_compress_trigger_tokens: this.normalizeCompressionNumber(
data.deep_compress_trigger_tokens
),
deep_compress_form: data.deep_compress_form === 'inject' ? 'inject' : 'file',
deep_compress_behavior: data.deep_compress_behavior === 'wait' ? 'wait' : 'continue',
agents_md_auto_inject: !!data.agents_md_auto_inject,
allow_root_file_creation: !!data.allow_root_file_creation,
theme: ['classic', 'light', 'dark'].includes(data.theme) ? data.theme : fallbackTheme,