v0.16.19: 修复日志面板清空、URL误传文件工具、网络工具超时、结果截断
This commit is contained in:
@@ -212,19 +212,19 @@ const TOOL_TIMEOUT_MAP: Record<string, { base: number; grade: 'fast' | 'medium'
|
||||
move_file: { base: 15_000, grade: 'medium', description: '移动文件' },
|
||||
tree: { base: 15_000, grade: 'medium', description: '目录树' },
|
||||
session_read: { base: 10_000, grade: 'medium', description: '会话读取' },
|
||||
web_search: { base: 30_000, grade: 'medium', description: '网页搜索' },
|
||||
web_search: { base: 900_000, grade: 'slow', description: '网页搜索' },
|
||||
git: { base: 30_000, grade: 'medium', description: 'Git 操作' },
|
||||
|
||||
// slow (15-60秒)
|
||||
read_file: { base: 30_000, grade: 'slow', description: '读取文件' },
|
||||
copy_file: { base: 30_000, grade: 'slow', description: '复制文件' },
|
||||
read_file: { base: 60_000, grade: 'slow', description: '读取文件' },
|
||||
copy_file: { base: 60_000, grade: 'slow', description: '复制文件' },
|
||||
search_files: { base: 60_000, grade: 'slow', description: '搜索文件' },
|
||||
read_multiple_files: { base: 60_000, grade: 'slow', description: '批量读取' },
|
||||
web_fetch: { base: 60_000, grade: 'slow', description: '网页抓取' },
|
||||
web_fetch: { base: 900_000, grade: 'long', description: '网页抓取' },
|
||||
compress: { base: 60_000, grade: 'slow', description: '压缩' },
|
||||
|
||||
// long (>60秒)
|
||||
download_file: { base: 120_000, grade: 'long', description: '下载文件' },
|
||||
download_file: { base: 900_000, grade: 'long', description: '下载文件' },
|
||||
default: { base: 60_000, grade: 'medium', description: '默认工具' },
|
||||
};
|
||||
|
||||
@@ -233,6 +233,18 @@ function getAdjustedToolTimeout(toolName: string, args: Record<string, unknown>)
|
||||
const config = TOOL_TIMEOUT_MAP[toolName] ?? TOOL_TIMEOUT_MAP.default;
|
||||
let timeout = config.base;
|
||||
|
||||
// 网络工具超时跟随用户配置的 HTTP 超时(取 base 和用户配置中的较大值)
|
||||
const NETWORK_TOOLS = ['web_search', 'web_fetch', 'download_file'];
|
||||
if (NETWORK_TOOLS.includes(toolName)) {
|
||||
const userHttpTimeout = state.get<number>('httpTimeout', 900_000);
|
||||
// userHttpTimeout=0 表示禁用超时,否则取 max(base, userConfig)
|
||||
if (userHttpTimeout === 0) {
|
||||
timeout = 0; // 不超时
|
||||
} else {
|
||||
timeout = Math.max(timeout, userHttpTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
// run_command 特殊处理:根据命令类型调整
|
||||
if (toolName === 'run_command' && args.command) {
|
||||
const cmd = String(args.command);
|
||||
@@ -251,7 +263,7 @@ function getAdjustedToolTimeout(toolName: string, args: Record<string, unknown>)
|
||||
if (toolName === 'web_fetch' && args.url) {
|
||||
const url = String(args.url);
|
||||
if (url.includes('youtube') || url.includes('video') || url.includes('mp4')) {
|
||||
timeout = 90_000;
|
||||
timeout = Math.max(timeout, 90_000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1024,6 +1036,8 @@ export interface AgentCallbacks {
|
||||
onDone: (finalContent: string, toolRecords?: ToolCallRecord[], stats?: { eval_count?: number; prompt_eval_count?: number; total_duration?: number; ctx_tokens?: number }) => void;
|
||||
onConfirmTool: (call: ToolCall) => Promise<boolean>;
|
||||
onNewIteration?: (toolCalls?: ToolCall[], stats?: { eval_count?: number; prompt_eval_count?: number; total_duration?: number }) => void;
|
||||
/** 每轮 THINKING 开始前调用,UI 确保 loading 占位符存在 */
|
||||
onThinkingStart?: () => void;
|
||||
/** Plan Mode: 计划已生成,等待用户确认 */
|
||||
onPlanReady?: (plan: string, steps: string[]) => Promise<boolean>;
|
||||
}
|
||||
@@ -1598,6 +1612,11 @@ async function handleThinking(
|
||||
|
||||
resetStreamProgress();
|
||||
|
||||
// 确保 loading 占位符存在 — 中间轮次中 onNewIteration 可能因各种原因未能正确创建 loading
|
||||
if (callbacks.onThinkingStart) {
|
||||
callbacks.onThinkingStart();
|
||||
}
|
||||
|
||||
if (progressTimer) clearInterval(progressTimer as unknown as number);
|
||||
progressTimer = setInterval(() => {
|
||||
const elapsed = Date.now() - streamStartTime;
|
||||
|
||||
@@ -19,9 +19,7 @@ export interface LogEntry {
|
||||
|
||||
let logBodyEl: HTMLElement | null = null;
|
||||
let logPanelEl: HTMLElement | null = null;
|
||||
const MAX_LOGS = 500;
|
||||
// R46: 日志轮转 — 旧日志自动清理
|
||||
const LOG_MAX_AGE_MS = 3600000; // 1 小时
|
||||
const MAX_LOGS = 2000;
|
||||
let logs: LogEntry[] = [];
|
||||
let autoScroll = true;
|
||||
let idCounter = 0;
|
||||
@@ -31,26 +29,6 @@ let panelInitialized = false;
|
||||
/** initLogPanel 前的日志暂存区 */
|
||||
const earlyBuffer: LogEntry[] = [];
|
||||
|
||||
// R46: 定期清理旧日志
|
||||
let _logCleanupTimer: ReturnType<typeof setInterval> | null = null;
|
||||
function startLogRotation(): void {
|
||||
if (_logCleanupTimer) clearInterval(_logCleanupTimer);
|
||||
_logCleanupTimer = setInterval(() => {
|
||||
const now = Date.now();
|
||||
const before = logs.length;
|
||||
const oldLogs = logs.filter(l => now - l.time >= LOG_MAX_AGE_MS);
|
||||
logs = logs.filter(l => now - l.time < LOG_MAX_AGE_MS);
|
||||
const removed = before - logs.length;
|
||||
if (removed > 0 && logBodyEl) {
|
||||
// 从 DOM 中移除旧日志条目
|
||||
for (const old of oldLogs) {
|
||||
const el = logBodyEl.querySelector(`#${old.id}`);
|
||||
if (el) el.remove();
|
||||
}
|
||||
}
|
||||
}, 60000); // 每分钟检查一次
|
||||
}
|
||||
|
||||
const LEVEL_ICONS: Record<LogLevel, string> = {
|
||||
info: 'ℹ️',
|
||||
success: '✅',
|
||||
@@ -78,8 +56,6 @@ export function initLogPanel(): void {
|
||||
document.querySelector('#btnExportLog')?.addEventListener('click', exportLog);
|
||||
|
||||
panelInitialized = true;
|
||||
// R46: 启动日志轮转
|
||||
startLogRotation();
|
||||
if (earlyBuffer.length > 0 && logBodyEl) {
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const entry of earlyBuffer) {
|
||||
@@ -200,42 +176,51 @@ export function logThink(thinking: string): void {
|
||||
|
||||
export function logStream(msg: string): void { addLog('stream', msg); }
|
||||
|
||||
/** 本轮流式开始:同步创建新进度条目到日志底部(不删旧,日志就是完整记录) */
|
||||
/** 当前流式进度条目 id(用于原地更新) */
|
||||
let _streamProgressId: string | null = null;
|
||||
|
||||
/** 本轮流式开始:通过 addLog 创建进度条目(纳入 logs 数组统一管理) */
|
||||
export function resetStreamProgress(): void {
|
||||
if (!logBodyEl) return;
|
||||
const entry = document.createElement('div');
|
||||
entry.className = 'log-entry log-stream log-stream-progress';
|
||||
entry.innerHTML = `<span class="log-time">${formatTime(Date.now())}</span><span class="log-icon">📡</span><span class="log-msg">⏳ 等待模型响应… 0s</span>`;
|
||||
logBodyEl.appendChild(entry);
|
||||
_streamProgressId = genId();
|
||||
addLog('stream', '⏳ 等待模型响应… 0s', undefined, '📡', _streamProgressId);
|
||||
}
|
||||
|
||||
/** 更新**最新**一条流式进度日志(原地更新消息,不改变 DOM 位置和时间戳) */
|
||||
/** 更新当前流式进度日志(原地更新 DOM,同步 logs 数组) */
|
||||
export function logStreamProgress(msg: string): void {
|
||||
const all = logBodyEl?.querySelectorAll?.('.log-stream-progress');
|
||||
if (all && all.length > 0) {
|
||||
const latest = all[all.length - 1] as HTMLElement;
|
||||
const msgSpan = latest.querySelector('.log-msg');
|
||||
if (!_streamProgressId) return;
|
||||
// 更新 DOM
|
||||
const el = logBodyEl?.querySelector(`#${_streamProgressId}`);
|
||||
if (el) {
|
||||
const msgSpan = el.querySelector('.log-msg');
|
||||
if (msgSpan) msgSpan.textContent = msg;
|
||||
}
|
||||
// 同步 logs 数组(确保导出时进度也是最新的)
|
||||
const entry = logs.find(l => l.id === _streamProgressId);
|
||||
if (entry) entry.message = msg;
|
||||
}
|
||||
|
||||
/** 视频提取进度:创建初始条目 */
|
||||
/** 当前视频提取进度条目 id */
|
||||
let _videoProgressId: string | null = null;
|
||||
|
||||
/** 视频提取进度:通过 addLog 创建条目(纳入 logs 数组统一管理) */
|
||||
export function resetVideoProgress(fileName: string): void {
|
||||
if (!logBodyEl) return;
|
||||
const entry = document.createElement('div');
|
||||
entry.className = 'log-entry log-info log-video-progress';
|
||||
entry.innerHTML = `<span class="log-time">${formatTime(Date.now())}</span><span class="log-icon">🎬</span><span class="log-msg">开始提取视频帧: ${escapeHtml(fileName)}</span><pre class="log-detail">0 帧</pre>`;
|
||||
logBodyEl.appendChild(entry);
|
||||
_videoProgressId = genId();
|
||||
addLog('info', `开始提取视频帧: ${fileName}`, '0 帧', '🎬', _videoProgressId);
|
||||
}
|
||||
|
||||
/** 更新视频提取进度(原地更新,不新增条目) */
|
||||
/** 更新视频提取进度(原地更新 DOM 和 logs 数组) */
|
||||
export function updateVideoProgress(current: number): void {
|
||||
const all = logBodyEl?.querySelectorAll?.('.log-video-progress');
|
||||
if (all && all.length > 0) {
|
||||
const latest = all[all.length - 1] as HTMLElement;
|
||||
const detail = latest.querySelector('.log-detail');
|
||||
if (detail) detail.textContent = `${current} 帧`;
|
||||
if (!_videoProgressId) return;
|
||||
const text = `${current} 帧`;
|
||||
// 更新 DOM
|
||||
const el = logBodyEl?.querySelector(`#${_videoProgressId}`);
|
||||
if (el) {
|
||||
const detail = el.querySelector('.log-detail');
|
||||
if (detail) detail.textContent = text;
|
||||
}
|
||||
// 同步 logs 数组
|
||||
const entry = logs.find(l => l.id === _videoProgressId);
|
||||
if (entry) entry.detail = text;
|
||||
}
|
||||
|
||||
export function logAgentLoop(iteration: number, maxLoops: number): void { addLog('info', `Loop #${iteration}/${maxLoops}`); }
|
||||
|
||||
@@ -13,12 +13,12 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'read_file',
|
||||
description: 'Read a file from the local filesystem. Supports text mode (utf-8/latin1, line-based pagination) and binary mode (base64, byte-based pagination). Files up to 5MB (text) or 50MB (binary). When truncated, returns remaining_lines/remaining_bytes and a hint to continue reading. Use start_line/end_line for text files, offset_bytes/limit_bytes for binary files or raw byte access.',
|
||||
description: 'Read a LOCAL file from the filesystem. Does NOT support URLs — use web_fetch for web pages. Supports text mode (utf-8/latin1, line-based pagination) and binary mode (base64, byte-based pagination). Files up to 5MB (text) or 50MB (binary). When truncated, returns remaining_lines/remaining_bytes and a hint to continue reading. Use start_line/end_line for text files, offset_bytes/limit_bytes for binary files or raw byte access.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
required: ['path'],
|
||||
properties: {
|
||||
path: { type: 'string', description: 'The file path to read. Absolute or relative to workspace.' },
|
||||
path: { type: 'string', description: 'Local file path only (NOT a URL). Absolute or relative to workspace. For web URLs, use web_fetch instead.' },
|
||||
encoding: { type: 'string', enum: ['utf-8', 'latin1', 'base64'], description: 'File encoding. Default: utf-8. Use base64 for binary content.' },
|
||||
start_line: { type: 'integer', description: 'Start line (1-indexed) for text files. Use with end_line for pagination.' },
|
||||
end_line: { type: 'integer', description: 'End line (inclusive). Default: start_line + 2000.' },
|
||||
@@ -231,12 +231,12 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'read_multiple_files',
|
||||
description: 'Read up to 50 files at once in parallel. Returns content of all requested files (10KB each by default).',
|
||||
description: 'Read up to 50 LOCAL files at once in parallel. Does NOT support URLs — use web_fetch for web pages. Returns content of all requested files (10KB each by default).',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
required: ['paths'],
|
||||
properties: {
|
||||
paths: { type: 'array', items: { type: 'string' }, description: 'Array of file paths to read.' },
|
||||
paths: { type: 'array', items: { type: 'string' }, description: 'Array of LOCAL file paths to read (NOT URLs). For web URLs, use web_fetch instead.' },
|
||||
max_chars_per_file: { type: 'integer', description: 'Max chars per file. Default: 10000.' }
|
||||
}
|
||||
}
|
||||
@@ -801,6 +801,31 @@ function hasCommandInjection(command: string): boolean {
|
||||
|
||||
/** R28: 工具安全验证 — 在执行前检查安全风险 */
|
||||
export function validateToolSecurity(toolName: string, args: Record<string, unknown>): string | null {
|
||||
// R28: URL 检测 — 本地文件工具不接受 URL
|
||||
const localFileTools = ['read_file', 'read_multiple_files', 'write_file', 'edit_file', 'list_directory', 'search_files', 'create_directory', 'delete_file', 'move_file', 'copy_file', 'tree'];
|
||||
if (localFileTools.includes(toolName)) {
|
||||
const pathFields = ['path', 'source', 'destination', 'file1', 'file2'];
|
||||
for (const field of pathFields) {
|
||||
if (typeof args[field] === 'string') {
|
||||
const val = (args[field] as string).toLowerCase().trim();
|
||||
if (val.startsWith('http://') || val.startsWith('https://')) {
|
||||
return `参数 "${field}" 是 URL,不是本地路径。请改用 web_fetch 工具读取网页内容: ${args[field]}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
// paths 数组检查(read_multiple_files)
|
||||
if (Array.isArray(args.paths)) {
|
||||
const urls = (args.paths as string[]).filter(p => {
|
||||
if (typeof p !== 'string') return false;
|
||||
const lower = p.toLowerCase().trim();
|
||||
return lower.startsWith('http://') || lower.startsWith('https://');
|
||||
});
|
||||
if (urls.length > 0) {
|
||||
return `paths 数组中包含 ${urls.length} 个 URL,read_multiple_files 仅支持本地文件。请改用 web_fetch 工具逐个抓取以下 URL:\n${urls.join('\n')}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 路径遍历检查
|
||||
const pathFields = ['path', 'source', 'destination', 'file1', 'file2', 'cwd'];
|
||||
for (const field of pathFields) {
|
||||
@@ -978,44 +1003,67 @@ export function validateToolArgs(toolName: string, args: Record<string, unknown>
|
||||
// ── R22: 工具结果截断 ──
|
||||
|
||||
/** R22: 工具结果最大字符数 */
|
||||
const MAX_TOOL_RESULT_CHARS = 30000;
|
||||
const MAX_TOOL_RESULT_CHARS = 100000;
|
||||
|
||||
/** R22: 截断单个大字符串,保留首尾并添加截断标记 */
|
||||
function truncateStringField(str: string, headKeep = 8000, tailKeep = 3000): string {
|
||||
if (str.length <= headKeep + tailKeep) return str;
|
||||
const head = str.slice(0, headKeep);
|
||||
const tail = str.slice(-tailKeep);
|
||||
const omitted = str.length - headKeep - tailKeep;
|
||||
return `${head}\n\n... [已截断 ${omitted} 字符,共 ${str.length} 字符] ...\n\n${tail}`;
|
||||
}
|
||||
|
||||
/** R22: 截断大输出结果,保留首尾并添加截断标记 */
|
||||
export function truncateToolResult(result: ToolResult, toolName: string): ToolResult {
|
||||
const jsonStr = JSON.stringify(result);
|
||||
if (jsonStr.length <= MAX_TOOL_RESULT_CHARS) return result;
|
||||
|
||||
// 不同工具有不同的截断策略
|
||||
const truncated = { ...result };
|
||||
|
||||
// 截断 stdout/content 类的大字段
|
||||
const largeFields = ['stdout', 'content', 'text', 'result', 'output', 'data'];
|
||||
// 1. 截断顶层大字符串字段
|
||||
const largeFields = ['stdout', 'content', 'text', 'result', 'output', 'data', 'formatted', 'snippet', 'error', 'stderr'];
|
||||
for (const field of largeFields) {
|
||||
if (typeof truncated[field] === 'string' && (truncated[field] as string).length > 10000) {
|
||||
const original = truncated[field] as string;
|
||||
const head = original.slice(0, 8000);
|
||||
const tail = original.slice(-3000);
|
||||
const omitted = original.length - 11000;
|
||||
truncated[field] = `${head}\n\n... [已截断 ${omitted} 字符,共 ${original.length} 字符] ...\n\n${tail}`;
|
||||
truncated[field] = truncateStringField(truncated[field] as string);
|
||||
}
|
||||
}
|
||||
|
||||
// 截断数组类结果
|
||||
if (Array.isArray(truncated.entries) && truncated.entries.length > 100) {
|
||||
const total = truncated.entries.length;
|
||||
truncated.entries = truncated.entries.slice(0, 50);
|
||||
truncated._truncated = true;
|
||||
truncated._totalEntries = total;
|
||||
truncated._truncatedMessage = `结果已截断:显示前 50 条,共 ${total} 条`;
|
||||
}
|
||||
if (Array.isArray(truncated.results) && truncated.results.length > 100) {
|
||||
const total = truncated.results.length;
|
||||
truncated.results = truncated.results.slice(0, 50);
|
||||
truncated._truncated = true;
|
||||
truncated._totalResults = total;
|
||||
// 2. 截断数组内每条记录的大字符串字段(如 _fetched[].content, results[].snippet)
|
||||
const arrayFields = ['results', 'entries', '_fetched', 'structured', 'files', 'matches'];
|
||||
for (const field of arrayFields) {
|
||||
if (Array.isArray(truncated[field])) {
|
||||
const arr = truncated[field] as unknown[];
|
||||
// 数组本身过长时先裁剪条数
|
||||
if (arr.length > 50) {
|
||||
(truncated as any)[`_original_${field}_count`] = arr.length;
|
||||
(truncated as any)[field] = arr.slice(0, 50);
|
||||
}
|
||||
// 逐条截断内部大字段
|
||||
const innerLargeKeys = ['content', 'snippet', 'text', 'stdout', 'output', 'formatted', 'data', 'result', 'stderr'];
|
||||
for (const item of (truncated as any)[field]) {
|
||||
if (item && typeof item === 'object' && !Array.isArray(item)) {
|
||||
for (const key of innerLargeKeys) {
|
||||
if (typeof item[key] === 'string' && item[key].length > 8000) {
|
||||
item[key] = truncateStringField(item[key], 6000, 2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logWarn(`R22: 工具 ${toolName} 结果已截断 (${jsonStr.length} → ~${JSON.stringify(truncated).length} 字符)`);
|
||||
// 3. 兜底:如果经过上述截断后仍然超限,对整个 JSON 暴力截断
|
||||
let finalStr = JSON.stringify(truncated);
|
||||
if (finalStr.length > MAX_TOOL_RESULT_CHARS) {
|
||||
const head = finalStr.slice(0, MAX_TOOL_RESULT_CHARS - 500);
|
||||
const tail = finalStr.slice(-300);
|
||||
const omitted = finalStr.length - (MAX_TOOL_RESULT_CHARS - 200);
|
||||
logWarn(`R22: 工具 ${toolName} 结果过大,暴力截断 (${jsonStr.length} → ~${MAX_TOOL_RESULT_CHARS} 字符)`);
|
||||
return JSON.parse(head + `"... [已暴力截断 ${omitted} 字符] ..."}` + tail) as ToolResult;
|
||||
}
|
||||
|
||||
logWarn(`R22: 工具 ${toolName} 结果已截断 (${jsonStr.length} → ~${finalStr.length} 字符)`);
|
||||
return truncated;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user