diff --git a/.env.example b/.env.example index ef1e9309..a2454351 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_... diff --git a/.gitignore b/.gitignore index 36202e82..8a8d6139 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ # dependencies /node_modules +.pnpm-store /.pnp .pnp.* .yarn/* @@ -22,6 +23,7 @@ # misc .DS_Store +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/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/_journal.json b/drizzle/meta/_journal.json index f7042925..222c9751 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1771377459878, "tag": "0002_add_workspace_share_links", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1771631691687, + "tag": "0002_deep_shadowcat", + "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/package.json b/package.json index ae4b7002..21366c3b 100644 --- a/package.json +++ b/package.json @@ -95,13 +95,15 @@ "@tanstack/react-virtual": "^3.13.14", "@vercel/speed-insights": "^1.3.1", "ai": "^6.0.78", - "assistant-cloud": "^0.1.17", + "assistant-stream": "^0.3.2", "better-auth": "^1.4.18", "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", @@ -112,10 +114,12 @@ "motion": "^12.34.0", "next": "16.1.6", "next-themes": "^0.4.6", + "parse-diff": "^0.11.1", "postgres": "^3.4.7", "posthog-js": "^1.345.0", "posthog-node": "^5.24.14", "prosemirror-highlight": "^0.13.0", + "prosemirror-search": "^1.1.0", "radix-ui": "^1.4.3", "react": "19.2.4", "react-color": "^2.19.3", 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/chat/route.ts b/src/app/api/chat/route.ts index bfe94d42..9392bdc3 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"; @@ -203,6 +203,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); @@ -298,6 +306,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/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..e4661436 --- /dev/null +++ b/src/app/api/threads/[id]/archive/route.ts @@ -0,0 +1,51 @@ +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"; + +/** + * POST /api/threads/[id]/archive + * Archive a thread + */ +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const userId = await requireAuth(); + const { id } = await params; + + 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, "editor"); + + await db + .update(chatThreads) + .set({ + isArchived: true, + 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] 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..a1df4ac3 --- /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 { format, content } = body; + + if (!format || content === undefined) { + return NextResponse.json( + { error: "format and content are 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..4e7d62dd --- /dev/null +++ b/src/app/api/threads/[id]/messages/route.ts @@ -0,0 +1,117 @@ +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, 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(eq(chatMessages.threadId, id)) + .orderBy(desc(chatMessages.createdAt)); + + const messages = rows + .filter((r) => r.format === format) + .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..71136b1a --- /dev/null +++ b/src/app/api/threads/[id]/unarchive/route.ts @@ -0,0 +1,51 @@ +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"; + +/** + * POST /api/threads/[id]/unarchive + * Unarchive a thread + */ +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const userId = await requireAuth(); + const { id } = await params; + + 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, "editor"); + + await db + .update(chatThreads) + .set({ + isArchived: false, + 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] 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..ca9a6d5f 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -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, }; }; diff --git a/src/app/globals.css b/src/app/globals.css index 9b8c8253..3ac437b9 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -3,8 +3,8 @@ @import "tw-shimmer"; -/* Path to your installed `@blocknote/shadcn` package. */ -@source "../node_modules/@blocknote/shadcn"; +/* BlockNote shadcn: use prebuilt styles to avoid Tailwind v4 parsing bug with [is(ol,ul)] arbitrary variant */ +@import "@blocknote/shadcn/style.css"; /* Streamdown markdown library - custom components handle styling, no @source needed */ @@ -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; @@ -1522,4 +1536,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..eee46a23 --- /dev/null +++ b/src/components/ai-elements/inline-citation.tsx @@ -0,0 +1,171 @@ +"use client"; + +import type { ComponentProps, ReactNode } from "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} +
+); diff --git a/src/components/assistant-ui/FileProcessingToolUI.tsx b/src/components/assistant-ui/FileProcessingToolUI.tsx index 69e7d1f1..74ffcc3e 100644 --- a/src/components/assistant-ui/FileProcessingToolUI.tsx +++ b/src/components/assistant-ui/FileProcessingToolUI.tsx @@ -29,6 +29,7 @@ import { Badge } from "@/components/ui/badge"; const ANIMATION_DURATION = 200; const SHIMMER_DURATION = 1000; +const MAX_DISPLAY_CHARS = 3000; /** * Root collapsible container that manages open/closed state and scroll lock. @@ -334,7 +335,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/GrepWorkspaceToolUI.tsx b/src/components/assistant-ui/GrepWorkspaceToolUI.tsx new file mode 100644 index 00000000..57d43d5a --- /dev/null +++ b/src/components/assistant-ui/GrepWorkspaceToolUI.tsx @@ -0,0 +1,86 @@ +"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 }; + +function GrepArgsSummary({ args }: { args?: GrepArgs }) { + if (!args?.pattern) return null; + const parts = [`pattern: "${args.pattern}"`]; + if (args.include) parts.push(`type: ${args.include}`); + if (args.path) parts.push(`path: ${args.path}`); + return ( +
+ {parts.join(" Β· ")} +
+ ); +} + +export const GrepWorkspaceToolUI = makeAssistantToolUI({ + toolName: "grepWorkspace", + render: ({ args, 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) { + content = ( +
+ +
+ + {result.matches != null && ( + + {result.matches} match + {result.matches !== 1 ? "es" : ""} + + )} +
+
+                            {result.output}
+                        
+
+ ); + } + } else if (status.type === "incomplete" && status.reason === "error") { + content = ( +
+ + +
+ ); + } + + return ( + + {content} + + ); + }, +}); diff --git a/src/components/assistant-ui/ReadWorkspaceToolUI.tsx b/src/components/assistant-ui/ReadWorkspaceToolUI.tsx new file mode 100644 index 00000000..52522f86 --- /dev/null +++ b/src/components/assistant-ui/ReadWorkspaceToolUI.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { FileText } from "lucide-react"; +import { makeAssistantToolUI } from "@assistant-ui/react"; +import { StandaloneMarkdown } from "@/components/assistant-ui/standalone-markdown"; +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 }; +type ReadResult = { + success: boolean; + itemName?: string; + type?: string; + path?: string; + content?: string; + message?: string; +}; + +export const ReadWorkspaceToolUI = makeAssistantToolUI({ + toolName: "readWorkspace", + render: ({ args, status, result }) => { + let content: React.ReactNode = null; + + if (status.type === "running") { + const label = args?.path + ? `Reading ${args.path}` + : args?.itemName + ? `Reading "${args.itemName}"` + : "Reading workspace item..."; + content = ; + } else if (status.type === "complete" && result) { + if (!result.success && result.message) { + content = ( + + ); + } else if (result.success && result.content) { + content = ( +
+
+ + + {result.path ?? result.itemName} + {result.type && ( + + {result.type} + + )} + +
+
+ {result.content} +
+
+ ); + } + } 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/WorkspaceRuntimeProvider.tsx b/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx index 6bc81b6f..e2d8739d 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,11 @@ 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 { 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 +44,9 @@ export function WorkspaceRuntimeProvider({ return ""; } - return formatSelectedCardsContext(selectedItems, workspaceState.items); + return formatSelectedCardsMetadata(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]); - - // Error handler for chat runtime errors (timeouts, network issues, etc.) const handleChatError = useCallback((error: Error) => { console.error("[Chat Error]", error); @@ -130,28 +88,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 - }, - headers: { - // Headers for static context if needed + selectedCardsContext, }, - }); - 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..842fd8d9 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 ( -
+
@@ -542,7 +542,7 @@ export const ComposerAddAttachment: FC = () => {