v0.16.7: 引擎修复 + 工具面板统一 + AGENT.md 改为仅工作空间加载
核心引擎修复: - 状态转换表补全 THINKING/PARSING/EXECUTING -> COMPRESSING,修复紧急压缩成为死代码的 P1 问题 - 新增跨轮次死循环检测器(软性提示 + 硬性熔断),防止模型陷入重复工具调用死循环 - handleCompressing 空响应回到 THINKING 而非 REFLECTING,避免错误终止 - executeHooks 添加 .catch() 防止未处理的 Promise 拒绝 - ALWAYS_PARALLEL 移除 git 和 browser_evaluate(有副作用的工具不应并行) - thinking fallback:content 为空但有 thinking 时,用 [推理过程] 作为 content 保留上下文 - 8个写类工具添加专用格式化器(含 success + message 字段) - 清理死代码:3个未使用函数 + 3个未使用 import 工具面板统一: - 10个工具独立下拉框统一为1个全局执行模式选择器 - FIFO 队列防止并行 showToolConfirm 导致静默取消 - delete_file 支持 paths 数组参数批量删除 工具定义与实现一致性修复: - run_command 移除未使用的 timeout 参数,描述改为"超时可配置" - list_directory 添加 2000 条截断逻辑 + filter_extension 参数 - calculator 正则移除 ^ 字符(parser 用 ** 替代) - search_files/tree/web_search/fetch_top 描述与实现对齐 消息传递修复: - trimByTokenLimit 改为原子组选择(assistant+tool_calls 与后续 tool 消息作为一组) - 历史工具结果复用 formatToolResultForModel,与当前格式一致 AGENT.md 加载策略变更: - 删除内置 AGENT.md 文件 - 仅从工作空间加载:有则注入,无则跳过 其他修复: - 修复初始化失败 "Cannot convert undefined or null to object"(saveSetting null 导致 JSON.parse 陷阱) - 修复工作空间命令行标签页 idle 状态残留导致样式错乱 版本号: 0.16.5 -> 0.16.7
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
import { state, KEYS } from '../state/state.js';
|
||||
import { formatSize } from '../utils/utils.js';
|
||||
import { OllamaAPI } from '../api/ollama.js';
|
||||
import { logConnection, logError, logDebug } from '../services/log-service.js';
|
||||
import { logConnection, logError, logDebug, logInfo } from '../services/log-service.js';
|
||||
|
||||
let connStatusEl: HTMLElement;
|
||||
|
||||
@@ -54,7 +54,7 @@ export async function updateConnectionInfo(): Promise<void> {
|
||||
badge.className = 'status-badge success';
|
||||
verEl.textContent = `v${version.version}`;
|
||||
corsHint.style.display = 'none';
|
||||
logConnection('info', `Ollama v${version.version}`);
|
||||
logInfo(`Ollama 服务器版本: v${version.version}`);
|
||||
} catch (err) {
|
||||
badge.textContent = '✗ 未连接';
|
||||
badge.className = 'status-badge error';
|
||||
|
||||
@@ -15,9 +15,10 @@ import { showToast } from './toast.js';
|
||||
import { addToolCard, startToolCard, updateToolCard, clearToolCardsExternal, clearTerminalExternal, getWorkspaceDirPath, hasActiveCards, showWorkingHint, clearWorkingHint } from './workspace-panel.js';
|
||||
import { ChatDB } from '../db/chat-db.js';
|
||||
import { OllamaAPI } from '../api/ollama.js';
|
||||
import { runAgentLoop } from '../services/agent-engine.js';
|
||||
import { runAgentLoop, formatToolResultForModel } from '../services/agent-engine.js';
|
||||
import { estimateTokens } from '../services/context-manager.js';
|
||||
import { showToolConfirm } from './tool-confirm-modal.js';
|
||||
import { showConfirm } from './prompt-modal.js';
|
||||
import { logInfo, logStream, logError, logSuccess, logWarn, resetVideoProgress, updateVideoProgress } from '../services/log-service.js';
|
||||
import type { ChatSession, ChatMessage, OllamaStreamChunk, OllamaMessage, FileContent, ChatFile, ToolCallRecord, AgentMode } from '../types.js';
|
||||
|
||||
@@ -95,9 +96,9 @@ export function initInputArea(): void {
|
||||
// Ctrl+K: 清空聊天(需要确认)
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
if (confirm('确定要清空当前对话吗?')) {
|
||||
clearMessages();
|
||||
}
|
||||
showConfirm('确定要清空当前对话吗?', '清空对话').then(ok => {
|
||||
if (ok) clearMessages();
|
||||
});
|
||||
}
|
||||
// Escape: 停止生成(当正在流式输出时)
|
||||
if (e.key === 'Escape' && state.get<boolean>(KEYS.IS_STREAMING)) {
|
||||
@@ -1023,17 +1024,21 @@ function buildHistoryMessages(msgs: ChatMessage[], maxCount = 20): OllamaMessage
|
||||
result.push(assistantMsg);
|
||||
|
||||
// ── 注入 tool 结果消息(role: 'tool')──
|
||||
// 复用 formatToolResultForModel 保持与当前 Loop 格式一致
|
||||
if (msg.toolCalls?.length) {
|
||||
for (const tc of msg.toolCalls) {
|
||||
if (!tc.result) continue;
|
||||
if (result.length >= maxCount) break;
|
||||
const resultContent = tc.status === 'success'
|
||||
? JSON.stringify(tc.result)
|
||||
: JSON.stringify({ success: false, error: tc.result.error || '工具执行失败' });
|
||||
const formattedResult = formatToolResultForModel(tc.name, tc.result);
|
||||
// R92: 工具结果格式标准化 — 添加统一头信息 + 数据边界标记
|
||||
const resultDuration = Date.now() - tc.timestamp;
|
||||
const resultSize = formattedResult.length;
|
||||
const sizeCategory = resultSize > 10000 ? 'large' : resultSize > 2000 ? 'medium' : 'small';
|
||||
const r92Header = `[工具:${tc.name} 状态:${tc.status} 耗时:${resultDuration}ms 大小:${sizeCategory}(${resultSize}字符)]`;
|
||||
result.push({
|
||||
role: 'tool',
|
||||
tool_name: tc.name,
|
||||
content: resultContent,
|
||||
content: `<<<TOOL_RESULT_START name="${tc.name}">>>\n${r92Header}\n${formattedResult}\n<<<TOOL_RESULT_END>>>`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,6 +285,51 @@ export function initSettingsModal(): void {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── 子代理相关设置(原顶层事件监听器移入此处,确保 DOM 已就绪)──
|
||||
// 子代理模型设置保存
|
||||
document.querySelector('#selectSubAgentModel')?.addEventListener('change', async () => {
|
||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||
if (!db) return;
|
||||
const val = (document.querySelector('#selectSubAgentModel') as HTMLSelectElement).value;
|
||||
await db.saveSetting('subAgentModel', val);
|
||||
logSetting('子代理默认模型', val || '跟随当前模型');
|
||||
});
|
||||
|
||||
// 子代理最大轮次
|
||||
const saveSubAgentMaxLoops = debounce(async () => {
|
||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||
const val = (document.querySelector('#inputSubAgentMaxLoops') as HTMLInputElement).value.trim();
|
||||
const maxLoops = val ? parseInt(val) : 10;
|
||||
state.set('subAgentMaxLoops', maxLoops);
|
||||
if (db) await db.saveSetting('subAgentMaxLoops', maxLoops);
|
||||
logSetting('子代理最大轮次', `${maxLoops} 轮`);
|
||||
}, 500);
|
||||
document.querySelector('#inputSubAgentMaxLoops')!.addEventListener('input', saveSubAgentMaxLoops);
|
||||
|
||||
// 子代理超时(秒)
|
||||
const saveSubAgentTimeout = debounce(async () => {
|
||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||
const val = (document.querySelector('#inputSubAgentTimeout') as HTMLInputElement).value.trim();
|
||||
const sec = val ? parseInt(val) : -1; // -1=默认
|
||||
const ms = sec >= 0 ? sec * 1000 : 300_000;
|
||||
state.set('subAgentTimeout', ms);
|
||||
if (db) await db.saveSetting('subAgentTimeout', sec);
|
||||
logSetting('子代理超时', sec >= 0 ? (sec === 0 ? '已禁用' : `${sec}s`) : '默认(300s)');
|
||||
}, 500);
|
||||
document.querySelector('#inputSubAgentTimeout')!.addEventListener('input', saveSubAgentTimeout);
|
||||
|
||||
// ── 看门狗超时(分钟,0=禁用)──
|
||||
const saveLoopWatchdog = debounce(async () => {
|
||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||
const val = (document.querySelector('#inputLoopWatchdog') as HTMLInputElement).value.trim();
|
||||
const minutes = val ? parseInt(val) : -1;
|
||||
const ms = minutes > 0 ? minutes * 60_000 : (minutes === 0 ? 0 : 1_800_000); // 默认30分钟
|
||||
state.set('loopWatchdogMs', ms);
|
||||
if (db) await db.saveSetting('loopWatchdogMs', ms);
|
||||
logSetting('看门狗超时', minutes >= 0 ? (minutes === 0 ? '已禁用' : `${minutes}分钟`) : '默认(30分钟)');
|
||||
}, 500);
|
||||
document.querySelector('#inputLoopWatchdog')!.addEventListener('input', saveLoopWatchdog);
|
||||
}
|
||||
|
||||
export function openSettingsModal(): void {
|
||||
@@ -429,50 +474,6 @@ async function populateSubAgentModels(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// 子代理模型设置保存
|
||||
document.querySelector('#selectSubAgentModel')?.addEventListener('change', async () => {
|
||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||
if (!db) return;
|
||||
const val = (document.querySelector('#selectSubAgentModel') as HTMLSelectElement).value;
|
||||
await db.saveSetting('subAgentModel', val);
|
||||
logSetting('子代理默认模型', val || '跟随当前模型');
|
||||
});
|
||||
|
||||
// 子代理最大轮次
|
||||
const saveSubAgentMaxLoops = debounce(async () => {
|
||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||
const val = (document.querySelector('#inputSubAgentMaxLoops') as HTMLInputElement).value.trim();
|
||||
const maxLoops = val ? parseInt(val) : 10;
|
||||
state.set('subAgentMaxLoops', maxLoops);
|
||||
if (db) await db.saveSetting('subAgentMaxLoops', maxLoops);
|
||||
logSetting('子代理最大轮次', `${maxLoops} 轮`);
|
||||
}, 500);
|
||||
document.querySelector('#inputSubAgentMaxLoops')!.addEventListener('input', saveSubAgentMaxLoops);
|
||||
|
||||
// 子代理超时(秒)
|
||||
const saveSubAgentTimeout = debounce(async () => {
|
||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||
const val = (document.querySelector('#inputSubAgentTimeout') as HTMLInputElement).value.trim();
|
||||
const sec = val ? parseInt(val) : -1; // -1=默认
|
||||
const ms = sec >= 0 ? sec * 1000 : 300_000;
|
||||
state.set('subAgentTimeout', ms);
|
||||
if (db) await db.saveSetting('subAgentTimeout', sec);
|
||||
logSetting('子代理超时', sec >= 0 ? (sec === 0 ? '已禁用' : `${sec}s`) : '默认(300s)');
|
||||
}, 500);
|
||||
document.querySelector('#inputSubAgentTimeout')!.addEventListener('input', saveSubAgentTimeout);
|
||||
|
||||
// ── 看门狗超时(分钟,0=禁用)──
|
||||
const saveLoopWatchdog = debounce(async () => {
|
||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||
const val = (document.querySelector('#inputLoopWatchdog') as HTMLInputElement).value.trim();
|
||||
const minutes = val ? parseInt(val) : -1;
|
||||
const ms = minutes > 0 ? minutes * 60_000 : (minutes === 0 ? 0 : 1_800_000); // 默认30分钟
|
||||
state.set('loopWatchdogMs', ms);
|
||||
if (db) await db.saveSetting('loopWatchdogMs', ms);
|
||||
logSetting('看门狗超时', minutes >= 0 ? (minutes === 0 ? '已禁用' : `${minutes}分钟`) : '默认(30分钟)');
|
||||
}, 500);
|
||||
document.querySelector('#inputLoopWatchdog')!.addEventListener('input', saveLoopWatchdog);
|
||||
|
||||
async function importSessions(filePath: string): Promise<void> {
|
||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||
if (!db) return;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { state, KEYS } from '../state/state.js';
|
||||
import { formatTime } from '../utils/utils.js';
|
||||
import { formatTime, escapeHtml } from '../utils/utils.js';
|
||||
import { logError } from '../services/log-service.js';
|
||||
import { getEffectiveNumCtx, getModelContextLength } from './model-bar.js';
|
||||
import { estimateTokens } from '../services/context-manager.js';
|
||||
@@ -39,17 +39,6 @@ interface AllSessionsTokenStats {
|
||||
totals: GlobalTokenTotals;
|
||||
}
|
||||
|
||||
/** P1 #5: HTML 转义,防止 XSS */
|
||||
function escapeHtml(s: unknown): string {
|
||||
if (s == null) return '';
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/** 打开仪表盘 */
|
||||
export function openTokenDashboard(): void {
|
||||
if (!dashboardModalEl) return;
|
||||
@@ -198,7 +187,7 @@ async function fetchGlobalStats(): Promise<AllSessionsTokenStats | null> {
|
||||
return result;
|
||||
}
|
||||
} catch (err) {
|
||||
logError('Token Dashboard: 获取全局统计失败', String(err));
|
||||
logError('Token Dashboard: 获取全局统计失败', (err as Error).message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
/**
|
||||
* ToolConfirmModal - 工具调用确认对话框
|
||||
*
|
||||
* 队列模式:当多个工具并行执行需要确认时,按 FIFO 队列逐个确认,
|
||||
* 避免单例竞态导致前一个确认被自动取消。
|
||||
*/
|
||||
|
||||
import type { ToolCall } from '../types.js';
|
||||
@@ -8,9 +11,18 @@ import { logDebug, logInfo } from '../services/log-service.js';
|
||||
import { escapeHtml } from '../utils/utils.js';
|
||||
|
||||
let modalEl: HTMLElement | null = null;
|
||||
let resolveConfirm: ((confirmed: boolean) => void) | null = null;
|
||||
let confirmBtn: HTMLButtonElement | null = null;
|
||||
let cancelBtn: HTMLButtonElement | null = null;
|
||||
let initialized = false;
|
||||
|
||||
/** 确认请求队列 */
|
||||
interface ConfirmRequest {
|
||||
call: ToolCall;
|
||||
resolve: (confirmed: boolean) => void;
|
||||
}
|
||||
const confirmQueue: ConfirmRequest[] = [];
|
||||
let isShowing = false;
|
||||
|
||||
export function initToolConfirmModal(): void {
|
||||
modalEl = document.querySelector('#toolConfirmModal')!;
|
||||
if (!modalEl || initialized) return;
|
||||
@@ -18,7 +30,7 @@ export function initToolConfirmModal(): void {
|
||||
|
||||
modalEl.addEventListener('click', (e) => {
|
||||
if (e.target === modalEl) {
|
||||
cancelConfirm();
|
||||
cancelCurrent();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -31,91 +43,105 @@ export function showToolConfirm(call: ToolCall): Promise<boolean> {
|
||||
if (!initialized) {
|
||||
initialized = true;
|
||||
modalEl.addEventListener('click', (e) => {
|
||||
if (e.target === modalEl) cancelConfirm();
|
||||
if (e.target === modalEl) cancelCurrent();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有未完成的确认,先取消
|
||||
if (resolveConfirm) {
|
||||
const oldResolve = resolveConfirm;
|
||||
resolveConfirm = null;
|
||||
oldResolve(false);
|
||||
// 加入队列,如果当前没有显示的确认框,则立即显示
|
||||
confirmQueue.push({ call, resolve });
|
||||
if (!isShowing) {
|
||||
showNext();
|
||||
}
|
||||
|
||||
resolveConfirm = resolve;
|
||||
|
||||
const icon = getToolIcon(call.function.name);
|
||||
const name = formatToolName(call.function.name);
|
||||
logInfo(`等待确认: ${call.function.name}`, JSON.stringify(call.function.arguments));
|
||||
|
||||
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 = (e: Event) => {
|
||||
e.stopPropagation();
|
||||
const r = resolveConfirm;
|
||||
cleanup();
|
||||
logInfo(`已确认: ${call.function.name}`);
|
||||
r?.(true);
|
||||
};
|
||||
const onCancel = (e: Event) => {
|
||||
e.stopPropagation();
|
||||
const r = resolveConfirm;
|
||||
cleanup();
|
||||
logInfo(`已取消: ${call.function.name}`);
|
||||
r?.(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 showNext(): void {
|
||||
if (confirmQueue.length === 0) {
|
||||
isShowing = false;
|
||||
return;
|
||||
}
|
||||
isShowing = true;
|
||||
|
||||
const { call, resolve } = confirmQueue.shift()!;
|
||||
const icon = getToolIcon(call.function.name);
|
||||
const name = formatToolName(call.function.name);
|
||||
logInfo(`等待确认: ${call.function.name}`, JSON.stringify(call.function.arguments));
|
||||
|
||||
const titleEl = modalEl!.querySelector('#toolConfirmTitle')!;
|
||||
const bodyEl = modalEl!.querySelector('#toolConfirmBody')!;
|
||||
confirmBtn = modalEl!.querySelector('#toolConfirmBtn') as HTMLButtonElement;
|
||||
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') {
|
||||
// 支持 paths 数组(批量删除)和单个 path
|
||||
if (args.paths && Array.isArray(args.paths) && args.paths.length > 0) {
|
||||
bodyHtml += `<div class="tool-confirm-row"><span class="tool-confirm-label">路径(${args.paths.length} 项):</span></div>`;
|
||||
bodyHtml += `<pre class="tool-confirm-preview">${escapeHtml(args.paths.map((p: string) => String(p)).join('\n'))}</pre>`;
|
||||
} else 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;
|
||||
|
||||
// 队列计数提示
|
||||
if (confirmQueue.length > 0) {
|
||||
bodyEl.querySelector('.tool-confirm-info')!.insertAdjacentHTML('beforeend',
|
||||
`<div class="tool-confirm-row" style="margin-top:8px;color:var(--text-muted);font-size:12px;">队列中还有 ${confirmQueue.length} 个待确认</div>`);
|
||||
}
|
||||
|
||||
modalEl!.style.display = '';
|
||||
|
||||
const onConfirm = (e: Event) => {
|
||||
e.stopPropagation();
|
||||
cleanup();
|
||||
logInfo(`已确认: ${call.function.name}`);
|
||||
resolve(true);
|
||||
showNext();
|
||||
};
|
||||
const onCancel = (e: Event) => {
|
||||
e.stopPropagation();
|
||||
cleanup();
|
||||
logInfo(`已取消: ${call.function.name}`);
|
||||
resolve(false);
|
||||
showNext();
|
||||
};
|
||||
const cleanup = () => {
|
||||
confirmBtn?.removeEventListener('click', onConfirm);
|
||||
cancelBtn?.removeEventListener('click', onCancel);
|
||||
if (modalEl) modalEl.style.display = 'none';
|
||||
};
|
||||
|
||||
confirmBtn.addEventListener('click', onConfirm);
|
||||
cancelBtn.addEventListener('click', onCancel);
|
||||
}
|
||||
|
||||
|
||||
/** 取消当前显示的确认(不影响队列中的后续确认) */
|
||||
function cancelCurrent(): void {
|
||||
if (!isShowing) return;
|
||||
// 当前显示的确认已从队列 shift 出来,直接触发取消按钮
|
||||
if (cancelBtn) {
|
||||
cancelBtn.click();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +1,43 @@
|
||||
/**
|
||||
* ToolsModal - 工具面板模态框
|
||||
* 展示所有可用工具的详细信息。Tool Calling 始终开启,不可关闭。
|
||||
* 支持三档开关(auto/confirm/disabled)的工具统一管理。
|
||||
* 全局执行模式统一管理所有工具的确认行为。
|
||||
*/
|
||||
|
||||
import { state, KEYS } from '../state/state.js';
|
||||
import { setToolMode, getToolMode, type ToolMode } from '../services/tool-registry.js';
|
||||
import { setToolMode, type ToolMode } from '../services/tool-registry.js';
|
||||
import { showToast } from './toast.js';
|
||||
import { logInfo } from '../services/log-service.js';
|
||||
import type { ChatDB } from '../db/chat-db.js';
|
||||
|
||||
let modalEl: HTMLElement;
|
||||
|
||||
const MODE_NAMES: Record<ToolMode, string> = {
|
||||
const MODE_NAMES: Record<string, string> = {
|
||||
auto: '自动执行',
|
||||
confirm: '需确认',
|
||||
disabled: '已禁用',
|
||||
};
|
||||
|
||||
const BADGE_MAP: Record<ToolMode, { text: string; cls: string }> = {
|
||||
auto: { text: '自动', cls: 'auto' },
|
||||
confirm: { text: '需确认', cls: 'confirm' },
|
||||
disabled: { text: '禁用', cls: 'disabled' },
|
||||
};
|
||||
// 需要全局模式管理的工具列表(与 tool-registry.ts MODE_TOOLS 一致)
|
||||
const MANAGED_TOOLS = [
|
||||
'run_command',
|
||||
'write_file', 'create_directory', 'delete_file',
|
||||
'edit_file', 'replace_in_files', 'move_file', 'copy_file',
|
||||
'download_file', 'compress',
|
||||
];
|
||||
|
||||
function updateToolBadge(toolName: string, mode: ToolMode): void {
|
||||
const badge = modalEl.querySelector(`#badge_${toolName}`) as HTMLElement;
|
||||
if (!badge) return;
|
||||
const info = BADGE_MAP[mode];
|
||||
badge.textContent = info.text;
|
||||
badge.className = `tool-card-badge ${info.cls}`;
|
||||
function updateAllBadges(mode: string): void {
|
||||
const badgeText = mode === 'auto' ? '自动' : '需确认';
|
||||
const badgeCls = mode === 'auto' ? 'auto' : 'confirm';
|
||||
// 更新所有被管理工具的 badge
|
||||
const badges = modalEl.querySelectorAll<HTMLElement>('.tool-card-badge');
|
||||
badges.forEach(badge => {
|
||||
// 只更新被管理工具的 badge(通过 tool-card-name 文本匹配)
|
||||
const nameEl = badge.closest('.tool-card')?.querySelector('.tool-card-name');
|
||||
if (nameEl && MANAGED_TOOLS.includes(nameEl.textContent || '')) {
|
||||
badge.textContent = badgeText;
|
||||
badge.className = `tool-card-badge ${badgeCls}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updatePlanBadge(autoConfirm: boolean): void {
|
||||
@@ -47,36 +55,33 @@ function updatePlanBadge(autoConfirm: boolean): void {
|
||||
export function initToolsModal(): void {
|
||||
modalEl = document.querySelector('#toolsModal')!;
|
||||
|
||||
// Header 按钮
|
||||
document.querySelector('#btnTools')!.addEventListener('click', openToolsModal);
|
||||
document.querySelector('#btnCloseTools')!.addEventListener('click', closeToolsModal);
|
||||
modalEl.addEventListener('click', (e) => {
|
||||
if (e.target === modalEl) closeToolsModal();
|
||||
});
|
||||
|
||||
// 所有带 .tool-mode-select 的下拉框统一绑定 change 事件
|
||||
const selects = modalEl.querySelectorAll<HTMLSelectElement>('.tool-mode-select');
|
||||
selects.forEach(selectEl => {
|
||||
selectEl.addEventListener('change', async () => {
|
||||
const toolName = selectEl.dataset.tool;
|
||||
if (!toolName) return;
|
||||
const mode = selectEl.value as ToolMode;
|
||||
// 全局工具执行模式
|
||||
const globalSelect = modalEl.querySelector<HTMLSelectElement>('#select_global_tool_mode');
|
||||
if (globalSelect) {
|
||||
globalSelect.addEventListener('change', async () => {
|
||||
const mode = globalSelect.value as ToolMode;
|
||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||
|
||||
// 更新运行时模式
|
||||
setToolMode(toolName, mode);
|
||||
updateToolBadge(toolName, mode);
|
||||
// 更新所有被管理工具的运行时模式
|
||||
for (const toolName of MANAGED_TOOLS) {
|
||||
setToolMode(toolName, mode);
|
||||
}
|
||||
|
||||
// 持久化到数据库(所有工具模式合并存储为 JSON)
|
||||
const allModes = state.get<Record<string, ToolMode>>('toolModes', {});
|
||||
allModes[toolName] = mode;
|
||||
state.set('toolModes', allModes);
|
||||
if (db) await db.saveSetting('toolModes', allModes);
|
||||
// 持久化全局模式
|
||||
state.set('globalToolMode', mode);
|
||||
if (db) await db.saveSetting('globalToolMode', mode);
|
||||
|
||||
showToast(`${toolName}:${MODE_NAMES[mode]}`, 'info');
|
||||
logInfo(`工具模式切换: ${toolName} → ${mode}`);
|
||||
updateAllBadges(mode);
|
||||
showToast(`工具执行模式:${MODE_NAMES[mode]}`, 'info');
|
||||
logInfo(`全局工具模式切换: → ${mode}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Plan 确认模式下拉框
|
||||
const planSelect = modalEl.querySelector<HTMLSelectElement>('#select_plan_confirm');
|
||||
@@ -96,18 +101,15 @@ export function initToolsModal(): void {
|
||||
}
|
||||
|
||||
export function openToolsModal(): void {
|
||||
// 从 state 读取所有工具模式,同步 UI
|
||||
const allModes = state.get<Record<string, ToolMode>>('toolModes', {});
|
||||
const selects = modalEl.querySelectorAll<HTMLSelectElement>('.tool-mode-select');
|
||||
selects.forEach(selectEl => {
|
||||
const toolName = selectEl.dataset.tool;
|
||||
if (!toolName) return;
|
||||
const mode = allModes[toolName] || getToolMode(toolName);
|
||||
selectEl.value = mode;
|
||||
updateToolBadge(toolName, mode);
|
||||
});
|
||||
// 同步全局工具执行模式
|
||||
const globalMode = state.get<string>('globalToolMode', 'confirm');
|
||||
const globalSelect = modalEl.querySelector<HTMLSelectElement>('#select_global_tool_mode');
|
||||
if (globalSelect) {
|
||||
globalSelect.value = globalMode;
|
||||
updateAllBadges(globalMode);
|
||||
}
|
||||
|
||||
// 同步 Plan 确认模式下拉框
|
||||
// 同步 Plan 确认模式
|
||||
const planSelect = modalEl.querySelector<HTMLSelectElement>('#select_plan_confirm');
|
||||
if (planSelect) {
|
||||
const autoConfirm = state.get<boolean>('planAutoConfirm', false);
|
||||
|
||||
@@ -555,6 +555,12 @@ function renderTerminal(): void {
|
||||
_termRenderedCount = 0;
|
||||
}
|
||||
|
||||
// 从 idle 状态切换到有内容时,清空 idle DOM
|
||||
// idle 状态用 position:absolute 覆盖在内容上方,不清空会同时显示
|
||||
if (_termRenderedCount === 0 && container.querySelector('.ws-idle-state')) {
|
||||
container.innerHTML = '';
|
||||
}
|
||||
|
||||
// 增量追加新行
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (let i = _termRenderedCount; i < session.lines.length; i++) {
|
||||
@@ -1068,7 +1074,16 @@ function renderToolCard(tc: ToolCallRecord): string {
|
||||
} else if (tc.name === 'write_file') {
|
||||
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>`;
|
||||
if (r.batch) {
|
||||
resultHtml = `<div class="tool-result-entry">✅ 批量删除 ${r.successCount}/${r.totalPaths} 个路径</div>`;
|
||||
if (r.results) {
|
||||
for (const res of r.results) {
|
||||
resultHtml += `<div class="tool-result-entry">${res.success ? '✅' : '❌'} ${escapeHtml(String(res.path || ''))}${res.success ? '' : ' — ' + escapeHtml(String(res.error || ''))}</div>`;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
resultHtml = `<div class="tool-result-entry">✅ 已删除 ${escapeHtml(String(r.path || ''))}</div>`;
|
||||
}
|
||||
} else if (tc.name === 'create_directory') {
|
||||
resultHtml = `<div class="tool-result-entry">✅ 已创建 ${escapeHtml(String(r.path || ''))}</div>`;
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user