feat: expand model tools and document handling
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user