diff --git a/core/main_terminal.py b/core/main_terminal.py index e537882..fb4b34f 100644 --- a/core/main_terminal.py +++ b/core/main_terminal.py @@ -400,6 +400,11 @@ class MainTerminal: ] self.default_disabled_tool_categories = disabled_categories + # 图片压缩模式传递给上下文 + img_mode = effective_config.get("image_compression") + if isinstance(img_mode, str): + self.context_manager.image_compression_mode = img_mode + # Reset category states to defaults before applying overrides for key, category in self.tool_categories_map.items(): self.tool_category_states[key] = False if key in disabled_categories else category.default_enabled diff --git a/modules/personalization_manager.py b/modules/personalization_manager.py index 4b578f4..d61c720 100644 --- a/modules/personalization_manager.py +++ b/modules/personalization_manager.py @@ -37,6 +37,7 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = { "auto_generate_title": True, "tool_intent_enabled": True, "default_model": "kimi-k2.5", + "image_compression": "original", # original / 1080p / 720p / 540p } __all__ = [ @@ -110,6 +111,7 @@ def sanitize_personalization_payload( data = payload or {} allowed_tool_categories = set(TOOL_CATEGORIES.keys()) allowed_models = {"kimi", "kimi-k2.5", "deepseek", "qwen3-max", "qwen3-vl-plus"} + allowed_image_modes = {"original", "1080p", "720p", "540p"} def _resolve_short_field(key: str) -> str: if key in data: @@ -155,6 +157,13 @@ def sanitize_personalization_payload( elif base.get("default_model") not in allowed_models: base["default_model"] = "kimi-k2.5" + # 图片压缩模式 + img_mode = data.get("image_compression", base.get("image_compression")) + if isinstance(img_mode, str) and img_mode in allowed_image_modes: + base["image_compression"] = img_mode + elif base.get("image_compression") not in allowed_image_modes: + base["image_compression"] = "original" + return base diff --git a/static/src/components/personalization/PersonalizationDrawer.vue b/static/src/components/personalization/PersonalizationDrawer.vue index 4c64c42..f809118 100644 --- a/static/src/components/personalization/PersonalizationDrawer.vue +++ b/static/src/components/personalization/PersonalizationDrawer.vue @@ -182,6 +182,48 @@ +
+
+
+
+ 图片压缩 +

发送图片或调用 view_image 时自动等比缩放,防止大图占用过多 tokens。

+
+
+ +
+

缩放保持原始比例;若原图已低于目标分辨率,将直接使用原图。

+
+
+
+
+
+ + {{ status }} + + + {{ error }} + +
+ +
+
+
@@ -578,11 +620,12 @@ const baseTabs = [ { id: 'preferences', label: '个性化设置' }, { id: 'model', label: '模型偏好', description: '默认模型选择' }, { id: 'behavior', label: '模型行为' }, + { id: 'image', label: '图片压缩', description: '发送图片的尺寸策略' }, { id: 'theme', label: '主题切换', description: '浅色 / 深色 / Claude' }, { id: 'experiments', label: '实验功能', description: 'Liquid Glass' } ] as const; -type PersonalTab = 'preferences' | 'model' | 'behavior' | 'theme' | 'experiments' | 'admin-monitor'; +type PersonalTab = 'preferences' | 'model' | 'behavior' | 'image' | 'theme' | 'experiments' | 'admin-monitor'; const isAdmin = computed(() => (resourceStore.usageQuota.role || '').toLowerCase() === 'admin'); @@ -628,6 +671,13 @@ const thinkingPresets = [ { id: 'high', label: '高', value: 3 } ]; +const imageCompressionOptions = [ + { id: 'original', label: '原图', desc: '不压缩' }, + { id: '1080p', label: '1080p', desc: '最长边不超过 1080p 等比缩放' }, + { id: '720p', label: '720p', desc: '最长边不超过 720p 等比缩放' }, + { id: '540p', label: '540p', desc: '最长边不超过 540p 等比缩放' } +] as const; + const setActiveTab = (tab: PersonalTab) => { activeTab.value = tab; }; diff --git a/static/src/stores/personalization.ts b/static/src/stores/personalization.ts index db006f7..032adb3 100644 --- a/static/src/stores/personalization.ts +++ b/static/src/stores/personalization.ts @@ -15,6 +15,7 @@ interface PersonalForm { disabled_tool_categories: string[]; default_run_mode: RunMode | null; default_model: string | null; + image_compression: string; } interface LiquidGlassPosition { @@ -65,7 +66,8 @@ const defaultForm = (): PersonalForm => ({ thinking_interval: null, disabled_tool_categories: [], default_run_mode: null, - default_model: 'kimi-k2.5' + default_model: 'kimi-k2.5', + image_compression: 'original' }); const defaultExperimentState = (): ExperimentState => ({ @@ -195,7 +197,8 @@ export const usePersonalizationStore = defineStore('personalization', { typeof data.default_run_mode === 'string' && RUN_MODE_OPTIONS.includes(data.default_run_mode as RunMode) ? data.default_run_mode as RunMode : null, - default_model: typeof data.default_model === 'string' ? data.default_model : fallbackModel + default_model: typeof data.default_model === 'string' ? data.default_model : fallbackModel, + image_compression: typeof data.image_compression === 'string' ? data.image_compression : 'original' }; this.clearFeedback(); }, @@ -365,6 +368,15 @@ export const usePersonalizationStore = defineStore('personalization', { }; this.clearFeedback(); }, + setImageCompression(mode: string) { + const allowed = ['original', '1080p', '720p', '540p']; + if (!allowed.includes(mode)) return; + this.form = { + ...this.form, + image_compression: mode + }; + this.clearFeedback(); + }, applyTonePreset(preset: string) { if (!preset) { return; diff --git a/utils/context_manager.py b/utils/context_manager.py index 5f57a96..6f0b3a3 100644 --- a/utils/context_manager.py +++ b/utils/context_manager.py @@ -4,6 +4,7 @@ import os import json import base64 import mimetypes +import io from copy import deepcopy from typing import Dict, List, Optional, Any from pathlib import Path @@ -52,6 +53,7 @@ class ContextManager: self.conversation_history = [] # 当前对话历史(内存中) self.todo_list: Optional[Dict[str, Any]] = None self.has_images: bool = False + self.image_compression_mode: str = "original" # 新增:对话持久化管理器 self.conversation_manager = ConversationManager(base_dir=self.data_dir) @@ -1197,8 +1199,44 @@ class ContextManager: "is_overflow": sizes["total"] > MAX_CONTEXT_SIZE, "usage_percent": (sizes["total"] / MAX_CONTEXT_SIZE) * 100 } + def _compress_image_if_needed(self, path: Path) -> Optional[str]: + """根据个性化设置压缩图片,返回 data URL(若压缩失败则返回 None 表示使用原图)。""" + mode = getattr(self, "image_compression_mode", "original") or "original" + if mode == "original": + return None + target_map = { + "1080p": (1920, 1080), + "720p": (1280, 720), + "540p": (960, 540), + } + target = target_map.get(mode) + if not target: + return None + try: + from PIL import Image + except Exception: + return None + try: + with Image.open(path) as im: + w, h = im.size + max_w, max_h = target + if w <= max_w and h <= max_h: + return None # 已经不超过目标,不压缩 + im_copy = im.copy() + im_copy.thumbnail((max_w, max_h)) + buf = io.BytesIO() + im_copy.save(buf, format="PNG", optimize=True) + data = buf.getvalue() + mime, _ = mimetypes.guess_type(path.name) + if not mime: + mime = "image/png" + b64 = base64.b64encode(data).decode("utf-8") + return f"data:{mime};base64,{b64}" + except Exception: + return None + def _build_content_with_images(self, text: str, images: List[str]) -> Any: - """将文本与图片路径组合成多模态content,图片转换为data URI。""" + """将文本与图片路径组合成多模态content,图片转换为data URI,支持按设置压缩。""" if not images: return text parts: List[Dict[str, Any]] = [] @@ -1209,12 +1247,15 @@ class ContextManager: abs_path = Path(self.project_path) / path if not abs_path.exists() or not abs_path.is_file(): continue - mime, _ = mimetypes.guess_type(abs_path.name) - if not mime: - mime = "image/png" - data = abs_path.read_bytes() - b64 = base64.b64encode(data).decode("utf-8") - parts.append({"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}}) + data_url = self._compress_image_if_needed(abs_path) + if not data_url: + mime, _ = mimetypes.guess_type(abs_path.name) + if not mime: + mime = "image/png" + data = abs_path.read_bytes() + b64 = base64.b64encode(data).decode("utf-8") + data_url = f"data:{mime};base64,{b64}" + parts.append({"type": "image_url", "image_url": {"url": data_url}}) except Exception: continue return parts if parts else text