mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-08 20:25:19 +02:00
Merge remote-tracking branch 'upstream/dev' into feat/page-limit-connectors
This commit is contained in:
commit
0d2acc665d
96 changed files with 6157 additions and 775 deletions
|
|
@ -24,7 +24,7 @@ SurfSense 现已支持以下国产 LLM:
|
|||
|
||||
1. 登录 SurfSense Dashboard
|
||||
2. 进入 **Settings** → **API Keys** (或 **LLM Configurations**)
|
||||
3. 点击 **Add LLM Model**
|
||||
3. 点击 **Add Model**
|
||||
4. 从 **Provider** 下拉菜单中选择你的国产 LLM 提供商
|
||||
5. 填写必填字段(见下方各提供商详细配置)
|
||||
6. 点击 **Save**
|
||||
|
|
|
|||
6
package-lock.json
generated
Normal file
6
package-lock.json
generated
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "SurfSense",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
"""Add LOCAL_FOLDER_FILE document type, folder metadata, and document_versions table
|
||||
|
||||
Revision ID: 118
|
||||
Revises: 117
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "118"
|
||||
down_revision: str | None = "117"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
PUBLICATION_NAME = "zero_publication"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# Add LOCAL_FOLDER_FILE to documenttype enum
|
||||
op.execute(
|
||||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_type t
|
||||
JOIN pg_enum e ON t.oid = e.enumtypid
|
||||
WHERE t.typname = 'documenttype' AND e.enumlabel = 'LOCAL_FOLDER_FILE'
|
||||
) THEN
|
||||
ALTER TYPE documenttype ADD VALUE 'LOCAL_FOLDER_FILE';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
"""
|
||||
)
|
||||
|
||||
# Add JSONB metadata column to folders table
|
||||
col_exists = conn.execute(
|
||||
sa.text(
|
||||
"SELECT 1 FROM information_schema.columns "
|
||||
"WHERE table_name = 'folders' AND column_name = 'metadata'"
|
||||
)
|
||||
).fetchone()
|
||||
if not col_exists:
|
||||
op.add_column(
|
||||
"folders",
|
||||
sa.Column("metadata", sa.dialects.postgresql.JSONB, nullable=True),
|
||||
)
|
||||
|
||||
# Create document_versions table
|
||||
table_exists = conn.execute(
|
||||
sa.text(
|
||||
"SELECT 1 FROM information_schema.tables WHERE table_name = 'document_versions'"
|
||||
)
|
||||
).fetchone()
|
||||
if not table_exists:
|
||||
op.create_table(
|
||||
"document_versions",
|
||||
sa.Column("id", sa.Integer(), nullable=False, autoincrement=True),
|
||||
sa.Column("document_id", sa.Integer(), nullable=False),
|
||||
sa.Column("version_number", sa.Integer(), nullable=False),
|
||||
sa.Column("source_markdown", sa.Text(), nullable=True),
|
||||
sa.Column("content_hash", sa.String(), nullable=False),
|
||||
sa.Column("title", sa.String(), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.TIMESTAMP(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["document_id"],
|
||||
["documents.id"],
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"document_id",
|
||||
"version_number",
|
||||
name="uq_document_version",
|
||||
),
|
||||
)
|
||||
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS ix_document_versions_document_id "
|
||||
"ON document_versions (document_id)"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS ix_document_versions_created_at "
|
||||
"ON document_versions (created_at)"
|
||||
)
|
||||
|
||||
# Add document_versions to Zero publication
|
||||
pub_exists = conn.execute(
|
||||
sa.text("SELECT 1 FROM pg_publication WHERE pubname = :name"),
|
||||
{"name": PUBLICATION_NAME},
|
||||
).fetchone()
|
||||
if pub_exists:
|
||||
already_in_pub = conn.execute(
|
||||
sa.text(
|
||||
"SELECT 1 FROM pg_publication_tables "
|
||||
"WHERE pubname = :name AND tablename = 'document_versions'"
|
||||
),
|
||||
{"name": PUBLICATION_NAME},
|
||||
).fetchone()
|
||||
if not already_in_pub:
|
||||
op.execute(
|
||||
f"ALTER PUBLICATION {PUBLICATION_NAME} ADD TABLE document_versions"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# Remove from publication
|
||||
pub_exists = conn.execute(
|
||||
sa.text("SELECT 1 FROM pg_publication WHERE pubname = :name"),
|
||||
{"name": PUBLICATION_NAME},
|
||||
).fetchone()
|
||||
if pub_exists:
|
||||
already_in_pub = conn.execute(
|
||||
sa.text(
|
||||
"SELECT 1 FROM pg_publication_tables "
|
||||
"WHERE pubname = :name AND tablename = 'document_versions'"
|
||||
),
|
||||
{"name": PUBLICATION_NAME},
|
||||
).fetchone()
|
||||
if already_in_pub:
|
||||
op.execute(
|
||||
f"ALTER PUBLICATION {PUBLICATION_NAME} DROP TABLE document_versions"
|
||||
)
|
||||
|
||||
op.execute("DROP INDEX IF EXISTS ix_document_versions_created_at")
|
||||
op.execute("DROP INDEX IF EXISTS ix_document_versions_document_id")
|
||||
op.execute("DROP TABLE IF EXISTS document_versions")
|
||||
|
||||
# Drop metadata column from folders
|
||||
col_exists = conn.execute(
|
||||
sa.text(
|
||||
"SELECT 1 FROM information_schema.columns "
|
||||
"WHERE table_name = 'folders' AND column_name = 'metadata'"
|
||||
)
|
||||
).fetchone()
|
||||
if col_exists:
|
||||
op.drop_column("folders", "metadata")
|
||||
|
|
@ -17,10 +17,10 @@ depends_on: str | Sequence[str] | None = None
|
|||
|
||||
def upgrade() -> None:
|
||||
"""
|
||||
Add the new_llm_configs table that combines LLM model settings with prompt configuration.
|
||||
Add the new_llm_configs table that combines model settings with prompt configuration.
|
||||
|
||||
This table includes:
|
||||
- LLM model configuration (provider, model_name, api_key, etc.)
|
||||
- Model configuration (provider, model_name, api_key, etc.)
|
||||
- Configurable system instructions
|
||||
- Citation toggle
|
||||
"""
|
||||
|
|
@ -41,7 +41,7 @@ def upgrade() -> None:
|
|||
name VARCHAR(100) NOT NULL,
|
||||
description VARCHAR(500),
|
||||
|
||||
-- LLM Model Configuration (same as llm_configs, excluding language)
|
||||
-- Model Configuration (same as llm_configs, excluding language)
|
||||
provider litellmprovider NOT NULL,
|
||||
custom_provider VARCHAR(100),
|
||||
model_name VARCHAR(100) NOT NULL,
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
# - Configure router_settings below to customize the load balancing behavior
|
||||
#
|
||||
# Structure matches NewLLMConfig:
|
||||
# - LLM model configuration (provider, model_name, api_key, etc.)
|
||||
# - Model configuration (provider, model_name, api_key, etc.)
|
||||
# - Prompt configuration (system_instructions, citations_enabled)
|
||||
|
||||
# Router Settings for Auto Mode
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ class DocumentType(StrEnum):
|
|||
COMPOSIO_GOOGLE_DRIVE_CONNECTOR = "COMPOSIO_GOOGLE_DRIVE_CONNECTOR"
|
||||
COMPOSIO_GMAIL_CONNECTOR = "COMPOSIO_GMAIL_CONNECTOR"
|
||||
COMPOSIO_GOOGLE_CALENDAR_CONNECTOR = "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR"
|
||||
LOCAL_FOLDER_FILE = "LOCAL_FOLDER_FILE"
|
||||
|
||||
|
||||
# Native Google document types → their legacy Composio equivalents.
|
||||
|
|
@ -955,6 +956,7 @@ class Folder(BaseModel, TimestampMixin):
|
|||
onupdate=lambda: datetime.now(UTC),
|
||||
index=True,
|
||||
)
|
||||
folder_metadata = Column("metadata", JSONB, nullable=True)
|
||||
|
||||
parent = relationship("Folder", remote_side="Folder.id", backref="children")
|
||||
search_space = relationship("SearchSpace", back_populates="folders")
|
||||
|
|
@ -1039,6 +1041,26 @@ class Document(BaseModel, TimestampMixin):
|
|||
)
|
||||
|
||||
|
||||
class DocumentVersion(BaseModel, TimestampMixin):
|
||||
__tablename__ = "document_versions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("document_id", "version_number", name="uq_document_version"),
|
||||
)
|
||||
|
||||
document_id = Column(
|
||||
Integer,
|
||||
ForeignKey("documents.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
version_number = Column(Integer, nullable=False)
|
||||
source_markdown = Column(Text, nullable=True)
|
||||
content_hash = Column(String, nullable=False)
|
||||
title = Column(String, nullable=True)
|
||||
|
||||
document = relationship("Document", backref="versions")
|
||||
|
||||
|
||||
class Chunk(BaseModel, TimestampMixin):
|
||||
__tablename__ = "chunks"
|
||||
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ class PipelineMessages:
|
|||
|
||||
LLM_AUTH = "LLM authentication failed. Check your API key."
|
||||
LLM_PERMISSION = "LLM request denied. Check your account permissions."
|
||||
LLM_NOT_FOUND = "LLM model not found. Check your model configuration."
|
||||
LLM_NOT_FOUND = "Model not found. Check your model configuration."
|
||||
LLM_BAD_REQUEST = "LLM rejected the request. Document content may be invalid."
|
||||
LLM_UNPROCESSABLE = (
|
||||
"Document exceeds the LLM context window even after optimization."
|
||||
|
|
@ -67,7 +67,7 @@ class PipelineMessages:
|
|||
LLM_RESPONSE = "LLM returned an invalid response."
|
||||
LLM_AUTH = "LLM authentication failed. Check your API key."
|
||||
LLM_PERMISSION = "LLM request denied. Check your account permissions."
|
||||
LLM_NOT_FOUND = "LLM model not found. Check your model configuration."
|
||||
LLM_NOT_FOUND = "Model not found. Check your model configuration."
|
||||
LLM_BAD_REQUEST = "LLM rejected the request. Document content may be invalid."
|
||||
LLM_UNPROCESSABLE = (
|
||||
"Document exceeds the LLM context window even after optimization."
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ router.include_router(confluence_add_connector_router)
|
|||
router.include_router(clickup_add_connector_router)
|
||||
router.include_router(dropbox_add_connector_router)
|
||||
router.include_router(new_llm_config_router) # LLM configs with prompt configuration
|
||||
router.include_router(model_list_router) # Dynamic LLM model catalogue from OpenRouter
|
||||
router.include_router(model_list_router) # Dynamic model catalogue from OpenRouter
|
||||
router.include_router(logs_router)
|
||||
router.include_router(circleback_webhook_router) # Circleback meeting webhooks
|
||||
router.include_router(surfsense_docs_router) # Surfsense documentation for citations
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import asyncio
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Query, UploadFile
|
||||
from pydantic import BaseModel as PydanticBaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
|
@ -10,6 +11,8 @@ from app.db import (
|
|||
Chunk,
|
||||
Document,
|
||||
DocumentType,
|
||||
DocumentVersion,
|
||||
Folder,
|
||||
Permission,
|
||||
SearchSpace,
|
||||
SearchSpaceMembership,
|
||||
|
|
@ -27,6 +30,7 @@ from app.schemas import (
|
|||
DocumentTitleSearchResponse,
|
||||
DocumentUpdate,
|
||||
DocumentWithChunksRead,
|
||||
FolderRead,
|
||||
PaginatedResponse,
|
||||
)
|
||||
from app.services.task_dispatcher import TaskDispatcher, get_task_dispatcher
|
||||
|
|
@ -957,6 +961,39 @@ async def get_document_by_chunk_id(
|
|||
) from e
|
||||
|
||||
|
||||
@router.get("/documents/watched-folders", response_model=list[FolderRead])
|
||||
async def get_watched_folders(
|
||||
search_space_id: int,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""Return root folders that are marked as watched (metadata->>'watched' = 'true')."""
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
search_space_id,
|
||||
Permission.DOCUMENTS_READ.value,
|
||||
"You don't have permission to read documents in this search space",
|
||||
)
|
||||
|
||||
folders = (
|
||||
(
|
||||
await session.execute(
|
||||
select(Folder).where(
|
||||
Folder.search_space_id == search_space_id,
|
||||
Folder.parent_id.is_(None),
|
||||
Folder.folder_metadata.isnot(None),
|
||||
Folder.folder_metadata["watched"].astext == "true",
|
||||
)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
|
||||
return folders
|
||||
|
||||
|
||||
@router.get(
|
||||
"/documents/{document_id}/chunks",
|
||||
response_model=PaginatedResponse[ChunkRead],
|
||||
|
|
@ -1212,3 +1249,297 @@ async def delete_document(
|
|||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to delete document: {e!s}"
|
||||
) from e
|
||||
|
||||
|
||||
# ====================================================================
|
||||
# Version History Endpoints
|
||||
# ====================================================================
|
||||
|
||||
|
||||
@router.get("/documents/{document_id}/versions")
|
||||
async def list_document_versions(
|
||||
document_id: int,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""List all versions for a document, ordered by version_number descending."""
|
||||
document = (
|
||||
await session.execute(select(Document).where(Document.id == document_id))
|
||||
).scalar_one_or_none()
|
||||
if not document:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
await check_permission(
|
||||
session, user, document.search_space_id, Permission.DOCUMENTS_READ.value
|
||||
)
|
||||
|
||||
versions = (
|
||||
(
|
||||
await session.execute(
|
||||
select(DocumentVersion)
|
||||
.where(DocumentVersion.document_id == document_id)
|
||||
.order_by(DocumentVersion.version_number.desc())
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
"version_number": v.version_number,
|
||||
"title": v.title,
|
||||
"content_hash": v.content_hash,
|
||||
"created_at": v.created_at.isoformat() if v.created_at else None,
|
||||
}
|
||||
for v in versions
|
||||
]
|
||||
|
||||
|
||||
@router.get("/documents/{document_id}/versions/{version_number}")
|
||||
async def get_document_version(
|
||||
document_id: int,
|
||||
version_number: int,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""Get full version content including source_markdown."""
|
||||
document = (
|
||||
await session.execute(select(Document).where(Document.id == document_id))
|
||||
).scalar_one_or_none()
|
||||
if not document:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
await check_permission(
|
||||
session, user, document.search_space_id, Permission.DOCUMENTS_READ.value
|
||||
)
|
||||
|
||||
version = (
|
||||
await session.execute(
|
||||
select(DocumentVersion).where(
|
||||
DocumentVersion.document_id == document_id,
|
||||
DocumentVersion.version_number == version_number,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not version:
|
||||
raise HTTPException(status_code=404, detail="Version not found")
|
||||
|
||||
return {
|
||||
"version_number": version.version_number,
|
||||
"title": version.title,
|
||||
"content_hash": version.content_hash,
|
||||
"source_markdown": version.source_markdown,
|
||||
"created_at": version.created_at.isoformat() if version.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/documents/{document_id}/versions/{version_number}/restore")
|
||||
async def restore_document_version(
|
||||
document_id: int,
|
||||
version_number: int,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""Restore a previous version: snapshot current state, then overwrite document content."""
|
||||
document = (
|
||||
await session.execute(select(Document).where(Document.id == document_id))
|
||||
).scalar_one_or_none()
|
||||
if not document:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
await check_permission(
|
||||
session, user, document.search_space_id, Permission.DOCUMENTS_UPDATE.value
|
||||
)
|
||||
|
||||
version = (
|
||||
await session.execute(
|
||||
select(DocumentVersion).where(
|
||||
DocumentVersion.document_id == document_id,
|
||||
DocumentVersion.version_number == version_number,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not version:
|
||||
raise HTTPException(status_code=404, detail="Version not found")
|
||||
|
||||
# Snapshot current state before restoring
|
||||
from app.utils.document_versioning import create_version_snapshot
|
||||
|
||||
await create_version_snapshot(session, document)
|
||||
|
||||
# Restore the version's content onto the document
|
||||
document.source_markdown = version.source_markdown
|
||||
document.title = version.title or document.title
|
||||
document.content_needs_reindexing = True
|
||||
await session.commit()
|
||||
|
||||
from app.tasks.celery_tasks.document_reindex_tasks import reindex_document_task
|
||||
|
||||
reindex_document_task.delay(document_id, str(user.id))
|
||||
|
||||
return {
|
||||
"message": f"Restored version {version_number}",
|
||||
"document_id": document_id,
|
||||
"restored_version": version_number,
|
||||
}
|
||||
|
||||
|
||||
# ===== Local folder indexing endpoints =====
|
||||
|
||||
|
||||
class FolderIndexRequest(PydanticBaseModel):
|
||||
folder_path: str
|
||||
folder_name: str
|
||||
search_space_id: int
|
||||
exclude_patterns: list[str] | None = None
|
||||
file_extensions: list[str] | None = None
|
||||
root_folder_id: int | None = None
|
||||
enable_summary: bool = False
|
||||
|
||||
|
||||
class FolderIndexFilesRequest(PydanticBaseModel):
|
||||
folder_path: str
|
||||
folder_name: str
|
||||
search_space_id: int
|
||||
target_file_paths: list[str]
|
||||
root_folder_id: int | None = None
|
||||
enable_summary: bool = False
|
||||
|
||||
|
||||
@router.post("/documents/folder-index")
|
||||
async def folder_index(
|
||||
request: FolderIndexRequest,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""Full-scan index of a local folder. Creates the root Folder row synchronously
|
||||
and dispatches the heavy indexing work to a Celery task.
|
||||
Returns the root_folder_id so the desktop can persist it.
|
||||
"""
|
||||
from app.config import config as app_config
|
||||
|
||||
if not app_config.is_self_hosted():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Local folder indexing is only available in self-hosted mode",
|
||||
)
|
||||
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
request.search_space_id,
|
||||
Permission.DOCUMENTS_CREATE.value,
|
||||
"You don't have permission to create documents in this search space",
|
||||
)
|
||||
|
||||
watched_metadata = {
|
||||
"watched": True,
|
||||
"folder_path": request.folder_path,
|
||||
"exclude_patterns": request.exclude_patterns,
|
||||
"file_extensions": request.file_extensions,
|
||||
}
|
||||
|
||||
root_folder_id = request.root_folder_id
|
||||
if root_folder_id:
|
||||
existing = (
|
||||
await session.execute(select(Folder).where(Folder.id == root_folder_id))
|
||||
).scalar_one_or_none()
|
||||
if not existing:
|
||||
root_folder_id = None
|
||||
else:
|
||||
existing.folder_metadata = watched_metadata
|
||||
await session.commit()
|
||||
|
||||
if not root_folder_id:
|
||||
root_folder = Folder(
|
||||
name=request.folder_name,
|
||||
search_space_id=request.search_space_id,
|
||||
created_by_id=str(user.id),
|
||||
position="a0",
|
||||
folder_metadata=watched_metadata,
|
||||
)
|
||||
session.add(root_folder)
|
||||
await session.flush()
|
||||
root_folder_id = root_folder.id
|
||||
await session.commit()
|
||||
|
||||
from app.tasks.celery_tasks.document_tasks import index_local_folder_task
|
||||
|
||||
index_local_folder_task.delay(
|
||||
search_space_id=request.search_space_id,
|
||||
user_id=str(user.id),
|
||||
folder_path=request.folder_path,
|
||||
folder_name=request.folder_name,
|
||||
exclude_patterns=request.exclude_patterns,
|
||||
file_extensions=request.file_extensions,
|
||||
root_folder_id=root_folder_id,
|
||||
enable_summary=request.enable_summary,
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "Folder indexing started",
|
||||
"status": "processing",
|
||||
"root_folder_id": root_folder_id,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/documents/folder-index-files")
|
||||
async def folder_index_files(
|
||||
request: FolderIndexFilesRequest,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""Index multiple files within a watched folder (batched chokidar trigger).
|
||||
Validates that all target_file_paths are under folder_path.
|
||||
Dispatches a single Celery task that processes them in parallel.
|
||||
"""
|
||||
from app.config import config as app_config
|
||||
|
||||
if not app_config.is_self_hosted():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Local folder indexing is only available in self-hosted mode",
|
||||
)
|
||||
|
||||
if not request.target_file_paths:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="target_file_paths must not be empty"
|
||||
)
|
||||
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
request.search_space_id,
|
||||
Permission.DOCUMENTS_CREATE.value,
|
||||
"You don't have permission to create documents in this search space",
|
||||
)
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
for fp in request.target_file_paths:
|
||||
try:
|
||||
Path(fp).relative_to(request.folder_path)
|
||||
except ValueError as err:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"target_file_path {fp} must be inside folder_path",
|
||||
) from err
|
||||
|
||||
from app.tasks.celery_tasks.document_tasks import index_local_folder_task
|
||||
|
||||
index_local_folder_task.delay(
|
||||
search_space_id=request.search_space_id,
|
||||
user_id=str(user.id),
|
||||
folder_path=request.folder_path,
|
||||
folder_name=request.folder_name,
|
||||
target_file_paths=request.target_file_paths,
|
||||
root_folder_id=request.root_folder_id,
|
||||
enable_summary=request.enable_summary,
|
||||
)
|
||||
|
||||
return {
|
||||
"message": f"Batch indexing started for {len(request.target_file_paths)} file(s)",
|
||||
"status": "processing",
|
||||
"file_count": len(request.target_file_paths),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -128,9 +128,20 @@ async def get_editor_content(
|
|||
chunk_contents = chunk_contents_result.scalars().all()
|
||||
|
||||
if not chunk_contents:
|
||||
doc_status = document.status or {}
|
||||
state = (
|
||||
doc_status.get("state", "ready")
|
||||
if isinstance(doc_status, dict)
|
||||
else "ready"
|
||||
)
|
||||
if state in ("pending", "processing"):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="This document is still being processed. Please wait a moment and try again.",
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="This document has no content and cannot be edited. Please re-upload to enable editing.",
|
||||
detail="This document has no viewable content yet. It may still be syncing. Try again in a few seconds, or re-upload if the issue persists.",
|
||||
)
|
||||
|
||||
markdown_content = "\n\n".join(chunk_contents)
|
||||
|
|
@ -138,7 +149,7 @@ async def get_editor_content(
|
|||
if not markdown_content.strip():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="This document has empty content and cannot be edited.",
|
||||
detail="This document appears to be empty. Try re-uploading or editing it to add content.",
|
||||
)
|
||||
|
||||
document.source_markdown = markdown_content
|
||||
|
|
|
|||
|
|
@ -192,6 +192,33 @@ async def get_folder_breadcrumb(
|
|||
) from e
|
||||
|
||||
|
||||
@router.patch("/folders/{folder_id}/watched")
|
||||
async def stop_watching_folder(
|
||||
folder_id: int,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""Clear the watched flag from a folder's metadata."""
|
||||
folder = await session.get(Folder, folder_id)
|
||||
if not folder:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
folder.search_space_id,
|
||||
Permission.DOCUMENTS_UPDATE.value,
|
||||
"You don't have permission to update folders in this search space",
|
||||
)
|
||||
|
||||
if folder.folder_metadata and isinstance(folder.folder_metadata, dict):
|
||||
updated = {**folder.folder_metadata, "watched": False}
|
||||
folder.folder_metadata = updated
|
||||
await session.commit()
|
||||
|
||||
return {"message": "Folder watch status updated"}
|
||||
|
||||
|
||||
@router.put("/folders/{folder_id}", response_model=FolderRead)
|
||||
async def update_folder(
|
||||
folder_id: int,
|
||||
|
|
@ -340,7 +367,7 @@ async def delete_folder(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""Delete a folder and cascade-delete subfolders. Documents are async-deleted via Celery."""
|
||||
"""Mark documents for deletion and dispatch Celery to delete docs first, then folders."""
|
||||
try:
|
||||
folder = await session.get(Folder, folder_id)
|
||||
if not folder:
|
||||
|
|
@ -372,30 +399,29 @@ async def delete_folder(
|
|||
)
|
||||
await session.commit()
|
||||
|
||||
await session.execute(Folder.__table__.delete().where(Folder.id == folder_id))
|
||||
await session.commit()
|
||||
try:
|
||||
from app.tasks.celery_tasks.document_tasks import (
|
||||
delete_folder_documents_task,
|
||||
)
|
||||
|
||||
if document_ids:
|
||||
try:
|
||||
from app.tasks.celery_tasks.document_tasks import (
|
||||
delete_folder_documents_task,
|
||||
)
|
||||
|
||||
delete_folder_documents_task.delay(document_ids)
|
||||
except Exception as err:
|
||||
delete_folder_documents_task.delay(
|
||||
document_ids, folder_subtree_ids=list(subtree_ids)
|
||||
)
|
||||
except Exception as err:
|
||||
if document_ids:
|
||||
await session.execute(
|
||||
Document.__table__.update()
|
||||
.where(Document.id.in_(document_ids))
|
||||
.values(status={"state": "ready"})
|
||||
)
|
||||
await session.commit()
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Folder deleted but document cleanup could not be queued. Documents have been restored.",
|
||||
) from err
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Could not queue folder deletion. Documents have been restored.",
|
||||
) from err
|
||||
|
||||
return {
|
||||
"message": "Folder deleted successfully",
|
||||
"message": "Folder deletion started",
|
||||
"documents_queued_for_deletion": len(document_ids),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
"""
|
||||
API route for fetching the available LLM models catalogue.
|
||||
API route for fetching the available models catalogue.
|
||||
|
||||
Serves a dynamically-updated list sourced from the OpenRouter public API,
|
||||
with a local JSON fallback when the API is unreachable.
|
||||
|
|
@ -30,7 +30,7 @@ async def list_available_models(
|
|||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Return all available LLM models grouped by provider.
|
||||
Return all available models grouped by provider.
|
||||
|
||||
The list is sourced from the OpenRouter public API and cached for 1 hour.
|
||||
If the API is unreachable, a local fallback file is used instead.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""
|
||||
API routes for NewLLMConfig CRUD operations.
|
||||
|
||||
NewLLMConfig combines LLM model settings with prompt configuration:
|
||||
NewLLMConfig combines model settings with prompt configuration:
|
||||
- LLM provider, model, API key, etc.
|
||||
- Configurable system instructions
|
||||
- Citation toggle
|
||||
|
|
|
|||
|
|
@ -55,23 +55,12 @@ from app.schemas import (
|
|||
)
|
||||
from app.services.composio_service import ComposioService, get_composio_service
|
||||
from app.services.notification_service import NotificationService
|
||||
from app.tasks.connector_indexers import (
|
||||
index_airtable_records,
|
||||
index_clickup_tasks,
|
||||
index_confluence_pages,
|
||||
index_crawled_urls,
|
||||
index_discord_messages,
|
||||
index_elasticsearch_documents,
|
||||
index_github_repos,
|
||||
index_google_calendar_events,
|
||||
index_google_gmail_messages,
|
||||
index_jira_issues,
|
||||
index_linear_issues,
|
||||
index_luma_events,
|
||||
index_notion_pages,
|
||||
index_slack_messages,
|
||||
)
|
||||
from app.users import current_active_user
|
||||
|
||||
# NOTE: connector indexer functions are imported lazily inside each
|
||||
# ``run_*_indexing`` helper to break a circular import cycle:
|
||||
# connector_indexers.__init__ → airtable_indexer → airtable_history
|
||||
# → app.routes.__init__ → this file → connector_indexers (not ready yet)
|
||||
from app.utils.connector_naming import ensure_unique_connector_name
|
||||
from app.utils.indexing_locks import (
|
||||
acquire_connector_indexing_lock,
|
||||
|
|
@ -1378,6 +1367,8 @@ async def run_slack_indexing(
|
|||
start_date: Start date for indexing
|
||||
end_date: End date for indexing
|
||||
"""
|
||||
from app.tasks.connector_indexers import index_slack_messages
|
||||
|
||||
await _run_indexing_with_notifications(
|
||||
session=session,
|
||||
connector_id=connector_id,
|
||||
|
|
@ -1824,6 +1815,8 @@ async def run_notion_indexing_with_new_session(
|
|||
Create a new session and run the Notion indexing task.
|
||||
This prevents session leaks by creating a dedicated session for the background task.
|
||||
"""
|
||||
from app.tasks.connector_indexers import index_notion_pages
|
||||
|
||||
async with async_session_maker() as session:
|
||||
await _run_indexing_with_notifications(
|
||||
session=session,
|
||||
|
|
@ -1858,6 +1851,8 @@ async def run_notion_indexing(
|
|||
start_date: Start date for indexing
|
||||
end_date: End date for indexing
|
||||
"""
|
||||
from app.tasks.connector_indexers import index_notion_pages
|
||||
|
||||
await _run_indexing_with_notifications(
|
||||
session=session,
|
||||
connector_id=connector_id,
|
||||
|
|
@ -1910,6 +1905,8 @@ async def run_github_indexing(
|
|||
start_date: Start date for indexing
|
||||
end_date: End date for indexing
|
||||
"""
|
||||
from app.tasks.connector_indexers import index_github_repos
|
||||
|
||||
await _run_indexing_with_notifications(
|
||||
session=session,
|
||||
connector_id=connector_id,
|
||||
|
|
@ -1961,6 +1958,8 @@ async def run_linear_indexing(
|
|||
start_date: Start date for indexing
|
||||
end_date: End date for indexing
|
||||
"""
|
||||
from app.tasks.connector_indexers import index_linear_issues
|
||||
|
||||
await _run_indexing_with_notifications(
|
||||
session=session,
|
||||
connector_id=connector_id,
|
||||
|
|
@ -2011,6 +2010,8 @@ async def run_discord_indexing(
|
|||
start_date: Start date for indexing
|
||||
end_date: End date for indexing
|
||||
"""
|
||||
from app.tasks.connector_indexers import index_discord_messages
|
||||
|
||||
await _run_indexing_with_notifications(
|
||||
session=session,
|
||||
connector_id=connector_id,
|
||||
|
|
@ -2113,6 +2114,8 @@ async def run_jira_indexing(
|
|||
start_date: Start date for indexing
|
||||
end_date: End date for indexing
|
||||
"""
|
||||
from app.tasks.connector_indexers import index_jira_issues
|
||||
|
||||
await _run_indexing_with_notifications(
|
||||
session=session,
|
||||
connector_id=connector_id,
|
||||
|
|
@ -2166,6 +2169,8 @@ async def run_confluence_indexing(
|
|||
start_date: Start date for indexing
|
||||
end_date: End date for indexing
|
||||
"""
|
||||
from app.tasks.connector_indexers import index_confluence_pages
|
||||
|
||||
await _run_indexing_with_notifications(
|
||||
session=session,
|
||||
connector_id=connector_id,
|
||||
|
|
@ -2217,6 +2222,8 @@ async def run_clickup_indexing(
|
|||
start_date: Start date for indexing
|
||||
end_date: End date for indexing
|
||||
"""
|
||||
from app.tasks.connector_indexers import index_clickup_tasks
|
||||
|
||||
await _run_indexing_with_notifications(
|
||||
session=session,
|
||||
connector_id=connector_id,
|
||||
|
|
@ -2268,6 +2275,8 @@ async def run_airtable_indexing(
|
|||
start_date: Start date for indexing
|
||||
end_date: End date for indexing
|
||||
"""
|
||||
from app.tasks.connector_indexers import index_airtable_records
|
||||
|
||||
await _run_indexing_with_notifications(
|
||||
session=session,
|
||||
connector_id=connector_id,
|
||||
|
|
@ -2321,6 +2330,8 @@ async def run_google_calendar_indexing(
|
|||
start_date: Start date for indexing
|
||||
end_date: End date for indexing
|
||||
"""
|
||||
from app.tasks.connector_indexers import index_google_calendar_events
|
||||
|
||||
await _run_indexing_with_notifications(
|
||||
session=session,
|
||||
connector_id=connector_id,
|
||||
|
|
@ -2370,6 +2381,7 @@ async def run_google_gmail_indexing(
|
|||
start_date: Start date for indexing
|
||||
end_date: End date for indexing
|
||||
"""
|
||||
from app.tasks.connector_indexers import index_google_gmail_messages
|
||||
|
||||
# Create a wrapper function that calls index_google_gmail_messages with max_messages
|
||||
async def gmail_indexing_wrapper(
|
||||
|
|
@ -2836,6 +2848,8 @@ async def run_luma_indexing(
|
|||
start_date: Start date for indexing
|
||||
end_date: End date for indexing
|
||||
"""
|
||||
from app.tasks.connector_indexers import index_luma_events
|
||||
|
||||
await _run_indexing_with_notifications(
|
||||
session=session,
|
||||
connector_id=connector_id,
|
||||
|
|
@ -2888,6 +2902,8 @@ async def run_elasticsearch_indexing(
|
|||
start_date: Start date for indexing
|
||||
end_date: End date for indexing
|
||||
"""
|
||||
from app.tasks.connector_indexers import index_elasticsearch_documents
|
||||
|
||||
await _run_indexing_with_notifications(
|
||||
session=session,
|
||||
connector_id=connector_id,
|
||||
|
|
@ -2938,6 +2954,8 @@ async def run_web_page_indexing(
|
|||
start_date: Start date for indexing
|
||||
end_date: End date for indexing
|
||||
"""
|
||||
from app.tasks.connector_indexers import index_crawled_urls
|
||||
|
||||
await _run_indexing_with_notifications(
|
||||
session=session,
|
||||
connector_id=connector_id,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"""Pydantic schemas for folder CRUD, move, and reorder operations."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
|
@ -34,6 +35,9 @@ class FolderRead(BaseModel):
|
|||
created_by_id: UUID | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
metadata: dict[str, Any] | None = Field(
|
||||
default=None, validation_alias="folder_metadata"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""
|
||||
Pydantic schemas for the NewLLMConfig API.
|
||||
|
||||
NewLLMConfig combines LLM model settings with prompt configuration:
|
||||
NewLLMConfig combines model settings with prompt configuration:
|
||||
- LLM provider, model, API key, etc.
|
||||
- Configurable system instructions
|
||||
- Citation toggle
|
||||
|
|
@ -26,7 +26,7 @@ class NewLLMConfigBase(BaseModel):
|
|||
None, max_length=500, description="Optional description"
|
||||
)
|
||||
|
||||
# LLM Model Configuration
|
||||
# Model Configuration
|
||||
provider: LiteLLMProvider = Field(..., description="LiteLLM provider type")
|
||||
custom_provider: str | None = Field(
|
||||
None, max_length=100, description="Custom provider name when provider is CUSTOM"
|
||||
|
|
@ -71,7 +71,7 @@ class NewLLMConfigUpdate(BaseModel):
|
|||
name: str | None = Field(None, max_length=100)
|
||||
description: str | None = Field(None, max_length=500)
|
||||
|
||||
# LLM Model Configuration
|
||||
# Model Configuration
|
||||
provider: LiteLLMProvider | None = None
|
||||
custom_provider: str | None = Field(None, max_length=100)
|
||||
model_name: str | None = Field(None, max_length=100)
|
||||
|
|
@ -106,7 +106,7 @@ class NewLLMConfigPublic(BaseModel):
|
|||
name: str
|
||||
description: str | None = None
|
||||
|
||||
# LLM Model Configuration (no api_key)
|
||||
# Model Configuration (no api_key)
|
||||
provider: LiteLLMProvider
|
||||
custom_provider: str | None = None
|
||||
model_name: str
|
||||
|
|
@ -149,7 +149,7 @@ class GlobalNewLLMConfigRead(BaseModel):
|
|||
name: str
|
||||
description: str | None = None
|
||||
|
||||
# LLM Model Configuration (no api_key)
|
||||
# Model Configuration (no api_key)
|
||||
provider: str # String because YAML doesn't enforce enum, "AUTO" for Auto mode
|
||||
custom_provider: str | None = None
|
||||
model_name: str
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
"""
|
||||
Service for fetching and caching the available LLM model list.
|
||||
Service for fetching and caching the available model list.
|
||||
|
||||
Uses the OpenRouter public API as the primary source, with a local
|
||||
fallback JSON file when the API is unreachable.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"""Celery tasks for document processing."""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
from uuid import UUID
|
||||
|
|
@ -10,6 +11,7 @@ from app.config import config
|
|||
from app.services.notification_service import NotificationService
|
||||
from app.services.task_logging_service import TaskLoggingService
|
||||
from app.tasks.celery_tasks import get_celery_session_maker
|
||||
from app.tasks.connector_indexers.local_folder_indexer import index_local_folder
|
||||
from app.tasks.document_processors import (
|
||||
add_extension_received_document,
|
||||
add_youtube_video_document,
|
||||
|
|
@ -141,21 +143,30 @@ async def _delete_document_background(document_id: int) -> None:
|
|||
retry_backoff_max=300,
|
||||
max_retries=5,
|
||||
)
|
||||
def delete_folder_documents_task(self, document_ids: list[int]):
|
||||
"""Celery task to batch-delete documents orphaned by folder deletion."""
|
||||
def delete_folder_documents_task(
|
||||
self,
|
||||
document_ids: list[int],
|
||||
folder_subtree_ids: list[int] | None = None,
|
||||
):
|
||||
"""Celery task to delete documents first, then the folder rows."""
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
loop.run_until_complete(_delete_folder_documents(document_ids))
|
||||
loop.run_until_complete(
|
||||
_delete_folder_documents(document_ids, folder_subtree_ids)
|
||||
)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
async def _delete_folder_documents(document_ids: list[int]) -> None:
|
||||
"""Delete chunks in batches, then document rows for each orphaned document."""
|
||||
async def _delete_folder_documents(
|
||||
document_ids: list[int],
|
||||
folder_subtree_ids: list[int] | None = None,
|
||||
) -> None:
|
||||
"""Delete chunks in batches, then document rows, then folder rows."""
|
||||
from sqlalchemy import delete as sa_delete, select
|
||||
|
||||
from app.db import Chunk, Document
|
||||
from app.db import Chunk, Document, Folder
|
||||
|
||||
async with get_celery_session_maker()() as session:
|
||||
batch_size = 500
|
||||
|
|
@ -177,6 +188,12 @@ async def _delete_folder_documents(document_ids: list[int]) -> None:
|
|||
await session.delete(doc)
|
||||
await session.commit()
|
||||
|
||||
if folder_subtree_ids:
|
||||
await session.execute(
|
||||
sa_delete(Folder).where(Folder.id.in_(folder_subtree_ids))
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
name="delete_search_space_background",
|
||||
|
|
@ -1243,3 +1260,154 @@ async def _process_circleback_meeting(
|
|||
heartbeat_task.cancel()
|
||||
if notification:
|
||||
_stop_heartbeat(notification.id)
|
||||
|
||||
|
||||
# ===== Local folder indexing task =====
|
||||
|
||||
|
||||
@celery_app.task(name="index_local_folder", bind=True)
|
||||
def index_local_folder_task(
|
||||
self,
|
||||
search_space_id: int,
|
||||
user_id: str,
|
||||
folder_path: str,
|
||||
folder_name: str,
|
||||
exclude_patterns: list[str] | None = None,
|
||||
file_extensions: list[str] | None = None,
|
||||
root_folder_id: int | None = None,
|
||||
enable_summary: bool = False,
|
||||
target_file_paths: list[str] | None = None,
|
||||
):
|
||||
"""Celery task to index a local folder. Config is passed directly — no connector row."""
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
try:
|
||||
loop.run_until_complete(
|
||||
_index_local_folder_async(
|
||||
search_space_id=search_space_id,
|
||||
user_id=user_id,
|
||||
folder_path=folder_path,
|
||||
folder_name=folder_name,
|
||||
exclude_patterns=exclude_patterns,
|
||||
file_extensions=file_extensions,
|
||||
root_folder_id=root_folder_id,
|
||||
enable_summary=enable_summary,
|
||||
target_file_paths=target_file_paths,
|
||||
)
|
||||
)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
async def _index_local_folder_async(
|
||||
search_space_id: int,
|
||||
user_id: str,
|
||||
folder_path: str,
|
||||
folder_name: str,
|
||||
exclude_patterns: list[str] | None = None,
|
||||
file_extensions: list[str] | None = None,
|
||||
root_folder_id: int | None = None,
|
||||
enable_summary: bool = False,
|
||||
target_file_paths: list[str] | None = None,
|
||||
):
|
||||
"""Run local folder indexing with notification + heartbeat."""
|
||||
is_batch = bool(target_file_paths)
|
||||
is_full_scan = not target_file_paths
|
||||
file_count = len(target_file_paths) if target_file_paths else None
|
||||
|
||||
if is_batch:
|
||||
doc_name = f"{folder_name} ({file_count} file{'s' if file_count != 1 else ''})"
|
||||
else:
|
||||
doc_name = folder_name
|
||||
|
||||
notification = None
|
||||
notification_id: int | None = None
|
||||
heartbeat_task = None
|
||||
|
||||
async with get_celery_session_maker()() as session:
|
||||
try:
|
||||
notification = (
|
||||
await NotificationService.document_processing.notify_processing_started(
|
||||
session=session,
|
||||
user_id=UUID(user_id),
|
||||
document_type="LOCAL_FOLDER_FILE",
|
||||
document_name=doc_name,
|
||||
search_space_id=search_space_id,
|
||||
)
|
||||
)
|
||||
notification_id = notification.id
|
||||
_start_heartbeat(notification_id)
|
||||
heartbeat_task = asyncio.create_task(_run_heartbeat_loop(notification_id))
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to create notification for local folder indexing",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
async def _heartbeat_progress(completed_count: int) -> None:
|
||||
"""Refresh heartbeat and optionally update notification progress."""
|
||||
if notification:
|
||||
with contextlib.suppress(Exception):
|
||||
await NotificationService.document_processing.notify_processing_progress(
|
||||
session=session,
|
||||
notification=notification,
|
||||
stage="indexing",
|
||||
stage_message=f"Syncing files ({completed_count}/{file_count or '?'})",
|
||||
)
|
||||
|
||||
try:
|
||||
_indexed, _skipped_or_failed, _rfid, err = await index_local_folder(
|
||||
session=session,
|
||||
search_space_id=search_space_id,
|
||||
user_id=user_id,
|
||||
folder_path=folder_path,
|
||||
folder_name=folder_name,
|
||||
exclude_patterns=exclude_patterns,
|
||||
file_extensions=file_extensions,
|
||||
root_folder_id=root_folder_id,
|
||||
enable_summary=enable_summary,
|
||||
target_file_paths=target_file_paths,
|
||||
on_heartbeat_callback=_heartbeat_progress
|
||||
if (is_batch or is_full_scan)
|
||||
else None,
|
||||
)
|
||||
|
||||
if notification:
|
||||
try:
|
||||
await session.refresh(notification)
|
||||
if err:
|
||||
await NotificationService.document_processing.notify_processing_completed(
|
||||
session=session,
|
||||
notification=notification,
|
||||
error_message=err,
|
||||
)
|
||||
else:
|
||||
await NotificationService.document_processing.notify_processing_completed(
|
||||
session=session,
|
||||
notification=notification,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to update notification after local folder indexing",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Local folder indexing failed: {e}")
|
||||
if notification:
|
||||
try:
|
||||
await session.refresh(notification)
|
||||
await NotificationService.document_processing.notify_processing_completed(
|
||||
session=session,
|
||||
notification=notification,
|
||||
error_message=str(e)[:200],
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
finally:
|
||||
if heartbeat_task:
|
||||
heartbeat_task.cancel()
|
||||
if notification_id is not None:
|
||||
_stop_heartbeat(notification_id)
|
||||
|
|
|
|||
|
|
@ -42,9 +42,9 @@ from .jira_indexer import index_jira_issues
|
|||
|
||||
# Issue tracking and project management
|
||||
from .linear_indexer import index_linear_issues
|
||||
from .luma_indexer import index_luma_events
|
||||
|
||||
# Documentation and knowledge management
|
||||
from .luma_indexer import index_luma_events
|
||||
from .notion_indexer import index_notion_pages
|
||||
from .obsidian_indexer import index_obsidian_vault
|
||||
from .slack_indexer import index_slack_messages
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
107
surfsense_backend/app/utils/document_versioning.py
Normal file
107
surfsense_backend/app/utils/document_versioning.py
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
"""Document versioning: snapshot creation and cleanup.
|
||||
|
||||
Rules:
|
||||
- 30-minute debounce window: if the latest version was created < 30 min ago,
|
||||
overwrite it instead of creating a new row.
|
||||
- Maximum 20 versions per document.
|
||||
- Versions older than 90 days are cleaned up.
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db import Document, DocumentVersion
|
||||
|
||||
MAX_VERSIONS_PER_DOCUMENT = 20
|
||||
DEBOUNCE_MINUTES = 30
|
||||
RETENTION_DAYS = 90
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
async def create_version_snapshot(
|
||||
session: AsyncSession,
|
||||
document: Document,
|
||||
) -> DocumentVersion | None:
|
||||
"""Snapshot the document's current state into a DocumentVersion row.
|
||||
|
||||
Returns the created/updated DocumentVersion, or None if nothing was done.
|
||||
"""
|
||||
now = _now()
|
||||
|
||||
latest = (
|
||||
await session.execute(
|
||||
select(DocumentVersion)
|
||||
.where(DocumentVersion.document_id == document.id)
|
||||
.order_by(DocumentVersion.version_number.desc())
|
||||
.limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if latest is not None:
|
||||
age = now - latest.created_at.replace(tzinfo=UTC)
|
||||
if age < timedelta(minutes=DEBOUNCE_MINUTES):
|
||||
latest.source_markdown = document.source_markdown
|
||||
latest.content_hash = document.content_hash
|
||||
latest.title = document.title
|
||||
latest.created_at = now
|
||||
await session.flush()
|
||||
return latest
|
||||
|
||||
max_num = (
|
||||
await session.execute(
|
||||
select(func.coalesce(func.max(DocumentVersion.version_number), 0)).where(
|
||||
DocumentVersion.document_id == document.id
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
version = DocumentVersion(
|
||||
document_id=document.id,
|
||||
version_number=max_num + 1,
|
||||
source_markdown=document.source_markdown,
|
||||
content_hash=document.content_hash,
|
||||
title=document.title,
|
||||
created_at=now,
|
||||
)
|
||||
session.add(version)
|
||||
await session.flush()
|
||||
|
||||
# Cleanup: remove versions older than 90 days
|
||||
cutoff = now - timedelta(days=RETENTION_DAYS)
|
||||
await session.execute(
|
||||
delete(DocumentVersion).where(
|
||||
DocumentVersion.document_id == document.id,
|
||||
DocumentVersion.created_at < cutoff,
|
||||
)
|
||||
)
|
||||
|
||||
# Cleanup: cap at MAX_VERSIONS_PER_DOCUMENT
|
||||
count = (
|
||||
await session.execute(
|
||||
select(func.count())
|
||||
.select_from(DocumentVersion)
|
||||
.where(DocumentVersion.document_id == document.id)
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
if count > MAX_VERSIONS_PER_DOCUMENT:
|
||||
excess = count - MAX_VERSIONS_PER_DOCUMENT
|
||||
oldest_ids_result = await session.execute(
|
||||
select(DocumentVersion.id)
|
||||
.where(DocumentVersion.document_id == document.id)
|
||||
.order_by(DocumentVersion.version_number.asc())
|
||||
.limit(excess)
|
||||
)
|
||||
oldest_ids = [row[0] for row in oldest_ids_result.all()]
|
||||
if oldest_ids:
|
||||
await session.execute(
|
||||
delete(DocumentVersion).where(DocumentVersion.id.in_(oldest_ids))
|
||||
)
|
||||
|
||||
await session.flush()
|
||||
return version
|
||||
File diff suppressed because it is too large
Load diff
167
surfsense_backend/tests/integration/test_document_versioning.py
Normal file
167
surfsense_backend/tests/integration/test_document_versioning.py
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
"""Integration tests for document versioning snapshot + cleanup."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db import Document, DocumentType, DocumentVersion, SearchSpace, User
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_document(
|
||||
db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
|
||||
) -> Document:
|
||||
doc = Document(
|
||||
title="Test Doc",
|
||||
document_type=DocumentType.LOCAL_FOLDER_FILE,
|
||||
document_metadata={},
|
||||
content="Summary of test doc.",
|
||||
content_hash="abc123",
|
||||
unique_identifier_hash="local_folder:test-folder:test.md",
|
||||
source_markdown="# Test\n\nOriginal content.",
|
||||
search_space_id=db_search_space.id,
|
||||
created_by_id=db_user.id,
|
||||
)
|
||||
db_session.add(doc)
|
||||
await db_session.flush()
|
||||
return doc
|
||||
|
||||
|
||||
async def _version_count(session: AsyncSession, document_id: int) -> int:
|
||||
result = await session.execute(
|
||||
select(func.count())
|
||||
.select_from(DocumentVersion)
|
||||
.where(DocumentVersion.document_id == document_id)
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
async def _get_versions(
|
||||
session: AsyncSession, document_id: int
|
||||
) -> list[DocumentVersion]:
|
||||
result = await session.execute(
|
||||
select(DocumentVersion)
|
||||
.where(DocumentVersion.document_id == document_id)
|
||||
.order_by(DocumentVersion.version_number)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
class TestCreateVersionSnapshot:
|
||||
"""V1-V5: TDD slices for create_version_snapshot."""
|
||||
|
||||
async def test_v1_creates_first_version(self, db_session, db_document):
|
||||
"""V1: First snapshot creates version 1 with the document's current state."""
|
||||
from app.utils.document_versioning import create_version_snapshot
|
||||
|
||||
await create_version_snapshot(db_session, db_document)
|
||||
|
||||
versions = await _get_versions(db_session, db_document.id)
|
||||
assert len(versions) == 1
|
||||
assert versions[0].version_number == 1
|
||||
assert versions[0].source_markdown == "# Test\n\nOriginal content."
|
||||
assert versions[0].content_hash == "abc123"
|
||||
assert versions[0].title == "Test Doc"
|
||||
assert versions[0].document_id == db_document.id
|
||||
|
||||
async def test_v2_creates_version_2_after_30_min(
|
||||
self, db_session, db_document, monkeypatch
|
||||
):
|
||||
"""V2: After 30+ minutes, a new version is created (not overwritten)."""
|
||||
from app.utils.document_versioning import create_version_snapshot
|
||||
|
||||
t0 = datetime(2025, 1, 1, 12, 0, 0, tzinfo=UTC)
|
||||
monkeypatch.setattr("app.utils.document_versioning._now", lambda: t0)
|
||||
await create_version_snapshot(db_session, db_document)
|
||||
|
||||
# Simulate content change and time passing
|
||||
db_document.source_markdown = "# Test\n\nUpdated content."
|
||||
db_document.content_hash = "def456"
|
||||
t1 = t0 + timedelta(minutes=31)
|
||||
monkeypatch.setattr("app.utils.document_versioning._now", lambda: t1)
|
||||
await create_version_snapshot(db_session, db_document)
|
||||
|
||||
versions = await _get_versions(db_session, db_document.id)
|
||||
assert len(versions) == 2
|
||||
assert versions[0].version_number == 1
|
||||
assert versions[1].version_number == 2
|
||||
assert versions[1].source_markdown == "# Test\n\nUpdated content."
|
||||
|
||||
async def test_v3_overwrites_within_30_min(
|
||||
self, db_session, db_document, monkeypatch
|
||||
):
|
||||
"""V3: Within 30 minutes, the latest version is overwritten."""
|
||||
from app.utils.document_versioning import create_version_snapshot
|
||||
|
||||
t0 = datetime(2025, 1, 1, 12, 0, 0, tzinfo=UTC)
|
||||
monkeypatch.setattr("app.utils.document_versioning._now", lambda: t0)
|
||||
await create_version_snapshot(db_session, db_document)
|
||||
count_after_first = await _version_count(db_session, db_document.id)
|
||||
assert count_after_first == 1
|
||||
|
||||
# Simulate quick edit within 30 minutes
|
||||
db_document.source_markdown = "# Test\n\nQuick edit."
|
||||
db_document.content_hash = "quick123"
|
||||
t1 = t0 + timedelta(minutes=10)
|
||||
monkeypatch.setattr("app.utils.document_versioning._now", lambda: t1)
|
||||
await create_version_snapshot(db_session, db_document)
|
||||
|
||||
count_after_second = await _version_count(db_session, db_document.id)
|
||||
assert count_after_second == 1 # still 1, not 2
|
||||
|
||||
versions = await _get_versions(db_session, db_document.id)
|
||||
assert versions[0].source_markdown == "# Test\n\nQuick edit."
|
||||
assert versions[0].content_hash == "quick123"
|
||||
|
||||
async def test_v4_cleanup_90_day_old_versions(
|
||||
self, db_session, db_document, monkeypatch
|
||||
):
|
||||
"""V4: Versions older than 90 days are cleaned up."""
|
||||
from app.utils.document_versioning import create_version_snapshot
|
||||
|
||||
base = datetime(2025, 1, 1, 12, 0, 0, tzinfo=UTC)
|
||||
|
||||
# Create 5 versions spread across time: 3 older than 90 days, 2 recent
|
||||
for i in range(5):
|
||||
db_document.source_markdown = f"Content v{i + 1}"
|
||||
db_document.content_hash = f"hash_{i + 1}"
|
||||
t = base + timedelta(days=i) if i < 3 else base + timedelta(days=100 + i)
|
||||
monkeypatch.setattr("app.utils.document_versioning._now", lambda _t=t: _t)
|
||||
await create_version_snapshot(db_session, db_document)
|
||||
|
||||
# Now trigger cleanup from a "current" time that makes the first 3 versions > 90 days old
|
||||
now = base + timedelta(days=200)
|
||||
monkeypatch.setattr("app.utils.document_versioning._now", lambda: now)
|
||||
db_document.source_markdown = "Content v6"
|
||||
db_document.content_hash = "hash_6"
|
||||
await create_version_snapshot(db_session, db_document)
|
||||
|
||||
versions = await _get_versions(db_session, db_document.id)
|
||||
# The first 3 (old) should be cleaned up; versions 4, 5, 6 remain
|
||||
for v in versions:
|
||||
age = now - v.created_at.replace(tzinfo=UTC)
|
||||
assert age <= timedelta(days=90), f"Version {v.version_number} is too old"
|
||||
|
||||
async def test_v5_cap_at_20_versions(self, db_session, db_document, monkeypatch):
|
||||
"""V5: More than 20 versions triggers cap — oldest gets deleted."""
|
||||
from app.utils.document_versioning import create_version_snapshot
|
||||
|
||||
base = datetime(2025, 6, 1, 12, 0, 0, tzinfo=UTC)
|
||||
|
||||
# Create 21 versions (all within 90 days, each 31 min apart)
|
||||
for i in range(21):
|
||||
db_document.source_markdown = f"Content v{i + 1}"
|
||||
db_document.content_hash = f"hash_{i + 1}"
|
||||
t = base + timedelta(minutes=31 * i)
|
||||
monkeypatch.setattr("app.utils.document_versioning._now", lambda _t=t: _t)
|
||||
await create_version_snapshot(db_session, db_document)
|
||||
|
||||
versions = await _get_versions(db_session, db_document.id)
|
||||
assert len(versions) == 20
|
||||
# The lowest version_number should be 2 (version 1 was the oldest and got capped)
|
||||
assert versions[0].version_number == 2
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
"""Unit tests for scan_folder() pure logic — Tier 2 TDD slices (S1-S4)."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
class TestScanFolder:
|
||||
"""S1-S4: scan_folder() with real tmp_path filesystem."""
|
||||
|
||||
def test_s1_single_md_file(self, tmp_path: Path):
|
||||
"""S1: scan_folder on a dir with one .md file returns correct entry."""
|
||||
from app.tasks.connector_indexers.local_folder_indexer import scan_folder
|
||||
|
||||
md = tmp_path / "note.md"
|
||||
md.write_text("# Hello")
|
||||
|
||||
results = scan_folder(str(tmp_path))
|
||||
|
||||
assert len(results) == 1
|
||||
entry = results[0]
|
||||
assert entry["relative_path"] == "note.md"
|
||||
assert entry["size"] > 0
|
||||
assert "modified_at" in entry
|
||||
assert entry["path"] == str(md)
|
||||
|
||||
def test_s2_extension_filter(self, tmp_path: Path):
|
||||
"""S2: file_extensions filter returns only matching files."""
|
||||
from app.tasks.connector_indexers.local_folder_indexer import scan_folder
|
||||
|
||||
(tmp_path / "a.md").write_text("md")
|
||||
(tmp_path / "b.txt").write_text("txt")
|
||||
(tmp_path / "c.pdf").write_bytes(b"%PDF")
|
||||
|
||||
results = scan_folder(str(tmp_path), file_extensions=[".md"])
|
||||
names = {r["relative_path"] for r in results}
|
||||
|
||||
assert names == {"a.md"}
|
||||
|
||||
def test_s3_exclude_patterns(self, tmp_path: Path):
|
||||
"""S3: exclude_patterns skips files inside excluded directories."""
|
||||
from app.tasks.connector_indexers.local_folder_indexer import scan_folder
|
||||
|
||||
(tmp_path / "good.md").write_text("good")
|
||||
nm = tmp_path / "node_modules"
|
||||
nm.mkdir()
|
||||
(nm / "dep.js").write_text("module")
|
||||
git = tmp_path / ".git"
|
||||
git.mkdir()
|
||||
(git / "config").write_text("gitconfig")
|
||||
|
||||
results = scan_folder(str(tmp_path), exclude_patterns=["node_modules", ".git"])
|
||||
names = {r["relative_path"] for r in results}
|
||||
|
||||
assert "good.md" in names
|
||||
assert not any("node_modules" in n for n in names)
|
||||
assert not any(".git" in n for n in names)
|
||||
|
||||
def test_s4_nested_dirs(self, tmp_path: Path):
|
||||
"""S4: nested subdirectories produce correct relative paths."""
|
||||
from app.tasks.connector_indexers.local_folder_indexer import scan_folder
|
||||
|
||||
daily = tmp_path / "notes" / "daily"
|
||||
daily.mkdir(parents=True)
|
||||
weekly = tmp_path / "notes" / "weekly"
|
||||
weekly.mkdir(parents=True)
|
||||
(daily / "today.md").write_text("today")
|
||||
(weekly / "review.md").write_text("review")
|
||||
(tmp_path / "root.txt").write_text("root")
|
||||
|
||||
results = scan_folder(str(tmp_path))
|
||||
paths = {r["relative_path"] for r in results}
|
||||
|
||||
assert "notes/daily/today.md" in paths or "notes\\daily\\today.md" in paths
|
||||
assert "notes/weekly/review.md" in paths or "notes\\weekly\\review.md" in paths
|
||||
assert "root.txt" in paths
|
||||
|
|
@ -27,6 +27,8 @@
|
|||
"wait-on": "^9.0.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"chokidar": "^5.0.0",
|
||||
"electron-store": "^11.0.2",
|
||||
"electron-updater": "^6.8.3",
|
||||
"get-port-please": "^3.2.0"
|
||||
}
|
||||
|
|
|
|||
168
surfsense_desktop/pnpm-lock.yaml
generated
168
surfsense_desktop/pnpm-lock.yaml
generated
|
|
@ -8,6 +8,12 @@ importers:
|
|||
|
||||
.:
|
||||
dependencies:
|
||||
chokidar:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0
|
||||
electron-store:
|
||||
specifier: ^11.0.2
|
||||
version: 11.0.2
|
||||
electron-updater:
|
||||
specifier: ^6.8.3
|
||||
version: 6.8.3
|
||||
|
|
@ -352,6 +358,14 @@ packages:
|
|||
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
ajv-formats@3.0.1:
|
||||
resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
|
||||
peerDependencies:
|
||||
ajv: ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
ajv:
|
||||
optional: true
|
||||
|
||||
ajv-keywords@3.5.2:
|
||||
resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==}
|
||||
peerDependencies:
|
||||
|
|
@ -360,6 +374,9 @@ packages:
|
|||
ajv@6.14.0:
|
||||
resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==}
|
||||
|
||||
ajv@8.18.0:
|
||||
resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==}
|
||||
|
||||
ansi-regex@5.0.1:
|
||||
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
|
@ -411,6 +428,9 @@ packages:
|
|||
resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==}
|
||||
engines: {node: '>= 4.0.0'}
|
||||
|
||||
atomically@2.1.1:
|
||||
resolution: {integrity: sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ==}
|
||||
|
||||
axios@1.13.6:
|
||||
resolution: {integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==}
|
||||
|
||||
|
|
@ -477,6 +497,10 @@ packages:
|
|||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
chokidar@5.0.0:
|
||||
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
chownr@3.0.0:
|
||||
resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -546,6 +570,10 @@ packages:
|
|||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
conf@15.1.0:
|
||||
resolution: {integrity: sha512-Uy5YN9KEu0WWDaZAVJ5FAmZoaJt9rdK6kH+utItPyGsCqCgaTKkrmZx3zoE0/3q6S3bcp3Ihkk+ZqPxWxFK5og==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
core-util-is@1.0.2:
|
||||
resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==}
|
||||
|
||||
|
|
@ -559,6 +587,10 @@ packages:
|
|||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
debounce-fn@6.0.0:
|
||||
resolution: {integrity: sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
debug@4.4.3:
|
||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||
engines: {node: '>=6.0'}
|
||||
|
|
@ -610,6 +642,10 @@ packages:
|
|||
os: [darwin]
|
||||
hasBin: true
|
||||
|
||||
dot-prop@10.1.0:
|
||||
resolution: {integrity: sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
dotenv-expand@11.0.7:
|
||||
resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==}
|
||||
engines: {node: '>=12'}
|
||||
|
|
@ -645,6 +681,10 @@ packages:
|
|||
electron-publish@26.8.1:
|
||||
resolution: {integrity: sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w==}
|
||||
|
||||
electron-store@11.0.2:
|
||||
resolution: {integrity: sha512-4VkNRdN+BImL2KcCi41WvAYbh6zLX5AUTi4so68yPqiItjbgTjqpEnGAqasgnG+lB6GuAyUltKwVopp6Uv+gwQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
electron-updater@6.8.3:
|
||||
resolution: {integrity: sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ==}
|
||||
|
||||
|
|
@ -673,6 +713,10 @@ packages:
|
|||
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
env-paths@3.0.0:
|
||||
resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
||||
err-code@2.0.3:
|
||||
resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==}
|
||||
|
||||
|
|
@ -726,6 +770,9 @@ packages:
|
|||
fast-json-stable-stringify@2.1.0:
|
||||
resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
|
||||
|
||||
fast-uri@3.1.0:
|
||||
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
|
||||
|
||||
fd-slicer@1.1.0:
|
||||
resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==}
|
||||
|
||||
|
|
@ -953,6 +1000,12 @@ packages:
|
|||
json-schema-traverse@0.4.1:
|
||||
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
|
||||
|
||||
json-schema-traverse@1.0.0:
|
||||
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
|
||||
|
||||
json-schema-typed@8.0.2:
|
||||
resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==}
|
||||
|
||||
json-stringify-safe@5.0.1:
|
||||
resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==}
|
||||
|
||||
|
|
@ -983,6 +1036,9 @@ packages:
|
|||
lodash@4.17.23:
|
||||
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
|
||||
|
||||
lodash@4.18.1:
|
||||
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
|
||||
|
||||
log-symbols@4.1.0:
|
||||
resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==}
|
||||
engines: {node: '>=10'}
|
||||
|
|
@ -1027,6 +1083,10 @@ packages:
|
|||
resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
mimic-function@5.0.1:
|
||||
resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
mimic-response@1.0.1:
|
||||
resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==}
|
||||
engines: {node: '>=4'}
|
||||
|
|
@ -1222,10 +1282,18 @@ packages:
|
|||
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
readdirp@5.0.0:
|
||||
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
require-directory@2.1.1:
|
||||
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
require-from-string@2.0.2:
|
||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
resedit@1.7.2:
|
||||
resolution: {integrity: sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==}
|
||||
engines: {node: '>=12', npm: '>=6'}
|
||||
|
|
@ -1365,6 +1433,12 @@ packages:
|
|||
resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
stubborn-fs@2.0.0:
|
||||
resolution: {integrity: sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==}
|
||||
|
||||
stubborn-utils@1.0.2:
|
||||
resolution: {integrity: sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==}
|
||||
|
||||
sumchecker@3.0.1:
|
||||
resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==}
|
||||
engines: {node: '>= 8.0'}
|
||||
|
|
@ -1377,6 +1451,10 @@ packages:
|
|||
resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
tagged-tag@1.0.0:
|
||||
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
tar@7.5.11:
|
||||
resolution: {integrity: sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -1419,11 +1497,19 @@ packages:
|
|||
resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
type-fest@5.5.0:
|
||||
resolution: {integrity: sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
typescript@5.9.3:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
uint8array-extras@1.5.0:
|
||||
resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
undici-types@7.16.0:
|
||||
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
|
||||
|
||||
|
|
@ -1467,6 +1553,9 @@ packages:
|
|||
wcwidth@1.0.1:
|
||||
resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==}
|
||||
|
||||
when-exit@2.1.5:
|
||||
resolution: {integrity: sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==}
|
||||
|
||||
which@2.0.2:
|
||||
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||
engines: {node: '>= 8'}
|
||||
|
|
@ -1827,6 +1916,10 @@ snapshots:
|
|||
|
||||
agent-base@7.1.4: {}
|
||||
|
||||
ajv-formats@3.0.1(ajv@8.18.0):
|
||||
optionalDependencies:
|
||||
ajv: 8.18.0
|
||||
|
||||
ajv-keywords@3.5.2(ajv@6.14.0):
|
||||
dependencies:
|
||||
ajv: 6.14.0
|
||||
|
|
@ -1838,6 +1931,13 @@ snapshots:
|
|||
json-schema-traverse: 0.4.1
|
||||
uri-js: 4.4.1
|
||||
|
||||
ajv@8.18.0:
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
fast-uri: 3.1.0
|
||||
json-schema-traverse: 1.0.0
|
||||
require-from-string: 2.0.2
|
||||
|
||||
ansi-regex@5.0.1: {}
|
||||
|
||||
ansi-regex@6.2.2: {}
|
||||
|
|
@ -1909,6 +2009,11 @@ snapshots:
|
|||
|
||||
at-least-node@1.0.0: {}
|
||||
|
||||
atomically@2.1.1:
|
||||
dependencies:
|
||||
stubborn-fs: 2.0.0
|
||||
when-exit: 2.1.5
|
||||
|
||||
axios@1.13.6:
|
||||
dependencies:
|
||||
follow-redirects: 1.15.11
|
||||
|
|
@ -2019,6 +2124,10 @@ snapshots:
|
|||
ansi-styles: 4.3.0
|
||||
supports-color: 7.2.0
|
||||
|
||||
chokidar@5.0.0:
|
||||
dependencies:
|
||||
readdirp: 5.0.0
|
||||
|
||||
chownr@3.0.0: {}
|
||||
|
||||
chromium-pickle-js@0.2.0: {}
|
||||
|
|
@ -2079,6 +2188,18 @@ snapshots:
|
|||
tree-kill: 1.2.2
|
||||
yargs: 17.7.2
|
||||
|
||||
conf@15.1.0:
|
||||
dependencies:
|
||||
ajv: 8.18.0
|
||||
ajv-formats: 3.0.1(ajv@8.18.0)
|
||||
atomically: 2.1.1
|
||||
debounce-fn: 6.0.0
|
||||
dot-prop: 10.1.0
|
||||
env-paths: 3.0.0
|
||||
json-schema-typed: 8.0.2
|
||||
semver: 7.7.4
|
||||
uint8array-extras: 1.5.0
|
||||
|
||||
core-util-is@1.0.2:
|
||||
optional: true
|
||||
|
||||
|
|
@ -2096,6 +2217,10 @@ snapshots:
|
|||
shebang-command: 2.0.0
|
||||
which: 2.0.2
|
||||
|
||||
debounce-fn@6.0.0:
|
||||
dependencies:
|
||||
mimic-function: 5.0.1
|
||||
|
||||
debug@4.4.3:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
|
@ -2161,6 +2286,10 @@ snapshots:
|
|||
verror: 1.10.1
|
||||
optional: true
|
||||
|
||||
dot-prop@10.1.0:
|
||||
dependencies:
|
||||
type-fest: 5.5.0
|
||||
|
||||
dotenv-expand@11.0.7:
|
||||
dependencies:
|
||||
dotenv: 16.6.1
|
||||
|
|
@ -2219,6 +2348,11 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
electron-store@11.0.2:
|
||||
dependencies:
|
||||
conf: 15.1.0
|
||||
type-fest: 5.5.0
|
||||
|
||||
electron-updater@6.8.3:
|
||||
dependencies:
|
||||
builder-util-runtime: 9.5.1
|
||||
|
|
@ -2237,7 +2371,7 @@ snapshots:
|
|||
'@electron/asar': 3.4.1
|
||||
debug: 4.4.3
|
||||
fs-extra: 7.0.1
|
||||
lodash: 4.17.23
|
||||
lodash: 4.18.1
|
||||
temp: 0.9.4
|
||||
optionalDependencies:
|
||||
'@electron/windows-sign': 1.2.2
|
||||
|
|
@ -2267,6 +2401,8 @@ snapshots:
|
|||
|
||||
env-paths@2.2.1: {}
|
||||
|
||||
env-paths@3.0.0: {}
|
||||
|
||||
err-code@2.0.3: {}
|
||||
|
||||
es-define-property@1.0.1: {}
|
||||
|
|
@ -2340,6 +2476,8 @@ snapshots:
|
|||
|
||||
fast-json-stable-stringify@2.1.0: {}
|
||||
|
||||
fast-uri@3.1.0: {}
|
||||
|
||||
fd-slicer@1.1.0:
|
||||
dependencies:
|
||||
pend: 1.2.0
|
||||
|
|
@ -2595,6 +2733,10 @@ snapshots:
|
|||
|
||||
json-schema-traverse@0.4.1: {}
|
||||
|
||||
json-schema-traverse@1.0.0: {}
|
||||
|
||||
json-schema-typed@8.0.2: {}
|
||||
|
||||
json-stringify-safe@5.0.1:
|
||||
optional: true
|
||||
|
||||
|
|
@ -2622,6 +2764,8 @@ snapshots:
|
|||
|
||||
lodash@4.17.23: {}
|
||||
|
||||
lodash@4.18.1: {}
|
||||
|
||||
log-symbols@4.1.0:
|
||||
dependencies:
|
||||
chalk: 4.1.2
|
||||
|
|
@ -2668,6 +2812,8 @@ snapshots:
|
|||
|
||||
mimic-fn@2.1.0: {}
|
||||
|
||||
mimic-function@5.0.1: {}
|
||||
|
||||
mimic-response@1.0.1: {}
|
||||
|
||||
mimic-response@3.1.0: {}
|
||||
|
|
@ -2863,8 +3009,12 @@ snapshots:
|
|||
string_decoder: 1.3.0
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
readdirp@5.0.0: {}
|
||||
|
||||
require-directory@2.1.1: {}
|
||||
|
||||
require-from-string@2.0.2: {}
|
||||
|
||||
resedit@1.7.2:
|
||||
dependencies:
|
||||
pe-library: 0.4.1
|
||||
|
|
@ -3002,6 +3152,12 @@ snapshots:
|
|||
dependencies:
|
||||
ansi-regex: 6.2.2
|
||||
|
||||
stubborn-fs@2.0.0:
|
||||
dependencies:
|
||||
stubborn-utils: 1.0.2
|
||||
|
||||
stubborn-utils@1.0.2: {}
|
||||
|
||||
sumchecker@3.0.1:
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
|
|
@ -3016,6 +3172,8 @@ snapshots:
|
|||
dependencies:
|
||||
has-flag: 4.0.0
|
||||
|
||||
tagged-tag@1.0.0: {}
|
||||
|
||||
tar@7.5.11:
|
||||
dependencies:
|
||||
'@isaacs/fs-minipass': 4.0.1
|
||||
|
|
@ -3062,8 +3220,14 @@ snapshots:
|
|||
type-fest@0.13.1:
|
||||
optional: true
|
||||
|
||||
type-fest@5.5.0:
|
||||
dependencies:
|
||||
tagged-tag: 1.0.0
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
uint8array-extras@1.5.0: {}
|
||||
|
||||
undici-types@7.16.0: {}
|
||||
|
||||
undici-types@7.18.2: {}
|
||||
|
|
@ -3109,6 +3273,8 @@ snapshots:
|
|||
dependencies:
|
||||
defaults: 1.0.4
|
||||
|
||||
when-exit@2.1.5: {}
|
||||
|
||||
which@2.0.2:
|
||||
dependencies:
|
||||
isexe: 2.0.0
|
||||
|
|
|
|||
|
|
@ -6,4 +6,19 @@ export const IPC_CHANNELS = {
|
|||
SET_QUICK_ASK_MODE: 'set-quick-ask-mode',
|
||||
GET_QUICK_ASK_MODE: 'get-quick-ask-mode',
|
||||
REPLACE_TEXT: 'replace-text',
|
||||
// Folder sync channels
|
||||
FOLDER_SYNC_SELECT_FOLDER: 'folder-sync:select-folder',
|
||||
FOLDER_SYNC_ADD_FOLDER: 'folder-sync:add-folder',
|
||||
FOLDER_SYNC_REMOVE_FOLDER: 'folder-sync:remove-folder',
|
||||
FOLDER_SYNC_GET_FOLDERS: 'folder-sync:get-folders',
|
||||
FOLDER_SYNC_GET_STATUS: 'folder-sync:get-status',
|
||||
FOLDER_SYNC_FILE_CHANGED: 'folder-sync:file-changed',
|
||||
FOLDER_SYNC_WATCHER_READY: 'folder-sync:watcher-ready',
|
||||
FOLDER_SYNC_PAUSE: 'folder-sync:pause',
|
||||
FOLDER_SYNC_RESUME: 'folder-sync:resume',
|
||||
FOLDER_SYNC_RENDERER_READY: 'folder-sync:renderer-ready',
|
||||
FOLDER_SYNC_GET_PENDING_EVENTS: 'folder-sync:get-pending-events',
|
||||
FOLDER_SYNC_ACK_EVENTS: 'folder-sync:ack-events',
|
||||
BROWSE_FILES: 'browse:files',
|
||||
READ_LOCAL_FILES: 'browse:read-local-files',
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,19 @@
|
|||
import { app, ipcMain, shell } from 'electron';
|
||||
import { IPC_CHANNELS } from './channels';
|
||||
import {
|
||||
selectFolder,
|
||||
addWatchedFolder,
|
||||
removeWatchedFolder,
|
||||
getWatchedFolders,
|
||||
getWatcherStatus,
|
||||
getPendingFileEvents,
|
||||
acknowledgeFileEvents,
|
||||
pauseWatcher,
|
||||
resumeWatcher,
|
||||
markRendererReady,
|
||||
browseFiles,
|
||||
readLocalFiles,
|
||||
} from '../modules/folder-watcher';
|
||||
|
||||
export function registerIpcHandlers(): void {
|
||||
ipcMain.on(IPC_CHANNELS.OPEN_EXTERNAL, (_event, url: string) => {
|
||||
|
|
@ -16,4 +30,41 @@ export function registerIpcHandlers(): void {
|
|||
ipcMain.handle(IPC_CHANNELS.GET_APP_VERSION, () => {
|
||||
return app.getVersion();
|
||||
});
|
||||
|
||||
// Folder sync handlers
|
||||
ipcMain.handle(IPC_CHANNELS.FOLDER_SYNC_SELECT_FOLDER, () => selectFolder());
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.FOLDER_SYNC_ADD_FOLDER, (_event, config) =>
|
||||
addWatchedFolder(config)
|
||||
);
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.FOLDER_SYNC_REMOVE_FOLDER, (_event, folderPath: string) =>
|
||||
removeWatchedFolder(folderPath)
|
||||
);
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.FOLDER_SYNC_GET_FOLDERS, () => getWatchedFolders());
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.FOLDER_SYNC_GET_STATUS, () => getWatcherStatus());
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.FOLDER_SYNC_PAUSE, () => pauseWatcher());
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.FOLDER_SYNC_RESUME, () => resumeWatcher());
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.FOLDER_SYNC_RENDERER_READY, () => {
|
||||
markRendererReady();
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.FOLDER_SYNC_GET_PENDING_EVENTS, () =>
|
||||
getPendingFileEvents()
|
||||
);
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.FOLDER_SYNC_ACK_EVENTS, (_event, eventIds: string[]) =>
|
||||
acknowledgeFileEvents(eventIds)
|
||||
);
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.BROWSE_FILES, () => browseFiles());
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.READ_LOCAL_FILES, (_event, paths: string[]) =>
|
||||
readLocalFiles(paths)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { setupDeepLinks, handlePendingDeepLink } from './modules/deep-links';
|
|||
import { setupAutoUpdater } from './modules/auto-updater';
|
||||
import { setupMenu } from './modules/menu';
|
||||
import { registerQuickAsk, unregisterQuickAsk } from './modules/quick-ask';
|
||||
import { registerFolderWatcher, unregisterFolderWatcher } from './modules/folder-watcher';
|
||||
import { registerIpcHandlers } from './ipc/handlers';
|
||||
|
||||
registerGlobalErrorHandlers();
|
||||
|
|
@ -28,6 +29,7 @@ app.whenReady().then(async () => {
|
|||
}
|
||||
createMainWindow();
|
||||
registerQuickAsk();
|
||||
registerFolderWatcher();
|
||||
setupAutoUpdater();
|
||||
|
||||
handlePendingDeepLink();
|
||||
|
|
@ -47,4 +49,5 @@ app.on('window-all-closed', () => {
|
|||
|
||||
app.on('will-quit', () => {
|
||||
unregisterQuickAsk();
|
||||
unregisterFolderWatcher();
|
||||
});
|
||||
|
|
|
|||
534
surfsense_desktop/src/modules/folder-watcher.ts
Normal file
534
surfsense_desktop/src/modules/folder-watcher.ts
Normal file
|
|
@ -0,0 +1,534 @@
|
|||
import { BrowserWindow, dialog } from 'electron';
|
||||
import chokidar, { type FSWatcher } from 'chokidar';
|
||||
import { randomUUID } from 'crypto';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { IPC_CHANNELS } from '../ipc/channels';
|
||||
|
||||
export interface WatchedFolderConfig {
|
||||
path: string;
|
||||
name: string;
|
||||
excludePatterns: string[];
|
||||
fileExtensions: string[] | null;
|
||||
rootFolderId: number | null;
|
||||
searchSpaceId: number;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
interface WatcherEntry {
|
||||
config: WatchedFolderConfig;
|
||||
watcher: FSWatcher | null;
|
||||
}
|
||||
|
||||
type MtimeMap = Record<string, number>;
|
||||
type FolderSyncAction = 'add' | 'change' | 'unlink';
|
||||
|
||||
export interface FolderSyncFileChangedEvent {
|
||||
id: string;
|
||||
rootFolderId: number | null;
|
||||
searchSpaceId: number;
|
||||
folderPath: string;
|
||||
folderName: string;
|
||||
relativePath: string;
|
||||
fullPath: string;
|
||||
action: FolderSyncAction;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
const STORE_KEY = 'watchedFolders';
|
||||
const OUTBOX_STORE_KEY = 'events';
|
||||
const MTIME_TOLERANCE_S = 1.0;
|
||||
|
||||
let store: any = null;
|
||||
let mtimeStore: any = null;
|
||||
let outboxStore: any = null;
|
||||
let watchers: Map<string, WatcherEntry> = new Map();
|
||||
|
||||
/**
|
||||
* In-memory cache of mtime maps, keyed by folder path.
|
||||
* Persisted to electron-store on mutation.
|
||||
*/
|
||||
const mtimeMaps: Map<string, MtimeMap> = new Map();
|
||||
|
||||
let rendererReady = false;
|
||||
const outboxEvents: Map<string, FolderSyncFileChangedEvent> = new Map();
|
||||
let outboxLoaded = false;
|
||||
|
||||
export function markRendererReady() {
|
||||
rendererReady = true;
|
||||
}
|
||||
|
||||
async function getStore() {
|
||||
if (!store) {
|
||||
const { default: Store } = await import('electron-store');
|
||||
store = new Store({
|
||||
name: 'folder-watcher',
|
||||
defaults: {
|
||||
[STORE_KEY]: [] as WatchedFolderConfig[],
|
||||
},
|
||||
});
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
async function getMtimeStore() {
|
||||
if (!mtimeStore) {
|
||||
const { default: Store } = await import('electron-store');
|
||||
mtimeStore = new Store({
|
||||
name: 'folder-mtime-maps',
|
||||
defaults: {} as Record<string, MtimeMap>,
|
||||
});
|
||||
}
|
||||
return mtimeStore;
|
||||
}
|
||||
|
||||
async function getOutboxStore() {
|
||||
if (!outboxStore) {
|
||||
const { default: Store } = await import('electron-store');
|
||||
outboxStore = new Store({
|
||||
name: 'folder-sync-outbox',
|
||||
defaults: {
|
||||
[OUTBOX_STORE_KEY]: [] as FolderSyncFileChangedEvent[],
|
||||
},
|
||||
});
|
||||
}
|
||||
return outboxStore;
|
||||
}
|
||||
|
||||
function makeEventKey(event: Pick<FolderSyncFileChangedEvent, 'folderPath' | 'relativePath'>): string {
|
||||
return `${event.folderPath}:${event.relativePath}`;
|
||||
}
|
||||
|
||||
function persistOutbox() {
|
||||
getOutboxStore().then((s) => {
|
||||
s.set(OUTBOX_STORE_KEY, Array.from(outboxEvents.values()));
|
||||
});
|
||||
}
|
||||
|
||||
async function loadOutbox() {
|
||||
if (outboxLoaded) return;
|
||||
const s = await getOutboxStore();
|
||||
const stored: FolderSyncFileChangedEvent[] = s.get(OUTBOX_STORE_KEY, []);
|
||||
outboxEvents.clear();
|
||||
for (const event of stored) {
|
||||
if (!event?.id || !event.folderPath || !event.relativePath) continue;
|
||||
outboxEvents.set(makeEventKey(event), event);
|
||||
}
|
||||
outboxLoaded = true;
|
||||
}
|
||||
|
||||
function sendFileChangedEvent(
|
||||
data: Omit<FolderSyncFileChangedEvent, 'id'>
|
||||
) {
|
||||
const event: FolderSyncFileChangedEvent = {
|
||||
id: randomUUID(),
|
||||
...data,
|
||||
};
|
||||
|
||||
outboxEvents.set(makeEventKey(event), event);
|
||||
persistOutbox();
|
||||
|
||||
if (rendererReady) {
|
||||
sendToRenderer(IPC_CHANNELS.FOLDER_SYNC_FILE_CHANGED, event);
|
||||
}
|
||||
}
|
||||
|
||||
function loadMtimeMap(folderPath: string): MtimeMap {
|
||||
return mtimeMaps.get(folderPath) ?? {};
|
||||
}
|
||||
|
||||
function persistMtimeMap(folderPath: string) {
|
||||
const map = mtimeMaps.get(folderPath) ?? {};
|
||||
getMtimeStore().then((s) => s.set(folderPath, map));
|
||||
}
|
||||
|
||||
function walkFolderMtimes(config: WatchedFolderConfig): MtimeMap {
|
||||
const root = config.path;
|
||||
const result: MtimeMap = {};
|
||||
const excludes = new Set(config.excludePatterns);
|
||||
|
||||
function walk(dir: string) {
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const name = entry.name;
|
||||
|
||||
if (name.startsWith('.') || excludes.has(name)) continue;
|
||||
|
||||
const full = path.join(dir, name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
walk(full);
|
||||
} else if (entry.isFile()) {
|
||||
if (
|
||||
config.fileExtensions &&
|
||||
config.fileExtensions.length > 0
|
||||
) {
|
||||
const ext = path.extname(name).toLowerCase();
|
||||
if (!config.fileExtensions.includes(ext)) continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = fs.statSync(full);
|
||||
const rel = path.relative(root, full);
|
||||
result[rel] = stat.mtimeMs;
|
||||
} catch {
|
||||
// File may have been removed between readdir and stat
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(root);
|
||||
return result;
|
||||
}
|
||||
|
||||
function getMainWindow(): BrowserWindow | null {
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
return windows.length > 0 ? windows[0] : null;
|
||||
}
|
||||
|
||||
function sendToRenderer(channel: string, data: any) {
|
||||
const win = getMainWindow();
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.webContents.send(channel, data);
|
||||
}
|
||||
}
|
||||
|
||||
async function startWatcher(config: WatchedFolderConfig) {
|
||||
if (watchers.has(config.path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ms = await getMtimeStore();
|
||||
const storedMap: MtimeMap = ms.get(config.path) ?? {};
|
||||
mtimeMaps.set(config.path, { ...storedMap });
|
||||
|
||||
const ignored = [
|
||||
/(^|[/\\])\../, // dotfiles by default
|
||||
...config.excludePatterns.map((p) => `**/${p}/**`),
|
||||
];
|
||||
|
||||
const watcher = chokidar.watch(config.path, {
|
||||
persistent: true,
|
||||
ignoreInitial: true,
|
||||
awaitWriteFinish: {
|
||||
stabilityThreshold: 500,
|
||||
pollInterval: 100,
|
||||
},
|
||||
ignored,
|
||||
});
|
||||
|
||||
let ready = false;
|
||||
|
||||
watcher.on('ready', () => {
|
||||
ready = true;
|
||||
|
||||
const currentMap = walkFolderMtimes(config);
|
||||
const storedSnapshot = loadMtimeMap(config.path);
|
||||
const now = Date.now();
|
||||
|
||||
// Track which files are unchanged so we can selectively update the mtime map
|
||||
const unchangedMap: MtimeMap = {};
|
||||
|
||||
for (const [rel, currentMtime] of Object.entries(currentMap)) {
|
||||
const storedMtime = storedSnapshot[rel];
|
||||
if (storedMtime === undefined) {
|
||||
sendFileChangedEvent({
|
||||
rootFolderId: config.rootFolderId,
|
||||
searchSpaceId: config.searchSpaceId,
|
||||
folderPath: config.path,
|
||||
folderName: config.name,
|
||||
relativePath: rel,
|
||||
fullPath: path.join(config.path, rel),
|
||||
action: 'add',
|
||||
timestamp: now,
|
||||
});
|
||||
} else if (Math.abs(currentMtime - storedMtime) >= MTIME_TOLERANCE_S * 1000) {
|
||||
sendFileChangedEvent({
|
||||
rootFolderId: config.rootFolderId,
|
||||
searchSpaceId: config.searchSpaceId,
|
||||
folderPath: config.path,
|
||||
folderName: config.name,
|
||||
relativePath: rel,
|
||||
fullPath: path.join(config.path, rel),
|
||||
action: 'change',
|
||||
timestamp: now,
|
||||
});
|
||||
} else {
|
||||
unchangedMap[rel] = currentMtime;
|
||||
}
|
||||
}
|
||||
|
||||
for (const rel of Object.keys(storedSnapshot)) {
|
||||
if (!(rel in currentMap)) {
|
||||
sendFileChangedEvent({
|
||||
rootFolderId: config.rootFolderId,
|
||||
searchSpaceId: config.searchSpaceId,
|
||||
folderPath: config.path,
|
||||
folderName: config.name,
|
||||
relativePath: rel,
|
||||
fullPath: path.join(config.path, rel),
|
||||
action: 'unlink',
|
||||
timestamp: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Only update the mtime map for unchanged files; changed files keep their
|
||||
// stored mtime so they'll be re-detected if the app crashes before indexing.
|
||||
mtimeMaps.set(config.path, unchangedMap);
|
||||
persistMtimeMap(config.path);
|
||||
|
||||
sendToRenderer(IPC_CHANNELS.FOLDER_SYNC_WATCHER_READY, {
|
||||
rootFolderId: config.rootFolderId,
|
||||
folderPath: config.path,
|
||||
});
|
||||
});
|
||||
|
||||
const handleFileEvent = (filePath: string, action: FolderSyncAction) => {
|
||||
if (!ready) return;
|
||||
|
||||
const relativePath = path.relative(config.path, filePath);
|
||||
|
||||
if (
|
||||
config.fileExtensions &&
|
||||
config.fileExtensions.length > 0
|
||||
) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
if (!config.fileExtensions.includes(ext)) return;
|
||||
}
|
||||
|
||||
const map = mtimeMaps.get(config.path);
|
||||
if (map) {
|
||||
if (action === 'unlink') {
|
||||
delete map[relativePath];
|
||||
} else {
|
||||
try {
|
||||
map[relativePath] = fs.statSync(filePath).mtimeMs;
|
||||
} catch {
|
||||
// File may have been removed between event and stat
|
||||
}
|
||||
}
|
||||
persistMtimeMap(config.path);
|
||||
}
|
||||
|
||||
sendFileChangedEvent({
|
||||
rootFolderId: config.rootFolderId,
|
||||
searchSpaceId: config.searchSpaceId,
|
||||
folderPath: config.path,
|
||||
folderName: config.name,
|
||||
relativePath,
|
||||
fullPath: filePath,
|
||||
action,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
};
|
||||
|
||||
watcher.on('add', (fp) => handleFileEvent(fp, 'add'));
|
||||
watcher.on('change', (fp) => handleFileEvent(fp, 'change'));
|
||||
watcher.on('unlink', (fp) => handleFileEvent(fp, 'unlink'));
|
||||
|
||||
watchers.set(config.path, { config, watcher });
|
||||
}
|
||||
|
||||
function stopWatcher(folderPath: string) {
|
||||
persistMtimeMap(folderPath);
|
||||
const entry = watchers.get(folderPath);
|
||||
if (entry?.watcher) {
|
||||
entry.watcher.close();
|
||||
}
|
||||
watchers.delete(folderPath);
|
||||
}
|
||||
|
||||
export async function selectFolder(): Promise<string | null> {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ['openDirectory'],
|
||||
title: 'Select a folder to watch',
|
||||
});
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return result.filePaths[0];
|
||||
}
|
||||
|
||||
export async function addWatchedFolder(
|
||||
config: WatchedFolderConfig
|
||||
): Promise<WatchedFolderConfig[]> {
|
||||
const s = await getStore();
|
||||
const folders: WatchedFolderConfig[] = s.get(STORE_KEY, []);
|
||||
|
||||
const existing = folders.findIndex((f: WatchedFolderConfig) => f.path === config.path);
|
||||
if (existing >= 0) {
|
||||
folders[existing] = config;
|
||||
} else {
|
||||
folders.push(config);
|
||||
}
|
||||
|
||||
s.set(STORE_KEY, folders);
|
||||
|
||||
if (config.active) {
|
||||
await startWatcher(config);
|
||||
}
|
||||
|
||||
return folders;
|
||||
}
|
||||
|
||||
export async function removeWatchedFolder(
|
||||
folderPath: string
|
||||
): Promise<WatchedFolderConfig[]> {
|
||||
const s = await getStore();
|
||||
const folders: WatchedFolderConfig[] = s.get(STORE_KEY, []);
|
||||
const updated = folders.filter((f: WatchedFolderConfig) => f.path !== folderPath);
|
||||
s.set(STORE_KEY, updated);
|
||||
|
||||
stopWatcher(folderPath);
|
||||
|
||||
mtimeMaps.delete(folderPath);
|
||||
const ms = await getMtimeStore();
|
||||
ms.delete(folderPath);
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function getWatchedFolders(): Promise<WatchedFolderConfig[]> {
|
||||
const s = await getStore();
|
||||
return s.get(STORE_KEY, []);
|
||||
}
|
||||
|
||||
export async function getWatcherStatus(): Promise<
|
||||
{ path: string; active: boolean; watching: boolean }[]
|
||||
> {
|
||||
const s = await getStore();
|
||||
const folders: WatchedFolderConfig[] = s.get(STORE_KEY, []);
|
||||
return folders.map((f: WatchedFolderConfig) => ({
|
||||
path: f.path,
|
||||
active: f.active,
|
||||
watching: watchers.has(f.path),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getPendingFileEvents(): Promise<FolderSyncFileChangedEvent[]> {
|
||||
await loadOutbox();
|
||||
return Array.from(outboxEvents.values()).sort((a, b) => a.timestamp - b.timestamp);
|
||||
}
|
||||
|
||||
export async function acknowledgeFileEvents(eventIds: string[]): Promise<{ acknowledged: number }> {
|
||||
if (!eventIds || eventIds.length === 0) return { acknowledged: 0 };
|
||||
await loadOutbox();
|
||||
|
||||
const ackSet = new Set(eventIds);
|
||||
let acknowledged = 0;
|
||||
|
||||
for (const [key, event] of outboxEvents.entries()) {
|
||||
if (ackSet.has(event.id)) {
|
||||
outboxEvents.delete(key);
|
||||
acknowledged += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (acknowledged > 0) {
|
||||
persistOutbox();
|
||||
}
|
||||
|
||||
return { acknowledged };
|
||||
}
|
||||
|
||||
export async function pauseWatcher(): Promise<void> {
|
||||
for (const [, entry] of watchers) {
|
||||
if (entry.watcher) {
|
||||
await entry.watcher.close();
|
||||
entry.watcher = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function resumeWatcher(): Promise<void> {
|
||||
for (const [, entry] of watchers) {
|
||||
if (!entry.watcher && entry.config.active) {
|
||||
await startWatcher(entry.config);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function registerFolderWatcher(): Promise<void> {
|
||||
await loadOutbox();
|
||||
const s = await getStore();
|
||||
const folders: WatchedFolderConfig[] = s.get(STORE_KEY, []);
|
||||
|
||||
for (const config of folders) {
|
||||
if (config.active && fs.existsSync(config.path)) {
|
||||
await startWatcher(config);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function unregisterFolderWatcher(): Promise<void> {
|
||||
for (const [folderPath] of watchers) {
|
||||
stopWatcher(folderPath);
|
||||
}
|
||||
watchers.clear();
|
||||
}
|
||||
|
||||
export async function browseFiles(): Promise<string[] | null> {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ['openFile', 'multiSelections'],
|
||||
title: 'Select files',
|
||||
});
|
||||
if (result.canceled || result.filePaths.length === 0) return null;
|
||||
return result.filePaths;
|
||||
}
|
||||
|
||||
const MIME_MAP: Record<string, string> = {
|
||||
'.pdf': 'application/pdf',
|
||||
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'.html': 'text/html', '.htm': 'text/html',
|
||||
'.csv': 'text/csv',
|
||||
'.txt': 'text/plain',
|
||||
'.md': 'text/markdown', '.markdown': 'text/markdown',
|
||||
'.mp3': 'audio/mpeg', '.mpeg': 'audio/mpeg', '.mpga': 'audio/mpeg',
|
||||
'.mp4': 'audio/mp4', '.m4a': 'audio/mp4',
|
||||
'.wav': 'audio/wav',
|
||||
'.webm': 'audio/webm',
|
||||
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.bmp': 'image/bmp',
|
||||
'.webp': 'image/webp',
|
||||
'.tiff': 'image/tiff',
|
||||
'.doc': 'application/msword',
|
||||
'.rtf': 'application/rtf',
|
||||
'.xml': 'application/xml',
|
||||
'.epub': 'application/epub+zip',
|
||||
'.xls': 'application/vnd.ms-excel',
|
||||
'.ppt': 'application/vnd.ms-powerpoint',
|
||||
'.eml': 'message/rfc822',
|
||||
'.odt': 'application/vnd.oasis.opendocument.text',
|
||||
'.msg': 'application/vnd.ms-outlook',
|
||||
};
|
||||
|
||||
export interface LocalFileData {
|
||||
name: string;
|
||||
data: ArrayBuffer;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export function readLocalFiles(filePaths: string[]): LocalFileData[] {
|
||||
return filePaths.map((p) => {
|
||||
const buf = fs.readFileSync(p);
|
||||
const ext = path.extname(p).toLowerCase();
|
||||
return {
|
||||
name: path.basename(p),
|
||||
data: buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength),
|
||||
mimeType: MIME_MAP[ext] || 'application/octet-stream',
|
||||
size: buf.byteLength,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -21,4 +21,34 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||
setQuickAskMode: (mode: string) => ipcRenderer.invoke(IPC_CHANNELS.SET_QUICK_ASK_MODE, mode),
|
||||
getQuickAskMode: () => ipcRenderer.invoke(IPC_CHANNELS.GET_QUICK_ASK_MODE),
|
||||
replaceText: (text: string) => ipcRenderer.invoke(IPC_CHANNELS.REPLACE_TEXT, text),
|
||||
|
||||
// Folder sync
|
||||
selectFolder: () => ipcRenderer.invoke(IPC_CHANNELS.FOLDER_SYNC_SELECT_FOLDER),
|
||||
addWatchedFolder: (config: any) => ipcRenderer.invoke(IPC_CHANNELS.FOLDER_SYNC_ADD_FOLDER, config),
|
||||
removeWatchedFolder: (folderPath: string) => ipcRenderer.invoke(IPC_CHANNELS.FOLDER_SYNC_REMOVE_FOLDER, folderPath),
|
||||
getWatchedFolders: () => ipcRenderer.invoke(IPC_CHANNELS.FOLDER_SYNC_GET_FOLDERS),
|
||||
getWatcherStatus: () => ipcRenderer.invoke(IPC_CHANNELS.FOLDER_SYNC_GET_STATUS),
|
||||
onFileChanged: (callback: (data: any) => void) => {
|
||||
const listener = (_event: unknown, data: any) => callback(data);
|
||||
ipcRenderer.on(IPC_CHANNELS.FOLDER_SYNC_FILE_CHANGED, listener);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.FOLDER_SYNC_FILE_CHANGED, listener);
|
||||
};
|
||||
},
|
||||
onWatcherReady: (callback: (data: any) => void) => {
|
||||
const listener = (_event: unknown, data: any) => callback(data);
|
||||
ipcRenderer.on(IPC_CHANNELS.FOLDER_SYNC_WATCHER_READY, listener);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.FOLDER_SYNC_WATCHER_READY, listener);
|
||||
};
|
||||
},
|
||||
pauseWatcher: () => ipcRenderer.invoke(IPC_CHANNELS.FOLDER_SYNC_PAUSE),
|
||||
resumeWatcher: () => ipcRenderer.invoke(IPC_CHANNELS.FOLDER_SYNC_RESUME),
|
||||
signalRendererReady: () => ipcRenderer.invoke(IPC_CHANNELS.FOLDER_SYNC_RENDERER_READY),
|
||||
getPendingFileEvents: () => ipcRenderer.invoke(IPC_CHANNELS.FOLDER_SYNC_GET_PENDING_EVENTS),
|
||||
acknowledgeFileEvents: (eventIds: string[]) => ipcRenderer.invoke(IPC_CHANNELS.FOLDER_SYNC_ACK_EVENTS, eventIds),
|
||||
|
||||
// Browse files via native dialog
|
||||
browseFiles: () => ipcRenderer.invoke(IPC_CHANNELS.BROWSE_FILES),
|
||||
readLocalFiles: (paths: string[]) => ipcRenderer.invoke(IPC_CHANNELS.READ_LOCAL_FILES, paths),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -160,10 +160,10 @@ export function LocalLoginForm() {
|
|||
placeholder="you@example.com"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className={`mt-1 block w-full rounded-md border px-3 py-1.5 md:py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 bg-background text-foreground transition-all ${
|
||||
className={`mt-1 block w-full rounded-md border px-3 py-1.5 md:py-2 shadow-sm focus:outline-none focus:ring-1 bg-background text-foreground transition-all ${
|
||||
error.title
|
||||
? "border-destructive focus:border-destructive focus:ring-destructive"
|
||||
: "border-border focus:border-primary focus:ring-primary"
|
||||
? "border-destructive focus:border-destructive focus:ring-destructive/40"
|
||||
: "border-border focus:border-primary focus:ring-primary/40"
|
||||
}`}
|
||||
disabled={isLoggingIn}
|
||||
/>
|
||||
|
|
@ -181,10 +181,10 @@ export function LocalLoginForm() {
|
|||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className={`mt-1 block w-full rounded-md border pr-10 px-3 py-1.5 md:py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 bg-background text-foreground transition-all ${
|
||||
className={`mt-1 block w-full rounded-md border pr-10 px-3 py-1.5 md:py-2 shadow-sm focus:outline-none focus:ring-1 bg-background text-foreground transition-all ${
|
||||
error.title
|
||||
? "border-destructive focus:border-destructive focus:ring-destructive"
|
||||
: "border-border focus:border-primary focus:ring-primary"
|
||||
? "border-destructive focus:border-destructive focus:ring-destructive/40"
|
||||
: "border-border focus:border-primary focus:ring-primary/40"
|
||||
}`}
|
||||
disabled={isLoggingIn}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ function LoginContent() {
|
|||
<div className="relative w-full overflow-hidden">
|
||||
<AmbientBackground />
|
||||
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
|
||||
<Logo className="h-16 w-16 md:h-32 md:w-32 rounded-md transition-all" />
|
||||
<Logo priority className="h-16 w-16 md:h-32 md:w-32 rounded-md transition-all" />
|
||||
<h1 className="mt-4 mb-6 text-xl font-bold text-neutral-800 dark:text-neutral-100 md:mt-8 md:mb-8 md:text-3xl lg:text-4xl transition-all">
|
||||
{t("sign_in")}
|
||||
</h1>
|
||||
|
|
|
|||
|
|
@ -160,7 +160,7 @@ export default function RegisterPage() {
|
|||
<div className="relative w-full overflow-hidden">
|
||||
<AmbientBackground />
|
||||
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center px-6 md:px-0">
|
||||
<Logo className="h-16 w-16 md:h-32 md:w-32 rounded-md transition-all" />
|
||||
<Logo priority className="h-16 w-16 md:h-32 md:w-32 rounded-md transition-all" />
|
||||
<h1 className="mt-4 mb-6 text-xl font-bold text-neutral-800 dark:text-neutral-100 md:mt-8 md:mb-8 md:text-3xl lg:text-4xl transition-all">
|
||||
{t("create_account")}
|
||||
</h1>
|
||||
|
|
@ -229,10 +229,7 @@ export default function RegisterPage() {
|
|||
</AnimatePresence>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-foreground">
|
||||
{t("email")}
|
||||
</label>
|
||||
<input
|
||||
|
|
@ -242,20 +239,17 @@ export default function RegisterPage() {
|
|||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className={`mt-1 block w-full rounded-md border px-3 py-1.5 md:py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-all ${
|
||||
className={`mt-1 block w-full rounded-md border px-3 py-1.5 md:py-2 shadow-sm focus:outline-none focus:ring-1 bg-background text-foreground transition-all ${
|
||||
error.title
|
||||
? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700"
|
||||
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700"
|
||||
? "border-destructive focus:border-destructive focus:ring-destructive/40"
|
||||
: "border-border focus:border-primary focus:ring-primary/40"
|
||||
}`}
|
||||
disabled={isRegistering}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-foreground">
|
||||
{t("password")}
|
||||
</label>
|
||||
<input
|
||||
|
|
@ -265,10 +259,10 @@ export default function RegisterPage() {
|
|||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className={`mt-1 block w-full rounded-md border px-3 py-1.5 md:py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-all ${
|
||||
className={`mt-1 block w-full rounded-md border px-3 py-1.5 md:py-2 shadow-sm focus:outline-none focus:ring-1 bg-background text-foreground transition-all ${
|
||||
error.title
|
||||
? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700"
|
||||
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700"
|
||||
? "border-destructive focus:border-destructive focus:ring-destructive/40"
|
||||
: "border-border focus:border-primary focus:ring-primary/40"
|
||||
}`}
|
||||
disabled={isRegistering}
|
||||
/>
|
||||
|
|
@ -277,7 +271,7 @@ export default function RegisterPage() {
|
|||
<div>
|
||||
<label
|
||||
htmlFor="confirmPassword"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
className="block text-sm font-medium text-foreground"
|
||||
>
|
||||
{t("confirm_password")}
|
||||
</label>
|
||||
|
|
@ -288,10 +282,10 @@ export default function RegisterPage() {
|
|||
placeholder="Confirm your password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className={`mt-1 block w-full rounded-md border px-3 py-1.5 md:py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-all ${
|
||||
className={`mt-1 block w-full rounded-md border px-3 py-1.5 md:py-2 shadow-sm focus:outline-none focus:ring-1 bg-background text-foreground transition-all ${
|
||||
error.title
|
||||
? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700"
|
||||
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700"
|
||||
? "border-destructive focus:border-destructive focus:ring-destructive/40"
|
||||
: "border-border focus:border-primary focus:ring-primary/40"
|
||||
}`}
|
||||
disabled={isRegistering}
|
||||
/>
|
||||
|
|
@ -300,7 +294,7 @@ export default function RegisterPage() {
|
|||
<button
|
||||
type="submit"
|
||||
disabled={isRegistering}
|
||||
className="relative w-full rounded-md bg-blue-600 px-4 py-1.5 md:py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-all text-sm md:text-base flex items-center justify-center gap-2"
|
||||
className="relative w-full rounded-md bg-primary px-4 py-1.5 md:py-2 text-primary-foreground shadow-sm hover:bg-primary/90 focus:outline-none focus:ring-1 focus:ring-primary/40 disabled:cursor-not-allowed disabled:opacity-50 transition-all text-sm md:text-base flex items-center justify-center gap-2"
|
||||
>
|
||||
<span className={isRegistering ? "invisible" : ""}>{t("register")}</span>
|
||||
{isRegistering && (
|
||||
|
|
@ -312,12 +306,9 @@ export default function RegisterPage() {
|
|||
</form>
|
||||
|
||||
<div className="mt-4 text-center text-sm">
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
<p className="text-muted-foreground">
|
||||
{t("already_have_account")}{" "}
|
||||
<Link
|
||||
href="/login"
|
||||
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
|
||||
>
|
||||
<Link href="/login" className="font-medium text-primary hover:text-primary/90">
|
||||
{t("sign_in")}
|
||||
</Link>
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { DocumentUploadDialogProvider } from "@/components/assistant-ui/document
|
|||
import { LayoutDataProvider } from "@/components/layout";
|
||||
import { OnboardingTour } from "@/components/onboarding-tour";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useFolderSync } from "@/hooks/use-folder-sync";
|
||||
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
|
||||
|
||||
export function DashboardClientLayout({
|
||||
|
|
@ -159,6 +160,9 @@ export function DashboardClientLayout({
|
|||
// Use global loading screen - spinner animation won't reset
|
||||
useGlobalLoadingEffect(shouldShowLoading);
|
||||
|
||||
// Wire desktop app file watcher -> single-file re-index API
|
||||
useFolderSync();
|
||||
|
||||
if (shouldShowLoading) {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ export function getDocumentTypeLabel(type: string): string {
|
|||
BOOKSTACK_CONNECTOR: "BookStack",
|
||||
CIRCLEBACK: "Circleback",
|
||||
OBSIDIAN_CONNECTOR: "Obsidian",
|
||||
LOCAL_FOLDER_FILE: "Local Folder",
|
||||
SURFSENSE_DOCS: "SurfSense Docs",
|
||||
NOTE: "Note",
|
||||
COMPOSIO_GOOGLE_DRIVE_CONNECTOR: "Composio Google Drive",
|
||||
|
|
|
|||
|
|
@ -267,12 +267,23 @@ export function DocumentsTableShell({
|
|||
const [metadataJson, setMetadataJson] = useState<Record<string, unknown> | null>(null);
|
||||
const [metadataLoading, setMetadataLoading] = useState(false);
|
||||
const [previewScrollPos, setPreviewScrollPos] = useState<"top" | "middle" | "bottom">("top");
|
||||
const previewRafRef = useRef<number>();
|
||||
const handlePreviewScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||
const el = e.currentTarget;
|
||||
const atTop = el.scrollTop <= 2;
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
|
||||
setPreviewScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
|
||||
if (previewRafRef.current) return;
|
||||
previewRafRef.current = requestAnimationFrame(() => {
|
||||
const atTop = el.scrollTop <= 2;
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
|
||||
setPreviewScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
|
||||
previewRafRef.current = undefined;
|
||||
});
|
||||
}, []);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (previewRafRef.current) cancelAnimationFrame(previewRafRef.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const [deleteDoc, setDeleteDoc] = useState<Document | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
|
@ -749,6 +760,7 @@ export function DocumentsTableShell({
|
|||
onClick={() =>
|
||||
onOpenInTab ? onOpenInTab(doc) : handleViewDocument(doc)
|
||||
}
|
||||
disabled={isBeingProcessed}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
Open
|
||||
|
|
@ -1044,6 +1056,10 @@ export function DocumentsTableShell({
|
|||
<Button
|
||||
variant="secondary"
|
||||
className="justify-start gap-2"
|
||||
disabled={
|
||||
mobileActionDoc?.status?.state === "pending" ||
|
||||
mobileActionDoc?.status?.state === "processing"
|
||||
}
|
||||
onClick={() => {
|
||||
if (mobileActionDoc) handleViewDocument(mobileActionDoc);
|
||||
setMobileActionDoc(null);
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ export function CommunityPromptsContent() {
|
|||
|
||||
{list.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed border-border/60 p-8 text-center">
|
||||
<Globe className="mx-auto size-8 text-muted-foreground/40" />
|
||||
<Globe className="mx-auto size-8 text-muted-foreground" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">No community prompts yet</p>
|
||||
<p className="text-xs text-muted-foreground/60">
|
||||
Share your own prompts from the My Prompts tab
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { AlertTriangle, Globe, Lock, PenLine, Plus, Sparkles, Trash2 } from "lucide-react";
|
||||
import { AlertTriangle, Globe, Lock, PenLine, Sparkles, Trash2 } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
|
|
@ -23,6 +23,7 @@ import {
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ShortcutKbd } from "@/components/ui/shortcut-kbd";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import type { PromptRead } from "@/contracts/types/prompts.types";
|
||||
|
|
@ -144,9 +145,8 @@ export function PromptsContent() {
|
|||
<div className="space-y-6 min-w-0 overflow-hidden">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create prompt templates triggered with{" "}
|
||||
<kbd className="rounded border bg-muted px-1.5 py-0.5 text-xs font-mono">/</kbd> in the
|
||||
chat composer.
|
||||
Create prompt templates triggered with <ShortcutKbd keys={["/"]} className="ml-0" /> in
|
||||
the chat composer.
|
||||
</p>
|
||||
{!showForm && (
|
||||
<Button
|
||||
|
|
@ -158,7 +158,6 @@ export function PromptsContent() {
|
|||
}}
|
||||
className="shrink-0 gap-1.5"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
New
|
||||
</Button>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ export const createNewLLMConfigMutationAtom = atomWithMutation((get) => {
|
|||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to create LLM model");
|
||||
toast.error(error.message || "Failed to create model");
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
@ -109,10 +109,11 @@ export const updateLLMPreferencesMutationAtom = atomWithMutation((get) => {
|
|||
mutationFn: async (request: UpdateLLMPreferencesRequest) => {
|
||||
return newLLMConfigApiService.updateLLMPreferences(request);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.newLLMConfigs.preferences(Number(searchSpaceId)),
|
||||
});
|
||||
onSuccess: (_data, request: UpdateLLMPreferencesRequest) => {
|
||||
queryClient.setQueryData(
|
||||
cacheKeys.newLLMConfigs.preferences(Number(searchSpaceId)),
|
||||
(old: Record<string, unknown> | undefined) => ({ ...old, ...request.data })
|
||||
);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to update LLM preferences");
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ export const defaultSystemInstructionsAtom = atomWithQuery(() => {
|
|||
});
|
||||
|
||||
/**
|
||||
* Query atom for the dynamic LLM model catalogue.
|
||||
* Query atom for the dynamic model catalogue.
|
||||
* Fetched from the backend (which proxies OpenRouter's public API).
|
||||
* Falls back to the static hardcoded list on error.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -5,9 +5,11 @@ import { cn } from "@/lib/utils";
|
|||
export const Logo = ({
|
||||
className,
|
||||
disableLink = false,
|
||||
priority = false,
|
||||
}: {
|
||||
className?: string;
|
||||
disableLink?: boolean;
|
||||
priority?: boolean;
|
||||
}) => {
|
||||
const image = (
|
||||
<Image
|
||||
|
|
@ -16,6 +18,7 @@ export const Logo = ({
|
|||
alt="logo"
|
||||
width={128}
|
||||
height={128}
|
||||
priority={priority}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -374,14 +374,17 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
|
|||
<div className="px-4 sm:px-12 py-4 sm:py-8 pb-12 sm:pb-16">
|
||||
{/* LLM Configuration Warning */}
|
||||
{!llmConfigLoading && !hasDocumentSummaryLLM && (
|
||||
<Alert variant="destructive" className="mb-6">
|
||||
<Alert
|
||||
variant="destructive"
|
||||
className="mb-6 bg-muted/50 rounded-xl border-destructive/30"
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>LLM Configuration Required</AlertTitle>
|
||||
<AlertDescription className="mt-2">
|
||||
<p className="mb-3">
|
||||
{isAutoMode && !hasGlobalConfigs
|
||||
? "Auto mode is selected but no global LLM configurations are available. Please configure a custom LLM in Settings to process and summarize documents from your connected sources."
|
||||
: "You need to configure a Document Summary LLM before adding connectors. This LLM is used to process and summarize documents from your connected sources."}
|
||||
? "Auto mode requires a global LLM configuration. Please add one in Settings"
|
||||
: "A Document Summary LLM is required to process uploads, configure one in Settings"}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
|
|||
|
|
@ -58,7 +58,6 @@ export function getConnectFormComponent(connectorType: string): ConnectFormCompo
|
|||
return MCPConnectForm;
|
||||
case "OBSIDIAN_CONNECTOR":
|
||||
return ObsidianConnectForm;
|
||||
// Add other connector types here as needed
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,9 +34,12 @@ export const CirclebackConfig: FC<CirclebackConfigProps> = ({ connector, onNameC
|
|||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Fetch webhook info
|
||||
// Fetch webhook info
|
||||
useEffect(() => {
|
||||
const fetchWebhookInfo = async () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
const doFetch = async () => {
|
||||
if (!connector.search_space_id) return;
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL;
|
||||
|
|
@ -49,8 +52,11 @@ export const CirclebackConfig: FC<CirclebackConfigProps> = ({ connector, onNameC
|
|||
setIsLoading(true);
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
`${baseUrl}/api/v1/webhooks/circleback/${connector.search_space_id}/info`
|
||||
`${baseUrl}/api/v1/webhooks/circleback/${connector.search_space_id}/info`,
|
||||
{ signal: controller.signal }
|
||||
);
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
if (response.ok) {
|
||||
const data: unknown = await response.json();
|
||||
// Runtime validation with zod schema
|
||||
|
|
@ -59,16 +65,18 @@ export const CirclebackConfig: FC<CirclebackConfigProps> = ({ connector, onNameC
|
|||
setWebhookUrl(validatedData.webhook_url);
|
||||
}
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return;
|
||||
console.error("Failed to fetch webhook info:", error);
|
||||
// Reset state on error
|
||||
setWebhookInfo(null);
|
||||
setWebhookUrl("");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
if (!controller.signal.aborted) setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchWebhookInfo();
|
||||
doFetch().catch(() => {});
|
||||
return () => controller.abort();
|
||||
}, [connector.search_space_id]);
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
|
|
|
|||
|
|
@ -272,7 +272,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
{/* AI Summary toggle */}
|
||||
<SummaryConfig enabled={enableSummary} onEnabledChange={onEnableSummaryChange} />
|
||||
|
||||
{/* Date range selector - not shown for file-based connectors (Drive, Dropbox, OneDrive), Webcrawler, or GitHub (indexes full repo snapshots) */}
|
||||
{/* Date range selector - not shown for file-based connectors (Drive, Dropbox, OneDrive), Webcrawler, GitHub, or Local Folder */}
|
||||
{connector.connector_type !== "GOOGLE_DRIVE_CONNECTOR" &&
|
||||
connector.connector_type !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" &&
|
||||
connector.connector_type !== "DROPBOX_CONNECTOR" &&
|
||||
|
|
@ -293,9 +293,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* Periodic sync - shown for all indexable connectors */}
|
||||
{(() => {
|
||||
// Check if Google Drive (regular or Composio) has folders/files selected
|
||||
const isGoogleDrive = connector.connector_type === "GOOGLE_DRIVE_CONNECTOR";
|
||||
const isComposioGoogleDrive =
|
||||
connector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR";
|
||||
|
|
|
|||
|
|
@ -158,7 +158,7 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
|
|||
{/* AI Summary toggle */}
|
||||
<SummaryConfig enabled={enableSummary} onEnabledChange={onEnableSummaryChange} />
|
||||
|
||||
{/* Date range selector - not shown for file-based connectors (Drive, Dropbox, OneDrive), Webcrawler, or GitHub (indexes full repo snapshots) */}
|
||||
{/* Date range selector - not shown for file-based connectors (Drive, Dropbox, OneDrive), Webcrawler, GitHub, or Local Folder */}
|
||||
{config.connectorType !== "GOOGLE_DRIVE_CONNECTOR" &&
|
||||
config.connectorType !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" &&
|
||||
config.connectorType !== "DROPBOX_CONNECTOR" &&
|
||||
|
|
@ -179,9 +179,10 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* Periodic sync - not shown for Google Drive (regular and Composio) */}
|
||||
{config.connectorType !== "GOOGLE_DRIVE_CONNECTOR" &&
|
||||
config.connectorType !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" && (
|
||||
config.connectorType !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" &&
|
||||
config.connectorType !== "DROPBOX_CONNECTOR" &&
|
||||
config.connectorType !== "ONEDRIVE_CONNECTOR" && (
|
||||
<PeriodicSyncConfig
|
||||
enabled={periodicEnabled}
|
||||
frequencyMinutes={frequencyMinutes}
|
||||
|
|
|
|||
|
|
@ -76,29 +76,26 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
|||
}) => {
|
||||
// Check if self-hosted mode (for showing self-hosted only connectors)
|
||||
const selfHosted = isSelfHosted();
|
||||
const isDesktop = typeof window !== "undefined" && !!window.electronAPI;
|
||||
|
||||
const matchesSearch = (title: string, description: string) =>
|
||||
title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
description.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
|
||||
const passesDeploymentFilter = (c: { selfHostedOnly?: boolean; desktopOnly?: boolean }) =>
|
||||
(!c.selfHostedOnly || selfHosted) && (!c.desktopOnly || isDesktop);
|
||||
|
||||
// Filter connectors based on search and deployment mode
|
||||
const filteredOAuth = OAUTH_CONNECTORS.filter(
|
||||
(c) =>
|
||||
// Filter by search query
|
||||
(c.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
c.description.toLowerCase().includes(searchQuery.toLowerCase())) &&
|
||||
// Filter self-hosted only connectors in cloud mode
|
||||
(!("selfHostedOnly" in c) || !c.selfHostedOnly || selfHosted)
|
||||
(c) => matchesSearch(c.title, c.description) && passesDeploymentFilter(c)
|
||||
);
|
||||
|
||||
const filteredCrawlers = CRAWLERS.filter(
|
||||
(c) =>
|
||||
(c.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
c.description.toLowerCase().includes(searchQuery.toLowerCase())) &&
|
||||
(!("selfHostedOnly" in c) || !c.selfHostedOnly || selfHosted)
|
||||
(c) => matchesSearch(c.title, c.description) && passesDeploymentFilter(c)
|
||||
);
|
||||
|
||||
const filteredOther = OTHER_CONNECTORS.filter(
|
||||
(c) =>
|
||||
(c.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
c.description.toLowerCase().includes(searchQuery.toLowerCase())) &&
|
||||
(!("selfHostedOnly" in c) || !c.selfHostedOnly || selfHosted)
|
||||
(c) => matchesSearch(c.title, c.description) && passesDeploymentFilter(c)
|
||||
);
|
||||
|
||||
// Filter Composio connectors
|
||||
|
|
|
|||
|
|
@ -125,38 +125,35 @@ const DocumentUploadPopupContent: FC<{
|
|||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
className="select-none max-w-4xl w-[95vw] sm:w-full h-[calc(100dvh-2rem)] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border ring-0 bg-muted dark:bg-muted text-foreground [&>button]:right-3 sm:[&>button]:right-12 [&>button]:top-3 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button]:z-[100] [&>button_svg]:size-4 sm:[&>button_svg]:size-5"
|
||||
className="select-none max-w-2xl w-[95vw] sm:w-[640px] h-[min(440px,75dvh)] sm:h-[min(500px,80vh)] flex flex-col p-0 gap-0 overflow-hidden border border-border ring-0 bg-muted dark:bg-muted text-foreground [&>button]:right-3 sm:[&>button]:right-6 [&>button]:top-3 sm:[&>button]:top-5 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button]:z-[100] [&>button_svg]:size-4 sm:[&>button_svg]:size-5"
|
||||
>
|
||||
<DialogTitle className="sr-only">Upload Document</DialogTitle>
|
||||
|
||||
{/* Scrollable container for mobile */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto overscroll-contain">
|
||||
{/* Header - scrolls with content on mobile */}
|
||||
<div className="sticky top-0 z-20 bg-muted px-4 sm:px-12 pt-4 sm:pt-10 pb-2 sm:pb-0">
|
||||
{/* Upload header */}
|
||||
<div className="flex items-center gap-2 sm:gap-4 mb-2 sm:mb-6">
|
||||
<div className="flex-1 min-w-0 pr-8 sm:pr-0">
|
||||
<h2 className="text-base sm:text-2xl font-semibold tracking-tight">
|
||||
Upload Documents
|
||||
</h2>
|
||||
<p className="text-xs sm:text-base text-muted-foreground mt-0.5 sm:mt-1 line-clamp-1 sm:line-clamp-none">
|
||||
Upload and sync your documents to your search space
|
||||
</p>
|
||||
</div>
|
||||
<div className="sticky top-0 z-20 bg-muted px-4 sm:px-6 pt-4 sm:pt-5 pb-10">
|
||||
<div className="flex items-center gap-2 mb-1 pr-8 sm:pr-0">
|
||||
<h2 className="text-base sm:text-lg font-semibold tracking-tight">
|
||||
Upload Documents
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground line-clamp-1">
|
||||
Upload and sync your documents to your search space
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-4 sm:px-12 pb-4 sm:pb-16">
|
||||
<div className="px-4 sm:px-6 pb-4 sm:pb-6">
|
||||
{!isLoading && !hasDocumentSummaryLLM ? (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<Alert
|
||||
variant="destructive"
|
||||
className="mb-4 bg-muted/50 rounded-xl border-destructive/30"
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>LLM Configuration Required</AlertTitle>
|
||||
<AlertDescription className="mt-2">
|
||||
<p className="mb-3">
|
||||
{isAutoMode && !hasGlobalConfigs
|
||||
? "Auto mode is selected but no global LLM configurations are available. Please configure a custom LLM in Settings to process and summarize your uploaded documents."
|
||||
: "You need to configure a Document Summary LLM before uploading files. This LLM is used to process and summarize your uploaded documents."}
|
||||
? "Auto mode requires a global LLM configuration. Please add one in Settings"
|
||||
: "A Document Summary LLM is required to process uploads, configure one in Settings"}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
@ -179,9 +176,6 @@ const DocumentUploadPopupContent: FC<{
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom fade shadow - hidden on very small screens */}
|
||||
<div className="hidden sm:block absolute bottom-0 left-0 right-0 h-7 bg-gradient-to-t from-muted via-muted/80 to-transparent pointer-events-none z-10" />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { ImageIcon, ImageOffIcon } from "lucide-react";
|
|||
import { memo, type PropsWithChildren, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { cn } from "@/lib/utils";
|
||||
import NextImage from 'next/image';
|
||||
|
||||
const imageVariants = cva("aui-image-root relative overflow-hidden rounded-lg", {
|
||||
variants: {
|
||||
|
|
@ -86,23 +87,57 @@ function ImagePreview({
|
|||
>
|
||||
<ImageOffIcon className="size-8 text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
) : isDataOrBlobUrl(src) ? (
|
||||
// biome-ignore lint/performance/noImgElement: data/blob URLs need plain img
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={cn("block h-auto w-full object-contain", !loaded && "invisible", className)}
|
||||
onLoad={(e) => {
|
||||
if (typeof src === "string") setLoadedSrc(src);
|
||||
onLoad?.(e);
|
||||
}}
|
||||
onError={(e) => {
|
||||
if (typeof src === "string") setErrorSrc(src);
|
||||
onError?.(e);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
) : (
|
||||
// biome-ignore lint/performance/noImgElement: intentional for dynamic external URLs
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={cn("block h-auto w-full object-contain", !loaded && "invisible", className)}
|
||||
onLoad={(e) => {
|
||||
if (typeof src === "string") setLoadedSrc(src);
|
||||
onLoad?.(e);
|
||||
}}
|
||||
onError={(e) => {
|
||||
if (typeof src === "string") setErrorSrc(src);
|
||||
onError?.(e);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
// <img
|
||||
// ref={imgRef}
|
||||
// src={src}
|
||||
// alt={alt}
|
||||
// className={cn("block h-auto w-full object-contain", !loaded && "invisible", className)}
|
||||
// onLoad={(e) => {
|
||||
// if (typeof src === "string") setLoadedSrc(src);
|
||||
// onLoad?.(e);
|
||||
// }}
|
||||
// onError={(e) => {
|
||||
// if (typeof src === "string") setErrorSrc(src);
|
||||
// onError?.(e);
|
||||
// }}
|
||||
// {...props}
|
||||
// />
|
||||
<NextImage
|
||||
fill
|
||||
src={src || ""}
|
||||
alt={alt}
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 60vw"
|
||||
className={cn("block object-contain", !loaded && "invisible", className)}
|
||||
onLoad={() => {
|
||||
if (typeof src === "string") setLoadedSrc(src);
|
||||
onLoad?.();
|
||||
}}
|
||||
onError={() => {
|
||||
if (typeof src === "string") setErrorSrc(src);
|
||||
onError?.();
|
||||
}}
|
||||
unoptimized={false}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -126,7 +161,10 @@ type ImageZoomProps = PropsWithChildren<{
|
|||
src: string;
|
||||
alt?: string;
|
||||
}>;
|
||||
|
||||
function isDataOrBlobUrl(src: string | undefined): boolean {
|
||||
if (!src || typeof src !== "string") return false;
|
||||
return src.startsWith("data:") || src.startsWith("blob:");
|
||||
}
|
||||
function ImageZoom({ src, alt = "Image preview", children }: ImageZoomProps) {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
|
@ -177,22 +215,39 @@ function ImageZoom({ src, alt = "Image preview", children }: ImageZoomProps) {
|
|||
aria-label="Close zoomed image"
|
||||
>
|
||||
{/** biome-ignore lint/performance/noImgElement: <explanation> */}
|
||||
<img
|
||||
data-slot="image-zoom-content"
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="aui-image-zoom-content fade-in zoom-in-95 max-h-[90vh] max-w-[90vw] animate-in object-contain duration-200"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleClose();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.stopPropagation();
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{isDataOrBlobUrl(src) ? (
|
||||
// biome-ignore lint/performance/noImgElement: data/blob URLs need plain img
|
||||
<img
|
||||
data-slot="image-zoom-content"
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="aui-image-zoom-content fade-in zoom-in-95 max-h-[90vh] max-w-[90vw] animate-in object-contain duration-200"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleClose();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.stopPropagation();
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<NextImage
|
||||
data-slot="image-zoom-content"
|
||||
fill
|
||||
src={src}
|
||||
alt={alt}
|
||||
sizes="90vw"
|
||||
className="aui-image-zoom-content fade-in zoom-in-95 object-contain duration-200"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleClose();
|
||||
}}
|
||||
unoptimized={false}
|
||||
/>
|
||||
)}
|
||||
</button>,
|
||||
document.body
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export const InlineCitation: FC<InlineCitationProps> = ({ chunkId, isDocsChunk =
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="ml-0.5 inline-flex h-5 min-w-5 cursor-pointer items-center justify-center rounded-md bg-muted/60 px-1.5 text-[11px] font-medium text-muted-foreground align-super shadow-sm transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none"
|
||||
className="ml-0.5 inline-flex h-5 min-w-5 cursor-pointer items-center justify-center rounded-md bg-muted/60 px-1.5 text-[11px] font-medium text-muted-foreground align-baseline shadow-sm transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none"
|
||||
title={`View source chunk #${chunkId}`}
|
||||
>
|
||||
{chunkId}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
ChevronDown,
|
||||
ChevronUp,
|
||||
Clipboard,
|
||||
Dot,
|
||||
Globe,
|
||||
Plus,
|
||||
Settings2,
|
||||
|
|
@ -816,12 +817,23 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
|||
const isDesktop = useMediaQuery("(min-width: 640px)");
|
||||
const { openDialog: openUploadDialog } = useDocumentUploadDialog();
|
||||
const [toolsScrollPos, setToolsScrollPos] = useState<"top" | "middle" | "bottom">("top");
|
||||
const toolsRafRef = useRef<number>();
|
||||
const handleToolsScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||
const el = e.currentTarget;
|
||||
const atTop = el.scrollTop <= 2;
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
|
||||
setToolsScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
|
||||
if (toolsRafRef.current) return;
|
||||
toolsRafRef.current = requestAnimationFrame(() => {
|
||||
const atTop = el.scrollTop <= 2;
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
|
||||
setToolsScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
|
||||
toolsRafRef.current = undefined;
|
||||
});
|
||||
}, []);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (toolsRafRef.current) cancelAnimationFrame(toolsRafRef.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
const isComposerTextEmpty = useAuiState(({ composer }) => {
|
||||
const text = composer.text?.trim() || "";
|
||||
return text.length === 0;
|
||||
|
|
@ -1064,7 +1076,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
|||
>
|
||||
<div className="sr-only">Manage Tools</div>
|
||||
<div
|
||||
className="max-h-48 sm:max-h-64 overflow-y-auto py-0.5 sm:py-1"
|
||||
className="max-h-48 sm:max-h-64 overflow-y-auto overscroll-none py-0.5 sm:py-1"
|
||||
onScroll={handleToolsScroll}
|
||||
style={{
|
||||
maskImage: `linear-gradient(to bottom, ${toolsScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${toolsScrollPos === "bottom" ? "black" : "transparent"})`,
|
||||
|
|
@ -1147,7 +1159,11 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
|||
<TooltipTrigger asChild>{row}</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-72 text-xs">
|
||||
{groupDef?.tooltip ??
|
||||
group.tools.map((t) => t.description).join(" · ")}
|
||||
group.tools.flatMap((t, i) =>
|
||||
i === 0
|
||||
? [t.description]
|
||||
: [<Dot key={i} className="inline h-4 w-4" />, t.description]
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon, XCircleIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { getToolIcon } from "@/contracts/enums/toolIcons";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -19,17 +19,28 @@ export const ToolFallback: ToolCallMessagePartComponent = ({
|
|||
const isCancelled = status?.type === "incomplete" && status.reason === "cancelled";
|
||||
const isError = status?.type === "incomplete" && status.reason === "error";
|
||||
const isRunning = status?.type === "running" || status?.type === "requires-action";
|
||||
const errorData = status?.type === "incomplete" ? status.error : undefined;
|
||||
const serializedError = useMemo(
|
||||
() => (errorData && typeof errorData !== "string" ? JSON.stringify(errorData) : null),
|
||||
[errorData]
|
||||
);
|
||||
|
||||
const serializedResult = useMemo(
|
||||
() => (result !== undefined && typeof result !== "string" ? JSON.stringify(result, null, 2) : null),
|
||||
[result]
|
||||
);
|
||||
|
||||
const cancelledReason =
|
||||
isCancelled && status.error
|
||||
? typeof status.error === "string"
|
||||
? status.error
|
||||
: JSON.stringify(status.error)
|
||||
: serializedError
|
||||
: null;
|
||||
const errorReason =
|
||||
isError && status.error
|
||||
? typeof status.error === "string"
|
||||
? status.error
|
||||
: JSON.stringify(status.error)
|
||||
: serializedError
|
||||
: null;
|
||||
|
||||
const Icon = getToolIcon(toolName);
|
||||
|
|
@ -122,7 +133,7 @@ export const ToolFallback: ToolCallMessagePartComponent = ({
|
|||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">Result</p>
|
||||
<pre className="text-xs text-foreground/80 whitespace-pre-wrap break-all">
|
||||
{typeof result === "string" ? result : JSON.stringify(result, null, 2)}
|
||||
{typeof result === "string" ? result : serializedResult}
|
||||
</pre>
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
Clock,
|
||||
Download,
|
||||
Eye,
|
||||
History,
|
||||
MoreHorizontal,
|
||||
Move,
|
||||
PenLine,
|
||||
|
|
@ -39,6 +40,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
|||
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DND_TYPES } from "./FolderNode";
|
||||
import { isVersionableType } from "./version-history";
|
||||
|
||||
const EDITABLE_DOCUMENT_TYPES = new Set(["FILE", "NOTE"]);
|
||||
|
||||
|
|
@ -60,6 +62,7 @@ interface DocumentNodeProps {
|
|||
onDelete: (doc: DocumentNodeDoc) => void;
|
||||
onMove: (doc: DocumentNodeDoc) => void;
|
||||
onExport?: (doc: DocumentNodeDoc, format: string) => void;
|
||||
onVersionHistory?: (doc: DocumentNodeDoc) => void;
|
||||
contextMenuOpen?: boolean;
|
||||
onContextMenuOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
|
@ -74,6 +77,7 @@ export const DocumentNode = React.memo(function DocumentNode({
|
|||
onDelete,
|
||||
onMove,
|
||||
onExport,
|
||||
onVersionHistory,
|
||||
contextMenuOpen,
|
||||
onContextMenuOpenChange,
|
||||
}: DocumentNodeProps) {
|
||||
|
|
@ -195,12 +199,17 @@ export const DocumentNode = React.memo(function DocumentNode({
|
|||
|
||||
<span className="flex-1 min-w-0 truncate">{doc.title}</span>
|
||||
|
||||
<span className="shrink-0">
|
||||
{getDocumentTypeIcon(
|
||||
doc.document_type as DocumentTypeEnum,
|
||||
"h-3.5 w-3.5 text-muted-foreground"
|
||||
)}
|
||||
</span>
|
||||
{getDocumentTypeIcon(
|
||||
doc.document_type as DocumentTypeEnum,
|
||||
"h-3.5 w-3.5 text-muted-foreground"
|
||||
) && (
|
||||
<span className="shrink-0">
|
||||
{getDocumentTypeIcon(
|
||||
doc.document_type as DocumentTypeEnum,
|
||||
"h-3.5 w-3.5 text-muted-foreground"
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
|
@ -219,7 +228,7 @@ export const DocumentNode = React.memo(function DocumentNode({
|
|||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40" onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenuItem onClick={() => onPreview(doc)}>
|
||||
<DropdownMenuItem onClick={() => onPreview(doc)} disabled={isProcessing}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
Open
|
||||
</DropdownMenuItem>
|
||||
|
|
@ -235,7 +244,7 @@ export const DocumentNode = React.memo(function DocumentNode({
|
|||
</DropdownMenuItem>
|
||||
{onExport && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubTrigger disabled={isProcessing}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export
|
||||
</DropdownMenuSubTrigger>
|
||||
|
|
@ -244,6 +253,12 @@ export const DocumentNode = React.memo(function DocumentNode({
|
|||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
{onVersionHistory && isVersionableType(doc.document_type) && (
|
||||
<DropdownMenuItem disabled={isProcessing} onClick={() => onVersionHistory(doc)}>
|
||||
<History className="mr-2 h-4 w-4" />
|
||||
Versions
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
disabled={isProcessing}
|
||||
|
|
@ -259,7 +274,7 @@ export const DocumentNode = React.memo(function DocumentNode({
|
|||
|
||||
{contextMenuOpen && (
|
||||
<ContextMenuContent className="w-40" onClick={(e) => e.stopPropagation()}>
|
||||
<ContextMenuItem onClick={() => onPreview(doc)}>
|
||||
<ContextMenuItem onClick={() => onPreview(doc)} disabled={isProcessing}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
Open
|
||||
</ContextMenuItem>
|
||||
|
|
@ -275,7 +290,7 @@ export const DocumentNode = React.memo(function DocumentNode({
|
|||
</ContextMenuItem>
|
||||
{onExport && (
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>
|
||||
<ContextMenuSubTrigger disabled={isProcessing}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export
|
||||
</ContextMenuSubTrigger>
|
||||
|
|
@ -284,6 +299,12 @@ export const DocumentNode = React.memo(function DocumentNode({
|
|||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
)}
|
||||
{onVersionHistory && isVersionableType(doc.document_type) && (
|
||||
<ContextMenuItem disabled={isProcessing} onClick={() => onVersionHistory(doc)}>
|
||||
<History className="mr-2 h-4 w-4" />
|
||||
Versions
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
disabled={isProcessing}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,18 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
FolderPlus,
|
||||
MoreHorizontal,
|
||||
Move,
|
||||
PenLine,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
|
@ -27,6 +31,8 @@ import {
|
|||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { FolderSelectionState } from "./FolderTreeView";
|
||||
|
||||
|
|
@ -52,6 +58,7 @@ interface FolderNodeProps {
|
|||
isRenaming: boolean;
|
||||
childCount: number;
|
||||
selectionState: FolderSelectionState;
|
||||
processingState: "idle" | "processing" | "failed";
|
||||
onToggleSelect: (folderId: number, selectAll: boolean) => void;
|
||||
onToggleExpand: (folderId: number) => void;
|
||||
onRename: (folder: FolderDisplay, newName: string) => void;
|
||||
|
|
@ -70,6 +77,9 @@ interface FolderNodeProps {
|
|||
disabledDropIds?: Set<number>;
|
||||
contextMenuOpen?: boolean;
|
||||
onContextMenuOpenChange?: (open: boolean) => void;
|
||||
isWatched?: boolean;
|
||||
onRescan?: (folder: FolderDisplay) => void;
|
||||
onStopWatching?: (folder: FolderDisplay) => void;
|
||||
}
|
||||
|
||||
function getDropZone(
|
||||
|
|
@ -93,6 +103,7 @@ export const FolderNode = React.memo(function FolderNode({
|
|||
isRenaming,
|
||||
childCount,
|
||||
selectionState,
|
||||
processingState,
|
||||
onToggleSelect,
|
||||
onToggleExpand,
|
||||
onRename,
|
||||
|
|
@ -107,6 +118,9 @@ export const FolderNode = React.memo(function FolderNode({
|
|||
disabledDropIds,
|
||||
contextMenuOpen,
|
||||
onContextMenuOpenChange,
|
||||
isWatched,
|
||||
onRescan,
|
||||
onStopWatching,
|
||||
}: FolderNodeProps) {
|
||||
const [renameValue, setRenameValue] = useState(folder.name);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
|
@ -242,7 +256,9 @@ export const FolderNode = React.memo(function FolderNode({
|
|||
isOver && !canDrop && "cursor-not-allowed"
|
||||
)}
|
||||
style={{ paddingLeft: `${depth * 16 + 4}px` }}
|
||||
onClick={() => onToggleExpand(folder.id)}
|
||||
onClick={() => {
|
||||
onToggleExpand(folder.id);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
|
|
@ -262,14 +278,45 @@ export const FolderNode = React.memo(function FolderNode({
|
|||
)}
|
||||
</span>
|
||||
|
||||
<Checkbox
|
||||
checked={
|
||||
selectionState === "all" ? true : selectionState === "some" ? "indeterminate" : false
|
||||
}
|
||||
onCheckedChange={handleCheckChange}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-3.5 w-3.5 shrink-0"
|
||||
/>
|
||||
{processingState !== "idle" && selectionState === "none" ? (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center group-hover:hidden">
|
||||
{processingState === "processing" ? (
|
||||
<Spinner size="xs" className="text-primary" />
|
||||
) : (
|
||||
<AlertCircle className="h-3.5 w-3.5 text-destructive" />
|
||||
)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{processingState === "processing"
|
||||
? "Syncing folder contents"
|
||||
: "Some files failed to process"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Checkbox
|
||||
checked={false}
|
||||
onCheckedChange={handleCheckChange}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-3.5 w-3.5 shrink-0 hidden group-hover:flex"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Checkbox
|
||||
checked={
|
||||
selectionState === "all"
|
||||
? true
|
||||
: selectionState === "some"
|
||||
? "indeterminate"
|
||||
: false
|
||||
}
|
||||
onCheckedChange={handleCheckChange}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-3.5 w-3.5 shrink-0"
|
||||
/>
|
||||
)}
|
||||
|
||||
<FolderIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
|
||||
|
|
@ -308,6 +355,28 @@ export const FolderNode = React.memo(function FolderNode({
|
|||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
{isWatched && onRescan && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRescan(folder);
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Re-scan
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isWatched && onStopWatching && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onStopWatching(folder);
|
||||
}}
|
||||
>
|
||||
<EyeOff className="mr-2 h-4 w-4" />
|
||||
Stop watching
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -353,6 +422,18 @@ export const FolderNode = React.memo(function FolderNode({
|
|||
|
||||
{!isRenaming && contextMenuOpen && (
|
||||
<ContextMenuContent className="w-40">
|
||||
{isWatched && onRescan && (
|
||||
<ContextMenuItem onClick={() => onRescan(folder)}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Re-scan
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{isWatched && onStopWatching && (
|
||||
<ContextMenuItem onClick={() => onStopWatching(folder)}>
|
||||
<EyeOff className="mr-2 h-4 w-4" />
|
||||
Stop watching
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuItem onClick={() => onCreateSubfolder(folder.id)}>
|
||||
<FolderPlus className="mr-2 h-4 w-4" />
|
||||
New subfolder
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useAtom } from "jotai";
|
||||
import { CirclePlus } from "lucide-react";
|
||||
import { Search } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { DndProvider } from "react-dnd";
|
||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||
|
|
@ -32,6 +32,7 @@ interface FolderTreeViewProps {
|
|||
onDeleteDocument: (doc: DocumentNodeDoc) => void;
|
||||
onMoveDocument: (doc: DocumentNodeDoc) => void;
|
||||
onExportDocument?: (doc: DocumentNodeDoc, format: string) => void;
|
||||
onVersionHistory?: (doc: DocumentNodeDoc) => void;
|
||||
activeTypes: DocumentTypeEnum[];
|
||||
searchQuery?: string;
|
||||
onDropIntoFolder?: (
|
||||
|
|
@ -40,6 +41,9 @@ interface FolderTreeViewProps {
|
|||
targetFolderId: number | null
|
||||
) => void;
|
||||
onReorderFolder?: (folderId: number, beforePos: string | null, afterPos: string | null) => void;
|
||||
watchedFolderIds?: Set<number>;
|
||||
onRescanFolder?: (folder: FolderDisplay) => void;
|
||||
onStopWatchingFolder?: (folder: FolderDisplay) => void;
|
||||
}
|
||||
|
||||
function groupBy<T>(items: T[], keyFn: (item: T) => string | number): Record<string | number, T[]> {
|
||||
|
|
@ -69,10 +73,14 @@ export function FolderTreeView({
|
|||
onDeleteDocument,
|
||||
onMoveDocument,
|
||||
onExportDocument,
|
||||
onVersionHistory,
|
||||
activeTypes,
|
||||
searchQuery,
|
||||
onDropIntoFolder,
|
||||
onReorderFolder,
|
||||
watchedFolderIds,
|
||||
onRescanFolder,
|
||||
onStopWatchingFolder,
|
||||
}: FolderTreeViewProps) {
|
||||
const foldersByParent = useMemo(() => groupBy(folders, (f) => f.parentId ?? "root"), [folders]);
|
||||
|
||||
|
|
@ -158,6 +166,35 @@ export function FolderTreeView({
|
|||
return states;
|
||||
}, [folders, docsByFolder, foldersByParent, mentionedDocIds]);
|
||||
|
||||
const folderProcessingStates = useMemo(() => {
|
||||
const states: Record<number, "idle" | "processing" | "failed"> = {};
|
||||
|
||||
function compute(folderId: number): { hasProcessing: boolean; hasFailed: boolean } {
|
||||
const directDocs = docsByFolder[folderId] ?? [];
|
||||
let hasProcessing = directDocs.some(
|
||||
(d) => d.status?.state === "pending" || d.status?.state === "processing"
|
||||
);
|
||||
let hasFailed = directDocs.some((d) => d.status?.state === "failed");
|
||||
|
||||
for (const child of foldersByParent[folderId] ?? []) {
|
||||
const sub = compute(child.id);
|
||||
hasProcessing = hasProcessing || sub.hasProcessing;
|
||||
hasFailed = hasFailed || sub.hasFailed;
|
||||
}
|
||||
|
||||
if (hasProcessing) states[folderId] = "processing";
|
||||
else if (hasFailed) states[folderId] = "failed";
|
||||
else states[folderId] = "idle";
|
||||
|
||||
return { hasProcessing, hasFailed };
|
||||
}
|
||||
|
||||
for (const f of folders) {
|
||||
if (states[f.id] === undefined) compute(f.id);
|
||||
}
|
||||
return states;
|
||||
}, [folders, docsByFolder, foldersByParent]);
|
||||
|
||||
function renderLevel(parentId: number | null, depth: number): React.ReactNode[] {
|
||||
const key = parentId ?? "root";
|
||||
const childFolders = (foldersByParent[key] ?? [])
|
||||
|
|
@ -191,6 +228,7 @@ export function FolderTreeView({
|
|||
isRenaming={renamingFolderId === f.id}
|
||||
childCount={folderChildCounts[f.id] ?? 0}
|
||||
selectionState={folderSelectionStates[f.id] ?? "none"}
|
||||
processingState={folderProcessingStates[f.id] ?? "idle"}
|
||||
onToggleSelect={onToggleFolderSelect}
|
||||
onToggleExpand={onToggleExpand}
|
||||
onRename={onRenameFolder}
|
||||
|
|
@ -204,6 +242,9 @@ export function FolderTreeView({
|
|||
siblingPositions={siblingPositions}
|
||||
contextMenuOpen={openContextMenuId === `folder-${f.id}`}
|
||||
onContextMenuOpenChange={(open) => setOpenContextMenuId(open ? `folder-${f.id}` : null)}
|
||||
isWatched={watchedFolderIds?.has(f.id)}
|
||||
onRescan={onRescanFolder}
|
||||
onStopWatching={onStopWatchingFolder}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -225,6 +266,7 @@ export function FolderTreeView({
|
|||
onDelete={onDeleteDocument}
|
||||
onMove={onMoveDocument}
|
||||
onExport={onExportDocument}
|
||||
onVersionHistory={onVersionHistory}
|
||||
contextMenuOpen={openContextMenuId === `doc-${d.id}`}
|
||||
onContextMenuOpenChange={(open) => setOpenContextMenuId(open ? `doc-${d.id}` : null)}
|
||||
/>
|
||||
|
|
@ -250,8 +292,9 @@ export function FolderTreeView({
|
|||
if (treeNodes.length === 0 && (activeTypes.length > 0 || searchQuery)) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-4 py-12 text-muted-foreground">
|
||||
<CirclePlus className="h-10 w-10 rotate-45" />
|
||||
<p className="text-sm">No matching documents</p>
|
||||
<Search className="h-10 w-10" />
|
||||
<p className="text-sm text-muted-foreground">No matching documents</p>
|
||||
<p className="text-xs text-muted-foreground/70 mt-1">Try a different search term</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
258
surfsense_web/components/documents/version-history.tsx
Normal file
258
surfsense_web/components/documents/version-history.tsx
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
"use client";
|
||||
|
||||
import { Check, ChevronRight, Clock, Copy, RotateCcw } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DocumentVersionSummary {
|
||||
version_number: number;
|
||||
title: string;
|
||||
content_hash: string;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
interface VersionHistoryProps {
|
||||
documentId: number;
|
||||
documentType: string;
|
||||
}
|
||||
|
||||
const VERSION_DOCUMENT_TYPES = new Set(["LOCAL_FOLDER_FILE", "OBSIDIAN_CONNECTOR"]);
|
||||
|
||||
export function isVersionableType(documentType: string) {
|
||||
return VERSION_DOCUMENT_TYPES.has(documentType);
|
||||
}
|
||||
|
||||
const DIALOG_CLASSES =
|
||||
"select-none max-w-[900px] w-[95vw] md:w-[90vw] h-[90vh] md:h-[80vh] max-h-[640px] flex flex-col md:flex-row p-0 gap-0 overflow-hidden [--card:var(--background)] dark:[--card:oklch(0.205_0_0)] dark:[--background:oklch(0.205_0_0)]";
|
||||
|
||||
export function VersionHistoryButton({ documentId, documentType }: VersionHistoryProps) {
|
||||
if (!isVersionableType(documentType)) return null;
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="gap-1.5 text-xs">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
Versions
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className={DIALOG_CLASSES}>
|
||||
<DialogTitle className="sr-only">Version History</DialogTitle>
|
||||
<VersionHistoryPanel documentId={documentId} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function VersionHistoryDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
documentId,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
documentId: number;
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className={DIALOG_CLASSES}>
|
||||
<DialogTitle className="sr-only">Version History</DialogTitle>
|
||||
{open && <VersionHistoryPanel documentId={documentId} />}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateStr: string): string {
|
||||
const now = Date.now();
|
||||
const then = new Date(dateStr).getTime();
|
||||
const diffMs = now - then;
|
||||
const diffMin = Math.floor(diffMs / 60_000);
|
||||
if (diffMin < 1) return "Just now";
|
||||
if (diffMin < 60) return `${diffMin} minute${diffMin !== 1 ? "s" : ""} ago`;
|
||||
const diffHr = Math.floor(diffMin / 60);
|
||||
if (diffHr < 24) return `${diffHr} hour${diffHr !== 1 ? "s" : ""} ago`;
|
||||
return new Date(dateStr).toLocaleDateString(undefined, {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function VersionHistoryPanel({ documentId }: { documentId: number }) {
|
||||
const [versions, setVersions] = useState<DocumentVersionSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedVersion, setSelectedVersion] = useState<number | null>(null);
|
||||
const [versionContent, setVersionContent] = useState<string>("");
|
||||
const [contentLoading, setContentLoading] = useState(false);
|
||||
const [restoring, setRestoring] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const loadVersions = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await documentsApiService.listDocumentVersions(documentId);
|
||||
setVersions(data as DocumentVersionSummary[]);
|
||||
} catch {
|
||||
toast.error("Failed to load version history");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [documentId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadVersions();
|
||||
}, [loadVersions]);
|
||||
|
||||
const handleSelectVersion = async (versionNumber: number) => {
|
||||
if (selectedVersion === versionNumber) return;
|
||||
setSelectedVersion(versionNumber);
|
||||
setContentLoading(true);
|
||||
try {
|
||||
const data = (await documentsApiService.getDocumentVersion(documentId, versionNumber)) as {
|
||||
source_markdown: string;
|
||||
};
|
||||
setVersionContent(data.source_markdown || "");
|
||||
} catch {
|
||||
toast.error("Failed to load version content");
|
||||
} finally {
|
||||
setContentLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestore = async (versionNumber: number) => {
|
||||
setRestoring(true);
|
||||
try {
|
||||
await documentsApiService.restoreDocumentVersion(documentId, versionNumber);
|
||||
toast.success(`Restored version ${versionNumber}`);
|
||||
await loadVersions();
|
||||
} catch {
|
||||
toast.error("Failed to restore version");
|
||||
} finally {
|
||||
setRestoring(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(versionContent);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Spinner size="lg" className="text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (versions.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center text-muted-foreground">
|
||||
<p className="text-sm">No version history available yet</p>
|
||||
<p className="text-xs mt-1">Versions are created when file content changes</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedVersionData = versions.find((v) => v.version_number === selectedVersion);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Left panel — version list */}
|
||||
<nav className="w-full md:w-[260px] shrink-0 flex flex-col border-b md:border-b-0 md:border-r border-border">
|
||||
<div className="px-4 pr-12 md:pr-4 pt-5 pb-2">
|
||||
<h2 className="text-sm font-semibold text-foreground">Version History</h2>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{versions.map((v) => (
|
||||
<button
|
||||
key={v.version_number}
|
||||
type="button"
|
||||
onClick={() => handleSelectVersion(v.version_number)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-lg px-3 py-2.5 text-left transition-colors focus:outline-none focus-visible:outline-none w-full",
|
||||
selectedVersion === v.version_number
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 min-w-0 space-y-0.5">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{v.created_at
|
||||
? formatRelativeTime(v.created_at)
|
||||
: `Version ${v.version_number}`}
|
||||
</p>
|
||||
{v.title && <p className="text-xs text-muted-foreground truncate">{v.title}</p>}
|
||||
</div>
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 opacity-50" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Right panel — content preview */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden min-w-0">
|
||||
{selectedVersion !== null && selectedVersionData ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between pl-6 pr-14 pt-5 pb-2">
|
||||
<h2 className="text-sm font-semibold truncate">
|
||||
{selectedVersionData.title || `Version ${selectedVersion}`}
|
||||
</h2>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5 text-xs"
|
||||
onClick={handleCopy}
|
||||
disabled={contentLoading || copied}
|
||||
>
|
||||
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5 text-xs"
|
||||
disabled={restoring || contentLoading}
|
||||
onClick={() => handleRestore(selectedVersion)}
|
||||
>
|
||||
{restoring ? <Spinner size="xs" /> : <RotateCcw className="h-3 w-3" />}
|
||||
Restore
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
{contentLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Spinner size="sm" className="text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<pre className="text-sm whitespace-pre-wrap font-mono leading-relaxed text-foreground/90">
|
||||
{versionContent || "(empty)"}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center text-muted-foreground">
|
||||
<p className="text-sm">Select a version to preview</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,11 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { AlertCircle, Download, FileText, Loader2, XIcon } from "lucide-react";
|
||||
import { Download, FileQuestionMark, FileText, Loader2, RefreshCw, XIcon } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { closeEditorPanelAtom, editorPanelAtom } from "@/atoms/editor/editor-panel.atom";
|
||||
import { VersionHistoryButton } from "@/components/documents/version-history";
|
||||
import { MarkdownViewer } from "@/components/markdown-viewer";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -79,7 +80,7 @@ export function EditorPanelContent({
|
|||
const isLargeDocument = (editorDoc?.content_size_bytes ?? 0) > LARGE_DOCUMENT_THRESHOLD;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setEditorDoc(null);
|
||||
|
|
@ -87,7 +88,7 @@ export function EditorPanelContent({
|
|||
initialLoadDone.current = false;
|
||||
changeCountRef.current = 0;
|
||||
|
||||
const fetchContent = async () => {
|
||||
const doFetch = async () => {
|
||||
const token = getBearerToken();
|
||||
if (!token) {
|
||||
redirectToLogin();
|
||||
|
|
@ -95,6 +96,9 @@ export function EditorPanelContent({
|
|||
}
|
||||
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content`,
|
||||
{ method: "GET", signal: controller.signal }
|
||||
const url = new URL(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content`
|
||||
);
|
||||
|
|
@ -102,7 +106,7 @@ export function EditorPanelContent({
|
|||
|
||||
const response = await authenticatedFetch(url.toString(), { method: "GET" });
|
||||
|
||||
if (cancelled) return;
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response
|
||||
|
|
@ -126,18 +130,16 @@ export function EditorPanelContent({
|
|||
setEditorDoc(data);
|
||||
initialLoadDone.current = true;
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
if (controller.signal.aborted) return;
|
||||
console.error("Error fetching document:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch document");
|
||||
} finally {
|
||||
if (!cancelled) setIsLoading(false);
|
||||
if (!controller.signal.aborted) setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchContent();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
doFetch().catch(() => {});
|
||||
return () => controller.abort();
|
||||
}, [documentId, searchSpaceId, title]);
|
||||
|
||||
const handleMarkdownChange = useCallback((md: string) => {
|
||||
|
|
@ -198,12 +200,17 @@ export function EditorPanelContent({
|
|||
<p className="text-[10px] text-muted-foreground">Unsaved changes</p>
|
||||
)}
|
||||
</div>
|
||||
{onClose && (
|
||||
<Button variant="ghost" size="icon" onClick={onClose} className="size-7 shrink-0">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close editor panel</span>
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{editorDoc?.document_type && (
|
||||
<VersionHistoryButton documentId={documentId} documentType={editorDoc.document_type} />
|
||||
)}
|
||||
{onClose && (
|
||||
<Button variant="ghost" size="icon" onClick={onClose} className="size-7 shrink-0">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close editor panel</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
|
|
@ -211,10 +218,24 @@ export function EditorPanelContent({
|
|||
<EditorPanelSkeleton />
|
||||
) : error || !editorDoc ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-6 text-center">
|
||||
<AlertCircle className="size-8 text-destructive" />
|
||||
<div>
|
||||
<p className="font-medium text-foreground">Failed to load document</p>
|
||||
<p className="text-sm text-red-500 mt-1">{error || "An unknown error occurred"}</p>
|
||||
{error?.toLowerCase().includes("still being processed") ? (
|
||||
<div className="rounded-full bg-muted/50 p-3">
|
||||
<RefreshCw className="size-6 text-muted-foreground animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-full bg-muted/50 p-3">
|
||||
<FileQuestionMark className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1 max-w-xs">
|
||||
<p className="font-medium text-foreground">
|
||||
{error?.toLowerCase().includes("still being processed")
|
||||
? "Document is processing"
|
||||
: "Document unavailable"}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{error || "An unknown error occurred"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : isLargeDocument ? (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
"use client";
|
||||
import Image from 'next/image';
|
||||
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { ExpandedGifOverlay, useExpandedGif } from "@/components/ui/expanded-gif-overlay";
|
||||
|
|
@ -81,6 +82,15 @@ function UseCaseCard({
|
|||
alt={title}
|
||||
className="w-full rounded-xl object-cover transition-transform duration-500 group-hover:scale-[1.02]"
|
||||
/>
|
||||
<div className="relative w-full h-48">
|
||||
<Image
|
||||
src={src}
|
||||
alt={title}
|
||||
fill
|
||||
className="rounded-xl object-cover transition-transform duration-500 group-hover:scale-[1.02]"
|
||||
unoptimized={src.endsWith('.gif')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 py-4">
|
||||
<h3 className="text-base font-semibold text-neutral-900 dark:text-white">{title}</h3>
|
||||
|
|
|
|||
|
|
@ -775,7 +775,8 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("delete_chat")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("delete_chat_confirm")} <span className="font-medium">{chatToDelete?.name}</span>?{" "}
|
||||
{t("delete_chat_confirm")}{" "}
|
||||
<span className="font-medium break-all">{chatToDelete?.name}</span>?{" "}
|
||||
{t("action_cannot_undone")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
|
@ -835,9 +836,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
<span className={isRenamingChat ? "opacity-0" : ""}>
|
||||
{tSidebar("rename") || "Rename"}
|
||||
</span>
|
||||
{isRenamingChat && (
|
||||
<span className="absolute h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
)}
|
||||
{isRenamingChat && <Spinner size="sm" className="absolute" />}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
@ -865,9 +864,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
<span className={isDeletingSearchSpace ? "opacity-0" : ""}>{tCommon("delete")}</span>
|
||||
{isDeletingSearchSpace && (
|
||||
<span className="absolute h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
)}
|
||||
{isDeletingSearchSpace && <Spinner size="sm" className="absolute" />}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
|
@ -895,9 +892,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
<span className={isLeavingSearchSpace ? "opacity-0" : ""}>{t("leave")}</span>
|
||||
{isLeavingSearchSpace && (
|
||||
<span className="absolute h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
)}
|
||||
{isLeavingSearchSpace && <Spinner size="sm" className="absolute" />}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ const EditorPanelContent = dynamic(
|
|||
import("@/components/editor-panel/editor-panel").then((m) => ({
|
||||
default: m.EditorPanelContent,
|
||||
})),
|
||||
{ ssr: false, loading: () => <Skeleton className="h-96 w-full" /> }
|
||||
{ ssr: false, loading: () => null }
|
||||
);
|
||||
|
||||
const HitlEditPanelContent = dynamic(
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@ export function AllPrivateChatsSidebarContent({
|
|||
queryKey: ["all-threads", searchSpaceId],
|
||||
queryFn: () => fetchThreads(Number(searchSpaceId)),
|
||||
enabled: !!searchSpaceId && !isSearchMode,
|
||||
placeholderData: () => queryClient.getQueryData(["threads", searchSpaceId, { limit: 40 }]),
|
||||
});
|
||||
|
||||
const {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import type { DocumentNodeDoc } from "@/components/documents/DocumentNode";
|
|||
import type { FolderDisplay } from "@/components/documents/FolderNode";
|
||||
import { FolderPickerDialog } from "@/components/documents/FolderPickerDialog";
|
||||
import { FolderTreeView } from "@/components/documents/FolderTreeView";
|
||||
import { VersionHistoryDialog } from "@/components/documents/version-history";
|
||||
import { EXPORT_FILE_EXTENSIONS } from "@/components/shared/ExportMenuItems";
|
||||
import {
|
||||
AlertDialog,
|
||||
|
|
@ -40,6 +41,7 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
|||
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||
import { foldersApiService } from "@/lib/apis/folders-api.service";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
import { queries } from "@/zero/queries/index";
|
||||
|
|
@ -92,6 +94,50 @@ export function DocumentsSidebar({
|
|||
const [search, setSearch] = useState("");
|
||||
const debouncedSearch = useDebouncedValue(search, 250);
|
||||
const [activeTypes, setActiveTypes] = useState<DocumentTypeEnum[]>([]);
|
||||
const [watchedFolderIds, setWatchedFolderIds] = useState<Set<number>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
const api = typeof window !== "undefined" ? window.electronAPI : null;
|
||||
if (!api?.getWatchedFolders) return;
|
||||
|
||||
async function loadWatchedIds() {
|
||||
const folders = await api!.getWatchedFolders();
|
||||
|
||||
if (folders.length === 0) {
|
||||
try {
|
||||
const backendFolders = await documentsApiService.getWatchedFolders(searchSpaceId);
|
||||
for (const bf of backendFolders) {
|
||||
const meta = bf.metadata as Record<string, unknown> | null;
|
||||
if (!meta?.watched || !meta.folder_path) continue;
|
||||
await api!.addWatchedFolder({
|
||||
path: meta.folder_path as string,
|
||||
name: bf.name,
|
||||
rootFolderId: bf.id,
|
||||
searchSpaceId: bf.search_space_id,
|
||||
excludePatterns: (meta.exclude_patterns as string[]) ?? [],
|
||||
fileExtensions: (meta.file_extensions as string[] | null) ?? null,
|
||||
active: true,
|
||||
});
|
||||
}
|
||||
const recovered = await api!.getWatchedFolders();
|
||||
const ids = new Set(
|
||||
recovered.filter((f) => f.rootFolderId != null).map((f) => f.rootFolderId as number)
|
||||
);
|
||||
setWatchedFolderIds(ids);
|
||||
return;
|
||||
} catch (err) {
|
||||
console.error("[DocumentsSidebar] Recovery from backend failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
const ids = new Set(
|
||||
folders.filter((f) => f.rootFolderId != null).map((f) => f.rootFolderId as number)
|
||||
);
|
||||
setWatchedFolderIds(ids);
|
||||
}
|
||||
|
||||
loadWatchedIds();
|
||||
}, [searchSpaceId]);
|
||||
const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom);
|
||||
|
||||
const [sidebarDocs, setSidebarDocs] = useAtom(sidebarSelectedDocumentsAtom);
|
||||
|
|
@ -134,7 +180,12 @@ export function DocumentsSidebar({
|
|||
|
||||
const treeDocuments: DocumentNodeDoc[] = useMemo(() => {
|
||||
const zeroDocs = (zeroAllDocs ?? [])
|
||||
.filter((d) => d.title && d.title.trim() !== "")
|
||||
.filter((d) => {
|
||||
if (!d.title || d.title.trim() === "") return false;
|
||||
const state = (d.status as { state?: string } | undefined)?.state;
|
||||
if (state === "deleting") return false;
|
||||
return true;
|
||||
})
|
||||
.map((d) => ({
|
||||
id: d.id,
|
||||
title: d.title,
|
||||
|
|
@ -223,6 +274,53 @@ export function DocumentsSidebar({
|
|||
[createFolderParentId, searchSpaceId, setExpandedFolderMap]
|
||||
);
|
||||
|
||||
const handleRescanFolder = useCallback(
|
||||
async (folder: FolderDisplay) => {
|
||||
const api = window.electronAPI;
|
||||
if (!api) return;
|
||||
|
||||
const watchedFolders = await api.getWatchedFolders();
|
||||
const matched = watchedFolders.find((wf) => wf.rootFolderId === folder.id);
|
||||
if (!matched) {
|
||||
toast.error("This folder is not being watched");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await documentsApiService.folderIndex(searchSpaceId, {
|
||||
folder_path: matched.path,
|
||||
folder_name: matched.name,
|
||||
search_space_id: searchSpaceId,
|
||||
root_folder_id: folder.id,
|
||||
});
|
||||
toast.success(`Re-scanning folder: ${matched.name}`);
|
||||
} catch (err) {
|
||||
toast.error((err as Error)?.message || "Failed to re-scan folder");
|
||||
}
|
||||
},
|
||||
[searchSpaceId]
|
||||
);
|
||||
|
||||
const handleStopWatching = useCallback(async (folder: FolderDisplay) => {
|
||||
const api = window.electronAPI;
|
||||
if (!api) return;
|
||||
|
||||
const watchedFolders = await api.getWatchedFolders();
|
||||
const matched = watchedFolders.find((wf) => wf.rootFolderId === folder.id);
|
||||
if (!matched) {
|
||||
toast.error("This folder is not being watched");
|
||||
return;
|
||||
}
|
||||
|
||||
await api.removeWatchedFolder(matched.path);
|
||||
try {
|
||||
await foldersApiService.stopWatching(folder.id);
|
||||
} catch (err) {
|
||||
console.error("[DocumentsSidebar] Failed to clear watched metadata:", err);
|
||||
}
|
||||
toast.success(`Stopped watching: ${matched.name}`);
|
||||
}, []);
|
||||
|
||||
const handleRenameFolder = useCallback(async (folder: FolderDisplay, newName: string) => {
|
||||
try {
|
||||
await foldersApiService.updateFolder(folder.id, { name: newName });
|
||||
|
|
@ -235,6 +333,14 @@ export function DocumentsSidebar({
|
|||
const handleDeleteFolder = useCallback(async (folder: FolderDisplay) => {
|
||||
if (!confirm(`Delete folder "${folder.name}" and all its contents?`)) return;
|
||||
try {
|
||||
const api = window.electronAPI;
|
||||
if (api) {
|
||||
const watchedFolders = await api.getWatchedFolders();
|
||||
const matched = watchedFolders.find((wf) => wf.rootFolderId === folder.id);
|
||||
if (matched) {
|
||||
await api.removeWatchedFolder(matched.path);
|
||||
}
|
||||
}
|
||||
await foldersApiService.deleteFolder(folder.id);
|
||||
toast.success("Folder deleted");
|
||||
} catch (e: unknown) {
|
||||
|
|
@ -448,6 +554,7 @@ export function DocumentsSidebar({
|
|||
|
||||
const [bulkDeleteConfirmOpen, setBulkDeleteConfirmOpen] = useState(false);
|
||||
const [isBulkDeleting, setIsBulkDeleting] = useState(false);
|
||||
const [versionDocId, setVersionDocId] = useState<number | null>(null);
|
||||
|
||||
const handleBulkDeleteSelected = useCallback(async () => {
|
||||
if (deletableSelectedIds.length === 0) return;
|
||||
|
|
@ -651,56 +758,72 @@ export function DocumentsSidebar({
|
|||
/>
|
||||
</div>
|
||||
|
||||
{deletableSelectedIds.length > 0 && (
|
||||
<div className="shrink-0 flex items-center justify-center px-4 py-1.5 animate-in fade-in duration-150">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBulkDeleteConfirmOpen(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-1 rounded-md bg-destructive text-destructive-foreground shadow-sm text-xs font-medium hover:bg-destructive/90 transition-colors"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Delete {deletableSelectedIds.length}{" "}
|
||||
{deletableSelectedIds.length === 1 ? "item" : "items"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative flex-1 min-h-0 overflow-auto">
|
||||
{deletableSelectedIds.length > 0 && (
|
||||
<div className="absolute inset-x-0 top-0 z-10 flex items-center justify-center px-4 py-1.5 animate-in fade-in duration-150 pointer-events-none">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBulkDeleteConfirmOpen(true)}
|
||||
className="pointer-events-auto flex items-center gap-1.5 px-3 py-1 rounded-md bg-destructive text-destructive-foreground shadow-lg text-xs font-medium hover:bg-destructive/90 transition-colors"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Delete {deletableSelectedIds.length}{" "}
|
||||
{deletableSelectedIds.length === 1 ? "item" : "items"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FolderTreeView
|
||||
folders={treeFolders}
|
||||
documents={searchFilteredDocuments}
|
||||
expandedIds={expandedIds}
|
||||
onToggleExpand={toggleFolderExpand}
|
||||
mentionedDocIds={mentionedDocIds}
|
||||
onToggleChatMention={handleToggleChatMention}
|
||||
onToggleFolderSelect={handleToggleFolderSelect}
|
||||
onRenameFolder={handleRenameFolder}
|
||||
onDeleteFolder={handleDeleteFolder}
|
||||
onMoveFolder={handleMoveFolder}
|
||||
onCreateFolder={handleCreateFolder}
|
||||
searchQuery={debouncedSearch.trim() || undefined}
|
||||
onPreviewDocument={(doc) => {
|
||||
openEditorPanel({
|
||||
documentId: doc.id,
|
||||
searchSpaceId,
|
||||
title: doc.title,
|
||||
});
|
||||
}}
|
||||
onEditDocument={(doc) => {
|
||||
openEditorPanel({
|
||||
documentId: doc.id,
|
||||
searchSpaceId,
|
||||
title: doc.title,
|
||||
});
|
||||
}}
|
||||
onDeleteDocument={(doc) => handleDeleteDocument(doc.id)}
|
||||
onMoveDocument={handleMoveDocument}
|
||||
onExportDocument={handleExportDocument}
|
||||
activeTypes={activeTypes}
|
||||
onDropIntoFolder={handleDropIntoFolder}
|
||||
onReorderFolder={handleReorderFolder}
|
||||
/>
|
||||
<FolderTreeView
|
||||
folders={treeFolders}
|
||||
documents={searchFilteredDocuments}
|
||||
expandedIds={expandedIds}
|
||||
onToggleExpand={toggleFolderExpand}
|
||||
mentionedDocIds={mentionedDocIds}
|
||||
onToggleChatMention={handleToggleChatMention}
|
||||
onToggleFolderSelect={handleToggleFolderSelect}
|
||||
onRenameFolder={handleRenameFolder}
|
||||
onDeleteFolder={handleDeleteFolder}
|
||||
onMoveFolder={handleMoveFolder}
|
||||
onCreateFolder={handleCreateFolder}
|
||||
searchQuery={debouncedSearch.trim() || undefined}
|
||||
onPreviewDocument={(doc) => {
|
||||
openEditorPanel({
|
||||
documentId: doc.id,
|
||||
searchSpaceId,
|
||||
title: doc.title,
|
||||
});
|
||||
}}
|
||||
onEditDocument={(doc) => {
|
||||
openEditorPanel({
|
||||
documentId: doc.id,
|
||||
searchSpaceId,
|
||||
title: doc.title,
|
||||
});
|
||||
}}
|
||||
onDeleteDocument={(doc) => handleDeleteDocument(doc.id)}
|
||||
onMoveDocument={handleMoveDocument}
|
||||
onExportDocument={handleExportDocument}
|
||||
onVersionHistory={(doc) => setVersionDocId(doc.id)}
|
||||
activeTypes={activeTypes}
|
||||
onDropIntoFolder={handleDropIntoFolder}
|
||||
onReorderFolder={handleReorderFolder}
|
||||
watchedFolderIds={watchedFolderIds}
|
||||
onRescanFolder={handleRescanFolder}
|
||||
onStopWatchingFolder={handleStopWatching}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{versionDocId !== null && (
|
||||
<VersionHistoryDialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setVersionDocId(null);
|
||||
}}
|
||||
documentId={versionDocId}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FolderPickerDialog
|
||||
open={folderPickerOpen}
|
||||
onOpenChange={setFolderPickerOpen}
|
||||
|
|
|
|||
|
|
@ -178,12 +178,23 @@ export function InboxSidebarContent({
|
|||
const [mounted, setMounted] = useState(false);
|
||||
const [openDropdown, setOpenDropdown] = useState<"filter" | null>(null);
|
||||
const [connectorScrollPos, setConnectorScrollPos] = useState<"top" | "middle" | "bottom">("top");
|
||||
const connectorRafRef = useRef<number>();
|
||||
const handleConnectorScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||
const el = e.currentTarget;
|
||||
const atTop = el.scrollTop <= 2;
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
|
||||
setConnectorScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
|
||||
if (connectorRafRef.current) return;
|
||||
connectorRafRef.current = requestAnimationFrame(() => {
|
||||
const atTop = el.scrollTop <= 2;
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
|
||||
setConnectorScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
|
||||
connectorRafRef.current = undefined;
|
||||
});
|
||||
}, []);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (connectorRafRef.current) cancelAnimationFrame(connectorRafRef.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
|
||||
const [markingAsReadId, setMarkingAsReadId] = useState<number | null>(null);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { AlertCircle, Download, FileText, Loader2, Pencil } from "lucide-react";
|
||||
import { Download, FileQuestionMark, FileText, Loader2, PenLine, RefreshCw } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { PlateEditor } from "@/components/editor/plate-editor";
|
||||
|
|
@ -64,7 +64,7 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
|
|||
const isLargeDocument = (doc?.content_size_bytes ?? 0) > LARGE_DOCUMENT_THRESHOLD;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setDoc(null);
|
||||
|
|
@ -73,7 +73,7 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
|
|||
initialLoadDone.current = false;
|
||||
changeCountRef.current = 0;
|
||||
|
||||
const fetchContent = async () => {
|
||||
const doFetch = async () => {
|
||||
const token = getBearerToken();
|
||||
if (!token) {
|
||||
redirectToLogin();
|
||||
|
|
@ -81,6 +81,9 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
|
|||
}
|
||||
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content`,
|
||||
{ method: "GET", signal: controller.signal }
|
||||
const url = new URL(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content`
|
||||
);
|
||||
|
|
@ -88,7 +91,7 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
|
|||
|
||||
const response = await authenticatedFetch(url.toString(), { method: "GET" });
|
||||
|
||||
if (cancelled) return;
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response
|
||||
|
|
@ -109,18 +112,16 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
|
|||
setDoc(data);
|
||||
initialLoadDone.current = true;
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
if (controller.signal.aborted) return;
|
||||
console.error("Error fetching document:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch document");
|
||||
} finally {
|
||||
if (!cancelled) setIsLoading(false);
|
||||
if (!controller.signal.aborted) setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchContent();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
doFetch().catch(() => {});
|
||||
return () => controller.abort();
|
||||
}, [documentId, searchSpaceId]);
|
||||
|
||||
const handleMarkdownChange = useCallback((md: string) => {
|
||||
|
|
@ -171,15 +172,33 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
|
|||
if (isLoading) return <DocumentSkeleton />;
|
||||
|
||||
if (error || !doc) {
|
||||
const isProcessing = error?.toLowerCase().includes("still being processed");
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-6 text-center">
|
||||
<AlertCircle className="size-10 text-destructive" />
|
||||
<div>
|
||||
<p className="font-medium text-foreground text-lg">Failed to load document</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{error || "An unknown error occurred"}
|
||||
</p>
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-4 p-8 text-center">
|
||||
<div className="rounded-full bg-muted/50 p-4">
|
||||
{isProcessing ? (
|
||||
<RefreshCw className="size-8 text-muted-foreground animate-spin" />
|
||||
) : (
|
||||
<FileQuestionMark className="size-8 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5 max-w-sm">
|
||||
<p className="font-semibold text-foreground text-lg">
|
||||
{isProcessing ? "Document is processing" : "Document unavailable"}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{error || "An unknown error occurred"}</p>
|
||||
</div>
|
||||
{!isProcessing && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-1 gap-1.5"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -240,7 +259,7 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
|
|||
onClick={() => setIsEditing(true)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
<PenLine className="size-3.5" />
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ export function TabBar({ onTabSwitch, onNewChat, rightActions, className }: TabB
|
|||
if (tabs.length <= 1) return null;
|
||||
|
||||
return (
|
||||
<div className={cn("mb-2 flex h-9 items-center shrink-0 px-1 gap-0.5", className)}>
|
||||
<div className={cn("mb-2 flex h-9 items-center shrink-0 px-1 gap-0.5 select-none", className)}>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex h-full items-center flex-1 gap-0.5 overflow-x-auto overflow-y-hidden scrollbar-hide [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden py-1"
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { createMathPlugin } from "@streamdown/math";
|
|||
import { Streamdown, type StreamdownProps } from "streamdown";
|
||||
import "katex/dist/katex.min.css";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Image from 'next/image';
|
||||
import { is } from "drizzle-orm";
|
||||
|
||||
const code = createCodePlugin({
|
||||
themes: ["nord", "nord"],
|
||||
|
|
@ -127,16 +129,31 @@ export function MarkdownViewer({ content, className, maxLength }: MarkdownViewer
|
|||
<blockquote className="border-l-4 border-muted pl-4 italic my-2" {...props} />
|
||||
),
|
||||
hr: ({ ...props }) => <hr className="my-4 border-muted" {...props} />,
|
||||
img: ({ src, alt, width: _w, height: _h, ...props }) => (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
className="max-w-full h-auto my-4 rounded"
|
||||
alt={alt || "markdown image"}
|
||||
src={typeof src === "string" ? src : ""}
|
||||
loading="lazy"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
img: ({ src, alt, width: _w, height: _h, ...props }) => {
|
||||
const isDataOrUnknownUrl = typeof src === "string" && (src.startsWith("data:") || !src.startsWith("http"));
|
||||
|
||||
return isDataOrUnknownUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
className="max-w-full h-auto my-4 rounded"
|
||||
alt={alt || "markdown image"}
|
||||
src={src}
|
||||
loading="lazy"
|
||||
{...props}
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
className="max-w-full h-auto my-4 rounded"
|
||||
alt={alt || "markdown image"}
|
||||
src={typeof src === "string" ? src : ""}
|
||||
width={_w || 800}
|
||||
height={_h || 600}
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 75vw, 60vw"
|
||||
unoptimized={isDataOrUnknownUrl}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
table: ({ ...props }) => (
|
||||
<div className="overflow-x-auto my-4 rounded-lg border border-border w-full">
|
||||
<table className="w-full divide-y divide-border" {...props} />
|
||||
|
|
|
|||
|
|
@ -498,7 +498,7 @@ export function ModelSelector({
|
|||
}}
|
||||
>
|
||||
<Plus className="size-4 text-primary" />
|
||||
<span className="text-sm font-medium">Add LLM Model</span>
|
||||
<span className="text-sm font-medium">Add Model</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CommandList>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
ChevronDown,
|
||||
ChevronUp,
|
||||
ExternalLink,
|
||||
FileQuestionMark,
|
||||
FileText,
|
||||
Hash,
|
||||
Loader2,
|
||||
|
|
@ -475,13 +476,11 @@ export function SourceDetailPanel({
|
|||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="flex flex-col items-center gap-4 text-center px-6"
|
||||
>
|
||||
<div className="w-20 h-20 rounded-full bg-destructive/10 flex items-center justify-center">
|
||||
<X className="h-10 w-10 text-destructive" />
|
||||
<div className="w-20 h-20 rounded-full bg-muted/50 flex items-center justify-center">
|
||||
<FileQuestionMark className="h-10 w-10 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-destructive text-lg">
|
||||
Failed to load document
|
||||
</p>
|
||||
<p className="font-semibold text-foreground text-lg">Document unavailable</p>
|
||||
<p className="text-sm text-muted-foreground mt-2 max-w-md">
|
||||
{documentByChunkFetchingError.message ||
|
||||
"An unexpected error occurred. Please try again."}
|
||||
|
|
|
|||
|
|
@ -429,6 +429,7 @@ export function OnboardingTour() {
|
|||
const pathname = usePathname();
|
||||
const retryCountRef = useRef(0);
|
||||
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const startCheckTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const maxRetries = 10;
|
||||
// Track previous user ID to detect user changes
|
||||
const previousUserIdRef = useRef<string | null>(null);
|
||||
|
|
@ -439,8 +440,8 @@ export function OnboardingTour() {
|
|||
|
||||
// Fetch threads data
|
||||
const { data: threadsData } = useQuery({
|
||||
queryKey: ["threads", searchSpaceId, { limit: 1 }],
|
||||
queryFn: () => fetchThreads(Number(searchSpaceId), 1), // Only need to check if any exist
|
||||
queryKey: ["threads", searchSpaceId, { limit: 40 }], // Same key as layout
|
||||
queryFn: () => fetchThreads(Number(searchSpaceId), 40),
|
||||
enabled: !!searchSpaceId,
|
||||
});
|
||||
|
||||
|
|
@ -460,6 +461,7 @@ export function OnboardingTour() {
|
|||
|
||||
// Find and track target element with retry logic
|
||||
const updateTarget = useCallback(() => {
|
||||
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
|
||||
if (!currentStep) return;
|
||||
|
||||
const el = document.querySelector(currentStep.target);
|
||||
|
|
@ -480,11 +482,13 @@ export function OnboardingTour() {
|
|||
}
|
||||
}, 200);
|
||||
}
|
||||
}, [currentStep]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
|
||||
};
|
||||
}, [currentStep]);
|
||||
}, []);
|
||||
|
||||
// Check if tour should run: localStorage + data validation with user ID tracking
|
||||
useEffect(() => {
|
||||
|
|
@ -573,15 +577,15 @@ export function OnboardingTour() {
|
|||
setPosition(calculatePosition(connectorEl, TOUR_STEPS[0].placement));
|
||||
} else {
|
||||
// Retry after delay
|
||||
setTimeout(checkAndStartTour, 200);
|
||||
startCheckTimerRef.current = setTimeout(checkAndStartTour, 200);
|
||||
}
|
||||
};
|
||||
|
||||
// Start checking after initial delay
|
||||
const timer = setTimeout(checkAndStartTour, 500);
|
||||
startCheckTimerRef.current = setTimeout(checkAndStartTour, 500);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
if (startCheckTimerRef.current) clearTimeout(startCheckTimerRef.current);
|
||||
};
|
||||
}, [mounted, user?.id, searchSpaceId, pathname, threadsData, documentTypeCounts, connectors]);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { Check, Copy, ExternalLink, MessageSquare, Trash2 } from "lucide-react";
|
||||
import { Check, Copy, Dot, ExternalLink, MessageSquare, Trash2 } from "lucide-react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
|
@ -153,7 +153,7 @@ export function PublicChatSnapshotRow({
|
|||
<span className="text-[11px] text-muted-foreground/60">{formattedDate}</span>
|
||||
{member && (
|
||||
<>
|
||||
<span className="text-muted-foreground/30">·</span>
|
||||
<Dot className="h-4 w-4 text-muted-foreground/30" />
|
||||
<TooltipProvider>
|
||||
<Tooltip open={isDesktop ? undefined : false}>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
|
|||
|
|
@ -11,11 +11,8 @@ export function PublicChatSnapshotsEmptyState({
|
|||
}: PublicChatSnapshotsEmptyStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="rounded-full bg-muted p-3 mb-4">
|
||||
<Link2Off className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-foreground mb-1">{title}</h3>
|
||||
<p className="text-xs text-muted-foreground max-w-sm">{description}</p>
|
||||
<h3 className="text-sm md:text-base font-semibold mb-2">{title}</h3>
|
||||
<p className="text-[11px] md:text-xs text-muted-foreground max-w-sm">{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { AlertCircle, Edit3, Info, Plus, RefreshCw, Trash2, Wand2 } from "lucide-react";
|
||||
import { AlertCircle, Dot, Edit3, Info, RefreshCw, Trash2, Wand2 } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { deleteImageGenConfigMutationAtom } from "@/atoms/image-gen-config/image-gen-config-mutation.atoms";
|
||||
import {
|
||||
|
|
@ -240,27 +240,14 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
|||
{!isLoading && (
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
{(userConfigs?.length ?? 0) === 0 ? (
|
||||
<Card className="border-dashed border-2 border-muted-foreground/25">
|
||||
<Card className="border-0 bg-transparent shadow-none">
|
||||
<CardContent className="flex flex-col items-center justify-center py-10 md:py-16 text-center">
|
||||
<div className="rounded-full bg-gradient-to-br from-teal-500/10 to-cyan-500/10 p-4 md:p-6 mb-4">
|
||||
<Wand2 className="h-8 w-8 md:h-12 md:w-12 text-teal-600 dark:text-teal-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">No Image Models Yet</h3>
|
||||
<p className="text-xs md:text-sm text-muted-foreground max-w-sm mb-4">
|
||||
<h3 className="text-sm md:text-base font-semibold mb-2">No Image Models Yet</h3>
|
||||
<p className="text-[11px] md:text-xs text-muted-foreground max-w-sm mb-4">
|
||||
{canCreate
|
||||
? "Add your own image generation model (DALL-E 3, GPT Image 1, etc.)"
|
||||
: "No image models have been added to this space yet. Contact a space owner to add one."}
|
||||
</p>
|
||||
{canCreate && (
|
||||
<Button
|
||||
onClick={openNewDialog}
|
||||
size="lg"
|
||||
className="gap-2 text-xs md:text-sm h-9 md:h-10"
|
||||
>
|
||||
<Plus className="h-3 w-3 md:h-4 md:w-4" />
|
||||
Add First Image Model
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
|
|
@ -343,7 +330,7 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
|||
</span>
|
||||
{member && (
|
||||
<>
|
||||
<span className="text-muted-foreground/30">·</span>
|
||||
<Dot className="h-4 w-4 text-muted-foreground/30" />
|
||||
<TooltipProvider>
|
||||
<Tooltip open={isDesktop ? undefined : false}>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
|
|||
|
|
@ -4,16 +4,14 @@ import { useAtomValue } from "jotai";
|
|||
import {
|
||||
AlertCircle,
|
||||
Bot,
|
||||
CheckCircle,
|
||||
CircleCheck,
|
||||
CircleDashed,
|
||||
FileText,
|
||||
ImageIcon,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Save,
|
||||
Shuffle,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
globalImageGenConfigsAtom,
|
||||
|
|
@ -40,6 +38,7 @@ import {
|
|||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { getProviderIcon } from "@/lib/provider-icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -48,8 +47,8 @@ const ROLE_DESCRIPTIONS = {
|
|||
icon: Bot,
|
||||
title: "Agent LLM",
|
||||
description: "Primary LLM for chat interactions and agent operations",
|
||||
color: "text-blue-600 dark:text-blue-400",
|
||||
bgColor: "bg-blue-500/10",
|
||||
color: "text-muted-foreground",
|
||||
bgColor: "bg-muted",
|
||||
prefKey: "agent_llm_id" as const,
|
||||
configType: "llm" as const,
|
||||
},
|
||||
|
|
@ -57,8 +56,8 @@ const ROLE_DESCRIPTIONS = {
|
|||
icon: FileText,
|
||||
title: "Document Summary LLM",
|
||||
description: "Handles document summarization and research synthesis",
|
||||
color: "text-purple-600 dark:text-purple-400",
|
||||
bgColor: "bg-purple-500/10",
|
||||
color: "text-muted-foreground",
|
||||
bgColor: "bg-muted",
|
||||
prefKey: "document_summary_llm_id" as const,
|
||||
configType: "llm" as const,
|
||||
},
|
||||
|
|
@ -66,8 +65,8 @@ const ROLE_DESCRIPTIONS = {
|
|||
icon: ImageIcon,
|
||||
title: "Image Generation Model",
|
||||
description: "Model used for AI image generation (DALL-E, GPT Image, etc.)",
|
||||
color: "text-teal-600 dark:text-teal-400",
|
||||
bgColor: "bg-teal-500/10",
|
||||
color: "text-muted-foreground",
|
||||
bgColor: "bg-muted",
|
||||
prefKey: "image_generation_config_id" as const,
|
||||
configType: "image" as const,
|
||||
},
|
||||
|
|
@ -118,88 +117,44 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
|||
image_generation_config_id: preferences.image_generation_config_id ?? "",
|
||||
}));
|
||||
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [savingRole, setSavingRole] = useState<string | null>(null);
|
||||
const savingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const newAssignments = {
|
||||
agent_llm_id: preferences.agent_llm_id ?? "",
|
||||
document_summary_llm_id: preferences.document_summary_llm_id ?? "",
|
||||
image_generation_config_id: preferences.image_generation_config_id ?? "",
|
||||
};
|
||||
setAssignments(newAssignments);
|
||||
setHasChanges(false);
|
||||
if (!savingRef.current) {
|
||||
setAssignments({
|
||||
agent_llm_id: preferences.agent_llm_id ?? "",
|
||||
document_summary_llm_id: preferences.document_summary_llm_id ?? "",
|
||||
image_generation_config_id: preferences.image_generation_config_id ?? "",
|
||||
});
|
||||
}
|
||||
}, [
|
||||
preferences?.agent_llm_id,
|
||||
preferences?.document_summary_llm_id,
|
||||
preferences?.image_generation_config_id,
|
||||
]);
|
||||
|
||||
const handleRoleAssignment = (prefKey: string, configId: string) => {
|
||||
const newAssignments = {
|
||||
...assignments,
|
||||
[prefKey]: configId === "unassigned" ? "" : parseInt(configId),
|
||||
};
|
||||
const handleRoleAssignment = useCallback(
|
||||
async (prefKey: string, configId: string) => {
|
||||
const value = configId === "unassigned" ? "" : parseInt(configId);
|
||||
|
||||
setAssignments(newAssignments);
|
||||
setAssignments((prev) => ({ ...prev, [prefKey]: value }));
|
||||
setSavingRole(prefKey);
|
||||
savingRef.current = true;
|
||||
|
||||
const currentPrefs = {
|
||||
agent_llm_id: preferences.agent_llm_id ?? "",
|
||||
document_summary_llm_id: preferences.document_summary_llm_id ?? "",
|
||||
image_generation_config_id: preferences.image_generation_config_id ?? "",
|
||||
};
|
||||
|
||||
const hasChangesNow = Object.keys(newAssignments).some(
|
||||
(key) =>
|
||||
newAssignments[key as keyof typeof newAssignments] !==
|
||||
currentPrefs[key as keyof typeof currentPrefs]
|
||||
);
|
||||
|
||||
setHasChanges(hasChangesNow);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
|
||||
const toNumericOrUndefined = (val: string | number) =>
|
||||
typeof val === "string" ? (val ? parseInt(val) : undefined) : val;
|
||||
|
||||
const numericAssignments = {
|
||||
agent_llm_id: toNumericOrUndefined(assignments.agent_llm_id),
|
||||
document_summary_llm_id: toNumericOrUndefined(assignments.document_summary_llm_id),
|
||||
image_generation_config_id: toNumericOrUndefined(assignments.image_generation_config_id),
|
||||
};
|
||||
|
||||
await updatePreferences({
|
||||
search_space_id: searchSpaceId,
|
||||
data: numericAssignments,
|
||||
});
|
||||
|
||||
setHasChanges(false);
|
||||
toast.success("Role assignments saved successfully!");
|
||||
|
||||
setIsSaving(false);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setAssignments({
|
||||
agent_llm_id: preferences.agent_llm_id ?? "",
|
||||
document_summary_llm_id: preferences.document_summary_llm_id ?? "",
|
||||
image_generation_config_id: preferences.image_generation_config_id ?? "",
|
||||
});
|
||||
setHasChanges(false);
|
||||
};
|
||||
|
||||
const isAssignmentComplete =
|
||||
assignments.agent_llm_id !== "" &&
|
||||
assignments.agent_llm_id !== null &&
|
||||
assignments.agent_llm_id !== undefined &&
|
||||
assignments.document_summary_llm_id !== "" &&
|
||||
assignments.document_summary_llm_id !== null &&
|
||||
assignments.document_summary_llm_id !== undefined &&
|
||||
assignments.image_generation_config_id !== "" &&
|
||||
assignments.image_generation_config_id !== null &&
|
||||
assignments.image_generation_config_id !== undefined;
|
||||
try {
|
||||
await updatePreferences({
|
||||
search_space_id: searchSpaceId,
|
||||
data: { [prefKey]: value || undefined },
|
||||
});
|
||||
toast.success("Role assignment updated");
|
||||
} finally {
|
||||
setSavingRole(null);
|
||||
savingRef.current = false;
|
||||
}
|
||||
},
|
||||
[updatePreferences, searchSpaceId]
|
||||
);
|
||||
|
||||
// Combine global and custom LLM configs
|
||||
const allLLMConfigs = [
|
||||
|
|
@ -213,6 +168,11 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
|||
...(userImageConfigs ?? []).filter((config) => config.id && config.id.toString().trim() !== ""),
|
||||
];
|
||||
|
||||
const isAssignmentComplete =
|
||||
allLLMConfigs.some((c) => c.id === assignments.agent_llm_id) &&
|
||||
allLLMConfigs.some((c) => c.id === assignments.document_summary_llm_id) &&
|
||||
allImageConfigs.some((c) => c.id === assignments.image_generation_config_id);
|
||||
|
||||
const isLoading =
|
||||
configsLoading ||
|
||||
preferencesLoading ||
|
||||
|
|
@ -242,11 +202,8 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
|||
Refresh
|
||||
</Button>
|
||||
{isAssignmentComplete && !isLoading && !hasError && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs gap-1.5 border-emerald-500/30 text-emerald-700 dark:text-emerald-300 bg-emerald-500/5"
|
||||
>
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
<Badge variant="outline" className="text-xs gap-1.5 text-muted-foreground">
|
||||
<CircleCheck className="h-3 w-3" />
|
||||
All roles assigned
|
||||
</Badge>
|
||||
)}
|
||||
|
|
@ -332,10 +289,7 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
|||
const roleAllConfigs = isImageRole ? allImageConfigs : allLLMConfigs;
|
||||
|
||||
const assignedConfig = roleAllConfigs.find((config) => config.id === currentAssignment);
|
||||
const isAssigned =
|
||||
currentAssignment !== "" &&
|
||||
currentAssignment !== null &&
|
||||
currentAssignment !== undefined;
|
||||
const isAssigned = !!assignedConfig;
|
||||
const isAutoMode =
|
||||
assignedConfig && "is_auto_mode" in assignedConfig && assignedConfig.is_auto_mode;
|
||||
|
||||
|
|
@ -361,8 +315,10 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{isAssigned ? (
|
||||
<CheckCircle className="w-4 h-4 text-emerald-500 shrink-0 mt-0.5" />
|
||||
{savingRole === role.prefKey ? (
|
||||
<Spinner size="sm" className="shrink-0 mt-0.5 text-muted-foreground" />
|
||||
) : isAssigned ? (
|
||||
<CircleCheck className="w-4 h-4 text-muted-foreground/40 shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<CircleDashed className="w-4 h-4 text-muted-foreground/40 shrink-0 mt-0.5" />
|
||||
)}
|
||||
|
|
@ -374,7 +330,7 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
|||
Configuration
|
||||
</Label>
|
||||
<Select
|
||||
value={currentAssignment?.toString() || "unassigned"}
|
||||
value={isAssigned ? currentAssignment.toString() : "unassigned"}
|
||||
onValueChange={(value) => handleRoleAssignment(role.prefKey, value)}
|
||||
>
|
||||
<SelectTrigger className="w-full h-9 md:h-10 text-xs md:text-sm">
|
||||
|
|
@ -404,13 +360,7 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
|||
>
|
||||
<div className="flex items-center gap-1 md:gap-1.5 flex-wrap min-w-0">
|
||||
{isAuto ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[9px] md:text-[10px] shrink-0 bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300 border-violet-200 dark:border-violet-700"
|
||||
>
|
||||
<Shuffle className="size-2 md:size-2.5 mr-0.5" />
|
||||
AUTO
|
||||
</Badge>
|
||||
<Shuffle className="size-3 md:size-3.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
getProviderIcon(config.provider, {
|
||||
className: "size-3 md:size-3.5 shrink-0",
|
||||
|
|
@ -533,34 +483,6 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
|||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Save / Reset Bar */}
|
||||
{hasChanges && (
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/50 p-3 md:p-4">
|
||||
<p className="text-xs md:text-sm text-muted-foreground">You have unsaved changes</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
disabled={isSaving}
|
||||
className="h-8 text-xs gap-1.5"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="h-8 text-xs gap-1.5"
|
||||
>
|
||||
<Save className="w-3 h-3" />
|
||||
{isSaving ? "Saving…" : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@
|
|||
import { useAtomValue } from "jotai";
|
||||
import {
|
||||
AlertCircle,
|
||||
Dot,
|
||||
Edit3,
|
||||
FileText,
|
||||
Info,
|
||||
MessageSquareQuote,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
Wand2,
|
||||
|
|
@ -151,7 +151,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
|||
onClick={openNewDialog}
|
||||
className="gap-2 bg-white text-black hover:bg-neutral-100 dark:bg-white dark:text-black dark:hover:bg-neutral-200"
|
||||
>
|
||||
Add LLM Model
|
||||
Add Model
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -251,29 +251,14 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
|||
<div className="space-y-4">
|
||||
{configs?.length === 0 ? (
|
||||
<div>
|
||||
<Card className="border-dashed border-2 border-muted-foreground/25">
|
||||
<Card className="border-0 bg-transparent shadow-none">
|
||||
<CardContent className="flex flex-col items-center justify-center py-10 md:py-16 text-center">
|
||||
<div className="rounded-full bg-gradient-to-br from-violet-500/10 to-purple-500/10 p-4 md:p-6 mb-4 md:mb-6">
|
||||
<Wand2 className="h-8 w-8 md:h-12 md:w-12 text-violet-600 dark:text-violet-400" />
|
||||
</div>
|
||||
<div className="space-y-2 mb-4 md:mb-6">
|
||||
<h3 className="text-lg md:text-xl font-semibold">No Configurations Yet</h3>
|
||||
<p className="text-xs md:text-sm text-muted-foreground max-w-sm">
|
||||
{canCreate
|
||||
? "Create your first AI configuration to customize how your agent responds"
|
||||
: "No AI configurations have been added to this space yet. Contact a space owner to add one."}
|
||||
</p>
|
||||
</div>
|
||||
{canCreate && (
|
||||
<Button
|
||||
onClick={openNewDialog}
|
||||
size="lg"
|
||||
className="gap-2 text-xs md:text-sm h-9 md:h-10"
|
||||
>
|
||||
<Plus className="h-3 w-3 md:h-4 md:w-4" />
|
||||
Create First Configuration
|
||||
</Button>
|
||||
)}
|
||||
<h3 className="text-sm md:text-base font-semibold mb-2">No Models Yet</h3>
|
||||
<p className="text-[11px] md:text-xs text-muted-foreground max-w-sm mb-4">
|
||||
{canCreate
|
||||
? "Add your first model to power document summarization, chat, and other agent capabilities"
|
||||
: "No models have been added to this space yet. Contact a space owner to add one"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
@ -380,7 +365,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
|||
</span>
|
||||
{member && (
|
||||
<>
|
||||
<span className="text-muted-foreground/30">·</span>
|
||||
<Dot className="h-4 w-4 text-muted-foreground/30" />
|
||||
<TooltipProvider>
|
||||
<Tooltip open={isDesktop ? undefined : false}>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -436,7 +421,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
|||
>
|
||||
<AlertDialogContent className="select-none">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete LLM Model</AlertDialogTitle>
|
||||
<AlertDialogTitle>Delete Model</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete{" "}
|
||||
<span className="font-semibold text-foreground">{configToDelete?.name}</span>? This
|
||||
|
|
|
|||
|
|
@ -1,33 +1,41 @@
|
|||
"use client";
|
||||
|
||||
import { useAtom } from "jotai";
|
||||
import { CheckCircle2, FileType, FolderOpen, Info, Upload, X } from "lucide-react";
|
||||
import { ChevronDown, Dot, File as FileIcon, FolderOpen, Upload, X } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { type ChangeEvent, useCallback, useMemo, useRef, useState } from "react";
|
||||
import { type ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { toast } from "sonner";
|
||||
import { uploadDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
|
||||
import { SummaryConfig } from "@/components/assistant-ui/connector-popup/components/summary-config";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||
import {
|
||||
trackDocumentUploadFailure,
|
||||
trackDocumentUploadStarted,
|
||||
trackDocumentUploadSuccess,
|
||||
} from "@/lib/posthog/events";
|
||||
import { GridPattern } from "./GridPattern";
|
||||
|
||||
interface SelectedFolder {
|
||||
path: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface DocumentUploadTabProps {
|
||||
searchSpaceId: string;
|
||||
|
|
@ -113,11 +121,12 @@ interface FileWithId {
|
|||
file: File;
|
||||
}
|
||||
|
||||
const cardClass = "border border-border bg-slate-400/5 dark:bg-white/5";
|
||||
|
||||
const MAX_FILE_SIZE_MB = 500;
|
||||
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;
|
||||
|
||||
const toggleRowClass =
|
||||
"flex items-center justify-between rounded-lg bg-slate-400/5 dark:bg-white/5 p-3";
|
||||
|
||||
export function DocumentUploadTab({
|
||||
searchSpaceId,
|
||||
onSuccess,
|
||||
|
|
@ -132,6 +141,20 @@ export function DocumentUploadTab({
|
|||
const { mutate: uploadDocuments, isPending: isUploading } = uploadDocumentMutation;
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const folderInputRef = useRef<HTMLInputElement>(null);
|
||||
const progressIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (progressIntervalRef.current) {
|
||||
clearInterval(progressIntervalRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const [selectedFolder, setSelectedFolder] = useState<SelectedFolder | null>(null);
|
||||
const [watchFolder, setWatchFolder] = useState(true);
|
||||
const [folderSubmitting, setFolderSubmitting] = useState(false);
|
||||
const isElectron = typeof window !== "undefined" && !!window.electronAPI?.browseFiles;
|
||||
|
||||
const acceptedFileTypes = useMemo(() => {
|
||||
const etlService = process.env.NEXT_PUBLIC_ETL_SERVICE;
|
||||
|
|
@ -175,6 +198,7 @@ export function DocumentUploadTab({
|
|||
|
||||
const onDrop = useCallback(
|
||||
(acceptedFiles: File[]) => {
|
||||
setSelectedFolder(null);
|
||||
addFiles(acceptedFiles);
|
||||
},
|
||||
[addFiles]
|
||||
|
|
@ -184,13 +208,42 @@ export function DocumentUploadTab({
|
|||
onDrop,
|
||||
accept: acceptedFileTypes,
|
||||
maxSize: MAX_FILE_SIZE_BYTES,
|
||||
noClick: false,
|
||||
noClick: isElectron,
|
||||
});
|
||||
|
||||
const handleFileInputClick = useCallback((e: React.MouseEvent<HTMLInputElement>) => {
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleBrowseFiles = useCallback(async () => {
|
||||
const api = window.electronAPI;
|
||||
if (!api?.browseFiles) return;
|
||||
|
||||
const paths = await api.browseFiles();
|
||||
if (!paths || paths.length === 0) return;
|
||||
|
||||
setSelectedFolder(null);
|
||||
const fileDataList = await api.readLocalFiles(paths);
|
||||
const newFiles: FileWithId[] = fileDataList.map((fd) => ({
|
||||
id: crypto.randomUUID?.() ?? `file-${Date.now()}-${Math.random().toString(36)}`,
|
||||
file: new File([fd.data], fd.name, { type: fd.mimeType }),
|
||||
}));
|
||||
setFiles((prev) => [...prev, ...newFiles]);
|
||||
}, []);
|
||||
|
||||
const handleBrowseFolder = useCallback(async () => {
|
||||
const api = window.electronAPI;
|
||||
if (!api?.selectFolder) return;
|
||||
|
||||
const folderPath = await api.selectFolder();
|
||||
if (!folderPath) return;
|
||||
|
||||
const folderName = folderPath.split("/").pop() || folderPath.split("\\").pop() || folderPath;
|
||||
setFiles([]);
|
||||
setSelectedFolder({ path: folderPath, name: folderName });
|
||||
setWatchFolder(true);
|
||||
}, []);
|
||||
|
||||
const handleFolderChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const fileList = e.target.files;
|
||||
|
|
@ -223,7 +276,8 @@ export function DocumentUploadTab({
|
|||
|
||||
const totalFileSize = files.reduce((total, entry) => total + entry.file.size, 0);
|
||||
|
||||
// Track accordion state changes
|
||||
const hasContent = files.length > 0 || selectedFolder !== null;
|
||||
|
||||
const handleAccordionChange = useCallback(
|
||||
(value: string) => {
|
||||
setAccordionValue(value);
|
||||
|
|
@ -232,11 +286,59 @@ export function DocumentUploadTab({
|
|||
[onAccordionStateChange]
|
||||
);
|
||||
|
||||
const handleFolderSubmit = useCallback(async () => {
|
||||
if (!selectedFolder) return;
|
||||
const api = window.electronAPI;
|
||||
if (!api) return;
|
||||
|
||||
setFolderSubmitting(true);
|
||||
try {
|
||||
const numericSpaceId = Number(searchSpaceId);
|
||||
const result = await documentsApiService.folderIndex(numericSpaceId, {
|
||||
folder_path: selectedFolder.path,
|
||||
folder_name: selectedFolder.name,
|
||||
search_space_id: numericSpaceId,
|
||||
enable_summary: shouldSummarize,
|
||||
});
|
||||
|
||||
const rootFolderId = (result as { root_folder_id?: number })?.root_folder_id ?? null;
|
||||
|
||||
if (watchFolder) {
|
||||
await api.addWatchedFolder({
|
||||
path: selectedFolder.path,
|
||||
name: selectedFolder.name,
|
||||
excludePatterns: [
|
||||
".git",
|
||||
"node_modules",
|
||||
"__pycache__",
|
||||
".DS_Store",
|
||||
".obsidian",
|
||||
".trash",
|
||||
],
|
||||
fileExtensions: null,
|
||||
rootFolderId,
|
||||
searchSpaceId: Number(searchSpaceId),
|
||||
active: true,
|
||||
});
|
||||
toast.success(`Watching folder: ${selectedFolder.name}`);
|
||||
} else {
|
||||
toast.success(`Syncing folder: ${selectedFolder.name}`);
|
||||
}
|
||||
|
||||
setSelectedFolder(null);
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
toast.error((err as Error)?.message || "Failed to process folder");
|
||||
} finally {
|
||||
setFolderSubmitting(false);
|
||||
}
|
||||
}, [selectedFolder, watchFolder, searchSpaceId, shouldSummarize, onSuccess]);
|
||||
|
||||
const handleUpload = async () => {
|
||||
setUploadProgress(0);
|
||||
trackDocumentUploadStarted(Number(searchSpaceId), files.length, totalFileSize);
|
||||
|
||||
const progressInterval = setInterval(() => {
|
||||
progressIntervalRef.current = setInterval(() => {
|
||||
setUploadProgress((prev) => (prev >= 90 ? prev : prev + Math.random() * 10));
|
||||
}, 200);
|
||||
|
||||
|
|
@ -249,14 +351,14 @@ export function DocumentUploadTab({
|
|||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
clearInterval(progressInterval);
|
||||
if (progressIntervalRef.current) clearInterval(progressIntervalRef.current);
|
||||
setUploadProgress(100);
|
||||
trackDocumentUploadSuccess(Number(searchSpaceId), files.length);
|
||||
toast(t("upload_initiated"), { description: t("upload_initiated_desc") });
|
||||
onSuccess?.();
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
clearInterval(progressInterval);
|
||||
if (progressIntervalRef.current) clearInterval(progressIntervalRef.current);
|
||||
setUploadProgress(0);
|
||||
const message = error instanceof Error ? error.message : "Upload failed";
|
||||
trackDocumentUploadFailure(Number(searchSpaceId), message);
|
||||
|
|
@ -268,16 +370,83 @@ export function DocumentUploadTab({
|
|||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3 sm:space-y-6 max-w-4xl mx-auto pt-0">
|
||||
<Alert className="border border-border bg-slate-400/5 dark:bg-white/5">
|
||||
<Info className="h-4 w-4 shrink-0 mt-0.5" />
|
||||
<AlertDescription className="text-xs sm:text-sm leading-relaxed pt-0.5">
|
||||
{t("file_size_limit", { maxMB: MAX_FILE_SIZE_MB })} {t("upload_limits")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
const renderBrowseButton = (options?: { compact?: boolean; fullWidth?: boolean }) => {
|
||||
const { compact, fullWidth } = options ?? {};
|
||||
const sizeClass = compact ? "h-7" : "h-8";
|
||||
const widthClass = fullWidth ? "w-full" : "";
|
||||
|
||||
{/* Hidden folder input */}
|
||||
if (isElectron) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`text-xs gap-1 bg-neutral-700/50 hover:bg-neutral-600/50 ${sizeClass} ${widthClass}`}
|
||||
>
|
||||
Browse
|
||||
<ChevronDown className="h-3 w-3 opacity-60" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="center"
|
||||
className="dark:bg-neutral-800"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DropdownMenuItem onClick={handleBrowseFiles}>
|
||||
<FileIcon className="h-4 w-4 mr-2" />
|
||||
Files
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleBrowseFolder}>
|
||||
<FolderOpen className="h-4 w-4 mr-2" />
|
||||
Folder
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`text-xs gap-1 bg-neutral-700/50 hover:bg-neutral-600/50 ${sizeClass} ${widthClass}`}
|
||||
>
|
||||
Browse
|
||||
<ChevronDown className="h-3 w-3 opacity-60" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="center"
|
||||
className="dark:bg-neutral-800"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DropdownMenuItem onClick={() => fileInputRef.current?.click()}>
|
||||
<FileIcon className="h-4 w-4 mr-2" />
|
||||
{t("browse_files")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => folderInputRef.current?.click()}>
|
||||
<FolderOpen className="h-4 w-4 mr-2" />
|
||||
{t("browse_folder")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2 w-full mx-auto">
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
{...getInputProps()}
|
||||
ref={fileInputRef}
|
||||
className="hidden"
|
||||
onClick={handleFileInputClick}
|
||||
/>
|
||||
|
||||
{/* Hidden folder input for web folder browsing */}
|
||||
<input
|
||||
ref={folderInputRef}
|
||||
type="file"
|
||||
|
|
@ -287,187 +456,236 @@ export function DocumentUploadTab({
|
|||
{...({ webkitdirectory: "", directory: "" } as React.InputHTMLAttributes<HTMLInputElement>)}
|
||||
/>
|
||||
|
||||
<Card className={`relative overflow-hidden ${cardClass}`}>
|
||||
<div className="absolute inset-0 [mask-image:radial-gradient(ellipse_at_center,white,transparent)] opacity-30">
|
||||
<GridPattern />
|
||||
</div>
|
||||
<CardContent className="p-4 sm:p-10 relative z-10">
|
||||
{/* MOBILE DROP ZONE */}
|
||||
<div className="sm:hidden">
|
||||
{hasContent ? (
|
||||
!selectedFolder &&
|
||||
(isElectron ? (
|
||||
<div className="w-full">{renderBrowseButton({ compact: true, fullWidth: true })}</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-xs h-8 flex items-center justify-center gap-1.5 rounded-md border border-dashed border-muted-foreground/30 text-muted-foreground hover:text-foreground hover:border-foreground/50 transition-colors"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
Add more files
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className="flex flex-col items-center justify-center min-h-[200px] sm:min-h-[300px] border-2 border-dashed rounded-lg transition-colors border-border hover:border-primary/50 cursor-pointer"
|
||||
className="flex flex-col items-center gap-4 py-12 px-4 cursor-pointer"
|
||||
onClick={() => {
|
||||
if (!isElectron) fileInputRef.current?.click();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
{...getInputProps()}
|
||||
ref={fileInputRef}
|
||||
className="hidden"
|
||||
onClick={handleFileInputClick}
|
||||
/>
|
||||
{isDragActive ? (
|
||||
<div className="flex flex-col items-center gap-2 sm:gap-4">
|
||||
<Upload className="h-8 w-8 sm:h-12 sm:w-12 text-primary" />
|
||||
<p className="text-sm sm:text-lg font-medium text-primary">{t("drop_files")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-2 sm:gap-4">
|
||||
<Upload className="h-8 w-8 sm:h-12 sm:w-12 text-muted-foreground" />
|
||||
<div className="text-center">
|
||||
<p className="text-sm sm:text-lg font-medium">{t("drag_drop")}</p>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground mt-1">{t("or_browse")}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 sm:mt-4 flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="text-xs sm:text-sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
fileInputRef.current?.click();
|
||||
}}
|
||||
>
|
||||
{t("browse_files")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs sm:text-sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
folderInputRef.current?.click();
|
||||
}}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4 mr-1.5" />
|
||||
{t("browse_folder")}
|
||||
</Button>
|
||||
<Upload className="h-10 w-10 text-muted-foreground" />
|
||||
<div className="text-center space-y-1.5">
|
||||
<p className="text-base font-medium">
|
||||
{isElectron ? "Select files or folder" : "Tap to select files or folder"}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{t("file_size_limit")}</p>
|
||||
</div>
|
||||
<div className="w-full mt-1" onClick={(e) => e.stopPropagation()}>
|
||||
{renderBrowseButton({ fullWidth: true })}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{files.length > 0 && (
|
||||
<Card className={cardClass}>
|
||||
<CardHeader className="p-4 sm:p-6">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="text-base sm:text-2xl">
|
||||
{t("selected_files", { count: files.length })}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs sm:text-sm">
|
||||
{t("total_size")}: {formatFileSize(totalFileSize)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs sm:text-sm shrink-0"
|
||||
onClick={() => setFiles([])}
|
||||
disabled={isUploading}
|
||||
>
|
||||
{t("clear_all")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 sm:p-6 pt-0">
|
||||
<div className="space-y-2 sm:space-y-3 max-h-[250px] sm:max-h-[400px] overflow-y-auto">
|
||||
{files.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className={`flex items-center justify-between p-2 sm:p-4 rounded-lg border border-border ${cardClass} hover:bg-slate-400/10 dark:hover:bg-white/10 transition-colors`}
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<FileType className="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm sm:text-base font-medium truncate">{entry.file.name}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{formatFileSize(entry.file.size)}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{entry.file.type || "Unknown type"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setFiles((prev) => prev.filter((e) => e.id !== entry.id))}
|
||||
disabled={isUploading}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isUploading && (
|
||||
<div className="mt-3 sm:mt-6 space-y-2 sm:space-y-3">
|
||||
<Separator className="bg-border" />
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs sm:text-sm">
|
||||
<span>{t("uploading_files")}</span>
|
||||
<span>{Math.round(uploadProgress)}%</span>
|
||||
</div>
|
||||
<Progress value={uploadProgress} className="h-2" />
|
||||
</div>
|
||||
{/* DESKTOP DROP ZONE */}
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`hidden sm:block border-2 border-dashed rounded-lg transition-colors border-muted-foreground/30 hover:border-foreground/70 cursor-pointer ${hasContent ? "p-3" : "py-20 px-4"}`}
|
||||
>
|
||||
{hasContent ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<Upload className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-xs text-muted-foreground flex-1 truncate">
|
||||
{isDragActive ? t("drop_files") : t("drag_drop_more")}
|
||||
</span>
|
||||
{renderBrowseButton({ compact: true })}
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
{isDragActive && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2">
|
||||
<Upload className="h-8 w-8 text-primary" />
|
||||
<p className="text-sm font-medium text-primary">{t("drop_files")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 sm:mt-6">
|
||||
<SummaryConfig enabled={shouldSummarize} onEnabledChange={setShouldSummarize} />
|
||||
<div className={`flex flex-col items-center gap-2 ${isDragActive ? "invisible" : ""}`}>
|
||||
<Upload className="h-8 w-8 text-muted-foreground" />
|
||||
<p className="text-sm font-medium">{t("drag_drop")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("file_size_limit")}</p>
|
||||
<div className="mt-1">{renderBrowseButton()}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 sm:mt-6">
|
||||
<Button
|
||||
className="w-full py-3 sm:py-6 text-xs sm:text-base font-medium"
|
||||
onClick={handleUpload}
|
||||
disabled={isUploading || files.length === 0}
|
||||
>
|
||||
{isUploading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Spinner size="sm" />
|
||||
{t("uploading")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 sm:h-5 sm:w-5" />
|
||||
{t("upload_button", { count: files.length })}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
{/* FOLDER SELECTED (Electron only — web flattens folder contents into file list) */}
|
||||
{isElectron && selectedFolder && (
|
||||
<div className="rounded-lg border border-border p-3 space-y-2">
|
||||
<div className="flex items-center gap-2 py-1.5 px-2 -mx-1 rounded-md hover:bg-slate-400/5 dark:hover:bg-white/5 group">
|
||||
<FolderOpen className="h-4 w-4 text-primary shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium truncate">{selectedFolder.name}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{selectedFolder.path}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={() => setSelectedFolder(null)}
|
||||
disabled={folderSubmitting}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-slate-400/5 dark:bg-white/5 divide-y divide-border">
|
||||
<div className="flex items-center justify-between p-3">
|
||||
<div className="space-y-0.5">
|
||||
<p className="font-medium text-sm">Watch folder</p>
|
||||
<p className="text-xs text-muted-foreground">Auto-sync when files change</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="watch-folder-toggle"
|
||||
checked={watchFolder}
|
||||
onCheckedChange={setWatchFolder}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3">
|
||||
<div className="space-y-0.5">
|
||||
<p className="font-medium text-sm">Enable AI Summary</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Improves search quality but adds latency
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={shouldSummarize} onCheckedChange={setShouldSummarize} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full relative"
|
||||
onClick={handleFolderSubmit}
|
||||
disabled={folderSubmitting}
|
||||
>
|
||||
<span className={folderSubmitting ? "invisible" : ""}>
|
||||
{watchFolder ? "Sync & Watch for Changes" : "Sync Folder"}
|
||||
</span>
|
||||
{folderSubmitting && (
|
||||
<span className="absolute inset-0 flex items-center justify-center">
|
||||
<Spinner size="sm" />
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* FILES SELECTED */}
|
||||
{files.length > 0 && (
|
||||
<div className="rounded-lg border border-border p-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium">
|
||||
{t("selected_files", { count: files.length })}
|
||||
<Dot className="inline h-4 w-4" />
|
||||
{formatFileSize(totalFileSize)}
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setFiles([])}
|
||||
disabled={isUploading}
|
||||
>
|
||||
{t("clear_all")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[160px] sm:max-h-[200px] overflow-y-auto -mx-1">
|
||||
{files.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-slate-400/5 dark:hover:bg-white/5 group"
|
||||
>
|
||||
<span className="text-[10px] font-medium uppercase leading-none bg-muted px-1.5 py-0.5 rounded text-muted-foreground shrink-0">
|
||||
{entry.file.name.split(".").pop() || "?"}
|
||||
</span>
|
||||
<span className="text-sm truncate flex-1 min-w-0">{entry.file.name}</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{formatFileSize(entry.file.size)}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
onClick={() => setFiles((prev) => prev.filter((e) => e.id !== entry.id))}
|
||||
disabled={isUploading}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isUploading && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span>{t("uploading_files")}</span>
|
||||
<span>{Math.round(uploadProgress)}%</span>
|
||||
</div>
|
||||
<Progress value={uploadProgress} className="h-1.5" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={toggleRowClass}>
|
||||
<div className="space-y-0.5">
|
||||
<p className="font-medium text-sm">Enable AI Summary</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Improves search quality but adds latency
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={shouldSummarize} onCheckedChange={setShouldSummarize} />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleUpload}
|
||||
disabled={isUploading || files.length === 0}
|
||||
>
|
||||
{isUploading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Spinner size="sm" />
|
||||
{t("uploading")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-2">
|
||||
{t("upload_button", { count: files.length })}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SUPPORTED FORMATS */}
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
value={accordionValue}
|
||||
onValueChange={handleAccordionChange}
|
||||
className={`w-full ${cardClass} border border-border rounded-lg mb-0`}
|
||||
className="w-full mt-5"
|
||||
>
|
||||
<AccordionItem value="supported-file-types" className="border-0">
|
||||
<AccordionTrigger className="px-3 sm:px-6 py-3 sm:py-4 hover:no-underline !items-center [&>svg]:!translate-y-0">
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<div className="text-left min-w-0">
|
||||
<div className="font-semibold text-sm sm:text-base">
|
||||
{t("supported_file_types")}
|
||||
</div>
|
||||
<div className="text-xs sm:text-sm text-muted-foreground font-normal">
|
||||
{t("file_types_desc")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AccordionItem value="supported-file-types" className="border border-border rounded-lg">
|
||||
<AccordionTrigger className="px-3 py-2.5 hover:no-underline !items-center [&>svg]:!translate-y-0">
|
||||
<span className="text-xs sm:text-sm text-muted-foreground font-normal">
|
||||
{t("supported_file_types")}
|
||||
</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-3 sm:px-6 pb-3 sm:pb-6">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<AccordionContent className="px-3 pb-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{supportedExtensions.map((ext) => (
|
||||
<Badge key={ext} variant="outline" className="text-xs">
|
||||
<Badge key={ext} variant="outline" className="text-[10px] px-1.5 py-0">
|
||||
{ext}
|
||||
</Badge>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import { openSafeNavigationHref, resolveSafeNavigationHref } from "../shared/med
|
|||
import { cn, Popover, PopoverContent, PopoverTrigger } from "./_adapter";
|
||||
import { Citation } from "./citation";
|
||||
import type { CitationType, CitationVariant, SerializableCitation } from "./schema";
|
||||
import NextImage from 'next/image';
|
||||
|
||||
|
||||
const TYPE_ICONS: Record<CitationType, LucideIcon> = {
|
||||
webpage: Globe,
|
||||
|
|
@ -253,18 +255,18 @@ function OverflowItem({ citation, onClick }: OverflowItemProps) {
|
|||
className="group hover:bg-muted focus-visible:bg-muted flex w-full cursor-pointer items-center gap-2.5 rounded-md px-2 py-2 text-left transition-colors focus-visible:outline-none"
|
||||
>
|
||||
{citation.favicon ? (
|
||||
// biome-ignore lint/performance/noImgElement: external favicon from arbitrary domain — next/image requires remotePatterns config
|
||||
<img
|
||||
<NextImage
|
||||
src={citation.favicon}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width={16}
|
||||
height={16}
|
||||
className="bg-muted size-4 shrink-0 rounded object-cover"
|
||||
width={18}
|
||||
height={18}
|
||||
className="size-4.5 rounded-full object-cover"
|
||||
unoptimized={true}
|
||||
/>
|
||||
) : (
|
||||
<TypeIcon className="text-muted-foreground size-4 shrink-0" aria-hidden="true" />
|
||||
)}
|
||||
) : (
|
||||
<TypeIcon className="text-muted-foreground size-3" aria-hidden="true" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="group-hover:decoration-foreground/30 truncate text-sm font-medium group-hover:underline group-hover:underline-offset-2">
|
||||
{citation.title}
|
||||
|
|
@ -339,18 +341,18 @@ function StackedCitations({ id, citations, className, onNavigate }: StackedCitat
|
|||
style={{ zIndex: maxIcons - index }}
|
||||
>
|
||||
{citation.favicon ? (
|
||||
// biome-ignore lint/performance/noImgElement: external favicon from arbitrary domain — next/image requires remotePatterns config
|
||||
<img
|
||||
src={citation.favicon}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width={18}
|
||||
height={18}
|
||||
className="size-4.5 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<TypeIcon className="text-muted-foreground size-3" aria-hidden="true" />
|
||||
)}
|
||||
<NextImage
|
||||
src={citation.favicon}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width={18}
|
||||
height={18}
|
||||
className="size-4.5 rounded-full object-cover"
|
||||
unoptimized={true}
|
||||
/>
|
||||
) : (
|
||||
<TypeIcon className="text-muted-foreground size-3" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import * as React from "react";
|
|||
import { openSafeNavigationHref, sanitizeHref } from "../shared/media";
|
||||
import { cn, Popover, PopoverContent, PopoverTrigger } from "./_adapter";
|
||||
import type { CitationType, CitationVariant, SerializableCitation } from "./schema";
|
||||
import NextImage from 'next/image';
|
||||
|
||||
const FALLBACK_LOCALE = "en-US";
|
||||
|
||||
|
|
@ -114,18 +115,18 @@ export function Citation(props: CitationProps) {
|
|||
};
|
||||
|
||||
const iconElement = favicon ? (
|
||||
// biome-ignore lint/performance/noImgElement: external favicon from arbitrary domain — next/image requires remotePatterns config
|
||||
<img
|
||||
src={favicon}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width={14}
|
||||
height={14}
|
||||
className="bg-muted size-3.5 shrink-0 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<TypeIcon className="size-3.5 shrink-0 opacity-60" aria-hidden="true" />
|
||||
);
|
||||
<NextImage
|
||||
src={favicon}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width={16}
|
||||
height={16}
|
||||
className="bg-muted size-3.5 shrink-0 rounded object-cover"
|
||||
unoptimized={true}
|
||||
/>
|
||||
) : (
|
||||
<TypeIcon className="size-3.5 shrink-0 opacity-60" aria-hidden="true" />
|
||||
);
|
||||
|
||||
const { open, handleMouseEnter, handleMouseLeave } = useHoverPopover();
|
||||
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ function ContextMenuSubTrigger({
|
|||
data-slot="context-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -182,7 +182,7 @@ function DropdownMenuSubTrigger({
|
|||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-neutral-200 focus:text-accent-foreground dark:focus:bg-neutral-700 data-[state=open]:bg-neutral-200 data-[state=open]:text-accent-foreground dark:data-[state=open]:bg-neutral-700 [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-neutral-200 focus:text-accent-foreground dark:focus:bg-neutral-700 data-[state=open]:bg-neutral-200 data-[state=open]:text-accent-foreground dark:data-[state=open]:bg-neutral-700 [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ SurfSense uses [pytest](https://docs.pytest.org/) with two test layers: **unit**
|
|||
|
||||
- **PostgreSQL + pgvector** running locally (database `surfsense_test` will be used)
|
||||
- **`REGISTRATION_ENABLED=TRUE`** in your `.env` (this is the default)
|
||||
- A working LLM model with a valid API key in `global_llm_config.yaml` (for integration tests)
|
||||
- A working model with a valid API key in `global_llm_config.yaml` (for integration tests)
|
||||
|
||||
No Redis or Celery is required — integration tests use an inline task dispatcher.
|
||||
|
||||
|
|
|
|||
|
|
@ -126,6 +126,8 @@ export const getConnectorIcon = (connectorType: EnumConnectorName | string, clas
|
|||
return <Microscope {...iconProps} />;
|
||||
case "DEEPEST":
|
||||
return <Telescope {...iconProps} />;
|
||||
case "LOCAL_FOLDER_FILE":
|
||||
return null;
|
||||
default:
|
||||
return <Search {...iconProps} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ export interface LLMModel {
|
|||
contextWindow?: string;
|
||||
}
|
||||
|
||||
// Comprehensive LLM models database organized by provider
|
||||
// Comprehensive models database organized by provider
|
||||
export const LLM_MODELS: LLMModel[] = [
|
||||
// OpenAI
|
||||
{
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export const documentTypeEnum = z.enum([
|
|||
"BOOKSTACK_CONNECTOR",
|
||||
"CIRCLEBACK",
|
||||
"OBSIDIAN_CONNECTOR",
|
||||
"LOCAL_FOLDER_FILE",
|
||||
"SURFSENSE_DOCS",
|
||||
"NOTE",
|
||||
"COMPOSIO_GOOGLE_DRIVE_CONNECTOR",
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export const folder = z.object({
|
|||
created_by_id: z.string().nullable().optional(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
metadata: z.record(z.unknown()).nullable().optional(),
|
||||
});
|
||||
|
||||
export const folderCreateRequest = z.object({
|
||||
|
|
|
|||
|
|
@ -41,14 +41,14 @@ export const liteLLMProviderEnum = z.enum([
|
|||
export type LiteLLMProvider = z.infer<typeof liteLLMProviderEnum>;
|
||||
|
||||
/**
|
||||
* NewLLMConfig - combines LLM model settings with prompt configuration
|
||||
* NewLLMConfig - combines model settings with prompt configuration
|
||||
*/
|
||||
export const newLLMConfig = z.object({
|
||||
id: z.number(),
|
||||
name: z.string().max(100),
|
||||
description: z.string().max(500).nullable().optional(),
|
||||
|
||||
// LLM Model Configuration
|
||||
// Model Configuration
|
||||
provider: liteLLMProviderEnum,
|
||||
custom_provider: z.string().max(100).nullable().optional(),
|
||||
model_name: z.string().max(100),
|
||||
|
|
@ -148,7 +148,7 @@ export const globalNewLLMConfig = z.object({
|
|||
name: z.string(),
|
||||
description: z.string().nullable().optional(),
|
||||
|
||||
// LLM Model Configuration (no api_key)
|
||||
// Model Configuration (no api_key)
|
||||
provider: z.string(), // String because YAML doesn't enforce enum, "AUTO" for Auto mode
|
||||
custom_provider: z.string().nullable().optional(),
|
||||
model_name: z.string(),
|
||||
|
|
|
|||
153
surfsense_web/hooks/use-folder-sync.ts
Normal file
153
surfsense_web/hooks/use-folder-sync.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||
|
||||
interface FileChangedEvent {
|
||||
id: string;
|
||||
rootFolderId: number | null;
|
||||
searchSpaceId: number;
|
||||
folderPath: string;
|
||||
folderName: string;
|
||||
relativePath: string;
|
||||
fullPath: string;
|
||||
action: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
const DEBOUNCE_MS = 2000;
|
||||
const MAX_WAIT_MS = 10_000;
|
||||
const MAX_BATCH_SIZE = 50;
|
||||
|
||||
interface BatchItem {
|
||||
folderPath: string;
|
||||
folderName: string;
|
||||
searchSpaceId: number;
|
||||
rootFolderId: number | null;
|
||||
filePaths: string[];
|
||||
ackIds: string[];
|
||||
}
|
||||
|
||||
export function useFolderSync() {
|
||||
const queueRef = useRef<BatchItem[]>([]);
|
||||
const processingRef = useRef(false);
|
||||
const debounceTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||
const pendingByFolder = useRef<Map<string, BatchItem>>(new Map());
|
||||
const firstEventTime = useRef<Map<string, number>>(new Map());
|
||||
const isMountedRef = useRef(false);
|
||||
|
||||
async function processQueue() {
|
||||
if (processingRef.current) return;
|
||||
processingRef.current = true;
|
||||
while (queueRef.current.length > 0) {
|
||||
const batch = queueRef.current.shift()!;
|
||||
try {
|
||||
await documentsApiService.folderIndexFiles(batch.searchSpaceId, {
|
||||
folder_path: batch.folderPath,
|
||||
folder_name: batch.folderName,
|
||||
search_space_id: batch.searchSpaceId,
|
||||
target_file_paths: batch.filePaths,
|
||||
root_folder_id: batch.rootFolderId,
|
||||
});
|
||||
const api = typeof window !== "undefined" ? window.electronAPI : null;
|
||||
if (api?.acknowledgeFileEvents && batch.ackIds.length > 0) {
|
||||
await api.acknowledgeFileEvents(batch.ackIds);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[FolderSync] Failed to trigger batch re-index:", err);
|
||||
}
|
||||
}
|
||||
processingRef.current = false;
|
||||
}
|
||||
|
||||
function flushFolder(folderKey: string) {
|
||||
debounceTimers.current.delete(folderKey);
|
||||
firstEventTime.current.delete(folderKey);
|
||||
const pending = pendingByFolder.current.get(folderKey);
|
||||
if (!pending) return;
|
||||
pendingByFolder.current.delete(folderKey);
|
||||
|
||||
for (let i = 0; i < pending.filePaths.length; i += MAX_BATCH_SIZE) {
|
||||
queueRef.current.push({
|
||||
...pending,
|
||||
filePaths: pending.filePaths.slice(i, i + MAX_BATCH_SIZE),
|
||||
ackIds: i === 0 ? pending.ackIds : [],
|
||||
});
|
||||
}
|
||||
processQueue();
|
||||
}
|
||||
|
||||
function enqueueWithDebounce(event: FileChangedEvent) {
|
||||
const folderKey = event.folderPath;
|
||||
const existing = pendingByFolder.current.get(folderKey);
|
||||
|
||||
if (existing) {
|
||||
const pathSet = new Set(existing.filePaths);
|
||||
pathSet.add(event.fullPath);
|
||||
existing.filePaths = Array.from(pathSet);
|
||||
if (!existing.ackIds.includes(event.id)) {
|
||||
existing.ackIds.push(event.id);
|
||||
}
|
||||
} else {
|
||||
pendingByFolder.current.set(folderKey, {
|
||||
folderPath: event.folderPath,
|
||||
folderName: event.folderName,
|
||||
searchSpaceId: event.searchSpaceId,
|
||||
rootFolderId: event.rootFolderId,
|
||||
filePaths: [event.fullPath],
|
||||
ackIds: [event.id],
|
||||
});
|
||||
firstEventTime.current.set(folderKey, Date.now());
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - (firstEventTime.current.get(folderKey) ?? Date.now());
|
||||
if (elapsed >= MAX_WAIT_MS) {
|
||||
const existingTimeout = debounceTimers.current.get(folderKey);
|
||||
if (existingTimeout) clearTimeout(existingTimeout);
|
||||
flushFolder(folderKey);
|
||||
return;
|
||||
}
|
||||
|
||||
const existingTimeout = debounceTimers.current.get(folderKey);
|
||||
if (existingTimeout) clearTimeout(existingTimeout);
|
||||
|
||||
const timeout = setTimeout(() => flushFolder(folderKey), DEBOUNCE_MS);
|
||||
debounceTimers.current.set(folderKey, timeout);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
const api = typeof window !== "undefined" ? window.electronAPI : null;
|
||||
if (!api?.onFileChanged) {
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}
|
||||
|
||||
// Signal to main process that the renderer is ready to receive events
|
||||
api.signalRendererReady?.();
|
||||
|
||||
// Drain durable outbox first so events survive renderer startup gaps and restarts
|
||||
void api.getPendingFileEvents?.().then((pendingEvents) => {
|
||||
if (!isMountedRef.current || !pendingEvents?.length) return;
|
||||
for (const event of pendingEvents) {
|
||||
enqueueWithDebounce(event);
|
||||
}
|
||||
});
|
||||
|
||||
const cleanup = api.onFileChanged((event: FileChangedEvent) => {
|
||||
enqueueWithDebounce(event);
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
cleanup();
|
||||
for (const timeout of debounceTimers.current.values()) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
debounceTimers.current.clear();
|
||||
pendingByFolder.current.clear();
|
||||
firstEventTime.current.clear();
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
|
@ -40,6 +40,7 @@ import {
|
|||
uploadDocumentRequest,
|
||||
uploadDocumentResponse,
|
||||
} from "@/contracts/types/document.types";
|
||||
import { folderListResponse } from "@/contracts/types/folder.types";
|
||||
import { ValidationError } from "../error";
|
||||
import { baseApiService } from "./base-api.service";
|
||||
|
||||
|
|
@ -411,6 +412,54 @@ class DocumentsApiService {
|
|||
});
|
||||
};
|
||||
|
||||
listDocumentVersions = async (documentId: number) => {
|
||||
return baseApiService.get(`/api/v1/documents/${documentId}/versions`);
|
||||
};
|
||||
|
||||
getDocumentVersion = async (documentId: number, versionNumber: number) => {
|
||||
return baseApiService.get(`/api/v1/documents/${documentId}/versions/${versionNumber}`);
|
||||
};
|
||||
|
||||
restoreDocumentVersion = async (documentId: number, versionNumber: number) => {
|
||||
return baseApiService.post(`/api/v1/documents/${documentId}/versions/${versionNumber}/restore`);
|
||||
};
|
||||
|
||||
folderIndex = async (
|
||||
searchSpaceId: number,
|
||||
body: {
|
||||
folder_path: string;
|
||||
folder_name: string;
|
||||
search_space_id: number;
|
||||
exclude_patterns?: string[];
|
||||
file_extensions?: string[];
|
||||
root_folder_id?: number;
|
||||
enable_summary?: boolean;
|
||||
}
|
||||
) => {
|
||||
return baseApiService.post(`/api/v1/documents/folder-index`, undefined, { body });
|
||||
};
|
||||
|
||||
folderIndexFiles = async (
|
||||
searchSpaceId: number,
|
||||
body: {
|
||||
folder_path: string;
|
||||
folder_name: string;
|
||||
search_space_id: number;
|
||||
target_file_paths: string[];
|
||||
root_folder_id?: number | null;
|
||||
enable_summary?: boolean;
|
||||
}
|
||||
) => {
|
||||
return baseApiService.post(`/api/v1/documents/folder-index-files`, undefined, { body });
|
||||
};
|
||||
|
||||
getWatchedFolders = async (searchSpaceId: number) => {
|
||||
return baseApiService.get(
|
||||
`/api/v1/documents/watched-folders?search_space_id=${searchSpaceId}`,
|
||||
folderListResponse
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a document
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -85,6 +85,10 @@ class FoldersApiService {
|
|||
return baseApiService.delete(`/api/v1/folders/${folderId}`, folderDeleteResponse);
|
||||
};
|
||||
|
||||
stopWatching = async (folderId: number) => {
|
||||
return baseApiService.patch(`/api/v1/folders/${folderId}/watched`, undefined);
|
||||
};
|
||||
|
||||
moveDocument = async (documentId: number, request: DocumentMoveRequest) => {
|
||||
const parsed = documentMoveRequest.safeParse(request);
|
||||
if (!parsed.success) {
|
||||
|
|
|
|||
|
|
@ -147,7 +147,7 @@ class NewLLMConfigApiService {
|
|||
};
|
||||
|
||||
/**
|
||||
* Get the dynamic LLM model catalogue (sourced from OpenRouter API)
|
||||
* Get the dynamic model catalogue (sourced from OpenRouter API)
|
||||
*/
|
||||
getModels = async () => {
|
||||
return baseApiService.get(`/api/v1/models`, getModelListResponse);
|
||||
|
|
|
|||
|
|
@ -376,14 +376,14 @@
|
|||
"upload_documents": {
|
||||
"title": "Upload Documents",
|
||||
"subtitle": "Upload your files to make them searchable and accessible through AI-powered conversations.",
|
||||
"file_size_limit": "Maximum file size: {maxMB}MB per file.",
|
||||
"upload_limits": "Upload files or entire folders",
|
||||
"file_size_limit": "Maximum file size: 500MB per file",
|
||||
"drop_files": "Drop files or folders here",
|
||||
"drag_drop": "Drag & drop files or folders here",
|
||||
"or_browse": "or click to browse files and folders",
|
||||
"drag_drop_more": "Drop or browse to add more files",
|
||||
"or_browse": "or click to browse",
|
||||
"browse_files": "Browse Files",
|
||||
"browse_folder": "Browse Folder",
|
||||
"selected_files": "Selected Files ({count})",
|
||||
"selected_files": "{count} selected {count, plural, one {file} other {files}}",
|
||||
"total_size": "Total size",
|
||||
"clear_all": "Clear all",
|
||||
"uploading_files": "Uploading files",
|
||||
|
|
@ -394,7 +394,6 @@
|
|||
"upload_error": "Upload Error",
|
||||
"upload_error_desc": "Error uploading files",
|
||||
"supported_file_types": "Supported File Types",
|
||||
"file_types_desc": "These file types are supported based on your current ETL service configuration.",
|
||||
"file_too_large": "File Too Large",
|
||||
"file_too_large_desc": "\"{name}\" exceeds the {maxMB}MB per-file limit.",
|
||||
"no_supported_files_in_folder": "No supported file types found in the selected folder."
|
||||
|
|
@ -734,7 +733,7 @@
|
|||
"nav_general": "General",
|
||||
"nav_general_desc": "Name, description & basic info",
|
||||
"nav_agent_configs": "Agent Configs",
|
||||
"nav_agent_configs_desc": "LLM models with prompts & citations",
|
||||
"nav_agent_configs_desc": "Models with prompts & citations",
|
||||
"nav_role_assignments": "Role Assignments",
|
||||
"nav_role_assignments_desc": "Assign configs to agent roles",
|
||||
"nav_image_models": "Image Models",
|
||||
|
|
|
|||
|
|
@ -376,14 +376,14 @@
|
|||
"upload_documents": {
|
||||
"title": "Subir documentos",
|
||||
"subtitle": "Sube tus archivos para hacerlos buscables y accesibles a través de conversaciones con IA.",
|
||||
"file_size_limit": "Tamaño máximo de archivo: {maxMB} MB por archivo.",
|
||||
"upload_limits": "Sube archivos o carpetas enteras",
|
||||
"file_size_limit": "Tamaño máximo de archivo: 500 MB por archivo",
|
||||
"drop_files": "Suelta archivos o carpetas aquí",
|
||||
"drag_drop": "Arrastra y suelta archivos o carpetas aquí",
|
||||
"or_browse": "o haz clic para explorar archivos y carpetas",
|
||||
"drag_drop_more": "Suelta o explora para agregar más archivos",
|
||||
"or_browse": "o haz clic para explorar",
|
||||
"browse_files": "Explorar archivos",
|
||||
"browse_folder": "Explorar carpeta",
|
||||
"selected_files": "Archivos seleccionados ({count})",
|
||||
"selected_files": "{count} {count, plural, one {archivo seleccionado} other {archivos seleccionados}}",
|
||||
"total_size": "Tamaño total",
|
||||
"clear_all": "Limpiar todo",
|
||||
"uploading_files": "Subiendo archivos",
|
||||
|
|
@ -394,7 +394,6 @@
|
|||
"upload_error": "Error de subida",
|
||||
"upload_error_desc": "Error al subir archivos",
|
||||
"supported_file_types": "Tipos de archivo soportados",
|
||||
"file_types_desc": "Estos tipos de archivo son soportados según la configuración actual de tu servicio ETL.",
|
||||
"file_too_large": "Archivo demasiado grande",
|
||||
"file_too_large_desc": "\"{name}\" excede el límite de {maxMB} MB por archivo.",
|
||||
"no_supported_files_in_folder": "No se encontraron tipos de archivo compatibles en la carpeta seleccionada."
|
||||
|
|
|
|||
|
|
@ -376,14 +376,14 @@
|
|||
"upload_documents": {
|
||||
"title": "दस्तावेज़ अपलोड करें",
|
||||
"subtitle": "AI-संचालित बातचीत के माध्यम से अपनी फ़ाइलों को खोजने योग्य और सुलभ बनाने के लिए अपलोड करें।",
|
||||
"file_size_limit": "अधिकतम फ़ाइल आकार: प्रति फ़ाइल {maxMB}MB।",
|
||||
"upload_limits": "फ़ाइलें या पूरे फ़ोल्डर अपलोड करें",
|
||||
"file_size_limit": "अधिकतम फ़ाइल आकार: प्रति फ़ाइल 500MB",
|
||||
"drop_files": "फ़ाइलें या फ़ोल्डर यहां छोड़ें",
|
||||
"drag_drop": "फ़ाइलें या फ़ोल्डर यहां खींचें और छोड़ें",
|
||||
"or_browse": "या फ़ाइलें और फ़ोल्डर ब्राउज़ करने के लिए क्लिक करें",
|
||||
"drag_drop_more": "और फ़ाइलें जोड़ने के लिए छोड़ें या ब्राउज़ करें",
|
||||
"or_browse": "या ब्राउज़ करने के लिए क्लिक करें",
|
||||
"browse_files": "फ़ाइलें ब्राउज़ करें",
|
||||
"browse_folder": "फ़ोल्डर ब्राउज़ करें",
|
||||
"selected_files": "चयनित फ़ाइलें ({count})",
|
||||
"selected_files": "{count} चयनित {count, plural, one {फ़ाइल} other {फ़ाइलें}}",
|
||||
"total_size": "कुल आकार",
|
||||
"clear_all": "सभी साफ करें",
|
||||
"uploading_files": "फ़ाइलें अपलोड हो रही हैं",
|
||||
|
|
@ -394,7 +394,6 @@
|
|||
"upload_error": "अपलोड त्रुटि",
|
||||
"upload_error_desc": "फ़ाइलें अपलोड करने में त्रुटि",
|
||||
"supported_file_types": "समर्थित फ़ाइल प्रकार",
|
||||
"file_types_desc": "ये फ़ाइल प्रकार आपकी वर्तमान ETL सेवा कॉन्फ़िगरेशन के आधार पर समर्थित हैं।",
|
||||
"file_too_large": "फ़ाइल बहुत बड़ी है",
|
||||
"file_too_large_desc": "\"{name}\" प्रति फ़ाइल {maxMB}MB की सीमा से अधिक है।",
|
||||
"no_supported_files_in_folder": "चयनित फ़ोल्डर में कोई समर्थित फ़ाइल प्रकार नहीं मिला।"
|
||||
|
|
|
|||
|
|
@ -376,14 +376,14 @@
|
|||
"upload_documents": {
|
||||
"title": "Enviar documentos",
|
||||
"subtitle": "Envie seus arquivos para torná-los pesquisáveis e acessíveis através de conversas com IA.",
|
||||
"file_size_limit": "Tamanho máximo do arquivo: {maxMB} MB por arquivo.",
|
||||
"upload_limits": "Envie arquivos ou pastas inteiras",
|
||||
"file_size_limit": "Tamanho máximo do arquivo: 500 MB por arquivo",
|
||||
"drop_files": "Solte arquivos ou pastas aqui",
|
||||
"drag_drop": "Arraste e solte arquivos ou pastas aqui",
|
||||
"or_browse": "ou clique para navegar arquivos e pastas",
|
||||
"drag_drop_more": "Solte ou navegue para adicionar mais arquivos",
|
||||
"or_browse": "ou clique para navegar",
|
||||
"browse_files": "Navegar arquivos",
|
||||
"browse_folder": "Navegar pasta",
|
||||
"selected_files": "Arquivos selecionados ({count})",
|
||||
"selected_files": "{count} {count, plural, one {arquivo selecionado} other {arquivos selecionados}}",
|
||||
"total_size": "Tamanho total",
|
||||
"clear_all": "Limpar tudo",
|
||||
"uploading_files": "Enviando arquivos",
|
||||
|
|
@ -394,7 +394,6 @@
|
|||
"upload_error": "Erro no envio",
|
||||
"upload_error_desc": "Erro ao enviar arquivos",
|
||||
"supported_file_types": "Tipos de arquivo suportados",
|
||||
"file_types_desc": "Estes tipos de arquivo são suportados com base na configuração atual do seu serviço ETL.",
|
||||
"file_too_large": "Arquivo muito grande",
|
||||
"file_too_large_desc": "\"{name}\" excede o limite de {maxMB} MB por arquivo.",
|
||||
"no_supported_files_in_folder": "Nenhum tipo de arquivo suportado encontrado na pasta selecionada."
|
||||
|
|
|
|||
|
|
@ -360,14 +360,14 @@
|
|||
"upload_documents": {
|
||||
"title": "上传文档",
|
||||
"subtitle": "上传您的文件,使其可通过 AI 对话进行搜索和访问。",
|
||||
"file_size_limit": "最大文件大小:每个文件 {maxMB}MB。",
|
||||
"upload_limits": "上传文件或整个文件夹",
|
||||
"file_size_limit": "最大文件大小:每个文件 500MB",
|
||||
"drop_files": "将文件或文件夹拖放到此处",
|
||||
"drag_drop": "将文件或文件夹拖放到此处",
|
||||
"or_browse": "或点击浏览文件和文件夹",
|
||||
"drag_drop": "拖放文件或文件夹到这里",
|
||||
"drag_drop_more": "拖放或浏览以添加更多文件",
|
||||
"or_browse": "或点击浏览",
|
||||
"browse_files": "浏览文件",
|
||||
"browse_folder": "浏览文件夹",
|
||||
"selected_files": "已选择的文件 ({count})",
|
||||
"selected_files": "已选择 {count} 个文件",
|
||||
"total_size": "总大小",
|
||||
"clear_all": "全部清除",
|
||||
"uploading_files": "正在上传文件...",
|
||||
|
|
@ -378,7 +378,6 @@
|
|||
"upload_error": "上传错误",
|
||||
"upload_error_desc": "上传文件时出错",
|
||||
"supported_file_types": "支持的文件类型",
|
||||
"file_types_desc": "根据您当前的 ETL 服务配置支持这些文件类型。",
|
||||
"file_too_large": "文件过大",
|
||||
"file_too_large_desc": "\"{name}\" 超过了每个文件 {maxMB}MB 的限制。",
|
||||
"no_supported_files_in_folder": "所选文件夹中没有找到支持的文件类型。"
|
||||
|
|
|
|||
50
surfsense_web/types/window.d.ts
vendored
50
surfsense_web/types/window.d.ts
vendored
|
|
@ -1,5 +1,39 @@
|
|||
import type { PostHog } from "posthog-js";
|
||||
|
||||
interface WatchedFolderConfig {
|
||||
path: string;
|
||||
name: string;
|
||||
excludePatterns: string[];
|
||||
fileExtensions: string[] | null;
|
||||
rootFolderId: number | null;
|
||||
searchSpaceId: number;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
interface FolderSyncFileChangedEvent {
|
||||
id: string;
|
||||
rootFolderId: number | null;
|
||||
searchSpaceId: number;
|
||||
folderPath: string;
|
||||
folderName: string;
|
||||
relativePath: string;
|
||||
fullPath: string;
|
||||
action: "add" | "change" | "unlink";
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface FolderSyncWatcherReadyEvent {
|
||||
rootFolderId: number | null;
|
||||
folderPath: string;
|
||||
}
|
||||
|
||||
interface LocalFileData {
|
||||
name: string;
|
||||
data: ArrayBuffer;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface ElectronAPI {
|
||||
versions: {
|
||||
electron: string;
|
||||
|
|
@ -14,6 +48,22 @@ interface ElectronAPI {
|
|||
setQuickAskMode: (mode: string) => Promise<void>;
|
||||
getQuickAskMode: () => Promise<string>;
|
||||
replaceText: (text: string) => Promise<void>;
|
||||
// Folder sync
|
||||
selectFolder: () => Promise<string | null>;
|
||||
addWatchedFolder: (config: WatchedFolderConfig) => Promise<WatchedFolderConfig[]>;
|
||||
removeWatchedFolder: (folderPath: string) => Promise<WatchedFolderConfig[]>;
|
||||
getWatchedFolders: () => Promise<WatchedFolderConfig[]>;
|
||||
getWatcherStatus: () => Promise<{ path: string; active: boolean; watching: boolean }[]>;
|
||||
onFileChanged: (callback: (data: FolderSyncFileChangedEvent) => void) => () => void;
|
||||
onWatcherReady: (callback: (data: FolderSyncWatcherReadyEvent) => void) => () => void;
|
||||
pauseWatcher: () => Promise<void>;
|
||||
resumeWatcher: () => Promise<void>;
|
||||
signalRendererReady: () => Promise<void>;
|
||||
getPendingFileEvents: () => Promise<FolderSyncFileChangedEvent[]>;
|
||||
acknowledgeFileEvents: (eventIds: string[]) => Promise<{ acknowledged: number }>;
|
||||
// Browse files/folders via native dialogs
|
||||
browseFiles: () => Promise<string[] | null>;
|
||||
readLocalFiles: (paths: string[]) => Promise<LocalFileData[]>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue