소스 검색

docs(plan): web UI implementation plan (plan 2 of 3)

Fszontagh 2 달 전
부모
커밋
5ae396f82a
1개의 변경된 파일543개의 추가작업 그리고 0개의 파일을 삭제
  1. 543 0
      docs/superpowers/plans/2026-05-31-smartbotic-vectorapi-webui.md

+ 543 - 0
docs/superpowers/plans/2026-05-31-smartbotic-vectorapi-webui.md

@@ -0,0 +1,543 @@
+# smartbotic-vectorapi Web UI Implementation Plan (Plan 2 of 3)
+
+> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Implement task-by-task. Steps use `- [ ]`. The backend (plan 1) is merged to `main` and the API is running on `localhost:8080` when started; the DB is on `localhost:9004`.
+
+**Goal:** A React admin console for `smartbotic-vectorapi` — login with an API key, switch between granted projects, view stats, manage collections, do full CRUD on JSON documents (Monaco editor), run a RAG search tester, and (for admin keys) manage global settings, projects, and API keys. Built via `npm run build` triggered from CMake; shipped as a separate `.deb` in plan 3.
+
+**Architecture:** Vite + React 19 + TypeScript (strict) + TailwindCSS v4 + Monaco editor + react-router + zustand + @tanstack/react-query — the same stack as `/data/callerai/webui`. Auth is **cookie-session**: the login form POSTs the key to `/ui/login` (backend sets an HttpOnly cookie); every API call uses `fetch(..., { credentials: 'include' })`, so the key lives only in the cookie, never in JS. The app is same-origin with the API in production (the binary serves the built assets at `/` and the API at `/api`, `/ui`); in dev, Vite proxies `/api` and `/ui` to `:8080`. The auth store holds only `{ loggedIn, admin, projects, currentProject }` (returned by `/ui/login`). Project is a URL path segment in every API call: `/api/v1/projects/{project}/...`.
+
+**Verification:** the primary gate is `npm run build` (`tsc -b` strict typecheck + `vite build`) — it must pass with zero type errors. Plus a Playwright smoke (Task W11) driving the real flow against a running backend. No per-component unit tests (admin console; build + e2e smoke is the agreed bar).
+
+**Backend API recap (what the client calls):** `POST /ui/login {key} → {ok,admin,projects}`, `POST /ui/logout`. Under `/api/v1` (cookie auth): `GET/POST /projects`, `GET/DELETE /projects/{p}`, `GET/POST /keys`, `PATCH/DELETE /keys/{key}`, `GET/POST /projects/{p}/collections`, `GET/DELETE /projects/{p}/collections/{name}`, `POST/GET .../documents`, `GET/PUT/PATCH/DELETE .../documents/{id}`, `POST .../vectors`, `POST .../search`, `GET /projects/{p}/stats`, `GET /stats` (admin). Errors: `{error:{code,message}}` with HTTP status. See `api/openapi.json`.
+
+---
+
+## File structure
+
+```
+webui/
+├── package.json            # callerai stack
+├── vite.config.ts          # proxy /api + /ui -> :8080; outDir overridable
+├── tsconfig*.json
+├── index.html
+├── eslint.config.js
+├── src/
+│   ├── main.tsx            # React root + QueryClient + RouterProvider
+│   ├── App.tsx             # routes + ProtectedRoute + AppShell
+│   ├── index.css          # tailwind entry
+│   ├── api/client.ts      # typed request<T>() wrapper (credentials: include)
+│   ├── types/index.ts     # API types
+│   ├── stores/authStore.ts# zustand: {loggedIn, admin, projects, currentProject}
+│   ├── stores/toastStore.ts
+│   ├── components/        # AppShell, Sidebar, ProjectSelector, Toast, Modal, JsonEditor, ConfirmDialog
+│   └── pages/             # Login, Dashboard, Collections, Documents, RagTester, Settings, ProjectsAdmin, KeysAdmin
+└── (dist/ produced by build)
+cmake/WebUI.cmake          # runs npm ci + npm run build -> ${CMAKE_BINARY_DIR}/webui/dist
+```
+
+---
+
+## Task W1: Scaffold (Vite + React + TS + Tailwind + router)
+
+**Files:** create `webui/package.json`, `webui/vite.config.ts`, `webui/tsconfig.json`, `webui/tsconfig.app.json`, `webui/tsconfig.node.json`, `webui/index.html`, `webui/eslint.config.js`, `webui/.gitignore`, `webui/src/main.tsx`, `webui/src/App.tsx`, `webui/src/index.css`.
+
+- [ ] **Step 1: `webui/package.json`** (mirror callerai's stack/versions)
+
+```json
+{
+  "name": "smartbotic-vectorapi-webui",
+  "private": true,
+  "version": "0.1.0",
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "tsc -b && vite build",
+    "lint": "eslint .",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "@monaco-editor/react": "^4.7.0",
+    "@tanstack/react-query": "^5.90.16",
+    "clsx": "^2.1.1",
+    "lucide-react": "^0.562.0",
+    "react": "^19.2.0",
+    "react-dom": "^19.2.0",
+    "react-router-dom": "^7.11.0",
+    "tailwind-merge": "^3.4.0",
+    "tailwindcss": "^4.1.18",
+    "zustand": "^5.0.10"
+  },
+  "devDependencies": {
+    "@tailwindcss/vite": "^4.1.18",
+    "@types/react": "^19.2.5",
+    "@types/react-dom": "^19.2.3",
+    "@vitejs/plugin-react": "^5.1.1",
+    "eslint": "^9.39.1",
+    "typescript": "~5.9.3",
+    "vite": "^7.2.4"
+  }
+}
+```
+
+- [ ] **Step 2: `webui/vite.config.ts`** — proxy `/api` and `/ui` to the backend; allow `--outDir` override (CMake passes `--outDir <build>/webui/dist`).
+
+```ts
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+import tailwindcss from '@tailwindcss/vite'
+import path from 'path'
+
+export default defineConfig({
+  plugins: [react(), tailwindcss()],
+  resolve: { alias: { '@': path.resolve(__dirname, './src') } },
+  server: {
+    port: 3000,
+    proxy: {
+      '/api': { target: 'http://localhost:8080', changeOrigin: true },
+      '/ui':  { target: 'http://localhost:8080', changeOrigin: true },
+    },
+  },
+  build: { outDir: 'dist', sourcemap: false },
+})
+```
+
+- [ ] **Step 3: tsconfig files** — standard Vite React TS strict config. `tsconfig.json` references `tsconfig.app.json` + `tsconfig.node.json`. `tsconfig.app.json` must set `"strict": true`, `"jsx": "react-jsx"`, `"moduleResolution": "bundler"`, `"paths": { "@/*": ["./src/*"] }`, `"baseUrl": "."`, target `ES2022`, `"noUnusedLocals": true`, `"noUnusedParameters": true`. Copy the shape from `/data/callerai/webui/tsconfig*.json` (read them) and adapt names.
+
+- [ ] **Step 4: `webui/index.html`**
+
+```html
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>Smartbotic VectorAPI</title>
+  </head>
+  <body>
+    <div id="root"></div>
+    <script type="module" src="/src/main.tsx"></script>
+  </body>
+</html>
+```
+
+- [ ] **Step 5: `webui/src/index.css`** — `@import "tailwindcss";` plus a dark, professional admin base (set `:root { color-scheme: dark; }`, body bg `#0b0f17`, text `#e5e7eb`, a sans stack). Keep minimal; pages use Tailwind utilities.
+
+- [ ] **Step 6: `webui/src/main.tsx`**
+
+```tsx
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { RouterProvider, createBrowserRouter } from 'react-router-dom'
+import { routes } from './App'
+import './index.css'
+
+const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } } })
+const router = createBrowserRouter(routes)
+
+createRoot(document.getElementById('root')!).render(
+  <StrictMode>
+    <QueryClientProvider client={queryClient}>
+      <RouterProvider router={router} />
+    </QueryClientProvider>
+  </StrictMode>,
+)
+```
+
+- [ ] **Step 7: `webui/src/App.tsx`** — export a `routes` array. For W1, a minimal placeholder: a single route `/` rendering `<div>smartbotic-vectorapi</div>`. (Real routes land in W3/W4.)
+
+```tsx
+import type { RouteObject } from 'react-router-dom'
+export const routes: RouteObject[] = [
+  { path: '/', element: <div className="p-8 text-xl">smartbotic-vectorapi web UI</div> },
+]
+```
+
+- [ ] **Step 8: `webui/.gitignore`** — `node_modules/`, `dist/`, `*.tsbuildinfo`.
+
+- [ ] **Step 9: Install + build**
+
+Run: `cd webui && npm install && npm run build`
+Expected: `npm install` resolves; `tsc -b` passes (0 errors); `vite build` emits `dist/index.html` + assets.
+
+- [ ] **Step 10: Commit**
+
+```bash
+git add webui
+git commit -m "feat(webui): scaffold Vite+React+TS+Tailwind app"
+```
+
+---
+
+## Task W2: API client + types
+
+**Files:** create `webui/src/types/index.ts`, `webui/src/api/client.ts`.
+
+- [ ] **Step 1: `webui/src/types/index.ts`** — API shapes:
+
+```ts
+export interface LoginResponse { ok: boolean; admin: boolean; projects: string[] }
+export interface CollectionMeta {
+  name: string; kind: 'json' | 'vector'; vector_dimension: number;
+  embedding_model: string; created_at: number;
+  document_count?: number; size_bytes?: number;
+}
+export interface ApiKeyPublic {
+  label: string; projects: string[]; admin: boolean; created_at: number; key_prefix: string;
+}
+export interface ApiKeyCreated extends ApiKeyPublic { key: string }
+export interface SearchResult { id: string; score: number; data: Record<string, unknown> }
+export interface ProjectStats { project: string; collections: number; documents: number }
+export interface GlobalStats {
+  total_documents: number; total_collections: number; memory_used_bytes: number;
+  insert_count: number; query_count: number;
+  memory_pressure_level: string; memory_pressure_percent: number; projects: number;
+}
+export interface FindResult { documents: Record<string, unknown>[]; count: number }
+export class ApiError extends Error { constructor(public status: number, message: string) { super(message); this.name = 'ApiError' } }
+```
+
+- [ ] **Step 2: `webui/src/api/client.ts`** — a `request<T>()` wrapper using cookie auth, plus typed endpoint helpers. On 401, clear the auth store and bounce to `/login` (import lazily to avoid a cycle).
+
+```ts
+import type {
+  LoginResponse, CollectionMeta, ApiKeyPublic, ApiKeyCreated,
+  SearchResult, ProjectStats, GlobalStats, FindResult,
+} from '@/types'
+import { ApiError } from '@/types'
+
+async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
+  const res = await fetch(path, {
+    ...options,
+    credentials: 'include',
+    headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
+  })
+  if (res.status === 401 && !path.endsWith('/ui/login')) {
+    const { useAuthStore } = await import('@/stores/authStore')
+    useAuthStore.getState().reset()
+    if (location.pathname !== '/login') location.assign('/login')
+    throw new ApiError(401, 'unauthorized')
+  }
+  const text = await res.text()
+  const body = text ? JSON.parse(text) : {}
+  if (!res.ok) throw new ApiError(res.status, body?.error?.message || res.statusText)
+  return body as T
+}
+
+const enc = encodeURIComponent
+const P = (project: string) => `/api/v1/projects/${enc(project)}`
+
+export const api = {
+  login: (key: string) => request<LoginResponse>('/ui/login', { method: 'POST', body: JSON.stringify({ key }) }),
+  logout: () => request<{ ok: boolean }>('/ui/logout', { method: 'POST' }),
+
+  // projects
+  listProjects: () => request<{ projects: string[] }>('/api/v1/projects'),
+  createProject: (name: string) => request<{ name: string }>('/api/v1/projects', { method: 'POST', body: JSON.stringify({ name }) }),
+  deleteProject: (name: string) => request<{ deleted: string }>(`/api/v1/projects/${enc(name)}`, { method: 'DELETE' }),
+
+  // keys (admin)
+  listKeys: () => request<{ keys: ApiKeyPublic[] }>('/api/v1/keys'),
+  createKey: (label: string, projects: string[], admin: boolean) =>
+    request<ApiKeyCreated>('/api/v1/keys', { method: 'POST', body: JSON.stringify({ label, projects, admin }) }),
+  updateKey: (key: string, patch: Partial<{ label: string; projects: string[]; admin: boolean }>) =>
+    request<{ updated: boolean }>(`/api/v1/keys/${enc(key)}`, { method: 'PATCH', body: JSON.stringify(patch) }),
+  deleteKey: (key: string) => request<{ deleted: boolean }>(`/api/v1/keys/${enc(key)}`, { method: 'DELETE' }),
+
+  // collections
+  listCollections: (project: string) => request<{ collections: CollectionMeta[] }>(`${P(project)}/collections`),
+  getCollection: (project: string, name: string) => request<CollectionMeta>(`${P(project)}/collections/${enc(name)}`),
+  createCollection: (project: string, body: { name: string; kind: 'json' | 'vector'; vector_dimension?: number; embedding_model?: string }) =>
+    request<CollectionMeta>(`${P(project)}/collections`, { method: 'POST', body: JSON.stringify(body) }),
+  deleteCollection: (project: string, name: string) =>
+    request<{ deleted: string }>(`${P(project)}/collections/${enc(name)}`, { method: 'DELETE' }),
+
+  // documents
+  findDocuments: (project: string, coll: string, qs = '') =>
+    request<FindResult>(`${P(project)}/collections/${enc(coll)}/documents${qs}`),
+  getDocument: (project: string, coll: string, id: string) =>
+    request<Record<string, unknown>>(`${P(project)}/collections/${enc(coll)}/documents/${enc(id)}`),
+  insertDocument: (project: string, coll: string, data: unknown) =>
+    request<{ id: string }>(`${P(project)}/collections/${enc(coll)}/documents`, { method: 'POST', body: JSON.stringify({ data }) }),
+  putDocument: (project: string, coll: string, id: string, data: unknown) =>
+    request<{ id: string }>(`${P(project)}/collections/${enc(coll)}/documents/${enc(id)}`, { method: 'PUT', body: JSON.stringify(data) }),
+  deleteDocument: (project: string, coll: string, id: string) =>
+    request<{ deleted: string }>(`${P(project)}/collections/${enc(coll)}/documents/${enc(id)}`, { method: 'DELETE' }),
+
+  // vectors / rag
+  storeVector: (project: string, coll: string, body: { id?: string; text?: string; vector?: number[]; metadata?: Record<string, unknown> }) =>
+    request<{ id: string }>(`${P(project)}/collections/${enc(coll)}/vectors`, { method: 'POST', body: JSON.stringify(body) }),
+  search: (project: string, coll: string, body: { query_text?: string; query_vector?: number[]; top_k?: number; min_score?: number }) =>
+    request<{ results: SearchResult[] }>(`${P(project)}/collections/${enc(coll)}/search`, { method: 'POST', body: JSON.stringify(body) }),
+
+  // stats
+  projectStats: (project: string) => request<ProjectStats>(`${P(project)}/stats`),
+  globalStats: () => request<GlobalStats>('/api/v1/stats'),
+}
+```
+
+- [ ] **Step 2b: Build** — `cd webui && npm run build`. Expect 0 type errors. (No runtime test yet.)
+
+- [ ] **Step 3: Commit** `feat(webui): typed API client + types`
+
+---
+
+## Task W3: Auth store + Login page + route guard
+
+**Files:** create `webui/src/stores/authStore.ts`, `webui/src/stores/toastStore.ts`, `webui/src/pages/Login.tsx`; update `webui/src/App.tsx`.
+
+- [ ] **Step 1: `webui/src/stores/authStore.ts`**
+
+```ts
+import { create } from 'zustand'
+import { persist } from 'zustand/middleware'
+import { api } from '@/api/client'
+
+interface AuthState {
+  loggedIn: boolean
+  admin: boolean
+  projects: string[]          // [] with admin=true means "all" (resolve via listProjects)
+  currentProject: string | null
+  login: (key: string) => Promise<void>
+  logout: () => Promise<void>
+  setCurrentProject: (p: string) => void
+  reset: () => void
+}
+
+export const useAuthStore = create<AuthState>()(
+  persist(
+    (set, get) => ({
+      loggedIn: false, admin: false, projects: [], currentProject: null,
+      login: async (key) => {
+        const r = await api.login(key)
+        // For admin or "*" grants, fetch the real project list to populate the selector.
+        let projects = r.projects
+        if (r.admin || projects.includes('*')) {
+          try { projects = (await api.listProjects()).projects } catch { /* keep */ }
+        }
+        set({ loggedIn: true, admin: r.admin, projects, currentProject: projects[0] ?? 'default' })
+      },
+      logout: async () => { try { await api.logout() } finally { get().reset() } },
+      setCurrentProject: (p) => set({ currentProject: p }),
+      reset: () => set({ loggedIn: false, admin: false, projects: [], currentProject: null }),
+    }),
+    { name: 'svapi-auth', partialize: (s) => ({ loggedIn: s.loggedIn, admin: s.admin, projects: s.projects, currentProject: s.currentProject }) },
+  ),
+)
+```
+
+> Note: the cookie is the real credential; this persisted state is just UI hint state. If the cookie has expired, the first API call returns 401 → `request()` resets the store and redirects to `/login`.
+
+- [ ] **Step 2: `webui/src/stores/toastStore.ts`** — minimal zustand toast store: `{ toasts: {id,msg,kind}[], push(msg, kind), dismiss(id) }` with `kind: 'success'|'error'|'info'`. (Used for action feedback.)
+
+- [ ] **Step 3: `webui/src/pages/Login.tsx`** — a centered card: password input for the API key, "Sign in" button, error line. On submit calls `useAuthStore.login(key)`, then `navigate('/')`. On error shows the message (e.g. "invalid key"). Use Tailwind for a clean dark card.
+
+- [ ] **Step 4: update `webui/src/App.tsx`** — add a `ProtectedRoute` wrapper (redirect to `/login` when `!loggedIn`) and wire the `/login` route + a protected shell with child routes (filled in W4+). For W3, protected area can render an empty `<AppShell/>` placeholder + an Outlet.
+
+- [ ] **Step 5: Build** — `npm run build` (0 errors).
+- [ ] **Step 6: Commit** `feat(webui): auth store, login page, protected routes`
+
+---
+
+## Task W4: App shell — sidebar nav + project selector + header
+
+**Files:** create `webui/src/components/AppShell.tsx`, `ProjectSelector.tsx`, `Toast.tsx`; update `App.tsx` routes.
+
+- [ ] **Step 1: `AppShell.tsx`** — a two-column layout: left sidebar (brand "VectorAPI", nav links: Dashboard, Collections, Documents, RAG Tester; and if `admin`: Settings, Projects, Keys — use `lucide-react` icons), top bar with the `<ProjectSelector/>` and a logout button. Renders `<Outlet/>` for the page. Renders `<Toast/>` (toasts) globally. Use `NavLink` active styling.
+
+- [ ] **Step 2: `ProjectSelector.tsx`** — a `<select>` (styled) bound to `useAuthStore.currentProject` / `setCurrentProject`, options from `useAuthStore.projects`. When the list is empty (non-admin with no projects yet) show the single granted project or "default". Changing it re-scopes all pages (they read `currentProject` from the store).
+
+- [ ] **Step 3: `Toast.tsx`** — renders `toastStore.toasts` as stacked dismissible notifications (success=green, error=red, info=slate), bottom-right, auto-dismiss after ~4s.
+
+- [ ] **Step 4: wire routes in `App.tsx`** — protected children: `/` (Dashboard), `/collections`, `/documents`, `/rag`, and admin-only `/settings`, `/projects`, `/keys`. Use lazy placeholders for pages not yet built (W5+), or stub components returning the page title; replace as each page lands.
+
+- [ ] **Step 5: Build** — `npm run build` (0 errors).
+- [ ] **Step 6: Commit** `feat(webui): app shell, sidebar nav, project selector, toasts`
+
+---
+
+## Task W5: Dashboard page
+
+**Files:** `webui/src/pages/Dashboard.tsx`; wire route in `App.tsx`.
+
+- [ ] **Step 1:** Implement `Dashboard.tsx`. Uses react-query:
+  - `useQuery(['projectStats', currentProject], () => api.projectStats(currentProject!))` → cards: project name, collection count, document count.
+  - If `admin`: `useQuery(['globalStats'], api.globalStats)` → a second row of cards: total documents, total collections, memory used (humanize bytes), memory pressure level (color-coded normal/soft/hard/emergency), projects count.
+  - Loading skeletons + error toast on failure.
+- [ ] **Step 2:** Build (`npm run build`, 0 errors).
+- [ ] **Step 3:** Commit `feat(webui): dashboard with project + global stats`
+
+**Acceptance:** with the dev server proxying to a running backend and a logged-in admin, the dashboard shows non-zero/zero numeric cards without console errors.
+
+---
+
+## Task W6: Collections page
+
+**Files:** `webui/src/pages/Collections.tsx`, `webui/src/components/Modal.tsx`, `webui/src/components/ConfirmDialog.tsx`; wire route.
+
+- [ ] **Step 1:** `Modal.tsx` (accessible overlay + card, Esc/backdrop close) and `ConfirmDialog.tsx` (title, message, confirm/cancel) — reused by several pages.
+- [ ] **Step 2:** `Collections.tsx` for `currentProject`:
+  - `useQuery(['collections', project], () => api.listCollections(project))` → a table: name, kind (badge json/vector), dimension (for vector), embedding model, document_count, size. 
+  - "New collection" button → Modal form: name (text), kind (json|vector radio), and when vector: vector_dimension (number, required >0) + embedding_model (text, optional, placeholder `text-embedding-3-small`). Submit → `api.createCollection` → invalidate query + success toast; show validation errors (422) inline/as toast.
+  - Per-row "Delete" → ConfirmDialog → `api.deleteCollection` → invalidate + toast.
+- [ ] **Step 3:** Build (0 errors).
+- [ ] **Step 4:** Commit `feat(webui): collections management page`
+
+**Acceptance:** create a json collection and a vector collection (dim 1536); they appear in the table; deleting removes them; creating a vector collection without a dimension shows the 422 message.
+
+---
+
+## Task W7: Documents page (browse / search / Monaco CRUD)
+
+**Files:** `webui/src/pages/Documents.tsx`, `webui/src/components/JsonEditor.tsx`; wire route.
+
+- [ ] **Step 1:** `JsonEditor.tsx` — wraps `@monaco-editor/react` `<Editor language="json" theme="vs-dark">`, controlled `value`/`onChange`, a height prop. Exposes a `parse()` helper or surfaces parse validity to the parent (disable Save when JSON is invalid).
+- [ ] **Step 2:** `Documents.tsx` for `currentProject`:
+  - Collection picker: a `<select>` populated from `api.listCollections(project)` (json + vector collections both selectable; for vector collections note the `_vector` field is large/read-mostly).
+  - Filter bar: free-form `filter` inputs (field, op dropdown of eq/ne/gt/gte/lt/lte/in/contains/exists/regex/search, value) → builds a query string `?filter=field:op:value&limit=&offset=` and calls `api.findDocuments`. Show `count` + a results table (id + a compact preview of the doc).
+  - Click a row → open Monaco (read the full doc via `api.getDocument`) in an editor panel/modal. Buttons: **Save** (`api.putDocument` whole-doc replace; disabled if JSON invalid), **Delete** (ConfirmDialog → `api.deleteDocument`).
+  - **New document** → Monaco with `{}` → `api.insertDocument(data)` → refresh.
+  - Success/error toasts; invalidate the find query after writes.
+- [ ] **Step 3:** Build (0 errors).
+- [ ] **Step 4:** Commit `feat(webui): document browser + Monaco JSON editor (full CRUD)`
+
+**Acceptance:** insert a doc via the editor, see it in the list, edit + save, filter to find it, delete it.
+
+---
+
+## Task W8: RAG Tester page
+
+**Files:** `webui/src/pages/RagTester.tsx`; wire route.
+
+- [ ] **Step 1:** `RagTester.tsx` for `currentProject`:
+  - Collection picker filtered to **vector** collections only (`kind==='vector'` from `listCollections`).
+  - An "Add item" section: a text area (`text`) + optional metadata JSON (Monaco, small) → `api.storeVector({ text, metadata })`. (Embedding is generated server-side via the collection's model; if OpenAI isn't configured the backend returns 422/503 — surface it as a toast.)
+  - A "Search" section: a query text input + `top_k` (default 5) + `min_score` (default 0) → `api.search({ query_text, top_k, min_score })` → a ranked results list showing `score` (4 decimals) + the result `data` (pretty JSON, `_vector` omitted/collapsed).
+- [ ] **Step 2:** Build (0 errors).
+- [ ] **Step 3:** Commit `feat(webui): RAG search tester`
+
+**Acceptance:** against a vector collection with an OpenAI key configured, add a couple text items and run a query → ranked results with scores. (If no OpenAI key, the add/search shows the backend's 422/503 message — that's correct behavior.)
+
+---
+
+## Task W9: Admin pages — Settings, Projects, Keys
+
+**Files:** `webui/src/pages/Settings.tsx`, `ProjectsAdmin.tsx`, `KeysAdmin.tsx`; wire admin-only routes.
+
+These routes render only for `admin` keys (guard in `App.tsx`; non-admin → redirect to `/`).
+
+- [ ] **Step 1: `Settings.tsx`** — there is no GET-settings endpoint in v1 (settings are write-through + hot-reloaded). So present a **form to update** the DB-backed settings by writing them. *Backend gap:* the API has no `GET /api/v1/settings` or `PUT`. **Add them in the backend first** (see Step 1a), then build this page.
+
+  - [ ] **Step 1a (backend addition):** In the backend (separate small change on a branch), add `GET /api/v1/settings` (admin → returns the current settings JSON with `openai_api_key` masked to a boolean `openai_api_key_set`) and `PUT /api/v1/settings` (admin → accepts `{openai_api_base?, openai_api_key?, default_embedding_model?, cors_origins?, session_ttl_minutes?, webui_enabled?, default_project?}`, merges into the current settings via `SettingsStore::save`, hot-reload picks it up). Add a `registerSettingsRoutes` handler + 2 integration tests (admin GET returns masked; PUT changes a field). Commit on the webui branch. Then add `api.getSettings()`/`api.updateSettings()` to `client.ts` + types.
+  - [ ] **Step 1b:** `Settings.tsx` — form fields for openai_api_base, openai_api_key (password; only sent if non-empty), default_embedding_model, cors_origins (comma-separated → array), session_ttl_minutes (number), webui_enabled (toggle), default_project (text). Load via `getSettings`, save via `updateSettings` → success toast. Note "changes apply immediately (hot reload)".
+
+- [ ] **Step 2: `ProjectsAdmin.tsx`** — `useQuery(api.listProjects)` → table of projects with doc/collection counts (via `api.projectStats` per project, or just names). "New project" (name input, validated `^[a-zA-Z_][a-zA-Z0-9_-]{0,62}$`) → `api.createProject`. Per-row "Delete" (ConfirmDialog; disable for `default`) → `api.deleteProject`. After create/delete, refresh and also refresh the auth store's project list (so the selector updates) — call `api.listProjects()` and `useAuthStore.setState({projects})`.
+
+- [ ] **Step 3: `KeysAdmin.tsx`** — `useQuery(api.listKeys)` → table: label, key_prefix (e.g. `abc12345…`), admin badge, projects (chips), created_at. "New key" → Modal form: label, admin toggle, projects (multi-select from `listProjects` + a `*` option) → `api.createKey` → show the **full key once** in a copy-to-clipboard reveal dialog with a warning it won't be shown again. Per-row: "Edit grants" (Modal → `api.updateKey`) and "Revoke" (ConfirmDialog → `api.deleteKey`; the backend rejects revoking the last admin with 422 → show that message).
+
+- [ ] **Step 4:** Build (0 errors).
+- [ ] **Step 5:** Commit `feat(webui): admin pages — settings, projects, keys`
+
+**Acceptance:** as admin: create a project (appears in selector), create a scoped key (full value shown once), revoke it; update a setting and see the success toast.
+
+---
+
+## Task W10: CMake integration (`npm run build` from CMake)
+
+**Files:** create `cmake/WebUI.cmake`; it is already `include(WebUI OPTIONAL)`-d by the root `CMakeLists.txt` (added in backend Task 1) under `if(BUILD_WEBUI)`.
+
+- [ ] **Step 1: `cmake/WebUI.cmake`**
+
+```cmake
+# Builds the React web UI via npm at CMake build time, into ${CMAKE_BINARY_DIR}/webui/dist.
+find_program(NPM_EXECUTABLE npm)
+if(NOT NPM_EXECUTABLE)
+    message(WARNING "npm not found — skipping web UI build (set BUILD_WEBUI=OFF to silence)")
+    return()
+endif()
+
+set(WEBUI_SRC  "${CMAKE_SOURCE_DIR}/webui")
+set(WEBUI_DIST "${CMAKE_BINARY_DIR}/webui/dist")
+
+# Install dependencies once (when node_modules is absent).
+add_custom_command(
+    OUTPUT "${WEBUI_SRC}/node_modules/.package-lock.json"
+    COMMAND ${NPM_EXECUTABLE} ci --no-audit --no-fund
+    WORKING_DIRECTORY "${WEBUI_SRC}"
+    COMMENT "webui: npm ci"
+    VERBATIM)
+
+# Build the UI into the CMake build tree (outDir override).
+add_custom_command(
+    OUTPUT "${WEBUI_DIST}/index.html"
+    COMMAND ${NPM_EXECUTABLE} run build -- --outDir "${WEBUI_DIST}" --emptyOutDir
+    WORKING_DIRECTORY "${WEBUI_SRC}"
+    DEPENDS "${WEBUI_SRC}/node_modules/.package-lock.json"
+    COMMENT "webui: npm run build -> ${WEBUI_DIST}"
+    VERBATIM)
+
+add_custom_target(webui ALL DEPENDS "${WEBUI_DIST}/index.html")
+```
+
+> `npm ci` requires a `package-lock.json`. Generate one by running `npm install` in `webui/` once (Task W1 already does) and commit it. If `npm ci`'s output marker differs across npm versions, depend on the `package.json` mtime instead; keep the target rebuild-safe (don't force a rebuild every time — gate on the dist index existing and sources changing). Acceptable simplification for v1: make `webui` target run the build each `cmake --build` if marker tracking is flaky, but prefer the OUTPUT-based form above.
+
+- [ ] **Step 2:** Commit `webui/package-lock.json` (from W1's `npm install`).
+
+- [ ] **Step 3: Verify CMake-driven build**
+
+Run: `cmake -B build -G Ninja -DBUILD_WEBUI=ON -S . && cmake --build build -j"$(nproc)"`
+Expected: the `webui` target runs `npm ci` (first time) + `npm run build`, producing `build/webui/dist/index.html`. The backend targets still build. (Backend tests unaffected.)
+
+- [ ] **Step 4: Commit** `build(webui): CMake target runs npm build into the build tree`
+
+---
+
+## Task W11: Playwright end-to-end smoke
+
+**Files:** `webui/e2e/smoke.spec.ts`, `webui/playwright.config.ts`; add `@playwright/test` devDep + an `e2e` script.
+
+This drives the real flow against a running backend + the built UI served by the backend.
+
+- [ ] **Step 1:** Add `@playwright/test` to devDependencies and scripts: `"e2e": "playwright test"`. `playwright.config.ts` with `use: { baseURL: process.env.SVAPI_BASE || 'http://localhost:8080' }`, single chromium project, no webServer (the backend is started by the test harness/operator).
+
+- [ ] **Step 2: `webui/e2e/smoke.spec.ts`** — preconditions documented at top: the backend is running on `:8080` with the built UI as its `--webui-dir` (so `/` serves the SPA), and the admin key is provided via `SVAPI_ADMIN_KEY`. Flow:
+  1. `goto('/')` → redirected to `/login`.
+  2. Fill the key (`process.env.SVAPI_ADMIN_KEY`), submit → lands on dashboard (project selector shows `default`).
+  3. Navigate to Collections → create a json collection `e2e_docs` → assert it appears.
+  4. Navigate to Documents → select `e2e_docs` → New → type `{"name":"e2e"}` in Monaco → Save → assert it appears in the list.
+  5. Delete the collection (cleanup) → assert gone.
+  6. Logout → redirected to `/login`.
+  Use resilient selectors (roles/labels/test-ids). Add `data-testid` attributes to the relevant controls in the pages as needed (small edits).
+
+- [ ] **Step 3: Run the smoke** (operator/harness sequence — document it in the spec, don't bake secrets in):
+
+```bash
+# Build UI + start backend serving it, with a known admin key:
+cmake --build build -j"$(nproc)"
+SMARTBOTIC_VECTORAPI_KEY=e2eadmin ./build/src/smartbotic-vectorapi \
+  --config config/config.json --share-dir "$PWD/api" --webui-dir "$PWD/build/webui/dist" &
+# then:
+cd webui && SVAPI_ADMIN_KEY=e2eadmin npx playwright test
+# stop the backend afterwards; the test cleans up its e2e_docs collection.
+```
+Expected: the smoke passes (login → create → doc → delete → logout). If Playwright browsers aren't installed, `npx playwright install chromium` first; if the environment can't run a browser, mark this task DONE_WITH_CONCERNS and rely on `npm run build` + the curl-level checks.
+
+- [ ] **Step 4: Commit** `test(webui): Playwright login→collection→document→logout smoke`
+
+---
+
+## Web UI definition of done
+
+- `cd webui && npm run build` passes with **0 TypeScript errors**.
+- `cmake --build build -DBUILD_WEBUI=ON` produces `build/webui/dist/index.html` via the `webui` target.
+- With the backend serving `--webui-dir build/webui/dist`: login with the admin key works; the project selector lists granted projects; collections/documents/RAG/stats pages function; admin sees settings/projects/keys; non-admin keys don't see admin routes and get the backend's 403s on forbidden actions.
+- The Playwright smoke passes (or DONE_WITH_CONCERNS if no browser available in the env).
+
+## Self-review
+
+**Spec coverage (Addendum A.7):** login (W3) · project selector (W4) · dashboard/stats (W5) · collections (W6) · documents+Monaco CRUD (W7) · RAG tester (W8) · admin settings/projects/keys (W9). Backend gap closed: `GET`/`PUT /api/v1/settings` added in W9 Step 1a (the only backend change in this plan).
+
+**Placeholder scan:** foundation tasks (W1–W4, W10, W11) carry full code; page tasks (W5–W9) are component specs with the exact API calls, UX, and acceptance criteria enumerated — deliberately not pixel-level, since the implementer writes idiomatic React/Tailwind and the gate is `tsc` + the e2e smoke.
+
+**Consistency:** all pages read `currentProject`/`admin` from `useAuthStore`; all data access goes through the typed `api` object (W2); writes invalidate the relevant react-query keys; the project segment is always `encodeURIComponent`'d via the client. The `GET/PUT /settings` backend addition (W9.1a) is the one new endpoint and must also be reflected in `openapi.json` + `llms.txt`.
+