chore: prepare GoodBuddy 0.8.6
Cross-platform packages / Validate source (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, windows, windows-2025) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, macos, macos-15-intel) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Has been cancelled
Cross-platform packages / Publish GitHub Release (push) Has been cancelled
Cross-platform packages / Validate source (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, windows, windows-2025) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, macos, macos-15-intel) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Has been cancelled
Cross-platform packages / Publish GitHub Release (push) Has been cancelled
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "goodbuddy",
|
||||
"version": "0.8.5",
|
||||
"version": "0.8.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "goodbuddy",
|
||||
"version": "0.8.5",
|
||||
"version": "0.8.6",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "goodbuddy",
|
||||
"version": "0.8.5",
|
||||
"version": "0.8.6",
|
||||
"private": true,
|
||||
"description": "Secure desktop AI workspace with controlled Agent Runtimes",
|
||||
"desktopName": "GoodBuddy",
|
||||
|
||||
@@ -77,7 +77,18 @@ async function createDistribution(version = '1.5.47'): Promise<{
|
||||
'listen(i,async()=>{console.log(Ht.green(`Server started on http://localhost:${i}`))',
|
||||
'async function SCt(e){return n5e||',
|
||||
'shouldUseResponsesEndpoint(t){return this.config.useResponsesApi===!1?!1:this.apiBase==="https://api.openai.com/v1/"&&A0e(t)}',
|
||||
'function uAe(e,t){let n={provider:e.provider,model:e.model,apiKey:e.apiKey,apiBase:e.apiBase,requestOptions:e.requestOptions,env:e.env};return CGn(n)??null}'
|
||||
'function uAe(e,t){let n={provider:e.provider,model:e.model,apiKey:e.apiKey,apiBase:e.apiBase,requestOptions:e.requestOptions,env:e.env};return CGn(n)??null}',
|
||||
'function Csa(e){return process.platform==="win32"?{shell:"powershell.exe",args:["-NoLogo","-ExecutionPolicy","Bypass","-Command",e]}',
|
||||
'a={onContent:u=>{},onContentComplete:u=>{},onToolStart:(u,l)=>{},onToolResult:(u,l,c)=>{},onToolError:(u,l)=>{},onToolPermissionRequest:',
|
||||
'pendingPermission:null},B=',
|
||||
'j.get("/state",(we,Te)=>{M.lastActivity=Date.now(),B();let ue=e7e(M.session,M.isProcessing,rS.getQueueLength(),M.pendingPermission);Te.json(ue)})',
|
||||
'n?.onToolStart?.(i.name,i.arguments);',
|
||||
'n?.onToolError?.(l,i.name)',
|
||||
't?.onToolStart?.(c.name,c.arguments);',
|
||||
't?.onToolResult?.(String(y.content),c.name,"canceled")',
|
||||
't?.onToolResult?.(f,c.name,"done")',
|
||||
't?.onToolError?.(g,c.name)',
|
||||
't?.onToolError?.(p,c.name)'
|
||||
].join(';')
|
||||
await writeFile(join(distribution, 'index.js'), sourceBundle, 'utf8')
|
||||
return {
|
||||
@@ -138,6 +149,12 @@ describe('ContinueHostAdapter', () => {
|
||||
expect(bundle).toContain(
|
||||
'useResponsesApi:e.useResponsesApi'
|
||||
)
|
||||
expect(bundle).toContain('"-NoProfile"')
|
||||
expect(bundle).toContain('[Console]::OutputEncoding')
|
||||
expect(bundle).toContain('goodbuddyEvents:[]')
|
||||
expect(bundle).toContain('goodbuddyEvents:ce')
|
||||
expect(bundle).toContain('type:"text",delta:u')
|
||||
expect(bundle).toContain('onToolStart?.(c.name,c.arguments,c.id)')
|
||||
expect(bundle).toContain(
|
||||
'function ZZo(e){let t=[];if(e.allow)'
|
||||
)
|
||||
@@ -394,7 +411,7 @@ describe('ContinueHostAdapter', () => {
|
||||
cacheWriteTokens: 0
|
||||
}
|
||||
})
|
||||
expect(launch?.entryPath).toContain('host-v4')
|
||||
expect(launch?.entryPath).toContain('host-v6')
|
||||
expect(launch?.args).toEqual([
|
||||
'--config',
|
||||
expect.stringContaining('model-config-'),
|
||||
@@ -870,6 +887,7 @@ describe('ContinueHostAdapter', () => {
|
||||
const distribution = await createDistribution()
|
||||
let launchArgs: string[] = []
|
||||
const permissionBodies: unknown[] = []
|
||||
const streamEvents: unknown[] = []
|
||||
const launchHost: ContinueHostLauncher = (
|
||||
_entryPath,
|
||||
args
|
||||
@@ -915,7 +933,16 @@ describe('ContinueHostAdapter', () => {
|
||||
toolName: 'Bash',
|
||||
toolArgs: { command: 'npm test' },
|
||||
requestId: 'permission-1'
|
||||
}
|
||||
},
|
||||
goodbuddyEvents: [
|
||||
{ type: 'text', delta: '先检查命令。' },
|
||||
{
|
||||
type: 'tool',
|
||||
callId: 'call-1',
|
||||
name: 'Bash',
|
||||
state: 'running'
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
return Response.json({
|
||||
@@ -940,7 +967,16 @@ describe('ContinueHostAdapter', () => {
|
||||
},
|
||||
isProcessing: false,
|
||||
messageQueueLength: 0,
|
||||
pendingPermission: null
|
||||
pendingPermission: null,
|
||||
goodbuddyEvents: [
|
||||
{
|
||||
type: 'tool',
|
||||
callId: 'call-1',
|
||||
name: 'Bash',
|
||||
state: 'completed'
|
||||
},
|
||||
{ type: 'text', delta: 'TOOLS_OK' }
|
||||
]
|
||||
})
|
||||
}
|
||||
return Response.json({})
|
||||
@@ -959,9 +995,19 @@ describe('ContinueHostAdapter', () => {
|
||||
const authorize = vi.fn(async () => 'once' as const)
|
||||
|
||||
await expect(
|
||||
adapter.run('hello', new AbortController().signal, authorize)
|
||||
adapter.run(
|
||||
'hello',
|
||||
new AbortController().signal,
|
||||
authorize,
|
||||
{
|
||||
onEvent: (event) => {
|
||||
streamEvents.push(event)
|
||||
}
|
||||
}
|
||||
)
|
||||
).resolves.toEqual({
|
||||
text: 'TOOLS_OK',
|
||||
streamedText: true,
|
||||
tools: [
|
||||
{
|
||||
callId: 'call-1',
|
||||
@@ -970,6 +1016,26 @@ describe('ContinueHostAdapter', () => {
|
||||
}
|
||||
]
|
||||
})
|
||||
expect(streamEvents).toEqual([
|
||||
{ type: 'text', delta: '先检查命令。' },
|
||||
{
|
||||
type: 'tool',
|
||||
tool: {
|
||||
callId: 'call-1',
|
||||
name: 'Bash',
|
||||
state: 'running'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'tool',
|
||||
tool: {
|
||||
callId: 'call-1',
|
||||
name: 'Bash',
|
||||
state: 'completed'
|
||||
}
|
||||
},
|
||||
{ type: 'text', delta: 'TOOLS_OK' }
|
||||
])
|
||||
expect(launchArgs).not.toContain('--readonly')
|
||||
expect(authorize).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ toolName: 'Bash' })
|
||||
|
||||
@@ -45,6 +45,7 @@ const maximumBundleBytes = 32 * 1024 * 1024
|
||||
const maximumStateBytes = 8 * 1024 * 1024
|
||||
const maximumConfigBytes = 1024 * 1024
|
||||
const maximumConfiguredMcpServers = 100
|
||||
const maximumStreamEvents = 5_000
|
||||
const knowledgeMcpName = 'goodbuddy-knowledge'
|
||||
export const continueConfigurationRequiredMessage =
|
||||
'Continue 尚未配置模型连接,请在设置中选择 GoodBuddy 模型连接或指定 Continue 配置文件'
|
||||
@@ -74,6 +75,24 @@ const sessionUsageSchema = z.object({
|
||||
.optional()
|
||||
})
|
||||
|
||||
const continueHostStreamEventSchema = z.discriminatedUnion('type', [
|
||||
z
|
||||
.object({
|
||||
type: z.literal('text'),
|
||||
delta: z.string().min(1).max(100_000)
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal('tool'),
|
||||
callId: z.string().min(1).max(256),
|
||||
name: z.string().min(1).max(200),
|
||||
state: z.enum(['running', 'completed', 'failed']),
|
||||
error: z.string().max(1_000).optional()
|
||||
})
|
||||
.strict()
|
||||
])
|
||||
|
||||
const stateSchema = z.object({
|
||||
session: z.object({
|
||||
history: z.array(z.unknown()).max(5_000),
|
||||
@@ -88,7 +107,11 @@ const stateSchema = z.object({
|
||||
requestId: z.string().min(1).max(256),
|
||||
toolCallPreview: z.array(z.unknown()).max(100).optional()
|
||||
})
|
||||
.nullable()
|
||||
.nullable(),
|
||||
goodbuddyEvents: z
|
||||
.array(continueHostStreamEventSchema)
|
||||
.max(maximumStreamEvents)
|
||||
.optional()
|
||||
})
|
||||
|
||||
type ContinueHostState = z.infer<typeof stateSchema>
|
||||
@@ -124,10 +147,15 @@ export type ContinueHostTool = {
|
||||
|
||||
export type ContinueHostRunResult = {
|
||||
text: string
|
||||
streamedText?: true
|
||||
usage?: ContinueHostUsage
|
||||
tools?: ContinueHostTool[]
|
||||
}
|
||||
|
||||
export type ContinueHostStreamEvent =
|
||||
| { type: 'text'; delta: string }
|
||||
| { type: 'tool'; tool: ContinueHostTool }
|
||||
|
||||
export class ContinueHostRunError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
@@ -158,6 +186,7 @@ export type ContinueHostRunOptions = {
|
||||
endpoint: string
|
||||
token: string
|
||||
}
|
||||
onEvent?: (event: ContinueHostStreamEvent) => void | Promise<void>
|
||||
}
|
||||
|
||||
type KnowledgeCapability = NonNullable<
|
||||
@@ -434,7 +463,7 @@ function extractContinueTools(
|
||||
: 'failed'
|
||||
const error =
|
||||
normalizedState === 'failed'
|
||||
? safeToolErrorDetail(state.output)
|
||||
? normalizeContinueToolError(state.output)
|
||||
: undefined
|
||||
tools.set(callId, {
|
||||
callId,
|
||||
@@ -447,6 +476,28 @@ function extractContinueTools(
|
||||
return [...tools.values()]
|
||||
}
|
||||
|
||||
function mergeContinueTools(
|
||||
current: ContinueHostTool[],
|
||||
updates: ContinueHostTool[]
|
||||
): ContinueHostTool[] {
|
||||
const tools = new Map(current.map((tool) => [tool.callId, tool]))
|
||||
for (const tool of updates) {
|
||||
tools.set(tool.callId, tool)
|
||||
}
|
||||
return [...tools.values()]
|
||||
}
|
||||
|
||||
function normalizeContinueToolError(value: unknown): string | undefined {
|
||||
const detail = safeToolErrorDetail(value)
|
||||
if (!detail) {
|
||||
return undefined
|
||||
}
|
||||
const replacementCharacters = detail.match(/\uFFFD/gu)?.length ?? 0
|
||||
return replacementCharacters >= 3
|
||||
? 'PowerShell 输出编码异常,原始错误无法安全显示;请重试该命令'
|
||||
: detail
|
||||
}
|
||||
|
||||
function subtractTokenCount(completed: number, initial: number): number {
|
||||
return Math.max(0, completed - initial)
|
||||
}
|
||||
@@ -538,6 +589,25 @@ export class ContinueHostAdapter {
|
||||
'shouldUseResponsesEndpoint(t){return this.config.useResponsesApi===!1?!1:this.apiBase==="https://api.openai.com/v1/"&&A0e(t)}'
|
||||
const modelConfigurationMarker =
|
||||
'function uAe(e,t){let n={provider:e.provider,model:e.model,apiKey:e.apiKey,apiBase:e.apiBase,requestOptions:e.requestOptions,env:e.env};return CGn(n)??null}'
|
||||
const windowsShellMarker =
|
||||
'function Csa(e){return process.platform==="win32"?{shell:"powershell.exe",args:["-NoLogo","-ExecutionPolicy","Bypass","-Command",e]}'
|
||||
const streamCallbacksMarker =
|
||||
'a={onContent:u=>{},onContentComplete:u=>{},onToolStart:(u,l)=>{},onToolResult:(u,l,c)=>{},onToolError:(u,l)=>{},onToolPermissionRequest:'
|
||||
const serverStateMarker = 'pendingPermission:null},B='
|
||||
const serverStateEndpointMarker =
|
||||
'j.get("/state",(we,Te)=>{M.lastActivity=Date.now(),B();let ue=e7e(M.session,M.isProcessing,rS.getQueueLength(),M.pendingPermission);Te.json(ue)})'
|
||||
const preprocessToolStartMarker =
|
||||
'n?.onToolStart?.(i.name,i.arguments);'
|
||||
const preprocessToolErrorMarker =
|
||||
'n?.onToolError?.(l,i.name)'
|
||||
const executeToolStartMarker =
|
||||
't?.onToolStart?.(c.name,c.arguments);'
|
||||
const cancelledToolResultMarker =
|
||||
't?.onToolResult?.(String(y.content),c.name,"canceled")'
|
||||
const completedToolResultMarker =
|
||||
't?.onToolResult?.(f,c.name,"done")'
|
||||
const failedToolResultMarker = 't?.onToolError?.(g,c.name)'
|
||||
const permissionToolErrorMarker = 't?.onToolError?.(p,c.name)'
|
||||
let patched = replaceExactly(
|
||||
sourceBundle,
|
||||
serveInitializationMarker,
|
||||
@@ -583,11 +653,66 @@ export class ContinueHostAdapter {
|
||||
modelConfigurationMarker,
|
||||
'function uAe(e,t){let n={provider:e.provider,model:e.model,apiKey:e.apiKey,apiBase:e.apiBase,requestOptions:e.requestOptions,env:e.env,useResponsesApi:e.useResponsesApi};return CGn(n)??null}'
|
||||
)
|
||||
patched = replaceExactly(
|
||||
patched,
|
||||
windowsShellMarker,
|
||||
'function Csa(e){return process.platform==="win32"?{shell:"powershell.exe",args:["-NoLogo","-NoProfile","-ExecutionPolicy","Bypass","-Command",\'[Console]::InputEncoding=[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false);$OutputEncoding=[Console]::OutputEncoding;\'+e]}'
|
||||
)
|
||||
patched = replaceExactly(
|
||||
patched,
|
||||
streamCallbacksMarker,
|
||||
'a={onContent:u=>{u&&e.goodbuddyEvents.length<5e3&&e.goodbuddyEvents.push({type:"text",delta:u})},onContentComplete:u=>{},onToolStart:(u,l,c)=>{c&&e.goodbuddyEvents.length<5e3&&e.goodbuddyEvents.push({type:"tool",callId:c,name:u,state:"running"})},onToolResult:(u,l,c,d)=>{d&&e.goodbuddyEvents.length<5e3&&e.goodbuddyEvents.push({type:"tool",callId:d,name:l,state:c==="done"?"completed":"failed"})},onToolError:(u,l,c)=>{c&&e.goodbuddyEvents.length<5e3&&e.goodbuddyEvents.push({type:"tool",callId:c,name:l??"unknown",state:"failed",error:String(u).slice(0,1e3)})},onToolPermissionRequest:'
|
||||
)
|
||||
patched = replaceExactly(
|
||||
patched,
|
||||
serverStateMarker,
|
||||
'pendingPermission:null,goodbuddyEvents:[]},B='
|
||||
)
|
||||
patched = replaceExactly(
|
||||
patched,
|
||||
serverStateEndpointMarker,
|
||||
'j.get("/state",(we,Te)=>{M.lastActivity=Date.now(),B();let ue=e7e(M.session,M.isProcessing,rS.getQueueLength(),M.pendingPermission),ce=M.goodbuddyEvents.splice(0);Te.json({...ue,goodbuddyEvents:ce})})'
|
||||
)
|
||||
patched = replaceExactly(
|
||||
patched,
|
||||
preprocessToolStartMarker,
|
||||
'n?.onToolStart?.(i.name,i.arguments,i.id);'
|
||||
)
|
||||
patched = replaceExactly(
|
||||
patched,
|
||||
preprocessToolErrorMarker,
|
||||
'n?.onToolError?.(l,i.name,i.id)'
|
||||
)
|
||||
patched = replaceExactly(
|
||||
patched,
|
||||
executeToolStartMarker,
|
||||
't?.onToolStart?.(c.name,c.arguments,c.id);'
|
||||
)
|
||||
patched = replaceExactly(
|
||||
patched,
|
||||
cancelledToolResultMarker,
|
||||
't?.onToolResult?.(String(y.content),c.name,"canceled",c.id)'
|
||||
)
|
||||
patched = replaceExactly(
|
||||
patched,
|
||||
completedToolResultMarker,
|
||||
't?.onToolResult?.(f,c.name,"done",c.id)'
|
||||
)
|
||||
patched = replaceExactly(
|
||||
patched,
|
||||
failedToolResultMarker,
|
||||
't?.onToolError?.(g,c.name,c.id)'
|
||||
)
|
||||
patched = replaceExactly(
|
||||
patched,
|
||||
permissionToolErrorMarker,
|
||||
't?.onToolError?.(p,c.name,c.id)'
|
||||
)
|
||||
const patchedHash = hashContents(patched)
|
||||
const digest = sourceHash.slice(0, 16)
|
||||
const targetRoot = join(
|
||||
this.options.cacheRoot,
|
||||
`host-v4-${supportedVersion}-${digest}`
|
||||
`host-v6-${supportedVersion}-${digest}`
|
||||
)
|
||||
const targetDist = join(targetRoot, 'dist')
|
||||
const targetBundle = join(targetDist, 'index.js')
|
||||
@@ -942,6 +1067,7 @@ export class ContinueHostAdapter {
|
||||
signal.addEventListener('abort', abort, { once: true })
|
||||
|
||||
let observedTools: ContinueHostTool[] = []
|
||||
let streamedText = false
|
||||
try {
|
||||
const initialState = await this.waitForStartup(
|
||||
child,
|
||||
@@ -972,10 +1098,27 @@ export class ContinueHostAdapter {
|
||||
const state = stateSchema.parse(
|
||||
await this.request(origin, token, '/state', { signal })
|
||||
)
|
||||
observedTools = extractContinueTools(
|
||||
state.session.history,
|
||||
startIndex
|
||||
observedTools = mergeContinueTools(
|
||||
observedTools,
|
||||
extractContinueTools(state.session.history, startIndex)
|
||||
)
|
||||
for (const event of state.goodbuddyEvents ?? []) {
|
||||
if (event.type === 'text') {
|
||||
streamedText = true
|
||||
await runOptions.onEvent?.(event)
|
||||
continue
|
||||
}
|
||||
const tool: ContinueHostTool = {
|
||||
callId: event.callId,
|
||||
name: event.name,
|
||||
state: event.state,
|
||||
...(event.error
|
||||
? { error: normalizeContinueToolError(event.error) }
|
||||
: {})
|
||||
}
|
||||
observedTools = mergeContinueTools(observedTools, [tool])
|
||||
await runOptions.onEvent?.({ type: 'tool', tool })
|
||||
}
|
||||
const pending = state.pendingPermission
|
||||
if (pending && !handledPermissionIds.has(pending.requestId)) {
|
||||
if (handledPermissionIds.size >= 100) {
|
||||
@@ -1053,6 +1196,7 @@ export class ContinueHostAdapter {
|
||||
)
|
||||
return {
|
||||
text,
|
||||
...(streamedText ? { streamedText: true as const } : {}),
|
||||
...(usage ? { usage } : {}),
|
||||
...(observedTools.length > 0
|
||||
? { tools: observedTools }
|
||||
|
||||
@@ -101,7 +101,10 @@ describe('ContinueAgentRuntime', () => {
|
||||
expect(mocks.runHost).toHaveBeenCalledWith(
|
||||
'test',
|
||||
expect.any(AbortSignal),
|
||||
expect.any(Function)
|
||||
expect.any(Function),
|
||||
expect.objectContaining({
|
||||
onEvent: expect.any(Function)
|
||||
})
|
||||
)
|
||||
expect(events).toContainEqual({
|
||||
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
|
||||
@@ -189,7 +192,8 @@ describe('ContinueAgentRuntime', () => {
|
||||
knowledgeCapability: {
|
||||
endpoint: 'http://127.0.0.1:4567/mcp',
|
||||
token: 'main-only-token'
|
||||
}
|
||||
},
|
||||
onEvent: expect.any(Function)
|
||||
}
|
||||
)
|
||||
const authorize = mocks.runHost.mock.calls[0]?.[2]
|
||||
@@ -403,6 +407,74 @@ describe('ContinueAgentRuntime', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('forwards streamed text and tool events in host order', async () => {
|
||||
mocks.runHost.mockImplementation(
|
||||
async (
|
||||
_prompt,
|
||||
_signal,
|
||||
_authorize,
|
||||
options
|
||||
) => {
|
||||
await options?.onEvent?.({
|
||||
type: 'text',
|
||||
delta: '先分析'
|
||||
})
|
||||
await options?.onEvent?.({
|
||||
type: 'tool',
|
||||
tool: {
|
||||
callId: 'call-1',
|
||||
name: 'Read',
|
||||
state: 'running'
|
||||
}
|
||||
})
|
||||
await options?.onEvent?.({
|
||||
type: 'tool',
|
||||
tool: {
|
||||
callId: 'call-1',
|
||||
name: 'Read',
|
||||
state: 'completed'
|
||||
}
|
||||
})
|
||||
await options?.onEvent?.({
|
||||
type: 'text',
|
||||
delta: '再回答'
|
||||
})
|
||||
return {
|
||||
text: '再回答',
|
||||
streamedText: true,
|
||||
tools: [
|
||||
{
|
||||
callId: 'call-1',
|
||||
name: 'Read',
|
||||
state: 'completed'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const events = await collectEvents(createRuntime(), 'execute')
|
||||
|
||||
expect(
|
||||
events.filter(
|
||||
(event) => event.type === 'text' || event.type === 'tool'
|
||||
)
|
||||
).toEqual([
|
||||
expect.objectContaining({ type: 'text', delta: '先分析' }),
|
||||
expect.objectContaining({
|
||||
type: 'tool',
|
||||
callId: 'call-1',
|
||||
state: 'running'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'tool',
|
||||
callId: 'call-1',
|
||||
state: 'completed'
|
||||
}),
|
||||
expect.objectContaining({ type: 'text', delta: '再回答' })
|
||||
])
|
||||
})
|
||||
|
||||
it('emits terminal tool audits before a failed Continue run', async () => {
|
||||
mocks.runHost.mockRejectedValue(
|
||||
new ContinueHostRunError('Continue failed', {
|
||||
@@ -441,7 +513,7 @@ describe('ContinueAgentRuntime', () => {
|
||||
await expect(stream.next()).rejects.toThrow('Continue failed')
|
||||
})
|
||||
|
||||
it('returns a failed Continue tool detail through AgentRuntime', async () => {
|
||||
it('keeps a completed Continue response when an earlier tool attempt failed', async () => {
|
||||
mocks.runHost.mockResolvedValue({
|
||||
text: 'Continue response',
|
||||
tools: [
|
||||
@@ -466,17 +538,25 @@ describe('ContinueAgentRuntime', () => {
|
||||
await expect(stream.next()).resolves.toMatchObject({
|
||||
value: { type: 'status' }
|
||||
})
|
||||
await expect(stream.next()).resolves.toMatchObject({
|
||||
value: {
|
||||
const events: RuntimeEvent[] = []
|
||||
for await (const event of stream) {
|
||||
events.push(event)
|
||||
}
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'tool',
|
||||
callId: 'call-1',
|
||||
state: 'failed',
|
||||
state: 'recoverable',
|
||||
error: 'PowerShell EmptyPipeElement'
|
||||
}
|
||||
})
|
||||
await expect(stream.next()).rejects.toThrow(
|
||||
'PowerShell EmptyPipeElement'
|
||||
})
|
||||
)
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'text',
|
||||
delta: 'Continue response'
|
||||
})
|
||||
)
|
||||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||||
})
|
||||
|
||||
it('fails a run that returns a nonterminal tool state', async () => {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
type ContinueHostAdapterOptions,
|
||||
type ContinueHostLauncher,
|
||||
type ContinueHostRunResult,
|
||||
type ContinueHostStreamEvent,
|
||||
type ContinueHostTool
|
||||
} from './continue-host-adapter'
|
||||
|
||||
@@ -56,7 +57,8 @@ function continueToolFailureMessage(tool: ContinueHostTool): string {
|
||||
function toContinueToolEvent(
|
||||
requestId: string,
|
||||
tool: ContinueHostTool,
|
||||
terminalize: boolean
|
||||
terminalize: boolean,
|
||||
recoverFailure = false
|
||||
): Extract<AgentEvent, { type: 'tool' }> {
|
||||
return {
|
||||
requestId,
|
||||
@@ -64,7 +66,9 @@ function toContinueToolEvent(
|
||||
callId: tool.callId,
|
||||
name: tool.name,
|
||||
state:
|
||||
terminalize && tool.state !== 'completed'
|
||||
recoverFailure && tool.state === 'failed'
|
||||
? 'recoverable'
|
||||
: terminalize && tool.state !== 'completed'
|
||||
? 'failed'
|
||||
: tool.state,
|
||||
summary: `Continue 工具:${tool.name}`,
|
||||
@@ -283,6 +287,7 @@ export class ContinueAgentRuntime implements AgentRuntime {
|
||||
}
|
||||
: undefined
|
||||
let result: ContinueHostRunResult
|
||||
const emittedTools = new Map<string, ContinueHostTool>()
|
||||
try {
|
||||
const host = this.getHostAdapter(
|
||||
binaryPath,
|
||||
@@ -299,17 +304,72 @@ export class ContinueAgentRuntime implements AgentRuntime {
|
||||
approval.toolName === 'knowledge_search')
|
||||
? 'once' as const
|
||||
: 'deny' as const
|
||||
result = knowledgeCapability
|
||||
? await host.run(
|
||||
conversationContext,
|
||||
signal,
|
||||
authorize,
|
||||
{
|
||||
workMode: request.workMode,
|
||||
knowledgeCapability
|
||||
const queuedEvents: ContinueHostStreamEvent[] = []
|
||||
let wakeStream: (() => void) | undefined
|
||||
let streamFinished = false
|
||||
let streamResult: ContinueHostRunResult | undefined
|
||||
let streamError: unknown
|
||||
const onEvent = (event: ContinueHostStreamEvent): void => {
|
||||
queuedEvents.push(event)
|
||||
wakeStream?.()
|
||||
wakeStream = undefined
|
||||
}
|
||||
const hostRun = host
|
||||
.run(
|
||||
conversationContext,
|
||||
signal,
|
||||
authorize,
|
||||
{
|
||||
workMode: request.workMode,
|
||||
...(knowledgeCapability ? { knowledgeCapability } : {}),
|
||||
onEvent
|
||||
}
|
||||
)
|
||||
.then(
|
||||
(value) => {
|
||||
streamResult = value
|
||||
},
|
||||
(error: unknown) => {
|
||||
streamError = error
|
||||
}
|
||||
)
|
||||
.finally(() => {
|
||||
streamFinished = true
|
||||
wakeStream?.()
|
||||
wakeStream = undefined
|
||||
})
|
||||
|
||||
while (!streamFinished || queuedEvents.length > 0) {
|
||||
if (queuedEvents.length === 0) {
|
||||
await new Promise<void>((resolve) => {
|
||||
wakeStream = resolve
|
||||
})
|
||||
continue
|
||||
}
|
||||
const event = queuedEvents.shift()!
|
||||
if (event.type === 'tool') {
|
||||
emittedTools.set(event.tool.callId, event.tool)
|
||||
}
|
||||
yield event.type === 'text'
|
||||
? {
|
||||
requestId: request.requestId,
|
||||
type: 'text',
|
||||
delta: event.delta
|
||||
}
|
||||
)
|
||||
: await host.run(conversationContext, signal, authorize)
|
||||
: toContinueToolEvent(
|
||||
request.requestId,
|
||||
event.tool,
|
||||
false
|
||||
)
|
||||
}
|
||||
await hostRun
|
||||
if (streamError) {
|
||||
throw streamError
|
||||
}
|
||||
if (!streamResult) {
|
||||
throw new Error('Continue 宿主未返回运行结果')
|
||||
}
|
||||
result = streamResult
|
||||
} catch (error) {
|
||||
if (error instanceof ContinueHostRunError) {
|
||||
for (const tool of error.tools) {
|
||||
@@ -323,23 +383,50 @@ export class ContinueAgentRuntime implements AgentRuntime {
|
||||
}
|
||||
|
||||
const tools = result.tools ?? []
|
||||
const unsuccessfulTool = tools.find(
|
||||
(tool) => tool.state !== 'completed'
|
||||
const incompleteTool = tools.find(
|
||||
(tool) => tool.state === 'pending' || tool.state === 'running'
|
||||
)
|
||||
if (unsuccessfulTool) {
|
||||
if (incompleteTool) {
|
||||
for (const tool of tools) {
|
||||
yield toContinueToolEvent(request.requestId, tool, true)
|
||||
const terminalEvent = toContinueToolEvent(
|
||||
request.requestId,
|
||||
tool,
|
||||
true
|
||||
)
|
||||
const previous = emittedTools.get(tool.callId)
|
||||
if (
|
||||
!previous ||
|
||||
previous.state !== terminalEvent.state ||
|
||||
previous.error !== terminalEvent.error
|
||||
) {
|
||||
yield terminalEvent
|
||||
}
|
||||
}
|
||||
throw new Error(continueToolFailureMessage(unsuccessfulTool))
|
||||
throw new Error(continueToolFailureMessage(incompleteTool))
|
||||
}
|
||||
|
||||
for (const tool of tools) {
|
||||
yield toContinueToolEvent(request.requestId, tool, false)
|
||||
const finalEvent = toContinueToolEvent(
|
||||
request.requestId,
|
||||
tool,
|
||||
false,
|
||||
true
|
||||
)
|
||||
const previous = emittedTools.get(tool.callId)
|
||||
if (
|
||||
!previous ||
|
||||
previous.state !== finalEvent.state ||
|
||||
previous.error !== finalEvent.error
|
||||
) {
|
||||
yield finalEvent
|
||||
}
|
||||
}
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: 'text',
|
||||
delta: result.text
|
||||
if (!result.streamedText) {
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: 'text',
|
||||
delta: result.text
|
||||
}
|
||||
}
|
||||
if (result.usage) {
|
||||
const usage = result.usage
|
||||
|
||||
@@ -153,6 +153,14 @@ function runClient(events: Record<string, unknown>[]) {
|
||||
data: true,
|
||||
error: undefined
|
||||
})
|
||||
const questionReply = vi.fn().mockResolvedValue({
|
||||
data: true,
|
||||
error: undefined
|
||||
})
|
||||
const questionReject = vi.fn().mockResolvedValue({
|
||||
data: true,
|
||||
error: undefined
|
||||
})
|
||||
const client = {
|
||||
session: {
|
||||
list: vi.fn().mockResolvedValue({ data: [], error: undefined }),
|
||||
@@ -192,6 +200,10 @@ function runClient(events: Record<string, unknown>[]) {
|
||||
permission: {
|
||||
reply: permissionReply
|
||||
},
|
||||
question: {
|
||||
reply: questionReply,
|
||||
reject: questionReject
|
||||
},
|
||||
mcp: {
|
||||
add: vi
|
||||
.fn()
|
||||
@@ -218,6 +230,8 @@ function runClient(events: Record<string, unknown>[]) {
|
||||
client,
|
||||
callOrder,
|
||||
permissionReply,
|
||||
questionReply,
|
||||
questionReject,
|
||||
session: client.session,
|
||||
event: client.event,
|
||||
tool: client.tool
|
||||
@@ -978,6 +992,84 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
||||
})
|
||||
|
||||
describe('OpenCodeRuntime embedded permission mediation', () => {
|
||||
it('parses OpenCode questions and sends the selected answers back', async () => {
|
||||
const setup = runClient([
|
||||
{
|
||||
id: 'question-event',
|
||||
type: 'question.asked',
|
||||
properties: {
|
||||
id: 'question-1',
|
||||
sessionID: 'session-1',
|
||||
questions: [
|
||||
{
|
||||
header: '实现方式',
|
||||
question: '请选择实现方式',
|
||||
options: [
|
||||
{
|
||||
label: '直接修改',
|
||||
description: '立即更新现有实现'
|
||||
},
|
||||
{
|
||||
label: '先写测试',
|
||||
description: '先增加回归测试'
|
||||
}
|
||||
],
|
||||
multiple: false,
|
||||
custom: true
|
||||
}
|
||||
],
|
||||
tool: {
|
||||
messageID: 'message-1',
|
||||
callID: 'call-question-1'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'idle',
|
||||
type: 'session.idle',
|
||||
properties: { sessionID: 'session-1' }
|
||||
}
|
||||
])
|
||||
const runtime = embeddedRuntime(setup.client)
|
||||
const stream = runtime.run(
|
||||
{
|
||||
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
|
||||
conversationId: 'conversation-1',
|
||||
prompt: 'test',
|
||||
workMode: 'execute'
|
||||
},
|
||||
new AbortController().signal
|
||||
)
|
||||
|
||||
await expect(stream.next()).resolves.toMatchObject({
|
||||
value: { type: 'status' }
|
||||
})
|
||||
await expect(stream.next()).resolves.toMatchObject({
|
||||
value: {
|
||||
type: 'question',
|
||||
questionId: 'question-1',
|
||||
questions: [
|
||||
{
|
||||
header: '实现方式',
|
||||
question: '请选择实现方式',
|
||||
multiple: false,
|
||||
custom: true
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
await runtime.respondToQuestion('question-1', [['先写测试']])
|
||||
expect(setup.questionReply).toHaveBeenCalledWith({
|
||||
requestID: 'question-1',
|
||||
directory: process.cwd(),
|
||||
answers: [['先写测试']]
|
||||
})
|
||||
await expect(stream.next()).resolves.toMatchObject({
|
||||
value: { type: 'done' }
|
||||
})
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('adds only the request-scoped knowledge MCP tool for Ask and disconnects it', async () => {
|
||||
const setup = runClient([
|
||||
{
|
||||
@@ -1420,6 +1512,33 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
|
||||
delta: 'approved output'
|
||||
})
|
||||
)
|
||||
expect(
|
||||
events.filter(
|
||||
(event) =>
|
||||
event.type === 'reasoning' ||
|
||||
event.type === 'text' ||
|
||||
event.type === 'tool'
|
||||
)
|
||||
).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'tool',
|
||||
callId: 'call-1',
|
||||
state: 'pending'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'tool',
|
||||
callId: 'call-1',
|
||||
state: 'completed'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'reasoning',
|
||||
delta: 'reasoning output'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'text',
|
||||
delta: 'approved output'
|
||||
})
|
||||
])
|
||||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
@@ -3,12 +3,16 @@ import {
|
||||
type AssistantMessage,
|
||||
type OpencodeClient,
|
||||
type PermissionRequest,
|
||||
type PermissionRuleset
|
||||
type PermissionRuleset,
|
||||
type QuestionRequest
|
||||
} from '@opencode-ai/sdk/v2'
|
||||
import spawn from 'cross-spawn'
|
||||
import { createHash, randomBytes } from 'node:crypto'
|
||||
import { resolve } from 'node:path'
|
||||
import type { AgentRuntimeStatus } from '../../shared/contracts'
|
||||
import type {
|
||||
AgentQuestionAnswer,
|
||||
AgentRuntimeStatus
|
||||
} from '../../shared/contracts'
|
||||
import { createAnthropicApiBaseUrl } from './anthropic-endpoint'
|
||||
import { createOpenAIApiBaseUrl } from './openai-endpoint'
|
||||
import type {
|
||||
@@ -42,6 +46,9 @@ const MAX_PERMISSION_PATTERN_LENGTH = 1_024
|
||||
const MAX_PERMISSION_PATTERNS_BYTES = 8 * 1_024
|
||||
const MAX_PERMISSION_METADATA_BYTES = 8 * 1_024
|
||||
const MAX_TOOL_CALLS_PER_RUN = 100
|
||||
const MAX_QUESTION_REQUEST_BYTES = 32 * 1_024
|
||||
const MAX_QUESTIONS_PER_REQUEST = 4
|
||||
const MAX_QUESTION_OPTIONS = 20
|
||||
const EMBEDDED_SERVER_USERNAME = 'goodbuddy'
|
||||
|
||||
type SpawnedProcess = ReturnType<typeof spawn>
|
||||
@@ -230,6 +237,69 @@ function parsePermissionRequest(
|
||||
return properties as PermissionRequest
|
||||
}
|
||||
|
||||
function parseQuestionRequest(
|
||||
properties: unknown,
|
||||
sessionId: string
|
||||
): QuestionRequest | undefined {
|
||||
if (!isRecord(properties) || properties.sessionID !== sessionId) {
|
||||
return undefined
|
||||
}
|
||||
const { id, questions, tool } = properties
|
||||
if (
|
||||
typeof id !== 'string' ||
|
||||
id.length === 0 ||
|
||||
id.length > MAX_PERMISSION_NAME_LENGTH ||
|
||||
!Array.isArray(questions) ||
|
||||
questions.length === 0 ||
|
||||
questions.length > MAX_QUESTIONS_PER_REQUEST ||
|
||||
!questions.every(
|
||||
(question) =>
|
||||
isRecord(question) &&
|
||||
typeof question.question === 'string' &&
|
||||
question.question.trim().length > 0 &&
|
||||
question.question.length <= 2_000 &&
|
||||
typeof question.header === 'string' &&
|
||||
question.header.trim().length > 0 &&
|
||||
question.header.length <= 120 &&
|
||||
Array.isArray(question.options) &&
|
||||
question.options.length <= MAX_QUESTION_OPTIONS &&
|
||||
question.options.every(
|
||||
(option) =>
|
||||
isRecord(option) &&
|
||||
typeof option.label === 'string' &&
|
||||
option.label.trim().length > 0 &&
|
||||
option.label.length <= 200 &&
|
||||
typeof option.description === 'string' &&
|
||||
option.description.length <= 1_000
|
||||
) &&
|
||||
(question.multiple === undefined ||
|
||||
typeof question.multiple === 'boolean') &&
|
||||
(question.custom === undefined ||
|
||||
typeof question.custom === 'boolean')
|
||||
) ||
|
||||
(tool !== undefined &&
|
||||
(!isRecord(tool) ||
|
||||
typeof tool.messageID !== 'string' ||
|
||||
tool.messageID.length === 0 ||
|
||||
tool.messageID.length > 256 ||
|
||||
typeof tool.callID !== 'string' ||
|
||||
tool.callID.length === 0 ||
|
||||
tool.callID.length > 256))
|
||||
) {
|
||||
throw new Error('OpenCode 提问请求格式无效')
|
||||
}
|
||||
let serialized: string
|
||||
try {
|
||||
serialized = JSON.stringify(properties)
|
||||
} catch {
|
||||
throw new Error('OpenCode 提问请求无法序列化')
|
||||
}
|
||||
if (!byteLengthWithin(serialized, MAX_QUESTION_REQUEST_BYTES)) {
|
||||
throw new Error('OpenCode 提问请求超过安全限制')
|
||||
}
|
||||
return properties as QuestionRequest
|
||||
}
|
||||
|
||||
function isSafeTokenCount(value: number): boolean {
|
||||
return Number.isSafeInteger(value) && value >= 0
|
||||
}
|
||||
@@ -355,6 +425,14 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
string,
|
||||
Promise<string>
|
||||
>()
|
||||
private readonly pendingQuestions = new Map<
|
||||
string,
|
||||
{
|
||||
client: OpencodeClient
|
||||
directory: string
|
||||
questionCount: number
|
||||
}
|
||||
>()
|
||||
private embeddedRunTail: Promise<void> = Promise.resolve()
|
||||
private readonly dependencies: OpenCodeRuntimeDependencies
|
||||
|
||||
@@ -904,6 +982,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
}
|
||||
>()
|
||||
const reasoningPartIds = new Set<string>()
|
||||
const reportedQuestionIds = new Set<string>()
|
||||
try {
|
||||
const promptText =
|
||||
session.created && request.history?.length
|
||||
@@ -1027,6 +1106,50 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === 'question.asked' &&
|
||||
event.properties.sessionID === sessionId
|
||||
) {
|
||||
const questionRequest = parseQuestionRequest(
|
||||
event.properties,
|
||||
sessionId
|
||||
)
|
||||
if (
|
||||
questionRequest &&
|
||||
!reportedQuestionIds.has(questionRequest.id)
|
||||
) {
|
||||
reportedQuestionIds.add(questionRequest.id)
|
||||
this.pendingQuestions.set(questionRequest.id, {
|
||||
client,
|
||||
directory,
|
||||
questionCount: questionRequest.questions.length
|
||||
})
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: 'question',
|
||||
questionId: questionRequest.id,
|
||||
questions: questionRequest.questions.map((question) => ({
|
||||
header: question.header,
|
||||
question: question.question,
|
||||
options: question.options.map((option) => ({
|
||||
label: option.label,
|
||||
description: option.description
|
||||
})),
|
||||
multiple: question.multiple ?? false,
|
||||
custom: question.custom ?? true
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(event.type === 'question.replied' ||
|
||||
event.type === 'question.rejected') &&
|
||||
event.properties.sessionID === sessionId
|
||||
) {
|
||||
this.pendingQuestions.delete(event.properties.requestID)
|
||||
}
|
||||
|
||||
if (
|
||||
this.usesEmbeddedPermissionMediation() &&
|
||||
event.type === 'permission.asked'
|
||||
@@ -1193,6 +1316,9 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
throw error
|
||||
} finally {
|
||||
signal.removeEventListener('abort', abortSession)
|
||||
for (const questionId of reportedQuestionIds) {
|
||||
this.pendingQuestions.delete(questionId)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (knowledgeMcpName) {
|
||||
@@ -1203,7 +1329,39 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
async respondToQuestion(
|
||||
questionId: string,
|
||||
answers?: AgentQuestionAnswer[]
|
||||
): Promise<void> {
|
||||
const pending = this.pendingQuestions.get(questionId)
|
||||
if (!pending) {
|
||||
throw new Error('OpenCode 提问已失效或不存在')
|
||||
}
|
||||
const response = answers
|
||||
? answers.length === pending.questionCount
|
||||
? await pending.client.question.reply({
|
||||
requestID: questionId,
|
||||
directory: pending.directory,
|
||||
answers
|
||||
})
|
||||
: undefined
|
||||
: await pending.client.question.reject({
|
||||
requestID: questionId,
|
||||
directory: pending.directory
|
||||
})
|
||||
if (!response) {
|
||||
throw new Error('OpenCode 提问回答数量不匹配')
|
||||
}
|
||||
if (response.error || response.data !== true) {
|
||||
throw new Error(
|
||||
answers ? 'OpenCode 提交回答失败' : 'OpenCode 取消提问失败'
|
||||
)
|
||||
}
|
||||
this.pendingQuestions.delete(questionId)
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.pendingQuestions.clear()
|
||||
const startingChild = this.startingChild
|
||||
this.startingChild = undefined
|
||||
if (startingChild) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
AgentQuestionAnswer,
|
||||
AgentRequest,
|
||||
AgentRuntimeStatus
|
||||
} from '../../shared/contracts'
|
||||
@@ -167,6 +168,20 @@ export class AgentRuntimeController implements AgentRuntime {
|
||||
await this.current.runtime.releaseConversation?.(conversationId)
|
||||
}
|
||||
|
||||
async respondToQuestion(
|
||||
questionId: string,
|
||||
answers?: AgentQuestionAnswer[]
|
||||
): Promise<void> {
|
||||
if (this.closing) {
|
||||
throw new Error('Agent Runtime 正在关闭')
|
||||
}
|
||||
const runtime = this.current.runtime
|
||||
if (!runtime.respondToQuestion) {
|
||||
throw new Error('当前 Runtime 不支持回答交互式问题')
|
||||
}
|
||||
await runtime.respondToQuestion(questionId, answers)
|
||||
}
|
||||
|
||||
private retire(slot: RuntimeSlot): Promise<void> {
|
||||
slot.retiring = true
|
||||
if (!slot.disposal) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
ApprovalDecision,
|
||||
AgentEvent,
|
||||
AgentQuestionAnswer,
|
||||
AgentRequest,
|
||||
AgentRuntimeStatus
|
||||
} from '../../shared/contracts'
|
||||
@@ -57,6 +58,10 @@ export interface AgentRuntime {
|
||||
signal: AbortSignal,
|
||||
authorize?: RuntimeAuthorizer
|
||||
): AsyncGenerator<RuntimeEvent, void, void>
|
||||
respondToQuestion?(
|
||||
questionId: string,
|
||||
answers?: AgentQuestionAnswer[]
|
||||
): Promise<void>
|
||||
releaseConversation?(conversationId: string): Promise<void>
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
|
||||
@@ -71,6 +71,44 @@ describe('SelectedRuntimeManager', () => {
|
||||
await manager.dispose()
|
||||
})
|
||||
|
||||
it('isolates cached runtimes by effective project workspace', async () => {
|
||||
const first = runtime()
|
||||
const second = runtime()
|
||||
const create = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(first.value)
|
||||
.mockResolvedValueOnce(second.value)
|
||||
const manager = new SelectedRuntimeManager(create)
|
||||
const selection = { provider: 'opencode' as const }
|
||||
|
||||
const projectOne = await manager.getRuntime(
|
||||
selection,
|
||||
'C:\\Projects\\One'
|
||||
)
|
||||
const projectOneAgain = await manager.getRuntime(
|
||||
selection,
|
||||
'C:\\Projects\\One'
|
||||
)
|
||||
const projectTwo = await manager.getRuntime(
|
||||
selection,
|
||||
'C:\\Projects\\Two'
|
||||
)
|
||||
|
||||
expect(projectOneAgain).toBe(projectOne)
|
||||
expect(projectTwo).not.toBe(projectOne)
|
||||
expect(create).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
selection,
|
||||
'C:\\Projects\\One'
|
||||
)
|
||||
expect(create).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
selection,
|
||||
'C:\\Projects\\Two'
|
||||
)
|
||||
await manager.dispose()
|
||||
})
|
||||
|
||||
it('retires cached runtimes when settings change', async () => {
|
||||
const first = runtime()
|
||||
const second = runtime()
|
||||
|
||||
@@ -7,7 +7,10 @@ import type { AgentRuntime } from './runtime'
|
||||
import { AgentRuntimeController } from './runtime-controller'
|
||||
|
||||
export type SelectedRuntimeResolver = {
|
||||
getRuntime(selection: AgentRuntimeSelection): Promise<AgentRuntime>
|
||||
getRuntime(
|
||||
selection: AgentRuntimeSelection,
|
||||
workspacePath?: string
|
||||
): Promise<AgentRuntime>
|
||||
getStatus(
|
||||
selection: AgentRuntimeSelection
|
||||
): Promise<AgentRuntimeStatus>
|
||||
@@ -15,6 +18,7 @@ export type SelectedRuntimeResolver = {
|
||||
selection: AgentRuntimeSelection
|
||||
): Promise<AgentRuntimeStatus>
|
||||
releaseConversation(conversationId: string): Promise<void>
|
||||
reset?(): Promise<void>
|
||||
}
|
||||
|
||||
export class SelectedRuntimeManager implements SelectedRuntimeResolver {
|
||||
@@ -28,28 +32,35 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver {
|
||||
|
||||
constructor(
|
||||
private readonly createRuntime: (
|
||||
selection: AgentRuntimeSelection
|
||||
selection: AgentRuntimeSelection,
|
||||
workspacePath?: string
|
||||
) => Promise<AgentRuntime>
|
||||
) {}
|
||||
|
||||
async getRuntime(
|
||||
selection: AgentRuntimeSelection
|
||||
selection: AgentRuntimeSelection,
|
||||
workspacePath?: string
|
||||
): Promise<AgentRuntime> {
|
||||
if (this.disposed) {
|
||||
throw new Error('Agent Runtime 正在关闭')
|
||||
}
|
||||
const key = agentRuntimeSelectionKey(selection)
|
||||
const key = JSON.stringify([
|
||||
agentRuntimeSelectionKey(selection),
|
||||
workspacePath ?? ''
|
||||
])
|
||||
const existing = this.entries.get(key)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const operation = this.createRuntime(selection).then(async (runtime) => {
|
||||
if (this.disposed || this.entries.get(key) !== operation) {
|
||||
await runtime.dispose()
|
||||
throw new Error('Runtime 设置已更改,请重新选择')
|
||||
const operation = this.createRuntime(selection, workspacePath).then(
|
||||
async (runtime) => {
|
||||
if (this.disposed || this.entries.get(key) !== operation) {
|
||||
await runtime.dispose()
|
||||
throw new Error('Runtime 设置已更改,请重新选择')
|
||||
}
|
||||
return new AgentRuntimeController(runtime)
|
||||
}
|
||||
return new AgentRuntimeController(runtime)
|
||||
})
|
||||
)
|
||||
this.entries.set(key, operation)
|
||||
try {
|
||||
return await operation
|
||||
|
||||
@@ -234,6 +234,97 @@ describe('AssistantDatabase', () => {
|
||||
database.close()
|
||||
})
|
||||
|
||||
it('safely deletes a confirmed project and its scoped data', async () => {
|
||||
const database = await createDatabase()
|
||||
const project = database.createProject({
|
||||
name: '待删除项目',
|
||||
description: '删除测试',
|
||||
rootPath: 'C:\\Delete',
|
||||
defaultWorkMode: 'execute'
|
||||
})
|
||||
const conversationId = '00000000-0000-4000-8000-000000000111'
|
||||
const taskId = '00000000-0000-4000-8000-000000000211'
|
||||
database.replaceConversations([
|
||||
{
|
||||
id: conversationId,
|
||||
projectId: project.id,
|
||||
title: '项目对话',
|
||||
updatedAt: Date.now(),
|
||||
messages: []
|
||||
}
|
||||
])
|
||||
database.createTask({
|
||||
id: taskId,
|
||||
projectId: project.id,
|
||||
conversationId,
|
||||
title: '项目任务',
|
||||
instructions: '执行任务',
|
||||
workMode: 'execute'
|
||||
})
|
||||
database.createTextArtifact({
|
||||
projectId: project.id,
|
||||
taskId,
|
||||
title: '项目成果',
|
||||
content: '内容'
|
||||
})
|
||||
database.createMemory({
|
||||
scope: 'project',
|
||||
scopeId: project.id,
|
||||
type: 'fact',
|
||||
content: '项目记忆'
|
||||
})
|
||||
database.createSchedule({
|
||||
projectId: project.id,
|
||||
title: '项目计划',
|
||||
prompt: '执行计划',
|
||||
workMode: 'ask',
|
||||
recurrence: 'daily',
|
||||
nextRunAt: '2026-08-08T00:00:00.000Z'
|
||||
})
|
||||
|
||||
expect(() =>
|
||||
database.deleteProject(project.id, project.name)
|
||||
).toThrow('项目仍有进行中的任务')
|
||||
database.updateTaskStatus(taskId, 'completed')
|
||||
expect(() =>
|
||||
database.deleteProject(project.id, '错误名称')
|
||||
).toThrow('项目名称确认不匹配')
|
||||
|
||||
database.deleteProject(project.id, project.name)
|
||||
|
||||
expect(
|
||||
database.listProjects(true).some((item) => item.id === project.id)
|
||||
).toBe(false)
|
||||
expect(
|
||||
database.listConversations().some(
|
||||
(conversation) => conversation.projectId === project.id
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
database.listTasks().some((task) => task.projectId === project.id)
|
||||
).toBe(false)
|
||||
expect(database.listArtifacts(project.id)).toEqual([])
|
||||
expect(database.listSchedules(project.id)).toEqual([])
|
||||
expect(
|
||||
database
|
||||
.listMemories(project.id)
|
||||
.some((memory) => memory.scopeId === project.id)
|
||||
).toBe(false)
|
||||
expect(database.listProjects()).toHaveLength(1)
|
||||
database.close()
|
||||
})
|
||||
|
||||
it('does not delete the final active project', async () => {
|
||||
const database = await createDatabase()
|
||||
const project = database.listProjects()[0]!
|
||||
|
||||
expect(() =>
|
||||
database.deleteProject(project.id, project.name)
|
||||
).toThrow('至少需要保留一个可用项目')
|
||||
expect(database.listProjects()).toHaveLength(1)
|
||||
database.close()
|
||||
})
|
||||
|
||||
it('creates, updates, and soft-deletes expert roles', async () => {
|
||||
const database = await createDatabase()
|
||||
const expert = database.createExpert({
|
||||
@@ -613,6 +704,29 @@ describe('AssistantDatabase', () => {
|
||||
id: '00000000-0000-4000-8000-000000000213',
|
||||
role: 'assistant',
|
||||
content: '处理中',
|
||||
reasoning: '先分析发布范围',
|
||||
blocks: [
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000217',
|
||||
type: 'reasoning',
|
||||
content: '先分析发布范围'
|
||||
},
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000218',
|
||||
type: 'tool',
|
||||
tool: {
|
||||
callId: 'call-1',
|
||||
name: 'read',
|
||||
state: 'running',
|
||||
summary: 'OpenCode 工具:read'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000219',
|
||||
type: 'text',
|
||||
content: '处理中'
|
||||
}
|
||||
],
|
||||
createdAt: 1_775_000_001_000,
|
||||
state: 'streaming',
|
||||
artifactIds: [
|
||||
@@ -665,6 +779,24 @@ describe('AssistantDatabase', () => {
|
||||
role: 'assistant',
|
||||
state: 'error',
|
||||
status: expect.stringContaining('意外中断'),
|
||||
reasoning: '先分析发布范围',
|
||||
blocks: [
|
||||
expect.objectContaining({
|
||||
type: 'reasoning',
|
||||
content: '先分析发布范围'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'tool',
|
||||
tool: expect.objectContaining({
|
||||
callId: 'call-1',
|
||||
state: 'interrupted'
|
||||
})
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'text',
|
||||
content: '处理中'
|
||||
})
|
||||
],
|
||||
artifactIds: [
|
||||
'00000000-0000-4000-8000-000000000216'
|
||||
],
|
||||
|
||||
@@ -89,6 +89,8 @@ type MessageRow = {
|
||||
type MessageMetadata = {
|
||||
createdAt?: number
|
||||
status?: string
|
||||
reasoning?: ConversationSnapshot['messages'][number]['reasoning']
|
||||
blocks?: ConversationSnapshot['messages'][number]['blocks']
|
||||
tools?: ConversationSnapshot['messages'][number]['tools']
|
||||
sources?: string[]
|
||||
sourceReferences?: ConversationSnapshot['messages'][number]['sourceReferences']
|
||||
@@ -579,6 +581,20 @@ function interruptActiveTools(
|
||||
)
|
||||
}
|
||||
|
||||
function interruptActiveToolBlocks(
|
||||
blocks: MessageMetadata['blocks']
|
||||
): MessageMetadata['blocks'] {
|
||||
return blocks?.map((block) =>
|
||||
block.type === 'tool' &&
|
||||
(block.tool.state === 'pending' || block.tool.state === 'running')
|
||||
? {
|
||||
...block,
|
||||
tool: { ...block.tool, state: 'interrupted' as const }
|
||||
}
|
||||
: block
|
||||
)
|
||||
}
|
||||
|
||||
export class AssistantDatabase {
|
||||
private database?: DatabaseSync
|
||||
|
||||
@@ -714,7 +730,13 @@ export class AssistantDatabase {
|
||||
metadata.tools?.some(
|
||||
(tool) =>
|
||||
tool.state === 'pending' || tool.state === 'running'
|
||||
)
|
||||
) ||
|
||||
metadata.blocks?.some(
|
||||
(block) =>
|
||||
block.type === 'tool' &&
|
||||
(block.tool.state === 'pending' ||
|
||||
block.tool.state === 'running')
|
||||
)
|
||||
)
|
||||
if (message.state !== 'streaming' && !hasActiveTool) {
|
||||
continue
|
||||
@@ -727,7 +749,8 @@ export class AssistantDatabase {
|
||||
message.state === 'streaming'
|
||||
? interruptedMessageStatus
|
||||
: metadata.status,
|
||||
tools: interruptActiveTools(metadata.tools)
|
||||
tools: interruptActiveTools(metadata.tools),
|
||||
blocks: interruptActiveToolBlocks(metadata.blocks)
|
||||
}),
|
||||
message.id
|
||||
)
|
||||
@@ -859,6 +882,96 @@ export class AssistantDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
deleteProject(projectId: string, confirmation: string): void {
|
||||
const database = this.requireDatabase()
|
||||
database.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const project = database
|
||||
.prepare('SELECT name, status FROM projects WHERE id = ?')
|
||||
.get(projectId) as
|
||||
| { name: string; status: AssistantProject['status'] }
|
||||
| undefined
|
||||
if (!project) {
|
||||
throw new Error('项目不存在')
|
||||
}
|
||||
if (confirmation !== project.name) {
|
||||
throw new Error('项目名称确认不匹配')
|
||||
}
|
||||
const activeProjectCount = database
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count FROM projects
|
||||
WHERE status = 'active'`
|
||||
)
|
||||
.get() as { count: number }
|
||||
if (project.status === 'active' && activeProjectCount.count <= 1) {
|
||||
throw new Error('至少需要保留一个可用项目')
|
||||
}
|
||||
const activeTaskCount = database
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count FROM tasks
|
||||
WHERE project_id = ?
|
||||
AND status IN ('queued', 'running', 'waiting_approval', 'paused')`
|
||||
)
|
||||
.get(projectId) as { count: number }
|
||||
if (activeTaskCount.count > 0) {
|
||||
throw new Error('项目仍有进行中的任务,请先停止任务')
|
||||
}
|
||||
|
||||
database
|
||||
.prepare(
|
||||
`DELETE FROM notifications
|
||||
WHERE task_id IN (
|
||||
SELECT id FROM tasks WHERE project_id = ?
|
||||
) OR schedule_id IN (
|
||||
SELECT id FROM schedules WHERE project_id = ?
|
||||
)`
|
||||
)
|
||||
.run(projectId, projectId)
|
||||
database
|
||||
.prepare(
|
||||
`DELETE FROM delegation_outbox
|
||||
WHERE task_id IN (
|
||||
SELECT id FROM tasks WHERE project_id = ?
|
||||
)`
|
||||
)
|
||||
.run(projectId)
|
||||
database
|
||||
.prepare(
|
||||
`DELETE FROM memory_items
|
||||
WHERE (scope = 'project' AND scope_id = ?)
|
||||
OR (scope = 'conversation' AND scope_id IN (
|
||||
SELECT id FROM conversations WHERE project_id = ?
|
||||
))`
|
||||
)
|
||||
.run(projectId, projectId)
|
||||
database
|
||||
.prepare('DELETE FROM heartbeat_configs WHERE project_id = ?')
|
||||
.run(projectId)
|
||||
database
|
||||
.prepare('DELETE FROM artifacts WHERE project_id = ?')
|
||||
.run(projectId)
|
||||
database
|
||||
.prepare('DELETE FROM tasks WHERE project_id = ?')
|
||||
.run(projectId)
|
||||
database
|
||||
.prepare('DELETE FROM conversations WHERE project_id = ?')
|
||||
.run(projectId)
|
||||
database
|
||||
.prepare('DELETE FROM schedules WHERE project_id = ?')
|
||||
.run(projectId)
|
||||
const result = database
|
||||
.prepare('DELETE FROM projects WHERE id = ?')
|
||||
.run(projectId)
|
||||
if (result.changes !== 1) {
|
||||
throw new Error('项目不存在')
|
||||
}
|
||||
database.exec('COMMIT')
|
||||
} catch (error) {
|
||||
database.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
listConversations(): ConversationSnapshot[] {
|
||||
const database = this.requireDatabase()
|
||||
const conversations = database
|
||||
@@ -897,6 +1010,10 @@ export class AssistantDatabase {
|
||||
id: message.id,
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
reasoning: metadata.reasoning,
|
||||
blocks: interrupted
|
||||
? interruptActiveToolBlocks(metadata.blocks)
|
||||
: metadata.blocks,
|
||||
createdAt:
|
||||
metadata.createdAt ?? Date.parse(message.created_at),
|
||||
state: interrupted ? ('error' as const) : message.state,
|
||||
@@ -1006,6 +1123,8 @@ export class AssistantDatabase {
|
||||
JSON.stringify({
|
||||
createdAt: message.createdAt,
|
||||
status: message.status,
|
||||
reasoning: message.reasoning,
|
||||
blocks: message.blocks,
|
||||
tools: message.tools,
|
||||
sources: message.sources,
|
||||
sourceReferences: message.sourceReferences,
|
||||
|
||||
@@ -62,7 +62,7 @@ describe('getWorkspaceChanges', () => {
|
||||
expect(changes.patch).toContain('+after')
|
||||
})
|
||||
|
||||
it('fails safely for a non-Git directory', async () => {
|
||||
it('keeps file browsing available without reporting Git errors', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-changes-'))
|
||||
temporaryDirectories.push(directory)
|
||||
|
||||
@@ -70,7 +70,7 @@ describe('getWorkspaceChanges', () => {
|
||||
|
||||
expect(changes.available).toBe(false)
|
||||
expect(changes.files).toEqual([])
|
||||
expect(changes.error).toBeTruthy()
|
||||
expect(changes.error).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import spawn from 'cross-spawn'
|
||||
import { basename, extname } from 'node:path'
|
||||
import { basename, extname, join } from 'node:path'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import type {
|
||||
WorkspaceChangedFile,
|
||||
WorkspaceChanges,
|
||||
@@ -164,6 +165,15 @@ async function resolveWorkspacePath(
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveWorkspaceEntryPath(
|
||||
rootPath: string,
|
||||
inputPath: string,
|
||||
expected: 'file' | 'directory'
|
||||
): Promise<string> {
|
||||
return (await resolveWorkspacePath(rootPath, inputPath, expected))
|
||||
.canonicalPath
|
||||
}
|
||||
|
||||
function parseChangedFiles(status: string): {
|
||||
files: WorkspaceChangedFile[]
|
||||
truncated: boolean
|
||||
@@ -223,6 +233,19 @@ export async function getWorkspaceChanges(
|
||||
error: '项目尚未配置工作区目录'
|
||||
}
|
||||
}
|
||||
const gitMetadata = await stat(join(rootPath, '.git')).catch(
|
||||
() => undefined
|
||||
)
|
||||
if (!gitMetadata) {
|
||||
return {
|
||||
rootPath,
|
||||
available: false,
|
||||
status: '',
|
||||
patch: '',
|
||||
files: [],
|
||||
truncated: false
|
||||
}
|
||||
}
|
||||
try {
|
||||
const [status, patch] = await Promise.all([
|
||||
runGit(rootPath, [
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { strToU8, zipSync } from 'fflate'
|
||||
import {
|
||||
afterEach,
|
||||
describe,
|
||||
@@ -265,6 +266,62 @@ describe('CapabilityService', () => {
|
||||
).rejects.toThrow('只能删除已导入')
|
||||
})
|
||||
|
||||
it('imports a managed Skill from a ZIP package', async () => {
|
||||
const { directory, importedRoot, service } = await createService()
|
||||
const packageRoot = join(directory, 'zip-source')
|
||||
await writeSkill(packageRoot, 'meeting-helper', '会议助手')
|
||||
const skillMarkdown = await readFile(
|
||||
join(packageRoot, 'meeting-helper', 'SKILL.md')
|
||||
)
|
||||
const archivePath = join(directory, 'meeting-helper.zip')
|
||||
await writeFile(
|
||||
archivePath,
|
||||
zipSync({
|
||||
'meeting-helper/SKILL.md': skillMarkdown,
|
||||
'meeting-helper/template.txt': strToU8('template')
|
||||
})
|
||||
)
|
||||
|
||||
const imported = await service.importSkill(archivePath)
|
||||
|
||||
expect(imported.skills).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: 'meeting-helper',
|
||||
source: 'imported'
|
||||
})
|
||||
)
|
||||
await expect(
|
||||
readFile(
|
||||
join(importedRoot, 'meeting-helper', 'template.txt'),
|
||||
'utf8'
|
||||
)
|
||||
).resolves.toBe('template')
|
||||
})
|
||||
|
||||
it('rejects unsafe paths in a Skill ZIP package', async () => {
|
||||
const { directory, importedRoot, service } = await createService()
|
||||
const packageRoot = join(directory, 'unsafe-source')
|
||||
await writeSkill(packageRoot, 'unsafe-skill', '不安全 Skill')
|
||||
const skillMarkdown = await readFile(
|
||||
join(packageRoot, 'unsafe-skill', 'SKILL.md')
|
||||
)
|
||||
const archivePath = join(directory, 'unsafe-skill.zip')
|
||||
await writeFile(
|
||||
archivePath,
|
||||
zipSync({
|
||||
'../escape.txt': strToU8('escape'),
|
||||
'unsafe-skill/SKILL.md': skillMarkdown
|
||||
})
|
||||
)
|
||||
|
||||
await expect(service.importSkill(archivePath)).rejects.toThrow(
|
||||
'Skill ZIP 包含不安全路径'
|
||||
)
|
||||
await expect(
|
||||
readFile(join(importedRoot, 'escape.txt'), 'utf8')
|
||||
).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('encrypts remote MCP secrets and never returns them publicly', async () => {
|
||||
const { filePath, service } = await createService()
|
||||
const snapshot = await service.saveMcpServer(undefined, {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { unzipSync } from 'fflate'
|
||||
import {
|
||||
lstat,
|
||||
mkdir,
|
||||
@@ -10,7 +11,7 @@ import {
|
||||
stat,
|
||||
writeFile
|
||||
} from 'node:fs/promises'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
import { basename, dirname, extname, join } from 'node:path'
|
||||
import { parse as parseYaml } from 'yaml'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
@@ -207,7 +208,7 @@ function defaultSkillState(): z.infer<typeof skillStateSchema> {
|
||||
async function readSkill(
|
||||
directoryPath: string,
|
||||
source: SkillSummary['source'],
|
||||
expectedId = basename(directoryPath)
|
||||
expectedId: string | null = basename(directoryPath)
|
||||
): Promise<Omit<SkillSummary, 'enabled' | 'assignments'>> {
|
||||
const filePath = join(directoryPath, 'SKILL.md')
|
||||
const file = await stat(filePath)
|
||||
@@ -220,7 +221,7 @@ async function readSkill(
|
||||
throw new Error(`${basename(directoryPath)} 的 SKILL.md 格式无效`)
|
||||
}
|
||||
const metadata = skillMetadataSchema.parse(parseYaml(match[1]))
|
||||
if (metadata.id !== expectedId) {
|
||||
if (expectedId !== null && metadata.id !== expectedId) {
|
||||
throw new Error(`Skill ID 必须与目录名一致:${metadata.id}`)
|
||||
}
|
||||
return skillSummarySchema
|
||||
@@ -309,6 +310,132 @@ async function copySkillPackage(
|
||||
await copyDirectory(sourceRoot, targetRoot, 0)
|
||||
}
|
||||
|
||||
function parseSkillZipPath(path: string): string[] {
|
||||
const normalized = path.replaceAll('\\', '/')
|
||||
const withoutTrailingSlash = normalized.replace(/\/+$/u, '')
|
||||
if (
|
||||
!withoutTrailingSlash ||
|
||||
normalized.startsWith('/') ||
|
||||
/^[a-z]:/iu.test(normalized)
|
||||
) {
|
||||
throw new Error('Skill ZIP 包含不安全路径')
|
||||
}
|
||||
const segments = withoutTrailingSlash.split('/')
|
||||
if (
|
||||
segments.some(
|
||||
(segment) =>
|
||||
!segment ||
|
||||
segment === '.' ||
|
||||
segment === '..' ||
|
||||
segment.length > 255 ||
|
||||
[...segment].some((character) => {
|
||||
const code = character.charCodeAt(0)
|
||||
return code <= 31 || code === 127
|
||||
})
|
||||
) ||
|
||||
normalized.length > 512
|
||||
) {
|
||||
throw new Error('Skill ZIP 包含不安全路径')
|
||||
}
|
||||
return segments
|
||||
}
|
||||
|
||||
function isIgnoredSkillZipPath(segments: readonly string[]): boolean {
|
||||
return (
|
||||
segments[0] === '__MACOSX' ||
|
||||
segments.at(-1) === '.DS_Store'
|
||||
)
|
||||
}
|
||||
|
||||
async function extractSkillZip(
|
||||
archivePath: string,
|
||||
targetRoot: string
|
||||
): Promise<string | undefined> {
|
||||
const archiveDetails = await stat(archivePath)
|
||||
if (
|
||||
!archiveDetails.isFile() ||
|
||||
archiveDetails.size > MAX_SKILL_PACKAGE_BYTES
|
||||
) {
|
||||
throw new Error('Skill ZIP 文件无效或过大')
|
||||
}
|
||||
const archiveBytes = await readFile(archivePath)
|
||||
const selectedPaths = new Map<string, string[]>()
|
||||
const normalizedPaths = new Set<string>()
|
||||
let fileCount = 0
|
||||
let totalBytes = 0
|
||||
let files: Record<string, Uint8Array>
|
||||
try {
|
||||
files = unzipSync(archiveBytes, {
|
||||
filter: (file) => {
|
||||
const segments = parseSkillZipPath(file.name)
|
||||
if (
|
||||
file.name.endsWith('/') ||
|
||||
isIgnoredSkillZipPath(segments)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const normalizedPath = segments.join('/').toLowerCase()
|
||||
if (normalizedPaths.has(normalizedPath)) {
|
||||
throw new Error('Skill ZIP 包含重复文件路径')
|
||||
}
|
||||
normalizedPaths.add(normalizedPath)
|
||||
fileCount += 1
|
||||
totalBytes += file.originalSize
|
||||
if (
|
||||
fileCount > MAX_SKILL_PACKAGE_FILES ||
|
||||
file.originalSize > MAX_SKILL_FILE_BYTES ||
|
||||
totalBytes > MAX_SKILL_PACKAGE_BYTES
|
||||
) {
|
||||
throw new Error('Skill ZIP 大小或文件数量超过安全限制')
|
||||
}
|
||||
selectedPaths.set(file.name, segments)
|
||||
return true
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith('Skill ZIP')) {
|
||||
throw error
|
||||
}
|
||||
throw new Error('Skill ZIP 文件无效或不受支持', {
|
||||
cause: error
|
||||
})
|
||||
}
|
||||
|
||||
const skillEntries = [...selectedPaths.entries()].filter(
|
||||
([, segments]) => segments.at(-1) === 'SKILL.md'
|
||||
)
|
||||
if (skillEntries.length !== 1) {
|
||||
throw new Error('Skill ZIP 必须且只能包含一个 SKILL.md')
|
||||
}
|
||||
const packageRoot = skillEntries[0]![1].slice(0, -1)
|
||||
const packageRootKey = packageRoot
|
||||
.map((segment) => segment.toLowerCase())
|
||||
for (const segments of selectedPaths.values()) {
|
||||
const belongsToPackage = packageRootKey.every(
|
||||
(segment, index) => segments[index]?.toLowerCase() === segment
|
||||
)
|
||||
if (!belongsToPackage || segments.length <= packageRoot.length) {
|
||||
throw new Error('Skill ZIP 只能包含一个 Skill 包')
|
||||
}
|
||||
if (segments.length - packageRoot.length - 1 > MAX_SKILL_DEPTH) {
|
||||
throw new Error('Skill ZIP 目录层级超过安全限制')
|
||||
}
|
||||
}
|
||||
|
||||
await mkdir(targetRoot, { recursive: true })
|
||||
for (const [archiveName, contents] of Object.entries(files)) {
|
||||
const segments = selectedPaths.get(archiveName)
|
||||
if (!segments) {
|
||||
continue
|
||||
}
|
||||
const relativeSegments = segments.slice(packageRoot.length)
|
||||
const targetPath = join(targetRoot, ...relativeSegments)
|
||||
await mkdir(dirname(targetPath), { recursive: true })
|
||||
await writeFile(targetPath, contents, { mode: 0o600 })
|
||||
}
|
||||
return packageRoot.at(-1)
|
||||
}
|
||||
|
||||
export class CapabilityService {
|
||||
private state?: StoredCapabilities
|
||||
private loadPromise?: Promise<StoredCapabilities>
|
||||
@@ -819,21 +946,13 @@ export class CapabilityService {
|
||||
importSkill(sourcePath: string): Promise<CapabilitySnapshot> {
|
||||
return this.queue(async () => {
|
||||
const canonicalSource = await realpath(sourcePath)
|
||||
if (!(await stat(canonicalSource)).isDirectory()) {
|
||||
throw new Error('所选 Skill 路径不是目录')
|
||||
}
|
||||
const skill = await readSkill(canonicalSource, 'imported')
|
||||
const builtins = await listSkills(this.builtinSkillsRoot, 'builtin')
|
||||
if (builtins.some((item) => item.id === skill.id)) {
|
||||
throw new Error('导入的 Skill ID 与内置 Skill 冲突')
|
||||
}
|
||||
const targetPath = join(this.importedSkillsRoot, skill.id)
|
||||
if (
|
||||
await stat(targetPath)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
) {
|
||||
throw new Error('同名 Skill 已导入,请先删除后重试')
|
||||
const sourceDetails = await stat(canonicalSource)
|
||||
const isDirectory = sourceDetails.isDirectory()
|
||||
const isZip =
|
||||
sourceDetails.isFile() &&
|
||||
extname(canonicalSource).toLowerCase() === '.zip'
|
||||
if (!isDirectory && !isZip) {
|
||||
throw new Error('所选 Skill 路径必须是目录或 .zip 文件')
|
||||
}
|
||||
await mkdir(this.importedSkillsRoot, { recursive: true })
|
||||
const temporaryPath = join(
|
||||
@@ -841,22 +960,47 @@ export class CapabilityService {
|
||||
`.import-${randomUUID()}`
|
||||
)
|
||||
try {
|
||||
await copySkillPackage(canonicalSource, temporaryPath)
|
||||
const archiveDirectoryName = isZip
|
||||
? await extractSkillZip(canonicalSource, temporaryPath)
|
||||
: undefined
|
||||
const skill = await readSkill(
|
||||
isDirectory ? canonicalSource : temporaryPath,
|
||||
'imported',
|
||||
isDirectory ? undefined : (archiveDirectoryName ?? null)
|
||||
)
|
||||
const builtins = await listSkills(
|
||||
this.builtinSkillsRoot,
|
||||
'builtin'
|
||||
)
|
||||
if (builtins.some((item) => item.id === skill.id)) {
|
||||
throw new Error('导入的 Skill ID 与内置 Skill 冲突')
|
||||
}
|
||||
const targetPath = join(this.importedSkillsRoot, skill.id)
|
||||
if (
|
||||
await stat(targetPath)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
) {
|
||||
throw new Error('同名 Skill 已导入,请先删除后重试')
|
||||
}
|
||||
if (isDirectory) {
|
||||
await copySkillPackage(canonicalSource, temporaryPath)
|
||||
}
|
||||
await readSkill(temporaryPath, 'imported', skill.id)
|
||||
await rename(temporaryPath, targetPath)
|
||||
const state = await this.load()
|
||||
await this.persist({
|
||||
...state,
|
||||
skills: {
|
||||
...state.skills,
|
||||
[skill.id]: defaultSkillState()
|
||||
}
|
||||
})
|
||||
return this.getSnapshot()
|
||||
} catch (error) {
|
||||
await rm(temporaryPath, { recursive: true, force: true })
|
||||
throw error
|
||||
}
|
||||
const state = await this.load()
|
||||
await this.persist({
|
||||
...state,
|
||||
skills: {
|
||||
...state.skills,
|
||||
[skill.id]: defaultSkillState()
|
||||
}
|
||||
})
|
||||
return this.getSnapshot()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { showDesktopNotificationWhenUnfocused } from './desktop-notification'
|
||||
|
||||
const notificationMocks = vi.hoisted(() => ({
|
||||
isSupported: vi.fn(() => true),
|
||||
show: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
Notification: class {
|
||||
static isSupported = notificationMocks.isSupported
|
||||
|
||||
show = notificationMocks.show
|
||||
}
|
||||
}))
|
||||
|
||||
describe('showDesktopNotificationWhenUnfocused', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
notificationMocks.isSupported.mockReturnValue(true)
|
||||
})
|
||||
|
||||
it('suppresses desktop notifications while GoodBuddy is focused', () => {
|
||||
const shown = showDesktopNotificationWhenUnfocused(
|
||||
{
|
||||
isDestroyed: vi.fn(() => false),
|
||||
isFocused: vi.fn(() => true)
|
||||
} as never,
|
||||
{ title: '任务已完成' }
|
||||
)
|
||||
|
||||
expect(shown).toBe(false)
|
||||
expect(notificationMocks.show).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows desktop notifications while GoodBuddy is unfocused', () => {
|
||||
const shown = showDesktopNotificationWhenUnfocused(
|
||||
{
|
||||
isDestroyed: vi.fn(() => false),
|
||||
isFocused: vi.fn(() => false)
|
||||
} as never,
|
||||
{ title: '任务已完成' }
|
||||
)
|
||||
|
||||
expect(shown).toBe(true)
|
||||
expect(notificationMocks.show).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import {
|
||||
Notification,
|
||||
type BrowserWindow,
|
||||
type NotificationConstructorOptions
|
||||
} from 'electron'
|
||||
|
||||
export function showDesktopNotificationWhenUnfocused(
|
||||
window: BrowserWindow,
|
||||
options: NotificationConstructorOptions
|
||||
): boolean {
|
||||
if (
|
||||
window.isDestroyed() ||
|
||||
window.isFocused() ||
|
||||
!Notification.isSupported()
|
||||
) {
|
||||
return false
|
||||
}
|
||||
new Notification(options).show()
|
||||
return true
|
||||
}
|
||||
+5
-2
@@ -382,14 +382,17 @@ if (hasSingleInstanceLock) {
|
||||
)
|
||||
}
|
||||
const createSelectedRuntime = async (
|
||||
selection: AgentRuntimeSelection
|
||||
selection: AgentRuntimeSelection,
|
||||
workspacePath?: string
|
||||
): Promise<AgentRuntime> => {
|
||||
const resolved = applyRuntimeSelection(
|
||||
await settingsStore.getResolvedSettings(),
|
||||
selection
|
||||
)
|
||||
return createRuntimeWithCapabilities(
|
||||
resolved.settings,
|
||||
workspacePath
|
||||
? { ...resolved.settings, workspacePath }
|
||||
: resolved.settings,
|
||||
resolved.target
|
||||
)
|
||||
}
|
||||
|
||||
+41
-3
@@ -18,6 +18,10 @@ const electronMocks = vi.hoisted(() => {
|
||||
removeHandler: vi.fn((channel: string) => {
|
||||
handlers.delete(channel)
|
||||
}),
|
||||
showOpenDialog: vi.fn(async () => ({
|
||||
canceled: true,
|
||||
filePaths: [] as string[]
|
||||
})),
|
||||
openPath: vi.fn(async () => ''),
|
||||
showItemInFolder: vi.fn(),
|
||||
openExternal: vi.fn(async () => undefined)
|
||||
@@ -78,6 +82,7 @@ describe('registerIpcHandlers computer capabilities', () => {
|
||||
browserProfiles: { profiles: [], defaultProfileId: null }
|
||||
}
|
||||
const capabilityService = {
|
||||
importSkill: vi.fn(async () => snapshot),
|
||||
setComputerCapabilityEnabled: vi.fn(async () => snapshot),
|
||||
createBrowserProfile: vi.fn(async () => snapshot),
|
||||
diagnoseComputerCapability: vi.fn(async () => ({
|
||||
@@ -131,6 +136,31 @@ describe('registerIpcHandlers computer capabilities', () => {
|
||||
).toHaveBeenCalledWith('host-browser-control', true)
|
||||
expect(onRuntimeSettingsChanged).toHaveBeenCalledOnce()
|
||||
|
||||
electronMocks.showOpenDialog.mockResolvedValueOnce({
|
||||
canceled: false,
|
||||
filePaths: ['C:\\meeting-helper.zip']
|
||||
})
|
||||
await expect(
|
||||
electronMocks.handlers.get(
|
||||
ipcChannels.capabilitiesImportSkill
|
||||
)?.(event, 'zip')
|
||||
).resolves.toEqual(snapshot)
|
||||
expect(electronMocks.showOpenDialog).toHaveBeenCalledWith(
|
||||
window,
|
||||
expect.objectContaining({
|
||||
properties: ['openFile'],
|
||||
filters: [{ name: 'Skill ZIP', extensions: ['zip'] }]
|
||||
})
|
||||
)
|
||||
expect(capabilityService.importSkill).toHaveBeenCalledWith(
|
||||
'C:\\meeting-helper.zip'
|
||||
)
|
||||
await expect(
|
||||
electronMocks.handlers.get(
|
||||
ipcChannels.capabilitiesImportSkill
|
||||
)?.(event, 'unsupported')
|
||||
).rejects.toThrow()
|
||||
|
||||
browserStateListener?.({
|
||||
conversationId: 'browser-conversation',
|
||||
status: 'ready',
|
||||
@@ -183,7 +213,9 @@ vi.mock('electron', () => ({
|
||||
getVersion: vi.fn(() => '0.1.0')
|
||||
},
|
||||
BrowserWindow: class {},
|
||||
dialog: {},
|
||||
dialog: {
|
||||
showOpenDialog: electronMocks.showOpenDialog
|
||||
},
|
||||
ipcMain: {
|
||||
handle: electronMocks.handle,
|
||||
removeHandler: electronMocks.removeHandler
|
||||
@@ -838,7 +870,11 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
upsertModelUsageCall: vi.fn(),
|
||||
clearAssistantData: vi.fn(),
|
||||
listExperts: vi.fn<() => Array<Record<string, unknown>>>(() => []),
|
||||
getExpert: vi.fn()
|
||||
getExpert: vi.fn(),
|
||||
getProject: vi.fn((projectId: string) => ({
|
||||
id: projectId,
|
||||
rootPath: 'C:\\ProjectWorkspace'
|
||||
}))
|
||||
}
|
||||
const webContents = {
|
||||
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
||||
@@ -1229,6 +1265,7 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
harness.handler?.(event, {
|
||||
requestId: '00000000-0000-4000-8000-000000000011',
|
||||
conversationId: 'conversation-one',
|
||||
projectId: '00000000-0000-4000-8000-000000000101',
|
||||
prompt: 'first request',
|
||||
workMode: 'ask',
|
||||
runtimeSelection: firstSelection
|
||||
@@ -1258,7 +1295,8 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
})
|
||||
expect(fallbackRuntime.run).not.toHaveBeenCalled()
|
||||
expect(selectedRuntimes.getRuntime).toHaveBeenCalledWith(
|
||||
firstSelection
|
||||
firstSelection,
|
||||
'C:\\ProjectWorkspace'
|
||||
)
|
||||
expect(selectedRuntimes.getRuntime).toHaveBeenCalledWith(
|
||||
secondSelection
|
||||
|
||||
+144
-45
@@ -3,7 +3,6 @@ import {
|
||||
BrowserWindow,
|
||||
dialog,
|
||||
ipcMain,
|
||||
Notification,
|
||||
shell
|
||||
} from 'electron'
|
||||
import { mkdir, readFile, realpath, stat } from 'node:fs/promises'
|
||||
@@ -14,6 +13,7 @@ import { z } from 'zod'
|
||||
import { formatShortcutForDisplay } from '../shared/shortcut'
|
||||
import {
|
||||
approvalDecisionSchema,
|
||||
agentQuestionResponseSchema,
|
||||
agentRequestSchema,
|
||||
browserStopRequestSchema,
|
||||
knowledgeCreateSchema,
|
||||
@@ -30,8 +30,10 @@ import {
|
||||
windowCaptureRequestSchema,
|
||||
workspaceDirectoryRequestSchema,
|
||||
workspaceFileRequestSchema,
|
||||
workspaceOpenPathRequestSchema,
|
||||
type AgentRuntimeDetection,
|
||||
type AgentEvent,
|
||||
type AgentRequest,
|
||||
type AppInfo,
|
||||
type BrowserLiveState,
|
||||
type KnowledgeSnapshot,
|
||||
@@ -49,6 +51,7 @@ import {
|
||||
mcpServerInputSchema,
|
||||
skillAssignmentsInputSchema,
|
||||
skillIdSchema,
|
||||
skillImportKindSchema,
|
||||
skillToggleInputSchema,
|
||||
type CapabilitySnapshot,
|
||||
type CapabilityDiagnosticReport,
|
||||
@@ -111,9 +114,11 @@ import { RemoteDelegationService } from './assistant/remote-delegation-service'
|
||||
import {
|
||||
getWorkspaceChanges,
|
||||
listWorkspaceDirectory,
|
||||
readWorkspaceFile
|
||||
readWorkspaceFile,
|
||||
resolveWorkspaceEntryPath
|
||||
} from './assistant/workspace-changes-service'
|
||||
import { HeartbeatService } from './assistant/heartbeat-service'
|
||||
import { showDesktopNotificationWhenUnfocused } from './desktop-notification'
|
||||
import {
|
||||
SubagentRunError,
|
||||
type SubagentService
|
||||
@@ -238,6 +243,12 @@ const projectArchiveRequestSchema = z
|
||||
archived: z.boolean()
|
||||
})
|
||||
.strict()
|
||||
const projectDeleteRequestSchema = z
|
||||
.object({
|
||||
projectId: assistantIdSchema,
|
||||
confirmation: z.string().max(120)
|
||||
})
|
||||
.strict()
|
||||
const memoryStatusRequestSchema = z
|
||||
.object({
|
||||
memoryId: assistantIdSchema,
|
||||
@@ -494,6 +505,10 @@ export function registerIpcHandlers(
|
||||
knowledgeGateway?: KnowledgeMcpGateway
|
||||
): () => Promise<void> {
|
||||
const activeRequests = new Map<string, AbortController>()
|
||||
const pendingAgentQuestions = new Map<
|
||||
string,
|
||||
{ requestId: string; runtime: AgentRuntime }
|
||||
>()
|
||||
const heartbeatControllers = new Set<AbortController>()
|
||||
let shuttingDown = false
|
||||
let executionPaused = false
|
||||
@@ -506,6 +521,21 @@ export function registerIpcHandlers(
|
||||
)
|
||||
return execution
|
||||
}
|
||||
const resolveRequestRuntime = async (
|
||||
request: Pick<AgentRequest, 'projectId' | 'runtimeSelection'>
|
||||
): Promise<AgentRuntime> => {
|
||||
const projectWorkspace = request.projectId
|
||||
? assistantDatabase.getProject(request.projectId).rootPath.trim()
|
||||
: ''
|
||||
if (!selectedRuntimes || (!request.runtimeSelection && !projectWorkspace)) {
|
||||
return runtime
|
||||
}
|
||||
const selection =
|
||||
request.runtimeSelection ?? ({ provider: 'auto' } as const)
|
||||
return projectWorkspace
|
||||
? selectedRuntimes.getRuntime(selection, projectWorkspace)
|
||||
: selectedRuntimes.getRuntime(selection)
|
||||
}
|
||||
const channels = Object.values(ipcChannels).filter(
|
||||
(channel) =>
|
||||
channel !== ipcChannels.agentEvent &&
|
||||
@@ -618,7 +648,10 @@ export function registerIpcHandlers(
|
||||
assistantDatabase,
|
||||
{
|
||||
summarize: async (request) => {
|
||||
if (runtime.capability === 'image-generation') {
|
||||
const requestRuntime = await resolveRequestRuntime({
|
||||
projectId: request.projectId
|
||||
})
|
||||
if (requestRuntime.capability === 'image-generation') {
|
||||
throw new Error('智能心跳需要文本模型,当前默认连接仅支持图像生成')
|
||||
}
|
||||
const controller = new AbortController()
|
||||
@@ -644,10 +677,11 @@ export function registerIpcHandlers(
|
||||
let output = ''
|
||||
let completed = false
|
||||
try {
|
||||
for await (const event of runtime.run(
|
||||
for await (const event of requestRuntime.run(
|
||||
{
|
||||
requestId,
|
||||
conversationId,
|
||||
projectId: request.projectId,
|
||||
workMode: 'ask',
|
||||
prompt: [
|
||||
request.systemInstruction,
|
||||
@@ -704,7 +738,7 @@ export function registerIpcHandlers(
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
heartbeatControllers.delete(controller)
|
||||
await runtime.releaseConversation?.(conversationId)
|
||||
await requestRuntime.releaseConversation?.(conversationId)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -755,7 +789,10 @@ export function registerIpcHandlers(
|
||||
let output = ''
|
||||
let completed = false
|
||||
try {
|
||||
for await (const agentEvent of runtime.run(
|
||||
const requestRuntime = await resolveRequestRuntime({
|
||||
projectId: schedule.projectId
|
||||
})
|
||||
for await (const agentEvent of requestRuntime.run(
|
||||
{
|
||||
requestId,
|
||||
conversationId: `${origin}:${schedule.id}`,
|
||||
@@ -841,12 +878,10 @@ export function registerIpcHandlers(
|
||||
})
|
||||
}
|
||||
assistantDatabase.updateTaskStatus(requestId, 'completed')
|
||||
if (Notification.isSupported()) {
|
||||
new Notification({
|
||||
title: `定时任务完成:${schedule.title}`,
|
||||
body: '结果已保存到 GoodBuddy 成果工作栏。'
|
||||
}).show()
|
||||
}
|
||||
showDesktopNotificationWhenUnfocused(window, {
|
||||
title: `定时任务完成:${schedule.title}`,
|
||||
body: '结果已保存到 GoodBuddy 成果工作栏。'
|
||||
})
|
||||
return { status: 'completed', output }
|
||||
} catch (error) {
|
||||
const message = safeRuntimeError(error, '定时任务执行失败')
|
||||
@@ -855,12 +890,10 @@ export function registerIpcHandlers(
|
||||
controller.signal.aborted ? 'cancelled' : 'failed',
|
||||
message
|
||||
)
|
||||
if (Notification.isSupported()) {
|
||||
new Notification({
|
||||
title: `定时任务失败:${schedule.title}`,
|
||||
body: '打开 GoodBuddy 任务工作栏查看详情。'
|
||||
}).show()
|
||||
}
|
||||
showDesktopNotificationWhenUnfocused(window, {
|
||||
title: `定时任务失败:${schedule.title}`,
|
||||
body: '打开 GoodBuddy 任务工作栏查看详情。'
|
||||
})
|
||||
return { status: 'failed', error: message }
|
||||
} finally {
|
||||
externalSignal?.removeEventListener(
|
||||
@@ -1182,12 +1215,7 @@ export function registerIpcHandlers(
|
||||
throw new Error('请求包含不存在的知识库')
|
||||
}
|
||||
}
|
||||
const selectedRuntime =
|
||||
parsedInput.runtimeSelection && selectedRuntimes
|
||||
? await selectedRuntimes.getRuntime(
|
||||
parsedInput.runtimeSelection
|
||||
)
|
||||
: runtime
|
||||
const selectedRuntime = await resolveRequestRuntime(parsedInput)
|
||||
const normalizedWorkMode = normalizeInteractiveWorkMode(
|
||||
parsedInput.workMode
|
||||
)
|
||||
@@ -1381,6 +1409,12 @@ export function registerIpcHandlers(
|
||||
if (publicEvent.type === 'tool') {
|
||||
toolStates.set(publicEvent.callId, publicEvent)
|
||||
}
|
||||
if (publicEvent.type === 'question') {
|
||||
pendingAgentQuestions.set(publicEvent.questionId, {
|
||||
requestId: request.requestId,
|
||||
runtime: selectedRuntime
|
||||
})
|
||||
}
|
||||
if (publicEvent.type === 'error') {
|
||||
assistantDatabase.appendTaskEvent(
|
||||
request.requestId,
|
||||
@@ -1446,12 +1480,10 @@ export function registerIpcHandlers(
|
||||
request.requestId,
|
||||
'completed'
|
||||
)
|
||||
if (!window.isFocused() && Notification.isSupported()) {
|
||||
new Notification({
|
||||
title: 'GoodBuddy 任务已完成',
|
||||
body: '任务结果已保存到成果工作栏。'
|
||||
}).show()
|
||||
}
|
||||
showDesktopNotificationWhenUnfocused(window, {
|
||||
title: 'GoodBuddy 任务已完成',
|
||||
body: '任务结果已保存到成果工作栏。'
|
||||
})
|
||||
}
|
||||
if (!window.isDestroyed()) {
|
||||
window.webContents.send(ipcChannels.agentEvent, publicEvent)
|
||||
@@ -1485,18 +1517,21 @@ export function registerIpcHandlers(
|
||||
agentEvent
|
||||
)
|
||||
}
|
||||
if (!window.isFocused() && Notification.isSupported()) {
|
||||
new Notification({
|
||||
title: controller.signal.aborted
|
||||
? 'GoodBuddy 任务已取消'
|
||||
: 'GoodBuddy 任务失败',
|
||||
body: '打开任务工作栏查看详情。'
|
||||
}).show()
|
||||
}
|
||||
showDesktopNotificationWhenUnfocused(window, {
|
||||
title: controller.signal.aborted
|
||||
? 'GoodBuddy 任务已取消'
|
||||
: 'GoodBuddy 任务失败',
|
||||
body: '打开任务工作栏查看详情。'
|
||||
})
|
||||
if (!window.isDestroyed()) {
|
||||
window.webContents.send(ipcChannels.agentEvent, agentEvent)
|
||||
}
|
||||
} finally {
|
||||
for (const [questionId, pending] of pendingAgentQuestions) {
|
||||
if (pending.requestId === request.requestId) {
|
||||
pendingAgentQuestions.delete(questionId)
|
||||
}
|
||||
}
|
||||
knowledgeGateway?.revoke(request.knowledgeCapabilityToken)
|
||||
activeRequests.delete(request.requestId)
|
||||
}
|
||||
@@ -1515,6 +1550,22 @@ export function registerIpcHandlers(
|
||||
const response = approvalResponseSchema.parse(input)
|
||||
approvalBroker.respond(response.approvalId, response.decision)
|
||||
})
|
||||
ipcMain.handle(
|
||||
ipcChannels.agentQuestionRespond,
|
||||
async (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const response = agentQuestionResponseSchema.parse(input)
|
||||
const pending = pendingAgentQuestions.get(response.questionId)
|
||||
if (!pending?.runtime.respondToQuestion) {
|
||||
throw new Error('OpenCode 提问已失效或不存在')
|
||||
}
|
||||
await pending.runtime.respondToQuestion(
|
||||
response.questionId,
|
||||
response.answers.length > 0 ? response.answers : undefined
|
||||
)
|
||||
pendingAgentQuestions.delete(response.questionId)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
ipcChannels.runtimeSettingsGet,
|
||||
@@ -2016,10 +2067,15 @@ export function registerIpcHandlers(
|
||||
|
||||
ipcMain.handle(
|
||||
ipcChannels.projectsUpdate,
|
||||
(event, input: unknown) => {
|
||||
async (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const value = projectUpdateRequestSchema.parse(input)
|
||||
return assistantDatabase.updateProject(value.projectId, value.input)
|
||||
const project = assistantDatabase.updateProject(
|
||||
value.projectId,
|
||||
value.input
|
||||
)
|
||||
await selectedRuntimes?.reset?.()
|
||||
return project
|
||||
}
|
||||
)
|
||||
|
||||
@@ -2034,6 +2090,18 @@ export function registerIpcHandlers(
|
||||
)
|
||||
}
|
||||
)
|
||||
ipcMain.handle(
|
||||
ipcChannels.projectsDelete,
|
||||
async (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const value = projectDeleteRequestSchema.parse(input)
|
||||
assistantDatabase.deleteProject(
|
||||
value.projectId,
|
||||
value.confirmation
|
||||
)
|
||||
await selectedRuntimes?.reset?.()
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(ipcChannels.conversationsList, (event) => {
|
||||
assertTrustedSender(event, window)
|
||||
@@ -2078,6 +2146,27 @@ export function registerIpcHandlers(
|
||||
return readWorkspaceFile(project.rootPath, value.path)
|
||||
}
|
||||
)
|
||||
ipcMain.handle(
|
||||
ipcChannels.workspacePathOpen,
|
||||
async (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const value = workspaceOpenPathRequestSchema.parse(input)
|
||||
const project = assistantDatabase.getProject(value.projectId)
|
||||
const targetPath = await resolveWorkspaceEntryPath(
|
||||
project.rootPath,
|
||||
value.path,
|
||||
value.type
|
||||
)
|
||||
const error = await shell.openPath(targetPath)
|
||||
if (error) {
|
||||
throw new Error(
|
||||
value.type === 'directory'
|
||||
? '无法在系统资源管理器中打开文件夹'
|
||||
: '无法使用系统默认应用打开文件'
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(ipcChannels.tasksList, (event) => {
|
||||
assertTrustedSender(event, window)
|
||||
@@ -2337,12 +2426,22 @@ export function registerIpcHandlers(
|
||||
|
||||
ipcMain.handle(
|
||||
ipcChannels.capabilitiesImportSkill,
|
||||
async (event): Promise<CapabilitySnapshot> => {
|
||||
async (event, input: unknown): Promise<CapabilitySnapshot> => {
|
||||
assertTrustedSender(event, window)
|
||||
const result = await dialog.showOpenDialog(window, {
|
||||
title: '选择包含 SKILL.md 的目录',
|
||||
properties: ['openDirectory']
|
||||
})
|
||||
const kind = skillImportKindSchema.parse(input)
|
||||
const result = await dialog.showOpenDialog(
|
||||
window,
|
||||
kind === 'zip'
|
||||
? {
|
||||
title: '选择 Skill ZIP 文件',
|
||||
properties: ['openFile'],
|
||||
filters: [{ name: 'Skill ZIP', extensions: ['zip'] }]
|
||||
}
|
||||
: {
|
||||
title: '选择包含 SKILL.md 的目录',
|
||||
properties: ['openDirectory']
|
||||
}
|
||||
)
|
||||
if (result.canceled || !result.filePaths[0]) {
|
||||
return capabilityService.getSnapshot()
|
||||
}
|
||||
|
||||
+31
-3
@@ -2,6 +2,7 @@ import { contextBridge, ipcRenderer, webUtils } from 'electron'
|
||||
import {
|
||||
type ApprovalDecision,
|
||||
type AgentEvent,
|
||||
type AgentQuestionAnswer,
|
||||
type AgentRequest,
|
||||
type AgentRuntimeDetection,
|
||||
type AgentRuntimeStatus,
|
||||
@@ -141,6 +142,15 @@ const desktopApi: DesktopApi = {
|
||||
decision
|
||||
})
|
||||
},
|
||||
respondQuestion: async (
|
||||
questionId: string,
|
||||
answers?: AgentQuestionAnswer[]
|
||||
) => {
|
||||
await ipcRenderer.invoke(ipcChannels.agentQuestionRespond, {
|
||||
questionId,
|
||||
answers: answers ?? []
|
||||
})
|
||||
},
|
||||
onEvent: (listener) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, payload: AgentEvent): void =>
|
||||
listener(payload)
|
||||
@@ -355,6 +365,12 @@ const desktopApi: DesktopApi = {
|
||||
projectId,
|
||||
archived
|
||||
})
|
||||
},
|
||||
delete: async (projectId: string, confirmation: string) => {
|
||||
await ipcRenderer.invoke(ipcChannels.projectsDelete, {
|
||||
projectId,
|
||||
confirmation
|
||||
})
|
||||
}
|
||||
},
|
||||
conversations: {
|
||||
@@ -384,7 +400,18 @@ const desktopApi: DesktopApi = {
|
||||
ipcRenderer.invoke(ipcChannels.workspaceFileRead, {
|
||||
projectId,
|
||||
path
|
||||
}) as Promise<WorkspaceFilePreview>
|
||||
}) as Promise<WorkspaceFilePreview>,
|
||||
openPath: async (
|
||||
projectId: string,
|
||||
path: string,
|
||||
type: 'file' | 'directory'
|
||||
) => {
|
||||
await ipcRenderer.invoke(ipcChannels.workspacePathOpen, {
|
||||
projectId,
|
||||
path,
|
||||
type
|
||||
})
|
||||
}
|
||||
},
|
||||
tasks: {
|
||||
list: () =>
|
||||
@@ -534,9 +561,10 @@ const desktopApi: DesktopApi = {
|
||||
ipcRenderer.invoke(
|
||||
ipcChannels.capabilitiesSnapshot
|
||||
) as Promise<CapabilitySnapshot>,
|
||||
importSkill: () =>
|
||||
importSkill: (kind) =>
|
||||
ipcRenderer.invoke(
|
||||
ipcChannels.capabilitiesImportSkill
|
||||
ipcChannels.capabilitiesImportSkill,
|
||||
kind
|
||||
) as Promise<CapabilitySnapshot>,
|
||||
removeSkill: (skillId) =>
|
||||
ipcRenderer.invoke(
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { CircleHelp } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import type {
|
||||
AgentEvent,
|
||||
AgentQuestionAnswer
|
||||
} from '../../shared/contracts'
|
||||
|
||||
type AgentQuestion = Extract<AgentEvent, { type: 'question' }>
|
||||
|
||||
type AgentQuestionCardProps = {
|
||||
value: AgentQuestion
|
||||
onReject: () => Promise<void>
|
||||
onSubmit: (answers: AgentQuestionAnswer[]) => Promise<void>
|
||||
}
|
||||
|
||||
export function AgentQuestionCard({
|
||||
value,
|
||||
onReject,
|
||||
onSubmit
|
||||
}: AgentQuestionCardProps): React.JSX.Element {
|
||||
const [selected, setSelected] = useState<string[][]>(
|
||||
value.questions.map(() => [])
|
||||
)
|
||||
const [custom, setCustom] = useState<string[]>(
|
||||
value.questions.map(() => '')
|
||||
)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const answers = useMemo(
|
||||
() =>
|
||||
value.questions.map((question, index) => {
|
||||
const ownAnswer = custom[index]?.trim()
|
||||
const choices = selected[index] ?? []
|
||||
return [
|
||||
...choices,
|
||||
...(ownAnswer && (question.multiple || choices.length === 0)
|
||||
? [ownAnswer]
|
||||
: [])
|
||||
]
|
||||
}),
|
||||
[custom, selected, value.questions]
|
||||
)
|
||||
const complete = answers.every((answer) => answer.length > 0)
|
||||
|
||||
const run = async (action: () => Promise<void>): Promise<void> => {
|
||||
setSubmitting(true)
|
||||
setError('')
|
||||
try {
|
||||
await action()
|
||||
} catch (reason) {
|
||||
setError(
|
||||
reason instanceof Error ? reason.message : '回答提交失败,请重试'
|
||||
)
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className="agent-question-card"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (complete) {
|
||||
void run(() => onSubmit(answers))
|
||||
}
|
||||
}}
|
||||
>
|
||||
<header>
|
||||
<CircleHelp aria-hidden="true" size={18} />
|
||||
<strong>OpenCode 需要补充信息</strong>
|
||||
</header>
|
||||
{value.questions.map((question, questionIndex) => (
|
||||
<fieldset key={`${question.header}:${questionIndex}`}>
|
||||
<legend>
|
||||
<span>{question.header}</span>
|
||||
{question.question}
|
||||
</legend>
|
||||
{question.options.map((option) => {
|
||||
const checked =
|
||||
selected[questionIndex]?.includes(option.label) ?? false
|
||||
return (
|
||||
<label key={option.label}>
|
||||
<input
|
||||
checked={checked}
|
||||
disabled={submitting}
|
||||
name={`agent-question-${value.questionId}-${questionIndex}`}
|
||||
onChange={() => {
|
||||
setSelected((current) =>
|
||||
current.map((answer, index) =>
|
||||
index !== questionIndex
|
||||
? answer
|
||||
: question.multiple
|
||||
? checked
|
||||
? answer.filter(
|
||||
(label) => label !== option.label
|
||||
)
|
||||
: [...answer, option.label]
|
||||
: [option.label]
|
||||
)
|
||||
)
|
||||
if (!question.multiple) {
|
||||
setCustom((current) =>
|
||||
current.map((answer, index) =>
|
||||
index === questionIndex ? '' : answer
|
||||
)
|
||||
)
|
||||
}
|
||||
}}
|
||||
type={question.multiple ? 'checkbox' : 'radio'}
|
||||
/>
|
||||
<span>
|
||||
<strong>{option.label}</strong>
|
||||
{option.description && <small>{option.description}</small>}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
{(question.custom || question.options.length === 0) && (
|
||||
<label className="agent-question-card__custom">
|
||||
<span>其他回答</span>
|
||||
<input
|
||||
disabled={submitting}
|
||||
maxLength={2_000}
|
||||
onChange={(event) => {
|
||||
const answer = event.target.value
|
||||
setCustom((current) =>
|
||||
current.map((item, index) =>
|
||||
index === questionIndex ? answer : item
|
||||
)
|
||||
)
|
||||
if (!question.multiple && answer.trim()) {
|
||||
setSelected((current) =>
|
||||
current.map((item, index) =>
|
||||
index === questionIndex ? [] : item
|
||||
)
|
||||
)
|
||||
}
|
||||
}}
|
||||
placeholder="输入你的回答"
|
||||
type="text"
|
||||
value={custom[questionIndex] ?? ''}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</fieldset>
|
||||
))}
|
||||
{error && (
|
||||
<p className="agent-question-card__error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<footer>
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={submitting}
|
||||
onClick={() => void run(onReject)}
|
||||
type="button"
|
||||
>
|
||||
跳过
|
||||
</button>
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={submitting || !complete}
|
||||
type="submit"
|
||||
>
|
||||
{submitting ? '提交中…' : '提交回答'}
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -85,6 +85,7 @@ const api: DesktopApi = {
|
||||
run,
|
||||
cancel: vi.fn(async () => {}),
|
||||
respondApproval: vi.fn(async () => {}),
|
||||
respondQuestion: vi.fn(async () => {}),
|
||||
onEvent: vi.fn((listener) => {
|
||||
agentListener = listener
|
||||
return () => {
|
||||
@@ -265,7 +266,8 @@ const api: DesktopApi = {
|
||||
...input,
|
||||
id: _projectId
|
||||
})),
|
||||
setArchived: vi.fn(async () => {})
|
||||
setArchived: vi.fn(async () => {}),
|
||||
delete: vi.fn(async () => {})
|
||||
},
|
||||
conversations: {
|
||||
list: vi.fn(async () => []),
|
||||
@@ -291,7 +293,8 @@ const api: DesktopApi = {
|
||||
content: '',
|
||||
mimeType: 'text/plain' as const,
|
||||
size: 0
|
||||
}))
|
||||
})),
|
||||
openPath: vi.fn(async () => {})
|
||||
},
|
||||
tasks: {
|
||||
list: vi.fn(async () => []),
|
||||
@@ -765,7 +768,7 @@ describe('App', () => {
|
||||
expect(await screen.findByRole('status')).toBeVisible()
|
||||
})
|
||||
|
||||
it('sends a prompt and renders streamed agent content', async () => {
|
||||
it('renders streamed reasoning, text, and tools in event order', async () => {
|
||||
render(<App />)
|
||||
|
||||
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
|
||||
@@ -815,6 +818,55 @@ describe('App', () => {
|
||||
expect(streamingReasoning).toHaveAttribute('open')
|
||||
expect(screen.getByText('先检查项目结构')).toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
if (!request) {
|
||||
throw new Error('Missing request')
|
||||
}
|
||||
agentListener?.({
|
||||
requestId: request.requestId,
|
||||
type: 'tool',
|
||||
callId: 'call-1',
|
||||
name: 'read',
|
||||
state: 'running',
|
||||
summary: 'OpenCode 工具:read'
|
||||
})
|
||||
agentListener?.({
|
||||
requestId: request.requestId,
|
||||
type: 'reasoning',
|
||||
delta: '再检查关键文件'
|
||||
})
|
||||
agentListener?.({
|
||||
requestId: request.requestId,
|
||||
type: 'text',
|
||||
delta: '最终结论'
|
||||
})
|
||||
agentListener?.({
|
||||
requestId: request.requestId,
|
||||
type: 'tool',
|
||||
callId: 'call-1',
|
||||
name: 'read',
|
||||
state: 'completed',
|
||||
summary: 'OpenCode 工具:read'
|
||||
})
|
||||
})
|
||||
|
||||
const assistantArticle = screen
|
||||
.getByText('最终结论')
|
||||
.closest('article')
|
||||
const orderedBlocks = [
|
||||
...assistantArticle!.querySelectorAll('.message-blocks > *')
|
||||
].map((element) => element.textContent)
|
||||
expect(orderedBlocks).toEqual([
|
||||
expect.stringContaining('这是回答内容'),
|
||||
expect.stringContaining('先检查项目结构'),
|
||||
expect.stringContaining('OpenCode 工具:read'),
|
||||
expect.stringContaining('再检查关键文件'),
|
||||
expect.stringContaining('最终结论')
|
||||
])
|
||||
expect(
|
||||
screen.getAllByText('OpenCode 工具:read')
|
||||
).toHaveLength(1)
|
||||
|
||||
act(() => {
|
||||
if (!request) {
|
||||
throw new Error('Missing request')
|
||||
@@ -825,8 +877,11 @@ describe('App', () => {
|
||||
})
|
||||
})
|
||||
|
||||
const completedReasoning = await screen.findByText('推理过程')
|
||||
expect(completedReasoning.closest('details')).not.toHaveAttribute('open')
|
||||
const completedReasoning = await screen.findAllByText('推理过程')
|
||||
expect(completedReasoning).toHaveLength(2)
|
||||
for (const reasoning of completedReasoning) {
|
||||
expect(reasoning.closest('details')).not.toHaveAttribute('open')
|
||||
}
|
||||
expect(screen.getByText('项目:默认项目')).toHaveClass('scope-badge')
|
||||
})
|
||||
|
||||
@@ -1274,7 +1329,7 @@ describe('App', () => {
|
||||
fireEvent.click(screen.getByLabelText('切换助手工作栏'))
|
||||
fireEvent.click(await screen.findByRole('tab', { name: '工作区' }))
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: /README\.md/u })
|
||||
await screen.findByRole('button', { name: 'README.md' })
|
||||
)
|
||||
|
||||
expect(
|
||||
@@ -1286,6 +1341,43 @@ describe('App', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('opens workspace entries from their row actions', async () => {
|
||||
vi.mocked(api.workspace.listDirectory).mockResolvedValue({
|
||||
path: '',
|
||||
entries: [
|
||||
{ name: 'docs', path: 'docs', type: 'directory' },
|
||||
{ name: 'README.md', path: 'README.md', type: 'file' }
|
||||
],
|
||||
truncated: false
|
||||
})
|
||||
render(<App />)
|
||||
|
||||
fireEvent.click(screen.getByLabelText('切换助手工作栏'))
|
||||
fireEvent.click(await screen.findByRole('tab', { name: '工作区' }))
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', {
|
||||
name: '在系统资源管理器中打开文件夹 docs'
|
||||
})
|
||||
)
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: '使用默认应用打开文件 README.md'
|
||||
})
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(api.workspace.openPath).toHaveBeenCalledWith(
|
||||
projectId,
|
||||
'docs',
|
||||
'directory'
|
||||
)
|
||||
)
|
||||
expect(api.workspace.openPath).toHaveBeenCalledWith(
|
||||
projectId,
|
||||
'README.md',
|
||||
'file'
|
||||
)
|
||||
})
|
||||
|
||||
it('refreshes generated workspace files when a run completes', async () => {
|
||||
vi.mocked(api.workspace.getChanges)
|
||||
.mockResolvedValueOnce({
|
||||
@@ -2399,6 +2491,87 @@ describe('App', () => {
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('edits and safely deletes the current project from project settings', async () => {
|
||||
const secondProject = {
|
||||
...project,
|
||||
id: '00000000-0000-4000-8000-000000000102',
|
||||
name: '第二项目',
|
||||
rootPath: 'C:\\Second'
|
||||
}
|
||||
vi.mocked(api.projects.list).mockResolvedValueOnce([
|
||||
project,
|
||||
secondProject
|
||||
])
|
||||
render(<App />)
|
||||
|
||||
fireEvent.click(await screen.findByLabelText('项目设置'))
|
||||
let dialog = screen.getByRole('dialog', { name: '项目设置' })
|
||||
expect(within(dialog).getByLabelText('名称')).toHaveValue(
|
||||
project.name
|
||||
)
|
||||
expect(within(dialog).getByLabelText('根目录')).toHaveValue(
|
||||
project.rootPath
|
||||
)
|
||||
fireEvent.change(within(dialog).getByLabelText('说明'), {
|
||||
target: { value: '更新后的说明' }
|
||||
})
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: '保存项目' })
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(api.projects.update).toHaveBeenCalledWith(
|
||||
project.id,
|
||||
expect.objectContaining({
|
||||
description: '更新后的说明',
|
||||
rootPath: project.rootPath
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByLabelText('项目设置'))
|
||||
dialog = screen.getByRole('dialog', { name: '项目设置' })
|
||||
expect(dialog).toHaveTextContent('不会删除磁盘上的项目目录或文件')
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: '删除项目' })
|
||||
)
|
||||
const confirmation = within(dialog).getByLabelText(
|
||||
`输入“${project.name}”确认删除`
|
||||
)
|
||||
const deleteButton = within(dialog).getByRole('button', {
|
||||
name: '永久删除项目'
|
||||
})
|
||||
expect(deleteButton).toBeDisabled()
|
||||
fireEvent.change(confirmation, {
|
||||
target: { value: project.name }
|
||||
})
|
||||
expect(deleteButton).toBeEnabled()
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.projects.delete).toHaveBeenCalledWith(
|
||||
project.id,
|
||||
project.name
|
||||
)
|
||||
)
|
||||
expect(screen.getByLabelText('当前项目')).toHaveValue(
|
||||
secondProject.id
|
||||
)
|
||||
})
|
||||
|
||||
it('uses a message icon for conversation navigation', async () => {
|
||||
render(<App />)
|
||||
|
||||
const conversationNavigation = await screen.findByRole('button', {
|
||||
name: '对话'
|
||||
})
|
||||
expect(
|
||||
conversationNavigation.querySelector('.lucide-message-square')
|
||||
).not.toBeNull()
|
||||
expect(
|
||||
conversationNavigation.querySelector('.lucide-history')
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('marks an image model and renders its generated artifact', async () => {
|
||||
const anchorClick = vi
|
||||
.spyOn(HTMLAnchorElement.prototype, 'click')
|
||||
@@ -2711,6 +2884,60 @@ describe('App', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('renders and answers an OpenCode question request', async () => {
|
||||
render(<App />)
|
||||
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
|
||||
target: { value: '需要确认的任务' }
|
||||
})
|
||||
fireEvent.click(await screen.findByLabelText('发送'))
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce())
|
||||
const request = run.mock.calls[0]?.[0]
|
||||
if (!request) {
|
||||
throw new Error('Missing request')
|
||||
}
|
||||
|
||||
act(() => {
|
||||
agentListener?.({
|
||||
requestId: request.requestId,
|
||||
type: 'question',
|
||||
questionId: 'question-1',
|
||||
questions: [
|
||||
{
|
||||
header: '实现方式',
|
||||
question: '请选择实现方式',
|
||||
options: [
|
||||
{
|
||||
label: '直接修改',
|
||||
description: '立即更新现有实现'
|
||||
},
|
||||
{
|
||||
label: '先写测试',
|
||||
description: '先增加回归测试'
|
||||
}
|
||||
],
|
||||
multiple: false,
|
||||
custom: true
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
expect(
|
||||
await screen.findByText('OpenCode 需要补充信息')
|
||||
).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByLabelText(/先写测试/u))
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交回答' }))
|
||||
await waitFor(() =>
|
||||
expect(api.agent.respondQuestion).toHaveBeenCalledWith(
|
||||
'question-1',
|
||||
[['先写测试']]
|
||||
)
|
||||
)
|
||||
expect(
|
||||
screen.queryByText('OpenCode 需要补充信息')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('configures a runtime without reading an existing API key', async () => {
|
||||
render(<App />)
|
||||
|
||||
|
||||
+409
-83
@@ -11,11 +11,11 @@ import {
|
||||
Edit3,
|
||||
FileText,
|
||||
HeartPulse,
|
||||
History,
|
||||
Info,
|
||||
Library,
|
||||
Maximize2,
|
||||
MessageSquarePlus,
|
||||
MessageSquare,
|
||||
Mic,
|
||||
MicOff,
|
||||
Minimize2,
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
import type {
|
||||
ApprovalDecision,
|
||||
AgentEvent,
|
||||
AgentQuestionAnswer,
|
||||
AgentRuntimeStatus,
|
||||
AppInfo,
|
||||
BrowserLiveState,
|
||||
@@ -77,16 +78,20 @@ import type {
|
||||
TokenUsageSummary,
|
||||
ConversationSnapshot,
|
||||
ConversationAttachment,
|
||||
ConversationMessageBlock,
|
||||
ConversationToolActivity,
|
||||
ProjectCreateInput,
|
||||
InteractiveWorkMode,
|
||||
WorkspaceChanges
|
||||
} from '../../shared/assistant-contracts'
|
||||
import {
|
||||
conversationAttachmentSchema,
|
||||
conversationMessageBlocksSchema,
|
||||
interactiveWorkModes,
|
||||
normalizeInteractiveWorkMode
|
||||
} from '../../shared/assistant-contracts'
|
||||
import { ActivityPanel } from './ActivityPanel'
|
||||
import { AgentQuestionCard } from './AgentQuestionCard'
|
||||
import {
|
||||
loadActivityRecords,
|
||||
reconcileActivityRecords,
|
||||
@@ -268,20 +273,7 @@ function supportsSubagentSmartRouting(
|
||||
return workMode === 'ask' || ['plan'].includes(workMode)
|
||||
}
|
||||
|
||||
type ToolActivity = {
|
||||
callId?: string
|
||||
name: string
|
||||
state:
|
||||
| 'pending'
|
||||
| 'running'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'recoverable'
|
||||
| 'cancelled'
|
||||
| 'interrupted'
|
||||
summary: string
|
||||
error?: string
|
||||
}
|
||||
type ToolActivity = ConversationToolActivity
|
||||
|
||||
type SubagentActivity = {
|
||||
childTaskId: string
|
||||
@@ -298,6 +290,7 @@ type Message = {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
reasoning?: string
|
||||
blocks?: ConversationMessageBlock[]
|
||||
createdAt: number
|
||||
state: 'streaming' | 'complete' | 'error'
|
||||
status?: string
|
||||
@@ -311,6 +304,7 @@ type Message = {
|
||||
argumentSummary?: string
|
||||
allowPermanent?: boolean
|
||||
}
|
||||
question?: Extract<AgentEvent, { type: 'question' }>
|
||||
sources?: string[]
|
||||
sourceReferences?: KnowledgeSearchReference[]
|
||||
artifactIds?: string[]
|
||||
@@ -394,6 +388,89 @@ const subagentStateLabels: Record<SubagentActivity['state'], string> = {
|
||||
cancelled: '已取消'
|
||||
}
|
||||
|
||||
const maxMessageContentLength = 1_000_000
|
||||
const maxMessageBlocks = 500
|
||||
|
||||
function appendMessageContentBlock(
|
||||
blocks: ConversationMessageBlock[] | undefined,
|
||||
type: 'text' | 'reasoning',
|
||||
delta: string
|
||||
): ConversationMessageBlock[] | undefined {
|
||||
if (!blocks || !delta) {
|
||||
return blocks
|
||||
}
|
||||
const current = [...blocks]
|
||||
const previous = current.at(-1)
|
||||
if (previous?.type === type) {
|
||||
previous.content = `${previous.content}${delta}`.slice(
|
||||
0,
|
||||
maxMessageContentLength
|
||||
)
|
||||
return current
|
||||
}
|
||||
if (current.length >= maxMessageBlocks) {
|
||||
return current
|
||||
}
|
||||
current.push({
|
||||
id: crypto.randomUUID(),
|
||||
type,
|
||||
content: delta.slice(0, maxMessageContentLength)
|
||||
})
|
||||
return current
|
||||
}
|
||||
|
||||
function upsertMessageToolBlock(
|
||||
blocks: ConversationMessageBlock[] | undefined,
|
||||
tool: ToolActivity
|
||||
): ConversationMessageBlock[] | undefined {
|
||||
if (!blocks) {
|
||||
return blocks
|
||||
}
|
||||
const callId = tool.callId
|
||||
const index = callId
|
||||
? blocks.findIndex(
|
||||
(block) =>
|
||||
block.type === 'tool' && block.tool.callId === callId
|
||||
)
|
||||
: -1
|
||||
if (index >= 0) {
|
||||
return blocks.map((block, blockIndex) =>
|
||||
blockIndex === index && block.type === 'tool'
|
||||
? { ...block, tool }
|
||||
: block
|
||||
)
|
||||
}
|
||||
if (blocks.length >= maxMessageBlocks) {
|
||||
return blocks
|
||||
}
|
||||
return [
|
||||
...blocks,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
type: 'tool',
|
||||
tool
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function terminalizeMessageToolBlocks(
|
||||
blocks: ConversationMessageBlock[] | undefined,
|
||||
state: 'failed' | 'cancelled'
|
||||
): ConversationMessageBlock[] | undefined {
|
||||
return blocks?.map((block) =>
|
||||
block.type === 'tool' &&
|
||||
(block.tool.state === 'pending' || block.tool.state === 'running')
|
||||
? {
|
||||
...block,
|
||||
tool: {
|
||||
...block.tool,
|
||||
state
|
||||
}
|
||||
}
|
||||
: block
|
||||
)
|
||||
}
|
||||
|
||||
function createConversation(
|
||||
projectId?: string,
|
||||
runtimeSelection?: AgentRuntimeSelection
|
||||
@@ -492,6 +569,9 @@ function isConversation(value: unknown): value is Conversation {
|
||||
entry.content.length <= 1_000_000 &&
|
||||
(entry.reasoning === undefined ||
|
||||
typeof entry.reasoning === 'string') &&
|
||||
(entry.blocks === undefined ||
|
||||
conversationMessageBlocksSchema.safeParse(entry.blocks)
|
||||
.success) &&
|
||||
typeof entry.createdAt === 'number' &&
|
||||
(entry.state === 'streaming' ||
|
||||
entry.state === 'complete' ||
|
||||
@@ -525,6 +605,7 @@ function toConversationSnapshots(
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
reasoning: message.reasoning,
|
||||
blocks: message.blocks,
|
||||
createdAt: message.createdAt,
|
||||
state: message.state,
|
||||
status: message.status,
|
||||
@@ -1608,7 +1689,7 @@ function App(): React.JSX.Element {
|
||||
? {
|
||||
...task,
|
||||
status:
|
||||
event.type === 'approval'
|
||||
event.type === 'approval' || event.type === 'question'
|
||||
? 'waiting_approval'
|
||||
: event.type === 'done'
|
||||
? 'completed'
|
||||
@@ -1673,19 +1754,46 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
|
||||
if (event.type === 'text') {
|
||||
updateMessage(run.conversationId, run.messageId, (message) => ({
|
||||
...message,
|
||||
content: `${message.content}${event.delta}`.slice(0, 1_000_000),
|
||||
status:
|
||||
message.content.length + event.delta.length > 1_000_000
|
||||
? '回答过长,已在本地截断显示'
|
||||
: undefined
|
||||
}))
|
||||
updateMessage(run.conversationId, run.messageId, (message) => {
|
||||
const remaining = Math.max(
|
||||
0,
|
||||
maxMessageContentLength - message.content.length
|
||||
)
|
||||
const acceptedDelta = event.delta.slice(0, remaining)
|
||||
return {
|
||||
...message,
|
||||
content: `${message.content}${acceptedDelta}`,
|
||||
blocks: appendMessageContentBlock(
|
||||
message.blocks,
|
||||
'text',
|
||||
acceptedDelta
|
||||
),
|
||||
status:
|
||||
event.delta.length > remaining
|
||||
? '回答过长,已在本地截断显示'
|
||||
: undefined
|
||||
}
|
||||
})
|
||||
} else if (event.type === 'reasoning') {
|
||||
updateMessage(run.conversationId, run.messageId, (message) => ({
|
||||
...message,
|
||||
reasoning: `${message.reasoning ?? ''}${event.delta}`
|
||||
}))
|
||||
updateMessage(run.conversationId, run.messageId, (message) => {
|
||||
const currentReasoning = message.reasoning ?? ''
|
||||
const acceptedDelta = event.delta.slice(
|
||||
0,
|
||||
Math.max(
|
||||
0,
|
||||
maxMessageContentLength - currentReasoning.length
|
||||
)
|
||||
)
|
||||
return {
|
||||
...message,
|
||||
reasoning: `${currentReasoning}${acceptedDelta}`,
|
||||
blocks: appendMessageContentBlock(
|
||||
message.blocks,
|
||||
'reasoning',
|
||||
acceptedDelta
|
||||
)
|
||||
}
|
||||
})
|
||||
} else if (event.type === 'status') {
|
||||
updateMessage(run.conversationId, run.messageId, (message) => ({
|
||||
...message,
|
||||
@@ -1728,7 +1836,11 @@ function App(): React.JSX.Element {
|
||||
} else {
|
||||
tools.push(tool)
|
||||
}
|
||||
return { ...message, tools }
|
||||
return {
|
||||
...message,
|
||||
tools,
|
||||
blocks: upsertMessageToolBlock(message.blocks, tool)
|
||||
}
|
||||
})
|
||||
} else if (event.type === 'subagent') {
|
||||
const childStatus = event.state
|
||||
@@ -1831,6 +1943,12 @@ function App(): React.JSX.Element {
|
||||
allowPermanent: event.allowPermanent
|
||||
}
|
||||
}))
|
||||
} else if (event.type === 'question') {
|
||||
updateMessage(run.conversationId, run.messageId, (message) => ({
|
||||
...message,
|
||||
status: undefined,
|
||||
question: event
|
||||
}))
|
||||
} else if (event.type === 'artifact') {
|
||||
updateMessage(run.conversationId, run.messageId, (message) => ({
|
||||
...message,
|
||||
@@ -1924,30 +2042,47 @@ function App(): React.JSX.Element {
|
||||
: 'Agent Runtime 已完成响应',
|
||||
status: terminalStatus
|
||||
})
|
||||
updateMessage(run.conversationId, run.messageId, (message) => ({
|
||||
...message,
|
||||
state: event.type === 'error' ? 'error' : 'complete',
|
||||
status: event.type === 'error' ? event.message : undefined,
|
||||
approval: undefined,
|
||||
tools:
|
||||
updateMessage(run.conversationId, run.messageId, (message) => {
|
||||
const toolTerminalState =
|
||||
event.type === 'error'
|
||||
? event.status === 'cancelled'
|
||||
? ('cancelled' as const)
|
||||
: ('failed' as const)
|
||||
: undefined
|
||||
const fallbackError =
|
||||
event.type === 'error' && !message.content
|
||||
? event.message.slice(0, maxMessageContentLength)
|
||||
: ''
|
||||
return {
|
||||
...message,
|
||||
state: event.type === 'error' ? 'error' : 'complete',
|
||||
status: event.type === 'error' ? event.message : undefined,
|
||||
approval: undefined,
|
||||
question: undefined,
|
||||
tools: toolTerminalState
|
||||
? message.tools?.map((tool) =>
|
||||
tool.state === 'pending' || tool.state === 'running'
|
||||
? {
|
||||
...tool,
|
||||
state:
|
||||
event.status === 'cancelled'
|
||||
? ('cancelled' as const)
|
||||
: ('failed' as const)
|
||||
}
|
||||
? { ...tool, state: toolTerminalState }
|
||||
: tool
|
||||
)
|
||||
: message.tools,
|
||||
content:
|
||||
event.type === 'error' && !message.content
|
||||
? event.message
|
||||
: message.content
|
||||
}))
|
||||
blocks: toolTerminalState
|
||||
? terminalizeMessageToolBlocks(
|
||||
appendMessageContentBlock(
|
||||
message.blocks,
|
||||
'text',
|
||||
fallbackError
|
||||
),
|
||||
toolTerminalState
|
||||
)
|
||||
: appendMessageContentBlock(
|
||||
message.blocks,
|
||||
'text',
|
||||
fallbackError
|
||||
),
|
||||
content: fallbackError || message.content
|
||||
}
|
||||
})
|
||||
activeRuns.current.delete(event.requestId)
|
||||
}
|
||||
},
|
||||
@@ -2088,6 +2223,22 @@ function App(): React.JSX.Element {
|
||||
},
|
||||
[activeProjectId]
|
||||
)
|
||||
const openWorkspaceEntry = useCallback(
|
||||
async (
|
||||
path: string,
|
||||
type: 'file' | 'directory'
|
||||
): Promise<void> => {
|
||||
if (!activeProjectId) {
|
||||
throw new Error('请先选择项目')
|
||||
}
|
||||
await window.goodbuddy.workspace.openPath(
|
||||
activeProjectId,
|
||||
path,
|
||||
type
|
||||
)
|
||||
},
|
||||
[activeProjectId]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (assistantSidebarTab !== 'changes') {
|
||||
@@ -2510,6 +2661,27 @@ function App(): React.JSX.Element {
|
||||
return project
|
||||
}
|
||||
|
||||
const updateProject = async (
|
||||
projectId: string,
|
||||
input: ProjectCreateInput
|
||||
): Promise<AssistantProject> => {
|
||||
const project = await window.goodbuddy.projects.update(
|
||||
projectId,
|
||||
input
|
||||
)
|
||||
setProjects((current) =>
|
||||
current.map((candidate) =>
|
||||
candidate.id === project.id ? project : candidate
|
||||
)
|
||||
)
|
||||
if (project.id === activeProjectId) {
|
||||
setWorkMode(
|
||||
normalizeInteractiveWorkMode(project.defaultWorkMode)
|
||||
)
|
||||
}
|
||||
return project
|
||||
}
|
||||
|
||||
const archiveProject = async (projectId: string): Promise<void> => {
|
||||
await window.goodbuddy.projects.setArchived(projectId, true)
|
||||
const remaining = projects.filter((project) => project.id !== projectId)
|
||||
@@ -2520,6 +2692,65 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
}
|
||||
|
||||
const deleteProject = async (
|
||||
projectId: string,
|
||||
confirmation: string
|
||||
): Promise<void> => {
|
||||
await window.goodbuddy.projects.delete(projectId, confirmation)
|
||||
const remainingProjects = projects.filter(
|
||||
(project) => project.id !== projectId
|
||||
)
|
||||
const remainingConversations = conversations.filter(
|
||||
(conversation) => conversation.projectId !== projectId
|
||||
)
|
||||
setProjects(remainingProjects)
|
||||
setConversations(remainingConversations)
|
||||
setAssistantTasks((current) =>
|
||||
current.filter((task) => task.projectId !== projectId)
|
||||
)
|
||||
setAssistantArtifacts((current) =>
|
||||
current.filter((artifact) => artifact.projectId !== projectId)
|
||||
)
|
||||
setAssistantMemories((current) =>
|
||||
current.filter(
|
||||
(memory) =>
|
||||
!(
|
||||
memory.scope === 'project' &&
|
||||
memory.scopeId === projectId
|
||||
)
|
||||
)
|
||||
)
|
||||
setAssistantSchedules((current) =>
|
||||
current.filter((schedule) => schedule.projectId !== projectId)
|
||||
)
|
||||
setAssistantHeartbeats((current) =>
|
||||
current.filter((heartbeat) => heartbeat.projectId !== projectId)
|
||||
)
|
||||
const next = remainingProjects[0]
|
||||
if (next) {
|
||||
setActiveProjectId(next.id)
|
||||
setWorkMode(
|
||||
normalizeInteractiveWorkMode(next.defaultWorkMode)
|
||||
)
|
||||
const nextConversation = remainingConversations.find(
|
||||
(conversation) => conversation.projectId === next.id
|
||||
)
|
||||
if (nextConversation) {
|
||||
setActiveId(nextConversation.id)
|
||||
} else {
|
||||
const created = createConversation(
|
||||
next.id,
|
||||
runtimeSettings
|
||||
? getDefaultRuntimeSelection(runtimeSettings)
|
||||
: undefined
|
||||
)
|
||||
setConversations((current) => [created, ...current])
|
||||
setActiveId(created.id)
|
||||
}
|
||||
}
|
||||
setView('chat')
|
||||
}
|
||||
|
||||
const newConversation = (): void => {
|
||||
startNewConversation(activeProjectId || undefined)
|
||||
}
|
||||
@@ -2818,6 +3049,7 @@ function App(): React.JSX.Element {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
blocks: [],
|
||||
createdAt: Date.now(),
|
||||
state: 'streaming',
|
||||
status: '正在连接 Agent Runtime'
|
||||
@@ -2982,6 +3214,20 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
}
|
||||
|
||||
const respondToQuestion = async (
|
||||
conversationId: string,
|
||||
messageId: string,
|
||||
questionId: string,
|
||||
answers?: AgentQuestionAnswer[]
|
||||
): Promise<void> => {
|
||||
await window.goodbuddy.agent.respondQuestion(questionId, answers)
|
||||
updateMessage(conversationId, messageId, (message) => ({
|
||||
...message,
|
||||
question: undefined,
|
||||
status: answers ? '回答已提交,OpenCode 正在继续执行' : '已跳过问题'
|
||||
}))
|
||||
}
|
||||
|
||||
const addContext = async (
|
||||
action: () => Promise<ContextAttachment | ContextAttachment[]>
|
||||
): Promise<void> => {
|
||||
@@ -3383,10 +3629,12 @@ function App(): React.JSX.Element {
|
||||
activeProjectId={activeProjectId}
|
||||
onArchive={archiveProject}
|
||||
onCreate={createProject}
|
||||
onDelete={deleteProject}
|
||||
onSelect={selectProject}
|
||||
onSelectRoot={() =>
|
||||
window.goodbuddy.settings.selectWorkspace()
|
||||
}
|
||||
onUpdate={updateProject}
|
||||
projects={projects}
|
||||
/>
|
||||
|
||||
@@ -3414,7 +3662,7 @@ function App(): React.JSX.Element {
|
||||
onClick={() => setView('chat')}
|
||||
type="button"
|
||||
>
|
||||
<History size={17} />
|
||||
<MessageSquare size={17} />
|
||||
<span>对话</span>
|
||||
</button>
|
||||
<button
|
||||
@@ -3901,30 +4149,85 @@ function App(): React.JSX.Element {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{message.reasoning && (
|
||||
<details
|
||||
className="message-reasoning"
|
||||
key={`${message.id}-${message.state}`}
|
||||
open={message.state === 'streaming'}
|
||||
>
|
||||
<summary>
|
||||
{message.state === 'streaming'
|
||||
? '正在推理'
|
||||
: '推理过程'}
|
||||
</summary>
|
||||
<div className="markdown-content message-reasoning__content">
|
||||
<MarkdownRenderer>
|
||||
{message.reasoning}
|
||||
</MarkdownRenderer>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
{message.content && (
|
||||
<div className="markdown-content message__content">
|
||||
<MarkdownRenderer>
|
||||
{message.content}
|
||||
</MarkdownRenderer>
|
||||
{message.blocks && message.blocks.length > 0 ? (
|
||||
<div className="message-blocks">
|
||||
{message.blocks.map((block) =>
|
||||
block.type === 'reasoning' ? (
|
||||
<details
|
||||
className="message-reasoning"
|
||||
key={block.id}
|
||||
open={
|
||||
message.state === 'streaming' &&
|
||||
message.blocks?.at(-1)?.id === block.id
|
||||
}
|
||||
>
|
||||
<summary>
|
||||
{message.state === 'streaming'
|
||||
? '正在推理'
|
||||
: '推理过程'}
|
||||
</summary>
|
||||
<div className="markdown-content message-reasoning__content">
|
||||
<MarkdownRenderer>
|
||||
{block.content}
|
||||
</MarkdownRenderer>
|
||||
</div>
|
||||
</details>
|
||||
) : block.type === 'text' ? (
|
||||
<div
|
||||
className="markdown-content message__content"
|
||||
key={block.id}
|
||||
>
|
||||
<MarkdownRenderer>
|
||||
{block.content}
|
||||
</MarkdownRenderer>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="tool-activity"
|
||||
key={block.id}
|
||||
>
|
||||
<TerminalSquare size={15} />
|
||||
<div className="tool-activity__content">
|
||||
<span>{block.tool.summary}</span>
|
||||
{block.tool.error && (
|
||||
<code>{block.tool.error}</code>
|
||||
)}
|
||||
</div>
|
||||
<small>
|
||||
{toolStateLabels[block.tool.state]}
|
||||
</small>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{message.reasoning && (
|
||||
<details
|
||||
className="message-reasoning"
|
||||
key={`${message.id}-${message.state}`}
|
||||
open={message.state === 'streaming'}
|
||||
>
|
||||
<summary>
|
||||
{message.state === 'streaming'
|
||||
? '正在推理'
|
||||
: '推理过程'}
|
||||
</summary>
|
||||
<div className="markdown-content message-reasoning__content">
|
||||
<MarkdownRenderer>
|
||||
{message.reasoning}
|
||||
</MarkdownRenderer>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
{message.content && (
|
||||
<div className="markdown-content message__content">
|
||||
<MarkdownRenderer>
|
||||
{message.content}
|
||||
</MarkdownRenderer>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{message.artifactIds?.map((artifactId) => {
|
||||
const candidate =
|
||||
@@ -4043,19 +4346,20 @@ function App(): React.JSX.Element {
|
||||
</ol>
|
||||
</details>
|
||||
)}
|
||||
{message.tools?.map((tool) => (
|
||||
<div
|
||||
className="tool-activity"
|
||||
key={tool.callId ?? tool.name}
|
||||
>
|
||||
<TerminalSquare size={15} />
|
||||
<div className="tool-activity__content">
|
||||
<span>{tool.summary}</span>
|
||||
{tool.error && <code>{tool.error}</code>}
|
||||
{(!message.blocks || message.blocks.length === 0) &&
|
||||
message.tools?.map((tool) => (
|
||||
<div
|
||||
className="tool-activity"
|
||||
key={tool.callId ?? tool.name}
|
||||
>
|
||||
<TerminalSquare size={15} />
|
||||
<div className="tool-activity__content">
|
||||
<span>{tool.summary}</span>
|
||||
{tool.error && <code>{tool.error}</code>}
|
||||
</div>
|
||||
<small>{toolStateLabels[tool.state]}</small>
|
||||
</div>
|
||||
<small>{toolStateLabels[tool.state]}</small>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
{message.subagents && message.subagents.length > 0 && (
|
||||
<section
|
||||
aria-label="子专家状态"
|
||||
@@ -4159,6 +4463,27 @@ function App(): React.JSX.Element {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{message.question && (
|
||||
<AgentQuestionCard
|
||||
key={message.question.questionId}
|
||||
onReject={() =>
|
||||
respondToQuestion(
|
||||
activeConversation.id,
|
||||
message.id,
|
||||
message.question!.questionId
|
||||
)
|
||||
}
|
||||
onSubmit={(answers) =>
|
||||
respondToQuestion(
|
||||
activeConversation.id,
|
||||
message.id,
|
||||
message.question!.questionId,
|
||||
answers
|
||||
)
|
||||
}
|
||||
value={message.question}
|
||||
/>
|
||||
)}
|
||||
{message.status && (
|
||||
<div
|
||||
className={
|
||||
@@ -5064,6 +5389,7 @@ function App(): React.JSX.Element {
|
||||
onSetHeartbeatPaused={setHeartbeatPaused}
|
||||
onListWorkspaceDirectory={listWorkspaceDirectory}
|
||||
onLoadWorkspaceFile={loadWorkspaceFile}
|
||||
onOpenWorkspaceEntry={openWorkspaceEntry}
|
||||
onRefreshChanges={refreshWorkspaceChanges}
|
||||
onRespondApproval={(approval, decision) => {
|
||||
void respondToApproval(
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { Archive, FolderOpen, Plus, X } from 'lucide-react'
|
||||
import {
|
||||
Archive,
|
||||
FolderOpen,
|
||||
Plus,
|
||||
Settings,
|
||||
Trash2,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type {
|
||||
AssistantProject,
|
||||
@@ -6,7 +13,10 @@ import type {
|
||||
ProjectCreateInput,
|
||||
WorkMode
|
||||
} from '../../shared/assistant-contracts'
|
||||
import { interactiveWorkModes } from '../../shared/assistant-contracts'
|
||||
import {
|
||||
interactiveWorkModes,
|
||||
normalizeInteractiveWorkMode
|
||||
} from '../../shared/assistant-contracts'
|
||||
import { trapTabFocus } from './dialog-focus'
|
||||
|
||||
type ProjectSwitcherProps = {
|
||||
@@ -14,8 +24,13 @@ type ProjectSwitcherProps = {
|
||||
activeProjectId: string
|
||||
onArchive: (projectId: string) => Promise<void>
|
||||
onCreate: (input: ProjectCreateInput) => Promise<AssistantProject>
|
||||
onDelete: (projectId: string, confirmation: string) => Promise<void>
|
||||
onSelect: (projectId: string) => void
|
||||
onSelectRoot: () => Promise<string | undefined>
|
||||
onUpdate: (
|
||||
projectId: string,
|
||||
input: ProjectCreateInput
|
||||
) => Promise<AssistantProject>
|
||||
}
|
||||
|
||||
export const workModeLabels: Record<InteractiveWorkMode, string> = {
|
||||
@@ -28,59 +43,86 @@ export function ProjectSwitcher({
|
||||
activeProjectId,
|
||||
onArchive,
|
||||
onCreate,
|
||||
onDelete,
|
||||
onSelect,
|
||||
onSelectRoot
|
||||
onSelectRoot,
|
||||
onUpdate
|
||||
}: ProjectSwitcherProps): React.JSX.Element {
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [dialogMode, setDialogMode] = useState<
|
||||
'create' | 'settings'
|
||||
>()
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [archiving, setArchiving] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
||||
const [deleteConfirmation, setDeleteConfirmation] = useState('')
|
||||
const [error, setError] = useState<string>()
|
||||
const createButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const settingsButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
const restoreCreateButtonFocus = useRef(false)
|
||||
const restoreFocusTarget = useRef<
|
||||
'create' | 'settings' | undefined
|
||||
>(undefined)
|
||||
const [draft, setDraft] = useState<ProjectCreateInput>({
|
||||
name: '',
|
||||
description: '',
|
||||
rootPath: '',
|
||||
defaultWorkMode: 'ask'
|
||||
})
|
||||
const activeProject = projects.find(
|
||||
(project) => project.id === activeProjectId
|
||||
)
|
||||
const busy = saving || archiving || deleting
|
||||
|
||||
useEffect(() => {
|
||||
if (!creating) {
|
||||
if (restoreCreateButtonFocus.current) {
|
||||
if (!dialogMode) {
|
||||
if (restoreFocusTarget.current === 'create') {
|
||||
createButtonRef.current?.focus()
|
||||
restoreCreateButtonFocus.current = false
|
||||
} else if (restoreFocusTarget.current === 'settings') {
|
||||
settingsButtonRef.current?.focus()
|
||||
}
|
||||
restoreFocusTarget.current = undefined
|
||||
return
|
||||
}
|
||||
restoreCreateButtonFocus.current = true
|
||||
const onKeyDown = (event: KeyboardEvent): void => {
|
||||
if (event.key === 'Escape' && !saving && !archiving) {
|
||||
if (event.key === 'Escape' && !busy) {
|
||||
setError(undefined)
|
||||
setCreating(false)
|
||||
setConfirmingDelete(false)
|
||||
setDeleteConfirmation('')
|
||||
setDialogMode(undefined)
|
||||
return
|
||||
}
|
||||
trapTabFocus(event, dialogRef.current)
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
return () => document.removeEventListener('keydown', onKeyDown)
|
||||
}, [archiving, creating, saving])
|
||||
}, [busy, dialogMode])
|
||||
|
||||
const create = async (): Promise<void> => {
|
||||
const closeDialog = (): void => {
|
||||
setError(undefined)
|
||||
setConfirmingDelete(false)
|
||||
setDeleteConfirmation('')
|
||||
setDialogMode(undefined)
|
||||
}
|
||||
|
||||
const save = async (): Promise<void> => {
|
||||
setSaving(true)
|
||||
setError(undefined)
|
||||
try {
|
||||
const project = await onCreate(draft)
|
||||
onSelect(project.id)
|
||||
setDraft({
|
||||
name: '',
|
||||
description: '',
|
||||
rootPath: '',
|
||||
defaultWorkMode: 'ask'
|
||||
})
|
||||
setCreating(false)
|
||||
if (dialogMode === 'settings' && activeProject) {
|
||||
await onUpdate(activeProject.id, draft)
|
||||
} else {
|
||||
await onCreate(draft)
|
||||
}
|
||||
closeDialog()
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : '创建项目失败')
|
||||
setError(
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: dialogMode === 'settings'
|
||||
? '保存项目失败'
|
||||
: '创建项目失败'
|
||||
)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -110,7 +152,7 @@ export function ProjectSwitcher({
|
||||
setError(undefined)
|
||||
try {
|
||||
await onArchive(activeProjectId)
|
||||
setCreating(false)
|
||||
closeDialog()
|
||||
} catch (reason) {
|
||||
setError(
|
||||
reason instanceof Error ? reason.message : '归档项目失败'
|
||||
@@ -120,6 +162,24 @@ export function ProjectSwitcher({
|
||||
}
|
||||
}
|
||||
|
||||
const deleteProject = async (): Promise<void> => {
|
||||
if (!activeProject) {
|
||||
return
|
||||
}
|
||||
setDeleting(true)
|
||||
setError(undefined)
|
||||
try {
|
||||
await onDelete(activeProject.id, deleteConfirmation)
|
||||
closeDialog()
|
||||
} catch (reason) {
|
||||
setError(
|
||||
reason instanceof Error ? reason.message : '删除项目失败'
|
||||
)
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="project-switcher">
|
||||
<div className="project-switcher__row">
|
||||
@@ -139,45 +199,79 @@ export function ProjectSwitcher({
|
||||
className="icon-button"
|
||||
onClick={() => {
|
||||
setError(undefined)
|
||||
setCreating(true)
|
||||
setConfirmingDelete(false)
|
||||
setDeleteConfirmation('')
|
||||
setDraft({
|
||||
name: '',
|
||||
description: '',
|
||||
rootPath: '',
|
||||
defaultWorkMode: 'ask'
|
||||
})
|
||||
restoreFocusTarget.current = 'create'
|
||||
setDialogMode('create')
|
||||
}}
|
||||
ref={createButtonRef}
|
||||
type="button"
|
||||
>
|
||||
<Plus size={15} />
|
||||
</button>
|
||||
<button
|
||||
aria-label="项目设置"
|
||||
className="icon-button"
|
||||
disabled={!activeProject}
|
||||
onClick={() => {
|
||||
if (!activeProject) {
|
||||
return
|
||||
}
|
||||
setError(undefined)
|
||||
setConfirmingDelete(false)
|
||||
setDeleteConfirmation('')
|
||||
setDraft({
|
||||
name: activeProject.name,
|
||||
description: activeProject.description,
|
||||
rootPath: activeProject.rootPath,
|
||||
defaultWorkMode: normalizeInteractiveWorkMode(
|
||||
activeProject.defaultWorkMode
|
||||
)
|
||||
})
|
||||
restoreFocusTarget.current = 'settings'
|
||||
setDialogMode('settings')
|
||||
}}
|
||||
ref={settingsButtonRef}
|
||||
type="button"
|
||||
>
|
||||
<Settings size={15} />
|
||||
</button>
|
||||
</div>
|
||||
{creating && (
|
||||
{dialogMode && (
|
||||
<div
|
||||
className="project-create-backdrop"
|
||||
onMouseDown={(event) => {
|
||||
if (
|
||||
event.currentTarget === event.target &&
|
||||
!saving &&
|
||||
!archiving
|
||||
) {
|
||||
setError(undefined)
|
||||
setCreating(false)
|
||||
if (event.currentTarget === event.target && !busy) {
|
||||
closeDialog()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
aria-labelledby="project-create-title"
|
||||
aria-labelledby="project-dialog-title"
|
||||
aria-modal="true"
|
||||
className="project-create-card"
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
>
|
||||
<header>
|
||||
<strong id="project-create-title">新建项目</strong>
|
||||
<strong id="project-dialog-title">
|
||||
{dialogMode === 'create' ? '新建项目' : '项目设置'}
|
||||
</strong>
|
||||
<button
|
||||
aria-label="关闭新建项目"
|
||||
aria-label={
|
||||
dialogMode === 'create'
|
||||
? '关闭新建项目'
|
||||
: '关闭项目设置'
|
||||
}
|
||||
className="icon-button"
|
||||
disabled={saving || archiving}
|
||||
onClick={() => {
|
||||
setError(undefined)
|
||||
setCreating(false)
|
||||
}}
|
||||
disabled={busy}
|
||||
onClick={closeDialog}
|
||||
type="button"
|
||||
>
|
||||
<X size={14} />
|
||||
@@ -186,7 +280,7 @@ export function ProjectSwitcher({
|
||||
<label>
|
||||
<span>名称</span>
|
||||
<input
|
||||
autoFocus
|
||||
autoFocus={!confirmingDelete}
|
||||
maxLength={120}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({
|
||||
@@ -218,7 +312,7 @@ export function ProjectSwitcher({
|
||||
<button
|
||||
aria-label="选择项目根目录"
|
||||
className="secondary-button"
|
||||
disabled={saving || archiving}
|
||||
disabled={busy}
|
||||
onClick={() => void selectRoot()}
|
||||
type="button"
|
||||
>
|
||||
@@ -249,27 +343,117 @@ export function ProjectSwitcher({
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{dialogMode === 'settings' && (
|
||||
<section
|
||||
aria-labelledby="project-danger-title"
|
||||
className="project-danger-zone"
|
||||
>
|
||||
<div>
|
||||
<strong id="project-danger-title">危险操作</strong>
|
||||
<p>
|
||||
删除项目会永久移除 GoodBuddy
|
||||
中的项目、对话、任务、计划、心跳、记忆和成果,但不会删除磁盘上的项目目录或文件。
|
||||
</p>
|
||||
</div>
|
||||
{!confirmingDelete ? (
|
||||
<button
|
||||
className="danger-button danger-button--quiet"
|
||||
disabled={busy || projects.length <= 1}
|
||||
onClick={() => {
|
||||
setError(undefined)
|
||||
setDeleteConfirmation('')
|
||||
setConfirmingDelete(true)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
删除项目
|
||||
</button>
|
||||
) : (
|
||||
<div className="project-delete-confirmation">
|
||||
<label>
|
||||
<span>
|
||||
输入“{activeProject?.name}”确认删除
|
||||
</span>
|
||||
<input
|
||||
autoFocus
|
||||
disabled={busy}
|
||||
onChange={(event) =>
|
||||
setDeleteConfirmation(event.target.value)
|
||||
}
|
||||
value={deleteConfirmation}
|
||||
/>
|
||||
</label>
|
||||
<div>
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setError(undefined)
|
||||
setDeleteConfirmation('')
|
||||
setConfirmingDelete(false)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
取消删除
|
||||
</button>
|
||||
<button
|
||||
className="danger-button"
|
||||
disabled={
|
||||
busy ||
|
||||
deleteConfirmation !== activeProject?.name
|
||||
}
|
||||
onClick={() => void deleteProject()}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
{deleting ? '删除中' : '永久删除项目'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{projects.length <= 1 && (
|
||||
<small>至少需要保留一个可用项目。</small>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
<div className="project-create-card__actions">
|
||||
{projects.length > 1 && activeProjectId && (
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={saving || archiving}
|
||||
onClick={() => void archive()}
|
||||
type="button"
|
||||
>
|
||||
<Archive size={13} />
|
||||
{archiving ? '归档中' : '归档当前'}
|
||||
</button>
|
||||
)}
|
||||
{dialogMode === 'settings' &&
|
||||
projects.length > 1 &&
|
||||
activeProjectId && (
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={busy}
|
||||
onClick={() => void archive()}
|
||||
type="button"
|
||||
>
|
||||
<Archive size={13} />
|
||||
{archiving ? '归档中' : '归档项目'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={busy}
|
||||
onClick={closeDialog}
|
||||
type="button"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={
|
||||
saving || archiving || !draft.name.trim()
|
||||
busy || !draft.name.trim() || confirmingDelete
|
||||
}
|
||||
onClick={() => void create()}
|
||||
onClick={() => void save()}
|
||||
type="button"
|
||||
>
|
||||
{saving ? '创建中' : '创建'}
|
||||
{saving
|
||||
? dialogMode === 'create'
|
||||
? '创建中'
|
||||
: '保存中'
|
||||
: dialogMode === 'create'
|
||||
? '创建'
|
||||
: '保存项目'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -49,6 +49,7 @@ function renderSidebar({
|
||||
}))}
|
||||
onLoadArtifact={vi.fn(async () => undefined)}
|
||||
onLoadWorkspaceFile={vi.fn()}
|
||||
onOpenWorkspaceEntry={vi.fn(async () => undefined)}
|
||||
onOpenConversation={vi.fn()}
|
||||
onOpenHeartbeat={vi.fn()}
|
||||
onRefreshChanges={vi.fn(async () => undefined)}
|
||||
|
||||
@@ -104,6 +104,10 @@ type RightAssistantSidebarProps = {
|
||||
path: string
|
||||
) => Promise<WorkspaceDirectoryListing>
|
||||
onLoadWorkspaceFile: (path: string) => Promise<WorkspaceFilePreview>
|
||||
onOpenWorkspaceEntry: (
|
||||
path: string,
|
||||
type: 'file' | 'directory'
|
||||
) => Promise<void>
|
||||
onRemoveMemory: (memoryId: string) => Promise<void>
|
||||
onSetMemoryStatus: (
|
||||
memoryId: string,
|
||||
@@ -139,7 +143,7 @@ const tabs: Array<{
|
||||
{
|
||||
id: 'changes',
|
||||
label: '工作区',
|
||||
description: '浏览项目文件、Git 变更与工具活动'
|
||||
description: '浏览项目文件与工具活动'
|
||||
},
|
||||
{
|
||||
id: 'browser',
|
||||
@@ -262,6 +266,7 @@ export function RightAssistantSidebar({
|
||||
onRefreshChanges,
|
||||
onListWorkspaceDirectory,
|
||||
onLoadWorkspaceFile,
|
||||
onOpenWorkspaceEntry,
|
||||
onRemoveMemory,
|
||||
onSetMemoryStatus,
|
||||
onRespondApproval,
|
||||
@@ -1079,8 +1084,8 @@ export function RightAssistantSidebar({
|
||||
<>
|
||||
<section className="assistant-sidebar__section">
|
||||
<p className="assistant-sidebar__section-description">
|
||||
浏览当前项目文件、检查未提交 Git 变更,并查看 Agent
|
||||
的工具活动。
|
||||
浏览当前项目文件,并查看 Agent 的工具活动。Git
|
||||
项目还会显示未提交更改。
|
||||
</p>
|
||||
<h3>
|
||||
<FolderTree size={15} />
|
||||
@@ -1088,6 +1093,7 @@ export function RightAssistantSidebar({
|
||||
<button
|
||||
aria-label="刷新工作区文件"
|
||||
className="icon-button"
|
||||
disabled={!workspaceProjectId}
|
||||
onClick={() => {
|
||||
setWorkspaceRefreshVersion((current) => current + 1)
|
||||
runAction(
|
||||
@@ -1095,6 +1101,7 @@ export function RightAssistantSidebar({
|
||||
'刷新工作区文件失败'
|
||||
)
|
||||
}}
|
||||
title="刷新"
|
||||
type="button"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
@@ -1104,6 +1111,7 @@ export function RightAssistantSidebar({
|
||||
changedFiles={workspaceChanges?.files ?? emptyChangedFiles}
|
||||
key={`${workspaceProjectId ?? 'none'}:${workspaceRefreshVersion}`}
|
||||
onListDirectory={onListWorkspaceDirectory}
|
||||
onOpenEntry={onOpenWorkspaceEntry}
|
||||
onOpenFile={openWorkspaceFile}
|
||||
projectId={workspaceProjectId}
|
||||
/>
|
||||
|
||||
@@ -193,6 +193,9 @@ const capabilitySnapshot = {
|
||||
}
|
||||
} satisfies CapabilitySnapshot
|
||||
const getCapabilitySnapshot = vi.fn(async () => capabilitySnapshot)
|
||||
const importSkill = vi.fn<DesktopApi['capabilities']['importSkill']>(
|
||||
async () => capabilitySnapshot
|
||||
)
|
||||
const saveMcpServer = vi.fn(async () => capabilitySnapshot)
|
||||
const setSkillEnabled = vi.fn(async (_skillId: string, enabled: boolean) => ({
|
||||
...capabilitySnapshot,
|
||||
@@ -342,7 +345,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
},
|
||||
capabilities: {
|
||||
getSnapshot: getCapabilitySnapshot,
|
||||
importSkill: vi.fn(async () => capabilitySnapshot),
|
||||
importSkill,
|
||||
removeSkill: vi.fn(async () => capabilitySnapshot),
|
||||
setSkillEnabled,
|
||||
setSkillAssignments: vi.fn(async () => capabilitySnapshot),
|
||||
@@ -1654,6 +1657,16 @@ describe('SettingsPanel runtime files', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Skills' }))
|
||||
expect(await screen.findByText('文档写作')).toBeInTheDocument()
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '导入 Skill 目录' })
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(importSkill).toHaveBeenCalledWith('directory')
|
||||
)
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '导入 Skill ZIP' })
|
||||
)
|
||||
await waitFor(() => expect(importSkill).toHaveBeenCalledWith('zip'))
|
||||
fireEvent.click(screen.getByLabelText('启用 文档写作'))
|
||||
await waitFor(() =>
|
||||
expect(setSkillEnabled).toHaveBeenCalledWith(
|
||||
|
||||
@@ -1194,15 +1194,17 @@ export function SettingsPanel({
|
||||
<div className="settings-section__title">
|
||||
<FolderOpen size={17} />
|
||||
<div>
|
||||
<strong>工作区</strong>
|
||||
<small>Agent 工具只能以此目录作为默认工作位置</small>
|
||||
<strong>默认工作区</strong>
|
||||
<small>
|
||||
当前项目未设置根目录时,Agent 才使用此默认位置
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<label className="field">
|
||||
<span>工作区目录</span>
|
||||
<span>默认工作区目录</span>
|
||||
<div className="workspace-picker">
|
||||
<input
|
||||
aria-label="工作区目录"
|
||||
aria-label="默认工作区目录"
|
||||
onChange={(event) => setWorkspacePath(event.target.value)}
|
||||
value={workspacePath}
|
||||
/>
|
||||
|
||||
@@ -68,13 +68,26 @@ export function SkillsSettingsSection(): React.JSX.Element {
|
||||
disabled={Boolean(busy)}
|
||||
onClick={() =>
|
||||
void run('import', () =>
|
||||
window.goodbuddy.capabilities.importSkill()
|
||||
window.goodbuddy.capabilities.importSkill('directory')
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<Download size={14} />
|
||||
导入 SKILL.md
|
||||
导入 Skill 目录
|
||||
</button>
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={Boolean(busy)}
|
||||
onClick={() =>
|
||||
void run('import', () =>
|
||||
window.goodbuddy.capabilities.importSkill('zip')
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<Download size={14} />
|
||||
导入 Skill ZIP
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -37,25 +37,39 @@ describe('WorkspaceFilesPanel', () => {
|
||||
}
|
||||
)
|
||||
const onOpenFile = vi.fn()
|
||||
const onOpenEntry = vi.fn(async () => undefined)
|
||||
|
||||
render(
|
||||
<WorkspaceFilesPanel
|
||||
changedFiles={[{ path: 'notes.txt', status: ' M' }]}
|
||||
onListDirectory={onListDirectory}
|
||||
onOpenEntry={onOpenEntry}
|
||||
onOpenFile={onOpenFile}
|
||||
projectId="00000000-0000-4000-8000-000000000101"
|
||||
/>
|
||||
)
|
||||
|
||||
expect(await screen.findByText('当前工作区')).toBeInTheDocument()
|
||||
fireEvent.click(await screen.findByRole('button', { name: /docs/u }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'docs' }))
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: /guide\.md/u })
|
||||
await screen.findByRole('button', { name: 'guide.md' })
|
||||
)
|
||||
|
||||
expect(onListDirectory).toHaveBeenCalledWith('')
|
||||
expect(onListDirectory).toHaveBeenCalledWith('docs')
|
||||
expect(onOpenFile).toHaveBeenCalledWith('docs/guide.md')
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: '使用默认应用打开文件 guide.md'
|
||||
})
|
||||
)
|
||||
expect(onOpenEntry).toHaveBeenCalledWith('docs/guide.md', 'file')
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: '在系统资源管理器中打开文件夹 docs'
|
||||
})
|
||||
)
|
||||
expect(onOpenEntry).toHaveBeenCalledWith('docs', 'directory')
|
||||
expect(screen.getAllByText('修改')).not.toHaveLength(0)
|
||||
})
|
||||
|
||||
@@ -84,6 +98,7 @@ describe('WorkspaceFilesPanel', () => {
|
||||
<WorkspaceFilesPanel
|
||||
changedFiles={[]}
|
||||
onListDirectory={onListDirectory}
|
||||
onOpenEntry={vi.fn(async () => undefined)}
|
||||
onOpenFile={vi.fn()}
|
||||
projectId="00000000-0000-4000-8000-000000000101"
|
||||
/>
|
||||
@@ -94,6 +109,7 @@ describe('WorkspaceFilesPanel', () => {
|
||||
<WorkspaceFilesPanel
|
||||
changedFiles={[]}
|
||||
onListDirectory={onListDirectory}
|
||||
onOpenEntry={vi.fn(async () => undefined)}
|
||||
onOpenFile={vi.fn()}
|
||||
projectId="00000000-0000-4000-8000-000000000102"
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
FileSearch,
|
||||
FileText,
|
||||
Folder,
|
||||
FolderOpen
|
||||
@@ -23,6 +24,10 @@ type WorkspaceFilesPanelProps = {
|
||||
changedFiles: WorkspaceChangedFile[]
|
||||
onListDirectory: (path: string) => Promise<WorkspaceDirectoryListing>
|
||||
onOpenFile: (path: string) => void
|
||||
onOpenEntry: (
|
||||
path: string,
|
||||
type: WorkspaceDirectoryEntry['type']
|
||||
) => Promise<void>
|
||||
}
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
@@ -46,7 +51,8 @@ export function WorkspaceFilesPanel({
|
||||
projectId,
|
||||
changedFiles,
|
||||
onListDirectory,
|
||||
onOpenFile
|
||||
onOpenFile,
|
||||
onOpenEntry
|
||||
}: WorkspaceFilesPanelProps): React.JSX.Element {
|
||||
const [listingState, setListingState] = useState<{
|
||||
projectId?: string
|
||||
@@ -174,6 +180,21 @@ export function WorkspaceFilesPanel({
|
||||
}
|
||||
}
|
||||
|
||||
const openEntry = (entry: WorkspaceDirectoryEntry): void => {
|
||||
setErrorState({ projectId })
|
||||
void onOpenEntry(entry.path, entry.type).catch((reason: unknown) => {
|
||||
setErrorState({
|
||||
projectId,
|
||||
value:
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: entry.type === 'directory'
|
||||
? '打开文件夹失败'
|
||||
: '打开文件失败'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const renderEntry = (
|
||||
entry: WorkspaceDirectoryEntry
|
||||
): React.JSX.Element => {
|
||||
@@ -183,20 +204,31 @@ export function WorkspaceFilesPanel({
|
||||
if (entry.type === 'directory') {
|
||||
return (
|
||||
<div key={entry.path}>
|
||||
<button
|
||||
aria-expanded={expanded}
|
||||
className="workspace-files__row"
|
||||
onClick={() => toggleDirectory(entry.path)}
|
||||
type="button"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown size={13} />
|
||||
) : (
|
||||
<ChevronRight size={13} />
|
||||
)}
|
||||
{expanded ? <FolderOpen size={15} /> : <Folder size={15} />}
|
||||
<span title={entry.path}>{entry.name}</span>
|
||||
</button>
|
||||
<div className="workspace-files__entry">
|
||||
<button
|
||||
aria-expanded={expanded}
|
||||
className="workspace-files__row"
|
||||
onClick={() => toggleDirectory(entry.path)}
|
||||
type="button"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown size={13} />
|
||||
) : (
|
||||
<ChevronRight size={13} />
|
||||
)}
|
||||
{expanded ? <FolderOpen size={15} /> : <Folder size={15} />}
|
||||
<span title={entry.path}>{entry.name}</span>
|
||||
</button>
|
||||
<button
|
||||
aria-label={`在系统资源管理器中打开文件夹 ${entry.name}`}
|
||||
className="workspace-files__open-entry"
|
||||
onClick={() => openEntry(entry)}
|
||||
title="打开文件夹"
|
||||
type="button"
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="workspace-files__children">
|
||||
{listing?.entries.map((child) =>
|
||||
@@ -216,22 +248,32 @@ export function WorkspaceFilesPanel({
|
||||
)
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className="workspace-files__row"
|
||||
key={entry.path}
|
||||
onClick={() => onOpenFile(entry.path)}
|
||||
title={entry.path}
|
||||
type="button"
|
||||
>
|
||||
<span className="workspace-files__indent" />
|
||||
<FileText size={15} />
|
||||
<span>{entry.name}</span>
|
||||
{changed && (
|
||||
<small className="workspace-files__change">
|
||||
{statusLabel(changed.status)}
|
||||
</small>
|
||||
)}
|
||||
</button>
|
||||
<div className="workspace-files__entry" key={entry.path}>
|
||||
<button
|
||||
className="workspace-files__row"
|
||||
onClick={() => onOpenFile(entry.path)}
|
||||
title={entry.path}
|
||||
type="button"
|
||||
>
|
||||
<span className="workspace-files__indent" />
|
||||
<FileText size={15} />
|
||||
<span>{entry.name}</span>
|
||||
{changed && (
|
||||
<small className="workspace-files__change">
|
||||
{statusLabel(changed.status)}
|
||||
</small>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
aria-label={`使用默认应用打开文件 ${entry.name}`}
|
||||
className="workspace-files__open-entry"
|
||||
onClick={() => openEntry(entry)}
|
||||
title="打开文件"
|
||||
type="button"
|
||||
>
|
||||
<FileSearch size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+197
-1
@@ -171,7 +171,7 @@ textarea:focus-visible {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
}
|
||||
|
||||
.project-switcher select {
|
||||
@@ -272,6 +272,56 @@ textarea:focus-visible {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.project-danger-zone {
|
||||
display: flex;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--danger-border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--danger-subtle);
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.project-danger-zone > div:first-child {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.project-danger-zone strong {
|
||||
color: var(--danger);
|
||||
font-size: var(--font-body);
|
||||
}
|
||||
|
||||
.project-danger-zone p,
|
||||
.project-danger-zone small {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-caption);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.project-danger-zone > .danger-button {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.project-delete-confirmation {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.project-delete-confirmation > div {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.project-delete-confirmation .danger-button,
|
||||
.project-delete-confirmation .secondary-button {
|
||||
align-self: auto;
|
||||
}
|
||||
|
||||
.new-chat {
|
||||
display: flex;
|
||||
min-width: 248px;
|
||||
@@ -798,9 +848,49 @@ textarea:focus-visible {
|
||||
|
||||
.workspace-files__row {
|
||||
padding: var(--space-2);
|
||||
padding-right: 38px;
|
||||
grid-template-columns: auto auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.workspace-files__entry {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.workspace-files__open-entry {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: var(--space-1);
|
||||
display: grid;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(-50%);
|
||||
transition:
|
||||
opacity var(--motion-fast) ease-out,
|
||||
background var(--motion-fast) ease-out,
|
||||
color var(--motion-fast) ease-out;
|
||||
}
|
||||
|
||||
.workspace-files__entry:hover .workspace-files__open-entry,
|
||||
.workspace-files__entry:focus-within .workspace-files__open-entry {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.workspace-files__open-entry:hover {
|
||||
background: var(--accent-selected);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.workspace-files__changed-row:hover:not(:disabled),
|
||||
.workspace-files__row:hover {
|
||||
background: var(--accent-subtle);
|
||||
@@ -1853,6 +1943,16 @@ textarea:focus-visible {
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.message-blocks {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.message-blocks .message-reasoning,
|
||||
.message-blocks .tool-activity {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.markdown-content > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
@@ -2152,6 +2252,102 @@ textarea:focus-visible {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.agent-question-card {
|
||||
display: flex;
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--border-control);
|
||||
border-radius: var(--radius-card);
|
||||
margin-top: var(--space-3);
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-primary);
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.agent-question-card > header,
|
||||
.agent-question-card > footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.agent-question-card > header {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.agent-question-card > footer {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.agent-question-card fieldset {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-control);
|
||||
margin: 0;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.agent-question-card legend {
|
||||
padding: 0 var(--space-1);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-body);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.agent-question-card legend span {
|
||||
display: block;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-caption);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.agent-question-card fieldset > label {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.agent-question-card fieldset > label > span {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.agent-question-card fieldset > label strong {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-body);
|
||||
}
|
||||
|
||||
.agent-question-card fieldset > label small {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
|
||||
.agent-question-card__custom {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.agent-question-card__custom input {
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
padding: 0 var(--space-3);
|
||||
border: 1px solid var(--border-control);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.agent-question-card__error {
|
||||
margin: 0;
|
||||
color: var(--danger);
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
|
||||
.composer-wrap {
|
||||
padding:
|
||||
8px
|
||||
|
||||
@@ -61,6 +61,59 @@ export type ConversationAttachment = z.infer<
|
||||
typeof conversationAttachmentSchema
|
||||
>
|
||||
|
||||
export const conversationToolActivitySchema = z
|
||||
.object({
|
||||
callId: z.string().max(256).optional(),
|
||||
name: z.string().max(200),
|
||||
state: z.enum([
|
||||
'pending',
|
||||
'running',
|
||||
'completed',
|
||||
'failed',
|
||||
'recoverable',
|
||||
'cancelled',
|
||||
'interrupted'
|
||||
]),
|
||||
summary: z.string().max(2_000),
|
||||
error: z.string().max(2_000).optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const conversationMessageBlockSchema = z.discriminatedUnion('type', [
|
||||
z
|
||||
.object({
|
||||
id: assistantIdSchema,
|
||||
type: z.literal('text'),
|
||||
content: z.string().min(1).max(1_000_000)
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
id: assistantIdSchema,
|
||||
type: z.literal('reasoning'),
|
||||
content: z.string().min(1).max(1_000_000)
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
id: assistantIdSchema,
|
||||
type: z.literal('tool'),
|
||||
tool: conversationToolActivitySchema
|
||||
})
|
||||
.strict()
|
||||
])
|
||||
|
||||
export const conversationMessageBlocksSchema = z
|
||||
.array(conversationMessageBlockSchema)
|
||||
.max(500)
|
||||
|
||||
export type ConversationToolActivity = z.infer<
|
||||
typeof conversationToolActivitySchema
|
||||
>
|
||||
export type ConversationMessageBlock = z.infer<
|
||||
typeof conversationMessageBlockSchema
|
||||
>
|
||||
|
||||
export const conversationSnapshotSchema = z
|
||||
.object({
|
||||
id: assistantIdSchema,
|
||||
@@ -76,31 +129,11 @@ export const conversationSnapshotSchema = z
|
||||
role: z.enum(['user', 'assistant']),
|
||||
content: z.string().max(1_000_000),
|
||||
reasoning: z.string().optional(),
|
||||
blocks: conversationMessageBlocksSchema.optional(),
|
||||
createdAt: z.number().int().nonnegative(),
|
||||
state: z.enum(['streaming', 'complete', 'error']),
|
||||
status: z.string().max(4_000).optional(),
|
||||
tools: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
callId: z.string().max(256).optional(),
|
||||
name: z.string().max(200),
|
||||
state: z.enum([
|
||||
'pending',
|
||||
'running',
|
||||
'completed',
|
||||
'failed',
|
||||
'recoverable',
|
||||
'cancelled',
|
||||
'interrupted'
|
||||
]),
|
||||
summary: z.string().max(2_000),
|
||||
error: z.string().max(2_000).optional()
|
||||
})
|
||||
.strict()
|
||||
)
|
||||
.max(100)
|
||||
.optional(),
|
||||
tools: z.array(conversationToolActivitySchema).max(100).optional(),
|
||||
sources: z.array(z.string().max(8_192)).max(100).optional(),
|
||||
sourceReferences: z
|
||||
.array(
|
||||
|
||||
@@ -51,6 +51,9 @@ export const skillIdSchema = z
|
||||
.max(128)
|
||||
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
|
||||
|
||||
export const skillImportKindSchema = z.enum(['directory', 'zip'])
|
||||
export type SkillImportKind = z.infer<typeof skillImportKindSchema>
|
||||
|
||||
export const skillToggleInputSchema = z
|
||||
.object({
|
||||
skillId: skillIdSchema,
|
||||
|
||||
+51
-2
@@ -7,7 +7,8 @@ import type {
|
||||
CapabilitySnapshot,
|
||||
ComputerCapabilityId,
|
||||
McpServerInput,
|
||||
McpServerTestResult
|
||||
McpServerTestResult,
|
||||
SkillImportKind
|
||||
} from './capability-contracts'
|
||||
import {
|
||||
assistantIdSchema,
|
||||
@@ -93,6 +94,29 @@ export const workspaceFileRequestSchema = z
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const workspaceOpenPathRequestSchema = z
|
||||
.object({
|
||||
projectId: assistantIdSchema,
|
||||
path: workspaceRelativePathSchema,
|
||||
type: z.enum(['file', 'directory'])
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const agentQuestionAnswerSchema = z
|
||||
.array(z.string().trim().min(1).max(2_000))
|
||||
.max(20)
|
||||
|
||||
export const agentQuestionResponseSchema = z
|
||||
.object({
|
||||
questionId: z.string().trim().min(1).max(128),
|
||||
answers: z.array(agentQuestionAnswerSchema).max(4)
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type AgentQuestionAnswer = z.infer<
|
||||
typeof agentQuestionAnswerSchema
|
||||
>
|
||||
|
||||
export const conversationIdSchema = z.string().min(1).max(128)
|
||||
|
||||
export const agentRequestSchema = z
|
||||
@@ -648,6 +672,21 @@ export type AgentEvent =
|
||||
argumentSummary?: string
|
||||
allowPermanent?: boolean
|
||||
}
|
||||
| {
|
||||
requestId: string
|
||||
type: 'question'
|
||||
questionId: string
|
||||
questions: Array<{
|
||||
header: string
|
||||
question: string
|
||||
options: Array<{
|
||||
label: string
|
||||
description: string
|
||||
}>
|
||||
multiple: boolean
|
||||
custom: boolean
|
||||
}>
|
||||
}
|
||||
| {
|
||||
requestId: string
|
||||
type: 'artifact'
|
||||
@@ -873,6 +912,10 @@ export type DesktopApi = {
|
||||
approvalId: string,
|
||||
decision: ApprovalDecision
|
||||
) => Promise<void>
|
||||
respondQuestion: (
|
||||
questionId: string,
|
||||
answers?: AgentQuestionAnswer[]
|
||||
) => Promise<void>
|
||||
onEvent: (listener: (event: AgentEvent) => void) => () => void
|
||||
}
|
||||
browser: {
|
||||
@@ -949,6 +992,7 @@ export type DesktopApi = {
|
||||
input: ProjectCreateInput
|
||||
) => Promise<AssistantProject>
|
||||
setArchived: (projectId: string, archived: boolean) => Promise<void>
|
||||
delete: (projectId: string, confirmation: string) => Promise<void>
|
||||
}
|
||||
conversations: {
|
||||
list: () => Promise<ConversationSnapshot[]>
|
||||
@@ -964,6 +1008,11 @@ export type DesktopApi = {
|
||||
projectId: string,
|
||||
path: string
|
||||
) => Promise<WorkspaceFilePreview>
|
||||
openPath: (
|
||||
projectId: string,
|
||||
path: string,
|
||||
type: 'file' | 'directory'
|
||||
) => Promise<void>
|
||||
}
|
||||
tasks: {
|
||||
list: () => Promise<AssistantTask[]>
|
||||
@@ -1026,7 +1075,7 @@ export type DesktopApi = {
|
||||
}
|
||||
capabilities: {
|
||||
getSnapshot: () => Promise<CapabilitySnapshot>
|
||||
importSkill: () => Promise<CapabilitySnapshot>
|
||||
importSkill: (kind: SkillImportKind) => Promise<CapabilitySnapshot>
|
||||
removeSkill: (skillId: string) => Promise<CapabilitySnapshot>
|
||||
setSkillEnabled: (
|
||||
skillId: string,
|
||||
|
||||
@@ -14,6 +14,7 @@ export const ipcChannels = {
|
||||
agentRun: 'agent:run',
|
||||
agentCancel: 'agent:cancel',
|
||||
agentApprovalRespond: 'agent:approval:respond',
|
||||
agentQuestionRespond: 'agent:question:respond',
|
||||
agentEvent: 'agent:event',
|
||||
browserStop: 'browser:stop',
|
||||
browserState: 'browser:state',
|
||||
@@ -52,11 +53,13 @@ export const ipcChannels = {
|
||||
projectsCreate: 'projects:create',
|
||||
projectsUpdate: 'projects:update',
|
||||
projectsSetArchived: 'projects:set-archived',
|
||||
projectsDelete: 'projects:delete',
|
||||
conversationsList: 'conversations:list',
|
||||
conversationsReplace: 'conversations:replace',
|
||||
workspaceChangesGet: 'workspace:changes:get',
|
||||
workspaceDirectoryList: 'workspace:directory:list',
|
||||
workspaceFileRead: 'workspace:file:read',
|
||||
workspacePathOpen: 'workspace:path:open',
|
||||
tasksList: 'tasks:list',
|
||||
tasksSetStatus: 'tasks:set-status',
|
||||
tokenUsageSummary: 'usage:token-summary',
|
||||
|
||||
Reference in New Issue
Block a user