feat: add image compression preference for uploads

This commit is contained in:
JOJO 2026-01-28 11:43:09 +08:00
parent 60d27e9c1c
commit 6f8c1b36cc
5 changed files with 127 additions and 10 deletions

View File

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

View File

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

View File

@ -182,6 +182,48 @@
</div>
</div>
</section>
<section v-else-if="activeTab === 'image'" key="image" class="personal-page behavior-page">
<div class="behavior-section">
<div class="behavior-field">
<div class="behavior-field-header">
<span class="field-title">图片压缩</span>
<p class="behavior-desc">发送图片或调用 view_image 时自动等比缩放防止大图占用过多 tokens</p>
</div>
<div class="run-mode-options">
<button
v-for="option in imageCompressionOptions"
:key="option.id"
type="button"
class="run-mode-card"
:class="{ active: form.image_compression === option.id }"
:aria-pressed="form.image_compression === option.id"
@click.prevent="personalization.setImageCompression(option.id)"
>
<div class="run-mode-card-header">
<span class="run-mode-title">{{ option.label }}</span>
</div>
<p class="run-mode-desc">{{ option.desc }}</p>
</button>
</div>
<p class="behavior-hint">缩放保持原始比例若原图已低于目标分辨率将直接使用原图</p>
</div>
</div>
<div class="personal-actions-row">
<div class="personal-form-actions card-aligned">
<div class="personal-status-group">
<transition name="personal-status-fade">
<span class="status success" v-if="status">{{ status }}</span>
</transition>
<transition name="personal-status-fade">
<span class="status error" v-if="error">{{ error }}</span>
</transition>
</div>
<button type="submit" class="primary" :disabled="saving">
{{ saving ? '保存中...' : '保存设置' }}
</button>
</div>
</div>
</section>
<section v-else-if="activeTab === 'model'" key="model" class="personal-page behavior-page">
<div class="behavior-section">
<div class="behavior-field">
@ -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;
};

View File

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

View File

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