diff --git a/deploy/.env.staging.identity.example b/deploy/.env.staging.identity.example index ff80c204..be70e398 100644 --- a/deploy/.env.staging.identity.example +++ b/deploy/.env.staging.identity.example @@ -19,6 +19,7 @@ RESEND_API_KEY= RESEND_FROM_EMAIL=Jyotisha Staging ADMIN_EMAILS= EPAY_CONFIG_ENCRYPTION_KEY= +MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY= EPAY_CHAT_ENABLED=false JYOTISH_DYNAMIC_RECTIFICATION_TOKEN= diff --git a/deploy/README.md b/deploy/README.md index a70b3911..0383f00f 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -85,20 +85,14 @@ EPAY_CHAT_ENABLED=false # Fixed birth-time rectification fee shown by the account UI. RECTIFICATION_PRICE_CREDITS=3 -# Recommended multi-model catalog. The JSON references server-only keys. -LLM_DEFAULT_MODEL_ID=deepseek-pro -LLM_MODELS_JSON='[{"id":"deepseek-pro","label":"DeepSeek V4 Pro","description":"更适合复杂分析","provider":"openai-compatible","baseURL":"https://api.deepseek.com","apiKeyEnv":"DEEPSEEK_API_KEY","model":"deepseek-v4-pro","creditCost":1},{"id":"gpt-5-mini","label":"ChatGPT 5 Mini","description":"响应稳定、速度均衡","provider":"openai","apiKeyEnv":"OPENAI_API_KEY","model":"openai/gpt-5-mini","creditCost":1}]' -DEEPSEEK_API_KEY= -OPENAI_API_KEY= +# Required to encrypt/decrypt model-provider API keys stored in the admin database. +# Base64 decoding must produce exactly 32 random bytes; do not reuse other keys. +MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY= +# OpenAI-compatible origins must be explicitly server-allowlisted. +# MODEL_PROVIDER_BASE_URL_ALLOWLIST=https://api.deepseek.com -# Legacy single-model OpenAI configuration remains supported: -# OPENAI_API_KEY= -# MASTRA_MODEL=openai/gpt-5-mini - -# Legacy single OpenAI-compatible provider remains supported: -# LLM_BASE_URL=https://provider.example/v1 -# LLM_API_KEY= -# LLM_MODEL=provider-model-id +# The admin database model catalog is the only runtime model/provider source. +# Provider API keys are entered in the admin UI and are never read from provider env vars. # Required VedAstro server-side upstream for chart creation and rectification: VEDASTRO_GATEWAY_MODE=official_first diff --git a/deploy/validate-staging-env.sh b/deploy/validate-staging-env.sh index b0e96aaf..ddd735ee 100755 --- a/deploy/validate-staging-env.sh +++ b/deploy/validate-staging-env.sh @@ -122,6 +122,18 @@ if [ "${#LITERAL_VALUE}" -ne 44 ] || echo "invalid staging identity setting: EPAY_CONFIG_ENCRYPTION_KEY" >&2 exit 1 fi +require_literal MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY 44 +if [ "${#LITERAL_VALUE}" -ne 44 ] || + [[ ! "$LITERAL_VALUE" =~ ^[A-Za-z0-9+/]{43}=$ ]]; then + echo "invalid staging model provider setting: MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY" >&2 + exit 1 +fi +legacy_model_setting_pattern='^(OPENAI_API_KEY|ANTHROPIC_API_KEY|DEEPSEEK_API_KEY|LLM_API_KEY|LLM_MODELS_JSON|LLM_BASE_URL|LLM_MODEL|LLM_DEFAULT_MODEL_ID|LLM_PROVIDER_ID|MASTRA_MODEL|MODEL_PROVIDER_[A-Z0-9_]+_API_KEY)=' +if grep -Eq "$legacy_model_setting_pattern" "$ENV_FILE"; then + echo "legacy model environment settings are forbidden" >&2 + exit 1 +fi + require_selector EPAY_CHAT_ENABLED false require_literal JYOTISH_DYNAMIC_RECTIFICATION_TOKEN 32 diff --git a/frontend/README.md b/frontend/README.md index ddae818c..7c8c79ad 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -40,27 +40,14 @@ $EDITOR .env.local # Python 占星计算服务 JYOTISH_API_BASE=http://127.0.0.1:5200 -# 推荐:多模型目录。目录只保存路由元数据,Key 由 apiKeyEnv 引用。 -LLM_DEFAULT_MODEL_ID=deepseek-pro -LLM_MODELS_JSON='[{"id":"deepseek-pro","label":"DeepSeek V4 Pro","description":"更适合复杂分析","provider":"openai-compatible","baseURL":"https://api.deepseek.com","apiKeyEnv":"DEEPSEEK_API_KEY","model":"deepseek-v4-pro","creditCost":1},{"id":"gpt-5-mini","label":"ChatGPT 5 Mini","description":"响应稳定、速度均衡","provider":"openai","apiKeyEnv":"OPENAI_API_KEY","model":"openai/gpt-5-mini","creditCost":1}]' -DEEPSEEK_API_KEY= -OPENAI_API_KEY= +# 模型供应商配置加密主密钥:严格 Base64 编码的 32 字节随机值 +MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY= -# 兼容旧的单模型 OpenAI 配置 -# OPENAI_API_KEY= -# MASTRA_MODEL=openai/gpt-5-mini - -# 兼容旧的单个 OpenAI-compatible 配置 -# LLM_BASE_URL=https://your-provider.example/v1 -# LLM_API_KEY= -# LLM_MODEL=your-model-id -# LLM_PROVIDER_ID=third-party - -# 可选:部署目录与本仓结构不同时,显式指定 Mastra Skill 目录 -# JYOTISH_SKILL_PATH=/absolute/path/to/yinduzhanxing/skills/jyotish-vedic-astrology +# OpenAI-compatible 自定义端点还必须加入服务端 origin allowlist +# MODEL_PROVIDER_BASE_URL_ALLOWLIST=https://api.deepseek.com ``` -第三方端点必须兼容 OpenAI 的 Chat Completions 调用方式,并支持工具调用(function calling),否则 Agent 无法稳定调用占星计算工具。`LLM_MODELS_JSON` 只能填写服务端认可的固定地址和模型;浏览器只会得到模型 ID、名称、说明和点数。密钥只放在 `.env.local`,**不要**加 `NEXT_PUBLIC_` 前缀,也不要提交到 Git。每次修改 `.env.local` 后重启 Next.js 开发服务器。 +模型目录、供应商地址及 API key 均通过管理端配置;API key 使用上述主密钥 AES-256-GCM 加密后存入数据库。运行时不读取 `LLM_MODELS_JSON`、`LLM_BASE_URL`、`LLM_MODEL` 或供应商 API-key 环境变量。 ## Skill 如何触发 @@ -240,14 +227,11 @@ NEXT_PUBLIC_SUPABASE_URL=... NEXT_PUBLIC_SUPABASE_ANON_KEY=... SUPABASE_SERVICE_ROLE_KEY=... ADMIN_EMAILS=... -LLM_DEFAULT_MODEL_ID=deepseek-pro -LLM_MODELS_JSON='[{"id":"deepseek-pro","label":"DeepSeek V4 Pro","description":"更适合复杂分析","provider":"openai-compatible","baseURL":"https://api.deepseek.com","apiKeyEnv":"DEEPSEEK_API_KEY","model":"deepseek-v4-pro","creditCost":1},{"id":"gpt-5-mini","label":"ChatGPT 5 Mini","description":"响应稳定、速度均衡","provider":"openai","apiKeyEnv":"OPENAI_API_KEY","model":"openai/gpt-5-mini","creditCost":1}]' -DEEPSEEK_API_KEY=... -OPENAI_API_KEY=... +MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY= JYOTISH_API_BASE=https://your-python-api.example.com ``` -多个模型优先使用 `LLM_MODELS_JSON`;旧的 `LLM_BASE_URL`、`LLM_API_KEY`、`LLM_MODEL` 单模型配置仍兼容。所有服务端 Key 只配置在 Vercel,不要写进浏览器代码。 +模型目录及供应商 API key 仅由管理端数据库配置提供;环境只保留 `MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY` 主密钥。不要把任何密钥写进浏览器代码。 ### Python 服务必须单独部署 diff --git a/frontend/src/app/api/admin/models/discover/route.ts b/frontend/src/app/api/admin/models/discover/route.ts new file mode 100644 index 00000000..ff89146e --- /dev/null +++ b/frontend/src/app/api/admin/models/discover/route.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; +import { queryAdminRows } from "@/lib/admin/database"; +import { adminErrorResponse, invalidQueryResponse, requireHighRiskAdminMutation } from "@/lib/admin/http"; +import { requestAllowedModelProvider } from "@/lib/epay/gateway-policy"; +import { decryptModelProviderApiKey, modelProviderModelsUrl, modelProviderRequestHeaders, type ModelProviderType } from "@/lib/model-provider-policy"; + +export const runtime="nodejs"; +const schema=z.object({providerId:z.string().uuid()}).strict(); +type Row={provider_type:ModelProviderType;base_url:string|null;encrypted_api_key:string|null;enabled:boolean}; +function normalize(payload:unknown){const data=payload&&typeof payload==="object"&&"data" in payload?(payload as {data?:unknown}).data:null;if(!Array.isArray(data))return[];const seen=new Set();const out:{id:string;label:string}[]=[];for(const item of data){if(out.length>=200)break;if(!item||typeof item!=="object")continue;const raw="id" in item?(item as {id?:unknown}).id:undefined;if(typeof raw!=="string")continue;const id=raw.trim();if(!id||id.length>160||seen.has(id))continue;seen.add(id);const display="display_name" in item?(item as {display_name?:unknown}).display_name:undefined;out.push({id,label:typeof display==="string"&&display.trim()?display.trim().slice(0,160):id});}return out;} +export async function POST(request:Request){try{await requireHighRiskAdminMutation(request,"models.test");const parsed=schema.safeParse(await request.json().catch(()=>null));if(!parsed.success)return invalidQueryResponse(parsed.error.flatten());const rows=await queryAdminRows("select provider_type,base_url,encrypted_api_key,enabled from public.model_providers where id=$1",[parsed.data.providerId]);const p=rows[0];if(!p||!p.enabled)return Response.json({error:"模型供应商不可用"},{status:404});let key="";try{key=decryptModelProviderApiKey({encryptedApiKey:p.encrypted_api_key});}catch{return Response.json({error:"模型供应商配置不可用"},{status:409});}const upstream=await requestAllowedModelProvider(modelProviderModelsUrl({providerType:p.provider_type,baseUrl:p.base_url}),modelProviderRequestHeaders(p.provider_type,key),process.env,{timeoutMs:8000,maxResponseBytes:256*1024});if(upstream.status<200||upstream.status>299)return Response.json({error:"模型发现失败"},{status:502});let payload:unknown;try{payload=JSON.parse(upstream.body.toString("utf8"));}catch{return Response.json({error:"模型发现失败"},{status:502});}return Response.json({data:normalize(payload)});}catch(e){return adminErrorResponse(e);}} diff --git a/frontend/src/app/api/admin/models/route.ts b/frontend/src/app/api/admin/models/route.ts index b84bb924..90c0ce0d 100644 --- a/frontend/src/app/api/admin/models/route.ts +++ b/frontend/src/app/api/admin/models/route.ts @@ -18,19 +18,16 @@ import { modelSettingsContainSecrets, sanitizeModelSettings, } from "@/lib/model-catalog"; -import { modelProviderSecretValue } from "@/lib/model-provider-policy"; export const runtime = "nodejs"; -const secretRefSchema = z.string().regex(/^env:[A-Z][A-Z0-9_]*$/); const providerSchema = z.object({ action: z.literal("saveProvider"), id: z.string().uuid().nullable().optional(), - code: z.string().regex(/^[a-z][a-z0-9_-]{1,63}$/), name: z.string().trim().min(1).max(80), - providerType: z.enum(["openai", "openai-compatible"]), + providerType: z.enum(["openai", "openai-compatible", "anthropic"]), baseUrl: z.string().url().startsWith("https://").nullable().optional(), - secretRef: secretRefSchema.optional(), + apiKey: z.string().max(4096).optional(), enabled: z.boolean(), reason: z.string().trim().min(1).max(500), }).strict(); @@ -74,9 +71,9 @@ type ProviderRow = { id: string; code: string; name: string; - provider_type: "openai" | "openai-compatible"; + provider_type: "openai" | "openai-compatible" | "anthropic"; base_url: string | null; - secret_ref: string; + encrypted_api_key: string | null; enabled: boolean; updated_at: Date; }; @@ -148,7 +145,7 @@ export async function GET(request: Request) { and ($2::text is null or v.status=$2) order by v.created_at desc limit $3 offset $4 `, [q, parsed.data.status ?? null, parsed.data.pageSize, pageOffset(parsed.data.page, parsed.data.pageSize)]), - queryAdminRows("select id,code,name,provider_type,base_url,secret_ref,enabled,updated_at from public.model_providers order by code"), + queryAdminRows("select id,code,name,provider_type,base_url,(encrypted_api_key is not null) secret_configured,enabled,updated_at from public.model_providers order by code"), ]); return NextResponse.json({ data: models.map(modelOutput), @@ -159,11 +156,7 @@ export async function GET(request: Request) { name: provider.name, providerType: provider.provider_type, baseUrl: provider.base_url, - secretConfigured: Boolean(modelProviderSecretValue({ - code: provider.code, - providerType: provider.provider_type, - secretRef: provider.secret_ref, - })), + secretConfigured: Boolean((provider as ProviderRow & { secret_configured?: boolean }).secret_configured), enabled: provider.enabled, updatedAt: provider.updated_at.toISOString(), })), @@ -195,7 +188,7 @@ export async function POST(request: Request) { { queryRows: (sql, values) => queryAdminRows>(sql, values), assertAllowedUrl: (value) => assertAllowedModelProviderUrl(value).then(() => undefined), - probeAllowed: (value, apiKey) => probeAllowedModelProvider(value, apiKey), + probeAllowed: (value, headers) => probeAllowedModelProvider(value, headers), invalidateCatalog: invalidateLanguageModelCatalog, }, ); diff --git a/frontend/src/app/api/admin/reauth/route.ts b/frontend/src/app/api/admin/reauth/route.ts index 759a974a..8697b640 100644 --- a/frontend/src/app/api/admin/reauth/route.ts +++ b/frontend/src/app/api/admin/reauth/route.ts @@ -19,7 +19,6 @@ import { adminErrorResponse, invalidQueryResponse, requestCookie, - requireAdminMfaIfRequired, requireAdminMutation, } from "@/lib/admin/http"; @@ -43,7 +42,6 @@ export async function POST(request: Request) { if (!parsed.success) return invalidQueryResponse(parsed.error.flatten()); const session = await requireAdminMutation(request, parsed.data.permission); - requireAdminMfaIfRequired(request, session); const auth = getIdentityEmailOtpApi(); const proofContext = { userId: session.user.id, diff --git a/frontend/src/app/api/birth-time-guide/route.ts b/frontend/src/app/api/birth-time-guide/route.ts index 2d1c6f17..d0ba805d 100644 --- a/frontend/src/app/api/birth-time-guide/route.ts +++ b/frontend/src/app/api/birth-time-guide/route.ts @@ -18,7 +18,7 @@ import { createAdminSupabaseClient } from "@/lib/supabase/admin"; import { isSupabaseConfigurationError } from "@/lib/supabase/config"; import { createServerSupabaseClient } from "@/lib/supabase/server"; import { getBirthTimeGuideAgent } from "@/mastra"; -import { defaultLanguageModel } from "@/mastra/model"; +import { loadLanguageModelCatalog } from "@/lib/model-catalog"; import { recordJourneyTransitionMetric, } from "@/lib/birth-time-journey-telemetry"; @@ -71,7 +71,8 @@ export async function POST(request: Request) { store, engine: createJyotishBirthTimeJourneyEngine(), }); - const model = defaultLanguageModel(); + const catalog = await loadLanguageModelCatalog(); + const model = catalog.models.find((entry) => entry.id === catalog.defaultModelId) ?? null; const generator = model ? { async generate(prompt: string) { diff --git a/frontend/src/app/api/consult/route.ts b/frontend/src/app/api/consult/route.ts index c6afb669..b81688da 100644 --- a/frontend/src/app/api/consult/route.ts +++ b/frontend/src/app/api/consult/route.ts @@ -6,7 +6,6 @@ import { getJyotishAgent, runConsultationWorkflow, } from "@/mastra"; -import { languageModelConfigurationMessage } from "@/mastra/model"; import { blocksPromptExtraction } from "@/lib/consult-safety"; import { consultationEntrypointSchema, @@ -148,6 +147,16 @@ export async function POST(request: Request) { chatSession.model_config_version, ); + if (!sessionModel) { + return NextResponse.json( + { + error: "模型暂不可用", + message: "当前会话绑定的数据库模型配置不可用,请联系管理员,本次不会扣点。", + }, + { status: 503 }, + ); + } + if (parsed.data.entrypoint === "birth_time_rectification") { return NextResponse.json( { @@ -406,9 +415,7 @@ export async function POST(request: Request) { { error: "暂时无法生成解读", message: "咨询服务暂时不可用,请稍后再试。", - recovery: languageModelConfigurationMessage() - ? "当前没有可用的咨询模型,请联系管理员。" - : "稍后重试,或换一个模型继续。", + recovery: "稍后重试,或换一个模型继续。", }, { status: 503 }, ); diff --git a/frontend/src/app/api/health/route.ts b/frontend/src/app/api/health/route.ts index ff89ab68..209a7c44 100644 --- a/frontend/src/app/api/health/route.ts +++ b/frontend/src/app/api/health/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { getTruthSourceRuntimeIdentity } from "@/lib/truth-source-runtime-identity"; +import { loadLanguageModelCatalog } from "@/lib/model-catalog"; type Check = { status: "ok" | "degraded" | "blocked"; @@ -16,12 +17,6 @@ function envCheck(names: string[]): Check { : { status: "ok" }; } -function anyEnvCheck(names: string[]): Check { - return names.some((name) => process.env[name]) - ? { status: "ok" } - : { status: "blocked", message: `missing_one_of:${names.join("|")}` }; -} - async function jyotishApiCheck(): Promise { const started = Date.now(); const controller = new AbortController(); @@ -47,6 +42,14 @@ async function jyotishApiCheck(): Promise { } } +async function modelCatalogCheck(): Promise { + const catalog = await loadLanguageModelCatalog(); + const defaults = catalog.models.filter((model) => model.isDefault && model.id === catalog.defaultModelId); + return defaults.length === 1 + ? { status: "ok" } + : { status: "blocked", message: catalog.issues[0] ?? "default_model_unavailable" }; +} + function aggregate(checks: Record) { if (Object.values(checks).some((check) => check.status === "blocked")) return "blocked"; if (Object.values(checks).some((check) => check.status === "degraded")) return "degraded"; @@ -72,7 +75,8 @@ export async function GET() { const checks = { web: { status: "ok" } satisfies Check, ...databaseChecks, - modelProvider: anyEnvCheck(["LLM_MODELS_JSON", "OPENAI_API_KEY", "LLM_API_KEY", "DEEPSEEK_API_KEY"]), + modelProviderEncryption: envCheck(["MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY"]), + modelCatalog: await modelCatalogCheck(), jyotishApi: await jyotishApiCheck(), researchTruthSource: { status: truthSourceIdentity.status, diff --git a/frontend/src/app/api/onboarding/route.ts b/frontend/src/app/api/onboarding/route.ts index 64202e04..fca53275 100644 --- a/frontend/src/app/api/onboarding/route.ts +++ b/frontend/src/app/api/onboarding/route.ts @@ -5,7 +5,7 @@ import { import { createAdminSupabaseClient } from "@/lib/supabase/admin"; import { createServerSupabaseClient } from "@/lib/supabase/server"; import { getOnboardingAgent } from "@/mastra"; -import { defaultLanguageModel, resolveLanguageModel } from "@/mastra/model"; +import { loadLanguageModelCatalog } from "@/lib/model-catalog"; export const runtime = "nodejs"; export const maxDuration = 30; @@ -66,7 +66,10 @@ export const POST = createOnboardingPost({ }, generateText: async (name, signal) => { const preferredModelId = process.env.ONBOARDING_MODEL_ID?.trim() || "deepseek-v4-flash"; - const model = resolveLanguageModel(preferredModelId) ?? defaultLanguageModel(); + const catalog = await loadLanguageModelCatalog(); + const model = catalog.models.find((entry) => entry.id === preferredModelId) + ?? catalog.models.find((entry) => entry.id === catalog.defaultModelId) + ?? null; if (!model) return null; const result = await getOnboardingAgent(model).generate([ { diff --git a/frontend/src/app/api/reports/route.ts b/frontend/src/app/api/reports/route.ts index 588e7470..07bbe006 100644 --- a/frontend/src/app/api/reports/route.ts +++ b/frontend/src/app/api/reports/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; import { runConsultationWorkflow } from "@/mastra"; import { createPersonalReportAgent } from "@/mastra/personal-report"; -import { defaultLanguageModel } from "@/mastra/model"; +import { loadLanguageModelCatalog } from "@/lib/model-catalog"; import { resolveSkillSnapshot, } from "@/lib/personal-report-generation"; @@ -62,6 +62,8 @@ export async function POST(request: Request) { profileError = result.error; } + const catalog = await loadLanguageModelCatalog(); + const defaultModel = catalog.models.find((entry) => entry.id === catalog.defaultModelId) ?? null; const deps: ReportCreateCoreDeps = { requestUrl: request.url, origin: request.headers.get("origin"), @@ -119,7 +121,7 @@ export async function POST(request: Request) { }, }, persistence, - model: defaultLanguageModel(), + model: defaultModel, runWorkflow: (input) => runConsultationWorkflow(input), createAgent: (model) => createPersonalReportAgent(model as Parameters[0]), skillSnapshot: resolveSkillSnapshot(), diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 3b8fd70a..30e6af0f 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -788,8 +788,8 @@ button:disabled { cursor: default; opacity: .45; } .synastry-history-item { display: grid; gap: 3px; padding: var(--space-3); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); color: var(--color-ink); text-align: left; } .synastry-history-item small { color: var(--color-ink-secondary); font-size: var(--type-caption); } -input, select { width: 100%; min-height: 44px; padding: 0 12px; border: 1px solid var(--color-border-strong); color: var(--color-ink); border-color: var(--color-border-strong); border-radius: var(--radius-md); background: var(--color-canvas); font-size: 14px; } -input:disabled, select:disabled { color: var(--color-ink-tertiary); background: var(--color-canvas-muted); } +input:not([class^="ant-"]):not([class*=" ant-"]):not(.ant-picker input), select:not([class^="ant-"]):not([class*=" ant-"]) { width: 100%; min-height: 44px; padding: 0 12px; border: 1px solid var(--color-border-strong); color: var(--color-ink); border-color: var(--color-border-strong); border-radius: var(--radius-md); background: var(--color-canvas); font-size: 14px; } +input:not([class^="ant-"]):not([class*=" ant-"]):not(.ant-picker input):disabled, select:not([class^="ant-"]):not([class*=" ant-"]):disabled { color: var(--color-ink-tertiary); background: var(--color-canvas-muted); } .button-primary, .button-secondary { min-height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0 15px; border: 1px solid var(--color-action); cursor: pointer; text-decoration: none; transition: background-color 120ms ease-out, color 120ms ease-out, transform 120ms ease-out; border-radius: var(--radius-md); font-size: 14px; font-weight: 500; } .button-primary { border-color: var(--color-action); background: var(--color-action); color: var(--color-on-dark); } .button-secondary { border-color: var(--color-border-strong); background: var(--color-canvas); color: var(--color-ink); } diff --git a/frontend/src/components/admin/admin-app.tsx b/frontend/src/components/admin/admin-app.tsx index 301052e5..271f406a 100644 --- a/frontend/src/components/admin/admin-app.tsx +++ b/frontend/src/components/admin/admin-app.tsx @@ -2,7 +2,6 @@ import { ApiOutlined, - ArrowLeftOutlined, AuditOutlined, ControlOutlined, CreditCardOutlined, @@ -17,10 +16,9 @@ import { UserOutlined, } from "@ant-design/icons"; import { Authenticated, Refine } from "@refinedev/core"; -import { ErrorComponent, ThemedLayout, ThemedSider, useNotificationProvider } from "@refinedev/antd"; +import { ErrorComponent, ThemedLayout, useNotificationProvider } from "@refinedev/antd"; import routerProvider from "@refinedev/nextjs-router"; -import { App as AntdApp, ConfigProvider, Menu, Spin, theme } from "antd"; -import Link from "next/link"; +import { App as AntdApp, ConfigProvider, Spin, theme } from "antd"; import type { ReactNode } from "react"; import { @@ -29,26 +27,11 @@ import { adminDataProvider, } from "@/lib/admin/providers"; -function AdminSider() { - return ( - ( - <> - {items} - } title="返回对话"> - {collapsed ? null : "返回对话"} - - - )} - /> - ); -} - export function AdminApp({ children }: { children: ReactNode }) { const notificationProvider = useNotificationProvider(); return (
- + 正在验证后台权限
} > - {children} + {children} diff --git a/frontend/src/components/admin/model-management.tsx b/frontend/src/components/admin/model-management.tsx index 24f6141d..892b6902 100644 --- a/frontend/src/components/admin/model-management.tsx +++ b/frontend/src/components/admin/model-management.tsx @@ -29,11 +29,13 @@ import { formatAdminDate } from "./resource-table"; const { Text } = Typography; +type ProviderType = "openai" | "openai-compatible" | "anthropic"; + type Provider = { id: string; code: string; name: string; - providerType: "openai" | "openai-compatible"; + providerType: ProviderType; baseUrl: string | null; secretConfigured: boolean; enabled: boolean; @@ -65,10 +67,10 @@ type ModelVersion = { }; type ProviderForm = { - code: string; name: string; - providerType: "openai" | "openai-compatible"; + providerType: ProviderType; baseUrl: string | null; + apiKey?: string; enabled: boolean; }; type ModelForm = Omit & { @@ -78,15 +80,35 @@ type ModelForm = Omit = { + openai: "OpenAI 官方", + anthropic: "Anthropic 官方", + "openai-compatible": "OpenAI 兼容接口", +}; +const modelTierLabels: Record = { + standard: "标准", + premium: "高级", + internal: "内部", +}; +const modelStatusLabels: Record = { + draft: "草稿", + published: "已发布", + retired: "已下线", +}; + export default function ModelManagement() { const { message } = App.useApp(); const { data: identity } = useGetIdentity(); const [providerForm] = Form.useForm(); const [modelForm] = Form.useForm(); const [filterForm] = Form.useForm(); + const selectedProviderId = Form.useWatch("providerId", modelForm); + const selectedProviderModel = Form.useWatch("providerModel", modelForm); const [models, setModels] = useState([]); const [providers, setProviders] = useState([]); const [loading, setLoading] = useState(true); @@ -95,6 +117,8 @@ export default function ModelManagement() { const [editingProvider, setEditingProvider] = useState(null); const [editingModel, setEditingModel] = useState(null); const [saving, setSaving] = useState(false); + const [discovering, setDiscovering] = useState(false); + const [discoveredModels, setDiscoveredModels] = useState([]); const [actingId, setActingId] = useState(null); const [versionAction, setVersionAction] = useState(null); const [pendingProvider, setPendingProvider] = useState | null>(null); @@ -127,21 +151,26 @@ export default function ModelManagement() { function openProvider(provider?: Provider) { setEditingProvider(provider ?? null); + providerForm.resetFields(); providerForm.setFieldsValue(provider ? { - ...provider, + name: provider.name, + providerType: provider.providerType, baseUrl: provider.baseUrl, + apiKey: "", + enabled: provider.enabled, } : { - code: "", name: "", providerType: "openai-compatible", baseUrl: "https://", + apiKey: "", enabled: false, }); setProviderOpen(true); } - function openModel(model?: ModelVersion) { + function openModel(model?: ModelVersion, providerId?: string) { setEditingModel(model ?? null); + setDiscoveredModels([]); modelForm.setFieldsValue(model ? { modelId: model.modelId, versionId: model.id, @@ -161,7 +190,7 @@ export default function ModelManagement() { reason: "", } : { modelId: "", - providerId: providers[0]?.id, + providerId: providerId ?? providers[0]?.id, label: "", description: "", providerModel: "", @@ -180,13 +209,14 @@ export default function ModelManagement() { } function prepareProviderSave(values: ProviderForm) { + const apiKey = values.apiKey?.trim(); setPendingProvider({ action: "saveProvider", id: editingProvider?.id ?? null, - code: values.code.trim(), name: values.name.trim(), providerType: values.providerType, - baseUrl: values.providerType === "openai" ? null : values.baseUrl?.trim(), + baseUrl: values.providerType === "openai-compatible" ? values.baseUrl?.trim() : null, + ...(apiKey ? { apiKey } : {}), enabled: values.enabled, }); } @@ -202,12 +232,42 @@ export default function ModelManagement() { message.success("供应商配置已保存"); setPendingProvider(null); setProviderOpen(false); + providerForm.resetFields(); await load(); } finally { setSaving(false); } } + async function discoverModels() { + if (!selectedProviderId) return; + setDiscovering(true); + try { + const payload = await adminRequestJson("/api/admin/models/discover", { + method: "POST", + body: JSON.stringify({ providerId: selectedProviderId }), + }); + setDiscoveredModels(payload.data); + if (!payload.data.length) message.info("未发现可用模型,可继续手工输入"); + } catch (error) { + message.error(error instanceof Error ? error.message : "获取模型列表失败"); + } finally { + setDiscovering(false); + } + } + + function selectDiscoveredModel(providerModel: string) { + const discovered = discoveredModels.find((item) => item.id === providerModel); + const values = modelForm.getFieldsValue(["modelId", "label"]); + modelForm.setFieldsValue({ + providerModel, + ...(!values.modelId ? { + modelId: providerModel.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64) || "model", + } : {}), + ...(!values.label ? { label: discovered?.label?.trim() || providerModel } : {}), + }); + } + async function saveModel(values: ModelForm) { setSaving(true); try { @@ -280,22 +340,22 @@ export default function ModelManagement() { const providerColumns: TableColumnsType = [ { title: "供应商", render: (_, item) => {item.name}{item.code} }, - { title: "类型", dataIndex: "providerType" }, - { title: "地址", dataIndex: "baseUrl", render: (value) => value ?? "OpenAI 官方" }, + { title: "类型", dataIndex: "providerType", render: (value: ProviderType) => providerTypeLabels[value] }, + { title: "地址", dataIndex: "baseUrl", render: (value, item) => value ?? (item.providerType === "anthropic" ? "Anthropic 官方" : "OpenAI 官方") }, { title: "密钥状态", render: (_, item) => {item.secretConfigured ? "已配置" : "未配置"} }, { title: "状态", dataIndex: "enabled", render: (value) => value ? 启用 : 停用 }, { title: "更新时间", dataIndex: "updatedAt", render: formatAdminDate }, - { title: "操作", render: (_, item) => canWrite ? : null }, + { title: "操作", render: (_, item) => canWrite ? : null }, ]; const modelColumns: TableColumnsType = [ { title: "模型", render: (_, item) => {item.label}{item.modelId} · v{item.version} }, { title: "供应商模型", render: (_, item) => `${item.providerCode} / ${item.providerModel}` }, - { title: "档位", dataIndex: "modelTier", render: (value) => {value} }, + { title: "档位", dataIndex: "modelTier", render: (value: ModelVersion["modelTier"]) => {modelTierLabels[value]} }, { title: "点数", dataIndex: "creditCost" }, { title: "成本/百万 Token", render: (_, item) => `$${(item.inputCostMicrousdPerMillion / 1_000_000).toFixed(4)} / $${(item.outputCostMicrousdPerMillion / 1_000_000).toFixed(4)}` }, - { title: "路由", render: (_, item) => {item.isDefault && 默认}fallback: {item.fallbackModelId ?? "—"} }, - { title: "状态", render: (_, item) => {item.status}{item.enabled ? 启用 : 停用} }, + { title: "路由", render: (_, item) => {item.isDefault && 默认}回退模型:{item.fallbackModelId ?? "—"} }, + { title: "状态", render: (_, item) => {modelStatusLabels[item.status] ?? item.status}{item.enabled ? 启用 : 停用} }, { title: "发布时间", dataIndex: "publishedAt", render: formatAdminDate }, { title: "操作", @@ -314,7 +374,7 @@ export default function ModelManagement() { } onClick={() => openProvider()}>新增供应商 : null}> - } onClick={() => openModel()} disabled={!providers.length}>新增模型草稿 : null}> + } onClick={() => openModel()} disabled={!providers.length} title={providers.length ? undefined : "请先新增供应商"}>新增模型 : null}> form={filterForm} @@ -333,7 +393,7 @@ export default function ModelManagement() { allowClear placeholder="全部状态" style={{ minWidth: 140 }} - options={["draft", "published", "retired"].map((value) => ({ value, label: value }))} + options={Object.entries(modelStatusLabels).map(([value, label]) => ({ value, label }))} /> @@ -351,12 +411,12 @@ export default function ModelManagement() { - providerForm.submit()} onCancel={() => setProviderOpen(false)} destroyOnHidden> + providerForm.submit()} onCancel={() => { setProviderOpen(false); providerForm.resetFields(); }} destroyOnHidden> form={providerForm} layout="vertical" onFinish={prepareProviderSave}> - - + : null} - 密钥引用由服务器按供应商类型与代码固定映射;控制台不能指定或读取环境变量。 + @@ -366,11 +426,23 @@ export default function ModelManagement() { - setDiscoveredModels([])} options={providers.map((item) => ({ value: item.id, label: `${item.name} (${item.code})` }))} /> + + - + + ({ validator(_, value) { return value && !getFieldValue("enabled") ? Promise.reject(new Error("默认模型必须启用")) : Promise.resolve(); } })]}> diff --git a/frontend/src/components/admin/reason-action-modal.tsx b/frontend/src/components/admin/reason-action-modal.tsx index bc4d48d2..e1173035 100644 --- a/frontend/src/components/admin/reason-action-modal.tsx +++ b/frontend/src/components/admin/reason-action-modal.tsx @@ -1,13 +1,10 @@ "use client"; -import { Alert, Button, Form, Input, Modal, Radio, Space, Spin, Typography } from "antd"; -import Link from "next/link"; +import { Alert, Button, Form, Input, Modal, Space } from "antd"; import { useState } from "react"; import type { AdminPermission } from "@/lib/admin/auth-policy"; -const { Text } = Typography; - type ReasonActionModalProps = { open: boolean; title: string; @@ -19,22 +16,15 @@ type ReasonActionModalProps = { onSubmit: (reason: string) => void | Promise; }; -type FormValues = { reason: string; otp?: string; mfaCode?: string }; -type MfaFactor = "totp" | "backup"; -type MfaStatus = { - required: boolean; - enrolled: boolean; - verified: boolean; - highRiskWritesEnabled: boolean; -}; +type FormValues = { reason: string; otp?: string }; -async function adminSecurityRequest(url: string, body?: object): Promise { +async function adminSecurityRequest(url: string, body: object): Promise { const response = await fetch(url, { - method: body ? "POST" : "GET", + method: "POST", cache: "no-store", credentials: "same-origin", - headers: body ? { "content-type": "application/json" } : undefined, - body: body ? JSON.stringify(body) : undefined, + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), }); const value = await response.json().catch(() => null) as { data?: T; @@ -57,59 +47,12 @@ export function ReasonActionModal({ onSubmit, }: ReasonActionModalProps) { const [form] = Form.useForm(); - const [mfaStatus, setMfaStatus] = useState(); - const [mfaFactor, setMfaFactor] = useState("totp"); - const [mfaLoading, setMfaLoading] = useState(false); const [otpSent, setOtpSent] = useState(false); const [reauthLoading, setReauthLoading] = useState(false); const [reauthError, setReauthError] = useState(); - const mfaReady = !reauthPermission - || (mfaStatus !== undefined && (!mfaStatus.required || mfaStatus.verified)); - - async function loadMfaStatus() { - if (!reauthPermission) return; - setMfaLoading(true); - setReauthError(undefined); - try { - setMfaStatus(await adminSecurityRequest("/api/admin/mfa")); - } catch (error) { - setMfaStatus(undefined); - setReauthError(error instanceof Error ? error.message : "无法读取 MFA 状态"); - } finally { - setMfaLoading(false); - } - } - - async function verifyMfa() { - const code = form.getFieldValue("mfaCode")?.trim(); - if (!code) { - setReauthError(mfaFactor === "totp" ? "请输入 6 位认证器验证码" : "请输入恢复码"); - return; - } - if (mfaFactor === "totp" && !/^\d{6}$/.test(code)) { - setReauthError("请输入 6 位认证器验证码"); - return; - } - - setMfaLoading(true); - setReauthError(undefined); - try { - const status = await adminSecurityRequest("/api/admin/mfa", { - action: mfaFactor === "totp" ? "verify" : "recover", - code, - }); - setMfaStatus(status); - form.setFieldValue("mfaCode", undefined); - } catch (error) { - setReauthError(error instanceof Error ? error.message : "MFA 验证失败"); - } finally { - setMfaLoading(false); - } - } - async function requestOtp() { - if (!reauthPermission || !mfaReady) return; + if (!reauthPermission) return; setReauthLoading(true); setReauthError(undefined); try { @@ -152,17 +95,12 @@ export function ReasonActionModal({ confirmLoading={confirmLoading || reauthLoading} okButtonProps={{ danger, - disabled: Boolean(reauthPermission && (!mfaReady || !otpSent)), + disabled: Boolean(reauthPermission && !otpSent), }} onOk={() => form.submit()} onCancel={onCancel} afterOpenChange={(visible) => { - if (visible) { - form.resetFields(); - void loadMfaStatus(); - } - setMfaStatus(undefined); - setMfaFactor("totp"); + if (visible) form.resetFields(); setOtpSent(false); setReauthError(undefined); }} @@ -173,64 +111,9 @@ export function ReasonActionModal({ - {reauthPermission && mfaLoading && !mfaStatus ? - - 正在确认当前 session 的 MFA 状态 - : null} - - {reauthPermission && mfaStatus?.required && !mfaStatus.enrolled ? - 高风险写入已 fail closed。请先前往 安全验证 完成 enrollment。 - } - style={{ marginBottom: 16 }} - /> : null} - - {reauthPermission && mfaStatus?.required && mfaStatus.enrolled && !mfaStatus.verified ? - - { - setMfaFactor(event.target.value as MfaFactor); - form.setFieldValue("mfaCode", undefined); - }} - > - 认证器验证码 - 恢复码 - - - - { - event.preventDefault(); - void verifyMfa(); - }} - /> - - - - : null} - - {reauthPermission && mfaReady ? @@ -239,7 +122,7 @@ export function ReasonActionModal({ noStyle rules={[{ required: true }, { pattern: /^\d{6}$/, message: "请输入 6 位验证码" }]} > - +