diff --git a/modules/personalization_manager.py b/modules/personalization_manager.py index 6f10471..4f2a8c1 100644 --- a/modules/personalization_manager.py +++ b/modules/personalization_manager.py @@ -23,6 +23,19 @@ MAX_CONSIDERATION_ITEMS = 10 TONE_PRESETS = ["健谈", "幽默", "直言不讳", "鼓励性", "诗意", "企业商务", "打破常规", "同理心"] THINKING_INTERVAL_MIN = 1 THINKING_INTERVAL_MAX = 50 +DEFAULT_SHALLOW_COMPRESS_TRIGGER_TOKENS = 80_000 +DEFAULT_SHALLOW_COMPRESS_KEEP_RECENT_TOOLS = 15 +DEFAULT_SHALLOW_COMPRESS_MAX_REPLACE_PER_ROUND = 10 +DEFAULT_SHALLOW_COMPRESS_TRIGGER_TOOL_CALLS_INTERVAL = 10 +DEFAULT_DEEP_COMPRESS_TRIGGER_TOKENS = 150_000 +MIN_COMPRESSION_TRIGGER_TOKENS = 1_000 +MAX_COMPRESSION_TRIGGER_TOKENS = 2_000_000 +MIN_SHALLOW_KEEP_RECENT_TOOLS = 0 +MAX_SHALLOW_KEEP_RECENT_TOOLS = 500 +MIN_SHALLOW_MAX_REPLACE_PER_ROUND = 1 +MAX_SHALLOW_MAX_REPLACE_PER_ROUND = 200 +MIN_SHALLOW_TRIGGER_TOOL_CALLS_INTERVAL = 1 +MAX_SHALLOW_TRIGGER_TOOL_CALLS_INTERVAL = 200 DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = { "enabled": False, @@ -44,6 +57,11 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = { "image_compression": "original", # original / 1080p / 720p / 540p "auto_shallow_compress_enabled": False, "auto_deep_compress_enabled": False, + "shallow_compress_trigger_tokens": None, + "shallow_compress_keep_recent_tools": None, + "shallow_compress_max_replace_per_round": None, + "shallow_compress_trigger_tool_calls_interval": None, + "deep_compress_trigger_tokens": None, "silent_tool_disable": False, # 禁用工具时不向模型插入提示 "enhanced_tool_display": True, # 增强工具显示 } @@ -58,6 +76,8 @@ __all__ = [ "ensure_personalization_config", "build_personalization_prompt", "sanitize_personalization_payload", + "resolve_context_compression_settings", + "validate_context_compression_settings", ] @@ -198,6 +218,71 @@ def sanitize_personalization_payload( else: base["auto_deep_compress_enabled"] = bool(base.get("auto_deep_compress_enabled", False)) + if "shallow_compress_trigger_tokens" in data: + base["shallow_compress_trigger_tokens"] = _sanitize_optional_int( + data.get("shallow_compress_trigger_tokens"), + min_value=MIN_COMPRESSION_TRIGGER_TOKENS, + max_value=MAX_COMPRESSION_TRIGGER_TOKENS, + ) + else: + base["shallow_compress_trigger_tokens"] = _sanitize_optional_int( + base.get("shallow_compress_trigger_tokens"), + min_value=MIN_COMPRESSION_TRIGGER_TOKENS, + max_value=MAX_COMPRESSION_TRIGGER_TOKENS, + ) + + if "shallow_compress_keep_recent_tools" in data: + base["shallow_compress_keep_recent_tools"] = _sanitize_optional_int( + data.get("shallow_compress_keep_recent_tools"), + min_value=MIN_SHALLOW_KEEP_RECENT_TOOLS, + max_value=MAX_SHALLOW_KEEP_RECENT_TOOLS, + ) + else: + base["shallow_compress_keep_recent_tools"] = _sanitize_optional_int( + base.get("shallow_compress_keep_recent_tools"), + min_value=MIN_SHALLOW_KEEP_RECENT_TOOLS, + max_value=MAX_SHALLOW_KEEP_RECENT_TOOLS, + ) + + if "shallow_compress_max_replace_per_round" in data: + base["shallow_compress_max_replace_per_round"] = _sanitize_optional_int( + data.get("shallow_compress_max_replace_per_round"), + min_value=MIN_SHALLOW_MAX_REPLACE_PER_ROUND, + max_value=MAX_SHALLOW_MAX_REPLACE_PER_ROUND, + ) + else: + base["shallow_compress_max_replace_per_round"] = _sanitize_optional_int( + base.get("shallow_compress_max_replace_per_round"), + min_value=MIN_SHALLOW_MAX_REPLACE_PER_ROUND, + max_value=MAX_SHALLOW_MAX_REPLACE_PER_ROUND, + ) + + if "deep_compress_trigger_tokens" in data: + base["deep_compress_trigger_tokens"] = _sanitize_optional_int( + data.get("deep_compress_trigger_tokens"), + min_value=MIN_COMPRESSION_TRIGGER_TOKENS, + max_value=MAX_COMPRESSION_TRIGGER_TOKENS, + ) + else: + base["deep_compress_trigger_tokens"] = _sanitize_optional_int( + base.get("deep_compress_trigger_tokens"), + min_value=MIN_COMPRESSION_TRIGGER_TOKENS, + max_value=MAX_COMPRESSION_TRIGGER_TOKENS, + ) + + 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"), + min_value=MIN_SHALLOW_TRIGGER_TOOL_CALLS_INTERVAL, + max_value=MAX_SHALLOW_TRIGGER_TOOL_CALLS_INTERVAL, + ) + else: + base["shallow_compress_trigger_tool_calls_interval"] = _sanitize_optional_int( + base.get("shallow_compress_trigger_tool_calls_interval"), + min_value=MIN_SHALLOW_TRIGGER_TOOL_CALLS_INTERVAL, + max_value=MAX_SHALLOW_TRIGGER_TOOL_CALLS_INTERVAL, + ) + # 静默禁用工具提示 if "silent_tool_disable" in data: base["silent_tool_disable"] = bool(data.get("silent_tool_disable")) @@ -242,6 +327,7 @@ def save_personalization_config(base_dir: PathLike, payload: Dict[str, Any]) -> """Persist sanitized personalization config and return it.""" existing = load_personalization_config(base_dir) config = sanitize_personalization_payload(payload, fallback=existing) + validate_context_compression_settings(config) path = _to_path(base_dir) _ensure_parent(path) with open(path, "w", encoding="utf-8") as f: @@ -249,6 +335,78 @@ def save_personalization_config(base_dir: PathLike, payload: Dict[str, Any]) -> return config +def _sanitize_optional_int(value: Any, *, min_value: int, max_value: int) -> Optional[int]: + if value is None or value == "": + return None + if isinstance(value, bool): + return None + try: + parsed = int(value) + except (TypeError, ValueError): + return None + if parsed < min_value: + return min_value + if parsed > max_value: + return max_value + return parsed + + +def resolve_context_compression_settings(config: Optional[Dict[str, Any]]) -> Dict[str, int]: + data = config or {} + shallow_trigger = _sanitize_optional_int( + data.get("shallow_compress_trigger_tokens"), + min_value=MIN_COMPRESSION_TRIGGER_TOKENS, + max_value=MAX_COMPRESSION_TRIGGER_TOKENS, + ) or DEFAULT_SHALLOW_COMPRESS_TRIGGER_TOKENS + shallow_keep_recent = _sanitize_optional_int( + data.get("shallow_compress_keep_recent_tools"), + min_value=MIN_SHALLOW_KEEP_RECENT_TOOLS, + max_value=MAX_SHALLOW_KEEP_RECENT_TOOLS, + ) + if shallow_keep_recent is None: + shallow_keep_recent = DEFAULT_SHALLOW_COMPRESS_KEEP_RECENT_TOOLS + shallow_max_replace = _sanitize_optional_int( + data.get("shallow_compress_max_replace_per_round"), + min_value=MIN_SHALLOW_MAX_REPLACE_PER_ROUND, + max_value=MAX_SHALLOW_MAX_REPLACE_PER_ROUND, + ) or DEFAULT_SHALLOW_COMPRESS_MAX_REPLACE_PER_ROUND + shallow_trigger_interval = _sanitize_optional_int( + data.get("shallow_compress_trigger_tool_calls_interval"), + min_value=MIN_SHALLOW_TRIGGER_TOOL_CALLS_INTERVAL, + max_value=MAX_SHALLOW_TRIGGER_TOOL_CALLS_INTERVAL, + ) or DEFAULT_SHALLOW_COMPRESS_TRIGGER_TOOL_CALLS_INTERVAL + deep_trigger = _sanitize_optional_int( + data.get("deep_compress_trigger_tokens"), + min_value=MIN_COMPRESSION_TRIGGER_TOKENS, + max_value=MAX_COMPRESSION_TRIGGER_TOKENS, + ) or DEFAULT_DEEP_COMPRESS_TRIGGER_TOKENS + if deep_trigger <= shallow_trigger: + deep_trigger = shallow_trigger + 1 + return { + "shallow_trigger_tokens": shallow_trigger, + "shallow_keep_recent_tools": shallow_keep_recent, + "shallow_max_replace_per_round": shallow_max_replace, + "shallow_trigger_tool_calls_interval": shallow_trigger_interval, + "deep_trigger_tokens": deep_trigger, + } + + +def validate_context_compression_settings(config: Optional[Dict[str, Any]]) -> None: + data = config or {} + shallow_trigger = _sanitize_optional_int( + data.get("shallow_compress_trigger_tokens"), + min_value=MIN_COMPRESSION_TRIGGER_TOKENS, + max_value=MAX_COMPRESSION_TRIGGER_TOKENS, + ) or DEFAULT_SHALLOW_COMPRESS_TRIGGER_TOKENS + deep_trigger = _sanitize_optional_int( + data.get("deep_compress_trigger_tokens"), + min_value=MIN_COMPRESSION_TRIGGER_TOKENS, + max_value=MAX_COMPRESSION_TRIGGER_TOKENS, + ) or DEFAULT_DEEP_COMPRESS_TRIGGER_TOKENS + if deep_trigger <= shallow_trigger: + raise ValueError("深压缩触发上下文必须大于浅压缩触发上下文") + + def build_personalization_prompt( config: Optional[Dict[str, Any]], include_header: bool = True diff --git a/server/chat_flow_task_main.py b/server/chat_flow_task_main.py index a8c44cf..6542b46 100644 --- a/server/chat_flow_task_main.py +++ b/server/chat_flow_task_main.py @@ -38,6 +38,7 @@ from modules.personalization_manager import ( save_personalization_config, THINKING_INTERVAL_MIN, THINKING_INTERVAL_MAX, + resolve_context_compression_settings, ) from modules.skill_hint_manager import SkillHintManager from modules.upload_security import UploadSecurityError @@ -566,11 +567,12 @@ async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspac personal_config = load_personalization_config(workspace.data_dir) except Exception: personal_config = {} + compression_settings = resolve_context_compression_settings(personal_config) auto_deep_enabled = bool(personal_config.get("auto_deep_compress_enabled", False)) current_tokens_for_deep = web_terminal.context_manager.get_current_context_tokens(conversation_id) if ( auto_deep_enabled - and current_tokens_for_deep > 150_000 + and current_tokens_for_deep > compression_settings["deep_trigger_tokens"] and not web_terminal.context_manager.is_compression_in_progress() ): web_terminal.context_manager._set_meta_flag("is_ultra_long_conversation", True) diff --git a/server/chat_flow_tool_loop.py b/server/chat_flow_tool_loop.py index 1248475..5d30e1f 100644 --- a/server/chat_flow_tool_loop.py +++ b/server/chat_flow_tool_loop.py @@ -12,8 +12,9 @@ from .security import compact_web_search_result from .chat_flow_helpers import detect_tool_failure from .chat_flow_runner_helpers import resolve_monitor_path, resolve_monitor_memory, capture_monitor_snapshot from utils.tool_result_formatter import format_tool_result_for_context +from utils.context_manager import AUTO_SHALLOW_PLACEHOLDER from config import TOOL_CALL_COOLDOWN -from modules.personalization_manager import load_personalization_config +from modules.personalization_manager import load_personalization_config, resolve_context_compression_settings from .deep_compression import run_deep_compression @@ -457,12 +458,21 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie personal_config = load_personalization_config(workspace_data_dir) if workspace_data_dir else {} except Exception: personal_config = {} + compression_settings = resolve_context_compression_settings(personal_config) auto_shallow_enabled = bool(personal_config.get("auto_shallow_compress_enabled", False)) auto_deep_enabled = bool(personal_config.get("auto_deep_compress_enabled", False)) compressed_count = 0 try: compressed_count = int( - web_terminal.context_manager.on_tool_call_finished(function_name, enable_shallow=auto_shallow_enabled) or 0 + web_terminal.context_manager.on_tool_call_finished( + function_name, + enable_shallow=auto_shallow_enabled, + shallow_trigger_tokens=compression_settings["shallow_trigger_tokens"], + deep_trigger_tokens=compression_settings["deep_trigger_tokens"], + shallow_batch_size=compression_settings["shallow_max_replace_per_round"], + shallow_keep_recent_tools=compression_settings["shallow_keep_recent_tools"], + shallow_trigger_tool_calls_interval=compression_settings["shallow_trigger_tool_calls_interval"], + ) or 0 ) except Exception as exc: debug_log(f"[ContextCompression] 工具后浅压缩检测失败: {exc}") @@ -470,8 +480,34 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie sender('shallow_compression', { "conversation_id": conversation_id, "compressed_count": compressed_count, - "keep_recent_tools": 15, + "keep_recent_tools": compression_settings["shallow_keep_recent_tools"], }) + # 关键修复:同一轮工具循环里,API 继续调用使用的是本地 messages(不是每次重建上下文), + # 因此需要把已打标的旧 tool 结果同步替换为占位符,避免日志看起来“弹窗触发但请求未替换”。 + if auto_shallow_enabled and messages: + try: + marked_keys = set() + for item in (web_terminal.context_manager.conversation_history or []): + if not isinstance(item, dict) or item.get("role") != "tool": + continue + item_meta = item.get("metadata") or {} + if not item_meta.get("auto_shallow_compacted"): + continue + marked_keys.add((str(item.get("tool_call_id") or ""), str(item.get("name") or ""))) + + if marked_keys: + replaced_in_loop = 0 + for msg in messages: + if not isinstance(msg, dict) or msg.get("role") != "tool": + continue + key = (str(msg.get("tool_call_id") or ""), str(msg.get("name") or "")) + if key in marked_keys and msg.get("content") != AUTO_SHALLOW_PLACEHOLDER: + msg["content"] = AUTO_SHALLOW_PLACEHOLDER + replaced_in_loop += 1 + if replaced_in_loop > 0: + debug_log(f"[ContextCompression] 同步替换本轮 messages 中已压缩 tool 结果: {replaced_in_loop}") + except Exception as exc: + debug_log(f"[ContextCompression] 同步替换本轮 messages 失败: {exc}") debug_log(f"💾 增量保存:工具结果 {function_name}") if function_name != "wait_sub_agent": system_message = result_data.get("system_message") if isinstance(result_data, dict) else None @@ -483,7 +519,7 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie current_context_tokens = web_terminal.context_manager.get_current_context_tokens(conversation_id) if ( auto_deep_enabled - and current_context_tokens > 150_000 + and current_context_tokens > compression_settings["deep_trigger_tokens"] and not web_terminal.context_manager.is_compression_in_progress() ): web_terminal.context_manager._set_meta_flag("is_ultra_long_conversation", True) diff --git a/static/src/App.vue b/static/src/App.vue index fb5a925..bbf9d98 100644 --- a/static/src/App.vue +++ b/static/src/App.vue @@ -250,7 +250,7 @@ :tool-settings="toolSettings" :tool-settings-loading="toolSettingsLoading" :settings-open="settingsOpen" - :compressing="compressing" + :compressing="compressing || compressionInProgress" :current-conversation-id="currentConversationId" :icon-style="iconStyle" :tool-category-icon="toolCategoryIcon" diff --git a/static/src/app/methods/taskPolling.ts b/static/src/app/methods/taskPolling.ts index bdb22f3..a1f7d0f 100644 --- a/static/src/app/methods/taskPolling.ts +++ b/static/src/app/methods/taskPolling.ts @@ -51,7 +51,12 @@ export const taskPollingMethods = { // 检查事件的 conversation_id 是否匹配当前对话 // 如果不匹配,忽略该事件(避免切换对话后旧任务的事件显示到新对话中) - if (eventType !== 'shallow_compression' && eventData.conversation_id && this.currentConversationId) { + const crossConversationAllowed = new Set([ + 'shallow_compression', + 'conversation_resolved', + 'compression_finished' + ]); + if (!crossConversationAllowed.has(eventType) && eventData.conversation_id && this.currentConversationId) { if (eventData.conversation_id !== this.currentConversationId) { console.log(`[DEBUG_AWAITING] 忽略不匹配的事件`, { eventType, @@ -935,9 +940,19 @@ export const taskPollingMethods = { if (!data || typeof data !== 'object') { return; } + const wasInProgress = !!this.compressionInProgress; this.compressionInProgress = !!data.in_progress; this.compressionMode = data.mode || ''; this.compressionStage = data.stage || ''; + if (this.compressionInProgress && !wasInProgress) { + const modeLabel = this.compressionMode === 'manual' ? '手动' : '自动'; + this.uiPushToast({ + title: '压缩中', + message: `对话正在${modeLabel}压缩,请稍候…`, + type: 'info', + duration: 2600 + }); + } if (!this.compressionInProgress) { this.compressionError = data.error || ''; } @@ -952,6 +967,16 @@ export const taskPollingMethods = { if (newId && newId !== this.currentConversationId) { await this.loadConversation(newId, { force: true }); } + this.conversationsOffset = 0; + if (typeof this.loadConversationsList === 'function') { + await this.loadConversationsList(); + } + this.uiPushToast({ + title: '压缩完成', + message: '已自动切换到压缩后的新对话', + type: 'success', + duration: 2400 + }); }, handleShallowCompression(data: any, eventIdx?: number) { diff --git a/static/src/components/personalization/PersonalizationDrawer.vue b/static/src/components/personalization/PersonalizationDrawer.vue index 2d72903..59a5f3d 100644 --- a/static/src/components/personalization/PersonalizationDrawer.vue +++ b/static/src/components/personalization/PersonalizationDrawer.vue @@ -522,6 +522,76 @@ 自动深层压缩 +
+ 自定义项留空则使用默认值;深压缩触发上下文必须大于浅压缩触发上下文。 +