From 3bf7de2a85f2f7976db3524bc01f5934c4e8f884 Mon Sep 17 00:00:00 2001 From: quannguyen Date: Tue, 17 Mar 2026 20:45:46 +0700 Subject: [PATCH 1/6] feat: Add custom metadata to documents and vector chunks --- backend/app/api/documents.py | 18 +++++++++++++++++- backend/app/api/rag.py | 1 + backend/app/models/document.py | 3 ++- backend/app/schemas/document.py | 1 + backend/app/schemas/rag.py | 1 + backend/app/services/deep_retriever.py | 12 +++++++++--- backend/app/services/nexus_rag_service.py | 21 +++++++++++++++------ backend/app/services/rag_service.py | 11 +++++++---- tasks/lessons.md | 2 ++ tasks/todo.md | 23 +++++++++++++++++++++++ 10 files changed, 78 insertions(+), 15 deletions(-) create mode 100644 tasks/lessons.md create mode 100644 tasks/todo.md diff --git a/backend/app/api/documents.py b/backend/app/api/documents.py index 71fd709..176303a 100644 --- a/backend/app/api/documents.py +++ b/backend/app/api/documents.py @@ -6,9 +6,10 @@ import logging from pathlib import Path -from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, BackgroundTasks +from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, BackgroundTasks, Form from fastapi.responses import PlainTextResponse from sqlalchemy.ext.asyncio import AsyncSession +import json from sqlalchemy import select from app.core.config import settings @@ -126,9 +127,23 @@ async def process_document_background(document_id: int, file_path: str, workspac async def upload_document( workspace_id: int, file: UploadFile = File(...), + custom_metadata: str | None = Form(None), db: AsyncSession = Depends(get_db), ): """Upload a document to a knowledge base. Processing must be triggered separately.""" + + parsed_metadata = None + if custom_metadata: + try: + parsed_metadata = json.loads(custom_metadata) + if not isinstance(parsed_metadata, dict): + raise ValueError("Metadata must be a JSON object") + except Exception as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid custom_metadata JSON: {e}" + ) + result = await db.execute(select(KnowledgeBase).where(KnowledgeBase.id == workspace_id)) kb = result.scalar_one_or_none() @@ -163,6 +178,7 @@ async def upload_document( file_type=ext[1:], file_size=len(content), status=DocumentStatus.PENDING, + custom_metadata=parsed_metadata, ) db.add(document) await db.commit() diff --git a/backend/app/api/rag.py b/backend/app/api/rag.py index 0125498..883f3f9 100644 --- a/backend/app/api/rag.py +++ b/backend/app/api/rag.py @@ -103,6 +103,7 @@ async def query_documents( top_k=request.top_k, document_ids=request.document_ids, mode=request.mode, + metadata_filter=request.metadata_filter, ) chunks_response = [] diff --git a/backend/app/models/document.py b/backend/app/models/document.py index 76896f4..5e046eb 100644 --- a/backend/app/models/document.py +++ b/backend/app/models/document.py @@ -1,4 +1,4 @@ -from sqlalchemy import String, ForeignKey, DateTime, Integer, Text, Enum +from sqlalchemy import String, ForeignKey, DateTime, Integer, Text, Enum, JSON from sqlalchemy.orm import Mapped, mapped_column, relationship from datetime import datetime import enum @@ -41,6 +41,7 @@ class Document(Base): table_count: Mapped[int] = mapped_column(Integer, default=0) parser_version: Mapped[str | None] = mapped_column(String(50), nullable=True) # "docling" | "legacy" processing_time_ms: Mapped[int] = mapped_column(Integer, default=0) + custom_metadata: Mapped[dict | None] = mapped_column(JSON, nullable=True) # Relationships workspace: Mapped["KnowledgeBase"] = relationship(back_populates="documents") diff --git a/backend/app/schemas/document.py b/backend/app/schemas/document.py index 046d871..c3aad72 100644 --- a/backend/app/schemas/document.py +++ b/backend/app/schemas/document.py @@ -28,6 +28,7 @@ class DocumentResponse(DocumentBase): table_count: int = 0 parser_version: str | None = None processing_time_ms: int = 0 + custom_metadata: dict | None = None model_config = {"from_attributes": True} diff --git a/backend/app/schemas/rag.py b/backend/app/schemas/rag.py index fabae7d..88c6ff6 100644 --- a/backend/app/schemas/rag.py +++ b/backend/app/schemas/rag.py @@ -10,6 +10,7 @@ class RAGQueryRequest(BaseModel): question: str = Field(..., min_length=1, max_length=1000, description="The question to query") top_k: int = Field(default=5, ge=1, le=20, description="Number of chunks to retrieve") document_ids: list[int] | None = Field(default=None, description="Filter to specific document IDs") + metadata_filter: dict | None = Field(default=None, description="Optional metadata filter for vector search") mode: str = Field( default="hybrid", description="Search mode: hybrid (default), vector_only, naive, local, global" diff --git a/backend/app/services/deep_retriever.py b/backend/app/services/deep_retriever.py index d8cbf69..5aa6b87 100644 --- a/backend/app/services/deep_retriever.py +++ b/backend/app/services/deep_retriever.py @@ -65,6 +65,7 @@ async def query( top_k: int = 5, document_ids: Optional[list[int]] = None, include_images: bool = True, + metadata_filter: dict | None = None, ) -> DeepRetrievalResult: """ Execute hybrid retrieval with reranking. @@ -96,7 +97,7 @@ async def query( prefetch_k = max(settings.NEXUSRAG_VECTOR_PREFETCH, top_k * 3) vector_task = asyncio.create_task( asyncio.to_thread( - self._vector_query, question, prefetch_k, document_ids + self._vector_query, question, prefetch_k, document_ids, metadata_filter ) ) @@ -165,13 +166,18 @@ def _vector_query( question: str, top_k: int, document_ids: Optional[list[int]], + metadata_filter: dict | None = None, ) -> tuple[list[EnrichedChunk], list[Citation]]: """Synchronous vector search via ChromaDB (over-fetch stage).""" query_embedding = self.embedder.embed_query(question) - where = None + # Merge metadata_filter and document_ids + where = metadata_filter.copy() if metadata_filter else {} if document_ids: - where = {"document_id": {"$in": document_ids}} + where["document_id"] = {"$in": document_ids} + + if not where: + where = None results = self.vector_store.query( query_embedding=query_embedding, diff --git a/backend/app/services/nexus_rag_service.py b/backend/app/services/nexus_rag_service.py index a3f113e..596b115 100644 --- a/backend/app/services/nexus_rag_service.py +++ b/backend/app/services/nexus_rag_service.py @@ -199,8 +199,9 @@ def _index_sync(): for img in parsed.images } - metadatas = [ - { + metadatas = [] + for c in parsed.chunks: + meta = { "document_id": document_id, "chunk_index": c.chunk_index, "source": c.source_file, @@ -216,8 +217,9 @@ def _index_sync(): _img_url_map.get(iid, "") for iid in c.image_refs ) if c.image_refs else "", } - for c in parsed.chunks - ] + if document.custom_metadata: + meta.update(document.custom_metadata) + metadatas.append(meta) self.vector_store.add_documents( ids=ids, @@ -268,6 +270,7 @@ def query( question: str, top_k: int = 5, document_ids: Optional[list[int]] = None, + metadata_filter: dict | None = None, ) -> RAGQueryResult: """ Backward-compatible sync query (vector-only). @@ -275,9 +278,13 @@ def query( """ query_embedding = self.embedder.embed_query(question) - where = None + # Merge metadata_filter and document_ids + where = metadata_filter.copy() if metadata_filter else {} if document_ids: - where = {"document_id": {"$in": document_ids}} + where["document_id"] = {"$in": document_ids} + + if not where: + where = None results = self.vector_store.query( query_embedding=query_embedding, @@ -325,6 +332,7 @@ async def query_deep( document_ids: Optional[list[int]] = None, mode: str = "hybrid", include_images: bool = True, + metadata_filter: dict | None = None, ) -> DeepRetrievalResult: """ Full async hybrid retrieval with KG + vector + images + citations. @@ -335,6 +343,7 @@ async def query_deep( top_k=top_k, document_ids=document_ids, include_images=include_images, + metadata_filter=metadata_filter, ) # ------------------------------------------------------------------ diff --git a/backend/app/services/rag_service.py b/backend/app/services/rag_service.py index f3b14cf..b7a38bb 100644 --- a/backend/app/services/rag_service.py +++ b/backend/app/services/rag_service.py @@ -123,8 +123,9 @@ def _process_sync(): # Prepare data for vector store ids = [f"doc_{document_id}_chunk_{i}" for i in range(len(chunks))] - metadatas = [ - { + metadatas = [] + for c in chunks: + meta = { "document_id": document_id, "chunk_index": c.chunk_index, "char_start": c.char_start, @@ -132,8 +133,9 @@ def _process_sync(): "source": c.metadata.get("source", ""), "file_type": c.metadata.get("file_type", "") } - for c in chunks - ] + if document.custom_metadata: + meta.update(document.custom_metadata) + metadatas.append(meta) # Store in vector database logger.info(f"Storing {len(chunks)} chunks in vector store") @@ -155,6 +157,7 @@ def _process_sync(): logger.warning(f"Document {document_id} produced no chunks (empty content)") return 0 + # Update document status document.status = DocumentStatus.INDEXED document.chunk_count = len(chunks) diff --git a/tasks/lessons.md b/tasks/lessons.md new file mode 100644 index 0000000..cf2a977 --- /dev/null +++ b/tasks/lessons.md @@ -0,0 +1,2 @@ +# Lessons Learned +_To be updated as mistakes or clarifications arise during the task._ diff --git a/tasks/todo.md b/tasks/todo.md new file mode 100644 index 0000000..281c092 --- /dev/null +++ b/tasks/todo.md @@ -0,0 +1,23 @@ +# Support Postgres as Vector DB + +## Goal +Add an option to use PostgreSQL (via pgvector) as a vector database instead of just ChromaDB. + +## Success Criteria +1. Configuration setting `VECTOR_DB_PROVIDER` (`chroma` or `postgres`) is added and properly respected. +2. Abstract VectorStore interface or a consistent duck-typing signature is defined. +3. Existing `VectorStore` in `vector_store.py` is renamed/moved to `ChromaVectorStore`. +4. A new `PostgresVectorStore` is created. +5. `pgvector` dependency is added to `backend/requirements.txt` and DB initialized properly with `pgvector` extension. +6. The factory methods provide the correct vector store instance. +7. Postgres vector database configuration is available in `docker-compose.yml`. + +## Steps +- [ ] List directory structure and examine database models/migrations to understand how to add `pgvector`. +- [ ] Create detailed Implementation Plan (pseudocode) for approval. +- [ ] Update `backend/app/core/config.py`. +- [ ] Define abstract vector store and implement `ChromaVectorStore` & `PostgresVectorStore`. +- [ ] Update database setup (migrations or init scripts) to initialize `vector` extension and table if using PG. +- [ ] Update `backend/requirements.txt`. +- [ ] Update `docker-compose.yml` and related environment variables. +- [ ] Verify functionality (testing with both providers). From 4c22c987bca4cedd8d77b3e96847fc177d483c8c Mon Sep 17 00:00:00 2001 From: quannguyen Date: Tue, 17 Mar 2026 20:55:10 +0700 Subject: [PATCH 2/6] feat: Modify custom_metadata upload API to accept list of key-value pairs --- backend/app/api/documents.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/backend/app/api/documents.py b/backend/app/api/documents.py index 176303a..a599f91 100644 --- a/backend/app/api/documents.py +++ b/backend/app/api/documents.py @@ -135,13 +135,19 @@ async def upload_document( parsed_metadata = None if custom_metadata: try: - parsed_metadata = json.loads(custom_metadata) - if not isinstance(parsed_metadata, dict): - raise ValueError("Metadata must be a JSON object") + raw_metadata = json.loads(custom_metadata) + if not isinstance(raw_metadata, list): + raise ValueError("Metadata must be a list of key-value objects") + + parsed_metadata = {} + for item in raw_metadata: + if not isinstance(item, dict) or "key" not in item or "value" not in item: + raise ValueError("Each metadata item must contain 'key' and 'value' fields") + parsed_metadata[item["key"]] = item["value"] except Exception as e: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Invalid custom_metadata JSON: {e}" + detail=f"Invalid custom_metadata format: {e}" ) result = await db.execute(select(KnowledgeBase).where(KnowledgeBase.id == workspace_id)) From c1bfac6a9b742f0a1ef2c44625d2cf9c51d8bcdb Mon Sep 17 00:00:00 2001 From: quannguyen Date: Tue, 17 Mar 2026 21:34:55 +0700 Subject: [PATCH 3/6] feat: Add custom metadata support for documents with a new frontend input component and backend database migration. --- backend/alembic.ini | 89 +++++++++++++++++ backend/alembic/README | 1 + backend/alembic/env.py | 97 +++++++++++++++++++ backend/alembic/script.py.mako | 28 ++++++ ...0692d0_add_custom_metadata_to_documents.py | 32 ++++++ backend/app/main.py | 64 +++++------- backend/app/schema.sql | 90 +++++++++++++++++ .../components/rag/CustomMetadataInput.tsx | 95 ++++++++++++++++++ frontend/src/components/rag/DataPanel.tsx | 25 ++++- frontend/src/lib/api.ts | 6 +- frontend/src/pages/WorkspacePage.tsx | 6 +- 11 files changed, 485 insertions(+), 48 deletions(-) create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/README create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/script.py.mako create mode 100644 backend/alembic/versions/2047460692d0_add_custom_metadata_to_documents.py create mode 100644 backend/app/schema.sql create mode 100644 frontend/src/components/rag/CustomMetadataInput.tsx diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..5e1c30e --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,89 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# file_template = %%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python-dateutil library that can be +# installed by adding it to the alembic section in setup.py +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. +# version_path_separator = : + +# sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/README b/backend/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/backend/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..b8de717 --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,97 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +import os +import sys + +# Add the parent directory of 'alembic' to the Python path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from app.core.database import Base +from app.core.config import settings + +# Import all models here so Alembic can discover them +from app.models.document import Document, DocumentImage, DocumentTable +from app.models.knowledge_base import KnowledgeBase +from app.models.chat_message import ChatMessage + +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +config.set_main_option("sqlalchemy.url", settings.DATABASE_URL) +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + configuration = config.get_section(config.config_ini_section, {}) + url = config.get_main_option("sqlalchemy.url") + if url and url.startswith("postgresql+asyncpg"): + url = url.replace("postgresql+asyncpg", "postgresql") + configuration["sqlalchemy.url"] = url + + connectable = engine_from_config( + configuration, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/2047460692d0_add_custom_metadata_to_documents.py b/backend/alembic/versions/2047460692d0_add_custom_metadata_to_documents.py new file mode 100644 index 0000000..d796c1a --- /dev/null +++ b/backend/alembic/versions/2047460692d0_add_custom_metadata_to_documents.py @@ -0,0 +1,32 @@ +"""Add custom_metadata to documents + +Revision ID: 2047460692d0 +Revises: +Create Date: 2026-03-17 14:09:11.881981 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '2047460692d0' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('documents', sa.Column('custom_metadata', sa.JSON(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('documents', 'custom_metadata') + # ### end Alembic commands ### diff --git a/backend/app/main.py b/backend/app/main.py index a6a5545..07e31e8 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -28,46 +28,32 @@ async def lifespan(app: FastAPI): auto_create = os.environ.get("AUTO_CREATE_TABLES", "true").lower() == "true" if auto_create: async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - # Auto-migrate: add new columns if missing - await conn.execute( - text("ALTER TABLE knowledge_bases ADD COLUMN IF NOT EXISTS system_prompt TEXT") + # Check if tables already exist (e.g., alembic_version) + result = await conn.execute( + text("SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'alembic_version');") ) - # Ensure chat_messages table + indexes exist (idempotent) - await conn.execute(text(""" - CREATE TABLE IF NOT EXISTS chat_messages ( - id SERIAL PRIMARY KEY, - workspace_id INTEGER NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE, - message_id VARCHAR(50) NOT NULL, - role VARCHAR(20) NOT NULL, - content TEXT NOT NULL, - sources JSON, - related_entities JSON, - image_refs JSON, - thinking TEXT, - created_at TIMESTAMP DEFAULT NOW() - ) - """)) - await conn.execute(text( - "CREATE INDEX IF NOT EXISTS ix_chat_messages_workspace_id ON chat_messages(workspace_id)" - )) - await conn.execute(text( - "CREATE INDEX IF NOT EXISTS ix_chat_messages_message_id ON chat_messages(message_id)" - )) - await conn.execute(text( - "ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS ratings JSON" - )) - await conn.execute(text( - "ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS agent_steps JSON" - )) - # Auto-migrate: add workspace settings columns - await conn.execute( - text("ALTER TABLE knowledge_bases ADD COLUMN IF NOT EXISTS kg_language VARCHAR(50)") - ) - await conn.execute( - text("ALTER TABLE knowledge_bases ADD COLUMN IF NOT EXISTS kg_entity_types JSON") - ) - logger.info("Database tables created/verified") + is_initialized = result.scalar() + + if not is_initialized: + schema_path = os.path.join(os.path.dirname(__file__), "schema.sql") + if os.path.exists(schema_path): + with open(schema_path, "r", encoding="utf-8") as f: + schema_sql = f.read() + + # Split and execute each statement to avoid asyncpg multi-statement issues + for statement in schema_sql.split(';'): + stmt = statement.strip() + if stmt: + await conn.execute(text(stmt)) + logger.info("Database tables created from schema.sql") + + # Stamp the alembic version + await conn.execute(text("INSERT INTO public.alembic_version (version_num) VALUES ('2047460692d0') ON CONFLICT DO NOTHING;")) + else: + await conn.run_sync(Base.metadata.create_all) + logger.info("Database tables created/verified (Base.metadata.create_all)") + else: + logger.info("Database is already initialized.") # Recover stale processing documents (stuck from previous runs) from app.models.document import Document, DocumentStatus diff --git a/backend/app/schema.sql b/backend/app/schema.sql new file mode 100644 index 0000000..c8797bf --- /dev/null +++ b/backend/app/schema.sql @@ -0,0 +1,90 @@ +-- Cleaned up schema.sql + +CREATE TYPE public.documentstatus AS ENUM ( + 'PENDING', + 'PARSING', + 'PROCESSING', + 'INDEXING', + 'INDEXED', + 'FAILED' +); + +CREATE TABLE public.alembic_version ( + version_num character varying(32) PRIMARY KEY +); + +CREATE TABLE public.knowledge_bases ( + id SERIAL PRIMARY KEY, + name character varying(255) NOT NULL, + description text, + system_prompt text, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL, + kg_language character varying(50), + kg_entity_types json +); + +CREATE TABLE public.chat_messages ( + id SERIAL PRIMARY KEY, + workspace_id integer NOT NULL REFERENCES public.knowledge_bases(id) ON DELETE CASCADE, + message_id character varying(50) NOT NULL, + role character varying(20) NOT NULL, + content text NOT NULL, + sources json, + related_entities json, + image_refs json, + thinking text, + ratings json, + agent_steps json, + created_at timestamp without time zone NOT NULL +); + +CREATE INDEX ix_chat_messages_id ON public.chat_messages USING btree (id); +CREATE INDEX ix_chat_messages_message_id ON public.chat_messages USING btree (message_id); +CREATE INDEX ix_chat_messages_workspace_id ON public.chat_messages USING btree (workspace_id); + +CREATE TABLE public.documents ( + id SERIAL PRIMARY KEY, + workspace_id integer NOT NULL REFERENCES public.knowledge_bases(id) ON DELETE CASCADE, + filename character varying(255) NOT NULL, + original_filename character varying(255) NOT NULL, + file_type character varying(50) NOT NULL, + file_size integer NOT NULL, + status public.documentstatus NOT NULL, + chunk_count integer NOT NULL, + error_message character varying(500), + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL, + markdown_content text, + page_count integer NOT NULL, + image_count integer NOT NULL, + table_count integer NOT NULL, + parser_version character varying(50), + processing_time_ms integer NOT NULL, + custom_metadata json +); + +CREATE TABLE public.document_images ( + id SERIAL PRIMARY KEY, + document_id integer NOT NULL REFERENCES public.documents(id) ON DELETE CASCADE, + image_id character varying(100) NOT NULL UNIQUE, + page_no integer NOT NULL, + file_path character varying(500) NOT NULL, + caption text NOT NULL, + width integer NOT NULL, + height integer NOT NULL, + mime_type character varying(50) NOT NULL, + created_at timestamp without time zone NOT NULL +); + +CREATE TABLE public.document_tables ( + id SERIAL PRIMARY KEY, + document_id integer NOT NULL REFERENCES public.documents(id) ON DELETE CASCADE, + table_id character varying(100) NOT NULL UNIQUE, + page_no integer NOT NULL, + content_markdown text NOT NULL, + caption text NOT NULL, + num_rows integer NOT NULL, + num_cols integer NOT NULL, + created_at timestamp without time zone NOT NULL +); diff --git a/frontend/src/components/rag/CustomMetadataInput.tsx b/frontend/src/components/rag/CustomMetadataInput.tsx new file mode 100644 index 0000000..20b3be5 --- /dev/null +++ b/frontend/src/components/rag/CustomMetadataInput.tsx @@ -0,0 +1,95 @@ +import { useState, memo } from "react"; +import { Plus, X, Settings2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; + +export interface CustomMetadataInputProps { + metadata: { key: string; value: string }[]; + onChange: (metadata: { key: string; value: string }[]) => void; +} + +export const CustomMetadataInput = memo(function CustomMetadataInput({ + metadata, + onChange, +}: CustomMetadataInputProps) { + const handleAdd = () => { + onChange([...metadata, { key: "", value: "" }]); + }; + + const handleRemove = (index: number) => { + const newMeta = [...metadata]; + newMeta.splice(index, 1); + onChange(newMeta); + }; + + const handleChange = (index: number, field: "key" | "value", val: string) => { + const newMeta = [...metadata]; + newMeta[index] = { ...newMeta[index], [field]: val }; + onChange(newMeta); + }; + + const validCount = metadata.filter((m) => m.key.trim() && m.value.trim()).length; + + return ( + + + + + +
+
+

Upload Metadata

+ +
+ +
+ {metadata.length === 0 ? ( +

+ No custom metadata. These will be added to newly uploaded files. +

+ ) : ( + metadata.map((item, i) => ( +
+ handleChange(i, "key", e.target.value)} + className="h-7 text-xs flex-1" + /> + handleChange(i, "value", e.target.value)} + className="h-7 text-xs flex-1" + /> + +
+ )) + )} +
+
+
+
+ ); +}); diff --git a/frontend/src/components/rag/DataPanel.tsx b/frontend/src/components/rag/DataPanel.tsx index 5033fc5..f710d81 100644 --- a/frontend/src/components/rag/DataPanel.tsx +++ b/frontend/src/components/rag/DataPanel.tsx @@ -17,9 +17,9 @@ import { Input } from "@/components/ui/input"; import { UploadZone } from "./UploadZone"; import { StatsBar } from "./StatsBar"; import { DocumentFilters, type FilterStatus } from "./DocumentFilters"; -import { DocumentCard } from "./DocumentCard"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { WorkspaceSettings } from "./WorkspaceSettings"; +import { CustomMetadataInput } from "./CustomMetadataInput"; import { api } from "@/lib/api"; import { cn } from "@/lib/utils"; import type { Document, RAGStats, DocumentStatus, KnowledgeBase, UpdateWorkspace } from "@/types"; @@ -38,7 +38,7 @@ interface DataPanelProps { ragStats: RAGStats | undefined; selectedDocId: number | null; onSelectDoc: (doc: Document) => void; - onUpload: (file: File) => void; + onUpload: (file: File, customMetadata?: {key: string, value: string}[]) => void; isUploading: boolean; onDelete: (id: number) => void; onProcess: (id: number) => void; @@ -71,6 +71,13 @@ export const DataPanel = memo(function DataPanel({ const [editDesc, setEditDesc] = useState(""); const [batchProcessing, setBatchProcessing] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); + const [customMetadata, setCustomMetadata] = useState<{key: string, value: string}[]>([]); + + const handleUpload = useCallback((file: File) => { + const validMeta = customMetadata.filter((m) => m.key.trim() !== ""); + onUpload(file, validMeta.length > 0 ? validMeta : undefined); + // Optional: clear metadata after successful upload? Leaving it for convenience if they upload multiple. + }, [customMetadata, onUpload]); const processingCount = useMemo( () => documents?.filter((d) => PROCESSING_STATUSES.has(d.status)).length ?? 0, @@ -219,9 +226,17 @@ export const DataPanel = memo(function DataPanel({ )} - {/* Upload zone — always visible, ~20% */} -
- + {/* Upload zone header & settings */} +
+

+ Add Documents +

+ +
+ + {/* Upload zone — always visible, ~15% */} +
+
{/* Stats bar */} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index d1d545f..d3cefed 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -80,9 +80,13 @@ class ApiClient { URL.revokeObjectURL(url); } - async uploadFile(path: string, file: File): Promise { + async uploadFile(path: string, file: File, customMetadata?: {key: string, value: string}[]): Promise { const formData = new FormData(); formData.append("file", file); + + if (customMetadata && customMetadata.length > 0) { + formData.append("custom_metadata", JSON.stringify(customMetadata)); + } const response = await fetch(`${BASE_URL}${path}`, { method: "POST", diff --git a/frontend/src/pages/WorkspacePage.tsx b/frontend/src/pages/WorkspacePage.tsx index d50d578..af95689 100644 --- a/frontend/src/pages/WorkspacePage.tsx +++ b/frontend/src/pages/WorkspacePage.tsx @@ -88,8 +88,8 @@ export function WorkspacePage() { // Mutations // ----------------------------------------------------------------------- const uploadDoc = useMutation({ - mutationFn: (file: File) => - api.uploadFile(`/documents/upload/${workspaceId}`, file), + mutationFn: ({ file, customMetadata }: { file: File, customMetadata?: {key: string, value: string}[] }) => + api.uploadFile(`/documents/upload/${workspaceId}`, file, customMetadata), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["documents", workspaceId] }); queryClient.invalidateQueries({ queryKey: ["rag-stats", workspaceId] }); @@ -179,7 +179,7 @@ export function WorkspacePage() { ragStats={ragStats} selectedDocId={selectedDoc?.id ?? null} onSelectDoc={handleSelectDoc} - onUpload={(f) => uploadDoc.mutate(f)} + onUpload={(file, customMetadata) => uploadDoc.mutate({ file, customMetadata })} isUploading={uploadDoc.isPending} onDelete={(id) => deleteDoc.mutate(id)} onProcess={(id) => processDoc.mutate(id)} From 0d57cbbf7e17d1ab6c1062b8bf450b1731e6dea8 Mon Sep 17 00:00:00 2001 From: quannguyen Date: Tue, 17 Mar 2026 21:56:27 +0700 Subject: [PATCH 4/6] Update PR Description with cURL examples --- PR_DESCRIPTION_CUSTOM_METADATA.md | 65 +++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 PR_DESCRIPTION_CUSTOM_METADATA.md diff --git a/PR_DESCRIPTION_CUSTOM_METADATA.md b/PR_DESCRIPTION_CUSTOM_METADATA.md new file mode 100644 index 0000000..f670050 --- /dev/null +++ b/PR_DESCRIPTION_CUSTOM_METADATA.md @@ -0,0 +1,65 @@ +# Lợi ích của tính năng Custom Metadata cho Documents (PR Description) + +## 📌 Tổng quan tính năng (Overview) +Tính năng này cho phép người dùng đính kèm các trường dữ liệu tùy chỉnh (Custom Metadata) dưới dạng Key-Value (ví dụ: `author: "John Doe"`, `category: "Finance"`, `year: "2023"`) ngay tại thời điểm upload tài liệu lên hệ thống NexusRAG. Dữ liệu này được lưu trữ trong Database (PostgreSQL) và toàn bộ các chunks trong Vector Database (ChromaDB). + +## 🚀 Các lợi ích cốt lõi (Key Benefits) + +### 1. Nâng cao độ chính xác của RAG thông qua Filter (Hybrid Search) +- **Vấn đề cũ:** Tìm kiếm Semantic Search (Vector) đôi khi trả về các chunks có kết quả đoạt độ tương đồng cao nhưng **sai ngữ cảnh** (ví dụ: tìm báo cáo tài chính năm 2023 nhưng chunk trả về lại là của 2022 do ngữ nghĩa giống nhau). +- **Lợi ích mới:** Nhờ có Custom Metadata lưu trực tiếp vào ChromaDB, hệ thống RAG giờ đây có thể thực hiện **Metadata Filtering** song song với Vector Search. + - *Ví dụ:* Query: *"Tìm doanh thu trong tài liệu."* + Lọc theo `{"year": "2023", "category": "finance"}`. + - Kết quả trả về sẽ **chính xác tuyệt đối** theo ngữ cảnh mà người dùng khoanh vùng, giảm thiểu "ảo giác" (hallucination) của LLM do bị mớm sai tài liệu. + +### 2. Tổ chức và quản lý tài liệu linh hoạt (Document Organization) +- Thay vì phải tạo vô số Knowledge Bases (Workspaces) nhỏ lẻ để phân loại tài liệu (ví dụ: Workspace "Báo_cáo_2022", Workspace "Báo_cáo_2023"...), người dùng có thể gom chung tất cả vào một Workspace dự án. +- Việc phân loại, gom nhóm, và quản lý tài liệu giờ đây được thực hiện cực kì mượt mà thông qua việc đánh Tag (Metadata), mô phỏng lại cách các hệ quản trị nội dung (CMS/DMS) hiện đại hoạt động. + +### 3. Tối ưu hiệu năng truy xuất (Performance Optimization) +- Pre-filtering (lọc Metadata trước khi tính toán khoảng cách Vector) giúp ChromaDB thu hẹp không gian tìm kiếm (Search Space) đi đáng kể. +- Thay vì phải quét qua hàng triệu vector chunks, database chỉ cần quét trên tập hợp nhỏ các chunks thỏa mãn Metadata. Điều này trực tiếp làm giảm độ trễ (latency) của toàn bộ quá trình Retrieval. + +### 4. Mở đường cho các tính năng nâng cao trong tương lai +- **Phân quyền nâng cao (RBAC):** Có thể tận dụng metadata cấp document (ví dụ: `access_level: "confidential"`) để lọc quyền truy cập dữ liệu của từng User/Agent. +- **Data Analytics:** Dễ dàng cho phép UI tổng hợp thống kê theo phân loại tài liệu (Ví dụ: Workspace này có 10 tài liệu "Legal", 15 tài liệu "Tech"). +- **Tương tác Multi-Agent:** Các Agents con có thể tự đưa ra quyết định "nên đọc file nào" dựa trên Metadata thay vì phải "mò mẫm" đọc toàn bộ nội dung text. + +## 💻 UX/UI +- Tính năng được thiết kế thân thiện, tích hợp thẳng vào Data Panel, giúp thao tác thêm Key-Value diễn ra tự nhiên, không làm gián đoạn luồng Upload tài liệu của End-user. + +## 🔌 Tích hợp API (API Integration) +Tính năng hỗ trợ Custom Metadata thông qua endpoint Upload Document hiện tại: +- **`POST /api/v1/documents/upload/{workspace_id}`** + - Trong Data Form gửi lên, ngoài file đính kèm, FE có thể gửi thêm field `custom_metadata` (List các objects gồm `key` và `value`). + - Hệ thống BE sẽ tự động convert lại thành JSON Object, validate cơ bản, và chèn vào Database (PostgreSQL) cũng như Vector Database (ChromaDB). + +**Example cURL Upload:** +```bash +curl -X POST "http://localhost:8080/api/v1/documents/upload/1" \ + -H "accept: application/json" \ + -H "Content-Type: multipart/form-data" \ + -F "file=@/path/to/your/document.pdf" \ + -F 'custom_metadata=[{"key":"category","value":"finance"},{"key":"year","value":"2023"}]' +``` + +Hỗ trợ truy vấn và lọc Metadata Filtering qua các endpoint Query và Chat: +- **`POST /api/v1/query/{workspace_id}`** +- **`POST /api/v1/chat/{workspace_id}`** + - Payload gửi lên sẽ hỗ trợ thêm tham số `metadata_filter` (dưới dạng JSON object chứa các điều kiện lọc Key-Value). + - API sẽ sử dụng các filter này đẩy xuống lớp Vector Search (bằng `where` clause trong ChromaDB), thu gọn không gian tìm kiếm trước khi trả về kết quả cho LLM. + +**Example cURL Query:** +```bash +curl -X POST "http://localhost:8080/api/v1/query/1" \ + -H "accept: application/json" \ + -H "Content-Type: application/json" \ + -d '{ + "question": "Tìm doanh thu năm nay.", + "top_k": 3, + "mode": "vector_only", + "metadata_filter": { + "category": "finance", + "year": "2023" + } + }' +``` From 93b8cabcf1833bcdd4f739288034e919555365d1 Mon Sep 17 00:00:00 2001 From: quannguyen Date: Tue, 17 Mar 2026 21:58:31 +0700 Subject: [PATCH 5/6] feat: introduce Radix UI Popover component and integrate DocumentCard in DataPanel. --- PR_DESCRIPTION_CUSTOM_METADATA.md | 65 -- frontend/package.json | 1 + frontend/pnpm-lock.yaml | 572 +++++++++++++++++- .../components/rag/CustomMetadataInput.tsx | 2 +- frontend/src/components/rag/DataPanel.tsx | 1 + frontend/src/components/ui/popover.tsx | 29 + 6 files changed, 583 insertions(+), 87 deletions(-) delete mode 100644 PR_DESCRIPTION_CUSTOM_METADATA.md create mode 100644 frontend/src/components/ui/popover.tsx diff --git a/PR_DESCRIPTION_CUSTOM_METADATA.md b/PR_DESCRIPTION_CUSTOM_METADATA.md deleted file mode 100644 index f670050..0000000 --- a/PR_DESCRIPTION_CUSTOM_METADATA.md +++ /dev/null @@ -1,65 +0,0 @@ -# Lợi ích của tính năng Custom Metadata cho Documents (PR Description) - -## 📌 Tổng quan tính năng (Overview) -Tính năng này cho phép người dùng đính kèm các trường dữ liệu tùy chỉnh (Custom Metadata) dưới dạng Key-Value (ví dụ: `author: "John Doe"`, `category: "Finance"`, `year: "2023"`) ngay tại thời điểm upload tài liệu lên hệ thống NexusRAG. Dữ liệu này được lưu trữ trong Database (PostgreSQL) và toàn bộ các chunks trong Vector Database (ChromaDB). - -## 🚀 Các lợi ích cốt lõi (Key Benefits) - -### 1. Nâng cao độ chính xác của RAG thông qua Filter (Hybrid Search) -- **Vấn đề cũ:** Tìm kiếm Semantic Search (Vector) đôi khi trả về các chunks có kết quả đoạt độ tương đồng cao nhưng **sai ngữ cảnh** (ví dụ: tìm báo cáo tài chính năm 2023 nhưng chunk trả về lại là của 2022 do ngữ nghĩa giống nhau). -- **Lợi ích mới:** Nhờ có Custom Metadata lưu trực tiếp vào ChromaDB, hệ thống RAG giờ đây có thể thực hiện **Metadata Filtering** song song với Vector Search. - - *Ví dụ:* Query: *"Tìm doanh thu trong tài liệu."* + Lọc theo `{"year": "2023", "category": "finance"}`. - - Kết quả trả về sẽ **chính xác tuyệt đối** theo ngữ cảnh mà người dùng khoanh vùng, giảm thiểu "ảo giác" (hallucination) của LLM do bị mớm sai tài liệu. - -### 2. Tổ chức và quản lý tài liệu linh hoạt (Document Organization) -- Thay vì phải tạo vô số Knowledge Bases (Workspaces) nhỏ lẻ để phân loại tài liệu (ví dụ: Workspace "Báo_cáo_2022", Workspace "Báo_cáo_2023"...), người dùng có thể gom chung tất cả vào một Workspace dự án. -- Việc phân loại, gom nhóm, và quản lý tài liệu giờ đây được thực hiện cực kì mượt mà thông qua việc đánh Tag (Metadata), mô phỏng lại cách các hệ quản trị nội dung (CMS/DMS) hiện đại hoạt động. - -### 3. Tối ưu hiệu năng truy xuất (Performance Optimization) -- Pre-filtering (lọc Metadata trước khi tính toán khoảng cách Vector) giúp ChromaDB thu hẹp không gian tìm kiếm (Search Space) đi đáng kể. -- Thay vì phải quét qua hàng triệu vector chunks, database chỉ cần quét trên tập hợp nhỏ các chunks thỏa mãn Metadata. Điều này trực tiếp làm giảm độ trễ (latency) của toàn bộ quá trình Retrieval. - -### 4. Mở đường cho các tính năng nâng cao trong tương lai -- **Phân quyền nâng cao (RBAC):** Có thể tận dụng metadata cấp document (ví dụ: `access_level: "confidential"`) để lọc quyền truy cập dữ liệu của từng User/Agent. -- **Data Analytics:** Dễ dàng cho phép UI tổng hợp thống kê theo phân loại tài liệu (Ví dụ: Workspace này có 10 tài liệu "Legal", 15 tài liệu "Tech"). -- **Tương tác Multi-Agent:** Các Agents con có thể tự đưa ra quyết định "nên đọc file nào" dựa trên Metadata thay vì phải "mò mẫm" đọc toàn bộ nội dung text. - -## 💻 UX/UI -- Tính năng được thiết kế thân thiện, tích hợp thẳng vào Data Panel, giúp thao tác thêm Key-Value diễn ra tự nhiên, không làm gián đoạn luồng Upload tài liệu của End-user. - -## 🔌 Tích hợp API (API Integration) -Tính năng hỗ trợ Custom Metadata thông qua endpoint Upload Document hiện tại: -- **`POST /api/v1/documents/upload/{workspace_id}`** - - Trong Data Form gửi lên, ngoài file đính kèm, FE có thể gửi thêm field `custom_metadata` (List các objects gồm `key` và `value`). - - Hệ thống BE sẽ tự động convert lại thành JSON Object, validate cơ bản, và chèn vào Database (PostgreSQL) cũng như Vector Database (ChromaDB). - -**Example cURL Upload:** -```bash -curl -X POST "http://localhost:8080/api/v1/documents/upload/1" \ - -H "accept: application/json" \ - -H "Content-Type: multipart/form-data" \ - -F "file=@/path/to/your/document.pdf" \ - -F 'custom_metadata=[{"key":"category","value":"finance"},{"key":"year","value":"2023"}]' -``` - -Hỗ trợ truy vấn và lọc Metadata Filtering qua các endpoint Query và Chat: -- **`POST /api/v1/query/{workspace_id}`** -- **`POST /api/v1/chat/{workspace_id}`** - - Payload gửi lên sẽ hỗ trợ thêm tham số `metadata_filter` (dưới dạng JSON object chứa các điều kiện lọc Key-Value). - - API sẽ sử dụng các filter này đẩy xuống lớp Vector Search (bằng `where` clause trong ChromaDB), thu gọn không gian tìm kiếm trước khi trả về kết quả cho LLM. - -**Example cURL Query:** -```bash -curl -X POST "http://localhost:8080/api/v1/query/1" \ - -H "accept: application/json" \ - -H "Content-Type: application/json" \ - -d '{ - "question": "Tìm doanh thu năm nay.", - "top_k": 3, - "mode": "vector_only", - "metadata_filter": { - "category": "finance", - "year": "2023" - } - }' -``` diff --git a/frontend/package.json b/frontend/package.json index c0a6117..1d1a5dc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "preview": "vite preview" }, "dependencies": { + "@radix-ui/react-popover": "^1.1.15", "@tailwindcss/typography": "^0.5.19", "@tanstack/react-query": "^5.90.20", "@types/react-syntax-highlighter": "^15.5.13", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 931e5e7..feda770 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@radix-ui/react-popover': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tailwindcss/typography': specifier: ^0.5.19 version: 0.5.19(tailwindcss@4.2.1) @@ -392,6 +395,21 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/react-dom@2.1.8': + resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -424,6 +442,224 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/react-arrow@1.1.7': + resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.1.2': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.11': + resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.3': + resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.7': + resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.1': + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-popover@1.1.15': + resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.2.8': + resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.9': + resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.1': + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.1': + resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.1': + resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.1': + resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/rect@1.1.1': + resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@rolldown/pluginutils@1.0.0-rc.3': resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} @@ -461,79 +697,66 @@ packages: resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.59.0': resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.59.0': resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.59.0': resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.59.0': resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.59.0': resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.59.0': resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.59.0': resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.59.0': resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.59.0': resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.59.0': resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.59.0': resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.59.0': resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.59.0': resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} @@ -603,28 +826,24 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.2.1': resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.2.1': resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.1': resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.1': resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==} @@ -819,6 +1038,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} @@ -938,6 +1161,9 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -1095,6 +1321,10 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} @@ -1281,28 +1511,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.31.1: resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.31.1: resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.31.1: resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.31.1: resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} @@ -1584,6 +1810,26 @@ packages: resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} engines: {node: '>=0.10.0'} + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + react-router-dom@7.13.1: resolution: {integrity: sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==} engines: {node: '>=20.0.0'} @@ -1601,6 +1847,16 @@ packages: react-dom: optional: true + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + react-syntax-highlighter@16.1.1: resolution: {integrity: sha512-PjVawBGy80C6YbC5DDZJeUjBmC7skaoEUdvfFQediQHgCL7aKyVHe57SaJGfQsloGDac+gCpTfRdtxzWWKmCXA==} engines: {node: '>= 16.20.2'} @@ -1775,6 +2031,26 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -2116,6 +2392,23 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/react-dom@2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@floating-ui/dom': 1.7.6 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + '@floating-ui/utils@0.2.11': {} + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -2146,6 +2439,193 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@radix-ui/primitive@1.1.3': {} + + '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + aria-hidden: 1.2.6 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/rect': 1.1.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/rect': 1.1.1 + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-size@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/rect@1.1.1': {} + '@rolldown/pluginutils@1.0.0-rc.3': {} '@rollup/rollup-android-arm-eabi@4.59.0': @@ -2494,6 +2974,10 @@ snapshots: argparse@2.0.1: {} + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + bail@2.0.2: {} balanced-match@1.0.2: {} @@ -2584,6 +3068,8 @@ snapshots: detect-libc@2.1.2: {} + detect-node-es@1.1.0: {} + devlop@1.1.0: dependencies: dequal: 2.0.3 @@ -2767,6 +3253,8 @@ snapshots: gensync@1.0.0-beta.2: {} + get-nonce@1.0.1: {} + glob-parent@6.0.2: dependencies: is-glob: 4.0.3 @@ -3487,6 +3975,25 @@ snapshots: react-refresh@0.18.0: {} + react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.4): + dependencies: + react: 19.2.4 + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.4) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.4): + dependencies: + react: 19.2.4 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.4) + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.4) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.4) + use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + react-router-dom@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: react: 19.2.4 @@ -3501,6 +4008,14 @@ snapshots: optionalDependencies: react-dom: 19.2.4(react@19.2.4) + react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.4): + dependencies: + get-nonce: 1.0.1 + react: 19.2.4 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + react-syntax-highlighter@16.1.1(react@19.2.4): dependencies: '@babel/runtime': 7.28.6 @@ -3741,6 +4256,21 @@ snapshots: dependencies: punycode: 2.3.1 + use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.4): + dependencies: + react: 19.2.4 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.4): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.4 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + util-deprecate@1.0.2: {} vfile-location@5.0.3: diff --git a/frontend/src/components/rag/CustomMetadataInput.tsx b/frontend/src/components/rag/CustomMetadataInput.tsx index 20b3be5..073391d 100644 --- a/frontend/src/components/rag/CustomMetadataInput.tsx +++ b/frontend/src/components/rag/CustomMetadataInput.tsx @@ -1,4 +1,4 @@ -import { useState, memo } from "react"; +import { memo } from "react"; import { Plus, X, Settings2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; diff --git a/frontend/src/components/rag/DataPanel.tsx b/frontend/src/components/rag/DataPanel.tsx index f710d81..d2b2646 100644 --- a/frontend/src/components/rag/DataPanel.tsx +++ b/frontend/src/components/rag/DataPanel.tsx @@ -20,6 +20,7 @@ import { DocumentFilters, type FilterStatus } from "./DocumentFilters"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { WorkspaceSettings } from "./WorkspaceSettings"; import { CustomMetadataInput } from "./CustomMetadataInput"; +import { DocumentCard } from "./DocumentCard"; import { api } from "@/lib/api"; import { cn } from "@/lib/utils"; import type { Document, RAGStats, DocumentStatus, KnowledgeBase, UpdateWorkspace } from "@/types"; diff --git a/frontend/src/components/ui/popover.tsx b/frontend/src/components/ui/popover.tsx new file mode 100644 index 0000000..7eb8622 --- /dev/null +++ b/frontend/src/components/ui/popover.tsx @@ -0,0 +1,29 @@ +import * as React from "react" +import * as PopoverPrimitive from "@radix-ui/react-popover" + +import { cn } from "@/lib/utils" + +const Popover = PopoverPrimitive.Root + +const PopoverTrigger = PopoverPrimitive.Trigger + +const PopoverContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + + + +)) +PopoverContent.displayName = PopoverPrimitive.Content.displayName + +export { Popover, PopoverTrigger, PopoverContent } From e687f1a175131652a8969eec38bde568b8b320c1 Mon Sep 17 00:00:00 2001 From: quannguyen Date: Tue, 17 Mar 2026 22:02:49 +0700 Subject: [PATCH 6/6] docs: Update README with Custom Metadata feature and API endpoints --- README.md | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b8e8236..3b92a98 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,18 @@ Images and tables are **embedded into chunk vectors** — not stored separately. +
+Custom Document Metadata + +Enhance RAG accuracy and organization by attaching custom key-value metadata during document upload: + +- **Metadata Filtering** — Perform hybrid search (semantic + metadata filtering) to narrow down the search space and prevent hallucinations. +- **Flexible Organization** — Tag documents with attributes like `year`, `category`, or `author` without needing separate workspaces. +- **Optimized Retrieval** — Pre-filtering in ChromaDB reduces processing time and latency during vector search. +- **Supported Endpoints** — Pass `custom_metadata` (list of key-values) in the upload API, and `metadata_filter` in query/chat APIs. + +
+
Citation System @@ -526,7 +538,7 @@ All endpoints prefixed with `/api/v1`. Interactive docs at http://localhost:8080 | Method | Endpoint | Description | |---|---|---| -| `POST` | `/documents/upload/{workspace_id}` | Upload file | +| `POST` | `/documents/upload/{workspace_id}` | Upload file (supports `custom_metadata`) | | `GET` | `/documents/{id}/markdown` | Get parsed content | | `GET` | `/documents/{id}/images` | List extracted images | | `DELETE` | `/documents/{id}` | Delete document | @@ -538,8 +550,8 @@ All endpoints prefixed with `/api/v1`. Interactive docs at http://localhost:8080 | Method | Endpoint | Description | |---|---|---| -| `POST` | `/rag/query/{workspace_id}` | Hybrid search | -| `POST` | `/rag/chat/{workspace_id}/stream` | Agentic streaming chat (SSE) | +| `POST` | `/rag/query/{workspace_id}` | Hybrid search (supports `metadata_filter`) | +| `POST` | `/rag/chat/{workspace_id}/stream` | Agentic streaming chat (SSE) (supports `metadata_filter`) | | `GET` | `/rag/chat/{workspace_id}/history` | Chat history | | `POST` | `/rag/process/{document_id}` | Process document | | `GET` | `/rag/graph/{workspace_id}` | Knowledge graph data |