From d065e3da88190698fd289b339197f96f9d5cff32 Mon Sep 17 00:00:00 2001 From: limitcool Date: Sat, 4 Apr 2026 00:42:23 +0800 Subject: [PATCH] Show AI reindex progress in admin --- admin/src/pages/site-settings-page.tsx | 1605 +++++++++++++++--------- admin/src/pages/workers-page.tsx | 611 ++++++--- 2 files changed, 1430 insertions(+), 786 deletions(-) diff --git a/admin/src/pages/site-settings-page.tsx b/admin/src/pages/site-settings-page.tsx index f5c7233..0e35f9c 100644 --- a/admin/src/pages/site-settings-page.tsx +++ b/admin/src/pages/site-settings-page.tsx @@ -1,19 +1,32 @@ -import { Bot, Check, Plus, RefreshCcw, Save, Trash2 } from 'lucide-react' -import type { ReactNode } from 'react' -import { startTransition, useCallback, useEffect, useMemo, useState } from 'react' -import { Link } from 'react-router-dom' -import { toast } from 'sonner' +import { Bot, Check, Plus, RefreshCcw, Save, Trash2 } from "lucide-react"; +import type { ReactNode } from "react"; +import { + startTransition, + useCallback, + useEffect, + useMemo, + useState, +} from "react"; +import { Link } from "react-router-dom"; +import { toast } from "sonner"; -import { MediaUrlControls } from '@/components/media-url-controls' -import { Badge } from '@/components/ui/badge' -import { Button } from '@/components/ui/button' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { Select } from '@/components/ui/select' -import { Skeleton } from '@/components/ui/skeleton' -import { Textarea } from '@/components/ui/textarea' -import { adminApi, ApiError } from '@/lib/api' +import { MediaUrlControls } from "@/components/media-url-controls"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select } from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Textarea } from "@/components/ui/textarea"; +import { adminApi, ApiError } from "@/lib/api"; +import { formatWorkerProgress } from "@/lib/worker-progress"; import type { AdminSiteSettingsResponse, AiProviderConfig, @@ -21,102 +34,113 @@ import type { MusicTrack, SiteSettingsPayload, WorkerJobRecord, -} from '@/lib/types' +} from "@/lib/types"; function createEmptyMusicTrack(): MusicTrack { return { - title: '', - artist: '', - album: '', - url: '', - cover_image_url: '', - accent_color: '', - description: '', - } + title: "", + artist: "", + album: "", + url: "", + cover_image_url: "", + accent_color: "", + description: "", + }; } function createAiProviderId() { - if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { - return `provider-${crypto.randomUUID()}` + if ( + typeof crypto !== "undefined" && + typeof crypto.randomUUID === "function" + ) { + return `provider-${crypto.randomUUID()}`; } - return `provider-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}` + return `provider-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; } function createEmptyAiProvider(): AiProviderConfig { return { id: createAiProviderId(), - name: '', - provider: 'openai', - api_base: '', - api_key: '', - chat_model: '', - image_model: '', - } + name: "", + provider: "openai", + api_base: "", + api_key: "", + chat_model: "", + image_model: "", + }; } const AI_PROVIDER_OPTIONS = [ - { value: 'openai', label: 'OpenAI' }, - { value: 'anthropic', label: 'Anthropic' }, - { value: 'gemini', label: 'Gemini' }, - { value: 'cloudflare', label: 'Cloudflare Workers AI' }, - { value: 'newapi', label: 'NewAPI / Responses' }, - { value: 'openai-compatible', label: 'OpenAI Compatible' }, -] as const + { value: "openai", label: "OpenAI" }, + { value: "anthropic", label: "Anthropic" }, + { value: "gemini", label: "Gemini" }, + { value: "cloudflare", label: "Cloudflare Workers AI" }, + { value: "newapi", label: "NewAPI / Responses" }, + { value: "openai-compatible", label: "OpenAI Compatible" }, +] as const; const MEDIA_STORAGE_PROVIDER_OPTIONS = [ - { value: 'r2', label: 'Cloudflare R2' }, - { value: 'minio', label: 'MinIO' }, -] as const + { value: "r2", label: "Cloudflare R2" }, + { value: "minio", label: "MinIO" }, +] as const; const NOTIFICATION_CHANNEL_OPTIONS = [ - { value: 'webhook', label: 'Webhook' }, - { value: 'ntfy', label: 'ntfy' }, -] as const + { value: "webhook", label: "Webhook" }, + { value: "ntfy", label: "ntfy" }, +] as const; const HUMAN_VERIFICATION_MODE_OPTIONS = [ - { value: 'off', label: '关闭' }, - { value: 'captcha', label: '普通验证码' }, - { value: 'turnstile', label: 'Turnstile' }, -] as const + { value: "off", label: "关闭" }, + { value: "captcha", label: "普通验证码" }, + { value: "turnstile", label: "Turnstile" }, +] as const; function normalizeHumanVerificationMode( value: string | null | undefined, fallback: HumanVerificationMode, ): HumanVerificationMode { - switch ((value ?? '').trim().toLowerCase()) { - case 'off': - return 'off' - case 'captcha': - case 'normal': - case 'simple': - return 'captcha' - case 'turnstile': - return 'turnstile' + switch ((value ?? "").trim().toLowerCase()) { + case "off": + return "off"; + case "captcha": + case "normal": + case "simple": + return "captcha"; + case "turnstile": + return "turnstile"; default: - return fallback + return fallback; } } function isCloudflareProvider(provider: string | null | undefined) { - const normalized = provider?.trim().toLowerCase() - return normalized === 'cloudflare' || normalized === 'cloudflare-workers-ai' || normalized === 'workers-ai' + const normalized = provider?.trim().toLowerCase(); + return ( + normalized === "cloudflare" || + normalized === "cloudflare-workers-ai" || + normalized === "workers-ai" + ); } function buildCloudflareAiPreset(current: AiProviderConfig): AiProviderConfig { return { ...current, - name: current.name?.trim() ? current.name : 'Cloudflare Workers AI', - provider: 'cloudflare', - chat_model: current.chat_model?.trim() || '@cf/meta/llama-3.1-8b-instruct', - } + name: current.name?.trim() ? current.name : "Cloudflare Workers AI", + provider: "cloudflare", + chat_model: current.chat_model?.trim() || "@cf/meta/llama-3.1-8b-instruct", + }; } function normalizeSettingsResponse( input: AdminSiteSettingsResponse, ): AdminSiteSettingsResponse { - const aiProviders = Array.isArray(input.ai_providers) ? input.ai_providers : [] - const searchSynonyms = Array.isArray(input.search_synonyms) ? input.search_synonyms : [] + const aiProviders = Array.isArray(input.ai_providers) + ? input.ai_providers + : []; + const searchSynonyms = Array.isArray(input.search_synonyms) + ? input.search_synonyms + : []; return { ...input, @@ -124,11 +148,11 @@ function normalizeSettingsResponse( search_synonyms: searchSynonyms, comment_verification_mode: normalizeHumanVerificationMode( input.comment_verification_mode, - input.comment_turnstile_enabled ? 'turnstile' : 'captcha', + input.comment_turnstile_enabled ? "turnstile" : "captcha", ), subscription_verification_mode: normalizeHumanVerificationMode( input.subscription_verification_mode, - input.subscription_turnstile_enabled ? 'turnstile' : 'off', + input.subscription_turnstile_enabled ? "turnstile" : "off", ), turnstile_site_key: input.turnstile_site_key ?? null, turnstile_secret_key: input.turnstile_secret_key ?? null, @@ -140,7 +164,7 @@ function normalizeSettingsResponse( maintenance_access_code: input.maintenance_access_code ?? null, ai_active_provider_id: input.ai_active_provider_id ?? aiProviders[0]?.id ?? null, - } + }; } function Field({ @@ -148,22 +172,26 @@ function Field({ hint, children, }: { - label: string - hint?: string - children: ReactNode + label: string; + hint?: string; + children: ReactNode; }) { return (
{children} - {hint ?

{hint}

: null} + {hint ? ( +

{hint}

+ ) : null}
- ) + ); } function toPayload(form: AdminSiteSettingsResponse): SiteSettingsPayload { - const commentTurnstileEnabled = form.comment_verification_mode === 'turnstile' - const subscriptionTurnstileEnabled = form.subscription_verification_mode === 'turnstile' + const commentTurnstileEnabled = + form.comment_verification_mode === "turnstile"; + const subscriptionTurnstileEnabled = + form.subscription_verification_mode === "turnstile"; return { siteName: form.site_name, @@ -231,162 +259,198 @@ function toPayload(form: AdminSiteSettingsResponse): SiteSettingsPayload { subscriptionPopupDescription: form.subscription_popup_description, subscriptionPopupDelaySeconds: form.subscription_popup_delay_seconds, searchSynonyms: form.search_synonyms, - } + }; } export function SiteSettingsPage() { - const [form, setForm] = useState(null) - const [loading, setLoading] = useState(true) - const [saving, setSaving] = useState(false) - const [reindexing, setReindexing] = useState(false) - const [reindexJobId, setReindexJobId] = useState(null) - const [reindexJobStatus, setReindexJobStatus] = useState(null) - const [testingProvider, setTestingProvider] = useState(false) - const [testingImageProvider, setTestingImageProvider] = useState(false) - const [testingR2Storage, setTestingR2Storage] = useState(false) - const [selectedTrackIndex, setSelectedTrackIndex] = useState(0) - const [selectedProviderIndex, setSelectedProviderIndex] = useState(0) + const [form, setForm] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [reindexing, setReindexing] = useState(false); + const [reindexJobId, setReindexJobId] = useState(null); + const [reindexJobStatus, setReindexJobStatus] = useState< + WorkerJobRecord["status"] | null + >(null); + const [reindexJobResult, setReindexJobResult] = + useState(null); + const [testingProvider, setTestingProvider] = useState(false); + const [testingImageProvider, setTestingImageProvider] = useState(false); + const [testingR2Storage, setTestingR2Storage] = useState(false); + const [selectedTrackIndex, setSelectedTrackIndex] = useState(0); + const [selectedProviderIndex, setSelectedProviderIndex] = useState(0); const loadSettings = useCallback(async (showToast = false) => { try { - const next = normalizeSettingsResponse(await adminApi.getSiteSettings()) + const next = normalizeSettingsResponse(await adminApi.getSiteSettings()); + let activeReindexJob: WorkerJobRecord | null | undefined = undefined; + + try { + const workerJobs = await adminApi.listWorkerJobs({ + workerName: "worker.ai_reindex", + limit: 8, + }); + activeReindexJob = + workerJobs.jobs.find( + (item) => item.status === "queued" || item.status === "running", + ) ?? null; + } catch { + activeReindexJob = undefined; + } + startTransition(() => { - setForm(next) - }) + setForm(next); + if (activeReindexJob !== undefined) { + setReindexing(Boolean(activeReindexJob)); + setReindexJobId(activeReindexJob?.id ?? null); + setReindexJobStatus(activeReindexJob?.status ?? null); + setReindexJobResult(activeReindexJob?.result ?? null); + } + }); if (showToast) { - toast.success('站点设置已刷新。') + toast.success("站点设置已刷新。"); } } catch (error) { if (error instanceof ApiError && error.status === 401) { - return + return; } - toast.error(error instanceof ApiError ? error.message : '无法加载站点设置。') + toast.error( + error instanceof ApiError ? error.message : "无法加载站点设置。", + ); } finally { - setLoading(false) + setLoading(false); } - }, []) + }, []); useEffect(() => { - void loadSettings(false) - }, [loadSettings]) + void loadSettings(false); + }, [loadSettings]); useEffect(() => { if (!form?.music_playlist.length) { - setSelectedTrackIndex(0) - return + setSelectedTrackIndex(0); + return; } - setSelectedTrackIndex((current) => Math.min(current, form.music_playlist.length - 1)) - }, [form?.music_playlist.length]) + setSelectedTrackIndex((current) => + Math.min(current, form.music_playlist.length - 1), + ); + }, [form?.music_playlist.length]); useEffect(() => { if (!form?.ai_providers.length) { - setSelectedProviderIndex(0) - return + setSelectedProviderIndex(0); + return; } setSelectedProviderIndex((current) => { const activeIndex = form.ai_providers.findIndex( (provider) => provider.id === form.ai_active_provider_id, - ) + ); if (activeIndex >= 0) { - return activeIndex + return activeIndex; } - return Math.min(current, form.ai_providers.length - 1) - }) - }, [form?.ai_active_provider_id, form?.ai_providers]) + return Math.min(current, form.ai_providers.length - 1); + }); + }, [form?.ai_active_provider_id, form?.ai_providers]); useEffect(() => { if (!reindexJobId) { - return + return; } - let cancelled = false - let timer: ReturnType | null = null + let cancelled = false; + let timer: ReturnType | null = null; const scheduleNextPoll = () => { timer = setTimeout(() => { - void pollJob() - }, 4000) - } + void pollJob(); + }, 4000); + }; const pollJob = async () => { try { - const job = await adminApi.getWorkerJob(reindexJobId) + const job = await adminApi.getWorkerJob(reindexJobId); if (cancelled) { - return + return; } - setReindexJobStatus(job.status) + setReindexJobStatus(job.status); + setReindexJobResult(job.result); - if (job.status === 'succeeded') { - setReindexing(false) - setReindexJobId(null) - setReindexJobStatus(null) - const indexedChunks = Number(job.result?.indexed_chunks ?? 0) + if (job.status === "succeeded") { + setReindexing(false); + setReindexJobId(null); + setReindexJobStatus(null); + setReindexJobResult(null); + const indexedChunks = Number(job.result?.indexed_chunks ?? 0); toast.success( indexedChunks > 0 ? `AI 索引重建完成,共生成 ${indexedChunks} 个分块。` - : 'AI 索引重建完成。', - ) - await loadSettings(false) - return + : "AI 索引重建完成。", + ); + await loadSettings(false); + return; } - if (job.status === 'failed' || job.status === 'cancelled') { - setReindexing(false) - setReindexJobId(null) - setReindexJobStatus(null) - toast.error(job.error_text?.trim() || 'AI 重建索引失败。') - return + if (job.status === "failed" || job.status === "cancelled") { + setReindexing(false); + setReindexJobId(null); + setReindexJobStatus(null); + setReindexJobResult(null); + toast.error(job.error_text?.trim() || "AI 重建索引失败。"); + return; } - scheduleNextPoll() + scheduleNextPoll(); } catch (error) { if (cancelled) { - return + return; } - scheduleNextPoll() + scheduleNextPoll(); if (error instanceof ApiError && error.status === 401) { - return + return; } } - } + }; - void pollJob() + void pollJob(); return () => { - cancelled = true + cancelled = true; if (timer) { - clearTimeout(timer) + clearTimeout(timer); } - } - }, [loadSettings, reindexJobId]) + }; + }, [loadSettings, reindexJobId]); const updateField = ( key: K, value: AdminSiteSettingsResponse[K], ) => { - setForm((current) => (current ? { ...current, [key]: value } : current)) - } + setForm((current) => (current ? { ...current, [key]: value } : current)); + }; - const updateMusicTrack = (index: number, key: K, value: MusicTrack[K]) => { + const updateMusicTrack = ( + index: number, + key: K, + value: MusicTrack[K], + ) => { setForm((current) => { if (!current) { - return current + return current; } const nextPlaylist = current.music_playlist.map((track, trackIndex) => trackIndex === index ? { ...track, [key]: value } : track, - ) + ); - return { ...current, music_playlist: nextPlaylist } - }) - } + return { ...current, music_playlist: nextPlaylist }; + }); + }; const updateAiProvider = ( index: number, @@ -395,153 +459,182 @@ export function SiteSettingsPage() { ) => { setForm((current) => { if (!current) { - return current + return current; } - const nextProviders = current.ai_providers.map((provider, providerIndex) => - providerIndex === index ? { ...provider, [key]: value } : provider, - ) + const nextProviders = current.ai_providers.map( + (provider, providerIndex) => + providerIndex === index ? { ...provider, [key]: value } : provider, + ); - return { ...current, ai_providers: nextProviders } - }) - } + return { ...current, ai_providers: nextProviders }; + }); + }; const addMusicTrack = () => { setForm((current) => { if (!current) { - return current + return current; } - const nextPlaylist = [...current.music_playlist, createEmptyMusicTrack()] - setSelectedTrackIndex(nextPlaylist.length - 1) + const nextPlaylist = [...current.music_playlist, createEmptyMusicTrack()]; + setSelectedTrackIndex(nextPlaylist.length - 1); - return { ...current, music_playlist: nextPlaylist } - }) - } + return { ...current, music_playlist: nextPlaylist }; + }); + }; const removeMusicTrack = (index: number) => { setForm((current) => { if (!current) { - return current + return current; } - const nextPlaylist = current.music_playlist.filter((_, trackIndex) => trackIndex !== index) + const nextPlaylist = current.music_playlist.filter( + (_, trackIndex) => trackIndex !== index, + ); setSelectedTrackIndex((currentIndex) => - Math.max(0, Math.min(currentIndex > index ? currentIndex - 1 : currentIndex, nextPlaylist.length - 1)), - ) + Math.max( + 0, + Math.min( + currentIndex > index ? currentIndex - 1 : currentIndex, + nextPlaylist.length - 1, + ), + ), + ); return { ...current, music_playlist: nextPlaylist, - } - }) - } + }; + }); + }; const addAiProvider = () => { setForm((current) => { if (!current) { - return current + return current; } - const nextProvider = createEmptyAiProvider() - const nextProviders = [...current.ai_providers, nextProvider] - setSelectedProviderIndex(nextProviders.length - 1) + const nextProvider = createEmptyAiProvider(); + const nextProviders = [...current.ai_providers, nextProvider]; + setSelectedProviderIndex(nextProviders.length - 1); return { ...current, ai_providers: nextProviders, ai_active_provider_id: current.ai_active_provider_id ?? nextProvider.id, - } - }) - } + }; + }); + }; const removeAiProvider = (index: number) => { setForm((current) => { if (!current) { - return current + return current; } - const removed = current.ai_providers[index] - const nextProviders = current.ai_providers.filter((_, providerIndex) => providerIndex !== index) + const removed = current.ai_providers[index]; + const nextProviders = current.ai_providers.filter( + (_, providerIndex) => providerIndex !== index, + ); const nextActiveProviderId = - removed?.id === current.ai_active_provider_id ? (nextProviders[0]?.id ?? null) : current.ai_active_provider_id + removed?.id === current.ai_active_provider_id + ? (nextProviders[0]?.id ?? null) + : current.ai_active_provider_id; setSelectedProviderIndex((currentIndex) => - Math.max(0, Math.min(currentIndex > index ? currentIndex - 1 : currentIndex, nextProviders.length - 1)), - ) + Math.max( + 0, + Math.min( + currentIndex > index ? currentIndex - 1 : currentIndex, + nextProviders.length - 1, + ), + ), + ); return { ...current, ai_providers: nextProviders, ai_active_provider_id: nextActiveProviderId, - } - }) - } + }; + }); + }; const setActiveAiProvider = (providerId: string) => { - updateField('ai_active_provider_id', providerId) - } + updateField("ai_active_provider_id", providerId); + }; const applyCloudflarePreset = (index: number) => { setForm((current) => { if (!current) { - return current + return current; } - const nextProviders = current.ai_providers.map((provider, providerIndex) => - providerIndex === index ? buildCloudflareAiPreset(provider) : provider, - ) + const nextProviders = current.ai_providers.map( + (provider, providerIndex) => + providerIndex === index + ? buildCloudflareAiPreset(provider) + : provider, + ); return { ...current, ai_providers: nextProviders, - } - }) - } + }; + }); + }; const applyCloudflareImagePreset = () => { setForm((current) => { if (!current) { - return current + return current; } return { ...current, - ai_image_provider: 'cloudflare', + ai_image_provider: "cloudflare", ai_image_model: - current.ai_image_model?.trim() || '@cf/black-forest-labs/flux-2-klein-4b', - } - }) - } + current.ai_image_model?.trim() || + "@cf/black-forest-labs/flux-2-klein-4b", + }; + }); + }; const techStackValue = useMemo( - () => (form?.tech_stack.length ? form.tech_stack.join('\n') : ''), + () => (form?.tech_stack.length ? form.tech_stack.join("\n") : ""), [form?.tech_stack], - ) + ); const selectedTrack = useMemo( () => form?.music_playlist[selectedTrackIndex] ?? createEmptyMusicTrack(), [form, selectedTrackIndex], - ) + ); const selectedProvider = useMemo( () => form?.ai_providers[selectedProviderIndex] ?? createEmptyAiProvider(), [form, selectedProviderIndex], - ) + ); const activeProvider = useMemo( - () => form?.ai_providers.find((provider) => provider.id === form.ai_active_provider_id) ?? null, + () => + form?.ai_providers.find( + (provider) => provider.id === form.ai_active_provider_id, + ) ?? null, [form], - ) + ); const selectedProviderIsCloudflare = useMemo( () => isCloudflareProvider(selectedProvider.provider), [selectedProvider.provider], - ) + ); const imageProviderIsCloudflare = useMemo( () => isCloudflareProvider(form?.ai_image_provider), [form?.ai_image_provider], - ) + ); const mediaStorageProvider = useMemo( - () => (form?.media_storage_provider?.trim().toLowerCase() === 'minio' ? 'minio' : 'r2'), + () => + form?.media_storage_provider?.trim().toLowerCase() === "minio" + ? "minio" + : "r2", [form?.media_storage_provider], - ) + ); if (loading || !form) { return ( @@ -549,7 +642,7 @@ export function SiteSettingsPage() { - ) + ); } return ( @@ -558,20 +651,31 @@ export function SiteSettingsPage() {
站点设置
-

品牌、资料与 AI 控制

+

+ 品牌、资料与 AI 控制 +

- 这里统一维护前台站点使用的品牌信息、站长资料与 AI 配置,确保和后端数据模型一致。 + 这里统一维护前台站点使用的品牌信息、站长资料与 AI + 配置,确保和后端数据模型一致。

- {reindexJobId ? ( - ) : null} @@ -580,54 +684,68 @@ export function SiteSettingsPage() { disabled={reindexing} data-testid="site-settings-reindex" onClick={async () => { - let queued = false + let queued = false; try { - setReindexing(true) - setReindexJobStatus('queued') - const result = await adminApi.reindexAi() - queued = true - setReindexJobId(result.job.id) - setReindexJobStatus(result.job.status) - toast.success(`AI 重建任务已入队:#${result.job.id}`) + setReindexing(true); + setReindexJobStatus("queued"); + setReindexJobResult(null); + const result = await adminApi.reindexAi(); + queued = true; + setReindexJobId(result.job.id); + setReindexJobStatus(result.job.status); + setReindexJobResult(result.job.result); + toast.success(`AI 重建任务已入队:#${result.job.id}`); } catch (error) { - setReindexJobId(null) - setReindexJobStatus(null) - toast.error(error instanceof ApiError ? error.message : 'AI 重建索引失败。') + setReindexJobId(null); + setReindexJobStatus(null); + setReindexJobResult(null); + toast.error( + error instanceof ApiError + ? error.message + : "AI 重建索引失败。", + ); } finally { if (!queued) { - setReindexing(false) + setReindexing(false); } } }} > - {reindexing ? '任务进行中...' : '重建 AI 索引'} + {reindexing ? "任务进行中..." : "重建 AI 索引"}
@@ -635,138 +753,175 @@ export function SiteSettingsPage() {
- - 前台站点资料 - - 前台站点用于展示品牌、首页文案、站长资料和社交信息的全部字段。 - - - - - updateField('site_name', event.target.value)} - /> - - - updateField('site_short_name', event.target.value)} - /> - - - updateField('site_url', event.target.value)} - /> - - - updateField('location', event.target.value)} - /> - - - updateField('site_title', event.target.value)} - /> - - - updateField('owner_title', event.target.value)} - /> - -
- -