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 @@
-
+
@@ -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 @@
🚀 快速开始
- 确保 Ollama 已启动(默认
http://127.0.0.1:11434,可在设置中修改) - 顶部模型栏选择一个模型,右侧会显示模型能力徽章(🧠 Think / 👁️ Vision / 🔧 Tools)
- 输入消息,按 Enter 发送,Shift+Enter 换行
-
💬 聊天功能
- 流式回复 — 实时打字效果,随时点 ■ 停止
- Think 推理 — 下方 Think 按钮切换,让模型展示深度思考过程(需模型支持,如 Qwen3)
- 上下文长度自动检测 — 切换模型时自动从模型元数据获取实际支持的上下文长度(如 Qwen3.6 27B = 262,144 tokens),无需手动配置
- 多模态 — 上传图片,模型需支持 Vision 能力。图片自动压缩至合适分辨率,节省上下文
- 文件分析 — 支持 50+ 种文本/代码格式,单文件 ≤500KB,自动剥离注释并按上下文预算智能截断
- AI 回复顶部 — 每条 AI 回复上方展示 📋 系统提示词折叠卡片,可点击查看实际发送给模型的完整上下文
+
💬 聊天功能
- 流式回复 — 实时打字效果,随时点 ■ 停止
- Think 推理 — 下方 Think 按钮切换,让模型展示深度思考过程(需模型支持,如 Qwen3)
- 上下文长度自动检测 — 切换模型时自动从模型元数据获取实际支持的上下文长度(如 Qwen3.6 27B = 262,144 tokens),无需手动配置
- 多模态 — 上传图片或视频(≤10MB),模型需支持 Vision。图片自动压缩,视频 1fps 提取帧序列带时序标注
- 文件分析 — 支持 50+ 种文本/代码格式,单文件 ≤500KB,自动剥离注释并按上下文预算智能截断
- AI 回复顶部 — 每条 AI 回复上方展示 📋 系统提示词折叠卡片,可点击查看实际发送给模型的完整上下文
🔧 Tool Calling(始终开启)
- 所有消息均通过 Agent Loop 自主调用本地工具,像一个本地 Agent,无普通聊天模式
- 44 个工具,分为 10 类:
- 文件系统(16 个):read_file / write_file / list_directory / search_files / create_directory / delete_file / move_file / copy_file / edit_file / get_file_info / tree / download_file / diff_files / replace_in_files / read_multiple_files / compress
- 命令执行(1 个):run_command(实时流式输出,支持自动/需确认/禁用三种模式,可配置超时)
- 联网搜索(2 个):web_search(支持 SearXNG 元搜索引擎 JSON API / 四引擎 HTML 解析,双模式可切换)/ web_fetch(反爬headers+UA自动切换+自动回退浏览器渲染)
- Git(1 个):git(17 个子操作,push/pull/clone 内置60-120s超时保护)
- 浏览器控制(9 个):browser_open / browser_screenshot / browser_evaluate / browser_extract / browser_click / browser_type / browser_scroll / browser_wait / browser_close
- 记忆 & 会话 & Skill(8 个):memory_search / memory_add / memory_replace / memory_remove / session_list / session_read / skill_list / skill_view
- 子代理(1 个):spawn_task
- 系统工具(6 个):datetime / calculator / random / uuid / json_format / hash
- read_file 支持 binary模式读取/base64解码/字节分页/2000行默认+截断hint自动续读
- write_file 支持 base64写二进制文件/mode(overwrite+append)合并append_file功能/10MB
- edit_file 支持 use_regex 正则替换
- search_files 支持正则表达式搜索(use_regex=true)
- browser_screenshot 支持全页面截图+元素截图
- browser_extract 支持CSS选择器提取特定区域
- new browser_wait 等待元素出现或定时等待
- 仅
run_command 支持三模式切换:自动/需确认/禁用 - 危险命令(
rm -rf、mkfs、反弹 shell 等)和系统路径(/etc、~/.ssh 等)被自动拦截 - 工具调用以可视化卡片展示状态(pending → running → success/error),在工作空间🔧工具页签中显示
- 默认最大 85 轮工具调用循环,上下文使用率>80%时自动缩减到3轮
- 独立工具自动并行执行(16个只读工具加入并行白名单),有依赖关系的工具串行执行
🧠 Agent 记忆系统
- 设置中开启后,AI 从对话中自动提取关键信息(事实/偏好/规则),跨会话持续积累
- 增量提取:长对话中每 20 轮自动触发轻量级记忆提取,不等到对话结束
- 对话结束时自动触发完整记忆提取
- 新对话时自动检索相关记忆注入上下文,让 AI "记住"你
- 点击顶部 🧠 按钮打开记忆面板:搜索、筛选、编辑、删除
- 记忆容量上限 500 条,超限时自动清理低价值条目(规则类型受保护)
- 向量语义搜索:在设置中选择嵌入模型后,支持语义级别记忆检索(更精准)
- 未选择嵌入模型时,使用关键词匹配检索记忆
- AI 可通过 memory 工具主动管理记忆
- 记忆写入前自动安全扫描(prompt injection / 敏感信息 / 不可见字符检测)
- 记忆存储在本地 SQLite,不会上传到任何服务器
-
🤖 Agent Loop v0.11.6 增强
- 智能上下文压缩:滑动窗口 + LLM结构化JSON摘要,超过50%上下文窗口自动触发
- 流式调用超时保护:可配置超时(默认300s),Ollama 卡死不再永久阻塞
- HTTP/MCP 超时可配:设置面板可分别调整 HTTP(默认30s)和 MCP(默认60s)超时
- Final Answer 智能检测:多模式匹配(Final Answer/最终答案/最终回答/总结),避免漏检或误触
- Token 感知迭代预算:上下文使用率>80%时自动缩减剩余轮次到3轮
- 工具缓存 TTL:搜索5分钟/网页10分钟/文件30分钟,过期自动刷新
- 自动子任务拆解:检测"同时/分别/以及"等并行关键词,自动 spawn 子代理并行处理
- 旧工具结果自动截断:超过10轮的工具结果自动截断到500字符,控制上下文膨胀
+
🤖 Agent Loop v0.11.7 增强
- 🎬 视频上传:支持上传 .mp4/.avi/.mov/.mkv/.webm 等视频(≤10MB),自动 1fps 提取帧序列(带时间戳),多模态模型原生理解视频时序关系
- 智能上下文压缩:滑动窗口 + LLM结构化JSON摘要,超过50%上下文窗口自动触发
- 流式调用超时保护:可配置超时(默认300s),Ollama 卡死不再永久阻塞
- HTTP/MCP 超时可配:设置面板可分别调整 HTTP(默认30s)和 MCP(默认60s)超时
- Final Answer 智能检测:多模式匹配(Final Answer/最终答案/最终回答/总结),避免漏检或误触
- Token 感知迭代预算:上下文使用率>80%时自动缩减剩余轮次到3轮
- 工具缓存 TTL:搜索5分钟/网页10分钟/文件30分钟,过期自动刷新
- 自动子任务拆解:检测"同时/分别/以及"等并行关键词,自动 spawn 子代理并行处理
- 旧工具结果自动截断:超过10轮的工具结果自动截断到500字符,控制上下文膨胀
🔌 MCP(Model Context Protocol)
- 支持连接外部 MCP Server,动态扩展工具能力
- 设置面板可添加/启用/禁用/删除 MCP 服务器
- MCP 工具以
mcp_{server}_{tool} 前缀注册,与内置工具统一调度 - 启动时自动连接已启用的 MCP 服务器
🔍 SearXNG 元搜索引擎
- 点击顶部 🔍 按钮打开配置面板,可接入自部署的 SearXNG 实例
- JSON 模式:调用 SearXNG JSON API,聚合 70+ 引擎结果,结构化解析
- HTML 模式:获取原始搜索结果页面,交由 AI 自行分析提取信息
- 支持认证 Key(HTTP Header Authorization),保护私有实例
- 启用后替代内置四引擎方案;关闭即回退,无缝切换
- 所有参数(引擎、语言、安全搜索、时间范围等)均可独立配置
📋 自定义文件(SOUL.md / AGENT.md / USER.md)
- 在工作空间目录创建以下文件即可自定义 AI 行为,修改后下一轮对话立即生效
- SOUL.md — AI 身份、性格、行为准则(永远不可被压缩,注入为最高优先级系统提示词)
- AGENT.md — 工具调用规则、链式调用模式、核心约束(内置精简默认版,可通过工作空间覆盖)
- USER.md — 用户画像:技术栈、偏好、习惯等个人信息,AI 在对话中自动参考
- 可在 AI 回复顶部的 📋 系统提示词卡片中查看实际注入的完整上下文
- 删除工作空间中的文件即可恢复为内置默认版本
diff --git a/src/renderer/styles/style.css b/src/renderer/styles/style.css
index ceb4b04..3bedc28 100644
--- a/src/renderer/styles/style.css
+++ b/src/renderer/styles/style.css
@@ -351,6 +351,15 @@ html, body {
background: var(--critical-bg);
}
+#btnAttachVideo.disabled {
+ pointer-events: auto;
+}
+
+#btnAttachVideo.disabled:hover {
+ opacity: 0.3;
+ background: var(--critical-bg);
+}
+
.icon-btn.sm {
width: 28px;
height: 28px;
diff --git a/src/renderer/types.d.ts b/src/renderer/types.d.ts
index 65b34e6..9400dd2 100644
--- a/src/renderer/types.d.ts
+++ b/src/renderer/types.d.ts
@@ -271,6 +271,14 @@ export interface MetonaDesktopAPI {
getStatuses: () => Promise
>;
refreshTools: (name: string) => Promise }>>;
};
+ video: {
+ extractFrames: (filePath: string, options?: { maxFrames?: number; maxWidth?: number; maxSize?: number }) => Promise<{
+ success: boolean;
+ frames?: Array<{ name: string; base64: string; timestampSeconds: number }>;
+ videoInfo?: { duration: number; width: number; height: number };
+ error?: string;
+ }>;
+ };
}
export interface AppInfo {