feat: add image compression preference for uploads
This commit is contained in:
parent
60d27e9c1c
commit
6f8c1b36cc
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user