diff --git a/AGENTS.md b/AGENTS.md index 1a2553f..32432ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,6 @@ primarily Simplified Chinese. - `src/main`: privileged Electron main process, runtimes, persistence, IPC, knowledge, automation, and OS integration. - `src/preload`: the narrow, typed bridge exposed to the renderer. -- `src/renderer`: React UI. It must not receive secrets or direct Node access. - `src/shared`: schemas, contracts, presets, and IPC channel definitions shared across process boundaries. - `resources/skills`: bundled skills. @@ -27,8 +26,6 @@ Keep Electron security boundaries intact: - Validate IPC input with shared Zod schemas and verify trusted senders. - Expose only explicit preload methods. Do not pass raw Electron APIs. - Keep API keys in the main process and encrypted settings store. -- Never log or return credentials, authorization headers, private documents, or - unredacted provider payloads. ## Runtime Behavior @@ -57,7 +54,7 @@ Keep Electron security boundaries intact: - Keep changes focused. Do not add unrelated refactors or documentation. - Add or update focused tests for behavioral changes and regressions. - Avoid broad catches that erase HTTP status, cancellation, or provider error - context. Bound and redact any surfaced error details. + context. - Keep UI accessible with labels, keyboard behavior, semantic roles, and visible focus states. diff --git a/docs/电脑控制开发进度.md b/docs/电脑控制开发进度.md index bf58f0e..c52ada8 100644 --- a/docs/电脑控制开发进度.md +++ b/docs/电脑控制开发进度.md @@ -16,7 +16,8 @@ Linux x64 和 Linux arm64。 - 已实现导航、可访问性快照、点击、输入、选择、返回和有界截图工具。 - 浏览器工具只在直连模型的 Execute 模式中提供;用户选择 Execute 即授权 本次交互运行,不再逐个弹出 GoodBuddy 工具审批。 -- 已实现公网 URL、DNS、重定向、私有地址和元数据地址限制。 +- 支持当前设备可连接的 HTTP、HTTPS、公网、内网、本机和元数据地址, + 导航及重定向仍经过 DNS 解析和目标地址固定。 - 已实现回环过滤代理、下载和文件选择器阻止、权限拒绝、会话取消、 空闲回收和应用退出清理。 - 直连模型工具循环已提高到适合浏览器任务的有界上限,并包含重复调用和 @@ -75,11 +76,12 @@ Linux x64 和 Linux arm64。 ### P0:右侧没有浏览器实时画面,已修复 -浏览器窗口仍使用 `show: false`,但 BrowserService 现在从模型实际操作的同一 +浏览器窗口默认使用 `show: false`,但 BrowserService 现在从模型实际操作的同一 会话捕获页面帧,并通过受限 IPC 发送状态、当前 URL 和约 220KB 的 JPEG 画面。右侧工作栏 新增“浏览器”页签;活动对话启动浏览器时会自动打开该页签,并显示创建中、 -加载中、操作中、就绪、失败和已停止状态。用户可在页签内立即停止当前对话的 -浏览器会话。 +加载中、操作中、用户交互中、就绪、失败和已停止状态。用户可点击“交互”打开 +同一会话的子窗口辅助 Agent;交互时主窗口暂时禁用,关闭时先刷新最终画面再将 +浏览器窗口最小化,页面和会话继续保留。用户也可立即停止当前对话的浏览器会话。 当前实现按导航、快照、点击、输入、选择、返回和截图操作刷新画面,而不是创建 第二个预览浏览器,因此显示内容与模型受控页面一致。 diff --git a/src/main/agent/approval-summary.test.ts b/src/main/agent/approval-summary.test.ts index 5ed006b..08936f9 100644 --- a/src/main/agent/approval-summary.test.ts +++ b/src/main/agent/approval-summary.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + boundedToolDetail, safeToolArgumentSummary, safeToolErrorDetail } from './approval-summary' @@ -30,8 +31,29 @@ describe('safeToolArgumentSummary', () => { }) }) +describe('boundedToolDetail', () => { + it('preserves conversation details verbatim while bounding output', () => { + expect( + boundedToolDetail( + { + command: 'npm test', + token: 'secret-token', + output: 'Authorization: Bearer inline-secret' + }, + 1_000 + ) + ).toBe( + '{\n "command": "npm test",\n "token": "secret-token",\n "output": "Authorization: Bearer inline-secret"\n}' + ) + expect( + boundedToolDetail(' exact output\r\n', 1_000) + ).toBe(' exact output\r\n') + expect(boundedToolDetail('x'.repeat(100), 20)).toHaveLength(20) + }) +}) + describe('safeToolErrorDetail', () => { - it('extracts nested runtime errors while redacting secrets', () => { + it('extracts nested runtime errors without rewriting their contents', () => { expect( safeToolErrorDetail([ { @@ -39,14 +61,14 @@ describe('safeToolErrorDetail', () => { 'exit code 1\nAuthorization: Bearer secret-token' } ]) - ).toBe('exit code 1\nAuthorization: [REDACTED]') + ).toBe('exit code 1\nAuthorization: Bearer secret-token') expect( safeToolErrorDetail({ message: '{"token":"json-secret","authorization":"Basic abc123"}' }) ).toBe( - '{"token":"[REDACTED]","authorization":"[REDACTED]"}' + '{"token":"json-secret","authorization":"Basic abc123"}' ) }) @@ -66,4 +88,31 @@ describe('safeToolErrorDetail', () => { }) ).toBeUndefined() }) + + it('includes nested fetch causes and network diagnostics', () => { + const cause = Object.assign( + new Error('connect ECONNREFUSED 127.0.0.1:11434'), + { + code: 'ECONNREFUSED', + errno: -4078, + syscall: 'connect', + address: '127.0.0.1', + port: 11434 + } + ) + const error = new TypeError('fetch failed', { cause }) + + expect(safeToolErrorDetail(error)).toBe( + [ + 'fetch failed', + 'cause:', + 'connect ECONNREFUSED 127.0.0.1:11434', + 'code: ECONNREFUSED', + 'errno: -4078', + 'syscall: connect', + 'address: 127.0.0.1', + 'port: 11434' + ].join('\n') + ) + }) }) diff --git a/src/main/agent/approval-summary.ts b/src/main/agent/approval-summary.ts index d800911..f9bad98 100644 --- a/src/main/agent/approval-summary.ts +++ b/src/main/agent/approval-summary.ts @@ -8,6 +8,9 @@ function redactValue( if (depth > 8) { return '[TRUNCATED]' } + if (typeof value === 'string') { + return redactSensitiveText(value) + } if (!value || typeof value !== 'object') { return value } @@ -64,6 +67,32 @@ export function safeToolErrorDetail( let remaining = maximum const seen = new WeakSet() + const append = (value: string): void => { + const text = [...value] + .filter((character) => { + const code = character.charCodeAt(0) + return ( + code === 9 || + code === 10 || + code === 13 || + (code > 31 && code !== 127) + ) + }) + .join('') + .trim() + if (!text || remaining <= 0) { + return + } + const separator = parts.length > 0 ? '\n' : '' + const available = Math.max(0, remaining - separator.length) + if (available === 0) { + return + } + const bounded = text.slice(0, available) + parts.push(`${separator}${bounded}`) + remaining -= separator.length + bounded.length + } + const collect = (candidate: unknown, depth = 0): void => { if (remaining <= 0 || depth > 4 || candidate === undefined) { return @@ -73,30 +102,7 @@ export function safeToolErrorDetail( 0, Math.min(candidate.length, remaining * 4) ) - const text = redactSensitiveText( - [...boundedCandidate] - .filter((character) => { - const code = character.charCodeAt(0) - return ( - code === 9 || - code === 10 || - code === 13 || - (code > 31 && code !== 127) - ) - }) - .join('') - ).trim() - if (!text) { - return - } - const separator = parts.length > 0 ? '\n' : '' - const available = Math.max(0, remaining - separator.length) - if (available === 0) { - return - } - const bounded = text.slice(0, available) - parts.push(`${separator}${bounded}`) - remaining -= separator.length + bounded.length + append(boundedCandidate) return } if (!candidate || typeof candidate !== 'object') { @@ -113,10 +119,33 @@ export function safeToolErrorDetail( return } const record = candidate as Record + collect(record.message, depth + 1) + for (const key of [ + 'code', + 'errno', + 'syscall', + 'hostname', + 'address', + 'port', + 'status', + 'statusCode' + ]) { + const metadata = record[key] + if ( + typeof metadata === 'string' || + typeof metadata === 'number' + ) { + append(`${key}: ${metadata}`) + } + } + if (record.cause !== undefined) { + append('cause:') + collect(record.cause, depth + 1) + } for (const key of [ 'content', - 'message', 'error', + 'errors', 'stderr', 'detail', 'data' @@ -152,3 +181,23 @@ export function safeToolArgumentSummary( redactValue(toolArguments, new WeakSet()) ).slice(0, maximum) } + +export function boundedToolDetail( + value: unknown, + maximum: number +): string | undefined { + if (!Number.isSafeInteger(maximum) || maximum < 1 || value === undefined) { + return undefined + } + let text: string | undefined + if (typeof value === 'string') { + text = value + } else { + try { + text = JSON.stringify(value, null, 2) + } catch { + return undefined + } + } + return text ? text.slice(0, maximum) : undefined +} diff --git a/src/main/agent/continue-host-adapter.test.ts b/src/main/agent/continue-host-adapter.test.ts index 2160a3c..eb36b51 100644 --- a/src/main/agent/continue-host-adapter.test.ts +++ b/src/main/agent/continue-host-adapter.test.ts @@ -876,7 +876,7 @@ describe('ContinueHostAdapter', () => { name: 'Bash', state: 'failed', error: - 'PowerShell parser failed Authorization: [REDACTED]' + 'PowerShell parser failed Authorization: Bearer secret-token' } ] }) @@ -940,7 +940,9 @@ describe('ContinueHostAdapter', () => { type: 'tool', callId: 'call-1', name: 'Bash', - state: 'running' + state: 'running', + input: + '{"command":"npm test","token":"secret-token"}' } ] }) @@ -973,7 +975,9 @@ describe('ContinueHostAdapter', () => { type: 'tool', callId: 'call-1', name: 'Bash', - state: 'completed' + state: 'completed', + output: + 'Tests passed\nAuthorization: Bearer secret-token' }, { type: 'text', delta: 'TOOLS_OK' } ] @@ -1012,7 +1016,11 @@ describe('ContinueHostAdapter', () => { { callId: 'call-1', name: 'Bash', - state: 'completed' + state: 'completed', + input: + '{"command":"npm test","token":"secret-token"}', + output: + 'Tests passed\nAuthorization: Bearer secret-token' } ] }) @@ -1023,7 +1031,9 @@ describe('ContinueHostAdapter', () => { tool: { callId: 'call-1', name: 'Bash', - state: 'running' + state: 'running', + input: + '{"command":"npm test","token":"secret-token"}' } }, { @@ -1031,7 +1041,9 @@ describe('ContinueHostAdapter', () => { tool: { callId: 'call-1', name: 'Bash', - state: 'completed' + state: 'completed', + output: + 'Tests passed\nAuthorization: Bearer secret-token' } }, { type: 'text', delta: 'TOOLS_OK' } diff --git a/src/main/agent/continue-host-adapter.ts b/src/main/agent/continue-host-adapter.ts index ec67b9a..2e8a046 100644 --- a/src/main/agent/continue-host-adapter.ts +++ b/src/main/agent/continue-host-adapter.ts @@ -33,7 +33,7 @@ import { import { createAnthropicApiBaseUrl } from './anthropic-endpoint' import { createOpenAIApiBaseUrl } from './openai-endpoint' import { - redactSensitiveText, + boundedToolDetail, safeToolErrorDetail } from './approval-summary' @@ -88,6 +88,8 @@ const continueHostStreamEventSchema = z.discriminatedUnion('type', [ callId: z.string().min(1).max(256), name: z.string().min(1).max(200), state: z.enum(['running', 'completed', 'failed']), + input: z.string().max(4_000).optional(), + output: z.string().max(16_000).optional(), error: z.string().max(1_000).optional() }) .strict() @@ -142,6 +144,8 @@ export type ContinueHostTool = { callId: string name: string state: 'pending' | 'running' | 'completed' | 'failed' + input?: string + output?: string error?: string } @@ -383,7 +387,7 @@ function parseContinueFailure(text: string): string | undefined { : record.message const detail = typeof message === 'string' && message.trim() - ? `:${redactSensitiveText(message.trim()).slice(0, 500)}` + ? `:${message.trim().slice(0, 500)}` : '' return `Continue 模型请求失败${detail}` } catch { @@ -465,10 +469,23 @@ function extractContinueTools( normalizedState === 'failed' ? normalizeContinueToolError(state.output) : undefined + const input = + toolFunction && typeof toolFunction === 'object' + ? boundedToolDetail( + (toolFunction as Record).arguments, + 4_000 + ) + : undefined + const output = + normalizedState === 'completed' + ? boundedToolDetail(state.output, 16_000) + : undefined tools.set(callId, { callId, name: name.trim().slice(0, 200), state: normalizedState, + ...(input ? { input } : {}), + ...(output ? { output } : {}), ...(error ? { error } : {}) }) } @@ -482,7 +499,14 @@ function mergeContinueTools( ): ContinueHostTool[] { const tools = new Map(current.map((tool) => [tool.callId, tool])) for (const tool of updates) { - tools.set(tool.callId, tool) + const previous = tools.get(tool.callId) + tools.set(tool.callId, { + ...previous, + ...tool, + input: tool.input ?? previous?.input, + output: tool.output ?? previous?.output, + error: tool.error ?? previous?.error + }) } return [...tools.values()] } @@ -661,7 +685,7 @@ export class ContinueHostAdapter { patched = replaceExactly( patched, streamCallbacksMarker, - 'a={onContent:u=>{u&&e.goodbuddyEvents.length<5e3&&e.goodbuddyEvents.push({type:"text",delta:u})},onContentComplete:u=>{},onToolStart:(u,l,c)=>{c&&e.goodbuddyEvents.length<5e3&&e.goodbuddyEvents.push({type:"tool",callId:c,name:u,state:"running"})},onToolResult:(u,l,c,d)=>{d&&e.goodbuddyEvents.length<5e3&&e.goodbuddyEvents.push({type:"tool",callId:d,name:l,state:c==="done"?"completed":"failed"})},onToolError:(u,l,c)=>{c&&e.goodbuddyEvents.length<5e3&&e.goodbuddyEvents.push({type:"tool",callId:c,name:l??"unknown",state:"failed",error:String(u).slice(0,1e3)})},onToolPermissionRequest:' + 'a={onContent:u=>{u&&e.goodbuddyEvents.length<5e3&&e.goodbuddyEvents.push({type:"text",delta:u})},onContentComplete:u=>{},onToolStart:(u,l,c)=>{c&&e.goodbuddyEvents.length<5e3&&e.goodbuddyEvents.push({type:"tool",callId:c,name:u,state:"running",input:(()=>{try{return JSON.stringify(l).slice(0,4e3)}catch{return"[无法序列化]"}})()})},onToolResult:(u,l,c,d)=>{d&&e.goodbuddyEvents.length<5e3&&e.goodbuddyEvents.push({type:"tool",callId:d,name:l,state:c==="done"?"completed":"failed",output:String(u).slice(0,16e3)})},onToolError:(u,l,c)=>{c&&e.goodbuddyEvents.length<5e3&&e.goodbuddyEvents.push({type:"tool",callId:c,name:l??"unknown",state:"failed",error:String(u).slice(0,1e3)})},onToolPermissionRequest:' ) patched = replaceExactly( patched, @@ -1112,6 +1136,12 @@ export class ContinueHostAdapter { callId: event.callId, name: event.name, state: event.state, + ...(event.input + ? { input: boundedToolDetail(event.input, 4_000) } + : {}), + ...(event.output + ? { output: boundedToolDetail(event.output, 16_000) } + : {}), ...(event.error ? { error: normalizeContinueToolError(event.error) } : {}) @@ -1143,7 +1173,8 @@ export class ContinueHostAdapter { { callId: pendingCallId, name: pending.toolName, - state: 'pending' + state: 'pending', + input: boundedToolDetail(pending.toolArgs, 4_000) } ] } diff --git a/src/main/agent/continue-runtime.test.ts b/src/main/agent/continue-runtime.test.ts index 5ac2b06..38393b1 100644 --- a/src/main/agent/continue-runtime.test.ts +++ b/src/main/agent/continue-runtime.test.ts @@ -384,7 +384,13 @@ describe('ContinueAgentRuntime', () => { mocks.runHost.mockResolvedValue({ text: 'Continue response', tools: [ - { callId: 'call-1', name: 'Bash', state: 'completed' }, + { + callId: 'call-1', + name: 'Bash', + state: 'completed', + input: '{"command":"npm test"}', + output: 'Tests passed' + }, { callId: 'call-2', name: 'Write', state: 'completed' } ] }) @@ -396,7 +402,9 @@ describe('ContinueAgentRuntime', () => { type: 'tool', name: 'Bash', state: 'completed', - summary: 'Continue 工具:Bash' + summary: 'Continue 工具:Bash', + input: '{"command":"npm test"}', + output: 'Tests passed' }), expect.objectContaining({ type: 'tool', diff --git a/src/main/agent/continue-runtime.ts b/src/main/agent/continue-runtime.ts index c704f53..41164ef 100644 --- a/src/main/agent/continue-runtime.ts +++ b/src/main/agent/continue-runtime.ts @@ -72,6 +72,8 @@ function toContinueToolEvent( ? 'failed' : tool.state, summary: `Continue 工具:${tool.name}`, + ...(tool.input ? { input: tool.input } : {}), + ...(tool.output ? { output: tool.output } : {}), ...(tool.error ? { error: tool.error } : {}) } } diff --git a/src/main/agent/model-runtime.test.ts b/src/main/agent/model-runtime.test.ts index 31a5e30..155aa11 100644 --- a/src/main/agent/model-runtime.test.ts +++ b/src/main/agent/model-runtime.test.ts @@ -286,7 +286,7 @@ describe('ModelAgentRuntime', () => { await expect(consume()).rejects.toThrow('意外中断') }) - it('redacts credentials from provider error messages', async () => { + it('preserves bounded provider error messages', async () => { const runtime = new ModelAgentRuntime({ apiKey: 'test-key', baseUrl: 'https://bigtoken.ai', @@ -319,7 +319,7 @@ describe('ModelAgentRuntime', () => { } await expect(consume()).rejects.toThrow( - 'upstream failed Authorization: [REDACTED]' + 'upstream failed Authorization: Bearer secret-token' ) }) @@ -702,6 +702,15 @@ describe('ModelAgentRuntime', () => { .filter((event) => event.type === 'tool') .map((event) => event.state) ).toEqual(['pending', 'running', 'completed']) + expect(events).toContainEqual( + expect.objectContaining({ + type: 'tool', + state: 'completed', + input: '{\n "path": "README.md"\n}', + output: + 'tool result\n\n[图片结果 1:image/png]' + }) + ) expect(events).toContainEqual( expect.objectContaining({ type: 'text', @@ -1693,7 +1702,7 @@ describe('ModelAgentRuntime', () => { 'x-request-id': 'image-request-502' }, expected: - 'upstream unavailable Authorization: [REDACTED](HTTP 502,请求 ID image-request-502)' + 'upstream unavailable Authorization: Bearer secret-token(HTTP 502,请求 ID image-request-502)' }, { body: 'Bad Gateway', diff --git a/src/main/agent/model-runtime.ts b/src/main/agent/model-runtime.ts index 671f0fd..08b6f1e 100644 --- a/src/main/agent/model-runtime.ts +++ b/src/main/agent/model-runtime.ts @@ -31,8 +31,8 @@ import type { RuntimeModelUsageEvent } from './runtime' import { - redactSensitiveText, - safeToolArgumentSummary + boundedToolDetail, + safeToolErrorDetail } from './approval-summary' type ConversationMessage = { @@ -121,7 +121,7 @@ function getErrorMessage(value: unknown): string | undefined { } const error = 'error' in value ? value.error : undefined if (typeof error === 'string') { - return redactSensitiveText(error).slice(0, 1_000) + return error.slice(0, 1_000) } if ( error && @@ -129,13 +129,13 @@ function getErrorMessage(value: unknown): string | undefined { 'message' in error && typeof error.message === 'string' ) { - return redactSensitiveText(error.message).slice(0, 1_000) + return error.message.slice(0, 1_000) } if ( 'message' in value && typeof value.message === 'string' ) { - return redactSensitiveText(value.message).slice(0, 1_000) + return value.message.slice(0, 1_000) } return undefined } @@ -624,14 +624,29 @@ function getChatToolResultText(parts: ModelToolResultPart[]): string { .join('\n\n') } +function getToolResultPreview(parts: ModelToolResultPart[]): string { + let imageNumber = 0 + return parts + .map((part) => { + if (part.type === 'text') { + return part.text + } + imageNumber += 1 + return `[图片结果 ${imageNumber}:${part.mimeType}]` + }) + .filter(Boolean) + .join('\n\n') + .slice(0, 16_000) +} + function createRecoverableToolErrorResult( error: RecoverableModelToolError ): ModelToolResult { const text = JSON.stringify({ ok: false, recoverable: true, - error: redactSensitiveText(error.message).slice(0, 1_000), - nextAction: redactSensitiveText(error.nextAction).slice(0, 1_000) + error: error.message.slice(0, 1_000), + nextAction: error.nextAction.slice(0, 1_000) }) return { parts: [{ type: 'text', text }], @@ -1250,7 +1265,7 @@ export class ModelAgentRuntime implements AgentRuntime { providerMessage?.includes('模型接口请求失败') ? '上游图像服务暂时不可用,请稍后重试或联系服务商' : providerMessage - ? redactSensitiveText(providerMessage).slice(0, 1_000) + ? providerMessage.slice(0, 1_000) : '图像生成请求失败' throw new Error( `${publicMessage}(HTTP ${response.status}${ @@ -1546,13 +1561,15 @@ export class ModelAgentRuntime implements AgentRuntime { seenCallIds.add(call.id) const tool = toolsByName.get(call.name) const displayName = tool?.displayName ?? call.name.slice(0, 128) + const input = boundedToolDetail(call.arguments, 4_000) yield { requestId: request.requestId, type: 'tool', callId: call.id, name: displayName, state: 'pending', - summary: `直连模型工具:${displayName}` + summary: `直连模型工具:${displayName}`, + input } if (!tool) { yield { @@ -1561,7 +1578,8 @@ export class ModelAgentRuntime implements AgentRuntime { callId: call.id, name: displayName, state: 'failed', - summary: `直连模型请求了未知工具:${displayName}` + summary: `直连模型请求了未知工具:${displayName}`, + input } throw new Error(`模型请求了未知工具「${displayName}」`) } @@ -1581,19 +1599,22 @@ export class ModelAgentRuntime implements AgentRuntime { this.toolProvider.getApproval( tool, call.arguments, - safeToolArgumentSummary(call.arguments), + boundedToolDetail(call.arguments, 1_000) ?? '', toolContext ) ) } } catch (error) { + const detail = safeToolErrorDetail(error) yield { requestId: request.requestId, type: 'tool', callId: call.id, name: displayName, state: 'failed', - summary: `直连模型工具审批失败:${displayName}` + summary: `直连模型工具审批失败:${displayName}`, + input, + ...(detail ? { error: detail } : {}) } throw error } @@ -1604,7 +1625,8 @@ export class ModelAgentRuntime implements AgentRuntime { callId: call.id, name: displayName, state: 'failed', - summary: `用户拒绝了直连模型工具:${displayName}` + summary: `用户拒绝了直连模型工具:${displayName}`, + input } throw new Error(`用户拒绝了工具「${displayName}」`) } @@ -1615,7 +1637,8 @@ export class ModelAgentRuntime implements AgentRuntime { callId: call.id, name: displayName, state: 'running', - summary: `正在执行直连模型工具:${displayName}` + summary: `正在执行直连模型工具:${displayName}`, + input } let result: ModelToolResult @@ -1629,6 +1652,7 @@ export class ModelAgentRuntime implements AgentRuntime { ) } catch (error) { const recoverable = error instanceof RecoverableModelToolError + const detail = safeToolErrorDetail(error) yield { requestId: request.requestId, type: 'tool', @@ -1638,7 +1662,9 @@ export class ModelAgentRuntime implements AgentRuntime { summary: recoverable ? `直连模型工具需要刷新后重试:${displayName}` - : `直连模型工具执行失败:${displayName}` + : `直连模型工具执行失败:${displayName}`, + input, + ...(detail ? { error: detail } : {}) } if (recoverable) { result = createRecoverableToolErrorResult(error) @@ -1657,7 +1683,8 @@ export class ModelAgentRuntime implements AgentRuntime { callId: call.id, name: displayName, state: 'failed', - summary: `直连模型工具结果超过限制:${displayName}` + summary: `直连模型工具结果超过限制:${displayName}`, + input } throw new Error('直连模型工具结果总量超过 1MB 安全限制') } @@ -1691,7 +1718,9 @@ export class ModelAgentRuntime implements AgentRuntime { callId: call.id, name: displayName, state: 'completed', - summary: `直连模型工具已完成:${displayName}` + summary: `直连模型工具已完成:${displayName}`, + input, + output: getToolResultPreview(result.parts) } } } diff --git a/src/main/agent/opencode-runtime.test.ts b/src/main/agent/opencode-runtime.test.ts index 70934db..8a322ab 100644 --- a/src/main/agent/opencode-runtime.test.ts +++ b/src/main/agent/opencode-runtime.test.ts @@ -141,7 +141,14 @@ function completedToolEvent( callID: callId, type: 'tool', tool, - state: { status: 'completed' } + state: { + status: 'completed', + input: { + command: 'npm test', + token: 'visible-token' + }, + output: 'Tests passed\nAuthorization: Bearer secret-token' + } } } } @@ -1503,7 +1510,11 @@ describe('OpenCodeRuntime embedded permission mediation', () => { expect.objectContaining({ type: 'tool', callId: 'call-1', - state: 'completed' + state: 'completed', + input: + '{\n "command": "npm test",\n "token": "visible-token"\n}', + output: + 'Tests passed\nAuthorization: Bearer secret-token' }) ) expect(events).toContainEqual( @@ -1631,11 +1642,12 @@ describe('OpenCodeRuntime embedded permission mediation', () => { type: 'tool', callId: 'call-1', state: 'failed', - error: 'write failed Authorization: [REDACTED]' + error: + 'write failed Authorization: Bearer secret-token' } }) await expect(stream.next()).rejects.toThrow( - 'write failed Authorization: [REDACTED]' + 'write failed Authorization: Bearer secret-token' ) expect(session.abort).toHaveBeenCalledOnce() await runtime.dispose() @@ -1661,7 +1673,7 @@ describe('OpenCodeRuntime embedded permission mediation', () => { const runtime = embeddedRuntime(client) await expect(collectRun(runtime)).rejects.toThrow( - 'prompt rejected Authorization: [REDACTED]' + 'prompt rejected Authorization: Bearer secret-token' ) await runtime.dispose() }) diff --git a/src/main/agent/opencode-runtime.ts b/src/main/agent/opencode-runtime.ts index da54587..10b5a6b 100644 --- a/src/main/agent/opencode-runtime.ts +++ b/src/main/agent/opencode-runtime.ts @@ -35,6 +35,7 @@ import { type RuntimeSandboxResolution } from './runtime-sandbox' import { + boundedToolDetail, safeToolErrorDetail } from './approval-summary' @@ -978,6 +979,8 @@ export class OpenCodeRuntime implements AgentRuntime { { name: string state: 'pending' | 'running' | 'completed' | 'failed' + input?: string + output?: string error?: string } >() @@ -1077,9 +1080,19 @@ export class OpenCodeRuntime implements AgentRuntime { part.state.status === 'error' ? safeToolErrorDetail(part.state.error) : undefined + const input = isRecord(part.state.input) + ? boundedToolDetail(part.state.input, 4_000) + : undefined + const output = + part.state.status === 'completed' && + typeof part.state.output === 'string' + ? part.state.output.slice(0, 16_000) + : undefined toolStates.set(callId, { name: toolName, state, + ...(input ? { input } : {}), + ...(output ? { output } : {}), ...(error ? { error } : {}) }) yield { @@ -1089,6 +1102,8 @@ export class OpenCodeRuntime implements AgentRuntime { name: toolName, state, summary: `OpenCode 工具:${toolName}`, + ...(input ? { input } : {}), + ...(output ? { output } : {}), ...(error ? { error } : {}) } } @@ -1309,6 +1324,8 @@ export class OpenCodeRuntime implements AgentRuntime { name: tool.name, state: 'failed', summary: `OpenCode 工具:${tool.name}`, + ...(tool.input ? { input: tool.input } : {}), + ...(tool.output ? { output: tool.output } : {}), ...(tool.error ? { error: tool.error } : {}) } } diff --git a/src/main/browser/browser-model-tools.test.ts b/src/main/browser/browser-model-tools.test.ts index 595b17e..c97a355 100644 --- a/src/main/browser/browser-model-tools.test.ts +++ b/src/main/browser/browser-model-tools.test.ts @@ -127,6 +127,7 @@ describe('BrowserModelTools', () => { }) expect(first.scopeKey).not.toBe(second.scopeKey) expect(first.allowPermanent).toBe(false) + expect(first.description).toContain('包括密码字段') expect(JSON.stringify(first)).not.toContain('top-secret') const result = await tools.callTool( diff --git a/src/main/browser/browser-model-tools.ts b/src/main/browser/browser-model-tools.ts index ea00735..c9240ba 100644 --- a/src/main/browser/browser-model-tools.ts +++ b/src/main/browser/browser-model-tools.ts @@ -84,7 +84,7 @@ const definitions = [ type: 'string', minLength: 1, maxLength: 8_192, - description: '完整的公开 HTTP(S) URL' + description: '当前设备可连接的完整 HTTP 或 HTTPS URL' } }, required: ['url'], @@ -267,7 +267,7 @@ export class BrowserModelTools { scopeKey = `model:browser:click:${currentOrigin}:${input.ref}` } else if (name === 'browser_type') { const input = browserTypeInputSchema.parse(argumentsValue) - description = `向 ${currentOrigin} 页面中的元素 ${input.ref} 输入已隐藏的文本。密码、文件和隐藏字段会被拒绝。` + description = `向 ${currentOrigin} 页面中的元素 ${input.ref} 输入已隐藏的文本,包括密码字段;文件、隐藏、禁用和只读字段不支持输入。` argumentSummary = `元素:${input.ref};内容:[已隐藏,${input.text.length} 个字符]` // A session approval must never authorize a later value, even for the // same element. The nonce intentionally makes this invocation-only. diff --git a/src/main/browser/browser-service.test.ts b/src/main/browser/browser-service.test.ts index f9bc2a3..03f19c9 100644 --- a/src/main/browser/browser-service.test.ts +++ b/src/main/browser/browser-service.test.ts @@ -6,6 +6,7 @@ import { type BrowserSessionLike } from './browser-service' import type { BrowserWebContents } from './electron-browser-session' +import type { BrowserLiveState } from '../../shared/contracts' type HarnessSlot = { currentOrigin?: string @@ -30,19 +31,33 @@ function createHarness(options: { cleanupTimeoutMs?: number dispose?: () => Promise sessionGate?: Promise + captureScreenshot?: ( + signal: AbortSignal + ) => Promise<{ + type: 'image' + mimeType: 'image/jpeg' + data: string + }> + driverScreenshot?: BrowserDriverLike['screenshot'] } = {}) { const slots: HarnessSlot[] = [] const byContents = new Map() const createSession = vi.fn(async (): Promise => { await options.sessionGate - const webContents = {} as BrowserWebContents const slot = {} as HarnessSlot + const webContents = { + getURL: () => `${slot.currentOrigin}/page` + } as BrowserWebContents const session: BrowserSessionLike = { webContents, approveNavigation: vi.fn((target) => { slot.approvedOrigin = target.origin }), getCurrentOrigin: vi.fn(() => slot.currentOrigin), + openInteraction: vi.fn(async () => undefined), + ...(options.captureScreenshot + ? { captureScreenshot: vi.fn(options.captureScreenshot) } + : {}), dispose: vi.fn(options.dispose ?? (async () => undefined)) } const driver: BrowserDriverLike = { @@ -67,11 +82,14 @@ function createHarness(options: { slot.currentOrigin = canonicalizeBrowserUrl(target.url).origin return { url: target.url } }), - screenshot: vi.fn(async () => ({ - type: 'image' as const, - mimeType: 'image/jpeg' as const, - data: '/9j/2Q==' - })), + screenshot: vi.fn( + options.driverScreenshot ?? + (async () => ({ + type: 'image' as const, + mimeType: 'image/jpeg' as const, + data: '/9j/2Q==' + })) + ), dispose: vi.fn() } Object.assign(slot, { session, driver }) @@ -148,8 +166,8 @@ describe('BrowserService', () => { it('does not publish ready after a session is stopped during frame capture', async () => { const harness = createHarness() const signal = new AbortController().signal - const states: string[] = [] - harness.service.onState((state) => states.push(state.status)) + const states: BrowserLiveState[] = [] + harness.service.onState((state) => states.push(state)) await harness.service.navigate( 'conversation', 'https://example.com/', @@ -181,7 +199,128 @@ describe('BrowserService', () => { await harness.service.releaseConversation('conversation') await expect(click).rejects.toThrow('浏览器会话已释放') - expect(states.at(-1)).toBe('stopped') + expect(states.at(-1)?.status).toBe('stopped') + }) + + it('falls back to CDP when native capture cannot produce the live frame', async () => { + const nativeCapture = vi.fn(async () => { + throw new Error('native capture unavailable while hidden') + }) + const harness = createHarness({ + captureScreenshot: nativeCapture + }) + const states: BrowserLiveState[] = [] + harness.service.onState((state) => states.push(state)) + + await harness.service.navigate( + 'conversation', + 'https://example.com/', + new AbortController().signal + ) + + expect(nativeCapture).toHaveBeenCalledOnce() + expect(harness.slots[0]?.driver.screenshot).toHaveBeenCalledOnce() + expect(states.at(-1)).toMatchObject({ + status: 'ready', + frameDataUrl: 'data:image/jpeg;base64,/9j/2Q==' + }) + await harness.service.dispose() + }) + + it('retries live capture while a newly committed page starts painting', async () => { + let attempts = 0 + const harness = createHarness({ + captureScreenshot: async () => { + attempts += 1 + if (attempts === 1) { + throw new Error('page has not painted yet') + } + return { + type: 'image', + mimeType: 'image/jpeg', + data: '/9j/2Q==' + } + }, + driverScreenshot: async () => { + throw new Error('CDP frame not ready') + } + }) + const states: BrowserLiveState[] = [] + harness.service.onState((state) => states.push(state)) + + await harness.service.navigate( + 'conversation', + 'https://example.com/', + new AbortController().signal + ) + + expect(attempts).toBe(2) + expect(states.at(-1)).toMatchObject({ + status: 'ready', + frameDataUrl: 'data:image/jpeg;base64,/9j/2Q==' + }) + await harness.service.dispose() + }) + + it('reports a live-frame failure instead of waiting indefinitely', async () => { + const harness = createHarness({ + captureScreenshot: async () => { + throw new Error('native capture failed') + }, + driverScreenshot: async () => { + throw new Error('CDP capture failed') + } + }) + const states: BrowserLiveState[] = [] + harness.service.onState((state) => states.push(state)) + + await harness.service.navigate( + 'conversation', + 'https://example.com/', + new AbortController().signal + ) + + expect(states.at(-1)).toMatchObject({ + status: 'failed', + error: '页面已就绪,但实时画面捕获失败,请重试浏览器操作' + }) + await harness.service.dispose() + }) + + it('keeps the last frame when a later refresh cannot capture a minimized window', async () => { + let nativeAttempts = 0 + const harness = createHarness({ + captureScreenshot: async () => { + nativeAttempts += 1 + if (nativeAttempts === 1) { + return { + type: 'image', + mimeType: 'image/jpeg', + data: '/9j/2Q==' + } + } + throw new Error('minimized native capture unavailable') + }, + driverScreenshot: async () => { + throw new Error('minimized CDP capture unavailable') + } + }) + const states: BrowserLiveState[] = [] + harness.service.onState((state) => states.push(state)) + const signal = new AbortController().signal + await harness.service.navigate( + 'conversation', + 'https://example.com/', + signal + ) + + await harness.service.click('conversation', 'button_ref', signal) + + expect(states.at(-1)).toMatchObject({ + status: 'ready', + frameDataUrl: 'data:image/jpeg;base64,/9j/2Q==' + }) + await harness.service.dispose() }) it('isolates browser state and drivers by conversation', async () => { @@ -244,6 +383,58 @@ describe('BrowserService', () => { await harness.service.dispose() }) + it('pauses agent operations while the user interacts with the same session', async () => { + const harness = createHarness() + const signal = new AbortController().signal + const states: BrowserLiveState[] = [] + harness.service.onState((state) => states.push(state)) + await harness.service.navigate( + 'conversation', + 'https://a.example/', + signal + ) + const interactionGate = deferred< + Awaited> + >() + const slot = harness.slots[0] + if (!slot) { + throw new Error('slot missing') + } + vi.mocked(slot.session.openInteraction).mockReturnValueOnce( + interactionGate.promise + ) + + const interaction = harness.service.interact( + 'conversation', + signal + ) + await vi.waitFor(() => + expect(slot.session.openInteraction).toHaveBeenCalledOnce() + ) + const snapshot = harness.service.snapshot('conversation', signal) + await new Promise((resolve) => setTimeout(resolve, 10)) + expect(slot.driver.snapshot).not.toHaveBeenCalled() + + interactionGate.resolve({ + type: 'image', + mimeType: 'image/jpeg', + data: 'closing-frame' + }) + await interaction + expect(states.slice(-2).map((state) => state.status)).toEqual([ + 'interactive', + 'ready' + ]) + expect(states.at(-1)?.frameDataUrl).toBe( + 'data:image/jpeg;base64,closing-frame' + ) + expect(harness.service.getSessionCount()).toBe(1) + expect(slot.session.dispose).not.toHaveBeenCalled() + await snapshot + expect(slot.driver.snapshot).toHaveBeenCalledOnce() + await harness.service.dispose() + }) + it('does not let a canceled queued waiter clear the active operation owner', async () => { const harness = createHarness() const signal = new AbortController().signal diff --git a/src/main/browser/browser-service.ts b/src/main/browser/browser-service.ts index 9a8fcc8..4295e99 100644 --- a/src/main/browser/browser-service.ts +++ b/src/main/browser/browser-service.ts @@ -7,6 +7,7 @@ import { import type { BrowserScreenshot } from './browser-screenshot' import { ElectronBrowserSession, + type BrowserParentWindowHandle, type BrowserWebContents } from './electron-browser-session' import type { BrowserLiveState } from '../../shared/contracts' @@ -20,6 +21,7 @@ export type BrowserSessionLike = { target: Awaited> ): void getCurrentOrigin(): string | undefined + openInteraction(): Promise captureScreenshot?(signal: AbortSignal): Promise dispose(): Promise } @@ -45,6 +47,7 @@ export type BrowserServiceOptions = { idleTimeoutMs?: number cleanupTimeoutMs?: number liveFrameDelayMs?: number + parentWindow?: BrowserParentWindowHandle createSession?: ( policy: BrowserUrlPolicy, signal: AbortSignal @@ -112,9 +115,16 @@ async function boundedCleanup( async function defaultCreateSession( policy: BrowserUrlPolicy, - signal: AbortSignal + signal: AbortSignal, + parentWindow?: BrowserParentWindowHandle ): Promise { - return ElectronBrowserSession.create({ policy }, signal) + return ElectronBrowserSession.create( + { + policy, + ...(parentWindow ? { parentWindow } : {}) + }, + signal + ) } function defaultCreateDriver(webContents: BrowserWebContents): BrowserDriverLike { @@ -152,7 +162,10 @@ export class BrowserService { this.cleanupTimeoutMs = options.cleanupTimeoutMs ?? DEFAULT_CLEANUP_TIMEOUT_MS this.liveFrameDelayMs = options.liveFrameDelayMs ?? 100 - this.createSession = options.createSession ?? defaultCreateSession + this.createSession = + options.createSession ?? + ((policy, signal) => + defaultCreateSession(policy, signal, options.parentWindow)) this.createDriver = options.createDriver ?? defaultCreateDriver if ( !Number.isSafeInteger(this.maximumSessions) || @@ -198,6 +211,9 @@ export class BrowserService { conversationId, status, ...(previous?.url ? { url: previous.url } : {}), + ...(status !== 'stopped' && previous?.frameDataUrl + ? { frameDataUrl: previous.frameDataUrl } + : {}), ...update, updatedAt: Date.now() } @@ -235,40 +251,73 @@ export class BrowserService { signal ) } - const previewController = new AbortController() - const timeout = setTimeout( - () => - previewController.abort( - new Error('浏览器实时画面捕获超时') - ), - 2_000 - ) - try { - const previewSignal = AbortSignal.any([ - signal, - previewController.signal - ]) - frame = slot.session.captureScreenshot - ? await slot.session.captureScreenshot(previewSignal) - : await slot.driver.screenshot(previewSignal) - } catch { - signal.throwIfAborted() - // Browser control succeeds even when the optional live frame fails. - } finally { - clearTimeout(timeout) + const captureDeadline = AbortSignal.any([ + signal, + AbortSignal.timeout(6_000) + ]) + for (let attempt = 0; attempt < 3 && !frame; attempt += 1) { + if (attempt > 0) { + try { + await waitFor( + new Promise((resolve) => + setTimeout(resolve, attempt * 150) + ), + captureDeadline + ) + } catch { + signal.throwIfAborted() + break + } + } + if (slot.session.captureScreenshot) { + try { + frame = await slot.session.captureScreenshot( + AbortSignal.any([ + captureDeadline, + AbortSignal.timeout(1_500) + ]) + ) + } catch { + signal.throwIfAborted() + } + } + if (!frame && !captureDeadline.aborted) { + try { + frame = await slot.driver.screenshot( + AbortSignal.any([ + captureDeadline, + AbortSignal.timeout(1_500) + ]) + ) + } catch { + signal.throwIfAborted() + } + } } } signal.throwIfAborted() if (slot.released || this.slots.get(conversationId) !== slot) { return } + if (!frame) { + const previousFrame = + this.liveStates.get(conversationId)?.frameDataUrl + if (previousFrame) { + this.emitState(conversationId, 'ready', { + ...(url ? { url } : {}), + frameDataUrl: previousFrame + }) + return + } + this.emitState(conversationId, 'failed', { + ...(url ? { url } : {}), + error: '页面已就绪,但实时画面捕获失败,请重试浏览器操作' + }) + return + } this.emitState(conversationId, 'ready', { ...(url ? { url } : {}), - ...(frame - ? { - frameDataUrl: `data:${frame.mimeType};base64,${frame.data}` - } - : {}) + frameDataUrl: `data:${frame.mimeType};base64,${frame.data}` }) } @@ -429,7 +478,7 @@ export class BrowserService { slot: BrowserSlot, signal: AbortSignal, operation: (effectiveSignal: AbortSignal) => Promise, - status?: 'loading' | 'acting' + status?: 'loading' | 'acting' | 'interactive' ): Promise { signal.throwIfAborted() if (slot.released || this.disposed) { @@ -496,7 +545,7 @@ export class BrowserService { private async runInSession( conversationId: string, signal: AbortSignal, - status: 'loading' | 'acting', + status: 'loading' | 'acting' | 'interactive', failureStage: string, operation: ( slot: BrowserSlot, @@ -715,10 +764,17 @@ export class BrowserService { '浏览器截图', async (slot, effectiveSignal) => { await this.verifyCurrentOriginOrRelease(slot) - const screenshot = - slot.session.captureScreenshot - ? await slot.session.captureScreenshot(effectiveSignal) - : await slot.driver.screenshot(effectiveSignal) + let screenshot: BrowserScreenshot | undefined + if (slot.session.captureScreenshot) { + try { + screenshot = await slot.session.captureScreenshot( + effectiveSignal + ) + } catch { + effectiveSignal.throwIfAborted() + } + } + screenshot ??= await slot.driver.screenshot(effectiveSignal) await this.captureFrame( conversationId, slot, @@ -731,6 +787,36 @@ export class BrowserService { ) } + async interact( + conversationId: string, + signal: AbortSignal + ): Promise { + await this.runInSession( + conversationId, + signal, + 'interactive', + '浏览器交互', + async (slot, effectiveSignal) => { + await this.verifyCurrentOriginOrRelease(slot) + const closingFrame = await waitFor( + slot.session.openInteraction(), + effectiveSignal + ) + const currentUrl = canonicalizeBrowserUrl( + slot.session.webContents.getURL() + ) + slot.origin = currentUrl.origin + await this.captureFrame( + conversationId, + slot, + effectiveSignal, + currentUrl.href, + closingFrame + ) + } + ) + } + async releaseConversation(conversationId: string): Promise { this.releaseRequests.add(conversationId) let releasedSlot = false diff --git a/src/main/browser/cdp-browser-driver.test.ts b/src/main/browser/cdp-browser-driver.test.ts index d2030da..967cc44 100644 --- a/src/main/browser/cdp-browser-driver.test.ts +++ b/src/main/browser/cdp-browser-driver.test.ts @@ -536,7 +536,7 @@ describe('CdpBrowserDriver', () => { driver.dispose() }) - it('rejects password, file, hidden, and stale typing targets', async () => { + it('allows password typing while keeping the password value redacted', async () => { const harness = createHarness(standardCommand) const driver = new CdpBrowserDriver(harness.webContents) const snapshot = await driver.snapshot(new AbortController().signal) @@ -545,15 +545,16 @@ describe('CdpBrowserDriver', () => { throw new Error('password missing') } await expect( - driver.type(password.ref, 'never-send', new AbortController().signal) - ).rejects.toThrow('受保护') + driver.type(password.ref, 'login-secret', new AbortController().signal) + ).resolves.toBeUndefined() expect( harness.sendCommand.mock.calls.some( ([method, parameters]) => method === 'Input.insertText' && - parameters?.text === 'never-send' + parameters?.text === 'login-secret' ) - ).toBe(false) + ).toBe(true) + expect(JSON.stringify(snapshot)).not.toContain('secret') driver.dispose() }) diff --git a/src/main/browser/cdp-browser-driver.ts b/src/main/browser/cdp-browser-driver.ts index 796aba3..8f24aa7 100644 --- a/src/main/browser/cdp-browser-driver.ts +++ b/src/main/browser/cdp-browser-driver.ts @@ -88,7 +88,6 @@ type RefBinding = { backendNodeId: number generation: number role: string - protected: boolean } export type CdpBrowserDriverOptions = { @@ -591,8 +590,7 @@ export class CdpBrowserDriver { this.refs.set(ref, { backendNodeId: node.backendDOMNodeId, generation: this.generation, - role, - protected: protectedNode + role }) output.push(item) } @@ -648,7 +646,6 @@ export class CdpBrowserDriver { typeof node.nodeName === 'string' ? node.nodeName.toLowerCase() : '' const inputType = (attributeMap.get('type') ?? '').toLowerCase() const blocked = - binding.protected || attributeMap.has('hidden') || attributeMap.has('disabled') || attributeMap.has('inert') || @@ -656,10 +653,9 @@ export class CdpBrowserDriver { attributeMap.get('aria-hidden') === 'true' || attributeMap.get('aria-disabled') === 'true' || inputType === 'hidden' || - inputType === 'password' || inputType === 'file' if (blocked) { - throw new Error('浏览器拒绝操作受保护、隐藏或禁用字段') + throw new Error('浏览器拒绝操作隐藏、禁用、只读或文件字段') } if ( action === 'type' && diff --git a/src/main/browser/electron-browser-session.test.ts b/src/main/browser/electron-browser-session.test.ts index 5890370..a23d287 100644 --- a/src/main/browser/electron-browser-session.test.ts +++ b/src/main/browser/electron-browser-session.test.ts @@ -21,6 +21,7 @@ function createHarness() { const debuggerEvents = new EventEmitter() const contentEvents = new EventEmitter() const partitionEvents = new EventEmitter() + const windowEvents = new EventEmitter() let currentUrl = '' let openHandler: ((details: { url: string }) => { action: 'deny' }) | undefined const sendCommand = vi.fn(async () => ({})) @@ -71,6 +72,21 @@ function createHarness() { loadURL: vi.fn(async (url: string) => { currentUrl = url }), + show: vi.fn(), + minimize: vi.fn(), + restore: vi.fn(), + isMinimized: vi.fn(() => false), + focus: vi.fn(), + on: (event, listener) => + windowEvents.on( + event, + listener as (...argumentsValue: unknown[]) => void + ), + off: (event, listener) => + windowEvents.off( + event, + listener as (...argumentsValue: unknown[]) => void + ), destroy: vi.fn(), isDestroyed: vi.fn(() => false) } @@ -125,6 +141,7 @@ function createHarness() { contentEvents, debuggerEvents, partitionEvents, + windowEvents, partition, proxy, policy, @@ -276,6 +293,74 @@ describe('ElectronBrowserSession', () => { await session.dispose() }) + it('restores the browser for interaction and minimizes it on close', async () => { + const harness = createHarness() + const parentWindow = { + setEnabled: vi.fn(), + focus: vi.fn(), + isDestroyed: vi.fn(() => false) + } + let createdWindowOptions: Record | undefined + const createWindow = vi.fn( + async (options: Record) => { + createdWindowOptions = options + return harness.window + } + ) + const session = await ElectronBrowserSession.create({ + policy: harness.policy, + parentWindow, + createPartition: async () => harness.partition, + createWindow, + createProxy: () => harness.proxy + }) + + expect(createWindow).toHaveBeenCalledWith( + expect.objectContaining({ + parent: parentWindow, + show: false, + title: 'GoodBuddy 浏览器交互' + }) + ) + expect(createdWindowOptions?.modal).toBeUndefined() + const interaction = session.openInteraction() + expect(parentWindow.setEnabled).toHaveBeenCalledWith(false) + expect(harness.window.show).toHaveBeenCalledOnce() + expect(harness.window.focus).toHaveBeenCalledOnce() + const closeEvent = { preventDefault: vi.fn() } + harness.windowEvents.emit('close', closeEvent) + + await expect(interaction).resolves.toEqual({ + type: 'image', + mimeType: 'image/jpeg', + data: '/9j/2Q==' + }) + expect(closeEvent.preventDefault).toHaveBeenCalledOnce() + expect(harness.webContents.capturePage).toHaveBeenCalledOnce() + expect(harness.window.minimize).toHaveBeenCalledOnce() + expect(parentWindow.setEnabled).toHaveBeenLastCalledWith(true) + expect(parentWindow.focus).toHaveBeenCalledOnce() + expect( + vi.mocked(harness.webContents.capturePage!).mock + .invocationCallOrder[0] + ).toBeLessThan( + vi.mocked(harness.window.minimize).mock.invocationCallOrder[0] ?? + Number.POSITIVE_INFINITY + ) + const repeatedCloseEvent = { preventDefault: vi.fn() } + harness.windowEvents.emit('close', repeatedCloseEvent) + expect(repeatedCloseEvent.preventDefault).toHaveBeenCalledOnce() + expect(harness.window.minimize).toHaveBeenCalledTimes(2) + expect(harness.window.destroy).not.toHaveBeenCalled() + vi.mocked(harness.window.isMinimized).mockReturnValue(true) + const reopenedInteraction = session.openInteraction() + expect(harness.window.restore).toHaveBeenCalledOnce() + harness.windowEvents.emit('close', { preventDefault: vi.fn() }) + await reopenedInteraction + await session.dispose() + expect(harness.window.destroy).toHaveBeenCalledOnce() + }) + it('detaches listeners and clears isolated data on idempotent disposal', async () => { const harness = createHarness() const session = await ElectronBrowserSession.create({ diff --git a/src/main/browser/electron-browser-session.ts b/src/main/browser/electron-browser-session.ts index 68cbb0f..c48f79c 100644 --- a/src/main/browser/electron-browser-session.ts +++ b/src/main/browser/electron-browser-session.ts @@ -49,10 +49,23 @@ export type BrowserWebContents = { export type BrowserWindowHandle = { webContents: BrowserWebContents loadURL(url: string): Promise + show(): void + minimize(): void + restore(): void + isMinimized(): boolean + focus(): void + on(event: string, listener: BrowserEventListener): unknown + off(event: string, listener: BrowserEventListener): unknown destroy(): void isDestroyed(): boolean } +export type BrowserParentWindowHandle = { + setEnabled?(enabled: boolean): void + focus?(): void + isDestroyed?(): boolean +} + export type BrowserPartitionSession = { setPermissionCheckHandler( handler: (...argumentsValue: never[]) => boolean @@ -100,6 +113,7 @@ export type ElectronBrowserSessionOptions = { options: Record ) => Promise createProxy?: (policy: BrowserUrlPolicy) => FilteringProxyLike + parentWindow?: BrowserParentWindowHandle } type Listener = { @@ -211,6 +225,11 @@ export class ElectronBrowserSession { readonly webContents: BrowserWebContents private approvedOrigin?: string private readonly listeners: Listener[] = [] + private interaction?: { + promise: Promise + resolve(frame?: BrowserScreenshot): void + } + private interactionClosing?: Promise private disposed = false private constructor( @@ -219,7 +238,8 @@ export class ElectronBrowserSession { private readonly window: BrowserWindowHandle, private readonly proxy: FilteringProxyLike, partition: string, - private readonly cleanupTimeoutMs: number + private readonly cleanupTimeoutMs: number, + private readonly parentWindow?: BrowserParentWindowHandle ) { this.partition = partition this.webContents = window.webContents @@ -301,6 +321,13 @@ export class ElectronBrowserSession { show: false, width: 1280, height: 900, + title: 'GoodBuddy 浏览器交互', + autoHideMenuBar: true, + ...(options.parentWindow + ? { + parent: options.parentWindow + } + : {}), webPreferences: { partition, sandbox: true, @@ -308,6 +335,7 @@ export class ElectronBrowserSession { nodeIntegration: false, nodeIntegrationInSubFrames: false, nodeIntegrationInWorker: false, + backgroundThrottling: false, webSecurity: true, allowRunningInsecureContent: false, plugins: false, @@ -337,7 +365,8 @@ export class ElectronBrowserSession { window, managedProxy, partition, - cleanupTimeoutMs + cleanupTimeoutMs, + options.parentWindow ) setupStage = '初始化浏览器协议' await boundedSetup(result.initialize(), signal, setupTimeoutMs) @@ -383,6 +412,21 @@ export class ElectronBrowserSession { private async initialize(): Promise { const contents = this.webContents + this.listen( + this.window, + 'close', + (event: { preventDefault(): void }) => { + if (this.disposed) { + return + } + event.preventDefault() + if (this.interaction) { + void this.captureAndFinishInteraction() + } else { + this.window.minimize() + } + } + ) contents.setWindowOpenHandler(() => ({ action: 'deny' })) this.listen(contents, 'will-navigate', (event: { preventDefault(): void }, details: { url?: string } | string) => { const url = typeof details === 'string' ? details : details.url @@ -513,12 +557,98 @@ export class ElectronBrowserSession { this.approvedOrigin = target.origin } + openInteraction(): Promise { + this.assertOpen() + if (this.interaction) { + this.setParentEnabled(false) + if (this.window.isMinimized()) { + this.window.restore() + } + this.window.show() + this.window.focus() + return this.interaction.promise + } + let resolve!: (frame?: BrowserScreenshot) => void + const promise = new Promise( + (resolvePromise) => { + resolve = resolvePromise + } + ) + this.interaction = { promise, resolve } + this.setParentEnabled(false) + try { + if (this.window.isMinimized()) { + this.window.restore() + } + this.window.show() + this.window.focus() + } catch (error) { + this.finishInteraction() + throw error + } + return promise + } + + private captureAndFinishInteraction(): Promise { + if (this.interactionClosing) { + return this.interactionClosing + } + const operation = (async (): Promise => { + let frame: BrowserScreenshot | undefined + try { + frame = await this.captureScreenshot(AbortSignal.timeout(2_000)) + } catch { + // The session remains usable even if the final visible frame fails. + } + try { + if (!this.disposed && !this.window.isDestroyed()) { + this.window.minimize() + } + } catch { + // Resolving interaction must not depend on native minimize success. + } + this.finishInteraction(frame) + })() + this.interactionClosing = operation + void operation.finally(() => { + if (this.interactionClosing === operation) { + this.interactionClosing = undefined + } + }) + return operation + } + + private finishInteraction(frame?: BrowserScreenshot): void { + const interaction = this.interaction + this.interaction = undefined + this.setParentEnabled(true) + interaction?.resolve(frame) + } + + private setParentEnabled(enabled: boolean): void { + try { + if ( + !this.parentWindow || + this.parentWindow.isDestroyed?.() === true + ) { + return + } + this.parentWindow.setEnabled?.(enabled) + if (enabled) { + this.parentWindow.focus?.() + } + } catch { + // Parent-window state must not break browser-session cleanup. + } + } + async dispose(): Promise { if (this.disposed) { return } this.disposed = true this.approvedOrigin = undefined + this.finishInteraction() for (const { target, event, listener } of this.listeners.splice(0)) { target.off(event, listener) } diff --git a/src/main/index.ts b/src/main/index.ts index 86da6a2..ea43b7d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -303,7 +303,7 @@ if (hasSingleInstanceLock) { const speechTranscriptionService = new SpeechTranscriptionService( speechModelManager ) - browserService = new BrowserService() + browserService = new BrowserService({ parentWindow: mainWindow }) const bundledRuntimePaths = resolveBundledRuntimePaths({ appPath: app.getAppPath(), resourcesPath: process.resourcesPath, diff --git a/src/main/ipc.test.ts b/src/main/ipc.test.ts index c4837e8..638396d 100644 --- a/src/main/ipc.test.ts +++ b/src/main/ipc.test.ts @@ -93,6 +93,7 @@ describe('registerIpcHandlers computer capabilities', () => { })) } const onRuntimeSettingsChanged = vi.fn(async () => {}) + const interact = vi.fn(async () => {}) const releaseConversation = vi.fn(async () => {}) let browserStateListener: | ((state: BrowserLiveState) => void) @@ -111,6 +112,7 @@ describe('registerIpcHandlers computer capabilities', () => { onRuntimeSettingsChanged, undefined, { + interact, releaseConversation, onState: (listener) => { browserStateListener = listener @@ -181,6 +183,15 @@ describe('registerIpcHandlers computer capabilities', () => { expect(releaseConversation).toHaveBeenCalledWith( 'browser-conversation' ) + await expect( + electronMocks.handlers.get(ipcChannels.browserInteract)?.(event, { + conversationId: 'browser-conversation' + }) + ).resolves.toBeUndefined() + expect(interact).toHaveBeenCalledWith( + 'browser-conversation', + expect.any(AbortSignal) + ) expect(() => electronMocks.handlers.get( @@ -2019,7 +2030,25 @@ describe('registerIpcHandlers agent terminal state', () => { await harness.dispose() }) - it('redacts runtime errors before persistence and renderer delivery', async () => { + it('preserves bounded runtime errors for persistence and renderer delivery', async () => { + const fetchCause = Object.assign( + new Error('connect ECONNREFUSED 127.0.0.1:11434'), + { + code: 'ECONNREFUSED', + syscall: 'connect', + address: '127.0.0.1', + port: 11434 + } + ) + const expectedError = [ + 'fetch failed', + 'cause:', + 'connect ECONNREFUSED 127.0.0.1:11434', + 'code: ECONNREFUSED', + 'syscall: connect', + 'address: 127.0.0.1', + 'port: 11434' + ].join('\n') const runtime = { capability: 'chat', requiresToolApproval: false, @@ -2028,9 +2057,7 @@ describe('registerIpcHandlers agent terminal state', () => { dispose: vi.fn(), async *run() { yield* [] - throw new Error( - 'gateway failed Authorization: Bearer secret-token' - ) + throw new TypeError('fetch failed', { cause: fetchCause }) } } const harness = createHarness(runtime) @@ -2047,13 +2074,13 @@ describe('registerIpcHandlers agent terminal state', () => { expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith( requestId, 'failed', - 'gateway failed Authorization: [REDACTED]' + expectedError ) ) expect(harness.webContents.send).toHaveBeenCalledWith( ipcChannels.agentEvent, expect.objectContaining({ - message: 'gateway failed Authorization: [REDACTED]' + message: expectedError }) ) await harness.dispose() diff --git a/src/main/ipc.ts b/src/main/ipc.ts index d5507a8..e06da5c 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -15,6 +15,7 @@ import { approvalDecisionSchema, agentQuestionResponseSchema, agentRequestSchema, + browserInteractRequestSchema, browserStopRequestSchema, knowledgeCreateSchema, knowledgeEntityUpdateSchema, @@ -491,6 +492,10 @@ export function registerIpcHandlers( onRuntimeSettingsChanged: () => Promise, onBeforeClearLocalData?: () => Promise, browserControl?: { + interact( + conversationId: string, + signal: AbortSignal + ): Promise releaseConversation(conversationId: string): Promise onState(listener: (state: BrowserLiveState) => void): () => void }, @@ -1193,6 +1198,18 @@ export function registerIpcHandlers( ]) }) + ipcMain.handle( + ipcChannels.browserInteract, + async (event, input: unknown) => { + assertTrustedSender(event, window) + const request = browserInteractRequestSchema.parse(input) + await browserControl?.interact( + request.conversationId, + new AbortController().signal + ) + } + ) + ipcMain.handle(ipcChannels.agentRun, async (event, input: unknown) => { assertTrustedSender(event, window) if (executionPaused || shuttingDown) { diff --git a/src/preload/index.ts b/src/preload/index.ts index 6d2f0ac..2c7076b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -159,6 +159,12 @@ const desktopApi: DesktopApi = { } }, browser: { + interact: async (conversationId: string) => { + await ipcRenderer.invoke( + ipcChannels.browserInteract, + { conversationId } + ) + }, stop: async (conversationId: string) => { await ipcRenderer.invoke( ipcChannels.browserStop, diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 4ffd525..835cdae 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -94,6 +94,7 @@ const api: DesktopApi = { }) }, browser: { + interact: vi.fn(async () => {}), stop: vi.fn(async () => {}), onState: vi.fn((listener) => { browserListener = listener @@ -828,7 +829,18 @@ describe('App', () => { callId: 'call-1', name: 'read', state: 'running', - summary: 'OpenCode 工具:read' + summary: 'OpenCode 工具:read', + input: '{"path":"README.md"}' + }) + agentListener?.({ + requestId: request.requestId, + type: 'tool', + callId: 'call-2', + name: 'grep', + state: 'completed', + summary: 'OpenCode 工具:grep', + input: '{"pattern":"runtime"}', + output: 'src/main/agent/runtime.ts' }) agentListener?.({ requestId: request.requestId, @@ -846,7 +858,10 @@ describe('App', () => { callId: 'call-1', name: 'read', state: 'completed', - summary: 'OpenCode 工具:read' + summary: 'OpenCode 工具:read', + input: '{"path":"README.md"}', + output: + 'README contents\nAuthorization: Bearer visible-token' }) }) @@ -866,6 +881,26 @@ describe('App', () => { expect( screen.getAllByText('OpenCode 工具:read') ).toHaveLength(1) + expect( + screen.getByRole('region', { name: '工具执行,共 2 项' }) + ).toBeInTheDocument() + const readTool = screen.getByText('read').closest('details') + const rawToolOutput = + 'README contents\nAuthorization: Bearer visible-token' + expect(readTool).not.toHaveAttribute('open') + expect( + screen.getByText( + (_, element) => element?.textContent === rawToolOutput + ) + ).not.toBeVisible() + fireEvent.click(screen.getByText('read').closest('summary')!) + expect(readTool).toHaveAttribute('open') + expect(within(readTool!).getByText('调用参数')).toBeVisible() + expect( + within(readTool!).getByText( + (_, element) => element?.textContent === rawToolOutput + ) + ).toBeVisible() act(() => { if (!request) { @@ -1040,8 +1075,19 @@ describe('App', () => { render() fireEvent.click(await screen.findByLabelText('添加附件')) - expect(await screen.findByText('需求说明.md')).toBeInTheDocument() - expect(screen.getByText('页面截图.png')).toBeInTheDocument() + const composer = screen + .getByLabelText('向 GoodBuddy 提问') + .closest('.composer') + expect(composer).not.toBeNull() + if (!composer) { + return + } + expect( + await within(composer).findByText('需求说明.md') + ).toBeInTheDocument() + expect( + within(composer).getByText('页面截图.png') + ).toBeInTheDocument() fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { target: { value: '分析这些附件' } }) @@ -1130,8 +1176,17 @@ describe('App', () => { render() fireEvent.click(await screen.findByLabelText('添加附件')) + const composer = screen + .getByLabelText('向 GoodBuddy 提问') + .closest('.composer') + expect(composer).not.toBeNull() + if (!composer) { + return + } await waitFor(() => - expect(screen.getAllByText(/^参考图-\d\.png$/u)).toHaveLength(5) + expect( + within(composer).getAllByText(/^参考图-\d\.png$/u) + ).toHaveLength(5) ) fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { target: { value: '比较这五张图片' } @@ -1188,8 +1243,15 @@ describe('App', () => { await waitFor(() => expect(api.context.captureWindow).toHaveBeenCalledWith('window-2') ) + const composer = screen + .getByLabelText('向 GoodBuddy 提问') + .closest('.composer') + expect(composer).not.toBeNull() + if (!composer) { + return + } expect( - await screen.findByText('窗口-Browser.jpg') + await within(composer).findByText('窗口-Browser.jpg') ).toBeInTheDocument() }) @@ -1335,10 +1397,22 @@ describe('App', () => { expect( await screen.findByRole('heading', { name: '工作区说明' }) ).toBeInTheDocument() + expect( + screen.getByRole('tab', { name: '工作区' }) + ).toHaveAttribute('aria-selected', 'true') + expect( + screen.queryByRole('tab', { name: '预览' }) + ).not.toBeInTheDocument() expect(api.workspace.readFile).toHaveBeenCalledWith( projectId, 'README.md' ) + fireEvent.click( + screen.getByRole('button', { name: '返回工作区' }) + ) + expect( + await screen.findByRole('button', { name: 'README.md' }) + ).toBeInTheDocument() }) it('opens workspace entries from their row actions', async () => { @@ -2986,19 +3060,31 @@ describe('App', () => { fireEvent.click(screen.getByLabelText('切换助手工作栏')) expect(sidebar).toHaveClass('assistant-sidebar--open') + expect( + screen.getByRole('tab', { name: '任务中心' }) + ).toHaveAttribute('aria-selected', 'true') + expect(screen.getByText('自动化')).toBeInTheDocument() + expect(screen.queryByText('最近任务')).not.toBeInTheDocument() fireEvent.click(screen.getByRole('tab', { name: '上下文' })) expect( screen.getByText('尚未添加文件、截图或剪贴板内容。') ).toBeInTheDocument() - fireEvent.click(screen.getByRole('tab', { name: '任务中心' })) + fireEvent.click(screen.getByRole('tab', { name: '工作区' })) expect( - screen.getByText(/查看当前和最近请求的运行状态/) + screen.getByText(/选择文件后在当前工作区内预览/) ).toBeInTheDocument() - fireEvent.click(screen.getByRole('tab', { name: '成果库' })) + fireEvent.click(screen.getByRole('tab', { name: '浏览器' })) + expect( + screen.getByText(/Agent 打开网页后/) + ).toBeInTheDocument() + fireEvent.click(screen.getByRole('tab', { name: '成果' })) expect(screen.getByText('对话与导入成果')).toBeInTheDocument() expect( - screen.getByText(/保存并预览由对话生成或手动导入/) + screen.getByText(/查看并预览由对话生成或手动导入/) ).toBeInTheDocument() + expect( + screen.queryByRole('tab', { name: '预览' }) + ).not.toBeInTheDocument() fireEvent.click(screen.getByLabelText('关闭助手工作栏')) expect(sidebar).not.toHaveClass('assistant-sidebar--open') }) @@ -3036,6 +3122,12 @@ describe('App', () => { 'src', 'data:image/jpeg;base64,/9j/2Q==' ) + fireEvent.click( + screen.getByRole('button', { name: '交互' }) + ) + await waitFor(() => + expect(api.browser.interact).toHaveBeenCalledWith(conversationId) + ) fireEvent.click( screen.getByRole('button', { name: '停止浏览器' }) ) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 7df5ac7..f8b64ba 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -471,6 +471,107 @@ function terminalizeMessageToolBlocks( ) } +type MessageBlockRenderItem = + | { + kind: 'block' + block: Exclude + } + | { + kind: 'tools' + id: string + tools: ToolActivity[] + } + +function groupMessageBlocks( + blocks: ConversationMessageBlock[] +): MessageBlockRenderItem[] { + const items: MessageBlockRenderItem[] = [] + for (const block of blocks) { + if (block.type !== 'tool') { + items.push({ kind: 'block', block }) + continue + } + const previous = items.at(-1) + if (previous?.kind === 'tools') { + previous.tools.push(block.tool) + } else { + items.push({ + kind: 'tools', + id: block.id, + tools: [block.tool] + }) + } + } + return items +} + +function ToolExecutionList({ + tools +}: { + tools: ToolActivity[] +}): React.JSX.Element { + return ( +
+
+
+
    + {tools.map((tool) => { + const hasDetails = Boolean( + tool.input || tool.output || tool.error + ) + return ( +
  1. +
    + + + {tool.name} + {tool.summary} + + {toolStateLabels[tool.state]} + +
    + {tool.input && ( +
    + 调用参数 +
    {tool.input}
    +
    + )} + {tool.output && ( +
    + 执行结果 +
    {tool.output}
    +
    + )} + {tool.error && ( +
    + 错误详情 +
    {tool.error}
    +
    + )} + {!hasDetails &&

    暂时没有可显示的执行详情。

    } +
    +
    +
  2. + ) + })} +
+
+ ) +} + function createConversation( projectId?: string, runtimeSelection?: AgentRuntimeSelection @@ -1829,6 +1930,8 @@ function App(): React.JSX.Element { name: event.name, state: event.state, summary: event.summary, + input: event.input, + output: event.output, error: event.error } if (index >= 0) { @@ -2241,7 +2344,7 @@ function App(): React.JSX.Element { ) useEffect(() => { - if (assistantSidebarTab !== 'changes') { + if (assistantSidebarTab !== 'workspace') { return } const timeout = setTimeout(() => { @@ -4151,14 +4254,20 @@ function App(): React.JSX.Element { )} {message.blocks && message.blocks.length > 0 ? (
- {message.blocks.map((block) => - block.type === 'reasoning' ? ( + {groupMessageBlocks(message.blocks).map((item) => + item.kind === 'tools' ? ( + + ) : item.block.type === 'reasoning' ? (
@@ -4168,34 +4277,18 @@ function App(): React.JSX.Element {
- {block.content} + {item.block.content}
- ) : block.type === 'text' ? ( -
- - {block.content} - -
) : (
- -
- {block.tool.summary} - {block.tool.error && ( - {block.tool.error} - )} -
- - {toolStateLabels[block.tool.state]} - + + {item.block.content} +
) )} @@ -4347,19 +4440,10 @@ function App(): React.JSX.Element { )} {(!message.blocks || message.blocks.length === 0) && - message.tools?.map((tool) => ( -
- -
- {tool.summary} - {tool.error && {tool.error}} -
- {toolStateLabels[tool.state]} -
- ))} + message.tools && + message.tools.length > 0 && ( + + )} {message.subagents && message.subagents.length > 0 && (
)} setAssistantSidebarOpen(false)} + onInteractBrowser={async () => { + if (!activeId) { + return + } + const browserApi = window.goodbuddy.browser + if (!browserApi) { + notify({ + tone: 'error', + message: '浏览器控制组件尚未加载,请重启 GoodBuddy' + }) + return + } + await browserApi.interact(activeId) + }} onStopBrowser={async () => { if (!activeId) { return @@ -5324,16 +5420,6 @@ function App(): React.JSX.Element { }) } }} - onOpenHeartbeat={() => setView('heartbeat')} - onCreateMemory={async (content) => { - const memory = await window.goodbuddy.memory.create({ - scope: activeProjectId ? 'project' : 'global', - scopeId: activeProjectId || undefined, - type: 'preference', - content - }) - setAssistantMemories((current) => [memory, ...current]) - }} onCreateHeartbeat={createHeartbeat} onCreateSchedule={async (input) => { const schedule = await window.goodbuddy.schedules.create({ @@ -5342,7 +5428,6 @@ function App(): React.JSX.Element { }) setAssistantSchedules((current) => [schedule, ...current]) }} - onOpenConversation={openActivityConversation} onImportArtifacts={async () => { const imported = await window.goodbuddy.artifacts.importFiles( activeProjectId || undefined @@ -5352,7 +5437,7 @@ function App(): React.JSX.Element { ...imported, ...current ]) - setAssistantSidebarTab('artifacts') + setAssistantSidebarTab('results') } }} onLoadArtifact={async (artifactId) => { @@ -5367,13 +5452,6 @@ function App(): React.JSX.Element { ) }} onRemoveAttachment={removeAttachment} - onRemoveMemory={async (memoryId) => { - await window.goodbuddy.memory.remove(memoryId) - setAssistantMemories((current) => - current.filter((memory) => memory.id !== memoryId) - ) - }} - onSetMemoryStatus={setMemoryStatus} onRemoveHeartbeat={removeHeartbeat} onRemoveSchedule={async (scheduleId) => { await window.goodbuddy.schedules.remove(scheduleId) @@ -5381,16 +5459,6 @@ function App(): React.JSX.Element { current.filter((schedule) => schedule.id !== scheduleId) ) }} - onRunSchedule={async (scheduleId) => { - await window.goodbuddy.schedules.runNow(scheduleId) - notify({ tone: 'success', message: '定时任务已开始执行' }) - }} - onRunHeartbeat={runHeartbeat} - onSetHeartbeatPaused={setHeartbeatPaused} - onListWorkspaceDirectory={listWorkspaceDirectory} - onLoadWorkspaceFile={loadWorkspaceFile} - onOpenWorkspaceEntry={openWorkspaceEntry} - onRefreshChanges={refreshWorkspaceChanges} onRespondApproval={(approval, decision) => { void respondToApproval( approval.conversationId, @@ -5399,13 +5467,21 @@ function App(): React.JSX.Element { decision ) }} + onRunHeartbeat={runHeartbeat} + onRunSchedule={async (scheduleId) => { + await window.goodbuddy.schedules.runNow(scheduleId) + notify({ tone: 'success', message: '定时任务已开始执行' }) + }} + onSetHeartbeatPaused={setHeartbeatPaused} + onListWorkspaceDirectory={listWorkspaceDirectory} + onLoadWorkspaceFile={loadWorkspaceFile} + onOpenWorkspaceEntry={openWorkspaceEntry} + onRefreshChanges={refreshWorkspaceChanges} onTabChange={setAssistantSidebarTab} open={ assistantSidebarOpen && view === 'chat' } - schedules={assistantSchedules} tab={assistantSidebarTab} - tasks={assistantTasks} workspaceChanges={workspaceChanges} workspaceProjectId={activeProjectId || undefined} /> diff --git a/src/renderer/src/McpSettingsSection.tsx b/src/renderer/src/McpSettingsSection.tsx index 8bfddd7..c9c59b6 100644 --- a/src/renderer/src/McpSettingsSection.tsx +++ b/src/renderer/src/McpSettingsSection.tsx @@ -319,10 +319,14 @@ export function McpSettingsSection(): React.JSX.Element {

- 内置工具由 GoodBuddy 提供,不属于 MCP Server。外部 MCP Server - 及其工具具有当前用户权限,请仅添加可信服务;远程访问令牌将由系统安全存储加密。 - 当前版本仅由直连模型在 Execute 模式加载这些工具,并在每次调用前请求 - GoodBuddy 审批。 + 自定义 MCP 当前仅用于直连模型,新建时默认分配给直连模型,并仅在 Execute + 模式加载。内置共享 MCP 当前仅有知识库搜索,可供直连模型、OpenCode 和 + Continue 使用。Runtime 自有 MCP 配置不在此处管理。 +

+

+ 内置工具由 GoodBuddy 提供,不属于 MCP Server。自定义 MCP Server + 及其工具具有当前用户权限,请仅添加可信服务;远程访问令牌将由系统安全存储加密, + 工具调用前仍需 GoodBuddy 审批。

{error && !editor &&

{error}

} diff --git a/src/renderer/src/RightAssistantSidebar.resize.test.tsx b/src/renderer/src/RightAssistantSidebar.resize.test.tsx index 38d3ed5..cda5a5a 100644 --- a/src/renderer/src/RightAssistantSidebar.resize.test.tsx +++ b/src/renderer/src/RightAssistantSidebar.resize.test.tsx @@ -1,10 +1,10 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { - AssistantExpert, - AssistantTask -} from '../../shared/assistant-contracts' -import { RightAssistantSidebar } from './RightAssistantSidebar' +import { + RightAssistantSidebar, + type AssistantSidebarTab, + type SidebarArtifact +} from './RightAssistantSidebar' afterEach(cleanup) @@ -16,58 +16,48 @@ beforeEach(() => { }) function renderSidebar({ - tasks = [], - experts = [], tab = 'context', - onCreateSchedule = vi.fn(async () => undefined) + artifacts = [], + onLoadArtifact = vi.fn(async () => undefined) }: { - tasks?: AssistantTask[] - experts?: AssistantExpert[] - tab?: 'tasks' | 'context' - onCreateSchedule?: () => Promise + tab?: AssistantSidebarTab + artifacts?: SidebarArtifact[] + onLoadArtifact?: (artifactId: string) => Promise } = {}): HTMLElement { render( undefined)} - onCreateMemory={vi.fn(async () => undefined)} - onCreateSchedule={onCreateSchedule} + onCreateSchedule={vi.fn(async () => undefined)} onImportArtifacts={vi.fn(async () => undefined)} onListWorkspaceDirectory={vi.fn(async (path: string) => ({ path, entries: [], truncated: false }))} - onLoadArtifact={vi.fn(async () => undefined)} + onLoadArtifact={onLoadArtifact} onLoadWorkspaceFile={vi.fn()} onOpenWorkspaceEntry={vi.fn(async () => undefined)} - onOpenConversation={vi.fn()} - onOpenHeartbeat={vi.fn()} + onInteractBrowser={vi.fn(async () => undefined)} onRefreshChanges={vi.fn(async () => undefined)} onRemoveAttachment={vi.fn()} onRemoveHeartbeat={vi.fn(async () => undefined)} - onRemoveMemory={vi.fn(async () => undefined)} onRemoveSchedule={vi.fn(async () => undefined)} onRespondApproval={vi.fn()} onRunHeartbeat={vi.fn(async () => undefined)} onRunSchedule={vi.fn(async () => undefined)} onSetHeartbeatPaused={vi.fn(async () => undefined)} - onSetMemoryStatus={vi.fn(async () => undefined)} onStopBrowser={vi.fn(async () => undefined)} onTabChange={vi.fn()} open - schedules={[]} tab={tab} - tasks={tasks} /> ) @@ -165,80 +155,55 @@ describe('RightAssistantSidebar resizing', () => { ).toBe('424px') }) - it('indents child tasks and names their expert and routing mode', () => { - const parentTask: AssistantTask = { - id: 'parent-task', - conversationId: 'conversation-1', - title: '分析发布计划', - instructions: '分析发布计划', - origin: 'user', - status: 'running', - createdAt: '2026-08-01T00:00:00.000Z' - } - const childTask: AssistantTask = { - id: 'child-task', - conversationId: 'conversation-1', - parentTaskId: parentTask.id, - expertId: 'expert-1', - routingMode: 'smart', - title: '研究子任务', - instructions: '收集资料', - origin: 'subagent', - status: 'completed', - createdAt: '2026-08-01T00:01:00.000Z' - } - renderSidebar({ - tab: 'tasks', - tasks: [childTask, parentTask], - experts: [ - { - id: 'expert-1', - name: '研究专家', - description: '分析证据', - systemInstructions: 'Analyze evidence.', - routingKeywords: ['研究'], - enabled: true, - createdAt: '2026-08-01T00:00:00.000Z', - updatedAt: '2026-08-01T00:00:00.000Z' - } - ] - }) + it('exposes the task center and four reusable work surfaces', () => { + renderSidebar() - const taskButtons = screen.getAllByRole('button', { - name: /分析发布计划|研究子任务/u - }) - expect(taskButtons[0]).toHaveTextContent('分析发布计划') - expect(taskButtons[1]).toHaveClass('assistant-sidebar__row--subtask') - expect(taskButtons[1]).toHaveTextContent('子专家:研究专家 · 智能路由') + expect( + screen.getAllByRole('tab').map((tab) => tab.textContent) + ).toEqual(['任务中心', '上下文', '工作区', '浏览器', '成果']) + expect( + screen.queryByRole('tab', { name: '预览' }) + ).not.toBeInTheDocument() }) - it('preserves schedule input and reports a failed action', async () => { - const onCreateSchedule = vi.fn(async () => { - throw new Error('定时服务不可用') - }) - renderSidebar({ tab: 'tasks', onCreateSchedule }) + it('keeps automation in the task center without recent tasks', () => { + renderSidebar({ tab: 'tasks' }) - fireEvent.change(screen.getByLabelText('定时任务标题'), { - target: { value: '每日摘要' } - }) - fireEvent.change(screen.getByLabelText('定时任务内容'), { - target: { value: '总结今天的工作' } - }) - fireEvent.change(screen.getByLabelText('定时任务时间'), { - target: { value: '2026-08-06T09:00' } - }) - fireEvent.click( - screen.getByRole('button', { name: '添加定时任务' }) - ) + expect(screen.getByText('等待审批')).toBeInTheDocument() + expect(screen.getByText('自动化')).toBeInTheDocument() + expect( + screen.getByLabelText('定时任务标题') + ).toBeInTheDocument() + expect(screen.queryByText('最近任务')).not.toBeInTheDocument() + }) - expect(await screen.findByRole('alert')).toHaveTextContent( - '定时服务不可用' - ) - expect(screen.getByLabelText('定时任务标题')).toHaveValue( - '每日摘要' - ) - expect(screen.getByLabelText('定时任务内容')).toHaveValue( - '总结今天的工作' - ) + it('previews a result without switching to a separate tab', () => { + const onLoadArtifact = vi.fn(async () => undefined) + renderSidebar({ + tab: 'results', + artifacts: [ + { + id: 'artifact-1', + title: '发布说明', + content: '# 发布说明', + createdAt: Date.now(), + mimeType: 'text/markdown' + } + ], + onLoadArtifact + }) + + fireEvent.click(screen.getByRole('button', { name: /发布说明/u })) + + expect(onLoadArtifact).toHaveBeenCalledWith('artifact-1') + expect( + screen.getByRole('tab', { name: '成果' }) + ).toHaveAttribute('aria-selected', 'true') + expect( + screen.getByRole('button', { name: '返回成果列表' }) + ).toBeInTheDocument() + expect( + screen.queryByRole('tab', { name: '预览' }) + ).not.toBeInTheDocument() }) }) diff --git a/src/renderer/src/RightAssistantSidebar.tsx b/src/renderer/src/RightAssistantSidebar.tsx index 24840bc..0e9f5c8 100644 --- a/src/renderer/src/RightAssistantSidebar.tsx +++ b/src/renderer/src/RightAssistantSidebar.tsx @@ -1,29 +1,25 @@ import { CheckCircle2, + ChevronLeft, ChevronRight, - FileDiff, + ExternalLink, FileText, FolderTree, Hourglass, Monitor, PanelRightClose, - PlayCircle, RefreshCw, ShieldAlert, Upload, - X, - XCircle + X } from 'lucide-react' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import type { + AssistantHeartbeatConfig, AssistantMemory, AssistantSchedule, - AssistantHeartbeatConfig, - AssistantHeartbeatEntry, - AssistantExpert, HeartbeatCreateInput, ScheduleCreateInput, - AssistantTask, WorkspaceChanges, WorkspaceDirectoryListing, WorkspaceFilePreview @@ -35,17 +31,15 @@ import type { ContextAttachment, KnowledgeLibrary } from '../../shared/contracts' -import type { ActivityRecord } from './activity-store' import { HeartbeatSettings } from './HeartbeatSettings' import { WorkspaceFilesPanel } from './WorkspaceFilesPanel' export type AssistantSidebarTab = | 'tasks' | 'context' - | 'artifacts' - | 'changes' + | 'workspace' | 'browser' - | 'preview' + | 'results' export type SidebarArtifact = { id: string @@ -67,38 +61,24 @@ export type PendingSidebarApproval = { type RightAssistantSidebarProps = { open: boolean tab: AssistantSidebarTab - activities: ActivityRecord[] - tasks: AssistantTask[] - experts?: AssistantExpert[] + approvals: PendingSidebarApproval[] artifacts: SidebarArtifact[] attachments: ContextAttachment[] enabledLibraries: KnowledgeLibrary[] - approvals: PendingSidebarApproval[] + heartbeats: AssistantHeartbeatConfig[] memories: AssistantMemory[] schedules: AssistantSchedule[] - heartbeats: AssistantHeartbeatConfig[] - heartbeatEntries: AssistantHeartbeatEntry[] workspaceChanges?: WorkspaceChanges workspaceProjectId?: string browserState?: BrowserLiveState onClose: () => void + onInteractBrowser: () => Promise onStopBrowser: () => Promise - onOpenHeartbeat: () => void - onOpenConversation: (conversationId: string) => void + onCreateHeartbeat: (input: HeartbeatCreateInput) => Promise + onCreateSchedule: (input: ScheduleCreateInput) => Promise onImportArtifacts: () => Promise onLoadArtifact: (artifactId: string) => Promise onRemoveAttachment: (attachmentId: string) => void - onCreateMemory: (content: string) => Promise - onCreateSchedule: (input: ScheduleCreateInput) => Promise - onCreateHeartbeat: (input: HeartbeatCreateInput) => Promise - onSetHeartbeatPaused: ( - heartbeatId: string, - paused: boolean - ) => Promise - onRemoveHeartbeat: (heartbeatId: string) => Promise - onRunHeartbeat: (heartbeatId: string) => Promise - onRemoveSchedule: (scheduleId: string) => Promise - onRunSchedule: (scheduleId: string) => Promise onRefreshChanges: () => Promise onListWorkspaceDirectory: ( path: string @@ -108,15 +88,18 @@ type RightAssistantSidebarProps = { path: string, type: 'file' | 'directory' ) => Promise - onRemoveMemory: (memoryId: string) => Promise - onSetMemoryStatus: ( - memoryId: string, - status: AssistantMemory['status'] - ) => Promise + onRemoveHeartbeat: (heartbeatId: string) => Promise + onRemoveSchedule: (scheduleId: string) => Promise onRespondApproval: ( approval: PendingSidebarApproval, decision: ApprovalDecision ) => void + onRunHeartbeat: (heartbeatId: string) => Promise + onRunSchedule: (scheduleId: string) => Promise + onSetHeartbeatPaused: ( + heartbeatId: string, + paused: boolean + ) => Promise onTabChange: (tab: AssistantSidebarTab) => void } @@ -128,22 +111,17 @@ const tabs: Array<{ { id: 'tasks', label: '任务中心', - description: '查看运行状态、处理审批并安排自动化' + description: '处理待审批操作并管理自动化' }, { id: 'context', label: '上下文', - description: '管理本次对话的附件、知识库与长期记忆' + description: '查看本次对话使用的附件、知识库与记忆' }, { - id: 'artifacts', - label: '成果库', - description: '集中保存和打开对话生成或手动导入的内容' - }, - { - id: 'changes', + id: 'workspace', label: '工作区', - description: '浏览项目文件与工具活动' + description: '浏览项目文件、Git 变更与文件内容' }, { id: 'browser', @@ -151,9 +129,9 @@ const tabs: Array<{ description: '查看 Agent 操作网页时的实时画面' }, { - id: 'preview', - label: '预览', - description: '预览选中的成果或工作区文件' + id: 'results', + label: '成果', + description: '查看对话生成或手动导入的内容' } ] const emptyChangedFiles: WorkspaceChanges['files'] = [] @@ -189,87 +167,37 @@ function clampSidebarWidth(width: number, viewportWidth: number): number { return Math.min(limits.maximum, Math.max(limits.minimum, width)) } -function formatTime(timestamp: number | string): string { - return sidebarTimeFormatter.format(new Date(timestamp)) -} - -export function orderTasksWithChildren( - tasks: readonly AssistantTask[] -): AssistantTask[] { - const childIds = new Set( - tasks.flatMap((task) => (task.parentTaskId ? [task.id] : [])) - ) - const childrenByParent = new Map() - for (const task of tasks) { - if (!task.parentTaskId) { - continue - } - const children = childrenByParent.get(task.parentTaskId) ?? [] - children.push(task) - childrenByParent.set(task.parentTaskId, children) - } - const ordered: AssistantTask[] = [] - const included = new Set() - const append = (task: AssistantTask): void => { - if (included.has(task.id)) { - return - } - included.add(task.id) - ordered.push(task) - for (const child of childrenByParent.get(task.id) ?? []) { - append(child) - } - } - for (const task of tasks) { - if (!childIds.has(task.id)) { - append(task) - } - } - for (const task of tasks) { - append(task) - } - return ordered -} - export function RightAssistantSidebar({ open, tab, - activities, - tasks, - experts = [], + approvals, artifacts, attachments, enabledLibraries, - approvals, + heartbeats, memories, schedules, - heartbeats, - heartbeatEntries, workspaceChanges, workspaceProjectId, browserState, onClose, + onInteractBrowser, onStopBrowser, - onOpenHeartbeat, - onOpenConversation, + onCreateHeartbeat, + onCreateSchedule, onImportArtifacts, onLoadArtifact, onRemoveAttachment, - onCreateMemory, - onCreateSchedule, - onCreateHeartbeat, - onSetHeartbeatPaused, - onRemoveHeartbeat, - onRunHeartbeat, - onRemoveSchedule, - onRunSchedule, onRefreshChanges, onListWorkspaceDirectory, onLoadWorkspaceFile, onOpenWorkspaceEntry, - onRemoveMemory, - onSetMemoryStatus, + onRemoveHeartbeat, + onRemoveSchedule, onRespondApproval, + onRunHeartbeat, + onRunSchedule, + onSetHeartbeatPaused, onTabChange }: RightAssistantSidebarProps): React.JSX.Element { const [viewportWidth, setViewportWidth] = useState(window.innerWidth) @@ -303,7 +231,6 @@ export function RightAssistantSidebar({ } >() const workspacePreviewRequest = useRef(0) - const [memoryDraft, setMemoryDraft] = useState('') const [workspaceRefreshVersion, setWorkspaceRefreshVersion] = useState(0) const [scheduleTitle, setScheduleTitle] = useState('') const [schedulePrompt, setSchedulePrompt] = useState('') @@ -312,31 +239,11 @@ export function RightAssistantSidebar({ ScheduleCreateInput['recurrence'] >('once') const [actionError, setActionError] = useState('') - const recentTasks = useMemo( - () => - activities - .filter((activity) => activity.kind === 'request') - .slice(0, 20), - [activities] - ) - const orderedTasks = useMemo( - () => orderTasksWithChildren(tasks), - [tasks] - ) - const expertNames = useMemo( - () => new Map(experts.map((expert) => [expert.id, expert.name])), - [experts] - ) - const changes = useMemo( - () => - activities - .filter((activity) => activity.kind === 'tool') - .slice(0, 30), - [activities] + const activeMemories = memories.filter( + (memory) => memory.status === 'confirmed' ) const artifactPreview = - artifacts.find((artifact) => artifact.id === selectedArtifactId) ?? - artifacts[0] + artifacts.find((artifact) => artifact.id === selectedArtifactId) const currentWorkspacePreview = workspacePreview?.projectId === workspaceProjectId ? workspacePreview @@ -428,7 +335,6 @@ export function RightAssistantSidebar({ const projectId = workspaceProjectId setWorkspacePreview({ projectId, path, state: 'loading' }) setActionError('') - onTabChange('preview') void onLoadWorkspaceFile(path) .then((file) => { if (workspacePreviewRequest.current === requestId) { @@ -624,103 +530,49 @@ export function RightAssistantSidebar({ {tab === 'tasks' && (

- 查看当前和最近请求的运行状态、处理待审批操作,并安排定时任务与智能心跳。 + 处理当前待审批操作,并创建和管理自动化任务。

- {approvals.length > 0 && ( - <> -

- - 等待审批 -

- {approvals.map((approval) => ( -
- {approval.title} -

{approval.description}

- {approval.toolName && {approval.toolName}} -
- - -
-
- ))} - - )} -

- - 最近任务 + + 等待审批

- {tasks.length === 0 && recentTasks.length === 0 ? ( + {approvals.length === 0 ? (

- 发送请求后,任务状态会显示在这里。 + 当前没有等待审批的操作。

) : ( - (orderedTasks.length > 0 ? orderedTasks : recentTasks).map((task) => ( - + {approval.title} +

{approval.description}

+ {approval.toolName && {approval.toolName}} +
+ + +
+ )) )} +

自动化 @@ -775,9 +627,7 @@ export function RightAssistantSidebar({ prompt: schedulePrompt.trim(), workMode: 'ask', recurrence: scheduleRecurrence, - nextRunAt: new Date( - scheduleTime - ).toISOString() + nextRunAt: new Date(scheduleTime).toISOString() }), '添加定时任务失败', () => { @@ -843,6 +693,9 @@ export function RightAssistantSidebar({ {tab === 'context' && (
+

+ 查看当前对话实际使用的附件、知识库与已确认记忆。 +

本次附件 @@ -892,200 +745,76 @@ export function RightAssistantSidebar({ )}

- 长期记忆 + 已确认记忆

-
- setMemoryDraft(event.target.value)} - placeholder="例如:我偏好简洁的中文回复" - value={memoryDraft} - /> - -
- {memories.length === 0 ? ( + {activeMemories.length === 0 ? (

- 尚无已确认的长期记忆。 + 当前范围没有已确认的长期记忆。

) : ( - memories.map((memory) => ( + activeMemories.map((memory) => (
- - {memory.content} - {memory.status === 'proposed' && ( - 智能心跳建议,等待确认 - )} - -
- {memory.status === 'proposed' && ( - <> - - - - )} - -
-
- )) - )} -

- - 智能心跳 - -

- {heartbeatEntries.length === 0 ? ( -

- 完成智能心跳后,最近的成长摘要会显示在这里。 -

- ) : ( - heartbeatEntries.slice(0, 10).map((entry) => ( -
- - - {new Date(entry.createdAt).toLocaleString('zh-CN')} - - - {entry.proposedMemoryIds.length} 条记忆建议 ·{' '} - {entry.followUpTaskIds.length} 个后续任务 - - -

{entry.summary}

+ {memory.content}
)) )}

)} - {tab === 'artifacts' && ( -
-

- 保存并预览由对话生成或手动导入的文本、图片、PDF 与网页内容。 -

-

- - 对话与导入成果 -

- - {artifacts.length === 0 ? ( -

- 完成的回复会作为可预览成果显示在这里。 -

- ) : ( - artifacts.map((artifact) => ( + {tab === 'workspace' && ( + currentWorkspacePreview ? ( +
+
- )) - )} -
- )} - - {tab === 'changes' && ( - <> + + {currentWorkspacePreview.path} + + {currentWorkspacePreview.state === 'ready' + ? `${currentWorkspacePreview.file.size.toLocaleString('zh-CN')} 字节` + : '项目工作区文件'} + + + + {currentWorkspacePreview.state === 'loading' ? ( +

+ 正在读取文件… +

+ ) : currentWorkspacePreview.state === 'error' ? ( +

+ {currentWorkspacePreview.error} +

+ ) : ( +
+ {currentWorkspacePreview.file.mimeType === + 'text/markdown' ? ( + + {currentWorkspacePreview.file.content} + + ) : ( +
{currentWorkspacePreview.file.content}
+ )} +
+ )} +
+ ) : (

- 浏览当前项目文件,并查看 Agent 的工具活动。Git - 项目还会显示未提交更改。 + 浏览当前项目文件与 Git 变更;选择文件后在当前工作区内预览。

@@ -1132,33 +861,119 @@ export function RightAssistantSidebar({ )}

+ ) + )} + + {tab === 'results' && ( + artifactPreview ? ( +
+
+ + + {artifactPreview.title} + + {sidebarTimeFormatter.format( + new Date(artifactPreview.createdAt) + )} + + +
+
+ {artifactPreview.mimeType.startsWith('image/') ? ( + artifactPreview.content ? ( + {artifactPreview.title} + ) : ( +

+ 正在加载图片… +

+ ) + ) : artifactPreview.mimeType === 'text/html' ? ( +