Merge remote-tracking branch 'upstream/dev' into feat/page-limit-connectors

This commit is contained in:
Anish Sarkar 2026-04-04 03:08:27 +05:30
commit 0d2acc665d
96 changed files with 6157 additions and 775 deletions

View file

@ -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
View file

@ -0,0 +1,6 @@
{
"name": "SurfSense",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View file

@ -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")

View file

@ -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,

View file

@ -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

View file

@ -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"

View file

@ -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."

View file

@ -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

View file

@ -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),
}

View file

@ -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

View file

@ -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),
}

View file

@ -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.

View file

@ -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

View file

@ -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,

View file

@ -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)

View file

@ -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

View file

@ -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.

View file

@ -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)

View file

@ -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

View 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

View 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

View file

@ -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

View file

@ -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"
}

View file

@ -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

View file

@ -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;

View file

@ -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)
);
}

View file

@ -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();
});

View 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,
};
});
}

View file

@ -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),
});

View file

@ -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}
/>

View file

@ -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>

View file

@ -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>

View file

@ -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;
}

View file

@ -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",

View file

@ -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);

View file

@ -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

View file

@ -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>
)}

View file

@ -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");

View file

@ -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.
*/

View file

@ -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}
/>
);

View file

@ -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"

View file

@ -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;
}

View file

@ -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) => {

View file

@ -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";

View file

@ -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}

View file

@ -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

View file

@ -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>
);

View file

@ -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
)}

View file

@ -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}

View file

@ -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>
);

View file

@ -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>
</>

View file

@ -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}

View file

@ -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

View file

@ -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>
);
}

View 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>
</>
);
}

View file

@ -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 ? (

View file

@ -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>

View file

@ -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>

View file

@ -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(

View file

@ -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 {

View file

@ -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}

View file

@ -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);

View file

@ -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>
)}

View file

@ -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"

View file

@ -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} />

View file

@ -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>

View file

@ -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."}

View file

@ -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]);

View file

@ -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>

View file

@ -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>
);
}

View file

@ -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>

View file

@ -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>
);
}

View file

@ -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

View file

@ -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>
))}

View file

@ -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>
);
})}

View file

@ -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();

View file

@ -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}

View file

@ -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}

View file

@ -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.

View file

@ -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} />;
}

View file

@ -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
{

View file

@ -26,6 +26,7 @@ export const documentTypeEnum = z.enum([
"BOOKSTACK_CONNECTOR",
"CIRCLEBACK",
"OBSIDIAN_CONNECTOR",
"LOCAL_FOLDER_FILE",
"SURFSENSE_DOCS",
"NOTE",
"COMPOSIO_GOOGLE_DRIVE_CONNECTOR",

View file

@ -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({

View file

@ -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(),

View 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();
};
}, []);
}

View file

@ -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
*/

View file

@ -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) {

View file

@ -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);

View file

@ -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",

View file

@ -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."

View file

@ -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": "चयनित फ़ोल्डर में कोई समर्थित फ़ाइल प्रकार नहीं मिला।"

View file

@ -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."

View file

@ -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": "所选文件夹中没有找到支持的文件类型。"

View file

@ -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 {