chore: prepare GoodBuddy 0.8.2
Cross-platform packages / Validate source (push) Waiting to run
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, macos, macos-15-intel) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / Publish GitHub Release (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions

This commit is contained in:
lofyer
2026-08-06 22:47:13 +08:00
parent 8d00e6371d
commit b8fc7bc86e
114 changed files with 22916 additions and 1560 deletions
@@ -0,0 +1,32 @@
import { z } from 'zod'
export const applicationSettingsSchema = z
.object({
checkUpdatesOnStartup: z.boolean()
})
.strict()
export type ApplicationSettings = z.infer<
typeof applicationSettingsSchema
>
export type VersionCheckFile = {
name: string
size: number
sha256: string
}
export type VersionCheckTarget = {
platform: 'windows' | 'macos' | 'linux'
arch: 'x64' | 'arm64'
formats: string[]
files: VersionCheckFile[]
}
export type VersionCheckResult = {
updateAvailable: boolean
currentVersion: string
latestVersion: string
releaseUrl: string
target: VersionCheckTarget
}
+3
View File
@@ -1,4 +1,5 @@
import { z } from 'zod'
import { agentRuntimeSelectionSchema } from './runtime-selection-contracts'
export const assistantIdSchema = z.string().uuid()
export const workModeSchema = z.enum(['ask', 'plan', 'execute'])
@@ -64,6 +65,7 @@ export const conversationSnapshotSchema = z
.object({
id: assistantIdSchema,
projectId: assistantIdSchema.optional(),
runtimeSelection: agentRuntimeSelectionSchema.optional(),
title: z.string().trim().min(1).max(200),
updatedAt: z.number().int().nonnegative(),
messages: z
@@ -488,6 +490,7 @@ export const expertCreateSchema = z
name: z.string().trim().min(1).max(80),
description: z.string().trim().max(500),
systemInstructions: z.string().trim().min(1).max(20_000),
modelProfileId: assistantIdSchema.optional(),
routingKeywords: z
.array(routingKeywordSchema)
.max(32)
+24
View File
@@ -0,0 +1,24 @@
import type { RuntimeTarget } from './capability-contracts'
export type BuiltinMcpServerSummary = {
id: string
name: string
description: string
tools: readonly string[]
assignments: readonly RuntimeTarget[]
access: 'read'
authorization: 'conversation-scoped'
}
export const builtinMcpServers = [
{
id: 'knowledge-base',
name: '知识库 MCP',
description:
'搜索当前对话明确选择的知识库,并返回可核验的来源与证据引用。',
tools: ['knowledge_search'],
assignments: ['model', 'opencode', 'continue'],
access: 'read',
authorization: 'conversation-scoped'
}
] as const satisfies readonly BuiltinMcpServerSummary[]
@@ -0,0 +1,120 @@
import { describe, expect, it } from 'vitest'
import {
CHANNEL_SETTINGS_LIMITS,
channelConnectionTestResultSchema,
channelSecretUpdateSchema,
channelSettingsApplySchema,
channelSettingsSnapshotSchema
} from './channel-settings-contracts'
describe('channel settings contracts', () => {
it('accepts bounded strict WeCom and DingTalk updates', () => {
expect(
channelSettingsApplySchema.parse({
wecom: {
enabled: true,
botId: ' bot-id ',
secret: { action: 'replace', value: ' secret ' },
allowedSenderIds: [' user-1 ', 'user-1'],
allowGroupMessages: false
},
dingtalk: {
enabled: false,
clientId: '',
secret: { action: 'clear' },
allowedSenderIds: [],
allowGroupMessages: true
}
})
).toEqual({
wecom: {
enabled: true,
botId: 'bot-id',
secret: { action: 'replace', value: 'secret' },
allowedSenderIds: ['user-1'],
allowGroupMessages: false
},
dingtalk: {
enabled: false,
clientId: '',
secret: { action: 'clear' },
allowedSenderIds: [],
allowGroupMessages: true
}
})
})
it('rejects unknown fields and unbounded values', () => {
expect(() =>
channelSecretUpdateSchema.parse({
action: 'keep',
value: 'must-not-be-accepted'
})
).toThrow()
expect(() =>
channelSettingsApplySchema.parse({
wecom: {
enabled: true,
botId: 'x'.repeat(
CHANNEL_SETTINGS_LIMITS.maximumIdentifierLength + 1
),
secret: { action: 'keep' },
allowedSenderIds: [],
allowGroupMessages: false
}
})
).toThrow()
expect(() => channelSettingsApplySchema.parse({})).toThrow()
})
it('models public credential source and runtime status without secrets', () => {
const snapshot = channelSettingsSnapshotSchema.parse({
wecom: {
enabled: true,
botId: 'bot-id',
secretConfigured: true,
source: 'environment',
readOnly: true,
allowedSenderIds: ['user-1'],
allowGroupMessages: false,
status: { state: 'running' }
},
dingtalk: {
enabled: false,
clientId: '',
secretConfigured: false,
source: 'none',
readOnly: false,
allowedSenderIds: [],
allowGroupMessages: false,
status: {
state: 'error',
lastError: '连接失败'
}
}
})
expect(JSON.stringify(snapshot)).not.toContain('secret":')
})
it('requires errors only for failed connection tests', () => {
expect(
channelConnectionTestResultSchema.parse({
channel: 'wecom',
ok: true
})
).toEqual({ channel: 'wecom', ok: true })
expect(() =>
channelConnectionTestResultSchema.parse({
channel: 'dingtalk',
ok: false
})
).toThrow()
expect(() =>
channelConnectionTestResultSchema.parse({
channel: 'dingtalk',
ok: true,
error: '不应存在'
})
).toThrow()
})
})
+196
View File
@@ -0,0 +1,196 @@
import { z } from 'zod'
export const CHANNEL_SETTINGS_LIMITS = {
maximumIdentifierLength: 256,
maximumSecretLength: 4_096,
maximumAllowedSenders: 100,
maximumStatusMessageLength: 500,
maximumWarningLength: 500
} as const
export const managedChannelSchema = z.enum(['wecom', 'dingtalk'])
export type ManagedChannel = z.infer<typeof managedChannelSchema>
const identifierSchema = z
.string()
.trim()
.max(CHANNEL_SETTINGS_LIMITS.maximumIdentifierLength)
const senderIdentifierSchema = identifierSchema.min(1)
export const allowedSenderIdsSchema = z
.array(senderIdentifierSchema)
.max(CHANNEL_SETTINGS_LIMITS.maximumAllowedSenders)
.transform((values) => [...new Set(values)])
export const channelSecretUpdateSchema = z.discriminatedUnion('action', [
z.object({ action: z.literal('keep') }).strict(),
z
.object({
action: z.literal('replace'),
value: z
.string()
.trim()
.min(1)
.max(CHANNEL_SETTINGS_LIMITS.maximumSecretLength)
})
.strict(),
z.object({ action: z.literal('clear') }).strict()
])
export type ChannelSecretUpdate = z.infer<
typeof channelSecretUpdateSchema
>
const editableChannelFields = {
enabled: z.boolean(),
secret: channelSecretUpdateSchema,
allowedSenderIds: allowedSenderIdsSchema,
allowGroupMessages: z.boolean()
} as const
export const weComChannelSettingsInputSchema = z
.object({
...editableChannelFields,
botId: identifierSchema
})
.strict()
export type WeComChannelSettingsInput = z.infer<
typeof weComChannelSettingsInputSchema
>
export const dingTalkChannelSettingsInputSchema = z
.object({
...editableChannelFields,
clientId: identifierSchema
})
.strict()
export type DingTalkChannelSettingsInput = z.infer<
typeof dingTalkChannelSettingsInputSchema
>
export const channelSettingsApplySchema = z
.object({
wecom: weComChannelSettingsInputSchema.optional(),
dingtalk: dingTalkChannelSettingsInputSchema.optional()
})
.strict()
.refine(
(input) => input.wecom !== undefined || input.dingtalk !== undefined,
'至少需要提供一个通道设置'
)
export type ChannelSettingsApply = z.infer<
typeof channelSettingsApplySchema
>
export const channelCredentialSourceSchema = z.enum([
'none',
'encrypted',
'environment'
])
export type ChannelCredentialSource = z.infer<
typeof channelCredentialSourceSchema
>
export const channelRuntimeStateSchema = z.enum([
'disabled',
'stopped',
'starting',
'running',
'error'
])
export type ChannelRuntimeState = z.infer<
typeof channelRuntimeStateSchema
>
export const channelRuntimeStatusSchema = z
.object({
state: channelRuntimeStateSchema,
lastError: z
.string()
.trim()
.min(1)
.max(CHANNEL_SETTINGS_LIMITS.maximumStatusMessageLength)
.optional()
})
.strict()
export type ChannelRuntimeStatus = z.infer<
typeof channelRuntimeStatusSchema
>
const publicChannelFields = {
enabled: z.boolean(),
secretConfigured: z.boolean(),
source: channelCredentialSourceSchema,
readOnly: z.boolean(),
allowedSenderIds: allowedSenderIdsSchema,
allowGroupMessages: z.boolean(),
status: channelRuntimeStatusSchema
} as const
export const weComChannelSettingsSchema = z
.object({
...publicChannelFields,
botId: identifierSchema
})
.strict()
export type WeComChannelSettings = z.infer<
typeof weComChannelSettingsSchema
>
export const dingTalkChannelSettingsSchema = z
.object({
...publicChannelFields,
clientId: identifierSchema
})
.strict()
export type DingTalkChannelSettings = z.infer<
typeof dingTalkChannelSettingsSchema
>
export const channelSettingsSnapshotSchema = z
.object({
wecom: weComChannelSettingsSchema,
dingtalk: dingTalkChannelSettingsSchema,
warning: z
.string()
.trim()
.min(1)
.max(CHANNEL_SETTINGS_LIMITS.maximumWarningLength)
.optional()
})
.strict()
export type ChannelSettingsSnapshot = z.infer<
typeof channelSettingsSnapshotSchema
>
export const channelConnectionTestResultSchema = z
.object({
channel: managedChannelSchema,
ok: z.boolean(),
error: z
.string()
.trim()
.min(1)
.max(CHANNEL_SETTINGS_LIMITS.maximumStatusMessageLength)
.optional()
})
.strict()
.superRefine((result, context) => {
if (result.ok && result.error !== undefined) {
context.addIssue({
code: 'custom',
path: ['error'],
message: '成功结果不能包含错误'
})
}
if (!result.ok && result.error === undefined) {
context.addIssue({
code: 'custom',
path: ['error'],
message: '失败结果必须包含错误'
})
}
})
export type ChannelConnectionTestResult = z.infer<
typeof channelConnectionTestResultSchema
>
+144 -25
View File
@@ -35,6 +35,33 @@ import {
type ExpertCreateInput,
type ExpertUpdateInput
} from './assistant-contracts'
import type {
ChannelConnectionTestResult,
ChannelSettingsApply,
ChannelSettingsSnapshot,
DingTalkChannelSettingsInput,
ManagedChannel,
WeComChannelSettingsInput
} from './channel-settings-contracts'
import { isIntranetHostname } from './intranet-hostname'
import type {
ApplicationSettings,
VersionCheckResult
} from './application-settings-contracts'
import type {
SpeechModelSnapshot,
SpeechTranscriptionInput,
SpeechTranscriptionResult
} from './speech-model-contracts'
import type {
EmbeddingDiagnosticResult,
EmbeddingIndexStatus,
EmbeddingSettingsSnapshot
} from './embedding-contracts'
import {
agentRuntimeSelectionSchema,
type AgentRuntimeSelection
} from './runtime-selection-contracts'
export const workspaceRelativePathSchema = z
.string()
@@ -77,8 +104,13 @@ export const agentRequestSchema = z
expertId: z.string().uuid().optional(),
teamMode: z.boolean().optional(),
smartRouting: z.boolean().optional(),
runtimeSelection: agentRuntimeSelectionSchema.optional(),
workMode: workModeSchema.optional(),
prompt: z.string().trim().min(1).max(100_000),
knowledgeLibraryIds: z
.array(z.string().uuid())
.max(20)
.default([]),
contextIds: z.array(z.string().uuid()).max(8).optional(),
history: z
.array(
@@ -108,7 +140,7 @@ export const agentRequestSchema = z
}
})
export type AgentRequest = z.infer<typeof agentRequestSchema>
export type AgentRequest = z.input<typeof agentRequestSchema>
export const runtimeProviderSchema = z.enum([
'auto',
@@ -140,6 +172,11 @@ export const imageGenerationQualitySchema = z.enum([
'high'
])
export type ModelProtocol = z.infer<typeof modelProtocolSchema>
export function isAgentRuntimeModelProtocol(
protocol: ModelProtocol
): boolean {
return protocol !== 'openai-images-generations'
}
export type ModelAuthentication = z.infer<
typeof modelAuthenticationSchema
>
@@ -148,16 +185,17 @@ export type ImageGenerationQuality = z.infer<
>
export const defaultModelProfileId =
'00000000-0000-4000-8000-000000000001'
export const modelProfileIdSchema = z.string().uuid()
export const defaultRuntimeSettings = {
provider: 'auto',
provider: 'model',
modelBaseUrl: 'https://bigtoken.ai',
modelName: 'sonnet-5',
modelProtocol: 'anthropic-messages',
modelAuthentication: 'api-key',
imageGenerationQuality: 'auto',
opencodeBaseUrl: '',
opencodeEmbedded: false,
opencodeEmbedded: true,
opencodeBinaryPath: '',
opencodeConfigPath: '',
continueBinaryPath: '',
@@ -165,6 +203,7 @@ export const defaultRuntimeSettings = {
continueMode: 'chat',
runtimeSandboxMode: 'auto',
subagentSmartRoutingEnabled: false,
intranetCompatibilityEnabled: false,
knowledgeEmbeddingEnabled: false,
knowledgeEmbeddingBaseUrl:
'http://127.0.0.1:11434/v1/embeddings',
@@ -196,6 +235,17 @@ export type RuntimeFileSelectionKind = z.infer<
typeof runtimeFileSelectionKindSchema
>
export const runtimeConfigActionInputSchema = z
.object({
runtime: z.enum(['opencode', 'continue']),
action: z.enum(['open-file', 'show-file', 'open-directory'])
})
.strict()
export type RuntimeConfigActionInput = z.infer<
typeof runtimeConfigActionInputSchema
>
const modelApiKeyUpdateSchema = z.discriminatedUnion('action', [
z.object({ action: z.literal('keep') }).strict(),
z
@@ -223,7 +273,7 @@ const modelApiKeyUpdateSchema = z.discriminatedUnion('action', [
const modelProfileInputSchema = z
.object({
id: z.string().uuid(),
id: modelProfileIdSchema,
name: z.string().trim().min(1).max(64),
baseUrl: z.string().url().max(2_048),
modelName: z
@@ -274,6 +324,7 @@ export const runtimeSettingsInputSchema = z
continueMode: continueModeSchema,
runtimeSandboxMode: runtimeSandboxModeSchema,
subagentSmartRoutingEnabled: z.boolean().optional(),
intranetCompatibilityEnabled: z.boolean().default(false),
knowledgeEmbeddingEnabled: z.boolean(),
knowledgeEmbeddingBaseUrl: z.string().url().max(2_048),
knowledgeEmbeddingModel: z
@@ -286,7 +337,7 @@ export const runtimeSettingsInputSchema = z
workspacePath: z.string().trim().min(1).max(4_096),
apiKey: modelApiKeyUpdateSchema,
modelProfiles: z.array(modelProfileInputSchema).min(1).max(20).optional(),
defaultModelProfileId: z.string().uuid().optional(),
defaultModelProfileId: modelProfileIdSchema.optional(),
opencodeModelSource: runtimeModelSourceSchema.optional(),
continueModelSource: runtimeModelSourceSchema.optional(),
toolApproval: toolApprovalPolicySchema
@@ -316,8 +367,13 @@ export const runtimeSettingsInputSchema = z
hostname === '[::1]' ||
/^127(?:\.\d{1,3}){3}$/u.test(hostname)
if (
(url.protocol !== 'https:' &&
!(url.protocol === 'http:' && loopback)) ||
!(
url.protocol === 'https:' ||
(url.protocol === 'http:' &&
(loopback ||
(settings.intranetCompatibilityEnabled &&
isIntranetHostname(hostname))))
) ||
url.username ||
url.password ||
url.search ||
@@ -326,8 +382,9 @@ export const runtimeSettingsInputSchema = z
context.addIssue({
code: 'custom',
path: endpoint.path,
message:
'模型服务地址必须使用 HTTPS;仅本机回环地址可使用 HTTP,且不得包含凭据、查询参数或片段'
message: settings.intranetCompatibilityEnabled
? '模型服务地址必须使用 HTTP(S),且不得包含凭据、查询参数或片段'
: '模型服务地址必须使用 HTTPS;仅本机回环地址可使用 HTTP,且不得包含凭据、查询参数或片段'
})
}
}
@@ -388,14 +445,13 @@ export const runtimeSettingsInputSchema = z
: undefined
if (
opencodeProfile &&
(opencodeProfile.protocol !== 'anthropic-messages' ||
opencodeProfile.authentication !== 'api-key')
!isAgentRuntimeModelProtocol(opencodeProfile.protocol)
) {
context.addIssue({
code: 'custom',
path: ['opencodeModelSource'],
message:
'OpenCode 独立模型连接仅支持需要 API Key 的 Anthropic Messages 协议'
'OpenCode 独立模型连接仅支持文本对话协议,不支持图像生成协议'
})
}
const continueSource = settings.continueModelSource
@@ -407,14 +463,13 @@ export const runtimeSettingsInputSchema = z
: undefined
if (
continueProfile &&
continueProfile.protocol !== 'anthropic-messages' &&
continueProfile.protocol !== 'openai-chat-completions'
!isAgentRuntimeModelProtocol(continueProfile.protocol)
) {
context.addIssue({
code: 'custom',
path: ['continueModelSource'],
message:
'Continue 独立模型连接仅支持 Anthropic Messages 或 OpenAI 兼容 Chat Completions'
'Continue 独立模型连接仅支持文本对话协议,不支持图像生成协议'
})
}
}
@@ -449,11 +504,14 @@ export const runtimeSettingsInputSchema = z
embeddingHost === '[::1]' ||
/^127(?:\.\d{1,3}){3}$/u.test(embeddingHost)
if (
(embeddingUrl.protocol !== 'https:' &&
!(
embeddingUrl.protocol === 'http:' &&
(loopback || privateIpv4)
)) ||
!(
embeddingUrl.protocol === 'https:' ||
(embeddingUrl.protocol === 'http:' &&
((settings.intranetCompatibilityEnabled &&
isIntranetHostname(embeddingHost)) ||
loopback ||
privateIpv4))
) ||
embeddingUrl.username ||
embeddingUrl.password ||
embeddingUrl.search ||
@@ -464,13 +522,14 @@ export const runtimeSettingsInputSchema = z
context.addIssue({
code: 'custom',
path: ['knowledgeEmbeddingBaseUrl'],
message:
'向量接口 URL 必须是完整的 HTTPS 端点;本机或私有网络可使用 HTTP,且不得包含凭据、查询参数或片段'
message: settings.intranetCompatibilityEnabled
? '向量接口 URL 必须是完整的 HTTP(S) 端点,且不得包含凭据、查询参数或片段'
: '向量接口 URL 必须是完整的 HTTPS 端点;本机或私有网络可使用 HTTP,且不得包含凭据、查询参数或片段'
})
}
})
export type RuntimeSettingsInput = z.input<typeof runtimeSettingsInputSchema>
export type RuntimeSettingsInput = z.infer<typeof runtimeSettingsInputSchema>
export type RuntimeModelSource = z.infer<typeof runtimeModelSourceSchema>
@@ -502,6 +561,7 @@ export type RuntimeSettings = {
continueMode: RuntimeSettingsInput['continueMode']
runtimeSandboxMode: RuntimeSettingsInput['runtimeSandboxMode']
subagentSmartRoutingEnabled: boolean
intranetCompatibilityEnabled: boolean
knowledgeEmbeddingEnabled: boolean
knowledgeEmbeddingBaseUrl: string
knowledgeEmbeddingModel: string
@@ -646,6 +706,11 @@ export type AgentEvent =
kind: 'image'
title: string
}
| {
requestId: string
type: 'source-references'
references: KnowledgeSearchReference[]
}
| {
requestId: string
type: 'done'
@@ -850,7 +915,9 @@ export type DesktopApi = {
onOpenSettings: (listener: () => void) => () => void
}
agent: {
getStatus: () => Promise<AgentRuntimeStatus>
getStatus: (
selection?: AgentRuntimeSelection
) => Promise<AgentRuntimeStatus>
run: (request: AgentRequest) => Promise<void>
cancel: (requestId: string) => Promise<void>
respondApproval: (
@@ -871,7 +938,59 @@ export type DesktopApi = {
selectRuntimeFile: (
kind: RuntimeFileSelectionKind
) => Promise<string | undefined>
testRuntime: () => Promise<AgentRuntimeStatus>
openRuntimeConfig: (input: RuntimeConfigActionInput) => Promise<void>
testModelConnection: (
profileId: string
) => Promise<AgentRuntimeStatus>
testRuntime: (
selection: AgentRuntimeSelection
) => Promise<AgentRuntimeStatus>
}
channels?: {
getSnapshot: () => Promise<ChannelSettingsSnapshot>
apply: (input: ChannelSettingsApply) => Promise<ChannelSettingsSnapshot>
testConnection: (
channel: ManagedChannel,
settings?: WeComChannelSettingsInput | DingTalkChannelSettingsInput
) => Promise<ChannelConnectionTestResult>
}
updates?: {
getSettings: () => Promise<ApplicationSettings>
updateSettings: (
input: ApplicationSettings
) => Promise<ApplicationSettings>
check: () => Promise<VersionCheckResult>
openReleasePage: () => Promise<void>
onResult: (
listener: (result: VersionCheckResult) => void
) => () => void
}
speechModels?: {
getSnapshot: () => Promise<SpeechModelSnapshot>
install: (modelId: string) => Promise<SpeechModelSnapshot>
cancel: (modelId: string) => Promise<boolean>
remove: (modelId: string) => Promise<SpeechModelSnapshot>
select: (modelId: string | null) => Promise<SpeechModelSnapshot>
importLocalDirectory: (
modelId: string
) => Promise<SpeechModelSnapshot | undefined>
openRepository: (modelId: string) => Promise<void>
openModelsDirectory: () => Promise<void>
}
speech?: {
transcribe: (
input: SpeechTranscriptionInput
) => Promise<SpeechTranscriptionResult>
cancel: (requestId: string) => Promise<boolean>
}
embeddings?: {
getSnapshot: () => Promise<EmbeddingSettingsSnapshot>
diagnose: () => Promise<EmbeddingDiagnosticResult>
rebuild: () => Promise<EmbeddingIndexStatus>
cancel: (jobId: string) => Promise<boolean>
onStatus: (
listener: (status: EmbeddingIndexStatus) => void
) => () => void
}
projects: {
list: (includeArchived?: boolean) => Promise<AssistantProject[]>
+123
View File
@@ -0,0 +1,123 @@
import { describe, expect, it } from 'vitest'
import {
embeddingDiagnosticResultSchema,
embeddingIndexJobSchema,
embeddingIndexStatusSchema,
embeddingSafeErrorSchema,
isEmbeddingIndexJobActive
} from './embedding-contracts'
describe('embedding contracts', () => {
it('accepts a real successful model test result', () => {
expect(
embeddingDiagnosticResultSchema.parse({
status: 'available',
provider: 'openai-compatible',
model: 'text-embedding-3-small',
checkedAt: 1_700_000_000_000,
latencyMs: 184,
dimensions: 1_536
})
).toMatchObject({
status: 'available',
latencyMs: 184,
dimensions: 1_536
})
expect(
embeddingDiagnosticResultSchema.safeParse({
status: 'available',
provider: 'provider',
model: 'model',
checkedAt: 1,
latencyMs: 1,
dimensions: 0,
reachable: true
}).success
).toBe(false)
})
it('bounds safe failures and excludes raw provider details', () => {
expect(
embeddingSafeErrorSchema.safeParse({
code: 'authentication',
message: '身份验证失败。',
retryable: false,
rawResponse: '{"api_key":"secret"}'
}).success
).toBe(false)
expect(
embeddingSafeErrorSchema.safeParse({
code: 'unknown',
message: 'x'.repeat(501),
retryable: false
}).success
).toBe(false)
})
it('validates queued, running and terminal index jobs', () => {
const base = {
id: 'job-1',
provider: 'provider',
model: 'model',
createdAt: 10
}
const queued = embeddingIndexJobSchema.parse({
...base,
status: 'queued',
progress: { completed: 0, total: 0, percent: 0 }
})
const running = embeddingIndexJobSchema.parse({
...base,
status: 'running',
startedAt: 11,
progress: { completed: 3, total: 4, percent: 75 }
})
expect(isEmbeddingIndexJobActive(queued)).toBe(true)
expect(isEmbeddingIndexJobActive(running)).toBe(true)
for (const status of ['completed', 'failed', 'cancelled'] as const) {
const result = embeddingIndexJobSchema.safeParse({
...base,
status,
startedAt: 11,
completedAt: 12,
progress:
status === 'completed'
? { completed: 4, total: 4, percent: 100 }
: { completed: 2, total: 4, percent: 50 },
...(status === 'failed'
? {
error: {
code: 'network',
message: '无法连接到向量服务。',
retryable: true
}
}
: {})
})
expect(result.success, status).toBe(true)
}
})
it('exposes only the persisted rebuild job in index status', () => {
const parsed = embeddingIndexStatusSchema.parse({
job: {
id: 'job-new',
status: 'running',
provider: 'provider',
model: 'new-model',
progress: { completed: 5, total: 20, percent: 25 },
createdAt: 11,
startedAt: 12
}
})
expect(parsed.job?.model).toBe('new-model')
expect(
embeddingIndexStatusSchema.safeParse({
job: null,
servingSnapshot: null
}).success
).toBe(false)
})
})
+203
View File
@@ -0,0 +1,203 @@
import { z } from 'zod'
const boundedLabelSchema = z.string().trim().min(1).max(256)
const timestampSchema = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER)
const countSchema = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER)
const safeEndpointSchema = z
.url()
.trim()
.max(2_048)
.refine((value) => {
const url = new URL(value)
return (
['http:', 'https:'].includes(url.protocol) &&
!url.username &&
!url.password
)
}, 'endpoint must be an HTTP URL without credentials')
export const embeddingErrorCodeSchema = z.enum([
'model_not_found',
'authentication',
'rate_limited',
'timeout',
'network',
'provider_unavailable',
'invalid_configuration',
'invalid_response',
'cancelled',
'unknown'
])
export type EmbeddingErrorCode = z.infer<typeof embeddingErrorCodeSchema>
export const embeddingSafeErrorSchema = z
.object({
code: embeddingErrorCodeSchema,
message: z.string().trim().min(1).max(500),
retryable: z.boolean(),
remedy: z.string().trim().min(1).max(500).optional()
})
.strict()
export type EmbeddingSafeError = z.infer<typeof embeddingSafeErrorSchema>
export const embeddingConfigurationSummarySchema = z
.object({
provider: boundedLabelSchema,
model: boundedLabelSchema,
endpoint: safeEndpointSchema.optional(),
credentialConfigured: z.boolean()
})
.strict()
export type EmbeddingConfigurationSummary = z.infer<
typeof embeddingConfigurationSummarySchema
>
const embeddingDiagnosticBase = {
provider: boundedLabelSchema,
model: boundedLabelSchema,
checkedAt: timestampSchema,
latencyMs: countSchema.max(120_000)
}
export const embeddingDiagnosticResultSchema = z.discriminatedUnion('status', [
z
.object({
...embeddingDiagnosticBase,
status: z.literal('available'),
dimensions: z.number().int().positive().max(8_192)
})
.strict(),
z
.object({
...embeddingDiagnosticBase,
status: z.literal('unavailable'),
error: embeddingSafeErrorSchema
})
.strict()
])
export type EmbeddingDiagnosticResult = z.infer<
typeof embeddingDiagnosticResultSchema
>
export const embeddingIndexJobStatusSchema = z.enum([
'queued',
'running',
'completed',
'failed',
'cancelled'
])
export type EmbeddingIndexJobStatus = z.infer<
typeof embeddingIndexJobStatusSchema
>
export const embeddingIndexProgressSchema = z
.object({
completed: countSchema,
total: countSchema,
percent: z.number().finite().min(0).max(100)
})
.strict()
.superRefine((value, context) => {
if (value.completed > value.total) {
context.addIssue({
code: 'custom',
message: 'completed must not exceed total',
path: ['completed']
})
}
const expected =
value.total === 0
? [0, 100]
: [(value.completed / value.total) * 100]
if (
expected.every(
(candidate) => Math.abs(value.percent - candidate) > 0.01
)
) {
context.addIssue({
code: 'custom',
message: 'percent must match completed and total',
path: ['percent']
})
}
})
export type EmbeddingIndexProgress = z.infer<
typeof embeddingIndexProgressSchema
>
export const embeddingIndexJobSchema = z
.object({
id: z.string().trim().min(1).max(256),
status: embeddingIndexJobStatusSchema,
provider: boundedLabelSchema,
model: boundedLabelSchema,
progress: embeddingIndexProgressSchema,
createdAt: timestampSchema,
startedAt: timestampSchema.optional(),
completedAt: timestampSchema.optional(),
error: embeddingSafeErrorSchema.optional()
})
.strict()
.superRefine((value, context) => {
if (value.status === 'queued' && value.startedAt !== undefined) {
context.addIssue({
code: 'custom',
message: 'a queued job must not have started',
path: ['startedAt']
})
}
if (
['completed', 'failed', 'cancelled'].includes(value.status) &&
value.completedAt === undefined
) {
context.addIssue({
code: 'custom',
message: 'a terminal job must have a completion time',
path: ['completedAt']
})
}
if (value.status === 'failed' && value.error === undefined) {
context.addIssue({
code: 'custom',
message: 'a failed job must include a safe error',
path: ['error']
})
}
if (value.status === 'completed' && value.progress.percent !== 100) {
context.addIssue({
code: 'custom',
message: 'a completed job must report 100 percent',
path: ['progress']
})
}
})
export type EmbeddingIndexJob = z.infer<typeof embeddingIndexJobSchema>
export const embeddingIndexStatusSchema = z
.object({
job: embeddingIndexJobSchema.nullable()
})
.strict()
export type EmbeddingIndexStatus = z.infer<
typeof embeddingIndexStatusSchema
>
export const embeddingSettingsSnapshotSchema = z
.object({
configuration: embeddingConfigurationSummarySchema,
indexStatus: embeddingIndexStatusSchema
})
.strict()
export type EmbeddingSettingsSnapshot = z.infer<
typeof embeddingSettingsSnapshotSchema
>
export const embeddingIndexJobRequestSchema = z
.object({
jobId: z.string().uuid()
})
.strict()
export const isEmbeddingIndexJobActive = (
job: EmbeddingIndexJob | null | undefined
): boolean => job?.status === 'queued' || job?.status === 'running'
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { isIntranetHostname } from './intranet-hostname'
describe('isIntranetHostname', () => {
it.each([
'localhost',
'printer',
'models.internal',
'models.corp.local',
'10.7.0.23',
'127.0.0.2',
'100.64.0.1',
'172.16.4.2',
'192.168.1.20',
'[fd12:3456::1]'
])('accepts the intranet host %s', (hostname) => {
expect(isIntranetHostname(hostname)).toBe(true)
})
it.each([
'models.example.com',
'8.8.8.8',
'169.254.169.254',
'100.100.100.200',
'[fd00:ec2::254]',
'metadata.google.internal'
])('rejects the public or metadata host %s', (hostname) => {
expect(isIntranetHostname(hostname)).toBe(false)
})
})
+95
View File
@@ -0,0 +1,95 @@
const INTRANET_HOST_SUFFIXES = [
'.home',
'.internal',
'.intranet',
'.lan',
'.local',
'.localdomain',
'.localhost'
] as const
const BLOCKED_HOSTNAMES = new Set([
'100.100.100.200',
'fd00:ec2::254',
'instance-data',
'instance-data.ec2.internal',
'metadata',
'metadata.aws.internal',
'metadata.google.internal'
])
function normalizeHostname(hostname: string): string {
const normalized = hostname.trim().toLowerCase().replace(/\.$/u, '')
return normalized.startsWith('[') && normalized.endsWith(']')
? normalized.slice(1, -1)
: normalized
}
function parseIpv4(hostname: string): readonly number[] | undefined {
const octets = hostname.split('.')
if (
octets.length !== 4 ||
octets.some(
(octet) =>
!/^(?:0|[1-9]\d{0,2})$/u.test(octet) ||
Number(octet) > 255
)
) {
return undefined
}
return octets.map(Number)
}
function isIntranetIpv4(hostname: string): boolean {
const octets = parseIpv4(hostname)
if (!octets) {
return false
}
const [first = -1, second = -1] = octets
return (
first === 10 ||
first === 127 ||
(first === 100 && second >= 64 && second <= 127) ||
(first === 172 && second >= 16 && second <= 31) ||
(first === 192 && second === 168)
)
}
function isIntranetIpv6(hostname: string): boolean {
const withoutZone = hostname.split('%', 1)[0] ?? ''
return (
withoutZone === '::1' ||
/^f[cd][0-9a-f]{2}(?::|$)/u.test(withoutZone)
)
}
export function isLoopbackHostname(hostname: string): boolean {
const normalized = normalizeHostname(hostname)
const ipv4 = parseIpv4(normalized)
return (
normalized === 'localhost' ||
normalized === '::1' ||
ipv4?.[0] === 127
)
}
export function isIntranetHostname(hostname: string): boolean {
const normalized = normalizeHostname(hostname)
if (!normalized || BLOCKED_HOSTNAMES.has(normalized)) {
return false
}
if (parseIpv4(normalized)) {
return isIntranetIpv4(normalized)
}
if (normalized.includes(':')) {
return isIntranetIpv6(normalized)
}
return (
isLoopbackHostname(normalized) ||
!normalized.includes('.') ||
INTRANET_HOST_SUFFIXES.some(
(suffix) =>
normalized === suffix.slice(1) || normalized.endsWith(suffix)
)
)
}
+25
View File
@@ -22,7 +22,32 @@ export const ipcChannels = {
runtimeSettingsSelectWorkspace: 'settings:runtime:select-workspace',
runtimeSettingsDetect: 'settings:runtime:detect',
runtimeSettingsSelectFile: 'settings:runtime:select-file',
runtimeSettingsOpenConfig: 'settings:runtime:open-config',
runtimeSettingsTestModel: 'settings:runtime:test-model',
runtimeSettingsTest: 'settings:runtime:test',
channelSettingsGet: 'settings:channels:get',
channelSettingsApply: 'settings:channels:apply',
channelSettingsTest: 'settings:channels:test',
applicationSettingsGet: 'settings:application:get',
applicationSettingsUpdate: 'settings:application:update',
versionCheck: 'application:update:check',
versionOpenReleasePage: 'application:update:open-release-page',
versionCheckResult: 'application:update:result',
speechModelsGet: 'settings:speech-models:get',
speechModelsInstall: 'settings:speech-models:install',
speechModelsCancel: 'settings:speech-models:cancel',
speechModelsRemove: 'settings:speech-models:remove',
speechModelsSelect: 'settings:speech-models:select',
speechModelsImportLocal: 'settings:speech-models:import-local',
speechModelsOpenRepository: 'settings:speech-models:open-repository',
speechModelsOpenDirectory: 'settings:speech-models:open-directory',
speechTranscribe: 'speech:transcribe',
speechTranscriptionCancel: 'speech:transcription:cancel',
embeddingSettingsGet: 'settings:embedding:get',
embeddingDiagnose: 'settings:embedding:diagnose',
embeddingIndexRebuild: 'settings:embedding:index:rebuild',
embeddingIndexCancel: 'settings:embedding:index:cancel',
embeddingIndexStatusChanged: 'settings:embedding:index:status-changed',
projectsList: 'projects:list',
projectsCreate: 'projects:create',
projectsUpdate: 'projects:update',
+82
View File
@@ -0,0 +1,82 @@
import { z } from 'zod'
const runtimeSelectionProfileIdSchema = z.string().uuid()
export const agentRuntimeSelectionSchema = z.discriminatedUnion(
'provider',
[
z.object({ provider: z.literal('auto') }).strict(),
z
.object({
provider: z.literal('model'),
profileId: runtimeSelectionProfileIdSchema
})
.strict(),
z
.object({
provider: z.literal('opencode'),
profileId: runtimeSelectionProfileIdSchema.optional()
})
.strict(),
z
.object({
provider: z.literal('continue'),
profileId: runtimeSelectionProfileIdSchema.optional()
})
.strict()
]
)
export type AgentRuntimeSelection = z.infer<
typeof agentRuntimeSelectionSchema
>
export type RuntimeSelectionRepairSettings = {
modelProfiles: ReadonlyArray<{ id: string }>
defaultModelProfileId: string
opencodeModelSource:
| { kind: 'platform' }
| { kind: 'profile'; profileId: string }
continueModelSource:
| { kind: 'platform' }
| { kind: 'profile'; profileId: string }
}
export function repairAgentRuntimeSelection(
selection: AgentRuntimeSelection,
settings: RuntimeSelectionRepairSettings
): AgentRuntimeSelection {
if (
!('profileId' in selection) ||
!selection.profileId ||
settings.modelProfiles.some(
(profile) => profile.id === selection.profileId
)
) {
return selection
}
if (selection.provider === 'model') {
return {
provider: 'model',
profileId: settings.defaultModelProfileId
}
}
const source =
selection.provider === 'opencode'
? settings.opencodeModelSource
: settings.continueModelSource
return {
provider: selection.provider,
...(source.kind === 'profile'
? { profileId: source.profileId }
: {})
}
}
export function agentRuntimeSelectionKey(
selection: AgentRuntimeSelection
): string {
return `${selection.provider}:${'profileId' in selection
? selection.profileId ?? 'platform'
: 'default'}`
}
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest'
import { formatShortcutForDisplay } from './shortcut'
describe('formatShortcutForDisplay', () => {
it.each([
['win32', 'Ctrl+Shift+Space'],
['linux', 'Ctrl+Shift+Space'],
['darwin', 'Command+Shift+Space']
])('formats the launcher accelerator for %s', (platform, expected) => {
expect(
formatShortcutForDisplay(
'CommandOrControl+Shift+Space',
platform
)
).toBe(expected)
})
})
+20
View File
@@ -0,0 +1,20 @@
export function formatShortcutForDisplay(
accelerator: string,
platform: string
): string {
return accelerator
.split('+')
.map((key) => {
if (key === 'CommandOrControl' || key === 'CmdOrCtrl') {
return platform === 'darwin' ? 'Command' : 'Ctrl'
}
if (key === 'Control' || key === 'Ctrl') {
return 'Ctrl'
}
if (key === 'Cmd' || key === 'Command') {
return 'Command'
}
return key
})
.join('+')
}
+107
View File
@@ -0,0 +1,107 @@
import { describe, expect, it } from 'vitest'
import {
speechModelCatalogEntrySchema,
speechModelLocalDirectoryInputSchema,
speechModelSnapshotSchema
} from './speech-model-contracts'
const downloadableEntry = {
id: 'test-speech-model',
displayName: 'Test speech model',
description: 'A model used to verify the shared contract.',
languages: ['中文'],
family: 'whisper' as const,
quantization: 'int8' as const,
repositoryUrl: 'https://huggingface.co/example/test-speech-model',
license: {
name: 'MIT License',
notice: 'Test license notice.',
url: 'https://opensource.org/license/mit'
},
manualOnly: false,
files: [
{
name: 'model.onnx',
role: 'model' as const,
download: {
url: 'https://huggingface.co/example/test/resolve/main/model.onnx',
size: 12,
sha256: 'a'.repeat(64)
}
}
]
}
describe('speech model contracts', () => {
it('requires verified download metadata for every automatic file', () => {
expect(speechModelCatalogEntrySchema.parse(downloadableEntry)).toEqual(
downloadableEntry
)
expect(
speechModelCatalogEntrySchema.safeParse({
...downloadableEntry,
files: [{ name: 'model.onnx', role: 'model' }]
}).success
).toBe(false)
})
it('requires a reason for manual-only models and rejects duplicate files', () => {
expect(
speechModelCatalogEntrySchema.safeParse({
...downloadableEntry,
manualOnly: true,
files: [
{ name: 'model.onnx', role: 'model' },
{ name: 'model.onnx', role: 'tokens' }
]
}).success
).toBe(false)
expect(
speechModelCatalogEntrySchema.safeParse({
...downloadableEntry,
manualOnly: true,
manualReason: '上游没有可核验的大小和摘要。',
files: [{ name: 'model.onnx', role: 'model' }]
}).success
).toBe(true)
})
it('rejects traversal, unknown fields, and malformed snapshots', () => {
expect(
speechModelCatalogEntrySchema.safeParse({
...downloadableEntry,
files: [
{
...downloadableEntry.files[0],
name: '../model.onnx'
}
]
}).success
).toBe(false)
expect(
speechModelLocalDirectoryInputSchema.safeParse({
modelId: 'test-speech-model',
directory: 'C:\\models',
copyEverything: true
}).success
).toBe(false)
expect(
speechModelSnapshotSchema.safeParse({
rootDirectory: 'C:\\models\\speech',
catalog: [downloadableEntry],
installed: [],
operations: [
{
modelId: 'test-speech-model',
kind: 'download',
phase: 'transferring',
currentFile: 'model.onnx',
completedBytes: -1,
totalBytes: 12
}
],
selectedModelId: null
}).success
).toBe(false)
})
})
+200
View File
@@ -0,0 +1,200 @@
import { z } from 'zod'
const safeIdentifierPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u
const safeFileNamePattern =
/^(?!\.{1,2}$)(?!.*(?:^|[\\/])\.{1,2}(?:[\\/]|$))[^/\\\0]+$/u
const sha256Pattern = /^[a-f0-9]{64}$/u
export const SPEECH_TRANSCRIPTION_SAMPLE_RATE = 16_000
export const SPEECH_TRANSCRIPTION_MAX_SECONDS = 20
export const SPEECH_TRANSCRIPTION_MAX_SAMPLES =
SPEECH_TRANSCRIPTION_SAMPLE_RATE * SPEECH_TRANSCRIPTION_MAX_SECONDS
export const speechModelIdSchema = z
.string()
.min(1)
.max(96)
.regex(safeIdentifierPattern)
export const speechModelFileNameSchema = z
.string()
.min(1)
.max(255)
.regex(safeFileNamePattern)
export const speechModelFileRoleSchema = z.enum([
'model',
'encoder',
'decoder',
'tokens',
'configuration'
])
export const speechModelDownloadSchema = z
.object({
url: z.url().max(2_048),
size: z.number().int().positive().safe(),
sha256: z.string().regex(sha256Pattern)
})
.strict()
export const speechModelFileSpecSchema = z
.object({
name: speechModelFileNameSchema,
role: speechModelFileRoleSchema,
download: speechModelDownloadSchema.optional()
})
.strict()
export const speechModelLicenseSchema = z
.object({
name: z.string().trim().min(1).max(120),
notice: z.string().trim().min(1).max(1_000),
url: z.url().max(2_048)
})
.strict()
export const speechModelCatalogEntrySchema = z
.object({
id: speechModelIdSchema,
displayName: z.string().trim().min(1).max(120),
description: z.string().trim().min(1).max(500),
languages: z.array(z.string().trim().min(1).max(32)).min(1).max(32),
family: z.enum(['sensevoice', 'whisper']),
quantization: z.enum(['int8', 'fp16', 'fp32']),
repositoryUrl: z.url().max(2_048),
license: speechModelLicenseSchema,
manualOnly: z.boolean(),
manualReason: z.string().trim().min(1).max(500).optional(),
files: z.array(speechModelFileSpecSchema).min(1).max(32)
})
.strict()
.superRefine((entry, context) => {
if (new Set(entry.files.map((file) => file.name)).size !== entry.files.length) {
context.addIssue({
code: 'custom',
path: ['files'],
message: '模型文件名不能重复'
})
}
if (entry.manualOnly && !entry.manualReason) {
context.addIssue({
code: 'custom',
path: ['manualReason'],
message: '仅手动导入的模型必须说明原因'
})
}
if (
!entry.manualOnly &&
entry.files.some((file) => file.download === undefined)
) {
context.addIssue({
code: 'custom',
path: ['files'],
message: '可下载模型的每个文件都必须提供已验证的大小和 SHA-256'
})
}
})
export const speechModelInstalledFileSchema = z
.object({
name: speechModelFileNameSchema,
role: speechModelFileRoleSchema,
size: z.number().int().nonnegative().safe(),
sha256: z.string().regex(sha256Pattern)
})
.strict()
export const installedSpeechModelSchema = z
.object({
id: speechModelIdSchema,
displayName: z.string().trim().min(1).max(120),
source: z.enum(['download', 'local']),
installedAt: z.string().datetime(),
files: z.array(speechModelInstalledFileSchema).min(1).max(32)
})
.strict()
export const speechModelOperationSchema = z
.object({
modelId: speechModelIdSchema,
kind: z.enum(['download', 'import']),
phase: z.enum(['preparing', 'transferring', 'installing']),
currentFile: speechModelFileNameSchema.nullable(),
completedBytes: z.number().int().nonnegative().safe(),
totalBytes: z.number().int().nonnegative().safe().nullable()
})
.strict()
export const speechModelSnapshotSchema = z
.object({
rootDirectory: z.string().min(1).max(32_768),
catalog: z.array(speechModelCatalogEntrySchema).max(64),
installed: z.array(installedSpeechModelSchema).max(64),
operations: z.array(speechModelOperationSchema).max(16),
selectedModelId: speechModelIdSchema.nullable()
})
.strict()
export const speechModelActionInputSchema = z
.object({
modelId: speechModelIdSchema
})
.strict()
export const speechModelSelectionInputSchema = z
.object({
modelId: speechModelIdSchema.nullable()
})
.strict()
export const speechModelLocalDirectoryInputSchema = z
.object({
modelId: speechModelIdSchema,
directory: z.string().trim().min(1).max(32_768)
})
.strict()
export const speechTranscriptionInputSchema = z
.object({
requestId: z.string().uuid(),
sampleRate: z.literal(SPEECH_TRANSCRIPTION_SAMPLE_RATE),
audio: z.custom<ArrayBuffer>(
(value) =>
value instanceof ArrayBuffer &&
value.byteLength > 0 &&
value.byteLength <= SPEECH_TRANSCRIPTION_MAX_SAMPLES * 4 &&
value.byteLength % 4 === 0,
'录音数据无效或超过长度限制'
)
})
.strict()
export const speechTranscriptionResultSchema = z
.object({
text: z.string().trim().max(20_000)
})
.strict()
export type SpeechModelId = z.infer<typeof speechModelIdSchema>
export type SpeechModelFileSpec = z.infer<
typeof speechModelFileSpecSchema
>
export type SpeechModelCatalogEntry = z.infer<
typeof speechModelCatalogEntrySchema
>
export type InstalledSpeechModel = z.infer<
typeof installedSpeechModelSchema
>
export type SpeechModelOperation = z.infer<
typeof speechModelOperationSchema
>
export type SpeechModelSnapshot = z.infer<typeof speechModelSnapshotSchema>
export type SpeechModelLocalDirectoryInput = z.infer<
typeof speechModelLocalDirectoryInputSchema
>
export type SpeechTranscriptionInput = z.infer<
typeof speechTranscriptionInputSchema
>
export type SpeechTranscriptionResult = z.infer<
typeof speechTranscriptionResultSchema
>