Skip to content

Commit f72bba7

Browse files
author
linyuan.yang
committed
图片自动缩放
1 parent 6be5fc0 commit f72bba7

9 files changed

Lines changed: 62 additions & 23 deletions

File tree

packages/admin/src/i18n/en.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,8 @@ export default {
173173
lang_zh: '中文',
174174
lang_en: 'English',
175175
service: 'Service',
176+
max_image_size: 'Max Image Size (px)',
177+
max_image_size_hint: 'Images exceeding this size will be proportionally resized. Leave empty to disable.',
176178
tool_approval: 'Tool Approval',
177179
auto_approve_all: 'Auto-approve all tools',
178180
auto_approve_all_hint: 'When enabled, all tool calls are approved without user confirmation',

packages/admin/src/i18n/zh.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,8 @@ export default {
173173
lang_zh: '中文',
174174
lang_en: 'English',
175175
service: '服务',
176+
max_image_size: '图片最大尺寸 (px)',
177+
max_image_size_hint: '图片宽高超过此值时按比例缩小,不设置则不压缩',
176178
tool_approval: '工具审批',
177179
auto_approve_all: '自动批准所有工具',
178180
auto_approve_all_hint: '开启后,智能体调用任何工具均无需用户确认',

packages/admin/src/views/SettingsView.vue

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,15 @@ function changeLocale(lang: string) {
2020
2121
const httpPort = ref<number | ''>('')
2222
const httpUrl = ref('')
23+
const maxImageSize = ref<number | ''>('')
2324
const autoApproveAllTools = ref(false)
2425
const autoApproveToolsText = ref('')
2526
const startupCommands = ref<string[]>([])
2627
2728
watch(() => store.settings, (s) => {
2829
httpPort.value = s.httpPort ?? ''
2930
httpUrl.value = s.httpUrl || ''
31+
maxImageSize.value = s.maxImageSize ?? ''
3032
autoApproveAllTools.value = s.autoApproveAllTools ?? false
3133
autoApproveToolsText.value = (s.autoApproveTools ?? []).join(', ')
3234
startupCommands.value = [...(s.startupCommands ?? [])]
@@ -88,6 +90,7 @@ async function save() {
8890
const res = await apiFetch('/api/settings/general', 'PUT', {
8991
httpPort: httpPort.value === '' ? undefined : Number(httpPort.value),
9092
httpUrl: httpUrl.value.trim() || undefined,
93+
maxImageSize: maxImageSize.value === '' ? undefined : Number(maxImageSize.value),
9194
autoApproveAllTools: autoApproveAllTools.value,
9295
autoApproveTools: tools,
9396
startupCommands: cmds,
@@ -132,6 +135,11 @@ async function save() {
132135
<label>{{ t('settings.http_url') }}</label>
133136
<input v-model="httpUrl" type="text" placeholder="http://localhost:5500" />
134137
</div>
138+
<div class="form-group">
139+
<label>{{ t('settings.max_image_size') }}</label>
140+
<input v-model.number="maxImageSize" type="number" placeholder="1024" min="0" />
141+
<div class="form-hint">{{ t('settings.max_image_size_hint') }}</div>
142+
</div>
135143
</div>
136144
</div>
137145
<div class="card">

packages/sbot/src/Core/Config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ export interface Settings {
7272
autoApproveAllTools?: boolean; // 全局自动批准所有工具(无需用户确认)
7373
startupCommands?: string[]; // 启动后立即执行的命令行列表,依次同步执行
7474
checkUpdateTime?: number; // 下次检查更新的时间戳(ms),0 或 undefined 表示立即检查
75+
maxImageSize?: number; // 图片最大尺寸(px),max(width,height) 超过此值时按比例缩小;不设置则不压缩
7576
models?: Record<string, NamedModelConfig>;
7677
embeddings?: Record<string, NamedEmbeddingConfig>;
7778
savers?: Record<string, SaverConfig>;
@@ -85,7 +86,7 @@ export interface Settings {
8586
// Record<keyof Settings, true> 保证与接口同步:漏写或多写都会编译报错
8687
const SETTINGS_KEYS: ReadonlySet<string> = new Set(Object.keys({
8788
httpPort: true, httpUrl: true, autoApproveTools: true, autoApproveAllTools: true,
88-
startupCommands: true, checkUpdateTime: true,
89+
startupCommands: true, checkUpdateTime: true, maxImageSize: true,
8990
models: true, embeddings: true, savers: true, memories: true, wikis: true, channels: true,
9091
plugins: true, agentSources: true,
9192
} satisfies Record<keyof Settings, true>));

packages/sbot/src/Server/HttpServer.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import os from 'os';
66
import { randomUUID } from 'crypto';
77
import { z } from 'zod';
88
import { WebSocket, WebSocketServer } from 'ws';
9-
import { AgentToolService, SkillService, ModelProvider, listThreadIds, listSubDirs, readImageAsDataUrl, isEmptyContent, type StoredMessage, type MessageContent } from "scorpio.ai";
9+
import { AgentToolService, SkillService, ModelProvider, listThreadIds, listSubDirs, readImageAsDataUrl, isEmptyContent, resizeImageIfNeeded, setMaxImageSize, type StoredMessage, type MessageContent } from "scorpio.ai";
1010
import { config, isDev, isValidAgentId } from '../Core/Config';
1111
import { AgentRunner } from '../Agent/AgentRunner';
1212
import { globalAgentToolService, refreshGlobalAgentToolService, refreshBuiltinTools, BuiltinProvider } from '../Agent/GlobalAgentToolService';
@@ -198,15 +198,16 @@ function isImageDataUrl(dataUrl: string): boolean {
198198
* Parts preserve the interleaved order from the editor.
199199
* File attachments (non-inline) are appended at the end.
200200
*/
201-
function processMessage(parts: ContentPartInput[], attachments: AttachmentInput[] | undefined, uploadDir: string): MessageContent {
201+
async function processMessage(parts: ContentPartInput[], attachments: AttachmentInput[] | undefined, uploadDir: string): Promise<MessageContent> {
202202
const msgParts: Array<{ type: string; text?: string; [key: string]: any }> = [];
203203
let hasImage = false;
204204

205205
for (const p of parts) {
206206
if (p.type === 'text') {
207207
msgParts.push({ type: 'text', text: p.text });
208208
} else if (p.type === 'image' && p.dataUrl) {
209-
msgParts.push({ type: 'image_url', image_url: { url: p.dataUrl } });
209+
const url = await resizeImageIfNeeded(p.dataUrl);
210+
msgParts.push({ type: 'image_url', image_url: { url } });
210211
hasImage = true;
211212
}
212213
}
@@ -215,7 +216,8 @@ function processMessage(parts: ContentPartInput[], attachments: AttachmentInput[
215216
if (attachments?.length) {
216217
for (const att of attachments) {
217218
if (att.dataUrl && isImageDataUrl(att.dataUrl)) {
218-
msgParts.push({ type: 'image_url', image_url: { url: att.dataUrl } });
219+
const url = await resizeImageIfNeeded(att.dataUrl);
220+
msgParts.push({ type: 'image_url', image_url: { url } });
219221
hasImage = true;
220222
} else if (att.dataUrl) {
221223
const filePath = path.join(uploadDir, `${randomUUID()}-${att.name}`);
@@ -397,15 +399,15 @@ class HttpServer {
397399
wss.on('connection', (ws) => {
398400
this.wsClients.add(ws);
399401
ws.on('close', () => { this.wsClients.delete(ws); });
400-
ws.on('message', (data) => {
402+
ws.on('message', async (data) => {
401403
try {
402404
const msg = JSON.parse(data.toString()) as { type?: string; [key: string]: any };
403405
const sid = msg.sessionId as string | undefined;
404406
if (!sid) throw new Error('sessionId is required');
405407
const threadId = sessionThreadId(sid);
406408
switch (msg.type) {
407409
case WsCommandType.Query: {
408-
const enriched = processMessage(msg.parts ?? [], msg.attachments, uploadDir);
410+
const enriched = await processMessage(msg.parts ?? [], msg.attachments, uploadDir);
409411
if (isEmptyContent(enriched)) break;
410412
sessionManager.onReceiveWebMessage(threadId, enriched, sid);
411413
break;
@@ -478,9 +480,13 @@ class HttpServer {
478480
app.get('/api/settings', api(() => this.settingsWithAgents()));
479481

480482
app.put('/api/settings/general', api(req => {
481-
const { httpPort, httpUrl, autoApproveTools, autoApproveAllTools, startupCommands } = req.body;
483+
const { httpPort, httpUrl, maxImageSize, autoApproveTools, autoApproveAllTools, startupCommands } = req.body;
482484
if (httpPort !== undefined) config.settings.httpPort = httpPort || undefined;
483485
if (httpUrl !== undefined) config.settings.httpUrl = httpUrl || undefined;
486+
if (maxImageSize !== undefined) {
487+
config.settings.maxImageSize = maxImageSize || undefined;
488+
setMaxImageSize(config.settings.maxImageSize);
489+
}
484490
if (autoApproveTools !== undefined) config.settings.autoApproveTools = autoApproveTools;
485491
if (autoApproveAllTools !== undefined) config.settings.autoApproveAllTools = autoApproveAllTools;
486492
if (startupCommands !== undefined) config.settings.startupCommands = startupCommands;

packages/sbot/src/Tools/FileSystem/content/readMediaFile.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { z } from 'zod';
55
import { LoggerService } from '../../../Core/LoggerService';
66
import {
77
createTextContent, createImageContent, createAudioContent, createDocumentContent,
8-
createErrorResult, createSuccessResult, type MCPToolResult,
8+
createErrorResult, createSuccessResult, resizeImageIfNeeded, type MCPToolResult,
99
} from 'scorpio.ai';
1010
import { checkFile, formatSize } from '../utils';
1111
import { loadPrompt } from '../../../Core/PromptLoader';
@@ -60,23 +60,31 @@ export function createReadMediaFileTool(): StructuredToolInterface {
6060
func: async ({ filePath }: any): Promise<MCPToolResult> => {
6161
try {
6262
const { abs, stat } = checkFile(filePath);
63+
const { mimeType, category } = detectMedia(abs);
64+
65+
if (category === 'image') {
66+
const buffer = await fsAsync.readFile(abs);
67+
const resized = await resizeImageIfNeeded(buffer);
68+
if (resized.length > MAX_SIZE) {
69+
return createErrorResult(`Image too large after resize: ${formatSize(resized.length)}, maximum is ${MAX_SIZE_LABEL}`);
70+
}
71+
return createSuccessResult(createImageContent(resized.toString('base64'), mimeType));
72+
}
73+
6374
if (stat.size > MAX_SIZE) {
6475
return createErrorResult(`File too large: ${formatSize(stat.size)}, maximum is ${MAX_SIZE_LABEL}`);
6576
}
6677

67-
const base64 = (await fsAsync.readFile(abs)).toString('base64');
68-
const { mimeType, category } = detectMedia(abs);
78+
const buffer = await fsAsync.readFile(abs);
6979

7080
switch (category) {
71-
case 'image':
72-
return createSuccessResult(createImageContent(base64, mimeType));
7381
case 'audio':
74-
return createSuccessResult(createAudioContent(base64, mimeType));
82+
return createSuccessResult(createAudioContent(buffer.toString('base64'), mimeType));
7583
case 'document':
76-
return createSuccessResult(createDocumentContent(base64, mimeType));
84+
return createSuccessResult(createDocumentContent(buffer.toString('base64'), mimeType));
7785
default:
7886
return createSuccessResult(
79-
createTextContent(`mimeType: ${mimeType}\nsize: ${formatSize(stat.size)}\nbase64: ${base64}`),
87+
createTextContent(`mimeType: ${mimeType}\nsize: ${formatSize(stat.size)}\nbase64: ${buffer.toString('base64')}`),
8088
);
8189
}
8290
} catch (e: any) {

packages/sbot/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// 第一行必须导入 logger 配置,确保 log4js 在所有模块加载前初始化
33
import {LoggerService, log4js} from "./Core/LoggerService";
44
import {config} from "./Core/Config";
5+
import {setMaxImageSize} from "scorpio.ai";
56
import {database} from "./Core/Database";
67
import { channelManager } from "./Channel/ChannelManager";
78
import {httpServer} from "./Server/HttpServer";
@@ -199,6 +200,7 @@ async function main() {
199200
}
200201
}
201202

203+
setMaxImageSize(config.settings.maxImageSize);
202204
await database.init()
203205
initGlobalAgentToolService()
204206
initGlobalSkillService()

packages/scorpio.ai/src/Utils/contentUtils.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -70,14 +70,24 @@ export function setMaxImageSize(size: number | undefined) {
7070
maxImageSize = size;
7171
}
7272

73-
async function resizeImageIfNeeded(buffer: Buffer): Promise<Buffer> {
74-
if (!maxImageSize) return buffer;
73+
export async function resizeImageIfNeeded(input: Buffer): Promise<Buffer>;
74+
export async function resizeImageIfNeeded(input: string): Promise<string>;
75+
export async function resizeImageIfNeeded(input: Buffer | string): Promise<Buffer | string> {
76+
if (!maxImageSize) return input;
77+
if (typeof input === 'string') {
78+
const match = input.match(/^data:(image\/[^;]+);base64,(.+)$/);
79+
if (!match) return input;
80+
const buffer = Buffer.from(match[2], 'base64');
81+
const resized = await resizeImageIfNeeded(buffer);
82+
if (resized === buffer) return input;
83+
return `data:${detectImageMimeType(resized)};base64,${resized.toString('base64')}`;
84+
}
7585
const sharp = (await import('sharp')).default;
76-
const metadata = await sharp(buffer).metadata();
86+
const metadata = await sharp(input).metadata();
7787
const { width, height } = metadata;
78-
if (!width || !height) return buffer;
79-
if (Math.max(width, height) <= maxImageSize) return buffer;
80-
return sharp(buffer).resize(maxImageSize, maxImageSize, { fit: 'inside' }).toBuffer();
88+
if (!width || !height) return input;
89+
if (Math.max(width, height) <= maxImageSize) return input;
90+
return sharp(input).resize(maxImageSize, maxImageSize, { fit: 'inside' }).toBuffer();
8191
}
8292

8393
export async function readMediaAsContentPart(filePath: string, mediaAsFilePath = false): Promise<{ part: ContentPart; category: MediaCategory }> {

packages/scorpio.ai/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -320,5 +320,5 @@ export { MessageDispatcher, MessageType, summarizeMultimodal } from "./User";
320320
// ========================================
321321
// Utils - 工具函数
322322
// ========================================
323-
export { contentToString, trimContent, isEmptyContent, readImageAsDataUrl, readMediaAsContentPart, detectMediaType, setMaxImageSize } from "./Utils/contentUtils";
323+
export { contentToString, trimContent, isEmptyContent, readImageAsDataUrl, readMediaAsContentPart, detectMediaType, setMaxImageSize, resizeImageIfNeeded } from "./Utils/contentUtils";
324324
export type { MediaCategory, ContentPart } from "./Utils/contentUtils";

0 commit comments

Comments
 (0)