chore: checkpoint admin editor and perf work

This commit is contained in:
2026-03-31 00:12:02 +08:00
parent 92a85eef20
commit 99b308e800
45 changed files with 7265 additions and 833 deletions

View File

@@ -1,5 +1,7 @@
import { import {
createContext, createContext,
lazy,
Suspense,
startTransition, startTransition,
useContext, useContext,
useEffect, useEffect,
@@ -22,13 +24,40 @@ import { Toaster, toast } from 'sonner'
import { AppShell } from '@/components/app-shell' import { AppShell } from '@/components/app-shell'
import { adminApi, ApiError } from '@/lib/api' import { adminApi, ApiError } from '@/lib/api'
import type { AdminSessionResponse } from '@/lib/types' import type { AdminSessionResponse } from '@/lib/types'
import { CommentsPage } from '@/pages/comments-page'
import { DashboardPage } from '@/pages/dashboard-page'
import { FriendLinksPage } from '@/pages/friend-links-page'
import { LoginPage } from '@/pages/login-page' import { LoginPage } from '@/pages/login-page'
import { PostsPage } from '@/pages/posts-page'
import { ReviewsPage } from '@/pages/reviews-page' const DashboardPage = lazy(async () => {
import { SiteSettingsPage } from '@/pages/site-settings-page' const mod = await import('@/pages/dashboard-page')
return { default: mod.DashboardPage }
})
const AnalyticsPage = lazy(async () => {
const mod = await import('@/pages/analytics-page')
return { default: mod.AnalyticsPage }
})
const PostsPage = lazy(async () => {
const mod = await import('@/pages/posts-page')
return { default: mod.PostsPage }
})
const CommentsPage = lazy(async () => {
const mod = await import('@/pages/comments-page')
return { default: mod.CommentsPage }
})
const FriendLinksPage = lazy(async () => {
const mod = await import('@/pages/friend-links-page')
return { default: mod.FriendLinksPage }
})
const MediaPage = lazy(async () => {
const mod = await import('@/pages/media-page')
return { default: mod.MediaPage }
})
const ReviewsPage = lazy(async () => {
const mod = await import('@/pages/reviews-page')
return { default: mod.ReviewsPage }
})
const SiteSettingsPage = lazy(async () => {
const mod = await import('@/pages/site-settings-page')
return { default: mod.SiteSettingsPage }
})
type SessionContextValue = { type SessionContextValue = {
session: AdminSessionResponse session: AdminSessionResponse
@@ -69,6 +98,26 @@ function AppLoadingScreen() {
) )
} }
function RouteLoadingScreen() {
return (
<div className="flex min-h-[320px] items-center justify-center rounded-3xl border border-border/70 bg-card/60 px-6 py-10 text-center text-muted-foreground">
<div className="space-y-3">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl border border-primary/20 bg-primary/10 text-primary">
<LoaderCircle className="h-5 w-5 animate-spin" />
</div>
<div>
<p className="text-sm font-medium text-foreground"></p>
<p className="mt-1 text-sm"></p>
</div>
</div>
</div>
)
}
function LazyRoute({ children }: { children: ReactNode }) {
return <Suspense fallback={<RouteLoadingScreen />}>{children}</Suspense>
}
function RequireAuth({ children }: { children: ReactNode }) { function RequireAuth({ children }: { children: ReactNode }) {
const { session } = useSession() const { session } = useSession()
@@ -151,14 +200,79 @@ function AppRoutes() {
<ProtectedLayout /> <ProtectedLayout />
</RequireAuth> </RequireAuth>
} }
> >
<Route index element={<DashboardPage />} /> <Route
<Route path="posts" element={<PostsPage />} /> index
<Route path="posts/:slug" element={<PostsPage />} /> element={
<Route path="comments" element={<CommentsPage />} /> <LazyRoute>
<Route path="friend-links" element={<FriendLinksPage />} /> <DashboardPage />
<Route path="reviews" element={<ReviewsPage />} /> </LazyRoute>
<Route path="settings" element={<SiteSettingsPage />} /> }
/>
<Route
path="analytics"
element={
<LazyRoute>
<AnalyticsPage />
</LazyRoute>
}
/>
<Route
path="posts"
element={
<LazyRoute>
<PostsPage />
</LazyRoute>
}
/>
<Route
path="posts/:slug"
element={
<LazyRoute>
<PostsPage />
</LazyRoute>
}
/>
<Route
path="comments"
element={
<LazyRoute>
<CommentsPage />
</LazyRoute>
}
/>
<Route
path="friend-links"
element={
<LazyRoute>
<FriendLinksPage />
</LazyRoute>
}
/>
<Route
path="media"
element={
<LazyRoute>
<MediaPage />
</LazyRoute>
}
/>
<Route
path="reviews"
element={
<LazyRoute>
<ReviewsPage />
</LazyRoute>
}
/>
<Route
path="settings"
element={
<LazyRoute>
<SiteSettingsPage />
</LazyRoute>
}
/>
</Route> </Route>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>

View File

@@ -1,6 +1,8 @@
import { import {
BarChart3,
BookOpenText, BookOpenText,
ExternalLink, ExternalLink,
Image as ImageIcon,
LayoutDashboard, LayoutDashboard,
Link2, Link2,
LogOut, LogOut,
@@ -25,6 +27,12 @@ const primaryNav = [
description: '站点运营总览', description: '站点运营总览',
icon: LayoutDashboard, icon: LayoutDashboard,
}, },
{
to: '/analytics',
label: '数据分析',
description: '搜索词与 AI 问答洞察',
icon: BarChart3,
},
{ {
to: '/posts', to: '/posts',
label: '文章', label: '文章',
@@ -49,6 +57,12 @@ const primaryNav = [
description: '评测内容库', description: '评测内容库',
icon: BookOpenText, icon: BookOpenText,
}, },
{
to: '/media',
label: '媒体库',
description: '对象存储图片管理',
icon: ImageIcon,
},
{ {
to: '/settings', to: '/settings',
label: '设置', label: '设置',

View File

@@ -0,0 +1,72 @@
import { lazy, Suspense } from 'react'
import type { DiffEditorProps, EditorProps } from '@monaco-editor/react'
const MonacoEditor = lazy(async () => {
const mod = await import('@monaco-editor/react')
return { default: mod.default }
})
const MonacoDiffEditor = lazy(async () => {
const mod = await import('@monaco-editor/react')
return { default: mod.DiffEditor }
})
function MonacoLoading({
height,
width,
className,
loading,
}: {
height?: string | number
width?: string | number
className?: string
loading?: React.ReactNode
}) {
return (
<div
className={className}
style={{ height: height ?? '100%', width: width ?? '100%' }}
>
{loading ?? (
<div className="flex h-full min-h-[280px] items-center justify-center bg-[#111111] text-sm text-slate-400">
...
</div>
)}
</div>
)
}
export function LazyEditor(props: EditorProps) {
return (
<Suspense
fallback={
<MonacoLoading
height={props.height}
width={props.width}
className={props.className}
loading={props.loading}
/>
}
>
<MonacoEditor {...props} />
</Suspense>
)
}
export function LazyDiffEditor(props: DiffEditorProps) {
return (
<Suspense
fallback={
<MonacoLoading
height={props.height}
width={props.width}
className={props.className}
loading={props.loading}
/>
}
>
<MonacoDiffEditor {...props} />
</Suspense>
)
}

View File

@@ -1,6 +1,6 @@
import DOMPurify from 'dompurify' import DOMPurify from 'dompurify'
import { marked } from 'marked' import { marked } from 'marked'
import { useMemo } from 'react' import { useDeferredValue, useMemo } from 'react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@@ -15,10 +15,11 @@ marked.setOptions({
}) })
export function MarkdownPreview({ markdown, className }: MarkdownPreviewProps) { export function MarkdownPreview({ markdown, className }: MarkdownPreviewProps) {
const deferredMarkdown = useDeferredValue(markdown)
const html = useMemo(() => { const html = useMemo(() => {
const rendered = marked.parse(markdown || '暂无内容。') const rendered = marked.parse(deferredMarkdown || '暂无内容。')
return DOMPurify.sanitize(typeof rendered === 'string' ? rendered : '') return DOMPurify.sanitize(typeof rendered === 'string' ? rendered : '')
}, [markdown]) }, [deferredMarkdown])
return ( return (
<div className={cn('h-full overflow-y-auto bg-[#fcfcfd]', className)}> <div className={cn('h-full overflow-y-auto bg-[#fcfcfd]', className)}>

View File

@@ -2,9 +2,10 @@ import type { ReactNode } from 'react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import Editor, { DiffEditor, type BeforeMount } from '@monaco-editor/react' import type { BeforeMount } from '@monaco-editor/react'
import { Expand, Minimize2, Sparkles } from 'lucide-react' import { Expand, Minimize2, Sparkles } from 'lucide-react'
import { LazyDiffEditor, LazyEditor } from '@/components/lazy-monaco'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@@ -16,6 +17,7 @@ type MarkdownWorkbenchProps = {
originalValue: string originalValue: string
diffValue?: string diffValue?: string
path: string path: string
workspaceHeightClassName?: string
readOnly?: boolean readOnly?: boolean
mode: MarkdownWorkbenchMode mode: MarkdownWorkbenchMode
visiblePanels: MarkdownWorkbenchPanel[] visiblePanels: MarkdownWorkbenchPanel[]
@@ -114,6 +116,7 @@ export function MarkdownWorkbench({
originalValue, originalValue,
diffValue, diffValue,
path, path,
workspaceHeightClassName = 'h-[560px]',
readOnly = false, readOnly = false,
mode, mode,
visiblePanels, visiblePanels,
@@ -128,7 +131,7 @@ export function MarkdownWorkbench({
onVisiblePanelsChange, onVisiblePanelsChange,
}: MarkdownWorkbenchProps) { }: MarkdownWorkbenchProps) {
const [fullscreen, setFullscreen] = useState(false) const [fullscreen, setFullscreen] = useState(false)
const editorHeight = fullscreen ? 'h-[calc(100dvh-82px)]' : 'h-[560px]' const editorHeight = fullscreen ? 'h-[calc(100dvh-82px)]' : workspaceHeightClassName
const diffContent = diffValue ?? value const diffContent = diffValue ?? value
const polishEnabled = allowPolish ?? Boolean(polishPanel) const polishEnabled = allowPolish ?? Boolean(polishPanel)
const workspacePanels = resolveVisiblePanels(visiblePanels, availablePanels) const workspacePanels = resolveVisiblePanels(visiblePanels, availablePanels)
@@ -262,7 +265,7 @@ export function MarkdownWorkbench({
{panel === 'edit' ? ( {panel === 'edit' ? (
<div className="min-h-0 flex-1"> <div className="min-h-0 flex-1">
<Editor <LazyEditor
height="100%" height="100%"
language="markdown" language="markdown"
path={path} path={path}
@@ -286,7 +289,7 @@ export function MarkdownWorkbench({
{panel === 'diff' ? ( {panel === 'diff' ? (
<div className="min-h-0 flex-1"> <div className="min-h-0 flex-1">
<DiffEditor <LazyDiffEditor
height="100%" height="100%"
language="markdown" language="markdown"
original={originalValue} original={originalValue}

View File

@@ -1,18 +1,473 @@
import * as React from 'react' import * as React from 'react'
import * as ReactDOM from 'react-dom'
import { Check, ChevronDown } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
const Select = React.forwardRef<HTMLSelectElement, React.ComponentProps<'select'>>( type NativeSelectProps = React.ComponentProps<'select'>
({ className, ...props }, ref) => (
<select type SelectOption = {
ref={ref} value: string
className={cn( label: React.ReactNode
'flex h-11 w-full rounded-xl border border-input bg-background/80 px-3 py-2 text-sm shadow-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring/70 disabled:cursor-not-allowed disabled:opacity-50', disabled: boolean
className, }
)}
{...props} type MenuPlacement = 'top' | 'bottom'
/>
), function normalizeValue(value: NativeSelectProps['value'] | NativeSelectProps['defaultValue']) {
if (Array.isArray(value)) {
return value[0] == null ? '' : String(value[0])
}
return value == null ? '' : String(value)
}
function extractOptions(children: React.ReactNode) {
const options: SelectOption[] = []
React.Children.forEach(children, (child) => {
if (!React.isValidElement(child) || child.type !== 'option') {
return
}
const props = child.props as React.OptionHTMLAttributes<HTMLOptionElement> & {
children?: React.ReactNode
}
options.push({
value: normalizeValue(props.value),
label: props.children,
disabled: Boolean(props.disabled),
})
})
return options
}
function getFirstEnabledIndex(options: SelectOption[]) {
return options.findIndex((option) => !option.disabled)
}
function getLastEnabledIndex(options: SelectOption[]) {
for (let index = options.length - 1; index >= 0; index -= 1) {
if (!options[index]?.disabled) {
return index
}
}
return -1
}
function getNextEnabledIndex(options: SelectOption[], currentIndex: number, direction: 1 | -1) {
if (options.length === 0) {
return -1
}
let index = currentIndex
for (let step = 0; step < options.length; step += 1) {
index = (index + direction + options.length) % options.length
if (!options[index]?.disabled) {
return index
}
}
return -1
}
const Select = React.forwardRef<HTMLSelectElement, NativeSelectProps>(
(
{
children,
className,
defaultValue,
disabled = false,
id,
onBlur,
onClick,
onFocus,
onKeyDown,
value,
...props
},
forwardedRef,
) => {
const options = React.useMemo(() => extractOptions(children), [children])
const isControlled = value !== undefined
const initialValue = React.useMemo(() => {
if (defaultValue !== undefined) {
return normalizeValue(defaultValue)
}
return options[0]?.value ?? ''
}, [defaultValue, options])
const [internalValue, setInternalValue] = React.useState(initialValue)
const [open, setOpen] = React.useState(false)
const [highlightedIndex, setHighlightedIndex] = React.useState(-1)
const [menuPlacement, setMenuPlacement] = React.useState<MenuPlacement>('bottom')
const [menuStyle, setMenuStyle] = React.useState<React.CSSProperties | null>(null)
const wrapperRef = React.useRef<HTMLDivElement>(null)
const triggerRef = React.useRef<HTMLButtonElement>(null)
const nativeSelectRef = React.useRef<HTMLSelectElement>(null)
const menuRef = React.useRef<HTMLDivElement>(null)
const optionRefs = React.useRef<Array<HTMLButtonElement | null>>([])
const menuId = React.useId()
const currentValue = isControlled ? normalizeValue(value) : internalValue
const selectedIndex = options.findIndex((option) => option.value === currentValue)
const selectedOption = selectedIndex >= 0 ? options[selectedIndex] : options[0] ?? null
React.useEffect(() => {
if (!isControlled && options.length > 0 && !options.some((option) => option.value === internalValue)) {
setInternalValue(options[0]?.value ?? '')
}
}, [internalValue, isControlled, options])
const updateMenuPosition = React.useCallback(() => {
const trigger = triggerRef.current
if (!trigger) {
return
}
const rect = trigger.getBoundingClientRect()
const viewportPadding = 12
const gutter = 6
const estimatedHeight = Math.min(Math.max(options.length, 1) * 44 + 18, 320)
const spaceBelow = window.innerHeight - rect.bottom - viewportPadding
const spaceAbove = rect.top - viewportPadding
const openToTop = spaceBelow < estimatedHeight && spaceAbove > spaceBelow
const maxHeight = Math.max(120, Math.min(openToTop ? spaceAbove : spaceBelow, 320))
const width = Math.min(rect.width, window.innerWidth - viewportPadding * 2)
const left = Math.min(Math.max(rect.left, viewportPadding), window.innerWidth - width - viewportPadding)
setMenuPlacement(openToTop ? 'top' : 'bottom')
setMenuStyle(
openToTop
? {
left,
width,
maxHeight,
bottom: window.innerHeight - rect.top + gutter,
}
: {
left,
width,
maxHeight,
top: rect.bottom + gutter,
},
)
}, [options.length])
const setOpenWithHighlight = React.useCallback(
(nextOpen: boolean, preferredIndex?: number) => {
if (disabled) {
return
}
if (nextOpen) {
const fallbackIndex =
preferredIndex ??
(selectedIndex >= 0 && !options[selectedIndex]?.disabled
? selectedIndex
: getFirstEnabledIndex(options))
setHighlightedIndex(fallbackIndex)
updateMenuPosition()
setOpen(true)
return
}
setOpen(false)
},
[disabled, options, selectedIndex, updateMenuPosition],
)
const commitValue = React.useCallback(
(nextIndex: number) => {
const option = options[nextIndex]
const nativeSelect = nativeSelectRef.current
if (!option || option.disabled) {
return
}
if (!isControlled) {
setInternalValue(option.value)
}
if (nativeSelect && currentValue !== option.value) {
nativeSelect.value = option.value
nativeSelect.dispatchEvent(new Event('change', { bubbles: true }))
}
setOpen(false)
window.requestAnimationFrame(() => {
triggerRef.current?.focus()
})
},
[currentValue, isControlled, options],
)
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLButtonElement | HTMLDivElement>) => {
onKeyDown?.(event as unknown as React.KeyboardEvent<HTMLSelectElement>)
if (event.defaultPrevented || disabled) {
return
}
switch (event.key) {
case 'ArrowDown': {
event.preventDefault()
if (!open) {
const nextIndex =
selectedIndex >= 0
? getNextEnabledIndex(options, selectedIndex, 1)
: getFirstEnabledIndex(options)
setOpenWithHighlight(true, nextIndex >= 0 ? nextIndex : getFirstEnabledIndex(options))
return
}
setHighlightedIndex((current) => getNextEnabledIndex(options, current, 1))
return
}
case 'ArrowUp': {
event.preventDefault()
if (!open) {
const nextIndex =
selectedIndex >= 0
? getNextEnabledIndex(options, selectedIndex, -1)
: getLastEnabledIndex(options)
setOpenWithHighlight(true, nextIndex >= 0 ? nextIndex : getLastEnabledIndex(options))
return
}
setHighlightedIndex((current) => getNextEnabledIndex(options, current, -1))
return
}
case 'Home': {
event.preventDefault()
const firstIndex = getFirstEnabledIndex(options)
if (!open) {
setOpenWithHighlight(true, firstIndex)
return
}
setHighlightedIndex(firstIndex)
return
}
case 'End': {
event.preventDefault()
const lastIndex = getLastEnabledIndex(options)
if (!open) {
setOpenWithHighlight(true, lastIndex)
return
}
setHighlightedIndex(lastIndex)
return
}
case 'Enter':
case ' ': {
event.preventDefault()
if (!open) {
setOpenWithHighlight(true)
return
}
if (highlightedIndex >= 0) {
commitValue(highlightedIndex)
}
return
}
case 'Escape': {
if (!open) {
return
}
event.preventDefault()
setOpen(false)
return
}
case 'Tab': {
setOpen(false)
return
}
default:
return
}
},
[commitValue, disabled, highlightedIndex, onKeyDown, open, options, selectedIndex, setOpenWithHighlight],
)
React.useLayoutEffect(() => {
if (!open) {
return
}
updateMenuPosition()
}, [open, updateMenuPosition])
React.useEffect(() => {
if (!open) {
return
}
const handlePointerDown = (event: MouseEvent) => {
const target = event.target as Node
if (wrapperRef.current?.contains(target) || menuRef.current?.contains(target)) {
return
}
setOpen(false)
}
const handleWindowChange = () => updateMenuPosition()
document.addEventListener('mousedown', handlePointerDown)
window.addEventListener('resize', handleWindowChange)
window.addEventListener('scroll', handleWindowChange, true)
return () => {
document.removeEventListener('mousedown', handlePointerDown)
window.removeEventListener('resize', handleWindowChange)
window.removeEventListener('scroll', handleWindowChange, true)
}
}, [open, updateMenuPosition])
React.useEffect(() => {
if (!open || highlightedIndex < 0) {
return
}
optionRefs.current[highlightedIndex]?.scrollIntoView({ block: 'nearest' })
}, [highlightedIndex, open])
const triggerClasses = cn(
'flex h-11 w-full items-center justify-between gap-3 rounded-xl border border-input bg-background/80 px-3 py-2 text-left text-sm text-foreground shadow-sm outline-none transition-[border-color,box-shadow,background-color,transform] focus-visible:ring-2 focus-visible:ring-ring/70 disabled:cursor-not-allowed disabled:opacity-50 data-[state=open]:border-primary/40 data-[state=open]:bg-card data-[state=open]:shadow-[0_18px_40px_rgb(15_23_42_/_0.14)]',
className,
)
const menu = open && menuStyle
? ReactDOM.createPortal(
<div
ref={menuRef}
aria-orientation="vertical"
className={cn(
'custom-select-popover fixed z-[80] overflow-hidden rounded-2xl border border-border/70 bg-popover p-1.5 text-popover-foreground shadow-[0_18px_48px_rgb(15_23_42_/_0.18)] will-change-transform',
menuPlacement === 'top' ? 'origin-bottom' : 'origin-top',
)}
id={menuId}
onKeyDown={handleKeyDown}
role="listbox"
style={menuStyle}
tabIndex={-1}
>
<div className="max-h-full overflow-y-auto pr-0.5">
{options.map((option, index) => {
const selected = option.value === currentValue
const highlighted = index === highlightedIndex
return (
<button
key={`${option.value}-${index}`}
ref={(node) => {
optionRefs.current[index] = node
}}
aria-selected={selected}
className={cn(
'flex w-full items-center justify-between gap-3 rounded-xl px-3 py-2.5 text-left text-sm transition-colors',
option.disabled ? 'cursor-not-allowed opacity-45' : 'cursor-pointer',
selected
? 'bg-primary text-primary-foreground shadow-[0_12px_30px_rgb(37_99_235_/_0.22)]'
: highlighted
? 'bg-accent text-accent-foreground'
: 'text-foreground hover:bg-accent hover:text-accent-foreground',
)}
disabled={option.disabled}
onClick={() => commitValue(index)}
onMouseEnter={() => {
if (!option.disabled) {
setHighlightedIndex(index)
}
}}
role="option"
type="button"
>
<span className="truncate">{option.label}</span>
<Check className={cn('h-4 w-4 shrink-0', selected ? 'opacity-100' : 'opacity-0')} />
</button>
)
})}
</div>
</div>,
document.body,
)
: null
return (
<div className="relative w-full" ref={wrapperRef}>
<select
{...props}
aria-hidden="true"
className="pointer-events-none absolute h-0 w-0 opacity-0"
defaultValue={defaultValue}
disabled={disabled}
id={id}
onBlur={onBlur}
onFocus={onFocus}
ref={(node) => {
nativeSelectRef.current = node
if (typeof forwardedRef === 'function') {
forwardedRef(node)
} else if (forwardedRef) {
forwardedRef.current = node
}
}}
tabIndex={-1}
value={isControlled ? currentValue : internalValue}
>
{children}
</select>
<button
aria-controls={open ? menuId : undefined}
aria-expanded={open}
aria-haspopup="listbox"
className={triggerClasses}
data-state={open ? 'open' : 'closed'}
disabled={disabled}
onBlur={(event) => {
onBlur?.(event as unknown as React.FocusEvent<HTMLSelectElement>)
}}
onClick={(event) => {
onClick?.(event as unknown as React.MouseEvent<HTMLSelectElement>)
}}
onPointerDown={(event) => {
if (event.button !== 0 || disabled) {
return
}
event.preventDefault()
triggerRef.current?.focus()
setOpenWithHighlight(!open)
}}
onFocus={(event) => {
onFocus?.(event as unknown as React.FocusEvent<HTMLSelectElement>)
}}
onKeyDown={handleKeyDown}
ref={triggerRef}
role="combobox"
type="button"
>
<span className="min-w-0 flex-1 truncate">{selectedOption?.label ?? '请选择'}</span>
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted/70 text-muted-foreground transition-colors">
<ChevronDown className={cn('h-4 w-4 transition-transform duration-200', open && 'rotate-180')} />
</span>
</button>
{menu}
</div>
)
},
) )
Select.displayName = 'Select' Select.displayName = 'Select'

View File

@@ -116,6 +116,23 @@ a {
button, button,
input, input,
textarea { textarea,
select {
font: inherit; font: inherit;
} }
@keyframes custom-select-pop {
from {
opacity: 0;
transform: translateY(-2px) scale(0.985);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.custom-select-popover {
animation: custom-select-pop 0.1s ease-out;
}

View File

@@ -1,9 +1,19 @@
import type { import type {
AdminAnalyticsResponse,
AdminAiImageProviderTestResponse,
AdminAiReindexResponse, AdminAiReindexResponse,
AdminAiProviderTestResponse, AdminAiProviderTestResponse,
AdminImageUploadResponse,
AdminMediaDeleteResponse,
AdminMediaListResponse,
AdminPostCoverImageRequest,
AdminPostCoverImageResponse,
AdminDashboardResponse, AdminDashboardResponse,
AdminPostMetadataResponse, AdminPostMetadataResponse,
AdminPostPolishResponse, AdminPostPolishResponse,
AdminReviewPolishRequest,
AdminReviewPolishResponse,
AdminR2ConnectivityResponse,
AdminSessionResponse, AdminSessionResponse,
AdminSiteSettingsResponse, AdminSiteSettingsResponse,
CommentListQuery, CommentListQuery,
@@ -117,6 +127,7 @@ export const adminApi = {
method: 'DELETE', method: 'DELETE',
}), }),
dashboard: () => request<AdminDashboardResponse>('/api/admin/dashboard'), dashboard: () => request<AdminDashboardResponse>('/api/admin/dashboard'),
analytics: () => request<AdminAnalyticsResponse>('/api/admin/analytics'),
getSiteSettings: () => request<AdminSiteSettingsResponse>('/api/admin/site-settings'), getSiteSettings: () => request<AdminSiteSettingsResponse>('/api/admin/site-settings'),
updateSiteSettings: (payload: SiteSettingsPayload) => updateSiteSettings: (payload: SiteSettingsPayload) =>
request<AdminSiteSettingsResponse>('/api/admin/site-settings', { request<AdminSiteSettingsResponse>('/api/admin/site-settings', {
@@ -139,6 +150,48 @@ export const adminApi = {
method: 'POST', method: 'POST',
body: JSON.stringify({ provider }), body: JSON.stringify({ provider }),
}), }),
testAiImageProvider: (provider: {
provider: string
api_base: string | null
api_key: string | null
image_model: string | null
}) =>
request<AdminAiImageProviderTestResponse>('/api/admin/ai/test-image-provider', {
method: 'POST',
body: JSON.stringify({
provider: provider.provider,
api_base: provider.api_base,
api_key: provider.api_key,
image_model: provider.image_model,
}),
}),
uploadReviewCoverImage: (file: File) => {
const formData = new FormData()
formData.append('file', file, file.name)
return request<AdminImageUploadResponse>('/api/admin/storage/review-cover', {
method: 'POST',
body: formData,
})
},
testR2Storage: () =>
request<AdminR2ConnectivityResponse>('/api/admin/storage/r2/test', {
method: 'POST',
}),
listMediaObjects: (query?: { prefix?: string; limit?: number }) =>
request<AdminMediaListResponse>(
appendQueryParams('/api/admin/storage/media', {
prefix: query?.prefix,
limit: query?.limit,
}),
),
deleteMediaObject: (key: string) =>
request<AdminMediaDeleteResponse>(
`/api/admin/storage/media?key=${encodeURIComponent(key)}`,
{
method: 'DELETE',
},
),
generatePostMetadata: (markdown: string) => generatePostMetadata: (markdown: string) =>
request<AdminPostMetadataResponse>('/api/admin/ai/post-metadata', { request<AdminPostMetadataResponse>('/api/admin/ai/post-metadata', {
method: 'POST', method: 'POST',
@@ -149,6 +202,32 @@ export const adminApi = {
method: 'POST', method: 'POST',
body: JSON.stringify({ markdown }), body: JSON.stringify({ markdown }),
}), }),
polishReviewDescription: (payload: AdminReviewPolishRequest) =>
request<AdminReviewPolishResponse>('/api/admin/ai/polish-review', {
method: 'POST',
body: JSON.stringify({
title: payload.title,
review_type: payload.reviewType,
rating: payload.rating,
review_date: payload.reviewDate,
status: payload.status,
tags: payload.tags,
description: payload.description,
}),
}),
generatePostCoverImage: (payload: AdminPostCoverImageRequest) =>
request<AdminPostCoverImageResponse>('/api/admin/ai/post-cover', {
method: 'POST',
body: JSON.stringify({
title: payload.title,
description: payload.description,
category: payload.category,
tags: payload.tags,
post_type: payload.postType,
slug: payload.slug,
markdown: payload.markdown,
}),
}),
listPosts: (query?: PostListQuery) => listPosts: (query?: PostListQuery) =>
request<PostRecord[]>( request<PostRecord[]>(
appendQueryParams('/api/posts', { appendQueryParams('/api/posts', {

View File

@@ -71,6 +71,58 @@ export interface AdminDashboardResponse {
recent_reviews: DashboardReviewItem[] recent_reviews: DashboardReviewItem[]
} }
export interface AnalyticsOverview {
total_searches: number
total_ai_questions: number
searches_last_24h: number
ai_questions_last_24h: number
searches_last_7d: number
ai_questions_last_7d: number
unique_search_terms_last_7d: number
unique_ai_questions_last_7d: number
avg_search_results_last_7d: number
avg_ai_latency_ms_last_7d: number | null
}
export interface AnalyticsTopQuery {
query: string
count: number
last_seen_at: string
}
export interface AnalyticsRecentEvent {
id: number
event_type: string
query: string
result_count: number | null
success: boolean | null
response_mode: string | null
provider: string | null
chat_model: string | null
latency_ms: number | null
created_at: string
}
export interface AnalyticsProviderBucket {
provider: string
count: number
}
export interface AnalyticsDailyBucket {
date: string
searches: number
ai_questions: number
}
export interface AdminAnalyticsResponse {
overview: AnalyticsOverview
top_search_terms: AnalyticsTopQuery[]
top_ai_questions: AnalyticsTopQuery[]
recent_events: AnalyticsRecentEvent[]
providers_last_7d: AnalyticsProviderBucket[]
daily_activity: AnalyticsDailyBucket[]
}
export interface AdminSiteSettingsResponse { export interface AdminSiteSettingsResponse {
id: number id: number
site_name: string | null site_name: string | null
@@ -96,6 +148,10 @@ export interface AdminSiteSettingsResponse {
ai_api_base: string | null ai_api_base: string | null
ai_api_key: string | null ai_api_key: string | null
ai_chat_model: string | null ai_chat_model: string | null
ai_image_provider: string | null
ai_image_api_base: string | null
ai_image_api_key: string | null
ai_image_model: string | null
ai_providers: AiProviderConfig[] ai_providers: AiProviderConfig[]
ai_active_provider_id: string | null ai_active_provider_id: string | null
ai_embedding_model: string | null ai_embedding_model: string | null
@@ -105,6 +161,12 @@ export interface AdminSiteSettingsResponse {
ai_last_indexed_at: string | null ai_last_indexed_at: string | null
ai_chunks_count: number ai_chunks_count: number
ai_local_embedding: string ai_local_embedding: string
media_storage_provider: string | null
media_r2_account_id: string | null
media_r2_bucket: string | null
media_r2_public_base_url: string | null
media_r2_access_key_id: string | null
media_r2_secret_access_key: string | null
} }
export interface AiProviderConfig { export interface AiProviderConfig {
@@ -114,6 +176,7 @@ export interface AiProviderConfig {
api_base: string | null api_base: string | null
api_key: string | null api_key: string | null
chat_model: string | null chat_model: string | null
image_model: string | null
} }
export interface SiteSettingsPayload { export interface SiteSettingsPayload {
@@ -140,12 +203,22 @@ export interface SiteSettingsPayload {
aiApiBase?: string | null aiApiBase?: string | null
aiApiKey?: string | null aiApiKey?: string | null
aiChatModel?: string | null aiChatModel?: string | null
aiImageProvider?: string | null
aiImageApiBase?: string | null
aiImageApiKey?: string | null
aiImageModel?: string | null
aiProviders?: AiProviderConfig[] aiProviders?: AiProviderConfig[]
aiActiveProviderId?: string | null aiActiveProviderId?: string | null
aiEmbeddingModel?: string | null aiEmbeddingModel?: string | null
aiSystemPrompt?: string | null aiSystemPrompt?: string | null
aiTopK?: number | null aiTopK?: number | null
aiChunkSize?: number | null aiChunkSize?: number | null
mediaStorageProvider?: string | null
mediaR2AccountId?: string | null
mediaR2Bucket?: string | null
mediaR2PublicBaseUrl?: string | null
mediaR2AccessKeyId?: string | null
mediaR2SecretAccessKey?: string | null
} }
export interface AdminAiReindexResponse { export interface AdminAiReindexResponse {
@@ -160,6 +233,42 @@ export interface AdminAiProviderTestResponse {
reply_preview: string reply_preview: string
} }
export interface AdminAiImageProviderTestResponse {
provider: string
endpoint: string
image_model: string
result_preview: string
}
export interface AdminImageUploadResponse {
url: string
key: string
}
export interface AdminR2ConnectivityResponse {
bucket: string
public_base_url: string
}
export interface AdminMediaObjectResponse {
key: string
url: string
size_bytes: number
last_modified: string | null
}
export interface AdminMediaListResponse {
provider: string
bucket: string
public_base_url: string
items: AdminMediaObjectResponse[]
}
export interface AdminMediaDeleteResponse {
deleted: boolean
key: string
}
export interface MusicTrack { export interface MusicTrack {
title: string title: string
artist?: string | null artist?: string | null
@@ -182,6 +291,35 @@ export interface AdminPostPolishResponse {
polished_markdown: string polished_markdown: string
} }
export interface AdminReviewPolishRequest {
title: string
reviewType: string
rating: number
reviewDate?: string | null
status: string
tags: string[]
description: string
}
export interface AdminReviewPolishResponse {
polished_description: string
}
export interface AdminPostCoverImageRequest {
title: string
description?: string | null
category?: string | null
tags: string[]
postType: string
slug?: string | null
markdown: string
}
export interface AdminPostCoverImageResponse {
image_url: string
prompt: string
}
export interface PostRecord { export interface PostRecord {
created_at: string created_at: string
updated_at: string updated_at: string

View File

@@ -0,0 +1,413 @@
import { BarChart3, BrainCircuit, Clock3, RefreshCcw, Search } from 'lucide-react'
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { adminApi, ApiError } from '@/lib/api'
import type { AdminAnalyticsResponse } from '@/lib/types'
function StatCard({
label,
value,
note,
icon: Icon,
}: {
label: string
value: string
note: string
icon: typeof Search
}) {
return (
<Card className="bg-gradient-to-br from-card via-card to-background/70">
<CardContent className="flex items-start justify-between pt-6">
<div>
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">{label}</p>
<div className="mt-3 text-3xl font-semibold tracking-tight">{value}</div>
<p className="mt-2 text-sm leading-6 text-muted-foreground">{note}</p>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-primary/20 bg-primary/10 text-primary">
<Icon className="h-5 w-5" />
</div>
</CardContent>
</Card>
)
}
function formatEventType(value: string) {
return value === 'ai_question' ? 'AI 问答' : '站内搜索'
}
function formatSuccess(value: boolean | null) {
if (value === null) {
return '未记录'
}
return value ? '成功' : '失败'
}
export function AnalyticsPage() {
const [data, setData] = useState<AdminAnalyticsResponse | null>(null)
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const loadAnalytics = useCallback(async (showToast = false) => {
try {
if (showToast) {
setRefreshing(true)
}
const next = await adminApi.analytics()
startTransition(() => {
setData(next)
})
if (showToast) {
toast.success('数据分析已刷新。')
}
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
return
}
toast.error(error instanceof ApiError ? error.message : '无法加载数据分析。')
} finally {
setLoading(false)
setRefreshing(false)
}
}, [])
useEffect(() => {
void loadAnalytics(false)
}, [loadAnalytics])
const maxDailyTotal = useMemo(() => {
if (!data?.daily_activity.length) {
return 1
}
return Math.max(
...data.daily_activity.map((item) => item.searches + item.ai_questions),
1,
)
}, [data])
if (loading || !data) {
return (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{Array.from({ length: 4 }).map((_, index) => (
<Skeleton key={index} className="h-44 rounded-3xl" />
))}
</div>
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
<Skeleton className="h-[520px] rounded-3xl" />
<Skeleton className="h-[520px] rounded-3xl" />
</div>
</div>
)
}
const statCards = [
{
label: '累计搜索',
value: String(data.overview.total_searches),
note: `近 7 天 ${data.overview.searches_last_7d} 次,平均命中 ${data.overview.avg_search_results_last_7d.toFixed(1)}`,
icon: Search,
},
{
label: '累计 AI 提问',
value: String(data.overview.total_ai_questions),
note: `近 7 天 ${data.overview.ai_questions_last_7d}`,
icon: BrainCircuit,
},
{
label: '24 小时活跃',
value: String(data.overview.searches_last_24h + data.overview.ai_questions_last_24h),
note: `搜索 ${data.overview.searches_last_24h} / AI ${data.overview.ai_questions_last_24h}`,
icon: Clock3,
},
{
label: '近 7 天去重词',
value: String(
data.overview.unique_search_terms_last_7d +
data.overview.unique_ai_questions_last_7d,
),
note: `搜索 ${data.overview.unique_search_terms_last_7d} / AI ${data.overview.unique_ai_questions_last_7d}`,
icon: BarChart3,
},
]
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div className="space-y-3">
<Badge variant="secondary"></Badge>
<div>
<h2 className="text-3xl font-semibold tracking-tight"> AI </h2>
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
AI 便
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button variant="outline" asChild>
<a href="http://localhost:4321/ask" target="_blank" rel="noreferrer">
<BrainCircuit className="h-4 w-4" />
</a>
</Button>
<Button
variant="secondary"
onClick={() => void loadAnalytics(true)}
disabled={refreshing}
>
<RefreshCcw className="h-4 w-4" />
{refreshing ? '刷新中...' : '刷新'}
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{statCards.map((item) => (
<StatCard key={item.label} {...item} />
))}
</div>
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
<div className="space-y-6">
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle></CardTitle>
<CardDescription>
AI
</CardDescription>
</div>
<Badge variant="outline">{data.recent_events.length} </Badge>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.recent_events.map((event) => (
<TableRow key={event.id}>
<TableCell>
<div className="space-y-1">
<Badge variant={event.event_type === 'ai_question' ? 'secondary' : 'outline'}>
{formatEventType(event.event_type)}
</Badge>
{event.response_mode ? (
<p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
{event.response_mode}
</p>
) : null}
</div>
</TableCell>
<TableCell>
<div className="space-y-1">
<p className="line-clamp-2 font-medium">{event.query}</p>
<p className="text-xs text-muted-foreground">
{event.provider ? `${event.provider}` : '未记录渠道'}
{event.chat_model ? ` / ${event.chat_model}` : ''}
</p>
</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
<div>{formatSuccess(event.success)}</div>
<div className="mt-1">
{event.result_count !== null ? `${event.result_count} 条/源` : '无'}
</div>
{event.latency_ms !== null ? (
<div className="mt-1 text-xs uppercase tracking-[0.16em]">
{event.latency_ms} ms
</div>
) : null}
</TableCell>
<TableCell className="text-muted-foreground">{event.created_at}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle></CardTitle>
<CardDescription>
7
</CardDescription>
</div>
<Badge variant="outline">{data.top_search_terms.length} </Badge>
</CardHeader>
<CardContent className="space-y-3">
{data.top_search_terms.length ? (
data.top_search_terms.map((item) => (
<div
key={`${item.query}-${item.last_seen_at}`}
className="rounded-2xl border border-border/70 bg-background/70 p-4"
>
<div className="flex items-start justify-between gap-3">
<p className="font-medium">{item.query}</p>
<Badge variant="secondary">{item.count}</Badge>
</div>
<p className="mt-2 text-sm text-muted-foreground">
{item.last_seen_at}
</p>
</div>
))
) : (
<p className="text-sm text-muted-foreground"> 7 </p>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle> AI </CardTitle>
<CardDescription>
7
</CardDescription>
</div>
<Badge variant="outline">{data.top_ai_questions.length} </Badge>
</CardHeader>
<CardContent className="space-y-3">
{data.top_ai_questions.length ? (
data.top_ai_questions.map((item) => (
<div
key={`${item.query}-${item.last_seen_at}`}
className="rounded-2xl border border-border/70 bg-background/70 p-4"
>
<div className="flex items-start justify-between gap-3">
<p className="font-medium">{item.query}</p>
<Badge variant="secondary">{item.count}</Badge>
</div>
<p className="mt-2 text-sm text-muted-foreground">
{item.last_seen_at}
</p>
</div>
))
) : (
<p className="text-sm text-muted-foreground"> 7 AI </p>
)}
</CardContent>
</Card>
</div>
</div>
<div className="space-y-6 xl:sticky xl:top-28 xl:self-start">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
24 7
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
24
</p>
<p className="mt-3 text-3xl font-semibold">{data.overview.searches_last_24h}</p>
</div>
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
24 AI
</p>
<p className="mt-3 text-3xl font-semibold">{data.overview.ai_questions_last_24h}</p>
</div>
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
AI
</p>
<p className="mt-3 text-3xl font-semibold">
{data.overview.avg_ai_latency_ms_last_7d !== null
? `${Math.round(data.overview.avg_ai_latency_ms_last_7d)} ms`
: '暂无'}
</p>
<p className="mt-2 text-sm text-muted-foreground"> 7 </p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
7 AI 使 provider
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{data.providers_last_7d.length ? (
data.providers_last_7d.map((item) => (
<div
key={item.provider}
className="flex items-center justify-between rounded-2xl border border-border/70 bg-background/70 px-4 py-3"
>
<span className="font-medium">{item.provider}</span>
<Badge variant="outline">{item.count}</Badge>
</div>
))
) : (
<p className="text-sm text-muted-foreground"> 7 AI </p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>7 </CardTitle>
<CardDescription>
AI
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{data.daily_activity.map((item) => {
const total = item.searches + item.ai_questions
const width = `${Math.max((total / maxDailyTotal) * 100, total > 0 ? 12 : 0)}%`
return (
<div key={item.date} className="space-y-2">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="font-medium">{item.date}</span>
<span className="text-muted-foreground">
{item.searches} / AI {item.ai_questions}
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-secondary">
<div
className="h-full rounded-full bg-primary transition-[width] duration-300"
style={{ width }}
/>
</div>
</div>
)
})}
</CardContent>
</Card>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,190 @@
import { Copy, Image as ImageIcon, RefreshCcw, Trash2 } from 'lucide-react'
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
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 { Select } from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { adminApi, ApiError } from '@/lib/api'
import type { AdminMediaObjectResponse } from '@/lib/types'
function formatBytes(value: number) {
if (!Number.isFinite(value) || value <= 0) {
return '0 B'
}
const units = ['B', 'KB', 'MB', 'GB']
let size = value
let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024
unitIndex += 1
}
return `${size >= 10 || unitIndex === 0 ? size.toFixed(0) : size.toFixed(1)} ${units[unitIndex]}`
}
export function MediaPage() {
const [items, setItems] = useState<AdminMediaObjectResponse[]>([])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [deletingKey, setDeletingKey] = useState<string | null>(null)
const [prefixFilter, setPrefixFilter] = useState('all')
const [searchTerm, setSearchTerm] = useState('')
const [provider, setProvider] = useState<string | null>(null)
const [bucket, setBucket] = useState<string | null>(null)
const loadItems = useCallback(async (showToast = false) => {
try {
if (showToast) {
setRefreshing(true)
}
const prefix = prefixFilter === 'all' ? undefined : prefixFilter
const result = await adminApi.listMediaObjects({ prefix, limit: 200 })
startTransition(() => {
setItems(result.items)
setProvider(result.provider)
setBucket(result.bucket)
})
if (showToast) {
toast.success('媒体对象列表已刷新。')
}
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '媒体对象列表加载失败。')
} finally {
setLoading(false)
setRefreshing(false)
}
}, [prefixFilter])
useEffect(() => {
void loadItems(false)
}, [loadItems])
const filteredItems = useMemo(() => {
const keyword = searchTerm.trim().toLowerCase()
if (!keyword) {
return items
}
return items.filter((item) => item.key.toLowerCase().includes(keyword))
}, [items, searchTerm])
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div className="space-y-3">
<Badge variant="secondary"></Badge>
<div>
<h2 className="text-3xl font-semibold tracking-tight"></h2>
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button variant="outline" onClick={() => void loadItems(true)} disabled={refreshing}>
<RefreshCcw className="h-4 w-4" />
{refreshing ? '刷新中...' : '刷新'}
</Button>
</div>
</div>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
Provider{provider ?? '未配置'} / Bucket{bucket ?? '未配置'}
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 lg:grid-cols-[220px_1fr]">
<Select value={prefixFilter} onChange={(event) => setPrefixFilter(event.target.value)}>
<option value="all"></option>
<option value="post-covers/"></option>
<option value="review-covers/"></option>
</Select>
<Input
placeholder="按对象 key 搜索"
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
/>
</CardContent>
</Card>
{loading ? (
<Skeleton className="h-[520px] rounded-3xl" />
) : (
<div className="grid gap-4 xl:grid-cols-2 2xl:grid-cols-3">
{filteredItems.map((item) => (
<Card key={item.key} className="overflow-hidden">
<div className="aspect-[16/9] overflow-hidden bg-muted/30">
<img src={item.url} alt={item.key} className="h-full w-full object-cover" />
</div>
<CardContent className="space-y-4 p-5">
<div className="space-y-2">
<p className="line-clamp-2 break-all text-sm font-medium">{item.key}</p>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span>{formatBytes(item.size_bytes)}</span>
{item.last_modified ? <span>{item.last_modified}</span> : null}
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
onClick={async () => {
try {
await navigator.clipboard.writeText(item.url)
toast.success('图片链接已复制。')
} catch {
toast.error('复制失败,请手动复制。')
}
}}
>
<Copy className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="danger"
disabled={deletingKey === item.key}
onClick={async () => {
if (!window.confirm(`确定删除 ${item.key} 吗?`)) {
return
}
try {
setDeletingKey(item.key)
await adminApi.deleteMediaObject(item.key)
startTransition(() => {
setItems((current) => current.filter((currentItem) => currentItem.key !== item.key))
})
toast.success('媒体对象已删除。')
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '删除媒体对象失败。')
} finally {
setDeletingKey(null)
}
}}
>
<Trash2 className="h-4 w-4" />
{deletingKey === item.key ? '删除中...' : '删除'}
</Button>
</div>
</CardContent>
</Card>
))}
{!filteredItems.length ? (
<Card className="xl:col-span-2 2xl:col-span-3">
<CardContent className="flex flex-col items-center gap-3 px-6 py-16 text-center text-muted-foreground">
<ImageIcon className="h-8 w-8" />
<p></p>
</CardContent>
</Card>
) : null}
</div>
)}
</div>
)
}

View File

@@ -1,4 +1,3 @@
import { DiffEditor } from '@monaco-editor/react'
import { Bot, CheckCheck, RefreshCcw, WandSparkles } from 'lucide-react' import { Bot, CheckCheck, RefreshCcw, WandSparkles } from 'lucide-react'
import { startTransition, useEffect, useMemo, useState } from 'react' import { startTransition, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
@@ -8,6 +7,7 @@ import {
editorTheme, editorTheme,
sharedOptions, sharedOptions,
} from '@/components/markdown-workbench' } from '@/components/markdown-workbench'
import { LazyDiffEditor } from '@/components/lazy-monaco'
import { MarkdownPreview } from '@/components/markdown-preview' import { MarkdownPreview } from '@/components/markdown-preview'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -191,7 +191,7 @@ export function PostPolishPage() {
<span></span> <span></span>
</div> </div>
<div className="h-[560px]"> <div className="h-[560px]">
<DiffEditor <LazyDiffEditor
height="100%" height="100%"
language="markdown" language="markdown"
original={originalMarkdown} original={originalMarkdown}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
import { BookOpenText, RefreshCcw, Save, Trash2 } from 'lucide-react' import { BookOpenText, Bot, Check, RefreshCcw, RotateCcw, Save, Trash2, Upload } from 'lucide-react'
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react' import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { FormField } from '@/components/form-field' import { FormField } from '@/components/form-field'
@@ -32,6 +32,11 @@ type ReviewFormState = {
linkUrl: string linkUrl: string
} }
type ReviewDescriptionPolishState = {
originalDescription: string
polishedDescription: string
}
const defaultReviewForm: ReviewFormState = { const defaultReviewForm: ReviewFormState = {
title: '', title: '',
reviewType: 'book', reviewType: 'book',
@@ -94,8 +99,14 @@ export function ReviewsPage() {
const [refreshing, setRefreshing] = useState(false) const [refreshing, setRefreshing] = useState(false)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
const [uploadingCover, setUploadingCover] = useState(false)
const [polishingDescription, setPolishingDescription] = useState(false)
const [descriptionPolish, setDescriptionPolish] = useState<ReviewDescriptionPolishState | null>(
null,
)
const [searchTerm, setSearchTerm] = useState('') const [searchTerm, setSearchTerm] = useState('')
const [statusFilter, setStatusFilter] = useState('all') const [statusFilter, setStatusFilter] = useState('all')
const reviewCoverInputRef = useRef<HTMLInputElement | null>(null)
const loadReviews = useCallback(async (showToast = false) => { const loadReviews = useCallback(async (showToast = false) => {
try { try {
@@ -153,6 +164,70 @@ export function ReviewsPage() {
[reviews, selectedId], [reviews, selectedId],
) )
const requestDescriptionPolish = useCallback(async () => {
if (!form.description.trim()) {
toast.error('请先写一点点评内容,再让 AI 帮你润色。')
return
}
try {
setPolishingDescription(true)
const result = await adminApi.polishReviewDescription({
title: form.title.trim() || '未命名评测',
reviewType: form.reviewType,
rating: Number(form.rating) || 0,
reviewDate: form.reviewDate || null,
status: form.status,
tags: csvToList(form.tags),
description: form.description,
})
const polishedDescription =
typeof result.polished_description === 'string' ? result.polished_description : ''
if (!polishedDescription.trim()) {
throw new Error('AI 润色返回为空。')
}
startTransition(() => {
setDescriptionPolish({
originalDescription: form.description,
polishedDescription,
})
})
if (polishedDescription.trim() === form.description.trim()) {
toast.success('AI 已检查这段点评,当前文案已经比较完整。')
} else {
toast.success('AI 已生成一版更顺的点评文案,可以先对比再决定是否采用。')
}
} catch (error) {
toast.error(
error instanceof ApiError
? error.message
: error instanceof Error
? error.message
: 'AI 润色点评失败。',
)
} finally {
setPolishingDescription(false)
}
}, [form])
const uploadReviewCover = useCallback(async (file: File) => {
try {
setUploadingCover(true)
const result = await adminApi.uploadReviewCoverImage(file)
startTransition(() => {
setForm((current) => ({ ...current, cover: result.url }))
})
toast.success('评测封面已上传到 R2。')
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '评测封面上传失败。')
} finally {
setUploadingCover(false)
}
}, [])
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between"> <div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
@@ -172,6 +247,7 @@ export function ReviewsPage() {
onClick={() => { onClick={() => {
setSelectedId(null) setSelectedId(null)
setForm(defaultReviewForm) setForm(defaultReviewForm)
setDescriptionPolish(null)
}} }}
> >
@@ -220,6 +296,7 @@ export function ReviewsPage() {
onClick={() => { onClick={() => {
setSelectedId(review.id) setSelectedId(review.id)
setForm(toFormState(review)) setForm(toFormState(review))
setDescriptionPolish(null)
}} }}
className={`w-full rounded-3xl border px-4 py-4 text-left transition ${ className={`w-full rounded-3xl border px-4 py-4 text-left transition ${
selectedId === review.id selectedId === review.id
@@ -295,6 +372,7 @@ export function ReviewsPage() {
startTransition(() => { startTransition(() => {
setSelectedId(updated.id) setSelectedId(updated.id)
setForm(toFormState(updated)) setForm(toFormState(updated))
setDescriptionPolish(null)
}) })
toast.success('评测已更新。') toast.success('评测已更新。')
} else { } else {
@@ -302,6 +380,7 @@ export function ReviewsPage() {
startTransition(() => { startTransition(() => {
setSelectedId(created.id) setSelectedId(created.id)
setForm(toFormState(created)) setForm(toFormState(created))
setDescriptionPolish(null)
}) })
toast.success('评测已创建。') toast.success('评测已创建。')
} }
@@ -332,6 +411,7 @@ export function ReviewsPage() {
toast.success('评测已删除。') toast.success('评测已删除。')
setSelectedId(null) setSelectedId(null)
setForm(defaultReviewForm) setForm(defaultReviewForm)
setDescriptionPolish(null)
await loadReviews(false) await loadReviews(false)
} catch (error) { } catch (error) {
toast.error(error instanceof ApiError ? error.message : '无法删除评测。') toast.error(error instanceof ApiError ? error.message : '无法删除评测。')
@@ -414,13 +494,49 @@ export function ReviewsPage() {
<option value="archived"></option> <option value="archived"></option>
</Select> </Select>
</FormField> </FormField>
<FormField label="封面 URL"> <FormField label="封面 URL" hint="可直接填外链,也可以上传图片到 R2。">
<Input <div className="space-y-3">
value={form.cover} <div className="flex flex-col gap-3 sm:flex-row">
onChange={(event) => <Input
setForm((current) => ({ ...current, cover: event.target.value })) value={form.cover}
} onChange={(event) =>
/> setForm((current) => ({ ...current, cover: event.target.value }))
}
/>
<input
ref={reviewCoverInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif,image/svg+xml"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0]
if (file) {
void uploadReviewCover(file)
}
event.target.value = ''
}}
/>
<Button
type="button"
variant="outline"
disabled={uploadingCover}
onClick={() => reviewCoverInputRef.current?.click()}
>
<Upload className="h-4 w-4" />
{uploadingCover ? '上传中...' : '上传到 R2'}
</Button>
</div>
{form.cover ? (
<div className="overflow-hidden rounded-[1.4rem] border border-border/70 bg-background/70">
<img
src={form.cover}
alt={form.title || '评测封面预览'}
className="h-48 w-full object-cover"
/>
</div>
) : null}
</div>
</FormField> </FormField>
<FormField label="跳转链接" hint="可填写站内路径或完整 URL。"> <FormField label="跳转链接" hint="可填写站内路径或完整 URL。">
<Input <Input
@@ -442,13 +558,113 @@ export function ReviewsPage() {
</FormField> </FormField>
</div> </div>
<div className="lg:col-span-2"> <div className="lg:col-span-2">
<FormField label="简介"> <FormField
<Textarea label="简介 / 点评"
value={form.description} hint="可以先写你的原始观感,再用 AI 帮你把这段点评润得更顺。"
onChange={(event) => >
setForm((current) => ({ ...current, description: event.target.value })) <div className="space-y-3">
} <div className="flex flex-col gap-3 rounded-[1.5rem] border border-border/70 bg-background/65 px-4 py-4 lg:flex-row lg:items-center lg:justify-between">
/> <p className="text-sm leading-6 text-muted-foreground">
AI
</p>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={() => void requestDescriptionPolish()}
disabled={polishingDescription}
>
<Bot className="h-4 w-4" />
{polishingDescription ? '润色中...' : 'AI 润色点评'}
</Button>
{descriptionPolish ? (
<Button
size="sm"
variant="ghost"
onClick={() => setDescriptionPolish(null)}
>
<RotateCcw className="h-4 w-4" />
</Button>
) : null}
</div>
</div>
<Textarea
value={form.description}
onChange={(event) => {
const nextDescription = event.target.value
setForm((current) => ({ ...current, description: nextDescription }))
setDescriptionPolish((current) =>
current && current.originalDescription === nextDescription ? current : null,
)
}}
/>
{descriptionPolish ? (
<div className="overflow-hidden rounded-[1.8rem] border border-border/70 bg-background/80">
<div className="flex flex-col gap-3 border-b border-border/70 px-5 py-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<p className="text-base font-semibold">AI </p>
<p className="mt-1 text-sm text-muted-foreground">
AI
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
onClick={() => {
setForm((current) => ({
...current,
description: descriptionPolish.polishedDescription,
}))
setDescriptionPolish(null)
toast.success('AI 润色点评已回填到评测简介。')
}}
>
<Check className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => void requestDescriptionPolish()}
disabled={polishingDescription}
>
<Bot className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setDescriptionPolish(null)}
>
</Button>
</div>
</div>
<div className="grid gap-4 p-5 xl:grid-cols-2">
<div className="rounded-[1.4rem] border border-border/70 bg-muted/20 p-4">
<p className="text-[11px] uppercase tracking-[0.22em] text-muted-foreground">
</p>
<p className="mt-3 whitespace-pre-wrap break-words text-sm leading-7">
{descriptionPolish.originalDescription.trim() || '未填写'}
</p>
</div>
<div className="rounded-[1.4rem] border border-emerald-500/30 bg-emerald-500/5 p-4">
<p className="text-[11px] uppercase tracking-[0.22em] text-muted-foreground">
AI
</p>
<p className="mt-3 whitespace-pre-wrap break-words text-sm leading-7">
{descriptionPolish.polishedDescription.trim() || '未填写'}
</p>
</div>
</div>
</div>
) : null}
</div>
</FormField> </FormField>
</div> </div>
</div> </div>

View File

@@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Select } from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { adminApi, ApiError } from '@/lib/api' import { adminApi, ApiError } from '@/lib/api'
@@ -42,10 +43,39 @@ function createEmptyAiProvider(): AiProviderConfig {
return { return {
id: createAiProviderId(), id: createAiProviderId(),
name: '', name: '',
provider: 'newapi', provider: 'openai',
api_base: '', api_base: '',
api_key: '', api_key: '',
chat_model: '', 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
const MEDIA_STORAGE_PROVIDER_OPTIONS = [
{ value: 'r2', label: 'Cloudflare R2' },
{ value: 'minio', label: 'MinIO' },
] as const
function isCloudflareProvider(provider: string | null | undefined) {
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',
} }
} }
@@ -105,12 +135,22 @@ function toPayload(form: AdminSiteSettingsResponse): SiteSettingsPayload {
aiApiBase: form.ai_api_base, aiApiBase: form.ai_api_base,
aiApiKey: form.ai_api_key, aiApiKey: form.ai_api_key,
aiChatModel: form.ai_chat_model, aiChatModel: form.ai_chat_model,
aiImageProvider: form.ai_image_provider,
aiImageApiBase: form.ai_image_api_base,
aiImageApiKey: form.ai_image_api_key,
aiImageModel: form.ai_image_model,
aiProviders: form.ai_providers, aiProviders: form.ai_providers,
aiActiveProviderId: form.ai_active_provider_id, aiActiveProviderId: form.ai_active_provider_id,
aiEmbeddingModel: form.ai_embedding_model, aiEmbeddingModel: form.ai_embedding_model,
aiSystemPrompt: form.ai_system_prompt, aiSystemPrompt: form.ai_system_prompt,
aiTopK: form.ai_top_k, aiTopK: form.ai_top_k,
aiChunkSize: form.ai_chunk_size, aiChunkSize: form.ai_chunk_size,
mediaStorageProvider: form.media_storage_provider,
mediaR2AccountId: form.media_r2_account_id,
mediaR2Bucket: form.media_r2_bucket,
mediaR2PublicBaseUrl: form.media_r2_public_base_url,
mediaR2AccessKeyId: form.media_r2_access_key_id,
mediaR2SecretAccessKey: form.media_r2_secret_access_key,
} }
} }
@@ -120,6 +160,8 @@ export function SiteSettingsPage() {
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [reindexing, setReindexing] = useState(false) const [reindexing, setReindexing] = useState(false)
const [testingProvider, setTestingProvider] = useState(false) const [testingProvider, setTestingProvider] = useState(false)
const [testingImageProvider, setTestingImageProvider] = useState(false)
const [testingR2Storage, setTestingR2Storage] = useState(false)
const [selectedTrackIndex, setSelectedTrackIndex] = useState(0) const [selectedTrackIndex, setSelectedTrackIndex] = useState(0)
const [selectedProviderIndex, setSelectedProviderIndex] = useState(0) const [selectedProviderIndex, setSelectedProviderIndex] = useState(0)
@@ -290,6 +332,38 @@ export function SiteSettingsPage() {
updateField('ai_active_provider_id', providerId) updateField('ai_active_provider_id', providerId)
} }
const applyCloudflarePreset = (index: number) => {
setForm((current) => {
if (!current) {
return current
}
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,
ai_image_provider: 'cloudflare',
ai_image_model:
current.ai_image_model?.trim() || '@cf/black-forest-labs/flux-2-klein-4b',
}
})
}
const techStackValue = useMemo( const techStackValue = useMemo(
() => (form?.tech_stack.length ? form.tech_stack.join('\n') : ''), () => (form?.tech_stack.length ? form.tech_stack.join('\n') : ''),
[form?.tech_stack], [form?.tech_stack],
@@ -306,6 +380,18 @@ export function SiteSettingsPage() {
() => 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], [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],
)
if (loading || !form) { if (loading || !form) {
return ( return (
@@ -532,7 +618,7 @@ export function SiteSettingsPage() {
<CardHeader> <CardHeader>
<CardTitle>AI </CardTitle> <CardTitle>AI </CardTitle>
<CardDescription> <CardDescription>
AI 使
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-5"> <CardContent className="space-y-5">
@@ -551,182 +637,328 @@ export function SiteSettingsPage() {
</div> </div>
</label> </label>
<Field label="提供方"> <div className="rounded-[1.75rem] border border-border/70 bg-background/55 p-4">
<div className="rounded-[1.75rem] border border-border/70 bg-background/55 p-4"> <div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center justify-between gap-3"> <div>
<div> <p className="text-sm font-medium"> Provider</p>
<p className="text-sm font-medium"></p> <p className="mt-1 text-sm leading-6 text-muted-foreground">
<p className="mt-1 text-sm leading-6 text-muted-foreground">
</p>
</p> </div>
</div> <Button type="button" variant="outline" onClick={addAiProvider}>
<Button type="button" variant="outline" onClick={addAiProvider}> <Plus className="h-4 w-4" />
<Plus className="h-4 w-4" />
</Button>
</Button> </div>
<div className="mt-4 grid gap-4 xl:grid-cols-[280px_minmax(0,1fr)]">
<div className="space-y-3">
{form.ai_providers.length ? (
form.ai_providers.map((provider, index) => {
const active = provider.id === form.ai_active_provider_id
const selected = index === selectedProviderIndex
return (
<button
key={provider.id}
type="button"
onClick={() => setSelectedProviderIndex(index)}
className={
selected
? 'w-full rounded-[1.35rem] border border-primary/30 bg-primary/10 px-4 py-4 text-left shadow-[0_12px_28px_rgba(37,99,235,0.12)]'
: 'w-full rounded-[1.35rem] border border-border/70 bg-background/70 px-4 py-4 text-left transition hover:border-border hover:bg-accent/35'
}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="truncate font-medium">
{provider.name?.trim() || `提供商 ${index + 1}`}
</p>
<p className="mt-1 truncate text-sm text-muted-foreground">
Provider{provider.provider?.trim() || '未填写'}
</p>
</div>
{active ? (
<Badge variant="secondary" className="shrink-0">
</Badge>
) : null}
</div>
<p className="mt-3 truncate font-mono text-[11px] text-muted-foreground">
{provider.chat_model?.trim() || '未填写模型'}
</p>
</button>
)
})
) : (
<div className="rounded-[1.35rem] border border-dashed border-border/70 bg-background/60 px-4 py-6 text-sm leading-6 text-muted-foreground">
使
</div>
)}
</div> </div>
<div className="mt-4 grid gap-4 xl:grid-cols-[280px_minmax(0,1fr)]"> <div className="rounded-[1.5rem] border border-border/70 bg-background/65 p-5">
<div className="space-y-3"> {form.ai_providers.length ? (
{form.ai_providers.length ? ( <div className="space-y-4">
form.ai_providers.map((provider, index) => { <div className="flex flex-wrap items-start justify-between gap-3">
const active = provider.id === form.ai_active_provider_id <div>
const selected = index === selectedProviderIndex <p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
return ( </p>
<button <p className="mt-2 text-lg font-semibold">
key={provider.id} {selectedProvider.name?.trim() || `提供商 ${selectedProviderIndex + 1}`}
type="button" </p>
onClick={() => setSelectedProviderIndex(index)} <p className="mt-1 text-sm text-muted-foreground">
className={ 使 AI
selected </p>
? 'w-full rounded-[1.35rem] border border-primary/30 bg-primary/10 px-4 py-4 text-left shadow-[0_12px_28px_rgba(37,99,235,0.12)]'
: 'w-full rounded-[1.35rem] border border-border/70 bg-background/70 px-4 py-4 text-left transition hover:border-border hover:bg-accent/35'
}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="truncate font-medium">
{provider.name?.trim() || `提供商 ${index + 1}`}
</p>
<p className="mt-1 truncate text-sm text-muted-foreground">
{provider.provider?.trim() || '未填写 provider'}
</p>
</div>
{active ? (
<Badge variant="secondary" className="shrink-0">
</Badge>
) : null}
</div>
<p className="mt-3 truncate font-mono text-[11px] text-muted-foreground">
{provider.chat_model?.trim() || '未填写模型'}
</p>
</button>
)
})
) : (
<div className="rounded-[1.35rem] border border-dashed border-border/70 bg-background/60 px-4 py-6 text-sm leading-6 text-muted-foreground">
使
</div>
)}
</div>
<div className="rounded-[1.5rem] border border-border/70 bg-background/65 p-5">
{form.ai_providers.length ? (
<div className="space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
</p>
<p className="mt-2 text-lg font-semibold">
{selectedProvider.name?.trim() || `提供商 ${selectedProviderIndex + 1}`}
</p>
<p className="mt-1 text-sm text-muted-foreground">
使 AI
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
disabled={testingProvider}
onClick={async () => {
try {
setTestingProvider(true)
const result = await adminApi.testAiProvider(selectedProvider)
toast.success(
`连通成功:${result.provider} / ${result.chat_model} / ${result.reply_preview}`,
)
} catch (error) {
toast.error(
error instanceof ApiError ? error.message : '模型连通性测试失败。',
)
} finally {
setTestingProvider(false)
}
}}
>
<Bot className="h-4 w-4" />
{testingProvider ? '测试中...' : '测试连通性'}
</Button>
<Button
type="button"
variant={selectedProvider.id === form.ai_active_provider_id ? 'secondary' : 'outline'}
onClick={() => setActiveAiProvider(selectedProvider.id)}
>
<Check className="h-4 w-4" />
{selectedProvider.id === form.ai_active_provider_id ? '已启用' : '设为启用'}
</Button>
<Button
type="button"
variant="outline"
onClick={() => removeAiProvider(selectedProviderIndex)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div> </div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
onClick={() => applyCloudflarePreset(selectedProviderIndex)}
>
<RefreshCcw className="h-4 w-4" />
Cloudflare
</Button>
<Button
type="button"
variant="outline"
disabled={testingProvider}
onClick={async () => {
try {
setTestingProvider(true)
const result = await adminApi.testAiProvider(selectedProvider)
toast.success(
`连通成功:${result.provider} / ${result.chat_model} / ${result.reply_preview}`,
)
} catch (error) {
toast.error(
error instanceof ApiError ? error.message : '模型连通性测试失败。',
)
} finally {
setTestingProvider(false)
}
}}
>
<Bot className="h-4 w-4" />
{testingProvider ? '测试中...' : '测试连通性'}
</Button>
<Button
type="button"
variant={selectedProvider.id === form.ai_active_provider_id ? 'secondary' : 'outline'}
onClick={() => setActiveAiProvider(selectedProvider.id)}
>
<Check className="h-4 w-4" />
{selectedProvider.id === form.ai_active_provider_id ? '已启用' : '设为启用'}
</Button>
<Button
type="button"
variant="outline"
onClick={() => removeAiProvider(selectedProviderIndex)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<Field label="显示名称" hint="例如 OpenAI 主通道、Gemini 备用线路。"> <Field label="显示名称" hint="例如 OpenAI 主通道、Gemini 备用线路。">
<Input <Input
value={selectedProvider.name ?? ''} value={selectedProvider.name ?? ''}
onChange={(event) => onChange={(event) =>
updateAiProvider(selectedProviderIndex, 'name', event.target.value) updateAiProvider(selectedProviderIndex, 'name', event.target.value)
} }
/> />
</Field> </Field>
<Field label="Provider 标识"> <Field
<Input label="Provider"
value={selectedProvider.provider ?? ''} hint="选择文本模型提供方。Cloudflare 文本模型也支持。"
onChange={(event) => >
updateAiProvider(selectedProviderIndex, 'provider', event.target.value) <Select
} value={selectedProvider.provider ?? ''}
placeholder="newapi / openai-compatible / 其他兼容值" onChange={(event) =>
/> updateAiProvider(selectedProviderIndex, 'provider', event.target.value)
</Field> }
<Field label="API 地址"> >
<Input {AI_PROVIDER_OPTIONS.map((option) => (
value={selectedProvider.api_base ?? ''} <option key={option.value} value={option.value}>
onChange={(event) => {option.label}
updateAiProvider(selectedProviderIndex, 'api_base', event.target.value) </option>
} ))}
/> </Select>
</Field> </Field>
<Field label="API 密钥"> <Field
<Input label="API 地址"
value={selectedProvider.api_key ?? ''} hint={selectedProviderIsCloudflare ? 'Cloudflare 可直接填写 Account ID或填写 https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>。' : undefined}
onChange={(event) => >
updateAiProvider(selectedProviderIndex, 'api_key', event.target.value) <Input
} value={selectedProvider.api_base ?? ''}
/> onChange={(event) =>
</Field> updateAiProvider(selectedProviderIndex, 'api_base', event.target.value)
<Field label="对话模型"> }
<Input placeholder={selectedProviderIsCloudflare ? 'Cloudflare Account ID 或完整 accounts URL' : undefined}
value={selectedProvider.chat_model ?? ''} />
onChange={(event) => </Field>
updateAiProvider(selectedProviderIndex, 'chat_model', event.target.value) <Field
} label="API 密钥"
/> hint={selectedProviderIsCloudflare ? '请填写 Cloudflare Workers AI API Token。该 Token 需要 Workers AI Read 和 Edit 权限。' : undefined}
</Field> >
</div> <Input
) : ( value={selectedProvider.api_key ?? ''}
<div className="flex h-full min-h-[240px] items-center justify-center rounded-[1.35rem] border border-dashed border-border/70 bg-background/60 px-6 text-center text-sm leading-6 text-muted-foreground"> onChange={(event) =>
provider API updateAiProvider(selectedProviderIndex, 'api_key', event.target.value)
</div> }
)} />
</div> </Field>
<Field
label="对话模型"
hint={selectedProviderIsCloudflare ? '例如 @cf/meta/llama-3.1-8b-instruct用于问答与连通性测试。' : undefined}
>
<Input
value={selectedProvider.chat_model ?? ''}
onChange={(event) =>
updateAiProvider(selectedProviderIndex, 'chat_model', event.target.value)
}
/>
</Field>
{selectedProviderIsCloudflare ? (
<div className="rounded-2xl border border-primary/15 bg-primary/5 px-4 py-3 text-sm leading-6 text-muted-foreground">
<p className="font-medium text-foreground"> / Cloudflare </p>
<ul className="mt-2 list-disc space-y-1 pl-5">
<li>API Cloudflare Account ID</li>
<li> AI </li>
</ul>
</div>
) : null}
</div>
) : (
<div className="flex h-full min-h-[240px] items-center justify-center rounded-[1.35rem] border border-dashed border-border/70 bg-background/60 px-6 text-center text-sm leading-6 text-muted-foreground">
provider API
</div>
)}
</div> </div>
</div> </div>
</Field> </div>
<div className="rounded-2xl border border-border/70 bg-background/60 p-4 text-sm leading-6 text-muted-foreground"> <div className="rounded-2xl border border-border/70 bg-background/60 p-4 text-sm leading-6 text-muted-foreground">
AI
{activeProvider {activeProvider
? `${activeProvider.name || activeProvider.provider} / ${activeProvider.chat_model || '未填写模型'}` ? `${activeProvider.provider || activeProvider.name} / ${activeProvider.chat_model || '未填写模型'}`
: '未选择提供商'} : '未选择提供商'}
</div> </div>
<div className="rounded-[1.75rem] border border-border/70 bg-background/55 p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-sm font-medium"></p>
<p className="mt-1 text-sm leading-6 text-muted-foreground">
AI
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" onClick={applyCloudflareImagePreset}>
<RefreshCcw className="h-4 w-4" />
Cloudflare
</Button>
<Button
type="button"
variant="outline"
disabled={testingImageProvider}
onClick={async () => {
try {
setTestingImageProvider(true)
const result = await adminApi.testAiImageProvider({
provider: form.ai_image_provider ?? '',
api_base: form.ai_image_api_base,
api_key: form.ai_image_api_key,
image_model: form.ai_image_model,
})
toast.success(
`图片连通成功:${result.provider} / ${result.image_model} / ${result.result_preview}`,
)
} catch (error) {
toast.error(
error instanceof ApiError ? error.message : '图片模型连通性测试失败。',
)
} finally {
setTestingImageProvider(false)
}
}}
>
<Bot className="h-4 w-4" />
{testingImageProvider ? '测试中...' : '测试图片连通性'}
</Button>
</div>
</div>
<div className="mt-4 grid gap-4 lg:grid-cols-2">
<Field
label="图片 Provider"
hint="选择图片模型提供方。这里专门用于封面图生成。"
>
<Select
value={form.ai_image_provider ?? ''}
onChange={(event) => updateField('ai_image_provider', event.target.value)}
>
{AI_PROVIDER_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
</Field>
<Field
label="图片模型"
hint={imageProviderIsCloudflare ? 'Cloudflare 建议使用 @cf/black-forest-labs/flux-2-klein-4b。' : '这里填写图片模型名。'}
>
<Input
value={form.ai_image_model ?? ''}
onChange={(event) => updateField('ai_image_model', event.target.value)}
placeholder={imageProviderIsCloudflare ? '@cf/black-forest-labs/flux-2-klein-4b' : undefined}
/>
</Field>
<div className="lg:col-span-2">
<Field
label="图片 API 地址"
hint={imageProviderIsCloudflare ? 'Cloudflare 可直接填写 Account ID或填写完整 accounts URL。' : '填写图片服务的 API 地址。'}
>
<Input
value={form.ai_image_api_base ?? ''}
onChange={(event) => updateField('ai_image_api_base', event.target.value)}
placeholder={imageProviderIsCloudflare ? 'Cloudflare Account ID 或完整 accounts URL' : undefined}
/>
</Field>
</div>
<div className="lg:col-span-2">
<Field
label="图片 API 密钥"
hint={imageProviderIsCloudflare ? '请填写 Cloudflare Workers AI API Token。' : '填写图片服务的 API Key。'}
>
<Input
value={form.ai_image_api_key ?? ''}
onChange={(event) => updateField('ai_image_api_key', event.target.value)}
/>
</Field>
</div>
</div>
<div className="mt-4 rounded-2xl border border-border/70 bg-background/60 p-4 text-sm leading-6 text-muted-foreground">
AI
{form.ai_image_provider?.trim()
? `${form.ai_image_provider} / ${form.ai_image_model || '未填写模型'}`
: '未填写,封面图会回退到旧配置'}
</div>
{imageProviderIsCloudflare ? (
<div className="mt-4 rounded-2xl border border-primary/15 bg-primary/5 px-4 py-3 text-sm leading-6 text-muted-foreground">
<p className="font-medium text-foreground"> / Cloudflare </p>
<ul className="mt-2 list-disc space-y-1 pl-5">
<li>API Cloudflare Account ID</li>
<li>AI </li>
<li></li>
</ul>
</div>
) : null}
</div>
<Field <Field
label="向量模型" label="向量模型"
hint={`本地选项:${form.ai_local_embedding}`} hint={`本地选项:${form.ai_local_embedding}`}
@@ -771,6 +1003,128 @@ export function SiteSettingsPage() {
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader>
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<CardTitle></CardTitle>
<CardDescription>
AI Cloudflare R2 / MinIO
</CardDescription>
</div>
<Button
type="button"
variant="outline"
disabled={testingR2Storage}
onClick={async () => {
try {
setTestingR2Storage(true)
const result = await adminApi.testR2Storage()
toast.success(`存储连通成功:${result.bucket} / ${result.public_base_url}`)
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '对象存储连通性测试失败。')
} finally {
setTestingR2Storage(false)
}
}}
>
<Bot className="h-4 w-4" />
{testingR2Storage ? '测试中...' : '测试存储连通性'}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-5">
<div className="grid gap-4 lg:grid-cols-2">
<Field
label="存储 Provider"
hint="选择媒体资源存储后端。"
>
<Select
value={form.media_storage_provider ?? 'r2'}
onChange={(event) => updateField('media_storage_provider', event.target.value)}
>
{MEDIA_STORAGE_PROVIDER_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
</Field>
<Field
label={mediaStorageProvider === 'minio' ? 'Endpoint' : 'Account ID'}
hint={
mediaStorageProvider === 'minio'
? '例如 http://10.0.0.2:9100 或你的 MinIO API 地址。'
: 'Cloudflare 账户 ID用来拼接 R2 S3 兼容 endpoint。'
}
>
<Input
value={form.media_r2_account_id ?? ''}
onChange={(event) => updateField('media_r2_account_id', event.target.value)}
placeholder={mediaStorageProvider === 'minio' ? 'http://10.0.0.2:9100' : undefined}
/>
</Field>
<Field
label="Bucket"
hint={mediaStorageProvider === 'minio' ? '存放封面图的 MinIO bucket 名称。' : '存放封面图的 R2 bucket 名称。'}
>
<Input
value={form.media_r2_bucket ?? ''}
onChange={(event) => updateField('media_r2_bucket', event.target.value)}
/>
</Field>
<div className="lg:col-span-2">
<Field
label="Public Base URL"
hint={
mediaStorageProvider === 'minio'
? '例如 https://s3.init.cool/你的bucket 或 http://10.0.0.2:9100/你的bucket。系统会把对象 key 拼到这个地址后面。'
: '例如 https://image.init.cool 或你的 R2 公网域名。系统会把对象 key 拼到这个地址后面。'
}
>
<Input
value={form.media_r2_public_base_url ?? ''}
onChange={(event) =>
updateField('media_r2_public_base_url', event.target.value)
}
/>
</Field>
</div>
<Field
label="Access Key ID"
hint={mediaStorageProvider === 'minio' ? 'MinIO / S3 的 Access Key ID。' : 'R2 S3 API 的 Access Key ID。'}
>
<Input
value={form.media_r2_access_key_id ?? ''}
onChange={(event) =>
updateField('media_r2_access_key_id', event.target.value)
}
/>
</Field>
<Field
label="Secret Access Key"
hint={mediaStorageProvider === 'minio' ? 'MinIO / S3 的 Secret Access Key。后端会用 Rust SDK 上传图片。' : 'R2 S3 API 的 Secret Access Key。后端会用 Rust SDK 上传图片。'}
>
<Input
value={form.media_r2_secret_access_key ?? ''}
onChange={(event) =>
updateField('media_r2_secret_access_key', event.target.value)
}
/>
</Field>
</div>
<div className="rounded-2xl border border-border/70 bg-background/60 p-4 text-sm leading-6 text-muted-foreground">
<p className="font-medium text-foreground"></p>
<ul className="mt-2 list-disc space-y-1 pl-5">
<li> AI `post-covers/`</li>
<li> `review-covers/`</li>
<li>{mediaStorageProvider === 'minio' ? '当前会按 MinIO / S3 兼容方式上传。' : '当前会按 Cloudflare R2 方式上传。'}</li>
</ul>
</div>
</CardContent>
</Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle></CardTitle> <CardTitle></CardTitle>

966
backend/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -42,10 +42,12 @@ unic-langid = { version = "0.9" }
# /view engine # /view engine
axum-extra = { version = "0.10", features = ["form"] } axum-extra = { version = "0.10", features = ["form"] }
tower-http = { version = "0.6", features = ["cors"] } tower-http = { version = "0.6", features = ["cors"] }
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "multipart", "rustls-tls"] }
fastembed = "5.1" fastembed = "5.1"
async-stream = "0.3" async-stream = "0.3"
base64 = "0.22" base64 = "0.22"
aws-config = "1"
aws-sdk-s3 = "1"
[[bin]] [[bin]]
name = "termi_api-cli" name = "termi_api-cli"

View File

@@ -83,8 +83,9 @@
<div class="field-hint">关闭后,前台导航不会显示 AI 页面公开接口也不会对外提供回答。Embedding 已改为后端本地生成,并使用 PostgreSQL 的 pgvector 存储与检索。</div> <div class="field-hint">关闭后,前台导航不会显示 AI 页面公开接口也不会对外提供回答。Embedding 已改为后端本地生成,并使用 PostgreSQL 的 pgvector 存储与检索。</div>
</div> </div>
<div class="field"> <div class="field">
<label>聊天 Provider</label> <label>接入类型 / 协议</label>
<input name="ai_provider" value="{{ form.ai_provider }}" placeholder="newapi"> <input name="ai_provider" value="{{ form.ai_provider }}" placeholder="newapi">
<div class="field-hint">这里是后端适配器类型,不是模型厂商名。`newapi` 表示走 NewAPI 兼容的 Responses 接口;厂商和型号建议写在你的通道备注与模型名里。</div>
</div> </div>
<div class="field"> <div class="field">
<label>聊天 API Base</label> <label>聊天 API Base</label>
@@ -121,7 +122,7 @@
<button type="submit" class="btn btn-primary">保存设置</button> <button type="submit" class="btn btn-primary">保存设置</button>
<button type="button" id="reindex-btn" class="btn">重建 AI 索引</button> <button type="button" id="reindex-btn" class="btn">重建 AI 索引</button>
</div> </div>
<div class="field-hint" style="margin-top: 10px;">文章内容变化后建议手动重建一次 AI 索引。本地 embedding 使用后端内置 `fastembed` 生成,向量会写入 PostgreSQL 的 `pgvector` 列,并通过 HNSW 索引做相似度检索;聊天回答默认走 `newapi -> /responses -> gpt-5.4`。</div> <div class="field-hint" style="margin-top: 10px;">文章内容变化后建议手动重建一次 AI 索引。本地 embedding 使用后端内置 `fastembed` 生成,向量会写入 PostgreSQL 的 `pgvector` 列,并通过 HNSW 索引做相似度检索;聊天回答默认走 `newapi -> /responses -> gpt-5.4`。前台用户提交过的搜索词和 AI 问题会单独写入分析日志,方便在新版后台里查看。</div>
<div id="notice" class="notice"></div> <div id="notice" class="notice"></div>
</div> </div>
</form> </form>

View File

@@ -1,15 +1,18 @@
--- ---
title: "Redis常用命令" title: "Redis 安装与常用命令整理"
description:
date: 2022-04-21T09:42:24+08:00
draft: false
slug: redis slug: redis
image: description: "文章介绍了 Redis 在 Debian 下的安装方法、Windows 图形客户端的安装方式以及监听端口修改、BitMap、消息队列、LREM 和 Pipeline 等常用操作示例。"
categories: category: "数据库"
- Database post_type: "article"
pinned: false
published: true
tags: tags:
- Database - "Redis安装"
- Redis - "Debian"
- "BitMap"
- "消息队列"
- "Pipeline"
- "go-redis"
--- ---
# 安装`Redis` # 安装`Redis`

View File

@@ -1,15 +1,17 @@
--- ---
title: "如何在 Tmux 会话窗格中发送命令" title: "在 Tmux 会话窗格中发送命令的方法"
description: 本文介绍了在 Tmux 中发送命令的步骤,包括新建分离会话、发送命令至会话窗格、连接会话窗格、以及发送特殊命令。通过本文,读者将了解如何在 Tmux 中发送命令,并能够更加高效地使用 Tmux。
date: 2022-08-02T14:54:08+08:00
draft: false
slug: tmux slug: tmux
image: description: "介绍如何在 Tmux 中创建分离会话、向指定窗格发送命令并执行回车,同时说明连接会话和发送特殊按键的基本用法。"
categories: category: "Linux"
- Linux post_type: "article"
pinned: false
published: true
tags: tags:
- Linux - "Tmux"
- Tmux - "终端复用"
- "send-keys"
- "会话管理"
- "命令行"
--- ---
## 在 Tmux 会话窗格中发送命令的方法 ## 在 Tmux 会话窗格中发送命令的方法

View File

@@ -21,6 +21,10 @@ mod m20260328_000010_add_paragraph_comments_toggle_to_site_settings;
mod m20260328_000011_add_post_images_and_music_playlist; mod m20260328_000011_add_post_images_and_music_playlist;
mod m20260329_000012_add_link_url_to_reviews; mod m20260329_000012_add_link_url_to_reviews;
mod m20260329_000013_add_ai_provider_presets_to_site_settings; mod m20260329_000013_add_ai_provider_presets_to_site_settings;
mod m20260329_000014_create_query_events;
mod m20260330_000015_add_image_ai_settings_to_site_settings;
mod m20260330_000016_add_r2_media_settings_to_site_settings;
mod m20260330_000017_add_media_storage_provider_to_site_settings;
pub struct Migrator; pub struct Migrator;
#[async_trait::async_trait] #[async_trait::async_trait]
@@ -46,6 +50,10 @@ impl MigratorTrait for Migrator {
Box::new(m20260328_000011_add_post_images_and_music_playlist::Migration), Box::new(m20260328_000011_add_post_images_and_music_playlist::Migration),
Box::new(m20260329_000012_add_link_url_to_reviews::Migration), Box::new(m20260329_000012_add_link_url_to_reviews::Migration),
Box::new(m20260329_000013_add_ai_provider_presets_to_site_settings::Migration), Box::new(m20260329_000013_add_ai_provider_presets_to_site_settings::Migration),
Box::new(m20260329_000014_create_query_events::Migration),
Box::new(m20260330_000015_add_image_ai_settings_to_site_settings::Migration),
Box::new(m20260330_000016_add_r2_media_settings_to_site_settings::Migration),
Box::new(m20260330_000017_add_media_storage_provider_to_site_settings::Migration),
// inject-above (do not remove this comment) // inject-above (do not remove this comment)
] ]
} }

View File

@@ -0,0 +1,73 @@
use loco_rs::schema::*;
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
create_table(
manager,
"query_events",
&[
("id", ColType::PkAuto),
("event_type", ColType::String),
("query_text", ColType::Text),
("normalized_query", ColType::Text),
("request_path", ColType::StringNull),
("referrer", ColType::StringNull),
("user_agent", ColType::TextNull),
("result_count", ColType::IntegerNull),
("success", ColType::BooleanNull),
("response_mode", ColType::StringNull),
("provider", ColType::StringNull),
("chat_model", ColType::StringNull),
("latency_ms", ColType::IntegerNull),
],
&[],
)
.await?;
manager
.create_index(
Index::create()
.name("idx_query_events_event_type_created_at")
.table(Alias::new("query_events"))
.col(Alias::new("event_type"))
.col(Alias::new("created_at"))
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx_query_events_normalized_query")
.table(Alias::new("query_events"))
.col(Alias::new("normalized_query"))
.to_owned(),
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
for index_name in [
"idx_query_events_normalized_query",
"idx_query_events_event_type_created_at",
] {
manager
.drop_index(
Index::drop()
.name(index_name)
.table(Alias::new("query_events"))
.to_owned(),
)
.await?;
}
drop_table(manager, "query_events").await
}
}

View File

@@ -0,0 +1,101 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let table = Alias::new("site_settings");
if !manager
.has_column("site_settings", "ai_image_provider")
.await?
{
manager
.alter_table(
Table::alter()
.table(table.clone())
.add_column(
ColumnDef::new(Alias::new("ai_image_provider"))
.string()
.null(),
)
.to_owned(),
)
.await?;
}
if !manager
.has_column("site_settings", "ai_image_api_base")
.await?
{
manager
.alter_table(
Table::alter()
.table(table.clone())
.add_column(
ColumnDef::new(Alias::new("ai_image_api_base"))
.string()
.null(),
)
.to_owned(),
)
.await?;
}
if !manager
.has_column("site_settings", "ai_image_api_key")
.await?
{
manager
.alter_table(
Table::alter()
.table(table.clone())
.add_column(ColumnDef::new(Alias::new("ai_image_api_key")).text().null())
.to_owned(),
)
.await?;
}
if !manager
.has_column("site_settings", "ai_image_model")
.await?
{
manager
.alter_table(
Table::alter()
.table(table)
.add_column(ColumnDef::new(Alias::new("ai_image_model")).string().null())
.to_owned(),
)
.await?;
}
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let table = Alias::new("site_settings");
for column in [
"ai_image_model",
"ai_image_api_key",
"ai_image_api_base",
"ai_image_provider",
] {
if manager.has_column("site_settings", column).await? {
manager
.alter_table(
Table::alter()
.table(table.clone())
.drop_column(Alias::new(column))
.to_owned(),
)
.await?;
}
}
Ok(())
}
}

View File

@@ -0,0 +1,128 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let table = Alias::new("site_settings");
if !manager
.has_column("site_settings", "media_r2_account_id")
.await?
{
manager
.alter_table(
Table::alter()
.table(table.clone())
.add_column(
ColumnDef::new(Alias::new("media_r2_account_id"))
.string()
.null(),
)
.to_owned(),
)
.await?;
}
if !manager
.has_column("site_settings", "media_r2_bucket")
.await?
{
manager
.alter_table(
Table::alter()
.table(table.clone())
.add_column(
ColumnDef::new(Alias::new("media_r2_bucket"))
.string()
.null(),
)
.to_owned(),
)
.await?;
}
if !manager
.has_column("site_settings", "media_r2_public_base_url")
.await?
{
manager
.alter_table(
Table::alter()
.table(table.clone())
.add_column(
ColumnDef::new(Alias::new("media_r2_public_base_url"))
.string()
.null(),
)
.to_owned(),
)
.await?;
}
if !manager
.has_column("site_settings", "media_r2_access_key_id")
.await?
{
manager
.alter_table(
Table::alter()
.table(table.clone())
.add_column(
ColumnDef::new(Alias::new("media_r2_access_key_id"))
.string()
.null(),
)
.to_owned(),
)
.await?;
}
if !manager
.has_column("site_settings", "media_r2_secret_access_key")
.await?
{
manager
.alter_table(
Table::alter()
.table(table)
.add_column(
ColumnDef::new(Alias::new("media_r2_secret_access_key"))
.text()
.null(),
)
.to_owned(),
)
.await?;
}
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let table = Alias::new("site_settings");
for column in [
"media_r2_secret_access_key",
"media_r2_access_key_id",
"media_r2_public_base_url",
"media_r2_bucket",
"media_r2_account_id",
] {
if manager.has_column("site_settings", column).await? {
manager
.alter_table(
Table::alter()
.table(table.clone())
.drop_column(Alias::new(column))
.to_owned(),
)
.await?;
}
}
Ok(())
}
}

View File

@@ -0,0 +1,53 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
if manager
.has_column("site_settings", "media_storage_provider")
.await?
{
return Ok(());
}
manager
.alter_table(
Table::alter()
.table(SiteSettings::Table)
.add_column(
ColumnDef::new(SiteSettings::MediaStorageProvider)
.string()
.null(),
)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
if !manager
.has_column("site_settings", "media_storage_provider")
.await?
{
return Ok(());
}
manager
.alter_table(
Table::alter()
.table(SiteSettings::Table)
.drop_column(SiteSettings::MediaStorageProvider)
.to_owned(),
)
.await
}
}
#[derive(DeriveIden)]
enum SiteSettings {
Table,
MediaStorageProvider,
}

View File

@@ -1,3 +1,4 @@
use axum::extract::{Multipart, Query};
use loco_rs::prelude::*; use loco_rs::prelude::*;
use sea_orm::{ use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, PaginatorTrait, QueryFilter, ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, PaginatorTrait, QueryFilter,
@@ -14,7 +15,7 @@ use crate::{
site_settings::{self, SiteSettingsPayload}, site_settings::{self, SiteSettingsPayload},
}, },
models::_entities::{ai_chunks, comments, friend_links, posts, reviews}, models::_entities::{ai_chunks, comments, friend_links, posts, reviews},
services::{ai, content}, services::{ai, analytics, content, storage},
}; };
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize)]
@@ -130,6 +131,10 @@ pub struct AdminSiteSettingsResponse {
pub ai_api_base: Option<String>, pub ai_api_base: Option<String>,
pub ai_api_key: Option<String>, pub ai_api_key: Option<String>,
pub ai_chat_model: Option<String>, pub ai_chat_model: Option<String>,
pub ai_image_provider: Option<String>,
pub ai_image_api_base: Option<String>,
pub ai_image_api_key: Option<String>,
pub ai_image_model: Option<String>,
pub ai_providers: Vec<site_settings::AiProviderConfig>, pub ai_providers: Vec<site_settings::AiProviderConfig>,
pub ai_active_provider_id: Option<String>, pub ai_active_provider_id: Option<String>,
pub ai_embedding_model: Option<String>, pub ai_embedding_model: Option<String>,
@@ -139,6 +144,12 @@ pub struct AdminSiteSettingsResponse {
pub ai_last_indexed_at: Option<String>, pub ai_last_indexed_at: Option<String>,
pub ai_chunks_count: u64, pub ai_chunks_count: u64,
pub ai_local_embedding: String, pub ai_local_embedding: String,
pub media_storage_provider: Option<String>,
pub media_r2_account_id: Option<String>,
pub media_r2_bucket: Option<String>,
pub media_r2_public_base_url: Option<String>,
pub media_r2_access_key_id: Option<String>,
pub media_r2_secret_access_key: Option<String>,
} }
#[derive(Clone, Debug, Serialize)] #[derive(Clone, Debug, Serialize)]
@@ -160,6 +171,67 @@ pub struct AdminAiProviderTestResponse {
pub reply_preview: String, pub reply_preview: String,
} }
#[derive(Clone, Debug, Deserialize)]
pub struct AdminAiImageProviderTestRequest {
pub provider: String,
pub api_base: String,
pub api_key: String,
pub image_model: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct AdminAiImageProviderTestResponse {
pub provider: String,
pub endpoint: String,
pub image_model: String,
pub result_preview: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct AdminImageUploadResponse {
pub url: String,
pub key: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct AdminR2ConnectivityResponse {
pub bucket: String,
pub public_base_url: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct AdminMediaObjectResponse {
pub key: String,
pub url: String,
pub size_bytes: i64,
pub last_modified: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct AdminMediaListResponse {
pub provider: String,
pub bucket: String,
pub public_base_url: String,
pub items: Vec<AdminMediaObjectResponse>,
}
#[derive(Clone, Debug, Serialize)]
pub struct AdminMediaDeleteResponse {
pub deleted: bool,
pub key: String,
}
#[derive(Clone, Debug, Deserialize)]
pub struct AdminMediaListQuery {
pub prefix: Option<String>,
pub limit: Option<i32>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct AdminMediaDeleteQuery {
pub key: String,
}
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize)]
pub struct AdminPostMetadataRequest { pub struct AdminPostMetadataRequest {
pub markdown: String, pub markdown: String,
@@ -170,6 +242,30 @@ pub struct AdminPostPolishRequest {
pub markdown: String, pub markdown: String,
} }
#[derive(Clone, Debug, Deserialize)]
pub struct AdminReviewPolishRequest {
pub title: String,
pub review_type: String,
pub rating: i32,
pub review_date: Option<String>,
pub status: String,
#[serde(default)]
pub tags: Vec<String>,
pub description: String,
}
#[derive(Clone, Debug, Deserialize)]
pub struct AdminPostCoverImageRequest {
pub title: String,
pub description: Option<String>,
pub category: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
pub post_type: String,
pub slug: Option<String>,
pub markdown: String,
}
fn format_timestamp( fn format_timestamp(
value: Option<sea_orm::prelude::DateTimeWithTimeZone>, value: Option<sea_orm::prelude::DateTimeWithTimeZone>,
pattern: &str, pattern: &str,
@@ -242,6 +338,10 @@ fn build_settings_response(
ai_api_base: item.ai_api_base, ai_api_base: item.ai_api_base,
ai_api_key: item.ai_api_key, ai_api_key: item.ai_api_key,
ai_chat_model: item.ai_chat_model, ai_chat_model: item.ai_chat_model,
ai_image_provider: item.ai_image_provider,
ai_image_api_base: item.ai_image_api_base,
ai_image_api_key: item.ai_image_api_key,
ai_image_model: item.ai_image_model,
ai_providers, ai_providers,
ai_active_provider_id, ai_active_provider_id,
ai_embedding_model: item.ai_embedding_model, ai_embedding_model: item.ai_embedding_model,
@@ -251,6 +351,12 @@ fn build_settings_response(
ai_last_indexed_at: format_timestamp(item.ai_last_indexed_at, "%Y-%m-%d %H:%M:%S UTC"), ai_last_indexed_at: format_timestamp(item.ai_last_indexed_at, "%Y-%m-%d %H:%M:%S UTC"),
ai_chunks_count, ai_chunks_count,
ai_local_embedding: ai::local_embedding_label().to_string(), ai_local_embedding: ai::local_embedding_label().to_string(),
media_storage_provider: item.media_storage_provider,
media_r2_account_id: item.media_r2_account_id,
media_r2_bucket: item.media_r2_bucket,
media_r2_public_base_url: item.media_r2_public_base_url,
media_r2_access_key_id: item.media_r2_access_key_id,
media_r2_secret_access_key: item.media_r2_secret_access_key,
} }
} }
@@ -410,6 +516,12 @@ pub async fn dashboard(State(ctx): State<AppContext>) -> Result<Response> {
}) })
} }
#[debug_handler]
pub async fn analytics_overview(State(ctx): State<AppContext>) -> Result<Response> {
check_auth()?;
format::json(analytics::build_admin_analytics(&ctx).await?)
}
#[debug_handler] #[debug_handler]
pub async fn get_site_settings(State(ctx): State<AppContext>) -> Result<Response> { pub async fn get_site_settings(State(ctx): State<AppContext>) -> Result<Response> {
check_auth()?; check_auth()?;
@@ -428,7 +540,7 @@ pub async fn update_site_settings(
let current = site_settings::load_current(&ctx).await?; let current = site_settings::load_current(&ctx).await?;
let mut item = current; let mut item = current;
params.apply(&mut item); params.apply(&mut item);
let item = item.into_active_model(); let item = item.into_active_model().reset_all();
let updated = item.update(&ctx.db).await?; let updated = item.update(&ctx.db).await?;
let ai_chunks_count = ai_chunks::Entity::find().count(&ctx.db).await?; let ai_chunks_count = ai_chunks::Entity::find().count(&ctx.db).await?;
@@ -469,6 +581,88 @@ pub async fn test_ai_provider(Json(payload): Json<AdminAiProviderTestRequest>) -
}) })
} }
#[debug_handler]
pub async fn test_ai_image_provider(
Json(payload): Json<AdminAiImageProviderTestRequest>,
) -> Result<Response> {
check_auth()?;
let result = ai::test_image_provider_connectivity(
&payload.provider,
&payload.api_base,
&payload.api_key,
&payload.image_model,
)
.await?;
format::json(AdminAiImageProviderTestResponse {
provider: result.provider,
endpoint: result.endpoint,
image_model: result.image_model,
result_preview: result.result_preview,
})
}
#[debug_handler]
pub async fn test_r2_storage(State(ctx): State<AppContext>) -> Result<Response> {
check_auth()?;
let settings = storage::require_r2_settings(&ctx).await?;
let bucket = storage::test_r2_connectivity(&ctx).await?;
format::json(AdminR2ConnectivityResponse {
bucket,
public_base_url: settings.public_base_url,
})
}
#[debug_handler]
pub async fn list_media_objects(
State(ctx): State<AppContext>,
Query(query): Query<AdminMediaListQuery>,
) -> Result<Response> {
check_auth()?;
let settings = storage::require_r2_settings(&ctx).await?;
let items = storage::list_objects(&ctx, query.prefix.as_deref(), query.limit.unwrap_or(200))
.await?
.into_iter()
.map(|item| AdminMediaObjectResponse {
key: item.key,
url: item.url,
size_bytes: item.size_bytes,
last_modified: item.last_modified,
})
.collect::<Vec<_>>();
format::json(AdminMediaListResponse {
provider: settings.provider_name,
bucket: settings.bucket,
public_base_url: settings.public_base_url,
items,
})
}
#[debug_handler]
pub async fn delete_media_object(
State(ctx): State<AppContext>,
Query(query): Query<AdminMediaDeleteQuery>,
) -> Result<Response> {
check_auth()?;
let key = query.key.trim();
if key.is_empty() {
return Err(Error::BadRequest("缺少对象 key".to_string()));
}
storage::delete_object(&ctx, key).await?;
format::json(AdminMediaDeleteResponse {
deleted: true,
key: key.to_string(),
})
}
#[debug_handler] #[debug_handler]
pub async fn generate_post_metadata( pub async fn generate_post_metadata(
State(ctx): State<AppContext>, State(ctx): State<AppContext>,
@@ -487,6 +681,127 @@ pub async fn polish_post_markdown(
format::json(ai::polish_post_markdown(&ctx, &payload.markdown).await?) format::json(ai::polish_post_markdown(&ctx, &payload.markdown).await?)
} }
#[debug_handler]
pub async fn polish_review_description(
State(ctx): State<AppContext>,
Json(payload): Json<AdminReviewPolishRequest>,
) -> Result<Response> {
check_auth()?;
format::json(
ai::polish_review_description(
&ctx,
&payload.title,
&payload.review_type,
payload.rating,
payload.review_date.as_deref(),
&payload.status,
&payload.tags,
&payload.description,
)
.await?,
)
}
#[debug_handler]
pub async fn generate_post_cover_image(
State(ctx): State<AppContext>,
Json(payload): Json<AdminPostCoverImageRequest>,
) -> Result<Response> {
check_auth()?;
format::json(
ai::generate_post_cover_image(
&ctx,
&payload.title,
payload.description.as_deref(),
payload.category.as_deref(),
&payload.tags,
&payload.post_type,
payload.slug.as_deref(),
&payload.markdown,
)
.await?,
)
}
fn review_cover_extension(
file_name: Option<&str>,
content_type: Option<&str>,
) -> Option<&'static str> {
let from_file_name = file_name
.and_then(|name| name.rsplit('.').next())
.map(|ext| ext.trim().to_ascii_lowercase());
match from_file_name.as_deref() {
Some("png") => return Some("png"),
Some("jpg") | Some("jpeg") => return Some("jpg"),
Some("webp") => return Some("webp"),
Some("gif") => return Some("gif"),
Some("avif") => return Some("avif"),
Some("svg") => return Some("svg"),
_ => {}
}
match content_type
.unwrap_or_default()
.trim()
.to_ascii_lowercase()
.as_str()
{
"image/png" => Some("png"),
"image/jpeg" => Some("jpg"),
"image/webp" => Some("webp"),
"image/gif" => Some("gif"),
"image/avif" => Some("avif"),
"image/svg+xml" => Some("svg"),
_ => None,
}
}
#[debug_handler]
pub async fn upload_review_cover_image(
State(ctx): State<AppContext>,
mut multipart: Multipart,
) -> Result<Response> {
check_auth()?;
let field = multipart
.next_field()
.await
.map_err(|error| Error::BadRequest(error.to_string()))?
.ok_or_else(|| Error::BadRequest("请先选择图片文件".to_string()))?;
let file_name = field.file_name().map(ToString::to_string);
let content_type = field.content_type().map(ToString::to_string);
let extension = review_cover_extension(file_name.as_deref(), content_type.as_deref())
.ok_or_else(|| Error::BadRequest("仅支持常见图片格式上传".to_string()))?;
let bytes = field
.bytes()
.await
.map_err(|error| Error::BadRequest(error.to_string()))?;
if bytes.is_empty() {
return Err(Error::BadRequest("上传的图片内容为空".to_string()));
}
let key = crate::services::storage::build_object_key(
"review-covers",
file_name.as_deref().unwrap_or("review-cover"),
extension,
);
let stored = crate::services::storage::upload_bytes_to_r2(
&ctx,
&key,
bytes.to_vec(),
content_type.as_deref(),
Some("public, max-age=31536000, immutable"),
)
.await?;
format::json(AdminImageUploadResponse {
url: stored.url,
key: stored.key,
})
}
pub fn routes() -> Routes { pub fn routes() -> Routes {
Routes::new() Routes::new()
.prefix("/api/admin") .prefix("/api/admin")
@@ -494,11 +809,21 @@ pub fn routes() -> Routes {
.add("/session", delete(session_logout)) .add("/session", delete(session_logout))
.add("/session/login", post(session_login)) .add("/session/login", post(session_login))
.add("/dashboard", get(dashboard)) .add("/dashboard", get(dashboard))
.add("/analytics", get(analytics_overview))
.add("/site-settings", get(get_site_settings)) .add("/site-settings", get(get_site_settings))
.add("/site-settings", patch(update_site_settings)) .add("/site-settings", patch(update_site_settings))
.add("/site-settings", put(update_site_settings)) .add("/site-settings", put(update_site_settings))
.add("/ai/reindex", post(reindex_ai)) .add("/ai/reindex", post(reindex_ai))
.add("/ai/test-provider", post(test_ai_provider)) .add("/ai/test-provider", post(test_ai_provider))
.add("/ai/test-image-provider", post(test_ai_image_provider))
.add("/storage/r2/test", post(test_r2_storage))
.add(
"/storage/media",
get(list_media_objects).delete(delete_media_object),
)
.add("/ai/post-metadata", post(generate_post_metadata)) .add("/ai/post-metadata", post(generate_post_metadata))
.add("/ai/polish-post", post(polish_post_markdown)) .add("/ai/polish-post", post(polish_post_markdown))
.add("/ai/polish-review", post(polish_review_description))
.add("/ai/post-cover", post(generate_post_cover_image))
.add("/storage/review-cover", post(upload_review_cover_image))
} }

View File

@@ -5,15 +5,19 @@ use axum::{
body::{Body, Bytes}, body::{Body, Bytes},
http::{ http::{
header::{CACHE_CONTROL, CONNECTION, CONTENT_TYPE}, header::{CACHE_CONTROL, CONNECTION, CONTENT_TYPE},
HeaderValue, HeaderMap, HeaderValue,
}, },
}; };
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use loco_rs::prelude::*; use loco_rs::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use std::time::Instant;
use crate::{controllers::admin::check_auth, services::ai}; use crate::{
controllers::{admin::check_auth, site_settings},
services::{ai, analytics},
};
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize)]
pub struct AskPayload { pub struct AskPayload {
@@ -55,6 +59,30 @@ fn format_timestamp(value: Option<DateTime<Utc>>) -> Option<String> {
value.map(|item| item.to_rfc3339()) value.map(|item| item.to_rfc3339())
} }
fn trim_to_option(value: Option<String>) -> Option<String> {
value.and_then(|item| {
let trimmed = item.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
})
}
async fn current_provider_metadata(ctx: &AppContext) -> (Option<String>, Option<String>) {
match site_settings::load_current(ctx).await {
Ok(settings) => (
trim_to_option(settings.ai_provider),
trim_to_option(settings.ai_chat_model),
),
Err(error) => {
tracing::warn!("failed to load ai provider metadata for analytics: {error}");
(None, None)
}
}
}
fn sse_bytes<T: Serialize>(event: &str, payload: &T) -> Bytes { fn sse_bytes<T: Serialize>(event: &str, payload: &T) -> Bytes {
let data = serde_json::to_string(payload) let data = serde_json::to_string(payload)
.unwrap_or_else(|_| "{\"message\":\"failed to serialize SSE payload\"}".to_string()); .unwrap_or_else(|_| "{\"message\":\"failed to serialize SSE payload\"}".to_string());
@@ -178,24 +206,66 @@ fn build_ask_response(prepared: &ai::PreparedAiAnswer, answer: String) -> AskRes
#[debug_handler] #[debug_handler]
pub async fn ask( pub async fn ask(
State(ctx): State<AppContext>, State(ctx): State<AppContext>,
headers: HeaderMap,
Json(payload): Json<AskPayload>, Json(payload): Json<AskPayload>,
) -> Result<Response> { ) -> Result<Response> {
let result = ai::answer_question(&ctx, &payload.question).await?; let started_at = Instant::now();
format::json(AskResponse { let question = payload.question.trim().to_string();
question: payload.question.trim().to_string(), let (provider, chat_model) = current_provider_metadata(&ctx).await;
answer: result.answer,
sources: result.sources, match ai::answer_question(&ctx, &payload.question).await {
indexed_chunks: result.indexed_chunks, Ok(result) => {
last_indexed_at: format_timestamp(result.last_indexed_at), analytics::record_ai_question_event(
}) &ctx,
&question,
&headers,
true,
"sync",
provider,
chat_model,
Some(result.sources.len()),
started_at.elapsed().as_millis() as i64,
)
.await;
format::json(AskResponse {
question,
answer: result.answer,
sources: result.sources,
indexed_chunks: result.indexed_chunks,
last_indexed_at: format_timestamp(result.last_indexed_at),
})
}
Err(error) => {
analytics::record_ai_question_event(
&ctx,
&question,
&headers,
false,
"sync",
provider,
chat_model,
None,
started_at.elapsed().as_millis() as i64,
)
.await;
Err(error)
}
}
} }
#[debug_handler] #[debug_handler]
pub async fn ask_stream( pub async fn ask_stream(
State(ctx): State<AppContext>, State(ctx): State<AppContext>,
headers: HeaderMap,
Json(payload): Json<AskPayload>, Json(payload): Json<AskPayload>,
) -> Result<Response> { ) -> Result<Response> {
let request_headers = headers.clone();
let question = payload.question.trim().to_string();
let (fallback_provider, fallback_chat_model) = current_provider_metadata(&ctx).await;
let stream = stream! { let stream = stream! {
let started_at = Instant::now();
yield Ok::<Bytes, std::io::Error>(sse_bytes("status", &StreamStatusEvent { yield Ok::<Bytes, std::io::Error>(sse_bytes("status", &StreamStatusEvent {
phase: "retrieving".to_string(), phase: "retrieving".to_string(),
message: "正在检索知识库上下文...".to_string(), message: "正在检索知识库上下文...".to_string(),
@@ -204,6 +274,18 @@ pub async fn ask_stream(
let prepared = match ai::prepare_answer(&ctx, &payload.question).await { let prepared = match ai::prepare_answer(&ctx, &payload.question).await {
Ok(prepared) => prepared, Ok(prepared) => prepared,
Err(error) => { Err(error) => {
analytics::record_ai_question_event(
&ctx,
&question,
&request_headers,
false,
"stream",
fallback_provider.clone(),
fallback_chat_model.clone(),
None,
started_at.elapsed().as_millis() as i64,
)
.await;
yield Ok(sse_bytes("error", &StreamErrorEvent { yield Ok(sse_bytes("error", &StreamErrorEvent {
message: error.to_string(), message: error.to_string(),
})); }));
@@ -212,6 +294,16 @@ pub async fn ask_stream(
}; };
let mut accumulated_answer = String::new(); let mut accumulated_answer = String::new();
let active_provider = prepared
.provider_request
.as_ref()
.map(|request| request.provider.clone())
.or_else(|| fallback_provider.clone());
let active_chat_model = prepared
.provider_request
.as_ref()
.map(|request| request.chat_model.clone())
.or_else(|| fallback_chat_model.clone());
if let Some(answer) = prepared.immediate_answer.as_deref() { if let Some(answer) = prepared.immediate_answer.as_deref() {
yield Ok(sse_bytes("status", &StreamStatusEvent { yield Ok(sse_bytes("status", &StreamStatusEvent {
@@ -241,6 +333,18 @@ pub async fn ask_stream(
let mut response = match response { let mut response = match response {
Ok(response) => response, Ok(response) => response,
Err(error) => { Err(error) => {
analytics::record_ai_question_event(
&ctx,
&question,
&request_headers,
false,
"stream",
active_provider.clone(),
active_chat_model.clone(),
Some(prepared.sources.len()),
started_at.elapsed().as_millis() as i64,
)
.await;
yield Ok(sse_bytes("error", &StreamErrorEvent { yield Ok(sse_bytes("error", &StreamErrorEvent {
message: format!("AI request failed: {error}"), message: format!("AI request failed: {error}"),
})); }));
@@ -251,6 +355,18 @@ pub async fn ask_stream(
if !response.status().is_success() { if !response.status().is_success() {
let status = response.status(); let status = response.status();
let body = response.text().await.unwrap_or_default(); let body = response.text().await.unwrap_or_default();
analytics::record_ai_question_event(
&ctx,
&question,
&request_headers,
false,
"stream",
active_provider.clone(),
active_chat_model.clone(),
Some(prepared.sources.len()),
started_at.elapsed().as_millis() as i64,
)
.await;
yield Ok(sse_bytes("error", &StreamErrorEvent { yield Ok(sse_bytes("error", &StreamErrorEvent {
message: format!("AI provider returned {status}: {body}"), message: format!("AI provider returned {status}: {body}"),
})); }));
@@ -265,6 +381,18 @@ pub async fn ask_stream(
let Some(chunk) = (match next_chunk { let Some(chunk) = (match next_chunk {
Ok(chunk) => chunk, Ok(chunk) => chunk,
Err(error) => { Err(error) => {
analytics::record_ai_question_event(
&ctx,
&question,
&request_headers,
false,
"stream",
active_provider.clone(),
active_chat_model.clone(),
Some(prepared.sources.len()),
started_at.elapsed().as_millis() as i64,
)
.await;
yield Ok(sse_bytes("error", &StreamErrorEvent { yield Ok(sse_bytes("error", &StreamErrorEvent {
message: format!("AI stream read failed: {error}"), message: format!("AI stream read failed: {error}"),
})); }));
@@ -323,6 +451,18 @@ pub async fn ask_stream(
} }
if accumulated_answer.is_empty() { if accumulated_answer.is_empty() {
analytics::record_ai_question_event(
&ctx,
&question,
&request_headers,
false,
"stream",
active_provider.clone(),
active_chat_model.clone(),
Some(prepared.sources.len()),
started_at.elapsed().as_millis() as i64,
)
.await;
yield Ok(sse_bytes("error", &StreamErrorEvent { yield Ok(sse_bytes("error", &StreamErrorEvent {
message: "AI chat response did not contain readable content".to_string(), message: "AI chat response did not contain readable content".to_string(),
})); }));
@@ -330,6 +470,19 @@ pub async fn ask_stream(
} }
} }
analytics::record_ai_question_event(
&ctx,
&question,
&request_headers,
true,
"stream",
active_provider,
active_chat_model,
Some(prepared.sources.len()),
started_at.elapsed().as_millis() as i64,
)
.await;
let final_payload = build_ask_response(&prepared, accumulated_answer); let final_payload = build_ask_response(&prepared, accumulated_answer);
yield Ok(sse_bytes("complete", &final_payload)); yield Ok(sse_bytes("complete", &final_payload));
}; };

View File

@@ -3,7 +3,10 @@ use loco_rs::prelude::*;
use sea_orm::{EntityTrait, QueryOrder, Set}; use sea_orm::{EntityTrait, QueryOrder, Set};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::models::_entities::reviews::{self, Entity as ReviewEntity}; use crate::{
models::_entities::reviews::{self, Entity as ReviewEntity},
services::storage,
};
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
pub struct CreateReviewRequest { pub struct CreateReviewRequest {
@@ -83,9 +86,11 @@ pub async fn update(
) -> Result<impl IntoResponse> { ) -> Result<impl IntoResponse> {
let review = ReviewEntity::find_by_id(id).one(&ctx.db).await?; let review = ReviewEntity::find_by_id(id).one(&ctx.db).await?;
let Some(mut review) = review.map(|r| r.into_active_model()) else { let Some(existing_review) = review else {
return Err(Error::NotFound); return Err(Error::NotFound);
}; };
let old_cover = existing_review.cover.clone();
let mut review = existing_review.into_active_model();
if let Some(title) = req.title { if let Some(title) = req.title {
review.title = Set(Some(title)); review.title = Set(Some(title));
@@ -108,7 +113,9 @@ pub async fn update(
if let Some(tags) = req.tags { if let Some(tags) = req.tags {
review.tags = Set(Some(serde_json::to_string(&tags).unwrap_or_default())); review.tags = Set(Some(serde_json::to_string(&tags).unwrap_or_default()));
} }
let mut next_cover = old_cover.clone();
if let Some(cover) = req.cover { if let Some(cover) = req.cover {
next_cover = Some(cover.clone());
review.cover = Set(Some(cover)); review.cover = Set(Some(cover));
} }
if let Some(link_url) = req.link_url { if let Some(link_url) = req.link_url {
@@ -117,6 +124,14 @@ pub async fn update(
} }
let review = review.update(&ctx.db).await?; let review = review.update(&ctx.db).await?;
if let Some(old_cover) = old_cover
.filter(|old| Some(old.clone()) != next_cover)
.filter(|old| !old.trim().is_empty())
{
if let Err(error) = storage::delete_managed_url(&ctx, &old_cover).await {
tracing::warn!("failed to cleanup replaced review cover: {error}");
}
}
format::json(review) format::json(review)
} }
@@ -128,7 +143,13 @@ pub async fn remove(
match review { match review {
Some(r) => { Some(r) => {
let cover = r.cover.clone();
r.delete(&ctx.db).await?; r.delete(&ctx.db).await?;
if let Some(cover) = cover.filter(|value| !value.trim().is_empty()) {
if let Err(error) = storage::delete_managed_url(&ctx, &cover).await {
tracing::warn!("failed to cleanup deleted review cover: {error}");
}
}
format::empty() format::empty()
} }
None => Err(Error::NotFound), None => Err(Error::NotFound),

View File

@@ -1,15 +1,46 @@
use axum::http::HeaderMap;
use loco_rs::prelude::*; use loco_rs::prelude::*;
use sea_orm::{ConnectionTrait, DatabaseBackend, DbBackend, FromQueryResult, Statement}; use sea_orm::{ConnectionTrait, DatabaseBackend, DbBackend, FromQueryResult, Statement};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Deserializer, Serialize};
use serde_json::Value; use serde_json::Value;
use std::time::Instant;
use crate::models::_entities::posts; use crate::models::_entities::posts;
use crate::services::content; use crate::services::{analytics, content};
fn deserialize_boolish_option<'de, D>(
deserializer: D,
) -> std::result::Result<Option<bool>, D::Error>
where
D: Deserializer<'de>,
{
let raw = Option::<String>::deserialize(deserializer)?;
raw.map(|value| match value.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Ok(true),
"0" | "false" | "no" | "off" => Ok(false),
other => Err(serde::de::Error::custom(format!(
"invalid boolean value `{other}`"
))),
})
.transpose()
}
fn is_preview_search(query: &SearchQuery, headers: &HeaderMap) -> bool {
query.preview.unwrap_or(false)
|| headers
.get("x-termi-search-mode")
.and_then(|value| value.to_str().ok())
.map(|value| value.eq_ignore_ascii_case("preview"))
.unwrap_or(false)
}
#[derive(Clone, Debug, Default, Deserialize)] #[derive(Clone, Debug, Default, Deserialize)]
pub struct SearchQuery { pub struct SearchQuery {
pub q: Option<String>, pub q: Option<String>,
pub limit: Option<u64>, pub limit: Option<u64>,
#[serde(default, deserialize_with = "deserialize_boolish_option")]
pub preview: Option<bool>,
} }
#[derive(Clone, Debug, Serialize, FromQueryResult)] #[derive(Clone, Debug, Serialize, FromQueryResult)]
@@ -157,7 +188,10 @@ async fn fallback_search(ctx: &AppContext, q: &str, limit: u64) -> Result<Vec<Se
pub async fn search( pub async fn search(
Query(query): Query<SearchQuery>, Query(query): Query<SearchQuery>,
State(ctx): State<AppContext>, State(ctx): State<AppContext>,
headers: HeaderMap,
) -> Result<Response> { ) -> Result<Response> {
let started_at = Instant::now();
let preview_search = is_preview_search(&query, &headers);
content::sync_markdown_posts(&ctx).await?; content::sync_markdown_posts(&ctx).await?;
let q = query.q.unwrap_or_default().trim().to_string(); let q = query.q.unwrap_or_default().trim().to_string();
@@ -186,6 +220,17 @@ pub async fn search(
fallback_search(&ctx, &q, limit).await? fallback_search(&ctx, &q, limit).await?
}; };
if !preview_search {
analytics::record_search_event(
&ctx,
&q,
results.len(),
&headers,
started_at.elapsed().as_millis() as i64,
)
.await;
}
format::json(results) format::json(results)
} }

View File

@@ -44,6 +44,8 @@ pub struct AiProviderConfig {
pub api_key: Option<String>, pub api_key: Option<String>,
#[serde(default, alias = "chatModel")] #[serde(default, alias = "chatModel")]
pub chat_model: Option<String>, pub chat_model: Option<String>,
#[serde(default, alias = "imageModel")]
pub image_model: Option<String>,
} }
#[derive(Clone, Debug, Default, Deserialize, Serialize)] #[derive(Clone, Debug, Default, Deserialize, Serialize)]
@@ -94,6 +96,14 @@ pub struct SiteSettingsPayload {
pub ai_api_key: Option<String>, pub ai_api_key: Option<String>,
#[serde(default, alias = "aiChatModel")] #[serde(default, alias = "aiChatModel")]
pub ai_chat_model: Option<String>, pub ai_chat_model: Option<String>,
#[serde(default, alias = "aiImageProvider")]
pub ai_image_provider: Option<String>,
#[serde(default, alias = "aiImageApiBase")]
pub ai_image_api_base: Option<String>,
#[serde(default, alias = "aiImageApiKey")]
pub ai_image_api_key: Option<String>,
#[serde(default, alias = "aiImageModel")]
pub ai_image_model: Option<String>,
#[serde(default, alias = "aiProviders")] #[serde(default, alias = "aiProviders")]
pub ai_providers: Option<Vec<AiProviderConfig>>, pub ai_providers: Option<Vec<AiProviderConfig>>,
#[serde(default, alias = "aiActiveProviderId")] #[serde(default, alias = "aiActiveProviderId")]
@@ -106,6 +116,18 @@ pub struct SiteSettingsPayload {
pub ai_top_k: Option<i32>, pub ai_top_k: Option<i32>,
#[serde(default, alias = "aiChunkSize")] #[serde(default, alias = "aiChunkSize")]
pub ai_chunk_size: Option<i32>, pub ai_chunk_size: Option<i32>,
#[serde(default, alias = "mediaR2AccountId")]
pub media_r2_account_id: Option<String>,
#[serde(default, alias = "mediaStorageProvider")]
pub media_storage_provider: Option<String>,
#[serde(default, alias = "mediaR2Bucket")]
pub media_r2_bucket: Option<String>,
#[serde(default, alias = "mediaR2PublicBaseUrl")]
pub media_r2_public_base_url: Option<String>,
#[serde(default, alias = "mediaR2AccessKeyId")]
pub media_r2_access_key_id: Option<String>,
#[serde(default, alias = "mediaR2SecretAccessKey")]
pub media_r2_secret_access_key: Option<String>,
} }
#[derive(Clone, Debug, Serialize)] #[derive(Clone, Debug, Serialize)]
@@ -152,13 +174,16 @@ fn create_ai_provider_id() -> String {
} }
fn default_ai_provider_config() -> AiProviderConfig { fn default_ai_provider_config() -> AiProviderConfig {
let provider = ai::provider_name(None);
AiProviderConfig { AiProviderConfig {
id: "default".to_string(), id: "default".to_string(),
name: "默认提供商".to_string(), name: "默认提供商".to_string(),
provider: ai::provider_name(None), provider: provider.clone(),
api_base: Some(ai::default_api_base().to_string()), api_base: Some(ai::default_api_base().to_string()),
api_key: Some(ai::default_api_key().to_string()), api_key: Some(ai::default_api_key().to_string()),
chat_model: Some(ai::default_chat_model().to_string()), chat_model: Some(ai::default_chat_model().to_string()),
image_model: Some(ai::default_image_model_for_provider(&provider).to_string()),
} }
} }
@@ -174,11 +199,13 @@ fn normalize_ai_provider_configs(items: Vec<AiProviderConfig>) -> Vec<AiProvider
let api_base = normalize_optional_string(item.api_base); let api_base = normalize_optional_string(item.api_base);
let api_key = normalize_optional_string(item.api_key); let api_key = normalize_optional_string(item.api_key);
let chat_model = normalize_optional_string(item.chat_model); let chat_model = normalize_optional_string(item.chat_model);
let image_model = normalize_optional_string(item.image_model);
let has_content = !item.name.trim().is_empty() let has_content = !item.name.trim().is_empty()
|| !provider.trim().is_empty() || !provider.trim().is_empty()
|| api_base.is_some() || api_base.is_some()
|| api_key.is_some() || api_key.is_some()
|| chat_model.is_some(); || chat_model.is_some()
|| image_model.is_some();
if !has_content { if !has_content {
return None; return None;
@@ -201,6 +228,7 @@ fn normalize_ai_provider_configs(items: Vec<AiProviderConfig>) -> Vec<AiProvider
api_base, api_base,
api_key, api_key,
chat_model, chat_model,
image_model,
}) })
}) })
.collect() .collect()
@@ -216,13 +244,16 @@ fn legacy_ai_provider_config(model: &Model) -> Option<AiProviderConfig> {
return None; return None;
} }
let normalized_provider = provider.unwrap_or_else(|| ai::provider_name(None));
Some(AiProviderConfig { Some(AiProviderConfig {
id: "default".to_string(), id: "default".to_string(),
name: "当前提供商".to_string(), name: "当前提供商".to_string(),
provider: provider.unwrap_or_else(|| ai::provider_name(None)), provider: normalized_provider.clone(),
api_base, api_base,
api_key, api_key,
chat_model, chat_model,
image_model: Some(ai::default_image_model_for_provider(&normalized_provider).to_string()),
}) })
} }
@@ -301,6 +332,8 @@ fn update_active_provider_from_legacy_fields(model: &mut Model) {
config.api_base = api_base; config.api_base = api_base;
config.api_key = api_key; config.api_key = api_key;
config.chat_model = chat_model; config.chat_model = chat_model;
config.image_model =
Some(ai::default_image_model_for_provider(&config.provider).to_string());
write_ai_provider_state( write_ai_provider_state(
model, model,
vec![config], vec![config],
@@ -322,6 +355,10 @@ fn update_active_provider_from_legacy_fields(model: &mut Model) {
config.api_base = api_base.clone(); config.api_base = api_base.clone();
config.api_key = api_key.clone(); config.api_key = api_key.clone();
config.chat_model = chat_model.clone(); config.chat_model = chat_model.clone();
if config.image_model.is_none() {
config.image_model =
Some(ai::default_image_model_for_provider(&config.provider).to_string());
}
} }
} }
} }
@@ -425,6 +462,18 @@ impl SiteSettingsPayload {
if let Some(ai_chat_model) = self.ai_chat_model { if let Some(ai_chat_model) = self.ai_chat_model {
item.ai_chat_model = normalize_optional_string(Some(ai_chat_model)); item.ai_chat_model = normalize_optional_string(Some(ai_chat_model));
} }
if let Some(ai_image_provider) = self.ai_image_provider {
item.ai_image_provider = normalize_optional_string(Some(ai_image_provider));
}
if let Some(ai_image_api_base) = self.ai_image_api_base {
item.ai_image_api_base = normalize_optional_string(Some(ai_image_api_base));
}
if let Some(ai_image_api_key) = self.ai_image_api_key {
item.ai_image_api_key = normalize_optional_string(Some(ai_image_api_key));
}
if let Some(ai_image_model) = self.ai_image_model {
item.ai_image_model = normalize_optional_string(Some(ai_image_model));
}
if let Some(ai_embedding_model) = self.ai_embedding_model { if let Some(ai_embedding_model) = self.ai_embedding_model {
item.ai_embedding_model = normalize_optional_string(Some(ai_embedding_model)); item.ai_embedding_model = normalize_optional_string(Some(ai_embedding_model));
} }
@@ -437,6 +486,26 @@ impl SiteSettingsPayload {
if self.ai_chunk_size.is_some() { if self.ai_chunk_size.is_some() {
item.ai_chunk_size = normalize_optional_int(self.ai_chunk_size, 400, 4000); item.ai_chunk_size = normalize_optional_int(self.ai_chunk_size, 400, 4000);
} }
if let Some(media_r2_account_id) = self.media_r2_account_id {
item.media_r2_account_id = normalize_optional_string(Some(media_r2_account_id));
}
if let Some(media_storage_provider) = self.media_storage_provider {
item.media_storage_provider = normalize_optional_string(Some(media_storage_provider));
}
if let Some(media_r2_bucket) = self.media_r2_bucket {
item.media_r2_bucket = normalize_optional_string(Some(media_r2_bucket));
}
if let Some(media_r2_public_base_url) = self.media_r2_public_base_url {
item.media_r2_public_base_url =
normalize_optional_string(Some(media_r2_public_base_url));
}
if let Some(media_r2_access_key_id) = self.media_r2_access_key_id {
item.media_r2_access_key_id = normalize_optional_string(Some(media_r2_access_key_id));
}
if let Some(media_r2_secret_access_key) = self.media_r2_secret_access_key {
item.media_r2_secret_access_key =
normalize_optional_string(Some(media_r2_secret_access_key));
}
if provider_list_supplied { if provider_list_supplied {
write_ai_provider_state( write_ai_provider_state(
@@ -524,6 +593,10 @@ fn default_payload() -> SiteSettingsPayload {
ai_api_base: Some(ai::default_api_base().to_string()), ai_api_base: Some(ai::default_api_base().to_string()),
ai_api_key: Some(ai::default_api_key().to_string()), ai_api_key: Some(ai::default_api_key().to_string()),
ai_chat_model: Some(ai::default_chat_model().to_string()), ai_chat_model: Some(ai::default_chat_model().to_string()),
ai_image_provider: None,
ai_image_api_base: None,
ai_image_api_key: None,
ai_image_model: None,
ai_providers: Some(vec![default_ai_provider_config()]), ai_providers: Some(vec![default_ai_provider_config()]),
ai_active_provider_id: Some("default".to_string()), ai_active_provider_id: Some("default".to_string()),
ai_embedding_model: Some(ai::local_embedding_label().to_string()), ai_embedding_model: Some(ai::local_embedding_label().to_string()),
@@ -533,6 +606,12 @@ fn default_payload() -> SiteSettingsPayload {
), ),
ai_top_k: Some(4), ai_top_k: Some(4),
ai_chunk_size: Some(1200), ai_chunk_size: Some(1200),
media_storage_provider: None,
media_r2_account_id: None,
media_r2_bucket: None,
media_r2_public_base_url: None,
media_r2_access_key_id: None,
media_r2_secret_access_key: None,
} }
} }
@@ -553,7 +632,11 @@ pub(crate) async fn load_current(ctx: &AppContext) -> Result<Model> {
.await?; .await?;
let mut model = inserted; let mut model = inserted;
default_payload().apply(&mut model); default_payload().apply(&mut model);
Ok(model.into_active_model().update(&ctx.db).await?) Ok(model
.into_active_model()
.reset_all()
.update(&ctx.db)
.await?)
} }
fn public_response(model: Model) -> PublicSiteSettingsResponse { fn public_response(model: Model) -> PublicSiteSettingsResponse {
@@ -596,7 +679,7 @@ pub async fn update(
let current = load_current(&ctx).await?; let current = load_current(&ctx).await?;
let mut item = current; let mut item = current;
params.apply(&mut item); params.apply(&mut item);
let item = item.into_active_model(); let item = item.into_active_model().reset_all();
let updated = item.update(&ctx.db).await?; let updated = item.update(&ctx.db).await?;
format::json(public_response(updated)) format::json(public_response(updated))
} }

View File

@@ -7,6 +7,7 @@ pub mod categories;
pub mod comments; pub mod comments;
pub mod friend_links; pub mod friend_links;
pub mod posts; pub mod posts;
pub mod query_events;
pub mod reviews; pub mod reviews;
pub mod site_settings; pub mod site_settings;
pub mod tags; pub mod tags;

View File

@@ -5,6 +5,7 @@ pub use super::categories::Entity as Categories;
pub use super::comments::Entity as Comments; pub use super::comments::Entity as Comments;
pub use super::friend_links::Entity as FriendLinks; pub use super::friend_links::Entity as FriendLinks;
pub use super::posts::Entity as Posts; pub use super::posts::Entity as Posts;
pub use super::query_events::Entity as QueryEvents;
pub use super::reviews::Entity as Reviews; pub use super::reviews::Entity as Reviews;
pub use super::site_settings::Entity as SiteSettings; pub use super::site_settings::Entity as SiteSettings;
pub use super::tags::Entity as Tags; pub use super::tags::Entity as Tags;

View File

@@ -0,0 +1,33 @@
//! `SeaORM` Entity, manually maintained
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "query_events")]
pub struct Model {
pub created_at: DateTimeWithTimeZone,
pub updated_at: DateTimeWithTimeZone,
#[sea_orm(primary_key)]
pub id: i32,
pub event_type: String,
#[sea_orm(column_type = "Text")]
pub query_text: String,
#[sea_orm(column_type = "Text")]
pub normalized_query: String,
pub request_path: Option<String>,
pub referrer: Option<String>,
#[sea_orm(column_type = "Text", nullable)]
pub user_agent: Option<String>,
pub result_count: Option<i32>,
pub success: Option<bool>,
pub response_mode: Option<String>,
pub provider: Option<String>,
pub chat_model: Option<String>,
pub latency_ms: Option<i32>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -37,6 +37,11 @@ pub struct Model {
#[sea_orm(column_type = "Text", nullable)] #[sea_orm(column_type = "Text", nullable)]
pub ai_api_key: Option<String>, pub ai_api_key: Option<String>,
pub ai_chat_model: Option<String>, pub ai_chat_model: Option<String>,
pub ai_image_provider: Option<String>,
pub ai_image_api_base: Option<String>,
#[sea_orm(column_type = "Text", nullable)]
pub ai_image_api_key: Option<String>,
pub ai_image_model: Option<String>,
#[sea_orm(column_type = "JsonBinary", nullable)] #[sea_orm(column_type = "JsonBinary", nullable)]
pub ai_providers: Option<Json>, pub ai_providers: Option<Json>,
pub ai_active_provider_id: Option<String>, pub ai_active_provider_id: Option<String>,
@@ -46,6 +51,13 @@ pub struct Model {
pub ai_top_k: Option<i32>, pub ai_top_k: Option<i32>,
pub ai_chunk_size: Option<i32>, pub ai_chunk_size: Option<i32>,
pub ai_last_indexed_at: Option<DateTimeWithTimeZone>, pub ai_last_indexed_at: Option<DateTimeWithTimeZone>,
pub media_storage_provider: Option<String>,
pub media_r2_account_id: Option<String>,
pub media_r2_bucket: Option<String>,
pub media_r2_public_base_url: Option<String>,
pub media_r2_access_key_id: Option<String>,
#[sea_orm(column_type = "Text", nullable)]
pub media_r2_secret_access_key: Option<String>,
} }
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

View File

@@ -4,7 +4,7 @@ use fastembed::{
InitOptionsUserDefined, Pooling, TextEmbedding, TokenizerFiles, UserDefinedEmbeddingModel, InitOptionsUserDefined, Pooling, TextEmbedding, TokenizerFiles, UserDefinedEmbeddingModel,
}; };
use loco_rs::prelude::*; use loco_rs::prelude::*;
use reqwest::{Client, Url}; use reqwest::{header::CONTENT_TYPE, multipart, Client, Url};
use sea_orm::{ use sea_orm::{
ActiveModelTrait, ConnectionTrait, DbBackend, EntityTrait, FromQueryResult, IntoActiveModel, ActiveModelTrait, ConnectionTrait, DbBackend, EntityTrait, FromQueryResult, IntoActiveModel,
PaginatorTrait, QueryOrder, Set, Statement, PaginatorTrait, QueryOrder, Set, Statement,
@@ -17,11 +17,12 @@ use std::sync::{Mutex, OnceLock};
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
controllers::site_settings as site_settings_controller,
models::_entities::{ai_chunks, site_settings}, models::_entities::{ai_chunks, site_settings},
services::content, services::{content, storage},
}; };
const DEFAULT_AI_PROVIDER: &str = "newapi"; const DEFAULT_AI_PROVIDER: &str = "openai";
const DEFAULT_AI_API_BASE: &str = "https://91code.jiangnight.com/v1"; const DEFAULT_AI_API_BASE: &str = "https://91code.jiangnight.com/v1";
const DEFAULT_AI_API_KEY: &str = const DEFAULT_AI_API_KEY: &str =
"sk-5a5e27db9fb8f8ee7e1d8e3c6a44638c2e50cdb0a0cf9d926fefb5418ff62571"; "sk-5a5e27db9fb8f8ee7e1d8e3c6a44638c2e50cdb0a0cf9d926fefb5418ff62571";
@@ -29,6 +30,8 @@ const DEFAULT_CHAT_MODEL: &str = "gpt-5.4";
const DEFAULT_REASONING_EFFORT: &str = "medium"; const DEFAULT_REASONING_EFFORT: &str = "medium";
const DEFAULT_DISABLE_RESPONSE_STORAGE: bool = true; const DEFAULT_DISABLE_RESPONSE_STORAGE: bool = true;
const DEFAULT_IMAGE_MODEL: &str = "gpt-image-1"; const DEFAULT_IMAGE_MODEL: &str = "gpt-image-1";
const DEFAULT_CLOUDFLARE_CHAT_MODEL: &str = "@cf/meta/llama-3.1-8b-instruct";
const DEFAULT_CLOUDFLARE_IMAGE_MODEL: &str = "@cf/black-forest-labs/flux-2-klein-4b";
const DEFAULT_TOP_K: usize = 4; const DEFAULT_TOP_K: usize = 4;
const DEFAULT_CHUNK_SIZE: usize = 1200; const DEFAULT_CHUNK_SIZE: usize = 1200;
const DEFAULT_SYSTEM_PROMPT: &str = const DEFAULT_SYSTEM_PROMPT: &str =
@@ -49,6 +52,14 @@ const LOCAL_EMBEDDING_FILES: [&str; 5] = [
static TEXT_EMBEDDING_MODEL: OnceLock<Mutex<TextEmbedding>> = OnceLock::new(); static TEXT_EMBEDDING_MODEL: OnceLock<Mutex<TextEmbedding>> = OnceLock::new();
#[derive(Clone, Debug)]
struct AiImageRuntimeSettings {
provider: String,
api_base: Option<String>,
api_key: Option<String>,
image_model: String,
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
struct AiRuntimeSettings { struct AiRuntimeSettings {
raw: site_settings::Model, raw: site_settings::Model,
@@ -56,6 +67,7 @@ struct AiRuntimeSettings {
api_base: Option<String>, api_base: Option<String>,
api_key: Option<String>, api_key: Option<String>,
chat_model: String, chat_model: String,
image: AiImageRuntimeSettings,
system_prompt: String, system_prompt: String,
top_k: usize, top_k: usize,
chunk_size: usize, chunk_size: usize,
@@ -121,6 +133,11 @@ pub struct PolishedPostMarkdown {
pub polished_markdown: String, pub polished_markdown: String,
} }
#[derive(Clone, Debug, Serialize)]
pub struct PolishedReviewDescription {
pub polished_description: String,
}
#[derive(Clone, Debug, Serialize)] #[derive(Clone, Debug, Serialize)]
pub struct GeneratedPostCoverImage { pub struct GeneratedPostCoverImage {
pub image_url: String, pub image_url: String,
@@ -135,6 +152,14 @@ pub struct AiProviderConnectivityResult {
pub reply_preview: String, pub reply_preview: String,
} }
#[derive(Clone, Debug, Serialize)]
pub struct AiImageProviderConnectivityResult {
pub provider: String,
pub endpoint: String,
pub image_model: String,
pub result_preview: String,
}
#[derive(Clone, Debug, Default, Deserialize)] #[derive(Clone, Debug, Default, Deserialize)]
struct GeneratedPostMetadataDraft { struct GeneratedPostMetadataDraft {
title: Option<String>, title: Option<String>,
@@ -227,16 +252,57 @@ fn build_endpoint(api_base: &str, path: &str) -> String {
) )
} }
fn provider_uses_cloudflare(provider: &str) -> bool {
provider.eq_ignore_ascii_case("cloudflare")
|| provider.eq_ignore_ascii_case("cloudflare-workers-ai")
|| provider.eq_ignore_ascii_case("workers-ai")
}
fn provider_uses_openai_api_prefix(provider: &str) -> bool { fn provider_uses_openai_api_prefix(provider: &str) -> bool {
provider_uses_responses(provider) || provider.eq_ignore_ascii_case("openai-compatible") provider_uses_responses(provider) || provider.eq_ignore_ascii_case("openai-compatible")
} }
fn normalize_cloudflare_api_base(api_base: &str) -> String {
let trimmed = api_base.trim().trim_end_matches('/');
if trimmed.is_empty() {
return String::new();
}
if !trimmed.starts_with("http://") && !trimmed.starts_with("https://") {
return format!(
"https://api.cloudflare.com/client/v4/accounts/{}",
trimmed.trim_matches('/'),
);
}
let Ok(mut parsed) = Url::parse(trimmed) else {
return trimmed.to_string();
};
let segments = parsed
.path_segments()
.map(|items| items.collect::<Vec<_>>())
.unwrap_or_default();
if let Some(account_index) = segments.iter().position(|segment| *segment == "accounts") {
if let Some(account_id) = segments.get(account_index + 1) {
parsed.set_path(&format!("/client/v4/accounts/{account_id}"));
}
}
parsed.to_string().trim_end_matches('/').to_string()
}
fn normalize_provider_api_base(provider: &str, api_base: &str) -> String { fn normalize_provider_api_base(provider: &str, api_base: &str) -> String {
let trimmed = api_base.trim(); let trimmed = api_base.trim();
if trimmed.is_empty() { if trimmed.is_empty() {
return String::new(); return String::new();
} }
if provider_uses_cloudflare(provider) {
return normalize_cloudflare_api_base(trimmed);
}
if !provider_uses_openai_api_prefix(provider) { if !provider_uses_openai_api_prefix(provider) {
return trimmed.trim_end_matches('/').to_string(); return trimmed.trim_end_matches('/').to_string();
} }
@@ -604,6 +670,19 @@ fn parse_provider_sse_body(body: &str) -> Option<Value> {
latest_response.or(latest_payload) latest_response.or(latest_payload)
} }
fn parse_json_body(body: &str) -> Result<Value> {
serde_json::from_str(body)
.or_else(|_| {
parse_provider_sse_body(body).ok_or_else(|| {
serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"provider returned neither JSON nor SSE JSON payload",
))
})
})
.map_err(|error| Error::BadRequest(format!("AI response parse failed: {error}")))
}
async fn request_json(client: &Client, url: &str, api_key: &str, payload: Value) -> Result<Value> { async fn request_json(client: &Client, url: &str, api_key: &str, payload: Value) -> Result<Value> {
let response = client let response = client
.post(url) .post(url)
@@ -626,20 +705,56 @@ async fn request_json(client: &Client, url: &str, api_key: &str, payload: Value)
))); )));
} }
serde_json::from_str(&body) parse_json_body(&body)
.or_else(|_| { }
parse_provider_sse_body(&body).ok_or_else(|| {
serde_json::Error::io(std::io::Error::new( async fn request_multipart_json(
std::io::ErrorKind::InvalidData, client: &Client,
"provider returned neither JSON nor SSE JSON payload", url: &str,
)) api_key: &str,
}) form: multipart::Form,
}) ) -> Result<Value> {
.map_err(|error| Error::BadRequest(format!("AI response parse failed: {error}"))) let response = client
.post(url)
.bearer_auth(api_key)
.header("Accept", "application/json")
.multipart(form)
.send()
.await
.map_err(|error| Error::BadRequest(format!("AI request failed: {error}")))?;
let status = response.status();
let body = response
.text()
.await
.map_err(|error| Error::BadRequest(format!("AI response read failed: {error}")))?;
if !status.is_success() {
return Err(Error::BadRequest(format!(
"AI provider returned {status}: {body}"
)));
}
parse_json_body(&body)
} }
fn provider_uses_responses(provider: &str) -> bool { fn provider_uses_responses(provider: &str) -> bool {
provider.eq_ignore_ascii_case("newapi") provider.eq_ignore_ascii_case("newapi")
|| provider.eq_ignore_ascii_case("openai")
|| provider.eq_ignore_ascii_case("anthropic")
|| provider.eq_ignore_ascii_case("gemini")
}
fn default_image_model_for_provider_name(provider: &str) -> &'static str {
if provider_uses_cloudflare(provider) {
DEFAULT_CLOUDFLARE_IMAGE_MODEL
} else {
DEFAULT_IMAGE_MODEL
}
}
pub fn default_image_model_for_provider(provider: &str) -> &'static str {
default_image_model_for_provider_name(provider)
} }
async fn embed_texts_locally(inputs: Vec<String>, kind: EmbeddingKind) -> Result<Vec<Vec<f64>>> { async fn embed_texts_locally(inputs: Vec<String>, kind: EmbeddingKind) -> Result<Vec<Vec<f64>>> {
@@ -1015,6 +1130,66 @@ fn build_polish_markdown_prompt(markdown: &str) -> String {
) )
} }
fn build_polish_review_prompt(
title: &str,
review_type: &str,
rating: i32,
review_date: Option<&str>,
status: &str,
tags: &[String],
description: &str,
) -> String {
let review_date_text = review_date
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("未填写");
let tag_text = if tags.is_empty() {
"".to_string()
} else {
tags.join(" / ")
};
format!(
"请润色一段中文作品评测简介/点评文案。\n\
要求:\n\
1. 保留原文观点、倾向和结论,不要杜撰剧情、设定或体验细节。\n\
2. 语言更凝练、更自然,适合放在评测页中展示。\n\
3. 可以优化句式与节奏,但不要改写成标题、列表或营销文案。\n\
4. 默认输出一到三段正文,总长度尽量控制在 80 到 220 字之间;如果原文本身更长,可适度保留信息密度。\n\
5. 只返回润色后的简介正文,不要附加解释。\n\n\
作品标题:{}\n\
评测类型:{}\n\
评分:{}/5\n\
评测日期:{}\n\
状态:{}\n\
标签:{}\n\n\
当前简介:\n{}",
title.trim(),
review_type.trim(),
rating,
review_date_text,
status.trim(),
tag_text,
description.trim(),
)
}
fn ensure_sentence_ending(text: &str) -> String {
let trimmed = text.trim();
if trimmed.is_empty() {
return String::new();
}
if matches!(
trimmed.chars().last(),
Some('。' | '' | '' | '.' | '!' | '?')
) {
trimmed.to_string()
} else {
format!("{trimmed}")
}
}
fn build_post_cover_prompt( fn build_post_cover_prompt(
title: &str, title: &str,
description: Option<&str>, description: Option<&str>,
@@ -1063,12 +1238,40 @@ fn build_post_cover_prompt(
) )
} }
fn build_image_generation_url(provider: &str, api_base: &str) -> String { fn build_image_generation_url(provider: &str, api_base: &str, image_model: &str) -> String {
let normalized = normalize_provider_api_base(provider, api_base); let normalized = normalize_provider_api_base(provider, api_base);
if provider_uses_cloudflare(provider) {
return build_endpoint(&normalized, &format!("/ai/run/{}", image_model.trim()));
}
build_endpoint(&normalized, "/images/generations") build_endpoint(&normalized, "/images/generations")
} }
fn extract_image_generation_result(value: &Value) -> Option<String> { fn extract_image_generation_result(value: &Value) -> Option<String> {
if let Some(result) = value.get("result") {
if let Some(image) = result.get("image").and_then(Value::as_str) {
let trimmed = image.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
if let Some(url) = result.get("url").and_then(Value::as_str) {
let trimmed = url.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
}
if let Some(image) = value.get("image").and_then(Value::as_str) {
let trimmed = image.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
let data = value.get("data").and_then(Value::as_array)?; let data = value.get("data").and_then(Value::as_array)?;
for item in data { for item in data {
@@ -1115,25 +1318,113 @@ fn generated_cover_directory() -> PathBuf {
}) })
} }
fn persist_generated_cover_image(slug_hint: &str, base64_data: &str) -> Result<String> { fn image_details_from_mime(content_type: &str) -> Option<(&'static str, &'static str)> {
match content_type
.trim()
.split(';')
.next()
.unwrap_or_default()
.trim()
.to_ascii_lowercase()
.as_str()
{
"image/png" => Some(("png", "image/png")),
"image/jpeg" | "image/jpg" => Some(("jpg", "image/jpeg")),
"image/webp" => Some(("webp", "image/webp")),
"image/gif" => Some(("gif", "image/gif")),
"image/svg+xml" => Some(("svg", "image/svg+xml")),
_ => None,
}
}
fn image_details_from_extension(extension: &str) -> Option<(&'static str, &'static str)> {
match extension
.trim()
.trim_start_matches('.')
.to_ascii_lowercase()
.as_str()
{
"png" => Some(("png", "image/png")),
"jpg" | "jpeg" => Some(("jpg", "image/jpeg")),
"webp" => Some(("webp", "image/webp")),
"gif" => Some(("gif", "image/gif")),
"svg" => Some(("svg", "image/svg+xml")),
_ => None,
}
}
fn image_details_from_url(url: &str) -> Option<(&'static str, &'static str)> {
let parsed = Url::parse(url).ok()?;
let path = parsed.path();
let extension = path.rsplit_once('.')?.1;
image_details_from_extension(extension)
}
fn decode_base64_image_payload(base64_data: &str) -> Result<(Vec<u8>, &'static str, &'static str)> {
let trimmed = base64_data.trim();
let (payload, extension, content_type) = if let Some(rest) = trimmed.strip_prefix("data:") {
let (metadata, encoded) = rest
.split_once(',')
.ok_or_else(|| Error::BadRequest("AI 封面图 data URL 格式不正确".to_string()))?;
let mime = metadata
.split(';')
.next()
.filter(|value| !value.trim().is_empty())
.unwrap_or("image/png");
let (extension, content_type) =
image_details_from_mime(mime).unwrap_or(("png", "image/png"));
(encoded, extension, content_type)
} else {
(trimmed, "png", "image/png")
};
let image_bytes = BASE64_STANDARD
.decode(payload)
.map_err(|error| Error::BadRequest(format!("解析 AI 封面图失败: {error}")))?;
Ok((image_bytes, extension, content_type))
}
async fn persist_generated_cover_image_bytes(
ctx: &AppContext,
slug_hint: &str,
image_bytes: Vec<u8>,
extension: &str,
content_type: &str,
) -> Result<String> {
if storage::optional_r2_settings(ctx).await?.is_some() {
let key = storage::build_object_key("post-covers", slug_hint, extension);
let stored = storage::upload_bytes_to_r2(
ctx,
&key,
image_bytes,
Some(content_type),
Some("public, max-age=31536000, immutable"),
)
.await?;
return Ok(stored.url);
}
let directory = generated_cover_directory(); let directory = generated_cover_directory();
fs::create_dir_all(&directory) fs::create_dir_all(&directory)
.map_err(|error| Error::BadRequest(format!("创建封面图目录失败: {error}")))?; .map_err(|error| Error::BadRequest(format!("创建封面图目录失败: {error}")))?;
let safe_slug = metadata_slugify(slug_hint); let safe_slug = metadata_slugify(slug_hint);
let file_name = format!( let file_name = format!(
"{}-{}.png", "{}-{}.{}",
if safe_slug.is_empty() { if safe_slug.is_empty() {
"cover".to_string() "cover".to_string()
} else { } else {
safe_slug safe_slug
}, },
Uuid::new_v4().simple() Uuid::new_v4().simple(),
extension
.trim()
.trim_start_matches('.')
.to_ascii_lowercase()
); );
let file_path = directory.join(&file_name); let file_path = directory.join(&file_name);
let image_bytes = BASE64_STANDARD
.decode(base64_data)
.map_err(|error| Error::BadRequest(format!("解析 AI 封面图失败: {error}")))?;
fs::write(&file_path, image_bytes) fs::write(&file_path, image_bytes)
.map_err(|error| Error::BadRequest(format!("写入 AI 封面图失败: {error}")))?; .map_err(|error| Error::BadRequest(format!("写入 AI 封面图失败: {error}")))?;
@@ -1141,6 +1432,49 @@ fn persist_generated_cover_image(slug_hint: &str, base64_data: &str) -> Result<S
Ok(format!("/generated-covers/{file_name}")) Ok(format!("/generated-covers/{file_name}"))
} }
async fn persist_generated_cover_image(
ctx: &AppContext,
slug_hint: &str,
image_result: &str,
) -> Result<String> {
if image_result.starts_with("http://") || image_result.starts_with("https://") {
let client = Client::new();
let response = client
.get(image_result)
.send()
.await
.map_err(|error| Error::BadRequest(format!("下载 AI 封面图失败: {error}")))?
.error_for_status()
.map_err(|error| Error::BadRequest(format!("下载 AI 封面图失败: {error}")))?;
let content_type_header = response
.headers()
.get(CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(ToString::to_string);
let image_bytes = response
.bytes()
.await
.map_err(|error| Error::BadRequest(format!("读取 AI 封面图失败: {error}")))?;
let (extension, content_type) = content_type_header
.as_deref()
.and_then(image_details_from_mime)
.or_else(|| image_details_from_url(image_result))
.unwrap_or(("png", "image/png"));
return persist_generated_cover_image_bytes(
ctx,
slug_hint,
image_bytes.to_vec(),
extension,
content_type,
)
.await;
}
let (image_bytes, extension, content_type) = decode_base64_image_payload(image_result)?;
persist_generated_cover_image_bytes(ctx, slug_hint, image_bytes, extension, content_type).await
}
fn fallback_polished_markdown(markdown: &str) -> String { fn fallback_polished_markdown(markdown: &str) -> String {
let metadata = fallback_generated_metadata(markdown); let metadata = fallback_generated_metadata(markdown);
let body = strip_markdown_frontmatter(markdown) let body = strip_markdown_frontmatter(markdown)
@@ -1176,6 +1510,17 @@ tags:\n{}\n\
) )
} }
fn fallback_polished_review_description(description: &str) -> String {
let normalized = normalize_newlines(description)
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.collect::<Vec<_>>()
.join("\n\n");
ensure_sentence_ending(&normalized)
}
pub async fn generate_post_metadata( pub async fn generate_post_metadata(
ctx: &AppContext, ctx: &AppContext,
markdown: &str, markdown: &str,
@@ -1303,6 +1648,85 @@ pub async fn polish_post_markdown(
} }
} }
pub async fn polish_review_description(
ctx: &AppContext,
title: &str,
review_type: &str,
rating: i32,
review_date: Option<&str>,
status: &str,
tags: &[String],
description: &str,
) -> Result<PolishedReviewDescription> {
let trimmed_description = description.trim();
if trimmed_description.is_empty() {
return Err(Error::BadRequest(
"请先填写点评内容,再进行润色。".to_string(),
));
}
let settings = load_runtime_settings(ctx, false).await?;
let remote_result: Result<PolishedReviewDescription> = match (
settings.api_base.clone(),
settings.api_key.clone(),
) {
(Some(api_base), Some(api_key)) => {
let request = AiProviderRequest {
provider: settings.provider.clone(),
api_base,
api_key,
chat_model: settings.chat_model.clone(),
system_prompt: "你是中文内容平台里的资深评测编辑。你只负责润色用户已有的作品点评文案,不要改写核心观点,不要虚构事实,不要输出额外解释。".to_string(),
prompt: build_polish_review_prompt(
title,
review_type,
rating,
review_date,
status,
tags,
trimmed_description,
),
};
let client = Client::new();
let response = request_json(
&client,
&build_provider_url(&request),
&request.api_key,
build_provider_payload(&request, false),
)
.await;
match response {
Ok(response) => {
let polished_description =
extract_provider_text(&response).ok_or_else(|| {
Error::BadRequest("AI 润色响应里没有可读取内容。".to_string())
})?;
Ok(PolishedReviewDescription {
polished_description: normalize_newlines(polished_description.trim()),
})
}
Err(error) => Err(error),
}
}
_ => Err(Error::BadRequest(
"AI 服务未配置完整,已自动切换为本地智能润色。".to_string(),
)),
};
match remote_result {
Ok(result) => Ok(result),
Err(error) => {
tracing::warn!("AI review polish fallback: {error}");
Ok(PolishedReviewDescription {
polished_description: fallback_polished_review_description(trimmed_description),
})
}
}
}
pub async fn generate_post_cover_image( pub async fn generate_post_cover_image(
ctx: &AppContext, ctx: &AppContext,
title: &str, title: &str,
@@ -1323,14 +1747,14 @@ pub async fn generate_post_cover_image(
} }
let settings = load_runtime_settings(ctx, false).await?; let settings = load_runtime_settings(ctx, false).await?;
let api_base = settings let image_settings = settings.image.clone();
.api_base let api_base = image_settings.api_base.clone().ok_or_else(|| {
.clone() Error::BadRequest("图片 AI API Base 未配置,无法生成封面图。".to_string())
.ok_or_else(|| Error::BadRequest("AI API Base 未配置,无法生成封面图。".to_string()))?; })?;
let api_key = settings let api_key = image_settings
.api_key .api_key
.clone() .clone()
.ok_or_else(|| Error::BadRequest("AI API Key 未配置,无法生成封面图。".to_string()))?; .ok_or_else(|| Error::BadRequest("图片 AI API Key 未配置,无法生成封面图。".to_string()))?;
let prompt = build_post_cover_prompt( let prompt = build_post_cover_prompt(
if trimmed_title.is_empty() { if trimmed_title.is_empty() {
"未命名文章" "未命名文章"
@@ -1343,33 +1767,22 @@ pub async fn generate_post_cover_image(
post_type, post_type,
trimmed_markdown, trimmed_markdown,
); );
let payload = json!({ let image_model = image_settings.image_model.clone();
"model": DEFAULT_IMAGE_MODEL, let response = request_image_generation(
"prompt": prompt, &image_settings.provider,
"size": "1536x1024", &api_base,
"quality": "high",
"output_format": "png"
});
let client = Client::new();
let response = request_json(
&client,
&build_image_generation_url(&settings.provider, &api_base),
&api_key, &api_key,
payload, &image_model,
&prompt,
) )
.await?; .await?;
let image_result = extract_image_generation_result(&response) let image_result = extract_image_generation_result(&response)
.ok_or_else(|| Error::BadRequest("AI 封面图响应里没有可读取图片。".to_string()))?; .ok_or_else(|| Error::BadRequest("AI 封面图响应里没有可读取图片。".to_string()))?;
let image_url = if image_result.starts_with("http://") || image_result.starts_with("https://") { let slug_hint = slug
image_result .map(str::trim)
} else { .filter(|value| !value.is_empty())
let slug_hint = slug .unwrap_or(trimmed_title);
.map(str::trim) let image_url = persist_generated_cover_image(ctx, slug_hint, &image_result).await?;
.filter(|value| !value.is_empty())
.unwrap_or(trimmed_title);
persist_generated_cover_image(slug_hint, &image_result)?
};
Ok(GeneratedPostCoverImage { image_url, prompt }) Ok(GeneratedPostCoverImage { image_url, prompt })
} }
@@ -1389,6 +1802,12 @@ fn extract_response_output(value: &Value) -> Option<String> {
} }
} }
if let Some(result) = value.get("result") {
if let Some(text) = extract_response_output(result) {
return Some(text);
}
}
if let Some(response) = value.get("response") { if let Some(response) = value.get("response") {
if let Some(text) = extract_response_output(response) { if let Some(text) = extract_response_output(response) {
return Some(text); return Some(text);
@@ -1590,7 +2009,16 @@ fn build_sources(matches: &[ScoredChunk]) -> Vec<AiSource> {
} }
pub(crate) fn build_provider_payload(request: &AiProviderRequest, stream: bool) -> Value { pub(crate) fn build_provider_payload(request: &AiProviderRequest, stream: bool) -> Value {
if provider_uses_responses(&request.provider) { if provider_uses_cloudflare(&request.provider) {
json!({
"prompt": format!(
"系统指令:{}
用户请求:{}",
request.system_prompt, request.prompt,
)
})
} else if provider_uses_responses(&request.provider) {
json!({ json!({
"model": request.chat_model, "model": request.chat_model,
"input": [ "input": [
@@ -1640,13 +2068,15 @@ pub(crate) fn build_provider_payload(request: &AiProviderRequest, stream: bool)
pub(crate) fn build_provider_url(request: &AiProviderRequest) -> String { pub(crate) fn build_provider_url(request: &AiProviderRequest) -> String {
let api_base = normalize_provider_api_base(&request.provider, &request.api_base); let api_base = normalize_provider_api_base(&request.provider, &request.api_base);
let path = if provider_uses_responses(&request.provider) { let path = if provider_uses_cloudflare(&request.provider) {
"/responses" format!("/ai/run/{}", request.chat_model.trim())
} else if provider_uses_responses(&request.provider) {
"/responses".to_string()
} else { } else {
"/chat/completions" "/chat/completions".to_string()
}; };
build_endpoint(&api_base, path) build_endpoint(&api_base, &path)
} }
#[cfg(test)] #[cfg(test)]
@@ -1701,6 +2131,25 @@ mod tests {
); );
} }
#[test]
fn normalize_provider_api_base_supports_cloudflare_account_id() {
assert_eq!(
normalize_provider_api_base("cloudflare", "test-account-id"),
"https://api.cloudflare.com/client/v4/accounts/test-account-id"
);
}
#[test]
fn build_provider_url_uses_cloudflare_run_endpoint() {
let mut request = build_request("cloudflare", "test-account-id");
request.chat_model = "@cf/meta/llama-3.1-8b-instruct".to_string();
assert_eq!(
build_provider_url(&request),
"https://api.cloudflare.com/client/v4/accounts/test-account-id/ai/run/@cf/meta/llama-3.1-8b-instruct"
);
}
#[test] #[test]
fn profile_question_detects_owner_keywords() { fn profile_question_detects_owner_keywords() {
assert!(is_profile_question("站长的技术栈和个人介绍是什么?")); assert!(is_profile_question("站长的技术栈和个人介绍是什么?"));
@@ -1780,6 +2229,59 @@ async fn request_chat_answer(request: &AiProviderRequest) -> Result<String> {
}) })
} }
async fn request_image_generation(
provider: &str,
api_base: &str,
api_key: &str,
image_model: &str,
prompt: &str,
) -> Result<Value> {
let client = Client::new();
if provider_uses_cloudflare(provider) {
if image_model.eq_ignore_ascii_case(DEFAULT_CLOUDFLARE_IMAGE_MODEL) {
let form = multipart::Form::new()
.text("prompt", prompt.to_string())
.text("width", "1024")
.text("height", "576")
.text("steps", "16");
return request_multipart_json(
&client,
&build_image_generation_url(provider, api_base, image_model),
api_key,
form,
)
.await;
}
return request_json(
&client,
&build_image_generation_url(provider, api_base, image_model),
api_key,
json!({
"prompt": prompt,
"steps": 4
}),
)
.await;
}
request_json(
&client,
&build_image_generation_url(provider, api_base, image_model),
api_key,
json!({
"model": image_model,
"prompt": prompt,
"size": "1536x1024",
"quality": "high",
"output_format": "png"
}),
)
.await
}
pub async fn test_provider_connectivity( pub async fn test_provider_connectivity(
provider: &str, provider: &str,
api_base: &str, api_base: &str,
@@ -1814,6 +2316,59 @@ pub async fn test_provider_connectivity(
}) })
} }
pub async fn test_image_provider_connectivity(
provider: &str,
api_base: &str,
api_key: &str,
image_model: &str,
) -> Result<AiImageProviderConnectivityResult> {
let provider = trim_to_option(Some(provider.to_string()))
.unwrap_or_else(|| DEFAULT_AI_PROVIDER.to_string());
let api_base = trim_to_option(Some(api_base.to_string()))
.ok_or_else(|| Error::BadRequest("请先填写图片 API 地址".to_string()))?;
let api_key = trim_to_option(Some(api_key.to_string()))
.ok_or_else(|| Error::BadRequest("请先填写图片 API 密钥".to_string()))?;
let image_model = trim_to_option(Some(image_model.to_string()))
.ok_or_else(|| Error::BadRequest("请先填写图片模型".to_string()))?;
let prompt = "Minimal abstract technology cover art, blue gradient, no text, no watermark";
let response = if provider_uses_cloudflare(&provider)
&& image_model.eq_ignore_ascii_case(DEFAULT_CLOUDFLARE_IMAGE_MODEL)
{
let client = Client::new();
let form = multipart::Form::new()
.text("prompt", prompt.to_string())
.text("width", "512")
.text("height", "288")
.text("steps", "4");
request_multipart_json(
&client,
&build_image_generation_url(&provider, &api_base, &image_model),
&api_key,
form,
)
.await?
} else {
request_image_generation(&provider, &api_base, &api_key, &image_model, prompt).await?
};
let image_result = extract_image_generation_result(&response)
.ok_or_else(|| Error::BadRequest("图片接口响应里没有可读取的图片结果".to_string()))?;
let result_preview =
if image_result.starts_with("http://") || image_result.starts_with("https://") {
image_result
} else {
format!("base64 image ok ({} chars)", image_result.len())
};
Ok(AiImageProviderConnectivityResult {
provider: provider.clone(),
endpoint: build_image_generation_url(&provider, &api_base, &image_model),
image_model,
result_preview,
})
}
pub(crate) async fn prepare_answer(ctx: &AppContext, question: &str) -> Result<PreparedAiAnswer> { pub(crate) async fn prepare_answer(ctx: &AppContext, question: &str) -> Result<PreparedAiAnswer> {
let trimmed_question = question.trim(); let trimmed_question = question.trim();
if trimmed_question.is_empty() { if trimmed_question.is_empty() {
@@ -1907,12 +2462,72 @@ async fn load_runtime_settings(
return Err(Error::NotFound); return Err(Error::NotFound);
} }
let active_provider =
site_settings_controller::active_ai_provider_id(&raw).and_then(|active_id| {
site_settings_controller::ai_provider_configs(&raw)
.into_iter()
.find(|item| item.id == active_id)
});
let provider = active_provider
.as_ref()
.map(|item| provider_name(Some(item.provider.as_str())))
.unwrap_or_else(|| provider_name(raw.ai_provider.as_deref()));
let api_base = active_provider
.as_ref()
.and_then(|item| trim_to_option(item.api_base.clone()))
.or_else(|| trim_to_option(raw.ai_api_base.clone()));
let api_key = active_provider
.as_ref()
.and_then(|item| trim_to_option(item.api_key.clone()))
.or_else(|| trim_to_option(raw.ai_api_key.clone()));
let chat_model = active_provider
.as_ref()
.and_then(|item| trim_to_option(item.chat_model.clone()))
.unwrap_or_else(|| {
if provider_uses_cloudflare(&provider) {
DEFAULT_CLOUDFLARE_CHAT_MODEL.to_string()
} else {
DEFAULT_CHAT_MODEL.to_string()
}
});
let legacy_image_model = active_provider
.as_ref()
.and_then(|item| trim_to_option(item.image_model.clone()))
.unwrap_or_else(|| default_image_model_for_provider_name(&provider).to_string());
let image_provider = trim_to_option(raw.ai_image_provider.clone());
let image_api_base = trim_to_option(raw.ai_image_api_base.clone());
let image_api_key = trim_to_option(raw.ai_image_api_key.clone());
let image_model = trim_to_option(raw.ai_image_model.clone());
let has_dedicated_image_settings = image_provider.is_some()
|| image_api_base.is_some()
|| image_api_key.is_some()
|| image_model.is_some();
let image = if has_dedicated_image_settings {
let provider = image_provider.unwrap_or_else(|| provider.clone());
let image_model = image_model
.unwrap_or_else(|| default_image_model_for_provider_name(&provider).to_string());
AiImageRuntimeSettings {
provider,
api_base: image_api_base,
api_key: image_api_key,
image_model,
}
} else {
AiImageRuntimeSettings {
provider: provider.clone(),
api_base: api_base.clone(),
api_key: api_key.clone(),
image_model: legacy_image_model,
}
};
Ok(AiRuntimeSettings { Ok(AiRuntimeSettings {
provider: provider_name(raw.ai_provider.as_deref()), provider,
api_base: trim_to_option(raw.ai_api_base.clone()), api_base,
api_key: trim_to_option(raw.ai_api_key.clone()), api_key,
chat_model: trim_to_option(raw.ai_chat_model.clone()) chat_model,
.unwrap_or_else(|| DEFAULT_CHAT_MODEL.to_string()), image,
system_prompt: trim_to_option(raw.ai_system_prompt.clone()) system_prompt: trim_to_option(raw.ai_system_prompt.clone())
.unwrap_or_else(|| DEFAULT_SYSTEM_PROMPT.to_string()), .unwrap_or_else(|| DEFAULT_SYSTEM_PROMPT.to_string()),
top_k: raw top_k: raw

View File

@@ -0,0 +1,441 @@
use std::collections::{BTreeMap, HashMap};
use axum::http::HeaderMap;
use chrono::{DateTime, Duration, NaiveDate, Utc};
use loco_rs::prelude::*;
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
QuerySelect, Set,
};
use serde::Serialize;
use crate::models::_entities::query_events;
const EVENT_TYPE_SEARCH: &str = "search";
const EVENT_TYPE_AI_QUESTION: &str = "ai_question";
#[derive(Clone, Debug, Default)]
pub struct QueryEventRequestContext {
pub request_path: Option<String>,
pub referrer: Option<String>,
pub user_agent: Option<String>,
}
#[derive(Clone, Debug)]
pub struct QueryEventDraft {
pub event_type: String,
pub query_text: String,
pub request_context: QueryEventRequestContext,
pub result_count: Option<i32>,
pub success: Option<bool>,
pub response_mode: Option<String>,
pub provider: Option<String>,
pub chat_model: Option<String>,
pub latency_ms: Option<i32>,
}
#[derive(Clone, Debug, Serialize)]
pub struct AnalyticsOverview {
pub total_searches: u64,
pub total_ai_questions: u64,
pub searches_last_24h: u64,
pub ai_questions_last_24h: u64,
pub searches_last_7d: u64,
pub ai_questions_last_7d: u64,
pub unique_search_terms_last_7d: usize,
pub unique_ai_questions_last_7d: usize,
pub avg_search_results_last_7d: f64,
pub avg_ai_latency_ms_last_7d: Option<f64>,
}
#[derive(Clone, Debug, Serialize)]
pub struct AnalyticsTopQuery {
pub query: String,
pub count: u64,
pub last_seen_at: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct AnalyticsRecentEvent {
pub id: i32,
pub event_type: String,
pub query: String,
pub result_count: Option<i32>,
pub success: Option<bool>,
pub response_mode: Option<String>,
pub provider: Option<String>,
pub chat_model: Option<String>,
pub latency_ms: Option<i32>,
pub created_at: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct AnalyticsProviderBucket {
pub provider: String,
pub count: u64,
}
#[derive(Clone, Debug, Serialize)]
pub struct AnalyticsDailyBucket {
pub date: String,
pub searches: u64,
pub ai_questions: u64,
}
#[derive(Clone, Debug, Serialize)]
pub struct AdminAnalyticsResponse {
pub overview: AnalyticsOverview,
pub top_search_terms: Vec<AnalyticsTopQuery>,
pub top_ai_questions: Vec<AnalyticsTopQuery>,
pub recent_events: Vec<AnalyticsRecentEvent>,
pub providers_last_7d: Vec<AnalyticsProviderBucket>,
pub daily_activity: Vec<AnalyticsDailyBucket>,
}
#[derive(Clone, Debug)]
struct QueryAggregate {
query: String,
count: u64,
last_seen_at: DateTime<Utc>,
}
fn trim_to_option(value: Option<String>) -> Option<String> {
value.and_then(|item| {
let trimmed = item.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
})
}
fn normalize_query(value: &str) -> String {
value
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_lowercase()
}
fn format_timestamp(value: DateTime<Utc>) -> String {
value.format("%Y-%m-%d %H:%M").to_string()
}
fn header_value(headers: &HeaderMap, key: &str) -> Option<String> {
headers
.get(key)
.and_then(|value| value.to_str().ok())
.map(ToString::to_string)
.and_then(|value| trim_to_option(Some(value)))
}
fn clamp_latency(latency_ms: i64) -> i32 {
latency_ms.clamp(0, i64::from(i32::MAX)) as i32
}
fn build_query_aggregates(
events: &[query_events::Model],
wanted_type: &str,
) -> Vec<QueryAggregate> {
let mut grouped: HashMap<String, QueryAggregate> = HashMap::new();
for event in events
.iter()
.filter(|event| event.event_type == wanted_type)
{
let entry = grouped
.entry(event.normalized_query.clone())
.or_insert_with(|| QueryAggregate {
query: event.query_text.clone(),
count: 0,
last_seen_at: event.created_at.into(),
});
entry.count += 1;
let created_at = DateTime::<Utc>::from(event.created_at);
if created_at >= entry.last_seen_at {
entry.query = event.query_text.clone();
entry.last_seen_at = created_at;
}
}
let mut items = grouped.into_values().collect::<Vec<_>>();
items.sort_by(|left, right| {
right
.count
.cmp(&left.count)
.then_with(|| right.last_seen_at.cmp(&left.last_seen_at))
});
items
}
fn aggregate_queries(
events: &[query_events::Model],
wanted_type: &str,
limit: usize,
) -> (usize, Vec<AnalyticsTopQuery>) {
let aggregates = build_query_aggregates(events, wanted_type);
let total_unique = aggregates.len();
let items = aggregates
.into_iter()
.take(limit)
.map(|item| AnalyticsTopQuery {
query: item.query,
count: item.count,
last_seen_at: format_timestamp(item.last_seen_at),
})
.collect::<Vec<_>>();
(total_unique, items)
}
pub fn request_context_from_headers(path: &str, headers: &HeaderMap) -> QueryEventRequestContext {
QueryEventRequestContext {
request_path: trim_to_option(Some(path.to_string())),
referrer: header_value(headers, "referer"),
user_agent: header_value(headers, "user-agent"),
}
}
pub async fn record_event(ctx: &AppContext, draft: QueryEventDraft) {
let query_text = draft.query_text.trim().to_string();
if query_text.is_empty() {
return;
}
let active_model = query_events::ActiveModel {
event_type: Set(draft.event_type),
query_text: Set(query_text.clone()),
normalized_query: Set(normalize_query(&query_text)),
request_path: Set(trim_to_option(draft.request_context.request_path)),
referrer: Set(trim_to_option(draft.request_context.referrer)),
user_agent: Set(trim_to_option(draft.request_context.user_agent)),
result_count: Set(draft.result_count),
success: Set(draft.success),
response_mode: Set(trim_to_option(draft.response_mode)),
provider: Set(trim_to_option(draft.provider)),
chat_model: Set(trim_to_option(draft.chat_model)),
latency_ms: Set(draft.latency_ms.map(|value| value.max(0))),
..Default::default()
};
if let Err(error) = active_model.insert(&ctx.db).await {
tracing::warn!("failed to record query analytics event: {error}");
}
}
pub async fn record_search_event(
ctx: &AppContext,
query_text: &str,
result_count: usize,
headers: &HeaderMap,
latency_ms: i64,
) {
record_event(
ctx,
QueryEventDraft {
event_type: EVENT_TYPE_SEARCH.to_string(),
query_text: query_text.to_string(),
request_context: request_context_from_headers("/api/search", headers),
result_count: Some(result_count.min(i32::MAX as usize) as i32),
success: Some(true),
response_mode: None,
provider: None,
chat_model: None,
latency_ms: Some(clamp_latency(latency_ms)),
},
)
.await;
}
pub async fn record_ai_question_event(
ctx: &AppContext,
question: &str,
headers: &HeaderMap,
success: bool,
response_mode: &str,
provider: Option<String>,
chat_model: Option<String>,
result_count: Option<usize>,
latency_ms: i64,
) {
record_event(
ctx,
QueryEventDraft {
event_type: EVENT_TYPE_AI_QUESTION.to_string(),
query_text: question.to_string(),
request_context: request_context_from_headers(
if response_mode == "stream" {
"/api/ai/ask/stream"
} else {
"/api/ai/ask"
},
headers,
),
result_count: result_count.map(|value| value.min(i32::MAX as usize) as i32),
success: Some(success),
response_mode: Some(response_mode.to_string()),
provider,
chat_model,
latency_ms: Some(clamp_latency(latency_ms)),
},
)
.await;
}
pub async fn build_admin_analytics(ctx: &AppContext) -> Result<AdminAnalyticsResponse> {
let now = Utc::now();
let since_24h = now - Duration::hours(24);
let since_7d = now - Duration::days(7);
let total_searches = query_events::Entity::find()
.filter(query_events::Column::EventType.eq(EVENT_TYPE_SEARCH))
.count(&ctx.db)
.await?;
let total_ai_questions = query_events::Entity::find()
.filter(query_events::Column::EventType.eq(EVENT_TYPE_AI_QUESTION))
.count(&ctx.db)
.await?;
let searches_last_24h = query_events::Entity::find()
.filter(query_events::Column::EventType.eq(EVENT_TYPE_SEARCH))
.filter(query_events::Column::CreatedAt.gte(since_24h))
.count(&ctx.db)
.await?;
let ai_questions_last_24h = query_events::Entity::find()
.filter(query_events::Column::EventType.eq(EVENT_TYPE_AI_QUESTION))
.filter(query_events::Column::CreatedAt.gte(since_24h))
.count(&ctx.db)
.await?;
let last_7d_events = query_events::Entity::find()
.filter(query_events::Column::CreatedAt.gte(since_7d))
.order_by_desc(query_events::Column::CreatedAt)
.all(&ctx.db)
.await?;
let searches_last_7d = last_7d_events
.iter()
.filter(|event| event.event_type == EVENT_TYPE_SEARCH)
.count() as u64;
let ai_questions_last_7d = last_7d_events
.iter()
.filter(|event| event.event_type == EVENT_TYPE_AI_QUESTION)
.count() as u64;
let (unique_search_terms_last_7d, top_search_terms) =
aggregate_queries(&last_7d_events, EVENT_TYPE_SEARCH, 8);
let (unique_ai_questions_last_7d, top_ai_questions) =
aggregate_queries(&last_7d_events, EVENT_TYPE_AI_QUESTION, 8);
let mut provider_breakdown: HashMap<String, u64> = HashMap::new();
let mut daily_map: BTreeMap<NaiveDate, (u64, u64)> = BTreeMap::new();
let mut total_search_results = 0.0_f64;
let mut counted_search_results = 0_u64;
let mut total_ai_latency = 0.0_f64;
let mut counted_ai_latency = 0_u64;
for offset in 0..7 {
let date = (now - Duration::days(offset)).date_naive();
daily_map.entry(date).or_insert((0, 0));
}
for event in &last_7d_events {
let day = DateTime::<Utc>::from(event.created_at).date_naive();
let entry = daily_map.entry(day).or_insert((0, 0));
if event.event_type == EVENT_TYPE_SEARCH {
entry.0 += 1;
if let Some(result_count) = event.result_count {
total_search_results += f64::from(result_count.max(0));
counted_search_results += 1;
}
continue;
}
if event.event_type == EVENT_TYPE_AI_QUESTION {
entry.1 += 1;
let provider = event
.provider
.clone()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "local-or-unspecified".to_string());
*provider_breakdown.entry(provider).or_insert(0) += 1;
if let Some(latency_ms) = event.latency_ms {
total_ai_latency += f64::from(latency_ms.max(0));
counted_ai_latency += 1;
}
}
}
let mut providers_last_7d = provider_breakdown
.into_iter()
.map(|(provider, count)| AnalyticsProviderBucket { provider, count })
.collect::<Vec<_>>();
providers_last_7d.sort_by(|left, right| {
right
.count
.cmp(&left.count)
.then_with(|| left.provider.cmp(&right.provider))
});
providers_last_7d.truncate(6);
let mut daily_activity = daily_map
.into_iter()
.map(|(date, (searches, ai_questions))| AnalyticsDailyBucket {
date: date.format("%Y-%m-%d").to_string(),
searches,
ai_questions,
})
.collect::<Vec<_>>();
daily_activity.sort_by(|left, right| left.date.cmp(&right.date));
let recent_events = query_events::Entity::find()
.order_by_desc(query_events::Column::CreatedAt)
.limit(24)
.all(&ctx.db)
.await?
.into_iter()
.map(|event| AnalyticsRecentEvent {
id: event.id,
event_type: event.event_type,
query: event.query_text,
result_count: event.result_count,
success: event.success,
response_mode: event.response_mode,
provider: event.provider,
chat_model: event.chat_model,
latency_ms: event.latency_ms,
created_at: format_timestamp(event.created_at.into()),
})
.collect::<Vec<_>>();
Ok(AdminAnalyticsResponse {
overview: AnalyticsOverview {
total_searches,
total_ai_questions,
searches_last_24h,
ai_questions_last_24h,
searches_last_7d,
ai_questions_last_7d,
unique_search_terms_last_7d,
unique_ai_questions_last_7d,
avg_search_results_last_7d: if counted_search_results > 0 {
total_search_results / counted_search_results as f64
} else {
0.0
},
avg_ai_latency_ms_last_7d: (counted_ai_latency > 0)
.then(|| total_ai_latency / counted_ai_latency as f64),
},
top_search_terms,
top_ai_questions,
recent_events,
providers_last_7d,
daily_activity,
})
}

View File

@@ -1,2 +1,4 @@
pub mod ai; pub mod ai;
pub mod analytics;
pub mod content; pub mod content;
pub mod storage;

View File

@@ -0,0 +1,513 @@
use aws_config::BehaviorVersion;
use aws_sdk_s3::{config::Credentials, primitives::ByteStream, Client};
use loco_rs::prelude::*;
use sea_orm::{EntityTrait, QueryOrder};
use std::path::{Path, PathBuf};
use uuid::Uuid;
use crate::models::_entities::site_settings;
const ENV_MEDIA_PROVIDER: &str = "TERMI_MEDIA_PROVIDER";
const ENV_MEDIA_ENDPOINT: &str = "TERMI_MEDIA_ENDPOINT";
const ENV_MEDIA_BUCKET: &str = "TERMI_MEDIA_BUCKET";
const ENV_MEDIA_PUBLIC_BASE_URL: &str = "TERMI_MEDIA_PUBLIC_BASE_URL";
const ENV_MEDIA_ACCESS_KEY_ID: &str = "TERMI_MEDIA_ACCESS_KEY_ID";
const ENV_MEDIA_SECRET_ACCESS_KEY: &str = "TERMI_MEDIA_SECRET_ACCESS_KEY";
const ENV_R2_ACCOUNT_ID: &str = "TERMI_R2_ACCOUNT_ID";
const ENV_R2_BUCKET: &str = "TERMI_R2_BUCKET";
const ENV_R2_PUBLIC_BASE_URL: &str = "TERMI_R2_PUBLIC_BASE_URL";
const ENV_R2_ACCESS_KEY_ID: &str = "TERMI_R2_ACCESS_KEY_ID";
const ENV_R2_SECRET_ACCESS_KEY: &str = "TERMI_R2_SECRET_ACCESS_KEY";
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum MediaStorageProvider {
R2,
Minio,
}
#[derive(Clone, Debug)]
pub struct MediaStorageSettings {
pub provider: MediaStorageProvider,
pub provider_name: String,
pub endpoint: String,
pub bucket: String,
pub public_base_url: String,
pub access_key_id: String,
pub secret_access_key: String,
pub region: String,
pub force_path_style: bool,
}
#[derive(Clone, Debug)]
pub struct StoredObject {
pub key: String,
pub url: String,
}
#[derive(Clone, Debug)]
pub struct StoredObjectSummary {
pub key: String,
pub url: String,
pub size_bytes: i64,
pub last_modified: Option<String>,
}
fn trim_to_option(value: Option<String>) -> Option<String> {
value.and_then(|item| {
let trimmed = item.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
})
}
fn env_value(name: &str) -> Option<String> {
std::env::var(name)
.ok()
.and_then(|value| trim_to_option(Some(value)))
}
fn slugify_segment(value: &str) -> String {
let mut output = String::new();
let mut previous_dash = false;
for ch in value.chars() {
if ch.is_ascii_alphanumeric() {
output.push(ch.to_ascii_lowercase());
previous_dash = false;
} else if !previous_dash {
output.push('-');
previous_dash = true;
}
}
output.trim_matches('-').to_string()
}
fn normalize_public_base_url(value: String) -> String {
value.trim().trim_end_matches('/').to_string()
}
fn normalize_provider(value: Option<String>) -> MediaStorageProvider {
match value
.as_deref()
.map(str::trim)
.unwrap_or_default()
.to_ascii_lowercase()
.as_str()
{
"minio" => MediaStorageProvider::Minio,
_ => MediaStorageProvider::R2,
}
}
async fn load_settings_row(ctx: &AppContext) -> Result<Option<site_settings::Model>> {
site_settings::Entity::find()
.order_by_asc(site_settings::Column::Id)
.one(&ctx.db)
.await
.map_err(Into::into)
}
fn build_r2_endpoint(account_id: &str) -> String {
let trimmed = account_id.trim().trim_end_matches('/');
if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
trimmed.to_string()
} else {
format!("https://{trimmed}.r2.cloudflarestorage.com")
}
}
fn default_public_base_url_for_minio(endpoint: &str, bucket: &str) -> String {
format!(
"{}/{}",
endpoint.trim().trim_end_matches('/'),
bucket.trim().trim_start_matches('/')
)
}
pub fn generated_cover_directory() -> PathBuf {
let current_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let candidates = [
current_dir
.join("frontend")
.join("public")
.join("generated-covers"),
current_dir
.join("..")
.join("frontend")
.join("public")
.join("generated-covers"),
];
candidates
.into_iter()
.find(|path| path.parent().map(|parent| parent.exists()).unwrap_or(false))
.unwrap_or_else(|| {
PathBuf::from("..")
.join("frontend")
.join("public")
.join("generated-covers")
})
}
pub fn file_extension_for_path(path: &Path) -> &str {
path.extension()
.and_then(|ext| ext.to_str())
.map(str::trim)
.filter(|ext| !ext.is_empty())
.unwrap_or("png")
}
pub async fn optional_r2_settings(ctx: &AppContext) -> Result<Option<MediaStorageSettings>> {
let row = load_settings_row(ctx).await?;
let provider_raw = row
.as_ref()
.and_then(|item| trim_to_option(item.media_storage_provider.clone()))
.or_else(|| env_value(ENV_MEDIA_PROVIDER));
let provider = normalize_provider(provider_raw.clone());
let endpoint_or_account = row
.as_ref()
.and_then(|item| trim_to_option(item.media_r2_account_id.clone()))
.or_else(|| env_value(ENV_MEDIA_ENDPOINT))
.or_else(|| env_value(ENV_R2_ACCOUNT_ID));
let bucket = row
.as_ref()
.and_then(|item| trim_to_option(item.media_r2_bucket.clone()))
.or_else(|| env_value(ENV_MEDIA_BUCKET))
.or_else(|| env_value(ENV_R2_BUCKET));
let access_key_id = row
.as_ref()
.and_then(|item| trim_to_option(item.media_r2_access_key_id.clone()))
.or_else(|| env_value(ENV_MEDIA_ACCESS_KEY_ID))
.or_else(|| env_value(ENV_R2_ACCESS_KEY_ID));
let secret_access_key = row
.as_ref()
.and_then(|item| trim_to_option(item.media_r2_secret_access_key.clone()))
.or_else(|| env_value(ENV_MEDIA_SECRET_ACCESS_KEY))
.or_else(|| env_value(ENV_R2_SECRET_ACCESS_KEY));
let public_base_url = row
.as_ref()
.and_then(|item| trim_to_option(item.media_r2_public_base_url.clone()))
.or_else(|| env_value(ENV_MEDIA_PUBLIC_BASE_URL))
.or_else(|| env_value(ENV_R2_PUBLIC_BASE_URL))
.or_else(
|| match (&provider, endpoint_or_account.as_deref(), bucket.as_deref()) {
(MediaStorageProvider::Minio, Some(endpoint), Some(bucket)) => {
Some(default_public_base_url_for_minio(endpoint, bucket))
}
_ => None,
},
);
let has_any = endpoint_or_account.is_some()
|| bucket.is_some()
|| public_base_url.is_some()
|| access_key_id.is_some()
|| secret_access_key.is_some()
|| provider_raw.is_some();
if !has_any {
return Ok(None);
}
let missing = [
(
match provider {
MediaStorageProvider::Minio => "Endpoint",
MediaStorageProvider::R2 => "Account ID",
},
endpoint_or_account.is_some(),
),
("Bucket", bucket.is_some()),
("Public Base URL", public_base_url.is_some()),
("Access Key ID", access_key_id.is_some()),
("Secret Access Key", secret_access_key.is_some()),
]
.into_iter()
.filter_map(|(label, present)| (!present).then_some(label))
.collect::<Vec<_>>();
if !missing.is_empty() {
return Err(Error::BadRequest(format!(
"对象存储配置不完整,请补齐:{}",
missing.join(" / ")
)));
}
let endpoint = match provider {
MediaStorageProvider::R2 => {
build_r2_endpoint(&endpoint_or_account.clone().unwrap_or_default())
}
MediaStorageProvider::Minio => endpoint_or_account.clone().unwrap_or_default(),
};
Ok(Some(MediaStorageSettings {
provider: provider.clone(),
provider_name: match provider {
MediaStorageProvider::R2 => "r2".to_string(),
MediaStorageProvider::Minio => "minio".to_string(),
},
endpoint,
bucket: bucket.unwrap_or_default(),
public_base_url: normalize_public_base_url(public_base_url.unwrap_or_default()),
access_key_id: access_key_id.unwrap_or_default(),
secret_access_key: secret_access_key.unwrap_or_default(),
region: match provider {
MediaStorageProvider::R2 => "auto".to_string(),
MediaStorageProvider::Minio => "us-east-1".to_string(),
},
force_path_style: provider == MediaStorageProvider::Minio,
}))
}
pub async fn require_r2_settings(ctx: &AppContext) -> Result<MediaStorageSettings> {
optional_r2_settings(ctx)
.await?
.ok_or_else(|| Error::BadRequest("请先在后台配置媒体对象存储".to_string()))
}
async fn r2_client(settings: &MediaStorageSettings) -> Client {
let shared_config = aws_config::defaults(BehaviorVersion::latest())
.endpoint_url(settings.endpoint.clone())
.credentials_provider(Credentials::new(
settings.access_key_id.clone(),
settings.secret_access_key.clone(),
None,
None,
match settings.provider {
MediaStorageProvider::R2 => "r2",
MediaStorageProvider::Minio => "minio",
},
))
.region(aws_sdk_s3::config::Region::new(settings.region.clone()))
.load()
.await;
let conf = aws_sdk_s3::config::Builder::from(&shared_config)
.force_path_style(settings.force_path_style)
.build();
Client::from_conf(conf)
}
fn build_public_url(settings: &MediaStorageSettings, key: &str) -> String {
format!(
"{}/{}",
settings.public_base_url,
key.trim_start_matches('/')
)
}
pub fn object_key_from_public_url(settings: &MediaStorageSettings, url: &str) -> Option<String> {
let normalized_base = settings.public_base_url.trim().trim_end_matches('/');
let normalized_url = url.trim();
if normalized_base.is_empty() || normalized_url.is_empty() {
return None;
}
normalized_url
.strip_prefix(normalized_base)
.map(|suffix| suffix.trim_start_matches('/').to_string())
.filter(|suffix| !suffix.is_empty())
}
pub fn build_object_key(prefix: &str, stem: &str, extension: &str) -> String {
let safe_prefix = prefix.trim_matches('/');
let safe_stem = slugify_segment(stem);
let safe_extension = extension
.trim()
.trim_start_matches('.')
.to_ascii_lowercase();
let object_name = format!(
"{}-{}.{}",
if safe_stem.is_empty() {
"asset".to_string()
} else {
safe_stem
},
Uuid::new_v4().simple(),
if safe_extension.is_empty() {
"bin".to_string()
} else {
safe_extension
},
);
if safe_prefix.is_empty() {
object_name
} else {
format!("{safe_prefix}/{object_name}")
}
}
async fn ensure_bucket_exists(client: &Client, settings: &MediaStorageSettings) -> Result<()> {
if client
.head_bucket()
.bucket(&settings.bucket)
.send()
.await
.is_ok()
{
return Ok(());
}
if settings.provider != MediaStorageProvider::Minio {
return Err(Error::BadRequest(format!(
"对象存储 bucket 不存在或不可访问:{}",
settings.bucket
)));
}
client
.create_bucket()
.bucket(&settings.bucket)
.send()
.await
.map_err(|error| Error::BadRequest(format!("自动创建 MinIO bucket 失败: {error}")))?;
Ok(())
}
pub async fn upload_bytes_to_r2(
ctx: &AppContext,
key: &str,
bytes: Vec<u8>,
content_type: Option<&str>,
cache_control: Option<&str>,
) -> Result<StoredObject> {
let settings = require_r2_settings(ctx).await?;
let client = r2_client(&settings).await;
ensure_bucket_exists(&client, &settings).await?;
let mut request = client
.put_object()
.bucket(&settings.bucket)
.key(key)
.body(ByteStream::from(bytes));
if let Some(content_type) = content_type
.map(str::trim)
.filter(|value| !value.is_empty())
{
request = request.content_type(content_type);
}
if let Some(cache_control) = cache_control
.map(str::trim)
.filter(|value| !value.is_empty())
{
request = request.cache_control(cache_control);
}
request
.send()
.await
.map_err(|error| Error::BadRequest(format!("上传文件到对象存储失败: {error}")))?;
Ok(StoredObject {
key: key.to_string(),
url: build_public_url(&settings, key),
})
}
pub async fn delete_object(ctx: &AppContext, key: &str) -> Result<()> {
let settings = require_r2_settings(ctx).await?;
let client = r2_client(&settings).await;
client
.delete_object()
.bucket(&settings.bucket)
.key(key)
.send()
.await
.map_err(|error| Error::BadRequest(format!("删除对象存储文件失败: {error}")))?;
Ok(())
}
pub async fn delete_managed_url(ctx: &AppContext, url: &str) -> Result<bool> {
let Some(settings) = optional_r2_settings(ctx).await? else {
return Ok(false);
};
let Some(key) = object_key_from_public_url(&settings, url) else {
return Ok(false);
};
let client = r2_client(&settings).await;
client
.delete_object()
.bucket(&settings.bucket)
.key(&key)
.send()
.await
.map_err(|error| Error::BadRequest(format!("删除对象存储文件失败: {error}")))?;
Ok(true)
}
pub async fn list_objects(
ctx: &AppContext,
prefix: Option<&str>,
limit: i32,
) -> Result<Vec<StoredObjectSummary>> {
let settings = require_r2_settings(ctx).await?;
let client = r2_client(&settings).await;
ensure_bucket_exists(&client, &settings).await?;
let mut request = client
.list_objects_v2()
.bucket(&settings.bucket)
.max_keys(limit.clamp(1, 1000));
if let Some(prefix) = prefix.map(str::trim).filter(|value| !value.is_empty()) {
request = request.prefix(prefix);
}
let result = request
.send()
.await
.map_err(|error| Error::BadRequest(format!("读取对象存储列表失败: {error}")))?;
Ok(result
.contents()
.iter()
.filter_map(|item| {
let key = item.key()?.to_string();
Some(StoredObjectSummary {
url: build_public_url(&settings, &key),
key,
size_bytes: item.size().unwrap_or_default(),
last_modified: item.last_modified().map(|ts| format!("{ts:?}")),
})
})
.collect())
}
pub async fn test_r2_connectivity(ctx: &AppContext) -> Result<String> {
let settings = require_r2_settings(ctx).await?;
let client = r2_client(&settings).await;
ensure_bucket_exists(&client, &settings).await?;
let healthcheck_key = format!(".healthchecks/{}.txt", Uuid::new_v4().simple());
client
.put_object()
.bucket(&settings.bucket)
.key(&healthcheck_key)
.body(ByteStream::from_static(b"termi-storage-ok"))
.content_type("text/plain")
.send()
.await
.map_err(|error| Error::BadRequest(format!("对象存储连接测试失败: {error}")))?;
client
.delete_object()
.bucket(&settings.bucket)
.key(&healthcheck_key)
.send()
.await
.map_err(|error| Error::BadRequest(format!("对象存储清理测试文件失败: {error}")))?;
Ok(settings.bucket)
}

View File

@@ -868,7 +868,9 @@ const currentNavLabel =
renderSearchResults(query, [], 'loading'); renderSearchResults(query, [], 'loading');
try { try {
const response = await fetch(`${searchApiBase}/search?q=${encodeURIComponent(query)}&limit=6`); const response = await fetch(
`${searchApiBase}/search?q=${encodeURIComponent(query)}&limit=6&preview=true`
);
if (!response.ok) { if (!response.ok) {
throw new Error(`Search failed: ${response.status}`); throw new Error(`Search failed: ${response.status}`);
} }

View File

@@ -5,21 +5,25 @@ import Footer from '../components/Footer.astro';
import BackToTop from '../components/interactive/BackToTop.svelte'; import BackToTop from '../components/interactive/BackToTop.svelte';
import { api, DEFAULT_SITE_SETTINGS } from '../lib/api/client'; import { api, DEFAULT_SITE_SETTINGS } from '../lib/api/client';
import { getI18n, LOCALE_COOKIE_NAME, SUPPORTED_LOCALES } from '../lib/i18n'; import { getI18n, LOCALE_COOKIE_NAME, SUPPORTED_LOCALES } from '../lib/i18n';
import type { SiteSettings } from '../lib/types';
interface Props { interface Props {
title?: string; title?: string;
description?: string; description?: string;
siteSettings?: SiteSettings;
} }
const props = Astro.props; const props = Astro.props;
const { locale, messages } = getI18n(Astro); const { locale, messages } = getI18n(Astro);
let siteSettings = DEFAULT_SITE_SETTINGS; let siteSettings = props.siteSettings ?? DEFAULT_SITE_SETTINGS;
try { if (!props.siteSettings) {
siteSettings = await api.getSiteSettings(); try {
} catch (error) { siteSettings = await api.getSiteSettings();
console.error('Failed to load site settings:', error); } catch (error) {
console.error('Failed to load site settings:', error);
}
} }
const title = props.title || siteSettings.siteTitle; const title = props.title || siteSettings.siteTitle;
@@ -305,7 +309,7 @@ const i18nPayload = JSON.stringify({ locale, messages });
</main> </main>
<Footer siteSettings={siteSettings} /> <Footer siteSettings={siteSettings} />
<BackToTop client:load /> <BackToTop client:idle />
</div> </div>
</body> </body>
</html> </html>

View File

@@ -44,7 +44,11 @@ try {
const ownerInitial = siteSettings.ownerName.charAt(0) || 'T'; const ownerInitial = siteSettings.ownerName.charAt(0) || 'T';
--- ---
<BaseLayout title={`${t('about.pageTitle')} - ${siteSettings.siteShortName}`} description={siteSettings.siteDescription}> <BaseLayout
title={`${t('about.pageTitle')} - ${siteSettings.siteShortName}`}
description={siteSettings.siteDescription}
siteSettings={siteSettings}
>
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<TerminalWindow title="~/about" class="w-full"> <TerminalWindow title="~/about" class="w-full">
<div class="mb-6 px-4"> <div class="mb-6 px-4">

View File

@@ -29,16 +29,21 @@ const { slug } = Astro.params;
let post = null; let post = null;
let siteSettings = DEFAULT_SITE_SETTINGS; let siteSettings = DEFAULT_SITE_SETTINGS;
try { const [postResult, siteSettingsResult] = await Promise.allSettled([
post = await apiClient.getPostBySlug(slug ?? ''); apiClient.getPostBySlug(slug ?? ''),
} catch (error) { apiClient.getSiteSettings(),
console.error('API Error:', error); ]);
if (postResult.status === 'fulfilled') {
post = postResult.value;
} else {
console.error('API Error:', postResult.reason);
} }
try { if (siteSettingsResult.status === 'fulfilled') {
siteSettings = await apiClient.getSiteSettings(); siteSettings = siteSettingsResult.value;
} catch (error) { } else {
console.error('Site settings API Error:', error); console.error('Site settings API Error:', siteSettingsResult.reason);
} }
if (!post) { if (!post) {
@@ -59,7 +64,7 @@ const markdownProcessor = await createMarkdownProcessor();
const renderedContent = await markdownProcessor.render(articleMarkdown); const renderedContent = await markdownProcessor.render(articleMarkdown);
--- ---
<BaseLayout title={post.title} description={post.description}> <BaseLayout title={post.title} description={post.description} siteSettings={siteSettings}>
<ReadingProgress /> <ReadingProgress />
<BackToTop /> <BackToTop />
<Lightbox /> <Lightbox />

View File

@@ -19,9 +19,14 @@ const selectedSearch = url.searchParams.get('search') || '';
const { t } = getI18n(Astro); const { t } = getI18n(Astro);
try { try {
allPosts = selectedSearch ? await api.searchPosts(selectedSearch) : await api.getPosts(); const [posts, categories, rawTags] = await Promise.all([
allCategories = await api.getCategories(); selectedSearch ? api.searchPosts(selectedSearch) : api.getPosts(),
const rawTags = await api.getTags(); api.getCategories(),
api.getTags(),
]);
allPosts = posts;
allCategories = categories;
const seenTagIds = new Set<string>(); const seenTagIds = new Set<string>();
allTags = rawTags.filter(tag => { allTags = rawTags.filter(tag => {
const key = `${tag.slug}:${tag.name}`.toLowerCase(); const key = `${tag.slug}:${tag.name}`.toLowerCase();

View File

@@ -29,7 +29,11 @@ const sampleQuestions = [
]; ];
--- ---
<BaseLayout title={`${t('ask.pageTitle')} | ${siteSettings.siteShortName}`} description={t('ask.pageDescription', { siteName: siteSettings.siteName })}> <BaseLayout
title={`${t('ask.pageTitle')} | ${siteSettings.siteShortName}`}
description={t('ask.pageDescription', { siteName: siteSettings.siteName })}
siteSettings={siteSettings}
>
<section class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-12"> <section class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div class="rounded-3xl border border-[var(--border-color)] bg-[var(--terminal-bg)] shadow-[0_28px_90px_rgba(15,23,42,0.08)] overflow-hidden"> <div class="rounded-3xl border border-[var(--border-color)] bg-[var(--terminal-bg)] shadow-[0_28px_90px_rgba(15,23,42,0.08)] overflow-hidden">
<div class="flex items-center justify-between gap-4 border-b border-[var(--border-color)] px-5 py-4"> <div class="flex items-center justify-between gap-4 border-b border-[var(--border-color)] px-5 py-4">

View File

@@ -37,8 +37,20 @@ let apiError: string | null = null;
const { locale, t } = getI18n(Astro); const { locale, t } = getI18n(Astro);
try { try {
siteSettings = await api.getSiteSettings(); const [settings, posts, rawTags, rawFriendLinks, nextCategories] = await Promise.all([
allPosts = await api.getPosts(); api.getSiteSettings(),
api.getPosts(),
api.getTags(),
api.getFriendLinks(),
api.getCategories(),
]);
siteSettings = settings;
allPosts = posts;
tags = rawTags.map(tag => tag.name);
friendLinks = rawFriendLinks.filter(friend => friend.status === 'approved');
categories = nextCategories;
const filteredPosts = allPosts.filter(post => { const filteredPosts = allPosts.filter(post => {
const normalizedCategory = post.category?.trim().toLowerCase() || ''; const normalizedCategory = post.category?.trim().toLowerCase() || '';
if (selectedType !== 'all' && post.type !== selectedType) return false; if (selectedType !== 'all' && post.type !== selectedType) return false;
@@ -50,9 +62,6 @@ try {
recentPosts = filteredPosts.slice(0, previewLimit); recentPosts = filteredPosts.slice(0, previewLimit);
filteredPostsCount = filteredPosts.length; filteredPostsCount = filteredPosts.length;
pinnedPost = allPosts.find(post => post.pinned) || null; pinnedPost = allPosts.find(post => post.pinned) || null;
tags = (await api.getTags()).map(tag => tag.name);
friendLinks = (await api.getFriendLinks()).filter(friend => friend.status === 'approved');
categories = await api.getCategories();
} catch (error) { } catch (error) {
apiError = error instanceof Error ? error.message : t('common.apiUnavailable'); apiError = error instanceof Error ? error.message : t('common.apiUnavailable');
console.error('API Error:', error); console.error('API Error:', error);
@@ -146,7 +155,7 @@ const navLinks = [
]; ];
--- ---
<BaseLayout title={siteSettings.siteTitle} description={siteSettings.siteDescription}> <BaseLayout title={siteSettings.siteTitle} description={siteSettings.siteDescription} siteSettings={siteSettings}>
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> <div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<TerminalWindow title={terminalConfig.title} class="w-full"> <TerminalWindow title={terminalConfig.title} class="w-full">
<div class="mb-5 px-4 overflow-x-auto"> <div class="mb-5 px-4 overflow-x-auto">