From 5ed9a1f9f6cfd5fe15ae6be6512d6752bcf6bdcc Mon Sep 17 00:00:00 2001 From: thzxx Date: Thu, 18 Jun 2026 10:58:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=A7=86=E9=A2=91=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E6=94=AF=E6=8C=81=20(v0.11.7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增视频上传按钮,支持 mp4/avi/mov/mkv/webm (≤10MB) - ffmpeg 1fps 提取帧序列,自动缩放至512px,带时序标注 - 消息中注入帧序列时间线,多模态模型原生理解视频时序 - 视频按钮与 vision 模型能力自动联动 - 版本号 0.11.6 → 0.11.7,同步更新 README/帮助面板/菜单 --- README.md | 6 +- docs/BUILD.md | 6 +- docs/DEVELOPMENT.md | 2 +- package-lock.json | 4 +- package.json | 2 +- src/main/ipc.ts | 131 ++++++++++++++++++++++++++ src/main/menu.ts | 2 +- src/main/preload.ts | 4 + src/renderer/components/input-area.ts | 77 ++++++++++++++- src/renderer/components/model-bar.ts | 12 +++ src/renderer/index.html | 12 ++- src/renderer/styles/style.css | 9 ++ src/renderer/types.d.ts | 8 ++ 13 files changed, 258 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 6b6a70e..c2a2438 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@

- version + version electron typescript license @@ -245,7 +245,7 @@ npm start ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist ``` -产出:`release/Metona Ollama Setup v0.11.6.exe` +产出:`release/Metona Ollama Setup v0.11.7.exe` ## 🛠️ 常用命令 @@ -481,7 +481,7 @@ npm start ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist ``` -Output: `release/Metona Ollama Setup v0.11.6.exe` +Output: `release/Metona Ollama Setup v0.11.7.exe` ## 🛠️ Common Commands diff --git a/docs/BUILD.md b/docs/BUILD.md index c820148..31e325c 100644 --- a/docs/BUILD.md +++ b/docs/BUILD.md @@ -91,7 +91,7 @@ npm run dist # NSIS 安装包 | 文件 | 说明 | |------|------| -| `release/Metona Ollama Setup v0.11.6.exe` | NSIS 安装包(可选目录、创建快捷方式) | +| `release/Metona Ollama Setup v0.11.7.exe` | NSIS 安装包(可选目录、创建快捷方式) | > 仅提供 NSIS 安装包。 @@ -103,7 +103,7 @@ npm run dist # NSIS 安装包 TOKEN="your_access_token" curl -X POST "https://gitee.com/api/v5/repos/thzxx/metona-ollama-desktop/releases?access_token=$TOKEN" \ -H "Content-Type: application/json" \ - -d '{"tag_name":"v0.11.6","name":"v0.11.6","body":"Release notes","target_commitish":"master"}' + -d '{"tag_name":"v0.11.7","name":"v0.11.7","body":"Release notes","target_commitish":"master"}' ``` ### 上传附件 @@ -112,7 +112,7 @@ curl -X POST "https://gitee.com/api/v5/repos/thzxx/metona-ollama-desktop/release TOKEN="your_access_token" RELEASE_ID="" curl -X POST "https://gitee.com/api/v5/repos/thzxx/metona-ollama-desktop/releases/$RELEASE_ID/attach_files?access_token=$TOKEN" \ - -H "Content-Type: multipart/form-data" -F "file=@release/Metona Ollama Setup v0.11.6.exe" + -H "Content-Type: multipart/form-data" -F "file=@release/Metona Ollama Setup v0.11.7.exe" ``` ## 故障排查 diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index f468a5a..5699114 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1,6 +1,6 @@ # Metona Ollama Desktop — 开发规范 -> 版本: v0.11.6 | 更新: 2026-06-05 | 维护: 项目团队 +> 版本: v0.11.7 | 更新: 2026-06-05 | 维护: 项目团队 --- diff --git a/package-lock.json b/package-lock.json index 2645d7c..6e96f8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "metona-ollama-desktop", - "version": "0.11.6", + "version": "0.11.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "metona-ollama-desktop", - "version": "0.11.6", + "version": "0.11.7", "license": "MIT", "dependencies": { "sql.js": "^1.11.0" diff --git a/package.json b/package.json index 4f60458..0820349 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metona-ollama-desktop", - "version": "0.11.6", + "version": "0.11.7", "description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端", "main": "dist/main/main.js", "author": "thzxx", diff --git a/src/main/ipc.ts b/src/main/ipc.ts index ee0ed2d..2bc8c90 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -5,6 +5,7 @@ import { ipcMain, dialog, shell } from 'electron'; import * as fs from 'fs'; import * as path from 'path'; +import * as os from 'os'; import { mainWindow } from './main.js'; import { showNotification } from './utils.js'; import { @@ -58,6 +59,8 @@ import { startServer, stopServer, stopAllServers, callTool, getAllTools, getServ import { getAllowedDirs, getBlockedDirs, setAllowedDirs } from './tool-security.js'; import { startProcess, killProcess, getWorkspaceDir, setWorkspaceDir, listWorkspaceDir } from './workspace.js'; import { setHTTPTimeout } from './tool-handlers-system.js'; +import { execFile } from 'child_process'; +import * as crypto from 'crypto'; /** 工具结果摘要(用于日志面板) */ function summarizeResult(toolName: string, result: Record): string { @@ -479,4 +482,132 @@ export async function setupIPC(): Promise { ipcMain.handle('mcp:refreshTools', async (_, name: string) => { return refreshTools(name); }); + + // ── 视频帧提取 ── + ipcMain.handle('video:extractFrames', async (_, filePath: string, options?: { maxFrames?: number; maxWidth?: number; maxSize?: number }) => { + const maxFrames = options?.maxFrames || 600; + const maxWidth = options?.maxWidth || 512; + const maxSize = options?.maxSize || 10 * 1024 * 1024; // 默认 10MB + + // 文件大小检查 + try { + const stat = await fs.promises.stat(filePath); + if (stat.size > maxSize) { + return { success: false, error: `视频文件过大: ${(stat.size / 1024 / 1024).toFixed(1)}MB,限制 ${maxSize / 1024 / 1024}MB` }; + } + } catch { + return { success: false, error: '无法读取视频文件' }; + } + + return extractVideoFrames(filePath, maxFrames, maxWidth); + }); +} + +// ── 视频帧提取 (ffmpeg) ── + +interface ExtractedFrame { + name: string; // 如 "frame_01_0.0s.jpg" + base64: string; // JPEG base64 + timestampSeconds: number; // 帧在视频中的时间位置(秒) +} + +interface VideoInfo { + duration: number; // 秒 + width: number; + height: number; +} + +function ffprobeVideo(filePath: string): Promise { + return new Promise((resolve, reject) => { + execFile('ffprobe', [ + '-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', + '-select_streams', 'v:0', filePath + ], { timeout: 15_000 }, (err, stdout) => { + if (err) { reject(new Error(`ffprobe 失败: ${err.message}`)); return; } + try { + const info = JSON.parse(stdout); + const stream = (info.streams || []).find((s: any) => s.codec_type === 'video'); + if (!stream) { reject(new Error('未找到视频流')); return; } + resolve({ + duration: parseFloat(info.format?.duration || stream.duration || '0'), + width: stream.width || 0, + height: stream.height || 0 + }); + } catch { reject(new Error('ffprobe 输出解析失败')); } + }); + }); +} + +function extractVideoFrames(filePath: string, maxFrames: number, maxWidth: number): Promise<{ success: boolean; frames?: ExtractedFrame[]; videoInfo?: VideoInfo; error?: string }> { + return new Promise(async (resolve) => { + const tmpDir = path.join(os.tmpdir(), `metona-video-${crypto.randomBytes(6).toString('hex')}`); + try { + await fs.promises.mkdir(tmpDir, { recursive: true }); + + // 1. 获取视频信息 + const videoInfo = await ffprobeVideo(filePath); + if (videoInfo.duration <= 0) { + await fs.promises.rm(tmpDir, { recursive: true, force: true }); + resolve({ success: false, error: '无法获取视频时长' }); + return; + } + + // 2. 计算帧提取策略:固定 1fps,按视频实际时长 + const frameCount = Math.min(Math.ceil(videoInfo.duration), maxFrames); + const scaleFilter = videoInfo.width > maxWidth + ? `scale=${maxWidth}:-1` + : 'scale=iw:ih'; // 不放大 + + // 3. ffmpeg 提取帧:固定 1fps + const outPattern = path.join(tmpDir, 'frame_%04d.jpg'); + await new Promise((ffResolve, ffReject) => { + execFile('ffmpeg', [ + '-y', + '-i', filePath, + '-vf', `${scaleFilter},fps=1`, + '-q:v', '3', // JPEG 质量 (2-31, 越小越好, 3≈80%) + '-frames:v', String(frameCount), + '-threads', '2', + outPattern + ], { timeout: 120_000 }, (err) => { + if (err) { ffReject(new Error(`ffmpeg 执行失败: ${err.message}`)); return; } + ffResolve(); + }); + }); + + // 4. 读取提取的帧为 base64,附时间戳 + const files = await fs.promises.readdir(tmpDir); + const jpegs = files.filter(f => f.endsWith('.jpg')).sort(); + if (jpegs.length === 0) { + await fs.promises.rm(tmpDir, { recursive: true, force: true }); + resolve({ success: false, error: '没有提取到视频帧' }); + return; + } + + const frames: ExtractedFrame[] = []; + for (let i = 0; i < jpegs.length; i++) { + const f = jpegs[i]; + const buf = await fs.promises.readFile(path.join(tmpDir, f)); + // 跳过太小的帧(可能是纯黑/白,无信息量) + if (buf.length < 200) continue; + const timestampSec = i; // fps=1,所以帧索引 = 秒数 + frames.push({ + name: `frame_${String(i + 1).padStart(2, '0')}_${timestampSec.toFixed(1)}s.jpg`, + base64: buf.toString('base64'), + timestampSeconds: timestampSec + }); + } + + // 5. 清理临时文件 + await fs.promises.rm(tmpDir, { recursive: true, force: true }); + + sendLog('info', `🎬 视频帧提取完成: 1fps`, `${filePath} → ${frames.length} 帧 / ${videoInfo.duration.toFixed(1)}s (${videoInfo.width}x${videoInfo.height})`); + resolve({ success: true, frames, videoInfo }); + + } catch (err) { + try { await fs.promises.rm(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ } + sendLog('error', '视频帧提取失败', (err as Error).message); + resolve({ success: false, error: (err as Error).message }); + } + }); } diff --git a/src/main/menu.ts b/src/main/menu.ts index 9210a53..47803b4 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -101,7 +101,7 @@ export function createMenu(): void { dialog.showMessageBox(mainWindow!, { type: 'info', title: '关于 Metona Ollama', - message: 'Metona Ollama Desktop v0.11.6', + message: 'Metona Ollama Desktop v0.11.7', detail: 'TypeScript + Electron Ollama AI 聊天客户端\n\nhttps://gitee.com/thzxx/metona-ollama', icon: getIconPath() }); diff --git a/src/main/preload.ts b/src/main/preload.ts index 6214d02..9004e2a 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -124,5 +124,9 @@ contextBridge.exposeInMainWorld('metonaDesktop', { getTools: () => ipcRenderer.invoke('mcp:getTools'), getStatuses: () => ipcRenderer.invoke('mcp:getStatuses'), refreshTools: (name: string) => ipcRenderer.invoke('mcp:refreshTools', name) + }, + video: { + extractFrames: (filePath: string, options?: { maxFrames?: number; maxWidth?: number; maxSize?: number }) => + ipcRenderer.invoke('video:extractFrames', filePath, options) } }); diff --git a/src/renderer/components/input-area.ts b/src/renderer/components/input-area.ts index 4575413..408f69b 100644 --- a/src/renderer/components/input-area.ts +++ b/src/renderer/components/input-area.ts @@ -26,8 +26,9 @@ let chatInputEl: HTMLTextAreaElement; let btnSendEl: HTMLButtonElement; let imagePreviewEl: HTMLElement; let filePreviewEl: HTMLElement; -let pendingImages: Array<{ name: string; base64: string }> = []; +let pendingImages: Array<{ name: string; base64: string; timestampSeconds?: number }> = []; let pendingFiles: Array<{ name: string; content: string; language: string; size: number }> = []; +let pendingVideoInfo: { duration: number; width: number; height: number } | null = null; const TEXT_EXTENSIONS = new Set([ 'txt','md','markdown','rst','log','csv','tsv', @@ -42,6 +43,8 @@ const TEXT_EXTENSIONS = new Set([ const MAX_FILE_SIZE = 500 * 1024; +const VIDEO_EXTENSIONS = new Set(['mp4', 'avi', 'mov', 'mkv', 'webm', 'flv', 'wmv', 'm4v']); + function getFileIcon(filename: string): string { const name = filename.toLowerCase(); if (name === 'dockerfile') return '🐳'; @@ -111,6 +114,21 @@ export function initInputArea(): void { await handleTextFilePaths(paths); }); + // ── 视频上传:提取帧作为图片 ── + document.querySelector('#btnAttachVideo')!.addEventListener('click', async () => { + if (!isVisionAvailable()) { + showToast('当前模型不支持图片/视频分析', 'warning'); + return; + } + const bridge = getBridge(); + if (!bridge) return; + const paths = await bridge.dialog.openFile({ + filters: [{ name: '视频', extensions: Array.from(VIDEO_EXTENSIONS) }] + }); + if (!paths || paths.length === 0) return; + await handleVideoPath(paths[0]); + }); + imagePreviewEl.addEventListener('click', (e) => { const target = e.target as HTMLElement; if (target.classList.contains('remove-img')) { @@ -222,6 +240,45 @@ async function handleImagePaths(paths: string[]): Promise { renderImagePreviews(); } +async function handleVideoPath(filePath: string): Promise { + const bridge = getBridge(); + if (!bridge?.video) { + showToast('视频功能需要桌面版', 'warning'); + return; + } + + const name = filePath.split(/[/\\]/).pop() || filePath; + appendSystemMessage(`🎬 正在提取视频帧 (1fps): ${name}...`); + + try { + const result = await bridge.video.extractFrames(filePath, { maxFrames: 60, maxWidth: 512 }); + if (!result.success) { + appendSystemMessage(`❌ 视频帧提取失败: ${result.error}`); + return; + } + + const frames = result.frames!; + const info = result.videoInfo!; + + // 保存视频元信息(用于消息构建时的时序标注) + pendingVideoInfo = { duration: info.duration, width: info.width, height: info.height }; + + // 帧加入 pendingImages,保留时间戳 + for (const frame of frames) { + pendingImages.push({ name: frame.name, base64: frame.base64, timestampSeconds: frame.timestampSeconds }); + } + + logInfo(`视频帧提取完成: ${name}`, + `${frames.length} 帧 (1fps, ${info.duration.toFixed(0)}s, ${info.width}x${info.height})`); + appendSystemMessage(`✅ ${name}: 已提取 ${frames.length} 帧 (1fps 采样)`); + + renderImagePreviews(); + } catch (err) { + logError('视频帧提取异常', (err as Error).message); + appendSystemMessage(`❌ 视频处理失败: ${(err as Error).message}`); + } +} + function renderImagePreviews(): void { if (pendingImages.length === 0) { imagePreviewEl.style.display = 'none'; @@ -843,7 +900,20 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio const images = pendingImages.map(img => img.base64); if (pendingImages.length > 0) { - const imgDesc = pendingImages.length === 1 ? `[上传了图片: ${pendingImages[0].name}]` : `[上传了 ${pendingImages.length} 张图片]`; + const hasVideoFrames = pendingVideoInfo !== null && pendingImages.some(img => img.timestampSeconds !== undefined); + let imgDesc: string; + if (hasVideoFrames) { + const vi = pendingVideoInfo!; + const frameList = pendingImages + .filter(img => img.timestampSeconds !== undefined) + .map(img => ` ${String(img.timestampSeconds!).padStart(4, ' ')}s — ${img.name}`) + .join('\n'); + imgDesc = `[视频帧序列 · ${pendingImages.length}帧 · 1fps · ${vi.duration.toFixed(0)}s]\n${frameList}\n\n上述帧按时间顺序排列,帧间存在时序关系。请分析视频内容。`; + } else if (pendingImages.length === 1) { + imgDesc = `[上传了图片: ${pendingImages[0].name}]`; + } else { + imgDesc = `[上传了 ${pendingImages.length} 张图片]`; + } const msg: ChatMessage = { role: 'user', content: text ? `${text}\n\n${imgDesc}` : imgDesc, @@ -872,7 +942,7 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio state.update(KEYS.CURRENT_SESSION, (session: any) => ({ ...session, ...(isFirstMsg && { - title: truncate(text || (pendingFiles.length > 0 ? `[文件: ${pendingFiles.map(f => f.name).join(', ')}]` : '[图片消息]'), 30), + title: truncate(text || (pendingFiles.length > 0 ? `[文件: ${pendingFiles.map(f => f.name).join(', ')}]` : pendingVideoInfo ? '[视频消息]' : '[图片消息]'), 30), model }), messages: [...session.messages, ...msgsToAdd], @@ -888,6 +958,7 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio chatInputEl.value = ''; pendingImages = []; pendingFiles = []; + pendingVideoInfo = null; imagePreviewEl.style.display = 'none'; imagePreviewEl.innerHTML = ''; filePreviewEl.style.display = 'none'; diff --git a/src/renderer/components/model-bar.ts b/src/renderer/components/model-bar.ts index e9b538c..88a7a5d 100644 --- a/src/renderer/components/model-bar.ts +++ b/src/renderer/components/model-bar.ts @@ -18,6 +18,7 @@ let badgeToolsEl: HTMLElement; let thinkCheckbox: HTMLInputElement; let thinkWrap: HTMLElement; let btnAttachImg: HTMLButtonElement; +let btnAttachVideo: HTMLButtonElement; const modelCapabilityCache = new Map(); @@ -29,6 +30,7 @@ export function initModelBar(): void { thinkCheckbox = document.querySelector('#toggleThink')!; thinkWrap = document.querySelector('#thinkToggleWrap')!; btnAttachImg = document.querySelector('#btnAttachImg')!; + btnAttachVideo = document.querySelector('#btnAttachVideo')!; // Think 开关:同步 state 中的 thinkEnabled thinkCheckbox.addEventListener('change', () => { @@ -252,10 +254,20 @@ function setVisionAvailable(available: boolean): void { btnAttachImg.disabled = false; btnAttachImg.title = '上传图片'; btnAttachImg.classList.remove('disabled'); + if (btnAttachVideo) { + btnAttachVideo.disabled = false; + btnAttachVideo.title = '上传视频(提取帧分析)'; + btnAttachVideo.classList.remove('disabled'); + } } else { btnAttachImg.disabled = true; btnAttachImg.title = '当前模型不支持图片分析'; btnAttachImg.classList.add('disabled'); + if (btnAttachVideo) { + btnAttachVideo.disabled = true; + btnAttachVideo.title = '当前模型不支持图片/视频分析'; + btnAttachVideo.classList.add('disabled'); + } } } diff --git a/src/renderer/index.html b/src/renderer/index.html index f8df08f..e6038f7 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -28,7 +28,7 @@

Metona Ollama - v0.11.6 + v0.11.7 +