|
@@ -1,212 +1,58 @@
|
|
|
-import { useState, useEffect } from 'react'
|
|
|
|
|
-import { useQuery, useQueryClient } from '@tanstack/react-query'
|
|
|
|
|
-import { Save } from 'lucide-react'
|
|
|
|
|
|
|
+import { useState } from 'react'
|
|
|
|
|
+import { useQuery } from '@tanstack/react-query'
|
|
|
import { api } from '@/api/client'
|
|
import { api } from '@/api/client'
|
|
|
-import { useToastStore } from '@/stores/toastStore'
|
|
|
|
|
-import { ApiError } from '@/types'
|
|
|
|
|
-import type { SettingsView } from '@/types'
|
|
|
|
|
-
|
|
|
|
|
-// ─── field helpers ────────────────────────────────────────────────────────────
|
|
|
|
|
-
|
|
|
|
|
-const inputCls =
|
|
|
|
|
- 'w-full rounded-md bg-slate-800 border border-slate-600 text-slate-100 px-3 py-2 text-sm placeholder-slate-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/40'
|
|
|
|
|
-
|
|
|
|
|
-function FormRow({
|
|
|
|
|
- label,
|
|
|
|
|
- hint,
|
|
|
|
|
- children,
|
|
|
|
|
-}: {
|
|
|
|
|
|
|
+import { useAuthStore } from '@/stores/authStore'
|
|
|
|
|
+import EmbeddingsTab from '@/pages/settings/EmbeddingsTab'
|
|
|
|
|
+import CacheTab from '@/pages/settings/CacheTab'
|
|
|
|
|
+import AccessTab from '@/pages/settings/AccessTab'
|
|
|
|
|
+import WebuiTab from '@/pages/settings/WebuiTab'
|
|
|
|
|
+import ProjectEmbeddingsTab from '@/pages/settings/ProjectEmbeddingsTab'
|
|
|
|
|
+
|
|
|
|
|
+interface TabDef {
|
|
|
|
|
+ id: string
|
|
|
label: string
|
|
label: string
|
|
|
- hint?: string
|
|
|
|
|
- children: React.ReactNode
|
|
|
|
|
-}) {
|
|
|
|
|
- return (
|
|
|
|
|
- <div className="flex flex-col gap-1">
|
|
|
|
|
- <label className="text-xs font-medium text-slate-400">{label}</label>
|
|
|
|
|
- {children}
|
|
|
|
|
- {hint && <p className="text-xs text-slate-500">{hint}</p>}
|
|
|
|
|
- </div>
|
|
|
|
|
- )
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-// ─── form state ───────────────────────────────────────────────────────────────
|
|
|
|
|
-
|
|
|
|
|
-interface FormValues {
|
|
|
|
|
- openai_api_base: string
|
|
|
|
|
- openai_api_key: string // blank = don't update
|
|
|
|
|
- default_embedding_model: string
|
|
|
|
|
- cors_origins: string // comma-separated in the input
|
|
|
|
|
- session_ttl_minutes: string // string for controlled input
|
|
|
|
|
- webui_enabled: boolean
|
|
|
|
|
- default_project: string
|
|
|
|
|
- embedding_connect_timeout_sec: string
|
|
|
|
|
- embedding_read_timeout_sec: string
|
|
|
|
|
- embedding_cache_size: string
|
|
|
|
|
- embedding_cache_ttl_sec: string
|
|
|
|
|
- embedding_cache_max_bytes: string
|
|
|
|
|
- embedding_cache_normalize: boolean
|
|
|
|
|
- embedding_client_pool_size: string
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-function settingsToForm(s: SettingsView): FormValues {
|
|
|
|
|
- return {
|
|
|
|
|
- openai_api_base: s.openai_api_base,
|
|
|
|
|
- openai_api_key: '', // never prefill a secret
|
|
|
|
|
- default_embedding_model: s.default_embedding_model,
|
|
|
|
|
- cors_origins: s.cors_origins.join(', '),
|
|
|
|
|
- session_ttl_minutes: String(s.session_ttl_minutes),
|
|
|
|
|
- webui_enabled: s.webui_enabled,
|
|
|
|
|
- default_project: s.default_project,
|
|
|
|
|
- embedding_connect_timeout_sec: String(s.embedding_connect_timeout_sec),
|
|
|
|
|
- embedding_read_timeout_sec: String(s.embedding_read_timeout_sec),
|
|
|
|
|
- embedding_cache_size: String(s.embedding_cache_size),
|
|
|
|
|
- embedding_cache_ttl_sec: String(s.embedding_cache_ttl_sec),
|
|
|
|
|
- embedding_cache_max_bytes: String(s.embedding_cache_max_bytes),
|
|
|
|
|
- embedding_cache_normalize: s.embedding_cache_normalize,
|
|
|
|
|
- embedding_client_pool_size: String(s.embedding_client_pool_size),
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ adminOnly: boolean
|
|
|
|
|
+ render: () => React.ReactNode
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// ─── page ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
-
|
|
|
|
|
export default function Settings() {
|
|
export default function Settings() {
|
|
|
- const push = useToastStore((s) => s.push)
|
|
|
|
|
- const queryClient = useQueryClient()
|
|
|
|
|
-
|
|
|
|
|
- const { data, isLoading, isError, error } = useQuery({
|
|
|
|
|
|
|
+ const admin = useAuthStore((s) => s.admin)
|
|
|
|
|
+ const project = useAuthStore((s) => s.currentProject)
|
|
|
|
|
+
|
|
|
|
|
+ // Global settings are admin-only; the project tab is not, so a project
|
|
|
|
|
+ // manager landing here sees exactly the one tab they may use.
|
|
|
|
|
+ const tabs: TabDef[] = [
|
|
|
|
|
+ { id: 'embeddings', label: 'Embeddings', adminOnly: true, render: () => <EmbeddingsTab /> },
|
|
|
|
|
+ { id: 'cache', label: 'Cache & performance', adminOnly: true, render: () => <CacheTab /> },
|
|
|
|
|
+ { id: 'access', label: 'Access & security', adminOnly: true, render: () => <AccessTab /> },
|
|
|
|
|
+ { id: 'webui', label: 'Web UI', adminOnly: true, render: () => <WebuiTab /> },
|
|
|
|
|
+ {
|
|
|
|
|
+ id: 'project',
|
|
|
|
|
+ label: project ? `Project: ${project}` : 'Project',
|
|
|
|
|
+ adminOnly: false,
|
|
|
|
|
+ render: () => <ProjectEmbeddingsTab />,
|
|
|
|
|
+ },
|
|
|
|
|
+ ].filter((t) => admin || !t.adminOnly)
|
|
|
|
|
+
|
|
|
|
|
+ const [active, setActive] = useState(tabs[0]?.id ?? 'project')
|
|
|
|
|
+ const current = tabs.find((t) => t.id === active) ?? tabs[0]
|
|
|
|
|
+
|
|
|
|
|
+ // Only used to surface a load failure once, at the page level.
|
|
|
|
|
+ const { isError, error } = useQuery({
|
|
|
queryKey: ['settings'],
|
|
queryKey: ['settings'],
|
|
|
queryFn: () => api.getSettings(),
|
|
queryFn: () => api.getSettings(),
|
|
|
|
|
+ enabled: admin,
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
- const [form, setForm] = useState<FormValues>({
|
|
|
|
|
- openai_api_base: '',
|
|
|
|
|
- openai_api_key: '',
|
|
|
|
|
- default_embedding_model: '',
|
|
|
|
|
- cors_origins: '',
|
|
|
|
|
- session_ttl_minutes: '60',
|
|
|
|
|
- webui_enabled: true,
|
|
|
|
|
- default_project: 'default',
|
|
|
|
|
- embedding_connect_timeout_sec: '10',
|
|
|
|
|
- embedding_read_timeout_sec: '60',
|
|
|
|
|
- embedding_cache_size: '4096',
|
|
|
|
|
- embedding_cache_ttl_sec: '86400',
|
|
|
|
|
- embedding_cache_max_bytes: '268435456',
|
|
|
|
|
- embedding_cache_normalize: false,
|
|
|
|
|
- embedding_client_pool_size: '4',
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- // Populate the form once settings load, but don't overwrite user's edits on re-render.
|
|
|
|
|
- const [populated, setPopulated] = useState(false)
|
|
|
|
|
- useEffect(() => {
|
|
|
|
|
- if (data && !populated) {
|
|
|
|
|
- setForm(settingsToForm(data))
|
|
|
|
|
- setPopulated(true)
|
|
|
|
|
- }
|
|
|
|
|
- }, [data, populated])
|
|
|
|
|
-
|
|
|
|
|
- const [saving, setSaving] = useState(false)
|
|
|
|
|
-
|
|
|
|
|
- function setField<K extends keyof FormValues>(key: K, value: FormValues[K]) {
|
|
|
|
|
- setForm((prev) => ({ ...prev, [key]: value }))
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- const handleSubmit = async (e: React.FormEvent) => {
|
|
|
|
|
- e.preventDefault()
|
|
|
|
|
-
|
|
|
|
|
- const ttl = parseInt(form.session_ttl_minutes, 10)
|
|
|
|
|
- if (isNaN(ttl) || ttl <= 0) {
|
|
|
|
|
- push('Session TTL must be a positive integer.', 'error')
|
|
|
|
|
- return
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- const connectTimeout = parseInt(form.embedding_connect_timeout_sec, 10)
|
|
|
|
|
- if (isNaN(connectTimeout) || connectTimeout < 1) {
|
|
|
|
|
- push('Embedding connect timeout must be a positive integer.', 'error')
|
|
|
|
|
- return
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- const readTimeout = parseInt(form.embedding_read_timeout_sec, 10)
|
|
|
|
|
- if (isNaN(readTimeout) || readTimeout < 1) {
|
|
|
|
|
- push('Embedding read timeout must be a positive integer.', 'error')
|
|
|
|
|
- return
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- const cacheSize = parseInt(form.embedding_cache_size, 10)
|
|
|
|
|
- if (isNaN(cacheSize) || cacheSize < 1) {
|
|
|
|
|
- push('Embedding cache size must be a positive integer.', 'error')
|
|
|
|
|
- return
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- const cacheTtl = parseInt(form.embedding_cache_ttl_sec, 10)
|
|
|
|
|
- if (isNaN(cacheTtl) || cacheTtl < 0) {
|
|
|
|
|
- push('Embedding cache TTL must be a non-negative integer (0 = never expire).', 'error')
|
|
|
|
|
- return
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- const cacheMaxBytes = parseInt(form.embedding_cache_max_bytes, 10)
|
|
|
|
|
- if (isNaN(cacheMaxBytes) || cacheMaxBytes < 1) {
|
|
|
|
|
- push('Embedding cache max bytes must be a positive integer.', 'error')
|
|
|
|
|
- return
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- const poolSize = parseInt(form.embedding_client_pool_size, 10)
|
|
|
|
|
- if (isNaN(poolSize) || poolSize < 1) {
|
|
|
|
|
- push('Embedding client pool size must be a positive integer.', 'error')
|
|
|
|
|
- return
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- const patch: Partial<Omit<SettingsView, 'openai_api_key_set'> & { openai_api_key?: string }> = {
|
|
|
|
|
- openai_api_base: form.openai_api_base.trim(),
|
|
|
|
|
- default_embedding_model: form.default_embedding_model.trim(),
|
|
|
|
|
- cors_origins: form.cors_origins
|
|
|
|
|
- .split(',')
|
|
|
|
|
- .map((s) => s.trim())
|
|
|
|
|
- .filter(Boolean),
|
|
|
|
|
- session_ttl_minutes: ttl,
|
|
|
|
|
- webui_enabled: form.webui_enabled,
|
|
|
|
|
- default_project: form.default_project.trim(),
|
|
|
|
|
- embedding_connect_timeout_sec: connectTimeout,
|
|
|
|
|
- embedding_read_timeout_sec: readTimeout,
|
|
|
|
|
- embedding_cache_size: cacheSize,
|
|
|
|
|
- embedding_cache_ttl_sec: cacheTtl,
|
|
|
|
|
- embedding_cache_max_bytes: cacheMaxBytes,
|
|
|
|
|
- embedding_cache_normalize: form.embedding_cache_normalize,
|
|
|
|
|
- embedding_client_pool_size: poolSize,
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // Only include the key if the user typed something
|
|
|
|
|
- if (form.openai_api_key.trim()) {
|
|
|
|
|
- patch.openai_api_key = form.openai_api_key.trim()
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- setSaving(true)
|
|
|
|
|
- try {
|
|
|
|
|
- await api.updateSettings(patch)
|
|
|
|
|
- await queryClient.invalidateQueries({ queryKey: ['settings'] })
|
|
|
|
|
- // Reset populated so the form re-syncs from the refreshed data
|
|
|
|
|
- setPopulated(false)
|
|
|
|
|
- // Clear the key field after a successful save
|
|
|
|
|
- setForm((prev) => ({ ...prev, openai_api_key: '' }))
|
|
|
|
|
- push('Settings saved. Changes apply immediately (hot reload).', 'success')
|
|
|
|
|
- } catch (err) {
|
|
|
|
|
- const msg = err instanceof ApiError ? err.message : String(err)
|
|
|
|
|
- push(`Failed to save settings: ${msg}`, 'error')
|
|
|
|
|
- } finally {
|
|
|
|
|
- setSaving(false)
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
return (
|
|
return (
|
|
|
- <div className="flex flex-col gap-6 max-w-2xl">
|
|
|
|
|
- {/* Page header */}
|
|
|
|
|
|
|
+ <div className="flex flex-col gap-6 max-w-3xl">
|
|
|
<div>
|
|
<div>
|
|
|
<h1 className="text-2xl font-semibold text-slate-100">Settings</h1>
|
|
<h1 className="text-2xl font-semibold text-slate-100">Settings</h1>
|
|
|
<p className="text-slate-400 text-sm mt-1">
|
|
<p className="text-slate-400 text-sm mt-1">
|
|
|
- Global settings — changes apply immediately (hot reload).
|
|
|
|
|
|
|
+ Changes apply immediately (hot reload). Each tab saves on its own.
|
|
|
</p>
|
|
</p>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- {/* Error */}
|
|
|
|
|
{isError && (
|
|
{isError && (
|
|
|
<div className="rounded-xl border border-red-500/20 bg-red-500/5 px-6 py-4">
|
|
<div className="rounded-xl border border-red-500/20 bg-red-500/5 px-6 py-4">
|
|
|
<p className="text-red-400 text-sm">
|
|
<p className="text-red-400 text-sm">
|
|
@@ -215,272 +61,27 @@ export default function Settings() {
|
|
|
</div>
|
|
</div>
|
|
|
)}
|
|
)}
|
|
|
|
|
|
|
|
- {/* Loading skeleton */}
|
|
|
|
|
- {isLoading && (
|
|
|
|
|
- <div className="flex flex-col gap-4 animate-pulse">
|
|
|
|
|
- {[1, 2, 3, 4, 5, 6].map((i) => (
|
|
|
|
|
- <div key={i} className="flex flex-col gap-1">
|
|
|
|
|
- <div className="h-3 w-24 rounded bg-slate-700/60" />
|
|
|
|
|
- <div className="h-9 rounded-md bg-slate-800/60" />
|
|
|
|
|
- </div>
|
|
|
|
|
- ))}
|
|
|
|
|
- </div>
|
|
|
|
|
- )}
|
|
|
|
|
-
|
|
|
|
|
- {/* Form */}
|
|
|
|
|
- {!isLoading && (
|
|
|
|
|
- <form onSubmit={handleSubmit} className="flex flex-col gap-5">
|
|
|
|
|
- {/* OpenAI API base */}
|
|
|
|
|
- <FormRow
|
|
|
|
|
- label="OpenAI API Base"
|
|
|
|
|
- hint="Base URL for the OpenAI-compatible embeddings endpoint."
|
|
|
|
|
- >
|
|
|
|
|
- <input
|
|
|
|
|
- type="text"
|
|
|
|
|
- value={form.openai_api_base}
|
|
|
|
|
- onChange={(e) => setField('openai_api_base', e.target.value)}
|
|
|
|
|
- placeholder="https://api.openai.com/v1"
|
|
|
|
|
- className={inputCls}
|
|
|
|
|
- />
|
|
|
|
|
- </FormRow>
|
|
|
|
|
-
|
|
|
|
|
- {/* OpenAI API key */}
|
|
|
|
|
- <FormRow
|
|
|
|
|
- label="OpenAI API Key"
|
|
|
|
|
- hint={
|
|
|
|
|
- data?.openai_api_key_set
|
|
|
|
|
- ? 'A key is currently set. Leave blank to keep it unchanged.'
|
|
|
|
|
- : 'No key is currently set. Enter one to configure it.'
|
|
|
|
|
- }
|
|
|
|
|
- >
|
|
|
|
|
- <input
|
|
|
|
|
- type="password"
|
|
|
|
|
- value={form.openai_api_key}
|
|
|
|
|
- onChange={(e) => setField('openai_api_key', e.target.value)}
|
|
|
|
|
- placeholder={data?.openai_api_key_set ? 'set — leave blank to keep' : 'not set'}
|
|
|
|
|
- autoComplete="new-password"
|
|
|
|
|
- className={inputCls}
|
|
|
|
|
- />
|
|
|
|
|
- </FormRow>
|
|
|
|
|
-
|
|
|
|
|
- {/* Default embedding model */}
|
|
|
|
|
- <FormRow
|
|
|
|
|
- label="Default Embedding Model"
|
|
|
|
|
- hint="Used when a vector collection doesn't specify a model."
|
|
|
|
|
- >
|
|
|
|
|
- <input
|
|
|
|
|
- type="text"
|
|
|
|
|
- value={form.default_embedding_model}
|
|
|
|
|
- onChange={(e) => setField('default_embedding_model', e.target.value)}
|
|
|
|
|
- placeholder="text-embedding-3-small"
|
|
|
|
|
- className={inputCls}
|
|
|
|
|
- />
|
|
|
|
|
- </FormRow>
|
|
|
|
|
-
|
|
|
|
|
- {/* CORS origins */}
|
|
|
|
|
- <FormRow
|
|
|
|
|
- label="CORS Origins"
|
|
|
|
|
- hint="Comma-separated list of allowed origins (e.g. http://localhost:3000, https://app.example.com)."
|
|
|
|
|
- >
|
|
|
|
|
- <input
|
|
|
|
|
- type="text"
|
|
|
|
|
- value={form.cors_origins}
|
|
|
|
|
- onChange={(e) => setField('cors_origins', e.target.value)}
|
|
|
|
|
- placeholder="http://localhost:3000, https://app.example.com"
|
|
|
|
|
- className={inputCls}
|
|
|
|
|
- />
|
|
|
|
|
- </FormRow>
|
|
|
|
|
-
|
|
|
|
|
- {/* Session TTL */}
|
|
|
|
|
- <FormRow
|
|
|
|
|
- label="Session TTL (minutes)"
|
|
|
|
|
- hint="How long an API-key session cookie remains valid."
|
|
|
|
|
- >
|
|
|
|
|
- <input
|
|
|
|
|
- type="number"
|
|
|
|
|
- value={form.session_ttl_minutes}
|
|
|
|
|
- onChange={(e) => setField('session_ttl_minutes', e.target.value)}
|
|
|
|
|
- min={1}
|
|
|
|
|
- placeholder="60"
|
|
|
|
|
- className={inputCls}
|
|
|
|
|
- />
|
|
|
|
|
- </FormRow>
|
|
|
|
|
-
|
|
|
|
|
- {/* Default project */}
|
|
|
|
|
- <FormRow
|
|
|
|
|
- label="Default Project"
|
|
|
|
|
- hint="The project used when no project is specified."
|
|
|
|
|
- >
|
|
|
|
|
- <input
|
|
|
|
|
- type="text"
|
|
|
|
|
- value={form.default_project}
|
|
|
|
|
- onChange={(e) => setField('default_project', e.target.value)}
|
|
|
|
|
- placeholder="default"
|
|
|
|
|
- className={inputCls}
|
|
|
|
|
- />
|
|
|
|
|
- </FormRow>
|
|
|
|
|
-
|
|
|
|
|
- {/* WebUI enabled */}
|
|
|
|
|
- <FormRow label="WebUI Enabled" hint="Disable to turn off the built-in web interface.">
|
|
|
|
|
- <label className="flex items-center gap-3 cursor-pointer select-none w-fit">
|
|
|
|
|
- <div
|
|
|
|
|
- role="switch"
|
|
|
|
|
- aria-checked={form.webui_enabled}
|
|
|
|
|
- onClick={() => setField('webui_enabled', !form.webui_enabled)}
|
|
|
|
|
- className={[
|
|
|
|
|
- 'relative inline-flex h-6 w-11 shrink-0 rounded-full border-2 border-transparent transition-colors focus:outline-none',
|
|
|
|
|
- form.webui_enabled ? 'bg-indigo-600' : 'bg-slate-600',
|
|
|
|
|
- ].join(' ')}
|
|
|
|
|
- >
|
|
|
|
|
- <span
|
|
|
|
|
- className={[
|
|
|
|
|
- 'pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow ring-0 transition-transform',
|
|
|
|
|
- form.webui_enabled ? 'translate-x-5' : 'translate-x-0',
|
|
|
|
|
- ].join(' ')}
|
|
|
|
|
- />
|
|
|
|
|
- </div>
|
|
|
|
|
- <span className="text-sm text-slate-300">
|
|
|
|
|
- {form.webui_enabled ? 'Enabled' : 'Disabled'}
|
|
|
|
|
- </span>
|
|
|
|
|
- </label>
|
|
|
|
|
- </FormRow>
|
|
|
|
|
-
|
|
|
|
|
- {/* ── Embedding performance ─────────────────────────────────── */}
|
|
|
|
|
- <h2 className="text-sm font-semibold text-slate-300 border-t border-slate-700 pt-4 mt-1">
|
|
|
|
|
- Embedding performance
|
|
|
|
|
- </h2>
|
|
|
|
|
-
|
|
|
|
|
- {/* Embedding connect timeout */}
|
|
|
|
|
- <FormRow
|
|
|
|
|
- label="Embedding connect timeout (s)"
|
|
|
|
|
- hint="TCP connection timeout when reaching the embeddings endpoint."
|
|
|
|
|
|
|
+ <div className="flex flex-wrap gap-1 border-b border-slate-700">
|
|
|
|
|
+ {tabs.map((t) => (
|
|
|
|
|
+ <button
|
|
|
|
|
+ key={t.id}
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ onClick={() => setActive(t.id)}
|
|
|
|
|
+ className={[
|
|
|
|
|
+ 'px-4 py-2 text-sm font-medium border-b-2 -mb-px transition',
|
|
|
|
|
+ t.id === current?.id
|
|
|
|
|
+ ? 'border-indigo-500 text-slate-100'
|
|
|
|
|
+ : 'border-transparent text-slate-400 hover:text-slate-200',
|
|
|
|
|
+ ].join(' ')}
|
|
|
>
|
|
>
|
|
|
- <input
|
|
|
|
|
- type="number"
|
|
|
|
|
- value={form.embedding_connect_timeout_sec}
|
|
|
|
|
- onChange={(e) => setField('embedding_connect_timeout_sec', e.target.value)}
|
|
|
|
|
- min={1}
|
|
|
|
|
- placeholder="10"
|
|
|
|
|
- className={inputCls}
|
|
|
|
|
- />
|
|
|
|
|
- </FormRow>
|
|
|
|
|
-
|
|
|
|
|
- {/* Embedding read timeout */}
|
|
|
|
|
- <FormRow
|
|
|
|
|
- label="Embedding read timeout (s)"
|
|
|
|
|
- hint="Read timeout for each embeddings API response."
|
|
|
|
|
- >
|
|
|
|
|
- <input
|
|
|
|
|
- type="number"
|
|
|
|
|
- value={form.embedding_read_timeout_sec}
|
|
|
|
|
- onChange={(e) => setField('embedding_read_timeout_sec', e.target.value)}
|
|
|
|
|
- min={1}
|
|
|
|
|
- placeholder="60"
|
|
|
|
|
- className={inputCls}
|
|
|
|
|
- />
|
|
|
|
|
- </FormRow>
|
|
|
|
|
-
|
|
|
|
|
- {/* Embedding cache size */}
|
|
|
|
|
- <FormRow
|
|
|
|
|
- label="Embedding cache size (entries)"
|
|
|
|
|
- hint="Max distinct (model, text) embeddings cached. 0 disables via size, but use the toggle/TTL instead."
|
|
|
|
|
- >
|
|
|
|
|
- <input
|
|
|
|
|
- type="number"
|
|
|
|
|
- value={form.embedding_cache_size}
|
|
|
|
|
- onChange={(e) => setField('embedding_cache_size', e.target.value)}
|
|
|
|
|
- min={1}
|
|
|
|
|
- placeholder="4096"
|
|
|
|
|
- className={inputCls}
|
|
|
|
|
- />
|
|
|
|
|
- </FormRow>
|
|
|
|
|
-
|
|
|
|
|
- {/* Embedding cache TTL */}
|
|
|
|
|
- <FormRow
|
|
|
|
|
- label="Embedding cache TTL (s)"
|
|
|
|
|
- hint="0 = never expire."
|
|
|
|
|
- >
|
|
|
|
|
- <input
|
|
|
|
|
- type="number"
|
|
|
|
|
- value={form.embedding_cache_ttl_sec}
|
|
|
|
|
- onChange={(e) => setField('embedding_cache_ttl_sec', e.target.value)}
|
|
|
|
|
- min={0}
|
|
|
|
|
- placeholder="86400"
|
|
|
|
|
- className={inputCls}
|
|
|
|
|
- />
|
|
|
|
|
- </FormRow>
|
|
|
|
|
-
|
|
|
|
|
- {/* Embedding cache max bytes */}
|
|
|
|
|
- <FormRow
|
|
|
|
|
- label="Embedding cache max bytes"
|
|
|
|
|
- hint="Byte budget for cached vectors (e.g. 268435456 = 256 MB)."
|
|
|
|
|
- >
|
|
|
|
|
- <input
|
|
|
|
|
- type="number"
|
|
|
|
|
- value={form.embedding_cache_max_bytes}
|
|
|
|
|
- onChange={(e) => setField('embedding_cache_max_bytes', e.target.value)}
|
|
|
|
|
- min={1}
|
|
|
|
|
- placeholder="268435456"
|
|
|
|
|
- className={inputCls}
|
|
|
|
|
- />
|
|
|
|
|
- </FormRow>
|
|
|
|
|
-
|
|
|
|
|
- {/* Embedding client pool size */}
|
|
|
|
|
- <FormRow
|
|
|
|
|
- label="Embedding client pool size"
|
|
|
|
|
- hint="Keep-alive HTTP clients per upstream origin (concurrency)."
|
|
|
|
|
- >
|
|
|
|
|
- <input
|
|
|
|
|
- type="number"
|
|
|
|
|
- value={form.embedding_client_pool_size}
|
|
|
|
|
- onChange={(e) => setField('embedding_client_pool_size', e.target.value)}
|
|
|
|
|
- min={1}
|
|
|
|
|
- placeholder="4"
|
|
|
|
|
- className={inputCls}
|
|
|
|
|
- />
|
|
|
|
|
- </FormRow>
|
|
|
|
|
-
|
|
|
|
|
- {/* Normalize cache keys */}
|
|
|
|
|
- <FormRow
|
|
|
|
|
- label="Normalize cache keys"
|
|
|
|
|
- hint="Trim + lowercase query text before caching (collapses near-duplicate queries)."
|
|
|
|
|
- >
|
|
|
|
|
- <label className="flex items-center gap-3 cursor-pointer select-none w-fit">
|
|
|
|
|
- <div
|
|
|
|
|
- role="switch"
|
|
|
|
|
- aria-checked={form.embedding_cache_normalize}
|
|
|
|
|
- onClick={() => setField('embedding_cache_normalize', !form.embedding_cache_normalize)}
|
|
|
|
|
- className={[
|
|
|
|
|
- 'relative inline-flex h-6 w-11 shrink-0 rounded-full border-2 border-transparent transition-colors focus:outline-none',
|
|
|
|
|
- form.embedding_cache_normalize ? 'bg-indigo-600' : 'bg-slate-600',
|
|
|
|
|
- ].join(' ')}
|
|
|
|
|
- >
|
|
|
|
|
- <span
|
|
|
|
|
- className={[
|
|
|
|
|
- 'pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow ring-0 transition-transform',
|
|
|
|
|
- form.embedding_cache_normalize ? 'translate-x-5' : 'translate-x-0',
|
|
|
|
|
- ].join(' ')}
|
|
|
|
|
- />
|
|
|
|
|
- </div>
|
|
|
|
|
- <span className="text-sm text-slate-300">
|
|
|
|
|
- {form.embedding_cache_normalize ? 'Enabled' : 'Disabled'}
|
|
|
|
|
- </span>
|
|
|
|
|
- </label>
|
|
|
|
|
- </FormRow>
|
|
|
|
|
|
|
+ {t.label}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
|
|
|
- {/* Save */}
|
|
|
|
|
- <div className="pt-2">
|
|
|
|
|
- <button
|
|
|
|
|
- type="submit"
|
|
|
|
|
- disabled={saving}
|
|
|
|
|
- className="flex items-center gap-2 px-5 py-2 rounded-md text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-500 transition disabled:opacity-50"
|
|
|
|
|
- >
|
|
|
|
|
- <Save size={15} />
|
|
|
|
|
- {saving ? 'Saving…' : 'Save settings'}
|
|
|
|
|
- </button>
|
|
|
|
|
- </div>
|
|
|
|
|
- </form>
|
|
|
|
|
- )}
|
|
|
|
|
|
|
+ <div className="rounded-xl border border-slate-700 bg-slate-800/30 px-6 py-5">
|
|
|
|
|
+ {current?.render()}
|
|
|
|
|
+ </div>
|
|
|
</div>
|
|
</div>
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|