import fs from 'node:fs'; import path from 'node:path'; import yaml from 'js-yaml'; import { AiApiError, AiErrorCodes } from '../../domain/ai-shared/AiError'; export type SomaEnvName = 'test' | 'prod'; export interface AiKnowledgeConfigDocument { roots?: unknown; allowedExtensions?: unknown; maxFileBytes?: unknown; } export interface AiAssistantConfigDocument { model?: unknown; maxSteps?: unknown; systemPrompt?: unknown; knowledge?: AiKnowledgeConfigDocument; /** Per-toolkit switches and pass-through options; every toolkit defaults to enabled. */ toolkits?: Record; /** Browser-only optional override; it never reaches the model prompt. */ icon?: unknown; } export interface AiConfigDocument { apiKey?: unknown; baseUrl?: unknown; model?: unknown; timeoutMs?: unknown; maxConcurrency?: unknown; aiApi?: { model?: unknown; emptyOutputFallbackModel?: unknown; maxOutputTokens?: unknown; retryOnParseError?: unknown; depAllowedExtensions?: unknown; }; assistant?: AiAssistantConfigDocument; } export interface AiHostConfigDocument { assistant?: AiAssistantConfigDocument; } export interface RuntimeConfigDocument { aiHost?: AiHostConfigDocument; [key: string]: unknown; } function readEnv(name: string, env: Record = process.env): string | undefined { const value = env[name]; return value && value.trim() ? value.trim() : undefined; } /** Retained from Admin Shell runtime-environment selection. */ export const AI_SYS_ENV_INI_PATH = '/etc/sys_env.ini'; export const AI_SYS_ENV_DEFAULT = 'DEV'; export function readSysEnvIni( filePath = readEnv('SOMA_SYS_ENV_INI') || AI_SYS_ENV_INI_PATH, ): string { try { const content = fs.readFileSync(filePath, 'utf8'); for (const line of content.split(/\r?\n/)) { if (!line.startsWith('env=')) continue; const value = line.slice('env='.length).trim(); if (value) return value; } } catch { // The optional system file is absent in ordinary local and container Hosts. } return AI_SYS_ENV_DEFAULT; } function normalizeSomaEnv(raw?: string): SomaEnvName { const value = String(raw ?? '').trim().toLowerCase(); if (!value) return 'test'; if (['test', 'dev', 'development'].includes(value)) return 'test'; if (['prod', 'production'].includes(value)) return 'prod'; throw new AiApiError( AiErrorCodes.AI_CONFIG_MISSING, `Unsupported SOMA_ENV/config-family value: ${raw}. Expected test|dev|development|prod|production.`, ); } function readRawRuntimeEnv(env: Record = process.env): string { return ( readEnv('SOMA_ENV', env) ?? readEnv('APP_ENV', env) ?? readEnv('ADMIN_ENV', env) ?? readEnv('NEXT_PUBLIC_ADMIN_ENV', env) ?? readSysEnvIni(readEnv('SOMA_SYS_ENV_INI', env) || AI_SYS_ENV_INI_PATH) ); } export function readSomaEnv( env: Record = process.env, ): SomaEnvName { return normalizeSomaEnv(readRawRuntimeEnv(env)); } function resolveSafeChildRoot( parent: string, relativePath: string | undefined, options: { requireExists?: boolean; fallback?: string } = {}, ): string { const trimmed = relativePath?.trim(); if (!trimmed) return parent; const resolved = path.resolve(parent, trimmed); const normalizedParent = path.resolve(parent); if (resolved === normalizedParent || resolved.startsWith(`${normalizedParent}${path.sep}`)) { if (options.requireExists && !fs.existsSync(resolved)) return normalizedParent; return resolved; } return options.fallback ?? normalizedParent; } export function resolveHostDir( env: Record = process.env, ): string { return path.resolve(readEnv('SOMA_HOST_DIR', env) || process.cwd()); } export function resolveHostConfigRoot(hostDir = resolveHostDir()): string { const normalizedHostDir = path.resolve(hostDir); const packagePath = path.join(normalizedHostDir, 'package.json'); try { const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8')) as { soma?: { hostRoot?: string; configRoot?: string }; }; const configured = packageJson.soma?.configRoot?.trim(); if (configured) { return resolveSafeChildRoot(normalizedHostDir, configured, { fallback: path.join(normalizedHostDir, 'config'), }); } const hostSourceRoot = resolveSafeChildRoot(normalizedHostDir, packageJson.soma?.hostRoot, { requireExists: true, }); const candidates = [path.join(normalizedHostDir, 'config'), path.join(hostSourceRoot, 'config')]; for (const candidate of candidates) { if (fs.existsSync(candidate)) return candidate; } return path.join(hostSourceRoot, 'config'); } catch { return path.join(normalizedHostDir, 'config'); } } function resolveEnvironmentConfigPath( configRoot: string, baseName: string, environment = readSomaEnv(), ): string | null { const candidates = [ path.join(configRoot, `${baseName}.${environment}.yaml`), path.join(configRoot, `${baseName}.yaml`), ...(baseName === 'app.config' && environment !== 'test' ? [path.join(configRoot, 'app.config.test.yaml')] : []), ]; for (const candidate of candidates) { if (fs.existsSync(candidate)) return candidate; } return null; } /** AI credentials are deliberately environment-addressed. Unlike ordinary * Host chrome configuration, an unqualified `ai.config.yaml` can never become * an accidental fallback for a different deployment environment. */ function resolveEnvironmentAiConfigPath( configRoot: string, environment: SomaEnvName, ): string | null { const candidate = path.join(configRoot, `ai.config.${environment}.yaml`); return fs.existsSync(candidate) ? candidate : null; } function loadYamlConfig(configPath: string | null): T | undefined { if (!configPath) return undefined; try { const parsed = yaml.load(fs.readFileSync(configPath, 'utf8')); return isRecord(parsed) ? parsed as T : undefined; } catch { return undefined; } } function loadAiYamlConfig(configPath: string | null): AiConfigDocument | undefined { if (!configPath) return undefined; try { const parsed = yaml.load(fs.readFileSync(configPath, 'utf8')); return isRecord(parsed) ? parsed as AiConfigDocument : undefined; } catch { throw new AiApiError(AiErrorCodes.AI_CONFIG_MISSING, 'AI 配置文件无法解析'); } } export function loadHostAppConfig(hostDir = resolveHostDir()): RuntimeConfigDocument { return loadYamlConfig( resolveEnvironmentConfigPath(resolveHostConfigRoot(hostDir), 'app.config'), ) ?? {}; } /** * Load the `ai.config..yaml` credentials. They are Host-owned: the package * ships no template, so an unconfigured Host resolves to `undefined` and the AI * endpoints report `AI_CONFIG_MISSING`. * * `SOMA_AI_CONFIG_ROOT` / `SOMA_ADMIN_CONFIG_ROOT` keep the Admin Shell * platform-config contract: when set, only that directory is read. */ export function loadAdminAiConfig(hostDir = resolveHostDir()): AiConfigDocument | undefined { const explicitRoot = readEnv('SOMA_AI_CONFIG_ROOT') ?? readEnv('SOMA_ADMIN_CONFIG_ROOT'); const configRoot = explicitRoot ? path.resolve(explicitRoot) : resolveHostConfigRoot(hostDir); return loadAiConfigFromRoot(configRoot, readSomaEnv()); } function loadAiConfigFromRoot( configRoot: string, environment: SomaEnvName, ): AiConfigDocument | undefined { return loadAiYamlConfig(resolveEnvironmentAiConfigPath(configRoot, environment)); } function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); }