mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-04 22:02:16 +02:00
Merge remote-tracking branch 'upstream/dev' into feat/azure-ocr
This commit is contained in:
commit
6038f6dfc0
84 changed files with 6041 additions and 1065 deletions
40
.github/workflows/desktop-release.yml
vendored
40
.github/workflows/desktop-release.yml
vendored
|
|
@ -5,6 +5,20 @@ on:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
- 'beta-v*'
|
- 'beta-v*'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: 'Version number (e.g. 0.0.15) — used for dry-run testing without a tag'
|
||||||
|
required: true
|
||||||
|
default: '0.0.0-test'
|
||||||
|
publish:
|
||||||
|
description: 'Publish to GitHub Releases'
|
||||||
|
required: true
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- never
|
||||||
|
- always
|
||||||
|
default: 'never'
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
@ -25,24 +39,28 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Extract version from tag
|
- name: Extract version
|
||||||
id: version
|
id: version
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
TAG=${GITHUB_REF#refs/tags/}
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
VERSION=${TAG#beta-}
|
VERSION="${{ inputs.version }}"
|
||||||
VERSION=${VERSION#v}
|
else
|
||||||
|
TAG=${GITHUB_REF#refs/tags/}
|
||||||
|
VERSION=${TAG#beta-}
|
||||||
|
VERSION=${VERSION#v}
|
||||||
|
fi
|
||||||
echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT"
|
echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v5
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 22
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
cache-dependency-path: |
|
cache-dependency-path: |
|
||||||
surfsense_web/pnpm-lock.yaml
|
surfsense_web/pnpm-lock.yaml
|
||||||
|
|
@ -60,6 +78,7 @@ jobs:
|
||||||
NEXT_PUBLIC_ZERO_CACHE_URL: ${{ vars.NEXT_PUBLIC_ZERO_CACHE_URL }}
|
NEXT_PUBLIC_ZERO_CACHE_URL: ${{ vars.NEXT_PUBLIC_ZERO_CACHE_URL }}
|
||||||
NEXT_PUBLIC_DEPLOYMENT_MODE: ${{ vars.NEXT_PUBLIC_DEPLOYMENT_MODE }}
|
NEXT_PUBLIC_DEPLOYMENT_MODE: ${{ vars.NEXT_PUBLIC_DEPLOYMENT_MODE }}
|
||||||
NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE: ${{ vars.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE }}
|
NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE: ${{ vars.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE }}
|
||||||
|
NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }}
|
||||||
|
|
||||||
- name: Install desktop dependencies
|
- name: Install desktop dependencies
|
||||||
run: pnpm install
|
run: pnpm install
|
||||||
|
|
@ -70,9 +89,12 @@ jobs:
|
||||||
working-directory: surfsense_desktop
|
working-directory: surfsense_desktop
|
||||||
env:
|
env:
|
||||||
HOSTED_FRONTEND_URL: ${{ vars.HOSTED_FRONTEND_URL }}
|
HOSTED_FRONTEND_URL: ${{ vars.HOSTED_FRONTEND_URL }}
|
||||||
|
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
|
||||||
|
POSTHOG_HOST: ${{ vars.POSTHOG_HOST }}
|
||||||
|
|
||||||
- name: Package & Publish
|
- name: Package & Publish
|
||||||
run: pnpm exec electron-builder ${{ matrix.platform }} --config electron-builder.yml --publish always -c.extraMetadata.version=${{ steps.version.outputs.VERSION }}
|
shell: bash
|
||||||
|
run: pnpm exec electron-builder ${{ matrix.platform }} --config electron-builder.yml --publish ${{ inputs.publish || 'always' }} -c.extraMetadata.version=${{ steps.version.outputs.VERSION }}
|
||||||
working-directory: surfsense_desktop
|
working-directory: surfsense_desktop
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
|
||||||
5
package.json
Normal file
5
package.json
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"name": "surfsense",
|
||||||
|
"private": true,
|
||||||
|
"packageManager": "pnpm@10.24.0"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,190 @@
|
||||||
|
"""Add vision LLM configs table and rename preference column
|
||||||
|
|
||||||
|
Revision ID: 120
|
||||||
|
Revises: 119
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
1. Create visionprovider enum type
|
||||||
|
2. Create vision_llm_configs table
|
||||||
|
3. Rename vision_llm_id -> vision_llm_config_id on searchspaces
|
||||||
|
4. Add vision config permissions to existing system roles
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM, UUID
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "120"
|
||||||
|
down_revision: str | None = "119"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
VISION_PROVIDER_VALUES = (
|
||||||
|
"OPENAI",
|
||||||
|
"ANTHROPIC",
|
||||||
|
"GOOGLE",
|
||||||
|
"AZURE_OPENAI",
|
||||||
|
"VERTEX_AI",
|
||||||
|
"BEDROCK",
|
||||||
|
"XAI",
|
||||||
|
"OPENROUTER",
|
||||||
|
"OLLAMA",
|
||||||
|
"GROQ",
|
||||||
|
"TOGETHER_AI",
|
||||||
|
"FIREWORKS_AI",
|
||||||
|
"DEEPSEEK",
|
||||||
|
"MISTRAL",
|
||||||
|
"CUSTOM",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
connection = op.get_bind()
|
||||||
|
|
||||||
|
# 1. Create visionprovider enum
|
||||||
|
connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'visionprovider') THEN
|
||||||
|
CREATE TYPE visionprovider AS ENUM (
|
||||||
|
'OPENAI', 'ANTHROPIC', 'GOOGLE', 'AZURE_OPENAI', 'VERTEX_AI',
|
||||||
|
'BEDROCK', 'XAI', 'OPENROUTER', 'OLLAMA', 'GROQ',
|
||||||
|
'TOGETHER_AI', 'FIREWORKS_AI', 'DEEPSEEK', 'MISTRAL', 'CUSTOM'
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Create vision_llm_configs table
|
||||||
|
result = connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'vision_llm_configs')"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not result.scalar():
|
||||||
|
op.create_table(
|
||||||
|
"vision_llm_configs",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("name", sa.String(100), nullable=False),
|
||||||
|
sa.Column("description", sa.String(500), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"provider",
|
||||||
|
PG_ENUM(*VISION_PROVIDER_VALUES, name="visionprovider", create_type=False),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("custom_provider", sa.String(100), nullable=True),
|
||||||
|
sa.Column("model_name", sa.String(100), nullable=False),
|
||||||
|
sa.Column("api_key", sa.String(), nullable=False),
|
||||||
|
sa.Column("api_base", sa.String(500), nullable=True),
|
||||||
|
sa.Column("api_version", sa.String(50), nullable=True),
|
||||||
|
sa.Column("litellm_params", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("search_space_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("user_id", UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.TIMESTAMP(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["search_space_id"], ["searchspaces.id"], ondelete="CASCADE"
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["user_id"], ["user.id"], ondelete="CASCADE"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_vision_llm_configs_name "
|
||||||
|
"ON vision_llm_configs (name)"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_vision_llm_configs_search_space_id "
|
||||||
|
"ON vision_llm_configs (search_space_id)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Rename vision_llm_id -> vision_llm_config_id on searchspaces
|
||||||
|
existing_columns = [
|
||||||
|
col["name"] for col in sa.inspect(connection).get_columns("searchspaces")
|
||||||
|
]
|
||||||
|
if "vision_llm_id" in existing_columns and "vision_llm_config_id" not in existing_columns:
|
||||||
|
op.alter_column("searchspaces", "vision_llm_id", new_column_name="vision_llm_config_id")
|
||||||
|
elif "vision_llm_config_id" not in existing_columns:
|
||||||
|
op.add_column(
|
||||||
|
"searchspaces",
|
||||||
|
sa.Column("vision_llm_config_id", sa.Integer(), nullable=True, server_default="0"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Add vision config permissions to existing system roles
|
||||||
|
connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
UPDATE search_space_roles
|
||||||
|
SET permissions = array_cat(
|
||||||
|
permissions,
|
||||||
|
ARRAY['vision_configs:create', 'vision_configs:read']
|
||||||
|
)
|
||||||
|
WHERE is_system_role = true
|
||||||
|
AND name = 'Editor'
|
||||||
|
AND NOT ('vision_configs:create' = ANY(permissions))
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
UPDATE search_space_roles
|
||||||
|
SET permissions = array_cat(
|
||||||
|
permissions,
|
||||||
|
ARRAY['vision_configs:read']
|
||||||
|
)
|
||||||
|
WHERE is_system_role = true
|
||||||
|
AND name = 'Viewer'
|
||||||
|
AND NOT ('vision_configs:read' = ANY(permissions))
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
connection = op.get_bind()
|
||||||
|
|
||||||
|
# Remove permissions
|
||||||
|
connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
UPDATE search_space_roles
|
||||||
|
SET permissions = array_remove(
|
||||||
|
array_remove(
|
||||||
|
array_remove(permissions, 'vision_configs:create'),
|
||||||
|
'vision_configs:read'
|
||||||
|
),
|
||||||
|
'vision_configs:delete'
|
||||||
|
)
|
||||||
|
WHERE is_system_role = true
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Rename column back
|
||||||
|
existing_columns = [
|
||||||
|
col["name"] for col in sa.inspect(connection).get_columns("searchspaces")
|
||||||
|
]
|
||||||
|
if "vision_llm_config_id" in existing_columns:
|
||||||
|
op.alter_column("searchspaces", "vision_llm_config_id", new_column_name="vision_llm_id")
|
||||||
|
|
||||||
|
# Drop table and enum
|
||||||
|
op.execute("DROP INDEX IF EXISTS ix_vision_llm_configs_search_space_id")
|
||||||
|
op.execute("DROP INDEX IF EXISTS ix_vision_llm_configs_name")
|
||||||
|
op.execute("DROP TABLE IF EXISTS vision_llm_configs")
|
||||||
|
op.execute("DROP TYPE IF EXISTS visionprovider")
|
||||||
11
surfsense_backend/app/agents/autocomplete/__init__.py
Normal file
11
surfsense_backend/app/agents/autocomplete/__init__.py
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
"""Agent-based vision autocomplete with scoped filesystem exploration."""
|
||||||
|
|
||||||
|
from app.agents.autocomplete.autocomplete_agent import (
|
||||||
|
create_autocomplete_agent,
|
||||||
|
stream_autocomplete_agent,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"create_autocomplete_agent",
|
||||||
|
"stream_autocomplete_agent",
|
||||||
|
]
|
||||||
497
surfsense_backend/app/agents/autocomplete/autocomplete_agent.py
Normal file
497
surfsense_backend/app/agents/autocomplete/autocomplete_agent.py
Normal file
|
|
@ -0,0 +1,497 @@
|
||||||
|
"""Vision autocomplete agent with scoped filesystem exploration.
|
||||||
|
|
||||||
|
Converts the stateless single-shot vision autocomplete into an agent that
|
||||||
|
seeds a virtual filesystem from KB search results and lets the vision LLM
|
||||||
|
explore documents via ``ls``, ``read_file``, ``glob``, ``grep``, etc.
|
||||||
|
before generating the final completion.
|
||||||
|
|
||||||
|
Performance: KB search and agent graph compilation run in parallel so
|
||||||
|
the only sequential latency is KB-search (or agent compile, whichever is
|
||||||
|
slower) + the agent's LLM turns. There is no separate "query extraction"
|
||||||
|
LLM call — the window title is used directly as the KB search query.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from deepagents.graph import BASE_AGENT_PROMPT
|
||||||
|
from deepagents.middleware.patch_tool_calls import PatchToolCallsMiddleware
|
||||||
|
from langchain.agents import create_agent
|
||||||
|
from langchain_anthropic.middleware import AnthropicPromptCachingMiddleware
|
||||||
|
from langchain_core.language_models import BaseChatModel
|
||||||
|
from langchain_core.messages import AIMessage, ToolMessage
|
||||||
|
|
||||||
|
from app.agents.new_chat.middleware.filesystem import SurfSenseFilesystemMiddleware
|
||||||
|
from app.agents.new_chat.middleware.knowledge_search import (
|
||||||
|
build_scoped_filesystem,
|
||||||
|
search_knowledge_base,
|
||||||
|
)
|
||||||
|
from app.services.new_streaming_service import VercelStreamingService
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
KB_TOP_K = 10
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# System prompt
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
AUTOCOMPLETE_SYSTEM_PROMPT = """You are a smart writing assistant that analyzes the user's screen to draft or complete text.
|
||||||
|
|
||||||
|
You will receive a screenshot of the user's screen. Your PRIMARY source of truth is the screenshot itself — the visual context determines what to write.
|
||||||
|
|
||||||
|
Your job:
|
||||||
|
1. Analyze the ENTIRE screenshot to understand what the user is working on (email thread, chat conversation, document, code editor, form, etc.).
|
||||||
|
2. Identify the text area where the user will type.
|
||||||
|
3. Generate the text the user most likely wants to write based on the visual context.
|
||||||
|
|
||||||
|
You also have access to the user's knowledge base documents via filesystem tools. However:
|
||||||
|
- ONLY consult the knowledge base if the screenshot clearly involves a topic where your KB documents are DIRECTLY relevant (e.g., the user is writing about a specific project/topic that matches a document title).
|
||||||
|
- Do NOT explore documents just because they exist. Most autocomplete requests can be answered purely from the screenshot.
|
||||||
|
- If you do read a document, only incorporate information that is 100% relevant to what the user is typing RIGHT NOW. Do not add extra details, background, or tangential information from the KB.
|
||||||
|
- Keep your output SHORT — autocomplete should feel like a natural continuation, not an essay.
|
||||||
|
|
||||||
|
Key behavior:
|
||||||
|
- If the text area is EMPTY, draft a concise response or message based on what you see on screen (e.g., reply to an email, respond to a chat message, continue a document).
|
||||||
|
- If the text area already has text, continue it naturally — typically just a sentence or two.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Be CONCISE. Prefer a single paragraph or a few sentences. Autocomplete is a quick assist, not a full draft.
|
||||||
|
- Match the tone and formality of the surrounding context.
|
||||||
|
- If the screen shows code, write code. If it shows a casual chat, be casual. If it shows a formal email, be formal.
|
||||||
|
- Do NOT describe the screenshot or explain your reasoning.
|
||||||
|
- Do NOT cite or reference documents explicitly — just let the knowledge inform your writing naturally.
|
||||||
|
- If you cannot determine what to write, output an empty JSON array: []
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
You MUST provide exactly 3 different suggestion options. Each should be a distinct, plausible completion — vary the tone, detail level, or angle.
|
||||||
|
|
||||||
|
Return your suggestions as a JSON array of exactly 3 strings. Output ONLY the JSON array, nothing else — no markdown fences, no explanation, no commentary.
|
||||||
|
|
||||||
|
Example format:
|
||||||
|
["First suggestion text here.", "Second suggestion — a different take.", "Third option with another approach."]
|
||||||
|
|
||||||
|
## Filesystem Tools `ls`, `read_file`, `write_file`, `edit_file`, `glob`, `grep`
|
||||||
|
|
||||||
|
All file paths must start with a `/`.
|
||||||
|
- ls: list files and directories at a given path.
|
||||||
|
- read_file: read a file from the filesystem.
|
||||||
|
- write_file: create a temporary file in the session (not persisted).
|
||||||
|
- edit_file: edit a file in the session (not persisted for /documents/ files).
|
||||||
|
- glob: find files matching a pattern (e.g., "**/*.xml").
|
||||||
|
- grep: search for text within files.
|
||||||
|
|
||||||
|
## When to Use Filesystem Tools
|
||||||
|
|
||||||
|
BEFORE reaching for any tool, ask yourself: "Can I write a good completion purely from the screenshot?" If yes, just write it — do NOT explore the KB.
|
||||||
|
|
||||||
|
Only use tools when:
|
||||||
|
- The user is clearly writing about a specific topic that likely has detailed information in their KB.
|
||||||
|
- You need a specific fact, name, number, or reference that the screenshot doesn't provide.
|
||||||
|
|
||||||
|
When you do use tools, be surgical:
|
||||||
|
- Check the `ls` output first. If no document title looks relevant, stop — do not read files just to see what's there.
|
||||||
|
- If a title looks relevant, read only the `<chunk_index>` (first ~20 lines) and jump to matched chunks. Do not read entire documents.
|
||||||
|
- Extract only the specific information you need and move on to generating the completion.
|
||||||
|
|
||||||
|
## Reading Documents Efficiently
|
||||||
|
|
||||||
|
Documents are formatted as XML. Each document contains:
|
||||||
|
- `<document_metadata>` — title, type, URL, etc.
|
||||||
|
- `<chunk_index>` — a table of every chunk with its **line range** and a
|
||||||
|
`matched="true"` flag for chunks that matched the search query.
|
||||||
|
- `<document_content>` — the actual chunks in original document order.
|
||||||
|
|
||||||
|
**Workflow**: read the first ~20 lines to see the `<chunk_index>`, identify
|
||||||
|
chunks marked `matched="true"`, then use `read_file(path, offset=<start_line>,
|
||||||
|
limit=<lines>)` to jump directly to those sections."""
|
||||||
|
|
||||||
|
APP_CONTEXT_BLOCK = """
|
||||||
|
|
||||||
|
The user is currently working in "{app_name}" (window: "{window_title}"). Use this to understand the type of application and adapt your tone and format accordingly."""
|
||||||
|
|
||||||
|
|
||||||
|
def _build_autocomplete_system_prompt(app_name: str, window_title: str) -> str:
|
||||||
|
prompt = AUTOCOMPLETE_SYSTEM_PROMPT
|
||||||
|
if app_name:
|
||||||
|
prompt += APP_CONTEXT_BLOCK.format(app_name=app_name, window_title=window_title)
|
||||||
|
return prompt
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pre-compute KB filesystem (runs in parallel with agent compilation)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _KBResult:
|
||||||
|
"""Container for pre-computed KB filesystem results."""
|
||||||
|
|
||||||
|
__slots__ = ("files", "ls_ai_msg", "ls_tool_msg")
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
files: dict[str, Any] | None = None,
|
||||||
|
ls_ai_msg: AIMessage | None = None,
|
||||||
|
ls_tool_msg: ToolMessage | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.files = files
|
||||||
|
self.ls_ai_msg = ls_ai_msg
|
||||||
|
self.ls_tool_msg = ls_tool_msg
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_documents(self) -> bool:
|
||||||
|
return bool(self.files)
|
||||||
|
|
||||||
|
|
||||||
|
async def precompute_kb_filesystem(
|
||||||
|
search_space_id: int,
|
||||||
|
query: str,
|
||||||
|
top_k: int = KB_TOP_K,
|
||||||
|
) -> _KBResult:
|
||||||
|
"""Search the KB and build the scoped filesystem outside the agent.
|
||||||
|
|
||||||
|
This is designed to be called via ``asyncio.gather`` alongside agent
|
||||||
|
graph compilation so the two run concurrently.
|
||||||
|
"""
|
||||||
|
if not query:
|
||||||
|
return _KBResult()
|
||||||
|
|
||||||
|
try:
|
||||||
|
search_results = await search_knowledge_base(
|
||||||
|
query=query,
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
top_k=top_k,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not search_results:
|
||||||
|
return _KBResult()
|
||||||
|
|
||||||
|
new_files, _ = await build_scoped_filesystem(
|
||||||
|
documents=search_results,
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not new_files:
|
||||||
|
return _KBResult()
|
||||||
|
|
||||||
|
doc_paths = [
|
||||||
|
p
|
||||||
|
for p, v in new_files.items()
|
||||||
|
if p.startswith("/documents/") and v is not None
|
||||||
|
]
|
||||||
|
tool_call_id = f"auto_ls_{uuid.uuid4().hex[:12]}"
|
||||||
|
ai_msg = AIMessage(
|
||||||
|
content="",
|
||||||
|
tool_calls=[
|
||||||
|
{"name": "ls", "args": {"path": "/documents"}, "id": tool_call_id}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
tool_msg = ToolMessage(
|
||||||
|
content=str(doc_paths) if doc_paths else "No documents found.",
|
||||||
|
tool_call_id=tool_call_id,
|
||||||
|
)
|
||||||
|
return _KBResult(files=new_files, ls_ai_msg=ai_msg, ls_tool_msg=tool_msg)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"KB pre-computation failed, proceeding without KB", exc_info=True
|
||||||
|
)
|
||||||
|
return _KBResult()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Filesystem middleware — no save_document, no persistence
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class AutocompleteFilesystemMiddleware(SurfSenseFilesystemMiddleware):
|
||||||
|
"""Filesystem middleware for autocomplete — read-only exploration only.
|
||||||
|
|
||||||
|
Strips ``save_document`` (permanent KB persistence) and passes
|
||||||
|
``search_space_id=None`` so ``write_file`` / ``edit_file`` stay ephemeral.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__(search_space_id=None, created_by_id=None)
|
||||||
|
self.tools = [t for t in self.tools if t.name != "save_document"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Agent factory
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def _compile_agent(
|
||||||
|
llm: BaseChatModel,
|
||||||
|
app_name: str,
|
||||||
|
window_title: str,
|
||||||
|
) -> Any:
|
||||||
|
"""Compile the agent graph (CPU-bound, runs in a thread)."""
|
||||||
|
system_prompt = _build_autocomplete_system_prompt(app_name, window_title)
|
||||||
|
final_system_prompt = system_prompt + "\n\n" + BASE_AGENT_PROMPT
|
||||||
|
|
||||||
|
middleware = [
|
||||||
|
AutocompleteFilesystemMiddleware(),
|
||||||
|
PatchToolCallsMiddleware(),
|
||||||
|
AnthropicPromptCachingMiddleware(unsupported_model_behavior="ignore"),
|
||||||
|
]
|
||||||
|
|
||||||
|
agent = await asyncio.to_thread(
|
||||||
|
create_agent,
|
||||||
|
llm,
|
||||||
|
system_prompt=final_system_prompt,
|
||||||
|
tools=[],
|
||||||
|
middleware=middleware,
|
||||||
|
)
|
||||||
|
return agent.with_config({"recursion_limit": 200})
|
||||||
|
|
||||||
|
|
||||||
|
async def create_autocomplete_agent(
|
||||||
|
llm: BaseChatModel,
|
||||||
|
*,
|
||||||
|
search_space_id: int,
|
||||||
|
kb_query: str,
|
||||||
|
app_name: str = "",
|
||||||
|
window_title: str = "",
|
||||||
|
) -> tuple[Any, _KBResult]:
|
||||||
|
"""Create the autocomplete agent and pre-compute KB in parallel.
|
||||||
|
|
||||||
|
Returns ``(agent, kb_result)`` so the caller can inject the pre-computed
|
||||||
|
filesystem into the agent's initial state without any middleware delay.
|
||||||
|
"""
|
||||||
|
agent, kb = await asyncio.gather(
|
||||||
|
_compile_agent(llm, app_name, window_title),
|
||||||
|
precompute_kb_filesystem(search_space_id, kb_query),
|
||||||
|
)
|
||||||
|
return agent, kb
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# JSON suggestion parsing (with fallback)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_suggestions(raw: str) -> list[str]:
|
||||||
|
"""Extract a list of suggestion strings from the agent's output.
|
||||||
|
|
||||||
|
Tries, in order:
|
||||||
|
1. Direct ``json.loads``
|
||||||
|
2. Extract content between ```json ... ``` fences
|
||||||
|
3. Find the first ``[`` … ``]`` span
|
||||||
|
Falls back to wrapping the raw text as a single suggestion.
|
||||||
|
"""
|
||||||
|
text = raw.strip()
|
||||||
|
if not text:
|
||||||
|
return []
|
||||||
|
|
||||||
|
for candidate in _json_candidates(text):
|
||||||
|
try:
|
||||||
|
parsed = json.loads(candidate)
|
||||||
|
if isinstance(parsed, list) and all(isinstance(s, str) for s in parsed):
|
||||||
|
return [s for s in parsed if s.strip()]
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
return [text]
|
||||||
|
|
||||||
|
|
||||||
|
def _json_candidates(text: str) -> list[str]:
|
||||||
|
"""Yield candidate JSON strings from raw text."""
|
||||||
|
candidates = [text]
|
||||||
|
|
||||||
|
fence = re.search(r"```(?:json)?\s*\n?(.*?)```", text, re.DOTALL)
|
||||||
|
if fence:
|
||||||
|
candidates.append(fence.group(1).strip())
|
||||||
|
|
||||||
|
bracket = re.search(r"\[.*]", text, re.DOTALL)
|
||||||
|
if bracket:
|
||||||
|
candidates.append(bracket.group(0))
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Streaming helper
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def stream_autocomplete_agent(
|
||||||
|
agent: Any,
|
||||||
|
input_data: dict[str, Any],
|
||||||
|
streaming_service: VercelStreamingService,
|
||||||
|
*,
|
||||||
|
emit_message_start: bool = True,
|
||||||
|
) -> AsyncGenerator[str, None]:
|
||||||
|
"""Stream agent events as Vercel SSE, with thinking steps for tool calls.
|
||||||
|
|
||||||
|
When ``emit_message_start`` is False the caller has already sent the
|
||||||
|
``message_start`` event (e.g. to show preparation steps before the agent
|
||||||
|
runs).
|
||||||
|
"""
|
||||||
|
thread_id = uuid.uuid4().hex
|
||||||
|
config = {"configurable": {"thread_id": thread_id}}
|
||||||
|
|
||||||
|
text_buffer: list[str] = []
|
||||||
|
active_tool_depth = 0
|
||||||
|
thinking_step_counter = 0
|
||||||
|
tool_step_ids: dict[str, str] = {}
|
||||||
|
step_titles: dict[str, str] = {}
|
||||||
|
completed_step_ids: set[str] = set()
|
||||||
|
last_active_step_id: str | None = None
|
||||||
|
|
||||||
|
def next_thinking_step_id() -> str:
|
||||||
|
nonlocal thinking_step_counter
|
||||||
|
thinking_step_counter += 1
|
||||||
|
return f"autocomplete-step-{thinking_step_counter}"
|
||||||
|
|
||||||
|
def complete_current_step() -> str | None:
|
||||||
|
nonlocal last_active_step_id
|
||||||
|
if last_active_step_id and last_active_step_id not in completed_step_ids:
|
||||||
|
completed_step_ids.add(last_active_step_id)
|
||||||
|
title = step_titles.get(last_active_step_id, "Done")
|
||||||
|
event = streaming_service.format_thinking_step(
|
||||||
|
step_id=last_active_step_id,
|
||||||
|
title=title,
|
||||||
|
status="complete",
|
||||||
|
)
|
||||||
|
last_active_step_id = None
|
||||||
|
return event
|
||||||
|
return None
|
||||||
|
|
||||||
|
if emit_message_start:
|
||||||
|
yield streaming_service.format_message_start()
|
||||||
|
|
||||||
|
gen_step_id = next_thinking_step_id()
|
||||||
|
last_active_step_id = gen_step_id
|
||||||
|
step_titles[gen_step_id] = "Generating suggestions"
|
||||||
|
yield streaming_service.format_thinking_step(
|
||||||
|
step_id=gen_step_id,
|
||||||
|
title="Generating suggestions",
|
||||||
|
status="in_progress",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async for event in agent.astream_events(
|
||||||
|
input_data, config=config, version="v2"
|
||||||
|
):
|
||||||
|
event_type = event.get("event", "")
|
||||||
|
if event_type == "on_chat_model_stream":
|
||||||
|
if active_tool_depth > 0:
|
||||||
|
continue
|
||||||
|
if "surfsense:internal" in event.get("tags", []):
|
||||||
|
continue
|
||||||
|
chunk = event.get("data", {}).get("chunk")
|
||||||
|
if chunk and hasattr(chunk, "content"):
|
||||||
|
content = chunk.content
|
||||||
|
if content and isinstance(content, str):
|
||||||
|
text_buffer.append(content)
|
||||||
|
|
||||||
|
elif event_type == "on_chat_model_end":
|
||||||
|
if active_tool_depth > 0:
|
||||||
|
continue
|
||||||
|
if "surfsense:internal" in event.get("tags", []):
|
||||||
|
continue
|
||||||
|
output = event.get("data", {}).get("output")
|
||||||
|
if output and hasattr(output, "content"):
|
||||||
|
if getattr(output, "tool_calls", None):
|
||||||
|
continue
|
||||||
|
content = output.content
|
||||||
|
if content and isinstance(content, str) and not text_buffer:
|
||||||
|
text_buffer.append(content)
|
||||||
|
|
||||||
|
elif event_type == "on_tool_start":
|
||||||
|
active_tool_depth += 1
|
||||||
|
tool_name = event.get("name", "unknown_tool")
|
||||||
|
run_id = event.get("run_id", "")
|
||||||
|
tool_input = event.get("data", {}).get("input", {})
|
||||||
|
|
||||||
|
step_event = complete_current_step()
|
||||||
|
if step_event:
|
||||||
|
yield step_event
|
||||||
|
|
||||||
|
tool_step_id = next_thinking_step_id()
|
||||||
|
tool_step_ids[run_id] = tool_step_id
|
||||||
|
last_active_step_id = tool_step_id
|
||||||
|
|
||||||
|
title, items = _describe_tool_call(tool_name, tool_input)
|
||||||
|
step_titles[tool_step_id] = title
|
||||||
|
yield streaming_service.format_thinking_step(
|
||||||
|
step_id=tool_step_id,
|
||||||
|
title=title,
|
||||||
|
status="in_progress",
|
||||||
|
items=items,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif event_type == "on_tool_end":
|
||||||
|
active_tool_depth = max(0, active_tool_depth - 1)
|
||||||
|
run_id = event.get("run_id", "")
|
||||||
|
step_id = tool_step_ids.pop(run_id, None)
|
||||||
|
if step_id and step_id not in completed_step_ids:
|
||||||
|
completed_step_ids.add(step_id)
|
||||||
|
title = step_titles.get(step_id, "Done")
|
||||||
|
yield streaming_service.format_thinking_step(
|
||||||
|
step_id=step_id,
|
||||||
|
title=title,
|
||||||
|
status="complete",
|
||||||
|
)
|
||||||
|
if last_active_step_id == step_id:
|
||||||
|
last_active_step_id = None
|
||||||
|
|
||||||
|
step_event = complete_current_step()
|
||||||
|
if step_event:
|
||||||
|
yield step_event
|
||||||
|
|
||||||
|
raw_text = "".join(text_buffer)
|
||||||
|
suggestions = _parse_suggestions(raw_text)
|
||||||
|
|
||||||
|
yield streaming_service.format_data(
|
||||||
|
"suggestions", {"options": suggestions}
|
||||||
|
)
|
||||||
|
|
||||||
|
yield streaming_service.format_finish()
|
||||||
|
yield streaming_service.format_done()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Autocomplete agent streaming error: {e}", exc_info=True)
|
||||||
|
yield streaming_service.format_error("Autocomplete failed. Please try again.")
|
||||||
|
yield streaming_service.format_done()
|
||||||
|
|
||||||
|
|
||||||
|
def _describe_tool_call(tool_name: str, tool_input: Any) -> tuple[str, list[str]]:
|
||||||
|
"""Return a human-readable (title, items) for a tool call thinking step."""
|
||||||
|
inp = tool_input if isinstance(tool_input, dict) else {}
|
||||||
|
if tool_name == "ls":
|
||||||
|
path = inp.get("path", "/")
|
||||||
|
return "Listing files", [path]
|
||||||
|
if tool_name == "read_file":
|
||||||
|
fp = inp.get("file_path", "")
|
||||||
|
display = fp if len(fp) <= 80 else "…" + fp[-77:]
|
||||||
|
return "Reading file", [display]
|
||||||
|
if tool_name == "write_file":
|
||||||
|
fp = inp.get("file_path", "")
|
||||||
|
display = fp if len(fp) <= 80 else "…" + fp[-77:]
|
||||||
|
return "Writing file", [display]
|
||||||
|
if tool_name == "edit_file":
|
||||||
|
fp = inp.get("file_path", "")
|
||||||
|
display = fp if len(fp) <= 80 else "…" + fp[-77:]
|
||||||
|
return "Editing file", [display]
|
||||||
|
if tool_name == "glob":
|
||||||
|
pat = inp.get("pattern", "")
|
||||||
|
base = inp.get("path", "/")
|
||||||
|
return "Searching files", [f"{pat} in {base}"]
|
||||||
|
if tool_name == "grep":
|
||||||
|
pat = inp.get("pattern", "")
|
||||||
|
path = inp.get("path", "")
|
||||||
|
display_pat = pat[:60] + ("…" if len(pat) > 60 else "")
|
||||||
|
return "Searching content", [
|
||||||
|
f'"{display_pat}"' + (f" in {path}" if path else "")
|
||||||
|
]
|
||||||
|
return f"Using {tool_name}", []
|
||||||
|
|
@ -25,7 +25,12 @@ from app.agents.new_chat.checkpointer import (
|
||||||
close_checkpointer,
|
close_checkpointer,
|
||||||
setup_checkpointer_tables,
|
setup_checkpointer_tables,
|
||||||
)
|
)
|
||||||
from app.config import config, initialize_image_gen_router, initialize_llm_router
|
from app.config import (
|
||||||
|
config,
|
||||||
|
initialize_image_gen_router,
|
||||||
|
initialize_llm_router,
|
||||||
|
initialize_vision_llm_router,
|
||||||
|
)
|
||||||
from app.db import User, create_db_and_tables, get_async_session
|
from app.db import User, create_db_and_tables, get_async_session
|
||||||
from app.routes import router as crud_router
|
from app.routes import router as crud_router
|
||||||
from app.routes.auth_routes import router as auth_router
|
from app.routes.auth_routes import router as auth_router
|
||||||
|
|
@ -223,6 +228,7 @@ async def lifespan(app: FastAPI):
|
||||||
await setup_checkpointer_tables()
|
await setup_checkpointer_tables()
|
||||||
initialize_llm_router()
|
initialize_llm_router()
|
||||||
initialize_image_gen_router()
|
initialize_image_gen_router()
|
||||||
|
initialize_vision_llm_router()
|
||||||
try:
|
try:
|
||||||
await asyncio.wait_for(seed_surfsense_docs(), timeout=120)
|
await asyncio.wait_for(seed_surfsense_docs(), timeout=120)
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,15 @@ def init_worker(**kwargs):
|
||||||
This ensures the Auto mode (LiteLLM Router) is available for background tasks
|
This ensures the Auto mode (LiteLLM Router) is available for background tasks
|
||||||
like document summarization and image generation.
|
like document summarization and image generation.
|
||||||
"""
|
"""
|
||||||
from app.config import initialize_image_gen_router, initialize_llm_router
|
from app.config import (
|
||||||
|
initialize_image_gen_router,
|
||||||
|
initialize_llm_router,
|
||||||
|
initialize_vision_llm_router,
|
||||||
|
)
|
||||||
|
|
||||||
initialize_llm_router()
|
initialize_llm_router()
|
||||||
initialize_image_gen_router()
|
initialize_image_gen_router()
|
||||||
|
initialize_vision_llm_router()
|
||||||
|
|
||||||
|
|
||||||
# Get Celery configuration from environment
|
# Get Celery configuration from environment
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,44 @@ def load_global_image_gen_configs():
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def load_global_vision_llm_configs():
|
||||||
|
global_config_file = BASE_DIR / "app" / "config" / "global_llm_config.yaml"
|
||||||
|
|
||||||
|
if not global_config_file.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(global_config_file, encoding="utf-8") as f:
|
||||||
|
data = yaml.safe_load(f)
|
||||||
|
return data.get("global_vision_llm_configs", [])
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Failed to load global vision LLM configs: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def load_vision_llm_router_settings():
|
||||||
|
default_settings = {
|
||||||
|
"routing_strategy": "usage-based-routing",
|
||||||
|
"num_retries": 3,
|
||||||
|
"allowed_fails": 3,
|
||||||
|
"cooldown_time": 60,
|
||||||
|
}
|
||||||
|
|
||||||
|
global_config_file = BASE_DIR / "app" / "config" / "global_llm_config.yaml"
|
||||||
|
|
||||||
|
if not global_config_file.exists():
|
||||||
|
return default_settings
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(global_config_file, encoding="utf-8") as f:
|
||||||
|
data = yaml.safe_load(f)
|
||||||
|
settings = data.get("vision_llm_router_settings", {})
|
||||||
|
return {**default_settings, **settings}
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Failed to load vision LLM router settings: {e}")
|
||||||
|
return default_settings
|
||||||
|
|
||||||
|
|
||||||
def load_image_gen_router_settings():
|
def load_image_gen_router_settings():
|
||||||
"""
|
"""
|
||||||
Load router settings for image generation Auto mode from YAML file.
|
Load router settings for image generation Auto mode from YAML file.
|
||||||
|
|
@ -182,6 +220,29 @@ def initialize_image_gen_router():
|
||||||
print(f"Warning: Failed to initialize Image Generation Router: {e}")
|
print(f"Warning: Failed to initialize Image Generation Router: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def initialize_vision_llm_router():
|
||||||
|
vision_configs = load_global_vision_llm_configs()
|
||||||
|
router_settings = load_vision_llm_router_settings()
|
||||||
|
|
||||||
|
if not vision_configs:
|
||||||
|
print(
|
||||||
|
"Info: No global vision LLM configs found, "
|
||||||
|
"Vision LLM Auto mode will not be available"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.services.vision_llm_router_service import VisionLLMRouterService
|
||||||
|
|
||||||
|
VisionLLMRouterService.initialize(vision_configs, router_settings)
|
||||||
|
print(
|
||||||
|
f"Info: Vision LLM Router initialized with {len(vision_configs)} models "
|
||||||
|
f"(strategy: {router_settings.get('routing_strategy', 'usage-based-routing')})"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Failed to initialize Vision LLM Router: {e}")
|
||||||
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
# Check if ffmpeg is installed
|
# Check if ffmpeg is installed
|
||||||
if not is_ffmpeg_installed():
|
if not is_ffmpeg_installed():
|
||||||
|
|
@ -335,6 +396,12 @@ class Config:
|
||||||
# Router settings for Image Generation Auto mode
|
# Router settings for Image Generation Auto mode
|
||||||
IMAGE_GEN_ROUTER_SETTINGS = load_image_gen_router_settings()
|
IMAGE_GEN_ROUTER_SETTINGS = load_image_gen_router_settings()
|
||||||
|
|
||||||
|
# Global Vision LLM Configurations (optional)
|
||||||
|
GLOBAL_VISION_LLM_CONFIGS = load_global_vision_llm_configs()
|
||||||
|
|
||||||
|
# Router settings for Vision LLM Auto mode
|
||||||
|
VISION_LLM_ROUTER_SETTINGS = load_vision_llm_router_settings()
|
||||||
|
|
||||||
# Chonkie Configuration | Edit this to your needs
|
# Chonkie Configuration | Edit this to your needs
|
||||||
EMBEDDING_MODEL = os.getenv("EMBEDDING_MODEL")
|
EMBEDDING_MODEL = os.getenv("EMBEDDING_MODEL")
|
||||||
# Azure OpenAI credentials from environment variables
|
# Azure OpenAI credentials from environment variables
|
||||||
|
|
|
||||||
|
|
@ -263,6 +263,82 @@ global_image_generation_configs:
|
||||||
# rpm: 30
|
# rpm: 30
|
||||||
# litellm_params: {}
|
# litellm_params: {}
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Vision LLM Configuration
|
||||||
|
# =============================================================================
|
||||||
|
# These configurations power the vision autocomplete feature (screenshot analysis).
|
||||||
|
# Only vision-capable models should be used here (e.g. GPT-4o, Gemini Pro, Claude 3).
|
||||||
|
# Supported providers: OpenAI, Anthropic, Google, Azure OpenAI, Vertex AI, Bedrock,
|
||||||
|
# xAI, OpenRouter, Ollama, Groq, Together AI, Fireworks AI, DeepSeek, Mistral, Custom
|
||||||
|
#
|
||||||
|
# Auto mode (ID 0) uses LiteLLM Router for load balancing across all vision configs.
|
||||||
|
|
||||||
|
# Router Settings for Vision LLM Auto Mode
|
||||||
|
vision_llm_router_settings:
|
||||||
|
routing_strategy: "usage-based-routing"
|
||||||
|
num_retries: 3
|
||||||
|
allowed_fails: 3
|
||||||
|
cooldown_time: 60
|
||||||
|
|
||||||
|
global_vision_llm_configs:
|
||||||
|
# Example: OpenAI GPT-4o (recommended for vision)
|
||||||
|
- id: -1
|
||||||
|
name: "Global GPT-4o Vision"
|
||||||
|
description: "OpenAI's GPT-4o with strong vision capabilities"
|
||||||
|
provider: "OPENAI"
|
||||||
|
model_name: "gpt-4o"
|
||||||
|
api_key: "sk-your-openai-api-key-here"
|
||||||
|
api_base: ""
|
||||||
|
rpm: 500
|
||||||
|
tpm: 100000
|
||||||
|
litellm_params:
|
||||||
|
temperature: 0.3
|
||||||
|
max_tokens: 1000
|
||||||
|
|
||||||
|
# Example: Google Gemini 2.0 Flash
|
||||||
|
- id: -2
|
||||||
|
name: "Global Gemini 2.0 Flash"
|
||||||
|
description: "Google's fast vision model with large context"
|
||||||
|
provider: "GOOGLE"
|
||||||
|
model_name: "gemini-2.0-flash"
|
||||||
|
api_key: "your-google-ai-api-key-here"
|
||||||
|
api_base: ""
|
||||||
|
rpm: 1000
|
||||||
|
tpm: 200000
|
||||||
|
litellm_params:
|
||||||
|
temperature: 0.3
|
||||||
|
max_tokens: 1000
|
||||||
|
|
||||||
|
# Example: Anthropic Claude 3.5 Sonnet
|
||||||
|
- id: -3
|
||||||
|
name: "Global Claude 3.5 Sonnet Vision"
|
||||||
|
description: "Anthropic's Claude 3.5 Sonnet with vision support"
|
||||||
|
provider: "ANTHROPIC"
|
||||||
|
model_name: "claude-3-5-sonnet-20241022"
|
||||||
|
api_key: "sk-ant-your-anthropic-api-key-here"
|
||||||
|
api_base: ""
|
||||||
|
rpm: 1000
|
||||||
|
tpm: 100000
|
||||||
|
litellm_params:
|
||||||
|
temperature: 0.3
|
||||||
|
max_tokens: 1000
|
||||||
|
|
||||||
|
# Example: Azure OpenAI GPT-4o
|
||||||
|
# - id: -4
|
||||||
|
# name: "Global Azure GPT-4o Vision"
|
||||||
|
# description: "Azure-hosted GPT-4o for vision analysis"
|
||||||
|
# provider: "AZURE_OPENAI"
|
||||||
|
# model_name: "azure/gpt-4o-deployment"
|
||||||
|
# api_key: "your-azure-api-key-here"
|
||||||
|
# api_base: "https://your-resource.openai.azure.com"
|
||||||
|
# api_version: "2024-02-15-preview"
|
||||||
|
# rpm: 500
|
||||||
|
# tpm: 100000
|
||||||
|
# litellm_params:
|
||||||
|
# temperature: 0.3
|
||||||
|
# max_tokens: 1000
|
||||||
|
# base_model: "gpt-4o"
|
||||||
|
|
||||||
# Notes:
|
# Notes:
|
||||||
# - ID 0 is reserved for "Auto" mode - uses LiteLLM Router for load balancing
|
# - ID 0 is reserved for "Auto" mode - uses LiteLLM Router for load balancing
|
||||||
# - Use negative IDs to distinguish global configs from user configs (NewLLMConfig in DB)
|
# - Use negative IDs to distinguish global configs from user configs (NewLLMConfig in DB)
|
||||||
|
|
@ -283,3 +359,9 @@ global_image_generation_configs:
|
||||||
# - The router uses litellm.aimage_generation() for async image generation
|
# - The router uses litellm.aimage_generation() for async image generation
|
||||||
# - Only RPM (requests per minute) is relevant for image generation rate limiting.
|
# - Only RPM (requests per minute) is relevant for image generation rate limiting.
|
||||||
# TPM (tokens per minute) does not apply since image APIs are billed/rate-limited per request, not per token.
|
# TPM (tokens per minute) does not apply since image APIs are billed/rate-limited per request, not per token.
|
||||||
|
#
|
||||||
|
# VISION LLM NOTES:
|
||||||
|
# - Vision configs use the same ID scheme (negative for global, positive for user DB)
|
||||||
|
# - Only use vision-capable models (GPT-4o, Gemini, Claude 3, etc.)
|
||||||
|
# - Lower temperature (0.3) is recommended for accurate screenshot analysis
|
||||||
|
# - Lower max_tokens (1000) is sufficient since autocomplete produces short suggestions
|
||||||
|
|
|
||||||
23
surfsense_backend/app/config/vision_model_list_fallback.json
Normal file
23
surfsense_backend/app/config/vision_model_list_fallback.json
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
[
|
||||||
|
{"value": "gpt-4o", "label": "GPT-4o", "provider": "OPENAI", "context_window": "128K"},
|
||||||
|
{"value": "gpt-4o-mini", "label": "GPT-4o Mini", "provider": "OPENAI", "context_window": "128K"},
|
||||||
|
{"value": "gpt-4-turbo", "label": "GPT-4 Turbo", "provider": "OPENAI", "context_window": "128K"},
|
||||||
|
{"value": "claude-sonnet-4-20250514", "label": "Claude Sonnet 4", "provider": "ANTHROPIC", "context_window": "200K"},
|
||||||
|
{"value": "claude-3-7-sonnet-20250219", "label": "Claude 3.7 Sonnet", "provider": "ANTHROPIC", "context_window": "200K"},
|
||||||
|
{"value": "claude-3-5-sonnet-20241022", "label": "Claude 3.5 Sonnet", "provider": "ANTHROPIC", "context_window": "200K"},
|
||||||
|
{"value": "claude-3-opus-20240229", "label": "Claude 3 Opus", "provider": "ANTHROPIC", "context_window": "200K"},
|
||||||
|
{"value": "claude-3-haiku-20240307", "label": "Claude 3 Haiku", "provider": "ANTHROPIC", "context_window": "200K"},
|
||||||
|
{"value": "gemini-2.5-flash", "label": "Gemini 2.5 Flash", "provider": "GOOGLE", "context_window": "1M"},
|
||||||
|
{"value": "gemini-2.5-pro", "label": "Gemini 2.5 Pro", "provider": "GOOGLE", "context_window": "1M"},
|
||||||
|
{"value": "gemini-2.0-flash", "label": "Gemini 2.0 Flash", "provider": "GOOGLE", "context_window": "1M"},
|
||||||
|
{"value": "gemini-1.5-pro", "label": "Gemini 1.5 Pro", "provider": "GOOGLE", "context_window": "1M"},
|
||||||
|
{"value": "gemini-1.5-flash", "label": "Gemini 1.5 Flash", "provider": "GOOGLE", "context_window": "1M"},
|
||||||
|
{"value": "pixtral-large-latest", "label": "Pixtral Large", "provider": "MISTRAL", "context_window": "128K"},
|
||||||
|
{"value": "pixtral-12b-2409", "label": "Pixtral 12B", "provider": "MISTRAL", "context_window": "128K"},
|
||||||
|
{"value": "grok-2-vision-1212", "label": "Grok 2 Vision", "provider": "XAI", "context_window": "32K"},
|
||||||
|
{"value": "llava", "label": "LLaVA", "provider": "OLLAMA"},
|
||||||
|
{"value": "bakllava", "label": "BakLLaVA", "provider": "OLLAMA"},
|
||||||
|
{"value": "llava-llama3", "label": "LLaVA Llama 3", "provider": "OLLAMA"},
|
||||||
|
{"value": "llama-4-scout-17b-16e-instruct", "label": "Llama 4 Scout 17B", "provider": "GROQ", "context_window": "128K"},
|
||||||
|
{"value": "meta-llama/Llama-4-Scout-17B-16E-Instruct", "label": "Llama 4 Scout 17B", "provider": "TOGETHER_AI", "context_window": "128K"}
|
||||||
|
]
|
||||||
|
|
@ -260,6 +260,24 @@ class ImageGenProvider(StrEnum):
|
||||||
NSCALE = "NSCALE"
|
NSCALE = "NSCALE"
|
||||||
|
|
||||||
|
|
||||||
|
class VisionProvider(StrEnum):
|
||||||
|
OPENAI = "OPENAI"
|
||||||
|
ANTHROPIC = "ANTHROPIC"
|
||||||
|
GOOGLE = "GOOGLE"
|
||||||
|
AZURE_OPENAI = "AZURE_OPENAI"
|
||||||
|
VERTEX_AI = "VERTEX_AI"
|
||||||
|
BEDROCK = "BEDROCK"
|
||||||
|
XAI = "XAI"
|
||||||
|
OPENROUTER = "OPENROUTER"
|
||||||
|
OLLAMA = "OLLAMA"
|
||||||
|
GROQ = "GROQ"
|
||||||
|
TOGETHER_AI = "TOGETHER_AI"
|
||||||
|
FIREWORKS_AI = "FIREWORKS_AI"
|
||||||
|
DEEPSEEK = "DEEPSEEK"
|
||||||
|
MISTRAL = "MISTRAL"
|
||||||
|
CUSTOM = "CUSTOM"
|
||||||
|
|
||||||
|
|
||||||
class LogLevel(StrEnum):
|
class LogLevel(StrEnum):
|
||||||
DEBUG = "DEBUG"
|
DEBUG = "DEBUG"
|
||||||
INFO = "INFO"
|
INFO = "INFO"
|
||||||
|
|
@ -377,6 +395,11 @@ class Permission(StrEnum):
|
||||||
IMAGE_GENERATIONS_READ = "image_generations:read"
|
IMAGE_GENERATIONS_READ = "image_generations:read"
|
||||||
IMAGE_GENERATIONS_DELETE = "image_generations:delete"
|
IMAGE_GENERATIONS_DELETE = "image_generations:delete"
|
||||||
|
|
||||||
|
# Vision LLM Configs
|
||||||
|
VISION_CONFIGS_CREATE = "vision_configs:create"
|
||||||
|
VISION_CONFIGS_READ = "vision_configs:read"
|
||||||
|
VISION_CONFIGS_DELETE = "vision_configs:delete"
|
||||||
|
|
||||||
# Connectors
|
# Connectors
|
||||||
CONNECTORS_CREATE = "connectors:create"
|
CONNECTORS_CREATE = "connectors:create"
|
||||||
CONNECTORS_READ = "connectors:read"
|
CONNECTORS_READ = "connectors:read"
|
||||||
|
|
@ -445,6 +468,9 @@ DEFAULT_ROLE_PERMISSIONS = {
|
||||||
# Image Generations (create and read, no delete)
|
# Image Generations (create and read, no delete)
|
||||||
Permission.IMAGE_GENERATIONS_CREATE.value,
|
Permission.IMAGE_GENERATIONS_CREATE.value,
|
||||||
Permission.IMAGE_GENERATIONS_READ.value,
|
Permission.IMAGE_GENERATIONS_READ.value,
|
||||||
|
# Vision Configs (create and read, no delete)
|
||||||
|
Permission.VISION_CONFIGS_CREATE.value,
|
||||||
|
Permission.VISION_CONFIGS_READ.value,
|
||||||
# Connectors (no delete)
|
# Connectors (no delete)
|
||||||
Permission.CONNECTORS_CREATE.value,
|
Permission.CONNECTORS_CREATE.value,
|
||||||
Permission.CONNECTORS_READ.value,
|
Permission.CONNECTORS_READ.value,
|
||||||
|
|
@ -478,6 +504,8 @@ DEFAULT_ROLE_PERMISSIONS = {
|
||||||
Permission.VIDEO_PRESENTATIONS_READ.value,
|
Permission.VIDEO_PRESENTATIONS_READ.value,
|
||||||
# Image Generations (read only)
|
# Image Generations (read only)
|
||||||
Permission.IMAGE_GENERATIONS_READ.value,
|
Permission.IMAGE_GENERATIONS_READ.value,
|
||||||
|
# Vision Configs (read only)
|
||||||
|
Permission.VISION_CONFIGS_READ.value,
|
||||||
# Connectors (read only)
|
# Connectors (read only)
|
||||||
Permission.CONNECTORS_READ.value,
|
Permission.CONNECTORS_READ.value,
|
||||||
# Logs (read only)
|
# Logs (read only)
|
||||||
|
|
@ -1263,6 +1291,35 @@ class ImageGenerationConfig(BaseModel, TimestampMixin):
|
||||||
user = relationship("User", back_populates="image_generation_configs")
|
user = relationship("User", back_populates="image_generation_configs")
|
||||||
|
|
||||||
|
|
||||||
|
class VisionLLMConfig(BaseModel, TimestampMixin):
|
||||||
|
__tablename__ = "vision_llm_configs"
|
||||||
|
|
||||||
|
name = Column(String(100), nullable=False, index=True)
|
||||||
|
description = Column(String(500), nullable=True)
|
||||||
|
|
||||||
|
provider = Column(SQLAlchemyEnum(VisionProvider), nullable=False)
|
||||||
|
custom_provider = Column(String(100), nullable=True)
|
||||||
|
model_name = Column(String(100), nullable=False)
|
||||||
|
|
||||||
|
api_key = Column(String, nullable=False)
|
||||||
|
api_base = Column(String(500), nullable=True)
|
||||||
|
api_version = Column(String(50), nullable=True)
|
||||||
|
|
||||||
|
litellm_params = Column(JSON, nullable=True, default={})
|
||||||
|
|
||||||
|
search_space_id = Column(
|
||||||
|
Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
search_space = relationship(
|
||||||
|
"SearchSpace", back_populates="vision_llm_configs"
|
||||||
|
)
|
||||||
|
|
||||||
|
user_id = Column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
user = relationship("User", back_populates="vision_llm_configs")
|
||||||
|
|
||||||
|
|
||||||
class ImageGeneration(BaseModel, TimestampMixin):
|
class ImageGeneration(BaseModel, TimestampMixin):
|
||||||
"""
|
"""
|
||||||
Stores image generation requests and results using litellm.aimage_generation().
|
Stores image generation requests and results using litellm.aimage_generation().
|
||||||
|
|
@ -1351,7 +1408,7 @@ class SearchSpace(BaseModel, TimestampMixin):
|
||||||
image_generation_config_id = Column(
|
image_generation_config_id = Column(
|
||||||
Integer, nullable=True, default=0
|
Integer, nullable=True, default=0
|
||||||
) # For image generation, defaults to Auto mode
|
) # For image generation, defaults to Auto mode
|
||||||
vision_llm_id = Column(
|
vision_llm_config_id = Column(
|
||||||
Integer, nullable=True, default=0
|
Integer, nullable=True, default=0
|
||||||
) # For vision/screenshot analysis, defaults to Auto mode
|
) # For vision/screenshot analysis, defaults to Auto mode
|
||||||
|
|
||||||
|
|
@ -1432,6 +1489,12 @@ class SearchSpace(BaseModel, TimestampMixin):
|
||||||
order_by="ImageGenerationConfig.id",
|
order_by="ImageGenerationConfig.id",
|
||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
)
|
)
|
||||||
|
vision_llm_configs = relationship(
|
||||||
|
"VisionLLMConfig",
|
||||||
|
back_populates="search_space",
|
||||||
|
order_by="VisionLLMConfig.id",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
# RBAC relationships
|
# RBAC relationships
|
||||||
roles = relationship(
|
roles = relationship(
|
||||||
|
|
@ -1961,6 +2024,12 @@ if config.AUTH_TYPE == "GOOGLE":
|
||||||
passive_deletes=True,
|
passive_deletes=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
vision_llm_configs = relationship(
|
||||||
|
"VisionLLMConfig",
|
||||||
|
back_populates="user",
|
||||||
|
passive_deletes=True,
|
||||||
|
)
|
||||||
|
|
||||||
# User memories for personalized AI responses
|
# User memories for personalized AI responses
|
||||||
memories = relationship(
|
memories = relationship(
|
||||||
"UserMemory",
|
"UserMemory",
|
||||||
|
|
@ -2075,6 +2144,12 @@ else:
|
||||||
passive_deletes=True,
|
passive_deletes=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
vision_llm_configs = relationship(
|
||||||
|
"VisionLLMConfig",
|
||||||
|
back_populates="user",
|
||||||
|
passive_deletes=True,
|
||||||
|
)
|
||||||
|
|
||||||
# User memories for personalized AI responses
|
# User memories for personalized AI responses
|
||||||
memories = relationship(
|
memories = relationship(
|
||||||
"UserMemory",
|
"UserMemory",
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ from .stripe_routes import router as stripe_router
|
||||||
from .surfsense_docs_routes import router as surfsense_docs_router
|
from .surfsense_docs_routes import router as surfsense_docs_router
|
||||||
from .teams_add_connector_route import router as teams_add_connector_router
|
from .teams_add_connector_route import router as teams_add_connector_router
|
||||||
from .video_presentations_routes import router as video_presentations_router
|
from .video_presentations_routes import router as video_presentations_router
|
||||||
|
from .vision_llm_routes import router as vision_llm_router
|
||||||
from .youtube_routes import router as youtube_router
|
from .youtube_routes import router as youtube_router
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
@ -68,6 +69,7 @@ router.include_router(
|
||||||
) # Video presentation status and streaming
|
) # Video presentation status and streaming
|
||||||
router.include_router(reports_router) # Report CRUD and multi-format export
|
router.include_router(reports_router) # Report CRUD and multi-format export
|
||||||
router.include_router(image_generation_router) # Image generation via litellm
|
router.include_router(image_generation_router) # Image generation via litellm
|
||||||
|
router.include_router(vision_llm_router) # Vision LLM configs for screenshot analysis
|
||||||
router.include_router(search_source_connectors_router)
|
router.include_router(search_source_connectors_router)
|
||||||
router.include_router(google_calendar_add_connector_router)
|
router.include_router(google_calendar_add_connector_router)
|
||||||
router.include_router(google_gmail_add_connector_router)
|
router.include_router(google_gmail_add_connector_router)
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ from app.db import (
|
||||||
SearchSpaceMembership,
|
SearchSpaceMembership,
|
||||||
SearchSpaceRole,
|
SearchSpaceRole,
|
||||||
User,
|
User,
|
||||||
|
VisionLLMConfig,
|
||||||
get_async_session,
|
get_async_session,
|
||||||
get_default_roles_config,
|
get_default_roles_config,
|
||||||
)
|
)
|
||||||
|
|
@ -483,6 +484,63 @@ async def _get_image_gen_config_by_id(
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_vision_llm_config_by_id(
|
||||||
|
session: AsyncSession, config_id: int | None
|
||||||
|
) -> dict | None:
|
||||||
|
if config_id is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if config_id == 0:
|
||||||
|
return {
|
||||||
|
"id": 0,
|
||||||
|
"name": "Auto (Fastest)",
|
||||||
|
"description": "Automatically routes requests across available vision LLM providers",
|
||||||
|
"provider": "AUTO",
|
||||||
|
"model_name": "auto",
|
||||||
|
"is_global": True,
|
||||||
|
"is_auto_mode": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
if config_id < 0:
|
||||||
|
for cfg in config.GLOBAL_VISION_LLM_CONFIGS:
|
||||||
|
if cfg.get("id") == config_id:
|
||||||
|
return {
|
||||||
|
"id": cfg.get("id"),
|
||||||
|
"name": cfg.get("name"),
|
||||||
|
"description": cfg.get("description"),
|
||||||
|
"provider": cfg.get("provider"),
|
||||||
|
"custom_provider": cfg.get("custom_provider"),
|
||||||
|
"model_name": cfg.get("model_name"),
|
||||||
|
"api_base": cfg.get("api_base") or None,
|
||||||
|
"api_version": cfg.get("api_version") or None,
|
||||||
|
"litellm_params": cfg.get("litellm_params", {}),
|
||||||
|
"is_global": True,
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(VisionLLMConfig).filter(VisionLLMConfig.id == config_id)
|
||||||
|
)
|
||||||
|
db_config = result.scalars().first()
|
||||||
|
if db_config:
|
||||||
|
return {
|
||||||
|
"id": db_config.id,
|
||||||
|
"name": db_config.name,
|
||||||
|
"description": db_config.description,
|
||||||
|
"provider": db_config.provider.value if db_config.provider else None,
|
||||||
|
"custom_provider": db_config.custom_provider,
|
||||||
|
"model_name": db_config.model_name,
|
||||||
|
"api_base": db_config.api_base,
|
||||||
|
"api_version": db_config.api_version,
|
||||||
|
"litellm_params": db_config.litellm_params or {},
|
||||||
|
"created_at": db_config.created_at.isoformat()
|
||||||
|
if db_config.created_at
|
||||||
|
else None,
|
||||||
|
"search_space_id": db_config.search_space_id,
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/search-spaces/{search_space_id}/llm-preferences",
|
"/search-spaces/{search_space_id}/llm-preferences",
|
||||||
response_model=LLMPreferencesRead,
|
response_model=LLMPreferencesRead,
|
||||||
|
|
@ -522,17 +580,19 @@ async def get_llm_preferences(
|
||||||
image_generation_config = await _get_image_gen_config_by_id(
|
image_generation_config = await _get_image_gen_config_by_id(
|
||||||
session, search_space.image_generation_config_id
|
session, search_space.image_generation_config_id
|
||||||
)
|
)
|
||||||
vision_llm = await _get_llm_config_by_id(session, search_space.vision_llm_id)
|
vision_llm_config = await _get_vision_llm_config_by_id(
|
||||||
|
session, search_space.vision_llm_config_id
|
||||||
|
)
|
||||||
|
|
||||||
return LLMPreferencesRead(
|
return LLMPreferencesRead(
|
||||||
agent_llm_id=search_space.agent_llm_id,
|
agent_llm_id=search_space.agent_llm_id,
|
||||||
document_summary_llm_id=search_space.document_summary_llm_id,
|
document_summary_llm_id=search_space.document_summary_llm_id,
|
||||||
image_generation_config_id=search_space.image_generation_config_id,
|
image_generation_config_id=search_space.image_generation_config_id,
|
||||||
vision_llm_id=search_space.vision_llm_id,
|
vision_llm_config_id=search_space.vision_llm_config_id,
|
||||||
agent_llm=agent_llm,
|
agent_llm=agent_llm,
|
||||||
document_summary_llm=document_summary_llm,
|
document_summary_llm=document_summary_llm,
|
||||||
image_generation_config=image_generation_config,
|
image_generation_config=image_generation_config,
|
||||||
vision_llm=vision_llm,
|
vision_llm_config=vision_llm_config,
|
||||||
)
|
)
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|
@ -592,17 +652,19 @@ async def update_llm_preferences(
|
||||||
image_generation_config = await _get_image_gen_config_by_id(
|
image_generation_config = await _get_image_gen_config_by_id(
|
||||||
session, search_space.image_generation_config_id
|
session, search_space.image_generation_config_id
|
||||||
)
|
)
|
||||||
vision_llm = await _get_llm_config_by_id(session, search_space.vision_llm_id)
|
vision_llm_config = await _get_vision_llm_config_by_id(
|
||||||
|
session, search_space.vision_llm_config_id
|
||||||
|
)
|
||||||
|
|
||||||
return LLMPreferencesRead(
|
return LLMPreferencesRead(
|
||||||
agent_llm_id=search_space.agent_llm_id,
|
agent_llm_id=search_space.agent_llm_id,
|
||||||
document_summary_llm_id=search_space.document_summary_llm_id,
|
document_summary_llm_id=search_space.document_summary_llm_id,
|
||||||
image_generation_config_id=search_space.image_generation_config_id,
|
image_generation_config_id=search_space.image_generation_config_id,
|
||||||
vision_llm_id=search_space.vision_llm_id,
|
vision_llm_config_id=search_space.vision_llm_config_id,
|
||||||
agent_llm=agent_llm,
|
agent_llm=agent_llm,
|
||||||
document_summary_llm=document_summary_llm,
|
document_summary_llm=document_summary_llm,
|
||||||
image_generation_config=image_generation_config,
|
image_generation_config=image_generation_config,
|
||||||
vision_llm=vision_llm,
|
vision_llm_config=vision_llm_config,
|
||||||
)
|
)
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|
|
||||||
295
surfsense_backend/app/routes/vision_llm_routes.py
Normal file
295
surfsense_backend/app/routes/vision_llm_routes.py
Normal file
|
|
@ -0,0 +1,295 @@
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.config import config
|
||||||
|
from app.db import (
|
||||||
|
Permission,
|
||||||
|
User,
|
||||||
|
VisionLLMConfig,
|
||||||
|
get_async_session,
|
||||||
|
)
|
||||||
|
from app.schemas import (
|
||||||
|
GlobalVisionLLMConfigRead,
|
||||||
|
VisionLLMConfigCreate,
|
||||||
|
VisionLLMConfigRead,
|
||||||
|
VisionLLMConfigUpdate,
|
||||||
|
)
|
||||||
|
from app.services.vision_model_list_service import get_vision_model_list
|
||||||
|
from app.users import current_active_user
|
||||||
|
from app.utils.rbac import check_permission
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Vision Model Catalogue (from OpenRouter, filtered for image-input models)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class VisionModelListItem(BaseModel):
|
||||||
|
value: str
|
||||||
|
label: str
|
||||||
|
provider: str
|
||||||
|
context_window: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/vision-models", response_model=list[VisionModelListItem])
|
||||||
|
async def list_vision_models(
|
||||||
|
user: User = Depends(current_active_user),
|
||||||
|
):
|
||||||
|
"""Return vision-capable models sourced from OpenRouter (filtered by image input)."""
|
||||||
|
try:
|
||||||
|
return await get_vision_model_list()
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Failed to fetch vision model list")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Failed to fetch vision model list: {e!s}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Global Vision LLM Configs (from YAML)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/global-vision-llm-configs",
|
||||||
|
response_model=list[GlobalVisionLLMConfigRead],
|
||||||
|
)
|
||||||
|
async def get_global_vision_llm_configs(
|
||||||
|
user: User = Depends(current_active_user),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
global_configs = config.GLOBAL_VISION_LLM_CONFIGS
|
||||||
|
safe_configs = []
|
||||||
|
|
||||||
|
if global_configs and len(global_configs) > 0:
|
||||||
|
safe_configs.append(
|
||||||
|
{
|
||||||
|
"id": 0,
|
||||||
|
"name": "Auto (Fastest)",
|
||||||
|
"description": "Automatically routes across available vision LLM providers.",
|
||||||
|
"provider": "AUTO",
|
||||||
|
"custom_provider": None,
|
||||||
|
"model_name": "auto",
|
||||||
|
"api_base": None,
|
||||||
|
"api_version": None,
|
||||||
|
"litellm_params": {},
|
||||||
|
"is_global": True,
|
||||||
|
"is_auto_mode": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for cfg in global_configs:
|
||||||
|
safe_configs.append(
|
||||||
|
{
|
||||||
|
"id": cfg.get("id"),
|
||||||
|
"name": cfg.get("name"),
|
||||||
|
"description": cfg.get("description"),
|
||||||
|
"provider": cfg.get("provider"),
|
||||||
|
"custom_provider": cfg.get("custom_provider"),
|
||||||
|
"model_name": cfg.get("model_name"),
|
||||||
|
"api_base": cfg.get("api_base") or None,
|
||||||
|
"api_version": cfg.get("api_version") or None,
|
||||||
|
"litellm_params": cfg.get("litellm_params", {}),
|
||||||
|
"is_global": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return safe_configs
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Failed to fetch global vision LLM configs")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Failed to fetch configs: {e!s}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# VisionLLMConfig CRUD
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/vision-llm-configs", response_model=VisionLLMConfigRead)
|
||||||
|
async def create_vision_llm_config(
|
||||||
|
config_data: VisionLLMConfigCreate,
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
user: User = Depends(current_active_user),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
await check_permission(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
config_data.search_space_id,
|
||||||
|
Permission.VISION_CONFIGS_CREATE.value,
|
||||||
|
"You don't have permission to create vision LLM configs in this search space",
|
||||||
|
)
|
||||||
|
|
||||||
|
db_config = VisionLLMConfig(**config_data.model_dump(), user_id=user.id)
|
||||||
|
session.add(db_config)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(db_config)
|
||||||
|
return db_config
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
await session.rollback()
|
||||||
|
logger.exception("Failed to create VisionLLMConfig")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Failed to create config: {e!s}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/vision-llm-configs", response_model=list[VisionLLMConfigRead])
|
||||||
|
async def list_vision_llm_configs(
|
||||||
|
search_space_id: int,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
user: User = Depends(current_active_user),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
await check_permission(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
search_space_id,
|
||||||
|
Permission.VISION_CONFIGS_READ.value,
|
||||||
|
"You don't have permission to view vision LLM configs in this search space",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(VisionLLMConfig)
|
||||||
|
.filter(VisionLLMConfig.search_space_id == search_space_id)
|
||||||
|
.order_by(VisionLLMConfig.created_at.desc())
|
||||||
|
.offset(skip)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Failed to list VisionLLMConfigs")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Failed to fetch configs: {e!s}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/vision-llm-configs/{config_id}", response_model=VisionLLMConfigRead
|
||||||
|
)
|
||||||
|
async def get_vision_llm_config(
|
||||||
|
config_id: int,
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
user: User = Depends(current_active_user),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
result = await session.execute(
|
||||||
|
select(VisionLLMConfig).filter(VisionLLMConfig.id == config_id)
|
||||||
|
)
|
||||||
|
db_config = result.scalars().first()
|
||||||
|
if not db_config:
|
||||||
|
raise HTTPException(status_code=404, detail="Config not found")
|
||||||
|
|
||||||
|
await check_permission(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
db_config.search_space_id,
|
||||||
|
Permission.VISION_CONFIGS_READ.value,
|
||||||
|
"You don't have permission to view vision LLM configs in this search space",
|
||||||
|
)
|
||||||
|
return db_config
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Failed to get VisionLLMConfig")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Failed to fetch config: {e!s}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/vision-llm-configs/{config_id}", response_model=VisionLLMConfigRead
|
||||||
|
)
|
||||||
|
async def update_vision_llm_config(
|
||||||
|
config_id: int,
|
||||||
|
update_data: VisionLLMConfigUpdate,
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
user: User = Depends(current_active_user),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
result = await session.execute(
|
||||||
|
select(VisionLLMConfig).filter(VisionLLMConfig.id == config_id)
|
||||||
|
)
|
||||||
|
db_config = result.scalars().first()
|
||||||
|
if not db_config:
|
||||||
|
raise HTTPException(status_code=404, detail="Config not found")
|
||||||
|
|
||||||
|
await check_permission(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
db_config.search_space_id,
|
||||||
|
Permission.VISION_CONFIGS_CREATE.value,
|
||||||
|
"You don't have permission to update vision LLM configs in this search space",
|
||||||
|
)
|
||||||
|
|
||||||
|
for key, value in update_data.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(db_config, key, value)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(db_config)
|
||||||
|
return db_config
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
await session.rollback()
|
||||||
|
logger.exception("Failed to update VisionLLMConfig")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Failed to update config: {e!s}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/vision-llm-configs/{config_id}", response_model=dict)
|
||||||
|
async def delete_vision_llm_config(
|
||||||
|
config_id: int,
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
user: User = Depends(current_active_user),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
result = await session.execute(
|
||||||
|
select(VisionLLMConfig).filter(VisionLLMConfig.id == config_id)
|
||||||
|
)
|
||||||
|
db_config = result.scalars().first()
|
||||||
|
if not db_config:
|
||||||
|
raise HTTPException(status_code=404, detail="Config not found")
|
||||||
|
|
||||||
|
await check_permission(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
db_config.search_space_id,
|
||||||
|
Permission.VISION_CONFIGS_DELETE.value,
|
||||||
|
"You don't have permission to delete vision LLM configs in this search space",
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.delete(db_config)
|
||||||
|
await session.commit()
|
||||||
|
return {
|
||||||
|
"message": "Vision LLM config deleted successfully",
|
||||||
|
"id": config_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
await session.rollback()
|
||||||
|
logger.exception("Failed to delete VisionLLMConfig")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Failed to delete config: {e!s}"
|
||||||
|
) from e
|
||||||
|
|
@ -125,6 +125,13 @@ from .video_presentations import (
|
||||||
VideoPresentationRead,
|
VideoPresentationRead,
|
||||||
VideoPresentationUpdate,
|
VideoPresentationUpdate,
|
||||||
)
|
)
|
||||||
|
from .vision_llm import (
|
||||||
|
GlobalVisionLLMConfigRead,
|
||||||
|
VisionLLMConfigCreate,
|
||||||
|
VisionLLMConfigPublic,
|
||||||
|
VisionLLMConfigRead,
|
||||||
|
VisionLLMConfigUpdate,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Folder schemas
|
# Folder schemas
|
||||||
|
|
@ -163,6 +170,8 @@ __all__ = [
|
||||||
"FolderUpdate",
|
"FolderUpdate",
|
||||||
"GlobalImageGenConfigRead",
|
"GlobalImageGenConfigRead",
|
||||||
"GlobalNewLLMConfigRead",
|
"GlobalNewLLMConfigRead",
|
||||||
|
# Vision LLM Config schemas
|
||||||
|
"GlobalVisionLLMConfigRead",
|
||||||
"GoogleDriveIndexRequest",
|
"GoogleDriveIndexRequest",
|
||||||
"GoogleDriveIndexingOptions",
|
"GoogleDriveIndexingOptions",
|
||||||
# Base schemas
|
# Base schemas
|
||||||
|
|
@ -264,4 +273,8 @@ __all__ = [
|
||||||
"VideoPresentationCreate",
|
"VideoPresentationCreate",
|
||||||
"VideoPresentationRead",
|
"VideoPresentationRead",
|
||||||
"VideoPresentationUpdate",
|
"VideoPresentationUpdate",
|
||||||
|
"VisionLLMConfigCreate",
|
||||||
|
"VisionLLMConfigPublic",
|
||||||
|
"VisionLLMConfigRead",
|
||||||
|
"VisionLLMConfigUpdate",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -182,8 +182,8 @@ class LLMPreferencesRead(BaseModel):
|
||||||
image_generation_config_id: int | None = Field(
|
image_generation_config_id: int | None = Field(
|
||||||
None, description="ID of the image generation config to use"
|
None, description="ID of the image generation config to use"
|
||||||
)
|
)
|
||||||
vision_llm_id: int | None = Field(
|
vision_llm_config_id: int | None = Field(
|
||||||
None, description="ID of the LLM config to use for vision/screenshot analysis"
|
None, description="ID of the vision LLM config to use for vision/screenshot analysis"
|
||||||
)
|
)
|
||||||
agent_llm: dict[str, Any] | None = Field(
|
agent_llm: dict[str, Any] | None = Field(
|
||||||
None, description="Full config for agent LLM"
|
None, description="Full config for agent LLM"
|
||||||
|
|
@ -194,7 +194,7 @@ class LLMPreferencesRead(BaseModel):
|
||||||
image_generation_config: dict[str, Any] | None = Field(
|
image_generation_config: dict[str, Any] | None = Field(
|
||||||
None, description="Full config for image generation"
|
None, description="Full config for image generation"
|
||||||
)
|
)
|
||||||
vision_llm: dict[str, Any] | None = Field(
|
vision_llm_config: dict[str, Any] | None = Field(
|
||||||
None, description="Full config for vision LLM"
|
None, description="Full config for vision LLM"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -213,6 +213,6 @@ class LLMPreferencesUpdate(BaseModel):
|
||||||
image_generation_config_id: int | None = Field(
|
image_generation_config_id: int | None = Field(
|
||||||
None, description="ID of the image generation config to use"
|
None, description="ID of the image generation config to use"
|
||||||
)
|
)
|
||||||
vision_llm_id: int | None = Field(
|
vision_llm_config_id: int | None = Field(
|
||||||
None, description="ID of the LLM config to use for vision/screenshot analysis"
|
None, description="ID of the vision LLM config to use for vision/screenshot analysis"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
75
surfsense_backend/app/schemas/vision_llm.py
Normal file
75
surfsense_backend/app/schemas/vision_llm.py
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from app.db import VisionProvider
|
||||||
|
|
||||||
|
|
||||||
|
class VisionLLMConfigBase(BaseModel):
|
||||||
|
name: str = Field(..., max_length=100)
|
||||||
|
description: str | None = Field(None, max_length=500)
|
||||||
|
provider: VisionProvider = Field(...)
|
||||||
|
custom_provider: str | None = Field(None, max_length=100)
|
||||||
|
model_name: str = Field(..., max_length=100)
|
||||||
|
api_key: str = Field(...)
|
||||||
|
api_base: str | None = Field(None, max_length=500)
|
||||||
|
api_version: str | None = Field(None, max_length=50)
|
||||||
|
litellm_params: dict[str, Any] | None = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
|
class VisionLLMConfigCreate(VisionLLMConfigBase):
|
||||||
|
search_space_id: int = Field(...)
|
||||||
|
|
||||||
|
|
||||||
|
class VisionLLMConfigUpdate(BaseModel):
|
||||||
|
name: str | None = Field(None, max_length=100)
|
||||||
|
description: str | None = Field(None, max_length=500)
|
||||||
|
provider: VisionProvider | None = None
|
||||||
|
custom_provider: str | None = Field(None, max_length=100)
|
||||||
|
model_name: str | None = Field(None, max_length=100)
|
||||||
|
api_key: str | None = None
|
||||||
|
api_base: str | None = Field(None, max_length=500)
|
||||||
|
api_version: str | None = Field(None, max_length=50)
|
||||||
|
litellm_params: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class VisionLLMConfigRead(VisionLLMConfigBase):
|
||||||
|
id: int
|
||||||
|
created_at: datetime
|
||||||
|
search_space_id: int
|
||||||
|
user_id: uuid.UUID
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class VisionLLMConfigPublic(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
provider: VisionProvider
|
||||||
|
custom_provider: str | None = None
|
||||||
|
model_name: str
|
||||||
|
api_base: str | None = None
|
||||||
|
api_version: str | None = None
|
||||||
|
litellm_params: dict[str, Any] | None = None
|
||||||
|
created_at: datetime
|
||||||
|
search_space_id: int
|
||||||
|
user_id: uuid.UUID
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class GlobalVisionLLMConfigRead(BaseModel):
|
||||||
|
id: int = Field(...)
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
provider: str
|
||||||
|
custom_provider: str | None = None
|
||||||
|
model_name: str
|
||||||
|
api_base: str | None = None
|
||||||
|
api_version: str | None = None
|
||||||
|
litellm_params: dict[str, Any] | None = None
|
||||||
|
is_global: bool = True
|
||||||
|
is_auto_mode: bool = False
|
||||||
|
|
@ -32,7 +32,6 @@ logger = logging.getLogger(__name__)
|
||||||
class LLMRole:
|
class LLMRole:
|
||||||
AGENT = "agent" # For agent/chat operations
|
AGENT = "agent" # For agent/chat operations
|
||||||
DOCUMENT_SUMMARY = "document_summary" # For document summarization
|
DOCUMENT_SUMMARY = "document_summary" # For document summarization
|
||||||
VISION = "vision" # For vision/screenshot analysis
|
|
||||||
|
|
||||||
|
|
||||||
def get_global_llm_config(llm_config_id: int) -> dict | None:
|
def get_global_llm_config(llm_config_id: int) -> dict | None:
|
||||||
|
|
@ -188,7 +187,7 @@ async def get_search_space_llm_instance(
|
||||||
Args:
|
Args:
|
||||||
session: Database session
|
session: Database session
|
||||||
search_space_id: Search Space ID
|
search_space_id: Search Space ID
|
||||||
role: LLM role ('agent', 'document_summary', or 'vision')
|
role: LLM role ('agent' or 'document_summary')
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
ChatLiteLLM or ChatLiteLLMRouter instance, or None if not found
|
ChatLiteLLM or ChatLiteLLMRouter instance, or None if not found
|
||||||
|
|
@ -210,8 +209,6 @@ async def get_search_space_llm_instance(
|
||||||
llm_config_id = search_space.agent_llm_id
|
llm_config_id = search_space.agent_llm_id
|
||||||
elif role == LLMRole.DOCUMENT_SUMMARY:
|
elif role == LLMRole.DOCUMENT_SUMMARY:
|
||||||
llm_config_id = search_space.document_summary_llm_id
|
llm_config_id = search_space.document_summary_llm_id
|
||||||
elif role == LLMRole.VISION:
|
|
||||||
llm_config_id = search_space.vision_llm_id
|
|
||||||
else:
|
else:
|
||||||
logger.error(f"Invalid LLM role: {role}")
|
logger.error(f"Invalid LLM role: {role}")
|
||||||
return None
|
return None
|
||||||
|
|
@ -411,8 +408,118 @@ async def get_document_summary_llm(
|
||||||
async def get_vision_llm(
|
async def get_vision_llm(
|
||||||
session: AsyncSession, search_space_id: int
|
session: AsyncSession, search_space_id: int
|
||||||
) -> ChatLiteLLM | ChatLiteLLMRouter | None:
|
) -> ChatLiteLLM | ChatLiteLLMRouter | None:
|
||||||
"""Get the search space's vision LLM instance for screenshot analysis."""
|
"""Get the search space's vision LLM instance for screenshot analysis.
|
||||||
return await get_search_space_llm_instance(session, search_space_id, LLMRole.VISION)
|
|
||||||
|
Resolves from the dedicated VisionLLMConfig system:
|
||||||
|
- Auto mode (ID 0): VisionLLMRouterService
|
||||||
|
- Global (negative ID): YAML configs
|
||||||
|
- DB (positive ID): VisionLLMConfig table
|
||||||
|
"""
|
||||||
|
from app.db import VisionLLMConfig
|
||||||
|
from app.services.vision_llm_router_service import (
|
||||||
|
VISION_PROVIDER_MAP,
|
||||||
|
VisionLLMRouterService,
|
||||||
|
get_global_vision_llm_config,
|
||||||
|
is_vision_auto_mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await session.execute(
|
||||||
|
select(SearchSpace).where(SearchSpace.id == search_space_id)
|
||||||
|
)
|
||||||
|
search_space = result.scalars().first()
|
||||||
|
if not search_space:
|
||||||
|
logger.error(f"Search space {search_space_id} not found")
|
||||||
|
return None
|
||||||
|
|
||||||
|
config_id = search_space.vision_llm_config_id
|
||||||
|
if config_id is None:
|
||||||
|
logger.error(
|
||||||
|
f"No vision LLM configured for search space {search_space_id}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if is_vision_auto_mode(config_id):
|
||||||
|
if not VisionLLMRouterService.is_initialized():
|
||||||
|
logger.error(
|
||||||
|
"Vision Auto mode requested but Vision LLM Router not initialized"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return ChatLiteLLMRouter(
|
||||||
|
router=VisionLLMRouterService.get_router(),
|
||||||
|
streaming=True,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to create vision ChatLiteLLMRouter: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if config_id < 0:
|
||||||
|
global_cfg = get_global_vision_llm_config(config_id)
|
||||||
|
if not global_cfg:
|
||||||
|
logger.error(f"Global vision LLM config {config_id} not found")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if global_cfg.get("custom_provider"):
|
||||||
|
model_string = (
|
||||||
|
f"{global_cfg['custom_provider']}/{global_cfg['model_name']}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
prefix = VISION_PROVIDER_MAP.get(
|
||||||
|
global_cfg["provider"].upper(),
|
||||||
|
global_cfg["provider"].lower(),
|
||||||
|
)
|
||||||
|
model_string = f"{prefix}/{global_cfg['model_name']}"
|
||||||
|
|
||||||
|
litellm_kwargs = {
|
||||||
|
"model": model_string,
|
||||||
|
"api_key": global_cfg["api_key"],
|
||||||
|
}
|
||||||
|
if global_cfg.get("api_base"):
|
||||||
|
litellm_kwargs["api_base"] = global_cfg["api_base"]
|
||||||
|
if global_cfg.get("litellm_params"):
|
||||||
|
litellm_kwargs.update(global_cfg["litellm_params"])
|
||||||
|
|
||||||
|
return ChatLiteLLM(**litellm_kwargs)
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(VisionLLMConfig).where(
|
||||||
|
VisionLLMConfig.id == config_id,
|
||||||
|
VisionLLMConfig.search_space_id == search_space_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
vision_cfg = result.scalars().first()
|
||||||
|
if not vision_cfg:
|
||||||
|
logger.error(
|
||||||
|
f"Vision LLM config {config_id} not found in search space {search_space_id}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if vision_cfg.custom_provider:
|
||||||
|
model_string = f"{vision_cfg.custom_provider}/{vision_cfg.model_name}"
|
||||||
|
else:
|
||||||
|
prefix = VISION_PROVIDER_MAP.get(
|
||||||
|
vision_cfg.provider.value.upper(),
|
||||||
|
vision_cfg.provider.value.lower(),
|
||||||
|
)
|
||||||
|
model_string = f"{prefix}/{vision_cfg.model_name}"
|
||||||
|
|
||||||
|
litellm_kwargs = {
|
||||||
|
"model": model_string,
|
||||||
|
"api_key": vision_cfg.api_key,
|
||||||
|
}
|
||||||
|
if vision_cfg.api_base:
|
||||||
|
litellm_kwargs["api_base"] = vision_cfg.api_base
|
||||||
|
if vision_cfg.litellm_params:
|
||||||
|
litellm_kwargs.update(vision_cfg.litellm_params)
|
||||||
|
|
||||||
|
return ChatLiteLLM(**litellm_kwargs)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Error getting vision LLM for search space {search_space_id}: {e!s}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
# Backward-compatible alias (LLM preferences are now per-search-space, not per-user)
|
# Backward-compatible alias (LLM preferences are now per-search-space, not per-user)
|
||||||
|
|
|
||||||
|
|
@ -1,149 +1,40 @@
|
||||||
|
"""Vision autocomplete service — agent-based with scoped filesystem.
|
||||||
|
|
||||||
|
Optimized pipeline:
|
||||||
|
1. Start the SSE stream immediately so the UI shows progress.
|
||||||
|
2. Derive a KB search query from window_title (no separate LLM call).
|
||||||
|
3. Run KB filesystem pre-computation and agent graph compilation in PARALLEL.
|
||||||
|
4. Inject pre-computed KB files as initial state and stream the agent.
|
||||||
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
|
|
||||||
from langchain_core.messages import HumanMessage, SystemMessage
|
from langchain_core.messages import HumanMessage
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.retriever.chunks_hybrid_search import ChucksHybridSearchRetriever
|
from app.agents.autocomplete import create_autocomplete_agent, stream_autocomplete_agent
|
||||||
from app.services.llm_service import get_vision_llm
|
from app.services.llm_service import get_vision_llm
|
||||||
from app.services.new_streaming_service import VercelStreamingService
|
from app.services.new_streaming_service import VercelStreamingService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
KB_TOP_K = 5
|
PREP_STEP_ID = "autocomplete-prep"
|
||||||
KB_MAX_CHARS = 4000
|
|
||||||
|
|
||||||
EXTRACT_QUERY_PROMPT = """Look at this screenshot and describe in 1-2 short sentences what the user is working on and what topic they need to write about. Be specific about the subject matter. Output ONLY the description, nothing else."""
|
|
||||||
|
|
||||||
EXTRACT_QUERY_PROMPT_WITH_APP = """The user is currently in the application "{app_name}" with the window titled "{window_title}".
|
|
||||||
|
|
||||||
Look at this screenshot and describe in 1-2 short sentences what the user is working on and what topic they need to write about. Be specific about the subject matter. Output ONLY the description, nothing else."""
|
|
||||||
|
|
||||||
VISION_SYSTEM_PROMPT = """You are a smart writing assistant that analyzes the user's screen to draft or complete text.
|
|
||||||
|
|
||||||
You will receive a screenshot of the user's screen. Your job:
|
|
||||||
1. Analyze the ENTIRE screenshot to understand what the user is working on (email thread, chat conversation, document, code editor, form, etc.).
|
|
||||||
2. Identify the text area where the user will type.
|
|
||||||
3. Based on the full visual context, generate the text the user most likely wants to write.
|
|
||||||
|
|
||||||
Key behavior:
|
|
||||||
- If the text area is EMPTY, draft a full response or message based on what you see on screen (e.g., reply to an email, respond to a chat message, continue a document).
|
|
||||||
- If the text area already has text, continue it naturally.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- Output ONLY the text to be inserted. No quotes, no explanations, no meta-commentary.
|
|
||||||
- Be concise but complete — a full thought, not a fragment.
|
|
||||||
- Match the tone and formality of the surrounding context.
|
|
||||||
- If the screen shows code, write code. If it shows a casual chat, be casual. If it shows a formal email, be formal.
|
|
||||||
- Do NOT describe the screenshot or explain your reasoning.
|
|
||||||
- If you cannot determine what to write, output nothing."""
|
|
||||||
|
|
||||||
APP_CONTEXT_BLOCK = """
|
|
||||||
|
|
||||||
The user is currently working in "{app_name}" (window: "{window_title}"). Use this to understand the type of application and adapt your tone and format accordingly."""
|
|
||||||
|
|
||||||
KB_CONTEXT_BLOCK = """
|
|
||||||
|
|
||||||
You also have access to the user's knowledge base documents below. Use them to write more accurate, informed, and contextually relevant text. Do NOT cite or reference the documents explicitly — just let the knowledge inform your writing naturally.
|
|
||||||
|
|
||||||
<knowledge_base>
|
|
||||||
{kb_context}
|
|
||||||
</knowledge_base>"""
|
|
||||||
|
|
||||||
|
|
||||||
def _build_system_prompt(app_name: str, window_title: str, kb_context: str) -> str:
|
def _derive_kb_query(app_name: str, window_title: str) -> str:
|
||||||
"""Assemble the system prompt from optional context blocks."""
|
parts = [p for p in (window_title, app_name) if p]
|
||||||
prompt = VISION_SYSTEM_PROMPT
|
return " ".join(parts)
|
||||||
if app_name:
|
|
||||||
prompt += APP_CONTEXT_BLOCK.format(app_name=app_name, window_title=window_title)
|
|
||||||
if kb_context:
|
|
||||||
prompt += KB_CONTEXT_BLOCK.format(kb_context=kb_context)
|
|
||||||
return prompt
|
|
||||||
|
|
||||||
|
|
||||||
def _is_vision_unsupported_error(e: Exception) -> bool:
|
def _is_vision_unsupported_error(e: Exception) -> bool:
|
||||||
"""Check if an exception indicates the model doesn't support vision/images."""
|
|
||||||
msg = str(e).lower()
|
msg = str(e).lower()
|
||||||
return "content must be a string" in msg or "does not support image" in msg
|
return "content must be a string" in msg or "does not support image" in msg
|
||||||
|
|
||||||
|
|
||||||
async def _extract_query_from_screenshot(
|
# ---------------------------------------------------------------------------
|
||||||
llm,
|
# Main entry point
|
||||||
screenshot_data_url: str,
|
# ---------------------------------------------------------------------------
|
||||||
app_name: str = "",
|
|
||||||
window_title: str = "",
|
|
||||||
) -> str | None:
|
|
||||||
"""Ask the Vision LLM to describe what the user is working on.
|
|
||||||
|
|
||||||
Raises vision-unsupported errors so the caller can return a
|
|
||||||
friendly message immediately instead of retrying with astream.
|
|
||||||
"""
|
|
||||||
if app_name:
|
|
||||||
prompt_text = EXTRACT_QUERY_PROMPT_WITH_APP.format(
|
|
||||||
app_name=app_name,
|
|
||||||
window_title=window_title,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
prompt_text = EXTRACT_QUERY_PROMPT
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = await llm.ainvoke(
|
|
||||||
[
|
|
||||||
HumanMessage(
|
|
||||||
content=[
|
|
||||||
{"type": "text", "text": prompt_text},
|
|
||||||
{
|
|
||||||
"type": "image_url",
|
|
||||||
"image_url": {"url": screenshot_data_url},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
query = response.content.strip() if hasattr(response, "content") else ""
|
|
||||||
return query if query else None
|
|
||||||
except Exception as e:
|
|
||||||
if _is_vision_unsupported_error(e):
|
|
||||||
raise
|
|
||||||
logger.warning(f"Failed to extract query from screenshot: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def _search_knowledge_base(
|
|
||||||
session: AsyncSession, search_space_id: int, query: str
|
|
||||||
) -> str:
|
|
||||||
"""Search the KB and return formatted context string."""
|
|
||||||
try:
|
|
||||||
retriever = ChucksHybridSearchRetriever(session)
|
|
||||||
results = await retriever.hybrid_search(
|
|
||||||
query_text=query,
|
|
||||||
top_k=KB_TOP_K,
|
|
||||||
search_space_id=search_space_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not results:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
parts: list[str] = []
|
|
||||||
char_count = 0
|
|
||||||
for doc in results:
|
|
||||||
title = doc.get("document", {}).get("title", "Untitled")
|
|
||||||
for chunk in doc.get("chunks", []):
|
|
||||||
content = chunk.get("content", "").strip()
|
|
||||||
if not content:
|
|
||||||
continue
|
|
||||||
entry = f"[{title}]\n{content}"
|
|
||||||
if char_count + len(entry) > KB_MAX_CHARS:
|
|
||||||
break
|
|
||||||
parts.append(entry)
|
|
||||||
char_count += len(entry)
|
|
||||||
if char_count >= KB_MAX_CHARS:
|
|
||||||
break
|
|
||||||
|
|
||||||
return "\n\n---\n\n".join(parts)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"KB search failed, proceeding without context: {e}")
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
async def stream_vision_autocomplete(
|
async def stream_vision_autocomplete(
|
||||||
|
|
@ -154,13 +45,7 @@ async def stream_vision_autocomplete(
|
||||||
app_name: str = "",
|
app_name: str = "",
|
||||||
window_title: str = "",
|
window_title: str = "",
|
||||||
) -> AsyncGenerator[str, None]:
|
) -> AsyncGenerator[str, None]:
|
||||||
"""Analyze a screenshot with the vision LLM and stream a text completion.
|
"""Analyze a screenshot with a vision-LLM agent and stream a text completion."""
|
||||||
|
|
||||||
Pipeline:
|
|
||||||
1. Extract a search query from the screenshot (non-streaming)
|
|
||||||
2. Search the knowledge base for relevant context
|
|
||||||
3. Stream the final completion with screenshot + KB + app context
|
|
||||||
"""
|
|
||||||
streaming = VercelStreamingService()
|
streaming = VercelStreamingService()
|
||||||
vision_error_msg = (
|
vision_error_msg = (
|
||||||
"The selected model does not support vision. "
|
"The selected model does not support vision. "
|
||||||
|
|
@ -174,71 +59,100 @@ async def stream_vision_autocomplete(
|
||||||
yield streaming.format_done()
|
yield streaming.format_done()
|
||||||
return
|
return
|
||||||
|
|
||||||
kb_context = ""
|
# Start SSE stream immediately so the UI has something to show
|
||||||
|
yield streaming.format_message_start()
|
||||||
|
|
||||||
|
kb_query = _derive_kb_query(app_name, window_title)
|
||||||
|
|
||||||
|
# Show a preparation step while KB search + agent compile run
|
||||||
|
yield streaming.format_thinking_step(
|
||||||
|
step_id=PREP_STEP_ID,
|
||||||
|
title="Searching knowledge base",
|
||||||
|
status="in_progress",
|
||||||
|
items=[kb_query] if kb_query else [],
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
query = await _extract_query_from_screenshot(
|
agent, kb = await create_autocomplete_agent(
|
||||||
llm,
|
llm,
|
||||||
screenshot_data_url,
|
search_space_id=search_space_id,
|
||||||
|
kb_query=kb_query,
|
||||||
app_name=app_name,
|
app_name=app_name,
|
||||||
window_title=window_title,
|
window_title=window_title,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
if _is_vision_unsupported_error(e):
|
||||||
f"Vision autocomplete: selected model does not support vision: {e}"
|
logger.warning("Vision autocomplete: model does not support vision: %s", e)
|
||||||
)
|
yield streaming.format_error(vision_error_msg)
|
||||||
yield streaming.format_message_start()
|
yield streaming.format_done()
|
||||||
yield streaming.format_error(vision_error_msg)
|
return
|
||||||
|
logger.error("Failed to create autocomplete agent: %s", e, exc_info=True)
|
||||||
|
yield streaming.format_error("Autocomplete failed. Please try again.")
|
||||||
yield streaming.format_done()
|
yield streaming.format_done()
|
||||||
return
|
return
|
||||||
|
|
||||||
if query:
|
has_kb = kb.has_documents
|
||||||
kb_context = await _search_knowledge_base(session, search_space_id, query)
|
doc_count = len(kb.files) if has_kb else 0 # type: ignore[arg-type]
|
||||||
|
|
||||||
system_prompt = _build_system_prompt(app_name, window_title, kb_context)
|
yield streaming.format_thinking_step(
|
||||||
|
step_id=PREP_STEP_ID,
|
||||||
|
title="Searching knowledge base",
|
||||||
|
status="complete",
|
||||||
|
items=[f"Found {doc_count} document{'s' if doc_count != 1 else ''}"]
|
||||||
|
if kb_query
|
||||||
|
else ["Skipped"],
|
||||||
|
)
|
||||||
|
|
||||||
messages = [
|
# Build agent input with pre-computed KB as initial state
|
||||||
SystemMessage(content=system_prompt),
|
if has_kb:
|
||||||
HumanMessage(
|
instruction = (
|
||||||
content=[
|
"Analyze this screenshot, then explore the knowledge base documents "
|
||||||
{
|
"listed above — read the chunk index of any document whose title "
|
||||||
"type": "text",
|
"looks relevant and check matched chunks for useful facts. "
|
||||||
"text": "Analyze this screenshot. Understand the full context of what the user is working on, then generate the text they most likely want to write in the active text area.",
|
"Finally, generate a concise autocomplete for the active text area, "
|
||||||
},
|
"enhanced with any relevant KB information you found."
|
||||||
{
|
)
|
||||||
"type": "image_url",
|
else:
|
||||||
"image_url": {"url": screenshot_data_url},
|
instruction = (
|
||||||
},
|
"Analyze this screenshot and generate a concise autocomplete "
|
||||||
]
|
"for the active text area based on what you see."
|
||||||
),
|
)
|
||||||
]
|
|
||||||
|
|
||||||
text_started = False
|
user_message = HumanMessage(
|
||||||
text_id = ""
|
content=[
|
||||||
|
{"type": "text", "text": instruction},
|
||||||
|
{"type": "image_url", "image_url": {"url": screenshot_data_url}},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
input_data: dict = {"messages": [user_message]}
|
||||||
|
|
||||||
|
if has_kb:
|
||||||
|
input_data["files"] = kb.files
|
||||||
|
input_data["messages"] = [kb.ls_ai_msg, kb.ls_tool_msg, user_message]
|
||||||
|
logger.info(
|
||||||
|
"Autocomplete: injected %d KB files into agent initial state", doc_count
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
"Autocomplete: no KB documents found, proceeding with screenshot only"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stream the agent (message_start already sent above)
|
||||||
try:
|
try:
|
||||||
yield streaming.format_message_start()
|
async for sse in stream_autocomplete_agent(
|
||||||
text_id = streaming.generate_text_id()
|
agent,
|
||||||
yield streaming.format_text_start(text_id)
|
input_data,
|
||||||
text_started = True
|
streaming,
|
||||||
|
emit_message_start=False,
|
||||||
async for chunk in llm.astream(messages):
|
):
|
||||||
token = chunk.content if hasattr(chunk, "content") else str(chunk)
|
yield sse
|
||||||
if token:
|
|
||||||
yield streaming.format_text_delta(text_id, token)
|
|
||||||
|
|
||||||
yield streaming.format_text_end(text_id)
|
|
||||||
yield streaming.format_finish()
|
|
||||||
yield streaming.format_done()
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if text_started:
|
|
||||||
yield streaming.format_text_end(text_id)
|
|
||||||
|
|
||||||
if _is_vision_unsupported_error(e):
|
if _is_vision_unsupported_error(e):
|
||||||
logger.warning(
|
logger.warning("Vision autocomplete: model does not support vision: %s", e)
|
||||||
f"Vision autocomplete: selected model does not support vision: {e}"
|
|
||||||
)
|
|
||||||
yield streaming.format_error(vision_error_msg)
|
yield streaming.format_error(vision_error_msg)
|
||||||
|
yield streaming.format_done()
|
||||||
else:
|
else:
|
||||||
logger.error(f"Vision autocomplete streaming error: {e}", exc_info=True)
|
logger.error("Vision autocomplete streaming error: %s", e, exc_info=True)
|
||||||
yield streaming.format_error("Autocomplete failed. Please try again.")
|
yield streaming.format_error("Autocomplete failed. Please try again.")
|
||||||
yield streaming.format_done()
|
yield streaming.format_done()
|
||||||
|
|
|
||||||
193
surfsense_backend/app/services/vision_llm_router_service.py
Normal file
193
surfsense_backend/app/services/vision_llm_router_service.py
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from litellm import Router
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
VISION_AUTO_MODE_ID = 0
|
||||||
|
|
||||||
|
VISION_PROVIDER_MAP = {
|
||||||
|
"OPENAI": "openai",
|
||||||
|
"ANTHROPIC": "anthropic",
|
||||||
|
"GOOGLE": "gemini",
|
||||||
|
"AZURE_OPENAI": "azure",
|
||||||
|
"VERTEX_AI": "vertex_ai",
|
||||||
|
"BEDROCK": "bedrock",
|
||||||
|
"XAI": "xai",
|
||||||
|
"OPENROUTER": "openrouter",
|
||||||
|
"OLLAMA": "ollama_chat",
|
||||||
|
"GROQ": "groq",
|
||||||
|
"TOGETHER_AI": "together_ai",
|
||||||
|
"FIREWORKS_AI": "fireworks_ai",
|
||||||
|
"DEEPSEEK": "openai",
|
||||||
|
"MISTRAL": "mistral",
|
||||||
|
"CUSTOM": "custom",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class VisionLLMRouterService:
|
||||||
|
_instance = None
|
||||||
|
_router: Router | None = None
|
||||||
|
_model_list: list[dict] = []
|
||||||
|
_router_settings: dict = {}
|
||||||
|
_initialized: bool = False
|
||||||
|
|
||||||
|
def __new__(cls):
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = super().__new__(cls)
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_instance(cls) -> "VisionLLMRouterService":
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = cls()
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def initialize(
|
||||||
|
cls,
|
||||||
|
global_configs: list[dict],
|
||||||
|
router_settings: dict | None = None,
|
||||||
|
) -> None:
|
||||||
|
instance = cls.get_instance()
|
||||||
|
|
||||||
|
if instance._initialized:
|
||||||
|
logger.debug("Vision LLM Router already initialized, skipping")
|
||||||
|
return
|
||||||
|
|
||||||
|
model_list = []
|
||||||
|
for config in global_configs:
|
||||||
|
deployment = cls._config_to_deployment(config)
|
||||||
|
if deployment:
|
||||||
|
model_list.append(deployment)
|
||||||
|
|
||||||
|
if not model_list:
|
||||||
|
logger.warning(
|
||||||
|
"No valid vision LLM configs found for router initialization"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
instance._model_list = model_list
|
||||||
|
instance._router_settings = router_settings or {}
|
||||||
|
|
||||||
|
default_settings = {
|
||||||
|
"routing_strategy": "usage-based-routing",
|
||||||
|
"num_retries": 3,
|
||||||
|
"allowed_fails": 3,
|
||||||
|
"cooldown_time": 60,
|
||||||
|
"retry_after": 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
final_settings = {**default_settings, **instance._router_settings}
|
||||||
|
|
||||||
|
try:
|
||||||
|
instance._router = Router(
|
||||||
|
model_list=model_list,
|
||||||
|
routing_strategy=final_settings.get(
|
||||||
|
"routing_strategy", "usage-based-routing"
|
||||||
|
),
|
||||||
|
num_retries=final_settings.get("num_retries", 3),
|
||||||
|
allowed_fails=final_settings.get("allowed_fails", 3),
|
||||||
|
cooldown_time=final_settings.get("cooldown_time", 60),
|
||||||
|
set_verbose=False,
|
||||||
|
)
|
||||||
|
instance._initialized = True
|
||||||
|
logger.info(
|
||||||
|
"Vision LLM Router initialized with %d deployments, strategy: %s",
|
||||||
|
len(model_list),
|
||||||
|
final_settings.get("routing_strategy"),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to initialize Vision LLM Router: {e}")
|
||||||
|
instance._router = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _config_to_deployment(cls, config: dict) -> dict | None:
|
||||||
|
try:
|
||||||
|
if not config.get("model_name") or not config.get("api_key"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if config.get("custom_provider"):
|
||||||
|
model_string = f"{config['custom_provider']}/{config['model_name']}"
|
||||||
|
else:
|
||||||
|
provider = config.get("provider", "").upper()
|
||||||
|
provider_prefix = VISION_PROVIDER_MAP.get(provider, provider.lower())
|
||||||
|
model_string = f"{provider_prefix}/{config['model_name']}"
|
||||||
|
|
||||||
|
litellm_params: dict[str, Any] = {
|
||||||
|
"model": model_string,
|
||||||
|
"api_key": config.get("api_key"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.get("api_base"):
|
||||||
|
litellm_params["api_base"] = config["api_base"]
|
||||||
|
|
||||||
|
if config.get("api_version"):
|
||||||
|
litellm_params["api_version"] = config["api_version"]
|
||||||
|
|
||||||
|
if config.get("litellm_params"):
|
||||||
|
litellm_params.update(config["litellm_params"])
|
||||||
|
|
||||||
|
deployment: dict[str, Any] = {
|
||||||
|
"model_name": "auto",
|
||||||
|
"litellm_params": litellm_params,
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.get("rpm"):
|
||||||
|
deployment["rpm"] = config["rpm"]
|
||||||
|
if config.get("tpm"):
|
||||||
|
deployment["tpm"] = config["tpm"]
|
||||||
|
|
||||||
|
return deployment
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to convert vision config to deployment: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_router(cls) -> Router | None:
|
||||||
|
instance = cls.get_instance()
|
||||||
|
return instance._router
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_initialized(cls) -> bool:
|
||||||
|
instance = cls.get_instance()
|
||||||
|
return instance._initialized and instance._router is not None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_model_count(cls) -> int:
|
||||||
|
instance = cls.get_instance()
|
||||||
|
return len(instance._model_list)
|
||||||
|
|
||||||
|
|
||||||
|
def is_vision_auto_mode(config_id: int | None) -> bool:
|
||||||
|
return config_id == VISION_AUTO_MODE_ID
|
||||||
|
|
||||||
|
|
||||||
|
def build_vision_model_string(
|
||||||
|
provider: str, model_name: str, custom_provider: str | None
|
||||||
|
) -> str:
|
||||||
|
if custom_provider:
|
||||||
|
return f"{custom_provider}/{model_name}"
|
||||||
|
prefix = VISION_PROVIDER_MAP.get(provider.upper(), provider.lower())
|
||||||
|
return f"{prefix}/{model_name}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_global_vision_llm_config(config_id: int) -> dict | None:
|
||||||
|
from app.config import config
|
||||||
|
|
||||||
|
if config_id == VISION_AUTO_MODE_ID:
|
||||||
|
return {
|
||||||
|
"id": VISION_AUTO_MODE_ID,
|
||||||
|
"name": "Auto (Fastest)",
|
||||||
|
"provider": "AUTO",
|
||||||
|
"model_name": "auto",
|
||||||
|
"is_auto_mode": True,
|
||||||
|
}
|
||||||
|
if config_id > 0:
|
||||||
|
return None
|
||||||
|
for cfg in config.GLOBAL_VISION_LLM_CONFIGS:
|
||||||
|
if cfg.get("id") == config_id:
|
||||||
|
return cfg
|
||||||
|
return None
|
||||||
132
surfsense_backend/app/services/vision_model_list_service.py
Normal file
132
surfsense_backend/app/services/vision_model_list_service.py
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
"""
|
||||||
|
Service for fetching and caching the vision-capable model list.
|
||||||
|
|
||||||
|
Reuses the same OpenRouter public API and local fallback as the LLM model
|
||||||
|
list service, but filters for models that accept image input.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
OPENROUTER_API_URL = "https://openrouter.ai/api/v1/models"
|
||||||
|
FALLBACK_FILE = Path(__file__).parent.parent / "config" / "vision_model_list_fallback.json"
|
||||||
|
CACHE_TTL_SECONDS = 86400 # 24 hours
|
||||||
|
|
||||||
|
_cache: list[dict] | None = None
|
||||||
|
_cache_timestamp: float = 0
|
||||||
|
|
||||||
|
OPENROUTER_SLUG_TO_VISION_PROVIDER: dict[str, str] = {
|
||||||
|
"openai": "OPENAI",
|
||||||
|
"anthropic": "ANTHROPIC",
|
||||||
|
"google": "GOOGLE",
|
||||||
|
"mistralai": "MISTRAL",
|
||||||
|
"x-ai": "XAI",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _format_context_length(length: int | None) -> str | None:
|
||||||
|
if not length:
|
||||||
|
return None
|
||||||
|
if length >= 1_000_000:
|
||||||
|
return f"{length / 1_000_000:g}M"
|
||||||
|
if length >= 1_000:
|
||||||
|
return f"{length / 1_000:g}K"
|
||||||
|
return str(length)
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_from_openrouter() -> list[dict] | None:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
|
response = await client.get(OPENROUTER_API_URL)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
return data.get("data", [])
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to fetch from OpenRouter API for vision models: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _load_fallback() -> list[dict]:
|
||||||
|
try:
|
||||||
|
with open(FALLBACK_FILE, encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to load vision model fallback list: %s", e)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _is_vision_model(model: dict) -> bool:
|
||||||
|
"""Return True if the model accepts image input and outputs text."""
|
||||||
|
arch = model.get("architecture", {})
|
||||||
|
input_mods = arch.get("input_modalities", [])
|
||||||
|
output_mods = arch.get("output_modalities", [])
|
||||||
|
return "image" in input_mods and "text" in output_mods
|
||||||
|
|
||||||
|
|
||||||
|
def _process_vision_models(raw_models: list[dict]) -> list[dict]:
|
||||||
|
processed: list[dict] = []
|
||||||
|
|
||||||
|
for model in raw_models:
|
||||||
|
model_id: str = model.get("id", "")
|
||||||
|
name: str = model.get("name", "")
|
||||||
|
context_length = model.get("context_length")
|
||||||
|
|
||||||
|
if "/" not in model_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not _is_vision_model(model):
|
||||||
|
continue
|
||||||
|
|
||||||
|
provider_slug, model_name = model_id.split("/", 1)
|
||||||
|
context_window = _format_context_length(context_length)
|
||||||
|
|
||||||
|
processed.append(
|
||||||
|
{
|
||||||
|
"value": model_id,
|
||||||
|
"label": name,
|
||||||
|
"provider": "OPENROUTER",
|
||||||
|
"context_window": context_window,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
native_provider = OPENROUTER_SLUG_TO_VISION_PROVIDER.get(provider_slug)
|
||||||
|
if native_provider:
|
||||||
|
if native_provider == "GOOGLE" and not model_name.startswith("gemini-"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
processed.append(
|
||||||
|
{
|
||||||
|
"value": model_name,
|
||||||
|
"label": name,
|
||||||
|
"provider": native_provider,
|
||||||
|
"context_window": context_window,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return processed
|
||||||
|
|
||||||
|
|
||||||
|
async def get_vision_model_list() -> list[dict]:
|
||||||
|
global _cache, _cache_timestamp
|
||||||
|
|
||||||
|
if _cache is not None and (time.time() - _cache_timestamp) < CACHE_TTL_SECONDS:
|
||||||
|
return _cache
|
||||||
|
|
||||||
|
raw_models = await _fetch_from_openrouter()
|
||||||
|
|
||||||
|
if raw_models is None:
|
||||||
|
logger.info("Using fallback vision model list")
|
||||||
|
return _load_fallback()
|
||||||
|
|
||||||
|
processed = _process_vision_models(raw_models)
|
||||||
|
|
||||||
|
_cache = processed
|
||||||
|
_cache_timestamp = time.time()
|
||||||
|
|
||||||
|
return processed
|
||||||
|
|
@ -46,8 +46,6 @@ dependencies = [
|
||||||
"redis>=5.2.1",
|
"redis>=5.2.1",
|
||||||
"firecrawl-py>=4.9.0",
|
"firecrawl-py>=4.9.0",
|
||||||
"boto3>=1.35.0",
|
"boto3>=1.35.0",
|
||||||
"litellm>=1.80.10",
|
|
||||||
"langchain-litellm>=0.3.5",
|
|
||||||
"fake-useragent>=2.2.0",
|
"fake-useragent>=2.2.0",
|
||||||
"trafilatura>=2.0.0",
|
"trafilatura>=2.0.0",
|
||||||
"fastapi-users[oauth,sqlalchemy]>=15.0.3",
|
"fastapi-users[oauth,sqlalchemy]>=15.0.3",
|
||||||
|
|
@ -76,6 +74,8 @@ dependencies = [
|
||||||
"deepagents>=0.4.12",
|
"deepagents>=0.4.12",
|
||||||
"stripe>=15.0.0",
|
"stripe>=15.0.0",
|
||||||
"azure-ai-documentintelligence>=1.0.2",
|
"azure-ai-documentintelligence>=1.0.2",
|
||||||
|
"litellm>=1.83.0",
|
||||||
|
"langchain-litellm>=0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
|
|
|
||||||
180
surfsense_backend/uv.lock
generated
180
surfsense_backend/uv.lock
generated
|
|
@ -62,7 +62,7 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aiohttp"
|
name = "aiohttp"
|
||||||
version = "3.13.3"
|
version = "3.13.5"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiohappyeyeballs" },
|
{ name = "aiohappyeyeballs" },
|
||||||
|
|
@ -73,76 +73,76 @@ dependencies = [
|
||||||
{ name = "propcache" },
|
{ name = "propcache" },
|
||||||
{ name = "yarl" },
|
{ name = "yarl" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" },
|
{ url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" },
|
{ url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" },
|
{ url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" },
|
{ url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" },
|
{ url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" },
|
{ url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" },
|
{ url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" },
|
{ url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" },
|
{ url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" },
|
{ url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" },
|
{ url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" },
|
{ url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" },
|
{ url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" },
|
{ url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" },
|
{ url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" },
|
{ url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" },
|
{ url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" },
|
{ url = "https://files.pythonhosted.org/packages/78/e9/d76bf503005709e390122d34e15256b88f7008e246c4bdbe915cd4f1adce/aiohttp-3.13.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5029cc80718bbd545123cd8fe5d15025eccaaaace5d0eeec6bd556ad6163d61", size = 742930, upload-time = "2026-03-31T21:58:13.155Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" },
|
{ url = "https://files.pythonhosted.org/packages/57/00/4b7b70223deaebd9bb85984d01a764b0d7bd6526fcdc73cca83bcbe7243e/aiohttp-3.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4bb6bf5811620003614076bdc807ef3b5e38244f9d25ca5fe888eaccea2a9832", size = 496927, upload-time = "2026-03-31T21:58:15.073Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" },
|
{ url = "https://files.pythonhosted.org/packages/9c/f5/0fb20fb49f8efdcdce6cd8127604ad2c503e754a8f139f5e02b01626523f/aiohttp-3.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a84792f8631bf5a94e52d9cc881c0b824ab42717165a5579c760b830d9392ac9", size = 497141, upload-time = "2026-03-31T21:58:17.009Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" },
|
{ url = "https://files.pythonhosted.org/packages/3b/86/b7c870053e36a94e8951b803cb5b909bfbc9b90ca941527f5fcafbf6b0fa/aiohttp-3.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57653eac22c6a4c13eb22ecf4d673d64a12f266e72785ab1c8b8e5940d0e8090", size = 1732476, upload-time = "2026-03-31T21:58:18.925Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" },
|
{ url = "https://files.pythonhosted.org/packages/b5/e5/4e161f84f98d80c03a238671b4136e6530453d65262867d989bbe78244d0/aiohttp-3.13.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5e5f7debc7a57af53fdf5c5009f9391d9f4c12867049d509bf7bb164a6e295b", size = 1706507, upload-time = "2026-03-31T21:58:21.094Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" },
|
{ url = "https://files.pythonhosted.org/packages/d4/56/ea11a9f01518bd5a2a2fcee869d248c4b8a0cfa0bb13401574fa31adf4d4/aiohttp-3.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c719f65bebcdf6716f10e9eff80d27567f7892d8988c06de12bbbd39307c6e3a", size = 1773465, upload-time = "2026-03-31T21:58:23.159Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" },
|
{ url = "https://files.pythonhosted.org/packages/eb/40/333ca27fb74b0383f17c90570c748f7582501507307350a79d9f9f3c6eb1/aiohttp-3.13.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d97f93fdae594d886c5a866636397e2bcab146fd7a132fd6bb9ce182224452f8", size = 1873523, upload-time = "2026-03-31T21:58:25.59Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" },
|
{ url = "https://files.pythonhosted.org/packages/f0/d2/e2f77eef1acb7111405433c707dc735e63f67a56e176e72e9e7a2cd3f493/aiohttp-3.13.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df334e39d4c2f899a914f1dba283c1aadc311790733f705182998c6f7cae665", size = 1754113, upload-time = "2026-03-31T21:58:27.624Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" },
|
{ url = "https://files.pythonhosted.org/packages/fb/56/3f653d7f53c89669301ec9e42c95233e2a0c0a6dd051269e6e678db4fdb0/aiohttp-3.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe6970addfea9e5e081401bcbadf865d2b6da045472f58af08427e108d618540", size = 1562351, upload-time = "2026-03-31T21:58:29.918Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" },
|
{ url = "https://files.pythonhosted.org/packages/ec/a6/9b3e91eb8ae791cce4ee736da02211c85c6f835f1bdfac0594a8a3b7018c/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7becdf835feff2f4f335d7477f121af787e3504b48b449ff737afb35869ba7bb", size = 1693205, upload-time = "2026-03-31T21:58:32.214Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" },
|
{ url = "https://files.pythonhosted.org/packages/98/fc/bfb437a99a2fcebd6b6eaec609571954de2ed424f01c352f4b5504371dd3/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:676e5651705ad5d8a70aeb8eb6936c436d8ebbd56e63436cb7dd9bb36d2a9a46", size = 1730618, upload-time = "2026-03-31T21:58:34.728Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" },
|
{ url = "https://files.pythonhosted.org/packages/e4/b6/c8534862126191a034f68153194c389addc285a0f1347d85096d349bbc15/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9b16c653d38eb1a611cc898c41e76859ca27f119d25b53c12875fd0474ae31a8", size = 1745185, upload-time = "2026-03-31T21:58:36.909Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" },
|
{ url = "https://files.pythonhosted.org/packages/0b/93/4ca8ee2ef5236e2707e0fd5fecb10ce214aee1ff4ab307af9c558bda3b37/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:999802d5fa0389f58decd24b537c54aa63c01c3219ce17d1214cbda3c2b22d2d", size = 1557311, upload-time = "2026-03-31T21:58:39.38Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" },
|
{ url = "https://files.pythonhosted.org/packages/57/ae/76177b15f18c5f5d094f19901d284025db28eccc5ae374d1d254181d33f4/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ec707059ee75732b1ba130ed5f9580fe10ff75180c812bc267ded039db5128c6", size = 1773147, upload-time = "2026-03-31T21:58:41.476Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" },
|
{ url = "https://files.pythonhosted.org/packages/01/a4/62f05a0a98d88af59d93b7fcac564e5f18f513cb7471696ac286db970d6a/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d6d44a5b48132053c2f6cd5c8cb14bc67e99a63594e336b0f2af81e94d5530c", size = 1730356, upload-time = "2026-03-31T21:58:44.049Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" },
|
{ url = "https://files.pythonhosted.org/packages/e4/85/fc8601f59dfa8c9523808281f2da571f8b4699685f9809a228adcc90838d/aiohttp-3.13.5-cp313-cp313-win32.whl", hash = "sha256:329f292ed14d38a6c4c435e465f48bebb47479fd676a0411936cc371643225cc", size = 432637, upload-time = "2026-03-31T21:58:46.167Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" },
|
{ url = "https://files.pythonhosted.org/packages/c0/1b/ac685a8882896acf0f6b31d689e3792199cfe7aba37969fa91da63a7fa27/aiohttp-3.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:69f571de7500e0557801c0b51f4780482c0ec5fe2ac851af5a92cfce1af1cb83", size = 458896, upload-time = "2026-03-31T21:58:48.119Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" },
|
{ url = "https://files.pythonhosted.org/packages/5d/ce/46572759afc859e867a5bc8ec3487315869013f59281ce61764f76d879de/aiohttp-3.13.5-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:eb4639f32fd4a9904ab8fb45bf3383ba71137f3d9d4ba25b3b3f3109977c5b8c", size = 745721, upload-time = "2026-03-31T21:58:50.229Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" },
|
{ url = "https://files.pythonhosted.org/packages/13/fe/8a2efd7626dbe6049b2ef8ace18ffda8a4dfcbe1bcff3ac30c0c7575c20b/aiohttp-3.13.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:7e5dc4311bd5ac493886c63cbf76ab579dbe4641268e7c74e48e774c74b6f2be", size = 497663, upload-time = "2026-03-31T21:58:52.232Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" },
|
{ url = "https://files.pythonhosted.org/packages/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:756c3c304d394977519824449600adaf2be0ccee76d206ee339c5e76b70ded25", size = 499094, upload-time = "2026-03-31T21:58:54.566Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" },
|
{ url = "https://files.pythonhosted.org/packages/0a/33/a8362cb15cf16a3af7e86ed11962d5cd7d59b449202dc576cdc731310bde/aiohttp-3.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecc26751323224cf8186efcf7fbcbc30f4e1d8c7970659daf25ad995e4032a56", size = 1726701, upload-time = "2026-03-31T21:58:56.864Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" },
|
{ url = "https://files.pythonhosted.org/packages/45/0c/c091ac5c3a17114bd76cbf85d674650969ddf93387876cf67f754204bd77/aiohttp-3.13.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10a75acfcf794edf9d8db50e5a7ec5fc818b2a8d3f591ce93bc7b1210df016d2", size = 1683360, upload-time = "2026-03-31T21:58:59.072Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" },
|
{ url = "https://files.pythonhosted.org/packages/23/73/bcee1c2b79bc275e964d1446c55c54441a461938e70267c86afaae6fba27/aiohttp-3.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f7a18f258d124cd678c5fe072fe4432a4d5232b0657fca7c1847f599233c83a", size = 1773023, upload-time = "2026-03-31T21:59:01.776Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" },
|
{ url = "https://files.pythonhosted.org/packages/c7/ef/720e639df03004fee2d869f771799d8c23046dec47d5b81e396c7cda583a/aiohttp-3.13.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df6104c009713d3a89621096f3e3e88cc323fd269dbd7c20afe18535094320be", size = 1853795, upload-time = "2026-03-31T21:59:04.568Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" },
|
{ url = "https://files.pythonhosted.org/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241a94f7de7c0c3b616627aaad530fe2cb620084a8b144d3be7b6ecfe95bae3b", size = 1730405, upload-time = "2026-03-31T21:59:07.221Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" },
|
{ url = "https://files.pythonhosted.org/packages/ce/75/ee1fd286ca7dc599d824b5651dad7b3be7ff8d9a7e7b3fe9820d9180f7db/aiohttp-3.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c974fb66180e58709b6fc402846f13791240d180b74de81d23913abe48e96d94", size = 1558082, upload-time = "2026-03-31T21:59:09.484Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" },
|
{ url = "https://files.pythonhosted.org/packages/c3/20/1e9e6650dfc436340116b7aa89ff8cb2bbdf0abc11dfaceaad8f74273a10/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6e27ea05d184afac78aabbac667450c75e54e35f62238d44463131bd3f96753d", size = 1692346, upload-time = "2026-03-31T21:59:12.068Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" },
|
{ url = "https://files.pythonhosted.org/packages/d8/40/8ebc6658d48ea630ac7903912fe0dd4e262f0e16825aa4c833c56c9f1f56/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a79a6d399cef33a11b6f004c67bb07741d91f2be01b8d712d52c75711b1e07c7", size = 1698891, upload-time = "2026-03-31T21:59:14.552Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" },
|
{ url = "https://files.pythonhosted.org/packages/d8/78/ea0ae5ec8ba7a5c10bdd6e318f1ba5e76fcde17db8275188772afc7917a4/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c632ce9c0b534fbe25b52c974515ed674937c5b99f549a92127c85f771a78772", size = 1742113, upload-time = "2026-03-31T21:59:17.068Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" },
|
{ url = "https://files.pythonhosted.org/packages/8a/66/9d308ed71e3f2491be1acb8769d96c6f0c47d92099f3bc9119cada27b357/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fceedde51fbd67ee2bcc8c0b33d0126cc8b51ef3bbde2f86662bd6d5a6f10ec5", size = 1553088, upload-time = "2026-03-31T21:59:19.541Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" },
|
{ url = "https://files.pythonhosted.org/packages/da/a6/6cc25ed8dfc6e00c90f5c6d126a98e2cf28957ad06fa1036bd34b6f24a2c/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f92995dfec9420bb69ae629abf422e516923ba79ba4403bc750d94fb4a6c68c1", size = 1757976, upload-time = "2026-03-31T21:59:22.311Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" },
|
{ url = "https://files.pythonhosted.org/packages/c1/2b/cce5b0ffe0de99c83e5e36d8f828e4161e415660a9f3e58339d07cce3006/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20ae0ff08b1f2c8788d6fb85afcb798654ae6ba0b747575f8562de738078457b", size = 1712444, upload-time = "2026-03-31T21:59:24.635Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" },
|
{ url = "https://files.pythonhosted.org/packages/6c/cf/9e1795b4160c58d29421eafd1a69c6ce351e2f7c8d3c6b7e4ca44aea1a5b/aiohttp-3.13.5-cp314-cp314-win32.whl", hash = "sha256:b20df693de16f42b2472a9c485e1c948ee55524786a0a34345511afdd22246f3", size = 438128, upload-time = "2026-03-31T21:59:27.291Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" },
|
{ url = "https://files.pythonhosted.org/packages/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:f85c6f327bf0b8c29da7d93b1cabb6363fb5e4e160a32fa241ed2dce21b73162", size = 464029, upload-time = "2026-03-31T21:59:29.429Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" },
|
{ url = "https://files.pythonhosted.org/packages/79/11/c27d9332ee20d68dd164dc12a6ecdef2e2e35ecc97ed6cf0d2442844624b/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1efb06900858bb618ff5cee184ae2de5828896c448403d51fb633f09e109be0a", size = 778758, upload-time = "2026-03-31T21:59:31.547Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" },
|
{ url = "https://files.pythonhosted.org/packages/04/fb/377aead2e0a3ba5f09b7624f702a964bdf4f08b5b6728a9799830c80041e/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fee86b7c4bd29bdaf0d53d14739b08a106fdda809ca5fe032a15f52fae5fe254", size = 512883, upload-time = "2026-03-31T21:59:34.098Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" },
|
{ url = "https://files.pythonhosted.org/packages/bb/a6/aa109a33671f7a5d3bd78b46da9d852797c5e665bfda7d6b373f56bff2ec/aiohttp-3.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:20058e23909b9e65f9da62b396b77dfa95965cbe840f8def6e572538b1d32e36", size = 516668, upload-time = "2026-03-31T21:59:36.497Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" },
|
{ url = "https://files.pythonhosted.org/packages/79/b3/ca078f9f2fa9563c36fb8ef89053ea2bb146d6f792c5104574d49d8acb63/aiohttp-3.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cf20a8d6868cb15a73cab329ffc07291ba8c22b1b88176026106ae39aa6df0f", size = 1883461, upload-time = "2026-03-31T21:59:38.723Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" },
|
{ url = "https://files.pythonhosted.org/packages/b7/e3/a7ad633ca1ca497b852233a3cce6906a56c3225fb6d9217b5e5e60b7419d/aiohttp-3.13.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:330f5da04c987f1d5bdb8ae189137c77139f36bd1cb23779ca1a354a4b027800", size = 1747661, upload-time = "2026-03-31T21:59:41.187Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" },
|
{ url = "https://files.pythonhosted.org/packages/33/b9/cd6fe579bed34a906d3d783fe60f2fa297ef55b27bb4538438ee49d4dc41/aiohttp-3.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f1cbf0c7926d315c3c26c2da41fd2b5d2fe01ac0e157b78caefc51a782196cf", size = 1863800, upload-time = "2026-03-31T21:59:43.84Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" },
|
{ url = "https://files.pythonhosted.org/packages/c0/3f/2c1e2f5144cefa889c8afd5cf431994c32f3b29da9961698ff4e3811b79a/aiohttp-3.13.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53fc049ed6390d05423ba33103ded7281fe897cf97878f369a527070bd95795b", size = 1958382, upload-time = "2026-03-31T21:59:46.187Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" },
|
{ url = "https://files.pythonhosted.org/packages/66/1d/f31ec3f1013723b3babe3609e7f119c2c2fb6ef33da90061a705ef3e1bc8/aiohttp-3.13.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:898703aa2667e3c5ca4c54ca36cd73f58b7a38ef87a5606414799ebce4d3fd3a", size = 1803724, upload-time = "2026-03-31T21:59:48.656Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" },
|
{ url = "https://files.pythonhosted.org/packages/0e/b4/57712dfc6f1542f067daa81eb61da282fab3e6f1966fca25db06c4fc62d5/aiohttp-3.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0494a01ca9584eea1e5fbd6d748e61ecff218c51b576ee1999c23db7066417d8", size = 1640027, upload-time = "2026-03-31T21:59:51.284Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" },
|
{ url = "https://files.pythonhosted.org/packages/25/3c/734c878fb43ec083d8e31bf029daae1beafeae582d1b35da234739e82ee7/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6cf81fe010b8c17b09495cbd15c1d35afbc8fb405c0c9cf4738e5ae3af1d65be", size = 1806644, upload-time = "2026-03-31T21:59:53.753Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" },
|
{ url = "https://files.pythonhosted.org/packages/20/a5/f671e5cbec1c21d044ff3078223f949748f3a7f86b14e34a365d74a5d21f/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c564dd5f09ddc9d8f2c2d0a301cd30a79a2cc1b46dd1a73bef8f0038863d016b", size = 1791630, upload-time = "2026-03-31T21:59:56.239Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" },
|
{ url = "https://files.pythonhosted.org/packages/0b/63/fb8d0ad63a0b8a99be97deac8c04dacf0785721c158bdf23d679a87aa99e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2994be9f6e51046c4f864598fd9abeb4fba6e88f0b2152422c9666dcd4aea9c6", size = 1809403, upload-time = "2026-03-31T21:59:59.103Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" },
|
{ url = "https://files.pythonhosted.org/packages/59/0c/bfed7f30662fcf12206481c2aac57dedee43fe1c49275e85b3a1e1742294/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:157826e2fa245d2ef46c83ea8a5faf77ca19355d278d425c29fda0beb3318037", size = 1634924, upload-time = "2026-03-31T22:00:02.116Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" },
|
{ url = "https://files.pythonhosted.org/packages/17/d6/fd518d668a09fd5a3319ae5e984d4d80b9a4b3df4e21c52f02251ef5a32e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a8aca50daa9493e9e13c0f566201a9006f080e7c50e5e90d0b06f53146a54500", size = 1836119, upload-time = "2026-03-31T22:00:04.756Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" },
|
{ url = "https://files.pythonhosted.org/packages/78/b7/15fb7a9d52e112a25b621c67b69c167805cb1f2ab8f1708a5c490d1b52fe/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b13560160d07e047a93f23aaa30718606493036253d5430887514715b67c9d9", size = 1772072, upload-time = "2026-03-31T22:00:07.494Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" },
|
{ url = "https://files.pythonhosted.org/packages/7e/df/57ba7f0c4a553fc2bd8b6321df236870ec6fd64a2a473a8a13d4f733214e/aiohttp-3.13.5-cp314-cp314t-win32.whl", hash = "sha256:9a0f4474b6ea6818b41f82172d799e4b3d29e22c2c520ce4357856fced9af2f8", size = 471819, upload-time = "2026-03-31T22:00:10.277Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" },
|
{ url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441, upload-time = "2026-03-31T22:00:12.791Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1037,14 +1037,14 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "click"
|
name = "click"
|
||||||
version = "8.3.1"
|
version = "8.1.8"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
|
{ url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -2998,14 +2998,14 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "importlib-metadata"
|
name = "importlib-metadata"
|
||||||
version = "8.7.1"
|
version = "8.5.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "zipp" },
|
{ name = "zipp" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/cd/12/33e59336dca5be0c398a7482335911a33aa0e20776128f038019f1a95f1b/importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7", size = 55304, upload-time = "2024-09-11T14:56:08.937Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" },
|
{ url = "https://files.pythonhosted.org/packages/a0/d9/a1e041c5e7caa9a05c925f4bdbdfb7f006d1f74996af53467bc394c97be7/importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b", size = 26514, upload-time = "2024-09-11T14:56:07.019Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -3240,7 +3240,7 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jsonschema"
|
name = "jsonschema"
|
||||||
version = "4.26.0"
|
version = "4.23.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "attrs" },
|
{ name = "attrs" },
|
||||||
|
|
@ -3248,9 +3248,9 @@ dependencies = [
|
||||||
{ name = "referencing" },
|
{ name = "referencing" },
|
||||||
{ name = "rpds-py" },
|
{ name = "rpds-py" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/38/2e/03362ee4034a4c917f697890ccd4aec0800ccf9ded7f511971c75451deec/jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4", size = 325778, upload-time = "2024-07-08T18:40:05.546Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
|
{ url = "https://files.pythonhosted.org/packages/69/4a/4f9dbeb84e8850557c02365a0eee0649abe5eb1d84af92a25731c6c0f922/jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566", size = 88462, upload-time = "2024-07-08T18:40:00.165Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -3555,7 +3555,7 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langchain-litellm"
|
name = "langchain-litellm"
|
||||||
version = "0.6.2"
|
version = "0.6.4"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "cryptography" },
|
{ name = "cryptography" },
|
||||||
|
|
@ -3563,9 +3563,9 @@ dependencies = [
|
||||||
{ name = "langchain-core" },
|
{ name = "langchain-core" },
|
||||||
{ name = "litellm" },
|
{ name = "litellm" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/ee/6f/ba0490ec0fbc9d97cd9433749455fb4b5fbec3852bcbe113a0278ec1d32d/langchain_litellm-0.6.2.tar.gz", hash = "sha256:93372df7c3f1802358746e2c0a94012d8c27d9f9b57b769b23f6af2264bbaabb", size = 332878, upload-time = "2026-03-24T17:16:45.14Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/68/37/ccc1f284a42900ca5b267a50da8e50145e9f264b32ee955ce91aa360d188/langchain_litellm-0.6.4.tar.gz", hash = "sha256:663281db392b3de1f07f891d0f80f9d4b26c0f0d2abbf854ef9b186d99c309ee", size = 339457, upload-time = "2026-04-03T16:56:47.886Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/da/14/ad857a3f56fa4ea0879ac9d6ee5248c883663d0bad94bf8741e1ab6ab200/langchain_litellm-0.6.2-py3-none-any.whl", hash = "sha256:98af79dbcdea4b492e9601351bc5fd15fdd368e021183b8540f0d0b6b6b1589c", size = 24865, upload-time = "2026-03-24T17:16:44.262Z" },
|
{ url = "https://files.pythonhosted.org/packages/43/e8/25c50bbad7a05106c7af65557e165d6cb6159c90854dae61de59debe735d/langchain_litellm-0.6.4-py3-none-any.whl", hash = "sha256:60f4e37be1a47dc88f94fac7085675ef8fa04bba92f48735792d82f492120744", size = 26360, upload-time = "2026-04-03T16:56:46.76Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -3731,7 +3731,7 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "litellm"
|
name = "litellm"
|
||||||
version = "1.82.6"
|
version = "1.83.4"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiohttp" },
|
{ name = "aiohttp" },
|
||||||
|
|
@ -3747,9 +3747,9 @@ dependencies = [
|
||||||
{ name = "tiktoken" },
|
{ name = "tiktoken" },
|
||||||
{ name = "tokenizers" },
|
{ name = "tokenizers" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/29/75/1c537aa458426a9127a92bc2273787b2f987f4e5044e21f01f2eed5244fd/litellm-1.82.6.tar.gz", hash = "sha256:2aa1c2da21fe940c33613aa447119674a3ad4d2ad5eb064e4d5ce5ee42420136", size = 17414147, upload-time = "2026-03-22T06:36:00.452Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/03/c4/30469c06ae7437a4406bc11e3c433cfd380a6771068cca15ea918dcd158f/litellm-1.83.4.tar.gz", hash = "sha256:6458d2030a41229460b321adee00517a91dbd8e63213cc953d355cb41d16f2d4", size = 17733899, upload-time = "2026-04-07T04:33:47.445Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/02/6c/5327667e6dbe9e98cbfbd4261c8e91386a52e38f41419575854248bbab6a/litellm-1.82.6-py3-none-any.whl", hash = "sha256:164a3ef3e19f309e3cabc199bef3d2045212712fefdfa25fc7f75884a5b5b205", size = 15591595, upload-time = "2026-03-22T06:35:56.795Z" },
|
{ url = "https://files.pythonhosted.org/packages/b8/bd/df19d3f8f6654535ee343a341fd921f81c411abf601a53e3eaef58129b02/litellm-1.83.4-py3-none-any.whl", hash = "sha256:17d7b4d48d47aca988ea4f762ddda5e7bd72cda3270192b22813d0330869d7b4", size = 16015555, upload-time = "2026-04-07T04:33:44.268Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -6797,11 +6797,11 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-dotenv"
|
name = "python-dotenv"
|
||||||
version = "1.2.2"
|
version = "1.0.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115, upload-time = "2024-01-23T06:33:00.505Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
{ url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863, upload-time = "2024-01-23T06:32:58.246Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -8082,12 +8082,12 @@ requires-dist = [
|
||||||
{ name = "langchain", specifier = ">=1.2.13" },
|
{ name = "langchain", specifier = ">=1.2.13" },
|
||||||
{ name = "langchain-community", specifier = ">=0.4.1" },
|
{ name = "langchain-community", specifier = ">=0.4.1" },
|
||||||
{ name = "langchain-daytona", specifier = ">=0.0.2" },
|
{ name = "langchain-daytona", specifier = ">=0.0.2" },
|
||||||
{ name = "langchain-litellm", specifier = ">=0.3.5" },
|
{ name = "langchain-litellm", specifier = ">=0.6.4" },
|
||||||
{ name = "langchain-unstructured", specifier = ">=1.0.1" },
|
{ name = "langchain-unstructured", specifier = ">=1.0.1" },
|
||||||
{ name = "langgraph", specifier = ">=1.1.3" },
|
{ name = "langgraph", specifier = ">=1.1.3" },
|
||||||
{ name = "langgraph-checkpoint-postgres", specifier = ">=3.0.2" },
|
{ name = "langgraph-checkpoint-postgres", specifier = ">=3.0.2" },
|
||||||
{ name = "linkup-sdk", specifier = ">=0.2.4" },
|
{ name = "linkup-sdk", specifier = ">=0.2.4" },
|
||||||
{ name = "litellm", specifier = ">=1.80.10" },
|
{ name = "litellm", specifier = ">=1.83.0" },
|
||||||
{ name = "llama-cloud-services", specifier = ">=0.6.25" },
|
{ name = "llama-cloud-services", specifier = ">=0.6.25" },
|
||||||
{ name = "markdown", specifier = ">=3.7" },
|
{ name = "markdown", specifier = ">=3.7" },
|
||||||
{ name = "markdownify", specifier = ">=0.14.1" },
|
{ name = "markdownify", specifier = ">=0.14.1" },
|
||||||
|
|
|
||||||
|
|
@ -3,4 +3,8 @@
|
||||||
|
|
||||||
# The hosted web frontend URL. Used to intercept OAuth redirects and keep them
|
# The hosted web frontend URL. Used to intercept OAuth redirects and keep them
|
||||||
# inside the desktop app. Set to your production frontend domain.
|
# inside the desktop app. Set to your production frontend domain.
|
||||||
HOSTED_FRONTEND_URL=https://surfsense.net
|
HOSTED_FRONTEND_URL=https://surfsense.com
|
||||||
|
|
||||||
|
# PostHog analytics (leave empty to disable)
|
||||||
|
POSTHOG_KEY=
|
||||||
|
POSTHOG_HOST=https://assets.surfsense.com
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,9 @@ files:
|
||||||
- "!scripts"
|
- "!scripts"
|
||||||
- "!release"
|
- "!release"
|
||||||
extraResources:
|
extraResources:
|
||||||
|
- from: assets/
|
||||||
|
to: assets/
|
||||||
|
filter: ["*.ico", "*.png", "*.icns"]
|
||||||
- from: ../surfsense_web/.next/standalone/surfsense_web/
|
- from: ../surfsense_web/.next/standalone/surfsense_web/
|
||||||
to: standalone/
|
to: standalone/
|
||||||
filter:
|
filter:
|
||||||
|
|
@ -58,7 +61,7 @@ win:
|
||||||
icon: assets/icon.ico
|
icon: assets/icon.ico
|
||||||
target:
|
target:
|
||||||
- target: nsis
|
- target: nsis
|
||||||
arch: [x64, arm64]
|
arch: [x64]
|
||||||
nsis:
|
nsis:
|
||||||
oneClick: false
|
oneClick: false
|
||||||
perMachine: false
|
perMachine: false
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
"description": "SurfSense Desktop App",
|
"description": "SurfSense Desktop App",
|
||||||
"main": "dist/main.js",
|
"main": "dist/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently -k \"pnpm --dir ../surfsense_web dev\" \"wait-on http://localhost:3000 && electron .\"",
|
"dev": "pnpm build && concurrently -k \"pnpm --dir ../surfsense_web dev\" \"wait-on http://localhost:3000 && electron .\"",
|
||||||
"build": "node scripts/build-electron.mjs",
|
"build": "node scripts/build-electron.mjs",
|
||||||
"pack:dir": "pnpm build && electron-builder --dir --config electron-builder.yml",
|
"pack:dir": "pnpm build && electron-builder --dir --config electron-builder.yml",
|
||||||
"dist": "pnpm build && electron-builder --config electron-builder.yml",
|
"dist": "pnpm build && electron-builder --config electron-builder.yml",
|
||||||
|
|
@ -14,7 +14,11 @@
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"postinstall": "electron-rebuild"
|
"postinstall": "electron-rebuild"
|
||||||
},
|
},
|
||||||
"author": "MODSetter",
|
"homepage": "https://github.com/MODSetter/SurfSense",
|
||||||
|
"author": {
|
||||||
|
"name": "MODSetter",
|
||||||
|
"email": "rohan@surfsense.com"
|
||||||
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"packageManager": "pnpm@10.24.0",
|
"packageManager": "pnpm@10.24.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -34,6 +38,8 @@
|
||||||
"electron-store": "^11.0.2",
|
"electron-store": "^11.0.2",
|
||||||
"electron-updater": "^6.8.3",
|
"electron-updater": "^6.8.3",
|
||||||
"get-port-please": "^3.2.0",
|
"get-port-please": "^3.2.0",
|
||||||
"node-mac-permissions": "^2.5.0"
|
"node-mac-permissions": "^2.5.0",
|
||||||
|
"node-machine-id": "^1.1.12",
|
||||||
|
"posthog-node": "^5.29.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
31
surfsense_desktop/pnpm-lock.yaml
generated
31
surfsense_desktop/pnpm-lock.yaml
generated
|
|
@ -26,6 +26,12 @@ importers:
|
||||||
node-mac-permissions:
|
node-mac-permissions:
|
||||||
specifier: ^2.5.0
|
specifier: ^2.5.0
|
||||||
version: 2.5.0
|
version: 2.5.0
|
||||||
|
node-machine-id:
|
||||||
|
specifier: ^1.1.12
|
||||||
|
version: 1.1.12
|
||||||
|
posthog-node:
|
||||||
|
specifier: ^5.29.0
|
||||||
|
version: 5.29.0(rxjs@7.8.2)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@electron/rebuild':
|
'@electron/rebuild':
|
||||||
specifier: ^4.0.3
|
specifier: ^4.0.3
|
||||||
|
|
@ -308,6 +314,9 @@ packages:
|
||||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
|
|
||||||
|
'@posthog/core@1.25.0':
|
||||||
|
resolution: {integrity: sha512-XKaHvRFIIN7Dw84r1eKimV1rl9DS+9XMCPPZ7P3+l8fE+rDsmumebiTFsY+q40bVXflcGW9wB+57LH0lvcGmhw==}
|
||||||
|
|
||||||
'@sindresorhus/is@4.6.0':
|
'@sindresorhus/is@4.6.0':
|
||||||
resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==}
|
resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
@ -1194,6 +1203,9 @@ packages:
|
||||||
resolution: {integrity: sha512-zR8SVCaN3WqV1xwWd04XVAdzm3UTdjbxciLrZtB0Cc7F2Kd34AJfhPD4hm1HU0YH3oGUZO4X9OBLY5ijSTHsGw==}
|
resolution: {integrity: sha512-zR8SVCaN3WqV1xwWd04XVAdzm3UTdjbxciLrZtB0Cc7F2Kd34AJfhPD4hm1HU0YH3oGUZO4X9OBLY5ijSTHsGw==}
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
|
node-machine-id@1.1.12:
|
||||||
|
resolution: {integrity: sha512-QNABxbrPa3qEIfrE6GOJ7BYIuignnJw7iQ2YPbc3Nla1HzRJjXzZOiikfF8m7eAMfichLt3M4VgLOetqgDmgGQ==}
|
||||||
|
|
||||||
nopt@8.1.0:
|
nopt@8.1.0:
|
||||||
resolution: {integrity: sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==}
|
resolution: {integrity: sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==}
|
||||||
engines: {node: ^18.17.0 || >=20.5.0}
|
engines: {node: ^18.17.0 || >=20.5.0}
|
||||||
|
|
@ -1263,6 +1275,15 @@ packages:
|
||||||
resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==}
|
resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==}
|
||||||
engines: {node: '>=10.4.0'}
|
engines: {node: '>=10.4.0'}
|
||||||
|
|
||||||
|
posthog-node@5.29.0:
|
||||||
|
resolution: {integrity: sha512-po7N55haSKxV8VOulkBZJja938yILShl6+fFjoUV3iQgOBCg4Muu615/xRg8mpNiz+UASvL0EEiGvIxdhXfj6Q==}
|
||||||
|
engines: {node: ^20.20.0 || >=22.22.0}
|
||||||
|
peerDependencies:
|
||||||
|
rxjs: ^7.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
rxjs:
|
||||||
|
optional: true
|
||||||
|
|
||||||
postject@1.0.0-alpha.6:
|
postject@1.0.0-alpha.6:
|
||||||
resolution: {integrity: sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==}
|
resolution: {integrity: sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==}
|
||||||
engines: {node: '>=14.0.0'}
|
engines: {node: '>=14.0.0'}
|
||||||
|
|
@ -1876,6 +1897,8 @@ snapshots:
|
||||||
'@pkgjs/parseargs@0.11.0':
|
'@pkgjs/parseargs@0.11.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@posthog/core@1.25.0': {}
|
||||||
|
|
||||||
'@sindresorhus/is@4.6.0': {}
|
'@sindresorhus/is@4.6.0': {}
|
||||||
|
|
||||||
'@standard-schema/spec@1.1.0': {}
|
'@standard-schema/spec@1.1.0': {}
|
||||||
|
|
@ -2940,6 +2963,8 @@ snapshots:
|
||||||
bindings: 1.5.0
|
bindings: 1.5.0
|
||||||
node-addon-api: 7.1.1
|
node-addon-api: 7.1.1
|
||||||
|
|
||||||
|
node-machine-id@1.1.12: {}
|
||||||
|
|
||||||
nopt@8.1.0:
|
nopt@8.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
abbrev: 3.0.1
|
abbrev: 3.0.1
|
||||||
|
|
@ -3002,6 +3027,12 @@ snapshots:
|
||||||
base64-js: 1.5.1
|
base64-js: 1.5.1
|
||||||
xmlbuilder: 15.1.1
|
xmlbuilder: 15.1.1
|
||||||
|
|
||||||
|
posthog-node@5.29.0(rxjs@7.8.2):
|
||||||
|
dependencies:
|
||||||
|
'@posthog/core': 1.25.0
|
||||||
|
optionalDependencies:
|
||||||
|
rxjs: 7.8.2
|
||||||
|
|
||||||
postject@1.0.0-alpha.6:
|
postject@1.0.0-alpha.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
commander: 9.5.0
|
commander: 9.5.0
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,12 @@ async function buildElectron() {
|
||||||
'process.env.HOSTED_FRONTEND_URL': JSON.stringify(
|
'process.env.HOSTED_FRONTEND_URL': JSON.stringify(
|
||||||
process.env.HOSTED_FRONTEND_URL || desktopEnv.HOSTED_FRONTEND_URL || 'https://surfsense.net'
|
process.env.HOSTED_FRONTEND_URL || desktopEnv.HOSTED_FRONTEND_URL || 'https://surfsense.net'
|
||||||
),
|
),
|
||||||
|
'process.env.POSTHOG_KEY': JSON.stringify(
|
||||||
|
process.env.POSTHOG_KEY || desktopEnv.POSTHOG_KEY || ''
|
||||||
|
),
|
||||||
|
'process.env.POSTHOG_HOST': JSON.stringify(
|
||||||
|
process.env.POSTHOG_HOST || desktopEnv.POSTHOG_HOST || 'https://assets.surfsense.com'
|
||||||
|
),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,4 +32,13 @@ export const IPC_CHANNELS = {
|
||||||
FOLDER_SYNC_ACK_EVENTS: 'folder-sync:ack-events',
|
FOLDER_SYNC_ACK_EVENTS: 'folder-sync:ack-events',
|
||||||
BROWSE_FILES: 'browse:files',
|
BROWSE_FILES: 'browse:files',
|
||||||
READ_LOCAL_FILES: 'browse:read-local-files',
|
READ_LOCAL_FILES: 'browse:read-local-files',
|
||||||
|
// Auth token sync across windows
|
||||||
|
GET_AUTH_TOKENS: 'auth:get-tokens',
|
||||||
|
SET_AUTH_TOKENS: 'auth:set-tokens',
|
||||||
|
// Keyboard shortcut configuration
|
||||||
|
GET_SHORTCUTS: 'shortcuts:get',
|
||||||
|
SET_SHORTCUTS: 'shortcuts:set',
|
||||||
|
// Active search space
|
||||||
|
GET_ACTIVE_SEARCH_SPACE: 'search-space:get-active',
|
||||||
|
SET_ACTIVE_SEARCH_SPACE: 'search-space:set-active',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,13 @@ import {
|
||||||
browseFiles,
|
browseFiles,
|
||||||
readLocalFiles,
|
readLocalFiles,
|
||||||
} from '../modules/folder-watcher';
|
} from '../modules/folder-watcher';
|
||||||
|
import { getShortcuts, setShortcuts, type ShortcutConfig } from '../modules/shortcuts';
|
||||||
|
import { getActiveSearchSpaceId, setActiveSearchSpaceId } from '../modules/active-search-space';
|
||||||
|
import { reregisterQuickAsk } from '../modules/quick-ask';
|
||||||
|
import { reregisterAutocomplete } from '../modules/autocomplete';
|
||||||
|
import { reregisterGeneralAssist } from '../modules/tray';
|
||||||
|
|
||||||
|
let authTokens: { bearer: string; refresh: string } | null = null;
|
||||||
|
|
||||||
export function registerIpcHandlers(): void {
|
export function registerIpcHandlers(): void {
|
||||||
ipcMain.on(IPC_CHANNELS.OPEN_EXTERNAL, (_event, url: string) => {
|
ipcMain.on(IPC_CHANNELS.OPEN_EXTERNAL, (_event, url: string) => {
|
||||||
|
|
@ -89,4 +96,28 @@ export function registerIpcHandlers(): void {
|
||||||
ipcMain.handle(IPC_CHANNELS.READ_LOCAL_FILES, (_event, paths: string[]) =>
|
ipcMain.handle(IPC_CHANNELS.READ_LOCAL_FILES, (_event, paths: string[]) =>
|
||||||
readLocalFiles(paths)
|
readLocalFiles(paths)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.SET_AUTH_TOKENS, (_event, tokens: { bearer: string; refresh: string }) => {
|
||||||
|
authTokens = tokens;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.GET_AUTH_TOKENS, () => {
|
||||||
|
return authTokens;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.GET_SHORTCUTS, () => getShortcuts());
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.GET_ACTIVE_SEARCH_SPACE, () => getActiveSearchSpaceId());
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.SET_ACTIVE_SEARCH_SPACE, (_event, id: string) =>
|
||||||
|
setActiveSearchSpaceId(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.SET_SHORTCUTS, async (_event, config: Partial<ShortcutConfig>) => {
|
||||||
|
const updated = await setShortcuts(config);
|
||||||
|
if (config.generalAssist) await reregisterGeneralAssist();
|
||||||
|
if (config.quickAsk) await reregisterQuickAsk();
|
||||||
|
if (config.autocomplete) await reregisterAutocomplete();
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { app, BrowserWindow } from 'electron';
|
import { app, BrowserWindow } from 'electron';
|
||||||
|
|
||||||
|
let isQuitting = false;
|
||||||
import { registerGlobalErrorHandlers, showErrorDialog } from './modules/errors';
|
import { registerGlobalErrorHandlers, showErrorDialog } from './modules/errors';
|
||||||
import { startNextServer } from './modules/server';
|
import { startNextServer } from './modules/server';
|
||||||
import { createMainWindow } from './modules/window';
|
import { createMainWindow, getMainWindow } from './modules/window';
|
||||||
import { setupDeepLinks, handlePendingDeepLink } from './modules/deep-links';
|
import { setupDeepLinks, handlePendingDeepLink } from './modules/deep-links';
|
||||||
import { setupAutoUpdater } from './modules/auto-updater';
|
import { setupAutoUpdater } from './modules/auto-updater';
|
||||||
import { setupMenu } from './modules/menu';
|
import { setupMenu } from './modules/menu';
|
||||||
|
|
@ -9,6 +11,8 @@ import { registerQuickAsk, unregisterQuickAsk } from './modules/quick-ask';
|
||||||
import { registerAutocomplete, unregisterAutocomplete } from './modules/autocomplete';
|
import { registerAutocomplete, unregisterAutocomplete } from './modules/autocomplete';
|
||||||
import { registerFolderWatcher, unregisterFolderWatcher } from './modules/folder-watcher';
|
import { registerFolderWatcher, unregisterFolderWatcher } from './modules/folder-watcher';
|
||||||
import { registerIpcHandlers } from './ipc/handlers';
|
import { registerIpcHandlers } from './ipc/handlers';
|
||||||
|
import { createTray, destroyTray } from './modules/tray';
|
||||||
|
import { initAnalytics, shutdownAnalytics, trackEvent } from './modules/analytics';
|
||||||
|
|
||||||
registerGlobalErrorHandlers();
|
registerGlobalErrorHandlers();
|
||||||
|
|
||||||
|
|
@ -19,6 +23,8 @@ if (!setupDeepLinks()) {
|
||||||
registerIpcHandlers();
|
registerIpcHandlers();
|
||||||
|
|
||||||
app.whenReady().then(async () => {
|
app.whenReady().then(async () => {
|
||||||
|
initAnalytics();
|
||||||
|
trackEvent('desktop_app_launched');
|
||||||
setupMenu();
|
setupMenu();
|
||||||
try {
|
try {
|
||||||
await startNextServer();
|
await startNextServer();
|
||||||
|
|
@ -28,29 +34,54 @@ app.whenReady().then(async () => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
createMainWindow('/dashboard');
|
await createTray();
|
||||||
registerQuickAsk();
|
|
||||||
registerAutocomplete();
|
const win = createMainWindow('/dashboard');
|
||||||
|
|
||||||
|
// Minimize to tray instead of closing the app
|
||||||
|
win.on('close', (e) => {
|
||||||
|
if (!isQuitting) {
|
||||||
|
e.preventDefault();
|
||||||
|
win.hide();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await registerQuickAsk();
|
||||||
|
await registerAutocomplete();
|
||||||
registerFolderWatcher();
|
registerFolderWatcher();
|
||||||
setupAutoUpdater();
|
setupAutoUpdater();
|
||||||
|
|
||||||
handlePendingDeepLink();
|
handlePendingDeepLink();
|
||||||
|
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
const mw = getMainWindow();
|
||||||
|
if (!mw || mw.isDestroyed()) {
|
||||||
createMainWindow('/dashboard');
|
createMainWindow('/dashboard');
|
||||||
|
} else {
|
||||||
|
mw.show();
|
||||||
|
mw.focus();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Keep running in the background — the tray "Quit" calls app.exit()
|
||||||
app.on('window-all-closed', () => {
|
app.on('window-all-closed', () => {
|
||||||
if (process.platform !== 'darwin') {
|
// Do nothing: the app stays alive in the tray
|
||||||
app.quit();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on('will-quit', () => {
|
app.on('before-quit', () => {
|
||||||
|
isQuitting = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
let didCleanup = false;
|
||||||
|
app.on('will-quit', async (e) => {
|
||||||
|
if (didCleanup) return;
|
||||||
|
didCleanup = true;
|
||||||
|
e.preventDefault();
|
||||||
unregisterQuickAsk();
|
unregisterQuickAsk();
|
||||||
unregisterAutocomplete();
|
unregisterAutocomplete();
|
||||||
unregisterFolderWatcher();
|
unregisterFolderWatcher();
|
||||||
|
destroyTray();
|
||||||
|
await shutdownAnalytics();
|
||||||
|
app.exit();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
24
surfsense_desktop/src/modules/active-search-space.ts
Normal file
24
surfsense_desktop/src/modules/active-search-space.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
const STORE_KEY = 'activeSearchSpaceId';
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
let store: any = null;
|
||||||
|
|
||||||
|
async function getStore() {
|
||||||
|
if (!store) {
|
||||||
|
const { default: Store } = await import('electron-store');
|
||||||
|
store = new Store({
|
||||||
|
name: 'active-search-space',
|
||||||
|
defaults: { [STORE_KEY]: null as string | null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActiveSearchSpaceId(): Promise<string | null> {
|
||||||
|
const s = await getStore();
|
||||||
|
return (s.get(STORE_KEY) as string | null) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setActiveSearchSpaceId(id: string): Promise<void> {
|
||||||
|
const s = await getStore();
|
||||||
|
s.set(STORE_KEY, id);
|
||||||
|
}
|
||||||
50
surfsense_desktop/src/modules/analytics.ts
Normal file
50
surfsense_desktop/src/modules/analytics.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { PostHog } from 'posthog-node';
|
||||||
|
import { machineIdSync } from 'node-machine-id';
|
||||||
|
import { app } from 'electron';
|
||||||
|
|
||||||
|
let client: PostHog | null = null;
|
||||||
|
let distinctId = '';
|
||||||
|
|
||||||
|
export function initAnalytics(): void {
|
||||||
|
const key = process.env.POSTHOG_KEY;
|
||||||
|
if (!key) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
distinctId = machineIdSync(true);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
client = new PostHog(key, {
|
||||||
|
host: process.env.POSTHOG_HOST || 'https://assets.surfsense.com',
|
||||||
|
flushAt: 20,
|
||||||
|
flushInterval: 10000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trackEvent(event: string, properties?: Record<string, unknown>): void {
|
||||||
|
if (!client) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
client.capture({
|
||||||
|
distinctId,
|
||||||
|
event,
|
||||||
|
properties: {
|
||||||
|
platform: 'desktop',
|
||||||
|
app_version: app.getVersion(),
|
||||||
|
os: process.platform,
|
||||||
|
...properties,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Analytics should never break the app
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function shutdownAnalytics(): Promise<void> {
|
||||||
|
if (!client) return;
|
||||||
|
|
||||||
|
const timeout = new Promise<void>((resolve) => setTimeout(resolve, 3000));
|
||||||
|
await Promise.race([client.shutdown(), timeout]);
|
||||||
|
client = null;
|
||||||
|
}
|
||||||
|
|
@ -2,16 +2,16 @@ import { clipboard, globalShortcut, ipcMain, screen } from 'electron';
|
||||||
import { IPC_CHANNELS } from '../../ipc/channels';
|
import { IPC_CHANNELS } from '../../ipc/channels';
|
||||||
import { getFrontmostApp, getWindowTitle, hasAccessibilityPermission, simulatePaste } from '../platform';
|
import { getFrontmostApp, getWindowTitle, hasAccessibilityPermission, simulatePaste } from '../platform';
|
||||||
import { hasScreenRecordingPermission, requestAccessibility, requestScreenRecording } from '../permissions';
|
import { hasScreenRecordingPermission, requestAccessibility, requestScreenRecording } from '../permissions';
|
||||||
import { getMainWindow } from '../window';
|
|
||||||
import { captureScreen } from './screenshot';
|
import { captureScreen } from './screenshot';
|
||||||
import { createSuggestionWindow, destroySuggestion, getSuggestionWindow } from './suggestion-window';
|
import { createSuggestionWindow, destroySuggestion, getSuggestionWindow } from './suggestion-window';
|
||||||
|
import { getShortcuts } from '../shortcuts';
|
||||||
|
import { getActiveSearchSpaceId } from '../active-search-space';
|
||||||
|
import { trackEvent } from '../analytics';
|
||||||
|
|
||||||
const SHORTCUT = 'CommandOrControl+Shift+Space';
|
let currentShortcut = '';
|
||||||
|
|
||||||
let autocompleteEnabled = true;
|
let autocompleteEnabled = true;
|
||||||
let savedClipboard = '';
|
let savedClipboard = '';
|
||||||
let sourceApp = '';
|
let sourceApp = '';
|
||||||
let lastSearchSpaceId: string | null = null;
|
|
||||||
|
|
||||||
function isSurfSenseWindow(): boolean {
|
function isSurfSenseWindow(): boolean {
|
||||||
const app = getFrontmostApp();
|
const app = getFrontmostApp();
|
||||||
|
|
@ -37,21 +37,12 @@ async function triggerAutocomplete(): Promise<void> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mainWin = getMainWindow();
|
const searchSpaceId = await getActiveSearchSpaceId();
|
||||||
if (mainWin && !mainWin.isDestroyed()) {
|
if (!searchSpaceId) {
|
||||||
const mainUrl = mainWin.webContents.getURL();
|
console.warn('[autocomplete] No active search space. Select a search space first.');
|
||||||
const match = mainUrl.match(/\/dashboard\/(\d+)/);
|
|
||||||
if (match) {
|
|
||||||
lastSearchSpaceId = match[1];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!lastSearchSpaceId) {
|
|
||||||
console.warn('[autocomplete] No active search space. Open a search space first.');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
trackEvent('desktop_autocomplete_triggered', { search_space_id: searchSpaceId });
|
||||||
const searchSpaceId = lastSearchSpaceId;
|
|
||||||
const cursor = screen.getCursorScreenPoint();
|
const cursor = screen.getCursorScreenPoint();
|
||||||
const win = createSuggestionWindow(cursor.x, cursor.y);
|
const win = createSuggestionWindow(cursor.x, cursor.y);
|
||||||
|
|
||||||
|
|
@ -91,11 +82,18 @@ async function acceptAndInject(text: string): Promise<void> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let ipcRegistered = false;
|
||||||
|
|
||||||
function registerIpcHandlers(): void {
|
function registerIpcHandlers(): void {
|
||||||
|
if (ipcRegistered) return;
|
||||||
|
ipcRegistered = true;
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.ACCEPT_SUGGESTION, async (_event, text: string) => {
|
ipcMain.handle(IPC_CHANNELS.ACCEPT_SUGGESTION, async (_event, text: string) => {
|
||||||
|
trackEvent('desktop_autocomplete_accepted');
|
||||||
await acceptAndInject(text);
|
await acceptAndInject(text);
|
||||||
});
|
});
|
||||||
ipcMain.handle(IPC_CHANNELS.DISMISS_SUGGESTION, () => {
|
ipcMain.handle(IPC_CHANNELS.DISMISS_SUGGESTION, () => {
|
||||||
|
trackEvent('desktop_autocomplete_dismissed');
|
||||||
destroySuggestion();
|
destroySuggestion();
|
||||||
});
|
});
|
||||||
ipcMain.handle(IPC_CHANNELS.SET_AUTOCOMPLETE_ENABLED, (_event, enabled: boolean) => {
|
ipcMain.handle(IPC_CHANNELS.SET_AUTOCOMPLETE_ENABLED, (_event, enabled: boolean) => {
|
||||||
|
|
@ -107,26 +105,39 @@ function registerIpcHandlers(): void {
|
||||||
ipcMain.handle(IPC_CHANNELS.GET_AUTOCOMPLETE_ENABLED, () => autocompleteEnabled);
|
ipcMain.handle(IPC_CHANNELS.GET_AUTOCOMPLETE_ENABLED, () => autocompleteEnabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerAutocomplete(): void {
|
function autocompleteHandler(): void {
|
||||||
registerIpcHandlers();
|
const sw = getSuggestionWindow();
|
||||||
|
if (sw && !sw.isDestroyed()) {
|
||||||
|
destroySuggestion();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
triggerAutocomplete();
|
||||||
|
}
|
||||||
|
|
||||||
const ok = globalShortcut.register(SHORTCUT, () => {
|
async function registerShortcut(): Promise<void> {
|
||||||
const sw = getSuggestionWindow();
|
const shortcuts = await getShortcuts();
|
||||||
if (sw && !sw.isDestroyed()) {
|
currentShortcut = shortcuts.autocomplete;
|
||||||
destroySuggestion();
|
|
||||||
return;
|
const ok = globalShortcut.register(currentShortcut, autocompleteHandler);
|
||||||
}
|
|
||||||
triggerAutocomplete();
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
console.error(`[autocomplete] Failed to register shortcut ${SHORTCUT}`);
|
console.error(`[autocomplete] Failed to register shortcut ${currentShortcut}`);
|
||||||
} else {
|
} else {
|
||||||
console.log(`[autocomplete] Registered shortcut ${SHORTCUT}`);
|
console.log(`[autocomplete] Registered shortcut ${currentShortcut}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function registerAutocomplete(): Promise<void> {
|
||||||
|
registerIpcHandlers();
|
||||||
|
await registerShortcut();
|
||||||
|
}
|
||||||
|
|
||||||
export function unregisterAutocomplete(): void {
|
export function unregisterAutocomplete(): void {
|
||||||
globalShortcut.unregister(SHORTCUT);
|
if (currentShortcut) globalShortcut.unregister(currentShortcut);
|
||||||
destroySuggestion();
|
destroySuggestion();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function reregisterAutocomplete(): Promise<void> {
|
||||||
|
unregisterAutocomplete();
|
||||||
|
await registerShortcut();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,20 @@
|
||||||
import { execSync } from 'child_process';
|
import { execSync } from 'child_process';
|
||||||
import { systemPreferences } from 'electron';
|
import { systemPreferences } from 'electron';
|
||||||
|
|
||||||
|
const EXEC_OPTS = { windowsHide: true } as const;
|
||||||
|
|
||||||
export function getFrontmostApp(): string {
|
export function getFrontmostApp(): string {
|
||||||
try {
|
try {
|
||||||
if (process.platform === 'darwin') {
|
if (process.platform === 'darwin') {
|
||||||
return execSync(
|
return execSync(
|
||||||
'osascript -e \'tell application "System Events" to get name of first application process whose frontmost is true\''
|
'osascript -e \'tell application "System Events" to get name of first application process whose frontmost is true\'',
|
||||||
|
EXEC_OPTS,
|
||||||
).toString().trim();
|
).toString().trim();
|
||||||
}
|
}
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
return execSync(
|
return execSync(
|
||||||
'powershell -command "Add-Type \'using System; using System.Runtime.InteropServices; public class W { [DllImport(\\\"user32.dll\\\")] public static extern IntPtr GetForegroundWindow(); }\'; (Get-Process | Where-Object { $_.MainWindowHandle -eq [W]::GetForegroundWindow() }).ProcessName"'
|
'powershell -NoProfile -NonInteractive -command "Add-Type \'using System; using System.Runtime.InteropServices; public class W { [DllImport(\\\"user32.dll\\\")] public static extern IntPtr GetForegroundWindow(); }\'; (Get-Process | Where-Object { $_.MainWindowHandle -eq [W]::GetForegroundWindow() }).ProcessName"',
|
||||||
|
EXEC_OPTS,
|
||||||
).toString().trim();
|
).toString().trim();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -21,9 +25,23 @@ export function getFrontmostApp(): string {
|
||||||
|
|
||||||
export function simulatePaste(): void {
|
export function simulatePaste(): void {
|
||||||
if (process.platform === 'darwin') {
|
if (process.platform === 'darwin') {
|
||||||
execSync('osascript -e \'tell application "System Events" to keystroke "v" using command down\'');
|
execSync('osascript -e \'tell application "System Events" to keystroke "v" using command down\'', EXEC_OPTS);
|
||||||
} else if (process.platform === 'win32') {
|
} else if (process.platform === 'win32') {
|
||||||
execSync('powershell -command "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait(\'^v\')"');
|
execSync('powershell -NoProfile -NonInteractive -command "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait(\'^v\')"', EXEC_OPTS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function simulateCopy(): boolean {
|
||||||
|
try {
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
execSync('osascript -e \'tell application "System Events" to keystroke "c" using command down\'', EXEC_OPTS);
|
||||||
|
} else if (process.platform === 'win32') {
|
||||||
|
execSync('powershell -NoProfile -NonInteractive -command "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait(\'^c\')"', EXEC_OPTS);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[simulateCopy] Failed:', err);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -36,12 +54,14 @@ export function getWindowTitle(): string {
|
||||||
try {
|
try {
|
||||||
if (process.platform === 'darwin') {
|
if (process.platform === 'darwin') {
|
||||||
return execSync(
|
return execSync(
|
||||||
'osascript -e \'tell application "System Events" to get title of front window of first application process whose frontmost is true\''
|
'osascript -e \'tell application "System Events" to get title of front window of first application process whose frontmost is true\'',
|
||||||
|
EXEC_OPTS,
|
||||||
).toString().trim();
|
).toString().trim();
|
||||||
}
|
}
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
return execSync(
|
return execSync(
|
||||||
'powershell -command "(Get-Process | Where-Object { $_.MainWindowHandle -eq (Add-Type -MemberDefinition \'[DllImport(\\\"user32.dll\\\")] public static extern IntPtr GetForegroundWindow();\' -Name W -PassThru)::GetForegroundWindow() }).MainWindowTitle"'
|
'powershell -NoProfile -NonInteractive -command "(Get-Process | Where-Object { $_.MainWindowHandle -eq (Add-Type -MemberDefinition \'[DllImport(\\\"user32.dll\\\")] public static extern IntPtr GetForegroundWindow();\' -Name W -PassThru)::GetForegroundWindow() }).MainWindowTitle"',
|
||||||
|
EXEC_OPTS,
|
||||||
).toString().trim();
|
).toString().trim();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,17 @@
|
||||||
import { BrowserWindow, clipboard, globalShortcut, ipcMain, screen, shell } from 'electron';
|
import { BrowserWindow, clipboard, globalShortcut, ipcMain, screen, shell } from 'electron';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { IPC_CHANNELS } from '../ipc/channels';
|
import { IPC_CHANNELS } from '../ipc/channels';
|
||||||
import { checkAccessibilityPermission, getFrontmostApp, simulatePaste } from './platform';
|
import { checkAccessibilityPermission, getFrontmostApp, simulateCopy, simulatePaste } from './platform';
|
||||||
import { getServerPort } from './server';
|
import { getServerPort } from './server';
|
||||||
|
import { getShortcuts } from './shortcuts';
|
||||||
|
import { getActiveSearchSpaceId } from './active-search-space';
|
||||||
|
import { trackEvent } from './analytics';
|
||||||
|
|
||||||
const SHORTCUT = 'CommandOrControl+Option+S';
|
let currentShortcut = '';
|
||||||
let quickAskWindow: BrowserWindow | null = null;
|
let quickAskWindow: BrowserWindow | null = null;
|
||||||
let pendingText = '';
|
let pendingText = '';
|
||||||
let pendingMode = '';
|
let pendingMode = '';
|
||||||
|
let pendingSearchSpaceId: string | null = null;
|
||||||
let sourceApp = '';
|
let sourceApp = '';
|
||||||
let savedClipboard = '';
|
let savedClipboard = '';
|
||||||
|
|
||||||
|
|
@ -52,7 +56,9 @@ function createQuickAskWindow(x: number, y: number): BrowserWindow {
|
||||||
skipTaskbar: true,
|
skipTaskbar: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
quickAskWindow.loadURL(`http://localhost:${getServerPort()}/dashboard`);
|
const spaceId = pendingSearchSpaceId;
|
||||||
|
const route = spaceId ? `/dashboard/${spaceId}/new-chat` : '/dashboard';
|
||||||
|
quickAskWindow.loadURL(`http://localhost:${getServerPort()}${route}?quickAssist=true`);
|
||||||
|
|
||||||
quickAskWindow.once('ready-to-show', () => {
|
quickAskWindow.once('ready-to-show', () => {
|
||||||
quickAskWindow?.show();
|
quickAskWindow?.show();
|
||||||
|
|
@ -77,29 +83,55 @@ function createQuickAskWindow(x: number, y: number): BrowserWindow {
|
||||||
return quickAskWindow;
|
return quickAskWindow;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerQuickAsk(): void {
|
async function openQuickAsk(text: string): Promise<void> {
|
||||||
const ok = globalShortcut.register(SHORTCUT, () => {
|
pendingText = text;
|
||||||
if (quickAskWindow && !quickAskWindow.isDestroyed()) {
|
pendingMode = 'quick-assist';
|
||||||
destroyQuickAsk();
|
pendingSearchSpaceId = await getActiveSearchSpaceId();
|
||||||
return;
|
const cursor = screen.getCursorScreenPoint();
|
||||||
}
|
const pos = clampToScreen(cursor.x, cursor.y, 450, 750);
|
||||||
|
createQuickAskWindow(pos.x, pos.y);
|
||||||
|
}
|
||||||
|
|
||||||
sourceApp = getFrontmostApp();
|
async function quickAskHandler(): Promise<void> {
|
||||||
savedClipboard = clipboard.readText();
|
console.log('[quick-ask] Handler triggered');
|
||||||
|
|
||||||
const text = savedClipboard.trim();
|
if (quickAskWindow && !quickAskWindow.isDestroyed()) {
|
||||||
if (!text) return;
|
console.log('[quick-ask] Window already open, closing');
|
||||||
|
destroyQuickAsk();
|
||||||
pendingText = text;
|
return;
|
||||||
const cursor = screen.getCursorScreenPoint();
|
|
||||||
const pos = clampToScreen(cursor.x, cursor.y, 450, 750);
|
|
||||||
createQuickAskWindow(pos.x, pos.y);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!ok) {
|
|
||||||
console.log(`Quick-ask: failed to register ${SHORTCUT}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!checkAccessibilityPermission()) {
|
||||||
|
console.log('[quick-ask] Accessibility permission denied');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
savedClipboard = clipboard.readText();
|
||||||
|
console.log('[quick-ask] Saved clipboard length:', savedClipboard.length);
|
||||||
|
|
||||||
|
const copyOk = simulateCopy();
|
||||||
|
console.log('[quick-ask] simulateCopy result:', copyOk);
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
|
|
||||||
|
const afterCopy = clipboard.readText();
|
||||||
|
const selected = afterCopy.trim();
|
||||||
|
console.log('[quick-ask] Clipboard after copy length:', afterCopy.length, 'changed:', afterCopy !== savedClipboard);
|
||||||
|
|
||||||
|
const text = selected || savedClipboard.trim();
|
||||||
|
|
||||||
|
sourceApp = getFrontmostApp();
|
||||||
|
console.log('[quick-ask] Source app:', sourceApp, '| Opening Quick Assist with', text.length, 'chars', selected ? '(selected)' : text ? '(clipboard fallback)' : '(empty)');
|
||||||
|
trackEvent('desktop_quick_ask_opened', { has_selected_text: !!selected });
|
||||||
|
openQuickAsk(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
let ipcRegistered = false;
|
||||||
|
|
||||||
|
function registerIpcHandlers(): void {
|
||||||
|
if (ipcRegistered) return;
|
||||||
|
ipcRegistered = true;
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.QUICK_ASK_TEXT, () => {
|
ipcMain.handle(IPC_CHANNELS.QUICK_ASK_TEXT, () => {
|
||||||
const text = pendingText;
|
const text = pendingText;
|
||||||
pendingText = '';
|
pendingText = '';
|
||||||
|
|
@ -122,6 +154,7 @@ export function registerQuickAsk(): void {
|
||||||
|
|
||||||
if (!checkAccessibilityPermission()) return;
|
if (!checkAccessibilityPermission()) return;
|
||||||
|
|
||||||
|
trackEvent('desktop_quick_ask_replaced');
|
||||||
clipboard.writeText(text);
|
clipboard.writeText(text);
|
||||||
destroyQuickAsk();
|
destroyQuickAsk();
|
||||||
|
|
||||||
|
|
@ -136,6 +169,24 @@ export function registerQuickAsk(): void {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function unregisterQuickAsk(): void {
|
async function registerShortcut(): Promise<void> {
|
||||||
globalShortcut.unregister(SHORTCUT);
|
const shortcuts = await getShortcuts();
|
||||||
|
currentShortcut = shortcuts.quickAsk;
|
||||||
|
|
||||||
|
const ok = globalShortcut.register(currentShortcut, () => { quickAskHandler(); });
|
||||||
|
console.log(`[quick-ask] Register ${currentShortcut}: ${ok ? 'OK' : 'FAILED'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerQuickAsk(): Promise<void> {
|
||||||
|
registerIpcHandlers();
|
||||||
|
await registerShortcut();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unregisterQuickAsk(): void {
|
||||||
|
if (currentShortcut) globalShortcut.unregister(currentShortcut);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reregisterQuickAsk(): Promise<void> {
|
||||||
|
unregisterQuickAsk();
|
||||||
|
await registerShortcut();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
44
surfsense_desktop/src/modules/shortcuts.ts
Normal file
44
surfsense_desktop/src/modules/shortcuts.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
export interface ShortcutConfig {
|
||||||
|
generalAssist: string;
|
||||||
|
quickAsk: string;
|
||||||
|
autocomplete: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULTS: ShortcutConfig = {
|
||||||
|
generalAssist: 'CommandOrControl+Shift+S',
|
||||||
|
quickAsk: 'CommandOrControl+Alt+S',
|
||||||
|
autocomplete: 'CommandOrControl+Shift+Space',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STORE_KEY = 'shortcuts';
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- lazily imported ESM module; matches folder-watcher.ts pattern
|
||||||
|
let store: any = null;
|
||||||
|
|
||||||
|
async function getStore() {
|
||||||
|
if (!store) {
|
||||||
|
const { default: Store } = await import('electron-store');
|
||||||
|
store = new Store({
|
||||||
|
name: 'keyboard-shortcuts',
|
||||||
|
defaults: { [STORE_KEY]: DEFAULTS },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getShortcuts(): Promise<ShortcutConfig> {
|
||||||
|
const s = await getStore();
|
||||||
|
const stored = s.get(STORE_KEY) as Partial<ShortcutConfig> | undefined;
|
||||||
|
return { ...DEFAULTS, ...stored };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setShortcuts(config: Partial<ShortcutConfig>): Promise<ShortcutConfig> {
|
||||||
|
const s = await getStore();
|
||||||
|
const current = (s.get(STORE_KEY) as ShortcutConfig) ?? DEFAULTS;
|
||||||
|
const merged = { ...current, ...config };
|
||||||
|
s.set(STORE_KEY, merged);
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaults(): ShortcutConfig {
|
||||||
|
return { ...DEFAULTS };
|
||||||
|
}
|
||||||
77
surfsense_desktop/src/modules/tray.ts
Normal file
77
surfsense_desktop/src/modules/tray.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { app, globalShortcut, Menu, nativeImage, Tray } from 'electron';
|
||||||
|
import path from 'path';
|
||||||
|
import { getMainWindow, createMainWindow } from './window';
|
||||||
|
import { getShortcuts } from './shortcuts';
|
||||||
|
|
||||||
|
let tray: Tray | null = null;
|
||||||
|
let currentShortcut: string | null = null;
|
||||||
|
|
||||||
|
function getTrayIcon(): nativeImage {
|
||||||
|
const iconName = process.platform === 'win32' ? 'icon.ico' : 'icon.png';
|
||||||
|
const iconPath = app.isPackaged
|
||||||
|
? path.join(process.resourcesPath, 'assets', iconName)
|
||||||
|
: path.join(__dirname, '..', 'assets', iconName);
|
||||||
|
const img = nativeImage.createFromPath(iconPath);
|
||||||
|
return img.resize({ width: 16, height: 16 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMainWindow(): void {
|
||||||
|
let win = getMainWindow();
|
||||||
|
if (!win || win.isDestroyed()) {
|
||||||
|
win = createMainWindow('/dashboard');
|
||||||
|
} else {
|
||||||
|
win.show();
|
||||||
|
win.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerShortcut(accelerator: string): void {
|
||||||
|
if (currentShortcut) {
|
||||||
|
globalShortcut.unregister(currentShortcut);
|
||||||
|
currentShortcut = null;
|
||||||
|
}
|
||||||
|
if (!accelerator) return;
|
||||||
|
try {
|
||||||
|
const ok = globalShortcut.register(accelerator, showMainWindow);
|
||||||
|
if (ok) {
|
||||||
|
currentShortcut = accelerator;
|
||||||
|
} else {
|
||||||
|
console.warn(`[tray] Failed to register General Assist shortcut: ${accelerator}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[tray] Error registering General Assist shortcut:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTray(): Promise<void> {
|
||||||
|
if (tray) return;
|
||||||
|
|
||||||
|
tray = new Tray(getTrayIcon());
|
||||||
|
tray.setToolTip('SurfSense');
|
||||||
|
|
||||||
|
const contextMenu = Menu.buildFromTemplate([
|
||||||
|
{ label: 'Open SurfSense', click: showMainWindow },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ label: 'Quit', click: () => { app.exit(0); } },
|
||||||
|
]);
|
||||||
|
|
||||||
|
tray.setContextMenu(contextMenu);
|
||||||
|
tray.on('double-click', showMainWindow);
|
||||||
|
|
||||||
|
const shortcuts = await getShortcuts();
|
||||||
|
registerShortcut(shortcuts.generalAssist);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reregisterGeneralAssist(): Promise<void> {
|
||||||
|
const shortcuts = await getShortcuts();
|
||||||
|
registerShortcut(shortcuts.generalAssist);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function destroyTray(): void {
|
||||||
|
if (currentShortcut) {
|
||||||
|
globalShortcut.unregister(currentShortcut);
|
||||||
|
currentShortcut = null;
|
||||||
|
}
|
||||||
|
tray?.destroy();
|
||||||
|
tray = null;
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import { app, BrowserWindow, shell, session } from 'electron';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { showErrorDialog } from './errors';
|
import { showErrorDialog } from './errors';
|
||||||
import { getServerPort } from './server';
|
import { getServerPort } from './server';
|
||||||
|
import { setActiveSearchSpaceId } from './active-search-space';
|
||||||
|
|
||||||
const isDev = !app.isPackaged;
|
const isDev = !app.isPackaged;
|
||||||
const HOSTED_FRONTEND_URL = process.env.HOSTED_FRONTEND_URL as string;
|
const HOSTED_FRONTEND_URL = process.env.HOSTED_FRONTEND_URL as string;
|
||||||
|
|
@ -55,6 +56,16 @@ export function createMainWindow(initialPath = '/dashboard'): BrowserWindow {
|
||||||
showErrorDialog('Page failed to load', new Error(`${errorDescription} (${errorCode})\n${validatedURL}`));
|
showErrorDialog('Page failed to load', new Error(`${errorDescription} (${errorCode})\n${validatedURL}`));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Auto-sync active search space from URL navigation
|
||||||
|
const syncSearchSpace = (url: string) => {
|
||||||
|
const match = url.match(/\/dashboard\/(\d+)/);
|
||||||
|
if (match) {
|
||||||
|
setActiveSearchSpaceId(match[1]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
mainWindow.webContents.on('did-navigate', (_event, url) => syncSearchSpace(url));
|
||||||
|
mainWindow.webContents.on('did-navigate-in-page', (_event, url) => syncSearchSpace(url));
|
||||||
|
|
||||||
if (isDev) {
|
if (isDev) {
|
||||||
mainWindow.webContents.openDevTools();
|
mainWindow.webContents.openDevTools();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -68,4 +68,19 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
// Browse files via native dialog
|
// Browse files via native dialog
|
||||||
browseFiles: () => ipcRenderer.invoke(IPC_CHANNELS.BROWSE_FILES),
|
browseFiles: () => ipcRenderer.invoke(IPC_CHANNELS.BROWSE_FILES),
|
||||||
readLocalFiles: (paths: string[]) => ipcRenderer.invoke(IPC_CHANNELS.READ_LOCAL_FILES, paths),
|
readLocalFiles: (paths: string[]) => ipcRenderer.invoke(IPC_CHANNELS.READ_LOCAL_FILES, paths),
|
||||||
|
|
||||||
|
// Auth token sync across windows
|
||||||
|
getAuthTokens: () => ipcRenderer.invoke(IPC_CHANNELS.GET_AUTH_TOKENS),
|
||||||
|
setAuthTokens: (bearer: string, refresh: string) =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.SET_AUTH_TOKENS, { bearer, refresh }),
|
||||||
|
|
||||||
|
// Keyboard shortcut configuration
|
||||||
|
getShortcuts: () => ipcRenderer.invoke(IPC_CHANNELS.GET_SHORTCUTS),
|
||||||
|
setShortcuts: (config: Record<string, string>) =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.SET_SHORTCUTS, config),
|
||||||
|
|
||||||
|
// Active search space
|
||||||
|
getActiveSearchSpace: () => ipcRenderer.invoke(IPC_CHANNELS.GET_ACTIVE_SEARCH_SPACE),
|
||||||
|
setActiveSearchSpace: (id: string) =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.SET_ACTIVE_SEARCH_SPACE, id),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -7,4 +7,7 @@ NEXT_PUBLIC_ZERO_CACHE_URL=http://localhost:4848
|
||||||
DATABASE_URL=postgresql://postgres:[YOUR-PASSWORD]@db.sdsf.supabase.co:5432/postgres
|
DATABASE_URL=postgresql://postgres:[YOUR-PASSWORD]@db.sdsf.supabase.co:5432/postgres
|
||||||
|
|
||||||
# Deployment mode (optional)
|
# Deployment mode (optional)
|
||||||
NEXT_PUBLIC_DEPLOYMENT_MODE="self-hosted" or "cloud"
|
NEXT_PUBLIC_DEPLOYMENT_MODE="self-hosted" or "cloud"
|
||||||
|
|
||||||
|
# PostHog analytics (optional, leave empty to disable)
|
||||||
|
NEXT_PUBLIC_POSTHOG_KEY=
|
||||||
|
|
@ -19,6 +19,7 @@ import { OnboardingTour } from "@/components/onboarding-tour";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { useFolderSync } from "@/hooks/use-folder-sync";
|
import { useFolderSync } from "@/hooks/use-folder-sync";
|
||||||
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
|
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
|
||||||
|
import { useElectronAPI } from "@/hooks/use-platform";
|
||||||
|
|
||||||
export function DashboardClientLayout({
|
export function DashboardClientLayout({
|
||||||
children,
|
children,
|
||||||
|
|
@ -139,6 +140,8 @@ export function DashboardClientLayout({
|
||||||
refetchPreferences,
|
refetchPreferences,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const electronAPI = useElectronAPI();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const activeSeacrhSpaceId =
|
const activeSeacrhSpaceId =
|
||||||
typeof search_space_id === "string"
|
typeof search_space_id === "string"
|
||||||
|
|
@ -148,7 +151,19 @@ export function DashboardClientLayout({
|
||||||
: "";
|
: "";
|
||||||
if (!activeSeacrhSpaceId) return;
|
if (!activeSeacrhSpaceId) return;
|
||||||
setActiveSearchSpaceIdState(activeSeacrhSpaceId);
|
setActiveSearchSpaceIdState(activeSeacrhSpaceId);
|
||||||
}, [search_space_id, setActiveSearchSpaceIdState]);
|
|
||||||
|
// Sync to Electron store if stored value is null (first navigation)
|
||||||
|
if (electronAPI?.setActiveSearchSpace) {
|
||||||
|
electronAPI
|
||||||
|
.getActiveSearchSpace?.()
|
||||||
|
.then((stored) => {
|
||||||
|
if (!stored) {
|
||||||
|
electronAPI.setActiveSearchSpace!(activeSeacrhSpaceId);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
}, [search_space_id, setActiveSearchSpaceIdState, electronAPI]);
|
||||||
|
|
||||||
// Determine if we should show loading
|
// Determine if we should show loading
|
||||||
const shouldShowLoading =
|
const shouldShowLoading =
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,71 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { BrainCog, Rocket, Zap } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { DEFAULT_SHORTCUTS, ShortcutRecorder } from "@/components/desktop/shortcut-recorder";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import type { SearchSpace } from "@/contracts/types/search-space.types";
|
||||||
|
import { useElectronAPI } from "@/hooks/use-platform";
|
||||||
|
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||||
|
|
||||||
export function DesktopContent() {
|
export function DesktopContent() {
|
||||||
const [isElectron, setIsElectron] = useState(false);
|
const api = useElectronAPI();
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [enabled, setEnabled] = useState(true);
|
const [enabled, setEnabled] = useState(true);
|
||||||
|
|
||||||
|
const [shortcuts, setShortcuts] = useState(DEFAULT_SHORTCUTS);
|
||||||
|
const [shortcutsLoaded, setShortcutsLoaded] = useState(false);
|
||||||
|
|
||||||
|
const [searchSpaces, setSearchSpaces] = useState<SearchSpace[]>([]);
|
||||||
|
const [activeSpaceId, setActiveSpaceId] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!window.electronAPI) {
|
if (!api) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
setShortcutsLoaded(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setIsElectron(true);
|
|
||||||
|
|
||||||
window.electronAPI.getAutocompleteEnabled().then((val) => {
|
let mounted = true;
|
||||||
setEnabled(val);
|
|
||||||
setLoading(false);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!isElectron) {
|
Promise.all([
|
||||||
|
api.getAutocompleteEnabled(),
|
||||||
|
api.getShortcuts?.() ?? Promise.resolve(null),
|
||||||
|
api.getActiveSearchSpace?.() ?? Promise.resolve(null),
|
||||||
|
searchSpacesApiService.getSearchSpaces(),
|
||||||
|
])
|
||||||
|
.then(([autoEnabled, config, spaceId, spaces]) => {
|
||||||
|
if (!mounted) return;
|
||||||
|
setEnabled(autoEnabled);
|
||||||
|
if (config) setShortcuts(config);
|
||||||
|
setActiveSpaceId(spaceId);
|
||||||
|
if (spaces) setSearchSpaces(spaces);
|
||||||
|
setLoading(false);
|
||||||
|
setShortcutsLoaded(true);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!mounted) return;
|
||||||
|
setLoading(false);
|
||||||
|
setShortcutsLoaded(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
|
if (!api) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
|
|
@ -44,14 +85,120 @@ export function DesktopContent() {
|
||||||
|
|
||||||
const handleToggle = async (checked: boolean) => {
|
const handleToggle = async (checked: boolean) => {
|
||||||
setEnabled(checked);
|
setEnabled(checked);
|
||||||
await window.electronAPI!.setAutocompleteEnabled(checked);
|
await api.setAutocompleteEnabled(checked);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateShortcut = (
|
||||||
|
key: "generalAssist" | "quickAsk" | "autocomplete",
|
||||||
|
accelerator: string
|
||||||
|
) => {
|
||||||
|
setShortcuts((prev) => {
|
||||||
|
const updated = { ...prev, [key]: accelerator };
|
||||||
|
api.setShortcuts?.({ [key]: accelerator }).catch(() => {
|
||||||
|
toast.error("Failed to update shortcut");
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
toast.success("Shortcut updated");
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetShortcut = (key: "generalAssist" | "quickAsk" | "autocomplete") => {
|
||||||
|
updateShortcut(key, DEFAULT_SHORTCUTS[key]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearchSpaceChange = (value: string) => {
|
||||||
|
setActiveSpaceId(value);
|
||||||
|
api.setActiveSearchSpace?.(value);
|
||||||
|
toast.success("Default search space updated");
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 md:space-y-6">
|
<div className="space-y-4 md:space-y-6">
|
||||||
|
{/* Default Search Space */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
|
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
|
||||||
<CardTitle className="text-base md:text-lg">Autocomplete</CardTitle>
|
<CardTitle className="text-base md:text-lg">Default Search Space</CardTitle>
|
||||||
|
<CardDescription className="text-xs md:text-sm">
|
||||||
|
Choose which search space General Assist, Quick Assist, and Extreme Assist operate
|
||||||
|
against.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-3 md:px-6 pb-3 md:pb-6">
|
||||||
|
{searchSpaces.length > 0 ? (
|
||||||
|
<Select value={activeSpaceId ?? undefined} onValueChange={handleSearchSpaceChange}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Select a search space" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{searchSpaces.map((space) => (
|
||||||
|
<SelectItem key={space.id} value={String(space.id)}>
|
||||||
|
{space.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No search spaces found. Create one first.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Keyboard Shortcuts */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
|
||||||
|
<CardTitle className="text-base md:text-lg">Keyboard Shortcuts</CardTitle>
|
||||||
|
<CardDescription className="text-xs md:text-sm">
|
||||||
|
Customize the global keyboard shortcuts for desktop features.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-3 md:px-6 pb-3 md:pb-6">
|
||||||
|
{shortcutsLoaded ? (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<ShortcutRecorder
|
||||||
|
value={shortcuts.generalAssist}
|
||||||
|
onChange={(accel) => updateShortcut("generalAssist", accel)}
|
||||||
|
onReset={() => resetShortcut("generalAssist")}
|
||||||
|
defaultValue={DEFAULT_SHORTCUTS.generalAssist}
|
||||||
|
label="General Assist"
|
||||||
|
description="Launch SurfSense instantly from any application"
|
||||||
|
icon={Rocket}
|
||||||
|
/>
|
||||||
|
<ShortcutRecorder
|
||||||
|
value={shortcuts.quickAsk}
|
||||||
|
onChange={(accel) => updateShortcut("quickAsk", accel)}
|
||||||
|
onReset={() => resetShortcut("quickAsk")}
|
||||||
|
defaultValue={DEFAULT_SHORTCUTS.quickAsk}
|
||||||
|
label="Quick Assist"
|
||||||
|
description="Select text anywhere, then ask AI to explain, rewrite, or act on it"
|
||||||
|
icon={Zap}
|
||||||
|
/>
|
||||||
|
<ShortcutRecorder
|
||||||
|
value={shortcuts.autocomplete}
|
||||||
|
onChange={(accel) => updateShortcut("autocomplete", accel)}
|
||||||
|
onReset={() => resetShortcut("autocomplete")}
|
||||||
|
defaultValue={DEFAULT_SHORTCUTS.autocomplete}
|
||||||
|
label="Extreme Assist"
|
||||||
|
description="AI drafts text using your screen context and knowledge base"
|
||||||
|
icon={BrainCog}
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
Click a shortcut and press a new key combination to change it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex justify-center py-4">
|
||||||
|
<Spinner size="sm" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Extreme Assist Toggle */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
|
||||||
|
<CardTitle className="text-base md:text-lg">Extreme Assist</CardTitle>
|
||||||
<CardDescription className="text-xs md:text-sm">
|
<CardDescription className="text-xs md:text-sm">
|
||||||
Get inline writing suggestions powered by your knowledge base as you type in any app.
|
Get inline writing suggestions powered by your knowledge base as you type in any app.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
|
|
@ -60,7 +207,7 @@ export function DesktopContent() {
|
||||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label htmlFor="autocomplete-toggle" className="text-sm font-medium cursor-pointer">
|
<Label htmlFor="autocomplete-toggle" className="text-sm font-medium cursor-pointer">
|
||||||
Enable autocomplete
|
Enable Extreme Assist
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Show suggestions while typing in other applications.
|
Show suggestions while typing in other applications.
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { USER_QUERY_KEY } from "@/atoms/user/user-query.atoms";
|
import { USER_QUERY_KEY } from "@/atoms/user/user-query.atoms";
|
||||||
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
|
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
|
||||||
import { getBearerToken, redirectToLogin } from "@/lib/auth-utils";
|
import { ensureTokensFromElectron, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
|
||||||
import { queryClient } from "@/lib/query-client/client";
|
import { queryClient } from "@/lib/query-client/client";
|
||||||
|
|
||||||
interface DashboardLayoutProps {
|
interface DashboardLayoutProps {
|
||||||
|
|
@ -17,15 +17,20 @@ export default function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||||
useGlobalLoadingEffect(isCheckingAuth);
|
useGlobalLoadingEffect(isCheckingAuth);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check if user is authenticated
|
async function checkAuth() {
|
||||||
const token = getBearerToken();
|
let token = getBearerToken();
|
||||||
if (!token) {
|
if (!token) {
|
||||||
// Save current path and redirect to login
|
const synced = await ensureTokensFromElectron();
|
||||||
redirectToLogin();
|
if (synced) token = getBearerToken();
|
||||||
return;
|
}
|
||||||
|
if (!token) {
|
||||||
|
redirectToLogin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
queryClient.invalidateQueries({ queryKey: [...USER_QUERY_KEY] });
|
||||||
|
setIsCheckingAuth(false);
|
||||||
}
|
}
|
||||||
queryClient.invalidateQueries({ queryKey: [...USER_QUERY_KEY] });
|
checkAuth();
|
||||||
setIsCheckingAuth(false);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Return null while loading - the global provider handles the loading UI
|
// Return null while loading - the global provider handles the loading UI
|
||||||
|
|
|
||||||
282
surfsense_web/app/desktop/login/page.tsx
Normal file
282
surfsense_web/app/desktop/login/page.tsx
Normal file
|
|
@ -0,0 +1,282 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { IconBrandGoogleFilled } from "@tabler/icons-react";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { BrainCog, Eye, EyeOff, Rocket, Zap } from "lucide-react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { loginMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
|
||||||
|
import { DEFAULT_SHORTCUTS, ShortcutRecorder } from "@/components/desktop/shortcut-recorder";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { useElectronAPI } from "@/hooks/use-platform";
|
||||||
|
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||||
|
import { setBearerToken } from "@/lib/auth-utils";
|
||||||
|
import { AUTH_TYPE, BACKEND_URL } from "@/lib/env-config";
|
||||||
|
|
||||||
|
const isGoogleAuth = AUTH_TYPE === "GOOGLE";
|
||||||
|
|
||||||
|
export default function DesktopLoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const api = useElectronAPI();
|
||||||
|
const [{ mutateAsync: login, isPending: isLoggingIn }] = useAtom(loginMutationAtom);
|
||||||
|
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [loginError, setLoginError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [shortcuts, setShortcuts] = useState(DEFAULT_SHORTCUTS);
|
||||||
|
const [shortcutsLoaded, setShortcutsLoaded] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!api?.getShortcuts) {
|
||||||
|
setShortcutsLoaded(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
api
|
||||||
|
.getShortcuts()
|
||||||
|
.then((config) => {
|
||||||
|
if (config) setShortcuts(config);
|
||||||
|
setShortcutsLoaded(true);
|
||||||
|
})
|
||||||
|
.catch(() => setShortcutsLoaded(true));
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
|
const updateShortcut = useCallback(
|
||||||
|
(key: "generalAssist" | "quickAsk" | "autocomplete", accelerator: string) => {
|
||||||
|
setShortcuts((prev) => {
|
||||||
|
const updated = { ...prev, [key]: accelerator };
|
||||||
|
api?.setShortcuts?.({ [key]: accelerator }).catch(() => {
|
||||||
|
toast.error("Failed to update shortcut");
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
toast.success("Shortcut updated");
|
||||||
|
},
|
||||||
|
[api]
|
||||||
|
);
|
||||||
|
|
||||||
|
const resetShortcut = useCallback(
|
||||||
|
(key: "generalAssist" | "quickAsk" | "autocomplete") => {
|
||||||
|
updateShortcut(key, DEFAULT_SHORTCUTS[key]);
|
||||||
|
},
|
||||||
|
[updateShortcut]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleGoogleLogin = () => {
|
||||||
|
window.location.href = `${BACKEND_URL}/auth/google/authorize-redirect`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const autoSetSearchSpace = async () => {
|
||||||
|
try {
|
||||||
|
const stored = await api?.getActiveSearchSpace?.();
|
||||||
|
if (stored) return;
|
||||||
|
const spaces = await searchSpacesApiService.getSearchSpaces();
|
||||||
|
if (spaces?.length) {
|
||||||
|
await api?.setActiveSearchSpace?.(String(spaces[0].id));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// non-critical — dashboard-sync will catch it later
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLocalLogin = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoginError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await login({
|
||||||
|
username: email,
|
||||||
|
password,
|
||||||
|
grant_type: "password",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
sessionStorage.setItem("login_success_tracked", "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
setBearerToken(data.access_token);
|
||||||
|
await autoSetSearchSpace();
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push(`/auth/callback?token=${data.access_token}`);
|
||||||
|
}, 300);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error) {
|
||||||
|
setLoginError(err.message);
|
||||||
|
} else {
|
||||||
|
setLoginError("Login failed. Please check your credentials.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex min-h-svh items-center justify-center bg-background p-4 sm:p-6">
|
||||||
|
{/* Subtle radial glow */}
|
||||||
|
<div className="pointer-events-none fixed inset-0 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="absolute -top-1/2 left-1/2 size-[800px] -translate-x-1/2 rounded-full opacity-[0.03]"
|
||||||
|
style={{
|
||||||
|
background: "radial-gradient(circle, hsl(var(--primary)) 0%, transparent 70%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative flex w-full max-w-md flex-col overflow-hidden rounded-xl border bg-card shadow-lg">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col items-center px-6 pt-6 pb-2 text-center">
|
||||||
|
<Image
|
||||||
|
src="/icon-128.svg"
|
||||||
|
className="select-none dark:invert size-12 rounded-lg mb-3"
|
||||||
|
alt="SurfSense"
|
||||||
|
width={48}
|
||||||
|
height={48}
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
<h1 className="text-lg font-semibold tracking-tight">Welcome to SurfSense Desktop</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Configure shortcuts, then sign in to get started.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable content */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
{/* ---- Shortcuts ---- */}
|
||||||
|
{shortcutsLoaded ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||||
|
Keyboard Shortcuts
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<ShortcutRecorder
|
||||||
|
value={shortcuts.generalAssist}
|
||||||
|
onChange={(accel) => updateShortcut("generalAssist", accel)}
|
||||||
|
onReset={() => resetShortcut("generalAssist")}
|
||||||
|
defaultValue={DEFAULT_SHORTCUTS.generalAssist}
|
||||||
|
label="General Assist"
|
||||||
|
description="Launch SurfSense instantly from any application"
|
||||||
|
icon={Rocket}
|
||||||
|
/>
|
||||||
|
<ShortcutRecorder
|
||||||
|
value={shortcuts.quickAsk}
|
||||||
|
onChange={(accel) => updateShortcut("quickAsk", accel)}
|
||||||
|
onReset={() => resetShortcut("quickAsk")}
|
||||||
|
defaultValue={DEFAULT_SHORTCUTS.quickAsk}
|
||||||
|
label="Quick Assist"
|
||||||
|
description="Select text anywhere, then ask AI to explain, rewrite, or act on it"
|
||||||
|
icon={Zap}
|
||||||
|
/>
|
||||||
|
<ShortcutRecorder
|
||||||
|
value={shortcuts.autocomplete}
|
||||||
|
onChange={(accel) => updateShortcut("autocomplete", accel)}
|
||||||
|
onReset={() => resetShortcut("autocomplete")}
|
||||||
|
defaultValue={DEFAULT_SHORTCUTS.autocomplete}
|
||||||
|
label="Extreme Assist"
|
||||||
|
description="AI drafts text using your screen context and knowledge base"
|
||||||
|
icon={BrainCog}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground text-center mt-1">
|
||||||
|
Click a shortcut and press a new key combination to change it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex justify-center py-6">
|
||||||
|
<Spinner size="sm" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* ---- Auth ---- */}
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||||
|
Sign In
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{isGoogleAuth ? (
|
||||||
|
<Button variant="outline" className="w-full gap-2 h-10" onClick={handleGoogleLogin}>
|
||||||
|
<IconBrandGoogleFilled className="size-4" />
|
||||||
|
Continue with Google
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleLocalLogin} className="flex flex-col gap-3">
|
||||||
|
{loginError && (
|
||||||
|
<div className="rounded-md border border-destructive/20 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
{loginError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="email" className="text-xs">
|
||||||
|
Email
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
disabled={isLoggingIn}
|
||||||
|
autoFocus
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="password" className="text-xs">
|
||||||
|
Password
|
||||||
|
</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
placeholder="Enter your password"
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
disabled={isLoggingIn}
|
||||||
|
className="h-9 pr-9"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword((v) => !v)}
|
||||||
|
className="absolute inset-y-0 right-0 flex items-center pr-2.5 text-muted-foreground hover:text-foreground"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<EyeOff className="size-3.5" />
|
||||||
|
) : (
|
||||||
|
<Eye className="size-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={isLoggingIn} className="h-9 mt-1">
|
||||||
|
{isLoggingIn ? (
|
||||||
|
<>
|
||||||
|
<Spinner size="sm" className="text-primary-foreground" />
|
||||||
|
Signing in…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Sign in"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
|
||||||
import { Logo } from "@/components/Logo";
|
import { Logo } from "@/components/Logo";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { useElectronAPI } from "@/hooks/use-platform";
|
||||||
|
|
||||||
type PermissionStatus = "authorized" | "denied" | "not determined" | "restricted" | "limited";
|
type PermissionStatus = "authorized" | "denied" | "not determined" | "restricted" | "limited";
|
||||||
|
|
||||||
|
|
@ -58,19 +59,18 @@ function StatusBadge({ status }: { status: PermissionStatus }) {
|
||||||
|
|
||||||
export default function DesktopPermissionsPage() {
|
export default function DesktopPermissionsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const api = useElectronAPI();
|
||||||
const [permissions, setPermissions] = useState<PermissionsStatus | null>(null);
|
const [permissions, setPermissions] = useState<PermissionsStatus | null>(null);
|
||||||
const [isElectron, setIsElectron] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!window.electronAPI) return;
|
if (!api) return;
|
||||||
setIsElectron(true);
|
|
||||||
|
|
||||||
let interval: ReturnType<typeof setInterval> | null = null;
|
let interval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
const isResolved = (s: string) => s === "authorized" || s === "restricted";
|
const isResolved = (s: string) => s === "authorized" || s === "restricted";
|
||||||
|
|
||||||
const poll = async () => {
|
const poll = async () => {
|
||||||
const status = await window.electronAPI!.getPermissionsStatus();
|
const status = await api.getPermissionsStatus();
|
||||||
setPermissions(status);
|
setPermissions(status);
|
||||||
|
|
||||||
if (isResolved(status.accessibility) && isResolved(status.screenRecording)) {
|
if (isResolved(status.accessibility) && isResolved(status.screenRecording)) {
|
||||||
|
|
@ -83,9 +83,9 @@ export default function DesktopPermissionsPage() {
|
||||||
return () => {
|
return () => {
|
||||||
if (interval) clearInterval(interval);
|
if (interval) clearInterval(interval);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [api]);
|
||||||
|
|
||||||
if (!isElectron) {
|
if (!api) {
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex items-center justify-center bg-background">
|
<div className="h-screen flex items-center justify-center bg-background">
|
||||||
<p className="text-muted-foreground">This page is only available in the desktop app.</p>
|
<p className="text-muted-foreground">This page is only available in the desktop app.</p>
|
||||||
|
|
@ -106,15 +106,15 @@ export default function DesktopPermissionsPage() {
|
||||||
|
|
||||||
const handleRequest = async (action: string) => {
|
const handleRequest = async (action: string) => {
|
||||||
if (action === "requestScreenRecording") {
|
if (action === "requestScreenRecording") {
|
||||||
await window.electronAPI!.requestScreenRecording();
|
await api.requestScreenRecording();
|
||||||
} else if (action === "requestAccessibility") {
|
} else if (action === "requestAccessibility") {
|
||||||
await window.electronAPI!.requestAccessibility();
|
await api.requestAccessibility();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleContinue = () => {
|
const handleContinue = () => {
|
||||||
if (allGranted) {
|
if (allGranted) {
|
||||||
window.electronAPI!.restartApp();
|
api.restartApp();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -206,6 +206,7 @@ export default function DesktopPermissionsPage() {
|
||||||
Grant permissions to continue
|
Grant permissions to continue
|
||||||
</Button>
|
</Button>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={handleSkip}
|
onClick={handleSkip}
|
||||||
className="block mx-auto text-xs text-muted-foreground hover:text-foreground transition-colors"
|
className="block mx-auto text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { getBearerToken } from "@/lib/auth-utils";
|
import { useElectronAPI } from "@/hooks/use-platform";
|
||||||
|
import { ensureTokensFromElectron, getBearerToken } from "@/lib/auth-utils";
|
||||||
|
|
||||||
type SSEEvent =
|
type SSEEvent =
|
||||||
| { type: "text-delta"; id: string; delta: string }
|
| { type: "text-delta"; id: string; delta: string }
|
||||||
|
|
@ -9,51 +10,108 @@ type SSEEvent =
|
||||||
| { type: "text-end"; id: string }
|
| { type: "text-end"; id: string }
|
||||||
| { type: "start"; messageId: string }
|
| { type: "start"; messageId: string }
|
||||||
| { type: "finish" }
|
| { type: "finish" }
|
||||||
| { type: "error"; errorText: string };
|
| { type: "error"; errorText: string }
|
||||||
|
| {
|
||||||
|
type: "data-thinking-step";
|
||||||
|
data: { id: string; title: string; status: string; items: string[] };
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "data-suggestions";
|
||||||
|
data: { options: string[] };
|
||||||
|
};
|
||||||
|
|
||||||
function friendlyError(raw: string | number): string {
|
interface AgentStep {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
items: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type FriendlyError = { message: string; isSetup?: boolean };
|
||||||
|
|
||||||
|
function friendlyError(raw: string | number): FriendlyError {
|
||||||
if (typeof raw === "number") {
|
if (typeof raw === "number") {
|
||||||
if (raw === 401) return "Please sign in to use suggestions.";
|
if (raw === 401) return { message: "Please sign in to use suggestions." };
|
||||||
if (raw === 403) return "You don\u2019t have permission for this.";
|
if (raw === 403) return { message: "You don\u2019t have permission for this." };
|
||||||
if (raw === 404) return "Suggestion service not found. Is the backend running?";
|
if (raw === 404) return { message: "Suggestion service not found. Is the backend running?" };
|
||||||
if (raw >= 500) return "Something went wrong on the server. Try again.";
|
if (raw >= 500) return { message: "Something went wrong on the server. Try again." };
|
||||||
return "Something went wrong. Try again.";
|
return { message: "Something went wrong. Try again." };
|
||||||
}
|
}
|
||||||
const lower = raw.toLowerCase();
|
const lower = raw.toLowerCase();
|
||||||
if (lower.includes("not authenticated") || lower.includes("unauthorized"))
|
if (lower.includes("not authenticated") || lower.includes("unauthorized"))
|
||||||
return "Please sign in to use suggestions.";
|
return { message: "Please sign in to use suggestions." };
|
||||||
if (lower.includes("no vision llm configured") || lower.includes("no llm configured"))
|
if (lower.includes("no vision llm configured") || lower.includes("no llm configured"))
|
||||||
return "No Vision LLM configured. Set one in search space settings.";
|
return {
|
||||||
|
message: "Configure a vision-capable model (e.g. GPT-4o, Gemini) to enable autocomplete.",
|
||||||
|
isSetup: true,
|
||||||
|
};
|
||||||
if (lower.includes("does not support vision"))
|
if (lower.includes("does not support vision"))
|
||||||
return "Selected model doesn\u2019t support vision. Set a vision-capable model in settings.";
|
return {
|
||||||
|
message: "The selected model doesn\u2019t support vision. Choose a vision-capable model.",
|
||||||
|
isSetup: true,
|
||||||
|
};
|
||||||
if (lower.includes("fetch") || lower.includes("network") || lower.includes("econnrefused"))
|
if (lower.includes("fetch") || lower.includes("network") || lower.includes("econnrefused"))
|
||||||
return "Can\u2019t reach the server. Check your connection.";
|
return { message: "Can\u2019t reach the server. Check your connection." };
|
||||||
return "Something went wrong. Try again.";
|
return { message: "Something went wrong. Try again." };
|
||||||
}
|
}
|
||||||
|
|
||||||
const AUTO_DISMISS_MS = 3000;
|
const AUTO_DISMISS_MS = 3000;
|
||||||
|
|
||||||
|
function StepIcon({ status }: { status: string }) {
|
||||||
|
if (status === "complete") {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className="step-icon step-icon-done"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
aria-label="Step complete"
|
||||||
|
>
|
||||||
|
<circle cx="8" cy="8" r="7" stroke="#4ade80" strokeWidth="1.5" />
|
||||||
|
<path
|
||||||
|
d="M5 8.5l2 2 4-4.5"
|
||||||
|
stroke="#4ade80"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span className="step-spinner" />;
|
||||||
|
}
|
||||||
|
|
||||||
export default function SuggestionPage() {
|
export default function SuggestionPage() {
|
||||||
const [suggestion, setSuggestion] = useState("");
|
const api = useElectronAPI();
|
||||||
|
const [options, setOptions] = useState<string[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isDesktop, setIsDesktop] = useState(true);
|
const [error, setError] = useState<FriendlyError | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [steps, setSteps] = useState<AgentStep[]>([]);
|
||||||
|
const [expandedOption, setExpandedOption] = useState<number | null>(null);
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
const isDesktop = !!api?.onAutocompleteContext;
|
||||||
if (!window.electronAPI?.onAutocompleteContext) {
|
|
||||||
setIsDesktop(false);
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!error) return;
|
if (!api?.onAutocompleteContext) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!error || error.isSetup) return;
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
window.electronAPI?.dismissSuggestion?.();
|
api?.dismissSuggestion?.();
|
||||||
}, AUTO_DISMISS_MS);
|
}, AUTO_DISMISS_MS);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [error]);
|
}, [error, api]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoading || error || options.length > 0) return;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
api?.dismissSuggestion?.();
|
||||||
|
}, AUTO_DISMISS_MS);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [isLoading, error, options, api]);
|
||||||
|
|
||||||
const fetchSuggestion = useCallback(
|
const fetchSuggestion = useCallback(
|
||||||
async (screenshot: string, searchSpaceId: string, appName?: string, windowTitle?: string) => {
|
async (screenshot: string, searchSpaceId: string, appName?: string, windowTitle?: string) => {
|
||||||
|
|
@ -62,10 +120,16 @@ export default function SuggestionPage() {
|
||||||
abortRef.current = controller;
|
abortRef.current = controller;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setSuggestion("");
|
setOptions([]);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setSteps([]);
|
||||||
|
setExpandedOption(null);
|
||||||
|
|
||||||
const token = getBearerToken();
|
let token = getBearerToken();
|
||||||
|
if (!token) {
|
||||||
|
await ensureTokensFromElectron();
|
||||||
|
token = getBearerToken();
|
||||||
|
}
|
||||||
if (!token) {
|
if (!token) {
|
||||||
setError(friendlyError("not authenticated"));
|
setError(friendlyError("not authenticated"));
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|
@ -123,10 +187,21 @@ export default function SuggestionPage() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed: SSEEvent = JSON.parse(data);
|
const parsed: SSEEvent = JSON.parse(data);
|
||||||
if (parsed.type === "text-delta") {
|
if (parsed.type === "data-suggestions") {
|
||||||
setSuggestion((prev) => prev + parsed.delta);
|
setOptions(parsed.data.options);
|
||||||
} else if (parsed.type === "error") {
|
} else if (parsed.type === "error") {
|
||||||
setError(friendlyError(parsed.errorText));
|
setError(friendlyError(parsed.errorText));
|
||||||
|
} else if (parsed.type === "data-thinking-step") {
|
||||||
|
const { id, title, status, items } = parsed.data;
|
||||||
|
setSteps((prev) => {
|
||||||
|
const existing = prev.findIndex((s) => s.id === id);
|
||||||
|
if (existing >= 0) {
|
||||||
|
const updated = [...prev];
|
||||||
|
updated[existing] = { id, title, status, items };
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
return [...prev, { id, title, status, items }];
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
@ -143,9 +218,9 @@ export default function SuggestionPage() {
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!window.electronAPI?.onAutocompleteContext) return;
|
if (!api?.onAutocompleteContext) return;
|
||||||
|
|
||||||
const cleanup = window.electronAPI.onAutocompleteContext((data) => {
|
const cleanup = api.onAutocompleteContext((data) => {
|
||||||
const searchSpaceId = data.searchSpaceId || "1";
|
const searchSpaceId = data.searchSpaceId || "1";
|
||||||
if (data.screenshot) {
|
if (data.screenshot) {
|
||||||
fetchSuggestion(data.screenshot, searchSpaceId, data.appName, data.windowTitle);
|
fetchSuggestion(data.screenshot, searchSpaceId, data.appName, data.windowTitle);
|
||||||
|
|
@ -153,7 +228,7 @@ export default function SuggestionPage() {
|
||||||
});
|
});
|
||||||
|
|
||||||
return cleanup;
|
return cleanup;
|
||||||
}, [fetchSuggestion]);
|
}, [fetchSuggestion, api]);
|
||||||
|
|
||||||
if (!isDesktop) {
|
if (!isDesktop) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -166,48 +241,140 @@ export default function SuggestionPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
if (error.isSetup) {
|
||||||
|
return (
|
||||||
|
<div className="suggestion-tooltip suggestion-setup">
|
||||||
|
<div className="setup-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" width="28" height="28" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M1 12C1 12 5 4 12 4C19 4 23 12 23 12C23 12 19 20 12 20C5 20 1 12 1 12Z"
|
||||||
|
stroke="#a78bfa"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="3"
|
||||||
|
stroke="#a78bfa"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="setup-content">
|
||||||
|
<span className="setup-title">Vision Model Required</span>
|
||||||
|
<span className="setup-message">{error.message}</span>
|
||||||
|
<span className="setup-hint">Settings → Vision Models</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="setup-dismiss"
|
||||||
|
onClick={() => api?.dismissSuggestion?.()}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="suggestion-tooltip suggestion-error">
|
<div className="suggestion-tooltip suggestion-error">
|
||||||
<span className="suggestion-error-text">{error}</span>
|
<span className="suggestion-error-text">{error.message}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading && !suggestion) {
|
const showLoading = isLoading && options.length === 0;
|
||||||
|
|
||||||
|
if (showLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="suggestion-tooltip">
|
<div className="suggestion-tooltip">
|
||||||
<div className="suggestion-loading">
|
<div className="agent-activity">
|
||||||
<span className="suggestion-dot" />
|
{steps.length === 0 && (
|
||||||
<span className="suggestion-dot" />
|
<div className="activity-initial">
|
||||||
<span className="suggestion-dot" />
|
<span className="step-spinner" />
|
||||||
|
<span className="activity-label">Preparing…</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{steps.length > 0 && (
|
||||||
|
<div className="activity-steps">
|
||||||
|
{steps.map((step) => (
|
||||||
|
<div key={step.id} className="activity-step">
|
||||||
|
<StepIcon status={step.status} />
|
||||||
|
<span className="step-label">
|
||||||
|
{step.title}
|
||||||
|
{step.items.length > 0 && (
|
||||||
|
<span className="step-detail"> · {step.items[0]}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAccept = () => {
|
const handleSelect = (text: string) => {
|
||||||
if (suggestion) {
|
api?.acceptSuggestion?.(text);
|
||||||
window.electronAPI?.acceptSuggestion?.(suggestion);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDismiss = () => {
|
const handleDismiss = () => {
|
||||||
window.electronAPI?.dismissSuggestion?.();
|
api?.dismissSuggestion?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!suggestion) return null;
|
const TRUNCATE_LENGTH = 120;
|
||||||
|
|
||||||
|
if (options.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="suggestion-tooltip suggestion-error">
|
||||||
|
<span className="suggestion-error-text">No suggestions available.</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="suggestion-tooltip">
|
<div className="suggestion-tooltip">
|
||||||
<p className="suggestion-text">{suggestion}</p>
|
<div className="suggestion-options">
|
||||||
|
{options.map((option, index) => {
|
||||||
|
const isExpanded = expandedOption === index;
|
||||||
|
const needsTruncation = option.length > TRUNCATE_LENGTH;
|
||||||
|
const displayText =
|
||||||
|
needsTruncation && !isExpanded ? option.slice(0, TRUNCATE_LENGTH) + "…" : option;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
className="suggestion-option"
|
||||||
|
onClick={() => handleSelect(option)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") handleSelect(option);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="option-number">{index + 1}</span>
|
||||||
|
<span className="option-text">{displayText}</span>
|
||||||
|
{needsTruncation && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="option-expand"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setExpandedOption(isExpanded ? null : index);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isExpanded ? "less" : "more"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
<div className="suggestion-actions">
|
<div className="suggestion-actions">
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="suggestion-btn suggestion-btn-accept"
|
|
||||||
onClick={handleAccept}
|
|
||||||
>
|
|
||||||
Accept
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="suggestion-btn suggestion-btn-dismiss"
|
className="suggestion-btn suggestion-btn-dismiss"
|
||||||
|
|
|
||||||
|
|
@ -19,13 +19,21 @@ body:has(.suggestion-body) {
|
||||||
}
|
}
|
||||||
|
|
||||||
.suggestion-tooltip {
|
.suggestion-tooltip {
|
||||||
|
box-sizing: border-box;
|
||||||
background: #1e1e1e;
|
background: #1e1e1e;
|
||||||
border: 1px solid #3c3c3c;
|
border: 1px solid #3c3c3c;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
margin: 4px;
|
margin: 4px;
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
|
/* MAX_HEIGHT in suggestion-window.ts is 400px. Subtract 8px for margin
|
||||||
|
(4px * 2) so the tooltip + margin fits within the Electron window.
|
||||||
|
box-sizing: border-box ensures padding + border are included. */
|
||||||
|
max-height: 392px;
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.suggestion-text {
|
.suggestion-text {
|
||||||
|
|
@ -35,6 +43,26 @@ body:has(.suggestion-body) {
|
||||||
margin: 0 0 6px 0;
|
margin: 0 0 6px 0;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-text::-webkit-scrollbar {
|
||||||
|
width: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-text::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-text::-webkit-scrollbar-thumb {
|
||||||
|
background: #555;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-text::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #777;
|
||||||
}
|
}
|
||||||
|
|
||||||
.suggestion-actions {
|
.suggestion-actions {
|
||||||
|
|
@ -43,6 +71,7 @@ body:has(.suggestion-body) {
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
border-top: 1px solid #2a2a2a;
|
border-top: 1px solid #2a2a2a;
|
||||||
padding-top: 6px;
|
padding-top: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.suggestion-btn {
|
.suggestion-btn {
|
||||||
|
|
@ -88,38 +117,234 @@ body:has(.suggestion-body) {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.suggestion-loading {
|
/* --- Setup prompt (vision model not configured) --- */
|
||||||
|
|
||||||
|
.suggestion-setup {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 5px;
|
flex-direction: row;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
border-color: #3b2d6b;
|
||||||
|
padding: 10px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #c4b5fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-message {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: #a1a1aa;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-hint {
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: #7c6dac;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-dismiss {
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-self: flex-start;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #6b6b7b;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 4px;
|
||||||
|
line-height: 1;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-dismiss:hover {
|
||||||
|
color: #c4b5fd;
|
||||||
|
background: rgba(124, 109, 172, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Agent activity indicator --- */
|
||||||
|
|
||||||
|
.agent-activity {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 340px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-activity::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-initial {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
padding: 2px 0;
|
padding: 2px 0;
|
||||||
justify-content: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.suggestion-dot {
|
.activity-label {
|
||||||
width: 4px;
|
color: #a1a1aa;
|
||||||
height: 4px;
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-steps {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-step {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-label {
|
||||||
|
color: #d4d4d4;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-detail {
|
||||||
|
color: #71717a;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spinner (in_progress) */
|
||||||
|
.step-spinner {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 1.5px solid #3f3f46;
|
||||||
|
border-top-color: #a78bfa;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #666;
|
animation: step-spin 0.7s linear infinite;
|
||||||
animation: suggestion-pulse 1.2s infinite ease-in-out;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.suggestion-dot:nth-child(2) {
|
/* Checkmark icon (complete) */
|
||||||
animation-delay: 0.15s;
|
.step-icon {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.suggestion-dot:nth-child(3) {
|
@keyframes step-spin {
|
||||||
animation-delay: 0.3s;
|
to {
|
||||||
}
|
transform: rotate(360deg);
|
||||||
|
|
||||||
@keyframes suggestion-pulse {
|
|
||||||
0%,
|
|
||||||
80%,
|
|
||||||
100% {
|
|
||||||
opacity: 0.3;
|
|
||||||
transform: scale(0.8);
|
|
||||||
}
|
|
||||||
40% {
|
|
||||||
opacity: 1;
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Suggestion option cards --- */
|
||||||
|
|
||||||
|
.suggestion-options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-options::-webkit-scrollbar {
|
||||||
|
width: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-options::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-options::-webkit-scrollbar-thumb {
|
||||||
|
background: #555;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 1px solid #333;
|
||||||
|
background: #262626;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
font-family: inherit;
|
||||||
|
transition:
|
||||||
|
background 0.15s,
|
||||||
|
border-color 0.15s;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-option:hover {
|
||||||
|
background: #2a2d3a;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-number {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #3f3f46;
|
||||||
|
color: #d4d4d4;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-option:hover .option-number {
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-text {
|
||||||
|
color: #d4d4d4;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
word-wrap: break-word;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-expand {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #71717a;
|
||||||
|
font-size: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 2px;
|
||||||
|
font-family: inherit;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-expand:hover {
|
||||||
|
color: #a1a1aa;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { ZeroProvider } from "@/components/providers/ZeroProvider";
|
||||||
import { ThemeProvider } from "@/components/theme/theme-provider";
|
import { ThemeProvider } from "@/components/theme/theme-provider";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { LocaleProvider } from "@/contexts/LocaleContext";
|
import { LocaleProvider } from "@/contexts/LocaleContext";
|
||||||
|
import { PlatformProvider } from "@/contexts/platform-context";
|
||||||
import { ReactQueryClientProvider } from "@/lib/query-client/query-client.provider";
|
import { ReactQueryClientProvider } from "@/lib/query-client/query-client.provider";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
@ -139,15 +140,17 @@ export default function RootLayout({
|
||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
defaultTheme="system"
|
defaultTheme="system"
|
||||||
>
|
>
|
||||||
<RootProvider>
|
<PlatformProvider>
|
||||||
<ReactQueryClientProvider>
|
<RootProvider>
|
||||||
<ZeroProvider>
|
<ReactQueryClientProvider>
|
||||||
<GlobalLoadingProvider>{children}</GlobalLoadingProvider>
|
<ZeroProvider>
|
||||||
</ZeroProvider>
|
<GlobalLoadingProvider>{children}</GlobalLoadingProvider>
|
||||||
</ReactQueryClientProvider>
|
</ZeroProvider>
|
||||||
<Toaster />
|
</ReactQueryClientProvider>
|
||||||
<AnnouncementToastProvider />
|
<Toaster />
|
||||||
</RootProvider>
|
<AnnouncementToastProvider />
|
||||||
|
</RootProvider>
|
||||||
|
</PlatformProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
</LocaleProvider>
|
</LocaleProvider>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { atomWithMutation } from "jotai-tanstack-query";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import type {
|
||||||
|
CreateVisionLLMConfigRequest,
|
||||||
|
CreateVisionLLMConfigResponse,
|
||||||
|
DeleteVisionLLMConfigResponse,
|
||||||
|
GetVisionLLMConfigsResponse,
|
||||||
|
UpdateVisionLLMConfigRequest,
|
||||||
|
UpdateVisionLLMConfigResponse,
|
||||||
|
} from "@/contracts/types/new-llm-config.types";
|
||||||
|
import { visionLLMConfigApiService } from "@/lib/apis/vision-llm-config-api.service";
|
||||||
|
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||||
|
import { queryClient } from "@/lib/query-client/client";
|
||||||
|
import { activeSearchSpaceIdAtom } from "../search-spaces/search-space-query.atoms";
|
||||||
|
|
||||||
|
export const createVisionLLMConfigMutationAtom = atomWithMutation((get) => {
|
||||||
|
const searchSpaceId = get(activeSearchSpaceIdAtom);
|
||||||
|
|
||||||
|
return {
|
||||||
|
mutationKey: ["vision-llm-configs", "create"],
|
||||||
|
enabled: !!searchSpaceId,
|
||||||
|
mutationFn: async (request: CreateVisionLLMConfigRequest) => {
|
||||||
|
return visionLLMConfigApiService.createConfig(request);
|
||||||
|
},
|
||||||
|
onSuccess: (_: CreateVisionLLMConfigResponse, request: CreateVisionLLMConfigRequest) => {
|
||||||
|
toast.success(`${request.name} created`);
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: cacheKeys.visionLLMConfigs.all(Number(searchSpaceId)),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error(error.message || "Failed to create vision model");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateVisionLLMConfigMutationAtom = atomWithMutation((get) => {
|
||||||
|
const searchSpaceId = get(activeSearchSpaceIdAtom);
|
||||||
|
|
||||||
|
return {
|
||||||
|
mutationKey: ["vision-llm-configs", "update"],
|
||||||
|
enabled: !!searchSpaceId,
|
||||||
|
mutationFn: async (request: UpdateVisionLLMConfigRequest) => {
|
||||||
|
return visionLLMConfigApiService.updateConfig(request);
|
||||||
|
},
|
||||||
|
onSuccess: (_: UpdateVisionLLMConfigResponse, request: UpdateVisionLLMConfigRequest) => {
|
||||||
|
toast.success(`${request.data.name ?? "Configuration"} updated`);
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: cacheKeys.visionLLMConfigs.all(Number(searchSpaceId)),
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: cacheKeys.visionLLMConfigs.byId(request.id),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error(error.message || "Failed to update vision model");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const deleteVisionLLMConfigMutationAtom = atomWithMutation((get) => {
|
||||||
|
const searchSpaceId = get(activeSearchSpaceIdAtom);
|
||||||
|
|
||||||
|
return {
|
||||||
|
mutationKey: ["vision-llm-configs", "delete"],
|
||||||
|
enabled: !!searchSpaceId,
|
||||||
|
mutationFn: async (request: { id: number; name: string }) => {
|
||||||
|
return visionLLMConfigApiService.deleteConfig(request.id);
|
||||||
|
},
|
||||||
|
onSuccess: (_: DeleteVisionLLMConfigResponse, request: { id: number; name: string }) => {
|
||||||
|
toast.success(`${request.name} deleted`);
|
||||||
|
queryClient.setQueryData(
|
||||||
|
cacheKeys.visionLLMConfigs.all(Number(searchSpaceId)),
|
||||||
|
(oldData: GetVisionLLMConfigsResponse | undefined) => {
|
||||||
|
if (!oldData) return oldData;
|
||||||
|
return oldData.filter((config) => config.id !== request.id);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error(error.message || "Failed to delete vision model");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { atomWithQuery } from "jotai-tanstack-query";
|
||||||
|
import type { LLMModel } from "@/contracts/enums/llm-models";
|
||||||
|
import { VISION_MODELS } from "@/contracts/enums/vision-providers";
|
||||||
|
import { visionLLMConfigApiService } from "@/lib/apis/vision-llm-config-api.service";
|
||||||
|
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||||
|
import { activeSearchSpaceIdAtom } from "../search-spaces/search-space-query.atoms";
|
||||||
|
|
||||||
|
export const visionLLMConfigsAtom = atomWithQuery((get) => {
|
||||||
|
const searchSpaceId = get(activeSearchSpaceIdAtom);
|
||||||
|
|
||||||
|
return {
|
||||||
|
queryKey: cacheKeys.visionLLMConfigs.all(Number(searchSpaceId)),
|
||||||
|
enabled: !!searchSpaceId,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
queryFn: async () => {
|
||||||
|
return visionLLMConfigApiService.getConfigs(Number(searchSpaceId));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const globalVisionLLMConfigsAtom = atomWithQuery(() => {
|
||||||
|
return {
|
||||||
|
queryKey: cacheKeys.visionLLMConfigs.global(),
|
||||||
|
staleTime: 10 * 60 * 1000,
|
||||||
|
queryFn: async () => {
|
||||||
|
return visionLLMConfigApiService.getGlobalConfigs();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const visionModelListAtom = atomWithQuery(() => {
|
||||||
|
return {
|
||||||
|
queryKey: cacheKeys.visionLLMConfigs.modelList(),
|
||||||
|
staleTime: 60 * 60 * 1000,
|
||||||
|
placeholderData: VISION_MODELS,
|
||||||
|
queryFn: async (): Promise<LLMModel[]> => {
|
||||||
|
const data = await visionLLMConfigApiService.getModels();
|
||||||
|
const dynamicModels = data.map((m) => ({
|
||||||
|
value: m.value,
|
||||||
|
label: m.label,
|
||||||
|
provider: m.provider,
|
||||||
|
contextWindow: m.context_window ?? undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const coveredProviders = new Set(dynamicModels.map((m) => m.provider));
|
||||||
|
const staticFallbacks = VISION_MODELS.filter((m) => !coveredProviders.has(m.provider));
|
||||||
|
|
||||||
|
return [...dynamicModels, ...staticFallbacks];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
|
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
|
||||||
|
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||||
import { getAndClearRedirectPath, setBearerToken, setRefreshToken } from "@/lib/auth-utils";
|
import { getAndClearRedirectPath, setBearerToken, setRefreshToken } from "@/lib/auth-utils";
|
||||||
import { trackLoginSuccess } from "@/lib/posthog/events";
|
import { trackLoginSuccess } from "@/lib/posthog/events";
|
||||||
|
|
||||||
|
|
@ -29,52 +30,54 @@ const TokenHandler = ({
|
||||||
useGlobalLoadingEffect(true);
|
useGlobalLoadingEffect(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only run on client-side
|
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
// Read tokens from URL at mount time — no subscription needed.
|
const run = async () => {
|
||||||
// TokenHandler only runs once after an auth redirect, so a stale read
|
const params = new URLSearchParams(window.location.search);
|
||||||
// is impossible and useSearchParams() would add a pointless subscription.
|
const token = params.get(tokenParamName);
|
||||||
// (Vercel Best Practice: rerender-defer-reads 5.2)
|
const refreshToken = params.get("refresh_token");
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
const token = params.get(tokenParamName);
|
|
||||||
const refreshToken = params.get("refresh_token");
|
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
try {
|
try {
|
||||||
// Track login success for OAuth flows (e.g., Google)
|
const alreadyTracked = sessionStorage.getItem("login_success_tracked");
|
||||||
// Local login already tracks success before redirecting here
|
if (!alreadyTracked) {
|
||||||
const alreadyTracked = sessionStorage.getItem("login_success_tracked");
|
trackLoginSuccess("google");
|
||||||
if (!alreadyTracked) {
|
}
|
||||||
// This is an OAuth flow (Google login) - track success
|
sessionStorage.removeItem("login_success_tracked");
|
||||||
trackLoginSuccess("google");
|
|
||||||
|
localStorage.setItem(storageKey, token);
|
||||||
|
setBearerToken(token);
|
||||||
|
|
||||||
|
if (refreshToken) {
|
||||||
|
setRefreshToken(refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-set active search space in desktop if not already set
|
||||||
|
if (window.electronAPI?.getActiveSearchSpace) {
|
||||||
|
try {
|
||||||
|
const stored = await window.electronAPI.getActiveSearchSpace();
|
||||||
|
if (!stored) {
|
||||||
|
const spaces = await searchSpacesApiService.getSearchSpaces();
|
||||||
|
if (spaces?.length) {
|
||||||
|
await window.electronAPI.setActiveSearchSpace?.(String(spaces[0].id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// non-critical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedRedirectPath = getAndClearRedirectPath();
|
||||||
|
const finalRedirectPath = savedRedirectPath || redirectPath;
|
||||||
|
window.location.href = finalRedirectPath;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error storing token in localStorage:", error);
|
||||||
|
window.location.href = redirectPath;
|
||||||
}
|
}
|
||||||
// Clear the flag for future logins
|
|
||||||
sessionStorage.removeItem("login_success_tracked");
|
|
||||||
|
|
||||||
// Store access token in localStorage using both methods for compatibility
|
|
||||||
localStorage.setItem(storageKey, token);
|
|
||||||
setBearerToken(token);
|
|
||||||
|
|
||||||
// Store refresh token if provided
|
|
||||||
if (refreshToken) {
|
|
||||||
setRefreshToken(refreshToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if there's a saved redirect path from before the auth flow
|
|
||||||
const savedRedirectPath = getAndClearRedirectPath();
|
|
||||||
|
|
||||||
// Use the saved path if available, otherwise use the default redirectPath
|
|
||||||
const finalRedirectPath = savedRedirectPath || redirectPath;
|
|
||||||
|
|
||||||
// Redirect to the appropriate path
|
|
||||||
window.location.href = finalRedirectPath;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error storing token in localStorage:", error);
|
|
||||||
// Even if there's an error, try to redirect to the default path
|
|
||||||
window.location.href = redirectPath;
|
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
run();
|
||||||
}, [tokenParamName, storageKey, redirectPath]);
|
}, [tokenParamName, storageKey, redirectPath]);
|
||||||
|
|
||||||
// Return null - the global provider handles the loading UI
|
// Return null - the global provider handles the loading UI
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import {
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { logout } from "@/lib/auth-utils";
|
import { getLoginPath, logout } from "@/lib/auth-utils";
|
||||||
import { resetUser, trackLogout } from "@/lib/posthog/events";
|
import { resetUser, trackLogout } from "@/lib/posthog/events";
|
||||||
|
|
||||||
export function UserDropdown({
|
export function UserDropdown({
|
||||||
|
|
@ -33,22 +33,19 @@ export function UserDropdown({
|
||||||
if (isLoggingOut) return;
|
if (isLoggingOut) return;
|
||||||
setIsLoggingOut(true);
|
setIsLoggingOut(true);
|
||||||
try {
|
try {
|
||||||
// Track logout event and reset PostHog identity
|
|
||||||
trackLogout();
|
trackLogout();
|
||||||
resetUser();
|
resetUser();
|
||||||
|
|
||||||
// Revoke refresh token on server and clear all tokens from localStorage
|
|
||||||
await logout();
|
await logout();
|
||||||
|
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
window.location.href = "/";
|
window.location.href = getLoginPath();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error during logout:", error);
|
console.error("Error during logout:", error);
|
||||||
// Even if there's an error, try to clear tokens and redirect
|
|
||||||
await logout();
|
await logout();
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
window.location.href = "/";
|
window.location.href = getLoginPath();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -87,8 +87,14 @@ import {
|
||||||
} from "@/components/ui/drawer";
|
} from "@/components/ui/drawer";
|
||||||
import { useComments } from "@/hooks/use-comments";
|
import { useComments } from "@/hooks/use-comments";
|
||||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||||
|
import { useElectronAPI } from "@/hooks/use-platform";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
// Captured once at module load — survives client-side navigations that strip the query param.
|
||||||
|
const IS_QUICK_ASSIST_WINDOW =
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
new URLSearchParams(window.location.search).get("quickAssist") === "true";
|
||||||
|
|
||||||
// Dynamically import video presentation tool to avoid loading Babel and Remotion in main bundle
|
// Dynamically import video presentation tool to avoid loading Babel and Remotion in main bundle
|
||||||
const GenerateVideoPresentationToolUI = dynamic(
|
const GenerateVideoPresentationToolUI = dynamic(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -463,16 +469,9 @@ export const AssistantMessage: FC = () => {
|
||||||
const AssistantActionBar: FC = () => {
|
const AssistantActionBar: FC = () => {
|
||||||
const isLast = useAuiState((s) => s.message.isLast);
|
const isLast = useAuiState((s) => s.message.isLast);
|
||||||
const aui = useAui();
|
const aui = useAui();
|
||||||
const [quickAskMode, setQuickAskMode] = useState("");
|
const api = useElectronAPI();
|
||||||
|
|
||||||
useEffect(() => {
|
const isQuickAssist = !!api?.replaceText && IS_QUICK_ASSIST_WINDOW;
|
||||||
if (!isLast || !window.electronAPI?.getQuickAskMode) return;
|
|
||||||
window.electronAPI.getQuickAskMode().then((mode) => {
|
|
||||||
if (mode) setQuickAskMode(mode);
|
|
||||||
});
|
|
||||||
}, [isLast]);
|
|
||||||
|
|
||||||
const isTransform = isLast && !!window.electronAPI?.replaceText && quickAskMode === "transform";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ActionBarPrimitive.Root
|
<ActionBarPrimitive.Root
|
||||||
|
|
@ -482,7 +481,7 @@ const AssistantActionBar: FC = () => {
|
||||||
className="aui-assistant-action-bar-root -ml-1 col-start-3 row-start-2 flex gap-1 text-muted-foreground md:data-floating:absolute md:data-floating:rounded-md md:data-floating:p-1 [&>button]:opacity-100 md:[&>button]:opacity-[var(--aui-button-opacity,1)]"
|
className="aui-assistant-action-bar-root -ml-1 col-start-3 row-start-2 flex gap-1 text-muted-foreground md:data-floating:absolute md:data-floating:rounded-md md:data-floating:p-1 [&>button]:opacity-100 md:[&>button]:opacity-[var(--aui-button-opacity,1)]"
|
||||||
>
|
>
|
||||||
<ActionBarPrimitive.Copy asChild>
|
<ActionBarPrimitive.Copy asChild>
|
||||||
<TooltipIconButton tooltip="Copy">
|
<TooltipIconButton tooltip="Copy to clipboard">
|
||||||
<AuiIf condition={({ message }) => message.isCopied}>
|
<AuiIf condition={({ message }) => message.isCopied}>
|
||||||
<CheckIcon />
|
<CheckIcon />
|
||||||
</AuiIf>
|
</AuiIf>
|
||||||
|
|
@ -492,29 +491,27 @@ const AssistantActionBar: FC = () => {
|
||||||
</TooltipIconButton>
|
</TooltipIconButton>
|
||||||
</ActionBarPrimitive.Copy>
|
</ActionBarPrimitive.Copy>
|
||||||
<ActionBarPrimitive.ExportMarkdown asChild>
|
<ActionBarPrimitive.ExportMarkdown asChild>
|
||||||
<TooltipIconButton tooltip="Download">
|
<TooltipIconButton tooltip="Download as Markdown">
|
||||||
<DownloadIcon />
|
<DownloadIcon />
|
||||||
</TooltipIconButton>
|
</TooltipIconButton>
|
||||||
</ActionBarPrimitive.ExportMarkdown>
|
</ActionBarPrimitive.ExportMarkdown>
|
||||||
{isLast && (
|
{isLast && (
|
||||||
<ActionBarPrimitive.Reload asChild>
|
<ActionBarPrimitive.Reload asChild>
|
||||||
<TooltipIconButton tooltip="Refresh">
|
<TooltipIconButton tooltip="Regenerate response">
|
||||||
<RefreshCwIcon />
|
<RefreshCwIcon />
|
||||||
</TooltipIconButton>
|
</TooltipIconButton>
|
||||||
</ActionBarPrimitive.Reload>
|
</ActionBarPrimitive.Reload>
|
||||||
)}
|
)}
|
||||||
{isTransform && (
|
{isQuickAssist && (
|
||||||
<button
|
<TooltipIconButton
|
||||||
type="button"
|
tooltip="Paste back into source app"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const text = aui.message().getCopyText();
|
const text = aui.message().getCopyText();
|
||||||
window.electronAPI?.replaceText(text);
|
api?.replaceText(text);
|
||||||
}}
|
}}
|
||||||
className="ml-1 inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
|
||||||
>
|
>
|
||||||
<ClipboardPaste className="size-3.5" />
|
<ClipboardPaste />
|
||||||
Paste back
|
</TooltipIconButton>
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</ActionBarPrimitive.Root>
|
</ActionBarPrimitive.Root>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { Search } from "lucide-react";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||||
|
import { usePlatform } from "@/hooks/use-platform";
|
||||||
import { isSelfHosted } from "@/lib/env-config";
|
import { isSelfHosted } from "@/lib/env-config";
|
||||||
import { ConnectorCard } from "../components/connector-card";
|
import { ConnectorCard } from "../components/connector-card";
|
||||||
import {
|
import {
|
||||||
|
|
@ -75,9 +76,8 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
||||||
onManage,
|
onManage,
|
||||||
onViewAccountsList,
|
onViewAccountsList,
|
||||||
}) => {
|
}) => {
|
||||||
// Check if self-hosted mode (for showing self-hosted only connectors)
|
|
||||||
const selfHosted = isSelfHosted();
|
const selfHosted = isSelfHosted();
|
||||||
const isDesktop = typeof window !== "undefined" && !!window.electronAPI;
|
const { isDesktop } = usePlatform();
|
||||||
|
|
||||||
const matchesSearch = (title: string, description: string) =>
|
const matchesSearch = (title: string, description: string) =>
|
||||||
title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ export interface MentionedDocument {
|
||||||
export interface InlineMentionEditorRef {
|
export interface InlineMentionEditorRef {
|
||||||
focus: () => void;
|
focus: () => void;
|
||||||
clear: () => void;
|
clear: () => void;
|
||||||
|
setText: (text: string) => void;
|
||||||
getText: () => string;
|
getText: () => string;
|
||||||
getMentionedDocuments: () => MentionedDocument[];
|
getMentionedDocuments: () => MentionedDocument[];
|
||||||
insertDocumentChip: (doc: Pick<Document, "id" | "title" | "document_type">) => void;
|
insertDocumentChip: (doc: Pick<Document, "id" | "title" | "document_type">) => void;
|
||||||
|
|
@ -397,6 +398,19 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Replace editor content with plain text and place cursor at end
|
||||||
|
const setText = useCallback(
|
||||||
|
(text: string) => {
|
||||||
|
if (!editorRef.current) return;
|
||||||
|
editorRef.current.innerText = text;
|
||||||
|
const empty = text.length === 0;
|
||||||
|
setIsEmpty(empty);
|
||||||
|
onChange?.(text, Array.from(mentionedDocs.values()));
|
||||||
|
focusAtEnd();
|
||||||
|
},
|
||||||
|
[focusAtEnd, onChange, mentionedDocs]
|
||||||
|
);
|
||||||
|
|
||||||
const setDocumentChipStatus = useCallback(
|
const setDocumentChipStatus = useCallback(
|
||||||
(
|
(
|
||||||
docId: number,
|
docId: number,
|
||||||
|
|
@ -469,6 +483,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
focus: () => editorRef.current?.focus(),
|
focus: () => editorRef.current?.focus(),
|
||||||
clear,
|
clear,
|
||||||
|
setText,
|
||||||
getText,
|
getText,
|
||||||
getMentionedDocuments,
|
getMentionedDocuments,
|
||||||
insertDocumentChip,
|
insertDocumentChip,
|
||||||
|
|
|
||||||
|
|
@ -89,17 +89,10 @@ import type { Document } from "@/contracts/types/document.types";
|
||||||
import { useBatchCommentsPreload } from "@/hooks/use-comments";
|
import { useBatchCommentsPreload } from "@/hooks/use-comments";
|
||||||
import { useCommentsSync } from "@/hooks/use-comments-sync";
|
import { useCommentsSync } from "@/hooks/use-comments-sync";
|
||||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||||
|
import { useElectronAPI } from "@/hooks/use-platform";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
/** Placeholder texts that cycle in new chats when input is empty */
|
const COMPOSER_PLACEHOLDER = "Ask anything · Type / for prompts · Type @ to mention docs";
|
||||||
const CYCLING_PLACEHOLDERS = [
|
|
||||||
"Ask SurfSense anything or @mention docs",
|
|
||||||
"Generate a podcast from my vacation ideas in Notion",
|
|
||||||
"Sum up last week's meeting notes from Drive in a bulleted list",
|
|
||||||
"Give me a brief overview of the most urgent tickets in Jira and Linear",
|
|
||||||
"Briefly, what are today's top ten important emails and calendar events?",
|
|
||||||
"Check if this week's Slack messages reference any GitHub issues",
|
|
||||||
];
|
|
||||||
|
|
||||||
export const Thread: FC = () => {
|
export const Thread: FC = () => {
|
||||||
return <ThreadContent />;
|
return <ThreadContent />;
|
||||||
|
|
@ -362,45 +355,23 @@ const Composer: FC = () => {
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const electronAPI = useElectronAPI();
|
||||||
const [clipboardInitialText, setClipboardInitialText] = useState<string | undefined>();
|
const [clipboardInitialText, setClipboardInitialText] = useState<string | undefined>();
|
||||||
const clipboardLoadedRef = useRef(false);
|
const clipboardLoadedRef = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!window.electronAPI || clipboardLoadedRef.current) return;
|
if (!electronAPI || clipboardLoadedRef.current) return;
|
||||||
clipboardLoadedRef.current = true;
|
clipboardLoadedRef.current = true;
|
||||||
window.electronAPI.getQuickAskText().then((text) => {
|
electronAPI.getQuickAskText().then((text) => {
|
||||||
if (text) {
|
if (text) {
|
||||||
setClipboardInitialText(text);
|
setClipboardInitialText(text);
|
||||||
setShowPromptPicker(true);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, []);
|
}, [electronAPI]);
|
||||||
|
|
||||||
const isThreadEmpty = useAuiState(({ thread }) => thread.isEmpty);
|
const isThreadEmpty = useAuiState(({ thread }) => thread.isEmpty);
|
||||||
const isThreadRunning = useAuiState(({ thread }) => thread.isRunning);
|
const isThreadRunning = useAuiState(({ thread }) => thread.isRunning);
|
||||||
|
|
||||||
// Cycling placeholder state - only cycles in new chats
|
const currentPlaceholder = COMPOSER_PLACEHOLDER;
|
||||||
const [placeholderIndex, setPlaceholderIndex] = useState(0);
|
|
||||||
|
|
||||||
// Cycle through placeholders every 4 seconds when thread is empty (new chat)
|
|
||||||
useEffect(() => {
|
|
||||||
// Only cycle when thread is empty (new chat)
|
|
||||||
if (!isThreadEmpty) {
|
|
||||||
// Reset to first placeholder when chat becomes active
|
|
||||||
setPlaceholderIndex(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const intervalId = setInterval(() => {
|
|
||||||
setPlaceholderIndex((prev) => (prev + 1) % CYCLING_PLACEHOLDERS.length);
|
|
||||||
}, 6000);
|
|
||||||
|
|
||||||
return () => clearInterval(intervalId);
|
|
||||||
}, [isThreadEmpty]);
|
|
||||||
|
|
||||||
// Compute current placeholder - only cycle in new chats
|
|
||||||
const currentPlaceholder = isThreadEmpty
|
|
||||||
? CYCLING_PLACEHOLDERS[placeholderIndex]
|
|
||||||
: CYCLING_PLACEHOLDERS[0];
|
|
||||||
|
|
||||||
// Live collaboration state
|
// Live collaboration state
|
||||||
const { data: currentUser } = useAtomValue(currentUserAtom);
|
const { data: currentUser } = useAtomValue(currentUserAtom);
|
||||||
|
|
@ -504,34 +475,28 @@ const Composer: FC = () => {
|
||||||
: userText
|
: userText
|
||||||
? `${action.prompt}\n\n${userText}`
|
? `${action.prompt}\n\n${userText}`
|
||||||
: action.prompt;
|
: action.prompt;
|
||||||
|
editorRef.current?.setText(finalPrompt);
|
||||||
aui.composer().setText(finalPrompt);
|
aui.composer().setText(finalPrompt);
|
||||||
aui.composer().send();
|
|
||||||
editorRef.current?.clear();
|
|
||||||
setShowPromptPicker(false);
|
setShowPromptPicker(false);
|
||||||
setActionQuery("");
|
setActionQuery("");
|
||||||
setMentionedDocuments([]);
|
|
||||||
setSidebarDocs([]);
|
|
||||||
},
|
},
|
||||||
[actionQuery, aui, setMentionedDocuments, setSidebarDocs]
|
[actionQuery, aui]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleQuickAskSelect = useCallback(
|
const handleQuickAskSelect = useCallback(
|
||||||
(action: { name: string; prompt: string; mode: "transform" | "explore" }) => {
|
(action: { name: string; prompt: string; mode: "transform" | "explore" }) => {
|
||||||
if (!clipboardInitialText) return;
|
if (!clipboardInitialText) return;
|
||||||
window.electronAPI?.setQuickAskMode(action.mode);
|
electronAPI?.setQuickAskMode(action.mode);
|
||||||
const finalPrompt = action.prompt.includes("{selection}")
|
const finalPrompt = action.prompt.includes("{selection}")
|
||||||
? action.prompt.replace("{selection}", () => clipboardInitialText)
|
? action.prompt.replace("{selection}", () => clipboardInitialText)
|
||||||
: `${action.prompt}\n\n${clipboardInitialText}`;
|
: `${action.prompt}\n\n${clipboardInitialText}`;
|
||||||
|
editorRef.current?.setText(finalPrompt);
|
||||||
aui.composer().setText(finalPrompt);
|
aui.composer().setText(finalPrompt);
|
||||||
aui.composer().send();
|
|
||||||
editorRef.current?.clear();
|
|
||||||
setShowPromptPicker(false);
|
setShowPromptPicker(false);
|
||||||
setActionQuery("");
|
setActionQuery("");
|
||||||
setClipboardInitialText(undefined);
|
setClipboardInitialText(undefined);
|
||||||
setMentionedDocuments([]);
|
|
||||||
setSidebarDocs([]);
|
|
||||||
},
|
},
|
||||||
[clipboardInitialText, aui, setMentionedDocuments, setSidebarDocs]
|
[clipboardInitialText, electronAPI, aui]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Keyboard navigation for document/action picker (arrow keys, Enter, Escape)
|
// Keyboard navigation for document/action picker (arrow keys, Enter, Escape)
|
||||||
|
|
|
||||||
163
surfsense_web/components/desktop/shortcut-recorder.tsx
Normal file
163
surfsense_web/components/desktop/shortcut-recorder.tsx
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { RotateCcw } from "lucide-react";
|
||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Accelerator <-> display helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function keyEventToAccelerator(e: React.KeyboardEvent): string | null {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (e.ctrlKey || e.metaKey) parts.push("CommandOrControl");
|
||||||
|
if (e.altKey) parts.push("Alt");
|
||||||
|
if (e.shiftKey) parts.push("Shift");
|
||||||
|
|
||||||
|
const key = e.key;
|
||||||
|
if (["Control", "Meta", "Alt", "Shift"].includes(key)) return null;
|
||||||
|
|
||||||
|
if (key === " ") parts.push("Space");
|
||||||
|
else if (key.length === 1) parts.push(key.toUpperCase());
|
||||||
|
else parts.push(key);
|
||||||
|
|
||||||
|
if (parts.length < 2) return null;
|
||||||
|
return parts.join("+");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function acceleratorToDisplay(accel: string): string[] {
|
||||||
|
if (!accel) return [];
|
||||||
|
return accel.split("+").map((part) => {
|
||||||
|
if (part === "CommandOrControl") return "Ctrl";
|
||||||
|
if (part === "Space") return "Space";
|
||||||
|
return part;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_SHORTCUTS = {
|
||||||
|
generalAssist: "CommandOrControl+Shift+S",
|
||||||
|
quickAsk: "CommandOrControl+Alt+S",
|
||||||
|
autocomplete: "CommandOrControl+Shift+Space",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Kbd pill component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function Kbd({ keys, className }: { keys: string[]; className?: string }) {
|
||||||
|
return (
|
||||||
|
<span className={cn("inline-flex items-center gap-0.5", className)}>
|
||||||
|
{keys.map((key, i) => (
|
||||||
|
<kbd
|
||||||
|
key={`${key}-${i}`}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-6 min-w-6 items-center justify-center rounded border bg-muted px-1 font-mono text-[11px] font-medium text-muted-foreground",
|
||||||
|
key.length > 3 && "px-1.5"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{key}
|
||||||
|
</kbd>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shortcut recorder component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function ShortcutRecorder({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onReset,
|
||||||
|
defaultValue,
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
icon: Icon,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (accelerator: string) => void;
|
||||||
|
onReset: () => void;
|
||||||
|
defaultValue: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
icon: React.ElementType;
|
||||||
|
}) {
|
||||||
|
const [recording, setRecording] = useState(false);
|
||||||
|
const inputRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (!recording) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
setRecording(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const accel = keyEventToAccelerator(e);
|
||||||
|
if (accel) {
|
||||||
|
onChange(accel);
|
||||||
|
setRecording(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[recording, onChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const displayKeys = acceleratorToDisplay(value);
|
||||||
|
const isDefault = value === defaultValue;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group flex items-center gap-3 rounded-lg border border-border/60 bg-card px-3 py-2.5 transition-colors hover:border-border">
|
||||||
|
{/* Icon */}
|
||||||
|
<div className="flex size-8 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
|
||||||
|
<Icon className="size-4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Label + description */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-[13px] font-medium leading-none">{label}</p>
|
||||||
|
<p className="mt-1 text-[11px] leading-snug text-muted-foreground">{description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
{!isDefault && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-6 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
onClick={onReset}
|
||||||
|
title="Reset to default"
|
||||||
|
>
|
||||||
|
<RotateCcw className="size-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
ref={inputRef}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRecording(true)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onBlur={() => setRecording(false)}
|
||||||
|
className={cn(
|
||||||
|
"flex h-7 items-center gap-0.5 rounded-md border px-2 transition-all focus:outline-none",
|
||||||
|
recording
|
||||||
|
? "border-primary bg-primary/5 ring-2 ring-primary/20"
|
||||||
|
: "border-input bg-muted/40 hover:bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{recording ? (
|
||||||
|
<span className="text-[11px] text-primary animate-pulse whitespace-nowrap">
|
||||||
|
Press keys…
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Kbd keys={displayKeys} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,39 +1,15 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
import { Monitor } from "lucide-react";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import dynamic from "next/dynamic";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type React from "react";
|
import React, { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import Balancer from "react-wrap-balancer";
|
import Balancer from "react-wrap-balancer";
|
||||||
|
import { ExpandedMediaOverlay, useExpandedMedia } from "@/components/ui/expanded-gif-overlay";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { AUTH_TYPE, BACKEND_URL } from "@/lib/env-config";
|
import { AUTH_TYPE, BACKEND_URL } from "@/lib/env-config";
|
||||||
import { trackLoginAttempt } from "@/lib/posthog/events";
|
import { trackLoginAttempt } from "@/lib/posthog/events";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const HeroCarousel = dynamic(
|
|
||||||
() => import("@/components/ui/hero-carousel").then((m) => ({ default: m.HeroCarousel })),
|
|
||||||
{
|
|
||||||
ssr: false,
|
|
||||||
loading: () => (
|
|
||||||
<div className="w-full py-4 sm:py-8">
|
|
||||||
<div className="mx-auto w-full max-w-[900px]">
|
|
||||||
<div className="overflow-hidden rounded-2xl border border-neutral-200/60 bg-white shadow-xl sm:rounded-3xl dark:border-neutral-700/60 dark:bg-neutral-900">
|
|
||||||
<div className="flex items-center gap-3 border-b border-neutral-200/60 px-4 py-3 sm:px-6 sm:py-4 dark:border-neutral-700/60">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="h-5 w-32 animate-pulse rounded bg-neutral-200 dark:bg-neutral-700" />
|
|
||||||
<div className="mt-2 h-4 w-64 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-neutral-50 p-2 sm:p-3 dark:bg-neutral-950">
|
|
||||||
<div className="aspect-video w-full animate-pulse rounded-lg bg-neutral-100 sm:rounded-xl dark:bg-neutral-800" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Official Google "G" logo with brand colors
|
|
||||||
const GoogleLogo = ({ className }: { className?: string }) => (
|
const GoogleLogo = ({ className }: { className?: string }) => (
|
||||||
<svg
|
<svg
|
||||||
className={className}
|
className={className}
|
||||||
|
|
@ -62,87 +38,99 @@ const GoogleLogo = ({ className }: { className?: string }) => (
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
function useIsDesktop(breakpoint = 1024) {
|
const TAB_ITEMS = [
|
||||||
const [isDesktop, setIsDesktop] = useState(false);
|
{
|
||||||
useEffect(() => {
|
title: "Connect & Sync",
|
||||||
const mql = window.matchMedia(`(min-width: ${breakpoint}px)`);
|
description:
|
||||||
setIsDesktop(mql.matches);
|
"Connect data sources like Notion, Drive and Gmail. Automatically sync to keep them updated.",
|
||||||
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
|
src: "/homepage/hero_tutorial/ConnectorFlowGif.mp4",
|
||||||
mql.addEventListener("change", handler);
|
featured: true,
|
||||||
return () => mql.removeEventListener("change", handler);
|
},
|
||||||
}, [breakpoint]);
|
{
|
||||||
return isDesktop;
|
title: "Upload Documents",
|
||||||
}
|
description: "Upload documents directly, from images to massive PDFs.",
|
||||||
|
src: "/homepage/hero_tutorial/DocUploadGif.mp4",
|
||||||
|
featured: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Search & Citation",
|
||||||
|
description: "Ask questions and get cited responses from your knowledge base.",
|
||||||
|
src: "/homepage/hero_tutorial/BSNCGif.mp4",
|
||||||
|
featured: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Document Q&A",
|
||||||
|
description: "Mention specific documents in chat for targeted answers.",
|
||||||
|
src: "/homepage/hero_tutorial/BQnaGif_compressed.mp4",
|
||||||
|
featured: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Reports",
|
||||||
|
description: "Generate reports from your sources in many formats.",
|
||||||
|
src: "/homepage/hero_tutorial/ReportGenGif_compressed.mp4",
|
||||||
|
featured: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Podcasts",
|
||||||
|
description: "Turn anything into a podcast in under 20 seconds.",
|
||||||
|
src: "/homepage/hero_tutorial/PodcastGenGif.mp4",
|
||||||
|
featured: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Image Generation",
|
||||||
|
description: "Generate high-quality images easily from your conversations.",
|
||||||
|
src: "/homepage/hero_tutorial/ImageGenGif.mp4",
|
||||||
|
featured: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Collaborative Chat",
|
||||||
|
description: "Collaborate on AI-powered conversations in realtime with your team.",
|
||||||
|
src: "/homepage/hero_realtime/RealTimeChatGif.mp4",
|
||||||
|
featured: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Comments",
|
||||||
|
description: "Add comments and tag teammates on any message.",
|
||||||
|
src: "/homepage/hero_realtime/RealTimeCommentsFlow.mp4",
|
||||||
|
featured: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Video Generation",
|
||||||
|
description: "Create short videos with AI-generated visuals and narration from your sources.",
|
||||||
|
src: "/homepage/hero_tutorial/video_gen_surf.mp4",
|
||||||
|
featured: false,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
export function HeroSection() {
|
export function HeroSection() {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const parentRef = useRef<HTMLDivElement>(null);
|
|
||||||
const isDesktop = useIsDesktop();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="mx-auto w-full max-w-7xl min-w-0 pt-36">
|
||||||
ref={parentRef}
|
<div className="mt-4 flex w-full min-w-0 flex-col items-start px-2 md:px-8 xl:px-0">
|
||||||
className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4 py-24 md:px-8 md:py-48"
|
<h1
|
||||||
>
|
className={cn(
|
||||||
<BackgroundGrids />
|
"relative mt-4 max-w-7xl text-left text-4xl font-bold tracking-tight text-balance text-neutral-900 sm:text-5xl md:text-6xl xl:text-8xl dark:text-neutral-50"
|
||||||
{isDesktop && (
|
)}
|
||||||
<>
|
>
|
||||||
<CollisionMechanism
|
<Balancer>NotebookLM for Teams</Balancer>
|
||||||
parentRef={parentRef}
|
</h1>
|
||||||
beamOptions={{
|
<div className="mt-4 flex w-full flex-col items-start justify-between gap-4 md:mt-12 md:flex-row md:items-end md:gap-10">
|
||||||
initialX: -400,
|
<div>
|
||||||
translateX: 600,
|
<h2
|
||||||
duration: 7,
|
className={cn(
|
||||||
repeatDelay: 3,
|
"relative mb-8 max-w-2xl text-left text-sm tracking-wide text-neutral-600 antialiased sm:text-base md:text-xl dark:text-neutral-400"
|
||||||
}}
|
)}
|
||||||
/>
|
>
|
||||||
<CollisionMechanism
|
An open source, privacy focused alternative to NotebookLM for teams with no data
|
||||||
parentRef={parentRef}
|
limits.
|
||||||
beamOptions={{
|
</h2>
|
||||||
initialX: -200,
|
|
||||||
translateX: 800,
|
|
||||||
duration: 4,
|
|
||||||
repeatDelay: 3,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<CollisionMechanism
|
|
||||||
parentRef={parentRef}
|
|
||||||
beamOptions={{
|
|
||||||
initialX: 200,
|
|
||||||
translateX: 1200,
|
|
||||||
duration: 5,
|
|
||||||
repeatDelay: 3,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<CollisionMechanism
|
|
||||||
parentRef={parentRef}
|
|
||||||
beamOptions={{
|
|
||||||
initialX: 400,
|
|
||||||
translateX: 1400,
|
|
||||||
duration: 6,
|
|
||||||
repeatDelay: 3,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<h2 className="relative z-50 mx-auto mb-4 mt-8 max-w-4xl text-balance text-center text-3xl font-semibold tracking-tight text-gray-700 md:text-7xl dark:text-neutral-300">
|
<div className="relative mb-4 flex w-full flex-col justify-center gap-y-2 sm:flex-row sm:justify-start sm:space-y-0 sm:space-x-4">
|
||||||
<div className="relative mx-auto inline-block w-max filter-[drop-shadow(0px_1px_3px_rgba(27,37,80,0.14))]">
|
<GetStartedButton />
|
||||||
<div className="text-black [text-shadow:0_0_rgba(0,0,0,0.1)] dark:text-white">
|
</div>
|
||||||
<Balancer>NotebookLM for Teams</Balancer>
|
|
||||||
</div>
|
</div>
|
||||||
|
<DownloadApp />
|
||||||
</div>
|
</div>
|
||||||
</h2>
|
<BrowserWindow />
|
||||||
<p className="relative z-50 mx-auto mt-4 max-w-lg px-6 text-center text-sm leading-relaxed text-gray-600 sm:text-base sm:leading-relaxed md:max-w-xl md:text-lg md:leading-relaxed dark:text-gray-200">
|
|
||||||
Connect any LLM to your internal knowledge sources and chat with it in real time alongside
|
|
||||||
your team.
|
|
||||||
</p>
|
|
||||||
<div className="mb-6 mt-6 flex w-full flex-col items-center justify-center gap-4 px-8 sm:flex-row md:mb-10">
|
|
||||||
<GetStartedButton />
|
|
||||||
{/* <ContactSalesButton /> */}
|
|
||||||
</div>
|
|
||||||
<div ref={containerRef} className="relative w-full z-51">
|
|
||||||
<HeroCarousel />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -158,256 +146,247 @@ function GetStartedButton() {
|
||||||
|
|
||||||
if (isGoogleAuth) {
|
if (isGoogleAuth) {
|
||||||
return (
|
return (
|
||||||
<motion.button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleGoogleLogin}
|
onClick={handleGoogleLogin}
|
||||||
whileHover="hover"
|
className="flex h-14 w-full cursor-pointer items-center justify-center gap-3 rounded-lg bg-white text-center text-base font-medium text-neutral-700 shadow-sm ring-1 shadow-black/10 ring-black/10 transition duration-150 active:scale-98 hover:bg-neutral-50 sm:w-56 dark:bg-neutral-900 dark:text-neutral-200 dark:ring-neutral-700/50 dark:hover:bg-neutral-800"
|
||||||
whileTap={{ scale: 0.98 }}
|
|
||||||
initial="idle"
|
|
||||||
className="group relative z-20 flex h-11 w-full cursor-pointer items-center justify-center gap-3 overflow-hidden rounded-xl bg-white px-6 py-2.5 text-sm font-semibold text-neutral-700 shadow-lg ring-1 ring-neutral-200/50 transition-shadow duration-300 hover:shadow-xl sm:w-56 dark:bg-neutral-900 dark:text-neutral-200 dark:ring-neutral-700/50"
|
|
||||||
variants={{
|
|
||||||
idle: { scale: 1, y: 0 },
|
|
||||||
hover: { scale: 1.02, y: -2 },
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* Animated gradient background on hover */}
|
<GoogleLogo className="h-5 w-5" />
|
||||||
<motion.div
|
<span>Continue with Google</span>
|
||||||
className="absolute inset-0 bg-linear-to-r from-blue-50 via-green-50 to-yellow-50 dark:from-blue-950/30 dark:via-green-950/30 dark:to-yellow-950/30"
|
</button>
|
||||||
variants={{
|
|
||||||
idle: { opacity: 0 },
|
|
||||||
hover: { opacity: 1 },
|
|
||||||
}}
|
|
||||||
transition={{ duration: 0.3 }}
|
|
||||||
/>
|
|
||||||
{/* Google logo with subtle animation */}
|
|
||||||
<motion.div
|
|
||||||
className="relative"
|
|
||||||
variants={{
|
|
||||||
idle: { rotate: 0 },
|
|
||||||
hover: { rotate: [0, -8, 8, 0] },
|
|
||||||
}}
|
|
||||||
transition={{ duration: 0.4, ease: "easeInOut" }}
|
|
||||||
>
|
|
||||||
<GoogleLogo className="h-5 w-5" />
|
|
||||||
</motion.div>
|
|
||||||
<span className="relative">Continue with Google</span>
|
|
||||||
</motion.button>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div whileHover={{ scale: 1.02, y: -2 }} whileTap={{ scale: 0.98 }}>
|
<Link
|
||||||
<Link
|
href="/login"
|
||||||
href="/login"
|
className="flex h-14 w-full items-center justify-center rounded-lg bg-black text-center text-base font-medium text-white shadow-sm ring-1 shadow-black/10 ring-black/10 transition duration-150 active:scale-98 sm:w-52 dark:bg-white dark:text-black"
|
||||||
className="group relative z-20 flex h-11 w-full cursor-pointer items-center justify-center gap-2 rounded-xl bg-black px-6 py-2.5 text-sm font-semibold text-white shadow-lg transition-shadow duration-300 hover:shadow-xl sm:w-56 dark:bg-white dark:text-black"
|
>
|
||||||
>
|
Get Started
|
||||||
Get Started
|
</Link>
|
||||||
</Link>
|
|
||||||
</motion.div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const BackgroundGrids = () => {
|
const BrowserWindow = () => {
|
||||||
return (
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
<div className="pointer-events-none absolute inset-0 z-0 grid h-screen w-full -rotate-45 transform select-none grid-cols-2 gap-10 md:grid-cols-4">
|
const selectedItem = TAB_ITEMS[selectedIndex];
|
||||||
<div className="relative h-full w-full">
|
const { expanded, open, close } = useExpandedMedia();
|
||||||
<GridLineVertical className="left-0" />
|
|
||||||
<GridLineVertical className="left-auto right-0" />
|
|
||||||
</div>
|
|
||||||
<div className="relative h-full w-full">
|
|
||||||
<GridLineVertical className="left-0" />
|
|
||||||
<GridLineVertical className="left-auto right-0" />
|
|
||||||
</div>
|
|
||||||
<div className="relative h-full w-full bg-linear-to-b from-transparent via-neutral-100 to-transparent dark:via-neutral-800">
|
|
||||||
<GridLineVertical className="left-0" />
|
|
||||||
<GridLineVertical className="left-auto right-0" />
|
|
||||||
</div>
|
|
||||||
<div className="relative h-full w-full">
|
|
||||||
<GridLineVertical className="left-0" />
|
|
||||||
<GridLineVertical className="left-auto right-0" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const CollisionMechanism = ({
|
|
||||||
parentRef,
|
|
||||||
beamOptions = {},
|
|
||||||
}: {
|
|
||||||
parentRef: React.RefObject<HTMLDivElement | null>;
|
|
||||||
beamOptions?: {
|
|
||||||
initialX?: number;
|
|
||||||
translateX?: number;
|
|
||||||
initialY?: number;
|
|
||||||
translateY?: number;
|
|
||||||
rotate?: number;
|
|
||||||
className?: string;
|
|
||||||
duration?: number;
|
|
||||||
delay?: number;
|
|
||||||
repeatDelay?: number;
|
|
||||||
};
|
|
||||||
}) => {
|
|
||||||
const beamRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [collision, setCollision] = useState<{
|
|
||||||
detected: boolean;
|
|
||||||
coordinates: { x: number; y: number } | null;
|
|
||||||
}>({ detected: false, coordinates: null });
|
|
||||||
const [beamKey, setBeamKey] = useState(0);
|
|
||||||
const [cycleCollisionDetected, setCycleCollisionDetected] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const checkCollision = () => {
|
|
||||||
if (beamRef.current && parentRef.current && !cycleCollisionDetected) {
|
|
||||||
const beamRect = beamRef.current.getBoundingClientRect();
|
|
||||||
const parentRect = parentRef.current.getBoundingClientRect();
|
|
||||||
const rightEdge = parentRect.right;
|
|
||||||
|
|
||||||
if (beamRect.right >= rightEdge - 20) {
|
|
||||||
const relativeX = parentRect.width - 20;
|
|
||||||
const relativeY = beamRect.top - parentRect.top + beamRect.height / 2;
|
|
||||||
|
|
||||||
setCollision({
|
|
||||||
detected: true,
|
|
||||||
coordinates: { x: relativeX, y: relativeY },
|
|
||||||
});
|
|
||||||
setCycleCollisionDetected(true);
|
|
||||||
if (beamRef.current) {
|
|
||||||
beamRef.current.style.opacity = "0";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const animationInterval = setInterval(checkCollision, 100);
|
|
||||||
|
|
||||||
return () => clearInterval(animationInterval);
|
|
||||||
}, [cycleCollisionDetected, parentRef]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!collision.detected || !collision.coordinates) return;
|
|
||||||
|
|
||||||
const timer1 = setTimeout(() => {
|
|
||||||
setCollision({ detected: false, coordinates: null });
|
|
||||||
setCycleCollisionDetected(false);
|
|
||||||
if (beamRef.current) {
|
|
||||||
beamRef.current.style.opacity = "1";
|
|
||||||
}
|
|
||||||
}, 2000);
|
|
||||||
|
|
||||||
const timer2 = setTimeout(() => {
|
|
||||||
setBeamKey((prevKey) => prevKey + 1);
|
|
||||||
}, 2000);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearTimeout(timer1);
|
|
||||||
clearTimeout(timer2);
|
|
||||||
};
|
|
||||||
}, [collision]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<motion.div
|
<motion.div className="relative my-4 flex w-full flex-col items-start justify-start overflow-hidden rounded-2xl shadow-2xl md:my-12">
|
||||||
key={beamKey}
|
<div className="flex w-full items-center justify-start overflow-hidden bg-gray-200 py-4 pl-4 dark:bg-neutral-800">
|
||||||
ref={beamRef}
|
<div className="mr-6 flex items-center gap-2">
|
||||||
animate="animate"
|
<div className="size-3 rounded-full bg-red-500" />
|
||||||
initial={{
|
<div className="size-3 rounded-full bg-yellow-500" />
|
||||||
translateY: beamOptions.initialY || "-200px",
|
<div className="size-3 rounded-full bg-green-500" />
|
||||||
translateX: beamOptions.initialX || "0px",
|
</div>
|
||||||
rotate: beamOptions.rotate || -45,
|
<div className="no-visible-scrollbar flex min-w-0 shrink flex-row items-center justify-start gap-2 overflow-x-auto mask-l-from-98% py-0.5 pr-2 pl-2 md:pl-4">
|
||||||
}}
|
{TAB_ITEMS.map((item, index) => (
|
||||||
variants={{
|
<React.Fragment key={item.title}>
|
||||||
animate: {
|
<button
|
||||||
translateY: beamOptions.translateY || "800px",
|
type="button"
|
||||||
translateX: beamOptions.translateX || "700px",
|
onClick={() => setSelectedIndex(index)}
|
||||||
rotate: beamOptions.rotate || -45,
|
className={cn(
|
||||||
},
|
"flex shrink-0 items-center gap-1.5 rounded-md px-2 py-1 text-xs transition duration-150 hover:bg-white sm:text-sm dark:hover:bg-neutral-950",
|
||||||
}}
|
selectedIndex === index &&
|
||||||
transition={{
|
!item.featured &&
|
||||||
duration: beamOptions.duration || 8,
|
"bg-white shadow ring-1 shadow-black/10 ring-black/10 dark:bg-neutral-900",
|
||||||
repeat: Infinity,
|
selectedIndex === index &&
|
||||||
repeatType: "loop",
|
item.featured &&
|
||||||
ease: "linear",
|
"bg-amber-50 shadow ring-1 shadow-amber-200/50 ring-amber-400/60 dark:bg-amber-950/40 dark:shadow-amber-900/30 dark:ring-amber-500/50",
|
||||||
delay: beamOptions.delay || 0,
|
item.featured &&
|
||||||
repeatDelay: beamOptions.repeatDelay || 0,
|
selectedIndex !== index &&
|
||||||
}}
|
"hover:bg-amber-50 dark:hover:bg-amber-950/30"
|
||||||
className={cn(
|
)}
|
||||||
"absolute left-96 top-20 m-auto h-14 w-px rounded-full bg-linear-to-t from-orange-500 via-yellow-500 to-transparent will-change-transform",
|
>
|
||||||
beamOptions.className
|
{item.title}
|
||||||
)}
|
{item.featured && (
|
||||||
/>
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="inline-flex shrink-0 items-center justify-center rounded border border-amber-300 bg-amber-100 p-0.5 text-amber-700 dark:border-amber-700 dark:bg-amber-900/50 dark:text-amber-400">
|
||||||
|
<Monitor className="size-3" />
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">Desktop app only</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{index !== TAB_ITEMS.length - 1 && (
|
||||||
|
<div className="h-4 w-px shrink-0 rounded-full bg-neutral-300 dark:bg-neutral-700" />
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full overflow-hidden bg-gray-100/50 px-4 pt-4 perspective-distant dark:bg-neutral-950">
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.div
|
||||||
|
initial={{
|
||||||
|
opacity: 0,
|
||||||
|
scale: 0.99,
|
||||||
|
filter: "blur(10px)",
|
||||||
|
}}
|
||||||
|
animate={{
|
||||||
|
opacity: 1,
|
||||||
|
scale: 1,
|
||||||
|
filter: "blur(0px)",
|
||||||
|
}}
|
||||||
|
exit={{
|
||||||
|
opacity: 0,
|
||||||
|
scale: 0.98,
|
||||||
|
filter: "blur(10px)",
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 0.3,
|
||||||
|
ease: "easeOut",
|
||||||
|
}}
|
||||||
|
key={selectedItem.title}
|
||||||
|
className="relative overflow-hidden rounded-tl-xl rounded-tr-xl bg-white shadow-sm ring-1 shadow-black/10 ring-black/10 will-change-transform dark:bg-neutral-950"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 border-b border-neutral-200/60 px-4 py-3 sm:px-6 sm:py-4 dark:border-neutral-700/60">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="truncate text-base font-semibold text-neutral-900 sm:text-lg dark:text-white">
|
||||||
|
{selectedItem.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
|
{selectedItem.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="cursor-pointer bg-neutral-50 p-2 sm:p-3 dark:bg-neutral-950 w-full"
|
||||||
|
onClick={open}
|
||||||
|
>
|
||||||
|
<TabVideo src={selectedItem.src} />
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{collision.detected && collision.coordinates && (
|
{expanded && (
|
||||||
<Explosion
|
<ExpandedMediaOverlay src={selectedItem.src} alt={selectedItem.title} onClose={close} />
|
||||||
key={`${collision.coordinates.x}-${collision.coordinates.y}`}
|
|
||||||
className=""
|
|
||||||
style={{
|
|
||||||
left: `${collision.coordinates.x + 20}px`,
|
|
||||||
top: `${collision.coordinates.y}px`,
|
|
||||||
transform: "translate(-50%, -50%)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Explosion = ({ ...props }: React.HTMLProps<HTMLDivElement>) => {
|
const TabVideo = memo(function TabVideo({ src }: { src: string }) {
|
||||||
const spans = Array.from({ length: 20 }, (_, index) => ({
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
id: index,
|
const [hasLoaded, setHasLoaded] = useState(false);
|
||||||
initialX: 0,
|
|
||||||
initialY: 0,
|
useEffect(() => {
|
||||||
directionX: Math.floor(Math.random() * 80 - 40),
|
setHasLoaded(false);
|
||||||
directionY: Math.floor(Math.random() * -50 - 10),
|
const video = videoRef.current;
|
||||||
}));
|
if (!video) return;
|
||||||
|
video.currentTime = 0;
|
||||||
|
video.play().catch(() => {});
|
||||||
|
}, [src]);
|
||||||
|
|
||||||
|
const handleCanPlay = useCallback(() => {
|
||||||
|
setHasLoaded(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div {...props} className={cn("absolute z-50 h-2 w-2", props.className)}>
|
<div className="relative">
|
||||||
<motion.div
|
<video
|
||||||
initial={{ opacity: 0 }}
|
ref={videoRef}
|
||||||
animate={{ opacity: [0, 1, 0] }}
|
key={src}
|
||||||
exit={{ opacity: 0 }}
|
src={src}
|
||||||
transition={{ duration: 1, ease: "easeOut" }}
|
preload="auto"
|
||||||
className="absolute -inset-x-10 top-0 m-auto h-[4px] w-10 rounded-full bg-linear-to-r from-transparent via-orange-500 to-transparent blur-sm"
|
loop
|
||||||
></motion.div>
|
muted
|
||||||
{spans.map((span) => (
|
playsInline
|
||||||
<motion.span
|
onCanPlay={handleCanPlay}
|
||||||
key={span.id}
|
className="aspect-video w-full rounded-lg sm:rounded-xl"
|
||||||
initial={{ x: span.initialX, y: span.initialY, opacity: 1 }}
|
/>
|
||||||
animate={{ x: span.directionX, y: span.directionY, opacity: 0 }}
|
{!hasLoaded && (
|
||||||
transition={{ duration: Math.random() * 1.5 + 0.5, ease: "easeOut" }}
|
<div className="absolute inset-0 aspect-video w-full animate-pulse rounded-lg bg-neutral-100 sm:rounded-xl dark:bg-neutral-800" />
|
||||||
className="absolute h-1 w-1 rounded-full bg-linear-to-b from-orange-500 to-yellow-500"
|
)}
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
const GridLineVertical = ({ className, offset }: { className?: string; offset?: string }) => {
|
const GITHUB_RELEASES_URL = "https://github.com/MODSetter/SurfSense/releases/latest";
|
||||||
|
|
||||||
|
const DownloadApp = memo(function DownloadApp() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="flex flex-col items-start justify-start">
|
||||||
style={
|
<p className="mb-4 text-left text-sm text-neutral-500 lg:text-lg dark:text-neutral-400">
|
||||||
{
|
Download the desktop app
|
||||||
"--background": "#ffffff",
|
</p>
|
||||||
"--color": "rgba(0, 0, 0, 0.2)",
|
<div className="mb-2 flex flex-row flex-wrap items-center gap-3">
|
||||||
"--height": "5px",
|
<a
|
||||||
"--width": "1px",
|
href={GITHUB_RELEASES_URL}
|
||||||
"--fade-stop": "90%",
|
target="_blank"
|
||||||
"--offset": offset || "150px", //-100px if you want to keep the line inside
|
rel="noopener noreferrer"
|
||||||
"--color-dark": "rgba(255, 255, 255, 0.3)",
|
className="flex items-center gap-2 rounded-lg border border-neutral-200 bg-white px-4 py-2.5 text-sm font-medium text-neutral-700 shadow-sm transition hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-200 dark:hover:bg-neutral-800"
|
||||||
maskComposite: "exclude",
|
>
|
||||||
} as React.CSSProperties
|
<svg
|
||||||
}
|
className="size-4"
|
||||||
className={cn(
|
viewBox="0 0 24 24"
|
||||||
"absolute top-[calc(var(--offset)/2*-1)] h-[calc(100%+var(--offset))] w-(--width)",
|
fill="none"
|
||||||
"bg-[linear-gradient(to_bottom,var(--color),var(--color)_50%,transparent_0,transparent)]",
|
stroke="currentColor"
|
||||||
"bg-size-[var(--width)_var(--height)]",
|
strokeWidth="2"
|
||||||
"[mask:linear-gradient(to_top,var(--background)_var(--fade-stop),transparent),linear-gradient(to_bottom,var(--background)_var(--fade-stop),transparent),linear-gradient(black,black)]",
|
strokeLinecap="round"
|
||||||
"mask-exclude",
|
strokeLinejoin="round"
|
||||||
"z-30",
|
aria-label="Download for macOS"
|
||||||
"dark:bg-[linear-gradient(to_bottom,var(--color-dark),var(--color-dark)_50%,transparent_0,transparent)]",
|
>
|
||||||
className
|
<path d="M12 17V3" />
|
||||||
)}
|
<path d="m6 11 6 6 6-6" />
|
||||||
></div>
|
<path d="M19 21H5" />
|
||||||
|
</svg>
|
||||||
|
macOS
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={GITHUB_RELEASES_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-2 rounded-lg border border-neutral-200 bg-white px-4 py-2.5 text-sm font-medium text-neutral-700 shadow-sm transition hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-200 dark:hover:bg-neutral-800"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="size-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-label="Download for Windows"
|
||||||
|
>
|
||||||
|
<path d="M12 17V3" />
|
||||||
|
<path d="m6 11 6 6 6-6" />
|
||||||
|
<path d="M19 21H5" />
|
||||||
|
</svg>
|
||||||
|
Windows
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={GITHUB_RELEASES_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-2 rounded-lg border border-neutral-200 bg-white px-4 py-2.5 text-sm font-medium text-neutral-700 shadow-sm transition hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-200 dark:hover:bg-neutral-800"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="size-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-label="Download for Linux"
|
||||||
|
>
|
||||||
|
<path d="M12 17V3" />
|
||||||
|
<path d="m6 11 6 6 6-6" />
|
||||||
|
<path d="M19 21H5" />
|
||||||
|
</svg>
|
||||||
|
Linux
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ import { useInbox } from "@/hooks/use-inbox";
|
||||||
import { useIsMobile } from "@/hooks/use-mobile";
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import { notificationsApiService } from "@/lib/apis/notifications-api.service";
|
import { notificationsApiService } from "@/lib/apis/notifications-api.service";
|
||||||
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||||
import { logout } from "@/lib/auth-utils";
|
import { getLoginPath, logout } from "@/lib/auth-utils";
|
||||||
import { deleteThread, fetchThreads, updateThread } from "@/lib/chat/thread-persistence";
|
import { deleteThread, fetchThreads, updateThread } from "@/lib/chat/thread-persistence";
|
||||||
import { resetUser, trackLogout } from "@/lib/posthog/events";
|
import { resetUser, trackLogout } from "@/lib/posthog/events";
|
||||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||||
|
|
@ -603,12 +603,12 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
||||||
await logout();
|
await logout();
|
||||||
|
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
router.push("/");
|
router.push(getLoginPath());
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error during logout:", error);
|
console.error("Error during logout:", error);
|
||||||
await logout();
|
await logout();
|
||||||
router.push("/");
|
router.push(getLoginPath());
|
||||||
}
|
}
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||||
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
|
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
|
||||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||||
|
import { useElectronAPI } from "@/hooks/use-platform";
|
||||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||||
import { foldersApiService } from "@/lib/apis/folders-api.service";
|
import { foldersApiService } from "@/lib/apis/folders-api.service";
|
||||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||||
|
|
@ -85,6 +86,7 @@ export function DocumentsSidebar({
|
||||||
const tSidebar = useTranslations("sidebar");
|
const tSidebar = useTranslations("sidebar");
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const isMobile = !useMediaQuery("(min-width: 640px)");
|
const isMobile = !useMediaQuery("(min-width: 640px)");
|
||||||
|
const electronAPI = useElectronAPI();
|
||||||
const searchSpaceId = Number(params.search_space_id);
|
const searchSpaceId = Number(params.search_space_id);
|
||||||
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
|
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
|
||||||
const setRightPanelCollapsed = useSetAtom(rightPanelCollapsedAtom);
|
const setRightPanelCollapsed = useSetAtom(rightPanelCollapsedAtom);
|
||||||
|
|
@ -114,11 +116,11 @@ export function DocumentsSidebar({
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const api = typeof window !== "undefined" ? window.electronAPI : null;
|
if (!electronAPI?.getWatchedFolders) return;
|
||||||
if (!api?.getWatchedFolders) return;
|
const api = electronAPI;
|
||||||
|
|
||||||
async function loadWatchedIds() {
|
async function loadWatchedIds() {
|
||||||
const folders = await api!.getWatchedFolders();
|
const folders = await api.getWatchedFolders();
|
||||||
|
|
||||||
if (folders.length === 0) {
|
if (folders.length === 0) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -126,7 +128,7 @@ export function DocumentsSidebar({
|
||||||
for (const bf of backendFolders) {
|
for (const bf of backendFolders) {
|
||||||
const meta = bf.metadata as Record<string, unknown> | null;
|
const meta = bf.metadata as Record<string, unknown> | null;
|
||||||
if (!meta?.watched || !meta.folder_path) continue;
|
if (!meta?.watched || !meta.folder_path) continue;
|
||||||
await api!.addWatchedFolder({
|
await api.addWatchedFolder({
|
||||||
path: meta.folder_path as string,
|
path: meta.folder_path as string,
|
||||||
name: bf.name,
|
name: bf.name,
|
||||||
rootFolderId: bf.id,
|
rootFolderId: bf.id,
|
||||||
|
|
@ -136,7 +138,7 @@ export function DocumentsSidebar({
|
||||||
active: true,
|
active: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const recovered = await api!.getWatchedFolders();
|
const recovered = await api.getWatchedFolders();
|
||||||
const ids = new Set(
|
const ids = new Set(
|
||||||
recovered.filter((f) => f.rootFolderId != null).map((f) => f.rootFolderId as number)
|
recovered.filter((f) => f.rootFolderId != null).map((f) => f.rootFolderId as number)
|
||||||
);
|
);
|
||||||
|
|
@ -154,7 +156,7 @@ export function DocumentsSidebar({
|
||||||
}
|
}
|
||||||
|
|
||||||
loadWatchedIds();
|
loadWatchedIds();
|
||||||
}, [searchSpaceId]);
|
}, [searchSpaceId, electronAPI]);
|
||||||
const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom);
|
const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom);
|
||||||
|
|
||||||
const [sidebarDocs, setSidebarDocs] = useAtom(sidebarSelectedDocumentsAtom);
|
const [sidebarDocs, setSidebarDocs] = useAtom(sidebarSelectedDocumentsAtom);
|
||||||
|
|
@ -293,10 +295,9 @@ export function DocumentsSidebar({
|
||||||
|
|
||||||
const handleRescanFolder = useCallback(
|
const handleRescanFolder = useCallback(
|
||||||
async (folder: FolderDisplay) => {
|
async (folder: FolderDisplay) => {
|
||||||
const api = window.electronAPI;
|
if (!electronAPI) return;
|
||||||
if (!api) return;
|
|
||||||
|
|
||||||
const watchedFolders = await api.getWatchedFolders();
|
const watchedFolders = await electronAPI.getWatchedFolders();
|
||||||
const matched = watchedFolders.find((wf) => wf.rootFolderId === folder.id);
|
const matched = watchedFolders.find((wf) => wf.rootFolderId === folder.id);
|
||||||
if (!matched) {
|
if (!matched) {
|
||||||
toast.error("This folder is not being watched");
|
toast.error("This folder is not being watched");
|
||||||
|
|
@ -316,28 +317,30 @@ export function DocumentsSidebar({
|
||||||
toast.error((err as Error)?.message || "Failed to re-scan folder");
|
toast.error((err as Error)?.message || "Failed to re-scan folder");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[searchSpaceId]
|
[searchSpaceId, electronAPI]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleStopWatching = useCallback(async (folder: FolderDisplay) => {
|
const handleStopWatching = useCallback(
|
||||||
const api = window.electronAPI;
|
async (folder: FolderDisplay) => {
|
||||||
if (!api) return;
|
if (!electronAPI) return;
|
||||||
|
|
||||||
const watchedFolders = await api.getWatchedFolders();
|
const watchedFolders = await electronAPI.getWatchedFolders();
|
||||||
const matched = watchedFolders.find((wf) => wf.rootFolderId === folder.id);
|
const matched = watchedFolders.find((wf) => wf.rootFolderId === folder.id);
|
||||||
if (!matched) {
|
if (!matched) {
|
||||||
toast.error("This folder is not being watched");
|
toast.error("This folder is not being watched");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await api.removeWatchedFolder(matched.path);
|
await electronAPI.removeWatchedFolder(matched.path);
|
||||||
try {
|
try {
|
||||||
await foldersApiService.stopWatching(folder.id);
|
await foldersApiService.stopWatching(folder.id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[DocumentsSidebar] Failed to clear watched metadata:", err);
|
console.error("[DocumentsSidebar] Failed to clear watched metadata:", err);
|
||||||
}
|
}
|
||||||
toast.success(`Stopped watching: ${matched.name}`);
|
toast.success(`Stopped watching: ${matched.name}`);
|
||||||
}, []);
|
},
|
||||||
|
[electronAPI]
|
||||||
|
);
|
||||||
|
|
||||||
const handleRenameFolder = useCallback(async (folder: FolderDisplay, newName: string) => {
|
const handleRenameFolder = useCallback(async (folder: FolderDisplay, newName: string) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -348,23 +351,25 @@ export function DocumentsSidebar({
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDeleteFolder = useCallback(async (folder: FolderDisplay) => {
|
const handleDeleteFolder = useCallback(
|
||||||
if (!confirm(`Delete folder "${folder.name}" and all its contents?`)) return;
|
async (folder: FolderDisplay) => {
|
||||||
try {
|
if (!confirm(`Delete folder "${folder.name}" and all its contents?`)) return;
|
||||||
const api = window.electronAPI;
|
try {
|
||||||
if (api) {
|
if (electronAPI) {
|
||||||
const watchedFolders = await api.getWatchedFolders();
|
const watchedFolders = await electronAPI.getWatchedFolders();
|
||||||
const matched = watchedFolders.find((wf) => wf.rootFolderId === folder.id);
|
const matched = watchedFolders.find((wf) => wf.rootFolderId === folder.id);
|
||||||
if (matched) {
|
if (matched) {
|
||||||
await api.removeWatchedFolder(matched.path);
|
await electronAPI.removeWatchedFolder(matched.path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
await foldersApiService.deleteFolder(folder.id);
|
||||||
|
toast.success("Folder deleted");
|
||||||
|
} catch (e: unknown) {
|
||||||
|
toast.error((e as Error)?.message || "Failed to delete folder");
|
||||||
}
|
}
|
||||||
await foldersApiService.deleteFolder(folder.id);
|
},
|
||||||
toast.success("Folder deleted");
|
[electronAPI]
|
||||||
} catch (e: unknown) {
|
);
|
||||||
toast.error((e as Error)?.message || "Failed to delete folder");
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleMoveFolder = useCallback(
|
const handleMoveFolder = useCallback(
|
||||||
(folder: FolderDisplay) => {
|
(folder: FolderDisplay) => {
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,14 @@
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { ImageConfigDialog } from "@/components/shared/image-config-dialog";
|
import { ImageConfigDialog } from "@/components/shared/image-config-dialog";
|
||||||
import { ModelConfigDialog } from "@/components/shared/model-config-dialog";
|
import { ModelConfigDialog } from "@/components/shared/model-config-dialog";
|
||||||
|
import { VisionConfigDialog } from "@/components/shared/vision-config-dialog";
|
||||||
import type {
|
import type {
|
||||||
GlobalImageGenConfig,
|
GlobalImageGenConfig,
|
||||||
GlobalNewLLMConfig,
|
GlobalNewLLMConfig,
|
||||||
|
GlobalVisionLLMConfig,
|
||||||
ImageGenerationConfig,
|
ImageGenerationConfig,
|
||||||
NewLLMConfigPublic,
|
NewLLMConfigPublic,
|
||||||
|
VisionLLMConfig,
|
||||||
} from "@/contracts/types/new-llm-config.types";
|
} from "@/contracts/types/new-llm-config.types";
|
||||||
import { ModelSelector } from "./model-selector";
|
import { ModelSelector } from "./model-selector";
|
||||||
|
|
||||||
|
|
@ -33,6 +36,14 @@ export function ChatHeader({ searchSpaceId, className }: ChatHeaderProps) {
|
||||||
const [isImageGlobal, setIsImageGlobal] = useState(false);
|
const [isImageGlobal, setIsImageGlobal] = useState(false);
|
||||||
const [imageDialogMode, setImageDialogMode] = useState<"create" | "edit" | "view">("view");
|
const [imageDialogMode, setImageDialogMode] = useState<"create" | "edit" | "view">("view");
|
||||||
|
|
||||||
|
// Vision config dialog state
|
||||||
|
const [visionDialogOpen, setVisionDialogOpen] = useState(false);
|
||||||
|
const [selectedVisionConfig, setSelectedVisionConfig] = useState<
|
||||||
|
VisionLLMConfig | GlobalVisionLLMConfig | null
|
||||||
|
>(null);
|
||||||
|
const [isVisionGlobal, setIsVisionGlobal] = useState(false);
|
||||||
|
const [visionDialogMode, setVisionDialogMode] = useState<"create" | "edit" | "view">("view");
|
||||||
|
|
||||||
// LLM handlers
|
// LLM handlers
|
||||||
const handleEditLLMConfig = useCallback(
|
const handleEditLLMConfig = useCallback(
|
||||||
(config: NewLLMConfigPublic | GlobalNewLLMConfig, global: boolean) => {
|
(config: NewLLMConfigPublic | GlobalNewLLMConfig, global: boolean) => {
|
||||||
|
|
@ -79,6 +90,29 @@ export function ChatHeader({ searchSpaceId, className }: ChatHeaderProps) {
|
||||||
if (!open) setSelectedImageConfig(null);
|
if (!open) setSelectedImageConfig(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Vision model handlers
|
||||||
|
const handleAddVisionModel = useCallback(() => {
|
||||||
|
setSelectedVisionConfig(null);
|
||||||
|
setIsVisionGlobal(false);
|
||||||
|
setVisionDialogMode("create");
|
||||||
|
setVisionDialogOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleEditVisionConfig = useCallback(
|
||||||
|
(config: VisionLLMConfig | GlobalVisionLLMConfig, global: boolean) => {
|
||||||
|
setSelectedVisionConfig(config);
|
||||||
|
setIsVisionGlobal(global);
|
||||||
|
setVisionDialogMode(global ? "view" : "edit");
|
||||||
|
setVisionDialogOpen(true);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleVisionDialogClose = useCallback((open: boolean) => {
|
||||||
|
setVisionDialogOpen(open);
|
||||||
|
if (!open) setSelectedVisionConfig(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ModelSelector
|
<ModelSelector
|
||||||
|
|
@ -86,6 +120,8 @@ export function ChatHeader({ searchSpaceId, className }: ChatHeaderProps) {
|
||||||
onAddNewLLM={handleAddNewLLM}
|
onAddNewLLM={handleAddNewLLM}
|
||||||
onEditImage={handleEditImageConfig}
|
onEditImage={handleEditImageConfig}
|
||||||
onAddNewImage={handleAddImageModel}
|
onAddNewImage={handleAddImageModel}
|
||||||
|
onEditVision={handleEditVisionConfig}
|
||||||
|
onAddNewVision={handleAddVisionModel}
|
||||||
className={className}
|
className={className}
|
||||||
/>
|
/>
|
||||||
<ModelConfigDialog
|
<ModelConfigDialog
|
||||||
|
|
@ -104,6 +140,14 @@ export function ChatHeader({ searchSpaceId, className }: ChatHeaderProps) {
|
||||||
searchSpaceId={searchSpaceId}
|
searchSpaceId={searchSpaceId}
|
||||||
mode={imageDialogMode}
|
mode={imageDialogMode}
|
||||||
/>
|
/>
|
||||||
|
<VisionConfigDialog
|
||||||
|
open={visionDialogOpen}
|
||||||
|
onOpenChange={handleVisionDialogClose}
|
||||||
|
config={selectedVisionConfig}
|
||||||
|
isGlobal={isVisionGlobal}
|
||||||
|
searchSpaceId={searchSpaceId}
|
||||||
|
mode={visionDialogMode}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { Bot, Check, ChevronDown, Edit3, ImageIcon, Plus, Search, Zap } from "lucide-react";
|
import { Bot, Check, ChevronDown, Edit3, Eye, ImageIcon, Plus, Search, Zap } from "lucide-react";
|
||||||
import { type UIEvent, useCallback, useMemo, useState } from "react";
|
import { type UIEvent, useCallback, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
|
|
@ -15,6 +15,10 @@ import {
|
||||||
newLLMConfigsAtom,
|
newLLMConfigsAtom,
|
||||||
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
|
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
|
||||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||||
|
import {
|
||||||
|
globalVisionLLMConfigsAtom,
|
||||||
|
visionLLMConfigsAtom,
|
||||||
|
} from "@/atoms/vision-llm-config/vision-llm-config-query.atoms";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
|
|
@ -32,8 +36,10 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import type {
|
import type {
|
||||||
GlobalImageGenConfig,
|
GlobalImageGenConfig,
|
||||||
GlobalNewLLMConfig,
|
GlobalNewLLMConfig,
|
||||||
|
GlobalVisionLLMConfig,
|
||||||
ImageGenerationConfig,
|
ImageGenerationConfig,
|
||||||
NewLLMConfigPublic,
|
NewLLMConfigPublic,
|
||||||
|
VisionLLMConfig,
|
||||||
} from "@/contracts/types/new-llm-config.types";
|
} from "@/contracts/types/new-llm-config.types";
|
||||||
import { getProviderIcon } from "@/lib/provider-icons";
|
import { getProviderIcon } from "@/lib/provider-icons";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
@ -43,6 +49,8 @@ interface ModelSelectorProps {
|
||||||
onAddNewLLM: () => void;
|
onAddNewLLM: () => void;
|
||||||
onEditImage?: (config: ImageGenerationConfig | GlobalImageGenConfig, isGlobal: boolean) => void;
|
onEditImage?: (config: ImageGenerationConfig | GlobalImageGenConfig, isGlobal: boolean) => void;
|
||||||
onAddNewImage?: () => void;
|
onAddNewImage?: () => void;
|
||||||
|
onEditVision?: (config: VisionLLMConfig | GlobalVisionLLMConfig, isGlobal: boolean) => void;
|
||||||
|
onAddNewVision?: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -51,14 +59,18 @@ export function ModelSelector({
|
||||||
onAddNewLLM,
|
onAddNewLLM,
|
||||||
onEditImage,
|
onEditImage,
|
||||||
onAddNewImage,
|
onAddNewImage,
|
||||||
|
onEditVision,
|
||||||
|
onAddNewVision,
|
||||||
className,
|
className,
|
||||||
}: ModelSelectorProps) {
|
}: ModelSelectorProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState<"llm" | "image">("llm");
|
const [activeTab, setActiveTab] = useState<"llm" | "image" | "vision">("llm");
|
||||||
const [llmSearchQuery, setLlmSearchQuery] = useState("");
|
const [llmSearchQuery, setLlmSearchQuery] = useState("");
|
||||||
const [imageSearchQuery, setImageSearchQuery] = useState("");
|
const [imageSearchQuery, setImageSearchQuery] = useState("");
|
||||||
|
const [visionSearchQuery, setVisionSearchQuery] = useState("");
|
||||||
const [llmScrollPos, setLlmScrollPos] = useState<"top" | "middle" | "bottom">("top");
|
const [llmScrollPos, setLlmScrollPos] = useState<"top" | "middle" | "bottom">("top");
|
||||||
const [imageScrollPos, setImageScrollPos] = useState<"top" | "middle" | "bottom">("top");
|
const [imageScrollPos, setImageScrollPos] = useState<"top" | "middle" | "bottom">("top");
|
||||||
|
const [visionScrollPos, setVisionScrollPos] = useState<"top" | "middle" | "bottom">("top");
|
||||||
const handleListScroll = useCallback(
|
const handleListScroll = useCallback(
|
||||||
(setter: typeof setLlmScrollPos) => (e: UIEvent<HTMLDivElement>) => {
|
(setter: typeof setLlmScrollPos) => (e: UIEvent<HTMLDivElement>) => {
|
||||||
const el = e.currentTarget;
|
const el = e.currentTarget;
|
||||||
|
|
@ -82,8 +94,21 @@ export function ModelSelector({
|
||||||
useAtomValue(globalImageGenConfigsAtom);
|
useAtomValue(globalImageGenConfigsAtom);
|
||||||
const { data: imageUserConfigs, isLoading: imageUserLoading } = useAtomValue(imageGenConfigsAtom);
|
const { data: imageUserConfigs, isLoading: imageUserLoading } = useAtomValue(imageGenConfigsAtom);
|
||||||
|
|
||||||
|
// Vision data
|
||||||
|
const { data: visionGlobalConfigs, isLoading: visionGlobalLoading } = useAtomValue(
|
||||||
|
globalVisionLLMConfigsAtom
|
||||||
|
);
|
||||||
|
const { data: visionUserConfigs, isLoading: visionUserLoading } =
|
||||||
|
useAtomValue(visionLLMConfigsAtom);
|
||||||
|
|
||||||
const isLoading =
|
const isLoading =
|
||||||
llmUserLoading || llmGlobalLoading || prefsLoading || imageGlobalLoading || imageUserLoading;
|
llmUserLoading ||
|
||||||
|
llmGlobalLoading ||
|
||||||
|
prefsLoading ||
|
||||||
|
imageGlobalLoading ||
|
||||||
|
imageUserLoading ||
|
||||||
|
visionGlobalLoading ||
|
||||||
|
visionUserLoading;
|
||||||
|
|
||||||
// ─── LLM current config ───
|
// ─── LLM current config ───
|
||||||
const currentLLMConfig = useMemo(() => {
|
const currentLLMConfig = useMemo(() => {
|
||||||
|
|
@ -116,6 +141,24 @@ export function ModelSelector({
|
||||||
);
|
);
|
||||||
}, [currentImageConfig]);
|
}, [currentImageConfig]);
|
||||||
|
|
||||||
|
// ─── Vision current config ───
|
||||||
|
const currentVisionConfig = useMemo(() => {
|
||||||
|
if (!preferences) return null;
|
||||||
|
const id = preferences.vision_llm_config_id;
|
||||||
|
if (id === null || id === undefined) return null;
|
||||||
|
const globalMatch = visionGlobalConfigs?.find((c) => c.id === id);
|
||||||
|
if (globalMatch) return globalMatch;
|
||||||
|
return visionUserConfigs?.find((c) => c.id === id) ?? null;
|
||||||
|
}, [preferences, visionGlobalConfigs, visionUserConfigs]);
|
||||||
|
|
||||||
|
const isVisionAutoMode = useMemo(() => {
|
||||||
|
return (
|
||||||
|
currentVisionConfig &&
|
||||||
|
"is_auto_mode" in currentVisionConfig &&
|
||||||
|
currentVisionConfig.is_auto_mode
|
||||||
|
);
|
||||||
|
}, [currentVisionConfig]);
|
||||||
|
|
||||||
// ─── LLM filtering ───
|
// ─── LLM filtering ───
|
||||||
const filteredLLMGlobal = useMemo(() => {
|
const filteredLLMGlobal = useMemo(() => {
|
||||||
if (!llmGlobalConfigs) return [];
|
if (!llmGlobalConfigs) return [];
|
||||||
|
|
@ -170,6 +213,33 @@ export function ModelSelector({
|
||||||
|
|
||||||
const totalImageModels = (imageGlobalConfigs?.length ?? 0) + (imageUserConfigs?.length ?? 0);
|
const totalImageModels = (imageGlobalConfigs?.length ?? 0) + (imageUserConfigs?.length ?? 0);
|
||||||
|
|
||||||
|
// ─── Vision filtering ───
|
||||||
|
const filteredVisionGlobal = useMemo(() => {
|
||||||
|
if (!visionGlobalConfigs) return [];
|
||||||
|
if (!visionSearchQuery) return visionGlobalConfigs;
|
||||||
|
const q = visionSearchQuery.toLowerCase();
|
||||||
|
return visionGlobalConfigs.filter(
|
||||||
|
(c) =>
|
||||||
|
c.name.toLowerCase().includes(q) ||
|
||||||
|
c.model_name.toLowerCase().includes(q) ||
|
||||||
|
c.provider.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}, [visionGlobalConfigs, visionSearchQuery]);
|
||||||
|
|
||||||
|
const filteredVisionUser = useMemo(() => {
|
||||||
|
if (!visionUserConfigs) return [];
|
||||||
|
if (!visionSearchQuery) return visionUserConfigs;
|
||||||
|
const q = visionSearchQuery.toLowerCase();
|
||||||
|
return visionUserConfigs.filter(
|
||||||
|
(c) =>
|
||||||
|
c.name.toLowerCase().includes(q) ||
|
||||||
|
c.model_name.toLowerCase().includes(q) ||
|
||||||
|
c.provider.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}, [visionUserConfigs, visionSearchQuery]);
|
||||||
|
|
||||||
|
const totalVisionModels = (visionGlobalConfigs?.length ?? 0) + (visionUserConfigs?.length ?? 0);
|
||||||
|
|
||||||
// ─── Handlers ───
|
// ─── Handlers ───
|
||||||
const handleSelectLLM = useCallback(
|
const handleSelectLLM = useCallback(
|
||||||
async (config: NewLLMConfigPublic | GlobalNewLLMConfig) => {
|
async (config: NewLLMConfigPublic | GlobalNewLLMConfig) => {
|
||||||
|
|
@ -229,6 +299,30 @@ export function ModelSelector({
|
||||||
[currentImageConfig, searchSpaceId, updatePreferences]
|
[currentImageConfig, searchSpaceId, updatePreferences]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleSelectVision = useCallback(
|
||||||
|
async (configId: number) => {
|
||||||
|
if (currentVisionConfig?.id === configId) {
|
||||||
|
setOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!searchSpaceId) {
|
||||||
|
toast.error("No search space selected");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await updatePreferences({
|
||||||
|
search_space_id: Number(searchSpaceId),
|
||||||
|
data: { vision_llm_config_id: configId },
|
||||||
|
});
|
||||||
|
toast.success("Vision model updated");
|
||||||
|
setOpen(false);
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to switch vision model");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[currentVisionConfig, searchSpaceId, updatePreferences]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
|
|
@ -282,6 +376,23 @@ export function ModelSelector({
|
||||||
) : (
|
) : (
|
||||||
<ImageIcon className="size-4 text-muted-foreground" />
|
<ImageIcon className="size-4 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="h-4 w-px bg-border/60 dark:bg-white/10 mx-0.5" />
|
||||||
|
|
||||||
|
{/* Vision section */}
|
||||||
|
{currentVisionConfig ? (
|
||||||
|
<>
|
||||||
|
{getProviderIcon(currentVisionConfig.provider, {
|
||||||
|
isAutoMode: isVisionAutoMode ?? false,
|
||||||
|
})}
|
||||||
|
<span className="max-w-[80px] md:max-w-[100px] truncate hidden md:inline">
|
||||||
|
{currentVisionConfig.name}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Eye className="size-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground ml-1 shrink-0" />
|
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground ml-1 shrink-0" />
|
||||||
|
|
@ -295,25 +406,32 @@ export function ModelSelector({
|
||||||
>
|
>
|
||||||
<Tabs
|
<Tabs
|
||||||
value={activeTab}
|
value={activeTab}
|
||||||
onValueChange={(v) => setActiveTab(v as "llm" | "image")}
|
onValueChange={(v) => setActiveTab(v as "llm" | "image" | "vision")}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
<div className="border-b border-border/80 dark:border-neutral-800">
|
<div className="border-b border-border/80 dark:border-neutral-800">
|
||||||
<TabsList className="w-full grid grid-cols-2 rounded-none rounded-t-lg bg-transparent h-11 p-0 gap-0">
|
<TabsList className="w-full grid grid-cols-3 rounded-none rounded-t-lg bg-transparent h-11 p-0 gap-0">
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="llm"
|
value="llm"
|
||||||
className="gap-2 text-sm font-medium rounded-none text-muted-foreground transition-all duration-200 h-full bg-transparent data-[state=active]:bg-transparent shadow-none data-[state=active]:shadow-none border-b-[1.5px] border-transparent data-[state=active]:border-foreground dark:data-[state=active]:border-white data-[state=active]:text-foreground"
|
className="gap-1.5 text-sm font-medium rounded-none text-muted-foreground transition-all duration-200 h-full bg-transparent data-[state=active]:bg-transparent shadow-none data-[state=active]:shadow-none border-b-[1.5px] border-transparent data-[state=active]:border-foreground dark:data-[state=active]:border-white data-[state=active]:text-foreground"
|
||||||
>
|
>
|
||||||
<Zap className="size-4" />
|
<Zap className="size-3.5" />
|
||||||
LLM
|
LLM
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="image"
|
value="image"
|
||||||
className="gap-2 text-sm font-medium rounded-none text-muted-foreground transition-all duration-200 h-full bg-transparent data-[state=active]:bg-transparent shadow-none data-[state=active]:shadow-none border-b-[1.5px] border-transparent data-[state=active]:border-foreground dark:data-[state=active]:border-white data-[state=active]:text-foreground"
|
className="gap-1.5 text-sm font-medium rounded-none text-muted-foreground transition-all duration-200 h-full bg-transparent data-[state=active]:bg-transparent shadow-none data-[state=active]:shadow-none border-b-[1.5px] border-transparent data-[state=active]:border-foreground dark:data-[state=active]:border-white data-[state=active]:text-foreground"
|
||||||
>
|
>
|
||||||
<ImageIcon className="size-4" />
|
<ImageIcon className="size-3.5" />
|
||||||
Image
|
Image
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="vision"
|
||||||
|
className="gap-1.5 text-sm font-medium rounded-none text-muted-foreground transition-all duration-200 h-full bg-transparent data-[state=active]:bg-transparent shadow-none data-[state=active]:shadow-none border-b-[1.5px] border-transparent data-[state=active]:border-foreground dark:data-[state=active]:border-white data-[state=active]:text-foreground"
|
||||||
|
>
|
||||||
|
<Eye className="size-3.5" />
|
||||||
|
Vision
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -676,6 +794,174 @@ export function ModelSelector({
|
||||||
</CommandList>
|
</CommandList>
|
||||||
</Command>
|
</Command>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* ─── Vision Tab ─── */}
|
||||||
|
<TabsContent value="vision" className="mt-0">
|
||||||
|
<Command
|
||||||
|
shouldFilter={false}
|
||||||
|
className="rounded-none rounded-b-lg dark:bg-neutral-900 [&_[data-slot=command-input-wrapper]]:border-0 [&_[data-slot=command-input-wrapper]]:px-0 [&_[data-slot=command-input-wrapper]]:gap-2"
|
||||||
|
>
|
||||||
|
{totalVisionModels > 3 && (
|
||||||
|
<div className="px-2 md:px-3 py-1.5 md:py-2">
|
||||||
|
<CommandInput
|
||||||
|
placeholder="Search vision models"
|
||||||
|
value={visionSearchQuery}
|
||||||
|
onValueChange={setVisionSearchQuery}
|
||||||
|
className="h-7 md:h-8 w-full text-xs md:text-sm border-0 bg-transparent focus:ring-0 placeholder:text-muted-foreground/60"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<CommandList
|
||||||
|
className="max-h-[300px] md:max-h-[400px] overflow-y-auto"
|
||||||
|
onScroll={handleListScroll(setVisionScrollPos)}
|
||||||
|
style={{
|
||||||
|
maskImage: `linear-gradient(to bottom, ${visionScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${visionScrollPos === "bottom" ? "black" : "transparent"})`,
|
||||||
|
WebkitMaskImage: `linear-gradient(to bottom, ${visionScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${visionScrollPos === "bottom" ? "black" : "transparent"})`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CommandEmpty className="py-8 text-center">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<Search className="size-8 text-muted-foreground" />
|
||||||
|
<p className="text-sm text-muted-foreground">No vision models found</p>
|
||||||
|
<p className="text-xs text-muted-foreground/60">Try a different search term</p>
|
||||||
|
</div>
|
||||||
|
</CommandEmpty>
|
||||||
|
|
||||||
|
{filteredVisionGlobal.length > 0 && (
|
||||||
|
<CommandGroup>
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 text-xs font-semibold text-muted-foreground tracking-wider">
|
||||||
|
Global Vision Models
|
||||||
|
</div>
|
||||||
|
{filteredVisionGlobal.map((config) => {
|
||||||
|
const isSelected = currentVisionConfig?.id === config.id;
|
||||||
|
const isAuto = "is_auto_mode" in config && config.is_auto_mode;
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
key={`vis-g-${config.id}`}
|
||||||
|
value={`vis-g-${config.id}`}
|
||||||
|
onSelect={() => handleSelectVision(config.id)}
|
||||||
|
className={cn(
|
||||||
|
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50 dark:hover:bg-white/[0.06]",
|
||||||
|
isSelected && "bg-accent/80 dark:bg-white/[0.06]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||||
|
<div className="shrink-0">
|
||||||
|
{getProviderIcon(config.provider, { isAutoMode: isAuto })}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium truncate">{config.name}</span>
|
||||||
|
{isAuto && (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="text-[9px] px-1 py-0 h-3.5 bg-violet-800 text-white dark:bg-violet-800 dark:text-white border-0"
|
||||||
|
>
|
||||||
|
Recommended
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{isSelected && <Check className="size-3.5 text-primary shrink-0" />}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground truncate block">
|
||||||
|
{isAuto ? "Auto Mode" : config.model_name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{onEditVision && !isAuto && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-7 shrink-0 rounded-md hover:bg-muted opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setOpen(false);
|
||||||
|
onEditVision(config as VisionLLMConfig, true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Edit3 className="size-3.5 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filteredVisionUser.length > 0 && (
|
||||||
|
<>
|
||||||
|
{filteredVisionGlobal.length > 0 && (
|
||||||
|
<CommandSeparator className="my-1 mx-4 bg-border/60" />
|
||||||
|
)}
|
||||||
|
<CommandGroup>
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 text-xs font-semibold text-muted-foreground tracking-wider">
|
||||||
|
Your Vision Models
|
||||||
|
</div>
|
||||||
|
{filteredVisionUser.map((config) => {
|
||||||
|
const isSelected = currentVisionConfig?.id === config.id;
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
key={`vis-u-${config.id}`}
|
||||||
|
value={`vis-u-${config.id}`}
|
||||||
|
onSelect={() => handleSelectVision(config.id)}
|
||||||
|
className={cn(
|
||||||
|
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50 dark:hover:bg-white/[0.06]",
|
||||||
|
isSelected && "bg-accent/80 dark:bg-white/[0.06]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||||
|
<div className="shrink-0">{getProviderIcon(config.provider)}</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium truncate">{config.name}</span>
|
||||||
|
{isSelected && (
|
||||||
|
<Check className="size-3.5 text-primary shrink-0" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground truncate block">
|
||||||
|
{config.model_name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{onEditVision && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setOpen(false);
|
||||||
|
onEditVision(config, false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Edit3 className="size-3.5 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{onAddNewVision && (
|
||||||
|
<div className="p-2 bg-muted/20 dark:bg-neutral-900">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full justify-start gap-2 h-9 rounded-lg hover:bg-accent/50 dark:hover:bg-white/[0.06]"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
onAddNewVision();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="size-4 text-primary" />
|
||||||
|
<span className="text-sm font-medium">Add Vision Model</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|
|
||||||
16
surfsense_web/components/platform-gate.tsx
Normal file
16
surfsense_web/components/platform-gate.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { usePlatform } from "@/hooks/use-platform";
|
||||||
|
|
||||||
|
export function DesktopOnly({ children }: { children: ReactNode }) {
|
||||||
|
const { isDesktop } = usePlatform();
|
||||||
|
if (!isDesktop) return null;
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WebOnly({ children }: { children: ReactNode }) {
|
||||||
|
const { isWeb } = usePlatform();
|
||||||
|
if (!isWeb) return null;
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,10 @@ import {
|
||||||
llmPreferencesAtom,
|
llmPreferencesAtom,
|
||||||
newLLMConfigsAtom,
|
newLLMConfigsAtom,
|
||||||
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
|
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
|
||||||
|
import {
|
||||||
|
globalVisionLLMConfigsAtom,
|
||||||
|
visionLLMConfigsAtom,
|
||||||
|
} from "@/atoms/vision-llm-config/vision-llm-config-query.atoms";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -77,8 +81,8 @@ const ROLE_DESCRIPTIONS = {
|
||||||
description: "Vision-capable model for screenshot analysis and context extraction",
|
description: "Vision-capable model for screenshot analysis and context extraction",
|
||||||
color: "text-amber-600 dark:text-amber-400",
|
color: "text-amber-600 dark:text-amber-400",
|
||||||
bgColor: "bg-amber-500/10",
|
bgColor: "bg-amber-500/10",
|
||||||
prefKey: "vision_llm_id" as const,
|
prefKey: "vision_llm_config_id" as const,
|
||||||
configType: "llm" as const,
|
configType: "vision" as const,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -112,6 +116,18 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
||||||
error: globalImageConfigsError,
|
error: globalImageConfigsError,
|
||||||
} = useAtomValue(globalImageGenConfigsAtom);
|
} = useAtomValue(globalImageGenConfigsAtom);
|
||||||
|
|
||||||
|
// Vision LLM configs
|
||||||
|
const {
|
||||||
|
data: userVisionConfigs = [],
|
||||||
|
isFetching: visionConfigsLoading,
|
||||||
|
error: visionConfigsError,
|
||||||
|
} = useAtomValue(visionLLMConfigsAtom);
|
||||||
|
const {
|
||||||
|
data: globalVisionConfigs = [],
|
||||||
|
isFetching: globalVisionConfigsLoading,
|
||||||
|
error: globalVisionConfigsError,
|
||||||
|
} = useAtomValue(globalVisionLLMConfigsAtom);
|
||||||
|
|
||||||
// Preferences
|
// Preferences
|
||||||
const {
|
const {
|
||||||
data: preferences = {},
|
data: preferences = {},
|
||||||
|
|
@ -125,7 +141,7 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
||||||
agent_llm_id: preferences.agent_llm_id ?? "",
|
agent_llm_id: preferences.agent_llm_id ?? "",
|
||||||
document_summary_llm_id: preferences.document_summary_llm_id ?? "",
|
document_summary_llm_id: preferences.document_summary_llm_id ?? "",
|
||||||
image_generation_config_id: preferences.image_generation_config_id ?? "",
|
image_generation_config_id: preferences.image_generation_config_id ?? "",
|
||||||
vision_llm_id: preferences.vision_llm_id ?? "",
|
vision_llm_config_id: preferences.vision_llm_config_id ?? "",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const [savingRole, setSavingRole] = useState<string | null>(null);
|
const [savingRole, setSavingRole] = useState<string | null>(null);
|
||||||
|
|
@ -137,14 +153,14 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
||||||
agent_llm_id: preferences.agent_llm_id ?? "",
|
agent_llm_id: preferences.agent_llm_id ?? "",
|
||||||
document_summary_llm_id: preferences.document_summary_llm_id ?? "",
|
document_summary_llm_id: preferences.document_summary_llm_id ?? "",
|
||||||
image_generation_config_id: preferences.image_generation_config_id ?? "",
|
image_generation_config_id: preferences.image_generation_config_id ?? "",
|
||||||
vision_llm_id: preferences.vision_llm_id ?? "",
|
vision_llm_config_id: preferences.vision_llm_config_id ?? "",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
preferences?.agent_llm_id,
|
preferences?.agent_llm_id,
|
||||||
preferences?.document_summary_llm_id,
|
preferences?.document_summary_llm_id,
|
||||||
preferences?.image_generation_config_id,
|
preferences?.image_generation_config_id,
|
||||||
preferences?.vision_llm_id,
|
preferences?.vision_llm_config_id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleRoleAssignment = useCallback(
|
const handleRoleAssignment = useCallback(
|
||||||
|
|
@ -181,6 +197,14 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
||||||
...(userImageConfigs ?? []).filter((config) => config.id && config.id.toString().trim() !== ""),
|
...(userImageConfigs ?? []).filter((config) => config.id && config.id.toString().trim() !== ""),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Combine global and custom vision LLM configs
|
||||||
|
const allVisionConfigs = [
|
||||||
|
...globalVisionConfigs.map((config) => ({ ...config, is_global: true })),
|
||||||
|
...(userVisionConfigs ?? []).filter(
|
||||||
|
(config) => config.id && config.id.toString().trim() !== ""
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
const isAssignmentComplete =
|
const isAssignmentComplete =
|
||||||
allLLMConfigs.some((c) => c.id === assignments.agent_llm_id) &&
|
allLLMConfigs.some((c) => c.id === assignments.agent_llm_id) &&
|
||||||
allLLMConfigs.some((c) => c.id === assignments.document_summary_llm_id) &&
|
allLLMConfigs.some((c) => c.id === assignments.document_summary_llm_id) &&
|
||||||
|
|
@ -191,13 +215,17 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
||||||
preferencesLoading ||
|
preferencesLoading ||
|
||||||
globalConfigsLoading ||
|
globalConfigsLoading ||
|
||||||
imageConfigsLoading ||
|
imageConfigsLoading ||
|
||||||
globalImageConfigsLoading;
|
globalImageConfigsLoading ||
|
||||||
|
visionConfigsLoading ||
|
||||||
|
globalVisionConfigsLoading;
|
||||||
const hasError =
|
const hasError =
|
||||||
configsError ||
|
configsError ||
|
||||||
preferencesError ||
|
preferencesError ||
|
||||||
globalConfigsError ||
|
globalConfigsError ||
|
||||||
imageConfigsError ||
|
imageConfigsError ||
|
||||||
globalImageConfigsError;
|
globalImageConfigsError ||
|
||||||
|
visionConfigsError ||
|
||||||
|
globalVisionConfigsError;
|
||||||
const hasAnyConfigs = allLLMConfigs.length > 0 || allImageConfigs.length > 0;
|
const hasAnyConfigs = allLLMConfigs.length > 0 || allImageConfigs.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -291,15 +319,27 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
||||||
<div className="grid gap-4 grid-cols-1 lg:grid-cols-2">
|
<div className="grid gap-4 grid-cols-1 lg:grid-cols-2">
|
||||||
{Object.entries(ROLE_DESCRIPTIONS).map(([key, role]) => {
|
{Object.entries(ROLE_DESCRIPTIONS).map(([key, role]) => {
|
||||||
const IconComponent = role.icon;
|
const IconComponent = role.icon;
|
||||||
const isImageRole = role.configType === "image";
|
|
||||||
const currentAssignment = assignments[role.prefKey as keyof typeof assignments];
|
const currentAssignment = assignments[role.prefKey as keyof typeof assignments];
|
||||||
|
|
||||||
// Pick the right config lists based on role type
|
// Pick the right config lists based on role type
|
||||||
const roleGlobalConfigs = isImageRole ? globalImageConfigs : globalConfigs;
|
const roleGlobalConfigs =
|
||||||
const roleUserConfigs = isImageRole
|
role.configType === "image"
|
||||||
? (userImageConfigs ?? []).filter((c) => c.id && c.id.toString().trim() !== "")
|
? globalImageConfigs
|
||||||
: newLLMConfigs.filter((c) => c.id && c.id.toString().trim() !== "");
|
: role.configType === "vision"
|
||||||
const roleAllConfigs = isImageRole ? allImageConfigs : allLLMConfigs;
|
? globalVisionConfigs
|
||||||
|
: globalConfigs;
|
||||||
|
const roleUserConfigs =
|
||||||
|
role.configType === "image"
|
||||||
|
? (userImageConfigs ?? []).filter((c) => c.id && c.id.toString().trim() !== "")
|
||||||
|
: role.configType === "vision"
|
||||||
|
? (userVisionConfigs ?? []).filter((c) => c.id && c.id.toString().trim() !== "")
|
||||||
|
: newLLMConfigs.filter((c) => c.id && c.id.toString().trim() !== "");
|
||||||
|
const roleAllConfigs =
|
||||||
|
role.configType === "image"
|
||||||
|
? allImageConfigs
|
||||||
|
: role.configType === "vision"
|
||||||
|
? allVisionConfigs
|
||||||
|
: allLLMConfigs;
|
||||||
|
|
||||||
const assignedConfig = roleAllConfigs.find((config) => config.id === currentAssignment);
|
const assignedConfig = roleAllConfigs.find((config) => config.id === currentAssignment);
|
||||||
const isAssigned = !!assignedConfig;
|
const isAssigned = !!assignedConfig;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { Bot, Brain, FileText, Globe, ImageIcon, MessageSquare, Shield } from "lucide-react";
|
import { Bot, Brain, Eye, FileText, Globe, ImageIcon, MessageSquare, Shield } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { searchSpaceSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
|
import { searchSpaceSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
|
||||||
|
|
@ -13,6 +13,7 @@ import { ModelConfigManager } from "@/components/settings/model-config-manager";
|
||||||
import { PromptConfigManager } from "@/components/settings/prompt-config-manager";
|
import { PromptConfigManager } from "@/components/settings/prompt-config-manager";
|
||||||
import { RolesManager } from "@/components/settings/roles-manager";
|
import { RolesManager } from "@/components/settings/roles-manager";
|
||||||
import { SettingsDialog } from "@/components/settings/settings-dialog";
|
import { SettingsDialog } from "@/components/settings/settings-dialog";
|
||||||
|
import { VisionModelManager } from "@/components/settings/vision-model-manager";
|
||||||
|
|
||||||
interface SearchSpaceSettingsDialogProps {
|
interface SearchSpaceSettingsDialogProps {
|
||||||
searchSpaceId: number;
|
searchSpaceId: number;
|
||||||
|
|
@ -31,6 +32,11 @@ export function SearchSpaceSettingsDialog({ searchSpaceId }: SearchSpaceSettings
|
||||||
label: t("nav_image_models"),
|
label: t("nav_image_models"),
|
||||||
icon: <ImageIcon className="h-4 w-4" />,
|
icon: <ImageIcon className="h-4 w-4" />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: "vision-models",
|
||||||
|
label: t("nav_vision_models"),
|
||||||
|
icon: <Eye className="h-4 w-4" />,
|
||||||
|
},
|
||||||
{ value: "team-roles", label: t("nav_team_roles"), icon: <Shield className="h-4 w-4" /> },
|
{ value: "team-roles", label: t("nav_team_roles"), icon: <Shield className="h-4 w-4" /> },
|
||||||
{
|
{
|
||||||
value: "prompts",
|
value: "prompts",
|
||||||
|
|
@ -45,6 +51,7 @@ export function SearchSpaceSettingsDialog({ searchSpaceId }: SearchSpaceSettings
|
||||||
models: <ModelConfigManager searchSpaceId={searchSpaceId} />,
|
models: <ModelConfigManager searchSpaceId={searchSpaceId} />,
|
||||||
roles: <LLMRoleManager searchSpaceId={searchSpaceId} />,
|
roles: <LLMRoleManager searchSpaceId={searchSpaceId} />,
|
||||||
"image-models": <ImageModelManager searchSpaceId={searchSpaceId} />,
|
"image-models": <ImageModelManager searchSpaceId={searchSpaceId} />,
|
||||||
|
"vision-models": <VisionModelManager searchSpaceId={searchSpaceId} />,
|
||||||
"team-roles": <RolesManager searchSpaceId={searchSpaceId} />,
|
"team-roles": <RolesManager searchSpaceId={searchSpaceId} />,
|
||||||
prompts: <PromptConfigManager searchSpaceId={searchSpaceId} />,
|
prompts: <PromptConfigManager searchSpaceId={searchSpaceId} />,
|
||||||
"public-links": <PublicChatSnapshotsManager searchSpaceId={searchSpaceId} />,
|
"public-links": <PublicChatSnapshotsManager searchSpaceId={searchSpaceId} />,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { Globe, KeyRound, Monitor, Receipt, Sparkles, User } from "lucide-react";
|
import { Globe, KeyRound, Monitor, Receipt, Sparkles, User } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useMemo } from "react";
|
||||||
import { ApiKeyContent } from "@/app/dashboard/[search_space_id]/user-settings/components/ApiKeyContent";
|
import { ApiKeyContent } from "@/app/dashboard/[search_space_id]/user-settings/components/ApiKeyContent";
|
||||||
import { CommunityPromptsContent } from "@/app/dashboard/[search_space_id]/user-settings/components/CommunityPromptsContent";
|
import { CommunityPromptsContent } from "@/app/dashboard/[search_space_id]/user-settings/components/CommunityPromptsContent";
|
||||||
import { DesktopContent } from "@/app/dashboard/[search_space_id]/user-settings/components/DesktopContent";
|
import { DesktopContent } from "@/app/dashboard/[search_space_id]/user-settings/components/DesktopContent";
|
||||||
|
|
@ -11,37 +12,42 @@ import { PromptsContent } from "@/app/dashboard/[search_space_id]/user-settings/
|
||||||
import { PurchaseHistoryContent } from "@/app/dashboard/[search_space_id]/user-settings/components/PurchaseHistoryContent";
|
import { PurchaseHistoryContent } from "@/app/dashboard/[search_space_id]/user-settings/components/PurchaseHistoryContent";
|
||||||
import { userSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
|
import { userSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
|
||||||
import { SettingsDialog } from "@/components/settings/settings-dialog";
|
import { SettingsDialog } from "@/components/settings/settings-dialog";
|
||||||
|
import { usePlatform } from "@/hooks/use-platform";
|
||||||
|
|
||||||
export function UserSettingsDialog() {
|
export function UserSettingsDialog() {
|
||||||
const t = useTranslations("userSettings");
|
const t = useTranslations("userSettings");
|
||||||
const [state, setState] = useAtom(userSettingsDialogAtom);
|
const [state, setState] = useAtom(userSettingsDialogAtom);
|
||||||
|
const { isDesktop } = usePlatform();
|
||||||
|
|
||||||
const navItems = [
|
const navItems = useMemo(
|
||||||
{ value: "profile", label: t("profile_nav_label"), icon: <User className="h-4 w-4" /> },
|
() => [
|
||||||
{
|
{ value: "profile", label: t("profile_nav_label"), icon: <User className="h-4 w-4" /> },
|
||||||
value: "api-key",
|
{
|
||||||
label: t("api_key_nav_label"),
|
value: "api-key",
|
||||||
icon: <KeyRound className="h-4 w-4" />,
|
label: t("api_key_nav_label"),
|
||||||
},
|
icon: <KeyRound className="h-4 w-4" />,
|
||||||
{
|
},
|
||||||
value: "prompts",
|
{
|
||||||
label: "My Prompts",
|
value: "prompts",
|
||||||
icon: <Sparkles className="h-4 w-4" />,
|
label: "My Prompts",
|
||||||
},
|
icon: <Sparkles className="h-4 w-4" />,
|
||||||
{
|
},
|
||||||
value: "community-prompts",
|
{
|
||||||
label: "Community Prompts",
|
value: "community-prompts",
|
||||||
icon: <Globe className="h-4 w-4" />,
|
label: "Community Prompts",
|
||||||
},
|
icon: <Globe className="h-4 w-4" />,
|
||||||
{
|
},
|
||||||
value: "purchases",
|
{
|
||||||
label: "Purchase History",
|
value: "purchases",
|
||||||
icon: <Receipt className="h-4 w-4" />,
|
label: "Purchase History",
|
||||||
},
|
icon: <Receipt className="h-4 w-4" />,
|
||||||
...(typeof window !== "undefined" && window.electronAPI
|
},
|
||||||
? [{ value: "desktop", label: "Desktop", icon: <Monitor className="h-4 w-4" /> }]
|
...(isDesktop
|
||||||
: []),
|
? [{ value: "desktop", label: "Desktop", icon: <Monitor className="h-4 w-4" /> }]
|
||||||
];
|
: []),
|
||||||
|
],
|
||||||
|
[t, isDesktop]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsDialog
|
<SettingsDialog
|
||||||
|
|
|
||||||
401
surfsense_web/components/settings/vision-model-manager.tsx
Normal file
401
surfsense_web/components/settings/vision-model-manager.tsx
Normal file
|
|
@ -0,0 +1,401 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { AlertCircle, Dot, Edit3, Info, RefreshCw, Trash2 } from "lucide-react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
|
||||||
|
import { deleteVisionLLMConfigMutationAtom } from "@/atoms/vision-llm-config/vision-llm-config-mutation.atoms";
|
||||||
|
import {
|
||||||
|
globalVisionLLMConfigsAtom,
|
||||||
|
visionLLMConfigsAtom,
|
||||||
|
} from "@/atoms/vision-llm-config/vision-llm-config-query.atoms";
|
||||||
|
import { VisionConfigDialog } from "@/components/shared/vision-config-dialog";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import type { VisionLLMConfig } from "@/contracts/types/new-llm-config.types";
|
||||||
|
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||||
|
import { getProviderIcon } from "@/lib/provider-icons";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface VisionModelManagerProps {
|
||||||
|
searchSpaceId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitials(name: string): string {
|
||||||
|
const parts = name.trim().split(/\s+/);
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
return (parts[0][0] + parts[1][0]).toUpperCase();
|
||||||
|
}
|
||||||
|
return name.slice(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VisionModelManager({ searchSpaceId }: VisionModelManagerProps) {
|
||||||
|
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||||
|
|
||||||
|
const {
|
||||||
|
mutateAsync: deleteConfig,
|
||||||
|
isPending: isDeleting,
|
||||||
|
error: deleteError,
|
||||||
|
} = useAtomValue(deleteVisionLLMConfigMutationAtom);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: userConfigs,
|
||||||
|
isFetching: configsLoading,
|
||||||
|
error: fetchError,
|
||||||
|
refetch: refreshConfigs,
|
||||||
|
} = useAtomValue(visionLLMConfigsAtom);
|
||||||
|
const { data: globalConfigs = [], isFetching: globalLoading } = useAtomValue(
|
||||||
|
globalVisionLLMConfigsAtom
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: members } = useAtomValue(membersAtom);
|
||||||
|
const memberMap = useMemo(() => {
|
||||||
|
const map = new Map<string, { name: string; email?: string; avatarUrl?: string }>();
|
||||||
|
if (members) {
|
||||||
|
for (const m of members) {
|
||||||
|
map.set(m.user_id, {
|
||||||
|
name: m.user_display_name || m.user_email || "Unknown",
|
||||||
|
email: m.user_email || undefined,
|
||||||
|
avatarUrl: m.user_avatar_url || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [members]);
|
||||||
|
|
||||||
|
const { data: access } = useAtomValue(myAccessAtom);
|
||||||
|
const canCreate = useMemo(() => {
|
||||||
|
if (!access) return false;
|
||||||
|
if (access.is_owner) return true;
|
||||||
|
return access.permissions?.includes("vision_configs:create") ?? false;
|
||||||
|
}, [access]);
|
||||||
|
const canDelete = useMemo(() => {
|
||||||
|
if (!access) return false;
|
||||||
|
if (access.is_owner) return true;
|
||||||
|
return access.permissions?.includes("vision_configs:delete") ?? false;
|
||||||
|
}, [access]);
|
||||||
|
const canUpdate = canCreate;
|
||||||
|
const isReadOnly = !canCreate && !canDelete;
|
||||||
|
|
||||||
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
|
const [editingConfig, setEditingConfig] = useState<VisionLLMConfig | null>(null);
|
||||||
|
const [configToDelete, setConfigToDelete] = useState<VisionLLMConfig | null>(null);
|
||||||
|
|
||||||
|
const isLoading = configsLoading || globalLoading;
|
||||||
|
const errors = [deleteError, fetchError].filter(Boolean) as Error[];
|
||||||
|
|
||||||
|
const openEditDialog = (config: VisionLLMConfig) => {
|
||||||
|
setEditingConfig(config);
|
||||||
|
setIsDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openNewDialog = () => {
|
||||||
|
setEditingConfig(null);
|
||||||
|
setIsDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!configToDelete) return;
|
||||||
|
try {
|
||||||
|
await deleteConfig({ id: configToDelete.id, name: configToDelete.name });
|
||||||
|
setConfigToDelete(null);
|
||||||
|
} catch {
|
||||||
|
// Error handled by mutation
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 md:space-y-6">
|
||||||
|
<div className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => refreshConfigs()}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn("h-3.5 w-3.5", configsLoading && "animate-spin")} />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
{canCreate && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
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 Vision Model
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{errors.map((err) => (
|
||||||
|
<div key={err?.message}>
|
||||||
|
<Alert variant="destructive" className="py-3">
|
||||||
|
<AlertCircle className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
||||||
|
<AlertDescription className="text-xs md:text-sm">{err?.message}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{access && !isLoading && isReadOnly && (
|
||||||
|
<div>
|
||||||
|
<Alert className="bg-muted/50 py-3 md:py-4">
|
||||||
|
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
||||||
|
<AlertDescription className="text-xs md:text-sm">
|
||||||
|
You have <span className="font-medium">read-only</span> access to vision model
|
||||||
|
configurations. Contact a space owner to request additional permissions.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{access && !isLoading && !isReadOnly && (!canCreate || !canDelete) && (
|
||||||
|
<div>
|
||||||
|
<Alert className="bg-muted/50 py-3 md:py-4">
|
||||||
|
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
||||||
|
<AlertDescription className="text-xs md:text-sm">
|
||||||
|
You can{" "}
|
||||||
|
{[canCreate && "create and edit", canDelete && "delete"]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" and ")}{" "}
|
||||||
|
vision model configurations
|
||||||
|
{!canDelete && ", but cannot delete them"}.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length > 0 && (
|
||||||
|
<Alert className="bg-muted/50 py-3">
|
||||||
|
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
||||||
|
<AlertDescription className="text-xs md:text-sm">
|
||||||
|
<p>
|
||||||
|
<span className="font-medium">
|
||||||
|
{globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length}{" "}
|
||||||
|
global vision{" "}
|
||||||
|
{globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length === 1
|
||||||
|
? "model"
|
||||||
|
: "models"}
|
||||||
|
</span>{" "}
|
||||||
|
available from your administrator. Use the model selector to view and select them.
|
||||||
|
</p>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="space-y-4 md:space-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Skeleton className="h-6 md:h-7 w-40 md:w-48" />
|
||||||
|
<Skeleton className="h-8 md:h-9 w-32 md:w-36 rounded-md" />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => (
|
||||||
|
<Card key={key} className="border-border/60">
|
||||||
|
<CardContent className="p-4 flex flex-col gap-3">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="space-y-1.5 flex-1 min-w-0">
|
||||||
|
<Skeleton className="h-4 w-28 md:w-32" />
|
||||||
|
<Skeleton className="h-3 w-40 md:w-48" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Skeleton className="h-5 w-16 rounded-full" />
|
||||||
|
<Skeleton className="h-5 w-24 rounded-md" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 pt-2 border-t border-border/40">
|
||||||
|
<Skeleton className="h-3 w-20" />
|
||||||
|
<Skeleton className="h-4 w-4 rounded-full" />
|
||||||
|
<Skeleton className="h-3 w-16" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && (
|
||||||
|
<div className="space-y-4 md:space-y-6">
|
||||||
|
{(userConfigs?.length ?? 0) === 0 ? (
|
||||||
|
<Card className="border-0 bg-transparent shadow-none">
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-10 md:py-16 text-center">
|
||||||
|
<h3 className="text-sm md:text-base font-semibold mb-2">No Vision Models Yet</h3>
|
||||||
|
<p className="text-[11px] md:text-xs text-muted-foreground max-w-sm mb-4">
|
||||||
|
{canCreate
|
||||||
|
? "Add your own vision-capable model (GPT-4o, Claude, Gemini, etc.)"
|
||||||
|
: "No vision models have been added to this space yet. Contact a space owner to add one."}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{userConfigs?.map((config) => {
|
||||||
|
const member = config.user_id ? memberMap.get(config.user_id) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={config.id}>
|
||||||
|
<Card className="group relative overflow-hidden transition-all duration-200 border-border/60 hover:shadow-md h-full">
|
||||||
|
<CardContent className="p-4 flex flex-col gap-3 h-full">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h4 className="text-sm font-semibold tracking-tight truncate">
|
||||||
|
{config.name}
|
||||||
|
</h4>
|
||||||
|
{config.description && (
|
||||||
|
<p className="text-[11px] text-muted-foreground/70 truncate mt-0.5">
|
||||||
|
{config.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{(canUpdate || canDelete) && (
|
||||||
|
<div className="flex items-center gap-0.5 shrink-0 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity duration-150">
|
||||||
|
{canUpdate && (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip open={isDesktop ? undefined : false}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => openEditDialog(config)}
|
||||||
|
className="h-7 w-7 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Edit3 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Edit</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
{canDelete && (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip open={isDesktop ? undefined : false}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setConfigToDelete(config)}
|
||||||
|
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Delete</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{getProviderIcon(config.provider, {
|
||||||
|
className: "size-3.5 shrink-0",
|
||||||
|
})}
|
||||||
|
<code className="text-[11px] font-mono text-muted-foreground bg-muted/60 px-2 py-0.5 rounded-md truncate max-w-[160px]">
|
||||||
|
{config.model_name}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 pt-2 border-t border-border/40 mt-auto">
|
||||||
|
<span className="text-[11px] text-muted-foreground/60">
|
||||||
|
{new Date(config.created_at).toLocaleDateString(undefined, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
{member && (
|
||||||
|
<>
|
||||||
|
<Dot className="h-4 w-4 text-muted-foreground/30" />
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip open={isDesktop ? undefined : false}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className="flex items-center gap-1.5 cursor-default">
|
||||||
|
<Avatar className="size-4.5 shrink-0">
|
||||||
|
{member.avatarUrl && (
|
||||||
|
<AvatarImage src={member.avatarUrl} alt={member.name} />
|
||||||
|
)}
|
||||||
|
<AvatarFallback className="text-[9px]">
|
||||||
|
{getInitials(member.name)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="text-[11px] text-muted-foreground/60 truncate max-w-[120px]">
|
||||||
|
{member.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">
|
||||||
|
{member.email || member.name}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<VisionConfigDialog
|
||||||
|
open={isDialogOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setIsDialogOpen(open);
|
||||||
|
if (!open) setEditingConfig(null);
|
||||||
|
}}
|
||||||
|
config={editingConfig}
|
||||||
|
isGlobal={false}
|
||||||
|
searchSpaceId={searchSpaceId}
|
||||||
|
mode={editingConfig ? "edit" : "create"}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
open={!!configToDelete}
|
||||||
|
onOpenChange={(open) => !open && setConfigToDelete(null)}
|
||||||
|
>
|
||||||
|
<AlertDialogContent className="select-none">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete Vision Model</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to delete{" "}
|
||||||
|
<span className="font-semibold text-foreground">{configToDelete?.name}</span>?
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
<span className={isDeleting ? "opacity-0" : ""}>Delete</span>
|
||||||
|
{isDeleting && <Spinner size="sm" className="absolute" />}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
481
surfsense_web/components/shared/vision-config-dialog.tsx
Normal file
481
surfsense_web/components/shared/vision-config-dialog.tsx
Normal file
|
|
@ -0,0 +1,481 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { AlertCircle, Check, ChevronsUpDown } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { updateLLMPreferencesMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
|
||||||
|
import {
|
||||||
|
createVisionLLMConfigMutationAtom,
|
||||||
|
updateVisionLLMConfigMutationAtom,
|
||||||
|
} from "@/atoms/vision-llm-config/vision-llm-config-mutation.atoms";
|
||||||
|
import { visionModelListAtom } from "@/atoms/vision-llm-config/vision-llm-config-query.atoms";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { VISION_PROVIDERS } from "@/contracts/enums/vision-providers";
|
||||||
|
import type {
|
||||||
|
GlobalVisionLLMConfig,
|
||||||
|
VisionLLMConfig,
|
||||||
|
VisionProvider,
|
||||||
|
} from "@/contracts/types/new-llm-config.types";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface VisionConfigDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
config: VisionLLMConfig | GlobalVisionLLMConfig | null;
|
||||||
|
isGlobal: boolean;
|
||||||
|
searchSpaceId: number;
|
||||||
|
mode: "create" | "edit" | "view";
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL_FORM = {
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
provider: "",
|
||||||
|
model_name: "",
|
||||||
|
api_key: "",
|
||||||
|
api_base: "",
|
||||||
|
api_version: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function VisionConfigDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
config,
|
||||||
|
isGlobal,
|
||||||
|
searchSpaceId,
|
||||||
|
mode,
|
||||||
|
}: VisionConfigDialogProps) {
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [formData, setFormData] = useState(INITIAL_FORM);
|
||||||
|
const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top");
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
if (mode === "edit" && config && !isGlobal) {
|
||||||
|
setFormData({
|
||||||
|
name: config.name || "",
|
||||||
|
description: config.description || "",
|
||||||
|
provider: config.provider || "",
|
||||||
|
model_name: config.model_name || "",
|
||||||
|
api_key: (config as VisionLLMConfig).api_key || "",
|
||||||
|
api_base: config.api_base || "",
|
||||||
|
api_version: (config as VisionLLMConfig).api_version || "",
|
||||||
|
});
|
||||||
|
} else if (mode === "create") {
|
||||||
|
setFormData(INITIAL_FORM);
|
||||||
|
}
|
||||||
|
setScrollPos("top");
|
||||||
|
}
|
||||||
|
}, [open, mode, config, isGlobal]);
|
||||||
|
|
||||||
|
const { mutateAsync: createConfig } = useAtomValue(createVisionLLMConfigMutationAtom);
|
||||||
|
const { mutateAsync: updateConfig } = useAtomValue(updateVisionLLMConfigMutationAtom);
|
||||||
|
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
|
||||||
|
|
||||||
|
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||||
|
const el = e.currentTarget;
|
||||||
|
const atTop = el.scrollTop <= 2;
|
||||||
|
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
|
||||||
|
setScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getTitle = () => {
|
||||||
|
if (mode === "create") return "Add Vision Model";
|
||||||
|
if (isGlobal) return "View Global Vision Model";
|
||||||
|
return "Edit Vision Model";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSubtitle = () => {
|
||||||
|
if (mode === "create") return "Set up a new vision-capable LLM provider";
|
||||||
|
if (isGlobal) return "Read-only global configuration";
|
||||||
|
return "Update your vision model settings";
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
if (mode === "create") {
|
||||||
|
const result = await createConfig({
|
||||||
|
name: formData.name,
|
||||||
|
provider: formData.provider as VisionProvider,
|
||||||
|
model_name: formData.model_name,
|
||||||
|
api_key: formData.api_key,
|
||||||
|
api_base: formData.api_base || undefined,
|
||||||
|
api_version: formData.api_version || undefined,
|
||||||
|
description: formData.description || undefined,
|
||||||
|
search_space_id: searchSpaceId,
|
||||||
|
});
|
||||||
|
if (result?.id) {
|
||||||
|
await updatePreferences({
|
||||||
|
search_space_id: searchSpaceId,
|
||||||
|
data: { vision_llm_config_id: result.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
onOpenChange(false);
|
||||||
|
} else if (!isGlobal && config) {
|
||||||
|
await updateConfig({
|
||||||
|
id: config.id,
|
||||||
|
data: {
|
||||||
|
name: formData.name,
|
||||||
|
description: formData.description || undefined,
|
||||||
|
provider: formData.provider as VisionProvider,
|
||||||
|
model_name: formData.model_name,
|
||||||
|
api_key: formData.api_key,
|
||||||
|
api_base: formData.api_base || undefined,
|
||||||
|
api_version: formData.api_version || undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save vision config:", error);
|
||||||
|
toast.error("Failed to save vision model");
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
mode,
|
||||||
|
isGlobal,
|
||||||
|
config,
|
||||||
|
formData,
|
||||||
|
searchSpaceId,
|
||||||
|
createConfig,
|
||||||
|
updateConfig,
|
||||||
|
updatePreferences,
|
||||||
|
onOpenChange,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleUseGlobalConfig = useCallback(async () => {
|
||||||
|
if (!config || !isGlobal) return;
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
await updatePreferences({
|
||||||
|
search_space_id: searchSpaceId,
|
||||||
|
data: { vision_llm_config_id: config.id },
|
||||||
|
});
|
||||||
|
toast.success(`Now using ${config.name}`);
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to set vision model:", error);
|
||||||
|
toast.error("Failed to set vision model");
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [config, isGlobal, searchSpaceId, updatePreferences, onOpenChange]);
|
||||||
|
|
||||||
|
const { data: dynamicModels } = useAtomValue(visionModelListAtom);
|
||||||
|
const [modelComboboxOpen, setModelComboboxOpen] = useState(false);
|
||||||
|
|
||||||
|
const availableModels = useMemo(
|
||||||
|
() => (dynamicModels ?? []).filter((m) => m.provider === formData.provider),
|
||||||
|
[dynamicModels, formData.provider]
|
||||||
|
);
|
||||||
|
|
||||||
|
const isFormValid = formData.name && formData.provider && formData.model_name && formData.api_key;
|
||||||
|
const selectedProvider = VISION_PROVIDERS.find((p) => p.value === formData.provider);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent
|
||||||
|
className="max-w-lg h-[85vh] flex flex-col p-0 gap-0 overflow-hidden"
|
||||||
|
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<DialogTitle className="sr-only">{getTitle()}</DialogTitle>
|
||||||
|
|
||||||
|
<div className="flex items-start justify-between px-6 pt-6 pb-4 pr-14">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className="text-lg font-semibold tracking-tight">{getTitle()}</h2>
|
||||||
|
{isGlobal && mode !== "create" && (
|
||||||
|
<Badge variant="secondary" className="text-[10px]">
|
||||||
|
Global
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">{getSubtitle()}</p>
|
||||||
|
{config && mode !== "create" && (
|
||||||
|
<p className="text-xs font-mono text-muted-foreground/70">{config.model_name}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
className="flex-1 overflow-y-auto px-6 py-5"
|
||||||
|
style={{
|
||||||
|
maskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
|
||||||
|
WebkitMaskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isGlobal && config && (
|
||||||
|
<>
|
||||||
|
<Alert className="mb-5 border-amber-500/30 bg-amber-500/5">
|
||||||
|
<AlertCircle className="size-4 text-amber-500" />
|
||||||
|
<AlertDescription className="text-sm text-amber-700 dark:text-amber-400">
|
||||||
|
Global configurations are read-only. To customize, create a new model.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
Name
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium">{config.name}</p>
|
||||||
|
</div>
|
||||||
|
{config.description && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
Description
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">{config.description}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
Provider
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium">{config.provider}</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
Model
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium font-mono">{config.model_name}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(mode === "create" || (mode === "edit" && !isGlobal)) && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-medium">Name *</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g., My GPT-4o Vision"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData((p) => ({ ...p, name: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-medium">Description</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="Optional description"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData((p) => ({ ...p, description: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-medium">Provider *</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.provider}
|
||||||
|
onValueChange={(val) =>
|
||||||
|
setFormData((p) => ({ ...p, provider: val, model_name: "" }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a provider" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{VISION_PROVIDERS.map((p) => (
|
||||||
|
<SelectItem key={p.value} value={p.value} description={p.example}>
|
||||||
|
{p.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-medium">Model Name *</Label>
|
||||||
|
<Popover open={modelComboboxOpen} onOpenChange={setModelComboboxOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={modelComboboxOpen}
|
||||||
|
className={cn(
|
||||||
|
"w-full justify-between font-normal bg-transparent",
|
||||||
|
!formData.model_name && "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formData.model_name || "Select a model"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="w-full p-0 bg-muted dark:border-neutral-700"
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<Command shouldFilter={false} className="bg-transparent">
|
||||||
|
<CommandInput
|
||||||
|
placeholder={selectedProvider?.example || "Search model name"}
|
||||||
|
value={formData.model_name}
|
||||||
|
onValueChange={(val) =>
|
||||||
|
setFormData((p) => ({ ...p, model_name: val }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<CommandList className="max-h-[300px]">
|
||||||
|
<CommandEmpty>
|
||||||
|
<div className="py-3 text-center text-sm text-muted-foreground">
|
||||||
|
{formData.model_name
|
||||||
|
? `Using: "${formData.model_name}"`
|
||||||
|
: "Type your model name"}
|
||||||
|
</div>
|
||||||
|
</CommandEmpty>
|
||||||
|
{availableModels.length > 0 && (
|
||||||
|
<CommandGroup heading="Suggested Models">
|
||||||
|
{availableModels
|
||||||
|
.filter(
|
||||||
|
(model) =>
|
||||||
|
!formData.model_name ||
|
||||||
|
model.value
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(formData.model_name.toLowerCase()) ||
|
||||||
|
model.label
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(formData.model_name.toLowerCase())
|
||||||
|
)
|
||||||
|
.slice(0, 50)
|
||||||
|
.map((model) => (
|
||||||
|
<CommandItem
|
||||||
|
key={model.value}
|
||||||
|
value={model.value}
|
||||||
|
onSelect={(value) => {
|
||||||
|
setFormData((p) => ({
|
||||||
|
...p,
|
||||||
|
model_name: value,
|
||||||
|
}));
|
||||||
|
setModelComboboxOpen(false);
|
||||||
|
}}
|
||||||
|
className="py-2"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
formData.model_name === model.value
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{model.label}</div>
|
||||||
|
{model.contextWindow && (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Context: {model.contextWindow}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
)}
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-medium">API Key *</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="sk-..."
|
||||||
|
value={formData.api_key}
|
||||||
|
onChange={(e) => setFormData((p) => ({ ...p, api_key: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-medium">API Base URL</Label>
|
||||||
|
<Input
|
||||||
|
placeholder={selectedProvider?.apiBase || "Optional"}
|
||||||
|
value={formData.api_base}
|
||||||
|
onChange={(e) => setFormData((p) => ({ ...p, api_base: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formData.provider === "AZURE_OPENAI" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-medium">API Version (Azure)</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="2024-02-15-preview"
|
||||||
|
value={formData.api_version}
|
||||||
|
onChange={(e) => setFormData((p) => ({ ...p, api_version: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="shrink-0 px-6 py-4 flex items-center justify-end gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="text-sm h-9"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
{mode === "create" || (mode === "edit" && !isGlobal) ? (
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isSubmitting || !isFormValid}
|
||||||
|
className="relative text-sm h-9 min-w-[120px]"
|
||||||
|
>
|
||||||
|
<span className={isSubmitting ? "opacity-0" : ""}>
|
||||||
|
{mode === "edit" ? "Save Changes" : "Add Model"}
|
||||||
|
</span>
|
||||||
|
{isSubmitting && <Spinner size="sm" className="absolute" />}
|
||||||
|
</Button>
|
||||||
|
) : isGlobal && config ? (
|
||||||
|
<Button
|
||||||
|
className="relative text-sm h-9"
|
||||||
|
onClick={handleUseGlobalConfig}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
<span className={isSubmitting ? "opacity-0" : ""}>Use This Model</span>
|
||||||
|
{isSubmitting && <Spinner size="sm" className="absolute" />}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -25,6 +25,7 @@ import {
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { useElectronAPI } from "@/hooks/use-platform";
|
||||||
import { getAcceptedFileTypes, getSupportedExtensions, getSupportedExtensionsSet } from "@/lib/supported-extensions";
|
import { getAcceptedFileTypes, getSupportedExtensions, getSupportedExtensionsSet } from "@/lib/supported-extensions";
|
||||||
import {
|
import {
|
||||||
trackDocumentUploadFailure,
|
trackDocumentUploadFailure,
|
||||||
|
|
@ -73,7 +74,8 @@ export function DocumentUploadTab({
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const isElectron = typeof window !== "undefined" && !!window.electronAPI?.browseFiles;
|
const electronAPI = useElectronAPI();
|
||||||
|
const isElectron = !!electronAPI?.browseFiles;
|
||||||
|
|
||||||
const acceptedFileTypes = useMemo(() => getAcceptedFileTypes(), []);
|
const acceptedFileTypes = useMemo(() => getAcceptedFileTypes(), []);
|
||||||
const supportedExtensions = useMemo(
|
const supportedExtensions = useMemo(
|
||||||
|
|
@ -129,14 +131,13 @@ export function DocumentUploadTab({
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleBrowseFiles = useCallback(async () => {
|
const handleBrowseFiles = useCallback(async () => {
|
||||||
const api = window.electronAPI;
|
if (!electronAPI?.browseFiles) return;
|
||||||
if (!api?.browseFiles) return;
|
|
||||||
|
|
||||||
const paths = await api.browseFiles();
|
const paths = await electronAPI.browseFiles();
|
||||||
if (!paths || paths.length === 0) return;
|
if (!paths || paths.length === 0) return;
|
||||||
|
|
||||||
const fileDataList = await api.readLocalFiles(paths);
|
const fileDataList = await electronAPI.readLocalFiles(paths);
|
||||||
const filtered = fileDataList.filter((fd) => {
|
const filtered = fileDataList.filter((fd: { name: string; data: ArrayBuffer; mimeType: string }) => {
|
||||||
const ext = fd.name.includes(".") ? `.${fd.name.split(".").pop()?.toLowerCase()}` : "";
|
const ext = fd.name.includes(".") ? `.${fd.name.split(".").pop()?.toLowerCase()}` : "";
|
||||||
return ext !== "" && supportedExtensionsSet.has(ext);
|
return ext !== "" && supportedExtensionsSet.has(ext);
|
||||||
});
|
});
|
||||||
|
|
@ -146,12 +147,12 @@ export function DocumentUploadTab({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newFiles: FileWithId[] = filtered.map((fd) => ({
|
const newFiles: FileWithId[] = filtered.map((fd: { name: string; data: ArrayBuffer; mimeType: string }) => ({
|
||||||
id: crypto.randomUUID?.() ?? `file-${Date.now()}-${Math.random().toString(36)}`,
|
id: crypto.randomUUID?.() ?? `file-${Date.now()}-${Math.random().toString(36)}`,
|
||||||
file: new File([fd.data], fd.name, { type: fd.mimeType }),
|
file: new File([fd.data], fd.name, { type: fd.mimeType }),
|
||||||
}));
|
}));
|
||||||
setFiles((prev) => [...prev, ...newFiles]);
|
setFiles((prev) => [...prev, ...newFiles]);
|
||||||
}, [supportedExtensionsSet, t]);
|
}, [electronAPI, supportedExtensionsSet, t]);
|
||||||
|
|
||||||
const handleFolderChange = useCallback(
|
const handleFolderChange = useCallback(
|
||||||
(e: ChangeEvent<HTMLInputElement>) => {
|
(e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
|
|
||||||
29
surfsense_web/contexts/platform-context.tsx
Normal file
29
surfsense_web/contexts/platform-context.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, type ReactNode, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export interface PlatformContextValue {
|
||||||
|
isDesktop: boolean;
|
||||||
|
isWeb: boolean;
|
||||||
|
electronAPI: ElectronAPI | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SSR_VALUE: PlatformContextValue = {
|
||||||
|
isDesktop: false,
|
||||||
|
isWeb: false,
|
||||||
|
electronAPI: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PlatformContext = createContext<PlatformContextValue>(SSR_VALUE);
|
||||||
|
|
||||||
|
export function PlatformProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [value, setValue] = useState<PlatformContextValue>(SSR_VALUE);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const api = window.electronAPI ?? null;
|
||||||
|
const isDesktop = !!api;
|
||||||
|
setValue({ isDesktop, isWeb: !isDesktop, electronAPI: api });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <PlatformContext.Provider value={value}>{children}</PlatformContext.Provider>;
|
||||||
|
}
|
||||||
128
surfsense_web/contracts/enums/vision-providers.ts
Normal file
128
surfsense_web/contracts/enums/vision-providers.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
import type { LLMModel } from "./llm-models";
|
||||||
|
|
||||||
|
export interface VisionProviderInfo {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
example: string;
|
||||||
|
description: string;
|
||||||
|
apiBase?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VISION_PROVIDERS: VisionProviderInfo[] = [
|
||||||
|
{
|
||||||
|
value: "OPENAI",
|
||||||
|
label: "OpenAI",
|
||||||
|
example: "gpt-4o, gpt-4o-mini",
|
||||||
|
description: "GPT-4o vision models",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "ANTHROPIC",
|
||||||
|
label: "Anthropic",
|
||||||
|
example: "claude-sonnet-4-20250514",
|
||||||
|
description: "Claude vision models",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "GOOGLE",
|
||||||
|
label: "Google AI Studio",
|
||||||
|
example: "gemini-2.5-flash, gemini-2.0-flash",
|
||||||
|
description: "Gemini vision models",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "AZURE_OPENAI",
|
||||||
|
label: "Azure OpenAI",
|
||||||
|
example: "azure/gpt-4o",
|
||||||
|
description: "OpenAI vision models on Azure",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "VERTEX_AI",
|
||||||
|
label: "Google Vertex AI",
|
||||||
|
example: "vertex_ai/gemini-2.5-flash",
|
||||||
|
description: "Gemini vision models on Vertex AI",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "BEDROCK",
|
||||||
|
label: "AWS Bedrock",
|
||||||
|
example: "bedrock/anthropic.claude-sonnet-4-20250514-v1:0",
|
||||||
|
description: "Vision models on AWS Bedrock",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "XAI",
|
||||||
|
label: "xAI",
|
||||||
|
example: "grok-2-vision",
|
||||||
|
description: "Grok vision models",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "OPENROUTER",
|
||||||
|
label: "OpenRouter",
|
||||||
|
example: "openrouter/openai/gpt-4o",
|
||||||
|
description: "Vision models via OpenRouter",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "OLLAMA",
|
||||||
|
label: "Ollama",
|
||||||
|
example: "llava, bakllava",
|
||||||
|
description: "Local vision models via Ollama",
|
||||||
|
apiBase: "http://localhost:11434",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "GROQ",
|
||||||
|
label: "Groq",
|
||||||
|
example: "llama-4-scout-17b-16e-instruct",
|
||||||
|
description: "Vision models on Groq",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "TOGETHER_AI",
|
||||||
|
label: "Together AI",
|
||||||
|
example: "meta-llama/Llama-4-Scout-17B-16E-Instruct",
|
||||||
|
description: "Vision models on Together AI",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "FIREWORKS_AI",
|
||||||
|
label: "Fireworks AI",
|
||||||
|
example: "fireworks_ai/phi-3-vision-128k-instruct",
|
||||||
|
description: "Vision models on Fireworks AI",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "DEEPSEEK",
|
||||||
|
label: "DeepSeek",
|
||||||
|
example: "deepseek-chat",
|
||||||
|
description: "DeepSeek vision models",
|
||||||
|
apiBase: "https://api.deepseek.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "MISTRAL",
|
||||||
|
label: "Mistral",
|
||||||
|
example: "pixtral-large-latest",
|
||||||
|
description: "Pixtral vision models",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "CUSTOM",
|
||||||
|
label: "Custom Provider",
|
||||||
|
example: "custom/my-vision-model",
|
||||||
|
description: "Custom OpenAI-compatible vision endpoint",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const VISION_MODELS: LLMModel[] = [
|
||||||
|
{ value: "gpt-4o", label: "GPT-4o", provider: "OPENAI", contextWindow: "128K" },
|
||||||
|
{ value: "gpt-4o-mini", label: "GPT-4o Mini", provider: "OPENAI", contextWindow: "128K" },
|
||||||
|
{ value: "gpt-4-turbo", label: "GPT-4 Turbo", provider: "OPENAI", contextWindow: "128K" },
|
||||||
|
{ value: "claude-sonnet-4-20250514", label: "Claude Sonnet 4", provider: "ANTHROPIC", contextWindow: "200K" },
|
||||||
|
{ value: "claude-3-7-sonnet-20250219", label: "Claude 3.7 Sonnet", provider: "ANTHROPIC", contextWindow: "200K" },
|
||||||
|
{ value: "claude-3-5-sonnet-20241022", label: "Claude 3.5 Sonnet", provider: "ANTHROPIC", contextWindow: "200K" },
|
||||||
|
{ value: "claude-3-opus-20240229", label: "Claude 3 Opus", provider: "ANTHROPIC", contextWindow: "200K" },
|
||||||
|
{ value: "claude-3-haiku-20240307", label: "Claude 3 Haiku", provider: "ANTHROPIC", contextWindow: "200K" },
|
||||||
|
{ value: "gemini-2.5-flash", label: "Gemini 2.5 Flash", provider: "GOOGLE", contextWindow: "1M" },
|
||||||
|
{ value: "gemini-2.5-pro", label: "Gemini 2.5 Pro", provider: "GOOGLE", contextWindow: "1M" },
|
||||||
|
{ value: "gemini-2.0-flash", label: "Gemini 2.0 Flash", provider: "GOOGLE", contextWindow: "1M" },
|
||||||
|
{ value: "gemini-1.5-pro", label: "Gemini 1.5 Pro", provider: "GOOGLE", contextWindow: "1M" },
|
||||||
|
{ value: "gemini-1.5-flash", label: "Gemini 1.5 Flash", provider: "GOOGLE", contextWindow: "1M" },
|
||||||
|
{ value: "pixtral-large-latest", label: "Pixtral Large", provider: "MISTRAL", contextWindow: "128K" },
|
||||||
|
{ value: "pixtral-12b-2409", label: "Pixtral 12B", provider: "MISTRAL", contextWindow: "128K" },
|
||||||
|
{ value: "grok-2-vision-1212", label: "Grok 2 Vision", provider: "XAI", contextWindow: "32K" },
|
||||||
|
{ value: "llava", label: "LLaVA", provider: "OLLAMA" },
|
||||||
|
{ value: "bakllava", label: "BakLLaVA", provider: "OLLAMA" },
|
||||||
|
{ value: "llava-llama3", label: "LLaVA Llama 3", provider: "OLLAMA" },
|
||||||
|
{ value: "llama-4-scout-17b-16e-instruct", label: "Llama 4 Scout 17B", provider: "GROQ", contextWindow: "128K" },
|
||||||
|
{ value: "meta-llama/Llama-4-Scout-17B-16E-Instruct", label: "Llama 4 Scout 17B", provider: "TOGETHER_AI", contextWindow: "128K" },
|
||||||
|
];
|
||||||
|
|
@ -252,23 +252,99 @@ export const globalImageGenConfig = z.object({
|
||||||
|
|
||||||
export const getGlobalImageGenConfigsResponse = z.array(globalImageGenConfig);
|
export const getGlobalImageGenConfigsResponse = z.array(globalImageGenConfig);
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Vision LLM Config (separate table for vision-capable models)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const visionProviderEnum = z.enum([
|
||||||
|
"OPENAI",
|
||||||
|
"ANTHROPIC",
|
||||||
|
"GOOGLE",
|
||||||
|
"AZURE_OPENAI",
|
||||||
|
"VERTEX_AI",
|
||||||
|
"BEDROCK",
|
||||||
|
"XAI",
|
||||||
|
"OPENROUTER",
|
||||||
|
"OLLAMA",
|
||||||
|
"GROQ",
|
||||||
|
"TOGETHER_AI",
|
||||||
|
"FIREWORKS_AI",
|
||||||
|
"DEEPSEEK",
|
||||||
|
"MISTRAL",
|
||||||
|
"CUSTOM",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type VisionProvider = z.infer<typeof visionProviderEnum>;
|
||||||
|
|
||||||
|
export const visionLLMConfig = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
name: z.string().max(100),
|
||||||
|
description: z.string().max(500).nullable().optional(),
|
||||||
|
provider: visionProviderEnum,
|
||||||
|
custom_provider: z.string().max(100).nullable().optional(),
|
||||||
|
model_name: z.string().max(100),
|
||||||
|
api_key: z.string(),
|
||||||
|
api_base: z.string().max(500).nullable().optional(),
|
||||||
|
api_version: z.string().max(50).nullable().optional(),
|
||||||
|
litellm_params: z.record(z.string(), z.any()).nullable().optional(),
|
||||||
|
created_at: z.string(),
|
||||||
|
search_space_id: z.number(),
|
||||||
|
user_id: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createVisionLLMConfigRequest = visionLLMConfig.omit({
|
||||||
|
id: true,
|
||||||
|
created_at: true,
|
||||||
|
user_id: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createVisionLLMConfigResponse = visionLLMConfig;
|
||||||
|
|
||||||
|
export const getVisionLLMConfigsResponse = z.array(visionLLMConfig);
|
||||||
|
|
||||||
|
export const updateVisionLLMConfigRequest = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
data: visionLLMConfig
|
||||||
|
.omit({ id: true, created_at: true, search_space_id: true, user_id: true })
|
||||||
|
.partial(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateVisionLLMConfigResponse = visionLLMConfig;
|
||||||
|
|
||||||
|
export const deleteVisionLLMConfigResponse = z.object({
|
||||||
|
message: z.string(),
|
||||||
|
id: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const globalVisionLLMConfig = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
name: z.string(),
|
||||||
|
description: z.string().nullable().optional(),
|
||||||
|
provider: z.string(),
|
||||||
|
custom_provider: z.string().nullable().optional(),
|
||||||
|
model_name: z.string(),
|
||||||
|
api_base: z.string().nullable().optional(),
|
||||||
|
api_version: z.string().nullable().optional(),
|
||||||
|
litellm_params: z.record(z.string(), z.any()).nullable().optional(),
|
||||||
|
is_global: z.literal(true),
|
||||||
|
is_auto_mode: z.boolean().optional().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getGlobalVisionLLMConfigsResponse = z.array(globalVisionLLMConfig);
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// LLM Preferences (Role Assignments)
|
// LLM Preferences (Role Assignments)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
/**
|
|
||||||
* LLM Preferences schemas - for role assignments
|
|
||||||
* image_generation uses image_generation_config_id (not llm_id)
|
|
||||||
*/
|
|
||||||
export const llmPreferences = z.object({
|
export const llmPreferences = z.object({
|
||||||
agent_llm_id: z.union([z.number(), z.null()]).optional(),
|
agent_llm_id: z.union([z.number(), z.null()]).optional(),
|
||||||
document_summary_llm_id: z.union([z.number(), z.null()]).optional(),
|
document_summary_llm_id: z.union([z.number(), z.null()]).optional(),
|
||||||
image_generation_config_id: z.union([z.number(), z.null()]).optional(),
|
image_generation_config_id: z.union([z.number(), z.null()]).optional(),
|
||||||
vision_llm_id: z.union([z.number(), z.null()]).optional(),
|
vision_llm_config_id: z.union([z.number(), z.null()]).optional(),
|
||||||
agent_llm: z.union([z.record(z.string(), z.unknown()), z.null()]).optional(),
|
agent_llm: z.union([z.record(z.string(), z.unknown()), z.null()]).optional(),
|
||||||
document_summary_llm: z.union([z.record(z.string(), z.unknown()), z.null()]).optional(),
|
document_summary_llm: z.union([z.record(z.string(), z.unknown()), z.null()]).optional(),
|
||||||
image_generation_config: z.union([z.record(z.string(), z.unknown()), z.null()]).optional(),
|
image_generation_config: z.union([z.record(z.string(), z.unknown()), z.null()]).optional(),
|
||||||
vision_llm: z.union([z.record(z.string(), z.unknown()), z.null()]).optional(),
|
vision_llm_config: z.union([z.record(z.string(), z.unknown()), z.null()]).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -289,7 +365,7 @@ export const updateLLMPreferencesRequest = z.object({
|
||||||
agent_llm_id: true,
|
agent_llm_id: true,
|
||||||
document_summary_llm_id: true,
|
document_summary_llm_id: true,
|
||||||
image_generation_config_id: true,
|
image_generation_config_id: true,
|
||||||
vision_llm_id: true,
|
vision_llm_config_id: true,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -341,6 +417,15 @@ export type UpdateImageGenConfigResponse = z.infer<typeof updateImageGenConfigRe
|
||||||
export type DeleteImageGenConfigResponse = z.infer<typeof deleteImageGenConfigResponse>;
|
export type DeleteImageGenConfigResponse = z.infer<typeof deleteImageGenConfigResponse>;
|
||||||
export type GlobalImageGenConfig = z.infer<typeof globalImageGenConfig>;
|
export type GlobalImageGenConfig = z.infer<typeof globalImageGenConfig>;
|
||||||
export type GetGlobalImageGenConfigsResponse = z.infer<typeof getGlobalImageGenConfigsResponse>;
|
export type GetGlobalImageGenConfigsResponse = z.infer<typeof getGlobalImageGenConfigsResponse>;
|
||||||
|
export type VisionLLMConfig = z.infer<typeof visionLLMConfig>;
|
||||||
|
export type CreateVisionLLMConfigRequest = z.infer<typeof createVisionLLMConfigRequest>;
|
||||||
|
export type CreateVisionLLMConfigResponse = z.infer<typeof createVisionLLMConfigResponse>;
|
||||||
|
export type GetVisionLLMConfigsResponse = z.infer<typeof getVisionLLMConfigsResponse>;
|
||||||
|
export type UpdateVisionLLMConfigRequest = z.infer<typeof updateVisionLLMConfigRequest>;
|
||||||
|
export type UpdateVisionLLMConfigResponse = z.infer<typeof updateVisionLLMConfigResponse>;
|
||||||
|
export type DeleteVisionLLMConfigResponse = z.infer<typeof deleteVisionLLMConfigResponse>;
|
||||||
|
export type GlobalVisionLLMConfig = z.infer<typeof globalVisionLLMConfig>;
|
||||||
|
export type GetGlobalVisionLLMConfigsResponse = z.infer<typeof getGlobalVisionLLMConfigsResponse>;
|
||||||
export type LLMPreferences = z.infer<typeof llmPreferences>;
|
export type LLMPreferences = z.infer<typeof llmPreferences>;
|
||||||
export type GetLLMPreferencesRequest = z.infer<typeof getLLMPreferencesRequest>;
|
export type GetLLMPreferencesRequest = z.infer<typeof getLLMPreferencesRequest>;
|
||||||
export type GetLLMPreferencesResponse = z.infer<typeof getLLMPreferencesResponse>;
|
export type GetLLMPreferencesResponse = z.infer<typeof getLLMPreferencesResponse>;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useElectronAPI } from "@/hooks/use-platform";
|
||||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||||
|
|
||||||
interface FileChangedEvent {
|
interface FileChangedEvent {
|
||||||
|
|
@ -29,6 +30,7 @@ interface BatchItem {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useFolderSync() {
|
export function useFolderSync() {
|
||||||
|
const electronAPI = useElectronAPI();
|
||||||
const queueRef = useRef<BatchItem[]>([]);
|
const queueRef = useRef<BatchItem[]>([]);
|
||||||
const processingRef = useRef(false);
|
const processingRef = useRef(false);
|
||||||
const debounceTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
const debounceTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||||
|
|
@ -49,9 +51,8 @@ export function useFolderSync() {
|
||||||
target_file_paths: batch.filePaths,
|
target_file_paths: batch.filePaths,
|
||||||
root_folder_id: batch.rootFolderId,
|
root_folder_id: batch.rootFolderId,
|
||||||
});
|
});
|
||||||
const api = typeof window !== "undefined" ? window.electronAPI : null;
|
if (electronAPI?.acknowledgeFileEvents && batch.ackIds.length > 0) {
|
||||||
if (api?.acknowledgeFileEvents && batch.ackIds.length > 0) {
|
await electronAPI.acknowledgeFileEvents(batch.ackIds);
|
||||||
await api.acknowledgeFileEvents(batch.ackIds);
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[FolderSync] Failed to trigger batch re-index:", err);
|
console.error("[FolderSync] Failed to trigger batch re-index:", err);
|
||||||
|
|
@ -117,25 +118,22 @@ export function useFolderSync() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
isMountedRef.current = true;
|
isMountedRef.current = true;
|
||||||
const api = typeof window !== "undefined" ? window.electronAPI : null;
|
if (!electronAPI?.onFileChanged) {
|
||||||
if (!api?.onFileChanged) {
|
|
||||||
return () => {
|
return () => {
|
||||||
isMountedRef.current = false;
|
isMountedRef.current = false;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Signal to main process that the renderer is ready to receive events
|
electronAPI.signalRendererReady?.();
|
||||||
api.signalRendererReady?.();
|
|
||||||
|
|
||||||
// Drain durable outbox first so events survive renderer startup gaps and restarts
|
void electronAPI.getPendingFileEvents?.().then((pendingEvents) => {
|
||||||
void api.getPendingFileEvents?.().then((pendingEvents) => {
|
|
||||||
if (!isMountedRef.current || !pendingEvents?.length) return;
|
if (!isMountedRef.current || !pendingEvents?.length) return;
|
||||||
for (const event of pendingEvents) {
|
for (const event of pendingEvents) {
|
||||||
enqueueWithDebounce(event);
|
enqueueWithDebounce(event);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const cleanup = api.onFileChanged((event: FileChangedEvent) => {
|
const cleanup = electronAPI.onFileChanged((event: FileChangedEvent) => {
|
||||||
enqueueWithDebounce(event);
|
enqueueWithDebounce(event);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -149,5 +147,5 @@ export function useFolderSync() {
|
||||||
pendingByFolder.current.clear();
|
pendingByFolder.current.clear();
|
||||||
firstEventTime.current.clear();
|
firstEventTime.current.clear();
|
||||||
};
|
};
|
||||||
}, []);
|
}, [electronAPI]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
12
surfsense_web/hooks/use-platform.ts
Normal file
12
surfsense_web/hooks/use-platform.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { useContext } from "react";
|
||||||
|
import { PlatformContext, type PlatformContextValue } from "@/contexts/platform-context";
|
||||||
|
|
||||||
|
export function usePlatform(): Pick<PlatformContextValue, "isDesktop" | "isWeb"> {
|
||||||
|
const { isDesktop, isWeb } = useContext(PlatformContext);
|
||||||
|
return { isDesktop, isWeb };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useElectronAPI(): ElectronAPI | null {
|
||||||
|
const { electronAPI } = useContext(PlatformContext);
|
||||||
|
return electronAPI;
|
||||||
|
}
|
||||||
63
surfsense_web/lib/apis/vision-llm-config-api.service.ts
Normal file
63
surfsense_web/lib/apis/vision-llm-config-api.service.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import {
|
||||||
|
type CreateVisionLLMConfigRequest,
|
||||||
|
createVisionLLMConfigRequest,
|
||||||
|
createVisionLLMConfigResponse,
|
||||||
|
deleteVisionLLMConfigResponse,
|
||||||
|
getGlobalVisionLLMConfigsResponse,
|
||||||
|
getModelListResponse,
|
||||||
|
getVisionLLMConfigsResponse,
|
||||||
|
type UpdateVisionLLMConfigRequest,
|
||||||
|
updateVisionLLMConfigRequest,
|
||||||
|
updateVisionLLMConfigResponse,
|
||||||
|
} from "@/contracts/types/new-llm-config.types";
|
||||||
|
import { ValidationError } from "../error";
|
||||||
|
import { baseApiService } from "./base-api.service";
|
||||||
|
|
||||||
|
class VisionLLMConfigApiService {
|
||||||
|
getModels = async () => {
|
||||||
|
return baseApiService.get(`/api/v1/vision-models`, getModelListResponse);
|
||||||
|
};
|
||||||
|
|
||||||
|
getGlobalConfigs = async () => {
|
||||||
|
return baseApiService.get(
|
||||||
|
`/api/v1/global-vision-llm-configs`,
|
||||||
|
getGlobalVisionLLMConfigsResponse
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
createConfig = async (request: CreateVisionLLMConfigRequest) => {
|
||||||
|
const parsed = createVisionLLMConfigRequest.safeParse(request);
|
||||||
|
if (!parsed.success) {
|
||||||
|
const msg = parsed.error.issues.map((i) => i.message).join(", ");
|
||||||
|
throw new ValidationError(`Invalid request: ${msg}`);
|
||||||
|
}
|
||||||
|
return baseApiService.post(`/api/v1/vision-llm-configs`, createVisionLLMConfigResponse, {
|
||||||
|
body: parsed.data,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
getConfigs = async (searchSpaceId: number) => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
search_space_id: String(searchSpaceId),
|
||||||
|
}).toString();
|
||||||
|
return baseApiService.get(`/api/v1/vision-llm-configs?${params}`, getVisionLLMConfigsResponse);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateConfig = async (request: UpdateVisionLLMConfigRequest) => {
|
||||||
|
const parsed = updateVisionLLMConfigRequest.safeParse(request);
|
||||||
|
if (!parsed.success) {
|
||||||
|
const msg = parsed.error.issues.map((i) => i.message).join(", ");
|
||||||
|
throw new ValidationError(`Invalid request: ${msg}`);
|
||||||
|
}
|
||||||
|
const { id, data } = parsed.data;
|
||||||
|
return baseApiService.put(`/api/v1/vision-llm-configs/${id}`, updateVisionLLMConfigResponse, {
|
||||||
|
body: data,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
deleteConfig = async (id: number) => {
|
||||||
|
return baseApiService.delete(`/api/v1/vision-llm-configs/${id}`, deleteVisionLLMConfigResponse);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const visionLLMConfigApiService = new VisionLLMConfigApiService();
|
||||||
|
|
@ -15,6 +15,7 @@ const PUBLIC_ROUTE_PREFIXES = [
|
||||||
"/login",
|
"/login",
|
||||||
"/register",
|
"/register",
|
||||||
"/auth",
|
"/auth",
|
||||||
|
"/desktop/login",
|
||||||
"/docs",
|
"/docs",
|
||||||
"/public",
|
"/public",
|
||||||
"/invite",
|
"/invite",
|
||||||
|
|
@ -34,6 +35,11 @@ export function isPublicRoute(pathname: string): boolean {
|
||||||
return PUBLIC_ROUTE_PREFIXES.some((prefix) => pathname.startsWith(prefix));
|
return PUBLIC_ROUTE_PREFIXES.some((prefix) => pathname.startsWith(prefix));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getLoginPath(): string {
|
||||||
|
if (typeof window !== "undefined" && window.electronAPI) return "/desktop/login";
|
||||||
|
return "/login";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clears tokens and optionally redirects to login.
|
* Clears tokens and optionally redirects to login.
|
||||||
* Call this when a 401 response is received.
|
* Call this when a 401 response is received.
|
||||||
|
|
@ -55,7 +61,7 @@ export function handleUnauthorized(): void {
|
||||||
if (!excludedPaths.includes(pathname)) {
|
if (!excludedPaths.includes(pathname)) {
|
||||||
localStorage.setItem(REDIRECT_PATH_KEY, currentPath);
|
localStorage.setItem(REDIRECT_PATH_KEY, currentPath);
|
||||||
}
|
}
|
||||||
window.location.href = "/login";
|
window.location.href = getLoginPath();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -87,6 +93,7 @@ export function getBearerToken(): string | null {
|
||||||
export function setBearerToken(token: string): void {
|
export function setBearerToken(token: string): void {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
localStorage.setItem(BEARER_TOKEN_KEY, token);
|
localStorage.setItem(BEARER_TOKEN_KEY, token);
|
||||||
|
syncTokensToElectron();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -111,6 +118,7 @@ export function getRefreshToken(): string | null {
|
||||||
export function setRefreshToken(token: string): void {
|
export function setRefreshToken(token: string): void {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
localStorage.setItem(REFRESH_TOKEN_KEY, token);
|
localStorage.setItem(REFRESH_TOKEN_KEY, token);
|
||||||
|
syncTokensToElectron();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -129,6 +137,44 @@ export function clearAllTokens(): void {
|
||||||
clearRefreshToken();
|
clearRefreshToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pushes the current localStorage tokens into the Electron main process
|
||||||
|
* so that other BrowserWindows (Quick Ask, Autocomplete) can access them.
|
||||||
|
*/
|
||||||
|
function syncTokensToElectron(): void {
|
||||||
|
if (typeof window === "undefined" || !window.electronAPI?.setAuthTokens) return;
|
||||||
|
const bearer = localStorage.getItem(BEARER_TOKEN_KEY) || "";
|
||||||
|
const refresh = localStorage.getItem(REFRESH_TOKEN_KEY) || "";
|
||||||
|
if (bearer) {
|
||||||
|
window.electronAPI.setAuthTokens(bearer, refresh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to pull auth tokens from the Electron main process into localStorage.
|
||||||
|
* Useful for popup windows (Quick Ask, Autocomplete) on platforms where
|
||||||
|
* localStorage is not reliably shared across BrowserWindow instances.
|
||||||
|
* Returns true if tokens were found and written to localStorage.
|
||||||
|
*/
|
||||||
|
export async function ensureTokensFromElectron(): Promise<boolean> {
|
||||||
|
if (typeof window === "undefined" || !window.electronAPI?.getAuthTokens) return false;
|
||||||
|
if (getBearerToken()) return true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tokens = await window.electronAPI.getAuthTokens();
|
||||||
|
if (tokens?.bearer) {
|
||||||
|
localStorage.setItem(BEARER_TOKEN_KEY, tokens.bearer);
|
||||||
|
if (tokens.refresh) {
|
||||||
|
localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refresh);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// IPC failure — fall through
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Logout the current user by revoking the refresh token and clearing localStorage.
|
* Logout the current user by revoking the refresh token and clearing localStorage.
|
||||||
* Returns true if logout was successful (or tokens were cleared), false otherwise.
|
* Returns true if logout was successful (or tokens were cleared), false otherwise.
|
||||||
|
|
@ -181,13 +227,12 @@ export function redirectToLogin(): void {
|
||||||
const currentPath = window.location.pathname + window.location.search + window.location.hash;
|
const currentPath = window.location.pathname + window.location.search + window.location.hash;
|
||||||
|
|
||||||
// Don't save auth-related paths or home page
|
// Don't save auth-related paths or home page
|
||||||
const excludedPaths = ["/auth", "/auth/callback", "/", "/login", "/register"];
|
const excludedPaths = ["/auth", "/auth/callback", "/", "/login", "/register", "/desktop/login"];
|
||||||
if (!excludedPaths.includes(window.location.pathname)) {
|
if (!excludedPaths.includes(window.location.pathname)) {
|
||||||
localStorage.setItem(REDIRECT_PATH_KEY, currentPath);
|
localStorage.setItem(REDIRECT_PATH_KEY, currentPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redirect to login page
|
window.location.href = getLoginPath();
|
||||||
window.location.href = "/login";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,12 @@ export const cacheKeys = {
|
||||||
byId: (configId: number) => ["image-gen-configs", "detail", configId] as const,
|
byId: (configId: number) => ["image-gen-configs", "detail", configId] as const,
|
||||||
global: () => ["image-gen-configs", "global"] as const,
|
global: () => ["image-gen-configs", "global"] as const,
|
||||||
},
|
},
|
||||||
|
visionLLMConfigs: {
|
||||||
|
all: (searchSpaceId: number) => ["vision-llm-configs", searchSpaceId] as const,
|
||||||
|
byId: (configId: number) => ["vision-llm-configs", "detail", configId] as const,
|
||||||
|
global: () => ["vision-llm-configs", "global"] as const,
|
||||||
|
modelList: () => ["vision-models", "catalogue"] as const,
|
||||||
|
},
|
||||||
auth: {
|
auth: {
|
||||||
user: ["auth", "user"] as const,
|
user: ["auth", "user"] as const,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -738,6 +738,8 @@
|
||||||
"nav_role_assignments_desc": "Assign configs to agent roles",
|
"nav_role_assignments_desc": "Assign configs to agent roles",
|
||||||
"nav_image_models": "Image Models",
|
"nav_image_models": "Image Models",
|
||||||
"nav_image_models_desc": "Configure image generation models",
|
"nav_image_models_desc": "Configure image generation models",
|
||||||
|
"nav_vision_models": "Vision Models",
|
||||||
|
"nav_vision_models_desc": "Configure vision-capable LLM models",
|
||||||
"nav_system_instructions": "System Instructions",
|
"nav_system_instructions": "System Instructions",
|
||||||
"nav_system_instructions_desc": "SearchSpace-wide AI instructions",
|
"nav_system_instructions_desc": "SearchSpace-wide AI instructions",
|
||||||
"nav_public_links": "Public Chat Links",
|
"nav_public_links": "Public Chat Links",
|
||||||
|
|
|
||||||
|
|
@ -738,6 +738,8 @@
|
||||||
"nav_role_assignments_desc": "Asignar configuraciones a roles de agente",
|
"nav_role_assignments_desc": "Asignar configuraciones a roles de agente",
|
||||||
"nav_image_models": "Modelos de imagen",
|
"nav_image_models": "Modelos de imagen",
|
||||||
"nav_image_models_desc": "Configurar modelos de generación de imágenes",
|
"nav_image_models_desc": "Configurar modelos de generación de imágenes",
|
||||||
|
"nav_vision_models": "Modelos de visión",
|
||||||
|
"nav_vision_models_desc": "Configurar modelos LLM con capacidad de visión",
|
||||||
"nav_system_instructions": "Instrucciones del sistema",
|
"nav_system_instructions": "Instrucciones del sistema",
|
||||||
"nav_system_instructions_desc": "Instrucciones de IA a nivel del espacio de búsqueda",
|
"nav_system_instructions_desc": "Instrucciones de IA a nivel del espacio de búsqueda",
|
||||||
"nav_public_links": "Enlaces de chat públicos",
|
"nav_public_links": "Enlaces de chat públicos",
|
||||||
|
|
|
||||||
|
|
@ -738,6 +738,8 @@
|
||||||
"nav_role_assignments_desc": "एजेंट भूमिकाओं को कॉन्फ़िगरेशन असाइन करें",
|
"nav_role_assignments_desc": "एजेंट भूमिकाओं को कॉन्फ़िगरेशन असाइन करें",
|
||||||
"nav_image_models": "इमेज मॉडल",
|
"nav_image_models": "इमेज मॉडल",
|
||||||
"nav_image_models_desc": "इमेज जनरेशन मॉडल कॉन्फ़िगर करें",
|
"nav_image_models_desc": "इमेज जनरेशन मॉडल कॉन्फ़िगर करें",
|
||||||
|
"nav_vision_models": "विज़न मॉडल",
|
||||||
|
"nav_vision_models_desc": "विज़न-सक्षम LLM मॉडल कॉन्फ़िगर करें",
|
||||||
"nav_system_instructions": "सिस्टम निर्देश",
|
"nav_system_instructions": "सिस्टम निर्देश",
|
||||||
"nav_system_instructions_desc": "सर्च स्पेस-व्यापी AI निर्देश",
|
"nav_system_instructions_desc": "सर्च स्पेस-व्यापी AI निर्देश",
|
||||||
"nav_public_links": "सार्वजनिक चैट लिंक",
|
"nav_public_links": "सार्वजनिक चैट लिंक",
|
||||||
|
|
|
||||||
|
|
@ -738,6 +738,8 @@
|
||||||
"nav_role_assignments_desc": "Atribuir configurações a funções do agente",
|
"nav_role_assignments_desc": "Atribuir configurações a funções do agente",
|
||||||
"nav_image_models": "Modelos de imagem",
|
"nav_image_models": "Modelos de imagem",
|
||||||
"nav_image_models_desc": "Configurar modelos de geração de imagens",
|
"nav_image_models_desc": "Configurar modelos de geração de imagens",
|
||||||
|
"nav_vision_models": "Modelos de visão",
|
||||||
|
"nav_vision_models_desc": "Configurar modelos LLM com capacidade de visão",
|
||||||
"nav_system_instructions": "Instruções do sistema",
|
"nav_system_instructions": "Instruções do sistema",
|
||||||
"nav_system_instructions_desc": "Instruções de IA em nível do espaço de pesquisa",
|
"nav_system_instructions_desc": "Instruções de IA em nível do espaço de pesquisa",
|
||||||
"nav_public_links": "Links de chat públicos",
|
"nav_public_links": "Links de chat públicos",
|
||||||
|
|
|
||||||
|
|
@ -722,6 +722,8 @@
|
||||||
"nav_role_assignments_desc": "为代理角色分配配置",
|
"nav_role_assignments_desc": "为代理角色分配配置",
|
||||||
"nav_image_models": "图像模型",
|
"nav_image_models": "图像模型",
|
||||||
"nav_image_models_desc": "配置图像生成模型",
|
"nav_image_models_desc": "配置图像生成模型",
|
||||||
|
"nav_vision_models": "视觉模型",
|
||||||
|
"nav_vision_models_desc": "配置具有视觉能力的LLM模型",
|
||||||
"nav_system_instructions": "系统指令",
|
"nav_system_instructions": "系统指令",
|
||||||
"nav_system_instructions_desc": "搜索空间级别的 AI 指令",
|
"nav_system_instructions_desc": "搜索空间级别的 AI 指令",
|
||||||
"nav_public_links": "公开聊天链接",
|
"nav_public_links": "公开聊天链接",
|
||||||
|
|
|
||||||
11
surfsense_web/types/window.d.ts
vendored
11
surfsense_web/types/window.d.ts
vendored
|
|
@ -85,6 +85,17 @@ interface ElectronAPI {
|
||||||
// Browse files/folders via native dialogs
|
// Browse files/folders via native dialogs
|
||||||
browseFiles: () => Promise<string[] | null>;
|
browseFiles: () => Promise<string[] | null>;
|
||||||
readLocalFiles: (paths: string[]) => Promise<LocalFileData[]>;
|
readLocalFiles: (paths: string[]) => Promise<LocalFileData[]>;
|
||||||
|
// Auth token sync across windows
|
||||||
|
getAuthTokens: () => Promise<{ bearer: string; refresh: string } | null>;
|
||||||
|
setAuthTokens: (bearer: string, refresh: string) => Promise<void>;
|
||||||
|
// Keyboard shortcut configuration
|
||||||
|
getShortcuts: () => Promise<{ generalAssist: string; quickAsk: string; autocomplete: string }>;
|
||||||
|
setShortcuts: (
|
||||||
|
config: Partial<{ generalAssist: string; quickAsk: string; autocomplete: string }>
|
||||||
|
) => Promise<{ generalAssist: string; quickAsk: string; autocomplete: string }>;
|
||||||
|
// Active search space
|
||||||
|
getActiveSearchSpace: () => Promise<string | null>;
|
||||||
|
setActiveSearchSpace: (id: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue