refactor: extract model settings IPC handlers
This commit is contained in:
@@ -125,3 +125,25 @@ Main 的 `toPublicSettings` 复用 `resolveDeepSeekHarnessModelProfile`,只在
|
|||||||
`source: 'profile'` 描述来源分支,不代表凭据来源:仅环境密钥或旧环境变量可能仍影响该
|
`source: 'profile'` 描述来源分支,不代表凭据来源:仅环境密钥或旧环境变量可能仍影响该
|
||||||
GoodBuddy 连接,但不会被误报为完整管理员预置。连接解析不代替真实模型请求或可用性测试。
|
GoodBuddy 连接,但不会被误报为完整管理员预置。连接解析不代替真实模型请求或可用性测试。
|
||||||
UI 的未保存兼容候选只作草稿预览,保存后继续以 Main 返回的投影为准。
|
UI 的未保存兼容候选只作草稿预览,保存后继续以 Main 返回的投影为准。
|
||||||
|
|
||||||
|
## 10. Settings IPC Ownership
|
||||||
|
|
||||||
|
[model-settings-ipc.ts](../../../src/main/model-settings-ipc.ts) registers the
|
||||||
|
11 model/runtime settings handlers: public settings, customization reads and
|
||||||
|
updates, native inventory, settings updates, workspace/file selection, runtime
|
||||||
|
detection, config actions, and model/runtime connection tests. It owns sender
|
||||||
|
checks, shared schema parsing, config-file metadata, and activation rollback.
|
||||||
|
|
||||||
|
[ipc.ts](../../../src/main/ipc.ts) calls this registrar at the original position
|
||||||
|
and retains tracked registration, the shared settings-update queue, runtime
|
||||||
|
activation, conversation-selection repair, and project execution-space resolution.
|
||||||
|
These operations are injected as narrow callbacks. Runtime activation still
|
||||||
|
cancels affected expert-team requests; failed activation restores the previous
|
||||||
|
settings and reactivates them before the next queued update can run. Cleanup waits
|
||||||
|
for the tracked handler promises. Usage recording, runtime refresh subscriptions,
|
||||||
|
and runtime/Agent lifecycle ownership remain in the composition root.
|
||||||
|
|
||||||
|
The integration tests in [ipc.test.ts](../../../src/main/ipc.test.ts) exercise the
|
||||||
|
production registrar through `registerIpcHandlers`, including registration order,
|
||||||
|
trusted senders, serialized updates, rollback failures, cleanup waiting for reload,
|
||||||
|
native inventory resolution, and connection/config actions.
|
||||||
|
|||||||
@@ -4945,6 +4945,35 @@ describe('registerIpcHandlers Runtime customization', () => {
|
|||||||
senderFrame: webContents.mainFrame
|
senderFrame: webContents.mainFrame
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const settingsChannels = [
|
||||||
|
ipcChannels.runtimeSettingsGet,
|
||||||
|
ipcChannels.runtimeCustomizationGet,
|
||||||
|
ipcChannels.runtimeCustomizationUpdate,
|
||||||
|
ipcChannels.runtimeNativeSnapshot,
|
||||||
|
ipcChannels.runtimeSettingsUpdate,
|
||||||
|
ipcChannels.runtimeSettingsSelectWorkspace,
|
||||||
|
ipcChannels.runtimeSettingsDetect,
|
||||||
|
ipcChannels.runtimeSettingsSelectFile,
|
||||||
|
ipcChannels.runtimeSettingsOpenConfig,
|
||||||
|
ipcChannels.runtimeSettingsTestModel,
|
||||||
|
ipcChannels.runtimeSettingsTest
|
||||||
|
]
|
||||||
|
const registeredChannels = [...electronMocks.handlers.keys()]
|
||||||
|
const settingsStart = registeredChannels.indexOf(settingsChannels[0]!)
|
||||||
|
expect(registeredChannels.slice(settingsStart - 1, settingsStart + 12)).toEqual([
|
||||||
|
ipcChannels.agentCompactConversation,
|
||||||
|
...settingsChannels,
|
||||||
|
ipcChannels.sshHostsGet
|
||||||
|
])
|
||||||
|
for (const channel of settingsChannels) {
|
||||||
|
await expect(Promise.resolve().then(() =>
|
||||||
|
electronMocks.handlers.get(channel)?.({ ...event, sender: {} }, {})
|
||||||
|
)).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
|
||||||
|
}
|
||||||
|
expect(settingsStore.getRuntimeCustomization).not.toHaveBeenCalled()
|
||||||
|
expect(settingsStore.updateRuntimeCustomization).not.toHaveBeenCalled()
|
||||||
|
expect(selectedRuntimes.getNativeSnapshot).not.toHaveBeenCalled()
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
electronMocks.handlers.get(
|
electronMocks.handlers.get(
|
||||||
ipcChannels.runtimeCustomizationGet
|
ipcChannels.runtimeCustomizationGet
|
||||||
@@ -5005,6 +5034,22 @@ describe('registerIpcHandlers Runtime customization', () => {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
electronMocks.handlers.get(ipcChannels.runtimeNativeSnapshot)?.(event, {
|
||||||
|
provider: 'opencode'
|
||||||
|
})
|
||||||
|
).resolves.toEqual(snapshot)
|
||||||
|
expect(selectedRuntimes.getNativeSnapshot).toHaveBeenLastCalledWith(
|
||||||
|
{ provider: 'opencode' },
|
||||||
|
expect.objectContaining({ kind: 'local', rootPath: 'C:\\DefaultWorkspace' })
|
||||||
|
)
|
||||||
|
await expect(
|
||||||
|
electronMocks.handlers.get(ipcChannels.runtimeNativeSnapshot)?.(event, {
|
||||||
|
provider: 'opencode', projectId: 'invalid'
|
||||||
|
})
|
||||||
|
).rejects.toThrow()
|
||||||
|
expect(selectedRuntimes.getNativeSnapshot).toHaveBeenCalledTimes(2)
|
||||||
|
|
||||||
const compactInput = {
|
const compactInput = {
|
||||||
requestId,
|
requestId,
|
||||||
conversationId,
|
conversationId,
|
||||||
|
|||||||
+21
-352
@@ -1,4 +1,5 @@
|
|||||||
import { registerKnowledgeIpcHandlers } from './knowledge/knowledge-ipc'
|
import { registerKnowledgeIpcHandlers } from './knowledge/knowledge-ipc'
|
||||||
|
import { registerModelSettingsIpcHandlers } from './model-settings-ipc'
|
||||||
import { knowledgeReferenceKey, toKnowledgeReference } from '../shared/knowledge-reference'
|
import { knowledgeReferenceKey, toKnowledgeReference } from '../shared/knowledge-reference'
|
||||||
import { localInferenceService } from './local-inference-service'
|
import { localInferenceService } from './local-inference-service'
|
||||||
import { inferenceActionSchema, inferenceCancelSchema } from '../shared/local-inference-contracts'
|
import { inferenceActionSchema, inferenceCancelSchema } from '../shared/local-inference-contracts'
|
||||||
@@ -13,14 +14,12 @@ import {
|
|||||||
type IpcMainInvokeEvent
|
type IpcMainInvokeEvent
|
||||||
} from 'electron'
|
} from 'electron'
|
||||||
import {
|
import {
|
||||||
mkdir,
|
|
||||||
readFile,
|
readFile,
|
||||||
realpath,
|
realpath,
|
||||||
stat
|
stat
|
||||||
} from 'node:fs/promises'
|
} from 'node:fs/promises'
|
||||||
import { randomUUID } from 'node:crypto'
|
import { randomUUID } from 'node:crypto'
|
||||||
import { homedir } from 'node:os'
|
import { basename, extname, join } from 'node:path'
|
||||||
import { basename, extname, isAbsolute, join } from 'node:path'
|
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { documentResourceInputSchema } from '../shared/document-result-contracts'
|
import { documentResourceInputSchema } from '../shared/document-result-contracts'
|
||||||
import { maximumAttachmentsPerMessage } from '../shared/attachment-limits'
|
import { maximumAttachmentsPerMessage } from '../shared/attachment-limits'
|
||||||
@@ -51,21 +50,13 @@ import {
|
|||||||
conversationQueueUserInputSchema,
|
conversationQueueUserInputSchema,
|
||||||
defaultRuntimeSettings,
|
defaultRuntimeSettings,
|
||||||
isAgentRuntimeModelProtocol,
|
isAgentRuntimeModelProtocol,
|
||||||
modelProfileIdSchema,
|
|
||||||
pastedImageInputSchema,
|
pastedImageInputSchema,
|
||||||
runtimeConversationCompactInputSchema,
|
runtimeConversationCompactInputSchema,
|
||||||
runtimeConversationCompactResultSchema,
|
runtimeConversationCompactResultSchema,
|
||||||
runtimeConfigActionInputSchema,
|
|
||||||
runtimeCustomizationSettingsSchema,
|
|
||||||
runtimeFileSelectionKindSchema,
|
|
||||||
runtimeNativeSnapshotInputSchema,
|
|
||||||
runtimeNativeSnapshotSchema,
|
|
||||||
runtimeSettingsInputSchema,
|
|
||||||
windowCaptureRequestSchema,
|
windowCaptureRequestSchema,
|
||||||
workspaceDirectoryRequestSchema,
|
workspaceDirectoryRequestSchema,
|
||||||
workspaceFileRequestSchema,
|
workspaceFileRequestSchema,
|
||||||
workspaceOpenPathRequestSchema,
|
workspaceOpenPathRequestSchema,
|
||||||
type AgentRuntimeDetection,
|
|
||||||
type AgentEvent,
|
type AgentEvent,
|
||||||
type AgentRequest,
|
type AgentRequest,
|
||||||
type AppInfo,
|
type AppInfo,
|
||||||
@@ -73,8 +64,7 @@ import {
|
|||||||
type BrowserTabId,
|
type BrowserTabId,
|
||||||
type ConversationQueueDispatch,
|
type ConversationQueueDispatch,
|
||||||
type ConversationQueueUserInput,
|
type ConversationQueueUserInput,
|
||||||
type KnowledgeSearchReference,
|
type KnowledgeSearchReference
|
||||||
type RuntimeSettings
|
|
||||||
} from '../shared/contracts'
|
} from '../shared/contracts'
|
||||||
import { stripKnowledgeHighlightTags } from '../shared/knowledge-text'
|
import { stripKnowledgeHighlightTags } from '../shared/knowledge-text'
|
||||||
import type { KnowledgeRetrievalResponse } from '../shared/knowledge-contracts'
|
import type { KnowledgeRetrievalResponse } from '../shared/knowledge-contracts'
|
||||||
@@ -172,7 +162,6 @@ import type {
|
|||||||
RuntimeGeneratedImageEvent,
|
RuntimeGeneratedImageEvent,
|
||||||
RuntimeModelUsageEvent
|
RuntimeModelUsageEvent
|
||||||
} from './agent/runtime'
|
} from './agent/runtime'
|
||||||
import { detectAgentRuntimes } from './agent/runtime-discovery'
|
|
||||||
import {
|
import {
|
||||||
createModelProfileRuntime
|
createModelProfileRuntime
|
||||||
} from './agent/create-runtime'
|
} from './agent/create-runtime'
|
||||||
@@ -186,11 +175,7 @@ import {
|
|||||||
RemotePromptCancelledError,
|
RemotePromptCancelledError,
|
||||||
RemotePromptRecoveryUnavailableError
|
RemotePromptRecoveryUnavailableError
|
||||||
} from './agent/acp-remote-runtime'
|
} from './agent/acp-remote-runtime'
|
||||||
import {
|
import type { BundledRuntimePaths } from './agent/bundled-runtimes'
|
||||||
bundledContinueVersion,
|
|
||||||
bundledDeepSeekHarnessVersion,
|
|
||||||
type BundledRuntimePaths
|
|
||||||
} from './agent/bundled-runtimes'
|
|
||||||
import type { SelectedRuntimeResolver } from './agent/selected-runtime-manager'
|
import type { SelectedRuntimeResolver } from './agent/selected-runtime-manager'
|
||||||
import {
|
import {
|
||||||
type MagicNotesCapabilityAccess,
|
type MagicNotesCapabilityAccess,
|
||||||
@@ -335,23 +320,6 @@ import {
|
|||||||
const requestIdSchema = z.string().uuid()
|
const requestIdSchema = z.string().uuid()
|
||||||
const BACKGROUND_QUESTION_REJECTION_TIMEOUT_MS = 1_000
|
const BACKGROUND_QUESTION_REJECTION_TIMEOUT_MS = 1_000
|
||||||
const DURABLE_AGENT_EVENT_FLUSH_INTERVAL_MS = 250
|
const DURABLE_AGENT_EVENT_FLUSH_INTERVAL_MS = 250
|
||||||
const runtimeConfigFileMetadata = {
|
|
||||||
opencode: {
|
|
||||||
filterName: 'OpenCode 配置',
|
|
||||||
filterExtensions: ['json', 'jsonc'],
|
|
||||||
allowedExtensions: new Set<string>(['.json', '.jsonc'])
|
|
||||||
},
|
|
||||||
continue: {
|
|
||||||
filterName: 'Continue 配置',
|
|
||||||
filterExtensions: ['yaml', 'yml', 'json', 'jsonc'],
|
|
||||||
allowedExtensions: new Set<string>([
|
|
||||||
'.yaml',
|
|
||||||
'.yml',
|
|
||||||
'.json',
|
|
||||||
'.jsonc'
|
|
||||||
])
|
|
||||||
}
|
|
||||||
} as const
|
|
||||||
const channelSettingsTestRequestSchema = z.discriminatedUnion('channel', [
|
const channelSettingsTestRequestSchema = z.discriminatedUnion('channel', [
|
||||||
z
|
z
|
||||||
.object({
|
.object({
|
||||||
@@ -367,20 +335,6 @@ const channelSettingsTestRequestSchema = z.discriminatedUnion('channel', [
|
|||||||
.strict()
|
.strict()
|
||||||
])
|
])
|
||||||
|
|
||||||
function getRuntimeConfigDirectory(
|
|
||||||
runtime: 'opencode' | 'continue'
|
|
||||||
): string {
|
|
||||||
if (runtime === 'continue') {
|
|
||||||
return join(homedir(), '.continue')
|
|
||||||
}
|
|
||||||
const xdgConfigHome = process.env.XDG_CONFIG_HOME?.trim()
|
|
||||||
const configHome =
|
|
||||||
xdgConfigHome && isAbsolute(xdgConfigHome)
|
|
||||||
? xdgConfigHome
|
|
||||||
: join(homedir(), '.config')
|
|
||||||
return join(configHome, 'opencode')
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAgentRuntime(runtime: AgentRuntime): boolean {
|
function isAgentRuntime(runtime: AgentRuntime): boolean {
|
||||||
return (
|
return (
|
||||||
runtime.runtimeId === 'opencode' ||
|
runtime.runtimeId === 'opencode' ||
|
||||||
@@ -650,31 +604,6 @@ function stripRemoteSemanticProvenance(
|
|||||||
return publicEvent
|
return publicEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
async function activateOrRollback<T>(input: {
|
|
||||||
previous: T
|
|
||||||
persistCandidate(): Promise<T>
|
|
||||||
activate(): Promise<void>
|
|
||||||
persistPrevious(previous: T): Promise<unknown>
|
|
||||||
}): Promise<T> {
|
|
||||||
const saved = await input.persistCandidate()
|
|
||||||
try {
|
|
||||||
await input.activate()
|
|
||||||
return saved
|
|
||||||
} catch (activationError) {
|
|
||||||
try {
|
|
||||||
await input.persistPrevious(input.previous)
|
|
||||||
await input.activate()
|
|
||||||
} catch (rollbackError) {
|
|
||||||
throw new AggregateError(
|
|
||||||
[activationError, rollbackError],
|
|
||||||
'Runtime 配置激活失败,且回滚未能完成',
|
|
||||||
{ cause: rollbackError }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
throw activationError
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createPromiseTracker(): {
|
function createPromiseTracker(): {
|
||||||
track<T>(operation: Promise<T>): Promise<T>
|
track<T>(operation: Promise<T>): Promise<T>
|
||||||
drain(): Promise<void>
|
drain(): Promise<void>
|
||||||
@@ -4955,292 +4884,32 @@ export function registerIpcHandlers(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
registerHandler(
|
registerModelSettingsIpcHandlers(registerHandler, window, {
|
||||||
ipcChannels.runtimeSettingsGet,
|
settingsStore,
|
||||||
(event): Promise<RuntimeSettings> => {
|
runtime,
|
||||||
assertTrustedSender(event, window)
|
selectedRuntimes,
|
||||||
return settingsStore.getPublicSettings()
|
bundledRuntimePaths,
|
||||||
}
|
enqueueRuntimeSettingsUpdate,
|
||||||
)
|
onRuntimeSettingsChanged,
|
||||||
|
repairRuntimeSelections: (savedSettings) => {
|
||||||
registerHandler(
|
channelSettingsStore?.reportRuntimeSelectionRepairs(
|
||||||
ipcChannels.runtimeCustomizationGet,
|
assistantDatabase.repairConversationRuntimeSelections(savedSettings)
|
||||||
(event) => {
|
)
|
||||||
assertTrustedSender(event, window)
|
},
|
||||||
return settingsStore.getRuntimeCustomization()
|
resolveSnapshotExecutionSpace: async (projectId) => {
|
||||||
}
|
const project = projectId
|
||||||
)
|
? assistantDatabase.getProject(projectId)
|
||||||
|
|
||||||
registerHandler(
|
|
||||||
ipcChannels.runtimeCustomizationUpdate,
|
|
||||||
async (event, input: unknown) => {
|
|
||||||
assertTrustedSender(event, window)
|
|
||||||
const settings =
|
|
||||||
runtimeCustomizationSettingsSchema.parse(input)
|
|
||||||
return enqueueRuntimeSettingsUpdate(async () => {
|
|
||||||
const previous =
|
|
||||||
await settingsStore.getRuntimeCustomization()
|
|
||||||
return activateOrRollback({
|
|
||||||
previous,
|
|
||||||
persistCandidate: async () => {
|
|
||||||
const saved =
|
|
||||||
await settingsStore.updateRuntimeCustomization(settings)
|
|
||||||
return saved
|
|
||||||
},
|
|
||||||
activate: onRuntimeSettingsChanged,
|
|
||||||
persistPrevious: (previousSettings) =>
|
|
||||||
settingsStore.updateRuntimeCustomization(previousSettings)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
registerHandler(
|
|
||||||
ipcChannels.runtimeNativeSnapshot,
|
|
||||||
async (event, input: unknown) => {
|
|
||||||
assertTrustedSender(event, window)
|
|
||||||
if (!selectedRuntimes) {
|
|
||||||
throw new Error('Runtime 管理器不可用')
|
|
||||||
}
|
|
||||||
const request = runtimeNativeSnapshotInputSchema.parse(input)
|
|
||||||
const selection: AgentRuntimeSelection = {
|
|
||||||
provider: request.provider,
|
|
||||||
...(request.profileId
|
|
||||||
? { profileId: request.profileId }
|
|
||||||
: {})
|
|
||||||
}
|
|
||||||
const project = request.projectId
|
|
||||||
? assistantDatabase.getProject(request.projectId)
|
|
||||||
: undefined
|
: undefined
|
||||||
if (project?.executionSpace?.kind === 'ssh') {
|
if (project?.executionSpace?.kind === 'ssh') {
|
||||||
await requireRemoteProjectsEnabled()
|
await requireRemoteProjectsEnabled()
|
||||||
}
|
}
|
||||||
const executionSpace = project
|
return project
|
||||||
? spaceResolver.resolveProject(project)
|
? spaceResolver.resolveProject(project)
|
||||||
: spaceResolver.resolveLocal(
|
: spaceResolver.resolveLocal(
|
||||||
(await settingsStore.getResolvedSettings()).workspacePath
|
(await settingsStore.getResolvedSettings()).workspacePath
|
||||||
)
|
)
|
||||||
return runtimeNativeSnapshotSchema.parse(
|
|
||||||
await selectedRuntimes.getNativeSnapshot(
|
|
||||||
selection,
|
|
||||||
executionSpace
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
)
|
})
|
||||||
|
|
||||||
registerHandler(
|
|
||||||
ipcChannels.runtimeSettingsUpdate,
|
|
||||||
async (event, input: unknown): Promise<RuntimeSettings> => {
|
|
||||||
assertTrustedSender(event, window)
|
|
||||||
const settings = runtimeSettingsInputSchema.parse(input)
|
|
||||||
return enqueueRuntimeSettingsUpdate(async () => {
|
|
||||||
let workspacePath: string
|
|
||||||
try {
|
|
||||||
workspacePath = await realpath(settings.workspacePath)
|
|
||||||
if (!(await stat(workspacePath)).isDirectory()) {
|
|
||||||
throw new Error('Not a directory')
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
throw new Error('所选工作区不存在、不可访问或不是文件夹')
|
|
||||||
}
|
|
||||||
const rollback = await settingsStore.captureRollback()
|
|
||||||
const previousSettings = rollback.publicSettings
|
|
||||||
const savedSettings = await activateOrRollback({
|
|
||||||
previous: previousSettings,
|
|
||||||
persistCandidate: async () => {
|
|
||||||
const saved = await settingsStore.update({
|
|
||||||
...settings,
|
|
||||||
workspacePath
|
|
||||||
})
|
|
||||||
return saved
|
|
||||||
},
|
|
||||||
activate: onRuntimeSettingsChanged,
|
|
||||||
persistPrevious: () => rollback.restore()
|
|
||||||
})
|
|
||||||
channelSettingsStore?.reportRuntimeSelectionRepairs(
|
|
||||||
assistantDatabase.repairConversationRuntimeSelections(
|
|
||||||
savedSettings
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return savedSettings
|
|
||||||
})
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
registerHandler(
|
|
||||||
ipcChannels.runtimeSettingsSelectWorkspace,
|
|
||||||
async (event): Promise<string | undefined> => {
|
|
||||||
assertTrustedSender(event, window)
|
|
||||||
const result = await dialog.showOpenDialog(window, {
|
|
||||||
properties: ['openDirectory', 'createDirectory']
|
|
||||||
})
|
|
||||||
return result.canceled ? undefined : result.filePaths[0]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
registerHandler(
|
|
||||||
ipcChannels.runtimeSettingsDetect,
|
|
||||||
async (event): Promise<AgentRuntimeDetection> => {
|
|
||||||
assertTrustedSender(event, window)
|
|
||||||
const settings = await settingsStore.getResolvedSettings()
|
|
||||||
return detectAgentRuntimes({
|
|
||||||
opencodeBinaryPath: settings.opencodeBinaryPath,
|
|
||||||
continueBinaryPath: settings.continueBinaryPath,
|
|
||||||
bundledPaths: bundledRuntimePaths,
|
|
||||||
bundledVersions: {
|
|
||||||
continue: bundledContinueVersion,
|
|
||||||
deepseekHarness: bundledDeepSeekHarnessVersion
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
registerHandler(
|
|
||||||
ipcChannels.runtimeSettingsSelectFile,
|
|
||||||
async (event, input: unknown): Promise<string | undefined> => {
|
|
||||||
assertTrustedSender(event, window)
|
|
||||||
const kind = runtimeFileSelectionKindSchema.parse(input)
|
|
||||||
const binary =
|
|
||||||
kind === 'opencodeBinary' ||
|
|
||||||
kind === 'continueBinary'
|
|
||||||
const configRuntime =
|
|
||||||
kind === 'opencodeConfig'
|
|
||||||
? 'opencode'
|
|
||||||
: kind === 'continueConfig'
|
|
||||||
? 'continue'
|
|
||||||
: undefined
|
|
||||||
const configMetadata = configRuntime
|
|
||||||
? runtimeConfigFileMetadata[configRuntime]
|
|
||||||
: undefined
|
|
||||||
const filters =
|
|
||||||
binary && process.platform === 'win32'
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
name: '可执行文件',
|
|
||||||
extensions: ['exe', 'cmd', 'bat', 'com']
|
|
||||||
},
|
|
||||||
{ name: '所有文件', extensions: ['*'] }
|
|
||||||
]
|
|
||||||
: configMetadata
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
name: configMetadata.filterName,
|
|
||||||
extensions: [...configMetadata.filterExtensions]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
: undefined
|
|
||||||
const result = await dialog.showOpenDialog(window, {
|
|
||||||
properties: ['openFile'],
|
|
||||||
title: binary ? '选择可执行文件' : '选择配置文件',
|
|
||||||
...(filters ? { filters } : {})
|
|
||||||
})
|
|
||||||
if (result.canceled || !result.filePaths[0]) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
const selectedPath = await realpath(result.filePaths[0])
|
|
||||||
if (!(await stat(selectedPath)).isFile()) {
|
|
||||||
throw new Error('所选路径不是普通文件')
|
|
||||||
}
|
|
||||||
return selectedPath
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
registerHandler(
|
|
||||||
ipcChannels.runtimeSettingsOpenConfig,
|
|
||||||
async (event, input: unknown): Promise<void> => {
|
|
||||||
assertTrustedSender(event, window)
|
|
||||||
const request = runtimeConfigActionInputSchema.parse(input)
|
|
||||||
if (request.action === 'open-directory') {
|
|
||||||
const directory = getRuntimeConfigDirectory(request.runtime)
|
|
||||||
await mkdir(directory, { recursive: true, mode: 0o700 })
|
|
||||||
const error = await shell.openPath(await realpath(directory))
|
|
||||||
if (error) {
|
|
||||||
throw new Error('无法打开 Runtime 配置目录')
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const settings = await settingsStore.getPublicSettings()
|
|
||||||
const persisted = settings.configured ?? settings
|
|
||||||
const configuredPath =
|
|
||||||
request.runtime === 'opencode'
|
|
||||||
? persisted.opencodeConfigPath
|
|
||||||
: persisted.continueConfigPath
|
|
||||||
if (!configuredPath) {
|
|
||||||
throw new Error('尚未选择 Runtime 自有配置文件')
|
|
||||||
}
|
|
||||||
const configPath = await realpath(configuredPath)
|
|
||||||
if (!(await stat(configPath)).isFile()) {
|
|
||||||
throw new Error('Runtime 配置路径不是普通文件')
|
|
||||||
}
|
|
||||||
if (request.action === 'show-file') {
|
|
||||||
shell.showItemInFolder(configPath)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
!runtimeConfigFileMetadata[request.runtime].allowedExtensions.has(
|
|
||||||
extname(configPath).toLowerCase()
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new Error('Runtime 配置文件类型不支持直接打开')
|
|
||||||
}
|
|
||||||
const error = await shell.openPath(configPath)
|
|
||||||
if (error) {
|
|
||||||
throw new Error('无法打开 Runtime 配置文件')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
registerHandler(
|
|
||||||
ipcChannels.runtimeSettingsTestModel,
|
|
||||||
async (event, input: unknown) => {
|
|
||||||
assertTrustedSender(event, window)
|
|
||||||
const profileId = modelProfileIdSchema.parse(input)
|
|
||||||
const settings = await settingsStore.getResolvedSettings()
|
|
||||||
const profile = settings.modelProfiles.find(
|
|
||||||
(candidate) => candidate.id === profileId
|
|
||||||
)
|
|
||||||
if (!profile) {
|
|
||||||
throw new Error('所选模型连接不存在')
|
|
||||||
}
|
|
||||||
if (profile.authentication === 'api-key' && !profile.apiKey) {
|
|
||||||
throw new Error(`模型连接“${profile.name}”未配置 API Key`)
|
|
||||||
}
|
|
||||||
const modelRuntime = createModelProfileRuntime(
|
|
||||||
settings.workspacePath,
|
|
||||||
settings,
|
|
||||||
profile
|
|
||||||
)
|
|
||||||
try {
|
|
||||||
const status =
|
|
||||||
(await modelRuntime.testConnection?.()) ??
|
|
||||||
(await modelRuntime.getStatus())
|
|
||||||
if (!status.available) {
|
|
||||||
throw new Error(status.detail)
|
|
||||||
}
|
|
||||||
return status
|
|
||||||
} finally {
|
|
||||||
await modelRuntime.dispose()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
registerHandler(
|
|
||||||
ipcChannels.runtimeSettingsTest,
|
|
||||||
async (event, input: unknown) => {
|
|
||||||
assertTrustedSender(event, window)
|
|
||||||
const selection = agentRuntimeSelectionSchema.parse(input)
|
|
||||||
const status = selectedRuntimes
|
|
||||||
? await selectedRuntimes.testStatus(selection)
|
|
||||||
: ((await runtime.testConnection?.()) ??
|
|
||||||
(await runtime.getStatus()))
|
|
||||||
if (!status.available) {
|
|
||||||
throw new Error(status.detail)
|
|
||||||
}
|
|
||||||
return status
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
registerHandler(ipcChannels.sshHostsGet, async (event) => {
|
registerHandler(ipcChannels.sshHostsGet, async (event) => {
|
||||||
assertTrustedSender(event, window)
|
assertTrustedSender(event, window)
|
||||||
|
|||||||
@@ -0,0 +1,388 @@
|
|||||||
|
import { dialog, shell, type BrowserWindow, type ipcMain } from 'electron'
|
||||||
|
import { mkdir, realpath, stat } from 'node:fs/promises'
|
||||||
|
import { homedir } from 'node:os'
|
||||||
|
import { extname, isAbsolute, join } from 'node:path'
|
||||||
|
import {
|
||||||
|
modelProfileIdSchema,
|
||||||
|
runtimeConfigActionInputSchema,
|
||||||
|
runtimeCustomizationSettingsSchema,
|
||||||
|
runtimeFileSelectionKindSchema,
|
||||||
|
runtimeNativeSnapshotInputSchema,
|
||||||
|
runtimeNativeSnapshotSchema,
|
||||||
|
runtimeSettingsInputSchema,
|
||||||
|
type AgentRuntimeDetection,
|
||||||
|
type RuntimeSettings
|
||||||
|
} from '../shared/contracts'
|
||||||
|
import { ipcChannels } from '../shared/ipc-channels'
|
||||||
|
import {
|
||||||
|
agentRuntimeSelectionSchema,
|
||||||
|
type AgentRuntimeSelection
|
||||||
|
} from '../shared/runtime-selection-contracts'
|
||||||
|
import {
|
||||||
|
bundledContinueVersion,
|
||||||
|
bundledDeepSeekHarnessVersion,
|
||||||
|
type BundledRuntimePaths
|
||||||
|
} from './agent/bundled-runtimes'
|
||||||
|
import { createModelProfileRuntime } from './agent/create-runtime'
|
||||||
|
import type { AgentRuntime } from './agent/runtime'
|
||||||
|
import { detectAgentRuntimes } from './agent/runtime-discovery'
|
||||||
|
import type { SelectedRuntimeResolver } from './agent/selected-runtime-manager'
|
||||||
|
import type { ExecutionSpaceResolver } from './execution-space'
|
||||||
|
import type { RuntimeSettingsStore } from './runtime-settings-store'
|
||||||
|
import { assertTrustedSender } from './trusted-ipc-sender'
|
||||||
|
|
||||||
|
const runtimeConfigFileMetadata = {
|
||||||
|
opencode: {
|
||||||
|
filterName: 'OpenCode 配置',
|
||||||
|
filterExtensions: ['json', 'jsonc'],
|
||||||
|
allowedExtensions: new Set<string>(['.json', '.jsonc'])
|
||||||
|
},
|
||||||
|
continue: {
|
||||||
|
filterName: 'Continue 配置',
|
||||||
|
filterExtensions: ['yaml', 'yml', 'json', 'jsonc'],
|
||||||
|
allowedExtensions: new Set<string>([
|
||||||
|
'.yaml',
|
||||||
|
'.yml',
|
||||||
|
'.json',
|
||||||
|
'.jsonc'
|
||||||
|
])
|
||||||
|
}
|
||||||
|
} as const
|
||||||
|
|
||||||
|
function getRuntimeConfigDirectory(
|
||||||
|
runtime: 'opencode' | 'continue'
|
||||||
|
): string {
|
||||||
|
if (runtime === 'continue') {
|
||||||
|
return join(homedir(), '.continue')
|
||||||
|
}
|
||||||
|
const xdgConfigHome = process.env.XDG_CONFIG_HOME?.trim()
|
||||||
|
const configHome =
|
||||||
|
xdgConfigHome && isAbsolute(xdgConfigHome)
|
||||||
|
? xdgConfigHome
|
||||||
|
: join(homedir(), '.config')
|
||||||
|
return join(configHome, 'opencode')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function activateOrRollback<T>(input: {
|
||||||
|
previous: T
|
||||||
|
persistCandidate(): Promise<T>
|
||||||
|
activate(): Promise<void>
|
||||||
|
persistPrevious(previous: T): Promise<unknown>
|
||||||
|
}): Promise<T> {
|
||||||
|
const saved = await input.persistCandidate()
|
||||||
|
try {
|
||||||
|
await input.activate()
|
||||||
|
return saved
|
||||||
|
} catch (activationError) {
|
||||||
|
try {
|
||||||
|
await input.persistPrevious(input.previous)
|
||||||
|
await input.activate()
|
||||||
|
} catch (rollbackError) {
|
||||||
|
throw new AggregateError(
|
||||||
|
[activationError, rollbackError],
|
||||||
|
'Runtime 配置激活失败,且回滚未能完成',
|
||||||
|
{ cause: rollbackError }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
throw activationError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerModelSettingsIpcHandlers(
|
||||||
|
registerHandler: typeof ipcMain.handle,
|
||||||
|
window: BrowserWindow,
|
||||||
|
{
|
||||||
|
settingsStore,
|
||||||
|
runtime,
|
||||||
|
selectedRuntimes,
|
||||||
|
bundledRuntimePaths,
|
||||||
|
enqueueRuntimeSettingsUpdate,
|
||||||
|
onRuntimeSettingsChanged,
|
||||||
|
repairRuntimeSelections,
|
||||||
|
resolveSnapshotExecutionSpace
|
||||||
|
}: {
|
||||||
|
settingsStore: RuntimeSettingsStore
|
||||||
|
runtime: Pick<AgentRuntime, 'testConnection' | 'getStatus'>
|
||||||
|
selectedRuntimes?: Pick<SelectedRuntimeResolver, 'getNativeSnapshot' | 'testStatus'>
|
||||||
|
bundledRuntimePaths: BundledRuntimePaths
|
||||||
|
enqueueRuntimeSettingsUpdate: <T>(transaction: () => Promise<T>) => Promise<T>
|
||||||
|
onRuntimeSettingsChanged: () => Promise<void>
|
||||||
|
repairRuntimeSelections: (settings: RuntimeSettings) => void
|
||||||
|
resolveSnapshotExecutionSpace: (
|
||||||
|
projectId?: string
|
||||||
|
) => Promise<ReturnType<ExecutionSpaceResolver['resolveProject']>>
|
||||||
|
}
|
||||||
|
): void {
|
||||||
|
registerHandler(
|
||||||
|
ipcChannels.runtimeSettingsGet,
|
||||||
|
(event): Promise<RuntimeSettings> => {
|
||||||
|
assertTrustedSender(event, window)
|
||||||
|
return settingsStore.getPublicSettings()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
registerHandler(
|
||||||
|
ipcChannels.runtimeCustomizationGet,
|
||||||
|
(event) => {
|
||||||
|
assertTrustedSender(event, window)
|
||||||
|
return settingsStore.getRuntimeCustomization()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
registerHandler(
|
||||||
|
ipcChannels.runtimeCustomizationUpdate,
|
||||||
|
async (event, input: unknown) => {
|
||||||
|
assertTrustedSender(event, window)
|
||||||
|
const settings =
|
||||||
|
runtimeCustomizationSettingsSchema.parse(input)
|
||||||
|
return enqueueRuntimeSettingsUpdate(async () => {
|
||||||
|
const previous =
|
||||||
|
await settingsStore.getRuntimeCustomization()
|
||||||
|
return activateOrRollback({
|
||||||
|
previous,
|
||||||
|
persistCandidate: async () => {
|
||||||
|
const saved =
|
||||||
|
await settingsStore.updateRuntimeCustomization(settings)
|
||||||
|
return saved
|
||||||
|
},
|
||||||
|
activate: onRuntimeSettingsChanged,
|
||||||
|
persistPrevious: (previousSettings) =>
|
||||||
|
settingsStore.updateRuntimeCustomization(previousSettings)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
registerHandler(
|
||||||
|
ipcChannels.runtimeNativeSnapshot,
|
||||||
|
async (event, input: unknown) => {
|
||||||
|
assertTrustedSender(event, window)
|
||||||
|
if (!selectedRuntimes) {
|
||||||
|
throw new Error('Runtime 管理器不可用')
|
||||||
|
}
|
||||||
|
const request = runtimeNativeSnapshotInputSchema.parse(input)
|
||||||
|
const selection: AgentRuntimeSelection = {
|
||||||
|
provider: request.provider,
|
||||||
|
...(request.profileId
|
||||||
|
? { profileId: request.profileId }
|
||||||
|
: {})
|
||||||
|
}
|
||||||
|
const executionSpace = await resolveSnapshotExecutionSpace(request.projectId)
|
||||||
|
return runtimeNativeSnapshotSchema.parse(
|
||||||
|
await selectedRuntimes.getNativeSnapshot(
|
||||||
|
selection,
|
||||||
|
executionSpace
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
registerHandler(
|
||||||
|
ipcChannels.runtimeSettingsUpdate,
|
||||||
|
async (event, input: unknown): Promise<RuntimeSettings> => {
|
||||||
|
assertTrustedSender(event, window)
|
||||||
|
const settings = runtimeSettingsInputSchema.parse(input)
|
||||||
|
return enqueueRuntimeSettingsUpdate(async () => {
|
||||||
|
let workspacePath: string
|
||||||
|
try {
|
||||||
|
workspacePath = await realpath(settings.workspacePath)
|
||||||
|
if (!(await stat(workspacePath)).isDirectory()) {
|
||||||
|
throw new Error('Not a directory')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
throw new Error('所选工作区不存在、不可访问或不是文件夹')
|
||||||
|
}
|
||||||
|
const rollback = await settingsStore.captureRollback()
|
||||||
|
const previousSettings = rollback.publicSettings
|
||||||
|
const savedSettings = await activateOrRollback({
|
||||||
|
previous: previousSettings,
|
||||||
|
persistCandidate: async () => {
|
||||||
|
const saved = await settingsStore.update({
|
||||||
|
...settings,
|
||||||
|
workspacePath
|
||||||
|
})
|
||||||
|
return saved
|
||||||
|
},
|
||||||
|
activate: onRuntimeSettingsChanged,
|
||||||
|
persistPrevious: () => rollback.restore()
|
||||||
|
})
|
||||||
|
repairRuntimeSelections(savedSettings)
|
||||||
|
return savedSettings
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
registerHandler(
|
||||||
|
ipcChannels.runtimeSettingsSelectWorkspace,
|
||||||
|
async (event): Promise<string | undefined> => {
|
||||||
|
assertTrustedSender(event, window)
|
||||||
|
const result = await dialog.showOpenDialog(window, {
|
||||||
|
properties: ['openDirectory', 'createDirectory']
|
||||||
|
})
|
||||||
|
return result.canceled ? undefined : result.filePaths[0]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
registerHandler(
|
||||||
|
ipcChannels.runtimeSettingsDetect,
|
||||||
|
async (event): Promise<AgentRuntimeDetection> => {
|
||||||
|
assertTrustedSender(event, window)
|
||||||
|
const settings = await settingsStore.getResolvedSettings()
|
||||||
|
return detectAgentRuntimes({
|
||||||
|
opencodeBinaryPath: settings.opencodeBinaryPath,
|
||||||
|
continueBinaryPath: settings.continueBinaryPath,
|
||||||
|
bundledPaths: bundledRuntimePaths,
|
||||||
|
bundledVersions: {
|
||||||
|
continue: bundledContinueVersion,
|
||||||
|
deepseekHarness: bundledDeepSeekHarnessVersion
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
registerHandler(
|
||||||
|
ipcChannels.runtimeSettingsSelectFile,
|
||||||
|
async (event, input: unknown): Promise<string | undefined> => {
|
||||||
|
assertTrustedSender(event, window)
|
||||||
|
const kind = runtimeFileSelectionKindSchema.parse(input)
|
||||||
|
const binary =
|
||||||
|
kind === 'opencodeBinary' ||
|
||||||
|
kind === 'continueBinary'
|
||||||
|
const configRuntime =
|
||||||
|
kind === 'opencodeConfig'
|
||||||
|
? 'opencode'
|
||||||
|
: kind === 'continueConfig'
|
||||||
|
? 'continue'
|
||||||
|
: undefined
|
||||||
|
const configMetadata = configRuntime
|
||||||
|
? runtimeConfigFileMetadata[configRuntime]
|
||||||
|
: undefined
|
||||||
|
const filters =
|
||||||
|
binary && process.platform === 'win32'
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
name: '可执行文件',
|
||||||
|
extensions: ['exe', 'cmd', 'bat', 'com']
|
||||||
|
},
|
||||||
|
{ name: '所有文件', extensions: ['*'] }
|
||||||
|
]
|
||||||
|
: configMetadata
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
name: configMetadata.filterName,
|
||||||
|
extensions: [...configMetadata.filterExtensions]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: undefined
|
||||||
|
const result = await dialog.showOpenDialog(window, {
|
||||||
|
properties: ['openFile'],
|
||||||
|
title: binary ? '选择可执行文件' : '选择配置文件',
|
||||||
|
...(filters ? { filters } : {})
|
||||||
|
})
|
||||||
|
if (result.canceled || !result.filePaths[0]) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const selectedPath = await realpath(result.filePaths[0])
|
||||||
|
if (!(await stat(selectedPath)).isFile()) {
|
||||||
|
throw new Error('所选路径不是普通文件')
|
||||||
|
}
|
||||||
|
return selectedPath
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
registerHandler(
|
||||||
|
ipcChannels.runtimeSettingsOpenConfig,
|
||||||
|
async (event, input: unknown): Promise<void> => {
|
||||||
|
assertTrustedSender(event, window)
|
||||||
|
const request = runtimeConfigActionInputSchema.parse(input)
|
||||||
|
if (request.action === 'open-directory') {
|
||||||
|
const directory = getRuntimeConfigDirectory(request.runtime)
|
||||||
|
await mkdir(directory, { recursive: true, mode: 0o700 })
|
||||||
|
const error = await shell.openPath(await realpath(directory))
|
||||||
|
if (error) {
|
||||||
|
throw new Error('无法打开 Runtime 配置目录')
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = await settingsStore.getPublicSettings()
|
||||||
|
const persisted = settings.configured ?? settings
|
||||||
|
const configuredPath =
|
||||||
|
request.runtime === 'opencode'
|
||||||
|
? persisted.opencodeConfigPath
|
||||||
|
: persisted.continueConfigPath
|
||||||
|
if (!configuredPath) {
|
||||||
|
throw new Error('尚未选择 Runtime 自有配置文件')
|
||||||
|
}
|
||||||
|
const configPath = await realpath(configuredPath)
|
||||||
|
if (!(await stat(configPath)).isFile()) {
|
||||||
|
throw new Error('Runtime 配置路径不是普通文件')
|
||||||
|
}
|
||||||
|
if (request.action === 'show-file') {
|
||||||
|
shell.showItemInFolder(configPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!runtimeConfigFileMetadata[request.runtime].allowedExtensions.has(
|
||||||
|
extname(configPath).toLowerCase()
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error('Runtime 配置文件类型不支持直接打开')
|
||||||
|
}
|
||||||
|
const error = await shell.openPath(configPath)
|
||||||
|
if (error) {
|
||||||
|
throw new Error('无法打开 Runtime 配置文件')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
registerHandler(
|
||||||
|
ipcChannels.runtimeSettingsTestModel,
|
||||||
|
async (event, input: unknown) => {
|
||||||
|
assertTrustedSender(event, window)
|
||||||
|
const profileId = modelProfileIdSchema.parse(input)
|
||||||
|
const settings = await settingsStore.getResolvedSettings()
|
||||||
|
const profile = settings.modelProfiles.find(
|
||||||
|
(candidate) => candidate.id === profileId
|
||||||
|
)
|
||||||
|
if (!profile) {
|
||||||
|
throw new Error('所选模型连接不存在')
|
||||||
|
}
|
||||||
|
if (profile.authentication === 'api-key' && !profile.apiKey) {
|
||||||
|
throw new Error(`模型连接“${profile.name}”未配置 API Key`)
|
||||||
|
}
|
||||||
|
const modelRuntime = createModelProfileRuntime(
|
||||||
|
settings.workspacePath,
|
||||||
|
settings,
|
||||||
|
profile
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
const status =
|
||||||
|
(await modelRuntime.testConnection?.()) ??
|
||||||
|
(await modelRuntime.getStatus())
|
||||||
|
if (!status.available) {
|
||||||
|
throw new Error(status.detail)
|
||||||
|
}
|
||||||
|
return status
|
||||||
|
} finally {
|
||||||
|
await modelRuntime.dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
registerHandler(
|
||||||
|
ipcChannels.runtimeSettingsTest,
|
||||||
|
async (event, input: unknown) => {
|
||||||
|
assertTrustedSender(event, window)
|
||||||
|
const selection = agentRuntimeSelectionSchema.parse(input)
|
||||||
|
const status = selectedRuntimes
|
||||||
|
? await selectedRuntimes.testStatus(selection)
|
||||||
|
: ((await runtime.testConnection?.()) ??
|
||||||
|
(await runtime.getStatus()))
|
||||||
|
if (!status.available) {
|
||||||
|
throw new Error(status.detail)
|
||||||
|
}
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user