The mechanism (context surface registry, persona storage + hooks, tool
parser/dispatcher) is now generic and lives in @crema/aifirst-ui/{context,
agents,tools}. This template keeps only the arcadia-shaped configuration:
- agents.ts — owns DEFAULT_AGENTS + legacy/retired migration sets, calls
configureAgents() at module load, re-exports the runtime
- admin-tools.ts — keeps the 19 arcadia tool definitions, binds the
runtime via createToolRuntime(TOOLS), re-exports the bound functions
- admin-context.ts — deleted; 18 routes now import directly from
@crema/aifirst-ui/context
Routes that import from ~/lib/agents and ~/lib/admin-tools are unchanged
(wrapper modules preserve the existing import surface).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
894 lines
25 KiB
TypeScript
894 lines
25 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from "react"
|
|
import {
|
|
CheckCircle2,
|
|
Database,
|
|
FileText,
|
|
Plus,
|
|
Power,
|
|
RefreshCw,
|
|
Trash2,
|
|
} from "lucide-react"
|
|
|
|
import {
|
|
ActionsCell,
|
|
DataTable,
|
|
Pagination,
|
|
useTable,
|
|
type ActionItem,
|
|
type Column,
|
|
} from "@crema/table-ui"
|
|
import { SearchInput } from "@crema/search-ui"
|
|
import {
|
|
AlertBanner,
|
|
ConfirmDialog,
|
|
EmptyState,
|
|
LoadingOverlay,
|
|
} from "@crema/feedback-ui"
|
|
import { KpiTile, formatCompact } from "@crema/dashboard-ui"
|
|
|
|
import { AppShell } from "~/components/layout/app-shell"
|
|
import { Badge } from "~/components/ui/badge"
|
|
import { Button } from "~/components/ui/button"
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardHeader,
|
|
} from "~/components/ui/card"
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "~/components/ui/dialog"
|
|
import { Input } from "~/components/ui/input"
|
|
import { Label } from "~/components/ui/label"
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "~/components/ui/select"
|
|
import { Textarea } from "~/components/ui/textarea"
|
|
import {
|
|
searchAdmin,
|
|
SearchAdminError,
|
|
type CorpusSummary,
|
|
type TenantSummary,
|
|
} from "~/lib/search-admin"
|
|
import { pageTitle } from "~/lib/page-meta"
|
|
import { useSession } from "~/lib/session"
|
|
import { useRegisterContext } from "@crema/aifirst-ui/context"
|
|
|
|
export const meta = () => pageTitle("Search")
|
|
|
|
type Row = CorpusSummary & { rowId: string }
|
|
|
|
type EditorState =
|
|
| { kind: "new-tenant" }
|
|
| { kind: "new-corpus"; tenant: string }
|
|
| { kind: "edit-corpus"; tenant: string; corpus: string }
|
|
| null
|
|
|
|
export default function SearchRoute() {
|
|
const session = useSession()
|
|
|
|
const [tenants, setTenants] = useState<TenantSummary[]>([])
|
|
const [corpora, setCorpora] = useState<Row[]>([])
|
|
const [loading, setLoading] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [info, setInfo] = useState<string | null>(null)
|
|
const [editor, setEditor] = useState<EditorState>(null)
|
|
const [pendingDeleteTenant, setPendingDeleteTenant] = useState<string | null>(
|
|
null,
|
|
)
|
|
const [pendingDeleteCorpus, setPendingDeleteCorpus] = useState<{
|
|
tenant: string
|
|
corpus: string
|
|
} | null>(null)
|
|
const [restartConfirm, setRestartConfirm] = useState(false)
|
|
const [rebuilding, setRebuilding] = useState<string | null>(null)
|
|
|
|
const reportError = useCallback((err: unknown, fallback: string) => {
|
|
setError(
|
|
err instanceof SearchAdminError
|
|
? `${err.status}: ${err.message}`
|
|
: err instanceof Error
|
|
? err.message
|
|
: fallback,
|
|
)
|
|
}, [])
|
|
|
|
const refresh = useCallback(async () => {
|
|
setLoading(true)
|
|
setError(null)
|
|
try {
|
|
const tRes = await searchAdmin.listTenants()
|
|
setTenants(tRes.tenants)
|
|
// Fan out per-tenant corpus lookups in parallel.
|
|
const cByT = await Promise.all(
|
|
tRes.tenants.map(async (t) => {
|
|
try {
|
|
const r = await searchAdmin.listCorpora(t.id)
|
|
return r.corpora
|
|
} catch {
|
|
return []
|
|
}
|
|
}),
|
|
)
|
|
const flat: Row[] = cByT.flat().map((c) => ({
|
|
...c,
|
|
rowId: `${c.tenant}/${c.corpus}`,
|
|
}))
|
|
setCorpora(flat)
|
|
} catch (err) {
|
|
reportError(err, "Failed to load search admin state.")
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [reportError])
|
|
|
|
useEffect(() => {
|
|
if (!session) return
|
|
refresh()
|
|
}, [session, refresh])
|
|
|
|
const totals = useMemo(() => {
|
|
const indexed = corpora.filter((c) => c.indexed).length
|
|
const docs = corpora.reduce((a, c) => a + (c.num_docs ?? 0), 0)
|
|
return { indexed, docs }
|
|
}, [corpora])
|
|
|
|
// Publish a snapshot to the assistant's admin context so the agent
|
|
// can answer "what corpora exist?" / "is the docs corpus indexed?"
|
|
// without having to call list_search_corpora.
|
|
const adminSurface = useMemo(
|
|
() => ({
|
|
endpoint: searchAdmin.baseUrl,
|
|
tenants: tenants.map((t) => ({ id: t.id, corpus_count: t.corpus_count })),
|
|
corpora: corpora.map((c) => ({
|
|
tenant: c.tenant,
|
|
corpus: c.corpus,
|
|
indexed: c.indexed,
|
|
num_docs: c.num_docs,
|
|
})),
|
|
}),
|
|
[tenants, corpora],
|
|
)
|
|
useRegisterContext("search", adminSurface)
|
|
|
|
const rebuild = useCallback(
|
|
async (tenant: string, corpus: string) => {
|
|
const id = `${tenant}/${corpus}`
|
|
setRebuilding(id)
|
|
setError(null)
|
|
try {
|
|
const out = await searchAdmin.rebuild(tenant, corpus)
|
|
setInfo(
|
|
`Rebuilt ${tenant}/${corpus} — ${out.chunk_count} chunks indexed.`,
|
|
)
|
|
await refresh()
|
|
} catch (err) {
|
|
reportError(err, "Rebuild failed.")
|
|
} finally {
|
|
setRebuilding(null)
|
|
}
|
|
},
|
|
[refresh, reportError],
|
|
)
|
|
|
|
return (
|
|
<AppShell>
|
|
<div className="flex flex-col gap-4">
|
|
<header className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-semibold tracking-tight">Search</h1>
|
|
<p className="text-sm text-muted-foreground">
|
|
Manage arcadia-search tenants and corpora. Trigger rebuilds and
|
|
restart the service after env changes.
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={refresh}
|
|
disabled={loading}
|
|
data-action="search-refresh"
|
|
>
|
|
<RefreshCw
|
|
className={`size-4 ${loading ? "animate-spin" : ""}`}
|
|
/>
|
|
Refresh
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setRestartConfirm(true)}
|
|
data-action="search-restart"
|
|
>
|
|
<Power className="size-4" />
|
|
Restart service
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setEditor({ kind: "new-tenant" })}
|
|
data-action="search-new-tenant"
|
|
>
|
|
<Plus className="size-4" />
|
|
New tenant
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
disabled={tenants.length === 0}
|
|
onClick={() =>
|
|
setEditor({
|
|
kind: "new-corpus",
|
|
tenant: tenants[0]?.id ?? "",
|
|
})
|
|
}
|
|
data-action="search-new-corpus"
|
|
>
|
|
<Plus className="size-4" />
|
|
New corpus
|
|
</Button>
|
|
</div>
|
|
</header>
|
|
|
|
{!searchAdmin.hasToken ? (
|
|
<AlertBanner variant="warning">
|
|
VITE_ARCADIA_SEARCH_ADMIN_TOKEN is unset. The Search section will
|
|
return 401 until the bearer token is configured. Endpoint:{" "}
|
|
<code className="font-mono">{searchAdmin.baseUrl}</code>
|
|
</AlertBanner>
|
|
) : null}
|
|
{error ? (
|
|
<AlertBanner
|
|
variant="error"
|
|
dismissible
|
|
onDismiss={() => setError(null)}
|
|
>
|
|
{error}
|
|
</AlertBanner>
|
|
) : null}
|
|
{info ? (
|
|
<AlertBanner
|
|
variant="success"
|
|
dismissible
|
|
onDismiss={() => setInfo(null)}
|
|
>
|
|
{info}
|
|
</AlertBanner>
|
|
) : null}
|
|
|
|
<Card>
|
|
<CardHeader className="flex flex-row flex-wrap items-end gap-3">
|
|
<div className="grid grid-cols-3 gap-3 min-w-0">
|
|
<KpiTile
|
|
label="Tenants"
|
|
value={formatCompact(tenants.length)}
|
|
/>
|
|
<KpiTile
|
|
label="Corpora indexed"
|
|
value={`${totals.indexed} / ${corpora.length}`}
|
|
/>
|
|
<KpiTile label="Docs" value={formatCompact(totals.docs)} />
|
|
</div>
|
|
</CardHeader>
|
|
</Card>
|
|
|
|
<TenantsCard
|
|
tenants={tenants}
|
|
onDelete={(id) => setPendingDeleteTenant(id)}
|
|
/>
|
|
|
|
<CorporaCard
|
|
corpora={corpora}
|
|
loading={loading}
|
|
rebuildingId={rebuilding}
|
|
onRebuild={rebuild}
|
|
onEdit={(t, c) => setEditor({ kind: "edit-corpus", tenant: t, corpus: c })}
|
|
onDelete={(t, c) => setPendingDeleteCorpus({ tenant: t, corpus: c })}
|
|
/>
|
|
</div>
|
|
|
|
{/* New tenant */}
|
|
<NewTenantDialog
|
|
open={editor?.kind === "new-tenant"}
|
|
onClose={() => setEditor(null)}
|
|
onCreated={async (msg) => {
|
|
setEditor(null)
|
|
if (msg) setInfo(msg)
|
|
await refresh()
|
|
}}
|
|
onError={(msg) => setError(msg)}
|
|
/>
|
|
|
|
{/* New / edit corpus */}
|
|
<CorpusEditor
|
|
editor={
|
|
editor?.kind === "new-corpus" || editor?.kind === "edit-corpus"
|
|
? editor
|
|
: null
|
|
}
|
|
tenants={tenants}
|
|
onClose={() => setEditor(null)}
|
|
onSaved={async (msg) => {
|
|
setEditor(null)
|
|
if (msg) setInfo(msg)
|
|
await refresh()
|
|
}}
|
|
onError={(msg) => setError(msg)}
|
|
/>
|
|
|
|
{/* Delete tenant */}
|
|
<ConfirmDialog
|
|
open={pendingDeleteTenant !== null}
|
|
onOpenChange={(o) => !o && setPendingDeleteTenant(null)}
|
|
title={`Delete tenant ${pendingDeleteTenant ?? ""}?`}
|
|
description="Removes the tenant's config directory AND its entire index directory. This cannot be undone."
|
|
confirmLabel="Delete tenant"
|
|
variant="danger"
|
|
onConfirm={async () => {
|
|
if (!pendingDeleteTenant) return
|
|
try {
|
|
await searchAdmin.deleteTenant(pendingDeleteTenant)
|
|
setInfo(`Tenant ${pendingDeleteTenant} deleted.`)
|
|
setPendingDeleteTenant(null)
|
|
await refresh()
|
|
} catch (err) {
|
|
reportError(err, "Delete failed.")
|
|
setPendingDeleteTenant(null)
|
|
}
|
|
}}
|
|
/>
|
|
|
|
{/* Delete corpus */}
|
|
<ConfirmDialog
|
|
open={pendingDeleteCorpus !== null}
|
|
onOpenChange={(o) => !o && setPendingDeleteCorpus(null)}
|
|
title={
|
|
pendingDeleteCorpus
|
|
? `Delete ${pendingDeleteCorpus.tenant}/${pendingDeleteCorpus.corpus}?`
|
|
: ""
|
|
}
|
|
description="Removes the corpus config and its index directory. The tenant is preserved."
|
|
confirmLabel="Delete corpus"
|
|
variant="danger"
|
|
onConfirm={async () => {
|
|
if (!pendingDeleteCorpus) return
|
|
const { tenant, corpus } = pendingDeleteCorpus
|
|
try {
|
|
await searchAdmin.deleteCorpus(tenant, corpus)
|
|
setInfo(`Deleted ${tenant}/${corpus}.`)
|
|
setPendingDeleteCorpus(null)
|
|
await refresh()
|
|
} catch (err) {
|
|
reportError(err, "Delete failed.")
|
|
setPendingDeleteCorpus(null)
|
|
}
|
|
}}
|
|
/>
|
|
|
|
{/* Restart confirm */}
|
|
<ConfirmDialog
|
|
open={restartConfirm}
|
|
onOpenChange={(o) => !o && setRestartConfirm(false)}
|
|
title="Restart arcadia-search admin?"
|
|
description="The sidecar will exit and systemd will bring it back up. Active rebuilds will be aborted."
|
|
confirmLabel="Restart"
|
|
variant="danger"
|
|
onConfirm={async () => {
|
|
setRestartConfirm(false)
|
|
try {
|
|
await searchAdmin.restart()
|
|
setInfo("Restart requested.")
|
|
} catch (err) {
|
|
reportError(err, "Restart request failed.")
|
|
}
|
|
}}
|
|
/>
|
|
</AppShell>
|
|
)
|
|
}
|
|
|
|
// --- Tenants card --------------------------------------------------------
|
|
|
|
function TenantsCard({
|
|
tenants,
|
|
onDelete,
|
|
}: {
|
|
tenants: TenantSummary[]
|
|
onDelete: (id: string) => void
|
|
}) {
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<h2 className="text-base font-semibold">Tenants</h2>
|
|
</CardHeader>
|
|
<CardContent className="p-4">
|
|
{tenants.length === 0 ? (
|
|
<EmptyState
|
|
title="No tenants yet."
|
|
description="Create one to start adding corpora."
|
|
className="py-8"
|
|
/>
|
|
) : (
|
|
<ul className="flex flex-wrap gap-2">
|
|
{tenants.map((t) => (
|
|
<li
|
|
key={t.id}
|
|
className="flex items-center gap-2 rounded-md border bg-card px-3 py-1.5"
|
|
>
|
|
<code className="font-mono text-xs">{t.id}</code>
|
|
<Badge variant="secondary" className="text-xs">
|
|
{t.corpus_count} corpus{t.corpus_count === 1 ? "" : "es"}
|
|
</Badge>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
onClick={() => onDelete(t.id)}
|
|
aria-label={`Delete tenant ${t.id}`}
|
|
data-action={`tenant-${t.id}-delete`}
|
|
>
|
|
<Trash2 className="size-3.5" />
|
|
</Button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
// --- Corpora table -------------------------------------------------------
|
|
|
|
function CorporaCard({
|
|
corpora,
|
|
loading,
|
|
rebuildingId,
|
|
onRebuild,
|
|
onEdit,
|
|
onDelete,
|
|
}: {
|
|
corpora: Row[]
|
|
loading: boolean
|
|
rebuildingId: string | null
|
|
onRebuild: (tenant: string, corpus: string) => void
|
|
onEdit: (tenant: string, corpus: string) => void
|
|
onDelete: (tenant: string, corpus: string) => void
|
|
}) {
|
|
const [search, setSearch] = useState("")
|
|
|
|
const columns = useMemo<Column<Row>[]>(
|
|
() => [
|
|
{
|
|
id: "tenant",
|
|
header: "Tenant",
|
|
accessor: "tenant",
|
|
sortable: true,
|
|
cell: (r) => (
|
|
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
|
|
{r.tenant}
|
|
</code>
|
|
),
|
|
},
|
|
{
|
|
id: "corpus",
|
|
header: "Corpus",
|
|
accessor: "corpus",
|
|
sortable: true,
|
|
cell: (r) => (
|
|
<span className="flex items-center gap-2 font-medium">
|
|
<Database className="size-4 text-muted-foreground" />
|
|
{r.corpus}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
id: "indexed",
|
|
header: "Status",
|
|
sortable: true,
|
|
accessor: (r) => (r.indexed ? 1 : 0),
|
|
cell: (r) =>
|
|
r.indexed ? (
|
|
<Badge variant="default" className="text-xs">
|
|
Indexed
|
|
</Badge>
|
|
) : (
|
|
<Badge variant="secondary" className="text-xs">
|
|
Not built
|
|
</Badge>
|
|
),
|
|
},
|
|
{
|
|
id: "docs",
|
|
header: "Docs",
|
|
sortable: true,
|
|
accessor: (r) => r.num_docs ?? -1,
|
|
cell: (r) =>
|
|
r.num_docs != null ? (
|
|
<span className="font-mono text-xs">
|
|
{r.num_docs.toLocaleString()}
|
|
</span>
|
|
) : (
|
|
<span className="text-muted-foreground">—</span>
|
|
),
|
|
},
|
|
{
|
|
id: "actions",
|
|
header: "",
|
|
align: "right",
|
|
cell: (r) => {
|
|
const id = `${r.tenant}/${r.corpus}`
|
|
const isRebuilding = rebuildingId === id
|
|
const items: ActionItem[] = [
|
|
{
|
|
id: "rebuild",
|
|
label: isRebuilding ? "Rebuilding…" : "Rebuild",
|
|
icon: (
|
|
<RefreshCw
|
|
className={`size-4 ${isRebuilding ? "animate-spin" : ""}`}
|
|
/>
|
|
),
|
|
dataAction: `corpus-${r.tenant}-${r.corpus}-rebuild`,
|
|
onSelect: () =>
|
|
isRebuilding ? undefined : onRebuild(r.tenant, r.corpus),
|
|
},
|
|
{
|
|
id: "edit",
|
|
label: "Edit config",
|
|
icon: <FileText className="size-4" />,
|
|
dataAction: `corpus-${r.tenant}-${r.corpus}-edit`,
|
|
onSelect: () => onEdit(r.tenant, r.corpus),
|
|
},
|
|
{
|
|
id: "delete",
|
|
label: "Delete",
|
|
icon: <Trash2 className="size-4" />,
|
|
destructive: true,
|
|
dataAction: `corpus-${r.tenant}-${r.corpus}-delete`,
|
|
onSelect: () => onDelete(r.tenant, r.corpus),
|
|
},
|
|
]
|
|
return (
|
|
<ActionsCell
|
|
items={items}
|
|
triggerDataAction={`corpus-${r.tenant}-${r.corpus}-actions`}
|
|
/>
|
|
)
|
|
},
|
|
},
|
|
],
|
|
[rebuildingId, onRebuild, onEdit, onDelete],
|
|
)
|
|
|
|
const table = useTable<Row>({
|
|
data: corpora,
|
|
columns,
|
|
getRowId: (r) => r.rowId,
|
|
initialPageSize: 25,
|
|
initialSearch: search,
|
|
})
|
|
useEffect(() => {
|
|
table.setSearch(search)
|
|
}, [search, table])
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center gap-3">
|
|
<SearchInput
|
|
value={search}
|
|
onValueChange={setSearch}
|
|
placeholder="Search by tenant or corpus"
|
|
data-action="corpora-search"
|
|
className="max-w-sm flex-1"
|
|
/>
|
|
<div className="ml-auto text-xs text-muted-foreground">
|
|
{table.total} of {corpora.length}
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="relative p-0">
|
|
<LoadingOverlay
|
|
active={loading && corpora.length === 0}
|
|
label="Loading corpora…"
|
|
/>
|
|
{table.total === 0 && !loading ? (
|
|
<EmptyState
|
|
icon={<Database className="size-6" />}
|
|
title={search ? "No matches." : "No corpora yet."}
|
|
description={
|
|
search ? "Try a different search." : "Create one above."
|
|
}
|
|
className="py-12"
|
|
/>
|
|
) : (
|
|
<>
|
|
<DataTable
|
|
columns={columns}
|
|
rows={table.pageRows}
|
|
getRowId={(r) => r.rowId}
|
|
sort={table.sort}
|
|
onSortToggle={table.toggleSort}
|
|
loading={loading && corpora.length > 0}
|
|
stickyHeader
|
|
/>
|
|
<Pagination
|
|
page={table.page}
|
|
pageSize={table.pageSize}
|
|
total={table.total}
|
|
onPageChange={table.setPage}
|
|
onPageSizeChange={table.setPageSize}
|
|
/>
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
// --- Dialogs -------------------------------------------------------------
|
|
|
|
function NewTenantDialog({
|
|
open,
|
|
onClose,
|
|
onCreated,
|
|
onError,
|
|
}: {
|
|
open: boolean
|
|
onClose: () => void
|
|
onCreated: (msg?: string) => Promise<void>
|
|
onError: (msg: string) => void
|
|
}) {
|
|
const [id, setId] = useState("")
|
|
const [saving, setSaving] = useState(false)
|
|
|
|
useEffect(() => {
|
|
if (!open) setId("")
|
|
}, [open])
|
|
|
|
const submit = async () => {
|
|
setSaving(true)
|
|
try {
|
|
await searchAdmin.createTenant(id)
|
|
await onCreated(`Tenant ${id} created.`)
|
|
} catch (err) {
|
|
onError(
|
|
err instanceof SearchAdminError
|
|
? `${err.status}: ${err.message}`
|
|
: "Create failed.",
|
|
)
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>New tenant</DialogTitle>
|
|
<DialogDescription>
|
|
Creates an empty config dir at{" "}
|
|
<code className="font-mono text-xs">
|
|
$INDEX_CONFIG_DIR/<id>/
|
|
</code>
|
|
. Add corpora separately. Names are alphanumeric, dash, or
|
|
underscore.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="flex flex-col gap-1.5">
|
|
<Label htmlFor="new-tenant-id">Tenant id</Label>
|
|
<Input
|
|
id="new-tenant-id"
|
|
value={id}
|
|
onChange={(e) => setId(e.target.value)}
|
|
placeholder="acme"
|
|
data-action="tenant-form-id"
|
|
/>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={onClose}
|
|
disabled={saving}
|
|
data-action="tenant-form-cancel"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={submit}
|
|
disabled={saving || !id}
|
|
data-action="tenant-form-save"
|
|
>
|
|
{saving ? (
|
|
<RefreshCw className="size-4 animate-spin" />
|
|
) : (
|
|
<CheckCircle2 className="size-4" />
|
|
)}
|
|
Create
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|
|
|
|
const CORPUS_CONFIG_TEMPLATE = `{
|
|
"corpus": "docs",
|
|
"sources": [
|
|
{
|
|
"type": "arcadia",
|
|
"list_url": "/api/v1/files?tenant_id={tenant}",
|
|
"item_url": "/api/v1/files/{id}/content",
|
|
"title_field": "name",
|
|
"id_field": "id",
|
|
"mtime_field": "updated_at",
|
|
"tags": ["uploaded"]
|
|
}
|
|
]
|
|
}`
|
|
|
|
function CorpusEditor({
|
|
editor,
|
|
tenants,
|
|
onClose,
|
|
onSaved,
|
|
onError,
|
|
}: {
|
|
editor:
|
|
| { kind: "new-corpus"; tenant: string }
|
|
| { kind: "edit-corpus"; tenant: string; corpus: string }
|
|
| null
|
|
tenants: TenantSummary[]
|
|
onClose: () => void
|
|
onSaved: (msg?: string) => Promise<void>
|
|
onError: (msg: string) => void
|
|
}) {
|
|
const [tenant, setTenant] = useState("")
|
|
const [text, setText] = useState("")
|
|
const [saving, setSaving] = useState(false)
|
|
const [loading, setLoading] = useState(false)
|
|
|
|
const isEdit = editor?.kind === "edit-corpus"
|
|
const headerCorpus = isEdit ? editor.corpus : ""
|
|
|
|
// Hydrate on open: load existing config for edit, template for new.
|
|
useEffect(() => {
|
|
if (!editor) return
|
|
setTenant(editor.tenant)
|
|
if (editor.kind === "edit-corpus") {
|
|
setLoading(true)
|
|
searchAdmin
|
|
.getCorpus(editor.tenant, editor.corpus)
|
|
.then((res) => {
|
|
setText(JSON.stringify(res.config, null, 2))
|
|
})
|
|
.catch((err) => {
|
|
onError(
|
|
err instanceof SearchAdminError
|
|
? `${err.status}: ${err.message}`
|
|
: "Load failed.",
|
|
)
|
|
})
|
|
.finally(() => setLoading(false))
|
|
} else {
|
|
setText(CORPUS_CONFIG_TEMPLATE)
|
|
}
|
|
}, [editor, onError])
|
|
|
|
if (!editor) return null
|
|
|
|
const submit = async () => {
|
|
setSaving(true)
|
|
try {
|
|
const parsed = JSON.parse(text)
|
|
if (typeof parsed !== "object" || parsed === null) {
|
|
throw new Error("config must be a JSON object")
|
|
}
|
|
if (editor.kind === "new-corpus") {
|
|
const corpus = parsed.corpus
|
|
if (typeof corpus !== "string" || !corpus) {
|
|
throw new Error('config must have a string "corpus" field')
|
|
}
|
|
await searchAdmin.createCorpus(tenant, parsed)
|
|
await onSaved(`Created ${tenant}/${corpus}.`)
|
|
} else {
|
|
await searchAdmin.updateCorpus(editor.tenant, editor.corpus, parsed)
|
|
await onSaved(`Updated ${editor.tenant}/${editor.corpus}.`)
|
|
}
|
|
} catch (err) {
|
|
onError(
|
|
err instanceof SearchAdminError
|
|
? `${err.status}: ${err.message}`
|
|
: err instanceof Error
|
|
? err.message
|
|
: "Save failed.",
|
|
)
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
|
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{isEdit ? `Edit ${editor.tenant}/${headerCorpus}` : "New corpus"}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
JSON config matching arcadia-search's IndexerConfig schema. The{" "}
|
|
<code className="font-mono text-xs">tenant</code> field is set
|
|
from the URL — your value is overwritten.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="flex flex-col gap-3">
|
|
{!isEdit ? (
|
|
<div className="flex flex-col gap-1.5">
|
|
<Label htmlFor="corpus-tenant">Tenant</Label>
|
|
<Select value={tenant} onValueChange={setTenant}>
|
|
<SelectTrigger
|
|
id="corpus-tenant"
|
|
data-action="corpus-form-tenant"
|
|
>
|
|
<SelectValue placeholder="Pick a tenant" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{tenants.map((t) => (
|
|
<SelectItem key={t.id} value={t.id}>
|
|
{t.id}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="flex flex-col gap-1.5">
|
|
<Label htmlFor="corpus-config">Config JSON</Label>
|
|
<Textarea
|
|
id="corpus-config"
|
|
value={text}
|
|
onChange={(e) => setText(e.target.value)}
|
|
rows={20}
|
|
className="font-mono text-xs"
|
|
spellCheck={false}
|
|
disabled={loading}
|
|
data-action="corpus-form-config"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={onClose}
|
|
disabled={saving}
|
|
data-action="corpus-form-cancel"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={submit}
|
|
disabled={saving || loading || !tenant}
|
|
data-action="corpus-form-save"
|
|
>
|
|
{saving ? (
|
|
<RefreshCw className="size-4 animate-spin" />
|
|
) : (
|
|
<CheckCircle2 className="size-4" />
|
|
)}
|
|
{isEdit ? "Update" : "Create"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|