From 47164a7702d082ae6029f5cd949099ccf415b50e Mon Sep 17 00:00:00 2001 From: athivaratz Date: Fri, 17 Jul 2026 00:50:23 +0700 Subject: [PATCH 1/6] Enhance setup process and update documentation - Added `.env.setup.local` to `.gitignore` for better environment management. - Introduced new scripts `dev:setup` and `setup:reset` in `package.json` for streamlined sandbox setup. - Updated `README.md` with detailed instructions for using the setup wizard in a sandbox environment. - Refactored setup completion logic in `setup-lock.ts` to improve clarity and maintainability. - Changed `updated_by` field in `wizard-db.ts` to allow null values for better flexibility. --- .env.setup.example | 11 +++ .gitignore | 1 + README.md | 24 +++++ lib/setup/setup-lock.ts | 14 +-- lib/setup/wizard-db.ts | 2 +- package.json | 4 +- scripts/dev-setup-sandbox.ts | 33 +++++++ scripts/lib/load-env-file.ts | 33 +++++++ scripts/reset-setup-sandbox.ts | 175 +++++++++++++++++++++++++++++++++ 9 files changed, 289 insertions(+), 8 deletions(-) create mode 100644 .env.setup.example create mode 100644 scripts/dev-setup-sandbox.ts create mode 100644 scripts/lib/load-env-file.ts create mode 100644 scripts/reset-setup-sandbox.ts diff --git a/.env.setup.example b/.env.setup.example new file mode 100644 index 0000000..f817838 --- /dev/null +++ b/.env.setup.example @@ -0,0 +1,11 @@ +# Setup wizard sandbox — copy to .env.setup.local (gitignored) +# Use a SEPARATE Supabase project from your main .env.local dev/deploy DB. + +NEXT_PUBLIC_SUPABASE_URL=https://YOUR_SANDBOX_PROJECT.supabase.co +NEXT_PUBLIC_SUPABASE_ANON_KEY=your_sandbox_anon_key +SUPABASE_SERVICE_ROLE_KEY=your_sandbox_service_role_key +POSTGRES_URL_NON_POOLING=postgresql://postgres.YOUR_SANDBOX_REF:YOUR_PASSWORD@aws-0-ap-southeast-1.pooler.supabase.com:5432/postgres + +# Optional for local setup testing +NEXT_PUBLIC_APP_URL=http://localhost:3000 +SCHOOL_AUTH_DOMAIN=localhost diff --git a/.gitignore b/.gitignore index 7f39dcb..44cc803 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env.local .env +.env.setup.local # Firebase Service Account Keys (NEVER commit these!) *-firebase-adminsdk-*.json diff --git a/README.md b/README.md index a8c0e89..9ad9759 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,30 @@ bun dev # http://localhost:3000 | `bun run gen:students` / `import:students` | สร้าง/นำเข้ารายชื่อนักเรียนจาก CSV | | `bun run test:login` | ทดสอบ flow ล็อกอินนักเรียน | +### ทดสอบ Setup Wizard แบบ Sandbox (ไม่กระทบ `.env.local`) + +ใช้ **Supabase project แยก** สำหรับลอง wizard ซ้ำๆ โดยไม่ต้อง redeploy และไม่ต้องเปลี่ยน env หลักที่เชื่อม deploy อยู่แล้ว + +```bash +# 1) สร้างโปรเจกต์ Supabase ฟรีอีกตัว (เช่น found-u-setup-sandbox) +cp .env.setup.example .env.setup.local +# แก้ .env.setup.local ใส่ URL / keys / POSTGRES ของ sandbox + +# 2) รีเซ็ตสถานะ wizard (ทำซ้ำได้ทุกครั้งหลังทดสอบ) +bun run setup:reset + +# 3) รัน dev ด้วย sandbox env (override .env.local ชั่วคราว) +bun run dev:setup +# เปิด http://localhost:3000/setup +``` + +| คำสั่ง | ใช้ทำอะไร | +|--------|-----------| +| `bun run dev:setup` | `next dev` โดยโหลด `.env.setup.local` | +| `bun run setup:reset` | รีเซ็ต `setup_status`, branding, AI config, แอดมินที่สร้างจาก wizard | + +DB ว่างครั้งแรก: เปิด `/setup` แล้ว hydrator จะรัน migration อัตโนมัติ (เหมือน production) — ไม่ต้อง `db:push` ถ้าใช้ sandbox ใหม่เปล่าๆ + ## Deploy ให้โรงเรียนใหม่ แนวทางที่แนะนำ: **Clone + Deploy ก่อน** ให้ใช้งานได้จริง แล้วค่อย **Fork** ทีหลังถ้าต้องการ sync อัปเดตจากโค้ดหลัก diff --git a/lib/setup/setup-lock.ts b/lib/setup/setup-lock.ts index c7d743b..5baa8c1 100644 --- a/lib/setup/setup-lock.ts +++ b/lib/setup/setup-lock.ts @@ -43,15 +43,17 @@ export async function markSetupCompletedAtomic( try { const now = new Date().toISOString(); + const configData = { + is_completed: true, + current_step: 3, + completed_at: now, + completed_by: completedBy, + }; + const rows = await sql<{ id: string }[]>` UPDATE public.system_config SET - config_data = jsonb_build_object( - 'is_completed', true, - 'current_step', 3, - 'completed_at', ${now}, - 'completed_by', ${completedBy} - ), + config_data = ${sql.json(configData)}, updated_at = ${now} WHERE id = 'setup_status' AND COALESCE((config_data->>'is_completed')::boolean, false) IS NOT TRUE diff --git a/lib/setup/wizard-db.ts b/lib/setup/wizard-db.ts index 65e7d0a..600f180 100644 --- a/lib/setup/wizard-db.ts +++ b/lib/setup/wizard-db.ts @@ -186,7 +186,7 @@ export async function upsertAppSettingsOg( id: "default", settings: merged, updated_at: now, - updated_by: "setup-wizard", + updated_by: null, }, { onConflict: "id" } ); diff --git a/package.json b/package.json index 55c1818..926660b 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,9 @@ "create:admin": "bun run scripts/create-admin.ts", "test:login": "bun run scripts/test-student-login.ts", "test": "bun test tests", - "db:push": "supabase db push" + "db:push": "supabase db push", + "dev:setup": "bun run scripts/dev-setup-sandbox.ts", + "setup:reset": "bun run scripts/reset-setup-sandbox.ts" }, "dependencies": { "@ai-sdk/google": "^4.0.6", diff --git a/scripts/dev-setup-sandbox.ts b/scripts/dev-setup-sandbox.ts new file mode 100644 index 0000000..a436f6b --- /dev/null +++ b/scripts/dev-setup-sandbox.ts @@ -0,0 +1,33 @@ +#!/usr/bin/env bun +/** + * Run Next.js dev server against .env.setup.local (sandbox Supabase). + * Overrides .env.local so you can test /setup without touching main dev env. + * + * Usage: bun run dev:setup + */ + +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { loadEnvFile } from "./lib/load-env-file"; + +const ENV_FILE = ".env.setup.local"; +const root = resolve(import.meta.dir, ".."); + +if (!existsSync(resolve(root, ENV_FILE))) { + console.error( + `Missing ${ENV_FILE}. Copy .env.setup.example → ${ENV_FILE} and add a dedicated Supabase sandbox project.` + ); + process.exit(1); +} + +loadEnvFile(ENV_FILE, root); + +const child = spawn("bun run dev", { + cwd: root, + env: process.env, + stdio: "inherit", + shell: true, +}); + +child.on("exit", (code) => process.exit(code ?? 0)); diff --git a/scripts/lib/load-env-file.ts b/scripts/lib/load-env-file.ts new file mode 100644 index 0000000..bc3efc8 --- /dev/null +++ b/scripts/lib/load-env-file.ts @@ -0,0 +1,33 @@ +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +/** Load KEY=VALUE pairs from a dotenv file into process.env (overwrites existing). */ +export function loadEnvFile(filename: string, cwd = process.cwd()): void { + const path = resolve(cwd, filename); + if (!existsSync(path)) { + throw new Error(`Env file not found: ${path}`); + } + + for (const rawLine of readFileSync(path, "utf8").split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + + const eq = line.indexOf("="); + if (eq <= 0) continue; + + const key = line.slice(0, eq).trim(); + let value = line.slice(eq + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + process.env[key] = value; + } +} + +export function applyEnvToProcess(filename: string): NodeJS.ProcessEnv { + loadEnvFile(filename); + return { ...process.env }; +} diff --git a/scripts/reset-setup-sandbox.ts b/scripts/reset-setup-sandbox.ts new file mode 100644 index 0000000..314e99d --- /dev/null +++ b/scripts/reset-setup-sandbox.ts @@ -0,0 +1,175 @@ +#!/usr/bin/env bun +/** + * Reset setup wizard state on the sandbox Supabase project (.env.setup.local). + * Re-run the wizard from step 1 without redeploying or swapping main .env.local. + * + * Usage: bun run setup:reset + */ + +import postgres from "postgres"; +import { createClient } from "@supabase/supabase-js"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { loadEnvFile } from "./lib/load-env-file"; + +const ENV_FILE = ".env.setup.local"; +const root = resolve(import.meta.dir, ".."); + +if (!existsSync(resolve(root, ENV_FILE))) { + console.error(`Missing ${ENV_FILE}. Copy .env.setup.example first.`); + process.exit(1); +} + +loadEnvFile(ENV_FILE, root); + +const connectionString = + process.env.POSTGRES_URL_NON_POOLING?.trim() || + process.env.POSTGRES_URL?.trim(); + +if (!connectionString) { + console.error("POSTGRES_URL_NON_POOLING (or POSTGRES_URL) is required in .env.setup.local"); + process.exit(1); +} + +const sql = postgres(connectionString, { + max: 1, + idle_timeout: 5, + connect_timeout: 15, + prepare: false, +}); + +async function tableExists(schema: string, table: string): Promise { + const rows = await sql<{ exists: boolean }[]>` + SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = ${schema} AND table_name = ${table} + ) AS exists + `; + return rows[0]?.exists === true; +} + +async function listStoragePaths( + supabaseUrl: string, + serviceKey: string, + bucket: string, + prefix = "" +): Promise { + const supabase = createClient(supabaseUrl, serviceKey, { + auth: { autoRefreshToken: false, persistSession: false }, + }); + + const { data, error } = await supabase.storage.from(bucket).list(prefix, { + limit: 1000, + }); + if (error) { + if (error.message?.toLowerCase().includes("bucket not found")) { + return []; + } + throw error; + } + if (!data?.length) return []; + + const paths: string[] = []; + for (const item of data) { + const path = prefix ? `${prefix}/${item.name}` : item.name; + if (item.id === null) { + paths.push(...(await listStoragePaths(supabaseUrl, serviceKey, bucket, path))); + } else { + paths.push(path); + } + } + return paths; +} + +async function clearStorageBucket( + supabaseUrl: string, + serviceKey: string, + bucket: string +): Promise { + const supabase = createClient(supabaseUrl, serviceKey, { + auth: { autoRefreshToken: false, persistSession: false }, + }); + + const paths = await listStoragePaths(supabaseUrl, serviceKey, bucket); + if (paths.length === 0) return 0; + + const { error } = await supabase.storage.from(bucket).remove(paths); + if (error) throw error; + return paths.length; +} + +try { + if (!(await tableExists("public", "system_config"))) { + console.log("system_config not found — open /setup once to run hydration, then retry."); + process.exit(0); + } + + await sql` + INSERT INTO public.system_config (id, config_data) + VALUES ('setup_status', '{"is_completed": false, "current_step": 1}'::jsonb) + ON CONFLICT (id) DO UPDATE + SET config_data = '{"is_completed": false, "current_step": 1}'::jsonb, + updated_at = now() + `; + + await sql` + DELETE FROM public.system_config + WHERE id IN ('school_branding', 'ai_credentials') + `; + + if (await tableExists("public", "accounts")) { + const removed = await sql<{ student_id: string | null }[]>` + DELETE FROM public.accounts WHERE role = 'admin' + RETURNING student_id + `; + if (removed.length > 0) { + console.log( + `Removed ${removed.length} wizard admin account(s):`, + removed.map((r) => r.student_id).filter(Boolean).join(", ") || "(no student_id)" + ); + } + } + + if (await tableExists("public", "app_settings")) { + await sql` + UPDATE public.app_settings + SET + settings = COALESCE(settings, '{}'::jsonb) + - 'ogTitle' - 'ogDescription' - 'ogImage' - 'updatedAt' - 'updatedBy', + updated_at = now(), + updated_by = NULL + WHERE id = 'default' + `; + } + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim(); + const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim(); + + if (supabaseUrl && serviceKey) { + try { + const removed = await clearStorageBucket( + supabaseUrl, + serviceKey, + "school-branding" + ); + if (removed > 0) { + console.log(`Removed ${removed} file(s) from school-branding bucket.`); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn(`Could not clear school-branding bucket: ${message}`); + } + } else { + console.warn( + "Skipping storage reset — add NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY to .env.setup.local" + ); + } + + console.log("Setup sandbox reset complete."); + console.log("Next: bun run dev:setup → http://localhost:3000/setup"); +} catch (error) { + console.error("Reset failed:", error); + process.exit(1); +} finally { + await sql.end({ timeout: 5 }); +} From 87ea0e3f604bbe8b5a186932005cb89df95c6f34 Mon Sep 17 00:00:00 2001 From: athivaratz Date: Fri, 17 Jul 2026 01:42:32 +0700 Subject: [PATCH 2/6] Refactor AI settings and models management - Updated AI settings page to improve API key handling and testing functionality. - Enhanced the admin AI models page by removing unused state and functions for better performance. - Revised descriptions and labels for clarity in the admin interface. - Removed deprecated API routes related to OpenRouter and AI models to streamline the codebase. - Improved user experience by providing direct links for API key retrieval in the setup process. --- app/admin/ai/models/page.tsx | 424 ++---------------------- app/admin/ai/openrouter/page.tsx | 84 ----- app/admin/ai/page.tsx | 4 +- app/admin/ai/settings/page.tsx | 360 +++++++++++++++++++- app/api/admin/ai-credentials/route.ts | 114 +++++++ app/api/agent/openrouter/test/route.ts | 106 ------ app/api/ai/models/route.ts | 52 --- app/setup/actions.ts | 11 - app/setup/components/step-ai-config.tsx | 46 +-- components/admin/api-key-label-link.tsx | 33 ++ lib/admin-nav.ts | 2 +- 11 files changed, 551 insertions(+), 685 deletions(-) create mode 100644 app/api/admin/ai-credentials/route.ts delete mode 100644 app/api/agent/openrouter/test/route.ts delete mode 100644 app/api/ai/models/route.ts create mode 100644 components/admin/api-key-label-link.tsx diff --git a/app/admin/ai/models/page.tsx b/app/admin/ai/models/page.tsx index 86064a0..4643ff2 100644 --- a/app/admin/ai/models/page.tsx +++ b/app/admin/ai/models/page.tsx @@ -1,148 +1,30 @@ "use client"; -// Force dynamic rendering for security export const dynamic = "force-dynamic"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useState } from "react"; import Link from "next/link"; import { ArrowLeft, Bot, - RefreshCw, Save, Loader2, CheckCircle2, AlertTriangle, - Search, - Activity, } from "lucide-react"; import { useAuth } from "@/contexts/auth-context"; import { getAppSettingsWithMeta, updateAppSettings } from "@/lib/database"; import { pickSettingsKeys, GEMINI_PIPELINE_SETTING_KEYS } from "@/lib/admin/ai-settings-keys"; import { DEFAULT_APP_SETTINGS, type AppSettings } from "@/lib/types"; -interface ModelInfo { - name: string; - displayName?: string; - description?: string; - supportedGenerationMethods?: string[]; -} - -function normalizeModelName(model: string) { - return model.replace(/^models\//, ""); -} - function parseNumber(value: string) { if (value.trim() === "") return undefined; const parsed = Number(value); return Number.isNaN(parsed) ? undefined : parsed; } -type ProviderName = "gemini" | "openrouter"; - -type ProviderTestState = { - testing: boolean; - result: string | null; -}; - -function formatProviderResult( - name: string, - info: { - configured: boolean; - ok: boolean; - model?: string; - error?: string; - } -): string { - const modelSuffix = info.model ? ` [${info.model}]` : ""; - if (info.ok) return `${name}${modelSuffix}: OK`; - if (!info.configured) return `${name}: no key`; - return `${name}${modelSuffix}: ${info.error || "fail"}`; -} - -function ProviderTestButton({ - label, - provider, - settings, - className, -}: { - label: string; - provider?: ProviderName; - settings: AppSettings; - className?: string; -}) { - const [state, setState] = useState({ - testing: false, - result: null, - }); - - const runTest = async () => { - setState({ testing: true, result: null }); - try { - const res = await fetch("/api/agent/test-providers", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ settings, provider }), - }); - const data = await res.json(); - const lines = Object.entries(data.providers || {}).map(([name, info]) => - formatProviderResult(name, info as Parameters[1]) - ); - setState({ testing: false, result: lines.join(" · ") }); - } catch { - setState({ testing: false, result: "ทดสอบไม่สำเร็จ" }); - } - }; - - const isOk = state.result?.includes(": OK"); - const isFail = state.result && !isOk; - - return ( -
- - {state.result ? ( -

- {isOk ? ( - - ) : isFail ? ( - - ) : null} - {state.result} -

- ) : null} -
- ); -} - -function AgentProviderTestPanel({ settings }: { settings: AppSettings }) { - return ( -
- - - -
- ); -} +const inputClass = + "mt-1 w-full rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-white"; export default function AdminAIModelsPage() { const { user } = useAuth(); @@ -152,34 +34,6 @@ export default function AdminAIModelsPage() { const [settingsLoadError, setSettingsLoadError] = useState(null); const [showSuccess, setShowSuccess] = useState(false); - const [models, setModels] = useState([]); - const [loadingModels, setLoadingModels] = useState(true); - const [modelError, setModelError] = useState(null); - const [searchTerm, setSearchTerm] = useState(""); - - const loadModels = async () => { - setLoadingModels(true); - setModelError(null); - - try { - const response = await fetch("/api/ai/models"); - if (!response.ok) { - const errorText = await response.text(); - setModelError(errorText || "ไม่สามารถโหลดรายการโมเดลได้"); - setLoadingModels(false); - return; - } - - const data = await response.json(); - setModels(data.models || []); - } catch (error) { - console.error("Error loading AI models:", error); - setModelError("เกิดข้อผิดพลาดในการเรียกข้อมูลโมเดล"); - } finally { - setLoadingModels(false); - } - }; - useEffect(() => { let mounted = true; @@ -201,54 +55,11 @@ export default function AdminAIModelsPage() { if (mounted) setLoadingSettings(false); }); - loadModels(); - return () => { mounted = false; }; }, []); - const generateContentModels = useMemo(() => { - const filtered = models.filter((model) => - (model.supportedGenerationMethods || []).includes("generateContent") - ); - - if (!searchTerm.trim()) return filtered; - - const term = searchTerm.toLowerCase(); - return filtered.filter((model) => - (model.displayName || model.name).toLowerCase().includes(term) - ); - }, [models, searchTerm]); - - const nerModelValid = useMemo(() => { - if (!settings.aiNerModel) return false; - return generateContentModels.some( - (model) => normalizeModelName(model.name) === normalizeModelName(settings.aiNerModel || "") - ); - }, [generateContentModels, settings.aiNerModel]); - - const matchingModelValid = useMemo(() => { - if (!settings.aiMatchingModel) return false; - return generateContentModels.some( - (model) => normalizeModelName(model.name) === normalizeModelName(settings.aiMatchingModel || "") - ); - }, [generateContentModels, settings.aiMatchingModel]); - - const visionModelValid = useMemo(() => { - if (!settings.aiVisionModel) return false; - return generateContentModels.some( - (model) => normalizeModelName(model.name) === normalizeModelName(settings.aiVisionModel || "") - ); - }, [generateContentModels, settings.aiVisionModel]); - - const agentModelValid = useMemo(() => { - if (!settings.agentModel) return false; - return generateContentModels.some( - (model) => normalizeModelName(model.name) === normalizeModelName(settings.agentModel || "") - ); - }, [generateContentModels, settings.agentModel]); - const handleSave = async () => { if (!user?.uid) return; @@ -294,7 +105,10 @@ export default function AdminAIModelsPage() { โมเดลที่ใช้ในระบบ

- กำหนดโมเดลสำหรับระบบภายใน + กรอกชื่อโมเดล Gemini โดยตรง — ตั้ง API key ที่{" "} + + ตั้งค่า AI +

- - -
-
- - setSearchTerm(e.target.value)} - placeholder="ค้นหาโมเดล" - className="w-full pl-9 pr-3 py-2 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm text-gray-900 dark:text-white" - /> -
-
- - {modelError && ( -
- {modelError} -
- )} - -
- {loadingModels ? ( -
- - กำลังโหลดรายการโมเดล -
- ) : generateContentModels.length === 0 ? ( -
ไม่พบรายการโมเดล
- ) : ( - generateContentModels.map((model) => ( -
-
-
- {model.displayName || model.name} -
-
- {model.name} -
-
- {(model.supportedGenerationMethods || []).map((method) => ( - - {method} - - ))} -
-
-
- - - - -
-
- )) - )} +
+ + + setSettings((prev) => ({ ...prev, agentModel: e.target.value })) + } + placeholder="models/gemini-2.5-flash" + className={inputClass} + />
diff --git a/app/admin/ai/openrouter/page.tsx b/app/admin/ai/openrouter/page.tsx index 9dc0291..62f9c3a 100644 --- a/app/admin/ai/openrouter/page.tsx +++ b/app/admin/ai/openrouter/page.tsx @@ -15,7 +15,6 @@ import { RefreshCw, Save, AlertTriangle, - Activity, } from "lucide-react"; import { useAuth } from "@/contexts/auth-context"; import { getAppSettingsWithMeta, updateAppSettings } from "@/lib/database"; @@ -34,17 +33,6 @@ type EndpointRow = { uptimeLast30m?: number | null; }; -type ProbeResult = { - ok: boolean; - model: string; - text: string; - finishReason?: string; - nativeFinishReason?: string; - provider?: string; - generationId?: string; - error?: string; -}; - function parseCsvList(value: string): string[] { return value .split(",") @@ -64,14 +52,6 @@ export default function AdminOpenRouterSettingsPage() { const [endpointsLoading, setEndpointsLoading] = useState(false); const [endpointsError, setEndpointsError] = useState(null); - const [testing, setTesting] = useState(false); - const [testResult, setTestResult] = useState(null); - - const modelId = - settings.agentOpenRouterModel || - process.env.NEXT_PUBLIC_OPENROUTER_MODEL || - DEFAULT_APP_SETTINGS.agentOpenRouterModel!; - const selectedOrder = settings.agentOpenRouterProviderOrder ?? []; const ignoreText = (settings.agentOpenRouterProviderIgnore ?? []).join(", "); @@ -169,29 +149,6 @@ export default function AdminOpenRouterSettingsPage() { } }; - const runTest = async () => { - setTesting(true); - setTestResult(null); - try { - const res = await fetch("/api/agent/openrouter/test", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ settings }), - }); - const data = (await res.json()) as ProbeResult; - setTestResult(data); - } catch (error) { - setTestResult({ - ok: false, - model: modelId, - text: "", - error: error instanceof Error ? error.message : "ทดสอบไม่สำเร็จ", - }); - } finally { - setTesting(false); - } - }; - return (
@@ -478,47 +435,6 @@ export default function AdminOpenRouterSettingsPage() {

)}
- -
-
-

- - ทดสอบการเชื่อมต่อ -

- -
- - {testResult ? ( -
- {testResult.ok ? ( - <> -

OK — provider: {testResult.provider || "?"}

-

finish_reason: {testResult.finishReason ?? "-"}

- {testResult.nativeFinishReason ? ( -

native: {testResult.nativeFinishReason}

- ) : null} -

{testResult.text.slice(0, 200)}

- - ) : ( -

{testResult.error || "ทดสอบไม่สำเร็จ"}

- )} -
- ) : null} -
); diff --git a/app/admin/ai/page.tsx b/app/admin/ai/page.tsx index a54806a..1c08944 100644 --- a/app/admin/ai/page.tsx +++ b/app/admin/ai/page.tsx @@ -39,7 +39,7 @@ export default function AdminAIPage() { AI Center

- จัดการโมเดล AI และทดสอบการเชื่อมต่อระบบ + ตั้งค่า AI, API keys และ pipeline models

@@ -80,7 +80,7 @@ export default function AdminAIPage() { Gemini & Pipeline

- NER, Matching, Vision, Gemini Agent model + NER, Matching, Vision — กรอกชื่อโมเดลโดยตรง

diff --git a/app/admin/ai/settings/page.tsx b/app/admin/ai/settings/page.tsx index c04e5d9..d758723 100644 --- a/app/admin/ai/settings/page.tsx +++ b/app/admin/ai/settings/page.tsx @@ -14,6 +14,8 @@ import { Settings2, Sparkles, Route, + Activity, + AlertTriangle, } from "lucide-react"; import { useAuth } from "@/contexts/auth-context"; import { getAppSettingsWithMeta, updateAppSettings } from "@/lib/database"; @@ -22,8 +24,25 @@ import { AGENT_SHARED_SETTING_KEYS, } from "@/lib/admin/ai-settings-keys"; import { AiSettingField } from "@/components/admin/ai-setting-field"; +import { ApiKeyLabelLink } from "@/components/admin/api-key-label-link"; +import { WIZARD_FREE_OPENROUTER_MODELS } from "@/lib/setup/validations/wizard-ai"; import { DEFAULT_APP_SETTINGS, type AppSettings } from "@/lib/types"; +type AiCredentialsMeta = { + provider: "auto" | "gemini" | "openrouter" | "none"; + openrouterModel: string | null; + hasGeminiKey: boolean; + hasOpenrouterKey: boolean; + configuredAt: string | null; +}; + +const KEY_PLACEHOLDER = "••••••••••••••••"; + +function isPlaceholderKeyInput(value: string): boolean { + if (!value.trim()) return true; + return /^[•*.\s]+$/.test(value.trim()); +} + const TABS = [ { id: "agent", label: "Agent ร่วม", icon: Settings2 }, { id: "gemini", label: "Gemini & Pipeline", icon: Sparkles }, @@ -50,6 +69,17 @@ function AdminAiSettingsContent() { const [saving, setSaving] = useState(false); const [showSuccess, setShowSuccess] = useState(false); + const [credentialsMeta, setCredentialsMeta] = useState(null); + const [loadingCredentials, setLoadingCredentials] = useState(false); + const [savingCredentials, setSavingCredentials] = useState(false); + const [credentialsSuccess, setCredentialsSuccess] = useState(false); + const [credentialsError, setCredentialsError] = useState(null); + const [geminiApiKey, setGeminiApiKey] = useState(""); + const [openrouterApiKey, setOpenrouterApiKey] = useState(""); + const [openrouterModel, setOpenrouterModel] = useState(WIZARD_FREE_OPENROUTER_MODELS[0]); + const [testingProvider, setTestingProvider] = useState<"gemini" | "openrouter" | null>(null); + const [testResult, setTestResult] = useState(null); + useEffect(() => { let mounted = true; getAppSettingsWithMeta() @@ -64,6 +94,123 @@ function AdminAiSettingsContent() { }; }, []); + useEffect(() => { + if (activeTab !== "gemini" && activeTab !== "openrouter") return; + + let mounted = true; + setLoadingCredentials(true); + setCredentialsError(null); + + fetch("/api/admin/ai-credentials") + .then(async (res) => { + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || "โหลด credentials ไม่สำเร็จ"); + } + return res.json() as Promise; + }) + .then((data) => { + if (!mounted) return; + setCredentialsMeta(data); + setOpenrouterModel( + data.openrouterModel?.trim() || WIZARD_FREE_OPENROUTER_MODELS[0] + ); + setGeminiApiKey(""); + setOpenrouterApiKey(""); + }) + .catch((error) => { + if (mounted) { + setCredentialsError( + error instanceof Error ? error.message : "โหลด credentials ไม่สำเร็จ" + ); + } + }) + .finally(() => { + if (mounted) setLoadingCredentials(false); + }); + + return () => { + mounted = false; + }; + }, [activeTab]); + + const handleSaveCredentials = async ( + patch: { + geminiApiKey?: string; + openrouterApiKey?: string; + openrouterModel?: string; + } + ) => { + setSavingCredentials(true); + setCredentialsError(null); + setCredentialsSuccess(false); + setTestResult(null); + + const body: Record = {}; + if (patch.geminiApiKey && !isPlaceholderKeyInput(patch.geminiApiKey)) { + body.geminiApiKey = patch.geminiApiKey.trim(); + } + if (patch.openrouterApiKey && !isPlaceholderKeyInput(patch.openrouterApiKey)) { + body.openrouterApiKey = patch.openrouterApiKey.trim(); + } + if (patch.openrouterModel?.trim()) { + body.openrouterModel = patch.openrouterModel.trim(); + } + + try { + const res = await fetch("/api/admin/ai-credentials", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const data = await res.json(); + if (!res.ok) { + throw new Error(data.error || "บันทึกไม่สำเร็จ"); + } + setCredentialsMeta(data); + setGeminiApiKey(""); + setOpenrouterApiKey(""); + setCredentialsSuccess(true); + setTimeout(() => setCredentialsSuccess(false), 3000); + } catch (error) { + setCredentialsError(error instanceof Error ? error.message : "บันทึกไม่สำเร็จ"); + } finally { + setSavingCredentials(false); + } + }; + + const handleTestProvider = async (provider: "gemini" | "openrouter") => { + setTestingProvider(provider); + setTestResult(null); + setCredentialsError(null); + + try { + const res = await fetch("/api/agent/test-providers", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ settings, provider }), + }); + const data = await res.json(); + if (!res.ok) { + throw new Error(data.error || "ทดสอบไม่สำเร็จ"); + } + const info = data.providers?.[provider] as + | { configured?: boolean; ok?: boolean; error?: string; model?: string } + | undefined; + if (info?.ok) { + setTestResult(`${provider}: OK${info.model ? ` [${info.model}]` : ""}`); + } else if (!info?.configured) { + setTestResult(`${provider}: ยังไม่ได้ตั้งค่า API key`); + } else { + setTestResult(`${provider}: ${info?.error || "เชื่อมต่อไม่สำเร็จ"}`); + } + } catch (error) { + setTestResult(error instanceof Error ? error.message : "ทดสอบไม่สำเร็จ"); + } finally { + setTestingProvider(null); + } + }; + const handleSaveAgent = async () => { if (!user?.uid) return; setSaving(true); @@ -310,30 +457,221 @@ function AdminAiSettingsContent() { ) : null} {activeTab === "gemini" ? ( -
-

- ตั้งค่า NER, Matching, Vision และ Gemini Agent model — บันทึกเฉพาะฟิลด์ Gemini - ไม่ทับ OpenRouter +

+

+ ตั้งค่า Gemini API key — บันทึกได้โดยไม่ต้องทดสอบก่อน ตรวจคีย์ด้วยปุ่มทดสอบด้านล่าง

+ + {loadingCredentials ? ( +
+ + กำลังโหลด credentials... +
+ ) : null} + + {credentialsError ? ( +

+ + {credentialsError} +

+ ) : null} + + {credentialsMeta?.hasGeminiKey && !geminiApiKey ? ( +

+ + ตั้งค่า Gemini API key แล้ว — กรอกค่าใหม่เพื่อเปลี่ยน +

+ ) : null} + +
+ + setGeminiApiKey(e.target.value)} + placeholder={ + credentialsMeta?.hasGeminiKey ? KEY_PLACEHOLDER : "AIza..." + } + autoComplete="off" + className={inputClass} + /> +
+ +
+ + +
+ + {credentialsSuccess ? ( +

+ + บันทึกแล้ว +

+ ) : null} + {testResult ? ( +

{testResult}

+ ) : null} + - เปิดหน้าตั้งค่า Gemini & Pipeline เต็มรูปแบบ → + ตั้งค่า NER / Matching / Vision / Agent model →
) : null} {activeTab === "openrouter" ? ( -
-

- Lock provider, reasoning, routing — บันทึกเฉพาะฟิลด์ OpenRouter +

+

+ ตั้งค่า OpenRouter API key และโมเดลเริ่มต้น — routing เพิ่มเติมอยู่ที่หน้า OpenRouter

+ + {loadingCredentials ? ( +
+ + กำลังโหลด credentials... +
+ ) : null} + + {credentialsError ? ( +

+ + {credentialsError} +

+ ) : null} + + {credentialsMeta?.hasOpenrouterKey && !openrouterApiKey ? ( +

+ + ตั้งค่า OpenRouter API key แล้ว — กรอกค่าใหม่เพื่อเปลี่ยน +

+ ) : null} + +
+ + setOpenrouterApiKey(e.target.value)} + placeholder={ + credentialsMeta?.hasOpenrouterKey ? KEY_PLACEHOLDER : "sk-or-..." + } + autoComplete="off" + className={inputClass} + /> +
+ +
+ + +
+ +
+ + +
+ + {credentialsSuccess ? ( +

+ + บันทึกแล้ว +

+ ) : null} + {testResult ? ( +

{testResult}

+ ) : null} + - เปิดหน้าตั้งค่า OpenRouter เต็มรูปแบบ → + ตั้งค่า OpenRouter routing / lock provider →
) : null} diff --git a/app/api/admin/ai-credentials/route.ts b/app/api/admin/ai-credentials/route.ts new file mode 100644 index 0000000..a11f449 --- /dev/null +++ b/app/api/admin/ai-credentials/route.ts @@ -0,0 +1,114 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { clearAiCredentialsCache } from "@/lib/ai/credentials-resolver"; +import { encryptSecret } from "@/lib/setup/credentials-crypto"; +import { getAiCredentialsData, saveAiCredentialsData } from "@/lib/setup/wizard-db"; +import { wizardAiProviderSchema } from "@/lib/setup/validations/wizard-ai"; +import { createClient } from "@/lib/supabase/server"; +import { createAdminClient } from "@/lib/supabase/admin"; + +const patchSchema = z.object({ + provider: wizardAiProviderSchema.optional(), + geminiApiKey: z.string().optional(), + openrouterApiKey: z.string().optional(), + openrouterModel: z.string().optional(), +}); + +function isPlaceholderKey(value: string | undefined): boolean { + if (!value?.trim()) return true; + return /^[•*.\s]+$/.test(value.trim()); +} + +async function isAdminUser(userId: string): Promise { + const admin = createAdminClient(); + const { data } = await admin.from("accounts").select("role").eq("id", userId).maybeSingle(); + return data?.role === "admin"; +} + +async function requireAdmin() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + return { error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }) }; + } + if (!(await isAdminUser(user.id))) { + return { error: NextResponse.json({ error: "Forbidden" }, { status: 403 }) }; + } + return { user }; +} + +export async function GET() { + const auth = await requireAdmin(); + if ("error" in auth && auth.error) return auth.error; + + const record = await getAiCredentialsData(); + if (!record) { + return NextResponse.json({ + provider: "none", + openrouterModel: null, + hasGeminiKey: false, + hasOpenrouterKey: false, + configuredAt: null, + }); + } + + return NextResponse.json({ + provider: record.provider, + openrouterModel: record.openrouter_model ?? null, + hasGeminiKey: Boolean(record.gemini_api_key_encrypted), + hasOpenrouterKey: Boolean(record.openrouter_api_key_encrypted), + configuredAt: record.configured_at ?? null, + }); +} + +export async function PATCH(request: Request) { + const auth = await requireAdmin(); + if ("error" in auth && auth.error) return auth.error; + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const parsed = patchSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: parsed.error.issues[0]?.message ?? "ข้อมูลไม่ถูกต้อง" }, + { status: 400 } + ); + } + + const current = await getAiCredentialsData(); + const provider = parsed.data.provider ?? current?.provider ?? "none"; + + const patch: Parameters[0] = { provider }; + + if (parsed.data.openrouterModel?.trim()) { + patch.openrouter_model = parsed.data.openrouterModel.trim(); + } + + if (!isPlaceholderKey(parsed.data.geminiApiKey)) { + patch.gemini_api_key_encrypted = encryptSecret(parsed.data.geminiApiKey!.trim()); + } + + if (!isPlaceholderKey(parsed.data.openrouterApiKey)) { + patch.openrouter_api_key_encrypted = encryptSecret(parsed.data.openrouterApiKey!.trim()); + } + + await saveAiCredentialsData(patch); + clearAiCredentialsCache(); + + const updated = await getAiCredentialsData(); + return NextResponse.json({ + ok: true, + provider: updated?.provider ?? provider, + openrouterModel: updated?.openrouter_model ?? null, + hasGeminiKey: Boolean(updated?.gemini_api_key_encrypted), + hasOpenrouterKey: Boolean(updated?.openrouter_api_key_encrypted), + configuredAt: updated?.configured_at ?? null, + }); +} diff --git a/app/api/agent/openrouter/test/route.ts b/app/api/agent/openrouter/test/route.ts deleted file mode 100644 index 800ffbd..0000000 --- a/app/api/agent/openrouter/test/route.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { NextResponse } from "next/server"; -import { createClient } from "@/lib/supabase/server"; -import { createAdminClient } from "@/lib/supabase/admin"; -import { probeOpenRouterChat } from "@/lib/agent/openrouter-api"; -import { - resolveAiCredentials, - getOpenRouterApiKey, - getOpenRouterModel, -} from "@/lib/ai/credentials-resolver"; -import { - buildOpenRouterRequestExtras, - type OpenRouterRequestExtras, -} from "@/lib/agent/openrouter-routing"; -import { normalizeAgentSettings } from "@/lib/agent/normalize-agent-settings"; -import { getAppSettingsAdmin } from "@/lib/ai-rate-limit"; -import { - AGENT_DEFAULT_MAX_OUTPUT_TOKENS, - DEFAULT_APP_SETTINGS, - type AppSettings, -} from "@/lib/types"; - -async function isAdminUser(userId: string): Promise { - const admin = createAdminClient(); - const { data } = await admin - .from("accounts") - .select("role") - .eq("id", userId) - .maybeSingle(); - return data?.role === "admin"; -} - -async function requireAdmin() { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - if (!user) { - return { error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }) }; - } - if (!(await isAdminUser(user.id))) { - return { error: NextResponse.json({ error: "Forbidden" }, { status: 403 }) }; - } - return { user }; -} - -function pickSettings(body: Record | null): AppSettings { - const fromBody = - body?.settings && typeof body.settings === "object" - ? (body.settings as AppSettings) - : {}; - return { ...DEFAULT_APP_SETTINGS, ...fromBody }; -} - -export async function POST(request: Request) { - const auth = await requireAdmin(); - if (auth.error) return auth.error; - - const credentials = await resolveAiCredentials(); - const openRouterKey = getOpenRouterApiKey(credentials); - if (!openRouterKey) { - return NextResponse.json( - { ok: false, error: "OpenRouter API key is not configured" }, - { status: 503 } - ); - } - - let body: Record | null = null; - try { - body = (await request.json()) as Record; - } catch { - body = null; - } - - const dbSettings = await getAppSettingsAdmin(); - const settings = normalizeAgentSettings({ - ...DEFAULT_APP_SETTINGS, - ...dbSettings, - ...pickSettings(body), - }); - - const modelId = - (typeof body?.model === "string" ? body.model : null) || - settings.agentOpenRouterModel || - getOpenRouterModel(credentials) || - DEFAULT_APP_SETTINGS.agentOpenRouterModel!; - - const prompt = - typeof body?.prompt === "string" - ? body.prompt - : "ตอบเป็นภาษาไทยสั้นๆ ว่า OK"; - - const extras: OpenRouterRequestExtras | undefined = - body?.routing && typeof body.routing === "object" - ? (body.routing as OpenRouterRequestExtras) - : buildOpenRouterRequestExtras(settings); - - const result = await probeOpenRouterChat({ - modelId, - prompt, - maxTokens: settings.agentMaxOutputTokens ?? AGENT_DEFAULT_MAX_OUTPUT_TOKENS, - extras, - apiKey: openRouterKey, - }); - - return NextResponse.json(result); -} diff --git a/app/api/ai/models/route.ts b/app/api/ai/models/route.ts deleted file mode 100644 index 2a7e920..0000000 --- a/app/api/ai/models/route.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { NextResponse } from "next/server"; -import { resolveAiCredentials, getGeminiApiKey } from "@/lib/ai/credentials-resolver"; - -export const dynamic = "force-dynamic"; - -const LIST_MODELS_URL = "https://generativelanguage.googleapis.com/v1beta/models"; - -export async function GET() { - try { - const credentials = await resolveAiCredentials(); - const apiKey = getGeminiApiKey(credentials); - if (!apiKey) { - return NextResponse.json( - { error: "Gemini API key not configured" }, - { status: 500 } - ); - } - - const response = await fetch(`${LIST_MODELS_URL}?key=${apiKey}`); - - if (!response.ok) { - const errorText = await response.text(); - return NextResponse.json( - { error: "Failed to list models", details: errorText }, - { status: response.status } - ); - } - - const data = await response.json() as { - models?: Array<{ - name?: string; - displayName?: string; - description?: string; - supportedGenerationMethods?: string[]; - }>; - }; - const models = (data.models || []).map((model) => ({ - name: model.name, - displayName: model.displayName, - description: model.description, - supportedGenerationMethods: model.supportedGenerationMethods || [], - })); - - return NextResponse.json({ models }); - } catch (error) { - console.error("Error listing AI models:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 } - ); - } -} diff --git a/app/setup/actions.ts b/app/setup/actions.ts index c81adb5..18a66d6 100644 --- a/app/setup/actions.ts +++ b/app/setup/actions.ts @@ -181,17 +181,6 @@ export async function saveAiConfigAction(input: { parsed.data.openrouterModel?.trim() || WIZARD_FREE_OPENROUTER_MODELS[0]; - if (parsed.data.provider === "gemini" || parsed.data.provider === "auto") { - const key = parsed.data.geminiApiKey?.trim(); - if (!key) return { ok: false, error: "กรุณากรอก Gemini API key" }; - await testGeminiKey(key); - } - if (parsed.data.provider === "openrouter" || parsed.data.provider === "auto") { - const key = parsed.data.openrouterApiKey?.trim(); - if (!key) return { ok: false, error: "กรุณากรอก OpenRouter API key" }; - await testOpenRouterKey(key, model); - } - await saveAiCredentialsData({ provider: parsed.data.provider, ...(parsed.data.geminiApiKey?.trim() diff --git a/app/setup/components/step-ai-config.tsx b/app/setup/components/step-ai-config.tsx index 6245ac1..bf8cfc0 100644 --- a/app/setup/components/step-ai-config.tsx +++ b/app/setup/components/step-ai-config.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import { SegmentedTabs } from "@/components/ui/segmented-tabs"; import InfoTooltip from "@/components/ui/info-tooltip"; +import { ApiKeyLabelLink } from "@/components/admin/api-key-label-link"; import { WIZARD_FREE_OPENROUTER_MODELS, type WizardAiConfigInput, @@ -84,7 +85,7 @@ export function StepAiConfig({

ตั้งค่า AI (ไม่บังคับ)

- +
@@ -106,9 +107,12 @@ export function StepAiConfig({ {showGemini ? (
- +
- + ) : null} -
-

รับ API key ฟรีได้ที่ Google AI Studio และ OpenRouter

-

- - Google AI Studio - - {" · "} - - OpenRouter - -

-
+

+ กดไอคอนข้างชื่อฟิลด์เพื่อไปรับ API key ฟรี — ตรวจคีย์ทีหลังได้ที่แผงแอดมิน +