refactor: extract model settings IPC handlers
This commit is contained in:
@@ -125,3 +125,25 @@ Main 的 `toPublicSettings` 复用 `resolveDeepSeekHarnessModelProfile`,只在
|
||||
`source: 'profile'` 描述来源分支,不代表凭据来源:仅环境密钥或旧环境变量可能仍影响该
|
||||
GoodBuddy 连接,但不会被误报为完整管理员预置。连接解析不代替真实模型请求或可用性测试。
|
||||
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
|
||||
}
|
||||
|
||||
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(
|
||||
electronMocks.handlers.get(
|
||||
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 = {
|
||||
requestId,
|
||||
conversationId,
|
||||
|
||||
+21
-352
@@ -1,4 +1,5 @@
|
||||
import { registerKnowledgeIpcHandlers } from './knowledge/knowledge-ipc'
|
||||
import { registerModelSettingsIpcHandlers } from './model-settings-ipc'
|
||||
import { knowledgeReferenceKey, toKnowledgeReference } from '../shared/knowledge-reference'
|
||||
import { localInferenceService } from './local-inference-service'
|
||||
import { inferenceActionSchema, inferenceCancelSchema } from '../shared/local-inference-contracts'
|
||||
@@ -13,14 +14,12 @@ import {
|
||||
type IpcMainInvokeEvent
|
||||
} from 'electron'
|
||||
import {
|
||||
mkdir,
|
||||
readFile,
|
||||
realpath,
|
||||
stat
|
||||
} from 'node:fs/promises'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { homedir } from 'node:os'
|
||||
import { basename, extname, isAbsolute, join } from 'node:path'
|
||||
import { basename, extname, join } from 'node:path'
|
||||
import { z } from 'zod'
|
||||
import { documentResourceInputSchema } from '../shared/document-result-contracts'
|
||||
import { maximumAttachmentsPerMessage } from '../shared/attachment-limits'
|
||||
@@ -51,21 +50,13 @@ import {
|
||||
conversationQueueUserInputSchema,
|
||||
defaultRuntimeSettings,
|
||||
isAgentRuntimeModelProtocol,
|
||||
modelProfileIdSchema,
|
||||
pastedImageInputSchema,
|
||||
runtimeConversationCompactInputSchema,
|
||||
runtimeConversationCompactResultSchema,
|
||||
runtimeConfigActionInputSchema,
|
||||
runtimeCustomizationSettingsSchema,
|
||||
runtimeFileSelectionKindSchema,
|
||||
runtimeNativeSnapshotInputSchema,
|
||||
runtimeNativeSnapshotSchema,
|
||||
runtimeSettingsInputSchema,
|
||||
windowCaptureRequestSchema,
|
||||
workspaceDirectoryRequestSchema,
|
||||
workspaceFileRequestSchema,
|
||||
workspaceOpenPathRequestSchema,
|
||||
type AgentRuntimeDetection,
|
||||
type AgentEvent,
|
||||
type AgentRequest,
|
||||
type AppInfo,
|
||||
@@ -73,8 +64,7 @@ import {
|
||||
type BrowserTabId,
|
||||
type ConversationQueueDispatch,
|
||||
type ConversationQueueUserInput,
|
||||
type KnowledgeSearchReference,
|
||||
type RuntimeSettings
|
||||
type KnowledgeSearchReference
|
||||
} from '../shared/contracts'
|
||||
import { stripKnowledgeHighlightTags } from '../shared/knowledge-text'
|
||||
import type { KnowledgeRetrievalResponse } from '../shared/knowledge-contracts'
|
||||
@@ -172,7 +162,6 @@ import type {
|
||||
RuntimeGeneratedImageEvent,
|
||||
RuntimeModelUsageEvent
|
||||
} from './agent/runtime'
|
||||
import { detectAgentRuntimes } from './agent/runtime-discovery'
|
||||
import {
|
||||
createModelProfileRuntime
|
||||
} from './agent/create-runtime'
|
||||
@@ -186,11 +175,7 @@ import {
|
||||
RemotePromptCancelledError,
|
||||
RemotePromptRecoveryUnavailableError
|
||||
} from './agent/acp-remote-runtime'
|
||||
import {
|
||||
bundledContinueVersion,
|
||||
bundledDeepSeekHarnessVersion,
|
||||
type BundledRuntimePaths
|
||||
} from './agent/bundled-runtimes'
|
||||
import type { BundledRuntimePaths } from './agent/bundled-runtimes'
|
||||
import type { SelectedRuntimeResolver } from './agent/selected-runtime-manager'
|
||||
import {
|
||||
type MagicNotesCapabilityAccess,
|
||||
@@ -335,23 +320,6 @@ import {
|
||||
const requestIdSchema = z.string().uuid()
|
||||
const BACKGROUND_QUESTION_REJECTION_TIMEOUT_MS = 1_000
|
||||
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', [
|
||||
z
|
||||
.object({
|
||||
@@ -367,20 +335,6 @@ const channelSettingsTestRequestSchema = z.discriminatedUnion('channel', [
|
||||
.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 {
|
||||
return (
|
||||
runtime.runtimeId === 'opencode' ||
|
||||
@@ -650,31 +604,6 @@ function stripRemoteSemanticProvenance(
|
||||
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(): {
|
||||
track<T>(operation: Promise<T>): Promise<T>
|
||||
drain(): Promise<void>
|
||||
@@ -4955,292 +4884,32 @@ export function registerIpcHandlers(
|
||||
}
|
||||
)
|
||||
|
||||
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 project = request.projectId
|
||||
? assistantDatabase.getProject(request.projectId)
|
||||
registerModelSettingsIpcHandlers(registerHandler, window, {
|
||||
settingsStore,
|
||||
runtime,
|
||||
selectedRuntimes,
|
||||
bundledRuntimePaths,
|
||||
enqueueRuntimeSettingsUpdate,
|
||||
onRuntimeSettingsChanged,
|
||||
repairRuntimeSelections: (savedSettings) => {
|
||||
channelSettingsStore?.reportRuntimeSelectionRepairs(
|
||||
assistantDatabase.repairConversationRuntimeSelections(savedSettings)
|
||||
)
|
||||
},
|
||||
resolveSnapshotExecutionSpace: async (projectId) => {
|
||||
const project = projectId
|
||||
? assistantDatabase.getProject(projectId)
|
||||
: undefined
|
||||
if (project?.executionSpace?.kind === 'ssh') {
|
||||
await requireRemoteProjectsEnabled()
|
||||
}
|
||||
const executionSpace = project
|
||||
return project
|
||||
? spaceResolver.resolveProject(project)
|
||||
: spaceResolver.resolveLocal(
|
||||
(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) => {
|
||||
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