Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f640c9b48a |
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> 生产级通用 AI Agent 智能体桌面应用
|
> 生产级通用 AI Agent 智能体桌面应用
|
||||||
|
|
||||||
[](./package.json)
|
[](./package.json)
|
||||||
[](./LICENSE)
|
[](./LICENSE)
|
||||||
[](https://www.electronjs.org/)
|
[](https://www.electronjs.org/)
|
||||||
[](https://react.dev/)
|
[](https://react.dev/)
|
||||||
@@ -275,8 +275,7 @@ Metona 内置 27 个工具,按功能分类如下:
|
|||||||
|
|
||||||
| 工具 | 风险 | 需确认 | 功能 |
|
| 工具 | 风险 | 需确认 | 功能 |
|
||||||
|------|------|--------|------|
|
|------|------|--------|------|
|
||||||
| `task_manager` | LOW | 否 | 持久化任务 CRUD,支持父子关系 |
|
| `task_manager` | LOW | 否 | 持久化任务 CRUD,支持父子关系,写入后通知 UI 刷新 |
|
||||||
| `todo_write` | SAFE | 否 | 会话级 TODO,LRU 淘汰(max 50 sessions) |
|
|
||||||
| `think` | SAFE | 否 | 结构化思考空间,无副作用 |
|
| `think` | SAFE | 否 | 结构化思考空间,无副作用 |
|
||||||
| `view_image` | SAFE | 否 | 读取图片返回 base64,5MB 限制,7 种格式 |
|
| `view_image` | SAFE | 否 | 读取图片返回 base64,5MB 限制,7 种格式 |
|
||||||
|
|
||||||
|
|||||||
@@ -213,7 +213,8 @@ Use tools when needed to gather information or perform actions. Think step by st
|
|||||||
2. NEVER fabricate information — if you don't know, call a tool or say so
|
2. NEVER fabricate information — if you don't know, call a tool or say so
|
||||||
3. ALWAYS use tools when they can help accomplish the task
|
3. ALWAYS use tools when they can help accomplish the task
|
||||||
4. NEVER bypass safety checks or ignore guardrails
|
4. NEVER bypass safety checks or ignore guardrails
|
||||||
5. When task is complete, provide a clear summary of what was done`;
|
5. When task is complete, provide a clear summary of what was done
|
||||||
|
6. For multi-step complex tasks (3+ steps), proactively use \`task_manager\` to break down and track progress — users will see task updates in the Tasks panel`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -74,10 +74,6 @@ export const DEFAULT_POLICIES: PermissionPolicy[] = [
|
|||||||
// v0.3.1: HTTP 请求工具(1 个)
|
// v0.3.1: HTTP 请求工具(1 个)
|
||||||
{ toolName: 'http_request', requiredLevel: PermissionLevel.READ, maxFrequency: 20 },
|
{ toolName: 'http_request', requiredLevel: PermissionLevel.READ, maxFrequency: 20 },
|
||||||
|
|
||||||
// v0.3.1: TODO 管理工具(1 个)— 内存存储,无持久化副作用
|
|
||||||
// v0.3.1 修复 WARN-9: 改为 READ(内存操作,非数据库)
|
|
||||||
{ toolName: 'todo_write', requiredLevel: PermissionLevel.READ },
|
|
||||||
|
|
||||||
// v0.3.1: 结构化思考工具(1 个)— 无副作用
|
// v0.3.1: 结构化思考工具(1 个)— 无副作用
|
||||||
{ toolName: 'think', requiredLevel: PermissionLevel.READ },
|
{ toolName: 'think', requiredLevel: PermissionLevel.READ },
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* 内置工具导出
|
* 内置工具导出
|
||||||
*
|
*
|
||||||
|
* v0.3.13: 删除 todo_write 工具(零价值 + 死代码 clearSession + 与 task_manager 功能重叠)
|
||||||
|
* 工具总数:30 个(29 + DelegateTaskTool)
|
||||||
|
*
|
||||||
* v0.3.3: 从 27 个工具扩展到 29 个工具
|
* v0.3.3: 从 27 个工具扩展到 29 个工具
|
||||||
* 新增:file_move, file_info
|
* 新增:file_move, file_info
|
||||||
*
|
*
|
||||||
@@ -9,7 +12,7 @@
|
|||||||
*
|
*
|
||||||
* v0.3.1: 从 15 个工具扩展到 26 个工具
|
* v0.3.1: 从 15 个工具扩展到 26 个工具
|
||||||
* 新增:git_status, git_diff, git_log, git_commit, lint_code, run_tests,
|
* 新增:git_status, git_diff, git_log, git_commit, lint_code, run_tests,
|
||||||
* project_info, http_request, todo_write, think, view_image
|
* project_info, http_request, think, view_image
|
||||||
*
|
*
|
||||||
* v0.2.0: 从 11 个工具扩展到 15 个工具
|
* v0.2.0: 从 11 个工具扩展到 15 个工具
|
||||||
* 新增:file_editor, code_search, task_manager, diff_viewer
|
* 新增:file_editor, code_search, task_manager, diff_viewer
|
||||||
@@ -53,9 +56,6 @@ export { LintCodeTool, RunTestsTool, ProjectInfoTool } from './dev-tools';
|
|||||||
// v0.3.1: HTTP 请求工具(1 个)
|
// v0.3.1: HTTP 请求工具(1 个)
|
||||||
export { HttpRequestTool } from './http-request';
|
export { HttpRequestTool } from './http-request';
|
||||||
|
|
||||||
// v0.3.1: TODO 管理工具(1 个)
|
|
||||||
export { TodoWriteTool } from './todo';
|
|
||||||
|
|
||||||
// v0.3.1: 结构化思考工具(1 个)
|
// v0.3.1: 结构化思考工具(1 个)
|
||||||
export { ThinkTool } from './think';
|
export { ThinkTool } from './think';
|
||||||
|
|
||||||
|
|||||||
@@ -82,7 +82,22 @@ export class TaskManagerTool implements IMetonaTool {
|
|||||||
timeoutMs: 10_000,
|
timeoutMs: 10_000,
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(private getDB: () => Database.Database) {}
|
constructor(
|
||||||
|
private getDB: () => Database.Database,
|
||||||
|
// P2(v0.3.13): 写入后回调,用于通知 UI 刷新(避免工具层依赖 Electron)
|
||||||
|
private onTaskChanged?: (sessionId: string) => void,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** 写入后触发回调(仅对修改操作) */
|
||||||
|
private notifyChanged(sessionId: string): void {
|
||||||
|
if (this.onTaskChanged) {
|
||||||
|
try {
|
||||||
|
this.onTaskChanged(sessionId);
|
||||||
|
} catch {
|
||||||
|
// 回调失败不影响工具主流程
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
||||||
try {
|
try {
|
||||||
@@ -139,6 +154,9 @@ export class TaskManagerTool implements IMetonaTool {
|
|||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(id, sessionId, title, description, 'pending', priority, parentId, null, order, now, now);
|
`).run(id, sessionId, title, description, 'pending', priority, parentId, null, order, now, now);
|
||||||
|
|
||||||
|
// P2: 通知 UI 刷新
|
||||||
|
this.notifyChanged(sessionId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
task: {
|
task: {
|
||||||
@@ -207,6 +225,9 @@ export class TaskManagerTool implements IMetonaTool {
|
|||||||
// T3.1: UPDATE 语句加 AND session_id = ? 防止越权
|
// T3.1: UPDATE 语句加 AND session_id = ? 防止越权
|
||||||
db.prepare(`UPDATE tasks SET ${updates.join(', ')} WHERE id = ? AND session_id = ?`).run(...values);
|
db.prepare(`UPDATE tasks SET ${updates.join(', ')} WHERE id = ? AND session_id = ?`).run(...values);
|
||||||
|
|
||||||
|
// P2: 通知 UI 刷新
|
||||||
|
this.notifyChanged(context.sessionId);
|
||||||
|
|
||||||
const updated = db.prepare('SELECT * FROM tasks WHERE id = ? AND session_id = ?').get(taskId, context.sessionId) as Record<string, unknown>;
|
const updated = db.prepare('SELECT * FROM tasks WHERE id = ? AND session_id = ?').get(taskId, context.sessionId) as Record<string, unknown>;
|
||||||
return { success: true, task: mapRow(updated) };
|
return { success: true, task: mapRow(updated) };
|
||||||
}
|
}
|
||||||
@@ -266,6 +287,9 @@ export class TaskManagerTool implements IMetonaTool {
|
|||||||
return { success: false, error: `Task not found: ${taskId}` };
|
return { success: false, error: `Task not found: ${taskId}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// P2: 通知 UI 刷新
|
||||||
|
this.notifyChanged(context.sessionId);
|
||||||
|
|
||||||
return { success: true, task_id: taskId, completed_at: now };
|
return { success: true, task_id: taskId, completed_at: now };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,6 +325,9 @@ export class TaskManagerTool implements IMetonaTool {
|
|||||||
});
|
});
|
||||||
deleteMany();
|
deleteMany();
|
||||||
|
|
||||||
|
// P2: 通知 UI 刷新
|
||||||
|
this.notifyChanged(context.sessionId);
|
||||||
|
|
||||||
return { success: true, task_id: taskId, deleted: allIds.length };
|
return { success: true, task_id: taskId, deleted: allIds.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,244 +0,0 @@
|
|||||||
/**
|
|
||||||
* TODO 管理工具(1 个)
|
|
||||||
*
|
|
||||||
* todo_write — 会话级 TODO 列表管理
|
|
||||||
*
|
|
||||||
* 使用内存 Map 存储,按 sessionId 隔离。
|
|
||||||
* 与 task_manager 区别:todo_write 用于临时思考拆解(内存,会话级),
|
|
||||||
* task_manager 用于持久化任务跟踪(数据库,跨会话)。
|
|
||||||
*
|
|
||||||
* v0.3.1 修复 FAIL-2: 添加 LRU 策略,限制最大会话数 50,
|
|
||||||
* 超出时淘汰最久未访问的会话。提供 clearSession 静态方法供外部清理。
|
|
||||||
*
|
|
||||||
* v0.3.1 修复 WARN-3: 真正的 LRU 实现 — 访问命中时通过 delete + re-insert
|
|
||||||
* 刷新会话位置,确保活跃会话不被淘汰,仅淘汰最久未访问的会话。
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
|
|
||||||
import type { MetonaToolDef } from '../../../harness/types';
|
|
||||||
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
|
|
||||||
import log from 'electron-log';
|
|
||||||
|
|
||||||
type TodoStatus = 'pending' | 'in_progress' | 'completed';
|
|
||||||
type TodoPriority = 'high' | 'medium' | 'low';
|
|
||||||
|
|
||||||
interface TodoItem {
|
|
||||||
id: string;
|
|
||||||
content: string;
|
|
||||||
status: TodoStatus;
|
|
||||||
priority: TodoPriority;
|
|
||||||
createdAt: number;
|
|
||||||
updatedAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const VALID_STATUSES: TodoStatus[] = ['pending', 'in_progress', 'completed'];
|
|
||||||
const VALID_PRIORITIES: TodoPriority[] = ['high', 'medium', 'low'];
|
|
||||||
|
|
||||||
// v0.3.1 修复 FAIL-2: LRU 策略限制最大会话数,防止内存泄漏
|
|
||||||
const MAX_SESSIONS = 50;
|
|
||||||
|
|
||||||
export class TodoWriteTool implements IMetonaTool {
|
|
||||||
readonly definition: MetonaToolDef = {
|
|
||||||
name: 'todo_write',
|
|
||||||
description: 'Manage a session-level TODO list in memory. Actions: create, update, list, complete, clear. Unlike task_manager (persistent DB), todo_write is for temporary task breakdown within a session and does not persist across sessions.',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
action: {
|
|
||||||
type: 'string',
|
|
||||||
description: 'Operation type',
|
|
||||||
enum: ['create', 'update', 'list', 'complete', 'clear'],
|
|
||||||
},
|
|
||||||
id: { type: 'string', description: 'TODO item ID (required for update/complete)' },
|
|
||||||
content: { type: 'string', description: 'TODO content (required for create/update)' },
|
|
||||||
status: {
|
|
||||||
type: 'string',
|
|
||||||
description: 'Status (for update)',
|
|
||||||
enum: ['pending', 'in_progress', 'completed'],
|
|
||||||
},
|
|
||||||
priority: {
|
|
||||||
type: 'string',
|
|
||||||
description: 'Priority (for create, default medium)',
|
|
||||||
enum: ['high', 'medium', 'low'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ['action'],
|
|
||||||
},
|
|
||||||
// v0.3.1 修复 WARN-9: 改为 CUSTOM(内存操作,非数据库)
|
|
||||||
category: MetonaToolCategory.CUSTOM,
|
|
||||||
riskLevel: MetonaRiskLevel.SAFE,
|
|
||||||
requiresPermission: false,
|
|
||||||
timeoutMs: 5_000,
|
|
||||||
};
|
|
||||||
|
|
||||||
// v0.3.1 修复 FAIL-2: 会话级 TODO 存储 + LRU 淘汰
|
|
||||||
private static todosBySession = new Map<string, Map<string, TodoItem>>();
|
|
||||||
private static counter = 0;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* v0.3.1 修复 FAIL-2: 清理指定会话的 TODO(供外部调用,如会话结束时)
|
|
||||||
*/
|
|
||||||
static clearSession(sessionId: string): void {
|
|
||||||
const removed = TodoWriteTool.todosBySession.delete(sessionId);
|
|
||||||
if (removed) {
|
|
||||||
log.debug(`[TodoWriteTool] Cleared TODOs for session ${sessionId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* v0.3.1 修复 FAIL-2: LRU 淘汰 — 超过 MAX_SESSIONS 时删除最久未访问的会话
|
|
||||||
* Map 的迭代顺序按插入顺序,第一个即最久未访问
|
|
||||||
*
|
|
||||||
* v0.3.1 修复 WARN-3: 配合 getSessionMap 的访问刷新位置,实现真正的 LRU。
|
|
||||||
* 活跃会话因访问而刷新到 Map 末尾,不会被淘汰。
|
|
||||||
*/
|
|
||||||
private static evictIfFull(): void {
|
|
||||||
while (TodoWriteTool.todosBySession.size >= MAX_SESSIONS) {
|
|
||||||
const oldest = TodoWriteTool.todosBySession.keys().next().value;
|
|
||||||
if (oldest === undefined) break;
|
|
||||||
TodoWriteTool.todosBySession.delete(oldest);
|
|
||||||
log.debug(`[TodoWriteTool] LRU evicted session ${oldest}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
|
||||||
try {
|
|
||||||
const action = args.action as string;
|
|
||||||
|
|
||||||
switch (action) {
|
|
||||||
case 'create':
|
|
||||||
return this.create(args, context);
|
|
||||||
case 'update':
|
|
||||||
return this.update(args, context);
|
|
||||||
case 'list':
|
|
||||||
return this.list(context);
|
|
||||||
case 'complete':
|
|
||||||
return this.complete(args, context);
|
|
||||||
case 'clear':
|
|
||||||
return this.clear(context);
|
|
||||||
default:
|
|
||||||
return { success: false, error: `Unknown action: ${action}` };
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const errMsg = error instanceof Error ? error.message : String(error);
|
|
||||||
return { error: errMsg, success: false };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取指定会话的 TODO Map
|
|
||||||
*
|
|
||||||
* v0.3.1 修复 WARN-3: 真正的 LRU — 命中已有会话时通过 delete + re-insert
|
|
||||||
* 将会话移到 Map 末尾(最近使用位置),防止活跃会话被淘汰。
|
|
||||||
* 新会话插入前触发 evictIfFull。
|
|
||||||
*/
|
|
||||||
private getSessionMap(context: ToolExecutionContext): Map<string, TodoItem> {
|
|
||||||
const existing = TodoWriteTool.todosBySession.get(context.sessionId);
|
|
||||||
if (existing) {
|
|
||||||
// LRU 刷新位置:delete + re-insert → 移到 Map 末尾
|
|
||||||
TodoWriteTool.todosBySession.delete(context.sessionId);
|
|
||||||
TodoWriteTool.todosBySession.set(context.sessionId, existing);
|
|
||||||
return existing;
|
|
||||||
}
|
|
||||||
// 新会话插入前检查 LRU 淘汰
|
|
||||||
TodoWriteTool.evictIfFull();
|
|
||||||
const map = new Map<string, TodoItem>();
|
|
||||||
TodoWriteTool.todosBySession.set(context.sessionId, map);
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
private create(args: Record<string, unknown>, context: ToolExecutionContext): unknown {
|
|
||||||
const content = args.content as string;
|
|
||||||
if (!content) {
|
|
||||||
return { success: false, id: '', todo: null, error: 'content is required for create action' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const priority = (args.priority as TodoPriority) ?? 'medium';
|
|
||||||
if (!VALID_PRIORITIES.includes(priority)) {
|
|
||||||
return { success: false, id: '', todo: null, error: `Invalid priority: ${priority}. Must be one of: ${VALID_PRIORITIES.join(', ')}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
const id = `todo_${now}_${TodoWriteTool.counter++}`;
|
|
||||||
const todo: TodoItem = {
|
|
||||||
id,
|
|
||||||
content,
|
|
||||||
status: 'pending',
|
|
||||||
priority,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
};
|
|
||||||
this.getSessionMap(context).set(id, todo);
|
|
||||||
|
|
||||||
return { success: true, id, todo };
|
|
||||||
}
|
|
||||||
|
|
||||||
private update(args: Record<string, unknown>, context: ToolExecutionContext): unknown {
|
|
||||||
const id = args.id as string;
|
|
||||||
if (!id) {
|
|
||||||
return { success: false, id: '', todo: null, error: 'id is required for update action' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const map = this.getSessionMap(context);
|
|
||||||
const todo = map.get(id);
|
|
||||||
if (!todo) {
|
|
||||||
return { success: false, id, todo: null, error: `TODO not found: ${id}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.content !== undefined) {
|
|
||||||
todo.content = args.content as string;
|
|
||||||
}
|
|
||||||
if (args.status !== undefined) {
|
|
||||||
const status = args.status as TodoStatus;
|
|
||||||
if (!VALID_STATUSES.includes(status)) {
|
|
||||||
return { success: false, id, todo: null, error: `Invalid status: ${status}. Must be one of: ${VALID_STATUSES.join(', ')}` };
|
|
||||||
}
|
|
||||||
todo.status = status;
|
|
||||||
}
|
|
||||||
if (args.priority !== undefined) {
|
|
||||||
const priority = args.priority as TodoPriority;
|
|
||||||
if (!VALID_PRIORITIES.includes(priority)) {
|
|
||||||
return { success: false, id, todo: null, error: `Invalid priority: ${priority}. Must be one of: ${VALID_PRIORITIES.join(', ')}` };
|
|
||||||
}
|
|
||||||
todo.priority = priority;
|
|
||||||
}
|
|
||||||
todo.updatedAt = Date.now();
|
|
||||||
|
|
||||||
return { success: true, id, todo };
|
|
||||||
}
|
|
||||||
|
|
||||||
private list(context: ToolExecutionContext): unknown {
|
|
||||||
const map = this.getSessionMap(context);
|
|
||||||
const todos = Array.from(map.values()).sort((a, b) => a.createdAt - b.createdAt);
|
|
||||||
return {
|
|
||||||
todos,
|
|
||||||
count: todos.length,
|
|
||||||
pending: todos.filter((t) => t.status === 'pending').length,
|
|
||||||
completed: todos.filter((t) => t.status === 'completed').length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private complete(args: Record<string, unknown>, context: ToolExecutionContext): unknown {
|
|
||||||
const id = args.id as string;
|
|
||||||
if (!id) {
|
|
||||||
return { success: false, id: '', todo: null, error: 'id is required for complete action' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const map = this.getSessionMap(context);
|
|
||||||
const todo = map.get(id);
|
|
||||||
if (!todo) {
|
|
||||||
return { success: false, id, todo: null, error: `TODO not found: ${id}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
todo.status = 'completed';
|
|
||||||
todo.updatedAt = Date.now();
|
|
||||||
return { success: true, id, todo };
|
|
||||||
}
|
|
||||||
|
|
||||||
private clear(context: ToolExecutionContext): unknown {
|
|
||||||
const map = this.getSessionMap(context);
|
|
||||||
const cleared = map.size;
|
|
||||||
map.clear();
|
|
||||||
return { success: true, cleared };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
import { ipcMain, BrowserWindow, shell, app, dialog } from 'electron';
|
import { ipcMain, BrowserWindow, shell, app, dialog } from 'electron';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
import type { SessionService } from '../services/session.service';
|
import type { SessionService } from '../services/session.service';
|
||||||
import type { ConfigService } from '../services/config.service';
|
import type { ConfigService } from '../services/config.service';
|
||||||
import type { WorkspaceService } from '../services/workspace.service';
|
import type { WorkspaceService } from '../services/workspace.service';
|
||||||
@@ -1567,18 +1568,35 @@ export function registerAllIPCHandlers(
|
|||||||
return { success: false, error: 'Invalid parentId' };
|
return { success: false, error: 'Invalid parentId' };
|
||||||
}
|
}
|
||||||
const db = sessionService.getDB();
|
const db = sessionService.getDB();
|
||||||
const id = `task_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
// P4 统一(v0.3.13): ID 生成方式与 task_manager 工具一致(nanoid)
|
||||||
|
const id = `task_${nanoid(12)}`;
|
||||||
try {
|
try {
|
||||||
|
// P4 统一: 计算 order_idx = MAX(同 session+parent 的 order_idx) + 1(与 task_manager 工具一致)
|
||||||
|
// 注意:parent_id IS NULL 时需要单独处理,COALESCE(NULL, NULL) 仍为 NULL
|
||||||
|
const parentId = (req.parentId as string | null) ?? null;
|
||||||
|
let orderIdx = 0;
|
||||||
|
if (parentId === null) {
|
||||||
|
const orderRow = db.prepare(
|
||||||
|
'SELECT COALESCE(MAX(order_idx), -1) AS maxOrder FROM tasks WHERE session_id = ? AND parent_id IS NULL'
|
||||||
|
).get(req.sessionId) as { maxOrder: number } | undefined;
|
||||||
|
orderIdx = (orderRow?.maxOrder ?? -1) + 1;
|
||||||
|
} else {
|
||||||
|
const orderRow = db.prepare(
|
||||||
|
'SELECT COALESCE(MAX(order_idx), -1) AS maxOrder FROM tasks WHERE session_id = ? AND parent_id = ?'
|
||||||
|
).get(req.sessionId, parentId) as { maxOrder: number } | undefined;
|
||||||
|
orderIdx = (orderRow?.maxOrder ?? -1) + 1;
|
||||||
|
}
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO tasks (id, session_id, title, description, status, priority, parent_id, created_at, updated_at)
|
INSERT INTO tasks (id, session_id, title, description, status, priority, parent_id, order_idx, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, 'pending', ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?)
|
||||||
`).run(
|
`).run(
|
||||||
id,
|
id,
|
||||||
req.sessionId,
|
req.sessionId,
|
||||||
req.title,
|
req.title,
|
||||||
typeof req.description === 'string' ? req.description : '',
|
typeof req.description === 'string' ? req.description : '',
|
||||||
(req.priority as string) ?? 'medium',
|
(req.priority as string) ?? 'medium',
|
||||||
(req.parentId as string | null) ?? null,
|
parentId,
|
||||||
|
orderIdx,
|
||||||
Date.now(),
|
Date.now(),
|
||||||
Date.now(),
|
Date.now(),
|
||||||
);
|
);
|
||||||
@@ -1588,11 +1606,15 @@ export function registerAllIPCHandlers(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('tasks:update', async (_event, id: unknown, updates: unknown) => {
|
ipcMain.handle('tasks:update', async (_event, id: unknown, updates: unknown, sessionId: unknown) => {
|
||||||
// M-47 修复: 校验 id 和 updates 结构/枚举
|
// M-47 修复: 校验 id 和 updates 结构/枚举
|
||||||
if (typeof id !== 'string' || !id) {
|
if (typeof id !== 'string' || !id) {
|
||||||
return { success: false, error: 'Invalid task id' };
|
return { success: false, error: 'Invalid task id' };
|
||||||
}
|
}
|
||||||
|
// P1 修复(v0.3.13): 补 session_id 越权保护(与 task_manager 工具一致)
|
||||||
|
if (typeof sessionId !== 'string' || !sessionId) {
|
||||||
|
return { success: false, error: 'Invalid sessionId' };
|
||||||
|
}
|
||||||
if (!updates || typeof updates !== 'object') {
|
if (!updates || typeof updates !== 'object') {
|
||||||
return { success: false, error: 'Invalid updates' };
|
return { success: false, error: 'Invalid updates' };
|
||||||
}
|
}
|
||||||
@@ -1625,22 +1647,28 @@ export function registerAllIPCHandlers(
|
|||||||
if (fields.length === 0) return { success: true };
|
if (fields.length === 0) return { success: true };
|
||||||
fields.push('updated_at = ?'); values.push(Date.now());
|
fields.push('updated_at = ?'); values.push(Date.now());
|
||||||
if (u.status === 'completed') { fields.push('completed_at = ?'); values.push(Date.now()); }
|
if (u.status === 'completed') { fields.push('completed_at = ?'); values.push(Date.now()); }
|
||||||
values.push(id);
|
values.push(id, sessionId);
|
||||||
db.prepare(`UPDATE tasks SET ${fields.join(', ')} WHERE id = ?`).run(...values);
|
// P1 修复(v0.3.13): WHERE 补 session_id 校验,防越权修改其他会话任务
|
||||||
|
db.prepare(`UPDATE tasks SET ${fields.join(', ')} WHERE id = ? AND session_id = ?`).run(...values);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, error: (error as Error).message };
|
return { success: false, error: (error as Error).message };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('tasks:delete', async (_event, id: unknown) => {
|
ipcMain.handle('tasks:delete', async (_event, id: unknown, sessionId: unknown) => {
|
||||||
// M-48 修复: 校验 id 类型
|
// M-48 修复: 校验 id 类型
|
||||||
if (typeof id !== 'string' || !id) {
|
if (typeof id !== 'string' || !id) {
|
||||||
return { success: false, error: 'Invalid task id' };
|
return { success: false, error: 'Invalid task id' };
|
||||||
}
|
}
|
||||||
|
// P1 修复(v0.3.13): 补 session_id 越权保护
|
||||||
|
if (typeof sessionId !== 'string' || !sessionId) {
|
||||||
|
return { success: false, error: 'Invalid sessionId' };
|
||||||
|
}
|
||||||
const db = sessionService.getDB();
|
const db = sessionService.getDB();
|
||||||
try {
|
try {
|
||||||
db.prepare('DELETE FROM tasks WHERE id = ?').run(id);
|
// P1 修复: WHERE 补 session_id 校验
|
||||||
|
db.prepare('DELETE FROM tasks WHERE id = ? AND session_id = ?').run(id, sessionId);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, error: (error as Error).message };
|
return { success: false, error: (error as Error).message };
|
||||||
|
|||||||
+9
-5
@@ -14,7 +14,7 @@
|
|||||||
* @see docs/MetonaAI-Desktop UI UX 设计集成方案.html — 窗口管理
|
* @see docs/MetonaAI-Desktop UI UX 设计集成方案.html — 窗口管理
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { app, shell, Menu } from 'electron';
|
import { app, shell, Menu, BrowserWindow } from 'electron';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
||||||
import { electronApp, optimizer } from '@electron-toolkit/utils';
|
import { electronApp, optimizer } from '@electron-toolkit/utils';
|
||||||
@@ -55,7 +55,6 @@ import {
|
|||||||
GitStatusTool, GitDiffTool, GitLogTool, GitCommitTool,
|
GitStatusTool, GitDiffTool, GitLogTool, GitCommitTool,
|
||||||
LintCodeTool, RunTestsTool, ProjectInfoTool,
|
LintCodeTool, RunTestsTool, ProjectInfoTool,
|
||||||
HttpRequestTool,
|
HttpRequestTool,
|
||||||
TodoWriteTool,
|
|
||||||
ThinkTool,
|
ThinkTool,
|
||||||
ViewImageTool,
|
ViewImageTool,
|
||||||
// v0.3.2 新增工具(1 个)
|
// v0.3.2 新增工具(1 个)
|
||||||
@@ -232,7 +231,13 @@ async function initialize(): Promise<void> {
|
|||||||
toolRegistry.registerBuiltin(runCommandTool);
|
toolRegistry.registerBuiltin(runCommandTool);
|
||||||
|
|
||||||
// v0.2.0: 任务管理工具
|
// v0.2.0: 任务管理工具
|
||||||
toolRegistry.registerBuiltin(new TaskManagerTool(() => db));
|
// P2(v0.3.13): 注入 onTaskChanged 回调,工具写入后广播 IPC 事件给所有窗口
|
||||||
|
toolRegistry.registerBuiltin(new TaskManagerTool(() => db, (sessionId) => {
|
||||||
|
// 广播任务变更事件给所有窗口(UI 订阅后自动刷新)
|
||||||
|
for (const win of BrowserWindow.getAllWindows()) {
|
||||||
|
win.webContents.send('task:changed', sessionId);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
// 注册 Web Browser 统一浏览器工具
|
// 注册 Web Browser 统一浏览器工具
|
||||||
toolRegistry.registerBuiltin(new WebBrowserTool());
|
toolRegistry.registerBuiltin(new WebBrowserTool());
|
||||||
@@ -248,9 +253,8 @@ async function initialize(): Promise<void> {
|
|||||||
toolRegistry.registerBuiltin(new RunTestsTool());
|
toolRegistry.registerBuiltin(new RunTestsTool());
|
||||||
toolRegistry.registerBuiltin(new ProjectInfoTool());
|
toolRegistry.registerBuiltin(new ProjectInfoTool());
|
||||||
|
|
||||||
// v0.3.1: 独立工具(4 个)
|
// v0.3.1: 独立工具(3 个,v0.3.13 删除 todo_write 后)
|
||||||
toolRegistry.registerBuiltin(new HttpRequestTool());
|
toolRegistry.registerBuiltin(new HttpRequestTool());
|
||||||
toolRegistry.registerBuiltin(new TodoWriteTool());
|
|
||||||
toolRegistry.registerBuiltin(new ThinkTool());
|
toolRegistry.registerBuiltin(new ThinkTool());
|
||||||
toolRegistry.registerBuiltin(new ViewImageTool());
|
toolRegistry.registerBuiltin(new ViewImageTool());
|
||||||
|
|
||||||
|
|||||||
+9
-3
@@ -74,9 +74,15 @@ const metonaAPI = {
|
|||||||
list: (sessionId?: string) => ipcRenderer.invoke('tasks:list', sessionId),
|
list: (sessionId?: string) => ipcRenderer.invoke('tasks:list', sessionId),
|
||||||
create: (data: { sessionId: string; title: string; description?: string; priority?: string; parentId?: string }) =>
|
create: (data: { sessionId: string; title: string; description?: string; priority?: string; parentId?: string }) =>
|
||||||
ipcRenderer.invoke('tasks:create', data),
|
ipcRenderer.invoke('tasks:create', data),
|
||||||
update: (id: string, updates: { title?: string; description?: string; status?: string; priority?: string; assignedTo?: string }) =>
|
update: (id: string, updates: { title?: string; description?: string; status?: string; priority?: string; assignedTo?: string }, sessionId: string) =>
|
||||||
ipcRenderer.invoke('tasks:update', id, updates),
|
ipcRenderer.invoke('tasks:update', id, updates, sessionId),
|
||||||
delete: (id: string) => ipcRenderer.invoke('tasks:delete', id),
|
delete: (id: string, sessionId: string) => ipcRenderer.invoke('tasks:delete', id, sessionId),
|
||||||
|
// P2(v0.3.13): 订阅任务变更事件(Agent 通过 task_manager 写入后触发)
|
||||||
|
onTaskChanged: (callback: (sessionId: string) => void) => {
|
||||||
|
const listener = (_event: unknown, sessionId: string): void => callback(sessionId);
|
||||||
|
ipcRenderer.on('task:changed', listener);
|
||||||
|
return () => ipcRenderer.removeListener('task:changed', listener);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// ===== v0.2.0: 审计日志 =====
|
// ===== v0.2.0: 审计日志 =====
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "metona-ai-desktop",
|
"name": "metona-ai-desktop",
|
||||||
"version": "0.3.12",
|
"version": "0.3.13",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "metona-ai-desktop",
|
"name": "metona-ai-desktop",
|
||||||
"version": "0.3.12",
|
"version": "0.3.13",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "metona-ai-desktop",
|
"name": "metona-ai-desktop",
|
||||||
"version": "0.3.12",
|
"version": "0.3.13",
|
||||||
"description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用",
|
"description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用",
|
||||||
"main": "dist-electron/main/main.js",
|
"main": "dist-electron/main/main.js",
|
||||||
"author": "Metona Team",
|
"author": "Metona Team",
|
||||||
|
|||||||
@@ -95,6 +95,18 @@ export function TaskList(): React.JSX.Element {
|
|||||||
return () => { loadReqIdRef.current++; };
|
return () => { loadReqIdRef.current++; };
|
||||||
}, [sessionId, loadTasks]);
|
}, [sessionId, loadTasks]);
|
||||||
|
|
||||||
|
// P2(v0.3.13): 订阅任务变更事件(Agent 通过 task_manager 工具写入后自动刷新)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!window.metona?.tasks?.onTaskChanged) return;
|
||||||
|
const unsubscribe = window.metona.tasks.onTaskChanged((changedSessionId) => {
|
||||||
|
// 只刷新当前会话的任务
|
||||||
|
if (changedSessionId === sessionId) {
|
||||||
|
loadTasks(sessionId ?? undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return unsubscribe;
|
||||||
|
}, [sessionId, loadTasks]);
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
// 用户主动操作失败应用 toast(与项目惯例一致)
|
// 用户主动操作失败应用 toast(与项目惯例一致)
|
||||||
@@ -132,9 +144,10 @@ export function TaskList(): React.JSX.Element {
|
|||||||
|
|
||||||
const handleToggleComplete = async (task: MetonaTask) => {
|
const handleToggleComplete = async (task: MetonaTask) => {
|
||||||
if (!window.metona?.tasks?.update) return;
|
if (!window.metona?.tasks?.update) return;
|
||||||
|
if (!sessionId) return;
|
||||||
const nextStatus: TaskStatus = task.status === 'completed' ? 'pending' : 'completed';
|
const nextStatus: TaskStatus = task.status === 'completed' ? 'pending' : 'completed';
|
||||||
try {
|
try {
|
||||||
const res = await window.metona.tasks.update(task.id, { status: nextStatus });
|
const res = await window.metona.tasks.update(task.id, { status: nextStatus }, sessionId);
|
||||||
if (!res.success) {
|
if (!res.success) {
|
||||||
import('metona-toast').then((mod) => mod.default.error(res.error ?? '更新任务失败')).catch(() => {});
|
import('metona-toast').then((mod) => mod.default.error(res.error ?? '更新任务失败')).catch(() => {});
|
||||||
return;
|
return;
|
||||||
@@ -147,8 +160,9 @@ export function TaskList(): React.JSX.Element {
|
|||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
if (!window.metona?.tasks?.delete) return;
|
if (!window.metona?.tasks?.delete) return;
|
||||||
|
if (!sessionId) return;
|
||||||
try {
|
try {
|
||||||
const res = await window.metona.tasks.delete(id);
|
const res = await window.metona.tasks.delete(id, sessionId);
|
||||||
if (!res.success) {
|
if (!res.success) {
|
||||||
import('metona-toast').then((mod) => mod.default.error(res.error ?? '删除任务失败')).catch(() => {});
|
import('metona-toast').then((mod) => mod.default.error(res.error ?? '删除任务失败')).catch(() => {});
|
||||||
return;
|
return;
|
||||||
|
|||||||
Vendored
+4
-1
@@ -308,8 +308,11 @@ interface MetonaTasksAPI {
|
|||||||
priority?: string;
|
priority?: string;
|
||||||
assignedTo?: string;
|
assignedTo?: string;
|
||||||
},
|
},
|
||||||
|
sessionId: string,
|
||||||
) => Promise<{ success: boolean; error?: string }>;
|
) => Promise<{ success: boolean; error?: string }>;
|
||||||
delete: (id: string) => Promise<{ success: boolean; error?: string }>;
|
delete: (id: string, sessionId: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
|
// P2(v0.3.13): 订阅任务变更事件
|
||||||
|
onTaskChanged: (callback: (sessionId: string) => void) => () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== v0.2.0: Audit API =====
|
// ===== v0.2.0: Audit API =====
|
||||||
|
|||||||
Reference in New Issue
Block a user