feat: improve runtime visibility and browser interaction
This commit is contained in:
@@ -13,7 +13,6 @@ primarily Simplified Chinese.
|
|||||||
- `src/main`: privileged Electron main process, runtimes, persistence, IPC,
|
- `src/main`: privileged Electron main process, runtimes, persistence, IPC,
|
||||||
knowledge, automation, and OS integration.
|
knowledge, automation, and OS integration.
|
||||||
- `src/preload`: the narrow, typed bridge exposed to the renderer.
|
- `src/preload`: the narrow, typed bridge exposed to the renderer.
|
||||||
- `src/renderer`: React UI. It must not receive secrets or direct Node access.
|
|
||||||
- `src/shared`: schemas, contracts, presets, and IPC channel definitions shared
|
- `src/shared`: schemas, contracts, presets, and IPC channel definitions shared
|
||||||
across process boundaries.
|
across process boundaries.
|
||||||
- `resources/skills`: bundled skills.
|
- `resources/skills`: bundled skills.
|
||||||
@@ -27,8 +26,6 @@ Keep Electron security boundaries intact:
|
|||||||
- Validate IPC input with shared Zod schemas and verify trusted senders.
|
- Validate IPC input with shared Zod schemas and verify trusted senders.
|
||||||
- Expose only explicit preload methods. Do not pass raw Electron APIs.
|
- Expose only explicit preload methods. Do not pass raw Electron APIs.
|
||||||
- Keep API keys in the main process and encrypted settings store.
|
- Keep API keys in the main process and encrypted settings store.
|
||||||
- Never log or return credentials, authorization headers, private documents, or
|
|
||||||
unredacted provider payloads.
|
|
||||||
|
|
||||||
## Runtime Behavior
|
## Runtime Behavior
|
||||||
|
|
||||||
@@ -57,7 +54,7 @@ Keep Electron security boundaries intact:
|
|||||||
- Keep changes focused. Do not add unrelated refactors or documentation.
|
- Keep changes focused. Do not add unrelated refactors or documentation.
|
||||||
- Add or update focused tests for behavioral changes and regressions.
|
- Add or update focused tests for behavioral changes and regressions.
|
||||||
- Avoid broad catches that erase HTTP status, cancellation, or provider error
|
- Avoid broad catches that erase HTTP status, cancellation, or provider error
|
||||||
context. Bound and redact any surfaced error details.
|
context.
|
||||||
- Keep UI accessible with labels, keyboard behavior, semantic roles, and visible
|
- Keep UI accessible with labels, keyboard behavior, semantic roles, and visible
|
||||||
focus states.
|
focus states.
|
||||||
|
|
||||||
|
|||||||
+6
-4
@@ -16,7 +16,8 @@ Linux x64 和 Linux arm64。
|
|||||||
- 已实现导航、可访问性快照、点击、输入、选择、返回和有界截图工具。
|
- 已实现导航、可访问性快照、点击、输入、选择、返回和有界截图工具。
|
||||||
- 浏览器工具只在直连模型的 Execute 模式中提供;用户选择 Execute 即授权
|
- 浏览器工具只在直连模型的 Execute 模式中提供;用户选择 Execute 即授权
|
||||||
本次交互运行,不再逐个弹出 GoodBuddy 工具审批。
|
本次交互运行,不再逐个弹出 GoodBuddy 工具审批。
|
||||||
- 已实现公网 URL、DNS、重定向、私有地址和元数据地址限制。
|
- 支持当前设备可连接的 HTTP、HTTPS、公网、内网、本机和元数据地址,
|
||||||
|
导航及重定向仍经过 DNS 解析和目标地址固定。
|
||||||
- 已实现回环过滤代理、下载和文件选择器阻止、权限拒绝、会话取消、
|
- 已实现回环过滤代理、下载和文件选择器阻止、权限拒绝、会话取消、
|
||||||
空闲回收和应用退出清理。
|
空闲回收和应用退出清理。
|
||||||
- 直连模型工具循环已提高到适合浏览器任务的有界上限,并包含重复调用和
|
- 直连模型工具循环已提高到适合浏览器任务的有界上限,并包含重复调用和
|
||||||
@@ -75,11 +76,12 @@ Linux x64 和 Linux arm64。
|
|||||||
|
|
||||||
### P0:右侧没有浏览器实时画面,已修复
|
### P0:右侧没有浏览器实时画面,已修复
|
||||||
|
|
||||||
浏览器窗口仍使用 `show: false`,但 BrowserService 现在从模型实际操作的同一
|
浏览器窗口默认使用 `show: false`,但 BrowserService 现在从模型实际操作的同一
|
||||||
会话捕获页面帧,并通过受限 IPC 发送状态、当前 URL 和约 220KB 的 JPEG 画面。右侧工作栏
|
会话捕获页面帧,并通过受限 IPC 发送状态、当前 URL 和约 220KB 的 JPEG 画面。右侧工作栏
|
||||||
新增“浏览器”页签;活动对话启动浏览器时会自动打开该页签,并显示创建中、
|
新增“浏览器”页签;活动对话启动浏览器时会自动打开该页签,并显示创建中、
|
||||||
加载中、操作中、就绪、失败和已停止状态。用户可在页签内立即停止当前对话的
|
加载中、操作中、用户交互中、就绪、失败和已停止状态。用户可点击“交互”打开
|
||||||
浏览器会话。
|
同一会话的子窗口辅助 Agent;交互时主窗口暂时禁用,关闭时先刷新最终画面再将
|
||||||
|
浏览器窗口最小化,页面和会话继续保留。用户也可立即停止当前对话的浏览器会话。
|
||||||
|
|
||||||
当前实现按导航、快照、点击、输入、选择、返回和截图操作刷新画面,而不是创建
|
当前实现按导航、快照、点击、输入、选择、返回和截图操作刷新画面,而不是创建
|
||||||
第二个预览浏览器,因此显示内容与模型受控页面一致。
|
第二个预览浏览器,因此显示内容与模型受控页面一致。
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import {
|
import {
|
||||||
|
boundedToolDetail,
|
||||||
safeToolArgumentSummary,
|
safeToolArgumentSummary,
|
||||||
safeToolErrorDetail
|
safeToolErrorDetail
|
||||||
} from './approval-summary'
|
} 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', () => {
|
describe('safeToolErrorDetail', () => {
|
||||||
it('extracts nested runtime errors while redacting secrets', () => {
|
it('extracts nested runtime errors without rewriting their contents', () => {
|
||||||
expect(
|
expect(
|
||||||
safeToolErrorDetail([
|
safeToolErrorDetail([
|
||||||
{
|
{
|
||||||
@@ -39,14 +61,14 @@ describe('safeToolErrorDetail', () => {
|
|||||||
'exit code 1\nAuthorization: Bearer secret-token'
|
'exit code 1\nAuthorization: Bearer secret-token'
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
).toBe('exit code 1\nAuthorization: [REDACTED]')
|
).toBe('exit code 1\nAuthorization: Bearer secret-token')
|
||||||
expect(
|
expect(
|
||||||
safeToolErrorDetail({
|
safeToolErrorDetail({
|
||||||
message:
|
message:
|
||||||
'{"token":"json-secret","authorization":"Basic abc123"}'
|
'{"token":"json-secret","authorization":"Basic abc123"}'
|
||||||
})
|
})
|
||||||
).toBe(
|
).toBe(
|
||||||
'{"token":"[REDACTED]","authorization":"[REDACTED]"}'
|
'{"token":"json-secret","authorization":"Basic abc123"}'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -66,4 +88,31 @@ describe('safeToolErrorDetail', () => {
|
|||||||
})
|
})
|
||||||
).toBeUndefined()
|
).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')
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ function redactValue(
|
|||||||
if (depth > 8) {
|
if (depth > 8) {
|
||||||
return '[TRUNCATED]'
|
return '[TRUNCATED]'
|
||||||
}
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return redactSensitiveText(value)
|
||||||
|
}
|
||||||
if (!value || typeof value !== 'object') {
|
if (!value || typeof value !== 'object') {
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
@@ -64,6 +67,32 @@ export function safeToolErrorDetail(
|
|||||||
let remaining = maximum
|
let remaining = maximum
|
||||||
const seen = new WeakSet<object>()
|
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 => {
|
const collect = (candidate: unknown, depth = 0): void => {
|
||||||
if (remaining <= 0 || depth > 4 || candidate === undefined) {
|
if (remaining <= 0 || depth > 4 || candidate === undefined) {
|
||||||
return
|
return
|
||||||
@@ -73,30 +102,7 @@ export function safeToolErrorDetail(
|
|||||||
0,
|
0,
|
||||||
Math.min(candidate.length, remaining * 4)
|
Math.min(candidate.length, remaining * 4)
|
||||||
)
|
)
|
||||||
const text = redactSensitiveText(
|
append(boundedCandidate)
|
||||||
[...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
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!candidate || typeof candidate !== 'object') {
|
if (!candidate || typeof candidate !== 'object') {
|
||||||
@@ -113,10 +119,33 @@ export function safeToolErrorDetail(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const record = candidate as Record<string, unknown>
|
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 [
|
for (const key of [
|
||||||
'content',
|
'content',
|
||||||
'message',
|
|
||||||
'error',
|
'error',
|
||||||
|
'errors',
|
||||||
'stderr',
|
'stderr',
|
||||||
'detail',
|
'detail',
|
||||||
'data'
|
'data'
|
||||||
@@ -152,3 +181,23 @@ export function safeToolArgumentSummary(
|
|||||||
redactValue(toolArguments, new WeakSet())
|
redactValue(toolArguments, new WeakSet())
|
||||||
).slice(0, maximum)
|
).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
|
||||||
|
}
|
||||||
|
|||||||
@@ -876,7 +876,7 @@ describe('ContinueHostAdapter', () => {
|
|||||||
name: 'Bash',
|
name: 'Bash',
|
||||||
state: 'failed',
|
state: 'failed',
|
||||||
error:
|
error:
|
||||||
'PowerShell parser failed Authorization: [REDACTED]'
|
'PowerShell parser failed Authorization: Bearer secret-token'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
@@ -940,7 +940,9 @@ describe('ContinueHostAdapter', () => {
|
|||||||
type: 'tool',
|
type: 'tool',
|
||||||
callId: 'call-1',
|
callId: 'call-1',
|
||||||
name: 'Bash',
|
name: 'Bash',
|
||||||
state: 'running'
|
state: 'running',
|
||||||
|
input:
|
||||||
|
'{"command":"npm test","token":"secret-token"}'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
@@ -973,7 +975,9 @@ describe('ContinueHostAdapter', () => {
|
|||||||
type: 'tool',
|
type: 'tool',
|
||||||
callId: 'call-1',
|
callId: 'call-1',
|
||||||
name: 'Bash',
|
name: 'Bash',
|
||||||
state: 'completed'
|
state: 'completed',
|
||||||
|
output:
|
||||||
|
'Tests passed\nAuthorization: Bearer secret-token'
|
||||||
},
|
},
|
||||||
{ type: 'text', delta: 'TOOLS_OK' }
|
{ type: 'text', delta: 'TOOLS_OK' }
|
||||||
]
|
]
|
||||||
@@ -1012,7 +1016,11 @@ describe('ContinueHostAdapter', () => {
|
|||||||
{
|
{
|
||||||
callId: 'call-1',
|
callId: 'call-1',
|
||||||
name: 'Bash',
|
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: {
|
tool: {
|
||||||
callId: 'call-1',
|
callId: 'call-1',
|
||||||
name: 'Bash',
|
name: 'Bash',
|
||||||
state: 'running'
|
state: 'running',
|
||||||
|
input:
|
||||||
|
'{"command":"npm test","token":"secret-token"}'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1031,7 +1041,9 @@ describe('ContinueHostAdapter', () => {
|
|||||||
tool: {
|
tool: {
|
||||||
callId: 'call-1',
|
callId: 'call-1',
|
||||||
name: 'Bash',
|
name: 'Bash',
|
||||||
state: 'completed'
|
state: 'completed',
|
||||||
|
output:
|
||||||
|
'Tests passed\nAuthorization: Bearer secret-token'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ type: 'text', delta: 'TOOLS_OK' }
|
{ type: 'text', delta: 'TOOLS_OK' }
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import {
|
|||||||
import { createAnthropicApiBaseUrl } from './anthropic-endpoint'
|
import { createAnthropicApiBaseUrl } from './anthropic-endpoint'
|
||||||
import { createOpenAIApiBaseUrl } from './openai-endpoint'
|
import { createOpenAIApiBaseUrl } from './openai-endpoint'
|
||||||
import {
|
import {
|
||||||
redactSensitiveText,
|
boundedToolDetail,
|
||||||
safeToolErrorDetail
|
safeToolErrorDetail
|
||||||
} from './approval-summary'
|
} from './approval-summary'
|
||||||
|
|
||||||
@@ -88,6 +88,8 @@ const continueHostStreamEventSchema = z.discriminatedUnion('type', [
|
|||||||
callId: z.string().min(1).max(256),
|
callId: z.string().min(1).max(256),
|
||||||
name: z.string().min(1).max(200),
|
name: z.string().min(1).max(200),
|
||||||
state: z.enum(['running', 'completed', 'failed']),
|
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()
|
error: z.string().max(1_000).optional()
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
@@ -142,6 +144,8 @@ export type ContinueHostTool = {
|
|||||||
callId: string
|
callId: string
|
||||||
name: string
|
name: string
|
||||||
state: 'pending' | 'running' | 'completed' | 'failed'
|
state: 'pending' | 'running' | 'completed' | 'failed'
|
||||||
|
input?: string
|
||||||
|
output?: string
|
||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,7 +387,7 @@ function parseContinueFailure(text: string): string | undefined {
|
|||||||
: record.message
|
: record.message
|
||||||
const detail =
|
const detail =
|
||||||
typeof message === 'string' && message.trim()
|
typeof message === 'string' && message.trim()
|
||||||
? `:${redactSensitiveText(message.trim()).slice(0, 500)}`
|
? `:${message.trim().slice(0, 500)}`
|
||||||
: ''
|
: ''
|
||||||
return `Continue 模型请求失败${detail}`
|
return `Continue 模型请求失败${detail}`
|
||||||
} catch {
|
} catch {
|
||||||
@@ -465,10 +469,23 @@ function extractContinueTools(
|
|||||||
normalizedState === 'failed'
|
normalizedState === 'failed'
|
||||||
? normalizeContinueToolError(state.output)
|
? normalizeContinueToolError(state.output)
|
||||||
: undefined
|
: 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, {
|
tools.set(callId, {
|
||||||
callId,
|
callId,
|
||||||
name: name.trim().slice(0, 200),
|
name: name.trim().slice(0, 200),
|
||||||
state: normalizedState,
|
state: normalizedState,
|
||||||
|
...(input ? { input } : {}),
|
||||||
|
...(output ? { output } : {}),
|
||||||
...(error ? { error } : {})
|
...(error ? { error } : {})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -482,7 +499,14 @@ function mergeContinueTools(
|
|||||||
): ContinueHostTool[] {
|
): ContinueHostTool[] {
|
||||||
const tools = new Map(current.map((tool) => [tool.callId, tool]))
|
const tools = new Map(current.map((tool) => [tool.callId, tool]))
|
||||||
for (const tool of updates) {
|
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()]
|
return [...tools.values()]
|
||||||
}
|
}
|
||||||
@@ -661,7 +685,7 @@ export class ContinueHostAdapter {
|
|||||||
patched = replaceExactly(
|
patched = replaceExactly(
|
||||||
patched,
|
patched,
|
||||||
streamCallbacksMarker,
|
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 = replaceExactly(
|
||||||
patched,
|
patched,
|
||||||
@@ -1112,6 +1136,12 @@ export class ContinueHostAdapter {
|
|||||||
callId: event.callId,
|
callId: event.callId,
|
||||||
name: event.name,
|
name: event.name,
|
||||||
state: event.state,
|
state: event.state,
|
||||||
|
...(event.input
|
||||||
|
? { input: boundedToolDetail(event.input, 4_000) }
|
||||||
|
: {}),
|
||||||
|
...(event.output
|
||||||
|
? { output: boundedToolDetail(event.output, 16_000) }
|
||||||
|
: {}),
|
||||||
...(event.error
|
...(event.error
|
||||||
? { error: normalizeContinueToolError(event.error) }
|
? { error: normalizeContinueToolError(event.error) }
|
||||||
: {})
|
: {})
|
||||||
@@ -1143,7 +1173,8 @@ export class ContinueHostAdapter {
|
|||||||
{
|
{
|
||||||
callId: pendingCallId,
|
callId: pendingCallId,
|
||||||
name: pending.toolName,
|
name: pending.toolName,
|
||||||
state: 'pending'
|
state: 'pending',
|
||||||
|
input: boundedToolDetail(pending.toolArgs, 4_000)
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -384,7 +384,13 @@ describe('ContinueAgentRuntime', () => {
|
|||||||
mocks.runHost.mockResolvedValue({
|
mocks.runHost.mockResolvedValue({
|
||||||
text: 'Continue response',
|
text: 'Continue response',
|
||||||
tools: [
|
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' }
|
{ callId: 'call-2', name: 'Write', state: 'completed' }
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
@@ -396,7 +402,9 @@ describe('ContinueAgentRuntime', () => {
|
|||||||
type: 'tool',
|
type: 'tool',
|
||||||
name: 'Bash',
|
name: 'Bash',
|
||||||
state: 'completed',
|
state: 'completed',
|
||||||
summary: 'Continue 工具:Bash'
|
summary: 'Continue 工具:Bash',
|
||||||
|
input: '{"command":"npm test"}',
|
||||||
|
output: 'Tests passed'
|
||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
type: 'tool',
|
type: 'tool',
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ function toContinueToolEvent(
|
|||||||
? 'failed'
|
? 'failed'
|
||||||
: tool.state,
|
: tool.state,
|
||||||
summary: `Continue 工具:${tool.name}`,
|
summary: `Continue 工具:${tool.name}`,
|
||||||
|
...(tool.input ? { input: tool.input } : {}),
|
||||||
|
...(tool.output ? { output: tool.output } : {}),
|
||||||
...(tool.error ? { error: tool.error } : {})
|
...(tool.error ? { error: tool.error } : {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -286,7 +286,7 @@ describe('ModelAgentRuntime', () => {
|
|||||||
await expect(consume()).rejects.toThrow('意外中断')
|
await expect(consume()).rejects.toThrow('意外中断')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('redacts credentials from provider error messages', async () => {
|
it('preserves bounded provider error messages', async () => {
|
||||||
const runtime = new ModelAgentRuntime({
|
const runtime = new ModelAgentRuntime({
|
||||||
apiKey: 'test-key',
|
apiKey: 'test-key',
|
||||||
baseUrl: 'https://bigtoken.ai',
|
baseUrl: 'https://bigtoken.ai',
|
||||||
@@ -319,7 +319,7 @@ describe('ModelAgentRuntime', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await expect(consume()).rejects.toThrow(
|
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')
|
.filter((event) => event.type === 'tool')
|
||||||
.map((event) => event.state)
|
.map((event) => event.state)
|
||||||
).toEqual(['pending', 'running', 'completed'])
|
).toEqual(['pending', 'running', 'completed'])
|
||||||
|
expect(events).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'tool',
|
||||||
|
state: 'completed',
|
||||||
|
input: '{\n "path": "README.md"\n}',
|
||||||
|
output:
|
||||||
|
'tool result\n\n[图片结果 1:image/png]'
|
||||||
|
})
|
||||||
|
)
|
||||||
expect(events).toContainEqual(
|
expect(events).toContainEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
type: 'text',
|
type: 'text',
|
||||||
@@ -1693,7 +1702,7 @@ describe('ModelAgentRuntime', () => {
|
|||||||
'x-request-id': 'image-request-502'
|
'x-request-id': 'image-request-502'
|
||||||
},
|
},
|
||||||
expected:
|
expected:
|
||||||
'upstream unavailable Authorization: [REDACTED](HTTP 502,请求 ID image-request-502)'
|
'upstream unavailable Authorization: Bearer secret-token(HTTP 502,请求 ID image-request-502)'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
body: '<html>Bad Gateway</html>',
|
body: '<html>Bad Gateway</html>',
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ import type {
|
|||||||
RuntimeModelUsageEvent
|
RuntimeModelUsageEvent
|
||||||
} from './runtime'
|
} from './runtime'
|
||||||
import {
|
import {
|
||||||
redactSensitiveText,
|
boundedToolDetail,
|
||||||
safeToolArgumentSummary
|
safeToolErrorDetail
|
||||||
} from './approval-summary'
|
} from './approval-summary'
|
||||||
|
|
||||||
type ConversationMessage = {
|
type ConversationMessage = {
|
||||||
@@ -121,7 +121,7 @@ function getErrorMessage(value: unknown): string | undefined {
|
|||||||
}
|
}
|
||||||
const error = 'error' in value ? value.error : undefined
|
const error = 'error' in value ? value.error : undefined
|
||||||
if (typeof error === 'string') {
|
if (typeof error === 'string') {
|
||||||
return redactSensitiveText(error).slice(0, 1_000)
|
return error.slice(0, 1_000)
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
error &&
|
error &&
|
||||||
@@ -129,13 +129,13 @@ function getErrorMessage(value: unknown): string | undefined {
|
|||||||
'message' in error &&
|
'message' in error &&
|
||||||
typeof error.message === 'string'
|
typeof error.message === 'string'
|
||||||
) {
|
) {
|
||||||
return redactSensitiveText(error.message).slice(0, 1_000)
|
return error.message.slice(0, 1_000)
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
'message' in value &&
|
'message' in value &&
|
||||||
typeof value.message === 'string'
|
typeof value.message === 'string'
|
||||||
) {
|
) {
|
||||||
return redactSensitiveText(value.message).slice(0, 1_000)
|
return value.message.slice(0, 1_000)
|
||||||
}
|
}
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
@@ -624,14 +624,29 @@ function getChatToolResultText(parts: ModelToolResultPart[]): string {
|
|||||||
.join('\n\n')
|
.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(
|
function createRecoverableToolErrorResult(
|
||||||
error: RecoverableModelToolError
|
error: RecoverableModelToolError
|
||||||
): ModelToolResult {
|
): ModelToolResult {
|
||||||
const text = JSON.stringify({
|
const text = JSON.stringify({
|
||||||
ok: false,
|
ok: false,
|
||||||
recoverable: true,
|
recoverable: true,
|
||||||
error: redactSensitiveText(error.message).slice(0, 1_000),
|
error: error.message.slice(0, 1_000),
|
||||||
nextAction: redactSensitiveText(error.nextAction).slice(0, 1_000)
|
nextAction: error.nextAction.slice(0, 1_000)
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
parts: [{ type: 'text', text }],
|
parts: [{ type: 'text', text }],
|
||||||
@@ -1250,7 +1265,7 @@ export class ModelAgentRuntime implements AgentRuntime {
|
|||||||
providerMessage?.includes('模型接口请求失败')
|
providerMessage?.includes('模型接口请求失败')
|
||||||
? '上游图像服务暂时不可用,请稍后重试或联系服务商'
|
? '上游图像服务暂时不可用,请稍后重试或联系服务商'
|
||||||
: providerMessage
|
: providerMessage
|
||||||
? redactSensitiveText(providerMessage).slice(0, 1_000)
|
? providerMessage.slice(0, 1_000)
|
||||||
: '图像生成请求失败'
|
: '图像生成请求失败'
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`${publicMessage}(HTTP ${response.status}${
|
`${publicMessage}(HTTP ${response.status}${
|
||||||
@@ -1546,13 +1561,15 @@ export class ModelAgentRuntime implements AgentRuntime {
|
|||||||
seenCallIds.add(call.id)
|
seenCallIds.add(call.id)
|
||||||
const tool = toolsByName.get(call.name)
|
const tool = toolsByName.get(call.name)
|
||||||
const displayName = tool?.displayName ?? call.name.slice(0, 128)
|
const displayName = tool?.displayName ?? call.name.slice(0, 128)
|
||||||
|
const input = boundedToolDetail(call.arguments, 4_000)
|
||||||
yield {
|
yield {
|
||||||
requestId: request.requestId,
|
requestId: request.requestId,
|
||||||
type: 'tool',
|
type: 'tool',
|
||||||
callId: call.id,
|
callId: call.id,
|
||||||
name: displayName,
|
name: displayName,
|
||||||
state: 'pending',
|
state: 'pending',
|
||||||
summary: `直连模型工具:${displayName}`
|
summary: `直连模型工具:${displayName}`,
|
||||||
|
input
|
||||||
}
|
}
|
||||||
if (!tool) {
|
if (!tool) {
|
||||||
yield {
|
yield {
|
||||||
@@ -1561,7 +1578,8 @@ export class ModelAgentRuntime implements AgentRuntime {
|
|||||||
callId: call.id,
|
callId: call.id,
|
||||||
name: displayName,
|
name: displayName,
|
||||||
state: 'failed',
|
state: 'failed',
|
||||||
summary: `直连模型请求了未知工具:${displayName}`
|
summary: `直连模型请求了未知工具:${displayName}`,
|
||||||
|
input
|
||||||
}
|
}
|
||||||
throw new Error(`模型请求了未知工具「${displayName}」`)
|
throw new Error(`模型请求了未知工具「${displayName}」`)
|
||||||
}
|
}
|
||||||
@@ -1581,19 +1599,22 @@ export class ModelAgentRuntime implements AgentRuntime {
|
|||||||
this.toolProvider.getApproval(
|
this.toolProvider.getApproval(
|
||||||
tool,
|
tool,
|
||||||
call.arguments,
|
call.arguments,
|
||||||
safeToolArgumentSummary(call.arguments),
|
boundedToolDetail(call.arguments, 1_000) ?? '',
|
||||||
toolContext
|
toolContext
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const detail = safeToolErrorDetail(error)
|
||||||
yield {
|
yield {
|
||||||
requestId: request.requestId,
|
requestId: request.requestId,
|
||||||
type: 'tool',
|
type: 'tool',
|
||||||
callId: call.id,
|
callId: call.id,
|
||||||
name: displayName,
|
name: displayName,
|
||||||
state: 'failed',
|
state: 'failed',
|
||||||
summary: `直连模型工具审批失败:${displayName}`
|
summary: `直连模型工具审批失败:${displayName}`,
|
||||||
|
input,
|
||||||
|
...(detail ? { error: detail } : {})
|
||||||
}
|
}
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
@@ -1604,7 +1625,8 @@ export class ModelAgentRuntime implements AgentRuntime {
|
|||||||
callId: call.id,
|
callId: call.id,
|
||||||
name: displayName,
|
name: displayName,
|
||||||
state: 'failed',
|
state: 'failed',
|
||||||
summary: `用户拒绝了直连模型工具:${displayName}`
|
summary: `用户拒绝了直连模型工具:${displayName}`,
|
||||||
|
input
|
||||||
}
|
}
|
||||||
throw new Error(`用户拒绝了工具「${displayName}」`)
|
throw new Error(`用户拒绝了工具「${displayName}」`)
|
||||||
}
|
}
|
||||||
@@ -1615,7 +1637,8 @@ export class ModelAgentRuntime implements AgentRuntime {
|
|||||||
callId: call.id,
|
callId: call.id,
|
||||||
name: displayName,
|
name: displayName,
|
||||||
state: 'running',
|
state: 'running',
|
||||||
summary: `正在执行直连模型工具:${displayName}`
|
summary: `正在执行直连模型工具:${displayName}`,
|
||||||
|
input
|
||||||
}
|
}
|
||||||
|
|
||||||
let result: ModelToolResult
|
let result: ModelToolResult
|
||||||
@@ -1629,6 +1652,7 @@ export class ModelAgentRuntime implements AgentRuntime {
|
|||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const recoverable = error instanceof RecoverableModelToolError
|
const recoverable = error instanceof RecoverableModelToolError
|
||||||
|
const detail = safeToolErrorDetail(error)
|
||||||
yield {
|
yield {
|
||||||
requestId: request.requestId,
|
requestId: request.requestId,
|
||||||
type: 'tool',
|
type: 'tool',
|
||||||
@@ -1638,7 +1662,9 @@ export class ModelAgentRuntime implements AgentRuntime {
|
|||||||
summary:
|
summary:
|
||||||
recoverable
|
recoverable
|
||||||
? `直连模型工具需要刷新后重试:${displayName}`
|
? `直连模型工具需要刷新后重试:${displayName}`
|
||||||
: `直连模型工具执行失败:${displayName}`
|
: `直连模型工具执行失败:${displayName}`,
|
||||||
|
input,
|
||||||
|
...(detail ? { error: detail } : {})
|
||||||
}
|
}
|
||||||
if (recoverable) {
|
if (recoverable) {
|
||||||
result = createRecoverableToolErrorResult(error)
|
result = createRecoverableToolErrorResult(error)
|
||||||
@@ -1657,7 +1683,8 @@ export class ModelAgentRuntime implements AgentRuntime {
|
|||||||
callId: call.id,
|
callId: call.id,
|
||||||
name: displayName,
|
name: displayName,
|
||||||
state: 'failed',
|
state: 'failed',
|
||||||
summary: `直连模型工具结果超过限制:${displayName}`
|
summary: `直连模型工具结果超过限制:${displayName}`,
|
||||||
|
input
|
||||||
}
|
}
|
||||||
throw new Error('直连模型工具结果总量超过 1MB 安全限制')
|
throw new Error('直连模型工具结果总量超过 1MB 安全限制')
|
||||||
}
|
}
|
||||||
@@ -1691,7 +1718,9 @@ export class ModelAgentRuntime implements AgentRuntime {
|
|||||||
callId: call.id,
|
callId: call.id,
|
||||||
name: displayName,
|
name: displayName,
|
||||||
state: 'completed',
|
state: 'completed',
|
||||||
summary: `直连模型工具已完成:${displayName}`
|
summary: `直连模型工具已完成:${displayName}`,
|
||||||
|
input,
|
||||||
|
output: getToolResultPreview(result.parts)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,7 +141,14 @@ function completedToolEvent(
|
|||||||
callID: callId,
|
callID: callId,
|
||||||
type: 'tool',
|
type: 'tool',
|
||||||
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({
|
expect.objectContaining({
|
||||||
type: 'tool',
|
type: 'tool',
|
||||||
callId: 'call-1',
|
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(
|
expect(events).toContainEqual(
|
||||||
@@ -1631,11 +1642,12 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
|
|||||||
type: 'tool',
|
type: 'tool',
|
||||||
callId: 'call-1',
|
callId: 'call-1',
|
||||||
state: 'failed',
|
state: 'failed',
|
||||||
error: 'write failed Authorization: [REDACTED]'
|
error:
|
||||||
|
'write failed Authorization: Bearer secret-token'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
await expect(stream.next()).rejects.toThrow(
|
await expect(stream.next()).rejects.toThrow(
|
||||||
'write failed Authorization: [REDACTED]'
|
'write failed Authorization: Bearer secret-token'
|
||||||
)
|
)
|
||||||
expect(session.abort).toHaveBeenCalledOnce()
|
expect(session.abort).toHaveBeenCalledOnce()
|
||||||
await runtime.dispose()
|
await runtime.dispose()
|
||||||
@@ -1661,7 +1673,7 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
|
|||||||
const runtime = embeddedRuntime(client)
|
const runtime = embeddedRuntime(client)
|
||||||
|
|
||||||
await expect(collectRun(runtime)).rejects.toThrow(
|
await expect(collectRun(runtime)).rejects.toThrow(
|
||||||
'prompt rejected Authorization: [REDACTED]'
|
'prompt rejected Authorization: Bearer secret-token'
|
||||||
)
|
)
|
||||||
await runtime.dispose()
|
await runtime.dispose()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
type RuntimeSandboxResolution
|
type RuntimeSandboxResolution
|
||||||
} from './runtime-sandbox'
|
} from './runtime-sandbox'
|
||||||
import {
|
import {
|
||||||
|
boundedToolDetail,
|
||||||
safeToolErrorDetail
|
safeToolErrorDetail
|
||||||
} from './approval-summary'
|
} from './approval-summary'
|
||||||
|
|
||||||
@@ -978,6 +979,8 @@ export class OpenCodeRuntime implements AgentRuntime {
|
|||||||
{
|
{
|
||||||
name: string
|
name: string
|
||||||
state: 'pending' | 'running' | 'completed' | 'failed'
|
state: 'pending' | 'running' | 'completed' | 'failed'
|
||||||
|
input?: string
|
||||||
|
output?: string
|
||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
>()
|
>()
|
||||||
@@ -1077,9 +1080,19 @@ export class OpenCodeRuntime implements AgentRuntime {
|
|||||||
part.state.status === 'error'
|
part.state.status === 'error'
|
||||||
? safeToolErrorDetail(part.state.error)
|
? safeToolErrorDetail(part.state.error)
|
||||||
: undefined
|
: 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, {
|
toolStates.set(callId, {
|
||||||
name: toolName,
|
name: toolName,
|
||||||
state,
|
state,
|
||||||
|
...(input ? { input } : {}),
|
||||||
|
...(output ? { output } : {}),
|
||||||
...(error ? { error } : {})
|
...(error ? { error } : {})
|
||||||
})
|
})
|
||||||
yield {
|
yield {
|
||||||
@@ -1089,6 +1102,8 @@ export class OpenCodeRuntime implements AgentRuntime {
|
|||||||
name: toolName,
|
name: toolName,
|
||||||
state,
|
state,
|
||||||
summary: `OpenCode 工具:${toolName}`,
|
summary: `OpenCode 工具:${toolName}`,
|
||||||
|
...(input ? { input } : {}),
|
||||||
|
...(output ? { output } : {}),
|
||||||
...(error ? { error } : {})
|
...(error ? { error } : {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1309,6 +1324,8 @@ export class OpenCodeRuntime implements AgentRuntime {
|
|||||||
name: tool.name,
|
name: tool.name,
|
||||||
state: 'failed',
|
state: 'failed',
|
||||||
summary: `OpenCode 工具:${tool.name}`,
|
summary: `OpenCode 工具:${tool.name}`,
|
||||||
|
...(tool.input ? { input: tool.input } : {}),
|
||||||
|
...(tool.output ? { output: tool.output } : {}),
|
||||||
...(tool.error ? { error: tool.error } : {})
|
...(tool.error ? { error: tool.error } : {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ describe('BrowserModelTools', () => {
|
|||||||
})
|
})
|
||||||
expect(first.scopeKey).not.toBe(second.scopeKey)
|
expect(first.scopeKey).not.toBe(second.scopeKey)
|
||||||
expect(first.allowPermanent).toBe(false)
|
expect(first.allowPermanent).toBe(false)
|
||||||
|
expect(first.description).toContain('包括密码字段')
|
||||||
expect(JSON.stringify(first)).not.toContain('top-secret')
|
expect(JSON.stringify(first)).not.toContain('top-secret')
|
||||||
|
|
||||||
const result = await tools.callTool(
|
const result = await tools.callTool(
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ const definitions = [
|
|||||||
type: 'string',
|
type: 'string',
|
||||||
minLength: 1,
|
minLength: 1,
|
||||||
maxLength: 8_192,
|
maxLength: 8_192,
|
||||||
description: '完整的公开 HTTP(S) URL'
|
description: '当前设备可连接的完整 HTTP 或 HTTPS URL'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
required: ['url'],
|
required: ['url'],
|
||||||
@@ -267,7 +267,7 @@ export class BrowserModelTools {
|
|||||||
scopeKey = `model:browser:click:${currentOrigin}:${input.ref}`
|
scopeKey = `model:browser:click:${currentOrigin}:${input.ref}`
|
||||||
} else if (name === 'browser_type') {
|
} else if (name === 'browser_type') {
|
||||||
const input = browserTypeInputSchema.parse(argumentsValue)
|
const input = browserTypeInputSchema.parse(argumentsValue)
|
||||||
description = `向 ${currentOrigin} 页面中的元素 ${input.ref} 输入已隐藏的文本。密码、文件和隐藏字段会被拒绝。`
|
description = `向 ${currentOrigin} 页面中的元素 ${input.ref} 输入已隐藏的文本,包括密码字段;文件、隐藏、禁用和只读字段不支持输入。`
|
||||||
argumentSummary = `元素:${input.ref};内容:[已隐藏,${input.text.length} 个字符]`
|
argumentSummary = `元素:${input.ref};内容:[已隐藏,${input.text.length} 个字符]`
|
||||||
// A session approval must never authorize a later value, even for the
|
// A session approval must never authorize a later value, even for the
|
||||||
// same element. The nonce intentionally makes this invocation-only.
|
// same element. The nonce intentionally makes this invocation-only.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
type BrowserSessionLike
|
type BrowserSessionLike
|
||||||
} from './browser-service'
|
} from './browser-service'
|
||||||
import type { BrowserWebContents } from './electron-browser-session'
|
import type { BrowserWebContents } from './electron-browser-session'
|
||||||
|
import type { BrowserLiveState } from '../../shared/contracts'
|
||||||
|
|
||||||
type HarnessSlot = {
|
type HarnessSlot = {
|
||||||
currentOrigin?: string
|
currentOrigin?: string
|
||||||
@@ -30,19 +31,33 @@ function createHarness(options: {
|
|||||||
cleanupTimeoutMs?: number
|
cleanupTimeoutMs?: number
|
||||||
dispose?: () => Promise<void>
|
dispose?: () => Promise<void>
|
||||||
sessionGate?: Promise<void>
|
sessionGate?: Promise<void>
|
||||||
|
captureScreenshot?: (
|
||||||
|
signal: AbortSignal
|
||||||
|
) => Promise<{
|
||||||
|
type: 'image'
|
||||||
|
mimeType: 'image/jpeg'
|
||||||
|
data: string
|
||||||
|
}>
|
||||||
|
driverScreenshot?: BrowserDriverLike['screenshot']
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const slots: HarnessSlot[] = []
|
const slots: HarnessSlot[] = []
|
||||||
const byContents = new Map<BrowserWebContents, HarnessSlot>()
|
const byContents = new Map<BrowserWebContents, HarnessSlot>()
|
||||||
const createSession = vi.fn(async (): Promise<BrowserSessionLike> => {
|
const createSession = vi.fn(async (): Promise<BrowserSessionLike> => {
|
||||||
await options.sessionGate
|
await options.sessionGate
|
||||||
const webContents = {} as BrowserWebContents
|
|
||||||
const slot = {} as HarnessSlot
|
const slot = {} as HarnessSlot
|
||||||
|
const webContents = {
|
||||||
|
getURL: () => `${slot.currentOrigin}/page`
|
||||||
|
} as BrowserWebContents
|
||||||
const session: BrowserSessionLike = {
|
const session: BrowserSessionLike = {
|
||||||
webContents,
|
webContents,
|
||||||
approveNavigation: vi.fn((target) => {
|
approveNavigation: vi.fn((target) => {
|
||||||
slot.approvedOrigin = target.origin
|
slot.approvedOrigin = target.origin
|
||||||
}),
|
}),
|
||||||
getCurrentOrigin: vi.fn(() => slot.currentOrigin),
|
getCurrentOrigin: vi.fn(() => slot.currentOrigin),
|
||||||
|
openInteraction: vi.fn(async () => undefined),
|
||||||
|
...(options.captureScreenshot
|
||||||
|
? { captureScreenshot: vi.fn(options.captureScreenshot) }
|
||||||
|
: {}),
|
||||||
dispose: vi.fn(options.dispose ?? (async () => undefined))
|
dispose: vi.fn(options.dispose ?? (async () => undefined))
|
||||||
}
|
}
|
||||||
const driver: BrowserDriverLike = {
|
const driver: BrowserDriverLike = {
|
||||||
@@ -67,11 +82,14 @@ function createHarness(options: {
|
|||||||
slot.currentOrigin = canonicalizeBrowserUrl(target.url).origin
|
slot.currentOrigin = canonicalizeBrowserUrl(target.url).origin
|
||||||
return { url: target.url }
|
return { url: target.url }
|
||||||
}),
|
}),
|
||||||
screenshot: vi.fn(async () => ({
|
screenshot: vi.fn(
|
||||||
type: 'image' as const,
|
options.driverScreenshot ??
|
||||||
mimeType: 'image/jpeg' as const,
|
(async () => ({
|
||||||
data: '/9j/2Q=='
|
type: 'image' as const,
|
||||||
})),
|
mimeType: 'image/jpeg' as const,
|
||||||
|
data: '/9j/2Q=='
|
||||||
|
}))
|
||||||
|
),
|
||||||
dispose: vi.fn()
|
dispose: vi.fn()
|
||||||
}
|
}
|
||||||
Object.assign(slot, { session, driver })
|
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 () => {
|
it('does not publish ready after a session is stopped during frame capture', async () => {
|
||||||
const harness = createHarness()
|
const harness = createHarness()
|
||||||
const signal = new AbortController().signal
|
const signal = new AbortController().signal
|
||||||
const states: string[] = []
|
const states: BrowserLiveState[] = []
|
||||||
harness.service.onState((state) => states.push(state.status))
|
harness.service.onState((state) => states.push(state))
|
||||||
await harness.service.navigate(
|
await harness.service.navigate(
|
||||||
'conversation',
|
'conversation',
|
||||||
'https://example.com/',
|
'https://example.com/',
|
||||||
@@ -181,7 +199,128 @@ describe('BrowserService', () => {
|
|||||||
await harness.service.releaseConversation('conversation')
|
await harness.service.releaseConversation('conversation')
|
||||||
|
|
||||||
await expect(click).rejects.toThrow('浏览器会话已释放')
|
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 () => {
|
it('isolates browser state and drivers by conversation', async () => {
|
||||||
@@ -244,6 +383,58 @@ describe('BrowserService', () => {
|
|||||||
await harness.service.dispose()
|
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 () => {
|
it('does not let a canceled queued waiter clear the active operation owner', async () => {
|
||||||
const harness = createHarness()
|
const harness = createHarness()
|
||||||
const signal = new AbortController().signal
|
const signal = new AbortController().signal
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
import type { BrowserScreenshot } from './browser-screenshot'
|
import type { BrowserScreenshot } from './browser-screenshot'
|
||||||
import {
|
import {
|
||||||
ElectronBrowserSession,
|
ElectronBrowserSession,
|
||||||
|
type BrowserParentWindowHandle,
|
||||||
type BrowserWebContents
|
type BrowserWebContents
|
||||||
} from './electron-browser-session'
|
} from './electron-browser-session'
|
||||||
import type { BrowserLiveState } from '../../shared/contracts'
|
import type { BrowserLiveState } from '../../shared/contracts'
|
||||||
@@ -20,6 +21,7 @@ export type BrowserSessionLike = {
|
|||||||
target: Awaited<ReturnType<BrowserUrlPolicy['validate']>>
|
target: Awaited<ReturnType<BrowserUrlPolicy['validate']>>
|
||||||
): void
|
): void
|
||||||
getCurrentOrigin(): string | undefined
|
getCurrentOrigin(): string | undefined
|
||||||
|
openInteraction(): Promise<BrowserScreenshot | undefined>
|
||||||
captureScreenshot?(signal: AbortSignal): Promise<BrowserScreenshot>
|
captureScreenshot?(signal: AbortSignal): Promise<BrowserScreenshot>
|
||||||
dispose(): Promise<void>
|
dispose(): Promise<void>
|
||||||
}
|
}
|
||||||
@@ -45,6 +47,7 @@ export type BrowserServiceOptions = {
|
|||||||
idleTimeoutMs?: number
|
idleTimeoutMs?: number
|
||||||
cleanupTimeoutMs?: number
|
cleanupTimeoutMs?: number
|
||||||
liveFrameDelayMs?: number
|
liveFrameDelayMs?: number
|
||||||
|
parentWindow?: BrowserParentWindowHandle
|
||||||
createSession?: (
|
createSession?: (
|
||||||
policy: BrowserUrlPolicy,
|
policy: BrowserUrlPolicy,
|
||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
@@ -112,9 +115,16 @@ async function boundedCleanup(
|
|||||||
|
|
||||||
async function defaultCreateSession(
|
async function defaultCreateSession(
|
||||||
policy: BrowserUrlPolicy,
|
policy: BrowserUrlPolicy,
|
||||||
signal: AbortSignal
|
signal: AbortSignal,
|
||||||
|
parentWindow?: BrowserParentWindowHandle
|
||||||
): Promise<BrowserSessionLike> {
|
): Promise<BrowserSessionLike> {
|
||||||
return ElectronBrowserSession.create({ policy }, signal)
|
return ElectronBrowserSession.create(
|
||||||
|
{
|
||||||
|
policy,
|
||||||
|
...(parentWindow ? { parentWindow } : {})
|
||||||
|
},
|
||||||
|
signal
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultCreateDriver(webContents: BrowserWebContents): BrowserDriverLike {
|
function defaultCreateDriver(webContents: BrowserWebContents): BrowserDriverLike {
|
||||||
@@ -152,7 +162,10 @@ export class BrowserService {
|
|||||||
this.cleanupTimeoutMs =
|
this.cleanupTimeoutMs =
|
||||||
options.cleanupTimeoutMs ?? DEFAULT_CLEANUP_TIMEOUT_MS
|
options.cleanupTimeoutMs ?? DEFAULT_CLEANUP_TIMEOUT_MS
|
||||||
this.liveFrameDelayMs = options.liveFrameDelayMs ?? 100
|
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
|
this.createDriver = options.createDriver ?? defaultCreateDriver
|
||||||
if (
|
if (
|
||||||
!Number.isSafeInteger(this.maximumSessions) ||
|
!Number.isSafeInteger(this.maximumSessions) ||
|
||||||
@@ -198,6 +211,9 @@ export class BrowserService {
|
|||||||
conversationId,
|
conversationId,
|
||||||
status,
|
status,
|
||||||
...(previous?.url ? { url: previous.url } : {}),
|
...(previous?.url ? { url: previous.url } : {}),
|
||||||
|
...(status !== 'stopped' && previous?.frameDataUrl
|
||||||
|
? { frameDataUrl: previous.frameDataUrl }
|
||||||
|
: {}),
|
||||||
...update,
|
...update,
|
||||||
updatedAt: Date.now()
|
updatedAt: Date.now()
|
||||||
}
|
}
|
||||||
@@ -235,40 +251,73 @@ export class BrowserService {
|
|||||||
signal
|
signal
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const previewController = new AbortController()
|
const captureDeadline = AbortSignal.any([
|
||||||
const timeout = setTimeout(
|
signal,
|
||||||
() =>
|
AbortSignal.timeout(6_000)
|
||||||
previewController.abort(
|
])
|
||||||
new Error('浏览器实时画面捕获超时')
|
for (let attempt = 0; attempt < 3 && !frame; attempt += 1) {
|
||||||
),
|
if (attempt > 0) {
|
||||||
2_000
|
try {
|
||||||
)
|
await waitFor(
|
||||||
try {
|
new Promise<void>((resolve) =>
|
||||||
const previewSignal = AbortSignal.any([
|
setTimeout(resolve, attempt * 150)
|
||||||
signal,
|
),
|
||||||
previewController.signal
|
captureDeadline
|
||||||
])
|
)
|
||||||
frame = slot.session.captureScreenshot
|
} catch {
|
||||||
? await slot.session.captureScreenshot(previewSignal)
|
signal.throwIfAborted()
|
||||||
: await slot.driver.screenshot(previewSignal)
|
break
|
||||||
} catch {
|
}
|
||||||
signal.throwIfAborted()
|
}
|
||||||
// Browser control succeeds even when the optional live frame fails.
|
if (slot.session.captureScreenshot) {
|
||||||
} finally {
|
try {
|
||||||
clearTimeout(timeout)
|
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()
|
signal.throwIfAborted()
|
||||||
if (slot.released || this.slots.get(conversationId) !== slot) {
|
if (slot.released || this.slots.get(conversationId) !== slot) {
|
||||||
return
|
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', {
|
this.emitState(conversationId, 'ready', {
|
||||||
...(url ? { url } : {}),
|
...(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,
|
slot: BrowserSlot,
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
operation: (effectiveSignal: AbortSignal) => Promise<T>,
|
operation: (effectiveSignal: AbortSignal) => Promise<T>,
|
||||||
status?: 'loading' | 'acting'
|
status?: 'loading' | 'acting' | 'interactive'
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
signal.throwIfAborted()
|
signal.throwIfAborted()
|
||||||
if (slot.released || this.disposed) {
|
if (slot.released || this.disposed) {
|
||||||
@@ -496,7 +545,7 @@ export class BrowserService {
|
|||||||
private async runInSession<T>(
|
private async runInSession<T>(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
status: 'loading' | 'acting',
|
status: 'loading' | 'acting' | 'interactive',
|
||||||
failureStage: string,
|
failureStage: string,
|
||||||
operation: (
|
operation: (
|
||||||
slot: BrowserSlot,
|
slot: BrowserSlot,
|
||||||
@@ -715,10 +764,17 @@ export class BrowserService {
|
|||||||
'浏览器截图',
|
'浏览器截图',
|
||||||
async (slot, effectiveSignal) => {
|
async (slot, effectiveSignal) => {
|
||||||
await this.verifyCurrentOriginOrRelease(slot)
|
await this.verifyCurrentOriginOrRelease(slot)
|
||||||
const screenshot =
|
let screenshot: BrowserScreenshot | undefined
|
||||||
slot.session.captureScreenshot
|
if (slot.session.captureScreenshot) {
|
||||||
? await slot.session.captureScreenshot(effectiveSignal)
|
try {
|
||||||
: await slot.driver.screenshot(effectiveSignal)
|
screenshot = await slot.session.captureScreenshot(
|
||||||
|
effectiveSignal
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
effectiveSignal.throwIfAborted()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
screenshot ??= await slot.driver.screenshot(effectiveSignal)
|
||||||
await this.captureFrame(
|
await this.captureFrame(
|
||||||
conversationId,
|
conversationId,
|
||||||
slot,
|
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> {
|
async releaseConversation(conversationId: string): Promise<void> {
|
||||||
this.releaseRequests.add(conversationId)
|
this.releaseRequests.add(conversationId)
|
||||||
let releasedSlot = false
|
let releasedSlot = false
|
||||||
|
|||||||
@@ -536,7 +536,7 @@ describe('CdpBrowserDriver', () => {
|
|||||||
driver.dispose()
|
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 harness = createHarness(standardCommand)
|
||||||
const driver = new CdpBrowserDriver(harness.webContents)
|
const driver = new CdpBrowserDriver(harness.webContents)
|
||||||
const snapshot = await driver.snapshot(new AbortController().signal)
|
const snapshot = await driver.snapshot(new AbortController().signal)
|
||||||
@@ -545,15 +545,16 @@ describe('CdpBrowserDriver', () => {
|
|||||||
throw new Error('password missing')
|
throw new Error('password missing')
|
||||||
}
|
}
|
||||||
await expect(
|
await expect(
|
||||||
driver.type(password.ref, 'never-send', new AbortController().signal)
|
driver.type(password.ref, 'login-secret', new AbortController().signal)
|
||||||
).rejects.toThrow('受保护')
|
).resolves.toBeUndefined()
|
||||||
expect(
|
expect(
|
||||||
harness.sendCommand.mock.calls.some(
|
harness.sendCommand.mock.calls.some(
|
||||||
([method, parameters]) =>
|
([method, parameters]) =>
|
||||||
method === 'Input.insertText' &&
|
method === 'Input.insertText' &&
|
||||||
parameters?.text === 'never-send'
|
parameters?.text === 'login-secret'
|
||||||
)
|
)
|
||||||
).toBe(false)
|
).toBe(true)
|
||||||
|
expect(JSON.stringify(snapshot)).not.toContain('secret')
|
||||||
driver.dispose()
|
driver.dispose()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -88,7 +88,6 @@ type RefBinding = {
|
|||||||
backendNodeId: number
|
backendNodeId: number
|
||||||
generation: number
|
generation: number
|
||||||
role: string
|
role: string
|
||||||
protected: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CdpBrowserDriverOptions = {
|
export type CdpBrowserDriverOptions = {
|
||||||
@@ -591,8 +590,7 @@ export class CdpBrowserDriver {
|
|||||||
this.refs.set(ref, {
|
this.refs.set(ref, {
|
||||||
backendNodeId: node.backendDOMNodeId,
|
backendNodeId: node.backendDOMNodeId,
|
||||||
generation: this.generation,
|
generation: this.generation,
|
||||||
role,
|
role
|
||||||
protected: protectedNode
|
|
||||||
})
|
})
|
||||||
output.push(item)
|
output.push(item)
|
||||||
}
|
}
|
||||||
@@ -648,7 +646,6 @@ export class CdpBrowserDriver {
|
|||||||
typeof node.nodeName === 'string' ? node.nodeName.toLowerCase() : ''
|
typeof node.nodeName === 'string' ? node.nodeName.toLowerCase() : ''
|
||||||
const inputType = (attributeMap.get('type') ?? '').toLowerCase()
|
const inputType = (attributeMap.get('type') ?? '').toLowerCase()
|
||||||
const blocked =
|
const blocked =
|
||||||
binding.protected ||
|
|
||||||
attributeMap.has('hidden') ||
|
attributeMap.has('hidden') ||
|
||||||
attributeMap.has('disabled') ||
|
attributeMap.has('disabled') ||
|
||||||
attributeMap.has('inert') ||
|
attributeMap.has('inert') ||
|
||||||
@@ -656,10 +653,9 @@ export class CdpBrowserDriver {
|
|||||||
attributeMap.get('aria-hidden') === 'true' ||
|
attributeMap.get('aria-hidden') === 'true' ||
|
||||||
attributeMap.get('aria-disabled') === 'true' ||
|
attributeMap.get('aria-disabled') === 'true' ||
|
||||||
inputType === 'hidden' ||
|
inputType === 'hidden' ||
|
||||||
inputType === 'password' ||
|
|
||||||
inputType === 'file'
|
inputType === 'file'
|
||||||
if (blocked) {
|
if (blocked) {
|
||||||
throw new Error('浏览器拒绝操作受保护、隐藏或禁用字段')
|
throw new Error('浏览器拒绝操作隐藏、禁用、只读或文件字段')
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
action === 'type' &&
|
action === 'type' &&
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ function createHarness() {
|
|||||||
const debuggerEvents = new EventEmitter()
|
const debuggerEvents = new EventEmitter()
|
||||||
const contentEvents = new EventEmitter()
|
const contentEvents = new EventEmitter()
|
||||||
const partitionEvents = new EventEmitter()
|
const partitionEvents = new EventEmitter()
|
||||||
|
const windowEvents = new EventEmitter()
|
||||||
let currentUrl = ''
|
let currentUrl = ''
|
||||||
let openHandler: ((details: { url: string }) => { action: 'deny' }) | undefined
|
let openHandler: ((details: { url: string }) => { action: 'deny' }) | undefined
|
||||||
const sendCommand = vi.fn(async () => ({}))
|
const sendCommand = vi.fn(async () => ({}))
|
||||||
@@ -71,6 +72,21 @@ function createHarness() {
|
|||||||
loadURL: vi.fn(async (url: string) => {
|
loadURL: vi.fn(async (url: string) => {
|
||||||
currentUrl = url
|
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(),
|
destroy: vi.fn(),
|
||||||
isDestroyed: vi.fn(() => false)
|
isDestroyed: vi.fn(() => false)
|
||||||
}
|
}
|
||||||
@@ -125,6 +141,7 @@ function createHarness() {
|
|||||||
contentEvents,
|
contentEvents,
|
||||||
debuggerEvents,
|
debuggerEvents,
|
||||||
partitionEvents,
|
partitionEvents,
|
||||||
|
windowEvents,
|
||||||
partition,
|
partition,
|
||||||
proxy,
|
proxy,
|
||||||
policy,
|
policy,
|
||||||
@@ -276,6 +293,74 @@ describe('ElectronBrowserSession', () => {
|
|||||||
await session.dispose()
|
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 () => {
|
it('detaches listeners and clears isolated data on idempotent disposal', async () => {
|
||||||
const harness = createHarness()
|
const harness = createHarness()
|
||||||
const session = await ElectronBrowserSession.create({
|
const session = await ElectronBrowserSession.create({
|
||||||
|
|||||||
@@ -49,10 +49,23 @@ export type BrowserWebContents = {
|
|||||||
export type BrowserWindowHandle = {
|
export type BrowserWindowHandle = {
|
||||||
webContents: BrowserWebContents
|
webContents: BrowserWebContents
|
||||||
loadURL(url: string): Promise<unknown>
|
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
|
destroy(): void
|
||||||
isDestroyed(): boolean
|
isDestroyed(): boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BrowserParentWindowHandle = {
|
||||||
|
setEnabled?(enabled: boolean): void
|
||||||
|
focus?(): void
|
||||||
|
isDestroyed?(): boolean
|
||||||
|
}
|
||||||
|
|
||||||
export type BrowserPartitionSession = {
|
export type BrowserPartitionSession = {
|
||||||
setPermissionCheckHandler(
|
setPermissionCheckHandler(
|
||||||
handler: (...argumentsValue: never[]) => boolean
|
handler: (...argumentsValue: never[]) => boolean
|
||||||
@@ -100,6 +113,7 @@ export type ElectronBrowserSessionOptions = {
|
|||||||
options: Record<string, unknown>
|
options: Record<string, unknown>
|
||||||
) => Promise<BrowserWindowHandle>
|
) => Promise<BrowserWindowHandle>
|
||||||
createProxy?: (policy: BrowserUrlPolicy) => FilteringProxyLike
|
createProxy?: (policy: BrowserUrlPolicy) => FilteringProxyLike
|
||||||
|
parentWindow?: BrowserParentWindowHandle
|
||||||
}
|
}
|
||||||
|
|
||||||
type Listener = {
|
type Listener = {
|
||||||
@@ -211,6 +225,11 @@ export class ElectronBrowserSession {
|
|||||||
readonly webContents: BrowserWebContents
|
readonly webContents: BrowserWebContents
|
||||||
private approvedOrigin?: string
|
private approvedOrigin?: string
|
||||||
private readonly listeners: Listener[] = []
|
private readonly listeners: Listener[] = []
|
||||||
|
private interaction?: {
|
||||||
|
promise: Promise<BrowserScreenshot | undefined>
|
||||||
|
resolve(frame?: BrowserScreenshot): void
|
||||||
|
}
|
||||||
|
private interactionClosing?: Promise<void>
|
||||||
private disposed = false
|
private disposed = false
|
||||||
|
|
||||||
private constructor(
|
private constructor(
|
||||||
@@ -219,7 +238,8 @@ export class ElectronBrowserSession {
|
|||||||
private readonly window: BrowserWindowHandle,
|
private readonly window: BrowserWindowHandle,
|
||||||
private readonly proxy: FilteringProxyLike,
|
private readonly proxy: FilteringProxyLike,
|
||||||
partition: string,
|
partition: string,
|
||||||
private readonly cleanupTimeoutMs: number
|
private readonly cleanupTimeoutMs: number,
|
||||||
|
private readonly parentWindow?: BrowserParentWindowHandle
|
||||||
) {
|
) {
|
||||||
this.partition = partition
|
this.partition = partition
|
||||||
this.webContents = window.webContents
|
this.webContents = window.webContents
|
||||||
@@ -301,6 +321,13 @@ export class ElectronBrowserSession {
|
|||||||
show: false,
|
show: false,
|
||||||
width: 1280,
|
width: 1280,
|
||||||
height: 900,
|
height: 900,
|
||||||
|
title: 'GoodBuddy 浏览器交互',
|
||||||
|
autoHideMenuBar: true,
|
||||||
|
...(options.parentWindow
|
||||||
|
? {
|
||||||
|
parent: options.parentWindow
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
partition,
|
partition,
|
||||||
sandbox: true,
|
sandbox: true,
|
||||||
@@ -308,6 +335,7 @@ export class ElectronBrowserSession {
|
|||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
nodeIntegrationInSubFrames: false,
|
nodeIntegrationInSubFrames: false,
|
||||||
nodeIntegrationInWorker: false,
|
nodeIntegrationInWorker: false,
|
||||||
|
backgroundThrottling: false,
|
||||||
webSecurity: true,
|
webSecurity: true,
|
||||||
allowRunningInsecureContent: false,
|
allowRunningInsecureContent: false,
|
||||||
plugins: false,
|
plugins: false,
|
||||||
@@ -337,7 +365,8 @@ export class ElectronBrowserSession {
|
|||||||
window,
|
window,
|
||||||
managedProxy,
|
managedProxy,
|
||||||
partition,
|
partition,
|
||||||
cleanupTimeoutMs
|
cleanupTimeoutMs,
|
||||||
|
options.parentWindow
|
||||||
)
|
)
|
||||||
setupStage = '初始化浏览器协议'
|
setupStage = '初始化浏览器协议'
|
||||||
await boundedSetup(result.initialize(), signal, setupTimeoutMs)
|
await boundedSetup(result.initialize(), signal, setupTimeoutMs)
|
||||||
@@ -383,6 +412,21 @@ export class ElectronBrowserSession {
|
|||||||
|
|
||||||
private async initialize(): Promise<void> {
|
private async initialize(): Promise<void> {
|
||||||
const contents = this.webContents
|
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' }))
|
contents.setWindowOpenHandler(() => ({ action: 'deny' }))
|
||||||
this.listen(contents, 'will-navigate', (event: { preventDefault(): void }, details: { url?: string } | string) => {
|
this.listen(contents, 'will-navigate', (event: { preventDefault(): void }, details: { url?: string } | string) => {
|
||||||
const url = typeof details === 'string' ? details : details.url
|
const url = typeof details === 'string' ? details : details.url
|
||||||
@@ -513,12 +557,98 @@ export class ElectronBrowserSession {
|
|||||||
this.approvedOrigin = target.origin
|
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> {
|
async dispose(): Promise<void> {
|
||||||
if (this.disposed) {
|
if (this.disposed) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.disposed = true
|
this.disposed = true
|
||||||
this.approvedOrigin = undefined
|
this.approvedOrigin = undefined
|
||||||
|
this.finishInteraction()
|
||||||
for (const { target, event, listener } of this.listeners.splice(0)) {
|
for (const { target, event, listener } of this.listeners.splice(0)) {
|
||||||
target.off(event, listener)
|
target.off(event, listener)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -303,7 +303,7 @@ if (hasSingleInstanceLock) {
|
|||||||
const speechTranscriptionService = new SpeechTranscriptionService(
|
const speechTranscriptionService = new SpeechTranscriptionService(
|
||||||
speechModelManager
|
speechModelManager
|
||||||
)
|
)
|
||||||
browserService = new BrowserService()
|
browserService = new BrowserService({ parentWindow: mainWindow })
|
||||||
const bundledRuntimePaths = resolveBundledRuntimePaths({
|
const bundledRuntimePaths = resolveBundledRuntimePaths({
|
||||||
appPath: app.getAppPath(),
|
appPath: app.getAppPath(),
|
||||||
resourcesPath: process.resourcesPath,
|
resourcesPath: process.resourcesPath,
|
||||||
|
|||||||
+33
-6
@@ -93,6 +93,7 @@ describe('registerIpcHandlers computer capabilities', () => {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
const onRuntimeSettingsChanged = vi.fn(async () => {})
|
const onRuntimeSettingsChanged = vi.fn(async () => {})
|
||||||
|
const interact = vi.fn(async () => {})
|
||||||
const releaseConversation = vi.fn(async () => {})
|
const releaseConversation = vi.fn(async () => {})
|
||||||
let browserStateListener:
|
let browserStateListener:
|
||||||
| ((state: BrowserLiveState) => void)
|
| ((state: BrowserLiveState) => void)
|
||||||
@@ -111,6 +112,7 @@ describe('registerIpcHandlers computer capabilities', () => {
|
|||||||
onRuntimeSettingsChanged,
|
onRuntimeSettingsChanged,
|
||||||
undefined,
|
undefined,
|
||||||
{
|
{
|
||||||
|
interact,
|
||||||
releaseConversation,
|
releaseConversation,
|
||||||
onState: (listener) => {
|
onState: (listener) => {
|
||||||
browserStateListener = listener
|
browserStateListener = listener
|
||||||
@@ -181,6 +183,15 @@ describe('registerIpcHandlers computer capabilities', () => {
|
|||||||
expect(releaseConversation).toHaveBeenCalledWith(
|
expect(releaseConversation).toHaveBeenCalledWith(
|
||||||
'browser-conversation'
|
'browser-conversation'
|
||||||
)
|
)
|
||||||
|
await expect(
|
||||||
|
electronMocks.handlers.get(ipcChannels.browserInteract)?.(event, {
|
||||||
|
conversationId: 'browser-conversation'
|
||||||
|
})
|
||||||
|
).resolves.toBeUndefined()
|
||||||
|
expect(interact).toHaveBeenCalledWith(
|
||||||
|
'browser-conversation',
|
||||||
|
expect.any(AbortSignal)
|
||||||
|
)
|
||||||
|
|
||||||
expect(() =>
|
expect(() =>
|
||||||
electronMocks.handlers.get(
|
electronMocks.handlers.get(
|
||||||
@@ -2019,7 +2030,25 @@ describe('registerIpcHandlers agent terminal state', () => {
|
|||||||
await harness.dispose()
|
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 = {
|
const runtime = {
|
||||||
capability: 'chat',
|
capability: 'chat',
|
||||||
requiresToolApproval: false,
|
requiresToolApproval: false,
|
||||||
@@ -2028,9 +2057,7 @@ describe('registerIpcHandlers agent terminal state', () => {
|
|||||||
dispose: vi.fn(),
|
dispose: vi.fn(),
|
||||||
async *run() {
|
async *run() {
|
||||||
yield* []
|
yield* []
|
||||||
throw new Error(
|
throw new TypeError('fetch failed', { cause: fetchCause })
|
||||||
'gateway failed Authorization: Bearer secret-token'
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const harness = createHarness(runtime)
|
const harness = createHarness(runtime)
|
||||||
@@ -2047,13 +2074,13 @@ describe('registerIpcHandlers agent terminal state', () => {
|
|||||||
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
|
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
|
||||||
requestId,
|
requestId,
|
||||||
'failed',
|
'failed',
|
||||||
'gateway failed Authorization: [REDACTED]'
|
expectedError
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
expect(harness.webContents.send).toHaveBeenCalledWith(
|
expect(harness.webContents.send).toHaveBeenCalledWith(
|
||||||
ipcChannels.agentEvent,
|
ipcChannels.agentEvent,
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
message: 'gateway failed Authorization: [REDACTED]'
|
message: expectedError
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
await harness.dispose()
|
await harness.dispose()
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
approvalDecisionSchema,
|
approvalDecisionSchema,
|
||||||
agentQuestionResponseSchema,
|
agentQuestionResponseSchema,
|
||||||
agentRequestSchema,
|
agentRequestSchema,
|
||||||
|
browserInteractRequestSchema,
|
||||||
browserStopRequestSchema,
|
browserStopRequestSchema,
|
||||||
knowledgeCreateSchema,
|
knowledgeCreateSchema,
|
||||||
knowledgeEntityUpdateSchema,
|
knowledgeEntityUpdateSchema,
|
||||||
@@ -491,6 +492,10 @@ export function registerIpcHandlers(
|
|||||||
onRuntimeSettingsChanged: () => Promise<void>,
|
onRuntimeSettingsChanged: () => Promise<void>,
|
||||||
onBeforeClearLocalData?: () => Promise<void>,
|
onBeforeClearLocalData?: () => Promise<void>,
|
||||||
browserControl?: {
|
browserControl?: {
|
||||||
|
interact(
|
||||||
|
conversationId: string,
|
||||||
|
signal: AbortSignal
|
||||||
|
): Promise<void>
|
||||||
releaseConversation(conversationId: string): Promise<void>
|
releaseConversation(conversationId: string): Promise<void>
|
||||||
onState(listener: (state: BrowserLiveState) => void): () => 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) => {
|
ipcMain.handle(ipcChannels.agentRun, async (event, input: unknown) => {
|
||||||
assertTrustedSender(event, window)
|
assertTrustedSender(event, window)
|
||||||
if (executionPaused || shuttingDown) {
|
if (executionPaused || shuttingDown) {
|
||||||
|
|||||||
@@ -159,6 +159,12 @@ const desktopApi: DesktopApi = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
browser: {
|
browser: {
|
||||||
|
interact: async (conversationId: string) => {
|
||||||
|
await ipcRenderer.invoke(
|
||||||
|
ipcChannels.browserInteract,
|
||||||
|
{ conversationId }
|
||||||
|
)
|
||||||
|
},
|
||||||
stop: async (conversationId: string) => {
|
stop: async (conversationId: string) => {
|
||||||
await ipcRenderer.invoke(
|
await ipcRenderer.invoke(
|
||||||
ipcChannels.browserStop,
|
ipcChannels.browserStop,
|
||||||
|
|||||||
+102
-10
@@ -94,6 +94,7 @@ const api: DesktopApi = {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
browser: {
|
browser: {
|
||||||
|
interact: vi.fn(async () => {}),
|
||||||
stop: vi.fn(async () => {}),
|
stop: vi.fn(async () => {}),
|
||||||
onState: vi.fn((listener) => {
|
onState: vi.fn((listener) => {
|
||||||
browserListener = listener
|
browserListener = listener
|
||||||
@@ -828,7 +829,18 @@ describe('App', () => {
|
|||||||
callId: 'call-1',
|
callId: 'call-1',
|
||||||
name: 'read',
|
name: 'read',
|
||||||
state: 'running',
|
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?.({
|
agentListener?.({
|
||||||
requestId: request.requestId,
|
requestId: request.requestId,
|
||||||
@@ -846,7 +858,10 @@ describe('App', () => {
|
|||||||
callId: 'call-1',
|
callId: 'call-1',
|
||||||
name: 'read',
|
name: 'read',
|
||||||
state: 'completed',
|
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(
|
expect(
|
||||||
screen.getAllByText('OpenCode 工具:read')
|
screen.getAllByText('OpenCode 工具:read')
|
||||||
).toHaveLength(1)
|
).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(() => {
|
act(() => {
|
||||||
if (!request) {
|
if (!request) {
|
||||||
@@ -1040,8 +1075,19 @@ describe('App', () => {
|
|||||||
render(<App />)
|
render(<App />)
|
||||||
|
|
||||||
fireEvent.click(await screen.findByLabelText('添加附件'))
|
fireEvent.click(await screen.findByLabelText('添加附件'))
|
||||||
expect(await screen.findByText('需求说明.md')).toBeInTheDocument()
|
const composer = screen
|
||||||
expect(screen.getByText('页面截图.png')).toBeInTheDocument()
|
.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 提问'), {
|
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
|
||||||
target: { value: '分析这些附件' }
|
target: { value: '分析这些附件' }
|
||||||
})
|
})
|
||||||
@@ -1130,8 +1176,17 @@ describe('App', () => {
|
|||||||
render(<App />)
|
render(<App />)
|
||||||
|
|
||||||
fireEvent.click(await screen.findByLabelText('添加附件'))
|
fireEvent.click(await screen.findByLabelText('添加附件'))
|
||||||
|
const composer = screen
|
||||||
|
.getByLabelText('向 GoodBuddy 提问')
|
||||||
|
.closest<HTMLElement>('.composer')
|
||||||
|
expect(composer).not.toBeNull()
|
||||||
|
if (!composer) {
|
||||||
|
return
|
||||||
|
}
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.getAllByText(/^参考图-\d\.png$/u)).toHaveLength(5)
|
expect(
|
||||||
|
within(composer).getAllByText(/^参考图-\d\.png$/u)
|
||||||
|
).toHaveLength(5)
|
||||||
)
|
)
|
||||||
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
|
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
|
||||||
target: { value: '比较这五张图片' }
|
target: { value: '比较这五张图片' }
|
||||||
@@ -1188,8 +1243,15 @@ describe('App', () => {
|
|||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(api.context.captureWindow).toHaveBeenCalledWith('window-2')
|
expect(api.context.captureWindow).toHaveBeenCalledWith('window-2')
|
||||||
)
|
)
|
||||||
|
const composer = screen
|
||||||
|
.getByLabelText('向 GoodBuddy 提问')
|
||||||
|
.closest<HTMLElement>('.composer')
|
||||||
|
expect(composer).not.toBeNull()
|
||||||
|
if (!composer) {
|
||||||
|
return
|
||||||
|
}
|
||||||
expect(
|
expect(
|
||||||
await screen.findByText('窗口-Browser.jpg')
|
await within(composer).findByText('窗口-Browser.jpg')
|
||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1335,10 +1397,22 @@ describe('App', () => {
|
|||||||
expect(
|
expect(
|
||||||
await screen.findByRole('heading', { name: '工作区说明' })
|
await screen.findByRole('heading', { name: '工作区说明' })
|
||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
screen.getByRole('tab', { name: '工作区' })
|
||||||
|
).toHaveAttribute('aria-selected', 'true')
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('tab', { name: '预览' })
|
||||||
|
).not.toBeInTheDocument()
|
||||||
expect(api.workspace.readFile).toHaveBeenCalledWith(
|
expect(api.workspace.readFile).toHaveBeenCalledWith(
|
||||||
projectId,
|
projectId,
|
||||||
'README.md'
|
'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 () => {
|
it('opens workspace entries from their row actions', async () => {
|
||||||
@@ -2986,19 +3060,31 @@ describe('App', () => {
|
|||||||
fireEvent.click(screen.getByLabelText('切换助手工作栏'))
|
fireEvent.click(screen.getByLabelText('切换助手工作栏'))
|
||||||
expect(sidebar).toHaveClass('assistant-sidebar--open')
|
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: '上下文' }))
|
fireEvent.click(screen.getByRole('tab', { name: '上下文' }))
|
||||||
expect(
|
expect(
|
||||||
screen.getByText('尚未添加文件、截图或剪贴板内容。')
|
screen.getByText('尚未添加文件、截图或剪贴板内容。')
|
||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
fireEvent.click(screen.getByRole('tab', { name: '任务中心' }))
|
fireEvent.click(screen.getByRole('tab', { name: '工作区' }))
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(/查看当前和最近请求的运行状态/)
|
screen.getByText(/选择文件后在当前工作区内预览/)
|
||||||
).toBeInTheDocument()
|
).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('对话与导入成果')).toBeInTheDocument()
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(/保存并预览由对话生成或手动导入/)
|
screen.getByText(/查看并预览由对话生成或手动导入/)
|
||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('tab', { name: '预览' })
|
||||||
|
).not.toBeInTheDocument()
|
||||||
fireEvent.click(screen.getByLabelText('关闭助手工作栏'))
|
fireEvent.click(screen.getByLabelText('关闭助手工作栏'))
|
||||||
expect(sidebar).not.toHaveClass('assistant-sidebar--open')
|
expect(sidebar).not.toHaveClass('assistant-sidebar--open')
|
||||||
})
|
})
|
||||||
@@ -3036,6 +3122,12 @@ describe('App', () => {
|
|||||||
'src',
|
'src',
|
||||||
'data:image/jpeg;base64,/9j/2Q=='
|
'data:image/jpeg;base64,/9j/2Q=='
|
||||||
)
|
)
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole('button', { name: '交互' })
|
||||||
|
)
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.browser.interact).toHaveBeenCalledWith(conversationId)
|
||||||
|
)
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
screen.getByRole('button', { name: '停止浏览器' })
|
screen.getByRole('button', { name: '停止浏览器' })
|
||||||
)
|
)
|
||||||
|
|||||||
+150
-74
@@ -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(
|
function createConversation(
|
||||||
projectId?: string,
|
projectId?: string,
|
||||||
runtimeSelection?: AgentRuntimeSelection
|
runtimeSelection?: AgentRuntimeSelection
|
||||||
@@ -1829,6 +1930,8 @@ function App(): React.JSX.Element {
|
|||||||
name: event.name,
|
name: event.name,
|
||||||
state: event.state,
|
state: event.state,
|
||||||
summary: event.summary,
|
summary: event.summary,
|
||||||
|
input: event.input,
|
||||||
|
output: event.output,
|
||||||
error: event.error
|
error: event.error
|
||||||
}
|
}
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
@@ -2241,7 +2344,7 @@ function App(): React.JSX.Element {
|
|||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (assistantSidebarTab !== 'changes') {
|
if (assistantSidebarTab !== 'workspace') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
@@ -4151,14 +4254,20 @@ function App(): React.JSX.Element {
|
|||||||
)}
|
)}
|
||||||
{message.blocks && message.blocks.length > 0 ? (
|
{message.blocks && message.blocks.length > 0 ? (
|
||||||
<div className="message-blocks">
|
<div className="message-blocks">
|
||||||
{message.blocks.map((block) =>
|
{groupMessageBlocks(message.blocks).map((item) =>
|
||||||
block.type === 'reasoning' ? (
|
item.kind === 'tools' ? (
|
||||||
|
<ToolExecutionList
|
||||||
|
key={item.id}
|
||||||
|
tools={item.tools}
|
||||||
|
/>
|
||||||
|
) : item.block.type === 'reasoning' ? (
|
||||||
<details
|
<details
|
||||||
className="message-reasoning"
|
className="message-reasoning"
|
||||||
key={block.id}
|
key={item.block.id}
|
||||||
open={
|
open={
|
||||||
message.state === 'streaming' &&
|
message.state === 'streaming' &&
|
||||||
message.blocks?.at(-1)?.id === block.id
|
message.blocks?.at(-1)?.id ===
|
||||||
|
item.block.id
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<summary>
|
<summary>
|
||||||
@@ -4168,34 +4277,18 @@ function App(): React.JSX.Element {
|
|||||||
</summary>
|
</summary>
|
||||||
<div className="markdown-content message-reasoning__content">
|
<div className="markdown-content message-reasoning__content">
|
||||||
<MarkdownRenderer>
|
<MarkdownRenderer>
|
||||||
{block.content}
|
{item.block.content}
|
||||||
</MarkdownRenderer>
|
</MarkdownRenderer>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
) : block.type === 'text' ? (
|
|
||||||
<div
|
|
||||||
className="markdown-content message__content"
|
|
||||||
key={block.id}
|
|
||||||
>
|
|
||||||
<MarkdownRenderer>
|
|
||||||
{block.content}
|
|
||||||
</MarkdownRenderer>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className="tool-activity"
|
className="markdown-content message__content"
|
||||||
key={block.id}
|
key={item.block.id}
|
||||||
>
|
>
|
||||||
<TerminalSquare size={15} />
|
<MarkdownRenderer>
|
||||||
<div className="tool-activity__content">
|
{item.block.content}
|
||||||
<span>{block.tool.summary}</span>
|
</MarkdownRenderer>
|
||||||
{block.tool.error && (
|
|
||||||
<code>{block.tool.error}</code>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<small>
|
|
||||||
{toolStateLabels[block.tool.state]}
|
|
||||||
</small>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
@@ -4347,19 +4440,10 @@ function App(): React.JSX.Element {
|
|||||||
</details>
|
</details>
|
||||||
)}
|
)}
|
||||||
{(!message.blocks || message.blocks.length === 0) &&
|
{(!message.blocks || message.blocks.length === 0) &&
|
||||||
message.tools?.map((tool) => (
|
message.tools &&
|
||||||
<div
|
message.tools.length > 0 && (
|
||||||
className="tool-activity"
|
<ToolExecutionList tools={message.tools} />
|
||||||
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.subagents && message.subagents.length > 0 && (
|
{message.subagents && message.subagents.length > 0 && (
|
||||||
<section
|
<section
|
||||||
aria-label="子专家状态"
|
aria-label="子专家状态"
|
||||||
@@ -5292,17 +5376,29 @@ function App(): React.JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<RightAssistantSidebar
|
<RightAssistantSidebar
|
||||||
activities={activityRecords}
|
|
||||||
approvals={pendingSidebarApprovals}
|
approvals={pendingSidebarApprovals}
|
||||||
artifacts={sidebarArtifacts}
|
artifacts={sidebarArtifacts}
|
||||||
attachments={attachments}
|
attachments={attachments}
|
||||||
browserState={browserStates[activeId]}
|
browserState={browserStates[activeId]}
|
||||||
enabledLibraries={enabledSidebarLibraries}
|
enabledLibraries={enabledSidebarLibraries}
|
||||||
experts={assistantExperts}
|
|
||||||
heartbeatEntries={heartbeatEntries}
|
|
||||||
heartbeats={assistantHeartbeats}
|
heartbeats={assistantHeartbeats}
|
||||||
memories={assistantMemories}
|
memories={assistantMemories}
|
||||||
|
schedules={assistantSchedules}
|
||||||
onClose={() => setAssistantSidebarOpen(false)}
|
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 () => {
|
onStopBrowser={async () => {
|
||||||
if (!activeId) {
|
if (!activeId) {
|
||||||
return
|
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}
|
onCreateHeartbeat={createHeartbeat}
|
||||||
onCreateSchedule={async (input) => {
|
onCreateSchedule={async (input) => {
|
||||||
const schedule = await window.goodbuddy.schedules.create({
|
const schedule = await window.goodbuddy.schedules.create({
|
||||||
@@ -5342,7 +5428,6 @@ function App(): React.JSX.Element {
|
|||||||
})
|
})
|
||||||
setAssistantSchedules((current) => [schedule, ...current])
|
setAssistantSchedules((current) => [schedule, ...current])
|
||||||
}}
|
}}
|
||||||
onOpenConversation={openActivityConversation}
|
|
||||||
onImportArtifacts={async () => {
|
onImportArtifacts={async () => {
|
||||||
const imported = await window.goodbuddy.artifacts.importFiles(
|
const imported = await window.goodbuddy.artifacts.importFiles(
|
||||||
activeProjectId || undefined
|
activeProjectId || undefined
|
||||||
@@ -5352,7 +5437,7 @@ function App(): React.JSX.Element {
|
|||||||
...imported,
|
...imported,
|
||||||
...current
|
...current
|
||||||
])
|
])
|
||||||
setAssistantSidebarTab('artifacts')
|
setAssistantSidebarTab('results')
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onLoadArtifact={async (artifactId) => {
|
onLoadArtifact={async (artifactId) => {
|
||||||
@@ -5367,13 +5452,6 @@ function App(): React.JSX.Element {
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
onRemoveAttachment={removeAttachment}
|
onRemoveAttachment={removeAttachment}
|
||||||
onRemoveMemory={async (memoryId) => {
|
|
||||||
await window.goodbuddy.memory.remove(memoryId)
|
|
||||||
setAssistantMemories((current) =>
|
|
||||||
current.filter((memory) => memory.id !== memoryId)
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
onSetMemoryStatus={setMemoryStatus}
|
|
||||||
onRemoveHeartbeat={removeHeartbeat}
|
onRemoveHeartbeat={removeHeartbeat}
|
||||||
onRemoveSchedule={async (scheduleId) => {
|
onRemoveSchedule={async (scheduleId) => {
|
||||||
await window.goodbuddy.schedules.remove(scheduleId)
|
await window.goodbuddy.schedules.remove(scheduleId)
|
||||||
@@ -5381,16 +5459,6 @@ function App(): React.JSX.Element {
|
|||||||
current.filter((schedule) => schedule.id !== scheduleId)
|
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) => {
|
onRespondApproval={(approval, decision) => {
|
||||||
void respondToApproval(
|
void respondToApproval(
|
||||||
approval.conversationId,
|
approval.conversationId,
|
||||||
@@ -5399,13 +5467,21 @@ function App(): React.JSX.Element {
|
|||||||
decision
|
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}
|
onTabChange={setAssistantSidebarTab}
|
||||||
open={
|
open={
|
||||||
assistantSidebarOpen && view === 'chat'
|
assistantSidebarOpen && view === 'chat'
|
||||||
}
|
}
|
||||||
schedules={assistantSchedules}
|
|
||||||
tab={assistantSidebarTab}
|
tab={assistantSidebarTab}
|
||||||
tasks={assistantTasks}
|
|
||||||
workspaceChanges={workspaceChanges}
|
workspaceChanges={workspaceChanges}
|
||||||
workspaceProjectId={activeProjectId || undefined}
|
workspaceProjectId={activeProjectId || undefined}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -319,10 +319,14 @@ export function McpSettingsSection(): React.JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="settings-notice">
|
<p className="settings-notice">
|
||||||
内置工具由 GoodBuddy 提供,不属于 MCP Server。外部 MCP Server
|
自定义 MCP 当前仅用于直连模型,新建时默认分配给直连模型,并仅在 Execute
|
||||||
及其工具具有当前用户权限,请仅添加可信服务;远程访问令牌将由系统安全存储加密。
|
模式加载。内置共享 MCP 当前仅有知识库搜索,可供直连模型、OpenCode 和
|
||||||
当前版本仅由直连模型在 Execute 模式加载这些工具,并在每次调用前请求
|
Continue 使用。Runtime 自有 MCP 配置不在此处管理。
|
||||||
GoodBuddy 审批。
|
</p>
|
||||||
|
<p className="settings-notice">
|
||||||
|
内置工具由 GoodBuddy 提供,不属于 MCP Server。自定义 MCP Server
|
||||||
|
及其工具具有当前用户权限,请仅添加可信服务;远程访问令牌将由系统安全存储加密,
|
||||||
|
工具调用前仍需 GoodBuddy 审批。
|
||||||
</p>
|
</p>
|
||||||
{error && !editor && <p className="settings-warning">{error}</p>}
|
{error && !editor && <p className="settings-warning">{error}</p>}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import type {
|
import {
|
||||||
AssistantExpert,
|
RightAssistantSidebar,
|
||||||
AssistantTask
|
type AssistantSidebarTab,
|
||||||
} from '../../shared/assistant-contracts'
|
type SidebarArtifact
|
||||||
import { RightAssistantSidebar } from './RightAssistantSidebar'
|
} from './RightAssistantSidebar'
|
||||||
|
|
||||||
afterEach(cleanup)
|
afterEach(cleanup)
|
||||||
|
|
||||||
@@ -16,58 +16,48 @@ beforeEach(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function renderSidebar({
|
function renderSidebar({
|
||||||
tasks = [],
|
|
||||||
experts = [],
|
|
||||||
tab = 'context',
|
tab = 'context',
|
||||||
onCreateSchedule = vi.fn(async () => undefined)
|
artifacts = [],
|
||||||
|
onLoadArtifact = vi.fn(async () => undefined)
|
||||||
}: {
|
}: {
|
||||||
tasks?: AssistantTask[]
|
tab?: AssistantSidebarTab
|
||||||
experts?: AssistantExpert[]
|
artifacts?: SidebarArtifact[]
|
||||||
tab?: 'tasks' | 'context'
|
onLoadArtifact?: (artifactId: string) => Promise<void>
|
||||||
onCreateSchedule?: () => Promise<void>
|
|
||||||
} = {}): HTMLElement {
|
} = {}): HTMLElement {
|
||||||
render(
|
render(
|
||||||
<RightAssistantSidebar
|
<RightAssistantSidebar
|
||||||
activities={[]}
|
|
||||||
approvals={[]}
|
approvals={[]}
|
||||||
artifacts={[]}
|
artifacts={artifacts}
|
||||||
attachments={[]}
|
attachments={[]}
|
||||||
enabledLibraries={[]}
|
enabledLibraries={[]}
|
||||||
experts={experts}
|
|
||||||
heartbeatEntries={[]}
|
|
||||||
heartbeats={[]}
|
heartbeats={[]}
|
||||||
memories={[]}
|
memories={[]}
|
||||||
|
schedules={[]}
|
||||||
onClose={vi.fn()}
|
onClose={vi.fn()}
|
||||||
onCreateHeartbeat={vi.fn(async () => undefined)}
|
onCreateHeartbeat={vi.fn(async () => undefined)}
|
||||||
onCreateMemory={vi.fn(async () => undefined)}
|
onCreateSchedule={vi.fn(async () => undefined)}
|
||||||
onCreateSchedule={onCreateSchedule}
|
|
||||||
onImportArtifacts={vi.fn(async () => undefined)}
|
onImportArtifacts={vi.fn(async () => undefined)}
|
||||||
onListWorkspaceDirectory={vi.fn(async (path: string) => ({
|
onListWorkspaceDirectory={vi.fn(async (path: string) => ({
|
||||||
path,
|
path,
|
||||||
entries: [],
|
entries: [],
|
||||||
truncated: false
|
truncated: false
|
||||||
}))}
|
}))}
|
||||||
onLoadArtifact={vi.fn(async () => undefined)}
|
onLoadArtifact={onLoadArtifact}
|
||||||
onLoadWorkspaceFile={vi.fn()}
|
onLoadWorkspaceFile={vi.fn()}
|
||||||
onOpenWorkspaceEntry={vi.fn(async () => undefined)}
|
onOpenWorkspaceEntry={vi.fn(async () => undefined)}
|
||||||
onOpenConversation={vi.fn()}
|
onInteractBrowser={vi.fn(async () => undefined)}
|
||||||
onOpenHeartbeat={vi.fn()}
|
|
||||||
onRefreshChanges={vi.fn(async () => undefined)}
|
onRefreshChanges={vi.fn(async () => undefined)}
|
||||||
onRemoveAttachment={vi.fn()}
|
onRemoveAttachment={vi.fn()}
|
||||||
onRemoveHeartbeat={vi.fn(async () => undefined)}
|
onRemoveHeartbeat={vi.fn(async () => undefined)}
|
||||||
onRemoveMemory={vi.fn(async () => undefined)}
|
|
||||||
onRemoveSchedule={vi.fn(async () => undefined)}
|
onRemoveSchedule={vi.fn(async () => undefined)}
|
||||||
onRespondApproval={vi.fn()}
|
onRespondApproval={vi.fn()}
|
||||||
onRunHeartbeat={vi.fn(async () => undefined)}
|
onRunHeartbeat={vi.fn(async () => undefined)}
|
||||||
onRunSchedule={vi.fn(async () => undefined)}
|
onRunSchedule={vi.fn(async () => undefined)}
|
||||||
onSetHeartbeatPaused={vi.fn(async () => undefined)}
|
onSetHeartbeatPaused={vi.fn(async () => undefined)}
|
||||||
onSetMemoryStatus={vi.fn(async () => undefined)}
|
|
||||||
onStopBrowser={vi.fn(async () => undefined)}
|
onStopBrowser={vi.fn(async () => undefined)}
|
||||||
onTabChange={vi.fn()}
|
onTabChange={vi.fn()}
|
||||||
open
|
open
|
||||||
schedules={[]}
|
|
||||||
tab={tab}
|
tab={tab}
|
||||||
tasks={tasks}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -165,80 +155,55 @@ describe('RightAssistantSidebar resizing', () => {
|
|||||||
).toBe('424px')
|
).toBe('424px')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('indents child tasks and names their expert and routing mode', () => {
|
it('exposes the task center and four reusable work surfaces', () => {
|
||||||
const parentTask: AssistantTask = {
|
renderSidebar()
|
||||||
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'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
})
|
|
||||||
|
|
||||||
const taskButtons = screen.getAllByRole('button', {
|
expect(
|
||||||
name: /分析发布计划|研究子任务/u
|
screen.getAllByRole('tab').map((tab) => tab.textContent)
|
||||||
})
|
).toEqual(['任务中心', '上下文', '工作区', '浏览器', '成果'])
|
||||||
expect(taskButtons[0]).toHaveTextContent('分析发布计划')
|
expect(
|
||||||
expect(taskButtons[1]).toHaveClass('assistant-sidebar__row--subtask')
|
screen.queryByRole('tab', { name: '预览' })
|
||||||
expect(taskButtons[1]).toHaveTextContent('子专家:研究专家 · 智能路由')
|
).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('preserves schedule input and reports a failed action', async () => {
|
it('keeps automation in the task center without recent tasks', () => {
|
||||||
const onCreateSchedule = vi.fn(async () => {
|
renderSidebar({ tab: 'tasks' })
|
||||||
throw new Error('定时服务不可用')
|
|
||||||
})
|
|
||||||
renderSidebar({ tab: 'tasks', onCreateSchedule })
|
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText('定时任务标题'), {
|
expect(screen.getByText('等待审批')).toBeInTheDocument()
|
||||||
target: { value: '每日摘要' }
|
expect(screen.getByText('自动化')).toBeInTheDocument()
|
||||||
})
|
expect(
|
||||||
fireEvent.change(screen.getByLabelText('定时任务内容'), {
|
screen.getByLabelText('定时任务标题')
|
||||||
target: { value: '总结今天的工作' }
|
).toBeInTheDocument()
|
||||||
})
|
expect(screen.queryByText('最近任务')).not.toBeInTheDocument()
|
||||||
fireEvent.change(screen.getByLabelText('定时任务时间'), {
|
})
|
||||||
target: { value: '2026-08-06T09:00' }
|
|
||||||
})
|
|
||||||
fireEvent.click(
|
|
||||||
screen.getByRole('button', { name: '添加定时任务' })
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
it('previews a result without switching to a separate tab', () => {
|
||||||
'定时服务不可用'
|
const onLoadArtifact = vi.fn(async () => undefined)
|
||||||
)
|
renderSidebar({
|
||||||
expect(screen.getByLabelText('定时任务标题')).toHaveValue(
|
tab: 'results',
|
||||||
'每日摘要'
|
artifacts: [
|
||||||
)
|
{
|
||||||
expect(screen.getByLabelText('定时任务内容')).toHaveValue(
|
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()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,29 +1,25 @@
|
|||||||
import {
|
import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
FileDiff,
|
ExternalLink,
|
||||||
FileText,
|
FileText,
|
||||||
FolderTree,
|
FolderTree,
|
||||||
Hourglass,
|
Hourglass,
|
||||||
Monitor,
|
Monitor,
|
||||||
PanelRightClose,
|
PanelRightClose,
|
||||||
PlayCircle,
|
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
Upload,
|
Upload,
|
||||||
X,
|
X
|
||||||
XCircle
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import type {
|
import type {
|
||||||
|
AssistantHeartbeatConfig,
|
||||||
AssistantMemory,
|
AssistantMemory,
|
||||||
AssistantSchedule,
|
AssistantSchedule,
|
||||||
AssistantHeartbeatConfig,
|
|
||||||
AssistantHeartbeatEntry,
|
|
||||||
AssistantExpert,
|
|
||||||
HeartbeatCreateInput,
|
HeartbeatCreateInput,
|
||||||
ScheduleCreateInput,
|
ScheduleCreateInput,
|
||||||
AssistantTask,
|
|
||||||
WorkspaceChanges,
|
WorkspaceChanges,
|
||||||
WorkspaceDirectoryListing,
|
WorkspaceDirectoryListing,
|
||||||
WorkspaceFilePreview
|
WorkspaceFilePreview
|
||||||
@@ -35,17 +31,15 @@ import type {
|
|||||||
ContextAttachment,
|
ContextAttachment,
|
||||||
KnowledgeLibrary
|
KnowledgeLibrary
|
||||||
} from '../../shared/contracts'
|
} from '../../shared/contracts'
|
||||||
import type { ActivityRecord } from './activity-store'
|
|
||||||
import { HeartbeatSettings } from './HeartbeatSettings'
|
import { HeartbeatSettings } from './HeartbeatSettings'
|
||||||
import { WorkspaceFilesPanel } from './WorkspaceFilesPanel'
|
import { WorkspaceFilesPanel } from './WorkspaceFilesPanel'
|
||||||
|
|
||||||
export type AssistantSidebarTab =
|
export type AssistantSidebarTab =
|
||||||
| 'tasks'
|
| 'tasks'
|
||||||
| 'context'
|
| 'context'
|
||||||
| 'artifacts'
|
| 'workspace'
|
||||||
| 'changes'
|
|
||||||
| 'browser'
|
| 'browser'
|
||||||
| 'preview'
|
| 'results'
|
||||||
|
|
||||||
export type SidebarArtifact = {
|
export type SidebarArtifact = {
|
||||||
id: string
|
id: string
|
||||||
@@ -67,38 +61,24 @@ export type PendingSidebarApproval = {
|
|||||||
type RightAssistantSidebarProps = {
|
type RightAssistantSidebarProps = {
|
||||||
open: boolean
|
open: boolean
|
||||||
tab: AssistantSidebarTab
|
tab: AssistantSidebarTab
|
||||||
activities: ActivityRecord[]
|
approvals: PendingSidebarApproval[]
|
||||||
tasks: AssistantTask[]
|
|
||||||
experts?: AssistantExpert[]
|
|
||||||
artifacts: SidebarArtifact[]
|
artifacts: SidebarArtifact[]
|
||||||
attachments: ContextAttachment[]
|
attachments: ContextAttachment[]
|
||||||
enabledLibraries: KnowledgeLibrary[]
|
enabledLibraries: KnowledgeLibrary[]
|
||||||
approvals: PendingSidebarApproval[]
|
heartbeats: AssistantHeartbeatConfig[]
|
||||||
memories: AssistantMemory[]
|
memories: AssistantMemory[]
|
||||||
schedules: AssistantSchedule[]
|
schedules: AssistantSchedule[]
|
||||||
heartbeats: AssistantHeartbeatConfig[]
|
|
||||||
heartbeatEntries: AssistantHeartbeatEntry[]
|
|
||||||
workspaceChanges?: WorkspaceChanges
|
workspaceChanges?: WorkspaceChanges
|
||||||
workspaceProjectId?: string
|
workspaceProjectId?: string
|
||||||
browserState?: BrowserLiveState
|
browserState?: BrowserLiveState
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
|
onInteractBrowser: () => Promise<void>
|
||||||
onStopBrowser: () => Promise<void>
|
onStopBrowser: () => Promise<void>
|
||||||
onOpenHeartbeat: () => void
|
onCreateHeartbeat: (input: HeartbeatCreateInput) => Promise<void>
|
||||||
onOpenConversation: (conversationId: string) => void
|
onCreateSchedule: (input: ScheduleCreateInput) => Promise<void>
|
||||||
onImportArtifacts: () => Promise<void>
|
onImportArtifacts: () => Promise<void>
|
||||||
onLoadArtifact: (artifactId: string) => Promise<void>
|
onLoadArtifact: (artifactId: string) => Promise<void>
|
||||||
onRemoveAttachment: (attachmentId: string) => 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>
|
onRefreshChanges: () => Promise<void>
|
||||||
onListWorkspaceDirectory: (
|
onListWorkspaceDirectory: (
|
||||||
path: string
|
path: string
|
||||||
@@ -108,15 +88,18 @@ type RightAssistantSidebarProps = {
|
|||||||
path: string,
|
path: string,
|
||||||
type: 'file' | 'directory'
|
type: 'file' | 'directory'
|
||||||
) => Promise<void>
|
) => Promise<void>
|
||||||
onRemoveMemory: (memoryId: string) => Promise<void>
|
onRemoveHeartbeat: (heartbeatId: string) => Promise<void>
|
||||||
onSetMemoryStatus: (
|
onRemoveSchedule: (scheduleId: string) => Promise<void>
|
||||||
memoryId: string,
|
|
||||||
status: AssistantMemory['status']
|
|
||||||
) => Promise<void>
|
|
||||||
onRespondApproval: (
|
onRespondApproval: (
|
||||||
approval: PendingSidebarApproval,
|
approval: PendingSidebarApproval,
|
||||||
decision: ApprovalDecision
|
decision: ApprovalDecision
|
||||||
) => void
|
) => void
|
||||||
|
onRunHeartbeat: (heartbeatId: string) => Promise<void>
|
||||||
|
onRunSchedule: (scheduleId: string) => Promise<void>
|
||||||
|
onSetHeartbeatPaused: (
|
||||||
|
heartbeatId: string,
|
||||||
|
paused: boolean
|
||||||
|
) => Promise<void>
|
||||||
onTabChange: (tab: AssistantSidebarTab) => void
|
onTabChange: (tab: AssistantSidebarTab) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,22 +111,17 @@ const tabs: Array<{
|
|||||||
{
|
{
|
||||||
id: 'tasks',
|
id: 'tasks',
|
||||||
label: '任务中心',
|
label: '任务中心',
|
||||||
description: '查看运行状态、处理审批并安排自动化'
|
description: '处理待审批操作并管理自动化'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'context',
|
id: 'context',
|
||||||
label: '上下文',
|
label: '上下文',
|
||||||
description: '管理本次对话的附件、知识库与长期记忆'
|
description: '查看本次对话使用的附件、知识库与记忆'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'artifacts',
|
id: 'workspace',
|
||||||
label: '成果库',
|
|
||||||
description: '集中保存和打开对话生成或手动导入的内容'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'changes',
|
|
||||||
label: '工作区',
|
label: '工作区',
|
||||||
description: '浏览项目文件与工具活动'
|
description: '浏览项目文件、Git 变更与文件内容'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'browser',
|
id: 'browser',
|
||||||
@@ -151,9 +129,9 @@ const tabs: Array<{
|
|||||||
description: '查看 Agent 操作网页时的实时画面'
|
description: '查看 Agent 操作网页时的实时画面'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'preview',
|
id: 'results',
|
||||||
label: '预览',
|
label: '成果',
|
||||||
description: '预览选中的成果或工作区文件'
|
description: '查看对话生成或手动导入的内容'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
const emptyChangedFiles: WorkspaceChanges['files'] = []
|
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))
|
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({
|
export function RightAssistantSidebar({
|
||||||
open,
|
open,
|
||||||
tab,
|
tab,
|
||||||
activities,
|
approvals,
|
||||||
tasks,
|
|
||||||
experts = [],
|
|
||||||
artifacts,
|
artifacts,
|
||||||
attachments,
|
attachments,
|
||||||
enabledLibraries,
|
enabledLibraries,
|
||||||
approvals,
|
heartbeats,
|
||||||
memories,
|
memories,
|
||||||
schedules,
|
schedules,
|
||||||
heartbeats,
|
|
||||||
heartbeatEntries,
|
|
||||||
workspaceChanges,
|
workspaceChanges,
|
||||||
workspaceProjectId,
|
workspaceProjectId,
|
||||||
browserState,
|
browserState,
|
||||||
onClose,
|
onClose,
|
||||||
|
onInteractBrowser,
|
||||||
onStopBrowser,
|
onStopBrowser,
|
||||||
onOpenHeartbeat,
|
onCreateHeartbeat,
|
||||||
onOpenConversation,
|
onCreateSchedule,
|
||||||
onImportArtifacts,
|
onImportArtifacts,
|
||||||
onLoadArtifact,
|
onLoadArtifact,
|
||||||
onRemoveAttachment,
|
onRemoveAttachment,
|
||||||
onCreateMemory,
|
|
||||||
onCreateSchedule,
|
|
||||||
onCreateHeartbeat,
|
|
||||||
onSetHeartbeatPaused,
|
|
||||||
onRemoveHeartbeat,
|
|
||||||
onRunHeartbeat,
|
|
||||||
onRemoveSchedule,
|
|
||||||
onRunSchedule,
|
|
||||||
onRefreshChanges,
|
onRefreshChanges,
|
||||||
onListWorkspaceDirectory,
|
onListWorkspaceDirectory,
|
||||||
onLoadWorkspaceFile,
|
onLoadWorkspaceFile,
|
||||||
onOpenWorkspaceEntry,
|
onOpenWorkspaceEntry,
|
||||||
onRemoveMemory,
|
onRemoveHeartbeat,
|
||||||
onSetMemoryStatus,
|
onRemoveSchedule,
|
||||||
onRespondApproval,
|
onRespondApproval,
|
||||||
|
onRunHeartbeat,
|
||||||
|
onRunSchedule,
|
||||||
|
onSetHeartbeatPaused,
|
||||||
onTabChange
|
onTabChange
|
||||||
}: RightAssistantSidebarProps): React.JSX.Element {
|
}: RightAssistantSidebarProps): React.JSX.Element {
|
||||||
const [viewportWidth, setViewportWidth] = useState(window.innerWidth)
|
const [viewportWidth, setViewportWidth] = useState(window.innerWidth)
|
||||||
@@ -303,7 +231,6 @@ export function RightAssistantSidebar({
|
|||||||
}
|
}
|
||||||
>()
|
>()
|
||||||
const workspacePreviewRequest = useRef(0)
|
const workspacePreviewRequest = useRef(0)
|
||||||
const [memoryDraft, setMemoryDraft] = useState('')
|
|
||||||
const [workspaceRefreshVersion, setWorkspaceRefreshVersion] = useState(0)
|
const [workspaceRefreshVersion, setWorkspaceRefreshVersion] = useState(0)
|
||||||
const [scheduleTitle, setScheduleTitle] = useState('')
|
const [scheduleTitle, setScheduleTitle] = useState('')
|
||||||
const [schedulePrompt, setSchedulePrompt] = useState('')
|
const [schedulePrompt, setSchedulePrompt] = useState('')
|
||||||
@@ -312,31 +239,11 @@ export function RightAssistantSidebar({
|
|||||||
ScheduleCreateInput['recurrence']
|
ScheduleCreateInput['recurrence']
|
||||||
>('once')
|
>('once')
|
||||||
const [actionError, setActionError] = useState('')
|
const [actionError, setActionError] = useState('')
|
||||||
const recentTasks = useMemo(
|
const activeMemories = memories.filter(
|
||||||
() =>
|
(memory) => memory.status === 'confirmed'
|
||||||
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 artifactPreview =
|
const artifactPreview =
|
||||||
artifacts.find((artifact) => artifact.id === selectedArtifactId) ??
|
artifacts.find((artifact) => artifact.id === selectedArtifactId)
|
||||||
artifacts[0]
|
|
||||||
const currentWorkspacePreview =
|
const currentWorkspacePreview =
|
||||||
workspacePreview?.projectId === workspaceProjectId
|
workspacePreview?.projectId === workspaceProjectId
|
||||||
? workspacePreview
|
? workspacePreview
|
||||||
@@ -428,7 +335,6 @@ export function RightAssistantSidebar({
|
|||||||
const projectId = workspaceProjectId
|
const projectId = workspaceProjectId
|
||||||
setWorkspacePreview({ projectId, path, state: 'loading' })
|
setWorkspacePreview({ projectId, path, state: 'loading' })
|
||||||
setActionError('')
|
setActionError('')
|
||||||
onTabChange('preview')
|
|
||||||
void onLoadWorkspaceFile(path)
|
void onLoadWorkspaceFile(path)
|
||||||
.then((file) => {
|
.then((file) => {
|
||||||
if (workspacePreviewRequest.current === requestId) {
|
if (workspacePreviewRequest.current === requestId) {
|
||||||
@@ -624,103 +530,49 @@ export function RightAssistantSidebar({
|
|||||||
{tab === 'tasks' && (
|
{tab === 'tasks' && (
|
||||||
<section className="assistant-sidebar__section">
|
<section className="assistant-sidebar__section">
|
||||||
<p className="assistant-sidebar__section-description">
|
<p className="assistant-sidebar__section-description">
|
||||||
查看当前和最近请求的运行状态、处理待审批操作,并安排定时任务与智能心跳。
|
处理当前待审批操作,并创建和管理自动化任务。
|
||||||
</p>
|
</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>
|
<h3>
|
||||||
<PlayCircle size={15} />
|
<ShieldAlert size={15} />
|
||||||
最近任务
|
等待审批
|
||||||
</h3>
|
</h3>
|
||||||
{tasks.length === 0 && recentTasks.length === 0 ? (
|
{approvals.length === 0 ? (
|
||||||
<p className="assistant-sidebar__empty">
|
<p className="assistant-sidebar__empty">
|
||||||
发送请求后,任务状态会显示在这里。
|
当前没有等待审批的操作。
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
(orderedTasks.length > 0 ? orderedTasks : recentTasks).map((task) => (
|
approvals.map((approval) => (
|
||||||
<button
|
<article
|
||||||
className={
|
className="assistant-sidebar__approval"
|
||||||
'parentTaskId' in task && task.parentTaskId
|
key={approval.approvalId}
|
||||||
? 'assistant-sidebar__row assistant-sidebar__row--subtask'
|
|
||||||
: 'assistant-sidebar__row'
|
|
||||||
}
|
|
||||||
key={task.id}
|
|
||||||
onClick={() => {
|
|
||||||
if (task.conversationId) {
|
|
||||||
onOpenConversation(task.conversationId)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
>
|
>
|
||||||
{task.status === 'running' ||
|
<strong>{approval.title}</strong>
|
||||||
task.status === 'pending' ? (
|
<p>{approval.description}</p>
|
||||||
<Hourglass size={15} />
|
{approval.toolName && <code>{approval.toolName}</code>}
|
||||||
) : task.status === 'failed' ||
|
<div className="assistant-sidebar__approval-actions">
|
||||||
task.status === 'denied' ? (
|
<button
|
||||||
<XCircle size={15} />
|
className="secondary-button"
|
||||||
) : (
|
onClick={() =>
|
||||||
<CheckCircle2 size={15} />
|
onRespondApproval(approval, 'deny')
|
||||||
)}
|
}
|
||||||
<span>
|
type="button"
|
||||||
<strong>{task.title}</strong>
|
>
|
||||||
<small>
|
拒绝
|
||||||
{formatTime(task.createdAt)} · {task.status}
|
</button>
|
||||||
</small>
|
<button
|
||||||
{'parentTaskId' in task && task.parentTaskId && (
|
className="primary-button"
|
||||||
<small className="assistant-sidebar__subtask-meta">
|
onClick={() =>
|
||||||
子专家:
|
onRespondApproval(approval, 'once')
|
||||||
{task.expertId
|
}
|
||||||
? expertNames.get(task.expertId) ??
|
type="button"
|
||||||
task.title
|
>
|
||||||
: task.title}
|
仅此次允许
|
||||||
{' · '}
|
</button>
|
||||||
{task.routingMode === 'smart'
|
</div>
|
||||||
? '智能路由'
|
</article>
|
||||||
: '手动指定'}
|
|
||||||
</small>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<ChevronRight size={14} />
|
|
||||||
</button>
|
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<h3>
|
<h3>
|
||||||
<Hourglass size={15} />
|
<Hourglass size={15} />
|
||||||
自动化
|
自动化
|
||||||
@@ -775,9 +627,7 @@ export function RightAssistantSidebar({
|
|||||||
prompt: schedulePrompt.trim(),
|
prompt: schedulePrompt.trim(),
|
||||||
workMode: 'ask',
|
workMode: 'ask',
|
||||||
recurrence: scheduleRecurrence,
|
recurrence: scheduleRecurrence,
|
||||||
nextRunAt: new Date(
|
nextRunAt: new Date(scheduleTime).toISOString()
|
||||||
scheduleTime
|
|
||||||
).toISOString()
|
|
||||||
}),
|
}),
|
||||||
'添加定时任务失败',
|
'添加定时任务失败',
|
||||||
() => {
|
() => {
|
||||||
@@ -843,6 +693,9 @@ export function RightAssistantSidebar({
|
|||||||
|
|
||||||
{tab === 'context' && (
|
{tab === 'context' && (
|
||||||
<section className="assistant-sidebar__section">
|
<section className="assistant-sidebar__section">
|
||||||
|
<p className="assistant-sidebar__section-description">
|
||||||
|
查看当前对话实际使用的附件、知识库与已确认记忆。
|
||||||
|
</p>
|
||||||
<h3>
|
<h3>
|
||||||
<FileText size={15} />
|
<FileText size={15} />
|
||||||
本次附件
|
本次附件
|
||||||
@@ -892,200 +745,76 @@ export function RightAssistantSidebar({
|
|||||||
)}
|
)}
|
||||||
<h3>
|
<h3>
|
||||||
<CheckCircle2 size={15} />
|
<CheckCircle2 size={15} />
|
||||||
长期记忆
|
已确认记忆
|
||||||
</h3>
|
</h3>
|
||||||
<div className="assistant-sidebar__memory-form">
|
{activeMemories.length === 0 ? (
|
||||||
<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 ? (
|
|
||||||
<p className="assistant-sidebar__empty">
|
<p className="assistant-sidebar__empty">
|
||||||
尚无已确认的长期记忆。
|
当前范围没有已确认的长期记忆。
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
memories.map((memory) => (
|
activeMemories.map((memory) => (
|
||||||
<article
|
<article
|
||||||
className="assistant-sidebar__memory"
|
className="assistant-sidebar__memory"
|
||||||
key={memory.id}
|
key={memory.id}
|
||||||
>
|
>
|
||||||
<span>
|
<span>{memory.content}</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>
|
|
||||||
</article>
|
</article>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === 'artifacts' && (
|
{tab === 'workspace' && (
|
||||||
<section className="assistant-sidebar__section">
|
currentWorkspacePreview ? (
|
||||||
<p className="assistant-sidebar__section-description">
|
<section className="assistant-sidebar__preview">
|
||||||
保存并预览由对话生成或手动导入的文本、图片、PDF 与网页内容。
|
<header>
|
||||||
</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) => (
|
|
||||||
<button
|
<button
|
||||||
className="assistant-sidebar__row"
|
aria-label="返回工作区"
|
||||||
key={artifact.id}
|
className="assistant-sidebar__back"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
workspacePreviewRequest.current += 1
|
workspacePreviewRequest.current += 1
|
||||||
setWorkspacePreview(undefined)
|
setWorkspacePreview(undefined)
|
||||||
setSelectedArtifactId(artifact.id)
|
|
||||||
setActionError('')
|
setActionError('')
|
||||||
onTabChange('preview')
|
|
||||||
runAction(
|
|
||||||
() => onLoadArtifact(artifact.id),
|
|
||||||
'加载成果失败'
|
|
||||||
)
|
|
||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<FileText size={15} />
|
<ChevronLeft size={14} />
|
||||||
<span>
|
工作区
|
||||||
<strong>{artifact.title}</strong>
|
|
||||||
<small>{formatTime(artifact.createdAt)}</small>
|
|
||||||
</span>
|
|
||||||
<ChevronRight size={14} />
|
|
||||||
</button>
|
</button>
|
||||||
))
|
<span>
|
||||||
)}
|
<strong>{currentWorkspacePreview.path}</strong>
|
||||||
</section>
|
<small>
|
||||||
)}
|
{currentWorkspacePreview.state === 'ready'
|
||||||
|
? `${currentWorkspacePreview.file.size.toLocaleString('zh-CN')} 字节`
|
||||||
{tab === 'changes' && (
|
: '项目工作区文件'}
|
||||||
<>
|
</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">
|
<section className="assistant-sidebar__section">
|
||||||
<p className="assistant-sidebar__section-description">
|
<p className="assistant-sidebar__section-description">
|
||||||
浏览当前项目文件,并查看 Agent 的工具活动。Git
|
浏览当前项目文件与 Git 变更;选择文件后在当前工作区内预览。
|
||||||
项目还会显示未提交更改。
|
|
||||||
</p>
|
</p>
|
||||||
<h3>
|
<h3>
|
||||||
<FolderTree size={15} />
|
<FolderTree size={15} />
|
||||||
@@ -1132,33 +861,119 @@ export function RightAssistantSidebar({
|
|||||||
</details>
|
</details>
|
||||||
)}
|
)}
|
||||||
</section>
|
</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">
|
<section className="assistant-sidebar__section">
|
||||||
<h3>工具活动</h3>
|
<p className="assistant-sidebar__section-description">
|
||||||
{changes.length === 0 ? (
|
查看并预览由对话生成或手动导入的文本、图片、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 className="assistant-sidebar__empty">
|
||||||
Agent 调用工具后,相关记录会显示在这里。
|
完成的回复会作为可预览成果显示在这里。
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
changes.map((change) => (
|
artifacts.map((artifact) => (
|
||||||
<button
|
<button
|
||||||
className="assistant-sidebar__row"
|
className="assistant-sidebar__row"
|
||||||
key={change.id}
|
key={artifact.id}
|
||||||
onClick={() =>
|
onClick={() => {
|
||||||
onOpenConversation(change.conversationId)
|
setSelectedArtifactId(artifact.id)
|
||||||
}
|
setActionError('')
|
||||||
|
runAction(
|
||||||
|
() => onLoadArtifact(artifact.id),
|
||||||
|
'加载成果失败'
|
||||||
|
)
|
||||||
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<FileDiff size={15} />
|
<FileText size={15} />
|
||||||
<span>
|
<span>
|
||||||
<strong>{change.title}</strong>
|
<strong>{artifact.title}</strong>
|
||||||
<small>{change.detail || change.status}</small>
|
<small>
|
||||||
|
{sidebarTimeFormatter.format(
|
||||||
|
new Date(artifact.createdAt)
|
||||||
|
)}
|
||||||
|
</small>
|
||||||
</span>
|
</span>
|
||||||
<ChevronRight size={14} />
|
<ChevronRight size={14} />
|
||||||
</button>
|
</button>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</>
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === 'browser' && (
|
{tab === 'browser' && (
|
||||||
@@ -1170,18 +985,39 @@ export function RightAssistantSidebar({
|
|||||||
</span>
|
</span>
|
||||||
{browserState &&
|
{browserState &&
|
||||||
browserState.status !== 'stopped' && (
|
browserState.status !== 'stopped' && (
|
||||||
<button
|
<div className="assistant-sidebar__browser-actions">
|
||||||
className="secondary-button"
|
<button
|
||||||
onClick={() =>
|
className="secondary-button"
|
||||||
runAction(
|
disabled={
|
||||||
onStopBrowser,
|
browserState.status === 'creating' ||
|
||||||
'停止浏览器失败'
|
browserState.status === 'interactive'
|
||||||
)
|
}
|
||||||
}
|
onClick={() =>
|
||||||
type="button"
|
runAction(
|
||||||
>
|
onInteractBrowser,
|
||||||
停止浏览器
|
'打开浏览器交互窗口失败'
|
||||||
</button>
|
)
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ExternalLink aria-hidden="true" size={12} />
|
||||||
|
{browserState.status === 'interactive'
|
||||||
|
? '交互中'
|
||||||
|
: '交互'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="secondary-button"
|
||||||
|
onClick={() =>
|
||||||
|
runAction(
|
||||||
|
onStopBrowser,
|
||||||
|
'停止浏览器失败'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
停止浏览器
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
{!browserState ? (
|
{!browserState ? (
|
||||||
@@ -1201,6 +1037,8 @@ export function RightAssistantSidebar({
|
|||||||
? '正在加载页面…'
|
? '正在加载页面…'
|
||||||
: browserState.status === 'acting'
|
: browserState.status === 'acting'
|
||||||
? 'Agent 正在操作页面…'
|
? 'Agent 正在操作页面…'
|
||||||
|
: browserState.status === 'interactive'
|
||||||
|
? '用户正在辅助操作页面…'
|
||||||
: browserState.status === 'ready'
|
: browserState.status === 'ready'
|
||||||
? '浏览器已就绪'
|
? '浏览器已就绪'
|
||||||
: browserState.status === 'failed'
|
: browserState.status === 'failed'
|
||||||
@@ -1235,82 +1073,6 @@ export function RightAssistantSidebar({
|
|||||||
)}
|
)}
|
||||||
</section>
|
</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>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1657,6 +1657,14 @@ describe('SettingsPanel runtime files', () => {
|
|||||||
|
|
||||||
fireEvent.click(screen.getByRole('tab', { name: 'Skills' }))
|
fireEvent.click(screen.getByRole('tab', { name: 'Skills' }))
|
||||||
expect(await screen.findByText('文档写作')).toBeInTheDocument()
|
expect(await screen.findByText('文档写作')).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
screen.getByText('支持直连模型、OpenCode 和 Continue')
|
||||||
|
).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
screen.getByText(/新导入的 Skill 默认启用/)
|
||||||
|
).toHaveTextContent(
|
||||||
|
'分配给直连模型、OpenCode 和 Continue'
|
||||||
|
)
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
screen.getByRole('button', { name: '导入 Skill 目录' })
|
screen.getByRole('button', { name: '导入 Skill 目录' })
|
||||||
)
|
)
|
||||||
@@ -1677,6 +1685,15 @@ describe('SettingsPanel runtime files', () => {
|
|||||||
|
|
||||||
fireEvent.click(screen.getByRole('tab', { name: 'MCP' }))
|
fireEvent.click(screen.getByRole('tab', { name: 'MCP' }))
|
||||||
expect(await screen.findByText('电脑控制能力')).toBeInTheDocument()
|
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.getAllByText('托管浏览器配置').length).toBeGreaterThan(0)
|
||||||
expect(screen.getByLabelText('启用 Linux 桌面控制')).toBeDisabled()
|
expect(screen.getByLabelText('启用 Linux 桌面控制')).toBeDisabled()
|
||||||
fireEvent.click(screen.getByLabelText('启用 浏览器控制'))
|
fireEvent.click(screen.getByLabelText('启用 浏览器控制'))
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export function SkillsSettingsSection(): React.JSX.Element {
|
|||||||
<BookOpen size={17} />
|
<BookOpen size={17} />
|
||||||
<div>
|
<div>
|
||||||
<strong>Skills</strong>
|
<strong>Skills</strong>
|
||||||
<small>离线能力说明,可独立分配给各 Agent Runtime</small>
|
<small>支持直连模型、OpenCode 和 Continue</small>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="secondary-button"
|
className="secondary-button"
|
||||||
@@ -91,6 +91,10 @@ export function SkillsSettingsSection(): React.JSX.Element {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p className="settings-notice">
|
||||||
|
Skill 以本地能力说明注入所选目标,不会写入 Runtime
|
||||||
|
自有配置。新导入的 Skill 默认启用,并分配给直连模型、OpenCode 和 Continue。
|
||||||
|
</p>
|
||||||
{error && <p className="settings-warning">{error}</p>}
|
{error && <p className="settings-warning">{error}</p>}
|
||||||
{!snapshot && !error && <p className="settings-empty">正在读取 Skills…</p>}
|
{!snapshot && !error && <p className="settings-empty">正在读取 Skills…</p>}
|
||||||
<div className="capability-list">
|
<div className="capability-list">
|
||||||
|
|||||||
+173
-86
@@ -704,7 +704,7 @@ textarea:focus-visible {
|
|||||||
padding: 8px;
|
padding: 8px;
|
||||||
border-bottom: 1px solid #f0f0f0;
|
border-bottom: 1px solid #f0f0f0;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-sidebar__tab {
|
.assistant-sidebar__tab {
|
||||||
@@ -733,8 +733,8 @@ textarea:focus-visible {
|
|||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #ff4d4f;
|
background: var(--danger-solid);
|
||||||
color: #fff;
|
color: var(--text-on-accent);
|
||||||
font-size: 8px;
|
font-size: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -960,18 +960,6 @@ textarea:focus-visible {
|
|||||||
background: #e6f4ff;
|
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__row span,
|
||||||
.assistant-sidebar__context span,
|
.assistant-sidebar__context span,
|
||||||
.assistant-sidebar__library {
|
.assistant-sidebar__library {
|
||||||
@@ -1002,9 +990,9 @@ textarea:focus-visible {
|
|||||||
|
|
||||||
.assistant-sidebar__approval {
|
.assistant-sidebar__approval {
|
||||||
padding: 11px;
|
padding: 11px;
|
||||||
border: 1px solid #ffd591;
|
border: 1px solid var(--danger-border);
|
||||||
border-radius: 9px;
|
border-radius: var(--radius-control);
|
||||||
background: #fff7e6;
|
background: var(--danger-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-sidebar__approval strong {
|
.assistant-sidebar__approval strong {
|
||||||
@@ -1014,13 +1002,13 @@ textarea:focus-visible {
|
|||||||
|
|
||||||
.assistant-sidebar__approval p {
|
.assistant-sidebar__approval p {
|
||||||
margin: 5px 0;
|
margin: 5px 0;
|
||||||
color: #595959;
|
color: var(--text-secondary);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-sidebar__approval code {
|
.assistant-sidebar__approval code {
|
||||||
color: #ad6800;
|
color: var(--danger);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1053,24 +1041,6 @@ textarea:focus-visible {
|
|||||||
background: #fafafa;
|
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 {
|
.assistant-sidebar__memory {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -1096,21 +1066,6 @@ textarea:focus-visible {
|
|||||||
font-size: 8px;
|
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 {
|
.assistant-sidebar__schedule-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1122,18 +1077,19 @@ textarea:focus-visible {
|
|||||||
.assistant-sidebar__schedule-form select {
|
.assistant-sidebar__schedule-form select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 8px 9px;
|
padding: 8px 9px;
|
||||||
border: 1px solid #d9d9d9;
|
border: 1px solid var(--border-control);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-control);
|
||||||
background: #fff;
|
background: var(--surface-raised);
|
||||||
|
color: var(--text-primary);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-sidebar__schedule {
|
.assistant-sidebar__schedule {
|
||||||
padding: 9px 10px;
|
padding: 9px 10px;
|
||||||
border: 1px solid #d9d9d9;
|
border: 1px solid var(--border-default);
|
||||||
border-radius: 9px;
|
border-radius: var(--radius-control);
|
||||||
background: #fafafa;
|
background: var(--surface-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-sidebar__schedule > span {
|
.assistant-sidebar__schedule > span {
|
||||||
@@ -1147,7 +1103,7 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.assistant-sidebar__schedule small {
|
.assistant-sidebar__schedule small {
|
||||||
color: #8c8c8c;
|
color: var(--text-muted);
|
||||||
font-size: 8px;
|
font-size: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1160,18 +1116,11 @@ textarea:focus-visible {
|
|||||||
.assistant-sidebar__schedule button {
|
.assistant-sidebar__schedule button {
|
||||||
padding: 3px 0;
|
padding: 3px 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #1677ff;
|
color: var(--accent);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 8px;
|
font-size: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-sidebar__schedule p {
|
|
||||||
margin: 7px 0 0;
|
|
||||||
color: #595959;
|
|
||||||
font-size: 9px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.heartbeat-settings {
|
.heartbeat-settings {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -1371,11 +1320,20 @@ textarea:focus-visible {
|
|||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.assistant-sidebar__browser-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
.assistant-sidebar__browser > header .secondary-button {
|
.assistant-sidebar__browser > header .secondary-button {
|
||||||
|
display: inline-flex;
|
||||||
min-height: 28px;
|
min-height: 28px;
|
||||||
|
align-items: center;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
flex: none;
|
flex: none;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
|
gap: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-sidebar__browser-status {
|
.assistant-sidebar__browser-status {
|
||||||
@@ -1433,9 +1391,34 @@ textarea:focus-visible {
|
|||||||
border-bottom: 1px solid #f0f0f0;
|
border-bottom: 1px solid #f0f0f0;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-sidebar__preview > header > span {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
gap: 3px;
|
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 {
|
.assistant-sidebar__preview > header strong {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
@@ -1949,7 +1932,7 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.message-blocks .message-reasoning,
|
.message-blocks .message-reasoning,
|
||||||
.message-blocks .tool-activity {
|
.message-blocks .tool-execution-list {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2087,11 +2070,13 @@ textarea:focus-visible {
|
|||||||
|
|
||||||
.message__status {
|
.message__status {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
color: #8c8c8c;
|
color: #8c8c8c;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message__status--error {
|
.message__status--error {
|
||||||
@@ -2099,44 +2084,147 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.thinking-dot {
|
.thinking-dot {
|
||||||
|
flex: 0 0 auto;
|
||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
|
margin-top: 4px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: pulse 1.1s ease-in-out infinite;
|
animation: pulse 1.1s ease-in-out infinite;
|
||||||
background: #1677ff;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 9px 11px;
|
min-height: 32px;
|
||||||
border: 1px solid #d9d9d9;
|
padding: var(--space-2) var(--space-3);
|
||||||
border-radius: 9px;
|
gap: var(--space-2);
|
||||||
margin-top: 8px;
|
|
||||||
background: #fafafa;
|
|
||||||
color: #595959;
|
|
||||||
font-size: 10px;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
display: grid;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
gap: var(--space-1);
|
|
||||||
min-width: 0;
|
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);
|
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-family: var(--font-family-mono);
|
||||||
font-size: var(--font-caption);
|
font-size: var(--font-caption);
|
||||||
|
line-height: 1.5;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-activity small {
|
.tool-execution__details p {
|
||||||
color: #8c8c8c;
|
margin: 0;
|
||||||
text-transform: uppercase;
|
color: var(--text-muted);
|
||||||
|
font-size: var(--font-caption);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-execution__error pre {
|
||||||
|
border-color: var(--danger-border);
|
||||||
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.subagent-status-list {
|
.subagent-status-list {
|
||||||
@@ -6281,7 +6369,6 @@ details.settings-section > :not(summary) {
|
|||||||
.settings-panel__footer,
|
.settings-panel__footer,
|
||||||
.assistant-sidebar__row,
|
.assistant-sidebar__row,
|
||||||
.assistant-sidebar__library,
|
.assistant-sidebar__library,
|
||||||
.assistant-sidebar__schedule,
|
|
||||||
.assistant-sidebar__diff,
|
.assistant-sidebar__diff,
|
||||||
.assistant-sidebar__preview pre,
|
.assistant-sidebar__preview pre,
|
||||||
.tool-activity,
|
.tool-activity,
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ export const conversationToolActivitySchema = z
|
|||||||
'interrupted'
|
'interrupted'
|
||||||
]),
|
]),
|
||||||
summary: z.string().max(2_000),
|
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()
|
error: z.string().max(2_000).optional()
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export const builtinModelTools = [
|
|||||||
{
|
{
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
displayName: '浏览器导航',
|
displayName: '浏览器导航',
|
||||||
description: '在隔离浏览器中打开公开的 HTTP(S) 页面。',
|
description: '在隔离浏览器中打开当前设备可连接的 HTTP 或 HTTPS 页面。',
|
||||||
access: 'write'
|
access: 'write'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -40,13 +40,13 @@ export const builtinModelTools = [
|
|||||||
{
|
{
|
||||||
name: 'browser_click',
|
name: 'browser_click',
|
||||||
displayName: '点击浏览器元素',
|
displayName: '点击浏览器元素',
|
||||||
description: '点击最近一次浏览器快照中的可见且未受保护元素。',
|
description: '点击最近一次浏览器快照中的可见元素。',
|
||||||
access: 'write'
|
access: 'write'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'browser_type',
|
name: 'browser_type',
|
||||||
displayName: '输入浏览器文本',
|
displayName: '输入浏览器文本',
|
||||||
description: '向可编辑且未受保护的页面元素输入文本;不支持上传文件。',
|
description: '向可编辑页面元素(包括密码框)输入文本;不支持上传文件。',
|
||||||
access: 'write'
|
access: 'write'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -660,6 +660,8 @@ export type AgentEvent =
|
|||||||
| 'failed'
|
| 'failed'
|
||||||
| 'recoverable'
|
| 'recoverable'
|
||||||
summary: string
|
summary: string
|
||||||
|
input?: string
|
||||||
|
output?: string
|
||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
@@ -728,6 +730,7 @@ export const browserLiveStateSchema = z
|
|||||||
'loading',
|
'loading',
|
||||||
'ready',
|
'ready',
|
||||||
'acting',
|
'acting',
|
||||||
|
'interactive',
|
||||||
'failed',
|
'failed',
|
||||||
'stopped'
|
'stopped'
|
||||||
]),
|
]),
|
||||||
@@ -753,6 +756,8 @@ export const browserStopRequestSchema = z
|
|||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
|
|
||||||
|
export const browserInteractRequestSchema = browserStopRequestSchema
|
||||||
|
|
||||||
export const knowledgeIdSchema = z.string().uuid()
|
export const knowledgeIdSchema = z.string().uuid()
|
||||||
export const knowledgeCreateSchema = z
|
export const knowledgeCreateSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -919,6 +924,7 @@ export type DesktopApi = {
|
|||||||
onEvent: (listener: (event: AgentEvent) => void) => () => void
|
onEvent: (listener: (event: AgentEvent) => void) => () => void
|
||||||
}
|
}
|
||||||
browser: {
|
browser: {
|
||||||
|
interact: (conversationId: string) => Promise<void>
|
||||||
stop: (conversationId: string) => Promise<void>
|
stop: (conversationId: string) => Promise<void>
|
||||||
onState: (listener: (state: BrowserLiveState) => void) => () => void
|
onState: (listener: (state: BrowserLiveState) => void) => () => void
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const ipcChannels = {
|
|||||||
agentApprovalRespond: 'agent:approval:respond',
|
agentApprovalRespond: 'agent:approval:respond',
|
||||||
agentQuestionRespond: 'agent:question:respond',
|
agentQuestionRespond: 'agent:question:respond',
|
||||||
agentEvent: 'agent:event',
|
agentEvent: 'agent:event',
|
||||||
|
browserInteract: 'browser:interact',
|
||||||
browserStop: 'browser:stop',
|
browserStop: 'browser:stop',
|
||||||
browserState: 'browser:state',
|
browserState: 'browser:state',
|
||||||
runtimeSettingsGet: 'settings:runtime:get',
|
runtimeSettingsGet: 'settings:runtime:get',
|
||||||
|
|||||||
Reference in New Issue
Block a user