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 runtimeId = 'continue'
|
||||||
readonly requiresToolApproval = false
|
readonly requiresToolApproval = false
|
||||||
readonly supportsToolExecution = true
|
readonly supportsToolExecution = true
|
||||||
|
readonly supportsScopedDataTools = true
|
||||||
private detection?: Promise<RuntimeBinaryDetection>
|
private detection?: Promise<RuntimeBinaryDetection>
|
||||||
private readonly hostAdapters = new Map<
|
private readonly hostAdapters = new Map<
|
||||||
RuntimeSettings['continueMode'],
|
RuntimeSettings['continueMode'],
|
||||||
|
|||||||
@@ -1096,7 +1096,9 @@ describe('ModelAgentRuntime', () => {
|
|||||||
tool_calls: [
|
tool_calls: [
|
||||||
{
|
{
|
||||||
index: 0,
|
index: 0,
|
||||||
|
id: '',
|
||||||
function: {
|
function: {
|
||||||
|
name: '',
|
||||||
arguments: '"README.md"}'
|
arguments: '"README.md"}'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1219,6 +1221,86 @@ describe('ModelAgentRuntime', () => {
|
|||||||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
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 () => {
|
it('uses refreshed tool definitions in subsequent model rounds', async () => {
|
||||||
const loadTool: ModelToolDefinition = {
|
const loadTool: ModelToolDefinition = {
|
||||||
name: 'mcp_load_tools',
|
name: 'mcp_load_tools',
|
||||||
@@ -1833,6 +1915,81 @@ describe('ModelAgentRuntime', () => {
|
|||||||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
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 () => {
|
it('fails closed when a direct-model tool is denied', async () => {
|
||||||
const fetcher = vi.fn<typeof fetch>(async () =>
|
const fetcher = vi.fn<typeof fetch>(async () =>
|
||||||
Response.json({
|
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 () => {
|
it('does not issue a follow-up model request after tool cancellation', async () => {
|
||||||
const response = {
|
const response = {
|
||||||
choices: [
|
choices: [
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { randomBytes } from 'node:crypto'
|
||||||
import type {
|
import type {
|
||||||
ApprovalDecision,
|
ApprovalDecision,
|
||||||
AgentRuntimeStatus,
|
AgentRuntimeStatus,
|
||||||
@@ -725,21 +726,30 @@ function getChatToolImageCarrierContent(
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createToolCallId(): string {
|
||||||
|
return `goodbuddy_call_${randomBytes(16).toString('hex')}`
|
||||||
|
}
|
||||||
|
|
||||||
function parseToolCallIdentity(
|
function parseToolCallIdentity(
|
||||||
id: unknown,
|
id: unknown,
|
||||||
name: unknown
|
name: unknown,
|
||||||
|
fallbackId?: unknown
|
||||||
): { id: string; name: string } {
|
): { id: string; name: string } {
|
||||||
|
const resolvedId =
|
||||||
|
typeof id === 'string' && id.length > 0
|
||||||
|
? id
|
||||||
|
: typeof fallbackId === 'string' && fallbackId.length > 0
|
||||||
|
? fallbackId
|
||||||
|
: createToolCallId()
|
||||||
if (
|
if (
|
||||||
typeof id !== 'string' ||
|
resolvedId.length > 256 ||
|
||||||
id.length === 0 ||
|
|
||||||
id.length > 256 ||
|
|
||||||
typeof name !== 'string' ||
|
typeof name !== 'string' ||
|
||||||
name.length === 0 ||
|
name.length === 0 ||
|
||||||
name.length > 128
|
name.length > 128
|
||||||
) {
|
) {
|
||||||
throw new Error('模型返回了无效的工具调用标识')
|
throw new Error('模型返回了无效的工具调用标识或名称')
|
||||||
}
|
}
|
||||||
return { id, name }
|
return { id: resolvedId, name }
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseModelToolResponse(
|
function parseModelToolResponse(
|
||||||
@@ -771,6 +781,7 @@ function parseModelToolResponse(
|
|||||||
reasoning.push(record.thinking)
|
reasoning.push(record.thinking)
|
||||||
} else if (record.type === 'tool_use') {
|
} else if (record.type === 'tool_use') {
|
||||||
const identity = parseToolCallIdentity(record.id, record.name)
|
const identity = parseToolCallIdentity(record.id, record.name)
|
||||||
|
record.id = identity.id
|
||||||
toolCalls.push({
|
toolCalls.push({
|
||||||
...identity,
|
...identity,
|
||||||
arguments: parseToolArguments(record.input)
|
arguments: parseToolArguments(record.input)
|
||||||
@@ -845,8 +856,10 @@ function parseModelToolResponse(
|
|||||||
} else if (output.type === 'function_call') {
|
} else if (output.type === 'function_call') {
|
||||||
const identity = parseToolCallIdentity(
|
const identity = parseToolCallIdentity(
|
||||||
output.call_id,
|
output.call_id,
|
||||||
output.name
|
output.name,
|
||||||
|
output.id
|
||||||
)
|
)
|
||||||
|
output.call_id = identity.id
|
||||||
toolCalls.push({
|
toolCalls.push({
|
||||||
...identity,
|
...identity,
|
||||||
arguments: parseToolArguments(output.arguments)
|
arguments: parseToolArguments(output.arguments)
|
||||||
@@ -893,6 +906,7 @@ function parseModelToolResponse(
|
|||||||
toolCall.id,
|
toolCall.id,
|
||||||
functionCall.name
|
functionCall.name
|
||||||
)
|
)
|
||||||
|
toolCall.id = identity.id
|
||||||
toolCalls.push({
|
toolCalls.push({
|
||||||
...identity,
|
...identity,
|
||||||
arguments: parseToolArguments(functionCall.arguments)
|
arguments: parseToolArguments(functionCall.arguments)
|
||||||
@@ -1112,6 +1126,10 @@ export class ModelAgentRuntime implements AgentRuntime {
|
|||||||
return this.capability === 'chat'
|
return this.capability === 'chat'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get supportsScopedDataTools(): boolean {
|
||||||
|
return this.capability === 'chat'
|
||||||
|
}
|
||||||
|
|
||||||
private isConfigured(): boolean {
|
private isConfigured(): boolean {
|
||||||
return (
|
return (
|
||||||
this.options.authentication === 'none' ||
|
this.options.authentication === 'none' ||
|
||||||
@@ -1665,11 +1683,13 @@ export class ModelAgentRuntime implements AgentRuntime {
|
|||||||
? functionDelta.arguments
|
? functionDelta.arguments
|
||||||
: ''),
|
: ''),
|
||||||
id:
|
id:
|
||||||
typeof toolDelta?.id === 'string'
|
typeof toolDelta?.id === 'string' &&
|
||||||
|
toolDelta.id.length > 0
|
||||||
? toolDelta.id
|
? toolDelta.id
|
||||||
: current.id,
|
: current.id,
|
||||||
name:
|
name:
|
||||||
typeof functionDelta?.name === 'string'
|
typeof functionDelta?.name === 'string' &&
|
||||||
|
functionDelta.name.length > 0
|
||||||
? functionDelta.name
|
? functionDelta.name
|
||||||
: current.name
|
: current.name
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -565,6 +565,10 @@ export class OpenCodeRuntime implements AgentRuntime {
|
|||||||
return this.options.embedded && !this.options.baseUrl
|
return this.options.embedded && !this.options.baseUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get supportsScopedDataTools(): boolean {
|
||||||
|
return this.usesEmbeddedPermissionMediation()
|
||||||
|
}
|
||||||
|
|
||||||
private async acquireEmbeddedRun(
|
private async acquireEmbeddedRun(
|
||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
): Promise<() => void> {
|
): Promise<() => void> {
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ export class AgentRuntimeController implements AgentRuntime {
|
|||||||
return this.current.runtime.supportsToolExecution
|
return this.current.runtime.supportsToolExecution
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get supportsScopedDataTools(): boolean {
|
||||||
|
return this.current.runtime.supportsScopedDataTools !== false
|
||||||
|
}
|
||||||
|
|
||||||
get capability(): AgentRuntime['capability'] {
|
get capability(): AgentRuntime['capability'] {
|
||||||
return this.current.runtime.capability
|
return this.current.runtime.capability
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ export interface AgentRuntime {
|
|||||||
readonly runtimeId?: AgentRuntimeStatus['id']
|
readonly runtimeId?: AgentRuntimeStatus['id']
|
||||||
readonly requiresToolApproval: boolean
|
readonly requiresToolApproval: boolean
|
||||||
readonly supportsToolExecution: boolean
|
readonly supportsToolExecution: boolean
|
||||||
|
/** Whether request-scoped GoodBuddy data tools can reach this runtime. */
|
||||||
|
readonly supportsScopedDataTools?: boolean
|
||||||
readonly capability?: 'chat' | 'image-generation'
|
readonly capability?: 'chat' | 'image-generation'
|
||||||
getStatus(): Promise<AgentRuntimeStatus>
|
getStatus(): Promise<AgentRuntimeStatus>
|
||||||
testConnection?(): Promise<AgentRuntimeStatus>
|
testConnection?(): Promise<AgentRuntimeStatus>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export class UnconfiguredAgentRuntime implements AgentRuntime {
|
|||||||
readonly runtimeId = 'setup'
|
readonly runtimeId = 'setup'
|
||||||
readonly requiresToolApproval = false
|
readonly requiresToolApproval = false
|
||||||
readonly supportsToolExecution = false
|
readonly supportsToolExecution = false
|
||||||
|
readonly supportsScopedDataTools = false
|
||||||
|
|
||||||
getStatus(): Promise<AgentRuntimeStatus> {
|
getStatus(): Promise<AgentRuntimeStatus> {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
|
|||||||
@@ -266,9 +266,10 @@ describe('ApplicationSettingsStore', () => {
|
|||||||
const { directory, filePath, store } = await createStore()
|
const { directory, filePath, store } = await createStore()
|
||||||
await writeFile(filePath, data, 'utf8')
|
await writeFile(filePath, data, 'utf8')
|
||||||
|
|
||||||
await expect(store.get()).resolves.toEqual(
|
await expect(store.get()).resolves.toEqual({
|
||||||
defaultApplicationSettings
|
...defaultApplicationSettings,
|
||||||
)
|
warnings: [{ code: 'application-settings-recovered' }]
|
||||||
|
})
|
||||||
const entries = await readdir(directory)
|
const entries = await readdir(directory)
|
||||||
expect(entries).toHaveLength(1)
|
expect(entries).toHaveLength(1)
|
||||||
expect(entries[0]).toMatch(
|
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 () => {
|
it('does not classify an I/O failure as corrupt settings', async () => {
|
||||||
const { directory } = await createStore()
|
const { directory } = await createStore()
|
||||||
const filePath = join(directory, 'settings-directory')
|
const filePath = join(directory, 'settings-directory')
|
||||||
|
|||||||
@@ -1,12 +1,4 @@
|
|||||||
import {
|
import { readFile } from 'node:fs/promises'
|
||||||
mkdir,
|
|
||||||
readFile,
|
|
||||||
rename,
|
|
||||||
rm,
|
|
||||||
writeFile
|
|
||||||
} from 'node:fs/promises'
|
|
||||||
import { randomBytes } from 'node:crypto'
|
|
||||||
import { dirname } from 'node:path'
|
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import {
|
import {
|
||||||
applicationSettingsSchema,
|
applicationSettingsSchema,
|
||||||
@@ -14,6 +6,14 @@ import {
|
|||||||
type ApplicationSettings
|
type ApplicationSettings
|
||||||
} from '../shared/application-settings-contracts'
|
} from '../shared/application-settings-contracts'
|
||||||
import { releaseVersionSchema } from '../shared/release-notes-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 {
|
export {
|
||||||
applicationSettingsSchema,
|
applicationSettingsSchema,
|
||||||
applicationSettingsUpdateSchema
|
applicationSettingsUpdateSchema
|
||||||
@@ -70,36 +70,19 @@ export const defaultApplicationSettings: ApplicationSettings = {
|
|||||||
magicNoteCommentFormat: 'combined'
|
magicNoteCommentFormat: 'combined'
|
||||||
}
|
}
|
||||||
|
|
||||||
function isMissingFile(error: unknown): boolean {
|
|
||||||
return (
|
|
||||||
error !== null &&
|
|
||||||
typeof error === 'object' &&
|
|
||||||
'code' in error &&
|
|
||||||
error.code === 'ENOENT'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ApplicationSettingsStore {
|
export class ApplicationSettingsStore {
|
||||||
private settings?: StoredApplicationSettings
|
private settings?: StoredApplicationSettings
|
||||||
private settingsLoad?: Promise<StoredApplicationSettings>
|
private settingsLoad?: Promise<StoredApplicationSettings>
|
||||||
|
private warnings: SettingsWarning[] = []
|
||||||
private updateQueue: Promise<void> = Promise.resolve()
|
private updateQueue: Promise<void> = Promise.resolve()
|
||||||
|
|
||||||
constructor(private readonly filePath: string) {}
|
constructor(private readonly filePath: string) {}
|
||||||
|
|
||||||
private async isolateCorruptFile(): Promise<void> {
|
private async isolateCorruptFile(): Promise<void> {
|
||||||
const isolatedPath =
|
await isolateCorruptSettingsFile(
|
||||||
`${this.filePath}.corrupt-${Date.now()}-` +
|
this.filePath,
|
||||||
randomBytes(6).toString('hex')
|
'Application settings are corrupt and could not be isolated'
|
||||||
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 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadStored(): Promise<StoredApplicationSettings> {
|
private async loadStored(): Promise<StoredApplicationSettings> {
|
||||||
@@ -122,6 +105,7 @@ export class ApplicationSettingsStore {
|
|||||||
parsed = JSON.parse(contents) as unknown
|
parsed = JSON.parse(contents) as unknown
|
||||||
} catch {
|
} catch {
|
||||||
await this.isolateCorruptFile()
|
await this.isolateCorruptFile()
|
||||||
|
this.warnings = [{ code: 'application-settings-recovered' }]
|
||||||
this.settings = {
|
this.settings = {
|
||||||
version: CURRENT_SETTINGS_VERSION,
|
version: CURRENT_SETTINGS_VERSION,
|
||||||
lastSeenReleaseNotesVersion: null,
|
lastSeenReleaseNotesVersion: null,
|
||||||
@@ -129,6 +113,12 @@ export class ApplicationSettingsStore {
|
|||||||
}
|
}
|
||||||
return this.settings
|
return this.settings
|
||||||
}
|
}
|
||||||
|
assertSupportedSettingsVersion(
|
||||||
|
parsed,
|
||||||
|
CURRENT_SETTINGS_VERSION,
|
||||||
|
(version) =>
|
||||||
|
`当前 GoodBuddy 不支持应用设置版本 ${version},请升级应用后重试`
|
||||||
|
)
|
||||||
const result = storedApplicationSettingsSchema.safeParse(parsed)
|
const result = storedApplicationSettingsSchema.safeParse(parsed)
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
const versionFourResult =
|
const versionFourResult =
|
||||||
@@ -179,6 +169,7 @@ export class ApplicationSettingsStore {
|
|||||||
return this.settings
|
return this.settings
|
||||||
}
|
}
|
||||||
await this.isolateCorruptFile()
|
await this.isolateCorruptFile()
|
||||||
|
this.warnings = [{ code: 'application-settings-recovered' }]
|
||||||
this.settings = {
|
this.settings = {
|
||||||
version: CURRENT_SETTINGS_VERSION,
|
version: CURRENT_SETTINGS_VERSION,
|
||||||
lastSeenReleaseNotesVersion: null,
|
lastSeenReleaseNotesVersion: null,
|
||||||
@@ -188,7 +179,10 @@ export class ApplicationSettingsStore {
|
|||||||
}
|
}
|
||||||
this.settings = result.data
|
this.settings = result.data
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isMissingFile(error)) {
|
if (error instanceof UnsupportedSettingsVersionError) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (!isMissingFileError(error)) {
|
||||||
throw new Error('Application settings could not be read', {
|
throw new Error('Application settings could not be read', {
|
||||||
cause: error
|
cause: error
|
||||||
})
|
})
|
||||||
@@ -208,7 +202,10 @@ export class ApplicationSettingsStore {
|
|||||||
checkUpdatesOnStartup: stored.checkUpdatesOnStartup,
|
checkUpdatesOnStartup: stored.checkUpdatesOnStartup,
|
||||||
magicNotesEnabled: stored.magicNotesEnabled,
|
magicNotesEnabled: stored.magicNotesEnabled,
|
||||||
magicNoteCommentMode: stored.magicNoteCommentMode,
|
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> {
|
private async persist(next: StoredApplicationSettings): Promise<void> {
|
||||||
await mkdir(dirname(this.filePath), { recursive: true })
|
await writeJsonFileAtomically(this.filePath, next)
|
||||||
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 })
|
|
||||||
}
|
|
||||||
this.settings = next
|
this.settings = next
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,6 +228,7 @@ export class ApplicationSettingsStore {
|
|||||||
version: CURRENT_SETTINGS_VERSION
|
version: CURRENT_SETTINGS_VERSION
|
||||||
}
|
}
|
||||||
await this.persist(next)
|
await this.persist(next)
|
||||||
|
this.warnings = []
|
||||||
return {
|
return {
|
||||||
checkUpdatesOnStartup: next.checkUpdatesOnStartup,
|
checkUpdatesOnStartup: next.checkUpdatesOnStartup,
|
||||||
magicNotesEnabled: next.magicNotesEnabled,
|
magicNotesEnabled: next.magicNotesEnabled,
|
||||||
|
|||||||
@@ -1386,7 +1386,7 @@ describe('AssistantDatabase', () => {
|
|||||||
database.close()
|
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 database = await createDatabase()
|
||||||
const removedProfileId =
|
const removedProfileId =
|
||||||
'00000000-0000-4000-8000-000000000291'
|
'00000000-0000-4000-8000-000000000291'
|
||||||
@@ -1478,7 +1478,7 @@ describe('AssistantDatabase', () => {
|
|||||||
},
|
},
|
||||||
continueModelSource: { kind: 'platform' }
|
continueModelSource: { kind: 'platform' }
|
||||||
})
|
})
|
||||||
).toBe(7)
|
).toBe(4)
|
||||||
expect(
|
expect(
|
||||||
database
|
database
|
||||||
.listConversations()
|
.listConversations()
|
||||||
@@ -1486,9 +1486,9 @@ describe('AssistantDatabase', () => {
|
|||||||
.sort((left, right) => left.title.localeCompare(right.title))
|
.sort((left, right) => left.title.localeCompare(right.title))
|
||||||
.map((conversation) => conversation.runtimeSelection)
|
.map((conversation) => conversation.runtimeSelection)
|
||||||
).toEqual([
|
).toEqual([
|
||||||
{ provider: 'model', profileId: defaultProfileId },
|
{ provider: 'model', profileId: removedProfileId },
|
||||||
{ provider: 'opencode', profileId: runtimeProfileId },
|
{ provider: 'opencode', profileId: removedProfileId },
|
||||||
{ provider: 'continue' },
|
{ provider: 'continue', profileId: removedProfileId },
|
||||||
{ provider: 'model', profileId: runtimeProfileId }
|
{ provider: 'model', profileId: runtimeProfileId }
|
||||||
])
|
])
|
||||||
expect(database.getProject(channelProject.id).runtimeSelection).toEqual({
|
expect(database.getProject(channelProject.id).runtimeSelection).toEqual({
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
agentRuntimeSelectionKey,
|
agentRuntimeSelectionKey,
|
||||||
agentRuntimeSelectionSchema,
|
agentRuntimeSelectionSchema,
|
||||||
repairAgentRuntimeSelection,
|
|
||||||
repairChannelRuntimeSelection,
|
repairChannelRuntimeSelection,
|
||||||
type AgentRuntimeSelection,
|
type AgentRuntimeSelection,
|
||||||
type RuntimeSelectionRepairSettings
|
type RuntimeSelectionRepairSettings
|
||||||
@@ -1363,7 +1362,8 @@ export class AssistantDatabase {
|
|||||||
.prepare(
|
.prepare(
|
||||||
`SELECT id, runtime_selection_json, channel
|
`SELECT id, runtime_selection_json, channel
|
||||||
FROM conversations
|
FROM conversations
|
||||||
WHERE runtime_selection_json IS NOT NULL`
|
WHERE runtime_selection_json IS NOT NULL
|
||||||
|
AND channel IS NOT NULL`
|
||||||
)
|
)
|
||||||
.all() as Array<{
|
.all() as Array<{
|
||||||
id: string
|
id: string
|
||||||
@@ -1410,9 +1410,7 @@ export class AssistantDatabase {
|
|||||||
if (!current) {
|
if (!current) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const next = conversation.channel
|
const next = repairChannelRuntimeSelection(current, settings)
|
||||||
? repairChannelRuntimeSelection(current, settings)
|
|
||||||
: repairAgentRuntimeSelection(current, settings)
|
|
||||||
if (
|
if (
|
||||||
agentRuntimeSelectionKey(next) ===
|
agentRuntimeSelectionKey(next) ===
|
||||||
agentRuntimeSelectionKey(current)
|
agentRuntimeSelectionKey(current)
|
||||||
|
|||||||
@@ -80,6 +80,32 @@ describe('RemoteDelegationService', () => {
|
|||||||
).toHaveLength(2)
|
).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 () => {
|
it('drains a durable outbox before accepting another task', async () => {
|
||||||
const records = new Map<
|
const records = new Map<
|
||||||
string,
|
string,
|
||||||
@@ -157,7 +183,7 @@ describe('RemoteDelegationService', () => {
|
|||||||
|
|
||||||
const polling = service.pollOnce()
|
const polling = service.pollOnce()
|
||||||
await vi.waitFor(() => expect(observedSignal).toBeDefined())
|
await vi.waitFor(() => expect(observedSignal).toBeDefined())
|
||||||
service.stop()
|
await service.stop()
|
||||||
|
|
||||||
await expect(polling).rejects.toBeDefined()
|
await expect(polling).rejects.toBeDefined()
|
||||||
expect(observedSignal?.aborted).toBe(true)
|
expect(observedSignal?.aborted).toBe(true)
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ export class RemoteDelegationService {
|
|||||||
private readonly pendingResults = new Map<string, RemoteResult>()
|
private readonly pendingResults = new Map<string, RemoteResult>()
|
||||||
private interval?: NodeJS.Timeout
|
private interval?: NodeJS.Timeout
|
||||||
private activeRequest?: AbortController
|
private activeRequest?: AbortController
|
||||||
private polling = false
|
private activePoll?: Promise<void>
|
||||||
|
|
||||||
constructor(private readonly options: RemoteDelegationOptions) {
|
constructor(private readonly options: RemoteDelegationOptions) {
|
||||||
this.endpoint = normalizeEndpoint(options.endpoint)
|
this.endpoint = normalizeEndpoint(options.endpoint)
|
||||||
@@ -179,19 +179,29 @@ export class RemoteDelegationService {
|
|||||||
void this.pollOnce().catch(() => undefined)
|
void this.pollOnce().catch(() => undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
stop(): void {
|
async stop(): Promise<void> {
|
||||||
if (this.interval) {
|
if (this.interval) {
|
||||||
clearInterval(this.interval)
|
clearInterval(this.interval)
|
||||||
this.interval = undefined
|
this.interval = undefined
|
||||||
}
|
}
|
||||||
this.activeRequest?.abort()
|
this.activeRequest?.abort()
|
||||||
|
await this.activePoll?.catch(() => undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
async pollOnce(): Promise<void> {
|
pollOnce(): Promise<void> {
|
||||||
if (this.polling) {
|
if (this.activePoll) {
|
||||||
return
|
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()
|
const controller = new AbortController()
|
||||||
this.activeRequest = controller
|
this.activeRequest = controller
|
||||||
try {
|
try {
|
||||||
@@ -260,7 +270,6 @@ export class RemoteDelegationService {
|
|||||||
if (this.activeRequest === controller) {
|
if (this.activeRequest === controller) {
|
||||||
this.activeRequest = undefined
|
this.activeRequest = undefined
|
||||||
}
|
}
|
||||||
this.polling = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,7 @@ import {
|
|||||||
lstat,
|
lstat,
|
||||||
mkdir,
|
mkdir,
|
||||||
readFile,
|
readFile,
|
||||||
realpath,
|
realpath
|
||||||
rename,
|
|
||||||
rm,
|
|
||||||
writeFile
|
|
||||||
} from 'node:fs/promises'
|
} from 'node:fs/promises'
|
||||||
import { isAbsolute, join, relative, resolve } from 'node:path'
|
import { isAbsolute, join, relative, resolve } from 'node:path'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
@@ -14,6 +11,10 @@ import {
|
|||||||
browserProfileIdSchema,
|
browserProfileIdSchema,
|
||||||
browserProfileNameSchema
|
browserProfileNameSchema
|
||||||
} from '../../shared/capability-contracts'
|
} from '../../shared/capability-contracts'
|
||||||
|
import {
|
||||||
|
isMissingFileError,
|
||||||
|
writeJsonFileAtomically
|
||||||
|
} from '../settings-file-utils'
|
||||||
|
|
||||||
const MAX_PROFILES = 32
|
const MAX_PROFILES = 32
|
||||||
const MAX_REFERENCES = 64
|
const MAX_REFERENCES = 64
|
||||||
@@ -204,12 +205,7 @@ export class FileBrowserProfileStore implements BrowserProfileStore {
|
|||||||
}
|
}
|
||||||
return JSON.parse(await readFile(filePath, 'utf8')) as unknown
|
return JSON.parse(await readFile(filePath, 'utf8')) as unknown
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (
|
if (isMissingFileError(error)) {
|
||||||
error &&
|
|
||||||
typeof error === 'object' &&
|
|
||||||
'code' in error &&
|
|
||||||
error.code === 'ENOENT'
|
|
||||||
) {
|
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
throw error
|
throw error
|
||||||
@@ -217,36 +213,22 @@ export class FileBrowserProfileStore implements BrowserProfileStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async save(state: BrowserProfileState): Promise<void> {
|
async save(state: BrowserProfileState): Promise<void> {
|
||||||
const { root, filePath } = await this.prepareRoot()
|
const { filePath } = await this.prepareRoot()
|
||||||
try {
|
try {
|
||||||
const targetDetails = await lstat(filePath)
|
const targetDetails = await lstat(filePath)
|
||||||
if (targetDetails.isSymbolicLink() || !targetDetails.isFile()) {
|
if (targetDetails.isSymbolicLink() || !targetDetails.isFile()) {
|
||||||
throw new Error('Browser profile storage file must be a regular file')
|
throw new Error('Browser profile storage file must be a regular file')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (
|
if (!isMissingFileError(error)) {
|
||||||
!(
|
|
||||||
error &&
|
|
||||||
typeof error === 'object' &&
|
|
||||||
'code' in error &&
|
|
||||||
error.code === 'ENOENT'
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const temporaryPath = join(root, `.${this.fileName}.${randomUUID()}.tmp`)
|
await writeJsonFileAtomically(
|
||||||
try {
|
filePath,
|
||||||
await writeFile(
|
browserProfileStateSchema.parse(state)
|
||||||
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 })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 { tmpdir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { strToU8, zipSync } from 'fflate'
|
import { strToU8, zipSync } from 'fflate'
|
||||||
@@ -847,6 +854,98 @@ describe('CapabilityService', () => {
|
|||||||
expect(persisted).toContain('"allowDynamicTools": false')
|
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 () => {
|
it('gates enablement on the supported platform and architecture', async () => {
|
||||||
const { service } = await createService({
|
const { service } = await createService({
|
||||||
platform: 'darwin',
|
platform: 'darwin',
|
||||||
|
|||||||
@@ -38,6 +38,21 @@ import {
|
|||||||
type RuntimeTarget,
|
type RuntimeTarget,
|
||||||
type SkillSummary
|
type SkillSummary
|
||||||
} from '../../shared/capability-contracts'
|
} 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 {
|
import {
|
||||||
BrowserProfileService,
|
BrowserProfileService,
|
||||||
FileBrowserProfileStore,
|
FileBrowserProfileStore,
|
||||||
@@ -90,13 +105,8 @@ const skillStateSchema = z
|
|||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
|
|
||||||
const encryptedSecretSchema = z
|
const encryptedSecretSchema =
|
||||||
.object({
|
encryptedSettingsCredentialSchema.optional()
|
||||||
formatVersion: z.literal(1),
|
|
||||||
scheme: z.literal('electron-safe-storage'),
|
|
||||||
ciphertextBase64: z.string()
|
|
||||||
})
|
|
||||||
.optional()
|
|
||||||
|
|
||||||
const storedMcpCommonShape = {
|
const storedMcpCommonShape = {
|
||||||
id: mcpServerIdSchema,
|
id: mcpServerIdSchema,
|
||||||
@@ -199,11 +209,7 @@ const secretPayloadSchema = z
|
|||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
|
|
||||||
export type CapabilityCipher = {
|
export type CapabilityCipher = SettingsCredentialCipher
|
||||||
isAvailable: () => boolean
|
|
||||||
encrypt: (value: string) => Buffer
|
|
||||||
decrypt: (value: Buffer) => string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ResolvedMcpServer = McpServerSummary & {
|
export type ResolvedMcpServer = McpServerSummary & {
|
||||||
secret?: string
|
secret?: string
|
||||||
@@ -226,6 +232,7 @@ export type CapabilityServiceOptions = Readonly<{
|
|||||||
browserProfiles?: BrowserProfileService
|
browserProfiles?: BrowserProfileService
|
||||||
diagnostics?: CapabilityDiagnostics
|
diagnostics?: CapabilityDiagnostics
|
||||||
availableComputerCapabilityImplementations?: readonly ComputerCapabilityImplementationKind[]
|
availableComputerCapabilityImplementations?: readonly ComputerCapabilityImplementationKind[]
|
||||||
|
settingsFileOperations?: Partial<SettingsFileOperations>
|
||||||
}>
|
}>
|
||||||
|
|
||||||
function defaultComputerCapabilityStates(): StoredCapabilities['computerCapabilities'] {
|
function defaultComputerCapabilityStates(): StoredCapabilities['computerCapabilities'] {
|
||||||
@@ -241,12 +248,14 @@ function defaultComputerCapabilityStates(): StoredCapabilities['computerCapabili
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptyStoredCapabilities(): StoredCapabilities {
|
function emptyStoredCapabilities(
|
||||||
|
webSearchEnabled = true
|
||||||
|
): StoredCapabilities {
|
||||||
return {
|
return {
|
||||||
version: 4,
|
version: 4,
|
||||||
skills: {},
|
skills: {},
|
||||||
mcpServers: [],
|
mcpServers: [],
|
||||||
webSearch: { enabled: true },
|
webSearch: { enabled: webSearchEnabled },
|
||||||
computerCapabilities: defaultComputerCapabilityStates()
|
computerCapabilities: defaultComputerCapabilityStates()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -303,12 +312,7 @@ async function listSkills(
|
|||||||
try {
|
try {
|
||||||
entries = await readdir(root, { withFileTypes: true })
|
entries = await readdir(root, { withFileTypes: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (
|
if (isMissingFileError(error)) {
|
||||||
error &&
|
|
||||||
typeof error === 'object' &&
|
|
||||||
'code' in error &&
|
|
||||||
error.code === 'ENOENT'
|
|
||||||
) {
|
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
throw error
|
throw error
|
||||||
@@ -550,12 +554,14 @@ async function extractSkillZip(
|
|||||||
export class CapabilityService {
|
export class CapabilityService {
|
||||||
private state?: StoredCapabilities
|
private state?: StoredCapabilities
|
||||||
private loadPromise?: Promise<StoredCapabilities>
|
private loadPromise?: Promise<StoredCapabilities>
|
||||||
|
private warnings: SettingsWarning[] = []
|
||||||
private updateQueue: Promise<void> = Promise.resolve()
|
private updateQueue: Promise<void> = Promise.resolve()
|
||||||
private readonly platform: NodeJS.Platform
|
private readonly platform: NodeJS.Platform
|
||||||
private readonly architecture: string
|
private readonly architecture: string
|
||||||
private readonly electronTarget: boolean
|
private readonly electronTarget: boolean
|
||||||
private readonly browserProfiles: BrowserProfileService
|
private readonly browserProfiles: BrowserProfileService
|
||||||
private readonly diagnostics: CapabilityDiagnostics
|
private readonly diagnostics: CapabilityDiagnostics
|
||||||
|
private readonly settingsFileOperations?: Partial<SettingsFileOperations>
|
||||||
private readonly availableComputerCapabilityImplementations: ReadonlySet<ComputerCapabilityImplementationKind>
|
private readonly availableComputerCapabilityImplementations: ReadonlySet<ComputerCapabilityImplementationKind>
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -574,6 +580,7 @@ export class CapabilityService {
|
|||||||
'managed-browser-driver'
|
'managed-browser-driver'
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
this.settingsFileOperations = options.settingsFileOperations
|
||||||
this.browserProfiles =
|
this.browserProfiles =
|
||||||
options.browserProfiles ??
|
options.browserProfiles ??
|
||||||
new BrowserProfileService(
|
new BrowserProfileService(
|
||||||
@@ -635,6 +642,9 @@ export class CapabilityService {
|
|||||||
let shouldPersist = false
|
let shouldPersist = false
|
||||||
try {
|
try {
|
||||||
const raw = JSON.parse(await readFile(this.filePath, 'utf8')) as unknown
|
const raw = JSON.parse(await readFile(this.filePath, 'utf8')) as unknown
|
||||||
|
assertSupportedSettingsVersion(raw, 4, (version) =>
|
||||||
|
`当前 GoodBuddy 不支持能力设置版本 ${version},请升级应用后重试`
|
||||||
|
)
|
||||||
const version = z
|
const version = z
|
||||||
.object({
|
.object({
|
||||||
version: z.union([
|
version: z.union([
|
||||||
@@ -676,19 +686,21 @@ export class CapabilityService {
|
|||||||
loaded = storedCapabilitiesSchema.parse(raw)
|
loaded = storedCapabilitiesSchema.parse(raw)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (
|
if (error instanceof UnsupportedSettingsVersionError) {
|
||||||
error &&
|
throw error
|
||||||
typeof error === 'object' &&
|
}
|
||||||
'code' in error &&
|
if (isMissingFileError(error)) {
|
||||||
error.code === 'ENOENT'
|
|
||||||
) {
|
|
||||||
loaded = emptyStoredCapabilities()
|
loaded = emptyStoredCapabilities()
|
||||||
} else {
|
} else {
|
||||||
await rename(
|
await isolateCorruptSettingsFile(
|
||||||
this.filePath,
|
this.filePath,
|
||||||
`${this.filePath}.corrupt-${Date.now()}`
|
'能力设置已损坏且无法隔离',
|
||||||
).catch(() => undefined)
|
Date.now,
|
||||||
loaded = emptyStoredCapabilities()
|
this.settingsFileOperations
|
||||||
|
)
|
||||||
|
this.warnings = [{ code: 'capability-settings-recovered' }]
|
||||||
|
loaded = emptyStoredCapabilities(false)
|
||||||
|
shouldPersist = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const migrateMcpAssignments = loaded.mcpServers.some((server) =>
|
const migrateMcpAssignments = loaded.mcpServers.some((server) =>
|
||||||
@@ -741,17 +753,27 @@ export class CapabilityService {
|
|||||||
|
|
||||||
private async persist(state: StoredCapabilities): Promise<void> {
|
private async persist(state: StoredCapabilities): Promise<void> {
|
||||||
const validated = storedCapabilitiesSchema.parse(state)
|
const validated = storedCapabilitiesSchema.parse(state)
|
||||||
await mkdir(dirname(this.filePath), { recursive: true })
|
await writeJsonFileAtomically(
|
||||||
const temporaryPath = `${this.filePath}.${process.pid}.tmp`
|
this.filePath,
|
||||||
await writeFile(
|
validated,
|
||||||
temporaryPath,
|
this.settingsFileOperations
|
||||||
`${JSON.stringify(validated, null, 2)}\n`,
|
|
||||||
{ encoding: 'utf8', mode: 0o600 }
|
|
||||||
)
|
)
|
||||||
await rename(temporaryPath, this.filePath)
|
|
||||||
this.state = validated
|
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<
|
private async getSkillCatalog(): Promise<
|
||||||
Array<Omit<SkillSummary, 'enabled' | 'assignments'>>
|
Array<Omit<SkillSummary, 'enabled' | 'assignments'>>
|
||||||
> {
|
> {
|
||||||
@@ -823,7 +845,10 @@ export class CapabilityService {
|
|||||||
riskSummary: capability.riskSummary
|
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> {
|
setWebSearchEnabled(enabled: boolean): Promise<CapabilitySnapshot> {
|
||||||
return this.queue(async () => {
|
return this.queue(async () => {
|
||||||
const state = await this.load()
|
const state = await this.load()
|
||||||
await this.persist({
|
await this.persistUserChange({
|
||||||
...state,
|
...state,
|
||||||
webSearch: { enabled }
|
webSearch: { enabled }
|
||||||
})
|
})
|
||||||
@@ -898,7 +923,7 @@ export class CapabilityService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const state = await this.load()
|
const state = await this.load()
|
||||||
await this.persist({
|
await this.persistUserChange({
|
||||||
...state,
|
...state,
|
||||||
computerCapabilities: {
|
computerCapabilities: {
|
||||||
...state.computerCapabilities,
|
...state.computerCapabilities,
|
||||||
@@ -965,7 +990,7 @@ export class CapabilityService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await this.persist(nextState)
|
await this.persistUserChange(nextState)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (profileId) {
|
if (profileId) {
|
||||||
try {
|
try {
|
||||||
@@ -992,7 +1017,7 @@ export class CapabilityService {
|
|||||||
previousProfileId,
|
previousProfileId,
|
||||||
reference
|
reference
|
||||||
)
|
)
|
||||||
await this.persist(state)
|
await this.persistUserChange(state)
|
||||||
if (profileId) {
|
if (profileId) {
|
||||||
await this.browserProfiles.removeReference(
|
await this.browserProfiles.removeReference(
|
||||||
profileId,
|
profileId,
|
||||||
@@ -1064,6 +1089,7 @@ export class CapabilityService {
|
|||||||
await this.browserProfiles.createProfile(
|
await this.browserProfiles.createProfile(
|
||||||
browserProfileNameSchema.parse(name)
|
browserProfileNameSchema.parse(name)
|
||||||
)
|
)
|
||||||
|
this.clearRecoveryWarnings()
|
||||||
return this.getSnapshot()
|
return this.getSnapshot()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1077,6 +1103,7 @@ export class CapabilityService {
|
|||||||
browserProfileIdSchema.parse(profileId),
|
browserProfileIdSchema.parse(profileId),
|
||||||
browserProfileNameSchema.parse(name)
|
browserProfileNameSchema.parse(name)
|
||||||
)
|
)
|
||||||
|
this.clearRecoveryWarnings()
|
||||||
return this.getSnapshot()
|
return this.getSnapshot()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1086,6 +1113,7 @@ export class CapabilityService {
|
|||||||
await this.browserProfiles.setDefaultProfile(
|
await this.browserProfiles.setDefaultProfile(
|
||||||
browserProfileIdSchema.parse(profileId)
|
browserProfileIdSchema.parse(profileId)
|
||||||
)
|
)
|
||||||
|
this.clearRecoveryWarnings()
|
||||||
return this.getSnapshot()
|
return this.getSnapshot()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1095,6 +1123,7 @@ export class CapabilityService {
|
|||||||
await this.browserProfiles.deleteProfile(
|
await this.browserProfiles.deleteProfile(
|
||||||
browserProfileIdSchema.parse(profileId)
|
browserProfileIdSchema.parse(profileId)
|
||||||
)
|
)
|
||||||
|
this.clearRecoveryWarnings()
|
||||||
return this.getSnapshot()
|
return this.getSnapshot()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1125,7 +1154,7 @@ export class CapabilityService {
|
|||||||
await readSkill(temporaryPath, 'imported', skill.id)
|
await readSkill(temporaryPath, 'imported', skill.id)
|
||||||
await rename(temporaryPath, targetPath)
|
await rename(temporaryPath, targetPath)
|
||||||
const state = await this.load()
|
const state = await this.load()
|
||||||
await this.persist({
|
await this.persistUserChange({
|
||||||
...state,
|
...state,
|
||||||
skills: {
|
skills: {
|
||||||
...state.skills,
|
...state.skills,
|
||||||
@@ -1223,7 +1252,7 @@ export class CapabilityService {
|
|||||||
const state = await this.load()
|
const state = await this.load()
|
||||||
const skills = { ...state.skills }
|
const skills = { ...state.skills }
|
||||||
delete skills[id]
|
delete skills[id]
|
||||||
await this.persist({ ...state, skills })
|
await this.persistUserChange({ ...state, skills })
|
||||||
return this.getSnapshot()
|
return this.getSnapshot()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1255,7 +1284,7 @@ export class CapabilityService {
|
|||||||
throw new Error('Skill 不存在')
|
throw new Error('Skill 不存在')
|
||||||
}
|
}
|
||||||
const state = await this.load()
|
const state = await this.load()
|
||||||
await this.persist({
|
await this.persistUserChange({
|
||||||
...state,
|
...state,
|
||||||
skills: {
|
skills: {
|
||||||
...state.skills,
|
...state.skills,
|
||||||
@@ -1311,19 +1340,11 @@ export class CapabilityService {
|
|||||||
if (!this.cipher.isAvailable()) {
|
if (!this.cipher.isAvailable()) {
|
||||||
throw new Error('系统安全存储不可用,MCP 访问令牌未保存')
|
throw new Error('系统安全存储不可用,MCP 访问令牌未保存')
|
||||||
}
|
}
|
||||||
credential = {
|
credential = encryptSettingsCredential(this.cipher, {
|
||||||
formatVersion: 1 as const,
|
version: 1,
|
||||||
scheme: 'electron-safe-storage' as const,
|
serverId: id,
|
||||||
ciphertextBase64: this.cipher
|
secret: value.secret.value
|
||||||
.encrypt(
|
})
|
||||||
JSON.stringify({
|
|
||||||
version: 1,
|
|
||||||
serverId: id,
|
|
||||||
secret: value.secret.value
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.toString('base64')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const stored: StoredMcpServer =
|
const stored: StoredMcpServer =
|
||||||
value.transport === 'stdio'
|
value.transport === 'stdio'
|
||||||
@@ -1354,7 +1375,7 @@ export class CapabilityService {
|
|||||||
server.id === id ? stored : server
|
server.id === id ? stored : server
|
||||||
)
|
)
|
||||||
: [...state.mcpServers, stored]
|
: [...state.mcpServers, stored]
|
||||||
await this.persist({ ...state, mcpServers: nextServers })
|
await this.persistUserChange({ ...state, mcpServers: nextServers })
|
||||||
return this.getSnapshot()
|
return this.getSnapshot()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1366,7 +1387,7 @@ export class CapabilityService {
|
|||||||
if (!state.mcpServers.some((server) => server.id === id)) {
|
if (!state.mcpServers.some((server) => server.id === id)) {
|
||||||
throw new Error('MCP Server 不存在')
|
throw new Error('MCP Server 不存在')
|
||||||
}
|
}
|
||||||
await this.persist({
|
await this.persistUserChange({
|
||||||
...state,
|
...state,
|
||||||
mcpServers: state.mcpServers.filter((server) => server.id !== id)
|
mcpServers: state.mcpServers.filter((server) => server.id !== id)
|
||||||
})
|
})
|
||||||
@@ -1388,11 +1409,7 @@ export class CapabilityService {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const payload = secretPayloadSchema.parse(
|
const payload = secretPayloadSchema.parse(
|
||||||
JSON.parse(
|
decryptSettingsCredential(this.cipher, server.credential)
|
||||||
this.cipher.decrypt(
|
|
||||||
Buffer.from(server.credential.ciphertextBase64, 'base64')
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
if (payload.serverId === id) {
|
if (payload.serverId === id) {
|
||||||
secret = payload.secret
|
secret = payload.secret
|
||||||
|
|||||||
@@ -192,10 +192,14 @@ describe('ChannelSettingsStore', () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const initial = await store.snapshot()
|
const initial = await store.snapshot()
|
||||||
expect(initial.warning).toContain('已损坏')
|
expect(initial.warnings).toContainEqual({
|
||||||
|
code: 'channel-settings-recovered'
|
||||||
|
})
|
||||||
expect(
|
expect(
|
||||||
await readdir(join(filePath, '..'))
|
(await readdir(join(filePath, '..'))).some((name) =>
|
||||||
).toContain('channel-settings.json.corrupt-1234')
|
name.startsWith('channel-settings.json.corrupt-1234-')
|
||||||
|
)
|
||||||
|
).toBe(true)
|
||||||
|
|
||||||
await store.apply({
|
await store.apply({
|
||||||
dingtalk: {
|
dingtalk: {
|
||||||
@@ -215,6 +219,9 @@ describe('ChannelSettingsStore', () => {
|
|||||||
expect((await readdir(join(filePath, '..'))).some(
|
expect((await readdir(join(filePath, '..'))).some(
|
||||||
(name) => name.endsWith('.tmp')
|
(name) => name.endsWith('.tmp')
|
||||||
)).toBe(false)
|
)).toBe(false)
|
||||||
|
await expect(store.snapshot()).resolves.not.toHaveProperty(
|
||||||
|
'warnings'
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('encrypts Weixin binding credentials and removes them on disconnect', async () => {
|
it('encrypts Weixin binding credentials and removes them on disconnect', async () => {
|
||||||
@@ -251,4 +258,293 @@ describe('ChannelSettingsStore', () => {
|
|||||||
})
|
})
|
||||||
expect((await store.resolve('weixin')).token).toBeUndefined()
|
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 { readFile } from 'node:fs/promises'
|
||||||
import {
|
|
||||||
mkdir,
|
|
||||||
readFile,
|
|
||||||
rename,
|
|
||||||
rm,
|
|
||||||
writeFile
|
|
||||||
} from 'node:fs/promises'
|
|
||||||
import { dirname } from 'node:path'
|
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import {
|
import {
|
||||||
CHANNEL_SETTINGS_LIMITS,
|
CHANNEL_SETTINGS_LIMITS,
|
||||||
@@ -21,17 +13,28 @@ import {
|
|||||||
type WeComChannelSettingsInput
|
type WeComChannelSettingsInput
|
||||||
} from '../../shared/channel-settings-contracts'
|
} from '../../shared/channel-settings-contracts'
|
||||||
import { weixinAccountDisplay } from '../../shared/weixin-channel-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 {
|
export type ChannelCredentialCipher = SettingsCredentialCipher
|
||||||
isAvailable(): boolean
|
|
||||||
encrypt(value: string): Buffer
|
|
||||||
decrypt(value: Buffer): string
|
|
||||||
}
|
|
||||||
|
|
||||||
const encryptedCredentialSchema = z
|
const encryptedCredentialSchema = encryptedSettingsCredentialSchema
|
||||||
.object({
|
.extend({
|
||||||
formatVersion: z.literal(1),
|
|
||||||
scheme: z.literal('electron-safe-storage'),
|
|
||||||
ciphertextBase64: z
|
ciphertextBase64: z
|
||||||
.string()
|
.string()
|
||||||
.min(1)
|
.min(1)
|
||||||
@@ -112,6 +115,8 @@ type StoredEncryptedCredential = z.infer<
|
|||||||
typeof encryptedCredentialSchema
|
typeof encryptedCredentialSchema
|
||||||
>
|
>
|
||||||
|
|
||||||
|
class DeferredWeixinMigrationError extends Error {}
|
||||||
|
|
||||||
const credentialPayloadSchema = z
|
const credentialPayloadSchema = z
|
||||||
.object({
|
.object({
|
||||||
version: z.literal(1),
|
version: z.literal(1),
|
||||||
@@ -161,7 +166,7 @@ type EnvironmentChannel = {
|
|||||||
secret?: string
|
secret?: string
|
||||||
allowedSenderIds: readonly string[]
|
allowedSenderIds: readonly string[]
|
||||||
allowGroupMessages: boolean
|
allowGroupMessages: boolean
|
||||||
error?: string
|
warning?: SettingsWarning
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ResolvedChannelSettings =
|
export type ResolvedChannelSettings =
|
||||||
@@ -184,7 +189,7 @@ export type ResolvedChannelSettings =
|
|||||||
secret?: string
|
secret?: string
|
||||||
allowedSenderIds: readonly string[]
|
allowedSenderIds: readonly string[]
|
||||||
allowGroupMessages: boolean
|
allowGroupMessages: boolean
|
||||||
source: 'none' | 'encrypted' | 'environment'
|
source: 'none' | 'encrypted' | 'environment' | 'unreadable'
|
||||||
readOnly: boolean
|
readOnly: boolean
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
@@ -194,7 +199,7 @@ export type ResolvedChannelSettings =
|
|||||||
secret?: string
|
secret?: string
|
||||||
allowedSenderIds: readonly string[]
|
allowedSenderIds: readonly string[]
|
||||||
allowGroupMessages: boolean
|
allowGroupMessages: boolean
|
||||||
source: 'none' | 'encrypted' | 'environment'
|
source: 'none' | 'encrypted' | 'environment' | 'unreadable'
|
||||||
readOnly: boolean
|
readOnly: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,15 +226,6 @@ const defaultStatus = (enabled: boolean): ChannelRuntimeStatus => ({
|
|||||||
state: enabled ? 'stopped' : 'disabled'
|
state: enabled ? 'stopped' : 'disabled'
|
||||||
})
|
})
|
||||||
|
|
||||||
function isMissingFile(error: unknown): boolean {
|
|
||||||
return (
|
|
||||||
error !== null &&
|
|
||||||
typeof error === 'object' &&
|
|
||||||
'code' in error &&
|
|
||||||
error.code === 'ENOENT'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function boundedEnvironmentValue(
|
function boundedEnvironmentValue(
|
||||||
environment: NodeJS.ProcessEnv,
|
environment: NodeJS.ProcessEnv,
|
||||||
name: string,
|
name: string,
|
||||||
@@ -319,15 +315,27 @@ export type WeixinBinding = z.infer<typeof weixinBindingSchema>
|
|||||||
|
|
||||||
export class ChannelSettingsStore {
|
export class ChannelSettingsStore {
|
||||||
private settings?: StoredSettings
|
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 updateQueue: Promise<void> = Promise.resolve()
|
||||||
|
private readonly environmentChannels: Record<
|
||||||
|
CredentialChannel,
|
||||||
|
EnvironmentChannel
|
||||||
|
>
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly filePath: string,
|
private readonly filePath: string,
|
||||||
private readonly cipher: ChannelCredentialCipher,
|
private readonly cipher: ChannelCredentialCipher,
|
||||||
private readonly environment: NodeJS.ProcessEnv = process.env,
|
private readonly environment: NodeJS.ProcessEnv = process.env,
|
||||||
private readonly now: () => number = Date.now
|
private readonly now: () => number = Date.now
|
||||||
) {}
|
) {
|
||||||
|
this.environmentChannels = {
|
||||||
|
wecom: this.readEnvironmentChannel('wecom'),
|
||||||
|
dingtalk: this.readEnvironmentChannel('dingtalk')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async snapshot(
|
async snapshot(
|
||||||
statuses: Partial<Record<ManagedChannel, ChannelRuntimeStatus>> = {}
|
statuses: Partial<Record<ManagedChannel, ChannelRuntimeStatus>> = {}
|
||||||
@@ -339,9 +347,17 @@ export class ChannelSettingsStore {
|
|||||||
])
|
])
|
||||||
const weComEnvironment = this.environmentChannel('wecom')
|
const weComEnvironment = this.environmentChannel('wecom')
|
||||||
const dingTalkEnvironment = this.environmentChannel('dingtalk')
|
const dingTalkEnvironment = this.environmentChannel('dingtalk')
|
||||||
const environmentWarning =
|
const warnings = [
|
||||||
weComEnvironment.error ?? dingTalkEnvironment.error
|
...this.warnings,
|
||||||
const warning = this.warning ?? environmentWarning
|
...(this.runtimeRepairWarning ? [this.runtimeRepairWarning] : []),
|
||||||
|
...(weComEnvironment.warning ? [weComEnvironment.warning] : []),
|
||||||
|
...(dingTalkEnvironment.warning ? [dingTalkEnvironment.warning] : [])
|
||||||
|
].filter(
|
||||||
|
(warning, index, values) =>
|
||||||
|
values.findIndex(
|
||||||
|
(candidate) => settingsWarningsEqual(candidate, warning)
|
||||||
|
) === index
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
weixin: {
|
weixin: {
|
||||||
enabled: weixin.enabled,
|
enabled: weixin.enabled,
|
||||||
@@ -360,12 +376,9 @@ export class ChannelSettingsStore {
|
|||||||
allowGroupMessages: wecom.allowGroupMessages,
|
allowGroupMessages: wecom.allowGroupMessages,
|
||||||
status:
|
status:
|
||||||
statuses.wecom ??
|
statuses.wecom ??
|
||||||
(weComEnvironment.error === undefined
|
(weComEnvironment.warning === undefined
|
||||||
? defaultStatus(wecom.enabled)
|
? defaultStatus(wecom.enabled)
|
||||||
: {
|
: { state: 'error' })
|
||||||
state: 'error',
|
|
||||||
lastError: weComEnvironment.error
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
dingtalk: {
|
dingtalk: {
|
||||||
enabled: dingtalk.enabled,
|
enabled: dingtalk.enabled,
|
||||||
@@ -377,17 +390,24 @@ export class ChannelSettingsStore {
|
|||||||
allowGroupMessages: dingtalk.allowGroupMessages,
|
allowGroupMessages: dingtalk.allowGroupMessages,
|
||||||
status:
|
status:
|
||||||
statuses.dingtalk ??
|
statuses.dingtalk ??
|
||||||
(dingTalkEnvironment.error === undefined
|
(dingTalkEnvironment.warning === undefined
|
||||||
? defaultStatus(dingtalk.enabled)
|
? defaultStatus(dingtalk.enabled)
|
||||||
: {
|
: { state: 'error' })
|
||||||
state: 'error',
|
|
||||||
lastError: dingTalkEnvironment.error
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
...(warning === undefined ? {} : { warning })
|
...(warnings.length > 0 ? { warnings } : {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reportRuntimeSelectionRepairs(count: number): void {
|
||||||
|
this.runtimeRepairWarning =
|
||||||
|
count > 0
|
||||||
|
? {
|
||||||
|
code: 'channel-runtime-selections-repaired',
|
||||||
|
count
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
|
||||||
getSnapshot(
|
getSnapshot(
|
||||||
statuses?: Partial<Record<ManagedChannel, ChannelRuntimeStatus>>
|
statuses?: Partial<Record<ManagedChannel, ChannelRuntimeStatus>>
|
||||||
): Promise<ChannelSettingsSnapshot> {
|
): Promise<ChannelSettingsSnapshot> {
|
||||||
@@ -409,9 +429,16 @@ export class ChannelSettingsStore {
|
|||||||
const settings = await this.load()
|
const settings = await this.load()
|
||||||
const stored = settings.weixin
|
const stored = settings.weixin
|
||||||
const binding = this.decryptWeixinBinding(stored)
|
const binding = this.decryptWeixinBinding(stored)
|
||||||
|
if (this.temporarilyDisabledWeixin && binding) {
|
||||||
|
this.temporarilyDisabledWeixin = false
|
||||||
|
this.removeWarnings([
|
||||||
|
'channel-weixin-credential-unreadable',
|
||||||
|
'channel-weixin-secure-storage-unavailable'
|
||||||
|
])
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
channel,
|
channel,
|
||||||
enabled: stored.enabled,
|
enabled: stored.enabled && !this.temporarilyDisabledWeixin,
|
||||||
accountId: binding?.accountId ?? '',
|
accountId: binding?.accountId ?? '',
|
||||||
userId: binding?.userId ?? '',
|
userId: binding?.userId ?? '',
|
||||||
baseUrl: binding?.baseUrl ?? '',
|
baseUrl: binding?.baseUrl ?? '',
|
||||||
@@ -448,12 +475,18 @@ export class ChannelSettingsStore {
|
|||||||
const settings = await this.load()
|
const settings = await this.load()
|
||||||
const stored = settings[channel]
|
const stored = settings[channel]
|
||||||
const secret = this.decryptCredential(channel, stored)
|
const secret = this.decryptCredential(channel, stored)
|
||||||
|
const credentialUnreadable =
|
||||||
|
stored.credential !== undefined && secret === undefined
|
||||||
const common = {
|
const common = {
|
||||||
enabled: stored.enabled,
|
enabled: stored.enabled,
|
||||||
...(secret === undefined ? {} : { secret }),
|
...(secret === undefined ? {} : { secret }),
|
||||||
allowedSenderIds: [...stored.allowedSenderIds],
|
allowedSenderIds: [...stored.allowedSenderIds],
|
||||||
allowGroupMessages: stored.allowGroupMessages,
|
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
|
readOnly: false
|
||||||
}
|
}
|
||||||
return channel === 'wecom'
|
return channel === 'wecom'
|
||||||
@@ -484,7 +517,12 @@ export class ChannelSettingsStore {
|
|||||||
}
|
}
|
||||||
await this.persist(current)
|
await this.persist(current)
|
||||||
this.settings = 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()
|
snapshot = await this.snapshot()
|
||||||
}
|
}
|
||||||
const operation = this.updateQueue.then(update, update)
|
const operation = this.updateQueue.then(update, update)
|
||||||
@@ -504,7 +542,12 @@ export class ChannelSettingsStore {
|
|||||||
}
|
}
|
||||||
await this.persist(current)
|
await this.persist(current)
|
||||||
this.settings = 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()
|
snapshot = await this.snapshot()
|
||||||
}
|
}
|
||||||
const operation = this.updateQueue.then(update, update)
|
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('wecom', current.wecom)
|
||||||
this.validateEnabledCredentialChannel('dingtalk', current.dingtalk)
|
this.validateEnabledCredentialChannel('dingtalk', current.dingtalk)
|
||||||
await this.persist(current)
|
await this.persist(current)
|
||||||
this.settings = 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()
|
return this.snapshot()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -652,34 +713,47 @@ export class ChannelSettingsStore {
|
|||||||
if (!this.cipher.isAvailable()) {
|
if (!this.cipher.isAvailable()) {
|
||||||
throw new Error('系统安全存储不可用,无法保存通道 Secret')
|
throw new Error('系统安全存储不可用,无法保存通道 Secret')
|
||||||
}
|
}
|
||||||
const encrypted = this.cipher.encrypt(
|
return encryptSettingsCredential(this.cipher, {
|
||||||
JSON.stringify({ version: 1, channel, secret })
|
version: 1,
|
||||||
)
|
channel,
|
||||||
return {
|
secret
|
||||||
formatVersion: 1,
|
})
|
||||||
scheme: 'electron-safe-storage',
|
|
||||||
ciphertextBase64: encrypted.toString('base64')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private decryptCredential(
|
private decryptCredential(
|
||||||
channel: CredentialChannel,
|
channel: CredentialChannel,
|
||||||
stored: StoredCredentialChannel
|
stored: StoredCredentialChannel
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
if (stored.credential === undefined || !this.cipher.isAvailable()) {
|
if (stored.credential === undefined) {
|
||||||
return 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 {
|
try {
|
||||||
const payload = credentialPayloadSchema.parse(
|
const payload = credentialPayloadSchema.parse(
|
||||||
JSON.parse(
|
decryptSettingsCredential(this.cipher, stored.credential)
|
||||||
this.cipher.decrypt(
|
|
||||||
Buffer.from(stored.credential.ciphertextBase64, 'base64')
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
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 {
|
} catch {
|
||||||
return undefined
|
return warn()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -689,21 +763,14 @@ export class ChannelSettingsStore {
|
|||||||
if (!this.cipher.isAvailable()) {
|
if (!this.cipher.isAvailable()) {
|
||||||
throw new Error('系统安全存储不可用,无法保存微信绑定')
|
throw new Error('系统安全存储不可用,无法保存微信绑定')
|
||||||
}
|
}
|
||||||
const encrypted = this.cipher.encrypt(
|
return encryptSettingsCredential(this.cipher, {
|
||||||
JSON.stringify({
|
version: 2,
|
||||||
version: 2,
|
channel: 'weixin',
|
||||||
channel: 'weixin',
|
accountId: binding.accountId,
|
||||||
accountId: binding.accountId,
|
userId: binding.userId,
|
||||||
userId: binding.userId,
|
baseUrl: binding.baseUrl,
|
||||||
baseUrl: binding.baseUrl,
|
token: binding.token
|
||||||
token: binding.token
|
})
|
||||||
})
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
formatVersion: 1,
|
|
||||||
scheme: 'electron-safe-storage',
|
|
||||||
ciphertextBase64: encrypted.toString('base64')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private decryptWeixinBinding(
|
private decryptWeixinBinding(
|
||||||
@@ -714,81 +781,38 @@ export class ChannelSettingsStore {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return weixinCredentialPayloadSchema.parse(
|
return weixinCredentialPayloadSchema.parse(
|
||||||
JSON.parse(
|
decryptSettingsCredential(this.cipher, stored.credential)
|
||||||
this.cipher.decrypt(
|
|
||||||
Buffer.from(stored.credential.ciphertextBase64, 'base64')
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async load(): Promise<StoredSettings> {
|
private load(): Promise<StoredSettings> {
|
||||||
if (this.settings !== undefined) {
|
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 {
|
try {
|
||||||
const raw: unknown = JSON.parse(await readFile(this.filePath, 'utf8'))
|
const raw: unknown = JSON.parse(await readFile(this.filePath, 'utf8'))
|
||||||
|
assertSupportedSettingsVersion(raw, 3, (version) =>
|
||||||
|
`当前 GoodBuddy 不支持通道设置版本 ${version},请升级应用后重试`
|
||||||
|
)
|
||||||
const current = storedSettingsSchema.safeParse(raw)
|
const current = storedSettingsSchema.safeParse(raw)
|
||||||
if (current.success) {
|
if (current.success) {
|
||||||
this.settings = current.data
|
this.settings = this.normalizeStoredSettings(current.data)
|
||||||
} else {
|
} else {
|
||||||
const versionTwo = versionTwoStoredSettingsSchema.safeParse(raw)
|
const versionTwo = versionTwoStoredSettingsSchema.safeParse(raw)
|
||||||
if (versionTwo.success) {
|
if (versionTwo.success) {
|
||||||
const legacyWeixin = versionTwo.data.weixin
|
this.settings = this.migrateVersionTwo(versionTwo.data)
|
||||||
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 =
|
|
||||||
'旧版微信绑定无法安全迁移,请重新扫码绑定'
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
const legacy = legacyStoredSettingsSchema.parse(raw)
|
const legacy = legacyStoredSettingsSchema.parse(raw)
|
||||||
this.settings = {
|
this.settings = {
|
||||||
@@ -803,38 +827,114 @@ export class ChannelSettingsStore {
|
|||||||
await this.persist(this.settings)
|
await this.persist(this.settings)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isMissingFile(error)) {
|
if (
|
||||||
this.warning = '通道设置文件已损坏,已隔离原文件并恢复默认设置'
|
error instanceof UnsupportedSettingsVersionError ||
|
||||||
await rename(
|
error instanceof DeferredWeixinMigrationError
|
||||||
|
) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (!isMissingFileError(error)) {
|
||||||
|
await isolateCorruptSettingsFile(
|
||||||
this.filePath,
|
this.filePath,
|
||||||
`${this.filePath}.corrupt-${this.now()}`
|
'通道设置已损坏且无法隔离',
|
||||||
).catch(() => undefined)
|
this.now
|
||||||
|
)
|
||||||
|
this.warnings = [{ code: 'channel-settings-recovered' }]
|
||||||
}
|
}
|
||||||
this.settings = cloneStored(defaultStoredSettings)
|
this.settings = cloneStored(defaultStoredSettings)
|
||||||
}
|
}
|
||||||
return this.settings
|
return this.settings
|
||||||
}
|
}
|
||||||
|
|
||||||
private async persist(settings: StoredSettings): Promise<void> {
|
private normalizeStoredSettings(settings: StoredSettings): StoredSettings {
|
||||||
await mkdir(dirname(this.filePath), { recursive: true })
|
if (
|
||||||
const temporaryPath = `${this.filePath}.${process.pid}.${randomUUID()}.tmp`
|
settings.weixin.credential &&
|
||||||
try {
|
this.decryptWeixinBinding(settings.weixin) === undefined
|
||||||
await writeFile(
|
) {
|
||||||
temporaryPath,
|
this.temporarilyDisabledWeixin = true
|
||||||
`${JSON.stringify(settings, null, 2)}\n`,
|
this.addWarning({
|
||||||
{
|
code: this.cipher.isAvailable()
|
||||||
encoding: 'utf8',
|
? 'channel-weixin-credential-unreadable'
|
||||||
mode: 0o600,
|
: 'channel-weixin-secure-storage-unavailable'
|
||||||
flag: 'wx'
|
})
|
||||||
}
|
} 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 {
|
let token: string | undefined
|
||||||
await rm(temporaryPath, { force: true })
|
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 {
|
private environmentChannel(channel: CredentialChannel): EnvironmentChannel {
|
||||||
|
return this.environmentChannels[channel]
|
||||||
|
}
|
||||||
|
|
||||||
|
private readEnvironmentChannel(
|
||||||
|
channel: CredentialChannel
|
||||||
|
): EnvironmentChannel {
|
||||||
const prefix =
|
const prefix =
|
||||||
channel === 'wecom' ? 'GOODBUDDY_WECOM' : 'GOODBUDDY_DINGTALK'
|
channel === 'wecom' ? 'GOODBUDDY_WECOM' : 'GOODBUDDY_DINGTALK'
|
||||||
const idName =
|
const idName =
|
||||||
@@ -903,11 +1003,31 @@ export class ChannelSettingsStore {
|
|||||||
senders.value.length > 0
|
senders.value.length > 0
|
||||||
? {}
|
? {}
|
||||||
: {
|
: {
|
||||||
error:
|
warning: {
|
||||||
channel === 'wecom'
|
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()
|
return this.snapshot()
|
||||||
}
|
}
|
||||||
|
|
||||||
stop(): void {
|
async stop(): Promise<void> {
|
||||||
this.generation += 1
|
this.generation += 1
|
||||||
this.stopClient()
|
this.stopClient()
|
||||||
this.snapshotValue = { status: 'stopped' }
|
this.snapshotValue = { status: 'stopped' }
|
||||||
|
await this.credentialSave
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleMessage(
|
private handleMessage(
|
||||||
@@ -83,12 +84,13 @@ export class WechatBindingController {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (message.type === 'credential') {
|
if (message.type === 'credential') {
|
||||||
|
if (this.savingCredential) {
|
||||||
|
return
|
||||||
|
}
|
||||||
this.savingCredential = true
|
this.savingCredential = true
|
||||||
this.credentialSave = this.credentialSave
|
this.stopClient()
|
||||||
|
const save = this.credentialSave
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
if (generation !== this.generation) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.stopClient()
|
this.stopClient()
|
||||||
await this.store.saveWeixinBinding({
|
await this.store.saveWeixinBinding({
|
||||||
accountId: message.accountId,
|
accountId: message.accountId,
|
||||||
@@ -120,9 +122,13 @@ export class WechatBindingController {
|
|||||||
: '微信绑定保存失败'
|
: '微信绑定保存失败'
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
const trackedSave = save
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.savingCredential = false
|
if (this.credentialSave === trackedSave) {
|
||||||
|
this.savingCredential = false
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
this.credentialSave = trackedSave
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (message.type === 'qr') {
|
if (message.type === 'qr') {
|
||||||
|
|||||||
@@ -170,7 +170,10 @@ export class DocumentParsingService {
|
|||||||
conversionAvailable: false,
|
conversionAvailable: false,
|
||||||
localOcr
|
localOcr
|
||||||
},
|
},
|
||||||
ocrModels
|
ocrModels,
|
||||||
|
...(this.settingsStore.getWarnings().length > 0
|
||||||
|
? { warnings: [...this.settingsStore.getWarnings()] }
|
||||||
|
: {})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ describe('DocumentParsingSettingsStore', () => {
|
|||||||
await expect(store.get()).resolves.toEqual(
|
await expect(store.get()).resolves.toEqual(
|
||||||
defaultDocumentParsingSettings
|
defaultDocumentParsingSettings
|
||||||
)
|
)
|
||||||
|
expect(store.getWarnings()).toEqual([])
|
||||||
await expect(readdir(directory)).resolves.toEqual([])
|
await expect(readdir(directory)).resolves.toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -143,10 +144,32 @@ describe('DocumentParsingSettingsStore', () => {
|
|||||||
await expect(store.get()).resolves.toEqual(
|
await expect(store.get()).resolves.toEqual(
|
||||||
defaultDocumentParsingSettings
|
defaultDocumentParsingSettings
|
||||||
)
|
)
|
||||||
|
expect(store.getWarnings()).toEqual([
|
||||||
|
{ code: 'document-parsing-settings-recovered' }
|
||||||
|
])
|
||||||
const entries = await readdir(directory)
|
const entries = await readdir(directory)
|
||||||
expect(entries).toHaveLength(1)
|
expect(entries).toHaveLength(1)
|
||||||
expect(entries[0]).toMatch(
|
expect(entries[0]).toMatch(
|
||||||
/^document-parsing-settings\.json\.corrupt-\d+-[a-f0-9]{12}$/u
|
/^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 { readFile } from 'node:fs/promises'
|
||||||
import {
|
|
||||||
mkdir,
|
|
||||||
readFile,
|
|
||||||
rename,
|
|
||||||
rm,
|
|
||||||
writeFile
|
|
||||||
} from 'node:fs/promises'
|
|
||||||
import { dirname } from 'node:path'
|
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import {
|
import {
|
||||||
documentParsingSettingsSchema,
|
documentParsingSettingsSchema,
|
||||||
documentParsingSettingsUpdateSchema,
|
documentParsingSettingsUpdateSchema,
|
||||||
type DocumentParsingSettings
|
type DocumentParsingSettings
|
||||||
} from '../shared/document-parsing-contracts'
|
} 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
|
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 {
|
export class DocumentParsingSettingsStore {
|
||||||
private settings?: StoredDocumentParsingSettings
|
private settings?: StoredDocumentParsingSettings
|
||||||
|
private settingsLoad?: Promise<StoredDocumentParsingSettings>
|
||||||
|
private warnings: SettingsWarning[] = []
|
||||||
private updateQueue: Promise<void> = Promise.resolve()
|
private updateQueue: Promise<void> = Promise.resolve()
|
||||||
|
|
||||||
constructor(private readonly filePath: string) {}
|
constructor(private readonly filePath: string) {}
|
||||||
|
|
||||||
private async isolateCorruptFile(): Promise<void> {
|
private async isolateCorruptFile(): Promise<void> {
|
||||||
const isolatedPath =
|
await isolateCorruptSettingsFile(
|
||||||
`${this.filePath}.corrupt-${Date.now()}-` +
|
this.filePath,
|
||||||
randomBytes(6).toString('hex')
|
'文档解析设置损坏且无法隔离'
|
||||||
try {
|
)
|
||||||
await rename(this.filePath, isolatedPath)
|
|
||||||
} catch (error) {
|
|
||||||
if (!isMissingFile(error)) {
|
|
||||||
throw new Error('文档解析设置损坏且无法隔离', {
|
|
||||||
cause: error
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadStored(): Promise<StoredDocumentParsingSettings> {
|
private loadStored(): Promise<StoredDocumentParsingSettings> {
|
||||||
if (this.settings) {
|
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 {
|
try {
|
||||||
const contents = await readFile(this.filePath, 'utf8')
|
const contents = await readFile(this.filePath, 'utf8')
|
||||||
let parsed: unknown
|
let parsed: unknown
|
||||||
@@ -137,12 +131,19 @@ export class DocumentParsingSettingsStore {
|
|||||||
parsed = JSON.parse(contents) as unknown
|
parsed = JSON.parse(contents) as unknown
|
||||||
} catch {
|
} catch {
|
||||||
await this.isolateCorruptFile()
|
await this.isolateCorruptFile()
|
||||||
|
this.warnings = [{ code: 'document-parsing-settings-recovered' }]
|
||||||
this.settings = {
|
this.settings = {
|
||||||
version: CURRENT_SETTINGS_VERSION,
|
version: CURRENT_SETTINGS_VERSION,
|
||||||
...defaultDocumentParsingSettings
|
...defaultDocumentParsingSettings
|
||||||
}
|
}
|
||||||
return this.settings
|
return this.settings
|
||||||
}
|
}
|
||||||
|
assertSupportedSettingsVersion(
|
||||||
|
parsed,
|
||||||
|
CURRENT_SETTINGS_VERSION,
|
||||||
|
(version) =>
|
||||||
|
`当前 GoodBuddy 不支持文档解析设置版本 ${version},请升级应用后重试`
|
||||||
|
)
|
||||||
const result =
|
const result =
|
||||||
storedDocumentParsingSettingsSchema.safeParse(parsed)
|
storedDocumentParsingSettingsSchema.safeParse(parsed)
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@@ -170,6 +171,7 @@ export class DocumentParsingSettingsStore {
|
|||||||
return this.settings
|
return this.settings
|
||||||
}
|
}
|
||||||
await this.isolateCorruptFile()
|
await this.isolateCorruptFile()
|
||||||
|
this.warnings = [{ code: 'document-parsing-settings-recovered' }]
|
||||||
this.settings = {
|
this.settings = {
|
||||||
version: CURRENT_SETTINGS_VERSION,
|
version: CURRENT_SETTINGS_VERSION,
|
||||||
...defaultDocumentParsingSettings
|
...defaultDocumentParsingSettings
|
||||||
@@ -178,7 +180,10 @@ export class DocumentParsingSettingsStore {
|
|||||||
}
|
}
|
||||||
this.settings = result.data
|
this.settings = result.data
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isMissingFile(error)) {
|
if (error instanceof UnsupportedSettingsVersionError) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (!isMissingFileError(error)) {
|
||||||
throw new Error('无法读取文档解析设置', { cause: error })
|
throw new Error('无法读取文档解析设置', { cause: error })
|
||||||
}
|
}
|
||||||
this.settings = {
|
this.settings = {
|
||||||
@@ -195,6 +200,10 @@ export class DocumentParsingSettingsStore {
|
|||||||
return documentParsingSettingsSchema.parse(settings)
|
return documentParsingSettingsSchema.parse(settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getWarnings(): readonly SettingsWarning[] {
|
||||||
|
return this.warnings
|
||||||
|
}
|
||||||
|
|
||||||
update(input: unknown): Promise<DocumentParsingSettings> {
|
update(input: unknown): Promise<DocumentParsingSettings> {
|
||||||
const operation = this.updateQueue.then(async () => {
|
const operation = this.updateQueue.then(async () => {
|
||||||
const updates = documentParsingSettingsUpdateSchema.parse(input)
|
const updates = documentParsingSettingsUpdateSchema.parse(input)
|
||||||
@@ -202,25 +211,9 @@ export class DocumentParsingSettingsStore {
|
|||||||
version: CURRENT_SETTINGS_VERSION,
|
version: CURRENT_SETTINGS_VERSION,
|
||||||
...updates
|
...updates
|
||||||
}
|
}
|
||||||
await mkdir(dirname(this.filePath), { recursive: true })
|
await writeJsonFileAtomically(this.filePath, next)
|
||||||
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 })
|
|
||||||
}
|
|
||||||
this.settings = next
|
this.settings = next
|
||||||
|
this.warnings = []
|
||||||
return this.get()
|
return this.get()
|
||||||
})
|
})
|
||||||
this.updateQueue = operation.then(
|
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 { SpeechTranscriptionService } from './speech/speech-transcription-service'
|
||||||
import { GlobalTlsPolicy } from './global-tls-policy'
|
import { GlobalTlsPolicy } from './global-tls-policy'
|
||||||
import type { AgentRuntimeSelection } from '../shared/runtime-selection-contracts'
|
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 { DocumentParsingSettingsStore } from './document-parsing-settings-store'
|
||||||
import { DocumentOcrModelManager } from './document-ocr-model-manager'
|
import { DocumentOcrModelManager } from './document-ocr-model-manager'
|
||||||
import { DocumentOcrBroker } from './document-ocr-broker'
|
import { DocumentOcrBroker } from './document-ocr-broker'
|
||||||
@@ -104,6 +107,7 @@ let browserService: BrowserService | undefined
|
|||||||
let globalTlsPolicy: GlobalTlsPolicy | undefined
|
let globalTlsPolicy: GlobalTlsPolicy | undefined
|
||||||
let documentOcrBroker: DocumentOcrBroker | undefined
|
let documentOcrBroker: DocumentOcrBroker | undefined
|
||||||
let documentOcrModelManager: DocumentOcrModelManager | undefined
|
let documentOcrModelManager: DocumentOcrModelManager | undefined
|
||||||
|
let stopRuntimeReconfiguration: (() => Promise<void>) | undefined
|
||||||
|
|
||||||
function createEmbeddingProvider(
|
function createEmbeddingProvider(
|
||||||
settings: ResolvedRuntimeSettings
|
settings: ResolvedRuntimeSettings
|
||||||
@@ -425,8 +429,10 @@ if (hasSingleInstanceLock) {
|
|||||||
defaultWorkspace,
|
defaultWorkspace,
|
||||||
initialRuntimeSettings.defaultModelProfileId
|
initialRuntimeSettings.defaultModelProfileId
|
||||||
)
|
)
|
||||||
assistantDatabase.repairConversationRuntimeSelections(
|
channelSettingsStore.reportRuntimeSelectionRepairs(
|
||||||
initialRuntimeSettings
|
assistantDatabase.repairConversationRuntimeSelections(
|
||||||
|
initialRuntimeSettings
|
||||||
|
)
|
||||||
)
|
)
|
||||||
knowledgeGateway = new KnowledgeMcpGateway(knowledgeService, {
|
knowledgeGateway = new KnowledgeMcpGateway(knowledgeService, {
|
||||||
magicNotesDatabase: assistantDatabase
|
magicNotesDatabase: assistantDatabase
|
||||||
@@ -483,8 +489,11 @@ if (hasSingleInstanceLock) {
|
|||||||
webSearchEnabled: webSearchCapability?.enabled
|
webSearchEnabled: webSearchCapability?.enabled
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const createConfiguredRuntime = async (): Promise<AgentRuntime> => {
|
const createConfiguredRuntime = async (
|
||||||
const settings = await settingsStore.getResolvedSettings()
|
resolvedSettings?: ResolvedRuntimeSettings
|
||||||
|
): Promise<AgentRuntime> => {
|
||||||
|
const settings =
|
||||||
|
resolvedSettings ?? await settingsStore.getResolvedSettings()
|
||||||
return createRuntimeWithCapabilities(
|
return createRuntimeWithCapabilities(
|
||||||
settings,
|
settings,
|
||||||
getConfiguredRuntimeTarget(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(
|
removeIpcHandlers = registerIpcHandlers(
|
||||||
mainWindow,
|
mainWindow,
|
||||||
runtime,
|
runtime,
|
||||||
@@ -533,27 +577,7 @@ if (hasSingleInstanceLock) {
|
|||||||
assistantDatabase,
|
assistantDatabase,
|
||||||
approvalBroker,
|
approvalBroker,
|
||||||
bundledRuntimePaths,
|
bundledRuntimePaths,
|
||||||
async () => {
|
reconfigureRuntimes,
|
||||||
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)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
async () => {
|
async () => {
|
||||||
await browserService?.clearSessions()
|
await browserService?.clearSessions()
|
||||||
},
|
},
|
||||||
@@ -604,27 +628,28 @@ app.on('before-quit', (event) => {
|
|||||||
cleanupStarted = true
|
cleanupStarted = true
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const cleanup = Promise.allSettled([
|
const cleanup = settleCleanupPhases([
|
||||||
Promise.resolve().then(() => removeIpcHandlers?.()),
|
[() => removeIpcHandlers?.()],
|
||||||
Promise.resolve().then(() => runtime?.dispose()),
|
[() => stopRuntimeReconfiguration?.()],
|
||||||
Promise.resolve().then(() => selectedRuntimeManager?.dispose()),
|
[
|
||||||
Promise.resolve().then(() => knowledgeGateway?.dispose()),
|
() => runtime?.dispose(),
|
||||||
Promise.resolve().then(() => knowledgeService?.dispose()),
|
() => selectedRuntimeManager?.dispose(),
|
||||||
Promise.resolve().then(() => browserService?.dispose()),
|
() => browserService?.dispose(),
|
||||||
Promise.resolve().then(() => globalTlsPolicy?.dispose()),
|
() => globalTlsPolicy?.dispose(),
|
||||||
Promise.resolve().then(() => documentOcrModelManager?.dispose()),
|
() => documentOcrModelManager?.dispose(),
|
||||||
Promise.resolve().then(() => documentOcrBroker?.dispose())
|
() => documentOcrBroker?.dispose()
|
||||||
|
],
|
||||||
|
[() => knowledgeGateway?.dispose()],
|
||||||
|
[() => knowledgeService?.dispose()]
|
||||||
])
|
])
|
||||||
globalShortcut.unregisterAll()
|
globalShortcut.unregisterAll()
|
||||||
tray?.destroy()
|
tray?.destroy()
|
||||||
await waitForCleanup(cleanup, 8_000)
|
await runCleanupBeforeDeadline(cleanup, 8_000, () => {
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
assistantDatabase?.close()
|
assistantDatabase?.close()
|
||||||
} finally {
|
})
|
||||||
cleanupComplete = true
|
} finally {
|
||||||
app.exit(0)
|
cleanupComplete = true
|
||||||
}
|
app.exit(0)
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
})
|
})
|
||||||
|
|||||||
+697
-6
@@ -10,6 +10,12 @@ import { AssistantDatabase } from './assistant/assistant-database'
|
|||||||
import { registerIpcHandlers } from './ipc'
|
import { registerIpcHandlers } from './ipc'
|
||||||
|
|
||||||
type InvokeHandler = (event: unknown, input?: unknown) => unknown
|
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 electronMocks = vi.hoisted(() => {
|
||||||
const handlers = new Map<string, InvokeHandler>()
|
const handlers = new Map<string, InvokeHandler>()
|
||||||
@@ -274,6 +280,18 @@ describe('registerIpcHandlers computer capabilities', () => {
|
|||||||
).toThrow()
|
).toThrow()
|
||||||
expect(capabilityService.createBrowserProfile).not.toHaveBeenCalled()
|
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(() =>
|
expect(() =>
|
||||||
electronMocks.handlers.get(
|
electronMocks.handlers.get(
|
||||||
ipcChannels.capabilitiesDiagnoseComputer
|
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', () => {
|
describe('registerIpcHandlers knowledge snapshot ontology', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
electronMocks.handlers.clear()
|
electronMocks.handlers.clear()
|
||||||
@@ -1193,7 +1368,11 @@ describe('registerIpcHandlers Runtime config actions', () => {
|
|||||||
await writeFile(configPath, 'name: Test', 'utf8')
|
await writeFile(configPath, 'name: Test', 'utf8')
|
||||||
const getPublicSettings = vi.fn(async () => ({
|
const getPublicSettings = vi.fn(async () => ({
|
||||||
opencodeConfigPath: '',
|
opencodeConfigPath: '',
|
||||||
continueConfigPath: configPath
|
continueConfigPath: process.execPath,
|
||||||
|
configured: {
|
||||||
|
opencodeConfigPath: '',
|
||||||
|
continueConfigPath: configPath
|
||||||
|
}
|
||||||
}))
|
}))
|
||||||
const webContents = {
|
const webContents = {
|
||||||
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
||||||
@@ -1259,7 +1438,11 @@ describe('registerIpcHandlers Runtime config actions', () => {
|
|||||||
|
|
||||||
getPublicSettings.mockResolvedValueOnce({
|
getPublicSettings.mockResolvedValueOnce({
|
||||||
opencodeConfigPath: '',
|
opencodeConfigPath: '',
|
||||||
continueConfigPath: process.execPath
|
continueConfigPath: configPath,
|
||||||
|
configured: {
|
||||||
|
opencodeConfigPath: '',
|
||||||
|
continueConfigPath: process.execPath
|
||||||
|
}
|
||||||
})
|
})
|
||||||
await expect(
|
await expect(
|
||||||
electronMocks.handlers.get(
|
electronMocks.handlers.get(
|
||||||
@@ -1571,7 +1754,7 @@ describe('registerIpcHandlers agent terminal state', () => {
|
|||||||
profileId: '00000000-0000-4000-8000-000000000001'
|
profileId: '00000000-0000-4000-8000-000000000001'
|
||||||
},
|
},
|
||||||
kind: 'channel',
|
kind: 'channel',
|
||||||
channel: 'wecom',
|
channel: 'wecom',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
createdAt: '2026-08-04T00:00:00.000Z',
|
createdAt: '2026-08-04T00:00:00.000Z',
|
||||||
updatedAt: '2026-08-04T00:00:00.000Z'
|
updatedAt: '2026-08-04T00:00:00.000Z'
|
||||||
@@ -1633,11 +1816,21 @@ describe('registerIpcHandlers agent terminal state', () => {
|
|||||||
subagentSmartRoutingEnabled: smartRoutingEnabled
|
subagentSmartRoutingEnabled: smartRoutingEnabled
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
const getPolicySettings = vi.fn(
|
||||||
|
async (): Promise<Record<string, unknown>> => ({
|
||||||
|
toolApproval,
|
||||||
|
subagentSmartRoutingEnabled: smartRoutingEnabled
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const getApplicationSettings = vi.fn(async () => ({
|
||||||
|
magicNotesEnabled
|
||||||
|
}))
|
||||||
const dispose = registerIpcHandlers(
|
const dispose = registerIpcHandlers(
|
||||||
window as never,
|
window as never,
|
||||||
runtime as never,
|
runtime as never,
|
||||||
'CommandOrControl+Shift+Space',
|
'CommandOrControl+Shift+Space',
|
||||||
{
|
{
|
||||||
|
getPolicySettings,
|
||||||
getResolvedSettings
|
getResolvedSettings
|
||||||
} as never,
|
} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
@@ -1654,7 +1847,7 @@ describe('registerIpcHandlers agent terminal state', () => {
|
|||||||
subagentService as never,
|
subagentService as never,
|
||||||
undefined,
|
undefined,
|
||||||
{
|
{
|
||||||
get: vi.fn(async () => ({ magicNotesEnabled }))
|
get: getApplicationSettings
|
||||||
} as never,
|
} as never,
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
@@ -1668,6 +1861,8 @@ describe('registerIpcHandlers agent terminal state', () => {
|
|||||||
assistantDatabase,
|
assistantDatabase,
|
||||||
contextManager,
|
contextManager,
|
||||||
dispose,
|
dispose,
|
||||||
|
getApplicationSettings,
|
||||||
|
getPolicySettings,
|
||||||
getResolvedSettings,
|
getResolvedSettings,
|
||||||
clearHandler: electronMocks.handlers.get(
|
clearHandler: electronMocks.handlers.get(
|
||||||
ipcChannels.appClearLocalData
|
ipcChannels.appClearLocalData
|
||||||
@@ -1707,6 +1902,7 @@ describe('registerIpcHandlers agent terminal state', () => {
|
|||||||
}
|
}
|
||||||
const knowledgeGateway = {
|
const knowledgeGateway = {
|
||||||
grant: vi.fn(() => 'capability'),
|
grant: vi.fn(() => 'capability'),
|
||||||
|
getAvailableToolNames: vi.fn(() => ['knowledge_search']),
|
||||||
drainReferences: vi.fn(() => []),
|
drainReferences: vi.fn(() => []),
|
||||||
revoke: vi.fn()
|
revoke: vi.fn()
|
||||||
}
|
}
|
||||||
@@ -1769,6 +1965,22 @@ describe('registerIpcHandlers agent terminal state', () => {
|
|||||||
}
|
}
|
||||||
const knowledgeGateway = {
|
const knowledgeGateway = {
|
||||||
grant: vi.fn(() => 'capability'),
|
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(() => []),
|
drainReferences: vi.fn(() => []),
|
||||||
revoke: vi.fn()
|
revoke: vi.fn()
|
||||||
}
|
}
|
||||||
@@ -1831,6 +2043,74 @@ describe('registerIpcHandlers agent terminal state', () => {
|
|||||||
await harness.dispose()
|
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 () => {
|
it('accepts an authorized knowledge library after the first 100 entries', async () => {
|
||||||
const libraries = Array.from({ length: 101 }, (_, index) => ({
|
const libraries = Array.from({ length: 101 }, (_, index) => ({
|
||||||
id: `00000000-0000-4000-8000-${index
|
id: `00000000-0000-4000-8000-${index
|
||||||
@@ -1841,6 +2121,7 @@ describe('registerIpcHandlers agent terminal state', () => {
|
|||||||
const listKnowledgeBases = vi.fn(() => libraries)
|
const listKnowledgeBases = vi.fn(() => libraries)
|
||||||
const knowledgeGateway = {
|
const knowledgeGateway = {
|
||||||
grant: vi.fn(() => 'capability'),
|
grant: vi.fn(() => 'capability'),
|
||||||
|
getAvailableToolNames: vi.fn(() => ['knowledge_search']),
|
||||||
drainReferences: vi.fn(() => []),
|
drainReferences: vi.fn(() => []),
|
||||||
revoke: vi.fn()
|
revoke: vi.fn()
|
||||||
}
|
}
|
||||||
@@ -1882,7 +2163,8 @@ describe('registerIpcHandlers agent terminal state', () => {
|
|||||||
expect(knowledgeGateway.grant).toHaveBeenCalledWith(
|
expect(knowledgeGateway.grant).toHaveBeenCalledWith(
|
||||||
requestId,
|
requestId,
|
||||||
[libraries[100]!.id],
|
[libraries[100]!.id],
|
||||||
expect.any(AbortSignal)
|
expect.any(AbortSignal),
|
||||||
|
'none'
|
||||||
)
|
)
|
||||||
await harness.dispose()
|
await harness.dispose()
|
||||||
})
|
})
|
||||||
@@ -1912,6 +2194,7 @@ describe('registerIpcHandlers agent terminal state', () => {
|
|||||||
}
|
}
|
||||||
const knowledgeGateway = {
|
const knowledgeGateway = {
|
||||||
grant: vi.fn(() => 'capability'),
|
grant: vi.fn(() => 'capability'),
|
||||||
|
getAvailableToolNames: vi.fn(() => ['knowledge_search']),
|
||||||
drainReferences: vi.fn(() => [reference]),
|
drainReferences: vi.fn(() => [reference]),
|
||||||
revoke: vi.fn()
|
revoke: vi.fn()
|
||||||
}
|
}
|
||||||
@@ -1948,7 +2231,8 @@ describe('registerIpcHandlers agent terminal state', () => {
|
|||||||
expect(knowledgeGateway.grant).toHaveBeenCalledWith(
|
expect(knowledgeGateway.grant).toHaveBeenCalledWith(
|
||||||
requestId,
|
requestId,
|
||||||
[libraryId],
|
[libraryId],
|
||||||
expect.any(AbortSignal)
|
expect.any(AbortSignal),
|
||||||
|
'none'
|
||||||
)
|
)
|
||||||
const publicEvents = harness.webContents.send.mock.calls
|
const publicEvents = harness.webContents.send.mock.calls
|
||||||
.filter(([channel]) => channel === ipcChannels.agentEvent)
|
.filter(([channel]) => channel === ipcChannels.agentEvent)
|
||||||
@@ -2057,6 +2341,7 @@ describe('registerIpcHandlers agent terminal state', () => {
|
|||||||
])
|
])
|
||||||
const knowledgeGateway = {
|
const knowledgeGateway = {
|
||||||
grant: vi.fn(() => 'capability'),
|
grant: vi.fn(() => 'capability'),
|
||||||
|
getAvailableToolNames: vi.fn(() => ['knowledge_search']),
|
||||||
drainReferences: vi.fn(() => []),
|
drainReferences: vi.fn(() => []),
|
||||||
revoke: vi.fn()
|
revoke: vi.fn()
|
||||||
}
|
}
|
||||||
@@ -2333,6 +2618,67 @@ describe('registerIpcHandlers agent terminal state', () => {
|
|||||||
await harness.dispose()
|
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 () => {
|
it('aborts active work and clears browser sessions before assistant data', async () => {
|
||||||
const lifecycle: string[] = []
|
const lifecycle: string[] = []
|
||||||
let markStarted!: () => void
|
let markStarted!: () => void
|
||||||
@@ -2389,6 +2735,48 @@ describe('registerIpcHandlers agent terminal state', () => {
|
|||||||
await harness.dispose()
|
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 () => {
|
it('marks a request failed when a tool fails before runtime done', async () => {
|
||||||
const runtime = {
|
const runtime = {
|
||||||
capability: 'chat',
|
capability: 'chat',
|
||||||
@@ -2655,6 +3043,8 @@ describe('registerIpcHandlers agent terminal state', () => {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
expect(runtime.run).not.toHaveBeenCalled()
|
expect(runtime.run).not.toHaveBeenCalled()
|
||||||
|
expect(harness.getPolicySettings).toHaveBeenCalledOnce()
|
||||||
|
expect(harness.getResolvedSettings).not.toHaveBeenCalled()
|
||||||
expect(subagentService.run).toHaveBeenCalledWith(
|
expect(subagentService.run).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ expert, routingMode: 'smart' })
|
expect.objectContaining({ expert, routingMode: 'smart' })
|
||||||
)
|
)
|
||||||
@@ -2899,6 +3289,8 @@ describe('registerIpcHandlers agent terminal state', () => {
|
|||||||
})
|
})
|
||||||
).resolves.toBe('deny')
|
).resolves.toBe('deny')
|
||||||
expect(harness.approvalBroker.request).not.toHaveBeenCalled()
|
expect(harness.approvalBroker.request).not.toHaveBeenCalled()
|
||||||
|
expect(harness.getPolicySettings).not.toHaveBeenCalled()
|
||||||
|
expect(harness.getResolvedSettings).not.toHaveBeenCalled()
|
||||||
expect(harness.assistantDatabase.createTask).toHaveBeenCalledWith(
|
expect(harness.assistantDatabase.createTask).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
title: '企业微信远程请求',
|
title: '企业微信远程请求',
|
||||||
@@ -2910,6 +3302,128 @@ describe('registerIpcHandlers agent terminal state', () => {
|
|||||||
await harness.dispose()
|
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 () => {
|
it('persists remote media and passes it through the existing context path', async () => {
|
||||||
let receivedRequest:
|
let receivedRequest:
|
||||||
| {
|
| {
|
||||||
@@ -3191,6 +3705,179 @@ describe('registerIpcHandlers agent terminal state', () => {
|
|||||||
await harness.dispose()
|
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 () => {
|
it('routes remote Execute to a configured Agent Runtime without a GoodBuddy approval callback', async () => {
|
||||||
let receivedAuthorize: unknown = 'not-called'
|
let receivedAuthorize: unknown = 'not-called'
|
||||||
const configuredProfileId =
|
const configuredProfileId =
|
||||||
@@ -3374,6 +4061,8 @@ describe('registerIpcHandlers agent terminal state', () => {
|
|||||||
expect(receivedAuthorize).toEqual(expect.any(Function))
|
expect(receivedAuthorize).toEqual(expect.any(Function))
|
||||||
expect(decision).toBe('once')
|
expect(decision).toBe('once')
|
||||||
expect(harness.approvalBroker.request).not.toHaveBeenCalled()
|
expect(harness.approvalBroker.request).not.toHaveBeenCalled()
|
||||||
|
expect(harness.getPolicySettings).toHaveBeenCalledOnce()
|
||||||
|
expect(harness.getResolvedSettings).not.toHaveBeenCalled()
|
||||||
expect(
|
expect(
|
||||||
harness.assistantDatabase.updateTaskStatus
|
harness.assistantDatabase.updateTaskStatus
|
||||||
).not.toHaveBeenCalledWith(requestId, 'waiting_approval')
|
).not.toHaveBeenCalledWith(requestId, 'waiting_approval')
|
||||||
@@ -3429,6 +4118,8 @@ describe('registerIpcHandlers agent terminal state', () => {
|
|||||||
)
|
)
|
||||||
expect(decision).toBe('deny')
|
expect(decision).toBe('deny')
|
||||||
expect(harness.approvalBroker.request).not.toHaveBeenCalled()
|
expect(harness.approvalBroker.request).not.toHaveBeenCalled()
|
||||||
|
expect(harness.getPolicySettings).toHaveBeenCalledOnce()
|
||||||
|
expect(harness.getResolvedSettings).not.toHaveBeenCalled()
|
||||||
expect(harness.webContents.send).not.toHaveBeenCalledWith(
|
expect(harness.webContents.send).not.toHaveBeenCalledWith(
|
||||||
ipcChannels.agentEvent,
|
ipcChannels.agentEvent,
|
||||||
expect.objectContaining({ type: 'approval' })
|
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'
|
} from 'node:fs/promises'
|
||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { afterEach, describe, expect, it } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import {
|
import {
|
||||||
runtimeSettingsInputSchema,
|
runtimeSettingsInputSchema,
|
||||||
type RuntimeSettingsInput
|
type RuntimeSettingsInput
|
||||||
@@ -456,6 +456,29 @@ describe('RuntimeSettingsStore', () => {
|
|||||||
).toBe(true)
|
).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 () => {
|
it('encrypts an OpenAI-compatible embedding API key and binds it to the full endpoint', async () => {
|
||||||
const { filePath, store } = await createStore()
|
const { filePath, store } = await createStore()
|
||||||
await store.update(
|
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 () => {
|
it('clears rerank credentials and rejects replacement without secure storage', async () => {
|
||||||
const { filePath, store } = await createStore()
|
const { filePath, store } = await createStore()
|
||||||
await store.update(
|
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 () => {
|
it('defaults image quality when migrating version 7 settings', async () => {
|
||||||
const { filePath, store } = await createStore()
|
const { filePath, store } = await createStore()
|
||||||
await store.update(
|
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 () => {
|
it('migrates version 1 settings without losing the encrypted API key', async () => {
|
||||||
const { filePath, store } = await createStore()
|
const { filePath, store } = await createStore()
|
||||||
const encryptedCredential = cipher
|
const encryptedCredential = cipher
|
||||||
@@ -1319,11 +1540,103 @@ describe('RuntimeSettingsStore', () => {
|
|||||||
|
|
||||||
await expect(store.getPublicSettings()).resolves.toMatchObject({
|
await expect(store.getPublicSettings()).resolves.toMatchObject({
|
||||||
provider: 'model',
|
provider: 'model',
|
||||||
warning: expect.stringContaining('已损坏')
|
warnings: [{ code: 'runtime-settings-recovered' }]
|
||||||
})
|
})
|
||||||
const files = await readdir(join(filePath, '..'))
|
const files = await readdir(join(filePath, '..'))
|
||||||
expect(
|
expect(
|
||||||
files.some((name) => name.startsWith('runtime-settings.json.corrupt-'))
|
files.some((name) => name.startsWith('runtime-settings.json.corrupt-'))
|
||||||
).toBe(true)
|
).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 {
|
import {
|
||||||
mkdir,
|
|
||||||
readFile,
|
readFile,
|
||||||
realpath,
|
realpath,
|
||||||
rename,
|
stat
|
||||||
rm,
|
|
||||||
stat,
|
|
||||||
writeFile
|
|
||||||
} from 'node:fs/promises'
|
} from 'node:fs/promises'
|
||||||
import { homedir } from 'node:os'
|
import { homedir } from 'node:os'
|
||||||
import { dirname } from 'node:path'
|
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import {
|
import {
|
||||||
continueModeSchema,
|
continueModeSchema,
|
||||||
@@ -23,17 +18,28 @@ import {
|
|||||||
runtimeProviderSchema,
|
runtimeProviderSchema,
|
||||||
runtimeSandboxModeSchema,
|
runtimeSandboxModeSchema,
|
||||||
toolApprovalPolicySchema,
|
toolApprovalPolicySchema,
|
||||||
RuntimeSettings,
|
type RuntimeSettings,
|
||||||
type RuntimeSettingsInput
|
type RuntimeSettingsInput
|
||||||
} from '../shared/contracts'
|
} 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
|
const credentialSchema = encryptedSettingsCredentialSchema.optional()
|
||||||
.object({
|
|
||||||
formatVersion: z.literal(1),
|
|
||||||
scheme: z.literal('electron-safe-storage'),
|
|
||||||
ciphertextBase64: z.string()
|
|
||||||
})
|
|
||||||
.optional()
|
|
||||||
|
|
||||||
const version4StoredSettingsSchema = z.object({
|
const version4StoredSettingsSchema = z.object({
|
||||||
version: z.literal(4),
|
version: z.literal(4),
|
||||||
@@ -179,8 +185,6 @@ const storedSettingsSchema = version13StoredSettingsSchema
|
|||||||
knowledgeRerankCredential: credentialSchema
|
knowledgeRerankCredential: credentialSchema
|
||||||
})
|
})
|
||||||
|
|
||||||
class UnsupportedRuntimeSettingsVersionError extends Error {}
|
|
||||||
|
|
||||||
type StoredSettings = z.infer<typeof storedSettingsSchema>
|
type StoredSettings = z.infer<typeof storedSettingsSchema>
|
||||||
type Version10StoredSettings = z.infer<
|
type Version10StoredSettings = z.infer<
|
||||||
typeof version10StoredSettingsSchema
|
typeof version10StoredSettingsSchema
|
||||||
@@ -239,11 +243,7 @@ const embeddingCredentialPayloadSchema = z.object({
|
|||||||
|
|
||||||
const rerankCredentialPayloadSchema = embeddingCredentialPayloadSchema
|
const rerankCredentialPayloadSchema = embeddingCredentialPayloadSchema
|
||||||
|
|
||||||
export type CredentialCipher = {
|
export type CredentialCipher = SettingsCredentialCipher
|
||||||
isAvailable: () => boolean
|
|
||||||
encrypt: (value: string) => Buffer
|
|
||||||
decrypt: (value: Buffer) => string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ResolvedRuntimeSettings = {
|
export type ResolvedRuntimeSettings = {
|
||||||
provider: RuntimeSettings['provider']
|
provider: RuntimeSettings['provider']
|
||||||
@@ -279,6 +279,11 @@ export type ResolvedRuntimeSettings = {
|
|||||||
toolApproval: RuntimeSettings['toolApproval']
|
toolApproval: RuntimeSettings['toolApproval']
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RuntimePolicySettings = Pick<
|
||||||
|
ResolvedRuntimeSettings,
|
||||||
|
'subagentSmartRoutingEnabled' | 'toolApproval'
|
||||||
|
>
|
||||||
|
|
||||||
export type ResolvedModelProfile = {
|
export type ResolvedModelProfile = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@@ -347,6 +352,15 @@ function migrateContinueCommand(command: string): string {
|
|||||||
return value === 'cn' ? '' : value
|
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(
|
function compatibleTextProfileId(
|
||||||
settings: Pick<
|
settings: Pick<
|
||||||
Version10StoredSettings,
|
Version10StoredSettings,
|
||||||
@@ -445,14 +459,21 @@ function migrateVersion10(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeStoredSettings(settings: StoredSettings): StoredSettings {
|
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 = (
|
const normalizeSource = (
|
||||||
source: RuntimeSettings['opencodeModelSource']
|
source: RuntimeSettings['opencodeModelSource']
|
||||||
): RuntimeSettings['opencodeModelSource'] => {
|
): RuntimeSettings['opencodeModelSource'] => {
|
||||||
if (source.kind === 'platform') {
|
if (source.kind === 'platform') {
|
||||||
return source
|
return source
|
||||||
}
|
}
|
||||||
const profile = settings.modelProfiles.find(
|
const profile = modelProfiles.find(
|
||||||
(candidate) => candidate.id === source.profileId
|
(candidate) => candidate.id === source.profileId
|
||||||
)
|
)
|
||||||
if (profile && isAgentRuntimeModelProtocol(profile.protocol)) {
|
if (profile && isAgentRuntimeModelProtocol(profile.protocol)) {
|
||||||
@@ -463,14 +484,21 @@ function normalizeStoredSettings(settings: StoredSettings): StoredSettings {
|
|||||||
: { kind: 'platform' }
|
: { kind: 'platform' }
|
||||||
}
|
}
|
||||||
const opencodeBaseUrl = settings.opencodeBaseUrl.trim()
|
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
|
(profile) => profile.id === settings.defaultModelProfileId
|
||||||
)
|
)
|
||||||
? settings.defaultModelProfileId
|
? settings.defaultModelProfileId
|
||||||
: settings.modelProfiles[0]!.id
|
: modelProfiles[0]!.id
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...settings,
|
...settings,
|
||||||
|
modelProfiles,
|
||||||
provider:
|
provider:
|
||||||
settings.provider === 'auto' ? 'model' : settings.provider,
|
settings.provider === 'auto' ? 'model' : settings.provider,
|
||||||
defaultModelProfileId,
|
defaultModelProfileId,
|
||||||
@@ -558,15 +586,27 @@ function migrateVersion5(
|
|||||||
function migrateVersion6(
|
function migrateVersion6(
|
||||||
settings: z.infer<typeof version6StoredSettingsSchema>
|
settings: z.infer<typeof version6StoredSettingsSchema>
|
||||||
): StoredSettings {
|
): StoredSettings {
|
||||||
const endpoint = new URL(settings.knowledgeEmbeddingBaseUrl)
|
let knowledgeEmbeddingBaseUrl: string =
|
||||||
endpoint.pathname = `${endpoint.pathname.replace(/\/+$/u, '')}/v1/embeddings`
|
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({
|
return migrateVersion10({
|
||||||
...settings,
|
...settings,
|
||||||
version: 10,
|
version: 10,
|
||||||
subagentSmartRoutingEnabled:
|
subagentSmartRoutingEnabled:
|
||||||
defaultRuntimeSettings.subagentSmartRoutingEnabled,
|
defaultRuntimeSettings.subagentSmartRoutingEnabled,
|
||||||
intranetCompatibilityEnabled: true,
|
intranetCompatibilityEnabled: true,
|
||||||
knowledgeEmbeddingBaseUrl: endpoint.toString(),
|
knowledgeEmbeddingBaseUrl,
|
||||||
modelProfiles: settings.modelProfiles.map((profile) => ({
|
modelProfiles: settings.modelProfiles.map((profile) => ({
|
||||||
...profile,
|
...profile,
|
||||||
imageGenerationQuality:
|
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 {
|
export class RuntimeSettingsStore {
|
||||||
private settings?: StoredSettings
|
private settings?: StoredSettings
|
||||||
private loadWarning?: string
|
private settingsLoad?: Promise<StoredSettings>
|
||||||
|
private loadWarnings: SettingsWarning[] = []
|
||||||
private updateQueue: Promise<void> = Promise.resolve()
|
private updateQueue: Promise<void> = Promise.resolve()
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -630,25 +665,28 @@ export class RuntimeSettingsStore {
|
|||||||
private readonly environment: NodeJS.ProcessEnv = process.env
|
private readonly environment: NodeJS.ProcessEnv = process.env
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async load(): Promise<StoredSettings> {
|
private load(): Promise<StoredSettings> {
|
||||||
if (this.settings) {
|
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 {
|
try {
|
||||||
const contents = await readFile(this.filePath, 'utf8')
|
const contents = await readFile(this.filePath, 'utf8')
|
||||||
const parsed: unknown = JSON.parse(contents)
|
const parsed: unknown = JSON.parse(contents)
|
||||||
if (
|
assertSupportedSettingsVersion(
|
||||||
parsed &&
|
parsed,
|
||||||
typeof parsed === 'object' &&
|
14,
|
||||||
'version' in parsed &&
|
(version) =>
|
||||||
typeof parsed.version === 'number' &&
|
`当前 GoodBuddy 不支持 Runtime 设置版本 ${version},请升级应用后重试`
|
||||||
parsed.version > 14
|
)
|
||||||
) {
|
|
||||||
throw new UnsupportedRuntimeSettingsVersionError(
|
|
||||||
`当前 GoodBuddy 不支持 Runtime 设置版本 ${parsed.version},请升级应用后重试`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const current = storedSettingsSchema.safeParse(parsed)
|
const current = storedSettingsSchema.safeParse(parsed)
|
||||||
if (current.success) {
|
if (current.success) {
|
||||||
this.settings = current.data
|
this.settings = current.data
|
||||||
@@ -772,23 +810,15 @@ export class RuntimeSettingsStore {
|
|||||||
}
|
}
|
||||||
this.settings = normalizeStoredSettings(this.settings)
|
this.settings = normalizeStoredSettings(this.settings)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof UnsupportedRuntimeSettingsVersionError) {
|
if (error instanceof UnsupportedSettingsVersionError) {
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
if (
|
if (!isMissingFileError(error)) {
|
||||||
!(
|
await isolateCorruptSettingsFile(
|
||||||
error &&
|
|
||||||
typeof error === 'object' &&
|
|
||||||
'code' in error &&
|
|
||||||
error.code === 'ENOENT'
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
this.loadWarning =
|
|
||||||
'Runtime 设置文件已损坏,已隔离原文件并恢复默认设置'
|
|
||||||
await rename(
|
|
||||||
this.filePath,
|
this.filePath,
|
||||||
`${this.filePath}.corrupt-${Date.now()}`
|
'Runtime 设置已损坏且无法隔离'
|
||||||
).catch(() => undefined)
|
)
|
||||||
|
this.loadWarnings = [{ code: 'runtime-settings-recovered' }]
|
||||||
}
|
}
|
||||||
this.settings = { ...defaultSettings }
|
this.settings = { ...defaultSettings }
|
||||||
}
|
}
|
||||||
@@ -798,52 +828,70 @@ export class RuntimeSettingsStore {
|
|||||||
private getStoredApiKey(
|
private getStoredApiKey(
|
||||||
profile: StoredSettings['modelProfiles'][number]
|
profile: StoredSettings['modelProfiles'][number]
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
if (!profile.credential || !this.cipher.isAvailable()) {
|
if (!profile.credential) {
|
||||||
return undefined
|
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 {
|
try {
|
||||||
const payload = credentialPayloadSchema.parse(
|
const payload = credentialPayloadSchema.parse(
|
||||||
JSON.parse(
|
decryptSettingsCredential(this.cipher, profile.credential)
|
||||||
this.cipher.decrypt(
|
|
||||||
Buffer.from(profile.credential.ciphertextBase64, 'base64')
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
if (payload.origin !== new URL(profile.baseUrl).origin) {
|
if (payload.origin !== new URL(profile.baseUrl).origin) {
|
||||||
this.loadWarning =
|
return warning('runtime-model-credential-binding-mismatch')
|
||||||
`模型连接“${profile.name}”的服务地址与已保存 API Key 不匹配,请重新输入或清除 API Key`
|
|
||||||
return undefined
|
|
||||||
}
|
}
|
||||||
|
this.removeWarnings(
|
||||||
|
[
|
||||||
|
'runtime-model-credential-unreadable',
|
||||||
|
'runtime-model-credential-binding-mismatch'
|
||||||
|
],
|
||||||
|
profile.name
|
||||||
|
)
|
||||||
return payload.apiKey
|
return payload.apiKey
|
||||||
} catch {
|
} catch {
|
||||||
return undefined
|
return warning('runtime-model-credential-unreadable')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private getStoredEmbeddingApiKey(
|
private getStoredEmbeddingApiKey(
|
||||||
settings: StoredSettings
|
settings: StoredSettings
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
if (
|
if (!settings.knowledgeEmbeddingCredential) {
|
||||||
!settings.knowledgeEmbeddingCredential ||
|
return undefined
|
||||||
!this.cipher.isAvailable()
|
}
|
||||||
) {
|
if (!this.cipher.isAvailable()) {
|
||||||
|
this.addWarning({
|
||||||
|
code: 'runtime-embedding-credential-unreadable'
|
||||||
|
})
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const payload = embeddingCredentialPayloadSchema.parse(
|
const payload = embeddingCredentialPayloadSchema.parse(
|
||||||
JSON.parse(
|
decryptSettingsCredential(
|
||||||
this.cipher.decrypt(
|
this.cipher,
|
||||||
Buffer.from(
|
settings.knowledgeEmbeddingCredential
|
||||||
settings.knowledgeEmbeddingCredential.ciphertextBase64,
|
|
||||||
'base64'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return payload.endpoint === settings.knowledgeEmbeddingBaseUrl
|
if (payload.endpoint !== settings.knowledgeEmbeddingBaseUrl) {
|
||||||
? payload.apiKey
|
this.addWarning({
|
||||||
: undefined
|
code: 'runtime-embedding-credential-binding-mismatch'
|
||||||
|
})
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
this.removeWarnings([
|
||||||
|
'runtime-embedding-credential-unreadable',
|
||||||
|
'runtime-embedding-credential-binding-mismatch'
|
||||||
|
])
|
||||||
|
return payload.apiKey
|
||||||
} catch {
|
} catch {
|
||||||
|
this.addWarning({
|
||||||
|
code: 'runtime-embedding-credential-unreadable'
|
||||||
|
})
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -851,28 +899,64 @@ export class RuntimeSettingsStore {
|
|||||||
private getStoredRerankApiKey(
|
private getStoredRerankApiKey(
|
||||||
settings: StoredSettings
|
settings: StoredSettings
|
||||||
): string | undefined {
|
): 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
|
return undefined
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const payload = rerankCredentialPayloadSchema.parse(
|
const payload = rerankCredentialPayloadSchema.parse(
|
||||||
JSON.parse(
|
decryptSettingsCredential(
|
||||||
this.cipher.decrypt(
|
this.cipher,
|
||||||
Buffer.from(
|
settings.knowledgeRerankCredential
|
||||||
settings.knowledgeRerankCredential.ciphertextBase64,
|
|
||||||
'base64'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return payload.endpoint === settings.knowledgeRerankEndpoint
|
if (payload.endpoint !== settings.knowledgeRerankEndpoint) {
|
||||||
? payload.apiKey
|
this.addWarning({
|
||||||
: undefined
|
code: 'runtime-rerank-credential-binding-mismatch'
|
||||||
|
})
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
this.removeWarnings([
|
||||||
|
'runtime-rerank-credential-unreadable',
|
||||||
|
'runtime-rerank-credential-binding-mismatch'
|
||||||
|
])
|
||||||
|
return payload.apiKey
|
||||||
} catch {
|
} catch {
|
||||||
|
this.addWarning({
|
||||||
|
code: 'runtime-rerank-credential-unreadable'
|
||||||
|
})
|
||||||
return undefined
|
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 {
|
private getEnvironmentApiKey(): string | undefined {
|
||||||
return (
|
return (
|
||||||
this.environment.GOODBUDDY_MODEL_API_KEY?.trim() ||
|
this.environment.GOODBUDDY_MODEL_API_KEY?.trim() ||
|
||||||
@@ -903,7 +987,7 @@ export class RuntimeSettingsStore {
|
|||||||
? this.getEnvironmentApiKey()
|
? this.getEnvironmentApiKey()
|
||||||
: undefined
|
: undefined
|
||||||
const storedApiKey =
|
const storedApiKey =
|
||||||
profile.authentication === 'api-key'
|
profile.authentication === 'api-key' && !environmentApiKey
|
||||||
? this.getStoredApiKey(profile)
|
? this.getStoredApiKey(profile)
|
||||||
: undefined
|
: undefined
|
||||||
const environmentBaseUrl =
|
const environmentBaseUrl =
|
||||||
@@ -918,6 +1002,14 @@ export class RuntimeSettingsStore {
|
|||||||
const model = environmentApiKey
|
const model = environmentApiKey
|
||||||
? environmentModel || defaultRuntimeSettings.modelName
|
? environmentModel || defaultRuntimeSettings.modelName
|
||||||
: profile.modelName
|
: profile.modelName
|
||||||
|
const credentialSource: RuntimeSettings['credentialSource'] =
|
||||||
|
environmentApiKey
|
||||||
|
? 'environment'
|
||||||
|
: storedApiKey
|
||||||
|
? 'encrypted'
|
||||||
|
: profile.credential
|
||||||
|
? 'unreadable'
|
||||||
|
: 'none'
|
||||||
return {
|
return {
|
||||||
apiKey: environmentApiKey ?? storedApiKey,
|
apiKey: environmentApiKey ?? storedApiKey,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
@@ -926,52 +1018,45 @@ export class RuntimeSettingsStore {
|
|||||||
authentication: profile.authentication,
|
authentication: profile.authentication,
|
||||||
supportsImageInput: profile.supportsImageInput,
|
supportsImageInput: profile.supportsImageInput,
|
||||||
imageGenerationQuality: profile.imageGenerationQuality,
|
imageGenerationQuality: profile.imageGenerationQuality,
|
||||||
credentialSource: environmentApiKey
|
credentialSource
|
||||||
? 'environment'
|
|
||||||
: storedApiKey
|
|
||||||
? 'encrypted'
|
|
||||||
: 'none'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveProfile(
|
private resolveModelProfiles(
|
||||||
settings: StoredSettings,
|
settings: StoredSettings,
|
||||||
profileId: string
|
effective: ReturnType<
|
||||||
): ResolvedModelProfile | undefined {
|
RuntimeSettingsStore['resolveEffectiveModelSettings']
|
||||||
const profile = settings.modelProfiles.find(
|
>
|
||||||
(candidate) => candidate.id === profileId
|
): 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): {
|
private resolveAgentSettings(settings: StoredSettings): {
|
||||||
@@ -1022,48 +1107,81 @@ export class RuntimeSettingsStore {
|
|||||||
private toPublicSettings(settings: StoredSettings): RuntimeSettings {
|
private toPublicSettings(settings: StoredSettings): RuntimeSettings {
|
||||||
const effective = this.resolveEffectiveModelSettings(settings)
|
const effective = this.resolveEffectiveModelSettings(settings)
|
||||||
const agent = this.resolveAgentSettings(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 modelProfiles = settings.modelProfiles.map((profile) => {
|
||||||
const isDefault = profile.id === settings.defaultModelProfileId
|
const isDefault = profile.id === settings.defaultModelProfileId
|
||||||
const apiKey =
|
const resolved = resolvedProfilesById.get(profile.id)
|
||||||
profile.authentication === 'api-key'
|
if (!resolved) {
|
||||||
? this.getStoredApiKey(profile)
|
throw new Error(`模型连接不存在:${profile.id}`)
|
||||||
: undefined
|
}
|
||||||
|
const apiKey = resolved.apiKey
|
||||||
return {
|
return {
|
||||||
id: profile.id,
|
id: profile.id,
|
||||||
name: profile.name,
|
name: profile.name,
|
||||||
baseUrl: isDefault
|
baseUrl: resolved.baseUrl,
|
||||||
? effective.baseUrl
|
modelName: resolved.modelName,
|
||||||
: profile.baseUrl,
|
protocol: resolved.protocol,
|
||||||
modelName: isDefault ? effective.model : profile.modelName,
|
authentication: resolved.authentication,
|
||||||
protocol: isDefault
|
supportsImageInput: resolved.supportsImageInput,
|
||||||
? effective.protocol
|
imageGenerationQuality:
|
||||||
: profile.protocol,
|
resolved.imageGenerationQuality ??
|
||||||
authentication: isDefault
|
defaultRuntimeSettings.imageGenerationQuality,
|
||||||
? effective.authentication
|
apiKeyConfigured: Boolean(apiKey),
|
||||||
: profile.authentication,
|
|
||||||
supportsImageInput: isDefault
|
|
||||||
? effective.supportsImageInput
|
|
||||||
: profile.supportsImageInput,
|
|
||||||
imageGenerationQuality: isDefault
|
|
||||||
? effective.imageGenerationQuality
|
|
||||||
: profile.imageGenerationQuality,
|
|
||||||
apiKeyConfigured: isDefault
|
|
||||||
? Boolean(effective.apiKey)
|
|
||||||
: Boolean(apiKey),
|
|
||||||
credentialSource: isDefault
|
credentialSource: isDefault
|
||||||
? effective.credentialSource
|
? effective.credentialSource
|
||||||
: apiKey
|
: apiKey
|
||||||
? ('encrypted' as const)
|
? ('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 =
|
const embeddingEnvironmentApiKey =
|
||||||
this.environment.GOODBUDDY_EMBEDDING_API_KEY?.trim()
|
this.environment.GOODBUDDY_EMBEDDING_API_KEY?.trim()
|
||||||
const embeddingStoredApiKey =
|
const embeddingStoredApiKey = embeddingEnvironmentApiKey
|
||||||
this.getStoredEmbeddingApiKey(settings)
|
? undefined
|
||||||
|
: this.getStoredEmbeddingApiKey(settings)
|
||||||
const rerankEnvironmentApiKey =
|
const rerankEnvironmentApiKey =
|
||||||
this.environment.GOODBUDDY_RERANK_API_KEY?.trim()
|
this.environment.GOODBUDDY_RERANK_API_KEY?.trim()
|
||||||
const rerankStoredApiKey = this.getStoredRerankApiKey(settings)
|
const rerankStoredApiKey = rerankEnvironmentApiKey
|
||||||
|
? undefined
|
||||||
|
: this.getStoredRerankApiKey(settings)
|
||||||
return {
|
return {
|
||||||
provider: settings.provider,
|
provider: settings.provider,
|
||||||
modelBaseUrl: effective.baseUrl,
|
modelBaseUrl: effective.baseUrl,
|
||||||
@@ -1092,7 +1210,9 @@ export class RuntimeSettingsStore {
|
|||||||
? 'environment'
|
? 'environment'
|
||||||
: embeddingStoredApiKey
|
: embeddingStoredApiKey
|
||||||
? 'encrypted'
|
? 'encrypted'
|
||||||
: 'none',
|
: settings.knowledgeEmbeddingCredential
|
||||||
|
? 'unreadable'
|
||||||
|
: 'none',
|
||||||
knowledgeRerankEnabled: settings.knowledgeRerankEnabled,
|
knowledgeRerankEnabled: settings.knowledgeRerankEnabled,
|
||||||
knowledgeRerankEndpoint: settings.knowledgeRerankEndpoint,
|
knowledgeRerankEndpoint: settings.knowledgeRerankEndpoint,
|
||||||
knowledgeRerankModel: settings.knowledgeRerankModel,
|
knowledgeRerankModel: settings.knowledgeRerankModel,
|
||||||
@@ -1103,7 +1223,9 @@ export class RuntimeSettingsStore {
|
|||||||
? 'environment'
|
? 'environment'
|
||||||
: rerankStoredApiKey
|
: rerankStoredApiKey
|
||||||
? 'encrypted'
|
? 'encrypted'
|
||||||
: 'none',
|
: settings.knowledgeRerankCredential
|
||||||
|
? 'unreadable'
|
||||||
|
: 'none',
|
||||||
workspacePath: agent.workspacePath,
|
workspacePath: agent.workspacePath,
|
||||||
apiKeyConfigured: Boolean(effective.apiKey),
|
apiKeyConfigured: Boolean(effective.apiKey),
|
||||||
credentialSource: effective.credentialSource,
|
credentialSource: effective.credentialSource,
|
||||||
@@ -1115,7 +1237,20 @@ export class RuntimeSettingsStore {
|
|||||||
continueModelSource: settings.continueModelSource,
|
continueModelSource: settings.continueModelSource,
|
||||||
secureStorageAvailable: this.cipher.isAvailable(),
|
secureStorageAvailable: this.cipher.isAvailable(),
|
||||||
toolApproval: settings.toolApproval,
|
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())
|
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> {
|
async getResolvedSettings(): Promise<ResolvedRuntimeSettings> {
|
||||||
const settings = await this.load()
|
const settings = await this.load()
|
||||||
const effective = this.resolveEffectiveModelSettings(settings)
|
const effective = this.resolveEffectiveModelSettings(settings)
|
||||||
const agent = this.resolveAgentSettings(settings)
|
const agent = this.resolveAgentSettings(settings)
|
||||||
|
const modelProfiles = this.resolveModelProfiles(settings, effective)
|
||||||
|
const profilesById = new Map(
|
||||||
|
modelProfiles.map((profile) => [profile.id, profile])
|
||||||
|
)
|
||||||
const opencodeModelProfile =
|
const opencodeModelProfile =
|
||||||
!agent.opencodeBaseUrl &&
|
!agent.opencodeBaseUrl &&
|
||||||
settings.opencodeModelSource.kind === 'profile'
|
settings.opencodeModelSource.kind === 'profile'
|
||||||
? this.resolveProfile(
|
? profilesById.get(settings.opencodeModelSource.profileId)
|
||||||
settings,
|
|
||||||
settings.opencodeModelSource.profileId
|
|
||||||
)
|
|
||||||
: undefined
|
: undefined
|
||||||
const continueModelProfile =
|
const continueModelProfile =
|
||||||
settings.continueModelSource.kind === 'profile'
|
settings.continueModelSource.kind === 'profile'
|
||||||
? this.resolveProfile(
|
? profilesById.get(settings.continueModelSource.profileId)
|
||||||
settings,
|
|
||||||
settings.continueModelSource.profileId
|
|
||||||
)
|
|
||||||
: undefined
|
: undefined
|
||||||
return {
|
return {
|
||||||
provider: settings.provider,
|
provider: settings.provider,
|
||||||
@@ -1151,13 +1293,7 @@ export class RuntimeSettingsStore {
|
|||||||
supportsImageInput: effective.supportsImageInput,
|
supportsImageInput: effective.supportsImageInput,
|
||||||
imageGenerationQuality: effective.imageGenerationQuality,
|
imageGenerationQuality: effective.imageGenerationQuality,
|
||||||
apiKey: effective.apiKey,
|
apiKey: effective.apiKey,
|
||||||
modelProfiles: settings.modelProfiles.map((profile) => {
|
modelProfiles,
|
||||||
const resolved = this.resolveProfile(settings, profile.id)
|
|
||||||
if (!resolved) {
|
|
||||||
throw new Error(`模型连接不存在:${profile.id}`)
|
|
||||||
}
|
|
||||||
return resolved
|
|
||||||
}),
|
|
||||||
defaultModelProfileId: settings.defaultModelProfileId,
|
defaultModelProfileId: settings.defaultModelProfileId,
|
||||||
opencodeModelProfile,
|
opencodeModelProfile,
|
||||||
continueModelProfile,
|
continueModelProfile,
|
||||||
@@ -1248,7 +1384,15 @@ export class RuntimeSettingsStore {
|
|||||||
const existing = current.modelProfiles.find(
|
const existing = current.modelProfiles.find(
|
||||||
(candidate) => candidate.id === profile.id
|
(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 (
|
if (
|
||||||
profile.authentication === 'api-key' &&
|
profile.authentication === 'api-key' &&
|
||||||
profile.apiKey.action === 'keep' &&
|
profile.apiKey.action === 'keep' &&
|
||||||
@@ -1264,7 +1408,9 @@ export class RuntimeSettingsStore {
|
|||||||
id: profile.id,
|
id: profile.id,
|
||||||
name: profile.name,
|
name: profile.name,
|
||||||
baseUrl: normalizedBaseUrl,
|
baseUrl: normalizedBaseUrl,
|
||||||
modelName: profile.modelName,
|
modelName: environmentManaged
|
||||||
|
? existing.modelName
|
||||||
|
: profile.modelName,
|
||||||
protocol: profile.protocol,
|
protocol: profile.protocol,
|
||||||
authentication: profile.authentication,
|
authentication: profile.authentication,
|
||||||
supportsImageInput: profile.supportsImageInput ?? false,
|
supportsImageInput: profile.supportsImageInput ?? false,
|
||||||
@@ -1280,19 +1426,14 @@ export class RuntimeSettingsStore {
|
|||||||
profile.authentication === 'api-key' &&
|
profile.authentication === 'api-key' &&
|
||||||
profile.apiKey.action === 'replace'
|
profile.apiKey.action === 'replace'
|
||||||
) {
|
) {
|
||||||
nextProfile.credential = {
|
nextProfile.credential = encryptSettingsCredential(
|
||||||
formatVersion: 1,
|
this.cipher,
|
||||||
scheme: 'electron-safe-storage',
|
{
|
||||||
ciphertextBase64: this.cipher
|
version: 1,
|
||||||
.encrypt(
|
apiKey: profile.apiKey.value,
|
||||||
JSON.stringify({
|
origin: new URL(normalizedBaseUrl).origin
|
||||||
version: 1,
|
}
|
||||||
apiKey: profile.apiKey.value,
|
)
|
||||||
origin: new URL(normalizedBaseUrl).origin
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.toString('base64')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nextProfile
|
return nextProfile
|
||||||
})
|
})
|
||||||
@@ -1319,19 +1460,14 @@ export class RuntimeSettingsStore {
|
|||||||
knowledgeEmbeddingCredential =
|
knowledgeEmbeddingCredential =
|
||||||
current.knowledgeEmbeddingCredential
|
current.knowledgeEmbeddingCredential
|
||||||
} else if (embeddingApiKeyUpdate.action === 'replace') {
|
} else if (embeddingApiKeyUpdate.action === 'replace') {
|
||||||
knowledgeEmbeddingCredential = {
|
knowledgeEmbeddingCredential = encryptSettingsCredential(
|
||||||
formatVersion: 1,
|
this.cipher,
|
||||||
scheme: 'electron-safe-storage',
|
{
|
||||||
ciphertextBase64: this.cipher
|
version: 1,
|
||||||
.encrypt(
|
apiKey: embeddingApiKeyUpdate.value,
|
||||||
JSON.stringify({
|
endpoint: embeddingEndpoint
|
||||||
version: 1,
|
}
|
||||||
apiKey: embeddingApiKeyUpdate.value,
|
)
|
||||||
endpoint: embeddingEndpoint
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.toString('base64')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const rerankEndpoint = new URL(
|
const rerankEndpoint = new URL(
|
||||||
@@ -1355,19 +1491,14 @@ export class RuntimeSettingsStore {
|
|||||||
) {
|
) {
|
||||||
knowledgeRerankCredential = current.knowledgeRerankCredential
|
knowledgeRerankCredential = current.knowledgeRerankCredential
|
||||||
} else if (rerankApiKeyUpdate.action === 'replace') {
|
} else if (rerankApiKeyUpdate.action === 'replace') {
|
||||||
knowledgeRerankCredential = {
|
knowledgeRerankCredential = encryptSettingsCredential(
|
||||||
formatVersion: 1,
|
this.cipher,
|
||||||
scheme: 'electron-safe-storage',
|
{
|
||||||
ciphertextBase64: this.cipher
|
version: 1,
|
||||||
.encrypt(
|
apiKey: rerankApiKeyUpdate.value,
|
||||||
JSON.stringify({
|
endpoint: rerankEndpoint
|
||||||
version: 1,
|
}
|
||||||
apiKey: rerankApiKeyUpdate.value,
|
)
|
||||||
endpoint: rerankEndpoint
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.toString('base64')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const [
|
const [
|
||||||
@@ -1490,19 +1621,9 @@ export class RuntimeSettingsStore {
|
|||||||
toolApproval: input.toolApproval
|
toolApproval: input.toolApproval
|
||||||
}
|
}
|
||||||
|
|
||||||
await mkdir(dirname(this.filePath), { recursive: true })
|
await writeJsonFileAtomically(this.filePath, next)
|
||||||
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 })
|
|
||||||
}
|
|
||||||
this.settings = next
|
this.settings = next
|
||||||
this.loadWarning = undefined
|
this.loadWarnings = []
|
||||||
return this.toPublicSettings(next)
|
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 { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { waitForCleanup } from './shutdown'
|
import {
|
||||||
|
runCleanupBeforeDeadline,
|
||||||
|
settleCleanupPhases,
|
||||||
|
waitForCleanup
|
||||||
|
} from './shutdown'
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers()
|
vi.useRealTimers()
|
||||||
@@ -23,4 +27,49 @@ describe('waitForCleanup', () => {
|
|||||||
|
|
||||||
await expect(result).resolves.toBe(false)
|
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
|
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(
|
expect(
|
||||||
screen.getByRole('button', { name: 'Chat' })
|
screen.getByRole('button', { name: 'Chat' })
|
||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Desktop workspace')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('GOODBUDDY WORKSPACE')).toBeInTheDocument()
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole('heading', {
|
screen.getByRole('heading', {
|
||||||
name: 'What would you like to accomplish today?'
|
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 () => {
|
it('keeps Settings open when the interface language changes', async () => {
|
||||||
api.updates = {
|
api.updates = {
|
||||||
getSettings: vi.fn(async () => ({
|
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([
|
vi.mocked(api.conversations.list).mockResolvedValueOnce([
|
||||||
{
|
{
|
||||||
id: '00000000-0000-4000-8000-000000000020',
|
id: '00000000-0000-4000-8000-000000000020',
|
||||||
@@ -3306,17 +3315,14 @@ describe('App', () => {
|
|||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: '00000000-0000-4000-8000-000000000020',
|
id: '00000000-0000-4000-8000-000000000020',
|
||||||
runtimeSelection: {
|
runtimeSelection: { provider: 'auto' }
|
||||||
provider: 'model',
|
|
||||||
profileId: modelProfileId
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
])
|
])
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
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 =
|
const removedProfileId =
|
||||||
'00000000-0000-4000-8000-000000000099'
|
'00000000-0000-4000-8000-000000000099'
|
||||||
vi.mocked(api.conversations.list).mockResolvedValueOnce([
|
vi.mocked(api.conversations.list).mockResolvedValueOnce([
|
||||||
@@ -3341,9 +3347,6 @@ describe('App', () => {
|
|||||||
])
|
])
|
||||||
render(<App />)
|
render(<App />)
|
||||||
|
|
||||||
expect(
|
|
||||||
await screen.findByRole('button', { name: /默认模型.*sonnet-5/u })
|
|
||||||
).toBeInTheDocument()
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(api.conversations.replace).toHaveBeenLastCalledWith(
|
expect(api.conversations.replace).toHaveBeenLastCalledWith(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
@@ -3351,7 +3354,7 @@ describe('App', () => {
|
|||||||
id: '00000000-0000-4000-8000-000000000022',
|
id: '00000000-0000-4000-8000-000000000022',
|
||||||
runtimeSelection: {
|
runtimeSelection: {
|
||||||
provider: 'model',
|
provider: 'model',
|
||||||
profileId: modelProfileId
|
profileId: removedProfileId
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
])
|
])
|
||||||
|
|||||||
+31
-106
@@ -65,9 +65,12 @@ import { maximumPastedImageBytes } from '../../shared/contracts'
|
|||||||
import {
|
import {
|
||||||
agentRuntimeSelectionKey,
|
agentRuntimeSelectionKey,
|
||||||
agentRuntimeSelectionSchema,
|
agentRuntimeSelectionSchema,
|
||||||
repairAgentRuntimeSelection,
|
|
||||||
type AgentRuntimeSelection
|
type AgentRuntimeSelection
|
||||||
} from '../../shared/runtime-selection-contracts'
|
} from '../../shared/runtime-selection-contracts'
|
||||||
|
import {
|
||||||
|
getDefaultRuntimeSelection,
|
||||||
|
getRuntimeSelectionForProvider
|
||||||
|
} from './runtime-selection'
|
||||||
import type {
|
import type {
|
||||||
AssistantProject,
|
AssistantProject,
|
||||||
AssistantArtifact,
|
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(
|
function getProjectDefaultRuntimeSelection(
|
||||||
project: AssistantProject | undefined,
|
project: AssistantProject | undefined,
|
||||||
settings: RuntimeSettings
|
settings: RuntimeSettings
|
||||||
@@ -848,7 +812,7 @@ function getProjectDefaultRuntimeSelection(
|
|||||||
const selection = project?.runtimeSelection
|
const selection = project?.runtimeSelection
|
||||||
return !selection || selection.provider === 'auto'
|
return !selection || selection.provider === 'auto'
|
||||||
? getDefaultRuntimeSelection(settings)
|
? getDefaultRuntimeSelection(settings)
|
||||||
: repairAgentRuntimeSelection(selection, settings)
|
: selection
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRuntimeSelectionLabel(
|
function getRuntimeSelectionLabel(
|
||||||
@@ -859,6 +823,7 @@ function getRuntimeSelectionLabel(
|
|||||||
directModel: string
|
directModel: string
|
||||||
automatic: string
|
automatic: string
|
||||||
automaticSelection: string
|
automaticSelection: string
|
||||||
|
modelUnavailable: string
|
||||||
}
|
}
|
||||||
): string {
|
): string {
|
||||||
if (!selection || !settings) {
|
if (!selection || !settings) {
|
||||||
@@ -870,36 +835,36 @@ function getRuntimeSelectionLabel(
|
|||||||
(candidate) => candidate.id === selection.profileId
|
(candidate) => candidate.id === selection.profileId
|
||||||
)
|
)
|
||||||
: undefined
|
: undefined
|
||||||
|
const requestedProfileMissing =
|
||||||
|
'profileId' in selection &&
|
||||||
|
Boolean(selection.profileId) &&
|
||||||
|
profile === undefined
|
||||||
if (selection.provider === 'model') {
|
if (selection.provider === 'model') {
|
||||||
return profile
|
return profile
|
||||||
? `${profile.name} · ${profile.modelName}`
|
? `${profile.name} · ${profile.modelName}`
|
||||||
: status?.label ?? labels.directModel
|
: requestedProfileMissing
|
||||||
|
? labels.modelUnavailable
|
||||||
|
: status?.label ?? labels.directModel
|
||||||
}
|
}
|
||||||
if (selection.provider === 'opencode') {
|
if (selection.provider === 'opencode') {
|
||||||
return profile ? `OpenCode · ${profile.name}` : 'OpenCode'
|
return profile
|
||||||
|
? `OpenCode · ${profile.name}`
|
||||||
|
: requestedProfileMissing
|
||||||
|
? `OpenCode · ${labels.modelUnavailable}`
|
||||||
|
: 'OpenCode'
|
||||||
}
|
}
|
||||||
if (selection.provider === 'continue') {
|
if (selection.provider === 'continue') {
|
||||||
return profile ? `Continue · ${profile.name}` : 'Continue'
|
return profile
|
||||||
|
? `Continue · ${profile.name}`
|
||||||
|
: requestedProfileMissing
|
||||||
|
? `Continue · ${labels.modelUnavailable}`
|
||||||
|
: 'Continue'
|
||||||
}
|
}
|
||||||
return status
|
return status
|
||||||
? `${labels.automatic} · ${status.label}`
|
? `${labels.automatic} · ${status.label}`
|
||||||
: labels.automaticSelection
|
: 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(
|
function getConfiguredAgentRuntimeSource(
|
||||||
settings: RuntimeSettings,
|
settings: RuntimeSettings,
|
||||||
provider: 'opencode' | 'continue',
|
provider: 'opencode' | 'continue',
|
||||||
@@ -910,7 +875,7 @@ function getConfiguredAgentRuntimeSource(
|
|||||||
useOwnConfiguration: (runtime: string) => string
|
useOwnConfiguration: (runtime: string) => string
|
||||||
}
|
}
|
||||||
): { label: string; detail: string } {
|
): { label: string; detail: string } {
|
||||||
const selection = getConfiguredAgentRuntimeSelection(settings, provider)
|
const selection = getRuntimeSelectionForProvider(provider, settings)
|
||||||
const profile =
|
const profile =
|
||||||
'profileId' in selection
|
'profileId' in selection
|
||||||
? settings.modelProfiles.find(
|
? settings.modelProfiles.find(
|
||||||
@@ -1766,7 +1731,8 @@ function App(): React.JSX.Element {
|
|||||||
() => ({
|
() => ({
|
||||||
directModel: t('runtime.directModel'),
|
directModel: t('runtime.directModel'),
|
||||||
automatic: t('runtime.automatic'),
|
automatic: t('runtime.automatic'),
|
||||||
automaticSelection: t('runtime.automaticSelection')
|
automaticSelection: t('runtime.automaticSelection'),
|
||||||
|
modelUnavailable: t('runtime.modelUnavailable')
|
||||||
}),
|
}),
|
||||||
[t]
|
[t]
|
||||||
)
|
)
|
||||||
@@ -1787,10 +1753,10 @@ function App(): React.JSX.Element {
|
|||||||
runtimeLabels
|
runtimeLabels
|
||||||
)
|
)
|
||||||
const openCodeMenuSelection = runtimeSettings
|
const openCodeMenuSelection = runtimeSettings
|
||||||
? getConfiguredAgentRuntimeSelection(runtimeSettings, 'opencode')
|
? getRuntimeSelectionForProvider('opencode', runtimeSettings)
|
||||||
: undefined
|
: undefined
|
||||||
const continueMenuSelection = runtimeSettings
|
const continueMenuSelection = runtimeSettings
|
||||||
? getConfiguredAgentRuntimeSelection(runtimeSettings, 'continue')
|
? getRuntimeSelectionForProvider('continue', runtimeSettings)
|
||||||
: undefined
|
: undefined
|
||||||
const openCodeMenuSource = runtimeSettings
|
const openCodeMenuSource = runtimeSettings
|
||||||
? getConfiguredAgentRuntimeSource(
|
? getConfiguredAgentRuntimeSource(
|
||||||
@@ -1866,48 +1832,6 @@ function App(): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
}, [activeId, conversations])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const selection = activeRuntimeSelectionRef.current
|
const selection = activeRuntimeSelectionRef.current
|
||||||
if (!selection || !runtimeSettings) {
|
if (!selection || !runtimeSettings) {
|
||||||
@@ -4762,7 +4686,7 @@ function App(): React.JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
<div className="brand__copy">
|
<div className="brand__copy">
|
||||||
<strong>GoodBuddy</strong>
|
<strong>GoodBuddy</strong>
|
||||||
<span>Desktop workspace</span>
|
<span>{t('brand.desktopWorkspace')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -5299,7 +5223,7 @@ function App(): React.JSX.Element {
|
|||||||
<div className="welcome__badge">
|
<div className="welcome__badge">
|
||||||
<Sparkles size={18} />
|
<Sparkles size={18} />
|
||||||
</div>
|
</div>
|
||||||
<p className="eyebrow">GOODBUDDY WORKSPACE</p>
|
<p className="eyebrow">{t('chat.welcome.eyebrow')}</p>
|
||||||
<h1>{t('chat.welcome.title')}</h1>
|
<h1>{t('chat.welcome.title')}</h1>
|
||||||
<p className="welcome__description">
|
<p className="welcome__description">
|
||||||
{t('chat.welcome.description')}
|
{t('chat.welcome.description')}
|
||||||
@@ -6849,6 +6773,7 @@ function App(): React.JSX.Element {
|
|||||||
<SettingsPanel
|
<SettingsPanel
|
||||||
appearanceTheme={appearanceTheme}
|
appearanceTheme={appearanceTheme}
|
||||||
heartbeats={assistantHeartbeats}
|
heartbeats={assistantHeartbeats}
|
||||||
|
magicNotesEnabled={magicNotesEnabled}
|
||||||
onAppearanceThemeChange={setAppearanceTheme}
|
onAppearanceThemeChange={setAppearanceTheme}
|
||||||
onClearLocalData={clearLocalData}
|
onClearLocalData={clearLocalData}
|
||||||
onClose={() => setView('chat')}
|
onClose={() => setView('chat')}
|
||||||
|
|||||||
@@ -35,7 +35,10 @@ import type { WeixinBindingSnapshot } from '../../shared/weixin-channel-contract
|
|||||||
import type { AppNotificationInput } from './notifications'
|
import type { AppNotificationInput } from './notifications'
|
||||||
import { trapTabFocus } from './dialog-focus'
|
import { trapTabFocus } from './dialog-focus'
|
||||||
import { PageTabs, SegmentedControl } from './WorkspacePrimitives'
|
import { PageTabs, SegmentedControl } from './WorkspacePrimitives'
|
||||||
import { SettingsCategoryHeader } from './SettingsPrimitives'
|
import {
|
||||||
|
SettingsCategoryHeader,
|
||||||
|
SettingsWarningList
|
||||||
|
} from './SettingsPrimitives'
|
||||||
|
|
||||||
type ChannelDraft = {
|
type ChannelDraft = {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
@@ -455,6 +458,8 @@ function ChannelEditor({
|
|||||||
<small>
|
<small>
|
||||||
{settings.source === 'environment'
|
{settings.source === 'environment'
|
||||||
? t('channels.credential.environmentSource')
|
? t('channels.credential.environmentSource')
|
||||||
|
: settings.source === 'unreadable'
|
||||||
|
? t('channels.credential.secretUnreadable')
|
||||||
: settings.secretConfigured
|
: settings.secretConfigured
|
||||||
? t('channels.credential.secretSaved')
|
? t('channels.credential.secretSaved')
|
||||||
: t('channels.credential.secretMissing')}
|
: t('channels.credential.secretMissing')}
|
||||||
@@ -1327,7 +1332,7 @@ export function ChannelSettingsSection({
|
|||||||
aria-label={t('channels.sectionAriaLabel')}
|
aria-label={t('channels.sectionAriaLabel')}
|
||||||
className="settings-section channel-settings"
|
className="settings-section channel-settings"
|
||||||
>
|
>
|
||||||
{snapshot.warning && <p className="settings-warning">{snapshot.warning}</p>}
|
<SettingsWarningList warnings={snapshot.warnings} />
|
||||||
|
|
||||||
<div className="channel-settings__tabs">
|
<div className="channel-settings__tabs">
|
||||||
<PageTabs
|
<PageTabs
|
||||||
|
|||||||
@@ -199,6 +199,36 @@ describe('DocumentParsingSettingsSection', () => {
|
|||||||
|
|
||||||
afterEach(() => cleanup())
|
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 () => {
|
it('shows actual capability status and saves workflow settings', async () => {
|
||||||
const onNotify = vi.fn()
|
const onNotify = vi.fn()
|
||||||
render(
|
render(
|
||||||
|
|||||||
@@ -29,7 +29,10 @@ import type {
|
|||||||
DocumentParsingTestPurpose
|
DocumentParsingTestPurpose
|
||||||
} from '../../shared/document-parsing-contracts'
|
} from '../../shared/document-parsing-contracts'
|
||||||
import type { AppNotificationInput } from './notifications'
|
import type { AppNotificationInput } from './notifications'
|
||||||
import { SettingsCategoryHeader } from './SettingsPrimitives'
|
import {
|
||||||
|
SettingsCategoryHeader,
|
||||||
|
SettingsWarningList
|
||||||
|
} from './SettingsPrimitives'
|
||||||
|
|
||||||
type DocumentParsingSettingsSectionProps = {
|
type DocumentParsingSettingsSectionProps = {
|
||||||
onNotify?: (notification: AppNotificationInput) => void
|
onNotify?: (notification: AppNotificationInput) => void
|
||||||
@@ -402,7 +405,9 @@ export function DocumentParsingSettingsSection({
|
|||||||
error={error ?? unavailableError}
|
error={error ?? unavailableError}
|
||||||
/>
|
/>
|
||||||
{!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"
|
category="document-parsing"
|
||||||
error={error}
|
error={error}
|
||||||
/>
|
/>
|
||||||
|
<SettingsWarningList warnings={snapshot.warnings} />
|
||||||
{settingsDirty && (
|
{settingsDirty && (
|
||||||
<p
|
<p
|
||||||
className="settings-notice"
|
className="settings-notice"
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ import type {
|
|||||||
WebSearchTestResult
|
WebSearchTestResult
|
||||||
} from '../../shared/capability-contracts'
|
} from '../../shared/capability-contracts'
|
||||||
import { trapTabFocus } from './dialog-focus'
|
import { trapTabFocus } from './dialog-focus'
|
||||||
import { SettingsCategoryHeader } from './SettingsPrimitives'
|
import {
|
||||||
|
SettingsCategoryHeader,
|
||||||
|
SettingsWarningList
|
||||||
|
} from './SettingsPrimitives'
|
||||||
import { PageTabs } from './WorkspacePrimitives'
|
import { PageTabs } from './WorkspacePrimitives'
|
||||||
|
|
||||||
const configurableMcpTargets: RuntimeTarget[] = ['model']
|
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 { t } = useTranslation('integrations')
|
||||||
const tRef = useRef(t)
|
const tRef = useRef(t)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -106,7 +113,6 @@ export function McpSettingsSection(): React.JSX.Element {
|
|||||||
disabled: t('mcp.diagnosticStatuses.disabled')
|
disabled: t('mcp.diagnosticStatuses.disabled')
|
||||||
}
|
}
|
||||||
const [snapshot, setSnapshot] = useState<CapabilitySnapshot>()
|
const [snapshot, setSnapshot] = useState<CapabilitySnapshot>()
|
||||||
const [magicNotesEnabled, setMagicNotesEnabled] = useState(false)
|
|
||||||
const [editor, setEditor] = useState<McpEditor>()
|
const [editor, setEditor] = useState<McpEditor>()
|
||||||
const [busy, setBusy] = useState<string>()
|
const [busy, setBusy] = useState<string>()
|
||||||
const [error, setError] = 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(() => {
|
useEffect(() => {
|
||||||
if (!editorOpen) {
|
if (!editorOpen) {
|
||||||
return
|
return
|
||||||
@@ -417,6 +409,7 @@ export function McpSettingsSection(): React.JSX.Element {
|
|||||||
error={!editor ? error : undefined}
|
error={!editor ? error : undefined}
|
||||||
headingId="mcp-settings-heading"
|
headingId="mcp-settings-heading"
|
||||||
/>
|
/>
|
||||||
|
<SettingsWarningList warnings={snapshot?.warnings} />
|
||||||
<PageTabs
|
<PageTabs
|
||||||
ariaLabel={t('mcp.tabs.ariaLabel')}
|
ariaLabel={t('mcp.tabs.ariaLabel')}
|
||||||
idPrefix="mcp-settings"
|
idPrefix="mcp-settings"
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ import type {
|
|||||||
} from '../../shared/application-settings-contracts'
|
} from '../../shared/application-settings-contracts'
|
||||||
import type { MagicNoteCommentFormat } from '../../shared/magic-notes-contracts'
|
import type { MagicNoteCommentFormat } from '../../shared/magic-notes-contracts'
|
||||||
import { SegmentedControl } from './WorkspacePrimitives'
|
import { SegmentedControl } from './WorkspacePrimitives'
|
||||||
import { SettingsCategoryHeader } from './SettingsPrimitives'
|
import {
|
||||||
|
SettingsCategoryHeader,
|
||||||
|
SettingsWarningList
|
||||||
|
} from './SettingsPrimitives'
|
||||||
|
|
||||||
type PlatformFeaturesSettingsSectionProps = {
|
type PlatformFeaturesSettingsSectionProps = {
|
||||||
onMagicNotesEnabledChange: (enabled: boolean) => void
|
onMagicNotesEnabledChange: (enabled: boolean) => void
|
||||||
@@ -116,6 +119,7 @@ export function PlatformFeaturesSettingsSection({
|
|||||||
error={error}
|
error={error}
|
||||||
headingId="platform-features-heading"
|
headingId="platform-features-heading"
|
||||||
/>
|
/>
|
||||||
|
<SettingsWarningList warnings={settings?.warnings} />
|
||||||
<section
|
<section
|
||||||
aria-label={t('platformFeatures.label')}
|
aria-label={t('platformFeatures.label')}
|
||||||
className="settings-section"
|
className="settings-section"
|
||||||
|
|||||||
@@ -18,8 +18,11 @@ import {
|
|||||||
normalizeInteractiveWorkMode
|
normalizeInteractiveWorkMode
|
||||||
} from '../../shared/assistant-contracts'
|
} from '../../shared/assistant-contracts'
|
||||||
import type { RuntimeSettings } from '../../shared/contracts'
|
import type { RuntimeSettings } from '../../shared/contracts'
|
||||||
import type { AgentRuntimeSelection } from '../../shared/runtime-selection-contracts'
|
|
||||||
import { trapTabFocus } from './dialog-focus'
|
import { trapTabFocus } from './dialog-focus'
|
||||||
|
import {
|
||||||
|
getDefaultRuntimeSelection,
|
||||||
|
getRuntimeSelectionForProvider
|
||||||
|
} from './runtime-selection'
|
||||||
|
|
||||||
type ProjectSwitcherProps = {
|
type ProjectSwitcherProps = {
|
||||||
projects: AssistantProject[]
|
projects: AssistantProject[]
|
||||||
@@ -36,43 +39,6 @@ type ProjectSwitcherProps = {
|
|||||||
) => Promise<AssistantProject>
|
) => 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({
|
export function ProjectSwitcher({
|
||||||
projects,
|
projects,
|
||||||
activeProjectId,
|
activeProjectId,
|
||||||
@@ -157,7 +123,7 @@ export function ProjectSwitcher({
|
|||||||
? draft
|
? draft
|
||||||
: {
|
: {
|
||||||
...draft,
|
...draft,
|
||||||
runtimeSelection: defaultRuntimeSelection(runtimeSettings)
|
runtimeSelection: getDefaultRuntimeSelection(runtimeSettings)
|
||||||
}
|
}
|
||||||
if (dialogMode === 'settings' && activeProject) {
|
if (dialogMode === 'settings' && activeProject) {
|
||||||
await onUpdate(activeProject.id, input)
|
await onUpdate(activeProject.id, input)
|
||||||
@@ -276,7 +242,7 @@ export function ProjectSwitcher({
|
|||||||
rootPath: '',
|
rootPath: '',
|
||||||
defaultWorkMode: 'ask',
|
defaultWorkMode: 'ask',
|
||||||
runtimeSelection: runtimeSettings
|
runtimeSelection: runtimeSettings
|
||||||
? defaultRuntimeSelection(runtimeSettings)
|
? getDefaultRuntimeSelection(runtimeSettings)
|
||||||
: undefined
|
: undefined
|
||||||
})
|
})
|
||||||
restoreFocusTarget.current = 'create'
|
restoreFocusTarget.current = 'create'
|
||||||
@@ -308,7 +274,7 @@ export function ProjectSwitcher({
|
|||||||
runtimeSelection:
|
runtimeSelection:
|
||||||
activeProject.runtimeSelection ??
|
activeProject.runtimeSelection ??
|
||||||
(runtimeSettings
|
(runtimeSettings
|
||||||
? defaultRuntimeSelection(runtimeSettings)
|
? getDefaultRuntimeSelection(runtimeSettings)
|
||||||
: undefined)
|
: undefined)
|
||||||
})
|
})
|
||||||
restoreFocusTarget.current = 'settings'
|
restoreFocusTarget.current = 'settings'
|
||||||
@@ -441,7 +407,7 @@ export function ProjectSwitcher({
|
|||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setDraft((current) => ({
|
setDraft((current) => ({
|
||||||
...current,
|
...current,
|
||||||
runtimeSelection: runtimeSelectionForProvider(
|
runtimeSelection: getRuntimeSelectionForProvider(
|
||||||
event.target.value as
|
event.target.value as
|
||||||
| 'model'
|
| 'model'
|
||||||
| 'opencode'
|
| 'opencode'
|
||||||
@@ -454,7 +420,7 @@ export function ProjectSwitcher({
|
|||||||
draft.runtimeSelection?.provider === 'auto'
|
draft.runtimeSelection?.provider === 'auto'
|
||||||
? 'model'
|
? 'model'
|
||||||
: (draft.runtimeSelection?.provider ??
|
: (draft.runtimeSelection?.provider ??
|
||||||
defaultRuntimeSelection(runtimeSettings)
|
getDefaultRuntimeSelection(runtimeSettings)
|
||||||
.provider)
|
.provider)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
waitFor,
|
waitFor,
|
||||||
within
|
within
|
||||||
} from '@testing-library/react'
|
} from '@testing-library/react'
|
||||||
|
import { useState } from 'react'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import type { AssistantExpert } from '../../shared/assistant-contracts'
|
import type { AssistantExpert } from '../../shared/assistant-contracts'
|
||||||
import type {
|
import type {
|
||||||
@@ -569,6 +570,31 @@ describe('SettingsPanel runtime files', () => {
|
|||||||
screen.getByRole('radio', { name: /Use system language/u })
|
screen.getByRole('radio', { name: /Use system language/u })
|
||||||
).toBeInTheDocument()
|
).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(
|
fireEvent.click(
|
||||||
screen.getByRole('tab', { name: 'Agent Runtime' })
|
screen.getByRole('tab', { name: 'Agent Runtime' })
|
||||||
)
|
)
|
||||||
@@ -590,6 +616,69 @@ describe('SettingsPanel runtime files', () => {
|
|||||||
).toBeInTheDocument()
|
).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 () => {
|
it('toggles the Magic Notes platform entry setting', async () => {
|
||||||
const onMagicNotesEnabledChange = vi.fn()
|
const onMagicNotesEnabledChange = vi.fn()
|
||||||
render(
|
render(
|
||||||
@@ -617,7 +706,6 @@ describe('SettingsPanel runtime files', () => {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
expect(onMagicNotesEnabledChange).toHaveBeenCalledWith(true)
|
expect(onMagicNotesEnabledChange).toHaveBeenCalledWith(true)
|
||||||
|
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
screen.getByRole('button', { name: '保存后自动' })
|
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', () => {
|
it('keeps page navigation beside an independently scrollable panel', () => {
|
||||||
render(
|
render(
|
||||||
<SettingsPanel
|
<SettingsPanel
|
||||||
@@ -759,6 +900,77 @@ describe('SettingsPanel runtime files', () => {
|
|||||||
expect(screen.queryByText('设置已保存')).not.toBeInTheDocument()
|
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 () => {
|
it('applies a speech model draft only when Settings is saved', async () => {
|
||||||
render(
|
render(
|
||||||
<SettingsPanel
|
<SettingsPanel
|
||||||
|
|||||||
+259
-138
@@ -9,7 +9,7 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
X
|
X
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import type {
|
import type {
|
||||||
AssistantExpert,
|
AssistantExpert,
|
||||||
@@ -26,6 +26,7 @@ import type {
|
|||||||
} from '../../shared/contracts'
|
} from '../../shared/contracts'
|
||||||
import type { AgentRuntimeSelection } from '../../shared/runtime-selection-contracts'
|
import type { AgentRuntimeSelection } from '../../shared/runtime-selection-contracts'
|
||||||
import {
|
import {
|
||||||
|
defaultModelProfileId as builtInDefaultModelProfileId,
|
||||||
defaultRuntimeSettings,
|
defaultRuntimeSettings,
|
||||||
isAgentRuntimeModelProtocol
|
isAgentRuntimeModelProtocol
|
||||||
} from '../../shared/contracts'
|
} from '../../shared/contracts'
|
||||||
@@ -40,7 +41,10 @@ import { SpeechModelSettingsSection } from './SpeechModelSettingsSection'
|
|||||||
import { EmbeddingSettingsSection } from './EmbeddingSettingsSection'
|
import { EmbeddingSettingsSection } from './EmbeddingSettingsSection'
|
||||||
import { DocumentParsingSettingsSection } from './DocumentParsingSettingsSection'
|
import { DocumentParsingSettingsSection } from './DocumentParsingSettingsSection'
|
||||||
import { PageHeader, SegmentedControl } from './WorkspacePrimitives'
|
import { PageHeader, SegmentedControl } from './WorkspacePrimitives'
|
||||||
import { SettingsCategoryHeader } from './SettingsPrimitives'
|
import {
|
||||||
|
SettingsCategoryHeader,
|
||||||
|
SettingsWarningList
|
||||||
|
} from './SettingsPrimitives'
|
||||||
import {
|
import {
|
||||||
settingsCategoryList,
|
settingsCategoryList,
|
||||||
type SettingsCategoryId
|
type SettingsCategoryId
|
||||||
@@ -81,6 +85,7 @@ type SettingsPanelProps = {
|
|||||||
onRunHeartbeat: (heartbeatId: string) => Promise<void>
|
onRunHeartbeat: (heartbeatId: string) => Promise<void>
|
||||||
appearanceTheme?: AppearanceTheme
|
appearanceTheme?: AppearanceTheme
|
||||||
onAppearanceThemeChange?: (theme: AppearanceTheme) => void
|
onAppearanceThemeChange?: (theme: AppearanceTheme) => void
|
||||||
|
magicNotesEnabled?: boolean
|
||||||
onMagicNotesEnabledChange?: (enabled: boolean) => void
|
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 = {
|
type RuntimeConfigCardProps = {
|
||||||
runtime: AgentRuntimeType
|
runtime: AgentRuntimeType
|
||||||
runtimeLabel: string
|
runtimeLabel: string
|
||||||
@@ -246,6 +363,7 @@ export function SettingsPanel({
|
|||||||
onExpertsChanged = () => {},
|
onExpertsChanged = () => {},
|
||||||
appearanceTheme = 'system',
|
appearanceTheme = 'system',
|
||||||
onAppearanceThemeChange = () => {},
|
onAppearanceThemeChange = () => {},
|
||||||
|
magicNotesEnabled = false,
|
||||||
onMagicNotesEnabledChange = () => {}
|
onMagicNotesEnabledChange = () => {}
|
||||||
}: SettingsPanelProps): React.JSX.Element | null {
|
}: SettingsPanelProps): React.JSX.Element | null {
|
||||||
const { i18n, t } = useTranslation('settings')
|
const { i18n, t } = useTranslation('settings')
|
||||||
@@ -322,6 +440,13 @@ export function SettingsPanel({
|
|||||||
subagentSmartRoutingEnabled,
|
subagentSmartRoutingEnabled,
|
||||||
setSubagentSmartRoutingEnabled
|
setSubagentSmartRoutingEnabled
|
||||||
] = useState(false)
|
] = 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 [saving, setSaving] = useState(false)
|
||||||
const [testing, setTesting] = useState(false)
|
const [testing, setTesting] = useState(false)
|
||||||
const [embeddingConfiguration, setEmbeddingConfiguration] =
|
const [embeddingConfiguration, setEmbeddingConfiguration] =
|
||||||
@@ -350,6 +475,47 @@ export function SettingsPanel({
|
|||||||
const [agentRuntimeType, setAgentRuntimeType] =
|
const [agentRuntimeType, setAgentRuntimeType] =
|
||||||
useState<AgentRuntimeType>('opencode')
|
useState<AgentRuntimeType>('opencode')
|
||||||
const settingsBodyRef = useRef<HTMLDivElement>(null)
|
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 =
|
const configurationTab =
|
||||||
activeTab === 'model' ||
|
activeTab === 'model' ||
|
||||||
activeTab === 'runtime' ||
|
activeTab === 'runtime' ||
|
||||||
@@ -409,52 +575,7 @@ export function SettingsPanel({
|
|||||||
setPersistedSpeechModelId(undefined)
|
setPersistedSpeechModelId(undefined)
|
||||||
setSpeechModelSelectionDirty(false)
|
setSpeechModelSelectionDirty(false)
|
||||||
setAgentRuntimeType('opencode')
|
setAgentRuntimeType('opencode')
|
||||||
setSettings(value)
|
hydrateSettings(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
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
.catch((reason: unknown) => {
|
.catch((reason: unknown) => {
|
||||||
setError(
|
setError(
|
||||||
@@ -475,7 +596,7 @@ export function SettingsPanel({
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}, [i18n, open])
|
}, [hydrateSettings, i18n, open])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && settingsBodyRef.current) {
|
if (open && settingsBodyRef.current) {
|
||||||
@@ -550,32 +671,52 @@ export function SettingsPanel({
|
|||||||
if (!defaultProfile) {
|
if (!defaultProfile) {
|
||||||
throw new Error(t('errors.requireModelConnection'))
|
throw new Error(t('errors.requireModelConnection'))
|
||||||
}
|
}
|
||||||
const profileInputs = modelProfiles.map((profile) => ({
|
const configuredProfiles = new Map(
|
||||||
id: profile.id,
|
settings?.configured?.modelProfiles.map((profile) => [
|
||||||
name: profile.name,
|
profile.id,
|
||||||
baseUrl: profile.baseUrl,
|
profile
|
||||||
modelName: profile.modelName,
|
])
|
||||||
protocol: profile.protocol,
|
)
|
||||||
authentication: profile.authentication,
|
const profileInputs = modelProfiles.map((profile) => {
|
||||||
supportsImageInput: profile.supportsImageInput,
|
const configured = configuredProfiles.get(profile.id)
|
||||||
imageGenerationQuality: profile.imageGenerationQuality,
|
const environmentManaged =
|
||||||
apiKey: profile.clearApiKey
|
profile.credentialSource === 'environment' &&
|
||||||
? ({ action: 'clear' } as const)
|
configured !== undefined
|
||||||
: profile.apiKey.trim()
|
return {
|
||||||
? ({
|
id: profile.id,
|
||||||
action: 'replace',
|
name: profile.name,
|
||||||
value: profile.apiKey.trim()
|
baseUrl: environmentManaged
|
||||||
} as const)
|
? configured.baseUrl
|
||||||
: ({ action: 'keep' } as const)
|
: 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({
|
const value = await window.goodbuddy.settings.updateRuntime({
|
||||||
provider,
|
provider,
|
||||||
modelBaseUrl: defaultProfile.baseUrl,
|
modelBaseUrl: defaultProfileInput.baseUrl,
|
||||||
modelName: defaultProfile.modelName,
|
modelName: defaultProfileInput.modelName,
|
||||||
modelProtocol: defaultProfile.protocol,
|
modelProtocol: defaultProfileInput.protocol,
|
||||||
modelAuthentication: defaultProfile.authentication,
|
modelAuthentication: defaultProfileInput.authentication,
|
||||||
imageGenerationQuality:
|
imageGenerationQuality:
|
||||||
defaultProfile.imageGenerationQuality,
|
defaultProfileInput.imageGenerationQuality,
|
||||||
opencodeBaseUrl,
|
opencodeBaseUrl,
|
||||||
opencodeEmbedded: !opencodeBaseUrl,
|
opencodeEmbedded: !opencodeBaseUrl,
|
||||||
opencodeBinaryPath,
|
opencodeBinaryPath,
|
||||||
@@ -607,9 +748,7 @@ export function SettingsPanel({
|
|||||||
}
|
}
|
||||||
: { action: 'keep' },
|
: { action: 'keep' },
|
||||||
workspacePath,
|
workspacePath,
|
||||||
apiKey: profileInputs.find(
|
apiKey: defaultProfileInput.apiKey,
|
||||||
(profile) => profile.id === defaultProfile.id
|
|
||||||
)!.apiKey,
|
|
||||||
modelProfiles: profileInputs,
|
modelProfiles: profileInputs,
|
||||||
defaultModelProfileId: defaultProfile.id,
|
defaultModelProfileId: defaultProfile.id,
|
||||||
opencodeModelSource,
|
opencodeModelSource,
|
||||||
@@ -628,48 +767,7 @@ export function SettingsPanel({
|
|||||||
)
|
)
|
||||||
selectedSpeechModelId = speechSnapshot.selectedModelId
|
selectedSpeechModelId = speechSnapshot.selectedModelId
|
||||||
}
|
}
|
||||||
setSettings(value)
|
hydrateSettings(value, true)
|
||||||
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
|
|
||||||
)
|
|
||||||
if (speechModelSelectionDirty) {
|
if (speechModelSelectionDirty) {
|
||||||
setSpeechModelDraftId(selectedSpeechModelId)
|
setSpeechModelDraftId(selectedSpeechModelId)
|
||||||
setPersistedSpeechModelId(selectedSpeechModelId)
|
setPersistedSpeechModelId(selectedSpeechModelId)
|
||||||
@@ -987,7 +1085,10 @@ export function SettingsPanel({
|
|||||||
.filter((profile) =>
|
.filter((profile) =>
|
||||||
isAgentRuntimeModelProtocol(profile.protocol)
|
isAgentRuntimeModelProtocol(profile.protocol)
|
||||||
)
|
)
|
||||||
.map(({ id, name }) => ({ id, name }))
|
.map(({ id, name }) => ({
|
||||||
|
id,
|
||||||
|
name: modelProfileDisplayName({ id, name })
|
||||||
|
}))
|
||||||
const savedRoleDefaultModelProfileId =
|
const savedRoleDefaultModelProfileId =
|
||||||
savedRoleModelProfiles.some(
|
savedRoleModelProfiles.some(
|
||||||
(profile) => profile.id === settings?.defaultModelProfileId
|
(profile) => profile.id === settings?.defaultModelProfileId
|
||||||
@@ -1246,9 +1347,7 @@ export function SettingsPanel({
|
|||||||
)}
|
)}
|
||||||
{activeTab === 'runtime' && (
|
{activeTab === 'runtime' && (
|
||||||
<>
|
<>
|
||||||
{settings?.warning && (
|
<SettingsWarningList warnings={settings?.warnings} />
|
||||||
<p className="settings-warning">{settings.warning}</p>
|
|
||||||
)}
|
|
||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
<div className="settings-section__title">
|
<div className="settings-section__title">
|
||||||
<FolderOpen size={17} />
|
<FolderOpen size={17} />
|
||||||
@@ -1327,12 +1426,16 @@ export function SettingsPanel({
|
|||||||
})
|
})
|
||||||
: activeRuntimeModelProfile
|
: activeRuntimeModelProfile
|
||||||
? t('runtime.followGoodBuddy', {
|
? t('runtime.followGoodBuddy', {
|
||||||
name: activeRuntimeModelProfile.name,
|
name: modelProfileDisplayName(
|
||||||
|
activeRuntimeModelProfile
|
||||||
|
),
|
||||||
model: activeRuntimeModelProfile.modelName
|
model: activeRuntimeModelProfile.modelName
|
||||||
})
|
})
|
||||||
: defaultTextModelProfile
|
: defaultTextModelProfile
|
||||||
? t('runtime.followGoodBuddy', {
|
? t('runtime.followGoodBuddy', {
|
||||||
name: defaultTextModelProfile.name,
|
name: modelProfileDisplayName(
|
||||||
|
defaultTextModelProfile
|
||||||
|
),
|
||||||
model: defaultTextModelProfile.modelName
|
model: defaultTextModelProfile.modelName
|
||||||
})
|
})
|
||||||
: t('runtime.noCompatibleModel')}
|
: t('runtime.noCompatibleModel')}
|
||||||
@@ -1415,7 +1518,7 @@ export function SettingsPanel({
|
|||||||
key={profile.id}
|
key={profile.id}
|
||||||
value={profile.id}
|
value={profile.id}
|
||||||
>
|
>
|
||||||
{profile.name}
|
{modelProfileDisplayName(profile)}
|
||||||
{isOpenCodeCompatible(profile)
|
{isOpenCodeCompatible(profile)
|
||||||
? ''
|
? ''
|
||||||
: t('runtime.incompatibleSuffix')}
|
: t('runtime.incompatibleSuffix')}
|
||||||
@@ -1440,7 +1543,12 @@ export function SettingsPanel({
|
|||||||
path={opencodeConfigPath}
|
path={opencodeConfigPath}
|
||||||
runtime="opencode"
|
runtime="opencode"
|
||||||
runtimeLabel="OpenCode"
|
runtimeLabel="OpenCode"
|
||||||
savedPath={settings?.opencodeConfigPath}
|
savedPath={
|
||||||
|
settings
|
||||||
|
? configuredRuntimeSettings(settings)
|
||||||
|
.opencodeConfigPath
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{opencodeModelSource.kind === 'platform' &&
|
{opencodeModelSource.kind === 'platform' &&
|
||||||
@@ -1548,12 +1656,16 @@ export function SettingsPanel({
|
|||||||
})
|
})
|
||||||
: activeRuntimeModelProfile
|
: activeRuntimeModelProfile
|
||||||
? t('runtime.followGoodBuddy', {
|
? t('runtime.followGoodBuddy', {
|
||||||
name: activeRuntimeModelProfile.name,
|
name: modelProfileDisplayName(
|
||||||
|
activeRuntimeModelProfile
|
||||||
|
),
|
||||||
model: activeRuntimeModelProfile.modelName
|
model: activeRuntimeModelProfile.modelName
|
||||||
})
|
})
|
||||||
: defaultTextModelProfile
|
: defaultTextModelProfile
|
||||||
? t('runtime.followGoodBuddy', {
|
? t('runtime.followGoodBuddy', {
|
||||||
name: defaultTextModelProfile.name,
|
name: modelProfileDisplayName(
|
||||||
|
defaultTextModelProfile
|
||||||
|
),
|
||||||
model: defaultTextModelProfile.modelName
|
model: defaultTextModelProfile.modelName
|
||||||
})
|
})
|
||||||
: t('runtime.noCompatibleModel')}
|
: t('runtime.noCompatibleModel')}
|
||||||
@@ -1634,7 +1746,7 @@ export function SettingsPanel({
|
|||||||
key={profile.id}
|
key={profile.id}
|
||||||
value={profile.id}
|
value={profile.id}
|
||||||
>
|
>
|
||||||
{profile.name}
|
{modelProfileDisplayName(profile)}
|
||||||
{isContinueCompatible(profile)
|
{isContinueCompatible(profile)
|
||||||
? ''
|
? ''
|
||||||
: t('runtime.incompatibleSuffix')}
|
: t('runtime.incompatibleSuffix')}
|
||||||
@@ -1658,7 +1770,12 @@ export function SettingsPanel({
|
|||||||
path={continueConfigPath}
|
path={continueConfigPath}
|
||||||
runtime="continue"
|
runtime="continue"
|
||||||
runtimeLabel="Continue"
|
runtimeLabel="Continue"
|
||||||
savedPath={settings?.continueConfigPath}
|
savedPath={
|
||||||
|
settings
|
||||||
|
? configuredRuntimeSettings(settings)
|
||||||
|
.continueConfigPath
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<label className="field">
|
<label className="field">
|
||||||
@@ -1798,7 +1915,7 @@ export function SettingsPanel({
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
aria-label={t('model.profile.editAriaLabel', {
|
aria-label={t('model.profile.editAriaLabel', {
|
||||||
name: profile.name
|
name: modelProfileDisplayName(profile)
|
||||||
})}
|
})}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setSelectedModelProfileId(profile.id)
|
setSelectedModelProfileId(profile.id)
|
||||||
@@ -1806,7 +1923,7 @@ export function SettingsPanel({
|
|||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<span className="model-connection-list__name">
|
<span className="model-connection-list__name">
|
||||||
<strong>{profile.name}</strong>
|
<strong>{modelProfileDisplayName(profile)}</strong>
|
||||||
<small>{profile.modelName}</small>
|
<small>{profile.modelName}</small>
|
||||||
</span>
|
</span>
|
||||||
<span className="model-connection-list__badges">
|
<span className="model-connection-list__badges">
|
||||||
@@ -1836,7 +1953,7 @@ export function SettingsPanel({
|
|||||||
<div className="settings-section__title">
|
<div className="settings-section__title">
|
||||||
<div>
|
<div>
|
||||||
<strong id={`model-connection-${profile.id}`}>
|
<strong id={`model-connection-${profile.id}`}>
|
||||||
{profile.name}
|
{modelProfileDisplayName(profile)}
|
||||||
</strong>
|
</strong>
|
||||||
<small>{t('model.profile.detail')}</small>
|
<small>{t('model.profile.detail')}</small>
|
||||||
</div>
|
</div>
|
||||||
@@ -1858,7 +1975,7 @@ export function SettingsPanel({
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
aria-label={t('model.profile.deleteAriaLabel', {
|
aria-label={t('model.profile.deleteAriaLabel', {
|
||||||
name: profile.name
|
name: modelProfileDisplayName(profile)
|
||||||
})}
|
})}
|
||||||
className="danger-button danger-button--quiet"
|
className="danger-button danger-button--quiet"
|
||||||
disabled={modelProfiles.length <= 1}
|
disabled={modelProfiles.length <= 1}
|
||||||
@@ -1877,7 +1994,7 @@ export function SettingsPanel({
|
|||||||
name: event.target.value
|
name: event.target.value
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
value={profile.name}
|
value={modelProfileDisplayName(profile)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
@@ -1912,7 +2029,7 @@ export function SettingsPanel({
|
|||||||
<select
|
<select
|
||||||
aria-label={t(
|
aria-label={t(
|
||||||
'model.profile.protocolAriaLabel',
|
'model.profile.protocolAriaLabel',
|
||||||
{ name: profile.name }
|
{ name: modelProfileDisplayName(profile) }
|
||||||
)}
|
)}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
{
|
{
|
||||||
@@ -1979,7 +2096,7 @@ export function SettingsPanel({
|
|||||||
<select
|
<select
|
||||||
aria-label={t(
|
aria-label={t(
|
||||||
'model.profile.authenticationAriaLabel',
|
'model.profile.authenticationAriaLabel',
|
||||||
{ name: profile.name }
|
{ name: modelProfileDisplayName(profile) }
|
||||||
)}
|
)}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const authentication = event.target
|
const authentication = event.target
|
||||||
@@ -2027,7 +2144,7 @@ export function SettingsPanel({
|
|||||||
<select
|
<select
|
||||||
aria-label={t(
|
aria-label={t(
|
||||||
'model.profile.imageQualityAriaLabel',
|
'model.profile.imageQualityAriaLabel',
|
||||||
{ name: profile.name }
|
{ name: modelProfileDisplayName(profile) }
|
||||||
)}
|
)}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
updateModelProfile(profile.id, {
|
updateModelProfile(profile.id, {
|
||||||
@@ -2530,7 +2647,11 @@ export function SettingsPanel({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{activeTab === 'skills' && <SkillsSettingsSection />}
|
{activeTab === 'skills' && <SkillsSettingsSection />}
|
||||||
{activeTab === 'mcp' && <McpSettingsSection />}
|
{activeTab === 'mcp' && (
|
||||||
|
<McpSettingsSection
|
||||||
|
magicNotesEnabled={magicNotesEnabled}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{activeTab === 'about' && <UpdateSettingsSection />}
|
{activeTab === 'about' && <UpdateSettingsSection />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,38 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import {
|
||||||
|
settingsWarningKey,
|
||||||
|
type SettingsWarning
|
||||||
|
} from '../../shared/settings-warning-contracts'
|
||||||
import {
|
import {
|
||||||
settingsCategories,
|
settingsCategories,
|
||||||
type SettingsCategoryId
|
type SettingsCategoryId
|
||||||
} from './settings-categories'
|
} 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({
|
export function SettingsCategoryHeader({
|
||||||
actions,
|
actions,
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ import type {
|
|||||||
VersionCheckResult
|
VersionCheckResult
|
||||||
} from '../../shared/application-settings-contracts'
|
} from '../../shared/application-settings-contracts'
|
||||||
import type { AppInfo } from '../../shared/contracts'
|
import type { AppInfo } from '../../shared/contracts'
|
||||||
import { SettingsCategoryHeader } from './SettingsPrimitives'
|
import {
|
||||||
|
SettingsCategoryHeader,
|
||||||
|
SettingsWarningList
|
||||||
|
} from './SettingsPrimitives'
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
function formatBytes(bytes: number): string {
|
||||||
if (bytes >= 1024 * 1024 * 1024) {
|
if (bytes >= 1024 * 1024 * 1024) {
|
||||||
@@ -137,6 +140,7 @@ export function UpdateSettingsSection(): React.JSX.Element {
|
|||||||
error={error}
|
error={error}
|
||||||
headingId="update-settings-heading"
|
headingId="update-settings-heading"
|
||||||
/>
|
/>
|
||||||
|
<SettingsWarningList warnings={settings?.warnings} />
|
||||||
<section
|
<section
|
||||||
aria-label={t('updates.label')}
|
aria-label={t('updates.label')}
|
||||||
className="settings-section update-settings"
|
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 { settings as englishSettings } from './locales/en-US/settings'
|
||||||
import { settingsSections as englishSettingsSections } from './locales/en-US/settingsSections'
|
import { settingsSections as englishSettingsSections } from './locales/en-US/settingsSections'
|
||||||
import { workspace as englishWorkspace } from './locales/en-US/workspace'
|
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 { activity as chineseActivity } from './locales/zh-CN/activity'
|
||||||
import { app as chineseApp } from './locales/zh-CN/app'
|
import { app as chineseApp } from './locales/zh-CN/app'
|
||||||
import { heartbeat as chineseHeartbeat } from './locales/zh-CN/heartbeat'
|
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 { settings as chineseSettings } from './locales/zh-CN/settings'
|
||||||
import { settingsSections as chineseSettingsSections } from './locales/zh-CN/settingsSections'
|
import { settingsSections as chineseSettingsSections } from './locales/zh-CN/settingsSections'
|
||||||
import { workspace as chineseWorkspace } from './locales/zh-CN/workspace'
|
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 const supportedUiLocales = ['zh-CN', 'en-US'] as const
|
||||||
export type UiLocale = (typeof supportedUiLocales)[number]
|
export type UiLocale = (typeof supportedUiLocales)[number]
|
||||||
@@ -32,7 +34,8 @@ export const i18nResources = {
|
|||||||
magicNotes: chineseMagicNotes,
|
magicNotes: chineseMagicNotes,
|
||||||
settings: chineseSettings,
|
settings: chineseSettings,
|
||||||
settingsSections: chineseSettingsSections,
|
settingsSections: chineseSettingsSections,
|
||||||
workspace: chineseWorkspace
|
workspace: chineseWorkspace,
|
||||||
|
warnings: chineseWarnings
|
||||||
},
|
},
|
||||||
'en-US': {
|
'en-US': {
|
||||||
activity: englishActivity,
|
activity: englishActivity,
|
||||||
@@ -43,7 +46,8 @@ export const i18nResources = {
|
|||||||
magicNotes: englishMagicNotes,
|
magicNotes: englishMagicNotes,
|
||||||
settings: englishSettings,
|
settings: englishSettings,
|
||||||
settingsSections: englishSettingsSections,
|
settingsSections: englishSettingsSections,
|
||||||
workspace: englishWorkspace
|
workspace: englishWorkspace,
|
||||||
|
warnings: englishWarnings
|
||||||
}
|
}
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import type { TranslationShape } from '../../resource-types'
|
|||||||
import type { app as chineseApp } from '../zh-CN/app'
|
import type { app as chineseApp } from '../zh-CN/app'
|
||||||
|
|
||||||
export const app = {
|
export const app = {
|
||||||
|
brand: {
|
||||||
|
desktopWorkspace: 'Desktop workspace'
|
||||||
|
},
|
||||||
notifications: {
|
notifications: {
|
||||||
success: 'Success',
|
success: 'Success',
|
||||||
error: 'Error',
|
error: 'Error',
|
||||||
@@ -125,6 +128,7 @@ export const app = {
|
|||||||
user: 'You',
|
user: 'You',
|
||||||
assistantResult: 'Assistant result {{index}}',
|
assistantResult: 'Assistant result {{index}}',
|
||||||
welcome: {
|
welcome: {
|
||||||
|
eyebrow: 'GOODBUDDY WORKSPACE',
|
||||||
title: 'What would you like to accomplish today?',
|
title: 'What would you like to accomplish today?',
|
||||||
description:
|
description:
|
||||||
'Ask a question, organize information, or connect OpenCode for file search and development tools.'
|
'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',
|
environmentSource: 'Provided by environment variables',
|
||||||
secretSaved: 'Secret saved with encryption',
|
secretSaved: 'Secret saved with encryption',
|
||||||
secretMissing: 'Secret not configured',
|
secretMissing: 'Secret not configured',
|
||||||
|
secretUnreadable: 'Secret saved, but currently unreadable',
|
||||||
readOnly:
|
readOnly:
|
||||||
'This channel is managed by environment variables. Change the launch environment and restart the app.',
|
'This channel is managed by environment variables. Change the launch environment and restart the app.',
|
||||||
enable: 'Enable the {{channel}} channel',
|
enable: 'Enable the {{channel}} channel',
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ export const settings = {
|
|||||||
none: 'Not configured',
|
none: 'Not configured',
|
||||||
encrypted: 'Encrypted in secure system storage',
|
encrypted: 'Encrypted in secure system storage',
|
||||||
environment: 'Provided by an environment variable',
|
environment: 'Provided by an environment variable',
|
||||||
|
unreadable: 'Saved, but currently unreadable',
|
||||||
configuredPlaceholder: 'Configured; leave blank to keep it',
|
configuredPlaceholder: 'Configured; leave blank to keep it',
|
||||||
enterApiKey: 'Enter API Key',
|
enterApiKey: 'Enter API Key',
|
||||||
noAuthentication: 'No authentication',
|
noAuthentication: 'No authentication',
|
||||||
@@ -224,6 +225,7 @@ export const settings = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
documentParsing: {
|
documentParsing: {
|
||||||
|
loading: 'Loading…',
|
||||||
status: {
|
status: {
|
||||||
title: 'Runtime status',
|
title: 'Runtime status',
|
||||||
description: 'Capabilities currently available on this device',
|
description: 'Capabilities currently available on this device',
|
||||||
@@ -409,6 +411,7 @@ export const settings = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
profile: {
|
profile: {
|
||||||
|
seededDefaultName: 'Default model',
|
||||||
generatedName: 'Model connection {{count}}',
|
generatedName: 'Model connection {{count}}',
|
||||||
title: 'LLM model connections',
|
title: 'LLM model connections',
|
||||||
description:
|
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 = {
|
export const app = {
|
||||||
|
brand: {
|
||||||
|
desktopWorkspace: '桌面工作区'
|
||||||
|
},
|
||||||
notifications: {
|
notifications: {
|
||||||
success: '成功',
|
success: '成功',
|
||||||
error: '错误',
|
error: '错误',
|
||||||
@@ -121,6 +124,7 @@ export const app = {
|
|||||||
user: '用户',
|
user: '用户',
|
||||||
assistantResult: '助手成果 {{index}}',
|
assistantResult: '助手成果 {{index}}',
|
||||||
welcome: {
|
welcome: {
|
||||||
|
eyebrow: 'GOODBUDDY 工作台',
|
||||||
title: '今天想一起完成什么?',
|
title: '今天想一起完成什么?',
|
||||||
description:
|
description:
|
||||||
'快速提问、梳理信息,或连接 OpenCode 使用文件搜索和开发工具。'
|
'快速提问、梳理信息,或连接 OpenCode 使用文件搜索和开发工具。'
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export const integrations = {
|
|||||||
environmentSource: '由环境变量提供',
|
environmentSource: '由环境变量提供',
|
||||||
secretSaved: 'Secret 已加密保存',
|
secretSaved: 'Secret 已加密保存',
|
||||||
secretMissing: 'Secret 尚未配置',
|
secretMissing: 'Secret 尚未配置',
|
||||||
|
secretUnreadable: 'Secret 已保存,但当前无法读取',
|
||||||
readOnly:
|
readOnly:
|
||||||
'当前通道由环境变量管理。请在启动环境中修改配置后重启应用。',
|
'当前通道由环境变量管理。请在启动环境中修改配置后重启应用。',
|
||||||
enable: '启用{{channel}}通道',
|
enable: '启用{{channel}}通道',
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ export const settings = {
|
|||||||
none: '尚未配置',
|
none: '尚未配置',
|
||||||
encrypted: '已由系统安全存储加密',
|
encrypted: '已由系统安全存储加密',
|
||||||
environment: '由环境变量提供',
|
environment: '由环境变量提供',
|
||||||
|
unreadable: '已保存,但当前无法读取',
|
||||||
configuredPlaceholder: '已配置,留空保持不变',
|
configuredPlaceholder: '已配置,留空保持不变',
|
||||||
enterApiKey: '输入 API Key',
|
enterApiKey: '输入 API Key',
|
||||||
noAuthentication: '无需认证',
|
noAuthentication: '无需认证',
|
||||||
@@ -205,6 +206,7 @@ export const settings = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
documentParsing: {
|
documentParsing: {
|
||||||
|
loading: '正在加载…',
|
||||||
status: {
|
status: {
|
||||||
title: '运行状态',
|
title: '运行状态',
|
||||||
description: '显示当前设备实际可用的解析能力',
|
description: '显示当前设备实际可用的解析能力',
|
||||||
@@ -372,6 +374,7 @@ export const settings = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
profile: {
|
profile: {
|
||||||
|
seededDefaultName: '默认模型',
|
||||||
generatedName: '模型连接 {{count}}',
|
generatedName: '模型连接 {{count}}',
|
||||||
title: 'LLM 模型连接',
|
title: 'LLM 模型连接',
|
||||||
description:
|
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 magicNotes from './locales/zh-CN/magicNotes'
|
||||||
import integrations from './locales/zh-CN/integrations'
|
import integrations from './locales/zh-CN/integrations'
|
||||||
import workspace from './locales/zh-CN/workspace'
|
import workspace from './locales/zh-CN/workspace'
|
||||||
|
import warnings from './locales/zh-CN/warnings'
|
||||||
|
|
||||||
declare module 'i18next' {
|
declare module 'i18next' {
|
||||||
interface CustomTypeOptions {
|
interface CustomTypeOptions {
|
||||||
@@ -22,6 +23,7 @@ declare module 'i18next' {
|
|||||||
magicNotes: typeof magicNotes
|
magicNotes: typeof magicNotes
|
||||||
integrations: typeof integrations
|
integrations: typeof integrations
|
||||||
workspace: typeof workspace
|
workspace: typeof workspace
|
||||||
|
warnings: typeof warnings
|
||||||
}
|
}
|
||||||
returnNull: false
|
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 { z } from 'zod'
|
||||||
import { magicNoteCommentFormatSchema } from './magic-notes-contracts'
|
import { magicNoteCommentFormatSchema } from './magic-notes-contracts'
|
||||||
|
import { settingsWarningsSchema } from './settings-warning-contracts'
|
||||||
|
|
||||||
export const magicNoteCommentModeSchema = z.enum([
|
export const magicNoteCommentModeSchema = z.enum([
|
||||||
'immediate',
|
'immediate',
|
||||||
@@ -11,7 +12,7 @@ export type MagicNoteCommentMode = z.infer<
|
|||||||
typeof magicNoteCommentModeSchema
|
typeof magicNoteCommentModeSchema
|
||||||
>
|
>
|
||||||
|
|
||||||
export const applicationSettingsSchema = z
|
const applicationPreferencesSchema = z
|
||||||
.object({
|
.object({
|
||||||
checkUpdatesOnStartup: z.boolean(),
|
checkUpdatesOnStartup: z.boolean(),
|
||||||
magicNotesEnabled: z.boolean(),
|
magicNotesEnabled: z.boolean(),
|
||||||
@@ -20,7 +21,13 @@ export const applicationSettingsSchema = z
|
|||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
|
|
||||||
export const applicationSettingsUpdateSchema = applicationSettingsSchema
|
export const applicationSettingsSchema = applicationPreferencesSchema
|
||||||
|
.extend({
|
||||||
|
warnings: settingsWarningsSchema.optional()
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
export const applicationSettingsUpdateSchema = applicationPreferencesSchema
|
||||||
.partial()
|
.partial()
|
||||||
.refine((input) => Object.keys(input).length > 0, {
|
.refine((input) => Object.keys(input).length > 0, {
|
||||||
message: 'At least one application setting is required'
|
message: 'At least one application setting is required'
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { settingsWarningsSchema } from './settings-warning-contracts'
|
||||||
|
|
||||||
const controlCharacterFreeString = (maximumLength: number) =>
|
const controlCharacterFreeString = (maximumLength: number) =>
|
||||||
z
|
z
|
||||||
@@ -331,7 +332,8 @@ export const capabilitySnapshotSchema = z
|
|||||||
.array(computerCapabilityConfigSummarySchema)
|
.array(computerCapabilityConfigSummarySchema)
|
||||||
.max(2)
|
.max(2)
|
||||||
.optional(),
|
.optional(),
|
||||||
browserProfiles: browserProfilesSummarySchema.optional()
|
browserProfiles: browserProfilesSummarySchema.optional(),
|
||||||
|
warnings: settingsWarningsSchema.optional()
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
export type CapabilitySnapshot = z.infer<typeof capabilitySnapshotSchema>
|
export type CapabilitySnapshot = z.infer<typeof capabilitySnapshotSchema>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
projectChannelSchema,
|
projectChannelSchema,
|
||||||
type ProjectChannel
|
type ProjectChannel
|
||||||
} from './assistant-contracts'
|
} from './assistant-contracts'
|
||||||
|
import { settingsWarningsSchema } from './settings-warning-contracts'
|
||||||
|
|
||||||
export const CHANNEL_SETTINGS_LIMITS = {
|
export const CHANNEL_SETTINGS_LIMITS = {
|
||||||
maximumIdentifierLength: 256,
|
maximumIdentifierLength: 256,
|
||||||
@@ -106,7 +107,8 @@ export type ChannelSettingsApply = z.infer<
|
|||||||
export const channelCredentialSourceSchema = z.enum([
|
export const channelCredentialSourceSchema = z.enum([
|
||||||
'none',
|
'none',
|
||||||
'encrypted',
|
'encrypted',
|
||||||
'environment'
|
'environment',
|
||||||
|
'unreadable'
|
||||||
])
|
])
|
||||||
export type ChannelCredentialSource = z.infer<
|
export type ChannelCredentialSource = z.infer<
|
||||||
typeof channelCredentialSourceSchema
|
typeof channelCredentialSourceSchema
|
||||||
@@ -186,12 +188,7 @@ export const channelSettingsSnapshotSchema = z
|
|||||||
weixin: weixinChannelSettingsSchema,
|
weixin: weixinChannelSettingsSchema,
|
||||||
wecom: weComChannelSettingsSchema,
|
wecom: weComChannelSettingsSchema,
|
||||||
dingtalk: dingTalkChannelSettingsSchema,
|
dingtalk: dingTalkChannelSettingsSchema,
|
||||||
warning: z
|
warnings: settingsWarningsSchema.optional()
|
||||||
.string()
|
|
||||||
.trim()
|
|
||||||
.min(1)
|
|
||||||
.max(CHANNEL_SETTINGS_LIMITS.maximumWarningLength)
|
|
||||||
.optional()
|
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
export type ChannelSettingsSnapshot = z.infer<
|
export type ChannelSettingsSnapshot = z.infer<
|
||||||
|
|||||||
+27
-5
@@ -86,6 +86,7 @@ import type {
|
|||||||
DocumentParsingSnapshot,
|
DocumentParsingSnapshot,
|
||||||
DocumentParsingTestPurpose
|
DocumentParsingTestPurpose
|
||||||
} from './document-parsing-contracts'
|
} from './document-parsing-contracts'
|
||||||
|
import type { SettingsWarning } from './settings-warning-contracts'
|
||||||
import type {
|
import type {
|
||||||
KnowledgeChunkDeleteInput,
|
KnowledgeChunkDeleteInput,
|
||||||
KnowledgeChunkPage,
|
KnowledgeChunkPage,
|
||||||
@@ -601,7 +602,19 @@ export type ModelConnectionSettings = {
|
|||||||
supportsImageInput?: boolean
|
supportsImageInput?: boolean
|
||||||
imageGenerationQuality: ImageGenerationQuality
|
imageGenerationQuality: ImageGenerationQuality
|
||||||
apiKeyConfigured: boolean
|
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 = {
|
export type RuntimeSettings = {
|
||||||
@@ -625,22 +638,31 @@ export type RuntimeSettings = {
|
|||||||
knowledgeEmbeddingBaseUrl: string
|
knowledgeEmbeddingBaseUrl: string
|
||||||
knowledgeEmbeddingModel: string
|
knowledgeEmbeddingModel: string
|
||||||
knowledgeEmbeddingApiKeyConfigured: boolean
|
knowledgeEmbeddingApiKeyConfigured: boolean
|
||||||
knowledgeEmbeddingCredentialSource: 'none' | 'encrypted' | 'environment'
|
knowledgeEmbeddingCredentialSource:
|
||||||
|
| 'none'
|
||||||
|
| 'encrypted'
|
||||||
|
| 'environment'
|
||||||
|
| 'unreadable'
|
||||||
knowledgeRerankEnabled?: boolean
|
knowledgeRerankEnabled?: boolean
|
||||||
knowledgeRerankEndpoint?: string
|
knowledgeRerankEndpoint?: string
|
||||||
knowledgeRerankModel?: string
|
knowledgeRerankModel?: string
|
||||||
knowledgeRerankApiKeyConfigured?: boolean
|
knowledgeRerankApiKeyConfigured?: boolean
|
||||||
knowledgeRerankCredentialSource?: 'none' | 'encrypted' | 'environment'
|
knowledgeRerankCredentialSource?:
|
||||||
|
| 'none'
|
||||||
|
| 'encrypted'
|
||||||
|
| 'environment'
|
||||||
|
| 'unreadable'
|
||||||
workspacePath: string
|
workspacePath: string
|
||||||
apiKeyConfigured: boolean
|
apiKeyConfigured: boolean
|
||||||
credentialSource: 'none' | 'encrypted' | 'environment'
|
credentialSource: 'none' | 'encrypted' | 'environment' | 'unreadable'
|
||||||
modelProfiles: ModelConnectionSettings[]
|
modelProfiles: ModelConnectionSettings[]
|
||||||
defaultModelProfileId: string
|
defaultModelProfileId: string
|
||||||
opencodeModelSource: RuntimeModelSource
|
opencodeModelSource: RuntimeModelSource
|
||||||
continueModelSource: RuntimeModelSource
|
continueModelSource: RuntimeModelSource
|
||||||
secureStorageAvailable: boolean
|
secureStorageAvailable: boolean
|
||||||
toolApproval: RuntimeSettingsInput['toolApproval']
|
toolApproval: RuntimeSettingsInput['toolApproval']
|
||||||
warning?: string
|
configured?: ConfiguredRuntimeSettings
|
||||||
|
warnings?: SettingsWarning[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ContextAttachment = ConversationAttachment
|
export type ContextAttachment = ConversationAttachment
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { settingsWarningsSchema } from './settings-warning-contracts'
|
||||||
|
|
||||||
export const maximumDocumentExtractedCharacters = 5_000_000
|
export const maximumDocumentExtractedCharacters = 5_000_000
|
||||||
export const maximumDocumentOcrSectionCharacters = 1_000_000
|
export const maximumDocumentOcrSectionCharacters = 1_000_000
|
||||||
@@ -192,7 +193,8 @@ export const documentParsingSnapshotSchema = z
|
|||||||
.object({
|
.object({
|
||||||
settings: documentParsingSettingsSchema,
|
settings: documentParsingSettingsSchema,
|
||||||
status: documentParsingStatusSchema,
|
status: documentParsingStatusSchema,
|
||||||
ocrModels: documentOcrModelSnapshotSchema
|
ocrModels: documentOcrModelSnapshotSchema,
|
||||||
|
warnings: settingsWarningsSchema.optional()
|
||||||
})
|
})
|
||||||
.strict()
|
.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