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