diff --git a/app/lib/admin-tools.ts b/app/lib/admin-tools.ts index 6260fd2..03e75d6 100644 --- a/app/lib/admin-tools.ts +++ b/app/lib/admin-tools.ts @@ -22,6 +22,77 @@ import { BLOCK_INDEX, getBlockSchema } from "~/lib/block-schemas" // calls reuse the parsed MiniSearch instance. const docsClient = createRAGClient("/docs-index.json") +// Server-side Tantivy backend (arcadia-search). +// +// URL: comes from window.__ARCADIA_SEARCH_URL (override hook) or +// VITE_ARCADIA_SEARCH_URL build-time env, defaulting to localhost. +// +// Token: prefer the real arcadia access token (sessionStorage — +// matches lib-arcadia-client's storage convention). Fall back to "dev" +// when missing, which only works against AUTH_MODE=dev backends. In +// production, arcadia-search runs in JWT mode and the dev fallback +// gets rejected with 401 — surfacing the missing-login as a clear +// error rather than silently using the wrong identity. +const KB_BASE_URL: string = + (typeof window !== "undefined" && + (window as unknown as { __ARCADIA_SEARCH_URL?: string }).__ARCADIA_SEARCH_URL) || + (typeof import.meta !== "undefined" && + (import.meta as unknown as { env?: { VITE_ARCADIA_SEARCH_URL?: string } }).env + ?.VITE_ARCADIA_SEARCH_URL) || + "http://127.0.0.1:7800" + +function kbAuthToken(): string { + if (typeof window === "undefined") return "dev" + try { + return window.sessionStorage.getItem("arcadia_access_token") ?? "dev" + } catch { + return "dev" + } +} + +type KBHit = { + chunk_id: string + title: string + source_path: string + heading_path: string + tags: string[] + snippet: string + score: number + mtime: string +} + +async function kbSearch( + query: string, + corpus: string, + limit: number, + tags?: string[], +): Promise<{ count: number; hits: KBHit[] }> { + const res = await fetch(`${KB_BASE_URL}/search`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${kbAuthToken()}`, + }, + body: JSON.stringify({ query, corpus, limit, tags }), + }) + if (!res.ok) { + throw new Error(`arcadia-search ${res.status}: ${await res.text()}`) + } + return (await res.json()) as { count: number; hits: KBHit[] } +} + +async function kbRead(chunkId: string, corpus: string): Promise { + const url = `${KB_BASE_URL}/chunks/${encodeURIComponent(chunkId)}?corpus=${encodeURIComponent(corpus)}` + const res = await fetch(url, { + headers: { Authorization: `Bearer ${kbAuthToken()}` }, + }) + if (res.status === 404) return null + if (!res.ok) { + throw new Error(`arcadia-search ${res.status}: ${await res.text()}`) + } + return await res.json() +} + export type ToolCall = { name: string args: Record @@ -275,6 +346,73 @@ const TOOLS: ToolDef[] = [ } }, }, + { + name: "search_kb", + description: + "Lexical (BM25) search over the arcadia-search Tantivy backend. Use for the LARGER, server-hosted knowledge corpora — the same arcadia docs the browser RAG serves are indexed here as `corpus=docs` for parity, and additional corpora (uploaded files, runbooks, etc.) will land here as they're added. Returns chunks with snippets + chunk_ids that can be passed to `read_chunk` to expand. Prefer this over `search_docs` (browser) when you need richer hits or when the user is asking about content that wouldn't be in the bundled docs (e.g. uploaded files).", + parameters: { + type: "object", + properties: { + query: { type: "string", description: "Lexical search query." }, + corpus: { + type: "string", + description: + "Which indexed corpus to search. `docs` is the parity corpus (arcadia documentation). New corpora are added by the operator.", + }, + limit: { + type: "integer", + description: "Max hits. Default 5, cap 20.", + minimum: 1, + maximum: 20, + }, + tags: { + type: "array", + items: { type: "string" }, + description: + "Optional tag filter — return only hits whose chunk has at least one matching tag.", + }, + }, + required: ["query", "corpus"], + additionalProperties: false, + }, + isWrite: false, + run: async (args) => { + const query = typeof args.query === "string" ? args.query.trim() : "" + const corpus = typeof args.corpus === "string" ? args.corpus.trim() : "" + if (!query) throw new Error("search_kb requires a non-empty { query }") + if (!corpus) throw new Error("search_kb requires a { corpus } name") + const limit = Math.min(20, Math.max(1, typeof args.limit === "number" ? args.limit : 5)) + const tags = Array.isArray(args.tags) ? (args.tags as string[]) : undefined + return await kbSearch(query, corpus, limit, tags) + }, + }, + { + name: "read_chunk", + description: + "Fetch the full body of one chunk by id from the arcadia-search backend, after `search_kb` returned it as a snippet. Use this to expand a hit when the snippet looked promising but you need more context to answer.", + parameters: { + type: "object", + properties: { + chunk_id: { type: "string", description: "The chunk_id from a prior search_kb hit." }, + corpus: { type: "string", description: "Same corpus the chunk came from." }, + }, + required: ["chunk_id", "corpus"], + additionalProperties: false, + }, + isWrite: false, + run: async (args) => { + const chunkId = typeof args.chunk_id === "string" ? args.chunk_id : "" + const corpus = typeof args.corpus === "string" ? args.corpus : "" + if (!chunkId || !corpus) { + throw new Error("read_chunk requires { chunk_id, corpus }") + } + const result = await kbRead(chunkId, corpus) + if (result === null) { + return { error: "chunk not found", chunk_id: chunkId, corpus } + } + return result + }, + }, { name: "get_block_schema", description: `Fetch the full JSON schema + example for a rich-output block kind so you can emit it correctly in your reply. Call this the first time in a thread that you intend to render a particular kind. Available kinds: ${Object.entries( diff --git a/app/routes/ai.tsx b/app/routes/ai.tsx index ca08361..707ceeb 100644 --- a/app/routes/ai.tsx +++ b/app/routes/ai.tsx @@ -111,6 +111,48 @@ function ToolResultBlock({ name, result }: { name: string; result: unknown }) { return
{rich}
} +// Trigger a server-side rebuild of an arcadia-search corpus. Reads the +// same KB URL + token resolution as the search_kb tool (see admin-tools.ts). +// Surfaces success/failure via the existing toast provider. +async function reindexKB( + corpus: string, + toast: ReturnType, +): Promise { + const baseUrl = + (typeof window !== "undefined" && + (window as unknown as { __ARCADIA_SEARCH_URL?: string }).__ARCADIA_SEARCH_URL) || + "http://127.0.0.1:7800" + const token = + (typeof window !== "undefined" && + window.sessionStorage.getItem("arcadia_access_token")) || + "dev" + const url = `${baseUrl}/index/${encodeURIComponent(corpus)}/build` + toast.show?.({ + title: "Reindexing…", + description: `Rebuilding corpus '${corpus}'.`, + }) + try { + const res = await fetch(url, { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + }) + if (!res.ok) { + throw new Error(`HTTP ${res.status}: ${await res.text()}`) + } + const out = (await res.json()) as { chunk_count: number; built_at: string } + toast.show?.({ + title: "Reindex complete", + description: `${out.chunk_count} chunks indexed for '${corpus}'.`, + }) + } catch (err) { + toast.show?.({ + title: "Reindex failed", + description: err instanceof Error ? err.message : String(err), + tone: "error", + }) + } +} + // Synthetic assistant message that exercises every typed rich-output block. // Wired to the "preview rich-output blocks" button in the empty state — used // to eyeball renderer + theme without driving a live model. Safe to delete @@ -1179,7 +1221,7 @@ function ChatSurface({ Issue an instruction. Read tools run automatically. Writes pause for confirmation. Tab ⇥ for command palette.

-
+
+
diff --git a/app/routes/assistant.tsx b/app/routes/assistant.tsx index 3e59e52..2936357 100644 --- a/app/routes/assistant.tsx +++ b/app/routes/assistant.tsx @@ -113,6 +113,7 @@ function buildAdminPreface(activeAgent: Agent | undefined, uiControl: boolean): const ctx = formatAdminContextForPrompt() const parts = [ "You are the operator's assistant inside Arcadia Admin. Be precise and direct. You have native function tools attached to this conversation — call them whenever the user asks about live platform state (counts, statuses, listings, lookups). Never invent tenant slugs, user counts, or statuses; if you need data, call a tool.", + "Two retrieval surfaces exist for documentation/knowledge: `search_docs` (browser-side, BM25 over the bundled arcadia docs — fast, always available, small corpus) and `search_kb` (server-side, BM25 over arcadia-search — same docs as `corpus=docs` for parity, plus larger and additional corpora as the operator adds them). For questions about the bundled arcadia docs either is fine; prefer `search_kb` when you want richer hits or when the user is asking about content that wouldn't be in the bundled docs (uploaded files, tenant-specific knowledge). When `search_kb` returns a chunk_id you want to expand, call `read_chunk(chunk_id, corpus)`.", RICH_OUTPUT_PREFACE, ARCADIA_KNOWLEDGE, persona,