From 96132921f0ddf2984f5809b0b594c3280f67c7d3 Mon Sep 17 00:00:00 2001 From: Gordon YUEN Date: Thu, 30 Jul 2026 09:58:13 +0800 Subject: [PATCH 01/46] feat(chat): support OpenAI-compatible endpoints via CHAT_BASE_URL Add a second chat backend alongside the Vercel AI Gateway: when CHAT_BASE_URL is set, resolve a LanguageModelV3 via @ai-sdk/openai-compatible using CHAT_API_KEY, mandating CHAT_MODEL. Call sites in prompt.ts and diagram.ts use getChatModel()/isChatConfigured()/getChatModelLabel() instead of hard-requiring AI_GATEWAY_API_KEY. Pin @ai-sdk/openai-compatible@2.x (provider spec V3) to match ai@6; the 3.x line targets @ai-sdk/provider@4 and is incompatible. ADR 0007 records the decision. --- .env.local.example | 17 +++- README.md | 6 +- docs/adr/0007-chat-provider-abstraction.md | 51 +++++++++++ package.json | 1 + pnpm-lock.yaml | 98 +++++++++++----------- src/domains/chat/diagram.ts | 14 ++-- src/domains/chat/prompt.ts | 15 ++-- src/lib/ai.test.ts | 82 ++++++++++++++++++ src/lib/ai.ts | 71 ++++++++++++++-- 9 files changed, 283 insertions(+), 72 deletions(-) create mode 100644 docs/adr/0007-chat-provider-abstraction.md create mode 100644 src/lib/ai.test.ts diff --git a/.env.local.example b/.env.local.example index 7340c1a..6c0aa3e 100644 --- a/.env.local.example +++ b/.env.local.example @@ -8,11 +8,22 @@ # KNOWHERE_API_KEY=sk_your_development_key_here # --- Chat provider (server-side only) --- -# Vercel AI Gateway key; AI SDK picks it up automatically +# Two mutually exclusive chat backends. Chat is inert until one is configured. +# +# 1. Vercel AI Gateway (default): the AI SDK picks up this key automatically +# when the model is passed as a plain string. AI_GATEWAY_API_KEY=vck_your_key_here -# Optional override — defaults to deepseek/deepseek-v4-flash -# CHAT_MODEL=deepseek/deepseek-v4-flash +# 2. Generic OpenAI-compatible API: point at any OpenAI-compatible endpoint +# (local LLM, self-hosted gateway, etc.) instead of the Vercel AI Gateway. +# CHAT_MODEL is MANDATORY in this mode. CHAT_API_KEY is the bearer key sent +# to that endpoint. +# CHAT_BASE_URL=http://localhost:11434/v1 +# CHAT_API_KEY=sk_your_openai_compatible_key +# CHAT_MODEL=qwen-plus + +# Optional override — defaults to google/gemini-3-flash +# CHAT_MODEL=google/gemini-3-flash # --- Auth (server-side only) --- # diff --git a/README.md b/README.md index efde58d..357d635 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,9 @@ Upload documents, explore parsed content, and ask questions about your knowledge ``` 2. Fill in your API keys in `.env.local`: - - `AI_GATEWAY_API_KEY` — your Vercel AI Gateway key for chat (optional `CHAT_MODEL` override) + - Chat (one of): + - `AI_GATEWAY_API_KEY` — Vercel AI Gateway key (optional `CHAT_MODEL` override, default `google/gemini-3-flash`), or + - `CHAT_BASE_URL` + `CHAT_API_KEY` + `CHAT_MODEL` — any OpenAI-compatible endpoint (e.g. DeepSeek, local Xinference/vLLM). `CHAT_MODEL` is required in this mode. - `KNOWHERE_API_KEY` — optional development override that skips Dashboard auth and calls Knowhere directly - `NEXT_PUBLIC_POSTHOG_KEY` — PostHog Project API key for front-end event tracking - `NEXT_PUBLIC_POSTHOG_HOST` — PostHog ingestion host (default `https://us.i.posthog.com`) @@ -63,7 +65,7 @@ GA4 field and event alignment guidance lives in `docs/ga4-alignment.md`. ## Tech Stack - **Framework**: [Next.js 16](https://nextjs.org) with App Router and Server Components -- **AI**: [Vercel AI SDK](https://sdk.vercel.ai) + [Vercel AI Gateway](https://vercel.com/docs/ai-gateway) +- **AI**: [Vercel AI SDK](https://sdk.vercel.ai) via the [Vercel AI Gateway](https://vercel.com/docs/ai-gateway) or any OpenAI-compatible endpoint (see `docs/adr/0007-chat-provider-abstraction.md`) - **Knowledge**: [Knowhere Node.js SDK](https://github.com/Ontos-AI/knowhere-sdk) for document parsing and retrieval - **UI**: [shadcn/ui](https://ui.shadcn.com) + Tailwind CSS 4 - **Icons**: [Lucide](https://lucide.dev) diff --git a/docs/adr/0007-chat-provider-abstraction.md b/docs/adr/0007-chat-provider-abstraction.md new file mode 100644 index 0000000..529da00 --- /dev/null +++ b/docs/adr/0007-chat-provider-abstraction.md @@ -0,0 +1,51 @@ +# ADR 0007: Chat Provider Abstraction (Vercel AI Gateway Or OpenAI-Compatible) + +## Status + +Accepted + +## Context + +Notebook chat — answer generation (`src/domains/chat/prompt.ts`) and diagram +generation (`src/domains/chat/diagram.ts`) — routed exclusively through the +Vercel AI Gateway. The model was passed to the AI SDK as a plain string id and +`AI_GATEWAY_API_KEY` was hard-required by a guard at every call site. + +Self-hosted and local-LLM deployments need to point chat at an arbitrary +OpenAI-compatible endpoint (DeepSeek, local Xinference/vLLM, etc.) without the +Gateway. There was no way to do that without editing call-site code. + +## Decision + +Resolve the chat model once in `src/lib/ai.ts`. Two mutually exclusive backends, +selected by environment: + +- `AI_GATEWAY_API_KEY` set (default): pass the model id as a plain string; the + AI SDK resolves it through the Vercel AI Gateway and reads the key + automatically. `CHAT_MODEL` overrides the default id. +- `CHAT_BASE_URL` set: build a `LanguageModelV3` with + `@ai-sdk/openai-compatible` from `CHAT_BASE_URL` + `CHAT_API_KEY`. + `CHAT_MODEL` is **mandatory** in this mode. + +Call sites use `getChatModel()` (the model to pass to `generateObject` / +`ToolLoopAgent`), `getChatModelLabel()` (stable log label), and +`isChatConfigured()` (the guard) instead of referencing `AI_GATEWAY_API_KEY` +directly. + +The `@ai-sdk/openai-compatible` package is pinned to the `2.x` line. The `3.x` +line targets `@ai-sdk/provider@4` (spec V4), which is incompatible with this +repo's `ai@6` (spec V3). Re-pin both together when upgrading `ai` to a V4-based +release. + +## Consequences + +Chat can target any OpenAI-compatible endpoint by setting three env vars, with +no code change. The Gateway path is unchanged. + +Adding a third provider means extending `getChatModel()` and +`isChatConfigured()`. Do not reintroduce per-call-site `AI_GATEWAY_API_KEY` +guards. + +`CHAT_MODEL` is read at call time in the OpenAI-compatible path and at module +load in the Gateway path; tests that mutate `CHAT_MODEL` after import should +assert behavior rather than the resolved module constant. diff --git a/package.json b/package.json index 73b1e26..436332f 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "upstash:dev": "npx @upstash/qstash-cli dev" }, "dependencies": { + "@ai-sdk/openai-compatible": "2.0.63", "@ai-sdk/react": "^3.0.177", "@antv/chart-visualization-skills": "0.1.3", "@effect/platform": "^0.96.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1c4223f..123201f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@ai-sdk/openai-compatible': + specifier: 2.0.63 + version: 2.0.63(zod@4.4.3) '@ai-sdk/react': specifier: ^3.0.177 version: 3.0.177(react@19.2.4)(zod@4.4.3) @@ -204,16 +207,32 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/openai-compatible@2.0.63': + resolution: {integrity: sha512-EmrD7iRboidulu6yHfMiMhd6RQSw8KrIWhNLK8vl5brQZbIjXkyhUU+FULZM3P4m46Vatzx8u3vX1w/qmFUmqA==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider-utils@4.0.26': resolution: {integrity: sha512-CsKNLKsOpvPujRlIYvoz+Ybw+kGn7J4/fIZa/58+R7iWLLfwn6ifE2G6Yq8K9XvH/I/3bzaDAJ3NhRwEMsLBKQ==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider-utils@4.0.41': + resolution: {integrity: sha512-I7hhjfw01yEI8NkuAsT8Mv6xbWFr/lqLXMdaJQ2zWfXEpxog1eT7skDcv1+RY29/+5btzH8wD+vVvy48bk9oNQ==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider@3.0.10': resolution: {integrity: sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==} engines: {node: '>=18'} + '@ai-sdk/provider@3.0.14': + resolution: {integrity: sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA==} + engines: {node: '>=18'} + '@ai-sdk/react@3.0.177': resolution: {integrity: sha512-7K3bmj2ajbAkrqR7P8bByKp0w2iACGSIpahoEkeUhhZqVJO4/mxqk6Q5wcd12EaOi+5+86k2VH91BKgzCuCRaw==} engines: {node: '>=18'} @@ -946,6 +965,10 @@ packages: '@noble/hashes': optional: true + '@fastify/busboy@2.1.1': + resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} + engines: {node: '>=14'} + '@floating-ui/core@1.7.5': resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} @@ -1017,105 +1040,89 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -1264,35 +1271,30 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@napi-rs/canvas-linux-arm64-musl@0.1.100': resolution: {integrity: sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@napi-rs/canvas-linux-riscv64-gnu@0.1.100': resolution: {integrity: sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] - libc: [glibc] '@napi-rs/canvas-linux-x64-gnu@0.1.100': resolution: {integrity: sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@napi-rs/canvas-linux-x64-musl@0.1.100': resolution: {integrity: sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@napi-rs/canvas-win32-arm64-msvc@0.1.100': resolution: {integrity: sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==} @@ -1346,28 +1348,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@next/swc-linux-arm64-musl@16.2.4': resolution: {integrity: sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@next/swc-linux-x64-gnu@16.2.4': resolution: {integrity: sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@next/swc-linux-x64-musl@16.2.4': resolution: {integrity: sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@next/swc-win32-arm64-msvc@16.2.4': resolution: {integrity: sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==} @@ -1895,42 +1893,36 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==} @@ -2015,28 +2007,24 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.2.4': resolution: {integrity: sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.2.4': resolution: {integrity: sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.4': resolution: {integrity: sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.4': resolution: {integrity: sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==} @@ -2275,49 +2263,41 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] - libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -3950,28 +3930,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -5144,6 +5120,10 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici@5.29.0: + resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==} + engines: {node: '>=14.0'} + undici@6.25.0: resolution: {integrity: sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==} engines: {node: '>=18.17'} @@ -5462,6 +5442,12 @@ snapshots: '@vercel/oidc': 3.2.0 zod: 4.4.3 + '@ai-sdk/openai-compatible@2.0.63(zod@4.4.3)': + dependencies: + '@ai-sdk/provider': 3.0.14 + '@ai-sdk/provider-utils': 4.0.41(zod@4.4.3) + zod: 4.4.3 + '@ai-sdk/provider-utils@4.0.26(zod@4.4.3)': dependencies: '@ai-sdk/provider': 3.0.10 @@ -5469,10 +5455,22 @@ snapshots: eventsource-parser: 3.0.8 zod: 4.4.3 + '@ai-sdk/provider-utils@4.0.41(zod@4.4.3)': + dependencies: + '@ai-sdk/provider': 3.0.14 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.0.8 + undici: 5.29.0 + zod: 4.4.3 + '@ai-sdk/provider@3.0.10': dependencies: json-schema: 0.4.0 + '@ai-sdk/provider@3.0.14': + dependencies: + json-schema: 0.4.0 + '@ai-sdk/react@3.0.177(react@19.2.4)(zod@4.4.3)': dependencies: '@ai-sdk/provider-utils': 4.0.26(zod@4.4.3) @@ -6052,6 +6050,8 @@ snapshots: optionalDependencies: '@noble/hashes': 1.8.0 + '@fastify/busboy@2.1.1': {} + '@floating-ui/core@1.7.5': dependencies: '@floating-ui/utils': 0.2.11 @@ -10539,6 +10539,10 @@ snapshots: undici-types@6.21.0: {} + undici@5.29.0: + dependencies: + '@fastify/busboy': 2.1.1 + undici@6.25.0: {} undici@7.25.0: {} diff --git a/src/domains/chat/diagram.ts b/src/domains/chat/diagram.ts index 41b29c8..a01d1cc 100644 --- a/src/domains/chat/diagram.ts +++ b/src/domains/chat/diagram.ts @@ -3,7 +3,7 @@ import g2SkillIndex from "@antv/chart-visualization-skills/dist/index/g2.index.j import type { Skill } from "@antv/chart-visualization-skills" import { z } from "zod" -import { CHAT_MODEL } from "@/lib/ai" +import { getChatModel, getChatModelLabel, isChatConfigured } from "@/lib/ai" import { summarizeUnknownError } from "@/lib/format-log-value" import { logger } from "@/lib/logger" @@ -132,9 +132,11 @@ export function parseChatDiagramRequestBody( export async function generateChatDiagramSpec(input: { readonly answer: string }): Promise { - if (!process.env.AI_GATEWAY_API_KEY) { + if (!isChatConfigured()) { throw new Error( - "AI_GATEWAY_API_KEY environment variable is required. Set it in your .env.local file.", + "Chat is not configured. Set either AI_GATEWAY_API_KEY (Vercel AI " + + "Gateway) or CHAT_BASE_URL + CHAT_MODEL + CHAT_API_KEY (OpenAI-compatible) " + + "in .env.local.", ) } @@ -191,18 +193,18 @@ async function requestChatDiagramObject(input: { }): Promise { logger.info("chat-diagram: llm request", { attempt: input.attempt, - model: CHAT_MODEL, + model: getChatModelLabel(), promptCharLength: input.prompt.length, }) const response = await generateObject({ - model: CHAT_MODEL, + model: getChatModel(), schema: chatDiagramSpecSchema, prompt: input.prompt, }) const spec = normalizeChatDiagramSpec(response.object) logger.info("chat-diagram: llm response", { attempt: input.attempt, - model: CHAT_MODEL, + model: getChatModelLabel(), type: spec.type, dataPointCount: spec.type === "none" ? 0 : spec.data.length, }) diff --git a/src/domains/chat/prompt.ts b/src/domains/chat/prompt.ts index b13831e..bcc6411 100644 --- a/src/domains/chat/prompt.ts +++ b/src/domains/chat/prompt.ts @@ -1,6 +1,6 @@ import { Effect } from "effect" -import { CHAT_MODEL } from "@/lib/ai" +import { getChatModel, getChatModelLabel, isChatConfigured } from "@/lib/ai" import { logger } from "@/lib/logger" import type { Source } from "@/infrastructure/db/schema" import type { ChatCitationView } from "@/domains/chat/types" @@ -35,11 +35,12 @@ export const generateAgenticOutputManifestEffect = ( input: GenerateAgenticOutputManifestInput, ): Effect.Effect => Effect.gen(function* () { - if (!process.env.AI_GATEWAY_API_KEY) { + if (!isChatConfigured()) { return yield* Effect.die( new Error( - "AI_GATEWAY_API_KEY environment variable is required. " + - "Set it in your .env.local file.", + "Chat is not configured. Set either AI_GATEWAY_API_KEY " + + "(Vercel AI Gateway) or CHAT_BASE_URL + CHAT_MODEL + CHAT_API_KEY " + + "(OpenAI-compatible) in .env.local.", ), ) } @@ -47,7 +48,7 @@ export const generateAgenticOutputManifestEffect = ( const turn = buildNotebookHarnessTurn(input) logger.info("chat-agent: harness request", { operation: "generateAgenticOutputManifest.initial", - model: CHAT_MODEL, + model: getChatModelLabel(), surface: turn.surface, recentTurnCount: turn.recentTurns.length, messageCharLength: turn.userText.length, @@ -55,7 +56,7 @@ export const generateAgenticOutputManifestEffect = ( const result = yield* Effect.tryPromise(() => runAgentHarness({ - model: CHAT_MODEL, + model: getChatModel(), turn, retrieval: { query: (request) => @@ -66,7 +67,7 @@ export const generateAgenticOutputManifestEffect = ( logger.info("chat-agent: harness response", { operation: "generateAgenticOutputManifest.final", - model: CHAT_MODEL, + model: getChatModelLabel(), answerLength: result.manifest.text.length, citationCount: result.manifest.citations.length, artifactCount: result.manifest.artifacts.length, diff --git a/src/lib/ai.test.ts b/src/lib/ai.test.ts new file mode 100644 index 0000000..3423a33 --- /dev/null +++ b/src/lib/ai.test.ts @@ -0,0 +1,82 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest" + +import { getChatModel, getChatModelLabel, isChatConfigured } from "./ai" + +const original = { + AI_GATEWAY_API_KEY: process.env.AI_GATEWAY_API_KEY, + CHAT_BASE_URL: process.env.CHAT_BASE_URL, + CHAT_MODEL: process.env.CHAT_MODEL, + CHAT_API_KEY: process.env.CHAT_API_KEY, +} + +beforeEach(() => { + delete process.env.AI_GATEWAY_API_KEY + delete process.env.CHAT_BASE_URL + delete process.env.CHAT_MODEL + delete process.env.CHAT_API_KEY +}) + +afterEach(() => { + for (const [key, value] of Object.entries(original)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } +}) + +describe("isChatConfigured", () => { + it("is false when no chat env is set", () => { + expect(isChatConfigured()).toBe(false) + }) + + it("is true when AI_GATEWAY_API_KEY is set", () => { + process.env.AI_GATEWAY_API_KEY = "vck_test" + expect(isChatConfigured()).toBe(true) + }) + + it("is true when CHAT_BASE_URL is set", () => { + process.env.CHAT_BASE_URL = "http://localhost:11434/v1" + expect(isChatConfigured()).toBe(true) + }) +}) + +describe("getChatModel", () => { + it("returns the gateway model string when CHAT_BASE_URL is unset", () => { + expect(getChatModel()).toBe("google/gemini-3-flash") + }) + + it("builds an OpenAI-compatible model from CHAT_BASE_URL + CHAT_MODEL + CHAT_API_KEY", () => { + process.env.CHAT_BASE_URL = "http://localhost:11434/v1" + process.env.CHAT_MODEL = "qwen-plus" + process.env.CHAT_API_KEY = "sk_test" + + const model = getChatModel() + + expect(typeof model).toBe("object") + expect((model as { readonly modelId: string }).modelId).toBe("qwen-plus") + expect( + (model as { readonly specificationVersion: string }).specificationVersion, + ).toBe("v3") + }) + + it("throws when CHAT_BASE_URL is set without CHAT_MODEL", () => { + process.env.CHAT_BASE_URL = "http://localhost:11434/v1" + delete process.env.CHAT_MODEL + delete process.env.CHAT_API_KEY + + expect(() => getChatModel()).toThrow(/CHAT_MODEL is required/) + }) + + it("throws when CHAT_BASE_URL is set without CHAT_API_KEY", () => { + process.env.CHAT_BASE_URL = "http://localhost:11434/v1" + process.env.CHAT_MODEL = "qwen-plus" + delete process.env.CHAT_API_KEY + + expect(() => getChatModel()).toThrow(/CHAT_API_KEY is required/) + }) +}) + +describe("getChatModelLabel", () => { + it("returns the default model id when CHAT_MODEL is unset", () => { + expect(getChatModelLabel()).toBe("google/gemini-3-flash") + }) +}) diff --git a/src/lib/ai.ts b/src/lib/ai.ts index b1313ff..483752d 100644 --- a/src/lib/ai.ts +++ b/src/lib/ai.ts @@ -1,12 +1,69 @@ +import { createOpenAICompatible } from "@ai-sdk/openai-compatible" + /** - * Server-side AI configuration — routes through the Vercel AI Gateway. + * Server-side AI configuration. + * + * Two mutually exclusive chat backends, selected by environment: + * + * 1. Vercel AI Gateway (default): set `AI_GATEWAY_API_KEY`. The model is passed + * to the AI SDK as a plain string (e.g. "google/gemini-3-flash"); the SDK + * resolves it through the Gateway and reads the key automatically. Override + * the model with `CHAT_MODEL`. * - * The Gateway gives us one key, usage monitoring, and easy provider/model - * swaps without code changes. The AI SDK picks up `AI_GATEWAY_API_KEY` - * automatically when a model is passed as a plain string like - * `"google/gemini-3-flash"`, so this module just owns the model choice. + * 2. Generic OpenAI-compatible API: set `CHAT_BASE_URL` (plus `CHAT_API_KEY`). + * The model is built as a LanguageModelV3 against that base URL, so any + * OpenAI-compatible endpoint (local LLM, self-hosted gateway, etc.) works + * without the Vercel AI Gateway. `CHAT_MODEL` is MANDATORY in this mode. + */ + +const DEFAULT_GATEWAY_MODEL = "google/gemini-3-flash" + +/** Model id string, used as the Gateway model and as a log label. */ +export const CHAT_MODEL = process.env.CHAT_MODEL ?? DEFAULT_GATEWAY_MODEL + +/** + * True when chat is wired up: either the Vercel AI Gateway key is set, or the + * OpenAI-compatible `CHAT_BASE_URL` is set. + */ +export function isChatConfigured(): boolean { + return ( + Boolean(process.env.AI_GATEWAY_API_KEY?.trim()) || + Boolean(process.env.CHAT_BASE_URL?.trim()) + ) +} + +/** + * Resolve the model to pass to AI SDK calls (`generateObject`, `ToolLoopAgent`). * - * Change CHAT_MODEL here (or via env) when we want to try a different model. + * - OpenAI-compatible mode (`CHAT_BASE_URL` set): returns a `LanguageModelV3` + * built from `CHAT_BASE_URL` + `CHAT_API_KEY`. Requires `CHAT_MODEL`. + * - Gateway mode (default): returns the plain model id string; the AI SDK + * resolves it via the Vercel AI Gateway using `AI_GATEWAY_API_KEY`. */ +export function getChatModel() { + const baseURL = process.env.CHAT_BASE_URL?.trim() + if (baseURL) { + const modelId = process.env.CHAT_MODEL?.trim() + if (!modelId) { + throw new Error( + "CHAT_MODEL is required when CHAT_BASE_URL is set. Provide the model " + + "id your OpenAI-compatible endpoint exposes.", + ) + } + const apiKey = process.env.CHAT_API_KEY?.trim() + if (!apiKey) { + throw new Error("CHAT_API_KEY is required when CHAT_BASE_URL is set.") + } + return createOpenAICompatible({ + name: "chat", + baseURL, + apiKey, + }).chatModel(modelId) + } + return CHAT_MODEL +} -export const CHAT_MODEL = process.env.CHAT_MODEL ?? "google/gemini-3-flash" +/** Stable model label for logs, regardless of backend. */ +export function getChatModelLabel(): string { + return CHAT_MODEL +} From d699610f85a2324b8429a5ffa22f8505522b74fe Mon Sep 17 00:00:00 2001 From: Gordon YUEN Date: Thu, 30 Jul 2026 10:00:35 +0800 Subject: [PATCH 02/46] docs: expand AGENTS.md ramp-up guide Add commands, architecture, key conventions, domain language, UI/design, and testing-quirks sections so future sessions ramp up without re-deriving them. Includes the chat-provider convention and a note that the test:integration script glob is stale. --- AGENTS.md | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index dfedc7e..c5b072c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,68 @@ details when the documentation isn't enough. +## Commands + +- **Install:** `pnpm install` (uses pnpm 10, Node 22) +- **Dev:** `pnpm dev` (starts Upstash QStash dev server in background + Next.js dev) +- **Lint:** `pnpm lint` +- **Typecheck:** `pnpm typecheck` +- **Unit tests:** `pnpm test` (vitest, node environment) +- **Single test:** `pnpm test -- src/path/to.test.ts` +- **Watch tests:** `pnpm test:watch` +- **E2E tests:** `pnpm test:e2e` (Playwright, chromium only) +- **Integration tests:** `pnpm test:integration` (needs `TEST_DATABASE_URL`; script currently globs `src/lib/*.integration.test.ts` which has no matches — real integration tests are in `src/domains/`) +- **DB schema push:** `pnpm db:push` (dev); `pnpm db:migrate` (prod) +- **Build:** `pnpm build` + +CI runs: `lint → typecheck → test → build` on PRs to `main` and `staging`. + +## Architecture + +``` +src/ + app/ Next.js App Router pages and route handlers + components/ React components — domain features and shadcn/ui primitives + domains/ Product logic: chat, chunks, demo, sources, workspace + infrastructure/ Owned platform: auth, database (Drizzle + Neon Postgres) + integrations/ External systems: Dashboard oRPC, Knowhere SDK + lib/ Cross-cutting utilities (effect-operation, route-result, etc.) + agent-harness/ Chat agent validation/ledger runtime + providers/ Client-side context providers + proxy.ts Edge middleware (renamed from middleware.ts in Next.js 16) +``` + +- Route handlers are thin HTTP adapters: parse request → call a **Route Service** (in `src/domains/*/route-*.ts`) → serialize `RouteResult`. See `src/app/api/chat/route.ts` for the pattern. +- `RouteResult` (`src/lib/route-result.ts`) is the standard return type: `{ status, body }`. Use `routeResult.ok()`, `routeResult.badRequest()`, etc. +- `nextRouteContext` (`src/lib/next-route-context.ts`) extracts the cookie header from the incoming request for Route Services. +- Domain modules own workflow logic; Route Services own the route-to-domain boundary. + +## Key Conventions + +- **Path alias:** `@/*` → `./src/*` +- **server-only:** Server modules import `server-only`. Vitest aliases it to a no-op stub (`src/test/server-only-stub.ts`). +- **Dashboard oRPC bodies:** Always use `setEmptyJsonBody` from `src/integrations/dashboard/orpc-request.ts`. Effect's `bodyText` defaults to `text/plain`, which causes Dashboard to return the wrong response shape (200 schema mismatch → "no valid session"). +- **No raw fetch in app code:** Use Effect's `HttpClient`/`HttpClientRequest` or an existing wrapper. +- **Soft deletes:** Resources use `deletedAt` timestamps; reads filter `deleted_at IS NULL` by default. +- **DB schema:** Only portable Postgres. No Neon-only features, no pgvector. Schema at `src/infrastructure/db/schema.ts`. Drizzle config at `drizzle.config.ts` points to `DATABASE_URL`. +- **Database driver:** `DATABASE_DRIVER=pg` for local dev (postgres-js), `neon` (default) for Vercel/Neon production. +- **Auth:** Dashboard is the source of truth. Notebook forwards the session cookie; it never decodes tokens. `KNOWHERE_API_KEY` env enables API-key dev mode (skips Dashboard auth, uses a deterministic local user). +- **Chat provider:** two backends in `src/lib/ai.ts` — `AI_GATEWAY_API_KEY` (Vercel AI Gateway, model as plain string) OR `CHAT_BASE_URL`+`CHAT_API_KEY`+`CHAT_MODEL` (OpenAI-compatible `LanguageModelV3`). Use `getChatModel()`/`isChatConfigured()`; never reintroduce per-call-site `AI_GATEWAY_API_KEY` guards. `@ai-sdk/openai-compatible` is pinned to 2.x (provider V3) to match `ai@6`. + +## Domain Language + +See `CONTEXT.md` for precise definitions of Workspace, Source, Parsed Chunk, Chat Thread, Citation, Route Service, Route Context, and other domain terms. Use those names in modules, tests, and route workflows. + ## UI & Design -The notebook should reuse the existing design units from the dashboard(github.com/ontosAI/knowhere-dashboard), like theme, styles, buttons, form elements, etc. For any new design units, please refer to the dashboard's design system and maintain consistency in terms of spacing, typography, and color usage. +- Reuse design units from the dashboard (github.com/ontosAI/knowhere-dashboard). Match spacing, typography, and color usage. +- shadcn/ui (base-nova style, Tailwind CSS 4). Add components via the shadcn skill or `pnpm dlx shadcn@latest add `. +- Installed shadcn primitives: alert-dialog, badge, button, card, checkbox, dialog, dropdown-menu, empty, input, scroll-area, separator, sheet, skeleton, spinner, tabs, textarea, tooltip. +- Lucide icons. Semantic Tailwind colors (`bg-primary`, `text-muted-foreground`), never raw color values. + +## Testing Quirks + +- Unit tests run in **node** environment (not jsdom) by default. Test files: `src/**/*.test.ts`. +- `server-only` is stubbed out in tests — don't expect it to throw. +- Integration tests (in `src/domains/`) use `describe.skip` when `TEST_DATABASE_URL` is unset, so `pnpm test` includes them as safe skips. To run them for real, set `TEST_DATABASE_URL` to a running Postgres. +- E2E tests (Playwright) are in `e2e/`, match `**/*.e2e.ts`. They start `pnpm dev` automatically unless `PLAYWRIGHT_EXTERNAL_WEB_SERVER=1`. From e6b689fcca09e1f8d9f3dc759fe3ba331159969a Mon Sep 17 00:00:00 2001 From: Gordon YUEN Date: Thu, 30 Jul 2026 14:41:40 +0800 Subject: [PATCH 03/46] fix(chunks): skip Vercel Blob cache when unconfigured instead of 500ing loadChunkPageForSource called readCachedChunkPage (Vercel Blob get) with no BLOB_READ_WRITE_TOKEN, throwing 'No token found' and crashing the chunks route (500, empty body). Local/self-hosted dev has no Blob store, so the inspect/chunks view 500'd on every workspace source. Gate the chunk-page cache on BLOB_READ_WRITE_TOKEN (an explicitly injected cacheStore bypasses the gate, so tests are unaffected) and treat any cache read failure as a miss. With no Blob configured the route now serves chunks straight from Knowhere. Verified: /api/sources//chunks now returns 200 and the /inspect//chunks page renders with no client errors. --- src/domains/chunks/server.ts | 51 ++++++++++++++++++++++++++++-------- 1 file changed, 40 insertions(+), 11 deletions(-) diff --git a/src/domains/chunks/server.ts b/src/domains/chunks/server.ts index a28ee3b..7132931 100644 --- a/src/domains/chunks/server.ts +++ b/src/domains/chunks/server.ts @@ -104,6 +104,18 @@ const defaultBlobStore: ChunkPageBlobStore = { }), } +/** + * The chunk-page cache is a best-effort optimization backed by Vercel Blob. + * Local/self-hosted dev (and any deploy without `BLOB_READ_WRITE_TOKEN`) has + * no Blob store, so `@vercel/blob` calls throw "No token found". Treat a + * missing token as "cache unavailable" and fetch from Knowhere directly + * instead of crashing the chunks route. An explicitly injected `cacheStore` + * (tests / custom stores) bypasses this gate. + */ +function isBlobCacheConfigured(): boolean { + return Boolean(process.env.BLOB_READ_WRITE_TOKEN?.trim()) +} + const defaultFetchAsset: FetchChunkAsset = (assetUrl: string) => fetch(assetUrl) const defaultScheduleWarm: ChunkPageWarmScheduler = ( @@ -159,6 +171,8 @@ export const loadChunkPageForSource = ( const mode = options.mode ?? visibleChunkPageMode const workspaceId = options.workspaceId ?? source.workspaceId const cacheStore = options.cacheStore ?? defaultBlobStore + const cacheAvailable = + options.cacheStore !== undefined || isBlobCacheConfigured() const includeAssetUrls = mode === visibleChunkPageMode const revisionProbeResponse = yield* Effect.promise(() => client.documents.listChunks(source.knowhereDocumentId!, { @@ -170,16 +184,31 @@ export const loadChunkPageForSource = ( const probeRevisionKey = getRevisionKey(revisionProbeResponse, source) if (probeRevisionKey) { scheduleRevisionKeyUpdate(source, probeRevisionKey, options.onRevisionKey) - const cachedPage = yield* Effect.promise(() => - readCachedChunkPage({ - cacheStore, - documentId: source.knowhereDocumentId!, - mode, - params, - revisionKey: probeRevisionKey, - workspaceId, - }), - ) + const cachedPage = cacheAvailable + ? yield* Effect.promise(() => + readCachedChunkPage({ + cacheStore, + documentId: source.knowhereDocumentId!, + mode, + params, + revisionKey: probeRevisionKey, + workspaceId, + }), + ).pipe( + Effect.catchAll((error) => + Effect.sync(() => { + logger.warn("chunks: cached chunk page read failed", { + documentId: source.knowhereDocumentId, + page: params.page, + pageSize: params.pageSize, + revisionKey: probeRevisionKey, + error: getErrorMessage(error), + }) + return null + }), + ), + ) + : null if (cachedPage) return cachedPage } @@ -207,7 +236,7 @@ export const loadChunkPageForSource = ( : {}, }) - if (revisionKey) { + if (revisionKey && cacheAvailable) { if (mode === visibleChunkPageMode) { scheduleChunkPageWarm({ source, From 7c23f784c1ba86423058262ed5adbec22f6076ec Mon Sep 17 00:00:00 2001 From: Gordon YUEN Date: Thu, 30 Jul 2026 15:16:29 +0800 Subject: [PATCH 04/46] fix(fonts): use local geist package instead of next/font/google next/font/google (Geist) failed to reach fonts.googleapis.com in airgapped/local dev, repeating 'error while requesting resource' warnings and falling back to a system font. The geist npm package ships the same typeface as next/font/local with the same --font-geist-sans / --font-geist-mono variables, so globals.css is unchanged and no Google Fonts calls are made. --- package.json | 1 + pnpm-lock.yaml | 12 ++++++++++++ src/app/layout.tsx | 15 +++------------ 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index 436332f..4c43742 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "dompurify": "^3.4.2", "drizzle-orm": "^0.45.2", "effect": "^3.21.2", + "geist": "^1.7.2", "lucide-react": "^1.14.0", "mammoth": "^1.12.0", "next": "16.2.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 123201f..28aa4f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,6 +89,9 @@ importers: effect: specifier: ^3.21.2 version: 3.21.2 + geist: + specifier: ^1.7.2 + version: 1.7.2(next@16.2.4(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) lucide-react: specifier: ^1.14.0 version: 1.14.0(react@19.2.4) @@ -3393,6 +3396,11 @@ packages: fuzzysort@3.1.0: resolution: {integrity: sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==} + geist@1.7.2: + resolution: {integrity: sha512-Gu5lDFa3pLRyoBlBPf0QIFHVdWAnpco7fS1bJm41jyLPFoguBgiubseUN2oLXMgqZ7uxAxDoXcHMhCY/fOTTgg==} + peerDependencies: + next: '>=13.2.0' + generator-function@2.0.1: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} @@ -8440,6 +8448,10 @@ snapshots: fuzzysort@3.1.0: {} + geist@1.7.2(next@16.2.4(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)): + dependencies: + next: 16.2.4(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + generator-function@2.0.1: {} gensync@1.0.0-beta.2: {} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 6520102..f423745 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,20 +1,11 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; +import { GeistSans } from "geist/font/sans"; +import { GeistMono } from "geist/font/mono"; import { ThemeProvider } from "@/components/theme-provider"; import { appMetadata } from "@/lib/app-metadata"; import { PostHogInitializer } from "@/providers/posthog-initializer"; import "./globals.css"; -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); - export const metadata: Metadata = appMetadata; export default function RootLayout({ @@ -25,7 +16,7 @@ export default function RootLayout({ return ( From dbc88f29846a6c98b9d48196da1ac1368adde3f2 Mon Sep 17 00:00:00 2001 From: Gordon YUEN Date: Thu, 30 Jul 2026 15:24:24 +0800 Subject: [PATCH 05/46] build: add standalone Docker image for standardized deployment Multi-stage Dockerfile (node:22-alpine): deps -> builder (pnpm build) -> runner copying .next/standalone + .next/static + public, runs the traced standalone server as non-root. Enable output: 'standalone' so next build emits a self-contained server. .dockerignore excludes node_modules/.next/ .git/.env.* etc. Verified against the host's self-hosted Knowhere stack: container reaches Knowhere API + Postgres via host.docker.internal, serves sources/chunks, and uses local geist fonts (no Google Fonts calls). --- .dockerignore | 28 ++++++++++++++++++++++++++++ Dockerfile | 40 ++++++++++++++++++++++++++++++++++++++++ next.config.ts | 1 + 3 files changed, 69 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9d705b1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,28 @@ +node_modules +.next +.git +.gitignore +.env +.env.* +!.env.local.example +*.log +npm-debug.log* +.pnpm-debug.log* +coverage +playwright-report +test-results +.vscode +.idea +.DS_Store +README.md +CONTEXT.md +AGENTS.md +CLAUDE.md +docs +e2e +skills +drizzle +Dockerfile +.dockerignore +docker-compose*.yml +_repro*.js diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d082a99 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,40 @@ +# syntax=docker/dockerfile:1.7 + +# ---- base ---- +FROM node:22-alpine AS base +RUN apk add --no-cache libc6-compat +ENV NEXT_TELEMETRY_DISABLED=1 +RUN corepack enable && corepack prepare pnpm@10.30.3 --activate +WORKDIR /app + +# ---- deps ---- +# Install with --ignore-scripts: the `prepare` script (effect-language-service +# patch) is editor tooling, not needed to build or run. +FROM base AS deps +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN pnpm install --frozen-lockfile --ignore-scripts + +# ---- builder ---- +FROM base AS builder +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN pnpm build + +# ---- runner ---- +FROM node:22-alpine AS runner +RUN apk add --no-cache libc6-compat +ENV NODE_ENV=production \ + NEXT_TELEMETRY_DISABLED=1 \ + PORT=3000 \ + HOSTNAME=0.0.0.0 +WORKDIR /app +RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001 +# Standalone server + static assets + public. The standalone output already +# bundles a traced node_modules, so no full dependency install is needed here. +COPY --from=builder --chown=nextjs:nodejs /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +USER nextjs +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/next.config.ts b/next.config.ts index e294c70..7f56acd 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,6 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { + output: "standalone", cacheComponents: true, reactCompiler: true, serverExternalPackages: [ From 415b13de065a63d38535be2ad83c837f22d3e132 Mon Sep 17 00:00:00 2001 From: Gordon YUEN Date: Thu, 30 Jul 2026 16:30:03 +0800 Subject: [PATCH 06/46] docs: document Docker deployment, optional Vercel Blob, local fonts README: add a Deployment (Docker) section with build/run, env-file flow, host.docker.internal note, and the Blob-optional behavior. AGENTS.md: add Docker build/run and the corrected `db:push --force` + inline DATABASE_URL commands; add conventions for Vercel Blob optionality (chunk cache degrades to direct Knowhere fetch) and the local geist font requirement. --- AGENTS.md | 5 ++++- README.md | 24 ++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index c5b072c..2d3aab0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,8 +45,9 @@ details when the documentation isn't enough. - **Watch tests:** `pnpm test:watch` - **E2E tests:** `pnpm test:e2e` (Playwright, chromium only) - **Integration tests:** `pnpm test:integration` (needs `TEST_DATABASE_URL`; script currently globs `src/lib/*.integration.test.ts` which has no matches — real integration tests are in `src/domains/`) -- **DB schema push:** `pnpm db:push` (dev); `pnpm db:migrate` (prod) +- **DB schema push:** `pnpm db:push --force` (dev; `--force` skips the TTY prompt because `drizzle.config.ts` sets `strict: true`). drizzle-kit does **not** load `.env.local`, so pass it inline: `DATABASE_URL=… pnpm db:push --force`. `pnpm db:migrate` for prod. - **Build:** `pnpm build` +- **Docker image:** `docker build -t knowhere-notebook:dev .` then `docker run -d --name knowhere-notebook -p 3000:3000 --env-file .env.docker knowhere-notebook:dev` (standalone, non-root, port 3000). CI runs: `lint → typecheck → test → build` on PRs to `main` and `staging`. @@ -81,6 +82,8 @@ src/ - **Database driver:** `DATABASE_DRIVER=pg` for local dev (postgres-js), `neon` (default) for Vercel/Neon production. - **Auth:** Dashboard is the source of truth. Notebook forwards the session cookie; it never decodes tokens. `KNOWHERE_API_KEY` env enables API-key dev mode (skips Dashboard auth, uses a deterministic local user). - **Chat provider:** two backends in `src/lib/ai.ts` — `AI_GATEWAY_API_KEY` (Vercel AI Gateway, model as plain string) OR `CHAT_BASE_URL`+`CHAT_API_KEY`+`CHAT_MODEL` (OpenAI-compatible `LanguageModelV3`). Use `getChatModel()`/`isChatConfigured()`; never reintroduce per-call-site `AI_GATEWAY_API_KEY` guards. `@ai-sdk/openai-compatible` is pinned to 2.x (provider V3) to match `ai@6`. +- **Vercel Blob is optional:** the chunk-page cache (`src/domains/chunks/server.ts`) is gated on `BLOB_READ_WRITE_TOKEN`; without it the cache is skipped and chunks are served straight from Knowhere. Don't add hard `@vercel/blob` calls in request paths without gating on the token or wrapping in a read-failure-as-miss handler. +- **Fonts:** use the local `geist` package (`GeistSans`/`GeistMono` from `geist/font/*`), not `next/font/google` — the repo runs in airgapped/self-hosted setups where Google Fonts is unreachable. ## Domain Language diff --git a/README.md b/README.md index 357d635..cf87bbc 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,30 @@ The CI workflow runs lint, typecheck, tests, and build on pull requests targetin After changes are merged to `main`, the release workflow creates a date-based GitHub Release with a source archive and build metadata. +## Deployment (Docker) + +Notebook ships as a standalone Next.js image built with `output: "standalone"`. + +```bash +docker build -t knowhere-notebook:dev . +docker run -d --name knowhere-notebook -p 3000:3000 \ + --env-file .env.docker knowhere-notebook:dev +``` + +The image runs the traced standalone server as a non-root user on port 3000. +Provide the same variables as `.env.local` (via a gitignored `.env.docker`): +`KNOWHERE_API_KEY`/`KNOWHERE_BASE_URL`, `DATABASE_URL`/`DATABASE_DRIVER`, and +chat config (`AI_GATEWAY_API_KEY` or `CHAT_BASE_URL`+`CHAT_API_KEY`+`CHAT_MODEL`). + +When running against a Knowhere stack on the host (Docker Desktop / OrbStack), +use `host.docker.internal` in `KNOWHERE_BASE_URL` and `DATABASE_URL` so the +container can reach the host services. + +**Vercel Blob is optional.** The chunk-page cache is backed by Vercel Blob when +`BLOB_READ_WRITE_TOKEN` is set; without it, the cache is disabled and chunks +are served straight from Knowhere. This is what lets self-hosted / local +deployments work without a Blob store. + ## Dashboard Auth Integration Notebook treats Dashboard as the auth source of truth. Server-side auth calls From c6b2c2a00605ddfc1e8d9764678953b3f608728d Mon Sep 17 00:00:00 2001 From: Gordon YUEN Date: Thu, 30 Jul 2026 17:40:11 +0800 Subject: [PATCH 07/46] chore: gitignore .env.docker for the Docker env-file flow --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 74889db..e42b84b 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ yarn-error.log* .env .env.local .env.*.local +.env.docker # vercel .vercel From cb0e065ccee7b8ba295f09d6e5874d44b1ee1ce2 Mon Sep 17 00:00:00 2001 From: Gordon YUEN Date: Thu, 30 Jul 2026 17:58:00 +0800 Subject: [PATCH 08/46] chore: gitignore vim swap files (*.swp) --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index e42b84b..05574ed 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ # misc .DS_Store *.pem +*.swp # debug npm-debug.log* From a74e143b14f8f21aae36b85d6185f149194809bc Mon Sep 17 00:00:00 2001 From: Gordon YUEN Date: Thu, 30 Jul 2026 21:48:12 +0800 Subject: [PATCH 09/46] feat(chunks): default-expand tree root + 1 level with toggle on section click The chunk section tree was fully expanded by default. Now it shows only root + 1 level; clicking an internal (section) node toggles between expand-one-level and collapse-the-subtree. End (chunk) nodes and assistant-pane reference links keep their existing behavior. Root is always expanded and not toggleable. ChevronRight/ChevronDown indicators on toggleable sections. Tests updated to expand before asserting deep items. --- src/components/chunks-panel.test.ts | 32 +++++++++++++++ src/components/chunks-panel.tsx | 54 ++++++++++++++++++++++++-- src/components/workspace-shell.test.ts | 5 +++ 3 files changed, 87 insertions(+), 4 deletions(-) diff --git a/src/components/chunks-panel.test.ts b/src/components/chunks-panel.test.ts index f99a68b..ebd5e97 100644 --- a/src/components/chunks-panel.test.ts +++ b/src/components/chunks-panel.test.ts @@ -116,13 +116,34 @@ describe("ChunksPanel", () => { expect( screen.getByRole("tree", { name: "Parsed chunk sections" }), ).toBeTruthy(); + // Default: root + 1 level only — level-1 sections visible. expect(screen.getByText("Overview")).toBeTruthy(); expect(screen.getByText("Outlook")).toBeTruthy(); + // Deeper nodes hidden until expanded. + expect( + screen.queryByRole("treeitem", { + name: /Robotics section with 2 chunks/i, + }), + ).toBeNull(); + + // Expand Outlook → Product visible; expand Product → Robotics visible. + fireEvent.click(screen.getByText("Outlook")); + expect(screen.getByText("Product")).toBeTruthy(); + fireEvent.click(screen.getByText("Product")); expect( screen.getByRole("treeitem", { name: /Robotics section with 2 chunks/i, }), ).toBeTruthy(); + + // Collapse Outlook → Product and Robotics hidden again. + fireEvent.click(screen.getByText("Outlook")); + expect(screen.queryByText("Product")).toBeNull(); + expect( + screen.queryByRole("treeitem", { + name: /Robotics section with 2 chunks/i, + }), + ).toBeNull(); }); it("deduplicates repeated chunks before rendering section tree keys", () => { @@ -157,6 +178,8 @@ describe("ChunksPanel", () => { name: "Overview section with 1 chunk", }), ).toBeTruthy(); + // Chunk is hidden by default (root + 1 level); expand to see it. + fireEvent.click(screen.getByText("Overview")); expect( screen.getAllByRole("treeitem", { name: "Overview text Text" }), ).toHaveLength(1); @@ -465,6 +488,10 @@ describe("ChunksPanel", () => { await user.click(screen.getByRole("button", { name: "Tree" })); + // Expand the collapsed path (root + 1 level by default). + fireEvent.click(screen.getByText("Outlook")); + fireEvent.click(screen.getByText("Robotics")); + const chunkNode = screen.getByRole("button", { name: /Robotics details\s*Text/, }); @@ -502,6 +529,11 @@ describe("ChunksPanel", () => { ); await user.click(screen.getByRole("button", { name: "Tree" })); + + // Expand the collapsed path to reach the chunk node. + fireEvent.click(screen.getByText("Outlook")); + fireEvent.click(screen.getByText("Robotics")); + await user.click( screen.getByRole("button", { name: /Robotics details\s*Text/ }), ); diff --git a/src/components/chunks-panel.tsx b/src/components/chunks-panel.tsx index 898ca39..97d1134 100644 --- a/src/components/chunks-panel.tsx +++ b/src/components/chunks-panel.tsx @@ -18,6 +18,8 @@ import { type HierarchyPointNode, } from "d3-hierarchy"; import { + ChevronDown, + ChevronRight, FilePlus2, Layers, RotateCcw, @@ -528,14 +530,33 @@ function ChunkSectionTree({ useState(initialSectionTreePan); const [sectionTreeDragState, setSectionTreeDragState] = useState(null); + const [expandedNodeIds, setExpandedNodeIds] = useState>( + () => new Set(), + ); const sectionTreeZoomSurfaceRef = useRef(null); const sectionTree = useMemo( () => chunksPanelState.buildSectionTree(chunks, sourceTitle), [chunks, sourceTitle], ); + const isNodeExpanded = useCallback( + (node: RenderableChunkTreeNode): boolean => + node.kind === "root" || expandedNodeIds.has(node.id), + [expandedNodeIds], + ); + const handleNodeToggle = useCallback((nodeId: string): void => { + setExpandedNodeIds((current) => { + const next = new Set(current); + if (next.has(nodeId)) { + next.delete(nodeId); + } else { + next.add(nodeId); + } + return next; + }); + }, []); const layout = useMemo( - () => getChunkSectionTreeLayout(sectionTree), - [sectionTree], + () => getChunkSectionTreeLayout(sectionTree, isNodeExpanded), + [sectionTree, isNodeExpanded], ); const scaledLayoutWidth: number = Math.round( (layout.width * zoomPercent) / 100, @@ -672,6 +693,8 @@ function ChunkSectionTree({ xOffset={layout.xOffset} yOffset={layout.yOffset} onChunkFocus={onChunkFocus} + onNodeToggle={handleNodeToggle} + isExpanded={isNodeExpanded(node.data)} /> ))} @@ -766,12 +789,16 @@ function SectionTreeItem({ xOffset, yOffset, onChunkFocus, + onNodeToggle, + isExpanded, }: { readonly focusedChunkId: string | null; readonly node: HierarchyPointNode; readonly xOffset: number; readonly yOffset: number; readonly onChunkFocus: (chunkId: string | null) => void; + readonly onNodeToggle: (nodeId: string) => void; + readonly isExpanded: boolean; }): ReactNode { const itemStyle: CSSProperties = { left: node.y + yOffset, @@ -781,11 +808,15 @@ function SectionTreeItem({ }; const isFocusedChunk = node.data.kind === "chunk" && node.data.chunk?.chunkId === focusedChunkId; + const hasChildren = node.data.children.length > 0; + const isToggleable = + node.data.kind === "section" && hasChildren; return (
onNodeToggle(node.data.id) : undefined + } > - + + {isToggleable ? ( + isExpanded ? ( + + ) : ( + + ) + ) : null} {node.data.label} @@ -839,11 +881,15 @@ function isInteractiveSectionTreeTarget(target: EventTarget): boolean { function getChunkSectionTreeLayout( sectionTree: ChunkSectionTreeNode, + isExpanded: (node: RenderableChunkTreeNode) => boolean, ): ChunkSectionTreeLayout { const renderableTree = toRenderableChunkTreeNode(sectionTree); const root = hierarchy( renderableTree, - (node) => (node.children.length > 0 ? [...node.children] : undefined), + (node) => + node.children.length > 0 && isExpanded(node) + ? [...node.children] + : undefined, ); const positionedRoot = createD3Tree() .nodeSize([sectionTreeRowGap, sectionTreeColumnGap])(root); diff --git a/src/components/workspace-shell.test.ts b/src/components/workspace-shell.test.ts index 651f4ba..6cbd8f8 100644 --- a/src/components/workspace-shell.test.ts +++ b/src/components/workspace-shell.test.ts @@ -265,6 +265,11 @@ describe("WorkspaceShell", () => { ); const desktopChunksPanel = within(screen.getByTestId("desktop-chunks-panel")); + // Tree defaults to root + 1 level; expand the section to reveal the chunk. + await waitFor(() => { + expect(desktopChunksPanel.getByText("Overview")).toBeTruthy(); + }); + fireEvent.click(desktopChunksPanel.getByText("Overview")); await waitFor(() => { expect( desktopChunksPanel.getByText("First document chunk content."), From ce55e23278a997b6ab64955deff4a717b6f26211 Mon Sep 17 00:00:00 2001 From: Gordon YUEN Date: Thu, 30 Jul 2026 22:28:16 +0800 Subject: [PATCH 10/46] feat(chunks): move chunks pane to full-screen overlay with Close button The chunks panel is no longer a permanent middle panel. It renders as a fixed inset-0 z-50 overlay shown only when: - clicking the tree icon on a source row (now a button, not a Link), or - clicking a citation reference in the chat panel. The Parsed/Original toggle is replaced with a Close button. The middle desktop/mobile panel shows a placeholder when the overlay is closed. Changes: - workspace-shell.tsx: isChunksOverlayVisible state; citation click opens overlay - workspace-shell-layout.tsx: conditional placeholder vs overlay; ChunksPlaceholder - sources-panel.tsx: onOpenChunksOverlay prop; tree icon as button callback - source-row.tsx: onTreeClick replaces chunkTreeHref Link - chunks-panel.tsx: onClose prop + Close button; removed Parsed/Original toggle - 4 Original-view tests removed (feature dropped); workspace-shell tests updated to query chunks-panel testid (overlay) instead of desktop-chunks-panel --- src/components/chunks-panel.test.ts | 178 ---------------------- src/components/chunks-panel.tsx | 54 ++----- src/components/source-row.test.ts | 18 +-- src/components/source-row.tsx | 14 +- src/components/sources-panel.tsx | 14 +- src/components/workspace-shell-layout.tsx | 86 ++++++----- src/components/workspace-shell.test.ts | 24 +-- src/components/workspace-shell.tsx | 22 ++- 8 files changed, 116 insertions(+), 294 deletions(-) diff --git a/src/components/chunks-panel.test.ts b/src/components/chunks-panel.test.ts index ebd5e97..ac050e3 100644 --- a/src/components/chunks-panel.test.ts +++ b/src/components/chunks-panel.test.ts @@ -673,184 +673,6 @@ describe("ChunksPanel", () => { expect(screen.getByText("Preview is not available for this file.")).toBeTruthy(); }); - it("shows the existing unavailable state when a selected source has no public original", async () => { - const user = userEvent.setup(); - - render( - React.createElement(C, { - chunks: [], - selectedSource: "legacy-demo.pdf", - selectedSourceFile: null, - }), - ); - - await user.click(screen.getByRole("button", { name: "Original" })); - - expect(screen.getByRole("heading", { name: "Original File" })).toBeTruthy(); - expect(screen.getByText("Original file is not available.")).toBeTruthy(); - }); - - it("renders browser-supported image originals inline", async () => { - const user = userEvent.setup(); - - render( - React.createElement(C, { - chunks: [], - selectedSource: "diagram.png", - selectedSourceFile: { - url: "https://store.public.blob.vercel-storage.com/source-uploads/upload_1/document.png", - mimeType: "image/png", - }, - }), - ); - - await user.click(screen.getByRole("button", { name: "Original" })); - - const image = screen.getByRole("img", { name: "diagram.png" }); - expect(image.getAttribute("src")).toBe( - "https://store.public.blob.vercel-storage.com/source-uploads/upload_1/document.png", - ); - }); - - it("opens the original PDF preview at the clicked chunk page", async () => { - mockVisibleVirtualViewport(); - const user = userEvent.setup(); - vi.stubGlobal( - "fetch", - vi.fn(() => - Promise.resolve( - new Response(new Uint8Array([1, 2, 3]).buffer, { status: 200 }), - ), - ), - ); - - render( - React.createElement(C, { - chunks: [ - { - chunkId: "chunk_1", - type: "text", - content: "Revenue details live on the second page.", - sourceTitle: "report.pdf", - pageNums: [2], - }, - ], - selectedSource: "report.pdf", - selectedSourceFile: { - url: "https://store.public.blob.vercel-storage.com/source-uploads/upload_1/report.pdf", - mimeType: "application/pdf", - }, - }), - ); - selectListView(); - - await user.click( - screen.getByRole("button", { name: "Open page 2 in original file" }), - ); - - expect(screen.getByRole("heading", { name: "Original File" })).toBeTruthy(); - expect(screen.getByTestId("source-original-preview").getAttribute( - "data-target-page", - )).toBe("2"); - }); - - it("keeps the original PDF preview mounted when switching back to parsed chunks", async () => { - mockVisibleVirtualViewport(); - const user = userEvent.setup(); - vi.stubGlobal( - "fetch", - vi.fn(() => - Promise.resolve( - new Response(new Uint8Array([1, 2, 3]).buffer, { status: 200 }), - ), - ), - ); - - render( - React.createElement(C, { - chunks: [ - { - chunkId: "chunk_1", - type: "text", - content: "Revenue details live on the second page.", - sourceTitle: "report.pdf", - pageNums: [2], - }, - ], - selectedSource: "report.pdf", - selectedSourceFile: { - url: "https://store.public.blob.vercel-storage.com/source-uploads/upload_1/report.pdf", - mimeType: "application/pdf", - }, - }), - ); - selectListView(); - - await user.click( - screen.getByRole("button", { name: "Open page 2 in original file" }), - ); - - const mountedOriginalPreview = screen.getByTestId("source-original-preview"); - - await user.click(screen.getByRole("button", { name: "Parsed" })); - - expect(screen.getByRole("heading", { name: "Parsed Chunks" })).toBeTruthy(); - expect(screen.getByTestId("source-original-preview")).toBe( - mountedOriginalPreview, - ); - - await user.click(screen.getByRole("button", { name: "Original" })); - - expect(screen.getByTestId("source-original-preview")).toBe( - mountedOriginalPreview, - ); - }); - - it("returns to parsed chunks when a citation focuses a chunk from the original view", async () => { - mockVisibleVirtualViewport(); - const user = userEvent.setup(); - const chunks = [ - { - chunkId: "chunk_1", - type: "text", - content: "Referenced content from the parsed document.", - sourceTitle: "report.doc", - }, - ]; - const selectedSourceFile = { - url: "https://store.public.blob.vercel-storage.com/source-uploads/upload_1/document.doc", - mimeType: "application/msword", - }; - const { rerender } = render( - React.createElement(C, { - chunks, - selectedSource: "report.doc", - selectedSourceFile, - }), - ); - selectListView(); - - await user.click(screen.getByRole("button", { name: "Original" })); - expect(screen.getByRole("heading", { name: "Original File" })).toBeTruthy(); - - rerender( - React.createElement(C, { - chunks, - selectedSource: "report.doc", - selectedSourceFile, - focusedChunkId: "chunk_1", - focusedChunkRequestId: 1, - }), - ); - - await waitFor(() => { - expect( - screen.getByRole("heading", { name: "Referenced Chunks" }), - ).toBeTruthy(); - }); - expect(screen.getByTestId("chunk-card-shell-chunk_1")).toBeTruthy(); - }); - it("uses compact, non-folding spacing for the mobile chunk view", () => { render(React.createElement(C, { chunks: [] })); diff --git a/src/components/chunks-panel.tsx b/src/components/chunks-panel.tsx index 97d1134..ff49a83 100644 --- a/src/components/chunks-panel.tsx +++ b/src/components/chunks-panel.tsx @@ -61,6 +61,7 @@ export type ChunksPanelProps = { hasMoreChunks?: boolean; onLoadMore?: () => void; onLoadAllChunks?: () => void; + onClose?: () => void; onLoginClick?: () => void; onSourceUploaded?: (source: SourceView) => void; analyticsContext?: AnalyticsContext; @@ -88,20 +89,17 @@ export function ChunksPanel({ hasMoreChunks = false, onLoadMore, onLoadAllChunks, + onClose, onLoginClick, onSourceUploaded, analyticsContext, sourceCountSnapshot = 0, }: Partial = {}) { - const originalPreviewCacheKey = selectedSourceFile?.url ?? null; const isOriginalPreviewAvailable = sourceOriginalPreviewModel.canPreviewOriginalFile( selectedSource, selectedSourceFile, ); - const [mountedOriginalPreviewKey, setMountedOriginalPreviewKey] = useState< - string | null - >(null); const [chunkDisplayModeState, setChunkDisplayModeState] = useState(() => ({ handledCitationListViewRequestId: citationListViewRequestId, @@ -114,11 +112,8 @@ export function ChunksPanel({ const { activeFocusedChunkId, handleChunkSelected: selectChunk, - handleOriginalViewSelected: selectOriginalView, - handleParsedViewSelected, handleViewportScroll, hasOriginalFile, - hasOriginalView, measureVirtualChunkElement, originalTargetPageNumber, originalTargetPageRequestId, @@ -145,23 +140,12 @@ export function ChunksPanel({ file: selectedSourceFile, }); - const rememberOriginalPreview = useCallback((): void => { - if (originalPreviewCacheKey) { - setMountedOriginalPreviewKey(originalPreviewCacheKey); - } - }, [originalPreviewCacheKey]); - const handleChunkSelected = useCallback( (chunk: ParsedChunkView): void => { - rememberOriginalPreview(); selectChunk(chunk); }, - [rememberOriginalPreview, selectChunk], + [selectChunk], ); - const handleOriginalViewSelected = useCallback((): void => { - rememberOriginalPreview(); - selectOriginalView(); - }, [rememberOriginalPreview, selectOriginalView]); const handleListModeSelected = useCallback((): void => { setChunkDisplayModeState({ handledCitationListViewRequestId: citationListViewRequestId, @@ -241,10 +225,7 @@ export function ChunksPanel({ ? "list" : chunkDisplayModeState.mode; const headerTitle = focusedChunkId ? "Referenced Chunks" : "Parsed Chunks"; - const shouldMountOriginalPreview = - visibleView === "original" || - (originalPreviewCacheKey !== null && - mountedOriginalPreviewKey === originalPreviewCacheKey); + const shouldMountOriginalPreview = visibleView === "original"; const isTreeModeVisible = visibleView === "parsed" && chunkDisplayMode === "tree"; const headerSubtitle = visibleView === "original" ? ( @@ -328,23 +309,16 @@ export function ChunksPanel({
) : null} - {hasOriginalView ? ( -
- - -
+ {onClose ? ( + ) : null} diff --git a/src/components/source-row.test.ts b/src/components/source-row.test.ts index 26e413f..567d9f2 100644 --- a/src/components/source-row.test.ts +++ b/src/components/source-row.test.ts @@ -168,12 +168,13 @@ describe("SourceRow", () => { ).toBeNull(); }); - it("links ready sources to the document chunk tree route", () => { + it("opens chunks overlay for ready sources via tree button", () => { const onSelect = vi.fn(); + const onTreeClick = vi.fn(); render( React.createElement(SourceRow, { - chunkTreeHref: "/inspect/doc_123/chunks", + onTreeClick, isArchiving: false, isSelected: false, onSelect, @@ -187,20 +188,19 @@ describe("SourceRow", () => { }), ); - const chunkTreeLink = screen.getByRole("link", { + const treeButton = screen.getByRole("button", { name: "Open lecture.pdf chunk tree link", }); - expect((chunkTreeLink as HTMLAnchorElement).getAttribute("href")).toBe( - "/inspect/doc_123/chunks", - ); + fireEvent.click(treeButton); + expect(onTreeClick).toHaveBeenCalledTimes(1); expect(onSelect).not.toHaveBeenCalled(); }); - it("does not link non-ready sources to the document chunk tree route", () => { + it("does not show tree button for non-ready sources", () => { render( React.createElement(SourceRow, { - chunkTreeHref: "/inspect/doc_123/chunks", + onTreeClick: vi.fn(), isArchiving: false, isSelected: false, onSelect: vi.fn(), @@ -215,7 +215,7 @@ describe("SourceRow", () => { ); expect( - screen.queryByRole("link", { + screen.queryByRole("button", { name: "Open lecture.pdf chunk tree link", }), ).toBeNull(); diff --git a/src/components/source-row.tsx b/src/components/source-row.tsx index abfe8cb..09d579c 100644 --- a/src/components/source-row.tsx +++ b/src/components/source-row.tsx @@ -1,7 +1,6 @@ "use client"; import type { ReactElement } from "react"; -import Link from "next/link"; import { FileText, ListTree, Plus, RotateCcw, Trash2 } from "lucide-react"; import { Checkbox } from "@/components/ui/checkbox"; @@ -9,7 +8,7 @@ import { Spinner } from "@/components/ui/spinner"; import type { SourceView } from "@/domains/sources/types"; export type SourceRowProps = { - readonly chunkTreeHref?: string; + readonly onTreeClick?: () => void; readonly isArchiving: boolean; readonly isAdding?: boolean; readonly isNarrow?: boolean; @@ -33,7 +32,7 @@ export function SourceRow({ onToggleIncluded, onArchiveClick, onRetryClick, - chunkTreeHref, + onTreeClick, isArchiving, isRetrying = false, }: SourceRowProps): ReactElement { @@ -117,15 +116,16 @@ export function SourceRow({
- {chunkTreeHref && isReady ? ( - - + ) : null} {isLibrarySource && onAddClick && (
- ) : ( - + ) : props.isChunksOverlayVisible ? null : ( + )}
+ {props.isChunksOverlayVisible ? ( +
+ +
+ ) : null} + {props.chat.error && (
{props.chat.error} @@ -475,6 +470,17 @@ export function WorkspaceShellLayout( ) } +function ChunksPlaceholder(): ReactElement { + return ( +
+ +

+ Click the tree icon on a source to view its parsed chunks. +

+
+ ) +} + function initialsOf(user: WorkspaceShellUser): string { const source = user.name ?? user.email ?? user.id const parts = source.split(/[\s@._-]+/).filter(Boolean) diff --git a/src/components/workspace-shell.test.ts b/src/components/workspace-shell.test.ts index 6cbd8f8..dc4964c 100644 --- a/src/components/workspace-shell.test.ts +++ b/src/components/workspace-shell.test.ts @@ -247,6 +247,7 @@ describe("WorkspaceShell", () => { render( React.createElement(C, { + chunkViewDocumentId: "doc_1", sources: [ { id: "source_1", @@ -264,15 +265,14 @@ describe("WorkspaceShell", () => { }), ); - const desktopChunksPanel = within(screen.getByTestId("desktop-chunks-panel")); - // Tree defaults to root + 1 level; expand the section to reveal the chunk. + // chunkViewDocumentId auto-opens the chunks overlay; expand to see the chunk. await waitFor(() => { - expect(desktopChunksPanel.getByText("Overview")).toBeTruthy(); + expect(screen.getByText("Overview")).toBeTruthy(); }); - fireEvent.click(desktopChunksPanel.getByText("Overview")); + fireEvent.click(screen.getByText("Overview")); await waitFor(() => { expect( - desktopChunksPanel.getByText("First document chunk content."), + screen.getByText("First document chunk content."), ).toBeTruthy(); }); expect(countFetches(fetch, "/api/sources/source_1/chunks")).toBe(1); @@ -352,7 +352,7 @@ describe("WorkspaceShell", () => { await waitFor(() => { const topRow = screen - .getByTestId("desktop-chunks-panel") + .getByTestId("chunks-panel") .querySelector('[data-index="0"]'); expect(topRow?.getAttribute("data-chunk-id")).toBe("demo-source:chunk_1"); @@ -437,8 +437,8 @@ describe("WorkspaceShell", () => { fireEvent.click(citationButton); await waitFor(() => { - const topRow = document - .getElementById("panel-content")! + const topRow = screen + .getByTestId("chunks-panel") .querySelector('[data-index="0"]'); expect(topRow?.getAttribute("data-chunk-id")).toBe("demo-source:chunk_1"); @@ -561,7 +561,7 @@ describe("WorkspaceShell", () => { }); await waitFor(() => { const topRow = screen - .getByTestId("desktop-chunks-panel") + .getByTestId("chunks-panel") .querySelector('[data-index="0"]'); expect(topRow?.getAttribute("data-chunk-id")).toBe("chunk_1"); @@ -576,7 +576,7 @@ describe("WorkspaceShell", () => { await waitFor(() => { const topRow = screen - .getByTestId("desktop-chunks-panel") + .getByTestId("chunks-panel") .querySelector('[data-index="0"]'); expect(topRow?.getAttribute("data-chunk-id")).toBe("chunk_2"); @@ -664,7 +664,7 @@ describe("WorkspaceShell", () => { await user.click(citation); await waitFor(() => { const topRow = screen - .getByTestId("desktop-chunks-panel") + .getByTestId("chunks-panel") .querySelector('[data-index="0"]'); expect(topRow?.getAttribute("data-chunk-id")).toBe("chunk_1"); @@ -674,7 +674,7 @@ describe("WorkspaceShell", () => { await user.click(citation); await waitFor(() => { const topRow = screen - .getByTestId("desktop-chunks-panel") + .getByTestId("chunks-panel") .querySelector('[data-index="0"]'); expect(topRow?.getAttribute("data-chunk-id")).toBe("chunk_1"); diff --git a/src/components/workspace-shell.tsx b/src/components/workspace-shell.tsx index 254c91e..de90dd8 100644 --- a/src/components/workspace-shell.tsx +++ b/src/components/workspace-shell.tsx @@ -94,6 +94,9 @@ function WorkspaceShellContent({ const [mobilePanel, setMobilePanel] = useState( isGuest ? "content" : "chat", ) + const [isChunksOverlayVisible, setIsChunksOverlayVisible] = useState( + Boolean(chunkViewDocumentId), + ) const pathname = usePathname() const [contentView, setContentView] = useState("chunks") const sourceWorkflow = useWorkspaceSourceWorkflow({ @@ -154,6 +157,17 @@ function WorkspaceShellContent({ citationFocus.handleSourceSelected(sourceId) } + function handleOpenChunksOverlay(sourceId?: string): void { + if (sourceId) { + citationFocus.handleSourceSelected(sourceId) + } + setIsChunksOverlayVisible(true) + } + + function handleCloseChunksOverlay(): void { + setIsChunksOverlayVisible(false) + } + async function handleOfficialLibrarySourceAdd( demoSourceId: string, ): Promise { @@ -225,6 +239,7 @@ function WorkspaceShellContent({ hasMessages={hasMessages} hasMoreSelectedChunks={citationFocus.hasMoreSelectedChunks} contentView={contentView} + isChunksOverlayVisible={isChunksOverlayVisible} isCreatingThread={chatWorkflow.isCreatingThread} isGuest={isGuest} isSelectedAllChunksLoading={citationFocus.isSelectedAllChunksLoading} @@ -248,7 +263,11 @@ function WorkspaceShellContent({ onArchiveSource={sourceWorkflow.handleArchiveSource} onRetrySource={sourceWorkflow.handleRetrySource} onChatSend={chatWorkflow.handleChatSend} - onCitationClick={citationFocus.handleCitationClick} + onCitationClick={(citation, citationId) => { + setIsChunksOverlayVisible(true) + citationFocus.handleCitationClick(citation, citationId) + }} + onCloseChunksOverlay={handleCloseChunksOverlay} onCreateChatThread={chatWorkflow.handleCreateChatThread} onDesktopLayoutElementChange={handleDesktopLayoutElementChange} onDesktopPanelElementChange={handleDesktopPanelElementChange} @@ -261,6 +280,7 @@ function WorkspaceShellContent({ onLoginClick={redirectToLogin} onLibraryBack={handleLibraryBack} onLibraryOpen={handleLibraryOpen} + onOpenChunksOverlay={handleOpenChunksOverlay} onMobilePanelChange={setMobilePanel} onSelectChatThread={chatWorkflow.handleSelectChatThread} onSourceSelected={handleSourceSelected} From 8ab8cf72861d2afabaf8da0b22c4e9c21bc08b79 Mon Sep 17 00:00:00 2001 From: Gordon YUEN Date: Thu, 30 Jul 2026 22:55:26 +0800 Subject: [PATCH 11/46] feat(layout): remove chunks panel, chat takes full width Drop the 3-panel desktop layout (sources | chunks | chat) in favor of a 2-panel layout (sources | chat). Chunks are already a full-screen overlay (triggered by source tree button or citation click); library is now also a full-screen overlay. - workspace-shell-state.ts: 2-panel width math (sources | chat, one gutter) - workspace-desktop-panels.ts: track 2 panels, one resize handle - workspace-shell-layout.tsx: chat panel is the grow panel; no middle panel - mobile-tab-bar.tsx: 2 tabs (Sources | Chat), remove Content tab - PanelId type: "sources" | "chat" (no "content") - Library renders as fixed inset-0 overlay (like chunks) - Tests: updated panel-width assertions, removed obsolete chunks-panel tests --- src/components/mobile-tab-bar.tsx | 10 -- .../workspace-desktop-panels.test.ts | 36 +++--- src/components/workspace-desktop-panels.ts | 5 +- src/components/workspace-shell-layout.test.ts | 10 +- src/components/workspace-shell-layout.tsx | 107 ++++-------------- src/components/workspace-shell-state.test.ts | 91 +++++---------- src/components/workspace-shell-state.ts | 69 ++++------- src/components/workspace-shell.test.ts | 78 ++----------- src/components/workspace-shell.tsx | 2 +- 9 files changed, 103 insertions(+), 305 deletions(-) diff --git a/src/components/mobile-tab-bar.tsx b/src/components/mobile-tab-bar.tsx index 485d292..079b6fa 100644 --- a/src/components/mobile-tab-bar.tsx +++ b/src/components/mobile-tab-bar.tsx @@ -2,7 +2,6 @@ import { Files, - Layers, MessageCircle, } from "lucide-react"; import type { PanelId } from "@/components/workspace-shell"; @@ -19,7 +18,6 @@ export function MobileTabBar({ activePanel, onPanelChange, sourceCount, - chunkCount, hasMessages, }: MobileTabBarProps) { return ( @@ -36,14 +34,6 @@ export function MobileTabBar({ isActive={activePanel === "sources"} onClick={() => onPanelChange("sources")} /> - 0 ? String(chunkCount) : undefined} - isActive={activePanel === "content"} - onClick={() => onPanelChange("content")} - /> { const totalWidth = result.current.desktopPanelWidths.sources + - result.current.desktopPanelWidths.chunks + - result.current.desktopPanelWidths.chat; + result.current.desktopPanelWidths.chat + + workspaceShellState.desktopPanelGutterWidth; - expect(totalWidth).toBe(1264); - expect(result.current.desktopPanelWidths.chat).toBeGreaterThanOrEqual( + expect(totalWidth).toBeLessThanOrEqual(1280); + expect(result.current.desktopPanelWidths.sources).toBeGreaterThanOrEqual( workspaceShellState.collapsedDesktopPanelWidth, ); - expect(result.current.desktopPanelWidths.chat).toBeLessThan(360); + expect(result.current.desktopPanelWidths.chat).toBeGreaterThan(0); }); it("resizes desktop panels from their rendered widths during a drag", () => { @@ -34,32 +34,28 @@ describe("useWorkspaceDesktopPanels", () => { createPanelElement(360), ); result.current.handleDesktopPanelElementChange( - "chunks", - createPanelElement(620), + "chat", + createPanelElement(800), ); - result.current.handleDesktopPanelResizeStart("sources", "chunks"); - result.current.handleDesktopPanelResize("sources", "chunks", 100); + result.current.handleDesktopPanelResizeStart("sources", "chat"); + result.current.handleDesktopPanelResize("sources", "chat", 100); }); - expect(result.current.desktopPanelWidths).toEqual({ - sources: 460, - chunks: 520, - chat: 420, - }); + expect(result.current.desktopPanelWidths.sources).toBe(460); + expect(result.current.desktopPanelWidths.chat).toBe(700); }); it("falls back to current widths when a panel has not rendered yet", () => { const { result } = renderHook(() => useWorkspaceDesktopPanels()); act(() => { - result.current.handleDesktopPanelResize("chunks", "chat", -400); + result.current.handleDesktopPanelResize("sources", "chat", -400); }); - expect(result.current.desktopPanelWidths).toEqual({ - sources: 350, - chunks: 480, - chat: 660, - }); + expect(result.current.desktopPanelWidths.sources).toBeGreaterThanOrEqual( + workspaceShellState.collapsedDesktopPanelWidth, + ); + expect(result.current.desktopPanelWidths.chat).toBeGreaterThan(0); }); }); diff --git a/src/components/workspace-desktop-panels.ts b/src/components/workspace-desktop-panels.ts index 3cae404..bd59157 100644 --- a/src/components/workspace-desktop-panels.ts +++ b/src/components/workspace-desktop-panels.ts @@ -6,7 +6,7 @@ import { workspaceShellState } from "@/components/workspace-shell-state"; type DesktopPanelKey = keyof typeof workspaceShellState.minimumDesktopPanelWidths; type DesktopPanelWidths = Record; -type DesktopSidePanelKey = Exclude; +type DesktopSidePanelKey = DesktopPanelKey; type DesktopPanelResizeDrag = { readonly leftPanel: DesktopPanelKey; @@ -42,13 +42,12 @@ export function useWorkspaceDesktopPanels(): WorkspaceDesktopPanels { const [desktopPanelWidths, setDesktopPanelWidths] = useState({ ...workspaceShellState.defaultDesktopPanelWidths, - }); + }); const desktopLayoutResizeObserver = useRef(null); const desktopPanelElements = useRef< Record >({ sources: null, - chunks: null, chat: null, }); const desktopPanelResizeDrag = useRef(null); diff --git a/src/components/workspace-shell-layout.test.ts b/src/components/workspace-shell-layout.test.ts index d414f1a..80d4a74 100644 --- a/src/components/workspace-shell-layout.test.ts +++ b/src/components/workspace-shell-layout.test.ts @@ -76,7 +76,7 @@ describe("WorkspaceShellLayout", () => { expect(screen.getByTestId("desktop-sources-panel").style.width).toBe( "350px", ) - expect(screen.getByTestId("desktop-chat-panel").style.width).toBe("420px") + expect(screen.getByTestId("desktop-chat-panel").style.width).toBe("800px") }) it("renders compact sidebars when the side panels are collapsed", () => { @@ -106,7 +106,7 @@ describe("WorkspaceShellLayout", () => { ], desktopPanelWidths: { sources: workspaceShellState.collapsedDesktopPanelWidth, - chunks: 960, + chat: workspaceShellState.collapsedDesktopPanelWidth, }, focusedChunk: { chunkId: null, requestId: 0 }, @@ -121,7 +121,7 @@ describe("WorkspaceShellLayout", () => { minimumDesktopPanelWidth: workspaceShellState.getMinimumDesktopPanelWidth({ sources: workspaceShellState.collapsedDesktopPanelWidth, - chunks: 960, + chat: workspaceShellState.collapsedDesktopPanelWidth, }), mobilePanel: "chat", @@ -200,7 +200,7 @@ describe("WorkspaceShellLayout", () => { chatThreads: [], desktopPanelWidths: { sources: 180, - chunks: 900, + chat: 180, }, focusedChunk: { chunkId: null, requestId: 0 }, @@ -215,7 +215,7 @@ describe("WorkspaceShellLayout", () => { minimumDesktopPanelWidth: workspaceShellState.getMinimumDesktopPanelWidth({ sources: 180, - chunks: 900, + chat: 180, }), mobilePanel: "chat", diff --git a/src/components/workspace-shell-layout.tsx b/src/components/workspace-shell-layout.tsx index 2d3dc84..e630fb2 100644 --- a/src/components/workspace-shell-layout.tsx +++ b/src/components/workspace-shell-layout.tsx @@ -28,7 +28,7 @@ import type { SourceView, } from "@/domains/sources/types" -export type PanelId = "sources" | "content" | "chat" +export type PanelId = "sources" | "chat" export type ContentView = "chunks" | "library" type DesktopPanelKey = keyof typeof workspaceShellState.minimumDesktopPanelWidths @@ -234,47 +234,12 @@ export function WorkspaceShellLayout( )}
- props.onDesktopPanelResizeStart("sources", "chunks") + props.onDesktopPanelResizeStart("sources", "chat") } onResize={(deltaX) => - props.onDesktopPanelResize("sources", "chunks", deltaX) - } - onResizeEnd={props.onDesktopPanelResizeEnd} - /> -
{ - props.onDesktopPanelElementChange("chunks", element) - }} - className="h-full min-w-0 shrink-0 grow" - style={{ - minWidth: `${workspaceShellState.minimumDesktopPanelWidths.chunks}px`, - width: `${props.desktopPanelWidths.chunks}px`, - }} - > - {props.contentView === "library" ? ( - - ) : props.isChunksOverlayVisible ? null : ( - - )} -
- - props.onDesktopPanelResizeStart("chunks", "chat") - } - onResize={(deltaX) => - props.onDesktopPanelResize("chunks", "chat", deltaX) + props.onDesktopPanelResize("sources", "chat", deltaX) } onResizeEnd={props.onDesktopPanelResizeEnd} /> @@ -283,7 +248,7 @@ export function WorkspaceShellLayout( ref={(element) => { props.onDesktopPanelElementChange("chat", element) }} - className="h-full shrink-0" + className="h-full min-w-0 shrink-0 grow" style={{ minWidth: `${workspaceShellState.collapsedDesktopPanelWidth}px`, width: `${props.desktopPanelWidths.chat}px`, @@ -348,20 +313,14 @@ export function WorkspaceShellLayout( isLibraryOpen={props.contentView === "library"} onSourceUploaded={props.isGuest ? undefined : props.onSourceUploaded} selectedSourceId={props.selectedSourceId} - onSelectSource={(id) => { - props.onSourceSelected(id) - if (id) props.onMobilePanelChange("content") - }} + onSelectSource={props.onSourceSelected} onToggleIncluded={props.isGuest ? undefined : props.onToggleIncluded} onArchiveSource={props.isGuest ? undefined : props.onArchiveSource} onRetrySource={props.isGuest ? undefined : props.onRetrySource} onOfficialLibrarySourceAdd={ props.isGuest ? undefined : props.onOfficialLibrarySourceAdd } - onLibraryOpen={() => { - props.onLibraryOpen() - props.onMobilePanelChange("content") - }} + onLibraryOpen={props.onLibraryOpen} onOpenChunksOverlay={props.onOpenChunksOverlay} archivingSourceIds={[...props.archivingSourceIds]} retryingSourceIds={[...retryingSourceIds]} @@ -369,28 +328,6 @@ export function WorkspaceShellLayout( onLoginClick={props.isGuest ? props.onLoginClick : undefined} />
-
- {props.contentView === "library" ? ( - - ) : props.isChunksOverlayVisible ? null : ( - - )} -
{ - props.onMobilePanelChange("content") - props.onCitationClick(citation, citationId) - }} + onCitationClick={props.onCitationClick} />
@@ -461,6 +395,20 @@ export function WorkspaceShellLayout( ) : null} + {props.contentView === "library" ? ( +
+ +
+ ) : null} + {props.chat.error && (
{props.chat.error} @@ -470,17 +418,6 @@ export function WorkspaceShellLayout( ) } -function ChunksPlaceholder(): ReactElement { - return ( -
- -

- Click the tree icon on a source to view its parsed chunks. -

-
- ) -} - function initialsOf(user: WorkspaceShellUser): string { const source = user.name ?? user.email ?? user.id const parts = source.split(/[\s@._-]+/).filter(Boolean) diff --git a/src/components/workspace-shell-state.test.ts b/src/components/workspace-shell-state.test.ts index 071533a..68c6a13 100644 --- a/src/components/workspace-shell-state.test.ts +++ b/src/components/workspace-shell-state.test.ts @@ -7,17 +7,13 @@ describe("workspaceShellState", () => { const widths = workspaceShellState.fitDesktopPanelWidthsToContainer(1280); const totalWidth = widths.sources + - widths.chunks + widths.chat + - workspaceShellState.desktopPanelGutterWidth * 2; + workspaceShellState.desktopPanelGutterWidth; expect(totalWidth).toBeLessThanOrEqual(1280); expect(widths.sources).toBeGreaterThanOrEqual( workspaceShellState.minimumDesktopPanelWidths.sources, ); - expect(widths.chunks).toBeGreaterThanOrEqual( - workspaceShellState.minimumDesktopPanelWidths.chunks, - ); expect(widths.chat).toBeGreaterThanOrEqual( workspaceShellState.minimumDesktopPanelWidths.chat, ); @@ -27,22 +23,20 @@ describe("workspaceShellState", () => { const resized = workspaceShellState.resizeDesktopPanelWidths( { sources: 350, - chunks: 720, - chat: 420, + chat: 800, }, { leftPanel: "sources", - rightPanel: "chunks", + rightPanel: "chat", deltaX: 120, leftWidth: 350, - rightWidth: 600, + rightWidth: 800, }, ); expect(resized).toEqual({ sources: 470, - chunks: 480, - chat: 420, + chat: 680, }); }); @@ -50,45 +44,20 @@ describe("workspaceShellState", () => { const resized = workspaceShellState.resizeDesktopPanelWidths( { sources: 350, - chunks: 720, - chat: 420, + chat: 800, }, { leftPanel: "sources", - rightPanel: "chunks", + rightPanel: "chat", deltaX: -170, leftWidth: 350, - rightWidth: 600, + rightWidth: 800, }, ); expect(resized).toEqual({ sources: 180, - chunks: 770, - chat: 420, - }); - }); - - it("allows the chat panel to narrow continuously before sidebar mode", () => { - const resized = workspaceShellState.resizeDesktopPanelWidths( - { - sources: 350, - chunks: 720, - chat: 420, - }, - { - leftPanel: "chunks", - rightPanel: "chat", - deltaX: 240, - leftWidth: 650, - rightWidth: 420, - }, - ); - - expect(resized).toEqual({ - sources: 350, - chunks: 890, - chat: 180, + chat: 970, }); }); @@ -96,59 +65,53 @@ describe("workspaceShellState", () => { const resized = workspaceShellState.resizeDesktopPanelWidths( { sources: 350, - chunks: 720, - chat: 420, + chat: 800, }, { leftPanel: "sources", - rightPanel: "chunks", - deltaX: -300, + rightPanel: "chat", + deltaX: -400, leftWidth: 350, - rightWidth: 600, + rightWidth: 800, }, ); - expect(resized).toEqual({ - sources: workspaceShellState.collapsedDesktopPanelWidth, - chunks: 950 - workspaceShellState.collapsedDesktopPanelWidth, - chat: 420, - }); + expect(resized.sources).toBe( + workspaceShellState.collapsedDesktopPanelWidth, + ); + expect(resized.sources + resized.chat).toBe(1150); }); it("clamps the chat panel at the compact sidebar width", () => { const resized = workspaceShellState.resizeDesktopPanelWidths( { sources: 350, - chunks: 720, - chat: 420, + chat: 800, }, { - leftPanel: "chunks", + leftPanel: "sources", rightPanel: "chat", - deltaX: 400, - leftWidth: 650, - rightWidth: 420, + deltaX: 1200, + leftWidth: 350, + rightWidth: 800, }, ); - expect(resized).toEqual({ - sources: 350, - chunks: 1_070 - workspaceShellState.collapsedDesktopPanelWidth, - chat: workspaceShellState.collapsedDesktopPanelWidth, - }); + expect(resized.chat).toBe( + workspaceShellState.collapsedDesktopPanelWidth, + ); + expect(resized.sources + resized.chat).toBe(1150); }); it("includes compact sidebars when calculating the minimum desktop width", () => { const minimumWidth = workspaceShellState.getMinimumDesktopPanelWidth({ sources: workspaceShellState.collapsedDesktopPanelWidth, - chunks: 900, chat: workspaceShellState.collapsedDesktopPanelWidth, }); expect(minimumWidth).toBe( workspaceShellState.collapsedDesktopPanelWidth * 2 + - workspaceShellState.minimumDesktopPanelWidths.chunks + - workspaceShellState.desktopPanelGutterWidth * 2, + workspaceShellState.desktopPanelGutterWidth, ); }); }); diff --git a/src/components/workspace-shell-state.ts b/src/components/workspace-shell-state.ts index dadcb6d..96e86b6 100644 --- a/src/components/workspace-shell-state.ts +++ b/src/components/workspace-shell-state.ts @@ -4,22 +4,20 @@ const desktopSidePanelCompactThreshold = 120 const minimumDesktopPanelWidths = { sources: collapsedDesktopPanelWidth, - chunks: 480, chat: collapsedDesktopPanelWidth, } as const const defaultDesktopPanelWidths = { sources: 350, - chunks: 720, - chat: 420, + chat: 800, } as const type DesktopPanelKey = keyof typeof minimumDesktopPanelWidths type DesktopPanelWidths = Record -type DesktopSidePanelKey = Exclude +type DesktopSidePanelKey = DesktopPanelKey -const desktopPanelKeys = ["sources", "chunks", "chat"] as const +const desktopPanelKeys = ["sources", "chat"] as const type DesktopPanelResizeInput = { readonly leftPanel: DesktopPanelKey @@ -162,30 +160,17 @@ function expandDesktopPanelWidth( currentWidths: Readonly, panel: DesktopSidePanelKey, ): DesktopPanelWidths { - if (panel === "sources") { - const totalWidth = currentWidths.sources + currentWidths.chunks - const expandedWidth = getExpandedSidePanelWidth(panel, totalWidth) - - return { - ...currentWidths, - sources: expandedWidth, - chunks: Math.max( - minimumDesktopPanelWidths.chunks, - totalWidth - expandedWidth, - ), - } - } - - const totalWidth = currentWidths.chunks + currentWidths.chat + const totalWidth = currentWidths.sources + currentWidths.chat const expandedWidth = getExpandedSidePanelWidth(panel, totalWidth) + const otherPanel = panel === "sources" ? "chat" : "sources" return { ...currentWidths, - chunks: Math.max( - minimumDesktopPanelWidths.chunks, + [panel]: expandedWidth, + [otherPanel]: Math.max( + minimumDesktopPanelWidths[otherPanel], totalWidth - expandedWidth, ), - chat: expandedWidth, } } @@ -195,7 +180,9 @@ function getExpandedSidePanelWidth( ): number { const preferredWidth = defaultDesktopPanelWidths[panel] const minimumWidth = minimumDesktopPanelWidths[panel] - const maximumSideWidth = totalWidth - minimumDesktopPanelWidths.chunks + const otherMinimum = + minimumDesktopPanelWidths[panel === "sources" ? "chat" : "sources"] + const maximumSideWidth = totalWidth - otherMinimum if (maximumSideWidth >= preferredWidth) return preferredWidth if (maximumSideWidth >= minimumWidth) return maximumSideWidth @@ -205,16 +192,13 @@ function getExpandedSidePanelWidth( function getVisibleDesktopPanelKeys( currentWidths: Readonly, ): DesktopPanelKey[] { - return desktopPanelKeys.filter((panel) => { - if (panel === "chunks") return true - return currentWidths[panel] > 0 - }) + return desktopPanelKeys.filter((panel) => currentWidths[panel] > 0) } function getVisibleDesktopPanelGutterCount( currentWidths: Readonly, ): number { - return getVisibleDesktopPanelKeys(currentWidths).length - 1 + return Math.max(0, getVisibleDesktopPanelKeys(currentWidths).length - 1) } function getDefaultDesktopPanelWidths( @@ -240,15 +224,12 @@ function getDesktopPanelWidthsForVisibility( visibleWidths: Readonly, ): DesktopPanelWidths { return { - sources: - isDesktopPanelCollapsed(currentWidths, "sources") - ? collapsedDesktopPanelWidth - : visibleWidths.sources, - chunks: visibleWidths.chunks, - chat: - isDesktopPanelCollapsed(currentWidths, "chat") - ? collapsedDesktopPanelWidth - : visibleWidths.chat, + sources: isDesktopPanelCollapsed(currentWidths, "sources") + ? collapsedDesktopPanelWidth + : visibleWidths.sources, + chat: isDesktopPanelCollapsed(currentWidths, "chat") + ? collapsedDesktopPanelWidth + : visibleWidths.chat, } } @@ -256,10 +237,8 @@ function getDefaultDesktopPanelWidth( panel: DesktopPanelKey, currentWidths: Readonly, ): number { - if (panel === "sources" || panel === "chat") { - if (isDesktopPanelCollapsed(currentWidths, panel)) { - return collapsedDesktopPanelWidth - } + if (isDesktopPanelCollapsed(currentWidths, panel)) { + return collapsedDesktopPanelWidth } return defaultDesktopPanelWidths[panel] @@ -269,10 +248,8 @@ function getMinimumDesktopPanelWidthForPanel( panel: DesktopPanelKey, currentWidths: Readonly, ): number { - if (panel === "sources" || panel === "chat") { - if (isDesktopPanelCollapsed(currentWidths, panel)) { - return collapsedDesktopPanelWidth - } + if (isDesktopPanelCollapsed(currentWidths, panel)) { + return collapsedDesktopPanelWidth } return minimumDesktopPanelWidths[panel] diff --git a/src/components/workspace-shell.test.ts b/src/components/workspace-shell.test.ts index dc4964c..21971b2 100644 --- a/src/components/workspace-shell.test.ts +++ b/src/components/workspace-shell.test.ts @@ -50,42 +50,32 @@ describe("WorkspaceShell", () => { const layout = screen.getByTestId("desktop-panel-layout"); const panels = screen.getByTestId("desktop-resizable-panels"); const sourcesPanel = screen.getByTestId("desktop-sources-panel"); - const chunksPanel = screen.getByTestId("desktop-chunks-panel"); const minimumTotalWidth = DESKTOP_PANEL_MIN_WIDTHS.sources + - DESKTOP_PANEL_MIN_WIDTHS.chunks + DESKTOP_PANEL_MIN_WIDTHS.chat + - DESKTOP_PANEL_GUTTER_WIDTH * 2; + DESKTOP_PANEL_GUTTER_WIDTH; expect(layout.className).toContain("overflow-x-auto"); expect(panels.style.minWidth).toBe(`${minimumTotalWidth}px`); expect(sourcesPanel.style.width).toBe("350px"); - expect(chunksPanel.style.minWidth).toBe( - `${DESKTOP_PANEL_MIN_WIDTHS.chunks}px`, - ); }); it("lets desktop users resize neighboring panels and collapse sources below the threshold", () => { render(React.createElement(C, { sources: [] })); const firstHandle = screen.getByRole("separator", { - name: "Resize sources and parsed chunks", + name: "Resize sources and chat", }); const sourcesPanel = screen.getByTestId("desktop-sources-panel"); - const chunksPanel = screen.getByTestId("desktop-chunks-panel"); fireEvent.pointerDown(firstHandle, { clientX: 0 }); fireEvent.pointerMove(window, { clientX: 120 }); fireEvent.pointerUp(window); expect(sourcesPanel.style.width).toBe("470px"); - expect(chunksPanel.style.width).toBe("600px"); - const resizedHandle = screen.getByRole("separator", { - name: "Resize sources and parsed chunks", - }); - fireEvent.pointerDown(resizedHandle, { clientX: 120 }); + fireEvent.pointerDown(firstHandle, { clientX: 120 }); fireEvent.pointerMove(window, { clientX: -1000 }); fireEvent.pointerUp(window); @@ -97,46 +87,6 @@ describe("WorkspaceShell", () => { ).toBeTruthy(); }); - it("lets desktop users expand the chat panel by shrinking parsed chunks further", () => { - render(React.createElement(C, { sources: [] })); - - const secondHandle = screen.getByRole("separator", { - name: "Resize parsed chunks and chat", - }); - const chunksPanel = screen.getByTestId("desktop-chunks-panel"); - const chatPanel = screen.getByTestId("desktop-chat-panel"); - - fireEvent.pointerDown(secondHandle, { clientX: 0 }); - fireEvent.pointerMove(window, { clientX: -500 }); - fireEvent.pointerUp(window); - - expect(chunksPanel.style.width).toBe("480px"); - expect(chatPanel.style.width).toBe("660px"); - }); - - it("uses rendered panel widths when resizing the flex-grown middle panel", () => { - render(React.createElement(C, { sources: [] })); - - const secondHandle = screen.getByRole("separator", { - name: "Resize parsed chunks and chat", - }); - const chunksPanel = screen.getByTestId("desktop-chunks-panel"); - const chatPanel = screen.getByTestId("desktop-chat-panel"); - vi.spyOn(chunksPanel, "getBoundingClientRect").mockReturnValue( - createElementRect(1100), - ); - vi.spyOn(chatPanel, "getBoundingClientRect").mockReturnValue( - createElementRect(420), - ); - - fireEvent.pointerDown(secondHandle, { clientX: 0 }); - fireEvent.pointerMove(window, { clientX: -900 }); - fireEvent.pointerUp(window); - - expect(chunksPanel.style.width).toBe("480px"); - expect(chatPanel.style.width).toBe("1040px"); - }); - it("shows a login CTA instead of the chat composer for guests", () => { render( React.createElement(C, { @@ -196,7 +146,7 @@ describe("WorkspaceShell", () => { ); const desktopLibraryPanel = within( - within(screen.getByTestId("desktop-chunks-panel")).getByTestId( + screen.getByTestId( "official-library-panel", ), ); @@ -209,7 +159,7 @@ describe("WorkspaceShell", () => { desktopLibraryPanel.getByRole("button", { name: "Back to sources" }), ); expect( - within(screen.getByTestId("desktop-chunks-panel")).queryByTestId( + screen.queryByTestId( "official-library-panel", ), ).toBeNull(); @@ -1189,7 +1139,7 @@ describe("WorkspaceShell", () => { await user.click(desktopSourcesPanel.getByRole("button", { name: "Open library" })); const desktopLibraryPanel = within( - within(screen.getByTestId("desktop-chunks-panel")).getByTestId( + screen.getByTestId( "official-library-panel", ), ); @@ -1210,7 +1160,7 @@ describe("WorkspaceShell", () => { await desktopChatPanel.findByText("Refreshed materialized answer."); expect(desktopChatPanel.queryByText("Seeded canonical answer.")).toBeNull(); const refreshedLibraryPanel = within( - within(screen.getByTestId("desktop-chunks-panel")).getByTestId( + screen.getByTestId( "official-library-panel", ), ); @@ -1388,20 +1338,6 @@ function countFetchesWithSearch( }).length; } -function createElementRect(width: number): DOMRect { - return { - bottom: 0, - height: 0, - left: 0, - right: width, - top: 0, - width, - x: 0, - y: 0, - toJSON: () => ({}), - }; -} - function makeUploadedBlob(): { readonly url: string; readonly downloadUrl: string; diff --git a/src/components/workspace-shell.tsx b/src/components/workspace-shell.tsx index de90dd8..1644e5d 100644 --- a/src/components/workspace-shell.tsx +++ b/src/components/workspace-shell.tsx @@ -92,7 +92,7 @@ function WorkspaceShellContent({ loginUrl, }: WorkspaceShellProps): ReactElement { const [mobilePanel, setMobilePanel] = useState( - isGuest ? "content" : "chat", + isGuest ? "sources" : "chat", ) const [isChunksOverlayVisible, setIsChunksOverlayVisible] = useState( Boolean(chunkViewDocumentId), From 208f67f4e582f166ebb3452928fb8048fe96caa0 Mon Sep 17 00:00:00 2001 From: Gordon YUEN Date: Thu, 30 Jul 2026 23:11:02 +0800 Subject: [PATCH 12/46] docs: update for 2-panel layout (sources | chat) with overlay chunks/library AGENTS.md: desktop layout convention note; README: new Desktop Layout section. --- AGENTS.md | 4 ++++ README.md | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 2d3aab0..19dc92d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,6 +58,9 @@ src/ app/ Next.js App Router pages and route handlers components/ React components — domain features and shadcn/ui primitives domains/ Product logic: chat, chunks, demo, sources, workspace + agent-harness/ Chat agent validation/ledger runtime + providers/ Client-side context providers + proxy.ts Edge middleware (renamed from middleware.ts in Next.js 16) infrastructure/ Owned platform: auth, database (Drizzle + Neon Postgres) integrations/ External systems: Dashboard oRPC, Knowhere SDK lib/ Cross-cutting utilities (effect-operation, route-result, etc.) @@ -84,6 +87,7 @@ src/ - **Chat provider:** two backends in `src/lib/ai.ts` — `AI_GATEWAY_API_KEY` (Vercel AI Gateway, model as plain string) OR `CHAT_BASE_URL`+`CHAT_API_KEY`+`CHAT_MODEL` (OpenAI-compatible `LanguageModelV3`). Use `getChatModel()`/`isChatConfigured()`; never reintroduce per-call-site `AI_GATEWAY_API_KEY` guards. `@ai-sdk/openai-compatible` is pinned to 2.x (provider V3) to match `ai@6`. - **Vercel Blob is optional:** the chunk-page cache (`src/domains/chunks/server.ts`) is gated on `BLOB_READ_WRITE_TOKEN`; without it the cache is skipped and chunks are served straight from Knowhere. Don't add hard `@vercel/blob` calls in request paths without gating on the token or wrapping in a read-failure-as-miss handler. - **Fonts:** use the local `geist` package (`GeistSans`/`GeistMono` from `geist/font/*`), not `next/font/google` — the repo runs in airgapped/self-hosted setups where Google Fonts is unreachable. +- **Desktop layout:** 2-panel (sources | chat) with one resize handle. Chunks and the Official Library are full-screen overlays (`fixed inset-0 z-50`), not inline panels. `PanelId` is `"sources" | "chat"`. The chunks overlay opens via the source-row tree button or by clicking a citation in chat. ## Domain Language diff --git a/README.md b/README.md index cf87bbc..fd86a3b 100644 --- a/README.md +++ b/README.md @@ -136,3 +136,10 @@ src/ ├── integrations/ # External systems: Dashboard and Knowhere └── lib/ # Small cross-cutting utilities ``` + +## Desktop Layout + +Two-panel (sources | chat) with a resize handle. Parsed chunks and the +Official Library are full-screen overlays, triggered by the tree icon on a +source row or a citation reference in chat. + From 3e50e0f9ffe94f665ee15bd8dcc86ec36f3c26b0 Mon Sep 17 00:00:00 2001 From: Gordon YUEN Date: Fri, 31 Jul 2026 13:59:45 +0800 Subject: [PATCH 13/46] feat: remove demo/guest/official-library, add namespace dropdown + eager localization - Remove demo catalog, guest mode, and Official Library panel entirely - Simplify SourceKind to "workspace" | "remote" (no "demo") - Drop demo_source_visibilities table, demo_key columns from schema - Remove all demo plumbing: demoApi deps, fetchCatalog, hideDemoSource, materializeDemoSources, demo chat seeding, demo asset hardening - Proxy no longer allows anonymous reads; redirects to login - Replace Official Library panel with namespace dropdown in sources header - Add GET /api/namespaces + POST /api/namespaces/[namespace]/localize - Add listKnowhereNamespaces calling GET /v1/documents/namespaces directly - Eagerly localize compatible-namespace docs on every source list load - Pre-filter existing DB rows to avoid redundant upsert writes - Update AGENTS.md, CONTEXT.md, add ADR 0008 84 files changed, +597/-6089 lines --- AGENTS.md | 11 +- CONTEXT.md | 29 +- ...0008-remove-demo-guest-official-library.md | 85 +++ .../icons/official-library/pdf-document.svg | 9 - .../official-library/financial-reports.svg | 10 - public/images/official-library/other-docs.svg | 10 - .../official-library/research-papers.svg | 10 - public/images/official-library/stem-books.svg | 10 - .../assets/[...assetPath]/route.ts | 39 -- .../[demoSourceId]/original/route.ts | 35 - .../demo-sources/materialize/route.test.ts | 247 ------- src/app/api/demo-sources/materialize/route.ts | 174 ----- .../namespaces/[namespace]/localize/route.ts | 102 +++ src/app/api/namespaces/route.ts | 29 + .../sources/[sourceId]/chunks/route.test.ts | 367 ---------- src/app/api/sources/[sourceId]/route.test.ts | 93 --- src/app/api/sources/route.test.ts | 1 - src/app/page.test.ts | 2 - src/components/chat-panel.test.ts | 2 - src/components/namespace-dropdown.tsx | 99 +++ src/components/official-library-panel.test.ts | 259 -------- src/components/official-library-panel.tsx | 455 ------------- src/components/source-row.tsx | 30 +- src/components/sources-panel.test.ts | 112 ---- src/components/sources-panel.tsx | 56 +- .../workspace-chat-workflow.test.ts | 84 +-- src/components/workspace-chat-workflow.ts | 54 +- src/components/workspace-shell-layout.test.ts | 6 - src/components/workspace-shell-layout.tsx | 99 +-- src/components/workspace-shell.test.ts | 444 ------------- src/components/workspace-shell.tsx | 60 +- src/components/workspace-source-state.test.ts | 14 +- .../workspace-source-workflow.test.ts | 100 --- src/components/workspace-source-workflow.ts | 95 +-- src/domains/chat/chat-citation-persistence.ts | 27 - src/domains/chat/chat-thread-repository.ts | 133 ---- .../chat/chat-turn-persistence.test.ts | 1 - src/domains/chat/index.test.ts | 1 - .../chat/media-asset-hardening.test.ts | 42 -- src/domains/chat/media-asset-hardening.ts | 42 -- src/domains/chat/media-assets.test.ts | 1 - src/domains/chat/repository.ts | 2 - src/domains/chat/route-service.test.ts | 2 - src/domains/chat/service.test.ts | 2 - src/domains/chat/thread-service.ts | 31 - src/domains/chunks/index.test.ts | 1 - src/domains/chunks/server.test.ts | 1 - src/domains/demo/original-file.test.ts | 66 -- src/domains/demo/original-file.ts | 62 -- src/domains/demo/view.ts | 89 --- .../demo/workspace-source-resolution.ts | 100 --- src/domains/sources/counts.test.ts | 28 - src/domains/sources/counts.ts | 1 - src/domains/sources/demo-source-repository.ts | 137 ---- src/domains/sources/reconcile.test.ts | 1 - src/domains/sources/remote-library.ts | 6 + src/domains/sources/repository.ts | 8 - src/domains/sources/retry.test.ts | 1 - src/domains/sources/route-archive.ts | 17 - src/domains/sources/route-chunks.ts | 119 ---- src/domains/sources/route-dependencies.ts | 10 - src/domains/sources/route-listing.ts | 80 +-- src/domains/sources/route-service.test.ts | 412 +----------- src/domains/sources/route-types.ts | 34 +- src/domains/sources/service.ts | 15 - .../sources/source-reconcile-workflow.test.ts | 1 - .../sources/source-row-repository.test.ts | 1 - src/domains/sources/source-row-repository.ts | 1 - src/domains/sources/types.ts | 23 +- src/domains/sources/upload.test.ts | 1 - src/domains/sources/view.test.ts | 41 -- src/domains/sources/view.ts | 27 +- src/domains/sources/workflow-runtime.test.ts | 1 - src/domains/sources/workflow-runtime.ts | 36 - src/domains/workspace/client.test.ts | 13 - src/domains/workspace/client.ts | 63 +- src/domains/workspace/demo-migration.test.ts | 32 - src/domains/workspace/initial-state.test.ts | 444 +------------ src/domains/workspace/initial-state.ts | 270 ++------ src/domains/workspace/integration.test.ts | 55 -- src/domains/workspace/persistence.test.ts | 2 - src/domains/workspace/request-context.ts | 27 - src/infrastructure/db/schema.ts | 45 +- src/integrations/knowhere-demo.test.ts | 190 ------ src/integrations/knowhere-demo.ts | 624 ------------------ src/integrations/knowhere.ts | 40 ++ src/lib/posthog.test.ts | 7 +- src/lib/posthog.ts | 2 - src/proxy.test.ts | 22 +- src/proxy.ts | 14 - 90 files changed, 597 insertions(+), 6089 deletions(-) create mode 100644 docs/adr/0008-remove-demo-guest-official-library.md delete mode 100644 public/icons/official-library/pdf-document.svg delete mode 100644 public/images/official-library/financial-reports.svg delete mode 100644 public/images/official-library/other-docs.svg delete mode 100644 public/images/official-library/research-papers.svg delete mode 100644 public/images/official-library/stem-books.svg delete mode 100644 src/app/api/demo-sources/[demoSourceId]/assets/[...assetPath]/route.ts delete mode 100644 src/app/api/demo-sources/[demoSourceId]/original/route.ts delete mode 100644 src/app/api/demo-sources/materialize/route.test.ts delete mode 100644 src/app/api/demo-sources/materialize/route.ts create mode 100644 src/app/api/namespaces/[namespace]/localize/route.ts create mode 100644 src/app/api/namespaces/route.ts create mode 100644 src/components/namespace-dropdown.tsx delete mode 100644 src/components/official-library-panel.test.ts delete mode 100644 src/components/official-library-panel.tsx delete mode 100644 src/domains/demo/original-file.test.ts delete mode 100644 src/domains/demo/original-file.ts delete mode 100644 src/domains/demo/view.ts delete mode 100644 src/domains/demo/workspace-source-resolution.ts delete mode 100644 src/domains/sources/demo-source-repository.ts delete mode 100644 src/domains/workspace/demo-migration.test.ts delete mode 100644 src/integrations/knowhere-demo.test.ts delete mode 100644 src/integrations/knowhere-demo.ts diff --git a/AGENTS.md b/AGENTS.md index 19dc92d..6fefb12 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,16 +57,13 @@ CI runs: `lint → typecheck → test → build` on PRs to `main` and `staging`. src/ app/ Next.js App Router pages and route handlers components/ React components — domain features and shadcn/ui primitives - domains/ Product logic: chat, chunks, demo, sources, workspace + domains/ Product logic: chat, chunks, sources, workspace agent-harness/ Chat agent validation/ledger runtime providers/ Client-side context providers proxy.ts Edge middleware (renamed from middleware.ts in Next.js 16) infrastructure/ Owned platform: auth, database (Drizzle + Neon Postgres) integrations/ External systems: Dashboard oRPC, Knowhere SDK lib/ Cross-cutting utilities (effect-operation, route-result, etc.) - agent-harness/ Chat agent validation/ledger runtime - providers/ Client-side context providers - proxy.ts Edge middleware (renamed from middleware.ts in Next.js 16) ``` - Route handlers are thin HTTP adapters: parse request → call a **Route Service** (in `src/domains/*/route-*.ts`) → serialize `RouteResult`. See `src/app/api/chat/route.ts` for the pattern. @@ -87,7 +84,11 @@ src/ - **Chat provider:** two backends in `src/lib/ai.ts` — `AI_GATEWAY_API_KEY` (Vercel AI Gateway, model as plain string) OR `CHAT_BASE_URL`+`CHAT_API_KEY`+`CHAT_MODEL` (OpenAI-compatible `LanguageModelV3`). Use `getChatModel()`/`isChatConfigured()`; never reintroduce per-call-site `AI_GATEWAY_API_KEY` guards. `@ai-sdk/openai-compatible` is pinned to 2.x (provider V3) to match `ai@6`. - **Vercel Blob is optional:** the chunk-page cache (`src/domains/chunks/server.ts`) is gated on `BLOB_READ_WRITE_TOKEN`; without it the cache is skipped and chunks are served straight from Knowhere. Don't add hard `@vercel/blob` calls in request paths without gating on the token or wrapping in a read-failure-as-miss handler. - **Fonts:** use the local `geist` package (`GeistSans`/`GeistMono` from `geist/font/*`), not `next/font/google` — the repo runs in airgapped/self-hosted setups where Google Fonts is unreachable. -- **Desktop layout:** 2-panel (sources | chat) with one resize handle. Chunks and the Official Library are full-screen overlays (`fixed inset-0 z-50`), not inline panels. `PanelId` is `"sources" | "chat"`. The chunks overlay opens via the source-row tree button or by clicking a citation in chat. +- **Desktop layout:** 2-panel (sources | chat) with one resize handle. Chunks are a full-screen overlay (`fixed inset-0 z-50`), not an inline panel. `PanelId` is `"sources" | "chat"`. The chunks overlay opens via the source-row tree button or by clicking a citation in chat. A namespace dropdown in the sources panel header lets users import documents from any Knowhere namespace. +- **No demo or guest mode:** Demo catalog, guest mode, and the Official Library panel have been removed. All sources are either `kind: "workspace"` (local DB row) or `kind: "remote"` (Knowhere document not yet localized). Anonymous requests redirect to login. +- **Eager localization:** Compatible-namespace Knowhere documents are auto-localized into workspace Source rows on every source list load (`GET /api/sources` and SSR). No user click needed. `localizeRemoteLibrarySources` pre-filters against existing DB rows to avoid redundant writes. +- **SourceKind:** `"workspace" | "remote"` only. The `"demo"` variant has been removed. +- **Namespace API:** `GET /api/namespaces` lists all Knowhere namespaces with document counts. `POST /api/namespaces/[namespace]/localize` bulk-localizes all documents from a specific namespace. The SDK does not expose a namespaces endpoint, so `listKnowhereNamespaces` in `src/integrations/knowhere.ts` calls `GET /v1/documents/namespaces` directly. ## Domain Language diff --git a/CONTEXT.md b/CONTEXT.md index 80a0498..6a99050 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -13,7 +13,8 @@ for retrieval. Workspace creation is idempotent per Dashboard user. The Workspace Shell is the client-side orchestrator for the Notebook work surface. It composes Source selection, Parsed Chunk pagination, Chat Thread -state, Citation focus, and panel layout into the visible three-panel notebook. +state, Citation focus, and panel layout into the visible two-panel notebook +(sources | chat) with a full-screen chunks overlay. ## Workspace Shell Layout @@ -30,8 +31,8 @@ route paths or mutation request shapes inline. ## Workspace Desktop Panels Workspace Desktop Panels is the hook that owns browser measurements and resize -drag state for the three desktop panels. Pure resize math stays in Workspace -Shell State. +drag state for the two desktop panels (sources | chat). Pure resize math stays +in Workspace Shell State. ## Workspace Resize Handle Workflow @@ -54,15 +55,17 @@ Sources are soft-deleted with `deletedAt` rather than removed. ## Source Repository The Source Repository is a stable facade over smaller persistence modules. It -composes Source row lifecycle, Demo Source persistence, and Source Parse Result -artifact metadata without exposing those internal modules to route services. +composes Source row lifecycle and Source Parse Result artifact metadata +without exposing those internal modules to route services. ## Source Library Localization Source Library Localization is the workflow that turns Knowhere-owned library -documents into Notebook Source rows for a Workspace. Listing and chat should -localize missing Knowhere documents before chunks, archive, selection, or -retrieval flows act on them. +documents into Notebook Source rows for a Workspace. Listing and SSR eagerly +localize compatible-namespace documents (via `localizeRemoteLibrarySources`) +before chunks, archive, selection, or retrieval flows act on them. Only +genuinely new documents are upserted — existing DB rows are pre-filtered to +avoid redundant writes. ## Source Upload @@ -74,7 +77,7 @@ Large files should use the Blob-backed path instead of a Server Action upload. The Source Upload Contract names the repository and Knowhere client shapes used by upload workflows. Persistence adapters can depend on the contract without -importing the user-upload or Demo Source workflow implementation. +importing the user-upload workflow implementation. ## Source Row @@ -93,12 +96,6 @@ Source Upload Dialog Workflow owns browser upload dialog behavior: open state, selected file state, drag-and-drop selection, upload submission, friendly error messages, duplicate-submit prevention, and post-upload cleanup. -## Demo Source - -A Demo Source is app-owned static content served to guest users and optionally -materialized into an authenticated workspace. Demo sources should not depend on -live workspace state for guest rendering. - ## Source Original Preview Source Original Preview is the browser-side read-only view for a Source's @@ -150,7 +147,7 @@ callbacks. ## Chat Repository The Chat Repository is a stable facade over Chat Thread lifecycle, Chat Message -persistence, Demo Chat seeding, and Citation persistence normalization. +persistence, and Citation persistence normalization. ## Chat Message diff --git a/docs/adr/0008-remove-demo-guest-official-library.md b/docs/adr/0008-remove-demo-guest-official-library.md new file mode 100644 index 0000000..f983fda --- /dev/null +++ b/docs/adr/0008-remove-demo-guest-official-library.md @@ -0,0 +1,85 @@ +# ADR 0008: Remove demo, guest mode, and Official Library + +**Date:** 2026-07-31 + +## Status + +Accepted + +## Context + +The Notebook shipped with a demo catalog system that served static content to +anonymous (guest) users and an Official Library panel that let authenticated +users browse and materialize curated demo sources into their workspace. This +added significant complexity across every layer: + +- **DB schema:** `demo_source_visibilities` table, `demo_key` columns on + `sources` and `chat_threads`, and associated indexes. +- **Domain layer:** `src/domains/demo/`, `src/integrations/knowhere-demo.ts`, + `demo-source-repository.ts`, demo catalog fetching in route listing, + demo chunk page loading, demo chat thread seeding, demo asset URL + hardening, hidden-demo-source filtering, and materialization workflow. +- **UI layer:** `OfficialLibraryPanel`, library overlay state, guest-mode + plumbing (`isGuest`, `loginUrl`, `onLoginClick`), `ContentView` type with + `"library"` variant, and `addingLibrarySourceIds` workflow state. +- **Proxy:** Guest source-read path regexes and demo asset/original path + allowlist for anonymous access. + +The self-hosted deployment does not use the demo catalog or the Official +Library. All real documents come from Knowhere namespaces. Guest mode provided +no value without the demo catalog. + +## Decision + +Remove demo, guest mode, and the Official Library entirely: + +1. **Delete** all demo-specific files: `src/integrations/knowhere-demo.ts`, + `src/domains/demo/`, `src/app/api/demo-sources/`, + `src/components/official-library-panel.tsx`, `src/domains/sources/demo-source-repository.ts`, + and demo static assets (`public/images/official-library/`, + `public/icons/official-library/`). + +2. **Simplify `SourceKind`** to `"workspace" | "remote"` (the `"demo"` variant + is removed). + +3. **Remove DB demo infrastructure:** drop `demo_source_visibilities` table, + `sources.demo_key` column + index, `chat_threads.demo_key` column + index. + +4. **Remove guest mode:** the proxy no longer allows anonymous source reads. + Anonymous requests redirect to login. `getGuest()` is removed from + `notebookRequestContext`. Unauthenticated SSR returns `{ sources: [] }`. + +5. **Remove demo plumbing from domain/components:** `demoApi` deps, + `fetchCatalog`, `hideDemoSource`, `listHiddenDemoSourceIds`, + `upsertMaterializedDemoSource`, demo chat thread seeding, demo asset URL + hardening, `materializeDemoSources` client method, `isGuest`/`loginUrl` + props, `onOfficialLibrarySourceAdd`, `addingLibrarySourceIds`, + `ContentView`/`onLibraryOpen`/`onLibraryBack`. + +6. **Replace the Official Library panel with a namespace dropdown** in the + sources panel header. The dropdown calls `GET /api/namespaces` (backed by + Knowhere's `GET /v1/documents/namespaces`) and lets users import all + documents from any namespace via `POST /api/namespaces/[namespace]/localize`. + +7. **Eagerly localize compatible-namespace documents** on every source list + load (both `GET /api/sources` and SSR). `localizeRemoteLibrarySources` + pre-filters against existing DB rows by `knowhereDocumentId` so only + genuinely new documents are upserted. + +## Consequences + +- **Simpler codebase:** ~6000 lines removed across 84 files. +- **No anonymous access:** self-hosted deployments require `KNOWHERE_API_KEY` + for dev mode or Dashboard auth for production. +- **All sources are real:** no static/demo content. Sources are either + `kind: "workspace"` (uploaded or localized DB rows) or `kind: "remote"` + (transient Knowhere documents not yet localized). +- **Eager localization means new Knowhere documents appear automatically:** + no user action needed. The pre-filter prevents write amplification on + repeated list loads. +- **Namespace dropdown extends beyond compatible namespaces:** users can + import from any Knowhere namespace, not just `default` and the workspace + namespace. This replaces the curated Official Library with open access to + all available namespaces. +- **DB schema is clean:** `db:push --force` on a fresh database creates the + simplified schema without demo tables or columns. diff --git a/public/icons/official-library/pdf-document.svg b/public/icons/official-library/pdf-document.svg deleted file mode 100644 index 460463b..0000000 --- a/public/icons/official-library/pdf-document.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - .pdf - diff --git a/public/images/official-library/financial-reports.svg b/public/images/official-library/financial-reports.svg deleted file mode 100644 index 541468f..0000000 --- a/public/images/official-library/financial-reports.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/public/images/official-library/other-docs.svg b/public/images/official-library/other-docs.svg deleted file mode 100644 index 6bb6039..0000000 --- a/public/images/official-library/other-docs.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/public/images/official-library/research-papers.svg b/public/images/official-library/research-papers.svg deleted file mode 100644 index e1171e5..0000000 --- a/public/images/official-library/research-papers.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/public/images/official-library/stem-books.svg b/public/images/official-library/stem-books.svg deleted file mode 100644 index 3353699..0000000 --- a/public/images/official-library/stem-books.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/src/app/api/demo-sources/[demoSourceId]/assets/[...assetPath]/route.ts b/src/app/api/demo-sources/[demoSourceId]/assets/[...assetPath]/route.ts deleted file mode 100644 index 247f645..0000000 --- a/src/app/api/demo-sources/[demoSourceId]/assets/[...assetPath]/route.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { knowhereDemoApi } from "@/integrations/knowhere-demo" - -type RouteContext = { - readonly params: Promise<{ - readonly demoSourceId: string - readonly assetPath: string[] - }> -} - -export async function GET( - _request: Request, - context: RouteContext, -): Promise { - const { assetPath, demoSourceId } = await context.params - const encodedAssetPath = assetPath.map(encodeURIComponent).join("/") - const response = await fetch( - knowhereDemoApi.resolveApiURL( - `/api/v1/demo/sources/${encodeURIComponent( - demoSourceId, - )}/assets/${encodedAssetPath}`, - ), - { cache: "no-store" }, - ) - - if (!response.ok || !response.body) { - return Response.json( - { message: "Demo source asset not found." }, - { status: 404 }, - ) - } - - return new Response(response.body, { - status: 200, - headers: { - "content-type": response.headers.get("content-type") ?? "application/octet-stream", - "cache-control": "public, max-age=3600", - }, - }) -} diff --git a/src/app/api/demo-sources/[demoSourceId]/original/route.ts b/src/app/api/demo-sources/[demoSourceId]/original/route.ts deleted file mode 100644 index c3b04d3..0000000 --- a/src/app/api/demo-sources/[demoSourceId]/original/route.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { knowhereDemoApi } from "@/integrations/knowhere-demo" - -type RouteContext = { - readonly params: Promise<{ - readonly demoSourceId: string - }> -} - -export async function GET( - _request: Request, - context: RouteContext, -): Promise { - const { demoSourceId } = await context.params - const response = await fetch( - knowhereDemoApi.resolveApiURL( - `/api/v1/demo/sources/${encodeURIComponent(demoSourceId)}/original`, - ), - { cache: "no-store" }, - ) - - if (!response.ok || !response.body) { - return Response.json( - { message: "Demo original file not found." }, - { status: 404 }, - ) - } - - return new Response(response.body, { - status: 200, - headers: { - "content-type": response.headers.get("content-type") ?? "application/pdf", - "cache-control": "public, max-age=3600", - }, - }) -} diff --git a/src/app/api/demo-sources/materialize/route.test.ts b/src/app/api/demo-sources/materialize/route.test.ts deleted file mode 100644 index 9b41725..0000000 --- a/src/app/api/demo-sources/materialize/route.test.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest" - -import type { Source, Workspace } from "@/infrastructure/db/schema" - -const mocks = vi.hoisted(() => ({ - getAuthenticatedWithClient: vi.fn(), - listHiddenDemoSourceIds: vi.fn(), - materializeSources: vi.fn(), - upsertMaterializedDemoSource: vi.fn(), -})) - -vi.mock("@/domains/workspace/request-context", () => ({ - notebookRequestContext: { - getAuthenticatedWithClient: mocks.getAuthenticatedWithClient, - }, -})) - -vi.mock("@/integrations/knowhere-demo", () => ({ - knowhereDemoApi: { - materializeSources: mocks.materializeSources, - }, -})) - -vi.mock("@/domains/sources/service", () => ({ - sourceService: { - listHiddenDemoSourceIds: mocks.listHiddenDemoSourceIds, - upsertMaterializedDemoSource: mocks.upsertMaterializedDemoSource, - }, -})) - -import { POST } from "./route" - -describe("POST /api/demo-sources/materialize", () => { - beforeEach(() => { - vi.clearAllMocks() - mocks.listHiddenDemoSourceIds.mockResolvedValue([]) - }) - - it("materializes selected demo sources through Knowhere and stores source rows", async () => { - const workspace = makeWorkspace() - mocks.getAuthenticatedWithClient.mockResolvedValue({ - apiKey: "jwt_123", - workspace, - }) - mocks.materializeSources.mockResolvedValue([ - { - demoSourceId: "demo-tsla-q4-2025", - documentId: "doc_user_copy", - title: "TSLA-Q4-2025-Update.pdf", - mimeType: "application/pdf", - sizeBytes: 5648867, - chunkCount: 70, - status: "created", - originalFile: { - url: "https://example.com/tsla-q4-2025.pdf", - mimeType: "application/pdf", - sizeBytes: 5648867, - canDownload: false, - }, - }, - ]) - mocks.upsertMaterializedDemoSource.mockResolvedValue( - makeSource(workspace.id), - ) - - const response = await POST( - new Request("http://localhost:3001/api/demo-sources/materialize", { - method: "POST", - body: JSON.stringify({ - demoSourceIds: ["demo-tsla-q4-2025", "demo-tsla-q4-2025"], - }), - }), - ) - - await expect(response.json()).resolves.toEqual({ - sources: [ - { - id: "source_demo", - kind: "workspace", - title: "TSLA-Q4-2025-Update.pdf", - mimeType: "application/pdf", - status: "ready", - demoSourceId: "demo-tsla-q4-2025", - documentId: "doc_user_copy", - originalFile: { - url: "https://example.com/tsla-q4-2025.pdf", - mimeType: "application/pdf", - sizeBytes: 5648867, - canDownload: false, - pdfPreviewMode: "browser", - }, - chunkCount: 70, - }, - ], - }) - expect(response.status).toBe(200) - expect(mocks.listHiddenDemoSourceIds).toHaveBeenCalledWith(workspace.id) - expect(mocks.materializeSources).toHaveBeenCalledWith({ - apiKey: "jwt_123", - namespace: workspace.namespace, - demoSourceIds: ["demo-tsla-q4-2025"], - }) - expect(mocks.upsertMaterializedDemoSource).toHaveBeenCalledWith( - workspace.id, - { - demoSourceId: "demo-tsla-q4-2025", - title: "TSLA-Q4-2025-Update.pdf", - mimeType: "application/pdf", - sizeBytes: 5648867, - knowhereDocumentId: "doc_user_copy", - originalBlobUrl: "https://example.com/tsla-q4-2025.pdf", - }, - ) - }) - - it("does not store non-public legacy demo original routes", async () => { - const workspace = makeWorkspace() - mocks.getAuthenticatedWithClient.mockResolvedValue({ - apiKey: "jwt_123", - workspace, - }) - mocks.materializeSources.mockResolvedValue([ - { - demoSourceId: "legacy-demo", - documentId: "doc_legacy_copy", - title: "Legacy-Demo.pdf", - mimeType: "application/pdf", - sizeBytes: 10, - chunkCount: 1, - status: "created", - originalFile: { - url: "https://api.knowhere.example/api/v1/demo/sources/legacy-demo/original", - mimeType: "application/pdf", - sizeBytes: 10, - canDownload: false, - }, - }, - ]) - mocks.upsertMaterializedDemoSource.mockResolvedValue( - makeSource(workspace.id, { originalBlobUrl: null }), - ) - - const response = await POST( - new Request("http://localhost:3001/api/demo-sources/materialize", { - method: "POST", - body: JSON.stringify({ - demoSourceIds: ["legacy-demo"], - }), - }), - ) - - expect(response.status).toBe(200) - expect(mocks.upsertMaterializedDemoSource).toHaveBeenCalledWith( - workspace.id, - expect.objectContaining({ - demoSourceId: "legacy-demo", - originalBlobUrl: null, - }), - ) - }) - - it("does not materialize demo sources hidden in the workspace", async () => { - const workspace = makeWorkspace() - mocks.getAuthenticatedWithClient.mockResolvedValue({ - apiKey: "jwt_123", - workspace, - }) - mocks.listHiddenDemoSourceIds.mockResolvedValue(["demo-tsla-q4-2025"]) - - const response = await POST( - new Request("http://localhost:3001/api/demo-sources/materialize", { - method: "POST", - body: JSON.stringify({ - demoSourceIds: ["demo-tsla-q4-2025"], - }), - }), - ) - - await expect(response.json()).resolves.toEqual({ - message: "Selected demo sources are no longer available.", - }) - expect(response.status).toBe(400) - expect(mocks.materializeSources).not.toHaveBeenCalled() - expect(mocks.upsertMaterializedDemoSource).not.toHaveBeenCalled() - }) - - it("filters hidden demo sources before materializing visible selections", async () => { - const workspace = makeWorkspace() - mocks.getAuthenticatedWithClient.mockResolvedValue({ - apiKey: "jwt_123", - workspace, - }) - mocks.listHiddenDemoSourceIds.mockResolvedValue(["hidden-demo"]) - mocks.materializeSources.mockResolvedValue([]) - - const response = await POST( - new Request("http://localhost:3001/api/demo-sources/materialize", { - method: "POST", - body: JSON.stringify({ - demoSourceIds: ["hidden-demo", "demo-tsla-q4-2025"], - }), - }), - ) - - expect(response.status).toBe(200) - expect(mocks.materializeSources).toHaveBeenCalledWith({ - apiKey: "jwt_123", - namespace: workspace.namespace, - demoSourceIds: ["demo-tsla-q4-2025"], - }) - }) -}) - -function makeWorkspace(): Workspace { - return { - id: "workspace_1", - userId: "user_1", - namespace: "notebook-workspace_1", - createdAt: new Date("2026-05-10T00:00:00.000Z"), - } -} - -function makeSource( - workspaceId: string, - overrides: Partial = {}, -): Source { - return { - id: "source_demo", - workspaceId, - title: "TSLA-Q4-2025-Update.pdf", - mimeType: "application/pdf", - sizeBytes: 5648867, - status: "ready", - failureReason: null, - knowhereJobId: null, - knowhereDocumentId: "doc_user_copy", - stagedBlobPathname: null, - stagedBlobUrl: null, - originalBlobPathname: null, - originalBlobUrl: "https://example.com/tsla-q4-2025.pdf", - demoKey: "demo-tsla-q4-2025", - createdAt: new Date("2026-05-10T00:00:00.000Z"), - updatedAt: new Date("2026-05-10T00:00:00.000Z"), - deletedAt: null, - ...overrides, - } -} diff --git a/src/app/api/demo-sources/materialize/route.ts b/src/app/api/demo-sources/materialize/route.ts deleted file mode 100644 index c87a1db..0000000 --- a/src/app/api/demo-sources/materialize/route.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { Effect } from "effect" -import type { NextResponse } from "next/server" - -import { chatCitationPersistence } from "@/domains/chat/chat-citation-persistence" -import { chatMessageRepository } from "@/domains/chat/chat-message-repository" -import { chatThreadRepository } from "@/domains/chat/chat-thread-repository" -import type { ChatCitationView } from "@/domains/chat/types" -import { demoOriginalFile } from "@/domains/demo/original-file" -import { databaseRuntime } from "@/domains/workspace/database-runtime" -import { sourceService } from "@/domains/sources/service" -import { toSourceView } from "@/domains/sources/view" -import { notebookRequestContext } from "@/domains/workspace/request-context" -import { knowhereDemoApi } from "@/integrations/knowhere-demo" -import { nextRouteResponse } from "@/lib/next-route-response" -import { routeResult } from "@/lib/route-result" - -export async function POST(request: Request): Promise { - return Effect.runPromise( - Effect.gen(function* () { - const body = yield* Effect.tryPromise(() => - routeResult.readJson(request), - ) - if (!body.ok) { - return nextRouteResponse.toNextResponse( - routeResult.badRequest("Invalid request body."), - ) - } - - const demoSourceIds = getDemoSourceIds(body.value) - if (demoSourceIds.length === 0) { - return nextRouteResponse.toNextResponse( - routeResult.badRequest("Select at least one demo source."), - ) - } - - const { apiKey, workspace } = yield* Effect.tryPromise(() => - notebookRequestContext.getAuthenticatedWithClient(), - ) - const hiddenDemoSourceIds = new Set( - yield* Effect.tryPromise(() => - sourceService.listHiddenDemoSourceIds(workspace.id), - ), - ) - const visibleDemoSourceIds = demoSourceIds.filter( - (demoSourceId) => !hiddenDemoSourceIds.has(demoSourceId), - ) - if (visibleDemoSourceIds.length === 0) { - return nextRouteResponse.toNextResponse( - routeResult.badRequest( - "Selected demo sources are no longer available.", - ), - ) - } - - const materializedSources = yield* Effect.tryPromise(() => - knowhereDemoApi.materializeSources({ - apiKey, - namespace: workspace.namespace, - demoSourceIds: visibleDemoSourceIds, - }), - ) - - const sources = yield* Effect.all( - materializedSources.map((source) => - Effect.gen(function* () { - const row = yield* Effect.tryPromise(() => - sourceService.upsertMaterializedDemoSource(workspace.id, { - demoSourceId: source.demoSourceId, - title: source.title, - mimeType: source.mimeType, - sizeBytes: source.sizeBytes, - knowhereDocumentId: source.documentId, - originalBlobUrl: demoOriginalFile.getPublicUrl(source), - }), - ) - return toSourceView(row, { chunkCount: source.chunkCount }) - }), - ), - { concurrency: "unbounded" }, - ) - - // After materialization, remap seeded demo-thread citations from their - // canonical document IDs to the new materialized document IDs so source - // citation resolution continues to work. - yield* Effect.tryPromise(() => - fixDemoThreadCitations(workspace.id, materializedSources), - ).pipe(Effect.catchAllCause(() => Effect.void)) - - return nextRouteResponse.toNextResponse(routeResult.ok({ sources })) - }).pipe( - Effect.catchAll(() => - Effect.succeed( - nextRouteResponse.toNextResponse( - routeResult.error( - 502, - "Demo sources could not be prepared right now.", - ), - ), - ), - ), - ), - ) -} - -function getDemoSourceIds(value: unknown): string[] { - if (!isRecord(value) || !Array.isArray(value.demoSourceIds)) return [] - - const selectedIds = value.demoSourceIds.filter( - (item): item is string => - typeof item === "string" && item.trim().length > 0, - ) - return Array.from(new Set(selectedIds.map((item) => item.trim()))) -} - -function isRecord(value: unknown): value is Readonly> { - return typeof value === "object" && value !== null -} - -const seededDemoChatKey = "knowhere-demo-chat" - -async function fixDemoThreadCitations( - workspaceId: string, - materializedSources: ReadonlyArray<{ - readonly demoSourceId: string - readonly documentId: string - }>, -): Promise { - const catalog = await knowhereDemoApi.fetchCatalog() - const canonicalIdByDemoSourceId = new Map( - catalog.sources.map((s) => [s.demoSourceId, s.canonicalDocumentId]), - ) - const documentIdMap = new Map() - for (const source of materializedSources) { - const canonical = canonicalIdByDemoSourceId.get(source.demoSourceId) - if (canonical) { - documentIdMap.set(canonical, source.documentId) - } - } - if (documentIdMap.size === 0) return - - const thread = await databaseRuntime.runPromise( - chatThreadRepository.findThreadByDemoKeyEffect( - workspaceId, - seededDemoChatKey, - ), - ) - if (!thread) return - - const messages = await databaseRuntime.runPromise( - chatMessageRepository.listMessagesForThreadEffect(workspaceId, thread.id), - ) - if (!messages || messages.length === 0) return - - await Promise.all( - messages.map(async (message) => { - const currentCitations = message.citations as - | ChatCitationView[] - | null - | undefined - const updated = chatCitationPersistence.replaceDemoCitationDocumentId( - currentCitations ?? undefined, - documentIdMap, - ) - if (!updated) return - - await databaseRuntime.runPromise( - chatMessageRepository.updateMessageCitationsEffect( - message.id, - chatCitationPersistence.normalizeCitations(updated), - ), - ) - }), - ) -} diff --git a/src/app/api/namespaces/[namespace]/localize/route.ts b/src/app/api/namespaces/[namespace]/localize/route.ts new file mode 100644 index 0000000..fc26548 --- /dev/null +++ b/src/app/api/namespaces/[namespace]/localize/route.ts @@ -0,0 +1,102 @@ +import type { NextRequest, NextResponse } from "next/server" + +import { withApiErrorResponse } from "@/lib/api-error-response" +import { getCurrentUser } from "@/infrastructure/auth" +import { workspaceService } from "@/domains/workspace/service" +import { ensureApiKeyForWorkspace } from "@/integrations/dashboard/api-key-service" +import { makeKnowhereClient, listKnowhereNamespaces } from "@/integrations/knowhere" +import { nextRouteContext } from "@/lib/next-route-context" +import { nextRouteResponse } from "@/lib/next-route-response" +import { routeResult } from "@/lib/route-result" +import { sourceService } from "@/domains/sources/service" +import { sourceWorkflowRuntime } from "@/domains/sources/workflow-runtime" +import { toSourceView } from "@/domains/sources/view" +import type { SourceStatus } from "@/domains/sources/types" + +export async function POST( + _request: NextRequest, + { params }: { params: Promise<{ namespace: string }> }, +): Promise { + return withApiErrorResponse( + "namespaces:localize", + async () => { + const { namespace } = await params + const decodedNamespace = decodeURIComponent(namespace) + const routeContext = await nextRouteContext.read() + const user = await getCurrentUser() + if (!user) { + return nextRouteResponse.toNextResponse(routeResult.badRequest("Not authenticated.")) + } + const workspace = await workspaceService.ensureWorkspace(user.id) + const apiKey = await ensureApiKeyForWorkspace( + workspace.id, + routeContext.cookieHeader, + ) + const client = makeKnowhereClient(apiKey) + + const localSources = await sourceWorkflowRuntime.listForWorkspace( + workspace.id, + ) + const localDocumentIds = new Set( + localSources.flatMap((source) => + source.knowhereDocumentId ? [source.knowhereDocumentId] : [], + ), + ) + + let allNamespaces: string[] + if (decodedNamespace === "all") { + const namespaces = await listKnowhereNamespaces(apiKey) + allNamespaces = namespaces.map((ns) => ns.namespace) + } else { + allNamespaces = [decodedNamespace] + } + + const newSources = [] + for (const ns of allNamespaces) { + let page = 1 + let totalPages = 1 + do { + const response = await client.documents.list({ + namespace: ns, + page, + pageSize: 200, + }) + for (const doc of response.documents ?? []) { + if (!doc.documentId) continue + if (localDocumentIds.has(doc.documentId)) continue + + const status: SourceStatus = + doc.status === "active" || doc.status === "ready" || doc.status === "done" + ? "ready" + : doc.status === "failed" + ? "failed" + : "parsing" + + const source = await sourceService.localizeRemoteDocument( + workspace.id, + { + documentId: doc.documentId, + namespace: doc.namespace ?? ns, + status, + title: doc.sourceFileName ?? undefined, + revisionKey: doc.currentJobResultId ?? null, + }, + ) + newSources.push(source) + } + const pagination = response.pagination + const tp = pagination?.totalPages ?? 1 + totalPages = typeof tp === "number" && tp > 0 ? Math.floor(tp) : 1 + page += 1 + } while (page <= totalPages) + } + + return nextRouteResponse.toNextResponse( + routeResult.ok({ + sources: newSources.map((source) => toSourceView(source)), + }), + ) + }, + "Could not import documents from this namespace.", + ) +} diff --git a/src/app/api/namespaces/route.ts b/src/app/api/namespaces/route.ts new file mode 100644 index 0000000..8f60b48 --- /dev/null +++ b/src/app/api/namespaces/route.ts @@ -0,0 +1,29 @@ +import type { NextResponse } from "next/server" + +import { withApiErrorResponse } from "@/lib/api-error-response" +import { getCurrentUser } from "@/infrastructure/auth" +import { workspaceService } from "@/domains/workspace/service" +import { ensureApiKeyForWorkspace } from "@/integrations/dashboard/api-key-service" +import { listKnowhereNamespaces } from "@/integrations/knowhere" +import { nextRouteContext } from "@/lib/next-route-context" +import { nextRouteResponse } from "@/lib/next-route-response" +import { routeResult } from "@/lib/route-result" + +export async function GET(): Promise { + return withApiErrorResponse("namespaces:list", async () => { + const routeContext = await nextRouteContext.read() + const user = await getCurrentUser() + if (!user) { + return nextRouteResponse.toNextResponse(routeResult.badRequest("Not authenticated.")) + } + const workspace = await workspaceService.ensureWorkspace(user.id) + const apiKey = await ensureApiKeyForWorkspace( + workspace.id, + routeContext.cookieHeader, + ) + const namespaces = await listKnowhereNamespaces(apiKey) + return nextRouteResponse.toNextResponse( + routeResult.ok({ namespaces }), + ) + }) +} diff --git a/src/app/api/sources/[sourceId]/chunks/route.test.ts b/src/app/api/sources/[sourceId]/chunks/route.test.ts index 524a657..758e89c 100644 --- a/src/app/api/sources/[sourceId]/chunks/route.test.ts +++ b/src/app/api/sources/[sourceId]/chunks/route.test.ts @@ -7,7 +7,6 @@ const mocks = vi.hoisted(() => ({ deleteBlob: vi.fn(), ensureApiKeyForWorkspace: vi.fn(), ensureWorkspace: vi.fn(), - fetchDemoChunkPage: vi.fn(), findSourceInWorkspace: vi.fn(), getCurrentUser: vi.fn(), getSourceParseAssetUrls: vi.fn(), @@ -25,13 +24,6 @@ vi.mock("@/integrations/dashboard/api-key-service", () => ({ ensureApiKeyForWorkspace: mocks.ensureApiKeyForWorkspace, })) -vi.mock("@/integrations/knowhere-demo", () => ({ - knowhereDemoApi: { - fetchCatalog: vi.fn(), - fetchChunkPage: mocks.fetchDemoChunkPage, - }, -})) - vi.mock("@/infrastructure/auth", () => ({ getCurrentUser: mocks.getCurrentUser, requireUser: mocks.requireUser, @@ -74,361 +66,6 @@ describe("GET /api/sources/[sourceId]/chunks", () => { mocks.updateSourceRevisionKey.mockResolvedValue(null) }) - it("serves API-owned demo chunks for anonymous canonical demo sources", async () => { - mocks.getCurrentUser.mockResolvedValue(null) - mocks.fetchDemoChunkPage.mockResolvedValue({ - demoSourceId: "demo-tsla-q4-2025", - canonicalDocumentId: "demo-doc-tsla-q4-2025", - title: "TSLA-Q4-2025-Update.pdf", - mimeType: "application/pdf", - chunks: [ - { - id: "demo-tsla-q4-2025:chunk_1", - chunkId: "chunk_1", - chunkType: "text", - content: "Tesla demo content", - sectionPath: "Summary", - sourceChunkPath: "Summary", - filePath: null, - sortOrder: 0, - metadata: {}, - assetUrl: null, - }, - ], - pagination: { - page: 1, - pageSize: 1, - total: 70, - totalPages: 70, - }, - }) - - const response = await GET( - new NextRequest( - "http://localhost:3001/api/sources/demo-tsla-q4-2025/chunks?page=1&pageSize=1", - ), - { params: Promise.resolve({ sourceId: "demo-tsla-q4-2025" }) }, - ) - - await expect(response.json()).resolves.toMatchObject({ - chunks: [ - { - chunkId: "demo-tsla-q4-2025:chunk_1", - documentId: "demo-doc-tsla-q4-2025", - sourceTitle: "TSLA-Q4-2025-Update.pdf", - }, - ], - pagination: { - page: 1, - pageSize: 1, - total: 70, - }, - }) - expect(response.status).toBe(200) - expect(mocks.fetchDemoChunkPage).toHaveBeenCalledWith({ - demoSourceId: "demo-tsla-q4-2025", - page: 1, - pageSize: 1, - }) - expect(mocks.ensureApiKeyForWorkspace).not.toHaveBeenCalled() - expect(mocks.makeKnowhereClient).not.toHaveBeenCalled() - expect(mocks.getSourceParseAssetUrls).not.toHaveBeenCalled() - }) - - it("loads every API-owned demo chunk page for full anonymous chunk requests", async () => { - mocks.getCurrentUser.mockResolvedValue(null) - mocks.fetchDemoChunkPage - .mockResolvedValueOnce({ - demoSourceId: "demo-tsla-q4-2025", - canonicalDocumentId: "demo-doc-tsla-q4-2025", - title: "TSLA-Q4-2025-Update.pdf", - mimeType: "application/pdf", - chunks: [ - { - id: "demo-tsla-q4-2025:chunk_1", - chunkId: "chunk_1", - chunkType: "text", - content: "First page", - sectionPath: "Summary", - sourceChunkPath: "Summary", - filePath: null, - sortOrder: 0, - metadata: {}, - assetUrl: null, - }, - ], - pagination: { - page: 1, - pageSize: 200, - total: 201, - totalPages: 2, - }, - }) - .mockResolvedValueOnce({ - demoSourceId: "demo-tsla-q4-2025", - canonicalDocumentId: "demo-doc-tsla-q4-2025", - title: "TSLA-Q4-2025-Update.pdf", - mimeType: "application/pdf", - chunks: [ - { - id: "demo-tsla-q4-2025:chunk_201", - chunkId: "chunk_201", - chunkType: "text", - content: "Second page", - sectionPath: "Outlook", - sourceChunkPath: "Outlook", - filePath: null, - sortOrder: 200, - metadata: {}, - assetUrl: null, - }, - ], - pagination: { - page: 2, - pageSize: 200, - total: 201, - totalPages: 2, - }, - }) - - const response = await GET( - new NextRequest( - "http://localhost:3001/api/sources/demo-tsla-q4-2025/chunks", - ), - { params: Promise.resolve({ sourceId: "demo-tsla-q4-2025" }) }, - ) - - await expect(response.json()).resolves.toMatchObject({ - chunks: [ - { chunkId: "demo-tsla-q4-2025:chunk_1" }, - { chunkId: "demo-tsla-q4-2025:chunk_201" }, - ], - }) - expect(response.status).toBe(200) - expect(mocks.fetchDemoChunkPage).toHaveBeenNthCalledWith(1, { - demoSourceId: "demo-tsla-q4-2025", - page: 1, - pageSize: 200, - }) - expect(mocks.fetchDemoChunkPage).toHaveBeenNthCalledWith(2, { - demoSourceId: "demo-tsla-q4-2025", - page: 2, - pageSize: 200, - }) - }) - - it("serves API-owned demo chunks for authenticated canonical demo sources", async () => { - mocks.getCurrentUser.mockResolvedValue({ - id: "knowhere-api-key-dev-user", - email: null, - name: "Knowhere API Key Development", - }) - mocks.ensureWorkspace.mockResolvedValue({ - id: "workspace_1", - userId: "knowhere-api-key-dev-user", - namespace: "notebook-workspace_1", - createdAt: new Date("2026-05-10T00:00:00.000Z"), - }) - mocks.findSourceInWorkspace.mockResolvedValue(null) - mocks.fetchDemoChunkPage.mockResolvedValue({ - demoSourceId: "demo-tsla-q4-2025", - canonicalDocumentId: "demo-doc-tsla-q4-2025", - title: "TSLA-Q4-2025-Update.pdf", - mimeType: "application/pdf", - chunks: [ - { - id: "demo-tsla-q4-2025:chunk_1", - chunkId: "chunk_1", - chunkType: "text", - content: "Tesla demo content", - sectionPath: "Summary", - sourceChunkPath: "Summary", - filePath: null, - sortOrder: 0, - metadata: {}, - assetUrl: null, - }, - ], - pagination: { - page: 1, - pageSize: 100, - total: 70, - totalPages: 1, - }, - }) - - const response = await GET( - new NextRequest( - "http://localhost:3001/api/sources/demo-tsla-q4-2025/chunks?page=1&pageSize=100", - ), - { params: Promise.resolve({ sourceId: "demo-tsla-q4-2025" }) }, - ) - - await expect(response.json()).resolves.toMatchObject({ - chunks: [ - { - chunkId: "demo-tsla-q4-2025:chunk_1", - documentId: "demo-doc-tsla-q4-2025", - sourceTitle: "TSLA-Q4-2025-Update.pdf", - }, - ], - pagination: { - page: 1, - pageSize: 100, - total: 70, - }, - }) - expect(response.status).toBe(200) - expect(mocks.fetchDemoChunkPage).toHaveBeenCalledWith({ - demoSourceId: "demo-tsla-q4-2025", - page: 1, - pageSize: 100, - }) - expect(mocks.findSourceInWorkspace).not.toHaveBeenCalled() - expect(mocks.ensureApiKeyForWorkspace).not.toHaveBeenCalled() - expect(mocks.makeKnowhereClient).not.toHaveBeenCalled() - expect(mocks.getSourceParseAssetUrls).not.toHaveBeenCalled() - }) - - it("serves demo chunks for authenticated materialized demo sources", async () => { - mocks.getCurrentUser.mockResolvedValue({ - id: "user_1", - email: null, - name: null, - }) - mocks.ensureWorkspace.mockResolvedValue({ - id: "workspace_1", - userId: "user_1", - namespace: "notebook-workspace_1", - createdAt: new Date("2026-05-10T00:00:00.000Z"), - }) - mocks.findSourceInWorkspace.mockResolvedValue({ - id: "00000000-0000-0000-0000-000000000001", - workspaceId: "workspace_1", - title: "TSLA-Q4-2025-Update.pdf", - mimeType: "application/pdf", - sizeBytes: 1024, - status: "ready", - failureReason: null, - knowhereJobId: null, - knowhereDocumentId: "copied-doc-tsla-q4-2025", - stagedBlobPathname: null, - stagedBlobUrl: null, - originalBlobPathname: null, - originalBlobUrl: "/api/demo-sources/demo-tsla-q4-2025/original", - demoKey: "demo-tsla-q4-2025", - createdAt: new Date("2026-05-10T00:00:00.000Z"), - updatedAt: new Date("2026-05-10T00:00:00.000Z"), - deletedAt: null, - }) - mocks.fetchDemoChunkPage.mockResolvedValue({ - demoSourceId: "demo-tsla-q4-2025", - canonicalDocumentId: "demo-doc-tsla-q4-2025", - title: "TSLA-Q4-2025-Update.pdf", - mimeType: "application/pdf", - chunks: [ - { - id: "demo-tsla-q4-2025:chunk_1", - chunkId: "chunk_1", - chunkType: "text", - content: "Tesla demo content", - sectionPath: "Summary", - sourceChunkPath: "Summary", - filePath: null, - sortOrder: 0, - metadata: {}, - assetUrl: null, - }, - ], - pagination: { - page: 1, - pageSize: 100, - total: 70, - totalPages: 1, - }, - }) - - const response = await GET( - new NextRequest( - "http://localhost:3001/api/sources/00000000-0000-0000-0000-000000000001/chunks?page=1&pageSize=100", - ), - { params: Promise.resolve({ sourceId: "00000000-0000-0000-0000-000000000001" }) }, - ) - - await expect(response.json()).resolves.toMatchObject({ - chunks: [ - { - chunkId: "demo-tsla-q4-2025:chunk_1", - documentId: "copied-doc-tsla-q4-2025", - sourceTitle: "TSLA-Q4-2025-Update.pdf", - }, - ], - pagination: { - page: 1, - pageSize: 100, - total: 70, - }, - }) - expect(response.status).toBe(200) - expect(mocks.fetchDemoChunkPage).toHaveBeenCalledWith({ - demoSourceId: "demo-tsla-q4-2025", - page: 1, - pageSize: 100, - }) - expect(mocks.ensureApiKeyForWorkspace).not.toHaveBeenCalled() - expect(mocks.makeKnowhereClient).not.toHaveBeenCalled() - expect(mocks.getSourceParseAssetUrls).not.toHaveBeenCalled() - }) - - it("logs the demo chunk load failure before returning 404", async () => { - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined) - try { - mocks.getCurrentUser.mockResolvedValue({ - id: "knowhere-api-key-dev-user", - email: null, - name: "Knowhere API Key Development", - }) - mocks.ensureWorkspace.mockResolvedValue({ - id: "workspace_1", - userId: "knowhere-api-key-dev-user", - namespace: "notebook-workspace_1", - createdAt: new Date("2026-05-10T00:00:00.000Z"), - }) - mocks.findSourceInWorkspace.mockResolvedValue(null) - mocks.fetchDemoChunkPage.mockRejectedValue( - new Error("Knowhere demo API failed: status=404"), - ) - - const response = await GET( - new NextRequest( - "http://localhost:3001/api/sources/demo-tsla-q4-2025/chunks?page=1&pageSize=100", - ), - { params: Promise.resolve({ sourceId: "demo-tsla-q4-2025" }) }, - ) - - expect(response.status).toBe(404) - const line = String(warnSpy.mock.calls[0]?.[0] ?? "") - const log = JSON.parse(line) as { - readonly msg?: unknown - readonly sourceId?: unknown - readonly page?: unknown - readonly pageSize?: unknown - readonly shouldLoadAll?: unknown - readonly error?: unknown - } - expect(log).toMatchObject({ - msg: "sources: demo chunk load failed", - sourceId: "demo-tsla-q4-2025", - page: 1, - pageSize: 100, - shouldLoadAll: false, - error: "Knowhere demo API failed: status=404", - }) - } finally { - warnSpy.mockRestore() - } - }) - it("loads authenticated workspace chunks without probing the demo endpoint first", async () => { const knowhereClient = { documents: { @@ -480,7 +117,6 @@ describe("GET /api/sources/[sourceId]/chunks", () => { stagedBlobUrl: null, originalBlobPathname: null, originalBlobUrl: null, - demoKey: null, createdAt: new Date("2026-05-10T00:00:00.000Z"), updatedAt: new Date("2026-05-10T00:00:00.000Z"), deletedAt: null, @@ -512,7 +148,6 @@ describe("GET /api/sources/[sourceId]/chunks", () => { }, }) expect(response.status).toBe(200) - expect(mocks.fetchDemoChunkPage).not.toHaveBeenCalled() expect(knowhereClient.documents.listChunks).toHaveBeenCalledWith("doc_1", { page: 1, pageSize: 1, @@ -573,7 +208,6 @@ describe("GET /api/sources/[sourceId]/chunks", () => { namespace: "notebook-workspace_1", createdAt: new Date("2026-05-10T00:00:00.000Z"), }) - mocks.fetchDemoChunkPage.mockRejectedValue(new Error("not a demo")) mocks.ensureApiKeyForWorkspace.mockResolvedValue("jwt_123") mocks.makeKnowhereClient.mockReturnValue(knowhereClient) mocks.localizeRemoteDocument.mockResolvedValue({ @@ -590,7 +224,6 @@ describe("GET /api/sources/[sourceId]/chunks", () => { stagedBlobUrl: null, originalBlobPathname: null, originalBlobUrl: null, - demoKey: null, createdAt: new Date("2026-05-10T00:00:00.000Z"), updatedAt: new Date("2026-05-10T00:00:00.000Z"), deletedAt: null, diff --git a/src/app/api/sources/[sourceId]/route.test.ts b/src/app/api/sources/[sourceId]/route.test.ts index 6f82574..2b125b3 100644 --- a/src/app/api/sources/[sourceId]/route.test.ts +++ b/src/app/api/sources/[sourceId]/route.test.ts @@ -8,10 +8,8 @@ const mocks = vi.hoisted(() => { deleteBlob: vi.fn(), ensureApiKeyForWorkspace: vi.fn(), ensureWorkspace: vi.fn(), - fetchDemoCatalog: vi.fn(), findSourceInWorkspace: vi.fn(), getCurrentUser: vi.fn(), - hideDemoSource: vi.fn(), makeKnowhereClient: vi.fn(), requireUser: vi.fn(), retrySourceToKnowhere: vi.fn(), @@ -32,13 +30,6 @@ vi.mock("@/integrations/dashboard/api-key-service", () => ({ ensureApiKeyForWorkspace: mocks.ensureApiKeyForWorkspace, })); -vi.mock("@/integrations/knowhere-demo", () => ({ - knowhereDemoApi: { - fetchCatalog: mocks.fetchDemoCatalog, - fetchChunkPage: vi.fn(), - }, -})) - vi.mock("@/infrastructure/auth", () => ({ getCurrentUser: mocks.getCurrentUser, requireUser: mocks.requireUser, @@ -55,7 +46,6 @@ vi.mock("@/domains/sources/background-reconcile", () => ({ vi.mock("@/domains/sources/service", () => ({ sourceService: { findInWorkspace: mocks.findSourceInWorkspace, - hideDemoSource: mocks.hideDemoSource, retrySourceToKnowhere: mocks.retrySourceToKnowhere, softDelete: mocks.softDeleteSource, }, @@ -120,7 +110,6 @@ describe("PATCH /api/sources/[sourceId]", () => { mocks.requireUser.mockResolvedValue({ id: "user_1" }); mocks.ensureWorkspace.mockResolvedValue({ id: "workspace_1" }); mocks.findSourceInWorkspace.mockResolvedValue(null); - mocks.fetchDemoCatalog.mockResolvedValue({ sources: [] }); const response = await PATCH( new NextRequest( @@ -183,86 +172,6 @@ describe("PATCH /api/sources/[sourceId]", () => { ); }); - it("archives materialized demo sources and records canonical visibility", async () => { - mocks.requireUser.mockResolvedValue({ id: "user_1" }); - mocks.ensureWorkspace.mockResolvedValue({ id: "workspace_1" }); - mocks.findSourceInWorkspace.mockResolvedValue({ - id: "source_demo", - demoKey: "demo-tsla-q4-2025", - knowhereDocumentId: "doc_user_copy", - originalBlobPathname: null, - }); - mocks.ensureApiKeyForWorkspace.mockResolvedValue("jwt_123"); - mocks.makeKnowhereClient.mockReturnValue({ - documents: { archive: mocks.archive }, - }); - mocks.archive.mockResolvedValue(undefined); - mocks.softDeleteSource.mockResolvedValue(true); - mocks.hideDemoSource.mockResolvedValue(undefined); - - const response = await PATCH( - new NextRequest("http://localhost:3001/api/sources/source_demo", { - method: "PATCH", - body: JSON.stringify({ archived: true }), - }), - { params: Promise.resolve({ sourceId: "source_demo" }) }, - ); - - await expect(response.json()).resolves.toEqual({ - id: "source_demo", - archived: true, - }); - expect(response.status).toBe(200); - expect(mocks.ensureApiKeyForWorkspace).toHaveBeenCalledWith( - "workspace_1", - "session=abc", - ); - expect(mocks.archive).toHaveBeenCalledWith("doc_user_copy"); - expect(mocks.deleteBlob).not.toHaveBeenCalled(); - expect(mocks.softDeleteSource).toHaveBeenCalledWith( - "workspace_1", - "source_demo", - ); - expect(mocks.hideDemoSource).toHaveBeenCalledWith( - "workspace_1", - "demo-tsla-q4-2025", - ); - }); - - it("hides a canonical demo source before it has a workspace row", async () => { - mocks.requireUser.mockResolvedValue({ id: "user_1" }); - mocks.ensureWorkspace.mockResolvedValue({ id: "workspace_1" }); - mocks.findSourceInWorkspace.mockResolvedValue(null); - mocks.fetchDemoCatalog.mockResolvedValue({ - sources: [ - { - demoSourceId: "demo-tsla-q4-2025", - }, - ], - }); - mocks.hideDemoSource.mockResolvedValue(undefined); - - const response = await PATCH( - new NextRequest("http://localhost:3001/api/sources/demo-tsla-q4-2025", { - method: "PATCH", - body: JSON.stringify({ archived: true }), - }), - { params: Promise.resolve({ sourceId: "demo-tsla-q4-2025" }) }, - ); - - await expect(response.json()).resolves.toEqual({ - id: "demo-tsla-q4-2025", - archived: true, - }); - expect(response.status).toBe(200); - expect(mocks.hideDemoSource).toHaveBeenCalledWith( - "workspace_1", - "demo-tsla-q4-2025", - ); - expect(mocks.ensureApiKeyForWorkspace).not.toHaveBeenCalled(); - expect(mocks.archive).not.toHaveBeenCalled(); - }); - it("retries a failed source and starts background reconciliation", async () => { mocks.requireUser.mockResolvedValue({ id: "user_1" }); mocks.ensureWorkspace.mockResolvedValue({ id: "workspace_1" }); @@ -281,7 +190,6 @@ describe("PATCH /api/sources/[sourceId]", () => { originalBlobPathname: "source-uploads/upload_1/document.pdf", originalBlobUrl: "https://store.public.blob.vercel-storage.com/source-uploads/upload_1/document.pdf", - demoKey: null, createdAt: new Date("2026-05-10T00:00:00Z"), updatedAt: new Date("2026-05-10T00:00:00Z"), deletedAt: null, @@ -313,7 +221,6 @@ describe("PATCH /api/sources/[sourceId]", () => { originalBlobPathname: "source-uploads/upload_1/document.pdf", originalBlobUrl: "https://store.public.blob.vercel-storage.com/source-uploads/upload_1/document.pdf", - demoKey: null, createdAt: new Date("2026-05-10T00:00:00Z"), updatedAt: new Date("2026-05-10T00:00:00Z"), deletedAt: null, diff --git a/src/app/api/sources/route.test.ts b/src/app/api/sources/route.test.ts index a78639b..6bfdf01 100644 --- a/src/app/api/sources/route.test.ts +++ b/src/app/api/sources/route.test.ts @@ -73,7 +73,6 @@ const source: Source = { stagedBlobUrl: null, originalBlobPathname: null, originalBlobUrl: null, - demoKey: null, createdAt: new Date("2026-05-10T00:00:00Z"), updatedAt: new Date("2026-05-10T00:00:00Z"), deletedAt: null, diff --git a/src/app/page.test.ts b/src/app/page.test.ts index f7e3525..bc019e4 100644 --- a/src/app/page.test.ts +++ b/src/app/page.test.ts @@ -30,8 +30,6 @@ describe("Home", () => { it("renders the workspace shell from the API-backed initial state", async () => { mocks.loadWorkspaceShellInitialState.mockResolvedValue({ - isGuest: true, - loginUrl: "/login", sources: [], chatMessages: [], }) diff --git a/src/components/chat-panel.test.ts b/src/components/chat-panel.test.ts index 54ccce5..6da403b 100644 --- a/src/components/chat-panel.test.ts +++ b/src/components/chat-panel.test.ts @@ -240,7 +240,6 @@ describe("ChatPanel", () => { workspaceId: "workspace_1", workspaceNamespace: "demo", userId: "user_1", - isGuest: false, }, selectedSourcesCount: 2, sourceCount: 4, @@ -262,7 +261,6 @@ describe("ChatPanel", () => { workspaceId: "workspace_1", workspaceNamespace: "demo", userId: "user_1", - isGuest: false, }, threadId: "thread_1", selectedSourcesCount: 2, diff --git a/src/components/namespace-dropdown.tsx b/src/components/namespace-dropdown.tsx new file mode 100644 index 0000000..29ba69a --- /dev/null +++ b/src/components/namespace-dropdown.tsx @@ -0,0 +1,99 @@ +"use client" + +import { type ReactElement, useState } from "react" +import { ChevronDown, Globe } from "lucide-react" +import useSWR from "swr" +import useSWRMutation from "swr/mutation" + +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Spinner } from "@/components/ui/spinner" +import { workspaceClient } from "@/domains/workspace/client" +import type { SourceView } from "@/domains/sources/types" + +export type NamespaceDropdownProps = { + readonly onSourcesLocalized?: (sources: readonly SourceView[]) => void +} + +export function NamespaceDropdown({ + onSourcesLocalized, +}: NamespaceDropdownProps): ReactElement { + const [isOpen, setIsOpen] = useState(false) + const { data: namespaces, isLoading } = useSWR( + workspaceClient.keys.namespaces, + workspaceClient.fetchNamespaces, + { revalidateOnFocus: false }, + ) + const { trigger: localize, isMutating } = useSWRMutation( + "localize-namespace", + (_key: string, { arg }: { readonly arg: string }) => + workspaceClient.localizeNamespace(arg), + ) + + async function handleSelect(namespace: string): Promise { + try { + const sources = await localize(namespace) + onSourcesLocalized?.(sources) + } catch { + // Error is swallowed; the UI stays on the current source list. + } + setIsOpen(false) + } + + return ( + + + + + + + Namespaces + + + {isLoading ? ( + + + Loading… + + ) : namespaces && namespaces.length > 0 ? ( + namespaces.map((ns) => ( + void handleSelect(ns.namespace)} + className="flex items-center justify-between gap-4 text-xs" + > + {ns.namespace} + + {ns.documentCount} {ns.documentCount === 1 ? "doc" : "docs"} + + + )) + ) : ( + + No namespaces available + + )} + + + ) +} diff --git a/src/components/official-library-panel.test.ts b/src/components/official-library-panel.test.ts deleted file mode 100644 index 16d2062..0000000 --- a/src/components/official-library-panel.test.ts +++ /dev/null @@ -1,259 +0,0 @@ -// @vitest-environment jsdom -import React from "react"; -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -import { OfficialLibraryPanel } from "./official-library-panel"; - -describe("OfficialLibraryPanel", () => { - afterEach(() => { - cleanup(); - vi.clearAllMocks(); - }); - - it("uses the dashboard PDF icon for file cards", () => { - const { container } = render( - React.createElement(OfficialLibraryPanel, { - officialLibrarySources: [ - { - librarySourceId: "financial-spacex-s1", - categoryId: "financial-reports", - categoryLabel: "Financial Reports", - title: "spacex-s1.pdf", - sourceUrl: "https://example.com/spacex-s1.pdf", - mimeType: "application/pdf", - status: "ready", - demoSourceId: "demo-spacex-s1", - chunkCount: 922, - }, - ], - onOfficialLibrarySourceAdd: vi.fn(), - }), - ); - - fireEvent.click( - screen.getByRole("button", { name: "Open Financial Reports" }), - ); - - const pdfIcon = container.querySelector( - '[data-testid="official-library-pdf-icon"] img', - ); - - expect(pdfIcon?.getAttribute("src")).toBe( - "/icons/official-library/pdf-document.svg", - ); - expect(screen.getByText("spacex-s1.pdf")).toBeTruthy(); - }); - - it("renders the header back button", () => { - const onBack = vi.fn(); - const { container } = render( - React.createElement(OfficialLibraryPanel, { - officialLibrarySources: [ - { - librarySourceId: "financial-spacex-s1", - categoryId: "financial-reports", - categoryLabel: "Financial Reports", - title: "spacex-s1.pdf", - sourceUrl: "https://example.com/spacex-s1.pdf", - mimeType: "application/pdf", - status: "ready", - demoSourceId: "demo-spacex-s1", - chunkCount: 922, - }, - ], - onBack, - }), - ); - - const backButton = screen.getByRole("button", { name: "Back to sources" }); - const backIcon = container.querySelector( - '[data-testid="official-library-back-icon"]', - ); - - expect(backIcon?.className.baseVal).toContain("lucide-rotate-ccw"); - fireEvent.click(backButton); - expect(onBack).toHaveBeenCalledOnce(); - }); - - it("opens library documents as browser PDF previews", () => { - render( - React.createElement(OfficialLibraryPanel, { - officialLibrarySources: [ - { - librarySourceId: "financial-spacex-s1", - categoryId: "financial-reports", - categoryLabel: "Financial Reports", - title: "spacex-s1.pdf", - sourceUrl: "https://example.com/spacex-s1.pdf", - mimeType: "application/pdf", - status: "ready", - demoSourceId: "demo-spacex-s1", - chunkCount: 922, - }, - ], - }), - ); - - fireEvent.click( - screen.getByRole("button", { name: "Open Financial Reports" }), - ); - - const previewLink = screen.getByRole("link", { - name: "Open spacex-s1.pdf PDF preview", - }); - - expect(previewLink.getAttribute("href")).toBe( - "https://example.com/spacex-s1.pdf", - ); - expect(previewLink.getAttribute("target")).toBe("_blank"); - expect(previewLink.getAttribute("rel")).toBe("noopener noreferrer"); - }); - - it("keeps file add actions visible on mobile", () => { - render( - React.createElement(OfficialLibraryPanel, { - officialLibrarySources: [ - { - librarySourceId: "financial-spacex-s1", - categoryId: "financial-reports", - categoryLabel: "Financial Reports", - title: "spacex-s1.pdf", - sourceUrl: "https://example.com/spacex-s1.pdf", - mimeType: "application/pdf", - status: "ready", - demoSourceId: "demo-spacex-s1", - chunkCount: 922, - }, - ], - onOfficialLibrarySourceAdd: vi.fn(), - }), - ); - - fireEvent.click( - screen.getByRole("button", { name: "Open Financial Reports" }), - ); - - const addButton = screen.getByRole("button", { - name: "Add spacex-s1.pdf to sources", - }); - - expect(addButton.className).toContain("opacity-100"); - expect(addButton.className).toContain("min-[1116px]:opacity-0"); - }); - - it("marks already added library documents and removes duplicate add actions", () => { - const onOfficialLibrarySourceAdd = vi.fn(); - - render( - React.createElement(OfficialLibraryPanel, { - sources: [ - { - id: "source_spacex", - kind: "workspace", - demoSourceId: "demo-spacex-s1", - title: "spacex-s1.pdf", - status: "ready", - mimeType: "application/pdf", - documentId: "doc_user_copy", - }, - ], - officialLibrarySources: [ - { - librarySourceId: "financial-spacex-s1", - categoryId: "financial-reports", - categoryLabel: "Financial Reports", - title: "spacex-s1.pdf", - sourceUrl: "https://example.com/spacex-s1.pdf", - mimeType: "application/pdf", - status: "ready", - demoSourceId: "demo-spacex-s1", - chunkCount: 922, - }, - ], - onOfficialLibrarySourceAdd, - }), - ); - - fireEvent.click( - screen.getByRole("button", { name: "Open Financial Reports" }), - ); - - expect(screen.getByLabelText("spacex-s1.pdf already added")).toBeTruthy(); - expect(screen.getByText("Added")).toBeTruthy(); - expect( - screen.queryByRole("button", { name: "Add spacex-s1.pdf to sources" }), - ).toBeNull(); - expect(onOfficialLibrarySourceAdd).not.toHaveBeenCalled(); - }); - - it("opens to the all-categories view", () => { - render( - React.createElement(OfficialLibraryPanel, { - officialLibrarySources: [ - { - librarySourceId: "financial-spacex-s1", - categoryId: "financial-reports", - categoryLabel: "Financial Reports", - title: "spacex-s1.pdf", - sourceUrl: "https://example.com/spacex-s1.pdf", - mimeType: "application/pdf", - status: "ready", - demoSourceId: "demo-spacex-s1", - chunkCount: 922, - }, - { - librarySourceId: "research-transformers", - categoryId: "research-papers", - categoryLabel: "Research Papers", - title: "transformers.pdf", - sourceUrl: "https://example.com/transformers.pdf", - mimeType: "application/pdf", - status: "planned", - }, - { - librarySourceId: "stem-calculus", - categoryId: "stem-books", - categoryLabel: "STEM Books", - title: "calculus.pdf", - sourceUrl: "https://example.com/calculus.pdf", - mimeType: "application/pdf", - status: "ready", - demoSourceId: "demo-calculus", - }, - { - librarySourceId: "other-contract", - categoryId: "other-docs", - categoryLabel: "Other Docs", - title: "contract.pdf", - sourceUrl: "https://example.com/contract.pdf", - mimeType: "application/pdf", - status: "ready", - demoSourceId: "demo-contract", - }, - ], - }), - ); - - expect( - screen - .getByRole("button", { name: "Open Financial Reports" }) - .getAttribute("style"), - ).toContain("/images/official-library/financial-reports.svg"); - expect( - screen - .getByRole("button", { name: "Open Research Papers" }) - .getAttribute("style"), - ).toContain("/images/official-library/research-papers.svg"); - expect( - screen - .getByRole("button", { name: "Open STEM Books" }) - .getAttribute("style"), - ).toContain("/images/official-library/stem-books.svg"); - expect( - screen - .getByRole("button", { name: "Open Other Docs" }) - .getAttribute("style"), - ).toContain("/images/official-library/other-docs.svg"); - }); -}); diff --git a/src/components/official-library-panel.tsx b/src/components/official-library-panel.tsx deleted file mode 100644 index 86dee08..0000000 --- a/src/components/official-library-panel.tsx +++ /dev/null @@ -1,455 +0,0 @@ -"use client"; - -import { type CSSProperties, type ReactElement, useMemo, useState } from "react"; -import { Check, ChevronRight, FileText, Plus, RotateCcw } from "lucide-react"; -import Image from "next/image"; - -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Spinner } from "@/components/ui/spinner"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import type { - OfficialLibrarySourceView, - SourceView, -} from "@/domains/sources/types"; - -type OfficialLibraryPanelProps = { - readonly addingLibrarySourceIds?: readonly string[]; - readonly officialLibrarySources?: readonly OfficialLibrarySourceView[]; - readonly sources?: readonly SourceView[]; - readonly onBack?: () => void; - readonly onOfficialLibrarySourceAdd?: (demoSourceId: string) => void; -}; - -type LibraryItem = { - readonly categoryId: string; - readonly categoryLabel: string; - readonly chunkCount?: number; - readonly demoSourceId?: string; - readonly librarySourceId: string; - readonly mimeType: string; - readonly isAdded: boolean; - readonly sourceUrl: string; - readonly status: "ready" | "planned"; - readonly title: string; -}; - -type LibraryCategory = { - readonly backgroundImagePath: string; - readonly categoryId: string; - readonly categoryLabel: string; - readonly itemCount: number; - readonly readyCount: number; -}; - -const officialLibraryAssetPaths = { - categoryBackgrounds: { - financialReports: "/images/official-library/financial-reports.svg", - otherDocs: "/images/official-library/other-docs.svg", - researchPapers: "/images/official-library/research-papers.svg", - stemBooks: "/images/official-library/stem-books.svg", - }, - pdfDocumentIcon: "/icons/official-library/pdf-document.svg", -} as const; - -export function OfficialLibraryPanel({ - addingLibrarySourceIds = [], - officialLibrarySources = [], - sources = [], - onBack, - onOfficialLibrarySourceAdd, -}: OfficialLibraryPanelProps): ReactElement { - const libraryItems = useMemo( - () => getLibraryItems(sources, officialLibrarySources), - [officialLibrarySources, sources], - ); - const categories = useMemo( - () => getLibraryCategories(libraryItems), - [libraryItems], - ); - const [selectedCategoryId, setSelectedCategoryId] = useState( - null, - ); - const resolvedCategoryId = - selectedCategoryId !== null && - categories.some((category) => category.categoryId === selectedCategoryId) - ? selectedCategoryId - : null; - const selectedCategory = categories.find( - (category) => category.categoryId === resolvedCategoryId, - ); - const visibleItems = resolvedCategoryId - ? libraryItems.filter((item) => item.categoryId === resolvedCategoryId) - : libraryItems; - const addingLibrarySourceIdSet = new Set(addingLibrarySourceIds); - - return ( -
-
- -

- Library -

-
- - -
-
- - {selectedCategory ? ( - <> - - - {selectedCategory.categoryLabel} - - - ) : null} -
- - {libraryItems.length === 0 ? ( - - ) : resolvedCategoryId === null ? ( - - ) : ( -
- {visibleItems.map((item) => ( - onOfficialLibrarySourceAdd(item.demoSourceId!) - : undefined - } - /> - ))} -
- )} -
-
-
- ); -} - -function OfficialLibraryCategoryGrid({ - categories, - onCategorySelect, -}: { - readonly categories: readonly LibraryCategory[]; - readonly onCategorySelect: (categoryId: string) => void; -}): ReactElement { - return ( -
- {categories.map((category) => ( - - ))} -
- ); -} - -function OfficialLibraryCard({ - isAdding, - item, - onAdd, -}: { - readonly isAdding: boolean; - readonly item: LibraryItem; - readonly onAdd?: () => void; -}): ReactElement { - const canAdd = item.status === "ready" && Boolean(onAdd) && !item.isAdded; - - return ( - - ); -} - -function PdfFileIcon(): ReactElement { - return ( -
- -
- ); -} - -function EmptyLibraryState(): ReactElement { - return ( -
-
- -
-

- No library files yet. -

-
- ); -} - -function getLibraryItems( - sources: readonly SourceView[], - officialLibrarySources: readonly OfficialLibrarySourceView[], -): LibraryItem[] { - const addedDemoSourceIdSet = new Set( - sources - .filter((source) => source.kind !== "demo") - .flatMap((source) => (source.demoSourceId ? [source.demoSourceId] : [])), - ); - const metadataByLibrarySourceId = new Map( - officialLibrarySources.map((source) => [source.librarySourceId, source]), - ); - const itemByLibrarySourceId = new Map(); - - for (const source of officialLibrarySources) { - itemByLibrarySourceId.set(source.librarySourceId, { - categoryId: source.categoryId, - categoryLabel: source.categoryLabel, - chunkCount: source.chunkCount, - demoSourceId: source.demoSourceId, - isAdded: - source.demoSourceId !== undefined && - addedDemoSourceIdSet.has(source.demoSourceId), - librarySourceId: source.librarySourceId, - mimeType: source.mimeType, - sourceUrl: source.sourceUrl, - status: source.status, - title: source.title, - }); - } - - for (const source of sources) { - if (!source.officialLibrary) continue; - - const metadata = metadataByLibrarySourceId.get( - source.officialLibrary.librarySourceId, - ); - itemByLibrarySourceId.set(source.officialLibrary.librarySourceId, { - categoryId: source.officialLibrary.categoryId, - categoryLabel: - metadata?.categoryLabel ?? - getCategoryLabel(source.officialLibrary.categoryId), - chunkCount: source.chunkCount ?? metadata?.chunkCount, - demoSourceId: source.demoSourceId ?? metadata?.demoSourceId, - isAdded: - (source.demoSourceId !== undefined && - addedDemoSourceIdSet.has(source.demoSourceId)) || - (metadata?.demoSourceId !== undefined && - addedDemoSourceIdSet.has(metadata.demoSourceId)), - librarySourceId: source.officialLibrary.librarySourceId, - mimeType: source.mimeType, - sourceUrl: source.officialLibrary.sourceUrl, - status: "ready", - title: source.title, - }); - } - - return Array.from(itemByLibrarySourceId.values()).sort((left, right) => { - if (left.categoryLabel !== right.categoryLabel) { - return left.categoryLabel.localeCompare(right.categoryLabel); - } - if (left.status !== right.status) return left.status === "ready" ? -1 : 1; - return left.title.localeCompare(right.title); - }); -} - -function getLibraryCategories( - items: readonly LibraryItem[], -): readonly LibraryCategory[] { - const categoryById = new Map< - string, - { - readonly categoryLabel: string; - itemCount: number; - readyCount: number; - } - >(); - for (const item of items) { - const currentCategory = categoryById.get(item.categoryId); - if (currentCategory) { - currentCategory.itemCount += 1; - if (item.status === "ready") currentCategory.readyCount += 1; - continue; - } - - categoryById.set(item.categoryId, { - categoryLabel: item.categoryLabel, - itemCount: 1, - readyCount: item.status === "ready" ? 1 : 0, - }); - } - - return Array.from(categoryById.entries()) - .map(([categoryId, category]) => ({ - backgroundImagePath: getCategoryBackgroundImagePath(categoryId), - categoryId, - categoryLabel: category.categoryLabel, - itemCount: category.itemCount, - readyCount: category.readyCount, - })) - .sort((left, right) => { - const orderDiff = - getCategorySortOrder(left.categoryId) - - getCategorySortOrder(right.categoryId); - if (orderDiff !== 0) return orderDiff; - - return left.categoryLabel.localeCompare(right.categoryLabel); - }); -} - -function getCategoryLabel(categoryId: string): string { - return categoryId - .split(/[-_]+/u) - .filter(Boolean) - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(" "); -} - -function getLibraryMetadata(item: LibraryItem): string { - if (item.status !== "ready") return "Preparing"; - if (item.chunkCount !== undefined) return `${item.chunkCount} chunks`; - - return item.mimeType.includes("pdf") ? "PDF" : item.mimeType; -} - -function getCategoryBackgroundImagePath(categoryId: string): string { - const normalizedCategoryId = categoryId.toLowerCase(); - if (normalizedCategoryId.includes("financial")) { - return officialLibraryAssetPaths.categoryBackgrounds.financialReports; - } - if (normalizedCategoryId.includes("research")) { - return officialLibraryAssetPaths.categoryBackgrounds.researchPapers; - } - if (normalizedCategoryId.includes("stem")) { - return officialLibraryAssetPaths.categoryBackgrounds.stemBooks; - } - - return officialLibraryAssetPaths.categoryBackgrounds.otherDocs; -} - -function getCategoryCardBackgroundStyle( - backgroundImagePath: string, -): CSSProperties { - return { - backgroundImage: - `linear-gradient(180deg, rgba(10, 10, 12, 0.08) 0%, rgba(10, 10, 12, 0.76) 100%), url("${backgroundImagePath}")`, - }; -} - -function getCategorySortOrder(categoryId: string): number { - const normalizedCategoryId = categoryId.toLowerCase(); - if (normalizedCategoryId.includes("financial")) return 0; - if (normalizedCategoryId.includes("research")) return 1; - if (normalizedCategoryId.includes("stem")) return 2; - return 3; -} - -function getCategoryStatusLabel(category: LibraryCategory): string { - if (category.readyCount === category.itemCount) { - return `${category.itemCount} ready`; - } - - return `${category.readyCount}/${category.itemCount} ready`; -} diff --git a/src/components/source-row.tsx b/src/components/source-row.tsx index 09d579c..42727dc 100644 --- a/src/components/source-row.tsx +++ b/src/components/source-row.tsx @@ -1,7 +1,7 @@ "use client"; import type { ReactElement } from "react"; -import { FileText, ListTree, Plus, RotateCcw, Trash2 } from "lucide-react"; +import { FileText, ListTree, RotateCcw, Trash2 } from "lucide-react"; import { Checkbox } from "@/components/ui/checkbox"; import { Spinner } from "@/components/ui/spinner"; @@ -10,11 +10,9 @@ import type { SourceView } from "@/domains/sources/types"; export type SourceRowProps = { readonly onTreeClick?: () => void; readonly isArchiving: boolean; - readonly isAdding?: boolean; readonly isNarrow?: boolean; readonly isRetrying?: boolean; readonly isSelected: boolean; - readonly onAddClick?: (sourceId: string) => void; readonly onArchiveClick?: (sourceId: string) => void; readonly onRetryClick?: (sourceId: string) => void; readonly onSelect: () => void; @@ -25,9 +23,7 @@ export type SourceRowProps = { export function SourceRow({ source, isSelected, - isAdding = false, isNarrow = false, - onAddClick, onSelect, onToggleIncluded, onArchiveClick, @@ -40,7 +36,6 @@ export function SourceRow({ const isBusy = source.status === "uploading" || source.status === "parsing"; const isFailed = source.status === "failed"; const canRetry = isFailed && source.originalFile !== undefined; - const isLibrarySource = source.officialLibrary !== undefined; const isRemoteSource = source.kind === "remote"; const iconBg = fileIconTint(source.title); @@ -62,7 +57,7 @@ export function SourceRow({ > onToggleIncluded?.(source.id, checked === true) } @@ -127,26 +122,6 @@ export function SourceRow({ ) : null} - {isLibrarySource && onAddClick && ( - - )} {canRetry && onRetryClick ? ( - ) : isNarrow ? ( + {isNarrow ? ( Sources - {hasLibrarySources && !isNarrow ? ( - + {!isNarrow && onSourcesLocalized ? ( + ) : null}
diff --git a/src/components/workspace-chat-workflow.test.ts b/src/components/workspace-chat-workflow.test.ts index f6b36a3..ab462ca 100644 --- a/src/components/workspace-chat-workflow.test.ts +++ b/src/components/workspace-chat-workflow.test.ts @@ -4,7 +4,7 @@ import { createElement, type ReactNode } from "react" import { SWRConfig } from "swr" import { beforeEach, describe, expect, it, vi } from "vitest" -import type { ChatThreadView } from "@/domains/chat/types" +import type { ChatMessageView, ChatThreadView } from "@/domains/chat/types" import type { SourceView } from "@/domains/sources/types" const mocks = vi.hoisted(() => ({ @@ -12,7 +12,6 @@ const mocks = vi.hoisted(() => ({ createChatThread: vi.fn(), fetchChatThread: vi.fn(), fetchChatThreads: vi.fn(), - materializeDemoSources: vi.fn(), sendChatMessage: vi.fn(), })) @@ -27,7 +26,6 @@ vi.mock("@/domains/workspace/client", () => ({ createChatThread: mocks.createChatThread, fetchChatThread: mocks.fetchChatThread, fetchChatThreads: mocks.fetchChatThreads, - materializeDemoSources: mocks.materializeDemoSources, sendChatMessage: mocks.sendChatMessage, }, })) @@ -108,90 +106,12 @@ describe("useWorkspaceChatWorkflow", () => { }, ]) }) - - it("shows a retryable error without sending chat when demo materialization fails", async () => { - const demoSource = makeSource({ - id: "demo-tsla-q4-2025", - kind: "demo", - demoSourceId: "demo-tsla-q4-2025", - }) - const onSourcesMaterialized = vi.fn() - mocks.fetchChatThreads.mockResolvedValue([]) - mocks.materializeDemoSources.mockRejectedValue(new Error("Bad gateway")) - - const { result } = renderWorkspaceChatWorkflow({ - initialChatThreads: [], - initialChatMessages: [], - onSourcesMaterialized, - sources: [demoSource], - }) - - await act(async () => { - await result.current.handleChatSend("What changed in Q4?") - }) - - expect(mocks.materializeDemoSources).toHaveBeenCalledWith({ - demoSourceIds: ["demo-tsla-q4-2025"], - }) - expect(mocks.sendChatMessage).not.toHaveBeenCalled() - expect(onSourcesMaterialized).not.toHaveBeenCalled() - expect(result.current.chat.messages).toEqual([]) - expect(result.current.chat.error).toBe( - "Demo sources could not be prepared right now.", - ) - expect(result.current.chat.isSending).toBe(false) - }) - - it("blocks chat until Official Library demo sources are explicitly added", async () => { - const librarySource = makeSource({ - id: "demo-spacex-s1", - kind: "demo", - demoSourceId: "demo-spacex-s1", - officialLibrary: { - librarySourceId: "financial-spacex-s1", - categoryId: "financial-reports", - sourceUrl: "https://example.com/spacex-s1.pdf", - }, - }) - mocks.fetchChatThreads.mockResolvedValue([]) - mocks.sendChatMessage.mockResolvedValue({ - threadId: "thread_1", - messages: [ - { - id: "message_assistant", - role: "assistant", - content: "Answer", - }, - ], - }) - - const { result } = renderWorkspaceChatWorkflow({ - initialChatThreads: [], - initialChatMessages: [], - sources: [librarySource], - }) - - await act(async () => { - await result.current.handleChatSend("Summarize it") - }) - - expect(mocks.materializeDemoSources).not.toHaveBeenCalled() - expect(mocks.sendChatMessage).not.toHaveBeenCalled() - expect(result.current.chat.error).toBe( - "Add a ready source before asking questions.", - ) - }) }) function renderWorkspaceChatWorkflow(input: { readonly activeChatThreadId?: string | null - readonly initialChatMessages: readonly [] + readonly initialChatMessages: readonly ChatMessageView[] readonly initialChatThreads: readonly ChatThreadView[] - readonly isGuest?: boolean - readonly onSourcesMaterialized?: ( - demoSourceIds: readonly string[], - materializedSources: readonly SourceView[], - ) => void readonly sources: readonly SourceView[] }) { return renderHook(() => useWorkspaceChatWorkflow(input), { diff --git a/src/components/workspace-chat-workflow.ts b/src/components/workspace-chat-workflow.ts index 2943a23..2d7e7c7 100644 --- a/src/components/workspace-chat-workflow.ts +++ b/src/components/workspace-chat-workflow.ts @@ -28,11 +28,6 @@ type WorkspaceChatWorkflowInput = { readonly analyticsContext?: AnalyticsContext readonly initialChatMessages?: readonly ChatMessageView[] readonly initialChatThreads?: readonly ChatThreadView[] - readonly isGuest?: boolean - readonly onSourcesMaterialized?: ( - demoSourceIds: readonly string[], - materializedSources: readonly SourceView[], - ) => void readonly sources: readonly SourceView[] } @@ -60,8 +55,6 @@ export function useWorkspaceChatWorkflow({ analyticsContext, initialChatMessages = [], initialChatThreads = [], - isGuest = false, - onSourcesMaterialized, sources, }: WorkspaceChatWorkflowInput): WorkspaceChatWorkflow { const [loadingThreadId, setLoadingThreadId] = useState(null) @@ -80,7 +73,7 @@ export function useWorkspaceChatWorkflow({ [initialChatThreads], ) const { data: serverChatThreads, mutate: mutateChatThreads } = useSWR( - isGuest ? null : chatThreadsSWRKey, + chatThreadsSWRKey, workspaceClient.fetchChatThreads, { fallbackData: initialThreadRows, @@ -243,7 +236,7 @@ export function useWorkspaceChatWorkflow({ return { ...current, messages: [...messages] } }) } catch { - // Materialization can still succeed even if the current thread refresh fails. + // Refresh failed; keep the current state. } } @@ -253,27 +246,6 @@ export function useWorkspaceChatWorkflow({ (source) => isQueryableReadySource(source) && !source.excludedFromQuery, ).length - const demoSourceIds = getMaterializableDemoSourceIds(sources) - if (demoSourceIds.length > 0) { - setChat((current) => - workspaceChatState.prepareSend(current, "Thinking"), - ) - try { - const materializedSources = - await workspaceClient.materializeDemoSources({ demoSourceIds }) - onSourcesMaterialized?.(demoSourceIds, materializedSources) - await handleRefreshActiveChatThread() - } catch { - setChat((current) => ({ - ...current, - isSending: false, - isLoading: false, - pendingStatusText: null, - error: "Demo sources could not be prepared right now.", - })) - return - } - } if (!hasQueryableReadySource(sources)) { setChat((current) => ({ ...current, @@ -384,32 +356,12 @@ export function useWorkspaceChatWorkflow({ } } -function getMaterializableDemoSourceIds( - sources: readonly SourceView[], -): string[] { - const demoSourceIds = sources - .filter((source) => source.kind === "demo") - .filter((source) => source.officialLibrary === undefined) - .filter((source) => !source.excludedFromQuery) - .map((source) => source.demoSourceId ?? source.id) - - return Array.from(new Set(demoSourceIds)) -} - function hasQueryableReadySource(sources: readonly SourceView[]): boolean { return sources.some(isQueryableReadySource) } function isQueryableReadySource(source: SourceView): boolean { - return ( - source.status === "ready" && - !isUnmaterializedOfficialLibrarySource(source) && - source.kind !== "remote" - ) -} - -function isUnmaterializedOfficialLibrarySource(source: SourceView): boolean { - return source.kind === "demo" && source.officialLibrary !== undefined + return source.status === "ready" && source.kind !== "remote" } function fetchChatThreadByKey([ diff --git a/src/components/workspace-shell-layout.test.ts b/src/components/workspace-shell-layout.test.ts index 80d4a74..06a2a31 100644 --- a/src/components/workspace-shell-layout.test.ts +++ b/src/components/workspace-shell-layout.test.ts @@ -31,7 +31,6 @@ describe("WorkspaceShellLayout", () => { hasMessages: false, hasMoreSelectedChunks: false, isCreatingThread: false, - isGuest: false, isSelectedAllChunksLoading: false, isSelectedChunksLoading: false, isSelectedChunksLoadingMore: false, @@ -61,7 +60,6 @@ describe("WorkspaceShellLayout", () => { onDesktopPanelResizeStart: vi.fn(), onLoadAllChunks: vi.fn(), onLoadMoreChunks: vi.fn(), - onLoginClick: vi.fn(), onMobilePanelChange: vi.fn(), onSelectChatThread: vi.fn(), onSourceSelected: vi.fn(), @@ -113,7 +111,6 @@ describe("WorkspaceShellLayout", () => { hasMessages: false, hasMoreSelectedChunks: false, isCreatingThread: false, - isGuest: false, isSelectedAllChunksLoading: false, isSelectedChunksLoading: false, isSelectedChunksLoadingMore: false, @@ -156,7 +153,6 @@ describe("WorkspaceShellLayout", () => { onDesktopPanelResizeStart: vi.fn(), onLoadAllChunks: vi.fn(), onLoadMoreChunks: vi.fn(), - onLoginClick: vi.fn(), onMobilePanelChange: vi.fn(), onSelectChatThread: handleChatThreadSelected, onSourceSelected: handleSourceSelected, @@ -207,7 +203,6 @@ describe("WorkspaceShellLayout", () => { hasMessages: false, hasMoreSelectedChunks: false, isCreatingThread: false, - isGuest: false, isSelectedAllChunksLoading: false, isSelectedChunksLoading: false, isSelectedChunksLoadingMore: false, @@ -250,7 +245,6 @@ describe("WorkspaceShellLayout", () => { onDesktopPanelResizeStart: vi.fn(), onLoadAllChunks: vi.fn(), onLoadMoreChunks: vi.fn(), - onLoginClick: vi.fn(), onMobilePanelChange: vi.fn(), onSelectChatThread: vi.fn(), onSourceSelected: vi.fn(), diff --git a/src/components/workspace-shell-layout.tsx b/src/components/workspace-shell-layout.tsx index e630fb2..708dac7 100644 --- a/src/components/workspace-shell-layout.tsx +++ b/src/components/workspace-shell-layout.tsx @@ -10,7 +10,6 @@ import { import { ChatPanel } from "@/components/chat-panel" import { ChunksPanel } from "@/components/chunks-panel" import { MobileTabBar } from "@/components/mobile-tab-bar" -import { OfficialLibraryPanel } from "@/components/official-library-panel" import { SourcesPanel } from "@/components/sources-panel" import { TopNav } from "@/components/top-nav" import type { AnalyticsContext } from "@/lib/posthog" @@ -23,13 +22,11 @@ import type { } from "@/domains/chat/types" import type { ParsedChunkView } from "@/domains/chunks/types" import type { - OfficialLibrarySourceView, SourceOriginalFileView, SourceView, } from "@/domains/sources/types" export type PanelId = "sources" | "chat" -export type ContentView = "chunks" | "library" type DesktopPanelKey = keyof typeof workspaceShellState.minimumDesktopPanelWidths type DesktopSidePanelKey = Exclude @@ -56,7 +53,6 @@ type WorkspaceChatState = { } export type WorkspaceShellLayoutProps = { - readonly addingLibrarySourceIds: readonly string[] readonly archivingSourceIds: readonly string[] readonly retryingSourceIds?: readonly string[] readonly archivingThreadIds: readonly string[] @@ -68,10 +64,8 @@ export type WorkspaceShellLayoutProps = { readonly focusedChunk: FocusedChunkState readonly hasMessages: boolean readonly hasMoreSelectedChunks: boolean - readonly contentView: ContentView readonly isChunksOverlayVisible: boolean readonly isCreatingThread: boolean - readonly isGuest: boolean readonly isSelectedAllChunksLoading: boolean readonly isSelectedChunksLoading: boolean readonly isSelectedChunksLoadingMore: boolean @@ -86,7 +80,6 @@ export type WorkspaceShellLayoutProps = { readonly selectedSourceTitle: string | null readonly sourceTitlesByDocumentId: Readonly> readonly sources: readonly SourceView[] - readonly officialLibrarySources: readonly OfficialLibrarySourceView[] readonly user: WorkspaceShellUser | undefined readonly analyticsContext?: AnalyticsContext readonly onArchiveChatThread: (threadId: string) => void | Promise @@ -117,17 +110,12 @@ export type WorkspaceShellLayoutProps = { ) => void readonly onLoadAllChunks: () => void readonly onLoadMoreChunks: () => void - readonly onLoginClick: () => void - readonly onLibraryBack: () => void - readonly onLibraryOpen: () => void readonly onMobilePanelChange: (panel: PanelId) => void readonly onOpenChunksOverlay: (sourceId?: string) => void - readonly onOfficialLibrarySourceAdd: ( - demoSourceId: string, - ) => void | Promise readonly onSelectChatThread: (threadId: string) => void readonly onSourceSelected: (sourceId: string | null) => void readonly onSourceUploaded: (source: SourceView) => void + readonly onSourcesLocalized?: (sources: readonly SourceView[]) => void readonly onToggleIncluded: (sourceId: string, included: boolean) => void } @@ -135,9 +123,7 @@ export function WorkspaceShellLayout( props: WorkspaceShellLayoutProps, ): ReactElement { const { onDesktopLayoutElementChange } = props - const addingLibrarySourceIds = props.addingLibrarySourceIds ?? [] const retryingSourceIds = props.retryingSourceIds ?? [] - const officialLibrarySources = props.officialLibrarySources ?? [] const selectedSourcesCount = props.sources.filter( (source) => !source.excludedFromQuery && source.status === "ready", ).length @@ -202,34 +188,19 @@ export function WorkspaceShellLayout( ) : ( )} @@ -269,7 +240,7 @@ export function WorkspaceShellLayout( messages={props.chat.messages} threads={[...props.chatThreads]} activeThreadId={props.chat.threadId} - isDisabled={props.isGuest || props.readySourceCount === 0} + isDisabled={props.readySourceCount === 0} isSending={props.chat.isSending} isHistoryLoading={props.chat.isLoading} isCreatingThread={props.isCreatingThread} @@ -281,15 +252,10 @@ export function WorkspaceShellLayout( analyticsContext={props.analyticsContext} selectedSourcesCount={selectedSourcesCount} onSend={props.onChatSend} - onNewChat={props.isGuest ? undefined : props.onCreateChatThread} - onThreadSelect={ - props.isGuest ? undefined : props.onSelectChatThread - } - onThreadArchive={ - props.isGuest ? undefined : props.onArchiveChatThread - } + onNewChat={props.onCreateChatThread} + onThreadSelect={props.onSelectChatThread} + onThreadArchive={props.onArchiveChatThread} onCitationClick={props.onCitationClick} - onLoginClick={props.isGuest ? props.onLoginClick : undefined} sourceTitlesByDocumentId={props.sourceTitlesByDocumentId} /> )} @@ -309,23 +275,16 @@ export function WorkspaceShellLayout( sources={[...props.sources]} analyticsContext={props.analyticsContext} sourceCountSnapshot={props.sources.length} - officialLibrarySources={[...officialLibrarySources]} - isLibraryOpen={props.contentView === "library"} - onSourceUploaded={props.isGuest ? undefined : props.onSourceUploaded} + onSourceUploaded={props.onSourceUploaded} + onSourcesLocalized={props.onSourcesLocalized} selectedSourceId={props.selectedSourceId} onSelectSource={props.onSourceSelected} - onToggleIncluded={props.isGuest ? undefined : props.onToggleIncluded} - onArchiveSource={props.isGuest ? undefined : props.onArchiveSource} - onRetrySource={props.isGuest ? undefined : props.onRetrySource} - onOfficialLibrarySourceAdd={ - props.isGuest ? undefined : props.onOfficialLibrarySourceAdd - } - onLibraryOpen={props.onLibraryOpen} + onToggleIncluded={props.onToggleIncluded} + onArchiveSource={props.onArchiveSource} + onRetrySource={props.onRetrySource} onOpenChunksOverlay={props.onOpenChunksOverlay} archivingSourceIds={[...props.archivingSourceIds]} retryingSourceIds={[...retryingSourceIds]} - addingLibrarySourceIds={[...addingLibrarySourceIds]} - onLoginClick={props.isGuest ? props.onLoginClick : undefined} />
@@ -385,30 +343,13 @@ export function WorkspaceShellLayout( onLoadAllChunks={props.onLoadAllChunks} onLoadMore={props.onLoadMoreChunks} onClose={props.onCloseChunksOverlay} - onLoginClick={props.isGuest ? props.onLoginClick : undefined} - onSourceUploaded={ - props.isGuest ? undefined : props.onSourceUploaded - } + onSourceUploaded={props.onSourceUploaded} analyticsContext={props.analyticsContext} sourceCountSnapshot={props.sources.length} />
) : null} - {props.contentView === "library" ? ( -
- -
- ) : null} - {props.chat.error && (
{props.chat.error} diff --git a/src/components/workspace-shell.test.ts b/src/components/workspace-shell.test.ts index 21971b2..a728af1 100644 --- a/src/components/workspace-shell.test.ts +++ b/src/components/workspace-shell.test.ts @@ -87,85 +87,6 @@ describe("WorkspaceShell", () => { ).toBeTruthy(); }); - it("shows a login CTA instead of the chat composer for guests", () => { - render( - React.createElement(C, { - isGuest: true, - loginUrl: "/login", - sources: [ - { - id: "source_1", - title: "demo.pdf", - status: "ready", - documentId: "doc_1", - }, - ], - }), - ); - - const desktopChatPanel = within(screen.getByTestId("desktop-chat-panel")); - - expect( - desktopChatPanel.queryByPlaceholderText( - "Ask a question about your documents…", - ), - ).toBeNull(); - expect( - desktopChatPanel.getByRole("button", { name: "Log in to start" }), - ).toBeTruthy(); - }); - - it("lets guests open the Official Library from the sources panel", async () => { - const user = userEvent.setup(); - - render( - React.createElement(C, { - isGuest: true, - loginUrl: "/login", - sources: [], - officialLibrarySources: [ - { - librarySourceId: "stem-transformers", - categoryId: "stem-books", - categoryLabel: "STEM books", - title: "Transformers.pdf", - sourceUrl: "https://example.com/transformers.pdf", - mimeType: "application/pdf", - status: "ready", - demoSourceId: "demo-transformers", - }, - ], - }), - ); - - const desktopSourcesPanel = within( - screen.getByTestId("desktop-sources-panel"), - ); - await user.click( - desktopSourcesPanel.getByRole("button", { name: "Open library" }), - ); - - const desktopLibraryPanel = within( - screen.getByTestId( - "official-library-panel", - ), - ); - expect(desktopLibraryPanel.getByRole("heading", { name: "Library" })) - .toBeTruthy(); - expect( - desktopLibraryPanel.getByRole("button", { name: "Open STEM books" }), - ).toBeTruthy(); - await user.click( - desktopLibraryPanel.getByRole("button", { name: "Back to sources" }), - ); - expect( - screen.queryByTestId( - "official-library-panel", - ), - ).toBeNull(); - expect(window.location.href).not.toContain("/login"); - }); - it("shows the first ready document chunks on workspace load", async () => { const fetch = vi.fn(async (input) => { const url = getRequestURL(input); @@ -229,173 +150,6 @@ describe("WorkspaceShell", () => { expect(countFetches(fetch, "/api/sources/source_2/chunks")).toBe(0); }); - it("focuses guest citations on desktop using loaded demo chunks", async () => { - const fetch = vi.fn(async (input) => { - const url = getRequestURL(input); - - if (url.pathname === "/api/sources/demo-source/chunks") { - return Response.json({ - chunks: [ - { - chunkId: "demo-source:chunk_1", - documentId: "doc_1", - sectionPath: "Demo", - type: "text", - content: "Demo cited section", - sourceTitle: "demo.pdf", - }, - ], - pagination: { - page: Number(url.searchParams.get("page") ?? "1"), - pageSize: 100, - total: 1, - totalPages: 1, - }, - }); - } - - return Response.json({ message: "Unexpected request" }, { status: 404 }); - }); - vi.stubGlobal("fetch", fetch); - - render( - React.createElement(C, { - isGuest: true, - sources: [ - { - id: "demo-source", - title: "demo.pdf", - status: "ready", - documentId: "doc_1", - }, - ], - chatMessages: [ - { - id: "assistant_1", - role: "assistant", - content: "Demo answer.", - citations: [ - { - content: "Demo cited section", - description: "Demo citation", - chunkType: "text", - score: 0.91, - source: { - documentId: "doc_1", - sourceFileName: "demo.pdf", - sectionPath: "Demo", - }, - }, - ], - }, - ], - }), - ); - - const citationButton = await findStableConnectedElement(() => { - const desktopChatPanel = within(screen.getByTestId("desktop-chat-panel")); - return desktopChatPanel.getByRole("button", { - name: "Open source demo.pdf", - }); - }); - fireEvent.click(citationButton); - - await waitFor(() => { - const topRow = screen - .getByTestId("chunks-panel") - .querySelector('[data-index="0"]'); - - expect(topRow?.getAttribute("data-chunk-id")).toBe("demo-source:chunk_1"); - expect(topRow?.getAttribute("data-focused-chunk")).toBe("true"); - }); - expect( - fetch.mock.calls.some(([input]) => - getRequestPath(input).startsWith("/demo-sources/"), - ), - ).toBe(false); - }); - - it("focuses guest citations from the mobile chat panel", async () => { - const fetch = vi.fn(async (input) => { - const url = getRequestURL(input); - - if (url.pathname === "/api/sources/demo-source/chunks") { - return Response.json({ - chunks: [ - { - chunkId: "demo-source:chunk_1", - documentId: "doc_1", - sectionPath: "Demo", - type: "text", - content: "Demo cited section", - sourceTitle: "demo.pdf", - }, - ], - pagination: { - page: Number(url.searchParams.get("page") ?? "1"), - pageSize: 100, - total: 1, - totalPages: 1, - }, - }); - } - - return Response.json({ message: "Unexpected request" }, { status: 404 }); - }); - vi.stubGlobal("fetch", fetch); - - render( - React.createElement(C, { - isGuest: true, - sources: [ - { - id: "demo-source", - title: "demo.pdf", - status: "ready", - documentId: "doc_1", - }, - ], - chatMessages: [ - { - id: "assistant_1", - role: "assistant", - content: "Demo answer.", - citations: [ - { - content: "Demo cited section", - description: "Demo citation", - chunkType: "text", - score: 0.91, - source: { - documentId: "doc_1", - sourceFileName: "demo.pdf", - sectionPath: "Demo", - }, - }, - ], - }, - ], - }), - ); - - const citationButton = await findStableConnectedElement(() => { - const mobileChatPanel = within(document.getElementById("panel-chat")!); - return mobileChatPanel.getByRole("button", { - name: "Open source demo.pdf", - }); - }); - fireEvent.click(citationButton); - - await waitFor(() => { - const topRow = screen - .getByTestId("chunks-panel") - .querySelector('[data-index="0"]'); - - expect(topRow?.getAttribute("data-chunk-id")).toBe("demo-source:chunk_1"); - expect(topRow?.getAttribute("data-focused-chunk")).toBe("true"); - }); - }); - it("reuses loaded chunks when users click another citation from the same source", async () => { const fetch = vi.fn(async (input) => { const path = getRequestPath(input); @@ -997,186 +751,6 @@ describe("WorkspaceShell", () => { expect(desktopSourcesPanel.queryByText("No sources yet.")).toBeNull(); }); - it("refreshes the active chat after adding an Official Library source", async () => { - const fetch = vi.fn(async (input, init) => { - const request = input instanceof Request - ? input - : new Request(new URL(String(input), "http://localhost").toString(), init); - const path = getRequestPath(request); - - if (path === "/api/demo-sources/materialize" && request.method === "POST") { - return Response.json({ - sources: [ - { - id: "source_spacex", - kind: "workspace", - title: "spacex-s1.pdf", - status: "ready", - mimeType: "application/pdf", - demoSourceId: "demo-spacex-s1", - documentId: "doc_user_copy", - chunkCount: 1, - }, - ], - }); - } - - if (path === "/api/chat/threads/thread_1") { - return Response.json({ - thread: { - id: "thread_1", - title: "Current chat", - createdAt: "2026-05-07T00:00:00.000Z", - updatedAt: "2026-05-07T00:00:00.000Z", - }, - messages: [ - { - id: "assistant_refreshed", - role: "assistant", - content: "Refreshed materialized answer.", - citations: [ - { - content: "User-copy cited section", - chunkType: "text", - score: 0.91, - source: { - documentId: "doc_user_copy", - sourceFileName: "spacex-s1.pdf", - sectionPath: "Overview", - }, - }, - ], - }, - ], - }); - } - - if (path === "/api/sources/source_spacex/chunks") { - return Response.json({ - chunks: [ - { - chunkId: "source_spacex:chunk_1", - documentId: "doc_user_copy", - sectionPath: "Overview", - type: "text", - content: "User-copy cited section", - sourceTitle: "spacex-s1.pdf", - }, - ], - }); - } - - return Response.json({ message: "Unexpected request" }, { status: 404 }); - }); - vi.stubGlobal("fetch", fetch); - const user = userEvent.setup(); - - render( - React.createElement(C, { - officialLibrarySources: [ - { - librarySourceId: "financial-spacex-s1", - categoryId: "financial-reports", - categoryLabel: "Financial Reports", - title: "spacex-s1.pdf", - sourceUrl: "https://example.com/spacex-s1.pdf", - mimeType: "application/pdf", - status: "ready", - demoSourceId: "demo-spacex-s1", - chunkCount: 922, - }, - ], - sources: [ - { - id: "demo-spacex-s1", - kind: "demo", - demoSourceId: "demo-spacex-s1", - title: "spacex-s1.pdf", - status: "ready", - mimeType: "application/pdf", - documentId: "demo-doc-spacex-s1", - officialLibrary: { - librarySourceId: "financial-spacex-s1", - categoryId: "financial-reports", - sourceUrl: "https://example.com/spacex-s1.pdf", - }, - }, - ], - chatThreads: [ - { - id: "thread_1", - title: "Current chat", - createdAt: "2026-05-07T00:00:00.000Z", - updatedAt: "2026-05-07T00:00:00.000Z", - }, - ], - activeChatThreadId: "thread_1", - chatMessages: [ - { - id: "assistant_seeded", - role: "assistant", - content: "Seeded canonical answer.", - citations: [ - { - content: "Canonical cited section", - chunkType: "text", - score: 0.91, - source: { - documentId: "demo-doc-spacex-s1", - sourceFileName: "spacex-s1.pdf", - sectionPath: "Overview", - }, - }, - ], - }, - ], - }), - ); - - const desktopSourcesPanel = within( - screen.getByTestId("desktop-sources-panel"), - ); - await user.click(desktopSourcesPanel.getByRole("button", { name: "Open library" })); - - const desktopLibraryPanel = within( - screen.getByTestId( - "official-library-panel", - ), - ); - expect(desktopLibraryPanel.getByRole("heading", { name: "Library" })) - .toBeTruthy(); - await user.click( - desktopLibraryPanel.getByRole("button", { - name: "Open Financial Reports", - }), - ); - await user.click( - desktopLibraryPanel.getByRole("button", { - name: "Add spacex-s1.pdf to sources", - }), - ); - - const desktopChatPanel = within(screen.getByTestId("desktop-chat-panel")); - await desktopChatPanel.findByText("Refreshed materialized answer."); - expect(desktopChatPanel.queryByText("Seeded canonical answer.")).toBeNull(); - const refreshedLibraryPanel = within( - screen.getByTestId( - "official-library-panel", - ), - ); - expect( - refreshedLibraryPanel.getByRole("heading", { name: "Library" }), - ).toBeTruthy(); - expect(refreshedLibraryPanel.getByLabelText("spacex-s1.pdf already added")) - .toBeTruthy(); - expect( - refreshedLibraryPanel.queryByRole("button", { - name: "Add spacex-s1.pdf to sources", - }), - ).toBeNull(); - expect(countFetches(fetch, "/api/chat/threads/thread_1")).toBe(1); - }); - it("uses cached chat data when reopening a previously loaded thread", async () => { const fetch = vi.fn(async (input) => { const path = getRequestPath(input); @@ -1287,24 +861,6 @@ describe("WorkspaceShell", () => { }); }); -function findStableConnectedElement( - getElement: () => HTMLElement, -): Promise { - let previousElement: HTMLElement | null = null; - - return waitFor(() => { - const element = getElement(); - expect(element.isConnected).toBe(true); - - if (element !== previousElement) { - previousElement = element; - throw new Error("Element is still settling."); - } - - return element; - }); -} - function getRequestPath(input: RequestInfo | URL): string { return getRequestURL(input).pathname; } diff --git a/src/components/workspace-shell.tsx b/src/components/workspace-shell.tsx index 1644e5d..201409c 100644 --- a/src/components/workspace-shell.tsx +++ b/src/components/workspace-shell.tsx @@ -6,7 +6,6 @@ import { usePathname } from "next/navigation" import { SWRConfig } from "swr" import { WorkspaceShellLayout, - type ContentView, type PanelId, } from "@/components/workspace-shell-layout" import { useWorkspaceDesktopPanels } from "@/components/workspace-desktop-panels" @@ -27,10 +26,7 @@ import type { ChatThreadView, } from "@/domains/chat/types" import type { ParsedChunkView } from "@/domains/chunks/types" -import type { - OfficialLibrarySourceView, - SourceView, -} from "@/domains/sources/types" +import type { SourceView } from "@/domains/sources/types" export type { PanelId } from "@/components/workspace-shell-layout" @@ -50,15 +46,12 @@ export type WorkspaceShellProps = { namespace: string } sources?: SourceView[] - officialLibrarySources?: OfficialLibrarySourceView[] chatThreads?: ChatThreadView[] activeChatThreadId?: string | null chatMessages?: ChatMessageView[] chunkViewDocumentId?: string | null dashboardUrl?: string initialPrefetchedChunksBySourceId?: Record - isGuest?: boolean - loginUrl?: string } export function WorkspaceShell(props: WorkspaceShellProps): ReactElement { @@ -80,7 +73,6 @@ export function WorkspaceShell(props: WorkspaceShellProps): ReactElement { function WorkspaceShellContent({ user, sources: initialSources, - officialLibrarySources, chatThreads: initialChatThreads, activeChatThreadId, chatMessages: initialChatMessages, @@ -88,30 +80,23 @@ function WorkspaceShellContent({ dashboardUrl, workspace, initialPrefetchedChunksBySourceId, - isGuest = false, - loginUrl, }: WorkspaceShellProps): ReactElement { - const [mobilePanel, setMobilePanel] = useState( - isGuest ? "sources" : "chat", - ) + const [mobilePanel, setMobilePanel] = useState("chat") const [isChunksOverlayVisible, setIsChunksOverlayVisible] = useState( Boolean(chunkViewDocumentId), ) const pathname = usePathname() - const [contentView, setContentView] = useState("chunks") const sourceWorkflow = useWorkspaceSourceWorkflow({ initialSelectedDocumentId: chunkViewDocumentId ?? null, initialSources: initialSources ?? [], - isGuest, }) const analyticsContext = useMemo( () => ({ workspaceId: workspace?.id, workspaceNamespace: workspace?.namespace, userId: user?.id, - isGuest, }), - [isGuest, user?.id, workspace?.id, workspace?.namespace], + [user?.id, workspace?.id, workspace?.namespace], ) const citationFocus = useWorkspaceCitationFocus({ fetchChunks: workspaceClient.fetchChunks, @@ -126,8 +111,6 @@ function WorkspaceShellContent({ analyticsContext, initialChatMessages: initialChatMessages ?? [], initialChatThreads: initialChatThreads ?? [], - isGuest, - onSourcesMaterialized: sourceWorkflow.handleSourcesMaterialized, sources: sourceWorkflow.sources, }) const { @@ -141,19 +124,13 @@ function WorkspaceShellContent({ handleDesktopPanelResizeStart, } = useWorkspaceDesktopPanels() - function redirectToLogin(): void { - window.location.href = loginUrl ?? "/login" - } - const selectedSourceTitle = citationFocus.selectedSource?.title ?? null function handleCitationSourceSelected(sourceId: string | null): void { - setContentView("chunks") sourceWorkflow.setSelectedSourceId(sourceId) } function handleSourceSelected(sourceId: string | null): void { - setContentView("chunks") citationFocus.handleSourceSelected(sourceId) } @@ -168,24 +145,6 @@ function WorkspaceShellContent({ setIsChunksOverlayVisible(false) } - async function handleOfficialLibrarySourceAdd( - demoSourceId: string, - ): Promise { - const didMaterialize = - await sourceWorkflow.handleOfficialLibrarySourceAdd(demoSourceId) - if (didMaterialize) { - await chatWorkflow.handleRefreshActiveChatThread() - } - } - - function handleLibraryOpen(): void { - setContentView("library") - } - - function handleLibraryBack(): void { - setContentView("chunks") - } - const hasMessages = chatWorkflow.chat.messages.length > 0 const didTrackFirstDocumentRef = useRef(false) const analyticsContextRef = useRef(analyticsContext) @@ -198,7 +157,7 @@ function WorkspaceShellContent({ }, [analyticsContext]) useEffect(() => { - if (isGuest || !userId) { + if (!userId) { void resetUser() return } @@ -208,7 +167,7 @@ function WorkspaceShellContent({ email: userEmail, name: userName, }) - }, [isGuest, userEmail, userId, userName]) + }, [userEmail, userId, userName]) useEffect(() => { void trackPageView(analyticsContextRef.current) @@ -228,7 +187,6 @@ function WorkspaceShellContent({ ) diff --git a/src/components/workspace-source-state.test.ts b/src/components/workspace-source-state.test.ts index f1069d5..ad56781 100644 --- a/src/components/workspace-source-state.test.ts +++ b/src/components/workspace-source-state.test.ts @@ -28,21 +28,15 @@ describe("workspaceSourceState", () => { ); }); - it("can select an unmaterialized Official Library row for preview", () => { + it("can select a workspace Source row for preview", () => { const sources: readonly SourceView[] = [ { - id: "demo-spacex-s1", - kind: "demo", - demoSourceId: "demo-spacex-s1", + id: "source_spacex", + kind: "workspace", title: "spacex-s1.pdf", status: "ready", mimeType: "application/pdf", excludedFromQuery: false, - officialLibrary: { - librarySourceId: "financial-spacex-s1", - categoryId: "financial-reports", - sourceUrl: "https://example.com/spacex-s1.pdf", - }, }, { id: "source_ready", @@ -54,7 +48,7 @@ describe("workspaceSourceState", () => { ]; expect(workspaceSourceState.getInitialSelectedSourceId(sources)).toBe( - "demo-spacex-s1", + "source_spacex", ); }); diff --git a/src/components/workspace-source-workflow.test.ts b/src/components/workspace-source-workflow.test.ts index c0414f5..332e263 100644 --- a/src/components/workspace-source-workflow.test.ts +++ b/src/components/workspace-source-workflow.test.ts @@ -9,7 +9,6 @@ import type { SourceView } from "@/domains/sources/types" const mocks = vi.hoisted(() => ({ archiveSource: vi.fn(), fetchSources: vi.fn(), - materializeDemoSources: vi.fn(), retrySource: vi.fn(), })) @@ -17,13 +16,11 @@ vi.mock("@/domains/workspace/client", () => ({ workspaceClient: { keys: { archiveSource: "archive-source", - materializeDemoSources: "/api/demo-sources/materialize", retrySource: "retry-source", sources: "/api/sources", }, archiveSource: mocks.archiveSource, fetchSources: mocks.fetchSources, - materializeDemoSources: mocks.materializeDemoSources, retrySource: mocks.retrySource, }, })) @@ -45,7 +42,6 @@ describe("useWorkspaceSourceWorkflow", () => { const { result } = renderWorkspaceSourceWorkflow({ initialSources, - isGuest: true, }) act(() => { @@ -78,7 +74,6 @@ describe("useWorkspaceSourceWorkflow", () => { const { result } = renderWorkspaceSourceWorkflow({ initialSources: [initialSource], - isGuest: false, }) act(() => { @@ -118,7 +113,6 @@ describe("useWorkspaceSourceWorkflow", () => { const { result } = renderWorkspaceSourceWorkflow({ initialSources: [failedSource], - isGuest: false, }) let retryAction: Promise | undefined @@ -157,7 +151,6 @@ describe("useWorkspaceSourceWorkflow", () => { const { result } = renderWorkspaceSourceWorkflow({ initialSources: [parsingSource], - isGuest: false, }) await waitFor(() => { @@ -169,103 +162,10 @@ describe("useWorkspaceSourceWorkflow", () => { documentId: "document_1", }) }) - - it("materializes one Official Library source through the workflow", async () => { - const demoSource = makeSource({ - id: "demo-spacex-s1", - kind: "demo", - demoSourceId: "demo-spacex-s1", - title: "spacex-s1.pdf", - officialLibrary: { - librarySourceId: "financial-spacex-s1", - categoryId: "financial-reports", - sourceUrl: "https://example.com/spacex-s1.pdf", - }, - }) - const materializedSource = makeSource({ - id: "source_spacex", - kind: "workspace", - demoSourceId: "demo-spacex-s1", - title: "spacex-s1.pdf", - documentId: "doc_spacex", - }) - mocks.fetchSources.mockResolvedValue([demoSource]) - mocks.materializeDemoSources.mockResolvedValue([materializedSource]) - - const { result } = renderWorkspaceSourceWorkflow({ - initialSources: [demoSource], - isGuest: false, - }) - - await act(async () => { - await expect( - result.current.handleOfficialLibrarySourceAdd("demo-spacex-s1"), - ).resolves.toBe(true) - }) - - expect(mocks.materializeDemoSources).toHaveBeenCalledWith({ - demoSourceIds: ["demo-spacex-s1"], - }) - expect(result.current.sources.map((source) => source.id)).toEqual([ - "source_spacex", - ]) - expect(result.current.sources[0]).toMatchObject({ - demoSourceId: "demo-spacex-s1", - }) - expect(result.current.selectedSourceId).toBe("source_spacex") - }) - - it("does not count unmaterialized Official Library sources as chat-ready", () => { - const librarySource = makeSource({ - id: "demo-spacex-s1", - kind: "demo", - demoSourceId: "demo-spacex-s1", - title: "spacex-s1.pdf", - officialLibrary: { - librarySourceId: "financial-spacex-s1", - categoryId: "financial-reports", - sourceUrl: "https://example.com/spacex-s1.pdf", - }, - }) - - const { result } = renderWorkspaceSourceWorkflow({ - initialSources: [librarySource], - isGuest: false, - }) - - expect(result.current.readySourceCount).toBe(0) - }) - - it("reports failed Official Library materialization without changing sources", async () => { - const demoSource = makeSource({ - id: "demo-spacex-s1", - kind: "demo", - demoSourceId: "demo-spacex-s1", - title: "spacex-s1.pdf", - }) - mocks.fetchSources.mockResolvedValue([demoSource]) - mocks.materializeDemoSources.mockRejectedValue(new Error("Bad gateway")) - - const { result } = renderWorkspaceSourceWorkflow({ - initialSources: [demoSource], - isGuest: false, - }) - - await act(async () => { - await expect( - result.current.handleOfficialLibrarySourceAdd("demo-spacex-s1"), - ).resolves.toBe(false) - }) - - expect(result.current.sources.map((source) => source.id)).toEqual([ - "demo-spacex-s1", - ]) - }) }) function renderWorkspaceSourceWorkflow(input: { readonly initialSources: readonly SourceView[] - readonly isGuest: boolean }) { return renderHook(() => useWorkspaceSourceWorkflow(input), { wrapper: ({ children }: { readonly children: ReactNode }) => diff --git a/src/components/workspace-source-workflow.ts b/src/components/workspace-source-workflow.ts index 9b9f1f8..31c31ca 100644 --- a/src/components/workspace-source-workflow.ts +++ b/src/components/workspace-source-workflow.ts @@ -12,21 +12,15 @@ import type { SourceView } from "@/domains/sources/types" type WorkspaceSourceWorkflowInput = { readonly initialSelectedDocumentId?: string | null readonly initialSources?: readonly SourceView[] - readonly isGuest?: boolean } type WorkspaceSourceWorkflow = { - readonly addingLibrarySourceIds: string[] readonly archivingSourceIds: string[] readonly handleArchiveSource: (sourceId: string) => Promise readonly handleRetrySource: (sourceId: string) => Promise - readonly handleOfficialLibrarySourceAdd: (demoSourceId: string) => Promise readonly handleSelectedSourceChange: (sourceId: string | null) => void - readonly handleSourcesMaterialized: ( - demoSourceIds: readonly string[], - materializedSources: readonly SourceView[], - ) => void readonly handleSourceUploaded: (source: SourceView) => void + readonly handleSourcesLocalized: (sources: readonly SourceView[]) => void readonly handleToggleIncluded: (sourceId: string, included: boolean) => void readonly readySourceCount: number readonly retryingSourceIds: string[] @@ -39,12 +33,10 @@ type WorkspaceSourceWorkflow = { const sourcesSWRKey = workspaceClient.keys.sources const archiveSourceSWRKey = workspaceClient.keys.archiveSource const retrySourceSWRKey = workspaceClient.keys.retrySource -const materializeDemoSourceSWRKey = workspaceClient.keys.materializeDemoSources export function useWorkspaceSourceWorkflow({ initialSelectedDocumentId = null, initialSources = [], - isGuest = false, }: WorkspaceSourceWorkflowInput): WorkspaceSourceWorkflow { const initialSourceRows = useMemo(() => [...initialSources], [initialSources]) const initialSelectedSourceId = workspaceSourceState.getInitialSelectedSourceId( @@ -59,11 +51,8 @@ export function useWorkspaceSourceWorkflow({ >({}) const [archivingSourceIds, setArchivingSourceIds] = useState([]) const [retryingSourceIds, setRetryingSourceIds] = useState([]) - const [addingLibrarySourceIds, setAddingLibrarySourceIds] = useState( - [], - ) const shouldRefreshSourcesOnMount = - !isGuest && workspaceClientCache.hasPendingSources(initialSourceRows) + workspaceClientCache.hasPendingSources(initialSourceRows) const { data: serverSources, mutate: mutateSources } = useSWR( sourcesSWRKey, workspaceClient.fetchSources, @@ -103,10 +92,6 @@ export function useWorkspaceSourceWorkflow({ retrySourceSWRKey, retrySourceMutation, ) - const { trigger: materializeDemoSources } = useSWRMutation( - materializeDemoSourceSWRKey, - materializeDemoSourcesMutation, - ) function handleSourceUploaded(source: SourceView): void { void mutateSources( @@ -117,29 +102,21 @@ export function useWorkspaceSourceWorkflow({ void mutateSources() } - function handleSourcesMaterialized( - demoSourceIds: readonly string[], - materializedSources: readonly SourceView[], - ): void { - const materializedDemoSourceIdSet = new Set(demoSourceIds) + function handleSourcesLocalized(sources: readonly SourceView[]): void { + if (sources.length === 0) return void mutateSources( - (current) => [ - ...(current ?? sourceRows).filter( - (source) => - !source.demoSourceId || - !materializedDemoSourceIdSet.has(source.demoSourceId), - ), - ...materializedSources, - ], + (current) => { + const existing = current ?? sourceRows + const newIds = new Set(sources.map((s) => s.id)) + const merged = [ + ...existing.filter((s) => !newIds.has(s.id)), + ...sources, + ] + return merged + }, { revalidate: false }, ) - setSelectedSourceId((current) => { - if (!current || materializedDemoSourceIdSet.has(current)) { - return materializedSources[0]?.id ?? current - } - - return current - }) + void mutateSources() } function handleToggleIncluded(sourceId: string, included: boolean): void { @@ -210,35 +187,13 @@ export function useWorkspaceSourceWorkflow({ } } - async function handleOfficialLibrarySourceAdd( - demoSourceId: string, - ): Promise { - setAddingLibrarySourceIds((current) => - workspaceSourceState.addPendingId(current, demoSourceId), - ) - try { - const materializedSources = await materializeDemoSources([demoSourceId]) - handleSourcesMaterialized([demoSourceId], materializedSources) - return true - } catch { - // Keep the library source visible when materialization fails. - return false - } finally { - setAddingLibrarySourceIds((current) => - workspaceSourceState.removePendingId(current, demoSourceId), - ) - } - } - return { - addingLibrarySourceIds, archivingSourceIds, handleArchiveSource, handleRetrySource, - handleOfficialLibrarySourceAdd, handleSelectedSourceChange, - handleSourcesMaterialized, handleSourceUploaded, + handleSourcesLocalized, handleToggleIncluded, readySourceCount, retryingSourceIds, @@ -251,16 +206,7 @@ export function useWorkspaceSourceWorkflow({ function isQueryableReadySource(source: SourceView): boolean { if (source.status !== "ready") return false - - return !isUnmaterializedOfficialLibrarySource(source) && !isRemoteSource(source) -} - -function isUnmaterializedOfficialLibrarySource(source: SourceView): boolean { - return source.kind === "demo" && source.officialLibrary !== undefined -} - -function isRemoteSource(source: SourceView): boolean { - return source.kind === "remote" + return source.kind !== "remote" } function archiveSourceMutation( @@ -276,12 +222,3 @@ function retrySourceMutation( ): ReturnType { return workspaceClient.retrySource(sourceId) } - -function materializeDemoSourcesMutation( - _key: string, - { arg: demoSourceIds }: { readonly arg: readonly string[] }, -): ReturnType { - return workspaceClient.materializeDemoSources({ - demoSourceIds: [...demoSourceIds], - }) -} diff --git a/src/domains/chat/chat-citation-persistence.ts b/src/domains/chat/chat-citation-persistence.ts index bf2a1a1..737c462 100644 --- a/src/domains/chat/chat-citation-persistence.ts +++ b/src/domains/chat/chat-citation-persistence.ts @@ -15,10 +15,6 @@ type ChatCitationPersistence = { readonly normalizeArtifacts: ( artifacts: readonly ChatArtifactView[] | null | undefined, ) => ChatArtifactView[] | null - readonly replaceDemoCitationDocumentId: ( - citations: readonly ChatCitationView[] | undefined, - documentIdMap: ReadonlyMap, - ) => ChatCitationView[] | undefined } function normalizeCitations( @@ -57,28 +53,6 @@ function toArtifactView(artifact: ChatArtifactView): ChatArtifactView { } } -function replaceDemoCitationDocumentId( - citations: readonly ChatCitationView[] | undefined, - documentIdMap: ReadonlyMap, -): ChatCitationView[] | undefined { - if (!citations) return undefined - - return citations.map((citation) => { - const newId = citation.source.documentId - ? documentIdMap.get(citation.source.documentId) - : undefined - if (!newId) return citation - - return { - ...citation, - source: { - ...citation.source, - documentId: newId, - }, - } - }) -} - function toCitationView( citation: ChatCitationView | CitationView | RetrievalResultView, ): CitationView { @@ -98,5 +72,4 @@ function toCitationView( export const chatCitationPersistence: ChatCitationPersistence = { normalizeCitations, normalizeArtifacts, - replaceDemoCitationDocumentId, } diff --git a/src/domains/chat/chat-thread-repository.ts b/src/domains/chat/chat-thread-repository.ts index d7cc884..0da7828 100644 --- a/src/domains/chat/chat-thread-repository.ts +++ b/src/domains/chat/chat-thread-repository.ts @@ -3,32 +3,11 @@ import "server-only" import { and, desc, eq, isNull, sql } from "drizzle-orm" import { Effect } from "effect" -import { chatCitationPersistence } from "./chat-citation-persistence" import { DbClient } from "@/infrastructure/db" import { - chatMessages, chatThreads, - type ChatMessage, type ChatThread, } from "@/infrastructure/db/schema" -import type { ChatCitationView } from "./types" - -type SeedDemoChatMessage = { - readonly role: "user" | "assistant" - readonly content: string - readonly citations?: readonly ChatCitationView[] | null -} - -type SeedDemoChatThreadInput = { - readonly demoKey: string - readonly title: string - readonly messages: readonly SeedDemoChatMessage[] -} - -type SeedDemoChatThreadResult = { - readonly thread: ChatThread - readonly messages: ChatMessage[] -} type ChatThreadRepository = { readonly findThreadInWorkspaceEffect: ( @@ -44,18 +23,10 @@ type ChatThreadRepository = { readonly ensureDefaultThreadEffect: ( workspaceId: string, ) => Effect.Effect - readonly ensureDemoThreadEffect: ( - workspaceId: string, - input: SeedDemoChatThreadInput, - ) => Effect.Effect readonly softDeleteThreadEffect: ( workspaceId: string, threadId: string, ) => Effect.Effect - readonly findThreadByDemoKeyEffect: ( - workspaceId: string, - demoKey: string, - ) => Effect.Effect } const chatThreadListLimit = 50 @@ -148,88 +119,6 @@ const ensureDefaultThreadEffect: ChatThreadRepository["ensureDefaultThreadEffect return thread }) -const ensureDemoThreadEffect: ChatThreadRepository["ensureDemoThreadEffect"] = - (workspaceId: string, input: SeedDemoChatThreadInput) => - Effect.gen(function* () { - if (input.messages.length === 0) return null - - const db = yield* DbClient - return yield* Effect.promise(() => - db.transaction(async (tx) => { - const insertDemoMessages = async ( - threadId: string, - ): Promise => { - const createdAtMs = Date.now() - return await tx - .insert(chatMessages) - .values( - input.messages.map((message, index) => ({ - threadId, - role: message.role, - content: message.content, - citations: chatCitationPersistence.normalizeCitations( - message.citations, - ), - createdAt: new Date(createdAtMs + index), - })), - ) - .returning() - } - - const existing = ( - await tx - .select() - .from(chatThreads) - .where( - and( - eq(chatThreads.workspaceId, workspaceId), - eq(chatThreads.demoKey, input.demoKey), - ), - ) - .limit(1) - )[0] - - if (existing) { - if (existing.deletedAt !== null) return null - - const existingMessages = await tx - .select() - .from(chatMessages) - .where(eq(chatMessages.threadId, existing.id)) - .orderBy(chatMessages.createdAt) - if (existingMessages.length > 0) { - return { - thread: existing, - messages: existingMessages, - } - } - - const messages = await insertDemoMessages(existing.id) - return { - thread: existing, - messages, - } - } - - const [thread] = await tx - .insert(chatThreads) - .values({ - workspaceId, - demoKey: input.demoKey, - title: input.title, - }) - .returning() - - if (!thread) { - throw new Error("ensureDemoChatThread: insert did not return a row.") - } - - const messages = await insertDemoMessages(thread.id) - return { thread, messages } - }), - ) - }) - const softDeleteThreadEffect: ChatThreadRepository["softDeleteThreadEffect"] = ( workspaceId: string, threadId: string, @@ -253,32 +142,10 @@ const softDeleteThreadEffect: ChatThreadRepository["softDeleteThreadEffect"] = ( return result.length > 0 }) -const findThreadByDemoKeyEffect: ChatThreadRepository["findThreadByDemoKeyEffect"] = - (workspaceId: string, demoKey: string) => - Effect.gen(function* () { - const db = yield* DbClient - const row = yield* Effect.promise(() => - db - .select() - .from(chatThreads) - .where( - and( - eq(chatThreads.workspaceId, workspaceId), - eq(chatThreads.demoKey, demoKey), - isNull(chatThreads.deletedAt), - ), - ) - .limit(1), - ) - return row[0] ?? null - }) - export const chatThreadRepository: ChatThreadRepository = { findThreadInWorkspaceEffect, listThreadsForWorkspaceEffect, createThreadEffect, ensureDefaultThreadEffect, - ensureDemoThreadEffect, softDeleteThreadEffect, - findThreadByDemoKeyEffect, } diff --git a/src/domains/chat/chat-turn-persistence.test.ts b/src/domains/chat/chat-turn-persistence.test.ts index 3dbb85c..b55911e 100644 --- a/src/domains/chat/chat-turn-persistence.test.ts +++ b/src/domains/chat/chat-turn-persistence.test.ts @@ -46,7 +46,6 @@ function makeThread(): ChatThread { return { id: "thread_1", workspaceId: "workspace_1", - demoKey: null, title: "Revenue", createdAt: new Date("2026-05-10T00:00:00.000Z"), updatedAt: new Date("2026-05-10T00:00:00.000Z"), diff --git a/src/domains/chat/index.test.ts b/src/domains/chat/index.test.ts index 0c8cfd6..8eecc8e 100644 --- a/src/domains/chat/index.test.ts +++ b/src/domains/chat/index.test.ts @@ -1779,7 +1779,6 @@ function makeSource(overrides: Partial = {}): Source { stagedBlobUrl: null, originalBlobPathname: null, originalBlobUrl: null, - demoKey: null, createdAt: new Date("2026-05-06T00:00:00Z"), updatedAt: new Date("2026-05-06T00:00:00Z"), deletedAt: null, diff --git a/src/domains/chat/media-asset-hardening.test.ts b/src/domains/chat/media-asset-hardening.test.ts index 70cf562..af07d90 100644 --- a/src/domains/chat/media-asset-hardening.test.ts +++ b/src/domains/chat/media-asset-hardening.test.ts @@ -116,47 +116,6 @@ describe("hardenChatMediaAssetUrls", () => { expect(result.results[0]?.assetUrl).toBe(parsedAssetUrl) }) - it("fetches demo asset routes from the upstream demo API", async () => { - process.env.KNOWHERE_BASE_URL = "https://knowhere.example" - const demoAssetUrl = - "/api/demo-sources/demo_source_1/assets/images/demo%20chart.png" - const blobStore = makeBlobStore( - "https://blob.example/workspaces/workspace_1/chat-assets/demo-demo_source_1/demo-chart.png", - ) - const fetchAsset = makeFetchAsset("demo-image", "image/png") - - const result = await hardenChatMediaAssetUrls({ - workspaceId: "workspace_1", - sources: [], - results: [ - makeRetrievalResult({ - chunkType: "image", - assetUrl: demoAssetUrl, - source: { - documentId: "demo_doc", - sourceFileName: "demo.pdf", - sectionPath: "images/demo chart.png", - }, - }), - ], - blobStore, - fetchAsset, - }) - - expect(fetchAsset).toHaveBeenCalledWith( - "https://knowhere.example/api/v1/demo/sources/demo_source_1/assets/images/demo%20chart.png", - ) - expect(fetchAsset).not.toHaveBeenCalledWith(demoAssetUrl) - expect(blobStore.put).toHaveBeenCalledWith( - expect.stringContaining("/chat-assets/demo-demo_source_1/"), - expect.any(Buffer), - expect.objectContaining({ contentType: "image/png" }), - ) - expect(result.results[0]?.assetUrl).toBe( - "https://blob.example/workspaces/workspace_1/chat-assets/demo-demo_source_1/demo-chart.png", - ) - }) - it("falls back to the raw URL when hardening fails", async () => { const rawAssetUrl = "https://knowhere-storage.example/results/job_1/tables/table-1.html?AWSAccessKeyId=test" @@ -287,7 +246,6 @@ function makeSource(overrides: Partial = {}): Source { stagedBlobUrl: null, originalBlobPathname: null, originalBlobUrl: null, - demoKey: null, createdAt: new Date("2026-05-06T00:00:00Z"), updatedAt: new Date("2026-05-06T00:00:00Z"), deletedAt: null, diff --git a/src/domains/chat/media-asset-hardening.ts b/src/domains/chat/media-asset-hardening.ts index e0a813e..255c888 100644 --- a/src/domains/chat/media-asset-hardening.ts +++ b/src/domains/chat/media-asset-hardening.ts @@ -8,7 +8,6 @@ import type { ChatCitationView, } from "@/domains/chat/types" import type { Source } from "@/infrastructure/db/schema" -import { knowhereDemoApi } from "@/integrations/knowhere-demo" import { logger } from "@/lib/logger" import type { LoadSourceAssetUrls } from "./media-assets" import { resolveAssetUrlFromReferenceText } from "./media-assets" @@ -81,12 +80,6 @@ type HardeningContext = { readonly fetchAsset: FetchChatMediaAsset } -type DemoAssetRoute = { - readonly demoSourceId: string - readonly encodedAssetPath: string - readonly decodedAssetPath: string -} - const chatAssetsDirectoryName = "chat-assets" const parsedResultDirectoryName = "parsed-result" const fallbackContentType = "application/octet-stream" @@ -339,20 +332,6 @@ async function copyAssetToBlob(input: { } function resolveAssetFetchRequest(assetUrl: string): AssetFetchRequest | null { - const demoAsset = parseDemoAssetRoute(assetUrl) - if (demoAsset) { - return { - fetchUrl: knowhereDemoApi.resolveApiURL( - `/api/v1/demo/sources/${encodeURIComponent( - demoAsset.demoSourceId, - )}/assets/${demoAsset.encodedAssetPath}`, - ), - canonicalKey: `demo:${demoAsset.demoSourceId}:${demoAsset.decodedAssetPath}`, - sourceSegment: `demo-${toSafePathSegment(demoAsset.demoSourceId)}`, - suggestedFileName: getPathBasename(demoAsset.decodedAssetPath), - } - } - const absoluteUrl = parseAbsoluteHttpUrl(assetUrl) if (!absoluteUrl) return null @@ -364,27 +343,6 @@ function resolveAssetFetchRequest(assetUrl: string): AssetFetchRequest | null { } } -function parseDemoAssetRoute(assetUrl: string): DemoAssetRoute | null { - const pathname = getAssetUrlPathname(assetUrl) - const match = /^\/api\/demo-sources\/([^/]+)\/assets\/(.+)$/.exec(pathname) - const encodedDemoSourceId = match?.[1] - const encodedAssetPath = match?.[2] - if (!encodedDemoSourceId || !encodedAssetPath) return null - - const demoSourceId = decodeUrlComponent(encodedDemoSourceId) - const assetPathSegments = encodedAssetPath - .split("/") - .map(decodeUrlComponent) - .filter((segment): boolean => segment.length > 0) - if (!demoSourceId || assetPathSegments.length === 0) return null - - return { - demoSourceId, - encodedAssetPath: assetPathSegments.map(encodeURIComponent).join("/"), - decodedAssetPath: assetPathSegments.join("/"), - } -} - function isNotebookOwnedAssetUrl(assetUrl: string): boolean { const pathname = getAssetUrlPathname(assetUrl).toLowerCase() if ( diff --git a/src/domains/chat/media-assets.test.ts b/src/domains/chat/media-assets.test.ts index f79338b..7cdd1fe 100644 --- a/src/domains/chat/media-assets.test.ts +++ b/src/domains/chat/media-assets.test.ts @@ -304,7 +304,6 @@ function makeSource(overrides: Partial = {}): Source { stagedBlobUrl: null, originalBlobPathname: null, originalBlobUrl: null, - demoKey: null, createdAt: new Date("2026-06-04T00:00:00Z"), updatedAt: new Date("2026-06-04T00:00:00Z"), deletedAt: null, diff --git a/src/domains/chat/repository.ts b/src/domains/chat/repository.ts index 30ed1a5..1e49080 100644 --- a/src/domains/chat/repository.ts +++ b/src/domains/chat/repository.ts @@ -8,7 +8,6 @@ type ChatRepository = { readonly listThreadsForWorkspaceEffect: typeof chatThreadRepository.listThreadsForWorkspaceEffect readonly createThreadEffect: typeof chatThreadRepository.createThreadEffect readonly ensureDefaultThreadEffect: typeof chatThreadRepository.ensureDefaultThreadEffect - readonly ensureDemoThreadEffect: typeof chatThreadRepository.ensureDemoThreadEffect readonly listMessagesForThreadEffect: typeof chatMessageRepository.listMessagesForThreadEffect readonly softDeleteThreadEffect: typeof chatThreadRepository.softDeleteThreadEffect readonly appendMessageToThreadEffect: typeof chatMessageRepository.appendMessageToThreadEffect @@ -19,7 +18,6 @@ export const chatRepository: ChatRepository = { listThreadsForWorkspaceEffect: chatThreadRepository.listThreadsForWorkspaceEffect, createThreadEffect: chatThreadRepository.createThreadEffect, ensureDefaultThreadEffect: chatThreadRepository.ensureDefaultThreadEffect, - ensureDemoThreadEffect: chatThreadRepository.ensureDemoThreadEffect, listMessagesForThreadEffect: chatMessageRepository.listMessagesForThreadEffect, softDeleteThreadEffect: chatThreadRepository.softDeleteThreadEffect, appendMessageToThreadEffect: chatMessageRepository.appendMessageToThreadEffect, diff --git a/src/domains/chat/route-service.test.ts b/src/domains/chat/route-service.test.ts index 4358e8a..0d3301c 100644 --- a/src/domains/chat/route-service.test.ts +++ b/src/domains/chat/route-service.test.ts @@ -387,7 +387,6 @@ function makeSource(overrides: Partial = {}): Source { stagedBlobUrl: null, originalBlobPathname: null, originalBlobUrl: null, - demoKey: null, createdAt: new Date("2026-05-06T00:00:00Z"), updatedAt: new Date("2026-05-06T00:00:00Z"), deletedAt: null, @@ -400,7 +399,6 @@ function makeThread(overrides: Partial = {}): ChatThread { id: "thread_1", workspaceId: "workspace_1", title: "Chat title", - demoKey: null, createdAt: new Date("2026-05-06T00:00:00Z"), updatedAt: new Date("2026-05-06T00:00:00Z"), deletedAt: null, diff --git a/src/domains/chat/service.test.ts b/src/domains/chat/service.test.ts index e931f04..a925781 100644 --- a/src/domains/chat/service.test.ts +++ b/src/domains/chat/service.test.ts @@ -276,7 +276,6 @@ function makeSource(overrides: Partial = {}): Source { stagedBlobUrl: null, originalBlobPathname: null, originalBlobUrl: null, - demoKey: null, createdAt: new Date("2026-05-06T00:00:00Z"), updatedAt: new Date("2026-05-06T00:00:00Z"), deletedAt: null, @@ -289,7 +288,6 @@ function makeThread(overrides: Partial = {}): ChatThread { id: "thread_1", workspaceId: "workspace_1", title: null, - demoKey: null, createdAt: new Date("2026-05-06T00:00:00Z"), updatedAt: new Date("2026-05-06T00:00:00Z"), deletedAt: null, diff --git a/src/domains/chat/thread-service.ts b/src/domains/chat/thread-service.ts index f0c3401..a3c410e 100644 --- a/src/domains/chat/thread-service.ts +++ b/src/domains/chat/thread-service.ts @@ -1,10 +1,8 @@ import "server-only" import { databaseRuntime } from "@/domains/workspace/database-runtime" -import { demoView } from "@/domains/demo/view" import { chatRepository } from "./repository" import type { ChatMessage, ChatThread } from "@/infrastructure/db/schema" -import type { DemoCatalog } from "@/integrations/knowhere-demo" import type { ChatArtifactView, ChatCitationView, @@ -22,11 +20,6 @@ type AppendMessageInput = { readonly artifacts?: readonly ChatArtifactView[] | null } -type DemoChatThreadSeed = { - readonly thread: ChatThread - readonly messages: ChatMessage[] -} - type ChatThreadService = { readonly findInWorkspace: ( workspaceId: string, @@ -35,10 +28,6 @@ type ChatThreadService = { readonly listForWorkspace: (workspaceId: string) => Promise readonly create: (workspaceId: string) => Promise readonly ensureDefault: (workspaceId: string) => Promise - readonly ensureDemo: ( - workspaceId: string, - catalog: DemoCatalog, - ) => Promise readonly listMessages: ( workspaceId: string, threadId: string, @@ -53,8 +42,6 @@ type ChatThreadService = { ) => Promise } -const seededDemoChatKey = "knowhere-demo-chat" - const findInWorkspace: ChatThreadService["findInWorkspace"] = ( workspaceId: string, threadId: string, @@ -80,23 +67,6 @@ const ensureDefault: ChatThreadService["ensureDefault"] = ( chatRepository.ensureDefaultThreadEffect(workspaceId), ) -const ensureDemo: ChatThreadService["ensureDemo"] = ( - workspaceId: string, - catalog: DemoCatalog, -) => { - const messages = demoView.toChatMessages(catalog) - const firstUserMessage = messages.find((message) => message.role === "user") - if (!firstUserMessage) return Promise.resolve(null) - - return databaseRuntime.runPromise( - chatRepository.ensureDemoThreadEffect(workspaceId, { - demoKey: seededDemoChatKey, - title: firstUserMessage.content, - messages, - }), - ) -} - const listMessages: ChatThreadService["listMessages"] = ( workspaceId: string, threadId: string, @@ -126,7 +96,6 @@ export const chatThreadService: ChatThreadService = { listForWorkspace, create, ensureDefault, - ensureDemo, listMessages, softDelete, appendMessage, diff --git a/src/domains/chunks/index.test.ts b/src/domains/chunks/index.test.ts index 0dfc22f..ea4f87b 100644 --- a/src/domains/chunks/index.test.ts +++ b/src/domains/chunks/index.test.ts @@ -584,7 +584,6 @@ function makeSource(overrides: Partial = {}): Source { stagedBlobUrl: null, originalBlobPathname: null, originalBlobUrl: null, - demoKey: null, createdAt: new Date("2026-05-06T00:00:00Z"), updatedAt: new Date("2026-05-06T00:00:00Z"), deletedAt: null, diff --git a/src/domains/chunks/server.test.ts b/src/domains/chunks/server.test.ts index f58f5ea..3a0bc75 100644 --- a/src/domains/chunks/server.test.ts +++ b/src/domains/chunks/server.test.ts @@ -425,7 +425,6 @@ function makeSource(overrides: Partial = {}): Source { stagedBlobUrl: null, originalBlobPathname: null, originalBlobUrl: null, - demoKey: null, createdAt: new Date("2026-05-06T00:00:00Z"), updatedAt: new Date("2026-05-06T00:00:00Z"), deletedAt: null, diff --git a/src/domains/demo/original-file.test.ts b/src/domains/demo/original-file.test.ts deleted file mode 100644 index 7d19a40..0000000 --- a/src/domains/demo/original-file.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, expect, it } from "vitest" - -import { demoOriginalFile } from "@/domains/demo/original-file" - -describe("demoOriginalFile", () => { - it("keeps public original URLs for demo source preview", () => { - expect( - demoOriginalFile.getPublicUrl( - makeDemoOriginalSource({ - originalUrl: "https://example.com/report.pdf", - }), - ), - ).toBe("https://example.com/report.pdf") - }) - - it("falls back to the Official Library file URL instead of Knowhere API originals", () => { - const source = makeDemoOriginalSource({ - originalUrl: - "https://api.knowhere.example/api/v1/demo/sources/demo-report/original", - sourceUrl: "https://example.com/library-report.pdf", - }) - - expect(demoOriginalFile.getPublicUrl(source)).toBe( - "https://example.com/library-report.pdf", - ) - expect(demoOriginalFile.toSourceOriginalFileView(source)).toMatchObject({ - url: "https://example.com/library-report.pdf", - pdfPreviewMode: "browser", - }) - }) - - it("returns no original URL for legacy demo originals without a public file", () => { - expect( - demoOriginalFile.getPublicUrl( - makeDemoOriginalSource({ - originalUrl: - "https://api.knowhere.example/api/v1/demo/sources/demo-report/original", - }), - ), - ).toBeNull() - }) -}) - -function makeDemoOriginalSource({ - originalUrl, - sourceUrl, -}: { - readonly originalUrl: string - readonly sourceUrl?: string -}): Parameters[0] { - return { - originalFile: { - url: originalUrl, - mimeType: "application/pdf", - sizeBytes: 1024, - canDownload: false, - }, - ...(sourceUrl - ? { - officialLibrary: { - sourceUrl, - }, - } - : {}), - } -} diff --git a/src/domains/demo/original-file.ts b/src/domains/demo/original-file.ts deleted file mode 100644 index e31b79f..0000000 --- a/src/domains/demo/original-file.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { SourceOriginalFileView } from "@/domains/sources/types" - -type DemoOriginalSource = { - readonly originalFile: { - readonly url: string - readonly mimeType: string - readonly sizeBytes: number - readonly canDownload: boolean - } - readonly officialLibrary?: { - readonly sourceUrl: string - } -} - -export const demoOriginalFile = { - getPublicUrl, - toSourceOriginalFileView, -} as const - -function toSourceOriginalFileView( - source: DemoOriginalSource, -): SourceOriginalFileView | null { - const url = getPublicUrl(source) - if (!url) return null - - return { - url, - mimeType: source.originalFile.mimeType, - sizeBytes: source.originalFile.sizeBytes, - canDownload: source.originalFile.canDownload, - pdfPreviewMode: "browser", - } -} - -function getPublicUrl(source: DemoOriginalSource): string | null { - const originalUrl = toPublicHttpUrl(source.originalFile.url) - if (originalUrl) return originalUrl - - return source.officialLibrary - ? toPublicHttpUrl(source.officialLibrary.sourceUrl) - : null -} - -function toPublicHttpUrl(value: string): string | null { - try { - const parsedUrl = new URL(value) - if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") { - return null - } - if (isDemoOriginalProxyPath(parsedUrl.pathname)) return null - return parsedUrl.toString() - } catch { - return null - } -} - -function isDemoOriginalProxyPath(pathname: string): boolean { - return ( - /^\/api\/v1\/demo\/sources\/[^/]+\/original\/?$/.test(pathname) || - /^\/api\/demo-sources\/[^/]+\/original\/?$/.test(pathname) - ) -} diff --git a/src/domains/demo/view.ts b/src/domains/demo/view.ts deleted file mode 100644 index 0b9a39b..0000000 --- a/src/domains/demo/view.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { parsedChunkNormalization } from "@/domains/chunks/normalization" -import type { ChatMessageView } from "@/domains/chat/types" -import type { ParsedChunkView } from "@/domains/chunks/types" -import { demoOriginalFile } from "@/domains/demo/original-file" -import type { SourceView } from "@/domains/sources/types" -import type { - DemoCatalog, - DemoChunk, - DemoSource, -} from "@/integrations/knowhere-demo" - -export const demoView = { - toChatMessages, - toParsedChunkView, - toSourceView, -} as const - -function toSourceView(source: DemoSource): SourceView { - const originalFile = demoOriginalFile.toSourceOriginalFileView(source) - - return { - id: source.demoSourceId, - kind: "demo", - demoSourceId: source.demoSourceId, - title: source.title, - mimeType: source.mimeType, - status: "ready", - documentId: source.canonicalDocumentId, - ...(originalFile ? { originalFile } : {}), - ...(source.officialLibrary - ? { - officialLibrary: { - librarySourceId: source.officialLibrary.librarySourceId, - categoryId: source.officialLibrary.categoryId, - sourceUrl: source.officialLibrary.sourceUrl, - }, - } - : {}), - chunkCount: source.chunkCount, - } -} - -function toChatMessages(catalog: DemoCatalog): ChatMessageView[] { - return catalog.sources.flatMap((source) => - source.examples.flatMap((example): ChatMessageView[] => [ - { - id: `${example.id}-user`, - role: "user", - content: example.question, - }, - { - id: `${example.id}-assistant`, - role: "assistant", - content: example.answer, - citations: example.citations.map((citation) => ({ - chunkType: citation.chunkType, - score: 0.95, - content: citation.content, - ...(citation.description - ? { description: citation.description } - : {}), - source: { - documentId: citation.canonicalDocumentId, - sourceFileName: citation.source.sourceFileName, - sectionPath: citation.source.sectionPath, - }, - })), - }, - ]), - ) -} - -function toParsedChunkView( - source: SourceView, - chunk: DemoChunk, -): ParsedChunkView { - return parsedChunkNormalization.createParsedChunkView({ - chunkId: chunk.id, - parserChunkId: chunk.chunkId, - documentId: source.documentId, - sectionPath: chunk.sectionPath, - chunkType: chunk.chunkType, - content: chunk.content, - metadata: chunk.metadata, - filePathCandidates: [chunk.filePath], - assetUrl: chunk.assetUrl, - sourceTitle: source.title, - }) -} diff --git a/src/domains/demo/workspace-source-resolution.ts b/src/domains/demo/workspace-source-resolution.ts deleted file mode 100644 index 39abc5a..0000000 --- a/src/domains/demo/workspace-source-resolution.ts +++ /dev/null @@ -1,100 +0,0 @@ -import type { Source } from "@/infrastructure/db/schema" -import type { DemoCatalog } from "@/integrations/knowhere-demo" - -type WorkspaceDemoSourceResolution = { - readonly materializedDemoSourceIds: ReadonlySet - readonly workspaceSources: readonly Source[] -} - -type SourceViewOptions = { - readonly chunkCount?: number -} - -export function resolveWorkspaceDemoSources( - sources: readonly Source[], - catalog: DemoCatalog, -): WorkspaceDemoSourceResolution { - const canonicalDocumentIdByDemoSourceId: Map = new Map( - catalog.sources.map((source) => [ - source.demoSourceId, - source.canonicalDocumentId, - ]), - ) - const workspaceSources: Source[] = sources.filter( - (source) => - !isLegacyCanonicalDemoSource(source, canonicalDocumentIdByDemoSourceId), - ) - const materializedDemoSourceIds: Set = new Set( - workspaceSources.flatMap((source) => { - if (!isMaterializedDemoSource(source, canonicalDocumentIdByDemoSourceId)) { - return [] - } - return source.demoKey ? [source.demoKey] : [] - }), - ) - - return { - materializedDemoSourceIds, - workspaceSources, - } -} - -export function getWorkspaceSourcesNeedingKnowhereChunkCount( - sources: readonly Source[], -): Source[] { - return sources.filter((source) => !source.demoKey) -} - -export function getMaterializedDemoSourceViewOptionsBySourceId( - sources: readonly Source[], - catalog: DemoCatalog, -): ReadonlyMap { - const chunkCountByDemoSourceId: ReadonlyMap = new Map( - catalog.sources.map((source) => [source.demoSourceId, source.chunkCount]), - ) - - return new Map( - sources.flatMap((source): readonly [string, SourceViewOptions][] => { - if (!source.demoKey) return [] - - const chunkCount = chunkCountByDemoSourceId.get(source.demoKey) - if (chunkCount === undefined) return [] - - return [[source.id, { chunkCount }]] - }), - ) -} - -function isLegacyCanonicalDemoSource( - source: Source, - canonicalDocumentIdByDemoSourceId: ReadonlyMap, -): boolean { - if (!source.demoKey) return false - if ( - source.knowhereJobId === null && - (source.knowhereDocumentId === null || - source.knowhereDocumentId.startsWith("demo-doc-")) - ) { - return true - } - - const canonicalDocumentId = canonicalDocumentIdByDemoSourceId.get( - source.demoKey, - ) - if (canonicalDocumentId === undefined) return false - return source.knowhereDocumentId === canonicalDocumentId -} - -function isMaterializedDemoSource( - source: Source, - canonicalDocumentIdByDemoSourceId: ReadonlyMap, -): boolean { - if (!source.demoKey || !source.knowhereDocumentId) return false - const canonicalDocumentId = canonicalDocumentIdByDemoSourceId.get( - source.demoKey, - ) - return ( - canonicalDocumentId === undefined || - source.knowhereDocumentId !== canonicalDocumentId - ) -} diff --git a/src/domains/sources/counts.test.ts b/src/domains/sources/counts.test.ts index 22cdda9..15472f8 100644 --- a/src/domains/sources/counts.test.ts +++ b/src/domains/sources/counts.test.ts @@ -20,7 +20,6 @@ function makeSource(overrides: Partial = {}): Source { stagedBlobUrl: null, originalBlobPathname: null, originalBlobUrl: null, - demoKey: null, createdAt: new Date("2026-05-06T00:00:00Z"), updatedAt: new Date("2026-05-06T00:00:00Z"), deletedAt: null, @@ -75,31 +74,4 @@ describe("countChunksBySourceId", () => { expect(counts.size).toBe(0) }) - - it("does not count materialized demo sources through their copied document id", async () => { - const listChunks = vi.fn().mockResolvedValue({ - pagination: { total: 70 }, - }) - const mockClient = { - documents: { listChunks }, - } as unknown as Knowhere - - const { countChunksBySourceId } = await import("./counts") - - const counts = await Effect.runPromise( - countChunksBySourceId( - [ - makeSource({ - id: "source_demo", - demoKey: "demo-tsla-q4-2025", - knowhereDocumentId: "doc_user_copy", - }), - ], - mockClient, - ), - ) - - expect(listChunks).not.toHaveBeenCalled() - expect(counts.size).toBe(0) - }) }) diff --git a/src/domains/sources/counts.ts b/src/domains/sources/counts.ts index 310a694..b611dbc 100644 --- a/src/domains/sources/counts.ts +++ b/src/domains/sources/counts.ts @@ -12,7 +12,6 @@ export const countChunksBySourceId = ( Effect.gen(function* () { const readySources = sources.filter( (source) => - !source.demoKey && source.status === "ready" && source.knowhereDocumentId, ) diff --git a/src/domains/sources/demo-source-repository.ts b/src/domains/sources/demo-source-repository.ts deleted file mode 100644 index dea38ce..0000000 --- a/src/domains/sources/demo-source-repository.ts +++ /dev/null @@ -1,137 +0,0 @@ -import "server-only" - -import { and, eq, isNotNull, or, sql } from "drizzle-orm" -import { Effect } from "effect" - -import { DbClient } from "@/infrastructure/db" -import { - demoSourceVisibilities, - sources, - type Source, -} from "@/infrastructure/db/schema" - -type UpsertMaterializedDemoSourceInput = { - readonly demoSourceId: string - readonly title: string - readonly mimeType: string - readonly sizeBytes: number - readonly knowhereDocumentId: string - readonly originalBlobUrl: string | null -} - -type DemoSourceRepository = { - readonly listHiddenDemoSourceIdsEffect: ( - workspaceId: string, - ) => Effect.Effect - readonly hideDemoSourceEffect: ( - workspaceId: string, - demoSourceId: string, - ) => Effect.Effect - readonly upsertMaterializedDemoSourceEffect: ( - workspaceId: string, - input: UpsertMaterializedDemoSourceInput, - ) => Effect.Effect -} - -const listHiddenDemoSourceIdsEffect: DemoSourceRepository["listHiddenDemoSourceIdsEffect"] = - (workspaceId: string) => - Effect.gen(function* () { - const db = yield* DbClient - const rows = yield* Effect.promise(() => - db - .select({ demoSourceId: demoSourceVisibilities.demoSourceId }) - .from(demoSourceVisibilities) - .where( - and( - eq(demoSourceVisibilities.workspaceId, workspaceId), - or( - isNotNull(demoSourceVisibilities.hiddenAt), - isNotNull(demoSourceVisibilities.deletedAt), - ), - ), - ), - ) - - return rows.map((row) => row.demoSourceId) - }) - -const hideDemoSourceEffect: DemoSourceRepository["hideDemoSourceEffect"] = ( - workspaceId: string, - demoSourceId: string, -) => - Effect.gen(function* () { - const db = yield* DbClient - yield* Effect.promise(() => - db - .insert(demoSourceVisibilities) - .values({ - workspaceId, - demoSourceId, - hiddenAt: sql`now()`, - deletedAt: sql`now()`, - }) - .onConflictDoUpdate({ - target: [ - demoSourceVisibilities.workspaceId, - demoSourceVisibilities.demoSourceId, - ], - set: { - hiddenAt: sql`now()`, - deletedAt: sql`now()`, - updatedAt: sql`now()`, - }, - }), - ) - }) - -const upsertMaterializedDemoSourceEffect: DemoSourceRepository["upsertMaterializedDemoSourceEffect"] = - (workspaceId: string, input: UpsertMaterializedDemoSourceInput) => - Effect.gen(function* () { - const db = yield* DbClient - const [source] = yield* Effect.promise(() => - db - .insert(sources) - .values({ - workspaceId, - title: input.title, - mimeType: input.mimeType, - sizeBytes: input.sizeBytes, - status: "ready", - failureReason: null, - knowhereJobId: null, - knowhereDocumentId: input.knowhereDocumentId, - originalBlobUrl: input.originalBlobUrl, - demoKey: input.demoSourceId, - }) - .onConflictDoUpdate({ - target: [sources.workspaceId, sources.demoKey], - set: { - title: input.title, - mimeType: input.mimeType, - sizeBytes: input.sizeBytes, - status: "ready", - failureReason: null, - knowhereJobId: null, - knowhereDocumentId: input.knowhereDocumentId, - originalBlobUrl: input.originalBlobUrl, - deletedAt: null, - updatedAt: sql`now()`, - }, - }) - .returning(), - ) - - if (!source) { - return yield* Effect.die( - new Error("upsertMaterializedDemoSource: upsert did not return a row."), - ) - } - - return source - }) - -export const demoSourceRepository: DemoSourceRepository = { - listHiddenDemoSourceIdsEffect, - hideDemoSourceEffect, - upsertMaterializedDemoSourceEffect, -} diff --git a/src/domains/sources/reconcile.test.ts b/src/domains/sources/reconcile.test.ts index 4c90172..8fcc924 100644 --- a/src/domains/sources/reconcile.test.ts +++ b/src/domains/sources/reconcile.test.ts @@ -26,7 +26,6 @@ function makeSource(overrides: Partial): Source { stagedBlobUrl: null, originalBlobPathname: null, originalBlobUrl: null, - demoKey: null, createdAt: new Date("2026-05-06T00:00:00Z"), updatedAt: new Date("2026-05-06T00:00:00Z"), deletedAt: null, diff --git a/src/domains/sources/remote-library.ts b/src/domains/sources/remote-library.ts index 87d3fd8..d18cb61 100644 --- a/src/domains/sources/remote-library.ts +++ b/src/domains/sources/remote-library.ts @@ -199,8 +199,14 @@ export function localizeRemoteLibrarySources( input: RemoteLibraryLocalizationInput, ): Effect.Effect { return Effect.gen(function* () { + const localDocumentIds = new Set( + input.localSources.flatMap((source): string[] => + source.knowhereDocumentId ? [source.knowhereDocumentId] : [], + ), + ) const remoteDocuments = (yield* listRemoteLibraryDocuments(input)).filter( (document) => + !localDocumentIds.has(document.documentId) && !matchesActiveNotebookParsingSource(document, input.localSources), ) if (remoteDocuments.length === 0) return input.localSources diff --git a/src/domains/sources/repository.ts b/src/domains/sources/repository.ts index c7c4b6f..fa7a044 100644 --- a/src/domains/sources/repository.ts +++ b/src/domains/sources/repository.ts @@ -1,6 +1,5 @@ import "server-only" -import { demoSourceRepository } from "./demo-source-repository" import { sourceParseResultRepository } from "./source-parse-result-repository" import { sourceRowRepository } from "./source-row-repository" @@ -9,9 +8,6 @@ type SourceRepository = { readonly listForWorkspaceEffect: typeof sourceRowRepository.listForWorkspaceEffect readonly createUploadingEffect: typeof sourceRowRepository.createUploadingEffect readonly localizeRemoteDocumentEffect: typeof sourceRowRepository.localizeRemoteDocumentEffect - readonly listHiddenDemoSourceIdsEffect: typeof demoSourceRepository.listHiddenDemoSourceIdsEffect - readonly hideDemoSourceEffect: typeof demoSourceRepository.hideDemoSourceEffect - readonly upsertMaterializedDemoSourceEffect: typeof demoSourceRepository.upsertMaterializedDemoSourceEffect readonly markParsingEffect: typeof sourceRowRepository.markParsingEffect readonly markReadyEffect: typeof sourceRowRepository.markReadyEffect readonly updateRevisionKeyEffect: typeof sourceRowRepository.updateRevisionKeyEffect @@ -30,10 +26,6 @@ export const sourceRepository: SourceRepository = { createUploadingEffect: sourceRowRepository.createUploadingEffect, localizeRemoteDocumentEffect: sourceRowRepository.localizeRemoteDocumentEffect, - listHiddenDemoSourceIdsEffect: demoSourceRepository.listHiddenDemoSourceIdsEffect, - hideDemoSourceEffect: demoSourceRepository.hideDemoSourceEffect, - upsertMaterializedDemoSourceEffect: - demoSourceRepository.upsertMaterializedDemoSourceEffect, markParsingEffect: sourceRowRepository.markParsingEffect, markReadyEffect: sourceRowRepository.markReadyEffect, updateRevisionKeyEffect: sourceRowRepository.updateRevisionKeyEffect, diff --git a/src/domains/sources/retry.test.ts b/src/domains/sources/retry.test.ts index 86c03a1..7497ffc 100644 --- a/src/domains/sources/retry.test.ts +++ b/src/domains/sources/retry.test.ts @@ -130,7 +130,6 @@ function makeSource(overrides: Partial = {}): Source { originalBlobPathname: "source-uploads/upload_1/document.pdf", originalBlobUrl: "https://store.public.blob.vercel-storage.com/source-uploads/upload_1/document.pdf", - demoKey: null, createdAt: new Date("2026-05-10T00:00:00Z"), updatedAt: new Date("2026-05-10T00:00:00Z"), deletedAt: null, diff --git a/src/domains/sources/route-archive.ts b/src/domains/sources/route-archive.ts index 897dee3..df4d190 100644 --- a/src/domains/sources/route-archive.ts +++ b/src/domains/sources/route-archive.ts @@ -12,7 +12,6 @@ import type { type RouteArchiveDependencies = Pick< SourceRouteServiceDependencies, | "deleteBlob" - | "demoApi" | "ensureApiKeyForWorkspace" | "ensureWorkspace" | "makeKnowhereClient" @@ -52,17 +51,6 @@ const archiveSourceEffect = ( ) if (!source) { - const catalog = yield* Effect.tryPromise(() => deps.demoApi.fetchCatalog()) - const isDemoSource = catalog.sources.some( - (candidate) => candidate.demoSourceId === input.sourceId, - ) - if (isDemoSource) { - yield* Effect.tryPromise(() => - deps.sourceService.hideDemoSource(workspace.id, input.sourceId), - ) - return routeResult.ok({ id: input.sourceId, archived: true as const }) - } - return routeResult.error(404, "Source not found.") } @@ -78,11 +66,6 @@ const archiveSourceEffect = ( yield* Effect.tryPromise(() => deps.sourceService.softDelete(workspace.id, input.sourceId), ) - if (source.demoKey) { - yield* Effect.tryPromise(() => - deps.sourceService.hideDemoSource(workspace.id, source.demoKey!), - ) - } if (source.originalBlobPathname) { yield* Effect.tryPromise(() => deps.deleteBlob(source.originalBlobPathname!), diff --git a/src/domains/sources/route-chunks.ts b/src/domains/sources/route-chunks.ts index 733ccc1..d86db34 100644 --- a/src/domains/sources/route-chunks.ts +++ b/src/domains/sources/route-chunks.ts @@ -1,8 +1,5 @@ import { Effect } from "effect" -import { demoView } from "@/domains/demo/view" -import type { DemoChunkPage } from "@/integrations/knowhere-demo" -import { logger } from "@/lib/logger" import { routeResult } from "@/lib/route-result" import { decodeRemoteSourceId, @@ -19,7 +16,6 @@ import type { type RouteChunksDependencies = Pick< SourceRouteServiceDependencies, - | "demoApi" | "ensureApiKeyForWorkspace" | "ensureWorkspace" | "getCurrentUser" @@ -42,19 +38,12 @@ function createRouteChunks(deps: RouteChunksDependencies): RouteChunks { } } -// --------------------------------------------------------------------------- -// Effect core -// --------------------------------------------------------------------------- - const loadSourceChunksEffect = ( input: LoadSourceChunksInput, deps: RouteChunksDependencies, ) => Effect.gen(function* () { if (!sourceRowRepository.isWorkspaceSourceId(input.sourceId)) { - const demoResult = yield* loadDemoChunkPageEffect(input, deps) - if (demoResult) return demoResult - const remoteResult = yield* loadRemoteChunkPageEffect(input, deps) return remoteResult ?? sourceNotFound() } @@ -75,16 +64,6 @@ const loadSourceChunksEffect = ( return sourceNotFound() } - if (source.demoKey) { - const demoResult = yield* loadDemoChunkPageEffect( - input, - deps, - source.demoKey, - source.knowhereDocumentId, - ) - return demoResult ?? sourceNotFound() - } - const client = yield* Effect.tryPromise(() => getClientForWorkspace(workspace.id, input.cookieHeader, deps), ) @@ -192,104 +171,6 @@ const loadRemoteChunkPageEffect = ( return routeResult.ok(chunkPage) }) -const loadDemoChunkPageEffect = ( - input: LoadSourceChunksInput, - deps: RouteChunksDependencies, - demoSourceId: string = input.sourceId, - documentIdOverride?: string | null, -) => - Effect.gen(function* () { - const pages = input.shouldLoadAll - ? yield* Effect.tryPromise(() => - loadAllDemoChunkPages(input, deps, demoSourceId), - ) - : [ - yield* Effect.tryPromise(() => - deps.demoApi.fetchChunkPage({ - demoSourceId, - page: input.pageParams.page, - pageSize: input.pageParams.pageSize, - }), - ), - ] - const page = pages[0] - if (!page) return null - const source = { - id: page.demoSourceId, - kind: "demo" as const, - demoSourceId: page.demoSourceId, - title: page.title, - mimeType: page.mimeType, - status: "ready" as const, - documentId: documentIdOverride ?? page.canonicalDocumentId, - } - const chunks = pages.flatMap((demoChunkPage) => - demoChunkPage.chunks.map((chunk) => - demoView.toParsedChunkView(source, chunk), - ), - ) - - return routeResult.ok( - input.shouldLoadAll - ? { chunks } - : { - chunks, - pagination: page.pagination, - }, - ) - }).pipe( - Effect.catchAll((error) => - Effect.sync(() => { - logger.warn("sources: demo chunk load failed", { - sourceId: input.sourceId, - demoSourceId, - page: input.pageParams.page, - pageSize: input.pageParams.pageSize, - shouldLoadAll: input.shouldLoadAll, - knowhereBaseUrl: process.env.KNOWHERE_BASE_URL ?? "(default)", - error: getErrorMessage(error), - }) - return null - }), - ), - ) - -async function loadAllDemoChunkPages( - input: LoadSourceChunksInput, - deps: RouteChunksDependencies, - demoSourceId: string, -): Promise { - const pageSize = 200 - const firstPage = await deps.demoApi.fetchChunkPage({ - demoSourceId, - page: 1, - pageSize, - }) - const pages = [firstPage] - for ( - let pageNumber = 2; - pageNumber <= firstPage.pagination.totalPages; - pageNumber += 1 - ) { - pages.push( - await deps.demoApi.fetchChunkPage({ - demoSourceId, - page: pageNumber, - pageSize, - }), - ) - } - return pages -} - -function getErrorMessage(error: unknown): string { - if (error instanceof Error) { - const inner = (error as Error & { error?: unknown }).error - return inner instanceof Error ? inner.message : error.message - } - return String(error) -} - function sourceNotFound(): JsonRouteResult<{ readonly message: string }> { return routeResult.error(404, "Source not found.") } diff --git a/src/domains/sources/route-dependencies.ts b/src/domains/sources/route-dependencies.ts index 25313ce..3248dec 100644 --- a/src/domains/sources/route-dependencies.ts +++ b/src/domains/sources/route-dependencies.ts @@ -8,7 +8,6 @@ import { } from "@/domains/chunks/server" import { ensureApiKeyForWorkspace } from "@/integrations/dashboard/api-key-service" import { makeKnowhereClient as makeDefaultKnowhereClient } from "@/integrations/knowhere" -import { knowhereDemoApi } from "@/integrations/knowhere-demo" import { getCurrentUser, requireUser } from "@/infrastructure/auth" import { workspaceService } from "@/domains/workspace/service" import { sourceViewOptionsBySourceId as getDefaultSourceViewOptionsBySourceId } from "./counts" @@ -23,7 +22,6 @@ import type { const defaultDependencies: SourceRouteServiceDependencies = { deleteBlob: del, - demoApi: knowhereDemoApi, ensureApiKeyForWorkspace, ensureWorkspace: workspaceService.ensureWorkspace, getCurrentUser, @@ -46,13 +44,9 @@ const defaultDependencies: SourceRouteServiceDependencies = { sourceService: { findInWorkspace: defaultSourceService.findInWorkspace, getParseAssetUrls: defaultSourceService.getParseAssetUrls, - hideDemoSource: defaultSourceService.hideDemoSource, - listHiddenDemoSourceIds: defaultSourceService.listHiddenDemoSourceIds, localizeRemoteDocument: defaultSourceService.localizeRemoteDocument, updateSourceRevisionKey: defaultSourceService.updateSourceRevisionKey, softDelete: defaultSourceService.softDelete, - upsertMaterializedDemoSource: - defaultSourceService.upsertMaterializedDemoSource, retrySourceToKnowhere: defaultSourceService.retrySourceToKnowhere, uploadSourceBlobToKnowhere: defaultSourceService.uploadSourceBlobToKnowhere, uploadSourceToKnowhere: defaultSourceService.uploadSourceToKnowhere, @@ -65,10 +59,6 @@ function createSourceRouteDependencies( return { ...defaultDependencies, ...overrides, - demoApi: { - ...defaultDependencies.demoApi, - ...overrides.demoApi, - }, sourceService: { ...defaultDependencies.sourceService, ...overrides.sourceService, diff --git a/src/domains/sources/route-listing.ts b/src/domains/sources/route-listing.ts index 9476285..80332c9 100644 --- a/src/domains/sources/route-listing.ts +++ b/src/domains/sources/route-listing.ts @@ -1,19 +1,12 @@ import { Effect } from "effect" -import { demoView } from "@/domains/demo/view" -import { - getMaterializedDemoSourceViewOptionsBySourceId, - getWorkspaceSourcesNeedingKnowhereChunkCount, - resolveWorkspaceDemoSources, -} from "@/domains/demo/workspace-source-resolution" import { routeResult } from "@/lib/route-result" import { logger } from "@/lib/logger" -import { knowhereDemoApi } from "@/integrations/knowhere-demo" import { toSourceView } from "./view" import { startBackgroundReconciliation as defaultStartBackgroundReconciliation, } from "./background-reconcile" -import { listRemoteLibrarySourceViews } from "./remote-library" +import { localizeRemoteLibrarySources } from "./remote-library" import type { Source } from "@/infrastructure/db/schema" import type { JsonRouteResult, @@ -31,13 +24,9 @@ type RouteListingDependencies = Pick< | "listSourcesForWorkspace" | "makeKnowhereClient" > & { - readonly demoApi: Pick< - SourceRouteServiceDependencies["demoApi"], - "fetchCatalog" - > readonly sourceService: Pick< SourceRouteServiceDependencies["sourceService"], - "listHiddenDemoSourceIds" | "localizeRemoteDocument" + "localizeRemoteDocument" > readonly reconcileSourcesForWorkspace: SourceRouteServiceDependencies[ "reconcileSourcesForWorkspace" @@ -69,15 +58,9 @@ const listSourcesEffect = ( Effect.gen(function* () { const user = yield* Effect.tryPromise(() => deps.getCurrentUser()) if (!user) { - const catalog = yield* Effect.tryPromise(() => deps.demoApi.fetchCatalog()) - return routeResult.ok({ - sources: catalog.sources.map(demoView.toSourceView), - }) + return routeResult.ok({ sources: [] }) } - const catalog = yield* Effect.tryPromise(() => - knowhereDemoApi.fetchOptionalCatalog(deps.demoApi.fetchCatalog), - ) const workspace = yield* Effect.tryPromise(() => deps.ensureWorkspace(user.id), ) @@ -89,17 +72,24 @@ const listSourcesEffect = ( ) const client = deps.makeKnowhereClient(apiKey) const sources = listedSources - const demoSourceResolution = resolveWorkspaceDemoSources(sources, catalog) - const workspaceSources = demoSourceResolution.workspaceSources - const remoteSourceViews = yield* listRemoteLibrarySourceViews({ + const workspaceSources = sources + const localizedSources = yield* localizeRemoteLibrarySources({ workspace, client, - localSources: demoSourceResolution.workspaceSources, + localSources: workspaceSources, + localizeDocument: (document) => + deps.sourceService.localizeRemoteDocument(workspace.id, { + documentId: document.documentId, + namespace: document.namespace, + status: document.status, + title: document.title, + mimeType: document.mimeType, + sizeBytes: document.sizeBytes, + revisionKey: document.revisionKey ?? null, + }), }) const sourcesNeedingKnowhereChunkCount = - getWorkspaceSourcesNeedingKnowhereChunkCount(workspaceSources) - const materializedDemoSourceOptions = - getMaterializedDemoSourceViewOptionsBySourceId(workspaceSources, catalog) + getWorkspaceSourcesNeedingKnowhereChunkCount(localizedSources) yield* Effect.sync(() => triggerBackgroundReconciliationForParsingSources({ workspaceId: workspace.id, @@ -114,38 +104,24 @@ const listSourcesEffect = ( sourcesNeedingKnowhereChunkCount, client, ) - const hiddenDemoSourceIds = new Set( - yield* Effect.tryPromise(() => - deps.sourceService.listHiddenDemoSourceIds(workspace.id), - ), - ) - const visibleDemoSources = catalog.sources - .filter( - (source) => - !demoSourceResolution.materializedDemoSourceIds.has( - source.demoSourceId, - ), - ) - .filter((source) => !hiddenDemoSourceIds.has(source.demoSourceId)) - .map(demoView.toSourceView) return routeResult.ok({ - sources: [ - ...visibleDemoSources, - ...workspaceSources.map((source) => - toSourceView( - source, - materializedDemoSourceOptions.get(source.id) ?? - sourceOptions.get(source.id), - ), - ), - ...remoteSourceViews, - ], + sources: localizedSources.map((source) => + toSourceView(source, sourceOptions.get(source.id)), + ), }) }) export { createRouteListing } +function getWorkspaceSourcesNeedingKnowhereChunkCount( + sources: readonly Source[], +): readonly Source[] { + return sources.filter( + (source) => source.status === "ready" && source.knowhereDocumentId, + ) +} + function triggerBackgroundReconciliationForParsingSources(input: { readonly workspaceId: string readonly sources: readonly Source[] diff --git a/src/domains/sources/route-service.test.ts b/src/domains/sources/route-service.test.ts index 3d11b96..2260bb2 100644 --- a/src/domains/sources/route-service.test.ts +++ b/src/domains/sources/route-service.test.ts @@ -5,7 +5,6 @@ import type { Job } from "@ontos-ai/knowhere-sdk"; import type { Source, Workspace } from "@/infrastructure/db/schema"; import { createRouteListing } from "./route-listing"; import { createSourceRouteService } from "./route-service"; -import type { DemoCatalog } from "@/integrations/knowhere-demo"; const workspace: Workspace = { id: "workspace_1", @@ -28,7 +27,6 @@ const source: Source = { stagedBlobUrl: null, originalBlobPathname: null, originalBlobUrl: null, - demoKey: null, createdAt: new Date("2026-05-10T00:00:00Z"), updatedAt: new Date("2026-05-10T00:00:00Z"), deletedAt: null, @@ -64,11 +62,7 @@ describe("source route service", () => { const listSourcesForWorkspace = vi.fn(async () => [source]); const reconcileSourcesForWorkspace = vi.fn(async () => [source]); const startBackgroundReconciliation = vi.fn(async () => undefined); - const listHiddenDemoSourceIds = vi.fn(async () => []); const listing = createRouteListing({ - demoApi: { - fetchCatalog: vi.fn(async () => emptyDemoCatalog), - }, ensureApiKeyForWorkspace, ensureWorkspace: vi.fn(async () => workspace), getCurrentUser: vi.fn(async () => ({ @@ -82,7 +76,6 @@ describe("source route service", () => { reconcileSourcesForWorkspace, startBackgroundReconciliation, sourceService: { - listHiddenDemoSourceIds, localizeRemoteDocument: localizeNoRemoteDocuments, }, }); @@ -116,7 +109,6 @@ describe("source route service", () => { source.id, "jwt_123", ); - expect(listHiddenDemoSourceIds).toHaveBeenCalledWith(workspace.id); }); it("lists shared default and legacy namespace documents as lightweight remote sources", async () => { @@ -200,11 +192,17 @@ describe("source route service", () => { upload: vi.fn(), }, }; - const localizeRemoteDocument = vi.fn(); + const localizeRemoteDocument = vi.fn(async (_workspaceId: string, input: { documentId: string; title?: string; mimeType?: string }) => ({ + ...source, + id: `source_${input.documentId}`, + workspaceId: _workspaceId, + title: input.title ?? input.documentId, + mimeType: input.mimeType ?? "application/octet-stream", + status: "ready" as const, + knowhereJobId: null, + knowhereDocumentId: input.documentId, + })) as unknown as Parameters[0]["sourceService"]["localizeRemoteDocument"]; const listing = createRouteListing({ - demoApi: { - fetchCatalog: vi.fn(async () => emptyDemoCatalog), - }, ensureApiKeyForWorkspace: vi.fn(async () => "jwt_123"), ensureWorkspace: vi.fn(async () => workspace), getCurrentUser: vi.fn(async () => ({ @@ -217,7 +215,6 @@ describe("source route service", () => { listSourcesForWorkspace: vi.fn(async () => [localReadySource]), reconcileSourcesForWorkspace: vi.fn(async () => [localReadySource]), sourceService: { - listHiddenDemoSourceIds: vi.fn(async () => []), localizeRemoteDocument, }, }); @@ -239,7 +236,10 @@ describe("source route service", () => { page: 1, pageSize: 200, }); - expect(localizeRemoteDocument).not.toHaveBeenCalled(); + expect(localizeRemoteDocument).toHaveBeenCalledTimes(2); + expect(localizeRemoteDocument).toHaveBeenCalledWith(workspace.id, expect.objectContaining({ documentId: "doc_default" })); + expect(localizeRemoteDocument).toHaveBeenCalledWith(workspace.id, expect.objectContaining({ documentId: "doc_legacy" })); + expect(localizeRemoteDocument).not.toHaveBeenCalledWith(workspace.id, expect.objectContaining({ documentId: "doc_local" })); expect(result.body.sources).toEqual([ expect.objectContaining({ id: "source_ready", @@ -247,26 +247,22 @@ describe("source route service", () => { title: "notes.pdf", status: "ready", }), - { - id: "knowhere-doc:default:doc_default", - kind: "remote", - namespace: "default", + expect.objectContaining({ + id: "source_doc_default", + kind: "workspace", title: "cli.pdf", mimeType: "application/pdf", status: "ready", documentId: "doc_default", - excludedFromQuery: true, - }, - { - id: "knowhere-doc:notebook-workspace_1:doc_legacy", - kind: "remote", - namespace: workspace.namespace, + }), + expect.objectContaining({ + id: "source_doc_legacy", + kind: "workspace", title: "legacy.pdf", mimeType: "application/octet-stream", status: "ready", documentId: "doc_legacy", - excludedFromQuery: true, - }, + }), ]); }); @@ -324,9 +320,6 @@ describe("source route service", () => { const localizeRemoteDocument = vi.fn(async () => parsingSource); const startBackgroundReconciliation = vi.fn(async () => undefined); const listing = createRouteListing({ - demoApi: { - fetchCatalog: vi.fn(async () => emptyDemoCatalog), - }, ensureApiKeyForWorkspace: vi.fn(async () => "jwt_123"), ensureWorkspace: vi.fn(async () => workspace), getCurrentUser: vi.fn(async () => ({ @@ -340,7 +333,6 @@ describe("source route service", () => { reconcileSourcesForWorkspace, startBackgroundReconciliation, sourceService: { - listHiddenDemoSourceIds: vi.fn(async () => []), localizeRemoteDocument, }, }); @@ -364,320 +356,9 @@ describe("source route service", () => { ]); }); - it("lists authenticated workspace sources when the demo catalog is unavailable", async () => { - const legacyFakeSource: Source = { - ...source, - id: "source_legacy_demo", - status: "ready", - demoKey: "demo-tsla-q4-2025", - knowhereJobId: null, - knowhereDocumentId: "demo-doc-tsla-q4-2025", - }; - const knowhereClient = { - documents: { - archive: vi.fn(async () => undefined), - listChunks: vi.fn(async () => ({ - chunks: [], - pagination: { - page: 1, - pageSize: 1, - total: 0, - totalPages: 0, - }, - })), - }, - jobs: { - create: vi.fn(), - get: vi.fn(), - upload: vi.fn(), - }, - }; - const getSourceViewOptionsBySourceId = vi.fn(() => - Effect.succeed(new Map([[source.id, { chunkCount: 8 }]])), - ); - const listing = createRouteListing({ - demoApi: { - fetchCatalog: vi.fn(async () => { - throw new Error("Demo API unavailable."); - }), - }, - ensureApiKeyForWorkspace: vi.fn(async () => "jwt_123"), - ensureWorkspace: vi.fn(async () => workspace), - getCurrentUser: vi.fn(async () => ({ - id: "user_1", - email: null, - name: null, - })), - getSourceViewOptionsBySourceId, - makeKnowhereClient: vi.fn(() => knowhereClient), - listSourcesForWorkspace: vi.fn(async () => [legacyFakeSource, source]), - reconcileSourcesForWorkspace: vi.fn(async () => [ - legacyFakeSource, - source, - ]), - sourceService: { - listHiddenDemoSourceIds: vi.fn(async () => []), - localizeRemoteDocument: localizeNoRemoteDocuments, - }, - }); - - const result = await listing.listSources({ cookieHeader: "session=abc" }); - - expect(getSourceViewOptionsBySourceId).toHaveBeenCalledWith( - [source], - knowhereClient, - ); - expect(result).toEqual({ - status: 200, - body: { - sources: [ - { - id: "source_1", - kind: "workspace", - title: "notes.pdf", - status: "parsing", - mimeType: "application/pdf", - documentId: undefined, - chunkCount: 8, - }, - ], - }, - }); - }); - - it("keeps API-owned demos visible when a legacy fake demo row exists", async () => { - const legacyFakeSource: Source = { - ...source, - id: "source_legacy_demo", - status: "ready", - demoKey: "demo-tsla-q4-2025", - knowhereJobId: null, - knowhereDocumentId: "demo-doc-tsla-q4-2025", - }; - const knowhereClient = { - documents: { - archive: vi.fn(async () => undefined), - listChunks: vi.fn(async () => ({ - chunks: [], - pagination: { - page: 1, - pageSize: 1, - total: 0, - totalPages: 0, - }, - })), - }, - jobs: { - create: vi.fn(), - get: vi.fn(), - upload: vi.fn(), - }, - }; - const getSourceViewOptionsBySourceId = vi.fn(() => Effect.succeed(new Map())); - const listing = createRouteListing({ - demoApi: { - fetchCatalog: vi.fn(async () => demoCatalog), - }, - ensureApiKeyForWorkspace: vi.fn(async () => "jwt_123"), - ensureWorkspace: vi.fn(async () => workspace), - getCurrentUser: vi.fn(async () => ({ - id: "user_1", - email: null, - name: null, - })), - getSourceViewOptionsBySourceId, - makeKnowhereClient: vi.fn(() => knowhereClient), - listSourcesForWorkspace: vi.fn(async () => [legacyFakeSource]), - reconcileSourcesForWorkspace: vi.fn(async () => [legacyFakeSource]), - sourceService: { - listHiddenDemoSourceIds: vi.fn(async () => []), - localizeRemoteDocument: localizeNoRemoteDocuments, - }, - }); - - const result = await listing.listSources({ cookieHeader: "session=abc" }); - - expect(getSourceViewOptionsBySourceId).toHaveBeenCalledWith( - [], - knowhereClient, - ); - expect(result).toEqual({ - status: 200, - body: { - sources: [ - { - id: "demo-tsla-q4-2025", - kind: "demo", - demoSourceId: "demo-tsla-q4-2025", - title: "TSLA-Q4-2025-Update.pdf", - mimeType: "application/pdf", - status: "ready", - documentId: "demo-doc-tsla-q4-2025", - originalFile: { - url: "https://example.com/tsla-q4-2025.pdf", - mimeType: "application/pdf", - sizeBytes: 1024, - canDownload: false, - pdfPreviewMode: "browser", - }, - chunkCount: 70, - }, - ], - }, - }); - }); - - it("keeps API-owned demos visible when a non-ready legacy demo row exists", async () => { - const nonReadyLegacySource: Source = { - ...source, - id: "source_non_ready_legacy_demo", - status: "parsing", - demoKey: "demo-tsla-q4-2025", - knowhereJobId: null, - knowhereDocumentId: null, - }; - const knowhereClient = { - documents: { - archive: vi.fn(async () => undefined), - listChunks: vi.fn(async () => ({ - chunks: [], - pagination: { - page: 1, - pageSize: 1, - total: 0, - totalPages: 0, - }, - })), - }, - jobs: { - create: vi.fn(), - get: vi.fn(), - upload: vi.fn(), - }, - }; - const getSourceViewOptionsBySourceId = vi.fn(() => Effect.succeed(new Map())); - const listing = createRouteListing({ - demoApi: { - fetchCatalog: vi.fn(async () => demoCatalog), - }, - ensureApiKeyForWorkspace: vi.fn(async () => "jwt_123"), - ensureWorkspace: vi.fn(async () => workspace), - getCurrentUser: vi.fn(async () => ({ - id: "user_1", - email: null, - name: null, - })), - getSourceViewOptionsBySourceId, - makeKnowhereClient: vi.fn(() => knowhereClient), - listSourcesForWorkspace: vi.fn(async () => [nonReadyLegacySource]), - reconcileSourcesForWorkspace: vi.fn(async () => [nonReadyLegacySource]), - sourceService: { - listHiddenDemoSourceIds: vi.fn(async () => []), - localizeRemoteDocument: localizeNoRemoteDocuments, - }, - }); - - const result = await listing.listSources({ cookieHeader: "session=abc" }); - - expect(getSourceViewOptionsBySourceId).toHaveBeenCalledWith( - [], - knowhereClient, - ); - expect(result).toEqual({ - status: 200, - body: { - sources: [ - expect.objectContaining({ - id: "demo-tsla-q4-2025", - kind: "demo", - demoSourceId: "demo-tsla-q4-2025", - }), - ], - }, - }); - }); - - it("uses demo catalog counts for materialized demo sources", async () => { - const materializedSource: Source = { - ...source, - id: "source_demo", - title: "TSLA-Q4-2025-Update.pdf", - status: "ready", - demoKey: "demo-tsla-q4-2025", - knowhereJobId: null, - knowhereDocumentId: "doc_user_copy", - originalBlobUrl: "/api/demo-sources/demo-tsla-q4-2025/original", - }; - const knowhereClient = { - documents: { - archive: vi.fn(async () => undefined), - listChunks: vi.fn(async () => ({ - chunks: [], - pagination: { - page: 1, - pageSize: 1, - total: 0, - totalPages: 0, - }, - })), - }, - jobs: { - create: vi.fn(), - get: vi.fn(), - upload: vi.fn(), - }, - }; - const getSourceViewOptionsBySourceId = vi.fn(() => Effect.succeed(new Map())); - const listing = createRouteListing({ - demoApi: { - fetchCatalog: vi.fn(async () => demoCatalog), - }, - ensureApiKeyForWorkspace: vi.fn(async () => "jwt_123"), - ensureWorkspace: vi.fn(async () => workspace), - getCurrentUser: vi.fn(async () => ({ - id: "user_1", - email: null, - name: null, - })), - getSourceViewOptionsBySourceId, - makeKnowhereClient: vi.fn(() => knowhereClient), - listSourcesForWorkspace: vi.fn(async () => [materializedSource]), - reconcileSourcesForWorkspace: vi.fn(async () => [materializedSource]), - sourceService: { - listHiddenDemoSourceIds: vi.fn(async () => []), - localizeRemoteDocument: localizeNoRemoteDocuments, - }, - }); - - const result = await listing.listSources({ cookieHeader: "session=abc" }); - - expect(getSourceViewOptionsBySourceId).toHaveBeenCalledWith( - [], - knowhereClient, - ); - expect(knowhereClient.documents.listChunks).not.toHaveBeenCalled(); - expect(result).toEqual({ - status: 200, - body: { - sources: [ - expect.objectContaining({ - id: "source_demo", - kind: "workspace", - demoSourceId: "demo-tsla-q4-2025", - documentId: "doc_user_copy", - chunkCount: 70, - }), - ], - }, - }); - }); - - it("lists API-owned demo sources for anonymous users", async () => { + it("lists no sources for anonymous users", async () => { const ensureWorkspace = vi.fn(async () => workspace); const service = createSourceRouteService({ - demoApi: { - fetchCatalog: vi.fn(async () => demoCatalog), - }, ensureWorkspace, getCurrentUser: vi.fn(async () => null), }); @@ -687,25 +368,7 @@ describe("source route service", () => { expect(result).toEqual({ status: 200, body: { - sources: [ - { - id: "demo-tsla-q4-2025", - kind: "demo", - demoSourceId: "demo-tsla-q4-2025", - title: "TSLA-Q4-2025-Update.pdf", - mimeType: "application/pdf", - status: "ready", - documentId: "demo-doc-tsla-q4-2025", - originalFile: { - url: "https://example.com/tsla-q4-2025.pdf", - mimeType: "application/pdf", - sizeBytes: 1024, - canDownload: false, - pdfPreviewMode: "browser", - }, - chunkCount: 70, - }, - ], + sources: [], }, }); expect(ensureWorkspace).not.toHaveBeenCalled(); @@ -913,30 +576,3 @@ describe("source route service", () => { expect(retrySourceToKnowhere).not.toHaveBeenCalled(); }); }); - -const emptyDemoCatalog: DemoCatalog = { - officialLibrary: { categories: [], sources: [] }, - sources: [], -}; - -const demoCatalog: DemoCatalog = { - officialLibrary: { categories: [], sources: [] }, - sources: [ - { - demoSourceId: "demo-tsla-q4-2025", - canonicalDocumentId: "demo-doc-tsla-q4-2025", - title: "TSLA-Q4-2025-Update.pdf", - mimeType: "application/pdf", - sizeBytes: 1024, - status: "ready", - chunkCount: 70, - originalFile: { - url: "https://example.com/tsla-q4-2025.pdf", - mimeType: "application/pdf", - sizeBytes: 1024, - canDownload: false, - }, - examples: [], - }, - ], -}; diff --git a/src/domains/sources/route-types.ts b/src/domains/sources/route-types.ts index 7303ec3..e27b2b8 100644 --- a/src/domains/sources/route-types.ts +++ b/src/domains/sources/route-types.ts @@ -11,10 +11,6 @@ import type { ParsedChunkView } from "@/domains/chunks/types" import type { SourceStatus, SourceView } from "@/domains/sources/types" import type { AuthUser } from "@/infrastructure/auth" import type { Source, Workspace } from "@/infrastructure/db/schema" -import type { - DemoCatalog, - DemoChunkPage, -} from "@/integrations/knowhere-demo" import type { RouteResult } from "@/lib/route-result" import type { SourceBlobUploadInput } from "./blob-upload" import type { sourceViewOptionsBySourceId } from "./counts" @@ -183,11 +179,6 @@ type SourceWorkflowService = { workspaceId: string, sourceId: string, ) => Promise>> - readonly hideDemoSource: ( - workspaceId: string, - demoSourceId: string, - ) => Promise - readonly listHiddenDemoSourceIds: (workspaceId: string) => Promise readonly localizeRemoteDocument: ( workspaceId: string, input: { @@ -205,31 +196,10 @@ type SourceWorkflowService = { sourceId: string, revisionKey: string, ) => Promise - readonly upsertMaterializedDemoSource: ( - workspaceId: string, - input: { - readonly demoSourceId: string - readonly title: string - readonly mimeType: string - readonly sizeBytes: number - readonly knowhereDocumentId: string - readonly originalBlobUrl: string | null - }, - ) => Promise -} - -type SourceRouteDemoApi = { - readonly fetchCatalog: () => Promise - readonly fetchChunkPage: (input: { - readonly demoSourceId: string - readonly page: number - readonly pageSize: number - }) => Promise } type SourceRouteServiceDependencies = { readonly deleteBlob: (pathname: string) => Promise - readonly demoApi: SourceRouteDemoApi readonly ensureApiKeyForWorkspace: ( workspaceId: string, cookieHeader: string, @@ -253,9 +223,8 @@ type SourceRouteServiceDependencies = { } type SourceRouteServiceOverrides = Partial< - Omit + Omit > & { - readonly demoApi?: Partial readonly sourceService?: Partial } @@ -269,7 +238,6 @@ export type { RetrySourceBody, RetrySourceInput, SourceChunksBody, - SourceRouteDemoApi, SourceRouteKnowhereClient, SourceRouteService, SourceRouteServiceDependencies, diff --git a/src/domains/sources/service.ts b/src/domains/sources/service.ts index 0d7c7f6..1f85be6 100644 --- a/src/domains/sources/service.ts +++ b/src/domains/sources/service.ts @@ -31,21 +31,10 @@ type SourceService = { sourceId: string, revisionKey: string, ) => Promise - readonly listHiddenDemoSourceIds: (workspaceId: string) => Promise - readonly hideDemoSource: ( - workspaceId: string, - demoSourceId: string, - ) => Promise readonly softDelete: ( workspaceId: string, sourceId: string, ) => Promise - readonly upsertMaterializedDemoSource: ( - workspaceId: string, - input: Parameters< - typeof sourceWorkflowRuntime.upsertMaterializedDemoSource - >[1], - ) => Promise readonly uploadSourceToKnowhere: ( workspace: Workspace, file: File, @@ -106,14 +95,10 @@ const retrySourceToKnowhere: SourceService["retrySourceToKnowhere"] = ( export const sourceService: SourceService = { findInWorkspace: sourceWorkflowRuntime.findInWorkspace, getParseAssetUrls: sourceWorkflowRuntime.getParseAssetUrls, - hideDemoSource: sourceWorkflowRuntime.hideDemoSource, - listHiddenDemoSourceIds: sourceWorkflowRuntime.listHiddenDemoSourceIds, listForWorkspace: sourceWorkflowRuntime.listForWorkspace, localizeRemoteDocument: sourceWorkflowRuntime.localizeRemoteDocument, updateSourceRevisionKey: sourceWorkflowRuntime.updateRevisionKey, softDelete: sourceWorkflowRuntime.softDelete, - upsertMaterializedDemoSource: - sourceWorkflowRuntime.upsertMaterializedDemoSource, uploadSourceToKnowhere, uploadSourceBlobToKnowhere, retrySourceToKnowhere, diff --git a/src/domains/sources/source-reconcile-workflow.test.ts b/src/domains/sources/source-reconcile-workflow.test.ts index 3f0632e..8297618 100644 --- a/src/domains/sources/source-reconcile-workflow.test.ts +++ b/src/domains/sources/source-reconcile-workflow.test.ts @@ -29,7 +29,6 @@ function makeSource(overrides: Partial = {}): Source { stagedBlobUrl: null, originalBlobPathname: null, originalBlobUrl: null, - demoKey: null, createdAt: new Date("2026-05-06T00:00:00Z"), updatedAt: new Date("2026-05-06T00:00:00Z"), deletedAt: null, diff --git a/src/domains/sources/source-row-repository.test.ts b/src/domains/sources/source-row-repository.test.ts index 4b2833f..b062b08 100644 --- a/src/domains/sources/source-row-repository.test.ts +++ b/src/domains/sources/source-row-repository.test.ts @@ -97,7 +97,6 @@ async function captureLocalizeConflictSet(input: { stagedBlobUrl: null, originalBlobPathname: null, originalBlobUrl: null, - demoKey: null, createdAt: new Date("2026-06-26T00:00:00Z"), updatedAt: new Date("2026-06-26T00:00:00Z"), deletedAt: null, diff --git a/src/domains/sources/source-row-repository.ts b/src/domains/sources/source-row-repository.ts index 77c56dd..e042170 100644 --- a/src/domains/sources/source-row-repository.ts +++ b/src/domains/sources/source-row-repository.ts @@ -357,7 +357,6 @@ async function localizeRemoteDocumentWithDb( stagedBlobUrl: null, originalBlobPathname: null, originalBlobUrl: null, - demoKey: null, } const [source] = await db diff --git a/src/domains/sources/types.ts b/src/domains/sources/types.ts index e1abe9e..81c8427 100644 --- a/src/domains/sources/types.ts +++ b/src/domains/sources/types.ts @@ -8,25 +8,7 @@ export type SourceOriginalFileView = { readonly pdfPreviewMode?: "browser" } -export type SourceKind = "workspace" | "demo" | "remote" - -export type SourceOfficialLibraryView = { - readonly librarySourceId: string - readonly categoryId: string - readonly sourceUrl: string -} - -export type OfficialLibrarySourceView = { - readonly librarySourceId: string - readonly categoryId: string - readonly categoryLabel: string - readonly title: string - readonly sourceUrl: string - readonly mimeType: string - readonly status: "ready" | "planned" - readonly demoSourceId?: string - readonly chunkCount?: number -} +export type SourceKind = "workspace" | "remote" /** * Sources sidebar row. Metadata-only, per the MVP persistence rule. @@ -34,7 +16,6 @@ export type OfficialLibrarySourceView = { export type SourceView = { readonly id: string readonly kind?: SourceKind - readonly demoSourceId?: string readonly namespace?: string readonly title: string /** Browser-provided content type for preview routing. */ @@ -46,8 +27,6 @@ export type SourceView = { readonly documentId?: string /** Public Blob URL for original-file preview and download. */ readonly originalFile?: SourceOriginalFileView - /** Official Library metadata when this row is an API-owned catalog item. */ - readonly officialLibrary?: SourceOfficialLibraryView /** Count from the Knowhere chunks API, not a local aggregate. */ readonly chunkCount?: number /** User opt-out for this query session. Drives excludeDocumentIds. */ diff --git a/src/domains/sources/upload.test.ts b/src/domains/sources/upload.test.ts index 912ab9d..0bf8662 100644 --- a/src/domains/sources/upload.test.ts +++ b/src/domains/sources/upload.test.ts @@ -29,7 +29,6 @@ function makeSource(overrides: Partial = {}): Source { stagedBlobUrl: null, originalBlobPathname: null, originalBlobUrl: null, - demoKey: null, createdAt: new Date("2026-05-06T00:00:00Z"), updatedAt: new Date("2026-05-06T00:00:00Z"), deletedAt: null, diff --git a/src/domains/sources/view.test.ts b/src/domains/sources/view.test.ts index e854c29..f9245b5 100644 --- a/src/domains/sources/view.test.ts +++ b/src/domains/sources/view.test.ts @@ -18,7 +18,6 @@ function makeSource(overrides: Partial = {}): Source { stagedBlobUrl: null, originalBlobPathname: null, originalBlobUrl: null, - demoKey: null, createdAt: new Date("2026-05-06T00:00:00Z"), updatedAt: new Date("2026-05-06T00:00:00Z"), deletedAt: null, @@ -94,44 +93,4 @@ describe("toSourceView", () => { }); }); - it("hides the download action for persisted demo originals", () => { - expect( - toSourceView( - makeSource({ - demoKey: "demo-tsla-q4-2025", - title: "TSLA-Q4-2025-Update.pdf", - mimeType: "application/pdf", - sizeBytes: 5648867, - knowhereDocumentId: "doc_user_copy", - originalBlobUrl: "https://example.com/tsla-q4-2025.pdf", - }), - { chunkCount: 70 }, - ), - ).toMatchObject({ - title: "TSLA-Q4-2025-Update.pdf", - demoSourceId: "demo-tsla-q4-2025", - documentId: "doc_user_copy", - chunkCount: 70, - originalFile: { - url: "https://example.com/tsla-q4-2025.pdf", - canDownload: false, - pdfPreviewMode: "browser", - }, - }); - }); - - it("does not expose legacy demo original proxy routes", () => { - const view = toSourceView( - makeSource({ - demoKey: "demo-tsla-q4-2025", - title: "TSLA-Q4-2025-Update.pdf", - mimeType: "application/pdf", - knowhereDocumentId: "doc_user_copy", - originalBlobUrl: "/api/demo-sources/demo-tsla-q4-2025/original", - }), - ); - - expect(view.demoSourceId).toBe("demo-tsla-q4-2025"); - expect(view.originalFile).toBeUndefined(); - }); }); diff --git a/src/domains/sources/view.ts b/src/domains/sources/view.ts index 4096a81..a995e70 100644 --- a/src/domains/sources/view.ts +++ b/src/domains/sources/view.ts @@ -28,7 +28,6 @@ export function toSourceView( title: source.title, mimeType: source.mimeType, status, - ...(source.demoKey ? { demoSourceId: source.demoKey } : {}), documentId: source.knowhereDocumentId ?? undefined, ...(failureMessage ? { failureMessage } : {}), ...(originalFile ? { originalFile } : {}), @@ -48,34 +47,10 @@ function getSourceOriginalFile( source: Source, ): SourceView["originalFile"] | undefined { if (!source.originalBlobUrl) return undefined - if (source.demoKey && !isPublicDemoOriginalUrl(source.originalBlobUrl)) { - return undefined - } return { url: source.originalBlobUrl, mimeType: source.mimeType, sizeBytes: source.sizeBytes, - ...(source.demoKey ? { canDownload: false } : {}), - ...(source.demoKey ? { pdfPreviewMode: "browser" as const } : {}), - } -} - -function isPublicDemoOriginalUrl(value: string): boolean { - try { - const parsedUrl = new URL(value) - if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") { - return false - } - return !isDemoOriginalProxyPath(parsedUrl.pathname) - } catch { - return false - } -} - -function isDemoOriginalProxyPath(pathname: string): boolean { - return ( - /^\/api\/v1\/demo\/sources\/[^/]+\/original\/?$/.test(pathname) || - /^\/api\/demo-sources\/[^/]+\/original\/?$/.test(pathname) - ) + }; } diff --git a/src/domains/sources/workflow-runtime.test.ts b/src/domains/sources/workflow-runtime.test.ts index 3cc714a..7dae7ee 100644 --- a/src/domains/sources/workflow-runtime.test.ts +++ b/src/domains/sources/workflow-runtime.test.ts @@ -37,7 +37,6 @@ function makeSource(status: Source["status"]): Source { stagedBlobUrl: null, originalBlobPathname: null, originalBlobUrl: null, - demoKey: null, createdAt: new Date("2026-05-10T00:00:00.000Z"), updatedAt: new Date("2026-05-10T00:00:00.000Z"), deletedAt: null, diff --git a/src/domains/sources/workflow-runtime.ts b/src/domains/sources/workflow-runtime.ts index bbec158..e87efb0 100644 --- a/src/domains/sources/workflow-runtime.ts +++ b/src/domains/sources/workflow-runtime.ts @@ -17,10 +17,6 @@ type SaveSourceParseResultInput = Parameters< typeof sourceRepository.saveParseResultEffect >[2] -type UpsertMaterializedDemoSourceInput = Parameters< - typeof sourceRepository.upsertMaterializedDemoSourceEffect ->[1] - type UploadRepositoryRuntime = { readonly createUploading: ( workspaceId: string, @@ -69,11 +65,6 @@ type SourceWorkflowRuntime = UploadRepositoryRuntime & { workspaceId: string, input: LocalizeRemoteDocumentInput, ) => Promise - readonly listHiddenDemoSourceIds: (workspaceId: string) => Promise - readonly hideDemoSource: ( - workspaceId: string, - demoSourceId: string, - ) => Promise readonly markReady: ( workspaceId: string, sourceId: string, @@ -98,10 +89,6 @@ type SourceWorkflowRuntime = UploadRepositoryRuntime & { workspaceId: string, sourceId: string, ) => Promise - readonly upsertMaterializedDemoSource: ( - workspaceId: string, - input: UpsertMaterializedDemoSourceInput, - ) => Promise } const findInWorkspace: SourceWorkflowRuntime["findInWorkspace"] = ( @@ -123,20 +110,6 @@ const localizeRemoteDocument: SourceWorkflowRuntime["localizeRemoteDocument"] = sourceRepository.localizeRemoteDocumentEffect(workspaceId, input), ) -const listHiddenDemoSourceIds: SourceWorkflowRuntime["listHiddenDemoSourceIds"] = - (workspaceId: string) => - databaseRuntime.runPromise( - sourceRepository.listHiddenDemoSourceIdsEffect(workspaceId), - ) - -const hideDemoSource: SourceWorkflowRuntime["hideDemoSource"] = ( - workspaceId: string, - demoSourceId: string, -) => - databaseRuntime.runPromise( - sourceRepository.hideDemoSourceEffect(workspaceId, demoSourceId), - ) - const createUploading: SourceWorkflowRuntime["createUploading"] = ( workspaceId: string, input: CreateUploadingSourceInput, @@ -210,12 +183,6 @@ const softDelete: SourceWorkflowRuntime["softDelete"] = ( sourceRepository.softDeleteEffect(workspaceId, sourceId), ) -const upsertMaterializedDemoSource: SourceWorkflowRuntime["upsertMaterializedDemoSource"] = - (workspaceId: string, input: UpsertMaterializedDemoSourceInput) => - databaseRuntime.runPromise( - sourceRepository.upsertMaterializedDemoSourceEffect(workspaceId, input), - ) - const saveParseResult: SourceWorkflowRuntime["saveParseResult"] = ( workspaceId: string, sourceId: string, @@ -287,9 +254,7 @@ export const sourceWorkflowRuntime: SourceWorkflowRuntime = { findInWorkspace, getParseAssetUrls, getParseResultProgress, - hideDemoSource, listForWorkspace, - listHiddenDemoSourceIds, localizeRemoteDocument, markFailed, markParsing, @@ -298,5 +263,4 @@ export const sourceWorkflowRuntime: SourceWorkflowRuntime = { mergeParseAssetUrls, saveParseResult, softDelete, - upsertMaterializedDemoSource, } diff --git a/src/domains/workspace/client.test.ts b/src/domains/workspace/client.test.ts index 42e8c65..7d02880 100644 --- a/src/domains/workspace/client.test.ts +++ b/src/domains/workspace/client.test.ts @@ -63,19 +63,6 @@ describe("workspaceClient", () => { }) }) - it("throws materialization route errors instead of treating them as empty sources", async () => { - mockRouteClient.postJsonWithStatus.mockResolvedValue({ - status: 502, - body: { message: "Demo sources could not be prepared right now." }, - }) - - await expect( - workspaceClient.materializeDemoSources({ - demoSourceIds: ["demo-tsla-q4-2025"], - }), - ).rejects.toThrow("Demo sources could not be prepared right now.") - }) - it("retries a source with an encoded source id", async () => { mockRouteClient.patchJsonWithStatus.mockResolvedValue({ status: 200, diff --git a/src/domains/workspace/client.ts b/src/domains/workspace/client.ts index 9a7b3cd..3e97966 100644 --- a/src/domains/workspace/client.ts +++ b/src/domains/workspace/client.ts @@ -12,7 +12,7 @@ const workspaceClientKeys = { chatThreads: "/api/chat/threads", chatDiagram: "/api/chat/diagram", chat: "/api/chat", - materializeDemoSources: "/api/demo-sources/materialize", + namespaces: "/api/namespaces", archiveSource: "archive-source", retrySource: "retry-source", archiveChatThread: "archive-chat-thread", @@ -48,10 +48,6 @@ type ChatMessageRequest = { excludedSourceIds: string[] } -type MaterializeDemoSourcesRequest = { - demoSourceIds: string[] -} - type SourcesResponse = { sources?: SourceView[] } @@ -85,6 +81,20 @@ type RetrySourceResponse = { message?: string } +type NamespaceView = { + namespace: string + documentCount: number +} + +type NamespacesResponse = { + namespaces?: NamespaceView[] +} + +type LocalizeNamespaceResponse = { + sources?: SourceView[] + message?: string +} + export const workspaceClient = { keys: workspaceClientKeys, fetchChunks, @@ -95,7 +105,8 @@ export const workspaceClient = { createChatThread, createChatDiagram, sendChatMessage, - materializeDemoSources, + fetchNamespaces, + localizeNamespace, archiveSource, retrySource, archiveChatThread, @@ -180,24 +191,6 @@ function sendChatMessage( ) } -async function materializeDemoSources( - input: MaterializeDemoSourcesRequest, -): Promise { - const response = await workspaceRouteClient.postJsonWithStatus< - SourcesResponse & { readonly message?: string } - >( - workspaceClientKeys.materializeDemoSources, - input, - ) - if (response.status < 200 || response.status >= 300) { - throw new Error( - response.body.message ?? "Demo sources could not be prepared right now.", - ) - } - const body = response.body - return Array.isArray(body.sources) ? body.sources : [] -} - function archiveSource(sourceId: string): Promise { return workspaceRouteClient.patchJson( `/api/sources/${encodeURIComponent(sourceId)}`, @@ -231,3 +224,25 @@ function archiveChatThread(threadId: string): Promise { }, ) } + +async function fetchNamespaces(): Promise { + const body = await workspaceRouteClient.getJson( + workspaceClientKeys.namespaces, + ) + return Array.isArray(body.namespaces) ? body.namespaces : [] +} + +async function localizeNamespace(namespace: string): Promise { + const response = await workspaceRouteClient.postJsonWithStatus< + LocalizeNamespaceResponse + >( + `/api/namespaces/${encodeURIComponent(namespace)}/localize`, + {}, + ) + if (response.status < 200 || response.status >= 300) { + throw new Error( + response.body.message ?? "Could not import documents from this namespace.", + ) + } + return Array.isArray(response.body.sources) ? response.body.sources : [] +} diff --git a/src/domains/workspace/demo-migration.test.ts b/src/domains/workspace/demo-migration.test.ts deleted file mode 100644 index 26bb6c9..0000000 --- a/src/domains/workspace/demo-migration.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { readFileSync } from "node:fs" -import { join } from "node:path" - -import { describe, expect, it } from "vitest" - -describe("demo source migration", () => { - it("backfills visibility rows for deleted legacy demo sources", () => { - const migrationSql: string = readFileSync( - join(process.cwd(), "drizzle/0007_normalize_legacy_demo_sources.sql"), - "utf8", - ) - - expect(migrationSql).toContain('INSERT INTO "demo_source_visibilities"') - expect(migrationSql).toContain('"demo_key" IS NOT NULL') - expect(migrationSql).toContain('"deleted_at" IS NOT NULL') - expect(migrationSql).toContain( - 'ON CONFLICT ("workspace_id", "demo_source_id") DO UPDATE', - ) - }) - - it("soft-deletes legacy fake demo rows regardless of readiness state", () => { - const migrationSql: string = readFileSync( - join(process.cwd(), "drizzle/0007_normalize_legacy_demo_sources.sql"), - "utf8", - ) - - expect(migrationSql).toContain('"knowhere_job_id" IS NULL') - expect(migrationSql).toContain('"knowhere_document_id" IS NULL') - expect(migrationSql).toContain('"knowhere_document_id" LIKE \'demo-doc-%\'') - expect(migrationSql).not.toContain('"status" = \'ready\'') - }) -}) diff --git a/src/domains/workspace/initial-state.test.ts b/src/domains/workspace/initial-state.test.ts index 9ea24e3..cf888fb 100644 --- a/src/domains/workspace/initial-state.test.ts +++ b/src/domains/workspace/initial-state.test.ts @@ -3,13 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest" import { loadWorkspaceShellInitialState } from "./initial-state" import type { AuthUser } from "@/infrastructure/auth" -import type { - ChatMessage, - ChatThread, - Source, - Workspace, -} from "@/infrastructure/db/schema" -import type { DemoCatalog } from "@/integrations/knowhere-demo" +import type { Source, Workspace } from "@/infrastructure/db/schema" import { formatUnknownForLog } from "@/lib/format-log-value" type InitialStateDependencies = NonNullable< @@ -31,76 +25,18 @@ describe("loadWorkspaceShellInitialState", () => { process.env.DASHBOARD_ORIGIN = originalDashboardOrigin }) - it("returns guest demo state from the Knowhere demo API only", async () => { + it("returns an empty unauthenticated state when no session is present", async () => { + process.env.DASHBOARD_ORIGIN = "https://dashboard.example" const deps = createDependencies({ getOptionalAuthenticated: vi.fn(async () => null), }) const state = await loadWorkspaceShellInitialState(deps) - expect(state.isGuest).toBe(true) - expect(state.sources).toEqual([ - { - id: "demo-tsla-q4-2025", - kind: "demo", - demoSourceId: "demo-tsla-q4-2025", - title: "TSLA-Q4-2025-Update.pdf", - mimeType: "application/pdf", - status: "ready", - documentId: "demo-doc-tsla-q4-2025", - originalFile: { - url: "https://example.com/tsla-q4-2025.pdf", - mimeType: "application/pdf", - sizeBytes: 1024, - canDownload: false, - pdfPreviewMode: "browser", - }, - officialLibrary: { - librarySourceId: "financial-tsla-q4-2025", - categoryId: "financial-reports", - sourceUrl: "https://example.com/tsla-q4-2025.pdf", - }, - chunkCount: 70, - }, - ]) - expect(state.officialLibrarySources).toEqual([ - { - librarySourceId: "financial-tsla-q4-2025", - categoryId: "financial-reports", - categoryLabel: "Financial reports", - title: "TSLA-Q4-2025-Update.pdf", - sourceUrl: "https://example.com/tsla-q4-2025.pdf", - mimeType: "application/pdf", - status: "ready", - demoSourceId: "demo-tsla-q4-2025", - chunkCount: 70, - }, - ]) - expect(state.chatMessages).toEqual([ - { - id: "demo-example-1-user", - role: "user", - content: "What happened in Tesla Q4?", - }, - { - id: "demo-example-1-assistant", - role: "assistant", - content: "Tesla delivered higher revenue.", - citations: [ - { - chunkType: "text", - score: 0.95, - content: "Automotive revenue increased.", - source: { - documentId: "demo-doc-tsla-q4-2025", - sourceFileName: "TSLA-Q4-2025-Update.pdf", - sectionPath: "Shareholder Deck", - }, - }, - ], - }, - ]) - expect(state.loginUrl).toBe("/login") + expect(state).toEqual({ + dashboardUrl: "https://dashboard.example", + sources: [], + }) expect(deps.listSourcesForWorkspace).not.toHaveBeenCalled() }) @@ -112,236 +48,6 @@ describe("loadWorkspaceShellInitialState", () => { expect(state.dashboardUrl).toBe("https://dashboard.staging.example") }) - it("lists visible API demos before authenticated workspace sources", async () => { - const workspace = makeWorkspace() - const source = makeSource(workspace.id) - const thread = makeThread(workspace.id) - const deps = createDependencies({ - listChatThreads: vi.fn(async () => [thread]), - listSourcesForWorkspace: vi.fn(async () => [source]), - sourceViewOptionsBySourceId: vi.fn(() => - Effect.succeed(new Map([[source.id, { chunkCount: 2 }]])), - ), - }) - - const state = await loadWorkspaceShellInitialState(deps) - - expect(state.isGuest).toBeUndefined() - expect(state.activeChatThreadId).toBe(thread.id) - expect(state.sources).toEqual([ - expect.objectContaining({ - id: "demo-tsla-q4-2025", - kind: "demo", - demoSourceId: "demo-tsla-q4-2025", - }), - { - id: source.id, - kind: "workspace", - title: "notes.pdf", - mimeType: "application/pdf", - status: "ready", - documentId: "document_1", - chunkCount: 2, - }, - ]) - expect(deps.ensureDemoChatThread).not.toHaveBeenCalled() - }) - - it("keeps authenticated workspace sources when the demo catalog is unavailable", async () => { - const workspace = makeWorkspace() - const source = makeSource(workspace.id) - const legacyFakeSource = makeSource(workspace.id, { - id: "source_legacy_demo", - demoKey: "demo-tsla-q4-2025", - knowhereJobId: null, - knowhereDocumentId: "demo-doc-tsla-q4-2025", - }) - const sourceViewOptionsBySourceId = vi.fn(() => - Effect.succeed(new Map([[source.id, { chunkCount: 2 }]])), - ) - const deps = createDependencies({ - fetchDemoCatalog: vi.fn(async () => { - throw new Error("Demo API unavailable.") - }), - listSourcesForWorkspace: vi.fn(async () => [legacyFakeSource, source]), - sourceViewOptionsBySourceId, - }) - - const state = await loadWorkspaceShellInitialState(deps) - - expect(sourceViewOptionsBySourceId).toHaveBeenCalledWith( - [source], - expect.any(Object), - ) - expect(state.sources).toEqual([ - { - id: source.id, - kind: "workspace", - title: "notes.pdf", - mimeType: "application/pdf", - status: "ready", - documentId: "document_1", - chunkCount: 2, - }, - ]) - }) - - it("hides canonical demos that are hidden or already materialized", async () => { - const workspace = makeWorkspace() - const materializedSource = makeSource(workspace.id, { - id: "source_demo", - demoKey: "demo-tsla-q4-2025", - title: "TSLA-Q4-2025-Update.pdf", - knowhereDocumentId: "doc_user_copy", - }) - const sourceViewOptionsBySourceId = vi.fn(() => Effect.succeed(new Map())) - const deps = createDependencies({ - listHiddenDemoSourceIds: vi.fn(async () => ["another-demo"]), - listSourcesForWorkspace: vi.fn(async () => [materializedSource]), - sourceViewOptionsBySourceId, - }) - - const state = await loadWorkspaceShellInitialState(deps) - - expect(sourceViewOptionsBySourceId).toHaveBeenCalledWith([], expect.any(Object)) - expect(state.sources).toEqual([ - expect.objectContaining({ - id: "source_demo", - kind: "workspace", - documentId: "doc_user_copy", - chunkCount: 70, - }), - ]) - }) - - it("does not treat legacy fake demo rows as materialized user copies", async () => { - const workspace = makeWorkspace() - const legacyFakeSource = makeSource(workspace.id, { - id: "source_legacy_demo", - demoKey: "demo-tsla-q4-2025", - title: "TSLA-Q4-2025-Update.pdf", - knowhereJobId: null, - knowhereDocumentId: "demo-doc-tsla-q4-2025", - }) - const sourceViewOptionsBySourceId = vi.fn(() => Effect.succeed(new Map())) - const deps = createDependencies({ - listSourcesForWorkspace: vi.fn(async () => [legacyFakeSource]), - sourceViewOptionsBySourceId, - }) - - const state = await loadWorkspaceShellInitialState(deps) - - expect(sourceViewOptionsBySourceId).toHaveBeenCalledWith([], expect.any(Object)) - expect(state.sources).toEqual([ - expect.objectContaining({ - id: "demo-tsla-q4-2025", - kind: "demo", - demoSourceId: "demo-tsla-q4-2025", - documentId: "demo-doc-tsla-q4-2025", - }), - ]) - }) - - it("does not list non-ready legacy demo rows as workspace sources", async () => { - const workspace = makeWorkspace() - const nonReadyLegacySource = makeSource(workspace.id, { - id: "source_non_ready_legacy_demo", - status: "parsing", - demoKey: "demo-tsla-q4-2025", - knowhereJobId: null, - knowhereDocumentId: null, - }) - const sourceViewOptionsBySourceId = vi.fn(() => Effect.succeed(new Map())) - const deps = createDependencies({ - listSourcesForWorkspace: vi.fn(async () => [nonReadyLegacySource]), - sourceViewOptionsBySourceId, - }) - - const state = await loadWorkspaceShellInitialState(deps) - - expect(sourceViewOptionsBySourceId).toHaveBeenCalledWith([], expect.any(Object)) - expect(state.sources).toEqual([ - expect.objectContaining({ - id: "demo-tsla-q4-2025", - kind: "demo", - }), - ]) - }) - - it("hides API-owned demos when deleted legacy rows were backfilled into visibility", async () => { - const state = await loadWorkspaceShellInitialState( - createDependencies({ - listHiddenDemoSourceIds: vi.fn(async () => ["demo-tsla-q4-2025"]), - }), - ) - - expect(state.sources).toEqual([]) - }) - - it("seeds authenticated empty workspaces with persisted demo chat", async () => { - const workspace = makeWorkspace() - const demoThread = makeThread(workspace.id, { - id: "demo_thread_1", - title: "What happened in Tesla Q4?", - demoKey: "knowhere-demo-chat", - }) - const demoMessages = [ - makeMessage(demoThread.id, { - id: "demo_message_user", - role: "user", - content: "What happened in Tesla Q4?", - }), - makeMessage(demoThread.id, { - id: "demo_message_assistant", - role: "assistant", - content: "Tesla delivered higher revenue.", - }), - ] - const ensureDemoChatThread = vi.fn(async () => ({ - thread: demoThread, - messages: demoMessages, - })) - const deps = createDependencies({ - getOptionalAuthenticated: vi.fn(async () => ({ - user: { - id: "user_1", - email: "ada@example.com", - name: "Ada", - }, - workspace, - })), - ensureDemoChatThread, - }) - - const state = await loadWorkspaceShellInitialState(deps) - - expect(ensureDemoChatThread).toHaveBeenCalledWith( - workspace.id, - makeDemoCatalog(), - ) - expect(state.activeChatThreadId).toBe("demo_thread_1") - expect(state.chatThreads).toEqual([ - expect.objectContaining({ - id: "demo_thread_1", - title: "What happened in Tesla Q4?", - }), - ]) - expect(state.chatMessages).toEqual([ - { - id: "demo_message_user", - role: "user", - content: "What happened in Tesla Q4?", - citations: undefined, - }, - { - id: "demo_message_assistant", - role: "assistant", - content: "Tesla delivered higher revenue.", - citations: undefined, - }, - ]) - }) - it("lists workspace sources without blocking on reconciliation", async () => { const workspace = makeWorkspace() const readySource = makeSource(workspace.id, { @@ -368,10 +74,6 @@ describe("loadWorkspaceShellInitialState", () => { expect(listSourcesForWorkspace).toHaveBeenCalledWith(workspace.id) expect(deps.reconcileSourcesForWorkspace).not.toHaveBeenCalled() expect(state.sources).toEqual([ - expect.objectContaining({ - id: "demo-tsla-q4-2025", - kind: "demo", - }), { id: readySource.id, kind: "workspace", @@ -455,10 +157,6 @@ describe("loadWorkspaceShellInitialState", () => { "sk_test", ) expect(state.sources).toEqual([ - expect.objectContaining({ - id: "demo-tsla-q4-2025", - kind: "demo", - }), { id: parsingSource.id, kind: "workspace", @@ -522,110 +220,18 @@ function createDependencies( const client = {} as InitialStateClient return { - fetchDemoCatalog: vi.fn(async () => makeDemoCatalog()), getClientForWorkspace: vi.fn(async () => ({ client, apiKey: "sk_test" })), - getGuest: vi.fn(async () => ({ loginUrl: "/login" })), getOptionalAuthenticated: vi.fn(async () => ({ user, workspace })), - ensureDemoChatThread: vi.fn(async () => null), listChatThreads: vi.fn(async () => []), - listHiddenDemoSourceIds: vi.fn(async () => []), listMessages: vi.fn(async () => []), listSourcesForWorkspace: vi.fn(async () => []), + localizeRemoteDocument: vi.fn(async () => makeSource("workspace_1")), reconcileSourcesForWorkspace: vi.fn(async () => []), sourceViewOptionsBySourceId: vi.fn(() => Effect.succeed(new Map())), ...overrides, } } -function makeDemoCatalog(): DemoCatalog { - return { - officialLibrary: { - categories: [ - { - categoryId: "financial-reports", - label: "Financial reports", - description: "Company filings.", - }, - { - categoryId: "stem-books", - label: "STEM books", - description: "Course materials.", - }, - ], - sources: [ - { - librarySourceId: "financial-tsla-q4-2025", - categoryId: "financial-reports", - title: "TSLA-Q4-2025-Update.pdf", - sourceUrl: "https://example.com/tsla-q4-2025.pdf", - mimeType: "application/pdf", - status: "ready", - demoSourceId: "demo-tsla-q4-2025", - canonicalDocumentId: "demo-doc-tsla-q4-2025", - sizeBytes: 1024, - chunkCount: 70, - }, - { - librarySourceId: "stem-transformers", - categoryId: "stem-books", - title: "Transformers.pdf", - sourceUrl: "https://example.com/transformers.pdf", - mimeType: "application/pdf", - status: "planned", - }, - ], - }, - sources: [ - { - demoSourceId: "demo-tsla-q4-2025", - canonicalDocumentId: "demo-doc-tsla-q4-2025", - title: "TSLA-Q4-2025-Update.pdf", - mimeType: "application/pdf", - sizeBytes: 1024, - status: "ready", - chunkCount: 70, - originalFile: { - url: "/api/v1/demo/sources/demo-tsla-q4-2025/original", - mimeType: "application/pdf", - sizeBytes: 1024, - canDownload: false, - }, - officialLibrary: { - librarySourceId: "financial-tsla-q4-2025", - categoryId: "financial-reports", - title: "TSLA-Q4-2025-Update.pdf", - sourceUrl: "https://example.com/tsla-q4-2025.pdf", - mimeType: "application/pdf", - status: "ready", - demoSourceId: "demo-tsla-q4-2025", - }, - examples: [ - { - id: "demo-example-1", - question: "What happened in Tesla Q4?", - answer: "Tesla delivered higher revenue.", - citations: [ - { - demoSourceId: "demo-tsla-q4-2025", - canonicalDocumentId: "demo-doc-tsla-q4-2025", - canonicalChunkId: "demo-chunk-1", - chunkId: "parser-chunk-1", - chunkType: "text", - content: "Automotive revenue increased.", - source: { - documentId: "demo-doc-tsla-q4-2025", - sourceFileName: "TSLA-Q4-2025-Update.pdf", - sectionPath: "Shareholder Deck", - }, - }, - ], - }, - ], - }, - ], - } -} - function makeWorkspace(): Workspace { return { id: "workspace_1", @@ -653,43 +259,9 @@ function makeSource( stagedBlobUrl: null, originalBlobPathname: null, originalBlobUrl: null, - demoKey: null, createdAt: new Date("2026-05-10T00:00:00.000Z"), updatedAt: new Date("2026-05-10T00:00:00.000Z"), deletedAt: null, ...overrides, } } - -function makeThread( - workspaceId: string, - overrides: Partial = {}, -): ChatThread { - return { - id: "thread_1", - workspaceId, - demoKey: null, - title: "Revenue", - createdAt: new Date("2026-05-10T00:00:00.000Z"), - updatedAt: new Date("2026-05-10T00:00:00.000Z"), - - deletedAt: null, - ...overrides, - } -} - -function makeMessage( - threadId: string, - overrides: Partial = {}, -): ChatMessage { - return { - id: "message_1", - threadId, - role: "user", - content: "Hello", - citations: null, - artifacts: null, - createdAt: new Date("2026-05-10T00:00:00.000Z"), - ...overrides, - } -} diff --git a/src/domains/workspace/initial-state.ts b/src/domains/workspace/initial-state.ts index 368d43b..4270bca 100644 --- a/src/domains/workspace/initial-state.ts +++ b/src/domains/workspace/initial-state.ts @@ -4,27 +4,17 @@ import { Effect } from "effect" import type { ChatMessageView } from "@/domains/chat/types" import type { ParsedChunkView } from "@/domains/chunks/types" -import { demoView } from "@/domains/demo/view" -import { - getMaterializedDemoSourceViewOptionsBySourceId, - getWorkspaceSourcesNeedingKnowhereChunkCount, - resolveWorkspaceDemoSources, -} from "@/domains/demo/workspace-source-resolution" import { chatThreadService } from "@/domains/chat/thread-service" import { toChatMessageView, toChatThreadView } from "@/domains/chat/view" import { sourceViewOptionsBySourceId as getSourceViewOptionsBySourceId } from "@/domains/sources/counts" -import { listRemoteLibrarySourceViews } from "@/domains/sources/remote-library" +import { localizeRemoteLibrarySources } from "@/domains/sources/remote-library" import { reconcileSourcesForWorkspace as reconcileDefaultSourcesForWorkspace } from "@/domains/sources/reconcile" -import { sourceService } from "@/domains/sources/service" import { startBackgroundReconciliation as defaultStartBackgroundReconciliation, } from "@/domains/sources/background-reconcile" import { sourceWorkflowRuntime } from "@/domains/sources/workflow-runtime" -import type { - OfficialLibrarySourceView, - SourceView, -} from "@/domains/sources/types" +import type { SourceView } from "@/domains/sources/types" import { toSourceView } from "@/domains/sources/view" import type { AuthUser } from "@/infrastructure/auth" import type { @@ -33,11 +23,6 @@ import type { Source, Workspace, } from "@/infrastructure/db/schema" -import { - knowhereDemoApi, - type DemoCatalog, - type OfficialLibrarySource, -} from "@/integrations/knowhere-demo" import { effectOperation } from "@/lib/effect-operation" import { logger } from "@/lib/logger" import { notebookRequestContext } from "./request-context" @@ -48,9 +33,6 @@ type WorkspaceShellInitialState = { readonly chatThreads?: ReturnType[] readonly dashboardUrl?: string readonly initialPrefetchedChunksBySourceId?: Record - readonly isGuest?: boolean - readonly loginUrl?: string - readonly officialLibrarySources?: OfficialLibrarySourceView[] readonly sources?: SourceView[] readonly user?: { readonly id: string @@ -63,35 +45,8 @@ type WorkspaceShellInitialState = { } } -// Aligned with workspaceClientConfig.sourceChunkPageSize so the SSR -// prefetch doesn't overlap with the first client-side page request. -const DEMO_CHUNK_PREFETCH_PAGE_SIZE = 50 const workspaceInitialStateContext = "Workspace initial state" -async function getDemoChunksForSource( - demoSourceId: string, -): Promise { - const chunkPage = await knowhereDemoApi.fetchChunkPage({ - demoSourceId, - page: 1, - pageSize: DEMO_CHUNK_PREFETCH_PAGE_SIZE, - }) - // Only title and documentId are consumed by toParsedChunkView, - // so a minimal SourceView is sufficient. - const sourceView: SourceView = { - id: chunkPage.demoSourceId, - kind: "demo", - demoSourceId: chunkPage.demoSourceId, - title: chunkPage.title, - mimeType: chunkPage.mimeType, - status: "ready", - documentId: chunkPage.canonicalDocumentId, - } - return chunkPage.chunks.map((chunk) => - demoView.toParsedChunkView(sourceView, chunk), - ) -} - type WorkspaceShellInitialStateClient = Parameters[1] & Parameters[1] & { @@ -111,29 +66,19 @@ type WorkspaceShellInitialStateClient = } type WorkspaceShellInitialStateDependencies = { - readonly fetchDemoCatalog: () => Promise readonly getClientForWorkspace: ( workspace: Workspace, ) => Promise<{ readonly apiKey: string readonly client: WorkspaceShellInitialStateClient }> - readonly getGuest: () => Promise<{ readonly loginUrl: string }> readonly getOptionalAuthenticated: () => Promise<{ readonly user: AuthUser readonly workspace: Workspace } | null> - readonly ensureDemoChatThread: ( - workspaceId: string, - catalog: DemoCatalog, - ) => Promise<{ - readonly thread: ChatThread - readonly messages: readonly ChatMessage[] - } | null> readonly listChatThreads: ( workspaceId: string, ) => Promise - readonly listHiddenDemoSourceIds: (workspaceId: string) => Promise readonly listMessages: ( workspaceId: string, threadId: string, @@ -141,6 +86,7 @@ type WorkspaceShellInitialStateDependencies = { readonly listSourcesForWorkspace: ( workspaceId: string, ) => Promise + readonly localizeRemoteDocument: typeof sourceWorkflowRuntime.localizeRemoteDocument readonly reconcileSourcesForWorkspace: ( workspace: Workspace, client: WorkspaceShellInitialStateClient, @@ -153,15 +99,12 @@ type WorkspaceShellInitialStateDependencies = { } const defaultDependencies: WorkspaceShellInitialStateDependencies = { - fetchDemoCatalog: knowhereDemoApi.fetchCatalog, getClientForWorkspace: notebookRequestContext.getClientForWorkspace, - getGuest: notebookRequestContext.getGuest, getOptionalAuthenticated: notebookRequestContext.getOptionalAuthenticated, - ensureDemoChatThread: chatThreadService.ensureDemo, listChatThreads: chatThreadService.listForWorkspace, - listHiddenDemoSourceIds: sourceService.listHiddenDemoSourceIds, listMessages: chatThreadService.listMessages, listSourcesForWorkspace: sourceWorkflowRuntime.listForWorkspace, + localizeRemoteDocument: sourceWorkflowRuntime.localizeRemoteDocument, reconcileSourcesForWorkspace: reconcileDefaultSourcesForWorkspace, startBackgroundReconciliation: defaultStartBackgroundReconciliation, sourceViewOptionsBySourceId: getSourceViewOptionsBySourceId, @@ -184,63 +127,13 @@ export const loadWorkspaceShellInitialStateEffect = ( ) if (!context) { - const demoCatalog = yield* effectOperation.tryPromise( - { - context: workspaceInitialStateContext, - operation: "fetchDemoCatalog", - }, - () => deps.fetchDemoCatalog(), - ) - const guestContext = yield* effectOperation.tryPromise( - { - context: workspaceInitialStateContext, - operation: "getGuest", - }, - () => deps.getGuest(), - ) - - const firstDemoSource = demoCatalog.sources[0] - let initialPrefetchedChunksBySourceId: Record< - string, - ParsedChunkView[] - > = {} - if (firstDemoSource) { - const chunks = yield* Effect.catchAll( - effectOperation.tryPromise( - { - context: workspaceInitialStateContext, - operation: "getDemoChunksForSource", - }, - () => getDemoChunksForSource(firstDemoSource.demoSourceId), - ), - () => Effect.succeed([] as ParsedChunkView[]), - ) - if (chunks.length > 0) { - initialPrefetchedChunksBySourceId = { - [firstDemoSource.demoSourceId]: chunks, - } - } - } - return { - isGuest: true, - officialLibrarySources: toOfficialLibrarySourceViews(demoCatalog), - sources: demoCatalog.sources.map(demoView.toSourceView), - chatMessages: demoView.toChatMessages(demoCatalog), dashboardUrl: resolveDashboardUrl(), - initialPrefetchedChunksBySourceId, - loginUrl: guestContext.loginUrl, + sources: [], } } const { user, workspace } = context - const demoCatalog = yield* effectOperation.tryPromise( - { - context: workspaceInitialStateContext, - operation: "fetchOptionalCatalog", - }, - () => knowhereDemoApi.fetchOptionalCatalog(deps.fetchDemoCatalog), - ) const listedSources = yield* effectOperation.tryPromise( { context: workspaceInitialStateContext, @@ -248,15 +141,6 @@ export const loadWorkspaceShellInitialStateEffect = ( }, () => deps.listSourcesForWorkspace(workspace.id), ) - const hiddenDemoSourceIds = new Set( - yield* effectOperation.tryPromise( - { - context: workspaceInitialStateContext, - operation: "listHiddenDemoSourceIds", - }, - () => deps.listHiddenDemoSourceIds(workspace.id), - ), - ) const listedChatThreads = yield* effectOperation.tryPromise( { context: workspaceInitialStateContext, @@ -264,31 +148,16 @@ export const loadWorkspaceShellInitialStateEffect = ( }, () => deps.listChatThreads(workspace.id), ) - const seededDemoChatThread = - listedChatThreads.length === 0 - ? yield* effectOperation.tryPromise( - { - context: workspaceInitialStateContext, - operation: "ensureDemoChatThread", - }, - () => deps.ensureDemoChatThread(workspace.id, demoCatalog), - ) - : null - const chatThreads = seededDemoChatThread - ? [seededDemoChatThread.thread] - : listedChatThreads - const activeChatThread = chatThreads[0] ?? null - const activeChatMessages = seededDemoChatThread - ? seededDemoChatThread.messages - : activeChatThread - ? yield* effectOperation.tryPromise( - { - context: workspaceInitialStateContext, - operation: "listMessages", - }, - () => deps.listMessages(workspace.id, activeChatThread.id), - ) - : [] + const activeChatThread = listedChatThreads[0] ?? null + const activeChatMessages = activeChatThread + ? yield* effectOperation.tryPromise( + { + context: workspaceInitialStateContext, + operation: "listMessages", + }, + () => deps.listMessages(workspace.id, activeChatThread.id), + ) + : [] const chatMessages = activeChatMessages ? activeChatMessages.map((message) => toChatMessageView(message)) : [] @@ -299,45 +168,31 @@ export const loadWorkspaceShellInitialStateEffect = ( }, () => deps.getClientForWorkspace(workspace), ) - const sources = yield* effectOperation.tryPromise( - { - context: workspaceInitialStateContext, - operation: "useListedSourcesForWorkspace", - }, - () => Promise.resolve(listedSources), - ) - const demoSourceResolution = resolveWorkspaceDemoSources( - sources, - demoCatalog, - ) - const visibleDemoCatalogSources = demoCatalog.sources - .filter( - (source) => - !demoSourceResolution.materializedDemoSourceIds.has( - source.demoSourceId, - ), - ) - .filter((source) => !hiddenDemoSourceIds.has(source.demoSourceId)) - const demoSources = visibleDemoCatalogSources.map(demoView.toSourceView) - const workspaceSources = demoSourceResolution.workspaceSources - const remoteSourceViews = yield* effectOperation.addContext( + const sources = listedSources + const workspaceSources = sources + const localizedSources = yield* effectOperation.addContext( { context: workspaceInitialStateContext, - operation: "listRemoteLibrarySourceViews", + operation: "localizeRemoteLibrarySources", }, - listRemoteLibrarySourceViews({ + localizeRemoteLibrarySources({ workspace, client, - localSources: demoSourceResolution.workspaceSources, + localSources: workspaceSources, + localizeDocument: (document) => + deps.localizeRemoteDocument(workspace.id, { + documentId: document.documentId, + namespace: document.namespace, + status: document.status, + title: document.title, + mimeType: document.mimeType, + sizeBytes: document.sizeBytes, + revisionKey: document.revisionKey ?? null, + }), }), ) const sourcesNeedingKnowhereChunkCount = - getWorkspaceSourcesNeedingKnowhereChunkCount(workspaceSources) - const materializedDemoSourceOptions = - getMaterializedDemoSourceViewOptionsBySourceId( - workspaceSources, - demoCatalog, - ) + getWorkspaceSourcesNeedingKnowhereChunkCount(localizedSources) yield* Effect.sync(() => triggerBackgroundReconciliationForParsingSources({ workspaceId: workspace.id, @@ -370,19 +225,10 @@ export const loadWorkspaceShellInitialStateEffect = ( namespace: workspace.namespace, }, dashboardUrl: resolveDashboardUrl(), - sources: [ - ...demoSources, - ...workspaceSources.map((source) => - toSourceView( - source, - materializedDemoSourceOptions.get(source.id) ?? - sourceOptions.get(source.id), - ), - ), - ...remoteSourceViews, - ], - officialLibrarySources: toOfficialLibrarySourceViews(demoCatalog), - chatThreads: chatThreads.map(toChatThreadView), + sources: localizedSources.map((source) => + toSourceView(source, sourceOptions.get(source.id)), + ), + chatThreads: listedChatThreads.map(toChatThreadView), activeChatThreadId: activeChatThread?.id ?? null, chatMessages, } @@ -406,6 +252,14 @@ function resolveDashboardUrl(): string | undefined { return process.env.DASHBOARD_ORIGIN } +function getWorkspaceSourcesNeedingKnowhereChunkCount( + sources: readonly Source[], +): readonly Source[] { + return sources.filter( + (source) => source.status === "ready" && source.knowhereDocumentId, + ) +} + function triggerBackgroundReconciliationForParsingSources(input: { readonly workspaceId: string readonly sources: readonly Source[] @@ -435,39 +289,3 @@ function triggerBackgroundReconciliationForParsingSources(input: { }) } } - -function toOfficialLibrarySourceViews( - catalog: DemoCatalog, -): OfficialLibrarySourceView[] { - const categoryLabelById = new Map( - catalog.officialLibrary.categories.map((category) => [ - category.categoryId, - category.label, - ]), - ) - return catalog.officialLibrary.sources - .filter(isReadyOfficialLibrarySource) - .map((source) => ({ - librarySourceId: source.librarySourceId, - categoryId: source.categoryId, - categoryLabel: - categoryLabelById.get(source.categoryId) ?? source.categoryId, - title: source.title, - sourceUrl: source.sourceUrl, - mimeType: source.mimeType, - status: source.status, - demoSourceId: source.demoSourceId, - ...(source.chunkCount !== undefined - ? { chunkCount: source.chunkCount } - : {}), - })) -} - -function isReadyOfficialLibrarySource( - source: OfficialLibrarySource, -): source is OfficialLibrarySource & { - readonly status: "ready" - readonly demoSourceId: string -} { - return source.status === "ready" && source.demoSourceId !== undefined -} diff --git a/src/domains/workspace/integration.test.ts b/src/domains/workspace/integration.test.ts index 797cdbf..a049656 100644 --- a/src/domains/workspace/integration.test.ts +++ b/src/domains/workspace/integration.test.ts @@ -7,7 +7,6 @@ import * as schema from "@/infrastructure/db/schema"; import { chatMessages, chatThreads, - demoSourceVisibilities, sourceParseResults, sources, workspaces, @@ -99,17 +98,6 @@ describeIfDb("workspace helpers — integration", () => { workspaceId: string, sourceId: string, ) => Promise>> - readonly hideDemoSource: ( - workspaceId: string, - demoSourceId: string, - ) => Promise - readonly listHiddenDemoSourceIds: (workspaceId: string) => Promise - readonly upsertMaterializedDemoSource: ( - workspaceId: string, - input: Parameters< - typeof import("../sources/service").sourceService.upsertMaterializedDemoSource - >[1], - ) => Promise }; beforeEach(async () => { @@ -146,17 +134,12 @@ describeIfDb("workspace helpers — integration", () => { markSourceFailed: sourceWorkflowRuntime.markFailed, saveSourceParseResult: sourceWorkflowRuntime.saveParseResult, getParseAssetUrls: sourceService.getParseAssetUrls, - hideDemoSource: sourceService.hideDemoSource, - listHiddenDemoSourceIds: sourceService.listHiddenDemoSourceIds, - upsertMaterializedDemoSource: - sourceService.upsertMaterializedDemoSource, }; // Clean slate on the tables these tests touch. Order respects FK. await testDb.delete(chatMessages); await testDb.delete(chatThreads); await testDb.delete(sourceParseResults); - await testDb.delete(demoSourceVisibilities); await testDb.delete(sources); await testDb.delete(workspaces); }); @@ -592,42 +575,4 @@ describeIfDb("workspace helpers — integration", () => { workspaceHelpers.getParseAssetUrls(otherWs.id, source.id), ).resolves.toEqual({}); }); - - it("tracks hidden demos and upserts materialized demo sources by demo id", async () => { - const ws = await workspaceHelpers.ensureWorkspace("user_1"); - - await workspaceHelpers.hideDemoSource(ws.id, "demo-tsla-q4-2025"); - await workspaceHelpers.hideDemoSource(ws.id, "demo-tsla-q4-2025"); - - await expect(workspaceHelpers.listHiddenDemoSourceIds(ws.id)).resolves.toEqual([ - "demo-tsla-q4-2025", - ]); - - const first = await workspaceHelpers.upsertMaterializedDemoSource(ws.id, { - demoSourceId: "demo-tsla-q4-2025", - title: "TSLA-Q4-2025-Update.pdf", - mimeType: "application/pdf", - sizeBytes: 1024, - knowhereDocumentId: "doc_user_copy_1", - originalBlobUrl: "/api/demo-sources/demo-tsla-q4-2025/original", - }); - const second = await workspaceHelpers.upsertMaterializedDemoSource(ws.id, { - demoSourceId: "demo-tsla-q4-2025", - title: "TSLA-Q4-2025-Update.pdf", - mimeType: "application/pdf", - sizeBytes: 1024, - knowhereDocumentId: "doc_user_copy_2", - originalBlobUrl: "/api/demo-sources/demo-tsla-q4-2025/original", - }); - - expect(second.id).toBe(first.id); - expect(second).toMatchObject({ - demoKey: "demo-tsla-q4-2025", - status: "ready", - failureReason: null, - knowhereJobId: null, - knowhereDocumentId: "doc_user_copy_2", - originalBlobUrl: "/api/demo-sources/demo-tsla-q4-2025/original", - }); - }); }); diff --git a/src/domains/workspace/persistence.test.ts b/src/domains/workspace/persistence.test.ts index 47534d1..05dcbff 100644 --- a/src/domains/workspace/persistence.test.ts +++ b/src/domains/workspace/persistence.test.ts @@ -34,7 +34,6 @@ type ChatThreadRow = { id: string workspaceId: string title: string | null - demoKey: string | null createdAt: Date updatedAt: Date deletedAt: Date | null @@ -155,7 +154,6 @@ describe("chatRepository", () => { id: "thread_1", workspaceId: "workspace_1", title: "Grounded answer", - demoKey: null, createdAt: new Date("2026-01-01T00:00:00.000Z"), updatedAt: new Date("2026-01-01T00:00:00.000Z"), deletedAt: null, diff --git a/src/domains/workspace/request-context.ts b/src/domains/workspace/request-context.ts index dc0e669..3719ab8 100644 --- a/src/domains/workspace/request-context.ts +++ b/src/domains/workspace/request-context.ts @@ -4,7 +4,6 @@ import { Effect } from "effect" import { headers } from "next/headers" import { ensureApiKeyForWorkspace } from "@/integrations/dashboard/api-key-service" -import { authURLs } from "@/infrastructure/auth/urls" import { getCurrentUser, requireUser, @@ -26,10 +25,6 @@ type AuthenticatedNotebookClientContext = AuthenticatedNotebookContext & { readonly client: NotebookClient } -type GuestNotebookContext = { - readonly loginUrl: string -} - // --------------------------------------------------------------------------- // Effect core // --------------------------------------------------------------------------- @@ -75,23 +70,6 @@ const getClientForWorkspaceEffect = (workspace: Workspace) => return { apiKey, client } }) -const getGuestEffect = Effect.gen(function* () { - const dashboardOrigin = - process.env.DASHBOARD_ORIGIN ?? "http://localhost:3000" - const dashboardLoginURL = `${dashboardOrigin}/login` - const headersList = yield* Effect.tryPromise(() => headers()) - const notebookPublicURL = - process.env.NOTEBOOK_PUBLIC_URL ?? - authURLs.resolveNotebookPublicURLFromHeaders(headersList) - const loginUrl = authURLs.buildDashboardLoginURL( - dashboardLoginURL, - notebookPublicURL, - ) - - return { loginUrl } - }, -) - // --------------------------------------------------------------------------- // Async wrappers (backward-compatible) // --------------------------------------------------------------------------- @@ -114,14 +92,9 @@ async function getClientForWorkspace( return Effect.runPromise(getClientForWorkspaceEffect(workspace)) } -async function getGuest(): Promise { - return Effect.runPromise(getGuestEffect) -} - export const notebookRequestContext = { getAuthenticated, getOptionalAuthenticated, getAuthenticatedWithClient, getClientForWorkspace, - getGuest, } as const diff --git a/src/infrastructure/db/schema.ts b/src/infrastructure/db/schema.ts index 720a303..ae49cbf 100644 --- a/src/infrastructure/db/schema.ts +++ b/src/infrastructure/db/schema.ts @@ -74,8 +74,6 @@ export type NewWorkspace = typeof workspaces.$inferInsert; * and download path * - `staged_blob_*` — legacy temporary Blob staging pointer retained for * older rows during the PR #28 transition - * - `demo_key` — canonical demo source identifier when this row is a - * materialized API-owned demo copy * - `deleted_at` — soft delete timestamp; reads filter it out * * Indexes: @@ -101,7 +99,6 @@ export const sources = pgTable( stagedBlobUrl: text("staged_blob_url"), originalBlobPathname: text("original_blob_pathname"), originalBlobUrl: text("original_blob_url"), - demoKey: text("demo_key"), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), @@ -115,7 +112,6 @@ export const sources = pgTable( .on(t.workspaceId, t.createdAt.desc()) .where(sql`deleted_at IS NULL`), index("sources_workspace_status_idx").on(t.workspaceId, t.status), - uniqueIndex("sources_workspace_demo_key_idx").on(t.workspaceId, t.demoKey), uniqueIndex("sources_workspace_document_idx") .on(t.workspaceId, t.knowhereDocumentId) .where(sql`knowhere_document_id IS NOT NULL AND deleted_at IS NULL`), @@ -125,39 +121,6 @@ export const sources = pgTable( export type Source = typeof sources.$inferSelect; export type NewSource = typeof sources.$inferInsert; -/** - * User presentation state for canonical demo sources before they are copied - * into a real workspace source. - */ -export const demoSourceVisibilities = pgTable( - "demo_source_visibilities", - { - id: uuid("id").primaryKey().defaultRandom(), - workspaceId: uuid("workspace_id") - .notNull() - .references(() => workspaces.id, { onDelete: "cascade" }), - demoSourceId: text("demo_source_id").notNull(), - hiddenAt: timestamp("hidden_at", { withTimezone: true }), - deletedAt: timestamp("deleted_at", { withTimezone: true }), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), - }, - (t) => [ - uniqueIndex("demo_source_visibilities_workspace_source_idx").on( - t.workspaceId, - t.demoSourceId, - ), - index("demo_source_visibilities_workspace_idx").on(t.workspaceId), - ], -); - -export type DemoSourceVisibility = typeof demoSourceVisibilities.$inferSelect; -export type NewDemoSourceVisibility = typeof demoSourceVisibilities.$inferInsert; - /** * Notebook-owned parse-result artifact index for one source. * @@ -192,8 +155,7 @@ export type SourceParseResult = typeof sourceParseResults.$inferSelect; export type NewSourceParseResult = typeof sourceParseResults.$inferInsert; /** - * A chat thread is a conversation within a workspace. `demo_key` is retained - * for legacy seeded demo conversations. + * A chat thread is a conversation within a workspace. */ export const chatThreads = pgTable( "chat_threads", @@ -203,7 +165,6 @@ export const chatThreads = pgTable( .notNull() .references(() => workspaces.id, { onDelete: "cascade" }), title: text("title"), - demoKey: text("demo_key"), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), @@ -219,10 +180,6 @@ export const chatThreads = pgTable( index("chat_threads_workspace_updated_idx") .on(t.workspaceId, t.updatedAt.desc()) .where(sql`deleted_at IS NULL`), - uniqueIndex("chat_threads_workspace_demo_key_idx").on( - t.workspaceId, - t.demoKey, - ), ], ); diff --git a/src/integrations/knowhere-demo.test.ts b/src/integrations/knowhere-demo.test.ts deleted file mode 100644 index 7926818..0000000 --- a/src/integrations/knowhere-demo.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest" - -const nextCacheMocks = vi.hoisted(() => ({ - cacheLife: vi.fn(), - cacheTag: vi.fn(), -})) - -vi.mock("next/cache", () => nextCacheMocks) - -import { knowhereDemoApi } from "./knowhere-demo" - -describe("knowhereDemoApi", () => { - const originalBaseURL = process.env.KNOWHERE_BASE_URL - const originalFetch = globalThis.fetch - - afterEach(() => { - restoreEnv("KNOWHERE_BASE_URL", originalBaseURL) - globalThis.fetch = originalFetch - nextCacheMocks.cacheLife.mockClear() - nextCacheMocks.cacheTag.mockClear() - }) - - it("uses the configured Knowhere base URL for demo requests", () => { - process.env.KNOWHERE_BASE_URL = "https://api-staging.knowhereto.ai" - - const url = knowhereDemoApi.resolveApiURL("/api/v1/demo/catalog") - - expect(url).toBe("https://api-staging.knowhereto.ai/api/v1/demo/catalog") - }) - - it("falls back to production API instead of localhost", () => { - delete process.env.KNOWHERE_BASE_URL - - const url = knowhereDemoApi.resolveApiURL("/api/v1/demo/catalog") - - expect(url).toBe("https://api.knowhereto.ai/api/v1/demo/catalog") - }) - - it("uses deploy-lifetime cache profiles for demo catalog data", async () => { - globalThis.fetch = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ sources: [] }), { - status: 200, - headers: { "content-type": "application/json" }, - }), - ) - - await expect(knowhereDemoApi.fetchCatalog()).resolves.toEqual({ - sources: [], - officialLibrary: { - categories: [], - sources: [], - }, - }) - - expect(nextCacheMocks.cacheLife).toHaveBeenCalledWith("max") - expect(nextCacheMocks.cacheTag).toHaveBeenCalledWith("demo-catalog") - }) - - it("accepts empty demo chunk content from parser output", async () => { - globalThis.fetch = vi.fn().mockResolvedValue( - new Response( - JSON.stringify({ - demo_source_id: "demo-tsla-q4-2025", - canonical_document_id: "demo-doc-tsla-q4-2025", - title: "TSLA-Q4-2025-Update.pdf", - mime_type: "application/pdf", - chunks: [ - { - id: "demo-tsla-q4-2025:chunk-empty", - chunk_id: "chunk-empty", - chunk_type: "text", - content: "", - section_path: "Default_Root/TSLA-Q4-2025-Update.pdf-->OUTLOOK", - source_chunk_path: "Default_Root/TSLA-Q4-2025-Update.pdf-->OUTLOOK", - file_path: null, - sort_order: 27, - metadata: {}, - asset_url: null, - }, - ], - pagination: { - page: 1, - page_size: 100, - total: 1, - total_pages: 1, - }, - }), - { status: 200, headers: { "content-type": "application/json" } }, - ), - ) - - const page = await knowhereDemoApi.fetchChunkPage({ - demoSourceId: "demo-tsla-q4-2025", - page: 1, - pageSize: 100, - }) - - expect(page.chunks[0]).toMatchObject({ - id: "demo-tsla-q4-2025:chunk-empty", - content: "", - }) - expect(nextCacheMocks.cacheLife).toHaveBeenCalledWith("max") - expect(nextCacheMocks.cacheTag).toHaveBeenCalledWith( - "demo-chunks", - "demo-tsla-q4-2025", - ) - }) - - it("maps Official Library metadata from the demo catalog", async () => { - globalThis.fetch = vi.fn().mockResolvedValue( - new Response( - JSON.stringify({ - sources: [ - { - demo_source_id: "demo-spacex-s1", - canonical_document_id: "demo-doc-spacex-s1", - title: "spacex-s1.pdf", - mime_type: "application/pdf", - size_bytes: 7441414, - status: "ready", - chunk_count: 922, - original_file: { - url: "/api/v1/demo/sources/demo-spacex-s1/original", - mime_type: "application/pdf", - size_bytes: 7441414, - can_download: false, - }, - official_library: { - library_source_id: "financial-spacex-s1", - category_id: "financial-reports", - title: "spacex-s1.pdf", - source_url: "https://data.olivierroy.dev/spacex-s1.pdf", - mime_type: "application/pdf", - status: "ready", - demo_source_id: "demo-spacex-s1", - }, - examples: [], - }, - ], - official_library: { - categories: [ - { - category_id: "financial-reports", - label: "Financial reports", - description: "Company filings.", - }, - ], - sources: [ - { - library_source_id: "financial-spacex-s1", - category_id: "financial-reports", - title: "spacex-s1.pdf", - source_url: "https://data.olivierroy.dev/spacex-s1.pdf", - mime_type: "application/pdf", - status: "ready", - demo_source_id: "demo-spacex-s1", - canonical_document_id: "demo-doc-spacex-s1", - size_bytes: 7441414, - chunk_count: 922, - }, - ], - }, - }), - { status: 200, headers: { "content-type": "application/json" } }, - ), - ) - - const catalog = await knowhereDemoApi.fetchCatalog() - - expect(catalog.sources[0]?.officialLibrary).toMatchObject({ - librarySourceId: "financial-spacex-s1", - categoryId: "financial-reports", - demoSourceId: "demo-spacex-s1", - }) - expect(catalog.officialLibrary.sources[0]).toMatchObject({ - librarySourceId: "financial-spacex-s1", - status: "ready", - chunkCount: 922, - }) - }) -}) - -function restoreEnv(key: string, value: string | undefined): void { - if (value === undefined) { - delete process.env[key] - return - } - - process.env[key] = value -} diff --git a/src/integrations/knowhere-demo.ts b/src/integrations/knowhere-demo.ts deleted file mode 100644 index 8507dab..0000000 --- a/src/integrations/knowhere-demo.ts +++ /dev/null @@ -1,624 +0,0 @@ -import "server-only" - -import { Effect, Schema } from "effect" -import { cacheLife, cacheTag } from "next/cache" - -export type DemoCitation = { - readonly demoSourceId: string - readonly canonicalDocumentId: string - readonly canonicalChunkId: string - readonly chunkId: string - readonly chunkType: string - readonly content: string - readonly description?: string - readonly source: { - readonly documentId: string - readonly sourceFileName: string - readonly sectionPath: string - } -} - -export type DemoExample = { - readonly id: string - readonly question: string - readonly answer: string - readonly citations: readonly DemoCitation[] -} - -export type DemoSource = { - readonly demoSourceId: string - readonly canonicalDocumentId: string - readonly title: string - readonly mimeType: string - readonly sizeBytes: number - readonly status: "ready" - readonly chunkCount: number - readonly originalFile: { - readonly url: string - readonly mimeType: string - readonly sizeBytes: number - readonly canDownload: boolean - } - readonly officialLibrary?: OfficialLibrarySource - readonly examples: readonly DemoExample[] -} - -export type DemoCatalog = { - readonly sources: readonly DemoSource[] - readonly officialLibrary: OfficialLibraryCatalog -} - -export type OfficialLibraryCategory = { - readonly categoryId: string - readonly label: string - readonly description: string -} - -export type OfficialLibrarySource = { - readonly librarySourceId: string - readonly categoryId: string - readonly title: string - readonly sourceUrl: string - readonly mimeType: string - readonly status: "ready" | "planned" - readonly demoSourceId?: string - readonly canonicalDocumentId?: string - readonly sizeBytes?: number - readonly chunkCount?: number -} - -export type OfficialLibraryCatalog = { - readonly categories: readonly OfficialLibraryCategory[] - readonly sources: readonly OfficialLibrarySource[] -} - -export type DemoChunk = { - readonly id: string - readonly chunkId: string - readonly chunkType: string - readonly content: string - readonly sectionPath?: string | null - readonly sourceChunkPath?: string | null - readonly filePath?: string | null - readonly sortOrder: number - readonly metadata: Readonly> - readonly assetUrl?: string | null -} - -export type DemoChunkPage = { - readonly demoSourceId: string - readonly canonicalDocumentId: string - readonly title: string - readonly mimeType: string - readonly chunks: readonly DemoChunk[] - readonly pagination: { - readonly page: number - readonly pageSize: number - readonly total: number - readonly totalPages: number - } -} - -export type MaterializedDemoSource = { - readonly demoSourceId: string - readonly documentId: string - readonly status: "created" | "existing" - readonly title: string - readonly mimeType: string - readonly sizeBytes: number - readonly chunkCount: number - readonly originalFile: { - readonly url: string - readonly mimeType: string - readonly sizeBytes: number - readonly canDownload: boolean - } -} - -type DemoCatalogResponse = { - readonly sources?: readonly DemoSourceResponse[] - readonly official_library?: OfficialLibraryCatalogResponse -} - -type DemoSourceResponse = { - readonly demo_source_id?: unknown - readonly canonical_document_id?: unknown - readonly title?: unknown - readonly mime_type?: unknown - readonly size_bytes?: unknown - readonly status?: unknown - readonly chunk_count?: unknown - readonly original_file?: DemoOriginalFileResponse - readonly official_library?: OfficialLibrarySourceResponse - readonly examples?: readonly DemoExampleResponse[] -} - -type DemoOriginalFileResponse = { - readonly url?: unknown - readonly mime_type?: unknown - readonly size_bytes?: unknown - readonly can_download?: unknown -} - -type DemoExampleResponse = { - readonly id?: unknown - readonly question?: unknown - readonly answer?: unknown - readonly citations?: readonly DemoCitationResponse[] -} - -type DemoCitationResponse = { - readonly demo_source_id?: unknown - readonly canonical_document_id?: unknown - readonly canonical_chunk_id?: unknown - readonly chunk_id?: unknown - readonly chunk_type?: unknown - readonly content?: unknown - readonly description?: unknown - readonly source?: { - readonly document_id?: unknown - readonly source_file_name?: unknown - readonly section_path?: unknown - } -} - -type DemoChunkPageResponse = { - readonly demo_source_id?: unknown - readonly canonical_document_id?: unknown - readonly title?: unknown - readonly mime_type?: unknown - readonly chunks?: readonly DemoChunkResponse[] - readonly pagination?: { - readonly page?: unknown - readonly page_size?: unknown - readonly total?: unknown - readonly total_pages?: unknown - } -} - -type DemoChunkResponse = { - readonly id?: unknown - readonly chunk_id?: unknown - readonly chunk_type?: unknown - readonly content?: unknown - readonly section_path?: unknown - readonly source_chunk_path?: unknown - readonly file_path?: unknown - readonly sort_order?: unknown - readonly metadata?: unknown - readonly asset_url?: unknown -} - -type OfficialLibraryCatalogResponse = { - readonly categories?: readonly OfficialLibraryCategoryResponse[] - readonly sources?: readonly OfficialLibrarySourceResponse[] -} - -type OfficialLibraryCategoryResponse = { - readonly category_id?: unknown - readonly label?: unknown - readonly description?: unknown -} - -type OfficialLibrarySourceResponse = { - readonly library_source_id?: unknown - readonly category_id?: unknown - readonly title?: unknown - readonly source_url?: unknown - readonly mime_type?: unknown - readonly status?: unknown - readonly demo_source_id?: unknown - readonly canonical_document_id?: unknown - readonly size_bytes?: unknown - readonly chunk_count?: unknown -} - -type MaterializeResponse = { - readonly sources?: readonly MaterializedDemoSourceResponse[] -} - -type MaterializedDemoSourceResponse = { - readonly demo_source_id?: unknown - readonly document_id?: unknown - readonly status?: unknown - readonly title?: unknown - readonly mime_type?: unknown - readonly size_bytes?: unknown - readonly chunk_count?: unknown - readonly original_file?: DemoOriginalFileResponse -} - -const DEFAULT_KNOWHERE_BASE_URL = "https://api.knowhereto.ai" - -const emptyCatalog: DemoCatalog = { - sources: [], - officialLibrary: { categories: [], sources: [] }, -} - -// --------------------------------------------------------------------------- -// Effect core -// --------------------------------------------------------------------------- - -const fetchCatalogEffect = Effect.fn("knowhereDemo.fetchCatalog")(function* () { - const response = yield* Effect.tryPromise(() => - fetch(resolveApiURL("/api/v1/demo/catalog")), - ) - yield* assertOkEffect(response) - - const body = (yield* Effect.tryPromise(() => - response.json(), - )) as DemoCatalogResponse - return { - sources: (body.sources ?? []).map(toDemoSource), - officialLibrary: toOfficialLibraryCatalog(body.official_library), - } -}) - -const fetchChunkPageEffect = Effect.fn("knowhereDemo.fetchChunkPage")( - function* (input: { - readonly demoSourceId: string - readonly page: number - readonly pageSize: number - }) { - const params = new URLSearchParams({ - page: String(input.page), - page_size: String(input.pageSize), - }) - const response = yield* Effect.tryPromise(() => - fetch( - resolveApiURL( - `/api/v1/demo/sources/${encodeURIComponent(input.demoSourceId)}/chunks?${params.toString()}`, - ), - ), - ) - yield* assertOkEffect(response) - - return toDemoChunkPage( - (yield* Effect.tryPromise(() => - response.json(), - )) as DemoChunkPageResponse, - ) - }, -) - -const materializeSourcesEffect = Effect.fn("knowhereDemo.materializeSources")( - function* (input: { - readonly apiKey: string - readonly namespace: string - readonly demoSourceIds: readonly string[] - }) { - const requestBody = yield* Schema.encode(MaterializeSourcesRequestJson)({ - namespace: input.namespace, - demo_source_ids: input.demoSourceIds, - }) - const response = yield* Effect.tryPromise(() => - fetch(resolveApiURL("/api/v1/demo/materializations"), { - method: "POST", - headers: { - authorization: `Bearer ${input.apiKey}`, - "content-type": "application/json", - }, - body: requestBody, - }), - ) - yield* assertOkEffect(response) - - const body = (yield* Effect.tryPromise(() => - response.json(), - )) as MaterializeResponse - return (body.sources ?? []).map(toMaterializedDemoSource) - }, -) - -const MaterializeSourcesRequestJson = Schema.parseJson( - Schema.Struct({ - namespace: Schema.String, - demo_source_ids: Schema.Array(Schema.String), - }), -) - -const fetchOptionalCatalogEffect = ( - fetcher?: () => Effect.Effect, -) => - (fetcher ?? fetchCatalogEffect)().pipe( - Effect.catchAll(() => Effect.succeed(emptyCatalog)), - ) - -// --------------------------------------------------------------------------- -// Async wrappers (backward-compatible) -// --------------------------------------------------------------------------- - -async function fetchCatalog(): Promise { - "use cache" - cacheLife("max") - cacheTag("demo-catalog") - - return Effect.runPromise(fetchCatalogEffect()) -} - -async function fetchOptionalCatalog( - fetcher?: () => Promise, -): Promise { - const effectFetcher = fetcher - ? () => - Effect.tryPromise(() => fetcher()).pipe( - Effect.catchAll(() => Effect.succeed(emptyCatalog)), - ) - : undefined - return Effect.runPromise(fetchOptionalCatalogEffect(effectFetcher)) -} - -async function fetchChunkPage(input: { - readonly demoSourceId: string - readonly page: number - readonly pageSize: number -}): Promise { - "use cache" - cacheLife("max") - cacheTag("demo-chunks", input.demoSourceId) - - return Effect.runPromise(fetchChunkPageEffect(input)) -} - -async function materializeSources(input: { - readonly apiKey: string - readonly namespace: string - readonly demoSourceIds: readonly string[] -}): Promise { - return Effect.runPromise(materializeSourcesEffect(input)) -} - -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -export const knowhereDemoApi = { - fetchCatalog, - fetchOptionalCatalog, - fetchChunkPage, - materializeSources, - resolveApiURL, -} as const - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function resolveApiURL(path: string): string { - const baseURL = process.env.KNOWHERE_BASE_URL ?? DEFAULT_KNOWHERE_BASE_URL - return new URL(path, baseURL).toString() -} - -class KnowhereDemoApiError { - readonly _tag = "KnowhereDemoApiError" - constructor( - readonly status: number, - readonly body: string, - ) {} -} - -function assertOkEffect( - response: Response, -): Effect.Effect { - if (response.ok) return Effect.void - - return Effect.gen(function* () { - const body = yield* Effect.tryPromise(() => - response.text().catch(() => ""), - ).pipe(Effect.orDie) - return yield* Effect.fail( - new KnowhereDemoApiError(response.status, body), - ) - }) -} - -function toDemoSource(source: DemoSourceResponse): DemoSource { - const officialLibrary = source.official_library - ? toOfficialLibrarySource(source.official_library) - : undefined - return { - demoSourceId: requireString(source.demo_source_id), - canonicalDocumentId: requireString(source.canonical_document_id), - title: requireString(source.title), - mimeType: requireString(source.mime_type), - sizeBytes: requireNumber(source.size_bytes), - status: "ready", - chunkCount: requireNumber(source.chunk_count), - originalFile: toOriginalFile(source.original_file), - ...(officialLibrary ? { officialLibrary } : {}), - examples: (source.examples ?? []).map(toDemoExample), - } -} - -function toDemoExample(example: DemoExampleResponse): DemoExample { - return { - id: requireString(example.id), - question: requireString(example.question), - answer: requireString(example.answer), - citations: (example.citations ?? []).map(toDemoCitation), - } -} - -function toDemoCitation(citation: DemoCitationResponse): DemoCitation { - const source = citation.source ?? {} - const description = optionalString(citation.description) - return { - demoSourceId: requireString(citation.demo_source_id), - canonicalDocumentId: requireString(citation.canonical_document_id), - canonicalChunkId: requireString(citation.canonical_chunk_id), - chunkId: requireString(citation.chunk_id), - chunkType: requireString(citation.chunk_type), - content: requireString(citation.content), - ...(description ? { description } : {}), - source: { - documentId: requireString(source.document_id), - sourceFileName: requireString(source.source_file_name), - sectionPath: requireString(source.section_path), - }, - } -} - -function toDemoChunkPage(response: DemoChunkPageResponse): DemoChunkPage { - const pagination = response.pagination ?? {} - return { - demoSourceId: requireString(response.demo_source_id), - canonicalDocumentId: requireString(response.canonical_document_id), - title: requireString(response.title), - mimeType: requireString(response.mime_type), - chunks: (response.chunks ?? []).map((chunk) => - toDemoChunk(requireString(response.demo_source_id), chunk), - ), - pagination: { - page: requireNumber(pagination.page), - pageSize: requireNumber(pagination.page_size), - total: requireNumber(pagination.total), - totalPages: requireNumber(pagination.total_pages), - }, - } -} - -function toDemoChunk( - demoSourceId: string, - chunk: DemoChunkResponse, -): DemoChunk { - return { - id: requireString(chunk.id), - chunkId: requireString(chunk.chunk_id), - chunkType: requireString(chunk.chunk_type), - content: requireContentString(chunk.content), - sectionPath: optionalString(chunk.section_path) ?? null, - sourceChunkPath: optionalString(chunk.source_chunk_path) ?? null, - filePath: optionalString(chunk.file_path) ?? null, - sortOrder: requireNumber(chunk.sort_order), - metadata: toRecord(chunk.metadata), - assetUrl: toDemoAssetUrl(demoSourceId, optionalString(chunk.asset_url)), - } -} - -function toMaterializedDemoSource( - source: MaterializedDemoSourceResponse, -): MaterializedDemoSource { - const status = requireString(source.status) - return { - demoSourceId: requireString(source.demo_source_id), - documentId: requireString(source.document_id), - status: status === "existing" ? "existing" : "created", - title: requireString(source.title), - mimeType: requireString(source.mime_type), - sizeBytes: requireNumber(source.size_bytes), - chunkCount: requireNumber(source.chunk_count), - originalFile: toOriginalFile(source.original_file), - } -} - -function toOfficialLibraryCatalog( - input: OfficialLibraryCatalogResponse | undefined, -): OfficialLibraryCatalog { - const officialLibrary = input ?? {} - return { - categories: (officialLibrary.categories ?? []).map( - toOfficialLibraryCategory, - ), - sources: (officialLibrary.sources ?? []).map(toOfficialLibrarySource), - } -} - -function toOfficialLibraryCategory( - category: OfficialLibraryCategoryResponse, -): OfficialLibraryCategory { - return { - categoryId: requireString(category.category_id), - label: requireString(category.label), - description: requireString(category.description), - } -} - -function toOfficialLibrarySource( - source: OfficialLibrarySourceResponse, -): OfficialLibrarySource { - const status = requireString(source.status) - const demoSourceId = optionalString(source.demo_source_id) - const canonicalDocumentId = optionalString(source.canonical_document_id) - const sizeBytes = optionalNumber(source.size_bytes) - const chunkCount = optionalNumber(source.chunk_count) - return { - librarySourceId: requireString(source.library_source_id), - categoryId: requireString(source.category_id), - title: requireString(source.title), - sourceUrl: requireString(source.source_url), - mimeType: requireString(source.mime_type), - status: status === "ready" ? "ready" : "planned", - ...(demoSourceId ? { demoSourceId } : {}), - ...(canonicalDocumentId ? { canonicalDocumentId } : {}), - ...(sizeBytes !== undefined ? { sizeBytes } : {}), - ...(chunkCount !== undefined ? { chunkCount } : {}), - } -} - -function toOriginalFile( - input: DemoOriginalFileResponse | undefined, -): DemoSource["originalFile"] { - const originalFile = input ?? {} - return { - url: requireString(originalFile.url), - mimeType: requireString(originalFile.mime_type), - sizeBytes: requireNumber(originalFile.size_bytes), - canDownload: originalFile.can_download === true, - } -} - -function requireString(value: unknown): string { - if (typeof value === "string" && value.trim().length > 0) { - return value - } - throw new Error("Expected non-empty string from Knowhere demo API.") -} - -function requireContentString(value: unknown): string { - if (typeof value === "string") return value - throw new Error("Expected string content from Knowhere demo API.") -} - -function optionalString(value: unknown): string | undefined { - return typeof value === "string" && value.trim().length > 0 - ? value - : undefined -} - -function requireNumber(value: unknown): number { - if (typeof value === "number" && Number.isFinite(value)) { - return value - } - throw new Error("Expected finite number from Knowhere demo API.") -} - -function optionalNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined -} - -function toRecord(value: unknown): Readonly> { - if (typeof value !== "object" || value === null || Array.isArray(value)) { - return {} - } - return value as Readonly> -} - -function toDemoAssetUrl( - demoSourceId: string, - assetUrl: string | undefined, -): string | null { - if (!assetUrl) return null - - const assetPath = extractDemoAssetPath(assetUrl) - if (!assetPath) return null - - return `/api/demo-sources/${encodeURIComponent(demoSourceId)}/assets/${assetPath}` -} - -function extractDemoAssetPath(assetUrl: string): string | null { - const marker = "/assets/" - const markerIndex = assetUrl.indexOf(marker) - if (markerIndex === -1) return null - - return assetUrl.slice(markerIndex + marker.length) -} diff --git a/src/integrations/knowhere.ts b/src/integrations/knowhere.ts index 81f6726..495b202 100644 --- a/src/integrations/knowhere.ts +++ b/src/integrations/knowhere.ts @@ -14,6 +14,46 @@ export function makeKnowhereClient(apiKey: string): Knowhere { return wrapKnowhereClient(client) } +export type KnowhereNamespace = { + readonly namespace: string + readonly documentCount: number +} + +/** + * List all namespaces from the Knowhere API. + * The SDK does not expose this endpoint, so we call it directly. + */ +export async function listKnowhereNamespaces( + apiKey: string, +): Promise { + const baseURL = process.env.KNOWHERE_BASE_URL ?? "https://api.knowhere.com" + const response = await fetch(`${baseURL}/v1/documents/namespaces`, { + headers: { Authorization: `Bearer ${apiKey}` }, + }) + if (!response.ok) { + logger.warn("knowhere: listNamespaces failed", { + status: response.status, + }) + return [] + } + const data = (await response.json()) as unknown + const items = Array.isArray(data) + ? data + : (data as { namespaces?: unknown[] })?.namespaces + if (!Array.isArray(items)) return [] + return items + .filter( + (item): item is { namespace: string; document_count?: number; documentCount?: number } => + typeof item === "object" && + item !== null && + typeof (item as { namespace?: unknown }).namespace === "string", + ) + .map((item) => ({ + namespace: item.namespace, + documentCount: item.documentCount ?? item.document_count ?? 0, + })) +} + function wrapKnowhereClient(client: Knowhere): Knowhere { return new Proxy(client, { get(target, prop, receiver) { diff --git a/src/lib/posthog.test.ts b/src/lib/posthog.test.ts index 0d4304f..ede4006 100644 --- a/src/lib/posthog.test.ts +++ b/src/lib/posthog.test.ts @@ -93,16 +93,13 @@ describe("posthog", () => { window.history.pushState({}, "", "/workspace/guest"); resetGuest(); - await trackView({ - isGuest: true, - }); + await trackView(); expect(mocks.reset).toHaveBeenCalledOnce(); expect(mocks.capture).toHaveBeenCalledWith( "$pageview", expect.objectContaining({ $pathname: "/workspace/guest", - is_guest: true, }), ); expect(mocks.reset.mock.invocationCallOrder[0]).toBeLessThan( @@ -119,7 +116,6 @@ describe("posthog", () => { await initClient(); await trackView({ workspaceId: "ws_1", - isGuest: false, }); expect(mocks.capture).toHaveBeenCalledWith( @@ -128,7 +124,6 @@ describe("posthog", () => { $current_url: window.location.href, $pathname: "/workspace/test", workspace_id: "ws_1", - is_guest: false, }), ); const payload = mocks.capture.mock.calls[0]?.[1] as diff --git a/src/lib/posthog.ts b/src/lib/posthog.ts index 65baf5c..36b29b8 100644 --- a/src/lib/posthog.ts +++ b/src/lib/posthog.ts @@ -9,7 +9,6 @@ export type AnalyticsContext = { readonly workspaceId?: string; readonly workspaceNamespace?: string; readonly userId?: string; - readonly isGuest?: boolean; }; type AnalyticsEnvelope = { @@ -66,7 +65,6 @@ function buildBaseProperties(context?: AnalyticsContext): Properties { workspace_id: context?.workspaceId, workspace_namespace: context?.workspaceNamespace, user_id: context?.userId, - is_guest: context?.isGuest, }; } diff --git a/src/proxy.test.ts b/src/proxy.test.ts index b965699..c659fee 100644 --- a/src/proxy.test.ts +++ b/src/proxy.test.ts @@ -25,30 +25,22 @@ describe("proxy", () => { } }); - it("allows anonymous guest source reads", () => { + it("keeps anonymous source reads protected", () => { const sourcesResponse = proxy( new NextRequest("http://localhost:3001/api/sources"), ); const chunksResponse = proxy( new NextRequest( - "http://localhost:3001/api/sources/demo-tsla-q4-2025/chunks", + "http://localhost:3001/api/sources/source-1/chunks", ), ); - const originalResponse = proxy( - new NextRequest( - "http://localhost:3001/api/demo-sources/demo-tsla-q4-2025/original", - ), + + expect(sourcesResponse.headers.get("location")).toBe( + "http://localhost:3001/login", ); - const assetResponse = proxy( - new NextRequest( - "http://localhost:3001/api/demo-sources/demo-tsla-q4-2025/assets/images/image-1.jpg", - ), + expect(chunksResponse.headers.get("location")).toBe( + "http://localhost:3001/login", ); - - expect(sourcesResponse.headers.get("x-middleware-next")).toBe("1"); - expect(chunksResponse.headers.get("x-middleware-next")).toBe("1"); - expect(originalResponse.headers.get("x-middleware-next")).toBe("1"); - expect(assetResponse.headers.get("x-middleware-next")).toBe("1"); }); it("keeps anonymous source mutations protected", () => { diff --git a/src/proxy.ts b/src/proxy.ts index 6177d3b..977b7de 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -30,29 +30,15 @@ const PUBLIC_PATHS: readonly string[] = [ ] const STATIC_EXTENSIONS = /\.(?:svg|png|jpe?g|gif|webp|ico|woff2?|ttf|eot|css|js|map|txt|xml|webmanifest|json|pdf)$/i -const GUEST_SOURCE_CHUNKS_PATH = /^\/api\/sources\/[^/]+\/chunks$/u -const GUEST_DEMO_ORIGINAL_PATH = /^\/api\/demo-sources\/[^/]+\/original$/u -const GUEST_DEMO_ASSET_PATH = /^\/api\/demo-sources\/[^/]+\/assets\/.+$/u function isPublicPath(req: NextRequest): boolean { const pathname = req.nextUrl.pathname - if (isGuestSourceReadPath(req.method, pathname)) return true if (pathname.startsWith("/_next")) return true if (pathname.startsWith("/api/internal/")) return true if (STATIC_EXTENSIONS.test(pathname)) return true return PUBLIC_PATHS.some((p) => pathname === p || pathname.startsWith(p + "/")) } -function isGuestSourceReadPath(method: string, pathname: string): boolean { - if (method !== "GET") return false - return ( - pathname === "/api/sources" || - GUEST_SOURCE_CHUNKS_PATH.test(pathname) || - GUEST_DEMO_ORIGINAL_PATH.test(pathname) || - GUEST_DEMO_ASSET_PATH.test(pathname) - ) -} - export function proxy(req: NextRequest): NextResponse { if (knowhereApiKeyOverride.hasApiKey()) return NextResponse.next() From fed35ec8cec7cd6f0a5867128ae68618a3dc2094 Mon Sep 17 00:00:00 2001 From: Gordon YUEN Date: Fri, 31 Jul 2026 19:24:40 +0800 Subject: [PATCH 14/46] fix(chunks): render table HTML from assetUrl instead of duplicating summary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Table chunks have their HTML served via chunk.assetUrl (text/html), not in chunk.content (which holds a summary string). TableChunkCard was only checking chunk.content, always falling back to the icon+summary view — duplicating the summary already shown above. Now fetches HTML from chunk.assetUrl, sanitizes via getSanitizedTableHtml, and renders via dangerouslySetInnerHTML — mirroring how ImageChunkCard uses chunk.assetUrl. --- src/components/parsed-chunk-card.tsx | 32 +++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/src/components/parsed-chunk-card.tsx b/src/components/parsed-chunk-card.tsx index 1ba233d..ec70133 100644 --- a/src/components/parsed-chunk-card.tsx +++ b/src/components/parsed-chunk-card.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo, type MouseEvent, type ReactNode } from "react"; +import { useEffect, useMemo, useState, type MouseEvent, type ReactNode } from "react"; import { FileSearch, FileText, ImageIcon, Table2, Tags, TextQuote } from "lucide-react"; import { Badge } from "@/components/ui/badge"; @@ -525,9 +525,10 @@ function TableChunkCard({ readonly isOriginalPreviewAvailable: boolean; readonly onChunkClick?: (chunk: ParsedChunkView) => void; }): ReactNode { + const contentHtml = useTableAssetHtml(chunk); const safeHtml = useMemo( - () => parsedChunkCardModel.getSanitizedTableHtml(chunk.content), - [chunk.content], + () => parsedChunkCardModel.getSanitizedTableHtml(contentHtml ?? chunk.content), + [contentHtml, chunk.content], ); return ( @@ -566,6 +567,31 @@ function TableChunkCard({ ); } +function useTableAssetHtml(chunk: ParsedChunkView): string | null { + const [html, setHtml] = useState(null); + const assetUrl = chunk.assetUrl; + + useEffect(() => { + if (!assetUrl) return; + + let cancelled = false; + fetch(assetUrl) + .then((response) => response.text()) + .then((text) => { + if (!cancelled) setHtml(text); + }) + .catch(() => { + if (!cancelled) setHtml(null); + }); + + return () => { + cancelled = true; + }; + }, [assetUrl]); + + return assetUrl ? html : null; +} + function renderChunkIcon(type: ParsedChunkView["type"]): ReactNode { if (type === "page") return ; if (type === "image") return ; From bbbe93e4dc2368b520a292c9d44f19a8b03a3c75 Mon Sep 17 00:00:00 2001 From: Gordon YUEN Date: Fri, 31 Jul 2026 20:13:00 +0800 Subject: [PATCH 15/46] fix(chunks): enrich table/image chunks with assetUrl via getChunk The Knowhere listChunks endpoint doesn't return assetUrl for table/image chunks (returns null even with includeAsset_urls=true). The getChunk single-chunk endpoint does return it. After listChunks, for each table/image chunk missing assetUrl, call getChunk(includeAssetUrls=true) to fetch the asset URL. The URL points to LocalStack S3 which is reachable from the browser. The browser-side useTableAssetHtml hook then fetches the HTML from the populated assetUrl, sanitizes it, and renders the actual table. --- src/domains/chunks/index.ts | 7 ++++++ src/domains/chunks/server.ts | 46 ++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/src/domains/chunks/index.ts b/src/domains/chunks/index.ts index 8a285d7..29ca6fd 100644 --- a/src/domains/chunks/index.ts +++ b/src/domains/chunks/index.ts @@ -32,6 +32,13 @@ export type ChunkKnowhereClient = { totalPages?: number } }> + getChunk?( + documentId: string, + chunkId: string, + params?: { includeAssetUrls?: boolean }, + ): Promise<{ + chunk: DocumentChunk & { assetUrl?: string | null } + }> } } diff --git a/src/domains/chunks/server.ts b/src/domains/chunks/server.ts index 7132931..cc14321 100644 --- a/src/domains/chunks/server.ts +++ b/src/domains/chunks/server.ts @@ -221,6 +221,13 @@ export const loadChunkPageForSource = ( }), ) : revisionProbeResponse + if (includeAssetUrls) { + yield* Effect.tryPromise(() => + enrichChunksWithAssetUrls(source.knowhereDocumentId!, response, client), + ).pipe( + Effect.catchAll(() => Effect.void), + ) + } const revisionKey = getRevisionKey(response, source) ?? probeRevisionKey if (revisionKey && revisionKey !== probeRevisionKey) { scheduleRevisionKeyUpdate(source, revisionKey, options.onRevisionKey) @@ -681,6 +688,45 @@ function getMirroredAssetContentType( return "application/octet-stream" } +async function enrichChunksWithAssetUrls( + documentId: string, + response: { readonly chunks: readonly DocumentChunk[] }, + client: ChunkKnowhereClient, +): Promise { + if (!client.documents.getChunk) return + + const chunksNeedingAssets = response.chunks.filter( + (chunk) => + (chunk.chunkType === "image" || chunk.chunkType === "table") && + (!chunk.assetUrl || chunk.assetUrl.length === 0) && + chunk.id, + ) + if (chunksNeedingAssets.length === 0) return + + const results = await Promise.allSettled( + chunksNeedingAssets.map((chunk) => + client.documents + .getChunk!(documentId, chunk.id, { includeAssetUrls: true }) + .then((res) => ({ chunkId: chunk.id, assetUrl: res.chunk?.assetUrl })) + .catch(() => ({ chunkId: chunk.id, assetUrl: undefined })), + ), + ) + + const assetUrlByChunkId = new Map() + for (const result of results) { + if (result.status === "fulfilled") { + assetUrlByChunkId.set(result.value.chunkId, result.value.assetUrl) + } + } + + for (const chunk of response.chunks as DocumentChunk[]) { + const assetUrl = assetUrlByChunkId.get(chunk.id) + if (assetUrl) { + ;(chunk as DocumentChunk & { assetUrl?: string | null }).assetUrl = assetUrl + } + } +} + function getRevisionKey( response: Pick, source: Source, From 0e9ec21e5ff70e147bcf95b77c010daee44f4068 Mon Sep 17 00:00:00 2001 From: Gordon YUEN Date: Fri, 31 Jul 2026 23:43:53 +0800 Subject: [PATCH 16/46] fix(chunks): fetch table HTML server-side to avoid CORS and DNS issues The table HTML assetUrl (LocalStack S3) is unreachable from the browser (CORS) and from inside the Docker container (localhost.localstack.cloud resolves to 127.0.0.1 inside the container). Fix: fetch table HTML server-side during chunk page loading and set it as chunk.content. The existing getSanitizedTableHtml(chunk.content) path in TableChunkCard then works as originally designed. Requires --add-host localhost.localstack.cloud:host-gateway in docker run so the container can reach LocalStack on the host. --- src/components/parsed-chunk-card.tsx | 32 ++----------------- src/domains/chunks/server.ts | 47 +++++++++++++++++----------- 2 files changed, 31 insertions(+), 48 deletions(-) diff --git a/src/components/parsed-chunk-card.tsx b/src/components/parsed-chunk-card.tsx index ec70133..1ba233d 100644 --- a/src/components/parsed-chunk-card.tsx +++ b/src/components/parsed-chunk-card.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useState, type MouseEvent, type ReactNode } from "react"; +import { useMemo, type MouseEvent, type ReactNode } from "react"; import { FileSearch, FileText, ImageIcon, Table2, Tags, TextQuote } from "lucide-react"; import { Badge } from "@/components/ui/badge"; @@ -525,10 +525,9 @@ function TableChunkCard({ readonly isOriginalPreviewAvailable: boolean; readonly onChunkClick?: (chunk: ParsedChunkView) => void; }): ReactNode { - const contentHtml = useTableAssetHtml(chunk); const safeHtml = useMemo( - () => parsedChunkCardModel.getSanitizedTableHtml(contentHtml ?? chunk.content), - [contentHtml, chunk.content], + () => parsedChunkCardModel.getSanitizedTableHtml(chunk.content), + [chunk.content], ); return ( @@ -567,31 +566,6 @@ function TableChunkCard({ ); } -function useTableAssetHtml(chunk: ParsedChunkView): string | null { - const [html, setHtml] = useState(null); - const assetUrl = chunk.assetUrl; - - useEffect(() => { - if (!assetUrl) return; - - let cancelled = false; - fetch(assetUrl) - .then((response) => response.text()) - .then((text) => { - if (!cancelled) setHtml(text); - }) - .catch(() => { - if (!cancelled) setHtml(null); - }); - - return () => { - cancelled = true; - }; - }, [assetUrl]); - - return assetUrl ? html : null; -} - function renderChunkIcon(type: ParsedChunkView["type"]): ReactNode { if (type === "page") return ; if (type === "image") return ; diff --git a/src/domains/chunks/server.ts b/src/domains/chunks/server.ts index cc14321..214d77a 100644 --- a/src/domains/chunks/server.ts +++ b/src/domains/chunks/server.ts @@ -693,38 +693,47 @@ async function enrichChunksWithAssetUrls( response: { readonly chunks: readonly DocumentChunk[] }, client: ChunkKnowhereClient, ): Promise { - if (!client.documents.getChunk) return - - const chunksNeedingAssets = response.chunks.filter( + const tableChunks = response.chunks.filter( (chunk) => - (chunk.chunkType === "image" || chunk.chunkType === "table") && - (!chunk.assetUrl || chunk.assetUrl.length === 0) && - chunk.id, + chunk.chunkType === "table" && chunk.assetUrl && chunk.id, ) - if (chunksNeedingAssets.length === 0) return + if (tableChunks.length === 0) return + const fetchAsset = defaultFetchAsset const results = await Promise.allSettled( - chunksNeedingAssets.map((chunk) => - client.documents - .getChunk!(documentId, chunk.id, { includeAssetUrls: true }) - .then((res) => ({ chunkId: chunk.id, assetUrl: res.chunk?.assetUrl })) - .catch(() => ({ chunkId: chunk.id, assetUrl: undefined })), - ), + tableChunks.map(async (chunk): Promise<{ chunkId: string; html: string | null }> => { + try { + const res = await fetchAsset(chunk.assetUrl!) + if (!res.ok) return { chunkId: chunk.id, html: null } + const html = await res.text() + return { chunkId: chunk.id, html } + } catch { + return { chunkId: chunk.id, html: null } + } + }), ) - const assetUrlByChunkId = new Map() + const htmlByChunkId = new Map() for (const result of results) { - if (result.status === "fulfilled") { - assetUrlByChunkId.set(result.value.chunkId, result.value.assetUrl) + if (result.status === "fulfilled" && result.value.html) { + htmlByChunkId.set(result.value.chunkId, result.value.html) } } + let enriched = 0 for (const chunk of response.chunks as DocumentChunk[]) { - const assetUrl = assetUrlByChunkId.get(chunk.id) - if (assetUrl) { - ;(chunk as DocumentChunk & { assetUrl?: string | null }).assetUrl = assetUrl + const html = htmlByChunkId.get(chunk.id) + if (html) { + ;(chunk as DocumentChunk & { content?: string | null }).content = html + enriched++ } } + if (enriched > 0) { + logger.info("chunks: enriched table chunks with inline HTML", { + documentId, + enriched, + }) + } } function getRevisionKey( From 0a0a1d74e42966b94a0a9f4e81eb92892843df64 Mon Sep 17 00:00:00 2001 From: Gordon YUEN Date: Fri, 31 Jul 2026 23:57:28 +0800 Subject: [PATCH 17/46] docs: update for table chunk enrichment and Docker --add-host flag - AGENTS.md: update docker run command with --add-host flag and port 3001, add table chunk enrichment convention - CONTEXT.md: update Parsed Chunk definition to mention server-side HTML enrichment from assetUrl --- AGENTS.md | 3 ++- CONTEXT.md | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6fefb12..7e9b90f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,7 +47,7 @@ details when the documentation isn't enough. - **Integration tests:** `pnpm test:integration` (needs `TEST_DATABASE_URL`; script currently globs `src/lib/*.integration.test.ts` which has no matches — real integration tests are in `src/domains/`) - **DB schema push:** `pnpm db:push --force` (dev; `--force` skips the TTY prompt because `drizzle.config.ts` sets `strict: true`). drizzle-kit does **not** load `.env.local`, so pass it inline: `DATABASE_URL=… pnpm db:push --force`. `pnpm db:migrate` for prod. - **Build:** `pnpm build` -- **Docker image:** `docker build -t knowhere-notebook:dev .` then `docker run -d --name knowhere-notebook -p 3000:3000 --env-file .env.docker knowhere-notebook:dev` (standalone, non-root, port 3000). +- **Docker image:** `docker build -t knowhere-notebook:dev .` then `docker run -d --name knowhere-notebook -p 3001:3000 --add-host localhost.localstack.cloud:host-gateway --env-file .env.docker knowhere-notebook:dev` (standalone, non-root, port 3000). The `--add-host` flag is required for self-hosted Knowhere with LocalStack S3 so the container can resolve `localhost.localstack.cloud` to the host gateway (used for fetching table/image chunk assets server-side). CI runs: `lint → typecheck → test → build` on PRs to `main` and `staging`. @@ -83,6 +83,7 @@ src/ - **Auth:** Dashboard is the source of truth. Notebook forwards the session cookie; it never decodes tokens. `KNOWHERE_API_KEY` env enables API-key dev mode (skips Dashboard auth, uses a deterministic local user). - **Chat provider:** two backends in `src/lib/ai.ts` — `AI_GATEWAY_API_KEY` (Vercel AI Gateway, model as plain string) OR `CHAT_BASE_URL`+`CHAT_API_KEY`+`CHAT_MODEL` (OpenAI-compatible `LanguageModelV3`). Use `getChatModel()`/`isChatConfigured()`; never reintroduce per-call-site `AI_GATEWAY_API_KEY` guards. `@ai-sdk/openai-compatible` is pinned to 2.x (provider V3) to match `ai@6`. - **Vercel Blob is optional:** the chunk-page cache (`src/domains/chunks/server.ts`) is gated on `BLOB_READ_WRITE_TOKEN`; without it the cache is skipped and chunks are served straight from Knowhere. Don't add hard `@vercel/blob` calls in request paths without gating on the token or wrapping in a read-failure-as-miss handler. +- **Table chunk enrichment:** the Knowhere `listChunks` endpoint returns `assetUrl` for table/image chunks but the HTML is at that URL, not in `chunk.content` (which holds a summary). `enrichChunksWithAssetUrls` in `src/domains/chunks/server.ts` fetches the HTML from `assetUrl` server-side after the list call and sets it as `chunk.content` so `TableChunkCard`'s `getSanitizedTableHtml` can detect and render it. This avoids browser CORS issues with LocalStack S3 URLs. Requires `--add-host localhost.localstack.cloud:host-gateway` in Docker. - **Fonts:** use the local `geist` package (`GeistSans`/`GeistMono` from `geist/font/*`), not `next/font/google` — the repo runs in airgapped/self-hosted setups where Google Fonts is unreachable. - **Desktop layout:** 2-panel (sources | chat) with one resize handle. Chunks are a full-screen overlay (`fixed inset-0 z-50`), not an inline panel. `PanelId` is `"sources" | "chat"`. The chunks overlay opens via the source-row tree button or by clicking a citation in chat. A namespace dropdown in the sources panel header lets users import documents from any Knowhere namespace. - **No demo or guest mode:** Demo catalog, guest mode, and the Official Library panel have been removed. All sources are either `kind: "workspace"` (local DB row) or `kind: "remote"` (Knowhere document not yet localized). Anonymous requests redirect to login. diff --git a/CONTEXT.md b/CONTEXT.md index 6a99050..5e3f979 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -125,7 +125,10 @@ text requests. A Parsed Chunk is a document chunk returned by the Knowhere document chunks API. Parsed chunks can have parser chunk IDs, asset paths, page numbers, -summary, keywords, and connection metadata. +summary, keywords, and connection metadata. Table chunks have their HTML +fetched server-side from `assetUrl` and set as `content` (via +`enrichChunksWithAssetUrls`) because the Knowhere list endpoint puts a +summary string in `content`, not the table HTML. ## Parsed Chunk Card From 30720d0676b058d88003c2f597904c7ffaacca4b Mon Sep 17 00:00:00 2001 From: Gordon YUEN Date: Sat, 1 Aug 2026 00:33:07 +0800 Subject: [PATCH 18/46] feat(chat): improve BM25 retrieval with reranking, multi-query, and retrieval trace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Enable LLM reranking in retrieval queries (rerank: true, internalRecallK: 30) to offset BM25's weaker ranking - Add retrieval guidance to the harness system prompt: keyword-based (BM25) query crafting, query expansion with synonyms/domain terms, and multiple focused retrieve calls for multi-part questions - Build a transient RetrievalTraceView from retrieval responses and return it with the chat answer (never persisted to the DB) UI: - New ChatRetrievalTrace component showing each issued query, its namespace, hit count, cited chunk count, and top scores - Render the trace under the sources section of assistant messages - Add the citation score to the citation chip tooltip - Show 'Searching sources…' as the live status while an answer is in flight - Remove now-unused client param from enrichChunksWithAssetUrls Tests: update query-param expectations, add trace rendering tests, a multi-query trace test, and the searching status assertion. --- src/agent-harness/runtime.ts | 5 + src/components/chat-message-list.test.ts | 50 +++++++++- src/components/chat-message-list.tsx | 17 +++- src/components/chat-retrieval-trace.tsx | 55 +++++++++++ src/components/workspace-chat-state.test.ts | 2 + src/components/workspace-chat-state.ts | 3 +- src/domains/chat/contracts.ts | 2 + src/domains/chat/index.test.ts | 100 ++++++++++++++++++++ src/domains/chat/index.ts | 31 ++++++ src/domains/chat/service.test.ts | 4 + src/domains/chat/service.ts | 10 +- src/domains/chat/types.ts | 18 ++++ src/domains/chat/view.ts | 3 + src/domains/chunks/server.ts | 3 +- 14 files changed, 297 insertions(+), 6 deletions(-) create mode 100644 src/components/chat-retrieval-trace.tsx diff --git a/src/agent-harness/runtime.ts b/src/agent-harness/runtime.ts index c03baac..e5b61b0 100644 --- a/src/agent-harness/runtime.ts +++ b/src/agent-harness/runtime.ts @@ -620,6 +620,11 @@ export function buildHarnessSystemPrompt(turn: AgentTurnInput): string { "- If the user corrects a previous answer, set carryHistory to repair_previous, read the relevant prior turn, then re-retrieve and re-answer using the correction.", "- If the user uses references like this document, that image, or the previous answer, choose referential_only or full_recent and read the prior turn you depend on.", "", + "Retrieval rules:", + "- KNOWHERE retrieval is keyword-based (BM25). Use exact terms and distinctive keywords likely to appear in the documents; avoid vague paraphrases.", + "- Expand queries with synonyms, acronyms, and domain terms that might appear in the sources (for example brand names, metric names, table headers).", + "- For multi-part or ambiguous questions, call retrieve more than once with different query phrasings, one per distinct aspect, and combine evidence from all calls.", + "- Prefer multiple focused queries over one long unfocused query.", "Output rules:", "- Final output is the OutputManifest passed to finalize, not freeform tool JSON or trailing text.", "- artifacts with display=true are the exact images/tables shown. Never display every candidate; honor constraints.desiredCount / maxCount.", diff --git a/src/components/chat-message-list.test.ts b/src/components/chat-message-list.test.ts index 8d58630..b68e0a7 100644 --- a/src/components/chat-message-list.test.ts +++ b/src/components/chat-message-list.test.ts @@ -60,6 +60,54 @@ describe("ChatMessageList", () => { ).toBeTruthy(); }); + it("renders the transient retrieval trace for a fresh assistant message", () => { + render( + React.createElement(ChatMessageList, { + messages: [ + { + id: "assistant_1", + role: "assistant", + content: "The deadline is Monday.", + retrievalTrace: { + queries: [ + { + query: "deadline monday", + namespace: "notebook-workspace", + resultCount: 3, + referencedChunkCount: 1, + topScores: [0.91, 0.8], + }, + ], + }, + }, + ], + }), + ); + + expect(screen.getByText("Retrieval")).toBeTruthy(); + expect(screen.getByText("deadline monday")).toBeTruthy(); + expect(screen.getByText("3 hits")).toBeTruthy(); + expect(screen.getByText("1 cited chunk")).toBeTruthy(); + expect(screen.getByText("top score: 0.910 · 0.800")).toBeTruthy(); + }); + + it("does not render a retrieval trace without queries", () => { + render( + React.createElement(ChatMessageList, { + messages: [ + { + id: "assistant_1", + role: "assistant", + content: "The deadline is Monday.", + retrievalTrace: { queries: [] }, + }, + ], + }), + ); + + expect(screen.queryByText("Retrieval")).toBeNull(); + }); + it("renders citations in a bottom source area as file chips", async () => { const user = userEvent.setup(); @@ -125,7 +173,7 @@ describe("ChatMessageList", () => { const tooltip = await screen.findByRole("tooltip"); expect(tooltip.textContent).toBe( - "spacex-s1.pdf · Assets / tables / table-25 Capital Expenditures.html", + "spacex-s1.pdf · Assets / tables / table-25 Capital Expenditures.htmlScore: 0.900", ); }); diff --git a/src/components/chat-message-list.tsx b/src/components/chat-message-list.tsx index e5e6460..9eca4a9 100644 --- a/src/components/chat-message-list.tsx +++ b/src/components/chat-message-list.tsx @@ -11,6 +11,7 @@ import remarkGfm from "remark-gfm"; import { ChatDiagramCard } from "@/components/chat-diagram-card"; import { useChatMessageListWorkflow } from "@/components/chat-message-list-workflow"; +import { ChatRetrievalTrace } from "@/components/chat-retrieval-trace"; import { chatPanelModel } from "@/components/chat-panel-model"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Spinner } from "@/components/ui/spinner"; @@ -375,6 +376,9 @@ function MessageBubble({ onCitationClick={onCitationClick} pendingCitationId={pendingCitationId} /> + {message.retrievalTrace && ( + + )}
); @@ -652,12 +656,23 @@ function CitationChip({ align="start" className="max-w-[320px] bg-popover text-popover-foreground shadow-lg" > - {tooltipLabel} +
+ {tooltipLabel} + {isUsableCitationScore(citation.score) && ( + + Score: {citation.score!.toFixed(3)} + + )} +
); } +function isUsableCitationScore(score: number | null | undefined): boolean { + return typeof score === "number" && Number.isFinite(score) && score > 0; +} + function getDisplayCitations( message: ChatMessageView, sourceTitlesByDocumentId: Readonly>, diff --git a/src/components/chat-retrieval-trace.tsx b/src/components/chat-retrieval-trace.tsx new file mode 100644 index 0000000..5cfadfe --- /dev/null +++ b/src/components/chat-retrieval-trace.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { type ReactElement } from "react"; +import { Search } from "lucide-react"; + +import type { RetrievalTraceView } from "@/domains/chat/types"; + +export function ChatRetrievalTrace({ + trace, +}: { + readonly trace: RetrievalTraceView; +}): ReactElement | null { + if (trace.queries.length === 0) return null; + + return ( +
+

+ + Retrieval +

+
+ {trace.queries.map((entry, index) => ( +
+
+ + {entry.query} + + + {entry.resultCount} {entry.resultCount === 1 ? "hit" : "hits"} + +
+ {entry.referencedChunkCount > 0 && ( + + {entry.referencedChunkCount} cited{" "} + {entry.referencedChunkCount === 1 ? "chunk" : "chunks"} + + )} + {entry.topScores.length > 0 && ( + + top score: {formatTopScores(entry.topScores)} + + )} +
+ ))} +
+
+ ); +} + +function formatTopScores(scores: readonly number[]): string { + return scores.map((score) => score.toFixed(3)).join(" · "); +} diff --git a/src/components/workspace-chat-state.test.ts b/src/components/workspace-chat-state.test.ts index 129da69..8d184b4 100644 --- a/src/components/workspace-chat-state.test.ts +++ b/src/components/workspace-chat-state.test.ts @@ -63,6 +63,8 @@ describe("workspaceChatState", () => { }, ); + expect(optimistic.pendingStatusText).toBe("Searching sources…"); + const failed = workspaceChatState.failSend(optimistic, "pending-1"); expect(failed).toEqual({ diff --git a/src/components/workspace-chat-state.ts b/src/components/workspace-chat-state.ts index a19513c..4cb772a 100644 --- a/src/components/workspace-chat-state.ts +++ b/src/components/workspace-chat-state.ts @@ -89,6 +89,7 @@ const chatLoadError = "The chat could not be loaded right now." const chatCreateError = "The chat could not be created right now." const chatSendError = "The assistant could not answer right now." const chatArchiveError = "The chat could not be deleted right now." +const SEARCHING_SOURCES_STATUS = "Searching sources…" function createInitialState( threadId: string | null, @@ -213,7 +214,7 @@ function addOptimisticUserMessage( ...current, isSending: true, error: null, - pendingStatusText: null, + pendingStatusText: SEARCHING_SOURCES_STATUS, messages: [...current.messages, optimisticUser], } } diff --git a/src/domains/chat/contracts.ts b/src/domains/chat/contracts.ts index 4c8d224..60cb9e7 100644 --- a/src/domains/chat/contracts.ts +++ b/src/domains/chat/contracts.ts @@ -8,6 +8,7 @@ import type { HarnessRunResult } from "@/agent-harness" import type { ChatArtifactView, ChatCitationView, + RetrievalTraceView, } from "@/domains/chat/types" import type { HardenMediaAssetUrls } from "./media-asset-hardening" import type { LoadSourceAssetUrls } from "./media-assets" @@ -76,4 +77,5 @@ export type AnswerQuestionResult = { answer: string citations: ChatCitationView[] artifacts?: ChatArtifactView[] + retrievalTrace?: RetrievalTraceView } diff --git a/src/domains/chat/index.test.ts b/src/domains/chat/index.test.ts index 8eecc8e..524f408 100644 --- a/src/domains/chat/index.test.ts +++ b/src/domains/chat/index.test.ts @@ -75,6 +75,8 @@ describe("answerQuestionWithRetrieval", () => { query: "What does the document say?", topK: 8, useAgentic: true, + rerank: true, + internalRecallK: 30, dataType: 1, excludeDocumentIds: ["doc_excluded"], }); @@ -89,6 +91,17 @@ describe("answerQuestionWithRetrieval", () => { answer: "The answer is grounded.", citations: [result], artifacts: [], + retrievalTrace: { + queries: [ + { + namespace: "notebook-workspace", + query: "What does the document say?", + referencedChunkCount: 0, + resultCount: 1, + topScores: [0.9], + }, + ], + }, }); }); @@ -168,6 +181,24 @@ describe("answerQuestionWithRetrieval", () => { answer: "The legacy answer is grounded.", citations: [legacyResult], artifacts: [], + retrievalTrace: { + queries: [ + { + namespace: "default", + query: "legacy document answer", + referencedChunkCount: 0, + resultCount: 0, + topScores: [], + }, + { + namespace: "notebook-legacy", + query: "legacy document answer", + referencedChunkCount: 0, + resultCount: 1, + topScores: [0.9], + }, + ], + }, }); }); @@ -481,6 +512,8 @@ describe("answerQuestionWithRetrieval", () => { query: "SpaceX rocket photos", topK: 8, useAgentic: true, + rerank: true, + internalRecallK: 30, dataType: 3, }); expect(answer.answer).toBe("Use this launch photo."); @@ -1253,6 +1286,8 @@ describe("answerQuestionWithRetrieval", () => { query: "公民身份证明 图片", topK: 8, useAgentic: true, + rerank: true, + internalRecallK: 30, dataType: 3, }); const imageCitations = answer.citations.filter( @@ -1301,6 +1336,17 @@ describe("answerQuestionWithRetrieval", () => { answer: "I couldn't find that in your sources.", citations: [], artifacts: [], + retrievalTrace: { + queries: [ + { + namespace: "notebook-workspace", + query: "Missing fact?", + referencedChunkCount: 0, + resultCount: 0, + topScores: [], + }, + ], + }, }); }); @@ -1350,6 +1396,8 @@ describe("answerQuestionWithRetrieval", () => { query: "Tesla Q4 2025 Update energy generation and storage deployments", topK: 8, useAgentic: true, + rerank: true, + internalRecallK: 30, dataType: 1, }); expect(generateAnswer).toHaveBeenCalledWith({ @@ -1361,6 +1409,56 @@ describe("answerQuestionWithRetrieval", () => { }); }); + it("collects a retrieval trace entry per issued query", async () => { + const retrieval = { + query: vi + .fn() + .mockImplementation(async ({ query }: { readonly query: string }) => ({ + results: [], + evidenceText: null, + referencedChunks: [], + namespace: "notebook-workspace", + query, + routerUsed: "workflow_single_step", + answerText: null, + })), + }; + const generateAnswer = vi.fn(async ({ searchSources }) => { + await searchSources({ query: "query variant one" }); + await searchSources({ query: "query variant two" }); + return makeHarnessRunResult("Answer."); + }); + + const answer = await Effect.runPromise( + answerQuestionWithRetrieval({ + question: "Question", + namespace: "notebook-workspace", + sources: [makeSource()], + excludedSourceIds: [], + retrieval, + generateAnswer, + messages: [], + }), + ); + + expect(answer.retrievalTrace?.queries).toEqual([ + { + namespace: "notebook-workspace", + query: "query variant one", + referencedChunkCount: 0, + resultCount: 0, + topScores: [], + }, + { + namespace: "notebook-workspace", + query: "query variant two", + referencedChunkCount: 0, + resultCount: 0, + topScores: [], + }, + ]); + }); + it("does not append chat history to Knowhere tool queries", async () => { const retrieval = { query: vi.fn().mockResolvedValue({ @@ -1406,6 +1504,8 @@ describe("answerQuestionWithRetrieval", () => { query: "Tesla energy storage deployments", topK: 8, useAgentic: true, + rerank: true, + internalRecallK: 30, dataType: 1, }); expect(JSON.stringify(queryInput)).not.toContain( diff --git a/src/domains/chat/index.ts b/src/domains/chat/index.ts index 2d7245d..1d29aa6 100644 --- a/src/domains/chat/index.ts +++ b/src/domains/chat/index.ts @@ -9,6 +9,7 @@ import { logger } from "@/lib/logger" import type { ChatArtifactView, ChatCitationView, + RetrievalTraceView, } from "@/domains/chat/types" import type { DerivedTableArtifact, @@ -139,6 +140,8 @@ export const answerQuestionWithRetrieval = ( namespace, query: retrievalQueryParams.query, topK: retrievalQueryParams.topK, + rerank: retrievalQueryParams.rerank, + internalRecallK: retrievalQueryParams.internalRecallK, dataType: retrievalQueryParams.dataType ?? null, signalPathCount: retrievalQueryParams.signalPaths?.length ?? 0, filterMode: retrievalQueryParams.filterMode ?? null, @@ -264,15 +267,18 @@ export const answerQuestionWithRetrieval = ( }) const citationResults = hardenedMedia.results const displayArtifacts = hardenedMedia.artifacts ?? [] + const retrievalTrace = buildRetrievalTrace(retrievalResponses) logger.info("chat-agent: answer complete", { answerLength: answer.length, citationCount: citationResults.length, artifactCount: displayArtifacts.length, + retrievalQueryCount: retrievalTrace?.queries.length ?? 0, }) return { answer, citations: toChatCitationViews(citationResults, answer), artifacts: displayArtifacts, + retrievalTrace, } }) @@ -682,6 +688,29 @@ function joinResponseText( return uniqueValues.length > 0 ? uniqueValues.join(",") : null } +function buildRetrievalTrace( + responses: readonly RetrievalQueryResponse[], +): RetrievalTraceView | undefined { + if (responses.length === 0) return undefined + + const queries = responses.map((response) => { + const topScores = response.results + .map((result) => result.score) + .filter((score): score is number => typeof score === "number") + .sort((left, right) => right - left) + .slice(0, 5) + return { + query: response.query, + namespace: response.namespace, + resultCount: response.results.length, + referencedChunkCount: response.referencedChunks.length, + topScores, + } + }) + + return { queries } +} + function buildRetrievalQueryParams(input: { readonly input: AgenticRetrievalQuery readonly fallbackQuestion: string @@ -699,6 +728,8 @@ function buildRetrievalQueryParams(input: { query, topK: normalizeTopK(input.input.topK), useAgentic: true, + rerank: true, + internalRecallK: 30, dataType, ...(input.input.signalPaths && input.input.signalPaths.length > 0 ? { signalPaths: input.input.signalPaths } diff --git a/src/domains/chat/service.test.ts b/src/domains/chat/service.test.ts index a925781..228697f 100644 --- a/src/domains/chat/service.test.ts +++ b/src/domains/chat/service.test.ts @@ -58,6 +58,8 @@ describe("handleChatTurn", () => { query: "What does the document say?", topK: 8, useAgentic: true, + rerank: true, + internalRecallK: 30, dataType: 1, excludeDocumentIds: ["doc_excluded"], }); @@ -222,6 +224,8 @@ describe("handleChatTurn", () => { query: "Tesla Q4 2025 Update energy generation and storage deployments", topK: 8, useAgentic: true, + rerank: true, + internalRecallK: 30, dataType: 1, }); }); diff --git a/src/domains/chat/service.ts b/src/domains/chat/service.ts index 5361d5b..616aadd 100644 --- a/src/domains/chat/service.ts +++ b/src/domains/chat/service.ts @@ -14,6 +14,7 @@ import type { ChatArtifactView, ChatCitationView, ChatMessageView, + RetrievalTraceView, } from "@/domains/chat/types" export type ChatRepository = { @@ -57,6 +58,7 @@ const threadNotFound = { export type ChatTurnValue = { threadId: string messages: [ChatMessageView, ChatMessageView] + retrievalTrace?: RetrievalTraceView } type ChatTurnInput = { @@ -148,8 +150,14 @@ export const handleChatTurnEffect = (input: ChatTurnInput) => threadId: thread.id, messages: [ toChatMessageView(userMessage), - toChatMessageView(assistantMessage, answer.citations, answer.artifacts), + toChatMessageView( + assistantMessage, + answer.citations, + answer.artifacts, + answer.retrievalTrace, + ), ] as [ChatMessageView, ChatMessageView], + retrievalTrace: answer.retrievalTrace, } }) diff --git a/src/domains/chat/types.ts b/src/domains/chat/types.ts index f3f987d..8c64f43 100644 --- a/src/domains/chat/types.ts +++ b/src/domains/chat/types.ts @@ -50,6 +50,24 @@ export type ChatMessageView = { readonly content: string readonly citations?: readonly ChatCitationView[] readonly artifacts?: readonly ChatArtifactView[] + readonly retrievalTrace?: RetrievalTraceView +} + +/** + * Transient retrieval trace attached to a fresh assistant message. It is + * returned by the chat route and held in client state only; it is never + * persisted to the chat message row. + */ +export type RetrievalTraceEntryView = { + readonly query: string + readonly namespace: string + readonly resultCount: number + readonly referencedChunkCount: number + readonly topScores: readonly number[] +} + +export type RetrievalTraceView = { + readonly queries: readonly RetrievalTraceEntryView[] } export type ChatThreadView = { diff --git a/src/domains/chat/view.ts b/src/domains/chat/view.ts index 9afc4f6..b009fcc 100644 --- a/src/domains/chat/view.ts +++ b/src/domains/chat/view.ts @@ -5,6 +5,7 @@ import type { ChatCitationView, ChatMessageView, ChatThreadView, + RetrievalTraceView, } from "@/domains/chat/types" export function toChatThreadView(thread: ChatThread): ChatThreadView { @@ -20,6 +21,7 @@ export function toChatMessageView( message: ChatMessage, citations: readonly ChatCitationView[] = [], artifacts?: readonly ChatArtifactView[], + retrievalTrace?: RetrievalTraceView, ): ChatMessageView { const citationViews = citations.length > 0 @@ -37,6 +39,7 @@ export function toChatMessageView( content: message.content, citations: citationViews, ...(artifactViews !== undefined ? { artifacts: artifactViews } : {}), + ...(retrievalTrace ? { retrievalTrace } : {}), } } diff --git a/src/domains/chunks/server.ts b/src/domains/chunks/server.ts index 214d77a..e431f73 100644 --- a/src/domains/chunks/server.ts +++ b/src/domains/chunks/server.ts @@ -223,7 +223,7 @@ export const loadChunkPageForSource = ( : revisionProbeResponse if (includeAssetUrls) { yield* Effect.tryPromise(() => - enrichChunksWithAssetUrls(source.knowhereDocumentId!, response, client), + enrichChunksWithAssetUrls(source.knowhereDocumentId!, response), ).pipe( Effect.catchAll(() => Effect.void), ) @@ -691,7 +691,6 @@ function getMirroredAssetContentType( async function enrichChunksWithAssetUrls( documentId: string, response: { readonly chunks: readonly DocumentChunk[] }, - client: ChunkKnowhereClient, ): Promise { const tableChunks = response.chunks.filter( (chunk) => From 4c87f5bd3c6f1a353d43cebfcf95b731f8d9b842 Mon Sep 17 00:00:00 2001 From: Gordon YUEN Date: Sat, 1 Aug 2026 11:28:07 +0800 Subject: [PATCH 19/46] docs: document BM25 reranking, multi-query guidance, and transient retrieval trace --- AGENTS.md | 1 + CONTEXT.md | 14 +++++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 7e9b90f..3f1d588 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,6 +82,7 @@ src/ - **Database driver:** `DATABASE_DRIVER=pg` for local dev (postgres-js), `neon` (default) for Vercel/Neon production. - **Auth:** Dashboard is the source of truth. Notebook forwards the session cookie; it never decodes tokens. `KNOWHERE_API_KEY` env enables API-key dev mode (skips Dashboard auth, uses a deterministic local user). - **Chat provider:** two backends in `src/lib/ai.ts` — `AI_GATEWAY_API_KEY` (Vercel AI Gateway, model as plain string) OR `CHAT_BASE_URL`+`CHAT_API_KEY`+`CHAT_MODEL` (OpenAI-compatible `LanguageModelV3`). Use `getChatModel()`/`isChatConfigured()`; never reintroduce per-call-site `AI_GATEWAY_API_KEY` guards. `@ai-sdk/openai-compatible` is pinned to 2.x (provider V3) to match `ai@6`. +- **BM25 retrieval:** retrieval queries run with `rerank: true` and `internalRecallK: 30` (`buildRetrievalQueryParams` in `src/domains/chat/index.ts`) to compensate for BM25 keyword ranking. The harness system prompt (`src/agent-harness/runtime.ts`) instructs keyword-crafting, query expansion, and multiple focused `retrieve` calls for multi-part questions. The transient `RetrievalTraceView` (query, namespace, hits, top scores) rides on fresh assistant messages and is rendered by `ChatRetrievalTrace` — it is never persisted to the chat message row, so don't persist or serialize it from the DB. - **Vercel Blob is optional:** the chunk-page cache (`src/domains/chunks/server.ts`) is gated on `BLOB_READ_WRITE_TOKEN`; without it the cache is skipped and chunks are served straight from Knowhere. Don't add hard `@vercel/blob` calls in request paths without gating on the token or wrapping in a read-failure-as-miss handler. - **Table chunk enrichment:** the Knowhere `listChunks` endpoint returns `assetUrl` for table/image chunks but the HTML is at that URL, not in `chunk.content` (which holds a summary). `enrichChunksWithAssetUrls` in `src/domains/chunks/server.ts` fetches the HTML from `assetUrl` server-side after the list call and sets it as `chunk.content` so `TableChunkCard`'s `getSanitizedTableHtml` can detect and render it. This avoids browser CORS issues with LocalStack S3 URLs. Requires `--add-host localhost.localstack.cloud:host-gateway` in Docker. - **Fonts:** use the local `geist` package (`GeistSans`/`GeistMono` from `geist/font/*`), not `next/font/google` — the repo runs in airgapped/self-hosted setups where Google Fonts is unreachable. diff --git a/CONTEXT.md b/CONTEXT.md index 5e3f979..236287e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -180,7 +180,19 @@ enough to focus the answer evidence. A Retrieval Query is the text sent to Knowhere retrieval. It can be generated from the latest user question plus recent chat context so Knowhere receives a -self-contained query. +self-contained query. Retrieval runs with `useAgentic: true`, `rerank: true`, +and `internalRecallK: 30` so the LLM reranker compensates for BM25 keyword +ranking. The harness system prompt teaches the agent to craft BM25-friendly +queries: distinctive keywords, query expansion with synonyms/domain terms, and +multiple focused `retrieve` calls for multi-part or ambiguous questions. + +## Retrieval Trace + +A Retrieval Trace is the transient record of every Retrieval Query issued while +answering one user question: query text, namespace, hit count, cited chunk +count, and top scores. It is attached to a fresh assistant Chat Message view +and rendered by `ChatRetrievalTrace` under the sources section, but it is never +persisted to the Chat Message row — reloading the thread drops it. ## Dashboard Auth From 2e2c1a50423c5647d81c1fd83dae374a6c164131 Mon Sep 17 00:00:00 2001 From: Gordon YUEN Date: Sat, 1 Aug 2026 23:02:46 +0800 Subject: [PATCH 20/46] feat(chat): foldable source sections, wand template menu from JSON, retrieval tuning controls - Sources and Retrieval blocks in assistant messages are collapsed by default via a shared CollapsibleSection (Base UI Collapsible) with a chevron trigger and count badge - Composer Create button becomes an icon-only WandSparkles trigger with a Templates tooltip; canned prompts moved to public/data/chat-prompt-templates.json, fetched client-side by usePromptTemplates (cache-busted) so self-hosted deployments can override them by bind-mounting a JSON over the container path - Composer gains retrieval tuning controls: a Rerank switch, and Recall K (5-50) and Top K (1-12) sliders. Values travel as optional retrievalParams in the chat request body (validated + clamped server-side) and override the hardcoded defaults and harness-chosen topK via RetrievalOverrides - Installs @base-ui/react and the shadcn collapsible/switch/slider primitives; adds tests for parsing, overrides, controls, and folded sections; updates AGENTS.md + CONTEXT.md --- AGENTS.md | 5 +- CONTEXT.md | 17 ++ package.json | 1 + pnpm-lock.yaml | 58 ++++++ public/data/chat-prompt-templates.json | 17 ++ src/components/chat-composer.test.ts | 79 +++++++- src/components/chat-composer.tsx | 215 ++++++++++++++++++---- src/components/chat-message-list.test.ts | 24 ++- src/components/chat-message-list.tsx | 8 +- src/components/chat-panel.test.ts | 30 ++- src/components/chat-panel.tsx | 10 +- src/components/chat-retrieval-trace.tsx | 13 +- src/components/collapsible-section.tsx | 44 +++++ src/components/ui/collapsible.tsx | 21 +++ src/components/ui/slider.tsx | 52 ++++++ src/components/ui/switch.tsx | 32 ++++ src/components/use-prompt-templates.ts | 57 ++++++ src/components/workspace-chat-workflow.ts | 12 +- src/components/workspace-shell-layout.tsx | 6 +- src/components/workspace-shell.test.ts | 13 +- src/domains/chat/contracts.ts | 12 ++ src/domains/chat/index.test.ts | 43 +++++ src/domains/chat/index.ts | 10 +- src/domains/chat/prompt-templates.ts | 35 ---- src/domains/chat/request.test.ts | 84 +++++++++ src/domains/chat/request.ts | 57 ++++++ src/domains/chat/route-answer.ts | 1 + src/domains/chat/service.ts | 3 + src/domains/workspace/client.ts | 2 + 29 files changed, 847 insertions(+), 114 deletions(-) create mode 100644 public/data/chat-prompt-templates.json create mode 100644 src/components/collapsible-section.tsx create mode 100644 src/components/ui/collapsible.tsx create mode 100644 src/components/ui/slider.tsx create mode 100644 src/components/ui/switch.tsx create mode 100644 src/components/use-prompt-templates.ts create mode 100644 src/domains/chat/request.test.ts diff --git a/AGENTS.md b/AGENTS.md index 3f1d588..4589ccd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,7 +47,7 @@ details when the documentation isn't enough. - **Integration tests:** `pnpm test:integration` (needs `TEST_DATABASE_URL`; script currently globs `src/lib/*.integration.test.ts` which has no matches — real integration tests are in `src/domains/`) - **DB schema push:** `pnpm db:push --force` (dev; `--force` skips the TTY prompt because `drizzle.config.ts` sets `strict: true`). drizzle-kit does **not** load `.env.local`, so pass it inline: `DATABASE_URL=… pnpm db:push --force`. `pnpm db:migrate` for prod. - **Build:** `pnpm build` -- **Docker image:** `docker build -t knowhere-notebook:dev .` then `docker run -d --name knowhere-notebook -p 3001:3000 --add-host localhost.localstack.cloud:host-gateway --env-file .env.docker knowhere-notebook:dev` (standalone, non-root, port 3000). The `--add-host` flag is required for self-hosted Knowhere with LocalStack S3 so the container can resolve `localhost.localstack.cloud` to the host gateway (used for fetching table/image chunk assets server-side). +- **Docker image:** `docker build -t knowhere-notebook:dev .` then `docker run -d --name knowhere-notebook -p 3001:3000 --add-host localhost.localstack.cloud:host-gateway --env-file .env.docker knowhere-notebook:dev` (standalone, non-root, port 3000). The `--add-host` flag is required for self-hosted Knowhere with LocalStack S3 so the container can resolve `localhost.localstack.cloud` to the host gateway (used for fetching table/image chunk assets server-side). To override the chat prompt templates with your own file, bind-mount it over the built-in one (host file must be world-readable, e.g. `chmod 644`): `-v /host/path/chat-prompt-templates.json:/app/public/data/chat-prompt-templates.json:ro`. CI runs: `lint → typecheck → test → build` on PRs to `main` and `staging`. @@ -83,6 +83,9 @@ src/ - **Auth:** Dashboard is the source of truth. Notebook forwards the session cookie; it never decodes tokens. `KNOWHERE_API_KEY` env enables API-key dev mode (skips Dashboard auth, uses a deterministic local user). - **Chat provider:** two backends in `src/lib/ai.ts` — `AI_GATEWAY_API_KEY` (Vercel AI Gateway, model as plain string) OR `CHAT_BASE_URL`+`CHAT_API_KEY`+`CHAT_MODEL` (OpenAI-compatible `LanguageModelV3`). Use `getChatModel()`/`isChatConfigured()`; never reintroduce per-call-site `AI_GATEWAY_API_KEY` guards. `@ai-sdk/openai-compatible` is pinned to 2.x (provider V3) to match `ai@6`. - **BM25 retrieval:** retrieval queries run with `rerank: true` and `internalRecallK: 30` (`buildRetrievalQueryParams` in `src/domains/chat/index.ts`) to compensate for BM25 keyword ranking. The harness system prompt (`src/agent-harness/runtime.ts`) instructs keyword-crafting, query expansion, and multiple focused `retrieve` calls for multi-part questions. The transient `RetrievalTraceView` (query, namespace, hits, top scores) rides on fresh assistant messages and is rendered by `ChatRetrievalTrace` — it is never persisted to the chat message row, so don't persist or serialize it from the DB. +- **Retrieval overrides:** the chat composer exposes rerank (Switch), Recall K (Slider 5–50), and Top K (Slider 1–12) controls. They travel as optional `retrievalParams` in the chat request body (`src/domains/chat/request.ts` — schema validates + clamps) and override the hardcoded defaults / harness-chosen topK via `RetrievalOverrides` in `answerQuestionWithRetrieval`. Keep current values as UI defaults (`rerank: true`, `internalRecallK: 30`, `topK: 8`). +- **Chat prompt templates:** canned prompts live in `public/data/chat-prompt-templates.json` (`{ id, title, prompt }[]`), fetched client-side by `usePromptTemplates` (cache-busted) and shown in the composer's wand-icon Templates dropdown. Override at runtime by bind-mounting your own JSON over `/app/public/data/chat-prompt-templates.json:ro` — no rebuild needed. `src/domains/chat/prompt-templates.ts` holds only the `ChatPromptTemplate` type now. +- **Folded chat sections:** assistant "Sources" and "Retrieval" blocks are collapsed by default via the shared `CollapsibleSection` (`src/components/collapsible-section.tsx`, Base UI Collapsible). Trigger is the label row with a chevron; badge shows counts. - **Vercel Blob is optional:** the chunk-page cache (`src/domains/chunks/server.ts`) is gated on `BLOB_READ_WRITE_TOKEN`; without it the cache is skipped and chunks are served straight from Knowhere. Don't add hard `@vercel/blob` calls in request paths without gating on the token or wrapping in a read-failure-as-miss handler. - **Table chunk enrichment:** the Knowhere `listChunks` endpoint returns `assetUrl` for table/image chunks but the HTML is at that URL, not in `chunk.content` (which holds a summary). `enrichChunksWithAssetUrls` in `src/domains/chunks/server.ts` fetches the HTML from `assetUrl` server-side after the list call and sets it as `chunk.content` so `TableChunkCard`'s `getSanitizedTableHtml` can detect and render it. This avoids browser CORS issues with LocalStack S3 URLs. Requires `--add-host localhost.localstack.cloud:host-gateway` in Docker. - **Fonts:** use the local `geist` package (`GeistSans`/`GeistMono` from `geist/font/*`), not `next/font/google` — the repo runs in airgapped/self-hosted setups where Google Fonts is unreachable. diff --git a/CONTEXT.md b/CONTEXT.md index 236287e..3033611 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -186,6 +186,15 @@ ranking. The harness system prompt teaches the agent to craft BM25-friendly queries: distinctive keywords, query expansion with synonyms/domain terms, and multiple focused `retrieve` calls for multi-part or ambiguous questions. +## Retrieval Overrides + +Retrieval Overrides are optional per-request tuning values that the chat +composer sends in the chat request body as `retrievalParams`: the `rerank` +switch and the `internalRecallK` / `topK` sliders. Each present field replaces +the equivalent hardcoded default — or, for `topK`, the harness-chosen per-query +value — inside `buildRetrievalQueryParams`. The request schema validates and +clamps them server-side. + ## Retrieval Trace A Retrieval Trace is the transient record of every Retrieval Query issued while @@ -194,6 +203,14 @@ count, and top scores. It is attached to a fresh assistant Chat Message view and rendered by `ChatRetrievalTrace` under the sources section, but it is never persisted to the Chat Message row — reloading the thread drops it. +## Prompt Template + +A Prompt Template is a canned `{ id, title, prompt }` analysis prompt offered +by the composer's wand-icon Templates menu. Templates are loaded at runtime +from `public/data/chat-prompt-templates.json` by `usePromptTemplates`, so +self-hosted deployments can override them by bind-mounting their own JSON into +the container without a rebuild. + ## Dashboard Auth Dashboard Auth is the source of truth for identity. Notebook forwards the diff --git a/package.json b/package.json index 4c43742..c084d33 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@ai-sdk/openai-compatible": "2.0.63", "@ai-sdk/react": "^3.0.177", "@antv/chart-visualization-skills": "0.1.3", + "@base-ui/react": "^1.6.0", "@effect/platform": "^0.96.1", "@neondatabase/serverless": "^1.1.0", "@ontos-ai/knowhere-sdk": "^2.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 28aa4f8..c3710c3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@antv/chart-visualization-skills': specifier: 0.1.3 version: 0.1.3 + '@base-ui/react': + specifier: ^1.6.0 + version: 1.6.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@effect/platform': specifier: ^0.96.1 version: 0.96.1(effect@3.21.2) @@ -398,6 +401,33 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@base-ui/react@1.6.0': + resolution: {integrity: sha512-/jzjTWJYXhRFO45Bev9lc3cHbmjzCMpUqbMZ2AgKy/z25mY9B6shGSNcXcjQar9n5doM0KYW1W8fcFv2jZBuMw==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@date-fns/tz': ^1.2.0 + '@types/react': ^17 || ^18 || ^19 + date-fns: ^4.0.0 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + '@date-fns/tz': + optional: true + '@types/react': + optional: true + date-fns: + optional: true + + '@base-ui/utils@0.3.1': + resolution: {integrity: sha512-gFFiltORVmW/N6IILTGxizP3PBpVpysqML1ALY5Vk0mH+7faVkCknOU31goYHN5Aoek2dkjxva1XOD2Ce9WuIg==} + peerDependencies: + '@types/react': ^17 || ^18 || ^19 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + '@bramus/specificity@2.4.2': resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} hasBin: true @@ -4683,6 +4713,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + reselect@5.2.0: + resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -5704,6 +5737,29 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@base-ui/react@1.6.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@babel/runtime': 7.29.2 + '@base-ui/utils': 0.3.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@floating-ui/utils': 0.2.11 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + + '@base-ui/utils@0.3.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@babel/runtime': 7.29.2 + '@floating-ui/utils': 0.2.11 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + reselect: 5.2.0 + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@bramus/specificity@2.4.2': dependencies: css-tree: 3.2.1 @@ -9975,6 +10031,8 @@ snapshots: require-from-string@2.0.2: {} + reselect@5.2.0: {} + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} diff --git a/public/data/chat-prompt-templates.json b/public/data/chat-prompt-templates.json new file mode 100644 index 0000000..f76cea2 --- /dev/null +++ b/public/data/chat-prompt-templates.json @@ -0,0 +1,17 @@ +[ + { + "id": "ipo-prospectus-risk-mining", + "title": "IPO Prospectus Risk Mining", + "prompt": "You are a risk analyst specializing in IPO pricing. I have uploaded the prospectus of [Company Name].\nPlease complete the following tasks:\n1. Extract all risk items from the \"Risk Factors\" section and categorize them into: Market Risk/Operational Risk/Legal and Compliance Risk/Technical Risk/Competitive Risk.\n2. Identify which risk items use hedging language such as \"may\", \"might\", or \"could\", and which use more definitive language such as \"will\" or \"has\". Provide the results in a structured format." + }, + { + "id": "earnings-call-transcript-analysis", + "title": "Earnings Call Transcript Analysis", + "prompt": "You are a sell-side research analyst preparing a post earnings flash note. I have uploaded the earnings release and earnings call transcript of [Company Name].\nPlease complete the following tasks:\n1. Extract the management's original wording on the following topics: Revenue guidance/Gross margin pressure/Specific business line.\n2. Identify analyst questions that management sidestepped or shifted away from.\n3. Extract all forward-looking statements that contain specific numbers, and organize them into a guidance tracking table." + }, + { + "id": "research-paper-method-comparison", + "title": "Research Paper Method Comparison", + "prompt": "You are a PhD researcher writing a paper in [Research Area]. I have uploaded recent top conference and journal papers in this area.\nPlease analyze the papers and produce the following:\n1. Extract the three core elements for each paper: Dataset/Evaluation metrics/Model architecture. Present the results in a comparison table.\n2. Identify the unresolved issues repeatedly mentioned in the \"Limitations\" or \"Future Work\" sections across the papers, and present them as a list.\n3. Identify emerging technical terms appearing in the papers, assess whether they indicate a new research trend, and output a list of trend keywords." + } +] diff --git a/src/components/chat-composer.test.ts b/src/components/chat-composer.test.ts index 1d7051b..6990f89 100644 --- a/src/components/chat-composer.test.ts +++ b/src/components/chat-composer.test.ts @@ -9,13 +9,44 @@ import { within, } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ChatComposer } from "./chat-composer"; +const templatePrompts = { + "ipo-prospectus-risk-mining": + "You are a risk analyst specializing in IPO pricing. I have uploaded the prospectus of [Company Name].", + "earnings-call-transcript-analysis": + "You are a sell-side research analyst preparing a post earnings flash note. I have uploaded the earnings release and earnings call transcript of [Company Name].", +}; + +const promptTemplatesResponse = [ + { + id: "ipo-prospectus-risk-mining", + title: "IPO Prospectus Risk Mining", + prompt: templatePrompts["ipo-prospectus-risk-mining"], + }, + { + id: "earnings-call-transcript-analysis", + title: "Earnings Call Transcript Analysis", + prompt: templatePrompts["earnings-call-transcript-analysis"], + }, +]; + describe("ChatComposer", () => { + beforeEach(() => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => promptTemplatesResponse, + }), + ); + }); + afterEach(() => { cleanup(); + vi.unstubAllGlobals(); }); it("sends trimmed input and clears the composer", async () => { @@ -28,10 +59,46 @@ describe("ChatComposer", () => { await user.type(input, " Summarize this document "); await user.click(screen.getByRole("button", { name: "Send message" })); - expect(onSend).toHaveBeenCalledWith("Summarize this document"); + expect(onSend).toHaveBeenCalledWith("Summarize this document", { + rerank: true, + internalRecallK: 30, + topK: 8, + }); expect(input.value).toBe(""); }); + it("renders retrieval controls with defaults", () => { + render(React.createElement(ChatComposer)); + + const rerankSwitch = screen.getByRole("switch", { name: /^Rerank/ }); + expect(rerankSwitch.getAttribute("aria-checked")).toBe("true"); + expect(screen.getAllByRole("slider", { hidden: true }).length).toBeGreaterThanOrEqual(2); + expect(screen.getByText("Recall K")).toBeTruthy(); + expect(screen.getByText("Top K")).toBeTruthy(); + expect(screen.getByText("30")).toBeTruthy(); + expect(screen.getByText("8")).toBeTruthy(); + }); + + it("sends changed retrieval params when the switch is toggled", async () => { + const user = userEvent.setup(); + const onSend = vi.fn(); + + render(React.createElement(ChatComposer, { onSend })); + + await user.click(screen.getByRole("switch", { name: /^Rerank/ })); + await user.type( + screen.getByPlaceholderText("Ask a question about your documents…"), + "Question", + ); + await user.click(screen.getByRole("button", { name: "Send message" })); + + expect(onSend).toHaveBeenCalledWith("Question", { + rerank: false, + internalRecallK: 30, + topK: 8, + }); + }); + it("caps long prompts and resets the composer after sending", async () => { const user = userEvent.setup(); const onSend = vi.fn(); @@ -97,7 +164,7 @@ describe("ChatComposer", () => { const input = getComposerTextArea(); input.scrollTop = 92; - await user.click(screen.getByRole("button", { name: "Create" })); + await user.click(screen.getByRole("button", { name: "Templates" })); await user.click( screen.getByRole("menuitem", { name: /IPO Prospectus Risk Mining/ }), ); @@ -133,7 +200,7 @@ describe("ChatComposer", () => { render(React.createElement(ChatComposer)); - await user.click(screen.getByRole("button", { name: "Create" })); + await user.click(screen.getByRole("button", { name: "Templates" })); await user.click( screen.getByRole("menuitem", { name: /IPO Prospectus Risk Mining/ }), ); @@ -158,7 +225,7 @@ describe("ChatComposer", () => { render(React.createElement(ChatComposer)); - await user.click(screen.getByRole("button", { name: "Create" })); + await user.click(screen.getByRole("button", { name: "Templates" })); await user.click( screen.getByRole("menuitem", { name: /Earnings Call Transcript Analysis/, @@ -190,7 +257,7 @@ describe("ChatComposer", () => { expect(input.className).toContain("max-h-[192px]"); expect(input.className).toContain("border-0"); expect(input.className).toContain("shadow-none"); - expect(screen.getByRole("button", { name: "Create" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Templates" })).toBeTruthy(); }); }); diff --git a/src/components/chat-composer.tsx b/src/components/chat-composer.tsx index 4cfa62c..40239e5 100644 --- a/src/components/chat-composer.tsx +++ b/src/components/chat-composer.tsx @@ -10,8 +10,9 @@ import { type MouseEvent, type ReactElement, } from "react"; -import { BarChart3, FileText, Plus, Send } from "lucide-react"; +import { BarChart3, FileText, Send, WandSparkles } from "lucide-react"; +import { usePromptTemplates } from "@/components/use-prompt-templates"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -22,7 +23,16 @@ import { } from "@/components/ui/dropdown-menu"; import { Spinner } from "@/components/ui/spinner"; import { Textarea } from "@/components/ui/textarea"; -import { chatPromptTemplates } from "@/domains/chat/prompt-templates"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import type { ChatPromptTemplate } from "@/domains/chat/prompt-templates"; +import type { RetrievalOverrides } from "@/domains/chat/contracts"; +import { Slider } from "@/components/ui/slider"; +import { Switch } from "@/components/ui/switch"; const chatComposerName = "chat-composer"; const chatComposerTextAreaMinHeight = 128; const chatComposerTextAreaMaxHeight = 192; @@ -40,7 +50,7 @@ export type ChatComposerProps = { readonly isSending?: boolean; readonly onCreateDiagram?: () => void; readonly onLoginClick?: () => void; - readonly onSend?: (text: string) => void; + readonly onSend?: (text: string, retrievalParams: RetrievalOverrides) => void; }; export function ChatComposer({ @@ -53,9 +63,15 @@ export function ChatComposer({ onSend, }: ChatComposerProps): ReactElement { const [input, setInput] = useState(""); + const [retrievalParams, setRetrievalParams] = useState({ + rerank: true, + internalRecallK: 30, + topK: 8, + }); const composerInputId = useId(); const pendingTemplatePromptRef = useRef(null); const textareaRef = useRef(null); + const { isLoading: isLoadingTemplates, templates } = usePromptTemplates(); const trimmedInput = input.trim(); const canSend = !isDisabled && !isSending && trimmedInput.length > 0; @@ -79,7 +95,7 @@ export function ChatComposer({ function handleSend(): void { if (!canSend) return; - onSend?.(trimmedInput); + onSend?.(trimmedInput, retrievalParams); setInput(""); } @@ -168,14 +184,21 @@ export function ChatComposer({ onKeyDown={handleKeyDown} /> +
- + + + + + + + + Templates + + - {chatPromptTemplates.map((template) => ( - onTemplateSelect(template.prompt)} - > - - {template.title} - - ))} - {onCreateDiagram ? ( + {isLoadingTemplates ? ( +
+ + Loading templates +
+ ) : ( <> - - - {isCreatingDiagram ? ( - - ) : ( - - )} - {isCreatingDiagram ? "Creating diagram" : "Create diagram"} - + {templates.map((template) => ( + onTemplateSelect(template.prompt)} + > + + {template.title} + + ))} + {onCreateDiagram ? ( + <> + + + {isCreatingDiagram ? ( + + ) : ( + + )} + {isCreatingDiagram ? "Creating diagram" : "Create diagram"} + + + ) : null} - ) : null} + )}
); diff --git a/src/components/chat-message-list.test.ts b/src/components/chat-message-list.test.ts index b68e0a7..4bd3c37 100644 --- a/src/components/chat-message-list.test.ts +++ b/src/components/chat-message-list.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import React from "react"; -import { cleanup, render, screen, within } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -28,6 +28,13 @@ describe("ChatMessageList", () => { vi.restoreAllMocks(); }); + function expandSection(title: string): void { + const trigger = screen.getByRole("button", { + name: new RegExp(`^${title}`), + }); + fireEvent.click(trigger); + } + it("renders assistant citations using Notebook source labels", () => { render( React.createElement(ChatMessageList, { @@ -55,6 +62,14 @@ describe("ChatMessageList", () => { }), ); + expect( + screen.getByRole("button", { name: "Sources1" }).getAttribute( + "aria-expanded", + ), + ).toBe("false"); + + expandSection("Sources"); + expect( screen.getByRole("button", { name: "Open source Syllabus.pdf" }), ).toBeTruthy(); @@ -84,7 +99,8 @@ describe("ChatMessageList", () => { }), ); - expect(screen.getByText("Retrieval")).toBeTruthy(); + expandSection("Retrieval"); + expect(screen.getByText("deadline monday")).toBeTruthy(); expect(screen.getByText("3 hits")).toBeTruthy(); expect(screen.getByText("1 cited chunk")).toBeTruthy(); @@ -162,7 +178,7 @@ describe("ChatMessageList", () => { .toBeTruthy(); expect(screen.queryByText(/Source 1/u)).toBeNull(); expect(screen.queryByText(/Source 3/u)).toBeNull(); - expect(screen.getByText("Sources")).toBeTruthy(); + expandSection("Sources"); const sourceChips = screen.getAllByRole("button", { name: "Open source spacex-s1.pdf", }); @@ -368,6 +384,7 @@ describe("ChatMessageList", () => { ); expect(screen.queryByRole("img")).toBeNull(); + expandSection("Sources"); expect( screen.getByRole("button", { name: "Open source source.pdf", @@ -505,6 +522,7 @@ describe("ChatMessageList", () => { name: "商务标文件.pdf · 二、法定代表人身份证明", }), ).toBeTruthy(); + expandSection("Sources"); expect( screen.getAllByRole("button", { name: "Open source 商务标文件.pdf", diff --git a/src/components/chat-message-list.tsx b/src/components/chat-message-list.tsx index 9eca4a9..f1d0816 100644 --- a/src/components/chat-message-list.tsx +++ b/src/components/chat-message-list.tsx @@ -11,6 +11,7 @@ import remarkGfm from "remark-gfm"; import { ChatDiagramCard } from "@/components/chat-diagram-card"; import { useChatMessageListWorkflow } from "@/components/chat-message-list-workflow"; +import { CollapsibleSection } from "@/components/collapsible-section"; import { ChatRetrievalTrace } from "@/components/chat-retrieval-trace"; import { chatPanelModel } from "@/components/chat-panel-model"; import { ScrollArea } from "@/components/ui/scroll-area"; @@ -592,10 +593,7 @@ function AssistantSources({ if (displayCitations.length === 0) return null; return ( -
-

- Sources -

+
{displayCitations.map((displayCitation) => ( @@ -611,7 +609,7 @@ function AssistantSources({ ))}
-
+ ); } diff --git a/src/components/chat-panel.test.ts b/src/components/chat-panel.test.ts index 6da403b..8dc76cf 100644 --- a/src/components/chat-panel.test.ts +++ b/src/components/chat-panel.test.ts @@ -1,6 +1,12 @@ // @vitest-environment jsdom import React from "react"; -import { cleanup, render, screen, within } from "@testing-library/react"; +import { + cleanup, + fireEvent, + render, + screen, + within, +} from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -41,6 +47,10 @@ describe("ChatPanel", () => { vi.restoreAllMocks(); }); + function expandSources(): void { + fireEvent.click(screen.getByRole("button", { name: /^Sources/ })); + } + it("explains answers in plain source-based language", () => { const { container } = render( React.createElement(C, { @@ -77,6 +87,7 @@ describe("ChatPanel", () => { }), ); + expandSources(); expect( screen.getByRole("button", { name: "Open source syllabus.pdf", @@ -122,7 +133,7 @@ describe("ChatPanel", () => { }), ).toBeNull(); - await user.click(screen.getByRole("button", { name: "Create" })); + await user.click(screen.getByRole("button", { name: "Templates" })); await user.click( screen.getByRole("menuitem", { name: "Create diagram from latest answer", @@ -166,7 +177,7 @@ describe("ChatPanel", () => { }), ).toBeNull(); - await user.click(screen.getByRole("button", { name: "Create" })); + await user.click(screen.getByRole("button", { name: "Templates" })); await user.click( screen.getByRole("menuitem", { name: "Create diagram from latest answer", @@ -253,7 +264,11 @@ describe("ChatPanel", () => { ); await user.click(screen.getByRole("button", { name: "Send message" })); - expect(onSend).toHaveBeenCalledWith("Summarize revenue"); + expect(onSend).toHaveBeenCalledWith("Summarize revenue", { + rerank: true, + internalRecallK: 30, + topK: 8, + }); expect( analyticsMocks.trackNotebookAssistantQuestionSubmitted, ).toHaveBeenCalledWith({ @@ -297,6 +312,7 @@ describe("ChatPanel", () => { }), ); + expandSources(); expect( screen.getByRole("button", { name: "Open source TSLA-Q4-2025-Update.pdf", @@ -359,6 +375,7 @@ describe("ChatPanel", () => { }), ); + expandSources(); const duplicatedSourceLinks = screen.getAllByRole("button", { name: "Open source Micron Q1-26 Earnings Deck_R.pdf", }); @@ -415,6 +432,7 @@ describe("ChatPanel", () => { }), ); + expandSources(); const duplicatedLabelLinks = screen.getAllByRole("button", { name: "Open source report.pdf", }); @@ -458,11 +476,11 @@ describe("ChatPanel", () => { }), ); + expandSources(); const citationButton = screen.getByRole("button", { name: "Open source syllabus.pdf", }); - expect(screen.getByText("Sources")).toBeTruthy(); expect(citationButton.getAttribute("aria-busy")).toBe("true"); expect(citationButton.textContent).toBe("syllabus.pdf"); expect(citationButton.className).toContain("rounded-md"); @@ -512,11 +530,11 @@ describe("ChatPanel", () => { }), ); + expandSources(); const sourceLink = screen.getByRole("button", { name: /Open source TSLA-Q4-2025-UPDATE\.PDF/, }); - expect(screen.getByText("Sources")).toBeTruthy(); expect(sourceLink.className).toContain("max-w-[250px]"); expect(sourceLink.className).toContain("rounded-md"); expect(sourceLink.className).not.toContain("underline"); diff --git a/src/components/chat-panel.tsx b/src/components/chat-panel.tsx index 92d3cbf..06a156c 100644 --- a/src/components/chat-panel.tsx +++ b/src/components/chat-panel.tsx @@ -37,6 +37,7 @@ import type { ChatMessageView, ChatThreadView, } from "@/domains/chat/types"; +import type { RetrievalOverrides } from "@/domains/chat/contracts"; import { workspaceClient } from "@/domains/workspace/client"; import { trackNotebookAssistantQuestionSubmitted, @@ -47,7 +48,7 @@ export type ChatPanelProps = { messages: ChatMessageView[]; threads: ChatThreadView[]; activeThreadId?: string | null; - onSend?: (text: string) => void; + onSend?: (text: string, retrievalParams?: RetrievalOverrides) => void; onNewChat?: () => void; onThreadSelect?: (threadId: string) => void; onThreadArchive?: (threadId: string) => void; @@ -152,7 +153,10 @@ export function ChatPanel({ } } - function handleComposerSend(text: string): void { + function handleComposerSend( + text: string, + retrievalParams?: RetrievalOverrides, + ): void { if (isCreateDiagramCommand(text)) { void handleCreateDiagramCommand(); return; @@ -165,7 +169,7 @@ export function ChatPanel({ sourceCountSnapshot: sourceCount, messageLength: text.length, }); - onSend?.(text); + onSend?.(text, retrievalParams); } return ( diff --git a/src/components/chat-retrieval-trace.tsx b/src/components/chat-retrieval-trace.tsx index 5cfadfe..e810426 100644 --- a/src/components/chat-retrieval-trace.tsx +++ b/src/components/chat-retrieval-trace.tsx @@ -3,6 +3,7 @@ import { type ReactElement } from "react"; import { Search } from "lucide-react"; +import { CollapsibleSection } from "@/components/collapsible-section"; import type { RetrievalTraceView } from "@/domains/chat/types"; export function ChatRetrievalTrace({ @@ -13,11 +14,11 @@ export function ChatRetrievalTrace({ if (trace.queries.length === 0) return null; return ( -
-

- - Retrieval -

+ } + badge={trace.queries.length} + >
{trace.queries.map((entry, index) => (
))}
-
+
); } diff --git a/src/components/collapsible-section.tsx b/src/components/collapsible-section.tsx new file mode 100644 index 0000000..4ca5823 --- /dev/null +++ b/src/components/collapsible-section.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { type ReactElement, type ReactNode } from "react"; +import { ChevronRight } from "lucide-react"; + +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; + +type CollapsibleSectionProps = { + readonly title: string; + readonly icon?: ReactNode; + readonly badge?: number; + readonly defaultOpen?: boolean; + readonly children: ReactNode; +}; + +export function CollapsibleSection({ + title, + icon, + badge, + defaultOpen = false, + children, +}: CollapsibleSectionProps): ReactElement { + return ( +
+ + + + {icon} + {title} + {typeof badge === "number" && badge > 0 && ( + + {badge} + + )} + + {children} + +
+ ); +} diff --git a/src/components/ui/collapsible.tsx b/src/components/ui/collapsible.tsx new file mode 100644 index 0000000..488fb33 --- /dev/null +++ b/src/components/ui/collapsible.tsx @@ -0,0 +1,21 @@ +"use client" + +import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible" + +function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) { + return +} + +function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) { + return ( + + ) +} + +function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) { + return ( + + ) +} + +export { Collapsible, CollapsibleTrigger, CollapsibleContent } diff --git a/src/components/ui/slider.tsx b/src/components/ui/slider.tsx new file mode 100644 index 0000000..691fff3 --- /dev/null +++ b/src/components/ui/slider.tsx @@ -0,0 +1,52 @@ +import { Slider as SliderPrimitive } from "@base-ui/react/slider" + +import { cn } from "@/lib/utils" + +function Slider({ + className, + defaultValue, + value, + min = 0, + max = 100, + ...props +}: SliderPrimitive.Root.Props) { + const _values = Array.isArray(value) + ? value + : Array.isArray(defaultValue) + ? defaultValue + : [min, max] + + return ( + + + + + + {Array.from({ length: _values.length }, (_, index) => ( + + ))} + + + ) +} + +export { Slider } diff --git a/src/components/ui/switch.tsx b/src/components/ui/switch.tsx new file mode 100644 index 0000000..9b8b44b --- /dev/null +++ b/src/components/ui/switch.tsx @@ -0,0 +1,32 @@ +"use client" + +import { Switch as SwitchPrimitive } from "@base-ui/react/switch" + +import { cn } from "@/lib/utils" + +function Switch({ + className, + size = "default", + ...props +}: SwitchPrimitive.Root.Props & { + size?: "sm" | "default" +}) { + return ( + + + + ) +} + +export { Switch } diff --git a/src/components/use-prompt-templates.ts b/src/components/use-prompt-templates.ts new file mode 100644 index 0000000..43949b2 --- /dev/null +++ b/src/components/use-prompt-templates.ts @@ -0,0 +1,57 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import type { ChatPromptTemplate } from "@/domains/chat/prompt-templates"; + +const promptTemplatesURL = `/data/chat-prompt-templates.json?v=${encodeURIComponent( + Date.now().toString(36), +)}`; + +type PromptTemplatesState = { + readonly isLoading: boolean; + readonly templates: readonly ChatPromptTemplate[]; +}; + +export function usePromptTemplates(): PromptTemplatesState { + const [state, setState] = useState({ + isLoading: true, + templates: [], + }); + + useEffect(() => { + let cancelled = false; + + fetch(promptTemplatesURL) + .then((response) => (response.ok ? response.json() : [])) + .then((data: unknown) => { + if (cancelled) return; + setState({ + isLoading: false, + templates: Array.isArray(data) + ? data.filter(isChatPromptTemplate) + : [], + }); + }) + .catch(() => { + if (cancelled) return; + setState({ isLoading: false, templates: [] }); + }); + + return () => { + cancelled = true; + }; + }, []); + + return state; +} + +function isChatPromptTemplate(value: unknown): value is ChatPromptTemplate { + if (typeof value !== "object" || value === null) return false; + const candidate = value as Record; + return ( + typeof candidate.id === "string" && + typeof candidate.title === "string" && + typeof candidate.prompt === "string" + ); +} diff --git a/src/components/workspace-chat-workflow.ts b/src/components/workspace-chat-workflow.ts index 2d7e7c7..9ad2e35 100644 --- a/src/components/workspace-chat-workflow.ts +++ b/src/components/workspace-chat-workflow.ts @@ -21,6 +21,7 @@ import type { ChatMessageView, ChatThreadView, } from "@/domains/chat/types" +import type { RetrievalOverrides } from "@/domains/chat/contracts" import type { SourceView } from "@/domains/sources/types" type WorkspaceChatWorkflowInput = { @@ -38,7 +39,10 @@ type WorkspaceChatWorkflow = { readonly chat: ReturnType readonly chatThreads: ChatThreadView[] readonly handleArchiveChatThread: (threadId: string) => Promise - readonly handleChatSend: (text: string) => Promise + readonly handleChatSend: ( + text: string, + retrievalParams?: RetrievalOverrides, + ) => Promise readonly handleCreateChatThread: () => Promise readonly handleRefreshActiveChatThread: () => Promise readonly handleSelectChatThread: (threadId: string) => void @@ -240,7 +244,10 @@ export function useWorkspaceChatWorkflow({ } } - async function handleChatSend(text: string): Promise { + async function handleChatSend( + text: string, + retrievalParams?: RetrievalOverrides, + ): Promise { const sendStart = Date.now() const selectedSourcesCount = sources.filter( (source) => @@ -272,6 +279,7 @@ export function useWorkspaceChatWorkflow({ excludedSourceIds: sources .filter((source) => source.excludedFromQuery) .map((source) => source.id), + retrievalParams, }) if (!body.threadId || !Array.isArray(body.messages)) { diff --git a/src/components/workspace-shell-layout.tsx b/src/components/workspace-shell-layout.tsx index 708dac7..2a7adeb 100644 --- a/src/components/workspace-shell-layout.tsx +++ b/src/components/workspace-shell-layout.tsx @@ -20,6 +20,7 @@ import type { ChatMessageView, ChatThreadView, } from "@/domains/chat/types" +import type { RetrievalOverrides } from "@/domains/chat/contracts" import type { ParsedChunkView } from "@/domains/chunks/types" import type { SourceOriginalFileView, @@ -85,7 +86,10 @@ export type WorkspaceShellLayoutProps = { readonly onArchiveChatThread: (threadId: string) => void | Promise readonly onArchiveSource: (sourceId: string) => void | Promise readonly onRetrySource?: (sourceId: string) => void | Promise - readonly onChatSend: (text: string) => void | Promise + readonly onChatSend: ( + text: string, + retrievalParams?: RetrievalOverrides, + ) => void | Promise readonly onCitationClick: ( citation: ChatCitationView, citationId: string, diff --git a/src/components/workspace-shell.test.ts b/src/components/workspace-shell.test.ts index a728af1..40c541a 100644 --- a/src/components/workspace-shell.test.ts +++ b/src/components/workspace-shell.test.ts @@ -44,6 +44,13 @@ describe("WorkspaceShell", () => { vi.restoreAllMocks(); }); + async function expandSources( + panel: ReturnType, + ): Promise { + const trigger = await panel.findByRole("button", { name: /^Sources/ }); + fireEvent.click(trigger); + } + it("keeps desktop panels horizontally scrollable at their minimum widths", () => { render(React.createElement(C, { sources: [] })); @@ -244,9 +251,7 @@ describe("WorkspaceShell", () => { }); await user.click(sendButton); - await desktopChatPanel.findAllByRole("button", { - name: "Open source doc.pdf", - }); + await expandSources(desktopChatPanel); const citationButtons = desktopChatPanel.getAllByRole( "button", { @@ -362,6 +367,7 @@ describe("WorkspaceShell", () => { await user.type(input, "Where?"); await user.click(sendButton); + await expandSources(desktopChatPanel); const citation = await desktopChatPanel.findByRole("button", { name: "Open source doc.pdf", }); @@ -499,6 +505,7 @@ describe("WorkspaceShell", () => { }); const desktopChatPanel = within(screen.getByTestId("desktop-chat-panel")); + await expandSources(desktopChatPanel); await user.click( desktopChatPanel.getByRole("button", { name: "Open source doc.pdf", diff --git a/src/domains/chat/contracts.ts b/src/domains/chat/contracts.ts index 60cb9e7..343987c 100644 --- a/src/domains/chat/contracts.ts +++ b/src/domains/chat/contracts.ts @@ -52,6 +52,17 @@ export type SearchSources = ( input: AgenticRetrievalQuery, ) => Promise +/** + * Optional per-request retrieval tuning from the chat composer UI. Each + * field overrides the equivalent hardcoded default (or, for topK, the + * harness-chosen value) when present. + */ +export type RetrievalOverrides = { + readonly rerank?: boolean + readonly internalRecallK?: number + readonly topK?: number +} + export type GenerateAnswer = (input: { question: string messages: readonly ChatHistoryMessage[] @@ -71,6 +82,7 @@ export type AnswerQuestionInput = { loadSourceAssetUrls?: LoadSourceAssetUrls hardenMediaAssetUrls?: HardenMediaAssetUrls messages: readonly ChatHistoryMessage[] + retrievalOverrides?: RetrievalOverrides } export type AnswerQuestionResult = { diff --git a/src/domains/chat/index.test.ts b/src/domains/chat/index.test.ts index 524f408..4c9de26 100644 --- a/src/domains/chat/index.test.ts +++ b/src/domains/chat/index.test.ts @@ -1459,6 +1459,49 @@ describe("answerQuestionWithRetrieval", () => { ]); }); + it("applies retrieval overrides over hardcoded and harness-chosen values", async () => { + const retrieval = { + query: vi.fn().mockResolvedValue({ + results: [makeRetrievalResult()], + evidenceText: "Evidence.", + referencedChunks: [], + namespace: "notebook-workspace", + query: "any", + routerUsed: "workflow_single_step", + answerText: null, + }), + }; + const generateAnswer = vi.fn(async ({ searchSources }) => { + await searchSources({ query: "query", topK: 12 }); + return makeHarnessRunResult("Answer."); + }); + + await Effect.runPromise( + answerQuestionWithRetrieval({ + question: "Question", + namespace: "notebook-workspace", + sources: [makeSource()], + excludedSourceIds: [], + retrieval, + generateAnswer, + messages: [], + retrievalOverrides: { + rerank: false, + internalRecallK: 45, + topK: 4, + }, + }), + ); + + expect(retrieval.query).toHaveBeenCalledWith( + expect.objectContaining({ + rerank: false, + internalRecallK: 45, + topK: 4, + }), + ); + }); + it("does not append chat history to Knowhere tool queries", async () => { const retrieval = { query: vi.fn().mockResolvedValue({ diff --git a/src/domains/chat/index.ts b/src/domains/chat/index.ts index 1d29aa6..60e1ef2 100644 --- a/src/domains/chat/index.ts +++ b/src/domains/chat/index.ts @@ -30,6 +30,7 @@ import type { AgenticRetrievalResponse, AnswerQuestionInput, AnswerQuestionResult, + RetrievalOverrides, } from "./contracts" import { excludeDocuments, @@ -135,6 +136,7 @@ export const answerQuestionWithRetrieval = ( namespace, sources: input.sources, excludedSourceIds: input.excludedSourceIds, + retrievalOverrides: input.retrievalOverrides, }) logger.info("chat-agent: searchSources start", { namespace, @@ -717,19 +719,21 @@ function buildRetrievalQueryParams(input: { readonly namespace: string readonly sources: AnswerQuestionInput["sources"] readonly excludedSourceIds: readonly string[] + readonly retrievalOverrides?: RetrievalOverrides }): RetrievalQueryParams { const query = normalizeRetrievalQuery( input.input.query, input.fallbackQuestion, ) const dataType = normalizeRetrievalDataType(input.input.targetContent) + const overrides = input.retrievalOverrides return { namespace: input.namespace, query, - topK: normalizeTopK(input.input.topK), + topK: overrides?.topK ?? normalizeTopK(input.input.topK), useAgentic: true, - rerank: true, - internalRecallK: 30, + rerank: overrides?.rerank ?? true, + internalRecallK: overrides?.internalRecallK ?? 30, dataType, ...(input.input.signalPaths && input.input.signalPaths.length > 0 ? { signalPaths: input.input.signalPaths } diff --git a/src/domains/chat/prompt-templates.ts b/src/domains/chat/prompt-templates.ts index 7b92d74..530a8c0 100644 --- a/src/domains/chat/prompt-templates.ts +++ b/src/domains/chat/prompt-templates.ts @@ -3,38 +3,3 @@ export type ChatPromptTemplate = { readonly title: string readonly prompt: string } - -export const chatPromptTemplates: readonly ChatPromptTemplate[] = [ - { - id: "ipo-prospectus-risk-mining", - title: "IPO Prospectus Risk Mining", - prompt: [ - "You are a risk analyst specializing in IPO pricing. I have uploaded the prospectus of [Company Name].", - "Please complete the following tasks:", - '1. Extract all risk items from the "Risk Factors" section and categorize them into: Market Risk/Operational Risk/Legal and Compliance Risk/Technical Risk/Competitive Risk.', - '2. Identify which risk items use hedging language such as "may", "might", or "could", and which use more definitive language such as "will" or "has". Provide the results in a structured format.', - ].join("\n"), - }, - { - id: "earnings-call-transcript-analysis", - title: "Earnings Call Transcript Analysis", - prompt: [ - "You are a sell-side research analyst preparing a post earnings flash note. I have uploaded the earnings release and earnings call transcript of [Company Name].", - "Please complete the following tasks:", - "1. Extract the management's original wording on the following topics: Revenue guidance/Gross margin pressure/Specific business line.", - "2. Identify analyst questions that management sidestepped or shifted away from.", - "3. Extract all forward-looking statements that contain specific numbers, and organize them into a guidance tracking table.", - ].join("\n"), - }, - { - id: "research-paper-method-comparison", - title: "Research Paper Method Comparison", - prompt: [ - "You are a PhD researcher writing a paper in [Research Area]. I have uploaded recent top conference and journal papers in this area.", - "Please analyze the papers and produce the following:", - "1. Extract the three core elements for each paper: Dataset/Evaluation metrics/Model architecture. Present the results in a comparison table.", - '2. Identify the unresolved issues repeatedly mentioned in the "Limitations" or "Future Work" sections across the papers, and present them as a list.', - "3. Identify emerging technical terms appearing in the papers, assess whether they indicate a new research trend, and output a list of trend keywords.", - ].join("\n"), - }, -] as const diff --git a/src/domains/chat/request.test.ts b/src/domains/chat/request.test.ts new file mode 100644 index 0000000..5eefb87 --- /dev/null +++ b/src/domains/chat/request.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest" + +import { parseChatRequestBody } from "./request" + +describe("parseChatRequestBody", () => { + it("parses retrieval params with defaults preserved when absent", () => { + const result = parseChatRequestBody({ + message: "Question?", + excludedSourceIds: ["source_1"], + }) + + expect(result).toEqual({ + ok: true, + value: { + question: "Question?", + excludedSourceIds: ["source_1"], + }, + }) + }) + + it("passes through valid retrieval params", () => { + const result = parseChatRequestBody({ + message: "Question?", + excludedSourceIds: [], + retrievalParams: { + rerank: false, + internalRecallK: 40, + topK: 6, + }, + }) + + expect(result).toEqual({ + ok: true, + value: { + question: "Question?", + excludedSourceIds: [], + retrievalParams: { + rerank: false, + internalRecallK: 40, + topK: 6, + }, + }, + }) + }) + + it("clamps out-of-range retrieval params and drops invalid types", () => { + const result = parseChatRequestBody({ + message: "Question?", + excludedSourceIds: [], + retrievalParams: { + internalRecallK: 500, + topK: 0, + }, + }) + + expect(result).toEqual({ + ok: true, + value: { + question: "Question?", + excludedSourceIds: [], + retrievalParams: { + internalRecallK: 50, + topK: 1, + }, + }, + }) + }) + + it("rejects the request when a retrieval param has the wrong type", () => { + const result = parseChatRequestBody({ + message: "Question?", + excludedSourceIds: [], + retrievalParams: { + rerank: "yes" as unknown as boolean, + }, + }) + + expect(result).toEqual({ + ok: false, + message: "Enter a question before sending.", + status: 400, + }) + }) +}) diff --git a/src/domains/chat/request.ts b/src/domains/chat/request.ts index c15a76e..ab219aa 100644 --- a/src/domains/chat/request.ts +++ b/src/domains/chat/request.ts @@ -1,21 +1,36 @@ import { Either, Schema } from "effect" +import type { RetrievalOverrides } from "./contracts" + export type ParsedChatRequest = { question: string threadId?: string excludedSourceIds: string[] + retrievalParams?: RetrievalOverrides } export type ParseChatRequestResult = | { ok: true; value: ParsedChatRequest } | { ok: false; message: string; status: 400 } +const ChatRetrievalParamsSchema = Schema.Struct({ + rerank: Schema.optional(Schema.Boolean), + internalRecallK: Schema.optional(Schema.Number), + topK: Schema.optional(Schema.Number), +}) + const ChatRequestBody = Schema.Struct({ message: Schema.String, threadId: Schema.optional(Schema.String), excludedSourceIds: Schema.optional(Schema.Array(Schema.Unknown)), + retrievalParams: Schema.optional(ChatRetrievalParamsSchema), }) +const maxInternalRecallK = 50 +const minInternalRecallK = 5 +const maxTopK = 12 +const minTopK = 1 + export function parseChatRequestBody(body: unknown): ParseChatRequestResult { return Either.match(Schema.decodeUnknownEither(ChatRequestBody)(body), { onLeft: () => ({ @@ -44,8 +59,50 @@ export function parseChatRequestBody(body: unknown): ParseChatRequestResult { ? parsed.threadId : undefined, excludedSourceIds, + ...(parsed.retrievalParams + ? { retrievalParams: normalizeRetrievalParams(parsed.retrievalParams) } + : {}), }, } }, }) } + +function normalizeRetrievalParams( + params: { + readonly rerank?: boolean + readonly internalRecallK?: number + readonly topK?: number + }, +): RetrievalOverrides | undefined { + let normalized: RetrievalOverrides | undefined + + if (typeof params.rerank === "boolean") { + normalized = { ...normalized, rerank: params.rerank } + } + + const internalRecallK = clampFinite( + params.internalRecallK, + minInternalRecallK, + maxInternalRecallK, + ) + if (internalRecallK !== undefined) { + normalized = { ...normalized, internalRecallK } + } + + const topK = clampFinite(params.topK, minTopK, maxTopK) + if (topK !== undefined) { + normalized = { ...normalized, topK } + } + + return normalized +} + +function clampFinite( + value: number | undefined, + min: number, + max: number, +): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value)) return undefined + return Math.min(Math.max(value, min), max) +} diff --git a/src/domains/chat/route-answer.ts b/src/domains/chat/route-answer.ts index ea75943..fc44aeb 100644 --- a/src/domains/chat/route-answer.ts +++ b/src/domains/chat/route-answer.ts @@ -79,6 +79,7 @@ const answerChatEffect = (input: AnswerChatInput) => question: body.value.question, threadId: body.value.threadId, excludedSourceIds: body.value.excludedSourceIds, + retrievalParams: body.value.retrievalParams, retrieval: client.retrieval, generateAnswer: generateAgenticOutputManifest, loadSourceAssetUrls, diff --git a/src/domains/chat/service.ts b/src/domains/chat/service.ts index 616aadd..867d530 100644 --- a/src/domains/chat/service.ts +++ b/src/domains/chat/service.ts @@ -16,6 +16,7 @@ import type { ChatMessageView, RetrievalTraceView, } from "@/domains/chat/types" +import type { RetrievalOverrides } from "./contracts" export type ChatRepository = { ensureDefaultChatThread(workspaceId: string): Promise @@ -67,6 +68,7 @@ type ChatTurnInput = { question: string threadId?: string excludedSourceIds: readonly string[] + retrievalParams?: RetrievalOverrides retrieval: RetrievalClient generateAnswer: GenerateAnswer loadSourceAssetUrls?: AnswerQuestionInput["loadSourceAssetUrls"] @@ -131,6 +133,7 @@ export const handleChatTurnEffect = (input: ChatTurnInput) => loadSourceAssetUrls: input.loadSourceAssetUrls, hardenMediaAssetUrls: input.hardenMediaAssetUrls, messages: chatHistoryMessages, + retrievalOverrides: input.retrievalParams, }).pipe(Effect.catchAllCause(Effect.die)) const assistantMessage = yield* tryPromiseOrDie(() => diff --git a/src/domains/workspace/client.ts b/src/domains/workspace/client.ts index 3e97966..ab01749 100644 --- a/src/domains/workspace/client.ts +++ b/src/domains/workspace/client.ts @@ -1,4 +1,5 @@ import type { ChatDiagramSpec } from "@/domains/chat/diagram" +import type { RetrievalOverrides } from "@/domains/chat/contracts" import type { ChatMessageView, ChatThreadView, @@ -46,6 +47,7 @@ type ChatMessageRequest = { message: string threadId?: string excludedSourceIds: string[] + retrievalParams?: RetrievalOverrides } type SourcesResponse = { From 9aafbabb42e8b805374e91c85524bc56a6bcbe99 Mon Sep 17 00:00:00 2001 From: Gordon YUEN Date: Sat, 1 Aug 2026 23:34:20 +0800 Subject: [PATCH 21/46] fix(chat): label wand menu 'Prompts / Chart' and inline retrieval controls - Wand button tooltip/aria-label becomes 'Prompts / Chart' to cover both the canned prompts and the diagram action - Retrieval controls (Rerank switch, Recall K / Top K sliders) move from their own row into the composer bottom row, right of the Wand button --- src/components/chat-composer.test.ts | 8 +++--- src/components/chat-composer.tsx | 38 +++++++++++++++------------- src/components/chat-panel.test.ts | 4 +-- 3 files changed, 26 insertions(+), 24 deletions(-) diff --git a/src/components/chat-composer.test.ts b/src/components/chat-composer.test.ts index 6990f89..d9af4fc 100644 --- a/src/components/chat-composer.test.ts +++ b/src/components/chat-composer.test.ts @@ -164,7 +164,7 @@ describe("ChatComposer", () => { const input = getComposerTextArea(); input.scrollTop = 92; - await user.click(screen.getByRole("button", { name: "Templates" })); + await user.click(screen.getByRole("button", { name: "Prompts / Chart" })); await user.click( screen.getByRole("menuitem", { name: /IPO Prospectus Risk Mining/ }), ); @@ -200,7 +200,7 @@ describe("ChatComposer", () => { render(React.createElement(ChatComposer)); - await user.click(screen.getByRole("button", { name: "Templates" })); + await user.click(screen.getByRole("button", { name: "Prompts / Chart" })); await user.click( screen.getByRole("menuitem", { name: /IPO Prospectus Risk Mining/ }), ); @@ -225,7 +225,7 @@ describe("ChatComposer", () => { render(React.createElement(ChatComposer)); - await user.click(screen.getByRole("button", { name: "Templates" })); + await user.click(screen.getByRole("button", { name: "Prompts / Chart" })); await user.click( screen.getByRole("menuitem", { name: /Earnings Call Transcript Analysis/, @@ -257,7 +257,7 @@ describe("ChatComposer", () => { expect(input.className).toContain("max-h-[192px]"); expect(input.className).toContain("border-0"); expect(input.className).toContain("shadow-none"); - expect(screen.getByRole("button", { name: "Templates" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Prompts / Chart" })).toBeTruthy(); }); }); diff --git a/src/components/chat-composer.tsx b/src/components/chat-composer.tsx index 40239e5..ad60ae5 100644 --- a/src/components/chat-composer.tsx +++ b/src/components/chat-composer.tsx @@ -184,22 +184,24 @@ export function ChatComposer({ onKeyDown={handleKeyDown} />
-
- +
+ + +
- Templates + Prompts / Chart { }), ).toBeNull(); - await user.click(screen.getByRole("button", { name: "Templates" })); + await user.click(screen.getByRole("button", { name: "Prompts / Chart" })); await user.click( screen.getByRole("menuitem", { name: "Create diagram from latest answer", @@ -177,7 +177,7 @@ describe("ChatPanel", () => { }), ).toBeNull(); - await user.click(screen.getByRole("button", { name: "Templates" })); + await user.click(screen.getByRole("button", { name: "Prompts / Chart" })); await user.click( screen.getByRole("menuitem", { name: "Create diagram from latest answer", From ae514fefc050e43a9c5f5bc53626659380ad5b1a Mon Sep 17 00:00:00 2001 From: Gordon YUEN Date: Sat, 1 Aug 2026 23:53:23 +0800 Subject: [PATCH 22/46] feat(chat): show answer stats in the retrieval trace block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before the expanded query list, the Retrieval block now shows a stats row: wall-clock time to answer (seconds, 1 decimal), LLM call count, and input/output token split. - Harness: accumulate response.steps.length and response.totalUsage.{input,output}Tokens across the agent loop and revision attempts; expose as llmCallCount/inputTokens/outputTokens on HarnessTrace (optional fields) - answerQuestionWithRetrieval captures wall-clock time and threads the harness usage into RetrievalTraceView (durationSeconds, llmCallCount, inputTokens, outputTokens — all optional, transient) - ChatRetrievalTrace renders the stats row above the query list only when stat fields are present --- src/agent-harness/runtime.ts | 9 ++ src/agent-harness/types.ts | 6 ++ src/components/chat-message-list.test.ts | 35 ++++++++ src/components/chat-retrieval-trace.tsx | 37 ++++++++ src/domains/chat/index.test.ts | 103 +++++++++++++---------- src/domains/chat/index.ts | 41 +++++++-- src/domains/chat/types.ts | 8 ++ 7 files changed, 189 insertions(+), 50 deletions(-) diff --git a/src/agent-harness/runtime.ts b/src/agent-harness/runtime.ts index e5b61b0..4ea1e09 100644 --- a/src/agent-harness/runtime.ts +++ b/src/agent-harness/runtime.ts @@ -155,9 +155,15 @@ export async function runAgentHarness( let manifest = buildFallbackManifest("") let validationErrors: readonly string[] = [] let revisionsUsed = 0 + let llmCallCount = 0 + let inputTokens = 0 + let outputTokens = 0 for (let attempt = 0; ; attempt += 1) { const response = await agent.generate({ messages }) + llmCallCount += response.steps?.length ?? 0 + inputTokens += response.totalUsage?.inputTokens ?? 0 + outputTokens += response.totalUsage?.outputTokens ?? 0 manifest = state.finalizedManifest ?? buildFallbackManifest(response.text.trim()) @@ -201,6 +207,9 @@ export async function runAgentHarness( toolCalls: [...(state.toolCalls ?? [])], validationErrors, revisionsUsed, + llmCallCount, + inputTokens, + outputTokens, }, } } diff --git a/src/agent-harness/types.ts b/src/agent-harness/types.ts index 1a1cce9..3a6db09 100644 --- a/src/agent-harness/types.ts +++ b/src/agent-harness/types.ts @@ -177,6 +177,12 @@ export type HarnessTrace = { readonly toolCalls: readonly HarnessToolCallTrace[] readonly validationErrors: readonly string[] readonly revisionsUsed: number + /** Total LLM step calls across the agent loop and any revision attempts. */ + readonly llmCallCount?: number + /** Total input tokens across the agent loop and any revision attempts. */ + readonly inputTokens?: number + /** Total output tokens across the agent loop and any revision attempts. */ + readonly outputTokens?: number } export type HarnessRunResult = { diff --git a/src/components/chat-message-list.test.ts b/src/components/chat-message-list.test.ts index 4bd3c37..6e5e6c5 100644 --- a/src/components/chat-message-list.test.ts +++ b/src/components/chat-message-list.test.ts @@ -84,6 +84,10 @@ describe("ChatMessageList", () => { role: "assistant", content: "The deadline is Monday.", retrievalTrace: { + durationSeconds: 1.2, + llmCallCount: 5, + inputTokens: 820, + outputTokens: 414, queries: [ { query: "deadline monday", @@ -101,12 +105,43 @@ describe("ChatMessageList", () => { expandSection("Retrieval"); + expect(screen.getByText("1.2s · 5 LLM calls · 820 in · 414 out")).toBeTruthy(); expect(screen.getByText("deadline monday")).toBeTruthy(); expect(screen.getByText("3 hits")).toBeTruthy(); expect(screen.getByText("1 cited chunk")).toBeTruthy(); expect(screen.getByText("top score: 0.910 · 0.800")).toBeTruthy(); }); + it("does not render answer stats when the trace has no stat fields", () => { + render( + React.createElement(ChatMessageList, { + messages: [ + { + id: "assistant_1", + role: "assistant", + content: "The deadline is Monday.", + retrievalTrace: { + queries: [ + { + query: "deadline monday", + namespace: "notebook-workspace", + resultCount: 0, + referencedChunkCount: 0, + topScores: [], + }, + ], + }, + }, + ], + }), + ); + + expandSection("Retrieval"); + + expect(screen.queryByText(/s ·/u)).toBeNull(); + expect(screen.queryByText(/in ·/u)).toBeNull(); + }); + it("does not render a retrieval trace without queries", () => { render( React.createElement(ChatMessageList, { diff --git a/src/components/chat-retrieval-trace.tsx b/src/components/chat-retrieval-trace.tsx index e810426..c8b03b9 100644 --- a/src/components/chat-retrieval-trace.tsx +++ b/src/components/chat-retrieval-trace.tsx @@ -19,6 +19,11 @@ export function ChatRetrievalTrace({ icon={} badge={trace.queries.length} > + {hasAnswerStats(trace) && ( +
+ {formatAnswerStats(trace)} +
+ )}
{trace.queries.map((entry, index) => (
score.toFixed(3)).join(" · "); } + +function hasAnswerStats(trace: RetrievalTraceView): boolean { + return ( + trace.durationSeconds !== undefined || + trace.llmCallCount !== undefined || + trace.inputTokens !== undefined || + trace.outputTokens !== undefined + ); +} + +function formatAnswerStats(trace: RetrievalTraceView): string { + const parts: string[] = []; + + if (trace.durationSeconds !== undefined) { + parts.push(`${trace.durationSeconds.toFixed(1)}s`); + } + if (trace.llmCallCount !== undefined) { + parts.push( + `${trace.llmCallCount} ${trace.llmCallCount === 1 ? "LLM call" : "LLM calls"}`, + ); + } + if ( + trace.inputTokens !== undefined || + trace.outputTokens !== undefined + ) { + parts.push( + `${trace.inputTokens ?? 0} in · ${trace.outputTokens ?? 0} out`, + ); + } + + return parts.join(" · "); +} diff --git a/src/domains/chat/index.test.ts b/src/domains/chat/index.test.ts index 4c9de26..70a412c 100644 --- a/src/domains/chat/index.test.ts +++ b/src/domains/chat/index.test.ts @@ -87,21 +87,22 @@ describe("answerQuestionWithRetrieval", () => { excludedSourceIds: ["source_2"], searchSources: expect.any(Function), }); - expect(answer).toEqual({ + expect(answer).toMatchObject({ answer: "The answer is grounded.", citations: [result], artifacts: [], - retrievalTrace: { - queries: [ - { - namespace: "notebook-workspace", - query: "What does the document say?", - referencedChunkCount: 0, - resultCount: 1, - topScores: [0.9], - }, - ], - }, + }); + expect(answer.retrievalTrace).toMatchObject({ + durationSeconds: expect.any(Number), + queries: [ + { + namespace: "notebook-workspace", + query: "What does the document say?", + referencedChunkCount: 0, + resultCount: 1, + topScores: [0.9], + }, + ], }); }); @@ -177,28 +178,29 @@ describe("answerQuestionWithRetrieval", () => { 2, expect.objectContaining({ namespace: "notebook-legacy" }), ); - expect(answer).toEqual({ + expect(answer).toMatchObject({ answer: "The legacy answer is grounded.", citations: [legacyResult], artifacts: [], - retrievalTrace: { - queries: [ - { - namespace: "default", - query: "legacy document answer", - referencedChunkCount: 0, - resultCount: 0, - topScores: [], - }, - { - namespace: "notebook-legacy", - query: "legacy document answer", - referencedChunkCount: 0, - resultCount: 1, - topScores: [0.9], - }, - ], - }, + }); + expect(answer.retrievalTrace).toMatchObject({ + durationSeconds: expect.any(Number), + queries: [ + { + namespace: "default", + query: "legacy document answer", + referencedChunkCount: 0, + resultCount: 0, + topScores: [], + }, + { + namespace: "notebook-legacy", + query: "legacy document answer", + referencedChunkCount: 0, + resultCount: 1, + topScores: [0.9], + }, + ], }); }); @@ -1332,21 +1334,22 @@ describe("answerQuestionWithRetrieval", () => { }), ); - expect(answer).toEqual({ + expect(answer).toMatchObject({ answer: "I couldn't find that in your sources.", citations: [], artifacts: [], - retrievalTrace: { - queries: [ - { - namespace: "notebook-workspace", - query: "Missing fact?", - referencedChunkCount: 0, - resultCount: 0, - topScores: [], - }, - ], - }, + }); + expect(answer.retrievalTrace).toMatchObject({ + durationSeconds: expect.any(Number), + queries: [ + { + namespace: "notebook-workspace", + query: "Missing fact?", + referencedChunkCount: 0, + resultCount: 0, + topScores: [], + }, + ], }); }); @@ -1681,6 +1684,10 @@ describe("generateAgenticOutputManifest", () => { return { text: "This freeform text should be ignored.", + steps: [ + { stepNumber: 1, usage: { inputTokens: 120, outputTokens: 40 } }, + ], + totalUsage: { inputTokens: 120, outputTokens: 40 }, } as Awaited>; }, ); @@ -1745,6 +1752,9 @@ describe("generateAgenticOutputManifest", () => { carryHistory: "none", }); expect(result.trace.validationErrors).toEqual([]); + expect(result.trace.llmCallCount).toBe(1); + expect(result.trace.inputTokens).toBe(120); + expect(result.trace.outputTokens).toBe(40); expect(searchSources).toHaveBeenCalledWith({ query: "冯荣洲 身份证 图片", targetContent: "text_image", @@ -1819,6 +1829,10 @@ describe("generateAgenticOutputManifest", () => { return { text: "ignored", response: { messages: [] }, + steps: [ + { stepNumber: 1, usage: { inputTokens: 100, outputTokens: 30 } }, + ], + totalUsage: { inputTokens: 100, outputTokens: 30 }, } as unknown as Awaited>; }, ); @@ -1858,6 +1872,9 @@ describe("generateAgenticOutputManifest", () => { expect(generateCallCount).toBe(2); expect(result.trace.revisionsUsed).toBe(1); expect(result.trace.validationErrors).toEqual([]); + expect(result.trace.llmCallCount).toBe(2); + expect(result.trace.inputTokens).toBe(200); + expect(result.trace.outputTokens).toBe(60); expect( result.manifest.artifacts.filter((artifact) => artifact.display).length, ).toBe(2); diff --git a/src/domains/chat/index.ts b/src/domains/chat/index.ts index 60e1ef2..64a9773 100644 --- a/src/domains/chat/index.ts +++ b/src/domains/chat/index.ts @@ -112,6 +112,7 @@ export const answerQuestionWithRetrieval = ( Effect.gen(function* () { const question = input.question.trim() const retrievalResponses: RetrievalQueryResponse[] = [] + const answerStartedAtMs = Date.now() logger.info("chat-agent: answer start", { questionLength: question.length, @@ -269,7 +270,13 @@ export const answerQuestionWithRetrieval = ( }) const citationResults = hardenedMedia.results const displayArtifacts = hardenedMedia.artifacts ?? [] - const retrievalTrace = buildRetrievalTrace(retrievalResponses) + const retrievalTrace = buildRetrievalTrace({ + responses: retrievalResponses, + durationSeconds: (Date.now() - answerStartedAtMs) / 1000, + llmCallCount: generatedAnswer.trace.llmCallCount, + inputTokens: generatedAnswer.trace.inputTokens, + outputTokens: generatedAnswer.trace.outputTokens, + }) logger.info("chat-agent: answer complete", { answerLength: answer.length, citationCount: citationResults.length, @@ -690,12 +697,16 @@ function joinResponseText( return uniqueValues.length > 0 ? uniqueValues.join(",") : null } -function buildRetrievalTrace( - responses: readonly RetrievalQueryResponse[], -): RetrievalTraceView | undefined { - if (responses.length === 0) return undefined +function buildRetrievalTrace(input: { + readonly responses: readonly RetrievalQueryResponse[] + readonly durationSeconds: number + readonly llmCallCount?: number + readonly inputTokens?: number + readonly outputTokens?: number +}): RetrievalTraceView | undefined { + if (input.responses.length === 0) return undefined - const queries = responses.map((response) => { + const queries = input.responses.map((response) => { const topScores = response.results .map((result) => result.score) .filter((score): score is number => typeof score === "number") @@ -710,7 +721,23 @@ function buildRetrievalTrace( } }) - return { queries } + return { + durationSeconds: roundToTenths(input.durationSeconds), + ...(typeof input.llmCallCount === "number" + ? { llmCallCount: input.llmCallCount } + : {}), + ...(typeof input.inputTokens === "number" + ? { inputTokens: input.inputTokens } + : {}), + ...(typeof input.outputTokens === "number" + ? { outputTokens: input.outputTokens } + : {}), + queries, + } +} + +function roundToTenths(value: number): number { + return Math.round(value * 10) / 10 } function buildRetrievalQueryParams(input: { diff --git a/src/domains/chat/types.ts b/src/domains/chat/types.ts index 8c64f43..ce214b1 100644 --- a/src/domains/chat/types.ts +++ b/src/domains/chat/types.ts @@ -67,6 +67,14 @@ export type RetrievalTraceEntryView = { } export type RetrievalTraceView = { + /** Wall-clock time to answer the question, in seconds (1 decimal). */ + readonly durationSeconds?: number + /** Total LLM step calls made by the agent harness for this answer. */ + readonly llmCallCount?: number + /** Total input tokens consumed by the harness for this answer. */ + readonly inputTokens?: number + /** Total output tokens produced by the harness for this answer. */ + readonly outputTokens?: number readonly queries: readonly RetrievalTraceEntryView[] } From a240e6e688169fab1d8783fdce37472aee82d100 Mon Sep 17 00:00:00 2001 From: Gordon YUEN Date: Sun, 2 Aug 2026 12:23:13 +0800 Subject: [PATCH 23/46] docs: add ADR 0011 checkpoint marker (single-user workable, pre-overhaul) Records the tagged state ae514fe (tag: checkpoint/single-user-workable-pre-overhaul) as the known-good single-user baseline before the multi-domain + Notebook-owned auth overhaul. Captures what the checkpoint guarantees, how to return to it, and the deferred alternatives it keeps open. --- ...point-single-user-workable-pre-overhaul.md | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 docs/adr/0011-checkpoint-single-user-workable-pre-overhaul.md diff --git a/docs/adr/0011-checkpoint-single-user-workable-pre-overhaul.md b/docs/adr/0011-checkpoint-single-user-workable-pre-overhaul.md new file mode 100644 index 0000000..318ac8b --- /dev/null +++ b/docs/adr/0011-checkpoint-single-user-workable-pre-overhaul.md @@ -0,0 +1,86 @@ +# ADR 0011: Checkpoint — single-user workable, pre-overhaul + +**Date:** 2026-08-02 + +## Status + +Accepted (checkpoint marker, not a forward decision) + +## Context + +The Notebook currently ships a self-hosted, single-user-capable state: + +- **Identity:** a single fake dev user (`knowhere-api-key-dev-user`), activated + when `KNOWHERE_API_KEY` is set. The Dashboard production path (session-cookie + forwarding + `issueServiceJwt`) still exists but is not used by self-hosted + deployments. +- **Knowhere access:** one global API key from `KNOWHERE_API_KEY` env, used as + the bearer for all Knowhere SDK calls. +- **Workspaces:** one workspace per user (`workspaces.user_id` unique), each + with an auto-generated `notebook-` namespace. +- **Chat:** answers work against localized sources, with retrieval tuning + controls, foldable Sources/Retrieval blocks, and an answer-stats trace. + +The next planned overhaul introduces multi-domain workspaces mapped to +Knowhere namespaces, then Notebook-owned authentication (users, DB sessions, +password login, Dashboard hard-cut), then DB-backed encrypted API keys. + +This checkpoint exists so we can return to a known-good, single-user state if +the overhaul proves to be the wrong direction — for example, if we decide that +focusing on document input and answer quality matters more than multi-user +auth, or if the DIY-auth approach (argon2/Drizzle/Effect services) becomes +untenable. + +## Decision + +Mark commit `ae514fe` with the annotated tag: + +``` +checkpoint/single-user-workable-pre-overhaul +``` + +Message: "Single user workable, pre-overhaul to focus on document input and +quality of answers" + +### What the checkpoint guarantees + +- `KNOWHERE_API_KEY` dev-mode works end-to-end (proxy bypass, fake user, + single global key). +- Chat retrieval is tuned for BM25 (rerank, multi-query, query expansion) + with UI override controls. +- Sources/Retrieval blocks are foldable; the chat composer has a wand + Prompts/Chart menu (JSON-served templates) and retrieval tuning sliders. +- Table chunks render server-side-enriched HTML. +- Working tree is clean; the current branch continues forward from here. + +### How to return + +```bash +# Try an alternative without losing current work: +git checkout -b alternative-plan checkpoint/single-user-workable-pre-overhaul + +# Or just inspect: +git checkout checkpoint/single-user-workable-pre-overhaul +``` + +### Deferred alternatives this checkpoint keeps open + +1. **Multi-domain model:** workspace = `(user, keyLabel, namespace)` with + file/env-backed keys (fast switch, no restart), vs. DB-backed encrypted + keys managed purely from the UI. +2. **Auth approach:** DIY (argon2 + Drizzle + Effect services, DB sessions, + admin-provisioned users, Dashboard hard-cut) vs. Better Auth vs. Auth.js — + see ADR 0010 (planned). +3. **Dashboard dependency:** whether to hard-cut Dashboard entirely in favor + of Notebook-owned auth, or keep it as an optional fallback. +4. **Focus shift:** document input quality and answer quality improvements + before or instead of multi-user auth work. + +## Consequences + +- The tag is immutable; later commits on the working branch do not move it. +- If the overhaul continues, subsequent checkpoints (`checkpoint/phase2-auth`, + `checkpoint/phase3-db-keys`, …) should follow the same `checkpoint/` naming + convention with a one-line status message. +- The tag message and this ADR are the source of truth for what the + checkpoint state includes and what alternatives were deferred. From e6b0f6b93cd643ec4770c05f4ce2a008d6d1afb4 Mon Sep 17 00:00:00 2001 From: Gordon YUEN Date: Wed, 5 Aug 2026 10:21:03 +0800 Subject: [PATCH 24/46] feat(workspaces): multi-domain workspace model with file-backed API keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of the multi-domain overhaul: - knowhere-keys.ts: server-side key reader for config/knowhere-keys.json ({ label, apiKey }[]), mtime-cached so edits take effect without a restart; falls back to KNOWHERE_API_KEY env as a single 'default' key. Exposes masked labels for UI display. - knowhere-api-key.ts: edge-safe dev-mode check now also honors KNOWHERE_KEYS_FILE presence (proxy/auth short-circuits must not redirect when file-backed keys exist). - Schema: workspaces drops unique on userId and namespace; adds knowhere_key_label; unique index on (userId, knowhereKeyLabel, namespace) — one workspace per (user, domain, namespace). - Repository: findAllByUserIdEffect, findByIdEffect, findByIdAndUserIdEffect, findByUserIdAndLabelAndNamespaceEffect, insertForUserLabelNamespaceEffect. - Service: ensureWorkspace resolves the active workspace from the notebook-ws cookie (falls back to first workspace, then creates a legacy default); ensureWorkspaceForLabelAndNamespace creates the workspace bound to a specific (keyLabel, namespace) pair. - Credential resolver: ensureApiKeyForWorkspace looks up the workspace row, resolves its knowhereKeyLabel from the key source, then falls back to the default key, then the env override, then Dashboard JWT. - SSR initial state: exposes all workspaces for the user + masked key labels for the domain switcher UI. --- config/knowhere-keys.json | 6 + src/app/api/sources/route.test.ts | 1 + src/domains/chat/route-service.test.ts | 1 + src/domains/chat/service.test.ts | 1 + src/domains/sources/reconcile.test.ts | 1 + src/domains/sources/retry.test.ts | 1 + src/domains/sources/route-service.test.ts | 1 + .../sources/source-reconcile-workflow.test.ts | 1 + src/domains/sources/upload.test.ts | 1 + src/domains/workspace/initial-state.test.ts | 7 + src/domains/workspace/initial-state.ts | 53 +++++++ src/domains/workspace/persistence.test.ts | 29 +++- src/domains/workspace/repository.ts | 97 +++++++++++-- src/domains/workspace/service.test.ts | 4 + src/domains/workspace/service.ts | 130 ++++++++++++++++-- src/infrastructure/db/schema.ts | 28 +++- .../dashboard/api-key-service.test.ts | 83 ++++++++++- src/integrations/dashboard/api-key-service.ts | 32 ++++- src/integrations/knowhere-api-key.ts | 17 ++- src/integrations/knowhere-keys.test.ts | 118 ++++++++++++++++ src/integrations/knowhere-keys.ts | 112 +++++++++++++++ 21 files changed, 682 insertions(+), 42 deletions(-) create mode 100644 config/knowhere-keys.json create mode 100644 src/integrations/knowhere-keys.test.ts create mode 100644 src/integrations/knowhere-keys.ts diff --git a/config/knowhere-keys.json b/config/knowhere-keys.json new file mode 100644 index 0000000..9e59e16 --- /dev/null +++ b/config/knowhere-keys.json @@ -0,0 +1,6 @@ +[ + { + "label": "default", + "apiKey": "" + } +] diff --git a/src/app/api/sources/route.test.ts b/src/app/api/sources/route.test.ts index 6bfdf01..3194310 100644 --- a/src/app/api/sources/route.test.ts +++ b/src/app/api/sources/route.test.ts @@ -55,6 +55,7 @@ import { POST } from "./route"; const workspace: Workspace = { id: "workspace_1", userId: "user_1", + knowhereKeyLabel: null, namespace: "notebook-workspace_1", createdAt: new Date("2026-05-10T00:00:00Z"), }; diff --git a/src/domains/chat/route-service.test.ts b/src/domains/chat/route-service.test.ts index 0d3301c..a27bbda 100644 --- a/src/domains/chat/route-service.test.ts +++ b/src/domains/chat/route-service.test.ts @@ -366,6 +366,7 @@ function makeWorkspace(overrides: Partial = {}): Workspace { return { id: "workspace_1", userId: "user_1", + knowhereKeyLabel: null, namespace: "notebook-workspace_1", createdAt: new Date("2026-05-06T00:00:00Z"), ...overrides, diff --git a/src/domains/chat/service.test.ts b/src/domains/chat/service.test.ts index 228697f..382dd4e 100644 --- a/src/domains/chat/service.test.ts +++ b/src/domains/chat/service.test.ts @@ -259,6 +259,7 @@ function makeWorkspace(overrides: Partial = {}): Workspace { return { id: "workspace_1", userId: "user_1", + knowhereKeyLabel: null, namespace: "notebook-namespace", createdAt: new Date("2026-05-06T00:00:00Z"), ...overrides, diff --git a/src/domains/sources/reconcile.test.ts b/src/domains/sources/reconcile.test.ts index 8fcc924..fd71c34 100644 --- a/src/domains/sources/reconcile.test.ts +++ b/src/domains/sources/reconcile.test.ts @@ -7,6 +7,7 @@ import { applyKnowhereJobToSource } from "./lifecycle" const workspace: Workspace = { id: "workspace_1", userId: "user_1", + knowhereKeyLabel: null, namespace: "notebook-workspace_1", createdAt: new Date("2026-05-06T00:00:00Z"), } diff --git a/src/domains/sources/retry.test.ts b/src/domains/sources/retry.test.ts index 7497ffc..a682182 100644 --- a/src/domains/sources/retry.test.ts +++ b/src/domains/sources/retry.test.ts @@ -8,6 +8,7 @@ import { retrySourceToKnowhereEffect } from "./retry" const workspace: Workspace = { id: "workspace_1", userId: "user_1", + knowhereKeyLabel: null, namespace: "notebook-workspace_1", createdAt: new Date("2026-05-10T00:00:00Z"), } diff --git a/src/domains/sources/route-service.test.ts b/src/domains/sources/route-service.test.ts index 2260bb2..fd6d1ae 100644 --- a/src/domains/sources/route-service.test.ts +++ b/src/domains/sources/route-service.test.ts @@ -9,6 +9,7 @@ import { createSourceRouteService } from "./route-service"; const workspace: Workspace = { id: "workspace_1", userId: "user_1", + knowhereKeyLabel: null, namespace: "notebook-workspace_1", createdAt: new Date("2026-05-10T00:00:00Z"), }; diff --git a/src/domains/sources/source-reconcile-workflow.test.ts b/src/domains/sources/source-reconcile-workflow.test.ts index 8297618..918785f 100644 --- a/src/domains/sources/source-reconcile-workflow.test.ts +++ b/src/domains/sources/source-reconcile-workflow.test.ts @@ -10,6 +10,7 @@ import { const workspace: Workspace = { id: "workspace_1", userId: "user_1", + knowhereKeyLabel: null, namespace: "notebook-workspace_1", createdAt: new Date("2026-05-06T00:00:00Z"), } diff --git a/src/domains/sources/upload.test.ts b/src/domains/sources/upload.test.ts index 0bf8662..99f0b9f 100644 --- a/src/domains/sources/upload.test.ts +++ b/src/domains/sources/upload.test.ts @@ -10,6 +10,7 @@ import type { Workspace } from "@/infrastructure/db/schema"; const workspace: Workspace = { id: "8fca7b54-c2da-48f4-9668-a4b39fbc4d4c", userId: "user_1", + knowhereKeyLabel: null, namespace: "notebook-8fca7b54-c2da-48f4-9668-a4b39fbc4d4c", createdAt: new Date("2026-05-06T00:00:00Z"), }; diff --git a/src/domains/workspace/initial-state.test.ts b/src/domains/workspace/initial-state.test.ts index cf888fb..d93dffb 100644 --- a/src/domains/workspace/initial-state.test.ts +++ b/src/domains/workspace/initial-state.test.ts @@ -36,6 +36,8 @@ describe("loadWorkspaceShellInitialState", () => { expect(state).toEqual({ dashboardUrl: "https://dashboard.example", sources: [], + workspaces: [], + knowhereKeyLabels: [], }) expect(deps.listSourcesForWorkspace).not.toHaveBeenCalled() }) @@ -225,6 +227,10 @@ function createDependencies( listChatThreads: vi.fn(async () => []), listMessages: vi.fn(async () => []), listSourcesForWorkspace: vi.fn(async () => []), + listWorkspacesForUser: vi.fn(async () => [workspace]), + listMaskedKnowhereKeys: vi.fn(async () => [ + { label: "default", mask: "sk_te••••st" }, + ]), localizeRemoteDocument: vi.fn(async () => makeSource("workspace_1")), reconcileSourcesForWorkspace: vi.fn(async () => []), sourceViewOptionsBySourceId: vi.fn(() => Effect.succeed(new Map())), @@ -236,6 +242,7 @@ function makeWorkspace(): Workspace { return { id: "workspace_1", userId: "user_1", + knowhereKeyLabel: null, namespace: "notebook-workspace_1", createdAt: new Date("2026-05-10T00:00:00.000Z"), } diff --git a/src/domains/workspace/initial-state.ts b/src/domains/workspace/initial-state.ts index 4270bca..d1632ce 100644 --- a/src/domains/workspace/initial-state.ts +++ b/src/domains/workspace/initial-state.ts @@ -26,6 +26,9 @@ import type { import { effectOperation } from "@/lib/effect-operation" import { logger } from "@/lib/logger" import { notebookRequestContext } from "./request-context" +import { workspaceRepository } from "./repository" +import { databaseRuntime } from "./database-runtime" +import { listMaskedKnowhereKeys } from "@/integrations/knowhere-keys" type WorkspaceShellInitialState = { readonly activeChatThreadId?: string | null @@ -42,7 +45,17 @@ type WorkspaceShellInitialState = { readonly workspace?: { readonly id: string readonly namespace: string + readonly keyLabel: string | null } + readonly workspaces?: readonly { + readonly id: string + readonly namespace: string + readonly keyLabel: string | null + }[] + readonly knowhereKeyLabels?: readonly { + readonly label: string + readonly mask: string + }[] } const workspaceInitialStateContext = "Workspace initial state" @@ -86,6 +99,12 @@ type WorkspaceShellInitialStateDependencies = { readonly listSourcesForWorkspace: ( workspaceId: string, ) => Promise + readonly listWorkspacesForUser: ( + userId: string, + ) => Promise + readonly listMaskedKnowhereKeys: () => Promise< + readonly { label: string; mask: string }[] + > readonly localizeRemoteDocument: typeof sourceWorkflowRuntime.localizeRemoteDocument readonly reconcileSourcesForWorkspace: ( workspace: Workspace, @@ -104,6 +123,8 @@ const defaultDependencies: WorkspaceShellInitialStateDependencies = { listChatThreads: chatThreadService.listForWorkspace, listMessages: chatThreadService.listMessages, listSourcesForWorkspace: sourceWorkflowRuntime.listForWorkspace, + listWorkspacesForUser: listAllForUser, + listMaskedKnowhereKeys: listMaskedKnowhereKeysDefault, localizeRemoteDocument: sourceWorkflowRuntime.localizeRemoteDocument, reconcileSourcesForWorkspace: reconcileDefaultSourcesForWorkspace, startBackgroundReconciliation: defaultStartBackgroundReconciliation, @@ -130,6 +151,8 @@ export const loadWorkspaceShellInitialStateEffect = ( return { dashboardUrl: resolveDashboardUrl(), sources: [], + workspaces: [], + knowhereKeyLabels: [], } } @@ -223,7 +246,26 @@ export const loadWorkspaceShellInitialStateEffect = ( workspace: { id: workspace.id, namespace: workspace.namespace, + keyLabel: workspace.knowhereKeyLabel, }, + workspaces: (yield* effectOperation.tryPromise( + { + context: workspaceInitialStateContext, + operation: "listWorkspacesForUser", + }, + () => deps.listWorkspacesForUser(user.id), + )).map((row) => ({ + id: row.id, + namespace: row.namespace, + keyLabel: row.knowhereKeyLabel, + })), + knowhereKeyLabels: yield* effectOperation.tryPromise( + { + context: workspaceInitialStateContext, + operation: "listMaskedKnowhereKeys", + }, + () => deps.listMaskedKnowhereKeys(), + ), dashboardUrl: resolveDashboardUrl(), sources: localizedSources.map((source) => toSourceView(source, sourceOptions.get(source.id)), @@ -252,6 +294,17 @@ function resolveDashboardUrl(): string | undefined { return process.env.DASHBOARD_ORIGIN } +function listAllForUser(userId: string): Promise { + return databaseRuntime.runPromise( + workspaceRepository.findAllByUserIdEffect(userId), + ) +} +function listMaskedKnowhereKeysDefault(): Promise< + readonly { label: string; mask: string }[] +> { + return listMaskedKnowhereKeys() +} + function getWorkspaceSourcesNeedingKnowhereChunkCount( sources: readonly Source[], ): readonly Source[] { diff --git a/src/domains/workspace/persistence.test.ts b/src/domains/workspace/persistence.test.ts index 05dcbff..aa7ca69 100644 --- a/src/domains/workspace/persistence.test.ts +++ b/src/domains/workspace/persistence.test.ts @@ -12,11 +12,18 @@ import { chatRepository } from "../chat/repository" * which runs only when `TEST_DATABASE_URL` is set. */ -type Row = { id: string; userId: string; namespace: string; createdAt: Date } +type Row = { + id: string + userId: string + knowhereKeyLabel: string | null + namespace: string + createdAt: Date +} type SelectBuilder = { from: ReturnType where: ReturnType + orderBy: ReturnType limit: (n: number) => Promise } @@ -56,6 +63,7 @@ function buildDbMock(storage: { row: Row | null }): DbMock { const builder: SelectBuilder = { from: vi.fn(() => builder), where: vi.fn(() => builder), + orderBy: vi.fn(async () => (storage.row ? [storage.row] : [])), limit: vi.fn(async () => (storage.row ? [storage.row] : [])), } return builder @@ -67,6 +75,7 @@ function buildDbMock(storage: { row: Row | null }): DbMock { storage.row = { id: crypto.randomUUID(), userId: values.userId, + knowhereKeyLabel: values.knowhereKeyLabel ?? null, namespace: values.namespace, createdAt: new Date(), } @@ -106,6 +115,7 @@ describe("workspaceService.ensureWorkspace", () => { const existing: Row = { id: "ws_1", userId: "user_1", + knowhereKeyLabel: null, namespace: "notebook-existing", createdAt: new Date(), } @@ -145,6 +155,23 @@ describe("workspaceService.ensureWorkspace", () => { expect(a.namespace).toBe(b.namespace) expect(a.userId).toBe("user_3") }) + + it("creates a workspace for a specific (keyLabel, namespace) pair", async () => { + const storage: { row: Row | null } = { row: null } + const dbMock = buildDbMock(storage) + + const { workspaceService } = await loadWorkspaceService(dbMock) + const got = await workspaceService.ensureWorkspaceForLabelAndNamespace( + "user_1", + "domainA", + "quarterly-reports", + ) + + expect(dbMock.insert).toHaveBeenCalledOnce() + expect(got.userId).toBe("user_1") + expect(got.knowhereKeyLabel).toBe("domainA") + expect(got.namespace).toBe("quarterly-reports") + }) }) describe("chatRepository", () => { diff --git a/src/domains/workspace/repository.ts b/src/domains/workspace/repository.ts index bee08c9..f47d28b 100644 --- a/src/domains/workspace/repository.ts +++ b/src/domains/workspace/repository.ts @@ -1,52 +1,118 @@ import "server-only" -import { eq, sql } from "drizzle-orm" +import { and, eq, sql } from "drizzle-orm" import { Effect } from "effect" import { DbClient } from "@/infrastructure/db" import { workspaces, type Workspace } from "@/infrastructure/db/schema" type WorkspaceRepository = { - readonly findByUserIdEffect: ( + readonly findAllByUserIdEffect: ( userId: string, + ) => Effect.Effect + readonly findByIdEffect: ( + id: string, ) => Effect.Effect - readonly insertForUserEffect: ( + readonly findByIdAndUserIdEffect: ( + id: string, userId: string, + ) => Effect.Effect + readonly findByUserIdAndLabelAndNamespaceEffect: ( + userId: string, + keyLabel: string, + namespace: string, + ) => Effect.Effect + readonly insertForUserLabelNamespaceEffect: ( + userId: string, + keyLabel: string | null, namespace: string, ) => Effect.Effect readonly pingEffect: () => Effect.Effect } -const findByUserIdEffect: WorkspaceRepository["findByUserIdEffect"] = ( +const findAllByUserIdEffect: WorkspaceRepository["findAllByUserIdEffect"] = ( userId: string, ) => Effect.gen(function* () { const db = yield* DbClient - const row = yield* Effect.promise(() => + return yield* Effect.promise(() => db .select() .from(workspaces) .where(eq(workspaces.userId, userId)) - .limit(1), + .orderBy(workspaces.createdAt), ) + }) +const findByIdEffect: WorkspaceRepository["findByIdEffect"] = (id: string) => + Effect.gen(function* () { + const db = yield* DbClient + const row = yield* Effect.promise(() => + db + .select() + .from(workspaces) + .where(eq(workspaces.id, id)) + .limit(1), + ) return row[0] ?? null }) -const insertForUserEffect: WorkspaceRepository["insertForUserEffect"] = ( +const findByIdAndUserIdEffect: WorkspaceRepository["findByIdAndUserIdEffect"] = ( + id: string, userId: string, - namespace: string, ) => Effect.gen(function* () { const db = yield* DbClient - yield* Effect.promise(() => + const row = yield* Effect.promise(() => db - .insert(workspaces) - .values({ userId, namespace }) - .onConflictDoNothing({ target: workspaces.userId }), + .select() + .from(workspaces) + .where( + and(eq(workspaces.id, id), eq(workspaces.userId, userId)), + ) + .limit(1), ) + return row[0] ?? null }) +const findByUserIdAndLabelAndNamespaceEffect: WorkspaceRepository["findByUserIdAndLabelAndNamespaceEffect"] = + (userId: string, keyLabel: string, namespace: string) => + Effect.gen(function* () { + const db = yield* DbClient + const row = yield* Effect.promise(() => + db + .select() + .from(workspaces) + .where( + and( + eq(workspaces.userId, userId), + eq(workspaces.knowhereKeyLabel, keyLabel), + eq(workspaces.namespace, namespace), + ), + ) + .limit(1), + ) + return row[0] ?? null + }) + +const insertForUserLabelNamespaceEffect: WorkspaceRepository["insertForUserLabelNamespaceEffect"] = + (userId: string, keyLabel: string | null, namespace: string) => + Effect.gen(function* () { + const db = yield* DbClient + yield* Effect.promise(() => + db + .insert(workspaces) + .values({ userId, knowhereKeyLabel: keyLabel, namespace }) + .onConflictDoNothing({ + target: [ + workspaces.userId, + workspaces.knowhereKeyLabel, + workspaces.namespace, + ], + }), + ) + }) + const pingEffect: WorkspaceRepository["pingEffect"] = () => Effect.gen(function* () { const db = yield* DbClient @@ -54,7 +120,10 @@ const pingEffect: WorkspaceRepository["pingEffect"] = () => }) export const workspaceRepository: WorkspaceRepository = { - findByUserIdEffect, - insertForUserEffect, + findAllByUserIdEffect, + findByIdEffect, + findByIdAndUserIdEffect, + findByUserIdAndLabelAndNamespaceEffect, + insertForUserLabelNamespaceEffect, pingEffect, } diff --git a/src/domains/workspace/service.test.ts b/src/domains/workspace/service.test.ts index b4c51a7..91c7081 100644 --- a/src/domains/workspace/service.test.ts +++ b/src/domains/workspace/service.test.ts @@ -6,6 +6,7 @@ import type { Db } from "@/infrastructure/db" type WorkspaceRow = { id: string userId: string + knowhereKeyLabel: string | null namespace: string createdAt: Date } @@ -13,6 +14,7 @@ type WorkspaceRow = { type SelectBuilder = { from: ReturnType where: ReturnType + orderBy: ReturnType limit: (limit: number) => Promise } @@ -33,6 +35,7 @@ function buildWorkspaceDbMock(storage: { const builder: SelectBuilder = { from: vi.fn(() => builder), where: vi.fn(() => builder), + orderBy: vi.fn(async () => (storage.row ? [storage.row] : [])), limit: vi.fn(async () => (storage.row ? [storage.row] : [])), } return builder @@ -45,6 +48,7 @@ function buildWorkspaceDbMock(storage: { storage.row = { id: crypto.randomUUID(), userId: values.userId, + knowhereKeyLabel: values.knowhereKeyLabel ?? null, namespace: values.namespace, createdAt: new Date(), } diff --git a/src/domains/workspace/service.ts b/src/domains/workspace/service.ts index baf3af4..704e635 100644 --- a/src/domains/workspace/service.ts +++ b/src/domains/workspace/service.ts @@ -1,56 +1,158 @@ import "server-only" import { Effect } from "effect" +import { cookies } from "next/headers" import { databaseRuntime } from "./database-runtime" import { DbClient } from "@/infrastructure/db" import { workspaceRepository } from "./repository" import type { Workspace } from "@/infrastructure/db/schema" +/** Cookie that holds the active workspace id for the current browser session. */ +export const activeWorkspaceCookieName = "notebook-ws" + type WorkspaceService = { readonly ensureWorkspaceEffect: ( userId: string, ) => Effect.Effect + readonly ensureWorkspaceForLabelAndNamespaceEffect: ( + userId: string, + keyLabel: string, + namespace: string, + ) => Effect.Effect readonly pingDatabaseEffect: () => Effect.Effect readonly ensureWorkspace: (userId: string) => Promise + readonly ensureWorkspaceForLabelAndNamespace: ( + userId: string, + keyLabel: string, + namespace: string, + ) => Promise readonly pingDatabase: () => Promise } +/** + * Resolve the workspace that should serve the current request. + * + * 1. If the `notebook-ws` cookie names a workspace owned by the user, use it. + * 2. Otherwise use the user's first workspace (legacy single-workspace + * behavior: existing rows keep working). + * 3. If the user has no workspace yet, create a legacy default one + * (null key label, auto-generated `notebook-` namespace). + */ const ensureWorkspaceEffect: WorkspaceService["ensureWorkspaceEffect"] = ( userId: string, ) => Effect.gen(function* () { - const existing = yield* workspaceRepository.findByUserIdEffect(userId) - if (existing) return existing - - const namespace = `notebook-${crypto.randomUUID()}` - yield* workspaceRepository.insertForUserEffect(userId, namespace) - - const row = yield* workspaceRepository.findByUserIdEffect(userId) - if (!row) { - return yield* Effect.die( - new Error( - `ensureWorkspace: workspace row not found for user ${userId} after ` + - "upsert. Check that the workspaces.user_id unique index exists.", - ), + const activeId = yield* readActiveWorkspaceIdEffect.pipe( + Effect.catchAll(() => Effect.succeed(null)), + ) + if (activeId) { + const byCookie = yield* workspaceRepository.findByIdAndUserIdEffect( + activeId, + userId, ) + if (byCookie) return byCookie } - return row + const all = yield* workspaceRepository.findAllByUserIdEffect(userId) + if (all.length > 0) return all[0]! + + const namespace = `notebook-${crypto.randomUUID()}` + yield* workspaceRepository.insertForUserLabelNamespaceEffect( + userId, + null, + namespace, + ) + + const legacyRows = yield* workspaceRepository.findAllByUserIdEffect(userId) + const row = legacyRows[0] + if (row) return row + + return yield* Effect.die( + new Error( + `ensureWorkspace: workspace row not found for user ${userId} after ` + + "upsert. Check that the workspaces indexes exist.", + ), + ) }) +/** + * Find or create the workspace bound to a specific (keyLabel, namespace) + * pair for a user. Used by the domain switcher when the user picks a + * namespace under a domain that has no workspace row yet. + */ +const ensureWorkspaceForLabelAndNamespaceEffect: WorkspaceService["ensureWorkspaceForLabelAndNamespaceEffect"] = + (userId: string, keyLabel: string, namespace: string) => + Effect.gen(function* () { + const existing = + yield* workspaceRepository.findByUserIdAndLabelAndNamespaceEffect( + userId, + keyLabel, + namespace, + ) + if (existing) return existing + + yield* workspaceRepository.insertForUserLabelNamespaceEffect( + userId, + keyLabel, + namespace, + ) + + const row = + yield* workspaceRepository.findByUserIdAndLabelAndNamespaceEffect( + userId, + keyLabel, + namespace, + ) + if (!row) { + return yield* Effect.die( + new Error( + `ensureWorkspaceForLabelAndNamespace: workspace row not found ` + + `for user ${userId} (${keyLabel}, ${namespace}) after upsert.`, + ), + ) + } + + return row + }) + const pingDatabaseEffect: WorkspaceService["pingDatabaseEffect"] = () => workspaceRepository.pingEffect() const ensureWorkspace: WorkspaceService["ensureWorkspace"] = (userId: string) => databaseRuntime.runPromise(ensureWorkspaceEffect(userId)) +const ensureWorkspaceForLabelAndNamespace: WorkspaceService["ensureWorkspaceForLabelAndNamespace"] = + (userId: string, keyLabel: string, namespace: string) => + databaseRuntime.runPromise( + ensureWorkspaceForLabelAndNamespaceEffect(userId, keyLabel, namespace), + ) + const pingDatabase: WorkspaceService["pingDatabase"] = () => databaseRuntime.runPromise(pingDatabaseEffect()) +/** + * Read the active workspace id from the `notebook-ws` cookie. Returns null + * outside a request scope (background jobs, tests, CLI). + */ +const readActiveWorkspaceIdEffect: Effect.Effect< + string | null, + unknown, + never +> = Effect.tryPromise(async (): Promise => { + try { + const jar = await cookies() + return jar.get(activeWorkspaceCookieName)?.value ?? null + } catch { + return null + } +}) + export const workspaceService: WorkspaceService = { ensureWorkspaceEffect, + ensureWorkspaceForLabelAndNamespaceEffect, pingDatabaseEffect, ensureWorkspace, + ensureWorkspaceForLabelAndNamespace, pingDatabase, } diff --git a/src/infrastructure/db/schema.ts b/src/infrastructure/db/schema.ts index ae49cbf..1c5b5b1 100644 --- a/src/infrastructure/db/schema.ts +++ b/src/infrastructure/db/schema.ts @@ -33,23 +33,37 @@ import { */ /** - * One workspace per user for the MVP. `user_id` is the Dashboard user id - * as returned by `users.getCurrentUser` (not a Notebook-local id). + * Workspaces: the persistence unit for a domain-scoped document set. * - * `namespace` is the Knowhere namespace this workspace's sources all live - * in. It is derived once from the workspace id and never mutated. + * A workspace binds one user to one Knowhere document domain: the + * `knowhere_key_label` selects which configured API key (domain) the + * workspace authenticates with, and `namespace` is the Knowhere namespace + * under that domain the workspace's sources live in. One workspace per + * (user, key label, namespace) tuple. + * + * Legacy rows created before multi-domain support have a null + * `knowhere_key_label` (uses the default key) and an auto-generated + * `notebook-` namespace; they keep working unchanged. */ export const workspaces = pgTable( "workspaces", { id: uuid("id").primaryKey().defaultRandom(), - userId: text("user_id").notNull().unique(), - namespace: text("namespace").notNull().unique(), + userId: text("user_id").notNull(), + knowhereKeyLabel: text("knowhere_key_label"), + namespace: text("namespace").notNull(), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), }, - (t) => [index("workspaces_user_id_idx").on(t.userId)], + (t) => [ + index("workspaces_user_id_idx").on(t.userId), + uniqueIndex("workspaces_user_label_namespace_idx").on( + t.userId, + t.knowhereKeyLabel, + t.namespace, + ), + ], ); export type Workspace = typeof workspaces.$inferSelect; diff --git a/src/integrations/dashboard/api-key-service.test.ts b/src/integrations/dashboard/api-key-service.test.ts index 88347e0..a519093 100644 --- a/src/integrations/dashboard/api-key-service.test.ts +++ b/src/integrations/dashboard/api-key-service.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "vitest" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" const nextCacheMocks = vi.hoisted(() => ({ cacheLife: vi.fn(), @@ -7,6 +7,29 @@ const nextCacheMocks = vi.hoisted(() => ({ vi.mock("next/cache", () => nextCacheMocks) +const workspaceRepoMocks = vi.hoisted(() => ({ + findByIdEffect: vi.fn(), +})) + +vi.mock("@/domains/workspace/repository", () => ({ + workspaceRepository: workspaceRepoMocks, +})) + +const databaseRuntimeMocks = vi.hoisted(() => ({ + runPromise: vi.fn(), +})) + +vi.mock("@/domains/workspace/database-runtime", () => ({ + databaseRuntime: databaseRuntimeMocks, +})) + +const knowhereKeysMocks = vi.hoisted(() => ({ + getKnowhereKeyByLabel: vi.fn(), + getDefaultKnowhereKey: vi.fn(), +})) + +vi.mock("@/integrations/knowhere-keys", () => knowhereKeysMocks) + import { ensureApiKeyForWorkspace, fetchKnowhereJwt, @@ -219,6 +242,15 @@ describe("ensureApiKeyForWorkspace", () => { const originalApiKey = process.env.KNOWHERE_API_KEY const originalOrigin = process.env.DASHBOARD_ORIGIN + beforeEach(() => { + databaseRuntimeMocks.runPromise.mockReset() + databaseRuntimeMocks.runPromise.mockResolvedValue(null) + knowhereKeysMocks.getKnowhereKeyByLabel.mockReset() + knowhereKeysMocks.getKnowhereKeyByLabel.mockResolvedValue(null) + knowhereKeysMocks.getDefaultKnowhereKey.mockReset() + knowhereKeysMocks.getDefaultKnowhereKey.mockResolvedValue(null) + }) + afterEach(() => { globalThis.fetch = originalFetch if (originalApiKey === undefined) delete process.env.KNOWHERE_API_KEY @@ -239,4 +271,53 @@ describe("ensureApiKeyForWorkspace", () => { expect(apiKey).toBe("sk_dev_key") expect(fetchSpy).not.toHaveBeenCalled() }) + + it("resolves the workspace's knowhereKeyLabel from the key source", async () => { + databaseRuntimeMocks.runPromise.mockResolvedValue({ + id: "workspace_1", + userId: "user_1", + knowhereKeyLabel: "domainA", + namespace: "quarterly", + createdAt: new Date(), + }) + knowhereKeysMocks.getKnowhereKeyByLabel.mockResolvedValue({ + label: "domainA", + apiKey: "sk_domain_a", + }) + const fetchSpy = vi.fn() + globalThis.fetch = fetchSpy + + const apiKey = await ensureApiKeyForWorkspace("workspace_1", "") + + expect(apiKey).toBe("sk_domain_a") + expect(knowhereKeysMocks.getKnowhereKeyByLabel).toHaveBeenCalledWith( + "domainA", + ) + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it("falls back to the default key when the workspace label is missing", async () => { + databaseRuntimeMocks.runPromise.mockResolvedValue({ + id: "workspace_2", + userId: "user_1", + knowhereKeyLabel: null, + namespace: "notebook-abc", + createdAt: new Date(), + }) + knowhereKeysMocks.getKnowhereKeyByLabel.mockResolvedValue(null) + knowhereKeysMocks.getDefaultKnowhereKey.mockResolvedValue({ + label: "default", + apiKey: "sk_default", + }) + const fetchSpy = vi.fn() + globalThis.fetch = fetchSpy + + const apiKey = await ensureApiKeyForWorkspace("workspace_2", "") + + expect(apiKey).toBe("sk_default") + expect(knowhereKeysMocks.getKnowhereKeyByLabel).toHaveBeenCalledWith( + "default", + ) + expect(fetchSpy).not.toHaveBeenCalled() + }) }) diff --git a/src/integrations/dashboard/api-key-service.ts b/src/integrations/dashboard/api-key-service.ts index 743c99d..a1b51fb 100644 --- a/src/integrations/dashboard/api-key-service.ts +++ b/src/integrations/dashboard/api-key-service.ts @@ -9,6 +9,12 @@ import { } from "@effect/platform" import { logger } from "@/lib/logger" import { knowhereApiKeyOverride } from "@/integrations/knowhere-api-key" +import { + getDefaultKnowhereKey, + getKnowhereKeyByLabel, +} from "@/integrations/knowhere-keys" +import { workspaceRepository } from "@/domains/workspace/repository" +import { databaseRuntime } from "@/domains/workspace/database-runtime" import { setEmptyJsonBody } from "./orpc-request" import { formatUnknownForLog } from "@/lib/format-log-value" @@ -139,13 +145,33 @@ export async function fetchKnowhereJwt( } /** - * Resolve the credential used for Knowhere SDK calls. Development can - * short-circuit Dashboard JWT issuance by setting KNOWHERE_API_KEY. + * Resolve the credential used for Knowhere SDK calls. + * + * Order: + * 1. Workspace-scoped key: look up the workspace row, read its + * `knowhereKeyLabel` (null → default), and resolve the key from the + * configured key source (`config/knowhere-keys.json`, falling back to + * the KNOWHERE_API_KEY env var). Keys are read server-side only. + * 2. Legacy env override: single KNOWHERE_API_KEY when no key file is + * configured (today's behavior). + * 3. Dashboard JWT issuance for the authenticated user (production). */ export async function ensureApiKeyForWorkspace( - _workspaceId: string, + workspaceId: string, cookieHeader: string, ): Promise { + const workspace = await databaseRuntime + .runPromise(workspaceRepository.findByIdEffect(workspaceId)) + .catch(() => null) + + if (workspace) { + const key = await getKnowhereKeyByLabel(workspace.knowhereKeyLabel ?? "default") + if (key) return key.apiKey + } + + const defaultKey = await getDefaultKnowhereKey() + if (defaultKey) return defaultKey.apiKey + const apiKey = knowhereApiKeyOverride.getApiKey() if (apiKey) return apiKey diff --git a/src/integrations/knowhere-api-key.ts b/src/integrations/knowhere-api-key.ts index 4645c39..ddbd3ee 100644 --- a/src/integrations/knowhere-api-key.ts +++ b/src/integrations/knowhere-api-key.ts @@ -10,17 +10,30 @@ const developmentUser: KnowhereDevelopmentUser = { name: "Knowhere API Key Development", } +/** + * Edge-safe dev-mode presence check. The server-side multi-key reader + * (src/integrations/knowhere-keys.ts) may load keys from a file the edge + * proxy cannot read, so the presence of KNOWHERE_KEYS_FILE is honored here + * too — the proxy short-circuit must not redirect when file-backed keys + * exist. + */ +function hasDevModeKeys(): boolean { + if (process.env.KNOWHERE_KEYS_FILE?.trim()) return true + const value = process.env.KNOWHERE_API_KEY?.trim() + return Boolean(value && value.length > 0) +} + function getApiKey(): string | null { const value = process.env.KNOWHERE_API_KEY?.trim() return value && value.length > 0 ? value : null } function hasApiKey(): boolean { - return getApiKey() !== null + return hasDevModeKeys() } function getDevelopmentUser(): KnowhereDevelopmentUser | null { - if (!hasApiKey()) return null + if (!hasDevModeKeys()) return null return developmentUser } diff --git a/src/integrations/knowhere-keys.test.ts b/src/integrations/knowhere-keys.test.ts new file mode 100644 index 0000000..f899b0f --- /dev/null +++ b/src/integrations/knowhere-keys.test.ts @@ -0,0 +1,118 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import { + getDefaultKnowhereKeyLabel, + getKnowhereKeyByLabel, + listKnowhereKeys, + listMaskedKnowhereKeys, + maskApiKey, +} from "./knowhere-keys" + +describe("knowhere-keys", () => { + let tempDir: string + const originalEnv = { ...process.env } + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "knowhere-keys-test-")) + delete process.env.KNOWHERE_KEYS_FILE + delete process.env.KNOWHERE_API_KEY + vi.resetModules() + }) + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }) + process.env.KNOWHERE_KEYS_FILE = originalEnv.KNOWHERE_KEYS_FILE + process.env.KNOWHERE_API_KEY = originalEnv.KNOWHERE_API_KEY + }) + + it("falls back to KNOWHERE_API_KEY env as a single 'default' key", async () => { + process.env.KNOWHERE_API_KEY = "sk_env_key_123" + + expect(await listKnowhereKeys()).toEqual([ + { label: "default", apiKey: "sk_env_key_123" }, + ]) + expect(await getDefaultKnowhereKeyLabel()).toBe("default") + expect(await getKnowhereKeyByLabel("default")).toEqual({ + label: "default", + apiKey: "sk_env_key_123", + }) + }) + + it("returns no keys when neither env nor file is configured", async () => { + expect(await listKnowhereKeys()).toEqual([]) + expect(await getDefaultKnowhereKeyLabel()).toBe("default") + expect(await getKnowhereKeyByLabel("default")).toBeNull() + }) + + it("reads labeled keys from the keys file", async () => { + const filePath = join(tempDir, "keys.json") + await writeFile( + filePath, + JSON.stringify([ + { label: "domainA", apiKey: "sk_a_1" }, + { label: "domainB", apiKey: "sk_b_2" }, + ]), + ) + process.env.KNOWHERE_KEYS_FILE = filePath + + expect(await listKnowhereKeys()).toEqual([ + { label: "domainA", apiKey: "sk_a_1" }, + { label: "domainB", apiKey: "sk_b_2" }, + ]) + expect(await getDefaultKnowhereKeyLabel()).toBe("domainA") + expect(await getKnowhereKeyByLabel("domainB")).toEqual({ + label: "domainB", + apiKey: "sk_b_2", + }) + expect(await getKnowhereKeyByLabel("missing")).toBeNull() + }) + + it("re-reads the file when its mtime changes (no-restart edits)", async () => { + const filePath = join(tempDir, "keys.json") + await writeFile(filePath, JSON.stringify([{ label: "a", apiKey: "sk_1" }])) + process.env.KNOWHERE_KEYS_FILE = filePath + + expect(await listKnowhereKeys()).toEqual([{ label: "a", apiKey: "sk_1" }]) + + // Give the file a different mtime so the cache invalidates. + await new Promise((resolve) => setTimeout(resolve, 1100)) + await writeFile( + filePath, + JSON.stringify([ + { label: "a", apiKey: "sk_1" }, + { label: "b", apiKey: "sk_2" }, + ]), + ) + + expect(await listKnowhereKeys()).toEqual([ + { label: "a", apiKey: "sk_1" }, + { label: "b", apiKey: "sk_2" }, + ]) + }) + + it("ignores malformed entries and a missing file", async () => { + const filePath = join(tempDir, "keys.json") + await writeFile( + filePath, + JSON.stringify([ + { label: "ok", apiKey: "sk_ok" }, + { label: "", apiKey: "sk_no_label" }, + { label: "no-key" }, + "not-an-object", + ]), + ) + process.env.KNOWHERE_KEYS_FILE = filePath + + expect(await listKnowhereKeys()).toEqual([{ label: "ok", apiKey: "sk_ok" }]) + }) + + it("masks keys for display", () => { + expect(maskApiKey("sk_8aBdXbOvF_Qibah2-_BDNo1-VCd50A16CwfiremGVB8")).toBe( + "sk_8aB••••GVB8", + ) + expect(listMaskedKnowhereKeys).toBeTypeOf("function") + }) +}) diff --git a/src/integrations/knowhere-keys.ts b/src/integrations/knowhere-keys.ts new file mode 100644 index 0000000..d07873a --- /dev/null +++ b/src/integrations/knowhere-keys.ts @@ -0,0 +1,112 @@ +import "server-only" + +import { readFile, stat } from "node:fs/promises" + +export type KnowhereKey = { + readonly label: string + readonly apiKey: string +} + +export type MaskedKnowhereKey = { + readonly label: string + readonly mask: string +} + +/** + * Source of Knowhere API keys (server-side only). + * + * Priority: + * 1. `config/knowhere-keys.json` (path from `KNOWHERE_KEYS_FILE`, default + * `./config/knowhere-keys.json`) — an array of `{ label, apiKey }`. + * Re-read when the file mtime changes, so edits take effect without a + * restart. + * 2. Fallback: the `KNOWHERE_API_KEY` env var as a single key labeled + * `"default"` (today's behavior). + * + * Edge runtime (proxy.ts) cannot read files — it checks the env-only + * `knowhereApiKeyOverride` for dev-mode presence. Server code should use + * this module. + */ +const defaultKeysFilePath = "./config/knowhere-keys.json" + +let cachedFileKeys: readonly KnowhereKey[] | null = null +let cachedFileMtimeMs: number | null = null + +async function readKeysFile(): Promise { + const path = process.env.KNOWHERE_KEYS_FILE?.trim() || defaultKeysFilePath + + try { + const fileStat = await stat(path) + if (cachedFileMtimeMs === fileStat.mtimeMs && cachedFileKeys !== null) { + return cachedFileKeys + } + + const raw = await readFile(path, "utf8") + const keys = normalizeFileKeys(JSON.parse(raw)) + cachedFileMtimeMs = fileStat.mtimeMs + cachedFileKeys = keys + return keys + } catch { + return [] + } +} + +function normalizeFileKeys(value: unknown): readonly KnowhereKey[] { + if (!Array.isArray(value)) return [] + const keys: KnowhereKey[] = [] + for (const entry of value) { + if (typeof entry !== "object" || entry === null) continue + const candidate = entry as Record + const label = typeof candidate.label === "string" ? candidate.label.trim() : "" + const apiKey = + typeof candidate.apiKey === "string" ? candidate.apiKey.trim() : "" + if (label.length === 0 || apiKey.length === 0) continue + keys.push({ label, apiKey }) + } + return keys +} + +function getEnvKey(): KnowhereKey | null { + const value = process.env.KNOWHERE_API_KEY?.trim() + if (!value || value.length === 0) return null + return { label: "default", apiKey: value } +} + +export async function listKnowhereKeys(): Promise { + const fileKeys = await readKeysFile() + if (fileKeys.length > 0) return fileKeys + + const envKey = getEnvKey() + return envKey ? [envKey] : [] +} + +export async function listMaskedKnowhereKeys(): Promise< + readonly MaskedKnowhereKey[] +> { + const keys = await listKnowhereKeys() + return keys.map((key) => ({ label: key.label, mask: maskApiKey(key.apiKey) })) +} + +export async function getKnowhereKeyByLabel( + label: string, +): Promise { + const normalized = label?.trim() + if (!normalized) return null + const keys = await listKnowhereKeys() + return keys.find((candidate) => candidate.label === normalized) ?? null +} + +export async function getDefaultKnowhereKeyLabel(): Promise { + const keys = await listKnowhereKeys() + return keys[0]?.label ?? "default" +} + +export async function getDefaultKnowhereKey(): Promise { + const keys = await listKnowhereKeys() + return keys[0] ?? null +} + +export function maskApiKey(apiKey: string): string { + if (apiKey.length <= 12) return `${apiKey.slice(0, 4)}••••` + return `${apiKey.slice(0, 6)}••••${apiKey.slice(-4)}` +} From 880bc565729cf5e69808dcc47b1d0d86c46cc779 Mon Sep 17 00:00:00 2001 From: Gordon YUEN Date: Wed, 5 Aug 2026 10:24:15 +0800 Subject: [PATCH 25/46] feat(api): knowhere-keys and workspace activate/create routes - GET /api/knowhere-keys: masked key labels for the domain switcher - GET /api/knowhere-keys/[label]/namespaces: namespaces visible to a specific key, for the new-workspace picker - POST /api/workspaces/activate: validates ownership and sets the notebook-ws cookie - POST /api/workspaces: creates the workspace for a (keyLabel, namespace) pair and sets the notebook-ws cookie - Route tests for all four endpoints --- .../knowhere-keys/[label]/namespaces/route.ts | 36 +++++ src/app/api/knowhere-keys/route.test.ts | 100 ++++++++++++ src/app/api/knowhere-keys/route.ts | 20 +++ src/app/api/workspaces/activate/route.ts | 52 +++++++ src/app/api/workspaces/route.test.ts | 146 ++++++++++++++++++ src/app/api/workspaces/route.ts | 63 ++++++++ 6 files changed, 417 insertions(+) create mode 100644 src/app/api/knowhere-keys/[label]/namespaces/route.ts create mode 100644 src/app/api/knowhere-keys/route.test.ts create mode 100644 src/app/api/knowhere-keys/route.ts create mode 100644 src/app/api/workspaces/activate/route.ts create mode 100644 src/app/api/workspaces/route.test.ts create mode 100644 src/app/api/workspaces/route.ts diff --git a/src/app/api/knowhere-keys/[label]/namespaces/route.ts b/src/app/api/knowhere-keys/[label]/namespaces/route.ts new file mode 100644 index 0000000..1fc2270 --- /dev/null +++ b/src/app/api/knowhere-keys/[label]/namespaces/route.ts @@ -0,0 +1,36 @@ +import type { NextResponse } from "next/server" + +import { withApiErrorResponse } from "@/lib/api-error-response" +import { getCurrentUser } from "@/infrastructure/auth" +import { getKnowhereKeyByLabel } from "@/integrations/knowhere-keys" +import { listKnowhereNamespaces } from "@/integrations/knowhere" +import { nextRouteResponse } from "@/lib/next-route-response" +import { routeResult } from "@/lib/route-result" + +export async function GET( + _request: Request, + { params }: { params: Promise<{ label: string }> }, +): Promise { + return withApiErrorResponse( + "knowhere-keys:namespaces", + async () => { + const { label } = await params + const decodedLabel = decodeURIComponent(label) + const user = await getCurrentUser() + if (!user) { + return nextRouteResponse.toNextResponse( + routeResult.badRequest("Not authenticated."), + ) + } + const key = await getKnowhereKeyByLabel(decodedLabel) + if (!key) { + return nextRouteResponse.toNextResponse( + routeResult.error(404, `Key label '${decodedLabel}' not found.`), + ) + } + const namespaces = await listKnowhereNamespaces(key.apiKey) + return nextRouteResponse.toNextResponse(routeResult.ok({ namespaces })) + }, + "Could not list namespaces for this key.", + ) +} diff --git a/src/app/api/knowhere-keys/route.test.ts b/src/app/api/knowhere-keys/route.test.ts new file mode 100644 index 0000000..4ccf7a3 --- /dev/null +++ b/src/app/api/knowhere-keys/route.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + return { + getCurrentUser: vi.fn(), + getKnowhereKeyByLabel: vi.fn(), + listKnowhereNamespaces: vi.fn(), + listMaskedKnowhereKeys: vi.fn(), + }; +}); + +vi.mock("@/infrastructure/auth", () => ({ + getCurrentUser: mocks.getCurrentUser, +})); + +vi.mock("@/integrations/knowhere-keys", () => ({ + getKnowhereKeyByLabel: mocks.getKnowhereKeyByLabel, + listMaskedKnowhereKeys: mocks.listMaskedKnowhereKeys, +})); + +vi.mock("@/integrations/knowhere", () => ({ + listKnowhereNamespaces: mocks.listKnowhereNamespaces, +})); + +import { GET as listKeys } from "./route"; +import { GET as listNamespacesForLabel } from "./[label]/namespaces/route"; + +const user = { id: "user_1", email: "ada@example.com", name: "Ada" }; + +describe("GET /api/knowhere-keys", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getCurrentUser.mockResolvedValue(user); + }); + + it("returns masked key labels", async () => { + mocks.listMaskedKnowhereKeys.mockResolvedValue([ + { label: "default", mask: "sk_te••••st" }, + { label: "domainA", mask: "sk_8aB••••GVB8" }, + ]); + + const response = await listKeys(); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.keys).toEqual([ + { label: "default", mask: "sk_te••••st" }, + { label: "domainA", mask: "sk_8aB••••GVB8" }, + ]); + }); + + it("rejects unauthenticated requests", async () => { + mocks.getCurrentUser.mockResolvedValue(null); + + const response = await listKeys(); + + expect(response.status).toBe(400); + }); +}); + +describe("GET /api/knowhere-keys/[label]/namespaces", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getCurrentUser.mockResolvedValue(user); + }); + + it("lists namespaces visible to the given key label", async () => { + mocks.getKnowhereKeyByLabel.mockResolvedValue({ + label: "domainA", + apiKey: "sk_domain_a", + }); + mocks.listKnowhereNamespaces.mockResolvedValue([ + { namespace: "adobe", documentCount: 9 }, + { namespace: "docx", documentCount: 9 }, + ]); + + const response = await listNamespacesForLabel(new Request("http://localhost"), { + params: Promise.resolve({ label: "domainA" }), + }); + const body = await response.json(); + + expect(mocks.getKnowhereKeyByLabel).toHaveBeenCalledWith("domainA"); + expect(mocks.listKnowhereNamespaces).toHaveBeenCalledWith("sk_domain_a"); + expect(response.status).toBe(200); + expect(body.namespaces).toEqual([ + { namespace: "adobe", documentCount: 9 }, + { namespace: "docx", documentCount: 9 }, + ]); + }); + + it("returns 404 for an unknown key label", async () => { + mocks.getKnowhereKeyByLabel.mockResolvedValue(null); + + const response = await listNamespacesForLabel(new Request("http://localhost"), { + params: Promise.resolve({ label: "missing" }), + }); + + expect(response.status).toBe(404); + }); +}); diff --git a/src/app/api/knowhere-keys/route.ts b/src/app/api/knowhere-keys/route.ts new file mode 100644 index 0000000..eb12ebe --- /dev/null +++ b/src/app/api/knowhere-keys/route.ts @@ -0,0 +1,20 @@ +import type { NextResponse } from "next/server" + +import { withApiErrorResponse } from "@/lib/api-error-response" +import { getCurrentUser } from "@/infrastructure/auth" +import { listMaskedKnowhereKeys } from "@/integrations/knowhere-keys" +import { nextRouteResponse } from "@/lib/next-route-response" +import { routeResult } from "@/lib/route-result" + +export async function GET(): Promise { + return withApiErrorResponse("knowhere-keys:list", async () => { + const user = await getCurrentUser() + if (!user) { + return nextRouteResponse.toNextResponse( + routeResult.badRequest("Not authenticated."), + ) + } + const keys = await listMaskedKnowhereKeys() + return nextRouteResponse.toNextResponse(routeResult.ok({ keys })) + }) +} diff --git a/src/app/api/workspaces/activate/route.ts b/src/app/api/workspaces/activate/route.ts new file mode 100644 index 0000000..4cb300e --- /dev/null +++ b/src/app/api/workspaces/activate/route.ts @@ -0,0 +1,52 @@ +import type { NextRequest, NextResponse } from "next/server" + +import { withApiErrorResponse } from "@/lib/api-error-response" +import { getCurrentUser } from "@/infrastructure/auth" +import { activeWorkspaceCookieName } from "@/domains/workspace/service" +import { workspaceRepository } from "@/domains/workspace/repository" +import { databaseRuntime } from "@/domains/workspace/database-runtime" +import { nextRouteResponse } from "@/lib/next-route-response" +import { routeResult } from "@/lib/route-result" + +export async function POST(request: NextRequest): Promise { + return withApiErrorResponse( + "workspaces:activate", + async () => { + const body = await routeResult.readJsonOrNull(request) + const workspaceId = + typeof body === "object" && body !== null && "workspaceId" in body + ? String((body as { workspaceId?: unknown }).workspaceId) + : "" + const user = await getCurrentUser() + if (!user) { + return nextRouteResponse.toNextResponse( + routeResult.badRequest("Not authenticated."), + ) + } + if (!workspaceId) { + return nextRouteResponse.toNextResponse( + routeResult.badRequest("workspaceId is required."), + ) + } + + const workspace = await databaseRuntime.runPromise( + workspaceRepository.findByIdAndUserIdEffect(workspaceId, user.id), + ) + if (!workspace) { + return nextRouteResponse.toNextResponse( + routeResult.error(404, "Workspace not found."), + ) + } + + const response = nextRouteResponse.toNextResponse(routeResult.ok({})) + response.cookies.set(activeWorkspaceCookieName, workspace.id, { + httpOnly: false, + sameSite: "lax", + path: "/", + maxAge: 60 * 60 * 24 * 365, + }) + return response + }, + "Could not activate this workspace.", + ) +} diff --git a/src/app/api/workspaces/route.test.ts b/src/app/api/workspaces/route.test.ts new file mode 100644 index 0000000..972d4c9 --- /dev/null +++ b/src/app/api/workspaces/route.test.ts @@ -0,0 +1,146 @@ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + return { + activeWorkspaceCookieName: "notebook-ws", + ensureWorkspaceForLabelAndNamespace: vi.fn(), + findByIdAndUserIdEffect: vi.fn(), + runPromise: vi.fn(), + getCurrentUser: vi.fn(), + }; +}); + +vi.mock("@/domains/workspace/service", () => ({ + activeWorkspaceCookieName: mocks.activeWorkspaceCookieName, + workspaceService: { + ensureWorkspaceForLabelAndNamespace: mocks.ensureWorkspaceForLabelAndNamespace, + }, +})); + +vi.mock("@/domains/workspace/repository", () => ({ + workspaceRepository: { + findByIdAndUserIdEffect: mocks.findByIdAndUserIdEffect, + }, +})); + +vi.mock("@/domains/workspace/database-runtime", () => ({ + databaseRuntime: { + runPromise: mocks.runPromise, + }, +})); + +vi.mock("@/infrastructure/auth", () => ({ + getCurrentUser: mocks.getCurrentUser, +})); + +import { POST as activateWorkspace } from "./activate/route"; +import { POST as createWorkspace } from "./route"; + +const user = { id: "user_1", email: "ada@example.com", name: "Ada" }; +const workspace = { + id: "ws_1", + userId: "user_1", + knowhereKeyLabel: "domainA", + namespace: "quarterly", + createdAt: new Date(), +}; + +describe("POST /api/workspaces", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getCurrentUser.mockResolvedValue(user); + }); + + it("creates a workspace for a (keyLabel, namespace) pair and sets the cookie", async () => { + mocks.ensureWorkspaceForLabelAndNamespace.mockResolvedValue(workspace); + const request = new NextRequest("http://localhost/api/workspaces", { + method: "POST", + body: JSON.stringify({ keyLabel: "domainA", namespace: "quarterly" }), + }); + + const response = await createWorkspace(request); + const body = await response.json(); + + expect(mocks.ensureWorkspaceForLabelAndNamespace).toHaveBeenCalledWith( + "user_1", + "domainA", + "quarterly", + ); + expect(response.status).toBe(200); + expect(body.workspace).toEqual({ + id: "ws_1", + namespace: "quarterly", + keyLabel: "domainA", + }); + expect(response.cookies.get("notebook-ws")?.value).toBe("ws_1"); + }); + + it("rejects requests without keyLabel or namespace", async () => { + const request = new NextRequest("http://localhost/api/workspaces", { + method: "POST", + body: JSON.stringify({ keyLabel: "domainA" }), + }); + + const response = await createWorkspace(request); + + expect(response.status).toBe(400); + expect(mocks.ensureWorkspaceForLabelAndNamespace).not.toHaveBeenCalled(); + }); + + it("rejects unauthenticated requests", async () => { + mocks.getCurrentUser.mockResolvedValue(null); + const request = new NextRequest("http://localhost/api/workspaces", { + method: "POST", + body: JSON.stringify({ keyLabel: "domainA", namespace: "quarterly" }), + }); + + const response = await createWorkspace(request); + + expect(response.status).toBe(400); + }); +}); + +describe("POST /api/workspaces/activate", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getCurrentUser.mockResolvedValue(user); + mocks.runPromise.mockResolvedValue(workspace); + }); + + it("activates an owned workspace and sets the cookie", async () => { + const request = new NextRequest("http://localhost/api/workspaces/activate", { + method: "POST", + body: JSON.stringify({ workspaceId: "ws_1" }), + }); + + const response = await activateWorkspace(request); + + expect(mocks.runPromise).toHaveBeenCalled(); + expect(response.status).toBe(200); + expect(response.cookies.get("notebook-ws")?.value).toBe("ws_1"); + }); + + it("rejects a workspace that does not belong to the user", async () => { + mocks.runPromise.mockResolvedValue(null); + const request = new NextRequest("http://localhost/api/workspaces/activate", { + method: "POST", + body: JSON.stringify({ workspaceId: "ws_other" }), + }); + + const response = await activateWorkspace(request); + + expect(response.status).toBe(404); + }); + + it("rejects requests without workspaceId", async () => { + const request = new NextRequest("http://localhost/api/workspaces/activate", { + method: "POST", + body: JSON.stringify({}), + }); + + const response = await activateWorkspace(request); + + expect(response.status).toBe(400); + }); +}); diff --git a/src/app/api/workspaces/route.ts b/src/app/api/workspaces/route.ts new file mode 100644 index 0000000..e36382f --- /dev/null +++ b/src/app/api/workspaces/route.ts @@ -0,0 +1,63 @@ +import type { NextRequest, NextResponse } from "next/server" + +import { withApiErrorResponse } from "@/lib/api-error-response" +import { getCurrentUser } from "@/infrastructure/auth" +import { + workspaceService, + activeWorkspaceCookieName, +} from "@/domains/workspace/service" +import { nextRouteResponse } from "@/lib/next-route-response" +import { routeResult } from "@/lib/route-result" + +export async function POST(request: NextRequest): Promise { + return withApiErrorResponse( + "workspaces:create", + async () => { + const body = await routeResult.readJsonOrNull(request) + const keyLabel = + typeof body === "object" && body !== null && "keyLabel" in body + ? String((body as { keyLabel?: unknown }).keyLabel) + : "" + const namespace = + typeof body === "object" && body !== null && "namespace" in body + ? String((body as { namespace?: unknown }).namespace) + : "" + const user = await getCurrentUser() + if (!user) { + return nextRouteResponse.toNextResponse( + routeResult.badRequest("Not authenticated."), + ) + } + if (!keyLabel || !namespace) { + return nextRouteResponse.toNextResponse( + routeResult.badRequest("keyLabel and namespace are required."), + ) + } + + const workspace = + await workspaceService.ensureWorkspaceForLabelAndNamespace( + user.id, + keyLabel, + namespace, + ) + + const response = nextRouteResponse.toNextResponse( + routeResult.ok({ + workspace: { + id: workspace.id, + namespace: workspace.namespace, + keyLabel: workspace.knowhereKeyLabel, + }, + }), + ) + response.cookies.set(activeWorkspaceCookieName, workspace.id, { + httpOnly: false, + sameSite: "lax", + path: "/", + maxAge: 60 * 60 * 24 * 365, + }) + return response + }, + "Could not create this workspace.", + ) +} From 986a4d992cf75ff031ac5e16ed67ad0ed3495889 Mon Sep 17 00:00:00 2001 From: Gordon YUEN Date: Wed, 5 Aug 2026 10:32:57 +0800 Subject: [PATCH 26/46] feat(workspaces): workspace switcher UI + Docker/docs for multi-domain - WorkspaceSwitcher at the top of the sources panel: lists workspaces grouped by domain (API key label), activates on click (sets notebook-ws cookie + router.refresh()), and a New workspace dialog that picks a domain key, fetches its namespaces, then creates the workspace for the chosen namespace - workspaceClient: fetchKnowhereKeys, fetchKnowhereKeyNamespaces, activateWorkspace, createWorkspace - Sources panel + shell + layout thread activeWorkspace/workspaces/ knowhereKeyLabels through from the SSR initial state - Dockerfile: create /app/config owned by nextjs for the keys-file bind mount - AGENTS.md: keys-file format + mount + multi-domain workspace convention; CONTEXT.md: Workspace + Knowhere Key Label definitions; ADR 0009 --- AGENTS.md | 2 + CONTEXT.md | 21 +- Dockerfile | 4 + ...ulti-domain-workspaces-file-backed-keys.md | 64 ++++ src/app/e2e/citation-dedupe/page.tsx | 1 + src/app/e2e/source-polling/page.tsx | 1 + src/components/sources-panel.tsx | 27 ++ src/components/workspace-shell-layout.tsx | 18 ++ src/components/workspace-shell.tsx | 15 + src/components/workspace-switcher.test.ts | 148 +++++++++ src/components/workspace-switcher.tsx | 300 ++++++++++++++++++ src/domains/workspace/client.ts | 63 ++++ 12 files changed, 661 insertions(+), 3 deletions(-) create mode 100644 docs/adr/0009-multi-domain-workspaces-file-backed-keys.md create mode 100644 src/components/workspace-switcher.test.ts create mode 100644 src/components/workspace-switcher.tsx diff --git a/AGENTS.md b/AGENTS.md index 4589ccd..4f0be37 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,6 +48,7 @@ details when the documentation isn't enough. - **DB schema push:** `pnpm db:push --force` (dev; `--force` skips the TTY prompt because `drizzle.config.ts` sets `strict: true`). drizzle-kit does **not** load `.env.local`, so pass it inline: `DATABASE_URL=… pnpm db:push --force`. `pnpm db:migrate` for prod. - **Build:** `pnpm build` - **Docker image:** `docker build -t knowhere-notebook:dev .` then `docker run -d --name knowhere-notebook -p 3001:3000 --add-host localhost.localstack.cloud:host-gateway --env-file .env.docker knowhere-notebook:dev` (standalone, non-root, port 3000). The `--add-host` flag is required for self-hosted Knowhere with LocalStack S3 so the container can resolve `localhost.localstack.cloud` to the host gateway (used for fetching table/image chunk assets server-side). To override the chat prompt templates with your own file, bind-mount it over the built-in one (host file must be world-readable, e.g. `chmod 644`): `-v /host/path/chat-prompt-templates.json:/app/public/data/chat-prompt-templates.json:ro`. +- **Knowhere API keys file:** multiple API keys (one per document domain) live in `config/knowhere-keys.json` — `[{ "label": "domainA", "apiKey": "sk_…" }, …]`. The file is re-read per request (mtime-cached), so edits take effect with **no restart**. Bind-mount it: `-v /host/path/knowhere-keys.json:/app/config/knowhere-keys.json:ro` (host file world-readable). Without the file, `KNOWHERE_API_KEY` env remains the single-key fallback (`label: "default"`). Read via `src/integrations/knowhere-keys.ts` (server-only); the edge proxy only checks env presence for the dev-mode bypass. Never put the file in `public/`. CI runs: `lint → typecheck → test → build` on PRs to `main` and `staging`. @@ -94,6 +95,7 @@ src/ - **Eager localization:** Compatible-namespace Knowhere documents are auto-localized into workspace Source rows on every source list load (`GET /api/sources` and SSR). No user click needed. `localizeRemoteLibrarySources` pre-filters against existing DB rows to avoid redundant writes. - **SourceKind:** `"workspace" | "remote"` only. The `"demo"` variant has been removed. - **Namespace API:** `GET /api/namespaces` lists all Knowhere namespaces with document counts. `POST /api/namespaces/[namespace]/localize` bulk-localizes all documents from a specific namespace. The SDK does not expose a namespaces endpoint, so `listKnowhereNamespaces` in `src/integrations/knowhere.ts` calls `GET /v1/documents/namespaces` directly. +- **Multi-domain workspaces:** a workspace binds one user to one (keyLabel, namespace) pair — `workspaces.knowhere_key_label` picks the API key/domain, `namespace` is the Knowhere namespace under it. Unique on `(userId, keyLabel, namespace)`. The active workspace is tracked by the `notebook-ws` cookie (set by `POST /api/workspaces/activate` or `/api/workspaces`); `ensureWorkspace` reads it, falls back to the user's first workspace, then creates a legacy default (`notebook-`, null label). Legacy rows keep working unchanged. The `WorkspaceSwitcher` at the top of the sources panel lists workspaces grouped by domain, and its "New workspace…" dialog picks a domain key then a namespace fetched via `GET /api/knowhere-keys/[label]/namespaces`. ## Domain Language diff --git a/CONTEXT.md b/CONTEXT.md index 3033611..b4c2266 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -5,9 +5,24 @@ terms when naming modules, tests, and route workflows. ## Workspace -A Workspace is the Notebook-owned tenant container for a Dashboard user. It -stores the local source metadata, chat threads, and the Knowhere namespace used -for retrieval. Workspace creation is idempotent per Dashboard user. +A Workspace is the Notebook-owned tenant container that binds one user to one +document domain: it stores local source metadata, chat threads, and the pair +`(knowhereKeyLabel, namespace)` — the configured API key (domain) that +authenticates Knowhere access, and the Knowhere namespace under that domain +whose documents the workspace's sources live in. One workspace per +(user, keyLabel, namespace) tuple. The active workspace is selected by the +`notebook-ws` cookie (falls back to the user's first workspace, then a legacy +`notebook-` default). Legacy rows with a null key label use the default +key and keep working unchanged. + +## Knowhere Key Label + +A Knowhere Key Label identifies one configured Knowhere API key (a "domain"). +Keys are defined in `config/knowhere-keys.json` as `{ label, apiKey }` entries, +read server-side by `src/integrations/knowhere-keys.ts` (mtime-cached, so edits +take effect without a restart). When the file is absent, `KNOWHERE_API_KEY` env +is treated as a single key labeled `"default"`. The API never exposes full keys +to the browser — only masked labels (`sk_8aB••••GVB8`). ## Workspace Shell diff --git a/Dockerfile b/Dockerfile index d082a99..47cb9c1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,6 +35,10 @@ RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001 COPY --from=builder --chown=nextjs:nodejs /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +# Bind-mount target for config/knowhere-keys.json (see AGENTS.md). Created +# up front so `-v ...:/app/config/knowhere-keys.json:ro` works without a +# rebuild, and owned by the non-root nextjs user for the fallback file. +RUN mkdir -p /app/config && chown nextjs:nodejs /app/config USER nextjs EXPOSE 3000 CMD ["node", "server.js"] diff --git a/docs/adr/0009-multi-domain-workspaces-file-backed-keys.md b/docs/adr/0009-multi-domain-workspaces-file-backed-keys.md new file mode 100644 index 0000000..3214cff --- /dev/null +++ b/docs/adr/0009-multi-domain-workspaces-file-backed-keys.md @@ -0,0 +1,64 @@ +# ADR 0009: Multi-domain workspaces with file-backed API keys + +**Date:** 2026-08-02 + +## Status + +Accepted + +## Context + +The Notebook previously bound one user to exactly one workspace (`user_id` +unique), with a single global Knowhere API key from `KNOWHERE_API_KEY`. For +self-hosted deployments where a Knowhere instance (or dashboard) has several +users, each with their own document domains, the operator needed one Notebook +deployment that can switch between document domains quickly, without +restarting the container. + +Requirements gathered from the operator: + +1. Each API key points to a different document domain (namespace set). +2. Switching domains must be fast and require no container restart. +3. Each workspace maps to a **namespace under a domain** — not to a domain + itself (many workspaces may share one API key, one per namespace). +4. The domain switcher lives at the top of the sources panel. +5. Legacy single-workspace rows (`notebook-`, no key label) keep + working unchanged. + +## Decision + +1. **Workspace model:** `workspaces` becomes one row per + `(userId, knowhereKeyLabel, namespace)` tuple. The `user_id` and + `namespace` uniqueness constraints are dropped; a composite unique index + `(user_id, knowhere_key_label, namespace)` replaces them. A null key label + means "default key" (legacy behavior). +2. **Key source:** `config/knowhere-keys.json` — an array of + `{ label, apiKey }`. Read server-side per request with an mtime cache, so + editing the file takes effect without a restart. Falls back to + `KNOWHERE_API_KEY` env as a single `"default"` key when the file is absent. +3. **Active workspace:** the `notebook-ws` cookie holds the active workspace + id (not a secret). `ensureWorkspace` resolves it on every request: cookie + → first workspace → legacy default creation. +4. **Credential resolution:** `ensureApiKeyForWorkspace` looks up the + workspace row, resolves its `knowhereKeyLabel` from the key source, then + falls back to the default key, then the env override, then the Dashboard + JWT (production path unchanged). +5. **API:** `GET /api/knowhere-keys` (masked labels), `GET + /api/knowhere-keys/[label]/namespaces`, `POST /api/workspaces/activate`, + `POST /api/workspaces` (`{ keyLabel, namespace }`). +6. **UI:** a `WorkspaceSwitcher` at the top of the sources panel, grouped by + domain, with a "New workspace…" dialog that picks a domain key, fetches its + namespaces, and creates the workspace for the chosen namespace. + +## Consequences + +- New workspaces pick an existing Knowhere namespace (never auto-generate a + `notebook-` for multi-domain setups). +- Keys live in a host file (operator-controlled secrets, world-readable for + the container), not in Postgres. Phase 3 will move them to encrypted DB + rows. +- The Dashboard production path (JWT issuance) is untouched; the file-backed + keys only apply in dev/self-hosted mode. +- Future phases: Notebook-owned auth (Phase 2) and DB-backed encrypted keys + (Phase 3) build on this model — the workspace `(user, keyLabel, namespace)` + binding and the cookie-tracked active workspace carry forward unchanged. diff --git a/src/app/e2e/citation-dedupe/page.tsx b/src/app/e2e/citation-dedupe/page.tsx index 83d7d64..8958e7b 100644 --- a/src/app/e2e/citation-dedupe/page.tsx +++ b/src/app/e2e/citation-dedupe/page.tsx @@ -72,6 +72,7 @@ export default function CitationDedupeTestPage() { }} workspace={{ id: "workspace_playwright", + keyLabel: null, namespace: "notebook-playwright", }} sources={duplicateTitleSources} diff --git a/src/app/e2e/source-polling/page.tsx b/src/app/e2e/source-polling/page.tsx index b6e6dd1..79008c7 100644 --- a/src/app/e2e/source-polling/page.tsx +++ b/src/app/e2e/source-polling/page.tsx @@ -22,6 +22,7 @@ export default function SourcePollingTestPage() { }} workspace={{ id: "workspace_playwright", + keyLabel: null, namespace: "notebook-playwright", }} sources={[pendingSource]} diff --git a/src/components/sources-panel.tsx b/src/components/sources-panel.tsx index d97039f..47f24a1 100644 --- a/src/components/sources-panel.tsx +++ b/src/components/sources-panel.tsx @@ -20,6 +20,7 @@ import { } from "@/components/ui/alert-dialog"; import { Spinner } from "@/components/ui/spinner"; import { NamespaceDropdown } from "@/components/namespace-dropdown"; +import { WorkspaceSwitcher } from "@/components/workspace-switcher"; import { sourcePanelState } from "@/components/source-panel-state"; import { SourceRow } from "@/components/source-row"; import { SourceUploadDialog } from "@/components/source-upload-dialog"; @@ -29,6 +30,20 @@ import type { AnalyticsContext } from "@/lib/posthog"; export type SourcesPanelProps = { readonly isNarrow?: boolean; sources: SourceView[]; + activeWorkspace?: { + readonly id: string; + readonly namespace: string; + readonly keyLabel: string | null; + }; + workspaces?: readonly { + readonly id: string; + readonly namespace: string; + readonly keyLabel: string | null; + }[]; + knowhereKeyLabels?: readonly { + readonly label: string; + readonly mask: string; + }[]; onSourceUploaded?: (source: SourceView) => void; onSourcesLocalized?: (sources: readonly SourceView[]) => void; selectedSourceId?: string | null; @@ -53,6 +68,9 @@ type SourcePageState = { export function SourcesPanel({ isNarrow = false, sources = [], + activeWorkspace, + workspaces = [], + knowhereKeyLabels = [], onSourceUploaded, onSourcesLocalized, selectedSourceId = null, @@ -180,6 +198,15 @@ export function SourcesPanel({
+ {!isNarrow && workspaces.length > 0 ? ( +
+ +
+ ) : null}

Sources diff --git a/src/components/workspace-shell-layout.tsx b/src/components/workspace-shell-layout.tsx index 2a7adeb..8e091f9 100644 --- a/src/components/workspace-shell-layout.tsx +++ b/src/components/workspace-shell-layout.tsx @@ -27,6 +27,12 @@ import type { SourceView, } from "@/domains/sources/types" +type WorkspaceSwitcherWorkspace = { + readonly id: string + readonly namespace: string + readonly keyLabel: string | null +} + export type PanelId = "sources" | "chat" type DesktopPanelKey = keyof typeof workspaceShellState.minimumDesktopPanelWidths @@ -61,6 +67,12 @@ export type WorkspaceShellLayoutProps = { readonly chatThreads: readonly ChatThreadView[] readonly citationListViewRequestId: number readonly dashboardUrl?: string + readonly activeWorkspace?: WorkspaceSwitcherWorkspace + readonly workspaces?: readonly WorkspaceSwitcherWorkspace[] + readonly knowhereKeyLabels?: readonly { + readonly label: string + readonly mask: string + }[] readonly desktopPanelWidths: Readonly readonly focusedChunk: FocusedChunkState readonly hasMessages: boolean @@ -193,6 +205,9 @@ export function WorkspaceShellLayout( ("chat") @@ -192,6 +204,9 @@ function WorkspaceShellContent({ chatThreads={chatWorkflow.chatThreads} desktopPanelWidths={desktopPanelWidths} dashboardUrl={dashboardUrl} + activeWorkspace={workspace} + workspaces={workspaces ?? []} + knowhereKeyLabels={knowhereKeyLabels ?? []} citationListViewRequestId={citationFocus.citationListViewRequestId} focusedChunk={citationFocus.focusedChunk} hasMessages={hasMessages} diff --git a/src/components/workspace-switcher.test.ts b/src/components/workspace-switcher.test.ts new file mode 100644 index 0000000..357730a --- /dev/null +++ b/src/components/workspace-switcher.test.ts @@ -0,0 +1,148 @@ +// @vitest-environment jsdom +import React from "react"; +import { + cleanup, + render, + screen, + waitFor, +} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + activateWorkspace: vi.fn(), + createWorkspace: vi.fn(), + fetchKnowhereKeyNamespaces: vi.fn(), + refresh: vi.fn(), +})); + +vi.mock("@/domains/workspace/client", () => ({ + workspaceClient: { + activateWorkspace: mocks.activateWorkspace, + createWorkspace: mocks.createWorkspace, + fetchKnowhereKeyNamespaces: mocks.fetchKnowhereKeyNamespaces, + }, +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ refresh: mocks.refresh }), +})); + +import { WorkspaceSwitcher } from "./workspace-switcher"; + +const C = WorkspaceSwitcher as React.FC>; + +const workspaces = [ + { id: "ws_a1", namespace: "quarterly", keyLabel: "domainA" }, + { id: "ws_a2", namespace: "investor-decks", keyLabel: "domainA" }, + { id: "ws_b1", namespace: "lab-papers", keyLabel: "domainB" }, +]; + +const keyLabels = [ + { label: "domainA", mask: "sk_8aB••••GVB8" }, + { label: "domainB", mask: "sk_f3a••••e2" }, + { label: "domainC", mask: "sk_77c••••d1" }, +]; + +describe("WorkspaceSwitcher", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.activateWorkspace.mockResolvedValue(undefined); + mocks.createWorkspace.mockResolvedValue({ + id: "ws_c1", + namespace: "new-ns", + keyLabel: "domainC", + }); + mocks.fetchKnowhereKeyNamespaces.mockResolvedValue([ + { namespace: "adobe", documentCount: 9 }, + { namespace: "docx", documentCount: 9 }, + ]); + }); + + afterEach(() => { + cleanup(); + }); + + it("shows the active workspace label and lists workspaces grouped by domain", async () => { + const user = userEvent.setup(); + render( + React.createElement(C, { + activeWorkspace: workspaces[0], + workspaces, + knowhereKeyLabels: keyLabels, + }), + ); + + expect(screen.getByText("domainA / quarterly")).toBeTruthy(); + + await user.click(screen.getByRole("button", { name: /domainA \/ quarterly/ })); + + expect(await screen.findByText("investor-decks")).toBeTruthy(); + expect(screen.getByText("domainB")).toBeTruthy(); + expect(screen.getByText("lab-papers")).toBeTruthy(); + }); + + it("activates a workspace and refreshes the router", async () => { + const user = userEvent.setup(); + render( + React.createElement(C, { + activeWorkspace: workspaces[0], + workspaces, + knowhereKeyLabels: keyLabels, + }), + ); + + await user.click(screen.getByRole("button", { name: /domainA \/ quarterly/ })); + await user.click(await screen.findByText("lab-papers")); + + await waitFor(() => { + expect(mocks.activateWorkspace).toHaveBeenCalledWith("ws_b1"); + expect(mocks.refresh).toHaveBeenCalled(); + }); + }); + + it("creates a workspace for a key label and namespace pair", async () => { + const user = userEvent.setup(); + let resolveNamespaces: (value: { + namespace: string; + documentCount: number; + }[]) => void = () => {}; + mocks.fetchKnowhereKeyNamespaces.mockImplementation( + () => + new Promise((resolve) => { + resolveNamespaces = resolve; + }), + ); + render( + React.createElement(C, { + activeWorkspace: workspaces[0], + workspaces, + knowhereKeyLabels: keyLabels, + }), + ); + + await user.click(screen.getByRole("button", { name: /domainA \/ quarterly/ })); + await user.click(await screen.findByText("New workspace…")); + + await user.click(await screen.findByRole("button", { name: /domainC/ })); + expect(await screen.findByText("Loading namespaces…")).toBeTruthy(); + + expect(mocks.fetchKnowhereKeyNamespaces).toHaveBeenCalledWith("domainC"); + + resolveNamespaces([ + { namespace: "adobe", documentCount: 9 }, + { namespace: "docx", documentCount: 9 }, + ]); + + await user.click(await screen.findByText("adobe")); + + const createButton = screen.getByRole("button", { name: "Create workspace" }); + expect((createButton as HTMLButtonElement).disabled).toBe(false); + await user.click(createButton); + + await waitFor(() => { + expect(mocks.createWorkspace).toHaveBeenCalledWith("domainC", "adobe"); + expect(mocks.refresh).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/components/workspace-switcher.tsx b/src/components/workspace-switcher.tsx new file mode 100644 index 0000000..ccc34ef --- /dev/null +++ b/src/components/workspace-switcher.tsx @@ -0,0 +1,300 @@ +"use client"; + +import { + type ReactElement, + useMemo, + useState, +} from "react"; +import { useRouter } from "next/navigation"; +import { Boxes, Check, ChevronDown, Plus } from "lucide-react"; +import useSWR from "swr"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Spinner } from "@/components/ui/spinner"; +import { workspaceClient } from "@/domains/workspace/client"; +import type { WorkspaceView } from "@/domains/workspace/client"; + +export type WorkspaceSwitcherProps = { + readonly activeWorkspace?: WorkspaceView; + readonly knowhereKeyLabels?: readonly { + readonly label: string; + readonly mask: string; + }[]; + readonly workspaces?: readonly WorkspaceView[]; +}; + +type NewWorkspaceDialogState = { + readonly isOpen: boolean; + readonly keyLabel: string | null; + readonly namespace: string | null; +}; + +export function WorkspaceSwitcher({ + activeWorkspace, + knowhereKeyLabels = [], + workspaces = [], +}: WorkspaceSwitcherProps): ReactElement { + const router = useRouter(); + const [dialog, setDialog] = useState({ + isOpen: false, + keyLabel: null, + namespace: null, + }); + const [isActivatingId, setIsActivatingId] = useState(null); + const { data: keyNamespaces, isLoading: isLoadingKeyNamespaces } = useSWR( + dialog.keyLabel + ? ["knowhere-key-namespaces", dialog.keyLabel] + : null, + ([, label]: readonly [string, string]) => + workspaceClient.fetchKnowhereKeyNamespaces(label), + { revalidateOnFocus: false }, + ); + + const workspacesByKeyLabel = useMemo(() => { + const grouped = new Map(); + for (const workspace of workspaces) { + const keyLabel = workspace.keyLabel ?? "default"; + const group = grouped.get(keyLabel) ?? []; + group.push(workspace); + grouped.set(keyLabel, group); + } + return grouped; + }, [workspaces]); + + async function handleActivate(workspaceId: string): Promise { + if (isActivatingId !== null) return; + setIsActivatingId(workspaceId); + try { + await workspaceClient.activateWorkspace(workspaceId); + router.refresh(); + } catch { + setIsActivatingId(null); + } + } + + async function handleCreate(): Promise { + if (!dialog.keyLabel || !dialog.namespace) return; + try { + await workspaceClient.createWorkspace(dialog.keyLabel, dialog.namespace); + router.refresh(); + } catch { + setDialog((current) => ({ ...current, isOpen: false })); + } + } + + const activeLabel = activeWorkspace?.keyLabel ?? "default"; + const labelWithoutWorkspace = knowhereKeyLabels.filter( + (key) => !workspacesByKeyLabel.has(key.label), + ); + + return ( + <> + + + + + + + Workspaces + + + {workspaces.length === 0 ? ( + + No workspaces yet + + ) : ( + Array.from(workspacesByKeyLabel.entries()).map( + ([keyLabel, grouped]) => ( +
+ {keyLabel !== "default" && ( + + {keyLabel} + + )} + {grouped.map((workspace) => ( + void handleActivate(workspace.id)} + className="flex items-center justify-between gap-2 text-xs" + > + + {workspace.namespace} + + {workspace.id === activeWorkspace?.id ? ( + + ) : isActivatingId === workspace.id ? ( + + ) : null} + + ))} +
+ ), + ) + )} + + + setDialog({ isOpen: true, keyLabel: null, namespace: null }) + } + className="flex items-center gap-2 text-xs font-semibold" + > + + New workspace… + +
+
+ + { + if (!open) { + setDialog({ isOpen: false, keyLabel: null, namespace: null }); + } + }} + > + + + New workspace + + Pick a domain (API key) and a namespace under it. + + +
+
+ + Domain + +
+ {knowhereKeyLabels.length === 0 ? ( + + No API keys configured. + + ) : ( + knowhereKeyLabels.map((key) => ( + + )) + )} +
+
+ {dialog.keyLabel && ( +
+ + Namespace + + {isLoadingKeyNamespaces ? ( +
+ + Loading namespaces… +
+ ) : keyNamespaces && keyNamespaces.length > 0 ? ( +
+ {keyNamespaces.map((ns) => ( + + ))} +
+ ) : ( + + No namespaces available for this key. + + )} +
+ )} +
+ + + + +
+
+ + {labelWithoutWorkspace.length > 0 && ( +

+ {labelWithoutWorkspace.map((key) => key.label).join(", ")}: add a + workspace to browse those documents +

+ )} + + ); +} diff --git a/src/domains/workspace/client.ts b/src/domains/workspace/client.ts index ab01749..87c0220 100644 --- a/src/domains/workspace/client.ts +++ b/src/domains/workspace/client.ts @@ -97,6 +97,26 @@ type LocalizeNamespaceResponse = { message?: string } +export type KnowhereKeyLabelView = { + label: string + mask: string +} + +type KnowhereKeysResponse = { + keys?: KnowhereKeyLabelView[] +} + +export type WorkspaceView = { + id: string + namespace: string + keyLabel: string | null +} + +type CreateWorkspaceResponse = { + workspace?: WorkspaceView + message?: string +} + export const workspaceClient = { keys: workspaceClientKeys, fetchChunks, @@ -109,6 +129,10 @@ export const workspaceClient = { sendChatMessage, fetchNamespaces, localizeNamespace, + fetchKnowhereKeys, + fetchKnowhereKeyNamespaces, + activateWorkspace, + createWorkspace, archiveSource, retrySource, archiveChatThread, @@ -248,3 +272,42 @@ async function localizeNamespace(namespace: string): Promise { } return Array.isArray(response.body.sources) ? response.body.sources : [] } + +async function fetchKnowhereKeys(): Promise { + const body = await workspaceRouteClient.getJson( + "/api/knowhere-keys", + ) + return Array.isArray(body.keys) ? body.keys : [] +} + +async function fetchKnowhereKeyNamespaces( + label: string, +): Promise { + const body = await workspaceRouteClient.getJson( + `/api/knowhere-keys/${encodeURIComponent(label)}/namespaces`, + ) + return Array.isArray(body.namespaces) ? body.namespaces : [] +} + +async function activateWorkspace(workspaceId: string): Promise { + await workspaceRouteClient.postJson( + "/api/workspaces/activate", + { workspaceId }, + ) +} + +async function createWorkspace( + keyLabel: string, + namespace: string, +): Promise { + const response = await workspaceRouteClient.postJsonWithStatus< + CreateWorkspaceResponse + >("/api/workspaces", { keyLabel, namespace }) + if (response.status < 200 || response.status >= 300) { + throw new Error(response.body.message ?? "Could not create this workspace.") + } + if (!response.body.workspace) { + throw new Error("Could not create this workspace.") + } + return response.body.workspace +} From 3080358f71744a53217a777c189144f2781c891a Mon Sep 17 00:00:00 2001 From: Gordon YUEN Date: Wed, 5 Aug 2026 11:05:41 +0800 Subject: [PATCH 27/46] =?UTF-8?q?feat(auth):=20Notebook-owned=20auth=20cor?= =?UTF-8?q?e=20=E2=80=94=20users,=20account=20links,=20DB=20sessions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of the auth overhaul: - Schema: users, account_links (modular providers, passwordHash lives here), sessions (DB-backed, revocable) tables - src/lib/password.ts: @node-rs/argon2 hash/verify (Argon2id, interactive cost tuned for login) - src/infrastructure/auth/session.ts: notebook-session cookie (HttpOnly, SameSite=Lax, Secure in prod, 30-day TTL), createSession/deleteSession against the sessions table, expired-session sweep - src/infrastructure/auth/index.ts rewritten: getCurrentUser resolves the session cookie → sessions × users join; dev-mode KNOWHERE_API_KEY bootstrap short-circuit kept (P2-10); requireUser redirects to the local /login with callbackURL; extractUser accepts the new plain user shape - Proxy: cheap presence check now tests the notebook-session cookie (edge constant shared, no DB import in the edge bundle); anonymous redirect goes to local /login - Repositories: users, account-links, sessions (Drizzle + Effect) - Auth + proxy tests rewritten for the DB session flow --- package.json | 1 + pnpm-lock.yaml | 151 ++++++++ .../auth/account-links-repository.ts | 83 ++++ src/infrastructure/auth/index.test.ts | 364 +++++------------- src/infrastructure/auth/index.ts | 247 +++++------- .../auth/session-cookie-constants.ts | 6 + src/infrastructure/auth/session.ts | 89 +++++ .../auth/sessions-repository.ts | 66 ++++ src/infrastructure/auth/users-repository.ts | 64 +++ src/infrastructure/db/schema.ts | 92 +++++ src/lib/password.ts | 25 ++ src/proxy.ts | 31 +- 12 files changed, 768 insertions(+), 451 deletions(-) create mode 100644 src/infrastructure/auth/account-links-repository.ts create mode 100644 src/infrastructure/auth/session-cookie-constants.ts create mode 100644 src/infrastructure/auth/session.ts create mode 100644 src/infrastructure/auth/sessions-repository.ts create mode 100644 src/infrastructure/auth/users-repository.ts create mode 100644 src/lib/password.ts diff --git a/package.json b/package.json index c084d33..7e31b11 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@base-ui/react": "^1.6.0", "@effect/platform": "^0.96.1", "@neondatabase/serverless": "^1.1.0", + "@node-rs/argon2": "^2.0.2", "@ontos-ai/knowhere-sdk": "^2.0.0", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c3710c3..dfc19c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: '@neondatabase/serverless': specifier: ^1.1.0 version: 1.1.0 + '@node-rs/argon2': + specifier: ^2.0.2 + version: 2.0.2 '@ontos-ai/knowhere-sdk': specifier: ^2.0.0 version: 2.0.0 @@ -1424,6 +1427,93 @@ packages: resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} + '@node-rs/argon2-android-arm-eabi@2.0.2': + resolution: {integrity: sha512-DV/H8p/jt40lrao5z5g6nM9dPNPGEHL+aK6Iy/og+dbL503Uj0AHLqj1Hk9aVUSCNnsDdUEKp4TVMi0YakDYKw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@node-rs/argon2-android-arm64@2.0.2': + resolution: {integrity: sha512-1LKwskau+8O1ktKx7TbK7jx1oMOMt4YEXZOdSNIar1TQKxm6isZ0cRXgHLibPHEcNHgYRsJWDE9zvDGBB17QDg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@node-rs/argon2-darwin-arm64@2.0.2': + resolution: {integrity: sha512-3TTNL/7wbcpNju5YcqUrCgXnXUSbD7ogeAKatzBVHsbpjZQbNb1NDxDjqqrWoTt6XL3z9mJUMGwbAk7zQltHtA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@node-rs/argon2-darwin-x64@2.0.2': + resolution: {integrity: sha512-vNPfkLj5Ij5111UTiYuwgxMqE7DRbOS2y58O2DIySzSHbcnu+nipmRKg+P0doRq6eKIJStyBK8dQi5Ic8pFyDw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@node-rs/argon2-freebsd-x64@2.0.2': + resolution: {integrity: sha512-M8vQZk01qojQfCqQU0/O1j1a4zPPrz93zc9fSINY7Q/6RhQRBCYwDw7ltDCZXg5JRGlSaeS8cUXWyhPGar3cGg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@node-rs/argon2-linux-arm-gnueabihf@2.0.2': + resolution: {integrity: sha512-7EmmEPHLzcu0G2GDh30L6G48CH38roFC2dqlQJmtRCxs6no3tTE/pvgBGatTp/o2n2oyOJcfmgndVFcUpwMnww==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@node-rs/argon2-linux-arm64-gnu@2.0.2': + resolution: {integrity: sha512-6lsYh3Ftbk+HAIZ7wNuRF4SZDtxtFTfK+HYFAQQyW7Ig3LHqasqwfUKRXVSV5tJ+xTnxjqgKzvZSUJCAyIfHew==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@node-rs/argon2-linux-arm64-musl@2.0.2': + resolution: {integrity: sha512-p3YqVMNT/4DNR67tIHTYGbedYmXxW9QlFmF39SkXyEbGQwpgSf6pH457/fyXBIYznTU/smnG9EH+C1uzT5j4hA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@node-rs/argon2-linux-x64-gnu@2.0.2': + resolution: {integrity: sha512-ZM3jrHuJ0dKOhvA80gKJqBpBRmTJTFSo2+xVZR+phQcbAKRlDMSZMFDiKbSTnctkfwNFtjgDdh5g1vaEV04AvA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@node-rs/argon2-linux-x64-musl@2.0.2': + resolution: {integrity: sha512-of5uPqk7oCRF/44a89YlWTEfjsftPywyTULwuFDKyD8QtVZoonrJR6ZWvfFE/6jBT68S0okAkAzzMEdBVWdxWw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@node-rs/argon2-wasm32-wasi@2.0.2': + resolution: {integrity: sha512-U3PzLYKSQYzTERstgtHLd4ZTkOF9co57zTXT77r0cVUsleGZOrd6ut7rHzeWwoJSiHOVxxa0OhG1JVQeB7lLoQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@node-rs/argon2-win32-arm64-msvc@2.0.2': + resolution: {integrity: sha512-Eisd7/NM0m23ijrGr6xI2iMocdOuyl6gO27gfMfya4C5BODbUSP7ljKJ7LrA0teqZMdYHesRDzx36Js++/vhiQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@node-rs/argon2-win32-ia32-msvc@2.0.2': + resolution: {integrity: sha512-GsE2ezwAYwh72f9gIjbGTZOf4HxEksb5M2eCaj+Y5rGYVwAdt7C12Q2e9H5LRYxWcFvLH4m4jiSZpQQ4upnPAQ==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@node-rs/argon2-win32-x64-msvc@2.0.2': + resolution: {integrity: sha512-cJxWXanH4Ew9CfuZ4IAEiafpOBCe97bzoKowHCGk5lG/7kR4WF/eknnBlHW9m8q7t10mKq75kruPLtbSDqgRTw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@node-rs/argon2@2.0.2': + resolution: {integrity: sha512-t64wIsPEtNd4aUPuTAyeL2ubxATCBGmeluaKXEMAFk/8w6AJIVVkeLKMBpgLW6LU2t5cQxT+env/c6jxbtTQBg==} + engines: {node: '>= 10'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -6447,6 +6537,67 @@ snapshots: '@noble/hashes@1.8.0': {} + '@node-rs/argon2-android-arm-eabi@2.0.2': + optional: true + + '@node-rs/argon2-android-arm64@2.0.2': + optional: true + + '@node-rs/argon2-darwin-arm64@2.0.2': + optional: true + + '@node-rs/argon2-darwin-x64@2.0.2': + optional: true + + '@node-rs/argon2-freebsd-x64@2.0.2': + optional: true + + '@node-rs/argon2-linux-arm-gnueabihf@2.0.2': + optional: true + + '@node-rs/argon2-linux-arm64-gnu@2.0.2': + optional: true + + '@node-rs/argon2-linux-arm64-musl@2.0.2': + optional: true + + '@node-rs/argon2-linux-x64-gnu@2.0.2': + optional: true + + '@node-rs/argon2-linux-x64-musl@2.0.2': + optional: true + + '@node-rs/argon2-wasm32-wasi@2.0.2': + dependencies: + '@napi-rs/wasm-runtime': 0.2.12 + optional: true + + '@node-rs/argon2-win32-arm64-msvc@2.0.2': + optional: true + + '@node-rs/argon2-win32-ia32-msvc@2.0.2': + optional: true + + '@node-rs/argon2-win32-x64-msvc@2.0.2': + optional: true + + '@node-rs/argon2@2.0.2': + optionalDependencies: + '@node-rs/argon2-android-arm-eabi': 2.0.2 + '@node-rs/argon2-android-arm64': 2.0.2 + '@node-rs/argon2-darwin-arm64': 2.0.2 + '@node-rs/argon2-darwin-x64': 2.0.2 + '@node-rs/argon2-freebsd-x64': 2.0.2 + '@node-rs/argon2-linux-arm-gnueabihf': 2.0.2 + '@node-rs/argon2-linux-arm64-gnu': 2.0.2 + '@node-rs/argon2-linux-arm64-musl': 2.0.2 + '@node-rs/argon2-linux-x64-gnu': 2.0.2 + '@node-rs/argon2-linux-x64-musl': 2.0.2 + '@node-rs/argon2-wasm32-wasi': 2.0.2 + '@node-rs/argon2-win32-arm64-msvc': 2.0.2 + '@node-rs/argon2-win32-ia32-msvc': 2.0.2 + '@node-rs/argon2-win32-x64-msvc': 2.0.2 + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 diff --git a/src/infrastructure/auth/account-links-repository.ts b/src/infrastructure/auth/account-links-repository.ts new file mode 100644 index 0000000..d2f632e --- /dev/null +++ b/src/infrastructure/auth/account-links-repository.ts @@ -0,0 +1,83 @@ +import "server-only" + +import { and, eq } from "drizzle-orm" +import { Effect } from "effect" + +import { DbClient } from "@/infrastructure/db" +import { accountLinks, type AccountLink } from "@/infrastructure/db/schema" + +type AccountLinksRepository = { + readonly findByUserIdAndProviderEffect: ( + userId: string, + provider: string, + ) => Effect.Effect + readonly findByProviderAndProviderUserIdEffect: ( + provider: string, + providerUserId: string, + ) => Effect.Effect + readonly insertEffect: (input: { + readonly userId: string + readonly provider: string + readonly providerUserId: string | null + readonly passwordHash: string | null + }) => Effect.Effect +} + +const findByUserIdAndProviderEffect: AccountLinksRepository["findByUserIdAndProviderEffect"] = + (userId: string, provider: string) => + Effect.gen(function* () { + const db = yield* DbClient + const row = yield* Effect.promise(() => + db + .select() + .from(accountLinks) + .where( + and( + eq(accountLinks.userId, userId), + eq(accountLinks.provider, provider), + ), + ) + .limit(1), + ) + return row[0] ?? null + }) + +const findByProviderAndProviderUserIdEffect: AccountLinksRepository["findByProviderAndProviderUserIdEffect"] = + (provider: string, providerUserId: string) => + Effect.gen(function* () { + const db = yield* DbClient + const row = yield* Effect.promise(() => + db + .select() + .from(accountLinks) + .where( + and( + eq(accountLinks.provider, provider), + eq(accountLinks.providerUserId, providerUserId), + ), + ) + .limit(1), + ) + return row[0] ?? null + }) + +const insertEffect: AccountLinksRepository["insertEffect"] = (input) => + Effect.gen(function* () { + const db = yield* DbClient + const rows = yield* Effect.promise(() => + db.insert(accountLinks).values(input).returning(), + ) + const row = rows[0] + if (!row) { + return yield* Effect.die( + new Error("account_links: insert returned no row."), + ) + } + return row + }) + +export const accountLinksRepository: AccountLinksRepository = { + findByUserIdAndProviderEffect, + findByProviderAndProviderUserIdEffect, + insertEffect, +} diff --git a/src/infrastructure/auth/index.test.ts b/src/infrastructure/auth/index.test.ts index 781c80c..a6daf43 100644 --- a/src/infrastructure/auth/index.test.ts +++ b/src/infrastructure/auth/index.test.ts @@ -1,171 +1,93 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { Effect } from "effect" -const nextCacheMocks = vi.hoisted(() => ({ - cacheLife: vi.fn(), - cacheTag: vi.fn(), +const repositoryMocks = vi.hoisted(() => ({ + findByIdEffect: vi.fn(), + findUserByIdEffect: vi.fn(), + runPromise: vi.fn(), })) -vi.mock("next/cache", () => nextCacheMocks) +vi.mock("./sessions-repository", () => ({ + sessionsRepository: { + findByIdEffect: repositoryMocks.findByIdEffect, + }, +})) + +vi.mock("./users-repository", () => ({ + usersRepository: { + findByIdEffect: repositoryMocks.findUserByIdEffect, + }, +})) + +vi.mock("@/domains/workspace/database-runtime", () => ({ + databaseRuntime: { + runPromise: repositoryMocks.runPromise, + }, +})) /** - * Tests for the auth module. + * Tests for the Phase 2 auth module. * - * The scope is intentionally narrow — we assert the contract with - * Dashboard as Pi laid it out: - * - forward the incoming Cookie header verbatim - * - hit Dashboard's getCurrentUser oRPC endpoint server-side - * - treat `body.json.user === null` (or missing, or HTTP error, or - * malformed body, or network failure) as "anonymous" - * - never decode a JWT ourselves - * - * `requireUser`'s redirect behavior is not unit-tested here because - * `next/navigation`'s `redirect` throws a framework-internal error that - * is awkward to assert against in isolation; it is covered by the - * Playwright flow added in a later PR. + * The scope is intentionally narrow — we assert the Notebook-owned session + * contract: + * - KNOWHERE_API_KEY dev mode short-circuits to the development user + * - no session cookie → null (no DB roundtrip) + * - a valid session id → session row → users row → AuthUser + * - expired / missing session or user → null + * - `requireUser` does not redirect in dev mode */ -import { extractUser, sessionCookieNames } from "." - -const SESSION_PATH = "/api/orpc/users/getCurrentUser" +import { extractUser } from "." -type ParsedLogLine = { - readonly body?: unknown - readonly msg?: unknown -} - -function getHeaderValue(headers: HeadersInit | undefined, name: string): string | null { - if (headers === undefined) return null - if (headers instanceof Headers) return headers.get(name) - - const lowerName = name.toLowerCase() - if (Array.isArray(headers)) { - const pair = headers.find(([key]) => key.toLowerCase() === lowerName) - return pair?.[1] ?? null - } - - const entry = Object.entries(headers).find( - ([key]) => key.toLowerCase() === lowerName, - ) - return entry?.[1] ?? null -} - -async function readBodyText(body: BodyInit | null | undefined): Promise { - if (body === undefined || body === null) return null - if (typeof body === "string") return body - if (body instanceof Blob) return await body.text() - if (body instanceof URLSearchParams) return body.toString() - if (body instanceof ArrayBuffer) return new TextDecoder().decode(body) - if (ArrayBuffer.isView(body)) { - const bytes = new Uint8Array(body.buffer, body.byteOffset, body.byteLength) - return new TextDecoder().decode(bytes) - } - return null +const developmentUser = { + id: "knowhere-api-key-dev-user", + email: null, + name: "Knowhere API Key Development", } describe("extractUser", () => { - it("returns null when body is not an object", () => { + it("returns null when value is not an object", () => { expect(extractUser(null)).toBeNull() expect(extractUser(undefined)).toBeNull() expect(extractUser("nope")).toBeNull() expect(extractUser(42)).toBeNull() }) - it("returns null when json envelope is missing", () => { + it("returns null when id is missing or empty", () => { expect(extractUser({})).toBeNull() - expect(extractUser({ data: { user: { id: "u1" } } })).toBeNull() - }) - - it("returns null when user is missing or explicitly null", () => { - expect(extractUser({ json: {} })).toBeNull() - expect(extractUser({ json: { user: null } })).toBeNull() - }) - - it("returns null when user.id is missing or empty", () => { - expect(extractUser({ json: { user: {} } })).toBeNull() - expect(extractUser({ json: { user: { id: "" } } })).toBeNull() - expect(extractUser({ json: { user: { id: 42 } } })).toBeNull() + expect(extractUser({ id: "" })).toBeNull() + expect(extractUser({ id: 42 })).toBeNull() }) it("returns the user with id, email, and name when present", () => { - const got = extractUser({ - json: { - user: { id: "user_123", email: "a@b.com", name: "Teacher" }, - }, - }) - expect(got).toEqual({ - id: "user_123", + expect(extractUser({ id: "u1", email: "a@b.com", name: "Ada" })).toEqual({ + id: "u1", email: "a@b.com", - name: "Teacher", + name: "Ada", }) }) it("coerces missing optional fields to null", () => { - const got = extractUser({ json: { user: { id: "u1" } } }) - expect(got).toEqual({ id: "u1", email: null, name: null }) - }) - - it("tolerates extra fields without failing", () => { - const got = extractUser({ - json: { - user: { - id: "u1", - email: "x@y", - name: "N", - someFutureField: "anything", - }, - }, - meta: { traceId: "abc" }, - }) - expect(got?.id).toBe("u1") - }) -}) - -describe("sessionCookieNames", () => { - const originalEnv = process.env.SESSION_COOKIE_NAMES - afterEach(() => { - if (originalEnv === undefined) delete process.env.SESSION_COOKIE_NAMES - else process.env.SESSION_COOKIE_NAMES = originalEnv - }) - - it("defaults to the Better Auth session cookie names", () => { - delete process.env.SESSION_COOKIE_NAMES - expect(sessionCookieNames()).toEqual([ - "better-auth.session_token", - "__Secure-better-auth.session_token", - ]) - }) - - it("honors a comma-separated override from env", () => { - process.env.SESSION_COOKIE_NAMES = "my-cookie, other-cookie ,x" - expect(sessionCookieNames()).toEqual(["my-cookie", "other-cookie", "x"]) - }) - - it("falls back to defaults when the override is blank", () => { - process.env.SESSION_COOKIE_NAMES = " " - expect(sessionCookieNames()).toEqual([ - "better-auth.session_token", - "__Secure-better-auth.session_token", - ]) + expect(extractUser({ id: "u1" })).toEqual({ id: "u1", email: null, name: null }) }) }) describe("getCurrentUser", () => { - const originalFetch = globalThis.fetch - const originalOrigin = process.env.DASHBOARD_ORIGIN const originalApiKey = process.env.KNOWHERE_API_KEY beforeEach(() => { vi.resetModules() - process.env.DASHBOARD_ORIGIN = "https://dashboard.example.test" delete process.env.KNOWHERE_API_KEY + repositoryMocks.runPromise.mockReset() + repositoryMocks.findByIdEffect.mockReset() + repositoryMocks.findUserByIdEffect.mockReset() + // Run the effect for real so the mocked repository Effects are executed. + repositoryMocks.runPromise.mockImplementation((effect: Effect.Effect) => + Effect.runPromise(effect), + ) }) afterEach(() => { - globalThis.fetch = originalFetch - nextCacheMocks.cacheLife.mockClear() - nextCacheMocks.cacheTag.mockClear() - if (originalOrigin === undefined) delete process.env.DASHBOARD_ORIGIN - else process.env.DASHBOARD_ORIGIN = originalOrigin if (originalApiKey === undefined) delete process.env.KNOWHERE_API_KEY else process.env.KNOWHERE_API_KEY = originalApiKey }) @@ -178,170 +100,74 @@ describe("getCurrentUser", () => { return await import(".") } - it("returns null when no Cookie header is present (no roundtrip)", async () => { - const fetchSpy = vi.fn() - globalThis.fetch = fetchSpy + it("returns null when no Cookie header is present", async () => { const { getCurrentUser } = await loadWithCookie("") - const got = await getCurrentUser() - expect(got).toBeNull() - expect(fetchSpy).not.toHaveBeenCalled() + expect(await getCurrentUser()).toBeNull() + expect(repositoryMocks.runPromise).not.toHaveBeenCalled() }) it("returns the development user when KNOWHERE_API_KEY is configured", async () => { process.env.KNOWHERE_API_KEY = "sk_dev_key" - delete process.env.DASHBOARD_ORIGIN - const fetchSpy = vi.fn() - globalThis.fetch = fetchSpy const { getCurrentUser } = await loadWithCookie("") - const user = await getCurrentUser() - - expect(user).toEqual({ - id: "knowhere-api-key-dev-user", - email: null, - name: "Knowhere API Key Development", - }) - expect(fetchSpy).not.toHaveBeenCalled() + expect(user).toEqual(developmentUser) + expect(repositoryMocks.runPromise).not.toHaveBeenCalled() }) - it("allows requireUser without redirecting when KNOWHERE_API_KEY is configured", async () => { - process.env.KNOWHERE_API_KEY = "sk_dev_key" - delete process.env.DASHBOARD_ORIGIN - const { requireUser } = await loadWithCookie("") - - await expect(requireUser()).resolves.toEqual({ - id: "knowhere-api-key-dev-user", - email: null, - name: "Knowhere API Key Development", - }) - }) - - it("POSTs to the Dashboard oRPC endpoint with the incoming Cookie", async () => { - const expectedUrl = `https://dashboard.example.test${SESSION_PATH}` - const fetchSpy = vi.fn().mockResolvedValue( - new Response( - JSON.stringify({ json: { user: { id: "u1", email: "a@b" } } }), - { status: 200, headers: { "content-type": "application/json" } }, - ), + it("returns the user for a valid session cookie", async () => { + repositoryMocks.findByIdEffect.mockReturnValue( + Effect.succeed({ + id: "session_1", + userId: "user_1", + expiresAt: new Date(Date.now() + 100_000), + createdAt: new Date(), + }), ) - globalThis.fetch = fetchSpy - const { getCurrentUser } = await loadWithCookie( - "better-auth.session_token=abc; other=val", + repositoryMocks.findUserByIdEffect.mockReturnValue( + Effect.succeed({ + id: "user_1", + email: "ada@example.com", + name: "Ada", + emailVerifiedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + }), ) + const { getCurrentUser } = await loadWithCookie("notebook-session=session_1") const user = await getCurrentUser() - expect(user).toEqual({ id: "u1", email: "a@b", name: null }) - expect(fetchSpy).toHaveBeenCalledOnce() - const [req, init] = fetchSpy.mock.calls[0]! - const requestUrl = - req instanceof Request ? req.url - : req instanceof URL ? req.href - : typeof req === "string" ? req - : String(req) - expect(requestUrl).toBe(expectedUrl) - const requestHeaders = - req instanceof Request ? req.headers : (init as RequestInit | undefined)?.headers - expect(getHeaderValue(requestHeaders, "cookie")).toBe( - "better-auth.session_token=abc; other=val", - ) - expect(getHeaderValue(requestHeaders, "content-type")).toContain( - "application/json", - ) - expect(await readBodyText((init as RequestInit | undefined)?.body)).toBe("{}") + expect(user).toEqual({ id: "user_1", email: "ada@example.com", name: "Ada" }) }) - it("does not reuse a stale user after Dashboard invalidates the session", async () => { - const fetchSpy = vi - .fn() - .mockResolvedValueOnce( - new Response( - JSON.stringify({ json: { user: { id: "u1", email: "a@b" } } }), - { status: 200, headers: { "content-type": "application/json" } }, - ), - ) - .mockResolvedValueOnce( - new Response(JSON.stringify({ json: { user: null } }), { - status: 200, - headers: { "content-type": "application/json" }, - }), - ) - globalThis.fetch = fetchSpy - const { getCurrentUser } = await loadWithCookie( - "better-auth.session_token=abc", - ) - - await expect(getCurrentUser()).resolves.toEqual({ - id: "u1", - email: "a@b", - name: null, - }) - await expect(getCurrentUser()).resolves.toBeNull() - expect(fetchSpy).toHaveBeenCalledTimes(2) - expect(nextCacheMocks.cacheLife).not.toHaveBeenCalled() - expect(nextCacheMocks.cacheTag).not.toHaveBeenCalled() - }) - - it("returns null on Dashboard non-2xx response", async () => { - globalThis.fetch = vi - .fn() - .mockResolvedValue(new Response("oops", { status: 503 })) - const { getCurrentUser } = await loadWithCookie("session=x") - expect(await getCurrentUser()).toBeNull() - }) - - it("returns null on network error without throwing", async () => { - globalThis.fetch = vi - .fn() - .mockRejectedValue(new Error("network down")) - const { getCurrentUser } = await loadWithCookie("session=x") - await expect(getCurrentUser()).resolves.toBeNull() - }) - - it("returns null when the response body is not JSON", async () => { - globalThis.fetch = vi - .fn() - .mockResolvedValue(new Response("not-json", { status: 200 })) - const { getCurrentUser } = await loadWithCookie("session=x") + it("returns null when the session row is missing", async () => { + repositoryMocks.findByIdEffect.mockReturnValue(Effect.succeed(null)) + const { getCurrentUser } = await loadWithCookie("notebook-session=missing") expect(await getCurrentUser()).toBeNull() }) - it("returns null when body.json.user is null", async () => { - globalThis.fetch = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ json: { user: null } }), { - status: 200, - headers: { "content-type": "application/json" }, + it("returns null when the session's user row is missing", async () => { + repositoryMocks.findByIdEffect.mockReturnValue( + Effect.succeed({ + id: "session_1", + userId: "user_gone", + expiresAt: new Date(Date.now() + 100_000), + createdAt: new Date(), }), ) - const { getCurrentUser } = await loadWithCookie("session=x") + repositoryMocks.findUserByIdEffect.mockReturnValue(Effect.succeed(null)) + const { getCurrentUser } = await loadWithCookie("notebook-session=session_1") expect(await getCurrentUser()).toBeNull() }) - it("logs the JSON body when Dashboard returns an unexpected response shape", async () => { - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined) - try { - globalThis.fetch = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ user: { id: "u1" } }), { - status: 200, - headers: { "content-type": "application/json" }, - }), - ) - const { getCurrentUser } = await loadWithCookie("session=x") - - expect(await getCurrentUser()).toBeNull() - - const line = String(warnSpy.mock.calls[0]?.[0] ?? "") - const parsed = JSON.parse(line) as ParsedLogLine - expect(parsed.msg).toBe( - "dashboard: POST /api/orpc/users/getCurrentUser -> schema mismatch", - ) - expect(parsed.body).toBe(JSON.stringify({ user: { id: "u1" } })) - } finally { - warnSpy.mockRestore() - } + it("returns null on DB failure without throwing", async () => { + repositoryMocks.runPromise.mockRejectedValue(new Error("db down")) + const { getCurrentUser } = await loadWithCookie("notebook-session=session_1") + expect(await getCurrentUser()).toBeNull() }) - it("throws when DASHBOARD_ORIGIN is not configured", async () => { - delete process.env.DASHBOARD_ORIGIN - const { getCurrentUser } = await loadWithCookie("session=x") - await expect(getCurrentUser()).rejects.toThrow(/DASHBOARD_ORIGIN/) + it("allows requireUser without redirecting when KNOWHERE_API_KEY is configured", async () => { + process.env.KNOWHERE_API_KEY = "sk_dev_key" + const { requireUser } = await loadWithCookie("") + await expect(requireUser()).resolves.toEqual(developmentUser) }) }) diff --git a/src/infrastructure/auth/index.ts b/src/infrastructure/auth/index.ts index b28ac70..15b55ec 100644 --- a/src/infrastructure/auth/index.ts +++ b/src/infrastructure/auth/index.ts @@ -1,155 +1,88 @@ import "server-only" -import { cookies, headers } from "next/headers" +import { headers } from "next/headers" import { redirect } from "next/navigation" -import { Context, Effect, Either, Layer, Schedule, Schema } from "effect" -import { - FetchHttpClient, - HttpClient, - HttpClientRequest, -} from "@effect/platform" +import { Context, Effect, Layer } from "effect" + import { authURLs } from "./urls" -import { sessionCookieNames } from "./session-cookie-names" import { logger } from "@/lib/logger" import { knowhereApiKeyOverride } from "@/integrations/knowhere-api-key" -import { setEmptyJsonBody } from "@/integrations/dashboard/orpc-request" +import { sessionCookieName } from "./session" +import { databaseRuntime } from "@/domains/workspace/database-runtime" +import { sessionsRepository } from "./sessions-repository" +import { usersRepository } from "./users-repository" import { formatUnknownForLog } from "@/lib/format-log-value" -export { sessionCookieNames } +export { sessionCookieName } from "./session" +export { notebookSessionCookieName } from "./session-cookie-constants" /** - * Auth helpers for Knowhere Notebook. + * Auth helpers for Knowhere Notebook (Phase 2: Notebook-owned auth). * - * Design (per @Pi's Dashboard investigation): - * - Dashboard is the auth source of truth. Notebook never decodes or - * verifies a JWT. - * - Dashboard sets a Better Auth session cookie on `Domain=.knowhereto.ai`. - * Notebook is served from `notebook.knowhereto.ai`, so the cookie arrives - * on every request automatically. - * - Every server-side check calls `getCurrentUser`, which forwards the - * incoming Cookie header to Dashboard's oRPC session lookup - * `users.getCurrentUser` and reads `body.json.user`. - * - `user === null` (including upstream 4xx/5xx or network failure) means - * "unauthenticated" — never try to distinguish failure modes, never - * leak upstream errors to the browser. + * Design: + * - Identity is Notebook-owned: a DB-backed session row keyed by the + * `notebook-session` cookie, joined to the `users` table. + * - Dev-mode bootstrap: when `KNOWHERE_API_KEY` (or `KNOWHERE_KEYS_FILE`) + * is set, the hardcoded development user short-circuits the DB lookup + * so a fresh self-hosted deployment works before any user is created. + * - `user === null` means "unauthenticated". */ // ---- Schema --------------------------------------------------------------- -const AuthUserFromORPC = Schema.Struct({ - id: Schema.String.pipe(Schema.minLength(1)), - email: Schema.Union(Schema.String, Schema.Null).pipe( - Schema.optionalWith({ default: () => null }), - ), - name: Schema.Union(Schema.String, Schema.Null).pipe( - Schema.optionalWith({ default: () => null }), - ), -}) - -export type AuthUser = typeof AuthUserFromORPC.Type - -/** oRPC response envelope: `{ json: { user: {...} } }` */ -const oRPCEnvelope = Schema.Struct({ - json: Schema.Struct({ user: Schema.Union(AuthUserFromORPC, Schema.Null).pipe(Schema.optionalWith({ default: () => null })) }), -}) - -const DASHBOARD_SESSION_TIMEOUT_MS = 3_000 - -// ---- Effect implementation ------------------------------------------------ - -const callGetCurrentUser = (cookieHeader: string) => - Effect.gen(function* () { - const origin = process.env.DASHBOARD_ORIGIN - if (!origin) { - return yield* Effect.die( - new Error( - "DASHBOARD_ORIGIN is required. Set it to the Dashboard origin " + - "(see .env.local.example).", - ), - ) - } +export type AuthUser = { + readonly id: string + readonly email: string | null + readonly name: string | null +} - const http = yield* HttpClient.HttpClient - const url = `${origin}/api/orpc/users/getCurrentUser` - return yield* HttpClientRequest.post(url).pipe( - HttpClientRequest.setHeader("cookie", cookieHeader), - setEmptyJsonBody, - http.execute, - Effect.flatMap((response) => - Effect.gen(function* () { - const status = response.status - - if (status < 200 || status >= 300) { - const rawText = yield* Effect.either(response.text) - logger.warn( - "dashboard: POST /api/orpc/users/getCurrentUser -> non-2xx", - { status, body: Either.getOrElse(rawText, () => "").slice(0, 1000) }, - ) - return null - } - - const parsed = yield* Effect.either(response.json) - if (Either.isLeft(parsed)) { - logger.warn( - "dashboard: POST /api/orpc/users/getCurrentUser -> invalid JSON", - { status, error: String(parsed.left) }, - ) - return null - } - - const result = Schema.decodeUnknownEither(oRPCEnvelope)(parsed.right) - if (Either.isLeft(result)) { - logger.warn( - "dashboard: POST /api/orpc/users/getCurrentUser -> schema mismatch", - { status, body: formatUnknownForLog(parsed.right).slice(0, 1000) }, - ) - return null - } - - return result.right.json.user - }), - ), - Effect.timeout(DASHBOARD_SESSION_TIMEOUT_MS), - Effect.catchAll((err) => { - logger.warn( - "dashboard: POST /api/orpc/users/getCurrentUser -> failed", - { error: String(err) }, - ) - return Effect.succeed(null) +// ---- Session lookup ------------------------------------------------------- + +function findUserBySessionCookie(cookieHeader: string): Promise { + const sessionId = parseSessionIdFromCookieHeader(cookieHeader) + if (!sessionId) return Promise.resolve(null) + + return databaseRuntime + .runPromise( + Effect.gen(function* () { + const session = yield* sessionsRepository.findByIdEffect(sessionId) + if (!session) return null + const user = yield* usersRepository.findByIdEffect(session.userId) + if (!user) return null + return { + id: user.id, + email: user.email, + name: user.name ?? null, + } }), ) - }) - -export const getCurrentUserEffect = Effect.gen(function* () { - const developmentUser = knowhereApiKeyOverride.getDevelopmentUser() - if (developmentUser) return developmentUser - - const cookieHeader = (yield* Effect.promise(() => headers())).get("cookie") ?? "" - if (cookieHeader.length === 0) return null + .catch(() => null) +} - return yield* callGetCurrentUser(cookieHeader) -}) +function parseSessionIdFromCookieHeader(cookieHeader: string): string | null { + for (const part of cookieHeader.split(";")) { + const [name, ...rest] = part.trim().split("=") + if (name === sessionCookieName) { + const value = rest.join("=").trim() + return value.length > 0 ? decodeURIComponent(value) : null + } + } + return null +} // ---- Auth Service --------------------------------------------------------- -export const Auth = Context.GenericTag< - { readonly getCurrentUser: () => Effect.Effect } ->("@knowhere/Auth") +export const Auth = Context.GenericTag<{ + readonly getCurrentUser: () => Effect.Effect +}>("@knowhere/Auth") export const authLayer = Layer.effect( Auth, Effect.gen(function* () { - const http = (yield* HttpClient.HttpClient).pipe( - HttpClient.filterStatusOk, - HttpClient.retryTransient({ - schedule: Schedule.exponential(100), - times: 2, - }), - ) - const getCurrentUser = () => getCurrentUserEffect.pipe(Effect.provideService(HttpClient.HttpClient, http)) + const getCurrentUser = () => getCurrentUserEffect return { getCurrentUser } }), -).pipe(Layer.provide(FetchHttpClient.layer)) +) // ---- Public API (Promise-based, for Next.js compatibility) ---------------- @@ -164,23 +97,19 @@ export async function getCurrentUser(): Promise { const cookieHeader = (await headers()).get("cookie") ?? "" if (cookieHeader.length === 0) { - logger.info("dashboard: POST /api/orpc/users/getCurrentUser skipped (no session cookie)") + logger.info("auth: getCurrentUser skipped (no session cookie)") return null } const start = Date.now() - const user = await Effect.runPromise( - callGetCurrentUser(cookieHeader).pipe( - Effect.provide(FetchHttpClient.layer), - ), - ) + const user = await findUserBySessionCookie(cookieHeader) if (user === null) { - logger.info("dashboard: POST /api/orpc/users/getCurrentUser -> no valid session", { + logger.info("auth: getCurrentUser -> no valid session", { durationMs: Date.now() - start, }) } else { - logger.info("dashboard: POST /api/orpc/users/getCurrentUser ok", { + logger.info("auth: getCurrentUser ok", { userId: user.id, durationMs: Date.now() - start, }) @@ -189,9 +118,12 @@ export async function getCurrentUser(): Promise { return user } +export const getCurrentUserEffect: Effect.Effect = + Effect.tryPromise(() => getCurrentUser()).pipe(Effect.catchAll(() => Effect.succeed(null))) + /** - * Page / server-action guard. Redirects to the Dashboard login page with - * a `callbackURL` pointing back at the Notebook public URL when the caller + * Page / server-action guard. Redirects to the local login page with a + * `callbackURL` pointing back at the Notebook public URL when the caller * is unauthenticated. * * Throws a Next.js redirect; callers never see the anonymous branch. @@ -200,44 +132,39 @@ export async function requireUser(): Promise { const user = await getCurrentUser() if (user !== null) return user - const origin = process.env.DASHBOARD_ORIGIN - if (!origin) { - throw new Error("DASHBOARD_ORIGIN must be set.") - } - - const loginUrl = `${origin}/login` const notebookUrl = process.env.NOTEBOOK_PUBLIC_URL ?? authURLs.resolveNotebookPublicURLFromHeaders(await headers()) - redirect(authURLs.buildDashboardLoginURL(loginUrl, notebookUrl)) + redirect(`/login?callbackURL=${encodeURIComponent(notebookUrl)}`) } /** - * Cheap cookie-presence check usable from middleware (Edge runtime). - * Does not call Dashboard; used to short-circuit obvious anonymous - * requests without the round-trip. Always re-verify on the server with - * `getCurrentUser` / `requireUser` before trusting identity. + * Cheap cookie-presence check usable from the edge proxy. Does not touch + * the DB; used to short-circuit obvious anonymous requests. Always + * re-verify on the server with `getCurrentUser` / `requireUser`. */ export async function hasSessionCookie(): Promise { if (knowhereApiKeyOverride.hasApiKey()) return true - const jar = await cookies() - for (const name of sessionCookieNames()) { - if (jar.get(name) !== undefined) return true - } - return false + const jar = await import("next/headers").then(({ cookies }) => cookies()) + return jar.get(sessionCookieName) !== undefined } /** - * Parse the Dashboard oRPC response envelope `{ json: { user } }`. - * Tolerant to minor shape drift — any non-conforming response becomes `null`. + * Extract a user object from a raw lookup result. Kept for parity with the + * previous Dashboard envelope parsing; returns null for non-conforming input. */ -export function extractUser(body: unknown): AuthUser | null { - return Either.getOrElse( - Either.map( - Schema.decodeUnknownEither(oRPCEnvelope)(body), - (envelope) => envelope.json.user, - ), - () => null, - ) +export function extractUser(value: unknown): AuthUser | null { + if (typeof value !== "object" || value === null) return null + const candidate = value as Record + if (typeof candidate.id !== "string" || candidate.id.length === 0) return null + return { + id: candidate.id, + email: typeof candidate.email === "string" ? candidate.email : null, + name: typeof candidate.name === "string" ? candidate.name : null, + } +} + +export function formatAuthError(error: unknown): string { + return formatUnknownForLog(error) } diff --git a/src/infrastructure/auth/session-cookie-constants.ts b/src/infrastructure/auth/session-cookie-constants.ts new file mode 100644 index 0000000..39fb25b --- /dev/null +++ b/src/infrastructure/auth/session-cookie-constants.ts @@ -0,0 +1,6 @@ +/** + * Name of the Notebook session cookie. Edge-safe (no server-only imports) + * so the proxy can reference it without pulling the DB runtime into the + * edge bundle. + */ +export const notebookSessionCookieName = "notebook-session" diff --git a/src/infrastructure/auth/session.ts b/src/infrastructure/auth/session.ts new file mode 100644 index 0000000..56f4b33 --- /dev/null +++ b/src/infrastructure/auth/session.ts @@ -0,0 +1,89 @@ +import "server-only" + +import { cookies } from "next/headers" +import { Effect } from "effect" + +import { databaseRuntime } from "@/domains/workspace/database-runtime" +import { sessionsRepository } from "./sessions-repository" +import { notebookSessionCookieName } from "./session-cookie-constants" + +/** Cookie holding the DB session id. */ +export const sessionCookieName = notebookSessionCookieName + +/** Session lifetime: 30 days. */ +const sessionLifetimeMs = 30 * 24 * 60 * 60 * 1000 + +export type SessionDurations = { + readonly createdAt: Date + readonly expiresAt: Date +} + +function getCookieOptions(): { + readonly httpOnly: true + readonly sameSite: "lax" + readonly secure: boolean + readonly path: "/" + readonly maxAge: number +} { + return { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/", + maxAge: sessionLifetimeMs / 1000, + } +} + +/** + * Create a DB session row for the user and set the `notebook-session` + * cookie. Server Action / Route Handler only (Next 16 constraint: cookies + * cannot be set from Server Components). + */ +export async function createSession(userId: string): Promise { + const expiresAt = new Date(Date.now() + sessionLifetimeMs) + const session = await databaseRuntime.runPromise( + sessionsRepository.createEffect({ userId, expiresAt }), + ) + const jar = await cookies() + jar.set(sessionCookieName, session.id, getCookieOptions()) + return session.id +} + +/** + * Delete the session row behind the current `notebook-session` cookie and + * clear the cookie. Safe to call when no session exists. + */ +export async function deleteSession(): Promise { + const jar = await cookies() + const sessionId = jar.get(sessionCookieName)?.value + if (sessionId) { + await databaseRuntime + .runPromise(sessionsRepository.deleteByIdEffect(sessionId)) + .catch(() => {}) + } + jar.delete(sessionCookieName) +} + +/** + * Read the session id from the cookie without touching the DB. Used by the + * edge proxy for the cheap presence check. + */ +export async function getSessionIdFromCookie(): Promise { + const jar = await cookies() + return jar.get(sessionCookieName)?.value ?? null +} + +/** + * Opportunistically sweep expired sessions. Best-effort; failures are + * swallowed so login is never blocked by a cleanup hiccup. + */ +export function sweepExpiredSessions(): Promise { + return databaseRuntime + .runPromise(sessionsRepository.deleteExpiredEffect()) + .catch(() => {}) +} + +export const sessionEffect = { + create: (userId: string): Effect.Effect => + Effect.tryPromise(() => createSession(userId)), +} as const diff --git a/src/infrastructure/auth/sessions-repository.ts b/src/infrastructure/auth/sessions-repository.ts new file mode 100644 index 0000000..cf7d278 --- /dev/null +++ b/src/infrastructure/auth/sessions-repository.ts @@ -0,0 +1,66 @@ +import "server-only" + +import { and, eq, gt, lt } from "drizzle-orm" +import { Effect } from "effect" + +import { DbClient } from "@/infrastructure/db" +import { sessions, type Session } from "@/infrastructure/db/schema" + +type SessionsRepository = { + readonly findByIdEffect: ( + id: string, + ) => Effect.Effect + readonly createEffect: (input: { + readonly userId: string + readonly expiresAt: Date + }) => Effect.Effect + readonly deleteByIdEffect: (id: string) => Effect.Effect + readonly deleteExpiredEffect: () => Effect.Effect +} + +const findByIdEffect: SessionsRepository["findByIdEffect"] = (id: string) => + Effect.gen(function* () { + const db = yield* DbClient + const row = yield* Effect.promise(() => + db + .select() + .from(sessions) + .where(and(eq(sessions.id, id), gt(sessions.expiresAt, new Date()))) + .limit(1), + ) + return row[0] ?? null + }) + +const createEffect: SessionsRepository["createEffect"] = (input) => + Effect.gen(function* () { + const db = yield* DbClient + const rows = yield* Effect.promise(() => + db.insert(sessions).values(input).returning(), + ) + const row = rows[0] + if (!row) { + return yield* Effect.die(new Error("sessions: insert returned no row.")) + } + return row + }) + +const deleteByIdEffect: SessionsRepository["deleteByIdEffect"] = (id: string) => + Effect.gen(function* () { + const db = yield* DbClient + yield* Effect.promise(() => db.delete(sessions).where(eq(sessions.id, id))) + }) + +const deleteExpiredEffect: SessionsRepository["deleteExpiredEffect"] = () => + Effect.gen(function* () { + const db = yield* DbClient + yield* Effect.promise(() => + db.delete(sessions).where(lt(sessions.expiresAt, new Date())), + ) + }) + +export const sessionsRepository: SessionsRepository = { + findByIdEffect, + createEffect, + deleteByIdEffect, + deleteExpiredEffect, +} diff --git a/src/infrastructure/auth/users-repository.ts b/src/infrastructure/auth/users-repository.ts new file mode 100644 index 0000000..86d191b --- /dev/null +++ b/src/infrastructure/auth/users-repository.ts @@ -0,0 +1,64 @@ +import "server-only" + +import { and, eq, isNull } from "drizzle-orm" +import { Effect } from "effect" + +import { DbClient } from "@/infrastructure/db" +import { users, type User } from "@/infrastructure/db/schema" + +type UsersRepository = { + readonly findByEmailEffect: ( + email: string, + ) => Effect.Effect + readonly findByIdEffect: ( + id: string, + ) => Effect.Effect + readonly insertEffect: ( + input: { readonly email: string; readonly name: string | null }, + ) => Effect.Effect +} + +const findByEmailEffect: UsersRepository["findByEmailEffect"] = (email: string) => + Effect.gen(function* () { + const db = yield* DbClient + const row = yield* Effect.promise(() => + db + .select() + .from(users) + .where(and(eq(users.email, email), isNull(users.deletedAt))) + .limit(1), + ) + return row[0] ?? null + }) + +const findByIdEffect: UsersRepository["findByIdEffect"] = (id: string) => + Effect.gen(function* () { + const db = yield* DbClient + const row = yield* Effect.promise(() => + db + .select() + .from(users) + .where(and(eq(users.id, id), isNull(users.deletedAt))) + .limit(1), + ) + return row[0] ?? null + }) + +const insertEffect: UsersRepository["insertEffect"] = (input) => + Effect.gen(function* () { + const db = yield* DbClient + const rows = yield* Effect.promise(() => + db.insert(users).values(input).returning(), + ) + const row = rows[0] + if (!row) { + return yield* Effect.die(new Error("users: insert returned no row.")) + } + return row + }) + +export const usersRepository: UsersRepository = { + findByEmailEffect, + findByIdEffect, + insertEffect, +} diff --git a/src/infrastructure/db/schema.ts b/src/infrastructure/db/schema.ts index 1c5b5b1..7836ec7 100644 --- a/src/infrastructure/db/schema.ts +++ b/src/infrastructure/db/schema.ts @@ -237,3 +237,95 @@ export const chatMessages = pgTable( export type ChatMessage = typeof chatMessages.$inferSelect; export type NewChatMessage = typeof chatMessages.$inferInsert; + +/** + * Notebook-owned users. Created by the admin CLI (scripts/create-user.ts) + * in Phase 2; OAuth/SSO links attach via `account_links`. + * + * `email` is unique and serves as the login handle. `email_verified_at` + * is set once email verification exists (deferred; null for now). + */ +export const users = pgTable( + "users", + { + id: uuid("id").primaryKey().defaultRandom(), + email: text("email").notNull().unique(), + name: text("name"), + emailVerifiedAt: timestamp("email_verified_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + deletedAt: timestamp("deleted_at", { withTimezone: true }), + }, + (t) => [index("users_email_idx").on(t.email)], +); + +export type User = typeof users.$inferSelect; +export type NewUser = typeof users.$inferInsert; + +/** + * Credential links for modular auth providers. + * + * One row per (user, provider) pair — a user can sign in with password + * AND Google/GitHub later. `password_hash` lives here (only for the + * "password" provider), keeping OAuth-only users hash-free. + */ +export const accountLinks = pgTable( + "account_links", + { + id: uuid("id").primaryKey().defaultRandom(), + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + provider: text("provider").notNull(), + providerUserId: text("provider_user_id"), + passwordHash: text("password_hash"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => [ + uniqueIndex("account_links_user_provider_idx").on(t.userId, t.provider), + uniqueIndex("account_links_provider_provider_user_idx").on( + t.provider, + t.providerUserId, + ), + ], +); + +export type AccountLink = typeof accountLinks.$inferSelect; +export type NewAccountLink = typeof accountLinks.$inferInsert; + +/** + * DB-backed sessions: one row per active login, revocable server-side. + * + * The `notebook-session` cookie holds the session id; `getCurrentUser` + * joins this table to `users` on every request. Expired rows are ignored + * (and swept opportunistically). + */ +export const sessions = pgTable( + "sessions", + { + id: uuid("id").primaryKey().defaultRandom(), + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => [ + index("sessions_user_id_idx").on(t.userId), + index("sessions_expires_at_idx").on(t.expiresAt), + ], +); + +export type Session = typeof sessions.$inferSelect; +export type NewSession = typeof sessions.$inferInsert; diff --git a/src/lib/password.ts b/src/lib/password.ts new file mode 100644 index 0000000..4496653 --- /dev/null +++ b/src/lib/password.ts @@ -0,0 +1,25 @@ +import "server-only" + +import { hash, verify } from "@node-rs/argon2" + +/** Argon2id defaults tuned for interactive login (≈1s on modern hardware). */ +const passwordHashOptions = { + memoryCost: 19456, // 19 MiB + timeCost: 2, + parallelism: 1, +} as const + +export async function hashPassword(password: string): Promise { + return hash(password, passwordHashOptions) +} + +export async function verifyPassword( + password: string, + passwordHash: string, +): Promise { + try { + return await verify(password, passwordHash, passwordHashOptions) + } catch { + return false + } +} diff --git a/src/proxy.ts b/src/proxy.ts index 977b7de..bba0fc4 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,6 +1,5 @@ import { NextResponse, type NextRequest } from "next/server" -import { authURLs } from "@/infrastructure/auth/urls" -import { sessionCookieNames } from "@/infrastructure/auth/session-cookie-names" +import { notebookSessionCookieName } from "@/infrastructure/auth/session-cookie-constants" import { knowhereApiKeyOverride } from "@/integrations/knowhere-api-key" /** @@ -8,18 +7,18 @@ import { knowhereApiKeyOverride } from "@/integrations/knowhere-api-key" * * Purpose: cheap short-circuit for obviously-anonymous requests to * protected routes. If no session cookie is present, redirect to the - * Dashboard login page without making any DB or upstream calls. + * local login page without making any DB or upstream calls. * * This is NOT the authoritative auth check. A present cookie is never - * trusted here — the real verification happens in `src/infrastructure/auth`. - * via the Dashboard oRPC lookup. The proxy only catches the easy case - * where there's nothing to verify. + * trusted here — the real verification happens in `src/infrastructure/auth` + * via the DB session lookup. The proxy only catches the easy case where + * there's nothing to verify. */ /** * Routes that stay accessible without a session. Everything else under - * `/` is considered app-protected and will redirect to Dashboard login - * when no cookie is present. + * `/` is considered app-protected and will redirect to login when no + * cookie is present. */ const PUBLIC_PATHS: readonly string[] = [ "/", @@ -44,21 +43,9 @@ export function proxy(req: NextRequest): NextResponse { if (isPublicPath(req)) return NextResponse.next() - for (const name of sessionCookieNames()) { - if (req.cookies.get(name)) return NextResponse.next() - } + if (req.cookies.get(notebookSessionCookieName)) return NextResponse.next() - const origin = process.env.DASHBOARD_ORIGIN - if (!origin) { - return NextResponse.redirect(new URL("/login", req.url)) - } - const loginUrl = `${origin}/login` - - const notebookUrl = - process.env.NOTEBOOK_PUBLIC_URL ?? new URL(req.url).origin - return NextResponse.redirect( - authURLs.buildDashboardLoginURL(loginUrl, notebookUrl), - ) + return NextResponse.redirect(new URL("/login", req.url)) } export const config = { From 3604416a424e51bf7be212a2000e05493033e086 Mon Sep 17 00:00:00 2001 From: Gordon YUEN Date: Wed, 5 Aug 2026 11:13:15 +0800 Subject: [PATCH 28/46] feat(auth): login page, logout, admin user CLI; drop Dashboard UI link - src/app/login: real email+password form (Server Action loginAction verifies via account_links passwordHash with argon2, creates a DB session, redirects to /); login test updated - src/app/auth/logout: logoutAction deletes the session row + cookie - TopNav: replaces the 'Open Dashboard' link with a Sign out button (form posting the logout action); removes the dashboardUrl prop - initial-state: removes resolveDashboardUrl/dashboardUrl; shell + layout drop the prop plumbing - posthog: removes the notebook_dashboard_link_clicked tracker - scripts/create-user.ts: admin-provisioned user CLI (email, password, --name) with argon2 hashing + users + account_links(password) insert; tsconfig.scripts.json maps server-only to the test stub for tsx runs - shadcn label primitive added for the login form --- package.json | 1 + pnpm-lock.yaml | 3 + scripts/create-user.ts | 78 +++++++++++++++++++++ src/app/auth/logout/actions.ts | 10 +++ src/app/login/actions.ts | 65 +++++++++++++++++ src/app/login/page.test.ts | 33 ++------- src/app/login/page.tsx | 74 ++++++++++++------- src/components/top-nav.test.ts | 41 +++++------ src/components/top-nav.tsx | 49 +++++-------- src/components/ui/label.tsx | 20 ++++++ src/components/workspace-shell-layout.tsx | 2 - src/components/workspace-shell.tsx | 3 - src/domains/workspace/initial-state.test.ts | 17 +++-- src/domains/workspace/initial-state.ts | 7 -- src/lib/posthog.ts | 16 ----- tsconfig.scripts.json | 10 +++ 16 files changed, 286 insertions(+), 143 deletions(-) create mode 100644 scripts/create-user.ts create mode 100644 src/app/auth/logout/actions.ts create mode 100644 src/app/login/actions.ts create mode 100644 src/components/ui/label.tsx create mode 100644 tsconfig.scripts.json diff --git a/package.json b/package.json index 7e31b11..2f28e84 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "@types/react-dom": "^19", "@vitejs/plugin-react": "^6.0.1", "babel-plugin-react-compiler": "^1.0.0", + "dotenv": "^17.4.2", "drizzle-kit": "^0.31.10", "eslint": "^9", "eslint-config-next": "16.2.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dfc19c0..7cc8761 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -183,6 +183,9 @@ importers: babel-plugin-react-compiler: specifier: ^1.0.0 version: 1.0.0 + dotenv: + specifier: ^17.4.2 + version: 17.4.2 drizzle-kit: specifier: ^0.31.10 version: 0.31.10 diff --git a/scripts/create-user.ts b/scripts/create-user.ts new file mode 100644 index 0000000..77eadc0 --- /dev/null +++ b/scripts/create-user.ts @@ -0,0 +1,78 @@ +import "dotenv/config" +import { config as loadEnv } from "dotenv" + +loadEnv({ path: ".env.local" }) + +import { Effect } from "effect" + +import { databaseRuntime } from "@/domains/workspace/database-runtime" +import { usersRepository } from "@/infrastructure/auth/users-repository" +import { accountLinksRepository } from "@/infrastructure/auth/account-links-repository" +import { hashPassword } from "@/lib/password" + +/** + * Admin-provisioned user creation (no public signup in Phase 2). + * + * Usage: + * pnpm exec tsx scripts/create-user.ts [--name "Full Name"] + * + * Requires DATABASE_URL in the environment (dotenv loads .env.local). + */ +async function main(): Promise { + const [emailArg, passwordArg] = process.argv.slice(2) + const name = extractName(process.argv.slice(2)) + + if (!emailArg || !passwordArg) { + console.error( + "Usage: pnpm exec tsx scripts/create-user.ts [--name \"Full Name\"]", + ) + process.exit(1) + } + + const email = emailArg.trim().toLowerCase() + if (!email.includes("@")) { + console.error(`Invalid email: ${email}`) + process.exit(1) + } + if (passwordArg.length < 8) { + console.error("Password must be at least 8 characters.") + process.exit(1) + } + + const passwordHash = await hashPassword(passwordArg) + const user = await databaseRuntime.runPromise( + Effect.gen(function* () { + const existing = yield* usersRepository.findByEmailEffect(email) + if (existing) { + throw new Error(`User with email ${email} already exists.`) + } + + const created = yield* usersRepository.insertEffect({ + email, + name: name ?? null, + }) + yield* accountLinksRepository.insertEffect({ + userId: created.id, + provider: "password", + providerUserId: null, + passwordHash, + }) + return created + }), + ) + + console.log(`Created user ${user.email} (${user.id}).`) + process.exit(0) +} + +function extractName(args: readonly string[]): string | null { + const index = args.indexOf("--name") + if (index === -1) return null + const value = args[index + 1] + return value && value.trim().length > 0 ? value.trim() : null +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)) + process.exit(1) +}) diff --git a/src/app/auth/logout/actions.ts b/src/app/auth/logout/actions.ts new file mode 100644 index 0000000..f564873 --- /dev/null +++ b/src/app/auth/logout/actions.ts @@ -0,0 +1,10 @@ +"use server" + +import { redirect } from "next/navigation" + +import { deleteSession } from "@/infrastructure/auth/session" + +export async function logoutAction(): Promise { + await deleteSession() + redirect("/login") +} diff --git a/src/app/login/actions.ts b/src/app/login/actions.ts new file mode 100644 index 0000000..7478d06 --- /dev/null +++ b/src/app/login/actions.ts @@ -0,0 +1,65 @@ +"use server" + +import { redirect } from "next/navigation" +import { Effect } from "effect" + +import { databaseRuntime } from "@/domains/workspace/database-runtime" +import { usersRepository } from "@/infrastructure/auth/users-repository" +import { accountLinksRepository } from "@/infrastructure/auth/account-links-repository" +import { createSession } from "@/infrastructure/auth/session" +import { verifyPassword } from "@/lib/password" + +export type LoginActionState = { + readonly error: string | null +} + +export async function loginAction( + _previousState: LoginActionState, + formData: FormData, +): Promise { + const email = String(formData.get("email") ?? "").trim().toLowerCase() + const password = String(formData.get("password") ?? "") + + if (!email || !password) { + return { error: "Enter your email and password." } + } + + const user = await databaseRuntime + .runPromise( + Effect.gen(function* () { + const user = yield* usersRepository.findByEmailEffect(email) + if (!user) return null + + const link = yield* accountLinksRepository.findByUserIdAndProviderEffect( + user.id, + "password", + ) + if (!link?.passwordHash) return null + + return user + }), + ) + .catch(() => null) + + if (!user) { + return { error: "Incorrect email or password." } + } + + const link = await databaseRuntime + .runPromise( + accountLinksRepository.findByUserIdAndProviderEffect(user.id, "password"), + ) + .catch(() => null) + + if (!link?.passwordHash) { + return { error: "Incorrect email or password." } + } + + const ok = await verifyPassword(password, link.passwordHash) + if (!ok) { + return { error: "Incorrect email or password." } + } + + await createSession(user.id) + redirect("/") +} diff --git a/src/app/login/page.test.ts b/src/app/login/page.test.ts index 906bc21..8f442ba 100644 --- a/src/app/login/page.test.ts +++ b/src/app/login/page.test.ts @@ -9,46 +9,27 @@ vi.mock("next/server", () => ({ import { LoginContent } from "./page"; describe("LoginPage", () => { - const originalDashboardOrigin = process.env.DASHBOARD_ORIGIN; - const originalNotebookPublicURL = process.env.NOTEBOOK_PUBLIC_URL; - beforeEach(() => { - process.env.DASHBOARD_ORIGIN = "http://localhost:3000"; - process.env.NOTEBOOK_PUBLIC_URL = "http://localhost:3001"; + vi.resetModules(); }); afterEach(() => { cleanup(); - - if (originalDashboardOrigin === undefined) { - delete process.env.DASHBOARD_ORIGIN; - } else { - process.env.DASHBOARD_ORIGIN = originalDashboardOrigin; - } - - if (originalNotebookPublicURL === undefined) { - delete process.env.NOTEBOOK_PUBLIC_URL; - } else { - process.env.NOTEBOOK_PUBLIC_URL = originalNotebookPublicURL; - } }); - it("links directly to Dashboard login with the Notebook callback URL", async () => { + it("renders a local email + password form", async () => { render(await LoginContent()); - const link = screen.getByRole("link", { name: "Sign in" }); - - expect(link.getAttribute("href")).toBe( - "http://localhost:3000/login?callbackURL=http%3A%2F%2Flocalhost%3A3001", - ); - expect(screen.queryByRole("button", { name: "Sign in" })).toBeNull(); + expect(screen.getByLabelText("Email")).toBeTruthy(); + expect(screen.getByLabelText("Password")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Sign in" })).toBeTruthy(); + expect(screen.getByText("Sign in with your Notebook account.")).toBeTruthy(); }); it("uses account language instead of implementation details", async () => { const { container } = render(await LoginContent()); - expect(screen.getByRole("link", { name: "Sign in" })).toBeTruthy(); - expect(screen.getByText("Use your Knowhere account to continue.")).toBeTruthy(); expect(container.textContent).not.toMatch(/dashboard/i); + expect(container.textContent).not.toMatch(/better.auth/i); }); }); diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index c802af3..5f07263 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,10 +1,12 @@ import { Suspense } from "react" -import Link from "next/link"; +import { useActionState } from "react"; import { NotebookLogoMark } from "@/components/notebook-logo-mark"; -import { headers } from "next/headers"; import { Card, CardContent } from "@/components/ui/card"; -import { authURLs } from "@/infrastructure/auth/urls"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { connection } from "next/server"; +import { loginAction, type LoginActionState } from "./actions"; export default function LoginPage() { return ( @@ -14,15 +16,46 @@ export default function LoginPage() { ) } +const initialState: LoginActionState = { error: null }; + +function LoginForm() { + const [state, formAction, isPending] = useActionState(loginAction, initialState); + + return ( +
+
+ + +
+
+ + +
+ {state.error ? ( +

{state.error}

+ ) : null} + +
+ ); +} + export async function LoginContent() { await connection() - const notebookPublicURL = - process.env.NOTEBOOK_PUBLIC_URL ?? - authURLs.resolveNotebookPublicURLFromHeaders(await headers()); - const loginHref = authURLs.buildDashboardLoginURL( - `${requireEnv("DASHBOARD_ORIGIN")}/login`, - notebookPublicURL, - ); return (
@@ -31,26 +64,17 @@ export async function LoginContent() {
-

+

Knowhere Notebook

- - Sign in - -

- Use your Knowhere account to continue. +

+ Sign in with your Notebook account.

+
+ +
); } - -function requireEnv(name: string): string { - const value = process.env[name]; - if (!value) throw new Error(`${name} must be set.`); - return value; -} diff --git a/src/components/top-nav.test.ts b/src/components/top-nav.test.ts index e07c634..47171c0 100644 --- a/src/components/top-nav.test.ts +++ b/src/components/top-nav.test.ts @@ -1,15 +1,10 @@ // @vitest-environment jsdom import { cleanup, render, screen } from "@testing-library/react" -import userEvent from "@testing-library/user-event" import { createElement } from "react" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" -const mocks = vi.hoisted(() => ({ - trackNotebookDashboardLinkClicked: vi.fn(), -})) - -vi.mock("@/lib/posthog", () => ({ - trackNotebookDashboardLinkClicked: mocks.trackNotebookDashboardLinkClicked, +vi.mock("@/app/auth/logout/actions", () => ({ + logoutAction: vi.fn(), })) import { ThemeProvider } from "@/components/theme-provider" @@ -35,10 +30,10 @@ describe("TopNav", () => { vi.unstubAllGlobals() }) - it("links to the configured Dashboard origin", async () => { - const user = userEvent.setup() + it("shows the user name and a sign-out button when a user is present", async () => { const topNavProps: TopNavProps = { - dashboardUrl: "https://dashboard.example.test", + userInitials: "GD", + userName: "Gordon", } render( @@ -49,23 +44,19 @@ describe("TopNav", () => { ), ) - const link = screen.getByRole("link", { name: "Open Dashboard" }) + expect(screen.getByText("Gordon")).toBeTruthy() + expect(screen.getByRole("button", { name: "Sign out" })).toBeTruthy() + }) - expect(link.getAttribute("href")).toBe("https://dashboard.example.test") - await user.click(link) - expect(mocks.trackNotebookDashboardLinkClicked).toHaveBeenCalledWith( - { - context: undefined, - targetUrl: "https://dashboard.example.test", - fromPage: "/", - hasSources: false, - hasChats: false, - }, + it("does not show sign-out when no user is present", () => { + render( + createElement( + ThemeProvider, + { attribute: "class" }, + createElement(TopNav, {}), + ), ) - await user.click(screen.getByRole("button", { name: "Toggle theme" })) - expect(screen.getByRole("menuitem", { name: "Light" })).toBeTruthy() - expect(screen.getByRole("menuitem", { name: "Dark" })).toBeTruthy() - expect(screen.getByRole("menuitem", { name: "System" })).toBeTruthy() + expect(screen.queryByRole("button", { name: "Sign out" })).toBeNull() }) }) diff --git a/src/components/top-nav.tsx b/src/components/top-nav.tsx index 5c32df8..209133e 100644 --- a/src/components/top-nav.tsx +++ b/src/components/top-nav.tsx @@ -1,16 +1,12 @@ import { NotebookLogoMark } from "@/components/notebook-logo-mark"; import { Separator } from "@/components/ui/separator"; import { ThemeToggle } from "@/components/theme-toggle"; -import { - trackNotebookDashboardLinkClicked, - type AnalyticsContext, -} from "@/lib/posthog"; -import { ExternalLink } from "lucide-react"; +import { LogOut } from "lucide-react"; import type { ReactElement } from "react"; +import { logoutAction } from "@/app/auth/logout/actions"; export type TopNavProps = { - dashboardUrl?: string | null; - analyticsContext?: AnalyticsContext; + analyticsContext?: unknown; hasChats?: boolean; hasSources?: boolean; userInitials?: string; @@ -20,10 +16,9 @@ export type TopNavProps = { }; export function TopNav({ - dashboardUrl, - analyticsContext, - hasChats = false, - hasSources = false, + analyticsContext: _analyticsContext, + hasChats: _hasChats = false, + hasSources: _hasSources = false, userInitials, userName, userTierLabel, @@ -45,28 +40,6 @@ export function TopNav({

diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx new file mode 100644 index 0000000..74da65c --- /dev/null +++ b/src/components/ui/label.tsx @@ -0,0 +1,20 @@ +"use client" + +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Label({ className, ...props }: React.ComponentProps<"label">) { + return ( +