import fs from 'node:fs'; import path from 'node:path'; import type { JsonSchemaObject } from '../ai-shared/AiStructuredOutputClient'; import type { AiRuntimeConfig } from '../ai-shared/AiRuntimeConfig'; import { AiApiDslParser, AiDslValidationError, type AiFuncDisplay, type AiFuncDsl, type ParsedAiDependency, } from './AiApiDslParser'; export interface AiFuncDefinition { readonly func: string; readonly desc: string; readonly input: Readonly>; readonly output: Readonly>; readonly dependencies: readonly ParsedAiDependency[]; readonly display?: Readonly; readonly options: Readonly>; readonly outputSchema: JsonSchemaObject; readonly sourceFile: string; } export interface AiFuncListItem { func: string; desc: string; in: Record; out: Record; deps: Array<{ file: string; desc: string }>; } /** 注册为助手工具的函数视图:只带工具面需要的字段,执行细节(out/dep/options)不外露。 */ export interface AiFuncAssistantTool { func: string; desc: string; in: Record; display?: AiFuncDisplay; } export class AiApiRegistry { private readonly definitions: ReadonlyMap; private constructor(definitions: Map) { this.definitions = definitions; } static compile(config: AiRuntimeConfig): AiApiRegistry { const realHostDir = resolveDirectory(config.hostDir, 'Host 根目录'); const aiApiRoot = path.join(realHostDir, 'ai-api'); if (!fs.existsSync(aiApiRoot)) { return new AiApiRegistry(new Map()); } let realAiApiRoot: string; try { realAiApiRoot = resolveContainedDirectory(realHostDir, aiApiRoot, 'ai-api 目录'); } catch (error) { throw new AiDslValidationError( 'AI_DSL_DEP_ESCAPED', aiApiRoot, '$', error instanceof Error ? error.message : 'ai-api 目录越出 Host 根目录或无法读取', ); } const parser = new AiApiDslParser(config.aiApi.depAllowedExtensions); const definitions = new Map(); const entries = fs.readdirSync(realAiApiRoot, { withFileTypes: true }) .filter((entry) => entry.name.endsWith('.func.yaml')) .sort((left, right) => left.name.localeCompare(right.name)); for (const entry of entries) { const sourcePath = path.join(realAiApiRoot, entry.name); if (entry.isSymbolicLink()) { throw new AiDslValidationError( 'AI_DSL_DEP_ESCAPED', sourcePath, '$', 'DSL 符号链接不允许作为函数声明', ); } if (!entry.isFile()) { continue; } const parsed = parser.parseFile(sourcePath, realAiApiRoot); if (definitions.has(parsed.dsl.func)) { throw new AiDslValidationError( 'AI_DSL_FUNC_DUPLICATE', parsed.sourceFile, 'func', `func 重复:${parsed.dsl.func}`, ); } definitions.set(parsed.dsl.func, freezeDefinition(parsed)); } return new AiApiRegistry(definitions); } get(func: string): AiFuncDefinition | undefined { return this.definitions.get(func); } list(): AiFuncListItem[] { return [...this.definitions.values()].map((definition) => ({ func: definition.func, desc: definition.desc, in: { ...definition.input }, out: { ...definition.output }, deps: definition.dependencies.map(({ file, desc }) => ({ file, desc })), })); } /** 声明即能力:缺省全部函数都注册为助手工具,单个函数用 options.assistantTool: false 退出。 */ listAssistantTools(): AiFuncAssistantTool[] { return [...this.definitions.values()] .filter((definition) => definition.options.assistantTool !== false) .map((definition) => ({ func: definition.func, desc: definition.desc, in: { ...definition.input }, ...(definition.display ? { display: { ...definition.display } } : {}), })); } } export function resolveBusinessRoot(hostDir: string): string { const resolvedHostDir = path.resolve(hostDir); const realHostDir = resolveDirectory(resolvedHostDir, 'Host 根目录'); const packagePath = path.join(realHostDir, 'package.json'); let configured: unknown; try { const packageDocument = JSON.parse(fs.readFileSync(packagePath, 'utf8')) as { webAdmin?: { businessRoot?: unknown }; }; configured = packageDocument.webAdmin?.businessRoot; } catch { configured = undefined; } if (typeof configured === 'string' && configured.trim()) { return resolveContainedBusinessRoot(realHostDir, configured.trim()); } const srcRoot = path.join(realHostDir, 'src'); return fs.existsSync(srcRoot) ? resolveContainedDirectory(realHostDir, srcRoot, '业务根目录') : realHostDir; } function resolveContainedBusinessRoot(hostDir: string, relativePath: string): string { if (path.isAbsolute(relativePath)) { throw new Error('webAdmin.businessRoot 必须是相对路径'); } const resolved = path.resolve(hostDir, relativePath); const relative = path.relative(hostDir, resolved); if (relative.startsWith('..') || path.isAbsolute(relative) || relative.split(path.sep)[0] === '.soma') { throw new Error('webAdmin.businessRoot 越出 Host 或指向构建目录'); } return resolveContainedDirectory(hostDir, resolved, '业务根目录'); } function resolveDirectory(candidate: string, label: string): string { try { const realPath = fs.realpathSync(candidate); if (!fs.statSync(realPath).isDirectory()) { throw new Error('not a directory'); } return realPath; } catch { throw new Error(`${label}不存在或不是目录`); } } function resolveContainedDirectory(root: string, candidate: string, label: string): string { const boundary = label === '业务根目录' ? 'Host' : '业务根目录'; const resolvedRoot = path.resolve(root); const resolvedCandidate = path.resolve(candidate); if (!isContained(resolvedRoot, resolvedCandidate)) { throw new Error(`${label}越出${boundary}`); } const realRoot = resolveDirectory(resolvedRoot, boundary === 'Host' ? 'Host 根目录' : '业务根目录'); const realCandidate = resolveDirectory(resolvedCandidate, label); if (!isContained(realRoot, realCandidate)) { throw new Error(`${label}越出${boundary}`); } return realCandidate; } function isContained(root: string, target: string): boolean { const relative = path.relative(path.resolve(root), path.resolve(target)); return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); } function deepFreeze(value: T): T { if (value && typeof value === 'object' && !Object.isFrozen(value)) { for (const nested of Object.values(value)) { deepFreeze(nested); } Object.freeze(value); } return value; } function freezeDefinition(parsed: ReturnType): AiFuncDefinition { const { dsl } = parsed; const outputSchema: JsonSchemaObject = { type: 'object', properties: Object.fromEntries( Object.entries(dsl.out).map(([name, description]) => [ name, { type: 'string', description }, ]), ), required: Object.keys(dsl.out), additionalProperties: false, }; return Object.freeze({ func: dsl.func, desc: dsl.desc, input: Object.freeze({ ...dsl.in }), output: Object.freeze({ ...dsl.out }), dependencies: Object.freeze(parsed.dependencies.map((entry) => Object.freeze({ ...entry }))), ...(dsl.display ? { display: Object.freeze({ ...dsl.display }) } : {}), options: Object.freeze({ ...dsl.options }), outputSchema: deepFreeze(outputSchema), sourceFile: parsed.sourceFile, }); }