feat: expand model tools and document handling

This commit is contained in:
lofyer
2026-08-11 19:52:58 +08:00
parent 71a8662690
commit 184180e618
50 changed files with 2537 additions and 747 deletions
@@ -201,6 +201,34 @@ describe('CapabilityService', () => {
).resolves.toEqual({ enabled: true, supported: true })
})
it('enables direct-model web search by default and persists its switch', async () => {
const { filePath, builtinRoot, importedRoot, service } =
await createService()
await expect(service.getSnapshot()).resolves.toMatchObject({
webSearch: {
provider: 'exa',
enabled: true,
availableIn: ['ask', 'execute'],
tools: ['web_search', 'web_fetch']
}
})
await service.setWebSearchEnabled(false)
await expect(
service.getWebSearchCapabilityStatus()
).resolves.toEqual({ enabled: false })
const reloaded = new CapabilityService(
filePath,
builtinRoot,
importedRoot,
cipher
)
await expect(reloaded.getSnapshot()).resolves.toMatchObject({
webSearch: { enabled: false }
})
})
it('discovers built-in skills and persists enablement and assignments', async () => {
const { filePath, builtinRoot, importedRoot, service } =
await createService()
@@ -641,7 +669,7 @@ describe('CapabilityService', () => {
await expect(service.getResolvedMcpServers('model')).resolves.toHaveLength(1)
})
it('migrates v1 to v2 without losing skills, MCP configuration, or encrypted secrets', async () => {
it('migrates v1 to v3 without losing skills, MCP configuration, or encrypted secrets', async () => {
const { filePath, builtinRoot, importedRoot } = await createService()
const credential = Buffer.from(
'encrypted:{"version":1,"serverId":"d2ef774b-146c-4467-a909-6feb112a9c2c","secret":"preserved-secret"}'
@@ -713,14 +741,52 @@ describe('CapabilityService', () => {
id: 'linux-desktop-control',
enabled: false
})
]
],
webSearch: {
provider: 'exa',
enabled: true
}
})
const persisted = await readFile(filePath, 'utf8')
expect(persisted).toContain('"version": 2')
expect(persisted).toContain('"version": 3')
expect(persisted).toContain(credential)
expect(persisted).not.toContain('preserved-secret')
})
it('migrates v2 capabilities with web search enabled by default', async () => {
const { filePath, builtinRoot, importedRoot } = await createService()
await writeFile(
filePath,
JSON.stringify({
version: 2,
skills: {},
mcpServers: [],
computerCapabilities: {
'host-browser-control': {
enabled: false,
browserProfileId: null
},
'linux-desktop-control': {
enabled: false,
browserProfileId: null
}
}
}),
'utf8'
)
const service = new CapabilityService(
filePath,
builtinRoot,
importedRoot,
cipher
)
await expect(service.getSnapshot()).resolves.toMatchObject({
webSearch: { enabled: true }
})
expect(await readFile(filePath, 'utf8')).toContain('"version": 3')
})
it('gates enablement on the supported platform and architecture', async () => {
const { service } = await createService({
platform: 'darwin',
+64 -4
View File
@@ -27,6 +27,7 @@ import {
mcpServerSummarySchema,
skillIdSchema,
skillSummarySchema,
webSearchCapabilitySchema,
type CapabilityAssignments,
type CapabilityDiagnosticReport,
type CapabilitySnapshot,
@@ -146,7 +147,7 @@ const computerCapabilityStateSchema = z
})
.strict()
const storedCapabilitiesSchema = z
const storedCapabilitiesV2Schema = z
.object({
version: z.literal(2),
skills: z.record(skillIdSchema, skillStateSchema),
@@ -160,6 +161,27 @@ const storedCapabilitiesSchema = z
})
.strict()
const webSearchStateSchema = z
.object({
enabled: z.boolean()
})
.strict()
const storedCapabilitiesSchema = z
.object({
version: z.literal(3),
skills: z.record(skillIdSchema, skillStateSchema),
mcpServers: z.array(storedMcpServerSchema).max(64),
webSearch: webSearchStateSchema,
computerCapabilities: z
.object({
'host-browser-control': computerCapabilityStateSchema,
'linux-desktop-control': computerCapabilityStateSchema
})
.strict()
})
.strict()
type StoredCapabilitiesV1 = z.infer<typeof storedCapabilitiesV1Schema>
type StoredCapabilities = z.infer<typeof storedCapabilitiesSchema>
type StoredMcpServer = z.infer<typeof storedMcpServerSchema>
@@ -216,9 +238,10 @@ function defaultComputerCapabilityStates(): StoredCapabilities['computerCapabili
function emptyStoredCapabilities(): StoredCapabilities {
return {
version: 2,
version: 3,
skills: {},
mcpServers: [],
webSearch: { enabled: true },
computerCapabilities: defaultComputerCapabilityStates()
}
}
@@ -608,19 +631,34 @@ export class CapabilityService {
try {
const raw = JSON.parse(await readFile(this.filePath, 'utf8')) as unknown
const version = z
.object({ version: z.union([z.literal(1), z.literal(2)]) })
.object({
version: z.union([
z.literal(1),
z.literal(2),
z.literal(3)
])
})
.passthrough()
.parse(raw).version
if (version === 1) {
const legacy: StoredCapabilitiesV1 =
storedCapabilitiesV1Schema.parse(raw)
loaded = {
version: 2,
version: 3,
skills: legacy.skills,
mcpServers: legacy.mcpServers,
webSearch: { enabled: true },
computerCapabilities: defaultComputerCapabilityStates()
}
shouldPersist = true
} else if (version === 2) {
const legacy = storedCapabilitiesV2Schema.parse(raw)
loaded = {
...legacy,
version: 3,
webSearch: { enabled: true }
}
shouldPersist = true
} else {
loaded = storedCapabilitiesSchema.parse(raw)
}
@@ -749,6 +787,12 @@ export class CapabilityService {
mcpServers: state.mcpServers.map((server) =>
this.toMcpSummary(server)
),
webSearch: webSearchCapabilitySchema.parse({
provider: 'exa',
enabled: state.webSearch.enabled,
availableIn: ['ask', 'execute'],
tools: ['web_search', 'web_fetch']
}),
computerCapabilities: computerCapabilityCatalog.map((capability) =>
computerCapabilityConfigSummarySchema.parse({
id: capability.id,
@@ -770,6 +814,22 @@ export class CapabilityService {
}
}
async getWebSearchCapabilityStatus(): Promise<{ enabled: boolean }> {
const state = await this.load()
return { enabled: state.webSearch.enabled }
}
setWebSearchEnabled(enabled: boolean): Promise<CapabilitySnapshot> {
return this.queue(async () => {
const state = await this.load()
await this.persist({
...state,
webSearch: { enabled }
})
return this.getSnapshot()
})
}
async getComputerCapabilityStatus(
capabilityId: ComputerCapabilityId
): Promise<{ enabled: boolean; supported: boolean }> {
@@ -0,0 +1,81 @@
import type { WebSearchTestResult } from '../../shared/capability-contracts'
import {
ModelToolProvider,
type ModelToolResultPart
} from '../agent/model-tool-provider'
const TEST_QUERY = 'GoodBuddy desktop assistant'
export async function testWebSearch(
signal?: AbortSignal
): Promise<WebSearchTestResult> {
const controller = new AbortController()
const timeout = setTimeout(
() => controller.abort(new Error('联网搜索测试超时')),
20_000
)
const abortFromCaller = (): void => controller.abort(signal?.reason)
signal?.addEventListener('abort', abortFromCaller, { once: true })
if (signal?.aborted) {
abortFromCaller()
}
const provider = new ModelToolProvider(
process.cwd(),
[],
undefined,
undefined,
true
)
const startedAt = Date.now()
try {
const context = {
conversationId: 'web-search-diagnostic',
workMode: 'ask' as const
}
const tools = await provider.listTools(context, controller.signal)
if (
!tools.some((tool) => tool.name === 'web_search') ||
!tools.some((tool) => tool.name === 'web_fetch')
) {
throw new Error('Exa MCP 未提供所需的联网工具')
}
const result = await provider.callTool(
'web_search',
{ query: TEST_QUERY, numResults: 1 },
controller.signal,
context
)
const preview = result.parts
.filter(
(
part
): part is Extract<ModelToolResultPart, { type: 'text' }> =>
part.type === 'text'
)
.map((part) => part.text)
.join('\n')
.replace(/\s+/gu, ' ')
.trim()
.slice(0, 500)
if (!preview) {
throw new Error('联网搜索测试未返回文本结果')
}
return {
provider: 'exa',
query: TEST_QUERY,
durationMs: Date.now() - startedAt,
preview
}
} catch (error) {
if (signal?.aborted) {
throw new Error('联网搜索测试已取消', { cause: error })
}
throw new Error('联网搜索测试失败,请检查网络连接或稍后重试', {
cause: error
})
} finally {
clearTimeout(timeout)
signal?.removeEventListener('abort', abortFromCaller)
await provider.dispose()
}
}