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:
parent
f5e4541d83
commit
3fd2214da0
@ -5,6 +5,8 @@
|
||||
|
||||
这份文档基于当前仓库实际代码重写。若与未来代码冲突,以代码为准并及时更新本文档。
|
||||
|
||||
> 和用户交流时默认使用中文。
|
||||
|
||||
## 1) 当前项目结构(按代码现状)
|
||||
|
||||
- **后端入口**
|
||||
|
||||
136
CLAUDE.md
136
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_<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 壳版本时,必须同步完成:
|
||||
|
||||
@ -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]:
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
});
|
||||
|
||||
@ -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
|
||||
});
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user