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 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 # Reset category states to defaults before applying overrides
for key, category in self.tool_categories_map.items(): for key, category in self.tool_categories_map.items():
self.tool_category_states[key] = False if key in disabled_categories else category.default_enabled 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, "auto_generate_title": True,
"tool_intent_enabled": True, "tool_intent_enabled": True,
"default_model": "kimi-k2.5", "default_model": "kimi-k2.5",
"image_compression": "original", # original / 1080p / 720p / 540p
} }
__all__ = [ __all__ = [
@ -110,6 +111,7 @@ def sanitize_personalization_payload(
data = payload or {} data = payload or {}
allowed_tool_categories = set(TOOL_CATEGORIES.keys()) allowed_tool_categories = set(TOOL_CATEGORIES.keys())
allowed_models = {"kimi", "kimi-k2.5", "deepseek", "qwen3-max", "qwen3-vl-plus"} 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: def _resolve_short_field(key: str) -> str:
if key in data: if key in data:
@ -155,6 +157,13 @@ def sanitize_personalization_payload(
elif base.get("default_model") not in allowed_models: elif base.get("default_model") not in allowed_models:
base["default_model"] = "kimi-k2.5" 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 return base

View File

@ -182,6 +182,48 @@
</div> </div>
</div> </div>
</section> </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"> <section v-else-if="activeTab === 'model'" key="model" class="personal-page behavior-page">
<div class="behavior-section"> <div class="behavior-section">
<div class="behavior-field"> <div class="behavior-field">
@ -578,11 +620,12 @@ const baseTabs = [
{ id: 'preferences', label: '个性化设置' }, { id: 'preferences', label: '个性化设置' },
{ id: 'model', label: '模型偏好', description: '默认模型选择' }, { id: 'model', label: '模型偏好', description: '默认模型选择' },
{ id: 'behavior', label: '模型行为' }, { id: 'behavior', label: '模型行为' },
{ id: 'image', label: '图片压缩', description: '发送图片的尺寸策略' },
{ id: 'theme', label: '主题切换', description: '浅色 / 深色 / Claude' }, { id: 'theme', label: '主题切换', description: '浅色 / 深色 / Claude' },
{ id: 'experiments', label: '实验功能', description: 'Liquid Glass' } { id: 'experiments', label: '实验功能', description: 'Liquid Glass' }
] as const; ] 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'); const isAdmin = computed(() => (resourceStore.usageQuota.role || '').toLowerCase() === 'admin');
@ -628,6 +671,13 @@ const thinkingPresets = [
{ id: 'high', label: '高', value: 3 } { 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) => { const setActiveTab = (tab: PersonalTab) => {
activeTab.value = tab; activeTab.value = tab;
}; };

View File

@ -15,6 +15,7 @@ interface PersonalForm {
disabled_tool_categories: string[]; disabled_tool_categories: string[];
default_run_mode: RunMode | null; default_run_mode: RunMode | null;
default_model: string | null; default_model: string | null;
image_compression: string;
} }
interface LiquidGlassPosition { interface LiquidGlassPosition {
@ -65,7 +66,8 @@ const defaultForm = (): PersonalForm => ({
thinking_interval: null, thinking_interval: null,
disabled_tool_categories: [], disabled_tool_categories: [],
default_run_mode: null, default_run_mode: null,
default_model: 'kimi-k2.5' default_model: 'kimi-k2.5',
image_compression: 'original'
}); });
const defaultExperimentState = (): ExperimentState => ({ 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) typeof data.default_run_mode === 'string' && RUN_MODE_OPTIONS.includes(data.default_run_mode as RunMode)
? data.default_run_mode as RunMode ? data.default_run_mode as RunMode
: null, : 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(); this.clearFeedback();
}, },
@ -365,6 +368,15 @@ export const usePersonalizationStore = defineStore('personalization', {
}; };
this.clearFeedback(); 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) { applyTonePreset(preset: string) {
if (!preset) { if (!preset) {
return; return;

View File

@ -4,6 +4,7 @@ import os
import json import json
import base64 import base64
import mimetypes import mimetypes
import io
from copy import deepcopy from copy import deepcopy
from typing import Dict, List, Optional, Any from typing import Dict, List, Optional, Any
from pathlib import Path from pathlib import Path
@ -52,6 +53,7 @@ class ContextManager:
self.conversation_history = [] # 当前对话历史(内存中) self.conversation_history = [] # 当前对话历史(内存中)
self.todo_list: Optional[Dict[str, Any]] = None self.todo_list: Optional[Dict[str, Any]] = None
self.has_images: bool = False self.has_images: bool = False
self.image_compression_mode: str = "original"
# 新增:对话持久化管理器 # 新增:对话持久化管理器
self.conversation_manager = ConversationManager(base_dir=self.data_dir) self.conversation_manager = ConversationManager(base_dir=self.data_dir)
@ -1197,8 +1199,44 @@ class ContextManager:
"is_overflow": sizes["total"] > MAX_CONTEXT_SIZE, "is_overflow": sizes["total"] > MAX_CONTEXT_SIZE,
"usage_percent": (sizes["total"] / MAX_CONTEXT_SIZE) * 100 "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: def _build_content_with_images(self, text: str, images: List[str]) -> Any:
"""将文本与图片路径组合成多模态content图片转换为data URI。""" """将文本与图片路径组合成多模态content图片转换为data URI,支持按设置压缩"""
if not images: if not images:
return text return text
parts: List[Dict[str, Any]] = [] parts: List[Dict[str, Any]] = []
@ -1209,12 +1247,15 @@ class ContextManager:
abs_path = Path(self.project_path) / path abs_path = Path(self.project_path) / path
if not abs_path.exists() or not abs_path.is_file(): if not abs_path.exists() or not abs_path.is_file():
continue continue
mime, _ = mimetypes.guess_type(abs_path.name) data_url = self._compress_image_if_needed(abs_path)
if not mime: if not data_url:
mime = "image/png" mime, _ = mimetypes.guess_type(abs_path.name)
data = abs_path.read_bytes() if not mime:
b64 = base64.b64encode(data).decode("utf-8") mime = "image/png"
parts.append({"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}}) 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: except Exception:
continue continue
return parts if parts else text return parts if parts else text