feat: expand secure assistant workflows

Harden runtime execution and add local knowledge, Smart Heartbeat, usage visibility, responsive product surfaces, and cross-platform packaging support.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
lofyer
2026-08-02 10:04:59 +08:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent 6ef1795b81
commit b3fdf96962
82 changed files with 17608 additions and 825 deletions
+184 -4
View File
@@ -11,13 +11,19 @@ import {
type AssistantArtifact,
type AssistantMemory,
type AssistantSchedule,
type AssistantHeartbeatConfig,
type AssistantHeartbeatEntry,
type AssistantHeartbeatRun,
type AssistantExpert,
type AssistantTask,
type TokenUsageSummary,
type ConversationSnapshot,
type WorkspaceChanges,
type ProjectCreateInput,
type MemoryCreateInput,
type ScheduleCreateInput,
type HeartbeatCreateInput,
type HeartbeatUpdateInput,
type ExpertCreateInput
} from './assistant-contracts'
@@ -76,6 +82,17 @@ export const toolApprovalPolicySchema = z.enum([
])
export const continueModeSchema = z.enum(['chat', 'agent'])
export const runtimeSandboxModeSchema = z.enum(['off', 'auto', 'strict'])
export const modelProtocolSchema = z.enum([
'anthropic-messages',
'openai-chat-completions',
'openai-images-generations'
])
export const modelAuthenticationSchema = z.enum(['api-key', 'none'])
export type ModelProtocol = z.infer<typeof modelProtocolSchema>
export type ModelAuthentication = z.infer<
typeof modelAuthenticationSchema
>
export const defaultModelProfileId =
'00000000-0000-4000-8000-000000000001'
@@ -83,6 +100,8 @@ export const defaultRuntimeSettings = {
provider: 'auto',
modelBaseUrl: 'https://bigtoken.ai',
modelName: 'sonnet-5',
modelProtocol: 'anthropic-messages',
modelAuthentication: 'api-key',
opencodeBaseUrl: '',
opencodeEmbedded: false,
opencodeBinaryPath: '',
@@ -90,6 +109,10 @@ export const defaultRuntimeSettings = {
continueBinaryPath: '',
continueConfigPath: '',
continueMode: 'chat',
runtimeSandboxMode: 'auto',
knowledgeEmbeddingEnabled: false,
knowledgeEmbeddingBaseUrl: 'http://127.0.0.1:11434',
knowledgeEmbeddingModel: 'nomic-embed-text',
workspacePath: '',
toolApproval: 'always'
} as const
@@ -153,6 +176,8 @@ const modelProfileInputSchema = z
.min(1)
.max(128)
.regex(/^[\w./:-]+$/, '模型名称包含不支持的字符'),
protocol: modelProtocolSchema,
authentication: modelAuthenticationSchema,
apiKey: modelApiKeyUpdateSchema
})
.strict()
@@ -177,6 +202,8 @@ export const runtimeSettingsInputSchema = z
.min(1)
.max(128)
.regex(/^[\w./:-]+$/, '模型名称包含不支持的字符'),
modelProtocol: modelProtocolSchema,
modelAuthentication: modelAuthenticationSchema,
opencodeBaseUrl: z.union([
z.literal(''),
z.string().url().max(2_048)
@@ -187,6 +214,15 @@ export const runtimeSettingsInputSchema = z
continueBinaryPath: runtimePathSchema,
continueConfigPath: runtimePathSchema,
continueMode: continueModeSchema,
runtimeSandboxMode: runtimeSandboxModeSchema,
knowledgeEmbeddingEnabled: z.boolean(),
knowledgeEmbeddingBaseUrl: z.string().url().max(2_048),
knowledgeEmbeddingModel: z
.string()
.trim()
.min(1)
.max(256)
.regex(/^[\w./:-]+$/, '向量模型名称包含不支持的字符'),
workspacePath: z.string().trim().min(1).max(4_096),
apiKey: modelApiKeyUpdateSchema,
modelProfiles: z.array(modelProfileInputSchema).min(1).max(20).optional(),
@@ -196,28 +232,58 @@ export const runtimeSettingsInputSchema = z
toolApproval: toolApprovalPolicySchema
}).strict()
.superRefine((settings, context) => {
if (
!settings.modelProfiles &&
settings.modelAuthentication === 'none' &&
settings.apiKey.action === 'replace'
) {
context.addIssue({
code: 'custom',
path: ['apiKey'],
message: '无认证模型连接不得配置 API Key'
})
}
const endpoints = settings.modelProfiles?.map((profile, index) => ({
path: ['modelProfiles', index, 'baseUrl'] as (string | number)[],
value: profile.baseUrl
})) ?? [{ path: ['modelBaseUrl'], value: settings.modelBaseUrl }]
for (const endpoint of endpoints) {
const url = new URL(endpoint.value)
const hostname = url.hostname.toLowerCase()
const loopback =
hostname === 'localhost' ||
hostname === '::1' ||
hostname === '[::1]' ||
/^127(?:\.\d{1,3}){3}$/u.test(hostname)
if (
url.protocol !== 'https:' ||
(url.protocol !== 'https:' &&
!(url.protocol === 'http:' && loopback)) ||
url.username ||
url.password ||
url.search ||
url.hash ||
(url.pathname !== '/' && url.pathname !== '')
url.hash
) {
context.addIssue({
code: 'custom',
path: endpoint.path,
message: '模型服务地址必须是无凭据和路径的 HTTPS origin'
message:
'模型服务地址必须使用 HTTPS;仅本机回环地址可使用 HTTP,且不得包含凭据、查询参数或片段'
})
}
}
if (settings.modelProfiles) {
for (const [index, profile] of settings.modelProfiles.entries()) {
if (
profile.authentication === 'none' &&
profile.apiKey.action === 'replace'
) {
context.addIssue({
code: 'custom',
path: ['modelProfiles', index, 'apiKey'],
message: '无认证模型连接不得配置 API Key'
})
}
}
const ids = new Set(settings.modelProfiles.map((profile) => profile.id))
const names = new Set(
settings.modelProfiles.map((profile) => profile.name.toLowerCase())
@@ -253,6 +319,39 @@ export const runtimeSettingsInputSchema = z
})
}
}
const opencodeSource = settings.opencodeModelSource
const opencodeProfile =
opencodeSource?.kind === 'profile'
? settings.modelProfiles.find(
(profile) => profile.id === opencodeSource.profileId
)
: undefined
if (
opencodeProfile &&
(opencodeProfile.protocol !== 'anthropic-messages' ||
opencodeProfile.authentication !== 'api-key')
) {
context.addIssue({
code: 'custom',
path: ['opencodeModelSource'],
message:
'OpenCode 独立模型连接仅支持需要 API Key 的 Anthropic Messages 协议'
})
}
const continueSource = settings.continueModelSource
const continueProfile =
continueSource?.kind === 'profile'
? settings.modelProfiles.find(
(profile) => profile.id === continueSource.profileId
)
: undefined
if (continueProfile?.protocol === 'openai-images-generations') {
context.addIssue({
code: 'custom',
path: ['continueModelSource'],
message: 'Continue 不支持图像生成模型连接'
})
}
}
if (settings.opencodeBaseUrl) {
const opencodeUrl = new URL(settings.opencodeBaseUrl)
@@ -271,6 +370,38 @@ export const runtimeSettingsInputSchema = z
})
}
}
const embeddingUrl = new URL(settings.knowledgeEmbeddingBaseUrl)
const embeddingHost = embeddingUrl.hostname.toLowerCase()
const privateIpv4 =
/^10(?:\.\d{1,3}){3}$/u.test(embeddingHost) ||
/^192\.168(?:\.\d{1,3}){2}$/u.test(embeddingHost) ||
/^172\.(?:1[6-9]|2\d|3[01])(?:\.\d{1,3}){2}$/u.test(
embeddingHost
)
const loopback =
embeddingHost === 'localhost' ||
embeddingHost === '::1' ||
embeddingHost === '[::1]' ||
/^127(?:\.\d{1,3}){3}$/u.test(embeddingHost)
if (
(embeddingUrl.protocol !== 'https:' &&
!(
embeddingUrl.protocol === 'http:' &&
(loopback || privateIpv4)
)) ||
embeddingUrl.username ||
embeddingUrl.password ||
embeddingUrl.search ||
embeddingUrl.hash ||
(embeddingUrl.pathname !== '/' && embeddingUrl.pathname !== '')
) {
context.addIssue({
code: 'custom',
path: ['knowledgeEmbeddingBaseUrl'],
message:
'Ollama 向量地址必须使用 HTTPS,或使用本机/私有网络 HTTP origin,且不得包含凭据、路径、查询参数或片段'
})
}
})
export type RuntimeSettingsInput = z.infer<typeof runtimeSettingsInputSchema>
@@ -282,6 +413,8 @@ export type ModelConnectionSettings = {
name: string
baseUrl: string
modelName: string
protocol: ModelProtocol
authentication: ModelAuthentication
apiKeyConfigured: boolean
credentialSource: 'none' | 'encrypted' | 'environment'
}
@@ -290,6 +423,8 @@ export type RuntimeSettings = {
provider: RuntimeSettingsInput['provider']
modelBaseUrl: string
modelName: string
modelProtocol: ModelProtocol
modelAuthentication: ModelAuthentication
opencodeBaseUrl: string
opencodeEmbedded: boolean
opencodeBinaryPath: string
@@ -297,6 +432,10 @@ export type RuntimeSettings = {
continueBinaryPath: string
continueConfigPath: string
continueMode: RuntimeSettingsInput['continueMode']
runtimeSandboxMode: RuntimeSettingsInput['runtimeSandboxMode']
knowledgeEmbeddingEnabled: boolean
knowledgeEmbeddingBaseUrl: string
knowledgeEmbeddingModel: string
workspacePath: string
apiKeyConfigured: boolean
credentialSource: 'none' | 'encrypted' | 'environment'
@@ -323,6 +462,8 @@ export type AgentRuntimeStatus = {
label: string
available: boolean
detail: string
capability?: 'chat' | 'image-generation'
supportsToolExecution: boolean
}
export type RuntimeBinaryDetection =
@@ -367,6 +508,7 @@ export type AgentEvent =
| {
requestId: string
type: 'tool'
callId: string
name: string
state: 'pending' | 'running' | 'completed' | 'failed'
summary: string
@@ -381,6 +523,13 @@ export type AgentEvent =
argumentSummary?: string
allowPermanent?: boolean
}
| {
requestId: string
type: 'artifact'
artifactId: string
kind: 'image'
title: string
}
| {
requestId: string
type: 'done'
@@ -389,6 +538,7 @@ export type AgentEvent =
| {
requestId: string
type: 'error'
status: 'failed' | 'cancelled'
message: string
}
@@ -531,6 +681,8 @@ export type KnowledgeSearchReference = {
locator?: string
snippet: string
rank: number
retrievalChannels?: Array<'fts' | 'vector' | 'graph'>
evidenceIds?: string[]
}
export type DesktopApi = {
@@ -538,6 +690,7 @@ export type DesktopApi = {
getInfo: () => Promise<AppInfo>
show: () => Promise<void>
hide: () => Promise<void>
clearLocalData: () => Promise<void>
onNewConversation: (listener: () => void) => () => void
onOpenSettings: (listener: () => void) => () => void
}
@@ -579,9 +732,17 @@ export type DesktopApi = {
}
tasks: {
list: () => Promise<AssistantTask[]>
setStatus: (
taskId: string,
status: Extract<AssistantTask['status'], 'completed' | 'cancelled'>
) => Promise<void>
}
usage: {
getTokenSummary: () => Promise<TokenUsageSummary>
}
artifacts: {
list: (projectId?: string) => Promise<AssistantArtifact[]>
get: (artifactId: string) => Promise<AssistantArtifact>
importFiles: (projectId?: string) => Promise<AssistantArtifact[]>
}
memory: {
@@ -600,6 +761,25 @@ export type DesktopApi = {
remove: (scheduleId: string) => Promise<void>
runNow: (scheduleId: string) => Promise<void>
}
heartbeats: {
list: (projectId?: string) => Promise<AssistantHeartbeatConfig[]>
create: (
input: HeartbeatCreateInput
) => Promise<AssistantHeartbeatConfig>
update: (
heartbeatId: string,
input: HeartbeatUpdateInput
) => Promise<AssistantHeartbeatConfig>
setPaused: (heartbeatId: string, paused: boolean) => Promise<void>
remove: (heartbeatId: string) => Promise<void>
runNow: (heartbeatId: string) => Promise<AssistantHeartbeatRun>
history: (
heartbeatId?: string
) => Promise<{
runs: AssistantHeartbeatRun[]
entries: AssistantHeartbeatEntry[]
}>
}
experts: {
list: () => Promise<AssistantExpert[]>
create: (input: ExpertCreateInput) => Promise<AssistantExpert>