feat: v3.0 Tool Calling — AI 本地文件操作系统
新增文件: - src/main/tool-security.ts: 路径白名单/黑名单、命令安全检查 - src/main/tool-handlers.ts: 7个工具实现(read/write/list/search/create/delete/run) - src/renderer/services/tool-registry.ts: 工具注册调度中心 - src/renderer/services/agent-engine.ts: Agent Loop 流式多轮工具调用引擎 - src/renderer/components/tool-confirm-modal.ts: 高风险操作确认对话框 修改文件: - types.d.ts: 新增 ToolCall/ToolResult/ToolCallRecord 等类型 - ollama.ts: chatStream 支持 tools 参数 - ipc.ts: 新增 tool:execute/getConfig/setAllowedDirs IPC - preload.ts: 暴露 tool API - chat-area.ts: 渲染工具调用卡片 - input-area.ts: 集成 Agent Loop 引擎 - settings-modal.ts: 工具调用开关设置 - index.html: 工具调用设置面板 + 确认对话框 HTML - style.css: 工具调用卡片、确认对话框样式 - main.ts: 初始化工具调用配置
This commit is contained in:
@@ -7,6 +7,16 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { mainWindow } from './main.js';
|
||||
import { showNotification } from './utils.js';
|
||||
import {
|
||||
handleReadFile,
|
||||
handleWriteFile,
|
||||
handleListDir,
|
||||
handleSearchFiles,
|
||||
handleCreateDir,
|
||||
handleDeleteFile,
|
||||
handleRunCommand
|
||||
} from './tool-handlers.js';
|
||||
import { getAllowedDirs, getBlockedDirs, setAllowedDirs } from './tool-security.js';
|
||||
|
||||
export function setupIPC(): void {
|
||||
ipcMain.handle('dialog:openFile', async (_, options?: { filters?: Array<{ name: string; extensions: string[] }> }) => {
|
||||
@@ -69,4 +79,27 @@ export function setupIPC(): void {
|
||||
shell.openExternal(url);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Tool Calling IPC ──
|
||||
ipcMain.handle('tool:execute', async (_, toolName: string, args: Record<string, unknown>) => {
|
||||
switch (toolName) {
|
||||
case 'read_file': return handleReadFile(args as { path: string; encoding?: string; start_line?: number; end_line?: number });
|
||||
case 'write_file': return handleWriteFile(args as { path: string; content: string; encoding?: string });
|
||||
case 'list_directory': return handleListDir(args as { path: string; recursive?: boolean; max_depth?: number; include_hidden?: boolean; filter_extension?: string });
|
||||
case 'search_files': return handleSearchFiles(args as { path: string; query: string; search_type?: string; case_sensitive?: boolean; max_results?: number; file_extensions?: string[] });
|
||||
case 'create_directory': return handleCreateDir(args as { path: string });
|
||||
case 'delete_file': return handleDeleteFile(args as { path: string; recursive?: boolean });
|
||||
case 'run_command': return handleRunCommand(args as { command: string; cwd?: string; timeout?: number });
|
||||
default: return { success: false, error: `未知工具: ${toolName}` };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('tool:getConfig', () => ({
|
||||
allowedDirs: getAllowedDirs(),
|
||||
blockedDirs: getBlockedDirs()
|
||||
}));
|
||||
|
||||
ipcMain.handle('tool:setAllowedDirs', (_, dirs: string[]) => {
|
||||
setAllowedDirs(dirs);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,6 +15,11 @@ contextBridge.exposeInMainWorld('metonaDesktop', {
|
||||
readFile: (filePath: string) => ipcRenderer.invoke('fs:readFile', filePath),
|
||||
writeFile: (filePath: string, content: string) => ipcRenderer.invoke('fs:writeFile', filePath, content)
|
||||
},
|
||||
tool: {
|
||||
execute: (toolName: string, args: Record<string, unknown>) => ipcRenderer.invoke('tool:execute', toolName, args),
|
||||
getConfig: () => ipcRenderer.invoke('tool:getConfig'),
|
||||
setAllowedDirs: (dirs: string[]) => ipcRenderer.invoke('tool:setAllowedDirs', dirs)
|
||||
},
|
||||
notify: (title: string, body: string) => ipcRenderer.invoke('notify', title, body),
|
||||
window: {
|
||||
minimize: () => ipcRenderer.invoke('window:minimize'),
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* Tool Handlers - 主进程工具执行器
|
||||
* 所有文件系统操作在此执行,通过 IPC 被渲染进程调用
|
||||
*/
|
||||
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { checkPathAllowed, checkCommandAllowed } from './tool-security.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export interface ToolResult {
|
||||
success: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export async function handleReadFile(params: { path: string; encoding?: string; start_line?: number; end_line?: number }): Promise<ToolResult> {
|
||||
try {
|
||||
const filePath = path.resolve(params.path);
|
||||
const allowed = checkPathAllowed(filePath, 'read');
|
||||
if (!allowed.ok) return { success: false, error: allowed.reason };
|
||||
|
||||
const stat = await fs.stat(filePath);
|
||||
if (stat.size > 1024 * 1024) {
|
||||
return { success: false, error: `文件过大 (${(stat.size / 1024 / 1024).toFixed(1)}MB),最大支持 1MB` };
|
||||
}
|
||||
|
||||
const encoding = (params.encoding as BufferEncoding) || 'utf-8';
|
||||
const content = await fs.readFile(filePath, encoding);
|
||||
const lines = content.split('\n');
|
||||
|
||||
let resultContent = content;
|
||||
let lineRange: [number, number] = [1, lines.length];
|
||||
let truncated = false;
|
||||
|
||||
if (params.start_line || params.end_line) {
|
||||
const start = Math.max(1, params.start_line || 1) - 1;
|
||||
const end = Math.min(lines.length, params.end_line || Math.min(start + 500, lines.length));
|
||||
resultContent = lines.slice(start, end).join('\n');
|
||||
lineRange = [start + 1, end];
|
||||
truncated = end < lines.length;
|
||||
} else if (lines.length > 500) {
|
||||
resultContent = lines.slice(0, 500).join('\n');
|
||||
lineRange = [1, 500];
|
||||
truncated = true;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
path: filePath,
|
||||
content: resultContent,
|
||||
encoding,
|
||||
size: stat.size,
|
||||
lines: lines.length,
|
||||
truncated,
|
||||
line_range: lineRange
|
||||
};
|
||||
} catch (err) {
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleWriteFile(params: { path: string; content: string; encoding?: string }): Promise<ToolResult> {
|
||||
try {
|
||||
const filePath = path.resolve(params.path);
|
||||
const allowed = checkPathAllowed(filePath, 'write');
|
||||
if (!allowed.ok) return { success: false, error: allowed.reason };
|
||||
|
||||
if (params.content.length > 5 * 1024 * 1024) {
|
||||
return { success: false, error: '内容过大,最大支持 5MB' };
|
||||
}
|
||||
|
||||
let created = false;
|
||||
try {
|
||||
await fs.stat(filePath);
|
||||
} catch {
|
||||
created = true;
|
||||
}
|
||||
|
||||
const dir = path.dirname(filePath);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
|
||||
await fs.writeFile(filePath, params.content, (params.encoding as BufferEncoding) || 'utf-8');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
path: filePath,
|
||||
bytesWritten: Buffer.byteLength(params.content, (params.encoding as BufferEncoding) || 'utf-8'),
|
||||
created
|
||||
};
|
||||
} catch (err) {
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleListDir(params: { path: string; recursive?: boolean; max_depth?: number; include_hidden?: boolean; filter_extension?: string }): Promise<ToolResult> {
|
||||
try {
|
||||
const dirPath = path.resolve(params.path);
|
||||
const allowed = checkPathAllowed(dirPath, 'read');
|
||||
if (!allowed.ok) return { success: false, error: allowed.reason };
|
||||
|
||||
const maxEntries = 500;
|
||||
const entries: Array<{ name: string; type: string; size: number | null; modified: string }> = [];
|
||||
let truncated = false;
|
||||
|
||||
async function scanDir(dir: string, depth: number): Promise<void> {
|
||||
if (entries.length >= maxEntries) { truncated = true; return; }
|
||||
if (params.recursive && params.max_depth && depth > params.max_depth) return;
|
||||
|
||||
const items = await fs.readdir(dir, { withFileTypes: true });
|
||||
for (const item of items) {
|
||||
if (entries.length >= maxEntries) { truncated = true; return; }
|
||||
if (!params.include_hidden && item.name.startsWith('.')) continue;
|
||||
if (params.filter_extension && item.isFile() && !item.name.endsWith(params.filter_extension)) continue;
|
||||
|
||||
const fullPath = path.join(dir, item.name);
|
||||
const stat = await fs.stat(fullPath);
|
||||
entries.push({
|
||||
name: params.recursive ? path.relative(dirPath, fullPath) : item.name,
|
||||
type: item.isDirectory() ? 'directory' : 'file',
|
||||
size: item.isFile() ? stat.size : null,
|
||||
modified: stat.mtime.toISOString()
|
||||
});
|
||||
|
||||
if (params.recursive && item.isDirectory()) {
|
||||
await scanDir(fullPath, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await scanDir(dirPath, 1);
|
||||
return { success: true, path: dirPath, entries, total: entries.length, truncated };
|
||||
} catch (err) {
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleSearchFiles(params: { path: string; query: string; search_type?: string; case_sensitive?: boolean; max_results?: number; file_extensions?: string[] }): Promise<ToolResult> {
|
||||
try {
|
||||
const rootPath = path.resolve(params.path);
|
||||
const allowed = checkPathAllowed(rootPath, 'read');
|
||||
if (!allowed.ok) return { success: false, error: allowed.reason };
|
||||
|
||||
const searchType = params.search_type || 'both';
|
||||
const caseSensitive = params.case_sensitive || false;
|
||||
const maxResults = params.max_results || 50;
|
||||
const query = caseSensitive ? params.query : params.query.toLowerCase();
|
||||
|
||||
const results: Array<{ path: string; matches: Array<{ line: number; text: string; column?: number }> }> = [];
|
||||
let totalMatches = 0;
|
||||
let filesScanned = 0;
|
||||
const maxScanFiles = 1000;
|
||||
|
||||
async function collectFiles(dir: string): Promise<string[]> {
|
||||
const files: string[] = [];
|
||||
let items;
|
||||
try { items = await fs.readdir(dir, { withFileTypes: true }); } catch { return files; }
|
||||
for (const item of items) {
|
||||
if (filesScanned >= maxScanFiles) return files;
|
||||
if (item.name.startsWith('.')) continue;
|
||||
const fullPath = path.join(dir, item.name);
|
||||
if (item.isDirectory()) {
|
||||
files.push(...(await collectFiles(fullPath)));
|
||||
} else {
|
||||
if (params.file_extensions?.length) {
|
||||
const ext = path.extname(item.name);
|
||||
if (!params.file_extensions.includes(ext)) continue;
|
||||
}
|
||||
files.push(fullPath);
|
||||
filesScanned++;
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
const allFiles = await collectFiles(rootPath);
|
||||
|
||||
for (const filePath of allFiles) {
|
||||
if (totalMatches >= maxResults) break;
|
||||
|
||||
if (searchType === 'filename' || searchType === 'both') {
|
||||
const fileName = path.basename(filePath);
|
||||
const nameToCheck = caseSensitive ? fileName : fileName.toLowerCase();
|
||||
if (nameToCheck.includes(query)) {
|
||||
results.push({ path: filePath, matches: [{ line: 0, text: `[文件名匹配] ${fileName}` }] });
|
||||
totalMatches++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (searchType === 'content' || searchType === 'both') {
|
||||
try {
|
||||
const stat = await fs.stat(filePath);
|
||||
if (stat.size > 500 * 1024) continue;
|
||||
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
const fileMatches: Array<{ line: number; text: string; column: number }> = [];
|
||||
|
||||
for (let i = 0; i < lines.length && fileMatches.length < 10; i++) {
|
||||
const lineToCheck = caseSensitive ? lines[i] : lines[i].toLowerCase();
|
||||
const col = lineToCheck.indexOf(query);
|
||||
if (col !== -1) {
|
||||
fileMatches.push({ line: i + 1, text: lines[i].trim(), column: col + 1 });
|
||||
totalMatches++;
|
||||
}
|
||||
}
|
||||
|
||||
if (fileMatches.length > 0) {
|
||||
results.push({ path: filePath, matches: fileMatches });
|
||||
}
|
||||
} catch { /* skip unreadable files */ }
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
query: params.query,
|
||||
search_type: searchType,
|
||||
results,
|
||||
total_files: allFiles.length,
|
||||
total_matches: totalMatches,
|
||||
truncated: totalMatches >= maxResults
|
||||
};
|
||||
} catch (err) {
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleCreateDir(params: { path: string }): Promise<ToolResult> {
|
||||
try {
|
||||
const dirPath = path.resolve(params.path);
|
||||
const allowed = checkPathAllowed(dirPath, 'write');
|
||||
if (!allowed.ok) return { success: false, error: allowed.reason };
|
||||
|
||||
await fs.mkdir(dirPath, { recursive: true });
|
||||
return { success: true, path: dirPath, created: true };
|
||||
} catch (err) {
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleDeleteFile(params: { path: string; recursive?: boolean }): Promise<ToolResult> {
|
||||
try {
|
||||
const filePath = path.resolve(params.path);
|
||||
const allowed = checkPathAllowed(filePath, 'write');
|
||||
if (!allowed.ok) return { success: false, error: allowed.reason };
|
||||
|
||||
const stat = await fs.stat(filePath);
|
||||
if (stat.isDirectory()) {
|
||||
if (params.recursive) {
|
||||
await fs.rm(filePath, { recursive: true, force: true });
|
||||
} else {
|
||||
await fs.rmdir(filePath);
|
||||
}
|
||||
} else {
|
||||
await fs.unlink(filePath);
|
||||
}
|
||||
|
||||
return { success: true, path: filePath, deleted: true };
|
||||
} catch (err) {
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleRunCommand(params: { command: string; cwd?: string; timeout?: number }): Promise<ToolResult> {
|
||||
try {
|
||||
const cmdCheck = checkCommandAllowed(params.command);
|
||||
if (!cmdCheck.ok) return { success: false, error: cmdCheck.reason };
|
||||
|
||||
const timeout = Math.min(params.timeout || 30000, 120000);
|
||||
const cwd = params.cwd ? path.resolve(params.cwd) : process.env.HOME || '/';
|
||||
|
||||
const cwdAllowed = checkPathAllowed(cwd, 'read');
|
||||
if (!cwdAllowed.ok) return { success: false, error: cwdAllowed.reason };
|
||||
|
||||
const start = Date.now();
|
||||
const { stdout, stderr } = await execAsync(params.command, {
|
||||
cwd,
|
||||
timeout,
|
||||
maxBuffer: 100 * 1024
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
stdout: stdout.slice(0, 100 * 1024),
|
||||
stderr: stderr.slice(0, 100 * 1024),
|
||||
exitCode: 0,
|
||||
duration: Date.now() - start
|
||||
};
|
||||
} catch (err) {
|
||||
const error = err as { stdout?: string; stderr?: string; code?: number; message: string };
|
||||
return {
|
||||
success: false,
|
||||
stdout: error.stdout?.slice(0, 100 * 1024) || '',
|
||||
stderr: error.stderr?.slice(0, 100 * 1024) || error.message,
|
||||
exitCode: error.code || 1,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Tool Security - 安全检查模块
|
||||
* 路径白名单/黑名单、命令过滤、路径遍历检测
|
||||
*/
|
||||
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
const HOME = os.homedir();
|
||||
|
||||
/** 默认允许的目录(可通过设置覆盖) */
|
||||
let allowedDirs: string[] = [
|
||||
HOME,
|
||||
path.join(HOME, 'Desktop'),
|
||||
path.join(HOME, 'Documents'),
|
||||
path.join(HOME, 'Downloads'),
|
||||
path.join(HOME, 'Projects'),
|
||||
path.join(HOME, 'projects'),
|
||||
'/tmp',
|
||||
];
|
||||
|
||||
/** 永久禁止的目录 */
|
||||
const BLOCKED_DIRS: string[] = [
|
||||
'/etc', '/sys', '/proc', '/dev', '/boot', '/root',
|
||||
'C:\\Windows', 'C:\\Program Files', 'C:\\ProgramData',
|
||||
path.join(HOME, '.ssh'),
|
||||
path.join(HOME, '.gnupg'),
|
||||
path.join(HOME, '.aws'),
|
||||
];
|
||||
|
||||
/** 命令黑名单 */
|
||||
const BLOCKED_COMMANDS: string[] = [
|
||||
'rm -rf /', 'rm -rf /*', ':(){ :|:& };:',
|
||||
'mkfs', 'dd if=', 'wipefs', 'shred',
|
||||
'shutdown', 'reboot', 'poweroff', 'halt',
|
||||
'useradd', 'usermod', 'userdel', 'passwd',
|
||||
'chmod 777', 'chown root',
|
||||
'crontab -e',
|
||||
'systemctl enable', 'systemctl disable',
|
||||
'curl | sh', 'wget | sh', 'curl | bash', 'wget | bash',
|
||||
'eval', 'exec >',
|
||||
];
|
||||
|
||||
export interface CheckResult {
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export function checkPathAllowed(targetPath: string, operation: 'read' | 'write'): CheckResult {
|
||||
const resolved = path.resolve(targetPath);
|
||||
|
||||
if (targetPath.split(path.sep).filter(s => s === '..').length > 5) {
|
||||
return { ok: false, reason: '路径遍历深度过大' };
|
||||
}
|
||||
|
||||
for (const blocked of BLOCKED_DIRS) {
|
||||
if (resolved === blocked || resolved.startsWith(blocked + path.sep)) {
|
||||
return { ok: false, reason: `禁止访问受保护路径: ${blocked}` };
|
||||
}
|
||||
}
|
||||
|
||||
if (operation === 'write') {
|
||||
const inAllowedDir = allowedDirs.some(dir =>
|
||||
resolved === dir || resolved.startsWith(dir + path.sep)
|
||||
);
|
||||
if (!inAllowedDir) {
|
||||
return { ok: false, reason: `写操作被限制在允许的目录内。当前路径: ${resolved}` };
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export function checkCommandAllowed(command: string): CheckResult {
|
||||
const lowerCmd = command.toLowerCase().trim();
|
||||
|
||||
for (const blocked of BLOCKED_COMMANDS) {
|
||||
if (lowerCmd.includes(blocked.toLowerCase())) {
|
||||
return { ok: false, reason: `命令包含被禁止的操作: ${blocked}` };
|
||||
}
|
||||
}
|
||||
|
||||
if (/(\||>|<)\s*(sh|bash|zsh|powershell|cmd)/i.test(lowerCmd)) {
|
||||
return { ok: false, reason: '禁止通过管道执行 shell 命令' };
|
||||
}
|
||||
|
||||
if (/\/dev\/tcp\//i.test(lowerCmd) || /bash\s+-i\s+>&/i.test(lowerCmd)) {
|
||||
return { ok: false, reason: '检测到疑似反弹 shell 操作' };
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export function setAllowedDirs(dirs: string[]): void {
|
||||
allowedDirs = dirs.map(d => path.resolve(d));
|
||||
}
|
||||
|
||||
export function getAllowedDirs(): string[] {
|
||||
return [...allowedDirs];
|
||||
}
|
||||
|
||||
export function getBlockedDirs(): string[] {
|
||||
return [...BLOCKED_DIRS];
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
import type {
|
||||
OllamaChatParams, OllamaStreamChunk, OllamaModelsResponse,
|
||||
OllamaPsResponse, OllamaVersionResponse, OllamaModelDetail,
|
||||
OllamaEmbedResponse
|
||||
OllamaEmbedResponse, ToolDefinition
|
||||
} from '../types.js';
|
||||
|
||||
export class OllamaAPI {
|
||||
@@ -65,7 +65,7 @@ export class OllamaAPI {
|
||||
}
|
||||
|
||||
async chatStream(
|
||||
params: OllamaChatParams,
|
||||
params: OllamaChatParams & { tools?: ToolDefinition[] },
|
||||
onChunk: (chunk: OllamaStreamChunk) => void,
|
||||
abortController?: AbortController
|
||||
): Promise<void> {
|
||||
@@ -78,6 +78,7 @@ export class OllamaAPI {
|
||||
if (params.system) body.system = params.system;
|
||||
if (params.keep_alive !== undefined) body.keep_alive = params.keep_alive;
|
||||
if (params.options) body.options = params.options;
|
||||
if (params.tools?.length) body.tools = params.tools;
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method: 'POST',
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { state, KEYS } from '../state/state.js';
|
||||
import { marked } from '../utils/marked-config.js';
|
||||
import { escapeHtml, formatTime, downloadFile } from '../utils/utils.js';
|
||||
import type { ChatSession, ChatMessage } from '../types.js';
|
||||
import type { ChatSession, ChatMessage, ToolCallRecord } from '../types.js';
|
||||
|
||||
function getFileIcon(filename: string): string {
|
||||
const name = filename.toLowerCase();
|
||||
@@ -175,6 +175,14 @@ export function appendMessageDOM(msg: ChatMessage, index: number): void {
|
||||
contentHtml += `<div class="rag-sources"><div class="rag-sources-header">🧠 基于知识库回答</div>${sourcesHtml}</div>`;
|
||||
}
|
||||
|
||||
if (msg.role === 'assistant' && msg.toolCalls && msg.toolCalls.length > 0) {
|
||||
contentHtml += '<div class="tool-calls-container">';
|
||||
for (const tc of msg.toolCalls) {
|
||||
contentHtml += renderToolCallCard(tc);
|
||||
}
|
||||
contentHtml += '</div>';
|
||||
}
|
||||
|
||||
div.innerHTML = `
|
||||
<div class="msg-avatar">${avatar}</div>
|
||||
<div class="msg-body">
|
||||
@@ -186,6 +194,93 @@ export function appendMessageDOM(msg: ChatMessage, index: number): void {
|
||||
messagesContainerEl.appendChild(div);
|
||||
}
|
||||
|
||||
function renderToolCallCard(tc: ToolCallRecord): string {
|
||||
const icons: Record<string, string> = {
|
||||
read_file: '📄', write_file: '✏️', list_directory: '📁',
|
||||
search_files: '🔍', create_directory: '📂', delete_file: '🗑️', run_command: '💻'
|
||||
};
|
||||
const names: Record<string, string> = {
|
||||
read_file: '读取文件', write_file: '写入文件', list_directory: '列出目录',
|
||||
search_files: '搜索文件', create_directory: '创建目录', delete_file: '删除文件', run_command: '执行命令'
|
||||
};
|
||||
const statusLabels: Record<string, string> = {
|
||||
pending: '⏳ 等待确认', running: '🔄 执行中', success: '✅ 完成', error: '❌ 失败', cancelled: '🚫 已取消'
|
||||
};
|
||||
|
||||
const icon = icons[tc.name] || '🔧';
|
||||
const name = names[tc.name] || tc.name;
|
||||
const status = statusLabels[tc.status] || tc.status;
|
||||
const args = tc.arguments || {};
|
||||
|
||||
let paramsHtml = '';
|
||||
if (args.path) paramsHtml += `<div class="tool-param">路径: <code>${escapeHtml(String(args.path))}</code></div>`;
|
||||
if (args.command) paramsHtml += `<div class="tool-param">命令: <code>${escapeHtml(String(args.command))}</code></div>`;
|
||||
if (args.query) paramsHtml += `<div class="tool-param">查询: <code>${escapeHtml(String(args.query))}</code></div>`;
|
||||
|
||||
let resultHtml = '';
|
||||
if (tc.result) {
|
||||
if (tc.result.success) {
|
||||
const r = tc.result as Record<string, unknown>;
|
||||
if (tc.name === 'read_file' && r.content) {
|
||||
const content = String(r.content);
|
||||
const lines = content.split('\n');
|
||||
const preview = lines.slice(0, 20).join('\n');
|
||||
resultHtml = `<pre class="tool-result-content">${escapeHtml(preview)}${lines.length > 20 ? `\n... (共 ${r.lines || lines.length} 行)` : ''}</pre>`;
|
||||
resultHtml += `<div class="tool-result-meta">📄 ${(r.size as number || 0) > 0 ? formatFileSize(r.size as number) : ''} · ${r.lines || '?'} 行</div>`;
|
||||
} else if (tc.name === 'list_directory' && r.entries) {
|
||||
const entries = r.entries as Array<{ name: string; type: string; size: number | null }>;
|
||||
resultHtml = entries.slice(0, 30).map(e =>
|
||||
`<div class="tool-result-entry">${e.type === 'directory' ? '📁' : '📄'} ${escapeHtml(e.name)}${e.size != null ? ` (${formatFileSize(e.size)})` : ''}</div>`
|
||||
).join('');
|
||||
if (entries.length > 30) resultHtml += `<div class="tool-result-entry">... 共 ${(r.total as number) || entries.length} 项</div>`;
|
||||
} else if (tc.name === 'search_files' && r.results) {
|
||||
const results = r.results as Array<{ path: string; matches: Array<{ line: number; text: string }> }>;
|
||||
resultHtml = results.slice(0, 10).map(file =>
|
||||
file.matches.slice(0, 3).map(m =>
|
||||
`<div class="tool-result-entry">📄 ${escapeHtml(file.path)}:${m.line} ${escapeHtml(m.text).slice(0, 80)}</div>`
|
||||
).join('')
|
||||
).join('');
|
||||
resultHtml += `<div class="tool-result-meta">共 ${r.total_files || '?'} 个文件,${r.total_matches || '?'} 处匹配</div>`;
|
||||
} else if (tc.name === 'write_file') {
|
||||
resultHtml = `<div class="tool-result-entry">✅ 已写入 ${escapeHtml(String(r.path || ''))} (${formatFileSize(r.bytesWritten as number || 0)})</div>`;
|
||||
} else if (tc.name === 'create_directory') {
|
||||
resultHtml = `<div class="tool-result-entry">✅ 已创建 ${escapeHtml(String(r.path || ''))}</div>`;
|
||||
} else if (tc.name === 'delete_file') {
|
||||
resultHtml = `<div class="tool-result-entry">✅ 已删除 ${escapeHtml(String(r.path || ''))}</div>`;
|
||||
} else if (tc.name === 'run_command') {
|
||||
const stdout = String(r.stdout || '').slice(0, 1000);
|
||||
const stderr = String(r.stderr || '').slice(0, 500);
|
||||
if (stdout) resultHtml += `<pre class="tool-result-content">${escapeHtml(stdout)}</pre>`;
|
||||
if (stderr) resultHtml += `<pre class="tool-result-content tool-result-stderr">${escapeHtml(stderr)}</pre>`;
|
||||
resultHtml += `<div class="tool-result-meta">exit: ${r.exitCode || 0} · ${r.duration || 0}ms</div>`;
|
||||
} else {
|
||||
resultHtml = `<div class="tool-result-entry">✅ 操作成功</div>`;
|
||||
}
|
||||
} else {
|
||||
resultHtml = `<div class="tool-result-entry tool-result-error">❌ ${escapeHtml(tc.result.error || '未知错误')}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
return `<div class="tool-call-card tool-call-${tc.status}">
|
||||
<div class="tool-call-header">
|
||||
<span class="tool-call-icon">${icon}</span>
|
||||
<span class="tool-call-name">${name}</span>
|
||||
<span class="tool-call-status">${status}</span>
|
||||
</div>
|
||||
${paramsHtml ? `<div class="tool-call-params">${paramsHtml}</div>` : ''}
|
||||
${resultHtml ? `<div class="tool-call-result">${resultHtml}</div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (!bytes) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let i = 0;
|
||||
let size = bytes;
|
||||
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; }
|
||||
return `${size.toFixed(1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
export function updateLastAssistantMessage(content: string, think: string | null, stats: { eval_count?: number; total_duration?: number } | null, model?: string): void {
|
||||
const lastMsg = currentPlaceholder;
|
||||
if (!lastMsg) return;
|
||||
|
||||
@@ -13,7 +13,9 @@ import { showToast } from './toast.js';
|
||||
import { isRagEnabled, performRagRetrieval } from './kb-modal.js';
|
||||
import { ChatDB } from '../db/chat-db.js';
|
||||
import { OllamaAPI } from '../api/ollama.js';
|
||||
import type { ChatSession, ChatMessage, OllamaStreamChunk, RagSource, FileContent, ChatFile } from '../types.js';
|
||||
import { runAgentLoop } from '../services/agent-engine.js';
|
||||
import { showToolConfirm } from './tool-confirm-modal.js';
|
||||
import type { ChatSession, ChatMessage, OllamaStreamChunk, RagSource, FileContent, ChatFile, ToolCallRecord } from '../types.js';
|
||||
|
||||
let chatInputEl: HTMLTextAreaElement;
|
||||
let btnSendEl: HTMLButtonElement;
|
||||
@@ -254,6 +256,15 @@ export async function sendMessage(): Promise<void> {
|
||||
const currentSession = state.get<ChatSession | null>(KEYS.CURRENT_SESSION);
|
||||
if (!currentSession) return;
|
||||
|
||||
// ── Tool Calling Agent Loop 分支 ──
|
||||
const toolCallingEnabled = state.get<boolean>('toolCallingEnabled', false);
|
||||
if (toolCallingEnabled) {
|
||||
await sendMessageWithAgentLoop(text, currentSession, model);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 原有普通聊天流程 ──
|
||||
|
||||
const now = Date.now();
|
||||
const msgsToAdd: ChatMessage[] = [];
|
||||
|
||||
@@ -520,6 +531,183 @@ export async function sendMessage(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessageWithAgentLoop(text: string, currentSession: ChatSession, model: string): Promise<void> {
|
||||
const now = Date.now();
|
||||
const msgsToAdd: ChatMessage[] = [];
|
||||
const userFiles: ChatFile[] = pendingFiles.map(f => ({ name: f.name, language: f.language, size: f.size }));
|
||||
const images = pendingImages.map(img => img.base64);
|
||||
|
||||
if (pendingImages.length > 0) {
|
||||
msgsToAdd.push({
|
||||
role: 'user',
|
||||
content: pendingImages.length === 1 ? `[上传了图片: ${pendingImages[0].name}]` : `[上传了 ${pendingImages.length} 张图片]`,
|
||||
images,
|
||||
timestamp: now
|
||||
});
|
||||
}
|
||||
|
||||
if (text || pendingFiles.length > 0) {
|
||||
const msg: ChatMessage = {
|
||||
role: 'user',
|
||||
content: text || '',
|
||||
timestamp: now
|
||||
};
|
||||
if (userFiles.length > 0) {
|
||||
msg.files = userFiles;
|
||||
msg._fileContents = pendingFiles.map(f => ({ language: f.language, content: f.content }));
|
||||
}
|
||||
msgsToAdd.push(msg);
|
||||
}
|
||||
|
||||
const isFirstMsg = currentSession.messages.length === 0;
|
||||
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
|
||||
...session,
|
||||
...(isFirstMsg && {
|
||||
title: truncate(text || (pendingFiles.length > 0 ? `[文件: ${pendingFiles.map(f => f.name).join(', ')}]` : '[图片消息]'), 30),
|
||||
model
|
||||
}),
|
||||
messages: [...session.messages, ...msgsToAdd],
|
||||
updatedAt: Date.now()
|
||||
}));
|
||||
|
||||
const freshSession = state.get<ChatSession>(KEYS.CURRENT_SESSION);
|
||||
|
||||
enableAutoScroll();
|
||||
renderMessages();
|
||||
await saveCurrentSession();
|
||||
|
||||
chatInputEl.value = '';
|
||||
pendingImages = [];
|
||||
pendingFiles = [];
|
||||
imagePreviewEl.style.display = 'none';
|
||||
imagePreviewEl.innerHTML = '';
|
||||
filePreviewEl.style.display = 'none';
|
||||
filePreviewEl.innerHTML = '';
|
||||
autoResizeTextarea();
|
||||
|
||||
appendAssistantPlaceholder();
|
||||
state.set(KEYS.IS_STREAMING, true);
|
||||
updateSendButton(true);
|
||||
|
||||
// 构建历史消息
|
||||
const historyMessages = freshSession.messages
|
||||
.filter(m => m.role === 'user' || m.role === 'assistant')
|
||||
.slice(-20)
|
||||
.map(m => {
|
||||
let content = m.content || '';
|
||||
if (m._fileContents && m._fileContents.length > 0) {
|
||||
const fileParts = m._fileContents.map(f => `📄 文件:\n\`\`\`${f.language}\n${f.content}\n\`\`\``);
|
||||
content = content ? content + '\n\n---\n' + fileParts.join('\n\n---\n') : fileParts.join('\n\n---\n');
|
||||
}
|
||||
return {
|
||||
role: m.role,
|
||||
content,
|
||||
...(m.images?.length && { images: m.images })
|
||||
};
|
||||
});
|
||||
|
||||
let assistantContent = '';
|
||||
let thinkContent = '';
|
||||
|
||||
try {
|
||||
await runAgentLoop(text || (pendingFiles.length > 0 ? `请分析 ${pendingFiles.map(f => f.name).join(', ')}` : ''), images, historyMessages, {
|
||||
onThinking: (thinking) => {
|
||||
thinkContent = thinking;
|
||||
updateLastAssistantMessage(assistantContent, thinkContent || null, null);
|
||||
},
|
||||
onContent: (content) => {
|
||||
assistantContent = content;
|
||||
updateLastAssistantMessage(assistantContent, thinkContent || null, null);
|
||||
},
|
||||
onToolCallStart: (_call) => {
|
||||
// 实时更新已由 agent-engine 的 callbacks 处理
|
||||
},
|
||||
onToolCallResult: (_name, _result, _call) => {
|
||||
// 工具结果实时更新
|
||||
},
|
||||
onToolCallError: (_name, _error, _call) => {
|
||||
// 工具错误实时更新
|
||||
},
|
||||
onConfirmTool: async (call) => {
|
||||
return showToolConfirm(call);
|
||||
},
|
||||
onDone: async (finalContent, toolRecords) => {
|
||||
assistantContent = finalContent;
|
||||
|
||||
const assistantMsg: ChatMessage = {
|
||||
role: 'assistant',
|
||||
content: assistantContent || '',
|
||||
timestamp: Date.now(),
|
||||
...(thinkContent && { think: thinkContent }),
|
||||
...(toolRecords?.length && { toolCalls: toolRecords })
|
||||
};
|
||||
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
|
||||
...session,
|
||||
messages: [...session.messages, assistantMsg],
|
||||
updatedAt: Date.now()
|
||||
}));
|
||||
|
||||
updateLastAssistantMessage(assistantContent, thinkContent || null, null);
|
||||
renderMessages();
|
||||
await saveCurrentSession();
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[AgentLoop] 错误:', err);
|
||||
|
||||
if ((err as Error).name === 'AbortError') {
|
||||
const placeholder = document.querySelector('#messagesContainer .message.assistant.loading') as HTMLElement;
|
||||
if (placeholder) {
|
||||
placeholder.classList.remove('loading');
|
||||
placeholder.querySelector('.loading-dots')?.remove();
|
||||
placeholder.querySelector('.loading-text')?.remove();
|
||||
const contentDiv = placeholder.querySelector('.msg-content');
|
||||
if (contentDiv) {
|
||||
let html = assistantContent ? safeMarkdown(assistantContent) : '';
|
||||
html += '<p><code>[已停止]</code></p>';
|
||||
contentDiv.innerHTML = html;
|
||||
}
|
||||
}
|
||||
const partialMsg: ChatMessage = {
|
||||
role: 'assistant',
|
||||
content: assistantContent,
|
||||
timestamp: Date.now(),
|
||||
...(thinkContent && { think: thinkContent }),
|
||||
stopped: true
|
||||
};
|
||||
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
|
||||
...session,
|
||||
messages: [...session.messages, partialMsg],
|
||||
updatedAt: Date.now()
|
||||
}));
|
||||
appendSystemMessage('⏹ 已停止生成');
|
||||
await saveCurrentSession();
|
||||
} else {
|
||||
const placeholder = document.querySelector('#messagesContainer .message.assistant.loading') as HTMLElement;
|
||||
if (placeholder) {
|
||||
placeholder.classList.remove('loading');
|
||||
placeholder.querySelector('.loading-dots')?.remove();
|
||||
placeholder.querySelector('.loading-text')?.remove();
|
||||
if (!assistantContent) placeholder.remove();
|
||||
else {
|
||||
const contentDiv = placeholder.querySelector('.msg-content');
|
||||
if (contentDiv) contentDiv.innerHTML = safeMarkdown(assistantContent) + '<p><code>[已中断]</code></p>';
|
||||
}
|
||||
}
|
||||
|
||||
let errMsg = `❌ 错误: ${(err as Error).message}`;
|
||||
if ((err as Error).message.includes('Failed to fetch') || (err as Error).message.includes('NetworkError')) {
|
||||
errMsg = '❌ 连接失败。请检查 Ollama 是否正在运行';
|
||||
}
|
||||
appendSystemMessage(errMsg);
|
||||
}
|
||||
} finally {
|
||||
state.set(KEYS.IS_STREAMING, false);
|
||||
updateSendButton(false);
|
||||
state.set(KEYS.ABORT_CONTROLLER, null);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCurrentSession(): Promise<void> {
|
||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||
const currentSession = state.get<ChatSession | null>(KEYS.CURRENT_SESSION);
|
||||
|
||||
@@ -103,6 +103,29 @@ export function initSettingsModal(): void {
|
||||
(e.target as HTMLInputElement).value = '';
|
||||
}
|
||||
});
|
||||
|
||||
// ── Tool Calling 设置 ──
|
||||
const toggleToolCalling = document.querySelector('#toggleToolCalling') as HTMLInputElement;
|
||||
if (toggleToolCalling) {
|
||||
toggleToolCalling.addEventListener('change', async () => {
|
||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||
state.set('toolCallingEnabled', toggleToolCalling.checked);
|
||||
if (db) await db.saveSetting('toolCallingEnabled', toggleToolCalling.checked);
|
||||
showToast(toggleToolCalling.checked ? '工具调用已开启' : '工具调用已关闭', 'info');
|
||||
});
|
||||
}
|
||||
|
||||
const toggleRunCommand = document.querySelector('#toggleRunCommand') as HTMLInputElement;
|
||||
if (toggleRunCommand) {
|
||||
toggleRunCommand.addEventListener('change', async () => {
|
||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||
state.set('runCommandEnabled', toggleRunCommand.checked);
|
||||
if (db) await db.saveSetting('runCommandEnabled', toggleRunCommand.checked);
|
||||
if (toggleRunCommand.checked) {
|
||||
showToast('⚠️ 命令执行已开启,请谨慎使用', 'warning');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function openSettingsModal(): void {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* ToolConfirmModal - 工具调用确认对话框
|
||||
*/
|
||||
|
||||
import type { ToolCall } from '../types.js';
|
||||
import { getToolIcon, formatToolName } from '../services/tool-registry.js';
|
||||
|
||||
let modalEl: HTMLElement | null = null;
|
||||
let resolveConfirm: ((confirmed: boolean) => void) | null = null;
|
||||
|
||||
export function initToolConfirmModal(): void {
|
||||
modalEl = document.querySelector('#toolConfirmModal')!;
|
||||
if (!modalEl) return;
|
||||
|
||||
modalEl.addEventListener('click', (e) => {
|
||||
if (e.target === modalEl) {
|
||||
cancelConfirm();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function showToolConfirm(call: ToolCall): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
if (!modalEl) {
|
||||
modalEl = document.querySelector('#toolConfirmModal')!;
|
||||
if (!modalEl) { resolve(false); return; }
|
||||
modalEl.addEventListener('click', (e) => {
|
||||
if (e.target === modalEl) cancelConfirm();
|
||||
});
|
||||
}
|
||||
|
||||
resolveConfirm = resolve;
|
||||
|
||||
const icon = getToolIcon(call.function.name);
|
||||
const name = formatToolName(call.function.name);
|
||||
|
||||
const titleEl = modalEl.querySelector('#toolConfirmTitle')!;
|
||||
const bodyEl = modalEl.querySelector('#toolConfirmBody')!;
|
||||
const confirmBtn = modalEl.querySelector('#toolConfirmBtn')! as HTMLButtonElement;
|
||||
const cancelBtn = modalEl.querySelector('#toolCancelBtn')! as HTMLButtonElement;
|
||||
|
||||
titleEl.textContent = `${icon} 确认操作:${name}`;
|
||||
|
||||
let bodyHtml = `<div class="tool-confirm-info">`;
|
||||
bodyHtml += `<div class="tool-confirm-row"><span class="tool-confirm-label">操作:</span><span>${name}</span></div>`;
|
||||
|
||||
const args = call.function.arguments || {};
|
||||
if (call.function.name === 'read_file' || call.function.name === 'write_file' ||
|
||||
call.function.name === 'list_directory' || call.function.name === 'search_files' ||
|
||||
call.function.name === 'create_directory' || call.function.name === 'delete_file') {
|
||||
if (args.path) {
|
||||
bodyHtml += `<div class="tool-confirm-row"><span class="tool-confirm-label">路径:</span><code>${escapeHtml(String(args.path))}</code></div>`;
|
||||
}
|
||||
}
|
||||
if (call.function.name === 'run_command') {
|
||||
if (args.command) {
|
||||
bodyHtml += `<div class="tool-confirm-row"><span class="tool-confirm-label">命令:</span><code>${escapeHtml(String(args.command))}</code></div>`;
|
||||
}
|
||||
}
|
||||
if (call.function.name === 'write_file' && args.content) {
|
||||
const preview = String(args.content).slice(0, 500);
|
||||
bodyHtml += `<div class="tool-confirm-row"><span class="tool-confirm-label">内容预览:</span></div>`;
|
||||
bodyHtml += `<pre class="tool-confirm-preview">${escapeHtml(preview)}${String(args.content).length > 500 ? '\n...' : ''}</pre>`;
|
||||
}
|
||||
|
||||
bodyHtml += `</div>`;
|
||||
bodyEl.innerHTML = bodyHtml;
|
||||
|
||||
modalEl.style.display = '';
|
||||
|
||||
const onConfirm = () => {
|
||||
cleanup();
|
||||
resolveConfirm?.(true);
|
||||
};
|
||||
const onCancel = () => {
|
||||
cleanup();
|
||||
resolveConfirm?.(false);
|
||||
};
|
||||
const cleanup = () => {
|
||||
confirmBtn.removeEventListener('click', onConfirm);
|
||||
cancelBtn.removeEventListener('click', onCancel);
|
||||
if (modalEl) modalEl.style.display = 'none';
|
||||
resolveConfirm = null;
|
||||
};
|
||||
|
||||
confirmBtn.addEventListener('click', onConfirm);
|
||||
cancelBtn.addEventListener('click', onCancel);
|
||||
});
|
||||
}
|
||||
|
||||
function cancelConfirm(): void {
|
||||
if (resolveConfirm) {
|
||||
const r = resolveConfirm;
|
||||
resolveConfirm = null;
|
||||
if (modalEl) modalEl.style.display = 'none';
|
||||
r(false);
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
const map: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
||||
return str.replace(/[&<>"']/g, c => map[c]);
|
||||
}
|
||||
@@ -199,6 +199,34 @@
|
||||
<p class="text-muted">无运行中的模型</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-group">
|
||||
<label class="setting-label">
|
||||
🔧 工具调用(Tool Calling)
|
||||
<label class="toggle-label" style="margin-left:auto;display:inline-flex;">
|
||||
<input type="checkbox" id="toggleToolCalling">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</label>
|
||||
<p class="text-muted" style="font-size:11px;margin-bottom:8px;">启用后,AI 可以在对话中主动调用本地工具(读写文件、搜索、创建目录等)。需要模型支持 Tool Calling。</p>
|
||||
<div class="tool-settings" id="toolSettings">
|
||||
<div class="tool-list">
|
||||
<div class="tool-item"><span>📄 读取文件</span><span class="text-muted">自动</span></div>
|
||||
<div class="tool-item"><span>✏️ 写入文件</span><span class="text-muted">需确认</span></div>
|
||||
<div class="tool-item"><span>📁 列出目录</span><span class="text-muted">自动</span></div>
|
||||
<div class="tool-item"><span>🔍 搜索文件</span><span class="text-muted">自动</span></div>
|
||||
<div class="tool-item"><span>📂 创建目录</span><span class="text-muted">需确认</span></div>
|
||||
<div class="tool-item"><span>🗑️ 删除文件</span><span class="text-muted">需确认</span></div>
|
||||
<div class="tool-item">
|
||||
<span>💻 执行命令</span>
|
||||
<label class="toggle-label" style="margin:0;">
|
||||
<input type="checkbox" id="toggleRunCommand">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-muted" style="font-size:11px;margin-top:8px;">⚠️ 高风险操作(写入/删除/命令执行)会弹出确认对话框。系统关键路径(/etc, /sys, ~/.ssh 等)已自动屏蔽。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-group">
|
||||
<label class="setting-label">显存管理</label>
|
||||
<button class="btn btn-danger" id="btnReleaseVRAM">释放显存(卸载模型)</button>
|
||||
@@ -370,6 +398,23 @@
|
||||
<img class="lightbox-img" id="lightboxImg" src="" alt="预览">
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════ 工具调用确认对话框 ═══════════════ -->
|
||||
<div class="modal-overlay" id="toolConfirmModal" style="display:none;">
|
||||
<div class="modal" style="max-width:520px;">
|
||||
<div class="modal-header">
|
||||
<h3 id="toolConfirmTitle">⚠️ 确认操作</h3>
|
||||
</div>
|
||||
<div class="modal-body" id="toolConfirmBody">
|
||||
<p>AI 请求执行一个需要确认的操作。</p>
|
||||
</div>
|
||||
<div class="preset-modal-actions" style="padding:12px 20px 16px;">
|
||||
<div style="flex:1;"></div>
|
||||
<button class="btn btn-outline" id="toolCancelBtn">❌ 取消</button>
|
||||
<button class="btn btn-primary" id="toolConfirmBtn">✅ 确认执行</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════ Toast 通知容器 ═══════════════ -->
|
||||
<div class="toast-container" id="toastContainer"></div>
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import { initKBModal } from './components/kb-modal.js';
|
||||
import { initPresetBar, initPresetModal } from './components/preset-bar.js';
|
||||
import { initPresetManager } from './services/preset-manager.js';
|
||||
import { initVectorStore } from './services/rag.js';
|
||||
import { initToolConfirmModal } from './components/tool-confirm-modal.js';
|
||||
import type { ChatSession } from './types.js';
|
||||
|
||||
function initHelpModal(): void {
|
||||
@@ -155,6 +156,7 @@ async function init(): Promise<void> {
|
||||
initPresetBar();
|
||||
initPresetModal();
|
||||
initHelpModal();
|
||||
initToolConfirmModal();
|
||||
setupDesktopIntegration();
|
||||
|
||||
bindGlobalEvents();
|
||||
@@ -188,6 +190,16 @@ async function init(): Promise<void> {
|
||||
(document.querySelector('#inputTemperature') as HTMLInputElement).value = String(temperature);
|
||||
document.querySelector('#tempValue')!.textContent = parseFloat(temperature).toFixed(1);
|
||||
|
||||
// ── Tool Calling 设置 ──
|
||||
const toolCallingEnabled = await db.getSetting('toolCallingEnabled', false);
|
||||
const runCommandEnabled = await db.getSetting('runCommandEnabled', false);
|
||||
state.set('toolCallingEnabled', toolCallingEnabled);
|
||||
state.set('runCommandEnabled', runCommandEnabled);
|
||||
const toggleTC = document.querySelector<HTMLInputElement>('#toggleToolCalling');
|
||||
if (toggleTC) toggleTC.checked = toolCallingEnabled;
|
||||
const toggleRC = document.querySelector<HTMLInputElement>('#toggleRunCommand');
|
||||
if (toggleRC) toggleRC.checked = runCommandEnabled;
|
||||
|
||||
(document.querySelector('#chatInput') as HTMLElement)?.focus();
|
||||
|
||||
console.log('[App] 初始化完成');
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Agent Engine - Agent Loop 核心引擎
|
||||
* 管理带工具调用的流式对话循环
|
||||
*/
|
||||
|
||||
import { OllamaAPI } from '../api/ollama.js';
|
||||
import { state, KEYS } from '../state/state.js';
|
||||
import {
|
||||
executeTool,
|
||||
getEnabledToolDefinitions,
|
||||
needsConfirmation
|
||||
} from './tool-registry.js';
|
||||
import { showToast } from '../components/toast.js';
|
||||
import type {
|
||||
OllamaMessage,
|
||||
OllamaStreamChunk,
|
||||
ToolCall,
|
||||
ToolResult,
|
||||
ToolCallRecord
|
||||
} from '../types.js';
|
||||
|
||||
export interface AgentCallbacks {
|
||||
onThinking: (text: string) => void;
|
||||
onContent: (text: string) => void;
|
||||
onToolCallStart: (call: ToolCall) => void;
|
||||
onToolCallResult: (name: string, result: ToolResult, call: ToolCall) => void;
|
||||
onToolCallError: (name: string, error: string, call: ToolCall) => void;
|
||||
onDone: (finalContent: string, toolRecords?: ToolCallRecord[]) => void;
|
||||
onConfirmTool: (call: ToolCall) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export async function runAgentLoop(
|
||||
userContent: string,
|
||||
images: string[],
|
||||
historyMessages: Array<{ role: string; content: string; images?: string[] }>,
|
||||
callbacks: AgentCallbacks
|
||||
): Promise<void> {
|
||||
const api = state.get<OllamaAPI>(KEYS.API);
|
||||
const model = state.get<string>('_defaultModel', '');
|
||||
|
||||
if (!api || !model) {
|
||||
showToast('请先选择模型', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const messages: OllamaMessage[] = [];
|
||||
|
||||
if (state.get<boolean>(KEYS.SYSTEM_PROMPT_ENABLED)) {
|
||||
const systemPrompt = state.get<string>(KEYS.SYSTEM_PROMPT, '');
|
||||
if (systemPrompt) {
|
||||
messages.push({ role: 'system', content: systemPrompt });
|
||||
}
|
||||
}
|
||||
|
||||
for (const msg of historyMessages) {
|
||||
messages.push({
|
||||
role: msg.role as 'user' | 'assistant',
|
||||
content: msg.content,
|
||||
...(msg.images?.length && { images: msg.images })
|
||||
});
|
||||
}
|
||||
|
||||
const userMsg: OllamaMessage = { role: 'user', content: userContent };
|
||||
if (images?.length) userMsg.images = images;
|
||||
messages.push(userMsg);
|
||||
|
||||
const tools = getEnabledToolDefinitions();
|
||||
const useTools = tools.length > 0;
|
||||
|
||||
let loopCount = 0;
|
||||
const maxLoops = 10;
|
||||
const allToolRecords: ToolCallRecord[] = [];
|
||||
|
||||
while (loopCount < maxLoops) {
|
||||
loopCount++;
|
||||
|
||||
let thinking = '';
|
||||
let content = '';
|
||||
const toolCalls: ToolCall[] = [];
|
||||
|
||||
const abortController = new AbortController();
|
||||
state.set(KEYS.ABORT_CONTROLLER, abortController);
|
||||
|
||||
try {
|
||||
await api.chatStream(
|
||||
{
|
||||
model,
|
||||
messages,
|
||||
stream: true,
|
||||
think: state.get<boolean>('thinkEnabled', false),
|
||||
options: {
|
||||
num_ctx: state.get<number>(KEYS.NUM_CTX, 24576),
|
||||
temperature: state.get<number>('temperature', 0.7)
|
||||
},
|
||||
...(useTools && { tools })
|
||||
},
|
||||
(chunk: OllamaStreamChunk) => {
|
||||
if (chunk.message?.thinking) {
|
||||
thinking += chunk.message.thinking;
|
||||
callbacks.onThinking(thinking);
|
||||
}
|
||||
if (chunk.message?.content) {
|
||||
content += chunk.message.content;
|
||||
callbacks.onContent(content);
|
||||
}
|
||||
if (chunk.message?.tool_calls?.length) {
|
||||
for (const tc of chunk.message.tool_calls) {
|
||||
if (tc.function?.name) {
|
||||
toolCalls.push({
|
||||
type: 'function',
|
||||
function: {
|
||||
name: tc.function.name,
|
||||
arguments: tc.function.arguments || {}
|
||||
}
|
||||
});
|
||||
} else if (toolCalls.length > 0) {
|
||||
const last = toolCalls[toolCalls.length - 1];
|
||||
if (tc.function?.arguments && typeof tc.function.arguments === 'object') {
|
||||
Object.assign(last.function.arguments, tc.function.arguments);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
abortController
|
||||
);
|
||||
} catch (err) {
|
||||
if (abortController.signal.aborted) {
|
||||
if (content || thinking) {
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content,
|
||||
...(thinking && { thinking })
|
||||
});
|
||||
}
|
||||
callbacks.onDone(content, allToolRecords.length > 0 ? allToolRecords : undefined);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const assistantMsg: OllamaMessage = {
|
||||
role: 'assistant',
|
||||
content,
|
||||
...(thinking && { thinking })
|
||||
};
|
||||
if (toolCalls.length > 0) {
|
||||
assistantMsg.tool_calls = toolCalls;
|
||||
}
|
||||
messages.push(assistantMsg);
|
||||
|
||||
if (toolCalls.length === 0) {
|
||||
callbacks.onDone(content, allToolRecords.length > 0 ? allToolRecords : undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const call of toolCalls) {
|
||||
callbacks.onToolCallStart(call);
|
||||
|
||||
if (needsConfirmation(call.function.name)) {
|
||||
const confirmed = await callbacks.onConfirmTool(call);
|
||||
if (!confirmed) {
|
||||
const record: ToolCallRecord = {
|
||||
name: call.function.name,
|
||||
arguments: call.function.arguments,
|
||||
result: { success: false, error: '用户取消了操作' },
|
||||
status: 'cancelled',
|
||||
timestamp: Date.now()
|
||||
};
|
||||
allToolRecords.push(record);
|
||||
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
tool_name: call.function.name,
|
||||
content: JSON.stringify({ success: false, error: '用户取消了操作' })
|
||||
});
|
||||
callbacks.onToolCallError(call.function.name, '用户取消', call);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await executeTool(call.function.name, call.function.arguments);
|
||||
const record: ToolCallRecord = {
|
||||
name: call.function.name,
|
||||
arguments: call.function.arguments,
|
||||
result,
|
||||
status: result.success ? 'success' : 'error',
|
||||
timestamp: Date.now()
|
||||
};
|
||||
allToolRecords.push(record);
|
||||
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
tool_name: call.function.name,
|
||||
content: JSON.stringify(result)
|
||||
});
|
||||
callbacks.onToolCallResult(call.function.name, result, call);
|
||||
} catch (err) {
|
||||
const errorMsg = (err as Error).message;
|
||||
const record: ToolCallRecord = {
|
||||
name: call.function.name,
|
||||
arguments: call.function.arguments,
|
||||
result: { success: false, error: errorMsg },
|
||||
status: 'error',
|
||||
timestamp: Date.now()
|
||||
};
|
||||
allToolRecords.push(record);
|
||||
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
tool_name: call.function.name,
|
||||
content: JSON.stringify({ success: false, error: errorMsg })
|
||||
});
|
||||
callbacks.onToolCallError(call.function.name, errorMsg, call);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
callbacks.onDone(content || '(达到最大工具调用次数限制)', allToolRecords);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Tool Registry - 工具注册与调度中心
|
||||
* 管理所有可用工具的定义,负责执行调度
|
||||
*/
|
||||
|
||||
import type { ToolDefinition, ToolResult } from '../types.js';
|
||||
|
||||
export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'read_file',
|
||||
description: 'Read the contents of a local file. Returns the file content as a string. Supports text files up to 1MB. Use start_line/end_line for large files.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
required: ['path'],
|
||||
properties: {
|
||||
path: { type: 'string', description: 'The file path to read. Absolute or relative.' },
|
||||
encoding: { type: 'string', enum: ['utf-8', 'latin1', 'base64'], description: 'File encoding. Default: utf-8' },
|
||||
start_line: { type: 'integer', description: 'Start line (1-indexed). For reading specific sections.' },
|
||||
end_line: { type: 'integer', description: 'End line (inclusive). Default: start_line + 500.' }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'write_file',
|
||||
description: 'Write content to a local file. Creates parent directories automatically. Overwrites existing files. Max 5MB content.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
required: ['path', 'content'],
|
||||
properties: {
|
||||
path: { type: 'string', description: 'The file path to write to.' },
|
||||
content: { type: 'string', description: 'The content to write.' }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'list_directory',
|
||||
description: 'List directory contents. Returns file names, types, sizes, and modification times.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
required: ['path'],
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Directory path.' },
|
||||
recursive: { type: 'boolean', description: 'List recursively. Default: false.' },
|
||||
max_depth: { type: 'integer', description: 'Max recursion depth. Default: 3.' },
|
||||
include_hidden: { type: 'boolean', description: 'Include hidden files. Default: false.' }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'search_files',
|
||||
description: 'Search files by name pattern or text content within files.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
required: ['path', 'query'],
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Root directory to search.' },
|
||||
query: { type: 'string', description: 'Search query (glob or text).' },
|
||||
search_type: { type: 'string', enum: ['filename', 'content', 'both'], description: 'Search target. Default: both.' },
|
||||
case_sensitive: { type: 'boolean', description: 'Case sensitive. Default: false.' },
|
||||
max_results: { type: 'integer', description: 'Max results. Default: 50.' },
|
||||
file_extensions: { type: 'array', items: { type: 'string' }, description: 'Filter extensions, e.g. [".ts", ".js"]' }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'create_directory',
|
||||
description: 'Create a new directory. Creates parents automatically.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
required: ['path'],
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Directory path to create.' }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'delete_file',
|
||||
description: 'Delete a file or directory. Requires user confirmation.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
required: ['path'],
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Path to delete.' },
|
||||
recursive: { type: 'boolean', description: 'Recursive delete for directories. DANGEROUS.' }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'run_command',
|
||||
description: 'Execute a shell command. DANGEROUS: disabled by default, must be enabled in settings.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
required: ['command'],
|
||||
properties: {
|
||||
command: { type: 'string', description: 'Shell command to execute.' },
|
||||
cwd: { type: 'string', description: 'Working directory.' },
|
||||
timeout: { type: 'integer', description: 'Timeout ms. Max 120000.' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const CONFIRM_TOOLS = ['write_file', 'delete_file', 'run_command', 'create_directory'];
|
||||
|
||||
export function needsConfirmation(toolName: string): boolean {
|
||||
return CONFIRM_TOOLS.includes(toolName);
|
||||
}
|
||||
|
||||
let enabledTools: Set<string> = new Set([
|
||||
'read_file', 'list_directory', 'search_files',
|
||||
'write_file', 'create_directory', 'delete_file'
|
||||
]);
|
||||
|
||||
export function setToolEnabled(toolName: string, enabled: boolean): void {
|
||||
if (enabled) enabledTools.add(toolName);
|
||||
else enabledTools.delete(toolName);
|
||||
}
|
||||
|
||||
export function isToolEnabled(toolName: string): boolean {
|
||||
return enabledTools.has(toolName);
|
||||
}
|
||||
|
||||
export function getEnabledToolDefinitions(): ToolDefinition[] {
|
||||
return TOOL_DEFINITIONS.filter(def => enabledTools.has(def.function.name));
|
||||
}
|
||||
|
||||
export async function executeTool(toolName: string, args: Record<string, unknown>): Promise<ToolResult> {
|
||||
if (!isToolEnabled(toolName)) {
|
||||
return { success: false, error: `工具 ${toolName} 未启用` };
|
||||
}
|
||||
|
||||
const bridge = window.metonaDesktop;
|
||||
if (!bridge?.isDesktop) {
|
||||
return { success: false, error: '工具调用仅支持桌面版' };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await bridge.tool.execute(toolName, args);
|
||||
return result;
|
||||
} catch (err) {
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
export function getToolIcon(name: string): string {
|
||||
const icons: Record<string, string> = {
|
||||
read_file: '📄',
|
||||
write_file: '✏️',
|
||||
list_directory: '📁',
|
||||
search_files: '🔍',
|
||||
create_directory: '📂',
|
||||
delete_file: '🗑️',
|
||||
run_command: '💻'
|
||||
};
|
||||
return icons[name] || '🔧';
|
||||
}
|
||||
|
||||
export function formatToolName(name: string): string {
|
||||
const names: Record<string, string> = {
|
||||
read_file: '读取文件',
|
||||
write_file: '写入文件',
|
||||
list_directory: '列出目录',
|
||||
search_files: '搜索文件',
|
||||
create_directory: '创建目录',
|
||||
delete_file: '删除文件',
|
||||
run_command: '执行命令'
|
||||
};
|
||||
return names[name] || name;
|
||||
}
|
||||
@@ -2181,3 +2181,216 @@ html, body {
|
||||
margin: 8px 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
Tool Calling 工具调用卡片
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.tool-calls-container {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.tool-call-card {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
margin: 8px 0;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tool-call-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.tool-call-icon {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.tool-call-name {
|
||||
font-weight: 600;
|
||||
color: var(--accent-cyan, #00f5d4);
|
||||
}
|
||||
|
||||
.tool-call-status {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, rgba(255, 255, 255, 0.6));
|
||||
}
|
||||
|
||||
.tool-call-params {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.tool-call-params code {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Cascadia Code', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--accent-cyan, #00f5d4);
|
||||
}
|
||||
|
||||
.tool-param {
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.tool-call-result {
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.tool-result-content {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
font-family: 'Cascadia Code', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
margin: 0;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.tool-result-stderr {
|
||||
color: #ff6b6b;
|
||||
border-left: 3px solid #ff6b6b;
|
||||
}
|
||||
|
||||
.tool-result-entry {
|
||||
padding: 2px 0;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.tool-result-entry code {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Cascadia Code', 'Consolas', monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.tool-result-meta {
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.tool-result-error {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
/* 状态颜色 */
|
||||
.tool-call-pending {
|
||||
border-color: rgba(255, 165, 0, 0.4);
|
||||
}
|
||||
|
||||
.tool-call-running {
|
||||
border-color: rgba(0, 245, 212, 0.4);
|
||||
}
|
||||
|
||||
.tool-call-success {
|
||||
border-color: rgba(0, 200, 83, 0.3);
|
||||
}
|
||||
|
||||
.tool-call-error {
|
||||
border-color: rgba(255, 82, 82, 0.4);
|
||||
}
|
||||
|
||||
.tool-call-cancelled {
|
||||
border-color: rgba(150, 150, 150, 0.4);
|
||||
}
|
||||
|
||||
/* 执行中 spinner */
|
||||
.tool-call-running .tool-call-header::after {
|
||||
content: '';
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(0, 245, 212, 0.3);
|
||||
border-top-color: var(--accent-cyan, #00f5d4);
|
||||
border-radius: 50%;
|
||||
animation: tool-spin 0.8s linear infinite;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@keyframes tool-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ── 工具调用设置 ── */
|
||||
|
||||
.tool-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tool-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tool-item span:first-child {
|
||||
color: var(--text-primary, #fff);
|
||||
}
|
||||
|
||||
/* ── 工具确认对话框 ── */
|
||||
|
||||
.tool-confirm-info {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tool-confirm-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 6px 0;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.tool-confirm-label {
|
||||
color: var(--text-secondary, rgba(255, 255, 255, 0.6));
|
||||
white-space: nowrap;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.tool-confirm-row code {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Cascadia Code', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--accent-cyan, #00f5d4);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.tool-confirm-preview {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
font-family: 'Cascadia Code', 'Consolas', monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
margin: 4px 0 0;
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
Vendored
+69
-2
@@ -1,11 +1,13 @@
|
||||
// ── Ollama API 类型 ──
|
||||
|
||||
export interface OllamaMessage {
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
role: 'user' | 'assistant' | 'system' | 'tool';
|
||||
content: string;
|
||||
images?: string[];
|
||||
thinking?: string;
|
||||
reasoning_content?: string;
|
||||
tool_calls?: ToolCall[];
|
||||
tool_name?: string;
|
||||
}
|
||||
|
||||
export interface OllamaChatParams {
|
||||
@@ -14,6 +16,7 @@ export interface OllamaChatParams {
|
||||
stream?: boolean;
|
||||
think?: boolean;
|
||||
system?: string;
|
||||
tools?: ToolDefinition[];
|
||||
keep_alive?: number | string;
|
||||
options?: {
|
||||
num_ctx?: number;
|
||||
@@ -29,6 +32,7 @@ export interface OllamaStreamChunk {
|
||||
content?: string;
|
||||
thinking?: string;
|
||||
reasoning_content?: string;
|
||||
tool_calls?: ToolCall[];
|
||||
};
|
||||
done?: boolean;
|
||||
eval_count?: number;
|
||||
@@ -100,6 +104,7 @@ export interface ChatMessage {
|
||||
_fileContents?: FileContent[];
|
||||
ragSources?: RagSource[];
|
||||
stopped?: boolean;
|
||||
toolCalls?: ToolCallRecord[];
|
||||
}
|
||||
|
||||
export interface ChatSession {
|
||||
@@ -172,6 +177,11 @@ export interface MetonaDesktopAPI {
|
||||
readFile: (filePath: string) => Promise<{ success: boolean; content?: string; name?: string; error?: string }>;
|
||||
writeFile: (filePath: string, content: string) => Promise<{ success: boolean; error?: string }>;
|
||||
};
|
||||
tool: {
|
||||
execute: (toolName: string, args: Record<string, unknown>) => Promise<ToolResult>;
|
||||
getConfig: () => Promise<{ allowedDirs: string[]; blockedDirs: string[] }>;
|
||||
setAllowedDirs: (dirs: string[]) => Promise<void>;
|
||||
};
|
||||
notify: (title: string, body: string) => void;
|
||||
window: {
|
||||
minimize: () => void;
|
||||
@@ -238,7 +248,62 @@ export type StateKey =
|
||||
| 'temperature'
|
||||
| 'thinkEnabled'
|
||||
| 'presets'
|
||||
| 'activePresetId';
|
||||
| 'activePresetId'
|
||||
| 'toolCallingEnabled'
|
||||
| 'runCommandEnabled';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Tool Calling 类型
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
export interface ToolParameterProperty {
|
||||
type: string;
|
||||
description?: string;
|
||||
enum?: string[];
|
||||
items?: { type: string };
|
||||
}
|
||||
|
||||
export interface ToolParameters {
|
||||
type: 'object';
|
||||
required?: string[];
|
||||
properties: Record<string, ToolParameterProperty>;
|
||||
}
|
||||
|
||||
export interface ToolFunctionDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: ToolParameters;
|
||||
}
|
||||
|
||||
export interface ToolDefinition {
|
||||
type: 'function';
|
||||
function: ToolFunctionDefinition;
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
type: 'function';
|
||||
function: {
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ToolResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ToolCallRecord {
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
result: ToolResult | null;
|
||||
status: 'pending' | 'running' | 'success' | 'error' | 'cancelled';
|
||||
confirmed?: boolean;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export type AgentState = 'idle' | 'sending' | 'accumulating' | 'executing' | 'confirming' | 'done';
|
||||
|
||||
// ── Window global 声明 ──
|
||||
|
||||
@@ -248,3 +313,5 @@ declare global {
|
||||
__metonaBridge?: DesktopBridge;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
|
||||
Reference in New Issue
Block a user