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
|
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
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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
|
||||||
|
data_url = self._compress_image_if_needed(abs_path)
|
||||||
|
if not data_url:
|
||||||
mime, _ = mimetypes.guess_type(abs_path.name)
|
mime, _ = mimetypes.guess_type(abs_path.name)
|
||||||
if not mime:
|
if not mime:
|
||||||
mime = "image/png"
|
mime = "image/png"
|
||||||
data = abs_path.read_bytes()
|
data = abs_path.read_bytes()
|
||||||
b64 = base64.b64encode(data).decode("utf-8")
|
b64 = base64.b64encode(data).decode("utf-8")
|
||||||
parts.append({"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}})
|
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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user