feat: open local runtime clients from the composer
Add composer shortcuts for local Continue and OpenCode terminals and the official DeepSeek Harness Web client, using the current project and model bridge. Manage browser service reuse, stop actions and terminal cleanup. Upgrade DeepSeek Harness to 0.1.7-rc.2 and Cordis to 4.0.4. Include the coordinated IPC handler extraction and related tests and documentation. Remote native-client coordinator wiring, managed Node consolidation, Ask MCP mappings and full desktop/package acceptance remain unfinished; this commit records the implemented local workflow and supporting SSH groundwork. Validation: typecheck and lint passed; 267 focused tests passed and 5 skipped. No new real-provider calls were made for this commit. Prior real-model evidence and remaining acceptance work are documented in the feature progress files. Release note: 本地项目选择 Continue 或 OpenCode 后,可从输入框下方打开原生终端;选择 DS 后,可打开使用当前项目和模型的官方 Web 客户端。客户端使用独立会话,DS 网页关闭后服务继续运行,可在 GoodBuddy 中停止;远程入口尚未完成。
This commit is contained in:
@@ -9,7 +9,7 @@ export type BundledRuntimePaths = {
|
||||
}
|
||||
|
||||
export const bundledContinueVersion = '1.5.47'
|
||||
export const bundledDeepSeekHarnessVersion = '0.1.2-rc.1'
|
||||
export const bundledDeepSeekHarnessVersion = '0.1.7-rc.2'
|
||||
|
||||
export function resolveBundledRuntimePaths(input: {
|
||||
appPath: string
|
||||
|
||||
@@ -126,19 +126,13 @@ function toolResultText(
|
||||
callId: string
|
||||
): string | undefined {
|
||||
for (const message of options.messages) {
|
||||
for (const block of message.content) {
|
||||
if (
|
||||
block.type !== 'tool-result' ||
|
||||
block.toolCallId !== callId
|
||||
) {
|
||||
continue
|
||||
}
|
||||
return block.content
|
||||
if (message.role === 'tool' && message.toolCallId === callId) {
|
||||
return message.content
|
||||
.filter(
|
||||
(
|
||||
content
|
||||
): content is Extract<
|
||||
(typeof block.content)[number],
|
||||
(typeof message.content)[number],
|
||||
{ type: 'text' }
|
||||
> => content.type === 'text'
|
||||
)
|
||||
@@ -154,7 +148,7 @@ function latestUserText(options: GenerateOptions): string {
|
||||
.filter(
|
||||
(message) =>
|
||||
message.role === 'user' &&
|
||||
message.source.kind === 'user'
|
||||
message.source?.kind === 'user'
|
||||
)
|
||||
.flatMap((message) =>
|
||||
message.content
|
||||
@@ -396,7 +390,7 @@ function createInProcessLaunch(
|
||||
model: options.model,
|
||||
supportsImageInput: options.supportsImageInput,
|
||||
requestHeaders: options.requestHeaders,
|
||||
harnessVersion: '0.1.2-rc.1',
|
||||
harnessVersion: '0.1.7-rc.2',
|
||||
credentialRefs: options.credentialRefs,
|
||||
skillPackages: options.skillPackages,
|
||||
extensionPackages: options.extensionPackages,
|
||||
@@ -838,9 +832,14 @@ describe('DeepSeek Harness real ACP control-plane E2E', () => {
|
||||
)
|
||||
|
||||
expect(observedRequest?.maxTokens).toBeUndefined()
|
||||
expect(observedRequest?.system).toContain(
|
||||
'act through the available tools'
|
||||
)
|
||||
expect(
|
||||
observedRequest?.messages
|
||||
.filter((message) => message.role === 'system')
|
||||
.flatMap((message) => message.content)
|
||||
.filter((block) => block.type === 'text')
|
||||
.map((block) => block.text)
|
||||
.join('\n')
|
||||
).toContain('act through the available tools')
|
||||
expect(reasoning).toHaveLength(8)
|
||||
expect(
|
||||
reasoning.map((event) => event.delta).join('')
|
||||
|
||||
@@ -159,7 +159,7 @@ function setup(
|
||||
if (method === 'goodbuddy/handshake') {
|
||||
return {
|
||||
controlProtocolVersion: 1,
|
||||
harnessVersion: '0.1.2-rc.1',
|
||||
harnessVersion: '0.1.7-rc.2',
|
||||
acpProtocolVersion: 1,
|
||||
supports: {
|
||||
cancellation: true,
|
||||
|
||||
@@ -95,7 +95,7 @@ describe.skipIf(!enabled)('DSH marketplace live E2E', () => {
|
||||
api: 'openai-completions',
|
||||
provider: 'goodbuddy',
|
||||
model: 'deepseek-test',
|
||||
harnessVersion: '0.1.2-rc.1',
|
||||
harnessVersion: '0.1.7-rc.2',
|
||||
credentialRefs: ['GOODBUDDY_API_KEY'],
|
||||
skillPackages: [],
|
||||
extensionPackages: extensions,
|
||||
|
||||
@@ -22,7 +22,7 @@ function controlPlane() {
|
||||
provider: 'goodbuddy',
|
||||
model: 'deepseek-test',
|
||||
workspace: resolve('workspace'),
|
||||
harnessVersion: '0.1.2-rc.1',
|
||||
harnessVersion: '0.1.7-rc.2',
|
||||
execution: { mode: 'host' },
|
||||
credentialRefs: ['GOODBUDDY_API_KEY'],
|
||||
skills: []
|
||||
@@ -75,7 +75,7 @@ function stubAgentContext() {
|
||||
provider: 'goodbuddy',
|
||||
model: 'deepseek-test',
|
||||
workspace: resolve('workspace'),
|
||||
harnessVersion: '0.1.2-rc.1',
|
||||
harnessVersion: '0.1.7-rc.2',
|
||||
execution: { mode: 'host' },
|
||||
credentialRefs: ['GOODBUDDY_API_KEY'],
|
||||
skills: [],
|
||||
@@ -169,7 +169,7 @@ describe('GoodBuddy Harness internal control plane', () => {
|
||||
model: 'vision-test',
|
||||
supportsImageInput: true,
|
||||
workspace: resolve('workspace'),
|
||||
harnessVersion: '0.1.2-rc.1',
|
||||
harnessVersion: '0.1.7-rc.2',
|
||||
execution: { mode: 'host' },
|
||||
credentialRefs: ['GOODBUDDY_API_KEY'],
|
||||
skills: []
|
||||
@@ -337,37 +337,33 @@ describe('GoodBuddy Harness internal control plane', () => {
|
||||
it('delivers more than 4 MiB of cumulative output without cancelling', async () => {
|
||||
const { listeners, extNotification, handle, internals } =
|
||||
stubAgentContext()
|
||||
const sessionEvent = listeners.get('session/event')!
|
||||
const assistantStream = listeners.get('agent/assistant-stream')!
|
||||
for (let index = 0; index < 65; index++) {
|
||||
sessionEvent(
|
||||
handle.agent.session,
|
||||
{
|
||||
type: 'assistant/chunk',
|
||||
data: {
|
||||
chunk: {
|
||||
type: 'text-delta',
|
||||
text: 'x'.repeat(65_536)
|
||||
assistantStream({
|
||||
agent: handle.agent,
|
||||
frame: {
|
||||
type: 'chunk',
|
||||
chunk: {
|
||||
type: 'text-delta',
|
||||
text: 'x'.repeat(65_536)
|
||||
}
|
||||
}
|
||||
})
|
||||
assistantStream({
|
||||
agent: handle.agent,
|
||||
frame: {
|
||||
type: 'chunk',
|
||||
chunk: {
|
||||
type: 'usage',
|
||||
usage: {
|
||||
inputTokens: 1,
|
||||
outputTokens: 1,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
sessionEvent(
|
||||
handle.agent.session,
|
||||
{
|
||||
type: 'assistant/chunk',
|
||||
data: {
|
||||
chunk: {
|
||||
type: 'usage',
|
||||
usage: {
|
||||
inputTokens: 1,
|
||||
outputTokens: 1,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
await internals.sessions.get('session-output')!.inflight.eventTail
|
||||
}
|
||||
|
||||
|
||||
@@ -730,6 +730,26 @@ export class GoodBuddyHarnessControlPlane {
|
||||
}
|
||||
return next()
|
||||
})
|
||||
this.ctx.on('agent/assistant-stream', ({ agent, frame }) => {
|
||||
const record = this.sessions.get(agent.session.id)
|
||||
if (
|
||||
record?.handle.agent !== agent ||
|
||||
!record.inflight ||
|
||||
frame.type !== 'chunk'
|
||||
) {
|
||||
return
|
||||
}
|
||||
const chunk = frame.chunk
|
||||
if (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta') {
|
||||
this.queueDelta(
|
||||
agent.session.id,
|
||||
chunk.type === 'text-delta' ? 'text' : 'reasoning',
|
||||
chunk.text
|
||||
)
|
||||
} else if (chunk.type === 'usage') {
|
||||
this.queueUsage(agent.session.id, chunk.usage)
|
||||
}
|
||||
})
|
||||
this.ctx.on(
|
||||
'session/event',
|
||||
(session, event: SessionEvent) => {
|
||||
@@ -741,23 +761,7 @@ export class GoodBuddyHarnessControlPlane {
|
||||
if (!inflight) {
|
||||
return
|
||||
}
|
||||
if (event.type === 'assistant/chunk') {
|
||||
const chunk = event.data.chunk
|
||||
if (
|
||||
chunk.type === 'text-delta' ||
|
||||
chunk.type === 'reasoning-delta'
|
||||
) {
|
||||
this.queueDelta(
|
||||
session.header.id,
|
||||
chunk.type === 'text-delta'
|
||||
? 'text'
|
||||
: 'reasoning',
|
||||
chunk.text
|
||||
)
|
||||
} else if (chunk.type === 'usage') {
|
||||
this.queueUsage(session.header.id, chunk.usage)
|
||||
}
|
||||
} else if (event.type === 'tool/call') {
|
||||
if (event.type === 'tool/call') {
|
||||
this.queueEvent(session.header.id, {
|
||||
type: 'tool',
|
||||
callId: event.data.callId,
|
||||
@@ -769,15 +773,13 @@ export class GoodBuddyHarnessControlPlane {
|
||||
)
|
||||
})
|
||||
} else if (event.type === 'tool/result') {
|
||||
const toolResult = event.data.message.content.find(
|
||||
(content) => content.type === 'tool-result'
|
||||
)
|
||||
const toolResult = event.data.message
|
||||
this.queueEvent(session.header.id, {
|
||||
type: 'tool',
|
||||
callId: toolResult?.toolCallId ?? 'unknown-tool-call',
|
||||
callId: toolResult.toolCallId,
|
||||
name: 'tool',
|
||||
state:
|
||||
event.data.error || toolResult?.isError === true
|
||||
event.data.error || toolResult.isError === true
|
||||
? 'failed'
|
||||
: 'completed',
|
||||
output: boundedJson(event.data.message.content)
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { NativeClientCoordinator } from './native-client-coordinator'
|
||||
import { NativeDshWebClientService, type NativeDshWebClientOptions } from './native-dsh-web-client'
|
||||
import type { LocalToolRuntimeSelection } from '../../shared/local-tool-environment-contracts'
|
||||
|
||||
const coordinators: NativeClientCoordinator[] = []
|
||||
afterEach(async () => {
|
||||
await Promise.all(coordinators.splice(0).map(coordinator => coordinator.dispose()))
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function fixture() {
|
||||
const profile = { id: 'selected', name: 'Selected', protocol: 'openai-chat-completions',
|
||||
authentication: 'api-key', apiKey: 'secret', baseUrl: 'https://example.com/v1', modelName: 'selected-model' }
|
||||
const settings = { provider: 'deepseek-harness', modelProfiles: [profile], deepseekHarnessModelProfile: profile }
|
||||
const conversation = { projectId: 'project', knowledgeLibraryIds: ['library'], workMode: 'ask' }
|
||||
const project = { id: 'project', runtimeSelection: { provider: 'deepseek-harness', profileId: profile.id } }
|
||||
const application = { magicNotesEnabled: true, localToolEnvironment: { node: { source: 'managed' } as LocalToolRuntimeSelection } }
|
||||
const gateway = { start: vi.fn(), dispose: vi.fn(), grant: vi.fn(() => 'builtin-token'),
|
||||
grantCustomMcp: vi.fn(() => 'custom-token'), getEndpoint: () => 'http://127.0.0.1:1234/mcp',
|
||||
getAvailableToolNames: vi.fn(() => ['knowledge_search', 'obsidian_read_note']) }
|
||||
const handles = new Map<string, { id: string; url: string }>()
|
||||
const start = vi.spyOn(NativeDshWebClientService.prototype, 'start').mockImplementation(async () => {
|
||||
const handle = { id: `service-${handles.size}`, url: 'http://127.0.0.1:1234/?token=private' }
|
||||
handles.set(handle.id, handle)
|
||||
return handle
|
||||
})
|
||||
vi.spyOn(NativeDshWebClientService.prototype, 'get').mockImplementation(async id => handles.get(id) ?? null)
|
||||
const stop = vi.spyOn(NativeDshWebClientService.prototype, 'stop').mockImplementation(async id => { handles.delete(id) })
|
||||
const openExternal = vi.fn(async () => {})
|
||||
const coordinator = new NativeClientCoordinator({
|
||||
database: { getConversation: () => conversation, getProject: () => project },
|
||||
settingsStore: { getResolvedSettings: async () => settings },
|
||||
applicationSettingsStore: { get: async () => application },
|
||||
capabilities: { getRuntimeSkillContext: async () => ({ packages: [{ directory: '/skills/test', id: 'test', digest: 'one' }] }),
|
||||
getResolvedMcpServers: async () => [{ id: 'custom' }], getEnabledBuiltinMcpServerIds: async () => ['knowledge-base', 'obsidian'],
|
||||
getObsidianSettings: async () => ({}) },
|
||||
executionSpaceResolver: { resolveProject: () => ({ kind: 'local', rootPath: '/workspace', cacheIdentity: '/workspace' }) },
|
||||
terminalManager: { closeOwner: vi.fn() }, localEnvironment: { launchEnvironmentProvider: () => process.env },
|
||||
rootDirectory: '/clients', createGateway: () => gateway, openExternal
|
||||
} as unknown as ConstructorParameters<typeof NativeClientCoordinator>[0])
|
||||
coordinators.push(coordinator)
|
||||
return { coordinator, profile, conversation, application, gateway, start, stop, openExternal }
|
||||
}
|
||||
|
||||
describe('native client coordinator', () => {
|
||||
it('retains a ready service and its gateway after browser failure, then reopens and stops it', async () => {
|
||||
const { coordinator, gateway, start, stop, openExternal } = fixture()
|
||||
openExternal.mockRejectedValueOnce(new Error('Browser unavailable'))
|
||||
await expect(coordinator.open(1, 'conversation')).rejects.toThrow('Browser unavailable')
|
||||
expect(await coordinator.get(1, 'conversation')).toEqual({ serviceId: 'service-0' })
|
||||
expect(gateway.dispose).not.toHaveBeenCalled()
|
||||
expect(stop).not.toHaveBeenCalled()
|
||||
await expect(coordinator.open(1, 'conversation')).resolves.toEqual({ kind: 'browser', serviceId: 'service-0' })
|
||||
expect(start).toHaveBeenCalledOnce()
|
||||
await coordinator.stop(2, 'service-0')
|
||||
expect(stop).not.toHaveBeenCalled()
|
||||
await coordinator.stop(1, 'service-0')
|
||||
expect(gateway.dispose).toHaveBeenCalledOnce()
|
||||
expect(await coordinator.get(1, 'conversation')).toBeNull()
|
||||
})
|
||||
|
||||
it('matches get and reuse against current credentials, mode and Node selection', async () => {
|
||||
const { coordinator, profile, conversation, application, start } = fixture()
|
||||
await Promise.all([coordinator.open(1, 'conversation'), coordinator.open(1, 'conversation')])
|
||||
expect(start).toHaveBeenCalledOnce()
|
||||
profile.apiKey = 'rotated'
|
||||
expect(await coordinator.get(1, 'conversation')).toBeNull()
|
||||
await coordinator.open(1, 'conversation')
|
||||
expect(start.mock.calls[1]![0].profile.apiKey).toBe('rotated')
|
||||
expect(start.mock.calls[1]![0].configurationKey).not.toBe(start.mock.calls[0]![0].configurationKey)
|
||||
conversation.workMode = 'execute'
|
||||
expect(await coordinator.get(1, 'conversation')).toBeNull()
|
||||
conversation.workMode = 'ask'
|
||||
expect(await coordinator.get(1, 'conversation')).toEqual({ serviceId: 'service-1' })
|
||||
application.localToolEnvironment.node = { source: 'custom', executablePath: '/new/node' }
|
||||
expect(await coordinator.get(1, 'conversation')).toBeNull()
|
||||
expect(await coordinator.get(2, 'conversation')).toBeNull()
|
||||
})
|
||||
|
||||
it('maps gateway read grants in Ask and custom MCP only in Execute', async () => {
|
||||
const { coordinator, gateway, start, conversation } = fixture()
|
||||
await coordinator.open(1, 'conversation')
|
||||
expect(gateway.grantCustomMcp).not.toHaveBeenCalled()
|
||||
expect(start.mock.calls[0]![0]).toMatchObject({ skillDirectories: ['/skills/test'], mcpServers: [
|
||||
{ serverName: 'goodbuddy-0', readOnlyTools: ['knowledge_search', 'obsidian_read_note'] }
|
||||
] })
|
||||
conversation.workMode = 'execute'
|
||||
await coordinator.open(1, 'conversation')
|
||||
expect(gateway.grantCustomMcp).toHaveBeenCalledOnce()
|
||||
expect(start.mock.calls[1]![0].mcpServers).toHaveLength(2)
|
||||
await coordinator.closeOwner(1)
|
||||
await expect(coordinator.open(1, 'conversation')).rejects.toThrow('closed')
|
||||
})
|
||||
|
||||
it('releases the gateway after startup failure and allows retry', async () => {
|
||||
const { coordinator, start, gateway } = fixture()
|
||||
start.mockRejectedValueOnce(new Error('DS startup failed'))
|
||||
await expect(coordinator.open(1, 'conversation')).rejects.toThrow('DS startup failed')
|
||||
expect(gateway.dispose).toHaveBeenCalledOnce()
|
||||
await expect(coordinator.open(1, 'conversation')).resolves.toMatchObject({ kind: 'browser' })
|
||||
})
|
||||
|
||||
it('uses the current custom Node selection without installing a separate runtime', async () => {
|
||||
const { coordinator, application, start } = fixture()
|
||||
const resolved: string[] = []
|
||||
start.mockImplementation(async function (this: NativeDshWebClientService) {
|
||||
const options = (this as unknown as { options: NativeDshWebClientOptions }).options
|
||||
resolved.push((await options.resolveLaunchEnvironment()).nodeExecutablePath)
|
||||
return { id: `custom-${resolved.length}`, url: 'http://127.0.0.1:1234/' }
|
||||
})
|
||||
application.localToolEnvironment.node = { source: 'custom', executablePath: '/selected/node' }
|
||||
await coordinator.open(1, 'conversation')
|
||||
application.localToolEnvironment.node = { source: 'custom', executablePath: '/changed/node' }
|
||||
await coordinator.open(1, 'conversation')
|
||||
expect(resolved).toEqual(['/selected/node', '/changed/node'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,229 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { mkdir, stat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { normalizeInteractiveWorkMode } from '../../shared/assistant-contracts'
|
||||
import { getDefaultRuntimeSelection } from '../../shared/runtime-selection-contracts'
|
||||
import type { RuntimeNativeClientResult } from '../../shared/runtime-native-client-contracts'
|
||||
import type { AssistantDatabase } from '../assistant/assistant-database'
|
||||
import type { ApplicationSettingsStore } from '../application-settings-store'
|
||||
import type { CapabilityService } from '../capabilities/capability-service'
|
||||
import type { ExecutionSpaceResolver } from '../execution-space/execution-space-resolver'
|
||||
import type { LocalToolEnvironmentService } from '../local-tool-environment/local-tool-environment-service'
|
||||
import type { RuntimeSettingsStore } from '../runtime-settings-store'
|
||||
import type { TerminalSessionManager } from '../terminal/terminal-session-manager'
|
||||
import type { BundledRuntimePaths } from './bundled-runtimes'
|
||||
import type { KnowledgeMcpGateway } from './knowledge-mcp-gateway'
|
||||
import { NativeDshWebClientService } from './native-dsh-web-client'
|
||||
import { NativeTerminalClient } from './native-terminal-client'
|
||||
import { applyRuntimeSelection } from './runtime-selection'
|
||||
|
||||
type Options = {
|
||||
database: AssistantDatabase
|
||||
settingsStore: RuntimeSettingsStore
|
||||
applicationSettingsStore: ApplicationSettingsStore
|
||||
capabilities: CapabilityService
|
||||
executionSpaceResolver: ExecutionSpaceResolver
|
||||
terminalManager: TerminalSessionManager
|
||||
localEnvironment: LocalToolEnvironmentService
|
||||
bundledRuntimePaths: BundledRuntimePaths
|
||||
rootDirectory: string
|
||||
managedNodeDirectory: string
|
||||
npmCliPath: string
|
||||
resourcesPath?: string
|
||||
createGateway: () => KnowledgeMcpGateway
|
||||
openExternal: (url: string) => Promise<void>
|
||||
}
|
||||
|
||||
export class NativeClientCoordinator {
|
||||
private readonly browser: NativeDshWebClientService
|
||||
private readonly services = new Map<string, { ownerId: number; id: string; dispose: () => Promise<void> }>()
|
||||
private readonly pending = new Map<string, Promise<RuntimeNativeClientResult>>()
|
||||
private readonly closedOwners = new Set<number>()
|
||||
private nodePreparation?: Promise<string>
|
||||
private readonly controller = new AbortController()
|
||||
|
||||
constructor(private readonly options: Options) {
|
||||
this.browser = new NativeDshWebClientService({
|
||||
rootDirectory: join(options.rootDirectory, 'dsh'),
|
||||
resourcesPath: options.resourcesPath,
|
||||
resolveLaunchEnvironment: async () => ({
|
||||
nodeExecutablePath: await this.prepareNode(),
|
||||
environment: options.localEnvironment.launchEnvironmentProvider()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private async prepareNode(): Promise<string> {
|
||||
const selection = (await this.options.applicationSettingsStore.get()).localToolEnvironment.node
|
||||
if (selection.source === 'custom') return selection.executablePath
|
||||
this.nodePreparation ??= (async () => {
|
||||
const directory = this.options.managedNodeDirectory
|
||||
const packageName = `node-${process.platform === 'win32' ? 'win' : process.platform}-${process.arch}`
|
||||
const executable = join(directory, 'node_modules', packageName, 'bin', process.platform === 'win32' ? 'node.exe' : 'node')
|
||||
if (!(await stat(executable).catch(() => undefined))?.isFile()) {
|
||||
await mkdir(directory, { recursive: true })
|
||||
await promisify(execFile)(process.execPath, [this.options.npmCliPath, 'install', '--prefix', directory,
|
||||
'--no-save', '--no-package-lock', '--ignore-scripts', '--no-audit', '--no-fund', '--prefer-offline', `${packageName}@22.22.0`], {
|
||||
env: { ...this.options.localEnvironment.launchEnvironmentProvider(), ELECTRON_RUN_AS_NODE: '1' },
|
||||
cwd: directory, windowsHide: true, timeout: 180_000, signal: this.controller.signal, maxBuffer: 1024 * 1024
|
||||
})
|
||||
}
|
||||
const { stdout } = await promisify(execFile)(executable, ['-p', 'JSON.stringify({node:process.versions.node,electron:process.versions.electron})'], {
|
||||
env: { ...this.options.localEnvironment.launchEnvironmentProvider() },
|
||||
windowsHide: true, timeout: 5_000, signal: this.controller.signal
|
||||
})
|
||||
const runtime = JSON.parse(stdout.trim()) as { node?: string; electron?: string }
|
||||
if (runtime.node !== '22.22.0' || runtime.electron) throw new Error('Native clients require managed standard Node 22.22.0')
|
||||
return executable
|
||||
})().catch(error => { this.nodePreparation = undefined; throw error })
|
||||
return this.nodePreparation
|
||||
}
|
||||
|
||||
private async resolve(conversationId: string) {
|
||||
const conversation = this.options.database.getConversation(conversationId)
|
||||
if (!conversation.projectId) throw new Error('Native clients require a saved project conversation')
|
||||
const project = this.options.database.getProject(conversation.projectId)
|
||||
const settings = await this.options.settingsStore.getResolvedSettings()
|
||||
const saved = conversation.runtimeSelection ?? project.runtimeSelection
|
||||
const selected = applyRuntimeSelection(settings, saved && saved.provider !== 'auto' ? saved : getDefaultRuntimeSelection(settings))
|
||||
const space = this.options.executionSpaceResolver.resolveProject(project)
|
||||
const workMode = normalizeInteractiveWorkMode(conversation.workMode ?? project.defaultWorkMode)
|
||||
const [skills, mcpServers, builtin, application, obsidian] = await Promise.all([
|
||||
this.options.capabilities.getRuntimeSkillContext(selected.target),
|
||||
this.options.capabilities.getResolvedMcpServers(selected.target),
|
||||
this.options.capabilities.getEnabledBuiltinMcpServerIds(selected.target),
|
||||
this.options.applicationSettingsStore.get(),
|
||||
this.options.capabilities.getObsidianSettings()
|
||||
])
|
||||
const key = createHash('sha256').update(JSON.stringify({ projectId: project.id, space: space.cacheIdentity,
|
||||
target: selected.target, profile: selected.target === 'deepseek-harness' ? selected.settings.deepseekHarnessModelProfile
|
||||
: selected.target === 'continue' ? selected.settings.continueModelProfile : selected.settings.opencodeModelProfile,
|
||||
workMode, skills, mcpServers, builtin,
|
||||
libraries: conversation.knowledgeLibraryIds, magicNotes: application.magicNotesEnabled, obsidian,
|
||||
node: application.localToolEnvironment.node,
|
||||
binary: selected.target === 'continue' ? selected.settings.continueBinaryPath
|
||||
: selected.target === 'opencode' ? selected.settings.opencodeBinaryPath : undefined,
|
||||
config: selected.target === 'continue' ? selected.settings.continueConfigPath : undefined })).digest('hex')
|
||||
return { conversation, project, selected, space, workMode, skills, mcpServers, builtin, application, obsidian, key }
|
||||
}
|
||||
|
||||
async get(ownerId: number, conversationId: string): Promise<{ serviceId: string } | null> {
|
||||
const context = await this.resolve(conversationId)
|
||||
const entry = this.services.get(`${ownerId}:${context.key}`)
|
||||
if (!entry) return null
|
||||
if (await this.browser.get(entry.id)) return { serviceId: entry.id }
|
||||
await entry.dispose()
|
||||
this.services.delete(`${ownerId}:${context.key}`)
|
||||
return null
|
||||
}
|
||||
|
||||
async open(ownerId: number, conversationId: string): Promise<RuntimeNativeClientResult> {
|
||||
this.assertOwner(ownerId)
|
||||
const context = await this.resolve(conversationId)
|
||||
this.assertOwner(ownerId)
|
||||
const key = `${ownerId}:${context.key}`
|
||||
const pending = this.pending.get(key)
|
||||
if (pending) return pending
|
||||
const operation = this.launch(ownerId, key, context).finally(() => this.pending.delete(key))
|
||||
this.pending.set(key, operation)
|
||||
return operation
|
||||
}
|
||||
|
||||
private assertOwner(ownerId: number): void {
|
||||
this.controller.signal.throwIfAborted()
|
||||
if (this.closedOwners.has(ownerId)) throw new Error('Native client window is closed')
|
||||
}
|
||||
|
||||
private async launch(ownerId: number, key: string, context: Awaited<ReturnType<NativeClientCoordinator['resolve']>>): Promise<RuntimeNativeClientResult> {
|
||||
const { project, selected, space, workMode, skills, mcpServers, builtin, application, conversation, obsidian } = context
|
||||
if (selected.target === 'model') throw new Error('This runtime has no native client')
|
||||
if (space.kind !== 'local') throw new Error('Native client requires a local execution space')
|
||||
const existing = this.services.get(key)
|
||||
if (existing) {
|
||||
const handle = await this.browser.get(existing.id)
|
||||
if (handle) {
|
||||
this.assertOwner(ownerId)
|
||||
await this.options.openExternal(handle.url)
|
||||
return { kind: 'browser', serviceId: handle.id }
|
||||
}
|
||||
await existing.dispose()
|
||||
this.services.delete(key)
|
||||
}
|
||||
const gateway = this.options.createGateway()
|
||||
const controller = new AbortController()
|
||||
const signal = AbortSignal.any([controller.signal, this.controller.signal])
|
||||
const dispose = async (): Promise<void> => { controller.abort(); await gateway.dispose() }
|
||||
try {
|
||||
await gateway.start()
|
||||
const requestId = randomUUID()
|
||||
const access = workMode === 'execute' ? 'write' : 'read'
|
||||
const token = gateway.grant(requestId, builtin.includes('knowledge-base') ? conversation.knowledgeLibraryIds ?? [] : [], signal,
|
||||
builtin.includes('magic-notes') && application.magicNotesEnabled ? access : 'none',
|
||||
builtin.includes('goodbuddy-config') ? { access, workspacePath: space.rootPath } : undefined,
|
||||
undefined, undefined, undefined, builtin.includes('obsidian') ? { settings: obsidian, access } : undefined)
|
||||
const customToken = workMode === 'execute' ? gateway.grantCustomMcp(requestId, mcpServers, signal) : undefined
|
||||
const endpoints = [token, customToken].flatMap((value, index) => value ? [{ name: `goodbuddy-${index}`, url: gateway.getEndpoint()!, headers: { Authorization: `Bearer ${value}` } }] : [])
|
||||
if (selected.target === 'deepseek-harness') {
|
||||
const profile = selected.settings.deepseekHarnessModelProfile
|
||||
if (!profile) throw new Error('DS Web requires a text model connection')
|
||||
const readOnlyTools = token && workMode === 'ask' ? gateway.getAvailableToolNames(token) : []
|
||||
const handle = await this.browser.start({ projectId: project.id, ownerId, configurationKey: context.key, workspace: space.rootPath, profile, workMode,
|
||||
skillDirectories: skills.packages.map(skill => skill.directory),
|
||||
mcpServers: endpoints.map(endpoint => ({ serverName: endpoint.name, transport: 'streamable-http', url: endpoint.url, headers: endpoint.headers,
|
||||
readOnlyTools: endpoint.name === 'goodbuddy-0' ? readOnlyTools : [] })) })
|
||||
this.services.set(key, { ownerId, id: handle.id, dispose })
|
||||
this.assertOwner(ownerId)
|
||||
await this.options.openExternal(handle.url)
|
||||
return { kind: 'browser', serviceId: handle.id }
|
||||
}
|
||||
const nodeExecutable = selected.target === 'continue' ? await this.prepareNode() : undefined
|
||||
this.assertOwner(ownerId)
|
||||
const terminal = await new NativeTerminalClient({
|
||||
rootDirectory: join(this.options.rootDirectory, 'terminal'), bundledRuntimePaths: this.options.bundledRuntimePaths,
|
||||
nodeExecutable, launchEnvironmentProvider: this.options.localEnvironment.launchEnvironmentProvider,
|
||||
terminalManager: { create: async (owner, input, launch) => {
|
||||
if (!launch) throw new Error('Native terminal launch is missing')
|
||||
return this.options.terminalManager.create(owner, input, { ...launch, dispose: async () => {
|
||||
try { await launch.dispose() } finally { await dispose() }
|
||||
} })
|
||||
} }
|
||||
}).open(ownerId, { projectId: project.id, projectName: project.name, directory: space.rootPath,
|
||||
runtime: selected.target, settings: selected.settings, workMode, skillPackages: skills.packages, mcpServers: endpoints })
|
||||
if (this.closedOwners.has(ownerId) || this.controller.signal.aborted) {
|
||||
await this.options.terminalManager.closeOwner(ownerId)
|
||||
throw new Error('Native client window is closed')
|
||||
}
|
||||
return { kind: 'terminal', terminal }
|
||||
} catch (error) {
|
||||
// Once ready, the service owns its gateway even if the OS browser cannot open.
|
||||
if (!this.services.has(key)) await dispose()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async stop(ownerId: number, serviceId: string): Promise<void> {
|
||||
for (const [key, entry] of this.services) {
|
||||
if (entry.id !== serviceId || entry.ownerId !== ownerId) continue
|
||||
await this.browser.stop(entry.id)
|
||||
await entry.dispose()
|
||||
this.services.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
async closeOwner(ownerId: number): Promise<void> {
|
||||
this.closedOwners.add(ownerId)
|
||||
await Promise.allSettled([...this.pending.entries()].filter(([key]) => key.startsWith(`${ownerId}:`)).map(([, operation]) => operation))
|
||||
await Promise.all([...this.services.values()].filter(entry => entry.ownerId === ownerId).map(entry => this.stop(ownerId, entry.id)))
|
||||
await this.options.terminalManager.closeOwner(ownerId)
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.controller.abort()
|
||||
await this.browser.dispose()
|
||||
await Promise.allSettled(this.pending.values())
|
||||
await Promise.all([...this.services.values()].map(entry => entry.dispose()))
|
||||
this.services.clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
// @vitest-environment node
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { createServer } from 'node:http'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { NativeDshWebClientService, resolveNativeDshCli, type NativeDshWebRequest } from './native-dsh-web-client'
|
||||
import { nativeDshMcpToolName, nativeDshWebPolicySource } from './native-dsh-web-policy'
|
||||
|
||||
const roots: string[] = []
|
||||
const services: NativeDshWebClientService[] = []
|
||||
afterEach(async () => {
|
||||
await Promise.all(services.splice(0).map(service => service.dispose()))
|
||||
await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
async function temporary(): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-native-dsh-'))
|
||||
roots.push(root)
|
||||
return root
|
||||
}
|
||||
const profile: NativeDshWebRequest['profile'] = {
|
||||
id: 'test', name: 'Test', protocol: 'openai-chat-completions',
|
||||
authentication: 'api-key', apiKey: 'provider-secret-must-stay-in-main',
|
||||
baseUrl: 'http://127.0.0.1:1', modelName: 'test-model'
|
||||
}
|
||||
|
||||
describe('native DS Web mode', () => {
|
||||
it('enforces Ask tool names independently of agent mode and permits Execute', async () => {
|
||||
const root = await temporary()
|
||||
const file = join(root, 'policy.mjs')
|
||||
await writeFile(file, nativeDshWebPolicySource)
|
||||
const plugin = await import(/* @vite-ignore */ pathToFileURL(file).href)
|
||||
for (const mode of ['ask', 'execute']) {
|
||||
let execute!: (execution: unknown, next: () => string) => string
|
||||
plugin.apply({ on: (_name: string, callback: typeof execute) => { execute = callback } }, {
|
||||
workMode: mode, readOnlyTools: ['mcp__docs__lookup']
|
||||
})
|
||||
for (const name of ['read', 'skill', 'mcp__docs__lookup']) {
|
||||
expect(execute({ name, agent: { mode: 'execute' } }, () => 'ran')).toBe('ran')
|
||||
}
|
||||
for (const name of ['write', 'edit', 'pwsh', 'bash', 'subagent', 'mcp__docs__delete']) {
|
||||
const run = () => execute({ name, agent: { mode: 'execute' } }, () => 'ran')
|
||||
if (mode === 'ask') expect(run).toThrow('GoodBuddy Ask mode')
|
||||
else expect(run()).toBe('ran')
|
||||
}
|
||||
}
|
||||
})
|
||||
it('matches official MCP names without collisions after normalization', () => {
|
||||
expect(nativeDshMcpToolName('docs', 'lookup')).toBe('mcp__docs__lookup')
|
||||
expect(nativeDshMcpToolName('docs', 'a.b')).not.toBe(nativeDshMcpToolName('docs', 'a_b'))
|
||||
expect(nativeDshMcpToolName('docs', 'a'.repeat(100))).toHaveLength(64)
|
||||
})
|
||||
it('resolves the packaged CLI outside ASAR', () => {
|
||||
expect(resolveNativeDshCli('/resources')).toBe(join('/resources', 'runtimes/dsh/node_modules/@deepseek-ai/dsh/lib/bin.js'))
|
||||
})
|
||||
})
|
||||
|
||||
const standardNode = process.env.NATIVE_DSH_TEST_NODE ?? (!process.versions.electron ? process.execPath : undefined)
|
||||
describe.skipIf(!standardNode)('official DS Web process', () => {
|
||||
it('initializes the workspace, coalesces starts, isolates modes and preserves history on stop', async () => {
|
||||
const root = await temporary()
|
||||
const workspace = join(root, 'workspace')
|
||||
await mkdir(workspace)
|
||||
const service = new NativeDshWebClientService({
|
||||
rootDirectory: join(root, 'homes'),
|
||||
cliPath: process.env.NATIVE_DSH_TEST_CLI,
|
||||
resolveLaunchEnvironment: async () => ({ nodeExecutablePath: standardNode!, environment: process.env })
|
||||
})
|
||||
services.push(service)
|
||||
const request: NativeDshWebRequest = { projectId: 'one', workspace, profile, workMode: 'ask' }
|
||||
const [first, repeated] = await Promise.all([service.start(request), service.start(request)])
|
||||
expect(first).toEqual(repeated)
|
||||
const config = await readFile(join(root, 'homes', first.id, 'cordis.patch.yml'), 'utf8')
|
||||
expect(config).toContain('test-model')
|
||||
expect(config).not.toContain(profile.apiKey)
|
||||
const auth = await fetch(first.url, { redirect: 'manual' })
|
||||
const cookie = auth.headers.getSetCookie().map(value => value.split(';')[0]).join('; ')
|
||||
const page = await fetch(new URL('/', first.url), { headers: { cookie } })
|
||||
expect(page.status).toBe(200)
|
||||
expect(await page.text()).toContain('<html')
|
||||
expect(await service.start(request)).toEqual(first)
|
||||
const history = join(root, 'homes', first.id, 'history-marker')
|
||||
await writeFile(history, 'retained')
|
||||
const execute = await service.start({ ...request, workMode: 'execute' })
|
||||
expect(execute.id).not.toBe(first.id)
|
||||
const changedConfiguration = await service.start({ ...request, configurationKey: 'updated-skill-digest' })
|
||||
expect(changedConfiguration.id).not.toBe(first.id)
|
||||
expect(await service.start({ ...request, configurationKey: 'updated-skill-digest' })).toEqual(changedConfiguration)
|
||||
await service.stop(changedConfiguration.id)
|
||||
await service.stop(first.id)
|
||||
await expect(fetch(first.url)).rejects.toThrow()
|
||||
expect(await readFile(history, 'utf8')).toBe('retained')
|
||||
await expect(readFile(join(root, 'homes', first.id, 'cordis.patch.yml'))).rejects.toThrow()
|
||||
expect((await fetch(execute.url, { redirect: 'manual' })).status).toBe(303)
|
||||
const restarted = await service.start(request)
|
||||
expect(restarted.id).toBe(first.id)
|
||||
expect(restarted.url).not.toBe(first.url)
|
||||
await service.dispose()
|
||||
await expect(service.start(request)).rejects.toThrow('disposed')
|
||||
}, 120_000)
|
||||
|
||||
it('routes official Web tool calls through the model bridge and enforces Ask at execution', async () => {
|
||||
const root = await temporary()
|
||||
const skills = join(root, 'skills')
|
||||
await mkdir(skills)
|
||||
await writeFile(join(skills, 'SKILL.md'), '---\nname: native-test\ndescription: Native test skill\n---\nSKILL_LOADED_MARKER')
|
||||
const toolResults: string[] = []
|
||||
let mcpCalls = 0
|
||||
const mcp = createServer(async (request, response) => {
|
||||
if (request.method !== 'POST') { response.writeHead(405).end(); return }
|
||||
let body = ''
|
||||
for await (const chunk of request) body += chunk
|
||||
const message = JSON.parse(body)
|
||||
if (message.id === undefined) { response.writeHead(202).end(); return }
|
||||
let result: unknown = {}
|
||||
if (message.method === 'initialize') result = { protocolVersion: message.params.protocolVersion,
|
||||
capabilities: { tools: {} }, serverInfo: { name: 'test', version: '1' } }
|
||||
if (message.method === 'tools/list') result = { tools: [
|
||||
{ name: 'lookup', description: 'Read test data', inputSchema: { type: 'object', properties: {} } },
|
||||
{ name: 'mutate', description: 'Write test data', inputSchema: { type: 'object', properties: {} } }
|
||||
] }
|
||||
if (message.method === 'tools/call') { mcpCalls++; result = { content: [{ type: 'text', text: 'MCP_CALLED_MARKER' }] } }
|
||||
response.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ jsonrpc: '2.0', id: message.id, result }))
|
||||
})
|
||||
await new Promise<void>(resolve => mcp.listen(0, '127.0.0.1', resolve))
|
||||
const address = mcp.address() as { port: number }
|
||||
const fetcher: typeof fetch = async (_url, init) => {
|
||||
expect(new Headers(init?.headers).get('authorization')).toBe(`Bearer ${profile.apiKey}`)
|
||||
const body = JSON.parse(String(init?.body))
|
||||
expect(body.model).toBe(profile.modelName)
|
||||
const toolResult = body.messages.findLast((message: { role: string }) => message.role === 'tool')
|
||||
const user = JSON.stringify(body.messages)
|
||||
const toolName = user.includes('Load skill') ? 'skill' : user.includes('MCP read') ? 'mcp__test__lookup'
|
||||
: user.includes('MCP write') ? 'mcp__test__mutate' : 'write'
|
||||
const write = !toolResult && body.tools?.some((tool: { function: { name: string } }) => tool.function.name === toolName)
|
||||
if (toolResult) toolResults.push(JSON.stringify(toolResult.content))
|
||||
const delta = write ? { role: 'assistant', tool_calls: [{ index: 0, id: 'call_write', type: 'function',
|
||||
function: { name: toolName, arguments: JSON.stringify(toolName === 'write'
|
||||
? { file_path: join(root, 'written.txt'), content: 'EXECUTED' } : toolName === 'skill' ? { name: 'native-test' } : {}) } }] }
|
||||
: { role: 'assistant', content: 'Finished' }
|
||||
const event = (value: unknown) => `data: ${JSON.stringify(value)}\n\n`
|
||||
return new Response(event({ id: 'test', object: 'chat.completion.chunk', model: profile.modelName,
|
||||
choices: [{ index: 0, delta, finish_reason: null }] }) + event({ id: 'test', object: 'chat.completion.chunk',
|
||||
choices: [{ index: 0, delta: {}, finish_reason: write ? 'tool_calls' : 'stop' }],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 10, total_tokens: 20 } }) + 'data: [DONE]\n\n',
|
||||
{ headers: { 'content-type': 'text/event-stream' } })
|
||||
}
|
||||
const service = new NativeDshWebClientService({ rootDirectory: join(root, 'homes'), fetcher,
|
||||
cliPath: process.env.NATIVE_DSH_TEST_CLI,
|
||||
resolveLaunchEnvironment: async () => ({ nodeExecutablePath: standardNode!, environment: process.env }) })
|
||||
services.push(service)
|
||||
try {
|
||||
for (const workMode of ['ask', 'execute'] as const) {
|
||||
const handle = await service.start({ projectId: 'tools', workspace: root, profile, workMode,
|
||||
skillDirectories: [skills], mcpServers: [{ serverName: 'test', transport: 'streamable-http',
|
||||
url: `http://127.0.0.1:${address.port}/mcp`, readOnlyTools: ['lookup'] }] })
|
||||
const auth = await fetch(handle.url, { redirect: 'manual' })
|
||||
const cookie = auth.headers.getSetCookie().map(value => value.split(';')[0]).join('; ')
|
||||
async function rpc(method: string, request: unknown): Promise<Record<string, unknown>> {
|
||||
const response = await fetch(new URL(`/api/${method}`, handle.url), { method: 'POST',
|
||||
headers: { 'content-type': 'application/json', cookie, origin: new URL(handle.url).origin },
|
||||
body: JSON.stringify({ type: 'client-request', rpcId: crypto.randomUUID(), method, payload: { args: { request } } }) })
|
||||
const payload = await response.json() as { result?: { ok?: boolean; value: Record<string, unknown> } }
|
||||
expect(payload.result?.ok, JSON.stringify(payload)).toBe(true)
|
||||
return payload.result!.value
|
||||
}
|
||||
const session = await rpc('session/create', { cwd: root })
|
||||
const previous = toolResults.length
|
||||
await rpc('session/prompt', { sessionId: session.sessionId, requestId: crypto.randomUUID(), mode: 'queue',
|
||||
content: [{ type: 'text', text: 'Write the requested marker file.' }] })
|
||||
await vi.waitFor(() => expect(toolResults.length).toBeGreaterThan(previous), { timeout: 15_000 })
|
||||
if (workMode === 'ask') {
|
||||
expect(toolResults.at(-1)).toContain('GoodBuddy Ask mode')
|
||||
await expect(readFile(join(root, 'written.txt'))).rejects.toThrow()
|
||||
} else {
|
||||
expect(await readFile(join(root, 'written.txt'), 'utf8')).toBe('EXECUTED')
|
||||
}
|
||||
for (const prompt of ['Load skill', 'MCP read', 'MCP write']) {
|
||||
const next = await rpc('session/create', { cwd: root })
|
||||
const before = toolResults.length
|
||||
const callsBefore = mcpCalls
|
||||
await rpc('session/prompt', { sessionId: next.sessionId, requestId: crypto.randomUUID(), mode: 'queue',
|
||||
content: [{ type: 'text', text: prompt }] })
|
||||
await vi.waitFor(() => expect(toolResults.length).toBeGreaterThan(before), { timeout: 15_000 })
|
||||
if (prompt === 'Load skill') await vi.waitFor(() => expect(toolResults.slice(before).join()).toContain('SKILL_LOADED_MARKER'), { timeout: 15_000 })
|
||||
else if (prompt === 'MCP write' && workMode === 'ask') {
|
||||
await vi.waitFor(() => expect(toolResults.slice(before).join()).toContain('does not allow tool: mcp__test__mutate'), { timeout: 15_000 })
|
||||
expect(mcpCalls).toBe(callsBefore)
|
||||
} else {
|
||||
await vi.waitFor(() => expect(toolResults.slice(before).join()).toContain('MCP_CALLED_MARKER'), { timeout: 15_000 })
|
||||
expect(mcpCalls).toBe(callsBefore + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await service.dispose()
|
||||
mcp.closeAllConnections()
|
||||
await new Promise<void>(resolve => mcp.close(() => resolve()))
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
it('cleans failed launches and permits retry', async () => {
|
||||
const root = await temporary()
|
||||
const service = new NativeDshWebClientService({ rootDirectory: join(root, 'homes'), cliPath: join(root, 'missing.js'),
|
||||
resolveLaunchEnvironment: async () => ({ nodeExecutablePath: standardNode!, environment: process.env }) })
|
||||
services.push(service)
|
||||
const request: NativeDshWebRequest = { projectId: 'one', workspace: root, profile, workMode: 'ask' }
|
||||
await expect(service.start(request)).rejects.toThrow()
|
||||
await expect(service.start(request)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('cancels an in-progress launch when disposed', async () => {
|
||||
const root = await temporary()
|
||||
const cliPath = join(root, 'waiting.mjs')
|
||||
await writeFile(cliPath, 'setInterval(() => {}, 1000)')
|
||||
const service = new NativeDshWebClientService({ rootDirectory: join(root, 'homes'), cliPath,
|
||||
resolveLaunchEnvironment: async () => ({ nodeExecutablePath: standardNode!, environment: process.env }) })
|
||||
services.push(service)
|
||||
const starting = service.start({ projectId: 'pending', workspace: root, profile, workMode: 'ask' })
|
||||
const rejected = expect(starting).rejects.toThrow()
|
||||
await new Promise(resolve => setTimeout(resolve, 200))
|
||||
await service.dispose()
|
||||
await rejected
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,232 @@
|
||||
import { execFile, spawn, type ChildProcess } from 'node:child_process'
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { mkdir, realpath, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { createRequire } from 'node:module'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { AgentModelCallLedger, AgentModelGateway } from '../../agent-daemon/agent-model-gateway'
|
||||
import { MODEL_BRIDGE_SDK_AUTH_SENTINEL, ModelBridgeLoopbackProxy } from '../../agent-daemon/model-bridge-helper'
|
||||
import { canonicalJson } from '../../shared/agent-protocol/canonical'
|
||||
import type { ResolvedModelProfile } from '../runtime-settings-store'
|
||||
import { createManagedModelBridge } from '../remote-agent/managed-model-bridge'
|
||||
import { terminateProcessTreeAndWait } from './child-process-termination'
|
||||
import { buildCredentialFilteredUserEnvironment } from './process-environment'
|
||||
import { nativeDshMcpToolName, nativeDshWebPatch, nativeDshWebPolicySource, type NativeDshMcpServer } from './native-dsh-web-policy'
|
||||
|
||||
export type { NativeDshMcpServer } from './native-dsh-web-policy'
|
||||
|
||||
export type NativeDshWebRequest = {
|
||||
projectId: string
|
||||
ownerId?: number
|
||||
/** Includes the coordinator's current capability digests and launch settings. */
|
||||
configurationKey?: string
|
||||
workspace: string
|
||||
profile: ResolvedModelProfile
|
||||
workMode: 'ask' | 'execute'
|
||||
/** Directories containing SKILL.md, or roots containing skill bundles. */
|
||||
skillDirectories?: string[]
|
||||
/** Already resolved by Main; GoodBuddy-only tools need a caller-owned MCP adapter. */
|
||||
mcpServers?: NativeDshMcpServer[]
|
||||
}
|
||||
|
||||
export type NativeDshWebHandle = { id: string; url: string }
|
||||
export type NativeDshWebClientOptions = {
|
||||
rootDirectory: string
|
||||
resolveLaunchEnvironment: () => Promise<{
|
||||
nodeExecutablePath: string
|
||||
environment: Readonly<NodeJS.ProcessEnv>
|
||||
}>
|
||||
/** Packaged: process.resourcesPath. Omit in development. */
|
||||
resourcesPath?: string
|
||||
cliPath?: string
|
||||
startupTimeoutMs?: number
|
||||
/** Existing gateway supports injecting fetch for focused protocol tests. */
|
||||
fetcher?: typeof fetch
|
||||
}
|
||||
|
||||
type Instance = {
|
||||
id: string
|
||||
controller: AbortController
|
||||
ready: Promise<NativeDshWebHandle>
|
||||
child?: ChildProcess
|
||||
proxy?: ModelBridgeLoopbackProxy
|
||||
ledger?: AgentModelCallLedger
|
||||
cleanup?: Promise<void>
|
||||
home: string
|
||||
}
|
||||
|
||||
export function resolveNativeDshCli(resourcesPath?: string): string {
|
||||
return resourcesPath
|
||||
? join(resourcesPath, 'runtimes', 'dsh', 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js')
|
||||
: join(dirname(createRequire(import.meta.url).resolve('@deepseek-ai/dsh/package.json')), 'lib', 'bin.js')
|
||||
}
|
||||
|
||||
export class NativeDshWebClientService {
|
||||
private readonly instances = new Map<string, Instance>()
|
||||
private disposed = false
|
||||
|
||||
constructor(private readonly options: NativeDshWebClientOptions) {}
|
||||
|
||||
async start(input: NativeDshWebRequest): Promise<NativeDshWebHandle> {
|
||||
if (this.disposed) throw new Error('DS Web service is disposed')
|
||||
const request = structuredClone(input)
|
||||
request.workspace = await realpath(request.workspace)
|
||||
if (!(await stat(request.workspace)).isDirectory()) throw new Error('DS Web workspace is not a directory')
|
||||
if (this.disposed) throw new Error('DS Web service is disposed')
|
||||
// Include credentials in the in-memory match hash so credential rotation starts
|
||||
// a fresh bridge. Only the digest is used as a directory name.
|
||||
const key = createHash('sha256').update(canonicalJson(JSON.parse(JSON.stringify(request)))).digest('hex')
|
||||
const existing = this.instances.get(key)
|
||||
if (existing) {
|
||||
if (existing.controller.signal.aborted || (existing.child &&
|
||||
(existing.child.exitCode !== null || existing.child.signalCode !== null))) {
|
||||
await existing.ready.catch(() => undefined)
|
||||
await this.cleanup(existing)
|
||||
return this.start(request)
|
||||
}
|
||||
return existing.ready
|
||||
}
|
||||
const instance: Instance = {
|
||||
id: key, controller: new AbortController(),
|
||||
home: join(this.options.rootDirectory, key),
|
||||
ready: Promise.resolve({ id: key, url: '' })
|
||||
}
|
||||
this.instances.set(key, instance)
|
||||
instance.ready = this.launch(instance, request).catch(async error => {
|
||||
await this.cleanup(instance)
|
||||
throw error
|
||||
})
|
||||
return instance.ready
|
||||
}
|
||||
|
||||
async stop(id: string): Promise<void> {
|
||||
const instance = this.instances.get(id)
|
||||
if (!instance) return
|
||||
instance.controller.abort(new Error('DS Web service stopped'))
|
||||
await instance.ready.catch(() => undefined)
|
||||
await this.cleanup(instance)
|
||||
}
|
||||
|
||||
async get(id: string): Promise<NativeDshWebHandle | null> {
|
||||
const instance = this.instances.get(id)
|
||||
if (!instance || instance.controller.signal.aborted) return null
|
||||
const handle = await instance.ready.catch(() => null)
|
||||
return instance.controller.signal.aborted || (instance.child &&
|
||||
(instance.child.exitCode !== null || instance.child.signalCode !== null)) ? null : handle
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.disposed = true
|
||||
await Promise.all([...this.instances.keys()].map(id => this.stop(id)))
|
||||
}
|
||||
|
||||
private async cleanup(instance: Instance): Promise<void> {
|
||||
instance.cleanup ??= (async () => {
|
||||
instance.controller.abort(new Error('DS Web service stopped'))
|
||||
if (instance.child && instance.child.signalCode === null) {
|
||||
await terminateProcessTreeAndWait(instance.child, { processGroup: process.platform !== 'win32' })
|
||||
}
|
||||
await instance.proxy?.close()
|
||||
instance.ledger?.close()
|
||||
// Native history and profile settings are persistent; only launch material is disposable.
|
||||
await Promise.all(['cordis.patch.yml', 'goodbuddy-mode.mjs', 'bridge.sqlite', 'bridge.sqlite-wal', 'bridge.sqlite-shm']
|
||||
.map(name => rm(join(instance.home, name), { force: true })))
|
||||
if (this.instances.get(instance.id) === instance) this.instances.delete(instance.id)
|
||||
})()
|
||||
return instance.cleanup
|
||||
}
|
||||
|
||||
private async launch(instance: Instance, request: NativeDshWebRequest): Promise<NativeDshWebHandle> {
|
||||
const signal = AbortSignal.any([instance.controller.signal, AbortSignal.timeout(this.options.startupTimeoutMs ?? 60_000)])
|
||||
const launch = await this.options.resolveLaunchEnvironment()
|
||||
signal.throwIfAborted()
|
||||
if (!launch.nodeExecutablePath) throw new Error('DS Web requires a standard Node executable from the local tool environment')
|
||||
const environment = buildCredentialFilteredUserEnvironment({ ...launch.environment })
|
||||
const { stdout } = await promisify(execFile)(launch.nodeExecutablePath,
|
||||
['-p', 'JSON.stringify({node:process.versions.node,electron:process.versions.electron})'],
|
||||
{ env: { ...environment, ELECTRON_RUN_AS_NODE: '1' }, signal, timeout: 5_000, windowsHide: true })
|
||||
const runtime = JSON.parse(stdout.trim()) as { node?: string; electron?: string }
|
||||
if (!runtime.node || runtime.electron) throw new Error('DS Web requires standard Node, not Electron')
|
||||
const cli = this.options.cliPath ?? resolveNativeDshCli(this.options.resourcesPath)
|
||||
await stat(cli)
|
||||
await mkdir(instance.home, { recursive: true, mode: 0o700 })
|
||||
const managed = createManagedModelBridge({ profile: request.profile })
|
||||
instance.ledger = new AgentModelCallLedger(join(instance.home, 'bridge.sqlite'))
|
||||
const gateway = new AgentModelGateway({ ledger: instance.ledger, fetcher: this.options.fetcher })
|
||||
const operationId = randomUUID()
|
||||
let roundIndex = 0
|
||||
instance.proxy = new ModelBridgeLoopbackProxy({ exchange: (message, context) => gateway.dispatch({
|
||||
bindingId: instance.id, operationId, promptSequence: 0, roundIndex: roundIndex++,
|
||||
profileDigest: managed.profile.modelProfileDigest, profile: managed.profile
|
||||
}, message, context.signal) })
|
||||
const origin = await instance.proxy.listen()
|
||||
const policyPath = join(instance.home, 'goodbuddy-mode.mjs')
|
||||
const mcpServers = request.mcpServers ?? []
|
||||
const patch = nativeDshWebPatch({
|
||||
model: request.profile.modelName,
|
||||
api: request.profile.protocol === 'anthropic-messages' ? 'anthropic-messages'
|
||||
: request.profile.protocol === 'openai-responses' ? 'openai-responses' : 'openai-completions',
|
||||
baseURL: `${origin}/v1`, workMode: request.workMode, policyPath,
|
||||
skillDirectories: request.skillDirectories ?? [], mcpServers,
|
||||
readOnlyTools: mcpServers.flatMap(server => (server.readOnlyTools ?? []).map(name => nativeDshMcpToolName(server.serverName, name)))
|
||||
})
|
||||
await writeFile(policyPath, nativeDshWebPolicySource, 'utf8')
|
||||
await writeFile(join(instance.home, 'cordis.patch.yml'), JSON.stringify(patch), 'utf8')
|
||||
signal.throwIfAborted()
|
||||
for (const name of Object.keys(environment)) if (name.startsWith('DSH_')) delete environment[name]
|
||||
instance.child = spawn(launch.nodeExecutablePath, [cli, 'web', '--no-open', '--host', '127.0.0.1', '--port', '0'], {
|
||||
cwd: request.workspace, shell: false, windowsHide: true,
|
||||
detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { ...environment, DSH_HOME: instance.home, DSH_TELEMETRY_DISABLED: '1',
|
||||
DSH_PERMISSION_MODE: 'danger-full-access', GOODBUDDY_DSH_MODEL_KEY: MODEL_BRIDGE_SDK_AUTH_SENTINEL }
|
||||
})
|
||||
const child = instance.child
|
||||
const url = await new Promise<string>((resolve, reject) => {
|
||||
let output = ''
|
||||
const finish = (error?: Error, address?: string): void => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
child.removeListener('error', onError)
|
||||
child.removeListener('exit', onExit)
|
||||
child.stdout?.removeListener('data', onData)
|
||||
child.stderr?.removeListener('data', onData)
|
||||
if (error) reject(error)
|
||||
else resolve(address!)
|
||||
}
|
||||
const onAbort = (): void => finish(new Error('DS Web startup cancelled or timed out', { cause: signal.reason }))
|
||||
const onError = (error: Error): void => finish(new Error(`DS Web process could not start: ${error.message}`))
|
||||
const onExit = (code: number | null): void => finish(new Error(`DS Web exited during startup (${code})`))
|
||||
const onData = (chunk: Buffer): void => {
|
||||
output = (output + chunk.toString()).slice(-32_768)
|
||||
// Wait for the full line; stdout can split in the middle of the token.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const match = output.match(/http:\/\/127\.0\.0\.1:\d+[^\s\x1b]*(?=[\s\x1b])/u)
|
||||
if (match) finish(undefined, match[0])
|
||||
}
|
||||
child.once('error', onError)
|
||||
child.once('exit', onExit)
|
||||
child.stdout?.on('data', onData)
|
||||
child.stderr?.on('data', onData)
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
if (signal.aborted) onAbort()
|
||||
})
|
||||
// Drain subsequent logs without retaining token-bearing output.
|
||||
child.stdout?.resume()
|
||||
child.stderr?.resume()
|
||||
child.once('exit', () => { void this.cleanup(instance) })
|
||||
const auth = await fetch(url, { redirect: 'manual', signal })
|
||||
if (auth.status !== 303) throw new Error(`DS Web authentication failed (HTTP ${auth.status})`)
|
||||
const cookie = auth.headers.getSetCookie().map(value => value.split(';')[0]).join('; ')
|
||||
const response = await fetch(new URL('/api/workspace/create', url), {
|
||||
method: 'POST', signal,
|
||||
headers: { 'content-type': 'application/json', cookie, origin: new URL(url).origin },
|
||||
body: JSON.stringify({ type: 'client-request', rpcId: randomUUID(), method: 'workspace/create',
|
||||
payload: { args: { request: { path: request.workspace } } } })
|
||||
})
|
||||
if (!response.ok) throw new Error(`DS Web workspace creation failed (HTTP ${response.status})`)
|
||||
const result = await response.json() as { result?: { ok?: boolean } }
|
||||
if (result.result?.ok !== true) throw new Error('DS Web workspace creation was rejected')
|
||||
signal.throwIfAborted()
|
||||
if (child.exitCode !== null || child.signalCode !== null) throw new Error('DS Web exited during workspace initialization')
|
||||
return { id: instance.id, url }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
// Official dsh-mcp-client 0.1.7-rc.2 naming contract; its helper is private.
|
||||
export function nativeDshMcpToolName(serverName: string, rawName: string): string {
|
||||
const joined = `mcp__${serverName}__${rawName}`
|
||||
const normalized = joined.replace(/[^A-Za-z0-9_-]/gu, '_')
|
||||
if (joined === normalized && normalized.length <= 64) return joined
|
||||
const hash = createHash('sha256').update(`${serverName}\0${rawName}`).digest('hex').slice(0, 12)
|
||||
return `${normalized.slice(0, 51)}_${hash}`
|
||||
}
|
||||
|
||||
export type NativeDshMcpServer = {
|
||||
serverName: string
|
||||
readOnlyTools?: string[]
|
||||
} & (
|
||||
| { transport: 'stdio'; command: string; args?: string[]; env?: Record<string, string>; cwd?: string }
|
||||
| { transport: 'streamable-http'; url: string; headers?: Record<string, string> }
|
||||
)
|
||||
|
||||
// Loaded by the official Cordis loader, outside Electron and its ASAR bundle.
|
||||
// The launch mode applies to every session, including delegated agents.
|
||||
export const nativeDshWebPolicySource = `
|
||||
export const name = 'goodbuddy-native-mode';
|
||||
export function apply(ctx, config) {
|
||||
const allowed = new Set(['read', 'skill', ...config.readOnlyTools]);
|
||||
ctx.on('tools/execute', (exec, next) => {
|
||||
if (config.workMode === 'ask' && !allowed.has(exec.name)) {
|
||||
throw new Error('GoodBuddy Ask mode does not allow tool: ' + exec.name);
|
||||
}
|
||||
return next();
|
||||
});
|
||||
}
|
||||
`
|
||||
|
||||
export function nativeDshWebPatch(options: {
|
||||
model: string
|
||||
api: string
|
||||
baseURL: string
|
||||
workMode: 'ask' | 'execute'
|
||||
policyPath: string
|
||||
skillDirectories: string[]
|
||||
mcpServers: NativeDshMcpServer[]
|
||||
readOnlyTools: string[]
|
||||
}): unknown[] {
|
||||
return [
|
||||
{
|
||||
id: 'llm-pi-ai',
|
||||
config: { providers: { goodbuddy: {
|
||||
displayName: 'GoodBuddy', api: options.api,
|
||||
apiKeyEnv: 'GOODBUDDY_DSH_MODEL_KEY', baseURL: options.baseURL,
|
||||
models: [{ id: options.model, name: options.model }]
|
||||
} } }
|
||||
},
|
||||
{ id: 'agent-default-model', config: { provider: 'goodbuddy', model: options.model } },
|
||||
{ id: 'sandbox-policy', config: { mode: 'danger-full-access' } },
|
||||
{ id: 'approval', config: { policy: 'never' } },
|
||||
// The launch mode owns permissions. Native Plan remains a planning aid.
|
||||
{ id: 'ui-permission', disabled: true },
|
||||
{ insert: [
|
||||
{ id: 'goodbuddy-native-mode', name: options.policyPath,
|
||||
config: { workMode: options.workMode, readOnlyTools: options.readOnlyTools } },
|
||||
...(options.skillDirectories.length ? [{
|
||||
id: 'goodbuddy-skills', name: '@deepseek-ai/dsh-skill-filesystem',
|
||||
config: { providerName: 'goodbuddy', includeDefaultRoots: false,
|
||||
customSkillDirs: options.skillDirectories }
|
||||
}] : []),
|
||||
...options.mcpServers.map((server, index) => {
|
||||
const config = { ...server, failOnStartupError: true }
|
||||
delete config.readOnlyTools
|
||||
return { id: `goodbuddy-mcp-${index}`, name: '@deepseek-ai/dsh-mcp-client', config }
|
||||
})
|
||||
] }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// @vitest-environment node
|
||||
import { spawn } from 'node:child_process'
|
||||
import { mkdtemp, rm, stat } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ResolvedRuntimeSettings } from '../runtime-settings-store'
|
||||
import type { TerminalSnapshot } from '../../shared/terminal-contracts'
|
||||
import type { NativeTerminalLaunch } from '../terminal/terminal-session-manager'
|
||||
import { LocalTerminalSession } from '../terminal/local-terminal-session'
|
||||
import { resolveBundledRuntimePaths } from './bundled-runtimes'
|
||||
import { NativeTerminalClient } from './native-terminal-client'
|
||||
|
||||
// Opt-in because these tests execute the installed native clients and a real PTY.
|
||||
describe.skipIf(!process.env.GOODBUDDY_NATIVE_NODE)('installed native terminal clients', () => {
|
||||
for (const runtime of ['continue', 'opencode'] as const) {
|
||||
for (const workMode of ['ask', 'execute'] as const) {
|
||||
it(`${runtime} ${workMode} enforces tool mode and starts its interactive PTY`, async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'native-client-e2e-'))
|
||||
let launch: NativeTerminalLaunch | undefined
|
||||
let pty: LocalTerminalSession | undefined
|
||||
const requests: Array<Record<string, unknown>> = []
|
||||
const profile = { id: 'native-test', name: 'Native Test Model', modelName: 'native-test-model', baseUrl: 'https://native-test.invalid/v1', protocol: 'openai-chat-completions' as const, authentication: 'api-key' as const, apiKey: 'test-provider-secret' }
|
||||
const client = new NativeTerminalClient({
|
||||
rootDirectory: root,
|
||||
nodeExecutable: process.env.GOODBUDDY_NATIVE_NODE,
|
||||
bundledRuntimePaths: resolveBundledRuntimePaths({ appPath: process.cwd(), resourcesPath: '', packaged: false }),
|
||||
fetcher: async (_url, options) => {
|
||||
const request = JSON.parse(String(options?.body))
|
||||
requests.push(request)
|
||||
if (!request.stream) return new Response(JSON.stringify({ id: 'native-test', object: 'chat.completion', created: 1, model: profile.modelName, choices: [{ index: 0, message: { role: 'assistant', content: 'NATIVE_BRIDGE_OK' }, finish_reason: 'stop' }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } }), { headers: { 'content-type': 'application/json' } })
|
||||
const chunk = (delta: object, finish_reason: string | null) => `data: ${JSON.stringify({ id: 'native-test', object: 'chat.completion.chunk', created: 1, model: profile.modelName, choices: [{ index: 0, delta, finish_reason }] })}\n\n`
|
||||
if (requests.length === 1) return new Response(chunk({ role: 'assistant', tool_calls: [{ index: 0, id: 'write-attempt', type: 'function', function: { name: runtime === 'continue' ? 'Bash' : 'bash', arguments: JSON.stringify({ command: 'echo NATIVE_WRITE > native-write-probe.txt', description: 'Write the test-owned marker' }) } }] }, null) + chunk({}, 'tool_calls') + 'data: [DONE]\n\n', { headers: { 'content-type': 'text/event-stream' } })
|
||||
return new Response(chunk({ role: 'assistant', content: 'NATIVE_BRIDGE_OK' }, null) + chunk({}, 'stop') + 'data: [DONE]\n\n', { headers: { 'content-type': 'text/event-stream' } })
|
||||
},
|
||||
terminalManager: { create: async (_owner, _request, prepared) => {
|
||||
launch = prepared
|
||||
return { sessionId: 'native-test', state: 'running' } as TerminalSnapshot
|
||||
} }
|
||||
})
|
||||
try {
|
||||
await client.open(1, {
|
||||
projectId: 'project', projectName: 'Native Test', directory: root, runtime, workMode,
|
||||
settings: { continueBinaryPath: '', opencodeBinaryPath: '', continueConfigPath: '', continueModelProfile: profile, opencodeModelProfile: profile } as ResolvedRuntimeSettings
|
||||
})
|
||||
const spec = launch!.spawnSpec
|
||||
spec.env.NODE_ENV = 'production'
|
||||
delete spec.env.VITEST
|
||||
delete spec.env.VITEST_WORKER_ID
|
||||
delete spec.env.VITEST_POOL_ID
|
||||
const output = await new Promise<string>((resolve, reject) => {
|
||||
const child = spawn(spec.executable, [...spec.args, ...(runtime === 'continue' ? ['-p', 'Reply with NATIVE_BRIDGE_OK'] : ['--print-logs', 'run', 'Reply with NATIVE_BRIDGE_OK'])], { cwd: root, env: spec.env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let text = ''
|
||||
const timeout = setTimeout(() => { child.kill(); reject(new Error(`Native CLI timeout: ${text.slice(-2000)}`)) }, 45_000)
|
||||
child.stdout.on('data', data => { text += data })
|
||||
child.stderr.on('data', data => { text += data })
|
||||
child.on('error', error => { clearTimeout(timeout); reject(error) })
|
||||
child.on('close', code => { clearTimeout(timeout); if (code === 0) resolve(text); else reject(new Error(`Native CLI exit ${code}: ${text.slice(-2000)}`)) })
|
||||
})
|
||||
expect(output, `requests: ${requests.length}; executable: ${spec.executable}; args: ${JSON.stringify(spec.args)}`).toContain('NATIVE_BRIDGE_OK')
|
||||
expect(requests.length).toBeGreaterThan(0)
|
||||
expect(requests.every(request => request.model === profile.modelName)).toBe(true)
|
||||
if (runtime === 'continue') {
|
||||
const tools = requests.flatMap(request => (request.tools ?? []) as Array<{ function: { name: string } }>).map(tool => tool.function.name)
|
||||
expect(tools).toContain('Read')
|
||||
if (workMode === 'ask') expect(tools).not.toContain('Write')
|
||||
}
|
||||
if (workMode === 'ask') await expect(stat(join(root, 'native-write-probe.txt'))).rejects.toThrow()
|
||||
else expect((await stat(join(root, 'native-write-probe.txt'))).isFile()).toBe(true)
|
||||
expect(requests.length).toBeGreaterThan(1)
|
||||
pty = await LocalTerminalSession.create({ target: { type: 'project', projectId: 'project' }, targetLabel: 'Native Test', title: runtime, size: { cols: 100, rows: 30 }, projectDirectory: root, spawnSpec: spec })
|
||||
expect(pty.snapshot().state).toBe('running')
|
||||
const session = pty
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let text = ''
|
||||
const timeout = setTimeout(() => { remove(); reject(new Error(`Interactive PTY did not become ready: ${text.slice(-1000)}`)) }, 15_000)
|
||||
const remove = session.onEvent(event => {
|
||||
session.acknowledge(event.sequence)
|
||||
if (event.type === 'output') {
|
||||
text += event.data
|
||||
if (/Continue|OpenCode|opencode|Native Test Model|native-test-model/u.test(text)) { clearTimeout(timeout); resolve() }
|
||||
}
|
||||
if (event.type === 'exit') { clearTimeout(timeout); reject(new Error(`Interactive PTY exited: ${text.slice(-1000)}`)) }
|
||||
})
|
||||
})
|
||||
} finally {
|
||||
await pty?.close()
|
||||
await launch?.dispose()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}, 70_000)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
// @vitest-environment node
|
||||
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ResolvedRuntimeSettings } from '../runtime-settings-store'
|
||||
import type { NativeTerminalLaunch } from '../terminal/terminal-session-manager'
|
||||
import type { TerminalSnapshot } from '../../shared/terminal-contracts'
|
||||
import { NativeTerminalClient, type NativeTerminalClientInput } from './native-terminal-client'
|
||||
|
||||
const roots: string[] = []
|
||||
const launches: NativeTerminalLaunch[] = []
|
||||
afterEach(async () => {
|
||||
await Promise.all(launches.splice(0).map(launch => launch.dispose()))
|
||||
await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
async function fixture() {
|
||||
const root = await mkdtemp(join(tmpdir(), 'native-terminal-test-'))
|
||||
roots.push(root)
|
||||
const create = vi.fn(async (_owner, _request, launch: NativeTerminalLaunch | undefined) => {
|
||||
launches.push(launch!)
|
||||
return { sessionId: 'terminal', state: 'running' } as TerminalSnapshot
|
||||
})
|
||||
const fetcher = vi.fn<typeof fetch>(async () => new Response(JSON.stringify({ choices: [{ message: { role: 'assistant', content: 'ok' } }] }), { headers: { 'content-type': 'application/json' } }))
|
||||
const client = new NativeTerminalClient({
|
||||
terminalManager: { create }, rootDirectory: root,
|
||||
bundledRuntimePaths: { opencode: process.execPath, continue: process.execPath, ripgrep: '', deepseekHarness: '' }, fetcher
|
||||
})
|
||||
const profile = { id: 'selected', name: 'Selected model', modelName: 'selected-model', baseUrl: 'https://provider.example/v1', authentication: 'api-key' as const, protocol: 'openai-chat-completions' as const, apiKey: 'provider-secret', requestHeaders: { 'x-provider-option': 'configured' } }
|
||||
const input: NativeTerminalClientInput = {
|
||||
projectId: 'project', projectName: 'Project', directory: root, runtime: 'opencode', workMode: 'ask',
|
||||
settings: { opencodeBinaryPath: '', opencodeModelProfile: profile } as unknown as ResolvedRuntimeSettings
|
||||
}
|
||||
return { root, client, create, fetcher, input }
|
||||
}
|
||||
|
||||
describe('native terminal client', () => {
|
||||
it('bridges successive native requests with the selected model and keeps credentials in Main', async () => {
|
||||
const { client, input, create, fetcher, root } = await fixture()
|
||||
const first = client.open(1, input)
|
||||
expect(client.open(1, input)).toBe(first)
|
||||
await first
|
||||
expect(create).toHaveBeenCalledOnce()
|
||||
const launch = launches[0]!
|
||||
expect(JSON.stringify(launch.spawnSpec)).not.toContain('provider-secret')
|
||||
const config = JSON.parse(launch.spawnSpec.env.OPENCODE_CONFIG_CONTENT!)
|
||||
const provider = Object.values(config.provider)[0] as { options: { baseURL: string; apiKey: string } }
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const response = await fetch(`${provider.options.baseURL}/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${provider.options.apiKey}` }, body: JSON.stringify({ model: 'selected-model', messages: [{ role: 'user', content: 'hi' }] }) })
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.text()).toContain('ok')
|
||||
}
|
||||
expect(fetcher).toHaveBeenCalledTimes(2)
|
||||
expect(String(fetcher.mock.calls[0]?.[0])).toBe('https://provider.example/v1/chat/completions')
|
||||
const headers = new Headers(fetcher.mock.calls[0]?.[1]?.headers)
|
||||
expect(headers.get('authorization')).toBe('Bearer provider-secret')
|
||||
expect(headers.get('x-provider-option')).toBe('configured')
|
||||
const configDirectory = launch.spawnSpec.env.OPENCODE_CONFIG_DIR!
|
||||
const pluginPath = new URL(config.plugin[0])
|
||||
const plugin = await import(/* @vite-ignore */ pluginPath.href)
|
||||
const hooks = await plugin.default()
|
||||
await expect(hooks['tool.execute.before']({ tool: 'read' })).resolves.toBeUndefined()
|
||||
await expect(hooks['tool.execute.before']({ tool: 'bash' })).rejects.toThrow('read-only')
|
||||
await expect(hooks['tool.execute.before']({ tool: 'mcp_write' })).rejects.toThrow('read-only')
|
||||
await launch.dispose()
|
||||
await expect(readdir(configDirectory)).rejects.toThrow()
|
||||
expect(await readdir(join(root, 'opencode'))).toHaveLength(1)
|
||||
await expect(fetch(`${provider.options.baseURL}/chat/completions`, { method: 'POST' })).rejects.toThrow()
|
||||
await client.open(1, input)
|
||||
expect(create).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('releases prepared resources after terminal creation fails and permits retry', async () => {
|
||||
const { client, input, create, root } = await fixture()
|
||||
create.mockImplementationOnce(async (_owner, _request, launch) => {
|
||||
launches.push(launch!)
|
||||
throw new Error('PTY unavailable')
|
||||
})
|
||||
await expect(client.open(1, input)).rejects.toThrow('PTY unavailable')
|
||||
expect((await readdir(root)).filter(name => name.startsWith('launch-'))).toEqual([])
|
||||
await expect(client.open(1, input)).resolves.toMatchObject({ state: 'running' })
|
||||
})
|
||||
|
||||
it('uses Execute permissions and explicit session MCP endpoints', async () => {
|
||||
const { client, input } = await fixture()
|
||||
await client.open(1, { ...input, workMode: 'execute', mcpServers: [{ name: 'tools', url: 'http://127.0.0.1:12345/mcp', headers: { Authorization: 'Bearer local-token' } }] })
|
||||
const launch = launches[0]!
|
||||
const config = JSON.parse(launch.spawnSpec.env.OPENCODE_CONFIG_CONTENT!)
|
||||
expect(config.permission).toBe('allow')
|
||||
expect(config.mcp.tools.url).toBe('http://127.0.0.1:12345/mcp')
|
||||
expect(await readFile(new URL(config.plugin[0]), 'utf8')).toContain('execute')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,213 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { cp, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { promisify } from 'node:util'
|
||||
import { AgentModelCallLedger, AgentModelGateway } from '../../agent-daemon/agent-model-gateway'
|
||||
import {
|
||||
ModelBridgeLoopbackProxy,
|
||||
MODEL_BRIDGE_SDK_AUTH_SENTINEL,
|
||||
createOpenCodeModelBridgeProviderConfig
|
||||
} from '../../agent-daemon/model-bridge-helper'
|
||||
import type { TerminalSize, TerminalSnapshot } from '../../shared/terminal-contracts'
|
||||
import type { ResolvedRuntimeSettings } from '../runtime-settings-store'
|
||||
import type { RuntimeSkillPackage } from '../capabilities/capability-service'
|
||||
import type { LaunchEnvironmentProvider } from '../local-tool-environment/launch-environment-provider'
|
||||
import { createManagedModelBridge, createResolvedModelProfileDigest } from '../remote-agent/managed-model-bridge'
|
||||
import type { TerminalSessionManager, NativeTerminalLaunch } from '../terminal/terminal-session-manager'
|
||||
import type { BundledRuntimePaths } from './bundled-runtimes'
|
||||
import { ContinueHostAdapter, loadContinueConfig } from './continue-host-adapter'
|
||||
import { detectRuntimeBinary } from './runtime-discovery'
|
||||
import { applyLaunchEnvironmentPath, buildCredentialFilteredUserEnvironment, runtimePrivacyEnvironment } from './process-environment'
|
||||
import { stageRuntimeSkillPackages } from './runtime-skill-packages'
|
||||
|
||||
export type NativeTerminalClientInput = {
|
||||
projectId: string
|
||||
projectName: string
|
||||
directory: string
|
||||
runtime: 'continue' | 'opencode'
|
||||
workMode: 'ask' | 'execute'
|
||||
settings: ResolvedRuntimeSettings
|
||||
size?: TerminalSize
|
||||
skillPackages?: RuntimeSkillPackage[]
|
||||
/** Session-lived, Main-owned MCP endpoints, such as the existing knowledge gateway. */
|
||||
mcpServers?: Array<{ name: string; url: string; headers: Record<string, string> }>
|
||||
}
|
||||
|
||||
export type NativeTerminalClientOptions = {
|
||||
terminalManager: Pick<TerminalSessionManager, 'create'>
|
||||
/** GoodBuddy user-data directory; native history is retained here. */
|
||||
rootDirectory: string
|
||||
bundledRuntimePaths: BundledRuntimePaths
|
||||
/** Standard Node resolved by the local tool environment, never Electron. */
|
||||
nodeExecutable?: string
|
||||
launchEnvironmentProvider?: LaunchEnvironmentProvider
|
||||
fetcher?: typeof fetch
|
||||
}
|
||||
|
||||
// Same native read permissions as the existing remote OpenCode adapter.
|
||||
const openCodeReadTools = ['read', 'glob', 'grep', 'list', 'lsp', 'webfetch', 'websearch', 'codesearch', 'question', 'external_directory']
|
||||
const continueReadTools = ['Read', 'List', 'Search', 'Fetch', 'Diff', 'AskQuestion', 'CheckBackgroundJob', 'Skills']
|
||||
|
||||
export class NativeTerminalClient {
|
||||
private readonly pending = new Map<string, Promise<TerminalSnapshot>>()
|
||||
|
||||
constructor(private readonly options: NativeTerminalClientOptions) {}
|
||||
|
||||
open(ownerId: number, input: NativeTerminalClientInput): Promise<TerminalSnapshot> {
|
||||
const profile = input.runtime === 'continue'
|
||||
? input.settings.continueModelProfile
|
||||
: input.settings.opencodeModelProfile
|
||||
if (!profile) return Promise.reject(new Error('Native client requires the selected text model connection'))
|
||||
const key = JSON.stringify([ownerId, input.projectId, input.runtime, input.workMode, createResolvedModelProfileDigest(profile)])
|
||||
const existing = this.pending.get(key)
|
||||
if (existing) return existing
|
||||
const operation = this.launch(ownerId, structuredClone(input)).finally(() => this.pending.delete(key))
|
||||
this.pending.set(key, operation)
|
||||
return operation
|
||||
}
|
||||
|
||||
private async launch(ownerId: number, input: NativeTerminalClientInput): Promise<TerminalSnapshot> {
|
||||
const launch = await this.prepare(input)
|
||||
try {
|
||||
const snapshot = await this.options.terminalManager.create(ownerId, {
|
||||
target: { type: 'project', projectId: input.projectId },
|
||||
...(input.size ?? { cols: 100, rows: 30 })
|
||||
}, launch)
|
||||
if (snapshot.state === 'failed') {
|
||||
await launch.dispose()
|
||||
throw new Error(snapshot.error?.message ?? 'Native terminal failed to start')
|
||||
}
|
||||
return snapshot
|
||||
} catch (error) {
|
||||
await launch.dispose()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async prepare(input: NativeTerminalClientInput): Promise<NativeTerminalLaunch> {
|
||||
if (!(await stat(input.directory)).isDirectory()) throw new Error('Native client project directory is unavailable')
|
||||
const selected = input.runtime === 'continue' ? input.settings.continueModelProfile : input.settings.opencodeModelProfile
|
||||
if (!selected) throw new Error('Native client requires a model connection')
|
||||
const managed = createManagedModelBridge({ profile: selected })
|
||||
const detection = await detectRuntimeBinary({
|
||||
binaryPath: input.runtime === 'continue' ? input.settings.continueBinaryPath : input.settings.opencodeBinaryPath,
|
||||
bundledPath: this.options.bundledRuntimePaths[input.runtime],
|
||||
binaryNames: input.runtime === 'continue' ? ['cn'] : ['opencode'],
|
||||
label: input.runtime,
|
||||
validation: 'canonical-file'
|
||||
})
|
||||
if (!detection.path) throw new Error(detection.detail)
|
||||
const env = applyLaunchEnvironmentPath({
|
||||
...buildCredentialFilteredUserEnvironment(), ...runtimePrivacyEnvironment
|
||||
}, this.options.launchEnvironmentProvider)
|
||||
// Do not inherit another native client's provider/config selection.
|
||||
for (const name of Object.keys(env)) {
|
||||
if (/^(OPENCODE_|CONTINUE_)/u.test(name)) delete env[name]
|
||||
}
|
||||
let executable = detection.path
|
||||
const args: string[] = []
|
||||
if (input.runtime === 'continue') {
|
||||
const node = this.options.nodeExecutable
|
||||
if (!node) throw new Error('Continue interactive terminal requires a standard Node executable from the local tool environment')
|
||||
const { stdout } = await promisify(execFile)(node, ['-p', 'JSON.stringify({node:process.versions.node,electron:process.versions.electron})'], { env, timeout: 5_000, windowsHide: true })
|
||||
const version = JSON.parse(stdout.trim()) as { node?: string; electron?: string }
|
||||
if (!version.node || version.electron) throw new Error('Continue interactive terminal requires standard Node, not Electron')
|
||||
executable = node
|
||||
args.push(detection.path)
|
||||
}
|
||||
const root = this.options.rootDirectory
|
||||
await mkdir(root, { recursive: true })
|
||||
const temporary = await mkdtemp(join(root, 'launch-'))
|
||||
const history = join(root, input.runtime, randomUUID())
|
||||
let ledger: AgentModelCallLedger | undefined
|
||||
let gateway: AgentModelGateway
|
||||
const bindingId = randomUUID()
|
||||
const proxy = new ModelBridgeLoopbackProxy({ exchange: (request, context) => gateway.dispatch({
|
||||
bindingId, operationId: context.requestId, promptSequence: 0, roundIndex: 0,
|
||||
profileDigest: managed.profile.modelProfileDigest, profile: managed.profile
|
||||
}, request, context.signal) })
|
||||
let disposal: Promise<void> | undefined
|
||||
const dispose = (): Promise<void> => disposal ??= (async () => {
|
||||
try { await proxy.close() } finally {
|
||||
ledger?.close()
|
||||
await rm(temporary, { recursive: true, force: true })
|
||||
}
|
||||
})()
|
||||
try {
|
||||
ledger = new AgentModelCallLedger(join(temporary, 'model-calls.sqlite'))
|
||||
gateway = new AgentModelGateway({ ledger, fetcher: this.options.fetcher })
|
||||
await mkdir(history, { recursive: true })
|
||||
const origin = await proxy.listen()
|
||||
const skills = input.skillPackages ?? []
|
||||
const mcp = input.workMode === 'execute' ? input.mcpServers ?? [] : []
|
||||
if (input.runtime === 'continue') {
|
||||
// Reuse the pinned adapter's protocol, permission-order and Windows fixes.
|
||||
const adapter = new ContinueHostAdapter({ binaryPath: detection.path,
|
||||
configPath: '', workspace: input.directory, cacheRoot: temporary, mode: 'chat' })
|
||||
const prepared = await adapter.getPreparedHost()
|
||||
const bundlePath = join(dirname(prepared.entryPath), 'index.js')
|
||||
const bundle = await readFile(bundlePath, 'utf8')
|
||||
const executionMarker = 'async function hti(e,t={parallelToolCallCount:1}){'
|
||||
if (bundle.split(executionMarker).length !== 2) throw new Error('Continue native tool execution entry point is incompatible')
|
||||
await writeFile(bundlePath, bundle.replace(executionMarker, `${executionMarker}if(${JSON.stringify(input.workMode)}==="ask"&&!${JSON.stringify(continueReadTools)}.includes(e.name))throw new Error("GoodBuddy Ask mode is read-only");`))
|
||||
args[0] = prepared.entryPath
|
||||
const configured = input.settings.continueConfigPath.trim()
|
||||
? await loadContinueConfig(input.settings.continueConfigPath.trim()) : {}
|
||||
const configPath = join(temporary, 'continue.json')
|
||||
await writeFile(configPath, JSON.stringify({
|
||||
name: 'GoodBuddy Native Client', version: '1.0.0', schema: 'v1',
|
||||
models: [{ name: selected.name, model: selected.modelName,
|
||||
provider: selected.protocol === 'anthropic-messages' ? 'anthropic' : 'openai',
|
||||
apiBase: `${origin}/v1`, apiKey: MODEL_BRIDGE_SDK_AUTH_SENTINEL,
|
||||
roles: ['chat'], capabilities: selected.supportsImageInput ? ['image_input'] : [],
|
||||
...(selected.protocol !== 'anthropic-messages' ? { useResponsesApi: selected.protocol === 'openai-responses' } : {})
|
||||
}],
|
||||
...(configured.rules ? { rules: configured.rules } : {}),
|
||||
...(configured.prompts ? { prompts: configured.prompts } : {}),
|
||||
mcpServers: mcp.map(server => ({ name: server.name, type: 'streamable-http', url: server.url, requestOptions: { headers: server.headers } }))
|
||||
}), { mode: 0o600 })
|
||||
await stageRuntimeSkillPackages(history, skills, 'Continue')
|
||||
Object.assign(env, { CONTINUE_GLOBAL_DIR: history, GOODBUDDY_DISABLE_CONTINUE_UPDATES: '1', CONTINUE_CLI_AUTO_UPDATED: '1', CONTINUE_CLI_ENABLE_TELEMETRY: '0', CONTINUE_METRICS_ENABLED: '0', CONTINUE_CLI_DISABLE_COMMIT_SIGNATURE: '1' })
|
||||
args.push('--config', configPath)
|
||||
if (input.workMode === 'execute') args.push('--auto')
|
||||
else args.push('--readonly', ...continueReadTools.flatMap(name => ['--allow', name]), '--exclude', '*')
|
||||
} else {
|
||||
const configDirectory = join(temporary, 'config')
|
||||
if (this.options.bundledRuntimePaths.opencodeConfig) {
|
||||
await cp(this.options.bundledRuntimePaths.opencodeConfig, configDirectory, { recursive: true })
|
||||
}
|
||||
const skillsRoot = await stageRuntimeSkillPackages(configDirectory, skills, 'OpenCode')
|
||||
const config = createOpenCodeModelBridgeProviderConfig({
|
||||
protocol: managed.profile.protocol, model: selected.modelName, name: selected.name,
|
||||
loopbackOrigin: origin, supportsImageInput: selected.supportsImageInput, workMode: input.workMode
|
||||
})
|
||||
const permission = input.workMode === 'execute' ? 'allow' : Object.fromEntries([
|
||||
['*', 'deny'], ...openCodeReadTools.map(name => [name, 'allow']), ['skill', 'allow']
|
||||
])
|
||||
// A tool hook keeps the launch mode fixed even when the TUI changes agents.
|
||||
const pluginPath = join(temporary, 'work-mode.mjs')
|
||||
await writeFile(pluginPath, `export default async () => ({'tool.execute.before': async ({tool}) => { if (${JSON.stringify(input.workMode)} === 'ask' && !${JSON.stringify([...openCodeReadTools, 'skill'])}.includes(tool)) throw new Error('GoodBuddy Ask mode is read-only'); }});`, { mode: 0o600 })
|
||||
Object.assign(env, {
|
||||
OPENCODE_CONFIG_DIR: configDirectory,
|
||||
OPENCODE_CONFIG_CONTENT: JSON.stringify({ ...config, permission,
|
||||
agent: { ...config.agent, build: { permission }, plan: { permission } },
|
||||
plugin: [pathToFileURL(pluginPath).href], skills: { paths: [skillsRoot] },
|
||||
mcp: Object.fromEntries(mcp.map(server => [server.name, { type: 'remote', url: server.url, headers: server.headers, enabled: true }]))
|
||||
}),
|
||||
OPENCODE_DISABLE_AUTOUPDATE: '1', OPENCODE_DISABLE_PROJECT_CONFIG: '1',
|
||||
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS: '1', OPENCODE_DISABLE_EXTERNAL_SKILLS: '1',
|
||||
OPENCODE_DISABLE_MODELS_FETCH: '1', OPENCODE_DISABLE_LSP_DOWNLOAD: '1', OPENCODE_DISABLE_SHARE: '1',
|
||||
XDG_CONFIG_HOME: join(temporary, 'xdg-config'), XDG_CACHE_HOME: join(root, 'cache'),
|
||||
XDG_DATA_HOME: join(history, 'data'), XDG_STATE_HOME: join(history, 'state')
|
||||
})
|
||||
args.push('--model', config.model)
|
||||
}
|
||||
return { spawnSpec: { executable, args, cwd: input.directory, env, label: input.runtime }, title: `${input.runtime} · ${input.projectName}`, dispose }
|
||||
} catch (error) {
|
||||
await dispose()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -219,7 +219,7 @@ describe('runtime discovery', () => {
|
||||
binaryPath: '',
|
||||
bundledPath: bundledScript,
|
||||
bundledValidation: 'canonical-file',
|
||||
bundledVersion: '0.1.2-rc.1',
|
||||
bundledVersion: '0.1.7-rc.2',
|
||||
binaryNames: [],
|
||||
label: 'GoodBuddy DeepSeek Harness Host'
|
||||
})
|
||||
@@ -227,7 +227,7 @@ describe('runtime discovery', () => {
|
||||
expect(detection).toMatchObject({
|
||||
available: true,
|
||||
path: await realpath(bundledScript),
|
||||
version: '0.1.2-rc.1',
|
||||
version: '0.1.7-rc.2',
|
||||
source: 'bundled'
|
||||
})
|
||||
expect(detection.detail).toContain('内置')
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
import { dialog, type BrowserWindow, type ipcMain } from 'electron'
|
||||
import { z } from 'zod'
|
||||
import { ipcChannels } from '../../shared/ipc-channels'
|
||||
import {
|
||||
browserProfileCreateInputSchema,
|
||||
browserProfileRenameInputSchema,
|
||||
browserProfileSelectionInputSchema,
|
||||
builtinMcpServerAssignmentsInputSchema,
|
||||
builtinMcpServerToggleInputSchema,
|
||||
computerCapabilityConfigInputSchema,
|
||||
computerCapabilityIdSchema,
|
||||
computerCapabilityToggleInputSchema,
|
||||
mcpServerIdSchema,
|
||||
mcpServerInputSchema,
|
||||
obsidianSettingsSchema,
|
||||
obsidianConnectionTestResultSchema,
|
||||
skillAssignmentsInputSchema,
|
||||
skillIdSchema,
|
||||
skillImportKindSchema,
|
||||
skillToggleInputSchema,
|
||||
type CapabilitySnapshot,
|
||||
type CapabilityDiagnosticReport,
|
||||
type McpServerTestResult,
|
||||
type WebSearchTestResult
|
||||
} from '../../shared/capability-contracts'
|
||||
import {
|
||||
runtimeExtensionActionSchema,
|
||||
type RuntimeExtensionMarketplaceSnapshot
|
||||
} from '../../shared/runtime-extension-contracts'
|
||||
import type { KnowledgeMcpGateway } from '../agent/knowledge-mcp-gateway'
|
||||
import type { RuntimeExtensionStore } from '../agent/runtime-extension-store'
|
||||
import type { LocalToolEnvironmentService } from '../local-tool-environment'
|
||||
import type { ObsidianService } from '../obsidian'
|
||||
import { assertTrustedSender } from '../trusted-ipc-sender'
|
||||
import type { CapabilityService } from './capability-service'
|
||||
import { testMcpServer } from './mcp-tester'
|
||||
import { testWebSearch } from './web-search-tester'
|
||||
|
||||
const mcpServerSaveSchema = z
|
||||
.object({
|
||||
serverId: mcpServerIdSchema.optional(),
|
||||
input: mcpServerInputSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
export function registerCapabilityIpcHandlers(
|
||||
registerHandler: typeof ipcMain.handle,
|
||||
window: BrowserWindow,
|
||||
{
|
||||
capabilityService,
|
||||
refreshCapabilities,
|
||||
onRuntimeSettingsChanged,
|
||||
runtimeExtensionStore,
|
||||
knowledgeGateway,
|
||||
obsidianService,
|
||||
localToolEnvironmentService
|
||||
}: {
|
||||
capabilityService: CapabilityService
|
||||
refreshCapabilities: (
|
||||
operation: Promise<CapabilitySnapshot>,
|
||||
reconfigureRuntime?: boolean
|
||||
) => Promise<CapabilitySnapshot>
|
||||
onRuntimeSettingsChanged: () => Promise<void>
|
||||
runtimeExtensionStore?: RuntimeExtensionStore
|
||||
knowledgeGateway?: Pick<KnowledgeMcpGateway, 'revokeObsidianCapabilities'>
|
||||
obsidianService?: Pick<ObsidianService, 'testConnection'>
|
||||
localToolEnvironmentService?: Pick<LocalToolEnvironmentService, 'launchEnvironmentProvider'>
|
||||
}
|
||||
): void {
|
||||
registerHandler(
|
||||
ipcChannels.capabilitiesSnapshot,
|
||||
(event): Promise<CapabilitySnapshot> => {
|
||||
assertTrustedSender(event, window)
|
||||
return capabilityService.getSnapshot()
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.runtimeExtensionsSnapshot,
|
||||
(event): Promise<RuntimeExtensionMarketplaceSnapshot> => {
|
||||
assertTrustedSender(event, window)
|
||||
if (!runtimeExtensionStore) {
|
||||
throw new Error('DSH 插件市场不可用')
|
||||
}
|
||||
return runtimeExtensionStore.getSnapshot()
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.runtimeExtensionsApply,
|
||||
async (
|
||||
event,
|
||||
input: unknown
|
||||
): Promise<RuntimeExtensionMarketplaceSnapshot> => {
|
||||
assertTrustedSender(event, window)
|
||||
if (!runtimeExtensionStore) {
|
||||
throw new Error('DSH 插件市场不可用')
|
||||
}
|
||||
const action = runtimeExtensionActionSchema.parse(input)
|
||||
const result =
|
||||
await runtimeExtensionStore.applyWithResult(action)
|
||||
if (
|
||||
result.changed &&
|
||||
action.type !== 'set-marketplace-enabled'
|
||||
) {
|
||||
await onRuntimeSettingsChanged()
|
||||
}
|
||||
return result.snapshot
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.capabilitiesImportSkill,
|
||||
async (event, input: unknown): Promise<CapabilitySnapshot> => {
|
||||
assertTrustedSender(event, window)
|
||||
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()
|
||||
}
|
||||
return refreshCapabilities(
|
||||
capabilityService.importSkill(result.filePaths[0])
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.capabilitiesRemoveSkill,
|
||||
(event, input: unknown): Promise<CapabilitySnapshot> => {
|
||||
assertTrustedSender(event, window)
|
||||
return refreshCapabilities(
|
||||
capabilityService.removeSkill(skillIdSchema.parse(input))
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.capabilitiesToggleSkill,
|
||||
(event, input: unknown): Promise<CapabilitySnapshot> => {
|
||||
assertTrustedSender(event, window)
|
||||
const value = skillToggleInputSchema.parse(input)
|
||||
return refreshCapabilities(
|
||||
capabilityService.setSkillEnabled(
|
||||
value.skillId,
|
||||
value.enabled
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.capabilitiesAssignSkill,
|
||||
(event, input: unknown): Promise<CapabilitySnapshot> => {
|
||||
assertTrustedSender(event, window)
|
||||
const value = skillAssignmentsInputSchema.parse(input)
|
||||
return refreshCapabilities(
|
||||
capabilityService.setSkillAssignments(
|
||||
value.skillId,
|
||||
value.assignments
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.capabilitiesUpdateObsidianSettings,
|
||||
(event, input: unknown): Promise<CapabilitySnapshot> => {
|
||||
assertTrustedSender(event, window)
|
||||
const settings = obsidianSettingsSchema.parse(input)
|
||||
knowledgeGateway?.revokeObsidianCapabilities()
|
||||
return refreshCapabilities(
|
||||
capabilityService.updateObsidianSettings(settings)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.capabilitiesTestObsidianConnection,
|
||||
async (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const settings = obsidianSettingsSchema.parse(input)
|
||||
if (!obsidianService) throw new Error('Obsidian 服务不可用')
|
||||
return obsidianConnectionTestResultSchema.parse(
|
||||
await obsidianService.testConnection(settings)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.capabilitiesSelectObsidianVault,
|
||||
async (event): Promise<string | null> => {
|
||||
assertTrustedSender(event, window)
|
||||
const result = await dialog.showOpenDialog(window, {
|
||||
properties: ['openDirectory']
|
||||
})
|
||||
return result.canceled ? null : result.filePaths[0] ?? null
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.capabilitiesToggleBuiltinMcp,
|
||||
(event, input: unknown): Promise<CapabilitySnapshot> => {
|
||||
assertTrustedSender(event, window)
|
||||
const value = builtinMcpServerToggleInputSchema.parse(input)
|
||||
if (value.serverId === 'obsidian') knowledgeGateway?.revokeObsidianCapabilities()
|
||||
return refreshCapabilities(
|
||||
capabilityService.setBuiltinMcpServerEnabled(
|
||||
value.serverId,
|
||||
value.enabled
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.capabilitiesAssignBuiltinMcp,
|
||||
(event, input: unknown): Promise<CapabilitySnapshot> => {
|
||||
assertTrustedSender(event, window)
|
||||
const value = builtinMcpServerAssignmentsInputSchema.parse(input)
|
||||
if (value.serverId === 'obsidian') knowledgeGateway?.revokeObsidianCapabilities()
|
||||
return refreshCapabilities(
|
||||
capabilityService.setBuiltinMcpServerAssignments(
|
||||
value.serverId,
|
||||
value.assignments
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.capabilitiesSaveMcp,
|
||||
(event, input: unknown): Promise<CapabilitySnapshot> => {
|
||||
assertTrustedSender(event, window)
|
||||
const value = mcpServerSaveSchema.parse(input)
|
||||
return refreshCapabilities(
|
||||
capabilityService.saveMcpServer(value.serverId, value.input)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.capabilitiesRemoveMcp,
|
||||
(event, input: unknown): Promise<CapabilitySnapshot> => {
|
||||
assertTrustedSender(event, window)
|
||||
return refreshCapabilities(
|
||||
capabilityService.removeMcpServer(
|
||||
mcpServerIdSchema.parse(input)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.capabilitiesTestMcp,
|
||||
async (event, input: unknown): Promise<McpServerTestResult> => {
|
||||
assertTrustedSender(event, window)
|
||||
return testMcpServer(
|
||||
await capabilityService.getResolvedMcpServer(
|
||||
mcpServerIdSchema.parse(input)
|
||||
),
|
||||
undefined,
|
||||
localToolEnvironmentService?.launchEnvironmentProvider
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.capabilitiesToggleWebSearch,
|
||||
(event, input: unknown): Promise<CapabilitySnapshot> => {
|
||||
assertTrustedSender(event, window)
|
||||
return refreshCapabilities(
|
||||
capabilityService.setWebSearchEnabled(z.boolean().parse(input))
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.capabilitiesTestWebSearch,
|
||||
(event): Promise<WebSearchTestResult> => {
|
||||
assertTrustedSender(event, window)
|
||||
return testWebSearch()
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.capabilitiesToggleComputer,
|
||||
(event, input: unknown): Promise<CapabilitySnapshot> => {
|
||||
assertTrustedSender(event, window)
|
||||
const value = computerCapabilityToggleInputSchema.parse(input)
|
||||
return refreshCapabilities(
|
||||
capabilityService.setComputerCapabilityEnabled(
|
||||
value.capabilityId,
|
||||
value.enabled
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.capabilitiesConfigureComputer,
|
||||
(event, input: unknown): Promise<CapabilitySnapshot> => {
|
||||
assertTrustedSender(event, window)
|
||||
const value = computerCapabilityConfigInputSchema.parse(input)
|
||||
return refreshCapabilities(
|
||||
capabilityService.setComputerCapabilityBrowserProfile(
|
||||
value.capabilityId,
|
||||
value.browserProfileId
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.capabilitiesDiagnoseComputer,
|
||||
(event, input: unknown): Promise<CapabilityDiagnosticReport> => {
|
||||
assertTrustedSender(event, window)
|
||||
return capabilityService.diagnoseComputerCapability(
|
||||
computerCapabilityIdSchema.parse(input)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.capabilitiesCreateBrowserProfile,
|
||||
(event, input: unknown): Promise<CapabilitySnapshot> => {
|
||||
assertTrustedSender(event, window)
|
||||
const value = browserProfileCreateInputSchema.parse(input)
|
||||
return refreshCapabilities(
|
||||
capabilityService.createBrowserProfile(value.name),
|
||||
false
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.capabilitiesRenameBrowserProfile,
|
||||
(event, input: unknown): Promise<CapabilitySnapshot> => {
|
||||
assertTrustedSender(event, window)
|
||||
const value = browserProfileRenameInputSchema.parse(input)
|
||||
return refreshCapabilities(
|
||||
capabilityService.renameBrowserProfile(value.profileId, value.name),
|
||||
false
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.capabilitiesDefaultBrowserProfile,
|
||||
(event, input: unknown): Promise<CapabilitySnapshot> => {
|
||||
assertTrustedSender(event, window)
|
||||
const value = browserProfileSelectionInputSchema.parse(input)
|
||||
return refreshCapabilities(
|
||||
capabilityService.setDefaultBrowserProfile(value.profileId),
|
||||
false
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.capabilitiesRemoveBrowserProfile,
|
||||
(event, input: unknown): Promise<CapabilitySnapshot> => {
|
||||
assertTrustedSender(event, window)
|
||||
const value = browserProfileSelectionInputSchema.parse(input)
|
||||
return refreshCapabilities(
|
||||
capabilityService.removeBrowserProfile(value.profileId),
|
||||
false
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -33,7 +33,7 @@ describe('controlled DeepSeek Harness host', () => {
|
||||
api: 'openai-completions',
|
||||
provider: 'goodbuddy',
|
||||
model: 'deepseek-test',
|
||||
harnessVersion: '0.1.2-rc.1',
|
||||
harnessVersion: '0.1.7-rc.2',
|
||||
credentialRefs: ['GOODBUDDY_API_KEY'],
|
||||
dshHome: 'C:\\controlled-dsh-home',
|
||||
skillPackages: []
|
||||
@@ -82,7 +82,7 @@ describe('controlled DeepSeek Harness host', () => {
|
||||
provider: 'goodbuddy',
|
||||
model: 'qwen-plus',
|
||||
supportsImageInput: true,
|
||||
harnessVersion: '0.1.2-rc.1',
|
||||
harnessVersion: '0.1.7-rc.2',
|
||||
credentialRefs: ['GOODBUDDY_API_KEY'],
|
||||
skillPackages: [],
|
||||
stream: {
|
||||
@@ -103,7 +103,7 @@ describe('controlled DeepSeek Harness host', () => {
|
||||
command: 'echo goodbuddy-host-execution'
|
||||
}).workdir
|
||||
).toBe(root)
|
||||
const execution = await host.context.shell.run(
|
||||
const processHandle = await host.context.shell.execute(
|
||||
host.context.shell.resolve({
|
||||
command:
|
||||
process.platform === 'win32'
|
||||
@@ -111,6 +111,7 @@ describe('controlled DeepSeek Harness host', () => {
|
||||
: 'printf goodbuddy-host-execution'
|
||||
})
|
||||
)
|
||||
const execution = await processHandle.result()
|
||||
expect(execution).toMatchObject({
|
||||
exitCode: 0,
|
||||
timedOut: false,
|
||||
@@ -143,7 +144,7 @@ describe('controlled DeepSeek Harness host', () => {
|
||||
api: 'openai-completions',
|
||||
provider: 'goodbuddy',
|
||||
model: 'deepseek-test',
|
||||
harnessVersion: '0.1.2-rc.1',
|
||||
harnessVersion: '0.1.7-rc.2',
|
||||
credentialRefs: ['GOODBUDDY_API_KEY'],
|
||||
skillPackages: [],
|
||||
stream: {
|
||||
@@ -181,7 +182,7 @@ describe('controlled DeepSeek Harness host', () => {
|
||||
api: 'openai-completions',
|
||||
provider: 'goodbuddy',
|
||||
model: 'deepseek-test',
|
||||
harnessVersion: '0.1.2-rc.1',
|
||||
harnessVersion: '0.1.7-rc.2',
|
||||
credentialRefs: ['GOODBUDDY_API_KEY'],
|
||||
skillPackages: [],
|
||||
extensionPackages: [
|
||||
@@ -237,7 +238,7 @@ describe('controlled DeepSeek Harness host', () => {
|
||||
api: 'openai-completions',
|
||||
provider: 'goodbuddy',
|
||||
model: 'deepseek-test',
|
||||
harnessVersion: '0.1.2-rc.1',
|
||||
harnessVersion: '0.1.7-rc.2',
|
||||
credentialRefs: ['GOODBUDDY_API_KEY'],
|
||||
skillPackages: [
|
||||
{ id: 'web-3d-game', directory: skillDirectory }
|
||||
@@ -256,7 +257,6 @@ describe('controlled DeepSeek Harness host', () => {
|
||||
isolate: ['skills', 'tools']
|
||||
})
|
||||
createdContext = agentContext
|
||||
await options.setup?.(agentContext)
|
||||
const agent = {
|
||||
options: options.agentOptions ?? {},
|
||||
session: {
|
||||
@@ -268,6 +268,7 @@ describe('controlled DeepSeek Harness host', () => {
|
||||
ctx: agentContext,
|
||||
cancel: vi.fn()
|
||||
}
|
||||
await options.setup?.(agentContext, agent as never)
|
||||
createdAgent = agent as never
|
||||
return {
|
||||
agent,
|
||||
|
||||
@@ -31,7 +31,7 @@ describe.skipIf(!entrypoint)(
|
||||
api: 'openai-completions',
|
||||
provider: 'goodbuddy',
|
||||
model: 'deepseek-test',
|
||||
harnessVersion: '0.1.2-rc.1',
|
||||
harnessVersion: '0.1.7-rc.2',
|
||||
credentialRefs: ['GOODBUDDY_API_KEY'],
|
||||
skillPackages: [],
|
||||
extensionPackages: [
|
||||
|
||||
+21
-1
@@ -1,4 +1,5 @@
|
||||
import { localInferenceService } from './local-inference-service'
|
||||
import { NativeClientCoordinator } from './agent/native-client-coordinator'
|
||||
import { createEmbeddingUtilityTransport } from './knowledge/embedding-utility-transport'
|
||||
import {
|
||||
app,
|
||||
@@ -229,6 +230,7 @@ let stopRuntimeReconfiguration: (() => Promise<void>) | undefined
|
||||
let dshExtensionInstaller: DshNpmExtensionInstaller | undefined
|
||||
let remoteAgentServices: RemoteAgentServices | undefined
|
||||
let terminalSessionManager: TerminalSessionManager | undefined
|
||||
let nativeClientCoordinator: NativeClientCoordinator | undefined
|
||||
let managedRemoteExecutionServices:
|
||||
| ManagedRemoteExecutionServices
|
||||
| undefined
|
||||
@@ -1424,6 +1426,22 @@ if (hasSingleInstanceLock) {
|
||||
}
|
||||
}
|
||||
})
|
||||
nativeClientCoordinator = new NativeClientCoordinator({
|
||||
database: startupAssistantDatabase, settingsStore, applicationSettingsStore,
|
||||
capabilities: capabilityService, executionSpaceResolver, terminalManager: terminalSessionManager,
|
||||
localEnvironment: startupLocalToolEnvironmentService, bundledRuntimePaths,
|
||||
rootDirectory: join(app.getPath('userData'), 'native-clients'),
|
||||
managedNodeDirectory: join(toolEnvironmentRoot, 'native-node-22.22.0'),
|
||||
npmCliPath: npmCliPaths.npmCliPath,
|
||||
resourcesPath: app.isPackaged ? process.resourcesPath : undefined,
|
||||
openExternal: (url) => shell.openExternal(url),
|
||||
createGateway: () => new KnowledgeMcpGateway(startupKnowledgeService, {
|
||||
// This dedicated gateway lives until native-client disposal, not a chat request timeout.
|
||||
now: () => 0,
|
||||
magicNotesDatabase: startupAssistantDatabase, configService: goodbuddyConfigService,
|
||||
obsidianService, launchEnvironmentProvider: startupLocalToolEnvironmentService.launchEnvironmentProvider
|
||||
})
|
||||
})
|
||||
removeIpcHandlers = registerIpcHandlers(
|
||||
mainWindow,
|
||||
runtime,
|
||||
@@ -1485,7 +1503,8 @@ if (hasSingleInstanceLock) {
|
||||
terminalSessionManager,
|
||||
startupLocalToolEnvironmentService,
|
||||
imageGenerationService,
|
||||
obsidianService
|
||||
obsidianService,
|
||||
nativeClientCoordinator
|
||||
)
|
||||
removeFeedbackIpcHandler = registerFeedbackIpcHandler(
|
||||
mainWindow,
|
||||
@@ -1583,6 +1602,7 @@ app.on('before-quit', (event) => {
|
||||
() => dshExtensionInstaller?.dispose(),
|
||||
() => imageGenerationService?.dispose(),
|
||||
() => knowledgeService?.beginShutdown(),
|
||||
() => nativeClientCoordinator?.dispose(),
|
||||
() => removeIpcHandlers?.()
|
||||
],
|
||||
[() => stopRuntimeReconfiguration?.()],
|
||||
|
||||
+255
-8
@@ -80,14 +80,10 @@ describe('embedding IPC boundary', () => {
|
||||
|
||||
describe('local tool environment IPC boundary', () => {
|
||||
it('trust-checks every operation and accepts only shared bounded schemas', async () => {
|
||||
const source = await readFile(
|
||||
join(process.cwd(), 'src', 'main', 'ipc.ts'),
|
||||
const region = await readFile(
|
||||
join(process.cwd(), 'src', 'main', 'local-tool-environment', 'local-tool-environment-ipc.ts'),
|
||||
'utf8'
|
||||
)
|
||||
const region =
|
||||
source.match(
|
||||
/const requireLocalToolEnvironmentService[\s\S]*?registerHandler\(ipcChannels\.shortcutSettingsGet/u
|
||||
)?.[0] ?? ''
|
||||
expect(region.match(/assertTrustedSender\(event, window\)/gu)).toHaveLength(8)
|
||||
expect(region).toContain('localToolEnvironmentSettingsSchema.parse(input)')
|
||||
expect(region).toContain('localToolKindInputSchema.parse(input)')
|
||||
@@ -335,6 +331,7 @@ const electronMocks = vi.hoisted(() => {
|
||||
const handlers = new Map<string, InvokeHandler>()
|
||||
return {
|
||||
handlers,
|
||||
invoke: vi.fn(),
|
||||
handle: vi.fn((channel: string, handler: InvokeHandler) => {
|
||||
handlers.set(channel, handler)
|
||||
}),
|
||||
@@ -1008,6 +1005,40 @@ describe('registerIpcHandlers computer capabilities', () => {
|
||||
)
|
||||
expect(onRuntimeSettingsChanged).toHaveBeenCalledTimes(5)
|
||||
|
||||
const capabilityChannels = [
|
||||
ipcChannels.capabilitiesSnapshot,
|
||||
ipcChannels.runtimeExtensionsSnapshot,
|
||||
ipcChannels.runtimeExtensionsApply,
|
||||
ipcChannels.capabilitiesImportSkill,
|
||||
ipcChannels.capabilitiesRemoveSkill,
|
||||
ipcChannels.capabilitiesToggleSkill,
|
||||
ipcChannels.capabilitiesAssignSkill,
|
||||
ipcChannels.capabilitiesUpdateObsidianSettings,
|
||||
ipcChannels.capabilitiesTestObsidianConnection,
|
||||
ipcChannels.capabilitiesSelectObsidianVault,
|
||||
ipcChannels.capabilitiesToggleBuiltinMcp,
|
||||
ipcChannels.capabilitiesAssignBuiltinMcp,
|
||||
ipcChannels.capabilitiesSaveMcp,
|
||||
ipcChannels.capabilitiesRemoveMcp,
|
||||
ipcChannels.capabilitiesTestMcp,
|
||||
ipcChannels.capabilitiesToggleWebSearch,
|
||||
ipcChannels.capabilitiesTestWebSearch,
|
||||
ipcChannels.capabilitiesToggleComputer,
|
||||
ipcChannels.capabilitiesConfigureComputer,
|
||||
ipcChannels.capabilitiesDiagnoseComputer,
|
||||
ipcChannels.capabilitiesCreateBrowserProfile,
|
||||
ipcChannels.capabilitiesRenameBrowserProfile,
|
||||
ipcChannels.capabilitiesDefaultBrowserProfile,
|
||||
ipcChannels.capabilitiesRemoveBrowserProfile
|
||||
]
|
||||
const registrations = electronMocks.handle.mock.calls.map(([channel]) => channel)
|
||||
const start = registrations.indexOf(ipcChannels.capabilitiesSnapshot)
|
||||
expect(registrations.slice(start - 1, start + capabilityChannels.length + 1)).toEqual([
|
||||
ipcChannels.expertsRemove,
|
||||
...capabilityChannels,
|
||||
ipcChannels.contextSelectFiles
|
||||
])
|
||||
|
||||
expect(() =>
|
||||
electronMocks.handlers.get(
|
||||
ipcChannels.capabilitiesDiagnoseComputer
|
||||
@@ -1020,6 +1051,10 @@ describe('registerIpcHandlers computer capabilities', () => {
|
||||
)
|
||||
).toThrow('拒绝来自未知窗口的 IPC 请求')
|
||||
await dispose()
|
||||
for (const channel of capabilityChannels) {
|
||||
expect(electronMocks.removeHandler).toHaveBeenCalledWith(channel)
|
||||
expect(electronMocks.handlers.has(channel)).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1329,6 +1364,9 @@ describe('registerIpcHandlers model download source routing', () => {
|
||||
})
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
contextBridge: { exposeInMainWorld: (name: string, value: unknown) => vi.stubGlobal(name, value) },
|
||||
ipcRenderer: { invoke: electronMocks.invoke },
|
||||
webUtils: {},
|
||||
nativeImage: { createFromBuffer: (buffer: Buffer) => ({ isEmpty: () => false, toPNG: () => buffer }) },
|
||||
app: {
|
||||
getAppPath: vi.fn(() => process.cwd()),
|
||||
@@ -5116,7 +5154,9 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
imageService?: ImageGenerationService,
|
||||
imageDatabase?: AssistantDatabase,
|
||||
heartbeatEnabled?: boolean,
|
||||
obsidianService?: ObsidianService
|
||||
obsidianService?: ObsidianService,
|
||||
nativeClientCoordinator?: Parameters<typeof registerIpcHandlers>[45],
|
||||
nativeTerminalManager?: Parameters<typeof registerIpcHandlers>[41]
|
||||
) {
|
||||
const assistantDatabase = {
|
||||
createTask: vi.fn(),
|
||||
@@ -5240,7 +5280,10 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
id: 9,
|
||||
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
||||
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
||||
send: vi.fn()
|
||||
send: vi.fn(),
|
||||
once: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
isDestroyed: vi.fn(() => false)
|
||||
}
|
||||
const window = {
|
||||
webContents,
|
||||
@@ -5348,6 +5391,8 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
]
|
||||
args[43] = imageService
|
||||
args[44] = obsidianService
|
||||
args[45] = nativeClientCoordinator
|
||||
args[41] = nativeTerminalManager
|
||||
const dispose = registerIpcHandlers(...args)
|
||||
return {
|
||||
approvalBroker,
|
||||
@@ -5952,6 +5997,35 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
} finally { finish(); await harness.dispose(); database.close() }
|
||||
})
|
||||
|
||||
it('routes native clients through validated owner-scoped IPC and cleans up the window', async () => {
|
||||
const coordinator = {
|
||||
open: vi.fn(async () => ({ kind: 'browser' as const, serviceId: 'native-service' })),
|
||||
get: vi.fn(async () => ({ serviceId: 'native-service' })),
|
||||
stop: vi.fn(async () => {}),
|
||||
closeOwner: vi.fn(async () => {})
|
||||
}
|
||||
const harness = createHarness({}, undefined, 'always', undefined, false, undefined,
|
||||
undefined, undefined, false, undefined, undefined, undefined, undefined, undefined, false, undefined, coordinator)
|
||||
const event = trustedEvent(harness.webContents)
|
||||
const input = { conversationId: '00000000-0000-4000-8000-000000000401' }
|
||||
const open = electronMocks.handlers.get(ipcChannels.runtimeNativeClientOpen)!
|
||||
const get = electronMocks.handlers.get(ipcChannels.runtimeNativeClientGet)!
|
||||
const stop = electronMocks.handlers.get(ipcChannels.runtimeNativeClientStop)!
|
||||
await expect(open(event, input)).resolves.toEqual({ kind: 'browser', serviceId: 'native-service' })
|
||||
expect(coordinator.open).toHaveBeenCalledWith(9, input.conversationId)
|
||||
await expect(get(event, input)).resolves.toEqual({ serviceId: 'native-service' })
|
||||
expect(coordinator.get).toHaveBeenCalledWith(9, input.conversationId)
|
||||
await stop(event, { serviceId: 'native-service' })
|
||||
expect(coordinator.stop).toHaveBeenCalledWith(9, 'native-service')
|
||||
await expect(open(event, { ...input, runtime: 'continue' })).rejects.toThrow()
|
||||
await expect(open({ ...event, sender: { id: 99 } }, input)).rejects.toThrow()
|
||||
await expect(stop(event, { serviceId: '' })).rejects.toThrow()
|
||||
expect(coordinator.open).toHaveBeenCalledTimes(1)
|
||||
expect(harness.webContents.once).toHaveBeenCalledWith('destroyed', expect.any(Function))
|
||||
await harness.dispose()
|
||||
expect(coordinator.closeOwner).toHaveBeenCalledWith(9)
|
||||
})
|
||||
|
||||
it('production supervision IPC pauses in-flight work and resumes saved batches without replaying them', async () => {
|
||||
const database = new AssistantDatabase(':memory:')
|
||||
database.initialize(process.cwd())
|
||||
@@ -8637,6 +8711,179 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(!process.env.GOODBUDDY_NATIVE_CLIENT_INTEGRATION)('opens and stops official native clients from the real UI through preload, IPC and saved project settings', async () => {
|
||||
const { createElement } = await import('react')
|
||||
const { render, fireEvent, waitFor, cleanup } = await import('@testing-library/react/pure')
|
||||
const rendererModule = '../renderer/src/RuntimeNativeClientActions'
|
||||
const { RuntimeNativeClientActions } = await import(rendererModule) as {
|
||||
RuntimeNativeClientActions: import('react').ComponentType<{
|
||||
browser: boolean; contextKey: string; conversationId: string; prepareConversation: () => Promise<string>
|
||||
onTerminal: (value: import('../shared/terminal-contracts').TerminalSnapshot) => void; notify: (value: unknown) => void
|
||||
}>
|
||||
}
|
||||
const { default: i18n } = await import('i18next')
|
||||
const { NativeClientCoordinator } = await import('./agent/native-client-coordinator')
|
||||
const { RuntimeSettingsStore } = await import('./runtime-settings-store')
|
||||
const { ApplicationSettingsStore } = await import('./application-settings-store')
|
||||
const { ExecutionSpaceResolver } = await import('./execution-space/execution-space-resolver')
|
||||
const { KnowledgeMcpGateway } = await import('./agent/knowledge-mcp-gateway')
|
||||
const { TerminalSessionManager } = await import('./terminal/terminal-session-manager')
|
||||
const { resolveNpmCliPaths } = await import('./local-tool-environment')
|
||||
const { resolveBundledRuntimePaths } = await import('./agent/bundled-runtimes')
|
||||
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-native-ui-'))
|
||||
const database = new AssistantDatabase(join(root, 'assistant.sqlite'))
|
||||
const liveModule = process.env.GOODBUDDY_NATIVE_CLIENT_LIVE_MODULE
|
||||
const { createRequire } = await import('node:module')
|
||||
const live = liveModule ? createRequire(import.meta.url)(liveModule) as {
|
||||
begin: (fetcher: typeof fetch) => typeof fetch
|
||||
profile: (runtime: string) => import('./runtime-settings-store').ResolvedModelProfile
|
||||
web: (url: string, workspace: string) => Promise<void>
|
||||
terminal: (runtime: string, write: (data: string) => Promise<unknown>, output: () => string) => Promise<void>
|
||||
finish: () => Promise<void>
|
||||
} : undefined
|
||||
if (live) vi.stubGlobal('fetch', live.begin(fetch))
|
||||
let coordinator: InstanceType<typeof NativeClientCoordinator> | undefined
|
||||
let harness: ReturnType<typeof createHarness> | undefined
|
||||
let terminalManager: InstanceType<typeof TerminalSessionManager> | undefined
|
||||
try {
|
||||
database.initialize(root)
|
||||
const secrets = new Map<string, string>()
|
||||
const cipher = { isAvailable: () => true,
|
||||
encrypt: (value: string) => { const id = crypto.randomUUID(); secrets.set(id, value); return Buffer.from(id) },
|
||||
decrypt: (value: Buffer) => secrets.get(value.toString())! }
|
||||
const settingsStore = new RuntimeSettingsStore(join(root, 'settings.json'), cipher, {})
|
||||
const profileId = '00000000-0000-4000-8000-000000000221'
|
||||
const inputDefaults = { ...defaultRuntimeSettings } as Record<string, unknown>
|
||||
delete inputDefaults.supportsImageInput
|
||||
const selectedProfile = live?.profile('deepseek-harness')
|
||||
await settingsStore.update(runtimeSettingsInputSchema.parse({ ...inputDefaults, apiKey: { action: 'keep' },
|
||||
provider: 'deepseek-harness', workspacePath: root,
|
||||
modelProfiles: [{ name: 'Native UI Test', baseUrl: 'http://127.0.0.1:1', modelName: 'native-ui-model',
|
||||
protocol: 'openai-chat-completions', authentication: 'api-key', imageGenerationQuality: 'auto',
|
||||
...selectedProfile, id: profileId, apiKey: { action: 'replace', value: selectedProfile?.apiKey ?? 'native-ui-secret' } }],
|
||||
defaultModelProfileId: profileId, deepseekHarnessModelSource: { kind: 'profile', profileId }
|
||||
}))
|
||||
const project = database.createProject({ name: 'Native UI', description: '', rootPath: root,
|
||||
defaultWorkMode: 'ask', runtimeSelection: { provider: 'deepseek-harness', profileId } })
|
||||
const conversationId = '00000000-0000-4000-8000-000000000222'
|
||||
const now = Date.now()
|
||||
const save = async (): Promise<string> => {
|
||||
database.saveLocalConversations([{ header: { id: conversationId, projectId: project.id, title: 'Native UI', updatedAt: now }, messages: [] }])
|
||||
return conversationId
|
||||
}
|
||||
await save()
|
||||
const capabilities = new CapabilityService(join(root, 'capabilities.json'), join(root, 'builtin'), join(root, 'imported'), cipher)
|
||||
const opened: string[] = []
|
||||
const terminalEvents: import('../shared/terminal-contracts').TerminalEvent[] = []
|
||||
terminalManager = new TerminalSessionManager({ database, executionSpaceResolver: new ExecutionSpaceResolver(),
|
||||
targetResolver: {} as never, sshPool: {} as never, remoteEnabled: () => false,
|
||||
deliverEvent: (ownerId, event) => {
|
||||
expect(ownerId).toBe(9)
|
||||
terminalEvents.push(event)
|
||||
terminalManager!.acknowledge(ownerId, event.sessionId, event.sequence)
|
||||
}
|
||||
})
|
||||
const applicationSettingsStore = new ApplicationSettingsStore(join(root, 'application.json'))
|
||||
if (process.env.NATIVE_DSH_TEST_NODE) await applicationSettingsStore.update({
|
||||
localToolEnvironment: { ...(await applicationSettingsStore.get()).localToolEnvironment,
|
||||
node: { source: 'custom', executablePath: process.env.NATIVE_DSH_TEST_NODE } }
|
||||
})
|
||||
coordinator = new NativeClientCoordinator({ database, settingsStore,
|
||||
applicationSettingsStore,
|
||||
capabilities, executionSpaceResolver: new ExecutionSpaceResolver(), terminalManager,
|
||||
localEnvironment: { launchEnvironmentProvider: () => process.env } as never,
|
||||
rootDirectory: join(root, 'clients'), managedNodeDirectory: join(root, 'node'),
|
||||
bundledRuntimePaths: resolveBundledRuntimePaths({ appPath: process.cwd(), resourcesPath: '', packaged: false }),
|
||||
npmCliPath: resolveNpmCliPaths({ appPath: process.cwd(), resourcesPath: '', packaged: false }).npmCliPath,
|
||||
createGateway: () => new KnowledgeMcpGateway({} as never, { now: () => 0 }),
|
||||
openExternal: async (url) => { opened.push(url) }
|
||||
})
|
||||
harness = createHarness({}, undefined, 'always', undefined, false, undefined, undefined, undefined,
|
||||
false, undefined, undefined, undefined, undefined, undefined, false, undefined, coordinator, terminalManager)
|
||||
const event = trustedEvent(harness.webContents)
|
||||
electronMocks.invoke.mockImplementation((channel: string, input: unknown) => electronMocks.handlers.get(channel)!(event, input))
|
||||
await import('../preload/index')
|
||||
const notify = vi.fn()
|
||||
const view = render(createElement(RuntimeNativeClientActions, { browser: true, contextKey: project.id,
|
||||
conversationId, prepareConversation: save, onTerminal: vi.fn(), notify }))
|
||||
fireEvent.click(view.getByRole('button', { name: i18n.t('app:composer.nativeClient.browser') }))
|
||||
await waitFor(() => {
|
||||
expect(notify).not.toHaveBeenCalled()
|
||||
expect(view.getByRole('button', { name: i18n.t('app:composer.nativeClient.reopen') })).toBeEnabled()
|
||||
}, { timeout: 120_000 })
|
||||
expect(opened).toHaveLength(1)
|
||||
const handle = await coordinator.get(9, conversationId)
|
||||
expect(handle?.serviceId).toBeTruthy()
|
||||
expect(await coordinator.get(10, conversationId)).toBeNull()
|
||||
await coordinator.stop(10, handle!.serviceId)
|
||||
expect(await coordinator.get(9, conversationId)).toEqual(handle)
|
||||
database.saveLocalConversations([{ header: { id: conversationId, projectId: project.id, title: 'Native UI', updatedAt: now + 1, workMode: 'execute' }, messages: [] }])
|
||||
expect(await coordinator.get(9, conversationId)).toBeNull()
|
||||
database.saveLocalConversations([{ header: { id: conversationId, projectId: project.id, title: 'Native UI', updatedAt: now + 2, workMode: 'ask' }, messages: [] }])
|
||||
expect(await coordinator.get(9, conversationId)).toEqual(handle)
|
||||
const config = await readFile(join(root, 'clients', 'dsh', handle!.serviceId, 'cordis.patch.yml'), 'utf8')
|
||||
expect(config).toContain(selectedProfile?.modelName ?? 'native-ui-model')
|
||||
expect(config).not.toContain('native-ui-secret')
|
||||
if (selectedProfile?.apiKey) expect(config).not.toContain(selectedProfile.apiKey)
|
||||
const auth = await fetch(opened[0]!, { redirect: 'manual' })
|
||||
expect(auth.status).toBe(303)
|
||||
const cookie = auth.headers.getSetCookie().map(value => value.split(';')[0]).join('; ')
|
||||
const page = await fetch(new URL('/', opened[0]!), { headers: { cookie } })
|
||||
expect(page.status).toBe(200)
|
||||
expect(await page.text()).toContain('<html')
|
||||
await live?.web(opened[0]!, root)
|
||||
fireEvent.click(view.getByRole('button', { name: i18n.t('app:composer.nativeClient.reopen') }))
|
||||
await waitFor(() => expect(opened).toHaveLength(2))
|
||||
expect(await coordinator.get(9, conversationId)).toEqual(handle)
|
||||
await waitFor(() => expect(view.getByRole('button', { name: i18n.t('app:composer.nativeClient.stop') })).toBeEnabled())
|
||||
fireEvent.click(view.getByRole('button', { name: i18n.t('app:composer.nativeClient.stop') }))
|
||||
fireEvent.click(view.getByRole('button', { name: i18n.t('app:composer.nativeClient.confirmStop') }))
|
||||
await waitFor(() => expect(view.queryByRole('button', { name: i18n.t('app:composer.nativeClient.stop') })).not.toBeInTheDocument(), { timeout: 10_000 })
|
||||
expect(await coordinator.get(9, conversationId)).toBeNull()
|
||||
await expect(fetch(new URL('/', opened[0]!))).rejects.toThrow()
|
||||
|
||||
const terminals: import('../shared/terminal-contracts').TerminalSnapshot[] = []
|
||||
for (const provider of ['continue', 'opencode'] as const) {
|
||||
if (live) {
|
||||
const profile = live.profile(provider)
|
||||
await settingsStore.update(runtimeSettingsInputSchema.parse({ ...inputDefaults, apiKey: { action: 'keep' },
|
||||
provider, workspacePath: root, defaultModelProfileId: profileId,
|
||||
modelProfiles: [{ ...profile, id: profileId, imageGenerationQuality: 'auto', apiKey: { action: 'replace', value: profile.apiKey } }],
|
||||
[`${provider}ModelSource`]: { kind: 'profile', profileId }
|
||||
}))
|
||||
}
|
||||
database.updateProject(project.id, { ...project, runtimeSelection: { provider, profileId } })
|
||||
const onTerminal = vi.fn<(value: import('../shared/terminal-contracts').TerminalSnapshot) => void>()
|
||||
notify.mockClear()
|
||||
view.rerender(createElement(RuntimeNativeClientActions, { browser: false, contextKey: `${project.id}:${provider}`,
|
||||
conversationId, prepareConversation: save, onTerminal, notify }))
|
||||
fireEvent.click(view.getByRole('button', { name: i18n.t('app:composer.nativeClient.terminal') }))
|
||||
await waitFor(() => {
|
||||
expect(notify).not.toHaveBeenCalled()
|
||||
expect(onTerminal).toHaveBeenCalledWith(expect.objectContaining({ state: 'running', workingDirectory: root }))
|
||||
}, { timeout: 30_000 })
|
||||
const terminal = onTerminal.mock.calls[0]![0]
|
||||
terminals.push(terminal)
|
||||
await waitFor(() => expect(terminalEvents.some(event => event.sessionId === terminal.sessionId && event.type === 'output' && /Continue|OpenCode|opencode|Native UI Test|native-ui-model/u.test(event.data))).toBe(true), { timeout: 20_000 })
|
||||
await live?.terminal(provider,
|
||||
data => (globalThis as unknown as { goodbuddy: import('../shared/contracts').DesktopApi }).goodbuddy.terminal.write({ sessionId: terminal.sessionId, data }),
|
||||
() => terminalEvents.flatMap(event => event.sessionId === terminal.sessionId && event.type === 'output' ? [event.data] : []).join(''))
|
||||
}
|
||||
await harness.dispose()
|
||||
harness = undefined
|
||||
for (const terminal of terminals) expect(() => terminalManager!.snapshot(9, terminal.sessionId)).toThrow()
|
||||
} finally {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
await harness?.dispose()
|
||||
await coordinator?.dispose()
|
||||
await terminalManager?.dispose()
|
||||
database.close()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await live?.finish()
|
||||
}
|
||||
}, 360_000)
|
||||
|
||||
it('tests an unsaved Obsidian UI draft through registered IPC and bundled MCPVault, then saves and refreshes', async () => {
|
||||
const { createElement } = await import('react')
|
||||
const { render, fireEvent, waitFor, cleanup } = await import('@testing-library/react/pure')
|
||||
|
||||
+67
-1733
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,841 @@
|
||||
import { dialog, shell, type BrowserWindow, type ipcMain } from 'electron'
|
||||
import { lstat, realpath, stat } from 'node:fs/promises'
|
||||
import { isAbsolute } from 'node:path'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
externalKnowledgeInstanceInputSchema,
|
||||
externalKnowledgeInstanceEnabledInputSchema,
|
||||
externalKnowledgeInstanceSaveInputSchema,
|
||||
externalKnowledgeCatalogListInputSchema,
|
||||
externalKnowledgeCatalogGetInputSchema,
|
||||
externalKnowledgeBindingSaveInputSchema,
|
||||
externalKnowledgeBindingUpdateInputSchema,
|
||||
externalKnowledgeBindingTestInputSchema
|
||||
} from '../../shared/external-knowledge-contracts'
|
||||
import { knowledgeReferenceKey, toKnowledgeReference } from '../../shared/knowledge-reference'
|
||||
import {
|
||||
knowledgeCreateSchema,
|
||||
knowledgeEntityUpdateSchema,
|
||||
knowledgeIdSchema,
|
||||
knowledgeImportPathsSchema,
|
||||
knowledgeRelationInputSchema,
|
||||
knowledgeUpdateLibrarySchema,
|
||||
knowledgeUrlImportSchema,
|
||||
type KnowledgeSnapshot
|
||||
} from '../../shared/contracts'
|
||||
import {
|
||||
knowledgeChunkDeleteInputSchema,
|
||||
knowledgeChunkPageSchema,
|
||||
knowledgeChunksListInputSchema,
|
||||
knowledgeChunkUpdateInputSchema,
|
||||
knowledgeDocumentOpenInputSchema,
|
||||
knowledgeDocumentRebuildInputSchema,
|
||||
knowledgeLibraryRebuildInputSchema,
|
||||
knowledgeReferenceContextInputSchema,
|
||||
knowledgeReferenceContextSchema,
|
||||
knowledgeReferenceOpenInputSchema,
|
||||
knowledgeRetrieveInputSchema,
|
||||
knowledgeRetrievalResponseSchema,
|
||||
knowledgeSettingsUpdateInputSchema
|
||||
} from '../../shared/knowledge-contracts'
|
||||
import {
|
||||
knowledgeTaskActionInputSchema,
|
||||
knowledgeTaskItemSchema
|
||||
} from '../../shared/knowledge-task-contracts'
|
||||
import {
|
||||
knowledgeEmbeddingIndexCancelRequestSchema,
|
||||
knowledgeEmbeddingIndexRequestSchema,
|
||||
knowledgeEmbeddingIndexSnapshotSchema
|
||||
} from '../../shared/embedding-contracts'
|
||||
import { ipcChannels } from '../../shared/ipc-channels'
|
||||
import { assertTrustedSender } from '../trusted-ipc-sender'
|
||||
import type { RuntimeSettingsStore } from '../runtime-settings-store'
|
||||
import type { KnowledgeService } from './knowledge-service'
|
||||
import { supportedDocumentExtensions } from './document-parser'
|
||||
|
||||
const knowledgeSearchSchema = z
|
||||
.object({
|
||||
libraryIds: z.array(knowledgeIdSchema).max(20),
|
||||
query: z.string().trim().min(1).max(512)
|
||||
})
|
||||
.strict()
|
||||
const knowledgeSelectionSchema = z
|
||||
.object({
|
||||
libraryId: knowledgeIdSchema,
|
||||
graphStrategy: z.enum(['rules', 'model', 'hybrid']).optional()
|
||||
})
|
||||
.strict()
|
||||
const knowledgeEntityPayloadSchema = z
|
||||
.object({
|
||||
entityId: knowledgeIdSchema,
|
||||
update: knowledgeEntityUpdateSchema
|
||||
})
|
||||
.strict()
|
||||
const knowledgeCreateEntitySchema = z
|
||||
.object({
|
||||
libraryId: knowledgeIdSchema,
|
||||
input: knowledgeEntityUpdateSchema
|
||||
})
|
||||
.strict()
|
||||
const knowledgeMoveEntitySchema = z
|
||||
.object({
|
||||
entityId: knowledgeIdSchema,
|
||||
position: z
|
||||
.object({
|
||||
x: z.number().finite().min(-100_000).max(100_000),
|
||||
y: z.number().finite().min(-100_000).max(100_000)
|
||||
})
|
||||
.strict()
|
||||
})
|
||||
.strict()
|
||||
const knowledgeMergeSchema = z
|
||||
.object({
|
||||
sourceEntityId: knowledgeIdSchema,
|
||||
targetEntityId: knowledgeIdSchema
|
||||
})
|
||||
.strict()
|
||||
const knowledgeCreateRelationSchema = z
|
||||
.object({
|
||||
libraryId: knowledgeIdSchema,
|
||||
input: knowledgeRelationInputSchema
|
||||
})
|
||||
.strict()
|
||||
const knowledgeUpdateRelationSchema = z
|
||||
.object({
|
||||
relationId: knowledgeIdSchema,
|
||||
input: knowledgeRelationInputSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
function getKnowledgeSnapshot(
|
||||
service: KnowledgeService,
|
||||
selectedLibraryId?: string
|
||||
): KnowledgeSnapshot {
|
||||
const snapshot = service.snapshot(selectedLibraryId)
|
||||
const activeLibraryId =
|
||||
selectedLibraryId ?? snapshot.libraries[0]?.id
|
||||
const documentsById = new Map(
|
||||
snapshot.documents.map((document) => [document.id, document])
|
||||
)
|
||||
const evidenceByEntity = new Map<string, string[]>()
|
||||
const evidenceByRelation = new Map<string, string[]>()
|
||||
for (const item of snapshot.evidence) {
|
||||
if (item.entityId) {
|
||||
evidenceByEntity.set(item.entityId, [
|
||||
...(evidenceByEntity.get(item.entityId) ?? []),
|
||||
item.id
|
||||
])
|
||||
}
|
||||
if (item.relationId) {
|
||||
evidenceByRelation.set(item.relationId, [
|
||||
...(evidenceByRelation.get(item.relationId) ?? []),
|
||||
item.id
|
||||
])
|
||||
}
|
||||
}
|
||||
const bindingsByLibraryId = new Map(
|
||||
service.database.externalStore
|
||||
.listBindings()
|
||||
.map((binding) => [binding.knowledgeBaseId, binding])
|
||||
)
|
||||
return {
|
||||
libraries: snapshot.libraries.map((library) => ({
|
||||
kind: bindingsByLibraryId.has(library.id) ? 'external' : 'local',
|
||||
external: bindingsByLibraryId.get(library.id),
|
||||
id: library.id,
|
||||
name: library.name,
|
||||
description: library.description ?? '',
|
||||
storageMode: library.storageMode,
|
||||
graphEnabled: library.graphEnabled,
|
||||
graphStrategy: library.graphStrategy,
|
||||
sourceCount: library.sourceCount,
|
||||
documentCount: library.documentCount,
|
||||
indexedDocumentCount: library.indexedDocumentCount,
|
||||
processingDocumentCount: library.processingDocumentCount,
|
||||
failedDocumentCount: library.failedDocumentCount,
|
||||
retrievalSettings: library.retrievalSettings,
|
||||
chunkingSettings: library.chunkingSettings,
|
||||
chunkingRebuildRequired: library.chunkingRebuildRequired,
|
||||
ontologySettings: library.ontologySettings,
|
||||
ontologyRebuildRequired: library.ontologyRebuildRequired,
|
||||
updatedAt: library.updatedAt
|
||||
})),
|
||||
selectedLibraryId: activeLibraryId,
|
||||
sources: snapshot.sources.map((source) => ({
|
||||
id: source.id,
|
||||
libraryId: source.knowledgeBaseId,
|
||||
name: source.displayName,
|
||||
kind: source.type,
|
||||
location: source.location,
|
||||
status:
|
||||
source.status === 'pending'
|
||||
? 'queued'
|
||||
: source.status === 'indexing'
|
||||
? 'syncing'
|
||||
: source.status === 'error'
|
||||
? 'failed'
|
||||
: source.status,
|
||||
progress: source.progress,
|
||||
documentCount: source.documentCount,
|
||||
lastSyncedAt: source.lastSyncedAt,
|
||||
error: source.lastError
|
||||
})),
|
||||
documents: snapshot.documents.map((document) => ({
|
||||
id: document.id,
|
||||
libraryId: document.knowledgeBaseId,
|
||||
sourceId: document.sourceId,
|
||||
name: document.title,
|
||||
resultId: typeof document.metadata.parsedResultId === 'string' ? document.metadata.parsedResultId : undefined,
|
||||
path: document.sourceLocation,
|
||||
status: document.status,
|
||||
textIndexStatus: document.textIndexStatus,
|
||||
vectorIndexStatus: document.vectorIndexStatus,
|
||||
graphIndexStatus: document.graphIndexStatus,
|
||||
indexProgress: document.status === 'ready' ? 100 : 0,
|
||||
chunkCount: document.chunkCount,
|
||||
size: document.size,
|
||||
updatedAt: document.updatedAt,
|
||||
error: document.error
|
||||
})),
|
||||
graphNodes: snapshot.entities.map((entity, index) => ({
|
||||
id: entity.id,
|
||||
label: entity.name,
|
||||
type: entity.type,
|
||||
description: entity.description,
|
||||
aliases: entity.aliases,
|
||||
x:
|
||||
typeof entity.properties.x === 'number'
|
||||
? entity.properties.x
|
||||
: 120 + (index % 5) * 150,
|
||||
y:
|
||||
typeof entity.properties.y === 'number'
|
||||
? entity.properties.y
|
||||
: 100 + Math.floor(index / 5) * 120,
|
||||
evidenceIds: evidenceByEntity.get(entity.id)
|
||||
})),
|
||||
graphRelations: snapshot.relations.map((relation) => ({
|
||||
id: relation.id,
|
||||
sourceId: relation.sourceEntityId,
|
||||
targetId: relation.targetEntityId,
|
||||
type: relation.type,
|
||||
description: relation.label,
|
||||
evidenceIds: evidenceByRelation.get(relation.id)
|
||||
})),
|
||||
evidence: snapshot.evidence.map((item) => ({
|
||||
id: item.id,
|
||||
documentId: item.documentId,
|
||||
documentName:
|
||||
documentsById.get(item.documentId)?.title ?? '未知文档',
|
||||
excerpt: item.quote ?? '',
|
||||
location: item.location
|
||||
})),
|
||||
tasks: snapshot.tasks.map((task) => knowledgeTaskItemSchema.parse(task))
|
||||
}
|
||||
}
|
||||
|
||||
export function registerKnowledgeIpcHandlers(
|
||||
registerHandler: typeof ipcMain.handle,
|
||||
window: BrowserWindow,
|
||||
knowledgeService: KnowledgeService,
|
||||
settingsStore: RuntimeSettingsStore
|
||||
): void {
|
||||
registerHandler(ipcChannels.externalInstancesList, (event) => {assertTrustedSender(event,window);return knowledgeService.external.listInstances()})
|
||||
registerHandler(ipcChannels.externalInstancesSave, (event,input:unknown) => {assertTrustedSender(event,window);return knowledgeService.external.saveInstance(externalKnowledgeInstanceSaveInputSchema.parse(input))})
|
||||
registerHandler(ipcChannels.externalInstancesTest, (event,input:unknown) => {assertTrustedSender(event,window);return knowledgeService.external.testInstance(externalKnowledgeInstanceInputSchema.parse(input).instanceId)})
|
||||
registerHandler(ipcChannels.externalInstancesSetEnabled, (event,input:unknown) => {assertTrustedSender(event,window);const value=externalKnowledgeInstanceEnabledInputSchema.parse(input);return knowledgeService.external.setEnabled(value.instanceId,value.enabled)})
|
||||
registerHandler(ipcChannels.externalInstancesDelete, (event,input:unknown) => {assertTrustedSender(event,window);return knowledgeService.external.deleteInstance(externalKnowledgeInstanceInputSchema.parse(input).instanceId)})
|
||||
registerHandler(ipcChannels.externalCatalogList, (event,input:unknown) => {assertTrustedSender(event,window);return knowledgeService.external.listCatalog(externalKnowledgeCatalogListInputSchema.parse(input))})
|
||||
registerHandler(ipcChannels.externalCatalogGet, (event,input:unknown) => {assertTrustedSender(event,window);return knowledgeService.external.getCatalog(externalKnowledgeCatalogGetInputSchema.parse(input))})
|
||||
registerHandler(ipcChannels.externalRetrievalTest, (event,input:unknown) => {assertTrustedSender(event,window);return knowledgeService.external.testRetrieval(externalKnowledgeBindingTestInputSchema.parse(input))})
|
||||
registerHandler(ipcChannels.externalBindingsCreate, async (event,input:unknown) => {assertTrustedSender(event,window);await knowledgeService.external.saveBinding(externalKnowledgeBindingSaveInputSchema.parse(input));return getKnowledgeSnapshot(knowledgeService)})
|
||||
registerHandler(ipcChannels.externalBindingsUpdate, async (event,input:unknown) => {assertTrustedSender(event,window);await knowledgeService.external.saveBinding(externalKnowledgeBindingUpdateInputSchema.parse(input));return getKnowledgeSnapshot(knowledgeService)})
|
||||
registerHandler(ipcChannels.knowledgeSnapshot, (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const libraryId =
|
||||
input === undefined ? undefined : knowledgeIdSchema.parse(input)
|
||||
return getKnowledgeSnapshot(knowledgeService, libraryId)
|
||||
})
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.knowledgeCreateLibrary,
|
||||
(event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const value = knowledgeCreateSchema.parse(input)
|
||||
const library = knowledgeService.createLibrary(value)
|
||||
const created = getKnowledgeSnapshot(
|
||||
knowledgeService,
|
||||
library.id
|
||||
).libraries.find((item) => item.id === library.id)
|
||||
if (!created) {
|
||||
throw new Error('知识库创建失败')
|
||||
}
|
||||
return created
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.knowledgeDeleteLibrary,
|
||||
async (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
await knowledgeService.deleteLibrary(knowledgeIdSchema.parse(input))
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.knowledgeUpdateLibrary,
|
||||
(event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const value = knowledgeUpdateLibrarySchema.parse(input)
|
||||
knowledgeService.database.updateKnowledgeBase(value.libraryId, {
|
||||
name: value.name,
|
||||
description: value.description,
|
||||
graphEnabled: value.graphEnabled,
|
||||
graphStrategy: value.graphStrategy
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.knowledgeReextractGraph,
|
||||
async (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
return knowledgeService.reextractGraph(knowledgeIdSchema.parse(input))
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.knowledgeSelectFiles,
|
||||
async (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const selection = knowledgeSelectionSchema.parse(input)
|
||||
const result = await dialog.showOpenDialog(window, {
|
||||
properties: ['openFile', 'multiSelections'],
|
||||
filters: [
|
||||
{
|
||||
name: '支持的知识文档',
|
||||
extensions: supportedDocumentExtensions.map((extension) =>
|
||||
extension.slice(1)
|
||||
)
|
||||
}
|
||||
]
|
||||
})
|
||||
if (!result.canceled) {
|
||||
await knowledgeService.importPaths(
|
||||
selection.libraryId,
|
||||
result.filePaths,
|
||||
selection.graphStrategy
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.knowledgeSelectDirectory,
|
||||
async (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const selection = knowledgeSelectionSchema.parse(input)
|
||||
const result = await dialog.showOpenDialog(window, {
|
||||
properties: ['openDirectory']
|
||||
})
|
||||
if (!result.canceled && result.filePaths[0]) {
|
||||
await knowledgeService.importPaths(
|
||||
selection.libraryId,
|
||||
[result.filePaths[0]],
|
||||
selection.graphStrategy
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.knowledgeImportPaths,
|
||||
async (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const value = knowledgeImportPathsSchema.parse(input)
|
||||
await knowledgeService.importPaths(
|
||||
value.libraryId,
|
||||
value.paths,
|
||||
value.graphStrategy
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.knowledgeImportUrl,
|
||||
async (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const value = knowledgeUrlImportSchema.parse(input)
|
||||
await knowledgeService.importUrl(
|
||||
value.libraryId,
|
||||
value.url,
|
||||
new AbortController().signal,
|
||||
undefined,
|
||||
value.graphStrategy
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
for (const [channel, action] of [
|
||||
[
|
||||
ipcChannels.knowledgeSyncSource,
|
||||
(id: string) => knowledgeService.syncSource(id)
|
||||
],
|
||||
[
|
||||
ipcChannels.knowledgePauseSource,
|
||||
(id: string) => knowledgeService.pauseSource(id)
|
||||
],
|
||||
[
|
||||
ipcChannels.knowledgeRetrySource,
|
||||
(id: string) => knowledgeService.retrySource(id)
|
||||
],
|
||||
[
|
||||
ipcChannels.knowledgeRemoveSource,
|
||||
(id: string) => knowledgeService.removeSource(id)
|
||||
]
|
||||
] as const) {
|
||||
registerHandler(channel, async (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
await action(knowledgeIdSchema.parse(input))
|
||||
})
|
||||
}
|
||||
|
||||
registerHandler(ipcChannels.knowledgeSearch, async (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const value = knowledgeSearchSchema.parse(input)
|
||||
if (value.libraryIds.length === 0) {
|
||||
return []
|
||||
}
|
||||
const availableLibraries =
|
||||
knowledgeService.database.listKnowledgeBases(100)
|
||||
const libraries = [...new Set(value.libraryIds)]
|
||||
const names = new Map(
|
||||
availableLibraries.map((library) => [library.id, library.name])
|
||||
)
|
||||
const results = (
|
||||
await knowledgeService.retrieveMany(
|
||||
libraries,
|
||||
value.query
|
||||
)
|
||||
)
|
||||
const failures = results.flatMap(item => item.response.diagnostics.failure ? [item.response.diagnostics.failure] : [])
|
||||
if (failures.length === results.length) throw new Error(failures.join('; '))
|
||||
const references = results.flatMap(({ knowledgeBaseId, response }) => response.results.map(result => ({
|
||||
...toKnowledgeReference(result, names.get(knowledgeBaseId) ?? '知识库'),
|
||||
...(failures.length ? { warnings: failures } : {})
|
||||
})))
|
||||
if (!references.length && failures.length) throw new Error(failures.join('; '))
|
||||
return [...new Map(references.map(reference => [knowledgeReferenceKey(reference), reference])).values()]
|
||||
.sort((left, right) => left.rank - right.rank)
|
||||
.slice(0, 8)
|
||||
})
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.knowledgeRetrieve,
|
||||
async (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const response = await knowledgeService.retrieve(
|
||||
knowledgeRetrieveInputSchema.parse(input)
|
||||
)
|
||||
return knowledgeRetrievalResponseSchema.parse(response)
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.knowledgeUpdateSettings,
|
||||
(event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const value = knowledgeSettingsUpdateInputSchema.parse(input)
|
||||
knowledgeService.updateSettings(value)
|
||||
const library = getKnowledgeSnapshot(
|
||||
knowledgeService,
|
||||
value.knowledgeBaseId
|
||||
).libraries.find((item) => item.id === value.knowledgeBaseId)
|
||||
if (!library) {
|
||||
throw new Error('知识库不存在')
|
||||
}
|
||||
return library
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.knowledgeListChunks,
|
||||
(event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const page = knowledgeService.listChunks(
|
||||
knowledgeChunksListInputSchema.parse(input)
|
||||
)
|
||||
return knowledgeChunkPageSchema.parse({
|
||||
items: page.items.map((chunk) => ({
|
||||
id: chunk.id,
|
||||
ordinal: chunk.ordinal,
|
||||
role: chunk.role,
|
||||
parentChunkId: chunk.parentChunkId,
|
||||
heading: chunk.heading,
|
||||
locator: chunk.location,
|
||||
characterCount: chunk.content.length,
|
||||
enabled: chunk.enabled,
|
||||
content: chunk.content,
|
||||
manuallyEdited: chunk.manuallyEdited,
|
||||
updatedAt: chunk.updatedAt
|
||||
})),
|
||||
page: page.page,
|
||||
pageSize: page.pageSize,
|
||||
totalItems: page.total
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.knowledgeUpdateChunk,
|
||||
async (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
await knowledgeService.updateChunk(
|
||||
knowledgeChunkUpdateInputSchema.parse(input)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.knowledgeDeleteChunk,
|
||||
async (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const deleted = await knowledgeService.deleteChunk(
|
||||
knowledgeChunkDeleteInputSchema.parse(input)
|
||||
)
|
||||
if (!deleted) {
|
||||
throw new Error('知识分块不存在')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.knowledgeRebuildDocument,
|
||||
async (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const value = knowledgeDocumentRebuildInputSchema.parse(input)
|
||||
await knowledgeService.rebuildDocument(value)
|
||||
return getKnowledgeSnapshot(
|
||||
knowledgeService,
|
||||
value.knowledgeBaseId
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.knowledgeRebuildLibrary,
|
||||
async (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const value = knowledgeLibraryRebuildInputSchema.parse(input)
|
||||
return knowledgeService.rebuildLibrary(value)
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.knowledgeCancelRebuild,
|
||||
(event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const knowledgeBaseId = knowledgeIdSchema.parse(input)
|
||||
return knowledgeService.cancelLibraryRebuild(knowledgeBaseId)
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.knowledgeTaskCancel,
|
||||
async (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const { taskId } = knowledgeTaskActionInputSchema.parse(input)
|
||||
return knowledgeService.cancelTask(taskId)
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.knowledgeTaskRetry,
|
||||
async (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const { taskId } = knowledgeTaskActionInputSchema.parse(input)
|
||||
await knowledgeService.retryTask(taskId)
|
||||
}
|
||||
)
|
||||
|
||||
const embeddingConfiguration = async () => {
|
||||
const settings = await settingsStore.getPublicSettings()
|
||||
const connection = settings.embeddingConnections?.find(
|
||||
(candidate) =>
|
||||
candidate.id === settings.activeEmbeddingConnectionId
|
||||
)
|
||||
return connection?.kind === 'builtin'
|
||||
? {
|
||||
provider: 'builtin',
|
||||
model: 'granite-embedding-97m-multilingual-r2',
|
||||
credentialConfigured: false
|
||||
}
|
||||
: {
|
||||
provider: 'openai-compatible',
|
||||
model:
|
||||
connection?.modelName ??
|
||||
settings.knowledgeEmbeddingModel,
|
||||
endpoint:
|
||||
connection?.baseUrl ??
|
||||
settings.knowledgeEmbeddingBaseUrl,
|
||||
credentialConfigured:
|
||||
connection?.apiKeyConfigured ??
|
||||
settings.knowledgeEmbeddingApiKeyConfigured
|
||||
}
|
||||
}
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.knowledgeEmbeddingIndexGet,
|
||||
async (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const { knowledgeBaseId } =
|
||||
knowledgeEmbeddingIndexRequestSchema.parse(input)
|
||||
const settings = await settingsStore.getResolvedSettings()
|
||||
return knowledgeEmbeddingIndexSnapshotSchema.parse(
|
||||
await knowledgeService.getEmbeddingIndexSnapshot(
|
||||
knowledgeBaseId,
|
||||
settings.knowledgeEmbeddingEnabled
|
||||
? await embeddingConfiguration()
|
||||
: undefined
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.knowledgeEmbeddingIndexRebuild,
|
||||
async (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const { knowledgeBaseId } =
|
||||
knowledgeEmbeddingIndexRequestSchema.parse(input)
|
||||
const snapshot = await knowledgeService.rebuildEmbeddingIndex(
|
||||
knowledgeBaseId,
|
||||
await embeddingConfiguration()
|
||||
)
|
||||
return knowledgeEmbeddingIndexSnapshotSchema.parse(snapshot)
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.knowledgeEmbeddingIndexCancel,
|
||||
async (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const { knowledgeBaseId, jobId } =
|
||||
knowledgeEmbeddingIndexCancelRequestSchema.parse(input)
|
||||
return knowledgeService.cancelEmbeddingIndex(
|
||||
knowledgeBaseId,
|
||||
jobId
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.knowledgeOpenDocumentSource,
|
||||
async (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const value = knowledgeDocumentOpenInputSchema.parse(input)
|
||||
const reference = knowledgeService.getDocumentSource(value)
|
||||
if (!reference) {
|
||||
throw new Error('文档来源不存在')
|
||||
}
|
||||
if (reference.source.type === 'url') {
|
||||
const target = new URL(reference.source.location)
|
||||
if (!['http:', 'https:'].includes(target.protocol)) {
|
||||
throw new Error('文档来源 URL 协议不受支持')
|
||||
}
|
||||
await shell.openExternal(target.href)
|
||||
return
|
||||
}
|
||||
const storedPath =
|
||||
reference.document.sourceLocation ?? reference.source.location
|
||||
if (!isAbsolute(storedPath)) {
|
||||
throw new Error('文档来源路径无效')
|
||||
}
|
||||
if ((await lstat(storedPath)).isSymbolicLink()) {
|
||||
throw new Error('文档来源不能是符号链接')
|
||||
}
|
||||
const targetPath = await realpath(storedPath)
|
||||
if (!(await stat(targetPath)).isFile()) {
|
||||
throw new Error('文档来源不是可打开的文件')
|
||||
}
|
||||
const openError = await shell.openPath(targetPath)
|
||||
if (openError) {
|
||||
throw new Error('无法打开文档来源')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.knowledgeReferenceContext,
|
||||
(event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const value = knowledgeReferenceContextInputSchema.parse(input)
|
||||
const reference = knowledgeService.getReferenceContext(value)
|
||||
if (!reference) {
|
||||
throw new Error('引用上下文不存在或已停用')
|
||||
}
|
||||
const fullContext = reference.contextChunks
|
||||
.map((chunk) => chunk.content)
|
||||
.join('\n\n')
|
||||
return knowledgeReferenceContextSchema.parse({
|
||||
knowledgeBaseId: value.knowledgeBaseId,
|
||||
documentId: value.documentId,
|
||||
chunkId: value.chunkId,
|
||||
documentTitle: reference.document.title,
|
||||
sourceDisplayName: reference.source.displayName,
|
||||
locator: reference.chunk.location,
|
||||
matchedContent: reference.chunk.content.slice(0, 48_000),
|
||||
contextContent: fullContext.slice(0, 48_000),
|
||||
contextChunkIds: reference.contextChunks.map((chunk) => chunk.id),
|
||||
truncated:
|
||||
reference.chunk.content.length > 48_000 ||
|
||||
fullContext.length > 48_000
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.knowledgeOpenReferenceSource,
|
||||
async (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const value = knowledgeReferenceOpenInputSchema.parse(input)
|
||||
const reference = knowledgeService.getReferenceContext(value)
|
||||
if (!reference) {
|
||||
throw new Error('引用来源不存在或已停用')
|
||||
}
|
||||
if (reference.source.type === 'url') {
|
||||
const target = new URL(reference.source.location)
|
||||
if (!['http:', 'https:'].includes(target.protocol)) {
|
||||
throw new Error('引用来源 URL 协议不受支持')
|
||||
}
|
||||
await shell.openExternal(target.href)
|
||||
return
|
||||
}
|
||||
const storedPath =
|
||||
reference.document.sourceLocation ?? reference.source.location
|
||||
if (!isAbsolute(storedPath)) {
|
||||
throw new Error('引用来源路径无效')
|
||||
}
|
||||
if ((await lstat(storedPath)).isSymbolicLink()) {
|
||||
throw new Error('引用来源不能是符号链接')
|
||||
}
|
||||
const targetPath = await realpath(storedPath)
|
||||
const targetStat = await stat(targetPath)
|
||||
if (!targetStat.isFile() && !targetStat.isDirectory()) {
|
||||
throw new Error('引用来源不是可打开的文件或目录')
|
||||
}
|
||||
const openError = await shell.openPath(targetPath)
|
||||
if (openError) {
|
||||
throw new Error('无法打开引用来源')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.knowledgeCreateEntity,
|
||||
(event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const value = knowledgeCreateEntitySchema.parse(input)
|
||||
knowledgeService.database.createEntity({
|
||||
knowledgeBaseId: value.libraryId,
|
||||
name: value.input.label,
|
||||
type: value.input.type,
|
||||
description: value.input.description || undefined,
|
||||
aliases: value.input.aliases,
|
||||
locked: true
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.knowledgeUpdateEntity,
|
||||
(event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const value = knowledgeEntityPayloadSchema.parse(input)
|
||||
knowledgeService.database.updateEntity(value.entityId, {
|
||||
name: value.update.label,
|
||||
type: value.update.type,
|
||||
description: value.update.description || null,
|
||||
aliases: value.update.aliases,
|
||||
locked: true
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.knowledgeMoveEntity,
|
||||
(event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const value = knowledgeMoveEntitySchema.parse(input)
|
||||
const entity = knowledgeService.database.getEntity(value.entityId)
|
||||
if (!entity) {
|
||||
throw new Error('图谱实体不存在')
|
||||
}
|
||||
knowledgeService.database.updateEntity(entity.id, {
|
||||
properties: {
|
||||
...entity.properties,
|
||||
x: value.position.x,
|
||||
y: value.position.y
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.knowledgeDeleteEntity,
|
||||
(event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
knowledgeService.database.deleteEntity(knowledgeIdSchema.parse(input))
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.knowledgeMergeEntities,
|
||||
(event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const value = knowledgeMergeSchema.parse(input)
|
||||
knowledgeService.database.mergeEntities(
|
||||
value.targetEntityId,
|
||||
value.sourceEntityId
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.knowledgeCreateRelation,
|
||||
(event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const value = knowledgeCreateRelationSchema.parse(input)
|
||||
knowledgeService.database.createRelation({
|
||||
knowledgeBaseId: value.libraryId,
|
||||
sourceEntityId: value.input.sourceId,
|
||||
targetEntityId: value.input.targetId,
|
||||
type: value.input.type,
|
||||
label: value.input.description || undefined,
|
||||
locked: true
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.knowledgeUpdateRelation,
|
||||
(event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const value = knowledgeUpdateRelationSchema.parse(input)
|
||||
knowledgeService.database.updateRelation(value.relationId, {
|
||||
sourceEntityId: value.input.sourceId,
|
||||
targetEntityId: value.input.targetId,
|
||||
type: value.input.type,
|
||||
label: value.input.description || null,
|
||||
locked: true
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.knowledgeDeleteRelation,
|
||||
(event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
knowledgeService.database.deleteRelation(knowledgeIdSchema.parse(input))
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { BrowserWindow, ipcMain } from 'electron'
|
||||
import { z } from 'zod'
|
||||
import { ipcChannels } from '../../shared/ipc-channels'
|
||||
import {
|
||||
localToolDiagnoseInputSchema,
|
||||
localToolEnvironmentSettingsSchema,
|
||||
localToolKindInputSchema
|
||||
} from '../../shared/local-tool-environment-contracts'
|
||||
import { assertTrustedSender } from '../trusted-ipc-sender'
|
||||
import type { LocalToolEnvironmentService } from './local-tool-environment-service'
|
||||
|
||||
export function registerLocalToolEnvironmentIpcHandlers(
|
||||
registerHandler: typeof ipcMain.handle,
|
||||
window: BrowserWindow,
|
||||
localToolEnvironmentService?: LocalToolEnvironmentService
|
||||
): void {
|
||||
const requireLocalToolEnvironmentService =
|
||||
(): LocalToolEnvironmentService => {
|
||||
if (!localToolEnvironmentService) {
|
||||
throw new Error('本地工具环境服务不可用')
|
||||
}
|
||||
return localToolEnvironmentService
|
||||
}
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.localToolEnvironmentGet,
|
||||
(event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
z.undefined().parse(input)
|
||||
return requireLocalToolEnvironmentService().getSnapshot()
|
||||
}
|
||||
)
|
||||
registerHandler(
|
||||
ipcChannels.localToolEnvironmentUpdate,
|
||||
(event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
return requireLocalToolEnvironmentService().updateSettings(
|
||||
localToolEnvironmentSettingsSchema.parse(input)
|
||||
)
|
||||
}
|
||||
)
|
||||
registerHandler(
|
||||
ipcChannels.localToolEnvironmentRefresh,
|
||||
(event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
z.undefined().parse(input)
|
||||
return requireLocalToolEnvironmentService().refreshCandidates()
|
||||
}
|
||||
)
|
||||
registerHandler(
|
||||
ipcChannels.localToolEnvironmentSelectExecutable,
|
||||
(event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const { kind } = localToolKindInputSchema.parse(input)
|
||||
return requireLocalToolEnvironmentService().selectExecutable(kind)
|
||||
}
|
||||
)
|
||||
registerHandler(
|
||||
ipcChannels.localToolEnvironmentDiagnose,
|
||||
(event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const { kind } = localToolDiagnoseInputSchema.parse(input)
|
||||
return requireLocalToolEnvironmentService().diagnose(kind)
|
||||
}
|
||||
)
|
||||
registerHandler(
|
||||
ipcChannels.localToolEnvironmentInstallPython,
|
||||
(event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
z.undefined().parse(input)
|
||||
return requireLocalToolEnvironmentService().installPython()
|
||||
}
|
||||
)
|
||||
registerHandler(
|
||||
ipcChannels.localToolEnvironmentCancelPython,
|
||||
(event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
z.undefined().parse(input)
|
||||
return requireLocalToolEnvironmentService().cancelPython()
|
||||
}
|
||||
)
|
||||
registerHandler(
|
||||
ipcChannels.localToolEnvironmentRemovePython,
|
||||
(event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
z.undefined().parse(input)
|
||||
return requireLocalToolEnvironmentService().removePython()
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { BrowserWindow, ipcMain } from 'electron'
|
||||
import { ipcChannels } from '../../shared/ipc-channels'
|
||||
import {
|
||||
magicNoteAnalyzeSchema,
|
||||
magicNoteDraftAnalyzeSchema,
|
||||
magicTodoIdSchema
|
||||
} from '../../shared/magic-notes-contracts'
|
||||
import { createDefaultModelRuntime } from '../agent/create-runtime'
|
||||
import type { RuntimeModelUsageEvent } from '../agent/runtime'
|
||||
import type { ApplicationSettingsStore } from '../application-settings-store'
|
||||
import type { AssistantDatabase } from '../assistant/assistant-database'
|
||||
import type { RuntimeSettingsStore } from '../runtime-settings-store'
|
||||
import { assertTrustedSender } from '../trusted-ipc-sender'
|
||||
import {
|
||||
analyzeMagicNoteDraft,
|
||||
analyzeMagicNoteEntry,
|
||||
analyzeMagicTodo
|
||||
} from './magic-note-analyzer'
|
||||
import { magicNotePlainText, validateMagicNoteContent } from './rich-content'
|
||||
|
||||
type AnalysisDependencies = {
|
||||
window: BrowserWindow
|
||||
assistantDatabase: AssistantDatabase
|
||||
settingsStore: Pick<RuntimeSettingsStore, 'getResolvedSettings'>
|
||||
applicationSettingsStore?: Pick<ApplicationSettingsStore, 'get'>
|
||||
persistModelUsage: (event: RuntimeModelUsageEvent) => void
|
||||
safeRuntimeError: (error: unknown, fallback: string) => string
|
||||
}
|
||||
|
||||
export function registerMagicNotesAnalysisIpcHandlers(
|
||||
registerHandler: typeof ipcMain.handle,
|
||||
{ window, assistantDatabase, settingsStore, applicationSettingsStore, persistModelUsage, safeRuntimeError }: AnalysisDependencies
|
||||
): void {
|
||||
registerHandler(
|
||||
ipcChannels.magicNotesAnalyze,
|
||||
async (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const { entryId, requestId, direction, format, canvasImages, canvasPageText, expectedRevision } =
|
||||
magicNoteAnalyzeSchema.parse(input)
|
||||
const settings = await settingsStore.getResolvedSettings()
|
||||
const entry = assistantDatabase.getMagicNoteEntry(entryId)
|
||||
if (expectedRevision !== undefined && entry.revision !== expectedRevision) {
|
||||
throw new Error('记录已被更新,请重新捕获并分析')
|
||||
}
|
||||
const note = assistantDatabase.getMagicNoteContext(entry.noteId)
|
||||
const canvasPageCount = (await applicationSettingsStore?.get())?.magicNoteCanvasPageCount ?? 1
|
||||
const analysisRuntime = createDefaultModelRuntime(
|
||||
settings.workspacePath,
|
||||
settings
|
||||
)
|
||||
assistantDatabase.createTask({
|
||||
id: requestId,
|
||||
title: `分析笔记:${note.title}`,
|
||||
instructions: '使用无工具模型对笔记记录进行只读分析',
|
||||
workMode: 'ask',
|
||||
origin: 'assistant',
|
||||
visible: false
|
||||
})
|
||||
try {
|
||||
const comments = await analyzeMagicNoteEntry(
|
||||
analysisRuntime,
|
||||
entry,
|
||||
{ requestId, direction, format, canvasImages, canvasPageText },
|
||||
format === 'structured'
|
||||
? undefined
|
||||
: (delta) => {
|
||||
if (!window.isDestroyed()) {
|
||||
window.webContents.send(
|
||||
ipcChannels.magicNotesAnalysisEvent,
|
||||
{
|
||||
requestId,
|
||||
type: 'text',
|
||||
delta,
|
||||
direction,
|
||||
format
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
persistModelUsage,
|
||||
{ supportsImageInput: settings.supportsImageInput === true, canvasPageCount }
|
||||
)
|
||||
const analyzedNote = assistantDatabase.saveMagicNoteAnalysis({
|
||||
entryId,
|
||||
expectedRevision: entry.revision,
|
||||
comments
|
||||
})
|
||||
assistantDatabase.updateTaskStatus(requestId, 'completed')
|
||||
return analyzedNote
|
||||
} catch (error) {
|
||||
const message = safeRuntimeError(error, '魔法笔记 AI 分析失败')
|
||||
assistantDatabase.updateTaskStatus(requestId, 'failed', message)
|
||||
throw new Error(message, { cause: error })
|
||||
} finally {
|
||||
try {
|
||||
await analysisRuntime.releaseConversation?.(
|
||||
`magic-notes:${entry.id}`
|
||||
)
|
||||
} finally {
|
||||
await analysisRuntime.dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.magicNotesAnalyzeDraft,
|
||||
async (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const parsed = magicNoteDraftAnalyzeSchema.parse(input)
|
||||
const content = validateMagicNoteContent(parsed.content)
|
||||
const plainText = magicNotePlainText(content)
|
||||
const settings = await settingsStore.getResolvedSettings()
|
||||
const canvasPageCount = (await applicationSettingsStore?.get())?.magicNoteCanvasPageCount ?? 1
|
||||
const analysisRuntime = createDefaultModelRuntime(
|
||||
settings.workspacePath,
|
||||
settings
|
||||
)
|
||||
const { requestId, direction, format, canvasImages, canvasPageText } = parsed
|
||||
assistantDatabase.createTask({
|
||||
id: requestId,
|
||||
title: '分析未保存笔记草稿',
|
||||
instructions: '使用无工具模型对未保存笔记草稿进行只读分析',
|
||||
workMode: 'ask',
|
||||
origin: 'assistant',
|
||||
visible: false
|
||||
})
|
||||
try {
|
||||
const comments = await analyzeMagicNoteDraft(
|
||||
analysisRuntime,
|
||||
plainText,
|
||||
{ requestId, direction, format, canvasImages, canvasPageText },
|
||||
format === 'structured'
|
||||
? undefined
|
||||
: (delta) => {
|
||||
if (!window.isDestroyed()) {
|
||||
window.webContents.send(
|
||||
ipcChannels.magicNotesAnalysisEvent,
|
||||
{
|
||||
requestId,
|
||||
type: 'text',
|
||||
delta,
|
||||
direction,
|
||||
format
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
persistModelUsage,
|
||||
{ supportsImageInput: settings.supportsImageInput === true, content, canvasPageCount }
|
||||
)
|
||||
assistantDatabase.updateTaskStatus(requestId, 'completed')
|
||||
return {
|
||||
id: randomUUID(),
|
||||
comments,
|
||||
inputMode: comments[0]?.inputMode,
|
||||
analyzedAt: new Date().toISOString()
|
||||
}
|
||||
} catch (error) {
|
||||
const message = safeRuntimeError(error, '魔法笔记草稿 AI 分析失败')
|
||||
assistantDatabase.updateTaskStatus(requestId, 'failed', message)
|
||||
throw new Error(message, { cause: error })
|
||||
} finally {
|
||||
try {
|
||||
await analysisRuntime.releaseConversation?.(
|
||||
`magic-note-drafts:${requestId}`
|
||||
)
|
||||
} finally {
|
||||
await analysisRuntime.dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function registerMagicTodosAnalysisIpcHandlers(
|
||||
registerHandler: typeof ipcMain.handle,
|
||||
{ window, assistantDatabase, settingsStore, applicationSettingsStore, persistModelUsage, safeRuntimeError }: AnalysisDependencies
|
||||
): void {
|
||||
registerHandler(
|
||||
ipcChannels.magicTodosAnalyze,
|
||||
async (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const { todoId, requestId, direction, format, canvasImages, canvasPageText, sourceEntryRevision } =
|
||||
magicTodoIdSchema.parse(input)
|
||||
const settings = await settingsStore.getResolvedSettings()
|
||||
const todo = assistantDatabase.getMagicTodo(todoId)
|
||||
const canvasPageCount = (await applicationSettingsStore?.get())?.magicNoteCanvasPageCount ?? 1
|
||||
const entry = assistantDatabase.getMagicNoteEntry(todo.entryId)
|
||||
if (sourceEntryRevision !== undefined && entry.revision !== sourceEntryRevision) {
|
||||
throw new Error('来源记录已被更新,请重新捕获并分析')
|
||||
}
|
||||
const analysisRuntime = createDefaultModelRuntime(
|
||||
settings.workspacePath,
|
||||
settings
|
||||
)
|
||||
assistantDatabase.createTask({
|
||||
id: requestId,
|
||||
title: `分析待办:${todo.title}`,
|
||||
instructions: '使用无工具模型对魔法笔记待办进行只读分析',
|
||||
workMode: 'ask',
|
||||
origin: 'assistant',
|
||||
visible: false
|
||||
})
|
||||
try {
|
||||
const comments = await analyzeMagicTodo(
|
||||
analysisRuntime,
|
||||
todo,
|
||||
{ requestId, direction, format, canvasImages, canvasPageText },
|
||||
format === 'structured'
|
||||
? undefined
|
||||
: (delta) => {
|
||||
if (!window.isDestroyed()) {
|
||||
window.webContents.send(
|
||||
ipcChannels.magicNotesAnalysisEvent,
|
||||
{
|
||||
requestId,
|
||||
type: 'text',
|
||||
delta,
|
||||
direction,
|
||||
format
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
persistModelUsage,
|
||||
{ supportsImageInput: settings.supportsImageInput === true, content: entry.content, canvasPageCount }
|
||||
)
|
||||
const analyzedTodo = assistantDatabase.saveMagicTodoAnalysis({
|
||||
todoId,
|
||||
expectedRevision: todo.revision,
|
||||
sourceEntryRevision: entry.revision,
|
||||
comments
|
||||
})
|
||||
assistantDatabase.updateTaskStatus(requestId, 'completed')
|
||||
return analyzedTodo
|
||||
} catch (error) {
|
||||
const message = safeRuntimeError(error, '魔法笔记待办 AI 分析失败')
|
||||
assistantDatabase.updateTaskStatus(requestId, 'failed', message)
|
||||
throw new Error(message, { cause: error })
|
||||
} finally {
|
||||
try {
|
||||
await analysisRuntime.releaseConversation?.(
|
||||
`magic-todos:${todo.id}`
|
||||
)
|
||||
} finally {
|
||||
await analysisRuntime.dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { BrowserWindow, ipcMain } from 'electron'
|
||||
import { ipcChannels } from '../../shared/ipc-channels'
|
||||
import {
|
||||
magicNoteCreateSchema,
|
||||
magicNoteSearchSchema,
|
||||
type MagicNoteSource,
|
||||
magicNoteDeleteSchema,
|
||||
magicNoteEntryCreateSchema,
|
||||
magicNoteEntryDeleteSchema,
|
||||
magicNoteEntryUpdateSchema,
|
||||
magicNoteUpdateSchema,
|
||||
magicTodoUpdateSchema
|
||||
} from '../../shared/magic-notes-contracts'
|
||||
import type { AssistantDatabase } from '../assistant/assistant-database'
|
||||
import { assertTrustedSender } from '../trusted-ipc-sender'
|
||||
import { magicNotePlainText, validateMagicNoteContent } from './rich-content'
|
||||
|
||||
export function registerMagicNotesIpcHandlers(
|
||||
registerHandler: typeof ipcMain.handle,
|
||||
window: BrowserWindow,
|
||||
assistantDatabase: AssistantDatabase
|
||||
): void {
|
||||
const resolveMagicNoteSource = (source?: MagicNoteSource): MagicNoteSource | undefined => {
|
||||
if (!source) return undefined
|
||||
const conversation = assistantDatabase.getConversation(source.conversationId)
|
||||
const messages = new Map(conversation.messages.map((message) => [message.id, message]))
|
||||
for (const id of source.messageIds) {
|
||||
const message = messages.get(id)
|
||||
if (!message || (source.kind === 'message' && message.role !== 'assistant')) {
|
||||
throw new Error('Invalid conversation source message')
|
||||
}
|
||||
}
|
||||
const project = conversation.projectId ? assistantDatabase.getProject(conversation.projectId) : undefined
|
||||
return {
|
||||
kind: source.kind,
|
||||
conversationId: conversation.id,
|
||||
messageIds: source.messageIds,
|
||||
capturedAt: source.capturedAt,
|
||||
conversationTitle: conversation.title,
|
||||
...(project ? { projectId: project.id, projectName: project.name } : {})
|
||||
}
|
||||
}
|
||||
|
||||
registerHandler(ipcChannels.magicNotesSearch, (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const { query, limit } = magicNoteSearchSchema.parse(input)
|
||||
return assistantDatabase.searchMagicNoteSummaries(query, limit)
|
||||
})
|
||||
|
||||
registerHandler(ipcChannels.magicNotesList, (event) => {
|
||||
assertTrustedSender(event, window)
|
||||
return { notes: assistantDatabase.listMagicNotes() }
|
||||
})
|
||||
|
||||
registerHandler(ipcChannels.magicNotesGet, (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const { noteId } = magicNoteDeleteSchema.parse(input)
|
||||
return assistantDatabase.getMagicNote(noteId)
|
||||
})
|
||||
|
||||
registerHandler(ipcChannels.magicNotesCreate, (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const parsed = magicNoteCreateSchema.parse(input)
|
||||
return assistantDatabase.createMagicNote({ ...parsed, source: resolveMagicNoteSource(parsed.source) })
|
||||
})
|
||||
|
||||
registerHandler(ipcChannels.magicNotesUpdate, (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
return assistantDatabase.updateMagicNote(
|
||||
magicNoteUpdateSchema.parse(input)
|
||||
)
|
||||
})
|
||||
|
||||
registerHandler(ipcChannels.magicNotesDelete, (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const { noteId } = magicNoteDeleteSchema.parse(input)
|
||||
assistantDatabase.deleteMagicNote(noteId)
|
||||
})
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.magicNotesCreateEntry,
|
||||
(event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const parsed = magicNoteEntryCreateSchema.parse(input)
|
||||
const content = validateMagicNoteContent(parsed.content)
|
||||
return assistantDatabase.createMagicNoteEntry({
|
||||
noteId: parsed.noteId,
|
||||
content,
|
||||
source: resolveMagicNoteSource(parsed.source),
|
||||
plainText: magicNotePlainText(content)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.magicNotesUpdateEntry,
|
||||
(event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const parsed = magicNoteEntryUpdateSchema.parse(input)
|
||||
const content = validateMagicNoteContent(parsed.content)
|
||||
return assistantDatabase.updateMagicNoteEntry({
|
||||
entryId: parsed.entryId,
|
||||
expectedRevision: parsed.expectedRevision,
|
||||
content,
|
||||
plainText: magicNotePlainText(content)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.magicNotesDeleteEntry,
|
||||
(event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const { entryId } = magicNoteEntryDeleteSchema.parse(input)
|
||||
return assistantDatabase.deleteMagicNoteEntry(entryId)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function registerMagicTodosIpcHandlers(
|
||||
registerHandler: typeof ipcMain.handle,
|
||||
window: BrowserWindow,
|
||||
assistantDatabase: AssistantDatabase
|
||||
): void {
|
||||
registerHandler(
|
||||
ipcChannels.magicTodosList,
|
||||
(event) => {
|
||||
assertTrustedSender(event, window)
|
||||
return { todos: assistantDatabase.listMagicTodos() }
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.magicTodosStatus,
|
||||
(event) => {
|
||||
assertTrustedSender(event, window)
|
||||
return assistantDatabase.getMagicTodoStatus()
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.magicTodosUpdate,
|
||||
(event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const todo = assistantDatabase.updateMagicTodo(
|
||||
magicTodoUpdateSchema.parse(input)
|
||||
)
|
||||
return {
|
||||
todo,
|
||||
note: assistantDatabase.getMagicNote(todo.noteId)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -59,6 +59,7 @@ function createConnection(identity: SshPoolConnectionIdentity) {
|
||||
return {
|
||||
identity,
|
||||
isUsable: vi.fn(() => true),
|
||||
forwardLoopback: vi.fn(),
|
||||
onDisconnect: vi.fn(() => () => undefined),
|
||||
openTerminalShell: vi.fn(),
|
||||
openAgentAttach: vi.fn(),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createHmac, getCiphers, randomBytes } from "node:crypto";
|
||||
import { connect as connectLoopback, type Socket } from "node:net";
|
||||
import { Client } from "ssh2";
|
||||
import type { ClientChannel, ConnectConfig, SFTPWrapper } from "ssh2";
|
||||
import {
|
||||
@@ -69,12 +70,18 @@ export type SshTerminalShellOptions = {
|
||||
cols: number;
|
||||
rows: number;
|
||||
term?: string;
|
||||
/** Main-generated native launch, not part of the renderer terminal contract. */
|
||||
command?: string;
|
||||
pty?: false;
|
||||
};
|
||||
|
||||
export type SshLoopbackForward = { port: number; close(): Promise<void> };
|
||||
|
||||
export interface AuthenticatedSshConnection {
|
||||
readonly identity: SshPoolConnectionIdentity;
|
||||
isUsable(): boolean;
|
||||
onDisconnect(listener: (error?: Error) => void): () => void;
|
||||
forwardLoopback(localPort: number): Promise<SshLoopbackForward>;
|
||||
openTerminalShell(
|
||||
options: SshTerminalShellOptions,
|
||||
signal?: AbortSignal,
|
||||
@@ -170,7 +177,7 @@ type InternalSshConnectionLease = SshTerminalConnectionLease &
|
||||
|
||||
type ClientLike = Pick<
|
||||
Client,
|
||||
"connect" | "end" | "destroy" | "exec" | "shell" | "sftp" | "on" | "once"
|
||||
"connect" | "end" | "destroy" | "exec" | "shell" | "sftp" | "on" | "once" | "removeListener" | "forwardIn" | "unforwardIn"
|
||||
>;
|
||||
|
||||
type SshConnectionDependencies = {
|
||||
@@ -468,20 +475,58 @@ export class Ssh2AuthenticatedConnection implements AuthenticatedSshConnection {
|
||||
finish(new Error("SSH 终端通道打开超时"));
|
||||
}, CHANNEL_OPEN_TIMEOUT_MS);
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
this.client.shell(
|
||||
{
|
||||
const pty = {
|
||||
term: options.term ?? "xterm-256color",
|
||||
cols: options.cols,
|
||||
rows: options.rows,
|
||||
width: 0,
|
||||
height: 0,
|
||||
},
|
||||
{ env: {}, x11: false },
|
||||
(error, channel) => finish(error, channel),
|
||||
);
|
||||
};
|
||||
if (options.command) {
|
||||
this.client.exec(options.command, { pty: options.pty === false ? false : pty, env: {}, x11: false }, (error, channel) => finish(error, channel));
|
||||
} else {
|
||||
this.client.shell(pty, { env: {}, x11: false }, (error, channel) => finish(error, channel));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async forwardLoopback(localPort: number): Promise<SshLoopbackForward> {
|
||||
this.assertUsable();
|
||||
const port = await new Promise<number>((resolve, reject) => {
|
||||
this.client.forwardIn("127.0.0.1", 0, (error, allocated) => error ? reject(error) : resolve(allocated));
|
||||
});
|
||||
const streams = new Set<Socket | ClientChannel>();
|
||||
const acceptConnection = (details: { destPort: number }, accept: () => ClientChannel): void => {
|
||||
if (details.destPort !== port) return;
|
||||
const remote = accept();
|
||||
const local = connectLoopback({ host: "127.0.0.1", port: localPort });
|
||||
streams.add(remote);
|
||||
streams.add(local);
|
||||
const close = (): void => {
|
||||
streams.delete(remote);
|
||||
streams.delete(local);
|
||||
remote.destroy();
|
||||
local.destroy();
|
||||
};
|
||||
remote.once("error", close);
|
||||
local.once("error", close);
|
||||
remote.once("close", close);
|
||||
local.once("close", close);
|
||||
remote.pipe(local).pipe(remote);
|
||||
};
|
||||
this.client.on("tcp connection", acceptConnection);
|
||||
let closing: Promise<void> | undefined;
|
||||
const removeDisconnect = this.onDisconnect(() => { void close(); });
|
||||
const close = (): Promise<void> => closing ??= (async () => {
|
||||
removeDisconnect();
|
||||
this.client.removeListener("tcp connection", acceptConnection);
|
||||
for (const stream of streams) stream.destroy();
|
||||
streams.clear();
|
||||
if (this.isUsable()) await new Promise<void>(resolve => this.client.unforwardIn("127.0.0.1", port, () => resolve()));
|
||||
})();
|
||||
return { port, close };
|
||||
}
|
||||
|
||||
async openAgentAttach(
|
||||
installationId: VerifiedAgentInstallationId,
|
||||
signal?: AbortSignal,
|
||||
@@ -1023,6 +1068,10 @@ export class SshConnectionPool {
|
||||
assertActive();
|
||||
return connection.openTerminalShell(options, signal);
|
||||
},
|
||||
forwardLoopback: (localPort) => {
|
||||
assertActive();
|
||||
return connection.forwardLoopback(localPort);
|
||||
},
|
||||
openAgentAttach: (installationId, signal) => {
|
||||
assertActive();
|
||||
return connection.openAgentAttach(installationId, signal);
|
||||
|
||||
@@ -52,6 +52,7 @@ function createLease(
|
||||
...identity
|
||||
},
|
||||
isUsable: vi.fn(() => true),
|
||||
forwardLoopback: vi.fn(),
|
||||
openAgentAttach: vi.fn(),
|
||||
runAgentDoctor: vi.fn(),
|
||||
runAgentBootstrapProbe: vi.fn(),
|
||||
|
||||
@@ -93,6 +93,24 @@ function sessionDependencies(
|
||||
}
|
||||
|
||||
describe('local terminal launch resolution', () => {
|
||||
it('spawns a Main-owned native executable directly without typing a shell command', async () => {
|
||||
const pty = new FakePty()
|
||||
const dependencies = sessionDependencies(pty)
|
||||
const spawnSpec = { executable: '/managed/node', args: ['/managed/cn.js', '--config', '/config with spaces.json'], cwd: '/project', env: { PATH: '/managed' }, label: 'Continue' }
|
||||
const session = await LocalTerminalSession.create({ ...baseOptions, spawnSpec, dependencies })
|
||||
expect(dependencies.spawn).toHaveBeenCalledWith(spawnSpec.executable, spawnSpec.args, expect.objectContaining({ cwd: '/project', env: spawnSpec.env }))
|
||||
expect(pty.write).not.toHaveBeenCalled()
|
||||
expect(session.snapshot()).toMatchObject({ state: 'running', shell: 'Continue', workingDirectory: '/project' })
|
||||
await session.close()
|
||||
})
|
||||
|
||||
it('fails a native launch with an invalid project directory instead of falling back home', async () => {
|
||||
const dependencies = sessionDependencies(new FakePty(), { directoryExists: async (path: string) => path === '/home/tester' })
|
||||
const session = await LocalTerminalSession.create({ ...baseOptions, dependencies,
|
||||
spawnSpec: { executable: '/managed/opencode', args: [], cwd: '/missing', env: {}, label: 'OpenCode' } })
|
||||
expect(session.snapshot().state).toBe('failed')
|
||||
expect(dependencies.spawn).not.toHaveBeenCalled()
|
||||
})
|
||||
it('prefers pwsh, then PowerShell, then COMSPEC on Windows', async () => {
|
||||
const available = new Set(['C:\\bin\\powershell.EXE'])
|
||||
const shell = await resolveLocalTerminalShell({
|
||||
|
||||
@@ -29,6 +29,15 @@ export type LocalTerminalLaunch = {
|
||||
env: NodeJS.ProcessEnv
|
||||
}
|
||||
|
||||
/** Main-owned launch material. Never accepted by the terminal IPC contract. */
|
||||
export type NativeTerminalSpawnSpec = {
|
||||
executable: string
|
||||
args: string[]
|
||||
cwd: string
|
||||
env: NodeJS.ProcessEnv
|
||||
label: string
|
||||
}
|
||||
|
||||
export type LocalPtySpawn = (
|
||||
file: string,
|
||||
args: string[],
|
||||
@@ -52,6 +61,7 @@ export type LocalTerminalSessionOptions = {
|
||||
title: string
|
||||
size: TerminalSize
|
||||
projectDirectory?: string
|
||||
spawnSpec?: NativeTerminalSpawnSpec
|
||||
dependencies?: LocalTerminalDependencies
|
||||
}
|
||||
|
||||
@@ -370,14 +380,23 @@ export class LocalTerminalSession {
|
||||
|
||||
private async start(): Promise<void> {
|
||||
try {
|
||||
this.launch = await resolveLocalTerminalLaunch(
|
||||
const spec = this.options.spawnSpec
|
||||
if (spec && !(await (this.dependencies.directoryExists ?? defaultDirectoryExists)(spec.cwd))) {
|
||||
throw new Error('Native client project directory is unavailable')
|
||||
}
|
||||
this.launch = spec ? {
|
||||
shell: spec.executable,
|
||||
shellLabel: spec.label,
|
||||
cwd: spec.cwd,
|
||||
env: spec.env
|
||||
} : await resolveLocalTerminalLaunch(
|
||||
this.options.target.type === 'project'
|
||||
? this.options.projectDirectory
|
||||
: undefined,
|
||||
this.dependencies
|
||||
)
|
||||
const spawn = this.dependencies.spawn ?? nodePty.spawn
|
||||
this.pty = spawn(this.launch.shell, [], {
|
||||
this.pty = spawn(this.launch.shell, spec?.args ?? [], {
|
||||
name: 'xterm-256color',
|
||||
cols: this.size.cols,
|
||||
rows: this.size.rows,
|
||||
|
||||
@@ -64,6 +64,7 @@ function createHarness(idleTimeoutMs = 60_000) {
|
||||
const connection = {
|
||||
identity,
|
||||
isUsable: vi.fn(() => usable),
|
||||
forwardLoopback: vi.fn(),
|
||||
onDisconnect: vi.fn((listener: (error?: Error) => void) => {
|
||||
disconnectListeners.add(listener);
|
||||
return () => disconnectListeners.delete(listener);
|
||||
|
||||
@@ -25,6 +25,7 @@ export type SshTerminalSessionOptions = {
|
||||
targetLabel: string;
|
||||
title: string;
|
||||
shell?: string;
|
||||
command?: string;
|
||||
workingDirectory?: string;
|
||||
size: TerminalSize;
|
||||
poolTarget: SshConnectionPoolTarget;
|
||||
@@ -95,7 +96,7 @@ export async function createSshTerminalSession(
|
||||
options: SshTerminalSessionOptions,
|
||||
): Promise<SshTerminalSession> {
|
||||
const initialSize = terminalSizeSchema.parse(options.size);
|
||||
const workingDirectoryCommand = options.workingDirectory
|
||||
const workingDirectoryCommand = options.workingDirectory && !options.command
|
||||
? `cd -- ${quotePosixShellArgument(options.workingDirectory)} || exit\r`
|
||||
: undefined;
|
||||
const lease = await pool.acquireTerminal(options.poolTarget, options.signal);
|
||||
@@ -106,6 +107,7 @@ export async function createSshTerminalSession(
|
||||
cols: initialSize.cols,
|
||||
rows: initialSize.rows,
|
||||
term: SSH_TERMINAL_TERM,
|
||||
...(options.command ? { command: options.command } : {}),
|
||||
},
|
||||
options.signal,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { BrowserWindow, ipcMain } from 'electron'
|
||||
import { ipcChannels } from '../../shared/ipc-channels'
|
||||
import {
|
||||
terminalAckRequestSchema,
|
||||
terminalCloseRequestSchema,
|
||||
terminalCreateRequestSchema,
|
||||
terminalResizeRequestSchema,
|
||||
terminalSnapshotRequestSchema,
|
||||
terminalWriteRequestSchema
|
||||
} from '../../shared/terminal-contracts'
|
||||
import { assertTrustedSender } from '../trusted-ipc-sender'
|
||||
import type { TerminalSessionManager } from './terminal-session-manager'
|
||||
|
||||
export function registerTerminalIpcHandlers(
|
||||
registerHandler: typeof ipcMain.handle,
|
||||
window: BrowserWindow,
|
||||
terminalSessionManager?: TerminalSessionManager
|
||||
): void {
|
||||
const requireTerminalSessionManager = (): TerminalSessionManager => {
|
||||
if (!terminalSessionManager) {
|
||||
throw new Error('终端服务不可用')
|
||||
}
|
||||
return terminalSessionManager
|
||||
}
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.terminalCreate,
|
||||
async (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const manager = requireTerminalSessionManager()
|
||||
const snapshot = await manager.create(
|
||||
event.sender.id,
|
||||
terminalCreateRequestSchema.parse(input)
|
||||
)
|
||||
setImmediate(() => {
|
||||
if (
|
||||
!window.isDestroyed() &&
|
||||
event.sender === window.webContents &&
|
||||
!event.sender.isDestroyed()
|
||||
) {
|
||||
manager.enableEventDelivery(
|
||||
event.sender.id,
|
||||
snapshot.sessionId
|
||||
)
|
||||
}
|
||||
})
|
||||
return snapshot
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(ipcChannels.terminalWrite, (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const manager = requireTerminalSessionManager()
|
||||
const request = terminalWriteRequestSchema.parse(input)
|
||||
manager.write(
|
||||
event.sender.id,
|
||||
request.sessionId,
|
||||
request.data
|
||||
)
|
||||
})
|
||||
|
||||
registerHandler(ipcChannels.terminalResize, (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const manager = requireTerminalSessionManager()
|
||||
const request = terminalResizeRequestSchema.parse(input)
|
||||
manager.resize(event.sender.id, request.sessionId, {
|
||||
cols: request.cols,
|
||||
rows: request.rows
|
||||
})
|
||||
})
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.terminalSnapshot,
|
||||
(event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const manager = requireTerminalSessionManager()
|
||||
const request = terminalSnapshotRequestSchema.parse(input)
|
||||
return manager.snapshot(
|
||||
event.sender.id,
|
||||
request.sessionId
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.terminalAck,
|
||||
(event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const manager = requireTerminalSessionManager()
|
||||
const request = terminalAckRequestSchema.parse(input)
|
||||
manager.acknowledge(
|
||||
event.sender.id,
|
||||
request.sessionId,
|
||||
request.sequence
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.terminalClose,
|
||||
async (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const manager = requireTerminalSessionManager()
|
||||
const request = terminalCloseRequestSchema.parse(input)
|
||||
return manager.close(
|
||||
event.sender.id,
|
||||
request.sessionId
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -160,6 +160,28 @@ function harness(
|
||||
}
|
||||
|
||||
describe('TerminalSessionManager routing and delivery', () => {
|
||||
it('releases native resources on exit before delivery, exactly once through close', async () => {
|
||||
const test = harness()
|
||||
const dispose = vi.fn(async () => undefined)
|
||||
const launch = { spawnSpec: { executable: '/runtime', args: [], cwd: '/authoritative/root', env: {}, label: 'OpenCode' }, title: 'OpenCode', dispose }
|
||||
const result = await test.manager.create(1, { target: { type: 'project', projectId }, cols: 80, rows: 24 }, launch)
|
||||
expect(test.createLocalSession.mock.calls[0]?.[0]).toMatchObject({ spawnSpec: launch.spawnSpec, title: 'OpenCode' })
|
||||
test.localSessions[0]!.emit({ sessionId: result.sessionId, sequence: 2, type: 'state', state: 'exited' })
|
||||
expect(dispose).toHaveBeenCalledTimes(1)
|
||||
expect(test.delivered).toHaveLength(0)
|
||||
await test.manager.close(1, result.sessionId)
|
||||
expect(dispose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('releases native resources when execution-space resolution disagrees', async () => {
|
||||
const test = harness()
|
||||
const dispose = vi.fn(async () => undefined)
|
||||
await expect(test.manager.create(1, { target: { type: 'project', projectId }, cols: 80, rows: 24 }, {
|
||||
spawnSpec: { executable: '/runtime', args: [], cwd: '/different', env: {}, label: 'OpenCode' }, title: 'OpenCode', dispose
|
||||
})).rejects.toThrow('execution space')
|
||||
expect(dispose).toHaveBeenCalledOnce()
|
||||
expect(test.createLocalSession).not.toHaveBeenCalled()
|
||||
})
|
||||
it('routes Home local and authoritative local project roots', async () => {
|
||||
const test = harness()
|
||||
const local = await test.manager.create(1, {
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
} from '../ssh/ssh-connection-pool'
|
||||
import {
|
||||
LocalTerminalSession,
|
||||
type NativeTerminalSpawnSpec,
|
||||
type LocalTerminalSessionOptions
|
||||
} from './local-terminal-session'
|
||||
import {
|
||||
@@ -73,6 +74,14 @@ type SessionRecord = {
|
||||
ready: Promise<ManagedTerminalSession>
|
||||
closePromise?: Promise<TerminalSnapshot>
|
||||
removeEventListener?: () => void
|
||||
releaseResources?: () => Promise<void>
|
||||
}
|
||||
|
||||
export type NativeTerminalLaunch = {
|
||||
spawnSpec: NativeTerminalSpawnSpec
|
||||
title: string
|
||||
dispose(): Promise<void>
|
||||
remote?: { hostId: string; hostRevision: number; hostKeyGeneration: number; command: string }
|
||||
}
|
||||
|
||||
type ResolvedLaunch =
|
||||
@@ -103,7 +112,8 @@ export class TerminalSessionManager {
|
||||
|
||||
async create(
|
||||
ownerWebContentsId: number,
|
||||
request: TerminalCreateRequest
|
||||
request: TerminalCreateRequest,
|
||||
nativeLaunch?: NativeTerminalLaunch
|
||||
): Promise<TerminalSnapshot> {
|
||||
this.assertOwnerId(ownerWebContentsId)
|
||||
if (this.disposed) {
|
||||
@@ -130,16 +140,26 @@ export class TerminalSessionManager {
|
||||
closing: false,
|
||||
ready: Promise.resolve(undefined as never)
|
||||
}
|
||||
if (nativeLaunch) {
|
||||
let release: Promise<void> | undefined
|
||||
record.releaseResources = () => release ??= nativeLaunch.dispose()
|
||||
}
|
||||
this.records.set(sessionId, record)
|
||||
record.ready = this.createSession(record, request)
|
||||
record.ready = this.createSession(record, request, nativeLaunch)
|
||||
|
||||
try {
|
||||
const session = await record.ready
|
||||
if (nativeLaunch && session.snapshot().state === 'failed') {
|
||||
const message = session.snapshot().error?.message ?? 'Native terminal failed to start'
|
||||
await this.closeRecord(record)
|
||||
throw new Error(message)
|
||||
}
|
||||
if (record.closing) {
|
||||
return await this.closeRecord(record)
|
||||
}
|
||||
return session.snapshot()
|
||||
} catch (error) {
|
||||
await record.releaseResources?.()
|
||||
if (this.records.get(sessionId) === record) {
|
||||
this.records.delete(sessionId)
|
||||
}
|
||||
@@ -263,14 +283,24 @@ export class TerminalSessionManager {
|
||||
|
||||
private async createSession(
|
||||
record: SessionRecord,
|
||||
request: TerminalCreateRequest
|
||||
request: TerminalCreateRequest,
|
||||
nativeLaunch?: NativeTerminalLaunch
|
||||
): Promise<ManagedTerminalSession> {
|
||||
const launch = await this.resolveLaunch(request)
|
||||
if (nativeLaunch) {
|
||||
const remote = nativeLaunch.remote
|
||||
const matches = remote
|
||||
? launch.kind === 'ssh' && launch.workingDirectory === nativeLaunch.spawnSpec.cwd &&
|
||||
launch.poolTarget.host.id === remote.hostId && launch.poolTarget.hostRevision === remote.hostRevision &&
|
||||
launch.poolTarget.hostKeyGeneration === remote.hostKeyGeneration
|
||||
: launch.kind === 'local' && launch.projectDirectory === nativeLaunch.spawnSpec.cwd
|
||||
if (!matches) throw new Error('Native terminal launch does not match the project execution space')
|
||||
}
|
||||
const common = {
|
||||
sessionId: record.sessionId,
|
||||
target: request.target,
|
||||
targetLabel: launch.targetLabel,
|
||||
title: `终端 · ${launch.targetLabel}`,
|
||||
title: nativeLaunch?.title ?? `终端 · ${launch.targetLabel}`,
|
||||
size: { cols: request.cols, rows: request.rows }
|
||||
}
|
||||
let session: ManagedTerminalSession
|
||||
@@ -284,7 +314,8 @@ export class TerminalSessionManager {
|
||||
TerminalCreateRequest['target'],
|
||||
{ type: 'local' | 'project' }
|
||||
>,
|
||||
projectDirectory: launch.projectDirectory
|
||||
projectDirectory: launch.projectDirectory,
|
||||
...(nativeLaunch ? { spawnSpec: nativeLaunch.spawnSpec } : {})
|
||||
})
|
||||
record.session = session
|
||||
record.removeEventListener = session.onEvent?.((event) =>
|
||||
@@ -299,6 +330,7 @@ export class TerminalSessionManager {
|
||||
...common,
|
||||
workingDirectory: launch.workingDirectory,
|
||||
poolTarget: launch.poolTarget,
|
||||
...(nativeLaunch?.remote ? { command: nativeLaunch.remote.command, shell: nativeLaunch.spawnSpec.label } : {}),
|
||||
onEvent: (event) => this.handleEvent(record, event)
|
||||
})
|
||||
record.session = session
|
||||
@@ -376,6 +408,10 @@ export class TerminalSessionManager {
|
||||
if (this.records.get(record.sessionId) !== record) {
|
||||
return
|
||||
}
|
||||
if (event.type === 'state' && ['exited', 'failed', 'interrupted'].includes(event.state)) {
|
||||
// Cleanup must also happen while event delivery is waiting for the IPC reply.
|
||||
void record.releaseResources?.().catch(() => undefined)
|
||||
}
|
||||
if (!record.deliveryEnabled) {
|
||||
record.delayedEvents.push(event)
|
||||
return
|
||||
@@ -413,6 +449,7 @@ export class TerminalSessionManager {
|
||||
try {
|
||||
const session = await record.ready
|
||||
const closed = await session.close()
|
||||
await record.releaseResources?.()
|
||||
const snapshot =
|
||||
closed && typeof closed === 'object' && 'sessionId' in closed
|
||||
? closed
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { clipboard, type BrowserWindow, type ipcMain } from 'electron'
|
||||
import { clipboardTextSchema } from '../shared/contracts'
|
||||
import { ipcChannels } from '../shared/ipc-channels'
|
||||
import { assertTrustedSender } from './trusted-ipc-sender'
|
||||
import { showWindow } from './window'
|
||||
|
||||
export function registerClipboardIpcHandlers(
|
||||
registerHandler: typeof ipcMain.handle,
|
||||
window: BrowserWindow
|
||||
): void {
|
||||
registerHandler(ipcChannels.clipboardReadText, (event) => {
|
||||
assertTrustedSender(event, window)
|
||||
return clipboardTextSchema.parse(clipboard.readText())
|
||||
})
|
||||
|
||||
registerHandler(ipcChannels.clipboardWriteText, (event, input) => {
|
||||
assertTrustedSender(event, window)
|
||||
clipboard.writeText(clipboardTextSchema.parse(input))
|
||||
})
|
||||
}
|
||||
|
||||
export function registerWindowIpcHandlers(
|
||||
registerHandler: typeof ipcMain.handle,
|
||||
window: BrowserWindow
|
||||
): void {
|
||||
registerHandler(ipcChannels.appShow, (event) => {
|
||||
assertTrustedSender(event, window)
|
||||
showWindow(window)
|
||||
})
|
||||
|
||||
registerHandler(ipcChannels.appHide, (event) => {
|
||||
assertTrustedSender(event, window)
|
||||
window.hide()
|
||||
})
|
||||
|
||||
registerHandler(ipcChannels.windowMinimize, (event) => {
|
||||
assertTrustedSender(event, window)
|
||||
window.minimize()
|
||||
})
|
||||
|
||||
registerHandler(ipcChannels.windowToggleMaximize, (event) => {
|
||||
assertTrustedSender(event, window)
|
||||
if (window.isMaximized()) {
|
||||
window.unmaximize()
|
||||
} else {
|
||||
window.maximize()
|
||||
}
|
||||
})
|
||||
|
||||
registerHandler(ipcChannels.windowClose, (event) => {
|
||||
assertTrustedSender(event, window)
|
||||
window.close()
|
||||
})
|
||||
|
||||
registerHandler(ipcChannels.windowIsMaximized, (event): boolean => {
|
||||
assertTrustedSender(event, window)
|
||||
return window.isMaximized()
|
||||
})
|
||||
}
|
||||
@@ -210,6 +210,9 @@ import type {
|
||||
|
||||
|
||||
const desktopApi: DesktopApi = {
|
||||
openRuntimeNativeClient: (input) => ipcRenderer.invoke(ipcChannels.runtimeNativeClientOpen, input),
|
||||
getRuntimeNativeClient: (input) => ipcRenderer.invoke(ipcChannels.runtimeNativeClientGet, input),
|
||||
stopRuntimeNativeClient: (input) => ipcRenderer.invoke(ipcChannels.runtimeNativeClientStop, input),
|
||||
storageUpgrade: {
|
||||
getProgress: () => ipcRenderer.invoke(ipcChannels.storageUpgradeProgress),
|
||||
act: async (action) => {
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from "../../shared/application-settings-contracts";
|
||||
import type { GlobalShortcutSettingsSnapshot } from "../../shared/shortcut";
|
||||
import type { TerminalSnapshot } from "../../shared/terminal-contracts";
|
||||
import type { RuntimeNativeClientApi } from "./RuntimeNativeClientActions";
|
||||
import { loadBrandingPreferences, saveBrandingPreferences } from "./branding";
|
||||
import type {
|
||||
AssistantProject,
|
||||
@@ -198,7 +199,10 @@ const terminalSnapshot: TerminalSnapshot = {
|
||||
error: null,
|
||||
};
|
||||
|
||||
const api: DesktopApi = {
|
||||
const api: DesktopApi & RuntimeNativeClientApi = {
|
||||
openRuntimeNativeClient: vi.fn(async () => ({ kind: "terminal" as const, terminal: terminalSnapshot })),
|
||||
getRuntimeNativeClient: vi.fn(async () => null),
|
||||
stopRuntimeNativeClient: vi.fn(async () => undefined),
|
||||
localInference: {
|
||||
openSettings: vi.fn(async () => undefined),
|
||||
getSnapshot: vi.fn(async () => ({ services: [], tasks: [] })),
|
||||
@@ -1162,6 +1166,9 @@ describe("App", () => {
|
||||
document.documentElement.style.colorScheme = "";
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(api.magicNotes.search).mockReset().mockResolvedValue([]);
|
||||
vi.mocked(api.openRuntimeNativeClient).mockReset().mockResolvedValue({ kind: "terminal", terminal: terminalSnapshot });
|
||||
vi.mocked(api.getRuntimeNativeClient).mockReset().mockResolvedValue(null);
|
||||
vi.mocked(api.stopRuntimeNativeClient).mockReset().mockResolvedValue();
|
||||
vi.mocked(api.knowledge.getSnapshot).mockReset();
|
||||
vi.mocked(api.knowledge.externalInstancesList).mockReset();
|
||||
vi.mocked(api.agent.respondQuestion).mockReset().mockResolvedValue();
|
||||
@@ -9663,6 +9670,77 @@ describe("App", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["continue", "opencode"] as const)("opens the %s native terminal without compression or history and persists current settings", async (provider) => {
|
||||
const conversationId = "00000000-0000-4000-8000-000000000731";
|
||||
vi.mocked(api.conversations.list).mockResolvedValueOnce([{
|
||||
id: conversationId, projectId, title: "Native client", updatedAt: Date.now(),
|
||||
runtimeSelection: { provider }, workMode: "execute", messages: [],
|
||||
}]);
|
||||
render(<App />);
|
||||
const open = await screen.findByRole("button", { name: "在终端中打开" });
|
||||
expect(open).toBeEnabled();
|
||||
expect(screen.queryByRole("button", { name: "压缩上下文" })).not.toBeInTheDocument();
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "向 GoodBuddy 提问" }), { target: { value: "Keep my draft" } });
|
||||
fireEvent.click(open);
|
||||
await waitFor(() => {
|
||||
expect(api.openRuntimeNativeClient).toHaveBeenCalledWith({ conversationId });
|
||||
expect(screen.getByRole("tab", { name: /终端 1/ })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByRole("region", { name: "用户终端:终端 1" })).toBeInTheDocument();
|
||||
});
|
||||
expect(api.conversations.saveLocal).toHaveBeenCalledWith([{
|
||||
header: expect.objectContaining({ id: conversationId, runtimeSelection: { provider }, workMode: "execute" }), messages: [],
|
||||
}]);
|
||||
expect(screen.getByRole("textbox", { name: "向 GoodBuddy 提问" })).toHaveValue("Keep my draft");
|
||||
expect(api.terminal.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hides the native client shortcut for the built-in model", async () => {
|
||||
render(<App />);
|
||||
await screen.findByRole("textbox", { name: "向 GoodBuddy 提问" });
|
||||
await waitFor(() => expect(api.settings.getRuntime).toHaveBeenCalled());
|
||||
expect(screen.queryByRole("button", { name: "在终端中打开" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "在浏览器中打开" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens local DS in the external browser without opening the workbar", async () => {
|
||||
const conversationId = "00000000-0000-4000-8000-000000000731";
|
||||
vi.mocked(api.conversations.list).mockResolvedValueOnce([{
|
||||
id: conversationId, projectId, title: "DS client", updatedAt: Date.now(),
|
||||
runtimeSelection: { provider: "deepseek-harness" }, messages: [],
|
||||
}]);
|
||||
vi.mocked(api.openRuntimeNativeClient).mockResolvedValue({ kind: "browser", serviceId: "ds-service" });
|
||||
render(<App />);
|
||||
const open = await screen.findByRole("button", { name: "在浏览器中打开" });
|
||||
await waitFor(() => expect(api.getRuntimeNativeClient).toHaveBeenCalledWith({ conversationId }));
|
||||
fireEvent.click(open);
|
||||
await waitFor(() => {
|
||||
expect(api.openRuntimeNativeClient).toHaveBeenCalledWith({ conversationId });
|
||||
expect(screen.getByRole("button", { name: "重新打开" })).toBeEnabled();
|
||||
expect(screen.getByRole("button", { name: "停止此服务" })).toBeEnabled();
|
||||
});
|
||||
expect(screen.getByRole("button", { name: "切换助手工作栏" })).toHaveAttribute("aria-expanded", "false");
|
||||
expect(api.terminal.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps a late native terminal without opening the workbar after switching conversations", async () => {
|
||||
const conversationId = "00000000-0000-4000-8000-000000000731";
|
||||
vi.mocked(api.conversations.list).mockResolvedValueOnce([
|
||||
{ id: conversationId, projectId, title: "Original native", updatedAt: 2, runtimeSelection: { provider: "continue" }, messages: [] },
|
||||
{ id: "00000000-0000-4000-8000-000000000732", projectId, title: "Other conversation", updatedAt: 1, messages: [] },
|
||||
]);
|
||||
let finish!: (result: Awaited<ReturnType<RuntimeNativeClientApi['openRuntimeNativeClient']>>) => void;
|
||||
vi.mocked(api.openRuntimeNativeClient).mockImplementation(() => new Promise(resolve => { finish = resolve; }));
|
||||
render(<App />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "在终端中打开" }));
|
||||
await waitFor(() => expect(api.openRuntimeNativeClient).toHaveBeenCalledWith({ conversationId }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /^Other conversation/ }));
|
||||
await waitFor(() => expect(screen.queryByRole("button", { name: "正在打开…" })).not.toBeInTheDocument());
|
||||
await act(async () => finish({ kind: "terminal", terminal: terminalSnapshot }));
|
||||
expect(screen.getByRole("button", { name: "切换助手工作栏" })).toHaveAttribute("aria-expanded", "false");
|
||||
fireEvent.click(screen.getByRole("button", { name: "切换助手工作栏" }));
|
||||
expect(await screen.findByRole("tab", { name: /终端 1/ })).toHaveAttribute("aria-selected", "false");
|
||||
});
|
||||
|
||||
it("manually compacts Continue context and persists the summary state", async () => {
|
||||
const conversationId = "00000000-0000-4000-8000-000000000731";
|
||||
const messages = [
|
||||
@@ -9755,11 +9833,10 @@ describe("App", () => {
|
||||
name: "压缩上下文",
|
||||
});
|
||||
expect(compactContext.parentElement).toHaveClass(
|
||||
"composer-meta",
|
||||
"composer-meta--with-context-compact",
|
||||
"composer-meta__actions",
|
||||
);
|
||||
expect(compactContext.parentElement?.firstElementChild).toBe(
|
||||
compactContext,
|
||||
screen.getByRole("button", { name: "在终端中打开" }),
|
||||
);
|
||||
fireEvent.click(compactContext);
|
||||
await waitFor(() =>
|
||||
|
||||
@@ -3,6 +3,8 @@ import { MagicNotesPanel } from './MagicNotesPanel';
|
||||
import { AnchoredMenu } from './AnchoredMenu';
|
||||
import { useMagicNoteDraft } from './use-magic-note-draft';
|
||||
import type { MagicNoteSource } from '../../shared/magic-notes-contracts';
|
||||
import { RuntimeNativeClientActions } from "./RuntimeNativeClientActions";
|
||||
import type { TerminalSnapshot } from "../../shared/terminal-contracts";
|
||||
import LocalInferencePage from "./LocalInferencePage";
|
||||
import { ApplicationMenu } from './ApplicationMenu';
|
||||
import { AttachmentResultButton } from './AttachmentResultButton';
|
||||
@@ -2440,6 +2442,7 @@ function App(): React.JSX.Element {
|
||||
loadPrimarySidebarWidth,
|
||||
);
|
||||
const [primarySidebarResizing, setPrimarySidebarResizing] = useState(false);
|
||||
const [nativeTerminals, setNativeTerminals] = useState<{ terminal: TerminalSnapshot; focus: boolean }[]>([]);
|
||||
const [assistantSidebarOpen, setAssistantSidebarOpen] = useState(
|
||||
() => window.innerWidth >= 1280,
|
||||
);
|
||||
@@ -8903,6 +8906,32 @@ function App(): React.JSX.Element {
|
||||
activeRuntimeSelection?.provider === "continue") &&
|
||||
runtimeNativeSnapshot?.context.manualCompact === true;
|
||||
|
||||
const nativeClientAvailable = activeRuntimeSelection?.provider === "continue" ||
|
||||
activeRuntimeSelection?.provider === "opencode" ||
|
||||
(activeRuntimeSelection?.provider === "deepseek-harness" && activeProject?.executionSpace.kind === "local");
|
||||
const nativeClientContextKey = useMemo(() => JSON.stringify([
|
||||
activeId, activeProjectId, activeRuntimeSelection, workMode, runtimeSettings,
|
||||
]), [activeId, activeProjectId, activeRuntimeSelection, workMode, runtimeSettings]);
|
||||
const prepareNativeClientConversation = async (): Promise<string> => {
|
||||
let conversation = activeConversation;
|
||||
if (!conversation) {
|
||||
conversation = await new Promise<Conversation>((resolve, reject) => {
|
||||
if (!startNewConversation(activeProjectId || undefined, { ready: resolve })) {
|
||||
reject(new Error(t("notices.channelConversationAutomatic")));
|
||||
}
|
||||
});
|
||||
setActiveId(conversation.id);
|
||||
}
|
||||
// Save the current selection before Main resolves the launch from its conversation ID.
|
||||
const header = toLocalConversationHeader({ ...conversation, runtimeSelection: activeRuntimeSelection, workMode });
|
||||
const operation = conversationPersistenceQueueRef.current.then(() =>
|
||||
window.goodbuddy.conversations.saveLocal([{ header, messages: [] }]),
|
||||
);
|
||||
conversationPersistenceQueueRef.current = operation.catch(() => undefined);
|
||||
await operation;
|
||||
return conversation.id;
|
||||
};
|
||||
|
||||
const composerContextMetrics = useMemo(() => {
|
||||
if (
|
||||
!activeConversation ||
|
||||
@@ -10995,12 +11024,22 @@ function App(): React.JSX.Element {
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`composer-meta${
|
||||
runtimeContextCompactAvailable
|
||||
? " composer-meta--with-context-compact"
|
||||
: ""
|
||||
}`}
|
||||
className="composer-meta"
|
||||
>
|
||||
<div className="composer-meta__actions">
|
||||
{nativeClientAvailable && <RuntimeNativeClientActions
|
||||
browser={activeRuntimeSelection?.provider === "deepseek-harness"}
|
||||
contextKey={nativeClientContextKey}
|
||||
conversationId={activeConversation?.id}
|
||||
prepareConversation={prepareNativeClientConversation}
|
||||
notify={notify}
|
||||
onTerminal={(terminal) => {
|
||||
const focus = activeProjectIdRef.current === activeProjectId &&
|
||||
activeConversationIdRef.current === activeId && viewRef.current === "chat";
|
||||
setNativeTerminals(current => [...current, { terminal, focus }]);
|
||||
if (focus) setAssistantSidebarOpen(true);
|
||||
}}
|
||||
/>}
|
||||
{runtimeContextCompactAvailable && (
|
||||
<button
|
||||
className="composer-context-compact"
|
||||
@@ -11023,6 +11062,7 @@ function App(): React.JSX.Element {
|
||||
: t("composer.context.compact")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{composerContextMetrics && (
|
||||
<div
|
||||
className={`composer-context-meter${
|
||||
@@ -11863,6 +11903,7 @@ function App(): React.JSX.Element {
|
||||
notesPanel={<MagicNotesPanel state={noteDraft} active={assistantSidebarOpen && magicNotesEnabled} commentMode={applicationSettings?.magicNoteCommentMode} commentFormat={applicationSettings?.magicNoteCommentFormat} onNotify={notify} onOpenSource={openNoteSource}
|
||||
onCancelCapture={() => requestAnimationFrame(() => { const trigger = noteCaptureTrigger.current; (trigger?.isConnected && !trigger.closest('[hidden], [inert]') ? trigger : noteTitleMenuRef.current)?.focus(); })}
|
||||
onOpenWorkspace={(noteId, entryId) => requestWorkspaceLeave('magic-notes', () => { setNotesNavigation({ noteId, entryId, requestId: Date.now() }); commitView('magic-notes'); })} />}
|
||||
nativeTerminals={nativeTerminals}
|
||||
activeConversationId={activeId}
|
||||
conversationStats={statsConversation && executionStats.conversation ? {
|
||||
conversationId: statsConversation.id,
|
||||
|
||||
@@ -125,6 +125,7 @@ export type RightAssistantSidebarProps = {
|
||||
notesOpenRequest?: number
|
||||
notesPanel?: React.ReactNode
|
||||
onBeforeCloseNotes?: () => Promise<boolean>
|
||||
nativeTerminals?: readonly { terminal: TerminalSnapshot; focus: boolean }[]
|
||||
open: boolean
|
||||
tab: AssistantSidebarTab
|
||||
approvals: PendingSidebarApproval[]
|
||||
@@ -815,6 +816,7 @@ export function RightAssistantSidebar({
|
||||
notesOpenRequest = 0,
|
||||
notesPanel,
|
||||
onBeforeCloseNotes,
|
||||
nativeTerminals,
|
||||
open,
|
||||
tab,
|
||||
approvals,
|
||||
@@ -1060,6 +1062,18 @@ export function RightAssistantSidebar({
|
||||
const [terminalSnapshots, setTerminalSnapshots] = useState<
|
||||
Record<string, TerminalSnapshot>
|
||||
>({})
|
||||
const [attachedNativeTerminalCount, setAttachedNativeTerminalCount] = useState(0)
|
||||
if (nativeTerminals && nativeTerminals.length > attachedNativeTerminalCount) {
|
||||
const additions = nativeTerminals.slice(attachedNativeTerminalCount)
|
||||
setAttachedNativeTerminalCount(nativeTerminals.length)
|
||||
setWorkbarInstances(current => [...current, ...additions.map(({ terminal }) => ({
|
||||
id: terminal.sessionId, appId: 'terminal' as const, title: terminal.title, targetRef: terminal.target
|
||||
}))])
|
||||
setTerminalSessionIds(current => ({ ...current, ...Object.fromEntries(additions.map(({ terminal }) => [terminal.sessionId, terminal.sessionId])) }))
|
||||
setTerminalSnapshots(current => ({ ...current, ...Object.fromEntries(additions.map(({ terminal }) => [terminal.sessionId, terminal])) }))
|
||||
const focused = additions.findLast(entry => entry.focus)
|
||||
if (focused) setActiveWorkbarInstanceId(focused.terminal.sessionId)
|
||||
}
|
||||
const [terminalCloseConfirmation, setTerminalCloseConfirmation] =
|
||||
useState<{
|
||||
instance: WorkbarTabInstance
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { RuntimeNativeClientActions, type RuntimeNativeClientApi } from './RuntimeNativeClientActions'
|
||||
import { changeUiLocale } from './i18n'
|
||||
import type { TerminalSnapshot } from '../../shared/terminal-contracts'
|
||||
|
||||
const terminal: TerminalSnapshot = {
|
||||
sessionId: '00000000-0000-4000-8000-000000000123', target: { type: 'local' },
|
||||
title: 'Continue', targetLabel: 'Local', state: 'running', shell: 'continue',
|
||||
workingDirectory: '/project', size: { cols: 80, rows: 24 }, lastSequence: 0, exit: null, error: null
|
||||
}
|
||||
const api = {
|
||||
openRuntimeNativeClient: vi.fn<RuntimeNativeClientApi['openRuntimeNativeClient']>(),
|
||||
getRuntimeNativeClient: vi.fn<RuntimeNativeClientApi['getRuntimeNativeClient']>(),
|
||||
stopRuntimeNativeClient: vi.fn<RuntimeNativeClientApi['stopRuntimeNativeClient']>()
|
||||
}
|
||||
const props = {
|
||||
browser: false, contextKey: 'first', conversationId: 'conversation',
|
||||
prepareConversation: vi.fn(async () => 'conversation'), onTerminal: vi.fn(), notify: vi.fn()
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetAllMocks()
|
||||
await changeUiLocale('en-US')
|
||||
props.prepareConversation.mockResolvedValue('conversation')
|
||||
api.getRuntimeNativeClient.mockResolvedValue(null)
|
||||
api.stopRuntimeNativeClient.mockResolvedValue()
|
||||
vi.stubGlobal('goodbuddy', api)
|
||||
})
|
||||
afterEach(() => { cleanup(); vi.unstubAllGlobals() })
|
||||
|
||||
describe('RuntimeNativeClientActions', () => {
|
||||
it('saves the conversation before launch and prevents duplicate clicks while opening', async () => {
|
||||
let finish!: (result: Awaited<ReturnType<RuntimeNativeClientApi['openRuntimeNativeClient']>>) => void
|
||||
api.openRuntimeNativeClient.mockImplementation(() => new Promise(resolve => { finish = resolve }))
|
||||
render(<RuntimeNativeClientActions {...props} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open in terminal' }))
|
||||
await waitFor(() => expect(api.openRuntimeNativeClient).toHaveBeenCalledWith({ conversationId: 'conversation' }))
|
||||
expect(props.prepareConversation.mock.invocationCallOrder[0]).toBeLessThan(api.openRuntimeNativeClient.mock.invocationCallOrder[0]!)
|
||||
const pending = screen.getByRole('button', { name: 'Opening...' })
|
||||
expect(pending).toBeDisabled()
|
||||
fireEvent.click(pending)
|
||||
expect(api.openRuntimeNativeClient).toHaveBeenCalledTimes(1)
|
||||
await act(async () => finish({ kind: 'terminal', terminal }))
|
||||
expect(props.onTerminal).toHaveBeenCalledWith(terminal)
|
||||
expect(screen.getByRole('button', { name: 'Open in terminal' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('refreshes the matching DS service on selection changes and stops only that service', async () => {
|
||||
api.getRuntimeNativeClient.mockResolvedValueOnce({ serviceId: 'first-service' }).mockResolvedValue({ serviceId: 'second-service' })
|
||||
const { rerender } = render(<RuntimeNativeClientActions {...props} browser />)
|
||||
await screen.findByRole('button', { name: 'Reopen' })
|
||||
rerender(<RuntimeNativeClientActions {...props} browser contextKey="second" />)
|
||||
expect(screen.queryByRole('button', { name: 'Stop this service' })).not.toBeInTheDocument()
|
||||
await screen.findByRole('button', { name: 'Reopen' })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Stop this service' }))
|
||||
expect(api.stopRuntimeNativeClient).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Stop service' }))
|
||||
await waitFor(() => {
|
||||
expect(api.stopRuntimeNativeClient).toHaveBeenCalledWith({ serviceId: 'second-service' })
|
||||
expect(screen.getByRole('button', { name: 'Open in browser' })).toBeEnabled()
|
||||
expect(screen.queryByRole('button', { name: 'Stop this service' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('reopens DS through Main and recovers the stop control after browser-open failure', async () => {
|
||||
api.openRuntimeNativeClient.mockRejectedValueOnce(new Error('Browser could not open'))
|
||||
render(<RuntimeNativeClientActions {...props} browser />)
|
||||
await waitFor(() => expect(api.getRuntimeNativeClient).toHaveBeenCalledTimes(1))
|
||||
api.getRuntimeNativeClient.mockResolvedValue({ serviceId: 'ready-service' })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open in browser' }))
|
||||
await waitFor(() => {
|
||||
expect(props.notify).toHaveBeenCalledWith({ tone: 'error', message: 'Browser could not open' })
|
||||
expect(screen.getByRole('button', { name: 'Reopen' })).toBeEnabled()
|
||||
})
|
||||
api.openRuntimeNativeClient.mockResolvedValue({ kind: 'browser', serviceId: 'ready-service' })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reopen' }))
|
||||
await waitFor(() => expect(api.openRuntimeNativeClient).toHaveBeenCalledTimes(2))
|
||||
expect(props.onTerminal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not display a service returned for an old selection', async () => {
|
||||
let finish!: (result: { serviceId: string }) => void
|
||||
api.getRuntimeNativeClient.mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
|
||||
const { rerender } = render(<RuntimeNativeClientActions {...props} browser />)
|
||||
await waitFor(() => expect(api.getRuntimeNativeClient).toHaveBeenCalledTimes(1))
|
||||
rerender(<RuntimeNativeClientActions {...props} browser contextKey="second" />)
|
||||
await waitFor(() => expect(api.getRuntimeNativeClient).toHaveBeenCalledTimes(2))
|
||||
await act(async () => finish({ serviceId: 'old-service' }))
|
||||
expect(screen.queryByRole('button', { name: 'Stop this service' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('retains controls and reports stop errors for retry', async () => {
|
||||
api.getRuntimeNativeClient.mockResolvedValue({ serviceId: 'service' })
|
||||
api.stopRuntimeNativeClient.mockRejectedValueOnce(new Error('Stop failed'))
|
||||
render(<RuntimeNativeClientActions {...props} browser />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Stop this service' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Stop service' }))
|
||||
await waitFor(() => {
|
||||
expect(props.notify).toHaveBeenCalledWith({ tone: 'error', message: 'Stop failed' })
|
||||
expect(screen.getByRole('button', { name: 'Stop service' })).toBeEnabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Globe, LoaderCircle, TerminalSquare } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { TerminalSnapshot } from '../../shared/terminal-contracts'
|
||||
|
||||
export type RuntimeNativeClientApi = {
|
||||
openRuntimeNativeClient: (input: { conversationId: string }) => Promise<
|
||||
{ kind: 'terminal'; terminal: TerminalSnapshot } | { kind: 'browser'; serviceId: string }
|
||||
>
|
||||
getRuntimeNativeClient: (input: { conversationId: string }) => Promise<{ serviceId: string } | null>
|
||||
stopRuntimeNativeClient: (input: { serviceId: string }) => Promise<void>
|
||||
}
|
||||
|
||||
type Props = {
|
||||
browser: boolean
|
||||
contextKey: string
|
||||
conversationId?: string
|
||||
prepareConversation: () => Promise<string>
|
||||
onTerminal: (terminal: TerminalSnapshot) => void
|
||||
notify: (notification: { tone: 'error' | 'success'; message: string }) => void
|
||||
}
|
||||
|
||||
export function RuntimeNativeClientActions({ browser, contextKey, conversationId, prepareConversation, onTerminal, notify }: Props): React.JSX.Element {
|
||||
const { t } = useTranslation('app')
|
||||
const [pending, setPending] = useState<'open' | 'stop'>()
|
||||
const busy = useRef(false)
|
||||
const lookupGeneration = useRef(0)
|
||||
const [service, setService] = useState<{ key: string; serviceId: string }>()
|
||||
const [confirmStop, setConfirmStop] = useState<string>()
|
||||
const current = useRef({ contextKey, prepareConversation, notify })
|
||||
useEffect(() => { current.current = { contextKey, prepareConversation, notify } })
|
||||
const api = window.goodbuddy as typeof window.goodbuddy & RuntimeNativeClientApi
|
||||
const serviceId = service?.key === contextKey ? service.serviceId : undefined
|
||||
|
||||
useEffect(() => {
|
||||
if (!browser || !conversationId) return
|
||||
let cancelled = false
|
||||
const refresh = async (): Promise<void> => {
|
||||
const generation = ++lookupGeneration.current
|
||||
try {
|
||||
const id = await current.current.prepareConversation()
|
||||
if (cancelled) return
|
||||
const result = await api.getRuntimeNativeClient({ conversationId: id })
|
||||
if (!cancelled && !busy.current && lookupGeneration.current === generation) setService(result ? { key: contextKey, ...result } : undefined)
|
||||
} catch (error) {
|
||||
if (!cancelled) current.current.notify({ tone: 'error', message: error instanceof Error ? error.message : t('composer.nativeClient.failed') })
|
||||
}
|
||||
}
|
||||
void refresh()
|
||||
window.addEventListener('focus', refresh)
|
||||
return () => { cancelled = true; window.removeEventListener('focus', refresh) }
|
||||
}, [api, browser, contextKey, conversationId, t])
|
||||
|
||||
const run = async (stopServiceId?: string): Promise<void> => {
|
||||
if (busy.current) return
|
||||
busy.current = true
|
||||
lookupGeneration.current += 1
|
||||
setPending(stopServiceId ? 'stop' : 'open')
|
||||
try {
|
||||
if (stopServiceId) {
|
||||
await api.stopRuntimeNativeClient({ serviceId: stopServiceId })
|
||||
setService(previous => previous?.serviceId === stopServiceId ? undefined : previous)
|
||||
setConfirmStop(undefined)
|
||||
notify({ tone: 'success', message: t('composer.nativeClient.stopped') })
|
||||
} else {
|
||||
const id = await prepareConversation()
|
||||
const result = await api.openRuntimeNativeClient({ conversationId: id })
|
||||
if (result.kind === 'terminal') onTerminal(result.terminal)
|
||||
else if (current.current.contextKey === contextKey) setService({ key: contextKey, serviceId: result.serviceId })
|
||||
}
|
||||
} catch (error) {
|
||||
notify({ tone: 'error', message: error instanceof Error ? error.message : t('composer.nativeClient.failed') })
|
||||
// A browser-open failure can leave a ready service that the user can stop or reopen.
|
||||
if (browser && !stopServiceId && current.current.contextKey === contextKey) {
|
||||
try {
|
||||
const id = await prepareConversation()
|
||||
const result = await api.getRuntimeNativeClient({ conversationId: id })
|
||||
if (current.current.contextKey === contextKey) setService(result ? { key: contextKey, ...result } : undefined)
|
||||
} catch { /* Keep the original operation error. */ }
|
||||
}
|
||||
} finally {
|
||||
busy.current = false
|
||||
setPending(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
return <>
|
||||
<button className="composer-context-compact" type="button" disabled={!!pending} aria-busy={!!pending} onClick={() => void run()}>
|
||||
{pending ? <LoaderCircle aria-hidden="true" className="context-chip__spinner" size={13} /> : browser ? <Globe aria-hidden="true" size={13} /> : <TerminalSquare aria-hidden="true" size={13} />}
|
||||
{t(pending === 'stop' ? 'composer.nativeClient.stopping' : pending ? 'composer.nativeClient.opening' : serviceId ? 'composer.nativeClient.reopen' : browser ? 'composer.nativeClient.browser' : 'composer.nativeClient.terminal')}
|
||||
</button>
|
||||
{browser && serviceId && <button className="composer-context-compact danger-ghost" type="button" disabled={!!pending} onClick={() => setConfirmStop(serviceId)}>{t('composer.nativeClient.stop')}</button>}
|
||||
{browser && <span className="composer-native-client-note">{t('composer.nativeClient.description')}</span>}
|
||||
{serviceId && confirmStop === serviceId && <span className="composer-native-client-confirm" role="group" aria-label={t('composer.nativeClient.stop')}>
|
||||
<span>{t('composer.nativeClient.stopWarning')}</span>
|
||||
<button type="button" className="secondary-button" disabled={!!pending} onClick={() => setConfirmStop(undefined)}>{t('composer.nativeClient.cancel')}</button>
|
||||
<button type="button" className="danger-ghost" disabled={!!pending} onClick={() => void run(serviceId)}>{t('composer.nativeClient.confirmStop')}</button>
|
||||
</span>}
|
||||
</>
|
||||
}
|
||||
@@ -202,7 +202,7 @@ const detectAgentRuntimes = vi.fn<
|
||||
deepseekHarness: {
|
||||
available: true,
|
||||
path: 'bundled://deepseek-harness',
|
||||
version: '0.1.2-rc.1',
|
||||
version: '0.1.7-rc.2',
|
||||
source: 'bundled',
|
||||
detail: '内置 Harness Adapter 已就绪'
|
||||
}
|
||||
@@ -2974,11 +2974,11 @@ describe('SettingsPanel runtime files', () => {
|
||||
)
|
||||
).toHaveClass('runtime-overview__path')
|
||||
expect(
|
||||
within(harnessOverview).getByText('0.1.2-rc.1')
|
||||
within(harnessOverview).getByText('0.1.7-rc.2')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(harnessOverview).getByText(
|
||||
'内置 DeepSeek Harness 0.1.2-rc.1 已就绪'
|
||||
'内置 DeepSeek Harness 0.1.7-rc.2 已就绪'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
|
||||
@@ -344,7 +344,7 @@ describe('WorkspacePrimitives', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('floats Runtime context compaction without shifting the composer', () => {
|
||||
it('wraps native client and compaction actions without overlapping composer metadata', () => {
|
||||
expect(stylesheet).toMatch(
|
||||
/\.composer-wrap\s*\{[^}]*var\(--space-2\);[^}]*background:\s*var\(--surface-raised\);/u
|
||||
)
|
||||
@@ -352,10 +352,10 @@ describe('WorkspacePrimitives', () => {
|
||||
/\.composer-meta\s*\{[^}]*position:\s*relative;[^}]*min-height:\s*26px;[^}]*margin:\s*var\(--space-1\) 0 0;/u
|
||||
)
|
||||
expect(stylesheet).toMatch(
|
||||
/\.composer-meta--with-context-compact\s*\{[^}]*padding-left:\s*calc\(/u
|
||||
/\.composer-meta__actions\s*\{[^}]*display:\s*flex;[^}]*flex-wrap:\s*wrap;/u
|
||||
)
|
||||
expect(stylesheet).toMatch(
|
||||
/\.composer-context-compact\s*\{[^}]*position:\s*absolute;[^}]*bottom:\s*0;[^}]*left:\s*0;/u
|
||||
expect(stylesheet).not.toMatch(
|
||||
/\.composer-context-compact\s*\{[^}]*position:\s*absolute;/u
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -570,6 +570,13 @@ export const app = {
|
||||
nothingToCompact: 'There is no earlier conversation history to compact',
|
||||
compactFailed: 'Context compaction failed'
|
||||
},
|
||||
nativeClient: {
|
||||
terminal: 'Open in terminal', browser: 'Open in browser', opening: 'Opening...', stopping: 'Stopping...',
|
||||
reopen: 'Reopen', stop: 'Stop this service', confirmStop: 'Stop service', cancel: 'Cancel',
|
||||
description: 'Independent session. Closing the page keeps the service running; stop it here or quit GoodBuddy.',
|
||||
stopWarning: 'Stopping disconnects this service and interrupts its requests.',
|
||||
stopped: 'Native client service stopped', failed: 'Could not open the native client'
|
||||
},
|
||||
experts: {
|
||||
general: 'General assistant',
|
||||
generalDescription: 'Default single assistant',
|
||||
|
||||
@@ -546,6 +546,13 @@ export const app = {
|
||||
nothingToCompact: '当前没有可压缩的较早对话历史',
|
||||
compactFailed: '上下文压缩失败'
|
||||
},
|
||||
nativeClient: {
|
||||
terminal: '在终端中打开', browser: '在浏览器中打开', opening: '正在打开…', stopping: '正在停止…',
|
||||
reopen: '重新打开', stop: '停止此服务', confirmStop: '停止服务', cancel: '取消',
|
||||
description: '独立会话;关闭网页后服务仍运行,可在此停止,退出 GoodBuddy 时停止。',
|
||||
stopWarning: '停止后此服务的网页会断开,进行中的请求将中断。',
|
||||
stopped: '原生客户端服务已停止', failed: '无法打开原生客户端'
|
||||
},
|
||||
experts: {
|
||||
general: '通用助手',
|
||||
generalDescription: '默认单助手',
|
||||
|
||||
@@ -7911,10 +7911,26 @@ button > svg {
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.composer-meta--with-context-compact {
|
||||
padding-left: calc(
|
||||
var(--composer-context-compact-width) + var(--space-3)
|
||||
);
|
||||
.composer-meta__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-right: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.composer-native-client-note,
|
||||
.composer-native-client-confirm {
|
||||
flex-basis: 100%;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.composer-native-client-confirm {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.composer-meta__error {
|
||||
@@ -7931,11 +7947,8 @@ button > svg {
|
||||
}
|
||||
|
||||
.composer-context-compact {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
display: inline-flex;
|
||||
width: var(--composer-context-compact-width);
|
||||
min-width: var(--composer-context-compact-width);
|
||||
min-height: 26px;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border: 1px solid var(--border-control);
|
||||
|
||||
@@ -1766,6 +1766,9 @@ export type KnowledgeSearchReference = {
|
||||
}
|
||||
|
||||
export type DesktopApi = {
|
||||
openRuntimeNativeClient: (input: import('./runtime-native-client-contracts').RuntimeNativeClientInput) => Promise<import('./runtime-native-client-contracts').RuntimeNativeClientResult>
|
||||
getRuntimeNativeClient: (input: import('./runtime-native-client-contracts').RuntimeNativeClientInput) => Promise<import('./runtime-native-client-contracts').RuntimeNativeClientService | null>
|
||||
stopRuntimeNativeClient: (input: import('./runtime-native-client-contracts').RuntimeNativeClientService) => Promise<void>
|
||||
localInference: import('./local-inference-contracts').LocalInferenceApi
|
||||
storageUpgrade: {
|
||||
getProgress: () => Promise<import('./assistant-storage-contracts').AssistantStorageProgress>
|
||||
|
||||
@@ -54,6 +54,9 @@ export const ipcChannels = {
|
||||
browserStop: 'browser:stop',
|
||||
browserState: 'browser:state',
|
||||
terminalCreate: 'terminal:create',
|
||||
runtimeNativeClientOpen: 'runtime-native-client:open',
|
||||
runtimeNativeClientGet: 'runtime-native-client:get',
|
||||
runtimeNativeClientStop: 'runtime-native-client:stop',
|
||||
terminalWrite: 'terminal:write',
|
||||
terminalResize: 'terminal:resize',
|
||||
terminalClose: 'terminal:close',
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { z } from 'zod'
|
||||
import { assistantIdSchema } from './assistant-contracts'
|
||||
import { terminalSnapshotSchema } from './terminal-contracts'
|
||||
|
||||
export const runtimeNativeClientInputSchema = z.object({ conversationId: assistantIdSchema }).strict()
|
||||
export const runtimeNativeClientServiceSchema = z.object({ serviceId: z.string().min(1).max(128) }).strict()
|
||||
export const runtimeNativeClientResultSchema = z.discriminatedUnion('kind', [
|
||||
z.object({ kind: z.literal('terminal'), terminal: terminalSnapshotSchema }).strict(),
|
||||
runtimeNativeClientServiceSchema.extend({ kind: z.literal('browser') })
|
||||
])
|
||||
export type RuntimeNativeClientInput = z.infer<typeof runtimeNativeClientInputSchema>
|
||||
export type RuntimeNativeClientService = z.infer<typeof runtimeNativeClientServiceSchema>
|
||||
export type RuntimeNativeClientResult = z.infer<typeof runtimeNativeClientResultSchema>
|
||||
Reference in New Issue
Block a user