feat: improve runtime visibility and browser interaction

This commit is contained in:
lofyer
2026-08-07 17:53:14 +08:00
parent 2c715e5e81
commit 53d18e2b06
36 changed files with 1680 additions and 907 deletions
+52 -3
View File
@@ -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')
)
})
})
+74 -25
View File
@@ -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<object>()
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<string, unknown>
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
}
+18 -6
View File
@@ -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' }
+36 -5
View File
@@ -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<string, unknown>).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)
}
]
}
+10 -2
View File
@@ -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',
+2
View File
@@ -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 } : {})
}
}
+12 -3
View File
@@ -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[图片结果 1image/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-tokenHTTP 502,请求 ID image-request-502'
},
{
body: '<html>Bad Gateway</html>',
+46 -17
View File
@@ -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)
}
}
}
+17 -5
View File
@@ -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()
})
+17
View File
@@ -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 } : {})
}
}
@@ -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(
+2 -2
View File
@@ -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.
+200 -9
View File
@@ -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<void>
sessionGate?: Promise<void>
captureScreenshot?: (
signal: AbortSignal
) => Promise<{
type: 'image'
mimeType: 'image/jpeg'
data: string
}>
driverScreenshot?: BrowserDriverLike['screenshot']
} = {}) {
const slots: HarnessSlot[] = []
const byContents = new Map<BrowserWebContents, HarnessSlot>()
const createSession = vi.fn(async (): Promise<BrowserSessionLike> => {
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<ReturnType<BrowserSessionLike['openInteraction']>>
>()
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
+121 -35
View File
@@ -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<ReturnType<BrowserUrlPolicy['validate']>>
): void
getCurrentOrigin(): string | undefined
openInteraction(): Promise<BrowserScreenshot | undefined>
captureScreenshot?(signal: AbortSignal): Promise<BrowserScreenshot>
dispose(): Promise<void>
}
@@ -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<BrowserSessionLike> {
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<void>((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<T>,
status?: 'loading' | 'acting'
status?: 'loading' | 'acting' | 'interactive'
): Promise<T> {
signal.throwIfAborted()
if (slot.released || this.disposed) {
@@ -496,7 +545,7 @@ export class BrowserService {
private async runInSession<T>(
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<void> {
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<void> {
this.releaseRequests.add(conversationId)
let releasedSlot = false
+6 -5
View File
@@ -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()
})
+2 -6
View File
@@ -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' &&
@@ -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<string, unknown> | undefined
const createWindow = vi.fn(
async (options: Record<string, unknown>) => {
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({
+132 -2
View File
@@ -49,10 +49,23 @@ export type BrowserWebContents = {
export type BrowserWindowHandle = {
webContents: BrowserWebContents
loadURL(url: string): Promise<unknown>
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<string, unknown>
) => Promise<BrowserWindowHandle>
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<BrowserScreenshot | undefined>
resolve(frame?: BrowserScreenshot): void
}
private interactionClosing?: Promise<void>
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<void> {
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<BrowserScreenshot | undefined> {
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<BrowserScreenshot | undefined>(
(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<void> {
if (this.interactionClosing) {
return this.interactionClosing
}
const operation = (async (): Promise<void> => {
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<void> {
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)
}
+1 -1
View File
@@ -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,
+33 -6
View File
@@ -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()
+17
View File
@@ -15,6 +15,7 @@ import {
approvalDecisionSchema,
agentQuestionResponseSchema,
agentRequestSchema,
browserInteractRequestSchema,
browserStopRequestSchema,
knowledgeCreateSchema,
knowledgeEntityUpdateSchema,
@@ -491,6 +492,10 @@ export function registerIpcHandlers(
onRuntimeSettingsChanged: () => Promise<void>,
onBeforeClearLocalData?: () => Promise<void>,
browserControl?: {
interact(
conversationId: string,
signal: AbortSignal
): Promise<void>
releaseConversation(conversationId: string): Promise<void>
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) {
+6
View File
@@ -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,
+102 -10
View File
@@ -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(<App />)
fireEvent.click(await screen.findByLabelText('添加附件'))
expect(await screen.findByText('需求说明.md')).toBeInTheDocument()
expect(screen.getByText('页面截图.png')).toBeInTheDocument()
const composer = screen
.getByLabelText('向 GoodBuddy 提问')
.closest<HTMLElement>('.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(<App />)
fireEvent.click(await screen.findByLabelText('添加附件'))
const composer = screen
.getByLabelText('向 GoodBuddy 提问')
.closest<HTMLElement>('.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<HTMLElement>('.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: '停止浏览器' })
)
+150 -74
View File
@@ -471,6 +471,107 @@ function terminalizeMessageToolBlocks(
)
}
type MessageBlockRenderItem =
| {
kind: 'block'
block: Exclude<ConversationMessageBlock, { type: 'tool' }>
}
| {
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 (
<section
aria-label={`工具执行,共 ${tools.length}`}
className="tool-execution-list"
>
<header className="tool-execution-list__header">
<TerminalSquare aria-hidden="true" size={15} />
<strong></strong>
<small>{tools.length} </small>
</header>
<ol>
{tools.map((tool) => {
const hasDetails = Boolean(
tool.input || tool.output || tool.error
)
return (
<li key={tool.callId ?? tool.name}>
<details
className={`tool-execution tool-execution--${tool.state}`}
open={
tool.state === 'pending' || tool.state === 'running'
? true
: undefined
}
>
<summary>
<span className="tool-execution__identity">
<strong>{tool.name}</strong>
<span>{tool.summary}</span>
</span>
<small>{toolStateLabels[tool.state]}</small>
</summary>
<div className="tool-execution__details">
{tool.input && (
<section>
<strong></strong>
<pre>{tool.input}</pre>
</section>
)}
{tool.output && (
<section>
<strong></strong>
<pre>{tool.output}</pre>
</section>
)}
{tool.error && (
<section className="tool-execution__error">
<strong></strong>
<pre>{tool.error}</pre>
</section>
)}
{!hasDetails && <p></p>}
</div>
</details>
</li>
)
})}
</ol>
</section>
)
}
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 ? (
<div className="message-blocks">
{message.blocks.map((block) =>
block.type === 'reasoning' ? (
{groupMessageBlocks(message.blocks).map((item) =>
item.kind === 'tools' ? (
<ToolExecutionList
key={item.id}
tools={item.tools}
/>
) : item.block.type === 'reasoning' ? (
<details
className="message-reasoning"
key={block.id}
key={item.block.id}
open={
message.state === 'streaming' &&
message.blocks?.at(-1)?.id === block.id
message.blocks?.at(-1)?.id ===
item.block.id
}
>
<summary>
@@ -4168,34 +4277,18 @@ function App(): React.JSX.Element {
</summary>
<div className="markdown-content message-reasoning__content">
<MarkdownRenderer>
{block.content}
{item.block.content}
</MarkdownRenderer>
</div>
</details>
) : block.type === 'text' ? (
<div
className="markdown-content message__content"
key={block.id}
>
<MarkdownRenderer>
{block.content}
</MarkdownRenderer>
</div>
) : (
<div
className="tool-activity"
key={block.id}
className="markdown-content message__content"
key={item.block.id}
>
<TerminalSquare size={15} />
<div className="tool-activity__content">
<span>{block.tool.summary}</span>
{block.tool.error && (
<code>{block.tool.error}</code>
)}
</div>
<small>
{toolStateLabels[block.tool.state]}
</small>
<MarkdownRenderer>
{item.block.content}
</MarkdownRenderer>
</div>
)
)}
@@ -4347,19 +4440,10 @@ function App(): React.JSX.Element {
</details>
)}
{(!message.blocks || message.blocks.length === 0) &&
message.tools?.map((tool) => (
<div
className="tool-activity"
key={tool.callId ?? tool.name}
>
<TerminalSquare size={15} />
<div className="tool-activity__content">
<span>{tool.summary}</span>
{tool.error && <code>{tool.error}</code>}
</div>
<small>{toolStateLabels[tool.state]}</small>
</div>
))}
message.tools &&
message.tools.length > 0 && (
<ToolExecutionList tools={message.tools} />
)}
{message.subagents && message.subagents.length > 0 && (
<section
aria-label="子专家状态"
@@ -5292,17 +5376,29 @@ function App(): React.JSX.Element {
</div>
)}
<RightAssistantSidebar
activities={activityRecords}
approvals={pendingSidebarApprovals}
artifacts={sidebarArtifacts}
attachments={attachments}
browserState={browserStates[activeId]}
enabledLibraries={enabledSidebarLibraries}
experts={assistantExperts}
heartbeatEntries={heartbeatEntries}
heartbeats={assistantHeartbeats}
memories={assistantMemories}
schedules={assistantSchedules}
onClose={() => 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}
/>
+8 -4
View File
@@ -319,10 +319,14 @@ export function McpSettingsSection(): React.JSX.Element {
</div>
<p className="settings-notice">
GoodBuddy MCP Server MCP Server
访
Execute
GoodBuddy
MCP Execute
MCP OpenCode
Continue 使Runtime MCP
</p>
<p className="settings-notice">
GoodBuddy MCP Server MCP Server
访
GoodBuddy
</p>
{error && !editor && <p className="settings-warning">{error}</p>}
@@ -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<void>
tab?: AssistantSidebarTab
artifacts?: SidebarArtifact[]
onLoadArtifact?: (artifactId: string) => Promise<void>
} = {}): HTMLElement {
render(
<RightAssistantSidebar
activities={[]}
approvals={[]}
artifacts={[]}
artifacts={artifacts}
attachments={[]}
enabledLibraries={[]}
experts={experts}
heartbeatEntries={[]}
heartbeats={[]}
memories={[]}
schedules={[]}
onClose={vi.fn()}
onCreateHeartbeat={vi.fn(async () => 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()
})
})
+256 -494
View File
@@ -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<void>
onStopBrowser: () => Promise<void>
onOpenHeartbeat: () => void
onOpenConversation: (conversationId: string) => void
onCreateHeartbeat: (input: HeartbeatCreateInput) => Promise<void>
onCreateSchedule: (input: ScheduleCreateInput) => Promise<void>
onImportArtifacts: () => Promise<void>
onLoadArtifact: (artifactId: string) => Promise<void>
onRemoveAttachment: (attachmentId: string) => void
onCreateMemory: (content: string) => Promise<void>
onCreateSchedule: (input: ScheduleCreateInput) => Promise<void>
onCreateHeartbeat: (input: HeartbeatCreateInput) => Promise<void>
onSetHeartbeatPaused: (
heartbeatId: string,
paused: boolean
) => Promise<void>
onRemoveHeartbeat: (heartbeatId: string) => Promise<void>
onRunHeartbeat: (heartbeatId: string) => Promise<void>
onRemoveSchedule: (scheduleId: string) => Promise<void>
onRunSchedule: (scheduleId: string) => Promise<void>
onRefreshChanges: () => Promise<void>
onListWorkspaceDirectory: (
path: string
@@ -108,15 +88,18 @@ type RightAssistantSidebarProps = {
path: string,
type: 'file' | 'directory'
) => Promise<void>
onRemoveMemory: (memoryId: string) => Promise<void>
onSetMemoryStatus: (
memoryId: string,
status: AssistantMemory['status']
) => Promise<void>
onRemoveHeartbeat: (heartbeatId: string) => Promise<void>
onRemoveSchedule: (scheduleId: string) => Promise<void>
onRespondApproval: (
approval: PendingSidebarApproval,
decision: ApprovalDecision
) => void
onRunHeartbeat: (heartbeatId: string) => Promise<void>
onRunSchedule: (scheduleId: string) => Promise<void>
onSetHeartbeatPaused: (
heartbeatId: string,
paused: boolean
) => Promise<void>
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<string, AssistantTask[]>()
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<string>()
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' && (
<section className="assistant-sidebar__section">
<p className="assistant-sidebar__section-description">
</p>
{approvals.length > 0 && (
<>
<h3>
<ShieldAlert size={15} />
</h3>
{approvals.map((approval) => (
<article
className="assistant-sidebar__approval"
key={approval.approvalId}
>
<strong>{approval.title}</strong>
<p>{approval.description}</p>
{approval.toolName && <code>{approval.toolName}</code>}
<div className="assistant-sidebar__approval-actions">
<button
className="secondary-button"
onClick={() =>
onRespondApproval(approval, 'deny')
}
type="button"
>
</button>
<button
className="primary-button"
onClick={() =>
onRespondApproval(approval, 'once')
}
type="button"
>
</button>
</div>
</article>
))}
</>
)}
<h3>
<PlayCircle size={15} />
<ShieldAlert size={15} />
</h3>
{tasks.length === 0 && recentTasks.length === 0 ? (
{approvals.length === 0 ? (
<p className="assistant-sidebar__empty">
</p>
) : (
(orderedTasks.length > 0 ? orderedTasks : recentTasks).map((task) => (
<button
className={
'parentTaskId' in task && task.parentTaskId
? 'assistant-sidebar__row assistant-sidebar__row--subtask'
: 'assistant-sidebar__row'
}
key={task.id}
onClick={() => {
if (task.conversationId) {
onOpenConversation(task.conversationId)
}
}}
type="button"
approvals.map((approval) => (
<article
className="assistant-sidebar__approval"
key={approval.approvalId}
>
{task.status === 'running' ||
task.status === 'pending' ? (
<Hourglass size={15} />
) : task.status === 'failed' ||
task.status === 'denied' ? (
<XCircle size={15} />
) : (
<CheckCircle2 size={15} />
)}
<span>
<strong>{task.title}</strong>
<small>
{formatTime(task.createdAt)} · {task.status}
</small>
{'parentTaskId' in task && task.parentTaskId && (
<small className="assistant-sidebar__subtask-meta">
{task.expertId
? expertNames.get(task.expertId) ??
task.title
: task.title}
{' · '}
{task.routingMode === 'smart'
? '智能路由'
: '手动指定'}
</small>
)}
</span>
<ChevronRight size={14} />
</button>
<strong>{approval.title}</strong>
<p>{approval.description}</p>
{approval.toolName && <code>{approval.toolName}</code>}
<div className="assistant-sidebar__approval-actions">
<button
className="secondary-button"
onClick={() =>
onRespondApproval(approval, 'deny')
}
type="button"
>
</button>
<button
className="primary-button"
onClick={() =>
onRespondApproval(approval, 'once')
}
type="button"
>
</button>
</div>
</article>
))
)}
<h3>
<Hourglass size={15} />
@@ -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' && (
<section className="assistant-sidebar__section">
<p className="assistant-sidebar__section-description">
使
</p>
<h3>
<FileText size={15} />
@@ -892,200 +745,76 @@ export function RightAssistantSidebar({
)}
<h3>
<CheckCircle2 size={15} />
</h3>
<div className="assistant-sidebar__memory-form">
<input
aria-label="新增长期记忆"
maxLength={8_000}
onChange={(event) => setMemoryDraft(event.target.value)}
placeholder="例如:我偏好简洁的中文回复"
value={memoryDraft}
/>
<button
className="primary-button"
disabled={!memoryDraft.trim()}
onClick={() => {
const content = memoryDraft.trim()
runAction(
() => onCreateMemory(content),
'保存长期记忆失败',
() => setMemoryDraft('')
)
}}
type="button"
>
</button>
</div>
{memories.length === 0 ? (
{activeMemories.length === 0 ? (
<p className="assistant-sidebar__empty">
</p>
) : (
memories.map((memory) => (
activeMemories.map((memory) => (
<article
className="assistant-sidebar__memory"
key={memory.id}
>
<span>
{memory.content}
{memory.status === 'proposed' && (
<small></small>
)}
</span>
<div>
{memory.status === 'proposed' && (
<>
<button
onClick={() =>
runAction(
() =>
onSetMemoryStatus(
memory.id,
'confirmed'
),
'确认长期记忆失败'
)
}
type="button"
>
</button>
<button
onClick={() =>
runAction(
() =>
onSetMemoryStatus(
memory.id,
'rejected'
),
'忽略长期记忆失败'
)
}
type="button"
>
</button>
</>
)}
<button
aria-label={`删除记忆 ${memory.content.slice(0, 24)}`}
className="icon-button"
onClick={() =>
runAction(
() => onRemoveMemory(memory.id),
'删除长期记忆失败'
)
}
type="button"
>
<X size={13} />
</button>
</div>
</article>
))
)}
<h3>
<RefreshCw size={15} />
<button
aria-label="打开智能心跳中心"
className="icon-button"
onClick={onOpenHeartbeat}
type="button"
>
<ChevronRight size={14} />
</button>
</h3>
{heartbeatEntries.length === 0 ? (
<p className="assistant-sidebar__empty">
</p>
) : (
heartbeatEntries.slice(0, 10).map((entry) => (
<article
className="assistant-sidebar__schedule"
key={entry.id}
>
<span>
<strong>
{new Date(entry.createdAt).toLocaleString('zh-CN')}
</strong>
<small>
{entry.proposedMemoryIds.length} ·{' '}
{entry.followUpTaskIds.length}
</small>
</span>
<p>{entry.summary}</p>
<span>{memory.content}</span>
</article>
))
)}
</section>
)}
{tab === 'artifacts' && (
<section className="assistant-sidebar__section">
<p className="assistant-sidebar__section-description">
PDF
</p>
<h3>
<FileText size={15} />
</h3>
<button
className="secondary-button assistant-sidebar__import"
onClick={() =>
runAction(
onImportArtifacts,
'导入成果失败'
)
}
type="button"
>
<Upload size={13} />
PDF
</button>
{artifacts.length === 0 ? (
<p className="assistant-sidebar__empty">
</p>
) : (
artifacts.map((artifact) => (
{tab === 'workspace' && (
currentWorkspacePreview ? (
<section className="assistant-sidebar__preview">
<header>
<button
className="assistant-sidebar__row"
key={artifact.id}
aria-label="返回工作区"
className="assistant-sidebar__back"
onClick={() => {
workspacePreviewRequest.current += 1
setWorkspacePreview(undefined)
setSelectedArtifactId(artifact.id)
setActionError('')
onTabChange('preview')
runAction(
() => onLoadArtifact(artifact.id),
'加载成果失败'
)
}}
type="button"
>
<FileText size={15} />
<span>
<strong>{artifact.title}</strong>
<small>{formatTime(artifact.createdAt)}</small>
</span>
<ChevronRight size={14} />
<ChevronLeft size={14} />
</button>
))
)}
</section>
)}
{tab === 'changes' && (
<>
<span>
<strong>{currentWorkspacePreview.path}</strong>
<small>
{currentWorkspacePreview.state === 'ready'
? `${currentWorkspacePreview.file.size.toLocaleString('zh-CN')} 字节`
: '项目工作区文件'}
</small>
</span>
</header>
{currentWorkspacePreview.state === 'loading' ? (
<p className="assistant-sidebar__empty">
</p>
) : currentWorkspacePreview.state === 'error' ? (
<p className="assistant-sidebar__empty" role="alert">
{currentWorkspacePreview.error}
</p>
) : (
<div className="markdown-body markdown-content">
{currentWorkspacePreview.file.mimeType ===
'text/markdown' ? (
<MarkdownRenderer>
{currentWorkspacePreview.file.content}
</MarkdownRenderer>
) : (
<pre>{currentWorkspacePreview.file.content}</pre>
)}
</div>
)}
</section>
) : (
<section className="assistant-sidebar__section">
<p className="assistant-sidebar__section-description">
Agent Git
Git
</p>
<h3>
<FolderTree size={15} />
@@ -1132,33 +861,119 @@ export function RightAssistantSidebar({
</details>
)}
</section>
)
)}
{tab === 'results' && (
artifactPreview ? (
<section className="assistant-sidebar__preview">
<header>
<button
aria-label="返回成果列表"
className="assistant-sidebar__back"
onClick={() => {
setSelectedArtifactId(undefined)
setActionError('')
}}
type="button"
>
<ChevronLeft size={14} />
</button>
<span>
<strong>{artifactPreview.title}</strong>
<small>
{sidebarTimeFormatter.format(
new Date(artifactPreview.createdAt)
)}
</small>
</span>
</header>
<div className="markdown-body markdown-content">
{artifactPreview.mimeType.startsWith('image/') ? (
artifactPreview.content ? (
<img
alt={artifactPreview.title}
className="assistant-sidebar__image-preview"
src={artifactPreview.content}
/>
) : (
<p className="assistant-sidebar__empty">
</p>
)
) : artifactPreview.mimeType === 'text/html' ? (
<iframe
className="assistant-sidebar__web-preview"
sandbox=""
srcDoc={artifactPreview.content}
title={artifactPreview.title}
/>
) : artifactPreview.mimeType === 'application/json' ? (
<pre>{artifactPreview.content}</pre>
) : (
<MarkdownRenderer>
{artifactPreview.content}
</MarkdownRenderer>
)}
</div>
</section>
) : (
<section className="assistant-sidebar__section">
<h3></h3>
{changes.length === 0 ? (
<p className="assistant-sidebar__section-description">
PDF
</p>
<h3>
<FileText size={15} />
</h3>
<button
className="secondary-button assistant-sidebar__import"
onClick={() =>
runAction(
onImportArtifacts,
'导入成果失败'
)
}
type="button"
>
<Upload size={13} />
PDF
</button>
{artifacts.length === 0 ? (
<p className="assistant-sidebar__empty">
Agent
</p>
) : (
changes.map((change) => (
artifacts.map((artifact) => (
<button
className="assistant-sidebar__row"
key={change.id}
onClick={() =>
onOpenConversation(change.conversationId)
}
key={artifact.id}
onClick={() => {
setSelectedArtifactId(artifact.id)
setActionError('')
runAction(
() => onLoadArtifact(artifact.id),
'加载成果失败'
)
}}
type="button"
>
<FileDiff size={15} />
<FileText size={15} />
<span>
<strong>{change.title}</strong>
<small>{change.detail || change.status}</small>
<strong>{artifact.title}</strong>
<small>
{sidebarTimeFormatter.format(
new Date(artifact.createdAt)
)}
</small>
</span>
<ChevronRight size={14} />
</button>
))
)}
</section>
</>
)
)}
{tab === 'browser' && (
@@ -1170,18 +985,39 @@ export function RightAssistantSidebar({
</span>
{browserState &&
browserState.status !== 'stopped' && (
<button
className="secondary-button"
onClick={() =>
runAction(
onStopBrowser,
'停止浏览器失败'
)
}
type="button"
>
</button>
<div className="assistant-sidebar__browser-actions">
<button
className="secondary-button"
disabled={
browserState.status === 'creating' ||
browserState.status === 'interactive'
}
onClick={() =>
runAction(
onInteractBrowser,
'打开浏览器交互窗口失败'
)
}
type="button"
>
<ExternalLink aria-hidden="true" size={12} />
{browserState.status === 'interactive'
? '交互中'
: '交互'}
</button>
<button
className="secondary-button"
onClick={() =>
runAction(
onStopBrowser,
'停止浏览器失败'
)
}
type="button"
>
</button>
</div>
)}
</header>
{!browserState ? (
@@ -1201,6 +1037,8 @@ export function RightAssistantSidebar({
? '正在加载页面…'
: browserState.status === 'acting'
? 'Agent 正在操作页面…'
: browserState.status === 'interactive'
? '用户正在辅助操作页面…'
: browserState.status === 'ready'
? '浏览器已就绪'
: browserState.status === 'failed'
@@ -1235,82 +1073,6 @@ export function RightAssistantSidebar({
)}
</section>
)}
{tab === 'preview' && (
<section className="assistant-sidebar__preview">
{currentWorkspacePreview ? (
<>
<header>
<strong>{currentWorkspacePreview.path}</strong>
<small>
{currentWorkspacePreview.state === 'ready'
? `${currentWorkspacePreview.file.size.toLocaleString('zh-CN')} 字节`
: '项目工作区文件'}
</small>
</header>
{currentWorkspacePreview.state === 'loading' ? (
<p className="assistant-sidebar__empty">
</p>
) : currentWorkspacePreview.state === 'error' ? (
<p className="assistant-sidebar__empty" role="alert">
{currentWorkspacePreview.error}
</p>
) : (
<div className="markdown-body markdown-content">
{currentWorkspacePreview.file.mimeType ===
'text/markdown' ? (
<MarkdownRenderer>
{currentWorkspacePreview.file.content}
</MarkdownRenderer>
) : (
<pre>{currentWorkspacePreview.file.content}</pre>
)}
</div>
)}
</>
) : artifactPreview ? (
<>
<header>
<strong>{artifactPreview.title}</strong>
<small>{formatTime(artifactPreview.createdAt)}</small>
</header>
<div className="markdown-body markdown-content">
{artifactPreview.mimeType.startsWith('image/') ? (
artifactPreview.content ? (
<img
alt={artifactPreview.title}
className="assistant-sidebar__image-preview"
src={artifactPreview.content}
/>
) : (
<p className="assistant-sidebar__empty">
</p>
)
) : artifactPreview.mimeType === 'text/html' ? (
<iframe
className="assistant-sidebar__web-preview"
sandbox=""
srcDoc={artifactPreview.content}
title={artifactPreview.title}
/>
) : artifactPreview.mimeType === 'application/json' ? (
<pre>{artifactPreview.content}</pre>
) : (
<MarkdownRenderer>
{artifactPreview.content}
</MarkdownRenderer>
)}
</div>
</>
) : (
<p className="assistant-sidebar__empty">
</p>
)}
</section>
)}
</div>
</aside>
)
+17
View File
@@ -1657,6 +1657,14 @@ describe('SettingsPanel runtime files', () => {
fireEvent.click(screen.getByRole('tab', { name: 'Skills' }))
expect(await screen.findByText('文档写作')).toBeInTheDocument()
expect(
screen.getByText('支持直连模型、OpenCode 和 Continue')
).toBeInTheDocument()
expect(
screen.getByText(/新导入的 Skill 默认启用/)
).toHaveTextContent(
'分配给直连模型、OpenCode 和 Continue'
)
fireEvent.click(
screen.getByRole('button', { name: '导入 Skill 目录' })
)
@@ -1677,6 +1685,15 @@ describe('SettingsPanel runtime files', () => {
fireEvent.click(screen.getByRole('tab', { name: 'MCP' }))
expect(await screen.findByText('电脑控制能力')).toBeInTheDocument()
expect(
screen.getByText(/自定义 MCP 当前仅用于直连模型/)
).toHaveTextContent('新建时默认分配给直连模型')
expect(
screen.getByText(/内置共享 MCP 当前仅有知识库搜索/)
).toHaveTextContent('直连模型、OpenCode 和 Continue')
expect(
screen.getByText(/Runtime 自有 MCP 配置不在此处管理/)
).toBeInTheDocument()
expect(screen.getAllByText('托管浏览器配置').length).toBeGreaterThan(0)
expect(screen.getByLabelText('启用 Linux 桌面控制')).toBeDisabled()
fireEvent.click(screen.getByLabelText('启用 浏览器控制'))
+5 -1
View File
@@ -61,7 +61,7 @@ export function SkillsSettingsSection(): React.JSX.Element {
<BookOpen size={17} />
<div>
<strong>Skills</strong>
<small>线 Agent Runtime</small>
<small>OpenCode Continue</small>
</div>
<button
className="secondary-button"
@@ -91,6 +91,10 @@ export function SkillsSettingsSection(): React.JSX.Element {
</button>
</div>
<p className="settings-notice">
Skill Runtime
Skill OpenCode Continue
</p>
{error && <p className="settings-warning">{error}</p>}
{!snapshot && !error && <p className="settings-empty"> Skills</p>}
<div className="capability-list">
+173 -86
View File
@@ -704,7 +704,7 @@ textarea:focus-visible {
padding: 8px;
border-bottom: 1px solid #f0f0f0;
gap: 3px;
grid-template-columns: repeat(6, minmax(0, 1fr));
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.assistant-sidebar__tab {
@@ -733,8 +733,8 @@ textarea:focus-visible {
padding: 0 4px;
place-items: center;
border-radius: 8px;
background: #ff4d4f;
color: #fff;
background: var(--danger-solid);
color: var(--text-on-accent);
font-size: 8px;
}
@@ -960,18 +960,6 @@ textarea:focus-visible {
background: #e6f4ff;
}
.assistant-sidebar__row--subtask {
width: calc(100% - var(--space-4));
margin-left: var(--space-4);
border-left: 2px solid var(--accent-selected);
background: var(--surface-subtle);
}
.assistant-sidebar__subtask-meta {
color: var(--text-secondary) !important;
white-space: normal !important;
}
.assistant-sidebar__row span,
.assistant-sidebar__context span,
.assistant-sidebar__library {
@@ -1002,9 +990,9 @@ textarea:focus-visible {
.assistant-sidebar__approval {
padding: 11px;
border: 1px solid #ffd591;
border-radius: 9px;
background: #fff7e6;
border: 1px solid var(--danger-border);
border-radius: var(--radius-control);
background: var(--danger-subtle);
}
.assistant-sidebar__approval strong {
@@ -1014,13 +1002,13 @@ textarea:focus-visible {
.assistant-sidebar__approval p {
margin: 5px 0;
color: #595959;
color: var(--text-secondary);
font-size: 10px;
line-height: 1.45;
}
.assistant-sidebar__approval code {
color: #ad6800;
color: var(--danger);
font-size: 9px;
}
@@ -1053,24 +1041,6 @@ textarea:focus-visible {
background: #fafafa;
}
.assistant-sidebar__memory-form {
display: grid;
gap: 6px;
grid-template-columns: minmax(0, 1fr) auto;
}
.assistant-sidebar__memory-form input {
min-width: 0;
padding: 8px 9px;
border: 1px solid #d9d9d9;
border-radius: 8px;
font-size: 10px;
}
.assistant-sidebar__memory-form button {
padding: 0 10px;
}
.assistant-sidebar__memory {
display: flex;
align-items: flex-start;
@@ -1096,21 +1066,6 @@ textarea:focus-visible {
font-size: 8px;
}
.assistant-sidebar__memory > div {
display: flex;
align-items: center;
gap: 5px;
}
.assistant-sidebar__memory > div > button:not(.icon-button) {
padding: 2px 4px;
border: 0;
background: transparent;
color: #1677ff;
cursor: pointer;
font-size: 8px;
}
.assistant-sidebar__schedule-form {
display: flex;
flex-direction: column;
@@ -1122,18 +1077,19 @@ textarea:focus-visible {
.assistant-sidebar__schedule-form select {
width: 100%;
padding: 8px 9px;
border: 1px solid #d9d9d9;
border-radius: 8px;
background: #fff;
border: 1px solid var(--border-control);
border-radius: var(--radius-control);
background: var(--surface-raised);
color: var(--text-primary);
font-size: 9px;
resize: vertical;
}
.assistant-sidebar__schedule {
padding: 9px 10px;
border: 1px solid #d9d9d9;
border-radius: 9px;
background: #fafafa;
border: 1px solid var(--border-default);
border-radius: var(--radius-control);
background: var(--surface-subtle);
}
.assistant-sidebar__schedule > span {
@@ -1147,7 +1103,7 @@ textarea:focus-visible {
}
.assistant-sidebar__schedule small {
color: #8c8c8c;
color: var(--text-muted);
font-size: 8px;
}
@@ -1160,18 +1116,11 @@ textarea:focus-visible {
.assistant-sidebar__schedule button {
padding: 3px 0;
background: transparent;
color: #1677ff;
color: var(--accent);
cursor: pointer;
font-size: 8px;
}
.assistant-sidebar__schedule p {
margin: 7px 0 0;
color: #595959;
font-size: 9px;
line-height: 1.5;
}
.heartbeat-settings {
display: flex;
min-width: 0;
@@ -1371,11 +1320,20 @@ textarea:focus-visible {
gap: var(--space-2);
}
.assistant-sidebar__browser-actions {
display: flex;
align-items: center;
gap: var(--space-2);
}
.assistant-sidebar__browser > header .secondary-button {
display: inline-flex;
min-height: 28px;
align-items: center;
padding: 4px 8px;
flex: none;
font-size: 10px;
gap: var(--space-1);
}
.assistant-sidebar__browser-status {
@@ -1433,9 +1391,34 @@ textarea:focus-visible {
border-bottom: 1px solid #f0f0f0;
margin-bottom: 12px;
flex-direction: column;
gap: var(--space-2);
}
.assistant-sidebar__preview > header > span {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
}
.assistant-sidebar__back {
display: flex;
width: fit-content;
align-items: center;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-control);
background: transparent;
color: var(--text-secondary);
cursor: pointer;
font-size: var(--font-caption);
gap: var(--space-1);
}
.assistant-sidebar__back:hover {
background: var(--surface-subtle);
color: var(--accent);
}
.assistant-sidebar__preview > header strong {
font-size: 12px;
}
@@ -1949,7 +1932,7 @@ textarea:focus-visible {
}
.message-blocks .message-reasoning,
.message-blocks .tool-activity {
.message-blocks .tool-execution-list {
margin: 0;
}
@@ -2087,11 +2070,13 @@ textarea:focus-visible {
.message__status {
display: flex;
align-items: center;
align-items: flex-start;
margin-top: 8px;
color: #8c8c8c;
font-size: 10px;
gap: 7px;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.message__status--error {
@@ -2099,44 +2084,147 @@ textarea:focus-visible {
}
.thinking-dot {
flex: 0 0 auto;
width: 6px;
height: 6px;
margin-top: 4px;
border-radius: 50%;
animation: pulse 1.1s ease-in-out infinite;
background: #1677ff;
}
.tool-activity {
.tool-execution-list {
display: grid;
margin-top: var(--space-2);
overflow: hidden;
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
background: var(--surface-subtle);
color: var(--text-secondary);
}
.tool-execution-list__header {
display: flex;
align-items: center;
padding: 9px 11px;
border: 1px solid #d9d9d9;
border-radius: 9px;
margin-top: 8px;
background: #fafafa;
color: #595959;
font-size: 10px;
gap: 8px;
min-height: 32px;
padding: var(--space-2) var(--space-3);
gap: var(--space-2);
}
.tool-activity__content {
.tool-execution-list__header strong {
color: var(--text-primary);
font-size: var(--font-body);
}
.tool-execution-list__header small {
margin-left: auto;
color: var(--text-muted);
font-size: var(--font-caption);
}
.tool-execution-list > ol {
display: grid;
padding: 0;
border-top: 1px solid var(--border-subtle);
margin: 0;
list-style: none;
}
.tool-execution-list > ol > li + li {
border-top: 1px solid var(--border-subtle);
}
.tool-execution > summary {
display: flex;
align-items: center;
min-height: 36px;
padding: var(--space-2) var(--space-3);
cursor: pointer;
gap: var(--space-3);
list-style-position: inside;
transition: background var(--motion-fast) ease-out;
}
.tool-execution > summary:hover {
background: var(--accent-subtle);
}
.tool-execution > summary:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.tool-execution__identity {
display: grid;
flex: 1;
gap: var(--space-1);
min-width: 0;
gap: var(--space-1);
}
.tool-activity__content code {
.tool-execution__identity strong {
color: var(--text-primary);
font-family: var(--font-family-mono);
font-size: var(--font-body);
overflow-wrap: anywhere;
}
.tool-execution__identity > span {
color: var(--text-muted);
font-size: var(--font-caption);
}
.tool-execution > summary small {
color: var(--text-muted);
font-size: var(--font-caption);
white-space: nowrap;
}
.tool-execution--failed > summary small,
.tool-execution--recoverable > summary small {
color: var(--danger);
}
.tool-execution__details {
display: grid;
padding: 0 var(--space-3) var(--space-3);
gap: var(--space-3);
}
.tool-execution__details section {
display: grid;
gap: var(--space-2);
}
.tool-execution__details strong {
color: var(--text-secondary);
font-size: var(--font-caption);
}
.tool-execution__details pre {
max-height: 320px;
padding: var(--space-3);
overflow: auto;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-control);
margin: 0;
background: var(--surface-raised);
color: var(--text-primary);
font-family: var(--font-family-mono);
font-size: var(--font-caption);
line-height: 1.5;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.tool-activity small {
color: #8c8c8c;
text-transform: uppercase;
.tool-execution__details p {
margin: 0;
color: var(--text-muted);
font-size: var(--font-caption);
}
.tool-execution__error pre {
border-color: var(--danger-border);
color: var(--danger);
}
.subagent-status-list {
@@ -6281,7 +6369,6 @@ details.settings-section > :not(summary) {
.settings-panel__footer,
.assistant-sidebar__row,
.assistant-sidebar__library,
.assistant-sidebar__schedule,
.assistant-sidebar__diff,
.assistant-sidebar__preview pre,
.tool-activity,
+2
View File
@@ -75,6 +75,8 @@ export const conversationToolActivitySchema = z
'interrupted'
]),
summary: z.string().max(2_000),
input: z.string().max(4_000).optional(),
output: z.string().max(16_000).optional(),
error: z.string().max(2_000).optional()
})
.strict()
+3 -3
View File
@@ -28,7 +28,7 @@ export const builtinModelTools = [
{
name: 'browser_navigate',
displayName: '浏览器导航',
description: '在隔离浏览器中打开公开的 HTTP(S) 页面。',
description: '在隔离浏览器中打开当前设备可连接的 HTTP 或 HTTPS 页面。',
access: 'write'
},
{
@@ -40,13 +40,13 @@ export const builtinModelTools = [
{
name: 'browser_click',
displayName: '点击浏览器元素',
description: '点击最近一次浏览器快照中的可见且未受保护元素。',
description: '点击最近一次浏览器快照中的可见元素。',
access: 'write'
},
{
name: 'browser_type',
displayName: '输入浏览器文本',
description: '向可编辑且未受保护的页面元素输入文本;不支持上传文件。',
description: '向可编辑页面元素(包括密码框)输入文本;不支持上传文件。',
access: 'write'
},
{
+6
View File
@@ -660,6 +660,8 @@ export type AgentEvent =
| 'failed'
| 'recoverable'
summary: string
input?: string
output?: string
error?: string
}
| {
@@ -728,6 +730,7 @@ export const browserLiveStateSchema = z
'loading',
'ready',
'acting',
'interactive',
'failed',
'stopped'
]),
@@ -753,6 +756,8 @@ export const browserStopRequestSchema = z
})
.strict()
export const browserInteractRequestSchema = browserStopRequestSchema
export const knowledgeIdSchema = z.string().uuid()
export const knowledgeCreateSchema = z
.object({
@@ -919,6 +924,7 @@ export type DesktopApi = {
onEvent: (listener: (event: AgentEvent) => void) => () => void
}
browser: {
interact: (conversationId: string) => Promise<void>
stop: (conversationId: string) => Promise<void>
onState: (listener: (state: BrowserLiveState) => void) => () => void
}
+1
View File
@@ -16,6 +16,7 @@ export const ipcChannels = {
agentApprovalRespond: 'agent:approval:respond',
agentQuestionRespond: 'agent:question:respond',
agentEvent: 'agent:event',
browserInteract: 'browser:interact',
browserStop: 'browser:stop',
browserState: 'browser:state',
runtimeSettingsGet: 'settings:runtime:get',