From 8f05707bee7e14077ea6c0c51aa3c84b7b94f77f Mon Sep 17 00:00:00 2001 From: chengke <404835780@qq.com> Date: Fri, 11 Sep 2026 19:16:13 +0800 Subject: [PATCH 1/5] feat(sources): implement folder assignment functionality for sources - Added support for assigning sources to folders, enhancing organization and management capabilities. - Introduced new `folderId` property in various source-related files and updated relevant tests to ensure proper functionality. - Modified source route service to handle folder assignments, including validation and error handling for folder-related requests. - Updated tests across multiple domains to cover new folder assignment scenarios, ensuring comprehensive coverage. This enhancement improves the user experience by allowing better categorization of sources within the application. --- drizzle/0019_large_invaders.sql | 18 + drizzle/meta/0019_snapshot.json | 1136 +++++++++++++++++ drizzle/meta/_journal.json | 7 + .../demo-sources/materialize/route.test.ts | 1 + src/app/api/folders/[folderId]/route.ts | 45 + src/app/api/folders/route.ts | 28 + .../sources/[sourceId]/chunks/route.test.ts | 2 + src/app/api/sources/[sourceId]/route.test.ts | 2 + src/app/api/sources/[sourceId]/route.ts | 6 +- src/app/api/sources/route.test.ts | 1 + src/components/folder-destination-menu.tsx | 56 + src/components/folder-panel-state.ts | 49 + src/components/folder-row.tsx | 115 ++ src/components/source-row.tsx | 42 +- src/components/sources-panel.test.ts | 27 + src/components/sources-panel.tsx | 317 ++++- src/components/ui/breadcrumb.tsx | 113 ++ .../workspace-chat-workflow.test.ts | 5 +- src/components/workspace-chat-workflow.ts | 3 + src/components/workspace-folder-workflow.ts | 217 ++++ src/components/workspace-shell-layout.tsx | 44 + src/components/workspace-shell.tsx | 21 + src/components/workspace-source-workflow.ts | 10 + src/domains/chat/contracts.ts | 1 + src/domains/chat/index.test.ts | 3 + src/domains/chat/index.ts | 9 +- .../chat/media-asset-hardening.test.ts | 1 + src/domains/chat/media-assets.test.ts | 1 + src/domains/chat/page-citation-assets.test.ts | 1 + src/domains/chat/request.ts | 4 + src/domains/chat/retrieval.test.ts | 75 ++ src/domains/chat/retrieval.ts | 30 +- src/domains/chat/route-answer.ts | 4 + src/domains/chat/route-service.test.ts | 1 + src/domains/chat/service.test.ts | 1 + src/domains/chat/service.ts | 16 + src/domains/chunks/index.test.ts | 1 + src/domains/folders/repository.ts | 427 +++++++ src/domains/folders/route-request.ts | 112 ++ src/domains/folders/route-service.ts | 178 +++ src/domains/folders/tree.test.ts | 82 ++ src/domains/folders/tree.ts | 97 ++ src/domains/folders/types.ts | 23 + src/domains/folders/workflow-runtime.ts | 100 ++ src/domains/sources/reconcile.test.ts | 1 + src/domains/sources/remote-library.test.ts | 1 + src/domains/sources/repository.ts | 2 + src/domains/sources/retry.test.ts | 1 + src/domains/sources/route-assign-folder.ts | 79 ++ src/domains/sources/route-request.test.ts | 25 +- src/domains/sources/route-request.ts | 37 +- src/domains/sources/route-retry.test.ts | 1 + src/domains/sources/route-service.test.ts | 1 + src/domains/sources/route-service.ts | 5 + src/domains/sources/route-types.ts | 19 + .../sources/source-reconcile-workflow.test.ts | 1 + .../sources/source-row-repository.test.ts | 1 + src/domains/sources/source-row-repository.ts | 16 + src/domains/sources/types.ts | 2 + src/domains/sources/upload.test.ts | 1 + src/domains/sources/view.test.ts | 3 + src/domains/sources/view.ts | 1 + src/domains/sources/workflow-runtime.test.ts | 1 + src/domains/sources/workflow-runtime.ts | 15 + src/domains/workspace/client.ts | 86 ++ src/domains/workspace/initial-state.test.ts | 2 + src/domains/workspace/initial-state.ts | 18 + src/infrastructure/db/schema.ts | 53 + 68 files changed, 3759 insertions(+), 45 deletions(-) create mode 100644 drizzle/0019_large_invaders.sql create mode 100644 drizzle/meta/0019_snapshot.json create mode 100644 src/app/api/folders/[folderId]/route.ts create mode 100644 src/app/api/folders/route.ts create mode 100644 src/components/folder-destination-menu.tsx create mode 100644 src/components/folder-panel-state.ts create mode 100644 src/components/folder-row.tsx create mode 100644 src/components/ui/breadcrumb.tsx create mode 100644 src/components/workspace-folder-workflow.ts create mode 100644 src/domains/chat/retrieval.test.ts create mode 100644 src/domains/folders/repository.ts create mode 100644 src/domains/folders/route-request.ts create mode 100644 src/domains/folders/route-service.ts create mode 100644 src/domains/folders/tree.test.ts create mode 100644 src/domains/folders/tree.ts create mode 100644 src/domains/folders/types.ts create mode 100644 src/domains/folders/workflow-runtime.ts create mode 100644 src/domains/sources/route-assign-folder.ts diff --git a/drizzle/0019_large_invaders.sql b/drizzle/0019_large_invaders.sql new file mode 100644 index 00000000..0580b654 --- /dev/null +++ b/drizzle/0019_large_invaders.sql @@ -0,0 +1,18 @@ +CREATE TABLE "folders" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "workspace_id" uuid NOT NULL, + "parent_id" uuid, + "name" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "deleted_at" timestamp with time zone +); +--> statement-breakpoint +ALTER TABLE "sources" ADD COLUMN "folder_id" uuid;--> statement-breakpoint +ALTER TABLE "folders" ADD CONSTRAINT "folders_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "folders" ADD CONSTRAINT "folders_parent_id_folders_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."folders"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "folders_workspace_parent_idx" ON "folders" USING btree ("workspace_id","parent_id");--> statement-breakpoint +CREATE UNIQUE INDEX "folders_workspace_parent_name_idx" ON "folders" USING btree ("workspace_id","parent_id","name") WHERE deleted_at IS NULL AND parent_id IS NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX "folders_workspace_root_name_idx" ON "folders" USING btree ("workspace_id","name") WHERE deleted_at IS NULL AND parent_id IS NULL;--> statement-breakpoint +ALTER TABLE "sources" ADD CONSTRAINT "sources_folder_id_folders_id_fk" FOREIGN KEY ("folder_id") REFERENCES "public"."folders"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "sources_workspace_folder_idx" ON "sources" USING btree ("workspace_id","folder_id") WHERE deleted_at IS NULL; \ No newline at end of file diff --git a/drizzle/meta/0019_snapshot.json b/drizzle/meta/0019_snapshot.json new file mode 100644 index 00000000..3c97a48a --- /dev/null +++ b/drizzle/meta/0019_snapshot.json @@ -0,0 +1,1136 @@ +{ + "id": "325d2bfd-d4cf-4c41-baf2-b93fc91afa5e", + "prevId": "01b8a746-ca75-4e79-adab-098a03a39e0e", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.chat_messages": { + "name": "chat_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "thread_id": { + "name": "thread_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "citations": { + "name": "citations", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "artifacts": { + "name": "artifacts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_messages_thread_created_idx": { + "name": "chat_messages_thread_created_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_messages_thread_id_chat_threads_id_fk": { + "name": "chat_messages_thread_id_chat_threads_id_fk", + "tableFrom": "chat_messages", + "tableTo": "chat_threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_threads": { + "name": "chat_threads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "demo_key": { + "name": "demo_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "chat_threads_workspace_updated_idx": { + "name": "chat_threads_workspace_updated_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "deleted_at IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_threads_workspace_demo_key_idx": { + "name": "chat_threads_workspace_demo_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "demo_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_threads_workspace_id_workspaces_id_fk": { + "name": "chat_threads_workspace_id_workspaces_id_fk", + "tableFrom": "chat_threads", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.demo_source_visibilities": { + "name": "demo_source_visibilities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "demo_source_id": { + "name": "demo_source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hidden_at": { + "name": "hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "demo_source_visibilities_workspace_source_idx": { + "name": "demo_source_visibilities_workspace_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "demo_source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "demo_source_visibilities_workspace_idx": { + "name": "demo_source_visibilities_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "demo_source_visibilities_workspace_id_workspaces_id_fk": { + "name": "demo_source_visibilities_workspace_id_workspaces_id_fk", + "tableFrom": "demo_source_visibilities", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.folders": { + "name": "folders", + "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 + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "folders_workspace_parent_idx": { + "name": "folders_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folders_workspace_parent_name_idx": { + "name": "folders_workspace_parent_name_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "deleted_at IS NULL AND parent_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "folders_workspace_root_name_idx": { + "name": "folders_workspace_root_name_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "deleted_at IS NULL AND parent_id IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "folders_workspace_id_workspaces_id_fk": { + "name": "folders_workspace_id_workspaces_id_fk", + "tableFrom": "folders", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folders_parent_id_folders_id_fk": { + "name": "folders_parent_id_folders_id_fk", + "tableFrom": "folders", + "tableTo": "folders", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.parsed_document_sync_leases": { + "name": "parsed_document_sync_leases", + "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 + }, + "source_id": { + "name": "source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision_key": { + "name": "revision_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_token": { + "name": "lease_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "acquired_at": { + "name": "acquired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "released_at": { + "name": "released_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "release_reason": { + "name": "release_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "parsed_document_sync_leases_token_idx": { + "name": "parsed_document_sync_leases_token_idx", + "columns": [ + { + "expression": "lease_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "parsed_document_sync_leases_active_idx": { + "name": "parsed_document_sync_leases_active_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "released_at IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "parsed_document_sync_leases_workspace_active_idx": { + "name": "parsed_document_sync_leases_workspace_active_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "released_at IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "parsed_document_sync_leases_document_active_idx": { + "name": "parsed_document_sync_leases_document_active_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "released_at IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "parsed_document_sync_leases_workspace_id_workspaces_id_fk": { + "name": "parsed_document_sync_leases_workspace_id_workspaces_id_fk", + "tableFrom": "parsed_document_sync_leases", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "parsed_document_sync_leases_source_id_sources_id_fk": { + "name": "parsed_document_sync_leases_source_id_sources_id_fk", + "tableFrom": "parsed_document_sync_leases", + "tableTo": "sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.source_parse_results": { + "name": "source_parse_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source_id": { + "name": "source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "result_blob_url": { + "name": "result_blob_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "snapshot_manifest_url": { + "name": "snapshot_manifest_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "snapshot_manifest_key": { + "name": "snapshot_manifest_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision_key": { + "name": "revision_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_status": { + "name": "sync_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "asset_urls": { + "name": "asset_urls", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "source_parse_results_source_id_idx": { + "name": "source_parse_results_source_id_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "source_parse_results_source_id_sources_id_fk": { + "name": "source_parse_results_source_id_sources_id_fk", + "tableFrom": "source_parse_results", + "tableTo": "sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "source_parse_results_source_id_unique": { + "name": "source_parse_results_source_id_unique", + "nullsNotDistinct": false, + "columns": [ + "source_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sources": { + "name": "sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_stage": { + "name": "failure_stage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "knowhere_job_id": { + "name": "knowhere_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "knowhere_document_id": { + "name": "knowhere_document_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "staged_blob_pathname": { + "name": "staged_blob_pathname", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "staged_blob_url": { + "name": "staged_blob_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_blob_pathname": { + "name": "original_blob_pathname", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_blob_url": { + "name": "original_blob_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "demo_key": { + "name": "demo_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sources_workspace_created_idx": { + "name": "sources_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "deleted_at IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "sources_workspace_status_idx": { + "name": "sources_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sources_workspace_demo_key_idx": { + "name": "sources_workspace_demo_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "demo_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sources_workspace_document_idx": { + "name": "sources_workspace_document_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "knowhere_document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "knowhere_document_id IS NOT NULL AND deleted_at IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "sources_workspace_folder_idx": { + "name": "sources_workspace_folder_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "deleted_at IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sources_workspace_id_workspaces_id_fk": { + "name": "sources_workspace_id_workspaces_id_fk", + "tableFrom": "sources", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sources_folder_id_folders_id_fk": { + "name": "sources_folder_id_folders_id_fk", + "tableFrom": "sources", + "tableTo": "folders", + "columnsFrom": [ + "folder_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspaces_user_id_idx": { + "name": "workspaces_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspaces_user_id_unique": { + "name": "workspaces_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + }, + "workspaces_namespace_unique": { + "name": "workspaces_namespace_unique", + "nullsNotDistinct": false, + "columns": [ + "namespace" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "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 84390235..a3a4315c 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -134,6 +134,13 @@ "when": 1789118032723, "tag": "0018_source_chunk_count", "breakpoints": true + }, + { + "idx": 19, + "version": "7", + "when": 1789124024330, + "tag": "0019_large_invaders", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/api/demo-sources/materialize/route.test.ts b/src/app/api/demo-sources/materialize/route.test.ts index 3a3be24d..618dc2c3 100644 --- a/src/app/api/demo-sources/materialize/route.test.ts +++ b/src/app/api/demo-sources/materialize/route.test.ts @@ -242,6 +242,7 @@ function makeSource( originalBlobUrl: "https://example.com/tsla-q4-2025.pdf", demoKey: "demo-tsla-q4-2025", chunkCount: null, + folderId: null, createdAt: new Date("2026-05-10T00:00:00.000Z"), updatedAt: new Date("2026-05-10T00:00:00.000Z"), deletedAt: null, diff --git a/src/app/api/folders/[folderId]/route.ts b/src/app/api/folders/[folderId]/route.ts new file mode 100644 index 00000000..dc680e81 --- /dev/null +++ b/src/app/api/folders/[folderId]/route.ts @@ -0,0 +1,45 @@ +import type { NextRequest, NextResponse } from "next/server" + +import { folderRouteRequest } from "@/domains/folders/route-request" +import { folderRouteService } from "@/domains/folders/route-service" +import { withApiErrorResponse } from "@/lib/api-error-response" +import { nextRouteResponse } from "@/lib/next-route-response" + +type RouteContext = { + params: Promise<{ + folderId: string + }> +} + +export async function PATCH( + request: NextRequest, + context: RouteContext, +): Promise { + return withApiErrorResponse("folders:update", async () => { + const { folderId } = await context.params + const updateRequest = await folderRouteRequest.readUpdateFolder({ request }) + if (!updateRequest.ok) { + return nextRouteResponse.toNextResponse(updateRequest.result) + } + + return nextRouteResponse.toNextResponse( + await folderRouteService.updateFolder({ + folderId, + name: updateRequest.name, + parentId: updateRequest.parentId, + }), + ) + }) +} + +export async function DELETE( + _request: NextRequest, + context: RouteContext, +): Promise { + return withApiErrorResponse("folders:delete", async () => { + const { folderId } = await context.params + return nextRouteResponse.toNextResponse( + await folderRouteService.deleteFolder({ folderId }), + ) + }) +} diff --git a/src/app/api/folders/route.ts b/src/app/api/folders/route.ts new file mode 100644 index 00000000..cd9e6add --- /dev/null +++ b/src/app/api/folders/route.ts @@ -0,0 +1,28 @@ +import type { NextRequest, NextResponse } from "next/server" + +import { folderRouteRequest } from "@/domains/folders/route-request" +import { folderRouteService } from "@/domains/folders/route-service" +import { withApiErrorResponse } from "@/lib/api-error-response" +import { nextRouteResponse } from "@/lib/next-route-response" + +export async function GET(): Promise { + return withApiErrorResponse("folders:list", async () => + nextRouteResponse.toNextResponse(await folderRouteService.listFolders()), + ) +} + +export async function POST(request: NextRequest): Promise { + return withApiErrorResponse("folders:create", async () => { + const createRequest = await folderRouteRequest.readCreateFolder({ request }) + if (!createRequest.ok) { + return nextRouteResponse.toNextResponse(createRequest.result) + } + + return nextRouteResponse.toNextResponse( + await folderRouteService.createFolder({ + name: createRequest.name, + parentId: createRequest.parentId, + }), + ) + }) +} diff --git a/src/app/api/sources/[sourceId]/chunks/route.test.ts b/src/app/api/sources/[sourceId]/chunks/route.test.ts index e38d9a8f..3ff41881 100644 --- a/src/app/api/sources/[sourceId]/chunks/route.test.ts +++ b/src/app/api/sources/[sourceId]/chunks/route.test.ts @@ -312,6 +312,7 @@ describe("GET /api/sources/[sourceId]/chunks", () => { originalBlobUrl: "/api/demo-sources/demo-tsla-q4-2025/original", demoKey: "demo-tsla-q4-2025", chunkCount: null, + folderId: null, createdAt: new Date("2026-05-10T00:00:00.000Z"), updatedAt: new Date("2026-05-10T00:00:00.000Z"), deletedAt: null, @@ -847,6 +848,7 @@ function makeReadySource(overrides: Record) { originalBlobUrl: null, demoKey: null, chunkCount: null, + folderId: null, createdAt: new Date("2026-05-10T00:00:00.000Z"), updatedAt: new Date("2026-05-10T00:00:00.000Z"), deletedAt: null, diff --git a/src/app/api/sources/[sourceId]/route.test.ts b/src/app/api/sources/[sourceId]/route.test.ts index b9cbcd60..034d6777 100644 --- a/src/app/api/sources/[sourceId]/route.test.ts +++ b/src/app/api/sources/[sourceId]/route.test.ts @@ -344,6 +344,7 @@ describe("PATCH /api/sources/[sourceId]", () => { "https://store.public.blob.vercel-storage.com/source-uploads/upload_1/document.pdf", demoKey: null, chunkCount: null, + folderId: null, createdAt: new Date("2026-05-10T00:00:00Z"), updatedAt: new Date("2026-05-10T00:00:00Z"), deletedAt: null, @@ -377,6 +378,7 @@ describe("PATCH /api/sources/[sourceId]", () => { "https://store.public.blob.vercel-storage.com/source-uploads/upload_1/document.pdf", demoKey: null, chunkCount: null, + folderId: null, createdAt: new Date("2026-05-10T00:00:00Z"), updatedAt: new Date("2026-05-10T00:00:00Z"), deletedAt: null, diff --git a/src/app/api/sources/[sourceId]/route.ts b/src/app/api/sources/[sourceId]/route.ts index 9d05d816..0104b42b 100644 --- a/src/app/api/sources/[sourceId]/route.ts +++ b/src/app/api/sources/[sourceId]/route.ts @@ -31,7 +31,11 @@ export async function PATCH( const result = mutationRequest.mutation.kind === "archive" ? await sourceRouteService.archiveSource(mutationRequest.mutation.input) - : await sourceRouteService.retrySource(mutationRequest.mutation.input) + : mutationRequest.mutation.kind === "retry" + ? await sourceRouteService.retrySource(mutationRequest.mutation.input) + : await sourceRouteService.assignSourceFolder( + mutationRequest.mutation.input, + ) return nextRouteResponse.toNextResponse(result) } diff --git a/src/app/api/sources/route.test.ts b/src/app/api/sources/route.test.ts index 154efa20..edc83a26 100644 --- a/src/app/api/sources/route.test.ts +++ b/src/app/api/sources/route.test.ts @@ -76,6 +76,7 @@ const source: Source = { originalBlobUrl: null, demoKey: null, chunkCount: null, + folderId: null, createdAt: new Date("2026-05-10T00:00:00Z"), updatedAt: new Date("2026-05-10T00:00:00Z"), deletedAt: null, diff --git a/src/components/folder-destination-menu.tsx b/src/components/folder-destination-menu.tsx new file mode 100644 index 00000000..6ad20a45 --- /dev/null +++ b/src/components/folder-destination-menu.tsx @@ -0,0 +1,56 @@ +"use client" + +import type { ReactElement } from "react" +import { Folder } from "lucide-react" + +import { + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, +} from "@/components/ui/dropdown-menu" +import { getFolderPath } from "@/domains/folders/tree" +import type { FolderView } from "@/domains/folders/types" + +export type FolderDestinationMenuProps = { + readonly folders: readonly FolderView[] + readonly excludedFolderIds?: ReadonlySet + readonly onSelect: (folderId: string | null) => void +} + +export function FolderDestinationMenu({ + folders, + excludedFolderIds, + onSelect, +}: FolderDestinationMenuProps): ReactElement { + const destinations = folders.filter( + (folder) => !excludedFolderIds?.has(folder.id), + ) + + return ( + <> + Move to + onSelect(null)}> + All sources + + {destinations.length > 0 ? : null} + {destinations.map((folder) => ( + onSelect(folder.id)} + > + + {formatFolderDestination(folders, folder.id)} + + ))} + + ) +} + +function formatFolderDestination( + folders: readonly FolderView[], + folderId: string, +): string { + return getFolderPath(folders, folderId) + .map((folder) => folder.name) + .join(" / ") +} diff --git a/src/components/folder-panel-state.ts b/src/components/folder-panel-state.ts new file mode 100644 index 00000000..16bffcab --- /dev/null +++ b/src/components/folder-panel-state.ts @@ -0,0 +1,49 @@ +import { + getFolderPath, + listChildFolders, + listSourcesInFolder, +} from "@/domains/folders/tree" +import type { FolderView } from "@/domains/folders/types" +import type { SourceView } from "@/domains/sources/types" + +export type FolderListItem = + | { + readonly kind: "folder" + readonly folder: FolderView + } + | { + readonly kind: "source" + readonly source: SourceView + } + +export function getFolderListItems( + folders: readonly FolderView[], + sources: readonly SourceView[], + currentFolderId: string | null, +): FolderListItem[] { + return [ + ...listChildFolders(folders, currentFolderId).map( + (folder): FolderListItem => ({ kind: "folder", folder }), + ), + ...listSourcesInFolder(sources, currentFolderId).map( + (source): FolderListItem => ({ kind: "source", source }), + ), + ] +} + +export function getResolvedCurrentFolderId( + folders: readonly FolderView[], + currentFolderId: string | null, +): string | null { + if (!currentFolderId) return null + return folders.some((folder) => folder.id === currentFolderId) + ? currentFolderId + : null +} + +export function getFolderBreadcrumb( + folders: readonly FolderView[], + currentFolderId: string | null, +): FolderView[] { + return getFolderPath(folders, currentFolderId) +} diff --git a/src/components/folder-row.tsx b/src/components/folder-row.tsx new file mode 100644 index 00000000..0f749df8 --- /dev/null +++ b/src/components/folder-row.tsx @@ -0,0 +1,115 @@ +"use client" + +import type { ReactElement } from "react" +import { Folder, MoreHorizontal, Pencil, Trash2 } from "lucide-react" + +import { FolderDestinationMenu } from "@/components/folder-destination-menu" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Spinner } from "@/components/ui/spinner" +import { getDescendantFolderIds } from "@/domains/folders/tree" +import type { FolderView } from "@/domains/folders/types" + +export type FolderRowProps = { + readonly folder: FolderView + readonly folders: readonly FolderView[] + readonly isDeleting?: boolean + readonly isNarrow?: boolean + readonly onDelete: (folderId: string) => void + readonly onMove: (folderId: string, parentId: string | null) => void + readonly onOpen: (folderId: string) => void + readonly onRename: (folderId: string) => void +} + +export function FolderRow({ + folder, + folders, + isDeleting = false, + isNarrow = false, + onDelete, + onMove, + onOpen, + onRename, +}: FolderRowProps): ReactElement { + const excludedFolderIds = new Set(getDescendantFolderIds(folders, folder.id)) + + return ( +
+ + + + + + + onRename(folder.id)}> + + Rename + + + Move to… + + onMove(folder.id, parentId)} + /> + + + + onDelete(folder.id)}> + + Delete + + + +
+ ) +} diff --git a/src/components/source-row.tsx b/src/components/source-row.tsx index 473453f3..4f951961 100644 --- a/src/components/source-row.tsx +++ b/src/components/source-row.tsx @@ -2,10 +2,18 @@ import type { ReactElement } from "react"; import Link from "next/link"; -import { FileText, ListTree, Plus, RotateCcw, Trash2 } from "lucide-react"; +import { FileText, FolderInput, ListTree, Plus, RotateCcw, Trash2 } from "lucide-react"; +import { FolderDestinationMenu } from "@/components/folder-destination-menu"; import { Checkbox } from "@/components/ui/checkbox"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Spinner } from "@/components/ui/spinner"; +import { canAssignSourceToFolder } from "@/domains/folders/tree"; +import type { FolderView } from "@/domains/folders/types"; import type { SourceView } from "@/domains/sources/types"; export type SourceRowProps = { @@ -20,6 +28,9 @@ export type SourceRowProps = { readonly onRetryClick?: (sourceId: string) => void; readonly onSelect: () => void; readonly onToggleIncluded?: (sourceId: string, included: boolean) => void; + readonly folders?: readonly FolderView[]; + readonly isMoving?: boolean; + readonly onMoveToFolder?: (sourceId: string, folderId: string | null) => void; readonly source: SourceView; }; @@ -36,8 +47,12 @@ export function SourceRow({ chunkTreeHref, isArchiving, isRetrying = false, + folders = [], + isMoving = false, + onMoveToFolder, }: SourceRowProps): ReactElement { const isReady = source.status === "ready"; + const canMove = canAssignSourceToFolder(source) && onMoveToFolder !== undefined; const isBusy = source.status === "uploading" || source.status === "parsing"; const isFailed = source.status === "failed"; const canRetry = isFailed && source.originalFile !== undefined; @@ -146,6 +161,31 @@ export function SourceRow({ {isNarrow ? null : "Add"} )} + {canMove ? ( + + + + + + onMoveToFolder?.(source.id, folderId)} + /> + + + ) : null} {canRetry && onRetryClick ? ( + + +
{onLoginClick ? ( @@ -199,6 +346,25 @@ export function SourcesPanel({ sourceCountSnapshot={sourceCountSnapshot} /> )} + {onCreateFolder && !onLoginClick ? ( + + ) : null}
@@ -221,39 +387,70 @@ export function SourcesPanel({ {isNarrow ? null : "open library"}
+ {onOpenFolder ? ( + + ) : null} - {workspaceSources.length === 0 ? ( + {listItems.length === 0 ? ( ) : (
- {sourcePagination.sources.map((source) => ( - - onSelectSource?.( - sourcePanelState.getNextSelectedSourceId({ - sourceId: source.id, - }), - ) - } - onToggleIncluded={onToggleIncluded} - onArchiveClick={ - onArchiveSource ? setConfirmSourceId : undefined - } - onRetryClick={onRetrySource} - isArchiving={archivingSourceIdSet.has(source.id)} - isRetrying={retryingSourceIdSet.has(source.id)} - isNarrow={isNarrow} - /> - ))} + {sourcePagination.items.map((item) => + item.kind === "folder" ? ( + { + void onMoveFolder?.(folderId, parentId); + }} + onOpen={(folderId) => onOpenFolder?.(folderId)} + onRename={(folderId) => { + const folder = folderRows.find( + (candidate) => candidate.id === folderId, + ); + setFolderName(folder?.name ?? ""); + setFolderNameDialog({ kind: "rename", folderId }); + }} + /> + ) : ( + + onSelectSource?.( + sourcePanelState.getNextSelectedSourceId({ + sourceId: item.source.id, + }), + ) + } + onToggleIncluded={onToggleIncluded} + onArchiveClick={ + onArchiveSource ? setConfirmSourceId : undefined + } + onRetryClick={onRetrySource} + folders={folderRows} + isMoving={movingSourceIdSet.has(item.source.id)} + onMoveToFolder={onMoveSourceToFolder} + isArchiving={archivingSourceIdSet.has(item.source.id)} + isRetrying={retryingSourceIdSet.has(item.source.id)} + isNarrow={isNarrow} + /> + ), + )}
)}
- {workspaceSources.length > sourceListPageSize ? ( + {listItems.length > sourceListPageSize ? ( source.id === selectedSourceId, + const selectedIndex = items.findIndex( + (item) => item.kind === "source" && item.source.id === selectedSourceId, ); return selectedIndex >= 0 ? getSourcePageForIndex(selectedIndex) : null; } +function FolderBreadcrumb({ + folders, + onOpenFolder, +}: { + readonly folders: readonly FolderView[]; + readonly onOpenFolder: (folderId: string | null) => void; +}): ReactElement { + return ( + + + + {folders.length === 0 ? ( + All sources + ) : ( + { + event.preventDefault(); + onOpenFolder(null); + }} + > + All sources + + )} + + {folders.map((folder, index) => ( + + + {index === folders.length - 1 ? ( + {folder.name} + ) : ( + { + event.preventDefault(); + onOpenFolder(folder.id); + }} + > + {folder.name} + + )} + + ))} + + + ); +} + function getChunkTreeHref(source: SourceView): string | undefined { if (source.documentPresentation?.kind === "page-assets") return undefined; return source.documentId diff --git a/src/components/ui/breadcrumb.tsx b/src/components/ui/breadcrumb.tsx new file mode 100644 index 00000000..58858ea7 --- /dev/null +++ b/src/components/ui/breadcrumb.tsx @@ -0,0 +1,113 @@ +import { cn } from "@/lib/utils" +import { Slot } from "@radix-ui/react-slot" +import { ChevronRight, MoreHorizontal } from "lucide-react" +import * as React from "react" + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<"nav"> & { + separator?: React.ReactNode + } +>(({ ...props }, ref) =>