"use server" import { z } from "zod" import { revalidatePath } from "next/cache" import { createId } from "@paralleldrive/cuid2" import type { ActionState } from "@/shared/types/action-state" import { requirePermission, PermissionDeniedError, getAuthContext, } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import { encryptAiApiKey, getAiErrorMessage, testAiProviderById, testAiProviderConfig } from "@/shared/lib/ai" import { countDefaultAiProviders, createAiProvider, deleteAiProvider as deleteAiProviderRecord, getAiProviderForUpdate, getAiProviderSummaries as fetchAiProviderSummaries, getAiProviderSummariesForUser, updateAiProvider, } from "./data-access" import type { AiProviderSummary, AiProviderVisibility } from "./types" export type { AiProviderSummary } from "./types" const ProviderSchema = z.enum(["zhipu", "openai", "gemini", "custom", "ollama"]) const VisibilitySchema = z.enum(["public", "private"]) const AiProviderFormSchema = z.object({ id: z.string().optional(), provider: ProviderSchema, baseUrl: z.string().url().optional().or(z.literal("")), model: z.string().min(1), apiKey: z.string().min(1).optional(), isDefault: z.boolean().optional(), visibility: VisibilitySchema.optional(), }) const AiProviderTestSchema = AiProviderFormSchema.extend({ apiKey: z.string().optional(), }).superRefine((data, ctx) => { // Ollama 本地部署无需 API Key if (data.provider === "ollama") return if (!data.apiKey?.trim() && !data.id?.trim()) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["apiKey"], message: "API key is required", }) } }) /** Ollama 默认 baseUrl(OpenAI 兼容端点) */ const OLLAMA_DEFAULT_BASE_URL = "http://localhost:11434/v1" /** Ollama 本地部署无需 API Key,使用占位符满足 NOT NULL 约束 */ const OLLAMA_PLACEHOLDER_API_KEY = "ollama" /** 判断是否为不需要 API Key 的本地 Provider */ const isLocalProvider = (provider: string): boolean => provider === "ollama" /** * 校验当前用户身份,返回 { id, isAdmin } * * - 所有 AI_CHAT 用户均可访问(用于管理自己的 private provider) * - isAdmin 标识是否拥有 AI_CONFIGURE 权限(可管理 public provider 与他人 private) */ const ensureUser = async (): Promise<{ id: string; isAdmin: boolean }> => { const ctx = await requirePermission(Permissions.AI_CHAT) return { id: ctx.userId, isAdmin: ctx.permissions.includes(Permissions.AI_CONFIGURE) } } const normalizeBaseUrl = (value: string | undefined): string | null => { const raw = String(value ?? "").trim() if (!raw.length) return null const trimmed = raw.replace(/\/+$/, "") return trimmed .replace(/\/v1\/chat\/completions$/i, "") .replace(/\/chat\/completions$/i, "") } /** * 解析 Provider 的 baseUrl,应用默认值 * * - Ollama:未提供时使用默认本地地址 http://localhost:11434/v1 * - 其他 Provider:未提供时返回 null(由调用方校验) */ const resolveBaseUrl = ( provider: string, raw: string | undefined ): string | null => { const normalized = normalizeBaseUrl(raw) if (normalized) return normalized if (isLocalProvider(provider)) return OLLAMA_DEFAULT_BASE_URL return null } /** * 获取当前用户可见的 AI Provider 列表 * * - 管理员:返回所有 public + private 记录 * - 普通用户:返回 public + 自己创建的 private 记录 */ export async function getAiProviderSummaries(): Promise> { try { const user = await ensureUser() const data = user.isAdmin ? await fetchAiProviderSummaries() : await getAiProviderSummariesForUser(user.id) return { success: true, data } } catch (error) { if (error instanceof PermissionDeniedError) return { success: false, message: error.message } return { success: false, message: "Failed to load AI providers" } } } export async function upsertAiProviderAction( data: z.infer ): Promise> { try { const user = await ensureUser() const parsed = AiProviderFormSchema.safeParse(data) if (!parsed.success) { return { success: false, message: "Invalid form data" } } const payload = parsed.data const baseUrl = resolveBaseUrl(payload.provider, payload.baseUrl) if (!isLocalProvider(payload.provider) && !baseUrl) { return { success: false, message: "Base URL is required for this provider" } } // 可见性规则: // - 管理员可创建/更新 public 或 private // - 普通用户只能创建/更新 private const requestedVisibility: AiProviderVisibility = payload.visibility ?? "private" const visibility: AiProviderVisibility = user.isAdmin ? requestedVisibility : "private" // Parallelize default-count and existing-provider queries const [defaultCount, existing] = await Promise.all([ countDefaultAiProviders(), payload.id ? getAiProviderForUpdate(payload.id, user.id) : Promise.resolve(null), ]) const hasDefault = defaultCount > 0 if (payload.id) { const id = payload.id if (!existing) return { success: false, message: "AI provider not found" } // Ollama 无需 API Key:未提供时使用占位符(仅新建时);更新时保留原值 const nextKey = payload.apiKey?.trim() const effectiveKey = nextKey ? nextKey : isLocalProvider(payload.provider) && !existing.apiKeyEncrypted ? OLLAMA_PLACEHOLDER_API_KEY : null const encrypted = effectiveKey ? encryptAiApiKey(effectiveKey) : existing.apiKeyEncrypted const last4 = effectiveKey ? effectiveKey.slice(-4) : existing.apiKeyLast4 const isNextDefault = payload.isDefault === false && existing.isDefault && defaultCount <= 1 ? true : payload.isDefault ?? existing.isDefault await updateAiProvider( id, { provider: payload.provider, baseUrl, model: payload.model, apiKeyEncrypted: encrypted, apiKeyLast4: last4, isDefault: isNextDefault, visibility, updatedBy: user.id, }, payload.isDefault === true ) revalidatePath("/admin/ai-settings") revalidatePath("/settings") return { success: true, message: "AI provider updated", data: id } } // 新建 Provider:Ollama 允许无 API Key(使用占位符) const rawApiKey = payload.apiKey?.trim() if (!rawApiKey && !isLocalProvider(payload.provider)) { return { success: false, message: "API key is required" } } const effectiveApiKey = rawApiKey ?? OLLAMA_PLACEHOLDER_API_KEY const id = createId() const encrypted = encryptAiApiKey(effectiveApiKey) const last4 = effectiveApiKey.slice(-4) const shouldMakeDefault = payload.isDefault ?? !hasDefault await createAiProvider( { id, provider: payload.provider, baseUrl, model: payload.model, apiKeyEncrypted: encrypted, apiKeyLast4: last4, isDefault: shouldMakeDefault, visibility, createdBy: user.id, updatedBy: user.id, }, shouldMakeDefault ) revalidatePath("/admin/ai-settings") revalidatePath("/settings") return { success: true, message: "AI provider created", data: id } } catch (error) { if (error instanceof PermissionDeniedError) return { success: false, message: error.message } console.error("[upsertAiProviderAction] Failed to save AI provider:", error) return { success: false, message: "Failed to save AI provider" } } } export async function testAiProviderAction( data: z.infer ): Promise> { try { await ensureUser() const parsed = AiProviderTestSchema.safeParse(data) if (!parsed.success) { return { success: false, message: "Invalid form data" } } const payload = parsed.data const baseUrl = resolveBaseUrl(payload.provider, payload.baseUrl) if (!isLocalProvider(payload.provider) && !baseUrl) { return { success: false, message: "Base URL is required for this provider" } } const model = payload.model.trim() const apiKey = payload.apiKey?.trim() // Ollama 无 API Key 时使用占位符进行测试 const effectiveApiKey = apiKey ?? (isLocalProvider(payload.provider) ? OLLAMA_PLACEHOLDER_API_KEY : undefined) if (effectiveApiKey) { await testAiProviderConfig({ apiKey: effectiveApiKey, baseUrl: baseUrl ?? undefined, model }) } else if (payload.id) { await testAiProviderById(payload.id, { baseUrl: baseUrl ?? undefined, model }) } return { success: true, message: "AI connection ok", data: null } } catch (error) { if (error instanceof PermissionDeniedError) return { success: false, message: error.message } return { success: false, message: getAiErrorMessage(error) } } } const DeleteAiProviderSchema = z.object({ id: z.string().min(1), }) /** * 删除 AI Provider * * 权限规则: * - 管理员(AI_CONFIGURE):可删除任意 Provider * - 普通用户(AI_CHAT):仅可删除自己创建的 Provider * * 如果删除的是默认 Provider,自动将最新的一条记录设为默认(若存在)。 */ export async function deleteAiProviderAction( input: z.infer ): Promise> { try { const user = await ensureUser() const parsed = DeleteAiProviderSchema.safeParse(input) if (!parsed.success) { return { success: false, message: "Invalid provider id" } } // 管理员不传 userId(可删除任意);普通用户传 userId 做所有权校验 await deleteAiProviderRecord( parsed.data.id, user.isAdmin ? undefined : user.id ) revalidatePath("/admin/ai-settings") revalidatePath("/settings") return { success: true, message: "AI provider deleted", data: null } } catch (error) { if (error instanceof PermissionDeniedError) return { success: false, message: error.message } return { success: false, message: "Failed to delete AI provider" } } } /** * 检查当前用户是否拥有 AI_CONFIGURE 权限(管理员) * * 供 UI 层决定是否显示 public 可见性选项。 */ export async function canConfigurePublicAiProvider(): Promise> { try { const ctx = await getAuthContext() return { success: true, data: ctx.permissions.includes(Permissions.AI_CONFIGURE) } } catch (error) { if (error instanceof PermissionDeniedError) return { success: false, message: error.message, data: false } return { success: false, message: "Failed to check permission", data: false } } }