diff --git a/.env.local.example b/.env.local.example index 272d031..f75dd2a 100644 --- a/.env.local.example +++ b/.env.local.example @@ -2,6 +2,11 @@ # Use staging when validating staging keys. # KNOWHERE_BASE_URL=https://api-staging.knowhereto.ai +# Optional development override. When set, Notebook skips Dashboard session +# auth and Dashboard-issued JWT creation, then calls Knowhere directly with +# this key. Leave unset for production and Dashboard-authenticated staging. +# KNOWHERE_API_KEY=sk_your_development_key_here + # --- Chat provider (server-side only) --- # Vercel AI Gateway key; AI SDK picks it up automatically AI_GATEWAY_API_KEY=vck_your_key_here diff --git a/README.md b/README.md index a0111b2..5e4e239 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ 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) + - `KNOWHERE_API_KEY` — optional development override that skips Dashboard auth and calls Knowhere directly 3. Install dependencies and run: ```bash @@ -42,6 +43,12 @@ Notebook treats Dashboard as the auth source of truth. Server-side auth calls forward the incoming session cookie to Dashboard oRPC endpoints, including `/api/orpc/users/getCurrentUser` and `/api/orpc/users/issueServiceJwt`. +For local development, setting server-side `KNOWHERE_API_KEY` switches Notebook +into API-key mode. In that mode the app uses a deterministic local development +user, skips Dashboard redirects and JWT issuance, and passes the configured key +directly to the Knowhere SDK. Leave it unset for production and normal +Dashboard-authenticated staging flows. + Dashboard chooses its oRPC handler by request shape and `Content-Type`. When using Effect's `HttpClientRequest.bodyText`, pass `"application/json"` as the body content type. Setting the header before diff --git a/drizzle.config.ts b/drizzle.config.ts index 7148eb7..03d9850 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -9,7 +9,7 @@ import { defineConfig } from "drizzle-kit"; * pnpm db:migrate # apply migrations to DATABASE_URL (prod deploy) */ export default defineConfig({ - schema: "./src/lib/schema.ts", + schema: "./src/infrastructure/db/schema.ts", out: "./drizzle", dialect: "postgresql", dbCredentials: { diff --git a/drizzle/0006_api_owned_demos.sql b/drizzle/0006_api_owned_demos.sql new file mode 100644 index 0000000..49ab642 --- /dev/null +++ b/drizzle/0006_api_owned_demos.sql @@ -0,0 +1,15 @@ +CREATE TABLE "demo_source_visibilities" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "workspace_id" uuid NOT NULL, + "demo_source_id" text NOT NULL, + "hidden_at" timestamp with time zone, + "deleted_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "demo_source_visibilities" ADD CONSTRAINT "demo_source_visibilities_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action; +--> statement-breakpoint +CREATE UNIQUE INDEX "demo_source_visibilities_workspace_source_idx" ON "demo_source_visibilities" USING btree ("workspace_id","demo_source_id"); +--> statement-breakpoint +CREATE INDEX "demo_source_visibilities_workspace_idx" ON "demo_source_visibilities" USING btree ("workspace_id"); diff --git a/drizzle/0007_normalize_legacy_demo_sources.sql b/drizzle/0007_normalize_legacy_demo_sources.sql new file mode 100644 index 0000000..49a6ace --- /dev/null +++ b/drizzle/0007_normalize_legacy_demo_sources.sql @@ -0,0 +1,50 @@ +INSERT INTO "demo_source_visibilities" ( + "workspace_id", + "demo_source_id", + "hidden_at", + "deleted_at", + "created_at", + "updated_at" +) +SELECT + "workspace_id", + "demo_key", + "deleted_at", + "deleted_at", + now(), + now() +FROM "sources" +WHERE + "demo_key" IS NOT NULL + AND "deleted_at" IS NOT NULL +ON CONFLICT ("workspace_id", "demo_source_id") DO UPDATE +SET + "hidden_at" = EXCLUDED."hidden_at", + "deleted_at" = EXCLUDED."deleted_at", + "updated_at" = now(); + +UPDATE "sources" +SET + "deleted_at" = now(), + "updated_at" = now() +WHERE + "demo_key" IS NOT NULL + AND "deleted_at" IS NULL + AND "knowhere_job_id" IS NULL + AND ( + "knowhere_document_id" IS NULL + OR "knowhere_document_id" LIKE 'demo-doc-%' + ); + +UPDATE "sources" +SET + "original_blob_pathname" = NULL, + "original_blob_url" = '/api/demo-sources/' || "demo_key" || '/original', + "updated_at" = now() +WHERE + "demo_key" IS NOT NULL + AND "deleted_at" IS NULL + AND ( + "original_blob_url" IS NULL + OR "original_blob_url" <> '/api/demo-sources/' || "demo_key" || '/original' + ); diff --git a/drizzle/meta/0006_snapshot.json b/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..3225762 --- /dev/null +++ b/drizzle/meta/0006_snapshot.json @@ -0,0 +1,688 @@ +{ + "id": "88168f0e-0623-47c2-b99d-9044ae4ade07", + "prevId": "f43a05d5-4baa-4e0a-81c3-6aeaeec48aa7", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.chat_messages": { + "name": "chat_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "thread_id": { + "name": "thread_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "citations": { + "name": "citations", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_messages_thread_created_idx": { + "name": "chat_messages_thread_created_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_messages_thread_id_chat_threads_id_fk": { + "name": "chat_messages_thread_id_chat_threads_id_fk", + "tableFrom": "chat_messages", + "tableTo": "chat_threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_threads": { + "name": "chat_threads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "demo_key": { + "name": "demo_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "chat_threads_workspace_updated_idx": { + "name": "chat_threads_workspace_updated_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "deleted_at IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_threads_workspace_demo_key_idx": { + "name": "chat_threads_workspace_demo_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "demo_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_threads_workspace_id_workspaces_id_fk": { + "name": "chat_threads_workspace_id_workspaces_id_fk", + "tableFrom": "chat_threads", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.source_parse_results": { + "name": "source_parse_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source_id": { + "name": "source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "result_blob_url": { + "name": "result_blob_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "asset_urls": { + "name": "asset_urls", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "source_parse_results_source_id_idx": { + "name": "source_parse_results_source_id_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "source_parse_results_source_id_sources_id_fk": { + "name": "source_parse_results_source_id_sources_id_fk", + "tableFrom": "source_parse_results", + "tableTo": "sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "source_parse_results_source_id_unique": { + "name": "source_parse_results_source_id_unique", + "nullsNotDistinct": false, + "columns": [ + "source_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sources": { + "name": "sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "knowhere_job_id": { + "name": "knowhere_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "knowhere_document_id": { + "name": "knowhere_document_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "staged_blob_pathname": { + "name": "staged_blob_pathname", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "staged_blob_url": { + "name": "staged_blob_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_blob_pathname": { + "name": "original_blob_pathname", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_blob_url": { + "name": "original_blob_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "demo_key": { + "name": "demo_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sources_workspace_created_idx": { + "name": "sources_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "deleted_at IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "sources_workspace_status_idx": { + "name": "sources_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sources_workspace_demo_key_idx": { + "name": "sources_workspace_demo_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "demo_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sources_workspace_id_workspaces_id_fk": { + "name": "sources_workspace_id_workspaces_id_fk", + "tableFrom": "sources", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspaces_user_id_idx": { + "name": "workspaces_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspaces_user_id_unique": { + "name": "workspaces_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + }, + "workspaces_namespace_unique": { + "name": "workspaces_namespace_unique", + "nullsNotDistinct": false, + "columns": [ + "namespace" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.demo_source_visibilities": { + "name": "demo_source_visibilities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "demo_source_id": { + "name": "demo_source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hidden_at": { + "name": "hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "demo_source_visibilities_workspace_source_idx": { + "name": "demo_source_visibilities_workspace_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "demo_source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "demo_source_visibilities_workspace_idx": { + "name": "demo_source_visibilities_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "demo_source_visibilities_workspace_id_workspaces_id_fk": { + "name": "demo_source_visibilities_workspace_id_workspaces_id_fk", + "tableFrom": "demo_source_visibilities", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/0007_snapshot.json b/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000..63c2b1f --- /dev/null +++ b/drizzle/meta/0007_snapshot.json @@ -0,0 +1,688 @@ +{ + "id": "a3d7f7f1-7afe-48ab-ae22-15c6dfbab98d", + "prevId": "88168f0e-0623-47c2-b99d-9044ae4ade07", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.chat_messages": { + "name": "chat_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "thread_id": { + "name": "thread_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "citations": { + "name": "citations", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_messages_thread_created_idx": { + "name": "chat_messages_thread_created_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_messages_thread_id_chat_threads_id_fk": { + "name": "chat_messages_thread_id_chat_threads_id_fk", + "tableFrom": "chat_messages", + "tableTo": "chat_threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_threads": { + "name": "chat_threads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "demo_key": { + "name": "demo_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "chat_threads_workspace_updated_idx": { + "name": "chat_threads_workspace_updated_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "deleted_at IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_threads_workspace_demo_key_idx": { + "name": "chat_threads_workspace_demo_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "demo_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_threads_workspace_id_workspaces_id_fk": { + "name": "chat_threads_workspace_id_workspaces_id_fk", + "tableFrom": "chat_threads", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.source_parse_results": { + "name": "source_parse_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source_id": { + "name": "source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "result_blob_url": { + "name": "result_blob_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "asset_urls": { + "name": "asset_urls", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "source_parse_results_source_id_idx": { + "name": "source_parse_results_source_id_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "source_parse_results_source_id_sources_id_fk": { + "name": "source_parse_results_source_id_sources_id_fk", + "tableFrom": "source_parse_results", + "tableTo": "sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "source_parse_results_source_id_unique": { + "name": "source_parse_results_source_id_unique", + "nullsNotDistinct": false, + "columns": [ + "source_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sources": { + "name": "sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "knowhere_job_id": { + "name": "knowhere_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "knowhere_document_id": { + "name": "knowhere_document_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "staged_blob_pathname": { + "name": "staged_blob_pathname", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "staged_blob_url": { + "name": "staged_blob_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_blob_pathname": { + "name": "original_blob_pathname", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_blob_url": { + "name": "original_blob_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "demo_key": { + "name": "demo_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sources_workspace_created_idx": { + "name": "sources_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "deleted_at IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "sources_workspace_status_idx": { + "name": "sources_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sources_workspace_demo_key_idx": { + "name": "sources_workspace_demo_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "demo_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sources_workspace_id_workspaces_id_fk": { + "name": "sources_workspace_id_workspaces_id_fk", + "tableFrom": "sources", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspaces_user_id_idx": { + "name": "workspaces_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspaces_user_id_unique": { + "name": "workspaces_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + }, + "workspaces_namespace_unique": { + "name": "workspaces_namespace_unique", + "nullsNotDistinct": false, + "columns": [ + "namespace" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.demo_source_visibilities": { + "name": "demo_source_visibilities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "demo_source_id": { + "name": "demo_source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hidden_at": { + "name": "hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "demo_source_visibilities_workspace_source_idx": { + "name": "demo_source_visibilities_workspace_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "demo_source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "demo_source_visibilities_workspace_idx": { + "name": "demo_source_visibilities_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "demo_source_visibilities_workspace_id_workspaces_id_fk": { + "name": "demo_source_visibilities_workspace_id_workspaces_id_fk", + "tableFrom": "demo_source_visibilities", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 5b821b3..c14aca0 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -43,6 +43,20 @@ "when": 1778470974929, "tag": "0005_dark_ultragirl", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1778564600000, + "tag": "0006_api_owned_demos", + "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1778569500000, + "tag": "0007_normalize_legacy_demo_sources", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/package.json b/package.json index 3515371..6570d03 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "db:generate": "drizzle-kit generate", "db:push": "drizzle-kit push", "db:migrate": "drizzle-kit migrate", - "db:studio": "drizzle-kit studio" + "db:studio": "drizzle-kit studio", + "upstash:dev": "npx @upstash/qstash-cli dev" }, "dependencies": { "@ai-sdk/react": "^3.0.177", @@ -26,6 +27,7 @@ "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-separator": "^1.1.8", @@ -33,6 +35,7 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-virtual": "^3.13.24", + "@upstash/workflow": "^1.2.1", "@vercel/blob": "^2.3.3", "ai": "^6.0.175", "class-variance-authority": "^0.7.1", @@ -44,6 +47,7 @@ "lucide-react": "^1.14.0", "mammoth": "^1.12.0", "next": "16.2.4", + "next-themes": "^0.4.6", "pdfjs-dist": "5.4.296", "postgres": "^3.4.9", "react": "19.2.4", @@ -77,4 +81,4 @@ "typescript": "^6.0.3", "vitest": "^4.1.5" } -} +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index df6967f..16c3536 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@radix-ui/react-dialog': specifier: ^1.1.15 version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-dropdown-menu': + specifier: ^2.1.16 + version: 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@radix-ui/react-label': specifier: ^2.1.8 version: 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -50,6 +53,9 @@ importers: '@tanstack/react-virtual': specifier: ^3.13.24 version: 3.13.24(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@upstash/workflow': + specifier: ^1.2.1 + version: 1.2.1(zod@4.4.3) '@vercel/blob': specifier: ^2.3.3 version: 2.3.3 @@ -83,6 +89,9 @@ importers: next: specifier: 16.2.4 version: 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) + next-themes: + specifier: ^0.4.6 + version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) pdfjs-dist: specifier: 5.4.296 version: 5.4.296 @@ -1523,6 +1532,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-dropdown-menu@2.1.16': + resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-focus-guards@1.1.3': resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} peerDependencies: @@ -1567,6 +1589,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-menu@2.1.16': + resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-popper@1.2.8': resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} peerDependencies: @@ -2279,6 +2314,14 @@ packages: cpu: [x64] os: [win32] + '@upstash/qstash@2.11.0': + resolution: {integrity: sha512-AfPPxsUeOJCrxMQ9dkh1RZL40wgxCsUNFkxrbBSomC3U4j4qKFRawU8sDK+dqqH+sZFUS1biVM4GK46d5Tg2Vg==} + + '@upstash/workflow@1.2.1': + resolution: {integrity: sha512-G2WfWruKXbPpKJNyCWS097jtRZXW993BeMPYJPc7gGoEjnpJTH9S9j/YsfRJXZxWBPAuHB31tMzmKMGWAkAMQw==} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + '@vercel/blob@2.3.3': resolution: {integrity: sha512-MtD7VLo6hU07eHR7bmk5SIMD290q574UaNYTe46qeyRT+hWrCy26CoAqfd7PnIefVXvRehRZBzukxuTO9iGTVg==} engines: {node: '>=20.0.0'} @@ -2673,6 +2716,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + crypto-js@4.2.0: + resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + css-tree@3.2.1: resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} @@ -3718,6 +3764,9 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jose@5.10.0: + resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} + jose@6.2.3: resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} @@ -4179,6 +4228,16 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} + neverthrow@7.2.0: + resolution: {integrity: sha512-iGBUfFB7yPczHHtA8dksKTJ9E8TESNTAx1UQWW6TzMF280vo9jdPYpLUXrMN1BCkPdHFdNG3fxOt2CUad8KhAw==} + engines: {node: '>=18'} + + next-themes@0.4.6: + resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} + peerDependencies: + react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + next@16.2.4: resolution: {integrity: sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==} engines: {node: '>=20.9.0'} @@ -6388,6 +6447,21 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.4)': dependencies: react: 19.2.4 @@ -6421,6 +6495,32 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + aria-hidden: 1.2.6 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@floating-ui/react-dom': 2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -7025,6 +7125,17 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true + '@upstash/qstash@2.11.0': + dependencies: + crypto-js: 4.2.0 + jose: 5.10.0 + neverthrow: 7.2.0 + + '@upstash/workflow@1.2.1(zod@4.4.3)': + dependencies: + '@upstash/qstash': 2.11.0 + zod: 4.4.3 + '@vercel/blob@2.3.3': dependencies: async-retry: 1.3.3 @@ -7429,6 +7540,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + crypto-js@4.2.0: {} + css-tree@3.2.1: dependencies: mdn-data: 2.27.1 @@ -8592,6 +8705,8 @@ snapshots: jiti@2.7.0: {} + jose@5.10.0: {} + jose@6.2.3: {} js-tokens@4.0.0: {} @@ -9246,6 +9361,13 @@ snapshots: negotiator@1.0.0: {} + neverthrow@7.2.0: {} + + next-themes@0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + 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/env': 16.2.4 diff --git a/public/demo-sources/tsla-q4-2025/chunks.json b/public/demo-sources/tsla-q4-2025/chunks.json deleted file mode 100755 index 6e4ae05..0000000 --- a/public/demo-sources/tsla-q4-2025/chunks.json +++ /dev/null @@ -1,3313 +0,0 @@ -{ - "chunks": [ - { - "chunk_id": "15bcc860-b8d0-50c6-a627-66dbae67acd4", - "type": "table", - "content": "
Profitability$4.4B GAAP operating income in 2025; $1.4B in Q42025 marked a critical year for Tesla as we further expanded our mission and continued our transition from a hardware-centric business to a physical AI company. We laid the foundation for the future of Tesla as we further advanced FSD (Supervised) $^{4}$ , launched our Robotaxi service, began installing production lines for Cybercab and fine-tuned our production-primed Optimus design while expanding our AI training infrastructure.
$3.8B GAAP net income in 2025; $0.8B in Q4
$5.9B non-GAAP net income $^{1}$ in 2025; $1.8B in Q4
CashOperating cash flow of $14.7B in 2025; $3.8B in Q4Our approach to autonomous vehicles and humanoid robots mirrors the way we approached electric vehicles and energy storage – at the system level where we identify the limiting factor and develop bespoke and scalable solutions (batteries, power electronics, inverters, software, AI silicon, etc.) to optimize for cost, functionality, efficiency and safety. Our vertical integration has enabled us to achieve economies of scale in a profitable manner, quickly troubleshoot bottlenecks in production and iteratively optimize our technologies more rapidly than others.
Free cash flow $^{2}$ of $6.2B in 2025; $1.4B in Q4In 2025, we completed the refresh of our vehicle lineup with the launch of the new Model Y, including additional variants. We believe that maintaining an optimized and efficient product portfolio, with a continued focus on high-value features such as long range, best-in-class software and autonomy, is the correct strategy to win the autos market of the future. Similarly, we continued to evolve our energy offerings for commercial, utility and retail customers, as we position ourselves as a supplier of choice for clean, affordable and rapidly deployable energy capacity ahead of expected sustained demand growth for electricity.
$7.5B increase in our cash and investments $^{3}$ in 2025 to $44.1B
OperationsBegan removing safety monitor from our Robotaxis in Austin in JanuaryIn 2026, we will further invest in the infrastructure needed to support clean energy and transport and autonomous robots, including the ramp of six new production lines across vehicle, robots, energy storage and battery manufacturing, while further leveraging our existing factory, charging and service center footprint to support future growth.
Record Q4 & FY'25 energy storage deployments
Record vehicle deliveries in APAC
", - "path": "tables/table-0 Tesla 2025 Results.html", - "metadata": { - "length": 2783, - "summary": "table-1\nTesla reported strong 2025 financials with $4.4B operating income and expanded AI initiatives including Robotaxi and Optimus.", - "page_nums": [ - 11 - ], - "document_top_summary": "This document includes:", - "file_path": "tables/table-0 Tesla 2025 Results.html", - "keywords": [ - "Profitability", - "Cash", - "Operations" - ], - "tokens": [] - } - }, - { - "chunk_id": "68a6be7d-c587-5c73-abf2-56f4686e28e6", - "type": "text", - "content": "[tables/table-0 Tesla 2025 Results.html]", - "path": "Default_Root/TSLA-Q4-2025-Update(1).pdf-->HIGHLIGHTS", - "metadata": { - "length": 83, - "summary": "", - "page_nums": [ - 1, - 3, - 11 - ], - "document_top_summary": "This document includes:", - "tokens": [], - "keywords": [], - "connect_to": [ - { - "target": "15bcc860-b8d0-50c6-a627-66dbae67acd4", - "relation": "embeds", - "ref": "[tables/table-0 Tesla 2025 Results.html]", - "position": { - "start": 0, - "end": 40 - } - } - ] - } - }, - { - "chunk_id": "481804a6-0fb5-52fc-bc47-ef5728621f6b", - "type": "table", - "content": "
($ in millions, except percentages and per share data)Q4-2024Q1-2025Q2-2025Q3-2025Q4-2025YoY
Total automotive revenues19,79813,96716,66121,20517,693-11%
Energy generation and storage revenue3,0612,7302,7893,4153,83725%
Services and other revenue2,8482,6383,0463,4753,37118%
Total revenues25,70719,33522,49628,09524,901-3%
Total gross profit4,1793,1533,8785,0545,00920%
Total GAAP gross margin16.3%16.3%17.2%18.0%20.1%386 bp
Operating expenses2,5962,7542,9553,4303,60039%
Income from operations1,5833999231,6241,409-11%
Operating margin6.2%2.1%4.1%5.8%5.7%-50 bp
Adjusted EBITDA (1) (2)4,3332,8143,4014,2274,154-4%
Adjusted EBITDA margin (1) (2)16.9%14.6%15.1%15.0%16.7%-17 bp
Net income attributable to common stockholders (GAAP) (1)2,1284091,1721,373840-61%
Net income attributable to common stockholders (non-GAAP) (1) (3)2,1079341,3931,7701,761-16%
EPS attributable to common stockholders, diluted (GAAP) (1)0.600.120.330.390.24-60%
EPS attributable to common stockholders, diluted (non-GAAP) (1) (3)0.600.270.400.500.50-17%
Net cash provided by operating activities4,8142,1562,5406,2383,813-21%
Capital expenditures (4)(2,780)(1,492)(2,394)(2,248)(2,393)-14%
Free cash flow (4)2,0346641463,9901,420-30%
Cash, cash equivalents and investments36,56336,99636,78241,64744,05921%
", - "path": "tables/table-1 Q4 2025 Financials.html", - "metadata": { - "length": 2894, - "summary": "table-2\nTable shows Tesla's quarterly financials through Q4 2025, including revenues, gross profit, operating income, and free cash flow in millions of dollars.", - "page_nums": [ - 11 - ], - "document_top_summary": "This document includes:", - "file_path": "tables/table-1 Q4 2025 Financials.html", - "keywords": [ - "revenue", - "profit", - "cash flow" - ], - "tokens": [] - } - }, - { - "chunk_id": "31077385-2188-5cc5-bbfb-85e07e94c5c8", - "type": "table", - "content": "
($ in millions, except percentages and per share data)20212022202320242025YoY
Total automotive revenues47,23271,46282,41977,07069,526-10%
Energy generation and storage revenue2,7893,9096,03510,08612,77127%
Services and other revenue3,8026,0918,31910,53412,53019%
Total revenues53,82381,46296,77397,69094,827-3%
Total gross profit13,60620,85317,66017,45017,094-2%
Total GAAP gross margin25.3%25.6%18.2%17.9%18.0%16 bp
Operating expenses7,0837,1978,76910,37412,73923%
Income from operations6,52313,6568,8917,0764,355-38%
Operating margin12.1%16.8%9.2%7.2%4.6%-265 bp
Adjusted EBITDA (1)11,72219,39016,63116,05614,596-9%
Adjusted EBITDA margin (1)21.8%23.8%17.2%16.4%15.4%-104 bp
Net income attributable to common stockholders (GAAP)5,51912,55614,9977,0913,794-46%
Net income attributable to common stockholders (non-GAAP) (2)7,71914,27610,8827,9605,858-26%
EPS attributable to common stockholders, diluted (GAAP)1.633.624.302.041.08-47%
EPS attributable to common stockholders, diluted (non-GAAP) (2)2.284.123.122.291.66-28%
Net cash provided by operating activities11,49714,72413,25614,92314,747-1%
Capital expenditures (3)(6,514)(7,163)(8,899)(11,342)(8,527)-25%
Free cash flow (3)4,9837,5614,3573,5816,22074%
Cash, cash equivalents and investments17,70722,18529,09436,56344,05921%
", - "path": "tables/table-2 Financial Data 2021-25.html", - "metadata": { - "length": 2897, - "summary": "table-3\nTable shows financial metrics from 2021 to 2025, including revenues, gross profit, operating income, and free cash flow in millions of dollars.", - "page_nums": [ - 11 - ], - "document_top_summary": "This document includes:", - "file_path": "tables/table-2 Financial Data 2021-25.html", - "keywords": [ - "revenue", - "profit", - "cash flow" - ], - "tokens": [] - } - }, - { - "chunk_id": "8ef4ea32-6ed5-5aec-a413-6af0d752355b", - "type": "table", - "content": "
Q4-2024Q1-2025Q2-2025Q3-2025Q4-2025YoY
Model 3/Y production436,718345,454396,835435,826422,652-3%
Other models production22,72717,16113,40911,62411,706-48%
Total production459,445362,615410,244447,450434,358-5%
Model 3/Y deliveries471,930323,800373,728481,166406,585-14%
Other models deliveries23,64012,88110,39415,93311,642-51%
Total deliveries495,570336,681384,122497,099418,227-16%
of which subject to operating lease accounting26,96213,7216,67010,23010,996-59%
Cumulative $deliveries^{(1)}$ (all-time; mil)7.37.68.08.58.922%
Active FSD $Subscriptions^{(2)}$ (mil)0.80.80.91.01.138%
Total end of quarter operating lease (new vehicle) $count^{(3)}$ 180,523179,930172,882167,163163,075-10%
Global vehicle inventory (days of supply) $^{(4)}$ 122224101525%
Storage deployed (GWh)11.010.49.612.514.229%
Tesla locations1,3591,3901,4541,4981,55314%
Supercharger stations6,9757,1317,3777,7538,18217%
Supercharger connectors65,49567,31670,22873,81777,68219%
", - "path": "tables/table-3 Tesla Q4-2025 Data.html", - "metadata": { - "length": 2288, - "summary": "table-4\nTable shows Tesla's quarterly production, deliveries, and inventory from Q4 2024 to Q4 2025. Total deliveries dropped 16% YoY in Q4 2025.", - "page_nums": [ - 11 - ], - "document_top_summary": "This document includes:", - "file_path": "tables/table-3 Tesla Q4-2025 Data.html", - "keywords": [ - "production", - "deliveries", - "inventory" - ], - "tokens": [] - } - }, - { - "chunk_id": "4c82227e-3ba3-5bb2-9d00-4397c1dc38f6", - "type": "table", - "content": "
20212022202320242025YoY
Model 3/Y production906,0321,298,4341,775,1591,679,3381,600,767-5%
Other models production24,39071,17770,82694,10553,900-43%
Total production930,4221,369,6111,845,9851,773,4431,654,667-7%
Model 3/Y deliveries911,2421,247,1461,739,7071,704,0931,585,279-7%
Other models deliveries24,98066,70568,87485,13350,850-40%
Total deliveries936,2221,313,8511,808,5811,789,2261,636,129-9%
of which subject to operating lease accounting60,91247,58272,22660,00341,617-31%
Cumulative $deliveries^{(1)}$ (all-time; mil)2.33.75.57.38.922%
Active FSD $Subscriptions^{(2)}$ (mil)0.40.50.60.81.138%
Total end of year operating lease (new vehicle) count120,342140,667176,564180,523163,075-10%
Global vehicle inventory (days of supply) $^{(3)}$ 61616131515%
Storage deployed (GWh)4.06.514.731.446.749%
Tesla locations6449631,2081,3591,55314%
Supercharger stations3,4764,6785,9526,9758,18217%
Supercharger connectors31,49842,41954,89265,49577,68219%
", - "path": "tables/table-4 Tesla 2021-2025 Data.html", - "metadata": { - "length": 2285, - "summary": "table-5\nTable shows Tesla's production, deliveries, and infrastructure metrics from 2021 to 2025. Total production and deliveries peaked in 2023 then declined by 2025.", - "page_nums": [ - 11 - ], - "document_top_summary": "This document includes:", - "file_path": "tables/table-4 Tesla 2021-2025 Data.html", - "keywords": [ - "production", - "deliveries", - "growth" - ], - "tokens": [] - } - }, - { - "chunk_id": "60109008-6261-51e6-b202-093d904eb881", - "type": "text", - "content": "FINANCIAL SUMMARY\n(Unaudited)\n\n[tables/table-1 Q4 2025 Financials.html]\n\n(1) As a result of the adoption of the new crypto assets standard, the previously reported quarterly periods in 2024 have been recast.\n(2) Beginning in Q1'25, Adjusted EBITDA (non-GAAP) is presented net of digital assets gains and losses and all prior periods have been adjusted.\n(3) Beginning in Q1'25, Net income attributable to common stockholders (non-GAAP) is presented net of digital assets gains and losses and all prior periods have been adjusted.\n(4) Beginning in Q1'25, Capital expenditures is presented inclusive of purchases of energy generation and storage systems and all prior periods have been adjusted.\nFINANCIAL SUMMARY\n(Unaudited)\n\n[tables/table-2 Financial Data 2021-25.html]\n\n(1) Beginning in Q1'25, Adjusted EBITDA (non-GAAP) is presented net of digital assets gains and losses and all prior periods have been adjusted.\n(2) Beginning in Q1'25, Net income attributable to common stockholders (non-GAAP) is presented net of digital assets gains and losses and all prior periods have been adjusted.\n(3) Beginning in Q1'25, Capital expenditures is presented inclusive of purchases of energy generation and storage systems and all prior periods have been adjusted.\nOPERATIONAL SUMMARY\n(Unaudited)\n\n[tables/table-3 Tesla Q4-2025 Data.html]\n\nOPERATIONAL SUMMARY\n(Unaudited)\n\n[tables/table-4 Tesla 2021-2025 Data.html]", - "path": "Default_Root/TSLA-Q4-2025-Update(1).pdf-->SUMMARY", - "metadata": { - "length": 1517, - "summary": "The document presents unaudited financial and operational summaries for a company, covering quarterly data from Q4 2024 through Q4 2025 and annual data from 2021 to 2025. Key notes indicate significant accounting changes effective Q1 2025: Adjusted EBITDA and Net income attributable to common stockholders are now presented net of digital assets gains and losses, with all prior periods adjusted accordingly. Additionally, Capital expenditures now include purchases of energy generation and storage systems, requiring restatement of previous periods. The content references multiple tables detailing these metrics but does not display the specific numerical values.", - "page_nums": [ - 8, - 11 - ], - "document_top_summary": "This document includes:", - "tokens": [ - "FINANCIAL", - "SUMMARY", - "Unaudited", - "As", - "result", - "adoption", - "crypto", - "assets", - "standard", - "previously", - "reported", - "quarterly", - "periods", - "2024", - "recast", - "Beginning", - "Q1", - "25", - "Adjusted", - "EBITDA", - "GAAP", - "presented", - "net", - "digital", - "gains", - "losses", - "prior", - "adjusted", - "Net", - "income", - "attributable", - "common", - "stockholders", - "Capital", - "expenditures", - "inclusive", - "purchases", - "energy", - "generation", - "storage", - "systems", - "OPERATIONAL" - ], - "keywords": [ - "financials", - "crypto assets", - "adjusted metrics" - ], - "connect_to": [ - { - "target": "481804a6-0fb5-52fc-bc47-ef5728621f6b", - "relation": "embeds", - "ref": "[tables/table-1 Q4 2025 Financials.html]", - "position": { - "start": 31, - "end": 71 - } - }, - { - "target": "31077385-2188-5cc5-bbfb-85e07e94c5c8", - "relation": "embeds", - "ref": "[tables/table-2 Financial Data 2021-25.html]", - "position": { - "start": 724, - "end": 768 - } - }, - { - "target": "8ef4ea32-6ed5-5aec-a413-6af0d752355b", - "relation": "embeds", - "ref": "[tables/table-3 Tesla Q4-2025 Data.html]", - "position": { - "start": 1288, - "end": 1328 - } - }, - { - "target": "4c82227e-3ba3-5bb2-9d00-4397c1dc38f6", - "relation": "embeds", - "ref": "[tables/table-4 Tesla 2021-2025 Data.html]", - "position": { - "start": 1363, - "end": 1405 - } - } - ] - } - }, - { - "chunk_id": "60339310-5480-5ae2-8791-e6017bafb730", - "type": "text", - "content": "While automotive sales declined sequentially, gross margin (even when excluding the impact of regulatory credits) improved. The APAC region continued to show strength across multiple markets and set a record for deliveries in the quarter. We continued the rollout of Model Y variants across markets in Q4, including the standard and performance versions.\nPreparations continue in North America for the production ramps of Tesla Semi and Cybercab, both commencing 1H26, and production of the next-generation Roadster.", - "path": "Default_Root/TSLA-Q4-2025-Update(1).pdf-->SUMMARY-->Automotive", - "metadata": { - "length": 516, - "summary": "", - "page_nums": [ - 8 - ], - "document_top_summary": "This document includes:", - "tokens": [ - "While", - "automotive", - "sales", - "declined", - "sequentially", - "gross", - "margin", - "excluding", - "impact", - "regulatory", - "credits", - "improved", - "The", - "APAC", - "region", - "continued", - "show", - "strength", - "multiple", - "markets", - "set", - "record", - "deliveries", - "quarter", - "We", - "rollout", - "Model", - "variants", - "Q4", - "including", - "standard", - "performance", - "versions", - "Preparations", - "continue", - "North", - "America", - "production", - "ramps", - "Tesla", - "Semi", - "Cybercab", - "commencing", - "1H26", - "generation", - "Roadster" - ], - "keywords": [], - "connect_to": [] - } - }, - { - "chunk_id": "eb18e33e-b093-532e-b4df-bd05d63b0294", - "type": "text", - "content": "We achieved our highest quarterly energy storage deployments, driven by record Megapack deployments. Total gross profit rose, both sequentially and year-over-year, to a record \\$1.1 billion, marking the fifth consecutive record quarter. We plan to begin Megapack 3 and Megablock production at Megafactory Houston in 2026. In 2025, our global Powerwall network supported more than 89,000 Virtual Power Plant events across over 1 million installed units, allowing homeowners to save over \\$1 billion in electricity bills as Virtual Power Plant participation continues to scale rapidly.", - "path": "Default_Root/TSLA-Q4-2025-Update(1).pdf-->SUMMARY-->Energy generation and storage", - "metadata": { - "length": 583, - "summary": "", - "page_nums": [ - 8 - ], - "document_top_summary": "This document includes:", - "tokens": [ - "We", - "achieved", - "highest", - "quarterly", - "energy", - "storage", - "deployments", - "driven", - "record", - "Megapack", - "Total", - "gross", - "profit", - "rose", - "sequentially", - "year", - "1.1", - "billion", - "marking", - "consecutive", - "quarter", - "plan", - "begin", - "Megablock", - "production", - "Megafactory", - "Houston", - "2026", - "In", - "2025", - "global", - "Powerwall", - "network", - "supported", - "89", - "000", - "Virtual", - "Power", - "Plant", - "events", - "million", - "installed", - "units", - "allowing", - "homeowners", - "save", - "electricity", - "bills", - "participation", - "continues", - "scale", - "rapidly" - ], - "keywords": [], - "connect_to": [] - } - }, - { - "chunk_id": "cdae270e-2dc4-5edc-9876-26a9454fbfff", - "type": "table", - "content": "
RegionProductCapacityStatus
Automotive
CaliforniaModel 3 / Model Y>550,000Production
Model S / Model X100,000Production
ShanghaiModel 3 / Model Y>950,000Production
BerlinModel Y>375,000Production
TexasModel Y>250,000Production
Cybertruck>125,000Production
Cybercab-Tooling
NevadaTesla Semi-Tooling
TBDRoadster-Design development
Energy Generation and Storage
CaliforniaMegapack40 GWhProduction
NevadaPowerwall>6 GWhProduction
ShanghaiMegapack40 GWhProduction
TexasMegapack-Construction
Robotics
CaliforniaOptimus-Construction
", - "path": "tables/table-5 Tesla Production.html", - "metadata": { - "length": 1306, - "summary": "table-6\nTesla operates global facilities in California, Shanghai, Berlin, Texas, and Nevada for Model 3/Y, S/X, Cybertruck, Semi, Megapack, Powerwall, and Optimus.", - "page_nums": [ - 8 - ], - "document_top_summary": "This document includes:", - "file_path": "tables/table-5 Tesla Production.html", - "keywords": [ - "Tesla", - "Manufacturing", - "Capacity" - ], - "tokens": [] - } - }, - { - "chunk_id": "60518074-bcbe-5b48-89a9-dfeb5d1b531b", - "type": "text", - "content": "We made further progress on the Optimus program in 2025. In Q1 of this year, we plan to unveil the Gen 3 version of Optimus, which will include major upgrades from version 2.5, including our latest hand design. The Gen 3 is our first design meant for mass production. Preparations are underway for the first production line, including supply chain readiness, with start of production planned before the end of 2026 and eventual planned capacity of 1 million robots per year.\nInstalled Annual Manufacturing Capacity\n\n[tables/table-5 Tesla Production.html]\n\nInstalled capacity ≠ current production rate and there may be limitations discovered as production rates approach capacity. Production rates depend on a variety of factors, including equipment uptime, component supply, downtime related to factory upgrades, regulatory considerations and other factors. Construction includes factory and infrastructure buildout as well as tool installation.", - "path": "Default_Root/TSLA-Q4-2025-Update(1).pdf-->SUMMARY-->Robotics", - "metadata": { - "length": 959, - "summary": "", - "page_nums": [ - 8, - 9 - ], - "document_top_summary": "This document includes:", - "tokens": [ - "We", - "made", - "progress", - "Optimus", - "program", - "2025", - "In", - "Q1", - "year", - "plan", - "unveil", - "Gen", - "version", - "include", - "major", - "upgrades", - "2.5", - "including", - "latest", - "hand", - "design", - "The", - "meant", - "mass", - "production", - "Preparations", - "underway", - "line", - "supply", - "chain", - "readiness", - "start", - "planned", - "end", - "2026", - "eventual", - "capacity", - "million", - "robots", - "Installed", - "Annual", - "Manufacturing", - "Capacity", - "current", - "rate", - "limitations", - "discovered", - "rates", - "approach", - "Production", - "depend", - "variety", - "factors", - "equipment", - "uptime", - "component", - "downtime", - "related", - "factory", - "regulatory", - "considerations", - "Construction", - "includes", - "infrastructure", - "buildout", - "tool", - "installation" - ], - "keywords": [], - "connect_to": [ - { - "target": "cdae270e-2dc4-5edc-9876-26a9454fbfff", - "relation": "embeds", - "ref": "[tables/table-5 Tesla Production.html]", - "position": { - "start": 516, - "end": 554 - } - } - ] - } - }, - { - "chunk_id": "c49883a3-5834-5537-be53-35e58da70bf7", - "type": "text", - "content": "We are currently building Cortex 2 at Gigafactory Texas to further increase our AI training compute capacity. In the first half of 2026, we plan to more than double the size of onsite compute in Texas (in terms of H100 equivalents). We aim to maximize capital efficiency by scaling training compute judiciously, including when the training backlog gets too long or in anticipation of greater demand from our engineers to support our AI-related offerings.", - "path": "Default_Root/TSLA-Q4-2025-Update(1).pdf-->SUMMARY-->AI Training Compute", - "metadata": { - "length": 454, - "summary": "", - "page_nums": [ - 9 - ], - "document_top_summary": "This document includes:", - "tokens": [ - "We", - "building", - "Cortex", - "Gigafactory", - "Texas", - "increase", - "AI", - "training", - "compute", - "capacity", - "In", - "half", - "2026", - "plan", - "double", - "size", - "onsite", - "terms", - "H100", - "equivalents", - "aim", - "maximize", - "capital", - "efficiency", - "scaling", - "judiciously", - "including", - "backlog", - "long", - "anticipation", - "greater", - "demand", - "engineers", - "support", - "related", - "offerings" - ], - "keywords": [], - "connect_to": [] - } - }, - { - "chunk_id": "fcbc3b02-dec6-5e91-849e-fccd3f22320e", - "type": "text", - "content": "Our lithium refinery commenced pilot production and is the first spodumene to lithium hydroxide refinery in North America, leveraging a simpler, cheaper and more environmentally friendly process. This refinery enables us to domestically produce critical minerals in support of energy storage, battery manufacturing and ultimately for EV growth.\nWe have begun to produce battery packs for certain Model Ys with our 4680 cells, unlocking an additional vector of supply to help navigate increasingly complex supply chain challenges caused by trade barriers and tariff risks. We now produce dry-electrode for 4680 cells with both anode and cathode made in Austin. We expect both domestic cathode material in Texas and LFP lines in Nevada to begin production in 2026.", - "path": "Default_Root/TSLA-Q4-2025-Update(1).pdf-->SUMMARY-->Battery", - "metadata": { - "length": 762, - "summary": "", - "page_nums": [ - 9 - ], - "document_top_summary": "This document includes:", - "tokens": [ - "Our", - "lithium", - "refinery", - "commenced", - "pilot", - "production", - "spodumene", - "hydroxide", - "North", - "America", - "leveraging", - "simpler", - "cheaper", - "environmentally", - "friendly", - "process", - "This", - "enables", - "domestically", - "produce", - "critical", - "minerals", - "support", - "energy", - "storage", - "battery", - "manufacturing", - "ultimately", - "EV", - "growth", - "We", - "begun", - "packs", - "Model", - "Ys", - "4680", - "cells", - "unlocking", - "additional", - "vector", - "supply", - "navigate", - "increasingly", - "complex", - "chain", - "challenges", - "caused", - "trade", - "barriers", - "tariff", - "risks", - "dry", - "electrode", - "anode", - "cathode", - "made", - "Austin", - "expect", - "domestic", - "material", - "Texas", - "LFP", - "lines", - "Nevada", - "begin", - "2026" - ], - "keywords": [], - "connect_to": [] - } - }, - { - "chunk_id": "0428af2c-bf5d-54a6-a142-09ec146d027e", - "type": "table", - "content": "
RegionProductCapacityStatus
AI Training Compute
TexasCortex 1>100k H100eProduction
Cortex 2-Construction
Battery Manufacturing
NevadaLFP7 GWhEarly Ramp
Texas468040 GWhProduction
Cathode Materials10 GWhEarly Ramp
Lithium Refining30 GWhEarly Ramp
", - "path": "tables/table-6 Facility Status.html", - "metadata": { - "length": 629, - "summary": "table-7\nTable lists AI training and battery manufacturing facilities. Texas hosts Cortex 1 (production) and Cortex 2 (construction). Nevada and Texas have LFP, 4680, cathode materials, and lithium refining plants in various stages.", - "page_nums": [ - 9 - ], - "document_top_summary": "This document includes:", - "file_path": "tables/table-6 Facility Status.html", - "keywords": [ - "AI Training", - "Battery Mfg", - "Capacity" - ], - "tokens": [] - } - }, - { - "chunk_id": "92dc6e40-720d-55b8-a2e2-c78bd4f59dba", - "type": "image", - "content": "\n## Other Supporting Infrastructure Tesla AI Training Capacity Ramp (H100 equivalent GPUs)0\n[images/image-1-Capacity Growth Projection.jpg]\n", - "path": "images/image-1-Capacity Growth Projection.jpg", - "metadata": { - "length": 124, - "summary": "image-1\nThe chart illustrates a steady increase in existing capacity from mid-2021 through late 2025, characterized by gradual step-wise growth. A significant surge is projected for the future planned capacity starting in early 2026, reaching levels well above the current trajectory.", - "page_nums": [ - 9 - ], - "document_top_summary": "This document includes:", - "file_path": "images/image-1-Capacity Growth Projection.jpg", - "keywords": [], - "tokens": [] - } - }, - { - "chunk_id": "34777c71-d716-5c39-b18a-bec0d0b64706", - "type": "text", - "content": "We continue to efficiently utilize our existing physical footprint in North America, with targeted augmentation to support the rollout of Robotaxi. While in the short-term, operational workstreams such as charging, cleaning and maintenance can be managed through our existing charging network, service centers and sales and delivery locations, we will have to add more capacity as the service expands. We added over 3,800 net new Supercharging stalls, growing the network 19% year-over-year.\nInstalled Annual Capacity\n\n[tables/table-6 Facility Status.html]\n\nInstalled capacity ≠ current production rate and there may be limitations discovered as production rates approach capacity. Production rates depend on a variety of factors, including equipment uptime, component supply, downtime related to factory upgrades, regulatory considerations and other factors. Construction includes factory and infrastructure buildout as well as tool installation.\n\n## Other Supporting Infrastructure Tesla AI Training Capacity Ramp (H100 equivalent GPUs)0\n[images/image-1-Capacity Growth Projection.jpg]\n\nTesla AI Training Capacity Ramp (H100 equivalent GPUs)", - "path": "Default_Root/TSLA-Q4-2025-Update(1).pdf-->SUMMARY-->Other Supporting Infrastructure", - "metadata": { - "length": 1142, - "summary": "", - "page_nums": [ - 9, - 10 - ], - "document_top_summary": "This document includes:", - "tokens": [ - "We", - "continue", - "efficiently", - "utilize", - "existing", - "physical", - "footprint", - "North", - "America", - "targeted", - "augmentation", - "support", - "rollout", - "Robotaxi", - "While", - "short", - "term", - "operational", - "workstreams", - "charging", - "cleaning", - "maintenance", - "managed", - "network", - "service", - "centers", - "sales", - "delivery", - "locations", - "add", - "capacity", - "expands", - "added", - "800", - "net", - "Supercharging", - "stalls", - "growing", - "19%", - "year", - "Installed", - "Annual", - "Capacity", - "current", - "production", - "rate", - "limitations", - "discovered", - "rates", - "approach", - "Production", - "depend", - "variety", - "factors", - "including", - "equipment", - "uptime", - "component", - "supply", - "downtime", - "related", - "factory", - "upgrades", - "regulatory", - "considerations", - "Construction", - "includes", - "infrastructure", - "buildout", - "tool", - "installation", - "Other", - "Supporting", - "Infrastructure", - "Tesla", - "AI", - "Training", - "Ramp", - "H100", - "equivalent", - "GPUs" - ], - "keywords": [], - "connect_to": [ - { - "target": "0428af2c-bf5d-54a6-a142-09ec146d027e", - "relation": "embeds", - "ref": "[tables/table-6 Facility Status.html]", - "position": { - "start": 519, - "end": 556 - } - }, - { - "target": "92dc6e40-720d-55b8-a2e2-c78bd4f59dba", - "relation": "embeds", - "ref": "[images/image-1-Capacity Growth Projection.jpg]", - "position": { - "start": 1040, - "end": 1087 - } - } - ] - } - }, - { - "chunk_id": "778a63c2-7955-514c-84d0-9c2cfd99a489", - "type": "text", - "content": "We continue to enhance FSD (Supervised) $^{1}$ via our end-to-end foundation model trained on both customer and Robotaxi real-world data with our latest version, v14. FSD (Supervised) $^{1}$ increasingly provides safety and convenience functionality that can relieve drivers of many tedious and potentially dangerous aspects of road travel, including giving access to personal transport for those who otherwise have difficulty driving. V14 offers unparalleled driver assistance to safely drive to the customer's destination, find a free parking spot and park at that location. Our global fleet can collect the equivalent of over 500 years of continuous driving data per day $^{2}$ , allowing us to safely deploy and scale capabilities that can handle the long and fat tail of corner cases across diverse geographies and driving environments.", - "path": "Default_Root/TSLA-Q4-2025-Update(1).pdf-->SUMMARY-->AI Software", - "metadata": { - "length": 841, - "summary": "", - "page_nums": [ - 10 - ], - "document_top_summary": "This document includes:", - "tokens": [ - "We", - "continue", - "enhance", - "FSD", - "Supervised", - "end", - "foundation", - "model", - "trained", - "customer", - "Robotaxi", - "real", - "world", - "data", - "latest", - "version", - "v14", - "increasingly", - "safety", - "convenience", - "functionality", - "relieve", - "drivers", - "tedious", - "potentially", - "dangerous", - "aspects", - "road", - "travel", - "including", - "giving", - "access", - "personal", - "transport", - "difficulty", - "driving", - "V14", - "offers", - "unparalleled", - "driver", - "assistance", - "safely", - "drive", - "destination", - "find", - "free", - "parking", - "spot", - "park", - "location", - "Our", - "global", - "fleet", - "collect", - "equivalent", - "500", - "years", - "continuous", - "day", - "allowing", - "deploy", - "scale", - "capabilities", - "handle", - "long", - "fat", - "tail", - "corner", - "cases", - "diverse", - "geographies", - "environments" - ], - "keywords": [], - "connect_to": [] - } - }, - { - "chunk_id": "164babde-7a64-5b81-9a5a-41443b221c40", - "type": "text", - "content": "Development of our in-house, custom designed AI5 and AI6 inference chips for autonomy progressed during the quarter, with production planned for 2027 and 2028, respectively. We are targeting a 50x improvement in performance for AI5 relative to AI4 thanks to 10x raw compute, 9x memory capacity and 5x hardened block quantization and softmax function (the latter enabling efficient low-precision computing without sacrificing model accuracy).", - "path": "Default_Root/TSLA-Q4-2025-Update(1).pdf-->SUMMARY-->AI Inference Compute", - "metadata": { - "length": 441, - "summary": "", - "page_nums": [ - 10 - ], - "document_top_summary": "This document includes:", - "tokens": [ - "Development", - "house", - "custom", - "designed", - "AI5", - "AI6", - "inference", - "chips", - "autonomy", - "progressed", - "quarter", - "production", - "planned", - "2027", - "2028", - "We", - "targeting", - "50x", - "improvement", - "performance", - "relative", - "AI4", - "10x", - "raw", - "compute", - "9x", - "memory", - "capacity", - "5x", - "hardened", - "block", - "quantization", - "softmax", - "function", - "enabling", - "efficient", - "low", - "precision", - "computing", - "sacrificing", - "model", - "accuracy" - ], - "keywords": [], - "connect_to": [] - } - }, - { - "chunk_id": "e4a241cf-298e-51c1-a928-4c6d21840920", - "type": "image", - "content": "\nWe continue to enhance FSD (Supervised) $^{1}$ via our end-to-end foundation model trained on both customer and Robotaxi real-world data with our latest version, v14. FSD (Supervised) $^{1}$ increasingly provides safety and convenience functionality that can relieve drivers of many tedious and potentially dangerous aspects of road travel, including giving access to personal transport for those who otherwise have difficulty driving. V14 offers unparalleled driver assistance to safely drive to the customer's destination, find a free parking spot and park at that location. Our global fleet can collect the equivalent of over 500 years of continuous driving data per day $^{2}$ , allowing us to safely deploy and scale capabilities that can handle the long and fat tail of corner cases across diverse geographies and driving environments. Cumulative Miles Driven with FSD (Supervised) $^{1}$ (billions)0\n[images/image-2-FSD Mileage Growth.jpg]\n", - "path": "images/image-2-FSD Mileage Growth.jpg", - "metadata": { - "length": 940, - "summary": "image-2\nThe chart illustrates the projected accumulation of miles driven on Full Self-Driving software over time. It distinguishes between two versions: an older version (V11 and before) represented by a blue area, and a newer version (V12 and beyond) shown in red. While mileage for the older version remains relatively flat, the newer version shows exponential growth starting around early 2024, eventually dominating the total distance traveled by late 2025.", - "page_nums": [ - 10 - ], - "document_top_summary": "This document includes:", - "file_path": "images/image-2-FSD Mileage Growth.jpg", - "keywords": [], - "tokens": [] - } - }, - { - "chunk_id": "1bbb4be7-849a-557b-86de-9d71a3e33ef0", - "type": "image", - "content": "\nDevelopment of our in-house, custom designed AI5 and AI6 inference chips for autonomy progressed during the quarter, with production planned for 2027 and 2028, respectively. We are targeting a 50x improvement in performance for AI5 relative to AI4 thanks to 10x raw compute, 9x memory capacity and 5x hardened block quantization and softmax function (the latter enabling efficient low-precision computing without sacrificing model accuracy). Targeting Step-Function Improvement for our Next-Generation Inference Chip, AI50\n[images/image-3-Tesla Silicon Optimization.jpg]\n", - "path": "images/image-3-Tesla Silicon Optimization.jpg", - "metadata": { - "length": 556, - "summary": "image-3\nThe image displays a high-performance computing chip alongside key performance metrics. It highlights significant improvements in hardened block quantization, memory capacity, and raw compute power compared to previous generations. The overall total improvement is presented as a substantial increase over the AI4 architecture.", - "page_nums": [ - 10 - ], - "document_top_summary": "This document includes:", - "file_path": "images/image-3-Tesla Silicon Optimization.jpg", - "keywords": [], - "tokens": [] - } - }, - { - "chunk_id": "32bfae6f-b2d4-51d3-94d9-82b9640828dd", - "type": "text", - "content": "The Robotaxi iOS app no longer has a waitlist in the areas we serve. Our vehicles keep getting better with our over-the-air updates, including: Grok (an AI companion) which now supports navigation commands (allowing users to find, add and edit navigation destinations hands-free); Tesla Photobooth which enables users to take photos in their car and download or share via the Tesla mobile app; Supercharger Site Maps which displays Supercharger layouts, nearby businesses and live availability details; Automatic HOV Lane Routing based on interior camera occupancy detection; Phone Left Behind Chime and SpaceX ISS Docking Simulator Game.\n\nWe continue to enhance FSD (Supervised) $^{1}$ via our end-to-end foundation model trained on both customer and Robotaxi real-world data with our latest version, v14. FSD (Supervised) $^{1}$ increasingly provides safety and convenience functionality that can relieve drivers of many tedious and potentially dangerous aspects of road travel, including giving access to personal transport for those who otherwise have difficulty driving. V14 offers unparalleled driver assistance to safely drive to the customer's destination, find a free parking spot and park at that location. Our global fleet can collect the equivalent of over 500 years of continuous driving data per day $^{2}$ , allowing us to safely deploy and scale capabilities that can handle the long and fat tail of corner cases across diverse geographies and driving environments. Cumulative Miles Driven with FSD (Supervised) $^{1}$ (billions)0\n[images/image-2-FSD Mileage Growth.jpg]\n\nCumulative Miles Driven with FSD (Supervised) $^{1}$ (billions)\n\nDevelopment of our in-house, custom designed AI5 and AI6 inference chips for autonomy progressed during the quarter, with production planned for 2027 and 2028, respectively. We are targeting a 50x improvement in performance for AI5 relative to AI4 thanks to 10x raw compute, 9x memory capacity and 5x hardened block quantization and softmax function (the latter enabling efficient low-precision computing without sacrificing model accuracy). Targeting Step-Function Improvement for our Next-Generation Inference Chip, AI50\n[images/image-3-Tesla Silicon Optimization.jpg]\n\nTargeting Step-Function Improvement for our Next-Generation Inference Chip, AI5\n(1) Active driver supervision required; does not make the vehicle autonomous\n(2) Calculated based on continuous hours of driving at an average of 30 miles per hour", - "path": "Default_Root/TSLA-Q4-2025-Update(1).pdf-->SUMMARY-->Automotive and Other Software", - "metadata": { - "length": 2444, - "summary": "The Robotaxi iOS app has removed its waitlist in served areas and now includes features like Grok navigation, Tesla Photobooth, Supercharger maps, automatic HOV routing, phone left-behind chimes, and a SpaceX game. FSD (Supervised) v14 uses an end-to-end foundation model trained on vast real-world data to assist drivers with navigation, parking, and safety, though active supervision remains required. The company is developing custom AI5 and AI6 inference chips for 2027 and 2028, targeting significant performance improvements over previous generations to handle complex driving scenarios globally.", - "page_nums": [ - 10 - ], - "document_top_summary": "This document includes:", - "tokens": [ - "The", - "Robotaxi", - "iOS", - "app", - "longer", - "waitlist", - "areas", - "serve", - "Our", - "vehicles", - "air", - "updates", - "including", - "Grok", - "AI", - "companion", - "supports", - "navigation", - "commands", - "allowing", - "users", - "find", - "add", - "edit", - "destinations", - "hands", - "free", - "Tesla", - "Photobooth", - "enables", - "photos", - "car", - "download", - "share", - "mobile", - "Supercharger", - "Site", - "Maps", - "displays", - "layouts", - "nearby", - "businesses", - "live", - "availability", - "details", - "Automatic", - "HOV", - "Lane", - "Routing", - "based", - "interior", - "camera", - "occupancy", - "detection", - "Phone", - "Left", - "Behind", - "Chime", - "SpaceX", - "ISS", - "Docking", - "Simulator", - "Game", - "We", - "continue", - "enhance", - "FSD", - "Supervised", - "end", - "foundation", - "model", - "trained", - "customer", - "real", - "world", - "data", - "latest", - "version", - "v14", - "increasingly", - "safety", - "convenience", - "functionality", - "relieve", - "drivers", - "tedious", - "potentially", - "dangerous", - "aspects", - "road", - "travel", - "giving", - "access", - "personal", - "transport", - "difficulty", - "driving", - "V14", - "offers", - "unparalleled", - "driver", - "assistance", - "safely", - "drive", - "destination", - "parking", - "spot", - "park", - "location", - "global", - "fleet", - "collect", - "equivalent", - "500", - "years", - "continuous", - "day", - "deploy", - "scale", - "capabilities", - "handle", - "long", - "fat", - "tail", - "corner", - "cases", - "diverse", - "geographies", - "environments", - "Cumulative", - "Miles", - "Driven", - "billions", - "Development", - "house", - "custom", - "designed", - "AI5", - "AI6", - "inference", - "chips", - "autonomy", - "progressed", - "quarter", - "production", - "planned", - "2027", - "2028", - "targeting", - "50x", - "improvement", - "performance", - "relative", - "AI4", - "10x", - "raw", - "compute", - "9x", - "memory", - "capacity", - "5x", - "hardened", - "block", - "quantization", - "softmax", - "function", - "enabling", - "efficient", - "low", - "precision", - "computing", - "sacrificing", - "accuracy", - "Targeting", - "Step", - "Function", - "Improvement", - "Next", - "Generation", - "Inference", - "Chip", - "AI50", - "Active", - "supervision", - "required", - "make", - "vehicle", - "autonomous", - "Calculated", - "hours", - "average", - "30", - "miles", - "hour" - ], - "keywords": [ - "Robotaxi", - "FSD", - "AI Chips" - ], - "connect_to": [ - { - "target": "e4a241cf-298e-51c1-a928-4c6d21840920", - "relation": "embeds", - "ref": "[images/image-2-FSD Mileage Growth.jpg]", - "position": { - "start": 1547, - "end": 1586 - } - }, - { - "target": "1bbb4be7-849a-557b-86de-9d71a3e33ef0", - "relation": "embeds", - "ref": "[images/image-3-Tesla Silicon Optimization.jpg]", - "position": { - "start": 2176, - "end": 2223 - } - } - ] - } - }, - { - "chunk_id": "41533301-58f0-554f-916d-8254cc2700df", - "type": "text", - "content": "We began testing driverless Robotaxis in Austin in December and began removing the safety monitor from customer rides in January on a limited basis, which will unlock further expansion of our Robotaxi fleet and coverage area in the Austin-metro. Our Bay Area ride-hailing service began serving the San Jose Airport in October, with plans to expand to other major airports in the Bay Area upon receiving required permitting.", - "path": "Default_Root/TSLA-Q4-2025-Update(1).pdf-->SUMMARY-->Robotaxi", - "metadata": { - "length": 423, - "summary": "", - "page_nums": [ - 10 - ], - "document_top_summary": "This document includes:", - "tokens": [ - "We", - "began", - "testing", - "driverless", - "Robotaxis", - "Austin", - "December", - "removing", - "safety", - "monitor", - "customer", - "rides", - "January", - "limited", - "basis", - "unlock", - "expansion", - "Robotaxi", - "fleet", - "coverage", - "area", - "metro", - "Our", - "Bay", - "Area", - "ride", - "hailing", - "service", - "serving", - "San", - "Jose", - "Airport", - "October", - "plans", - "expand", - "major", - "airports", - "receiving", - "required", - "permitting" - ], - "keywords": [], - "connect_to": [] - } - }, - { - "chunk_id": "3968a25f-933e-5a7f-a118-07db2e581153", - "type": "text", - "content": "We launched FSD (Supervised) $^{1}$ in South Korea, where customers drove over 1 million kilometers using the software in just one month. While we continue to pursue regulatory approval in China and Europe, we began offering ride-along experiences to consumers in Italy, Germany, France and Switzerland.\nMonthly subscriptions to FSD (Supervised) $^{1}$ continued to grow sequentially and more than doubled in 2025. Starting this quarter, we are transitioning access to FSD (Supervised) $^{1}$ to monthly subscriptions only as we begin to sunset the up-front payment option.", - "path": "Default_Root/TSLA-Q4-2025-Update(1).pdf-->SUMMARY-->FSD (Supervised) $^{1}$", - "metadata": { - "length": 573, - "summary": "", - "page_nums": [ - 10 - ], - "document_top_summary": "This document includes:", - "tokens": [ - "We", - "launched", - "FSD", - "Supervised", - "South", - "Korea", - "customers", - "drove", - "million", - "kilometers", - "software", - "month", - "While", - "continue", - "pursue", - "regulatory", - "approval", - "China", - "Europe", - "began", - "offering", - "ride", - "experiences", - "consumers", - "Italy", - "Germany", - "France", - "Switzerland", - "Monthly", - "subscriptions", - "continued", - "grow", - "sequentially", - "doubled", - "2025", - "Starting", - "quarter", - "transitioning", - "access", - "monthly", - "begin", - "sunset", - "front", - "payment", - "option" - ], - "keywords": [], - "connect_to": [] - } - }, - { - "chunk_id": "76c2f1a2-0b45-52a1-9708-e230345c7fae", - "type": "image", - "content": "\n## FSD (Supervised) $^{1}$ Cumulative Paid Robotaxi Miles0\n[images/image-4-Growth Trend 2025.jpg]\n", - "path": "images/image-4-Growth Trend 2025.jpg", - "metadata": { - "length": 92, - "summary": "image-4\nThe chart illustrates a steady increase in values over time, starting from June 2025 and extending through December 2025. The data shows minimal growth initially, followed by a significant upward trajectory beginning around August, reaching its peak at the end of the year.", - "page_nums": [ - 10 - ], - "document_top_summary": "This document includes:", - "file_path": "images/image-4-Growth Trend 2025.jpg", - "keywords": [], - "tokens": [] - } - }, - { - "chunk_id": "d4b47369-e206-5d49-afdd-db918ddee012", - "type": "table", - "content": "
StateMetroStatus
CaliforniaSF Bay AreaSafety Driver
TexasAustinRamping Unsupervised
Dallas1H 2026
Houston1H 2026
ArizonaPhoenix1H 2026
FloridaMiami1H 2026
Orlando1H 2026
Tampa1H 2026
NevadaLas Vegas1H 2026
", - "path": "tables/table-7 Autonomous Driving Status.html", - "metadata": { - "length": 571, - "summary": "table-8\nTable lists US states and metro areas with autonomous driving status. California SF Bay Area has safety drivers. Texas Austin is ramping unsupervised. Other locations like Dallas, Houston, Phoenix, Miami, Orlando, Tampa, and Las Vegas are scheduled for 1H 2026.", - "page_nums": [ - 10 - ], - "document_top_summary": "This document includes:", - "file_path": "tables/table-7 Autonomous Driving Status.html", - "keywords": [ - "safety driver", - "ramping", - "2026 plans" - ], - "tokens": [] - } - }, - { - "chunk_id": "adf89909-d366-51e1-b68c-459f9024191c", - "type": "text", - "content": "Services and Other gross profit of approximately \\$300 million was partly driven by Part Sales and Supercharging. We now offer Tesla Insurance in Florida, as we continue to expand our insurance product to new states. In certain states, customers receive a discount on their insurance premiums when using FSD (Supervised) $^{1}$ . The more you drive with FSD (Supervised) $^{1}$ enabled, the bigger the discount is on your insurance premium – helping, in certain cases, to completely offset the monthly subscription cost for FSD (Supervised) $^{1}$ .\n\n## FSD (Supervised) $^{1}$ Cumulative Paid Robotaxi Miles0\n[images/image-4-Growth Trend 2025.jpg]\n\nCumulative Paid Robotaxi Miles\n\n[tables/table-7 Autonomous Driving Status.html]\n\nPlanned Robotaxi Coverage", - "path": "Default_Root/TSLA-Q4-2025-Update(1).pdf-->SUMMARY-->Automotive Services", - "metadata": { - "length": 742, - "summary": "", - "page_nums": [ - 10, - 12 - ], - "document_top_summary": "This document includes:", - "tokens": [ - "Services", - "Other", - "gross", - "profit", - "approximately", - "300", - "million", - "partly", - "driven", - "Part", - "Sales", - "Supercharging", - "We", - "offer", - "Tesla", - "Insurance", - "Florida", - "continue", - "expand", - "insurance", - "product", - "states", - "In", - "customers", - "receive", - "discount", - "premiums", - "FSD", - "Supervised", - "The", - "drive", - "enabled", - "bigger", - "premium", - "helping", - "cases", - "completely", - "offset", - "monthly", - "subscription", - "cost", - "Cumulative", - "Paid", - "Robotaxi", - "Miles0", - "Miles", - "Planned", - "Coverage" - ], - "keywords": [], - "connect_to": [ - { - "target": "76c2f1a2-0b45-52a1-9708-e230345c7fae", - "relation": "embeds", - "ref": "[images/image-4-Growth Trend 2025.jpg]", - "position": { - "start": 610, - "end": 648 - } - }, - { - "target": "d4b47369-e206-5d49-afdd-db918ddee012", - "relation": "embeds", - "ref": "[tables/table-7 Autonomous Driving Status.html]", - "position": { - "start": 682, - "end": 729 - } - } - ] - } - }, - { - "chunk_id": "cf91377e-176a-5768-855a-b859092f4695", - "type": "text", - "content": "On January 16, 2026, Tesla entered into an agreement to invest approximately \\$2 billion to acquire shares of Series E Preferred Stock of xAI as part of their recent publicly-disclosed financing round. Tesla’s investment was made on market terms consistent with those previously agreed to by other investors in the financing round. As set forth in Master Plan Part IV, Tesla is building products and services that bring AI into the physical world. Meanwhile, xAI is developing leading digital AI products and services, such as its large language model (Grok).\nIn that context, and as part of Tesla's broader strategy under Master Plan Part IV, Tesla and xAI also entered into a framework agreement in connection with the investment. Among other things, the framework agreement builds upon the existing relationship between Tesla and xAI by providing a framework for evaluating potential AI collaborations between the companies. Together, the investment and the related framework agreement are intended to enhance Tesla's ability to develop and deploy AI products and services into the physical world at scale. This investment is subject to customary regulatory conditions with the expectation to close in Q1'2026.", - "path": "Default_Root/TSLA-Q4-2025-Update(1).pdf-->OTHER UPDATES", - "metadata": { - "length": 1213, - "summary": "", - "page_nums": [ - 12, - 13 - ], - "document_top_summary": "This document includes:", - "tokens": [ - "On", - "January", - "16", - "2026", - "Tesla", - "entered", - "agreement", - "invest", - "approximately", - "billion", - "acquire", - "shares", - "Series", - "Preferred", - "Stock", - "xAI", - "part", - "recent", - "publicly", - "disclosed", - "financing", - "round", - "investment", - "made", - "market", - "terms", - "consistent", - "previously", - "agreed", - "investors", - "As", - "set", - "Master", - "Plan", - "Part", - "IV", - "building", - "products", - "services", - "bring", - "AI", - "physical", - "world", - "Meanwhile", - "developing", - "leading", - "digital", - "large", - "language", - "model", - "Grok", - "In", - "context", - "broader", - "strategy", - "framework", - "connection", - "Among", - "things", - "builds", - "existing", - "relationship", - "providing", - "evaluating", - "potential", - "collaborations", - "companies", - "Together", - "related", - "intended", - "enhance", - "ability", - "develop", - "deploy", - "scale", - "This", - "subject", - "customary", - "regulatory", - "conditions", - "expectation", - "close", - "Q1" - ], - "keywords": [], - "connect_to": [] - } - }, - { - "chunk_id": "79b23d73-b353-5998-a646-93d6739ec465", - "type": "text", - "content": "", - "path": "Default_Root/TSLA-Q4-2025-Update(1).pdf-->OUTLOOK", - "metadata": { - "length": 0, - "summary": "", - "page_nums": [ - 13 - ], - "document_top_summary": "This document includes:", - "tokens": [], - "keywords": [], - "connect_to": [] - } - }, - { - "chunk_id": "8d4c49a6-c531-5fa6-a77d-ad028537fa52", - "type": "text", - "content": "We are focused on maximum capacity utilization at our factories. Deliveries and deployments will be impacted by aggregate demand for our products, supply chain readiness and allocation decisions between sale to customers or use for our owned and operated fleet.", - "path": "Default_Root/TSLA-Q4-2025-Update(1).pdf-->OUTLOOK-->Volume", - "metadata": { - "length": 261, - "summary": "", - "page_nums": [ - 13 - ], - "document_top_summary": "This document includes:", - "tokens": [ - "We", - "focused", - "maximum", - "capacity", - "utilization", - "factories", - "Deliveries", - "deployments", - "impacted", - "aggregate", - "demand", - "products", - "supply", - "chain", - "readiness", - "allocation", - "decisions", - "sale", - "customers", - "owned", - "operated", - "fleet" - ], - "keywords": [], - "connect_to": [] - } - }, - { - "chunk_id": "426e5119-3ef6-5176-9119-fc044eb90be7", - "type": "text", - "content": "We will manage the businesses such that we ensure a strong balance sheet, maintaining sufficient liquidity to fund our product roadmap, long-term capacity expansion plans – including further vertical integration – and other expenses.", - "path": "Default_Root/TSLA-Q4-2025-Update(1).pdf-->OUTLOOK-->Cash", - "metadata": { - "length": 233, - "summary": "", - "page_nums": [ - 13 - ], - "document_top_summary": "This document includes:", - "tokens": [ - "We", - "manage", - "businesses", - "ensure", - "strong", - "balance", - "sheet", - "maintaining", - "sufficient", - "liquidity", - "fund", - "product", - "roadmap", - "long", - "term", - "capacity", - "expansion", - "plans", - "including", - "vertical", - "integration", - "expenses" - ], - "keywords": [], - "connect_to": [] - } - }, - { - "chunk_id": "99f54247-8409-5df2-8299-39184863cd09", - "type": "text", - "content": "While we continue to execute on innovations to reduce the cost of manufacturing and operations, over time, we expect our hardware-related profits to be accompanied by an acceleration of AI, software and fleet-based profits.", - "path": "Default_Root/TSLA-Q4-2025-Update(1).pdf-->OUTLOOK-->Profit", - "metadata": { - "length": 223, - "summary": "", - "page_nums": [ - 13 - ], - "document_top_summary": "This document includes:", - "tokens": [ - "While", - "continue", - "execute", - "innovations", - "reduce", - "cost", - "manufacturing", - "operations", - "time", - "expect", - "hardware", - "related", - "profits", - "accompanied", - "acceleration", - "AI", - "software", - "fleet", - "based" - ], - "keywords": [], - "connect_to": [] - } - }, - { - "chunk_id": "29e89187-3dbe-5298-87c3-c38d3cbe6883", - "type": "text", - "content": "We continue to evolve and augment our product lineup with a focus on cost, scale and future monetization opportunities via services powered by our AI software. We remain focused on growing our sales volumes through a differentiated and efficiently managed product portfolio, which includes leveraging and optimizing our existing production capacity before building new factories and production lines.\nCybercab, Tesla Semi and Megapack 3 are on schedule for volume production starting in 2026. First generation production lines for Optimus are being installed in anticipation of volume production.\nPHOTOS & CHARTS", - "path": "Default_Root/TSLA-Q4-2025-Update(1).pdf-->OUTLOOK-->Product", - "metadata": { - "length": 612, - "summary": "", - "page_nums": [ - 13, - 14, - 15 - ], - "document_top_summary": "This document includes:", - "tokens": [ - "We", - "continue", - "evolve", - "augment", - "product", - "lineup", - "focus", - "cost", - "scale", - "future", - "monetization", - "opportunities", - "services", - "powered", - "AI", - "software", - "remain", - "focused", - "growing", - "sales", - "volumes", - "differentiated", - "efficiently", - "managed", - "portfolio", - "includes", - "leveraging", - "optimizing", - "existing", - "production", - "capacity", - "building", - "factories", - "lines", - "Cybercab", - "Tesla", - "Semi", - "Megapack", - "schedule", - "volume", - "starting", - "2026", - "First", - "generation", - "Optimus", - "installed", - "anticipation", - "PHOTOS", - "CHARTS" - ], - "keywords": [], - "connect_to": [] - } - }, - { - "chunk_id": "d119baec-2829-56d3-8406-fc994dfe3adf", - "type": "image", - "content": "\nCybercab, Tesla Semi and Megapack 3 are on schedule for volume production starting in 2026. First generation production lines for Optimus are being installed in anticipation of volume production. 0\n[images/image-5-Tesla Model Y Driving.jpg]\n", - "path": "images/image-5-Tesla Model Y Driving.jpg", - "metadata": { - "length": 247, - "summary": "image-5\nA sleek silver electric SUV travels along a winding highway through a scenic landscape. The vehicle is captured in motion with blurred surroundings, emphasizing speed against a backdrop of rolling hills and distant mountains under a bright sky.", - "page_nums": [ - 15 - ], - "document_top_summary": "This document includes:", - "file_path": "images/image-5-Tesla Model Y Driving.jpg", - "keywords": [], - "tokens": [] - } - }, - { - "chunk_id": "bbe9d89e-eebd-56b1-b7c0-7eda12dd3377", - "type": "text", - "content": "Cybercab, Tesla Semi and Megapack 3 are on schedule for volume production starting in 2026. First generation production lines for Optimus are being installed in anticipation of volume production. 0\n[images/image-5-Tesla Model Y Driving.jpg]", - "path": "Default_Root/TSLA-Q4-2025-Update(1).pdf-->OUTLOOK-->Product-->MODEL Y - 2025 BEST IN CLASS EURO NCAP $^{(1)}$ SMALL SUV", - "metadata": { - "length": 245, - "summary": "", - "page_nums": [ - 15, - 16 - ], - "document_top_summary": "This document includes:", - "tokens": [ - "Cybercab", - "Tesla", - "Semi", - "Megapack", - "schedule", - "volume", - "production", - "starting", - "2026", - "First", - "generation", - "lines", - "Optimus", - "installed", - "anticipation", - "page", - "16" - ], - "keywords": [], - "connect_to": [ - { - "target": "d119baec-2829-56d3-8406-fc994dfe3adf", - "relation": "embeds", - "ref": "[images/image-5-Tesla Model Y Driving.jpg]", - "position": { - "start": 214, - "end": 256 - } - } - ] - } - }, - { - "chunk_id": "f62cb7da-1f39-5ea6-a2f4-21ff5f5ff0d1", - "type": "image", - "content": "\n 0\n[images/image-6-Red Tesla on Coastal Road.jpg]\n", - "path": "images/image-6-Red Tesla on Coastal Road.jpg", - "metadata": { - "length": 68, - "summary": "image-6\nA red electric sedan drives along a winding asphalt road carved into a steep, rocky mountainside. The vehicle is captured in motion with a blurred background, emphasizing speed as it navigates the curve. To the right of the road lies a calm body of blue water, while the left side features a rugged cliff face covered in sparse green vegetation under a clear sky.", - "page_nums": [ - 16 - ], - "document_top_summary": "This document includes:", - "file_path": "images/image-6-Red Tesla on Coastal Road.jpg", - "keywords": [], - "tokens": [] - } - }, - { - "chunk_id": "02f7e756-798f-5675-9dd9-6ce9123f92d4", - "type": "text", - "content": " 0\n[images/image-6-Red Tesla on Coastal Road.jpg]", - "path": "Default_Root/TSLA-Q4-2025-Update(1).pdf-->OUTLOOK-->Product-->MODEL 3 - 2025 BEST IN CLASS EURO NCAP $^{(1)}$ LARGE FAMILY CAR", - "metadata": { - "length": 66, - "summary": "", - "page_nums": [ - 16, - 17 - ], - "document_top_summary": "This document includes:", - "tokens": [ - "page", - "15", - "17" - ], - "keywords": [], - "connect_to": [ - { - "target": "f62cb7da-1f39-5ea6-a2f4-21ff5f5ff0d1", - "relation": "embeds", - "ref": "[images/image-6-Red Tesla on Coastal Road.jpg]", - "position": { - "start": 35, - "end": 81 - } - } - ] - } - }, - { - "chunk_id": "ca26e264-c569-50b1-bef6-8cee03a027f2", - "type": "image", - "content": "\n 0\n[images/image-7-Tesla Interior Interface.jpg]\n", - "path": "images/image-7-Tesla Interior Interface.jpg", - "metadata": { - "length": 68, - "summary": "image-7\nThe image displays the interior of a Tesla vehicle, focusing on the driver's perspective. A person is interacting with the large central touchscreen display, which shows navigation maps and vehicle controls. The steering wheel features the Tesla logo, and ambient lighting accents are visible along the dashboard. Through the windshield, a modern stone building is seen outside.", - "page_nums": [ - 17 - ], - "document_top_summary": "This document includes:", - "file_path": "images/image-7-Tesla Interior Interface.jpg", - "keywords": [], - "tokens": [] - } - }, - { - "chunk_id": "494118dd-5788-526b-a139-407d1cfb0d20", - "type": "text", - "content": " 0\n[images/image-7-Tesla Interior Interface.jpg]", - "path": "Default_Root/TSLA-Q4-2025-Update(1).pdf-->OUTLOOK-->Product-->FSD (SUPERVISED) $^{1}$ – V14 OFFERS UNPARALLELED DRIVER ASSISTANCE", - "metadata": { - "length": 66, - "summary": "", - "page_nums": [ - 17, - 18 - ], - "document_top_summary": "This document includes:", - "tokens": [ - "page", - "16", - "18" - ], - "keywords": [], - "connect_to": [ - { - "target": "ca26e264-c569-50b1-bef6-8cee03a027f2", - "relation": "embeds", - "ref": "[images/image-7-Tesla Interior Interface.jpg]", - "position": { - "start": 35, - "end": 80 - } - } - ] - } - }, - { - "chunk_id": "47fd2c58-a064-544f-ac09-2ddd83eeb248", - "type": "image", - "content": "\n 0\n[images/image-8-Tesla Interior.jpg]\n", - "path": "images/image-8-Tesla Interior.jpg", - "metadata": { - "length": 68, - "summary": "image-8\nThe image displays the driver's perspective inside a Tesla vehicle, featuring a minimalist dashboard with a large central touchscreen. The steering wheel is visible on the left side, and the car appears to be in motion on a city street during daylight hours.", - "page_nums": [ - 18 - ], - "document_top_summary": "This document includes:", - "file_path": "images/image-8-Tesla Interior.jpg", - "keywords": [], - "tokens": [] - } - }, - { - "chunk_id": "6ac7765a-4da5-573a-963b-f35ab3796f6f", - "type": "text", - "content": " 0\n[images/image-8-Tesla Interior.jpg]", - "path": "Default_Root/TSLA-Q4-2025-Update(1).pdf-->OUTLOOK-->Product-->DRIVERLESS ROBOTAXI - TESTING IN AUSTIN", - "metadata": { - "length": 66, - "summary": "", - "page_nums": [ - 18, - 19 - ], - "document_top_summary": "This document includes:", - "tokens": [ - "page", - "17", - "19" - ], - "keywords": [], - "connect_to": [ - { - "target": "47fd2c58-a064-544f-ac09-2ddd83eeb248", - "relation": "embeds", - "ref": "[images/image-8-Tesla Interior.jpg]", - "position": { - "start": 35, - "end": 70 - } - } - ] - } - }, - { - "chunk_id": "04fc84c0-3bc5-50c2-a7d8-40b4f22a257e", - "type": "image", - "content": "\n 0\n[images/image-9-Tesla Cybertruck in Snow.jpg]\n", - "path": "images/image-9-Tesla Cybertruck in Snow.jpg", - "metadata": { - "length": 68, - "summary": "image-9\nA futuristic electric pickup truck is shown driving on a frozen, snow-covered surface. The vehicle features its signature angular design and metallic finish, with snow clinging to the rear bumper and wheel wells. It is set against a backdrop of distant mountains under a twilight sky.", - "page_nums": [ - 19 - ], - "document_top_summary": "This document includes:", - "file_path": "images/image-9-Tesla Cybertruck in Snow.jpg", - "keywords": [], - "tokens": [] - } - }, - { - "chunk_id": "4ebd8699-3d0c-523d-8288-9b7b5b6853aa", - "type": "image", - "content": "\n### DRIVERLESS ROBOTAXI - TESTING IN AUSTIN 0\n[images/image-10-Tesla Semi Trucks.jpg]\n", - "path": "images/image-10-Tesla Semi Trucks.jpg", - "metadata": { - "length": 96, - "summary": "image-10\nTwo white electric semi-trucks are parked side-by-side in an outdoor lot. The vehicles feature a futuristic, aerodynamic design with large windshields and distinctive horizontal headlights. They are positioned against a backdrop of industrial buildings and hills under a cloudy sky.", - "page_nums": [ - 19 - ], - "document_top_summary": "This document includes:", - "file_path": "images/image-10-Tesla Semi Trucks.jpg", - "keywords": [], - "tokens": [] - } - }, - { - "chunk_id": "bda2b508-b228-5ab9-b619-775c024822fa", - "type": "image", - "content": "\n### CYBERCAB - COLD WEATHER TESTING IN ALASKA 0\n[images/image-11-US Lightning Map.jpg]\n", - "path": "images/image-11-US Lightning Map.jpg", - "metadata": { - "length": 98, - "summary": "image-11\nA map of the United States displays numerous red markers with lightning symbols. These indicators are concentrated heavily along the West Coast, particularly in California, and throughout Texas. Additional clusters appear in the Southeast near Atlanta and scattered locations in the Midwest and Northeast.", - "page_nums": [ - 35 - ], - "document_top_summary": "This document includes:", - "file_path": "images/image-11-US Lightning Map.jpg", - "keywords": [], - "tokens": [] - } - }, - { - "chunk_id": "4cfe7ce9-5f93-5816-9505-ba562683ee42", - "type": "text", - "content": " 0\n[images/image-9-Tesla Cybertruck in Snow.jpg]\n\n\n### DRIVERLESS ROBOTAXI - TESTING IN AUSTIN 0\n[images/image-10-Tesla Semi Trucks.jpg]\n\nTESLA SEMI - MEGACHARGER NETWORK PLANNED SITES FOR 2026\n\n### CYBERCAB - COLD WEATHER TESTING IN ALASKA 0\n[images/image-11-US Lightning Map.jpg]", - "path": "Default_Root/TSLA-Q4-2025-Update(1).pdf-->OUTLOOK-->Product-->CYBERCAB - COLD WEATHER TESTING IN ALASKA", - "metadata": { - "length": 318, - "summary": "", - "page_nums": [ - 19, - 22, - 35 - ], - "document_top_summary": "This document includes:", - "tokens": [ - "page", - "18", - "35", - "DRIVERLESS", - "ROBOTAXI", - "TESTING", - "IN", - "AUSTIN", - "TESLA", - "SEMI", - "MEGACHARGER", - "NETWORK", - "PLANNED", - "SITES", - "FOR", - "2026", - "CYBERCAB", - "COLD", - "WEATHER", - "ALASKA", - "22" - ], - "keywords": [], - "connect_to": [ - { - "target": "04fc84c0-3bc5-50c2-a7d8-40b4f22a257e", - "relation": "embeds", - "ref": "[images/image-9-Tesla Cybertruck in Snow.jpg]", - "position": { - "start": 35, - "end": 80 - } - }, - { - "target": "4ebd8699-3d0c-523d-8288-9b7b5b6853aa", - "relation": "embeds", - "ref": "[images/image-10-Tesla Semi Trucks.jpg]", - "position": { - "start": 145, - "end": 184 - } - }, - { - "target": "bda2b508-b228-5ab9-b619-775c024822fa", - "relation": "embeds", - "ref": "[images/image-11-US Lightning Map.jpg]", - "position": { - "start": 307, - "end": 345 - } - } - ] - } - }, - { - "chunk_id": "bf02caea-5069-5866-9a6c-5f0ff28c981b", - "type": "image", - "content": "\n 0\n[images/image-12-Tesla Factory Milestone.jpg]\n", - "path": "images/image-12-Tesla Factory Milestone.jpg", - "metadata": { - "length": 69, - "summary": "image-12\nFactory workers and staff gather for a group photo on an automotive assembly line to celebrate the production of the 900th vehicle. A white car is positioned centrally in front of the crowd, while employees hold silver balloons displaying the number \"900\" to mark this significant manufacturing achievement.", - "page_nums": [ - 22 - ], - "document_top_summary": "This document includes:", - "file_path": "images/image-12-Tesla Factory Milestone.jpg", - "keywords": [], - "tokens": [] - } - }, - { - "chunk_id": "07535539-a2f9-5b79-ac6b-d41cf785ec88", - "type": "text", - "content": " 0\n[images/image-12-Tesla Factory Milestone.jpg]", - "path": "Default_Root/TSLA-Q4-2025-Update(1).pdf-->OUTLOOK-->Product-->GIGAFACTORY SHANGHAI - 9 MILLIONTH VEHICLE PRODUCED (GLOBALLY)", - "metadata": { - "length": 67, - "summary": "", - "page_nums": [ - 22, - 23 - ], - "document_top_summary": "This document includes:", - "tokens": [ - "page", - "35", - "23" - ], - "keywords": [], - "connect_to": [ - { - "target": "bf02caea-5069-5866-9a6c-5f0ff28c981b", - "relation": "embeds", - "ref": "[images/image-12-Tesla Factory Milestone.jpg]", - "position": { - "start": 35, - "end": 80 - } - } - ] - } - }, - { - "chunk_id": "a860be78-1920-5ae1-996e-40c19fc83386", - "type": "image", - "content": "\n 0\n[images/image-13-Tesla Factory Milestone.jpg]\n", - "path": "images/image-13-Tesla Factory Milestone.jpg", - "metadata": { - "length": 69, - "summary": "image-13\nA large group of factory workers gathers inside a manufacturing facility to celebrate a significant achievement. Several employees in the front row hold up oversized gold balloons that spell out \"600,000,\" marking a major production milestone for the company. The background reveals an industrial setting filled with machinery and assembly lines.", - "page_nums": [ - 23 - ], - "document_top_summary": "This document includes:", - "file_path": "images/image-13-Tesla Factory Milestone.jpg", - "keywords": [], - "tokens": [] - } - }, - { - "chunk_id": "b356395d-c199-500b-9879-6c7973de3bba", - "type": "image", - "content": "\n### GIGAFACTORY SHANGHAI - 9 MILLIONTH VEHICLE PRODUCED (GLOBALLY) 0\n[images/image-14-Vehicle Delivery Trends.jpg]\n", - "path": "images/image-14-Vehicle Delivery Trends.jpg", - "metadata": { - "length": 119, - "summary": "image-14\nA bar chart displays quarterly vehicle delivery volumes in millions of units spanning from the first quarter of 2023 through the fourth quarter of 2025. The data illustrates fluctuating delivery figures across the timeline, with values generally ranging between approximately 0.3 and 0.5 million units per quarter.", - "page_nums": [ - 23 - ], - "document_top_summary": "This document includes:", - "file_path": "images/image-14-Vehicle Delivery Trends.jpg", - "keywords": [], - "tokens": [] - } - }, - { - "chunk_id": "10b8881e-6d2c-5737-8ffa-b75db9f1c850", - "type": "image", - "content": "\n 0\n[images/image-15-Quarterly Cash Flow.jpg]\n", - "path": "images/image-15-Quarterly Cash Flow.jpg", - "metadata": { - "length": 69, - "summary": "image-15\nThe chart compares operating cash flow and free cash flow across multiple quarters from 2023 through 2025. Blue bars represent operating cash flow, while red bars indicate free cash flow. Operating cash flow remains consistently positive throughout the period, whereas free cash flow fluctuates significantly, including a notable negative value in early 2024.", - "page_nums": [ - 23 - ], - "document_top_summary": "This document includes:", - "file_path": "images/image-15-Quarterly Cash Flow.jpg", - "keywords": [], - "tokens": [] - } - }, - { - "chunk_id": "8126ecb1-7d09-5687-8290-988e8bbe8eec", - "type": "image", - "content": "\n 0\n[images/image-16-Financial Performance Chart.jpg]\n", - "path": "images/image-16-Financial Performance Chart.jpg", - "metadata": { - "length": 69, - "summary": "image-16\nThis bar chart compares Net Income and Adjusted EBITDA across quarterly periods from 2023 through 2025. The blue bars represent Net Income while the red bars indicate Adjusted EBITDA, with values measured in billions of dollars. A significant spike in Net Income is visible during the fourth quarter of 2023, whereas Adjusted EBITDA remains consistently higher than Net Income throughout most of the timeline.", - "page_nums": [ - 23 - ], - "document_top_summary": "This document includes:", - "file_path": "images/image-16-Financial Performance Chart.jpg", - "keywords": [], - "tokens": [] - } - }, - { - "chunk_id": "702bff82-1736-584d-956e-e7bf7eac4039", - "type": "text", - "content": " 0\n[images/image-13-Tesla Factory Milestone.jpg]\n\n\n### GIGAFACTORY SHANGHAI - 9 MILLIONTH VEHICLE PRODUCED (GLOBALLY) 0\n[images/image-14-Vehicle Delivery Trends.jpg]\n\n\n 0\n[images/image-15-Quarterly Cash Flow.jpg]\n\n\n 0\n[images/image-16-Financial Performance Chart.jpg]", - "path": "Default_Root/TSLA-Q4-2025-Update(1).pdf-->OUTLOOK-->Product-->GIGAFACTORY NEVADA - 6 MILLIONTH DRIVE UNIT PRODUCED", - "metadata": { - "length": 327, - "summary": "", - "page_nums": [ - 23, - 25 - ], - "document_top_summary": "This document includes:", - "tokens": [ - "page", - "22", - "25", - "GIGAFACTORY", - "SHANGHAI", - "MILLIONTH", - "VEHICLE", - "PRODUCED", - "GLOBALLY", - "23" - ], - "keywords": [], - "connect_to": [ - { - "target": "a860be78-1920-5ae1-996e-40c19fc83386", - "relation": "embeds", - "ref": "[images/image-13-Tesla Factory Milestone.jpg]", - "position": { - "start": 35, - "end": 80 - } - }, - { - "target": "b356395d-c199-500b-9879-6c7973de3bba", - "relation": "embeds", - "ref": "[images/image-14-Vehicle Delivery Trends.jpg]", - "position": { - "start": 168, - "end": 213 - } - }, - { - "target": "10b8881e-6d2c-5737-8ffa-b75db9f1c850", - "relation": "embeds", - "ref": "[images/image-15-Quarterly Cash Flow.jpg]", - "position": { - "start": 251, - "end": 292 - } - }, - { - "target": "8126ecb1-7d09-5687-8290-988e8bbe8eec", - "relation": "embeds", - "ref": "[images/image-16-Financial Performance Chart.jpg]", - "position": { - "start": 330, - "end": 379 - } - } - ] - } - }, - { - "chunk_id": "2504a912-1296-5922-a39b-5aa2f2634d75", - "type": "image", - "content": "\n 0\n[images/image-17-Projected Vehicle Deliveries.jpg]\n", - "path": "images/image-17-Projected Vehicle Deliveries.jpg", - "metadata": { - "length": 69, - "summary": "image-17\nThe bar chart illustrates a forecast of vehicle deliveries in millions of units spanning from the first quarter of 2023 through the fourth quarter of 2025. The data indicates an upward trend starting in early 2023, reaching a peak around late 2023 and continuing at high levels throughout 2024 before showing a slight decline toward the end of the projection period in 2025.", - "page_nums": [ - 25 - ], - "document_top_summary": "This document includes:", - "file_path": "images/image-17-Projected Vehicle Deliveries.jpg", - "keywords": [], - "tokens": [] - } - }, - { - "chunk_id": "26481825-241e-53e6-b380-dbfa166ef8cd", - "type": "image", - "content": "\n 0\n[images/image-18-Cash Flow Trends.jpg]\n", - "path": "images/image-18-Cash Flow Trends.jpg", - "metadata": { - "length": 69, - "summary": "image-18\nThe chart compares operating and free cash flow across multiple quarters from 2023 through 2025. Operating cash flow is consistently higher than free cash flow throughout the timeline, with both metrics showing significant fluctuations over time.", - "page_nums": [ - 25 - ], - "document_top_summary": "This document includes:", - "file_path": "images/image-18-Cash Flow Trends.jpg", - "keywords": [], - "tokens": [] - } - }, - { - "chunk_id": "f19dcb65-d67c-51d4-a0fe-112a38f24d8c", - "type": "image", - "content": "\n 0\n[images/image-19-Financial Performance Forecast.jpg]\n", - "path": "images/image-19-Financial Performance Forecast.jpg", - "metadata": { - "length": 69, - "summary": "image-19\nThe chart displays a comparison of Net Income and Adjusted EBITDA over a multi-year period. It features paired bars for each quarter, showing that while earnings fluctuate, the adjusted metric remains consistently higher than net income throughout the timeline.", - "page_nums": [ - 25 - ], - "document_top_summary": "This document includes:", - "file_path": "images/image-19-Financial Performance Forecast.jpg", - "keywords": [], - "tokens": [] - } - }, - { - "chunk_id": "5e17588f-ea71-56df-be53-135da93fb3a0", - "type": "text", - "content": " 0\n[images/image-17-Projected Vehicle Deliveries.jpg]\n\n\n 0\n[images/image-18-Cash Flow Trends.jpg]\n\n\n 0\n[images/image-19-Financial Performance Forecast.jpg]", - "path": "Default_Root/TSLA-Q4-2025-Update(1).pdf-->OUTLOOK-->KEY METRICS TRAILING 12 MONTHS (TTM) (Unaudited)", - "metadata": { - "length": 207, - "summary": "", - "page_nums": [ - 25, - 26 - ], - "document_top_summary": "This document includes:", - "tokens": [ - "page", - "25", - "26" - ], - "keywords": [], - "connect_to": [ - { - "target": "2504a912-1296-5922-a39b-5aa2f2634d75", - "relation": "embeds", - "ref": "[images/image-17-Projected Vehicle Deliveries.jpg]", - "position": { - "start": 35, - "end": 85 - } - }, - { - "target": "26481825-241e-53e6-b380-dbfa166ef8cd", - "relation": "embeds", - "ref": "[images/image-18-Cash Flow Trends.jpg]", - "position": { - "start": 123, - "end": 161 - } - }, - { - "target": "f19dcb65-d67c-51d4-a0fe-112a38f24d8c", - "relation": "embeds", - "ref": "[images/image-19-Financial Performance Forecast.jpg]", - "position": { - "start": 199, - "end": 251 - } - } - ] - } - }, - { - "chunk_id": "c82a6fed-f085-5ec3-b10a-0381723fa3c9", - "type": "text", - "content": "Total quarterly revenue decreased 3% YoY to \\$24.9B. YoY, revenue was impacted by the following items $^{(1)}$ :\n- decrease in vehicle deliveries\n- lower regulatory credit revenue\n+ growth in Energy Generation and Storage\n+ growth in Services and Other\n+ positive FX impact of \\$0.3B $^{1}$\n\\+ growth in other automotive ancillary sales, partly driven by an increase in FSD subscriptions\n\\+ higher vehicle average selling price (ASP) (excl. FX impact $^{1}$ ), inclusive of mix impact", - "path": "Default_Root/TSLA-Q4-2025-Update(1).pdf-->OUTLOOK-->KEY METRICS TRAILING 12 MONTHS (TTM) (Unaudited)-->Revenue", - "metadata": { - "length": 484, - "summary": "", - "page_nums": [ - 26 - ], - "document_top_summary": "This document includes:", - "tokens": [ - "Total", - "quarterly", - "revenue", - "decreased", - "3%", - "YoY", - "24.9", - "impacted", - "items", - "decrease", - "vehicle", - "deliveries", - "lower", - "regulatory", - "credit", - "growth", - "Energy", - "Generation", - "Storage", - "Services", - "Other", - "positive", - "FX", - "impact", - "0.3", - "automotive", - "ancillary", - "sales", - "partly", - "driven", - "increase", - "FSD", - "subscriptions", - "higher", - "average", - "selling", - "price", - "ASP", - "excl", - "inclusive", - "mix" - ], - "keywords": [], - "connect_to": [] - } - }, - { - "chunk_id": "946fabf6-000f-557a-9d81-0b8c1a25aafb", - "type": "text", - "content": "Our quarterly operating income decreased 11% YoY to \\$1.4B, resulting in a 5.7% operating margin. YoY, operating income was primarily impacted by the following items $^{(1)}$ :\n- increase in SBC and Restructuring and Other charges\n- increase in operating expenses (excl. SBC and Restructuring and Other) driven by AI and other R&D projects and SG&A\n- higher average cost per vehicle due to lower fixed cost absorption for certain models and an increase in tariffs\n- decrease in vehicle deliveries\n- lower regulatory credit revenue\n+ higher vehicle average gross profit due to mix and pricing impacts\n+ growth in Energy Generation and Storage gross profit\n+ growth in Services and Other gross profit\n+ growth in other automotive ancillary sales, partly driven by an increase in FSD subscriptions", - "path": "Default_Root/TSLA-Q4-2025-Update(1).pdf-->OUTLOOK-->KEY METRICS TRAILING 12 MONTHS (TTM) (Unaudited)-->Profitability", - "metadata": { - "length": 794, - "summary": "", - "page_nums": [ - 26 - ], - "document_top_summary": "This document includes:", - "tokens": [ - "Our", - "quarterly", - "operating", - "income", - "decreased", - "11%", - "YoY", - "1.4", - "resulting", - "5.7%", - "margin", - "primarily", - "impacted", - "items", - "increase", - "SBC", - "Restructuring", - "Other", - "charges", - "expenses", - "excl", - "driven", - "AI", - "projects", - "SG", - "higher", - "average", - "cost", - "vehicle", - "due", - "lower", - "fixed", - "absorption", - "models", - "tariffs", - "decrease", - "deliveries", - "regulatory", - "credit", - "revenue", - "gross", - "profit", - "mix", - "pricing", - "impacts", - "growth", - "Energy", - "Generation", - "Storage", - "Services", - "automotive", - "ancillary", - "sales", - "partly", - "FSD", - "subscriptions" - ], - "keywords": [], - "connect_to": [] - } - }, - { - "chunk_id": "001d4b8a-b264-5cae-86a5-8c419eabbec0", - "type": "text", - "content": "Quarter-end cash, cash equivalents and investments was \\$44.1B. The sequential increase of \\$2.4B was primarily the result of positive free cash flow.", - "path": "Default_Root/TSLA-Q4-2025-Update(1).pdf-->OUTLOOK-->KEY METRICS TRAILING 12 MONTHS (TTM) (Unaudited)-->Cash", - "metadata": { - "length": 150, - "summary": "", - "page_nums": [ - 26, - 27 - ], - "document_top_summary": "This document includes:", - "tokens": [ - "Quarter", - "end", - "cash", - "equivalents", - "investments", - "44.1", - "The", - "sequential", - "increase", - "2.4", - "primarily", - "result", - "positive", - "free", - "flow" - ], - "keywords": [], - "connect_to": [] - } - }, - { - "chunk_id": "3ff046c6-93f4-5d22-a6af-11ecf2660ce3", - "type": "table", - "content": "
In millions of USD or shares as applicable, except per share dataQ4-2024Q1-2025Q2-2025Q3-2025Q4-2025
REVENUES
Automotive sales18,65912,92515,78720,35916,750
Automotive regulatory credits692595439417542
Automotive leasing447447435429401
Total automotive revenues19,79813,96716,66121,20517,693
Energy generation and storage3,0612,7302,7893,4153,837
Services and other2,8482,6383,0463,4753,371
Total revenues25,70719,33522,49628,09524,901
COST OF REVENUES
Automotive sales16,26811,46113,56717,36513,874
Automotive leasing242239228225206
Total automotive cost of revenues16,51011,70013,79517,59014,080
Energy generation and storage2,2891,9451,9432,3422,739
Services and other2,7292,5372,8803,1093,073
Total cost of revenues21,52816,18218,61823,04119,892
Gross profit4,1793,1533,8785,0545,009
OPERATING EXPENSES
Research and development1,2761,4091,5891,6301,783
Selling, general and administrative1,3131,2511,3661,5621,655
Restructuring and other794238162
Total operating expenses2,5962,7542,9553,4303,600
INCOME FROM OPERATIONS1,5833999231,6241,409
Interest income442400392439449
Interest expense(96)(91)(86)(76)(85)
Other income (expense), net (1)595(119)320(28)(592)
INCOME BEFORE INCOME TAXES (1)2,5245891,5491,9591,181
Provision for income taxes (1)381169359570325
NET INCOME (1)2,1434201,1901,389856
Net income attributable to noncontrolling interests and redeemable noncontrolling interests in subsidiaries1511181616
NET INCOME ATTRIBUTABLE TO COMMON STOCKHOLDERS (1)2,1284091,1721,373840
Less: Buy-out of noncontrolling interest3
NET INCOME USED IN COMPUTING NET INCOME PER SHARE OF COMMON STOCK (1)2,1254091,1721,373840
Net income per share of common stock attributable to common stockholders
Basic (1)$ 0.66$ 0.13$ 0.36$ 0.43$ 0.26
Diluted (1)$ 0.60$ 0.12$ 0.33$ 0.39$ 0.24
Weighted average shares used in computing net income per share of common stock
Basic3,2133,2183,2233,2273,231
Diluted3,5173,5213,5193,5263,539
", - "path": "tables/table-8 Q4 2024-Q4 2025 Rev.html", - "metadata": { - "length": 4195, - "summary": "table-9\nFinancial table showing quarterly revenues, costs, and net income from Q4 2024 to Q4 2025 in millions USD.", - "page_nums": [ - 27 - ], - "document_top_summary": "This document includes:", - "file_path": "tables/table-8 Q4 2024-Q4 2025 Rev.html", - "keywords": [ - "revenue", - "expenses", - "income" - ], - "tokens": [] - } - }, - { - "chunk_id": "dc7fe1d0-57ac-514a-a80a-c46825591b4c", - "type": "table", - "content": "
In millions of USD31-Dec-2431-Mar-2530-Jun-2530-Sep-2531-Dec-25
ASSETS
Current assets
Cash, cash equivalents and investments36,56336,99636,78241,64744,059
Accounts receivable, net4,4183,7823,8384,7034,576
Inventory12,01713,70614,57012,27612,392
Prepaid expenses and other current assets5,3624,9055,9436,0277,615
Total current assets58,36059,38961,13364,65368,642
Operating lease vehicles, net5,5815,4775,2305,0194,912
Energy generation and storage systems, net4,9244,8554,7884,6734,604
Property, plant and equipment, net35,83637,08838,57439,40740,643
Operating lease right-of-use assets5,1605,3305,6335,7836,027
Digital assets (2)1,0769511,2351,3151,008
Deferred tax assets (2)6,5246,6876,7216,6376,925
Other non-current assets4,6095,3345,2536,2485,045
Total assets (2)122,070125,111128,567133,735137,806
LIABILITIES AND EQUITY
Current liabilities
Accounts payable12,47413,47113,21212,81913,371
Accrued liabilities and other10,72310,80211,51912,79113,279
Deferred revenue3,1683,2433,2373,7563,424
Current portion of debt and finance leases (1)2,4562,2372,0401,9241,640
Total current liabilities28,82129,75330,00831,29031,714
Debt and finance leases, net of current portion (1)5,7575,2925,1805,7786,736
Deferred revenue, net of current portion3,3173,6103,7643,7463,631
Other long-term liabilities10,49511,03811,54312,20512,860
Total liabilities48,39049,69350,49553,01954,941
Redeemable noncontrolling interests in subsidiaries6362615958
Total stockholders' equity (2)72,91374,65377,31479,97082,137
Noncontrolling interests in subsidiaries704703697687670
Total liabilities and equity (2)122,070125,111128,567133,735137,806
(1) Breakdown of our debt is as follows:
Non-recourse debt7,8717,2386,9537,4588,150
Recourse debt76333
Days sales outstanding1419151417
Days payable outstanding5872655261
", - "path": "tables/table-9 Balance Sheet 2024-25.html", - "metadata": { - "length": 3909, - "summary": "table-10\nFinancial table showing assets, liabilities, and equity from Dec 2024 to Dec 2025. Total assets grew from 122B to 138B USD.", - "page_nums": [ - 27 - ], - "document_top_summary": "This document includes:", - "file_path": "tables/table-9 Balance Sheet 2024-25.html", - "keywords": [ - "assets", - "liabilities", - "equity" - ], - "tokens": [] - } - }, - { - "chunk_id": "0bbfd678-18cc-5152-8775-a4c805e47d57", - "type": "table", - "content": "
In millions of USDQ4-2024Q1-2025Q2-2025Q3-2025Q4-2025
CASH FLOWS FROM OPERATING ACTIVITIES
Net income (1)2,1434201,1901,389856
Adjustments to reconcile net income to net cash provided by operating activities:
Depreciation, amortization and impairment1,4961,4471,4331,6251,643
Stock-based compensation579573635663954
Deferred income taxes (1)6(43)52225(111)
Digital assets (gain) loss, net (1)(347)125(284)(80)307
Other(93)188187333378
Changes in operating assets and liabilities1030(554)(673)2,083(214)
Net cash provided by operating activities4,8142,1562,5406,2383,813
CASH FLOWS FROM INVESTING ACTIVITIES
Capital expenditures (2)(2,780)(1,492)(2,394)(2,248)(2,393)
Purchases of investments(15,158)(6,015)(7,485)(11,402)(12,207)
Proceeds from maturities of investments10,3355,8566,9359,2958,072
Net cash used in investing activities(7,603)(1,651)(2,944)(4,355)(6,528)
CASH FLOWS FROM FINANCING ACTIVITIES
Net cash flows from other debt activities(108)(50)(23)410963
Net borrowings (repayments) under vehicle and energy product financing677(674)(400)81(377)
Net cash flows from noncontrolling interests – Solar(37)(22)(14)(20)(22)
Other453414215512146
Net cash provided by (used in) financing activities985(332)(222)983710
Effect of exchange rate changes on cash and cash equivalents and restricted cash(133)40111(17)37
Net (decrease) increase in cash and cash equivalents and restricted cash(1,937)213(515)2,849(1,968)
Cash and cash equivalents and restricted cash at beginning of period18,97417,03717,25016,73519,584
Cash and cash equivalents and restricted cash at end of period17,03717,25016,73519,58417,616
", - "path": "tables/table-10 Cash Flow Q4-25.html", - "metadata": { - "length": 3137, - "summary": "table-11\nTable shows quarterly cash flows from operating, investing, and financing activities in millions USD for Q4 2024 to Q4 2025.", - "page_nums": [ - 27 - ], - "document_top_summary": "This document includes:", - "file_path": "tables/table-10 Cash Flow Q4-25.html", - "keywords": [ - "cash flow", - "operating", - "investing" - ], - "tokens": [] - } - }, - { - "chunk_id": "675abba3-3bcf-5b5d-94dd-a9e9a711e78b", - "type": "table", - "content": "
In millions of USD or shares as applicable, except per share dataQ4-2024Q1-2025Q2-2025Q3-2025Q4-2025
Net income attributable to common stockholders (GAAP) (1)2,1284091,1721,373840
Stock-based compensation expense, net of tax249428443459682
Digital assets (gain) loss, net of tax (1)(270)97(222)(62)239
Net income attributable to common stockholders (non-GAAP) (1) (2)2,1079341,3931,7701,761
Less: Buy-outs of noncontrolling interests3
Net income used in computing diluted EPS attributable to common stockholders (non-GAAP) (1) (2)2,1049341,3931,7701,761
EPS attributable to common stockholders, diluted (GAAP) (1)0.600.120.330.390.24
Stock-based compensation expense, net of tax, per share0.080.120.130.130.19
Digital assets (gain) loss, net of tax, per share (1)(0.08)0.03(0.06)(0.02)0.07
EPS attributable to common stockholders, diluted (non-GAAP) (1) (2)0.600.270.400.500.50
Shares used in EPS calculation, diluted (GAAP and non-GAAP)3,5173,5213,5193,5263,539
Net income attributable to common stockholders (GAAP) (1)2,1284091,1721,373840
Interest expense9691867685
Provision for income taxes (1)381169359570325
Depreciation, amortization and impairment1,4961,4471,4331,6251,643
Stock-based compensation expense579573635663954
Digital assets (gain) loss, net (1)(347)125(284)(80)307
Adjusted EBITDA (non-GAAP) (1) (3)4,3332,8143,4014,2274,154
Total revenues25,70719,33522,49628,09524,901
Adjusted EBITDA margin (non-GAAP) (1) (3)16.9%14.6%15.1%15.0%16.7%
Automotive gross margin (GAAP)16.6%16.2%17.2%17.0%20.4%
Less: Total regulatory credit revenue recognized3.0%3.7%2.2%1.6%2.5%
Automotive gross margin excluding regulatory credit sales (non-GAAP)13.6%12.5%15.0%15.4%17.9%
", - "path": "tables/table-11 Q4 2024-Q4 2025.html", - "metadata": { - "length": 3184, - "summary": "table-12\nFinancial table showing GAAP and non-GAAP metrics for quarters Q4-2024 through Q4-2025, including net income, EPS, adjusted EBITDA, and automotive margins.", - "page_nums": [ - 27 - ], - "document_top_summary": "This document includes:", - "file_path": "tables/table-11 Q4 2024-Q4 2025.html", - "keywords": [ - "earnings", - "EBITDA", - "margins" - ], - "tokens": [] - } - }, - { - "chunk_id": "9a6a2ecd-f489-5cf2-971a-acb1610cbef8", - "type": "table", - "content": "
In millions of USD or shares as applicable, except per share data20212022202320242025
Net income attributable to common stockholders (GAAP)5,51912,55614,9977,0913,794
Stock-based compensation expense, net of tax2,1211,5601,8121,3282,012
Digital assets loss (gain), net of tax79160(459)52
Release of valuation allowance on deferred tax assets(5,927)
Net income attributable to common stockholders (non-GAAP)(1)7,71914,27610,8827,9605,858
Less: Buy-outs of noncontrolling interests(5)(27)(2)(39)
Less: Dilutive convertible debt(9)(1)
Net income used in computing diluted EPS attributable to common stockholders (non-GAAP)(1)7,73314,30410,8847,9995,858
EPS attributable to common stockholders, diluted (GAAP)1.633.624.302.041.08
Stock-based compensation expense, net of tax, per share0.630.450.520.380.57
Digital assets loss (gain), net of tax, per share0.020.05(0.13)0.01
Release of valuation allowance on deferred tax assets(1.70)
EPS attributable to common stockholders, diluted (non-GAAP)(1)2.284.123.122.291.66
Shares used in EPS calculation, diluted (GAAP and non-GAAP)3,3863,4753,4853,4983,528
Net income attributable to common stockholders (GAAP)5,51912,55614,9977,0913,794
Interest expense371191156350338
Provision for (benefit from) income taxes6991132(5,001)1,8371,423
Depreciation, amortization and impairment2,9113,7474,6675,3686,148
Stock-based compensation expense2,1211,5601,8121,9992,825
Digital assets loss (gain), net101204(589)68
Adjusted EBITDA (non-GAAP)(2)11,72219,39016,63116,05614,596
Total revenues53,82381,46296,77397,69094,827
Adjusted EBITDA margin (non-GAAP)(2)21.8%23.8%17.2%16.4%15.4%
Automotive gross margin (GAAP)29.3%28.5%19.4%18.4%17.8%
Less: Total regulatory credit revenue recognized2.3%1.8%1.7%3.0%2.4%
Automotive gross margin excluding regulatory credit sales (non-GAAP)27.0%26.7%17.7%15.4%15.4%
", - "path": "tables/table-12 Financial Metrics 2021-25.html", - "metadata": { - "length": 3537, - "summary": "table-13\nTable shows financial data from 2021 to 2025 including GAAP and non-GAAP net income, EPS, adjusted EBITDA, and margins.", - "page_nums": [ - 27 - ], - "document_top_summary": "This document includes:", - "file_path": "tables/table-12 Financial Metrics 2021-25.html", - "keywords": [ - "Net Income", - "EBITDA", - "EPS" - ], - "tokens": [] - } - }, - { - "chunk_id": "2ca08ebb-8851-5594-a629-470029525540", - "type": "table", - "content": "
In millions of USD2Q-20223Q-20224Q-20221Q-20232Q-20233Q-20234Q-20231Q-20242Q-20243Q-20244Q-20241Q-20252Q-20253Q-20254Q-2025
Net cash provided by operating activities (GAAP)2,3515,1003,2782,5133,0653,3084,3702423,6126,2554,8142,1562,5406,2383,813
Capital expenditures (1)(1,730)(1,803)(1,858)(2,073)(2,060)(2,459)(2,307)(2,777)(2,272)(3,513)(2,780)(1,492)(2,394)(2,248)(2,393)
Free cash flow (non-GAAP) (1)6213,2971,4204401,0058492,063(2,535)1,3402,7422,0346641463,9901,420
In millions of USD2Q-20223Q-20224Q-20221Q-20232Q-20233Q-20234Q-20231Q-20242Q-20243Q-20244Q-20241Q-20 252Q-20253Q-20254Q-2025
Net income attributable to common stockholders (GAAP) (2)2,2593,2923,6872,5132,7031,8537,9281,3901,4002,1732,1284091,1721,373840
Interest expense445333292838617686929691867685
Provision for (benefit from) income taxes (2)205305276261323167(5,752)483371602381169359570325
Depreciation, amortization and impairment9229569891,0461,1541,2351,2321,2461,2781,3481,4961,4471,4331,6251,643
Stock-based compensation expense361362419418445465484524439457579573635663954
Digital assets loss (gain), net (2)17034(335)100(7)(347)125(284)(80)307
Adjusted EBITDA (non-GAAP) (2) (3)3,9614,9685,4384,2674,6533,7583,9533,3843,6744,6654,3332,8143,4014,2274,154
", - "path": "tables/table-13 Financial Data 2022-25.html", - "metadata": { - "length": 3084, - "summary": "table-14\nTable shows quarterly financials from 2Q-2022 to 4Q-2025 in millions USD, including operating cash flow, capex, free cash flow, net income, and adjusted EBITDA.", - "page_nums": [ - 27 - ], - "document_top_summary": "This document includes:", - "file_path": "tables/table-13 Financial Data 2022-25.html", - "keywords": [ - "cash flow", - "EBITDA", - "net income" - ], - "tokens": [] - } - }, - { - "chunk_id": "6c7a815e-17db-5583-abeb-5ded325f3ce4", - "type": "table", - "content": "
In millions of USD1Q-20232Q-20233Q-20234Q-20231Q-20242Q-20243Q-20244Q-20241Q-20252Q-20253Q-20254Q-2025
Net cash provided by operating activities – TTM (GAAP)13,24213,95612,16413,25610,98511,53214,47914,92316,83715,76515,74814,747
Capital expenditures – TTM (1)(7,464)(7,794)(8,450)(8,899)(9,603)(9,815)(10,869)(11,342)(10,057)(10,179)(8,914)(8,527)
Free cash flow – TTM (non-GAAP) (1)5,7786,1623,7144,3571,3821,7173,6103,5816,7805,5866,8346,220
In millions of USD1Q-20232Q-20233Q-20234Q-20231Q-20242Q-20243Q-20244Q-20241Q-20252Q-20253Q-20254Q-20
Net income attributable to common stockholders – TTM (GAAP) (2)11,75112,19510,75614,99713,87412,57112,8917,0916,1105,8825,0823,794
Interest expense – TTM159143128156203261315350365365349338
Provision for (benefit from) income taxes – TTM (2)1,0471,1651,027(5,001)(4,779)(4,731)(4,296)1,8371,5231,5111,4791,423
Depreciation, amortization and impairment – TTM3,9134,1454,4244,6674,8674,9915,1045,3685,5695,7246,0016,148
Stock-based compensation expense – TTM1,5601,6441,7471,8121,9181,9121,9041,9992,0482,2442,4502,825
Digital assets loss (gain), net – TTM (2)2043434(335)(235)(242)(589)(129)(513)(586)68
Adjusted EBITDA – TTM (non-GAAP) (2) (3)18,63419,32618,11616,63115,74814,76915,67616,05615,48615,21314,77514,596
", - "path": "tables/table-14 Financial Metrics 2023-25.html", - "metadata": { - "length": 2886, - "summary": "table-15\nTable shows quarterly financial data from 1Q 2023 to 4Q 2025, including operating cash flow, capital expenditures, free cash flow, net income, and adjusted EBITDA in millions of USD.", - "page_nums": [ - 27 - ], - "document_top_summary": "This document includes:", - "file_path": "tables/table-14 Financial Metrics 2023-25.html", - "keywords": [ - "cash flow", - "EBITDA", - "net income" - ], - "tokens": [] - } - }, - { - "chunk_id": "15c18264-8e1e-5db6-b3c9-70cd181d1f39", - "type": "text", - "content": "STATEMENT OF OPERATIONS\n(Unaudited)\n\n[tables/table-8 Q4 2024-Q4 2025 Rev.html]\n\nBALANCE SHEET\n(Unaudited)\n\n[tables/table-9 Balance Sheet 2024-25.html]\n\nSTATEMENT OF CASH FLOWS\n(Unaudited)\n\n[tables/table-10 Cash Flow Q4-25.html]\n\nRECONCILIATION OF GAAP TO NON-GAAP FINANCIAL INFORMATION (Unaudited)\n\n[tables/table-11 Q4 2024-Q4 2025.html]\n\nRECONCILIATION OF GAAP TO NON-GAAP FINANCIAL INFORMATION\n(Unaudited)\n\n[tables/table-12 Financial Metrics 2021-25.html]\n\nRECONCILIATION OF GAAP TO NON-GAAP FINANCIAL INFORMATION\n(Unaudited)\n\n[tables/table-13 Financial Data 2022-25.html]\n\n\n[tables/table-14 Financial Metrics 2023-25.html]\n\nTTM = Trailing twelve months\n(1) Beginning in Q1'25, Capital expenditures is presented inclusive of purchases of energy generation and storage systems and all prior periods have been adjusted.\n(2) As a result of the adoption of the new crypto assets standard, the previously reported quarterly periods in 2024 have been recast.\n(3) Beginning in Q1'25, Adjusted EBITDA (non-GAAP) is presented net of digital assets gains and losses and all prior periods have been adjusted.", - "path": "Default_Root/TSLA-Q4-2025-Update(1).pdf-->FINANCIAL STATEMENTS", - "metadata": { - "length": 1418, - "summary": "", - "page_nums": [ - 27, - 34 - ], - "document_top_summary": "This document includes:", - "tokens": [ - "STATEMENT", - "OF", - "OPERATIONS", - "Unaudited", - "BALANCE", - "SHEET", - "CASH", - "FLOWS", - "RECONCILIATION", - "GAAP", - "TO", - "NON", - "FINANCIAL", - "INFORMATION", - "TTM", - "Trailing", - "twelve", - "months", - "Beginning", - "Q1", - "25", - "Capital", - "expenditures", - "presented", - "inclusive", - "purchases", - "energy", - "generation", - "storage", - "systems", - "prior", - "periods", - "adjusted", - "As", - "result", - "adoption", - "crypto", - "assets", - "standard", - "previously", - "reported", - "quarterly", - "2024", - "recast", - "Adjusted", - "EBITDA", - "net", - "digital", - "gains", - "losses" - ], - "keywords": [], - "connect_to": [ - { - "target": "3ff046c6-93f4-5d22-a6af-11ecf2660ce3", - "relation": "embeds", - "ref": "[tables/table-8 Q4 2024-Q4 2025 Rev.html]", - "position": { - "start": 37, - "end": 78 - } - }, - { - "target": "dc7fe1d0-57ac-514a-a80a-c46825591b4c", - "relation": "embeds", - "ref": "[tables/table-9 Balance Sheet 2024-25.html]", - "position": { - "start": 107, - "end": 150 - } - }, - { - "target": "0bbfd678-18cc-5152-8775-a4c805e47d57", - "relation": "embeds", - "ref": "[tables/table-10 Cash Flow Q4-25.html]", - "position": { - "start": 189, - "end": 227 - } - }, - { - "target": "675abba3-3bcf-5b5d-94dd-a9e9a711e78b", - "relation": "embeds", - "ref": "[tables/table-11 Q4 2024-Q4 2025.html]", - "position": { - "start": 299, - "end": 337 - } - }, - { - "target": "9a6a2ecd-f489-5cf2-971a-acb1610cbef8", - "relation": "embeds", - "ref": "[tables/table-12 Financial Metrics 2021-25.html]", - "position": { - "start": 409, - "end": 457 - } - }, - { - "target": "2ca08ebb-8851-5594-a629-470029525540", - "relation": "embeds", - "ref": "[tables/table-13 Financial Data 2022-25.html]", - "position": { - "start": 529, - "end": 574 - } - }, - { - "target": "6c7a815e-17db-5583-abeb-5ded325f3ce4", - "relation": "embeds", - "ref": "[tables/table-14 Financial Metrics 2023-25.html]", - "position": { - "start": 577, - "end": 625 - } - } - ] - } - }, - { - "chunk_id": "31babb3c-1abf-5f64-90c7-e8e8a5384bbd", - "type": "text", - "content": "Tesla will provide a live webcast of its fourth quarter 2025 financial results conference call beginning at 4:30 p.m. CT on January 28, 2026 at ir.tesla.com. This webcast will also be available for replay for approximately one year thereafter.", - "path": "Default_Root/TSLA-Q4-2025-Update(1).pdf-->WEBCAST INFORMATION", - "metadata": { - "length": 243, - "summary": "", - "page_nums": [ - 34 - ], - "document_top_summary": "This document includes:", - "tokens": [ - "Tesla", - "provide", - "live", - "webcast", - "fourth", - "quarter", - "2025", - "financial", - "results", - "conference", - "call", - "beginning", - "30", - "CT", - "January", - "28", - "2026", - "ir", - "tesla", - "This", - "replay", - "approximately", - "year" - ], - "keywords": [], - "connect_to": [] - } - }, - { - "chunk_id": "19e3f236-d15a-5b2a-8589-43d28c5316fe", - "type": "text", - "content": "When used in this update, certain terms have the following meanings. Our vehicle deliveries include only vehicles that have been transferred to end customers with all paperwork correctly completed. Our energy product deployment volume includes both customer units when installed and equipment sales at time of delivery. \"Net income attributable to common stockholders (non-GAAP)\" is equal to (i) net income attributable to common stockholders before (ii)(a) stock-based compensation expense, net of tax, (b) digital assets (gain) loss, net of tax and (c) release of valuation allowance on deferred tax assets. \"Adjusted EBITDA (non-GAAP)\" is equal to (i) net income attributable to common stockholders before (ii)(a) interest expense, (b) provision for (benefit from) income taxes, (c) depreciation, amortization and impairment, (d) stock-based compensation expense and (e) digital assets loss (gain), net. \"Free cash flow\" is operating cash flow less capital expenditures. Average cost per vehicle is cost of automotive sales divided by new vehicle deliveries (excluding operating leases). \"Days sales outstanding\" is equal to (i) average accounts receivable, net for the period divided by (ii) total revenues and multiplied by (iii) the number of days in the period. \"Days payable outstanding\" is equal to (i) average accounts payable for the period divided by (ii) total cost of revenues and multiplied by (iii) the number of days in the period. \"Days of supply\" is calculated by dividing new car ending inventory by the relevant period's deliveries and using trading days. Constant currency impacts are calculated by comparing actuals against current results converted into USD using average exchange rates from the prior period.", - "path": "Default_Root/TSLA-Q4-2025-Update(1).pdf-->CERTAIN TERMS", - "metadata": { - "length": 1733, - "summary": "This passage defines key financial and operational terms used in a specific update. It clarifies that vehicle deliveries refer to units transferred to end customers with completed paperwork, while energy product deployment includes installed customer units and equipment sales. The text details non-GAAP measures: 'Net income attributable to common stockholders' adjusts for stock-based compensation, digital asset gains/losses, and valuation allowances; 'Adjusted EBITDA' excludes interest, taxes, depreciation, amortization, impairment, stock-based compensation, and digital asset impacts. 'Free cash flow' is defined as operating cash flow minus capital expenditures. Operational metrics include 'Average cost per vehicle' (automotive sales cost divided by new deliveries excluding leases), 'Days sales outstanding' (average receivables divided by revenue times days in period), 'Days payable outstanding' (average payables divided by cost of revenues times days in period), and 'Days of supply' (ending inventory divided by deliveries using trading days). Finally, constant currency impacts are calculated by comparing actuals against results converted to USD using prior period average exchange rates.", - "page_nums": [ - 34 - ], - "document_top_summary": "This document includes:", - "tokens": [ - "When", - "update", - "terms", - "meanings", - "Our", - "vehicle", - "deliveries", - "include", - "vehicles", - "transferred", - "end", - "customers", - "paperwork", - "correctly", - "completed", - "energy", - "product", - "deployment", - "volume", - "includes", - "customer", - "units", - "installed", - "equipment", - "sales", - "time", - "delivery", - "Net", - "income", - "attributable", - "common", - "stockholders", - "GAAP", - "equal", - "net", - "ii", - "stock", - "based", - "compensation", - "expense", - "tax", - "digital", - "assets", - "gain", - "loss", - "release", - "valuation", - "allowance", - "deferred", - "Adjusted", - "EBITDA", - "interest", - "provision", - "benefit", - "taxes", - "depreciation", - "amortization", - "impairment", - "Free", - "cash", - "flow", - "operating", - "capital", - "expenditures", - "Average", - "cost", - "automotive", - "divided", - "excluding", - "leases", - "Days", - "outstanding", - "average", - "accounts", - "receivable", - "period", - "total", - "revenues", - "multiplied", - "iii", - "number", - "days", - "payable", - "supply", - "calculated", - "dividing", - "car", - "ending", - "inventory", - "relevant", - "trading", - "Constant", - "currency", - "impacts", - "comparing", - "actuals", - "current", - "results", - "converted", - "USD", - "exchange", - "rates", - "prior" - ], - "keywords": [ - "non-GAAP metrics", - "vehicle deliveries", - "cash flow" - ], - "connect_to": [] - } - }, - { - "chunk_id": "332edac0-294c-5e5b-8599-a0e52b25e53a", - "type": "text", - "content": "Consolidated financial information has been presented in accordance with GAAP as well as on a non-GAAP basis to supplement our consolidated financial results. Our non-GAAP financial measures include non-GAAP net income (loss) attributable to common stockholders, non-GAAP net income (loss) attributable to common stockholders on a diluted per share basis (calculated using weighted average shares for GAAP diluted net income (loss) attributable to common stockholders), Adjusted EBITDA margin, non-GAAP automotive gross margin and free cash flow. These non-GAAP financial measures also facilitate management's internal comparisons to Tesla's historical performance as well as comparisons to the operating results of other companies. Management believes that it is useful to supplement its GAAP financial statements with this non-GAAP information because management uses such information internally for its operating, budgeting and financial planning purposes. Management also believes that presentation of the non-GAAP financial measures provides useful information to our investors regarding our financial condition and results of operations, so that investors can see through the eyes of Tesla management regarding important financial metrics that Tesla uses to run the business and allowing investors to better understand Tesla's performance. Non-GAAP information is not prepared under a comprehensive set of accounting rules and therefore, should only be read in conjunction with financial information reported under U.S. GAAP when understanding Tesla's operating performance. A reconciliation between GAAP and non-GAAP financial information is provided above.", - "path": "Default_Root/TSLA-Q4-2025-Update(1).pdf-->NON-GAAP FINANCIAL INFORMATION", - "metadata": { - "length": 1664, - "summary": "Tesla presents consolidated financial information under both GAAP and non-GAAP standards to supplement its results. Key non-GAAP measures include net income attributable to common stockholders, diluted per share figures, Adjusted EBITDA margin, automotive gross margin, and free cash flow. These metrics aid internal management comparisons with historical performance and other companies, supporting operating, budgeting, and planning activities. Management believes these non-GAAP figures provide investors with a clearer view of Tesla's financial condition and operational results by reflecting the metrics used to run the business. However, since non-GAAP data is not prepared under comprehensive accounting rules, it should be read alongside U.S. GAAP information for a complete understanding of Tesla's performance. A reconciliation between GAAP and non-GAAP data is provided elsewhere.", - "page_nums": [ - 34 - ], - "document_top_summary": "This document includes:", - "tokens": [ - "Consolidated", - "financial", - "information", - "presented", - "accordance", - "GAAP", - "basis", - "supplement", - "consolidated", - "results", - "Our", - "measures", - "include", - "net", - "income", - "loss", - "attributable", - "common", - "stockholders", - "diluted", - "share", - "calculated", - "weighted", - "average", - "shares", - "Adjusted", - "EBITDA", - "margin", - "automotive", - "gross", - "free", - "cash", - "flow", - "These", - "facilitate", - "management", - "internal", - "comparisons", - "Tesla", - "historical", - "performance", - "operating", - "companies", - "Management", - "believes", - "statements", - "internally", - "budgeting", - "planning", - "purposes", - "presentation", - "investors", - "condition", - "operations", - "eyes", - "important", - "metrics", - "run", - "business", - "allowing", - "understand", - "Non", - "prepared", - "comprehensive", - "set", - "accounting", - "rules", - "read", - "conjunction", - "reported", - "understanding", - "reconciliation", - "provided" - ], - "keywords": [ - "non-GAAP", - "GAAP", - "financial measures" - ], - "connect_to": [] - } - }, - { - "chunk_id": "34263854-525e-5a28-b9fd-a88d4813756e", - "type": "text", - "content": "Certain statements in this update, including, but not limited to, statements in the “Outlook” section; statements relating to the development, strategy, ramp, production and capacity, demand and market growth, cost, pricing and profitability, investment, deliveries, deployment, availability and other features and improvements and timing of existing and future Tesla products and services and supporting infrastructure; statements regarding operating margin, operating profits, spending and liquidity; and statements regarding expansion, improvements and/or ramp and related timing at our facilities are “forward-looking statements” within the meaning of the Private Securities Litigation Reform Act of 1995. Forward-looking statements are based on assumptions and management’s current expectations, involve certain risks and uncertainties, and are not guarantees. Future results may differ materially from those expressed in any forward-looking statement. The following important factors, without limitation, could cause actual results to differ materially from those in the forward-looking statements: the risk of delays in launching and/or manufacturing our products, services and features cost-effectively; our ability to build and/or grow our products and services, sales, delivery, installation, servicing and charging capabilities and effectively manage this growth; our ability to successfully and timely develop, introduce and scale, as well as our consumers’ demand for, products and services based on artificial intelligence, robotics and automation, electric vehicles, advanced driver assistance systems, and ride-hailing services generally and our vehicles and services specifically; the ability of suppliers to deliver components according to schedules, prices, quality and volumes acceptable to us, and our ability to manage such components effectively; any issues with lithium-ion cells or other components manufactured at our factories; our ability to ramp our factories in accordance with our plans; our ability to procure supply of battery cells, including through our own manufacturing; risks relating to international operations and expansion, including unfavorable and uncertain regulatory, political, economic, tax, tariff, export controls and labor conditions; any failures by Tesla products to perform as expected or if product recalls occur; the risk of product liability claims; competition in the automotive, transportation and energy product and services and robotics markets; our ability to maintain public credibility and confidence in our long-term business prospects; our ability to manage risks relating to our various product financing programs; the status of government and economic incentives for electric vehicles and energy products; our ability to attract, hire and retain key employees and qualified personnel; our ability to maintain the security of our information and production and product systems; our compliance with various regulations and laws applicable to our operations and products, which may evolve from time to time; risks relating to our indebtedness and financing strategies; and adverse foreign exchange movements. More information on potential factors that could affect our financial results is included from time to time in our Securities and Exchange Commission filings and reports, including the risks identified under the section captioned “Risk Factors” in our annual report on Form 10-K filed with the SEC on January 30, 2025 and subsequent quarterly reports on Form 10-Q. Tesla disclaims any obligation to update information contained in these forward-looking statements whether as a result of new information, future events or otherwise.\nTESLA", - "path": "Default_Root/TSLA-Q4-2025-Update(1).pdf-->FORWARD-LOOKING STATEMENTS", - "metadata": { - "length": 3711, - "summary": "This passage from Tesla outlines that various statements in the update, particularly regarding future outlooks, product development, production capacity, financial metrics, and facility expansions, constitute forward-looking statements under the Private Securities Litigation Reform Act of 1995. These statements reflect management's current expectations based on assumptions and are subject to risks and uncertainties, meaning actual results may differ materially. The text lists numerous specific risk factors that could impact outcomes, including manufacturing delays, supply chain challenges, regulatory hurdles, competition, product performance issues, employee retention, and foreign exchange fluctuations. Tesla advises investors to consult SEC filings, specifically the Form 10-K filed on January 30, 2025, for detailed risk disclosures. The company explicitly disclaims any obligation to update these forward-looking statements due to new information or future events.", - "page_nums": [ - 34 - ], - "document_top_summary": "This document includes:", - "tokens": [ - "Certain", - "statements", - "update", - "including", - "limited", - "Outlook", - "section", - "relating", - "development", - "strategy", - "ramp", - "production", - "capacity", - "demand", - "market", - "growth", - "cost", - "pricing", - "profitability", - "investment", - "deliveries", - "deployment", - "availability", - "features", - "improvements", - "timing", - "existing", - "future", - "Tesla", - "products", - "services", - "supporting", - "infrastructure", - "operating", - "margin", - "profits", - "spending", - "liquidity", - "expansion", - "related", - "facilities", - "forward", - "meaning", - "Private", - "Securities", - "Litigation", - "Reform", - "Act", - "1995", - "Forward", - "based", - "assumptions", - "management", - "current", - "expectations", - "involve", - "risks", - "uncertainties", - "guarantees", - "Future", - "results", - "differ", - "materially", - "expressed", - "statement", - "The", - "important", - "factors", - "limitation", - "actual", - "risk", - "delays", - "launching", - "manufacturing", - "effectively", - "ability", - "build", - "grow", - "sales", - "delivery", - "installation", - "servicing", - "charging", - "capabilities", - "manage", - "successfully", - "timely", - "develop", - "introduce", - "scale", - "consumers", - "artificial", - "intelligence", - "robotics", - "automation", - "electric", - "vehicles", - "advanced", - "driver", - "assistance", - "systems", - "ride", - "hailing", - "generally", - "specifically", - "suppliers", - "deliver", - "components", - "schedules", - "prices", - "quality", - "volumes", - "acceptable", - "issues", - "lithium", - "ion", - "cells", - "manufactured", - "factories", - "accordance", - "plans", - "procure", - "supply", - "battery", - "international", - "operations", - "unfavorable", - "uncertain", - "regulatory", - "political", - "economic", - "tax", - "tariff", - "export", - "controls", - "labor", - "conditions", - "failures", - "perform", - "expected", - "product", - "recalls", - "occur", - "liability", - "claims", - "competition", - "automotive", - "transportation", - "energy", - "markets", - "maintain", - "public", - "credibility", - "confidence", - "long", - "term", - "business", - "prospects", - "financing", - "programs", - "status", - "government", - "incentives", - "attract", - "hire", - "retain", - "key", - "employees", - "qualified", - "personnel", - "security", - "information", - "compliance", - "regulations", - "laws", - "applicable", - "evolve", - "time", - "indebtedness", - "strategies", - "adverse", - "foreign", - "exchange", - "movements", - "More", - "potential", - "affect", - "financial", - "included", - "Exchange", - "Commission", - "filings", - "reports", - "identified", - "captioned", - "Risk", - "Factors", - "annual", - "report", - "Form", - "10", - "filed", - "SEC", - "January", - "30", - "2025", - "subsequent", - "quarterly", - "disclaims", - "obligation", - "contained", - "result", - "events", - "TESLA" - ], - "keywords": [ - "forward-looking statements", - "risk factors", - "legal disclaimer" - ], - "connect_to": [] - } - } - ] -} \ No newline at end of file diff --git a/public/demo-sources/tsla-q4-2025/doc_nav.json b/public/demo-sources/tsla-q4-2025/doc_nav.json deleted file mode 100755 index 18e8d59..0000000 --- a/public/demo-sources/tsla-q4-2025/doc_nav.json +++ /dev/null @@ -1,163 +0,0 @@ -{ - "version": "1.0", - "file_name": "TSLA-Q4-2025-Update(1).pdf", - "stats": { - "total_chunks": 70, - "text_chunks": 36, - "image_chunks": 19, - "table_chunks": 15, - "max_depth": 1 - }, - "sections": [ - { - "title": "Root", - "path": "Default_Root/TSLA-Q4-2025-Update(1).pdf-->HIGHLIGHTS", - "level": 1, - "summary": "[tables/table-0 Tesla 2025 Results.html]", - "chunk_count": 36, - "children": [] - } - ], - "resources": { - "images": [ - { - "path": "images/image-1-Capacity Growth Projection.jpg", - "summary": "image-1 The chart illustrates a steady increase in existing capacity from mid-2021 through late 2025, characterized by gradual step-wise growth. A significant surge is projected for the future planned capacity starting in early 2026, reaching levels well above the current trajectory." - }, - { - "path": "images/image-2-FSD Mileage Growth.jpg", - "summary": "image-2 The chart illustrates the projected accumulation of miles driven on Full Self-Driving software over time. It distinguishes between two versions: an older version (V11 and before) represented by a blue area, and a newer version (V12 and beyond) shown in red. While mileage for the older version remains relatively flat, the newer version shows exponential growth starting around early 2024, eventually dominating the total distance traveled by late 2025." - }, - { - "path": "images/image-3-Tesla Silicon Optimization.jpg", - "summary": "image-3 The image displays a high-performance computing chip alongside key performance metrics. It highlights significant improvements in hardened block quantization, memory capacity, and raw compute power compared to previous generations. The overall total improvement is presented as a substantial increase over the AI4 architecture." - }, - { - "path": "images/image-4-Growth Trend 2025.jpg", - "summary": "image-4 The chart illustrates a steady increase in values over time, starting from June 2025 and extending through December 2025. The data shows minimal growth initially, followed by a significant upward trajectory beginning around August, reaching its peak at the end of the year." - }, - { - "path": "images/image-5-Tesla Model Y Driving.jpg", - "summary": "image-5 A sleek silver electric SUV travels along a winding highway through a scenic landscape. The vehicle is captured in motion with blurred surroundings, emphasizing speed against a backdrop of rolling hills and distant mountains under a bright sky." - }, - { - "path": "images/image-6-Red Tesla on Coastal Road.jpg", - "summary": "image-6 A red electric sedan drives along a winding asphalt road carved into a steep, rocky mountainside. The vehicle is captured in motion with a blurred background, emphasizing speed as it navigates the curve. To the right of the road lies a calm body of blue water, while the left side features a rugged cliff face covered in sparse green vegetation under a clear sky." - }, - { - "path": "images/image-7-Tesla Interior Interface.jpg", - "summary": "image-7 The image displays the interior of a Tesla vehicle, focusing on the driver's perspective. A person is interacting with the large central touchscreen display, which shows navigation maps and vehicle controls. The steering wheel features the Tesla logo, and ambient lighting accents are visible along the dashboard. Through the windshield, a modern stone building is seen outside." - }, - { - "path": "images/image-8-Tesla Interior.jpg", - "summary": "image-8 The image displays the driver's perspective inside a Tesla vehicle, featuring a minimalist dashboard with a large central touchscreen. The steering wheel is visible on the left side, and the car appears to be in motion on a city street during daylight hours." - }, - { - "path": "images/image-9-Tesla Cybertruck in Snow.jpg", - "summary": "image-9 A futuristic electric pickup truck is shown driving on a frozen, snow-covered surface. The vehicle features its signature angular design and metallic finish, with snow clinging to the rear bumper and wheel wells. It is set against a backdrop of distant mountains under a twilight sky." - }, - { - "path": "images/image-10-Tesla Semi Trucks.jpg", - "summary": "image-10 Two white electric semi-trucks are parked side-by-side in an outdoor lot. The vehicles feature a futuristic, aerodynamic design with large windshields and distinctive horizontal headlights. They are positioned against a backdrop of industrial buildings and hills under a cloudy sky." - }, - { - "path": "images/image-11-US Lightning Map.jpg", - "summary": "image-11 A map of the United States displays numerous red markers with lightning symbols. These indicators are concentrated heavily along the West Coast, particularly in California, and throughout Texas. Additional clusters appear in the Southeast near Atlanta and scattered locations in the Midwest and Northeast." - }, - { - "path": "images/image-12-Tesla Factory Milestone.jpg", - "summary": "image-12 Factory workers and staff gather for a group photo on an automotive assembly line to celebrate the production of the 900th vehicle. A white car is positioned centrally in front of the crowd, while employees hold silver balloons displaying the number \"900\" to mark this significant manufacturing achievement." - }, - { - "path": "images/image-13-Tesla Factory Milestone.jpg", - "summary": "image-13 A large group of factory workers gathers inside a manufacturing facility to celebrate a significant achievement. Several employees in the front row hold up oversized gold balloons that spell out \"600,000,\" marking a major production milestone for the company. The background reveals an industrial setting filled with machinery and assembly lines." - }, - { - "path": "images/image-14-Vehicle Delivery Trends.jpg", - "summary": "image-14 A bar chart displays quarterly vehicle delivery volumes in millions of units spanning from the first quarter of 2023 through the fourth quarter of 2025. The data illustrates fluctuating delivery figures across the timeline, with values generally ranging between approximately 0.3 and 0.5 million units per quarter." - }, - { - "path": "images/image-15-Quarterly Cash Flow.jpg", - "summary": "image-15 The chart compares operating cash flow and free cash flow across multiple quarters from 2023 through 2025. Blue bars represent operating cash flow, while red bars indicate free cash flow. Operating cash flow remains consistently positive throughout the period, whereas free cash flow fluctuates significantly, including a notable negative value in early 2024." - }, - { - "path": "images/image-16-Financial Performance Chart.jpg", - "summary": "image-16 This bar chart compares Net Income and Adjusted EBITDA across quarterly periods from 2023 through 2025. The blue bars represent Net Income while the red bars indicate Adjusted EBITDA, with values measured in billions of dollars. A significant spike in Net Income is visible during the fourth quarter of 2023, whereas Adjusted EBITDA remains consistently higher than Net Income throughout most of the timeline." - }, - { - "path": "images/image-17-Projected Vehicle Deliveries.jpg", - "summary": "image-17 The bar chart illustrates a forecast of vehicle deliveries in millions of units spanning from the first quarter of 2023 through the fourth quarter of 2025. The data indicates an upward trend starting in early 2023, reaching a peak around late 2023 and continuing at high levels throughout 2024 before showing a slight decline toward the end of the projection period in 2025." - }, - { - "path": "images/image-18-Cash Flow Trends.jpg", - "summary": "image-18 The chart compares operating and free cash flow across multiple quarters from 2023 through 2025. Operating cash flow is consistently higher than free cash flow throughout the timeline, with both metrics showing significant fluctuations over time." - }, - { - "path": "images/image-19-Financial Performance Forecast.jpg", - "summary": "image-19 The chart displays a comparison of Net Income and Adjusted EBITDA over a multi-year period. It features paired bars for each quarter, showing that while earnings fluctuate, the adjusted metric remains consistently higher than net income throughout the timeline." - } - ], - "tables": [ - { - "path": "tables/table-0 Tesla 2025 Results.html", - "summary": "table-1 Tesla reported strong 2025 financials with $4.4B operating income and expanded AI initiatives including Robotaxi and Optimus." - }, - { - "path": "tables/table-1 Q4 2025 Financials.html", - "summary": "table-2 Table shows Tesla's quarterly financials through Q4 2025, including revenues, gross profit, operating income, and free cash flow in millions of dollars." - }, - { - "path": "tables/table-2 Financial Data 2021-25.html", - "summary": "table-3 Table shows financial metrics from 2021 to 2025, including revenues, gross profit, operating income, and free cash flow in millions of dollars." - }, - { - "path": "tables/table-3 Tesla Q4-2025 Data.html", - "summary": "table-4 Table shows Tesla's quarterly production, deliveries, and inventory from Q4 2024 to Q4 2025. Total deliveries dropped 16% YoY in Q4 2025." - }, - { - "path": "tables/table-4 Tesla 2021-2025 Data.html", - "summary": "table-5 Table shows Tesla's production, deliveries, and infrastructure metrics from 2021 to 2025. Total production and deliveries peaked in 2023 then declined by 2025." - }, - { - "path": "tables/table-5 Tesla Production.html", - "summary": "table-6 Tesla operates global facilities in California, Shanghai, Berlin, Texas, and Nevada for Model 3/Y, S/X, Cybertruck, Semi, Megapack, Powerwall, and Optimus." - }, - { - "path": "tables/table-6 Facility Status.html", - "summary": "table-7 Table lists AI training and battery manufacturing facilities. Texas hosts Cortex 1 (production) and Cortex 2 (construction). Nevada and Texas have LFP, 4680, cathode materials, and lithium refining plants in various stages." - }, - { - "path": "tables/table-7 Autonomous Driving Status.html", - "summary": "table-8 Table lists US states and metro areas with autonomous driving status. California SF Bay Area has safety drivers. Texas Austin is ramping unsupervised. Other locations like Dallas, Houston, Phoenix, Miami, Orlando, Tampa, and Las Vegas are scheduled for 1H 2026." - }, - { - "path": "tables/table-8 Q4 2024-Q4 2025 Rev.html", - "summary": "table-9 Financial table showing quarterly revenues, costs, and net income from Q4 2024 to Q4 2025 in millions USD." - }, - { - "path": "tables/table-9 Balance Sheet 2024-25.html", - "summary": "table-10 Financial table showing assets, liabilities, and equity from Dec 2024 to Dec 2025. Total assets grew from 122B to 138B USD." - }, - { - "path": "tables/table-10 Cash Flow Q4-25.html", - "summary": "table-11 Table shows quarterly cash flows from operating, investing, and financing activities in millions USD for Q4 2024 to Q4 2025." - }, - { - "path": "tables/table-11 Q4 2024-Q4 2025.html", - "summary": "table-12 Financial table showing GAAP and non-GAAP metrics for quarters Q4-2024 through Q4-2025, including net income, EPS, adjusted EBITDA, and automotive margins." - }, - { - "path": "tables/table-12 Financial Metrics 2021-25.html", - "summary": "table-13 Table shows financial data from 2021 to 2025 including GAAP and non-GAAP net income, EPS, adjusted EBITDA, and margins." - }, - { - "path": "tables/table-13 Financial Data 2022-25.html", - "summary": "table-14 Table shows quarterly financials from 2Q-2022 to 4Q-2025 in millions USD, including operating cash flow, capex, free cash flow, net income, and adjusted EBITDA." - }, - { - "path": "tables/table-14 Financial Metrics 2023-25.html", - "summary": "table-15 Table shows quarterly financial data from 1Q 2023 to 4Q 2025, including operating cash flow, capital expenditures, free cash flow, net income, and adjusted EBITDA in millions of USD." - } - ] - } -} \ No newline at end of file diff --git a/public/demo-sources/tsla-q4-2025/full.md b/public/demo-sources/tsla-q4-2025/full.md deleted file mode 100755 index 5415cbd..0000000 --- a/public/demo-sources/tsla-q4-2025/full.md +++ /dev/null @@ -1,333 +0,0 @@ - -# Q4 and FY 2025 Update - - -Highlights 03 - -Financial Summary 04 - -Operational Summary 06 - -Manufacturing & Hardware 08 - -Supporting Infrastructure 09 - -AI & Software 10 - -Services 11 - -Other Updates 12 - -Outlook 13 - -Photos & Charts 14 - -Key Metrics 24 - -Financial Statements 27 - -Additional Information 34 - - -# HIGHLIGHTS - - -
Profitability$4.4B GAAP operating income in 2025; $1.4B in Q42025 marked a critical year for Tesla as we further expanded our mission and continued our transition from a hardware-centric business to a physical AI company. We laid the foundation for the future of Tesla as we further advanced FSD (Supervised) $^{4}$ , launched our Robotaxi service, began installing production lines for Cybercab and fine-tuned our production-primed Optimus design while expanding our AI training infrastructure.
$3.8B GAAP net income in 2025; $0.8B in Q4
$5.9B non-GAAP net income $^{1}$ in 2025; $1.8B in Q4
CashOperating cash flow of $14.7B in 2025; $3.8B in Q4Our approach to autonomous vehicles and humanoid robots mirrors the way we approached electric vehicles and energy storage – at the system level where we identify the limiting factor and develop bespoke and scalable solutions (batteries, power electronics, inverters, software, AI silicon, etc.) to optimize for cost, functionality, efficiency and safety. Our vertical integration has enabled us to achieve economies of scale in a profitable manner, quickly troubleshoot bottlenecks in production and iteratively optimize our technologies more rapidly than others.
Free cash flow $^{2}$ of $6.2B in 2025; $1.4B in Q4In 2025, we completed the refresh of our vehicle lineup with the launch of the new Model Y, including additional variants. We believe that maintaining an optimized and efficient product portfolio, with a continued focus on high-value features such as long range, best-in-class software and autonomy, is the correct strategy to win the autos market of the future. Similarly, we continued to evolve our energy offerings for commercial, utility and retail customers, as we position ourselves as a supplier of choice for clean, affordable and rapidly deployable energy capacity ahead of expected sustained demand growth for electricity.
$7.5B increase in our cash and investments $^{3}$ in 2025 to $44.1B
OperationsBegan removing safety monitor from our Robotaxis in Austin in JanuaryIn 2026, we will further invest in the infrastructure needed to support clean energy and transport and autonomous robots, including the ramp of six new production lines across vehicle, robots, energy storage and battery manufacturing, while further leveraging our existing factory, charging and service center footprint to support future growth.
Record Q4 & FY'25 energy storage deployments
Record vehicle deliveries in APAC
- -# SUMMARY - -FINANCIAL SUMMARY -(Unaudited) - -
($ in millions, except percentages and per share data)Q4-2024Q1-2025Q2-2025Q3-2025Q4-2025YoY
Total automotive revenues19,79813,96716,66121,20517,693-11%
Energy generation and storage revenue3,0612,7302,7893,4153,83725%
Services and other revenue2,8482,6383,0463,4753,37118%
Total revenues25,70719,33522,49628,09524,901-3%
Total gross profit4,1793,1533,8785,0545,00920%
Total GAAP gross margin16.3%16.3%17.2%18.0%20.1%386 bp
Operating expenses2,5962,7542,9553,4303,60039%
Income from operations1,5833999231,6241,409-11%
Operating margin6.2%2.1%4.1%5.8%5.7%-50 bp
Adjusted EBITDA (1) (2)4,3332,8143,4014,2274,154-4%
Adjusted EBITDA margin (1) (2)16.9%14.6%15.1%15.0%16.7%-17 bp
Net income attributable to common stockholders (GAAP) (1)2,1284091,1721,373840-61%
Net income attributable to common stockholders (non-GAAP) (1) (3)2,1079341,3931,7701,761-16%
EPS attributable to common stockholders, diluted (GAAP) (1)0.600.120.330.390.24-60%
EPS attributable to common stockholders, diluted (non-GAAP) (1) (3)0.600.270.400.500.50-17%
Net cash provided by operating activities4,8142,1562,5406,2383,813-21%
Capital expenditures (4)(2,780)(1,492)(2,394)(2,248)(2,393)-14%
Free cash flow (4)2,0346641463,9901,420-30%
Cash, cash equivalents and investments36,56336,99636,78241,64744,05921%
- -(1) As a result of the adoption of the new crypto assets standard, the previously reported quarterly periods in 2024 have been recast. -(2) Beginning in Q1'25, Adjusted EBITDA (non-GAAP) is presented net of digital assets gains and losses and all prior periods have been adjusted. -(3) Beginning in Q1'25, Net income attributable to common stockholders (non-GAAP) is presented net of digital assets gains and losses and all prior periods have been adjusted. -(4) Beginning in Q1'25, Capital expenditures is presented inclusive of purchases of energy generation and storage systems and all prior periods have been adjusted. - -FINANCIAL SUMMARY -(Unaudited) - -
($ in millions, except percentages and per share data)20212022202320242025YoY
Total automotive revenues47,23271,46282,41977,07069,526-10%
Energy generation and storage revenue2,7893,9096,03510,08612,77127%
Services and other revenue3,8026,0918,31910,53412,53019%
Total revenues53,82381,46296,77397,69094,827-3%
Total gross profit13,60620,85317,66017,45017,094-2%
Total GAAP gross margin25.3%25.6%18.2%17.9%18.0%16 bp
Operating expenses7,0837,1978,76910,37412,73923%
Income from operations6,52313,6568,8917,0764,355-38%
Operating margin12.1%16.8%9.2%7.2%4.6%-265 bp
Adjusted EBITDA (1)11,72219,39016,63116,05614,596-9%
Adjusted EBITDA margin (1)21.8%23.8%17.2%16.4%15.4%-104 bp
Net income attributable to common stockholders (GAAP)5,51912,55614,9977,0913,794-46%
Net income attributable to common stockholders (non-GAAP) (2)7,71914,27610,8827,9605,858-26%
EPS attributable to common stockholders, diluted (GAAP)1.633.624.302.041.08-47%
EPS attributable to common stockholders, diluted (non-GAAP) (2)2.284.123.122.291.66-28%
Net cash provided by operating activities11,49714,72413,25614,92314,747-1%
Capital expenditures (3)(6,514)(7,163)(8,899)(11,342)(8,527)-25%
Free cash flow (3)4,9837,5614,3573,5816,22074%
Cash, cash equivalents and investments17,70722,18529,09436,56344,05921%
- -(1) Beginning in Q1'25, Adjusted EBITDA (non-GAAP) is presented net of digital assets gains and losses and all prior periods have been adjusted. -(2) Beginning in Q1'25, Net income attributable to common stockholders (non-GAAP) is presented net of digital assets gains and losses and all prior periods have been adjusted. -(3) Beginning in Q1'25, Capital expenditures is presented inclusive of purchases of energy generation and storage systems and all prior periods have been adjusted. - -OPERATIONAL SUMMARY -(Unaudited) - -
Q4-2024Q1-2025Q2-2025Q3-2025Q4-2025YoY
Model 3/Y production436,718345,454396,835435,826422,652-3%
Other models production22,72717,16113,40911,62411,706-48%
Total production459,445362,615410,244447,450434,358-5%
Model 3/Y deliveries471,930323,800373,728481,166406,585-14%
Other models deliveries23,64012,88110,39415,93311,642-51%
Total deliveries495,570336,681384,122497,099418,227-16%
of which subject to operating lease accounting26,96213,7216,67010,23010,996-59%
Cumulative $deliveries^{(1)}$ (all-time; mil)7.37.68.08.58.922%
Active FSD $Subscriptions^{(2)}$ (mil)0.80.80.91.01.138%
Total end of quarter operating lease (new vehicle) $count^{(3)}$ 180,523179,930172,882167,163163,075-10%
Global vehicle inventory (days of supply) $^{(4)}$ 122224101525%
Storage deployed (GWh)11.010.49.612.514.229%
Tesla locations1,3591,3901,4541,4981,55314%
Supercharger stations6,9757,1317,3777,7538,18217%
Supercharger connectors65,49567,31670,22873,81777,68219%
- -OPERATIONAL SUMMARY -(Unaudited) - -
20212022202320242025YoY
Model 3/Y production906,0321,298,4341,775,1591,679,3381,600,767-5%
Other models production24,39071,17770,82694,10553,900-43%
Total production930,4221,369,6111,845,9851,773,4431,654,667-7%
Model 3/Y deliveries911,2421,247,1461,739,7071,704,0931,585,279-7%
Other models deliveries24,98066,70568,87485,13350,850-40%
Total deliveries936,2221,313,8511,808,5811,789,2261,636,129-9%
of which subject to operating lease accounting60,91247,58272,22660,00341,617-31%
Cumulative $deliveries^{(1)}$ (all-time; mil)2.33.75.57.38.922%
Active FSD $Subscriptions^{(2)}$ (mil)0.40.50.60.81.138%
Total end of year operating lease (new vehicle) count120,342140,667176,564180,523163,075-10%
Global vehicle inventory (days of supply) $^{(3)}$ 61616131515%
Storage deployed (GWh)4.06.514.731.446.749%
Tesla locations6449631,2081,3591,55314%
Supercharger stations3,4764,6785,9526,9758,18217%
Supercharger connectors31,49842,41954,89265,49577,68219%
- - -# Automotive - -While automotive sales declined sequentially, gross margin (even when excluding the impact of regulatory credits) improved. The APAC region continued to show strength across multiple markets and set a record for deliveries in the quarter. We continued the rollout of Model Y variants across markets in Q4, including the standard and performance versions. - -Preparations continue in North America for the production ramps of Tesla Semi and Cybercab, both commencing 1H26, and production of the next-generation Roadster. - -# Energy generation and storage - -We achieved our highest quarterly energy storage deployments, driven by record Megapack deployments. Total gross profit rose, both sequentially and year-over-year, to a record \$1.1 billion, marking the fifth consecutive record quarter. We plan to begin Megapack 3 and Megablock production at Megafactory Houston in 2026. In 2025, our global Powerwall network supported more than 89,000 Virtual Power Plant events across over 1 million installed units, allowing homeowners to save over \$1 billion in electricity bills as Virtual Power Plant participation continues to scale rapidly. - -# Robotics - -We made further progress on the Optimus program in 2025. In Q1 of this year, we plan to unveil the Gen 3 version of Optimus, which will include major upgrades from version 2.5, including our latest hand design. The Gen 3 is our first design meant for mass production. Preparations are underway for the first production line, including supply chain readiness, with start of production planned before the end of 2026 and eventual planned capacity of 1 million robots per year. - -Installed Annual Manufacturing Capacity - -
RegionProductCapacityStatus
Automotive
CaliforniaModel 3 / Model Y>550,000Production
Model S / Model X100,000Production
ShanghaiModel 3 / Model Y>950,000Production
BerlinModel Y>375,000Production
TexasModel Y>250,000Production
Cybertruck>125,000Production
Cybercab-Tooling
NevadaTesla Semi-Tooling
TBDRoadster-Design development
Energy Generation and Storage
CaliforniaMegapack40 GWhProduction
NevadaPowerwall>6 GWhProduction
ShanghaiMegapack40 GWhProduction
TexasMegapack-Construction
Robotics
CaliforniaOptimus-Construction
- -Installed capacity ≠ current production rate and there may be limitations discovered as production rates approach capacity. Production rates depend on a variety of factors, including equipment uptime, component supply, downtime related to factory upgrades, regulatory considerations and other factors. Construction includes factory and infrastructure buildout as well as tool installation. - - -# AI Training Compute - -We are currently building Cortex 2 at Gigafactory Texas to further increase our AI training compute capacity. In the first half of 2026, we plan to more than double the size of onsite compute in Texas (in terms of H100 equivalents). We aim to maximize capital efficiency by scaling training compute judiciously, including when the training backlog gets too long or in anticipation of greater demand from our engineers to support our AI-related offerings. - -# Battery - -Our lithium refinery commenced pilot production and is the first spodumene to lithium hydroxide refinery in North America, leveraging a simpler, cheaper and more environmentally friendly process. This refinery enables us to domestically produce critical minerals in support of energy storage, battery manufacturing and ultimately for EV growth. - -We have begun to produce battery packs for certain Model Ys with our 4680 cells, unlocking an additional vector of supply to help navigate increasingly complex supply chain challenges caused by trade barriers and tariff risks. We now produce dry-electrode for 4680 cells with both anode and cathode made in Austin. We expect both domestic cathode material in Texas and LFP lines in Nevada to begin production in 2026. - -# Other Supporting Infrastructure - -We continue to efficiently utilize our existing physical footprint in North America, with targeted augmentation to support the rollout of Robotaxi. While in the short-term, operational workstreams such as charging, cleaning and maintenance can be managed through our existing charging network, service centers and sales and delivery locations, we will have to add more capacity as the service expands. We added over 3,800 net new Supercharging stalls, growing the network 19% year-over-year. - -Installed Annual Capacity - -
RegionProductCapacityStatus
AI Training Compute
TexasCortex 1>100k H100eProduction
Cortex 2-Construction
Battery Manufacturing
NevadaLFP7 GWhEarly Ramp
Texas468040 GWhProduction
Cathode Materials10 GWhEarly Ramp
Lithium Refining30 GWhEarly Ramp
- -Installed capacity ≠ current production rate and there may be limitations discovered as production rates approach capacity. Production rates depend on a variety of factors, including equipment uptime, component supply, downtime related to factory upgrades, regulatory considerations and other factors. Construction includes factory and infrastructure buildout as well as tool installation. - -![](images/c5419b096d319ebffb59e266e803a4f96d687486c0d5acf1ddbd4ac05c322704.jpg) -Tesla AI Training Capacity Ramp (H100 equivalent GPUs) - - -# AI Software - -We continue to enhance FSD (Supervised) $^{1}$ via our end-to-end foundation model trained on both customer and Robotaxi real-world data with our latest version, v14. FSD (Supervised) $^{1}$ increasingly provides safety and convenience functionality that can relieve drivers of many tedious and potentially dangerous aspects of road travel, including giving access to personal transport for those who otherwise have difficulty driving. V14 offers unparalleled driver assistance to safely drive to the customer's destination, find a free parking spot and park at that location. Our global fleet can collect the equivalent of over 500 years of continuous driving data per day $^{2}$ , allowing us to safely deploy and scale capabilities that can handle the long and fat tail of corner cases across diverse geographies and driving environments. - -# AI Inference Compute - -Development of our in-house, custom designed AI5 and AI6 inference chips for autonomy progressed during the quarter, with production planned for 2027 and 2028, respectively. We are targeting a 50x improvement in performance for AI5 relative to AI4 thanks to 10x raw compute, 9x memory capacity and 5x hardened block quantization and softmax function (the latter enabling efficient low-precision computing without sacrificing model accuracy). - -# Automotive and Other Software - -The Robotaxi iOS app no longer has a waitlist in the areas we serve. Our vehicles keep getting better with our over-the-air updates, including: Grok (an AI companion) which now supports navigation commands (allowing users to find, add and edit navigation destinations hands-free); Tesla Photobooth which enables users to take photos in their car and download or share via the Tesla mobile app; Supercharger Site Maps which displays Supercharger layouts, nearby businesses and live availability details; Automatic HOV Lane Routing based on interior camera occupancy detection; Phone Left Behind Chime and SpaceX ISS Docking Simulator Game. - -![](images/9f4063992747fdc3132e661986a68e701bee9186c21a5a3fde658ed89f9e81e6.jpg) - -Cumulative Miles Driven with FSD (Supervised) $^{1}$ (billions) -![](images/48fc2d9f835f11c1f7727e32e63c14619355a55be1227aa504a3bc4c8fb80df7.jpg) -Targeting Step-Function Improvement for our Next-Generation Inference Chip, AI5 - -(1) Active driver supervision required; does not make the vehicle autonomous -(2) Calculated based on continuous hours of driving at an average of 30 miles per hour - -# Robotaxi - -We began testing driverless Robotaxis in Austin in December and began removing the safety monitor from customer rides in January on a limited basis, which will unlock further expansion of our Robotaxi fleet and coverage area in the Austin-metro. Our Bay Area ride-hailing service began serving the San Jose Airport in October, with plans to expand to other major airports in the Bay Area upon receiving required permitting. - -# FSD (Supervised) $^{1}$ - -We launched FSD (Supervised) $^{1}$ in South Korea, where customers drove over 1 million kilometers using the software in just one month. While we continue to pursue regulatory approval in China and Europe, we began offering ride-along experiences to consumers in Italy, Germany, France and Switzerland. - -Monthly subscriptions to FSD (Supervised) $^{1}$ continued to grow sequentially and more than doubled in 2025. Starting this quarter, we are transitioning access to FSD (Supervised) $^{1}$ to monthly subscriptions only as we begin to sunset the up-front payment option. - -# Automotive Services - -Services and Other gross profit of approximately \$300 million was partly driven by Part Sales and Supercharging. We now offer Tesla Insurance in Florida, as we continue to expand our insurance product to new states. In certain states, customers receive a discount on their insurance premiums when using FSD (Supervised) $^{1}$ . The more you drive with FSD (Supervised) $^{1}$ enabled, the bigger the discount is on your insurance premium – helping, in certain cases, to completely offset the monthly subscription cost for FSD (Supervised) $^{1}$ . - -![](images/0988c8ad6fe18ff5072a2abf6a77a630f3902fcc1d80846fbf065e7811b99e87.jpg) - -Cumulative Paid Robotaxi Miles - -
StateMetroStatus
CaliforniaSF Bay AreaSafety Driver
TexasAustinRamping Unsupervised
Dallas1H 2026
Houston1H 2026
ArizonaPhoenix1H 2026
FloridaMiami1H 2026
Orlando1H 2026
Tampa1H 2026
NevadaLas Vegas1H 2026
- -Planned Robotaxi Coverage - - -# OTHER UPDATES - -On January 16, 2026, Tesla entered into an agreement to invest approximately \$2 billion to acquire shares of Series E Preferred Stock of xAI as part of their recent publicly-disclosed financing round. Tesla’s investment was made on market terms consistent with those previously agreed to by other investors in the financing round. As set forth in Master Plan Part IV, Tesla is building products and services that bring AI into the physical world. Meanwhile, xAI is developing leading digital AI products and services, such as its large language model (Grok). - -In that context, and as part of Tesla's broader strategy under Master Plan Part IV, Tesla and xAI also entered into a framework agreement in connection with the investment. Among other things, the framework agreement builds upon the existing relationship between Tesla and xAI by providing a framework for evaluating potential AI collaborations between the companies. Together, the investment and the related framework agreement are intended to enhance Tesla's ability to develop and deploy AI products and services into the physical world at scale. This investment is subject to customary regulatory conditions with the expectation to close in Q1'2026. - - -# OUTLOOK - -# Volume - -We are focused on maximum capacity utilization at our factories. Deliveries and deployments will be impacted by aggregate demand for our products, supply chain readiness and allocation decisions between sale to customers or use for our owned and operated fleet. - -# Cash - -We will manage the businesses such that we ensure a strong balance sheet, maintaining sufficient liquidity to fund our product roadmap, long-term capacity expansion plans – including further vertical integration – and other expenses. - -# Profit - -While we continue to execute on innovations to reduce the cost of manufacturing and operations, over time, we expect our hardware-related profits to be accompanied by an acceleration of AI, software and fleet-based profits. - -# Product - -We continue to evolve and augment our product lineup with a focus on cost, scale and future monetization opportunities via services powered by our AI software. We remain focused on growing our sales volumes through a differentiated and efficiently managed product portfolio, which includes leveraging and optimizing our existing production capacity before building new factories and production lines. - -Cybercab, Tesla Semi and Megapack 3 are on schedule for volume production starting in 2026. First generation production lines for Optimus are being installed in anticipation of volume production. - - -PHOTOS & CHARTS - - -# MODEL Y - 2025 BEST IN CLASS EURO NCAP $^{(1)}$ SMALL SUV - -![](images/158a8cd9950de369e065221dc0e852dbc1e830dc0f883a496bd9be77d6029db3.jpg) - - -# MODEL 3 - 2025 BEST IN CLASS EURO NCAP $^{(1)}$ LARGE FAMILY CAR - -![](images/07ce9a96f3e5d92fa1641b00312412b3deaf22189aa8904659176fa2bcd62e75.jpg) - - -# FSD (SUPERVISED) $^{1}$ – V14 OFFERS UNPARALLELED DRIVER ASSISTANCE - -![](images/b1f1448eab61493236c1530fcc2c05a7365b9ef91af9be38d3faf74ec71b5ae9.jpg) - - -# DRIVERLESS ROBOTAXI - TESTING IN AUSTIN - -![](images/cda4f95399f8a58b55750a199bb9434e1b7806478b1ca775e4c7c0737656b5de.jpg) - - -# CYBERCAB - COLD WEATHER TESTING IN ALASKA - -![](images/d4e7263d8fb8951d59a0c80292c412f4b75d36fb36a35fe058140095dddfbce1.jpg) - -![](images/ade7e435748c90f4f859d0d36b9856e2ba25bcb3de67e173f6a38e70039d9c2c.jpg) - - -TESLA SEMI - MEGACHARGER NETWORK PLANNED SITES FOR 2026 -![](images/8e2b663e678f56be153dc891d3913f90ed7e90060ca0b164434d28336d2c8820.jpg) - - -# GIGAFACTORY SHANGHAI - 9 MILLIONTH VEHICLE PRODUCED (GLOBALLY) - -![](images/0ec324caa888be92ff136948d030c18f5dba98b8d91fcdfe7a3a6f6f5b8660af.jpg) - - -# GIGAFACTORY NEVADA - 6 MILLIONTH DRIVE UNIT PRODUCED - -![](images/960786f1a7ef3448079eafcc69ce08cef09109fa0577b30c08f8fda23c394483.jpg) - -![](images/920fc64098ae7b1ce10f289ec9513682e5581ac225922fd4b75ba9c45125a352.jpg) - -![](images/1e7cdefaaf215c38ac82a80e892bd3bb9f87b0b2dfc423b6ec2e3525fb99d11f.jpg) - -![](images/afc60e5cf57f455cd6fa8ffafb95cb736170fcaef6b76d4e10c394bf81cb7849.jpg) - - -# KEY METRICS TRAILING 12 MONTHS (TTM) (Unaudited) - -![](images/12044b68a9c0ec6bd1c5e10a65a295f2ca643bce67aff18015086f71bfc4a5e7.jpg) - -![](images/a701d35afba0dcc13ebd0a273857f53b6c616039fae2341a2b927075ac37f64e.jpg) - -![](images/ff5078d01c1a41d45496ab1b851b69109387b3f2b158681ce7b326707f6716ba.jpg) - - -# Revenue - -Total quarterly revenue decreased 3% YoY to \$24.9B. YoY, revenue was impacted by the following items $^{(1)}$ : - -- decrease in vehicle deliveries -- lower regulatory credit revenue -+ growth in Energy Generation and Storage -+ growth in Services and Other -+ positive FX impact of \$0.3B $^{1}$ - -\+ growth in other automotive ancillary sales, partly driven by an increase in FSD subscriptions - -\+ higher vehicle average selling price (ASP) (excl. FX impact $^{1}$ ), inclusive of mix impact - -# Profitability - -Our quarterly operating income decreased 11% YoY to \$1.4B, resulting in a 5.7% operating margin. YoY, operating income was primarily impacted by the following items $^{(1)}$ : - -- increase in SBC and Restructuring and Other charges -- increase in operating expenses (excl. SBC and Restructuring and Other) driven by AI and other R&D projects and SG&A -- higher average cost per vehicle due to lower fixed cost absorption for certain models and an increase in tariffs -- decrease in vehicle deliveries -- lower regulatory credit revenue -+ higher vehicle average gross profit due to mix and pricing impacts -+ growth in Energy Generation and Storage gross profit -+ growth in Services and Other gross profit -+ growth in other automotive ancillary sales, partly driven by an increase in FSD subscriptions - -# Cash - -Quarter-end cash, cash equivalents and investments was \$44.1B. The sequential increase of \$2.4B was primarily the result of positive free cash flow. - - -# FINANCIAL STATEMENTS - -STATEMENT OF OPERATIONS -(Unaudited) - -
In millions of USD or shares as applicable, except per share dataQ4-2024Q1-2025Q2-2025Q3-2025Q4-2025
REVENUES
Automotive sales18,65912,92515,78720,35916,750
Automotive regulatory credits692595439417542
Automotive leasing447447435429401
Total automotive revenues19,79813,96716,66121,20517,693
Energy generation and storage3,0612,7302,7893,4153,837
Services and other2,8482,6383,0463,4753,371
Total revenues25,70719,33522,49628,09524,901
COST OF REVENUES
Automotive sales16,26811,46113,56717,36513,874
Automotive leasing242239228225206
Total automotive cost of revenues16,51011,70013,79517,59014,080
Energy generation and storage2,2891,9451,9432,3422,739
Services and other2,7292,5372,8803,1093,073
Total cost of revenues21,52816,18218,61823,04119,892
Gross profit4,1793,1533,8785,0545,009
OPERATING EXPENSES
Research and development1,2761,4091,5891,6301,783
Selling, general and administrative1,3131,2511,3661,5621,655
Restructuring and other794238162
Total operating expenses2,5962,7542,9553,4303,600
INCOME FROM OPERATIONS1,5833999231,6241,409
Interest income442400392439449
Interest expense(96)(91)(86)(76)(85)
Other income (expense), net (1)595(119)320(28)(592)
INCOME BEFORE INCOME TAXES (1)2,5245891,5491,9591,181
Provision for income taxes (1)381169359570325
NET INCOME (1)2,1434201,1901,389856
Net income attributable to noncontrolling interests and redeemable noncontrolling interests in subsidiaries1511181616
NET INCOME ATTRIBUTABLE TO COMMON STOCKHOLDERS (1)2,1284091,1721,373840
Less: Buy-out of noncontrolling interest3
NET INCOME USED IN COMPUTING NET INCOME PER SHARE OF COMMON STOCK (1)2,1254091,1721,373840
Net income per share of common stock attributable to common stockholders
Basic (1)$ 0.66$ 0.13$ 0.36$ 0.43$ 0.26
Diluted (1)$ 0.60$ 0.12$ 0.33$ 0.39$ 0.24
Weighted average shares used in computing net income per share of common stock
Basic3,2133,2183,2233,2273,231
Diluted3,5173,5213,5193,5263,539
- -BALANCE SHEET -(Unaudited) - -
In millions of USD31-Dec-2431-Mar-2530-Jun-2530-Sep-2531-Dec-25
ASSETS
Current assets
Cash, cash equivalents and investments36,56336,99636,78241,64744,059
Accounts receivable, net4,4183,7823,8384,7034,576
Inventory12,01713,70614,57012,27612,392
Prepaid expenses and other current assets5,3624,9055,9436,0277,615
Total current assets58,36059,38961,13364,65368,642
Operating lease vehicles, net5,5815,4775,2305,0194,912
Energy generation and storage systems, net4,9244,8554,7884,6734,604
Property, plant and equipment, net35,83637,08838,57439,40740,643
Operating lease right-of-use assets5,1605,3305,6335,7836,027
Digital assets (2)1,0769511,2351,3151,008
Deferred tax assets (2)6,5246,6876,7216,6376,925
Other non-current assets4,6095,3345,2536,2485,045
Total assets (2)122,070125,111128,567133,735137,806
LIABILITIES AND EQUITY
Current liabilities
Accounts payable12,47413,47113,21212,81913,371
Accrued liabilities and other10,72310,80211,51912,79113,279
Deferred revenue3,1683,2433,2373,7563,424
Current portion of debt and finance leases (1)2,4562,2372,0401,9241,640
Total current liabilities28,82129,75330,00831,29031,714
Debt and finance leases, net of current portion (1)5,7575,2925,1805,7786,736
Deferred revenue, net of current portion3,3173,6103,7643,7463,631
Other long-term liabilities10,49511,03811,54312,20512,860
Total liabilities48,39049,69350,49553,01954,941
Redeemable noncontrolling interests in subsidiaries6362615958
Total stockholders' equity (2)72,91374,65377,31479,97082,137
Noncontrolling interests in subsidiaries704703697687670
Total liabilities and equity (2)122,070125,111128,567133,735137,806
(1) Breakdown of our debt is as follows:
Non-recourse debt7,8717,2386,9537,4588,150
Recourse debt76333
Days sales outstanding1419151417
Days payable outstanding5872655261
- -STATEMENT OF CASH FLOWS -(Unaudited) - -
In millions of USDQ4-2024Q1-2025Q2-2025Q3-2025Q4-2025
CASH FLOWS FROM OPERATING ACTIVITIES
Net income (1)2,1434201,1901,389856
Adjustments to reconcile net income to net cash provided by operating activities:
Depreciation, amortization and impairment1,4961,4471,4331,6251,643
Stock-based compensation579573635663954
Deferred income taxes (1)6(43)52225(111)
Digital assets (gain) loss, net (1)(347)125(284)(80)307
Other(93)188187333378
Changes in operating assets and liabilities1030(554)(673)2,083(214)
Net cash provided by operating activities4,8142,1562,5406,2383,813
CASH FLOWS FROM INVESTING ACTIVITIES
Capital expenditures (2)(2,780)(1,492)(2,394)(2,248)(2,393)
Purchases of investments(15,158)(6,015)(7,485)(11,402)(12,207)
Proceeds from maturities of investments10,3355,8566,9359,2958,072
Net cash used in investing activities(7,603)(1,651)(2,944)(4,355)(6,528)
CASH FLOWS FROM FINANCING ACTIVITIES
Net cash flows from other debt activities(108)(50)(23)410963
Net borrowings (repayments) under vehicle and energy product financing677(674)(400)81(377)
Net cash flows from noncontrolling interests – Solar(37)(22)(14)(20)(22)
Other453414215512146
Net cash provided by (used in) financing activities985(332)(222)983710
Effect of exchange rate changes on cash and cash equivalents and restricted cash(133)40111(17)37
Net (decrease) increase in cash and cash equivalents and restricted cash(1,937)213(515)2,849(1,968)
Cash and cash equivalents and restricted cash at beginning of period18,97417,03717,25016,73519,584
Cash and cash equivalents and restricted cash at end of period17,03717,25016,73519,58417,616
- -RECONCILIATION OF GAAP TO NON-GAAP FINANCIAL INFORMATION (Unaudited) - -
In millions of USD or shares as applicable, except per share dataQ4-2024Q1-2025Q2-2025Q3-2025Q4-2025
Net income attributable to common stockholders (GAAP) (1)2,1284091,1721,373840
Stock-based compensation expense, net of tax249428443459682
Digital assets (gain) loss, net of tax (1)(270)97(222)(62)239
Net income attributable to common stockholders (non-GAAP) (1) (2)2,1079341,3931,7701,761
Less: Buy-outs of noncontrolling interests3
Net income used in computing diluted EPS attributable to common stockholders (non-GAAP) (1) (2)2,1049341,3931,7701,761
EPS attributable to common stockholders, diluted (GAAP) (1)0.600.120.330.390.24
Stock-based compensation expense, net of tax, per share0.080.120.130.130.19
Digital assets (gain) loss, net of tax, per share (1)(0.08)0.03(0.06)(0.02)0.07
EPS attributable to common stockholders, diluted (non-GAAP) (1) (2)0.600.270.400.500.50
Shares used in EPS calculation, diluted (GAAP and non-GAAP)3,5173,5213,5193,5263,539
Net income attributable to common stockholders (GAAP) (1)2,1284091,1721,373840
Interest expense9691867685
Provision for income taxes (1)381169359570325
Depreciation, amortization and impairment1,4961,4471,4331,6251,643
Stock-based compensation expense579573635663954
Digital assets (gain) loss, net (1)(347)125(284)(80)307
Adjusted EBITDA (non-GAAP) (1) (3)4,3332,8143,4014,2274,154
Total revenues25,70719,33522,49628,09524,901
Adjusted EBITDA margin (non-GAAP) (1) (3)16.9%14.6%15.1%15.0%16.7%
Automotive gross margin (GAAP)16.6%16.2%17.2%17.0%20.4%
Less: Total regulatory credit revenue recognized3.0%3.7%2.2%1.6%2.5%
Automotive gross margin excluding regulatory credit sales (non-GAAP)13.6%12.5%15.0%15.4%17.9%
- -RECONCILIATION OF GAAP TO NON-GAAP FINANCIAL INFORMATION -(Unaudited) - -
In millions of USD or shares as applicable, except per share data20212022202320242025
Net income attributable to common stockholders (GAAP)5,51912,55614,9977,0913,794
Stock-based compensation expense, net of tax2,1211,5601,8121,3282,012
Digital assets loss (gain), net of tax79160(459)52
Release of valuation allowance on deferred tax assets(5,927)
Net income attributable to common stockholders (non-GAAP)(1)7,71914,27610,8827,9605,858
Less: Buy-outs of noncontrolling interests(5)(27)(2)(39)
Less: Dilutive convertible debt(9)(1)
Net income used in computing diluted EPS attributable to common stockholders (non-GAAP)(1)7,73314,30410,8847,9995,858
EPS attributable to common stockholders, diluted (GAAP)1.633.624.302.041.08
Stock-based compensation expense, net of tax, per share0.630.450.520.380.57
Digital assets loss (gain), net of tax, per share0.020.05(0.13)0.01
Release of valuation allowance on deferred tax assets(1.70)
EPS attributable to common stockholders, diluted (non-GAAP)(1)2.284.123.122.291.66
Shares used in EPS calculation, diluted (GAAP and non-GAAP)3,3863,4753,4853,4983,528
Net income attributable to common stockholders (GAAP)5,51912,55614,9977,0913,794
Interest expense371191156350338
Provision for (benefit from) income taxes6991132(5,001)1,8371,423
Depreciation, amortization and impairment2,9113,7474,6675,3686,148
Stock-based compensation expense2,1211,5601,8121,9992,825
Digital assets loss (gain), net101204(589)68
Adjusted EBITDA (non-GAAP)(2)11,72219,39016,63116,05614,596
Total revenues53,82381,46296,77397,69094,827
Adjusted EBITDA margin (non-GAAP)(2)21.8%23.8%17.2%16.4%15.4%
Automotive gross margin (GAAP)29.3%28.5%19.4%18.4%17.8%
Less: Total regulatory credit revenue recognized2.3%1.8%1.7%3.0%2.4%
Automotive gross margin excluding regulatory credit sales (non-GAAP)27.0%26.7%17.7%15.4%15.4%
- -RECONCILIATION OF GAAP TO NON-GAAP FINANCIAL INFORMATION -(Unaudited) - -
In millions of USD2Q-20223Q-20224Q-20221Q-20232Q-20233Q-20234Q-20231Q-20242Q-20243Q-20244Q-20241Q-20252Q-20253Q-20254Q-2025
Net cash provided by operating activities (GAAP)2,3515,1003,2782,5133,0653,3084,3702423,6126,2554,8142,1562,5406,2383,813
Capital expenditures (1)(1,730)(1,803)(1,858)(2,073)(2,060)(2,459)(2,307)(2,777)(2,272)(3,513)(2,780)(1,492)(2,394)(2,248)(2,393)
Free cash flow (non-GAAP) (1)6213,2971,4204401,0058492,063(2,535)1,3402,7422,0346641463,9901,420
In millions of USD2Q-20223Q-20224Q-20221Q-20232Q-20233Q-20234Q-20231Q-20242Q-20243Q-20244Q-20241Q-20 252Q-20253Q-20254Q-2025
Net income attributable to common stockholders (GAAP) (2)2,2593,2923,6872,5132,7031,8537,9281,3901,4002,1732,1284091,1721,373840
Interest expense445333292838617686929691867685
Provision for (benefit from) income taxes (2)205305276261323167(5,752)483371602381169359570325
Depreciation, amortization and impairment9229569891,0461,1541,2351,2321,2461,2781,3481,4961,4471,4331,6251,643
Stock-based compensation expense361362419418445465484524439457579573635663954
Digital assets loss (gain), net (2)17034(335)100(7)(347)125(284)(80)307
Adjusted EBITDA (non-GAAP) (2) (3)3,9614,9685,4384,2674,6533,7583,9533,3843,6744,6654,3332,8143,4014,2274,154
- -
In millions of USD1Q-20232Q-20233Q-20234Q-20231Q-20242Q-20243Q-20244Q-20241Q-20252Q-20253Q-20254Q-2025
Net cash provided by operating activities – TTM (GAAP)13,24213,95612,16413,25610,98511,53214,47914,92316,83715,76515,74814,747
Capital expenditures – TTM (1)(7,464)(7,794)(8,450)(8,899)(9,603)(9,815)(10,869)(11,342)(10,057)(10,179)(8,914)(8,527)
Free cash flow – TTM (non-GAAP) (1)5,7786,1623,7144,3571,3821,7173,6103,5816,7805,5866,8346,220
In millions of USD1Q-20232Q-20233Q-20234Q-20231Q-20242Q-20243Q-20244Q-20241Q-20252Q-20253Q-20254Q-20
Net income attributable to common stockholders – TTM (GAAP) (2)11,75112,19510,75614,99713,87412,57112,8917,0916,1105,8825,0823,794
Interest expense – TTM159143128156203261315350365365349338
Provision for (benefit from) income taxes – TTM (2)1,0471,1651,027(5,001)(4,779)(4,731)(4,296)1,8371,5231,5111,4791,423
Depreciation, amortization and impairment – TTM3,9134,1454,4244,6674,8674,9915,1045,3685,5695,7246,0016,148
Stock-based compensation expense – TTM1,5601,6441,7471,8121,9181,9121,9041,9992,0482,2442,4502,825
Digital assets loss (gain), net – TTM (2)2043434(335)(235)(242)(589)(129)(513)(586)68
Adjusted EBITDA – TTM (non-GAAP) (2) (3)18,63419,32618,11616,63115,74814,76915,67616,05615,48615,21314,77514,596
- -TTM = Trailing twelve months -(1) Beginning in Q1'25, Capital expenditures is presented inclusive of purchases of energy generation and storage systems and all prior periods have been adjusted. -(2) As a result of the adoption of the new crypto assets standard, the previously reported quarterly periods in 2024 have been recast. -(3) Beginning in Q1'25, Adjusted EBITDA (non-GAAP) is presented net of digital assets gains and losses and all prior periods have been adjusted. - - -# WEBCAST INFORMATION - -Tesla will provide a live webcast of its fourth quarter 2025 financial results conference call beginning at 4:30 p.m. CT on January 28, 2026 at ir.tesla.com. This webcast will also be available for replay for approximately one year thereafter. - -# CERTAIN TERMS - -When used in this update, certain terms have the following meanings. Our vehicle deliveries include only vehicles that have been transferred to end customers with all paperwork correctly completed. Our energy product deployment volume includes both customer units when installed and equipment sales at time of delivery. "Net income attributable to common stockholders (non-GAAP)" is equal to (i) net income attributable to common stockholders before (ii)(a) stock-based compensation expense, net of tax, (b) digital assets (gain) loss, net of tax and (c) release of valuation allowance on deferred tax assets. "Adjusted EBITDA (non-GAAP)" is equal to (i) net income attributable to common stockholders before (ii)(a) interest expense, (b) provision for (benefit from) income taxes, (c) depreciation, amortization and impairment, (d) stock-based compensation expense and (e) digital assets loss (gain), net. "Free cash flow" is operating cash flow less capital expenditures. Average cost per vehicle is cost of automotive sales divided by new vehicle deliveries (excluding operating leases). "Days sales outstanding" is equal to (i) average accounts receivable, net for the period divided by (ii) total revenues and multiplied by (iii) the number of days in the period. "Days payable outstanding" is equal to (i) average accounts payable for the period divided by (ii) total cost of revenues and multiplied by (iii) the number of days in the period. "Days of supply" is calculated by dividing new car ending inventory by the relevant period's deliveries and using trading days. Constant currency impacts are calculated by comparing actuals against current results converted into USD using average exchange rates from the prior period. - -# NON-GAAP FINANCIAL INFORMATION - -Consolidated financial information has been presented in accordance with GAAP as well as on a non-GAAP basis to supplement our consolidated financial results. Our non-GAAP financial measures include non-GAAP net income (loss) attributable to common stockholders, non-GAAP net income (loss) attributable to common stockholders on a diluted per share basis (calculated using weighted average shares for GAAP diluted net income (loss) attributable to common stockholders), Adjusted EBITDA margin, non-GAAP automotive gross margin and free cash flow. These non-GAAP financial measures also facilitate management's internal comparisons to Tesla's historical performance as well as comparisons to the operating results of other companies. Management believes that it is useful to supplement its GAAP financial statements with this non-GAAP information because management uses such information internally for its operating, budgeting and financial planning purposes. Management also believes that presentation of the non-GAAP financial measures provides useful information to our investors regarding our financial condition and results of operations, so that investors can see through the eyes of Tesla management regarding important financial metrics that Tesla uses to run the business and allowing investors to better understand Tesla's performance. Non-GAAP information is not prepared under a comprehensive set of accounting rules and therefore, should only be read in conjunction with financial information reported under U.S. GAAP when understanding Tesla's operating performance. A reconciliation between GAAP and non-GAAP financial information is provided above. - -# FORWARD-LOOKING STATEMENTS - -Certain statements in this update, including, but not limited to, statements in the “Outlook” section; statements relating to the development, strategy, ramp, production and capacity, demand and market growth, cost, pricing and profitability, investment, deliveries, deployment, availability and other features and improvements and timing of existing and future Tesla products and services and supporting infrastructure; statements regarding operating margin, operating profits, spending and liquidity; and statements regarding expansion, improvements and/or ramp and related timing at our facilities are “forward-looking statements” within the meaning of the Private Securities Litigation Reform Act of 1995. Forward-looking statements are based on assumptions and management’s current expectations, involve certain risks and uncertainties, and are not guarantees. Future results may differ materially from those expressed in any forward-looking statement. The following important factors, without limitation, could cause actual results to differ materially from those in the forward-looking statements: the risk of delays in launching and/or manufacturing our products, services and features cost-effectively; our ability to build and/or grow our products and services, sales, delivery, installation, servicing and charging capabilities and effectively manage this growth; our ability to successfully and timely develop, introduce and scale, as well as our consumers’ demand for, products and services based on artificial intelligence, robotics and automation, electric vehicles, advanced driver assistance systems, and ride-hailing services generally and our vehicles and services specifically; the ability of suppliers to deliver components according to schedules, prices, quality and volumes acceptable to us, and our ability to manage such components effectively; any issues with lithium-ion cells or other components manufactured at our factories; our ability to ramp our factories in accordance with our plans; our ability to procure supply of battery cells, including through our own manufacturing; risks relating to international operations and expansion, including unfavorable and uncertain regulatory, political, economic, tax, tariff, export controls and labor conditions; any failures by Tesla products to perform as expected or if product recalls occur; the risk of product liability claims; competition in the automotive, transportation and energy product and services and robotics markets; our ability to maintain public credibility and confidence in our long-term business prospects; our ability to manage risks relating to our various product financing programs; the status of government and economic incentives for electric vehicles and energy products; our ability to attract, hire and retain key employees and qualified personnel; our ability to maintain the security of our information and production and product systems; our compliance with various regulations and laws applicable to our operations and products, which may evolve from time to time; risks relating to our indebtedness and financing strategies; and adverse foreign exchange movements. More information on potential factors that could affect our financial results is included from time to time in our Securities and Exchange Commission filings and reports, including the risks identified under the section captioned “Risk Factors” in our annual report on Form 10-K filed with the SEC on January 30, 2025 and subsequent quarterly reports on Form 10-Q. Tesla disclaims any obligation to update information contained in these forward-looking statements whether as a result of new information, future events or otherwise. - -TESLA \ No newline at end of file diff --git a/public/demo-sources/tsla-q4-2025/images/image-1-Capacity Growth Projection.jpg b/public/demo-sources/tsla-q4-2025/images/image-1-Capacity Growth Projection.jpg deleted file mode 100755 index 47856b7..0000000 Binary files a/public/demo-sources/tsla-q4-2025/images/image-1-Capacity Growth Projection.jpg and /dev/null differ diff --git a/public/demo-sources/tsla-q4-2025/images/image-10-Tesla Semi Trucks.jpg b/public/demo-sources/tsla-q4-2025/images/image-10-Tesla Semi Trucks.jpg deleted file mode 100755 index 0128b08..0000000 Binary files a/public/demo-sources/tsla-q4-2025/images/image-10-Tesla Semi Trucks.jpg and /dev/null differ diff --git a/public/demo-sources/tsla-q4-2025/images/image-11-US Lightning Map.jpg b/public/demo-sources/tsla-q4-2025/images/image-11-US Lightning Map.jpg deleted file mode 100755 index 97b7550..0000000 Binary files a/public/demo-sources/tsla-q4-2025/images/image-11-US Lightning Map.jpg and /dev/null differ diff --git a/public/demo-sources/tsla-q4-2025/images/image-12-Tesla Factory Milestone.jpg b/public/demo-sources/tsla-q4-2025/images/image-12-Tesla Factory Milestone.jpg deleted file mode 100755 index 68ffca6..0000000 Binary files a/public/demo-sources/tsla-q4-2025/images/image-12-Tesla Factory Milestone.jpg and /dev/null differ diff --git a/public/demo-sources/tsla-q4-2025/images/image-13-Tesla Factory Milestone.jpg b/public/demo-sources/tsla-q4-2025/images/image-13-Tesla Factory Milestone.jpg deleted file mode 100755 index 07396b7..0000000 Binary files a/public/demo-sources/tsla-q4-2025/images/image-13-Tesla Factory Milestone.jpg and /dev/null differ diff --git a/public/demo-sources/tsla-q4-2025/images/image-14-Vehicle Delivery Trends.jpg b/public/demo-sources/tsla-q4-2025/images/image-14-Vehicle Delivery Trends.jpg deleted file mode 100755 index a84014a..0000000 Binary files a/public/demo-sources/tsla-q4-2025/images/image-14-Vehicle Delivery Trends.jpg and /dev/null differ diff --git a/public/demo-sources/tsla-q4-2025/images/image-15-Quarterly Cash Flow.jpg b/public/demo-sources/tsla-q4-2025/images/image-15-Quarterly Cash Flow.jpg deleted file mode 100755 index 7a06aeb..0000000 Binary files a/public/demo-sources/tsla-q4-2025/images/image-15-Quarterly Cash Flow.jpg and /dev/null differ diff --git a/public/demo-sources/tsla-q4-2025/images/image-16-Financial Performance Chart.jpg b/public/demo-sources/tsla-q4-2025/images/image-16-Financial Performance Chart.jpg deleted file mode 100755 index 42e9b15..0000000 Binary files a/public/demo-sources/tsla-q4-2025/images/image-16-Financial Performance Chart.jpg and /dev/null differ diff --git a/public/demo-sources/tsla-q4-2025/images/image-17-Projected Vehicle Deliveries.jpg b/public/demo-sources/tsla-q4-2025/images/image-17-Projected Vehicle Deliveries.jpg deleted file mode 100755 index e669bbc..0000000 Binary files a/public/demo-sources/tsla-q4-2025/images/image-17-Projected Vehicle Deliveries.jpg and /dev/null differ diff --git a/public/demo-sources/tsla-q4-2025/images/image-18-Cash Flow Trends.jpg b/public/demo-sources/tsla-q4-2025/images/image-18-Cash Flow Trends.jpg deleted file mode 100755 index 99b8f27..0000000 Binary files a/public/demo-sources/tsla-q4-2025/images/image-18-Cash Flow Trends.jpg and /dev/null differ diff --git a/public/demo-sources/tsla-q4-2025/images/image-19-Financial Performance Forecast.jpg b/public/demo-sources/tsla-q4-2025/images/image-19-Financial Performance Forecast.jpg deleted file mode 100755 index 3a28a01..0000000 Binary files a/public/demo-sources/tsla-q4-2025/images/image-19-Financial Performance Forecast.jpg and /dev/null differ diff --git a/public/demo-sources/tsla-q4-2025/images/image-2-FSD Mileage Growth.jpg b/public/demo-sources/tsla-q4-2025/images/image-2-FSD Mileage Growth.jpg deleted file mode 100755 index c406820..0000000 Binary files a/public/demo-sources/tsla-q4-2025/images/image-2-FSD Mileage Growth.jpg and /dev/null differ diff --git a/public/demo-sources/tsla-q4-2025/images/image-3-Tesla Silicon Optimization.jpg b/public/demo-sources/tsla-q4-2025/images/image-3-Tesla Silicon Optimization.jpg deleted file mode 100755 index 7f9eea6..0000000 Binary files a/public/demo-sources/tsla-q4-2025/images/image-3-Tesla Silicon Optimization.jpg and /dev/null differ diff --git a/public/demo-sources/tsla-q4-2025/images/image-4-Growth Trend 2025.jpg b/public/demo-sources/tsla-q4-2025/images/image-4-Growth Trend 2025.jpg deleted file mode 100755 index ace4505..0000000 Binary files a/public/demo-sources/tsla-q4-2025/images/image-4-Growth Trend 2025.jpg and /dev/null differ diff --git a/public/demo-sources/tsla-q4-2025/images/image-5-Tesla Model Y Driving.jpg b/public/demo-sources/tsla-q4-2025/images/image-5-Tesla Model Y Driving.jpg deleted file mode 100755 index 386ada0..0000000 Binary files a/public/demo-sources/tsla-q4-2025/images/image-5-Tesla Model Y Driving.jpg and /dev/null differ diff --git a/public/demo-sources/tsla-q4-2025/images/image-6-Red Tesla on Coastal Road.jpg b/public/demo-sources/tsla-q4-2025/images/image-6-Red Tesla on Coastal Road.jpg deleted file mode 100755 index c84f0be..0000000 Binary files a/public/demo-sources/tsla-q4-2025/images/image-6-Red Tesla on Coastal Road.jpg and /dev/null differ diff --git a/public/demo-sources/tsla-q4-2025/images/image-7-Tesla Interior Interface.jpg b/public/demo-sources/tsla-q4-2025/images/image-7-Tesla Interior Interface.jpg deleted file mode 100755 index 03b5e63..0000000 Binary files a/public/demo-sources/tsla-q4-2025/images/image-7-Tesla Interior Interface.jpg and /dev/null differ diff --git a/public/demo-sources/tsla-q4-2025/images/image-8-Tesla Interior.jpg b/public/demo-sources/tsla-q4-2025/images/image-8-Tesla Interior.jpg deleted file mode 100755 index a24acf9..0000000 Binary files a/public/demo-sources/tsla-q4-2025/images/image-8-Tesla Interior.jpg and /dev/null differ diff --git a/public/demo-sources/tsla-q4-2025/images/image-9-Tesla Cybertruck in Snow.jpg b/public/demo-sources/tsla-q4-2025/images/image-9-Tesla Cybertruck in Snow.jpg deleted file mode 100755 index 3a30afe..0000000 Binary files a/public/demo-sources/tsla-q4-2025/images/image-9-Tesla Cybertruck in Snow.jpg and /dev/null differ diff --git a/public/demo-sources/tsla-q4-2025/manifest.json b/public/demo-sources/tsla-q4-2025/manifest.json deleted file mode 100755 index 43bfcba..0000000 --- a/public/demo-sources/tsla-q4-2025/manifest.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "version": "2.0", - "job_id": "job_b7886be54d96", - "data_id": null, - "source_file_name": "TSLA-Q4-2025-Update(1).pdf", - "processing_date": "2026-05-12T02:42:26.779662Z", - "processing": { - "page_count": 35, - "billing_status": "charged", - "cost": { - "micro_dollars": 52500, - "credits": 0.0525 - }, - "timing": { - "started_at": "2026-05-12T02:41:42.463885+00:00", - "completed_at": "2026-05-12T02:42:26.292306+00:00", - "duration_ms": 43828 - } - }, - "statistics": { - "total_chunks": 70, - "text_chunks": 36, - "image_chunks": 19, - "table_chunks": 15, - "total_pages": null - }, - "HIERARCHY": { - "Root": {} - } -} \ No newline at end of file diff --git a/public/demo-sources/tsla-q4-2025/original.pdf b/public/demo-sources/tsla-q4-2025/original.pdf deleted file mode 100644 index 87cdb1b..0000000 Binary files a/public/demo-sources/tsla-q4-2025/original.pdf and /dev/null differ diff --git a/public/demo-sources/tsla-q4-2025/tables/table-0 Tesla 2025 Results.html b/public/demo-sources/tsla-q4-2025/tables/table-0 Tesla 2025 Results.html deleted file mode 100755 index 459a4b3..0000000 --- a/public/demo-sources/tsla-q4-2025/tables/table-0 Tesla 2025 Results.html +++ /dev/null @@ -1 +0,0 @@ -
Profitability$4.4B GAAP operating income in 2025; $1.4B in Q42025 marked a critical year for Tesla as we further expanded our mission and continued our transition from a hardware-centric business to a physical AI company. We laid the foundation for the future of Tesla as we further advanced FSD (Supervised) $^{4}$ , launched our Robotaxi service, began installing production lines for Cybercab and fine-tuned our production-primed Optimus design while expanding our AI training infrastructure.
$3.8B GAAP net income in 2025; $0.8B in Q4
$5.9B non-GAAP net income $^{1}$ in 2025; $1.8B in Q4
CashOperating cash flow of $14.7B in 2025; $3.8B in Q4Our approach to autonomous vehicles and humanoid robots mirrors the way we approached electric vehicles and energy storage – at the system level where we identify the limiting factor and develop bespoke and scalable solutions (batteries, power electronics, inverters, software, AI silicon, etc.) to optimize for cost, functionality, efficiency and safety. Our vertical integration has enabled us to achieve economies of scale in a profitable manner, quickly troubleshoot bottlenecks in production and iteratively optimize our technologies more rapidly than others.
Free cash flow $^{2}$ of $6.2B in 2025; $1.4B in Q4In 2025, we completed the refresh of our vehicle lineup with the launch of the new Model Y, including additional variants. We believe that maintaining an optimized and efficient product portfolio, with a continued focus on high-value features such as long range, best-in-class software and autonomy, is the correct strategy to win the autos market of the future. Similarly, we continued to evolve our energy offerings for commercial, utility and retail customers, as we position ourselves as a supplier of choice for clean, affordable and rapidly deployable energy capacity ahead of expected sustained demand growth for electricity.
$7.5B increase in our cash and investments $^{3}$ in 2025 to $44.1B
OperationsBegan removing safety monitor from our Robotaxis in Austin in JanuaryIn 2026, we will further invest in the infrastructure needed to support clean energy and transport and autonomous robots, including the ramp of six new production lines across vehicle, robots, energy storage and battery manufacturing, while further leveraging our existing factory, charging and service center footprint to support future growth.
Record Q4 & FY'25 energy storage deployments
Record vehicle deliveries in APAC
\ No newline at end of file diff --git a/public/demo-sources/tsla-q4-2025/tables/table-1 Q4 2025 Financials.html b/public/demo-sources/tsla-q4-2025/tables/table-1 Q4 2025 Financials.html deleted file mode 100755 index c2da74e..0000000 --- a/public/demo-sources/tsla-q4-2025/tables/table-1 Q4 2025 Financials.html +++ /dev/null @@ -1 +0,0 @@ -
($ in millions, except percentages and per share data)Q4-2024Q1-2025Q2-2025Q3-2025Q4-2025YoY
Total automotive revenues19,79813,96716,66121,20517,693-11%
Energy generation and storage revenue3,0612,7302,7893,4153,83725%
Services and other revenue2,8482,6383,0463,4753,37118%
Total revenues25,70719,33522,49628,09524,901-3%
Total gross profit4,1793,1533,8785,0545,00920%
Total GAAP gross margin16.3%16.3%17.2%18.0%20.1%386 bp
Operating expenses2,5962,7542,9553,4303,60039%
Income from operations1,5833999231,6241,409-11%
Operating margin6.2%2.1%4.1%5.8%5.7%-50 bp
Adjusted EBITDA (1) (2)4,3332,8143,4014,2274,154-4%
Adjusted EBITDA margin (1) (2)16.9%14.6%15.1%15.0%16.7%-17 bp
Net income attributable to common stockholders (GAAP) (1)2,1284091,1721,373840-61%
Net income attributable to common stockholders (non-GAAP) (1) (3)2,1079341,3931,7701,761-16%
EPS attributable to common stockholders, diluted (GAAP) (1)0.600.120.330.390.24-60%
EPS attributable to common stockholders, diluted (non-GAAP) (1) (3)0.600.270.400.500.50-17%
Net cash provided by operating activities4,8142,1562,5406,2383,813-21%
Capital expenditures (4)(2,780)(1,492)(2,394)(2,248)(2,393)-14%
Free cash flow (4)2,0346641463,9901,420-30%
Cash, cash equivalents and investments36,56336,99636,78241,64744,05921%
\ No newline at end of file diff --git a/public/demo-sources/tsla-q4-2025/tables/table-10 Cash Flow Q4-25.html b/public/demo-sources/tsla-q4-2025/tables/table-10 Cash Flow Q4-25.html deleted file mode 100755 index a99dda4..0000000 --- a/public/demo-sources/tsla-q4-2025/tables/table-10 Cash Flow Q4-25.html +++ /dev/null @@ -1 +0,0 @@ -
In millions of USDQ4-2024Q1-2025Q2-2025Q3-2025Q4-2025
CASH FLOWS FROM OPERATING ACTIVITIES
Net income (1)2,1434201,1901,389856
Adjustments to reconcile net income to net cash provided by operating activities:
Depreciation, amortization and impairment1,4961,4471,4331,6251,643
Stock-based compensation579573635663954
Deferred income taxes (1)6(43)52225(111)
Digital assets (gain) loss, net (1)(347)125(284)(80)307
Other(93)188187333378
Changes in operating assets and liabilities1030(554)(673)2,083(214)
Net cash provided by operating activities4,8142,1562,5406,2383,813
CASH FLOWS FROM INVESTING ACTIVITIES
Capital expenditures (2)(2,780)(1,492)(2,394)(2,248)(2,393)
Purchases of investments(15,158)(6,015)(7,485)(11,402)(12,207)
Proceeds from maturities of investments10,3355,8566,9359,2958,072
Net cash used in investing activities(7,603)(1,651)(2,944)(4,355)(6,528)
CASH FLOWS FROM FINANCING ACTIVITIES
Net cash flows from other debt activities(108)(50)(23)410963
Net borrowings (repayments) under vehicle and energy product financing677(674)(400)81(377)
Net cash flows from noncontrolling interests – Solar(37)(22)(14)(20)(22)
Other453414215512146
Net cash provided by (used in) financing activities985(332)(222)983710
Effect of exchange rate changes on cash and cash equivalents and restricted cash(133)40111(17)37
Net (decrease) increase in cash and cash equivalents and restricted cash(1,937)213(515)2,849(1,968)
Cash and cash equivalents and restricted cash at beginning of period18,97417,03717,25016,73519,584
Cash and cash equivalents and restricted cash at end of period17,03717,25016,73519,58417,616
\ No newline at end of file diff --git a/public/demo-sources/tsla-q4-2025/tables/table-11 Q4 2024-Q4 2025.html b/public/demo-sources/tsla-q4-2025/tables/table-11 Q4 2024-Q4 2025.html deleted file mode 100755 index 5d6569c..0000000 --- a/public/demo-sources/tsla-q4-2025/tables/table-11 Q4 2024-Q4 2025.html +++ /dev/null @@ -1 +0,0 @@ -
In millions of USD or shares as applicable, except per share dataQ4-2024Q1-2025Q2-2025Q3-2025Q4-2025
Net income attributable to common stockholders (GAAP) (1)2,1284091,1721,373840
Stock-based compensation expense, net of tax249428443459682
Digital assets (gain) loss, net of tax (1)(270)97(222)(62)239
Net income attributable to common stockholders (non-GAAP) (1) (2)2,1079341,3931,7701,761
Less: Buy-outs of noncontrolling interests3
Net income used in computing diluted EPS attributable to common stockholders (non-GAAP) (1) (2)2,1049341,3931,7701,761
EPS attributable to common stockholders, diluted (GAAP) (1)0.600.120.330.390.24
Stock-based compensation expense, net of tax, per share0.080.120.130.130.19
Digital assets (gain) loss, net of tax, per share (1)(0.08)0.03(0.06)(0.02)0.07
EPS attributable to common stockholders, diluted (non-GAAP) (1) (2)0.600.270.400.500.50
Shares used in EPS calculation, diluted (GAAP and non-GAAP)3,5173,5213,5193,5263,539
Net income attributable to common stockholders (GAAP) (1)2,1284091,1721,373840
Interest expense9691867685
Provision for income taxes (1)381169359570325
Depreciation, amortization and impairment1,4961,4471,4331,6251,643
Stock-based compensation expense579573635663954
Digital assets (gain) loss, net (1)(347)125(284)(80)307
Adjusted EBITDA (non-GAAP) (1) (3)4,3332,8143,4014,2274,154
Total revenues25,70719,33522,49628,09524,901
Adjusted EBITDA margin (non-GAAP) (1) (3)16.9%14.6%15.1%15.0%16.7%
Automotive gross margin (GAAP)16.6%16.2%17.2%17.0%20.4%
Less: Total regulatory credit revenue recognized3.0%3.7%2.2%1.6%2.5%
Automotive gross margin excluding regulatory credit sales (non-GAAP)13.6%12.5%15.0%15.4%17.9%
\ No newline at end of file diff --git a/public/demo-sources/tsla-q4-2025/tables/table-12 Financial Metrics 2021-25.html b/public/demo-sources/tsla-q4-2025/tables/table-12 Financial Metrics 2021-25.html deleted file mode 100755 index af39680..0000000 --- a/public/demo-sources/tsla-q4-2025/tables/table-12 Financial Metrics 2021-25.html +++ /dev/null @@ -1 +0,0 @@ -
In millions of USD or shares as applicable, except per share data20212022202320242025
Net income attributable to common stockholders (GAAP)5,51912,55614,9977,0913,794
Stock-based compensation expense, net of tax2,1211,5601,8121,3282,012
Digital assets loss (gain), net of tax79160(459)52
Release of valuation allowance on deferred tax assets(5,927)
Net income attributable to common stockholders (non-GAAP)(1)7,71914,27610,8827,9605,858
Less: Buy-outs of noncontrolling interests(5)(27)(2)(39)
Less: Dilutive convertible debt(9)(1)
Net income used in computing diluted EPS attributable to common stockholders (non-GAAP)(1)7,73314,30410,8847,9995,858
EPS attributable to common stockholders, diluted (GAAP)1.633.624.302.041.08
Stock-based compensation expense, net of tax, per share0.630.450.520.380.57
Digital assets loss (gain), net of tax, per share0.020.05(0.13)0.01
Release of valuation allowance on deferred tax assets(1.70)
EPS attributable to common stockholders, diluted (non-GAAP)(1)2.284.123.122.291.66
Shares used in EPS calculation, diluted (GAAP and non-GAAP)3,3863,4753,4853,4983,528
Net income attributable to common stockholders (GAAP)5,51912,55614,9977,0913,794
Interest expense371191156350338
Provision for (benefit from) income taxes6991132(5,001)1,8371,423
Depreciation, amortization and impairment2,9113,7474,6675,3686,148
Stock-based compensation expense2,1211,5601,8121,9992,825
Digital assets loss (gain), net101204(589)68
Adjusted EBITDA (non-GAAP)(2)11,72219,39016,63116,05614,596
Total revenues53,82381,46296,77397,69094,827
Adjusted EBITDA margin (non-GAAP)(2)21.8%23.8%17.2%16.4%15.4%
Automotive gross margin (GAAP)29.3%28.5%19.4%18.4%17.8%
Less: Total regulatory credit revenue recognized2.3%1.8%1.7%3.0%2.4%
Automotive gross margin excluding regulatory credit sales (non-GAAP)27.0%26.7%17.7%15.4%15.4%
\ No newline at end of file diff --git a/public/demo-sources/tsla-q4-2025/tables/table-13 Financial Data 2022-25.html b/public/demo-sources/tsla-q4-2025/tables/table-13 Financial Data 2022-25.html deleted file mode 100755 index f850e6e..0000000 --- a/public/demo-sources/tsla-q4-2025/tables/table-13 Financial Data 2022-25.html +++ /dev/null @@ -1 +0,0 @@ -
In millions of USD2Q-20223Q-20224Q-20221Q-20232Q-20233Q-20234Q-20231Q-20242Q-20243Q-20244Q-20241Q-20252Q-20253Q-20254Q-2025
Net cash provided by operating activities (GAAP)2,3515,1003,2782,5133,0653,3084,3702423,6126,2554,8142,1562,5406,2383,813
Capital expenditures (1)(1,730)(1,803)(1,858)(2,073)(2,060)(2,459)(2,307)(2,777)(2,272)(3,513)(2,780)(1,492)(2,394)(2,248)(2,393)
Free cash flow (non-GAAP) (1)6213,2971,4204401,0058492,063(2,535)1,3402,7422,0346641463,9901,420
In millions of USD2Q-20223Q-20224Q-20221Q-20232Q-20233Q-20234Q-20231Q-20242Q-20243Q-20244Q-20241Q-20 252Q-20253Q-20254Q-2025
Net income attributable to common stockholders (GAAP) (2)2,2593,2923,6872,5132,7031,8537,9281,3901,4002,1732,1284091,1721,373840
Interest expense445333292838617686929691867685
Provision for (benefit from) income taxes (2)205305276261323167(5,752)483371602381169359570325
Depreciation, amortization and impairment9229569891,0461,1541,2351,2321,2461,2781,3481,4961,4471,4331,6251,643
Stock-based compensation expense361362419418445465484524439457579573635663954
Digital assets loss (gain), net (2)17034(335)100(7)(347)125(284)(80)307
Adjusted EBITDA (non-GAAP) (2) (3)3,9614,9685,4384,2674,6533,7583,9533,3843,6744,6654,3332,8143,4014,2274,154
\ No newline at end of file diff --git a/public/demo-sources/tsla-q4-2025/tables/table-14 Financial Metrics 2023-25.html b/public/demo-sources/tsla-q4-2025/tables/table-14 Financial Metrics 2023-25.html deleted file mode 100755 index f164537..0000000 --- a/public/demo-sources/tsla-q4-2025/tables/table-14 Financial Metrics 2023-25.html +++ /dev/null @@ -1 +0,0 @@ -
In millions of USD1Q-20232Q-20233Q-20234Q-20231Q-20242Q-20243Q-20244Q-20241Q-20252Q-20253Q-20254Q-2025
Net cash provided by operating activities – TTM (GAAP)13,24213,95612,16413,25610,98511,53214,47914,92316,83715,76515,74814,747
Capital expenditures – TTM (1)(7,464)(7,794)(8,450)(8,899)(9,603)(9,815)(10,869)(11,342)(10,057)(10,179)(8,914)(8,527)
Free cash flow – TTM (non-GAAP) (1)5,7786,1623,7144,3571,3821,7173,6103,5816,7805,5866,8346,220
In millions of USD1Q-20232Q-20233Q-20234Q-20231Q-20242Q-20243Q-20244Q-20241Q-20252Q-20253Q-20254Q-20
Net income attributable to common stockholders – TTM (GAAP) (2)11,75112,19510,75614,99713,87412,57112,8917,0916,1105,8825,0823,794
Interest expense – TTM159143128156203261315350365365349338
Provision for (benefit from) income taxes – TTM (2)1,0471,1651,027(5,001)(4,779)(4,731)(4,296)1,8371,5231,5111,4791,423
Depreciation, amortization and impairment – TTM3,9134,1454,4244,6674,8674,9915,1045,3685,5695,7246,0016,148
Stock-based compensation expense – TTM1,5601,6441,7471,8121,9181,9121,9041,9992,0482,2442,4502,825
Digital assets loss (gain), net – TTM (2)2043434(335)(235)(242)(589)(129)(513)(586)68
Adjusted EBITDA – TTM (non-GAAP) (2) (3)18,63419,32618,11616,63115,74814,76915,67616,05615,48615,21314,77514,596
\ No newline at end of file diff --git a/public/demo-sources/tsla-q4-2025/tables/table-2 Financial Data 2021-25.html b/public/demo-sources/tsla-q4-2025/tables/table-2 Financial Data 2021-25.html deleted file mode 100755 index c1da2bc..0000000 --- a/public/demo-sources/tsla-q4-2025/tables/table-2 Financial Data 2021-25.html +++ /dev/null @@ -1 +0,0 @@ -
($ in millions, except percentages and per share data)20212022202320242025YoY
Total automotive revenues47,23271,46282,41977,07069,526-10%
Energy generation and storage revenue2,7893,9096,03510,08612,77127%
Services and other revenue3,8026,0918,31910,53412,53019%
Total revenues53,82381,46296,77397,69094,827-3%
Total gross profit13,60620,85317,66017,45017,094-2%
Total GAAP gross margin25.3%25.6%18.2%17.9%18.0%16 bp
Operating expenses7,0837,1978,76910,37412,73923%
Income from operations6,52313,6568,8917,0764,355-38%
Operating margin12.1%16.8%9.2%7.2%4.6%-265 bp
Adjusted EBITDA (1)11,72219,39016,63116,05614,596-9%
Adjusted EBITDA margin (1)21.8%23.8%17.2%16.4%15.4%-104 bp
Net income attributable to common stockholders (GAAP)5,51912,55614,9977,0913,794-46%
Net income attributable to common stockholders (non-GAAP) (2)7,71914,27610,8827,9605,858-26%
EPS attributable to common stockholders, diluted (GAAP)1.633.624.302.041.08-47%
EPS attributable to common stockholders, diluted (non-GAAP) (2)2.284.123.122.291.66-28%
Net cash provided by operating activities11,49714,72413,25614,92314,747-1%
Capital expenditures (3)(6,514)(7,163)(8,899)(11,342)(8,527)-25%
Free cash flow (3)4,9837,5614,3573,5816,22074%
Cash, cash equivalents and investments17,70722,18529,09436,56344,05921%
\ No newline at end of file diff --git a/public/demo-sources/tsla-q4-2025/tables/table-3 Tesla Q4-2025 Data.html b/public/demo-sources/tsla-q4-2025/tables/table-3 Tesla Q4-2025 Data.html deleted file mode 100755 index 8e42226..0000000 --- a/public/demo-sources/tsla-q4-2025/tables/table-3 Tesla Q4-2025 Data.html +++ /dev/null @@ -1 +0,0 @@ -
Q4-2024Q1-2025Q2-2025Q3-2025Q4-2025YoY
Model 3/Y production436,718345,454396,835435,826422,652-3%
Other models production22,72717,16113,40911,62411,706-48%
Total production459,445362,615410,244447,450434,358-5%
Model 3/Y deliveries471,930323,800373,728481,166406,585-14%
Other models deliveries23,64012,88110,39415,93311,642-51%
Total deliveries495,570336,681384,122497,099418,227-16%
of which subject to operating lease accounting26,96213,7216,67010,23010,996-59%
Cumulative $deliveries^{(1)}$ (all-time; mil)7.37.68.08.58.922%
Active FSD $Subscriptions^{(2)}$ (mil)0.80.80.91.01.138%
Total end of quarter operating lease (new vehicle) $count^{(3)}$ 180,523179,930172,882167,163163,075-10%
Global vehicle inventory (days of supply) $^{(4)}$ 122224101525%
Storage deployed (GWh)11.010.49.612.514.229%
Tesla locations1,3591,3901,4541,4981,55314%
Supercharger stations6,9757,1317,3777,7538,18217%
Supercharger connectors65,49567,31670,22873,81777,68219%
\ No newline at end of file diff --git a/public/demo-sources/tsla-q4-2025/tables/table-4 Tesla 2021-2025 Data.html b/public/demo-sources/tsla-q4-2025/tables/table-4 Tesla 2021-2025 Data.html deleted file mode 100755 index 65641b1..0000000 --- a/public/demo-sources/tsla-q4-2025/tables/table-4 Tesla 2021-2025 Data.html +++ /dev/null @@ -1 +0,0 @@ -
20212022202320242025YoY
Model 3/Y production906,0321,298,4341,775,1591,679,3381,600,767-5%
Other models production24,39071,17770,82694,10553,900-43%
Total production930,4221,369,6111,845,9851,773,4431,654,667-7%
Model 3/Y deliveries911,2421,247,1461,739,7071,704,0931,585,279-7%
Other models deliveries24,98066,70568,87485,13350,850-40%
Total deliveries936,2221,313,8511,808,5811,789,2261,636,129-9%
of which subject to operating lease accounting60,91247,58272,22660,00341,617-31%
Cumulative $deliveries^{(1)}$ (all-time; mil)2.33.75.57.38.922%
Active FSD $Subscriptions^{(2)}$ (mil)0.40.50.60.81.138%
Total end of year operating lease (new vehicle) count120,342140,667176,564180,523163,075-10%
Global vehicle inventory (days of supply) $^{(3)}$ 61616131515%
Storage deployed (GWh)4.06.514.731.446.749%
Tesla locations6449631,2081,3591,55314%
Supercharger stations3,4764,6785,9526,9758,18217%
Supercharger connectors31,49842,41954,89265,49577,68219%
\ No newline at end of file diff --git a/public/demo-sources/tsla-q4-2025/tables/table-5 Tesla Production.html b/public/demo-sources/tsla-q4-2025/tables/table-5 Tesla Production.html deleted file mode 100755 index a95f021..0000000 --- a/public/demo-sources/tsla-q4-2025/tables/table-5 Tesla Production.html +++ /dev/null @@ -1 +0,0 @@ -
RegionProductCapacityStatus
Automotive
CaliforniaModel 3 / Model Y>550,000Production
Model S / Model X100,000Production
ShanghaiModel 3 / Model Y>950,000Production
BerlinModel Y>375,000Production
TexasModel Y>250,000Production
Cybertruck>125,000Production
Cybercab-Tooling
NevadaTesla Semi-Tooling
TBDRoadster-Design development
Energy Generation and Storage
CaliforniaMegapack40 GWhProduction
NevadaPowerwall>6 GWhProduction
ShanghaiMegapack40 GWhProduction
TexasMegapack-Construction
Robotics
CaliforniaOptimus-Construction
\ No newline at end of file diff --git a/public/demo-sources/tsla-q4-2025/tables/table-6 Facility Status.html b/public/demo-sources/tsla-q4-2025/tables/table-6 Facility Status.html deleted file mode 100755 index 3c1a54b..0000000 --- a/public/demo-sources/tsla-q4-2025/tables/table-6 Facility Status.html +++ /dev/null @@ -1 +0,0 @@ -
RegionProductCapacityStatus
AI Training Compute
TexasCortex 1>100k H100eProduction
Cortex 2-Construction
Battery Manufacturing
NevadaLFP7 GWhEarly Ramp
Texas468040 GWhProduction
Cathode Materials10 GWhEarly Ramp
Lithium Refining30 GWhEarly Ramp
\ No newline at end of file diff --git a/public/demo-sources/tsla-q4-2025/tables/table-7 Autonomous Driving Status.html b/public/demo-sources/tsla-q4-2025/tables/table-7 Autonomous Driving Status.html deleted file mode 100755 index 21f90f5..0000000 --- a/public/demo-sources/tsla-q4-2025/tables/table-7 Autonomous Driving Status.html +++ /dev/null @@ -1 +0,0 @@ -
StateMetroStatus
CaliforniaSF Bay AreaSafety Driver
TexasAustinRamping Unsupervised
Dallas1H 2026
Houston1H 2026
ArizonaPhoenix1H 2026
FloridaMiami1H 2026
Orlando1H 2026
Tampa1H 2026
NevadaLas Vegas1H 2026
\ No newline at end of file diff --git a/public/demo-sources/tsla-q4-2025/tables/table-8 Q4 2024-Q4 2025 Rev.html b/public/demo-sources/tsla-q4-2025/tables/table-8 Q4 2024-Q4 2025 Rev.html deleted file mode 100755 index 793ce07..0000000 --- a/public/demo-sources/tsla-q4-2025/tables/table-8 Q4 2024-Q4 2025 Rev.html +++ /dev/null @@ -1 +0,0 @@ -
In millions of USD or shares as applicable, except per share dataQ4-2024Q1-2025Q2-2025Q3-2025Q4-2025
REVENUES
Automotive sales18,65912,92515,78720,35916,750
Automotive regulatory credits692595439417542
Automotive leasing447447435429401
Total automotive revenues19,79813,96716,66121,20517,693
Energy generation and storage3,0612,7302,7893,4153,837
Services and other2,8482,6383,0463,4753,371
Total revenues25,70719,33522,49628,09524,901
COST OF REVENUES
Automotive sales16,26811,46113,56717,36513,874
Automotive leasing242239228225206
Total automotive cost of revenues16,51011,70013,79517,59014,080
Energy generation and storage2,2891,9451,9432,3422,739
Services and other2,7292,5372,8803,1093,073
Total cost of revenues21,52816,18218,61823,04119,892
Gross profit4,1793,1533,8785,0545,009
OPERATING EXPENSES
Research and development1,2761,4091,5891,6301,783
Selling, general and administrative1,3131,2511,3661,5621,655
Restructuring and other794238162
Total operating expenses2,5962,7542,9553,4303,600
INCOME FROM OPERATIONS1,5833999231,6241,409
Interest income442400392439449
Interest expense(96)(91)(86)(76)(85)
Other income (expense), net (1)595(119)320(28)(592)
INCOME BEFORE INCOME TAXES (1)2,5245891,5491,9591,181
Provision for income taxes (1)381169359570325
NET INCOME (1)2,1434201,1901,389856
Net income attributable to noncontrolling interests and redeemable noncontrolling interests in subsidiaries1511181616
NET INCOME ATTRIBUTABLE TO COMMON STOCKHOLDERS (1)2,1284091,1721,373840
Less: Buy-out of noncontrolling interest3
NET INCOME USED IN COMPUTING NET INCOME PER SHARE OF COMMON STOCK (1)2,1254091,1721,373840
Net income per share of common stock attributable to common stockholders
Basic (1)$ 0.66$ 0.13$ 0.36$ 0.43$ 0.26
Diluted (1)$ 0.60$ 0.12$ 0.33$ 0.39$ 0.24
Weighted average shares used in computing net income per share of common stock
Basic3,2133,2183,2233,2273,231
Diluted3,5173,5213,5193,5263,539
\ No newline at end of file diff --git a/public/demo-sources/tsla-q4-2025/tables/table-9 Balance Sheet 2024-25.html b/public/demo-sources/tsla-q4-2025/tables/table-9 Balance Sheet 2024-25.html deleted file mode 100755 index fa828c3..0000000 --- a/public/demo-sources/tsla-q4-2025/tables/table-9 Balance Sheet 2024-25.html +++ /dev/null @@ -1 +0,0 @@ -
In millions of USD31-Dec-2431-Mar-2530-Jun-2530-Sep-2531-Dec-25
ASSETS
Current assets
Cash, cash equivalents and investments36,56336,99636,78241,64744,059
Accounts receivable, net4,4183,7823,8384,7034,576
Inventory12,01713,70614,57012,27612,392
Prepaid expenses and other current assets5,3624,9055,9436,0277,615
Total current assets58,36059,38961,13364,65368,642
Operating lease vehicles, net5,5815,4775,2305,0194,912
Energy generation and storage systems, net4,9244,8554,7884,6734,604
Property, plant and equipment, net35,83637,08838,57439,40740,643
Operating lease right-of-use assets5,1605,3305,6335,7836,027
Digital assets (2)1,0769511,2351,3151,008
Deferred tax assets (2)6,5246,6876,7216,6376,925
Other non-current assets4,6095,3345,2536,2485,045
Total assets (2)122,070125,111128,567133,735137,806
LIABILITIES AND EQUITY
Current liabilities
Accounts payable12,47413,47113,21212,81913,371
Accrued liabilities and other10,72310,80211,51912,79113,279
Deferred revenue3,1683,2433,2373,7563,424
Current portion of debt and finance leases (1)2,4562,2372,0401,9241,640
Total current liabilities28,82129,75330,00831,29031,714
Debt and finance leases, net of current portion (1)5,7575,2925,1805,7786,736
Deferred revenue, net of current portion3,3173,6103,7643,7463,631
Other long-term liabilities10,49511,03811,54312,20512,860
Total liabilities48,39049,69350,49553,01954,941
Redeemable noncontrolling interests in subsidiaries6362615958
Total stockholders' equity (2)72,91374,65377,31479,97082,137
Noncontrolling interests in subsidiaries704703697687670
Total liabilities and equity (2)122,070125,111128,567133,735137,806
(1) Breakdown of our debt is as follows:
Non-recourse debt7,8717,2386,9537,4588,150
Recourse debt76333
Days sales outstanding1419151417
Days payable outstanding5872655261
\ No newline at end of file diff --git a/public/demo-sources/tsla-q4-2025/toc_hierarchies.json b/public/demo-sources/tsla-q4-2025/toc_hierarchies.json deleted file mode 100755 index 15f0979..0000000 --- a/public/demo-sources/tsla-q4-2025/toc_hierarchies.json +++ /dev/null @@ -1,30 +0,0 @@ -[ - { - "toc_range": [ - 1, - 15 - ], - "scan_range": [ - 1, - 194 - ], - "toc_with_level": "| id | heading | level |\n|----|------------------------------|-------|\n| 0 | # Q4 and FY 2025 Update | 1 |\n| 1 | Highlights 03 | 2 |\n| 2 | Financial Summary 04 | 2 |\n| 3 | Operational Summary 06 | 2 |\n| 4 | Manufacturing & Hardware 08 | 2 |\n| 5 | Supporting Infrastructure 09 | 2 |\n| 6 | AI & Software 10 | 2 |\n| 7 | Services 11 | 2 |\n| 8 | Other Updates 12 | 2 |\n| 9 | Outlook 13 | 2 |\n| 10 | Photos & Charts 14 | 2 |\n| 11 | Key Metrics 24 | 2 |\n| 12 | Financial Statements 27 | 2 |\n| 13 | Additional Information 34 | 2 |", - "toc_tree": { - "# Q4 and FY 2025 Update": { - "Highlights 03": {}, - "Financial Summary 04": {}, - "Operational Summary 06": {}, - "Manufacturing & Hardware 08": {}, - "Supporting Infrastructure 09": {}, - "AI & Software 10": {}, - "Services 11": {}, - "Other Updates 12": {}, - "Outlook 13": {}, - "Photos & Charts 14": {}, - "Key Metrics 24": {}, - "Financial Statements 27": {}, - "Additional Information 34": {} - } - } - } -] \ No newline at end of file diff --git a/src/app/api/demo-sources/[demoSourceId]/assets/[...assetPath]/route.ts b/src/app/api/demo-sources/[demoSourceId]/assets/[...assetPath]/route.ts new file mode 100644 index 0000000..247f645 --- /dev/null +++ b/src/app/api/demo-sources/[demoSourceId]/assets/[...assetPath]/route.ts @@ -0,0 +1,39 @@ +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 new file mode 100644 index 0000000..c3b04d3 --- /dev/null +++ b/src/app/api/demo-sources/[demoSourceId]/original/route.ts @@ -0,0 +1,35 @@ +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 new file mode 100644 index 0000000..a0a3187 --- /dev/null +++ b/src/app/api/demo-sources/materialize/route.test.ts @@ -0,0 +1,195 @@ +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: "/api/v1/demo/sources/demo-tsla-q4-2025/original", + 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", + documentId: "doc_user_copy", + originalFile: { + url: "/api/demo-sources/demo-tsla-q4-2025/original", + mimeType: "application/pdf", + sizeBytes: 5648867, + canDownload: false, + }, + 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: "/api/demo-sources/demo-tsla-q4-2025/original", + }, + ) + }) + + 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): 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: "/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, + } +} diff --git a/src/app/api/demo-sources/materialize/route.ts b/src/app/api/demo-sources/materialize/route.ts new file mode 100644 index 0000000..8e6f4b6 --- /dev/null +++ b/src/app/api/demo-sources/materialize/route.ts @@ -0,0 +1,175 @@ +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 { 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: `/api/demo-sources/${encodeURIComponent( + source.demoSourceId, + )}/original`, + }), + ) + 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/source-uploads/blob/route.ts b/src/app/api/source-uploads/blob/route.ts index 2dd5c73..a2c1d92 100644 --- a/src/app/api/source-uploads/blob/route.ts +++ b/src/app/api/source-uploads/blob/route.ts @@ -1,3 +1,4 @@ +import { Effect } from "effect" import { del } from "@vercel/blob" import { handleUpload, type HandleUploadBody } from "@vercel/blob/client" import type { NextRequest, NextResponse } from "next/server" @@ -13,81 +14,107 @@ import { nextRouteResponse } from "@/lib/next-route-response" import { routeResult } from "@/lib/route-result" export async function POST(request: NextRequest): Promise { - const user = await getCurrentUser() - if (!user) { - return nextRouteResponse.toNextResponse( - routeResult.error(401, "Please log in to upload documents."), - ) - } + return Effect.runPromise( + Effect.gen(function* () { + const user = yield* Effect.tryPromise(() => getCurrentUser()) + if (!user) { + return nextRouteResponse.toNextResponse( + routeResult.error(401, "Please log in to upload documents."), + ) + } - try { - const body = (await request.json()) as HandleUploadBody - const response = await handleUpload({ - body, - request, - onBeforeGenerateToken: async (pathname, clientPayload) => { - const input = parseSourceBlobClientPayload(clientPayload) - if (!input) { - throw new Error("Invalid upload metadata.") - } + const body = (yield* Effect.tryPromise(() => + request.json(), + )) as HandleUploadBody - const validation = validateSourceBlobUploadMetadata({ - ...input, - pathname, - }) - if (!validation.ok) { - throw new Error(validation.message) - } + const result = yield* Effect.tryPromise(() => + handleUpload({ + body, + request, + onBeforeGenerateToken: async (pathname, clientPayload) => { + const input = parseSourceBlobClientPayload(clientPayload) + if (!input) { + throw new Error("Invalid upload metadata.") + } - return { - addRandomSuffix: true, - allowOverwrite: false, - maximumSizeInBytes: MAX_UPLOAD_BYTES, - tokenPayload: JSON.stringify({ - userId: user.id, - fileName: validation.title, - mimeType: validation.mimeType, - sizeBytes: input.sizeBytes, - }), - } - }, - }) + const validation = validateSourceBlobUploadMetadata({ + ...input, + pathname, + }) + if (!validation.ok) { + throw new Error(validation.message) + } - return nextRouteResponse.toNextResponse(routeResult.ok(response)) - } catch (error) { - const message = error instanceof Error - ? error.message - : "Could not prepare the upload." - return nextRouteResponse.toNextResponse(routeResult.badRequest(message)) - } + return { + addRandomSuffix: true, + allowOverwrite: false, + maximumSizeInBytes: MAX_UPLOAD_BYTES, + tokenPayload: JSON.stringify({ + userId: user.id, + fileName: validation.title, + mimeType: validation.mimeType, + sizeBytes: input.sizeBytes, + }), + } + }, + }), + ).pipe( + Effect.map((response) => + nextRouteResponse.toNextResponse(routeResult.ok(response)), + ), + Effect.catchAll((error) => { + const message = + error instanceof Error + ? error.message + : "Could not prepare the upload." + return Effect.succeed( + nextRouteResponse.toNextResponse(routeResult.badRequest(message)), + ) + }), + ) + + return result + }), + ) } export async function DELETE(request: NextRequest): Promise { - const user = await getCurrentUser() - if (!user) { - return nextRouteResponse.toNextResponse( - routeResult.error(401, "Please log in to upload documents."), - ) - } + return Effect.runPromise( + Effect.gen(function* () { + const user = yield* Effect.tryPromise(() => getCurrentUser()) + if (!user) { + return nextRouteResponse.toNextResponse( + routeResult.error(401, "Please log in to upload documents."), + ) + } - try { - const body = (await request.json()) as unknown - const pathname = getCleanupPathname(body) - if (!pathname || !isValidSourceBlobPathname(pathname)) { - return nextRouteResponse.toNextResponse( - routeResult.badRequest( - "Invalid upload path. Choose the document again.", + const body = (yield* Effect.tryPromise(() => + request.json(), + ).pipe( + Effect.catchAll( + (): Effect.Effect => Effect.succeed(null), ), - ) - } + )) as unknown + + const pathname = getCleanupPathname(body) + if (!pathname || !isValidSourceBlobPathname(pathname)) { + return nextRouteResponse.toNextResponse( + routeResult.badRequest("Invalid upload path. Choose the document again."), + ) + } - await del(pathname) - return nextRouteResponse.toNextResponse(routeResult.ok({ ok: true })) - } catch { - return nextRouteResponse.toNextResponse( - routeResult.error(500, "Could not clean up the upload."), - ) - } + yield* Effect.tryPromise(() => del(pathname)) + return nextRouteResponse.toNextResponse(routeResult.ok({ ok: true })) + }).pipe( + Effect.catchAll(() => + Effect.succeed( + nextRouteResponse.toNextResponse( + routeResult.error(500, "Could not clean up the upload."), + ), + ), + ), + ), + ) } function getCleanupPathname(body: unknown): string | null { diff --git a/src/app/api/sources/[sourceId]/chunks/route.test.ts b/src/app/api/sources/[sourceId]/chunks/route.test.ts index 802890a..4b7ebf7 100644 --- a/src/app/api/sources/[sourceId]/chunks/route.test.ts +++ b/src/app/api/sources/[sourceId]/chunks/route.test.ts @@ -1,72 +1,103 @@ -import { NextRequest } from "next/server"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server" +import { beforeEach, describe, expect, it, vi } from "vitest" const mocks = vi.hoisted(() => ({ ensureApiKeyForWorkspace: vi.fn(), ensureWorkspace: vi.fn(), + fetchDemoChunkPage: vi.fn(), findSourceInWorkspace: vi.fn(), getCurrentUser: vi.fn(), getSourceParseAssetUrls: vi.fn(), makeKnowhereClient: vi.fn(), requireUser: vi.fn(), -})); +})) vi.mock("next/headers", () => ({ headers: vi.fn(async () => new Headers({ cookie: "session=abc" })), -})); +})) 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, -})); +})) vi.mock("@/integrations/knowhere", () => ({ makeKnowhereClient: mocks.makeKnowhereClient, -})); +})) vi.mock("@/domains/sources/service", () => ({ sourceService: { findInWorkspace: mocks.findSourceInWorkspace, getParseAssetUrls: mocks.getSourceParseAssetUrls, }, -})); +})) vi.mock("@/domains/workspace/service", () => ({ workspaceService: { ensureWorkspace: mocks.ensureWorkspace, }, -})); +})) -import { GET } from "./route"; +import { GET } from "./route" describe("GET /api/sources/[sourceId]/chunks", () => { beforeEach(() => { - vi.clearAllMocks(); - }); + vi.clearAllMocks() + }) - it("serves bundled chunks for persisted demo sources without calling Knowhere", async () => { - mocks.getCurrentUser.mockResolvedValue({ id: "user_1" }); - mocks.ensureWorkspace.mockResolvedValue({ id: "workspace_1" }); - mocks.findSourceInWorkspace.mockResolvedValue({ - id: "source_demo", - demoKey: "demo-tsla-q4-2025", - knowhereDocumentId: "demo-doc-tsla-q4-2025", - }); + 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/source_demo/chunks?page=1&pageSize=1"), - { params: Promise.resolve({ sourceId: "source_demo" }) }, - ); + 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(1).pdf", + sourceTitle: "TSLA-Q4-2025-Update.pdf", }, ], pagination: { @@ -74,10 +105,400 @@ describe("GET /api/sources/[sourceId]/chunks", () => { pageSize: 1, total: 70, }, - }); - expect(response.status).toBe(200); - expect(mocks.ensureApiKeyForWorkspace).not.toHaveBeenCalled(); - expect(mocks.makeKnowhereClient).not.toHaveBeenCalled(); - expect(mocks.getSourceParseAssetUrls).not.toHaveBeenCalled(); - }); -}); + }) + 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: { + listChunks: vi.fn(async () => ({ + chunks: [ + { + id: "dchk_1", + chunkId: "parser_1", + chunkType: "text", + content: "Workspace chunk", + sectionPath: "Summary", + sourceChunkPath: "Default_Root/notes.pdf/Summary", + filePath: null, + metadata: {}, + sortOrder: 0, + }, + ], + pagination: { + page: 1, + pageSize: 1, + total: 1, + totalPages: 1, + }, + })), + }, + } + 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-000000000002", + workspaceId: "workspace_1", + title: "notes.pdf", + mimeType: "application/pdf", + sizeBytes: 1024, + status: "ready", + failureReason: null, + knowhereJobId: "job_1", + knowhereDocumentId: "doc_1", + stagedBlobPathname: null, + 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, + }) + mocks.ensureApiKeyForWorkspace.mockResolvedValue("jwt_123") + mocks.makeKnowhereClient.mockReturnValue(knowhereClient) + mocks.getSourceParseAssetUrls.mockResolvedValue({}) + + const response = await GET( + new NextRequest( + "http://localhost:3001/api/sources/00000000-0000-0000-0000-000000000002/chunks?page=1&pageSize=1", + ), + { params: Promise.resolve({ sourceId: "00000000-0000-0000-0000-000000000002" }) }, + ) + + await expect(response.json()).resolves.toMatchObject({ + chunks: [ + { + chunkId: "dchk_1", + parserChunkId: "parser_1", + documentId: "doc_1", + sourceTitle: "notes.pdf", + }, + ], + pagination: { + page: 1, + pageSize: 1, + total: 1, + }, + }) + expect(response.status).toBe(200) + expect(mocks.fetchDemoChunkPage).not.toHaveBeenCalled() + expect(knowhereClient.documents.listChunks).toHaveBeenCalledWith("doc_1", { + page: 1, + pageSize: 1, + includeAssetUrls: true, + }) + }) +}) diff --git a/src/app/api/sources/[sourceId]/route.test.ts b/src/app/api/sources/[sourceId]/route.test.ts index 9e53849..6e49d81 100644 --- a/src/app/api/sources/[sourceId]/route.test.ts +++ b/src/app/api/sources/[sourceId]/route.test.ts @@ -8,8 +8,10 @@ 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(), softDeleteSource: vi.fn(), @@ -28,6 +30,13 @@ 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, @@ -40,6 +49,7 @@ vi.mock("@/integrations/knowhere", () => ({ vi.mock("@/domains/sources/service", () => ({ sourceService: { findInWorkspace: mocks.findSourceInWorkspace, + hideDemoSource: mocks.hideDemoSource, softDelete: mocks.softDeleteSource, }, })); @@ -132,16 +142,22 @@ describe("PATCH /api/sources/[sourceId]", () => { ); }); - it("soft deletes demo sources without calling Knowhere archive or Blob cleanup", async () => { + 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: "demo-doc-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", { @@ -156,12 +172,53 @@ describe("PATCH /api/sources/[sourceId]", () => { archived: true, }); expect(response.status).toBe(200); - expect(mocks.ensureApiKeyForWorkspace).not.toHaveBeenCalled(); - expect(mocks.archive).not.toHaveBeenCalled(); + 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(); }); }); diff --git a/src/app/api/sources/reconcile/route.ts b/src/app/api/sources/reconcile/route.ts new file mode 100644 index 0000000..c28bfa4 --- /dev/null +++ b/src/app/api/sources/reconcile/route.ts @@ -0,0 +1,50 @@ +import { serve } from "@upstash/workflow/nextjs" + +import { reconcileSourcesForWorkspace } from "@/domains/sources/reconcile" +import { makeKnowhereClient } from "@/integrations/knowhere" +import { logger } from "@/lib/logger" + +type ReconcilePayload = { + readonly workspaceId: string + readonly sourceId: string + readonly apiKey: string +} + +const MAX_POLL_ATTEMPTS = 60 +const INITIAL_DELAY_S = 3 +const MAX_DELAY_S = 30 + +export const { POST } = serve(async (context) => { + const { workspaceId, sourceId, apiKey } = context.requestPayload + const workspace = { id: workspaceId } + const client = makeKnowhereClient(apiKey) + let delay = INITIAL_DELAY_S + + for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt++) { + const resolved = await context.run(`poll-${attempt}`, async () => { + const sources = await reconcileSourcesForWorkspace(workspace, client) + const source = sources.find((s) => s.id === sourceId) + if (!source || source.status !== "parsing") { + return { done: true, status: source?.status ?? "gone" } as const + } + return { done: false } as const + }) + + if (resolved.done) { + logger.info("workflow: source resolved", { + sourceId, + status: resolved.status, + attempts: attempt + 1, + }) + return + } + + await context.sleep(`wait-${attempt}`, delay) + delay = Math.min(Math.round(delay * 1.5), MAX_DELAY_S) + } + + logger.error("workflow: exhausted poll attempts", { + sourceId, + maxAttempts: MAX_POLL_ATTEMPTS, + }) +}) diff --git a/src/app/api/sources/route.test.ts b/src/app/api/sources/route.test.ts index 5ef1d8a..a78639b 100644 --- a/src/app/api/sources/route.test.ts +++ b/src/app/api/sources/route.test.ts @@ -107,6 +107,7 @@ describe("POST /api/sources", () => { await expect(response.json()).resolves.toEqual({ source: { id: "source_1", + kind: "workspace", title: "notes.pdf", status: "parsing", mimeType: "application/pdf", @@ -146,6 +147,7 @@ describe("POST /api/sources", () => { await expect(response.json()).resolves.toEqual({ source: { id: "source_1", + kind: "workspace", title: "notes.pdf", status: "parsing", mimeType: "application/pdf", diff --git a/src/app/globals.css b/src/app/globals.css index 84b8d5f..0fab70e 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -6,9 +6,15 @@ @theme inline { --color-background: var(--background); + --color-background-secondary: var(--background-secondary); + --color-background-tertiary: var(--background-tertiary); --color-foreground: var(--foreground); --font-sans: var(--font-sans); - --font-mono: var(--font-geist-mono); + --font-mono: var(--font-mono); + --font-mono-display: var(--font-mono-display); + --font-mono-readable: var(--font-mono-readable); + --font-accent: var(--font-accent); + --font-brand: var(--font-brand); --font-heading: var(--font-sans); --color-sidebar-ring: var(--sidebar-ring); --color-sidebar-border: var(--sidebar-border); @@ -29,12 +35,16 @@ --color-destructive: var(--destructive); --color-accent-foreground: var(--accent-foreground); --color-accent: var(--accent); + --color-accent-light: var(--accent-light); + --color-accent-dark: var(--accent-dark); --color-muted-foreground: var(--muted-foreground); --color-muted: var(--muted); --color-secondary-foreground: var(--secondary-foreground); --color-secondary: var(--secondary); --color-primary-foreground: var(--primary-foreground); --color-primary: var(--primary); + --color-primary-light: var(--primary-light); + --color-primary-dark: var(--primary-dark); --color-popover-foreground: var(--popover-foreground); --color-popover: var(--popover); --color-card-foreground: var(--card-foreground); @@ -51,6 +61,25 @@ :root { color-scheme: light; --radius: 0.5rem; + --font-sans: + var(--font-geist-sans), "Geist", "Inter", "Segoe UI", "Helvetica Neue", + Arial, system-ui, -apple-system, BlinkMacSystemFont, sans-serif; + --font-mono: + var(--font-geist-mono), "Azeret Mono", "JetBrains Mono", "SFMono-Regular", + "SF Mono", "Cascadia Code", Menlo, Consolas, ui-monospace, monospace; + --font-mono-display: + "Azeret Mono", var(--font-geist-mono), "JetBrains Mono", "SFMono-Regular", + "SF Mono", "Cascadia Code", Menlo, Consolas, ui-monospace, monospace; + --font-mono-readable: + "Azeret Mono", "Atkinson Hyperlegible Mono", var(--font-geist-mono), + "JetBrains Mono", "SFMono-Regular", "SF Mono", "Cascadia Code", Menlo, + Consolas, ui-monospace, monospace; + --font-accent: + "Anuphan", "Avenir Next", "Segoe UI", "Helvetica Neue", Arial, system-ui, + sans-serif; + --font-brand: + var(--font-geist-sans), "Geist", "Ysabeau", "Iowan Old Style", + "Palatino Linotype", "Book Antiqua", Georgia, ui-serif, serif; } .dark { @@ -130,6 +159,8 @@ .dark { --background: hsl(222.2 47.4% 11.2%); + --background-secondary: hsl(217.2 32.6% 17.5%); + --background-tertiary: hsl(215.3 25% 26.7%); --foreground: hsl(210 40% 98%); --card: hsl(222.2 47.4% 11.2%); --card-foreground: hsl(210 40% 98%); @@ -137,6 +168,8 @@ --popover-foreground: hsl(210 40% 98%); --primary: hsl(217.2 91.2% 59.8%); + --primary-light: hsl(213.1 93.9% 67.8%); + --primary-dark: hsl(221.2 83.2% 53.3%); --primary-foreground: hsl(222.2 47.4% 11.2%); --secondary: hsl(217.2 32.6% 17.5%); @@ -144,6 +177,8 @@ --muted: hsl(217.2 32.6% 17.5%); --muted-foreground: hsl(215 20.2% 65.1%); --accent: hsl(217.2 32.6% 17.5%); + --accent-light: hsl(215.3 25% 26.7%); + --accent-dark: hsl(222.2 47.4% 11.2%); --accent-foreground: hsl(210 40% 98%); --destructive: hsl(0 72.2% 50.6%); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index dd8d3fc..afbda8b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; +import { ThemeProvider } from "@/components/theme-provider"; import { appMetadata } from "@/lib/app-metadata"; import "./globals.css"; @@ -24,8 +25,18 @@ export default function RootLayout({ - {children} + + + {children} + + ); } diff --git a/src/app/page.test.ts b/src/app/page.test.ts index 3450de5..3ce551b 100644 --- a/src/app/page.test.ts +++ b/src/app/page.test.ts @@ -1,94 +1,28 @@ -import React from "react"; -import { Effect } from "effect"; -import { describe, expect, it, vi } from "vitest"; +import React from "react" +import { describe, expect, it, vi } from "vitest" const mocks = vi.hoisted(() => ({ - ensureApiKeyForWorkspace: vi.fn(), - ensureDemoWorkspaceContent: vi.fn(), - ensureWorkspace: vi.fn(), - getCurrentUser: vi.fn(), - listChatThreadsForWorkspace: vi.fn(), - listMessagesForThread: vi.fn(), - makeKnowhereClient: vi.fn(), - reconcileSourcesForWorkspace: vi.fn(), - sourceViewOptionsBySourceId: vi.fn(), -})); + loadWorkspaceShellInitialState: vi.fn(), +})) -vi.mock("next/headers", () => ({ - headers: vi.fn(async () => new Headers({ cookie: "session=abc" })), -})); +vi.mock("@/domains/workspace/initial-state", () => ({ + loadWorkspaceShellInitialState: mocks.loadWorkspaceShellInitialState, +})) -vi.mock("@/integrations/dashboard/api-key-service", () => ({ - ensureApiKeyForWorkspace: mocks.ensureApiKeyForWorkspace, -})); - -vi.mock("@/infrastructure/auth", () => ({ - getCurrentUser: mocks.getCurrentUser, -})); - -vi.mock("@/integrations/knowhere", () => ({ - makeKnowhereClient: mocks.makeKnowhereClient, -})); - -vi.mock("@/domains/sources/counts", () => ({ - sourceViewOptionsBySourceId: mocks.sourceViewOptionsBySourceId, -})); - -vi.mock("@/domains/workspace/service", () => ({ - workspaceService: { - ensureDemoWorkspaceContent: mocks.ensureDemoWorkspaceContent, - ensureWorkspace: mocks.ensureWorkspace, - }, -})); - -vi.mock("@/domains/sources/reconcile", () => ({ - reconcileSourcesForWorkspace: mocks.reconcileSourcesForWorkspace, -})); - -vi.mock("@/domains/chat/thread-service", () => ({ - chatThreadService: { - listForWorkspace: mocks.listChatThreadsForWorkspace, - listMessages: mocks.listMessagesForThread, - }, -})); - -import Home from "./page"; +import Home from "./page" describe("Home", () => { - it("uploads bundled demo content into the logged-in workspace before rendering", async () => { - const client = {}; - mocks.getCurrentUser.mockResolvedValue({ - id: "user_1", - name: "Ada", - email: "ada@example.com", - }); - mocks.ensureWorkspace.mockResolvedValue({ - id: "workspace_1", - namespace: "notebook-workspace_1", - }); - mocks.ensureApiKeyForWorkspace.mockResolvedValue("jwt_123"); - mocks.makeKnowhereClient.mockReturnValue(client); - mocks.reconcileSourcesForWorkspace.mockResolvedValue([]); - mocks.listChatThreadsForWorkspace.mockResolvedValue([]); - mocks.sourceViewOptionsBySourceId.mockReturnValue(Effect.succeed(new Map())); - - const element = await Home(); - - expect(React.isValidElement(element)).toBe(true); - expect(mocks.ensureDemoWorkspaceContent).toHaveBeenCalledWith( - { - id: "workspace_1", - namespace: "notebook-workspace_1", - }, - client, - ); - expect( - mocks.ensureDemoWorkspaceContent.mock.invocationCallOrder[0], - ).toBeGreaterThan(mocks.makeKnowhereClient.mock.invocationCallOrder[0]); - expect( - mocks.ensureDemoWorkspaceContent.mock.invocationCallOrder[0], - ).toBeLessThan( - mocks.reconcileSourcesForWorkspace.mock.invocationCallOrder[0], - ); - }); -}); + it("renders the workspace shell from the API-backed initial state", async () => { + mocks.loadWorkspaceShellInitialState.mockResolvedValue({ + isGuest: true, + loginUrl: "/login", + sources: [], + chatMessages: [], + }) + + const element = await Home() + + expect(React.isValidElement(element)).toBe(true) + expect(mocks.loadWorkspaceShellInitialState).toHaveBeenCalledOnce() + }) +}) diff --git a/src/components/chat-message-list.tsx b/src/components/chat-message-list.tsx index 99f99b5..1e8aa06 100644 --- a/src/components/chat-message-list.tsx +++ b/src/components/chat-message-list.tsx @@ -23,6 +23,7 @@ export type ChatMessageListProps = { citationId: string, ) => void; readonly pendingCitationId?: string | null; + readonly pendingStatusText?: string | null; readonly sourceTitlesByDocumentId?: Readonly>; }; @@ -33,6 +34,7 @@ export function ChatMessageList({ needsLogin = false, onCitationClick, pendingCitationId = null, + pendingStatusText = null, sourceTitlesByDocumentId = {}, }: ChatMessageListProps): ReactElement { const { @@ -61,6 +63,7 @@ export function ChatMessageList({ key={virtualItem.key} virtualItem={virtualItem} measureElement={measureElement} + pendingStatusText={pendingStatusText} /> ) : ( void; + readonly pendingStatusText?: string | null; }): ReactElement { const rowStyle: CSSProperties = { position: "absolute", @@ -100,12 +105,16 @@ function VirtualThinkingRow({ style={rowStyle} className="min-w-0 pb-4 sm:pb-5" > - + ); } -function ThinkingProgressBubble(): ReactElement { +function ThinkingProgressBubble({ + pendingStatusText, +}: { + readonly pendingStatusText?: string | null; +}): ReactElement { return (
- Thinking + + {pendingStatusText ?? "Thinking"} +
@@ -165,9 +182,11 @@ function ChunkSourcePanel({ function OpenOriginalButton({ chunk, + isOriginalPreviewAvailable, onChunkClick, }: { readonly chunk: ParsedChunkView; + readonly isOriginalPreviewAvailable: boolean; readonly onChunkClick: (chunk: ParsedChunkView) => void; }): ReactNode { return ( @@ -175,23 +194,33 @@ function OpenOriginalButton({ type="button" variant="outline" size="sm" - className="h-8 shrink-0 rounded-md px-2.5 text-xs" + className={cn( + "h-8 shrink-0 rounded-md px-2.5 text-xs", + isOriginalPreviewAvailable + ? "border-primary/40 bg-primary/5 font-semibold text-primary hover:bg-primary/10 hover:text-primary" + : "font-normal text-muted-foreground", + )} onClick={() => onChunkClick(chunk)} > - {getOpenOriginalButtonLabel(chunk)} + {getOpenOriginalButtonLabel(chunk, isOriginalPreviewAvailable)} ); } -function getOpenOriginalButtonLabel(chunk: ParsedChunkView): string { +function getOpenOriginalButtonLabel( + chunk: ParsedChunkView, + isOriginalPreviewAvailable: boolean, +): string { + if (!isOriginalPreviewAvailable) return "Open original file"; + const pageNums = chunk.pageNums ?? []; const validPageNums = pageNums.filter( (pageNum) => Number.isFinite(pageNum) && pageNum > 0, ); - if (validPageNums.length === 0) return "Open original"; + if (validPageNums.length === 0) return "Open original file"; - return `Open page ${Math.min(...validPageNums)}`; + return `Open page ${Math.min(...validPageNums)} in original file`; } function ChunkSummaryPanel({ @@ -207,7 +236,9 @@ function ChunkSummaryPanel({ className="rounded-lg border border-border/70 bg-muted/35 p-3" > } label="Summary" /> -

{chunk.summary}

+

+ {chunk.summary} +

); } @@ -290,11 +321,13 @@ function SectionLabel({ function TextChunkCard({ chunk, isFocused, + isOriginalPreviewAvailable, onChunkClick, onReferenceClick, }: { readonly chunk: ParsedChunkView; readonly isFocused: boolean; + readonly isOriginalPreviewAvailable: boolean; readonly onChunkClick?: (chunk: ParsedChunkView) => void; readonly onReferenceClick: (chunkId: string) => void; }): ReactNode { @@ -302,6 +335,7 @@ function TextChunkCard({ @@ -318,16 +352,19 @@ function TextChunkCard({ function ImageChunkCard({ chunk, isFocused, + isOriginalPreviewAvailable, onChunkClick, }: { readonly chunk: ParsedChunkView; readonly isFocused: boolean; + readonly isOriginalPreviewAvailable: boolean; readonly onChunkClick?: (chunk: ParsedChunkView) => void; }): ReactNode { return ( @@ -349,7 +386,9 @@ function ImageChunkCard({ Image chunk

- {chunk.summary ?? "Image content is not available in this view."} + {chunk.summary + ? chunk.summary + : "Image content is not available in this view."}

@@ -365,10 +404,14 @@ function renderTextChunkContent( onReferenceClick: (chunkId: string) => void, ): ReactNode { const parts = parsedChunkCardModel.getTextContentParts(chunk); - if (parts.length === 1 && parts[0]?.type === "text") return parts[0].text; + if (parts.length === 1 && parts[0]?.type === "text") { + return parts[0].text; + } return parts.map((part) => { - if (part.type === "text") return part.text; + if (part.type === "text") { + return part.text; + } return ( void; }): ReactNode { const safeHtml = useMemo( @@ -421,6 +466,7 @@ function TableChunkCard({ @@ -439,7 +485,9 @@ function TableChunkCard({ Table chunk

- {chunk.summary ?? "Table content is not available in this view."} + {chunk.summary + ? chunk.summary + : "Table content is not available in this view."}

diff --git a/src/components/source-original-preview-model.ts b/src/components/source-original-preview-model.ts index b08680e..9d73454 100644 --- a/src/components/source-original-preview-model.ts +++ b/src/components/source-original-preview-model.ts @@ -15,6 +15,7 @@ const textPreviewByteLimit = 1024 * 1024; const docxPreviewByteLimit = 10 * 1024 * 1024; export const sourceOriginalPreviewModel = { + canPreviewOriginalFile, pdfPageAspectRatio, getInitialPdfPageWidth, getOriginalDownloadUrl, @@ -29,6 +30,18 @@ export const sourceOriginalPreviewModel = { normalizeMarkdownPreviewText, } as const; +function canPreviewOriginalFile( + sourceTitle: string | null | undefined, + file: SourceOriginalFileView | null | undefined, +): boolean { + if (!file) return false; + + const kind = getPreviewKind(sourceTitle ?? "", file.mimeType); + if (kind === "unsupported") return false; + + return isWithinPreviewByteLimit(kind, file); +} + function getPreviewKind(title: string, mimeType: string): PreviewKind { const extension = getExtension(title); const normalizedMimeType = mimeType.toLowerCase(); diff --git a/src/components/source-original-preview.test.ts b/src/components/source-original-preview.test.ts index 390c628..0ceb1e9 100644 --- a/src/components/source-original-preview.test.ts +++ b/src/components/source-original-preview.test.ts @@ -566,12 +566,46 @@ describe("SourceOriginalPreview", () => { expect(screen.queryByText(/
/)).toBeNull(); }); + it("keeps Markdown content readable inside the responsive original preview shell", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => + Promise.resolve( + new Response("# Scan report\n\nThe scan found placeholder keys.", { + status: 200, + }), + ), + ), + ); + + render( + React.createElement(SourceOriginalPreview, { + sourceTitle: "scan-report.md", + file: { + url: "https://store.public.blob.vercel-storage.com/source-uploads/upload_1/scan-report.md", + mimeType: "text/markdown", + }, + }), + ); + + await waitFor(() => { + expect(screen.getByText("Scan report")).toBeTruthy(); + }); + + const previewShell = screen.getByTestId("source-original-preview"); + expect(previewShell.className).toContain("w-[90%]"); + expect(previewShell.className).toContain("max-w-[1600px]"); + + const markdownPreview = document.querySelector(".original-markdown-preview"); + expect(markdownPreview?.parentElement?.className).toContain("max-w-4xl"); + }); + it("hides the download action for non-downloadable demo originals", () => { render( React.createElement(SourceOriginalPreview, { sourceTitle: "demo.pdf", file: { - url: "/demo-sources/example/original.pdf", + url: "/api/demo-sources/example/original", mimeType: "application/pdf", canDownload: false, }, diff --git a/src/components/source-original-preview.tsx b/src/components/source-original-preview.tsx index a105abe..03c69a1 100644 --- a/src/components/source-original-preview.tsx +++ b/src/components/source-original-preview.tsx @@ -49,7 +49,7 @@ export function SourceOriginalPreview({
@@ -84,6 +84,10 @@ export function SourceOriginalPreview({ ); } +function getPreviewShellClassName(): string { + return "mx-auto flex w-[90%] min-w-0 max-w-[1600px] flex-col gap-3 p-3 sm:p-6"; +} + function renderPreview( kind: PreviewKind, sourceTitle: string, @@ -119,9 +123,9 @@ function renderPreview( /> ); case "markdown": - return ; + return renderReadingPreview(file, "markdown"); case "text": - return ; + return renderReadingPreview(file, "text"); case "docx": return ; case "unsupported": @@ -129,6 +133,17 @@ function renderPreview( } } +function renderReadingPreview( + file: SourceOriginalFileView, + variant: "markdown" | "text", +): ReactNode { + return ( +
+ +
+ ); +} + function UnsupportedPreview(): ReactNode { return (
diff --git a/src/components/source-panel-state.test.ts b/src/components/source-panel-state.test.ts index 5bd7eb9..e7d83b8 100644 --- a/src/components/source-panel-state.test.ts +++ b/src/components/source-panel-state.test.ts @@ -23,18 +23,16 @@ describe("sourcePanelState", () => { expect(state.archivingSourceIdSet.has("source_1")).toBe(true); }); - it("selects or clears the current Source from a row click", () => { + it("selects a Source from a row click without clearing the current selection", () => { expect( sourcePanelState.getNextSelectedSourceId({ sourceId: "source_1", - selectedSourceId: null, }), ).toBe("source_1"); expect( sourcePanelState.getNextSelectedSourceId({ sourceId: "source_1", - selectedSourceId: "source_1", }), - ).toBeNull(); + ).toBe("source_1"); }); }); diff --git a/src/components/source-panel-state.ts b/src/components/source-panel-state.ts index 2f88600..07628a8 100644 --- a/src/components/source-panel-state.ts +++ b/src/components/source-panel-state.ts @@ -13,7 +13,6 @@ type ArchiveConfirmationState = { } type NextSelectedSourceInput = { - readonly selectedSourceId: string | null readonly sourceId: string } @@ -48,11 +47,8 @@ function getArchiveConfirmationState({ } } -function getNextSelectedSourceId({ - selectedSourceId, - sourceId, -}: NextSelectedSourceInput): string | null { - return sourceId === selectedSourceId ? null : sourceId +function getNextSelectedSourceId({ sourceId }: NextSelectedSourceInput): string | null { + return sourceId } function shouldCloseArchiveConfirmation( diff --git a/src/components/source-upload-dialog-workflow.ts b/src/components/source-upload-dialog-workflow.ts index 2e41bdb..cd56159 100644 --- a/src/components/source-upload-dialog-workflow.ts +++ b/src/components/source-upload-dialog-workflow.ts @@ -40,8 +40,8 @@ type SourceUploadDialogWorkflow = { readonly isUploading: boolean; readonly message: SourceUploadDialogMessage | null; readonly selectedFileName: string | null; - readonly handleDialogDragOver: (event: DragEvent) => void; - readonly handleDialogDrop: (event: DragEvent) => void; + readonly handleUploadDragOver: (event: DragEvent) => void; + readonly handleUploadDrop: (event: DragEvent) => void; readonly handleDialogOpenChange: (open: boolean) => void; readonly handleFileInputChange: (event: ChangeEvent) => void; readonly handleSubmit: (event: FormEvent) => Promise; @@ -110,13 +110,13 @@ export function useSourceUploadDialogWorkflow({ } } - function handleDialogDragOver(event: DragEvent): void { + function handleUploadDragOver(event: DragEvent): void { if (!hasDraggedFiles(event)) return; event.preventDefault(); event.stopPropagation(); } - function handleDialogDrop(event: DragEvent): void { + function handleUploadDrop(event: DragEvent): void { if (!hasDraggedFiles(event)) return; event.preventDefault(); event.stopPropagation(); @@ -127,6 +127,7 @@ export function useSourceUploadDialogWorkflow({ if (!file) return; setSelectedFileState(file); + setIsDialogOpen(true); } function handleDialogOpenChange(open: boolean): void { @@ -161,8 +162,8 @@ export function useSourceUploadDialogWorkflow({ isUploading, message, selectedFileName, - handleDialogDragOver, - handleDialogDrop, + handleUploadDragOver, + handleUploadDrop, handleDialogOpenChange, handleFileInputChange, handleSubmit, diff --git a/src/components/source-upload-dialog.tsx b/src/components/source-upload-dialog.tsx index 80011b4..55bf8cd 100644 --- a/src/components/source-upload-dialog.tsx +++ b/src/components/source-upload-dialog.tsx @@ -1,6 +1,7 @@ "use client"; import { + type DragEvent, useId, type ReactElement, } from "react"; @@ -20,10 +21,19 @@ import type { SourceView } from "@/domains/sources/types"; export type SourceUploadDialogProps = { readonly onSourceUploaded?: (source: SourceView) => void; + readonly renderTrigger?: (props: SourceUploadDialogTriggerProps) => ReactElement; +}; + +export type SourceUploadDialogTriggerProps = { + readonly isUploading: boolean; + readonly onClick: () => void; + readonly onDragOver: (event: DragEvent) => void; + readonly onDrop: (event: DragEvent) => void; }; export function SourceUploadDialog({ onSourceUploaded, + renderTrigger, }: SourceUploadDialogProps): ReactElement { const { inputRef, @@ -31,8 +41,8 @@ export function SourceUploadDialog({ isUploading, message, selectedFileName, - handleDialogDragOver, - handleDialogDrop, + handleUploadDragOver, + handleUploadDrop, handleDialogOpenChange, handleFileInputChange, handleSubmit, @@ -45,19 +55,30 @@ export function SourceUploadDialog({ open={isDialogOpen} onOpenChange={handleDialogOpenChange} > - + {renderTrigger ? ( + renderTrigger({ + isUploading, + onClick: handleUploadDialogOpen, + onDragOver: handleUploadDragOver, + onDrop: handleUploadDrop, + }) + ) : ( + + )} Add source diff --git a/src/components/sources-panel.tsx b/src/components/sources-panel.tsx index 0f96003..e947c49 100644 --- a/src/components/sources-panel.tsx +++ b/src/components/sources-panel.tsx @@ -137,7 +137,6 @@ export function SourcesPanel({ onSelectSource?.( sourcePanelState.getNextSelectedSourceId({ sourceId: source.id, - selectedSourceId, }), ) } diff --git a/src/components/theme-provider.tsx b/src/components/theme-provider.tsx new file mode 100644 index 0000000..2e81cc2 --- /dev/null +++ b/src/components/theme-provider.tsx @@ -0,0 +1,12 @@ +"use client" + +import { ThemeProvider as NextThemesProvider } from "next-themes" +import type { ThemeProviderProps } from "next-themes" +import type { ReactNode } from "react" + +export function ThemeProvider({ + children, + ...props +}: ThemeProviderProps): ReactNode { + return {children} +} diff --git a/src/components/theme-toggle.tsx b/src/components/theme-toggle.tsx new file mode 100644 index 0000000..14ed7e6 --- /dev/null +++ b/src/components/theme-toggle.tsx @@ -0,0 +1,45 @@ +"use client" + +import { Moon, Sun } from "lucide-react" +import { useTheme } from "next-themes" +import type { ReactElement } from "react" + +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" + +export function ThemeToggle(): ReactElement { + const { setTheme } = useTheme() + + return ( + + + + + + setTheme("light")}> + Light + + setTheme("dark")}> + Dark + + setTheme("system")}> + System + + + + ) +} diff --git a/src/components/top-nav.test.ts b/src/components/top-nav.test.ts new file mode 100644 index 0000000..e67a3fa --- /dev/null +++ b/src/components/top-nav.test.ts @@ -0,0 +1,52 @@ +// @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" + +import { ThemeProvider } from "@/components/theme-provider" +import { TopNav, type TopNavProps } from "./top-nav" + +describe("TopNav", () => { + beforeEach(() => { + vi.stubGlobal("matchMedia", () => ({ + addEventListener: vi.fn(), + addListener: vi.fn(), + dispatchEvent: vi.fn(), + matches: false, + media: "", + onchange: null, + removeEventListener: vi.fn(), + removeListener: vi.fn(), + })) + }) + + afterEach(() => { + cleanup() + vi.unstubAllGlobals() + }) + + it("links to the configured Dashboard origin", async () => { + const user = userEvent.setup() + const topNavProps: TopNavProps = { + dashboardUrl: "https://dashboard.example.test", + } + + render( + createElement( + ThemeProvider, + { attribute: "class" }, + createElement(TopNav, topNavProps), + ), + ) + + const link = screen.getByRole("link", { name: "Open Dashboard" }) + + expect(link.getAttribute("href")).toBe("https://dashboard.example.test") + 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() + }) +}) diff --git a/src/components/top-nav.tsx b/src/components/top-nav.tsx index 2e1c2ec..487fcc2 100644 --- a/src/components/top-nav.tsx +++ b/src/components/top-nav.tsx @@ -1,7 +1,11 @@ import { NotebookLogoMark } from "@/components/notebook-logo-mark"; import { Separator } from "@/components/ui/separator"; +import { ThemeToggle } from "@/components/theme-toggle"; +import { ExternalLink } from "lucide-react"; +import type { ReactElement } from "react"; export type TopNavProps = { + dashboardUrl?: string | null; userInitials?: string; userName?: string; userTierLabel?: string; @@ -9,11 +13,12 @@ export type TopNavProps = { }; export function TopNav({ + dashboardUrl, userInitials, userName, userTierLabel, workspaceLabel = "Personal Workspace", -}: TopNavProps = {}) { +}: TopNavProps): ReactElement { return (
@@ -30,6 +35,17 @@ export function TopNav({
+ {dashboardUrl ? ( + + Dashboard + + + ) : null} + {userInitials && ( <>
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 1ede5d6..1c7d6b3 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -19,6 +19,10 @@ const buttonVariants = cva( "pill-primary": "text-[#F5F3FF] shadow-none", "pill-secondary": "border-stone-200 text-stone-800 shadow-none", mono: "bg-zinc-800 font-mono text-primary-light shadow-none hover:bg-zinc-700", + "copy-cli": + "bg-zinc-800 font-normal text-[#A684FF] shadow-none hover:bg-zinc-900 hover:font-semibold active:bg-zinc-950 active:font-semibold", + "copy-code": + "bg-zinc-800 font-mono-readable font-normal text-[#A684FF] shadow-none hover:bg-zinc-700 hover:text-[#C4B4FF] active:bg-zinc-600 active:text-[#DDD6FF]", }, size: { default: "h-16 px-7 pb-1 text-base [&_svg]:size-5", @@ -27,6 +31,8 @@ const buttonVariants = cva( icon: "size-10", "pill-md": "h-16 px-7 pb-1 text-base [&_svg]:size-5", "pill-lg": "h-[72px] px-9 pb-1 text-xl [&_svg]:size-6", + "copy-cli": "h-9 w-[72px] px-0 py-2 text-sm", + "copy-code": "h-9 px-4 py-2 text-sm", }, }, compoundVariants: [ diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..a7ccbce --- /dev/null +++ b/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,197 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { Check, ChevronRight, Circle } from "lucide-react"; +import * as React from "react"; + +const DropdownMenu = DropdownMenuPrimitive.Root; + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; + +const DropdownMenuGroup = DropdownMenuPrimitive.Group; + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; + +const DropdownMenuSub = DropdownMenuPrimitive.Sub; + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)); +DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName; + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + svg]:size-4 [&>svg]:shrink-0", + inset && "pl-8", + className, + )} + {...props} + /> +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName; + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => ( + +); +DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +}; diff --git a/src/components/workspace-chat-state.test.ts b/src/components/workspace-chat-state.test.ts index 605794e..129da69 100644 --- a/src/components/workspace-chat-state.test.ts +++ b/src/components/workspace-chat-state.test.ts @@ -24,6 +24,7 @@ describe("workspaceChatState", () => { isSending: false, isLoading: false, error: "Previous error", + pendingStatusText: null, }, threadId: "thread_2", loadedMessages: messages, @@ -35,6 +36,7 @@ describe("workspaceChatState", () => { isSending: false, isLoading: false, error: null, + pendingStatusText: null, }); }); @@ -53,6 +55,7 @@ describe("workspaceChatState", () => { isSending: false, isLoading: false, error: null, + pendingStatusText: null, }, { id: "pending-1", @@ -68,6 +71,7 @@ describe("workspaceChatState", () => { isSending: false, isLoading: false, error: "The assistant could not answer right now.", + pendingStatusText: null, }); }); diff --git a/src/components/workspace-chat-state.ts b/src/components/workspace-chat-state.ts index 3e4c1fb..a19513c 100644 --- a/src/components/workspace-chat-state.ts +++ b/src/components/workspace-chat-state.ts @@ -11,6 +11,7 @@ type ChatState = { readonly isSending: boolean readonly isLoading: boolean readonly error: string | null + readonly pendingStatusText: string | null } type LoadedChatThreadData = { @@ -52,6 +53,10 @@ type WorkspaceChatStateModule = { messages: readonly ChatMessageView[], ) => ChatState readonly failCreate: (current: ChatState, message?: string | null) => ChatState + readonly prepareSend: ( + current: ChatState, + pendingStatusText: string, + ) => ChatState readonly addOptimisticUserMessage: ( current: ChatState, message: OptimisticUserMessageInput, @@ -95,6 +100,7 @@ function createInitialState( isSending: false, isLoading: false, error: null, + pendingStatusText: null, } } @@ -147,6 +153,7 @@ function selectThread(input: SelectThreadInput): ChatState { isSending: false, isLoading: false, error: null, + pendingStatusText: null, } } @@ -155,6 +162,7 @@ function selectThread(input: SelectThreadInput): ChatState { threadId: input.threadId, isLoading: true, error: null, + pendingStatusText: null, } } @@ -168,6 +176,7 @@ function createThread( isSending: false, isLoading: false, error: null, + pendingStatusText: null, } } @@ -178,6 +187,18 @@ function failCreate(current: ChatState, message?: string | null): ChatState { } } +function prepareSend( + current: ChatState, + pendingStatusText: string, +): ChatState { + return { + ...current, + isSending: true, + error: null, + pendingStatusText, + } +} + function addOptimisticUserMessage( current: ChatState, message: OptimisticUserMessageInput, @@ -192,6 +213,7 @@ function addOptimisticUserMessage( ...current, isSending: true, error: null, + pendingStatusText: null, messages: [...current.messages, optimisticUser], } } @@ -211,6 +233,7 @@ function completeSend( isSending: false, isLoading: false, error: null, + pendingStatusText: null, } } @@ -221,6 +244,7 @@ function failSend(current: ChatState, optimisticId: string): ChatState { isLoading: false, messages: current.messages.filter((message) => message.id !== optimisticId), error: chatSendError, + pendingStatusText: null, } } @@ -231,6 +255,7 @@ function clearThread(): ChatState { isSending: false, isLoading: false, error: null, + pendingStatusText: null, } } @@ -293,6 +318,7 @@ export const workspaceChatState: WorkspaceChatStateModule = { selectThread, createThread, failCreate, + prepareSend, addOptimisticUserMessage, completeSend, failSend, diff --git a/src/components/workspace-chat-workflow.test.ts b/src/components/workspace-chat-workflow.test.ts index 4320955..46304fd 100644 --- a/src/components/workspace-chat-workflow.test.ts +++ b/src/components/workspace-chat-workflow.test.ts @@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({ createChatThread: vi.fn(), fetchChatThread: vi.fn(), fetchChatThreads: vi.fn(), + materializeDemoSources: vi.fn(), sendChatMessage: vi.fn(), })) @@ -26,6 +27,7 @@ vi.mock("@/domains/workspace/client", () => ({ createChatThread: mocks.createChatThread, fetchChatThread: mocks.fetchChatThread, fetchChatThreads: mocks.fetchChatThreads, + materializeDemoSources: mocks.materializeDemoSources, sendChatMessage: mocks.sendChatMessage, }, })) @@ -106,6 +108,39 @@ 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) + }) }) function renderWorkspaceChatWorkflow(input: { @@ -113,6 +148,10 @@ function renderWorkspaceChatWorkflow(input: { readonly initialChatMessages: readonly [] 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 f9eda92..d50747f 100644 --- a/src/components/workspace-chat-workflow.ts +++ b/src/components/workspace-chat-workflow.ts @@ -22,6 +22,10 @@ type WorkspaceChatWorkflowInput = { readonly initialChatMessages?: readonly ChatMessageView[] readonly initialChatThreads?: readonly ChatThreadView[] readonly isGuest?: boolean + readonly onSourcesMaterialized?: ( + demoSourceIds: readonly string[], + materializedSources: readonly SourceView[], + ) => void readonly sources: readonly SourceView[] } @@ -48,6 +52,7 @@ export function useWorkspaceChatWorkflow({ initialChatMessages = [], initialChatThreads = [], isGuest = false, + onSourcesMaterialized, sources, }: WorkspaceChatWorkflowInput): WorkspaceChatWorkflow { const [loadingThreadId, setLoadingThreadId] = useState(null) @@ -210,6 +215,40 @@ export function useWorkspaceChatWorkflow({ } async function handleChatSend(text: string): Promise { + const demoSourceIds = getMaterializableDemoSourceIds(sources) + if (demoSourceIds.length > 0) { + setChat((current) => + workspaceChatState.prepareSend(current, "Thinking"), + ) + try { + const materializedSources = + await workspaceClient.materializeDemoSources({ demoSourceIds }) + onSourcesMaterialized?.(demoSourceIds, materializedSources) + if (chat.threadId) { + try { + const fresh = await workspaceClient.fetchChatThread(chat.threadId) + setChat((current) => { + if (current.threadId !== fresh.requestedThreadId) return current + if (!fresh.thread || !Array.isArray(fresh.messages)) + return current + return { ...current, messages: [...fresh.messages] } + }) + } catch { + // stale citations until page reload — materialization succeeded + } + } + } catch { + setChat((current) => ({ + ...current, + isSending: false, + isLoading: false, + pendingStatusText: null, + error: "Demo sources could not be prepared right now.", + })) + return + } + } + optimisticMessageSequence.current += 1 const optimisticId = `pending-${optimisticMessageSequence.current}` setChat((current) => @@ -281,6 +320,17 @@ export function useWorkspaceChatWorkflow({ } } +function getMaterializableDemoSourceIds( + sources: readonly SourceView[], +): string[] { + const demoSourceIds = sources + .filter((source) => source.kind === "demo") + .filter((source) => !source.excludedFromQuery) + .map((source) => source.demoSourceId ?? source.id) + + return Array.from(new Set(demoSourceIds)) +} + function fetchChatThreadByKey([ , threadId, diff --git a/src/components/workspace-citation-focus.ts b/src/components/workspace-citation-focus.ts index 7ca7a0f..d428bf0 100644 --- a/src/components/workspace-citation-focus.ts +++ b/src/components/workspace-citation-focus.ts @@ -1,10 +1,9 @@ "use client" -import { useCallback, useEffect, useState } from "react" +import { useCallback, useState } from "react" import { workspaceCitationState } from "@/components/workspace-citation-state" import { useWorkspaceSelectedChunks } from "@/components/workspace-selected-chunks" -import { useHashFragment } from "@/lib/use-hash-fragment" import type { ChatCitationView } from "@/domains/chat/types" import type { ParsedChunkView } from "@/domains/chunks/types" import type { SourceView } from "@/domains/sources/types" @@ -58,7 +57,6 @@ export function useWorkspaceCitationFocus({ ) const [prefetchedChunksBySourceId, setPrefetchedChunksBySourceId] = useState(initialPrefetchedChunksBySourceId) - const [hashChunkId, setHashChunkId] = useHashFragment() const { hasMoreSelectedChunks, handleLoadMoreChunks, @@ -78,21 +76,10 @@ export function useWorkspaceCitationFocus({ chunkId, requestId: current.requestId + 1, })) - setHashChunkId(chunkId) }, - [setHashChunkId], + [], ) - useEffect(() => { - if (!hashChunkId) return - - const frameId = window.requestAnimationFrame(() => { - requestChunkFocus(hashChunkId) - }) - - return () => window.cancelAnimationFrame(frameId) - }, [hashChunkId, requestChunkFocus]) - const handleSourceSelected = useCallback( (sourceId: string | null): void => { onSelectSource(sourceId) diff --git a/src/components/workspace-shell-layout.tsx b/src/components/workspace-shell-layout.tsx index b1fc0b0..f3d3dc4 100644 --- a/src/components/workspace-shell-layout.tsx +++ b/src/components/workspace-shell-layout.tsx @@ -40,6 +40,7 @@ type WorkspaceChatState = { readonly isSending: boolean readonly isLoading: boolean readonly error: string | null + readonly pendingStatusText: string | null } export type WorkspaceShellLayoutProps = { @@ -47,6 +48,7 @@ export type WorkspaceShellLayoutProps = { readonly archivingThreadIds: readonly string[] readonly chat: WorkspaceChatState readonly chatThreads: readonly ChatThreadView[] + readonly dashboardUrl?: string readonly desktopPanelWidths: Readonly readonly focusedChunk: FocusedChunkState readonly hasMessages: boolean @@ -104,6 +106,7 @@ export function WorkspaceShellLayout( return (
{ ).toBeTruthy(); }); + it("shows the first ready document chunks on workspace load", async () => { + const fetch = vi.fn(async (input) => { + const url = getRequestURL(input); + + if (url.pathname === "/api/sources/source_1/chunks") { + return Response.json({ + chunks: [ + { + chunkId: "source_1:chunk_1", + documentId: "doc_1", + sectionPath: "Overview", + type: "text", + content: "First document chunk content.", + sourceTitle: "first.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, { + sources: [ + { + id: "source_1", + title: "first.pdf", + status: "ready", + documentId: "doc_1", + }, + { + id: "source_2", + title: "second.pdf", + status: "ready", + documentId: "doc_2", + }, + ], + }), + ); + + const desktopChunksPanel = within(screen.getByTestId("desktop-chunks-panel")); + await waitFor(() => { + expect( + desktopChunksPanel.getByText("First document chunk content."), + ).toBeTruthy(); + }); + expect(countFetches(fetch, "/api/sources/source_1/chunks")).toBe(1); + 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); @@ -662,7 +720,7 @@ describe("WorkspaceShell", () => { ); await waitFor(() => { expect( - countFetchesWithSearch(fetch, "/api/sources/source_1/chunks", "?page=1&pageSize=100"), + countFetchesWithSearch(fetch, "/api/sources/source_1/chunks", "?page=1&pageSize=50"), ).toBeGreaterThan(0); }); diff --git a/src/components/workspace-shell.tsx b/src/components/workspace-shell.tsx index c175919..4e2af57 100644 --- a/src/components/workspace-shell.tsx +++ b/src/components/workspace-shell.tsx @@ -17,6 +17,7 @@ import type { ChatMessageView, ChatThreadView, } from "@/domains/chat/types" +import type { ParsedChunkView } from "@/domains/chunks/types" import type { SourceView } from "@/domains/sources/types" export type { PanelId } from "@/components/workspace-shell-layout" @@ -40,6 +41,8 @@ export type WorkspaceShellProps = { chatThreads?: ChatThreadView[] activeChatThreadId?: string | null chatMessages?: ChatMessageView[] + dashboardUrl?: string + initialPrefetchedChunksBySourceId?: Record isGuest?: boolean loginUrl?: string } @@ -66,6 +69,8 @@ function WorkspaceShellContent({ chatThreads: initialChatThreads, activeChatThreadId, chatMessages: initialChatMessages, + dashboardUrl, + initialPrefetchedChunksBySourceId, isGuest = false, loginUrl, }: WorkspaceShellProps): ReactElement { @@ -78,6 +83,8 @@ function WorkspaceShellContent({ }) const citationFocus = useWorkspaceCitationFocus({ fetchChunks: workspaceClient.fetchChunks, + initialPrefetchedChunksBySourceId: + initialPrefetchedChunksBySourceId ?? undefined, onSelectSource: sourceWorkflow.setSelectedSourceId, selectedSourceId: sourceWorkflow.selectedSourceId, sources: sourceWorkflow.sources, @@ -87,6 +94,7 @@ function WorkspaceShellContent({ initialChatMessages: initialChatMessages ?? [], initialChatThreads: initialChatThreads ?? [], isGuest, + onSourcesMaterialized: sourceWorkflow.handleSourcesMaterialized, sources: sourceWorkflow.sources, }) const { @@ -117,6 +125,7 @@ function WorkspaceShellContent({ chat={chatWorkflow.chat} chatThreads={chatWorkflow.chatThreads} desktopPanelWidths={desktopPanelWidths} + dashboardUrl={dashboardUrl} focusedChunk={citationFocus.focusedChunk} hasMessages={hasMessages} hasMoreSelectedChunks={citationFocus.hasMoreSelectedChunks} diff --git a/src/components/workspace-source-state.test.ts b/src/components/workspace-source-state.test.ts index ccae9e8..7f1596e 100644 --- a/src/components/workspace-source-state.test.ts +++ b/src/components/workspace-source-state.test.ts @@ -5,6 +5,29 @@ import { workspaceSourceState } from "./workspace-source-state"; import type { SourceView } from "@/domains/sources/types"; describe("workspaceSourceState", () => { + it("selects the first ready Source as the initial Source", () => { + const sources: readonly SourceView[] = [ + { + id: "source_parsing", + title: "pending.pdf", + status: "parsing", + mimeType: "application/pdf", + excludedFromQuery: false, + }, + { + id: "source_ready", + title: "ready.pdf", + status: "ready", + mimeType: "application/pdf", + excludedFromQuery: false, + }, + ]; + + expect(workspaceSourceState.getInitialSelectedSourceId(sources)).toBe( + "source_ready", + ); + }); + it("applies source query exclusions without mutating the source list", () => { const sources: readonly SourceView[] = [ { @@ -40,10 +63,27 @@ describe("workspaceSourceState", () => { expect(sources[1]?.excludedFromQuery).toBe(false); }); - it("clears selected and exclusion state when the selected source is archived", () => { + it("moves selection to the first remaining ready Source when the selected Source is archived", () => { + const sources: readonly SourceView[] = [ + { + id: "source_1", + title: "selected.pdf", + status: "ready", + mimeType: "application/pdf", + excludedFromQuery: false, + }, + { + id: "source_2", + title: "remaining.pdf", + status: "ready", + mimeType: "application/pdf", + excludedFromQuery: false, + }, + ]; const result = workspaceSourceState.archiveSource({ sourceId: "source_1", selectedSourceId: "source_1", + sources, sourceExclusionById: { source_1: true, source_2: false, @@ -51,7 +91,7 @@ describe("workspaceSourceState", () => { }); expect(result).toEqual({ - selectedSourceId: null, + selectedSourceId: "source_2", sourceExclusionById: { source_2: false, }, diff --git a/src/components/workspace-source-state.ts b/src/components/workspace-source-state.ts index 9f24185..d4f82d1 100644 --- a/src/components/workspace-source-state.ts +++ b/src/components/workspace-source-state.ts @@ -5,6 +5,7 @@ type SourceExclusionState = Readonly> type ArchiveSourceInput = { readonly sourceId: string readonly selectedSourceId: string | null + readonly sources: readonly SourceView[] readonly sourceExclusionById: SourceExclusionState } @@ -14,9 +15,15 @@ type ArchiveSourceResult = { } type WorkspaceSourceStateModule = { + readonly getFirstReadySourceId: ( + sources: readonly SourceView[], + ) => string | null readonly getInitialSelectedSourceId: ( sources: readonly SourceView[], - isGuest: boolean, + ) => string | null + readonly getResolvedSelectedSourceId: ( + sources: readonly SourceView[], + selectedSourceId: string | null, ) => string | null readonly applyQueryExclusions: ( sources: readonly SourceView[], @@ -38,13 +45,22 @@ type WorkspaceSourceStateModule = { ) => Record } -function getInitialSelectedSourceId( +function getInitialSelectedSourceId(sources: readonly SourceView[]): string | null { + return getFirstReadySourceId(sources) +} + +function getFirstReadySourceId(sources: readonly SourceView[]): string | null { + return sources.find((source) => source.status === "ready")?.id ?? null +} + +function getResolvedSelectedSourceId( sources: readonly SourceView[], - isGuest: boolean, + selectedSourceId: string | null, ): string | null { - if (!isGuest) return null + const selectedSource = sources.find((source) => source.id === selectedSourceId) + if (selectedSource?.status === "ready") return selectedSource.id - return sources.find((source) => source.status === "ready")?.id ?? null + return getFirstReadySourceId(sources) } function applyQueryExclusions( @@ -66,9 +82,14 @@ function upsertSource( } function archiveSource(input: ArchiveSourceInput): ArchiveSourceResult { + const remainingSources = input.sources.filter( + (source) => source.id !== input.sourceId, + ) return { selectedSourceId: - input.selectedSourceId === input.sourceId ? null : input.selectedSourceId, + input.selectedSourceId === input.sourceId + ? getFirstReadySourceId(remainingSources) + : getResolvedSelectedSourceId(remainingSources, input.selectedSourceId), sourceExclusionById: removeRecordKey( input.sourceExclusionById, input.sourceId, @@ -96,7 +117,9 @@ function removeRecordKey( } export const workspaceSourceState: WorkspaceSourceStateModule = { + getFirstReadySourceId, getInitialSelectedSourceId, + getResolvedSelectedSourceId, applyQueryExclusions, upsertSource, archiveSource, diff --git a/src/components/workspace-source-workflow.test.ts b/src/components/workspace-source-workflow.test.ts index 0960704..563f5f0 100644 --- a/src/components/workspace-source-workflow.test.ts +++ b/src/components/workspace-source-workflow.test.ts @@ -53,7 +53,7 @@ describe("useWorkspaceSourceWorkflow", () => { expect(mocks.archiveSource).toHaveBeenCalledWith("source_1") await waitFor(() => { - expect(result.current.selectedSourceId).toBeNull() + expect(result.current.selectedSourceId).toBe("source_2") }) expect(result.current.sources.map((source) => source.id)).toEqual([ "source_2", diff --git a/src/components/workspace-source-workflow.ts b/src/components/workspace-source-workflow.ts index 9cac0cf..b31380b 100644 --- a/src/components/workspace-source-workflow.ts +++ b/src/components/workspace-source-workflow.ts @@ -18,6 +18,10 @@ type WorkspaceSourceWorkflow = { readonly archivingSourceIds: string[] readonly handleArchiveSource: (sourceId: string) => Promise readonly handleSelectedSourceChange: (sourceId: string | null) => void + readonly handleSourcesMaterialized: ( + demoSourceIds: readonly string[], + materializedSources: readonly SourceView[], + ) => void readonly handleSourceUploaded: (source: SourceView) => void readonly handleToggleIncluded: (sourceId: string, included: boolean) => void readonly readySourceCount: number @@ -37,7 +41,6 @@ export function useWorkspaceSourceWorkflow({ const initialSourceRows = useMemo(() => [...initialSources], [initialSources]) const initialSelectedSourceId = workspaceSourceState.getInitialSelectedSourceId( initialSourceRows, - isGuest, ) const [selectedSourceId, setSelectedSourceId] = useState( initialSelectedSourceId, @@ -64,6 +67,11 @@ export function useWorkspaceSourceWorkflow({ sourceRows, sourceExclusionById, ) + const resolvedSelectedSourceId = + workspaceSourceState.getResolvedSelectedSourceId( + sourceRows, + selectedSourceId, + ) const sourceTitlesByDocumentId = useMemo>>( () => Object.fromEntries( @@ -90,6 +98,28 @@ export function useWorkspaceSourceWorkflow({ void mutateSources() } + function handleSourcesMaterialized( + demoSourceIds: readonly string[], + materializedSources: readonly SourceView[], + ): void { + const materializedDemoSourceIdSet = new Set(demoSourceIds) + void mutateSources( + (current) => [ + ...(current ?? sourceRows).filter( + (source) => + !source.demoSourceId || + !materializedDemoSourceIdSet.has(source.demoSourceId), + ), + ...materializedSources, + ], + { revalidate: false }, + ) + setSelectedSourceId((current) => { + if (!current || !materializedDemoSourceIdSet.has(current)) return current + return materializedSources[0]?.id ?? current + }) + } + function handleToggleIncluded(sourceId: string, included: boolean): void { setSourceExclusionById((current) => ({ ...current, @@ -116,6 +146,7 @@ export function useWorkspaceSourceWorkflow({ workspaceSourceState.archiveSource({ sourceId, selectedSourceId: current, + sources: sourceRows, sourceExclusionById, }).selectedSourceId, ) @@ -123,6 +154,7 @@ export function useWorkspaceSourceWorkflow({ workspaceSourceState.archiveSource({ sourceId, selectedSourceId, + sources: sourceRows, sourceExclusionById: current, }).sourceExclusionById, ) @@ -139,10 +171,11 @@ export function useWorkspaceSourceWorkflow({ archivingSourceIds, handleArchiveSource, handleSelectedSourceChange, + handleSourcesMaterialized, handleSourceUploaded, handleToggleIncluded, readySourceCount, - selectedSourceId, + selectedSourceId: resolvedSelectedSourceId, setSelectedSourceId, sourceTitlesByDocumentId, sources, diff --git a/src/domains/chat/chat-citation-persistence.ts b/src/domains/chat/chat-citation-persistence.ts index 498bcc1..a705362 100644 --- a/src/domains/chat/chat-citation-persistence.ts +++ b/src/domains/chat/chat-citation-persistence.ts @@ -13,7 +13,7 @@ type ChatCitationPersistence = { ) => CitationView[] | null readonly replaceDemoCitationDocumentId: ( citations: readonly ChatCitationView[] | undefined, - documentId: string, + documentIdMap: ReadonlyMap, ) => ChatCitationView[] | undefined } @@ -29,17 +29,24 @@ function normalizeCitations( function replaceDemoCitationDocumentId( citations: readonly ChatCitationView[] | undefined, - documentId: string, + documentIdMap: ReadonlyMap, ): ChatCitationView[] | undefined { if (!citations) return undefined - return citations.map((citation) => ({ - ...citation, - source: { - ...citation.source, - documentId, - }, - })) + 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( diff --git a/src/domains/chat/chat-message-repository.ts b/src/domains/chat/chat-message-repository.ts index 66efa1c..4cc04f1 100644 --- a/src/domains/chat/chat-message-repository.ts +++ b/src/domains/chat/chat-message-repository.ts @@ -32,6 +32,10 @@ type ChatMessageRepository = { workspaceId: string, input: AppendChatMessageInput, ) => Effect.Effect + readonly updateMessageCitationsEffect: ( + messageId: string, + citations: CitationView[] | null, + ) => Effect.Effect } const listMessagesForThreadEffect: ChatMessageRepository["listMessagesForThreadEffect"] = @@ -92,7 +96,22 @@ const appendMessageToThreadEffect: ChatMessageRepository["appendMessageToThreadE ) }) +const updateMessageCitationsEffect: ChatMessageRepository["updateMessageCitationsEffect"] = + (messageId: string, citations: CitationView[] | null) => + Effect.gen(function* () { + const db = yield* DbClient + const [updated] = yield* Effect.promise(() => + db + .update(chatMessages) + .set({ citations }) + .where(eq(chatMessages.id, messageId)) + .returning(), + ) + return updated ?? null + }) + export const chatMessageRepository: ChatMessageRepository = { listMessagesForThreadEffect, appendMessageToThreadEffect, + updateMessageCitationsEffect, } diff --git a/src/domains/chat/chat-thread-repository.ts b/src/domains/chat/chat-thread-repository.ts index 965aee9..d7cc884 100644 --- a/src/domains/chat/chat-thread-repository.ts +++ b/src/domains/chat/chat-thread-repository.ts @@ -3,8 +3,32 @@ 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 { chatThreads, type ChatThread } from "@/infrastructure/db/schema" +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: ( @@ -20,10 +44,18 @@ 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 @@ -116,6 +148,88 @@ 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, @@ -139,10 +253,32 @@ 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.ts b/src/domains/chat/chat-turn-persistence.ts index c2852dc..fbb759c 100644 --- a/src/domains/chat/chat-turn-persistence.ts +++ b/src/domains/chat/chat-turn-persistence.ts @@ -44,13 +44,19 @@ function createRepository( adapter: ChatThreadPersistenceAdapter = chatThreadService, ): ChatRepository { return { - ensureDefaultChatThread: adapter.ensureDefault, - findChatThreadInWorkspace: adapter.findInWorkspace, - listMessagesForThread: async (workspaceId: string, threadId: string) => { + ensureDefaultChatThread: (workspaceId) => + adapter.ensureDefault(workspaceId), + + findChatThreadInWorkspace: (workspaceId, threadId) => + adapter.findInWorkspace(workspaceId, threadId), + + listMessagesForThread: async (workspaceId, threadId) => { const messages = await adapter.listMessages(workspaceId, threadId) return messages ? [...messages] : null }, - appendMessageToThread: adapter.appendMessage, + + appendMessageToThread: (workspaceId, input) => + adapter.appendMessage(workspaceId, input), } } diff --git a/src/domains/chat/demo-chat-repository.ts b/src/domains/chat/demo-chat-repository.ts deleted file mode 100644 index 903d595..0000000 --- a/src/domains/chat/demo-chat-repository.ts +++ /dev/null @@ -1,85 +0,0 @@ -import "server-only" - -import { and, eq } from "drizzle-orm" -import { Effect } from "effect" - -import { chatCitationPersistence } from "./chat-citation-persistence" -import { DEMO_CHAT_MESSAGES } from "./demo" -import { DbClient } from "@/infrastructure/db" -import { chatMessages, chatThreads } from "@/infrastructure/db/schema" - -type DemoChatRepository = { - readonly ensureDemoThreadEffect: ( - workspaceId: string, - demoKey: string, - title: string, - documentId: string, - ) => Effect.Effect -} - -const demoChatCreatedAtMs = Date.parse("2026-01-01T00:00:00.000Z") - -const ensureDemoThreadEffect: DemoChatRepository["ensureDemoThreadEffect"] = ( - workspaceId: string, - demoKey: string, - title: string, - documentId: string, -) => - Effect.gen(function* () { - const db = yield* DbClient - const existingThread = yield* Effect.promise(() => - db - .select({ id: chatThreads.id }) - .from(chatThreads) - .where( - and( - eq(chatThreads.workspaceId, workspaceId), - eq(chatThreads.demoKey, demoKey), - ), - ) - .limit(1), - ) - - if (existingThread[0]) return - - yield* Effect.promise(() => - db.transaction(async (tx) => { - const [thread] = await tx - .insert(chatThreads) - .values({ - workspaceId, - title, - demoKey, - createdAt: new Date(demoChatCreatedAtMs), - updatedAt: new Date( - demoChatCreatedAtMs + DEMO_CHAT_MESSAGES.length * 1000, - ), - }) - .onConflictDoNothing({ - target: [chatThreads.workspaceId, chatThreads.demoKey], - }) - .returning() - - if (!thread) return - - await tx.insert(chatMessages).values( - DEMO_CHAT_MESSAGES.map((message, index) => ({ - threadId: thread.id, - role: message.role, - content: message.content, - citations: chatCitationPersistence.normalizeCitations( - chatCitationPersistence.replaceDemoCitationDocumentId( - message.citations, - documentId, - ), - ), - createdAt: new Date(demoChatCreatedAtMs + index * 1000), - })), - ) - }), - ) - }) - -export const demoChatRepository: DemoChatRepository = { - ensureDemoThreadEffect, -} diff --git a/src/domains/chat/demo.test.ts b/src/domains/chat/demo.test.ts deleted file mode 100644 index 1a98645..0000000 --- a/src/domains/chat/demo.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { readFileSync } from "node:fs"; -import path from "node:path"; - -import { describe, expect, it } from "vitest"; - -import { resolveCitationChunk } from "../chunks"; -import { DEMO_CHAT_MESSAGES } from "./demo"; -import type { ParsedChunkView } from "@/domains/chunks/types"; - -type RawDemoChunk = { - readonly chunk_id: string; - readonly type: string; - readonly content?: unknown; - readonly path?: unknown; - readonly metadata?: Readonly>; -}; - -describe("DEMO_CHAT_MESSAGES", () => { - it("uses citations that resolve to bundled TSLA demo chunks", () => { - const chunks = loadTslaDemoChunks(); - const citations = DEMO_CHAT_MESSAGES.flatMap((message) => - message.citations ?? [], - ); - - expect(citations.length).toBeGreaterThan(0); - for (const citation of citations) { - expect(resolveCitationChunk(citation, chunks)?.chunkId).toBeTruthy(); - } - }); - - it("keeps assistant examples aligned with single-result demo retrieval", () => { - const assistantMessages = DEMO_CHAT_MESSAGES.filter( - (message) => message.role === "assistant", - ); - - expect(assistantMessages).toHaveLength(3); - for (const message of assistantMessages) { - expect(message.citations).toHaveLength(1); - } - }); - - it("uses latest parser section paths for demo citations", () => { - const citations = DEMO_CHAT_MESSAGES.flatMap((message) => - message.citations ?? [], - ); - - expect(citations.map((citation) => citation.source.sectionPath)).toEqual([ - "Default_Root/TSLA-Q4-2025-Update(1).pdf-->OTHER UPDATES", - "Default_Root/TSLA-Q4-2025-Update(1).pdf-->SUMMARY-->Energy generation and storage", - "Default_Root/TSLA-Q4-2025-Update(1).pdf-->OUTLOOK-->Product", - ]); - expect( - citations.every( - (citation) => - citation.source.sourceFileName === "TSLA-Q4-2025-Update(1).pdf", - ), - ).toBe(true); - }); -}); - -function loadTslaDemoChunks(): ParsedChunkView[] { - const filePath = path.join( - process.cwd(), - "public", - "demo-sources", - "tsla-q4-2025", - "chunks.json", - ); - const rawBody = JSON.parse(readFileSync(filePath, "utf8")) as unknown; - const rawChunks = parseRawDemoChunks(rawBody); - - return rawChunks.map((chunk) => ({ - chunkId: `demo-tsla-q4-2025:${chunk.chunk_id}`, - parserChunkId: chunk.chunk_id, - documentId: "demo-doc-tsla-q4-2025", - sectionPath: getString(chunk.path) ?? null, - type: toChunkType(chunk.type), - content: getString(chunk.content) ?? "", - sourceTitle: "TSLA-Q4-2025-Update(1).pdf", - summary: getString(chunk.metadata?.summary), - })); -} - -function parseRawDemoChunks(value: unknown): readonly RawDemoChunk[] { - if (!isRecord(value) || !Array.isArray(value.chunks)) return []; - - return value.chunks.filter(isRawDemoChunk); -} - -function isRawDemoChunk(value: unknown): value is RawDemoChunk { - return ( - isRecord(value) && - typeof value.chunk_id === "string" && - typeof value.type === "string" - ); -} - -function toChunkType(value: string): ParsedChunkView["type"] { - if (value === "image" || value === "table") return value; - return "text"; -} - -function getString(value: unknown): string | undefined { - if (typeof value !== "string") return undefined; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; -} - -function isRecord(value: unknown): value is Readonly> { - return value !== null && typeof value === "object"; -} diff --git a/src/domains/chat/demo.ts b/src/domains/chat/demo.ts deleted file mode 100644 index 7ae1259..0000000 --- a/src/domains/chat/demo.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type { ChatCitationView, ChatMessageView } from "@/domains/chat/types"; - -/** - * Static demo chat messages with citations that reference demo sources. - * - * Each citation's `source.documentId` matches a demo source definition - * (see demo-data.ts). The workspace shell's `handleCitationClick` - * resolves the documentId against the loaded sources list, finds the - * matching demo source, loads its chunks from the public directory, and - * focuses the first text chunk. - * - * Clicking a citation chip in the guest demo should scroll to the - * corresponding parsed content. - */ -export const DEMO_CHAT_MESSAGES: readonly ChatMessageView[] = [ - { - id: "demo-user-1", - role: "user", - content: "What does the document say about Tesla's xAI investment?", - }, - { - id: "demo-asst-1", - role: "assistant", - content: [ - "Tesla entered an agreement on January 16, 2026 to invest approximately $2 billion in xAI Series E Preferred Stock.", - "The document also says Tesla and xAI entered a framework agreement to evaluate AI collaboration, with the investment expected to close in Q1 2026 subject to customary regulatory conditions.", - ].join("\n\n"), - citations: [ - makeCitation("demo-doc-tsla-q4-2025", { - sourceFileName: "TSLA-Q4-2025-Update(1).pdf", - sectionPath: "Default_Root/TSLA-Q4-2025-Update(1).pdf-->OTHER UPDATES", - description: "xAI investment", - content: "On January 16, 2026, Tesla entered into an agreement to invest approximately", - }), - ], - }, - { - id: "demo-user-2", - role: "user", - content: "What does the document say about energy storage?", - }, - { - id: "demo-asst-2", - role: "assistant", - content: [ - "Tesla achieved its highest quarterly energy storage deployments, driven by record Megapack deployments.", - "Energy gross profit reached a record $1.1 billion, marking the fifth consecutive record quarter.", - "Tesla also plans to begin Megapack 3 and Megablock production at Megafactory Houston in 2026.", - ].join("\n\n"), - citations: [ - makeCitation("demo-doc-tsla-q4-2025", { - sourceFileName: "TSLA-Q4-2025-Update(1).pdf", - sectionPath: "Default_Root/TSLA-Q4-2025-Update(1).pdf-->SUMMARY-->Energy generation and storage", - description: "Storage deployment growth", - content: "We achieved our highest quarterly energy storage deployments, driven by record Megapack deployments.", - }), - ], - }, - { - id: "demo-user-3", - role: "user", - content: "What production plans does Tesla mention for 2026?", - }, - { - id: "demo-asst-3", - role: "assistant", - content: [ - "Tesla says Cybercab, Tesla Semi, and Megapack 3 are on schedule for volume production starting in 2026.", - "The same product update also notes that first-generation Optimus production lines are being installed before volume production.", - ].join("\n\n"), - citations: [ - makeCitation("demo-doc-tsla-q4-2025", { - sourceFileName: "TSLA-Q4-2025-Update(1).pdf", - sectionPath: "Default_Root/TSLA-Q4-2025-Update(1).pdf-->OUTLOOK-->Product", - description: "2026 production plans", - content: "Cybercab, Tesla Semi and Megapack 3 are on schedule for volume production starting in 2026.", - }), - ], - }, -]; - -function makeCitation( - documentId: string, - overrides: { - sourceFileName: string; - sectionPath: string; - description: string; - content: string; - }, -): ChatCitationView { - return { - chunkType: "text", - score: 0.95, - content: overrides.content, - description: overrides.description, - assetUrl: undefined, - source: { - documentId, - sourceFileName: overrides.sourceFileName, - sectionPath: overrides.sectionPath, - }, - }; -} diff --git a/src/domains/chat/index.test.ts b/src/domains/chat/index.test.ts index 90627e7..835fabf 100644 --- a/src/domains/chat/index.test.ts +++ b/src/domains/chat/index.test.ts @@ -340,6 +340,23 @@ describe("buildGroundedPrompt", () => { expect(prompt).toContain("requirements.txt"); expect(prompt).toContain("don't answer the question, say so directly"); }); + + it("asks the model to answer naturally and directly", () => { + const prompt = buildGroundedPrompt({ + question: "How about the TBD?", + results: [ + makeRetrievalResult({ + content: "Roadster location: TBD. Status: Design development.", + }), + ], + }); + + expect(prompt).toContain("Answer in a natural, friendly, and direct tone."); + expect(prompt).toContain("Start with the answer first."); + expect(prompt).toContain("Avoid meta phrases like \"Based on the sources\""); + expect(prompt).toContain("Keep answers concise by default"); + expect(prompt).toContain("I don't see more detail in these sources"); + }); }); describe("buildRetrievalQueryPrompt", () => { diff --git a/src/domains/chat/prompt.ts b/src/domains/chat/prompt.ts index 0620306..ab715e4 100644 --- a/src/domains/chat/prompt.ts +++ b/src/domains/chat/prompt.ts @@ -144,6 +144,11 @@ export function buildGroundedPrompt(input: BuildGroundedPromptInput): string { "You are an assistant that answers questions from provided source excerpts.", "Your answer must be grounded only in the sources below. If they don't answer the question, say so directly.", "Use the recent conversation only to resolve references like \"this document\"; do not use it as factual evidence.", + "Answer in a natural, friendly, and direct tone.", + "Start with the answer first. Avoid meta phrases like \"Based on the sources\" or \"Based on the source excerpts\" unless the user asks how you know.", + "Use plain language. Prefer \"I don't see more detail in these sources\" over formal wording like \"the sources do not specify\".", + "Keep answers concise by default: 1-3 short paragraphs unless the user asks for detail.", + "Do not over-explain uncertainty. State what is known, then briefly state what is not shown in the sources.", "CITATION FORMAT: After each sourced statement include a brief citation label like [Source N: what the source says]. Use only the provided source numbers.", "", `Question: ${input.question}`, diff --git a/src/domains/chat/repository.ts b/src/domains/chat/repository.ts index a1f4fa6..30ed1a5 100644 --- a/src/domains/chat/repository.ts +++ b/src/domains/chat/repository.ts @@ -1,6 +1,5 @@ import "server-only" -import { demoChatRepository } from "./demo-chat-repository" import { chatMessageRepository } from "./chat-message-repository" import { chatThreadRepository } from "./chat-thread-repository" @@ -9,10 +8,10 @@ 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 - readonly ensureDemoThreadEffect: typeof demoChatRepository.ensureDemoThreadEffect } export const chatRepository: ChatRepository = { @@ -20,8 +19,8 @@ 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, - ensureDemoThreadEffect: demoChatRepository.ensureDemoThreadEffect, } diff --git a/src/domains/chat/route-answer.ts b/src/domains/chat/route-answer.ts index 896c026..a7599d8 100644 --- a/src/domains/chat/route-answer.ts +++ b/src/domains/chat/route-answer.ts @@ -1,4 +1,4 @@ -import { Either } from "effect" +import { Effect, Either } from "effect" import { generateContextualRetrievalQuery, @@ -30,42 +30,62 @@ type ChatAnswerRouteService = { ) => Promise> } -async function answerChat( - input: AnswerChatInput, -): Promise> { - const body = parseChatRequestBody(input.body) - if (!body.ok) { - return routeResult.error(body.status, body.message) - } +// --------------------------------------------------------------------------- +// Effect core +// --------------------------------------------------------------------------- - const { workspace, client } = - await notebookRequestContext.getAuthenticatedWithClient() - const sources = await reconcileSourcesForWorkspace(workspace, client) +const answerChatEffect = (input: AnswerChatInput) => + Effect.gen(function* () { + const body = parseChatRequestBody(input.body) + if (!body.ok) { + return routeResult.error(body.status, body.message) + } - try { - const result = await handleChatTurn({ - workspace, - sources, - question: body.value.question, - threadId: body.value.threadId, - excludedSourceIds: body.value.excludedSourceIds, - retrieval: client.retrieval, - generateRetrievalQuery: generateContextualRetrievalQuery, - generateAnswer: generateGroundedAnswer, - repository: chatTurnPersistence.createRepository(), - }) + const { workspace, client } = yield* Effect.tryPromise(() => + notebookRequestContext.getAuthenticatedWithClient(), + ) + const sources = yield* Effect.tryPromise(() => + reconcileSourcesForWorkspace(workspace, client), + ) + + const result = yield* Effect.tryPromise(() => + handleChatTurn({ + workspace, + sources, + question: body.value.question, + threadId: body.value.threadId, + excludedSourceIds: body.value.excludedSourceIds, + retrieval: client.retrieval, + generateRetrievalQuery: generateContextualRetrievalQuery, + generateAnswer: generateGroundedAnswer, + repository: chatTurnPersistence.createRepository(), + }), + ).pipe( + Effect.catchAll(() => + Effect.succeed( + Either.left({ + status: 401, + message: "Your session may have expired. Please refresh the page.", + }), + ), + ), + ) return Either.match(result, { onLeft: (error): RouteResponse => routeResult.error(error.status, error.message), onRight: (value): RouteResponse => routeResult.ok(value), }) - } catch { - return routeResult.error( - 401, - "Your session may have expired. Please refresh the page.", - ) - } + }) + +// --------------------------------------------------------------------------- +// Async wrapper (backward-compatible) +// --------------------------------------------------------------------------- + +async function answerChat( + input: AnswerChatInput, +): Promise> { + return Effect.runPromise(answerChatEffect(input)) } export const chatAnswerRouteService: ChatAnswerRouteService = { diff --git a/src/domains/chat/route-service.test.ts b/src/domains/chat/route-service.test.ts index b963d41..8fcc9cf 100644 --- a/src/domains/chat/route-service.test.ts +++ b/src/domains/chat/route-service.test.ts @@ -117,9 +117,9 @@ describe("chat route services", () => { generateRetrievalQuery: mocks.generateContextualRetrievalQuery, generateAnswer: mocks.generateGroundedAnswer, repository: expect.objectContaining({ - appendMessageToThread: mocks.appendMessageToThread, - ensureDefaultChatThread: mocks.ensureDefaultChatThread, - findChatThreadInWorkspace: mocks.findChatThreadInWorkspace, + appendMessageToThread: expect.any(Function), + ensureDefaultChatThread: expect.any(Function), + findChatThreadInWorkspace: expect.any(Function), listMessagesForThread: expect.any(Function), }), }), diff --git a/src/domains/chat/route-threads.ts b/src/domains/chat/route-threads.ts index 796996d..3a851a6 100644 --- a/src/domains/chat/route-threads.ts +++ b/src/domains/chat/route-threads.ts @@ -1,3 +1,5 @@ +import { Effect } from "effect" + import { chatThreadService } from "@/domains/chat/thread-service" import { toChatMessageView, toChatThreadView } from "@/domains/chat/view" import { notebookRequestContext } from "@/domains/workspace/request-context" @@ -48,67 +50,102 @@ type ChatThreadRouteService = { readonly listThreads: () => Promise> } -async function listThreads(): Promise> { - const { workspace } = await notebookRequestContext.getAuthenticated() - const threads = await chatThreadService.listForWorkspace(workspace.id) +// --------------------------------------------------------------------------- +// Effect core +// --------------------------------------------------------------------------- + +const listThreadsEffect = Effect.gen(function* () { + const { workspace } = yield* Effect.tryPromise(() => + notebookRequestContext.getAuthenticated(), + ) + const threads = yield* Effect.tryPromise(() => + chatThreadService.listForWorkspace(workspace.id), + ) return routeResult.ok({ threads: threads.map(toChatThreadView), }) -} +}) -async function createThread(): Promise> { - const { workspace } = await notebookRequestContext.getAuthenticated() - const thread = await chatThreadService.create(workspace.id) +const createThreadEffect = Effect.gen(function* () { + const { workspace } = yield* Effect.tryPromise(() => + notebookRequestContext.getAuthenticated(), + ) + const thread = yield* Effect.tryPromise(() => + chatThreadService.create(workspace.id), + ) return routeResult.ok({ thread: toChatThreadView(thread), - messages: [], + messages: [] as unknown as [], }) +}) + +const getThreadEffect = (input: ThreadInput) => + Effect.gen(function* () { + const { workspace } = yield* Effect.tryPromise(() => + notebookRequestContext.getAuthenticated(), + ) + const thread = yield* Effect.tryPromise(() => + chatThreadService.findInWorkspace(workspace.id, input.threadId), + ) + + if (!thread) { + return routeResult.error(404, "Chat thread not found.") + } + + const messages = yield* Effect.tryPromise(() => + chatThreadService.listMessages(workspace.id, input.threadId), + ) + if (!messages) { + return routeResult.error(404, "Chat thread not found.") + } + + return routeResult.ok({ + thread: toChatThreadView(thread), + messages: messages.map((message): ChatMessageView => + toChatMessageView(message), + ), + }) + }) + +const archiveThreadEffect = (input: ArchiveThreadInput) => + Effect.gen(function* () { + const { workspace } = yield* Effect.tryPromise(() => + notebookRequestContext.getAuthenticated(), + ) + const archived = yield* Effect.tryPromise(() => + chatThreadService.softDelete(workspace.id, input.threadId), + ) + if (!archived) { + return routeResult.error(404, "Chat thread not found.") + } + + return routeResult.ok({ id: input.threadId, archived: true as const }) + }) + +// --------------------------------------------------------------------------- +// Async wrappers (backward-compatible) +// --------------------------------------------------------------------------- + +async function listThreads(): Promise> { + return Effect.runPromise(listThreadsEffect) +} + +async function createThread(): Promise> { + return Effect.runPromise(createThreadEffect) } async function getThread( input: ThreadInput, ): Promise> { - const { workspace } = await notebookRequestContext.getAuthenticated() - const thread = await chatThreadService.findInWorkspace( - workspace.id, - input.threadId, - ) - - if (!thread) { - return routeResult.error(404, "Chat thread not found.") - } - - const messages = await chatThreadService.listMessages( - workspace.id, - input.threadId, - ) - if (!messages) { - return routeResult.error(404, "Chat thread not found.") - } - - return routeResult.ok({ - thread: toChatThreadView(thread), - messages: messages.map((message): ChatMessageView => - toChatMessageView(message), - ), - }) + return Effect.runPromise(getThreadEffect(input)) } async function archiveThread( input: ArchiveThreadInput, ): Promise> { - const { workspace } = await notebookRequestContext.getAuthenticated() - const archived = await chatThreadService.softDelete( - workspace.id, - input.threadId, - ) - if (!archived) { - return routeResult.error(404, "Chat thread not found.") - } - - return routeResult.ok({ id: input.threadId, archived: true }) + return Effect.runPromise(archiveThreadEffect(input)) } export const chatThreadRouteService: ChatThreadRouteService = { diff --git a/src/domains/chat/thread-service.ts b/src/domains/chat/thread-service.ts index b5f3275..e22085f 100644 --- a/src/domains/chat/thread-service.ts +++ b/src/domains/chat/thread-service.ts @@ -1,8 +1,10 @@ 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 { ChatCitationView, CitationView, @@ -18,6 +20,11 @@ type AppendMessageInput = { | null } +type DemoChatThreadSeed = { + readonly thread: ChatThread + readonly messages: ChatMessage[] +} + type ChatThreadService = { readonly findInWorkspace: ( workspaceId: string, @@ -26,6 +33,10 @@ 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, @@ -40,6 +51,8 @@ type ChatThreadService = { ) => Promise } +const seededDemoChatKey = "knowhere-demo-chat" + const findInWorkspace: ChatThreadService["findInWorkspace"] = ( workspaceId: string, threadId: string, @@ -65,6 +78,23 @@ 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, @@ -94,6 +124,7 @@ export const chatThreadService: ChatThreadService = { listForWorkspace, create, ensureDefault, + ensureDemo, listMessages, softDelete, appendMessage, diff --git a/src/domains/chunks/index.ts b/src/domains/chunks/index.ts index d3d98ce..d774708 100644 --- a/src/domains/chunks/index.ts +++ b/src/domains/chunks/index.ts @@ -7,7 +7,7 @@ import type { ChatCitationView } from "@/domains/chat/types" import type { ParsedChunkView } from "@/domains/chunks/types" const documentChunkPageSize = 200 -const defaultChunkPageSize = 100 +const defaultChunkPageSize = 50 const maximumChunkPageSize = 200 export type ChunkKnowhereClient = { diff --git a/src/domains/chunks/normalization.ts b/src/domains/chunks/normalization.ts index 7aaa25f..1568dd6 100644 --- a/src/domains/chunks/normalization.ts +++ b/src/domains/chunks/normalization.ts @@ -19,11 +19,6 @@ type ParsedChunkNormalizationInput = { readonly sourceTitle: string } -type DemoAssetUrlInput = { - readonly assetDirectory: string - readonly filePath: string -} - function createParsedChunkView( input: ParsedChunkNormalizationInput, ): ParsedChunkView { @@ -50,15 +45,6 @@ function createParsedChunkView( } } -function buildDemoAssetURL(input: DemoAssetUrlInput): string { - const encodedPath = input.filePath - .split("/") - .map((segment) => encodeURIComponent(segment)) - .join("/") - - return `/demo-sources/${encodeURIComponent(input.assetDirectory)}/${encodedPath}` -} - function resolveConnectionTargets( chunks: readonly ParsedChunkView[], ): ParsedChunkView[] { @@ -247,7 +233,6 @@ function isRecord(value: unknown): value is Readonly> { } export const parsedChunkNormalization = { - buildDemoAssetURL, createParsedChunkView, resolveCitationChunk, resolveCitationChunkByContent, diff --git a/src/domains/demo/view.ts b/src/domains/demo/view.ts new file mode 100644 index 0000000..861241e --- /dev/null +++ b/src/domains/demo/view.ts @@ -0,0 +1,82 @@ +import { parsedChunkNormalization } from "@/domains/chunks/normalization" +import type { ChatMessageView } from "@/domains/chat/types" +import type { ParsedChunkView } from "@/domains/chunks/types" +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 { + return { + id: source.demoSourceId, + kind: "demo", + demoSourceId: source.demoSourceId, + title: source.title, + mimeType: source.mimeType, + status: "ready", + documentId: source.canonicalDocumentId, + originalFile: { + url: `/api/demo-sources/${encodeURIComponent(source.demoSourceId)}/original`, + mimeType: source.originalFile.mimeType, + sizeBytes: source.originalFile.sizeBytes, + canDownload: source.originalFile.canDownload, + }, + 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.source.documentId, + 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 new file mode 100644 index 0000000..39abc5a --- /dev/null +++ b/src/domains/demo/workspace-source-resolution.ts @@ -0,0 +1,100 @@ +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/background-reconcile.ts b/src/domains/sources/background-reconcile.ts new file mode 100644 index 0000000..3b2a8c6 --- /dev/null +++ b/src/domains/sources/background-reconcile.ts @@ -0,0 +1,70 @@ +import "server-only" + +import { Effect } from "effect" +import { Client } from "@upstash/workflow" + +import { logger } from "@/lib/logger" + +// Re-trigger protection: Layers 1 & 2. +// +// Layer 1 — Upstash idempotency via workflowRunId=sourceId ensures at most one +// running workflow per source, even across process restarts or multiple instances. +// +// Layer 2 — In-memory Set avoids the network call entirely when the same process +// already triggered a workflow for this source. + +const triggeredSourceIds = new Set() + +function createClient(): Client { + return new Client({ token: process.env.QSTASH_TOKEN! }) +} + +function resolveBaseURL(): string { + return process.env.NOTEBOOK_PUBLIC_URL ?? "http://localhost:3000" +} + +// --------------------------------------------------------------------------- +// Effect core +// --------------------------------------------------------------------------- + +const startBackgroundReconciliationEffect = ( + workspaceId: string, + sourceId: string, + apiKey: string, +): Effect.Effect => + Effect.gen(function* () { + if (triggeredSourceIds.has(sourceId)) return + triggeredSourceIds.add(sourceId) + + yield* Effect.tryPromise(() => + createClient().trigger({ + url: `${resolveBaseURL()}/api/sources/reconcile`, + body: { workspaceId, sourceId, apiKey }, + workflowRunId: sourceId, + retries: 3, + }), + ) + yield* Effect.logInfo( + `background-reconcile: workflow triggered for ${sourceId}`, + ) + }).pipe( + Effect.catchAll((error) => + Effect.sync(() => { + triggeredSourceIds.delete(sourceId) + logger.error("background-reconcile: failed to trigger workflow", { + sourceId, + error: String(error), + }) + }), + ), + ) + +export async function startBackgroundReconciliation( + workspaceId: string, + sourceId: string, + apiKey: string, +): Promise { + return Effect.runPromise( + startBackgroundReconciliationEffect(workspaceId, sourceId, apiKey), + ) +} diff --git a/src/domains/sources/counts.test.ts b/src/domains/sources/counts.test.ts index 00d7e45..22cdda9 100644 --- a/src/domains/sources/counts.test.ts +++ b/src/domains/sources/counts.test.ts @@ -76,8 +76,10 @@ describe("countChunksBySourceId", () => { expect(counts.size).toBe(0) }) - it("uses bundled counts for persisted demo sources without calling Knowhere", async () => { - const listChunks = vi.fn() + 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 @@ -90,7 +92,7 @@ describe("countChunksBySourceId", () => { makeSource({ id: "source_demo", demoKey: "demo-tsla-q4-2025", - knowhereDocumentId: "demo-doc-tsla-q4-2025", + knowhereDocumentId: "doc_user_copy", }), ], mockClient, @@ -98,6 +100,6 @@ describe("countChunksBySourceId", () => { ) expect(listChunks).not.toHaveBeenCalled() - expect(counts).toEqual(new Map([["source_demo", 70]])) + expect(counts.size).toBe(0) }) }) diff --git a/src/domains/sources/counts.ts b/src/domains/sources/counts.ts index ea20aad..310a694 100644 --- a/src/domains/sources/counts.ts +++ b/src/domains/sources/counts.ts @@ -3,7 +3,6 @@ import "server-only" import { Effect, Either } from "effect" import type Knowhere from "@ontos-ai/knowhere-sdk" -import { demoData } from "./demo-data" import type { Source } from "@/infrastructure/db/schema" export const countChunksBySourceId = ( @@ -12,7 +11,10 @@ export const countChunksBySourceId = ( ) => Effect.gen(function* () { const readySources = sources.filter( - (source) => source.status === "ready" && source.knowhereDocumentId, + (source) => + !source.demoKey && + source.status === "ready" && + source.knowhereDocumentId, ) if (readySources.length === 0) return new Map() @@ -22,11 +24,6 @@ export const countChunksBySourceId = ( const documentId = source.knowhereDocumentId if (!documentId) return [source.id, undefined] as const - const demoChunkCount = demoData.getChunkCountForDocumentId(documentId) - if (demoChunkCount !== undefined) { - return [source.id, demoChunkCount] as const - } - const result = yield* Effect.either( Effect.tryPromise(() => client.documents.listChunks(documentId, { diff --git a/src/domains/sources/demo-data.test.ts b/src/domains/sources/demo-data.test.ts deleted file mode 100644 index 9b1e56b..0000000 --- a/src/domains/sources/demo-data.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import type { ParsedChunkView } from "@/domains/chunks/types"; -import type { SourceView } from "@/domains/sources/types"; - -type DemoDataModule = { - readonly demoData?: { - readonly listSources: () => readonly SourceView[]; - readonly loadChunksForSource: ( - sourceId: string, - ) => Promise; - }; -}; - -describe("demoData", () => { - it("exposes only the TSLA guest source with its real chunk count", async () => { - const demoModule: DemoDataModule = await import("./demo-data"); - const sources = demoModule.demoData?.listSources(); - - expect(sources).toEqual([ - { - id: "demo-tsla-q4-2025", - title: "TSLA-Q4-2025-Update(1).pdf", - mimeType: "application/pdf", - status: "ready", - documentId: "demo-doc-tsla-q4-2025", - chunkCount: 70, - originalFile: { - url: "/demo-sources/tsla-q4-2025/original.pdf", - mimeType: "application/pdf", - canDownload: false, - }, - }, - ]); - }); - - it("loads parsed chunks from the static guest packages", async () => { - const demoModule: DemoDataModule = await import("./demo-data"); - expect(typeof demoModule.demoData?.loadChunksForSource).toBe("function"); - - const tslaChunks = await demoModule.demoData!.loadChunksForSource( - "demo-tsla-q4-2025", - ); - - expect(tslaChunks).toHaveLength(70); - expect(tslaChunks?.[0]).toMatchObject({ - chunkId: "demo-tsla-q4-2025:15bcc860-b8d0-50c6-a627-66dbae67acd4", - documentId: "demo-doc-tsla-q4-2025", - sectionPath: "tables/table-0 Tesla 2025 Results.html", - type: "table", - sourceTitle: "TSLA-Q4-2025-Update(1).pdf", - }); - expect(tslaChunks?.[0]?.content).toContain(""); - expect(tslaChunks?.[0]?.summary).toContain("Tesla reported strong 2025"); - }); - - it("loads page numbers from the static guest chunks", async () => { - const demoModule: DemoDataModule = await import("./demo-data"); - const tslaChunks = await demoModule.demoData!.loadChunksForSource( - "demo-tsla-q4-2025", - ); - - expect(tslaChunks?.every((chunk) => chunk.pageNums?.length)).toBe(true); - expect(tslaChunks?.[0]?.pageNums).toEqual([11]); - expect( - tslaChunks?.find( - (chunk) => - chunk.sectionPath === - "Default_Root/TSLA-Q4-2025-Update(1).pdf-->SUMMARY-->Automotive", - )?.pageNums, - ).toEqual([8]); - expect( - tslaChunks?.find( - (chunk) => chunk.filePath === "images/image-5-Tesla Model Y Driving.jpg", - )?.pageNums, - ).toEqual([15]); - expect(tslaChunks?.at(-1)?.pageNums).toEqual([34]); - }); - - it("encodes static asset urls so reserved filename characters stay in the path", async () => { - const demoModule: DemoDataModule = await import("./demo-data"); - const tslaChunks = await demoModule.demoData!.loadChunksForSource( - "demo-tsla-q4-2025", - ); - - const modelYImageChunk = tslaChunks?.find( - (chunk) => chunk.filePath === "images/image-5-Tesla Model Y Driving.jpg", - ); - - expect(modelYImageChunk?.assetUrl).toBe( - "/demo-sources/tsla-q4-2025/images/image-5-Tesla%20Model%20Y%20Driving.jpg", - ); - }); - - it("returns null for unknown guest source ids", async () => { - const demoModule: DemoDataModule = await import("./demo-data"); - - await expect( - demoModule.demoData?.loadChunksForSource("missing-source"), - ).resolves.toBeNull(); - }); -}); diff --git a/src/domains/sources/demo-data.ts b/src/domains/sources/demo-data.ts deleted file mode 100644 index a4c1170..0000000 --- a/src/domains/sources/demo-data.ts +++ /dev/null @@ -1,245 +0,0 @@ -import "server-only"; - -import { readFile } from "node:fs/promises"; -import path from "node:path"; - -import { parsedChunkNormalization } from "../chunks/normalization"; -import type { ParsedChunkView } from "@/domains/chunks/types"; -import type { SourceView } from "@/domains/sources/types"; - -type DemoSourceDefinition = { - readonly id: string; - readonly documentId: string; - readonly title: string; - readonly mimeType: string; - readonly originalFilePath: string; - readonly originalSizeBytes: number; - readonly assetDirectory: string; - readonly chunkCount: number; - readonly chatThreadTitle: string; -}; - -export type DemoSourceSeed = { - readonly demoKey: string; - readonly documentId: string; - readonly title: string; - readonly mimeType: string; - readonly originalFileUrl: string; - readonly originalFileSystemPath: string; - readonly originalSizeBytes: number; - readonly chunkCount: number; - readonly chatThreadTitle: string; -}; - -type RawDemoChunk = { - readonly chunk_id: string; - readonly type: string; - readonly content?: unknown; - readonly path?: unknown; - readonly metadata?: Readonly>; -}; - -const demoSourceDefinitions: readonly DemoSourceDefinition[] = [ - { - id: "demo-tsla-q4-2025", - documentId: "demo-doc-tsla-q4-2025", - title: "TSLA-Q4-2025-Update(1).pdf", - mimeType: "application/pdf", - originalFilePath: "original.pdf", - originalSizeBytes: 5648867, - assetDirectory: "tsla-q4-2025", - chunkCount: 70, - chatThreadTitle: "TSLA demo conversation", - }, -] as const; - -const demoAssetsDirectoryPath = path.join( - process.cwd(), - "public", - "demo-sources", -); - -function listSources(): SourceView[] { - return demoSourceDefinitions.map((source) => ({ - id: source.id, - title: source.title, - mimeType: source.mimeType, - status: "ready", - documentId: source.documentId, - chunkCount: source.chunkCount, - originalFile: { - url: parsedChunkNormalization.buildDemoAssetURL({ - assetDirectory: source.assetDirectory, - filePath: source.originalFilePath, - }), - mimeType: source.mimeType, - canDownload: false, - }, - })); -} - -function listSourceSeeds(): DemoSourceSeed[] { - return demoSourceDefinitions.map(toDemoSourceSeed); -} - -function getSourceSeedByDemoKey( - demoKey: string | null | undefined, -): DemoSourceSeed | null { - if (!demoKey) return null; - const source = demoSourceDefinitions.find( - (candidate) => candidate.id === demoKey, - ); - return source ? toDemoSourceSeed(source) : null; -} - -function getSourceSeedByDocumentId( - documentId: string | null | undefined, -): DemoSourceSeed | null { - if (!documentId) return null; - const source = demoSourceDefinitions.find( - (candidate) => candidate.documentId === documentId, - ); - return source ? toDemoSourceSeed(source) : null; -} - -function getChunkCountForDocumentId( - documentId: string | null | undefined, -): number | undefined { - return getSourceSeedByDocumentId(documentId)?.chunkCount; -} - -async function loadChunksForSource( - sourceId: string, -): Promise { - const source = demoSourceDefinitions.find( - (candidate) => candidate.id === sourceId, - ); - return source ? loadChunksForDefinition(source) : null; -} - -async function loadChunksForDocumentId( - documentId: string | null | undefined, -): Promise { - if (!documentId) return null; - const source = demoSourceDefinitions.find( - (candidate) => candidate.documentId === documentId, - ); - return source ? loadChunksForDefinition(source) : null; -} - -async function loadChunksForDefinition( - source: DemoSourceDefinition, -): Promise { - const filePath = path.join( - demoAssetsDirectoryPath, - source.assetDirectory, - "chunks.json", - ); - const body = await readFile(filePath, "utf8"); - const rawChunks = parseRawChunks(JSON.parse(body) as unknown); - - return parsedChunkNormalization.resolveConnectionTargets( - rawChunks.map((chunk) => toParsedChunkView(source, chunk)), - ); -} - -function parseRawChunks(value: unknown): readonly RawDemoChunk[] { - if (!isRecord(value)) return []; - - const chunks = value["chunks"]; - if (!Array.isArray(chunks)) return []; - - return chunks.filter(isRawDemoChunk); -} - -function isRawDemoChunk(value: unknown): value is RawDemoChunk { - if (!isRecord(value)) return false; - return ( - typeof value["chunk_id"] === "string" && - typeof value["type"] === "string" - ); -} - -function toParsedChunkView( - source: DemoSourceDefinition, - chunk: RawDemoChunk, -): ParsedChunkView { - const metadata = chunk.metadata ?? {}; - const filePathCandidates = [ - metadata["file_path"], - metadata["filePath"], - chunk.path, - ] as const; - const filePath = getFirstString(filePathCandidates); - const assetUrl = isChunkAsset(chunk.type) && filePath - ? parsedChunkNormalization.buildDemoAssetURL({ - assetDirectory: source.assetDirectory, - filePath, - }) - : undefined; - - return parsedChunkNormalization.createParsedChunkView({ - chunkId: `${source.id}:${chunk.chunk_id}`, - parserChunkId: chunk.chunk_id, - documentId: source.documentId, - sectionPath: getString(chunk.path) ?? null, - chunkType: chunk.type, - content: chunk.content, - metadata, - filePathCandidates, - assetUrl, - sourceTitle: source.title, - }); -} - -function toDemoSourceSeed(source: DemoSourceDefinition): DemoSourceSeed { - return { - demoKey: source.id, - documentId: source.documentId, - title: source.title, - mimeType: source.mimeType, - originalFileUrl: parsedChunkNormalization.buildDemoAssetURL({ - assetDirectory: source.assetDirectory, - filePath: source.originalFilePath, - }), - originalFileSystemPath: path.join( - demoAssetsDirectoryPath, - source.assetDirectory, - source.originalFilePath, - ), - originalSizeBytes: source.originalSizeBytes, - chunkCount: source.chunkCount, - chatThreadTitle: source.chatThreadTitle, - }; -} - -function isChunkAsset(value: string): boolean { - return value === "image" || value === "table"; -} - -function getFirstString(values: readonly unknown[]): string | undefined { - return values.reduce( - (selected, value) => selected ?? getString(value), - undefined, - ); -} - -function getString(value: unknown): string | undefined { - if (typeof value !== "string") return undefined; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; -} - -function isRecord(value: unknown): value is Readonly> { - return typeof value === "object" && value !== null; -} - -export const demoData = { - getChunkCountForDocumentId, - getSourceSeedByDemoKey, - getSourceSeedByDocumentId, - listSourceSeeds, - listSources, - loadChunksForDocumentId, - loadChunksForSource, -} as const; diff --git a/src/domains/sources/demo-source-repository.ts b/src/domains/sources/demo-source-repository.ts index ca555fe..cfb62f2 100644 --- a/src/domains/sources/demo-source-repository.ts +++ b/src/domains/sources/demo-source-repository.ts @@ -1,201 +1,137 @@ import "server-only" -import { and, eq } from "drizzle-orm" +import { and, eq, isNotNull, or, sql } from "drizzle-orm" import { Effect } from "effect" -import { DbClient, type Db } from "@/infrastructure/db" -import { sources, type Source } from "@/infrastructure/db/schema" -import type { DemoSourceUploadRepository } from "./source-upload-contracts" -import { sourceRowRepository } from "./source-row-repository" +import { DbClient } from "@/infrastructure/db" +import { + demoSourceVisibilities, + sources, + type Source, +} from "@/infrastructure/db/schema" -type CreateDemoUploadingSourceInput = { - readonly demoKey: string - readonly title: string - readonly mimeType: string - readonly sizeBytes: number - readonly originalBlobUrl: string -} - -type MarkDemoSourceUploadingInput = { +type UpsertMaterializedDemoSourceInput = { + readonly demoSourceId: string readonly title: string readonly mimeType: string readonly sizeBytes: number + readonly knowhereDocumentId: string readonly originalBlobUrl: string } type DemoSourceRepository = { - readonly findByDemoKeyEffect: ( + readonly listHiddenDemoSourceIdsEffect: ( workspaceId: string, - demoKey: string, - ) => Effect.Effect - readonly createDemoUploadingEffect: ( + ) => Effect.Effect + readonly hideDemoSourceEffect: ( workspaceId: string, - input: CreateDemoUploadingSourceInput, - ) => Effect.Effect - readonly markDemoUploadingEffect: ( + demoSourceId: string, + ) => Effect.Effect + readonly upsertMaterializedDemoSourceEffect: ( workspaceId: string, - sourceId: string, - input: MarkDemoSourceUploadingInput, - ) => Effect.Effect - readonly createDemoUploadRepository: ( - db: Db, - ) => DemoSourceUploadRepository + input: UpsertMaterializedDemoSourceInput, + ) => Effect.Effect } -const findByDemoKeyEffect: DemoSourceRepository["findByDemoKeyEffect"] = ( +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, - demoKey: string, + demoSourceId: string, ) => Effect.gen(function* () { const db = yield* DbClient - return yield* Effect.promise(() => - findByDemoKeyWithDb(db, workspaceId, demoKey), + 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 createDemoUploadingEffect: DemoSourceRepository["createDemoUploadingEffect"] = - (workspaceId: string, input: CreateDemoUploadingSourceInput) => - Effect.gen(function* () { - const db = yield* DbClient - return yield* Effect.promise(() => - createDemoUploadingWithDb(db, workspaceId, input), - ) - }) - -const markDemoUploadingEffect: DemoSourceRepository["markDemoUploadingEffect"] = - ( - workspaceId: string, - sourceId: string, - input: MarkDemoSourceUploadingInput, - ) => +const upsertMaterializedDemoSourceEffect: DemoSourceRepository["upsertMaterializedDemoSourceEffect"] = + (workspaceId: string, input: UpsertMaterializedDemoSourceInput) => Effect.gen(function* () { const db = yield* DbClient - return yield* Effect.promise(() => - markDemoUploadingWithDb(db, workspaceId, sourceId, input), - ) - }) - -function createDemoUploadRepository(db: Db): DemoSourceUploadRepository { - return { - findSourceByDemoKey: (workspaceId: string, demoKey: string) => - findByDemoKeyWithDb(db, workspaceId, demoKey), - createDemoUploadingSource: ( - workspaceId: string, - input: CreateDemoUploadingSourceInput, - ) => createDemoUploadingWithDb(db, workspaceId, input), - markDemoSourceUploading: async ( - workspaceId: string, - sourceId: string, - input: MarkDemoSourceUploadingInput, - ) => - sourceRowRepository.requireSource( - await markDemoUploadingWithDb(db, workspaceId, sourceId, input), - "Source disappeared before demo upload.", - ), - markSourceParsing: async ( - workspaceId: string, - sourceId: string, - jobId: string, - ) => - sourceRowRepository.requireSource( - await sourceRowRepository.updateInWorkspaceWithDb( - db, - workspaceId, - sourceId, - { - status: "parsing", - knowhereJobId: jobId, + const [source] = yield* Effect.promise(() => + db + .insert(sources) + .values({ + workspaceId, + title: input.title, + mimeType: input.mimeType, + sizeBytes: input.sizeBytes, + status: "ready", failureReason: null, - }, - ), - "Source disappeared before parsing.", - ), - markSourceFailed: async ( - workspaceId: string, - sourceId: string, - reason: string, - ) => - sourceRowRepository.requireSource( - await sourceRowRepository.updateInWorkspaceWithDb( - db, - workspaceId, - sourceId, - { - status: "failed", - failureReason: reason, - }, - ), - "Source disappeared before failure.", - ), - } -} - -async function findByDemoKeyWithDb( - db: Db, - workspaceId: string, - demoKey: string, -): Promise { - const rows = await db - .select() - .from(sources) - .where( - and(eq(sources.workspaceId, workspaceId), eq(sources.demoKey, demoKey)), - ) - .limit(1) + 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(), + ) - return rows[0] ?? null -} + if (!source) { + return yield* Effect.die( + new Error("upsertMaterializedDemoSource: upsert did not return a row."), + ) + } -async function createDemoUploadingWithDb( - db: Db, - workspaceId: string, - input: CreateDemoUploadingSourceInput, -): Promise { - const [source] = await db - .insert(sources) - .values({ - workspaceId, - title: input.title, - mimeType: input.mimeType, - sizeBytes: input.sizeBytes, - status: "uploading", - originalBlobUrl: input.originalBlobUrl, - demoKey: input.demoKey, - }) - .onConflictDoNothing({ - target: [sources.workspaceId, sources.demoKey], + return source }) - .returning() - - return source ?? null -} - -async function markDemoUploadingWithDb( - db: Db, - workspaceId: string, - sourceId: string, - input: MarkDemoSourceUploadingInput, -): Promise { - return await sourceRowRepository.updateInWorkspaceWithDb( - db, - workspaceId, - sourceId, - { - title: input.title, - mimeType: input.mimeType, - sizeBytes: input.sizeBytes, - status: "uploading", - failureReason: null, - knowhereJobId: null, - knowhereDocumentId: null, - originalBlobUrl: input.originalBlobUrl, - }, - ) -} export const demoSourceRepository: DemoSourceRepository = { - findByDemoKeyEffect, - createDemoUploadingEffect, - markDemoUploadingEffect, - createDemoUploadRepository, + listHiddenDemoSourceIdsEffect, + hideDemoSourceEffect, + upsertMaterializedDemoSourceEffect, } diff --git a/src/domains/sources/demo-upload.ts b/src/domains/sources/demo-upload.ts deleted file mode 100644 index 30ab870..0000000 --- a/src/domains/sources/demo-upload.ts +++ /dev/null @@ -1,101 +0,0 @@ -import "server-only" - -import { Effect } from "effect" - -import type { Source, Workspace } from "@/infrastructure/db/schema" -import type { - DemoSourceUploadDependencies, - DemoSourceUploadInput, -} from "./source-upload-contracts" - -export const ensureDemoSourceUploadEffect = ( - workspace: Workspace, - input: DemoSourceUploadInput, - deps: DemoSourceUploadDependencies, -) => - Effect.gen(function* () { - const existingSource = yield* Effect.promise(() => - deps.repository.findSourceByDemoKey(workspace.id, input.demoKey), - ) - if (existingSource && !shouldUploadLegacyDemoSource(existingSource, input)) { - return existingSource - } - - const uploadInput = { - title: input.title, - mimeType: input.mimeType, - sizeBytes: input.originalSizeBytes, - originalBlobUrl: input.originalFileUrl, - } - const source = existingSource - ? yield* Effect.promise(() => - deps.repository.markDemoSourceUploading( - workspace.id, - existingSource.id, - uploadInput, - ), - ) - : yield* Effect.promise(() => - deps.repository.createDemoUploadingSource(workspace.id, { - demoKey: input.demoKey, - ...uploadInput, - }), - ) - if (!source) { - return yield* Effect.promise(() => - deps.repository.findSourceByDemoKey(workspace.id, input.demoKey), - ) - } - - return yield* Effect.gen(function* () { - const job = yield* Effect.tryPromise(() => - deps.knowhere.jobs.create({ - sourceType: "file", - fileName: input.title, - namespace: workspace.namespace, - }), - ) - yield* Effect.tryPromise(() => - deps.knowhere.jobs.upload(job, { file: input.originalFileSystemPath }), - ) - - return yield* Effect.promise(() => - deps.repository.markSourceParsing( - workspace.id, - source.id, - job.jobId, - ), - ) - }).pipe( - Effect.catchAll(() => - Effect.promise(() => - deps.repository.markSourceFailed( - workspace.id, - source.id, - "Knowhere upload failed.", - ), - ), - ), - ) - }) - -function shouldUploadLegacyDemoSource( - source: Source, - input: DemoSourceUploadInput, -): boolean { - return ( - source.deletedAt === null && - source.demoKey === input.demoKey && - source.status === "ready" && - source.knowhereJobId === null && - source.knowhereDocumentId === input.documentId - ) -} - -export async function ensureDemoSourceUpload( - workspace: Workspace, - input: DemoSourceUploadInput, - deps: DemoSourceUploadDependencies, -): Promise { - return Effect.runPromise(ensureDemoSourceUploadEffect(workspace, input, deps)) -} diff --git a/src/domains/sources/lifecycle.ts b/src/domains/sources/lifecycle.ts index cbc1404..780dad2 100644 --- a/src/domains/sources/lifecycle.ts +++ b/src/domains/sources/lifecycle.ts @@ -1,5 +1,6 @@ import "server-only" +import { Effect } from "effect" import type { JobResult } from "@ontos-ai/knowhere-sdk" import type { Source } from "@/infrastructure/db/schema" @@ -23,6 +24,7 @@ type SourceLifecycleRepository = { workspaceId: string, sourceId: string, reason: string, + requiredStatus?: string, ): Promise clearSourceStagedBlob(workspaceId: string, sourceId: string): Promise } @@ -47,6 +49,81 @@ type ApplyKnowhereJobToSourceInput = { blobStore: SourceLifecycleBlobStore } +// --------------------------------------------------------------------------- +// Effect core +// --------------------------------------------------------------------------- + +export const applyKnowhereJobToSourceEffect = Effect.fn( + "applyKnowhereJobToSource", +)( + function* ({ + workspaceId, + source, + job, + client, + repository, + parsedResultStore, + blobStore, + }: ApplyKnowhereJobToSourceInput) { + // Best-effort early exit: skip expensive asset uploads when the source has + // already been resolved. The atomic guard (Layer 3) is in the DB UPDATE below. + if (source.status !== "parsing") return + + if (job.isDone || job.status === "done") { + if (job.documentId) { + const stored = yield* Effect.tryPromise(() => + parsedResultStore.storeParsedResultAssets({ + workspaceId, + sourceId: source.id, + job, + client, + }), + ) + yield* Effect.tryPromise(() => + repository.saveSourceParseResult(workspaceId, source.id, stored), + ) + yield* Effect.tryPromise(() => + repository.markSourceReady(workspaceId, source.id, job.documentId!), + ) + yield* cleanupStagedBlobEffect( + workspaceId, + source, + repository, + blobStore, + ) + return + } + + yield* Effect.tryPromise(() => + repository.markSourceFailed( + workspaceId, + source.id, + "Parsing finished but no document was published.", + "parsing", + ), + ) + yield* cleanupStagedBlobEffect(workspaceId, source, repository, blobStore) + return + } + + if (job.isFailed || job.status === "failed") { + yield* Effect.tryPromise(() => + repository.markSourceFailed( + workspaceId, + source.id, + job.error?.message ?? "Parsing failed.", + "parsing", + ), + ) + yield* cleanupStagedBlobEffect(workspaceId, source, repository, blobStore) + } + }, +) + +// --------------------------------------------------------------------------- +// Async wrapper (backward-compatible) +// --------------------------------------------------------------------------- + export async function applyKnowhereJobToSource({ workspaceId, source, @@ -56,51 +133,39 @@ export async function applyKnowhereJobToSource({ parsedResultStore, blobStore, }: ApplyKnowhereJobToSourceInput): Promise { - if (job.isDone || job.status === "done") { - if (job.documentId) { - const stored = await parsedResultStore.storeParsedResultAssets({ - workspaceId, - sourceId: source.id, - job, - client, - }) - await repository.saveSourceParseResult(workspaceId, source.id, stored) - await repository.markSourceReady(workspaceId, source.id, job.documentId) - await cleanupStagedBlob(workspaceId, source, repository, blobStore) - return - } - - await repository.markSourceFailed( - workspaceId, - source.id, - "Parsing finished but no document was published.", - ) - await cleanupStagedBlob(workspaceId, source, repository, blobStore) - return - } - - if (job.isFailed || job.status === "failed") { - await repository.markSourceFailed( + return Effect.runPromise( + applyKnowhereJobToSourceEffect({ workspaceId, - source.id, - job.error?.message ?? "Parsing failed.", - ) - await cleanupStagedBlob(workspaceId, source, repository, blobStore) - } + source, + job, + client, + repository, + parsedResultStore, + blobStore, + }), + ) } -async function cleanupStagedBlob( +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function cleanupStagedBlobEffect( workspaceId: string, source: Source, repository: SourceLifecycleRepository, blobStore: SourceLifecycleBlobStore, -): Promise { - if (!source.stagedBlobPathname) return - - try { - await blobStore.deleteStagedSourceBlob(source.stagedBlobPathname) - await repository.clearSourceStagedBlob(workspaceId, source.id) - } catch { - // Staged upload cleanup is best-effort; source state is already advanced. - } +): Effect.Effect { + if (!source.stagedBlobPathname) return Effect.void + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + blobStore.deleteStagedSourceBlob(source.stagedBlobPathname!), + ) + yield* Effect.tryPromise(() => + repository.clearSourceStagedBlob(workspaceId, source.id), + ) + }).pipe( + Effect.catchAllCause(() => Effect.void), + ) } diff --git a/src/domains/sources/parsed-result-assets.ts b/src/domains/sources/parsed-result-assets.ts index 39ae608..6a36732 100644 --- a/src/domains/sources/parsed-result-assets.ts +++ b/src/domains/sources/parsed-result-assets.ts @@ -2,6 +2,7 @@ import "server-only" import path from "node:path" import { put } from "@vercel/blob" +import { Effect } from "effect" import type { JobResult } from "@ontos-ai/knowhere-sdk" export type StoredParsedResultAssets = { @@ -52,6 +53,73 @@ export type StoreParsedResultAssetsInput = { const parsedResultDirectoryName = "parsed-result" +// --------------------------------------------------------------------------- +// Effect core +// --------------------------------------------------------------------------- + +export const storeParsedResultAssetsEffect = Effect.fn( + "storeParsedResultAssets", +)( + function* ({ + workspaceId, + sourceId, + job, + client, + blobStore = vercelBlobStore, + }: StoreParsedResultAssetsInput) { + const parseResult = (yield* Effect.tryPromise(() => + client.jobs.load(job), + )) as ParsedResultWithAssets + const blobPrefix = getParsedResultBlobPrefix(workspaceId, sourceId) + const resultBlob = yield* Effect.tryPromise(() => + blobStore.put( + `${blobPrefix}/result.zip`, + parseResult.rawZip, + getBlobPutOptions("application/zip"), + ), + ) + + const assetUrlsByFilePath: Record = {} + + for (const image of parseResult.imageChunks ?? []) { + const filePath = normalizeParsedAssetPath(image.filePath) + if (!filePath || !image.data) continue + + const blob = yield* Effect.tryPromise(() => + blobStore.put( + `${blobPrefix}/${filePath}`, + image.data!, + getBlobPutOptions(getContentTypeForPath(filePath)), + ), + ) + assetUrlsByFilePath[filePath] = blob.url + } + + for (const table of parseResult.tableChunks ?? []) { + const filePath = normalizeParsedAssetPath(table.filePath) + if (!filePath || typeof table.html !== "string") continue + + const blob = yield* Effect.tryPromise(() => + blobStore.put( + `${blobPrefix}/${filePath}`, + table.html!, + getBlobPutOptions("text/html; charset=utf-8"), + ), + ) + assetUrlsByFilePath[filePath] = blob.url + } + + return { + resultBlobUrl: resultBlob.url, + assetUrlsByFilePath, + } + }, +) + +// --------------------------------------------------------------------------- +// Async wrapper (backward-compatible) +// --------------------------------------------------------------------------- + export async function storeParsedResultAssets({ workspaceId, sourceId, @@ -59,46 +127,21 @@ export async function storeParsedResultAssets({ client, blobStore = vercelBlobStore, }: StoreParsedResultAssetsInput): Promise { - const parseResult = (await client.jobs.load(job)) as ParsedResultWithAssets - const blobPrefix = getParsedResultBlobPrefix(workspaceId, sourceId) - const resultBlob = await blobStore.put( - `${blobPrefix}/result.zip`, - parseResult.rawZip, - getBlobPutOptions("application/zip"), + return Effect.runPromise( + storeParsedResultAssetsEffect({ + workspaceId, + sourceId, + job, + client, + blobStore, + }), ) - - const assetUrlsByFilePath: Record = {} - - for (const image of parseResult.imageChunks ?? []) { - const filePath = normalizeParsedAssetPath(image.filePath) - if (!filePath || !image.data) continue - - const blob = await blobStore.put( - `${blobPrefix}/${filePath}`, - image.data, - getBlobPutOptions(getContentTypeForPath(filePath)), - ) - assetUrlsByFilePath[filePath] = blob.url - } - - for (const table of parseResult.tableChunks ?? []) { - const filePath = normalizeParsedAssetPath(table.filePath) - if (!filePath || typeof table.html !== "string") continue - - const blob = await blobStore.put( - `${blobPrefix}/${filePath}`, - table.html, - getBlobPutOptions("text/html; charset=utf-8"), - ) - assetUrlsByFilePath[filePath] = blob.url - } - - return { - resultBlobUrl: resultBlob.url, - assetUrlsByFilePath, - } } +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + function getParsedResultBlobPrefix( workspaceId: string, sourceId: string, diff --git a/src/domains/sources/reconcile.test.ts b/src/domains/sources/reconcile.test.ts index 88803e1..808fd82 100644 --- a/src/domains/sources/reconcile.test.ts +++ b/src/domains/sources/reconcile.test.ts @@ -36,9 +36,9 @@ function makeSource(overrides: Partial): Source { async function loadReconcile({ listSourcesForWorkspace, - markSourceFailed = vi.fn(), - markSourceReady = vi.fn(), - saveSourceParseResult = vi.fn(), + markSourceFailed = vi.fn().mockResolvedValue(undefined), + markSourceReady = vi.fn().mockResolvedValue(undefined), + saveSourceParseResult = vi.fn().mockResolvedValue(undefined), storeParsedResultAssets = vi.fn().mockResolvedValue({ resultBlobUrl: "https://blob.example/result.zip", assetUrlsByFilePath: {}, @@ -247,6 +247,7 @@ describe("reconcileSourcesForWorkspace", () => { workspace.id, "source_1", "Parser rejected this document.", + "parsing", ) }) diff --git a/src/domains/sources/reconcile.ts b/src/domains/sources/reconcile.ts index 3be1954..e71ea9a 100644 --- a/src/domains/sources/reconcile.ts +++ b/src/domains/sources/reconcile.ts @@ -1,10 +1,11 @@ import "server-only" +import { Effect, pipe } from "effect" import { del } from "@vercel/blob" import type Knowhere from "@ontos-ai/knowhere-sdk" import type { JobResult } from "@ontos-ai/knowhere-sdk" -import type { Source, Workspace } from "@/infrastructure/db/schema" +import type { Source } from "@/infrastructure/db/schema" import { storeParsedResultAssets, type StoreParsedResultAssetsInput, @@ -29,34 +30,65 @@ type SourceReconcileDependencies = { ) => Promise } +// --------------------------------------------------------------------------- +// Effect core +// --------------------------------------------------------------------------- + +export const reconcileSourcesForWorkspaceEffect = Effect.fn( + "reconcileSourcesForWorkspace", +)( + function* ( + workspace: { readonly id: string }, + client: Knowhere, + deps: SourceReconcileDependencies = {}, + ) { + const rows = yield* Effect.tryPromise(() => + sourceWorkflowRuntime.listForWorkspace(workspace.id), + ) + const parsing = rows.filter( + (row) => row.status === "parsing" && row.knowhereJobId, + ) + if (parsing.length === 0) return rows + + yield* pipe( + parsing, + Effect.forEach( + (source) => + Effect.gen(function* () { + const jobId = source.knowhereJobId! + const job = yield* Effect.tryPromise(() => client.jobs.get(jobId)) + yield* Effect.tryPromise(() => + updateSourceFromJob(workspace.id, source, job, client, deps), + ) + }).pipe(Effect.catchAllCause(() => Effect.void)), + { concurrency: "unbounded" }, + ), + ) + + return yield* Effect.tryPromise(() => + sourceWorkflowRuntime.listForWorkspace(workspace.id), + ) + }, +) + +// --------------------------------------------------------------------------- +// Async wrapper (backward-compatible) +// --------------------------------------------------------------------------- + export async function reconcileSourcesForWorkspace( - workspace: Workspace, + workspace: { readonly id: string }, client: Knowhere, deps: SourceReconcileDependencies = {}, ): Promise { - const rows = await sourceWorkflowRuntime.listForWorkspace(workspace.id) - const parsing = rows.filter( - (row) => row.status === "parsing" && row.knowhereJobId, + return Effect.runPromise( + reconcileSourcesForWorkspaceEffect(workspace, client, deps), ) - if (parsing.length === 0) return rows - - await Promise.all( - parsing.map(async (source) => { - const jobId = source.knowhereJobId - if (!jobId) return - - try { - const job = await client.jobs.get(jobId) - await updateSourceFromJob(workspace.id, source, job, client, deps) - } catch { - // Leave the current row as-is on transient API errors. - } - }), - ) - - return await sourceWorkflowRuntime.listForWorkspace(workspace.id) } +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + async function updateSourceFromJob( workspaceId: string, source: Source, diff --git a/src/domains/sources/repository.ts b/src/domains/sources/repository.ts index 0be68a6..52eb1c9 100644 --- a/src/domains/sources/repository.ts +++ b/src/domains/sources/repository.ts @@ -6,11 +6,11 @@ import { sourceRowRepository } from "./source-row-repository" type SourceRepository = { readonly findInWorkspaceEffect: typeof sourceRowRepository.findInWorkspaceEffect - readonly findByDemoKeyEffect: typeof demoSourceRepository.findByDemoKeyEffect readonly listForWorkspaceEffect: typeof sourceRowRepository.listForWorkspaceEffect readonly createUploadingEffect: typeof sourceRowRepository.createUploadingEffect - readonly createDemoUploadingEffect: typeof demoSourceRepository.createDemoUploadingEffect - readonly markDemoUploadingEffect: typeof demoSourceRepository.markDemoUploadingEffect + 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 markFailedEffect: typeof sourceRowRepository.markFailedEffect @@ -18,16 +18,16 @@ type SourceRepository = { readonly softDeleteEffect: typeof sourceRowRepository.softDeleteEffect readonly saveParseResultEffect: typeof sourceParseResultRepository.saveParseResultEffect readonly getParseAssetUrlsEffect: typeof sourceParseResultRepository.getParseAssetUrlsEffect - readonly createDemoUploadRepository: typeof demoSourceRepository.createDemoUploadRepository } export const sourceRepository: SourceRepository = { findInWorkspaceEffect: sourceRowRepository.findInWorkspaceEffect, - findByDemoKeyEffect: demoSourceRepository.findByDemoKeyEffect, listForWorkspaceEffect: sourceRowRepository.listForWorkspaceEffect, createUploadingEffect: sourceRowRepository.createUploadingEffect, - createDemoUploadingEffect: demoSourceRepository.createDemoUploadingEffect, - markDemoUploadingEffect: demoSourceRepository.markDemoUploadingEffect, + listHiddenDemoSourceIdsEffect: demoSourceRepository.listHiddenDemoSourceIdsEffect, + hideDemoSourceEffect: demoSourceRepository.hideDemoSourceEffect, + upsertMaterializedDemoSourceEffect: + demoSourceRepository.upsertMaterializedDemoSourceEffect, markParsingEffect: sourceRowRepository.markParsingEffect, markReadyEffect: sourceRowRepository.markReadyEffect, markFailedEffect: sourceRowRepository.markFailedEffect, @@ -35,5 +35,4 @@ export const sourceRepository: SourceRepository = { softDeleteEffect: sourceRowRepository.softDeleteEffect, saveParseResultEffect: sourceParseResultRepository.saveParseResultEffect, getParseAssetUrlsEffect: sourceParseResultRepository.getParseAssetUrlsEffect, - createDemoUploadRepository: demoSourceRepository.createDemoUploadRepository, } diff --git a/src/domains/sources/route-archive.ts b/src/domains/sources/route-archive.ts index 2f9a956..2b570b6 100644 --- a/src/domains/sources/route-archive.ts +++ b/src/domains/sources/route-archive.ts @@ -1,3 +1,5 @@ +import { Effect } from "effect" + import { routeResult } from "@/lib/route-result" import { getClientForWorkspace } from "./route-dependencies" import type { @@ -10,7 +12,7 @@ import type { type RouteArchiveDependencies = Pick< SourceRouteServiceDependencies, | "deleteBlob" - | "demoData" + | "demoApi" | "ensureApiKeyForWorkspace" | "ensureWorkspace" | "makeKnowhereClient" @@ -26,48 +28,67 @@ type RouteArchive = { function createRouteArchive(deps: RouteArchiveDependencies): RouteArchive { return { - archiveSource: (input: ArchiveSourceInput) => archiveSource(input, deps), + archiveSource: (input: ArchiveSourceInput) => + Effect.runPromise(archiveSourceEffect(input, deps)), } } -async function archiveSource( +// --------------------------------------------------------------------------- +// Effect core +// --------------------------------------------------------------------------- + +const archiveSourceEffect = ( input: ArchiveSourceInput, deps: RouteArchiveDependencies, -): Promise> { - const user = await deps.requireUser() - const workspace = await deps.ensureWorkspace(user.id) - const source = await deps.sourceService.findInWorkspace( - workspace.id, - input.sourceId, - ) +) => + Effect.gen(function* () { + const user = yield* Effect.tryPromise(() => deps.requireUser()) + const workspace = yield* Effect.tryPromise(() => + deps.ensureWorkspace(user.id), + ) + const source = yield* Effect.tryPromise(() => + deps.sourceService.findInWorkspace(workspace.id, input.sourceId), + ) - if (!source) { - return routeResult.error(404, "Source not found.") - } + 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 }) + } - const isDemoSource = Boolean( - source.demoKey && deps.demoData.getSourceSeedByDemoKey(source.demoKey), - ) + return routeResult.error(404, "Source not found.") + } - if (!isDemoSource && source.knowhereDocumentId) { - const client = await getClientForWorkspace( - workspace.id, - input.cookieHeader, - deps, - ) - await client.documents.archive(source.knowhereDocumentId) - } + if (source.knowhereDocumentId) { + const client = yield* Effect.tryPromise(() => + getClientForWorkspace(workspace.id, input.cookieHeader, deps), + ) + yield* Effect.tryPromise(() => + client.documents.archive(source.knowhereDocumentId!), + ) + } - await deps.sourceService.softDelete(workspace.id, input.sourceId) - if (!isDemoSource && source.originalBlobPathname) { - try { - await deps.deleteBlob(source.originalBlobPathname) - } catch { - // Source archival already succeeded; Blob cleanup is best-effort. + 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!), + ).pipe(Effect.catchAllCause(() => Effect.void)) } - } - return routeResult.ok({ id: input.sourceId, archived: true }) -} + return routeResult.ok({ id: input.sourceId, archived: true as const }) + }) export { createRouteArchive } diff --git a/src/domains/sources/route-chunks.ts b/src/domains/sources/route-chunks.ts index 5723df7..f85ad34 100644 --- a/src/domains/sources/route-chunks.ts +++ b/src/domains/sources/route-chunks.ts @@ -1,9 +1,11 @@ import { Effect } from "effect" -import type { ChunkPage, ChunkPageParams } from "@/domains/chunks" -import type { ParsedChunkView } from "@/domains/chunks/types" +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 { getClientForWorkspace } from "./route-dependencies" +import { sourceRowRepository } from "./source-row-repository" import type { JsonRouteResult, LoadSourceChunksInput, @@ -13,7 +15,7 @@ import type { type RouteChunksDependencies = Pick< SourceRouteServiceDependencies, - | "demoData" + | "demoApi" | "ensureApiKeyForWorkspace" | "ensureWorkspace" | "getCurrentUser" @@ -32,92 +34,175 @@ type RouteChunks = { function createRouteChunks(deps: RouteChunksDependencies): RouteChunks { return { loadSourceChunks: (input: LoadSourceChunksInput) => - loadSourceChunks(input, deps), + Effect.runPromise(loadSourceChunksEffect(input, deps)), } } -async function loadSourceChunks( +// --------------------------------------------------------------------------- +// Effect core +// --------------------------------------------------------------------------- + +const loadSourceChunksEffect = ( input: LoadSourceChunksInput, deps: RouteChunksDependencies, -): Promise> { - const user = await deps.getCurrentUser() - if (!user) { - const chunks = await deps.demoData.loadChunksForSource(input.sourceId) - if (!chunks) return sourceNotFound() +) => + Effect.gen(function* () { + if (!sourceRowRepository.isWorkspaceSourceId(input.sourceId)) { + const demoResult = yield* loadDemoChunkPageEffect(input, deps) + return demoResult ?? sourceNotFound() + } + + const user = yield* Effect.tryPromise(() => deps.getCurrentUser()) + if (!user) { + const demoResult = yield* loadDemoChunkPageEffect(input, deps) + return demoResult ?? sourceNotFound() + } + + const workspace = yield* Effect.tryPromise(() => + deps.ensureWorkspace(user.id), + ) + const source = yield* Effect.tryPromise(() => + deps.sourceService.findInWorkspace(workspace.id, input.sourceId), + ) - return routeResult.ok( - input.shouldLoadAll - ? { chunks } - : toChunkPage(chunks, input.pageParams), + if (!source) { + const demoResult = yield* loadDemoChunkPageEffect(input, deps) + return demoResult ?? 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), + ) + const assetUrlsByFilePath = yield* Effect.tryPromise(() => + deps.sourceService.getParseAssetUrls(workspace.id, source.id), ) - } - const workspace = await deps.ensureWorkspace(user.id) - const source = await deps.sourceService.findInWorkspace( - workspace.id, - input.sourceId, - ) + if (input.shouldLoadAll) { + const chunks = yield* deps.loadChunksForSource(source, client, { + assetUrlsByFilePath, + }) + return routeResult.ok({ chunks }) + } + + const chunkPage = yield* deps.loadChunkPageForSource( + source, + client, + input.pageParams, + { assetUrlsByFilePath }, + ) + return routeResult.ok(chunkPage) + }) - if (!source) return sourceNotFound() +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), + ), + ) - const demoChunks = await deps.demoData.loadChunksForDocumentId( - source.knowhereDocumentId, - ) - if (demoChunks) { return routeResult.ok( input.shouldLoadAll - ? { chunks: demoChunks } - : toChunkPage(demoChunks, input.pageParams), + ? { chunks } + : { + chunks, + pagination: page.pagination, + }, ) - } - - const client = await getClientForWorkspace( - workspace.id, - input.cookieHeader, - deps, - ) - const assetUrlsByFilePath = await deps.sourceService.getParseAssetUrls( - workspace.id, - source.id, + }).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 + }), + ), ) - if (input.shouldLoadAll) { - const chunks = await Effect.runPromise( - deps.loadChunksForSource(source, client, { assetUrlsByFilePath }), +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 routeResult.ok({ chunks }) } + return pages +} - const chunkPage = await Effect.runPromise( - deps.loadChunkPageForSource(source, client, input.pageParams, { - assetUrlsByFilePath, - }), - ) - return routeResult.ok(chunkPage) +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.") } -function toChunkPage( - chunks: readonly ParsedChunkView[], - params: ChunkPageParams, -): ChunkPage { - const start = (params.page - 1) * params.pageSize - const pageChunks = chunks.slice(start, start + params.pageSize) - const totalPages = - chunks.length === 0 ? 0 : Math.ceil(chunks.length / params.pageSize) - - return { - chunks: pageChunks, - pagination: { - page: params.page, - pageSize: params.pageSize, - total: chunks.length, - totalPages, - }, - } -} - export { createRouteChunks } diff --git a/src/domains/sources/route-dependencies.ts b/src/domains/sources/route-dependencies.ts index 3b89ed7..0af5abb 100644 --- a/src/domains/sources/route-dependencies.ts +++ b/src/domains/sources/route-dependencies.ts @@ -8,11 +8,12 @@ import { } from "@/domains/chunks" 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" -import { demoData as defaultDemoData } from "./demo-data" import { reconcileSourcesForWorkspace as reconcileDefaultSourcesForWorkspace } from "./reconcile" +import { sourceWorkflowRuntime } from "./workflow-runtime" import { sourceService as defaultSourceService } from "./service" import type { SourceRouteKnowhereClient, @@ -22,7 +23,7 @@ import type { const defaultDependencies: SourceRouteServiceDependencies = { deleteBlob: del, - demoData: defaultDemoData, + demoApi: knowhereDemoApi, ensureApiKeyForWorkspace, ensureWorkspace: workspaceService.ensureWorkspace, getCurrentUser, @@ -35,6 +36,7 @@ const defaultDependencies: SourceRouteServiceDependencies = { loadChunksForSource, makeKnowhereClient: (apiKey: string) => makeDefaultKnowhereClient(apiKey) as SourceRouteKnowhereClient, + listSourcesForWorkspace: sourceWorkflowRuntime.listForWorkspace, reconcileSourcesForWorkspace: (workspace, client) => reconcileDefaultSourcesForWorkspace( workspace, @@ -44,7 +46,11 @@ const defaultDependencies: SourceRouteServiceDependencies = { sourceService: { findInWorkspace: defaultSourceService.findInWorkspace, getParseAssetUrls: defaultSourceService.getParseAssetUrls, + hideDemoSource: defaultSourceService.hideDemoSource, + listHiddenDemoSourceIds: defaultSourceService.listHiddenDemoSourceIds, softDelete: defaultSourceService.softDelete, + upsertMaterializedDemoSource: + defaultSourceService.upsertMaterializedDemoSource, uploadSourceBlobToKnowhere: defaultSourceService.uploadSourceBlobToKnowhere, uploadSourceToKnowhere: defaultSourceService.uploadSourceToKnowhere, }, @@ -56,9 +62,9 @@ function createSourceRouteDependencies( return { ...defaultDependencies, ...overrides, - demoData: { - ...defaultDependencies.demoData, - ...overrides.demoData, + demoApi: { + ...defaultDependencies.demoApi, + ...overrides.demoApi, }, sourceService: { ...defaultDependencies.sourceService, diff --git a/src/domains/sources/route-listing.ts b/src/domains/sources/route-listing.ts index 946ff12..b204969 100644 --- a/src/domains/sources/route-listing.ts +++ b/src/domains/sources/route-listing.ts @@ -1,8 +1,15 @@ 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 { knowhereDemoApi } from "@/integrations/knowhere-demo" import { toSourceView } from "./view" -import { getClientForWorkspace } from "./route-dependencies" +import { startBackgroundReconciliation } from "./background-reconcile" import type { JsonRouteResult, ListSourcesBody, @@ -16,12 +23,16 @@ type RouteListingDependencies = Pick< | "ensureWorkspace" | "getCurrentUser" | "getSourceViewOptionsBySourceId" + | "listSourcesForWorkspace" | "makeKnowhereClient" - | "reconcileSourcesForWorkspace" > & { - readonly demoData: Pick< - SourceRouteServiceDependencies["demoData"], - "listSources" + readonly demoApi: Pick< + SourceRouteServiceDependencies["demoApi"], + "fetchCatalog" + > + readonly sourceService: Pick< + SourceRouteServiceDependencies["sourceService"], + "listHiddenDemoSourceIds" > } @@ -33,35 +44,91 @@ type RouteListing = { function createRouteListing(deps: RouteListingDependencies): RouteListing { return { - listSources: (input: ListSourcesInput) => listSources(input, deps), + listSources: (input: ListSourcesInput) => + Effect.runPromise(listSourcesEffect(input, deps)), } } -async function listSources( +// --------------------------------------------------------------------------- +// Effect core +// --------------------------------------------------------------------------- + +const listSourcesEffect = ( input: ListSourcesInput, deps: RouteListingDependencies, -): Promise> { - const user = await deps.getCurrentUser() - if (!user) { - return routeResult.ok({ sources: deps.demoData.listSources() }) - } +) => + 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), + }) + } - const workspace = await deps.ensureWorkspace(user.id) - const client = await getClientForWorkspace( - workspace.id, - input.cookieHeader, - deps, - ) - const sources = await deps.reconcileSourcesForWorkspace(workspace, client) - const sourceOptions = await Effect.runPromise( - deps.getSourceViewOptionsBySourceId(sources, client), - ) + const catalog = yield* Effect.tryPromise(() => + knowhereDemoApi.fetchOptionalCatalog(deps.demoApi.fetchCatalog), + ) + const workspace = yield* Effect.tryPromise(() => + deps.ensureWorkspace(user.id), + ) + const sources = yield* Effect.tryPromise(() => + deps.listSourcesForWorkspace(workspace.id), + ) + const demoSourceResolution = resolveWorkspaceDemoSources(sources, catalog) + const sourcesNeedingKnowhereChunkCount = + getWorkspaceSourcesNeedingKnowhereChunkCount( + demoSourceResolution.workspaceSources, + ) + const materializedDemoSourceOptions = + getMaterializedDemoSourceViewOptionsBySourceId( + demoSourceResolution.workspaceSources, + catalog, + ) + const apiKey = yield* Effect.tryPromise(() => + deps.ensureApiKeyForWorkspace(workspace.id, input.cookieHeader), + ) + const client = deps.makeKnowhereClient(apiKey) + for (const source of sources) { + if (source.status === "parsing" && source.knowhereJobId) { + yield* Effect.fork( + Effect.tryPromise(() => + startBackgroundReconciliation(workspace.id, source.id, apiKey), + ), + ) + } + } + const sourceOptions = yield* deps.getSourceViewOptionsBySourceId( + 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: sources.map((source) => - toSourceView(source, sourceOptions.get(source.id)), - ), + return routeResult.ok({ + sources: [ + ...visibleDemoSources, + ...demoSourceResolution.workspaceSources.map((source) => + toSourceView( + source, + materializedDemoSourceOptions.get(source.id) ?? + sourceOptions.get(source.id), + ), + ), + ], + }) }) -} export { createRouteListing } diff --git a/src/domains/sources/route-service.test.ts b/src/domains/sources/route-service.test.ts index 2f5234b..650fce1 100644 --- a/src/domains/sources/route-service.test.ts +++ b/src/domains/sources/route-service.test.ts @@ -5,6 +5,7 @@ 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", @@ -57,10 +58,11 @@ describe("source route service", () => { const getSourceViewOptionsBySourceId = vi.fn(() => Effect.succeed(new Map([[source.id, { chunkCount: 8 }]])), ); - const reconcileSourcesForWorkspace = vi.fn(async () => [source]); + const listSourcesForWorkspace = vi.fn(async () => [source]); + const listHiddenDemoSourceIds = vi.fn(async () => []); const listing = createRouteListing({ - demoData: { - listSources: vi.fn(() => []), + demoApi: { + fetchCatalog: vi.fn(async () => emptyDemoCatalog), }, ensureApiKeyForWorkspace, ensureWorkspace: vi.fn(async () => workspace), @@ -71,7 +73,8 @@ describe("source route service", () => { })), getSourceViewOptionsBySourceId, makeKnowhereClient: vi.fn(() => knowhereClient), - reconcileSourcesForWorkspace, + listSourcesForWorkspace, + sourceService: { listHiddenDemoSourceIds }, }); const result = await listing.listSources({ cookieHeader: "session=abc" }); @@ -82,9 +85,11 @@ describe("source route service", () => { sources: [ { id: "source_1", + kind: "workspace", title: "notes.pdf", status: "parsing", mimeType: "application/pdf", + documentId: undefined, chunkCount: 8, }, ], @@ -94,25 +99,298 @@ describe("source route service", () => { workspace.id, "session=abc", ); - expect(reconcileSourcesForWorkspace).toHaveBeenCalledWith( - workspace, + expect(listSourcesForWorkspace).toHaveBeenCalledWith(workspace.id); + expect(listHiddenDemoSourceIds).toHaveBeenCalledWith(workspace.id); + }); + + 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(), + 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]), + sourceService: { listHiddenDemoSourceIds: vi.fn(async () => []) }, + }); + + 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("lists bundled demo sources for anonymous users", async () => { - const demoSource = { - id: "demo_source_1", - title: "Demo.pdf", - status: "ready" as const, - mimeType: "application/pdf", - documentId: "demo_doc_1", - chunkCount: 3, + 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(), + 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]), + sourceService: { listHiddenDemoSourceIds: vi.fn(async () => []) }, + }); + + 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: "/api/demo-sources/demo-tsla-q4-2025/original", + mimeType: "application/pdf", + sizeBytes: 1024, + canDownload: false, + }, + 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(), + 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]), + sourceService: { listHiddenDemoSourceIds: vi.fn(async () => []) }, + }); + + 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(), + 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]), + sourceService: { listHiddenDemoSourceIds: vi.fn(async () => []) }, + }); + + 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", + documentId: "doc_user_copy", + chunkCount: 70, + }), + ], + }, + }); + }); + + it("lists API-owned demo sources for anonymous users", async () => { const ensureWorkspace = vi.fn(async () => workspace); const service = createSourceRouteService({ - demoData: { - listSources: vi.fn(() => [demoSource]), + demoApi: { + fetchCatalog: vi.fn(async () => demoCatalog), }, ensureWorkspace, getCurrentUser: vi.fn(async () => null), @@ -122,7 +400,26 @@ describe("source route service", () => { expect(result).toEqual({ status: 200, - body: { sources: [demoSource] }, + 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: "/api/demo-sources/demo-tsla-q4-2025/original", + mimeType: "application/pdf", + sizeBytes: 1024, + canDownload: false, + }, + chunkCount: 70, + }, + ], + }, }); expect(ensureWorkspace).not.toHaveBeenCalled(); }); @@ -183,9 +480,11 @@ describe("source route service", () => { body: { source: { id: "source_1", + kind: "workspace", title: "notes.pdf", status: "parsing", mimeType: "application/pdf", + documentId: undefined, }, }, }); @@ -201,3 +500,28 @@ describe("source route service", () => { expect(onUploadFinished).toHaveBeenCalledOnce(); }); }); + +const emptyDemoCatalog: DemoCatalog = { + sources: [], +}; + +const demoCatalog: DemoCatalog = { + 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, + }, + examples: [], + }, + ], +}; diff --git a/src/domains/sources/route-types.ts b/src/domains/sources/route-types.ts index 96c8568..f6e9dad 100644 --- a/src/domains/sources/route-types.ts +++ b/src/domains/sources/route-types.ts @@ -9,10 +9,13 @@ import type { ParsedChunkView } from "@/domains/chunks/types" import type { 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" -import type { DemoSourceSeed } from "./demo-data" import type { UploadKnowhereClient } from "./upload" type SourceRouteKnowhereClient = UploadKnowhereClient & @@ -128,24 +131,36 @@ type SourceWorkflowService = { workspaceId: string, sourceId: string, ) => Promise>> + readonly hideDemoSource: ( + workspaceId: string, + demoSourceId: string, + ) => Promise + readonly listHiddenDemoSourceIds: (workspaceId: 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 + }, + ) => Promise } -type SourceRouteDemoData = { - readonly getSourceSeedByDemoKey: ( - demoKey: string | null | undefined, - ) => DemoSourceSeed | null - readonly listSources: () => readonly SourceView[] - readonly loadChunksForDocumentId: ( - documentId: string | null | undefined, - ) => Promise - readonly loadChunksForSource: ( - sourceId: string, - ) => 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 demoData: SourceRouteDemoData + readonly demoApi: SourceRouteDemoApi readonly ensureApiKeyForWorkspace: ( workspaceId: string, cookieHeader: string, @@ -159,6 +174,7 @@ type SourceRouteServiceDependencies = { readonly loadChunkPageForSource: typeof loadChunkPageForSource readonly loadChunksForSource: typeof loadChunksForSource readonly makeKnowhereClient: (apiKey: string) => SourceRouteKnowhereClient + readonly listSourcesForWorkspace: (workspaceId: string) => Promise readonly reconcileSourcesForWorkspace: ( workspace: Workspace, client: SourceRouteKnowhereClient, @@ -168,9 +184,9 @@ type SourceRouteServiceDependencies = { } type SourceRouteServiceOverrides = Partial< - Omit + Omit > & { - readonly demoData?: Partial + readonly demoApi?: Partial readonly sourceService?: Partial } @@ -182,7 +198,7 @@ export type { ListSourcesInput, LoadSourceChunksInput, SourceChunksBody, - SourceRouteDemoData, + SourceRouteDemoApi, SourceRouteKnowhereClient, SourceRouteService, SourceRouteServiceDependencies, diff --git a/src/domains/sources/route-upload.ts b/src/domains/sources/route-upload.ts index 6150f47..2f6f7f4 100644 --- a/src/domains/sources/route-upload.ts +++ b/src/domains/sources/route-upload.ts @@ -1,7 +1,9 @@ +import { Effect } from "effect" + import type { Source, Workspace } from "@/infrastructure/db/schema" import { routeResult } from "@/lib/route-result" +import { startBackgroundReconciliation } from "./background-reconcile" import { validateSourceBlobUploadInput } from "./blob-upload" -import { getClientForWorkspace } from "./route-dependencies" import type { JsonRouteResult, SourceRouteKnowhereClient, @@ -30,58 +32,81 @@ type RouteUpload = { function createRouteUpload(deps: RouteUploadDependencies): RouteUpload { return { - uploadSource: (input: UploadSourceInput) => uploadSource(input, deps), + uploadSource: (input: UploadSourceInput) => + Effect.runPromise(uploadSourceEffect(input, deps)), } } -async function uploadSource( +// --------------------------------------------------------------------------- +// Effect core +// --------------------------------------------------------------------------- + +const uploadSourceEffect = ( input: UploadSourceInput, deps: RouteUploadDependencies, -): Promise> { - const user = await deps.getCurrentUser() - if (!user) { - return routeResult.error(401, "Please log in to upload documents.") - } +) => + Effect.gen(function* () { + const user = yield* Effect.tryPromise(() => deps.getCurrentUser()) + if (!user) { + return routeResult.error(401, "Please log in to upload documents.") + } - if (input.upload.type === "error") { - return routeResult.badRequest(input.upload.message) - } + if (input.upload.type === "error") { + return routeResult.badRequest(input.upload.message) + } - const validation = - input.upload.type === "file" - ? validateUploadFile(input.upload.file) - : validateSourceBlobUploadInput(input.upload.input) - if (!validation.ok) { - return routeResult.badRequest(validation.message) - } + const validation = + input.upload.type === "file" + ? validateUploadFile(input.upload.file) + : validateSourceBlobUploadInput(input.upload.input) + if (!validation.ok) { + return routeResult.badRequest(validation.message) + } - const workspace = await deps.ensureWorkspace(user.id) - const client = await getClientForWorkspace( - workspace.id, - input.cookieHeader, - deps, - ) - const source = await uploadToKnowhere(workspace, input.upload, client, deps) - .finally(() => { - input.onUploadFinished?.() - }) + const workspace = yield* Effect.tryPromise(() => + deps.ensureWorkspace(user.id), + ) + const apiKey = yield* Effect.tryPromise(() => + deps.ensureApiKeyForWorkspace(workspace.id, input.cookieHeader), + ) + const client = deps.makeKnowhereClient(apiKey) - return routeResult.ok({ source: toSourceView(source) }, 201) -} + const source = yield* uploadToKnowhereEffect( + workspace, + input.upload, + client, + deps, + ).pipe( + Effect.onExit(() => + Effect.sync(() => { + input.onUploadFinished?.() + }), + ), + ) + + yield* Effect.tryPromise(() => + startBackgroundReconciliation(workspace.id, source.id, apiKey), + ) + + return routeResult.ok({ source: toSourceView(source) }, 201) + }) -async function uploadToKnowhere( +const uploadToKnowhereEffect = ( workspace: Workspace, upload: Exclude, client: SourceRouteKnowhereClient, deps: RouteUploadDependencies, -): Promise { - return upload.type === "file" - ? deps.sourceService.uploadSourceToKnowhere(workspace, upload.file, client) - : deps.sourceService.uploadSourceBlobToKnowhere( - workspace, - upload.input, - client, +) => + upload.type === "file" + ? Effect.tryPromise(() => + deps.sourceService.uploadSourceToKnowhere(workspace, upload.file, client), + ) + : Effect.tryPromise(() => + deps.sourceService.uploadSourceBlobToKnowhere( + workspace, + upload.input, + client, + ), ) -} export { createRouteUpload } diff --git a/src/domains/sources/service.ts b/src/domains/sources/service.ts index dcbe86d..a93f103 100644 --- a/src/domains/sources/service.ts +++ b/src/domains/sources/service.ts @@ -21,10 +21,21 @@ type SourceService = { sourceId: string, ) => Promise>> readonly listForWorkspace: (workspaceId: 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, @@ -65,8 +76,12 @@ const uploadSourceBlobToKnowhere: SourceService["uploadSourceBlobToKnowhere"] = export const sourceService: SourceService = { findInWorkspace: sourceWorkflowRuntime.findInWorkspace, getParseAssetUrls: sourceWorkflowRuntime.getParseAssetUrls, + hideDemoSource: sourceWorkflowRuntime.hideDemoSource, + listHiddenDemoSourceIds: sourceWorkflowRuntime.listHiddenDemoSourceIds, listForWorkspace: sourceWorkflowRuntime.listForWorkspace, softDelete: sourceWorkflowRuntime.softDelete, + upsertMaterializedDemoSource: + sourceWorkflowRuntime.upsertMaterializedDemoSource, uploadSourceToKnowhere, uploadSourceBlobToKnowhere, } diff --git a/src/domains/sources/source-row-repository.test.ts b/src/domains/sources/source-row-repository.test.ts new file mode 100644 index 0000000..aaa2cce --- /dev/null +++ b/src/domains/sources/source-row-repository.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest" +import { Effect } from "effect" + +import { sourceRowRepository } from "./source-row-repository" +import { DbClient, type Db } from "@/infrastructure/db" + +describe("sourceRowRepository", () => { + it("classifies canonical demo ids as non-workspace source ids", () => { + expect(sourceRowRepository.isWorkspaceSourceId("demo-tsla-q4-2025")).toBe( + false, + ) + }) + + it("classifies UUIDs as workspace source ids", () => { + expect( + sourceRowRepository.isWorkspaceSourceId( + "f03b2dd5-cbc6-44a1-a5cb-8106f8ce52bb", + ), + ).toBe(true) + }) + + it("does not update the database for canonical demo ids", async () => { + const db = makeThrowingDb() + + await expect( + sourceRowRepository.updateInWorkspaceWithDb( + db, + "workspace_1", + "demo-tsla-q4-2025", + { status: "ready" }, + ), + ).resolves.toBeNull() + }) + + it("does not soft-delete the database for canonical demo ids", async () => { + const db = makeThrowingDb() + + await expect( + Effect.runPromise( + sourceRowRepository + .softDeleteEffect("workspace_1", "demo-tsla-q4-2025") + .pipe(Effect.provideService(DbClient, db)), + ), + ).resolves.toBe(false) + }) +}) + +function makeThrowingDb(): Db { + return { + update: () => { + throw new Error("database should not be called for demo source ids") + }, + } as unknown as Db +} diff --git a/src/domains/sources/source-row-repository.ts b/src/domains/sources/source-row-repository.ts index 1ca5178..71b054a 100644 --- a/src/domains/sources/source-row-repository.ts +++ b/src/domains/sources/source-row-repository.ts @@ -59,6 +59,7 @@ type SourceRowRepository = { workspaceId: string, sourceId: string, reason: string, + requiredStatus?: string, ) => Effect.Effect readonly clearStagedBlobEffect: ( workspaceId: string, @@ -68,6 +69,7 @@ type SourceRowRepository = { workspaceId: string, sourceId: string, ) => Effect.Effect + readonly isWorkspaceSourceId: (sourceId: string) => boolean readonly findInWorkspaceWithDb: ( db: Db, workspaceId: string, @@ -78,6 +80,7 @@ type SourceRowRepository = { workspaceId: string, sourceId: string, values: SourceUpdate, + requiredStatus?: string, ) => Promise readonly requireSource: (source: Source | null, message: string) => Source } @@ -161,17 +164,18 @@ const markReadyEffect: SourceRowRepository["markReadyEffect"] = ( status: "ready", knowhereDocumentId: documentId, failureReason: null, - }) + }, "parsing") const markFailedEffect: SourceRowRepository["markFailedEffect"] = ( workspaceId: string, sourceId: string, reason: string, + requiredStatus?: string, ) => updateInWorkspaceEffect(workspaceId, sourceId, { status: "failed", failureReason: reason, - }) + }, requiredStatus) const clearStagedBlobEffect: SourceRowRepository["clearStagedBlobEffect"] = ( workspaceId: string, @@ -187,6 +191,8 @@ const softDeleteEffect: SourceRowRepository["softDeleteEffect"] = ( sourceId: string, ) => Effect.gen(function* () { + if (!isWorkspaceSourceId(sourceId)) return false + const db = yield* DbClient const result = yield* Effect.promise(() => db @@ -209,11 +215,12 @@ const updateInWorkspaceEffect = ( workspaceId: string, sourceId: string, values: SourceUpdate, + requiredStatus?: string, ) => Effect.gen(function* () { const db = yield* DbClient return yield* Effect.promise(() => - updateInWorkspaceWithDb(db, workspaceId, sourceId, values), + updateInWorkspaceWithDb(db, workspaceId, sourceId, values, requiredStatus), ) }) @@ -222,6 +229,8 @@ async function findInWorkspaceWithDb( workspaceId: string, sourceId: string, ): Promise { + if (!isWorkspaceSourceId(sourceId)) return null + const row = await db .select() .from(sources) @@ -242,22 +251,38 @@ async function updateInWorkspaceWithDb( workspaceId: string, sourceId: string, values: SourceUpdate, + requiredStatus?: string, ): Promise { + if (!isWorkspaceSourceId(sourceId)) return null + + // Layer 3 — Atomic status guard. + // When requiredStatus is set, the UPDATE only matches if the source is still in + // the expected status. Two concurrent workflows will race; only one wins. + const conditions = [ + eq(sources.id, sourceId), + eq(sources.workspaceId, workspaceId), + isNull(sources.deletedAt), + ] + if (requiredStatus) { + conditions.push(eq(sources.status, requiredStatus)) + } + const [source] = await db .update(sources) .set({ ...values, updatedAt: sql`now()` }) - .where( - and( - eq(sources.id, sourceId), - eq(sources.workspaceId, workspaceId), - isNull(sources.deletedAt), - ), - ) + .where(and(...conditions)) .returning() return source ?? null } +const WORKSPACE_SOURCE_ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu + +function isWorkspaceSourceId(sourceId: string): boolean { + return WORKSPACE_SOURCE_ID_PATTERN.test(sourceId) +} + function requireSource(source: Source | null, message: string): Source { if (!source) throw new Error(message) return source @@ -272,6 +297,7 @@ export const sourceRowRepository: SourceRowRepository = { markFailedEffect, clearStagedBlobEffect, softDeleteEffect, + isWorkspaceSourceId, findInWorkspaceWithDb, updateInWorkspaceWithDb, requireSource, diff --git a/src/domains/sources/source-upload-contracts.ts b/src/domains/sources/source-upload-contracts.ts index 24ff865..1684927 100644 --- a/src/domains/sources/source-upload-contracts.ts +++ b/src/domains/sources/source-upload-contracts.ts @@ -51,48 +51,3 @@ export type UploadSourceDependencies = { repository: UploadSourceRepository knowhere: UploadKnowhereClient } - -export type DemoSourceUploadInput = { - demoKey: string - documentId: string - title: string - mimeType: string - originalSizeBytes: number - originalFileUrl: string - originalFileSystemPath: string -} - -export type DemoSourceUploadRepository = Pick< - UploadSourceRepository, - "markSourceParsing" | "markSourceFailed" -> & { - findSourceByDemoKey( - workspaceId: string, - demoKey: string, - ): Promise - createDemoUploadingSource( - workspaceId: string, - input: { - demoKey: string - title: string - mimeType: string - sizeBytes: number - originalBlobUrl: string - }, - ): Promise - markDemoSourceUploading( - workspaceId: string, - sourceId: string, - input: { - title: string - mimeType: string - sizeBytes: number - originalBlobUrl: string - }, - ): Promise -} - -export type DemoSourceUploadDependencies = { - repository: DemoSourceUploadRepository - knowhere: UploadKnowhereClient -} diff --git a/src/domains/sources/types.ts b/src/domains/sources/types.ts index 8d73b8c..c5643c1 100644 --- a/src/domains/sources/types.ts +++ b/src/domains/sources/types.ts @@ -7,11 +7,15 @@ export type SourceOriginalFileView = { readonly canDownload?: boolean } +export type SourceKind = "workspace" | "demo" + /** * Sources sidebar row. Metadata-only, per the MVP persistence rule. */ export type SourceView = { readonly id: string + readonly kind?: SourceKind + readonly demoSourceId?: string readonly title: string /** Browser-provided content type for preview routing. */ readonly mimeType: string diff --git a/src/domains/sources/upload.test.ts b/src/domains/sources/upload.test.ts index 3992a08..443dc42 100644 --- a/src/domains/sources/upload.test.ts +++ b/src/domains/sources/upload.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { - ensureDemoSourceUpload, uploadSourceBlobToKnowhere, uploadSourceToKnowhere, } from "./upload"; @@ -270,170 +269,3 @@ describe("uploadSourceToKnowhere", () => { ); }); }); - -describe("ensureDemoSourceUpload", () => { - const demoInput = { - demoKey: "demo-tsla-q4-2025", - documentId: "demo-doc-tsla-q4-2025", - title: "TSLA-Q4-2025-Update(1).pdf", - mimeType: "application/pdf", - originalSizeBytes: 5, - originalFileUrl: "/demo-sources/tsla-q4-2025/original.pdf", - originalFileSystemPath: "/repo/public/demo-sources/tsla-q4-2025/original.pdf", - } as const; - - it("uploads a bundled demo file into the workspace namespace", async () => { - const uploadingSource = makeSource({ - demoKey: demoInput.demoKey, - title: demoInput.title, - sizeBytes: demoInput.originalSizeBytes, - originalBlobUrl: demoInput.originalFileUrl, - }); - const parsingSource = makeSource({ - ...uploadingSource, - status: "parsing", - knowhereJobId: "job_demo", - }); - const deps = { - repository: { - findSourceByDemoKey: vi.fn().mockResolvedValue(null), - createDemoUploadingSource: vi.fn().mockResolvedValue(uploadingSource), - markDemoSourceUploading: vi.fn(), - markSourceParsing: vi.fn().mockResolvedValue(parsingSource), - markSourceFailed: vi.fn(), - }, - knowhere: { - jobs: { - create: vi.fn().mockResolvedValue({ - jobId: "job_demo", - status: "waiting-file", - sourceType: "file", - createdAt: new Date("2026-05-06T00:00:00Z"), - }), - upload: vi.fn().mockResolvedValue(undefined), - }, - }, - }; - - const result = await ensureDemoSourceUpload(workspace, demoInput, deps); - - expect(deps.repository.findSourceByDemoKey).toHaveBeenCalledWith( - workspace.id, - demoInput.demoKey, - ); - expect(deps.repository.createDemoUploadingSource).toHaveBeenCalledWith( - workspace.id, - { - demoKey: demoInput.demoKey, - title: demoInput.title, - mimeType: demoInput.mimeType, - sizeBytes: demoInput.originalSizeBytes, - originalBlobUrl: demoInput.originalFileUrl, - }, - ); - expect(deps.knowhere.jobs.create).toHaveBeenCalledWith({ - sourceType: "file", - fileName: demoInput.title, - namespace: workspace.namespace, - }); - expect(deps.knowhere.jobs.upload).toHaveBeenCalledWith( - expect.objectContaining({ jobId: "job_demo" }), - { file: demoInput.originalFileSystemPath }, - ); - expect(deps.repository.markSourceParsing).toHaveBeenCalledWith( - workspace.id, - uploadingSource.id, - "job_demo", - ); - expect(result).toBe(parsingSource); - }); - - it("does not upload the bundled demo file again when the workspace already has it", async () => { - const existingSource = makeSource({ - demoKey: demoInput.demoKey, - status: "parsing", - knowhereJobId: "job_existing", - }); - const deps = { - repository: { - findSourceByDemoKey: vi.fn().mockResolvedValue(existingSource), - createDemoUploadingSource: vi.fn(), - markDemoSourceUploading: vi.fn(), - markSourceParsing: vi.fn(), - markSourceFailed: vi.fn(), - }, - knowhere: { - jobs: { - create: vi.fn(), - upload: vi.fn(), - }, - }, - }; - - const result = await ensureDemoSourceUpload(workspace, demoInput, deps); - - expect(result).toBe(existingSource); - expect(deps.repository.createDemoUploadingSource).not.toHaveBeenCalled(); - expect(deps.knowhere.jobs.create).not.toHaveBeenCalled(); - expect(deps.knowhere.jobs.upload).not.toHaveBeenCalled(); - }); - - it("uploads once for legacy static demo rows that were never sent to Knowhere", async () => { - const legacySource = makeSource({ - demoKey: demoInput.demoKey, - status: "ready", - knowhereDocumentId: demoInput.documentId, - knowhereJobId: null, - }); - const uploadingSource = makeSource({ - ...legacySource, - status: "uploading", - knowhereDocumentId: null, - }); - const parsingSource = makeSource({ - ...uploadingSource, - status: "parsing", - knowhereJobId: "job_demo", - }); - const deps = { - repository: { - findSourceByDemoKey: vi.fn().mockResolvedValue(legacySource), - createDemoUploadingSource: vi.fn(), - markDemoSourceUploading: vi.fn().mockResolvedValue(uploadingSource), - markSourceParsing: vi.fn().mockResolvedValue(parsingSource), - markSourceFailed: vi.fn(), - }, - knowhere: { - jobs: { - create: vi.fn().mockResolvedValue({ - jobId: "job_demo", - status: "waiting-file", - sourceType: "file", - createdAt: new Date("2026-05-06T00:00:00Z"), - }), - upload: vi.fn().mockResolvedValue(undefined), - }, - }, - }; - - const result = await ensureDemoSourceUpload(workspace, demoInput, deps); - - expect(deps.repository.createDemoUploadingSource).not.toHaveBeenCalled(); - expect(deps.repository.markDemoSourceUploading).toHaveBeenCalledWith( - workspace.id, - legacySource.id, - { - title: demoInput.title, - mimeType: demoInput.mimeType, - sizeBytes: demoInput.originalSizeBytes, - originalBlobUrl: demoInput.originalFileUrl, - }, - ); - expect(deps.knowhere.jobs.create).toHaveBeenCalledWith({ - sourceType: "file", - fileName: demoInput.title, - namespace: workspace.namespace, - }); - expect(result).toBe(parsingSource); - }); -}); diff --git a/src/domains/sources/upload.ts b/src/domains/sources/upload.ts index 010ac28..99313f6 100644 --- a/src/domains/sources/upload.ts +++ b/src/domains/sources/upload.ts @@ -6,14 +6,7 @@ export { uploadSourceToKnowhere, uploadSourceToKnowhereEffect, } from "./knowhere-upload" -export { - ensureDemoSourceUpload, - ensureDemoSourceUploadEffect, -} from "./demo-upload" export type { - DemoSourceUploadDependencies, - DemoSourceUploadInput, - DemoSourceUploadRepository, UploadKnowhereClient, UploadSourceDependencies, UploadSourceRepository, diff --git a/src/domains/sources/view.test.ts b/src/domains/sources/view.test.ts index e15a47f..93698dc 100644 --- a/src/domains/sources/view.test.ts +++ b/src/domains/sources/view.test.ts @@ -39,6 +39,7 @@ describe("toSourceView", () => { ), ).toEqual({ id: "source_1", + kind: "workspace", title: "notes.pdf", mimeType: "application/pdf", status: "ready", @@ -63,6 +64,7 @@ describe("toSourceView", () => { ), ).toEqual({ id: "source_1", + kind: "workspace", title: "notes.pdf", mimeType: "application/pdf", status: "parsing", @@ -75,20 +77,20 @@ describe("toSourceView", () => { toSourceView( makeSource({ demoKey: "demo-tsla-q4-2025", - title: "TSLA-Q4-2025-Update(1).pdf", + title: "TSLA-Q4-2025-Update.pdf", mimeType: "application/pdf", sizeBytes: 5648867, - knowhereDocumentId: "demo-doc-tsla-q4-2025", - originalBlobUrl: "/demo-sources/tsla-q4-2025/original.pdf", + knowhereDocumentId: "doc_user_copy", + originalBlobUrl: "/api/demo-sources/demo-tsla-q4-2025/original", }), { chunkCount: 70 }, ), ).toMatchObject({ - title: "TSLA-Q4-2025-Update(1).pdf", - documentId: "demo-doc-tsla-q4-2025", + title: "TSLA-Q4-2025-Update.pdf", + documentId: "doc_user_copy", chunkCount: 70, originalFile: { - url: "/demo-sources/tsla-q4-2025/original.pdf", + url: "/api/demo-sources/demo-tsla-q4-2025/original", canDownload: false, }, }); diff --git a/src/domains/sources/view.ts b/src/domains/sources/view.ts index c010b7a..18f1b5a 100644 --- a/src/domains/sources/view.ts +++ b/src/domains/sources/view.ts @@ -25,6 +25,7 @@ export function toSourceView( return { id: source.id, + kind: "workspace", title: source.title, mimeType: source.mimeType, status: toSourceStatus(source.status), diff --git a/src/domains/sources/workflow-runtime.ts b/src/domains/sources/workflow-runtime.ts index 97f7ee5..fff2202 100644 --- a/src/domains/sources/workflow-runtime.ts +++ b/src/domains/sources/workflow-runtime.ts @@ -13,6 +13,10 @@ type SaveSourceParseResultInput = Parameters< typeof sourceRepository.saveParseResultEffect >[2] +type UpsertMaterializedDemoSourceInput = Parameters< + typeof sourceRepository.upsertMaterializedDemoSourceEffect +>[1] + type UploadRepositoryRuntime = { readonly createUploading: ( workspaceId: string, @@ -27,6 +31,7 @@ type UploadRepositoryRuntime = { workspaceId: string, sourceId: string, reason: string, + requiredStatus?: string, ) => Promise } @@ -47,6 +52,11 @@ type SourceWorkflowRuntime = UploadRepositoryRuntime & { sourceId: string, ) => Promise>> readonly listForWorkspace: (workspaceId: string) => Promise + readonly listHiddenDemoSourceIds: (workspaceId: string) => Promise + readonly hideDemoSource: ( + workspaceId: string, + demoSourceId: string, + ) => Promise readonly markReady: ( workspaceId: string, sourceId: string, @@ -61,6 +71,10 @@ type SourceWorkflowRuntime = UploadRepositoryRuntime & { workspaceId: string, sourceId: string, ) => Promise + readonly upsertMaterializedDemoSource: ( + workspaceId: string, + input: UpsertMaterializedDemoSourceInput, + ) => Promise } const findInWorkspace: SourceWorkflowRuntime["findInWorkspace"] = ( @@ -76,6 +90,20 @@ const listForWorkspace: SourceWorkflowRuntime["listForWorkspace"] = ( ) => databaseRuntime.runPromise(sourceRepository.listForWorkspaceEffect(workspaceId)) +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, @@ -106,9 +134,10 @@ const markFailed: SourceWorkflowRuntime["markFailed"] = ( workspaceId: string, sourceId: string, reason: string, + requiredStatus?: string, ) => databaseRuntime.runPromise( - sourceRepository.markFailedEffect(workspaceId, sourceId, reason), + sourceRepository.markFailedEffect(workspaceId, sourceId, reason, requiredStatus), ) const clearStagedBlob: SourceWorkflowRuntime["clearStagedBlob"] = ( @@ -127,6 +156,12 @@ 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, @@ -181,10 +216,13 @@ export const sourceWorkflowRuntime: SourceWorkflowRuntime = { createUploading, findInWorkspace, getParseAssetUrls, + hideDemoSource, listForWorkspace, + listHiddenDemoSourceIds, markFailed, markParsing, markReady, saveParseResult, softDelete, + upsertMaterializedDemoSource, } diff --git a/src/domains/workspace/client.test.ts b/src/domains/workspace/client.test.ts index cfc3a6e..6b83cb9 100644 --- a/src/domains/workspace/client.test.ts +++ b/src/domains/workspace/client.test.ts @@ -1,41 +1,49 @@ import { beforeEach, describe, expect, it, vi } from "vitest" +const { mockRouteClient } = vi.hoisted(() => ({ + mockRouteClient: { + getJson: vi.fn(), + postJsonWithStatus: vi.fn(), + postJson: vi.fn(), + patchJson: vi.fn(), + deleteJson: vi.fn(), + }, +})) + +vi.mock("./route-client", () => ({ + workspaceRouteClient: mockRouteClient, +})) + import { workspaceClient } from "./client" describe("workspaceClient", () => { beforeEach(() => { - vi.unstubAllGlobals() + vi.clearAllMocks() }) it("fetches a normalized chunk page with an encoded source id", async () => { - const fetch = vi.fn(async (input) => { - const requestUrl = new URL(String(input), "http://localhost") - - expect(requestUrl.pathname).toBe("/api/sources/source%20one/chunks") - expect(requestUrl.searchParams.get("page")).toBe("2") - expect(requestUrl.searchParams.get("pageSize")).toBe("100") - - return Response.json({ - chunks: [ - { - chunkId: "chunk_1", - type: "text", - content: "Chunk body", - sourceTitle: "source one", - }, - ], - pagination: { - page: 2, - pageSize: 100, - total: 3, - totalPages: 3, + mockRouteClient.getJson.mockResolvedValue({ + chunks: [ + { + chunkId: "chunk_1", + type: "text", + content: "Chunk body", + sourceTitle: "source one", }, - }) + ], + pagination: { + page: 2, + pageSize: 50, + total: 3, + totalPages: 3, + }, }) - vi.stubGlobal("fetch", fetch) const page = await workspaceClient.fetchChunkPage("source one", 2) + expect(mockRouteClient.getJson).toHaveBeenCalledWith( + "/api/sources/source%20one/chunks?page=2&pageSize=50", + ) expect(page).toEqual({ chunks: [ { @@ -47,11 +55,23 @@ describe("workspaceClient", () => { ], pagination: { page: 2, - pageSize: 100, + pageSize: 50, total: 3, totalPages: 3, }, }) - expect(fetch).toHaveBeenCalledOnce() + }) + + 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.") }) }) diff --git a/src/domains/workspace/client.ts b/src/domains/workspace/client.ts index 4bce851..1cea55a 100644 --- a/src/domains/workspace/client.ts +++ b/src/domains/workspace/client.ts @@ -10,12 +10,13 @@ const workspaceClientKeys = { sources: "/api/sources", chatThreads: "/api/chat/threads", chat: "/api/chat", + materializeDemoSources: "/api/demo-sources/materialize", archiveSource: "archive-source", archiveChatThread: "archive-chat-thread", } as const const workspaceClientConfig = { - sourceChunkPageSize: 100, + sourceChunkPageSize: 50, } as const type SourceChunksResponse = { @@ -44,6 +45,10 @@ type ChatMessageRequest = { excludedSourceIds: string[] } +type MaterializeDemoSourcesRequest = { + demoSourceIds: string[] +} + type SourcesResponse = { sources?: SourceView[] } @@ -72,6 +77,7 @@ export const workspaceClient = { fetchChatThread, createChatThread, sendChatMessage, + materializeDemoSources, archiveSource, archiveChatThread, } as const @@ -146,6 +152,24 @@ 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)}`, diff --git a/src/domains/workspace/demo-migration.test.ts b/src/domains/workspace/demo-migration.test.ts new file mode 100644 index 0000000..26bb6c9 --- /dev/null +++ b/src/domains/workspace/demo-migration.test.ts @@ -0,0 +1,32 @@ +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 171e4ff..322f40c 100644 --- a/src/domains/workspace/initial-state.test.ts +++ b/src/domains/workspace/initial-state.test.ts @@ -1,91 +1,334 @@ -import { Effect } from "effect"; -import { describe, expect, it, vi } from "vitest"; +import { Effect } from "effect" +import { afterEach, describe, expect, it, vi } from "vitest" -import { loadWorkspaceShellInitialState } from "./initial-state"; -import type { AuthUser } from "@/infrastructure/auth"; -import type { ChatThread, Source, Workspace } from "@/infrastructure/db/schema"; +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" type InitialStateDependencies = NonNullable< Parameters[0] ->; +> type InitialStateClient = Awaited< ReturnType ->["client"]; +>["client"] + +const originalDashboardOrigin = process.env.DASHBOARD_ORIGIN describe("loadWorkspaceShellInitialState", () => { - it("returns static guest state without touching workspace persistence", async () => { + afterEach(() => { + if (originalDashboardOrigin === undefined) { + delete process.env.DASHBOARD_ORIGIN + return + } + + process.env.DASHBOARD_ORIGIN = originalDashboardOrigin + }) + + it("returns guest demo state from the Knowhere demo API only", async () => { const deps = createDependencies({ getOptionalAuthenticated: vi.fn(async () => null), - }); + }) - const state = await loadWorkspaceShellInitialState(deps); + const state = await loadWorkspaceShellInitialState(deps) - expect(state.isGuest).toBe(true); + expect(state.isGuest).toBe(true) expect(state.sources).toEqual([ { - id: "demo_source_1", - title: "Demo.pdf", + 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: "/api/demo-sources/demo-tsla-q4-2025/original", + mimeType: "application/pdf", + sizeBytes: 1024, + canDownload: false, + }, + chunkCount: 70, }, - ]); + ]) expect(state.chatMessages).toEqual([ { - id: "demo_message_1", + id: "demo-example-1-user", + role: "user", + content: "What happened in Tesla Q4?", + }, + { + id: "demo-example-1-assistant", role: "assistant", - content: "Demo answer", + 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(deps.ensureDemoWorkspaceContent).not.toHaveBeenCalled(); - }); - - it("seeds demo workspace content before loading authenticated shell data", async () => { - const workspace = makeWorkspace(); - const source = makeSource(workspace.id); - const thread = makeThread(workspace.id); - const callOrder: string[] = []; + ]) + expect(state.loginUrl).toBe("/login") + expect(deps.listSourcesForWorkspace).not.toHaveBeenCalled() + }) + + it("exposes the configured Dashboard origin to the shell", async () => { + process.env.DASHBOARD_ORIGIN = "https://dashboard.staging.example" + + const state = await loadWorkspaceShellInitialState(createDependencies()) + + 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({ - ensureDemoWorkspaceContent: vi.fn(async () => { - callOrder.push("seed"); - }), - listChatThreads: vi.fn(async () => { - callOrder.push("threads"); - return [thread]; - }), - reconcileSourcesForWorkspace: vi.fn(async () => { - callOrder.push("sources"); - return [source]; - }), + 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); + const state = await loadWorkspaceShellInitialState(deps) - expect(callOrder[0]).toBe("seed"); - expect(state.isGuest).toBeUndefined(); - expect(state.activeChatThreadId).toBe(thread.id); + 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("reconciles source state during authenticated shell load", async () => { - const workspace = makeWorkspace(); + 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, { status: "ready", knowhereDocumentId: "document_1", - }); - const reconcileSourcesForWorkspace = vi.fn(async () => [readySource]); + }) + const listSourcesForWorkspace = vi.fn(async () => [readySource]) const deps = { ...createDependencies({ getOptionalAuthenticated: vi.fn(async () => ({ @@ -97,64 +340,97 @@ describe("loadWorkspaceShellInitialState", () => { workspace, })), }), - reconcileSourcesForWorkspace, - } satisfies InitialStateDependencies; + listSourcesForWorkspace, + } satisfies InitialStateDependencies - const state = await loadWorkspaceShellInitialState(deps); + const state = await loadWorkspaceShellInitialState(deps) - expect(reconcileSourcesForWorkspace).toHaveBeenCalledWith( - workspace, - expect.any(Object), - ); + expect(listSourcesForWorkspace).toHaveBeenCalledWith(workspace.id) expect(state.sources).toEqual([ + expect.objectContaining({ + id: "demo-tsla-q4-2025", + kind: "demo", + }), { id: readySource.id, + kind: "workspace", title: "notes.pdf", mimeType: "application/pdf", status: "ready", documentId: "document_1", }, - ]); - }); -}); + ]) + }) +}) function createDependencies( overrides: Partial = {}, ): InitialStateDependencies { - const workspace = makeWorkspace(); + const workspace = makeWorkspace() const user: AuthUser = { id: "user_1", email: "ada@example.com", name: "Ada", - }; - const client = {} as InitialStateClient; + } + const client = {} as InitialStateClient return { - demoChatMessages: [ - { - id: "demo_message_1", - role: "assistant", - content: "Demo answer", - }, - ], - demoSources: [ - { - id: "demo_source_1", - title: "Demo.pdf", - mimeType: "application/pdf", - status: "ready", - }, - ], - ensureDemoWorkspaceContent: vi.fn(async () => undefined), - getClientForWorkspace: vi.fn(async () => ({ client })), + 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 () => []), - reconcileSourcesForWorkspace: vi.fn(async () => []), + listSourcesForWorkspace: vi.fn(async () => []), sourceViewOptionsBySourceId: vi.fn(() => Effect.succeed(new Map())), ...overrides, - }; + } +} + +function makeDemoCatalog(): DemoCatalog { + return { + 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, + }, + 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 { @@ -163,7 +439,7 @@ function makeWorkspace(): Workspace { userId: "user_1", namespace: "notebook-workspace_1", createdAt: new Date("2026-05-10T00:00:00.000Z"), - }; + } } function makeSource( @@ -189,10 +465,13 @@ function makeSource( updatedAt: new Date("2026-05-10T00:00:00.000Z"), deletedAt: null, ...overrides, - }; + } } -function makeThread(workspaceId: string): ChatThread { +function makeThread( + workspaceId: string, + overrides: Partial = {}, +): ChatThread { return { id: "thread_1", workspaceId, @@ -200,6 +479,23 @@ function makeThread(workspaceId: string): ChatThread { 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, + 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 d46f130..d11e1fb 100644 --- a/src/domains/workspace/initial-state.ts +++ b/src/domains/workspace/initial-state.ts @@ -1,14 +1,23 @@ import "server-only" +import { unstable_cache } from "next/cache" import { Effect } from "effect" -import { DEMO_CHAT_MESSAGES } from "@/domains/chat/demo" 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 { demoData } from "@/domains/sources/demo-data" -import { reconcileSourcesForWorkspace as reconcileDefaultSourcesForWorkspace } from "@/domains/sources/reconcile" +import { sourceService } from "@/domains/sources/service" +import { startBackgroundReconciliation } from "@/domains/sources/background-reconcile" +import { sourceWorkflowRuntime } from "@/domains/sources/workflow-runtime" + import type { SourceView } from "@/domains/sources/types" import { toSourceView } from "@/domains/sources/view" import type { AuthUser } from "@/infrastructure/auth" @@ -18,13 +27,15 @@ import type { Source, Workspace, } from "@/infrastructure/db/schema" +import { knowhereDemoApi, type DemoCatalog } from "@/integrations/knowhere-demo" import { notebookRequestContext } from "./request-context" -import { workspaceService } from "./service" type WorkspaceShellInitialState = { readonly activeChatThreadId?: string | null readonly chatMessages?: ChatMessageView[] readonly chatThreads?: ReturnType[] + readonly dashboardUrl?: string + readonly initialPrefetchedChunksBySourceId?: Record readonly isGuest?: boolean readonly loginUrl?: string readonly sources?: SourceView[] @@ -39,34 +50,71 @@ type WorkspaceShellInitialState = { } } +const getCachedDemoChunksForSource = (demoSourceId: string) => + unstable_cache( + async (): Promise => { + const chunkPage = await knowhereDemoApi.fetchChunkPage({ + demoSourceId, + page: 1, + pageSize: 100, + }) + const sourceView = demoView.toSourceView({ + demoSourceId: chunkPage.demoSourceId, + canonicalDocumentId: chunkPage.canonicalDocumentId, + title: chunkPage.title, + mimeType: chunkPage.mimeType, + sizeBytes: 0, + status: "ready", + chunkCount: chunkPage.pagination.total, + originalFile: { + url: "", + mimeType: "", + sizeBytes: 0, + canDownload: false, + }, + examples: [], + }) + return chunkPage.chunks.map((chunk) => + demoView.toParsedChunkView(sourceView, chunk), + ) + }, + ["demo-chunks", demoSourceId], + { revalidate: false }, + )() + type WorkspaceShellInitialStateClient = - Parameters[1] & - Parameters[1] & - Parameters[1] + Parameters[1] type WorkspaceShellInitialStateDependencies = { - readonly demoChatMessages: readonly ChatMessageView[] - readonly demoSources: readonly SourceView[] - readonly ensureDemoWorkspaceContent: ( - workspace: Workspace, - client: WorkspaceShellInitialStateClient, - ) => Promise + readonly fetchDemoCatalog: () => Promise readonly getClientForWorkspace: ( workspace: Workspace, - ) => Promise<{ readonly client: WorkspaceShellInitialStateClient }> + ) => Promise<{ + readonly apiKey: string + readonly client: WorkspaceShellInitialStateClient + }> readonly getGuest: () => Promise<{ readonly loginUrl: string }> readonly getOptionalAuthenticated: () => Promise<{ readonly user: AuthUser readonly workspace: Workspace } | null> - readonly listChatThreads: (workspaceId: string) => Promise + 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, ) => Promise - readonly reconcileSourcesForWorkspace: ( - workspace: Workspace, - client: WorkspaceShellInitialStateClient, + readonly listSourcesForWorkspace: ( + workspaceId: string, ) => Promise readonly sourceViewOptionsBySourceId: ( sources: readonly Source[], @@ -75,63 +123,180 @@ type WorkspaceShellInitialStateDependencies = { } const defaultDependencies: WorkspaceShellInitialStateDependencies = { - demoChatMessages: DEMO_CHAT_MESSAGES, - demoSources: demoData.listSources(), - ensureDemoWorkspaceContent: workspaceService.ensureDemoWorkspaceContent, + fetchDemoCatalog: knowhereDemoApi.fetchCatalog, getClientForWorkspace: notebookRequestContext.getClientForWorkspace, getGuest: notebookRequestContext.getGuest, getOptionalAuthenticated: notebookRequestContext.getOptionalAuthenticated, + ensureDemoChatThread: chatThreadService.ensureDemo, listChatThreads: chatThreadService.listForWorkspace, + listHiddenDemoSourceIds: sourceService.listHiddenDemoSourceIds, listMessages: chatThreadService.listMessages, - reconcileSourcesForWorkspace: reconcileDefaultSourcesForWorkspace, + listSourcesForWorkspace: sourceWorkflowRuntime.listForWorkspace, sourceViewOptionsBySourceId: getSourceViewOptionsBySourceId, } -export async function loadWorkspaceShellInitialState( +// --------------------------------------------------------------------------- +// Effect core +// --------------------------------------------------------------------------- + +export const loadWorkspaceShellInitialStateEffect = ( deps: WorkspaceShellInitialStateDependencies = defaultDependencies, -): Promise { - const context = await deps.getOptionalAuthenticated() +) => + Effect.gen(function* () { + const context = yield* Effect.tryPromise(() => + deps.getOptionalAuthenticated(), + ) + + if (!context) { + const demoCatalog = yield* Effect.tryPromise(() => + deps.fetchDemoCatalog(), + ) + const guestContext = yield* Effect.tryPromise(() => deps.getGuest()) + + const firstDemoSource = demoCatalog.sources[0] + let initialPrefetchedChunksBySourceId: Record< + string, + ParsedChunkView[] + > = {} + if (firstDemoSource) { + const chunks = yield* Effect.catchAll( + Effect.tryPromise(() => + getCachedDemoChunksForSource(firstDemoSource.demoSourceId), + ), + () => Effect.succeed([] as ParsedChunkView[]), + ) + if (chunks.length > 0) { + initialPrefetchedChunksBySourceId = { + [firstDemoSource.demoSourceId]: chunks, + } + } + } + + return { + isGuest: true, + sources: demoCatalog.sources.map(demoView.toSourceView), + chatMessages: demoView.toChatMessages(demoCatalog), + dashboardUrl: resolveDashboardUrl(), + initialPrefetchedChunksBySourceId, + loginUrl: guestContext.loginUrl, + } + } + + const { user, workspace } = context + const demoCatalog = yield* Effect.tryPromise(() => + knowhereDemoApi.fetchOptionalCatalog(deps.fetchDemoCatalog), + ) + const sources = yield* Effect.tryPromise(() => + deps.listSourcesForWorkspace(workspace.id), + ) + const demoSourceResolution = resolveWorkspaceDemoSources( + sources, + demoCatalog, + ) + const hiddenDemoSourceIds = new Set( + yield* Effect.tryPromise(() => + deps.listHiddenDemoSourceIds(workspace.id), + ), + ) + const visibleDemoCatalogSources = demoCatalog.sources + .filter( + (source) => + !demoSourceResolution.materializedDemoSourceIds.has( + source.demoSourceId, + ), + ) + .filter((source) => !hiddenDemoSourceIds.has(source.demoSourceId)) + const demoSources = visibleDemoCatalogSources.map(demoView.toSourceView) + const listedChatThreads = yield* Effect.tryPromise(() => + deps.listChatThreads(workspace.id), + ) + const seededDemoChatThread = + listedChatThreads.length === 0 + ? yield* Effect.tryPromise(() => + deps.ensureDemoChatThread(workspace.id, demoCatalog), + ) + : null + const chatThreads = seededDemoChatThread + ? [seededDemoChatThread.thread] + : listedChatThreads + const activeChatThread = chatThreads[0] ?? null + const activeChatMessages = seededDemoChatThread + ? seededDemoChatThread.messages + : activeChatThread + ? yield* Effect.tryPromise(() => + deps.listMessages(workspace.id, activeChatThread.id), + ) + : [] + const chatMessages = activeChatMessages + ? activeChatMessages.map((message) => toChatMessageView(message)) + : [] + const sourcesNeedingKnowhereChunkCount = + getWorkspaceSourcesNeedingKnowhereChunkCount( + demoSourceResolution.workspaceSources, + ) + const materializedDemoSourceOptions = + getMaterializedDemoSourceViewOptionsBySourceId( + demoSourceResolution.workspaceSources, + demoCatalog, + ) + const { client, apiKey } = yield* Effect.tryPromise(() => + deps.getClientForWorkspace(workspace), + ) + for (const source of sources) { + if (source.status === "parsing" && source.knowhereJobId) { + yield* Effect.fork( + Effect.tryPromise(() => + startBackgroundReconciliation(workspace.id, source.id, apiKey), + ), + ) + } + } + const sourceOptions = yield* deps.sourceViewOptionsBySourceId( + sourcesNeedingKnowhereChunkCount, + client, + ) - if (!context) { - const guestContext = await deps.getGuest() return { - isGuest: true, - sources: [...deps.demoSources], - chatMessages: [...deps.demoChatMessages], - loginUrl: guestContext.loginUrl, + user: { + id: user.id, + name: user.name ?? null, + email: user.email ?? null, + }, + workspace: { + id: workspace.id, + namespace: workspace.namespace, + }, + dashboardUrl: resolveDashboardUrl(), + sources: [ + ...demoSources, + ...demoSourceResolution.workspaceSources.map((source) => + toSourceView( + source, + materializedDemoSourceOptions.get(source.id) ?? + sourceOptions.get(source.id), + ), + ), + ], + chatThreads: chatThreads.map(toChatThreadView), + activeChatThreadId: activeChatThread?.id ?? null, + chatMessages, } - } + }) - const { user, workspace } = context - const { client } = await deps.getClientForWorkspace(workspace) - await deps.ensureDemoWorkspaceContent(workspace, client) - const sources = await deps.reconcileSourcesForWorkspace(workspace, client) - const chatThreads = await deps.listChatThreads(workspace.id) - const activeChatThread = chatThreads[0] ?? null - const chatMessages = activeChatThread - ? await deps.listMessages(workspace.id, activeChatThread.id) - : [] - const sourceOptions = await Effect.runPromise( - deps.sourceViewOptionsBySourceId(sources, client), - ) - - return { - user: { - id: user.id, - name: user.name ?? null, - email: user.email ?? null, - }, - workspace: { - id: workspace.id, - namespace: workspace.namespace, - }, - sources: sources.map((source) => - toSourceView(source, sourceOptions.get(source.id)), - ), - chatThreads: chatThreads.map(toChatThreadView), - activeChatThreadId: activeChatThread?.id ?? null, - chatMessages: (chatMessages ?? []).map((message) => - toChatMessageView(message), - ), - } +// --------------------------------------------------------------------------- +// Async wrapper (backward-compatible) +// --------------------------------------------------------------------------- + +export async function loadWorkspaceShellInitialState( + deps: WorkspaceShellInitialStateDependencies = defaultDependencies, +): Promise { + return Effect.runPromise(loadWorkspaceShellInitialStateEffect(deps)) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function resolveDashboardUrl(): string | undefined { + return process.env.DASHBOARD_ORIGIN } diff --git a/src/domains/workspace/integration.test.ts b/src/domains/workspace/integration.test.ts index 70a5933..797cdbf 100644 --- a/src/domains/workspace/integration.test.ts +++ b/src/domains/workspace/integration.test.ts @@ -7,6 +7,7 @@ import * as schema from "@/infrastructure/db/schema"; import { chatMessages, chatThreads, + demoSourceVisibilities, sourceParseResults, sources, workspaces, @@ -98,13 +99,17 @@ describeIfDb("workspace helpers — integration", () => { workspaceId: string, sourceId: string, ) => Promise>> - readonly createDemoUploadRepository: ( - db: Parameters< - typeof import("../sources/repository").sourceRepository.createDemoUploadRepository - >[0], - ) => ReturnType< - typeof import("../sources/repository").sourceRepository.createDemoUploadRepository - > + 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 () => { @@ -120,13 +125,11 @@ describeIfDb("workspace helpers — integration", () => { { sourceService }, { sourceWorkflowRuntime }, { chatThreadService }, - { sourceRepository }, ] = await Promise.all([ import("./service"), import("../sources/service"), import("../sources/workflow-runtime"), import("../chat/thread-service"), - import("../sources/repository"), ]); workspaceHelpers = { ensureWorkspace: workspaceService.ensureWorkspace, @@ -143,13 +146,17 @@ describeIfDb("workspace helpers — integration", () => { markSourceFailed: sourceWorkflowRuntime.markFailed, saveSourceParseResult: sourceWorkflowRuntime.saveParseResult, getParseAssetUrls: sourceService.getParseAssetUrls, - createDemoUploadRepository: sourceRepository.createDemoUploadRepository, + 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); }); @@ -586,49 +593,41 @@ describeIfDb("workspace helpers — integration", () => { ).resolves.toEqual({}); }); - it("demo source upload repository is idempotent by workspace demo key", async () => { + it("tracks hidden demos and upserts materialized demo sources by demo id", async () => { const ws = await workspaceHelpers.ensureWorkspace("user_1"); - const repository = workspaceHelpers.createDemoUploadRepository( - testDb as unknown as Parameters< - typeof workspaceHelpers.createDemoUploadRepository - >[0], - ); - const first = await repository.createDemoUploadingSource(ws.id, { - demoKey: "demo-intro", - title: "intro.pdf", - mimeType: "application/pdf", - sizeBytes: 128, - originalBlobUrl: "https://demo.example/intro.pdf", - }); - const duplicate = await repository.createDemoUploadingSource(ws.id, { - demoKey: "demo-intro", - title: "intro-copy.pdf", - mimeType: "application/pdf", - sizeBytes: 256, - originalBlobUrl: "https://demo.example/intro-copy.pdf", - }); + await workspaceHelpers.hideDemoSource(ws.id, "demo-tsla-q4-2025"); + await workspaceHelpers.hideDemoSource(ws.id, "demo-tsla-q4-2025"); - expect(first).not.toBeNull(); - expect(duplicate).toBeNull(); + await expect(workspaceHelpers.listHiddenDemoSourceIds(ws.id)).resolves.toEqual([ + "demo-tsla-q4-2025", + ]); - await repository.markSourceFailed(ws.id, first!.id, "Previous failure."); - await repository.markSourceParsing(ws.id, first!.id, "job_old"); - const reupload = await repository.markDemoSourceUploading(ws.id, first!.id, { - title: "intro-updated.pdf", + const first = await workspaceHelpers.upsertMaterializedDemoSource(ws.id, { + demoSourceId: "demo-tsla-q4-2025", + title: "TSLA-Q4-2025-Update.pdf", mimeType: "application/pdf", - sizeBytes: 512, - originalBlobUrl: "https://demo.example/intro-updated.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(reupload).toMatchObject({ - title: "intro-updated.pdf", - sizeBytes: 512, - status: "uploading", + expect(second.id).toBe(first.id); + expect(second).toMatchObject({ + demoKey: "demo-tsla-q4-2025", + status: "ready", failureReason: null, knowhereJobId: null, - knowhereDocumentId: null, - originalBlobUrl: "https://demo.example/intro-updated.pdf", + knowhereDocumentId: "doc_user_copy_2", + originalBlobUrl: "/api/demo-sources/demo-tsla-q4-2025/original", }); }); }); diff --git a/src/domains/workspace/request-context.ts b/src/domains/workspace/request-context.ts index 726502b..dc0e669 100644 --- a/src/domains/workspace/request-context.ts +++ b/src/domains/workspace/request-context.ts @@ -1,10 +1,15 @@ import "server-only" +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, type AuthUser } from "@/infrastructure/auth" +import { + getCurrentUser, + requireUser, + type AuthUser, +} from "@/infrastructure/auth" import { makeKnowhereClient } from "@/integrations/knowhere" import { workspaceService } from "@/domains/workspace/service" import type { Workspace } from "@/infrastructure/db/schema" @@ -25,54 +30,92 @@ type GuestNotebookContext = { readonly loginUrl: string } -async function getAuthenticated(): Promise { - const user = await requireUser() - const workspace = await workspaceService.ensureWorkspace(user.id) +// --------------------------------------------------------------------------- +// Effect core +// --------------------------------------------------------------------------- + +const getAuthenticatedEffect = Effect.gen(function* () { + const user = yield* Effect.tryPromise(() => requireUser()) + const workspace = yield* Effect.tryPromise(() => + workspaceService.ensureWorkspace(user.id), + ) + + return { user, workspace } + }) + +const getOptionalAuthenticatedEffect = Effect.gen(function* () { + const user = yield* Effect.tryPromise(() => getCurrentUser()) + if (!user) return null + + const workspace = yield* Effect.tryPromise(() => + workspaceService.ensureWorkspace(user.id), + ) + return { user, workspace } + }) + +const getAuthenticatedWithClientEffect = Effect.gen(function* () { + const context = yield* getAuthenticatedEffect + const clientContext = yield* getClientForWorkspaceEffect(context.workspace) + + return { + ...context, + ...clientContext, + } + }) + +const getClientForWorkspaceEffect = (workspace: Workspace) => + Effect.gen(function* () { + const cookieHeader = + (yield* Effect.tryPromise(() => headers())).get("cookie") ?? "" + const apiKey = yield* Effect.tryPromise(() => + ensureApiKeyForWorkspace(workspace.id, cookieHeader), + ) + const client = makeKnowhereClient(apiKey) + + 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) +// --------------------------------------------------------------------------- - return { user, workspace } +async function getAuthenticated(): Promise { + return Effect.runPromise(getAuthenticatedEffect) } async function getOptionalAuthenticated(): Promise { - const user = await getCurrentUser() - if (!user) return null - - const workspace = await workspaceService.ensureWorkspace(user.id) - return { user, workspace } + return Effect.runPromise(getOptionalAuthenticatedEffect) } async function getAuthenticatedWithClient(): Promise { - const context = await getAuthenticated() - const clientContext = await getClientForWorkspace(context.workspace) - - return { - ...context, - ...clientContext, - } + return Effect.runPromise(getAuthenticatedWithClientEffect) } async function getClientForWorkspace( workspace: Workspace, ): Promise> { - const cookieHeader = (await headers()).get("cookie") ?? "" - const apiKey = await ensureApiKeyForWorkspace(workspace.id, cookieHeader) - const client = makeKnowhereClient(apiKey) - - return { apiKey, client } + return Effect.runPromise(getClientForWorkspaceEffect(workspace)) } async function getGuest(): Promise { - const dashboardOrigin = - process.env.DASHBOARD_ORIGIN ?? "http://localhost:3000" - const dashboardLoginURL = `${dashboardOrigin}/login` - const notebookPublicURL = - process.env.NOTEBOOK_PUBLIC_URL ?? - authURLs.resolveNotebookPublicURLFromHeaders(await headers()) - const loginUrl = authURLs.buildDashboardLoginURL( - dashboardLoginURL, - notebookPublicURL, - ) - - return { loginUrl } + return Effect.runPromise(getGuestEffect) } export const notebookRequestContext = { diff --git a/src/domains/workspace/service.ts b/src/domains/workspace/service.ts index 1687de0..baf3af4 100644 --- a/src/domains/workspace/service.ts +++ b/src/domains/workspace/service.ts @@ -2,13 +2,8 @@ import "server-only" import { Effect } from "effect" -import { chatRepository } from "../chat/repository" import { databaseRuntime } from "./database-runtime" import { DbClient } from "@/infrastructure/db" -import { demoData } from "../sources/demo-data" -import { sourceRepository } from "../sources/repository" -import { ensureDemoSourceUploadEffect } from "../sources/upload" -import type { UploadKnowhereClient } from "../sources/upload" import { workspaceRepository } from "./repository" import type { Workspace } from "@/infrastructure/db/schema" @@ -16,16 +11,8 @@ type WorkspaceService = { readonly ensureWorkspaceEffect: ( userId: string, ) => Effect.Effect - readonly ensureDemoWorkspaceContentEffect: ( - workspace: Workspace, - knowhere: UploadKnowhereClient, - ) => Effect.Effect readonly pingDatabaseEffect: () => Effect.Effect readonly ensureWorkspace: (userId: string) => Promise - readonly ensureDemoWorkspaceContent: ( - workspace: Workspace, - knowhere: UploadKnowhereClient, - ) => Promise readonly pingDatabase: () => Promise } @@ -52,49 +39,18 @@ const ensureWorkspaceEffect: WorkspaceService["ensureWorkspaceEffect"] = ( return row }) -const ensureDemoWorkspaceContentEffect: WorkspaceService["ensureDemoWorkspaceContentEffect"] = - (workspace: Workspace, knowhere: UploadKnowhereClient) => - Effect.gen(function* () { - const db = yield* DbClient - const repository = sourceRepository.createDemoUploadRepository(db) - - for (const seed of demoData.listSourceSeeds()) { - const source = yield* ensureDemoSourceUploadEffect(workspace, seed, { - knowhere, - repository, - }) - - if (source?.status !== "ready" || !source.knowhereDocumentId) continue - - yield* chatRepository.ensureDemoThreadEffect( - workspace.id, - seed.demoKey, - seed.chatThreadTitle, - source.knowhereDocumentId, - ) - } - }) - const pingDatabaseEffect: WorkspaceService["pingDatabaseEffect"] = () => workspaceRepository.pingEffect() const ensureWorkspace: WorkspaceService["ensureWorkspace"] = (userId: string) => databaseRuntime.runPromise(ensureWorkspaceEffect(userId)) -const ensureDemoWorkspaceContent: WorkspaceService["ensureDemoWorkspaceContent"] = - (workspace: Workspace, knowhere: UploadKnowhereClient) => - databaseRuntime.runPromise( - ensureDemoWorkspaceContentEffect(workspace, knowhere), - ) - const pingDatabase: WorkspaceService["pingDatabase"] = () => databaseRuntime.runPromise(pingDatabaseEffect()) export const workspaceService: WorkspaceService = { ensureWorkspaceEffect, - ensureDemoWorkspaceContentEffect, pingDatabaseEffect, ensureWorkspace, - ensureDemoWorkspaceContent, pingDatabase, } diff --git a/src/infrastructure/auth/index.test.ts b/src/infrastructure/auth/index.test.ts index 67d8168..af43591 100644 --- a/src/infrastructure/auth/index.test.ts +++ b/src/infrastructure/auth/index.test.ts @@ -145,16 +145,20 @@ describe("sessionCookieNames", () => { 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 }) afterEach(() => { globalThis.fetch = originalFetch 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 }) async function loadWithCookie(cookieHeader: string) { @@ -174,6 +178,35 @@ describe("getCurrentUser", () => { expect(fetchSpy).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() + }) + + 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( diff --git a/src/infrastructure/auth/index.ts b/src/infrastructure/auth/index.ts index 7a72c66..95a65b0 100644 --- a/src/infrastructure/auth/index.ts +++ b/src/infrastructure/auth/index.ts @@ -11,6 +11,7 @@ import { 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 { formatUnknownForLog } from "@/lib/format-log-value" @@ -57,6 +58,9 @@ const DASHBOARD_SESSION_TIMEOUT_MS = 3_000 // ---- Effect implementation ------------------------------------------------ export const getCurrentUserEffect = Effect.gen(function* () { + const developmentUser = knowhereApiKeyOverride.getDevelopmentUser() + if (developmentUser) return developmentUser + const origin = process.env.DASHBOARD_ORIGIN if (!origin) { return yield* Effect.die( @@ -147,6 +151,14 @@ export const authLayer = Layer.effect( // ---- Public API (Promise-based, for Next.js compatibility) ---------------- export async function getCurrentUser(): Promise { + const developmentUser = knowhereApiKeyOverride.getDevelopmentUser() + if (developmentUser) { + logger.info("auth: using KNOWHERE_API_KEY development user", { + userId: developmentUser.id, + }) + return developmentUser + } + const cookieHeader = (await headers()).get("cookie") ?? "" if (cookieHeader.length === 0) { logger.info("dashboard: POST /api/orpc/users/getCurrentUser skipped (no session cookie)") @@ -202,6 +214,8 @@ export async function requireUser(): Promise { * `getCurrentUser` / `requireUser` before trusting identity. */ 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 diff --git a/src/infrastructure/db/schema.ts b/src/infrastructure/db/schema.ts index ab625a8..76f6e2c 100644 --- a/src/infrastructure/db/schema.ts +++ b/src/infrastructure/db/schema.ts @@ -13,20 +13,20 @@ import { /** * Drizzle schema for Knowhere Notebook. * - * Persistence rule (per @suguan + the technical plan): + * Persistence rule: * - Postgres stores only metadata, status, Knowhere IDs, and chat * threads/messages. * - It does NOT store file bytes or chunk copies in Postgres. Original * uploads and parsed media artifacts live in Blob storage; chunks are * fetched on demand from Knowhere's chunks API. * - * Soft delete (per @Pi's PR-B review criteria): + * Soft delete: * - Every user-visible resource has a nullable `deleted_at` timestamp. * - Reads filter on `deleted_at IS NULL` by default (see helpers in * src/lib/workspace.ts). * - Hard delete is reserved for retention sweeps and admin paths. * - * Portability rule (per @suguan): + * Portability rule: * - Stay on portable Postgres. No Neon-only syntax, no pgvector, no * extensions beyond `pgcrypto` (used implicitly by defaultRandom). * - Migrating to AWS Aurora Postgres is a DATABASE_URL swap. @@ -74,8 +74,8 @@ 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` — bundled demo source identifier when this row is seeded - * into a logged-in workspace + * - `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: @@ -111,13 +111,9 @@ export const sources = pgTable( deletedAt: timestamp("deleted_at", { withTimezone: true }), }, (t) => [ - // Sidebar list query: per workspace, newest first, soft-deleted - // rows hidden. Partial index keeps the hot path lean. index("sources_workspace_created_idx") .on(t.workspaceId, t.createdAt.desc()) .where(sql`deleted_at IS NULL`), - // Reconcile sweep picks up anything still in `uploading` or - // `parsing`. Small cardinality, small index. index("sources_workspace_status_idx").on(t.workspaceId, t.status), uniqueIndex("sources_workspace_demo_key_idx").on(t.workspaceId, t.demoKey), ], @@ -126,6 +122,39 @@ 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. * @@ -160,8 +189,8 @@ export type SourceParseResult = typeof sourceParseResults.$inferSelect; export type NewSourceParseResult = typeof sourceParseResults.$inferInsert; /** - * A chat thread is a conversation within a workspace. `demo_key` is set only - * for bundled demo conversations seeded into a logged-in workspace. + * A chat thread is a conversation within a workspace. `demo_key` is retained + * for legacy seeded demo conversations. */ export const chatThreads = pgTable( "chat_threads", diff --git a/src/integrations/dashboard/api-key-service.test.ts b/src/integrations/dashboard/api-key-service.test.ts index 7ba0b4e..30db09b 100644 --- a/src/integrations/dashboard/api-key-service.test.ts +++ b/src/integrations/dashboard/api-key-service.test.ts @@ -1,6 +1,10 @@ import { afterEach, describe, expect, it, vi } from "vitest" -import { fetchKnowhereJwt, isAuthError } from "./api-key-service" +import { + ensureApiKeyForWorkspace, + fetchKnowhereJwt, + isAuthError, +} from "./api-key-service" function getHeaderValue(headers: HeadersInit | undefined, name: string): string | null { if (headers === undefined) return null @@ -160,3 +164,30 @@ describe("fetchKnowhereJwt", () => { ).rejects.toThrow(/Dashboard JWT issuance: schema mismatch .*"token":""/) }) }) + +describe("ensureApiKeyForWorkspace", () => { + const originalFetch = globalThis.fetch + const originalApiKey = process.env.KNOWHERE_API_KEY + const originalOrigin = process.env.DASHBOARD_ORIGIN + + afterEach(() => { + globalThis.fetch = originalFetch + if (originalApiKey === undefined) delete process.env.KNOWHERE_API_KEY + else process.env.KNOWHERE_API_KEY = originalApiKey + if (originalOrigin === undefined) + delete process.env.DASHBOARD_ORIGIN + else process.env.DASHBOARD_ORIGIN = originalOrigin + }) + + it("uses KNOWHERE_API_KEY without issuing a Dashboard JWT", async () => { + process.env.KNOWHERE_API_KEY = "sk_dev_key" + delete process.env.DASHBOARD_ORIGIN + const fetchSpy = vi.fn() + globalThis.fetch = fetchSpy + + const apiKey = await ensureApiKeyForWorkspace("workspace_1", "") + + expect(apiKey).toBe("sk_dev_key") + expect(fetchSpy).not.toHaveBeenCalled() + }) +}) diff --git a/src/integrations/dashboard/api-key-service.ts b/src/integrations/dashboard/api-key-service.ts index 62ee732..1214cd1 100644 --- a/src/integrations/dashboard/api-key-service.ts +++ b/src/integrations/dashboard/api-key-service.ts @@ -7,6 +7,7 @@ import { HttpClientRequest, } from "@effect/platform" import { logger } from "@/lib/logger" +import { knowhereApiKeyOverride } from "@/integrations/knowhere-api-key" import { setEmptyJsonBody } from "./orpc-request" import { formatUnknownForLog } from "@/lib/format-log-value" @@ -113,13 +114,16 @@ export async function fetchKnowhereJwt( } /** - * Fetch a per-request Knowhere JWT from Dashboard, forwarding the - * incoming session cookie. + * Resolve the credential used for Knowhere SDK calls. Development can + * short-circuit Dashboard JWT issuance by setting KNOWHERE_API_KEY. */ export async function ensureApiKeyForWorkspace( _workspaceId: string, cookieHeader: string, ): Promise { + const apiKey = knowhereApiKeyOverride.getApiKey() + if (apiKey) return apiKey + return fetchKnowhereJwt(cookieHeader) } diff --git a/src/integrations/knowhere-api-key.ts b/src/integrations/knowhere-api-key.ts new file mode 100644 index 0000000..4645c39 --- /dev/null +++ b/src/integrations/knowhere-api-key.ts @@ -0,0 +1,31 @@ +type KnowhereDevelopmentUser = { + readonly id: string + readonly email: string | null + readonly name: string | null +} + +const developmentUser: KnowhereDevelopmentUser = { + id: "knowhere-api-key-dev-user", + email: null, + name: "Knowhere API Key Development", +} + +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 +} + +function getDevelopmentUser(): KnowhereDevelopmentUser | null { + if (!hasApiKey()) return null + return developmentUser +} + +export const knowhereApiKeyOverride = { + getApiKey, + hasApiKey, + getDevelopmentUser, +} as const diff --git a/src/integrations/knowhere-demo.test.ts b/src/integrations/knowhere-demo.test.ts new file mode 100644 index 0000000..4018592 --- /dev/null +++ b/src/integrations/knowhere-demo.test.ts @@ -0,0 +1,83 @@ +import { afterEach, describe, expect, it, vi } from "vitest" + +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 + }) + + 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("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: "", + }) + }) +}) + +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 new file mode 100644 index 0000000..37512ca --- /dev/null +++ b/src/integrations/knowhere-demo.ts @@ -0,0 +1,504 @@ +import "server-only" + +import { Effect } from "effect" + +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 examples: readonly DemoExample[] +} + +export type DemoCatalog = { + readonly sources: readonly DemoSource[] +} + +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[] +} + +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 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 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: [] } + +// --------------------------------------------------------------------------- +// Effect core +// --------------------------------------------------------------------------- + +const fetchCatalogEffect = Effect.fn("knowhereDemo.fetchCatalog")(function* () { + const response = yield* Effect.tryPromise(() => + fetch(resolveApiURL("/api/v1/demo/catalog"), { + cache: "force-cache", + next: { revalidate: 300 }, + }), + ) + yield* assertOkEffect(response) + + const body = (yield* Effect.tryPromise(() => + response.json(), + )) as DemoCatalogResponse + return { + sources: (body.sources ?? []).map(toDemoSource), + } +}) + +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()}`, + ), + { cache: "force-cache", next: { revalidate: 300 } }, + ), + ) + 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 = JSON.stringify({ + 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 fetchOptionalCatalogEffect = ( + fetcher?: () => Effect.Effect, +) => + (fetcher ?? fetchCatalogEffect)().pipe( + Effect.catchAll(() => Effect.succeed(emptyCatalog)), + ) + +// --------------------------------------------------------------------------- +// Async wrappers (backward-compatible) +// --------------------------------------------------------------------------- + +async function fetchCatalog(): Promise { + 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 { + 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 { + 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), + 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 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 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/lib/api-error-response.ts b/src/lib/api-error-response.ts index 251ddb1..c16a26e 100644 --- a/src/lib/api-error-response.ts +++ b/src/lib/api-error-response.ts @@ -1,5 +1,6 @@ import "server-only" +import { Effect } from "effect" import { NextResponse } from "next/server" import { formatUnknownForLog } from "./format-log-value" @@ -10,13 +11,20 @@ export async function withApiErrorResponse( handler: () => Promise, fallbackMessage: string = "Something went wrong. Please try again.", ): Promise { - try { - return await handler() - } catch (error) { - logger.error("api: unhandled request failure", { - context, - error: formatUnknownForLog(error), - }) - return NextResponse.json({ message: fallbackMessage }, { status: 500 }) - } + return Effect.runPromise( + Effect.tryPromise(handler).pipe( + Effect.catchAll((error) => + Effect.sync(() => { + logger.error("api: unhandled request failure", { + context, + error: formatUnknownForLog(error), + }) + return NextResponse.json( + { message: fallbackMessage }, + { status: 500 }, + ) + }), + ), + ), + ) } diff --git a/src/lib/route-result.ts b/src/lib/route-result.ts index 5b1bb4a..3ec3167 100644 --- a/src/lib/route-result.ts +++ b/src/lib/route-result.ts @@ -1,3 +1,5 @@ +import { Effect } from "effect" + export type RouteResult = { readonly status: number readonly body: TBody @@ -31,22 +33,39 @@ function badRequest(message: string): RouteResult { return error(400, message) } +// --------------------------------------------------------------------------- +// Effect core +// --------------------------------------------------------------------------- + +const readJsonEffect = ( + request: Request, +): Effect.Effect => + Effect.tryPromise(() => request.json()).pipe( + Effect.map( + (value): ReadJsonResult => ({ ok: true, value }), + ), + Effect.catchAllCause( + (): Effect.Effect => Effect.succeed({ ok: false }), + ), + ) + +const readJsonOrNullEffect = ( + request: Request, +): Effect.Effect => + readJsonEffect(request).pipe( + Effect.map((body) => (body.ok ? body.value : null)), + ) + +// --------------------------------------------------------------------------- +// Async wrappers (backward-compatible) +// --------------------------------------------------------------------------- + async function readJson(request: Request): Promise { - try { - return { - ok: true, - value: await request.json(), - } - } catch { - return { ok: false } - } + return Effect.runPromise(readJsonEffect(request)) } async function readJsonOrNull(request: Request): Promise { - const body = await readJson(request) - if (!body.ok) return null - - return body.value + return Effect.runPromise(readJsonOrNullEffect(request)) } export const routeResult = { diff --git a/src/lib/use-hash-fragment.test.ts b/src/lib/use-hash-fragment.test.ts deleted file mode 100644 index 91ddda4..0000000 --- a/src/lib/use-hash-fragment.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -// @vitest-environment node -import React from "react"; -import { renderToString } from "react-dom/server"; -import { describe, expect, it } from "vitest"; - -import { useHashFragment } from "./use-hash-fragment"; - -describe("useHashFragment", () => { - it("can render on the server without reading window", () => { - function HashFragmentProbe(): React.ReactElement { - const [chunkId] = useHashFragment(); - return React.createElement("span", null, chunkId ?? "none"); - } - - expect(() => renderToString(React.createElement(HashFragmentProbe))).not.toThrow(); - }); -}); diff --git a/src/lib/use-hash-fragment.ts b/src/lib/use-hash-fragment.ts deleted file mode 100644 index 0ab9ddd..0000000 --- a/src/lib/use-hash-fragment.ts +++ /dev/null @@ -1,45 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useState } from "react"; - -const CHUNK_PREFIX = "#chunk-"; - -export function useHashFragment(): [ - chunkId: string | null, - navigateToChunk: (id: string | null) => void, -] { - const [chunkId, setChunkId] = useState(() => - typeof window === "undefined" ? null : readHash(window.location.hash), - ); - - useEffect(() => { - function onHashChange(): void { - setChunkId(readHash(window.location.hash)); - } - onHashChange(); - window.addEventListener("hashchange", onHashChange); - return () => window.removeEventListener("hashchange", onHashChange); - }, []); - - const navigateToChunk = useCallback((id: string | null) => { - if (id) { - window.location.hash = `${CHUNK_PREFIX}${id}`; - } else { - window.history.replaceState( - null, - "", - window.location.pathname + window.location.search, - ); - setChunkId(null); - } - }, []); - - return [chunkId, navigateToChunk]; -} - -function readHash(hash: string): string | null { - if (hash.startsWith(CHUNK_PREFIX)) { - return hash.slice(CHUNK_PREFIX.length); - } - return null; -} diff --git a/src/proxy.test.ts b/src/proxy.test.ts index 68dbdb2..b965699 100644 --- a/src/proxy.test.ts +++ b/src/proxy.test.ts @@ -5,9 +5,11 @@ import { proxy } from "./proxy"; describe("proxy", () => { const originalDashboardOrigin = process.env.DASHBOARD_ORIGIN; + const originalKnowhereApiKey = process.env.KNOWHERE_API_KEY; beforeEach(() => { delete process.env.DASHBOARD_ORIGIN; + delete process.env.KNOWHERE_API_KEY; }); afterEach(() => { @@ -16,6 +18,11 @@ describe("proxy", () => { } else { process.env.DASHBOARD_ORIGIN = originalDashboardOrigin; } + if (originalKnowhereApiKey === undefined) { + delete process.env.KNOWHERE_API_KEY; + } else { + process.env.KNOWHERE_API_KEY = originalKnowhereApiKey; + } }); it("allows anonymous guest source reads", () => { @@ -27,9 +34,21 @@ describe("proxy", () => { "http://localhost:3001/api/sources/demo-tsla-q4-2025/chunks", ), ); + const originalResponse = proxy( + new NextRequest( + "http://localhost:3001/api/demo-sources/demo-tsla-q4-2025/original", + ), + ); + const assetResponse = proxy( + new NextRequest( + "http://localhost:3001/api/demo-sources/demo-tsla-q4-2025/assets/images/image-1.jpg", + ), + ); 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", () => { @@ -43,4 +62,16 @@ describe("proxy", () => { "http://localhost:3001/login", ); }); + + it("allows protected app routes without a session when KNOWHERE_API_KEY is configured", () => { + process.env.KNOWHERE_API_KEY = "sk_dev_key"; + + const response = proxy( + new NextRequest("http://localhost:3001/api/sources/source-1", { + method: "PATCH", + }), + ); + + expect(response.headers.get("x-middleware-next")).toBe("1"); + }); }); diff --git a/src/proxy.ts b/src/proxy.ts index 6b5823e..f245b78 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,6 +1,7 @@ import { NextResponse, type NextRequest } from "next/server" import { authURLs } from "@/infrastructure/auth/urls" import { sessionCookieNames } from "@/infrastructure/auth/session-cookie-names" +import { knowhereApiKeyOverride } from "@/integrations/knowhere-api-key" /** * Edge-runtime proxy (renamed from middleware.ts in Next.js 16). @@ -29,6 +30,8 @@ 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 @@ -41,10 +44,17 @@ function isPublicPath(req: NextRequest): boolean { function isGuestSourceReadPath(method: string, pathname: string): boolean { if (method !== "GET") return false - return pathname === "/api/sources" || GUEST_SOURCE_CHUNKS_PATH.test(pathname) + 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() + if (isPublicPath(req)) return NextResponse.next() for (const name of sessionCookieNames()) {