InfoLens1 / client /src /features /chat /toolConfig.ts
dqy08's picture
精简文档
37db941
Raw
History Blame Contribute Delete
9.71 kB
/**
* 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<string, unknown>;
trigger_keyword?: string;
};
export type ToolCatalogEntry = {
function: Record<string, unknown> & { name: string };
mock_results: ToolCatalogMockCandidate[];
};
export type ToolConfig = {
entries: ToolCatalogEntry[];
};
type LegacyToolConfig = {
tools_schema: Record<string, unknown>[];
mock_results: Record<string, string>;
};
export const EMPTY_TOOL_CONFIG: ToolConfig = {
entries: [],
};
export function toolConfigToolsSchema(config: ToolConfig): Record<string, unknown>[] {
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<string, unknown> }];
}
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<string, unknown>;
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<string, unknown>,
...(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<string, unknown>;
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<string, unknown>;
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>): 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<string, unknown>;
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<string>();
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) };
}