/** * Tool config:用户勾选/编辑后的 catalog 形状快照(`entries`),运行时持有; * 注入 chat template 的 tools schema 由此派生。 */ import { lsGet, lsSet } from '../../shared/storage/localStorageHelpers'; /** Mock candidate:`mock_results` 一项;可选 `trigger_keyword` 按参数选用。 */ export type ToolCatalogMockCandidate = { response: Record; trigger_keyword?: string; }; export type ToolCatalogEntry = { function: Record & { name: string }; mock_results: ToolCatalogMockCandidate[]; }; export type ToolConfig = { entries: ToolCatalogEntry[]; }; type LegacyToolConfig = { tools_schema: Record[]; mock_results: Record; }; export const EMPTY_TOOL_CONFIG: ToolConfig = { entries: [], }; export function toolConfigToolsSchema(config: ToolConfig): Record[] { return config.entries.map((e) => ({ function: e.function })); } export function cloneToolConfig(config: unknown): ToolConfig { const normalized = normalizeToolConfig(config); if (!normalized) return JSON.parse(JSON.stringify(EMPTY_TOOL_CONFIG)) as ToolConfig; return JSON.parse(JSON.stringify(normalized)) as ToolConfig; } export function isToolConfigEmpty(config: ToolConfig): boolean { return config.entries.length === 0; } function parseMockResultsValue(mockStr: string, toolName: string): ToolCatalogMockCandidate[] { let parsed: unknown; try { parsed = JSON.parse(mockStr); } catch { throw new Error(`invalid mock JSON for ${toolName}`); } if (Array.isArray(parsed)) { return parsed.map((item, i) => { const candidate = validateMockCandidate(item, i); if ('error' in candidate) { throw new Error(`${toolName}: ${candidate.error}`); } return candidate; }); } if (!parsed || typeof parsed !== 'object') { throw new Error(`invalid mock JSON for ${toolName}`); } return [{ response: parsed as Record }]; } function migrateLegacyToolConfig(legacy: LegacyToolConfig): ToolConfig { const entries: ToolCatalogEntry[] = legacy.tools_schema.map((schemaItem) => { const fn = (schemaItem as { function: ToolCatalogEntry['function'] }).function; if (!fn?.name) { throw new Error('tools_schema item missing function.name'); } const mockStr = legacy.mock_results[fn.name]; if (mockStr === undefined) { throw new Error(`tool config missing mock_results for ${fn.name}`); } return { function: JSON.parse(JSON.stringify(fn)) as ToolCatalogEntry['function'], mock_results: parseMockResultsValue(mockStr, fn.name), }; }); return { entries }; } function validateMockCandidate( item: unknown, index: number, ): ToolCatalogMockCandidate | { error: string } { if (!item || typeof item !== 'object') { return { error: `mock_results[${index}] must be an object` }; } const o = item as Record; const response = o.response; if (!response || typeof response !== 'object' || Array.isArray(response)) { return { error: `mock_results[${index}].response must be a JSON object` }; } const kw = o.trigger_keyword; if (kw !== undefined) { if (typeof kw !== 'string' || kw.length === 0) { return { error: `mock_results[${index}].trigger_keyword must be a non-empty string` }; } } return { response: response as Record, ...(kw !== undefined ? { trigger_keyword: kw as string } : {}), }; } function validateEntry(entry: unknown): ToolCatalogEntry | null { if (!entry || typeof entry !== 'object') return null; const o = entry as Record; const fn = o.function; if (!fn || typeof fn !== 'object') return null; const name = (fn as { name?: unknown }).name; if (typeof name !== 'string' || name.length === 0) return null; const mockResults = o.mock_results; if (!Array.isArray(mockResults) || mockResults.length === 0) return null; const candidates: ToolCatalogMockCandidate[] = []; for (let i = 0; i < mockResults.length; i++) { const result = validateMockCandidate(mockResults[i], i); if ('error' in result) return null; candidates.push(result); } return { function: fn as ToolCatalogEntry['function'], mock_results: candidates, }; } export function normalizeToolConfig(value: unknown): ToolConfig | null { if (!value || typeof value !== 'object') return null; const o = value as Record; if (Array.isArray(o.entries)) { const entries: ToolCatalogEntry[] = []; for (const item of o.entries) { const entry = validateEntry(item); if (!entry) return null; entries.push(entry); } return { entries }; } if (Array.isArray(o.tools_schema) && o.mock_results != null && typeof o.mock_results === 'object') { try { return migrateLegacyToolConfig(o as LegacyToolConfig); } catch { return null; } } return null; } export function isValidToolConfig(value: unknown): value is ToolConfig { return normalizeToolConfig(value) !== null; } export function readToolConfigFromLs(key: string): ToolConfig { const raw = lsGet(key); if (!raw) return cloneToolConfig(EMPTY_TOOL_CONFIG); try { const parsed: unknown = JSON.parse(raw); const normalized = normalizeToolConfig(parsed); if (normalized) return cloneToolConfig(normalized); } catch { /* invalid */ } return cloneToolConfig(EMPTY_TOOL_CONFIG); } export function writeToolConfigToLs(key: string, config: ToolConfig): void { lsSet(key, JSON.stringify(cloneToolConfig(config))); } export function formatToolCatalogEntryJson(entry: ToolCatalogEntry): string { return JSON.stringify(entry, null, 2); } export function createBlankToolEntry(name: string): ToolCatalogEntry { return { function: { name, parameters: { type: 'object', properties: {} }, }, mock_results: [{ response: {} }], }; } export function nextAvailableNewToolName(existingNames: Iterable): string { const used = new Set(existingNames); if (!used.has('new_tool')) return 'new_tool'; let n = 2; while (used.has(`new_tool_${n}`)) n += 1; return `new_tool_${n}`; } export function toolConfigToEntries(config: ToolConfig): ToolCatalogEntry[] { return JSON.parse(JSON.stringify(config.entries)) as ToolCatalogEntry[]; } export function entriesToToolConfig(entries: ToolCatalogEntry[]): ToolConfig { return cloneToolConfig({ entries }); } export function parseToolCatalogEntryJson( text: string, ): { entry: ToolCatalogEntry } | { error: string } { let parsed: unknown; try { parsed = JSON.parse(text); } catch { return { error: 'Invalid JSON' }; } if (!parsed || typeof parsed !== 'object') { return { error: 'Entry must be a JSON object' }; } const o = parsed as Record; const fn = o.function; if (!fn || typeof fn !== 'object') { return { error: 'Missing function' }; } const name = (fn as { name?: unknown }).name; if (typeof name !== 'string' || name.length === 0) { return { error: 'function.name is required' }; } const mockResults = o.mock_results; if (!Array.isArray(mockResults) || mockResults.length === 0) { return { error: 'mock_results must be a non-empty array' }; } const candidates: ToolCatalogMockCandidate[] = []; for (let i = 0; i < mockResults.length; i++) { const result = validateMockCandidate(mockResults[i], i); if ('error' in result) return result; candidates.push(result); } return { entry: { function: fn as ToolCatalogEntry['function'], mock_results: candidates, }, }; } /** 从已勾选条目的 JSON 文本构建 ToolConfig;失败返回英文错误信息(UI 层可 tr)。 */ export function buildToolConfigFromCheckedJson( checkedTexts: { label: string; text: string }[], ): { config: ToolConfig } | { error: string } { if (checkedTexts.length === 0) { return { config: cloneToolConfig(EMPTY_TOOL_CONFIG) }; } const entries: ToolCatalogEntry[] = []; const seen = new Set(); for (const { label, text } of checkedTexts) { const parsed = parseToolCatalogEntryJson(text); if ('error' in parsed) { return { error: `${label}: ${parsed.error}` }; } const name = parsed.entry.function.name; if (seen.has(name)) { return { error: `Duplicate function.name: ${name}` }; } seen.add(name); entries.push(parsed.entry); } return { config: entriesToToolConfig(entries) }; } /** fingerprint 用:稳定序列化 tool config 全文。 */ export function toolConfigFingerprint(config: ToolConfig): string { return JSON.stringify(cloneToolConfig(config)); } /** * 多轮 mock 时 tool config 影响后续轮次,须纳入缓存 business key。 * 单轮时 tools_schema 已编入 prompt / initialContext,无需重复。 */ export function toolConfigCacheKeyFields( multiTurn: boolean, config: ToolConfig, ): { toolConfigFingerprint?: string } { if (!multiTurn) return {}; return { toolConfigFingerprint: toolConfigFingerprint(config) }; }