diff --git a/.env.example b/.env.example index ef1e9309..c0a996ca 100644 --- a/.env.example +++ b/.env.example @@ -34,10 +34,6 @@ SUPABASE_SERVICE_ROLE_KEY=eyJ... # Google AI GOOGLE_GENERATIVE_AI_API_KEY=AIza... -# Assistant UI -NEXT_PUBLIC_ASSISTANT_BASE_URL=https://... -ASSISTANT_API_KEY=sk_aui_... - # Firecrawl Web Scraping (Optional) # Get your API key from firecrawl.dev FIRECRAWL_API_KEY=fc_... @@ -50,3 +46,9 @@ FIRECRAWL_API_KEY=fc_... # - direct-only (Force Direct Fetch only) SCRAPING_MODE=hybrid +# Azure Document AI (Mistral OCR) - for PDF upload OCR and scripts/ocr-pdf-from-url.sh +AZURE_DOCUMENT_AI_API_KEY=your-api-key-from-azure-deployment +# Required: AZURE_DOCUMENT_AI_ENDPOINT=your-ocr-endpoint-url +# Optional: AZURE_DOCUMENT_AI_MODEL (default: mistral-document-ai-2512) +# Optional: OCR_INCLUDE_IMAGES=false to skip extracting images (smaller OCR; pdfImageRefs won't work) + diff --git a/.gitignore b/.gitignore index 36202e82..81dc4e73 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ # dependencies /node_modules +.pnpm-store /.pnp .pnp.* .yarn/* @@ -22,6 +23,8 @@ # misc .DS_Store +tmp/ +assistant-ui-main/ *.pem # debug diff --git a/drizzle/0002_deep_shadowcat.sql b/drizzle/0002_deep_shadowcat.sql new file mode 100644 index 00000000..eb03c2f7 --- /dev/null +++ b/drizzle/0002_deep_shadowcat.sql @@ -0,0 +1,35 @@ +CREATE TABLE "chat_messages" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "thread_id" uuid NOT NULL, + "message_id" text NOT NULL, + "parent_id" text, + "format" text NOT NULL, + "content" jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() +); +--> statement-breakpoint +CREATE TABLE "chat_threads" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "workspace_id" uuid NOT NULL, + "user_id" text NOT NULL, + "title" text, + "is_archived" boolean DEFAULT false, + "external_id" text, + "created_at" timestamp with time zone DEFAULT now(), + "updated_at" timestamp with time zone DEFAULT now(), + "last_message_at" timestamp with time zone DEFAULT now() +); +--> statement-breakpoint +ALTER TABLE "chat_threads" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "chat_messages" ADD CONSTRAINT "chat_messages_thread_id_chat_threads_id_fk" FOREIGN KEY ("thread_id") REFERENCES "public"."chat_threads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "chat_threads" ADD CONSTRAINT "chat_threads_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "chat_threads" ADD CONSTRAINT "chat_threads_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "idx_chat_messages_thread" ON "chat_messages" USING btree ("thread_id" uuid_ops);--> statement-breakpoint +CREATE INDEX "idx_chat_messages_thread_created" ON "chat_messages" USING btree ("thread_id" uuid_ops,"created_at" timestamptz_ops);--> statement-breakpoint +CREATE INDEX "idx_chat_threads_workspace" ON "chat_threads" USING btree ("workspace_id" uuid_ops);--> statement-breakpoint +CREATE INDEX "idx_chat_threads_user" ON "chat_threads" USING btree ("user_id" text_ops);--> statement-breakpoint +CREATE INDEX "idx_chat_threads_last_message" ON "chat_threads" USING btree ("workspace_id" uuid_ops,"last_message_at" timestamptz_ops);--> statement-breakpoint +CREATE POLICY "Users can manage threads in their workspaces" ON "chat_threads" AS PERMISSIVE FOR ALL TO "authenticated" USING ((EXISTS ( SELECT 1 FROM workspaces w + WHERE ((w.id = chat_threads.workspace_id) AND (w.user_id = (auth.jwt() ->> 'sub'::text))))) + OR (EXISTS ( SELECT 1 FROM workspace_collaborators c + WHERE ((c.workspace_id = chat_threads.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)))))); \ No newline at end of file diff --git a/drizzle/0003_elite_harrier.sql b/drizzle/0003_elite_harrier.sql new file mode 100644 index 00000000..36963ed3 --- /dev/null +++ b/drizzle/0003_elite_harrier.sql @@ -0,0 +1,17 @@ +CREATE TABLE "workspace_item_reads" ( + "thread_id" uuid NOT NULL, + "item_id" text NOT NULL, + "last_modified" bigint NOT NULL, + "read_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "workspace_item_reads_thread_item_key" UNIQUE("thread_id","item_id") +); +--> statement-breakpoint +ALTER TABLE "workspace_item_reads" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "workspace_item_reads" ADD CONSTRAINT "workspace_item_reads_thread_id_chat_threads_id_fk" FOREIGN KEY ("thread_id") REFERENCES "public"."chat_threads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "idx_workspace_item_reads_thread_item" ON "workspace_item_reads" USING btree ("thread_id" uuid_ops,"item_id" text_ops);--> statement-breakpoint +CREATE POLICY "Users can manage reads for threads in their workspaces" ON "workspace_item_reads" AS PERMISSIVE FOR ALL TO "authenticated" USING ((EXISTS ( SELECT 1 FROM chat_threads ct + JOIN workspaces w ON w.id = ct.workspace_id + WHERE ((ct.id = workspace_item_reads.thread_id) AND (w.user_id = (auth.jwt() ->> 'sub'::text))))) + OR (EXISTS ( SELECT 1 FROM chat_threads ct + JOIN workspace_collaborators c ON c.workspace_id = ct.workspace_id + WHERE ((ct.id = workspace_item_reads.thread_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)))))); \ No newline at end of file diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 00000000..c7678de5 --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,1823 @@ +{ + "id": "208b0e93-f789-49c6-abc8-e9328aab88b5", + "prevId": "1fc3e75d-4a15-4c00-ad92-4edde926b371", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_account_user_id": { + "name": "idx_account_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "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 + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_chat_messages_thread": { + "name": "idx_chat_messages_thread", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_messages_thread_created": { + "name": "idx_chat_messages_thread_created", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "timestamptz_ops" + } + ], + "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 + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_archived": { + "name": "is_archived", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_chat_threads_workspace": { + "name": "idx_chat_threads_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_threads_user": { + "name": "idx_chat_threads_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_threads_last_message": { + "name": "idx_chat_threads_last_message", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "timestamptz_ops" + } + ], + "isUnique": false, + "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" + }, + "chat_threads_user_id_user_id_fk": { + "name": "chat_threads_user_id_user_id_fk", + "tableFrom": "chat_threads", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "Users can manage threads in their workspaces": { + "name": "Users can manage threads in their workspaces", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "authenticated" + ], + "using": "(EXISTS ( SELECT 1 FROM workspaces w\n WHERE ((w.id = chat_threads.workspace_id) AND (w.user_id = (auth.jwt() ->> 'sub'::text)))))\n OR (EXISTS ( SELECT 1 FROM workspace_collaborators c\n WHERE ((c.workspace_id = chat_threads.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)))))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_session_user_id": { + "name": "idx_session_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_session_token": { + "name": "idx_session_token", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_user_email": { + "name": "idx_user_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_profiles": { + "name": "user_profiles", + "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 + }, + "onboarding_completed": { + "name": "onboarding_completed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_user_profiles_user_id": { + "name": "idx_user_profiles_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_profiles_user_id_key": { + "name": "user_profiles_user_id_key", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": { + "Users can insert their own profile": { + "name": "Users can insert their own profile", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "authenticated" + ], + "withCheck": "(( SELECT (auth.jwt() ->> 'sub'::text)) = user_id)" + }, + "Users can update their own profile": { + "name": "Users can update their own profile", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": [ + "authenticated" + ] + }, + "Users can view their own profile": { + "name": "Users can view their own profile", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ] + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_verification_identifier": { + "name": "idx_verification_identifier", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_collaborators": { + "name": "workspace_collaborators", + "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 + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'editor'" + }, + "invite_token": { + "name": "invite_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_workspace_collaborators_lookup": { + "name": "idx_workspace_collaborators_lookup", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_collaborators_workspace": { + "name": "idx_workspace_collaborators_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_collaborators_last_opened_at": { + "name": "idx_workspace_collaborators_last_opened_at", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "last_opened_at", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "timestamptz_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_collaborators_workspace_id_fkey": { + "name": "workspace_collaborators_workspace_id_fkey", + "tableFrom": "workspace_collaborators", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_collaborators_invite_token_unique": { + "name": "workspace_collaborators_invite_token_unique", + "nullsNotDistinct": false, + "columns": [ + "invite_token" + ] + }, + "workspace_collaborators_workspace_user_unique": { + "name": "workspace_collaborators_workspace_user_unique", + "nullsNotDistinct": false, + "columns": [ + "workspace_id", + "user_id" + ] + } + }, + "policies": { + "Owners can manage collaborators": { + "name": "Owners can manage collaborators", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "authenticated" + ], + "using": "(EXISTS ( SELECT 1\n FROM workspaces w\n WHERE ((w.id = workspace_collaborators.workspace_id) AND (w.user_id = (auth.jwt() ->> 'sub'::text)))))" + }, + "Collaborators can view their access": { + "name": "Collaborators can view their access", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ], + "using": "(user_id = (auth.jwt() ->> 'sub'::text))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_events": { + "name": "workspace_events", + "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 + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_workspace_events_event_id": { + "name": "idx_workspace_events_event_id", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_events_timestamp": { + "name": "idx_workspace_events_timestamp", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int8_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_events_user_name": { + "name": "idx_workspace_events_user_name", + "columns": [ + { + "expression": "user_name", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_events_workspace": { + "name": "idx_workspace_events_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_events_workspace_id_fkey": { + "name": "workspace_events_workspace_id_fkey", + "tableFrom": "workspace_events", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_events_event_id_key": { + "name": "workspace_events_event_id_key", + "nullsNotDistinct": false, + "columns": [ + "event_id" + ] + } + }, + "policies": { + "Users can insert workspace events they have write access to": { + "name": "Users can insert workspace events they have write access to", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "public" + ], + "withCheck": "(EXISTS ( SELECT 1\n FROM workspaces\n WHERE ((workspaces.id = workspace_events.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_events.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))" + }, + "Users can read workspace events they have access to": { + "name": "Users can read workspace events they have access to", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "(EXISTS ( SELECT 1\n FROM workspaces\n WHERE ((workspaces.id = workspace_events.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_events.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)))))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_invites": { + "name": "workspace_invites", + "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 + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'editor'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_workspace_invites_token": { + "name": "idx_workspace_invites_token", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_invites_email": { + "name": "idx_workspace_invites_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_invites_workspace": { + "name": "idx_workspace_invites_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_invites_workspace_id_fkey": { + "name": "workspace_invites_workspace_id_fkey", + "tableFrom": "workspace_invites", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_invites_token_key": { + "name": "workspace_invites_token_key", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": { + "Public can view invite by token": { + "name": "Public can view invite by token", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "true" + }, + "Users can insert invites for workspaces they own/edit": { + "name": "Users can insert invites for workspaces they own/edit", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "authenticated" + ], + "withCheck": "(EXISTS ( SELECT 1\n FROM workspaces w\n WHERE ((w.id = workspace_invites.workspace_id) AND (w.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_invites.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_share_links": { + "name": "workspace_share_links", + "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 + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'editor'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_workspace_share_links_token": { + "name": "idx_workspace_share_links_token", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_share_links_workspace": { + "name": "idx_workspace_share_links_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_share_links_workspace_id_fkey": { + "name": "workspace_share_links_workspace_id_fkey", + "tableFrom": "workspace_share_links", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_share_links_token_key": { + "name": "workspace_share_links_token_key", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + }, + "workspace_share_links_workspace_key": { + "name": "workspace_share_links_workspace_key", + "nullsNotDistinct": false, + "columns": [ + "workspace_id" + ] + } + }, + "policies": { + "Public can view share link by token": { + "name": "Public can view share link by token", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "true" + }, + "Owners and editors can manage share links": { + "name": "Owners and editors can manage share links", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "authenticated" + ], + "using": "(EXISTS ( SELECT 1\n FROM workspaces w\n WHERE ((w.id = workspace_share_links.workspace_id) AND (w.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_share_links.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_snapshots": { + "name": "workspace_snapshots", + "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 + }, + "snapshot_version": { + "name": "snapshot_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "event_count": { + "name": "event_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_workspace_snapshots_version": { + "name": "idx_workspace_snapshots_version", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "snapshot_version", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_snapshots_workspace": { + "name": "idx_workspace_snapshots_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_snapshots_workspace_id_fkey": { + "name": "workspace_snapshots_workspace_id_fkey", + "tableFrom": "workspace_snapshots", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_snapshots_workspace_id_snapshot_version_key": { + "name": "workspace_snapshots_workspace_id_snapshot_version_key", + "nullsNotDistinct": false, + "columns": [ + "workspace_id", + "snapshot_version" + ] + } + }, + "policies": { + "Service role can insert workspace snapshots": { + "name": "Service role can insert workspace snapshots", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "public" + ], + "withCheck": "true" + }, + "Users can read workspace snapshots they have access to": { + "name": "Users can read workspace snapshots they have access to", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ] + } + }, + "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 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'blank'" + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_workspaces_created_at": { + "name": "idx_workspaces_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "timestamptz_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspaces_slug": { + "name": "idx_workspaces_slug", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspaces_user_id": { + "name": "idx_workspaces_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspaces_user_slug": { + "name": "idx_workspaces_user_slug", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspaces_user_sort_order": { + "name": "idx_workspaces_user_sort_order", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspaces_last_opened_at": { + "name": "idx_workspaces_last_opened_at", + "columns": [ + { + "expression": "last_opened_at", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "timestamptz_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "Users can delete their own workspaces": { + "name": "Users can delete their own workspaces", + "as": "PERMISSIVE", + "for": "DELETE", + "to": [ + "authenticated" + ], + "using": "(( SELECT (auth.jwt() ->> 'sub'::text)) = user_id)" + }, + "Users can insert their own workspaces": { + "name": "Users can insert their own workspaces", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "authenticated" + ] + }, + "Users can update their own workspaces": { + "name": "Users can update their own workspaces", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": [ + "authenticated" + ] + }, + "Users can view their own workspaces": { + "name": "Users can view their own workspaces", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ] + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json new file mode 100644 index 00000000..f74c0463 --- /dev/null +++ b/drizzle/meta/0003_snapshot.json @@ -0,0 +1,1918 @@ +{ + "id": "4ff977d6-4f70-49fe-a07e-c39bf93e134e", + "prevId": "208b0e93-f789-49c6-abc8-e9328aab88b5", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_account_user_id": { + "name": "idx_account_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "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 + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_chat_messages_thread": { + "name": "idx_chat_messages_thread", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_messages_thread_created": { + "name": "idx_chat_messages_thread_created", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "timestamptz_ops" + } + ], + "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 + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_archived": { + "name": "is_archived", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_chat_threads_workspace": { + "name": "idx_chat_threads_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_threads_user": { + "name": "idx_chat_threads_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_threads_last_message": { + "name": "idx_chat_threads_last_message", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "timestamptz_ops" + } + ], + "isUnique": false, + "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" + }, + "chat_threads_user_id_user_id_fk": { + "name": "chat_threads_user_id_user_id_fk", + "tableFrom": "chat_threads", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "Users can manage threads in their workspaces": { + "name": "Users can manage threads in their workspaces", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "authenticated" + ], + "using": "(EXISTS ( SELECT 1 FROM workspaces w\n WHERE ((w.id = chat_threads.workspace_id) AND (w.user_id = (auth.jwt() ->> 'sub'::text)))))\n OR (EXISTS ( SELECT 1 FROM workspace_collaborators c\n WHERE ((c.workspace_id = chat_threads.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)))))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_session_user_id": { + "name": "idx_session_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_session_token": { + "name": "idx_session_token", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_user_email": { + "name": "idx_user_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_profiles": { + "name": "user_profiles", + "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 + }, + "onboarding_completed": { + "name": "onboarding_completed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_user_profiles_user_id": { + "name": "idx_user_profiles_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_profiles_user_id_key": { + "name": "user_profiles_user_id_key", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": { + "Users can insert their own profile": { + "name": "Users can insert their own profile", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "authenticated" + ], + "withCheck": "(( SELECT (auth.jwt() ->> 'sub'::text)) = user_id)" + }, + "Users can update their own profile": { + "name": "Users can update their own profile", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": [ + "authenticated" + ] + }, + "Users can view their own profile": { + "name": "Users can view their own profile", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ] + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_verification_identifier": { + "name": "idx_verification_identifier", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_collaborators": { + "name": "workspace_collaborators", + "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 + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'editor'" + }, + "invite_token": { + "name": "invite_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_workspace_collaborators_lookup": { + "name": "idx_workspace_collaborators_lookup", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_collaborators_workspace": { + "name": "idx_workspace_collaborators_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_collaborators_last_opened_at": { + "name": "idx_workspace_collaborators_last_opened_at", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "last_opened_at", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "timestamptz_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_collaborators_workspace_id_fkey": { + "name": "workspace_collaborators_workspace_id_fkey", + "tableFrom": "workspace_collaborators", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_collaborators_invite_token_unique": { + "name": "workspace_collaborators_invite_token_unique", + "nullsNotDistinct": false, + "columns": [ + "invite_token" + ] + }, + "workspace_collaborators_workspace_user_unique": { + "name": "workspace_collaborators_workspace_user_unique", + "nullsNotDistinct": false, + "columns": [ + "workspace_id", + "user_id" + ] + } + }, + "policies": { + "Owners can manage collaborators": { + "name": "Owners can manage collaborators", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "authenticated" + ], + "using": "(EXISTS ( SELECT 1\n FROM workspaces w\n WHERE ((w.id = workspace_collaborators.workspace_id) AND (w.user_id = (auth.jwt() ->> 'sub'::text)))))" + }, + "Collaborators can view their access": { + "name": "Collaborators can view their access", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ], + "using": "(user_id = (auth.jwt() ->> 'sub'::text))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_events": { + "name": "workspace_events", + "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 + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_workspace_events_event_id": { + "name": "idx_workspace_events_event_id", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_events_timestamp": { + "name": "idx_workspace_events_timestamp", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int8_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_events_user_name": { + "name": "idx_workspace_events_user_name", + "columns": [ + { + "expression": "user_name", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_events_workspace": { + "name": "idx_workspace_events_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_events_workspace_id_fkey": { + "name": "workspace_events_workspace_id_fkey", + "tableFrom": "workspace_events", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_events_event_id_key": { + "name": "workspace_events_event_id_key", + "nullsNotDistinct": false, + "columns": [ + "event_id" + ] + } + }, + "policies": { + "Users can insert workspace events they have write access to": { + "name": "Users can insert workspace events they have write access to", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "public" + ], + "withCheck": "(EXISTS ( SELECT 1\n FROM workspaces\n WHERE ((workspaces.id = workspace_events.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_events.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))" + }, + "Users can read workspace events they have access to": { + "name": "Users can read workspace events they have access to", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "(EXISTS ( SELECT 1\n FROM workspaces\n WHERE ((workspaces.id = workspace_events.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_events.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)))))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_invites": { + "name": "workspace_invites", + "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 + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'editor'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_workspace_invites_token": { + "name": "idx_workspace_invites_token", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_invites_email": { + "name": "idx_workspace_invites_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_invites_workspace": { + "name": "idx_workspace_invites_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_invites_workspace_id_fkey": { + "name": "workspace_invites_workspace_id_fkey", + "tableFrom": "workspace_invites", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_invites_token_key": { + "name": "workspace_invites_token_key", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": { + "Public can view invite by token": { + "name": "Public can view invite by token", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "true" + }, + "Users can insert invites for workspaces they own/edit": { + "name": "Users can insert invites for workspaces they own/edit", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "authenticated" + ], + "withCheck": "(EXISTS ( SELECT 1\n FROM workspaces w\n WHERE ((w.id = workspace_invites.workspace_id) AND (w.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_invites.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_item_reads": { + "name": "workspace_item_reads", + "schema": "", + "columns": { + "thread_id": { + "name": "thread_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_modified": { + "name": "last_modified", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_workspace_item_reads_thread_item": { + "name": "idx_workspace_item_reads_thread_item", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_item_reads_thread_id_chat_threads_id_fk": { + "name": "workspace_item_reads_thread_id_chat_threads_id_fk", + "tableFrom": "workspace_item_reads", + "tableTo": "chat_threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_item_reads_thread_item_key": { + "name": "workspace_item_reads_thread_item_key", + "nullsNotDistinct": false, + "columns": [ + "thread_id", + "item_id" + ] + } + }, + "policies": { + "Users can manage reads for threads in their workspaces": { + "name": "Users can manage reads for threads in their workspaces", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "authenticated" + ], + "using": "(EXISTS ( SELECT 1 FROM chat_threads ct\n JOIN workspaces w ON w.id = ct.workspace_id\n WHERE ((ct.id = workspace_item_reads.thread_id) AND (w.user_id = (auth.jwt() ->> 'sub'::text)))))\n OR (EXISTS ( SELECT 1 FROM chat_threads ct\n JOIN workspace_collaborators c ON c.workspace_id = ct.workspace_id\n WHERE ((ct.id = workspace_item_reads.thread_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)))))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_share_links": { + "name": "workspace_share_links", + "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 + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'editor'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_workspace_share_links_token": { + "name": "idx_workspace_share_links_token", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_share_links_workspace": { + "name": "idx_workspace_share_links_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_share_links_workspace_id_fkey": { + "name": "workspace_share_links_workspace_id_fkey", + "tableFrom": "workspace_share_links", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_share_links_token_key": { + "name": "workspace_share_links_token_key", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + }, + "workspace_share_links_workspace_key": { + "name": "workspace_share_links_workspace_key", + "nullsNotDistinct": false, + "columns": [ + "workspace_id" + ] + } + }, + "policies": { + "Public can view share link by token": { + "name": "Public can view share link by token", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "true" + }, + "Owners and editors can manage share links": { + "name": "Owners and editors can manage share links", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "authenticated" + ], + "using": "(EXISTS ( SELECT 1\n FROM workspaces w\n WHERE ((w.id = workspace_share_links.workspace_id) AND (w.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_share_links.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_snapshots": { + "name": "workspace_snapshots", + "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 + }, + "snapshot_version": { + "name": "snapshot_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "event_count": { + "name": "event_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_workspace_snapshots_version": { + "name": "idx_workspace_snapshots_version", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "snapshot_version", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_snapshots_workspace": { + "name": "idx_workspace_snapshots_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_snapshots_workspace_id_fkey": { + "name": "workspace_snapshots_workspace_id_fkey", + "tableFrom": "workspace_snapshots", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_snapshots_workspace_id_snapshot_version_key": { + "name": "workspace_snapshots_workspace_id_snapshot_version_key", + "nullsNotDistinct": false, + "columns": [ + "workspace_id", + "snapshot_version" + ] + } + }, + "policies": { + "Service role can insert workspace snapshots": { + "name": "Service role can insert workspace snapshots", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "public" + ], + "withCheck": "true" + }, + "Users can read workspace snapshots they have access to": { + "name": "Users can read workspace snapshots they have access to", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ] + } + }, + "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 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'blank'" + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_workspaces_created_at": { + "name": "idx_workspaces_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "timestamptz_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspaces_slug": { + "name": "idx_workspaces_slug", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspaces_user_id": { + "name": "idx_workspaces_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspaces_user_slug": { + "name": "idx_workspaces_user_slug", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspaces_user_sort_order": { + "name": "idx_workspaces_user_sort_order", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspaces_last_opened_at": { + "name": "idx_workspaces_last_opened_at", + "columns": [ + { + "expression": "last_opened_at", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "timestamptz_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "Users can delete their own workspaces": { + "name": "Users can delete their own workspaces", + "as": "PERMISSIVE", + "for": "DELETE", + "to": [ + "authenticated" + ], + "using": "(( SELECT (auth.jwt() ->> 'sub'::text)) = user_id)" + }, + "Users can insert their own workspaces": { + "name": "Users can insert their own workspaces", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "authenticated" + ] + }, + "Users can update their own workspaces": { + "name": "Users can update their own workspaces", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": [ + "authenticated" + ] + }, + "Users can view their own workspaces": { + "name": "Users can view their own workspaces", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ] + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index f7042925..e0009c66 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -15,6 +15,20 @@ "when": 1771377459878, "tag": "0002_add_workspace_share_links", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1771631691687, + "tag": "0002_deep_shadowcat", + "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1771686859689, + "tag": "0003_elite_harrier", + "breakpoints": true } ] } \ No newline at end of file diff --git a/eslint.config.mjs b/eslint.config.mjs index 35232dec..382e1ee3 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -10,6 +10,7 @@ const compat = new FlatCompat({ }); const eslintConfig = [ + { ignores: ["assistant-ui-main/**"] }, ...compat.extends("next/core-web-vitals", "next/typescript"), { rules: { diff --git a/next.config.ts b/next.config.ts index 938792cc..f8861d2d 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,4 +1,5 @@ import type { NextConfig } from "next"; +import { withWorkflow } from "workflow/next"; const nextConfig: NextConfig = { reactCompiler: true, @@ -37,4 +38,4 @@ const nextConfig: NextConfig = { }, }; -export default nextConfig; +export default withWorkflow(nextConfig); diff --git a/package.json b/package.json index ae4b7002..29c2c906 100644 --- a/package.json +++ b/package.json @@ -27,19 +27,19 @@ }, "packageManager": "pnpm@10.14.0", "dependencies": { - "@ai-sdk/devtools": "^0.0.12", - "@ai-sdk/gateway": "^3.0.39", - "@ai-sdk/google": "^3.0.23", - "@assistant-ui/react": "0.12.9", - "@assistant-ui/react-ai-sdk": "^1.3.6", - "@assistant-ui/react-devtools": "^0.2.3", - "@assistant-ui/react-markdown": "^0.12.3", - "@blocknote/code-block": "^0.46.2", - "@blocknote/core": "^0.46.2", - "@blocknote/react": "^0.46.2", - "@blocknote/server-util": "^0.46.2", - "@blocknote/shadcn": "^0.46.2", - "@dnd-kit/react": "^0.2.4", + "@ai-sdk/devtools": "^0.0.15", + "@ai-sdk/gateway": "^3.0.53", + "@ai-sdk/google": "^3.0.30", + "@assistant-ui/react": "0.12.11", + "@assistant-ui/react-ai-sdk": "^1.3.8", + "@assistant-ui/react-devtools": "^1.0.0", + "@assistant-ui/react-markdown": "^0.12.4", + "@blocknote/code-block": "^0.47.0", + "@blocknote/core": "^0.47.0", + "@blocknote/react": "^0.47.0", + "@blocknote/server-util": "^0.47.0", + "@blocknote/shadcn": "^0.47.0", + "@dnd-kit/react": "^0.3.2", "@embedpdf/core": "^2.6.2", "@embedpdf/engines": "^2.6.2", "@embedpdf/models": "^2.6.2", @@ -60,13 +60,13 @@ "@embedpdf/plugin-tiling": "^2.6.2", "@embedpdf/plugin-viewport": "^2.6.2", "@embedpdf/plugin-zoom": "^2.6.2", - "@floating-ui/react": "^0.27.17", - "@google/genai": "^1.40.0", + "@floating-ui/react": "^0.27.18", + "@google/genai": "^1.42.0", "@gsap/react": "^2.1.2", "@heroicons/react": "^2.2.0", "@hookform/resolvers": "^5.2.2", - "@lottiefiles/dotlottie-react": "^0.17.14", - "@posthog/ai": "^7.8.8", + "@lottiefiles/dotlottie-react": "^0.18.2", + "@posthog/ai": "^7.9.2", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-avatar": "^1.1.11", @@ -86,69 +86,75 @@ "@radix-ui/react-tooltip": "1.2.8", "@shikijs/themes": "^3.22.0", "@shikijs/types": "^3.22.0", - "@streamdown/code": "^1.0.2", + "@streamdown/code": "^1.0.3", "@streamdown/math": "^1.0.2", "@streamdown/mermaid": "^1.0.2", - "@supabase/supabase-js": "^2.95.3", + "@supabase/supabase-js": "^2.97.0", "@tanstack/react-query": "^5.90.20", "@tanstack/react-query-devtools": "^5.91.3", - "@tanstack/react-virtual": "^3.13.14", + "@tanstack/react-virtual": "^3.13.19", "@vercel/speed-insights": "^1.3.1", - "ai": "^6.0.78", - "assistant-cloud": "^0.1.17", - "better-auth": "^1.4.18", + "ai": "^6.0.97", + "assistant-stream": "^0.3.3", + "better-auth": "^1.4.19", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "1.1.1", + "diff": "^8.0.3", "drizzle-kit": "^0.31.9", "drizzle-orm": "^0.45.1", + "embla-carousel-react": "^8.6.0", "geist": "^1.7.0", "gsap": "^3.14.2", "input-otp": "1.4.2", - "katex": "^0.16.28", - "lucide-react": "^0.563.0", + "katex": "^0.16.33", + "lucide-react": "^0.575.0", "mathlive": "^0.108.2", - "mermaid": "^11.12.2", - "motion": "^12.34.0", + "mermaid": "^11.12.3", + "motion": "^12.34.3", "next": "16.1.6", "next-themes": "^0.4.6", + "parse-diff": "^0.11.1", + "pdf-lib": "^1.17.1", "postgres": "^3.4.7", - "posthog-js": "^1.345.0", - "posthog-node": "^5.24.14", - "prosemirror-highlight": "^0.13.0", + "posthog-js": "^1.353.0", + "posthog-node": "^5.25.0", + "prosemirror-highlight": "^0.15.0", + "prosemirror-search": "^1.1.0", "radix-ui": "^1.4.3", "react": "19.2.4", "react-color": "^2.19.3", "react-dom": "19.2.4", - "react-dropzone": "^14.4.0", + "react-dropzone": "^15.0.0", "react-grid-layout": "git+https://github.com/ThinkEx-OSS/react-grid-layout.git", "react-helmet-async": "^2.0.5", - "react-hook-form": "^7.69.0", + "react-hook-form": "^7.71.2", "react-icons": "^5.5.0", "react-markdown": "^10.1.0", "react-quizlet-flashcard": "^4.0.22", - "react-resizable-panels": "^4.6.2", + "react-resizable-panels": "^4.6.5", "react-shiki": "^0.9.1", "react-speech-recognition": "^4.0.1", "regenerator-runtime": "^0.14.1", "rehype-katex": "^7.0.1", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", - "resend": "^4.1.2", + "resend": "^6.9.2", "shiki": "^3.22.0", "sonner": "^2.0.7", - "streamdown": "^2.2.0", - "tailwind-merge": "^3.4.0", + "streamdown": "^2.3.0", + "tailwind-merge": "^3.5.0", "thinkex": "link:", "tw-shimmer": "^0.4.6", + "workflow": "4.1.0-beta.60", "zod": "^4.3.6", "zustand": "^5.0.11" }, "devDependencies": { - "@eslint/eslintrc": "^3.3.3", - "@tailwindcss/postcss": "^4.1.18", + "@eslint/eslintrc": "^3.3.4", + "@tailwindcss/postcss": "^4.2.1", "@types/bun": "^1.3.8", - "@types/node": "^24.10.4", + "@types/node": "^25.3.0", "@types/pg": "^8.16.0", "@types/react": "19.2.13", "@types/react-color": "^3.0.13", @@ -158,13 +164,13 @@ "babel-plugin-react-compiler": "^1.0.0", "concurrently": "^9.2.1", "cross-env": "^10.1.0", - "eslint": "^9.39.2", + "eslint": "^9.39.3", "eslint-config-next": "16.1.6", "geist": "^1.4.2", - "knip": "^5.83.1", + "knip": "^5.85.0", "postinstall-postinstall": "^2.1.0", "prettier": "^3.8.1", - "tailwindcss": "^4.1.18", + "tailwindcss": "^4.2.1", "tsx": "^4.21.0", "tw-animate-css": "^1.4.0", "typescript": "^5.9.3" @@ -173,9 +179,6 @@ "overrides": { "@types/react": "19.2.2", "@types/react-dom": "19.2.2" - }, - "patchedDependencies": { - "@assistant-ui/react@0.12.9": "patches/@assistant-ui__react@0.12.9.patch" } } } \ No newline at end of file diff --git a/patches/@assistant-ui__react@0.12.11.patch b/patches/@assistant-ui__react@0.12.11.patch deleted file mode 100644 index 8d8d4843..00000000 --- a/patches/@assistant-ui__react@0.12.11.patch +++ /dev/null @@ -1,105 +0,0 @@ -diff --git a/dist/legacy-runtime/runtime-cores/assistant-transport/useToolInvocations.js b/dist/legacy-runtime/runtime-cores/assistant-transport/useToolInvocations.js -index 8a6a9c91f6ae5437977f9b9d2057bdc35dd1228a..4680b8d0149476489b94f462dac93d83f46251d6 100644 ---- a/dist/legacy-runtime/runtime-cores/assistant-transport/useToolInvocations.js -+++ b/dist/legacy-runtime/runtime-cores/assistant-transport/useToolInvocations.js -@@ -60,22 +60,22 @@ export function useToolInvocations({ state, getTools, onResult, setToolStatuses, - .pipeThrough(transform) - .pipeThrough(new AssistantMetaTransformStream()) - .pipeTo(new WritableStream({ -- write(chunk) { -- if (chunk.type === "result") { -- // the tool call result was already set by the backend -- if (lastToolStates.current[chunk.meta.toolCallId]?.hasResult) -- return; -- onResult({ -- type: "add-tool-result", -- toolCallId: chunk.meta.toolCallId, -- toolName: chunk.meta.toolName, -- result: chunk.result, -- isError: chunk.isError, -- ...(chunk.artifact && { artifact: chunk.artifact }), -- }); -- } -- }, -- })); -+ write(chunk) { -+ if (chunk.type === "result") { -+ // the tool call result was already set by the backend -+ if (lastToolStates.current[chunk.meta.toolCallId]?.hasResult) -+ return; -+ onResult({ -+ type: "add-tool-result", -+ toolCallId: chunk.meta.toolCallId, -+ toolName: chunk.meta.toolName, -+ result: chunk.result, -+ isError: chunk.isError, -+ ...(chunk.artifact && { artifact: chunk.artifact }), -+ }); -+ } -+ }, -+ })); - return controller; - }); - const ignoredToolIds = useRef(new Set()); -@@ -117,21 +117,49 @@ export function useToolInvocations({ state, getTools, onResult, setToolStatuses, - } - else { - if (!content.argsText.startsWith(lastState.argsText)) { -- // Check if this is key reordering (both are complete JSON) -+ // Check if new argsText is complete JSON (handles key reordering) - // This happens when transitioning from streaming to complete state - // and the provider returns keys in a different order -- if (isArgsTextComplete(lastState.argsText) && -- isArgsTextComplete(content.argsText)) { -- lastState.controller.argsText.close(); -- lastToolStates.current[content.toolCallId] = { -- argsText: content.argsText, -- hasResult: lastState.hasResult, -- argsComplete: true, -- controller: lastState.controller, -- }; -- return; // Continue to next content part -+ if (isArgsTextComplete(content.argsText)) { -+ try { -+ const newArgs = JSON.parse(content.argsText); -+ -+ // If old is also complete, verify they're equivalent (just reordered) -+ if (isArgsTextComplete(lastState.argsText)) { -+ const oldArgs = JSON.parse(lastState.argsText); -+ // Normalize both by sorting keys and compare -+ const oldNormalized = JSON.stringify(oldArgs, Object.keys(oldArgs).sort()); -+ const newNormalized = JSON.stringify(newArgs, Object.keys(newArgs).sort()); -+ -+ if (oldNormalized === newNormalized) { -+ // Same data, just reordered - accept it -+ lastState.controller.argsText.close(); -+ lastToolStates.current[content.toolCallId] = { -+ argsText: content.argsText, -+ hasResult: lastState.hasResult, -+ argsComplete: true, -+ controller: lastState.controller, -+ }; -+ return; // Continue to next content part -+ } -+ } else { -+ // Old is incomplete, but new is complete -+ // Accept the new complete version (it's the final state) -+ lastState.controller.argsText.close(); -+ lastToolStates.current[content.toolCallId] = { -+ argsText: content.argsText, -+ hasResult: lastState.hasResult, -+ argsComplete: true, -+ controller: lastState.controller, -+ }; -+ return; // Continue to next content part -+ } -+ } catch (e) { -+ // Not valid JSON, fall through to error -+ } - } -- throw new Error(`Tool call argsText can only be appended, not updated: ${content.argsText} does not start with ${lastState.argsText}`); -+ throw new Error(` -+Tool call argsText can only be appended, not updated: ${content.argsText} does not start with ${lastState.argsText}`); - } - const argsTextDelta = content.argsText.slice(lastState.argsText.length); - lastState.controller.argsText.append(argsTextDelta); diff --git a/patches/@assistant-ui__react@0.12.9.patch b/patches/@assistant-ui__react@0.12.9.patch deleted file mode 100644 index 8d8d4843..00000000 --- a/patches/@assistant-ui__react@0.12.9.patch +++ /dev/null @@ -1,105 +0,0 @@ -diff --git a/dist/legacy-runtime/runtime-cores/assistant-transport/useToolInvocations.js b/dist/legacy-runtime/runtime-cores/assistant-transport/useToolInvocations.js -index 8a6a9c91f6ae5437977f9b9d2057bdc35dd1228a..4680b8d0149476489b94f462dac93d83f46251d6 100644 ---- a/dist/legacy-runtime/runtime-cores/assistant-transport/useToolInvocations.js -+++ b/dist/legacy-runtime/runtime-cores/assistant-transport/useToolInvocations.js -@@ -60,22 +60,22 @@ export function useToolInvocations({ state, getTools, onResult, setToolStatuses, - .pipeThrough(transform) - .pipeThrough(new AssistantMetaTransformStream()) - .pipeTo(new WritableStream({ -- write(chunk) { -- if (chunk.type === "result") { -- // the tool call result was already set by the backend -- if (lastToolStates.current[chunk.meta.toolCallId]?.hasResult) -- return; -- onResult({ -- type: "add-tool-result", -- toolCallId: chunk.meta.toolCallId, -- toolName: chunk.meta.toolName, -- result: chunk.result, -- isError: chunk.isError, -- ...(chunk.artifact && { artifact: chunk.artifact }), -- }); -- } -- }, -- })); -+ write(chunk) { -+ if (chunk.type === "result") { -+ // the tool call result was already set by the backend -+ if (lastToolStates.current[chunk.meta.toolCallId]?.hasResult) -+ return; -+ onResult({ -+ type: "add-tool-result", -+ toolCallId: chunk.meta.toolCallId, -+ toolName: chunk.meta.toolName, -+ result: chunk.result, -+ isError: chunk.isError, -+ ...(chunk.artifact && { artifact: chunk.artifact }), -+ }); -+ } -+ }, -+ })); - return controller; - }); - const ignoredToolIds = useRef(new Set()); -@@ -117,21 +117,49 @@ export function useToolInvocations({ state, getTools, onResult, setToolStatuses, - } - else { - if (!content.argsText.startsWith(lastState.argsText)) { -- // Check if this is key reordering (both are complete JSON) -+ // Check if new argsText is complete JSON (handles key reordering) - // This happens when transitioning from streaming to complete state - // and the provider returns keys in a different order -- if (isArgsTextComplete(lastState.argsText) && -- isArgsTextComplete(content.argsText)) { -- lastState.controller.argsText.close(); -- lastToolStates.current[content.toolCallId] = { -- argsText: content.argsText, -- hasResult: lastState.hasResult, -- argsComplete: true, -- controller: lastState.controller, -- }; -- return; // Continue to next content part -+ if (isArgsTextComplete(content.argsText)) { -+ try { -+ const newArgs = JSON.parse(content.argsText); -+ -+ // If old is also complete, verify they're equivalent (just reordered) -+ if (isArgsTextComplete(lastState.argsText)) { -+ const oldArgs = JSON.parse(lastState.argsText); -+ // Normalize both by sorting keys and compare -+ const oldNormalized = JSON.stringify(oldArgs, Object.keys(oldArgs).sort()); -+ const newNormalized = JSON.stringify(newArgs, Object.keys(newArgs).sort()); -+ -+ if (oldNormalized === newNormalized) { -+ // Same data, just reordered - accept it -+ lastState.controller.argsText.close(); -+ lastToolStates.current[content.toolCallId] = { -+ argsText: content.argsText, -+ hasResult: lastState.hasResult, -+ argsComplete: true, -+ controller: lastState.controller, -+ }; -+ return; // Continue to next content part -+ } -+ } else { -+ // Old is incomplete, but new is complete -+ // Accept the new complete version (it's the final state) -+ lastState.controller.argsText.close(); -+ lastToolStates.current[content.toolCallId] = { -+ argsText: content.argsText, -+ hasResult: lastState.hasResult, -+ argsComplete: true, -+ controller: lastState.controller, -+ }; -+ return; // Continue to next content part -+ } -+ } catch (e) { -+ // Not valid JSON, fall through to error -+ } - } -- throw new Error(`Tool call argsText can only be appended, not updated: ${content.argsText} does not start with ${lastState.argsText}`); -+ throw new Error(` -+Tool call argsText can only be appended, not updated: ${content.argsText} does not start with ${lastState.argsText}`); - } - const argsTextDelta = content.argsText.slice(lastState.argsText.length); - lastState.controller.argsText.append(argsTextDelta); diff --git a/src/app/api/assistant-ui-token/route.ts b/src/app/api/assistant-ui-token/route.ts deleted file mode 100644 index f012403e..00000000 --- a/src/app/api/assistant-ui-token/route.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { AssistantCloud } from "@assistant-ui/react"; -import { headers } from "next/headers"; -import { auth } from "@/lib/auth"; -import { NextRequest, NextResponse } from "next/server"; - -export async function POST(req: NextRequest) { - try { - const session = await auth.api.getSession({ - headers: await headers(), - }); - - if (!session) { - return NextResponse.json( - { error: "User not authenticated" }, - { status: 401 } - ); - } - - const userId = session.user.id; - - const body = await req.json().catch(() => ({})); - const { workspaceId } = body; - - if (!workspaceId) { - return NextResponse.json( - { error: "workspaceId is required" }, - { status: 400 } - ); - } - - // Create AssistantCloud instance with API key - const assistantCloud = new AssistantCloud({ - apiKey: process.env.ASSISTANT_API_KEY!, - userId, - workspaceId, // This scopes threads to the specific workspace - }); - - // Generate auth token for this user and workspace - const { token } = await assistantCloud.auth.tokens.create(); - - return NextResponse.json({ token }); - } catch (error) { - console.error("Failed to generate assistant token:", error); - return NextResponse.json( - { error: "Failed to generate token" }, - { status: 500 } - ); - } -} - diff --git a/src/app/api/audio/process/route.ts b/src/app/api/audio/process/route.ts index d1cfbf48..d38cd1b6 100644 --- a/src/app/api/audio/process/route.ts +++ b/src/app/api/audio/process/route.ts @@ -1,25 +1,18 @@ -import { - GoogleGenAI, - Type, - createPartFromUri, - createUserContent, -} from "@google/genai"; import { NextRequest, NextResponse } from "next/server"; import { auth } from "@/lib/auth"; import { headers } from "next/headers"; +import { start } from "workflow/api"; +import { audioTranscribeWorkflow } from "@/workflows/audio-transcribe"; export const dynamic = "force-dynamic"; -const apiKey = process.env.GOOGLE_GENERATIVE_AI_API_KEY; - /** * POST /api/audio/process - * Receives an audio file URL, downloads it, uploads to Gemini Files API, - * and returns a structured transcript + summary. + * Receives an audio file URL, runs a durable workflow to download, upload to Gemini, + * and transcribe. Returns structured transcript + summary. */ export async function POST(req: NextRequest) { try { - // Auth check const session = await auth.api.getSession({ headers: await headers(), }); @@ -27,7 +20,7 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - if (!apiKey) { + if (!process.env.GOOGLE_GENERATIVE_AI_API_KEY) { return NextResponse.json( { error: "GOOGLE_GENERATIVE_AI_API_KEY is not set" }, { status: 500 } @@ -35,7 +28,7 @@ export async function POST(req: NextRequest) { } const body = await req.json(); - const { fileUrl, filename, mimeType } = body; + const { fileUrl, filename, mimeType, itemId, workspaceId } = body; if (!fileUrl) { return NextResponse.json( @@ -44,149 +37,75 @@ export async function POST(req: NextRequest) { ); } - // Validate URL origin to prevent SSRF - const allowedHosts = [ - process.env.NEXT_PUBLIC_SUPABASE_URL - ? new URL(process.env.NEXT_PUBLIC_SUPABASE_URL).hostname - : null, - ].filter(Boolean) as string[]; - - let parsedUrl: URL; - try { - parsedUrl = new URL(fileUrl); - } catch { + if (!itemId || typeof itemId !== "string") { return NextResponse.json( - { error: "Invalid fileUrl" }, + { error: "itemId is required for polling" }, { status: 400 } ); } - if ( - !allowedHosts.some((host) => parsedUrl.hostname === host || parsedUrl.hostname.endsWith(`.${host}`)) - ) { + if (!workspaceId || typeof workspaceId !== "string") { return NextResponse.json( - { error: "fileUrl origin is not allowed" }, + { error: "workspaceId is required" }, { status: 400 } ); } - // Determine MIME type - const audioMimeType = mimeType || guessMimeType(filename || fileUrl); - - // Download the audio file from storage - const audioResponse = await fetch(fileUrl, { redirect: "error" }); - if (!audioResponse.ok) { - return NextResponse.json( - { error: "Failed to download audio" }, - { status: 500 } - ); + // Validate URL origin to prevent SSRF + const allowedHosts: string[] = []; + if (process.env.NEXT_PUBLIC_SUPABASE_URL) { + allowedHosts.push(new URL(process.env.NEXT_PUBLIC_SUPABASE_URL).hostname); } - - // Enforce a 200 MB size limit before buffering into memory - const MAX_AUDIO_SIZE = 200 * 1024 * 1024; - const contentLength = Number(audioResponse.headers.get("content-length") || "0"); - if (contentLength > MAX_AUDIO_SIZE) { - return NextResponse.json( - { error: "Audio file exceeds the 200 MB size limit" }, - { status: 400 } - ); + if (process.env.NEXT_PUBLIC_APP_URL) { + allowedHosts.push(new URL(process.env.NEXT_PUBLIC_APP_URL).hostname); } - - const audioBuffer = await audioResponse.arrayBuffer(); - if (audioBuffer.byteLength > MAX_AUDIO_SIZE) { - return NextResponse.json( - { error: "Audio file exceeds the 200 MB size limit" }, - { status: 400 } - ); + if (process.env.NODE_ENV === "development") { + allowedHosts.push("localhost"); // local storage in dev } - const client = new GoogleGenAI({ apiKey }); - - // Upload audio to Gemini Files API (supports up to 2 GB, avoids 20 MB inline limit) - const audioBlob = new Blob([audioBuffer], { type: audioMimeType }); - const uploadedFile = await client.files.upload({ - file: audioBlob, - config: { mimeType: audioMimeType }, - }); + let parsedUrl: URL; + try { + parsedUrl = new URL(fileUrl); + } catch { + return NextResponse.json({ error: "Invalid fileUrl" }, { status: 400 }); + } - if (!uploadedFile.uri || !uploadedFile.mimeType) { + if ( + !allowedHosts.some( + (host) => + parsedUrl.hostname === host || parsedUrl.hostname.endsWith(`.${host}`) + ) + ) { return NextResponse.json( - { error: "Failed to upload audio to Gemini" }, - { status: 500 } + { error: "fileUrl origin is not allowed" }, + { status: 400 } ); } - const prompt = `Process this audio file and generate a detailed transcription and summary. + const audioMimeType = mimeType || guessMimeType(filename || fileUrl); -Requirements: -1. Provide a comprehensive summary of the entire audio content. -2. Identify distinct speakers (e.g., Speaker 1, Speaker 2, or names if context allows). -3. Provide accurate timestamps for each segment (Format: MM:SS). -4. Provide the total duration of the audio in seconds (a single number, e.g. 180.5 for 3 minutes).`; + const userId = session.user.id; - const response = await client.models.generateContent({ - model: "gemini-2.5-flash-lite", - contents: createUserContent([ - createPartFromUri(uploadedFile.uri, uploadedFile.mimeType), - prompt, - ]), - config: { - responseMimeType: "application/json", - responseSchema: { - type: Type.OBJECT, - properties: { - summary: { - type: Type.STRING, - description: "A concise summary of the audio content.", - }, - duration: { - type: Type.NUMBER, - description: "Total duration of the audio in seconds.", - }, - segments: { - type: Type.ARRAY, - description: - "List of transcribed segments with speaker and timestamp.", - items: { - type: Type.OBJECT, - properties: { - speaker: { type: Type.STRING }, - timestamp: { type: Type.STRING }, - content: { type: Type.STRING }, - }, - required: [ - "speaker", - "timestamp", - "content", - ], - }, - }, - }, - required: ["summary", "segments"], - }, - }, - }); - - const resultText = response.text; - if (!resultText) { - return NextResponse.json( - { error: "No response from Gemini" }, - { status: 500 } - ); - } - - const result = JSON.parse(resultText); + // Start durable workflow; return immediately for client to poll + const run = await start(audioTranscribeWorkflow, [ + fileUrl, + audioMimeType, + workspaceId, + itemId, + userId, + ]); return NextResponse.json({ - success: true, - summary: result.summary, - segments: result.segments, - duration: typeof result.duration === "number" && result.duration > 0 ? result.duration : undefined, + runId: run.runId, + itemId, }); } catch (error: unknown) { console.error("[AUDIO_PROCESS] Error:", error); return NextResponse.json( - { error: "Failed to process audio" }, + { + error: + error instanceof Error ? error.message : "Failed to process audio", + }, { status: 500 } ); } @@ -202,5 +121,5 @@ function guessMimeType(filenameOrUrl: string): string { if (lower.endsWith(".aiff")) return "audio/aiff"; if (lower.endsWith(".webm")) return "audio/webm"; if (lower.endsWith(".m4a")) return "audio/mp4"; - return "audio/mp3"; // Default fallback + return "audio/mp3"; } diff --git a/src/app/api/audio/process/status/route.ts b/src/app/api/audio/process/status/route.ts new file mode 100644 index 00000000..26827b27 --- /dev/null +++ b/src/app/api/audio/process/status/route.ts @@ -0,0 +1,73 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { headers } from "next/headers"; +import { getRun } from "workflow/api"; + +export const dynamic = "force-dynamic"; + +/** + * GET /api/audio/process/status?runId=xxx + * Poll workflow status. Returns { status, result? } when completed or { status, error? } when failed. + */ +export async function GET(req: NextRequest) { + try { + const session = await auth.api.getSession({ + headers: await headers(), + }); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const runId = req.nextUrl.searchParams.get("runId"); + if (!runId) { + return NextResponse.json( + { error: "runId is required" }, + { status: 400 } + ); + } + + const run = getRun(runId); + const status = await run.status; + + if (status === "completed") { + const result = (await run.returnValue) as { + summary: string; + segments: Array<{ speaker: string; timestamp: string; content: string }>; + duration?: number; + }; + return NextResponse.json({ + status: "completed", + result: { + summary: result.summary, + segments: result.segments, + duration: result.duration, + }, + }); + } + + if (status === "failed") { + let errorMessage = "Processing failed"; + try { + await run.returnValue; + } catch (err) { + errorMessage = + err instanceof Error ? err.message : String(err ?? "Processing failed"); + } + return NextResponse.json({ + status: "failed", + error: errorMessage, + }); + } + + return NextResponse.json({ status: "running" }); + } catch (error: unknown) { + console.error("[AUDIO_PROCESS_STATUS] Error:", error); + return NextResponse.json( + { + error: + error instanceof Error ? error.message : "Failed to get status", + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index bfe94d42..3c8a2859 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -1,5 +1,5 @@ import { gateway } from "ai"; -import { streamText, convertToModelMessages, stepCountIs, wrapLanguageModel, tool } from "ai"; +import { streamText, smoothStream, convertToModelMessages, pruneMessages, stepCountIs, wrapLanguageModel, tool } from "ai"; import { devToolsMiddleware } from "@ai-sdk/devtools"; import { PostHog } from "posthog-node"; import { withTracing } from "@posthog/ai"; @@ -190,6 +190,8 @@ export async function POST(req: Request) { const system = body.system || ""; workspaceId = extractWorkspaceId(body); activeFolderId = body.activeFolderId; + // AssistantChatTransport passes thread remoteId as body.id (see assistant-ui react-ai-sdk) + const threadId = body.id ?? body.threadId ?? null; // Convert messages let convertedMessages; @@ -203,6 +205,14 @@ export async function POST(req: Request) { throw convertError; } + // Prune older reasoning and tool calls to save context + convertedMessages = pruneMessages({ + messages: convertedMessages, + reasoning: "before-last-message", + toolCalls: "before-last-5-messages", + emptyMessages: "remove", + }); + // Process messages in single pass: extract URLs and clean markers const { urlContextUrls, cleanedMessages } = processMessages(convertedMessages); @@ -210,7 +220,7 @@ export async function POST(req: Request) { const selectedCardsContext = getSelectedCardsContext(body); // Get model ID and ensure it has the correct prefix for Gateway - let modelId = body.modelId || "gemini-3-flash-preview"; + let modelId = body.modelId || "gemini-2.5-flash"; // Auto-prefix with google/ if it looks like a gemini model and lacks prefix // This allows existing client code to work without changes @@ -253,6 +263,7 @@ export async function POST(req: Request) { workspaceId, userId, activeFolderId, + threadId, clientTools: body.tools, enableDeepResearch: false, }); @@ -298,6 +309,7 @@ export async function POST(req: Request) { stopWhen: stepCountIs(25), tools, providerOptions, + experimental_transform: smoothStream({ chunking: "word", delayInMs: 15 }), onFinish: ({ usage, finishReason }) => { const usageInfo = { inputTokens: usage?.inputTokens, diff --git a/src/app/api/deep-research/finalize/route.ts b/src/app/api/deep-research/finalize/route.ts index a5f988dd..9112b88d 100644 --- a/src/app/api/deep-research/finalize/route.ts +++ b/src/app/api/deep-research/finalize/route.ts @@ -46,7 +46,8 @@ export async function POST(req: NextRequest) { const result = await workspaceWorker("update", { workspaceId, itemId, - content: report, // This will be converted via markdownToBlocks + oldString: "", + newString: report, }); if (!result.success) { diff --git a/src/app/api/pdf/ocr/route.ts b/src/app/api/pdf/ocr/route.ts new file mode 100644 index 00000000..11b06b86 --- /dev/null +++ b/src/app/api/pdf/ocr/route.ts @@ -0,0 +1,129 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { headers } from "next/headers"; +import { ocrPdfFromBuffer } from "@/lib/pdf/azure-ocr"; +import { logger } from "@/lib/utils/logger"; + +export const dynamic = "force-dynamic"; +const MAX_PDF_SIZE_BYTES = 100 * 1024 * 1024; // 100 MB + +/** + * POST /api/pdf/ocr + * Receives a PDF file URL (Supabase or local), fetches it, runs Azure Mistral Document AI OCR, + * returns extracted text and page data. + */ +export async function POST(req: NextRequest) { + try { + const session = await auth.api.getSession({ + headers: await headers(), + }); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await req.json(); + const { fileUrl } = body; + + if (!fileUrl || typeof fileUrl !== "string") { + return NextResponse.json( + { error: "fileUrl is required" }, + { status: 400 } + ); + } + + const t0 = Date.now(); + + // Validate URL origin to prevent SSRF + const allowedHosts: string[] = []; + if (process.env.NEXT_PUBLIC_SUPABASE_URL) { + allowedHosts.push(new URL(process.env.NEXT_PUBLIC_SUPABASE_URL).hostname); + } + if (process.env.NEXT_PUBLIC_APP_URL) { + allowedHosts.push(new URL(process.env.NEXT_PUBLIC_APP_URL).hostname); + } + allowedHosts.push("localhost", "127.0.0.1"); + + let parsedUrl: URL; + try { + parsedUrl = new URL(fileUrl); + } catch { + return NextResponse.json({ error: "Invalid fileUrl" }, { status: 400 }); + } + + if ( + !allowedHosts.some( + (host) => + parsedUrl.hostname === host || parsedUrl.hostname.endsWith(`.${host}`) + ) + ) { + return NextResponse.json( + { error: "fileUrl origin is not allowed" }, + { status: 400 } + ); + } + + const res = await fetch(fileUrl); + if (!res.ok) { + return NextResponse.json( + { error: `Failed to fetch PDF: ${res.status} ${res.statusText}` }, + { status: 400 } + ); + } + + const contentType = res.headers.get("content-type") ?? ""; + if ( + !contentType.includes("application/pdf") && + !fileUrl.toLowerCase().includes(".pdf") + ) { + return NextResponse.json( + { error: "URL does not point to a PDF file" }, + { status: 400 } + ); + } + + const contentLength = res.headers.get("content-length"); + if (contentLength && parseInt(contentLength, 10) > MAX_PDF_SIZE_BYTES) { + return NextResponse.json( + { + error: `PDF exceeds ${MAX_PDF_SIZE_BYTES / (1024 * 1024)}MB limit`, + }, + { status: 400 } + ); + } + + const arrayBuffer = await res.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + if (buffer.length > MAX_PDF_SIZE_BYTES) { + return NextResponse.json( + { + error: `PDF exceeds ${MAX_PDF_SIZE_BYTES / (1024 * 1024)}MB limit`, + }, + { status: 400 } + ); + } + + const tOcr = Date.now(); + const result = await ocrPdfFromBuffer(buffer); + const totalMs = Date.now() - t0; + + logger.info("[PDF_OCR] Complete", { + pageCount: result.pages.length, + totalMs, + }); + + return NextResponse.json({ + textContent: result.textContent, + ocrPages: result.pages, + }); + } catch (error: unknown) { + logger.error("[PDF_OCR] Error:", error); + return NextResponse.json( + { + error: + error instanceof Error ? error.message : "OCR processing failed", + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/pdf/ocr/start/route.ts b/src/app/api/pdf/ocr/start/route.ts new file mode 100644 index 00000000..1a4bd667 --- /dev/null +++ b/src/app/api/pdf/ocr/start/route.ts @@ -0,0 +1,87 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { headers } from "next/headers"; +import { start } from "workflow/api"; +import { pdfOcrWorkflow } from "@/workflows/pdf-ocr"; + +export const dynamic = "force-dynamic"; + +/** + * POST /api/pdf/ocr/start + * Validates URL, starts durable PDF OCR workflow, returns runId and itemId for polling. + */ +export async function POST(req: NextRequest) { + try { + const session = await auth.api.getSession({ + headers: await headers(), + }); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await req.json(); + const { fileUrl, itemId } = body; + + if (!fileUrl || typeof fileUrl !== "string") { + return NextResponse.json( + { error: "fileUrl is required" }, + { status: 400 } + ); + } + + if (!itemId || typeof itemId !== "string") { + return NextResponse.json( + { error: "itemId is required for polling" }, + { status: 400 } + ); + } + + // Validate URL origin to prevent SSRF + const allowedHosts: string[] = []; + if (process.env.NEXT_PUBLIC_SUPABASE_URL) { + allowedHosts.push(new URL(process.env.NEXT_PUBLIC_SUPABASE_URL).hostname); + } + if (process.env.NEXT_PUBLIC_APP_URL) { + allowedHosts.push(new URL(process.env.NEXT_PUBLIC_APP_URL).hostname); + } + if (process.env.NODE_ENV === "development") { + allowedHosts.push("localhost", "127.0.0.1"); + } + + let parsedUrl: URL; + try { + parsedUrl = new URL(fileUrl); + } catch { + return NextResponse.json({ error: "Invalid fileUrl" }, { status: 400 }); + } + + if ( + !allowedHosts.some( + (host) => + parsedUrl.hostname === host || parsedUrl.hostname.endsWith(`.${host}`) + ) + ) { + return NextResponse.json( + { error: "fileUrl origin is not allowed" }, + { status: 400 } + ); + } + + // Start durable workflow; return immediately for client to poll + const run = await start(pdfOcrWorkflow, [fileUrl]); + + return NextResponse.json({ + runId: run.runId, + itemId, + }); + } catch (error: unknown) { + console.error("[PDF_OCR_START] Error:", error); + return NextResponse.json( + { + error: + error instanceof Error ? error.message : "Failed to start PDF OCR", + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/pdf/ocr/status/route.ts b/src/app/api/pdf/ocr/status/route.ts new file mode 100644 index 00000000..124a3e69 --- /dev/null +++ b/src/app/api/pdf/ocr/status/route.ts @@ -0,0 +1,75 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { headers } from "next/headers"; +import { getRun } from "workflow/api"; + +export const dynamic = "force-dynamic"; + +/** + * GET /api/pdf/ocr/status?runId=xxx + * Poll workflow status. Returns { status, result? } when completed or { status, error? } when failed. + */ +export async function GET(req: NextRequest) { + try { + const session = await auth.api.getSession({ + headers: await headers(), + }); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const runId = req.nextUrl.searchParams.get("runId"); + if (!runId) { + return NextResponse.json( + { error: "runId is required" }, + { status: 400 } + ); + } + + const run = getRun(runId); + const status = await run.status; + + if (status === "completed") { + const result = (await run.returnValue) as { + textContent: string; + ocrPages: Array<{ + index: number; + markdown: string; + [key: string]: unknown; + }>; + }; + return NextResponse.json({ + status: "completed", + result: { + textContent: result.textContent ?? "", + ocrPages: result.ocrPages ?? [], + }, + }); + } + + if (status === "failed") { + let errorMessage = "OCR failed"; + try { + await run.returnValue; + } catch (err) { + errorMessage = + err instanceof Error ? err.message : String(err ?? "OCR failed"); + } + return NextResponse.json({ + status: "failed", + error: errorMessage, + }); + } + + return NextResponse.json({ status: "running" }); + } catch (error: unknown) { + console.error("[PDF_OCR_STATUS] Error:", error); + return NextResponse.json( + { + error: + error instanceof Error ? error.message : "Failed to get status", + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/share/[id]/route.ts b/src/app/api/share/[id]/route.ts index b1381263..cf05c2b0 100644 --- a/src/app/api/share/[id]/route.ts +++ b/src/app/api/share/[id]/route.ts @@ -30,9 +30,8 @@ export async function GET( const state = await loadWorkspaceState(id); // Ensure state has workspace metadata if empty - if (!state.globalTitle && !state.globalDescription) { + if (!state.globalTitle) { state.globalTitle = workspace[0].name || ""; - state.globalDescription = workspace[0].description || ""; } // Return workspace data for forking (public access) diff --git a/src/app/api/threads/[id]/archive/route.ts b/src/app/api/threads/[id]/archive/route.ts new file mode 100644 index 00000000..ce967f9b --- /dev/null +++ b/src/app/api/threads/[id]/archive/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from "next/server"; +import { setThreadArchived } from "@/lib/api/thread-archive"; + +/** + * POST /api/threads/[id]/archive + * Archive a thread + */ +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + return await setThreadArchived(id, true); + } catch (error) { + if (error instanceof Response) return error; + console.error("[threads] archive error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/threads/[id]/messages/[messageId]/route.ts b/src/app/api/threads/[id]/messages/[messageId]/route.ts new file mode 100644 index 00000000..e3433c61 --- /dev/null +++ b/src/app/api/threads/[id]/messages/[messageId]/route.ts @@ -0,0 +1,75 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/lib/db/client"; +import { chatThreads, chatMessages } from "@/lib/db/schema"; +import { + requireAuth, + verifyWorkspaceAccess, +} from "@/lib/api/workspace-helpers"; +import { eq, and } from "drizzle-orm"; + +async function getThreadAndVerify(threadId: string, userId: string) { + const [thread] = await db + .select() + .from(chatThreads) + .where(eq(chatThreads.id, threadId)) + .limit(1); + + if (!thread) { + throw NextResponse.json({ error: "Thread not found" }, { status: 404 }); + } + + await verifyWorkspaceAccess(thread.workspaceId, userId); + + return thread; +} + +/** + * PATCH /api/threads/[id]/messages/[messageId] + * Update an existing message (e.g. step timestamps/duration from useExternalHistory) + */ +export async function PATCH( + req: NextRequest, + { params }: { params: Promise<{ id: string; messageId: string }> } +) { + try { + const userId = await requireAuth(); + const { id: threadId, messageId } = await params; + const body = await req.json().catch(() => ({})); + const { content } = body; + + if (content === undefined) { + return NextResponse.json( + { error: "content is required" }, + { status: 400 } + ); + } + + await getThreadAndVerify(threadId, userId); + + const [updated] = await db + .update(chatMessages) + .set({ + content: typeof content === "object" ? content : { raw: content }, + }) + .where( + and( + eq(chatMessages.threadId, threadId), + eq(chatMessages.messageId, messageId) + ) + ) + .returning({ messageId: chatMessages.messageId }); + + if (!updated) { + return NextResponse.json({ error: "Message not found" }, { status: 404 }); + } + + return NextResponse.json({ ok: true }); + } catch (error) { + if (error instanceof Response) return error; + console.error("[threads] messages PATCH error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/threads/[id]/messages/route.ts b/src/app/api/threads/[id]/messages/route.ts new file mode 100644 index 00000000..66cbfd84 --- /dev/null +++ b/src/app/api/threads/[id]/messages/route.ts @@ -0,0 +1,115 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/lib/db/client"; +import { chatThreads, chatMessages } from "@/lib/db/schema"; +import { + requireAuth, + verifyWorkspaceAccess, +} from "@/lib/api/workspace-helpers"; +import { eq, and, desc } from "drizzle-orm"; + +async function getThreadAndVerify(id: string, userId: string) { + const [thread] = await db + .select() + .from(chatThreads) + .where(eq(chatThreads.id, id)) + .limit(1); + + if (!thread) { + throw NextResponse.json({ error: "Thread not found" }, { status: 404 }); + } + + await verifyWorkspaceAccess(thread.workspaceId, userId); + + return thread; +} + +/** + * GET /api/threads/[id]/messages?format=ai-sdk/v6 + * Load messages for a thread. Format filter is strict (ai-sdk/v6 only). + */ +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const userId = await requireAuth(); + const { id } = await params; + const { searchParams } = new URL(req.url); + const format = searchParams.get("format") ?? "ai-sdk/v6"; + + await getThreadAndVerify(id, userId); + + const rows = await db + .select() + .from(chatMessages) + .where(and(eq(chatMessages.threadId, id), eq(chatMessages.format, format))) + .orderBy(desc(chatMessages.createdAt)); + + const messages = rows.map((r) => ({ + id: r.messageId, + parent_id: r.parentId, + format: r.format, + content: r.content, + created_at: r.createdAt, + })); + + return NextResponse.json({ messages }); + } catch (error) { + if (error instanceof Response) return error; + console.error("[threads] messages GET error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +/** + * POST /api/threads/[id]/messages + * Append a message to a thread + */ +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const userId = await requireAuth(); + const { id } = await params; + const body = await req.json().catch(() => ({})); + const { messageId, parentId, format, content } = body; + + if (!messageId || !format || content === undefined) { + return NextResponse.json( + { error: "messageId, format, and content are required" }, + { status: 400 } + ); + } + + const thread = await getThreadAndVerify(id, userId); + + await db.insert(chatMessages).values({ + threadId: id, + messageId: String(messageId), + parentId: parentId ?? null, + format: String(format), + content: typeof content === "object" ? content : { raw: content }, + }); + + await db + .update(chatThreads) + .set({ + lastMessageAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }) + .where(eq(chatThreads.id, id)); + + return NextResponse.json({ ok: true }); + } catch (error) { + if (error instanceof Response) return error; + console.error("[threads] messages POST error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/threads/[id]/route.ts b/src/app/api/threads/[id]/route.ts new file mode 100644 index 00000000..f73e0baf --- /dev/null +++ b/src/app/api/threads/[id]/route.ts @@ -0,0 +1,120 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/lib/db/client"; +import { chatThreads } from "@/lib/db/schema"; +import { + requireAuth, + verifyWorkspaceAccess, +} from "@/lib/api/workspace-helpers"; +import { eq } from "drizzle-orm"; + +async function getThreadAndVerify( + id: string, + userId: string, + permission: "viewer" | "editor" = "viewer" +) { + const [thread] = await db + .select() + .from(chatThreads) + .where(eq(chatThreads.id, id)) + .limit(1); + + if (!thread) { + throw NextResponse.json({ error: "Thread not found" }, { status: 404 }); + } + + await verifyWorkspaceAccess(thread.workspaceId, userId, permission); + + return thread; +} + +/** + * GET /api/threads/[id] + * Fetch a single thread + */ +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const userId = await requireAuth(); + const { id } = await params; + + const thread = await getThreadAndVerify(id, userId); + + return NextResponse.json({ + id: thread.id, + remoteId: thread.id, + status: thread.isArchived ? "archived" : "regular", + title: thread.title ?? undefined, + externalId: thread.externalId ?? undefined, + }); + } catch (error) { + if (error instanceof Response) return error; + console.error("[threads] GET [id] error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +/** + * PATCH /api/threads/[id] + * Update thread (e.g. rename) + */ +export async function PATCH( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const userId = await requireAuth(); + const { id } = await params; + const body = await req.json().catch(() => ({})); + const { title } = body; + + await getThreadAndVerify(id, userId, "editor"); + + if (title !== undefined) { + await db + .update(chatThreads) + .set({ title: String(title), updatedAt: new Date().toISOString() }) + .where(eq(chatThreads.id, id)); + } + + return new Response(null, { status: 204 }); + } catch (error) { + if (error instanceof Response) return error; + console.error("[threads] PATCH error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +/** + * DELETE /api/threads/[id] + * Delete a thread (and its messages via cascade) + */ +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const userId = await requireAuth(); + const { id } = await params; + + await getThreadAndVerify(id, userId, "editor"); + + await db.delete(chatThreads).where(eq(chatThreads.id, id)); + + return new Response(null, { status: 204 }); + } catch (error) { + if (error instanceof Response) return error; + console.error("[threads] DELETE error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/threads/[id]/title/route.ts b/src/app/api/threads/[id]/title/route.ts new file mode 100644 index 00000000..24b05d24 --- /dev/null +++ b/src/app/api/threads/[id]/title/route.ts @@ -0,0 +1,101 @@ +import { NextRequest, NextResponse } from "next/server"; +import { google } from "@ai-sdk/google"; +import { generateText } from "ai"; +import { db } from "@/lib/db/client"; +import { chatThreads } from "@/lib/db/schema"; +import { + requireAuth, + verifyWorkspaceAccess, +} from "@/lib/api/workspace-helpers"; +import { eq } from "drizzle-orm"; + +/** Model ID used for processFiles and other lightweight tasks */ +const GEMINI_FLASH_LITE_MODEL = "gemini-2.5-flash-lite"; + +function extractTextFromMessage(msg: { content?: unknown[] }): string { + if (!msg.content || !Array.isArray(msg.content)) return ""; + return (msg.content as { type?: string; text?: string }[]) + .filter((c) => c.type === "text") + .map((c) => c.text ?? "") + .join(" ") + .trim(); +} + +/** + * POST /api/threads/[id]/title + * Generate a title from messages using Gemini Flash Lite (same model as processFiles). + * Body: { messages: ThreadMessage[] } + */ +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const userId = await requireAuth(); + const { id } = await params; + const body = await req.json().catch(() => ({})); + const { messages } = body; + + const [thread] = await db + .select() + .from(chatThreads) + .where(eq(chatThreads.id, id)) + .limit(1); + + if (!thread) { + return NextResponse.json({ error: "Thread not found" }, { status: 404 }); + } + + await verifyWorkspaceAccess(thread.workspaceId, userId); + + let title = "New Chat"; + + if (messages && Array.isArray(messages) && messages.length > 0) { + const conversationText = messages + .slice(0, 6) + .map((m: { role?: string; content?: unknown[] }) => { + const text = extractTextFromMessage(m); + if (!text) return ""; + const role = m.role === "user" ? "User" : "Assistant"; + return `${role}: ${text}`; + }) + .filter(Boolean) + .join("\n\n"); + + if (conversationText.trim()) { + try { + const { text } = await generateText({ + model: google(GEMINI_FLASH_LITE_MODEL), + system: `Generate a very short chat title (2-6 words) that captures the topic. Output ONLY the title, no quotes or punctuation.`, + prompt: `Conversation:\n\n${conversationText}\n\nTitle:`, + }); + const generated = text.trim().slice(0, 60); + if (generated) title = generated; + } catch (err) { + console.warn("[threads] title Gemini fallback:", err); + const firstUser = messages.find( + (m: { role?: string }) => m.role === "user" + ); + const fallback = extractTextFromMessage(firstUser ?? {}); + if (fallback) { + title = fallback.slice(0, 50) + (fallback.length > 50 ? "..." : ""); + } + } + } + } + + await db + .update(chatThreads) + .set({ title }) + .where(eq(chatThreads.id, id)); + + return NextResponse.json({ title }); + } catch (error) { + if (error instanceof Response) return error; + console.error("[threads] title error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/threads/[id]/unarchive/route.ts b/src/app/api/threads/[id]/unarchive/route.ts new file mode 100644 index 00000000..8673c06d --- /dev/null +++ b/src/app/api/threads/[id]/unarchive/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from "next/server"; +import { setThreadArchived } from "@/lib/api/thread-archive"; + +/** + * POST /api/threads/[id]/unarchive + * Unarchive a thread + */ +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + return await setThreadArchived(id, false); + } catch (error) { + if (error instanceof Response) return error; + console.error("[threads] unarchive error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/threads/route.ts b/src/app/api/threads/route.ts new file mode 100644 index 00000000..63103a14 --- /dev/null +++ b/src/app/api/threads/route.ts @@ -0,0 +1,106 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/lib/db/client"; +import { chatThreads } from "@/lib/db/schema"; +import { + requireAuth, + verifyWorkspaceAccess, +} from "@/lib/api/workspace-helpers"; +import { eq, and, desc } from "drizzle-orm"; + +/** + * GET /api/threads?workspaceId=xxx + * List threads for a workspace + */ +export async function GET(req: NextRequest) { + try { + const userId = await requireAuth(); + const { searchParams } = new URL(req.url); + const workspaceId = searchParams.get("workspaceId"); + + if (!workspaceId) { + return NextResponse.json( + { error: "workspaceId is required" }, + { status: 400 } + ); + } + + await verifyWorkspaceAccess(workspaceId, userId); + + const threads = await db + .select({ + id: chatThreads.id, + title: chatThreads.title, + isArchived: chatThreads.isArchived, + externalId: chatThreads.externalId, + }) + .from(chatThreads) + .where(eq(chatThreads.workspaceId, workspaceId)) + .orderBy(desc(chatThreads.lastMessageAt)); + + return NextResponse.json({ + threads: threads.map((t) => ({ + remoteId: t.id, + status: t.isArchived ? "archived" : "regular", + title: t.title ?? undefined, + externalId: t.externalId ?? undefined, + })), + }); + } catch (error) { + if (error instanceof Response) return error; + console.error("[threads] GET error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +/** + * POST /api/threads + * Create a new thread + */ +export async function POST(req: NextRequest) { + try { + const userId = await requireAuth(); + const body = await req.json().catch(() => ({})); + const { workspaceId, localId, externalId } = body; + + if (!workspaceId) { + return NextResponse.json( + { error: "workspaceId is required" }, + { status: 400 } + ); + } + + await verifyWorkspaceAccess(workspaceId, userId, "editor"); + + const [inserted] = await db + .insert(chatThreads) + .values({ + workspaceId, + userId, + externalId: externalId ?? undefined, + }) + .returning({ id: chatThreads.id, externalId: chatThreads.externalId }); + + if (!inserted) { + return NextResponse.json( + { error: "Failed to create thread" }, + { status: 500 } + ); + } + + return NextResponse.json({ + id: inserted.id, + remoteId: inserted.id, + externalId: inserted.externalId ?? undefined, + }); + } catch (error) { + if (error instanceof Response) return error; + console.error("[threads] POST error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/workspaces/[id]/events/route.ts b/src/app/api/workspaces/[id]/events/route.ts index bf80ab52..945b3b77 100644 --- a/src/app/api/workspaces/[id]/events/route.ts +++ b/src/app/api/workspaces/[id]/events/route.ts @@ -1,6 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; import type { WorkspaceEvent, EventResponse } from "@/lib/workspace/events"; import { checkAndCreateSnapshot } from "@/lib/workspace/snapshot-manager"; +import { loadWorkspaceState } from "@/lib/workspace/state-loader"; +import { hasDuplicateName } from "@/lib/workspace/unique-name"; import { db, workspaceEvents } from "@/lib/db/client"; import { eq, gt, asc, sql, and } from "drizzle-orm"; import { requireAuth, verifyWorkspaceAccess, withErrorHandling } from "@/lib/api/workspace-helpers"; @@ -232,6 +234,37 @@ async function handlePOST( await verifyWorkspaceAccess(id, userId, 'editor'); timings.workspaceCheck = Date.now() - workspaceCheckStart; + // Validate unique name for ITEM_CREATED and ITEM_UPDATED (when name changes) + if (event.type === "ITEM_CREATED") { + const item = event.payload?.item; + if (item?.name != null && item?.type) { + const state = await loadWorkspaceState(id); + if (hasDuplicateName(state.items, item.name, item.type, item.folderId ?? null)) { + return NextResponse.json( + { error: `A ${item.type} named "${item.name}" already exists in this folder` }, + { status: 400 } + ); + } + } + } + if (event.type === "ITEM_UPDATED" && event.payload?.changes?.name != null) { + const itemId = event.payload?.id; + const newName = event.payload.changes.name; + if (itemId && newName) { + const state = await loadWorkspaceState(id); + const existingItem = state.items.find((i: { id: string }) => i.id === itemId); + if (existingItem) { + const newFolderId = event.payload.changes.folderId ?? existingItem.folderId ?? null; + if (hasDuplicateName(state.items, newName, existingItem.type, newFolderId, itemId)) { + return NextResponse.json( + { error: `A ${existingItem.type} named "${newName}" already exists in this folder` }, + { status: 400 } + ); + } + } + } + } + // Use the append function to handle versioning and conflicts const appendStart = Date.now(); const result = await db.execute(sql` diff --git a/src/app/api/workspaces/[id]/route.ts b/src/app/api/workspaces/[id]/route.ts index 2453bce4..6e8f48c6 100644 --- a/src/app/api/workspaces/[id]/route.ts +++ b/src/app/api/workspaces/[id]/route.ts @@ -39,9 +39,8 @@ async function handleGET( const state = await loadWorkspaceState(id); // Ensure state has workspace metadata if empty - if (!state.globalTitle && !state.globalDescription) { + if (!state.globalTitle) { state.globalTitle = workspace.name || ""; - state.globalDescription = workspace.description || ""; } return NextResponse.json({ diff --git a/src/app/api/workspaces/route.ts b/src/app/api/workspaces/route.ts index f4df5270..26866aa9 100644 --- a/src/app/api/workspaces/route.ts +++ b/src/app/api/workspaces/route.ts @@ -218,19 +218,14 @@ async function handlePOST(request: NextRequest) { // Use provided initial state (already validated on client side) initialState = customInitialState; initialState.workspaceId = workspace.id; - // Ensure globalTitle and globalDescription are set from workspace metadata if (!initialState.globalTitle) { initialState.globalTitle = name; } - if (!initialState.globalDescription && description) { - initialState.globalDescription = description; - } } else { // Use template-based initial state initialState = getTemplateInitialState(effectiveTemplate); initialState.workspaceId = workspace.id; initialState.globalTitle = name; - initialState.globalDescription = description || ""; } // Note: No need to create workspace_states record - state is now managed via events diff --git a/src/app/api/workspaces/slug/[slug]/route.ts b/src/app/api/workspaces/slug/[slug]/route.ts index 1ee9580c..7945148d 100644 --- a/src/app/api/workspaces/slug/[slug]/route.ts +++ b/src/app/api/workspaces/slug/[slug]/route.ts @@ -103,9 +103,8 @@ async function handleGET( const state = await loadWorkspaceState(workspace.id); // Ensure state has workspace metadata if empty - if (!state.globalTitle && !state.globalDescription) { + if (!state.globalTitle) { state.globalTitle = workspace.name || ""; - state.globalDescription = workspace.description || ""; } return NextResponse.json({ diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 5c604378..2aac1be3 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -43,8 +43,8 @@ import { useWorkspaceInstructionModal } from "@/hooks/workspace/use-workspace-in import { InviteGuard } from "@/components/workspace/InviteGuard"; import { useReactiveNavigation } from "@/hooks/ui/use-reactive-navigation"; -import { uploadFileDirect } from "@/lib/uploads/client-upload"; import { filterPasswordProtectedPdfs } from "@/lib/uploads/pdf-validation"; +import { uploadPdfToStorage } from "@/lib/uploads/pdf-upload-with-ocr"; import { emitPasswordProtectedPdf } from "@/components/modals/PasswordProtectedPdfDialog"; import { useFolderUrl } from "@/hooks/ui/use-folder-url"; import { OPEN_RECORD_PARAM } from "@/components/modals/RecordWorkspaceDialog"; @@ -296,10 +296,9 @@ function DashboardContent({ const getStatePreviewJSON = (s: AgentState | undefined): Record => { const snapshot = (s ?? initialState) as AgentState; - const { globalTitle, globalDescription, items } = snapshot; + const { globalTitle, items } = snapshot; return { globalTitle: globalTitle ?? initialState.globalTitle, - globalDescription: globalDescription ?? initialState.globalDescription, items: items ?? initialState.items, }; }; @@ -345,35 +344,104 @@ function DashboardContent({ return; } - const uploadPromises = unprotectedFiles.map(async (file) => { - const { url: fileUrl, filename } = await uploadFileDirect(file); + const uploadToastId = toast.loading( + `Uploading ${unprotectedFiles.length} PDF${unprotectedFiles.length > 1 ? "s" : ""}...`, + { style: { color: "#fff" } } + ); + + const uploadResults = await Promise.all( + unprotectedFiles.map(async (file) => { + try { + const { url, filename, fileSize } = await uploadPdfToStorage(file); + return { file, fileUrl: url, filename, fileSize }; + } catch (err) { + toast.error(`Failed to upload ${file.name}: ${err instanceof Error ? err.message : "Unknown error"}`); + return null; + } + }) + ); - return { - fileUrl, - filename: filename || file.name, - fileSize: file.size, - name: file.name.replace(/\.pdf$/i, ""), - }; - }); + toast.dismiss(uploadToastId); - const uploadResults = await Promise.all(uploadPromises); - const pdfCardDefinitions = uploadResults.map((result) => { - const pdfData: Partial = { - fileUrl: result.fileUrl, - filename: result.filename, - fileSize: result.fileSize, - }; - - return { - type: "pdf" as const, - name: result.name, - initialData: pdfData, - }; - }); + const validUploads = uploadResults.filter((r): r is NonNullable => r !== null); + if (validUploads.length === 0) return; + + const pdfCardDefinitions = validUploads.map(({ file, fileUrl, filename, fileSize }) => ({ + type: "pdf" as const, + name: file.name.replace(/\.pdf$/i, ""), + initialData: { + fileUrl, + filename, + fileSize, + ocrStatus: "processing" as const, + ocrPages: [], + } as Partial, + })); - // Create all PDF cards and navigate to the first one const createdIds = operations.createItems(pdfCardDefinitions); handleCreatedItems(createdIds); + + // Run OCR via workflow; poller dispatches pdf-processing-complete + validUploads.forEach((r, i) => { + const itemId = createdIds[i]; + if (!itemId) return; + fetch("/api/pdf/ocr/start", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ fileUrl: r.fileUrl, itemId }), + }) + .then(async (res) => { + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })); + throw new Error((err as { error?: string }).error ?? `OCR start failed: ${res.status}`); + } + return res.json(); + }) + .then((data) => { + if (data.runId && data.itemId) { + import("@/lib/pdf/poll-pdf-ocr") + .then(({ pollPdfOcr }) => pollPdfOcr(data.runId, data.itemId)) + .catch((err) => { + window.dispatchEvent( + new CustomEvent("pdf-processing-complete", { + detail: { + itemId, + textContent: "", + ocrPages: [], + ocrStatus: "failed" as const, + ocrError: err instanceof Error ? err.message : "Failed to start OCR polling", + }, + }) + ); + }); + } else { + window.dispatchEvent( + new CustomEvent("pdf-processing-complete", { + detail: { + itemId, + textContent: "", + ocrPages: [], + ocrStatus: "failed" as const, + ocrError: data.error || "Failed to start OCR", + }, + }) + ); + } + }) + .catch((err) => { + window.dispatchEvent( + new CustomEvent("pdf-processing-complete", { + detail: { + itemId, + textContent: "", + ocrPages: [], + ocrStatus: "failed" as const, + ocrError: err.message || "Failed to start OCR", + }, + }) + ); + }); + }); }, [operations, currentWorkspaceId, handleCreatedItems] ); diff --git a/src/app/globals.css b/src/app/globals.css index 9b8c8253..4a37db2c 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -208,6 +208,20 @@ overflow-wrap: break-word !important; } +/* Prosemirror-search citation highlight - theme-aware */ +.ProseMirror-search-match { + background-color: rgba(234, 179, 8, 0.35); +} +.ProseMirror-active-search-match { + background-color: rgba(234, 179, 8, 0.55); +} +.dark .ProseMirror-search-match { + background-color: rgba(251, 191, 36, 0.35); +} +.dark .ProseMirror-active-search-match { + background-color: rgba(251, 191, 36, 0.5); +} + /* Fix BlockNote formatting toolbar display - compact version */ .bn-formatting-toolbar { min-height: 1.75rem !important; @@ -603,6 +617,24 @@ html { } } +/* Home action card icon hover animations - unified 0.8s ease-in-out, moderate intensity */ +@keyframes icon-upload-bounce { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-3px); } +} +@keyframes icon-link-pulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(0.92); } +} +@keyframes icon-link-sway { + 0%, 100% { transform: rotate(-4deg); } + 50% { transform: rotate(4deg); } +} +@keyframes icon-paste-pop { + 0%, 100% { transform: translateX(0); } + 50% { transform: translateX(2px); } +} + /* Subtle pulse animation for the add button */ .animate-pulse-subtle { animation: pulseSubtle 2s ease-in-out infinite; @@ -1522,4 +1554,21 @@ body:has(.card-detail-modal) .workspace-grid-container { to { opacity: 1; } +} + +/* User message breathe-in animation */ +@keyframes breatheIn { + from { + opacity: 0; + transform: scale(0.7); + } + to { + opacity: 1; + transform: scale(1); + } +} + +.animate-breathe-in { + transform-origin: right center; + animation: breatheIn 0.7s cubic-bezier(0.16, 1.1, 0.3, 1.02) forwards; } \ No newline at end of file diff --git a/src/app/share-copy/[id]/layout.tsx b/src/app/share-copy/[id]/layout.tsx index 7828e3e1..cbbebad5 100644 --- a/src/app/share-copy/[id]/layout.tsx +++ b/src/app/share-copy/[id]/layout.tsx @@ -31,7 +31,7 @@ export async function generateMetadata( const state = await loadWorkspaceState(id); const title = state.globalTitle || workspace[0].name || "Untitled Workspace"; - const description = state.globalDescription || workspace[0].description || "View and import this shared ThinkEx workspace."; + const description = workspace[0].description || "View and import this shared ThinkEx workspace."; return { title: `Shared Workspace: ${title}`, diff --git a/src/components/ai-elements/inline-citation.tsx b/src/components/ai-elements/inline-citation.tsx new file mode 100644 index 00000000..4f5af2cb --- /dev/null +++ b/src/components/ai-elements/inline-citation.tsx @@ -0,0 +1,200 @@ +"use client"; + +import type { ComponentProps, ReactNode } from "react"; +import { ExternalLink } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; + +export type InlineCitationProps = ComponentProps<"span">; + +export const InlineCitation = ({ + className, + ...props +}: InlineCitationProps) => ( + +); + +export type InlineCitationTextProps = ComponentProps<"span">; + +export const InlineCitationText = ({ + className, + ...props +}: InlineCitationTextProps) => ( + +); + +export type InlineCitationCardProps = ComponentProps; + +export const InlineCitationCard = (props: InlineCitationCardProps) => ( + +); + +export type InlineCitationCardTriggerProps = ComponentProps & { + sources: string[]; + /** Shown when sources is empty (e.g. during streaming) */ + fallbackLabel?: string; +}; + +function getBadgeLabel(sources: string[], fallbackLabel: string): ReactNode { + if (!sources[0]) return fallbackLabel; + try { + const url = new URL(sources[0]); + return ( + <> + {url.hostname}{" "} + {sources.length > 1 && `+${sources.length - 1}`} + + ); + } catch { + return fallbackLabel; + } +} + +export const InlineCitationCardTrigger = ({ + sources, + fallbackLabel = "unknown", + className, + ...props +}: InlineCitationCardTriggerProps) => ( + + + {getBadgeLabel(sources, fallbackLabel)} + + +); + +export type InlineCitationCardBodyProps = ComponentProps<"div">; + +export const InlineCitationCardBody = ({ + className, + ...props +}: InlineCitationCardBodyProps) => ( + +); + +export type InlineCitationSourceProps = ComponentProps<"div"> & { + title?: string; + url?: string; + /** When set (and no url), makes content clickable (e.g. open workspace note) */ + onClick?: () => void; +}; + +export const InlineCitationSource = ({ + title, + url, + onClick, + className, + children, + ...props +}: InlineCitationSourceProps) => { + const content = ( + <> + {title && ( +

{title}

+ )} + {url && ( +

{url}

+ )} + {children} + + ); + + const clickable = url || onClick; + const baseClasses = cn( + "space-y-1 block w-full text-left min-h-0", + clickable && "cursor-pointer transition-colors hover:bg-muted/50 rounded-md" + ); + + if (url) { + return ( + )} + > + {content} + + ); + } + if (onClick) { + return ( + + ); + } + return ( +
+ {content} +
+ ); +}; + +export type InlineCitationQuoteProps = ComponentProps<"blockquote">; + +export const InlineCitationQuote = ({ + children, + className, + ...props +}: InlineCitationQuoteProps) => ( +
+ {children} +
+); + +function extractDomain(url: string): string { + try { + return new URL(url).hostname.replace(/^www\./, ""); + } catch { + return url; + } +} + +/** + * SurfSense-style URL citation: clickable badge with domain, opens in new tab. + * Used for [citation:https://...] refs. + */ +export function UrlCitation({ url }: { url: string }) { + const domain = extractDomain(url); + return ( + + + {domain} + + ); +} diff --git a/src/components/assistant-ui/FileProcessingToolUI.tsx b/src/components/assistant-ui/FileProcessingToolUI.tsx index 69e7d1f1..5a721994 100644 --- a/src/components/assistant-ui/FileProcessingToolUI.tsx +++ b/src/components/assistant-ui/FileProcessingToolUI.tsx @@ -1,9 +1,10 @@ "use client"; -import { FileIcon, ChevronDownIcon, CheckIcon, ExternalLinkIcon, AlertCircleIcon, FileTextIcon, VideoIcon, ImageIcon } from "lucide-react"; +import { FileIcon, ChevronDownIcon, FileTextIcon, VideoIcon, ImageIcon } from "lucide-react"; import { memo, useCallback, + useEffect, useRef, useState, type FC, @@ -15,6 +16,8 @@ import { makeAssistantToolUI, } from "@assistant-ui/react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useWorkspaceStore } from "@/lib/stores/workspace-store"; import { StandaloneMarkdown } from "@/components/assistant-ui/standalone-markdown"; import { ToolUIErrorBoundary } from "@/components/tool-ui/shared"; import { parseStringResult } from "@/lib/ai/tool-result-schemas"; @@ -29,6 +32,7 @@ import { Badge } from "@/components/ui/badge"; const ANIMATION_DURATION = 200; const SHIMMER_DURATION = 1000; +const MAX_DISPLAY_CHARS = 800; /** * Root collapsible container that manages open/closed state and scroll lock. @@ -221,11 +225,28 @@ function getFileType(url: string): { type: 'video' | 'pdf' | 'image' | 'document export const FileProcessingToolUI = makeAssistantToolUI<{ urls?: string[]; fileNames?: string[]; - instruction?: string; + pdfImageRefs?: Array<{ pdfName: string; imageId: string }>; forceReprocess?: boolean; }, string>({ toolName: "processFiles", render: function FileProcessingToolUI({ args, status, result }) { + const queryClient = useQueryClient(); + const workspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); + + // When processFiles completes with workspace file names, OCR may have been persisted + // server-side — invalidate events so the client refetches and shows updated PDF content + useEffect(() => { + if ( + status.type === "complete" && + workspaceId && + (args?.fileNames?.length ?? 0) > 0 + ) { + queryClient.invalidateQueries({ + queryKey: ["workspace", workspaceId, "events"], + }); + } + }, [status.type, workspaceId, args?.fileNames?.length, queryClient]); + // Client-side debugging if (typeof window !== 'undefined') { console.debug("📁 [FILE_TOOL_UI] Component render:", { @@ -243,17 +264,17 @@ export const FileProcessingToolUI = makeAssistantToolUI<{ // Parse args let urls: string[] = []; - let instruction: string | undefined; + let pdfImageRefs: Array<{ pdfName: string; imageId: string }> = []; // Use the actual tool parameters if (args?.urls && Array.isArray(args.urls)) { urls = args.urls; } - if (args?.instruction) { - instruction = args.instruction; + if (args?.pdfImageRefs && Array.isArray(args.pdfImageRefs)) { + pdfImageRefs = args.pdfImageRefs; } - const fileCount = urls.length; + const fileCount = urls.length + (args?.fileNames?.length ?? 0) + pdfImageRefs.length; // Debug parsed data if (typeof window !== 'undefined') { @@ -261,8 +282,6 @@ export const FileProcessingToolUI = makeAssistantToolUI<{ fileCount, urls, fileNames: args?.fileNames, - instruction, - forceReprocess: args?.forceReprocess, resultLength: result?.length || 0, isRunning, isComplete, @@ -286,23 +305,12 @@ export const FileProcessingToolUI = makeAssistantToolUI<{ Files: - {instruction && ( -
-
- -
- Custom instruction: -

{instruction}

-
-
-
- )}
{urls.map((url, index) => { const fileInfo = getFileType(url); const filename = url.split('/').pop() || url; return ( - )} @@ -334,7 +362,11 @@ export const FileProcessingToolUI = makeAssistantToolUI<{ {isComplete && parsedResult != null && parsedResult.trim() && (
- {parsedResult} + + {parsedResult.length > MAX_DISPLAY_CHARS + ? `${parsedResult.slice(0, MAX_DISPLAY_CHARS)}\n\n*…truncated for display*` + : parsedResult} +
)} diff --git a/src/components/assistant-ui/ReadWorkspaceToolUI.tsx b/src/components/assistant-ui/ReadWorkspaceToolUI.tsx new file mode 100644 index 00000000..9ae75971 --- /dev/null +++ b/src/components/assistant-ui/ReadWorkspaceToolUI.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { Eye } from "lucide-react"; +import { makeAssistantToolUI } from "@assistant-ui/react"; +import { ToolUIErrorBoundary } from "@/components/tool-ui/shared"; +import { ToolUILoadingShell } from "@/components/assistant-ui/tool-ui-loading-shell"; +import { ToolUIErrorShell } from "@/components/assistant-ui/tool-ui-error-shell"; + +type ReadArgs = { path?: string; itemName?: string; pageStart?: number; pageEnd?: number }; +type ReadResult = { + success: boolean; + itemName?: string; + type?: string; + path?: string; + content?: string; + message?: string; + totalPages?: number; + pageRange?: { start?: number; end?: number }; +}; + +function stripExtension(s: string): string { + return s.replace(/\.[^.]+$/, ""); +} + +export const ReadWorkspaceToolUI = makeAssistantToolUI({ + toolName: "readWorkspace", + render: ({ args, status, result }) => { + let content: React.ReactNode = null; + + if (status.type === "running") { + const pageInfo = + args?.pageStart != null && args?.pageEnd != null + ? ` (pages ${args.pageStart}-${args.pageEnd})` + : args?.pageStart != null + ? ` (from page ${args.pageStart})` + : args?.pageEnd != null + ? ` (to page ${args.pageEnd})` + : ""; + const label = args?.path + ? `Reading ${stripExtension(args.path)}${pageInfo}` + : args?.itemName + ? `Reading "${args.itemName}"${pageInfo}` + : "Reading workspace item..."; + content = ; + } else if (status.type === "complete" && result) { + if (!result.success && result.message) { + content = ( + + ); + } else if (result.success) { + const pageLabel = + result.pageRange?.start != null && result.pageRange?.end != null + ? ` pages ${result.pageRange.start}-${result.pageRange.end}` + : result.totalPages != null + ? ` (${result.totalPages} pages)` + : ""; + content = ( +
+
+ + + Read -{" "} + {result.path + ? stripExtension(result.path) + : result.itemName} + {pageLabel} + {result.type && ( + + {result.type} + + )} + +
+
+ ); + } + } else if (status.type === "incomplete" && status.reason === "error") { + content = ( + + ); + } + + return ( + + {content} + + ); + }, +}); diff --git a/src/components/assistant-ui/SearchWorkspaceToolUI.tsx b/src/components/assistant-ui/SearchWorkspaceToolUI.tsx new file mode 100644 index 00000000..253ab5de --- /dev/null +++ b/src/components/assistant-ui/SearchWorkspaceToolUI.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { Search } from "lucide-react"; +import { makeAssistantToolUI } from "@assistant-ui/react"; +import { ToolUIErrorBoundary } from "@/components/tool-ui/shared"; +import { ToolUILoadingShell } from "@/components/assistant-ui/tool-ui-loading-shell"; +import { ToolUIErrorShell } from "@/components/assistant-ui/tool-ui-error-shell"; + +type GrepArgs = { pattern: string; include?: string; path?: string }; +type GrepResult = { success: boolean; matches?: number; output?: string; message?: string }; + +const MAX_EXCERPTS = 5; +const MAX_EXCERPT_LEN = 80; + +function stripExtension(s: string): string { + return s.replace(/\.[^.]+$/, ""); +} + +function parseExcerpts(output: string): { path: string; excerpt: string }[] { + const items: { path: string; excerpt: string }[] = []; + const lines = output.split("\n"); + let currentPath = ""; + + for (const line of lines) { + if (line.match(/^Found \d+ matches/) || line === "" || line.startsWith("(Results truncated")) continue; + if (line.endsWith(":") && !line.startsWith(" ")) { + currentPath = stripExtension(line.slice(0, -1).trim()); + } else if (line.startsWith(" Line ") && currentPath) { + const match = line.match(/^ Line \d+: (.+)$/); + const excerpt = match ? match[1].trim() : line.replace(/^ Line \d+: /, "").trim(); + const truncated = excerpt.length > MAX_EXCERPT_LEN ? excerpt.slice(0, MAX_EXCERPT_LEN) + "…" : excerpt; + items.push({ path: currentPath, excerpt: truncated }); + if (items.length >= MAX_EXCERPTS) break; + } + } + return items; +} + +export const SearchWorkspaceToolUI = makeAssistantToolUI({ + toolName: "searchWorkspace", + render: ({ status, result }) => { + let content: React.ReactNode = null; + + if (status.type === "running") { + content = ; + } else if (status.type === "complete" && result) { + if (!result.success && result.message) { + content = ( + + ); + } else if (result.success && result.output) { + const isNoMatches = result.output.startsWith("No matches found"); + const excerpts = isNoMatches ? [] : parseExcerpts(result.output); + + content = ( +
+
+ + + Search -{" "} + {result.matches != null + ? `${result.matches} match${result.matches !== 1 ? "es" : ""}` + : "completed"} + +
+ {excerpts.length > 0 && ( +
    + {excerpts.map((item, i) => ( +
  • + {item.path} + {item.excerpt} +
  • + ))} +
+ )} +
+ ); + } else if (result.success) { + content = ( +
+ + + Search -{" "} + {result.matches != null + ? `${result.matches} match${result.matches !== 1 ? "es" : ""}` + : "completed"} + +
+ ); + } + } else if (status.type === "incomplete" && status.reason === "error") { + content = ( + + ); + } + + return ( + + {content} + + ); + }, +}); diff --git a/src/components/assistant-ui/UpdateNoteToolUI.tsx b/src/components/assistant-ui/UpdateNoteToolUI.tsx index a32b67db..140e71f4 100644 --- a/src/components/assistant-ui/UpdateNoteToolUI.tsx +++ b/src/components/assistant-ui/UpdateNoteToolUI.tsx @@ -1,7 +1,7 @@ "use client"; import type { ReactNode } from "react"; -import { useEffect, useMemo } from "react"; +import { useMemo } from "react"; import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state"; import { makeAssistantToolUI } from "@assistant-ui/react"; import { useOptimisticToolUpdate } from "@/hooks/ai/use-optimistic-tool-update"; @@ -14,14 +14,20 @@ import { useNavigateToItem } from "@/hooks/ui/use-navigate-to-item"; import { ToolUIErrorBoundary } from "@/components/tool-ui/shared"; import { ToolUILoadingShell } from "@/components/assistant-ui/tool-ui-loading-shell"; import { ToolUIErrorShell } from "@/components/assistant-ui/tool-ui-error-shell"; +import { DiffViewer } from "@/components/assistant-ui/diff-viewer"; import type { WorkspaceResult } from "@/lib/ai/tool-result-schemas"; import { parseWorkspaceResult } from "@/lib/ai/tool-result-schemas"; -type UpdateNoteArgs = { noteName: string; content: string }; +type UpdateNoteArgs = { noteName: string; oldString: string; newString: string; replaceAll?: boolean }; + +interface UpdateNoteResult extends WorkspaceResult { + diff?: string; + filediff?: { additions: number; deletions: number }; +} interface UpdateNoteReceiptProps { args: UpdateNoteArgs; - result: WorkspaceResult; + result: UpdateNoteResult; status: any; } @@ -45,52 +51,67 @@ const UpdateNoteReceipt = ({ args, result, status }: UpdateNoteReceiptProps) => } }; + const hasDiff = result.diff && result.diff.trim().length > 0; + return ( -
-
-
- {status?.type === "complete" ? ( - - ) : ( - - )} -
-
- - {status?.type === "complete" ? (card?.name || "Card Updated") : "Update Cancelled"} - - {status?.type === "complete" && ( - - Note updated +
+
+
+
+ {status?.type === "complete" ? ( + + ) : ( + + )} +
+
+ + {status?.type === "complete" ? (card?.name || "Card Updated") : "Update Cancelled"} + {status?.type === "complete" && ( + Note updated + )} +
+
+ +
+ {status?.type === "complete" && result.itemId && ( + )}
-
- {status?.type === "complete" && result.itemId && ( - - )} -
+ {hasDiff && ( + + )}
); }; @@ -117,7 +138,7 @@ export const UpdateNoteToolUI = makeAssistantToolUI; + content = ; } else if (status.type === "running") { content = ; } else if (status.type === "complete" && parsed && !parsed.success) { diff --git a/src/components/assistant-ui/UpdatePdfContentToolUI.tsx b/src/components/assistant-ui/UpdatePdfContentToolUI.tsx deleted file mode 100644 index 1c158f00..00000000 --- a/src/components/assistant-ui/UpdatePdfContentToolUI.tsx +++ /dev/null @@ -1,8 +0,0 @@ -"use client"; - -import { makeAssistantToolUI } from "@assistant-ui/react"; - -export const UpdatePdfContentToolUI = makeAssistantToolUI({ - toolName: "updatePdfContent", - render: () => null, -}); diff --git a/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx b/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx index 6bc81b6f..a08120a2 100644 --- a/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx +++ b/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx @@ -1,15 +1,18 @@ "use client"; -import { AssistantCloud, AssistantRuntimeProvider } from "@assistant-ui/react"; +import { + AssistantRuntimeProvider, + unstable_useRemoteThreadListRuntime as useRemoteThreadListRuntime, +} from "@assistant-ui/react"; import { useChatRuntime, AssistantChatTransport } from "@assistant-ui/react-ai-sdk"; import { useMemo, useCallback } from "react"; import { AssistantAvailableProvider } from "@/contexts/AssistantAvailabilityContext"; import { useUIStore } from "@/lib/stores/ui-store"; -import { useSession } from "@/lib/auth-client"; import { toast } from "sonner"; import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state"; -import { formatSelectedCardsContext } from "@/lib/utils/format-workspace-context"; -import { SupabaseAttachmentAdapter } from "@/lib/attachments/supabase-attachment-adapter"; +import { formatSelectedCardsMetadata } from "@/lib/utils/format-workspace-context"; +import { createThreadListAdapter } from "@/lib/chat/custom-thread-list-adapter"; +import { toCreateMessageWithContext } from "@/lib/chat/toCreateMessageWithContext"; interface WorkspaceRuntimeProviderProps { workspaceId: string; @@ -23,23 +26,12 @@ export function WorkspaceRuntimeProvider({ workspaceId, children }: WorkspaceRuntimeProviderProps) { - // ... existing hooks - - const selectedModelId = useUIStore((state) => state.selectedModelId); const activeFolderId = useUIStore((state) => state.activeFolderId); - /* - FIX for "Maximum update depth exceeded": - We select the Set directly because Array.from() inside the selector creates a new reference on every render, - triggering an infinite update loop. The Set reference is stable until changed. - */ const selectedCardIdsSet = useUIStore((state) => state.selectedCardIds); - const { data: session } = useSession(); - - // Get workspace state to format selected cards context on client + const activePdfPageByItemId = useUIStore((state) => state.activePdfPageByItemId); const { state: workspaceState } = useWorkspaceState(workspaceId); - // Format selected cards context on client side (eliminates server-side DB fetch) const selectedCardsContext = useMemo(() => { if (!workspaceState?.items || selectedCardIdsSet.size === 0) { return ""; @@ -53,42 +45,13 @@ export function WorkspaceRuntimeProvider({ return ""; } - return formatSelectedCardsContext(selectedItems, workspaceState.items); - }, [workspaceState?.items, selectedCardIdsSet]); - - // Create AssistantCloud instance - use anonymous mode for anonymous users - const cloud = useMemo(() => { - // If user is anonymous, use Assistant UI's built-in anonymous mode - if (session?.user?.isAnonymous) { - return new AssistantCloud({ - baseUrl: process.env.NEXT_PUBLIC_ASSISTANT_BASE_URL!, - anonymous: true, // Browser-session based user ID - }); - } - - // For authenticated users, use token-based authentication - return new AssistantCloud({ - baseUrl: process.env.NEXT_PUBLIC_ASSISTANT_BASE_URL!, - authToken: async () => { - const response = await fetch("/api/assistant-ui-token", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ workspaceId }), - }); - - if (!response.ok) { - throw new Error("Failed to get auth token"); - } - - const { token } = await response.json(); - return token; - }, - }); - }, [workspaceId, session?.user?.isAnonymous]); + return formatSelectedCardsMetadata( + selectedItems, + workspaceState.items, + activePdfPageByItemId + ); + }, [workspaceState?.items, selectedCardIdsSet, activePdfPageByItemId]); - // Error handler for chat runtime errors (timeouts, network issues, etc.) const handleChatError = useCallback((error: Error) => { console.error("[Chat Error]", error); @@ -130,28 +93,33 @@ export function WorkspaceRuntimeProvider({ } }, []); - // Create runtime with Assistant Cloud integration - const runtime = useChatRuntime({ - cloud, - transport: useMemo(() => { - const transport = new AssistantChatTransport({ + const threadListAdapter = useMemo( + () => createThreadListAdapter(workspaceId), + [workspaceId] + ); + + const transport = useMemo( + () => + new AssistantChatTransport({ api: "/api/chat", body: { workspaceId, modelId: selectedModelId, activeFolderId, - selectedCardsContext, // Pre-formatted context (client-side) instead of IDs + selectedCardsContext, }, - headers: { - // Headers for static context if needed - }, - }); - return transport; - }, [workspaceId, selectedModelId, activeFolderId, selectedCardsContext]), - adapters: { - attachments: new SupabaseAttachmentAdapter(), - }, - onError: handleChatError, + }), + [workspaceId, selectedModelId, activeFolderId, selectedCardsContext] + ); + + const runtime = useRemoteThreadListRuntime({ + runtimeHook: () => + useChatRuntime({ + transport, + onError: handleChatError, + toCreateMessage: toCreateMessageWithContext, + }), + adapter: threadListAdapter, }); return ( diff --git a/src/components/assistant-ui/attachment.tsx b/src/components/assistant-ui/attachment.tsx index e4d6032c..604afd03 100644 --- a/src/components/assistant-ui/attachment.tsx +++ b/src/components/assistant-ui/attachment.tsx @@ -442,7 +442,7 @@ export const UserMessageAttachments: FC = () => { export const ComposerAttachments: FC = () => { return ( -
+
@@ -467,7 +467,7 @@ export const ComposerAddAttachment: FC = () => { if (!files || files.length === 0) return; const MAX_FILES = 10; - const MAX_FILE_SIZE_MB = 10; + const MAX_FILE_SIZE_MB = 50; const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024; const fileArray = Array.from(files); @@ -542,7 +542,7 @@ export const ComposerAddAttachment: FC = () => {
+ ); +} + +function DiffViewerContent({ className, ...props }: ComponentProps<"div">) { + return ( +
+ ); +} + +interface DiffViewerHeaderProps extends ComponentProps<"div"> { + oldName?: string | undefined; + newName?: string | undefined; + additions?: number; + deletions?: number; + showIcon?: boolean; + showStats?: boolean; +} + +function DiffViewerHeader({ + oldName, + newName, + additions = 0, + deletions = 0, + showIcon = true, + showStats = true, + className, + ...props +}: DiffViewerHeaderProps) { + if (!oldName && !newName) return null; + + const displayName = newName || oldName; + + return ( +
+ {showIcon && } + + {oldName && newName && oldName !== newName ? ( + <> + {oldName} + {" → "} + + {newName} + + + ) : ( + displayName + )} + + {showStats && (additions > 0 || deletions > 0) && ( + + )} +
+ ); +} + +interface DiffViewerLineProps extends ComponentProps<"div"> { + line: ParsedLine; + showLineNumbers?: boolean; +} + +function DiffViewerLine({ + line, + showLineNumbers = true, + className, + ...props +}: DiffViewerLineProps) { + const indicator = line.type === "add" ? "+" : line.type === "del" ? "-" : " "; + + return ( +
+ {showLineNumbers && ( + + {line.type === "del" + ? line.oldLineNumber + : line.type === "add" + ? line.newLineNumber + : line.oldLineNumber} + + )} + + {indicator} + + + {line.content} + +
+ ); +} + +interface DiffViewerSplitLineProps extends ComponentProps<"div"> { + pair: SplitLinePair; + showLineNumbers?: boolean; +} + +function DiffViewerSplitLine({ + pair, + showLineNumbers = true, + className, + ...props +}: DiffViewerSplitLineProps) { + const { left, right } = pair; + + return ( +
+
+ {showLineNumbers && ( + + {left?.oldLineNumber ?? ""} + + )} + + {left ? (left.type === "del" ? "-" : " ") : ""} + + + {left?.content ?? ""} + +
+
+ {showLineNumbers && ( + + {right?.newLineNumber ?? ""} + + )} + + {right ? (right.type === "add" ? "+" : " ") : ""} + + + {right?.content ?? ""} + +
+
+ ); +} + +export type DiffViewerProps = Partial & + VariantProps & { + patch?: string; + oldFile?: { content: string; name?: string }; + newFile?: { content: string; name?: string }; + viewMode?: "split" | "unified"; + showLineNumbers?: boolean; + showIcon?: boolean; + showStats?: boolean; + /** Render content as markdown (math, code, etc.) via Streamdown */ + renderMarkdown?: boolean; + className?: string; + }; + +/** Group consecutive lines of same type into chunks (for markdown rendering) */ +function groupLinesByType(lines: ParsedLine[]): Array<{ type: DiffLineType; content: string }> { + const chunks: Array<{ type: DiffLineType; content: string }> = []; + if (lines.length === 0) return chunks; + + let current: { type: DiffLineType; lines: string[] } = { + type: lines[0].type, + lines: [lines[0].content], + }; + + for (let i = 1; i < lines.length; i++) { + const line = lines[i]; + if (line.type === current.type) { + current.lines.push(line.content); + } else { + chunks.push({ type: current.type, content: current.lines.join("\n") }); + current = { type: line.type, lines: [line.content] }; + } + } + chunks.push({ type: current.type, content: current.lines.join("\n") }); + return chunks; +} + +function DiffViewer({ + code, + patch, + oldFile, + newFile, + viewMode = "unified", + showLineNumbers = true, + showIcon = true, + showStats = true, + renderMarkdown = false, + variant, + size, + className, +}: DiffViewerProps) { + const diffPatch = patch ?? code; + + const parsedFiles = useMemo(() => { + if (diffPatch) { + return parsePatch(diffPatch); + } + if (oldFile && newFile) { + const { lines, additions, deletions } = computeDiff( + oldFile.content, + newFile.content, + ); + return [ + { + oldName: oldFile.name, + newName: newFile.name, + lines, + additions, + deletions, + }, + ]; + } + return []; + }, [diffPatch, oldFile, newFile]); + + if (parsedFiles.length === 0) { + return ( +
+        No diff content provided
+      
+ ); + } + + return ( +
+ {parsedFiles.map((file, fileIndex) => ( +
+ +
+ {renderMarkdown && viewMode === "unified" ? ( + groupLinesByType(file.lines).map((chunk, chunkIndex) => { + const chunkLabels = { + add: "Added", + del: "Removed", + normal: "Unchanged", + } as const; + const label = chunkLabels[chunk.type]; + return ( +
+
+ {label} +
+ + {chunk.content} + +
+ ); + }) + ) : viewMode === "split" ? ( + pairLinesForSplit(file.lines).map((pair, pairIndex) => ( + + )) + ) : ( + file.lines.map((line, lineIndex) => ( + + )) + )} +
+
+ ))} +
+ ); +} + +DiffViewer.displayName = "DiffViewer"; + +export type { ParsedLine, ParsedFile, SplitLinePair }; + +export { + DiffViewer, + DiffViewerFile, + DiffViewerHeader, + DiffViewerContent, + DiffViewerLine, + DiffViewerSplitLine, + DiffViewerFileBadge, + DiffViewerStats, + diffViewerVariants, + diffLineVariants, + diffLineTextVariants, + parsePatch, + computeDiff, +}; diff --git a/src/components/assistant-ui/markdown-text.tsx b/src/components/assistant-ui/markdown-text.tsx index 30b61ab6..91047f51 100644 --- a/src/components/assistant-ui/markdown-text.tsx +++ b/src/components/assistant-ui/markdown-text.tsx @@ -1,26 +1,174 @@ "use client"; -import { Streamdown, defaultRehypePlugins } from "streamdown"; +import { Streamdown } from "streamdown"; import "streamdown/styles.css"; import { createCodePlugin } from "@streamdown/code"; import { mermaid } from "@streamdown/mermaid"; import { createMathPlugin } from "@streamdown/math"; +import { useMessagePartText, useAuiState, type TextMessagePartProps } from "@assistant-ui/react"; +import { + Children, + isValidElement, + memo, + useRef, + useEffect, + type ReactNode, +} from "react"; +import type { + AnchorHTMLAttributes, + ClipboardEvent as ReactClipboardEvent, + HTMLAttributes, +} from "react"; +import { InlineCitation, UrlCitation } from "@/components/ai-elements/inline-citation"; +import { Badge } from "@/components/ui/badge"; +import { MarkdownLink } from "@/components/ui/markdown-link"; +import { useNavigateToItem } from "@/hooks/ui/use-navigate-to-item"; +import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state"; +import { useUIStore } from "@/lib/stores/ui-store"; +import { useWorkspaceStore } from "@/lib/stores/workspace-store"; +import { getCitationUrl } from "@/lib/utils/preprocess-latex"; +import { resolveItemByPath } from "@/lib/ai/tools/workspace-search-utils"; +import { preprocessLatex } from "@/lib/utils/preprocess-latex"; +import { cn } from "@/lib/utils"; const math = createMathPlugin({ singleDollarTextMath: true }); +const code = createCodePlugin({ themes: ["one-dark-pro", "one-dark-pro"] }); -// Create code plugin with one-dark-pro theme -const code = createCodePlugin({ - themes: ['one-dark-pro', 'one-dark-pro'], -}); -import { useMessagePartText } from "@assistant-ui/react"; -import { useAuiState } from "@assistant-ui/react"; -import { cn } from "@/lib/utils"; -import React, { memo, useRef, useEffect } from "react"; -import type { AnchorHTMLAttributes, HTMLAttributes } from "react"; -import { MarkdownLink } from "@/components/ui/markdown-link"; -import { preprocessLatex } from "@/lib/utils/preprocess-latex"; +/** Parse page number from citation ref (e.g. "Title | quote | p. 5" or "Title | p. 5"). */ +function parseCitationPage(ref: string): { title: string; quote?: string; pageNumber?: number } { + const segments = ref.split(/\s*\|\s*/).map((s) => s.trim()).filter(Boolean); + if (segments.length === 0) return { title: "" }; + + const last = segments[segments.length - 1]; + const pageMatch = last.match(/^(?:p\.?\s*)?(\d+)$/i) || last.match(/^page\s*(\d+)$/i); + let pageNumber: number | undefined; + let title: string; + let quote: string | undefined; + + if (pageMatch) { + pageNumber = parseInt(pageMatch[1], 10); + segments.pop(); + } + title = segments[0] ?? ""; + quote = segments.length > 1 ? segments.slice(1).join(" | ") : undefined; + return { title, quote: quote || undefined, pageNumber }; +} + +/** Extract raw text from citation element children (handles nested elements). */ +function extractCitationText(children: ReactNode): string { + if (typeof children === "string") return children.trim(); + if (children == null) return ""; + const arr = Children.toArray(children); + return arr + .map((child) => { + if (typeof child === "string") return child; + if (isValidElement(child)) { + const nested = (child.props as { children?: ReactNode }).children; + if (nested != null) return extractCitationText(nested); + } + return ""; + }) + .join("") + .trim(); +} + +/** + * SurfSense-style citation renderer. + * Content is the ref itself: urlciteN (URL), or Title, or Title|quote (workspace). + */ +const CitationRenderer = memo( + ({ children }: { children?: ReactNode }) => { + const ref = extractCitationText(children); + if (!ref) return null; + + // URL placeholder (from preprocess): render as direct link + const url = getCitationUrl(ref); + if (url) { + return ; + } + + // Direct URL (in case preprocess didn't run, e.g. edge case) + if (ref.startsWith("http://") || ref.startsWith("https://")) { + return ; + } -const MarkdownTextImpl = () => { + // Workspace citation: Title, Title|quote, or Title|quote|p. 5 (with optional page) + const parsed = parseCitationPage(ref); + const { title: parsedTitle, quote: parsedQuote, pageNumber } = parsed; + const title = parsedTitle; + const quote = parsedQuote; + + const workspaceId = useWorkspaceStore((s) => s.currentWorkspaceId); + const { state: workspaceState } = useWorkspaceState(workspaceId); + const navigateToItem = useNavigateToItem(); + const setOpenModalItemId = useUIStore((s) => s.setOpenModalItemId); + // Normalize for matching: trim, lowercase, strip .pdf (model may include it; items often don't) + const titleNorm = (s: string) => s.trim().toLowerCase().replace(/\.pdf$/i, ""); + const setCitationHighlightQuery = useUIStore((s) => s.setCitationHighlightQuery); + + const handleWorkspaceItemClick = () => { + if (!workspaceState?.items || !title) return; + // Resolve by virtual path first (e.g. "pdfs/Syllabus.pdf") — AI may cite using paths from + const items = workspaceState.items; + const byPath = resolveItemByPath(items, title); + const item = + byPath && (byPath.type === "note" || byPath.type === "pdf") + ? byPath + : items.find( + (i) => + (i.type === "note" || i.type === "pdf") && + titleNorm(i.name) === titleNorm(title) + ); + if (!item) return; + // Set citation highlight: for PDFs with page, or when we have a quote to search + if (quote?.trim() || (pageNumber != null && item.type === "pdf")) { + setCitationHighlightQuery({ + itemId: item.id, + query: quote?.trim() ?? "", + ...(pageNumber != null && { pageNumber }), + }); + } + navigateToItem(item.id, { silent: true }); + setOpenModalItemId(item.id); + }; + + // For path-like refs (e.g. pdfs/Syllabus.pdf), show basename in badge + const displayTitle = title.includes("/") ? title.split("/").pop() ?? title : title; + const badgeLabel = + displayTitle.slice(0, 20) + (displayTitle.length > 20 ? "…" : "") + + (pageNumber != null ? ` · p.${pageNumber}` : ""); + + return ( + + { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleWorkspaceItemClick(); + } + }} + > + {badgeLabel} + + + ); + } +); +CitationRenderer.displayName = "CitationRenderer"; + +/** Props from assistant-ui when used as Text component, or optional when used directly (e.g. in Reasoning) */ +type MarkdownTextProps = Partial & { + /** Use "reasoning" for smoother streaming in reasoning blocks (blurIn, longer duration) */ + streamingVariant?: "default" | "reasoning"; +}; + +const MarkdownTextImpl = (props: MarkdownTextProps) => { + const streamingVariant = props.streamingVariant ?? "default"; // Get the text content from assistant-ui context const { text } = useMessagePartText(); @@ -31,6 +179,11 @@ const MarkdownTextImpl = () => { // Check if the message is currently streaming const isRunning = useAuiState(({ thread }) => (thread as any)?.isRunning ?? false); + const animateConfig = + streamingVariant === "reasoning" + ? { animation: "blurIn" as const, duration: 250, easing: "ease-out" } + : { animation: "fadeIn" as const, duration: 200, easing: "ease-out" }; + const containerRef = useRef(null); // Combine thread and message ID for unique key per message @@ -69,7 +222,7 @@ const MarkdownTextImpl = () => { }, [threadId, messageId]); // Ensure copied content keeps rich HTML but strips background/highlight styles - const handleCopy = (event: React.ClipboardEvent) => { + const handleCopy = (event: ReactClipboardEvent) => { if (typeof window === "undefined") return; const selection = window.getSelection(); @@ -82,15 +235,10 @@ const MarkdownTextImpl = () => { const wrapper = document.createElement("div"); wrapper.appendChild(fragment); - // Strip background-related inline styles so no highlight color is preserved + // Strip background/highlight styles from copied HTML wrapper.querySelectorAll("*").forEach((el) => { - const style = el.style; - if (!style) return; - - // Remove background colors/highlights while leaving other styling intact - style.removeProperty("background"); - style.removeProperty("background-color"); - // Lines 88-89 already remove background/background-color, so this line can be deleted + el.style?.removeProperty("background"); + el.style?.removeProperty("background-color"); }); const html = wrapper.innerHTML; @@ -110,36 +258,23 @@ const MarkdownTextImpl = () => { return (
*:first-child]:mt-0 [&>*:last-child]:mb-0" )} linkSafety={{ enabled: false }} - plugins={{ - code: code, - mermaid: mermaid, - math: math, - }} - rehypePlugins={[ - defaultRehypePlugins.raw, - defaultRehypePlugins.sanitize, - [ - // @ts-ignore - accessing internal harden plugin - defaultRehypePlugins.harden[0], - { - allowedLinkPrefixes: ["*"], - allowedImagePrefixes: ["*"], - allowedProtocols: ["*"], - allowDataImages: true, - } - ] - ]} + plugins={{ code, mermaid, math }} components={{ a: (props: AnchorHTMLAttributes & { node?: any }) => ( ), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + citation: (props: any) => ( + {props.children} + ), ol: ({ children, node, ...props }: HTMLAttributes & { node?: any }) => (
    {children} diff --git a/src/components/assistant-ui/reasoning.tsx b/src/components/assistant-ui/reasoning.tsx index cf2d6dee..53bdb2c3 100644 --- a/src/components/assistant-ui/reasoning.tsx +++ b/src/components/assistant-ui/reasoning.tsx @@ -1,8 +1,8 @@ "use client"; -import { memo, useCallback, useRef, useState } from "react"; +import { memo, useCallback, useRef, useState, useEffect, useLayoutEffect, forwardRef } from "react"; import { cva, type VariantProps } from "class-variance-authority"; -import { BrainIcon, ChevronDownIcon } from "lucide-react"; +import { ChevronDownIcon } from "lucide-react"; import { useScrollLock, useAuiState, @@ -136,10 +136,6 @@ function ReasoningTrigger({ )} {...props} > - ) { - return ( -
    - ); -} +const ReasoningText = forwardRef>( + function ReasoningText({ className, ...props }, ref) { + return ( +
    + ); + } +); -const ReasoningImpl: ReasoningMessagePartComponent = () => ; +const ReasoningImpl: ReasoningMessagePartComponent = () => ( + +); const ReasoningGroupImpl: ReasoningGroupComponent = ({ children, startIndex, endIndex, }) => { + const textContainerRef = useRef(null); const isReasoningStreaming = useAuiState(({ message }) => { if (message.status?.type !== "running") return false; const lastIndex = message.parts.length - 1; @@ -233,11 +235,51 @@ const ReasoningGroupImpl: ReasoningGroupComponent = ({ return lastIndex >= startIndex && lastIndex <= endIndex; }); + // Subscribe to reasoning text length so we re-run scroll effect on each stream chunk + const reasoningTextSnapshot = useAuiState(({ message }) => { + let len = 0; + for (let i = startIndex; i <= endIndex && i < message.parts.length; i++) { + const p = message.parts[i] as { type?: string; text?: string } | undefined; + if (p?.type === "reasoning" && typeof p.text === "string") len += p.text.length; + } + return len; + }); + + const [isManuallyOpen, setIsManuallyOpen] = useState(false); + const isOpen = isReasoningStreaming || isManuallyOpen; + + // Auto-scroll to bottom as reasoning streams (like assistant-ui Viewport autoScroll) + // reasoningTextSnapshot ensures we run on every stream chunk + useLayoutEffect(() => { + if (!isReasoningStreaming || !textContainerRef.current) return; + const el = textContainerRef.current; + // Only skip scroll if user has clearly scrolled up (e.g. >80px from bottom) + const fromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; + if (fromBottom <= 80) { + el.scrollTop = el.scrollHeight; + } + }, [isReasoningStreaming, reasoningTextSnapshot]); + + // Auto-collapse when streaming finishes + useEffect(() => { + if (!isReasoningStreaming) { + setIsManuallyOpen(false); + } + }, [isReasoningStreaming]); + + const handleOpenChange = useCallback( + (open: boolean) => { + if (isReasoningStreaming && !open) return; + setIsManuallyOpen(open); + }, + [isReasoningStreaming], + ); + return ( - + - {children} + {children} ); diff --git a/src/components/assistant-ui/thread.tsx b/src/components/assistant-ui/thread.tsx index 5602b8f7..dc06800a 100644 --- a/src/components/assistant-ui/thread.tsx +++ b/src/components/assistant-ui/thread.tsx @@ -46,6 +46,7 @@ import * as m from "motion/react-m"; import { useEffect, useRef, useState, useMemo, useCallback } from "react"; import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; import { DropdownMenu, DropdownMenuContent, @@ -74,7 +75,8 @@ import { URLContextToolUI } from "@/components/assistant-ui/URLContextToolUI"; // import { DeepResearchToolUI } from "@/components/assistant-ui/DeepResearchToolUI"; import { UpdateNoteToolUI } from "@/components/assistant-ui/UpdateNoteToolUI"; import { WebSearchToolUI } from "@/components/assistant-ui/WebSearchToolUI"; -import { UpdatePdfContentToolUI } from "@/components/assistant-ui/UpdatePdfContentToolUI"; +import { SearchWorkspaceToolUI } from "@/components/assistant-ui/SearchWorkspaceToolUI"; +import { ReadWorkspaceToolUI } from "@/components/assistant-ui/ReadWorkspaceToolUI"; import { DeleteCardToolUI } from "@/components/assistant-ui/DeleteCardToolUI"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; @@ -96,6 +98,7 @@ import { ToolGroup } from "@/components/assistant-ui/tool-group"; import type { Item, PdfData } from "@/lib/workspace-state/types"; import { CardContextDisplay } from "@/components/chat/CardContextDisplay"; import { ReplyContextDisplay } from "@/components/chat/ReplyContextDisplay"; +import { MessageContextBadges } from "@/components/chat/MessageContextBadges"; import { MentionMenu } from "@/components/chat/MentionMenu"; import { useWorkspaceStore } from "@/lib/stores/workspace-store"; import { useUIStore, selectReplySelections, selectSelectedCardIdsArray, selectBlockNoteSelection } from "@/lib/stores/ui-store"; @@ -109,7 +112,6 @@ import { useCreateCardFromMessage } from "@/hooks/ai/use-create-card-from-messag import { extractUrls, createUrlFile } from "@/lib/attachments/url-utils"; import { filterItems } from "@/lib/workspace-state/search"; import { useSession } from "@/lib/auth-client"; -import { formatSelectedCardsContext } from "@/lib/utils/format-workspace-context"; import { focusComposerInput } from "@/lib/utils/composer-utils"; import { SpeechToTextButton } from "@/components/assistant-ui/SpeechToTextButton"; @@ -196,7 +198,8 @@ export const Thread: FC = ({ items = [] }) => { {/* */} - + + = ({ items = [] }) => { autoScroll={false} className="aui-thread-viewport relative flex flex-1 flex-col overflow-x-auto overflow-y-scroll px-4" > - thread.isEmpty}> + thread.isLoading}> + + + thread.isEmpty && !thread.isLoading}> @@ -252,6 +258,40 @@ const ThreadScrollToBottom: FC = () => { ); }; +const ThreadLoadingSkeleton: FC = () => { + return ( +
    + {/* User message skeleton (right-aligned) */} +
    +
    + +
    +
    + {/* Assistant message skeleton (left-aligned) */} +
    + + + +
    + {/* User message skeleton */} +
    + +
    + {/* Assistant message skeleton - taller */} +
    + + + + +
    +
    + ); +}; + const ThreadWelcome: FC = () => { return (
    @@ -636,7 +676,7 @@ const Composer: FC = ({ items }) => { return ( { // Focus the input when clicking anywhere in the composer area // This allows users to easily return focus after interacting with quizzes or other cards @@ -718,7 +758,7 @@ const Composer: FC = ({ items }) => { = ({ items }) => { }, [items]); return ( -
    +
    {/* Attachment buttons on the left */} -
    +
    @@ -801,7 +841,7 @@ const ComposerAction: FC = ({ items }) => { -
    - - {/* Selection Text */} - - {truncateText(selection.text)} - - - ))} - -
    - - {/* Expand/Collapse Button */} - - {showExpandButton && ( - + {/* Blue arrow icon - visible by default, hidden on hover */} + + {/* X icon - hidden by default, visible on hover */} + +
    + + {/* Selection Text */} + + {truncateText(selection.text)} + +
    + ))} +
    + + {/* Expand/Collapse Button */} + {showExpandButton && ( + )} - +
    ); } diff --git a/src/components/editor/BlockNoteEditor.tsx b/src/components/editor/BlockNoteEditor.tsx index 99fceaf5..532294c5 100644 --- a/src/components/editor/BlockNoteEditor.tsx +++ b/src/components/editor/BlockNoteEditor.tsx @@ -13,6 +13,8 @@ import { schema } from "./schema"; import { uploadFile } from "@/lib/editor/upload-file"; import { useWorkspaceStore } from "@/lib/stores/workspace-store"; import { useUIStore } from "@/lib/stores/ui-store"; +import { searchHighlightExtension } from "./search-highlight-extension"; +import { SearchQuery, setSearchState } from "prosemirror-search"; import { extractTextFromSelection } from "@/lib/utils/extract-blocknote-text"; import { MathEditProvider } from "./MathEditDialog"; import { useTheme } from "next-themes"; @@ -116,6 +118,7 @@ export default function BlockNoteEditor({ initialContent, onChange, readOnly, ca uploadFile: blockNoteUploadFile, dictionary: en, autofocus: autofocus ? (typeof autofocus === "boolean" ? "start" : autofocus) : false, + extensions: cardId && !readOnly ? [searchHighlightExtension] : [], }); useEffect(() => { @@ -235,6 +238,90 @@ export default function BlockNoteEditor({ initialContent, onChange, readOnly, ca }; }, [editor, readOnly, cardId, cardName, setBlockNoteSelection, clearBlockNoteSelection]); + // Sync citationHighlightQuery from store to prosemirror-search plugin (citation highlight) + useEffect(() => { + if (!cardId || readOnly) return; + + const HIGHLIGHT_DURATION_MS = 2500; + const setCitationHighlightQuery = useUIStore.getState().setCitationHighlightQuery; + + const applyHighlight = (query: string) => { + try { + const view = editor.prosemirrorView; + if (!view) return; + + const { state } = view; + const docSize = state.doc.content.size; + + const searchQuery = new SearchQuery({ + search: query, + literal: true, // treat as plain text, escape regex chars + caseSensitive: false, + }); + if (!searchQuery.valid) return; + + const tr = state.tr; + setSearchState(tr, searchQuery, { from: 0, to: docSize }); + view.dispatch(tr); + + // Scroll first match into view after DOM updates + const result = searchQuery.findNext(state, 0, docSize); + if (result) { + requestAnimationFrame(() => { + try { + const { node } = view.domAtPos(result.from); + const el = (node as Node).nodeType === 3 ? (node as Text).parentElement : (node as HTMLElement); + el?.scrollIntoView({ behavior: "smooth", block: "center" }); + } catch { + // ignore + } + }); + } + + // Clear highlight after a few seconds + const timeoutId = setTimeout(() => { + try { + const v = editor.prosemirrorView; + if (!v) return; + const s = v.state; + const clearQuery = new SearchQuery({ search: "\0", literal: true }); + const clearTr = s.tr; + setSearchState(clearTr, clearQuery, null); + v.dispatch(clearTr); + setCitationHighlightQuery(null); + } catch { + // ignore + } + }, HIGHLIGHT_DURATION_MS); + + return () => clearTimeout(timeoutId); + } catch (e) { + console.warn("[BlockNoteEditor] Citation highlight apply failed:", e); + } + }; + + let clearTimeoutFn: (() => void) | undefined; + + const unsub = useUIStore.subscribe((state) => { + const hl = state.citationHighlightQuery; + if (!hl || hl.itemId !== cardId) return; + if (!hl.query?.trim()) return; + clearTimeoutFn?.(); + clearTimeoutFn = applyHighlight(hl.query.trim()); + }); + + // Apply immediately if we already have a matching query (e.g. note already open) + const hl = useUIStore.getState().citationHighlightQuery; + if (hl?.itemId === cardId && hl.query?.trim()) { + clearTimeoutFn = applyHighlight(hl.query.trim()); + } + + return () => { + unsub(); + clearTimeoutFn?.(); + }; + }, [editor, cardId, readOnly]); + // Sync content ONLY when updated by AGENT (prevents cursor jumping on user input) useEffect(() => { // Only proceed if this is an update triggered by the agent diff --git a/src/components/editor/search-highlight-extension.ts b/src/components/editor/search-highlight-extension.ts new file mode 100644 index 00000000..f2dda6b4 --- /dev/null +++ b/src/components/editor/search-highlight-extension.ts @@ -0,0 +1,19 @@ +"use client"; + +import { createExtension } from "@blocknote/core"; +import { search } from "prosemirror-search"; +import "prosemirror-search/style/search.css"; + +/** + * BlockNote extension that adds prosemirror-search plugin for citation highlight. + * The plugin shows decoration-based highlights; the actual query is set via + * setSearchState() dispatched from BlockNoteEditor when citationHighlightQuery changes. + */ +export const searchHighlightExtension = createExtension({ + key: "searchHighlight", + prosemirrorPlugins: [ + search({ + // No initial query; BlockNoteEditor syncs from citationHighlightQuery store + }), + ], +}); diff --git a/src/components/home/HomeActionCards.tsx b/src/components/home/HomeActionCards.tsx index 544d43b7..658eacba 100644 --- a/src/components/home/HomeActionCards.tsx +++ b/src/components/home/HomeActionCards.tsx @@ -1,30 +1,42 @@ -import { Upload, Link as LinkIcon, ClipboardPaste, Mic, FolderPlus, Loader2 } from "lucide-react"; +import { Upload, Link as LinkIcon, ClipboardPaste, Mic, Loader2 } from "lucide-react"; import { cn } from "@/lib/utils"; +type HoverVariant = "upload" | "link" | "paste" | "record"; + +const HOVER_VARIANT_STYLES: Record = { + upload: "hover:border-emerald-500/60 hover:shadow-[0_0_24px_-4px_rgba(16,185,129,0.35)] [&:hover_.action-icon]:text-emerald-600 dark:[&:hover_.action-icon]:text-emerald-400 [&:hover_.action-icon]:animate-[icon-upload-bounce_0.8s_ease-in-out_infinite]", + link: "hover:border-blue-500/60 hover:shadow-[0_0_24px_-4px_rgba(59,130,246,0.35)] [&:hover_.action-icon]:text-blue-600 dark:[&:hover_.action-icon]:text-blue-400 [&:hover_.action-icon]:animate-[icon-link-sway_0.8s_ease-in-out_infinite]", + paste: "hover:border-amber-500/60 hover:shadow-[0_0_24px_-4px_rgba(245,158,11,0.35)] [&:hover_.action-icon]:text-amber-600 dark:[&:hover_.action-icon]:text-amber-400 [&:hover_.action-icon]:animate-[icon-paste-pop_0.8s_ease-in-out_infinite]", + record: "hover:border-rose-500/60 hover:shadow-[0_0_24px_-4px_rgba(244,63,94,0.35)] [&:hover_.action-icon]:text-rose-600 dark:[&:hover_.action-icon]:text-rose-400 [&:hover_.action-icon]:animate-[icon-link-pulse_0.8s_ease-in-out_infinite]", +}; + interface ActionCardProps { icon: React.ReactNode; title: string; subtitle: string; onClick?: () => void; isLoading?: boolean; + hoverVariant?: HoverVariant; /** When set, renders as label for native file picker—avoids JS round-trip and OS delay feels shorter */ htmlFor?: string; } -function ActionCard({ icon, title, subtitle, onClick, isLoading, htmlFor }: ActionCardProps) { +function ActionCard({ icon, title, subtitle, onClick, isLoading, hoverVariant = "upload", htmlFor }: ActionCardProps) { const sharedClassName = cn( - "flex flex-col items-start gap-2 p-4 min-h-[88px] w-full rounded-2xl border bg-white dark:bg-sidebar backdrop-blur-xl hover:bg-neutral-50 dark:hover:bg-accent hover:text-accent-foreground hover:scale-[1.02] active:scale-[0.98] transition-all duration-200 text-left cursor-pointer", + "group flex flex-col items-start gap-2 p-4 min-h-[88px] w-full rounded-2xl border bg-white dark:bg-sidebar backdrop-blur-xl", + "hover:scale-[1.02] hover:-translate-y-0.5 active:scale-[0.98] active:translate-y-0 transition-all duration-300 ease-out text-left cursor-pointer", "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-2", - "disabled:pointer-events-none disabled:opacity-50" + "disabled:pointer-events-none disabled:opacity-50", + !isLoading && HOVER_VARIANT_STYLES[hoverVariant] ); const content = ( <> -
    +
    {icon}
    -
    {title}
    +
    {title}
    {subtitle}
    @@ -55,21 +67,21 @@ interface HomeActionCardsProps { onLink: () => void; onPasteText: () => void; onRecord: () => void; - onStartFromScratch: () => void; isLoading?: boolean; /** ID of the hidden file input—enables native label click for instant file picker */ uploadInputId?: string; } -export function HomeActionCards({ onUpload, onLink, onPasteText, onRecord, onStartFromScratch, isLoading, uploadInputId }: HomeActionCardsProps) { +export function HomeActionCards({ onUpload, onLink, onPasteText, onRecord, isLoading, uploadInputId }: HomeActionCardsProps) { return ( -
    +
    } title="Upload" subtitle="PDF, Image, Audio" onClick={onUpload} isLoading={isLoading} + hoverVariant="upload" htmlFor={uploadInputId} /> } @@ -85,20 +98,15 @@ export function HomeActionCards({ onUpload, onLink, onPasteText, onRecord, onSta subtitle="From Clipboard" onClick={onPasteText} isLoading={isLoading} + hoverVariant="paste" /> } + icon={isLoading ? : } title="Record" subtitle="Lectures, Meetings" onClick={onRecord} isLoading={isLoading} - /> - : } - title="Start Fresh" - subtitle="Empty Workspace" - onClick={onStartFromScratch} - isLoading={isLoading} + hoverVariant="record" />
    ); diff --git a/src/components/home/HomeContent.tsx b/src/components/home/HomeContent.tsx index 975c7b95..d136ad47 100644 --- a/src/components/home/HomeContent.tsx +++ b/src/components/home/HomeContent.tsx @@ -11,7 +11,7 @@ import { HeroGlow } from "./HeroGlow"; import { Button } from "@/components/ui/button"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; -import { FolderPlus, ChevronDown } from "lucide-react"; +import { ChevronDown } from "lucide-react"; import { useCreateWorkspace } from "@/hooks/workspace/use-create-workspace"; import { HoverCard, @@ -35,6 +35,7 @@ export const useSectionVisibility = () => useContext(SectionVisibilityContext); import { HomeActionCards } from "./HomeActionCards"; import { RecordWorkspaceDialog, OPEN_RECORD_PARAM } from "@/components/modals/RecordWorkspaceDialog"; +import { Footer } from "@/components/landing/Footer"; const ACCEPT_FILES = "application/pdf,image/*,audio/*"; @@ -42,7 +43,6 @@ interface HeroAttachmentsSectionProps { fileInputRef: React.RefObject; showLinkDialog: boolean; setShowLinkDialog: (open: boolean) => void; - handleCreateBlankWorkspace: () => void; createWorkspacePending: boolean; onRecord: () => void; heroVisible: boolean; @@ -76,7 +76,6 @@ function HeroAttachmentsSection({ fileInputRef, showLinkDialog, setShowLinkDialog, - handleCreateBlankWorkspace, createWorkspacePending, onRecord, heroVisible, @@ -149,7 +148,6 @@ function HeroAttachmentsSection({ onLink={() => setShowLinkDialog(true)} onPasteText={handlePasteText} onRecord={onRecord} - onStartFromScratch={handleCreateBlankWorkspace} isLoading={createWorkspacePending} uploadInputId={uploadInputId} /> @@ -272,28 +270,6 @@ export function HomeContent() { workspacesRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }); }, []); - const handleCreateBlankWorkspace = () => { - // Guard against multiple rapid clicks - if (createWorkspace.isPending) return; - - createWorkspace.mutate( - { - name: "Blank Workspace", - icon: null, - color: null, - }, - { - onSuccess: ({ workspace }) => { - router.push(`/workspace/${workspace.slug}`); - }, - onError: (err) => { - const msg = err instanceof Error ? err.message : "Something went wrong"; - toast.error("Could not create workspace", { description: msg }); - }, - } - ); - }; - const handleRecordInNewWorkspace = () => { if (createWorkspace.isPending) return; setShowRecordDialog(false); @@ -320,6 +296,14 @@ export function HomeContent() { router.push(`/workspace/${slug}?${OPEN_RECORD_PARAM}=1`); }; + const handleRecord = () => { + if (!loadingWorkspaces && workspaces.length === 0) { + handleRecordInNewWorkspace(); + } else { + setShowRecordDialog(true); + } + }; + return ( <> setShowRecordDialog(true)} + onRecord={handleRecord} heroVisible={heroVisible} showPromptInput={showPromptInput} onRequestShowPromptInput={() => setShowPromptInput(true)} @@ -440,6 +423,11 @@ export function HomeContent() {
    + + {/* Footer */} +
    +
    +
diff --git a/src/components/home/HomePromptInput.tsx b/src/components/home/HomePromptInput.tsx index d4c8ba5d..15557357 100644 --- a/src/components/home/HomePromptInput.tsx +++ b/src/components/home/HomePromptInput.tsx @@ -240,7 +240,7 @@ export function HomePromptInput({ shouldFocus, initialValue, onInitialValueAppli paddingBottom: '0.25rem', paddingLeft: '0', paddingRight: '0', - maxHeight: '50vh', + maxHeight: '28vh', overflowY: 'auto', }} className={cn( diff --git a/src/components/home/HomeTopBar.tsx b/src/components/home/HomeTopBar.tsx index 3987be8d..2a5ae0af 100644 --- a/src/components/home/HomeTopBar.tsx +++ b/src/components/home/HomeTopBar.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { Github, Search } from "lucide-react"; +import { Search } from "lucide-react"; import { cn } from "@/lib/utils"; import { Input } from "@/components/ui/input"; import { UserProfileDropdown } from "./UserProfileDropdown"; @@ -73,17 +73,9 @@ export function HomeTopBar({ scrollY, searchQuery, onSearchChange }: HomeTopBarP
)} - {/* Right: Theme toggle + Open source + User profile */} + {/* Right: Theme toggle + User profile */}
diff --git a/src/components/landing/Footer.tsx b/src/components/landing/Footer.tsx index f5ad503b..7e7c3de7 100644 --- a/src/components/landing/Footer.tsx +++ b/src/components/landing/Footer.tsx @@ -1,7 +1,6 @@ "use client"; import { useState } from "react"; -import Link from "next/link"; import { ThinkExLogo } from "@/components/ui/thinkex-logo"; @@ -20,99 +19,55 @@ export function Footer() { }; return ( -