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 | 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/api/documents.py b/backend/app/api/documents.py index 71fd709..a599f91 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,29 @@ 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: + 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 format: {e}" + ) + result = await db.execute(select(KnowledgeBase).where(KnowledgeBase.id == workspace_id)) kb = result.scalar_one_or_none() @@ -163,6 +184,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/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/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/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/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/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 new file mode 100644 index 0000000..073391d --- /dev/null +++ b/frontend/src/components/rag/CustomMetadataInput.tsx @@ -0,0 +1,95 @@ +import { 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..d2b2646 100644 --- a/frontend/src/components/rag/DataPanel.tsx +++ b/frontend/src/components/rag/DataPanel.tsx @@ -17,9 +17,10 @@ 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 { DocumentCard } from "./DocumentCard"; import { api } from "@/lib/api"; import { cn } from "@/lib/utils"; import type { Document, RAGStats, DocumentStatus, KnowledgeBase, UpdateWorkspace } from "@/types"; @@ -38,7 +39,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 +72,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 +227,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/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 } 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)} 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).