fix: harden scoped tools and settings persistence
This commit is contained in:
@@ -139,6 +139,7 @@ export class ContinueAgentRuntime implements AgentRuntime {
|
||||
readonly runtimeId = 'continue'
|
||||
readonly requiresToolApproval = false
|
||||
readonly supportsToolExecution = true
|
||||
readonly supportsScopedDataTools = true
|
||||
private detection?: Promise<RuntimeBinaryDetection>
|
||||
private readonly hostAdapters = new Map<
|
||||
RuntimeSettings['continueMode'],
|
||||
|
||||
@@ -1096,7 +1096,9 @@ describe('ModelAgentRuntime', () => {
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
id: '',
|
||||
function: {
|
||||
name: '',
|
||||
arguments: '"README.md"}'
|
||||
}
|
||||
}
|
||||
@@ -1219,6 +1221,86 @@ describe('ModelAgentRuntime', () => {
|
||||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||||
})
|
||||
|
||||
it('synthesizes and pairs a missing OpenAI Chat tool call id', async () => {
|
||||
const responses = [
|
||||
{
|
||||
id: 'chatcmpl-missing-call-id-1',
|
||||
model: 'qwen3',
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: null,
|
||||
tool_calls: [
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'workspace_read_text',
|
||||
arguments: '{"path":"README.md"}'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'chatcmpl-missing-call-id-2',
|
||||
model: 'qwen3',
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: '读取完成。'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
const fetcher = vi.fn<typeof fetch>(async () =>
|
||||
Response.json(responses.shift())
|
||||
)
|
||||
const runtime = new ModelAgentRuntime({
|
||||
baseUrl: 'http://127.0.0.1:11434/v1',
|
||||
model: 'qwen3',
|
||||
protocol: 'openai-chat-completions',
|
||||
authentication: 'none',
|
||||
fetcher,
|
||||
toolProvider: createToolProvider()
|
||||
})
|
||||
|
||||
for await (const _event of runtime.run(
|
||||
{
|
||||
requestId: 'a431666e-5ec8-45e6-beb4-654132eed143',
|
||||
conversationId: 'conversation-chat-fallback-id',
|
||||
prompt: '读取 README',
|
||||
workMode: 'execute'
|
||||
},
|
||||
new AbortController().signal,
|
||||
async () => 'once'
|
||||
)) {
|
||||
void _event
|
||||
}
|
||||
|
||||
const secondBody = JSON.parse(
|
||||
fetcher.mock.calls[1]?.[1]?.body as string
|
||||
) as { messages: Array<Record<string, unknown>> }
|
||||
const assistant = secondBody.messages.at(-2) as {
|
||||
tool_calls: Array<Record<string, unknown>>
|
||||
}
|
||||
const result = secondBody.messages.at(-1) as {
|
||||
tool_call_id: string
|
||||
}
|
||||
const toolCallId = assistant.tool_calls[0]?.id
|
||||
expect(toolCallId).toEqual(
|
||||
expect.stringMatching(/^goodbuddy_call_[0-9a-f]{32}$/u)
|
||||
)
|
||||
expect(result).toMatchObject({
|
||||
role: 'tool',
|
||||
tool_call_id: toolCallId
|
||||
})
|
||||
})
|
||||
|
||||
it('uses refreshed tool definitions in subsequent model rounds', async () => {
|
||||
const loadTool: ModelToolDefinition = {
|
||||
name: 'mcp_load_tools',
|
||||
@@ -1833,6 +1915,81 @@ describe('ModelAgentRuntime', () => {
|
||||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||||
})
|
||||
|
||||
it('pairs a missing Responses call_id with the function-call item id', async () => {
|
||||
const responses = [
|
||||
{
|
||||
id: 'resp-tool-fallback-1',
|
||||
model: 'gpt-5',
|
||||
output: [
|
||||
{
|
||||
id: 'fc-responses-fallback-1',
|
||||
type: 'function_call',
|
||||
name: 'workspace_read_text',
|
||||
arguments: '{"path":"README.md"}'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'resp-tool-fallback-2',
|
||||
model: 'gpt-5',
|
||||
output: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'output_text',
|
||||
text: '读取完成。'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
const fetcher = vi.fn<typeof fetch>(async () =>
|
||||
Response.json(responses.shift())
|
||||
)
|
||||
const runtime = new ModelAgentRuntime({
|
||||
apiKey: 'test-key',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: 'gpt-5',
|
||||
protocol: 'openai-responses',
|
||||
authentication: 'api-key',
|
||||
fetcher,
|
||||
toolProvider: createToolProvider()
|
||||
})
|
||||
|
||||
for await (const _event of runtime.run(
|
||||
{
|
||||
requestId: 'a431666e-5ec8-45e6-beb4-654132eed141',
|
||||
conversationId: 'conversation-responses-fallback-id',
|
||||
prompt: '读取 README',
|
||||
workMode: 'execute'
|
||||
},
|
||||
new AbortController().signal,
|
||||
async () => 'once'
|
||||
)) {
|
||||
void _event
|
||||
}
|
||||
|
||||
const secondBody = JSON.parse(
|
||||
fetcher.mock.calls[1]?.[1]?.body as string
|
||||
) as { input: Array<Record<string, unknown>> }
|
||||
expect(secondBody.input).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: 'fc-responses-fallback-1',
|
||||
type: 'function_call',
|
||||
call_id: 'fc-responses-fallback-1'
|
||||
})
|
||||
)
|
||||
expect(secondBody.input).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'function_call_output',
|
||||
call_id: 'fc-responses-fallback-1'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('fails closed when a direct-model tool is denied', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>(async () =>
|
||||
Response.json({
|
||||
@@ -1980,6 +2137,70 @@ describe('ModelAgentRuntime', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('synthesizes and pairs a missing Anthropic tool_use id', async () => {
|
||||
const responses = [
|
||||
{
|
||||
id: 'message-tool-missing-id-1',
|
||||
model: 'claude',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
name: 'workspace_read_text',
|
||||
input: { path: 'notes.md' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'message-tool-missing-id-2',
|
||||
model: 'claude',
|
||||
content: [{ type: 'text', text: '读取完成。' }]
|
||||
}
|
||||
]
|
||||
const fetcher = vi.fn<typeof fetch>(async () =>
|
||||
Response.json(responses.shift())
|
||||
)
|
||||
const runtime = new ModelAgentRuntime({
|
||||
apiKey: 'test-key',
|
||||
baseUrl: 'https://bigtoken.ai',
|
||||
model: 'claude',
|
||||
protocol: 'anthropic-messages',
|
||||
authentication: 'api-key',
|
||||
fetcher,
|
||||
toolProvider: createToolProvider()
|
||||
})
|
||||
|
||||
for await (const _event of runtime.run(
|
||||
{
|
||||
requestId: 'a431666e-5ec8-45e6-beb4-654132eed142',
|
||||
conversationId: 'conversation-anthropic-fallback-id',
|
||||
prompt: '读取 notes',
|
||||
workMode: 'execute'
|
||||
},
|
||||
new AbortController().signal,
|
||||
async () => 'once'
|
||||
)) {
|
||||
void _event
|
||||
}
|
||||
|
||||
const secondBody = JSON.parse(
|
||||
fetcher.mock.calls[1]?.[1]?.body as string
|
||||
) as { messages: Array<Record<string, unknown>> }
|
||||
const assistant = secondBody.messages.at(-2) as {
|
||||
content: Array<Record<string, unknown>>
|
||||
}
|
||||
const result = secondBody.messages.at(-1) as {
|
||||
content: Array<Record<string, unknown>>
|
||||
}
|
||||
const toolUseId = assistant.content[0]?.id
|
||||
expect(toolUseId).toEqual(
|
||||
expect.stringMatching(/^goodbuddy_call_[0-9a-f]{32}$/u)
|
||||
)
|
||||
expect(result.content[0]).toMatchObject({
|
||||
type: 'tool_result',
|
||||
tool_use_id: toolUseId
|
||||
})
|
||||
})
|
||||
|
||||
it('does not issue a follow-up model request after tool cancellation', async () => {
|
||||
const response = {
|
||||
choices: [
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import type {
|
||||
ApprovalDecision,
|
||||
AgentRuntimeStatus,
|
||||
@@ -725,21 +726,30 @@ function getChatToolImageCarrierContent(
|
||||
]
|
||||
}
|
||||
|
||||
function createToolCallId(): string {
|
||||
return `goodbuddy_call_${randomBytes(16).toString('hex')}`
|
||||
}
|
||||
|
||||
function parseToolCallIdentity(
|
||||
id: unknown,
|
||||
name: unknown
|
||||
name: unknown,
|
||||
fallbackId?: unknown
|
||||
): { id: string; name: string } {
|
||||
const resolvedId =
|
||||
typeof id === 'string' && id.length > 0
|
||||
? id
|
||||
: typeof fallbackId === 'string' && fallbackId.length > 0
|
||||
? fallbackId
|
||||
: createToolCallId()
|
||||
if (
|
||||
typeof id !== 'string' ||
|
||||
id.length === 0 ||
|
||||
id.length > 256 ||
|
||||
resolvedId.length > 256 ||
|
||||
typeof name !== 'string' ||
|
||||
name.length === 0 ||
|
||||
name.length > 128
|
||||
) {
|
||||
throw new Error('模型返回了无效的工具调用标识')
|
||||
throw new Error('模型返回了无效的工具调用标识或名称')
|
||||
}
|
||||
return { id, name }
|
||||
return { id: resolvedId, name }
|
||||
}
|
||||
|
||||
function parseModelToolResponse(
|
||||
@@ -771,6 +781,7 @@ function parseModelToolResponse(
|
||||
reasoning.push(record.thinking)
|
||||
} else if (record.type === 'tool_use') {
|
||||
const identity = parseToolCallIdentity(record.id, record.name)
|
||||
record.id = identity.id
|
||||
toolCalls.push({
|
||||
...identity,
|
||||
arguments: parseToolArguments(record.input)
|
||||
@@ -845,8 +856,10 @@ function parseModelToolResponse(
|
||||
} else if (output.type === 'function_call') {
|
||||
const identity = parseToolCallIdentity(
|
||||
output.call_id,
|
||||
output.name
|
||||
output.name,
|
||||
output.id
|
||||
)
|
||||
output.call_id = identity.id
|
||||
toolCalls.push({
|
||||
...identity,
|
||||
arguments: parseToolArguments(output.arguments)
|
||||
@@ -893,6 +906,7 @@ function parseModelToolResponse(
|
||||
toolCall.id,
|
||||
functionCall.name
|
||||
)
|
||||
toolCall.id = identity.id
|
||||
toolCalls.push({
|
||||
...identity,
|
||||
arguments: parseToolArguments(functionCall.arguments)
|
||||
@@ -1112,6 +1126,10 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
return this.capability === 'chat'
|
||||
}
|
||||
|
||||
get supportsScopedDataTools(): boolean {
|
||||
return this.capability === 'chat'
|
||||
}
|
||||
|
||||
private isConfigured(): boolean {
|
||||
return (
|
||||
this.options.authentication === 'none' ||
|
||||
@@ -1665,11 +1683,13 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
? functionDelta.arguments
|
||||
: ''),
|
||||
id:
|
||||
typeof toolDelta?.id === 'string'
|
||||
typeof toolDelta?.id === 'string' &&
|
||||
toolDelta.id.length > 0
|
||||
? toolDelta.id
|
||||
: current.id,
|
||||
name:
|
||||
typeof functionDelta?.name === 'string'
|
||||
typeof functionDelta?.name === 'string' &&
|
||||
functionDelta.name.length > 0
|
||||
? functionDelta.name
|
||||
: current.name
|
||||
}
|
||||
|
||||
@@ -565,6 +565,10 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
return this.options.embedded && !this.options.baseUrl
|
||||
}
|
||||
|
||||
get supportsScopedDataTools(): boolean {
|
||||
return this.usesEmbeddedPermissionMediation()
|
||||
}
|
||||
|
||||
private async acquireEmbeddedRun(
|
||||
signal: AbortSignal
|
||||
): Promise<() => void> {
|
||||
|
||||
@@ -45,6 +45,10 @@ export class AgentRuntimeController implements AgentRuntime {
|
||||
return this.current.runtime.supportsToolExecution
|
||||
}
|
||||
|
||||
get supportsScopedDataTools(): boolean {
|
||||
return this.current.runtime.supportsScopedDataTools !== false
|
||||
}
|
||||
|
||||
get capability(): AgentRuntime['capability'] {
|
||||
return this.current.runtime.capability
|
||||
}
|
||||
|
||||
@@ -51,6 +51,8 @@ export interface AgentRuntime {
|
||||
readonly runtimeId?: AgentRuntimeStatus['id']
|
||||
readonly requiresToolApproval: boolean
|
||||
readonly supportsToolExecution: boolean
|
||||
/** Whether request-scoped GoodBuddy data tools can reach this runtime. */
|
||||
readonly supportsScopedDataTools?: boolean
|
||||
readonly capability?: 'chat' | 'image-generation'
|
||||
getStatus(): Promise<AgentRuntimeStatus>
|
||||
testConnection?(): Promise<AgentRuntimeStatus>
|
||||
|
||||
@@ -11,6 +11,7 @@ export class UnconfiguredAgentRuntime implements AgentRuntime {
|
||||
readonly runtimeId = 'setup'
|
||||
readonly requiresToolApproval = false
|
||||
readonly supportsToolExecution = false
|
||||
readonly supportsScopedDataTools = false
|
||||
|
||||
getStatus(): Promise<AgentRuntimeStatus> {
|
||||
return Promise.resolve({
|
||||
|
||||
@@ -266,9 +266,10 @@ describe('ApplicationSettingsStore', () => {
|
||||
const { directory, filePath, store } = await createStore()
|
||||
await writeFile(filePath, data, 'utf8')
|
||||
|
||||
await expect(store.get()).resolves.toEqual(
|
||||
defaultApplicationSettings
|
||||
)
|
||||
await expect(store.get()).resolves.toEqual({
|
||||
...defaultApplicationSettings,
|
||||
warnings: [{ code: 'application-settings-recovered' }]
|
||||
})
|
||||
const entries = await readdir(directory)
|
||||
expect(entries).toHaveLength(1)
|
||||
expect(entries[0]).toMatch(
|
||||
@@ -279,6 +280,25 @@ describe('ApplicationSettingsStore', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves settings created by a newer unsupported version', async () => {
|
||||
const { directory, filePath, store } = await createStore()
|
||||
const futureSettings = JSON.stringify({
|
||||
version: 99,
|
||||
futureField: 'keep-me'
|
||||
})
|
||||
await writeFile(filePath, futureSettings, 'utf8')
|
||||
|
||||
await expect(store.get()).rejects.toThrow(
|
||||
'不支持应用设置版本 99'
|
||||
)
|
||||
expect(await readFile(filePath, 'utf8')).toBe(futureSettings)
|
||||
expect(
|
||||
(await readdir(directory)).some((name) =>
|
||||
name.startsWith('application-settings.json.corrupt-')
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('does not classify an I/O failure as corrupt settings', async () => {
|
||||
const { directory } = await createStore()
|
||||
const filePath = join(directory, 'settings-directory')
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
import {
|
||||
mkdir,
|
||||
readFile,
|
||||
rename,
|
||||
rm,
|
||||
writeFile
|
||||
} from 'node:fs/promises'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { dirname } from 'node:path'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
applicationSettingsSchema,
|
||||
@@ -14,6 +6,14 @@ import {
|
||||
type ApplicationSettings
|
||||
} from '../shared/application-settings-contracts'
|
||||
import { releaseVersionSchema } from '../shared/release-notes-contracts'
|
||||
import type { SettingsWarning } from '../shared/settings-warning-contracts'
|
||||
import {
|
||||
assertSupportedSettingsVersion,
|
||||
isolateCorruptSettingsFile,
|
||||
isMissingFileError,
|
||||
UnsupportedSettingsVersionError,
|
||||
writeJsonFileAtomically
|
||||
} from './settings-file-utils'
|
||||
export {
|
||||
applicationSettingsSchema,
|
||||
applicationSettingsUpdateSchema
|
||||
@@ -70,36 +70,19 @@ export const defaultApplicationSettings: ApplicationSettings = {
|
||||
magicNoteCommentFormat: 'combined'
|
||||
}
|
||||
|
||||
function isMissingFile(error: unknown): boolean {
|
||||
return (
|
||||
error !== null &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
error.code === 'ENOENT'
|
||||
)
|
||||
}
|
||||
|
||||
export class ApplicationSettingsStore {
|
||||
private settings?: StoredApplicationSettings
|
||||
private settingsLoad?: Promise<StoredApplicationSettings>
|
||||
private warnings: SettingsWarning[] = []
|
||||
private updateQueue: Promise<void> = Promise.resolve()
|
||||
|
||||
constructor(private readonly filePath: string) {}
|
||||
|
||||
private async isolateCorruptFile(): Promise<void> {
|
||||
const isolatedPath =
|
||||
`${this.filePath}.corrupt-${Date.now()}-` +
|
||||
randomBytes(6).toString('hex')
|
||||
try {
|
||||
await rename(this.filePath, isolatedPath)
|
||||
} catch (error) {
|
||||
if (!isMissingFile(error)) {
|
||||
throw new Error(
|
||||
'Application settings are corrupt and could not be isolated',
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
}
|
||||
await isolateCorruptSettingsFile(
|
||||
this.filePath,
|
||||
'Application settings are corrupt and could not be isolated'
|
||||
)
|
||||
}
|
||||
|
||||
private async loadStored(): Promise<StoredApplicationSettings> {
|
||||
@@ -122,6 +105,7 @@ export class ApplicationSettingsStore {
|
||||
parsed = JSON.parse(contents) as unknown
|
||||
} catch {
|
||||
await this.isolateCorruptFile()
|
||||
this.warnings = [{ code: 'application-settings-recovered' }]
|
||||
this.settings = {
|
||||
version: CURRENT_SETTINGS_VERSION,
|
||||
lastSeenReleaseNotesVersion: null,
|
||||
@@ -129,6 +113,12 @@ export class ApplicationSettingsStore {
|
||||
}
|
||||
return this.settings
|
||||
}
|
||||
assertSupportedSettingsVersion(
|
||||
parsed,
|
||||
CURRENT_SETTINGS_VERSION,
|
||||
(version) =>
|
||||
`当前 GoodBuddy 不支持应用设置版本 ${version},请升级应用后重试`
|
||||
)
|
||||
const result = storedApplicationSettingsSchema.safeParse(parsed)
|
||||
if (!result.success) {
|
||||
const versionFourResult =
|
||||
@@ -179,6 +169,7 @@ export class ApplicationSettingsStore {
|
||||
return this.settings
|
||||
}
|
||||
await this.isolateCorruptFile()
|
||||
this.warnings = [{ code: 'application-settings-recovered' }]
|
||||
this.settings = {
|
||||
version: CURRENT_SETTINGS_VERSION,
|
||||
lastSeenReleaseNotesVersion: null,
|
||||
@@ -188,7 +179,10 @@ export class ApplicationSettingsStore {
|
||||
}
|
||||
this.settings = result.data
|
||||
} catch (error) {
|
||||
if (!isMissingFile(error)) {
|
||||
if (error instanceof UnsupportedSettingsVersionError) {
|
||||
throw error
|
||||
}
|
||||
if (!isMissingFileError(error)) {
|
||||
throw new Error('Application settings could not be read', {
|
||||
cause: error
|
||||
})
|
||||
@@ -208,7 +202,10 @@ export class ApplicationSettingsStore {
|
||||
checkUpdatesOnStartup: stored.checkUpdatesOnStartup,
|
||||
magicNotesEnabled: stored.magicNotesEnabled,
|
||||
magicNoteCommentMode: stored.magicNoteCommentMode,
|
||||
magicNoteCommentFormat: stored.magicNoteCommentFormat
|
||||
magicNoteCommentFormat: stored.magicNoteCommentFormat,
|
||||
...(this.warnings.length > 0
|
||||
? { warnings: [...this.warnings] }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,24 +214,7 @@ export class ApplicationSettingsStore {
|
||||
}
|
||||
|
||||
private async persist(next: StoredApplicationSettings): Promise<void> {
|
||||
await mkdir(dirname(this.filePath), { recursive: true })
|
||||
const temporaryPath =
|
||||
`${this.filePath}.${process.pid}.` +
|
||||
`${randomBytes(6).toString('hex')}.tmp`
|
||||
try {
|
||||
await writeFile(
|
||||
temporaryPath,
|
||||
`${JSON.stringify(next, null, 2)}\n`,
|
||||
{
|
||||
encoding: 'utf8',
|
||||
mode: 0o600,
|
||||
flag: 'wx'
|
||||
}
|
||||
)
|
||||
await rename(temporaryPath, this.filePath)
|
||||
} finally {
|
||||
await rm(temporaryPath, { force: true })
|
||||
}
|
||||
await writeJsonFileAtomically(this.filePath, next)
|
||||
this.settings = next
|
||||
}
|
||||
|
||||
@@ -248,6 +228,7 @@ export class ApplicationSettingsStore {
|
||||
version: CURRENT_SETTINGS_VERSION
|
||||
}
|
||||
await this.persist(next)
|
||||
this.warnings = []
|
||||
return {
|
||||
checkUpdatesOnStartup: next.checkUpdatesOnStartup,
|
||||
magicNotesEnabled: next.magicNotesEnabled,
|
||||
|
||||
@@ -1386,7 +1386,7 @@ describe('AssistantDatabase', () => {
|
||||
database.close()
|
||||
})
|
||||
|
||||
it('rebinds persisted conversations whose model profile was removed', async () => {
|
||||
it('repairs unattended channel selections without rebinding ordinary conversations', async () => {
|
||||
const database = await createDatabase()
|
||||
const removedProfileId =
|
||||
'00000000-0000-4000-8000-000000000291'
|
||||
@@ -1478,7 +1478,7 @@ describe('AssistantDatabase', () => {
|
||||
},
|
||||
continueModelSource: { kind: 'platform' }
|
||||
})
|
||||
).toBe(7)
|
||||
).toBe(4)
|
||||
expect(
|
||||
database
|
||||
.listConversations()
|
||||
@@ -1486,9 +1486,9 @@ describe('AssistantDatabase', () => {
|
||||
.sort((left, right) => left.title.localeCompare(right.title))
|
||||
.map((conversation) => conversation.runtimeSelection)
|
||||
).toEqual([
|
||||
{ provider: 'model', profileId: defaultProfileId },
|
||||
{ provider: 'opencode', profileId: runtimeProfileId },
|
||||
{ provider: 'continue' },
|
||||
{ provider: 'model', profileId: removedProfileId },
|
||||
{ provider: 'opencode', profileId: removedProfileId },
|
||||
{ provider: 'continue', profileId: removedProfileId },
|
||||
{ provider: 'model', profileId: runtimeProfileId }
|
||||
])
|
||||
expect(database.getProject(channelProject.id).runtimeSelection).toEqual({
|
||||
|
||||
@@ -42,7 +42,6 @@ import {
|
||||
import {
|
||||
agentRuntimeSelectionKey,
|
||||
agentRuntimeSelectionSchema,
|
||||
repairAgentRuntimeSelection,
|
||||
repairChannelRuntimeSelection,
|
||||
type AgentRuntimeSelection,
|
||||
type RuntimeSelectionRepairSettings
|
||||
@@ -1363,7 +1362,8 @@ export class AssistantDatabase {
|
||||
.prepare(
|
||||
`SELECT id, runtime_selection_json, channel
|
||||
FROM conversations
|
||||
WHERE runtime_selection_json IS NOT NULL`
|
||||
WHERE runtime_selection_json IS NOT NULL
|
||||
AND channel IS NOT NULL`
|
||||
)
|
||||
.all() as Array<{
|
||||
id: string
|
||||
@@ -1410,9 +1410,7 @@ export class AssistantDatabase {
|
||||
if (!current) {
|
||||
continue
|
||||
}
|
||||
const next = conversation.channel
|
||||
? repairChannelRuntimeSelection(current, settings)
|
||||
: repairAgentRuntimeSelection(current, settings)
|
||||
const next = repairChannelRuntimeSelection(current, settings)
|
||||
if (
|
||||
agentRuntimeSelectionKey(next) ===
|
||||
agentRuntimeSelectionKey(current)
|
||||
|
||||
@@ -80,6 +80,32 @@ describe('RemoteDelegationService', () => {
|
||||
).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('shares one in-flight poll between concurrent callers', async () => {
|
||||
let releaseTransport!: () => void
|
||||
const transportReleased = new Promise<void>((resolve) => {
|
||||
releaseTransport = resolve
|
||||
})
|
||||
const transport = vi.fn(async () => {
|
||||
await transportReleased
|
||||
return { status: 204, body: '' }
|
||||
})
|
||||
const service = new RemoteDelegationService({
|
||||
endpoint: 'https://delegate.example',
|
||||
token: 'test-token',
|
||||
lookup: async () => [{ address: '1.1.1.1', family: 4 }],
|
||||
transport,
|
||||
onTask: vi.fn()
|
||||
})
|
||||
|
||||
const first = service.pollOnce()
|
||||
const second = service.pollOnce()
|
||||
await vi.waitFor(() => expect(transport).toHaveBeenCalledOnce())
|
||||
releaseTransport()
|
||||
|
||||
await Promise.all([first, second])
|
||||
expect(transport).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('drains a durable outbox before accepting another task', async () => {
|
||||
const records = new Map<
|
||||
string,
|
||||
@@ -157,7 +183,7 @@ describe('RemoteDelegationService', () => {
|
||||
|
||||
const polling = service.pollOnce()
|
||||
await vi.waitFor(() => expect(observedSignal).toBeDefined())
|
||||
service.stop()
|
||||
await service.stop()
|
||||
|
||||
await expect(polling).rejects.toBeDefined()
|
||||
expect(observedSignal?.aborted).toBe(true)
|
||||
|
||||
@@ -157,7 +157,7 @@ export class RemoteDelegationService {
|
||||
private readonly pendingResults = new Map<string, RemoteResult>()
|
||||
private interval?: NodeJS.Timeout
|
||||
private activeRequest?: AbortController
|
||||
private polling = false
|
||||
private activePoll?: Promise<void>
|
||||
|
||||
constructor(private readonly options: RemoteDelegationOptions) {
|
||||
this.endpoint = normalizeEndpoint(options.endpoint)
|
||||
@@ -179,19 +179,29 @@ export class RemoteDelegationService {
|
||||
void this.pollOnce().catch(() => undefined)
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
async stop(): Promise<void> {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval)
|
||||
this.interval = undefined
|
||||
}
|
||||
this.activeRequest?.abort()
|
||||
await this.activePoll?.catch(() => undefined)
|
||||
}
|
||||
|
||||
async pollOnce(): Promise<void> {
|
||||
if (this.polling) {
|
||||
return
|
||||
pollOnce(): Promise<void> {
|
||||
if (this.activePoll) {
|
||||
return this.activePoll
|
||||
}
|
||||
this.polling = true
|
||||
const operation = this.performPoll()
|
||||
this.activePoll = operation
|
||||
return operation.finally(() => {
|
||||
if (this.activePoll === operation) {
|
||||
this.activePoll = undefined
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async performPoll(): Promise<void> {
|
||||
const controller = new AbortController()
|
||||
this.activeRequest = controller
|
||||
try {
|
||||
@@ -260,7 +270,6 @@ export class RemoteDelegationService {
|
||||
if (this.activeRequest === controller) {
|
||||
this.activeRequest = undefined
|
||||
}
|
||||
this.polling = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,7 @@ import {
|
||||
lstat,
|
||||
mkdir,
|
||||
readFile,
|
||||
realpath,
|
||||
rename,
|
||||
rm,
|
||||
writeFile
|
||||
realpath
|
||||
} from 'node:fs/promises'
|
||||
import { isAbsolute, join, relative, resolve } from 'node:path'
|
||||
import { z } from 'zod'
|
||||
@@ -14,6 +11,10 @@ import {
|
||||
browserProfileIdSchema,
|
||||
browserProfileNameSchema
|
||||
} from '../../shared/capability-contracts'
|
||||
import {
|
||||
isMissingFileError,
|
||||
writeJsonFileAtomically
|
||||
} from '../settings-file-utils'
|
||||
|
||||
const MAX_PROFILES = 32
|
||||
const MAX_REFERENCES = 64
|
||||
@@ -204,12 +205,7 @@ export class FileBrowserProfileStore implements BrowserProfileStore {
|
||||
}
|
||||
return JSON.parse(await readFile(filePath, 'utf8')) as unknown
|
||||
} catch (error) {
|
||||
if (
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
error.code === 'ENOENT'
|
||||
) {
|
||||
if (isMissingFileError(error)) {
|
||||
return undefined
|
||||
}
|
||||
throw error
|
||||
@@ -217,36 +213,22 @@ export class FileBrowserProfileStore implements BrowserProfileStore {
|
||||
}
|
||||
|
||||
async save(state: BrowserProfileState): Promise<void> {
|
||||
const { root, filePath } = await this.prepareRoot()
|
||||
const { filePath } = await this.prepareRoot()
|
||||
try {
|
||||
const targetDetails = await lstat(filePath)
|
||||
if (targetDetails.isSymbolicLink() || !targetDetails.isFile()) {
|
||||
throw new Error('Browser profile storage file must be a regular file')
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
!(
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
error.code === 'ENOENT'
|
||||
)
|
||||
) {
|
||||
if (!isMissingFileError(error)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const temporaryPath = join(root, `.${this.fileName}.${randomUUID()}.tmp`)
|
||||
try {
|
||||
await writeFile(
|
||||
temporaryPath,
|
||||
`${JSON.stringify(browserProfileStateSchema.parse(state), null, 2)}\n`,
|
||||
{ encoding: 'utf8', mode: 0o600, flag: 'wx' }
|
||||
)
|
||||
await rename(temporaryPath, filePath)
|
||||
} finally {
|
||||
await rm(temporaryPath, { force: true })
|
||||
}
|
||||
await writeJsonFileAtomically(
|
||||
filePath,
|
||||
browserProfileStateSchema.parse(state)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import {
|
||||
mkdtemp,
|
||||
mkdir,
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
writeFile
|
||||
} from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { strToU8, zipSync } from 'fflate'
|
||||
@@ -847,6 +854,98 @@ describe('CapabilityService', () => {
|
||||
expect(persisted).toContain('"allowDynamicTools": false')
|
||||
})
|
||||
|
||||
it('preserves capabilities created by a newer unsupported version', async () => {
|
||||
const { directory, filePath, builtinRoot, importedRoot } =
|
||||
await createService()
|
||||
const futureCapabilities = JSON.stringify({
|
||||
version: 99,
|
||||
skills: {
|
||||
'document-writing': {
|
||||
enabled: false,
|
||||
assignments: ['model']
|
||||
}
|
||||
},
|
||||
mcpServers: [{ futureTransport: 'keep-me' }],
|
||||
webSearch: { enabled: false },
|
||||
futureField: 'keep-me'
|
||||
})
|
||||
await writeFile(filePath, futureCapabilities, 'utf8')
|
||||
const service = new CapabilityService(
|
||||
filePath,
|
||||
builtinRoot,
|
||||
importedRoot,
|
||||
cipher
|
||||
)
|
||||
|
||||
await expect(service.getSnapshot()).rejects.toThrow(
|
||||
'不支持能力设置版本 99'
|
||||
)
|
||||
expect(await readFile(filePath, 'utf8')).toBe(futureCapabilities)
|
||||
expect(
|
||||
(await readdir(directory)).some((name) =>
|
||||
name.startsWith('capabilities.json.corrupt-')
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('continues isolating truly corrupt capability settings', async () => {
|
||||
const { directory, filePath, service } = await createService()
|
||||
await writeFile(filePath, '{not-json', 'utf8')
|
||||
|
||||
await expect(service.getSnapshot()).resolves.toMatchObject({
|
||||
webSearch: { enabled: false },
|
||||
mcpServers: [],
|
||||
warnings: [{ code: 'capability-settings-recovered' }]
|
||||
})
|
||||
const entries = await readdir(directory)
|
||||
expect(
|
||||
entries.some((name) =>
|
||||
name.startsWith('capabilities.json.corrupt-')
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('clears the recovery warning after a reviewed capability change', async () => {
|
||||
const { filePath, service } = await createService()
|
||||
await writeFile(filePath, '{not-json', 'utf8')
|
||||
|
||||
await expect(service.getSnapshot()).resolves.toMatchObject({
|
||||
warnings: [{ code: 'capability-settings-recovered' }]
|
||||
})
|
||||
await expect(
|
||||
service.setWebSearchEnabled(true)
|
||||
).resolves.not.toHaveProperty('warnings')
|
||||
})
|
||||
|
||||
it('preserves corrupt capability settings when isolation fails', async () => {
|
||||
const { directory, filePath } = await createService()
|
||||
const corruptContents = '{not-json'
|
||||
await writeFile(filePath, corruptContents, 'utf8')
|
||||
const service = new CapabilityService(
|
||||
filePath,
|
||||
join(directory, 'builtin'),
|
||||
join(directory, 'imported'),
|
||||
cipher,
|
||||
{
|
||||
browserProfiles: new BrowserProfileService(
|
||||
new MemoryBrowserProfileStore()
|
||||
),
|
||||
settingsFileOperations: {
|
||||
rename: vi.fn(async () => {
|
||||
throw Object.assign(new Error('rename denied'), {
|
||||
code: 'EACCES'
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await expect(service.getSnapshot()).rejects.toThrow(
|
||||
'能力设置已损坏且无法隔离'
|
||||
)
|
||||
expect(await readFile(filePath, 'utf8')).toBe(corruptContents)
|
||||
})
|
||||
|
||||
it('gates enablement on the supported platform and architecture', async () => {
|
||||
const { service } = await createService({
|
||||
platform: 'darwin',
|
||||
|
||||
@@ -38,6 +38,21 @@ import {
|
||||
type RuntimeTarget,
|
||||
type SkillSummary
|
||||
} from '../../shared/capability-contracts'
|
||||
import type { SettingsWarning } from '../../shared/settings-warning-contracts'
|
||||
import {
|
||||
assertSupportedSettingsVersion,
|
||||
isolateCorruptSettingsFile,
|
||||
isMissingFileError,
|
||||
type SettingsFileOperations,
|
||||
UnsupportedSettingsVersionError,
|
||||
writeJsonFileAtomically
|
||||
} from '../settings-file-utils'
|
||||
import {
|
||||
decryptSettingsCredential,
|
||||
encryptedSettingsCredentialSchema,
|
||||
encryptSettingsCredential,
|
||||
type SettingsCredentialCipher
|
||||
} from '../settings-credential-cipher'
|
||||
import {
|
||||
BrowserProfileService,
|
||||
FileBrowserProfileStore,
|
||||
@@ -90,13 +105,8 @@ const skillStateSchema = z
|
||||
})
|
||||
.strict()
|
||||
|
||||
const encryptedSecretSchema = z
|
||||
.object({
|
||||
formatVersion: z.literal(1),
|
||||
scheme: z.literal('electron-safe-storage'),
|
||||
ciphertextBase64: z.string()
|
||||
})
|
||||
.optional()
|
||||
const encryptedSecretSchema =
|
||||
encryptedSettingsCredentialSchema.optional()
|
||||
|
||||
const storedMcpCommonShape = {
|
||||
id: mcpServerIdSchema,
|
||||
@@ -199,11 +209,7 @@ const secretPayloadSchema = z
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type CapabilityCipher = {
|
||||
isAvailable: () => boolean
|
||||
encrypt: (value: string) => Buffer
|
||||
decrypt: (value: Buffer) => string
|
||||
}
|
||||
export type CapabilityCipher = SettingsCredentialCipher
|
||||
|
||||
export type ResolvedMcpServer = McpServerSummary & {
|
||||
secret?: string
|
||||
@@ -226,6 +232,7 @@ export type CapabilityServiceOptions = Readonly<{
|
||||
browserProfiles?: BrowserProfileService
|
||||
diagnostics?: CapabilityDiagnostics
|
||||
availableComputerCapabilityImplementations?: readonly ComputerCapabilityImplementationKind[]
|
||||
settingsFileOperations?: Partial<SettingsFileOperations>
|
||||
}>
|
||||
|
||||
function defaultComputerCapabilityStates(): StoredCapabilities['computerCapabilities'] {
|
||||
@@ -241,12 +248,14 @@ function defaultComputerCapabilityStates(): StoredCapabilities['computerCapabili
|
||||
}
|
||||
}
|
||||
|
||||
function emptyStoredCapabilities(): StoredCapabilities {
|
||||
function emptyStoredCapabilities(
|
||||
webSearchEnabled = true
|
||||
): StoredCapabilities {
|
||||
return {
|
||||
version: 4,
|
||||
skills: {},
|
||||
mcpServers: [],
|
||||
webSearch: { enabled: true },
|
||||
webSearch: { enabled: webSearchEnabled },
|
||||
computerCapabilities: defaultComputerCapabilityStates()
|
||||
}
|
||||
}
|
||||
@@ -303,12 +312,7 @@ async function listSkills(
|
||||
try {
|
||||
entries = await readdir(root, { withFileTypes: true })
|
||||
} catch (error) {
|
||||
if (
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
error.code === 'ENOENT'
|
||||
) {
|
||||
if (isMissingFileError(error)) {
|
||||
return []
|
||||
}
|
||||
throw error
|
||||
@@ -550,12 +554,14 @@ async function extractSkillZip(
|
||||
export class CapabilityService {
|
||||
private state?: StoredCapabilities
|
||||
private loadPromise?: Promise<StoredCapabilities>
|
||||
private warnings: SettingsWarning[] = []
|
||||
private updateQueue: Promise<void> = Promise.resolve()
|
||||
private readonly platform: NodeJS.Platform
|
||||
private readonly architecture: string
|
||||
private readonly electronTarget: boolean
|
||||
private readonly browserProfiles: BrowserProfileService
|
||||
private readonly diagnostics: CapabilityDiagnostics
|
||||
private readonly settingsFileOperations?: Partial<SettingsFileOperations>
|
||||
private readonly availableComputerCapabilityImplementations: ReadonlySet<ComputerCapabilityImplementationKind>
|
||||
|
||||
constructor(
|
||||
@@ -574,6 +580,7 @@ export class CapabilityService {
|
||||
'managed-browser-driver'
|
||||
]
|
||||
)
|
||||
this.settingsFileOperations = options.settingsFileOperations
|
||||
this.browserProfiles =
|
||||
options.browserProfiles ??
|
||||
new BrowserProfileService(
|
||||
@@ -635,6 +642,9 @@ export class CapabilityService {
|
||||
let shouldPersist = false
|
||||
try {
|
||||
const raw = JSON.parse(await readFile(this.filePath, 'utf8')) as unknown
|
||||
assertSupportedSettingsVersion(raw, 4, (version) =>
|
||||
`当前 GoodBuddy 不支持能力设置版本 ${version},请升级应用后重试`
|
||||
)
|
||||
const version = z
|
||||
.object({
|
||||
version: z.union([
|
||||
@@ -676,19 +686,21 @@ export class CapabilityService {
|
||||
loaded = storedCapabilitiesSchema.parse(raw)
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
error.code === 'ENOENT'
|
||||
) {
|
||||
if (error instanceof UnsupportedSettingsVersionError) {
|
||||
throw error
|
||||
}
|
||||
if (isMissingFileError(error)) {
|
||||
loaded = emptyStoredCapabilities()
|
||||
} else {
|
||||
await rename(
|
||||
await isolateCorruptSettingsFile(
|
||||
this.filePath,
|
||||
`${this.filePath}.corrupt-${Date.now()}`
|
||||
).catch(() => undefined)
|
||||
loaded = emptyStoredCapabilities()
|
||||
'能力设置已损坏且无法隔离',
|
||||
Date.now,
|
||||
this.settingsFileOperations
|
||||
)
|
||||
this.warnings = [{ code: 'capability-settings-recovered' }]
|
||||
loaded = emptyStoredCapabilities(false)
|
||||
shouldPersist = true
|
||||
}
|
||||
}
|
||||
const migrateMcpAssignments = loaded.mcpServers.some((server) =>
|
||||
@@ -741,17 +753,27 @@ export class CapabilityService {
|
||||
|
||||
private async persist(state: StoredCapabilities): Promise<void> {
|
||||
const validated = storedCapabilitiesSchema.parse(state)
|
||||
await mkdir(dirname(this.filePath), { recursive: true })
|
||||
const temporaryPath = `${this.filePath}.${process.pid}.tmp`
|
||||
await writeFile(
|
||||
temporaryPath,
|
||||
`${JSON.stringify(validated, null, 2)}\n`,
|
||||
{ encoding: 'utf8', mode: 0o600 }
|
||||
await writeJsonFileAtomically(
|
||||
this.filePath,
|
||||
validated,
|
||||
this.settingsFileOperations
|
||||
)
|
||||
await rename(temporaryPath, this.filePath)
|
||||
this.state = validated
|
||||
}
|
||||
|
||||
private clearRecoveryWarnings(): void {
|
||||
this.warnings = this.warnings.filter(
|
||||
(warning) => warning.code !== 'capability-settings-recovered'
|
||||
)
|
||||
}
|
||||
|
||||
private async persistUserChange(
|
||||
state: StoredCapabilities
|
||||
): Promise<void> {
|
||||
await this.persist(state)
|
||||
this.clearRecoveryWarnings()
|
||||
}
|
||||
|
||||
private async getSkillCatalog(): Promise<
|
||||
Array<Omit<SkillSummary, 'enabled' | 'assignments'>>
|
||||
> {
|
||||
@@ -823,7 +845,10 @@ export class CapabilityService {
|
||||
riskSummary: capability.riskSummary
|
||||
})
|
||||
),
|
||||
browserProfiles: this.toBrowserProfilesSummary(browserProfileState)
|
||||
browserProfiles: this.toBrowserProfilesSummary(browserProfileState),
|
||||
...(this.warnings.length > 0
|
||||
? { warnings: [...this.warnings] }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -835,7 +860,7 @@ export class CapabilityService {
|
||||
setWebSearchEnabled(enabled: boolean): Promise<CapabilitySnapshot> {
|
||||
return this.queue(async () => {
|
||||
const state = await this.load()
|
||||
await this.persist({
|
||||
await this.persistUserChange({
|
||||
...state,
|
||||
webSearch: { enabled }
|
||||
})
|
||||
@@ -898,7 +923,7 @@ export class CapabilityService {
|
||||
}
|
||||
}
|
||||
const state = await this.load()
|
||||
await this.persist({
|
||||
await this.persistUserChange({
|
||||
...state,
|
||||
computerCapabilities: {
|
||||
...state.computerCapabilities,
|
||||
@@ -965,7 +990,7 @@ export class CapabilityService {
|
||||
}
|
||||
}
|
||||
try {
|
||||
await this.persist(nextState)
|
||||
await this.persistUserChange(nextState)
|
||||
} catch (error) {
|
||||
if (profileId) {
|
||||
try {
|
||||
@@ -992,7 +1017,7 @@ export class CapabilityService {
|
||||
previousProfileId,
|
||||
reference
|
||||
)
|
||||
await this.persist(state)
|
||||
await this.persistUserChange(state)
|
||||
if (profileId) {
|
||||
await this.browserProfiles.removeReference(
|
||||
profileId,
|
||||
@@ -1064,6 +1089,7 @@ export class CapabilityService {
|
||||
await this.browserProfiles.createProfile(
|
||||
browserProfileNameSchema.parse(name)
|
||||
)
|
||||
this.clearRecoveryWarnings()
|
||||
return this.getSnapshot()
|
||||
})
|
||||
}
|
||||
@@ -1077,6 +1103,7 @@ export class CapabilityService {
|
||||
browserProfileIdSchema.parse(profileId),
|
||||
browserProfileNameSchema.parse(name)
|
||||
)
|
||||
this.clearRecoveryWarnings()
|
||||
return this.getSnapshot()
|
||||
})
|
||||
}
|
||||
@@ -1086,6 +1113,7 @@ export class CapabilityService {
|
||||
await this.browserProfiles.setDefaultProfile(
|
||||
browserProfileIdSchema.parse(profileId)
|
||||
)
|
||||
this.clearRecoveryWarnings()
|
||||
return this.getSnapshot()
|
||||
})
|
||||
}
|
||||
@@ -1095,6 +1123,7 @@ export class CapabilityService {
|
||||
await this.browserProfiles.deleteProfile(
|
||||
browserProfileIdSchema.parse(profileId)
|
||||
)
|
||||
this.clearRecoveryWarnings()
|
||||
return this.getSnapshot()
|
||||
})
|
||||
}
|
||||
@@ -1125,7 +1154,7 @@ export class CapabilityService {
|
||||
await readSkill(temporaryPath, 'imported', skill.id)
|
||||
await rename(temporaryPath, targetPath)
|
||||
const state = await this.load()
|
||||
await this.persist({
|
||||
await this.persistUserChange({
|
||||
...state,
|
||||
skills: {
|
||||
...state.skills,
|
||||
@@ -1223,7 +1252,7 @@ export class CapabilityService {
|
||||
const state = await this.load()
|
||||
const skills = { ...state.skills }
|
||||
delete skills[id]
|
||||
await this.persist({ ...state, skills })
|
||||
await this.persistUserChange({ ...state, skills })
|
||||
return this.getSnapshot()
|
||||
})
|
||||
}
|
||||
@@ -1255,7 +1284,7 @@ export class CapabilityService {
|
||||
throw new Error('Skill 不存在')
|
||||
}
|
||||
const state = await this.load()
|
||||
await this.persist({
|
||||
await this.persistUserChange({
|
||||
...state,
|
||||
skills: {
|
||||
...state.skills,
|
||||
@@ -1311,19 +1340,11 @@ export class CapabilityService {
|
||||
if (!this.cipher.isAvailable()) {
|
||||
throw new Error('系统安全存储不可用,MCP 访问令牌未保存')
|
||||
}
|
||||
credential = {
|
||||
formatVersion: 1 as const,
|
||||
scheme: 'electron-safe-storage' as const,
|
||||
ciphertextBase64: this.cipher
|
||||
.encrypt(
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
serverId: id,
|
||||
secret: value.secret.value
|
||||
})
|
||||
)
|
||||
.toString('base64')
|
||||
}
|
||||
credential = encryptSettingsCredential(this.cipher, {
|
||||
version: 1,
|
||||
serverId: id,
|
||||
secret: value.secret.value
|
||||
})
|
||||
}
|
||||
const stored: StoredMcpServer =
|
||||
value.transport === 'stdio'
|
||||
@@ -1354,7 +1375,7 @@ export class CapabilityService {
|
||||
server.id === id ? stored : server
|
||||
)
|
||||
: [...state.mcpServers, stored]
|
||||
await this.persist({ ...state, mcpServers: nextServers })
|
||||
await this.persistUserChange({ ...state, mcpServers: nextServers })
|
||||
return this.getSnapshot()
|
||||
})
|
||||
}
|
||||
@@ -1366,7 +1387,7 @@ export class CapabilityService {
|
||||
if (!state.mcpServers.some((server) => server.id === id)) {
|
||||
throw new Error('MCP Server 不存在')
|
||||
}
|
||||
await this.persist({
|
||||
await this.persistUserChange({
|
||||
...state,
|
||||
mcpServers: state.mcpServers.filter((server) => server.id !== id)
|
||||
})
|
||||
@@ -1388,11 +1409,7 @@ export class CapabilityService {
|
||||
}
|
||||
try {
|
||||
const payload = secretPayloadSchema.parse(
|
||||
JSON.parse(
|
||||
this.cipher.decrypt(
|
||||
Buffer.from(server.credential.ciphertextBase64, 'base64')
|
||||
)
|
||||
)
|
||||
decryptSettingsCredential(this.cipher, server.credential)
|
||||
)
|
||||
if (payload.serverId === id) {
|
||||
secret = payload.secret
|
||||
|
||||
@@ -192,10 +192,14 @@ describe('ChannelSettingsStore', () => {
|
||||
)
|
||||
|
||||
const initial = await store.snapshot()
|
||||
expect(initial.warning).toContain('已损坏')
|
||||
expect(initial.warnings).toContainEqual({
|
||||
code: 'channel-settings-recovered'
|
||||
})
|
||||
expect(
|
||||
await readdir(join(filePath, '..'))
|
||||
).toContain('channel-settings.json.corrupt-1234')
|
||||
(await readdir(join(filePath, '..'))).some((name) =>
|
||||
name.startsWith('channel-settings.json.corrupt-1234-')
|
||||
)
|
||||
).toBe(true)
|
||||
|
||||
await store.apply({
|
||||
dingtalk: {
|
||||
@@ -215,6 +219,9 @@ describe('ChannelSettingsStore', () => {
|
||||
expect((await readdir(join(filePath, '..'))).some(
|
||||
(name) => name.endsWith('.tmp')
|
||||
)).toBe(false)
|
||||
await expect(store.snapshot()).resolves.not.toHaveProperty(
|
||||
'warnings'
|
||||
)
|
||||
})
|
||||
|
||||
it('encrypts Weixin binding credentials and removes them on disconnect', async () => {
|
||||
@@ -251,4 +258,293 @@ describe('ChannelSettingsStore', () => {
|
||||
})
|
||||
expect((await store.resolve('weixin')).token).toBeUndefined()
|
||||
})
|
||||
|
||||
it('defers version 2 Weixin migration until safe storage recovers', async () => {
|
||||
const filePath = await settingsPath()
|
||||
let available = false
|
||||
const cipher = createCipher()
|
||||
const dynamicCipher: ChannelCredentialCipher = {
|
||||
...cipher,
|
||||
isAvailable: () => available
|
||||
}
|
||||
const legacyCredential = {
|
||||
formatVersion: 1,
|
||||
scheme: 'electron-safe-storage',
|
||||
ciphertextBase64: cipher
|
||||
.encrypt(
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
channel: 'weixin',
|
||||
secret: 'legacy-weixin-token'
|
||||
})
|
||||
)
|
||||
.toString('base64')
|
||||
}
|
||||
const legacySettings = JSON.stringify({
|
||||
version: 2,
|
||||
weixin: {
|
||||
enabled: true,
|
||||
credential: legacyCredential,
|
||||
accountId: 'account-legacy',
|
||||
userId: 'user-legacy',
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com'
|
||||
},
|
||||
wecom: {
|
||||
enabled: false,
|
||||
botId: '',
|
||||
allowedSenderIds: [],
|
||||
allowGroupMessages: false
|
||||
},
|
||||
dingtalk: {
|
||||
enabled: false,
|
||||
clientId: '',
|
||||
allowedSenderIds: [],
|
||||
allowGroupMessages: false
|
||||
}
|
||||
})
|
||||
await writeFile(filePath, legacySettings, 'utf8')
|
||||
const store = new ChannelSettingsStore(filePath, dynamicCipher, {})
|
||||
|
||||
await expect(store.snapshot()).rejects.toThrow(
|
||||
'安全存储暂不可用'
|
||||
)
|
||||
expect(await readFile(filePath, 'utf8')).toBe(legacySettings)
|
||||
expect(
|
||||
(await readdir(join(filePath, '..'))).some((name) =>
|
||||
name.startsWith('channel-settings.json.corrupt-')
|
||||
)
|
||||
).toBe(false)
|
||||
|
||||
available = true
|
||||
await expect(store.snapshot()).resolves.toMatchObject({
|
||||
weixin: {
|
||||
enabled: true,
|
||||
bindingConfigured: true,
|
||||
source: 'encrypted'
|
||||
}
|
||||
})
|
||||
await expect(store.resolve('weixin')).resolves.toMatchObject({
|
||||
accountId: 'account-legacy',
|
||||
userId: 'user-legacy',
|
||||
token: 'legacy-weixin-token'
|
||||
})
|
||||
expect(
|
||||
JSON.parse(await readFile(filePath, 'utf8'))
|
||||
).toMatchObject({
|
||||
version: 3,
|
||||
weixin: {
|
||||
enabled: true,
|
||||
credential: expect.any(Object)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves settings created by a newer unsupported version', async () => {
|
||||
const filePath = await settingsPath()
|
||||
const futureSettings = JSON.stringify({
|
||||
version: 99,
|
||||
futureField: 'keep-me'
|
||||
})
|
||||
await writeFile(filePath, futureSettings, 'utf8')
|
||||
const store = new ChannelSettingsStore(
|
||||
filePath,
|
||||
createCipher(),
|
||||
{}
|
||||
)
|
||||
|
||||
await expect(store.snapshot()).rejects.toThrow(
|
||||
'不支持通道设置版本 99'
|
||||
)
|
||||
expect(await readFile(filePath, 'utf8')).toBe(futureSettings)
|
||||
expect(
|
||||
(await readdir(join(filePath, '..'))).some((name) =>
|
||||
name.startsWith('channel-settings.json.corrupt-')
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('does not start Weixin with a temporarily unavailable credential', async () => {
|
||||
const filePath = await settingsPath()
|
||||
const availableStore = new ChannelSettingsStore(
|
||||
filePath,
|
||||
createCipher(),
|
||||
{}
|
||||
)
|
||||
await availableStore.saveWeixinBinding({
|
||||
accountId: 'account-123',
|
||||
userId: 'user-123',
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com',
|
||||
token: 'private-token'
|
||||
})
|
||||
|
||||
const unavailableStore = new ChannelSettingsStore(
|
||||
filePath,
|
||||
createCipher(false),
|
||||
{}
|
||||
)
|
||||
await expect(unavailableStore.resolve('weixin')).resolves.toMatchObject({
|
||||
enabled: false,
|
||||
source: 'none'
|
||||
})
|
||||
await expect(unavailableStore.snapshot()).resolves.toMatchObject({
|
||||
weixin: {
|
||||
enabled: false,
|
||||
bindingConfigured: false
|
||||
},
|
||||
warnings: expect.arrayContaining([
|
||||
{ code: 'channel-weixin-secure-storage-unavailable' }
|
||||
])
|
||||
})
|
||||
expect(
|
||||
JSON.parse(await readFile(filePath, 'utf8'))
|
||||
).toMatchObject({
|
||||
version: 3,
|
||||
weixin: {
|
||||
enabled: true,
|
||||
credential: expect.any(Object)
|
||||
}
|
||||
})
|
||||
|
||||
await unavailableStore.apply({
|
||||
wecom: {
|
||||
enabled: false,
|
||||
botId: 'bot-id',
|
||||
secret: { action: 'keep' },
|
||||
allowedSenderIds: [],
|
||||
allowGroupMessages: false
|
||||
}
|
||||
})
|
||||
expect(
|
||||
JSON.parse(await readFile(filePath, 'utf8'))
|
||||
).toMatchObject({
|
||||
weixin: {
|
||||
enabled: true,
|
||||
credential: expect.any(Object)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('distinguishes unreadable channel credentials from missing secrets', async () => {
|
||||
const filePath = await settingsPath()
|
||||
const availableStore = new ChannelSettingsStore(
|
||||
filePath,
|
||||
createCipher(),
|
||||
{}
|
||||
)
|
||||
await availableStore.apply({
|
||||
wecom: {
|
||||
enabled: false,
|
||||
botId: 'bot-id',
|
||||
secret: { action: 'replace', value: 'private-secret' },
|
||||
allowedSenderIds: ['sender-a'],
|
||||
allowGroupMessages: false
|
||||
}
|
||||
})
|
||||
const unreadableStore = new ChannelSettingsStore(
|
||||
filePath,
|
||||
{
|
||||
...createCipher(),
|
||||
decrypt: () => {
|
||||
throw new Error('cannot decrypt')
|
||||
}
|
||||
},
|
||||
{}
|
||||
)
|
||||
|
||||
await expect(unreadableStore.snapshot()).resolves.toMatchObject({
|
||||
wecom: {
|
||||
secretConfigured: false,
|
||||
source: 'unreadable'
|
||||
},
|
||||
warnings: expect.arrayContaining([
|
||||
{ code: 'channel-wecom-credential-unreadable' }
|
||||
])
|
||||
})
|
||||
|
||||
await unreadableStore.apply({
|
||||
wecom: {
|
||||
enabled: false,
|
||||
botId: 'replacement-bot',
|
||||
secret: { action: 'clear' },
|
||||
allowedSenderIds: ['sender-a'],
|
||||
allowGroupMessages: false
|
||||
}
|
||||
})
|
||||
await expect(unreadableStore.snapshot()).resolves.toMatchObject({
|
||||
wecom: {
|
||||
source: 'none'
|
||||
}
|
||||
})
|
||||
expect(
|
||||
(await unreadableStore.snapshot()).warnings ?? []
|
||||
).not.toContainEqual({
|
||||
code: 'channel-wecom-credential-unreadable'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['wecom', 'dingtalk'] as const)(
|
||||
'clears an unreadable %s credential warning after decryption recovers',
|
||||
async (channel) => {
|
||||
const filePath = await settingsPath()
|
||||
const availableCipher = createCipher()
|
||||
const availableStore = new ChannelSettingsStore(
|
||||
filePath,
|
||||
availableCipher,
|
||||
{}
|
||||
)
|
||||
await availableStore.apply(
|
||||
channel === 'wecom'
|
||||
? {
|
||||
wecom: {
|
||||
enabled: false,
|
||||
botId: 'bot-id',
|
||||
secret: { action: 'replace', value: 'private-secret' },
|
||||
allowedSenderIds: ['sender-a'],
|
||||
allowGroupMessages: false
|
||||
}
|
||||
}
|
||||
: {
|
||||
dingtalk: {
|
||||
enabled: false,
|
||||
clientId: 'client-id',
|
||||
secret: { action: 'replace', value: 'private-secret' },
|
||||
allowedSenderIds: ['sender-a'],
|
||||
allowGroupMessages: false
|
||||
}
|
||||
}
|
||||
)
|
||||
let decryptAvailable = false
|
||||
const recoveringStore = new ChannelSettingsStore(
|
||||
filePath,
|
||||
{
|
||||
...availableCipher,
|
||||
decrypt: (value) => {
|
||||
if (!decryptAvailable) {
|
||||
throw new Error('secure storage is temporarily unavailable')
|
||||
}
|
||||
return availableCipher.decrypt(value)
|
||||
}
|
||||
},
|
||||
{}
|
||||
)
|
||||
const warningCode =
|
||||
channel === 'wecom'
|
||||
? 'channel-wecom-credential-unreadable'
|
||||
: 'channel-dingtalk-credential-unreadable'
|
||||
|
||||
await expect(recoveringStore.snapshot()).resolves.toMatchObject({
|
||||
[channel]: { source: 'unreadable' },
|
||||
warnings: expect.arrayContaining([{ code: warningCode }])
|
||||
})
|
||||
|
||||
decryptAvailable = true
|
||||
await expect(recoveringStore.resolve(channel)).resolves.toMatchObject({
|
||||
source: 'encrypted',
|
||||
secret: 'private-secret'
|
||||
})
|
||||
expect((await recoveringStore.snapshot()).warnings ?? []).not.toContainEqual(
|
||||
{ code: warningCode }
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import {
|
||||
mkdir,
|
||||
readFile,
|
||||
rename,
|
||||
rm,
|
||||
writeFile
|
||||
} from 'node:fs/promises'
|
||||
import { dirname } from 'node:path'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
CHANNEL_SETTINGS_LIMITS,
|
||||
@@ -21,17 +13,28 @@ import {
|
||||
type WeComChannelSettingsInput
|
||||
} from '../../shared/channel-settings-contracts'
|
||||
import { weixinAccountDisplay } from '../../shared/weixin-channel-contracts'
|
||||
import {
|
||||
settingsWarningsEqual,
|
||||
type SettingsWarning
|
||||
} from '../../shared/settings-warning-contracts'
|
||||
import {
|
||||
assertSupportedSettingsVersion,
|
||||
isolateCorruptSettingsFile,
|
||||
isMissingFileError,
|
||||
UnsupportedSettingsVersionError,
|
||||
writeJsonFileAtomically
|
||||
} from '../settings-file-utils'
|
||||
import {
|
||||
decryptSettingsCredential,
|
||||
encryptedSettingsCredentialSchema,
|
||||
encryptSettingsCredential,
|
||||
type SettingsCredentialCipher
|
||||
} from '../settings-credential-cipher'
|
||||
|
||||
export interface ChannelCredentialCipher {
|
||||
isAvailable(): boolean
|
||||
encrypt(value: string): Buffer
|
||||
decrypt(value: Buffer): string
|
||||
}
|
||||
export type ChannelCredentialCipher = SettingsCredentialCipher
|
||||
|
||||
const encryptedCredentialSchema = z
|
||||
.object({
|
||||
formatVersion: z.literal(1),
|
||||
scheme: z.literal('electron-safe-storage'),
|
||||
const encryptedCredentialSchema = encryptedSettingsCredentialSchema
|
||||
.extend({
|
||||
ciphertextBase64: z
|
||||
.string()
|
||||
.min(1)
|
||||
@@ -112,6 +115,8 @@ type StoredEncryptedCredential = z.infer<
|
||||
typeof encryptedCredentialSchema
|
||||
>
|
||||
|
||||
class DeferredWeixinMigrationError extends Error {}
|
||||
|
||||
const credentialPayloadSchema = z
|
||||
.object({
|
||||
version: z.literal(1),
|
||||
@@ -161,7 +166,7 @@ type EnvironmentChannel = {
|
||||
secret?: string
|
||||
allowedSenderIds: readonly string[]
|
||||
allowGroupMessages: boolean
|
||||
error?: string
|
||||
warning?: SettingsWarning
|
||||
}
|
||||
|
||||
export type ResolvedChannelSettings =
|
||||
@@ -184,7 +189,7 @@ export type ResolvedChannelSettings =
|
||||
secret?: string
|
||||
allowedSenderIds: readonly string[]
|
||||
allowGroupMessages: boolean
|
||||
source: 'none' | 'encrypted' | 'environment'
|
||||
source: 'none' | 'encrypted' | 'environment' | 'unreadable'
|
||||
readOnly: boolean
|
||||
}
|
||||
| {
|
||||
@@ -194,7 +199,7 @@ export type ResolvedChannelSettings =
|
||||
secret?: string
|
||||
allowedSenderIds: readonly string[]
|
||||
allowGroupMessages: boolean
|
||||
source: 'none' | 'encrypted' | 'environment'
|
||||
source: 'none' | 'encrypted' | 'environment' | 'unreadable'
|
||||
readOnly: boolean
|
||||
}
|
||||
|
||||
@@ -221,15 +226,6 @@ const defaultStatus = (enabled: boolean): ChannelRuntimeStatus => ({
|
||||
state: enabled ? 'stopped' : 'disabled'
|
||||
})
|
||||
|
||||
function isMissingFile(error: unknown): boolean {
|
||||
return (
|
||||
error !== null &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
error.code === 'ENOENT'
|
||||
)
|
||||
}
|
||||
|
||||
function boundedEnvironmentValue(
|
||||
environment: NodeJS.ProcessEnv,
|
||||
name: string,
|
||||
@@ -319,15 +315,27 @@ export type WeixinBinding = z.infer<typeof weixinBindingSchema>
|
||||
|
||||
export class ChannelSettingsStore {
|
||||
private settings?: StoredSettings
|
||||
private warning?: string
|
||||
private settingsLoad?: Promise<StoredSettings>
|
||||
private temporarilyDisabledWeixin = false
|
||||
private warnings: SettingsWarning[] = []
|
||||
private runtimeRepairWarning?: SettingsWarning
|
||||
private updateQueue: Promise<void> = Promise.resolve()
|
||||
private readonly environmentChannels: Record<
|
||||
CredentialChannel,
|
||||
EnvironmentChannel
|
||||
>
|
||||
|
||||
constructor(
|
||||
private readonly filePath: string,
|
||||
private readonly cipher: ChannelCredentialCipher,
|
||||
private readonly environment: NodeJS.ProcessEnv = process.env,
|
||||
private readonly now: () => number = Date.now
|
||||
) {}
|
||||
) {
|
||||
this.environmentChannels = {
|
||||
wecom: this.readEnvironmentChannel('wecom'),
|
||||
dingtalk: this.readEnvironmentChannel('dingtalk')
|
||||
}
|
||||
}
|
||||
|
||||
async snapshot(
|
||||
statuses: Partial<Record<ManagedChannel, ChannelRuntimeStatus>> = {}
|
||||
@@ -339,9 +347,17 @@ export class ChannelSettingsStore {
|
||||
])
|
||||
const weComEnvironment = this.environmentChannel('wecom')
|
||||
const dingTalkEnvironment = this.environmentChannel('dingtalk')
|
||||
const environmentWarning =
|
||||
weComEnvironment.error ?? dingTalkEnvironment.error
|
||||
const warning = this.warning ?? environmentWarning
|
||||
const warnings = [
|
||||
...this.warnings,
|
||||
...(this.runtimeRepairWarning ? [this.runtimeRepairWarning] : []),
|
||||
...(weComEnvironment.warning ? [weComEnvironment.warning] : []),
|
||||
...(dingTalkEnvironment.warning ? [dingTalkEnvironment.warning] : [])
|
||||
].filter(
|
||||
(warning, index, values) =>
|
||||
values.findIndex(
|
||||
(candidate) => settingsWarningsEqual(candidate, warning)
|
||||
) === index
|
||||
)
|
||||
return {
|
||||
weixin: {
|
||||
enabled: weixin.enabled,
|
||||
@@ -360,12 +376,9 @@ export class ChannelSettingsStore {
|
||||
allowGroupMessages: wecom.allowGroupMessages,
|
||||
status:
|
||||
statuses.wecom ??
|
||||
(weComEnvironment.error === undefined
|
||||
(weComEnvironment.warning === undefined
|
||||
? defaultStatus(wecom.enabled)
|
||||
: {
|
||||
state: 'error',
|
||||
lastError: weComEnvironment.error
|
||||
})
|
||||
: { state: 'error' })
|
||||
},
|
||||
dingtalk: {
|
||||
enabled: dingtalk.enabled,
|
||||
@@ -377,17 +390,24 @@ export class ChannelSettingsStore {
|
||||
allowGroupMessages: dingtalk.allowGroupMessages,
|
||||
status:
|
||||
statuses.dingtalk ??
|
||||
(dingTalkEnvironment.error === undefined
|
||||
(dingTalkEnvironment.warning === undefined
|
||||
? defaultStatus(dingtalk.enabled)
|
||||
: {
|
||||
state: 'error',
|
||||
lastError: dingTalkEnvironment.error
|
||||
})
|
||||
: { state: 'error' })
|
||||
},
|
||||
...(warning === undefined ? {} : { warning })
|
||||
...(warnings.length > 0 ? { warnings } : {})
|
||||
}
|
||||
}
|
||||
|
||||
reportRuntimeSelectionRepairs(count: number): void {
|
||||
this.runtimeRepairWarning =
|
||||
count > 0
|
||||
? {
|
||||
code: 'channel-runtime-selections-repaired',
|
||||
count
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
||||
getSnapshot(
|
||||
statuses?: Partial<Record<ManagedChannel, ChannelRuntimeStatus>>
|
||||
): Promise<ChannelSettingsSnapshot> {
|
||||
@@ -409,9 +429,16 @@ export class ChannelSettingsStore {
|
||||
const settings = await this.load()
|
||||
const stored = settings.weixin
|
||||
const binding = this.decryptWeixinBinding(stored)
|
||||
if (this.temporarilyDisabledWeixin && binding) {
|
||||
this.temporarilyDisabledWeixin = false
|
||||
this.removeWarnings([
|
||||
'channel-weixin-credential-unreadable',
|
||||
'channel-weixin-secure-storage-unavailable'
|
||||
])
|
||||
}
|
||||
return {
|
||||
channel,
|
||||
enabled: stored.enabled,
|
||||
enabled: stored.enabled && !this.temporarilyDisabledWeixin,
|
||||
accountId: binding?.accountId ?? '',
|
||||
userId: binding?.userId ?? '',
|
||||
baseUrl: binding?.baseUrl ?? '',
|
||||
@@ -448,12 +475,18 @@ export class ChannelSettingsStore {
|
||||
const settings = await this.load()
|
||||
const stored = settings[channel]
|
||||
const secret = this.decryptCredential(channel, stored)
|
||||
const credentialUnreadable =
|
||||
stored.credential !== undefined && secret === undefined
|
||||
const common = {
|
||||
enabled: stored.enabled,
|
||||
...(secret === undefined ? {} : { secret }),
|
||||
allowedSenderIds: [...stored.allowedSenderIds],
|
||||
allowGroupMessages: stored.allowGroupMessages,
|
||||
source: secret === undefined ? ('none' as const) : ('encrypted' as const),
|
||||
source: credentialUnreadable
|
||||
? ('unreadable' as const)
|
||||
: secret === undefined
|
||||
? ('none' as const)
|
||||
: ('encrypted' as const),
|
||||
readOnly: false
|
||||
}
|
||||
return channel === 'wecom'
|
||||
@@ -484,7 +517,12 @@ export class ChannelSettingsStore {
|
||||
}
|
||||
await this.persist(current)
|
||||
this.settings = current
|
||||
this.warning = undefined
|
||||
this.temporarilyDisabledWeixin = false
|
||||
this.removeWarnings([
|
||||
'channel-weixin-credential-unreadable',
|
||||
'channel-weixin-secure-storage-unavailable',
|
||||
'channel-weixin-legacy-binding-invalid'
|
||||
])
|
||||
snapshot = await this.snapshot()
|
||||
}
|
||||
const operation = this.updateQueue.then(update, update)
|
||||
@@ -504,7 +542,12 @@ export class ChannelSettingsStore {
|
||||
}
|
||||
await this.persist(current)
|
||||
this.settings = current
|
||||
this.warning = undefined
|
||||
this.temporarilyDisabledWeixin = false
|
||||
this.removeWarnings([
|
||||
'channel-weixin-credential-unreadable',
|
||||
'channel-weixin-secure-storage-unavailable',
|
||||
'channel-weixin-legacy-binding-invalid'
|
||||
])
|
||||
snapshot = await this.snapshot()
|
||||
}
|
||||
const operation = this.updateQueue.then(update, update)
|
||||
@@ -557,12 +600,30 @@ export class ChannelSettingsStore {
|
||||
)
|
||||
}
|
||||
|
||||
this.validateEnabledWeixin(current.weixin)
|
||||
if (!this.temporarilyDisabledWeixin || input.weixin !== undefined) {
|
||||
this.validateEnabledWeixin(current.weixin)
|
||||
}
|
||||
this.validateEnabledCredentialChannel('wecom', current.wecom)
|
||||
this.validateEnabledCredentialChannel('dingtalk', current.dingtalk)
|
||||
await this.persist(current)
|
||||
this.settings = current
|
||||
this.warning = undefined
|
||||
if (!this.temporarilyDisabledWeixin) {
|
||||
this.removeWarnings([
|
||||
'channel-weixin-credential-unreadable',
|
||||
'channel-weixin-secure-storage-unavailable',
|
||||
'channel-weixin-legacy-binding-invalid'
|
||||
])
|
||||
}
|
||||
const resolvedWarningCodes: SettingsWarning['code'][] = [
|
||||
'channel-settings-recovered'
|
||||
]
|
||||
if (input.wecom !== undefined) {
|
||||
resolvedWarningCodes.push('channel-wecom-credential-unreadable')
|
||||
}
|
||||
if (input.dingtalk !== undefined) {
|
||||
resolvedWarningCodes.push('channel-dingtalk-credential-unreadable')
|
||||
}
|
||||
this.removeWarnings(resolvedWarningCodes)
|
||||
return this.snapshot()
|
||||
}
|
||||
|
||||
@@ -652,34 +713,47 @@ export class ChannelSettingsStore {
|
||||
if (!this.cipher.isAvailable()) {
|
||||
throw new Error('系统安全存储不可用,无法保存通道 Secret')
|
||||
}
|
||||
const encrypted = this.cipher.encrypt(
|
||||
JSON.stringify({ version: 1, channel, secret })
|
||||
)
|
||||
return {
|
||||
formatVersion: 1,
|
||||
scheme: 'electron-safe-storage',
|
||||
ciphertextBase64: encrypted.toString('base64')
|
||||
}
|
||||
return encryptSettingsCredential(this.cipher, {
|
||||
version: 1,
|
||||
channel,
|
||||
secret
|
||||
})
|
||||
}
|
||||
|
||||
private decryptCredential(
|
||||
channel: CredentialChannel,
|
||||
stored: StoredCredentialChannel
|
||||
): string | undefined {
|
||||
if (stored.credential === undefined || !this.cipher.isAvailable()) {
|
||||
if (stored.credential === undefined) {
|
||||
return undefined
|
||||
}
|
||||
const warn = (): undefined => {
|
||||
this.addWarning({
|
||||
code:
|
||||
channel === 'wecom'
|
||||
? 'channel-wecom-credential-unreadable'
|
||||
: 'channel-dingtalk-credential-unreadable'
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
if (!this.cipher.isAvailable()) {
|
||||
return warn()
|
||||
}
|
||||
try {
|
||||
const payload = credentialPayloadSchema.parse(
|
||||
JSON.parse(
|
||||
this.cipher.decrypt(
|
||||
Buffer.from(stored.credential.ciphertextBase64, 'base64')
|
||||
)
|
||||
)
|
||||
decryptSettingsCredential(this.cipher, stored.credential)
|
||||
)
|
||||
return payload.channel === channel ? payload.secret : undefined
|
||||
if (payload.channel !== channel) {
|
||||
return warn()
|
||||
}
|
||||
this.removeWarnings([
|
||||
channel === 'wecom'
|
||||
? 'channel-wecom-credential-unreadable'
|
||||
: 'channel-dingtalk-credential-unreadable'
|
||||
])
|
||||
return payload.secret
|
||||
} catch {
|
||||
return undefined
|
||||
return warn()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -689,21 +763,14 @@ export class ChannelSettingsStore {
|
||||
if (!this.cipher.isAvailable()) {
|
||||
throw new Error('系统安全存储不可用,无法保存微信绑定')
|
||||
}
|
||||
const encrypted = this.cipher.encrypt(
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
channel: 'weixin',
|
||||
accountId: binding.accountId,
|
||||
userId: binding.userId,
|
||||
baseUrl: binding.baseUrl,
|
||||
token: binding.token
|
||||
})
|
||||
)
|
||||
return {
|
||||
formatVersion: 1,
|
||||
scheme: 'electron-safe-storage',
|
||||
ciphertextBase64: encrypted.toString('base64')
|
||||
}
|
||||
return encryptSettingsCredential(this.cipher, {
|
||||
version: 2,
|
||||
channel: 'weixin',
|
||||
accountId: binding.accountId,
|
||||
userId: binding.userId,
|
||||
baseUrl: binding.baseUrl,
|
||||
token: binding.token
|
||||
})
|
||||
}
|
||||
|
||||
private decryptWeixinBinding(
|
||||
@@ -714,81 +781,38 @@ export class ChannelSettingsStore {
|
||||
}
|
||||
try {
|
||||
return weixinCredentialPayloadSchema.parse(
|
||||
JSON.parse(
|
||||
this.cipher.decrypt(
|
||||
Buffer.from(stored.credential.ciphertextBase64, 'base64')
|
||||
)
|
||||
)
|
||||
decryptSettingsCredential(this.cipher, stored.credential)
|
||||
)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
private async load(): Promise<StoredSettings> {
|
||||
private load(): Promise<StoredSettings> {
|
||||
if (this.settings !== undefined) {
|
||||
return this.settings
|
||||
return Promise.resolve(this.settings)
|
||||
}
|
||||
if (!this.settingsLoad) {
|
||||
this.settingsLoad = this.readSettings().finally(() => {
|
||||
this.settingsLoad = undefined
|
||||
})
|
||||
}
|
||||
return this.settingsLoad
|
||||
}
|
||||
|
||||
private async readSettings(): Promise<StoredSettings> {
|
||||
try {
|
||||
const raw: unknown = JSON.parse(await readFile(this.filePath, 'utf8'))
|
||||
assertSupportedSettingsVersion(raw, 3, (version) =>
|
||||
`当前 GoodBuddy 不支持通道设置版本 ${version},请升级应用后重试`
|
||||
)
|
||||
const current = storedSettingsSchema.safeParse(raw)
|
||||
if (current.success) {
|
||||
this.settings = current.data
|
||||
this.settings = this.normalizeStoredSettings(current.data)
|
||||
} else {
|
||||
const versionTwo = versionTwoStoredSettingsSchema.safeParse(raw)
|
||||
if (versionTwo.success) {
|
||||
const legacyWeixin = versionTwo.data.weixin
|
||||
let token: string | undefined
|
||||
if (
|
||||
legacyWeixin.credential &&
|
||||
this.cipher.isAvailable()
|
||||
) {
|
||||
try {
|
||||
const payload = credentialPayloadSchema.parse(
|
||||
JSON.parse(
|
||||
this.cipher.decrypt(
|
||||
Buffer.from(
|
||||
legacyWeixin.credential.ciphertextBase64,
|
||||
'base64'
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
token =
|
||||
payload.channel === 'weixin'
|
||||
? payload.secret
|
||||
: undefined
|
||||
} catch {
|
||||
token = undefined
|
||||
}
|
||||
}
|
||||
const binding =
|
||||
token &&
|
||||
legacyWeixin.accountId &&
|
||||
legacyWeixin.userId &&
|
||||
legacyWeixin.baseUrl
|
||||
? {
|
||||
accountId: legacyWeixin.accountId,
|
||||
userId: legacyWeixin.userId,
|
||||
baseUrl: legacyWeixin.baseUrl,
|
||||
token
|
||||
}
|
||||
: undefined
|
||||
this.settings = {
|
||||
version: 3,
|
||||
weixin: {
|
||||
enabled: binding ? legacyWeixin.enabled : false,
|
||||
...(binding
|
||||
? { credential: this.encryptWeixinBinding(binding) }
|
||||
: {})
|
||||
},
|
||||
wecom: versionTwo.data.wecom,
|
||||
dingtalk: versionTwo.data.dingtalk
|
||||
}
|
||||
if (legacyWeixin.enabled && !binding) {
|
||||
this.warning =
|
||||
'旧版微信绑定无法安全迁移,请重新扫码绑定'
|
||||
}
|
||||
this.settings = this.migrateVersionTwo(versionTwo.data)
|
||||
} else {
|
||||
const legacy = legacyStoredSettingsSchema.parse(raw)
|
||||
this.settings = {
|
||||
@@ -803,38 +827,114 @@ export class ChannelSettingsStore {
|
||||
await this.persist(this.settings)
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isMissingFile(error)) {
|
||||
this.warning = '通道设置文件已损坏,已隔离原文件并恢复默认设置'
|
||||
await rename(
|
||||
if (
|
||||
error instanceof UnsupportedSettingsVersionError ||
|
||||
error instanceof DeferredWeixinMigrationError
|
||||
) {
|
||||
throw error
|
||||
}
|
||||
if (!isMissingFileError(error)) {
|
||||
await isolateCorruptSettingsFile(
|
||||
this.filePath,
|
||||
`${this.filePath}.corrupt-${this.now()}`
|
||||
).catch(() => undefined)
|
||||
'通道设置已损坏且无法隔离',
|
||||
this.now
|
||||
)
|
||||
this.warnings = [{ code: 'channel-settings-recovered' }]
|
||||
}
|
||||
this.settings = cloneStored(defaultStoredSettings)
|
||||
}
|
||||
return this.settings
|
||||
}
|
||||
|
||||
private async persist(settings: StoredSettings): Promise<void> {
|
||||
await mkdir(dirname(this.filePath), { recursive: true })
|
||||
const temporaryPath = `${this.filePath}.${process.pid}.${randomUUID()}.tmp`
|
||||
try {
|
||||
await writeFile(
|
||||
temporaryPath,
|
||||
`${JSON.stringify(settings, null, 2)}\n`,
|
||||
{
|
||||
encoding: 'utf8',
|
||||
mode: 0o600,
|
||||
flag: 'wx'
|
||||
}
|
||||
private normalizeStoredSettings(settings: StoredSettings): StoredSettings {
|
||||
if (
|
||||
settings.weixin.credential &&
|
||||
this.decryptWeixinBinding(settings.weixin) === undefined
|
||||
) {
|
||||
this.temporarilyDisabledWeixin = true
|
||||
this.addWarning({
|
||||
code: this.cipher.isAvailable()
|
||||
? 'channel-weixin-credential-unreadable'
|
||||
: 'channel-weixin-secure-storage-unavailable'
|
||||
})
|
||||
} else {
|
||||
this.temporarilyDisabledWeixin = false
|
||||
}
|
||||
return settings
|
||||
}
|
||||
|
||||
private migrateVersionTwo(
|
||||
settings: z.infer<typeof versionTwoStoredSettingsSchema>
|
||||
): StoredSettings {
|
||||
const legacyWeixin = settings.weixin
|
||||
if (legacyWeixin.credential && !this.cipher.isAvailable()) {
|
||||
throw new DeferredWeixinMigrationError(
|
||||
'系统安全存储暂不可用,旧版微信绑定尚未迁移;原设置已保留,请恢复安全存储后重试'
|
||||
)
|
||||
await rename(temporaryPath, this.filePath)
|
||||
} finally {
|
||||
await rm(temporaryPath, { force: true })
|
||||
}
|
||||
let token: string | undefined
|
||||
if (legacyWeixin.credential) {
|
||||
try {
|
||||
const payload = credentialPayloadSchema.parse(
|
||||
decryptSettingsCredential(
|
||||
this.cipher,
|
||||
legacyWeixin.credential
|
||||
)
|
||||
)
|
||||
token =
|
||||
payload.channel === 'weixin' ? payload.secret : undefined
|
||||
} catch {
|
||||
throw new DeferredWeixinMigrationError(
|
||||
'旧版微信绑定无法解密,原设置已保留;请恢复原安全存储后重试'
|
||||
)
|
||||
}
|
||||
}
|
||||
const binding =
|
||||
token &&
|
||||
legacyWeixin.accountId &&
|
||||
legacyWeixin.userId &&
|
||||
legacyWeixin.baseUrl
|
||||
? {
|
||||
accountId: legacyWeixin.accountId,
|
||||
userId: legacyWeixin.userId,
|
||||
baseUrl: legacyWeixin.baseUrl,
|
||||
token
|
||||
}
|
||||
: undefined
|
||||
if (legacyWeixin.credential && !binding) {
|
||||
throw new DeferredWeixinMigrationError(
|
||||
'旧版微信绑定信息不完整或无法验证,原设置已保留;请恢复原配置后重试'
|
||||
)
|
||||
}
|
||||
if (legacyWeixin.enabled && !binding) {
|
||||
this.addWarning({
|
||||
code: 'channel-weixin-legacy-binding-invalid'
|
||||
})
|
||||
}
|
||||
return {
|
||||
version: 3,
|
||||
weixin: {
|
||||
enabled: binding ? legacyWeixin.enabled : false,
|
||||
...(binding
|
||||
? { credential: this.encryptWeixinBinding(binding) }
|
||||
: {})
|
||||
},
|
||||
wecom: settings.wecom,
|
||||
dingtalk: settings.dingtalk
|
||||
}
|
||||
}
|
||||
|
||||
private async persist(settings: StoredSettings): Promise<void> {
|
||||
await writeJsonFileAtomically(this.filePath, settings)
|
||||
}
|
||||
|
||||
private environmentChannel(channel: CredentialChannel): EnvironmentChannel {
|
||||
return this.environmentChannels[channel]
|
||||
}
|
||||
|
||||
private readEnvironmentChannel(
|
||||
channel: CredentialChannel
|
||||
): EnvironmentChannel {
|
||||
const prefix =
|
||||
channel === 'wecom' ? 'GOODBUDDY_WECOM' : 'GOODBUDDY_DINGTALK'
|
||||
const idName =
|
||||
@@ -903,11 +1003,31 @@ export class ChannelSettingsStore {
|
||||
senders.value.length > 0
|
||||
? {}
|
||||
: {
|
||||
error:
|
||||
channel === 'wecom'
|
||||
? '企业微信环境变量配置无效或不完整'
|
||||
: '钉钉环境变量配置无效或不完整'
|
||||
warning: {
|
||||
code:
|
||||
channel === 'wecom'
|
||||
? 'channel-wecom-environment-invalid'
|
||||
: 'channel-dingtalk-environment-invalid'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private addWarning(warning: SettingsWarning): void {
|
||||
if (
|
||||
!this.warnings.some(
|
||||
(current) => settingsWarningsEqual(current, warning)
|
||||
)
|
||||
) {
|
||||
this.warnings.push(warning)
|
||||
}
|
||||
}
|
||||
|
||||
private removeWarnings(
|
||||
codes: readonly SettingsWarning['code'][]
|
||||
): void {
|
||||
this.warnings = this.warnings.filter(
|
||||
(warning) => !codes.includes(warning.code)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { WechatBindingController } from './wechat-binding-controller'
|
||||
import type { WechatSidecarChild } from './wechat-sidecar-client'
|
||||
|
||||
function createDeferred(): {
|
||||
promise: Promise<void>
|
||||
resolve: () => void
|
||||
} {
|
||||
let resolve!: () => void
|
||||
const promise = new Promise<void>((done) => {
|
||||
resolve = done
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('WechatBindingController', () => {
|
||||
it('coalesces duplicate credential messages from the same login', async () => {
|
||||
const saveReleased = createDeferred()
|
||||
const saveWeixinBinding = vi.fn(async () => {
|
||||
await saveReleased.promise
|
||||
return {} as never
|
||||
})
|
||||
let messageListener: ((message: unknown) => void) | undefined
|
||||
const child: WechatSidecarChild = {
|
||||
postMessage: vi.fn(),
|
||||
kill: vi.fn(() => true),
|
||||
on: vi.fn((_event, listener) => {
|
||||
messageListener = listener
|
||||
return child
|
||||
}),
|
||||
once: vi.fn(() => child)
|
||||
}
|
||||
const onChanged = vi.fn(async () => undefined)
|
||||
const controller = new WechatBindingController(
|
||||
{ saveWeixinBinding } as never,
|
||||
() => child,
|
||||
onChanged,
|
||||
vi.fn()
|
||||
)
|
||||
const credential = {
|
||||
type: 'credential' as const,
|
||||
accountId: 'account-1',
|
||||
userId: 'user-1',
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com',
|
||||
token: 'binding-token'
|
||||
}
|
||||
|
||||
controller.start()
|
||||
messageListener?.(credential)
|
||||
messageListener?.(credential)
|
||||
await vi.waitFor(() =>
|
||||
expect(saveWeixinBinding).toHaveBeenCalledOnce()
|
||||
)
|
||||
saveReleased.resolve()
|
||||
await controller.stop()
|
||||
|
||||
expect(saveWeixinBinding).toHaveBeenCalledOnce()
|
||||
expect(onChanged).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('accepts only the first credential from one login generation', async () => {
|
||||
const firstSaveStarted = createDeferred()
|
||||
const firstSaveReleased = createDeferred()
|
||||
const saveWeixinBinding = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(async () => {
|
||||
firstSaveStarted.resolve()
|
||||
await firstSaveReleased.promise
|
||||
return {} as never
|
||||
})
|
||||
let messageListener: ((message: unknown) => void) | undefined
|
||||
const child: WechatSidecarChild = {
|
||||
postMessage: vi.fn(),
|
||||
kill: vi.fn(() => true),
|
||||
on: vi.fn((_event, listener) => {
|
||||
messageListener = listener
|
||||
return child
|
||||
}),
|
||||
once: vi.fn(() => child)
|
||||
}
|
||||
const onChanged = vi.fn(async () => undefined)
|
||||
const controller = new WechatBindingController(
|
||||
{ saveWeixinBinding } as never,
|
||||
() => child,
|
||||
onChanged,
|
||||
vi.fn()
|
||||
)
|
||||
|
||||
controller.start()
|
||||
messageListener?.({
|
||||
type: 'credential',
|
||||
accountId: 'account-1',
|
||||
userId: 'user-1',
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com',
|
||||
token: 'binding-token-1'
|
||||
})
|
||||
messageListener?.({
|
||||
type: 'credential',
|
||||
accountId: 'account-2',
|
||||
userId: 'user-2',
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com',
|
||||
token: 'binding-token-2'
|
||||
})
|
||||
await firstSaveStarted.promise
|
||||
|
||||
expect(() => controller.start()).toThrow(
|
||||
'微信绑定凭据正在保存,请稍后重试'
|
||||
)
|
||||
|
||||
let stopped = false
|
||||
const stop = controller.stop().then(() => {
|
||||
stopped = true
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(stopped).toBe(false)
|
||||
|
||||
firstSaveReleased.resolve()
|
||||
await stop
|
||||
expect(saveWeixinBinding).toHaveBeenCalledOnce()
|
||||
expect(onChanged).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('waits for an in-flight credential save when stopping', async () => {
|
||||
const saveStarted = createDeferred()
|
||||
const saveReleased = createDeferred()
|
||||
const saveWeixinBinding = vi.fn(async () => {
|
||||
saveStarted.resolve()
|
||||
await saveReleased.promise
|
||||
return {} as never
|
||||
})
|
||||
let messageListener: ((message: unknown) => void) | undefined
|
||||
const child: WechatSidecarChild = {
|
||||
postMessage: vi.fn(),
|
||||
kill: vi.fn(() => true),
|
||||
on: vi.fn((_event, listener) => {
|
||||
messageListener = listener
|
||||
return child
|
||||
}),
|
||||
once: vi.fn(() => child)
|
||||
}
|
||||
const onChanged = vi.fn(async () => undefined)
|
||||
const controller = new WechatBindingController(
|
||||
{ saveWeixinBinding } as never,
|
||||
() => child,
|
||||
onChanged,
|
||||
vi.fn()
|
||||
)
|
||||
|
||||
controller.start()
|
||||
messageListener?.({
|
||||
type: 'credential',
|
||||
accountId: 'account-1',
|
||||
userId: 'user-1',
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com',
|
||||
token: 'binding-token'
|
||||
})
|
||||
await saveStarted.promise
|
||||
|
||||
let stopped = false
|
||||
const stop = controller.stop().then(() => {
|
||||
stopped = true
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(stopped).toBe(false)
|
||||
|
||||
saveReleased.resolve()
|
||||
await stop
|
||||
expect(saveWeixinBinding).toHaveBeenCalledOnce()
|
||||
expect(onChanged).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -69,10 +69,11 @@ export class WechatBindingController {
|
||||
return this.snapshot()
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
async stop(): Promise<void> {
|
||||
this.generation += 1
|
||||
this.stopClient()
|
||||
this.snapshotValue = { status: 'stopped' }
|
||||
await this.credentialSave
|
||||
}
|
||||
|
||||
private handleMessage(
|
||||
@@ -83,12 +84,13 @@ export class WechatBindingController {
|
||||
return
|
||||
}
|
||||
if (message.type === 'credential') {
|
||||
if (this.savingCredential) {
|
||||
return
|
||||
}
|
||||
this.savingCredential = true
|
||||
this.credentialSave = this.credentialSave
|
||||
this.stopClient()
|
||||
const save = this.credentialSave
|
||||
.then(async () => {
|
||||
if (generation !== this.generation) {
|
||||
return
|
||||
}
|
||||
this.stopClient()
|
||||
await this.store.saveWeixinBinding({
|
||||
accountId: message.accountId,
|
||||
@@ -120,9 +122,13 @@ export class WechatBindingController {
|
||||
: '微信绑定保存失败'
|
||||
})
|
||||
})
|
||||
const trackedSave = save
|
||||
.finally(() => {
|
||||
this.savingCredential = false
|
||||
if (this.credentialSave === trackedSave) {
|
||||
this.savingCredential = false
|
||||
}
|
||||
})
|
||||
this.credentialSave = trackedSave
|
||||
return
|
||||
}
|
||||
if (message.type === 'qr') {
|
||||
|
||||
@@ -170,7 +170,10 @@ export class DocumentParsingService {
|
||||
conversionAvailable: false,
|
||||
localOcr
|
||||
},
|
||||
ocrModels
|
||||
ocrModels,
|
||||
...(this.settingsStore.getWarnings().length > 0
|
||||
? { warnings: [...this.settingsStore.getWarnings()] }
|
||||
: {})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ describe('DocumentParsingSettingsStore', () => {
|
||||
await expect(store.get()).resolves.toEqual(
|
||||
defaultDocumentParsingSettings
|
||||
)
|
||||
expect(store.getWarnings()).toEqual([])
|
||||
await expect(readdir(directory)).resolves.toEqual([])
|
||||
})
|
||||
|
||||
@@ -143,10 +144,32 @@ describe('DocumentParsingSettingsStore', () => {
|
||||
await expect(store.get()).resolves.toEqual(
|
||||
defaultDocumentParsingSettings
|
||||
)
|
||||
expect(store.getWarnings()).toEqual([
|
||||
{ code: 'document-parsing-settings-recovered' }
|
||||
])
|
||||
const entries = await readdir(directory)
|
||||
expect(entries).toHaveLength(1)
|
||||
expect(entries[0]).toMatch(
|
||||
/^document-parsing-settings\.json\.corrupt-\d+-[a-f0-9]{12}$/u
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves settings created by a newer unsupported version', async () => {
|
||||
const { directory, filePath, store } = await createStore()
|
||||
const futureSettings = JSON.stringify({
|
||||
version: 99,
|
||||
futureField: 'keep-me'
|
||||
})
|
||||
await writeFile(filePath, futureSettings, 'utf8')
|
||||
|
||||
await expect(store.get()).rejects.toThrow(
|
||||
'不支持文档解析设置版本 99'
|
||||
)
|
||||
expect(await readFile(filePath, 'utf8')).toBe(futureSettings)
|
||||
expect(
|
||||
(await readdir(directory)).some((name) =>
|
||||
name.startsWith('document-parsing-settings.json.corrupt-')
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import {
|
||||
mkdir,
|
||||
readFile,
|
||||
rename,
|
||||
rm,
|
||||
writeFile
|
||||
} from 'node:fs/promises'
|
||||
import { dirname } from 'node:path'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
documentParsingSettingsSchema,
|
||||
documentParsingSettingsUpdateSchema,
|
||||
type DocumentParsingSettings
|
||||
} from '../shared/document-parsing-contracts'
|
||||
import type { SettingsWarning } from '../shared/settings-warning-contracts'
|
||||
import {
|
||||
assertSupportedSettingsVersion,
|
||||
isolateCorruptSettingsFile,
|
||||
isMissingFileError,
|
||||
UnsupportedSettingsVersionError,
|
||||
writeJsonFileAtomically
|
||||
} from './settings-file-utils'
|
||||
|
||||
const CURRENT_SETTINGS_VERSION = 3
|
||||
|
||||
@@ -96,40 +96,34 @@ function migrateLegacySettings(
|
||||
}
|
||||
}
|
||||
|
||||
function isMissingFile(error: unknown): boolean {
|
||||
return (
|
||||
error !== null &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
error.code === 'ENOENT'
|
||||
)
|
||||
}
|
||||
|
||||
export class DocumentParsingSettingsStore {
|
||||
private settings?: StoredDocumentParsingSettings
|
||||
private settingsLoad?: Promise<StoredDocumentParsingSettings>
|
||||
private warnings: SettingsWarning[] = []
|
||||
private updateQueue: Promise<void> = Promise.resolve()
|
||||
|
||||
constructor(private readonly filePath: string) {}
|
||||
|
||||
private async isolateCorruptFile(): Promise<void> {
|
||||
const isolatedPath =
|
||||
`${this.filePath}.corrupt-${Date.now()}-` +
|
||||
randomBytes(6).toString('hex')
|
||||
try {
|
||||
await rename(this.filePath, isolatedPath)
|
||||
} catch (error) {
|
||||
if (!isMissingFile(error)) {
|
||||
throw new Error('文档解析设置损坏且无法隔离', {
|
||||
cause: error
|
||||
})
|
||||
}
|
||||
}
|
||||
await isolateCorruptSettingsFile(
|
||||
this.filePath,
|
||||
'文档解析设置损坏且无法隔离'
|
||||
)
|
||||
}
|
||||
|
||||
private async loadStored(): Promise<StoredDocumentParsingSettings> {
|
||||
private loadStored(): Promise<StoredDocumentParsingSettings> {
|
||||
if (this.settings) {
|
||||
return this.settings
|
||||
return Promise.resolve(this.settings)
|
||||
}
|
||||
if (!this.settingsLoad) {
|
||||
this.settingsLoad = this.readStored().finally(() => {
|
||||
this.settingsLoad = undefined
|
||||
})
|
||||
}
|
||||
return this.settingsLoad
|
||||
}
|
||||
|
||||
private async readStored(): Promise<StoredDocumentParsingSettings> {
|
||||
try {
|
||||
const contents = await readFile(this.filePath, 'utf8')
|
||||
let parsed: unknown
|
||||
@@ -137,12 +131,19 @@ export class DocumentParsingSettingsStore {
|
||||
parsed = JSON.parse(contents) as unknown
|
||||
} catch {
|
||||
await this.isolateCorruptFile()
|
||||
this.warnings = [{ code: 'document-parsing-settings-recovered' }]
|
||||
this.settings = {
|
||||
version: CURRENT_SETTINGS_VERSION,
|
||||
...defaultDocumentParsingSettings
|
||||
}
|
||||
return this.settings
|
||||
}
|
||||
assertSupportedSettingsVersion(
|
||||
parsed,
|
||||
CURRENT_SETTINGS_VERSION,
|
||||
(version) =>
|
||||
`当前 GoodBuddy 不支持文档解析设置版本 ${version},请升级应用后重试`
|
||||
)
|
||||
const result =
|
||||
storedDocumentParsingSettingsSchema.safeParse(parsed)
|
||||
if (!result.success) {
|
||||
@@ -170,6 +171,7 @@ export class DocumentParsingSettingsStore {
|
||||
return this.settings
|
||||
}
|
||||
await this.isolateCorruptFile()
|
||||
this.warnings = [{ code: 'document-parsing-settings-recovered' }]
|
||||
this.settings = {
|
||||
version: CURRENT_SETTINGS_VERSION,
|
||||
...defaultDocumentParsingSettings
|
||||
@@ -178,7 +180,10 @@ export class DocumentParsingSettingsStore {
|
||||
}
|
||||
this.settings = result.data
|
||||
} catch (error) {
|
||||
if (!isMissingFile(error)) {
|
||||
if (error instanceof UnsupportedSettingsVersionError) {
|
||||
throw error
|
||||
}
|
||||
if (!isMissingFileError(error)) {
|
||||
throw new Error('无法读取文档解析设置', { cause: error })
|
||||
}
|
||||
this.settings = {
|
||||
@@ -195,6 +200,10 @@ export class DocumentParsingSettingsStore {
|
||||
return documentParsingSettingsSchema.parse(settings)
|
||||
}
|
||||
|
||||
getWarnings(): readonly SettingsWarning[] {
|
||||
return this.warnings
|
||||
}
|
||||
|
||||
update(input: unknown): Promise<DocumentParsingSettings> {
|
||||
const operation = this.updateQueue.then(async () => {
|
||||
const updates = documentParsingSettingsUpdateSchema.parse(input)
|
||||
@@ -202,25 +211,9 @@ export class DocumentParsingSettingsStore {
|
||||
version: CURRENT_SETTINGS_VERSION,
|
||||
...updates
|
||||
}
|
||||
await mkdir(dirname(this.filePath), { recursive: true })
|
||||
const temporaryPath =
|
||||
`${this.filePath}.${process.pid}.` +
|
||||
`${randomBytes(6).toString('hex')}.tmp`
|
||||
try {
|
||||
await writeFile(
|
||||
temporaryPath,
|
||||
`${JSON.stringify(next, null, 2)}\n`,
|
||||
{
|
||||
encoding: 'utf8',
|
||||
mode: 0o600,
|
||||
flag: 'wx'
|
||||
}
|
||||
)
|
||||
await rename(temporaryPath, this.filePath)
|
||||
} finally {
|
||||
await rm(temporaryPath, { force: true })
|
||||
}
|
||||
await writeJsonFileAtomically(this.filePath, next)
|
||||
this.settings = next
|
||||
this.warnings = []
|
||||
return this.get()
|
||||
})
|
||||
this.updateQueue = operation.then(
|
||||
|
||||
+68
-43
@@ -65,7 +65,10 @@ import { SpeechModelManager } from './speech/speech-model-manager'
|
||||
import { SpeechTranscriptionService } from './speech/speech-transcription-service'
|
||||
import { GlobalTlsPolicy } from './global-tls-policy'
|
||||
import type { AgentRuntimeSelection } from '../shared/runtime-selection-contracts'
|
||||
import { waitForCleanup } from './shutdown'
|
||||
import {
|
||||
runCleanupBeforeDeadline,
|
||||
settleCleanupPhases
|
||||
} from './shutdown'
|
||||
import { DocumentParsingSettingsStore } from './document-parsing-settings-store'
|
||||
import { DocumentOcrModelManager } from './document-ocr-model-manager'
|
||||
import { DocumentOcrBroker } from './document-ocr-broker'
|
||||
@@ -104,6 +107,7 @@ let browserService: BrowserService | undefined
|
||||
let globalTlsPolicy: GlobalTlsPolicy | undefined
|
||||
let documentOcrBroker: DocumentOcrBroker | undefined
|
||||
let documentOcrModelManager: DocumentOcrModelManager | undefined
|
||||
let stopRuntimeReconfiguration: (() => Promise<void>) | undefined
|
||||
|
||||
function createEmbeddingProvider(
|
||||
settings: ResolvedRuntimeSettings
|
||||
@@ -425,8 +429,10 @@ if (hasSingleInstanceLock) {
|
||||
defaultWorkspace,
|
||||
initialRuntimeSettings.defaultModelProfileId
|
||||
)
|
||||
assistantDatabase.repairConversationRuntimeSelections(
|
||||
initialRuntimeSettings
|
||||
channelSettingsStore.reportRuntimeSelectionRepairs(
|
||||
assistantDatabase.repairConversationRuntimeSelections(
|
||||
initialRuntimeSettings
|
||||
)
|
||||
)
|
||||
knowledgeGateway = new KnowledgeMcpGateway(knowledgeService, {
|
||||
magicNotesDatabase: assistantDatabase
|
||||
@@ -483,8 +489,11 @@ if (hasSingleInstanceLock) {
|
||||
webSearchEnabled: webSearchCapability?.enabled
|
||||
})
|
||||
}
|
||||
const createConfiguredRuntime = async (): Promise<AgentRuntime> => {
|
||||
const settings = await settingsStore.getResolvedSettings()
|
||||
const createConfiguredRuntime = async (
|
||||
resolvedSettings?: ResolvedRuntimeSettings
|
||||
): Promise<AgentRuntime> => {
|
||||
const settings =
|
||||
resolvedSettings ?? await settingsStore.getResolvedSettings()
|
||||
return createRuntimeWithCapabilities(
|
||||
settings,
|
||||
getConfiguredRuntimeTarget(settings)
|
||||
@@ -522,6 +531,41 @@ if (hasSingleInstanceLock) {
|
||||
}
|
||||
})
|
||||
|
||||
let runtimeReconfigurationQueue: Promise<void> = Promise.resolve()
|
||||
let runtimeReconfigurationClosing = false
|
||||
const reconfigureRuntimes = (): Promise<void> => {
|
||||
const operation = runtimeReconfigurationQueue.then(async () => {
|
||||
if (runtimeReconfigurationClosing) {
|
||||
throw new Error('Runtime 配置正在关闭')
|
||||
}
|
||||
const settings = await settingsStore.getResolvedSettings()
|
||||
if (knowledgeService) {
|
||||
await knowledgeService.setEmbeddingProvider(
|
||||
createEmbeddingProvider(settings)
|
||||
)
|
||||
await knowledgeService.setRerankProvider(
|
||||
createRerankProvider(settings)
|
||||
)
|
||||
}
|
||||
if (runtime) {
|
||||
await runtime.replace(
|
||||
await createConfiguredRuntime(settings)
|
||||
)
|
||||
}
|
||||
await selectedRuntimeManager?.reset()
|
||||
await subagentService.replaceRuntimes(
|
||||
createDefaultModelRuntime(defaultWorkspace, settings),
|
||||
createSubagentProfileRuntimes(defaultWorkspace, settings)
|
||||
)
|
||||
})
|
||||
runtimeReconfigurationQueue = operation.catch(() => undefined)
|
||||
return operation
|
||||
}
|
||||
stopRuntimeReconfiguration = async () => {
|
||||
runtimeReconfigurationClosing = true
|
||||
await runtimeReconfigurationQueue
|
||||
}
|
||||
|
||||
removeIpcHandlers = registerIpcHandlers(
|
||||
mainWindow,
|
||||
runtime,
|
||||
@@ -533,27 +577,7 @@ if (hasSingleInstanceLock) {
|
||||
assistantDatabase,
|
||||
approvalBroker,
|
||||
bundledRuntimePaths,
|
||||
async () => {
|
||||
const settings = await settingsStore.getResolvedSettings()
|
||||
if (knowledgeService) {
|
||||
void knowledgeService
|
||||
.setEmbeddingProvider(createEmbeddingProvider(settings))
|
||||
.catch(() => undefined)
|
||||
void knowledgeService
|
||||
.setRerankProvider(createRerankProvider(settings))
|
||||
.catch(() => undefined)
|
||||
}
|
||||
if (runtime) {
|
||||
await runtime.replace(
|
||||
await createConfiguredRuntime()
|
||||
)
|
||||
}
|
||||
await selectedRuntimeManager?.reset()
|
||||
await subagentService.replaceRuntimes(
|
||||
createDefaultModelRuntime(defaultWorkspace, settings),
|
||||
createSubagentProfileRuntimes(defaultWorkspace, settings)
|
||||
)
|
||||
},
|
||||
reconfigureRuntimes,
|
||||
async () => {
|
||||
await browserService?.clearSessions()
|
||||
},
|
||||
@@ -604,27 +628,28 @@ app.on('before-quit', (event) => {
|
||||
cleanupStarted = true
|
||||
void (async () => {
|
||||
try {
|
||||
const cleanup = Promise.allSettled([
|
||||
Promise.resolve().then(() => removeIpcHandlers?.()),
|
||||
Promise.resolve().then(() => runtime?.dispose()),
|
||||
Promise.resolve().then(() => selectedRuntimeManager?.dispose()),
|
||||
Promise.resolve().then(() => knowledgeGateway?.dispose()),
|
||||
Promise.resolve().then(() => knowledgeService?.dispose()),
|
||||
Promise.resolve().then(() => browserService?.dispose()),
|
||||
Promise.resolve().then(() => globalTlsPolicy?.dispose()),
|
||||
Promise.resolve().then(() => documentOcrModelManager?.dispose()),
|
||||
Promise.resolve().then(() => documentOcrBroker?.dispose())
|
||||
const cleanup = settleCleanupPhases([
|
||||
[() => removeIpcHandlers?.()],
|
||||
[() => stopRuntimeReconfiguration?.()],
|
||||
[
|
||||
() => runtime?.dispose(),
|
||||
() => selectedRuntimeManager?.dispose(),
|
||||
() => browserService?.dispose(),
|
||||
() => globalTlsPolicy?.dispose(),
|
||||
() => documentOcrModelManager?.dispose(),
|
||||
() => documentOcrBroker?.dispose()
|
||||
],
|
||||
[() => knowledgeGateway?.dispose()],
|
||||
[() => knowledgeService?.dispose()]
|
||||
])
|
||||
globalShortcut.unregisterAll()
|
||||
tray?.destroy()
|
||||
await waitForCleanup(cleanup, 8_000)
|
||||
} finally {
|
||||
try {
|
||||
await runCleanupBeforeDeadline(cleanup, 8_000, () => {
|
||||
assistantDatabase?.close()
|
||||
} finally {
|
||||
cleanupComplete = true
|
||||
app.exit(0)
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
cleanupComplete = true
|
||||
app.exit(0)
|
||||
}
|
||||
})()
|
||||
})
|
||||
|
||||
+697
-6
@@ -10,6 +10,12 @@ import { AssistantDatabase } from './assistant/assistant-database'
|
||||
import { registerIpcHandlers } from './ipc'
|
||||
|
||||
type InvokeHandler = (event: unknown, input?: unknown) => unknown
|
||||
type KnowledgeGrantMock = (
|
||||
requestId: string,
|
||||
libraryIds: readonly string[],
|
||||
signal: AbortSignal,
|
||||
access: 'read' | 'write'
|
||||
) => string
|
||||
|
||||
const electronMocks = vi.hoisted(() => {
|
||||
const handlers = new Map<string, InvokeHandler>()
|
||||
@@ -274,6 +280,18 @@ describe('registerIpcHandlers computer capabilities', () => {
|
||||
).toThrow()
|
||||
expect(capabilityService.createBrowserProfile).not.toHaveBeenCalled()
|
||||
|
||||
await expect(
|
||||
electronMocks.handlers.get(
|
||||
ipcChannels.capabilitiesCreateBrowserProfile
|
||||
)?.(event, {
|
||||
name: '工作配置'
|
||||
})
|
||||
).resolves.toEqual(snapshot)
|
||||
expect(capabilityService.createBrowserProfile).toHaveBeenCalledWith(
|
||||
'工作配置'
|
||||
)
|
||||
expect(onRuntimeSettingsChanged).toHaveBeenCalledTimes(3)
|
||||
|
||||
expect(() =>
|
||||
electronMocks.handlers.get(
|
||||
ipcChannels.capabilitiesDiagnoseComputer
|
||||
@@ -339,6 +357,163 @@ vi.mock('./channels/channel-env', () => ({
|
||||
)
|
||||
}))
|
||||
|
||||
describe('registerIpcHandlers lifecycle tracking', () => {
|
||||
afterEach(() => {
|
||||
electronMocks.handlers.clear()
|
||||
vi.clearAllMocks()
|
||||
channelMocks.stop.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('waits for a pending settings update and Runtime reload during cleanup', async () => {
|
||||
let releaseUpdate!: () => void
|
||||
const updateReleased = new Promise<void>((resolve) => {
|
||||
releaseUpdate = resolve
|
||||
})
|
||||
const workspace = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-ipc-settings-')
|
||||
)
|
||||
const webContents = {
|
||||
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
||||
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
||||
send: vi.fn()
|
||||
}
|
||||
const window = {
|
||||
webContents,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
isMaximized: vi.fn(() => false),
|
||||
on: vi.fn(),
|
||||
removeListener: vi.fn()
|
||||
}
|
||||
const savedSettings = {
|
||||
provider: 'model',
|
||||
modelBaseUrl: 'https://bigtoken.ai',
|
||||
modelName: 'sonnet-5',
|
||||
modelProtocol: 'anthropic-messages',
|
||||
modelAuthentication: 'api-key',
|
||||
imageGenerationQuality: 'auto',
|
||||
opencodeBaseUrl: '',
|
||||
opencodeEmbedded: true,
|
||||
opencodeBinaryPath: '',
|
||||
opencodeConfigPath: '',
|
||||
continueBinaryPath: '',
|
||||
continueConfigPath: '',
|
||||
continueMode: 'chat',
|
||||
runtimeSandboxMode: 'auto',
|
||||
subagentSmartRoutingEnabled: false,
|
||||
knowledgeEmbeddingEnabled: false,
|
||||
knowledgeEmbeddingBaseUrl:
|
||||
'http://127.0.0.1:11434/v1/embeddings',
|
||||
knowledgeEmbeddingModel: 'nomic-embed-text',
|
||||
knowledgeEmbeddingApiKeyConfigured: false,
|
||||
knowledgeEmbeddingCredentialSource: 'none',
|
||||
knowledgeRerankEnabled: false,
|
||||
knowledgeRerankEndpoint: 'https://api.cohere.com/v1/rerank',
|
||||
knowledgeRerankModel: 'rerank-v3.5',
|
||||
knowledgeRerankApiKeyConfigured: false,
|
||||
knowledgeRerankCredentialSource: 'none',
|
||||
workspacePath: workspace,
|
||||
apiKeyConfigured: false,
|
||||
credentialSource: 'none',
|
||||
modelProfiles: [],
|
||||
defaultModelProfileId: '00000000-0000-4000-8000-000000000001',
|
||||
opencodeModelSource: { kind: 'platform' },
|
||||
continueModelSource: { kind: 'platform' },
|
||||
secureStorageAvailable: true,
|
||||
toolApproval: 'always'
|
||||
}
|
||||
const update = vi.fn(async () => {
|
||||
await updateReleased
|
||||
return savedSettings
|
||||
})
|
||||
let releaseReload!: () => void
|
||||
const reloadReleased = new Promise<void>((resolve) => {
|
||||
releaseReload = resolve
|
||||
})
|
||||
const onRuntimeSettingsChanged = vi.fn(async () => {
|
||||
await reloadReleased
|
||||
})
|
||||
const dispose = registerIpcHandlers(
|
||||
window as never,
|
||||
{ capability: 'text' } as never,
|
||||
'CommandOrControl+Shift+Space',
|
||||
{ update } as never,
|
||||
{} as never,
|
||||
{ clear: vi.fn() } as never,
|
||||
{} as never,
|
||||
{
|
||||
claimDueSchedules: vi.fn(() => []),
|
||||
repairConversationRuntimeSelections: vi.fn()
|
||||
} as never,
|
||||
{ clear: vi.fn() } as never,
|
||||
{} as never,
|
||||
onRuntimeSettingsChanged
|
||||
)
|
||||
const event = {
|
||||
sender: webContents,
|
||||
senderFrame: webContents.mainFrame
|
||||
}
|
||||
const input = {
|
||||
provider: savedSettings.provider,
|
||||
modelBaseUrl: savedSettings.modelBaseUrl,
|
||||
modelName: savedSettings.modelName,
|
||||
modelProtocol: savedSettings.modelProtocol,
|
||||
modelAuthentication: savedSettings.modelAuthentication,
|
||||
imageGenerationQuality: savedSettings.imageGenerationQuality,
|
||||
opencodeBaseUrl: savedSettings.opencodeBaseUrl,
|
||||
opencodeEmbedded: savedSettings.opencodeEmbedded,
|
||||
opencodeBinaryPath: savedSettings.opencodeBinaryPath,
|
||||
opencodeConfigPath: savedSettings.opencodeConfigPath,
|
||||
continueBinaryPath: savedSettings.continueBinaryPath,
|
||||
continueConfigPath: savedSettings.continueConfigPath,
|
||||
continueMode: savedSettings.continueMode,
|
||||
runtimeSandboxMode: savedSettings.runtimeSandboxMode,
|
||||
knowledgeEmbeddingEnabled:
|
||||
savedSettings.knowledgeEmbeddingEnabled,
|
||||
knowledgeEmbeddingBaseUrl:
|
||||
savedSettings.knowledgeEmbeddingBaseUrl,
|
||||
knowledgeEmbeddingModel: savedSettings.knowledgeEmbeddingModel,
|
||||
knowledgeRerankEnabled: savedSettings.knowledgeRerankEnabled,
|
||||
knowledgeRerankEndpoint: savedSettings.knowledgeRerankEndpoint,
|
||||
knowledgeRerankModel: savedSettings.knowledgeRerankModel,
|
||||
workspacePath: savedSettings.workspacePath,
|
||||
apiKey: { action: 'keep' as const },
|
||||
toolApproval: savedSettings.toolApproval
|
||||
}
|
||||
|
||||
try {
|
||||
const pendingUpdate = Promise.resolve(
|
||||
electronMocks.handlers.get(ipcChannels.runtimeSettingsUpdate)?.(
|
||||
event,
|
||||
input
|
||||
)
|
||||
)
|
||||
await vi.waitFor(() => expect(update).toHaveBeenCalledOnce())
|
||||
|
||||
let cleanupComplete = false
|
||||
const cleanup = dispose().then(() => {
|
||||
cleanupComplete = true
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(cleanupComplete).toBe(false)
|
||||
|
||||
releaseUpdate()
|
||||
await vi.waitFor(() =>
|
||||
expect(onRuntimeSettingsChanged).toHaveBeenCalledOnce()
|
||||
)
|
||||
expect(cleanupComplete).toBe(false)
|
||||
|
||||
releaseReload()
|
||||
await expect(pendingUpdate).resolves.toBe(savedSettings)
|
||||
await cleanup
|
||||
expect(cleanupComplete).toBe(true)
|
||||
} finally {
|
||||
releaseUpdate()
|
||||
releaseReload()
|
||||
await rm(workspace, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('registerIpcHandlers knowledge snapshot ontology', () => {
|
||||
afterEach(() => {
|
||||
electronMocks.handlers.clear()
|
||||
@@ -1193,7 +1368,11 @@ describe('registerIpcHandlers Runtime config actions', () => {
|
||||
await writeFile(configPath, 'name: Test', 'utf8')
|
||||
const getPublicSettings = vi.fn(async () => ({
|
||||
opencodeConfigPath: '',
|
||||
continueConfigPath: configPath
|
||||
continueConfigPath: process.execPath,
|
||||
configured: {
|
||||
opencodeConfigPath: '',
|
||||
continueConfigPath: configPath
|
||||
}
|
||||
}))
|
||||
const webContents = {
|
||||
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
||||
@@ -1259,7 +1438,11 @@ describe('registerIpcHandlers Runtime config actions', () => {
|
||||
|
||||
getPublicSettings.mockResolvedValueOnce({
|
||||
opencodeConfigPath: '',
|
||||
continueConfigPath: process.execPath
|
||||
continueConfigPath: configPath,
|
||||
configured: {
|
||||
opencodeConfigPath: '',
|
||||
continueConfigPath: process.execPath
|
||||
}
|
||||
})
|
||||
await expect(
|
||||
electronMocks.handlers.get(
|
||||
@@ -1571,7 +1754,7 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
profileId: '00000000-0000-4000-8000-000000000001'
|
||||
},
|
||||
kind: 'channel',
|
||||
channel: 'wecom',
|
||||
channel: 'wecom',
|
||||
status: 'active',
|
||||
createdAt: '2026-08-04T00:00:00.000Z',
|
||||
updatedAt: '2026-08-04T00:00:00.000Z'
|
||||
@@ -1633,11 +1816,21 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
subagentSmartRoutingEnabled: smartRoutingEnabled
|
||||
})
|
||||
)
|
||||
const getPolicySettings = vi.fn(
|
||||
async (): Promise<Record<string, unknown>> => ({
|
||||
toolApproval,
|
||||
subagentSmartRoutingEnabled: smartRoutingEnabled
|
||||
})
|
||||
)
|
||||
const getApplicationSettings = vi.fn(async () => ({
|
||||
magicNotesEnabled
|
||||
}))
|
||||
const dispose = registerIpcHandlers(
|
||||
window as never,
|
||||
runtime as never,
|
||||
'CommandOrControl+Shift+Space',
|
||||
{
|
||||
getPolicySettings,
|
||||
getResolvedSettings
|
||||
} as never,
|
||||
{} as never,
|
||||
@@ -1654,7 +1847,7 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
subagentService as never,
|
||||
undefined,
|
||||
{
|
||||
get: vi.fn(async () => ({ magicNotesEnabled }))
|
||||
get: getApplicationSettings
|
||||
} as never,
|
||||
undefined,
|
||||
undefined,
|
||||
@@ -1668,6 +1861,8 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
assistantDatabase,
|
||||
contextManager,
|
||||
dispose,
|
||||
getApplicationSettings,
|
||||
getPolicySettings,
|
||||
getResolvedSettings,
|
||||
clearHandler: electronMocks.handlers.get(
|
||||
ipcChannels.appClearLocalData
|
||||
@@ -1707,6 +1902,7 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
}
|
||||
const knowledgeGateway = {
|
||||
grant: vi.fn(() => 'capability'),
|
||||
getAvailableToolNames: vi.fn(() => ['knowledge_search']),
|
||||
drainReferences: vi.fn(() => []),
|
||||
revoke: vi.fn()
|
||||
}
|
||||
@@ -1769,6 +1965,22 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
}
|
||||
const knowledgeGateway = {
|
||||
grant: vi.fn(() => 'capability'),
|
||||
getAvailableToolNames: vi.fn(() => {
|
||||
const grantCallCount = knowledgeGateway.grant.mock.calls.length
|
||||
return grantCallCount === 1
|
||||
? ['note_list', 'note_get', 'note_search']
|
||||
: [
|
||||
'note_list',
|
||||
'note_get',
|
||||
'note_search',
|
||||
'note_create',
|
||||
'note_update',
|
||||
'note_entry_create',
|
||||
'note_entry_update',
|
||||
'note_entry_delete',
|
||||
'note_delete'
|
||||
]
|
||||
}),
|
||||
drainReferences: vi.fn(() => []),
|
||||
revoke: vi.fn()
|
||||
}
|
||||
@@ -1831,6 +2043,74 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it.each(['ask', 'execute'] as const)(
|
||||
'does not grant or advertise scoped data tools to external OpenCode in %s mode',
|
||||
async (workMode) => {
|
||||
let receivedRequest:
|
||||
| {
|
||||
knowledgeCapabilityToken?: string
|
||||
trustedInstructions?: string
|
||||
}
|
||||
| undefined
|
||||
const externalOpenCode = {
|
||||
runtimeId: 'opencode',
|
||||
capability: 'chat',
|
||||
supportsToolExecution: true,
|
||||
supportsScopedDataTools: false,
|
||||
async *run(request: {
|
||||
requestId: string
|
||||
knowledgeCapabilityToken?: string
|
||||
trustedInstructions?: string
|
||||
}) {
|
||||
receivedRequest = request
|
||||
yield { requestId: request.requestId, type: 'done' }
|
||||
}
|
||||
}
|
||||
const knowledgeGateway = {
|
||||
grant: vi.fn(() => 'must-not-be-granted'),
|
||||
getAvailableToolNames: vi.fn(() => ['note_list']),
|
||||
drainReferences: vi.fn(() => []),
|
||||
revoke: vi.fn()
|
||||
}
|
||||
const harness = createHarness(
|
||||
externalOpenCode,
|
||||
undefined,
|
||||
'always',
|
||||
undefined,
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
knowledgeGateway,
|
||||
true
|
||||
)
|
||||
const requestId = '00000000-0000-4000-8000-000000000025'
|
||||
|
||||
await harness.handler?.(trustedEvent(harness.webContents), {
|
||||
requestId,
|
||||
conversationId: 'external-opencode',
|
||||
prompt: '读取笔记',
|
||||
workMode,
|
||||
knowledgeLibraryIds: []
|
||||
})
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
|
||||
requestId,
|
||||
'completed'
|
||||
)
|
||||
)
|
||||
|
||||
expect(knowledgeGateway.grant).not.toHaveBeenCalled()
|
||||
expect(receivedRequest?.knowledgeCapabilityToken).toBeUndefined()
|
||||
expect(receivedRequest?.trustedInstructions).not.toContain(
|
||||
'note_list'
|
||||
)
|
||||
expect(receivedRequest?.trustedInstructions).not.toContain(
|
||||
'Available GoodBuddy data tools:'
|
||||
)
|
||||
await harness.dispose()
|
||||
}
|
||||
)
|
||||
|
||||
it('accepts an authorized knowledge library after the first 100 entries', async () => {
|
||||
const libraries = Array.from({ length: 101 }, (_, index) => ({
|
||||
id: `00000000-0000-4000-8000-${index
|
||||
@@ -1841,6 +2121,7 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
const listKnowledgeBases = vi.fn(() => libraries)
|
||||
const knowledgeGateway = {
|
||||
grant: vi.fn(() => 'capability'),
|
||||
getAvailableToolNames: vi.fn(() => ['knowledge_search']),
|
||||
drainReferences: vi.fn(() => []),
|
||||
revoke: vi.fn()
|
||||
}
|
||||
@@ -1882,7 +2163,8 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
expect(knowledgeGateway.grant).toHaveBeenCalledWith(
|
||||
requestId,
|
||||
[libraries[100]!.id],
|
||||
expect.any(AbortSignal)
|
||||
expect.any(AbortSignal),
|
||||
'none'
|
||||
)
|
||||
await harness.dispose()
|
||||
})
|
||||
@@ -1912,6 +2194,7 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
}
|
||||
const knowledgeGateway = {
|
||||
grant: vi.fn(() => 'capability'),
|
||||
getAvailableToolNames: vi.fn(() => ['knowledge_search']),
|
||||
drainReferences: vi.fn(() => [reference]),
|
||||
revoke: vi.fn()
|
||||
}
|
||||
@@ -1948,7 +2231,8 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
expect(knowledgeGateway.grant).toHaveBeenCalledWith(
|
||||
requestId,
|
||||
[libraryId],
|
||||
expect.any(AbortSignal)
|
||||
expect.any(AbortSignal),
|
||||
'none'
|
||||
)
|
||||
const publicEvents = harness.webContents.send.mock.calls
|
||||
.filter(([channel]) => channel === ipcChannels.agentEvent)
|
||||
@@ -2057,6 +2341,7 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
])
|
||||
const knowledgeGateway = {
|
||||
grant: vi.fn(() => 'capability'),
|
||||
getAvailableToolNames: vi.fn(() => ['knowledge_search']),
|
||||
drainReferences: vi.fn(() => []),
|
||||
revoke: vi.fn()
|
||||
}
|
||||
@@ -2333,6 +2618,67 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it('rejects an active duplicate before resolving Runtime or settings again', async () => {
|
||||
let releaseRun!: () => void
|
||||
const runReleased = new Promise<void>((resolve) => {
|
||||
releaseRun = resolve
|
||||
})
|
||||
let markRunStarted!: () => void
|
||||
const runStarted = new Promise<void>((resolve) => {
|
||||
markRunStarted = resolve
|
||||
})
|
||||
const selectedRuntime = {
|
||||
runtimeId: 'model',
|
||||
capability: 'chat',
|
||||
supportsToolExecution: true,
|
||||
async *run(request: { requestId: string }) {
|
||||
markRunStarted()
|
||||
await runReleased
|
||||
yield { requestId: request.requestId, type: 'done' }
|
||||
}
|
||||
}
|
||||
const selectedRuntimes = {
|
||||
getRuntime: vi.fn(async () => selectedRuntime),
|
||||
getStatus: vi.fn(),
|
||||
releaseConversation: vi.fn(async () => undefined)
|
||||
}
|
||||
const harness = createHarness(
|
||||
selectedRuntime,
|
||||
undefined,
|
||||
'always',
|
||||
undefined,
|
||||
false,
|
||||
selectedRuntimes
|
||||
)
|
||||
const event = trustedEvent(harness.webContents)
|
||||
const request = {
|
||||
requestId: '00000000-0000-4000-8000-000000000013',
|
||||
conversationId: 'duplicate-request',
|
||||
projectId: '00000000-0000-4000-8000-000000000101',
|
||||
prompt: 'run once',
|
||||
workMode: 'ask' as const
|
||||
}
|
||||
|
||||
await harness.handler?.(event, request)
|
||||
await runStarted
|
||||
await expect(harness.handler?.(event, request)).rejects.toThrow(
|
||||
'请求正在执行'
|
||||
)
|
||||
|
||||
expect(selectedRuntimes.getRuntime).toHaveBeenCalledOnce()
|
||||
expect(harness.getApplicationSettings).toHaveBeenCalledOnce()
|
||||
expect(harness.contextManager.enrichRequest).toHaveBeenCalledOnce()
|
||||
|
||||
releaseRun()
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
|
||||
request.requestId,
|
||||
'completed'
|
||||
)
|
||||
)
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it('aborts active work and clears browser sessions before assistant data', async () => {
|
||||
const lifecycle: string[] = []
|
||||
let markStarted!: () => void
|
||||
@@ -2389,6 +2735,48 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it('coalesces concurrent local-data clear requests', async () => {
|
||||
let releaseClear!: () => void
|
||||
const clearBlocked = new Promise<void>((resolve) => {
|
||||
releaseClear = resolve
|
||||
})
|
||||
const onBeforeClearLocalData = vi.fn(async () => {
|
||||
await clearBlocked
|
||||
})
|
||||
const runtime = {
|
||||
capability: 'chat',
|
||||
requiresToolApproval: false,
|
||||
supportsToolExecution: true,
|
||||
getStatus: vi.fn(),
|
||||
dispose: vi.fn()
|
||||
}
|
||||
const harness = createHarness(runtime, onBeforeClearLocalData)
|
||||
const event = trustedEvent(harness.webContents)
|
||||
|
||||
const firstClear = harness.clearHandler?.(event)
|
||||
const secondClear = harness.clearHandler?.(event)
|
||||
|
||||
expect(firstClear).toBe(secondClear)
|
||||
await vi.waitFor(() => {
|
||||
expect(onBeforeClearLocalData).toHaveBeenCalledOnce()
|
||||
})
|
||||
await expect(
|
||||
harness.handler?.(event, {
|
||||
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
|
||||
conversationId: 'conversation-during-clear',
|
||||
prompt: 'do not start',
|
||||
workMode: 'execute'
|
||||
})
|
||||
).rejects.toThrow('本地数据维护期间暂不接受新任务')
|
||||
|
||||
releaseClear()
|
||||
await expect(firstClear).resolves.toBeUndefined()
|
||||
expect(
|
||||
harness.assistantDatabase.clearAssistantData
|
||||
).toHaveBeenCalledOnce()
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it('marks a request failed when a tool fails before runtime done', async () => {
|
||||
const runtime = {
|
||||
capability: 'chat',
|
||||
@@ -2655,6 +3043,8 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
)
|
||||
)
|
||||
expect(runtime.run).not.toHaveBeenCalled()
|
||||
expect(harness.getPolicySettings).toHaveBeenCalledOnce()
|
||||
expect(harness.getResolvedSettings).not.toHaveBeenCalled()
|
||||
expect(subagentService.run).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ expert, routingMode: 'smart' })
|
||||
)
|
||||
@@ -2899,6 +3289,8 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
})
|
||||
).resolves.toBe('deny')
|
||||
expect(harness.approvalBroker.request).not.toHaveBeenCalled()
|
||||
expect(harness.getPolicySettings).not.toHaveBeenCalled()
|
||||
expect(harness.getResolvedSettings).not.toHaveBeenCalled()
|
||||
expect(harness.assistantDatabase.createTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: '企业微信远程请求',
|
||||
@@ -2910,6 +3302,128 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['weixin', '微信 ClawBot'],
|
||||
['wecom', '企业微信'],
|
||||
['dingtalk', '钉钉']
|
||||
] as const)(
|
||||
'grants read-only Magic Notes tools to %s channel Ask requests',
|
||||
async (channel, channelLabel) => {
|
||||
let receivedRequest:
|
||||
| {
|
||||
knowledgeCapabilityToken?: string
|
||||
prompt: string
|
||||
trustedInstructions?: string
|
||||
workMode: string
|
||||
}
|
||||
| undefined
|
||||
const runtime = {
|
||||
capability: 'chat',
|
||||
async *run(request: {
|
||||
requestId: string
|
||||
knowledgeCapabilityToken?: string
|
||||
prompt: string
|
||||
trustedInstructions?: string
|
||||
workMode: string
|
||||
}) {
|
||||
receivedRequest = request
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: 'tool',
|
||||
callId: `call-${channel}-note-list`,
|
||||
name: 'note_list',
|
||||
state: 'completed',
|
||||
summary: '读取笔记列表'
|
||||
}
|
||||
yield { requestId: request.requestId, type: 'done' }
|
||||
}
|
||||
}
|
||||
const knowledgeGateway = {
|
||||
grant: vi.fn<KnowledgeGrantMock>(() =>
|
||||
'channel-notes-capability'
|
||||
),
|
||||
getAvailableToolNames: vi.fn(() => [
|
||||
'note_list',
|
||||
'note_get',
|
||||
'note_search'
|
||||
]),
|
||||
drainReferences: vi.fn(() => []),
|
||||
revoke: vi.fn()
|
||||
}
|
||||
const harness = createHarness(
|
||||
runtime,
|
||||
undefined,
|
||||
'always',
|
||||
undefined,
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
knowledgeGateway,
|
||||
true
|
||||
)
|
||||
vi.mocked(
|
||||
harness.assistantDatabase.listProjects
|
||||
).mockReturnValue([
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000401',
|
||||
name: channelLabel,
|
||||
description: `${channelLabel}远程消息与受控任务`,
|
||||
rootPath: 'C:\\ProjectWorkspace',
|
||||
defaultWorkMode: 'ask',
|
||||
runtimeSelection: {
|
||||
provider: 'model',
|
||||
profileId: '00000000-0000-4000-8000-000000000001'
|
||||
},
|
||||
kind: 'channel',
|
||||
channel,
|
||||
status: 'active',
|
||||
createdAt: '2026-08-04T00:00:00.000Z',
|
||||
updatedAt: '2026-08-04T00:00:00.000Z'
|
||||
}
|
||||
])
|
||||
const executor = channelMocks.executor
|
||||
if (!executor) {
|
||||
throw new Error('Expected channel executor')
|
||||
}
|
||||
|
||||
await expect(
|
||||
executor(
|
||||
{
|
||||
channel,
|
||||
eventId: `event-${channel}-notes`,
|
||||
senderId: 'user-1',
|
||||
conversationId: `conversation-${channel}-notes`,
|
||||
conversationType: 'direct',
|
||||
text: '读取我的笔记',
|
||||
mentioned: false,
|
||||
workMode: 'ask'
|
||||
},
|
||||
new AbortController().signal
|
||||
)
|
||||
).resolves.toMatchObject({ status: 'completed' })
|
||||
const requestId =
|
||||
vi.mocked(knowledgeGateway.grant).mock.calls[0]?.[0]
|
||||
expect(knowledgeGateway.grant).toHaveBeenCalledWith(
|
||||
requestId,
|
||||
[],
|
||||
expect.any(AbortSignal),
|
||||
'read'
|
||||
)
|
||||
expect(receivedRequest).toMatchObject({
|
||||
knowledgeCapabilityToken: 'channel-notes-capability',
|
||||
workMode: 'ask',
|
||||
trustedInstructions: expect.stringContaining(
|
||||
'note_list, note_get, note_search'
|
||||
)
|
||||
})
|
||||
expect(receivedRequest?.prompt).toContain('读取我的笔记')
|
||||
expect(knowledgeGateway.revoke).toHaveBeenCalledWith(
|
||||
'channel-notes-capability'
|
||||
)
|
||||
await harness.dispose()
|
||||
}
|
||||
)
|
||||
|
||||
it('persists remote media and passes it through the existing context path', async () => {
|
||||
let receivedRequest:
|
||||
| {
|
||||
@@ -3191,6 +3705,179 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it('grants Magic Notes write tools to channel Execute requests', async () => {
|
||||
let receivedRequest:
|
||||
| {
|
||||
knowledgeCapabilityToken?: string
|
||||
trustedInstructions?: string
|
||||
workMode: string
|
||||
}
|
||||
| undefined
|
||||
const runtime = {
|
||||
runtimeId: 'model',
|
||||
capability: 'chat',
|
||||
supportsToolExecution: true,
|
||||
getStatus: vi.fn(async () => ({
|
||||
id: 'model',
|
||||
label: 'Direct model',
|
||||
available: true,
|
||||
supportsToolExecution: true
|
||||
})),
|
||||
async *run(request: {
|
||||
requestId: string
|
||||
knowledgeCapabilityToken?: string
|
||||
trustedInstructions?: string
|
||||
workMode: string
|
||||
}) {
|
||||
receivedRequest = request
|
||||
yield { requestId: request.requestId, type: 'done' }
|
||||
}
|
||||
}
|
||||
const knowledgeGateway = {
|
||||
grant: vi.fn<KnowledgeGrantMock>(() =>
|
||||
'channel-notes-write-capability'
|
||||
),
|
||||
getAvailableToolNames: vi.fn(() => [
|
||||
'note_list',
|
||||
'note_get',
|
||||
'note_search',
|
||||
'note_create',
|
||||
'note_update',
|
||||
'note_entry_create',
|
||||
'note_entry_update',
|
||||
'note_entry_delete',
|
||||
'note_delete'
|
||||
]),
|
||||
drainReferences: vi.fn(() => []),
|
||||
revoke: vi.fn()
|
||||
}
|
||||
const harness = createHarness(
|
||||
runtime,
|
||||
undefined,
|
||||
'always',
|
||||
undefined,
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
knowledgeGateway,
|
||||
true
|
||||
)
|
||||
const executor = channelMocks.executor
|
||||
if (!executor) {
|
||||
throw new Error('Expected channel executor')
|
||||
}
|
||||
|
||||
await expect(
|
||||
executor(
|
||||
{
|
||||
channel: 'wecom',
|
||||
eventId: 'event-execute-notes',
|
||||
senderId: 'user-1',
|
||||
conversationId: 'conversation-execute-notes',
|
||||
conversationType: 'direct',
|
||||
text: '/execute 创建一条笔记',
|
||||
mentioned: false,
|
||||
workMode: 'ask'
|
||||
},
|
||||
new AbortController().signal
|
||||
)
|
||||
).resolves.toMatchObject({ status: 'completed' })
|
||||
const requestId =
|
||||
vi.mocked(knowledgeGateway.grant).mock.calls[0]?.[0]
|
||||
expect(knowledgeGateway.grant).toHaveBeenCalledWith(
|
||||
requestId,
|
||||
[],
|
||||
expect.any(AbortSignal),
|
||||
'write'
|
||||
)
|
||||
expect(receivedRequest).toMatchObject({
|
||||
knowledgeCapabilityToken: 'channel-notes-write-capability',
|
||||
workMode: 'execute',
|
||||
trustedInstructions: expect.stringContaining('note_create')
|
||||
})
|
||||
expect(receivedRequest?.trustedInstructions).toContain(
|
||||
'note_delete'
|
||||
)
|
||||
expect(knowledgeGateway.revoke).toHaveBeenCalledWith(
|
||||
'channel-notes-write-capability'
|
||||
)
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it('does not grant or advertise Magic Notes to external OpenCode channels', async () => {
|
||||
let receivedRequest:
|
||||
| {
|
||||
knowledgeCapabilityToken?: string
|
||||
trustedInstructions?: string
|
||||
}
|
||||
| undefined
|
||||
const selectedRuntime = {
|
||||
runtimeId: 'opencode',
|
||||
capability: 'chat',
|
||||
supportsToolExecution: true,
|
||||
supportsScopedDataTools: false,
|
||||
getStatus: vi.fn(async () => ({
|
||||
id: 'opencode',
|
||||
label: 'External OpenCode',
|
||||
available: true,
|
||||
supportsToolExecution: true
|
||||
})),
|
||||
async *run(request: {
|
||||
requestId: string
|
||||
knowledgeCapabilityToken?: string
|
||||
trustedInstructions?: string
|
||||
}) {
|
||||
receivedRequest = request
|
||||
yield { requestId: request.requestId, type: 'done' }
|
||||
}
|
||||
}
|
||||
const knowledgeGateway = {
|
||||
grant: vi.fn<KnowledgeGrantMock>(() => 'must-not-be-granted'),
|
||||
getAvailableToolNames: vi.fn(() => ['note_list']),
|
||||
drainReferences: vi.fn(() => []),
|
||||
revoke: vi.fn()
|
||||
}
|
||||
const harness = createHarness(
|
||||
selectedRuntime,
|
||||
undefined,
|
||||
'always',
|
||||
undefined,
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
knowledgeGateway,
|
||||
true
|
||||
)
|
||||
const executor = channelMocks.executor
|
||||
if (!executor) {
|
||||
throw new Error('Expected channel executor')
|
||||
}
|
||||
const result = await executor(
|
||||
{
|
||||
channel: 'wecom',
|
||||
eventId: 'event-external-opencode',
|
||||
senderId: 'friend',
|
||||
conversationId: 'external-opencode',
|
||||
conversationType: 'direct',
|
||||
text: '读取我的笔记',
|
||||
mentioned: true,
|
||||
workMode: 'ask'
|
||||
},
|
||||
new AbortController().signal
|
||||
)
|
||||
if (result.status === 'failed') {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
expect(result).toMatchObject({ status: 'completed' })
|
||||
|
||||
expect(knowledgeGateway.grant).not.toHaveBeenCalled()
|
||||
expect(receivedRequest?.knowledgeCapabilityToken).toBeUndefined()
|
||||
expect(receivedRequest?.trustedInstructions).not.toContain(
|
||||
'note_list'
|
||||
)
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it('routes remote Execute to a configured Agent Runtime without a GoodBuddy approval callback', async () => {
|
||||
let receivedAuthorize: unknown = 'not-called'
|
||||
const configuredProfileId =
|
||||
@@ -3374,6 +4061,8 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
expect(receivedAuthorize).toEqual(expect.any(Function))
|
||||
expect(decision).toBe('once')
|
||||
expect(harness.approvalBroker.request).not.toHaveBeenCalled()
|
||||
expect(harness.getPolicySettings).toHaveBeenCalledOnce()
|
||||
expect(harness.getResolvedSettings).not.toHaveBeenCalled()
|
||||
expect(
|
||||
harness.assistantDatabase.updateTaskStatus
|
||||
).not.toHaveBeenCalledWith(requestId, 'waiting_approval')
|
||||
@@ -3429,6 +4118,8 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
)
|
||||
expect(decision).toBe('deny')
|
||||
expect(harness.approvalBroker.request).not.toHaveBeenCalled()
|
||||
expect(harness.getPolicySettings).toHaveBeenCalledOnce()
|
||||
expect(harness.getResolvedSettings).not.toHaveBeenCalled()
|
||||
expect(harness.webContents.send).not.toHaveBeenCalledWith(
|
||||
ipcChannels.agentEvent,
|
||||
expect.objectContaining({ type: 'approval' })
|
||||
|
||||
+399
-281
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ import {
|
||||
} from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
runtimeSettingsInputSchema,
|
||||
type RuntimeSettingsInput
|
||||
@@ -456,6 +456,29 @@ describe('RuntimeSettingsStore', () => {
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects non-HTTP model profile URLs during legacy migration', async () => {
|
||||
const { filePath, store } = await createStore()
|
||||
await store.update(settings())
|
||||
const persisted = JSON.parse(await readFile(filePath, 'utf8')) as {
|
||||
version: number
|
||||
modelProfiles: Array<{ baseUrl: string }>
|
||||
}
|
||||
persisted.version = 6
|
||||
persisted.modelProfiles[0]!.baseUrl = 'file:///tmp/model'
|
||||
await writeFile(filePath, JSON.stringify(persisted), 'utf8')
|
||||
|
||||
const migratedStore = new RuntimeSettingsStore(filePath, cipher, {})
|
||||
await expect(migratedStore.getPublicSettings()).resolves.toMatchObject({
|
||||
provider: 'model',
|
||||
warnings: [{ code: 'runtime-settings-recovered' }]
|
||||
})
|
||||
expect(
|
||||
(await readdir(join(filePath, '..'))).some((name) =>
|
||||
name.startsWith('runtime-settings.json.corrupt-')
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('encrypts an OpenAI-compatible embedding API key and binds it to the full endpoint', async () => {
|
||||
const { filePath, store } = await createStore()
|
||||
await store.update(
|
||||
@@ -582,6 +605,81 @@ describe('RuntimeSettingsStore', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('does not warn about unreadable stored credentials shadowed by environment keys', async () => {
|
||||
const { filePath, store } = await createStore()
|
||||
await store.update(
|
||||
settings({
|
||||
apiKey: { action: 'replace', value: 'stored-model-secret' },
|
||||
knowledgeEmbeddingApiKey: {
|
||||
action: 'replace',
|
||||
value: 'stored-embedding-secret'
|
||||
},
|
||||
knowledgeRerankApiKey: {
|
||||
action: 'replace',
|
||||
value: 'stored-rerank-secret'
|
||||
}
|
||||
})
|
||||
)
|
||||
const environmentStore = new RuntimeSettingsStore(
|
||||
filePath,
|
||||
{
|
||||
...cipher,
|
||||
decrypt: () => {
|
||||
throw new Error('stored credential is unreadable')
|
||||
}
|
||||
},
|
||||
{
|
||||
GOODBUDDY_MODEL_API_KEY: 'environment-model-secret',
|
||||
GOODBUDDY_EMBEDDING_API_KEY: 'environment-embedding-secret',
|
||||
GOODBUDDY_RERANK_API_KEY: 'environment-rerank-secret'
|
||||
}
|
||||
)
|
||||
|
||||
const publicSettings = await environmentStore.getPublicSettings()
|
||||
expect(publicSettings).toMatchObject({
|
||||
credentialSource: 'environment',
|
||||
knowledgeEmbeddingCredentialSource: 'environment',
|
||||
knowledgeRerankCredentialSource: 'environment'
|
||||
})
|
||||
expect(publicSettings.warnings ?? []).toEqual([])
|
||||
await expect(environmentStore.getResolvedSettings()).resolves.toMatchObject({
|
||||
apiKey: 'environment-model-secret',
|
||||
knowledgeEmbeddingApiKey: 'environment-embedding-secret',
|
||||
knowledgeRerankApiKey: 'environment-rerank-secret'
|
||||
})
|
||||
})
|
||||
|
||||
it('reads Runtime policy without decrypting stored credentials', async () => {
|
||||
const { filePath, store } = await createStore()
|
||||
await store.update(
|
||||
settings({
|
||||
subagentSmartRoutingEnabled: true,
|
||||
toolApproval: 'policy',
|
||||
apiKey: { action: 'replace', value: 'stored-model-secret' },
|
||||
knowledgeEmbeddingApiKey: {
|
||||
action: 'replace',
|
||||
value: 'stored-embedding-secret'
|
||||
},
|
||||
knowledgeRerankApiKey: {
|
||||
action: 'replace',
|
||||
value: 'stored-rerank-secret'
|
||||
}
|
||||
})
|
||||
)
|
||||
const decrypt = vi.fn(cipher.decrypt)
|
||||
const policyStore = new RuntimeSettingsStore(
|
||||
filePath,
|
||||
{ ...cipher, decrypt },
|
||||
{}
|
||||
)
|
||||
|
||||
await expect(policyStore.getPolicySettings()).resolves.toEqual({
|
||||
subagentSmartRoutingEnabled: true,
|
||||
toolApproval: 'policy'
|
||||
})
|
||||
expect(decrypt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears rerank credentials and rejects replacement without secure storage', async () => {
|
||||
const { filePath, store } = await createStore()
|
||||
await store.update(
|
||||
@@ -648,6 +746,57 @@ describe('RuntimeSettingsStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps an already complete version 6 embedding endpoint unchanged', async () => {
|
||||
const { filePath, store } = await createStore()
|
||||
await store.update(settings())
|
||||
const persisted = JSON.parse(await readFile(filePath, 'utf8')) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
persisted.version = 6
|
||||
persisted.knowledgeEmbeddingBaseUrl =
|
||||
'https://vectors.example/custom/v1/embeddings'
|
||||
delete persisted.knowledgeEmbeddingCredential
|
||||
await writeFile(filePath, JSON.stringify(persisted), 'utf8')
|
||||
|
||||
const migratedStore = new RuntimeSettingsStore(filePath, cipher, {})
|
||||
await expect(migratedStore.getPublicSettings()).resolves.toMatchObject({
|
||||
knowledgeEmbeddingBaseUrl:
|
||||
'https://vectors.example/custom/v1/embeddings'
|
||||
})
|
||||
})
|
||||
|
||||
it('repairs only an invalid version 6 embedding endpoint', async () => {
|
||||
const { filePath, store } = await createStore()
|
||||
await store.update(
|
||||
settings({
|
||||
provider: 'continue',
|
||||
workspacePath: 'preserve-this-workspace'
|
||||
})
|
||||
)
|
||||
const persisted = JSON.parse(await readFile(filePath, 'utf8')) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
persisted.version = 6
|
||||
persisted.knowledgeEmbeddingBaseUrl = 'not a URL'
|
||||
delete persisted.knowledgeEmbeddingCredential
|
||||
await writeFile(filePath, JSON.stringify(persisted), 'utf8')
|
||||
|
||||
const migratedStore = new RuntimeSettingsStore(filePath, cipher, {})
|
||||
await expect(migratedStore.getPublicSettings()).resolves.toMatchObject({
|
||||
provider: 'continue',
|
||||
workspacePath: 'preserve-this-workspace',
|
||||
knowledgeEmbeddingBaseUrl:
|
||||
'http://127.0.0.1:11434/v1/embeddings'
|
||||
})
|
||||
expect(
|
||||
(await readdir(join(filePath, '..'))).some((name) =>
|
||||
name.startsWith('runtime-settings.json.corrupt-')
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('defaults image quality when migrating version 7 settings', async () => {
|
||||
const { filePath, store } = await createStore()
|
||||
await store.update(
|
||||
@@ -915,6 +1064,78 @@ describe('RuntimeSettingsStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps configured model values when environment values are effective', async () => {
|
||||
const { filePath, store } = await createStore()
|
||||
await store.update(
|
||||
settings({
|
||||
modelBaseUrl: 'https://stored.example/v1',
|
||||
modelName: 'stored-model',
|
||||
apiKey: { action: 'replace', value: 'stored-key' }
|
||||
})
|
||||
)
|
||||
const environmentStore = new RuntimeSettingsStore(filePath, cipher, {
|
||||
GOODBUDDY_MODEL_API_KEY: 'environment-key',
|
||||
GOODBUDDY_MODEL_BASE_URL: 'https://environment.example/v1',
|
||||
GOODBUDDY_MODEL_NAME: 'environment-model'
|
||||
})
|
||||
|
||||
const publicSettings = await environmentStore.getPublicSettings()
|
||||
expect(publicSettings).toMatchObject({
|
||||
modelBaseUrl: 'https://environment.example/v1',
|
||||
modelName: 'environment-model',
|
||||
credentialSource: 'environment',
|
||||
modelProfiles: [
|
||||
expect.objectContaining({
|
||||
baseUrl: 'https://environment.example/v1',
|
||||
modelName: 'environment-model',
|
||||
credentialSource: 'environment'
|
||||
})
|
||||
],
|
||||
configured: {
|
||||
modelProfiles: [
|
||||
expect.objectContaining({
|
||||
baseUrl: 'https://stored.example/v1',
|
||||
modelName: 'stored-model',
|
||||
credentialSource: 'environment'
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const defaultProfile = publicSettings.configured!.modelProfiles[0]!
|
||||
await environmentStore.update(
|
||||
settings({
|
||||
modelBaseUrl: defaultProfile.baseUrl,
|
||||
modelName: defaultProfile.modelName,
|
||||
modelProtocol: defaultProfile.protocol,
|
||||
modelAuthentication: defaultProfile.authentication,
|
||||
imageGenerationQuality: defaultProfile.imageGenerationQuality,
|
||||
modelProfiles: publicSettings.configured!.modelProfiles.map(
|
||||
(profile) => ({
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
baseUrl: profile.baseUrl,
|
||||
modelName: profile.modelName,
|
||||
protocol: profile.protocol,
|
||||
authentication: profile.authentication,
|
||||
supportsImageInput: profile.supportsImageInput,
|
||||
imageGenerationQuality: profile.imageGenerationQuality,
|
||||
apiKey: { action: 'keep' }
|
||||
})
|
||||
),
|
||||
defaultModelProfileId: publicSettings.defaultModelProfileId
|
||||
})
|
||||
)
|
||||
|
||||
await expect(
|
||||
new RuntimeSettingsStore(filePath, cipher, {}).getResolvedSettings()
|
||||
).resolves.toMatchObject({
|
||||
modelBaseUrl: 'https://stored.example/v1',
|
||||
modelName: 'stored-model',
|
||||
apiKey: 'stored-key'
|
||||
})
|
||||
})
|
||||
|
||||
it('migrates version 1 settings without losing the encrypted API key', async () => {
|
||||
const { filePath, store } = await createStore()
|
||||
const encryptedCredential = cipher
|
||||
@@ -1319,11 +1540,103 @@ describe('RuntimeSettingsStore', () => {
|
||||
|
||||
await expect(store.getPublicSettings()).resolves.toMatchObject({
|
||||
provider: 'model',
|
||||
warning: expect.stringContaining('已损坏')
|
||||
warnings: [{ code: 'runtime-settings-recovered' }]
|
||||
})
|
||||
const files = await readdir(join(filePath, '..'))
|
||||
expect(
|
||||
files.some((name) => name.startsWith('runtime-settings.json.corrupt-'))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('distinguishes an unreadable saved credential from a missing credential', async () => {
|
||||
const { filePath, store } = await createStore()
|
||||
await store.update(
|
||||
settings({
|
||||
apiKey: {
|
||||
action: 'replace',
|
||||
value: 'credential-that-will-become-unreadable'
|
||||
}
|
||||
})
|
||||
)
|
||||
const unreadable = new RuntimeSettingsStore(
|
||||
filePath,
|
||||
{
|
||||
...cipher,
|
||||
decrypt: () => {
|
||||
throw new Error('cannot decrypt')
|
||||
}
|
||||
},
|
||||
{}
|
||||
)
|
||||
|
||||
await expect(unreadable.getPublicSettings()).resolves.toMatchObject({
|
||||
apiKeyConfigured: false,
|
||||
credentialSource: 'unreadable',
|
||||
modelProfiles: [
|
||||
expect.objectContaining({
|
||||
credentialSource: 'unreadable'
|
||||
})
|
||||
],
|
||||
warnings: [
|
||||
expect.objectContaining({
|
||||
code: 'runtime-model-credential-unreadable',
|
||||
subject: '默认模型'
|
||||
})
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('clears credential warnings after secure storage recovers', async () => {
|
||||
const { filePath, store } = await createStore()
|
||||
await store.update(
|
||||
settings({
|
||||
apiKey: {
|
||||
action: 'replace',
|
||||
value: 'recoverable-model-credential'
|
||||
},
|
||||
knowledgeEmbeddingApiKey: {
|
||||
action: 'replace',
|
||||
value: 'recoverable-embedding-credential'
|
||||
},
|
||||
knowledgeRerankApiKey: {
|
||||
action: 'replace',
|
||||
value: 'recoverable-rerank-credential'
|
||||
}
|
||||
})
|
||||
)
|
||||
let decryptAvailable = false
|
||||
const recoveringStore = new RuntimeSettingsStore(
|
||||
filePath,
|
||||
{
|
||||
...cipher,
|
||||
decrypt: (value) => {
|
||||
if (!decryptAvailable) {
|
||||
throw new Error('secure storage is temporarily unavailable')
|
||||
}
|
||||
return cipher.decrypt(value)
|
||||
}
|
||||
},
|
||||
{}
|
||||
)
|
||||
|
||||
await expect(recoveringStore.getPublicSettings()).resolves.toMatchObject({
|
||||
warnings: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
code: 'runtime-model-credential-unreadable'
|
||||
}),
|
||||
{ code: 'runtime-embedding-credential-unreadable' },
|
||||
{ code: 'runtime-rerank-credential-unreadable' }
|
||||
])
|
||||
})
|
||||
|
||||
decryptAvailable = true
|
||||
await expect(recoveringStore.getPublicSettings()).resolves.toMatchObject({
|
||||
apiKeyConfigured: true,
|
||||
knowledgeEmbeddingApiKeyConfigured: true,
|
||||
knowledgeRerankApiKeyConfigured: true
|
||||
})
|
||||
expect((await recoveringStore.getPublicSettings()).warnings ?? []).toEqual(
|
||||
[]
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
+358
-237
@@ -1,14 +1,9 @@
|
||||
import {
|
||||
mkdir,
|
||||
readFile,
|
||||
realpath,
|
||||
rename,
|
||||
rm,
|
||||
stat,
|
||||
writeFile
|
||||
stat
|
||||
} from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { dirname } from 'node:path'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
continueModeSchema,
|
||||
@@ -23,17 +18,28 @@ import {
|
||||
runtimeProviderSchema,
|
||||
runtimeSandboxModeSchema,
|
||||
toolApprovalPolicySchema,
|
||||
RuntimeSettings,
|
||||
type RuntimeSettings,
|
||||
type RuntimeSettingsInput
|
||||
} from '../shared/contracts'
|
||||
import {
|
||||
settingsWarningsEqual,
|
||||
type SettingsWarning
|
||||
} from '../shared/settings-warning-contracts'
|
||||
import {
|
||||
assertSupportedSettingsVersion,
|
||||
isolateCorruptSettingsFile,
|
||||
isMissingFileError,
|
||||
UnsupportedSettingsVersionError,
|
||||
writeJsonFileAtomically
|
||||
} from './settings-file-utils'
|
||||
import {
|
||||
decryptSettingsCredential,
|
||||
encryptedSettingsCredentialSchema,
|
||||
encryptSettingsCredential,
|
||||
type SettingsCredentialCipher
|
||||
} from './settings-credential-cipher'
|
||||
|
||||
const credentialSchema = z
|
||||
.object({
|
||||
formatVersion: z.literal(1),
|
||||
scheme: z.literal('electron-safe-storage'),
|
||||
ciphertextBase64: z.string()
|
||||
})
|
||||
.optional()
|
||||
const credentialSchema = encryptedSettingsCredentialSchema.optional()
|
||||
|
||||
const version4StoredSettingsSchema = z.object({
|
||||
version: z.literal(4),
|
||||
@@ -179,8 +185,6 @@ const storedSettingsSchema = version13StoredSettingsSchema
|
||||
knowledgeRerankCredential: credentialSchema
|
||||
})
|
||||
|
||||
class UnsupportedRuntimeSettingsVersionError extends Error {}
|
||||
|
||||
type StoredSettings = z.infer<typeof storedSettingsSchema>
|
||||
type Version10StoredSettings = z.infer<
|
||||
typeof version10StoredSettingsSchema
|
||||
@@ -239,11 +243,7 @@ const embeddingCredentialPayloadSchema = z.object({
|
||||
|
||||
const rerankCredentialPayloadSchema = embeddingCredentialPayloadSchema
|
||||
|
||||
export type CredentialCipher = {
|
||||
isAvailable: () => boolean
|
||||
encrypt: (value: string) => Buffer
|
||||
decrypt: (value: Buffer) => string
|
||||
}
|
||||
export type CredentialCipher = SettingsCredentialCipher
|
||||
|
||||
export type ResolvedRuntimeSettings = {
|
||||
provider: RuntimeSettings['provider']
|
||||
@@ -279,6 +279,11 @@ export type ResolvedRuntimeSettings = {
|
||||
toolApproval: RuntimeSettings['toolApproval']
|
||||
}
|
||||
|
||||
export type RuntimePolicySettings = Pick<
|
||||
ResolvedRuntimeSettings,
|
||||
'subagentSmartRoutingEnabled' | 'toolApproval'
|
||||
>
|
||||
|
||||
export type ResolvedModelProfile = {
|
||||
id: string
|
||||
name: string
|
||||
@@ -347,6 +352,15 @@ function migrateContinueCommand(command: string): string {
|
||||
return value === 'cn' ? '' : value
|
||||
}
|
||||
|
||||
function normalizeModelBaseUrl(value: string): string {
|
||||
const url = new URL(value)
|
||||
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||
throw new Error('模型服务地址必须使用 HTTP 或 HTTPS')
|
||||
}
|
||||
url.pathname = url.pathname.replace(/\/+$/u, '')
|
||||
return url.toString().replace(/\/$/u, '')
|
||||
}
|
||||
|
||||
function compatibleTextProfileId(
|
||||
settings: Pick<
|
||||
Version10StoredSettings,
|
||||
@@ -445,14 +459,21 @@ function migrateVersion10(
|
||||
}
|
||||
|
||||
function normalizeStoredSettings(settings: StoredSettings): StoredSettings {
|
||||
const fallbackProfileId = compatibleTextProfileId(settings)
|
||||
const modelProfiles = settings.modelProfiles.map((profile) => ({
|
||||
...profile,
|
||||
baseUrl: normalizeModelBaseUrl(profile.baseUrl)
|
||||
}))
|
||||
const fallbackProfileId = compatibleTextProfileId({
|
||||
modelProfiles,
|
||||
defaultModelProfileId: settings.defaultModelProfileId
|
||||
})
|
||||
const normalizeSource = (
|
||||
source: RuntimeSettings['opencodeModelSource']
|
||||
): RuntimeSettings['opencodeModelSource'] => {
|
||||
if (source.kind === 'platform') {
|
||||
return source
|
||||
}
|
||||
const profile = settings.modelProfiles.find(
|
||||
const profile = modelProfiles.find(
|
||||
(candidate) => candidate.id === source.profileId
|
||||
)
|
||||
if (profile && isAgentRuntimeModelProtocol(profile.protocol)) {
|
||||
@@ -463,14 +484,21 @@ function normalizeStoredSettings(settings: StoredSettings): StoredSettings {
|
||||
: { kind: 'platform' }
|
||||
}
|
||||
const opencodeBaseUrl = settings.opencodeBaseUrl.trim()
|
||||
const defaultModelProfileId = settings.modelProfiles.some(
|
||||
if (opencodeBaseUrl) {
|
||||
const url = new URL(opencodeBaseUrl)
|
||||
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||
throw new Error('OpenCode 地址必须使用 HTTP 或 HTTPS')
|
||||
}
|
||||
}
|
||||
const defaultModelProfileId = modelProfiles.some(
|
||||
(profile) => profile.id === settings.defaultModelProfileId
|
||||
)
|
||||
? settings.defaultModelProfileId
|
||||
: settings.modelProfiles[0]!.id
|
||||
: modelProfiles[0]!.id
|
||||
|
||||
return {
|
||||
...settings,
|
||||
modelProfiles,
|
||||
provider:
|
||||
settings.provider === 'auto' ? 'model' : settings.provider,
|
||||
defaultModelProfileId,
|
||||
@@ -558,15 +586,27 @@ function migrateVersion5(
|
||||
function migrateVersion6(
|
||||
settings: z.infer<typeof version6StoredSettingsSchema>
|
||||
): StoredSettings {
|
||||
const endpoint = new URL(settings.knowledgeEmbeddingBaseUrl)
|
||||
endpoint.pathname = `${endpoint.pathname.replace(/\/+$/u, '')}/v1/embeddings`
|
||||
let knowledgeEmbeddingBaseUrl: string =
|
||||
defaultRuntimeSettings.knowledgeEmbeddingBaseUrl
|
||||
try {
|
||||
const endpoint = new URL(settings.knowledgeEmbeddingBaseUrl)
|
||||
if (['http:', 'https:'].includes(endpoint.protocol)) {
|
||||
const path = endpoint.pathname.replace(/\/+$/u, '')
|
||||
if (!/\/v1\/embeddings$/iu.test(path)) {
|
||||
endpoint.pathname = `${path}/v1/embeddings`
|
||||
}
|
||||
knowledgeEmbeddingBaseUrl = endpoint.toString()
|
||||
}
|
||||
} catch {
|
||||
// Preserve the rest of the legacy settings and repair only this endpoint.
|
||||
}
|
||||
return migrateVersion10({
|
||||
...settings,
|
||||
version: 10,
|
||||
subagentSmartRoutingEnabled:
|
||||
defaultRuntimeSettings.subagentSmartRoutingEnabled,
|
||||
intranetCompatibilityEnabled: true,
|
||||
knowledgeEmbeddingBaseUrl: endpoint.toString(),
|
||||
knowledgeEmbeddingBaseUrl,
|
||||
modelProfiles: settings.modelProfiles.map((profile) => ({
|
||||
...profile,
|
||||
imageGenerationQuality:
|
||||
@@ -613,15 +653,10 @@ function migrateVersion9(
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeModelBaseUrl(value: string): string {
|
||||
const url = new URL(value)
|
||||
url.pathname = url.pathname.replace(/\/+$/u, '')
|
||||
return url.toString().replace(/\/$/u, '')
|
||||
}
|
||||
|
||||
export class RuntimeSettingsStore {
|
||||
private settings?: StoredSettings
|
||||
private loadWarning?: string
|
||||
private settingsLoad?: Promise<StoredSettings>
|
||||
private loadWarnings: SettingsWarning[] = []
|
||||
private updateQueue: Promise<void> = Promise.resolve()
|
||||
|
||||
constructor(
|
||||
@@ -630,25 +665,28 @@ export class RuntimeSettingsStore {
|
||||
private readonly environment: NodeJS.ProcessEnv = process.env
|
||||
) {}
|
||||
|
||||
private async load(): Promise<StoredSettings> {
|
||||
private load(): Promise<StoredSettings> {
|
||||
if (this.settings) {
|
||||
return this.settings
|
||||
return Promise.resolve(this.settings)
|
||||
}
|
||||
if (!this.settingsLoad) {
|
||||
this.settingsLoad = this.readSettings().finally(() => {
|
||||
this.settingsLoad = undefined
|
||||
})
|
||||
}
|
||||
return this.settingsLoad
|
||||
}
|
||||
|
||||
private async readSettings(): Promise<StoredSettings> {
|
||||
try {
|
||||
const contents = await readFile(this.filePath, 'utf8')
|
||||
const parsed: unknown = JSON.parse(contents)
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === 'object' &&
|
||||
'version' in parsed &&
|
||||
typeof parsed.version === 'number' &&
|
||||
parsed.version > 14
|
||||
) {
|
||||
throw new UnsupportedRuntimeSettingsVersionError(
|
||||
`当前 GoodBuddy 不支持 Runtime 设置版本 ${parsed.version},请升级应用后重试`
|
||||
)
|
||||
}
|
||||
assertSupportedSettingsVersion(
|
||||
parsed,
|
||||
14,
|
||||
(version) =>
|
||||
`当前 GoodBuddy 不支持 Runtime 设置版本 ${version},请升级应用后重试`
|
||||
)
|
||||
const current = storedSettingsSchema.safeParse(parsed)
|
||||
if (current.success) {
|
||||
this.settings = current.data
|
||||
@@ -772,23 +810,15 @@ export class RuntimeSettingsStore {
|
||||
}
|
||||
this.settings = normalizeStoredSettings(this.settings)
|
||||
} catch (error) {
|
||||
if (error instanceof UnsupportedRuntimeSettingsVersionError) {
|
||||
if (error instanceof UnsupportedSettingsVersionError) {
|
||||
throw error
|
||||
}
|
||||
if (
|
||||
!(
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
error.code === 'ENOENT'
|
||||
)
|
||||
) {
|
||||
this.loadWarning =
|
||||
'Runtime 设置文件已损坏,已隔离原文件并恢复默认设置'
|
||||
await rename(
|
||||
if (!isMissingFileError(error)) {
|
||||
await isolateCorruptSettingsFile(
|
||||
this.filePath,
|
||||
`${this.filePath}.corrupt-${Date.now()}`
|
||||
).catch(() => undefined)
|
||||
'Runtime 设置已损坏且无法隔离'
|
||||
)
|
||||
this.loadWarnings = [{ code: 'runtime-settings-recovered' }]
|
||||
}
|
||||
this.settings = { ...defaultSettings }
|
||||
}
|
||||
@@ -798,52 +828,70 @@ export class RuntimeSettingsStore {
|
||||
private getStoredApiKey(
|
||||
profile: StoredSettings['modelProfiles'][number]
|
||||
): string | undefined {
|
||||
if (!profile.credential || !this.cipher.isAvailable()) {
|
||||
if (!profile.credential) {
|
||||
return undefined
|
||||
}
|
||||
const warning = (code: SettingsWarning['code']): undefined => {
|
||||
this.addWarning({ code, subject: profile.name })
|
||||
return undefined
|
||||
}
|
||||
if (!this.cipher.isAvailable()) {
|
||||
return warning('runtime-model-credential-unreadable')
|
||||
}
|
||||
try {
|
||||
const payload = credentialPayloadSchema.parse(
|
||||
JSON.parse(
|
||||
this.cipher.decrypt(
|
||||
Buffer.from(profile.credential.ciphertextBase64, 'base64')
|
||||
)
|
||||
)
|
||||
decryptSettingsCredential(this.cipher, profile.credential)
|
||||
)
|
||||
if (payload.origin !== new URL(profile.baseUrl).origin) {
|
||||
this.loadWarning =
|
||||
`模型连接“${profile.name}”的服务地址与已保存 API Key 不匹配,请重新输入或清除 API Key`
|
||||
return undefined
|
||||
return warning('runtime-model-credential-binding-mismatch')
|
||||
}
|
||||
this.removeWarnings(
|
||||
[
|
||||
'runtime-model-credential-unreadable',
|
||||
'runtime-model-credential-binding-mismatch'
|
||||
],
|
||||
profile.name
|
||||
)
|
||||
return payload.apiKey
|
||||
} catch {
|
||||
return undefined
|
||||
return warning('runtime-model-credential-unreadable')
|
||||
}
|
||||
}
|
||||
|
||||
private getStoredEmbeddingApiKey(
|
||||
settings: StoredSettings
|
||||
): string | undefined {
|
||||
if (
|
||||
!settings.knowledgeEmbeddingCredential ||
|
||||
!this.cipher.isAvailable()
|
||||
) {
|
||||
if (!settings.knowledgeEmbeddingCredential) {
|
||||
return undefined
|
||||
}
|
||||
if (!this.cipher.isAvailable()) {
|
||||
this.addWarning({
|
||||
code: 'runtime-embedding-credential-unreadable'
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
try {
|
||||
const payload = embeddingCredentialPayloadSchema.parse(
|
||||
JSON.parse(
|
||||
this.cipher.decrypt(
|
||||
Buffer.from(
|
||||
settings.knowledgeEmbeddingCredential.ciphertextBase64,
|
||||
'base64'
|
||||
)
|
||||
)
|
||||
decryptSettingsCredential(
|
||||
this.cipher,
|
||||
settings.knowledgeEmbeddingCredential
|
||||
)
|
||||
)
|
||||
return payload.endpoint === settings.knowledgeEmbeddingBaseUrl
|
||||
? payload.apiKey
|
||||
: undefined
|
||||
if (payload.endpoint !== settings.knowledgeEmbeddingBaseUrl) {
|
||||
this.addWarning({
|
||||
code: 'runtime-embedding-credential-binding-mismatch'
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
this.removeWarnings([
|
||||
'runtime-embedding-credential-unreadable',
|
||||
'runtime-embedding-credential-binding-mismatch'
|
||||
])
|
||||
return payload.apiKey
|
||||
} catch {
|
||||
this.addWarning({
|
||||
code: 'runtime-embedding-credential-unreadable'
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -851,28 +899,64 @@ export class RuntimeSettingsStore {
|
||||
private getStoredRerankApiKey(
|
||||
settings: StoredSettings
|
||||
): string | undefined {
|
||||
if (!settings.knowledgeRerankCredential || !this.cipher.isAvailable()) {
|
||||
if (!settings.knowledgeRerankCredential) {
|
||||
return undefined
|
||||
}
|
||||
if (!this.cipher.isAvailable()) {
|
||||
this.addWarning({
|
||||
code: 'runtime-rerank-credential-unreadable'
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
try {
|
||||
const payload = rerankCredentialPayloadSchema.parse(
|
||||
JSON.parse(
|
||||
this.cipher.decrypt(
|
||||
Buffer.from(
|
||||
settings.knowledgeRerankCredential.ciphertextBase64,
|
||||
'base64'
|
||||
)
|
||||
)
|
||||
decryptSettingsCredential(
|
||||
this.cipher,
|
||||
settings.knowledgeRerankCredential
|
||||
)
|
||||
)
|
||||
return payload.endpoint === settings.knowledgeRerankEndpoint
|
||||
? payload.apiKey
|
||||
: undefined
|
||||
if (payload.endpoint !== settings.knowledgeRerankEndpoint) {
|
||||
this.addWarning({
|
||||
code: 'runtime-rerank-credential-binding-mismatch'
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
this.removeWarnings([
|
||||
'runtime-rerank-credential-unreadable',
|
||||
'runtime-rerank-credential-binding-mismatch'
|
||||
])
|
||||
return payload.apiKey
|
||||
} catch {
|
||||
this.addWarning({
|
||||
code: 'runtime-rerank-credential-unreadable'
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
private addWarning(warning: SettingsWarning): void {
|
||||
if (
|
||||
!this.loadWarnings.some(
|
||||
(current) => settingsWarningsEqual(current, warning)
|
||||
)
|
||||
) {
|
||||
this.loadWarnings.push(warning)
|
||||
}
|
||||
}
|
||||
|
||||
private removeWarnings(
|
||||
codes: readonly SettingsWarning['code'][],
|
||||
subject?: string
|
||||
): void {
|
||||
this.loadWarnings = this.loadWarnings.filter(
|
||||
(warning) =>
|
||||
!(
|
||||
codes.includes(warning.code) &&
|
||||
(subject === undefined || warning.subject === subject)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private getEnvironmentApiKey(): string | undefined {
|
||||
return (
|
||||
this.environment.GOODBUDDY_MODEL_API_KEY?.trim() ||
|
||||
@@ -903,7 +987,7 @@ export class RuntimeSettingsStore {
|
||||
? this.getEnvironmentApiKey()
|
||||
: undefined
|
||||
const storedApiKey =
|
||||
profile.authentication === 'api-key'
|
||||
profile.authentication === 'api-key' && !environmentApiKey
|
||||
? this.getStoredApiKey(profile)
|
||||
: undefined
|
||||
const environmentBaseUrl =
|
||||
@@ -918,6 +1002,14 @@ export class RuntimeSettingsStore {
|
||||
const model = environmentApiKey
|
||||
? environmentModel || defaultRuntimeSettings.modelName
|
||||
: profile.modelName
|
||||
const credentialSource: RuntimeSettings['credentialSource'] =
|
||||
environmentApiKey
|
||||
? 'environment'
|
||||
: storedApiKey
|
||||
? 'encrypted'
|
||||
: profile.credential
|
||||
? 'unreadable'
|
||||
: 'none'
|
||||
return {
|
||||
apiKey: environmentApiKey ?? storedApiKey,
|
||||
baseUrl,
|
||||
@@ -926,52 +1018,45 @@ export class RuntimeSettingsStore {
|
||||
authentication: profile.authentication,
|
||||
supportsImageInput: profile.supportsImageInput,
|
||||
imageGenerationQuality: profile.imageGenerationQuality,
|
||||
credentialSource: environmentApiKey
|
||||
? 'environment'
|
||||
: storedApiKey
|
||||
? 'encrypted'
|
||||
: 'none'
|
||||
credentialSource
|
||||
}
|
||||
}
|
||||
|
||||
private resolveProfile(
|
||||
private resolveModelProfiles(
|
||||
settings: StoredSettings,
|
||||
profileId: string
|
||||
): ResolvedModelProfile | undefined {
|
||||
const profile = settings.modelProfiles.find(
|
||||
(candidate) => candidate.id === profileId
|
||||
effective: ReturnType<
|
||||
RuntimeSettingsStore['resolveEffectiveModelSettings']
|
||||
>
|
||||
): ResolvedModelProfile[] {
|
||||
return settings.modelProfiles.map((profile) =>
|
||||
profile.id === settings.defaultModelProfileId
|
||||
? {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
baseUrl: effective.baseUrl,
|
||||
modelName: effective.model,
|
||||
protocol: effective.protocol,
|
||||
authentication: effective.authentication,
|
||||
supportsImageInput: effective.supportsImageInput,
|
||||
imageGenerationQuality:
|
||||
effective.imageGenerationQuality,
|
||||
apiKey: effective.apiKey
|
||||
}
|
||||
: {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
baseUrl: profile.baseUrl,
|
||||
modelName: profile.modelName,
|
||||
protocol: profile.protocol,
|
||||
authentication: profile.authentication,
|
||||
supportsImageInput: profile.supportsImageInput,
|
||||
imageGenerationQuality: profile.imageGenerationQuality,
|
||||
apiKey:
|
||||
profile.authentication === 'api-key'
|
||||
? this.getStoredApiKey(profile)
|
||||
: undefined
|
||||
}
|
||||
)
|
||||
if (!profile) {
|
||||
return undefined
|
||||
}
|
||||
if (profile.id === settings.defaultModelProfileId) {
|
||||
const effective = this.resolveEffectiveModelSettings(settings)
|
||||
return {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
baseUrl: effective.baseUrl,
|
||||
modelName: effective.model,
|
||||
protocol: effective.protocol,
|
||||
authentication: effective.authentication,
|
||||
supportsImageInput: effective.supportsImageInput,
|
||||
imageGenerationQuality: effective.imageGenerationQuality,
|
||||
apiKey: effective.apiKey
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
baseUrl: profile.baseUrl,
|
||||
modelName: profile.modelName,
|
||||
protocol: profile.protocol,
|
||||
authentication: profile.authentication,
|
||||
supportsImageInput: profile.supportsImageInput,
|
||||
imageGenerationQuality: profile.imageGenerationQuality,
|
||||
apiKey:
|
||||
profile.authentication === 'api-key'
|
||||
? this.getStoredApiKey(profile)
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
|
||||
private resolveAgentSettings(settings: StoredSettings): {
|
||||
@@ -1022,48 +1107,81 @@ export class RuntimeSettingsStore {
|
||||
private toPublicSettings(settings: StoredSettings): RuntimeSettings {
|
||||
const effective = this.resolveEffectiveModelSettings(settings)
|
||||
const agent = this.resolveAgentSettings(settings)
|
||||
const environmentApiKeyConfigured = Boolean(
|
||||
this.getEnvironmentApiKey()
|
||||
)
|
||||
const resolvedModelProfiles = this.resolveModelProfiles(
|
||||
settings,
|
||||
effective
|
||||
)
|
||||
const resolvedProfilesById = new Map(
|
||||
resolvedModelProfiles.map((profile) => [profile.id, profile])
|
||||
)
|
||||
const modelProfiles = settings.modelProfiles.map((profile) => {
|
||||
const isDefault = profile.id === settings.defaultModelProfileId
|
||||
const apiKey =
|
||||
profile.authentication === 'api-key'
|
||||
? this.getStoredApiKey(profile)
|
||||
: undefined
|
||||
const resolved = resolvedProfilesById.get(profile.id)
|
||||
if (!resolved) {
|
||||
throw new Error(`模型连接不存在:${profile.id}`)
|
||||
}
|
||||
const apiKey = resolved.apiKey
|
||||
return {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
baseUrl: isDefault
|
||||
? effective.baseUrl
|
||||
: profile.baseUrl,
|
||||
modelName: isDefault ? effective.model : profile.modelName,
|
||||
protocol: isDefault
|
||||
? effective.protocol
|
||||
: profile.protocol,
|
||||
authentication: isDefault
|
||||
? effective.authentication
|
||||
: profile.authentication,
|
||||
supportsImageInput: isDefault
|
||||
? effective.supportsImageInput
|
||||
: profile.supportsImageInput,
|
||||
imageGenerationQuality: isDefault
|
||||
? effective.imageGenerationQuality
|
||||
: profile.imageGenerationQuality,
|
||||
apiKeyConfigured: isDefault
|
||||
? Boolean(effective.apiKey)
|
||||
: Boolean(apiKey),
|
||||
baseUrl: resolved.baseUrl,
|
||||
modelName: resolved.modelName,
|
||||
protocol: resolved.protocol,
|
||||
authentication: resolved.authentication,
|
||||
supportsImageInput: resolved.supportsImageInput,
|
||||
imageGenerationQuality:
|
||||
resolved.imageGenerationQuality ??
|
||||
defaultRuntimeSettings.imageGenerationQuality,
|
||||
apiKeyConfigured: Boolean(apiKey),
|
||||
credentialSource: isDefault
|
||||
? effective.credentialSource
|
||||
: apiKey
|
||||
? ('encrypted' as const)
|
||||
: ('none' as const)
|
||||
: profile.credential
|
||||
? ('unreadable' as const)
|
||||
: ('none' as const)
|
||||
}
|
||||
})
|
||||
const configuredModelProfiles = settings.modelProfiles.map((profile) => {
|
||||
const environmentManaged =
|
||||
profile.id === settings.defaultModelProfileId &&
|
||||
profile.authentication === 'api-key' &&
|
||||
environmentApiKeyConfigured
|
||||
const apiKey = resolvedProfilesById.get(profile.id)?.apiKey
|
||||
return {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
baseUrl: profile.baseUrl,
|
||||
modelName: profile.modelName,
|
||||
protocol: profile.protocol,
|
||||
authentication: profile.authentication,
|
||||
supportsImageInput: profile.supportsImageInput,
|
||||
imageGenerationQuality:
|
||||
profile.imageGenerationQuality ??
|
||||
defaultRuntimeSettings.imageGenerationQuality,
|
||||
apiKeyConfigured: environmentManaged || Boolean(apiKey),
|
||||
credentialSource: environmentManaged
|
||||
? ('environment' as const)
|
||||
: apiKey
|
||||
? ('encrypted' as const)
|
||||
: profile.credential
|
||||
? ('unreadable' as const)
|
||||
: ('none' as const)
|
||||
}
|
||||
})
|
||||
const embeddingEnvironmentApiKey =
|
||||
this.environment.GOODBUDDY_EMBEDDING_API_KEY?.trim()
|
||||
const embeddingStoredApiKey =
|
||||
this.getStoredEmbeddingApiKey(settings)
|
||||
const embeddingStoredApiKey = embeddingEnvironmentApiKey
|
||||
? undefined
|
||||
: this.getStoredEmbeddingApiKey(settings)
|
||||
const rerankEnvironmentApiKey =
|
||||
this.environment.GOODBUDDY_RERANK_API_KEY?.trim()
|
||||
const rerankStoredApiKey = this.getStoredRerankApiKey(settings)
|
||||
const rerankStoredApiKey = rerankEnvironmentApiKey
|
||||
? undefined
|
||||
: this.getStoredRerankApiKey(settings)
|
||||
return {
|
||||
provider: settings.provider,
|
||||
modelBaseUrl: effective.baseUrl,
|
||||
@@ -1092,7 +1210,9 @@ export class RuntimeSettingsStore {
|
||||
? 'environment'
|
||||
: embeddingStoredApiKey
|
||||
? 'encrypted'
|
||||
: 'none',
|
||||
: settings.knowledgeEmbeddingCredential
|
||||
? 'unreadable'
|
||||
: 'none',
|
||||
knowledgeRerankEnabled: settings.knowledgeRerankEnabled,
|
||||
knowledgeRerankEndpoint: settings.knowledgeRerankEndpoint,
|
||||
knowledgeRerankModel: settings.knowledgeRerankModel,
|
||||
@@ -1103,7 +1223,9 @@ export class RuntimeSettingsStore {
|
||||
? 'environment'
|
||||
: rerankStoredApiKey
|
||||
? 'encrypted'
|
||||
: 'none',
|
||||
: settings.knowledgeRerankCredential
|
||||
? 'unreadable'
|
||||
: 'none',
|
||||
workspacePath: agent.workspacePath,
|
||||
apiKeyConfigured: Boolean(effective.apiKey),
|
||||
credentialSource: effective.credentialSource,
|
||||
@@ -1115,7 +1237,20 @@ export class RuntimeSettingsStore {
|
||||
continueModelSource: settings.continueModelSource,
|
||||
secureStorageAvailable: this.cipher.isAvailable(),
|
||||
toolApproval: settings.toolApproval,
|
||||
warning: this.loadWarning
|
||||
configured: {
|
||||
modelProfiles: configuredModelProfiles,
|
||||
opencodeBaseUrl: settings.opencodeBaseUrl,
|
||||
opencodeBinaryPath: settings.opencodeBinaryPath,
|
||||
opencodeConfigPath: settings.opencodeConfigPath,
|
||||
continueBinaryPath: settings.continueBinaryPath,
|
||||
continueConfigPath: settings.continueConfigPath,
|
||||
workspacePath: settings.workspacePath || homedir(),
|
||||
opencodeModelSource: settings.opencodeModelSource,
|
||||
continueModelSource: settings.continueModelSource
|
||||
},
|
||||
...(this.loadWarnings.length > 0
|
||||
? { warnings: [...this.loadWarnings] }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1123,24 +1258,31 @@ export class RuntimeSettingsStore {
|
||||
return this.toPublicSettings(await this.load())
|
||||
}
|
||||
|
||||
async getPolicySettings(): Promise<RuntimePolicySettings> {
|
||||
const settings = await this.load()
|
||||
return {
|
||||
subagentSmartRoutingEnabled:
|
||||
settings.subagentSmartRoutingEnabled,
|
||||
toolApproval: settings.toolApproval
|
||||
}
|
||||
}
|
||||
|
||||
async getResolvedSettings(): Promise<ResolvedRuntimeSettings> {
|
||||
const settings = await this.load()
|
||||
const effective = this.resolveEffectiveModelSettings(settings)
|
||||
const agent = this.resolveAgentSettings(settings)
|
||||
const modelProfiles = this.resolveModelProfiles(settings, effective)
|
||||
const profilesById = new Map(
|
||||
modelProfiles.map((profile) => [profile.id, profile])
|
||||
)
|
||||
const opencodeModelProfile =
|
||||
!agent.opencodeBaseUrl &&
|
||||
settings.opencodeModelSource.kind === 'profile'
|
||||
? this.resolveProfile(
|
||||
settings,
|
||||
settings.opencodeModelSource.profileId
|
||||
)
|
||||
? profilesById.get(settings.opencodeModelSource.profileId)
|
||||
: undefined
|
||||
const continueModelProfile =
|
||||
settings.continueModelSource.kind === 'profile'
|
||||
? this.resolveProfile(
|
||||
settings,
|
||||
settings.continueModelSource.profileId
|
||||
)
|
||||
? profilesById.get(settings.continueModelSource.profileId)
|
||||
: undefined
|
||||
return {
|
||||
provider: settings.provider,
|
||||
@@ -1151,13 +1293,7 @@ export class RuntimeSettingsStore {
|
||||
supportsImageInput: effective.supportsImageInput,
|
||||
imageGenerationQuality: effective.imageGenerationQuality,
|
||||
apiKey: effective.apiKey,
|
||||
modelProfiles: settings.modelProfiles.map((profile) => {
|
||||
const resolved = this.resolveProfile(settings, profile.id)
|
||||
if (!resolved) {
|
||||
throw new Error(`模型连接不存在:${profile.id}`)
|
||||
}
|
||||
return resolved
|
||||
}),
|
||||
modelProfiles,
|
||||
defaultModelProfileId: settings.defaultModelProfileId,
|
||||
opencodeModelProfile,
|
||||
continueModelProfile,
|
||||
@@ -1248,7 +1384,15 @@ export class RuntimeSettingsStore {
|
||||
const existing = current.modelProfiles.find(
|
||||
(candidate) => candidate.id === profile.id
|
||||
)
|
||||
const normalizedBaseUrl = normalizeModelBaseUrl(profile.baseUrl)
|
||||
const environmentManaged =
|
||||
profile.id === current.defaultModelProfileId &&
|
||||
profile.authentication === 'api-key' &&
|
||||
profile.apiKey.action === 'keep' &&
|
||||
existing !== undefined &&
|
||||
Boolean(this.getEnvironmentApiKey())
|
||||
const normalizedBaseUrl = normalizeModelBaseUrl(
|
||||
environmentManaged ? existing.baseUrl : profile.baseUrl
|
||||
)
|
||||
if (
|
||||
profile.authentication === 'api-key' &&
|
||||
profile.apiKey.action === 'keep' &&
|
||||
@@ -1264,7 +1408,9 @@ export class RuntimeSettingsStore {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
baseUrl: normalizedBaseUrl,
|
||||
modelName: profile.modelName,
|
||||
modelName: environmentManaged
|
||||
? existing.modelName
|
||||
: profile.modelName,
|
||||
protocol: profile.protocol,
|
||||
authentication: profile.authentication,
|
||||
supportsImageInput: profile.supportsImageInput ?? false,
|
||||
@@ -1280,19 +1426,14 @@ export class RuntimeSettingsStore {
|
||||
profile.authentication === 'api-key' &&
|
||||
profile.apiKey.action === 'replace'
|
||||
) {
|
||||
nextProfile.credential = {
|
||||
formatVersion: 1,
|
||||
scheme: 'electron-safe-storage',
|
||||
ciphertextBase64: this.cipher
|
||||
.encrypt(
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
apiKey: profile.apiKey.value,
|
||||
origin: new URL(normalizedBaseUrl).origin
|
||||
})
|
||||
)
|
||||
.toString('base64')
|
||||
}
|
||||
nextProfile.credential = encryptSettingsCredential(
|
||||
this.cipher,
|
||||
{
|
||||
version: 1,
|
||||
apiKey: profile.apiKey.value,
|
||||
origin: new URL(normalizedBaseUrl).origin
|
||||
}
|
||||
)
|
||||
}
|
||||
return nextProfile
|
||||
})
|
||||
@@ -1319,19 +1460,14 @@ export class RuntimeSettingsStore {
|
||||
knowledgeEmbeddingCredential =
|
||||
current.knowledgeEmbeddingCredential
|
||||
} else if (embeddingApiKeyUpdate.action === 'replace') {
|
||||
knowledgeEmbeddingCredential = {
|
||||
formatVersion: 1,
|
||||
scheme: 'electron-safe-storage',
|
||||
ciphertextBase64: this.cipher
|
||||
.encrypt(
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
apiKey: embeddingApiKeyUpdate.value,
|
||||
endpoint: embeddingEndpoint
|
||||
})
|
||||
)
|
||||
.toString('base64')
|
||||
}
|
||||
knowledgeEmbeddingCredential = encryptSettingsCredential(
|
||||
this.cipher,
|
||||
{
|
||||
version: 1,
|
||||
apiKey: embeddingApiKeyUpdate.value,
|
||||
endpoint: embeddingEndpoint
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const rerankEndpoint = new URL(
|
||||
@@ -1355,19 +1491,14 @@ export class RuntimeSettingsStore {
|
||||
) {
|
||||
knowledgeRerankCredential = current.knowledgeRerankCredential
|
||||
} else if (rerankApiKeyUpdate.action === 'replace') {
|
||||
knowledgeRerankCredential = {
|
||||
formatVersion: 1,
|
||||
scheme: 'electron-safe-storage',
|
||||
ciphertextBase64: this.cipher
|
||||
.encrypt(
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
apiKey: rerankApiKeyUpdate.value,
|
||||
endpoint: rerankEndpoint
|
||||
})
|
||||
)
|
||||
.toString('base64')
|
||||
}
|
||||
knowledgeRerankCredential = encryptSettingsCredential(
|
||||
this.cipher,
|
||||
{
|
||||
version: 1,
|
||||
apiKey: rerankApiKeyUpdate.value,
|
||||
endpoint: rerankEndpoint
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const [
|
||||
@@ -1490,19 +1621,9 @@ export class RuntimeSettingsStore {
|
||||
toolApproval: input.toolApproval
|
||||
}
|
||||
|
||||
await mkdir(dirname(this.filePath), { recursive: true })
|
||||
const temporaryPath = `${this.filePath}.${process.pid}.tmp`
|
||||
try {
|
||||
await writeFile(temporaryPath, `${JSON.stringify(next, null, 2)}\n`, {
|
||||
encoding: 'utf8',
|
||||
mode: 0o600
|
||||
})
|
||||
await rename(temporaryPath, this.filePath)
|
||||
} finally {
|
||||
await rm(temporaryPath, { force: true })
|
||||
}
|
||||
await writeJsonFileAtomically(this.filePath, next)
|
||||
this.settings = next
|
||||
this.loadWarning = undefined
|
||||
this.loadWarnings = []
|
||||
return this.toPublicSettings(next)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export interface SettingsCredentialCipher {
|
||||
isAvailable(): boolean
|
||||
encrypt(value: string): Buffer
|
||||
decrypt(value: Buffer): string
|
||||
}
|
||||
|
||||
export const encryptedSettingsCredentialSchema = z.object({
|
||||
formatVersion: z.literal(1),
|
||||
scheme: z.literal('electron-safe-storage'),
|
||||
ciphertextBase64: z.string()
|
||||
})
|
||||
|
||||
export type EncryptedSettingsCredential = z.infer<
|
||||
typeof encryptedSettingsCredentialSchema
|
||||
>
|
||||
|
||||
export function encryptSettingsCredential(
|
||||
cipher: SettingsCredentialCipher,
|
||||
payload: unknown
|
||||
): EncryptedSettingsCredential {
|
||||
return {
|
||||
formatVersion: 1,
|
||||
scheme: 'electron-safe-storage',
|
||||
ciphertextBase64: cipher
|
||||
.encrypt(JSON.stringify(payload))
|
||||
.toString('base64')
|
||||
}
|
||||
}
|
||||
|
||||
export function decryptSettingsCredential(
|
||||
cipher: SettingsCredentialCipher,
|
||||
credential: EncryptedSettingsCredential
|
||||
): unknown {
|
||||
return JSON.parse(
|
||||
cipher.decrypt(
|
||||
Buffer.from(credential.ciphertextBase64, 'base64')
|
||||
)
|
||||
) as unknown
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import {
|
||||
mkdir,
|
||||
rename,
|
||||
rm,
|
||||
writeFile
|
||||
} from 'node:fs/promises'
|
||||
import { dirname } from 'node:path'
|
||||
|
||||
export interface SettingsFileOperations {
|
||||
rename: typeof rename
|
||||
writeFile: typeof writeFile
|
||||
}
|
||||
|
||||
export class UnsupportedSettingsVersionError extends Error {}
|
||||
|
||||
const defaultSettingsFileOperations: SettingsFileOperations = {
|
||||
rename,
|
||||
writeFile
|
||||
}
|
||||
|
||||
function resolveSettingsFileOperations(
|
||||
operations?: Partial<SettingsFileOperations>
|
||||
): SettingsFileOperations {
|
||||
return {
|
||||
...defaultSettingsFileOperations,
|
||||
...operations
|
||||
}
|
||||
}
|
||||
|
||||
export function isMissingFileError(error: unknown): boolean {
|
||||
return (
|
||||
error !== null &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
error.code === 'ENOENT'
|
||||
)
|
||||
}
|
||||
|
||||
export function assertSupportedSettingsVersion(
|
||||
value: unknown,
|
||||
currentVersion: number,
|
||||
message: (version: number) => string
|
||||
): void {
|
||||
if (
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
'version' in value &&
|
||||
typeof value.version === 'number' &&
|
||||
value.version > currentVersion
|
||||
) {
|
||||
throw new UnsupportedSettingsVersionError(message(value.version))
|
||||
}
|
||||
}
|
||||
|
||||
export async function isolateCorruptSettingsFile(
|
||||
filePath: string,
|
||||
failureMessage: string,
|
||||
now: () => number = Date.now,
|
||||
operations?: Partial<SettingsFileOperations>
|
||||
): Promise<void> {
|
||||
const fileOperations = resolveSettingsFileOperations(operations)
|
||||
const isolatedPath =
|
||||
`${filePath}.corrupt-${now()}-` +
|
||||
randomBytes(6).toString('hex')
|
||||
try {
|
||||
await fileOperations.rename(filePath, isolatedPath)
|
||||
} catch (error) {
|
||||
if (!isMissingFileError(error)) {
|
||||
throw new Error(failureMessage, { cause: error })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeJsonFileAtomically(
|
||||
filePath: string,
|
||||
value: unknown,
|
||||
operations?: Partial<SettingsFileOperations>
|
||||
): Promise<void> {
|
||||
const fileOperations = resolveSettingsFileOperations(operations)
|
||||
await mkdir(dirname(filePath), { recursive: true })
|
||||
const temporaryPath =
|
||||
`${filePath}.${process.pid}.` +
|
||||
`${randomBytes(6).toString('hex')}.tmp`
|
||||
try {
|
||||
await fileOperations.writeFile(
|
||||
temporaryPath,
|
||||
`${JSON.stringify(value, null, 2)}\n`,
|
||||
{
|
||||
encoding: 'utf8',
|
||||
mode: 0o600,
|
||||
flag: 'wx'
|
||||
}
|
||||
)
|
||||
await fileOperations.rename(temporaryPath, filePath)
|
||||
} finally {
|
||||
await rm(temporaryPath, { force: true })
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { waitForCleanup } from './shutdown'
|
||||
import {
|
||||
runCleanupBeforeDeadline,
|
||||
settleCleanupPhases,
|
||||
waitForCleanup
|
||||
} from './shutdown'
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
@@ -23,4 +27,49 @@ describe('waitForCleanup', () => {
|
||||
|
||||
await expect(result).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('runs dependent cleanup phases in order despite failures', async () => {
|
||||
const order: string[] = []
|
||||
|
||||
await settleCleanupPhases([
|
||||
[
|
||||
async () => {
|
||||
order.push('ipc')
|
||||
throw new Error('cleanup failed')
|
||||
}
|
||||
],
|
||||
[
|
||||
async () => {
|
||||
order.push('gateway')
|
||||
}
|
||||
],
|
||||
[
|
||||
async () => {
|
||||
order.push('knowledge')
|
||||
}
|
||||
]
|
||||
])
|
||||
|
||||
expect(order).toEqual(['ipc', 'gateway', 'knowledge'])
|
||||
})
|
||||
|
||||
it('finalizes databases only after cleanup beats the deadline', async () => {
|
||||
const finalize = vi.fn()
|
||||
await expect(
|
||||
runCleanupBeforeDeadline(Promise.resolve(), 100, finalize)
|
||||
).resolves.toBe(true)
|
||||
expect(finalize).toHaveBeenCalledOnce()
|
||||
|
||||
vi.useFakeTimers()
|
||||
const timedOutFinalize = vi.fn()
|
||||
const result = runCleanupBeforeDeadline(
|
||||
new Promise(() => {}),
|
||||
100,
|
||||
timedOutFinalize
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
|
||||
await expect(result).resolves.toBe(false)
|
||||
expect(timedOutFinalize).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,3 +16,27 @@ export async function waitForCleanup(
|
||||
}
|
||||
return completed
|
||||
}
|
||||
|
||||
export type CleanupOperation = () => unknown | Promise<unknown>
|
||||
|
||||
export async function settleCleanupPhases(
|
||||
phases: readonly (readonly CleanupOperation[])[]
|
||||
): Promise<void> {
|
||||
for (const phase of phases) {
|
||||
await Promise.allSettled(
|
||||
phase.map((operation) => Promise.resolve().then(operation))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function runCleanupBeforeDeadline(
|
||||
cleanup: Promise<unknown>,
|
||||
timeoutMs: number,
|
||||
finalize: () => unknown | Promise<unknown>
|
||||
): Promise<boolean> {
|
||||
const completed = await waitForCleanup(cleanup, timeoutMs)
|
||||
if (completed) {
|
||||
await finalize()
|
||||
}
|
||||
return completed
|
||||
}
|
||||
|
||||
@@ -740,6 +740,8 @@ describe('App', () => {
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Chat' })
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('Desktop workspace')).toBeInTheDocument()
|
||||
expect(screen.getByText('GOODBUDDY WORKSPACE')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('heading', {
|
||||
name: 'What would you like to accomplish today?'
|
||||
@@ -760,6 +762,13 @@ describe('App', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('renders localized workspace branding in Chinese', async () => {
|
||||
render(<App />)
|
||||
|
||||
expect(await screen.findByText('桌面工作区')).toBeInTheDocument()
|
||||
expect(screen.getByText('GOODBUDDY 工作台')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps Settings open when the interface language changes', async () => {
|
||||
api.updates = {
|
||||
getSettings: vi.fn(async () => ({
|
||||
@@ -3278,7 +3287,7 @@ describe('App', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('normalizes a legacy Auto conversation to the explicit default Runtime', async () => {
|
||||
it('preserves a legacy Auto conversation without silently persisting a replacement', async () => {
|
||||
vi.mocked(api.conversations.list).mockResolvedValueOnce([
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000020',
|
||||
@@ -3306,17 +3315,14 @@ describe('App', () => {
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: '00000000-0000-4000-8000-000000000020',
|
||||
runtimeSelection: {
|
||||
provider: 'model',
|
||||
profileId: modelProfileId
|
||||
}
|
||||
runtimeSelection: { provider: 'auto' }
|
||||
})
|
||||
])
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('rebinds a loaded conversation when its model profile was removed', async () => {
|
||||
it('keeps a removed model selection visible until the user replaces it', async () => {
|
||||
const removedProfileId =
|
||||
'00000000-0000-4000-8000-000000000099'
|
||||
vi.mocked(api.conversations.list).mockResolvedValueOnce([
|
||||
@@ -3341,9 +3347,6 @@ describe('App', () => {
|
||||
])
|
||||
render(<App />)
|
||||
|
||||
expect(
|
||||
await screen.findByRole('button', { name: /默认模型.*sonnet-5/u })
|
||||
).toBeInTheDocument()
|
||||
await waitFor(() =>
|
||||
expect(api.conversations.replace).toHaveBeenLastCalledWith(
|
||||
expect.arrayContaining([
|
||||
@@ -3351,7 +3354,7 @@ describe('App', () => {
|
||||
id: '00000000-0000-4000-8000-000000000022',
|
||||
runtimeSelection: {
|
||||
provider: 'model',
|
||||
profileId: modelProfileId
|
||||
profileId: removedProfileId
|
||||
}
|
||||
})
|
||||
])
|
||||
|
||||
+31
-106
@@ -65,9 +65,12 @@ import { maximumPastedImageBytes } from '../../shared/contracts'
|
||||
import {
|
||||
agentRuntimeSelectionKey,
|
||||
agentRuntimeSelectionSchema,
|
||||
repairAgentRuntimeSelection,
|
||||
type AgentRuntimeSelection
|
||||
} from '../../shared/runtime-selection-contracts'
|
||||
import {
|
||||
getDefaultRuntimeSelection,
|
||||
getRuntimeSelectionForProvider
|
||||
} from './runtime-selection'
|
||||
import type {
|
||||
AssistantProject,
|
||||
AssistantArtifact,
|
||||
@@ -802,45 +805,6 @@ function mergeArtifacts(
|
||||
)
|
||||
}
|
||||
|
||||
function getDefaultRuntimeSelection(
|
||||
settings: RuntimeSettings
|
||||
): AgentRuntimeSelection {
|
||||
if (settings.provider === 'model') {
|
||||
return {
|
||||
provider: 'model',
|
||||
profileId: settings.defaultModelProfileId
|
||||
}
|
||||
}
|
||||
if (settings.provider === 'opencode') {
|
||||
return {
|
||||
provider: 'opencode',
|
||||
...(settings.opencodeModelSource.kind === 'profile'
|
||||
? { profileId: settings.opencodeModelSource.profileId }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
if (settings.provider === 'continue') {
|
||||
return {
|
||||
provider: 'continue',
|
||||
...(settings.continueModelSource.kind === 'profile'
|
||||
? { profileId: settings.continueModelSource.profileId }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
if (settings.opencodeBaseUrl || settings.opencodeEmbedded) {
|
||||
return {
|
||||
provider: 'opencode',
|
||||
...(settings.opencodeModelSource.kind === 'profile'
|
||||
? { profileId: settings.opencodeModelSource.profileId }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
return {
|
||||
provider: 'model',
|
||||
profileId: settings.defaultModelProfileId
|
||||
}
|
||||
}
|
||||
|
||||
function getProjectDefaultRuntimeSelection(
|
||||
project: AssistantProject | undefined,
|
||||
settings: RuntimeSettings
|
||||
@@ -848,7 +812,7 @@ function getProjectDefaultRuntimeSelection(
|
||||
const selection = project?.runtimeSelection
|
||||
return !selection || selection.provider === 'auto'
|
||||
? getDefaultRuntimeSelection(settings)
|
||||
: repairAgentRuntimeSelection(selection, settings)
|
||||
: selection
|
||||
}
|
||||
|
||||
function getRuntimeSelectionLabel(
|
||||
@@ -859,6 +823,7 @@ function getRuntimeSelectionLabel(
|
||||
directModel: string
|
||||
automatic: string
|
||||
automaticSelection: string
|
||||
modelUnavailable: string
|
||||
}
|
||||
): string {
|
||||
if (!selection || !settings) {
|
||||
@@ -870,36 +835,36 @@ function getRuntimeSelectionLabel(
|
||||
(candidate) => candidate.id === selection.profileId
|
||||
)
|
||||
: undefined
|
||||
const requestedProfileMissing =
|
||||
'profileId' in selection &&
|
||||
Boolean(selection.profileId) &&
|
||||
profile === undefined
|
||||
if (selection.provider === 'model') {
|
||||
return profile
|
||||
? `${profile.name} · ${profile.modelName}`
|
||||
: status?.label ?? labels.directModel
|
||||
: requestedProfileMissing
|
||||
? labels.modelUnavailable
|
||||
: status?.label ?? labels.directModel
|
||||
}
|
||||
if (selection.provider === 'opencode') {
|
||||
return profile ? `OpenCode · ${profile.name}` : 'OpenCode'
|
||||
return profile
|
||||
? `OpenCode · ${profile.name}`
|
||||
: requestedProfileMissing
|
||||
? `OpenCode · ${labels.modelUnavailable}`
|
||||
: 'OpenCode'
|
||||
}
|
||||
if (selection.provider === 'continue') {
|
||||
return profile ? `Continue · ${profile.name}` : 'Continue'
|
||||
return profile
|
||||
? `Continue · ${profile.name}`
|
||||
: requestedProfileMissing
|
||||
? `Continue · ${labels.modelUnavailable}`
|
||||
: 'Continue'
|
||||
}
|
||||
return status
|
||||
? `${labels.automatic} · ${status.label}`
|
||||
: labels.automaticSelection
|
||||
}
|
||||
|
||||
function getConfiguredAgentRuntimeSelection(
|
||||
settings: RuntimeSettings,
|
||||
provider: 'opencode' | 'continue'
|
||||
): AgentRuntimeSelection {
|
||||
const source =
|
||||
provider === 'opencode'
|
||||
? settings.opencodeModelSource
|
||||
: settings.continueModelSource
|
||||
return {
|
||||
provider,
|
||||
...(source.kind === 'profile' ? { profileId: source.profileId } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function getConfiguredAgentRuntimeSource(
|
||||
settings: RuntimeSettings,
|
||||
provider: 'opencode' | 'continue',
|
||||
@@ -910,7 +875,7 @@ function getConfiguredAgentRuntimeSource(
|
||||
useOwnConfiguration: (runtime: string) => string
|
||||
}
|
||||
): { label: string; detail: string } {
|
||||
const selection = getConfiguredAgentRuntimeSelection(settings, provider)
|
||||
const selection = getRuntimeSelectionForProvider(provider, settings)
|
||||
const profile =
|
||||
'profileId' in selection
|
||||
? settings.modelProfiles.find(
|
||||
@@ -1766,7 +1731,8 @@ function App(): React.JSX.Element {
|
||||
() => ({
|
||||
directModel: t('runtime.directModel'),
|
||||
automatic: t('runtime.automatic'),
|
||||
automaticSelection: t('runtime.automaticSelection')
|
||||
automaticSelection: t('runtime.automaticSelection'),
|
||||
modelUnavailable: t('runtime.modelUnavailable')
|
||||
}),
|
||||
[t]
|
||||
)
|
||||
@@ -1787,10 +1753,10 @@ function App(): React.JSX.Element {
|
||||
runtimeLabels
|
||||
)
|
||||
const openCodeMenuSelection = runtimeSettings
|
||||
? getConfiguredAgentRuntimeSelection(runtimeSettings, 'opencode')
|
||||
? getRuntimeSelectionForProvider('opencode', runtimeSettings)
|
||||
: undefined
|
||||
const continueMenuSelection = runtimeSettings
|
||||
? getConfiguredAgentRuntimeSelection(runtimeSettings, 'continue')
|
||||
? getRuntimeSelectionForProvider('continue', runtimeSettings)
|
||||
: undefined
|
||||
const openCodeMenuSource = runtimeSettings
|
||||
? getConfiguredAgentRuntimeSource(
|
||||
@@ -1866,48 +1832,6 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
}, [activeId, conversations])
|
||||
|
||||
useEffect(() => {
|
||||
if (!runtimeSettings || !conversationStoreReady) {
|
||||
return
|
||||
}
|
||||
const timeout = setTimeout(() => {
|
||||
setConversations((current) => {
|
||||
let changed = false
|
||||
const next = current.map((conversation) => {
|
||||
const project = projects.find(
|
||||
(candidate) => candidate.id === conversation.projectId
|
||||
)
|
||||
const defaultSelection = getProjectDefaultRuntimeSelection(
|
||||
project,
|
||||
runtimeSettings
|
||||
)
|
||||
const selection =
|
||||
!conversation.runtimeSelection ||
|
||||
conversation.runtimeSelection.provider === 'auto'
|
||||
? defaultSelection
|
||||
: repairAgentRuntimeSelection(
|
||||
conversation.runtimeSelection,
|
||||
runtimeSettings
|
||||
)
|
||||
if (
|
||||
conversation.runtimeSelection &&
|
||||
agentRuntimeSelectionKey(conversation.runtimeSelection) ===
|
||||
agentRuntimeSelectionKey(selection)
|
||||
) {
|
||||
return conversation
|
||||
}
|
||||
changed = true
|
||||
return {
|
||||
...conversation,
|
||||
runtimeSelection: selection
|
||||
}
|
||||
})
|
||||
return changed ? next : current
|
||||
})
|
||||
}, 0)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [conversationStoreReady, projects, runtimeSettings])
|
||||
|
||||
useEffect(() => {
|
||||
const selection = activeRuntimeSelectionRef.current
|
||||
if (!selection || !runtimeSettings) {
|
||||
@@ -4762,7 +4686,7 @@ function App(): React.JSX.Element {
|
||||
</div>
|
||||
<div className="brand__copy">
|
||||
<strong>GoodBuddy</strong>
|
||||
<span>Desktop workspace</span>
|
||||
<span>{t('brand.desktopWorkspace')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5299,7 +5223,7 @@ function App(): React.JSX.Element {
|
||||
<div className="welcome__badge">
|
||||
<Sparkles size={18} />
|
||||
</div>
|
||||
<p className="eyebrow">GOODBUDDY WORKSPACE</p>
|
||||
<p className="eyebrow">{t('chat.welcome.eyebrow')}</p>
|
||||
<h1>{t('chat.welcome.title')}</h1>
|
||||
<p className="welcome__description">
|
||||
{t('chat.welcome.description')}
|
||||
@@ -6849,6 +6773,7 @@ function App(): React.JSX.Element {
|
||||
<SettingsPanel
|
||||
appearanceTheme={appearanceTheme}
|
||||
heartbeats={assistantHeartbeats}
|
||||
magicNotesEnabled={magicNotesEnabled}
|
||||
onAppearanceThemeChange={setAppearanceTheme}
|
||||
onClearLocalData={clearLocalData}
|
||||
onClose={() => setView('chat')}
|
||||
|
||||
@@ -35,7 +35,10 @@ import type { WeixinBindingSnapshot } from '../../shared/weixin-channel-contract
|
||||
import type { AppNotificationInput } from './notifications'
|
||||
import { trapTabFocus } from './dialog-focus'
|
||||
import { PageTabs, SegmentedControl } from './WorkspacePrimitives'
|
||||
import { SettingsCategoryHeader } from './SettingsPrimitives'
|
||||
import {
|
||||
SettingsCategoryHeader,
|
||||
SettingsWarningList
|
||||
} from './SettingsPrimitives'
|
||||
|
||||
type ChannelDraft = {
|
||||
enabled: boolean
|
||||
@@ -455,6 +458,8 @@ function ChannelEditor({
|
||||
<small>
|
||||
{settings.source === 'environment'
|
||||
? t('channels.credential.environmentSource')
|
||||
: settings.source === 'unreadable'
|
||||
? t('channels.credential.secretUnreadable')
|
||||
: settings.secretConfigured
|
||||
? t('channels.credential.secretSaved')
|
||||
: t('channels.credential.secretMissing')}
|
||||
@@ -1327,7 +1332,7 @@ export function ChannelSettingsSection({
|
||||
aria-label={t('channels.sectionAriaLabel')}
|
||||
className="settings-section channel-settings"
|
||||
>
|
||||
{snapshot.warning && <p className="settings-warning">{snapshot.warning}</p>}
|
||||
<SettingsWarningList warnings={snapshot.warnings} />
|
||||
|
||||
<div className="channel-settings__tabs">
|
||||
<PageTabs
|
||||
|
||||
@@ -199,6 +199,36 @@ describe('DocumentParsingSettingsSection', () => {
|
||||
|
||||
afterEach(() => cleanup())
|
||||
|
||||
it.each([
|
||||
['zh-CN', '正在加载…'],
|
||||
['en-US', 'Loading…']
|
||||
] as const)('localizes the loading state in %s', async (locale, label) => {
|
||||
await changeUiLocale(locale)
|
||||
getSnapshot.mockImplementationOnce(
|
||||
() => new Promise(() => undefined)
|
||||
)
|
||||
|
||||
render(<DocumentParsingSettingsSection />)
|
||||
|
||||
expect(screen.getByText(label)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('localizes recovered document parsing settings warnings', async () => {
|
||||
await changeUiLocale('en-US')
|
||||
getSnapshot.mockResolvedValueOnce({
|
||||
...snapshot,
|
||||
warnings: [{ code: 'document-parsing-settings-recovered' }]
|
||||
})
|
||||
|
||||
render(<DocumentParsingSettingsSection />)
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
/The document parsing settings file was corrupt/u
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows actual capability status and saves workflow settings', async () => {
|
||||
const onNotify = vi.fn()
|
||||
render(
|
||||
|
||||
@@ -29,7 +29,10 @@ import type {
|
||||
DocumentParsingTestPurpose
|
||||
} from '../../shared/document-parsing-contracts'
|
||||
import type { AppNotificationInput } from './notifications'
|
||||
import { SettingsCategoryHeader } from './SettingsPrimitives'
|
||||
import {
|
||||
SettingsCategoryHeader,
|
||||
SettingsWarningList
|
||||
} from './SettingsPrimitives'
|
||||
|
||||
type DocumentParsingSettingsSectionProps = {
|
||||
onNotify?: (notification: AppNotificationInput) => void
|
||||
@@ -402,7 +405,9 @@ export function DocumentParsingSettingsSection({
|
||||
error={error ?? unavailableError}
|
||||
/>
|
||||
{!error && !unavailableError && (
|
||||
<p className="settings-empty">Loading…</p>
|
||||
<p className="settings-empty">
|
||||
{t('documentParsing.loading')}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
@@ -453,6 +458,7 @@ export function DocumentParsingSettingsSection({
|
||||
category="document-parsing"
|
||||
error={error}
|
||||
/>
|
||||
<SettingsWarningList warnings={snapshot.warnings} />
|
||||
{settingsDirty && (
|
||||
<p
|
||||
className="settings-notice"
|
||||
|
||||
@@ -31,7 +31,10 @@ import type {
|
||||
WebSearchTestResult
|
||||
} from '../../shared/capability-contracts'
|
||||
import { trapTabFocus } from './dialog-focus'
|
||||
import { SettingsCategoryHeader } from './SettingsPrimitives'
|
||||
import {
|
||||
SettingsCategoryHeader,
|
||||
SettingsWarningList
|
||||
} from './SettingsPrimitives'
|
||||
import { PageTabs } from './WorkspacePrimitives'
|
||||
|
||||
const configurableMcpTargets: RuntimeTarget[] = ['model']
|
||||
@@ -85,7 +88,11 @@ function editorFromServer(server: McpServerSummary): McpEditor {
|
||||
}
|
||||
}
|
||||
|
||||
export function McpSettingsSection(): React.JSX.Element {
|
||||
export function McpSettingsSection({
|
||||
magicNotesEnabled = false
|
||||
}: {
|
||||
magicNotesEnabled?: boolean
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('integrations')
|
||||
const tRef = useRef(t)
|
||||
useEffect(() => {
|
||||
@@ -106,7 +113,6 @@ export function McpSettingsSection(): React.JSX.Element {
|
||||
disabled: t('mcp.diagnosticStatuses.disabled')
|
||||
}
|
||||
const [snapshot, setSnapshot] = useState<CapabilitySnapshot>()
|
||||
const [magicNotesEnabled, setMagicNotesEnabled] = useState(false)
|
||||
const [editor, setEditor] = useState<McpEditor>()
|
||||
const [busy, setBusy] = useState<string>()
|
||||
const [error, setError] = useState<string>()
|
||||
@@ -158,20 +164,6 @@ export function McpSettingsSection(): React.JSX.Element {
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const getSettings = window.goodbuddy.updates?.getSettings
|
||||
if (!getSettings) {
|
||||
return
|
||||
}
|
||||
void getSettings()
|
||||
.then((settings) => {
|
||||
setMagicNotesEnabled(settings.magicNotesEnabled)
|
||||
})
|
||||
.catch(() => {
|
||||
setMagicNotesEnabled(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorOpen) {
|
||||
return
|
||||
@@ -417,6 +409,7 @@ export function McpSettingsSection(): React.JSX.Element {
|
||||
error={!editor ? error : undefined}
|
||||
headingId="mcp-settings-heading"
|
||||
/>
|
||||
<SettingsWarningList warnings={snapshot?.warnings} />
|
||||
<PageTabs
|
||||
ariaLabel={t('mcp.tabs.ariaLabel')}
|
||||
idPrefix="mcp-settings"
|
||||
|
||||
@@ -6,7 +6,10 @@ import type {
|
||||
} from '../../shared/application-settings-contracts'
|
||||
import type { MagicNoteCommentFormat } from '../../shared/magic-notes-contracts'
|
||||
import { SegmentedControl } from './WorkspacePrimitives'
|
||||
import { SettingsCategoryHeader } from './SettingsPrimitives'
|
||||
import {
|
||||
SettingsCategoryHeader,
|
||||
SettingsWarningList
|
||||
} from './SettingsPrimitives'
|
||||
|
||||
type PlatformFeaturesSettingsSectionProps = {
|
||||
onMagicNotesEnabledChange: (enabled: boolean) => void
|
||||
@@ -116,6 +119,7 @@ export function PlatformFeaturesSettingsSection({
|
||||
error={error}
|
||||
headingId="platform-features-heading"
|
||||
/>
|
||||
<SettingsWarningList warnings={settings?.warnings} />
|
||||
<section
|
||||
aria-label={t('platformFeatures.label')}
|
||||
className="settings-section"
|
||||
|
||||
@@ -18,8 +18,11 @@ import {
|
||||
normalizeInteractiveWorkMode
|
||||
} from '../../shared/assistant-contracts'
|
||||
import type { RuntimeSettings } from '../../shared/contracts'
|
||||
import type { AgentRuntimeSelection } from '../../shared/runtime-selection-contracts'
|
||||
import { trapTabFocus } from './dialog-focus'
|
||||
import {
|
||||
getDefaultRuntimeSelection,
|
||||
getRuntimeSelectionForProvider
|
||||
} from './runtime-selection'
|
||||
|
||||
type ProjectSwitcherProps = {
|
||||
projects: AssistantProject[]
|
||||
@@ -36,43 +39,6 @@ type ProjectSwitcherProps = {
|
||||
) => Promise<AssistantProject>
|
||||
}
|
||||
|
||||
function runtimeSelectionForProvider(
|
||||
provider: 'model' | 'opencode' | 'continue',
|
||||
settings: RuntimeSettings
|
||||
): AgentRuntimeSelection {
|
||||
if (provider === 'model') {
|
||||
return {
|
||||
provider,
|
||||
profileId: settings.defaultModelProfileId
|
||||
}
|
||||
}
|
||||
const source =
|
||||
provider === 'opencode'
|
||||
? settings.opencodeModelSource
|
||||
: settings.continueModelSource
|
||||
return {
|
||||
provider,
|
||||
...(source.kind === 'profile' ? { profileId: source.profileId } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function defaultRuntimeSelection(
|
||||
settings: RuntimeSettings
|
||||
): AgentRuntimeSelection {
|
||||
if (settings.provider === 'model') {
|
||||
return runtimeSelectionForProvider('model', settings)
|
||||
}
|
||||
if (settings.provider === 'opencode') {
|
||||
return runtimeSelectionForProvider('opencode', settings)
|
||||
}
|
||||
if (settings.provider === 'continue') {
|
||||
return runtimeSelectionForProvider('continue', settings)
|
||||
}
|
||||
return settings.opencodeBaseUrl || settings.opencodeEmbedded
|
||||
? runtimeSelectionForProvider('opencode', settings)
|
||||
: runtimeSelectionForProvider('model', settings)
|
||||
}
|
||||
|
||||
export function ProjectSwitcher({
|
||||
projects,
|
||||
activeProjectId,
|
||||
@@ -157,7 +123,7 @@ export function ProjectSwitcher({
|
||||
? draft
|
||||
: {
|
||||
...draft,
|
||||
runtimeSelection: defaultRuntimeSelection(runtimeSettings)
|
||||
runtimeSelection: getDefaultRuntimeSelection(runtimeSettings)
|
||||
}
|
||||
if (dialogMode === 'settings' && activeProject) {
|
||||
await onUpdate(activeProject.id, input)
|
||||
@@ -276,7 +242,7 @@ export function ProjectSwitcher({
|
||||
rootPath: '',
|
||||
defaultWorkMode: 'ask',
|
||||
runtimeSelection: runtimeSettings
|
||||
? defaultRuntimeSelection(runtimeSettings)
|
||||
? getDefaultRuntimeSelection(runtimeSettings)
|
||||
: undefined
|
||||
})
|
||||
restoreFocusTarget.current = 'create'
|
||||
@@ -308,7 +274,7 @@ export function ProjectSwitcher({
|
||||
runtimeSelection:
|
||||
activeProject.runtimeSelection ??
|
||||
(runtimeSettings
|
||||
? defaultRuntimeSelection(runtimeSettings)
|
||||
? getDefaultRuntimeSelection(runtimeSettings)
|
||||
: undefined)
|
||||
})
|
||||
restoreFocusTarget.current = 'settings'
|
||||
@@ -441,7 +407,7 @@ export function ProjectSwitcher({
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
runtimeSelection: runtimeSelectionForProvider(
|
||||
runtimeSelection: getRuntimeSelectionForProvider(
|
||||
event.target.value as
|
||||
| 'model'
|
||||
| 'opencode'
|
||||
@@ -454,7 +420,7 @@ export function ProjectSwitcher({
|
||||
draft.runtimeSelection?.provider === 'auto'
|
||||
? 'model'
|
||||
: (draft.runtimeSelection?.provider ??
|
||||
defaultRuntimeSelection(runtimeSettings)
|
||||
getDefaultRuntimeSelection(runtimeSettings)
|
||||
.provider)
|
||||
}
|
||||
>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
waitFor,
|
||||
within
|
||||
} from '@testing-library/react'
|
||||
import { useState } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AssistantExpert } from '../../shared/assistant-contracts'
|
||||
import type {
|
||||
@@ -569,6 +570,31 @@ describe('SettingsPanel runtime files', () => {
|
||||
screen.getByRole('radio', { name: /Use system language/u })
|
||||
).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('tab', { name: 'Model connections' })
|
||||
)
|
||||
expect(
|
||||
await screen.findByRole('button', {
|
||||
name: 'Edit model connection Default model'
|
||||
})
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Name')).toHaveValue('Default model')
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: 'Save settings' })
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(updateRuntime).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
modelProfiles: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: modelProfileId,
|
||||
name: '默认模型'
|
||||
})
|
||||
])
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('tab', { name: 'Agent Runtime' })
|
||||
)
|
||||
@@ -590,6 +616,69 @@ describe('SettingsPanel runtime files', () => {
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not translate user-defined model connection names', async () => {
|
||||
const userProfileId = '00000000-0000-4000-8000-000000000099'
|
||||
getRuntime.mockResolvedValueOnce({
|
||||
...runtimeSettings,
|
||||
modelProfiles: [
|
||||
{
|
||||
...runtimeSettings.modelProfiles[0]!,
|
||||
name: 'My renamed model'
|
||||
},
|
||||
{
|
||||
...runtimeSettings.modelProfiles[0]!,
|
||||
id: userProfileId,
|
||||
name: '默认模型'
|
||||
}
|
||||
]
|
||||
})
|
||||
await changeUiLocale('en-US')
|
||||
render(
|
||||
<SettingsPanel
|
||||
{...heartbeatSettingsProps}
|
||||
open
|
||||
onClearLocalData={vi.fn(async () => {})}
|
||||
onClose={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('tab', { name: 'Model connections' })
|
||||
)
|
||||
expect(
|
||||
await screen.findByRole('button', {
|
||||
name: 'Edit model connection My renamed model'
|
||||
})
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: 'Edit model connection 默认模型'
|
||||
})
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('localizes structured Runtime recovery warnings', async () => {
|
||||
getRuntime.mockResolvedValueOnce({
|
||||
...runtimeSettings,
|
||||
warnings: [{ code: 'runtime-settings-recovered' }]
|
||||
})
|
||||
await changeUiLocale('en-US')
|
||||
render(
|
||||
<SettingsPanel
|
||||
{...heartbeatSettingsProps}
|
||||
open
|
||||
onClearLocalData={vi.fn(async () => {})}
|
||||
onClose={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(
|
||||
await screen.findByText(/The Runtime settings file was corrupt/u)
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('toggles the Magic Notes platform entry setting', async () => {
|
||||
const onMagicNotesEnabledChange = vi.fn()
|
||||
render(
|
||||
@@ -617,7 +706,6 @@ describe('SettingsPanel runtime files', () => {
|
||||
})
|
||||
)
|
||||
expect(onMagicNotesEnabledChange).toHaveBeenCalledWith(true)
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '保存后自动' })
|
||||
)
|
||||
@@ -638,6 +726,59 @@ describe('SettingsPanel runtime files', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('refreshes built-in Notes MCP after enabling Magic Notes', async () => {
|
||||
function Harness(): React.JSX.Element {
|
||||
const [magicNotesEnabled, setMagicNotesEnabled] = useState(false)
|
||||
return (
|
||||
<SettingsPanel
|
||||
{...heartbeatSettingsProps}
|
||||
magicNotesEnabled={magicNotesEnabled}
|
||||
onMagicNotesEnabledChange={setMagicNotesEnabled}
|
||||
open
|
||||
onClearLocalData={vi.fn(async () => {})}
|
||||
onClose={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
render(
|
||||
<Harness />
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'MCP' }))
|
||||
const noteServerToggle = await screen.findByRole('button', {
|
||||
name: '展开服务器 笔记'
|
||||
})
|
||||
expect(noteServerToggle.closest('article')).toHaveClass(
|
||||
'mcp-server-card--disabled'
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
|
||||
fireEvent.click(
|
||||
await screen.findByRole('switch', {
|
||||
name: '显示魔法笔记入口'
|
||||
})
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(updateApplicationSettings).toHaveBeenCalledWith({
|
||||
magicNotesEnabled: true
|
||||
})
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'MCP' }))
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: '展开服务器 笔记' })
|
||||
.closest('article')
|
||||
).not.toHaveClass('mcp-server-card--disabled')
|
||||
)
|
||||
expect(
|
||||
screen.getByText('内置 MCP Server · 按模式读写 · 按对话授权')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps page navigation beside an independently scrollable panel', () => {
|
||||
render(
|
||||
<SettingsPanel
|
||||
@@ -759,6 +900,77 @@ describe('SettingsPanel runtime files', () => {
|
||||
expect(screen.queryByText('设置已保存')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('submits configured model values while environment values are effective', async () => {
|
||||
getRuntime.mockResolvedValueOnce({
|
||||
...runtimeSettings,
|
||||
modelBaseUrl: 'https://environment.example/v1',
|
||||
modelName: 'environment-model',
|
||||
apiKeyConfigured: true,
|
||||
credentialSource: 'environment',
|
||||
modelProfiles: [
|
||||
{
|
||||
...runtimeSettings.modelProfiles[0]!,
|
||||
baseUrl: 'https://environment.example/v1',
|
||||
modelName: 'environment-model',
|
||||
apiKeyConfigured: true,
|
||||
credentialSource: 'environment'
|
||||
}
|
||||
],
|
||||
configured: {
|
||||
modelProfiles: [
|
||||
{
|
||||
...runtimeSettings.modelProfiles[0]!,
|
||||
baseUrl: 'https://stored.example/v1',
|
||||
modelName: 'stored-model',
|
||||
apiKeyConfigured: true,
|
||||
credentialSource: 'environment'
|
||||
}
|
||||
],
|
||||
opencodeBaseUrl: '',
|
||||
opencodeBinaryPath: '',
|
||||
opencodeConfigPath: '',
|
||||
continueBinaryPath: '',
|
||||
continueConfigPath: '',
|
||||
workspacePath: 'C:\\Workspace',
|
||||
opencodeModelSource: runtimeSettings.opencodeModelSource,
|
||||
continueModelSource: runtimeSettings.continueModelSource
|
||||
}
|
||||
})
|
||||
render(
|
||||
<SettingsPanel
|
||||
{...heartbeatSettingsProps}
|
||||
open
|
||||
onClearLocalData={vi.fn(async () => {})}
|
||||
onClose={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
await screen.findByDisplayValue('C:\\Workspace')
|
||||
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
|
||||
expect(
|
||||
await screen.findByDisplayValue('https://environment.example/v1')
|
||||
).toBeDisabled()
|
||||
expect(screen.getByDisplayValue('environment-model')).toBeDisabled()
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(updateRuntime).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
modelBaseUrl: 'https://stored.example/v1',
|
||||
modelName: 'stored-model',
|
||||
modelProfiles: [
|
||||
expect.objectContaining({
|
||||
baseUrl: 'https://stored.example/v1',
|
||||
modelName: 'stored-model',
|
||||
apiKey: { action: 'keep' }
|
||||
})
|
||||
]
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('applies a speech model draft only when Settings is saved', async () => {
|
||||
render(
|
||||
<SettingsPanel
|
||||
|
||||
+259
-138
@@ -9,7 +9,7 @@ import {
|
||||
Trash2,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type {
|
||||
AssistantExpert,
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
} from '../../shared/contracts'
|
||||
import type { AgentRuntimeSelection } from '../../shared/runtime-selection-contracts'
|
||||
import {
|
||||
defaultModelProfileId as builtInDefaultModelProfileId,
|
||||
defaultRuntimeSettings,
|
||||
isAgentRuntimeModelProtocol
|
||||
} from '../../shared/contracts'
|
||||
@@ -40,7 +41,10 @@ import { SpeechModelSettingsSection } from './SpeechModelSettingsSection'
|
||||
import { EmbeddingSettingsSection } from './EmbeddingSettingsSection'
|
||||
import { DocumentParsingSettingsSection } from './DocumentParsingSettingsSection'
|
||||
import { PageHeader, SegmentedControl } from './WorkspacePrimitives'
|
||||
import { SettingsCategoryHeader } from './SettingsPrimitives'
|
||||
import {
|
||||
SettingsCategoryHeader,
|
||||
SettingsWarningList
|
||||
} from './SettingsPrimitives'
|
||||
import {
|
||||
settingsCategoryList,
|
||||
type SettingsCategoryId
|
||||
@@ -81,6 +85,7 @@ type SettingsPanelProps = {
|
||||
onRunHeartbeat: (heartbeatId: string) => Promise<void>
|
||||
appearanceTheme?: AppearanceTheme
|
||||
onAppearanceThemeChange?: (theme: AppearanceTheme) => void
|
||||
magicNotesEnabled?: boolean
|
||||
onMagicNotesEnabledChange?: (enabled: boolean) => void
|
||||
}
|
||||
|
||||
@@ -120,6 +125,118 @@ function toModelProfileDrafts(
|
||||
}))
|
||||
}
|
||||
|
||||
function configuredRuntimeSettings(
|
||||
settings: RuntimeSettings
|
||||
): NonNullable<RuntimeSettings['configured']> {
|
||||
return settings.configured ?? {
|
||||
modelProfiles: settings.modelProfiles,
|
||||
opencodeBaseUrl: settings.opencodeBaseUrl,
|
||||
opencodeBinaryPath: settings.opencodeBinaryPath,
|
||||
opencodeConfigPath: settings.opencodeConfigPath,
|
||||
continueBinaryPath: settings.continueBinaryPath,
|
||||
continueConfigPath: settings.continueConfigPath,
|
||||
workspacePath: settings.workspacePath,
|
||||
opencodeModelSource: settings.opencodeModelSource,
|
||||
continueModelSource: settings.continueModelSource
|
||||
}
|
||||
}
|
||||
|
||||
type RuntimeDraftSelection =
|
||||
| string
|
||||
| ((selectedId: string) => string)
|
||||
|
||||
function hydrateRuntimeSettings(
|
||||
value: RuntimeSettings,
|
||||
setters: {
|
||||
settings: (value: RuntimeSettings) => void
|
||||
provider: (value: RuntimeSettings['provider']) => void
|
||||
modelProfiles: (value: ModelProfileDraft[]) => void
|
||||
selectedModelProfileId: (value: RuntimeDraftSelection) => void
|
||||
defaultModelProfileId: (value: string) => void
|
||||
opencodeModelSource: (value: RuntimeModelSource) => void
|
||||
continueModelSource: (value: RuntimeModelSource) => void
|
||||
opencodeBaseUrl: (value: string) => void
|
||||
opencodeBinaryPath: (value: string) => void
|
||||
opencodeConfigPath: (value: string) => void
|
||||
continueBinaryPath: (value: string) => void
|
||||
continueConfigPath: (value: string) => void
|
||||
continueMode: (value: RuntimeSettings['continueMode']) => void
|
||||
runtimeSandboxMode: (
|
||||
value: RuntimeSettings['runtimeSandboxMode']
|
||||
) => void
|
||||
knowledgeEmbeddingEnabled: (value: boolean) => void
|
||||
knowledgeEmbeddingBaseUrl: (value: string) => void
|
||||
knowledgeEmbeddingModel: (value: string) => void
|
||||
knowledgeEmbeddingApiKey: (value: string) => void
|
||||
clearKnowledgeEmbeddingApiKey: (value: boolean) => void
|
||||
knowledgeRerankEnabled: (value: boolean) => void
|
||||
knowledgeRerankEndpoint: (value: string) => void
|
||||
knowledgeRerankModel: (value: string) => void
|
||||
knowledgeRerankApiKey: (value: string) => void
|
||||
clearKnowledgeRerankApiKey: (value: boolean) => void
|
||||
workspacePath: (value: string) => void
|
||||
toolApproval: (value: RuntimeSettingsInput['toolApproval']) => void
|
||||
subagentSmartRoutingEnabled: (value: boolean) => void
|
||||
},
|
||||
preserveSelectedProfile = false
|
||||
): void {
|
||||
const configured = configuredRuntimeSettings(value)
|
||||
setters.settings(value)
|
||||
setters.provider(value.provider)
|
||||
setters.modelProfiles(toModelProfileDrafts(value))
|
||||
const fallbackProfileId = value.modelProfiles.some(
|
||||
(profile) => profile.id === value.defaultModelProfileId
|
||||
)
|
||||
? value.defaultModelProfileId
|
||||
: value.modelProfiles[0]?.id ?? ''
|
||||
setters.selectedModelProfileId(
|
||||
preserveSelectedProfile
|
||||
? (selectedId) =>
|
||||
value.modelProfiles.some(
|
||||
(profile) => profile.id === selectedId
|
||||
)
|
||||
? selectedId
|
||||
: fallbackProfileId
|
||||
: fallbackProfileId
|
||||
)
|
||||
setters.defaultModelProfileId(value.defaultModelProfileId)
|
||||
setters.opencodeModelSource(configured.opencodeModelSource)
|
||||
setters.continueModelSource(configured.continueModelSource)
|
||||
setters.opencodeBaseUrl(configured.opencodeBaseUrl)
|
||||
setters.opencodeBinaryPath(configured.opencodeBinaryPath)
|
||||
setters.opencodeConfigPath(configured.opencodeConfigPath)
|
||||
setters.continueBinaryPath(configured.continueBinaryPath)
|
||||
setters.continueConfigPath(configured.continueConfigPath)
|
||||
setters.continueMode(value.continueMode)
|
||||
setters.runtimeSandboxMode(value.runtimeSandboxMode)
|
||||
setters.knowledgeEmbeddingEnabled(value.knowledgeEmbeddingEnabled)
|
||||
setters.knowledgeEmbeddingBaseUrl(value.knowledgeEmbeddingBaseUrl)
|
||||
setters.knowledgeEmbeddingModel(value.knowledgeEmbeddingModel)
|
||||
setters.knowledgeEmbeddingApiKey('')
|
||||
setters.clearKnowledgeEmbeddingApiKey(false)
|
||||
setters.knowledgeRerankEnabled(
|
||||
value.knowledgeRerankEnabled ??
|
||||
defaultRuntimeSettings.knowledgeRerankEnabled
|
||||
)
|
||||
setters.knowledgeRerankEndpoint(
|
||||
value.knowledgeRerankEndpoint ??
|
||||
defaultRuntimeSettings.knowledgeRerankEndpoint
|
||||
)
|
||||
setters.knowledgeRerankModel(
|
||||
value.knowledgeRerankModel ??
|
||||
defaultRuntimeSettings.knowledgeRerankModel
|
||||
)
|
||||
setters.knowledgeRerankApiKey('')
|
||||
setters.clearKnowledgeRerankApiKey(false)
|
||||
setters.workspacePath(configured.workspacePath)
|
||||
setters.toolApproval(
|
||||
value.toolApproval === 'policy' ? 'policy' : 'always'
|
||||
)
|
||||
setters.subagentSmartRoutingEnabled(
|
||||
value.subagentSmartRoutingEnabled
|
||||
)
|
||||
}
|
||||
|
||||
type RuntimeConfigCardProps = {
|
||||
runtime: AgentRuntimeType
|
||||
runtimeLabel: string
|
||||
@@ -246,6 +363,7 @@ export function SettingsPanel({
|
||||
onExpertsChanged = () => {},
|
||||
appearanceTheme = 'system',
|
||||
onAppearanceThemeChange = () => {},
|
||||
magicNotesEnabled = false,
|
||||
onMagicNotesEnabledChange = () => {}
|
||||
}: SettingsPanelProps): React.JSX.Element | null {
|
||||
const { i18n, t } = useTranslation('settings')
|
||||
@@ -322,6 +440,13 @@ export function SettingsPanel({
|
||||
subagentSmartRoutingEnabled,
|
||||
setSubagentSmartRoutingEnabled
|
||||
] = useState(false)
|
||||
const modelProfileDisplayName = (
|
||||
profile: Pick<ModelProfileDraft, 'id' | 'name'>
|
||||
): string =>
|
||||
profile.id === builtInDefaultModelProfileId &&
|
||||
profile.name === '默认模型'
|
||||
? t('model.profile.seededDefaultName')
|
||||
: profile.name
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [embeddingConfiguration, setEmbeddingConfiguration] =
|
||||
@@ -350,6 +475,47 @@ export function SettingsPanel({
|
||||
const [agentRuntimeType, setAgentRuntimeType] =
|
||||
useState<AgentRuntimeType>('opencode')
|
||||
const settingsBodyRef = useRef<HTMLDivElement>(null)
|
||||
const hydrateSettings = useCallback(
|
||||
(
|
||||
value: RuntimeSettings,
|
||||
preserveSelectedProfile = false
|
||||
): void => {
|
||||
hydrateRuntimeSettings(
|
||||
value,
|
||||
{
|
||||
settings: setSettings,
|
||||
provider: setProvider,
|
||||
modelProfiles: setModelProfiles,
|
||||
selectedModelProfileId: setSelectedModelProfileId,
|
||||
defaultModelProfileId: setDefaultModelProfileId,
|
||||
opencodeModelSource: setOpencodeModelSource,
|
||||
continueModelSource: setContinueModelSource,
|
||||
opencodeBaseUrl: setOpencodeBaseUrl,
|
||||
opencodeBinaryPath: setOpencodeBinaryPath,
|
||||
opencodeConfigPath: setOpencodeConfigPath,
|
||||
continueBinaryPath: setContinueBinaryPath,
|
||||
continueConfigPath: setContinueConfigPath,
|
||||
continueMode: setContinueMode,
|
||||
runtimeSandboxMode: setRuntimeSandboxMode,
|
||||
knowledgeEmbeddingEnabled: setKnowledgeEmbeddingEnabled,
|
||||
knowledgeEmbeddingBaseUrl: setKnowledgeEmbeddingBaseUrl,
|
||||
knowledgeEmbeddingModel: setKnowledgeEmbeddingModel,
|
||||
knowledgeEmbeddingApiKey: setKnowledgeEmbeddingApiKey,
|
||||
clearKnowledgeEmbeddingApiKey: setClearKnowledgeEmbeddingApiKey,
|
||||
knowledgeRerankEnabled: setKnowledgeRerankEnabled,
|
||||
knowledgeRerankEndpoint: setKnowledgeRerankEndpoint,
|
||||
knowledgeRerankModel: setKnowledgeRerankModel,
|
||||
knowledgeRerankApiKey: setKnowledgeRerankApiKey,
|
||||
clearKnowledgeRerankApiKey: setClearKnowledgeRerankApiKey,
|
||||
workspacePath: setWorkspacePath,
|
||||
toolApproval: setToolApproval,
|
||||
subagentSmartRoutingEnabled: setSubagentSmartRoutingEnabled
|
||||
},
|
||||
preserveSelectedProfile
|
||||
)
|
||||
},
|
||||
[]
|
||||
)
|
||||
const configurationTab =
|
||||
activeTab === 'model' ||
|
||||
activeTab === 'runtime' ||
|
||||
@@ -409,52 +575,7 @@ export function SettingsPanel({
|
||||
setPersistedSpeechModelId(undefined)
|
||||
setSpeechModelSelectionDirty(false)
|
||||
setAgentRuntimeType('opencode')
|
||||
setSettings(value)
|
||||
setProvider(value.provider)
|
||||
setModelProfiles(toModelProfileDrafts(value))
|
||||
setSelectedModelProfileId(
|
||||
value.modelProfiles.some(
|
||||
(profile) => profile.id === value.defaultModelProfileId
|
||||
)
|
||||
? value.defaultModelProfileId
|
||||
: value.modelProfiles[0]?.id ?? ''
|
||||
)
|
||||
setDefaultModelProfileId(value.defaultModelProfileId)
|
||||
setOpencodeModelSource(value.opencodeModelSource)
|
||||
setContinueModelSource(value.continueModelSource)
|
||||
setOpencodeBaseUrl(value.opencodeBaseUrl)
|
||||
setOpencodeBinaryPath(value.opencodeBinaryPath)
|
||||
setOpencodeConfigPath(value.opencodeConfigPath)
|
||||
setContinueBinaryPath(value.continueBinaryPath)
|
||||
setContinueConfigPath(value.continueConfigPath)
|
||||
setContinueMode(value.continueMode)
|
||||
setRuntimeSandboxMode(value.runtimeSandboxMode)
|
||||
setKnowledgeEmbeddingEnabled(value.knowledgeEmbeddingEnabled)
|
||||
setKnowledgeEmbeddingBaseUrl(value.knowledgeEmbeddingBaseUrl)
|
||||
setKnowledgeEmbeddingModel(value.knowledgeEmbeddingModel)
|
||||
setKnowledgeEmbeddingApiKey('')
|
||||
setClearKnowledgeEmbeddingApiKey(false)
|
||||
setKnowledgeRerankEnabled(
|
||||
value.knowledgeRerankEnabled ??
|
||||
defaultRuntimeSettings.knowledgeRerankEnabled
|
||||
)
|
||||
setKnowledgeRerankEndpoint(
|
||||
value.knowledgeRerankEndpoint ??
|
||||
defaultRuntimeSettings.knowledgeRerankEndpoint
|
||||
)
|
||||
setKnowledgeRerankModel(
|
||||
value.knowledgeRerankModel ??
|
||||
defaultRuntimeSettings.knowledgeRerankModel
|
||||
)
|
||||
setKnowledgeRerankApiKey('')
|
||||
setClearKnowledgeRerankApiKey(false)
|
||||
setWorkspacePath(value.workspacePath)
|
||||
setToolApproval(
|
||||
value.toolApproval === 'policy' ? 'policy' : 'always'
|
||||
)
|
||||
setSubagentSmartRoutingEnabled(
|
||||
value.subagentSmartRoutingEnabled
|
||||
)
|
||||
hydrateSettings(value)
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
setError(
|
||||
@@ -475,7 +596,7 @@ export function SettingsPanel({
|
||||
)
|
||||
)
|
||||
})
|
||||
}, [i18n, open])
|
||||
}, [hydrateSettings, i18n, open])
|
||||
|
||||
useEffect(() => {
|
||||
if (open && settingsBodyRef.current) {
|
||||
@@ -550,32 +671,52 @@ export function SettingsPanel({
|
||||
if (!defaultProfile) {
|
||||
throw new Error(t('errors.requireModelConnection'))
|
||||
}
|
||||
const profileInputs = modelProfiles.map((profile) => ({
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
baseUrl: profile.baseUrl,
|
||||
modelName: profile.modelName,
|
||||
protocol: profile.protocol,
|
||||
authentication: profile.authentication,
|
||||
supportsImageInput: profile.supportsImageInput,
|
||||
imageGenerationQuality: profile.imageGenerationQuality,
|
||||
apiKey: profile.clearApiKey
|
||||
? ({ action: 'clear' } as const)
|
||||
: profile.apiKey.trim()
|
||||
? ({
|
||||
action: 'replace',
|
||||
value: profile.apiKey.trim()
|
||||
} as const)
|
||||
: ({ action: 'keep' } as const)
|
||||
}))
|
||||
const configuredProfiles = new Map(
|
||||
settings?.configured?.modelProfiles.map((profile) => [
|
||||
profile.id,
|
||||
profile
|
||||
])
|
||||
)
|
||||
const profileInputs = modelProfiles.map((profile) => {
|
||||
const configured = configuredProfiles.get(profile.id)
|
||||
const environmentManaged =
|
||||
profile.credentialSource === 'environment' &&
|
||||
configured !== undefined
|
||||
return {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
baseUrl: environmentManaged
|
||||
? configured.baseUrl
|
||||
: profile.baseUrl,
|
||||
modelName: environmentManaged
|
||||
? configured.modelName
|
||||
: profile.modelName,
|
||||
protocol: profile.protocol,
|
||||
authentication: profile.authentication,
|
||||
supportsImageInput: profile.supportsImageInput,
|
||||
imageGenerationQuality: profile.imageGenerationQuality,
|
||||
apiKey: profile.clearApiKey
|
||||
? ({ action: 'clear' } as const)
|
||||
: profile.apiKey.trim()
|
||||
? ({
|
||||
action: 'replace',
|
||||
value: profile.apiKey.trim()
|
||||
} as const)
|
||||
: ({ action: 'keep' } as const)
|
||||
}
|
||||
})
|
||||
const defaultProfileInput =
|
||||
profileInputs.find(
|
||||
(profile) => profile.id === defaultProfile.id
|
||||
) ?? profileInputs[0]!
|
||||
const value = await window.goodbuddy.settings.updateRuntime({
|
||||
provider,
|
||||
modelBaseUrl: defaultProfile.baseUrl,
|
||||
modelName: defaultProfile.modelName,
|
||||
modelProtocol: defaultProfile.protocol,
|
||||
modelAuthentication: defaultProfile.authentication,
|
||||
modelBaseUrl: defaultProfileInput.baseUrl,
|
||||
modelName: defaultProfileInput.modelName,
|
||||
modelProtocol: defaultProfileInput.protocol,
|
||||
modelAuthentication: defaultProfileInput.authentication,
|
||||
imageGenerationQuality:
|
||||
defaultProfile.imageGenerationQuality,
|
||||
defaultProfileInput.imageGenerationQuality,
|
||||
opencodeBaseUrl,
|
||||
opencodeEmbedded: !opencodeBaseUrl,
|
||||
opencodeBinaryPath,
|
||||
@@ -607,9 +748,7 @@ export function SettingsPanel({
|
||||
}
|
||||
: { action: 'keep' },
|
||||
workspacePath,
|
||||
apiKey: profileInputs.find(
|
||||
(profile) => profile.id === defaultProfile.id
|
||||
)!.apiKey,
|
||||
apiKey: defaultProfileInput.apiKey,
|
||||
modelProfiles: profileInputs,
|
||||
defaultModelProfileId: defaultProfile.id,
|
||||
opencodeModelSource,
|
||||
@@ -628,48 +767,7 @@ export function SettingsPanel({
|
||||
)
|
||||
selectedSpeechModelId = speechSnapshot.selectedModelId
|
||||
}
|
||||
setSettings(value)
|
||||
setModelProfiles(toModelProfileDrafts(value))
|
||||
setSelectedModelProfileId((selectedId) =>
|
||||
value.modelProfiles.some((profile) => profile.id === selectedId)
|
||||
? selectedId
|
||||
: value.defaultModelProfileId
|
||||
)
|
||||
setDefaultModelProfileId(value.defaultModelProfileId)
|
||||
setOpencodeModelSource(value.opencodeModelSource)
|
||||
setContinueModelSource(value.continueModelSource)
|
||||
setOpencodeBaseUrl(value.opencodeBaseUrl)
|
||||
setOpencodeBinaryPath(value.opencodeBinaryPath)
|
||||
setOpencodeConfigPath(value.opencodeConfigPath)
|
||||
setContinueBinaryPath(value.continueBinaryPath)
|
||||
setContinueConfigPath(value.continueConfigPath)
|
||||
setContinueMode(value.continueMode)
|
||||
setRuntimeSandboxMode(value.runtimeSandboxMode)
|
||||
setKnowledgeEmbeddingEnabled(value.knowledgeEmbeddingEnabled)
|
||||
setKnowledgeEmbeddingBaseUrl(value.knowledgeEmbeddingBaseUrl)
|
||||
setKnowledgeEmbeddingModel(value.knowledgeEmbeddingModel)
|
||||
setKnowledgeEmbeddingApiKey('')
|
||||
setClearKnowledgeEmbeddingApiKey(false)
|
||||
setKnowledgeRerankEnabled(
|
||||
value.knowledgeRerankEnabled ??
|
||||
defaultRuntimeSettings.knowledgeRerankEnabled
|
||||
)
|
||||
setKnowledgeRerankEndpoint(
|
||||
value.knowledgeRerankEndpoint ??
|
||||
defaultRuntimeSettings.knowledgeRerankEndpoint
|
||||
)
|
||||
setKnowledgeRerankModel(
|
||||
value.knowledgeRerankModel ??
|
||||
defaultRuntimeSettings.knowledgeRerankModel
|
||||
)
|
||||
setKnowledgeRerankApiKey('')
|
||||
setClearKnowledgeRerankApiKey(false)
|
||||
setToolApproval(
|
||||
value.toolApproval === 'policy' ? 'policy' : 'always'
|
||||
)
|
||||
setSubagentSmartRoutingEnabled(
|
||||
value.subagentSmartRoutingEnabled
|
||||
)
|
||||
hydrateSettings(value, true)
|
||||
if (speechModelSelectionDirty) {
|
||||
setSpeechModelDraftId(selectedSpeechModelId)
|
||||
setPersistedSpeechModelId(selectedSpeechModelId)
|
||||
@@ -987,7 +1085,10 @@ export function SettingsPanel({
|
||||
.filter((profile) =>
|
||||
isAgentRuntimeModelProtocol(profile.protocol)
|
||||
)
|
||||
.map(({ id, name }) => ({ id, name }))
|
||||
.map(({ id, name }) => ({
|
||||
id,
|
||||
name: modelProfileDisplayName({ id, name })
|
||||
}))
|
||||
const savedRoleDefaultModelProfileId =
|
||||
savedRoleModelProfiles.some(
|
||||
(profile) => profile.id === settings?.defaultModelProfileId
|
||||
@@ -1246,9 +1347,7 @@ export function SettingsPanel({
|
||||
)}
|
||||
{activeTab === 'runtime' && (
|
||||
<>
|
||||
{settings?.warning && (
|
||||
<p className="settings-warning">{settings.warning}</p>
|
||||
)}
|
||||
<SettingsWarningList warnings={settings?.warnings} />
|
||||
<div className="settings-section">
|
||||
<div className="settings-section__title">
|
||||
<FolderOpen size={17} />
|
||||
@@ -1327,12 +1426,16 @@ export function SettingsPanel({
|
||||
})
|
||||
: activeRuntimeModelProfile
|
||||
? t('runtime.followGoodBuddy', {
|
||||
name: activeRuntimeModelProfile.name,
|
||||
name: modelProfileDisplayName(
|
||||
activeRuntimeModelProfile
|
||||
),
|
||||
model: activeRuntimeModelProfile.modelName
|
||||
})
|
||||
: defaultTextModelProfile
|
||||
? t('runtime.followGoodBuddy', {
|
||||
name: defaultTextModelProfile.name,
|
||||
name: modelProfileDisplayName(
|
||||
defaultTextModelProfile
|
||||
),
|
||||
model: defaultTextModelProfile.modelName
|
||||
})
|
||||
: t('runtime.noCompatibleModel')}
|
||||
@@ -1415,7 +1518,7 @@ export function SettingsPanel({
|
||||
key={profile.id}
|
||||
value={profile.id}
|
||||
>
|
||||
{profile.name}
|
||||
{modelProfileDisplayName(profile)}
|
||||
{isOpenCodeCompatible(profile)
|
||||
? ''
|
||||
: t('runtime.incompatibleSuffix')}
|
||||
@@ -1440,7 +1543,12 @@ export function SettingsPanel({
|
||||
path={opencodeConfigPath}
|
||||
runtime="opencode"
|
||||
runtimeLabel="OpenCode"
|
||||
savedPath={settings?.opencodeConfigPath}
|
||||
savedPath={
|
||||
settings
|
||||
? configuredRuntimeSettings(settings)
|
||||
.opencodeConfigPath
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{opencodeModelSource.kind === 'platform' &&
|
||||
@@ -1548,12 +1656,16 @@ export function SettingsPanel({
|
||||
})
|
||||
: activeRuntimeModelProfile
|
||||
? t('runtime.followGoodBuddy', {
|
||||
name: activeRuntimeModelProfile.name,
|
||||
name: modelProfileDisplayName(
|
||||
activeRuntimeModelProfile
|
||||
),
|
||||
model: activeRuntimeModelProfile.modelName
|
||||
})
|
||||
: defaultTextModelProfile
|
||||
? t('runtime.followGoodBuddy', {
|
||||
name: defaultTextModelProfile.name,
|
||||
name: modelProfileDisplayName(
|
||||
defaultTextModelProfile
|
||||
),
|
||||
model: defaultTextModelProfile.modelName
|
||||
})
|
||||
: t('runtime.noCompatibleModel')}
|
||||
@@ -1634,7 +1746,7 @@ export function SettingsPanel({
|
||||
key={profile.id}
|
||||
value={profile.id}
|
||||
>
|
||||
{profile.name}
|
||||
{modelProfileDisplayName(profile)}
|
||||
{isContinueCompatible(profile)
|
||||
? ''
|
||||
: t('runtime.incompatibleSuffix')}
|
||||
@@ -1658,7 +1770,12 @@ export function SettingsPanel({
|
||||
path={continueConfigPath}
|
||||
runtime="continue"
|
||||
runtimeLabel="Continue"
|
||||
savedPath={settings?.continueConfigPath}
|
||||
savedPath={
|
||||
settings
|
||||
? configuredRuntimeSettings(settings)
|
||||
.continueConfigPath
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<label className="field">
|
||||
@@ -1798,7 +1915,7 @@ export function SettingsPanel({
|
||||
: undefined
|
||||
}
|
||||
aria-label={t('model.profile.editAriaLabel', {
|
||||
name: profile.name
|
||||
name: modelProfileDisplayName(profile)
|
||||
})}
|
||||
onClick={() =>
|
||||
setSelectedModelProfileId(profile.id)
|
||||
@@ -1806,7 +1923,7 @@ export function SettingsPanel({
|
||||
type="button"
|
||||
>
|
||||
<span className="model-connection-list__name">
|
||||
<strong>{profile.name}</strong>
|
||||
<strong>{modelProfileDisplayName(profile)}</strong>
|
||||
<small>{profile.modelName}</small>
|
||||
</span>
|
||||
<span className="model-connection-list__badges">
|
||||
@@ -1836,7 +1953,7 @@ export function SettingsPanel({
|
||||
<div className="settings-section__title">
|
||||
<div>
|
||||
<strong id={`model-connection-${profile.id}`}>
|
||||
{profile.name}
|
||||
{modelProfileDisplayName(profile)}
|
||||
</strong>
|
||||
<small>{t('model.profile.detail')}</small>
|
||||
</div>
|
||||
@@ -1858,7 +1975,7 @@ export function SettingsPanel({
|
||||
)}
|
||||
<button
|
||||
aria-label={t('model.profile.deleteAriaLabel', {
|
||||
name: profile.name
|
||||
name: modelProfileDisplayName(profile)
|
||||
})}
|
||||
className="danger-button danger-button--quiet"
|
||||
disabled={modelProfiles.length <= 1}
|
||||
@@ -1877,7 +1994,7 @@ export function SettingsPanel({
|
||||
name: event.target.value
|
||||
})
|
||||
}
|
||||
value={profile.name}
|
||||
value={modelProfileDisplayName(profile)}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
@@ -1912,7 +2029,7 @@ export function SettingsPanel({
|
||||
<select
|
||||
aria-label={t(
|
||||
'model.profile.protocolAriaLabel',
|
||||
{ name: profile.name }
|
||||
{ name: modelProfileDisplayName(profile) }
|
||||
)}
|
||||
onChange={(event) =>
|
||||
{
|
||||
@@ -1979,7 +2096,7 @@ export function SettingsPanel({
|
||||
<select
|
||||
aria-label={t(
|
||||
'model.profile.authenticationAriaLabel',
|
||||
{ name: profile.name }
|
||||
{ name: modelProfileDisplayName(profile) }
|
||||
)}
|
||||
onChange={(event) => {
|
||||
const authentication = event.target
|
||||
@@ -2027,7 +2144,7 @@ export function SettingsPanel({
|
||||
<select
|
||||
aria-label={t(
|
||||
'model.profile.imageQualityAriaLabel',
|
||||
{ name: profile.name }
|
||||
{ name: modelProfileDisplayName(profile) }
|
||||
)}
|
||||
onChange={(event) =>
|
||||
updateModelProfile(profile.id, {
|
||||
@@ -2530,7 +2647,11 @@ export function SettingsPanel({
|
||||
</>
|
||||
)}
|
||||
{activeTab === 'skills' && <SkillsSettingsSection />}
|
||||
{activeTab === 'mcp' && <McpSettingsSection />}
|
||||
{activeTab === 'mcp' && (
|
||||
<McpSettingsSection
|
||||
magicNotesEnabled={magicNotesEnabled}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'about' && <UpdateSettingsSection />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,38 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
settingsWarningKey,
|
||||
type SettingsWarning
|
||||
} from '../../shared/settings-warning-contracts'
|
||||
import {
|
||||
settingsCategories,
|
||||
type SettingsCategoryId
|
||||
} from './settings-categories'
|
||||
import { translateSettingsWarning } from './settings-warnings'
|
||||
|
||||
export function SettingsWarningList({
|
||||
warnings
|
||||
}: {
|
||||
warnings?: readonly SettingsWarning[]
|
||||
}): React.JSX.Element | null {
|
||||
const { t } = useTranslation('warnings')
|
||||
if (!warnings?.length) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{warnings.map((warning) => (
|
||||
<p
|
||||
className="settings-warning"
|
||||
key={settingsWarningKey(warning)}
|
||||
role="alert"
|
||||
>
|
||||
{translateSettingsWarning(warning, t)}
|
||||
</p>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function SettingsCategoryHeader({
|
||||
actions,
|
||||
|
||||
@@ -6,7 +6,10 @@ import type {
|
||||
VersionCheckResult
|
||||
} from '../../shared/application-settings-contracts'
|
||||
import type { AppInfo } from '../../shared/contracts'
|
||||
import { SettingsCategoryHeader } from './SettingsPrimitives'
|
||||
import {
|
||||
SettingsCategoryHeader,
|
||||
SettingsWarningList
|
||||
} from './SettingsPrimitives'
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes >= 1024 * 1024 * 1024) {
|
||||
@@ -137,6 +140,7 @@ export function UpdateSettingsSection(): React.JSX.Element {
|
||||
error={error}
|
||||
headingId="update-settings-heading"
|
||||
/>
|
||||
<SettingsWarningList warnings={settings?.warnings} />
|
||||
<section
|
||||
aria-label={t('updates.label')}
|
||||
className="settings-section update-settings"
|
||||
|
||||
@@ -9,6 +9,7 @@ import { magicNotes as englishMagicNotes } from './locales/en-US/magicNotes'
|
||||
import { settings as englishSettings } from './locales/en-US/settings'
|
||||
import { settingsSections as englishSettingsSections } from './locales/en-US/settingsSections'
|
||||
import { workspace as englishWorkspace } from './locales/en-US/workspace'
|
||||
import { warnings as englishWarnings } from './locales/en-US/warnings'
|
||||
import { activity as chineseActivity } from './locales/zh-CN/activity'
|
||||
import { app as chineseApp } from './locales/zh-CN/app'
|
||||
import { heartbeat as chineseHeartbeat } from './locales/zh-CN/heartbeat'
|
||||
@@ -18,6 +19,7 @@ import { magicNotes as chineseMagicNotes } from './locales/zh-CN/magicNotes'
|
||||
import { settings as chineseSettings } from './locales/zh-CN/settings'
|
||||
import { settingsSections as chineseSettingsSections } from './locales/zh-CN/settingsSections'
|
||||
import { workspace as chineseWorkspace } from './locales/zh-CN/workspace'
|
||||
import { warnings as chineseWarnings } from './locales/zh-CN/warnings'
|
||||
|
||||
export const supportedUiLocales = ['zh-CN', 'en-US'] as const
|
||||
export type UiLocale = (typeof supportedUiLocales)[number]
|
||||
@@ -32,7 +34,8 @@ export const i18nResources = {
|
||||
magicNotes: chineseMagicNotes,
|
||||
settings: chineseSettings,
|
||||
settingsSections: chineseSettingsSections,
|
||||
workspace: chineseWorkspace
|
||||
workspace: chineseWorkspace,
|
||||
warnings: chineseWarnings
|
||||
},
|
||||
'en-US': {
|
||||
activity: englishActivity,
|
||||
@@ -43,7 +46,8 @@ export const i18nResources = {
|
||||
magicNotes: englishMagicNotes,
|
||||
settings: englishSettings,
|
||||
settingsSections: englishSettingsSections,
|
||||
workspace: englishWorkspace
|
||||
workspace: englishWorkspace,
|
||||
warnings: englishWarnings
|
||||
}
|
||||
} as const
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@ import type { TranslationShape } from '../../resource-types'
|
||||
import type { app as chineseApp } from '../zh-CN/app'
|
||||
|
||||
export const app = {
|
||||
brand: {
|
||||
desktopWorkspace: 'Desktop workspace'
|
||||
},
|
||||
notifications: {
|
||||
success: 'Success',
|
||||
error: 'Error',
|
||||
@@ -125,6 +128,7 @@ export const app = {
|
||||
user: 'You',
|
||||
assistantResult: 'Assistant result {{index}}',
|
||||
welcome: {
|
||||
eyebrow: 'GOODBUDDY WORKSPACE',
|
||||
title: 'What would you like to accomplish today?',
|
||||
description:
|
||||
'Ask a question, organize information, or connect OpenCode for file search and development tools.'
|
||||
|
||||
@@ -70,6 +70,7 @@ export const integrations = {
|
||||
environmentSource: 'Provided by environment variables',
|
||||
secretSaved: 'Secret saved with encryption',
|
||||
secretMissing: 'Secret not configured',
|
||||
secretUnreadable: 'Secret saved, but currently unreadable',
|
||||
readOnly:
|
||||
'This channel is managed by environment variables. Change the launch environment and restart the app.',
|
||||
enable: 'Enable the {{channel}} channel',
|
||||
|
||||
@@ -136,6 +136,7 @@ export const settings = {
|
||||
none: 'Not configured',
|
||||
encrypted: 'Encrypted in secure system storage',
|
||||
environment: 'Provided by an environment variable',
|
||||
unreadable: 'Saved, but currently unreadable',
|
||||
configuredPlaceholder: 'Configured; leave blank to keep it',
|
||||
enterApiKey: 'Enter API Key',
|
||||
noAuthentication: 'No authentication',
|
||||
@@ -224,6 +225,7 @@ export const settings = {
|
||||
}
|
||||
},
|
||||
documentParsing: {
|
||||
loading: 'Loading…',
|
||||
status: {
|
||||
title: 'Runtime status',
|
||||
description: 'Capabilities currently available on this device',
|
||||
@@ -409,6 +411,7 @@ export const settings = {
|
||||
}
|
||||
},
|
||||
profile: {
|
||||
seededDefaultName: 'Default model',
|
||||
generatedName: 'Model connection {{count}}',
|
||||
title: 'LLM model connections',
|
||||
description:
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { TranslationShape } from '../../resource-types'
|
||||
import type { warnings as chineseWarnings } from '../zh-CN/warnings'
|
||||
|
||||
export const warnings = {
|
||||
'application-settings-recovered':
|
||||
'The application settings file was corrupt. The original file was isolated, and safe defaults are now in use.',
|
||||
'document-parsing-settings-recovered':
|
||||
'The document parsing settings file was corrupt. The original file was isolated, and safe defaults are now in use.',
|
||||
'capability-settings-recovered':
|
||||
'The capability settings file was corrupt. The original file was isolated. Web search and computer control remain off until you review and enable them.',
|
||||
'runtime-settings-recovered':
|
||||
'The Runtime settings file was corrupt. The original file was isolated, and defaults are now in use.',
|
||||
'runtime-model-credential-unreadable':
|
||||
'The API Key for model connection “{{subject}}” cannot be read. Re-enter or clear this credential.',
|
||||
'runtime-model-credential-binding-mismatch':
|
||||
'The service address for model connection “{{subject}}” does not match its saved API Key. Re-enter or clear this credential.',
|
||||
'runtime-embedding-credential-unreadable':
|
||||
'The embedding model API Key cannot be read. Re-enter or clear this credential.',
|
||||
'runtime-embedding-credential-binding-mismatch':
|
||||
'The embedding endpoint does not match its saved API Key. Re-enter or clear this credential.',
|
||||
'runtime-rerank-credential-unreadable':
|
||||
'The rerank model API Key cannot be read. Re-enter or clear this credential.',
|
||||
'runtime-rerank-credential-binding-mismatch':
|
||||
'The rerank endpoint does not match its saved API Key. Re-enter or clear this credential.',
|
||||
'channel-settings-recovered':
|
||||
'The channel settings file was corrupt. The original file was isolated, and all channels were restored as disabled.',
|
||||
'channel-weixin-credential-unreadable':
|
||||
'The WeChat connection credential cannot be read, so the channel is temporarily disabled. Connect it again with a QR code.',
|
||||
'channel-weixin-secure-storage-unavailable':
|
||||
'Secure system storage is temporarily unavailable, so the WeChat channel is disabled. Retry after secure storage recovers.',
|
||||
'channel-weixin-legacy-binding-invalid':
|
||||
'The legacy WeChat connection could not be migrated safely. Connect it again with a QR code.',
|
||||
'channel-wecom-environment-invalid':
|
||||
'The WeCom environment configuration is invalid or incomplete, so the channel remains off.',
|
||||
'channel-dingtalk-environment-invalid':
|
||||
'The DingTalk environment configuration is invalid or incomplete, so the channel remains off.',
|
||||
'channel-wecom-credential-unreadable':
|
||||
'The WeCom Secret cannot be read. Re-enter or clear this credential.',
|
||||
'channel-dingtalk-credential-unreadable':
|
||||
'The DingTalk Client Secret cannot be read. Re-enter or clear this credential.',
|
||||
'channel-runtime-selections-repaired':
|
||||
'Repaired {{count}} unavailable backend selections for unattended channels. Review each channel project setting.'
|
||||
} as const satisfies TranslationShape<typeof chineseWarnings>
|
||||
|
||||
export default warnings
|
||||
@@ -1,4 +1,7 @@
|
||||
export const app = {
|
||||
brand: {
|
||||
desktopWorkspace: '桌面工作区'
|
||||
},
|
||||
notifications: {
|
||||
success: '成功',
|
||||
error: '错误',
|
||||
@@ -121,6 +124,7 @@ export const app = {
|
||||
user: '用户',
|
||||
assistantResult: '助手成果 {{index}}',
|
||||
welcome: {
|
||||
eyebrow: 'GOODBUDDY 工作台',
|
||||
title: '今天想一起完成什么?',
|
||||
description:
|
||||
'快速提问、梳理信息,或连接 OpenCode 使用文件搜索和开发工具。'
|
||||
|
||||
@@ -62,6 +62,7 @@ export const integrations = {
|
||||
environmentSource: '由环境变量提供',
|
||||
secretSaved: 'Secret 已加密保存',
|
||||
secretMissing: 'Secret 尚未配置',
|
||||
secretUnreadable: 'Secret 已保存,但当前无法读取',
|
||||
readOnly:
|
||||
'当前通道由环境变量管理。请在启动环境中修改配置后重启应用。',
|
||||
enable: '启用{{channel}}通道',
|
||||
|
||||
@@ -124,6 +124,7 @@ export const settings = {
|
||||
none: '尚未配置',
|
||||
encrypted: '已由系统安全存储加密',
|
||||
environment: '由环境变量提供',
|
||||
unreadable: '已保存,但当前无法读取',
|
||||
configuredPlaceholder: '已配置,留空保持不变',
|
||||
enterApiKey: '输入 API Key',
|
||||
noAuthentication: '无需认证',
|
||||
@@ -205,6 +206,7 @@ export const settings = {
|
||||
}
|
||||
},
|
||||
documentParsing: {
|
||||
loading: '正在加载…',
|
||||
status: {
|
||||
title: '运行状态',
|
||||
description: '显示当前设备实际可用的解析能力',
|
||||
@@ -372,6 +374,7 @@ export const settings = {
|
||||
}
|
||||
},
|
||||
profile: {
|
||||
seededDefaultName: '默认模型',
|
||||
generatedName: '模型连接 {{count}}',
|
||||
title: 'LLM 模型连接',
|
||||
description:
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
export const warnings = {
|
||||
'application-settings-recovered':
|
||||
'应用设置文件已损坏。原文件已隔离,当前使用安全默认设置。',
|
||||
'document-parsing-settings-recovered':
|
||||
'文档解析设置文件已损坏。原文件已隔离,当前使用安全默认设置。',
|
||||
'capability-settings-recovered':
|
||||
'能力设置文件已损坏。原文件已隔离,网页搜索和电脑控制已保持关闭,请检查后手动启用。',
|
||||
'runtime-settings-recovered':
|
||||
'Runtime 设置文件已损坏。原文件已隔离,当前使用默认设置。',
|
||||
'runtime-model-credential-unreadable':
|
||||
'模型连接“{{subject}}”的 API Key 无法读取。请重新输入或清除该凭据。',
|
||||
'runtime-model-credential-binding-mismatch':
|
||||
'模型连接“{{subject}}”的服务地址与已保存 API Key 不匹配。请重新输入或清除该凭据。',
|
||||
'runtime-embedding-credential-unreadable':
|
||||
'向量模型 API Key 无法读取。请重新输入或清除该凭据。',
|
||||
'runtime-embedding-credential-binding-mismatch':
|
||||
'向量接口地址与已保存 API Key 不匹配。请重新输入或清除该凭据。',
|
||||
'runtime-rerank-credential-unreadable':
|
||||
'重排模型 API Key 无法读取。请重新输入或清除该凭据。',
|
||||
'runtime-rerank-credential-binding-mismatch':
|
||||
'重排接口地址与已保存 API Key 不匹配。请重新输入或清除该凭据。',
|
||||
'channel-settings-recovered':
|
||||
'通道设置文件已损坏。原文件已隔离,所有通道已恢复为关闭状态。',
|
||||
'channel-weixin-credential-unreadable':
|
||||
'微信绑定凭据无法读取,通道已临时停用。请重新扫码绑定。',
|
||||
'channel-weixin-secure-storage-unavailable':
|
||||
'系统安全存储暂不可用,微信绑定已临时停用。恢复安全存储后可重试。',
|
||||
'channel-weixin-legacy-binding-invalid':
|
||||
'旧版微信绑定无法安全迁移,请重新扫码绑定。',
|
||||
'channel-wecom-environment-invalid':
|
||||
'企业微信环境变量配置无效或不完整,通道保持关闭。',
|
||||
'channel-dingtalk-environment-invalid':
|
||||
'钉钉环境变量配置无效或不完整,通道保持关闭。',
|
||||
'channel-wecom-credential-unreadable':
|
||||
'企业微信 Secret 无法读取。请重新输入或清除该凭据。',
|
||||
'channel-dingtalk-credential-unreadable':
|
||||
'钉钉 Client Secret 无法读取。请重新输入或清除该凭据。',
|
||||
'channel-runtime-selections-repaired':
|
||||
'已修复 {{count}} 个无人值守通道的不可用后端选择。请检查各通道项目设置。'
|
||||
} as const
|
||||
|
||||
export default warnings
|
||||
Vendored
+2
@@ -8,6 +8,7 @@ import activity from './locales/zh-CN/activity'
|
||||
import magicNotes from './locales/zh-CN/magicNotes'
|
||||
import integrations from './locales/zh-CN/integrations'
|
||||
import workspace from './locales/zh-CN/workspace'
|
||||
import warnings from './locales/zh-CN/warnings'
|
||||
|
||||
declare module 'i18next' {
|
||||
interface CustomTypeOptions {
|
||||
@@ -22,6 +23,7 @@ declare module 'i18next' {
|
||||
magicNotes: typeof magicNotes
|
||||
integrations: typeof integrations
|
||||
workspace: typeof workspace
|
||||
warnings: typeof warnings
|
||||
}
|
||||
returnNull: false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { RuntimeSettings } from '../../shared/contracts'
|
||||
import type { AgentRuntimeSelection } from '../../shared/runtime-selection-contracts'
|
||||
|
||||
export function getRuntimeSelectionForProvider(
|
||||
provider: 'model' | 'opencode' | 'continue',
|
||||
settings: RuntimeSettings
|
||||
): AgentRuntimeSelection {
|
||||
if (provider === 'model') {
|
||||
return {
|
||||
provider,
|
||||
profileId: settings.defaultModelProfileId
|
||||
}
|
||||
}
|
||||
const source =
|
||||
provider === 'opencode'
|
||||
? settings.opencodeModelSource
|
||||
: settings.continueModelSource
|
||||
return {
|
||||
provider,
|
||||
...(source.kind === 'profile' ? { profileId: source.profileId } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export function getDefaultRuntimeSelection(
|
||||
settings: RuntimeSettings
|
||||
): AgentRuntimeSelection {
|
||||
if (
|
||||
settings.provider === 'model' ||
|
||||
settings.provider === 'opencode' ||
|
||||
settings.provider === 'continue'
|
||||
) {
|
||||
return getRuntimeSelectionForProvider(settings.provider, settings)
|
||||
}
|
||||
return settings.opencodeBaseUrl || settings.opencodeEmbedded
|
||||
? getRuntimeSelectionForProvider('opencode', settings)
|
||||
: getRuntimeSelectionForProvider('model', settings)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { TFunction } from 'i18next'
|
||||
import type { SettingsWarning } from '../../shared/settings-warning-contracts'
|
||||
|
||||
export function translateSettingsWarning(
|
||||
warning: SettingsWarning,
|
||||
t: TFunction<'warnings'>
|
||||
): string {
|
||||
switch (warning.code) {
|
||||
case 'runtime-model-credential-unreadable':
|
||||
case 'runtime-model-credential-binding-mismatch':
|
||||
return t(warning.code, {
|
||||
subject: warning.subject ?? ''
|
||||
})
|
||||
case 'channel-runtime-selections-repaired':
|
||||
return t(warning.code, {
|
||||
count: warning.count ?? 0
|
||||
})
|
||||
default:
|
||||
return t(warning.code)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from 'zod'
|
||||
import { magicNoteCommentFormatSchema } from './magic-notes-contracts'
|
||||
import { settingsWarningsSchema } from './settings-warning-contracts'
|
||||
|
||||
export const magicNoteCommentModeSchema = z.enum([
|
||||
'immediate',
|
||||
@@ -11,7 +12,7 @@ export type MagicNoteCommentMode = z.infer<
|
||||
typeof magicNoteCommentModeSchema
|
||||
>
|
||||
|
||||
export const applicationSettingsSchema = z
|
||||
const applicationPreferencesSchema = z
|
||||
.object({
|
||||
checkUpdatesOnStartup: z.boolean(),
|
||||
magicNotesEnabled: z.boolean(),
|
||||
@@ -20,7 +21,13 @@ export const applicationSettingsSchema = z
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const applicationSettingsUpdateSchema = applicationSettingsSchema
|
||||
export const applicationSettingsSchema = applicationPreferencesSchema
|
||||
.extend({
|
||||
warnings: settingsWarningsSchema.optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const applicationSettingsUpdateSchema = applicationPreferencesSchema
|
||||
.partial()
|
||||
.refine((input) => Object.keys(input).length > 0, {
|
||||
message: 'At least one application setting is required'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod'
|
||||
import { settingsWarningsSchema } from './settings-warning-contracts'
|
||||
|
||||
const controlCharacterFreeString = (maximumLength: number) =>
|
||||
z
|
||||
@@ -331,7 +332,8 @@ export const capabilitySnapshotSchema = z
|
||||
.array(computerCapabilityConfigSummarySchema)
|
||||
.max(2)
|
||||
.optional(),
|
||||
browserProfiles: browserProfilesSummarySchema.optional()
|
||||
browserProfiles: browserProfilesSummarySchema.optional(),
|
||||
warnings: settingsWarningsSchema.optional()
|
||||
})
|
||||
.strict()
|
||||
export type CapabilitySnapshot = z.infer<typeof capabilitySnapshotSchema>
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
projectChannelSchema,
|
||||
type ProjectChannel
|
||||
} from './assistant-contracts'
|
||||
import { settingsWarningsSchema } from './settings-warning-contracts'
|
||||
|
||||
export const CHANNEL_SETTINGS_LIMITS = {
|
||||
maximumIdentifierLength: 256,
|
||||
@@ -106,7 +107,8 @@ export type ChannelSettingsApply = z.infer<
|
||||
export const channelCredentialSourceSchema = z.enum([
|
||||
'none',
|
||||
'encrypted',
|
||||
'environment'
|
||||
'environment',
|
||||
'unreadable'
|
||||
])
|
||||
export type ChannelCredentialSource = z.infer<
|
||||
typeof channelCredentialSourceSchema
|
||||
@@ -186,12 +188,7 @@ export const channelSettingsSnapshotSchema = z
|
||||
weixin: weixinChannelSettingsSchema,
|
||||
wecom: weComChannelSettingsSchema,
|
||||
dingtalk: dingTalkChannelSettingsSchema,
|
||||
warning: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(CHANNEL_SETTINGS_LIMITS.maximumWarningLength)
|
||||
.optional()
|
||||
warnings: settingsWarningsSchema.optional()
|
||||
})
|
||||
.strict()
|
||||
export type ChannelSettingsSnapshot = z.infer<
|
||||
|
||||
+27
-5
@@ -86,6 +86,7 @@ import type {
|
||||
DocumentParsingSnapshot,
|
||||
DocumentParsingTestPurpose
|
||||
} from './document-parsing-contracts'
|
||||
import type { SettingsWarning } from './settings-warning-contracts'
|
||||
import type {
|
||||
KnowledgeChunkDeleteInput,
|
||||
KnowledgeChunkPage,
|
||||
@@ -601,7 +602,19 @@ export type ModelConnectionSettings = {
|
||||
supportsImageInput?: boolean
|
||||
imageGenerationQuality: ImageGenerationQuality
|
||||
apiKeyConfigured: boolean
|
||||
credentialSource: 'none' | 'encrypted' | 'environment'
|
||||
credentialSource: 'none' | 'encrypted' | 'environment' | 'unreadable'
|
||||
}
|
||||
|
||||
export type ConfiguredRuntimeSettings = {
|
||||
modelProfiles: ModelConnectionSettings[]
|
||||
opencodeBaseUrl: string
|
||||
opencodeBinaryPath: string
|
||||
opencodeConfigPath: string
|
||||
continueBinaryPath: string
|
||||
continueConfigPath: string
|
||||
workspacePath: string
|
||||
opencodeModelSource: RuntimeModelSource
|
||||
continueModelSource: RuntimeModelSource
|
||||
}
|
||||
|
||||
export type RuntimeSettings = {
|
||||
@@ -625,22 +638,31 @@ export type RuntimeSettings = {
|
||||
knowledgeEmbeddingBaseUrl: string
|
||||
knowledgeEmbeddingModel: string
|
||||
knowledgeEmbeddingApiKeyConfigured: boolean
|
||||
knowledgeEmbeddingCredentialSource: 'none' | 'encrypted' | 'environment'
|
||||
knowledgeEmbeddingCredentialSource:
|
||||
| 'none'
|
||||
| 'encrypted'
|
||||
| 'environment'
|
||||
| 'unreadable'
|
||||
knowledgeRerankEnabled?: boolean
|
||||
knowledgeRerankEndpoint?: string
|
||||
knowledgeRerankModel?: string
|
||||
knowledgeRerankApiKeyConfigured?: boolean
|
||||
knowledgeRerankCredentialSource?: 'none' | 'encrypted' | 'environment'
|
||||
knowledgeRerankCredentialSource?:
|
||||
| 'none'
|
||||
| 'encrypted'
|
||||
| 'environment'
|
||||
| 'unreadable'
|
||||
workspacePath: string
|
||||
apiKeyConfigured: boolean
|
||||
credentialSource: 'none' | 'encrypted' | 'environment'
|
||||
credentialSource: 'none' | 'encrypted' | 'environment' | 'unreadable'
|
||||
modelProfiles: ModelConnectionSettings[]
|
||||
defaultModelProfileId: string
|
||||
opencodeModelSource: RuntimeModelSource
|
||||
continueModelSource: RuntimeModelSource
|
||||
secureStorageAvailable: boolean
|
||||
toolApproval: RuntimeSettingsInput['toolApproval']
|
||||
warning?: string
|
||||
configured?: ConfiguredRuntimeSettings
|
||||
warnings?: SettingsWarning[]
|
||||
}
|
||||
|
||||
export type ContextAttachment = ConversationAttachment
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod'
|
||||
import { settingsWarningsSchema } from './settings-warning-contracts'
|
||||
|
||||
export const maximumDocumentExtractedCharacters = 5_000_000
|
||||
export const maximumDocumentOcrSectionCharacters = 1_000_000
|
||||
@@ -192,7 +193,8 @@ export const documentParsingSnapshotSchema = z
|
||||
.object({
|
||||
settings: documentParsingSettingsSchema,
|
||||
status: documentParsingStatusSchema,
|
||||
ocrModels: documentOcrModelSnapshotSchema
|
||||
ocrModels: documentOcrModelSnapshotSchema,
|
||||
warnings: settingsWarningsSchema.optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const settingsWarningCodeSchema = z.enum([
|
||||
'application-settings-recovered',
|
||||
'document-parsing-settings-recovered',
|
||||
'capability-settings-recovered',
|
||||
'runtime-settings-recovered',
|
||||
'runtime-model-credential-unreadable',
|
||||
'runtime-model-credential-binding-mismatch',
|
||||
'runtime-embedding-credential-unreadable',
|
||||
'runtime-embedding-credential-binding-mismatch',
|
||||
'runtime-rerank-credential-unreadable',
|
||||
'runtime-rerank-credential-binding-mismatch',
|
||||
'channel-settings-recovered',
|
||||
'channel-weixin-credential-unreadable',
|
||||
'channel-weixin-secure-storage-unavailable',
|
||||
'channel-weixin-legacy-binding-invalid',
|
||||
'channel-wecom-environment-invalid',
|
||||
'channel-dingtalk-environment-invalid',
|
||||
'channel-wecom-credential-unreadable',
|
||||
'channel-dingtalk-credential-unreadable',
|
||||
'channel-runtime-selections-repaired'
|
||||
])
|
||||
|
||||
export const settingsWarningSchema = z
|
||||
.object({
|
||||
code: settingsWarningCodeSchema,
|
||||
subject: z.string().trim().min(1).max(120).optional(),
|
||||
count: z.number().int().min(1).max(10_000).optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const settingsWarningsSchema = z
|
||||
.array(settingsWarningSchema)
|
||||
.max(32)
|
||||
|
||||
export type SettingsWarningCode = z.infer<
|
||||
typeof settingsWarningCodeSchema
|
||||
>
|
||||
export type SettingsWarning = z.infer<typeof settingsWarningSchema>
|
||||
|
||||
export function settingsWarningKey(warning: SettingsWarning): string {
|
||||
return JSON.stringify([
|
||||
warning.code,
|
||||
warning.subject ?? null,
|
||||
warning.count ?? null
|
||||
])
|
||||
}
|
||||
|
||||
export function settingsWarningsEqual(
|
||||
left: SettingsWarning,
|
||||
right: SettingsWarning
|
||||
): boolean {
|
||||
return settingsWarningKey(left) === settingsWarningKey(right)
|
||||
}
|
||||
Reference in New Issue
Block a user