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
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:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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[]>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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'}`
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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('+')
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
>
|
||||
Reference in New Issue
Block a user