From 2c715e5e81c61d08796fb4fdf5b9239893845bf3 Mon Sep 17 00:00:00 2001 From: lofyer Date: Fri, 7 Aug 2026 13:11:45 +0800 Subject: [PATCH] chore: prepare GoodBuddy 0.8.6 --- package-lock.json | 4 +- package.json | 2 +- src/main/agent/continue-host-adapter.test.ts | 76 ++- src/main/agent/continue-host-adapter.ts | 156 +++++- src/main/agent/continue-runtime.test.ts | 100 +++- src/main/agent/continue-runtime.ts | 131 ++++- src/main/agent/opencode-runtime.test.ts | 119 +++++ src/main/agent/opencode-runtime.ts | 162 +++++- src/main/agent/runtime-controller.ts | 15 + src/main/agent/runtime.ts | 5 + .../agent/selected-runtime-manager.test.ts | 38 ++ src/main/agent/selected-runtime-manager.ts | 31 +- src/main/assistant/assistant-database.test.ts | 132 +++++ src/main/assistant/assistant-database.ts | 123 ++++- .../workspace-changes-service.test.ts | 4 +- .../assistant/workspace-changes-service.ts | 25 +- .../capabilities/capability-service.test.ts | 57 ++ src/main/capabilities/capability-service.ts | 200 ++++++- src/main/desktop-notification.test.ts | 48 ++ src/main/desktop-notification.ts | 20 + src/main/index.ts | 7 +- src/main/ipc.test.ts | 44 +- src/main/ipc.ts | 189 +++++-- src/preload/index.ts | 34 +- src/renderer/src/AgentQuestionCard.tsx | 171 ++++++ src/renderer/src/App.test.tsx | 239 ++++++++- src/renderer/src/App.tsx | 492 +++++++++++++++--- src/renderer/src/ProjectSwitcher.tsx | 298 +++++++++-- .../src/RightAssistantSidebar.resize.test.tsx | 1 + src/renderer/src/RightAssistantSidebar.tsx | 14 +- src/renderer/src/SettingsPanel.test.tsx | 15 +- src/renderer/src/SettingsPanel.tsx | 10 +- src/renderer/src/SkillsSettingsSection.tsx | 17 +- src/renderer/src/WorkspaceFilesPanel.test.tsx | 20 +- src/renderer/src/WorkspaceFilesPanel.tsx | 104 ++-- src/renderer/src/styles.css | 198 ++++++- src/shared/assistant-contracts.ts | 77 ++- src/shared/capability-contracts.ts | 3 + src/shared/contracts.ts | 53 +- src/shared/ipc-channels.ts | 3 + 40 files changed, 3079 insertions(+), 358 deletions(-) create mode 100644 src/main/desktop-notification.test.ts create mode 100644 src/main/desktop-notification.ts create mode 100644 src/renderer/src/AgentQuestionCard.tsx diff --git a/package-lock.json b/package-lock.json index ee38a87..aa8fc58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 968affb..5909790 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/main/agent/continue-host-adapter.test.ts b/src/main/agent/continue-host-adapter.test.ts index be5ab5d..2160a3c 100644 --- a/src/main/agent/continue-host-adapter.test.ts +++ b/src/main/agent/continue-host-adapter.test.ts @@ -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' }) diff --git a/src/main/agent/continue-host-adapter.ts b/src/main/agent/continue-host-adapter.ts index a47eca3..ec67b9a 100644 --- a/src/main/agent/continue-host-adapter.ts +++ b/src/main/agent/continue-host-adapter.ts @@ -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 @@ -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 } 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 } diff --git a/src/main/agent/continue-runtime.test.ts b/src/main/agent/continue-runtime.test.ts index 1f22bc5..5ac2b06 100644 --- a/src/main/agent/continue-runtime.test.ts +++ b/src/main/agent/continue-runtime.test.ts @@ -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 () => { diff --git a/src/main/agent/continue-runtime.ts b/src/main/agent/continue-runtime.ts index 4a2b849..c704f53 100644 --- a/src/main/agent/continue-runtime.ts +++ b/src/main/agent/continue-runtime.ts @@ -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 { 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() 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((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 diff --git a/src/main/agent/opencode-runtime.test.ts b/src/main/agent/opencode-runtime.test.ts index 4ae77b4..70934db 100644 --- a/src/main/agent/opencode-runtime.test.ts +++ b/src/main/agent/opencode-runtime.test.ts @@ -153,6 +153,14 @@ function runClient(events: Record[]) { 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[]) { permission: { reply: permissionReply }, + question: { + reply: questionReply, + reject: questionReject + }, mcp: { add: vi .fn() @@ -218,6 +230,8 @@ function runClient(events: Record[]) { 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() }) diff --git a/src/main/agent/opencode-runtime.ts b/src/main/agent/opencode-runtime.ts index 11c5939..da54587 100644 --- a/src/main/agent/opencode-runtime.ts +++ b/src/main/agent/opencode-runtime.ts @@ -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 @@ -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 >() + private readonly pendingQuestions = new Map< + string, + { + client: OpencodeClient + directory: string + questionCount: number + } + >() private embeddedRunTail: Promise = Promise.resolve() private readonly dependencies: OpenCodeRuntimeDependencies @@ -904,6 +982,7 @@ export class OpenCodeRuntime implements AgentRuntime { } >() const reasoningPartIds = new Set() + const reportedQuestionIds = new Set() 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 { + 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 { + this.pendingQuestions.clear() const startingChild = this.startingChild this.startingChild = undefined if (startingChild) { diff --git a/src/main/agent/runtime-controller.ts b/src/main/agent/runtime-controller.ts index 16ca809..2370eef 100644 --- a/src/main/agent/runtime-controller.ts +++ b/src/main/agent/runtime-controller.ts @@ -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 { + 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 { slot.retiring = true if (!slot.disposal) { diff --git a/src/main/agent/runtime.ts b/src/main/agent/runtime.ts index 50298fa..be9c537 100644 --- a/src/main/agent/runtime.ts +++ b/src/main/agent/runtime.ts @@ -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 + respondToQuestion?( + questionId: string, + answers?: AgentQuestionAnswer[] + ): Promise releaseConversation?(conversationId: string): Promise dispose(): Promise } diff --git a/src/main/agent/selected-runtime-manager.test.ts b/src/main/agent/selected-runtime-manager.test.ts index 3eeae8d..4f6e1dc 100644 --- a/src/main/agent/selected-runtime-manager.test.ts +++ b/src/main/agent/selected-runtime-manager.test.ts @@ -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() diff --git a/src/main/agent/selected-runtime-manager.ts b/src/main/agent/selected-runtime-manager.ts index 0a47ac0..12238d4 100644 --- a/src/main/agent/selected-runtime-manager.ts +++ b/src/main/agent/selected-runtime-manager.ts @@ -7,7 +7,10 @@ import type { AgentRuntime } from './runtime' import { AgentRuntimeController } from './runtime-controller' export type SelectedRuntimeResolver = { - getRuntime(selection: AgentRuntimeSelection): Promise + getRuntime( + selection: AgentRuntimeSelection, + workspacePath?: string + ): Promise getStatus( selection: AgentRuntimeSelection ): Promise @@ -15,6 +18,7 @@ export type SelectedRuntimeResolver = { selection: AgentRuntimeSelection ): Promise releaseConversation(conversationId: string): Promise + reset?(): Promise } 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 ) {} async getRuntime( - selection: AgentRuntimeSelection + selection: AgentRuntimeSelection, + workspacePath?: string ): Promise { 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 diff --git a/src/main/assistant/assistant-database.test.ts b/src/main/assistant/assistant-database.test.ts index f011beb..8876ccc 100644 --- a/src/main/assistant/assistant-database.test.ts +++ b/src/main/assistant/assistant-database.test.ts @@ -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' ], diff --git a/src/main/assistant/assistant-database.ts b/src/main/assistant/assistant-database.ts index 288103c..11bd905 100644 --- a/src/main/assistant/assistant-database.ts +++ b/src/main/assistant/assistant-database.ts @@ -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, diff --git a/src/main/assistant/workspace-changes-service.test.ts b/src/main/assistant/workspace-changes-service.test.ts index 6fcbf3e..9eacbb9 100644 --- a/src/main/assistant/workspace-changes-service.test.ts +++ b/src/main/assistant/workspace-changes-service.test.ts @@ -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() }) }) diff --git a/src/main/assistant/workspace-changes-service.ts b/src/main/assistant/workspace-changes-service.ts index 3eeb22e..ffddbed 100644 --- a/src/main/assistant/workspace-changes-service.ts +++ b/src/main/assistant/workspace-changes-service.ts @@ -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 { + 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, [ diff --git a/src/main/capabilities/capability-service.test.ts b/src/main/capabilities/capability-service.test.ts index 7501fb5..4e3f70c 100644 --- a/src/main/capabilities/capability-service.test.ts +++ b/src/main/capabilities/capability-service.test.ts @@ -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, { diff --git a/src/main/capabilities/capability-service.ts b/src/main/capabilities/capability-service.ts index 0f49ef9..4b1d226 100644 --- a/src/main/capabilities/capability-service.ts +++ b/src/main/capabilities/capability-service.ts @@ -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 { async function readSkill( directoryPath: string, source: SkillSummary['source'], - expectedId = basename(directoryPath) + expectedId: string | null = basename(directoryPath) ): Promise> { 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 { + 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() + const normalizedPaths = new Set() + let fileCount = 0 + let totalBytes = 0 + let files: Record + 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 @@ -819,21 +946,13 @@ export class CapabilityService { importSkill(sourcePath: string): Promise { 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() }) } diff --git a/src/main/desktop-notification.test.ts b/src/main/desktop-notification.test.ts new file mode 100644 index 0000000..877c0e7 --- /dev/null +++ b/src/main/desktop-notification.test.ts @@ -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() + }) +}) diff --git a/src/main/desktop-notification.ts b/src/main/desktop-notification.ts new file mode 100644 index 0000000..8279786 --- /dev/null +++ b/src/main/desktop-notification.ts @@ -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 +} diff --git a/src/main/index.ts b/src/main/index.ts index 26e2e76..86da6a2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -382,14 +382,17 @@ if (hasSingleInstanceLock) { ) } const createSelectedRuntime = async ( - selection: AgentRuntimeSelection + selection: AgentRuntimeSelection, + workspacePath?: string ): Promise => { const resolved = applyRuntimeSelection( await settingsStore.getResolvedSettings(), selection ) return createRuntimeWithCapabilities( - resolved.settings, + workspacePath + ? { ...resolved.settings, workspacePath } + : resolved.settings, resolved.target ) } diff --git a/src/main/ipc.test.ts b/src/main/ipc.test.ts index 06ce6ad..c4837e8 100644 --- a/src/main/ipc.test.ts +++ b/src/main/ipc.test.ts @@ -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>>(() => []), - 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 diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 852f81f..d5507a8 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -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 { const activeRequests = new Map() + const pendingAgentQuestions = new Map< + string, + { requestId: string; runtime: AgentRuntime } + >() const heartbeatControllers = new Set() let shuttingDown = false let executionPaused = false @@ -506,6 +521,21 @@ export function registerIpcHandlers( ) return execution } + const resolveRequestRuntime = async ( + request: Pick + ): Promise => { + 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 => { + async (event, input: unknown): Promise => { 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() } diff --git a/src/preload/index.ts b/src/preload/index.ts index 824f6d0..6d2f0ac 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 + }) as Promise, + 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, - importSkill: () => + importSkill: (kind) => ipcRenderer.invoke( - ipcChannels.capabilitiesImportSkill + ipcChannels.capabilitiesImportSkill, + kind ) as Promise, removeSkill: (skillId) => ipcRenderer.invoke( diff --git a/src/renderer/src/AgentQuestionCard.tsx b/src/renderer/src/AgentQuestionCard.tsx new file mode 100644 index 0000000..1117935 --- /dev/null +++ b/src/renderer/src/AgentQuestionCard.tsx @@ -0,0 +1,171 @@ +import { CircleHelp } from 'lucide-react' +import { useMemo, useState } from 'react' +import type { + AgentEvent, + AgentQuestionAnswer +} from '../../shared/contracts' + +type AgentQuestion = Extract + +type AgentQuestionCardProps = { + value: AgentQuestion + onReject: () => Promise + onSubmit: (answers: AgentQuestionAnswer[]) => Promise +} + +export function AgentQuestionCard({ + value, + onReject, + onSubmit +}: AgentQuestionCardProps): React.JSX.Element { + const [selected, setSelected] = useState( + value.questions.map(() => []) + ) + const [custom, setCustom] = useState( + 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): Promise => { + setSubmitting(true) + setError('') + try { + await action() + } catch (reason) { + setError( + reason instanceof Error ? reason.message : '回答提交失败,请重试' + ) + setSubmitting(false) + } + } + + return ( +
{ + event.preventDefault() + if (complete) { + void run(() => onSubmit(answers)) + } + }} + > +
+
+ {value.questions.map((question, questionIndex) => ( +
+ + {question.header} + {question.question} + + {question.options.map((option) => { + const checked = + selected[questionIndex]?.includes(option.label) ?? false + return ( + + ) + })} + {(question.custom || question.options.length === 0) && ( + + )} +
+ ))} + {error && ( +

+ {error} +

+ )} +
+ + +
+
+ ) +} diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 512da04..4ffd525 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -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() 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() + + 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() + + 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() + + 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() + 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() diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index d3a0bbe..7df5ac7 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 sources?: string[] sourceReferences?: KnowledgeSearchReference[] artifactIds?: string[] @@ -394,6 +388,89 @@ const subagentStateLabels: Record = { 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 => { + 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 => { + 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 => { 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 => { + 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 => { + await window.goodbuddy.agent.respondQuestion(questionId, answers) + updateMessage(conversationId, messageId, (message) => ({ + ...message, + question: undefined, + status: answers ? '回答已提交,OpenCode 正在继续执行' : '已跳过问题' + })) + } + const addContext = async ( action: () => Promise ): Promise => { @@ -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" > - + 对话 + - {creating && ( + {dialogMode && (
{ - if ( - event.currentTarget === event.target && - !saving && - !archiving - ) { - setError(undefined) - setCreating(false) + if (event.currentTarget === event.target && !busy) { + closeDialog() } }} >
- 新建项目 + + {dialogMode === 'create' ? '新建项目' : '项目设置'} + + ) : ( +
+ +
+ + +
+
+ )} + {projects.length <= 1 && ( + 至少需要保留一个可用项目。 + )} + + )}
- {projects.length > 1 && activeProjectId && ( - - )} + {dialogMode === 'settings' && + projects.length > 1 && + activeProjectId && ( + + )} +
diff --git a/src/renderer/src/RightAssistantSidebar.resize.test.tsx b/src/renderer/src/RightAssistantSidebar.resize.test.tsx index 2b47a02..38d3ed5 100644 --- a/src/renderer/src/RightAssistantSidebar.resize.test.tsx +++ b/src/renderer/src/RightAssistantSidebar.resize.test.tsx @@ -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)} diff --git a/src/renderer/src/RightAssistantSidebar.tsx b/src/renderer/src/RightAssistantSidebar.tsx index cfd243a..24840bc 100644 --- a/src/renderer/src/RightAssistantSidebar.tsx +++ b/src/renderer/src/RightAssistantSidebar.tsx @@ -104,6 +104,10 @@ type RightAssistantSidebarProps = { path: string ) => Promise onLoadWorkspaceFile: (path: string) => Promise + onOpenWorkspaceEntry: ( + path: string, + type: 'file' | 'directory' + ) => Promise onRemoveMemory: (memoryId: string) => Promise 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({ <>

- 浏览当前项目文件、检查未提交 Git 变更,并查看 Agent - 的工具活动。 + 浏览当前项目文件,并查看 Agent 的工具活动。Git + 项目还会显示未提交更改。

@@ -1088,6 +1093,7 @@ export function RightAssistantSidebar({ +

diff --git a/src/renderer/src/WorkspaceFilesPanel.test.tsx b/src/renderer/src/WorkspaceFilesPanel.test.tsx index f038029..77c4094 100644 --- a/src/renderer/src/WorkspaceFilesPanel.test.tsx +++ b/src/renderer/src/WorkspaceFilesPanel.test.tsx @@ -37,25 +37,39 @@ describe('WorkspaceFilesPanel', () => { } ) const onOpenFile = vi.fn() + const onOpenEntry = vi.fn(async () => undefined) render( ) 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', () => { undefined)} onOpenFile={vi.fn()} projectId="00000000-0000-4000-8000-000000000101" /> @@ -94,6 +109,7 @@ describe('WorkspaceFilesPanel', () => { undefined)} onOpenFile={vi.fn()} projectId="00000000-0000-4000-8000-000000000102" /> diff --git a/src/renderer/src/WorkspaceFilesPanel.tsx b/src/renderer/src/WorkspaceFilesPanel.tsx index 79cc926..43d3983 100644 --- a/src/renderer/src/WorkspaceFilesPanel.tsx +++ b/src/renderer/src/WorkspaceFilesPanel.tsx @@ -1,6 +1,7 @@ import { ChevronDown, ChevronRight, + FileSearch, FileText, Folder, FolderOpen @@ -23,6 +24,10 @@ type WorkspaceFilesPanelProps = { changedFiles: WorkspaceChangedFile[] onListDirectory: (path: string) => Promise onOpenFile: (path: string) => void + onOpenEntry: ( + path: string, + type: WorkspaceDirectoryEntry['type'] + ) => Promise } 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 (
- +
+ + +
{expanded && (
{listing?.entries.map((child) => @@ -216,22 +248,32 @@ export function WorkspaceFilesPanel({ ) } return ( - +
+ + +
) } diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index c23dd9c..0f27430 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -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 diff --git a/src/shared/assistant-contracts.ts b/src/shared/assistant-contracts.ts index 0ce6bf9..73cdb58 100644 --- a/src/shared/assistant-contracts.ts +++ b/src/shared/assistant-contracts.ts @@ -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( diff --git a/src/shared/capability-contracts.ts b/src/shared/capability-contracts.ts index 26263ea..4cacb89 100644 --- a/src/shared/capability-contracts.ts +++ b/src/shared/capability-contracts.ts @@ -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 + export const skillToggleInputSchema = z .object({ skillId: skillIdSchema, diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index 99f9579..a8e0669 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -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 + respondQuestion: ( + questionId: string, + answers?: AgentQuestionAnswer[] + ) => Promise onEvent: (listener: (event: AgentEvent) => void) => () => void } browser: { @@ -949,6 +992,7 @@ export type DesktopApi = { input: ProjectCreateInput ) => Promise setArchived: (projectId: string, archived: boolean) => Promise + delete: (projectId: string, confirmation: string) => Promise } conversations: { list: () => Promise @@ -964,6 +1008,11 @@ export type DesktopApi = { projectId: string, path: string ) => Promise + openPath: ( + projectId: string, + path: string, + type: 'file' | 'directory' + ) => Promise } tasks: { list: () => Promise @@ -1026,7 +1075,7 @@ export type DesktopApi = { } capabilities: { getSnapshot: () => Promise - importSkill: () => Promise + importSkill: (kind: SkillImportKind) => Promise removeSkill: (skillId: string) => Promise setSkillEnabled: ( skillId: string, diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index 93c8a9e..c91e92e 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -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',