Merge pull request #808 from MODSetter/dev
feat: various stability updates and hotpatches for cloud
|
|
@ -74,6 +74,23 @@ RUN dos2unix /app/scripts/docker/entrypoint.sh && chmod +x /app/scripts/docker/e
|
|||
ENV PYTHONPATH=/app
|
||||
ENV UVICORN_LOOP=asyncio
|
||||
|
||||
# SERVICE_ROLE controls which process this container runs:
|
||||
# api – FastAPI backend only (runs migrations on startup)
|
||||
# worker – Celery worker only
|
||||
# beat – Celery beat scheduler only
|
||||
# all – All three (legacy / dev default)
|
||||
ENV SERVICE_ROLE=all
|
||||
|
||||
# Celery worker tuning (only used when SERVICE_ROLE=worker or all)
|
||||
ENV CELERY_MAX_WORKERS=10
|
||||
ENV CELERY_MIN_WORKERS=2
|
||||
ENV CELERY_MAX_TASKS_PER_CHILD=50
|
||||
# CELERY_QUEUES: comma-separated queues to consume (empty = all queues)
|
||||
# "surfsense" – fast tasks only (file uploads, podcasts, etc.)
|
||||
# "surfsense.connectors" – slow connector indexing tasks only
|
||||
# "" – both queues (default, for single-worker setups)
|
||||
ENV CELERY_QUEUES=""
|
||||
|
||||
# Run
|
||||
EXPOSE 8000-8001
|
||||
CMD ["/app/scripts/docker/entrypoint.sh"]
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
"""Add user_id to new_llm_configs and image_generation_configs
|
||||
|
||||
Revision ID: 98
|
||||
Revises: 97
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "98"
|
||||
down_revision: str | None = "97"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add user_id column to new_llm_configs and image_generation_configs.
|
||||
|
||||
Backfills existing rows with the search space owner's user_id.
|
||||
"""
|
||||
# --- new_llm_configs ---
|
||||
# 1. Add nullable column first
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE new_llm_configs
|
||||
ADD COLUMN IF NOT EXISTS user_id UUID;
|
||||
"""
|
||||
)
|
||||
|
||||
# 2. Backfill from search space owner
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE new_llm_configs nlc
|
||||
SET user_id = ss.user_id
|
||||
FROM searchspaces ss
|
||||
WHERE nlc.search_space_id = ss.id
|
||||
AND nlc.user_id IS NULL;
|
||||
"""
|
||||
)
|
||||
|
||||
# 3. Make NOT NULL
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE new_llm_configs
|
||||
ALTER COLUMN user_id SET NOT NULL;
|
||||
"""
|
||||
)
|
||||
|
||||
# 4. Add FK constraint
|
||||
op.execute(
|
||||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE constraint_name = 'fk_new_llm_configs_user_id'
|
||||
AND table_name = 'new_llm_configs'
|
||||
) THEN
|
||||
ALTER TABLE new_llm_configs
|
||||
ADD CONSTRAINT fk_new_llm_configs_user_id
|
||||
FOREIGN KEY (user_id) REFERENCES "user"(id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END$$;
|
||||
"""
|
||||
)
|
||||
|
||||
# 5. Add index for user_id lookups
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS ix_new_llm_configs_user_id
|
||||
ON new_llm_configs (user_id);
|
||||
"""
|
||||
)
|
||||
|
||||
# --- image_generation_configs ---
|
||||
# 1. Add nullable column first
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE image_generation_configs
|
||||
ADD COLUMN IF NOT EXISTS user_id UUID;
|
||||
"""
|
||||
)
|
||||
|
||||
# 2. Backfill from search space owner
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE image_generation_configs igc
|
||||
SET user_id = ss.user_id
|
||||
FROM searchspaces ss
|
||||
WHERE igc.search_space_id = ss.id
|
||||
AND igc.user_id IS NULL;
|
||||
"""
|
||||
)
|
||||
|
||||
# 3. Make NOT NULL
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE image_generation_configs
|
||||
ALTER COLUMN user_id SET NOT NULL;
|
||||
"""
|
||||
)
|
||||
|
||||
# 4. Add FK constraint
|
||||
op.execute(
|
||||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE constraint_name = 'fk_image_generation_configs_user_id'
|
||||
AND table_name = 'image_generation_configs'
|
||||
) THEN
|
||||
ALTER TABLE image_generation_configs
|
||||
ADD CONSTRAINT fk_image_generation_configs_user_id
|
||||
FOREIGN KEY (user_id) REFERENCES "user"(id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END$$;
|
||||
"""
|
||||
)
|
||||
|
||||
# 5. Add index for user_id lookups
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS ix_image_generation_configs_user_id
|
||||
ON image_generation_configs (user_id);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove user_id from new_llm_configs and image_generation_configs."""
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE new_llm_configs DROP COLUMN IF EXISTS user_id;
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE image_generation_configs DROP COLUMN IF EXISTS user_id;
|
||||
"""
|
||||
)
|
||||
|
|
@ -109,7 +109,7 @@ class AgentConfig:
|
|||
use_default_system_instructions=True,
|
||||
citations_enabled=True,
|
||||
config_id=AUTO_MODE_ID,
|
||||
config_name="Auto (Load Balanced)",
|
||||
config_name="Auto (Fastest)",
|
||||
is_auto_mode=True,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,17 @@
|
|||
import logging
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from contextlib import asynccontextmanager
|
||||
from threading import Lock
|
||||
|
||||
import redis
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request, status
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from slowapi import Limiter
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from slowapi.middleware import SlowAPIASGIMiddleware
|
||||
from slowapi.util import get_remote_address
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
|
||||
|
||||
|
|
@ -17,6 +27,147 @@ from app.schemas import UserCreate, UserRead, UserUpdate
|
|||
from app.tasks.surfsense_docs_indexer import seed_surfsense_docs
|
||||
from app.users import SECRET, auth_backend, current_active_user, fastapi_users
|
||||
|
||||
rate_limit_logger = logging.getLogger("surfsense.rate_limit")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Rate Limiting Configuration (SlowAPI + Redis)
|
||||
# ============================================================================
|
||||
# Uses the same Redis instance as Celery for zero additional infrastructure.
|
||||
# Protects auth endpoints from brute force and user enumeration attacks.
|
||||
|
||||
# SlowAPI limiter — provides default rate limits (60/min) for ALL routes
|
||||
# via the ASGI middleware. This is the general safety net.
|
||||
limiter = Limiter(
|
||||
key_func=get_remote_address,
|
||||
storage_uri=config.REDIS_APP_URL,
|
||||
default_limits=["60/minute"],
|
||||
)
|
||||
|
||||
|
||||
def _rate_limit_exceeded_handler(request: Request, exc: RateLimitExceeded):
|
||||
"""Custom 429 handler that returns JSON matching our frontend error format."""
|
||||
retry_after = exc.detail.split("per")[-1].strip() if exc.detail else "60"
|
||||
return JSONResponse(
|
||||
status_code=429,
|
||||
content={"detail": "RATE_LIMIT_EXCEEDED"},
|
||||
headers={"Retry-After": retry_after},
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Auth-Specific Rate Limits (Redis-backed with in-memory fallback)
|
||||
# ============================================================================
|
||||
# Stricter per-IP limits on auth endpoints to prevent:
|
||||
# - Brute force password attacks
|
||||
# - User enumeration via REGISTER_USER_ALREADY_EXISTS
|
||||
# - Email spam via forgot-password
|
||||
#
|
||||
# Primary: Redis INCR+EXPIRE (shared across all workers).
|
||||
# Fallback: In-memory sliding window (per-worker) when Redis is unavailable.
|
||||
# Same Redis instance as SlowAPI / Celery.
|
||||
_rate_limit_redis: redis.Redis | None = None
|
||||
|
||||
# In-memory fallback rate limiter (per-worker, used only when Redis is down)
|
||||
_memory_rate_limits: dict[str, list[float]] = defaultdict(list)
|
||||
_memory_lock = Lock()
|
||||
|
||||
|
||||
def _get_rate_limit_redis() -> redis.Redis:
|
||||
"""Get or create Redis client for auth rate limiting."""
|
||||
global _rate_limit_redis
|
||||
if _rate_limit_redis is None:
|
||||
_rate_limit_redis = redis.from_url(config.REDIS_APP_URL, decode_responses=True)
|
||||
return _rate_limit_redis
|
||||
|
||||
|
||||
def _check_rate_limit_memory(
|
||||
client_ip: str, max_requests: int, window_seconds: int, scope: str
|
||||
):
|
||||
"""
|
||||
In-memory fallback rate limiter using a sliding window.
|
||||
Used only when Redis is unavailable. Per-worker only (not shared),
|
||||
so effective limit = max_requests x num_workers.
|
||||
"""
|
||||
key = f"{scope}:{client_ip}"
|
||||
now = time.monotonic()
|
||||
|
||||
with _memory_lock:
|
||||
# Evict timestamps outside the current window
|
||||
_memory_rate_limits[key] = [
|
||||
t for t in _memory_rate_limits[key] if now - t < window_seconds
|
||||
]
|
||||
|
||||
if len(_memory_rate_limits[key]) >= max_requests:
|
||||
rate_limit_logger.warning(
|
||||
f"Rate limit exceeded (in-memory fallback) on {scope} for IP {client_ip} "
|
||||
f"({len(_memory_rate_limits[key])}/{max_requests} in {window_seconds}s)"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="RATE_LIMIT_EXCEEDED",
|
||||
)
|
||||
|
||||
_memory_rate_limits[key].append(now)
|
||||
|
||||
|
||||
def _check_rate_limit(
|
||||
request: Request, max_requests: int, window_seconds: int, scope: str
|
||||
):
|
||||
"""
|
||||
Check per-IP rate limit using Redis. Raises 429 if exceeded.
|
||||
Uses atomic INCR + EXPIRE to avoid race conditions.
|
||||
Falls back to in-memory sliding window if Redis is unavailable.
|
||||
"""
|
||||
client_ip = get_remote_address(request)
|
||||
key = f"surfsense:auth_rate_limit:{scope}:{client_ip}"
|
||||
|
||||
try:
|
||||
r = _get_rate_limit_redis()
|
||||
|
||||
# Atomic: increment first, then set TTL if this is a new key
|
||||
pipe = r.pipeline()
|
||||
pipe.incr(key)
|
||||
pipe.expire(key, window_seconds)
|
||||
result = pipe.execute()
|
||||
except (redis.exceptions.RedisError, OSError) as exc:
|
||||
# Redis unavailable — fall back to in-memory rate limiting
|
||||
rate_limit_logger.warning(
|
||||
f"Redis unavailable for rate limiting ({scope}), "
|
||||
f"falling back to in-memory limiter for {client_ip}: {exc}"
|
||||
)
|
||||
_check_rate_limit_memory(client_ip, max_requests, window_seconds, scope)
|
||||
return
|
||||
|
||||
current_count = result[0] # INCR returns the new value
|
||||
|
||||
if current_count > max_requests:
|
||||
rate_limit_logger.warning(
|
||||
f"Rate limit exceeded on {scope} for IP {client_ip} "
|
||||
f"({current_count}/{max_requests} in {window_seconds}s)"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="RATE_LIMIT_EXCEEDED",
|
||||
)
|
||||
|
||||
|
||||
def rate_limit_login(request: Request):
|
||||
"""5 login attempts per minute per IP."""
|
||||
_check_rate_limit(request, max_requests=5, window_seconds=60, scope="login")
|
||||
|
||||
|
||||
def rate_limit_register(request: Request):
|
||||
"""3 registration attempts per minute per IP."""
|
||||
_check_rate_limit(request, max_requests=3, window_seconds=60, scope="register")
|
||||
|
||||
|
||||
def rate_limit_password_reset(request: Request):
|
||||
"""2 password reset attempts per minute per IP."""
|
||||
_check_rate_limit(
|
||||
request, max_requests=2, window_seconds=60, scope="password_reset"
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
|
|
@ -45,6 +196,14 @@ def registration_allowed():
|
|||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
|
||||
# Register rate limiter and custom 429 handler
|
||||
app.state.limiter = limiter
|
||||
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||
|
||||
# Add SlowAPI ASGI middleware for automatic rate limiting
|
||||
# This applies default_limits to all routes and enables per-route overrides
|
||||
app.add_middleware(SlowAPIASGIMiddleware)
|
||||
|
||||
# Add ProxyHeaders middleware FIRST to trust proxy headers (e.g., from Cloudflare)
|
||||
# This ensures FastAPI uses HTTPS in redirects when behind a proxy
|
||||
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="*")
|
||||
|
|
@ -90,18 +249,25 @@ app.add_middleware(
|
|||
)
|
||||
|
||||
app.include_router(
|
||||
fastapi_users.get_auth_router(auth_backend), prefix="/auth/jwt", tags=["auth"]
|
||||
fastapi_users.get_auth_router(auth_backend),
|
||||
prefix="/auth/jwt",
|
||||
tags=["auth"],
|
||||
dependencies=[Depends(rate_limit_login)],
|
||||
)
|
||||
app.include_router(
|
||||
fastapi_users.get_register_router(UserRead, UserCreate),
|
||||
prefix="/auth",
|
||||
tags=["auth"],
|
||||
dependencies=[Depends(registration_allowed)], # blocks registration when disabled
|
||||
dependencies=[
|
||||
Depends(rate_limit_register),
|
||||
Depends(registration_allowed), # blocks registration when disabled
|
||||
],
|
||||
)
|
||||
app.include_router(
|
||||
fastapi_users.get_reset_password_router(),
|
||||
prefix="/auth",
|
||||
tags=["auth"],
|
||||
dependencies=[Depends(rate_limit_password_reset)],
|
||||
)
|
||||
app.include_router(
|
||||
fastapi_users.get_verify_router(UserRead),
|
||||
|
|
|
|||
|
|
@ -86,6 +86,11 @@ celery_app = Celery(
|
|||
],
|
||||
)
|
||||
|
||||
# ── Queue names ──────────────────────────────────────────────
|
||||
# Default queue : fast, user-facing tasks (file upload, podcast, reindex, …)
|
||||
# Connectors queue: slow, long-running indexing tasks (Notion, Gmail, web crawl, …)
|
||||
CONNECTORS_QUEUE = f"{CELERY_TASK_DEFAULT_QUEUE}.connectors"
|
||||
|
||||
# Celery configuration
|
||||
celery_app.conf.update(
|
||||
# Task settings
|
||||
|
|
@ -114,6 +119,34 @@ celery_app.conf.update(
|
|||
broker_connection_retry_on_startup=True,
|
||||
# Beat scheduler settings
|
||||
beat_max_loop_interval=60, # Check every minute
|
||||
# ── Task routing ─────────────────────────────────────────
|
||||
# Route slow connector/indexing tasks to a dedicated queue so they
|
||||
# never block fast user-facing tasks (file uploads, podcasts, etc.)
|
||||
task_routes={
|
||||
# Connector indexing tasks → connectors queue
|
||||
"index_slack_messages": {"queue": CONNECTORS_QUEUE},
|
||||
"index_notion_pages": {"queue": CONNECTORS_QUEUE},
|
||||
"index_github_repos": {"queue": CONNECTORS_QUEUE},
|
||||
"index_linear_issues": {"queue": CONNECTORS_QUEUE},
|
||||
"index_jira_issues": {"queue": CONNECTORS_QUEUE},
|
||||
"index_confluence_pages": {"queue": CONNECTORS_QUEUE},
|
||||
"index_clickup_tasks": {"queue": CONNECTORS_QUEUE},
|
||||
"index_google_calendar_events": {"queue": CONNECTORS_QUEUE},
|
||||
"index_airtable_records": {"queue": CONNECTORS_QUEUE},
|
||||
"index_google_gmail_messages": {"queue": CONNECTORS_QUEUE},
|
||||
"index_google_drive_files": {"queue": CONNECTORS_QUEUE},
|
||||
"index_discord_messages": {"queue": CONNECTORS_QUEUE},
|
||||
"index_teams_messages": {"queue": CONNECTORS_QUEUE},
|
||||
"index_luma_events": {"queue": CONNECTORS_QUEUE},
|
||||
"index_elasticsearch_documents": {"queue": CONNECTORS_QUEUE},
|
||||
"index_crawled_urls": {"queue": CONNECTORS_QUEUE},
|
||||
"index_bookstack_pages": {"queue": CONNECTORS_QUEUE},
|
||||
"index_obsidian_vault": {"queue": CONNECTORS_QUEUE},
|
||||
"index_composio_connector": {"queue": CONNECTORS_QUEUE},
|
||||
"delete_connector_with_documents": {"queue": CONNECTORS_QUEUE},
|
||||
# Everything else (document processing, podcasts, reindexing,
|
||||
# schedule checker, cleanup) stays on the default fast queue.
|
||||
},
|
||||
)
|
||||
|
||||
# Configure Celery Beat schedule
|
||||
|
|
|
|||
|
|
@ -273,19 +273,19 @@ INCENTIVE_TASKS_CONFIG = {
|
|||
IncentiveTaskType.GITHUB_STAR: {
|
||||
"title": "Star our GitHub repository",
|
||||
"description": "Show your support by starring SurfSense on GitHub",
|
||||
"pages_reward": 100,
|
||||
"pages_reward": 30,
|
||||
"action_url": "https://github.com/MODSetter/SurfSense",
|
||||
},
|
||||
IncentiveTaskType.REDDIT_FOLLOW: {
|
||||
"title": "Join our Subreddit",
|
||||
"description": "Join the SurfSense community on Reddit",
|
||||
"pages_reward": 100,
|
||||
"pages_reward": 30,
|
||||
"action_url": "https://www.reddit.com/r/SurfSense/",
|
||||
},
|
||||
IncentiveTaskType.DISCORD_JOIN: {
|
||||
"title": "Join our Discord",
|
||||
"description": "Join the SurfSense community on Discord",
|
||||
"pages_reward": 100,
|
||||
"pages_reward": 40,
|
||||
"action_url": "https://discord.gg/ejRNvftDp9",
|
||||
},
|
||||
# Future tasks can be configured here:
|
||||
|
|
@ -1066,6 +1066,12 @@ class ImageGenerationConfig(BaseModel, TimestampMixin):
|
|||
"SearchSpace", back_populates="image_generation_configs"
|
||||
)
|
||||
|
||||
# User who created this config
|
||||
user_id = Column(
|
||||
UUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
user = relationship("User", back_populates="image_generation_configs")
|
||||
|
||||
|
||||
class ImageGeneration(BaseModel, TimestampMixin):
|
||||
"""
|
||||
|
|
@ -1284,6 +1290,7 @@ class SearchSourceConnector(BaseModel, TimestampMixin):
|
|||
user_id = Column(
|
||||
UUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
user = relationship("User", back_populates="search_source_connectors")
|
||||
|
||||
# Documents created by this connector (for cleanup on connector deletion)
|
||||
documents = relationship("Document", back_populates="connector")
|
||||
|
|
@ -1340,6 +1347,12 @@ class NewLLMConfig(BaseModel, TimestampMixin):
|
|||
)
|
||||
search_space = relationship("SearchSpace", back_populates="new_llm_configs")
|
||||
|
||||
# User who created this config
|
||||
user_id = Column(
|
||||
UUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
user = relationship("User", back_populates="new_llm_configs")
|
||||
|
||||
|
||||
class Log(BaseModel, TimestampMixin):
|
||||
__tablename__ = "logs"
|
||||
|
|
@ -1608,6 +1621,27 @@ if config.AUTH_TYPE == "GOOGLE":
|
|||
passive_deletes=True,
|
||||
)
|
||||
|
||||
# Connectors created by this user
|
||||
search_source_connectors = relationship(
|
||||
"SearchSourceConnector",
|
||||
back_populates="user",
|
||||
passive_deletes=True,
|
||||
)
|
||||
|
||||
# LLM configs created by this user
|
||||
new_llm_configs = relationship(
|
||||
"NewLLMConfig",
|
||||
back_populates="user",
|
||||
passive_deletes=True,
|
||||
)
|
||||
|
||||
# Image generation configs created by this user
|
||||
image_generation_configs = relationship(
|
||||
"ImageGenerationConfig",
|
||||
back_populates="user",
|
||||
passive_deletes=True,
|
||||
)
|
||||
|
||||
# User memories for personalized AI responses
|
||||
memories = relationship(
|
||||
"UserMemory",
|
||||
|
|
@ -1687,6 +1721,27 @@ else:
|
|||
passive_deletes=True,
|
||||
)
|
||||
|
||||
# Connectors created by this user
|
||||
search_source_connectors = relationship(
|
||||
"SearchSourceConnector",
|
||||
back_populates="user",
|
||||
passive_deletes=True,
|
||||
)
|
||||
|
||||
# LLM configs created by this user
|
||||
new_llm_configs = relationship(
|
||||
"NewLLMConfig",
|
||||
back_populates="user",
|
||||
passive_deletes=True,
|
||||
)
|
||||
|
||||
# Image generation configs created by this user
|
||||
image_generation_configs = relationship(
|
||||
"ImageGenerationConfig",
|
||||
back_populates="user",
|
||||
passive_deletes=True,
|
||||
)
|
||||
|
||||
# User memories for personalized AI responses
|
||||
memories = relationship(
|
||||
"UserMemory",
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ def _get_global_image_gen_config(config_id: int) -> dict | None:
|
|||
if config_id == IMAGE_GEN_AUTO_MODE_ID:
|
||||
return {
|
||||
"id": IMAGE_GEN_AUTO_MODE_ID,
|
||||
"name": "Auto (Load Balanced)",
|
||||
"name": "Auto (Fastest)",
|
||||
"provider": "AUTO",
|
||||
"model_name": "auto",
|
||||
"is_auto_mode": True,
|
||||
|
|
@ -215,7 +215,7 @@ async def get_global_image_gen_configs(
|
|||
safe_configs.append(
|
||||
{
|
||||
"id": 0,
|
||||
"name": "Auto (Load Balanced)",
|
||||
"name": "Auto (Fastest)",
|
||||
"description": "Automatically routes across available image generation providers.",
|
||||
"provider": "AUTO",
|
||||
"custom_provider": None,
|
||||
|
|
@ -273,7 +273,7 @@ async def create_image_gen_config(
|
|||
"You don't have permission to create image generation configs in this search space",
|
||||
)
|
||||
|
||||
db_config = ImageGenerationConfig(**config_data.model_dump())
|
||||
db_config = ImageGenerationConfig(**config_data.model_dump(), user_id=user.id)
|
||||
session.add(db_config)
|
||||
await session.commit()
|
||||
await session.refresh(db_config)
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ async def get_global_new_llm_configs(
|
|||
safe_configs.append(
|
||||
{
|
||||
"id": 0,
|
||||
"name": "Auto (Load Balanced)",
|
||||
"name": "Auto (Fastest)",
|
||||
"description": "Automatically routes requests across available LLM providers for optimal performance and rate limit handling. Recommended for most users.",
|
||||
"provider": "AUTO",
|
||||
"custom_provider": None,
|
||||
|
|
@ -149,8 +149,8 @@ async def create_new_llm_config(
|
|||
detail=f"Invalid LLM configuration: {error_message}",
|
||||
)
|
||||
|
||||
# Create the config
|
||||
db_config = NewLLMConfig(**config_data.model_dump())
|
||||
# Create the config with user association
|
||||
db_config = NewLLMConfig(**config_data.model_dump(), user_id=user.id)
|
||||
session.add(db_config)
|
||||
await session.commit()
|
||||
await session.refresh(db_config)
|
||||
|
|
|
|||
|
|
@ -324,7 +324,7 @@ async def _get_llm_config_by_id(
|
|||
if config_id == 0:
|
||||
return {
|
||||
"id": 0,
|
||||
"name": "Auto (Load Balanced)",
|
||||
"name": "Auto (Fastest)",
|
||||
"description": "Automatically routes requests across available LLM providers for optimal performance and rate limit handling",
|
||||
"provider": "AUTO",
|
||||
"custom_provider": None,
|
||||
|
|
@ -402,7 +402,7 @@ async def _get_image_gen_config_by_id(
|
|||
if config_id == 0:
|
||||
return {
|
||||
"id": 0,
|
||||
"name": "Auto (Load Balanced)",
|
||||
"name": "Auto (Fastest)",
|
||||
"description": "Automatically routes requests across available image generation providers",
|
||||
"provider": "AUTO",
|
||||
"model_name": "auto",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ ImageGeneration: Schemas for the actual image generation requests/results.
|
|||
GlobalImageGenConfigRead: Schema for admin-configured YAML configs.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -79,6 +80,7 @@ class ImageGenerationConfigRead(ImageGenerationConfigBase):
|
|||
id: int
|
||||
created_at: datetime
|
||||
search_space_id: int
|
||||
user_id: uuid.UUID
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
|
@ -97,6 +99,7 @@ class ImageGenerationConfigPublic(BaseModel):
|
|||
litellm_params: dict[str, Any] | None = None
|
||||
created_at: datetime
|
||||
search_space_id: int
|
||||
user_id: uuid.UUID
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
|
|
|||
|
|
@ -233,6 +233,7 @@ class PublicChatSnapshotDetail(BaseModel):
|
|||
message_count: int
|
||||
thread_id: int
|
||||
thread_title: str
|
||||
created_by_user_id: str | None = None
|
||||
|
||||
|
||||
class PublicChatSnapshotsBySpaceResponse(BaseModel):
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ NewLLMConfig combines LLM model settings with prompt configuration:
|
|||
- Citation toggle
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -90,6 +91,7 @@ class NewLLMConfigRead(NewLLMConfigBase):
|
|||
id: int
|
||||
created_at: datetime
|
||||
search_space_id: int
|
||||
user_id: uuid.UUID
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
|
@ -118,6 +120,7 @@ class NewLLMConfigPublic(BaseModel):
|
|||
|
||||
created_at: datetime
|
||||
search_space_id: int
|
||||
user_id: uuid.UUID
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ def get_global_llm_config(llm_config_id: int) -> dict | None:
|
|||
if llm_config_id == AUTO_MODE_ID:
|
||||
return {
|
||||
"id": AUTO_MODE_ID,
|
||||
"name": "Auto (Load Balanced)",
|
||||
"name": "Auto (Fastest)",
|
||||
"description": "Automatically routes requests across available LLM providers for optimal performance and rate limit handling",
|
||||
"provider": "AUTO",
|
||||
"model_name": "auto",
|
||||
|
|
|
|||
|
|
@ -439,6 +439,9 @@ async def list_snapshots_for_search_space(
|
|||
"message_count": len(s.message_ids) if s.message_ids else 0,
|
||||
"thread_id": s.thread_id,
|
||||
"thread_title": thread_titles.get(s.thread_id, "Untitled"),
|
||||
"created_by_user_id": str(s.created_by_user_id)
|
||||
if s.created_by_user_id
|
||||
else None,
|
||||
}
|
||||
for s in snapshots
|
||||
]
|
||||
|
|
|
|||
|
|
@ -47,6 +47,14 @@ if config.AUTH_TYPE == "GOOGLE":
|
|||
|
||||
|
||||
class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
|
||||
"""
|
||||
Custom user manager extending fastapi-users BaseUserManager.
|
||||
|
||||
Authentication returns a generic error for both non-existent accounts
|
||||
and incorrect passwords to comply with OWASP WSTG-IDNT-04 and
|
||||
prevent user enumeration attacks.
|
||||
"""
|
||||
|
||||
reset_password_token_secret = SECRET
|
||||
verification_token_secret = SECRET
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "surf-new-backend"
|
||||
version = "0.0.12"
|
||||
version = "0.0.13"
|
||||
description = "SurfSense Backend"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
|
|
@ -62,6 +62,7 @@ dependencies = [
|
|||
"unstructured[all-docs]>=0.18.31",
|
||||
"unstructured-client>=0.42.3",
|
||||
"langchain-unstructured>=1.0.1",
|
||||
"slowapi>=0.1.9",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
|
|
|||
|
|
@ -1,58 +1,129 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Function to handle shutdown gracefully
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# SERVICE_ROLE controls which process(es) this container runs.
|
||||
#
|
||||
# api – FastAPI backend only (runs migrations on startup)
|
||||
# worker – Celery worker only
|
||||
# beat – Celery beat scheduler only
|
||||
# all – All three in one container (legacy / dev default)
|
||||
#
|
||||
# Set SERVICE_ROLE as an environment variable in Coolify for
|
||||
# each service deployment.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
SERVICE_ROLE="${SERVICE_ROLE:-all}"
|
||||
echo "Starting SurfSense with SERVICE_ROLE=${SERVICE_ROLE}"
|
||||
|
||||
# ── Autoscale defaults (override via env) ────────────────────
|
||||
# CELERY_MAX_WORKERS – max concurrent worker processes
|
||||
# CELERY_MIN_WORKERS – min workers kept warm
|
||||
# CELERY_QUEUES – comma-separated queues to consume
|
||||
# (empty = all queues for backward compat)
|
||||
CELERY_MAX_WORKERS="${CELERY_MAX_WORKERS:-10}"
|
||||
CELERY_MIN_WORKERS="${CELERY_MIN_WORKERS:-2}"
|
||||
CELERY_MAX_TASKS_PER_CHILD="${CELERY_MAX_TASKS_PER_CHILD:-50}"
|
||||
CELERY_QUEUES="${CELERY_QUEUES:-}"
|
||||
|
||||
# ── Graceful shutdown ────────────────────────────────────────
|
||||
PIDS=()
|
||||
|
||||
cleanup() {
|
||||
echo "Shutting down services..."
|
||||
kill -TERM "$backend_pid" "$celery_worker_pid" "$celery_beat_pid" 2>/dev/null || true
|
||||
wait "$backend_pid" "$celery_worker_pid" "$celery_beat_pid" 2>/dev/null || true
|
||||
for pid in "${PIDS[@]}"; do
|
||||
kill -TERM "$pid" 2>/dev/null || true
|
||||
done
|
||||
for pid in "${PIDS[@]}"; do
|
||||
wait "$pid" 2>/dev/null || true
|
||||
done
|
||||
exit 0
|
||||
}
|
||||
|
||||
trap cleanup SIGTERM SIGINT
|
||||
|
||||
# Run database migrations with safeguards
|
||||
echo "Running database migrations..."
|
||||
# Wait for database to be ready (max 30 seconds)
|
||||
for i in {1..30}; do
|
||||
if python -c "from app.db import engine; import asyncio; asyncio.run(engine.dispose())" 2>/dev/null; then
|
||||
echo "Database is ready."
|
||||
break
|
||||
# ── Database migrations (only for api / all) ─────────────────
|
||||
run_migrations() {
|
||||
echo "Running database migrations..."
|
||||
for i in {1..30}; do
|
||||
if python -c "from app.db import engine; import asyncio; asyncio.run(engine.dispose())" 2>/dev/null; then
|
||||
echo "Database is ready."
|
||||
break
|
||||
fi
|
||||
echo "Waiting for database... ($i/30)"
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if timeout 60 alembic upgrade head 2>&1; then
|
||||
echo "Migrations completed successfully."
|
||||
else
|
||||
echo "WARNING: Migration failed or timed out. Continuing anyway..."
|
||||
echo "You may need to run migrations manually: alembic upgrade head"
|
||||
fi
|
||||
echo "Waiting for database... ($i/30)"
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
|
||||
# Run migrations with timeout (60 seconds max)
|
||||
if timeout 60 alembic upgrade head 2>&1; then
|
||||
echo "Migrations completed successfully."
|
||||
else
|
||||
echo "WARNING: Migration failed or timed out. Continuing anyway..."
|
||||
echo "You may need to run migrations manually: alembic upgrade head"
|
||||
fi
|
||||
# ── Service starters ─────────────────────────────────────────
|
||||
start_api() {
|
||||
echo "Starting FastAPI Backend..."
|
||||
python main.py &
|
||||
PIDS+=($!)
|
||||
echo " FastAPI PID=${PIDS[-1]}"
|
||||
}
|
||||
|
||||
echo "Starting FastAPI Backend..."
|
||||
python main.py &
|
||||
backend_pid=$!
|
||||
start_worker() {
|
||||
QUEUE_ARGS=""
|
||||
if [ -n "${CELERY_QUEUES}" ]; then
|
||||
QUEUE_ARGS="--queues=${CELERY_QUEUES}"
|
||||
fi
|
||||
|
||||
# Wait a bit for backend to initialize
|
||||
sleep 5
|
||||
echo "Starting Celery Worker (autoscale=${CELERY_MAX_WORKERS},${CELERY_MIN_WORKERS}, max-tasks-per-child=${CELERY_MAX_TASKS_PER_CHILD}, queues=${CELERY_QUEUES:-all})..."
|
||||
celery -A app.celery_app worker \
|
||||
--loglevel=info \
|
||||
--autoscale="${CELERY_MAX_WORKERS},${CELERY_MIN_WORKERS}" \
|
||||
--max-tasks-per-child="${CELERY_MAX_TASKS_PER_CHILD}" \
|
||||
--prefetch-multiplier=1 \
|
||||
-Ofair \
|
||||
${QUEUE_ARGS} &
|
||||
PIDS+=($!)
|
||||
echo " Celery Worker PID=${PIDS[-1]}"
|
||||
}
|
||||
|
||||
echo "Starting Celery Worker..."
|
||||
celery -A app.celery_app worker --loglevel=info --autoscale=128,4 &
|
||||
celery_worker_pid=$!
|
||||
start_beat() {
|
||||
echo "Starting Celery Beat..."
|
||||
celery -A app.celery_app beat --loglevel=info &
|
||||
PIDS+=($!)
|
||||
echo " Celery Beat PID=${PIDS[-1]}"
|
||||
}
|
||||
|
||||
# Wait a bit for worker to initialize
|
||||
sleep 3
|
||||
# ── Main: run based on role ──────────────────────────────────
|
||||
case "${SERVICE_ROLE}" in
|
||||
api)
|
||||
run_migrations
|
||||
start_api
|
||||
;;
|
||||
worker)
|
||||
start_worker
|
||||
;;
|
||||
beat)
|
||||
start_beat
|
||||
;;
|
||||
all)
|
||||
run_migrations
|
||||
start_api
|
||||
sleep 5
|
||||
start_worker
|
||||
sleep 3
|
||||
start_beat
|
||||
;;
|
||||
*)
|
||||
echo "ERROR: Unknown SERVICE_ROLE '${SERVICE_ROLE}'. Use: api, worker, beat, or all"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "Starting Celery Beat..."
|
||||
celery -A app.celery_app beat --loglevel=info &
|
||||
celery_beat_pid=$!
|
||||
|
||||
echo "All services started. PIDs: Backend=$backend_pid, Worker=$celery_worker_pid, Beat=$celery_beat_pid"
|
||||
echo "All requested services started. PIDs: ${PIDS[*]}"
|
||||
|
||||
# Wait for any process to exit
|
||||
wait -n
|
||||
|
||||
# If we get here, one process exited, so exit with its status
|
||||
# If we get here, one process exited unexpectedly
|
||||
exit $?
|
||||
|
|
|
|||
6443
surfsense_backend/uv.lock
generated
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "surfsense_browser_extension",
|
||||
"displayName": "Surfsense Browser Extension",
|
||||
"version": "0.0.12",
|
||||
"version": "0.0.13",
|
||||
"description": "Extension to collect Browsing History for SurfSense.",
|
||||
"author": "https://github.com/MODSetter",
|
||||
"engines": {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { useEffect, useState } from "react";
|
|||
import { toast } from "sonner";
|
||||
import { loginMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
|
||||
import { getAuthErrorDetails, isNetworkError } from "@/lib/auth-errors";
|
||||
import { AUTH_TYPE } from "@/lib/env-config";
|
||||
import { ValidationError } from "@/lib/error";
|
||||
import { trackLoginAttempt, trackLoginFailure, trackLoginSuccess } from "@/lib/posthog/events";
|
||||
|
|
@ -65,10 +65,6 @@ export function LocalLoginForm() {
|
|||
if (err instanceof ValidationError) {
|
||||
trackLoginFailure("local", err.message);
|
||||
setError({ title: err.name, message: err.message });
|
||||
toast.error(err.name, {
|
||||
description: err.message,
|
||||
duration: 6000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -92,22 +88,6 @@ export function LocalLoginForm() {
|
|||
title: errorDetails.title,
|
||||
message: errorDetails.description,
|
||||
});
|
||||
|
||||
// Show error toast with conditional retry action
|
||||
const toastOptions: any = {
|
||||
description: errorDetails.description,
|
||||
duration: 6000,
|
||||
};
|
||||
|
||||
// Add retry action if the error is retryable
|
||||
if (shouldRetry(errorCode)) {
|
||||
toastOptions.action = {
|
||||
label: "Retry",
|
||||
onClick: () => handleSubmit(e),
|
||||
};
|
||||
}
|
||||
|
||||
toast.error(errorDetails.title, toastOptions);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -187,5 +187,24 @@ button {
|
|||
background-color: hsl(var(--muted-foreground) / 0.4);
|
||||
}
|
||||
|
||||
/* Integrations section — vertical column auto-scroll */
|
||||
@keyframes integrations-scroll-up {
|
||||
0% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes integrations-scroll-down {
|
||||
0% {
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@source '../node_modules/@llamaindex/chat-ui/**/*.{ts,tsx}';
|
||||
@source '../node_modules/streamdown/dist/*.js';
|
||||
|
|
|
|||
72
surfsense_web/changelog/content/2026-02-09.mdx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
---
|
||||
title: "SurfSense v0.0.13 - Public Sharing, Image Generation & Redesigned Documents"
|
||||
description: "SurfSense v0.0.13 introduces public chat sharing with permissions, image generation support, an auto load-balanced model mode, a redesigned Documents page, and numerous bug fixes across connectors and UI."
|
||||
date: "2026-02-09"
|
||||
tags: ["Public Sharing", "Image Generation", "Documents", "UI", "Bug Fixes"]
|
||||
version: "0.0.13"
|
||||
---
|
||||
|
||||
## What's New in v0.0.13
|
||||
|
||||
This update brings **public sharing, image generation**, a redesigned Documents page, and numerous bug fixes.
|
||||
|
||||
### Features & Improvements
|
||||
|
||||
#### Image Generation
|
||||
|
||||
- **Image Generation**: Generate images directly in chat with custom model and provider configurations.
|
||||
|
||||
#### Public Sharing
|
||||
|
||||
- **Public Chat Links**: Share snapshots of chats via public links.
|
||||
- **Sharing Permissions**: Search Space owners control who can create and manage public links.
|
||||
- **Link Management Page**: View and revoke all public chat links from Search Space Settings.
|
||||
|
||||
#### Auto (Load Balanced) Mode
|
||||
|
||||
- **Auto Model Selection**: The default cloud model now automatically picks the lowest-load model for faster responses.
|
||||
|
||||
#### Redesigned Documents Page
|
||||
|
||||
- **Unified Page**: Merged Logs and Documents into one page with real-time statuses.
|
||||
- **Inline Connector Management**: Configure and monitor connectors alongside your documents.
|
||||
|
||||
#### UI & UX Polish
|
||||
|
||||
- **Inbox Refresh**: Cleaner inbox layout for easier scanning.
|
||||
- **Streamlined Login**: Consolidated loading screens into a single unified flow.
|
||||
- **Chat UI Tweaks**: Small refinements for a cleaner, more consistent chat experience.
|
||||
- **Prompt Suggestions**: Empty chat windows now show contextual suggestions.
|
||||
|
||||
#### Documentation
|
||||
|
||||
- **New Connector Docs**: Added docs for Luma, Circleback, Elasticsearch, Bookstack, and Obsidian connectors.
|
||||
|
||||
<Accordion type="multiple" className="w-full not-prose">
|
||||
<AccordionItem value="item-1">
|
||||
<AccordionTrigger>Bug Fixes</AccordionTrigger>
|
||||
<AccordionContent className="flex flex-col gap-4 text-balance">
|
||||
<ul className="list-disc space-y-2 pl-4">
|
||||
<li>Fixed cloud scaling issues where document queue congestion occurred under high load</li>
|
||||
<li>Documents now correctly attribute to the uploading user and de-index on disconnect or deletion</li>
|
||||
<li>Fixed common backend errors in indexing and large file handling</li>
|
||||
<li>Fixed Notion indexing failures caused by transcription blocks</li>
|
||||
<li>Chat refresh button now correctly regenerates AI responses</li>
|
||||
<li>Restored the previously disabled Role Editor</li>
|
||||
<li>Fixed Mentions tab appearing empty when document notifications pushed mentions out of the pagination window</li>
|
||||
<li>Bundled git in the Docker image to fix GitHub connector failures with gitingest</li>
|
||||
<li>Fixed Google Calendar default date range errors and aligned backend defaults with the frontend</li>
|
||||
</ul>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item-2">
|
||||
<AccordionTrigger>Technical Improvements</AccordionTrigger>
|
||||
<AccordionContent className="flex flex-col gap-4 text-balance">
|
||||
<ul className="list-disc space-y-2 pl-4">
|
||||
<li>Rebuilt the GitHub connector on gitingest for more efficient, lower-cost repository fetching</li>
|
||||
</ul>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
SurfSense is your AI-powered federated search solution, connecting all your knowledge sources in one place.
|
||||
|
|
@ -43,7 +43,7 @@ export function FooterNew() {
|
|||
},
|
||||
{
|
||||
title: "LinkedIn",
|
||||
href: "https://www.linkedin.com/in/rohan-verma-sde/",
|
||||
href: "https://www.linkedin.com/company/surfsense/",
|
||||
icon: IconBrandLinkedin,
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -85,21 +85,19 @@ export function HeroSection() {
|
|||
/>
|
||||
|
||||
<h2 className="relative z-50 mx-auto mb-4 mt-4 max-w-4xl text-balance text-center text-3xl font-semibold tracking-tight text-gray-700 md:text-7xl dark:text-neutral-300">
|
||||
<Balancer>
|
||||
{isNotebookLMVariant ? (
|
||||
<div className="relative mx-auto inline-block w-max filter-[drop-shadow(0px_1px_3px_rgba(27,37,80,0.14))]">
|
||||
<div className="text-black [text-shadow:0_0_rgba(0,0,0,0.1)] dark:text-white">
|
||||
<span className="">NotebookLM with Superpowers</span>
|
||||
</div>
|
||||
{isNotebookLMVariant ? (
|
||||
<div className="relative mx-auto inline-block w-max filter-[drop-shadow(0px_1px_3px_rgba(27,37,80,0.14))]">
|
||||
<div className="text-black [text-shadow:0_0_rgba(0,0,0,0.1)] dark:text-white">
|
||||
<Balancer>NotebookLM with Superpowers</Balancer>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative mx-auto inline-block w-max filter-[drop-shadow(0px_1px_3px_rgba(27,37,80,0.14))]">
|
||||
<div className="text-black [text-shadow:0_0_rgba(0,0,0,0.1)] dark:text-white">
|
||||
<span className="">NotebookLM for Teams</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative mx-auto inline-block w-max filter-[drop-shadow(0px_1px_3px_rgba(27,37,80,0.14))]">
|
||||
<div className="text-black [text-shadow:0_0_rgba(0,0,0,0.1)] dark:text-white">
|
||||
<Balancer>NotebookLM for Teams</Balancer>
|
||||
</div>
|
||||
)}
|
||||
</Balancer>
|
||||
</div>
|
||||
)}
|
||||
</h2>
|
||||
{/* // TODO:aCTUAL DESCRITION */}
|
||||
<p className="relative z-50 mx-auto mt-4 max-w-lg px-4 text-center text-base/6 text-gray-600 dark:text-gray-200">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
"use client";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import Image from "next/image";
|
||||
import type React from "react";
|
||||
|
||||
interface Integration {
|
||||
name: string;
|
||||
|
|
@ -8,181 +10,210 @@ interface Integration {
|
|||
|
||||
const INTEGRATIONS: Integration[] = [
|
||||
// Search
|
||||
{ name: "Tavily", icon: "https://www.tavily.com/images/logo.svg" },
|
||||
{
|
||||
name: "LinkUp",
|
||||
icon: "https://framerusercontent.com/images/7zeIm6t3f1HaSltkw8upEvsD80.png?scale-down-to=512",
|
||||
},
|
||||
{ name: "Elasticsearch", icon: "https://cdn.simpleicons.org/elastic/00A9E5" },
|
||||
{ name: "Tavily", icon: "/connectors/tavily.svg" },
|
||||
{ name: "Elasticsearch", icon: "/connectors/elasticsearch.svg" },
|
||||
{ name: "Baidu Search", icon: "/connectors/baidu-search.svg" },
|
||||
{ name: "SearXNG", icon: "/connectors/searxng.svg" },
|
||||
|
||||
// Communication
|
||||
{
|
||||
name: "Slack",
|
||||
icon: "https://upload.wikimedia.org/wikipedia/commons/d/d5/Slack_icon_2019.svg",
|
||||
},
|
||||
{ name: "Discord", icon: "https://cdn.simpleicons.org/discord/5865F2" },
|
||||
{ name: "Gmail", icon: "https://cdn.simpleicons.org/gmail/EA4335" },
|
||||
{ name: "Slack", icon: "/connectors/slack.svg" },
|
||||
{ name: "Discord", icon: "/connectors/discord.svg" },
|
||||
{ name: "Gmail", icon: "/connectors/google-gmail.svg" },
|
||||
{ name: "Microsoft Teams", icon: "/connectors/microsoft-teams.svg" },
|
||||
|
||||
// Project Management
|
||||
{ name: "Linear", icon: "https://cdn.simpleicons.org/linear/5E6AD2" },
|
||||
{ name: "Jira", icon: "https://cdn.simpleicons.org/jira/0052CC" },
|
||||
{ name: "ClickUp", icon: "https://cdn.simpleicons.org/clickup/7B68EE" },
|
||||
{ name: "Airtable", icon: "https://cdn.simpleicons.org/airtable/18BFFF" },
|
||||
{ name: "Linear", icon: "/connectors/linear.svg" },
|
||||
{ name: "Jira", icon: "/connectors/jira.svg" },
|
||||
{ name: "ClickUp", icon: "/connectors/clickup.svg" },
|
||||
{ name: "Airtable", icon: "/connectors/airtable.svg" },
|
||||
|
||||
// Documentation & Knowledge
|
||||
{ name: "Confluence", icon: "https://cdn.simpleicons.org/confluence/172B4D" },
|
||||
{ name: "Notion", icon: "https://cdn.simpleicons.org/notion/000000/ffffff" },
|
||||
{ name: "Web Pages", icon: "https://cdn.jsdelivr.net/npm/lucide-static@0.294.0/icons/globe.svg" },
|
||||
{ name: "Confluence", icon: "/connectors/confluence.svg" },
|
||||
{ name: "Notion", icon: "/connectors/notion.svg" },
|
||||
{ name: "BookStack", icon: "/connectors/bookstack.svg" },
|
||||
{ name: "Obsidian", icon: "/connectors/obsidian.svg" },
|
||||
|
||||
// Cloud Storage
|
||||
{ name: "Google Drive", icon: "https://cdn.simpleicons.org/googledrive/4285F4" },
|
||||
{ name: "Dropbox", icon: "https://cdn.simpleicons.org/dropbox/0061FF" },
|
||||
{
|
||||
name: "Amazon S3",
|
||||
icon: "https://upload.wikimedia.org/wikipedia/commons/b/bc/Amazon-S3-Logo.svg",
|
||||
},
|
||||
{ name: "Google Drive", icon: "/connectors/google-drive.svg" },
|
||||
|
||||
// Development
|
||||
{ name: "GitHub", icon: "https://cdn.simpleicons.org/github/181717/ffffff" },
|
||||
{ name: "GitHub", icon: "/connectors/github.svg" },
|
||||
|
||||
// Productivity
|
||||
{ name: "Google Calendar", icon: "https://cdn.simpleicons.org/googlecalendar/4285F4" },
|
||||
{ name: "Luma", icon: "https://images.lumacdn.com/social-images/default-social-202407.png" },
|
||||
{ name: "Google Calendar", icon: "/connectors/google-calendar.svg" },
|
||||
{ name: "Luma", icon: "/connectors/luma.svg" },
|
||||
|
||||
// Media
|
||||
{ name: "YouTube", icon: "https://cdn.simpleicons.org/youtube/FF0000" },
|
||||
{ name: "YouTube", icon: "/connectors/youtube.svg" },
|
||||
|
||||
// Search
|
||||
{ name: "Linkup", icon: "/connectors/linkup.svg" },
|
||||
|
||||
// Meetings
|
||||
{ name: "Circleback", icon: "/connectors/circleback.svg" },
|
||||
|
||||
// AI
|
||||
{ name: "MCP", icon: "/connectors/modelcontextprotocol.svg" },
|
||||
];
|
||||
|
||||
function SemiCircleOrbit({ radius, centerX, centerY, count, iconSize, startIndex }: any) {
|
||||
// 5 vertical columns — 23 icons spread across categories
|
||||
const COLUMNS: number[][] = [
|
||||
[2, 5, 10, 0, 21, 11],
|
||||
[1, 7, 20, 17],
|
||||
[13, 6, 23, 4, 16],
|
||||
[12, 8, 15, 18],
|
||||
[3, 9, 14, 22, 19],
|
||||
];
|
||||
|
||||
// Different scroll speeds per column for organic feel (seconds)
|
||||
const SCROLL_DURATIONS = [26, 32, 22, 30, 28];
|
||||
|
||||
function IntegrationCard({ integration }: { integration: Integration }) {
|
||||
return (
|
||||
<>
|
||||
{/* Semi-circle glow background */}
|
||||
<div className="absolute inset-0 flex justify-center items-start overflow-visible">
|
||||
<div
|
||||
className="
|
||||
w-[800px] h-[800px] rounded-full
|
||||
bg-[radial-gradient(circle_at_center,rgba(0,0,0,0.15),transparent_70%)]
|
||||
dark:bg-[radial-gradient(circle_at_center,rgba(255,255,255,0.15),transparent_70%)]
|
||||
blur-3xl
|
||||
pointer-events-none
|
||||
"
|
||||
style={{
|
||||
zIndex: 0,
|
||||
transform: "translateY(-20%)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="w-[60px] h-[60px] sm:w-[80px] sm:h-[80px] md:w-[120px] md:h-[120px] lg:w-[140px] lg:h-[140px] rounded-[16px] sm:rounded-[20px] md:rounded-[24px] flex items-center justify-center shrink-0 select-none"
|
||||
style={{
|
||||
background: "linear-gradient(145deg, var(--card-from), var(--card-to))",
|
||||
boxShadow: "inset 0 1px 0 0 var(--card-highlight), 0 4px 24px var(--card-shadow)",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={integration.icon}
|
||||
alt={integration.name}
|
||||
className="w-6 h-6 sm:w-7 sm:h-7 md:w-10 md:h-10 lg:w-12 lg:h-12 object-contain select-none pointer-events-none"
|
||||
loading="lazy"
|
||||
draggable={false}
|
||||
width={48}
|
||||
height={48}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScrollingColumn({
|
||||
cards,
|
||||
scrollUp,
|
||||
duration,
|
||||
colIndex,
|
||||
isEdge,
|
||||
isEdgeAdjacent,
|
||||
}: {
|
||||
cards: number[];
|
||||
scrollUp: boolean;
|
||||
duration: number;
|
||||
colIndex: number;
|
||||
isEdge: boolean;
|
||||
isEdgeAdjacent: boolean;
|
||||
}) {
|
||||
// Edge columns get a heavy vertical mask; edge-adjacent columns get a lighter one to smooth the transition
|
||||
const columnMask = isEdge
|
||||
? {
|
||||
maskImage:
|
||||
"linear-gradient(to bottom, transparent 0%, transparent 20%, black 40%, black 60%, transparent 80%, transparent 100%)",
|
||||
WebkitMaskImage:
|
||||
"linear-gradient(to bottom, transparent 0%, transparent 20%, black 40%, black 60%, transparent 80%, transparent 100%)",
|
||||
}
|
||||
: isEdgeAdjacent
|
||||
? {
|
||||
maskImage:
|
||||
"linear-gradient(to bottom, transparent 0%, transparent 10%, black 30%, black 70%, transparent 90%, transparent 100%)",
|
||||
WebkitMaskImage:
|
||||
"linear-gradient(to bottom, transparent 0%, transparent 10%, black 30%, black 70%, transparent 90%, transparent 100%)",
|
||||
}
|
||||
: {};
|
||||
|
||||
const cardSet = cards.map((integrationIndex, i) => (
|
||||
<IntegrationCard
|
||||
key={`${INTEGRATIONS[integrationIndex].name}-c${colIndex}-${i}`}
|
||||
integration={INTEGRATIONS[integrationIndex]}
|
||||
/>
|
||||
));
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-shrink-0 overflow-hidden"
|
||||
style={{ ...columnMask, contain: "layout style paint" }}
|
||||
>
|
||||
{/* Outer div has NO gap — each inner copy uses pb matching the gap so both halves are identical in height → seamless -50% loop */}
|
||||
<div
|
||||
className="flex flex-col"
|
||||
style={{
|
||||
animation: `${scrollUp ? "integrations-scroll-up" : "integrations-scroll-down"} ${duration}s linear infinite`,
|
||||
willChange: "transform",
|
||||
transform: "translateZ(0)",
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-2 sm:gap-3 md:gap-5 lg:gap-6 pb-2 sm:pb-3 md:pb-5 lg:pb-6">
|
||||
{cardSet}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:gap-3 md:gap-5 lg:gap-6 pb-2 sm:pb-3 md:pb-5 lg:pb-6">
|
||||
{cardSet}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Orbit icons */}
|
||||
{Array.from({ length: count }).map((_, index) => {
|
||||
const actualIndex = startIndex + index;
|
||||
// Skip if we've run out of integrations
|
||||
if (actualIndex >= INTEGRATIONS.length) return null;
|
||||
|
||||
const angle = (index / (count - 1)) * 180;
|
||||
const x = radius * Math.cos((angle * Math.PI) / 180);
|
||||
const y = radius * Math.sin((angle * Math.PI) / 180);
|
||||
const integration = INTEGRATIONS[actualIndex];
|
||||
|
||||
// Tooltip positioning — above or below based on angle
|
||||
const tooltipAbove = angle > 90;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="absolute flex flex-col items-center group"
|
||||
style={{
|
||||
left: `${centerX + x - iconSize / 2}px`,
|
||||
top: `${centerY - y - iconSize / 2}px`,
|
||||
zIndex: 5,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={integration.icon}
|
||||
alt={integration.name}
|
||||
width={iconSize}
|
||||
height={iconSize}
|
||||
className="object-contain cursor-pointer transition-transform hover:scale-110"
|
||||
style={{ minWidth: iconSize, minHeight: iconSize }} // fix accidental shrink
|
||||
/>
|
||||
|
||||
{/* Tooltip */}
|
||||
<div
|
||||
className={`absolute ${
|
||||
tooltipAbove ? "bottom-[calc(100%+8px)]" : "top-[calc(100%+8px)]"
|
||||
} hidden group-hover:block w-auto min-w-max rounded-lg bg-black px-3 py-1.5 text-xs text-white shadow-lg text-center whitespace-nowrap`}
|
||||
>
|
||||
{integration.name}
|
||||
<div
|
||||
className={`absolute left-1/2 -translate-x-1/2 w-3 h-3 rotate-45 bg-black ${
|
||||
tooltipAbove ? "top-full" : "bottom-full"
|
||||
}`}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ExternalIntegrations() {
|
||||
const [size, setSize] = useState({ width: 0, height: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
const updateSize = () => setSize({ width: window.innerWidth, height: window.innerHeight });
|
||||
updateSize();
|
||||
window.addEventListener("resize", updateSize);
|
||||
return () => window.removeEventListener("resize", updateSize);
|
||||
}, []);
|
||||
|
||||
const baseWidth = Math.min(size.width * 0.8, 700);
|
||||
const centerX = baseWidth / 2;
|
||||
const centerY = baseWidth * 0.5;
|
||||
|
||||
const iconSize =
|
||||
size.width < 480
|
||||
? Math.max(24, baseWidth * 0.05)
|
||||
: size.width < 768
|
||||
? Math.max(28, baseWidth * 0.06)
|
||||
: Math.max(32, baseWidth * 0.07);
|
||||
|
||||
return (
|
||||
<section className="py-12 relative min-h-screen w-full overflow-visible">
|
||||
<div className="relative flex flex-col items-center text-center z-10">
|
||||
<h1 className="my-6 text-4xl font-bold lg:text-7xl">Integrations</h1>
|
||||
<p className="mb-12 max-w-2xl text-gray-600 dark:text-gray-400 lg:text-xl">
|
||||
Integrate with your team's most important tools
|
||||
</p>
|
||||
<section
|
||||
className={[
|
||||
"relative py-20 md:py-28 overflow-hidden",
|
||||
// No explicit background — inherits the page gradient for seamless blending
|
||||
// CSS custom properties — light mode (card styling)
|
||||
"[--card-from:rgba(255,255,255,0.9)]",
|
||||
"[--card-to:rgba(245,245,248,0.92)]",
|
||||
"[--card-highlight:rgba(255,255,255,0.5)]",
|
||||
"[--card-lowlight:transparent]",
|
||||
"[--card-shadow:transparent]",
|
||||
"[--card-border:transparent]",
|
||||
// CSS custom properties — dark mode (card styling)
|
||||
"dark:[--card-from:rgb(28,28,32)]",
|
||||
"dark:[--card-to:rgb(28,28,32)]",
|
||||
"dark:[--card-highlight:rgba(255,255,255,0.03)]",
|
||||
"dark:[--card-lowlight:rgba(0,0,0,0.1)]",
|
||||
"dark:[--card-shadow:rgba(0,0,0,0.15)]",
|
||||
"dark:[--card-border:rgba(255,255,255,0.03)]",
|
||||
].join(" ")}
|
||||
>
|
||||
{/* Heading */}
|
||||
<div className="text-center mb-12 md:mb-16 relative z-20 px-4">
|
||||
<h3 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold text-gray-900 dark:text-white leading-[1.1] tracking-tight">
|
||||
Integrate with your
|
||||
<br />
|
||||
team's most important tools
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative overflow-visible"
|
||||
style={{ width: baseWidth, height: baseWidth * 0.7, paddingBottom: "100px" }}
|
||||
>
|
||||
<SemiCircleOrbit
|
||||
radius={baseWidth * 0.22}
|
||||
centerX={centerX}
|
||||
centerY={centerY}
|
||||
count={5}
|
||||
iconSize={iconSize}
|
||||
startIndex={0}
|
||||
/>
|
||||
<SemiCircleOrbit
|
||||
radius={baseWidth * 0.36}
|
||||
centerX={centerX}
|
||||
centerY={centerY}
|
||||
count={6}
|
||||
iconSize={iconSize}
|
||||
startIndex={5}
|
||||
/>
|
||||
<SemiCircleOrbit
|
||||
radius={baseWidth * 0.5}
|
||||
centerX={centerX}
|
||||
centerY={centerY}
|
||||
count={8}
|
||||
iconSize={iconSize}
|
||||
startIndex={11}
|
||||
/>
|
||||
{/* Scrolling columns container — masked at edges so the page background shows through seamlessly */}
|
||||
<div
|
||||
className="relative"
|
||||
style={
|
||||
{
|
||||
maskImage:
|
||||
"linear-gradient(to bottom, transparent 0%, black 25%, black 70%, transparent 100%), " +
|
||||
"linear-gradient(to right, transparent 0%, black 12%, black 88%, transparent 100%)",
|
||||
WebkitMaskImage:
|
||||
"linear-gradient(to bottom, transparent 0%, black 25%, black 75%, transparent 100%), " +
|
||||
"linear-gradient(to right, transparent 0%, black 12%, black 88%, transparent 100%)",
|
||||
maskComposite: "intersect",
|
||||
WebkitMaskComposite: "source-in",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{/* 5 scrolling columns */}
|
||||
<div className="flex justify-center gap-2 sm:gap-3 md:gap-5 lg:gap-6 h-[340px] sm:h-[420px] md:h-[560px] lg:h-[640px] overflow-hidden">
|
||||
{COLUMNS.map((column, colIndex) => (
|
||||
<ScrollingColumn
|
||||
key={`col-${SCROLL_DURATIONS[colIndex]}-${colIndex}`}
|
||||
cards={column}
|
||||
scrollUp={colIndex % 2 === 0}
|
||||
duration={SCROLL_DURATIONS[colIndex]}
|
||||
colIndex={colIndex}
|
||||
isEdge={colIndex === 0 || colIndex === COLUMNS.length - 1}
|
||||
isEdgeAdjacent={colIndex === 1 || colIndex === COLUMNS.length - 2}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
1
surfsense_web/components/icons/providers/ai21.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg fill="currentColor" fill-rule="evenodd" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M6.47 17l-.367-1.189H2.718L2.35 17H0l3.398-9.789h2.026L8.864 17H6.47zm-2.052-6.993l-1.17 4.028H5.56l-1.142-4.028zm4.707-2.796h2.23V17h-2.23V7.211zM11.955 15c.1-.483.277-.946.524-1.37.214-.359.482-.68.795-.951.32-.273.658-.52 1.013-.741.28-.168.54-.33.781-.483.222-.14.433-.296.632-.468.172-.148.317-.325.428-.525.107-.199.16-.423.157-.65 0-.392-.104-.674-.313-.846a1.176 1.176 0 00-.775-.259 1.207 1.207 0 00-.863.329c-.231.219-.347.585-.347 1.098H11.8a3.387 3.387 0 01.224-1.245c.146-.377.371-.716.66-.993.306-.29.667-.514 1.06-.657A4.04 4.04 0 0115.183 7c.42-.002.84.057 1.244.175.376.107.73.287 1.04.531.305.246.55.562.714.923.185.419.275.875.265 1.335.005.39-.084.774-.259 1.12-.167.328-.38.63-.632.894-.246.259-.517.49-.808.693-.29.2-.554.37-.789.51-.326.224-.596.417-.809.58a3.872 3.872 0 00-.51.455 1.229 1.229 0 00-.265.434 1.633 1.633 0 00-.074.517h4.078V17h-6.606a9.24 9.24 0 01.183-2zM18.8 8.93a5.05 5.05 0 001.135-.105c.25-.049.484-.156.686-.314.163-.139.28-.324.34-.532.068-.25.1-.51.095-.77H23V17h-2.243v-6.475H18.8V8.93z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
1
surfsense_web/components/icons/providers/anthropic.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M16.765 5h-3.308l5.923 15h3.23zM7.226 5L1.38 20h3.308l1.307-3.154h6.154l1.23 3.077h3.309L10.688 5zm-.308 9.077l2-5.308l2.077 5.308z"/></svg>
|
||||
|
After Width: | Height: | Size: 229 B |
1
surfsense_web/components/icons/providers/anyscale.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg fill="currentColor" fill-rule="evenodd" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M17.583 12.344L14.606 17.5H20.6c.22 0 .424-.117.535-.308l2.799-4.848h-6.351zM23.934 11.656l-2.799-4.848A.616.616 0 0020.6 6.5h-5.994l2.977 5.156h6.35zM8.653 6.5h5.953l-2.997-5.191A.616.616 0 0011.074 1H5.476l3.176 5.5zM4.881 1.343L2.083 6.191a.618.618 0 000 .617l2.997 5.191 2.976-5.156-3.175-5.5zM8.057 17.155L5.081 12l-2.998 5.192a.618.618 0 000 .617l2.798 4.848 3.175-5.5h.001zM5.476 23h5.598c.22 0 .424-.117.535-.308l2.997-5.192H8.653L5.477 23z"></path></svg>
|
||||
|
After Width: | Height: | Size: 572 B |
1
surfsense_web/components/icons/providers/bedrock.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg fill="currentColor" fill-rule="evenodd" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M13.05 15.513h3.08c.214 0 .389.177.389.394v1.82a1.704 1.704 0 011.296 1.661c0 .943-.755 1.708-1.685 1.708-.931 0-1.686-.765-1.686-1.708 0-.807.554-1.484 1.297-1.662v-1.425h-2.69v4.663a.395.395 0 01-.188.338l-2.69 1.641a.385.385 0 01-.405-.002l-4.926-3.086a.395.395 0 01-.185-.336V16.3L2.196 14.87A.395.395 0 012 14.555L2 14.528V9.406c0-.14.073-.27.192-.34l2.465-1.462V4.448c0-.129.062-.249.165-.322l.021-.014L9.77 1.058a.385.385 0 01.407 0l2.69 1.675a.395.395 0 01.185.336V7.6h3.856V5.683a1.704 1.704 0 01-1.296-1.662c0-.943.755-1.708 1.685-1.708.931 0 1.685.765 1.685 1.708 0 .807-.553 1.484-1.296 1.662v2.311a.391.391 0 01-.389.394h-4.245v1.806h6.624a1.69 1.69 0 011.64-1.313c.93 0 1.685.764 1.685 1.707 0 .943-.754 1.708-1.685 1.708a1.69 1.69 0 01-1.64-1.314H13.05v1.937h4.953l.915 1.18a1.66 1.66 0 01.84-.227c.931 0 1.685.764 1.685 1.707 0 .943-.754 1.708-1.685 1.708-.93 0-1.685-.765-1.685-1.708 0-.346.102-.668.276-.937l-.724-.935H13.05v1.806zM9.973 1.856L7.93 3.122V6.09h-.778V3.604L5.435 4.669v2.945l2.11 1.36L9.712 7.61V5.334h.778V7.83c0 .136-.07.263-.184.335L7.963 9.638v2.081l1.422 1.009-.446.646-1.406-.998-1.53 1.005-.423-.66 1.605-1.055v-1.99L5.038 8.29l-2.26 1.34v1.676l1.972-1.189.398.677-2.37 1.429V14.3l2.166 1.258 2.27-1.368.397.677-2.176 1.311V19.3l1.876 1.175 2.365-1.426.398.678-2.017 1.216 1.918 1.201 2.298-1.403v-5.78l-4.758 2.893-.4-.675 5.158-3.136V3.289L9.972 1.856zM16.13 18.47a.913.913 0 00-.908.92c0 .507.406.918.908.918a.913.913 0 00.907-.919.913.913 0 00-.907-.92zm3.63-3.81a.913.913 0 00-.908.92c0 .508.406.92.907.92a.913.913 0 00.908-.92.913.913 0 00-.908-.92zm1.555-4.99a.913.913 0 00-.908.92c0 .507.407.918.908.918a.913.913 0 00.907-.919.913.913 0 00-.907-.92zM17.296 3.1a.913.913 0 00-.907.92c0 .508.406.92.907.92a.913.913 0 00.908-.92.913.913 0 00-.908-.92z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
1
surfsense_web/components/icons/providers/cerebras.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg fill="currentColor" fill-rule="evenodd" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path clip-rule="evenodd" d="M14.121 2.701a9.299 9.299 0 000 18.598V22.7c-5.91 0-10.7-4.791-10.7-10.701S8.21 1.299 14.12 1.299V2.7zm4.752 3.677A7.353 7.353 0 109.42 17.643l-.901 1.074a8.754 8.754 0 01-1.08-12.334 8.755 8.755 0 0112.335-1.08l-.901 1.075zm-2.255.844a5.407 5.407 0 00-5.048 9.563l-.656 1.24a6.81 6.81 0 016.358-12.043l-.654 1.24zM14.12 8.539a3.46 3.46 0 100 6.922v1.402a4.863 4.863 0 010-9.726v1.402z"></path><path d="M15.407 10.836a2.24 2.24 0 00-.51-.409 1.084 1.084 0 00-.544-.152c-.255 0-.483.047-.684.14a1.58 1.58 0 00-.84.912c-.074.203-.11.416-.11.631 0 .218.036.43.11.631a1.594 1.594 0 00.84.913c.2.093.43.14.684.14.216 0 .417-.046.602-.135.188-.09.35-.225.475-.392l.928 1.006c-.14.14-.3.261-.482.363a3.367 3.367 0 01-1.083.38c-.17.026-.317.04-.44.04a3.315 3.315 0 01-1.182-.21 2.825 2.825 0 01-.961-.597 2.816 2.816 0 01-.644-.929 2.987 2.987 0 01-.238-1.21c0-.444.08-.847.238-1.21.15-.35.368-.666.643-.929.278-.261.605-.464.962-.596a3.315 3.315 0 011.182-.21c.355 0 .712.068 1.072.204.361.138.685.36.944.649l-.962.97z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
1
surfsense_web/components/icons/providers/cohere.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg fill="currentColor" fill-rule="evenodd" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path clip-rule="evenodd" d="M8.128 14.099c.592 0 1.77-.033 3.398-.703 1.897-.781 5.672-2.2 8.395-3.656 1.905-1.018 2.74-2.366 2.74-4.18A4.56 4.56 0 0018.1 1H7.549A6.55 6.55 0 001 7.55c0 3.617 2.745 6.549 7.128 6.549z"></path><path clip-rule="evenodd" d="M9.912 18.61a4.387 4.387 0 012.705-4.052l3.323-1.38c3.361-1.394 7.06 1.076 7.06 4.715a5.104 5.104 0 01-5.105 5.104l-3.597-.001a4.386 4.386 0 01-4.386-4.387z"></path><path d="M4.776 14.962A3.775 3.775 0 001 18.738v.489a3.776 3.776 0 007.551 0v-.49a3.775 3.775 0 00-3.775-3.775z"></path></svg>
|
||||
|
After Width: | Height: | Size: 646 B |
1
surfsense_web/components/icons/providers/cometapi.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg fill="currentColor" fill-rule="evenodd" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M7.754 3.248C9.483.97 12.144-.223 14.99.035c4.67.422 8.023 4.694 7.27 9.384-.266 1.667-1 3.125-2.203 4.374-.468.487-1.025.9-1.662 1.422-2.554 2.09-6.026 4.854-10.413 8.294-.224.176-.669.495-.94.49a.19.19 0 01-.137-.06.192.192 0 01-.05-.14c.01-.207.077-.473.202-.8.04-.108.44-.956 1.197-2.545a1.99 1.99 0 00.179-.577.143.143 0 00-.007-.068.142.142 0 00-.098-.09.144.144 0 00-.07 0 1.479 1.479 0 00-.505.237c-.414.288-.86.648-1.337 1.078-.506.453-1.137 1.025-1.895 1.716a8.873 8.873 0 01-1.252.977.155.155 0 01-.064.021.152.152 0 01-.123-.04.154.154 0 01-.037-.055c-.027-.067-.024-.165.01-.292.113-.423.283-.902.511-1.437.17-.396.52-1.206 1.051-2.428.17-.39.697-1.592.61-1.897a.167.167 0 00-.102-.111.166.166 0 00-.15.018c-.284.194-.593.485-.93.87-.782.895-1.569 1.78-2.358 2.657-.248.274-.477.388-.687.343v-.238c.058-.215.104-.438.178-.642C4.075 12.378 5.938 7.2 6.764 4.964c.198-.537.529-1.11.99-1.716zm6.49-1.771a6.641 6.641 0 100 13.283 6.641 6.641 0 000-13.283z"></path><path d="M14.244 3.104a5.017 5.017 0 11-.002 10.033 5.017 5.017 0 01.002-10.033zm2.049 1.695a3.087 3.087 0 00-4.363 1.187 1.583 1.583 0 00-.165.442c-.015.067-.027.13-.033.194a1.308 1.308 0 00.078.56c.025.07.056.137.091.203.287.529.884.944 1.43 1.288.135.086.269.167.393.245l.392.246c.343.212.72.43 1.102.568.305.112.613.173.908.142a1.34 1.34 0 00.535-.178c.103-.061.202-.14.298-.237.064-.065.127-.137.186-.22a3.133 3.133 0 00.445-.868 3.08 3.08 0 00.056-1.71A3.063 3.063 0 0016.293 4.8z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
1
surfsense_web/components/icons/providers/dbrx.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg fill="currentColor" fill-rule="evenodd" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M21.821 9.894l-9.81 5.595L1.505 9.511 1 9.787v4.34l11.01 6.256 9.811-5.574v2.297l-9.81 5.596-10.506-5.979L1 17v.745L12.01 24 23 17.745v-4.34l-.505-.277-10.484 5.957-9.832-5.574v-2.298l9.832 5.574L23 10.532V6.255l-.547-.319-10.442 5.936-9.327-5.276 9.327-5.298 7.663 4.362.673-.383v-.532L12.011 0 1 6.255v.681l11.01 6.255 9.811-5.595z"></path></svg>
|
||||
|
After Width: | Height: | Size: 457 B |
1
surfsense_web/components/icons/providers/deepinfra.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg fill="currentColor" fill-rule="evenodd" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M3.294 7.821A2.297 2.297 0 011 5.527a2.297 2.297 0 012.294-2.295A2.297 2.297 0 015.59 5.527 2.297 2.297 0 013.294 7.82zm0-3.688a1.396 1.396 0 000 2.79 1.396 1.396 0 000-2.79zM3.294 14.293A2.297 2.297 0 011 11.998a2.297 2.297 0 012.294-2.294 2.297 2.297 0 012.295 2.294 2.297 2.297 0 01-2.295 2.295zm0-3.688a1.395 1.395 0 000 2.788 1.395 1.395 0 100-2.788zM3.294 20.761A2.297 2.297 0 011 18.467a2.297 2.297 0 012.294-2.295 2.297 2.297 0 012.295 2.295 2.297 2.297 0 01-2.295 2.294zm0-3.688a1.396 1.396 0 000 2.79 1.396 1.396 0 000-2.79zM20.738 7.821a2.297 2.297 0 01-2.295-2.294 2.297 2.297 0 012.294-2.295 2.297 2.297 0 012.295 2.295 2.297 2.297 0 01-2.294 2.294zm0-3.688a1.396 1.396 0 101.395 1.395c0-.77-.626-1.395-1.395-1.395zM20.738 14.293a2.297 2.297 0 01-2.295-2.295 2.297 2.297 0 012.294-2.294 2.297 2.297 0 012.295 2.294 2.297 2.297 0 01-2.294 2.295zm0-3.688c-.769 0-1.395.625-1.395 1.393a1.396 1.396 0 002.79 0c0-.77-.626-1.393-1.395-1.393zM20.738 20.761a2.297 2.297 0 01-2.295-2.294 2.297 2.297 0 012.294-2.295 2.297 2.297 0 012.295 2.295 2.297 2.297 0 01-2.294 2.294zm0-3.688a1.396 1.396 0 101.395 1.395c0-.77-.626-1.395-1.395-1.395zM12.016 11.057a2.297 2.297 0 01-2.294-2.294 2.297 2.297 0 012.294-2.295 2.297 2.297 0 012.295 2.295 2.297 2.297 0 01-2.295 2.294zm0-3.688a1.396 1.396 0 101.395 1.395c0-.77-.625-1.395-1.395-1.395zM12.017 4.589a2.297 2.297 0 01-2.295-2.295A2.297 2.297 0 0112.017 0a2.297 2.297 0 012.294 2.294 2.297 2.297 0 01-2.294 2.295zm0-3.688a1.396 1.396 0 101.395 1.395c0-.77-.626-1.395-1.395-1.395zM12.017 17.529a2.297 2.297 0 01-2.295-2.295 2.297 2.297 0 012.295-2.294 2.297 2.297 0 012.294 2.294 2.297 2.297 0 01-2.294 2.295zm0-3.688a1.396 1.396 0 101.395 1.395c0-.77-.626-1.395-1.395-1.395zM12.016 24a2.297 2.297 0 01-2.294-2.295 2.297 2.297 0 012.294-2.294 2.297 2.297 0 012.295 2.294A2.297 2.297 0 0112.016 24zm0-3.688a1.396 1.396 0 101.395 1.395c0-.77-.625-1.395-1.395-1.395z"></path><path d="M8.363 8.222a.742.742 0 01-.277-.053l-1.494-.596a.75.75 0 11.557-1.392l1.493.595a.75.75 0 01-.278 1.446h-.001zM8.363 14.566a.743.743 0 01-.277-.053l-1.494-.595a.75.75 0 11.557-1.393l1.493.596a.75.75 0 01-.278 1.445h-.001zM17.124 11.397a.741.741 0 01-.277-.054l-1.493-.595a.75.75 0 11.555-1.392l1.493.595a.75.75 0 01-.278 1.446zM17.124 5.05a.744.744 0 01-.277-.054L15.354 4.4a.75.75 0 01.555-1.392l1.493.596a.75.75 0 01-.278 1.445zM17.124 17.739a.743.743 0 01-.277-.053l-1.494-.596a.75.75 0 11.556-1.392l1.493.596a.75.75 0 01-.278 1.445zM6.91 17.966a.75.75 0 01-.279-1.445l1.494-.595a.749.749 0 11.556 1.392l-1.493.595a.743.743 0 01-.277.053H6.91zM6.91 11.66a.75.75 0 01-.279-1.446l1.494-.595a.75.75 0 01.556 1.392l-1.493.595a.743.743 0 01-.277.053H6.91zM6.91 5.033a.75.75 0 01-.279-1.446l1.494-.595a.75.75 0 01.556 1.392l-1.493.596a.744.744 0 01-.277.053H6.91zM8.363 21.364a.743.743 0 01-.277-.053l-1.494-.596a.75.75 0 01.555-1.392l1.494.595a.75.75 0 01-.278 1.446zM15.63 8.223a.75.75 0 01-.278-1.447l1.494-.595a.75.75 0 01.556 1.393l-1.494.595a.744.744 0 01-.276.054h-.002zM15.63 14.567a.75.75 0 01-.278-1.446l1.494-.596a.75.75 0 01.556 1.394l-1.494.595a.743.743 0 01-.276.053h-.002zM15.63 21.363a.749.749 0 01-.278-1.445l1.494-.595a.75.75 0 11.555 1.392l-1.494.595a.741.741 0 01-.277.053z"></path></svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
1
surfsense_web/components/icons/providers/deepseek.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M23.75 4.927c-.245-.12-.34.108-.482.224c-.049.038-.09.087-.131.13c-.357.384-.773.634-1.315.604c-.796-.044-1.474.207-2.074.818c-.127-.754-.551-1.203-1.195-1.492c-.338-.15-.68-.3-.915-.626c-.165-.231-.21-.49-.293-.744c-.052-.153-.105-.31-.28-.337c-.192-.03-.266.13-.341.265c-.3.55-.416 1.158-.406 1.772c.027 1.382.608 2.482 1.762 3.266c.132.09.166.18.124.311c-.079.27-.172.531-.255.8c-.052.173-.13.211-.314.135A5.3 5.3 0 0 1 15.97 8.92c-.82-.797-1.563-1.677-2.489-2.366a11 11 0 0 0-.66-.454c-.944-.922.125-1.679.372-1.768c.259-.093.09-.416-.747-.412c-.835.004-1.6.285-2.574.659c-.143.057-.326.153-.446.13a9.2 9.2 0 0 0-2.763-.096c-1.806.203-3.25 1.06-4.31 2.525c-1.275 1.76-1.574 3.759-1.207 5.846c.385 2.197 1.502 4.019 3.22 5.442c1.78 1.474 3.83 2.197 6.169 2.058c1.42-.081 3.003-.273 4.786-1.789c.45.224.922.313 1.707.381c.603.057 1.184-.03 1.634-.123c.704-.15.655-.804.4-.926c-2.065-.966-1.612-.573-2.024-.89c1.05-1.248 2.632-2.544 3.25-6.741c.049-.334.007-.543 0-.814c-.003-.163.034-.228.22-.247a4 4 0 0 0 1.482-.457c1.338-.734 1.867-1.939 1.995-3.385c.019-.22-.004-.45-.236-.565m-11.652 13.01c-2.002-1.58-2.972-2.1-3.373-2.078c-.375.021-.308.452-.225.733c.086.277.198.468.356.711c.109.162.184.402-.108.58c-.645.403-1.766-.134-1.82-.16c-1.303-.77-2.394-1.79-3.163-3.182c-.741-1.342-1.172-2.78-1.243-4.315c-.02-.372.09-.503.456-.57a4.5 4.5 0 0 1 1.466-.037c2.043.3 3.782 1.218 5.24 2.67c.832.829 1.462 1.817 2.11 2.783c.69 1.027 1.432 2.004 2.377 2.804c.333.281.6.495.854.653c-.768.085-2.05.104-2.927-.592m.96-6.199a.294.294 0 1 1 .588 0a.294.294 0 0 1-.296.296a.29.29 0 0 1-.293-.296m2.98 1.537c-.192.078-.383.146-.566.154a1.2 1.2 0 0 1-.765-.245c-.262-.22-.45-.343-.53-.73a1.7 1.7 0 0 1 .016-.566c.068-.315-.008-.516-.228-.7c-.18-.15-.408-.19-.66-.19a.5.5 0 0 1-.244-.076c-.105-.053-.191-.184-.109-.345a1 1 0 0 1 .185-.201c.34-.195.734-.13 1.098.015c.337.139.592.393.959.752c.375.434.442.555.656.88c.168.256.323.518.428.818c.063.186-.02.34-.24.434"/></svg>
|
||||
|
After Width: | Height: | Size: 2 KiB |
1
surfsense_web/components/icons/providers/fireworksai.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg fill="currentColor" fill-rule="evenodd" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path clip-rule="evenodd" d="M14.8 5l-2.801 6.795L9.195 5H7.397l3.072 7.428a1.64 1.64 0 003.038.002L16.598 5H14.8zm1.196 10.352l5.124-5.244-.699-1.669-5.596 5.739a1.664 1.664 0 00-.343 1.807 1.642 1.642 0 001.516 1.012L16 17l8-.02-.699-1.669-7.303.041h-.002zM2.88 10.104l.699-1.669 5.596 5.739c.468.479.603 1.189.343 1.807a1.643 1.643 0 01-1.516 1.012l-8-.018-.002.002.699-1.669 7.303.042-5.122-5.246z"></path></svg>
|
||||
|
After Width: | Height: | Size: 516 B |
1
surfsense_web/components/icons/providers/gemini.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M24 12.024c-6.437.388-11.59 5.539-11.977 11.976h-.047C11.588 17.563 6.436 12.412 0 12.024v-.047C6.437 11.588 11.588 6.437 11.976 0h.047c.388 6.437 5.54 11.588 11.977 11.977z"/></svg>
|
||||
|
After Width: | Height: | Size: 271 B |
1
surfsense_web/components/icons/providers/groq.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg fill="currentColor" fill-rule="evenodd" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12.036 2c-3.853-.035-7 3-7.036 6.781-.035 3.782 3.055 6.872 6.908 6.907h2.42v-2.566h-2.292c-2.407.028-4.38-1.866-4.408-4.23-.029-2.362 1.901-4.298 4.308-4.326h.1c2.407 0 4.358 1.915 4.365 4.278v6.305c0 2.342-1.944 4.25-4.323 4.279a4.375 4.375 0 01-3.033-1.252l-1.851 1.818A7 7 0 0012.029 22h.092c3.803-.056 6.858-3.083 6.879-6.816v-6.5C18.907 4.963 15.817 2 12.036 2z"></path></svg>
|
||||
|
After Width: | Height: | Size: 492 B |
1
surfsense_web/components/icons/providers/huggingface.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg fill="currentColor" fill-rule="evenodd" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M16.781 3.277c2.997 1.704 4.844 4.851 4.844 8.258 0 .995-.155 1.955-.443 2.857a1.332 1.332 0 011.125.4 1.41 1.41 0 01.2 1.723c.204.165.352.385.428.632l.017.062c.06.222.12.69-.2 1.166.244.37.279.836.093 1.236-.255.57-.893 1.018-2.128 1.5l-.202.078-.131.048c-.478.173-.89.295-1.061.345l-.086.024c-.89.243-1.808.375-2.732.394-1.32 0-2.3-.36-2.923-1.067a9.852 9.852 0 01-3.18.018C9.778 21.647 8.802 22 7.494 22a11.249 11.249 0 01-2.541-.343l-.221-.06-.273-.08a16.574 16.574 0 01-1.175-.405c-1.237-.483-1.875-.93-2.13-1.501-.186-.4-.151-.867.093-1.236a1.42 1.42 0 01-.2-1.166c.069-.273.226-.516.447-.694a1.41 1.41 0 01.2-1.722c.233-.248.557-.391.917-.407l.078-.001a9.385 9.385 0 01-.44-2.85c0-3.407 1.847-6.554 4.844-8.258a9.822 9.822 0 019.687 0zM4.188 14.758c.125.687 2.357 2.35 2.14 2.707-.19.315-.796-.239-.948-.386l-.041-.04-.168-.147c-.561-.479-2.304-1.9-2.74-1.432-.43.46.119.859 1.055 1.42l.784.467.136.083c1.045.643 1.12.84.95 1.113-.188.295-3.07-2.1-3.34-1.083-.27 1.011 2.942 1.304 2.744 2.006-.2.7-2.265-1.324-2.685-.537-.425.79 2.913 1.718 2.94 1.725l.16.04.175.042c1.227.284 3.565.65 4.435-.604.673-.973.64-1.709-.248-2.61l-.057-.057c-.945-.928-1.495-2.288-1.495-2.288l-.017-.058-.025-.072c-.082-.22-.284-.639-.63-.584-.46.073-.798 1.21.12 1.933l.05.038c.977.721-.195 1.21-.573.534l-.058-.104-.143-.25c-.463-.799-1.282-2.111-1.739-2.397-.532-.332-.907-.148-.782.541zm14.842-.541c-.533.335-1.563 2.074-1.94 2.751a.613.613 0 01-.687.302.436.436 0 01-.176-.098.303.303 0 01-.049-.06l-.014-.028-.008-.02-.007-.019-.003-.013-.003-.017a.289.289 0 01-.004-.048c0-.12.071-.266.25-.427.026-.024.054-.047.084-.07l.047-.036c.022-.016.043-.032.063-.049.883-.71.573-1.81.131-1.917l-.031-.006-.056-.004a.368.368 0 00-.062.006l-.028.005-.042.014-.039.017-.028.015-.028.019-.036.027-.023.02c-.173.158-.273.428-.31.542l-.016.054s-.53 1.309-1.439 2.234l-.054.054c-.365.358-.596.69-.702 1.018-.143.437-.066.868.21 1.353.055.097.117.195.187.296.882 1.275 3.282.876 4.494.59l.286-.07.25-.074c.276-.084.736-.233 1.2-.42l.188-.077.065-.028.064-.028.124-.056.081-.038c.529-.252.964-.543.994-.827l.001-.036a.299.299 0 00-.037-.139c-.094-.176-.271-.212-.491-.168l-.045.01c-.044.01-.09.024-.136.04l-.097.035-.054.022c-.559.23-1.238.705-1.607.745h.006a.452.452 0 01-.05.003h-.024l-.024-.003-.023-.005c-.068-.016-.116-.06-.14-.142a.22.22 0 01-.005-.1c.062-.345.958-.595 1.713-.91l.066-.028c.528-.224.97-.483.985-.832v-.04a.47.47 0 00-.016-.098c-.048-.18-.175-.251-.36-.251-.785 0-2.55 1.36-2.92 1.36-.025 0-.048-.007-.058-.024a.6.6 0 01-.046-.088c-.1-.238.068-.462 1.06-1.066l.209-.126c.538-.32 1.01-.588 1.341-.831.29-.212.475-.406.503-.6l.003-.028c.008-.113-.038-.227-.147-.344a.266.266 0 00-.07-.054l-.034-.015-.013-.005a.403.403 0 00-.13-.02c-.162 0-.369.07-.595.18-.637.313-1.431.952-1.826 1.285l-.249.215-.033.033c-.08.078-.288.27-.493.386l-.071.037-.041.019a.535.535 0 01-.122.036h.005a.346.346 0 01-.031.003l.01-.001-.013.001c-.079.005-.145-.021-.19-.095a.113.113 0 01-.014-.065c.027-.465 2.034-1.991 2.152-2.642l.009-.048c.1-.65-.271-.817-.791-.493zM11.938 2.984c-4.798 0-8.688 3.829-8.688 8.55 0 .692.083 1.364.24 2.008l.008-.009c.252-.298.612-.46 1.017-.46.355.008.699.117.993.312.22.14.465.384.715.694.261-.372.69-.598 1.15-.605.852 0 1.367.728 1.562 1.383l.047.105.06.127c.192.396.595 1.139 1.143 1.68 1.06 1.04 1.324 2.115.8 3.266a8.865 8.865 0 002.024-.014c-.505-1.12-.26-2.17.74-3.186l.066-.066c.695-.684 1.157-1.69 1.252-1.912.195-.655.708-1.383 1.56-1.383.46.007.889.233 1.15.605.25-.31.495-.553.718-.694a1.87 1.87 0 01.99-.312c.357 0 .682.126.925.36.14-.61.215-1.245.215-1.898 0-4.722-3.89-8.55-8.687-8.55zm1.857 8.926l.439-.212c.553-.264.89-.383.89.152 0 1.093-.771 3.208-3.155 3.262h-.184c-2.325-.052-3.116-2.06-3.156-3.175l-.001-.087c0-1.107 1.452.586 3.25.586.716 0 1.379-.272 1.917-.526zm4.017-3.143c.45 0 .813.358.813.8 0 .441-.364.8-.813.8a.806.806 0 01-.812-.8c0-.442.364-.8.812-.8zm-11.624 0c.448 0 .812.358.812.8 0 .441-.364.8-.812.8a.806.806 0 01-.813-.8c0-.442.364-.8.813-.8zm7.79-.841c.32-.384.846-.54 1.33-.394.483.146.83.564.878 1.06.048.495-.212.97-.659 1.203-.322.168-.447-.477-.767-.585l.002-.003c-.287-.098-.772.362-.925.079a1.215 1.215 0 01.14-1.36zm-4.323 0c.322.384.377.92.14 1.36-.152.283-.64-.177-.925-.079l.003.003c-.108.036-.194.134-.273.24l-.118.165c-.11.15-.22.262-.377.18a1.226 1.226 0 01-.658-1.204c.048-.495.395-.913.878-1.059a1.262 1.262 0 011.33.394z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
31
surfsense_web/components/icons/providers/index.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
export { default as Ai21Icon } from "./ai21.svg";
|
||||
export { default as AnthropicIcon } from "./anthropic.svg";
|
||||
export { default as AnyscaleIcon } from "./anyscale.svg";
|
||||
export { default as BedrockIcon } from "./bedrock.svg";
|
||||
export { default as CerebrasIcon } from "./cerebras.svg";
|
||||
export { default as CohereIcon } from "./cohere.svg";
|
||||
export { default as CometApiIcon } from "./cometapi.svg";
|
||||
export { default as DatabricksIcon } from "./dbrx.svg";
|
||||
export { default as DeepInfraIcon } from "./deepinfra.svg";
|
||||
export { default as DeepSeekIcon } from "./deepseek.svg";
|
||||
export { default as FireworksAiIcon } from "./fireworksai.svg";
|
||||
export { default as GeminiIcon } from "./gemini.svg";
|
||||
export { default as GroqIcon } from "./groq.svg";
|
||||
export { default as HuggingFaceIcon } from "./huggingface.svg";
|
||||
export { default as MistralIcon } from "./mistral.svg";
|
||||
export { default as MoonshotIcon } from "./moonshot.svg";
|
||||
export { default as NscaleIcon } from "./nscale.svg";
|
||||
export { default as OllamaIcon } from "./ollama.svg";
|
||||
export { default as OpenaiIcon } from "./openai.svg";
|
||||
export { default as OpenRouterIcon } from "./openrouter.svg";
|
||||
export { default as PerplexityIcon } from "./perplexity.svg";
|
||||
export { default as QwenIcon } from "./qwen.svg";
|
||||
export { default as RecraftIcon } from "./recraft.svg";
|
||||
export { default as ReplicateIcon } from "./replicate.svg";
|
||||
export { default as SambaNovaIcon } from "./sambanova.svg";
|
||||
export { default as TogetherAiIcon } from "./togetherai.svg";
|
||||
export { default as VertexAiIcon } from "./vertexai.svg";
|
||||
export { default as CloudflareIcon } from "./workersai-cloudflare.svg";
|
||||
export { default as XaiIcon } from "./xai.svg";
|
||||
export { default as XinferenceIcon } from "./xinference.svg";
|
||||
export { default as ZhipuIcon } from "./zhipu.svg";
|
||||
1
surfsense_web/components/icons/providers/mistral.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M17.143 3.429v3.428h-3.429v3.429h-3.428V6.857H6.857V3.43H3.43v13.714H0v3.428h10.286v-3.428H6.857v-3.429h3.429v3.429h3.429v-3.429h3.428v3.429h-3.428v3.428H24v-3.428h-3.43V3.429z"/></svg>
|
||||
|
After Width: | Height: | Size: 274 B |
1
surfsense_web/components/icons/providers/moonshot.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg fill="currentColor" fill-rule="evenodd" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M1.052 16.916l9.539 2.552a21.007 21.007 0 00.06 2.033l5.956 1.593a11.997 11.997 0 01-5.586.865l-.18-.016-.044-.004-.084-.009-.094-.01a11.605 11.605 0 01-.157-.02l-.107-.014-.11-.016a11.962 11.962 0 01-.32-.051l-.042-.008-.075-.013-.107-.02-.07-.015-.093-.019-.075-.016-.095-.02-.097-.023-.094-.022-.068-.017-.088-.022-.09-.024-.095-.025-.082-.023-.109-.03-.062-.02-.084-.025-.093-.028-.105-.034-.058-.019-.08-.026-.09-.031-.066-.024a6.293 6.293 0 01-.044-.015l-.068-.025-.101-.037-.057-.022-.08-.03-.087-.035-.088-.035-.079-.032-.095-.04-.063-.028-.063-.027a5.655 5.655 0 01-.041-.018l-.066-.03-.103-.047-.052-.024-.096-.046-.062-.03-.084-.04-.086-.044-.093-.047-.052-.027-.103-.055-.057-.03-.058-.032a6.49 6.49 0 01-.046-.026l-.094-.053-.06-.034-.051-.03-.072-.041-.082-.05-.093-.056-.052-.032-.084-.053-.061-.039-.079-.05-.07-.047-.053-.035a7.785 7.785 0 01-.054-.036l-.044-.03-.044-.03a6.066 6.066 0 01-.04-.028l-.057-.04-.076-.054-.069-.05-.074-.054-.056-.042-.076-.057-.076-.059-.086-.067-.045-.035-.064-.052-.074-.06-.089-.073-.046-.039-.046-.039a7.516 7.516 0 01-.043-.037l-.045-.04-.061-.053-.07-.062-.068-.06-.062-.058-.067-.062-.053-.05-.088-.084a13.28 13.28 0 01-.099-.097l-.029-.028-.041-.042-.069-.07-.05-.051-.05-.053a6.457 6.457 0 01-.168-.179l-.08-.088-.062-.07-.071-.08-.042-.049-.053-.062-.058-.068-.046-.056a7.175 7.175 0 01-.027-.033l-.045-.055-.066-.082-.041-.052-.05-.064-.02-.025a11.99 11.99 0 01-1.44-2.402zm-1.02-5.794l11.353 3.037a20.468 20.468 0 00-.469 2.011l10.817 2.894a12.076 12.076 0 01-1.845 2.005L.657 15.923l-.016-.046-.035-.104a11.965 11.965 0 01-.05-.153l-.007-.023a11.896 11.896 0 01-.207-.741l-.03-.126-.018-.08-.021-.097-.018-.081-.018-.09-.017-.084-.018-.094c-.026-.141-.05-.283-.071-.426l-.017-.118-.011-.083-.013-.102a12.01 12.01 0 01-.019-.161l-.005-.047a12.12 12.12 0 01-.034-2.145zm1.593-5.15l11.948 3.196c-.368.605-.705 1.231-1.01 1.875l11.295 3.022c-.142.82-.368 1.612-.668 2.365l-11.55-3.09L.124 10.26l.015-.1.008-.049.01-.067.015-.087.018-.098c.026-.148.056-.295.088-.442l.028-.124.02-.085.024-.097c.022-.09.045-.18.07-.268l.028-.102.023-.083.03-.1.025-.082.03-.096.026-.082.031-.095a11.896 11.896 0 011.01-2.232zm4.442-4.4L17.352 4.59a20.77 20.77 0 00-1.688 1.721l7.823 2.093c.267.852.442 1.744.513 2.665L2.106 5.213l.045-.065.027-.04.04-.055.046-.065.055-.076.054-.072.064-.086.05-.065.057-.073.055-.07.06-.074.055-.069.065-.077.054-.066.066-.077.053-.06.072-.082.053-.06.067-.074.054-.058.073-.078.058-.06.063-.067.168-.17.1-.098.059-.056.076-.071a12.084 12.084 0 012.272-1.677zM12.017 0h.097l.082.001.069.001.054.002.068.002.046.001.076.003.047.002.06.003.054.002.087.005.105.007.144.011.088.007.044.004.077.008.082.008.047.005.102.012.05.006.108.014.081.01.042.006.065.01.207.032.07.012.065.011.14.026.092.018.11.022.046.01.075.016.041.01L14.7.3l.042.01.065.015.049.012.071.017.096.024.112.03.113.03.113.032.05.015.07.02.078.024.073.023.05.016.05.016.076.025.099.033.102.036.048.017.064.023.093.034.11.041.116.045.1.04.047.02.06.024.041.018.063.026.04.018.057.025.11.048.1.046.074.035.075.036.06.028.092.046.091.045.102.052.053.028.049.026.046.024.06.033.041.022.052.029.088.05.106.06.087.051.057.034.053.032.096.059.088.055.098.062.036.024.064.041.084.056.04.027.062.042.062.043.023.017c.054.037.108.075.161.114l.083.06.065.048.056.043.086.065.082.064.04.03.05.041.086.069.079.065.085.071c.712.6 1.353 1.283 1.909 2.031L7.222.994l.062-.027.065-.028.081-.034.086-.035c.113-.045.227-.09.341-.131l.096-.035.093-.033.084-.03.096-.031c.087-.03.176-.058.264-.085l.091-.027.086-.025.102-.03.085-.023.1-.026L9.04.37l.09-.023.091-.022.095-.022.09-.02.098-.021.091-.02.095-.018.092-.018.1-.018.091-.016.098-.017.092-.014.097-.015.092-.013.102-.013.091-.012.105-.012.09-.01.105-.01c.093-.01.186-.018.28-.024l.106-.008.09-.005.11-.006.093-.004.1-.004.097-.002.099-.002.197-.002z"></path></svg>
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
5
surfsense_web/components/icons/providers/nscale.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<svg viewBox="0 0 670 380" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M669.358 41.7198C669.358 5.33456 625.406 -12.9309 599.618 12.7374L245.993 364.722C240.942 369.75 244.502 378.374 251.629 378.374H302.73C305.153 378.374 307.573 378.19 309.968 377.824L388.815 365.781C438.73 358.157 489.537 358.467 539.356 366.701L606.083 377.729C608.666 378.156 611.281 378.371 613.899 378.371H649.823C660.612 378.371 669.358 369.625 669.358 358.836V41.7198Z"/>
|
||||
<path opacity="0.6" d="M122.769 378.256C115.654 378.256 112.088 369.656 117.116 364.622L361.637 119.745C375.933 105.427 399.132 105.419 413.438 119.726C427.738 134.025 427.738 157.209 413.438 171.508L217.784 367.163C210.682 374.265 201.05 378.255 191.006 378.255L122.769 378.256Z"/>
|
||||
<path opacity="0.4" d="M18.9735 378.303C2.11297 378.303 -6.33968 357.926 5.57059 345.992L154.164 197.101C168.458 182.779 191.658 182.768 205.966 197.075C220.263 211.373 220.263 234.553 205.966 248.851L87.6062 367.21C80.5043 374.312 70.872 378.302 60.8284 378.302L18.9735 378.303Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1 KiB |
1
surfsense_web/components/icons/providers/ollama.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M16.361 10.26a.9.9 0 0 0-.558.47l-.072.148l.001.207c0 .193.004.217.059.353c.076.193.152.312.291.448c.24.238.51.3.872.205a.86.86 0 0 0 .517-.436a.75.75 0 0 0 .08-.498c-.064-.453-.33-.782-.724-.897a1.1 1.1 0 0 0-.466 0m-9.203.005c-.305.096-.533.32-.65.639a1.2 1.2 0 0 0-.06.52c.057.309.31.59.598.667c.362.095.632.033.872-.205c.14-.136.215-.255.291-.448c.055-.136.059-.16.059-.353l.001-.207l-.072-.148a.9.9 0 0 0-.565-.472a1 1 0 0 0-.474.007m4.184 2c-.131.071-.223.25-.195.383c.031.143.157.288.353.407c.105.063.112.072.117.136c.004.038-.01.146-.029.243c-.02.094-.036.194-.036.222c.002.074.07.195.143.253c.064.052.076.054.255.059c.164.005.198.001.264-.03c.169-.082.212-.234.15-.525c-.052-.243-.042-.28.087-.355c.137-.08.281-.219.324-.314a.365.365 0 0 0-.175-.48a.4.4 0 0 0-.181-.033c-.126 0-.207.03-.355.124l-.085.053l-.053-.032c-.219-.13-.259-.145-.391-.143a.4.4 0 0 0-.193.032m.39-2.195c-.373.036-.475.05-.654.086a4.5 4.5 0 0 0-.951.328c-.94.46-1.589 1.226-1.787 2.114c-.04.176-.045.234-.045.53c0 .294.005.357.043.524c.264 1.16 1.332 2.017 2.714 2.173c.3.033 1.596.033 1.896 0c1.11-.125 2.064-.727 2.493-1.571c.114-.226.169-.372.22-.602c.039-.167.044-.23.044-.523c0-.297-.005-.355-.045-.531c-.288-1.29-1.539-2.304-3.072-2.497a7 7 0 0 0-.855-.031zm.645.937a3.3 3.3 0 0 1 1.44.514c.223.148.537.458.671.662c.166.251.26.508.303.82c.02.143.01.251-.043.482c-.08.345-.332.705-.672.957a3 3 0 0 1-.689.348c-.382.122-.632.144-1.525.138c-.582-.006-.686-.01-.853-.042q-.856-.16-1.35-.68c-.264-.28-.385-.535-.45-.946c-.03-.192.025-.509.137-.776c.136-.326.488-.73.836-.963c.403-.269.934-.46 1.422-.512c.187-.02.586-.02.773-.002m-5.503-11a1.65 1.65 0 0 0-.683.298C5.617.74 5.173 1.666 4.985 2.819c-.07.436-.119 1.04-.119 1.503c0 .544.064 1.24.155 1.721c.02.107.031.202.023.208l-.187.152a5.3 5.3 0 0 0-.949 1.02a5.5 5.5 0 0 0-.94 2.339a6.6 6.6 0 0 0-.023 1.357c.091.78.325 1.438.727 2.04l.13.195l-.037.064c-.269.452-.498 1.105-.605 1.732c-.084.496-.095.629-.095 1.294c0 .67.009.803.088 1.266c.095.555.288 1.143.503 1.534c.071.128.243.393.264.407c.007.003-.014.067-.046.141a7.4 7.4 0 0 0-.548 1.873a5 5 0 0 0-.071.991c0 .56.031.832.148 1.279L3.42 24h1.478l-.05-.091c-.297-.552-.325-1.575-.068-2.597c.117-.472.25-.819.498-1.296l.148-.29v-.177c0-.165-.003-.184-.057-.293a.9.9 0 0 0-.194-.25a1.7 1.7 0 0 1-.385-.543c-.424-.92-.506-2.286-.208-3.451c.124-.486.329-.918.544-1.154a.8.8 0 0 0 .223-.531c0-.195-.07-.355-.224-.522a3.14 3.14 0 0 1-.817-1.729c-.14-.96.114-2.005.69-2.834c.563-.814 1.353-1.336 2.237-1.475c.199-.033.57-.028.776.01c.226.04.367.028.512-.041c.179-.085.268-.19.374-.431c.093-.215.165-.333.36-.576c.234-.29.46-.489.822-.729c.413-.27.884-.467 1.352-.561c.17-.035.25-.04.569-.04s.398.005.569.04a4.07 4.07 0 0 1 1.914.997c.117.109.398.457.488.602c.034.057.095.177.132.267c.105.241.195.346.374.43c.14.068.286.082.503.045c.343-.058.607-.053.943.016c1.144.23 2.14 1.173 2.581 2.437c.385 1.108.276 2.267-.296 3.153c-.097.15-.193.27-.333.419c-.301.322-.301.722-.001 1.053c.493.539.801 1.866.708 3.036c-.062.772-.26 1.463-.533 1.854a2 2 0 0 1-.224.258a.9.9 0 0 0-.194.25c-.054.109-.057.128-.057.293v.178l.148.29c.248.476.38.823.498 1.295c.253 1.008.231 2.01-.059 2.581a1 1 0 0 0-.044.098c0 .006.329.009.732.009h.73l.02-.074l.036-.134c.019-.076.057-.3.088-.516a9 9 0 0 0 0-1.258c-.11-.875-.295-1.57-.597-2.226c-.032-.074-.053-.138-.046-.141a1.4 1.4 0 0 0 .108-.152c.376-.569.607-1.284.724-2.228c.031-.26.031-1.378 0-1.628c-.083-.645-.182-1.082-.348-1.525a6 6 0 0 0-.329-.7l-.038-.064l.131-.194c.402-.604.636-1.262.727-2.04a6.6 6.6 0 0 0-.024-1.358a5.5 5.5 0 0 0-.939-2.339a5.3 5.3 0 0 0-.95-1.02l-.186-.152a.7.7 0 0 1 .023-.208c.208-1.087.201-2.443-.017-3.503c-.19-.924-.535-1.658-.98-2.082c-.354-.338-.716-.482-1.15-.455c-.996.059-1.8 1.205-2.116 3.01a7 7 0 0 0-.097.726c0 .036-.007.066-.015.066a1 1 0 0 1-.149-.078A4.86 4.86 0 0 0 12 3.03c-.832 0-1.687.243-2.456.698a1 1 0 0 1-.148.078c-.008 0-.015-.03-.015-.066a7 7 0 0 0-.097-.725C8.997 1.392 8.337.319 7.46.048a2 2 0 0 0-.585-.041Zm.293 1.402c.248.197.523.759.682 1.388c.03.113.06.244.069.292c.007.047.026.152.041.233c.067.365.098.76.102 1.24l.002.475l-.12.175l-.118.178h-.278c-.324 0-.646.041-.954.124l-.238.06c-.033.007-.038-.003-.057-.144a8.4 8.4 0 0 1 .016-2.323c.124-.788.413-1.501.696-1.711c.067-.05.079-.049.157.013m9.825-.012c.17.126.358.46.498.888c.28.854.36 2.028.212 3.145c-.019.14-.024.151-.057.144l-.238-.06a3.7 3.7 0 0 0-.954-.124h-.278l-.119-.178l-.119-.175l.002-.474c.004-.669.066-1.19.214-1.772c.157-.623.434-1.185.68-1.382c.078-.062.09-.063.159-.012"/></svg>
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
1
surfsense_web/components/icons/providers/openai.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M20.562 10.188c.25-.688.313-1.376.25-2.063c-.062-.687-.312-1.375-.625-2c-.562-.937-1.375-1.687-2.312-2.125c-1-.437-2.063-.562-3.125-.312c-.5-.5-1.063-.938-1.688-1.25S11.687 2 11 2a5.17 5.17 0 0 0-3 .938c-.875.624-1.5 1.5-1.813 2.5c-.75.187-1.375.5-2 .875c-.562.437-1 1-1.375 1.562c-.562.938-.75 2-.625 3.063a5.44 5.44 0 0 0 1.25 2.874a4.7 4.7 0 0 0-.25 2.063c.063.688.313 1.375.625 2c.563.938 1.375 1.688 2.313 2.125c1 .438 2.062.563 3.125.313c.5.5 1.062.937 1.687 1.25S12.312 22 13 22a5.17 5.17 0 0 0 3-.937c.875-.625 1.5-1.5 1.812-2.5a4.54 4.54 0 0 0 1.938-.875c.562-.438 1.062-.938 1.375-1.563c.562-.937.75-2 .625-3.062c-.125-1.063-.5-2.063-1.188-2.876m-7.5 10.5c-1 0-1.75-.313-2.437-.875c0 0 .062-.063.125-.063l4-2.312a.5.5 0 0 0 .25-.25a.57.57 0 0 0 .062-.313V11.25l1.688 1v4.625a3.685 3.685 0 0 1-3.688 3.813M5 17.25c-.438-.75-.625-1.625-.438-2.5c0 0 .063.063.125.063l4 2.312a.56.56 0 0 0 .313.063c.125 0 .25 0 .312-.063l4.875-2.812v1.937l-4.062 2.375A3.7 3.7 0 0 1 7.312 19c-1-.25-1.812-.875-2.312-1.75M3.937 8.563a3.8 3.8 0 0 1 1.938-1.626v4.751c0 .124 0 .25.062.312a.5.5 0 0 0 .25.25l4.875 2.813l-1.687 1l-4-2.313a3.7 3.7 0 0 1-1.75-2.25c-.25-.937-.188-2.062.312-2.937M17.75 11.75l-4.875-2.812l1.687-1l4 2.312c.625.375 1.125.875 1.438 1.5s.5 1.313.437 2.063a3.7 3.7 0 0 1-.75 1.937c-.437.563-1 1-1.687 1.25v-4.75c0-.125 0-.25-.063-.312c0 0-.062-.126-.187-.188m1.687-2.5s-.062-.062-.125-.062l-4-2.313c-.125-.062-.187-.062-.312-.062s-.25 0-.313.062L9.812 9.688V7.75l4.063-2.375c.625-.375 1.312-.5 2.062-.5c.688 0 1.375.25 2 .688c.563.437 1.063 1 1.313 1.625s.312 1.375.187 2.062m-10.5 3.5l-1.687-1V7.063c0-.688.187-1.438.562-2C8.187 4.438 8.75 4 9.375 3.688a3.37 3.37 0 0 1 2.062-.313c.688.063 1.375.375 1.938.813c0 0-.063.062-.125.062l-4 2.313a.5.5 0 0 0-.25.25c-.063.125-.063.187-.063.312zm.875-2L12 9.5l2.187 1.25v2.5L12 14.5l-2.188-1.25z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
1
surfsense_web/components/icons/providers/openrouter.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg fill="currentColor" fill-rule="evenodd" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M16.804 1.957l7.22 4.105v.087L16.73 10.21l.017-2.117-.821-.03c-1.059-.028-1.611.002-2.268.11-1.064.175-2.038.577-3.147 1.352L8.345 11.03c-.284.195-.495.336-.68.455l-.515.322-.397.234.385.23.53.338c.476.314 1.17.796 2.701 1.866 1.11.775 2.083 1.177 3.147 1.352l.3.045c.694.091 1.375.094 2.825.033l.022-2.159 7.22 4.105v.087L16.589 22l.014-1.862-.635.022c-1.386.042-2.137.002-3.138-.162-1.694-.28-3.26-.926-4.881-2.059l-2.158-1.5a21.997 21.997 0 00-.755-.498l-.467-.28a55.927 55.927 0 00-.76-.43C2.908 14.73.563 14.116 0 14.116V9.888l.14.004c.564-.007 2.91-.622 3.809-1.124l1.016-.58.438-.274c.428-.28 1.072-.726 2.686-1.853 1.621-1.133 3.186-1.78 4.881-2.059 1.152-.19 1.974-.213 3.814-.138l.02-1.907z"></path></svg>
|
||||
|
After Width: | Height: | Size: 824 B |
1
surfsense_web/components/icons/providers/perplexity.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg fill="currentColor" fill-rule="evenodd" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M19.785 0v7.272H22.5V17.62h-2.935V24l-7.037-6.194v6.145h-1.091v-6.152L4.392 24v-6.465H1.5V7.188h2.884V0l7.053 6.494V.19h1.09v6.49L19.786 0zm-7.257 9.044v7.319l5.946 5.234V14.44l-5.946-5.397zm-1.099-.08l-5.946 5.398v7.235l5.946-5.234V8.965zm8.136 7.58h1.844V8.349H13.46l6.105 5.54v2.655zm-8.982-8.28H2.59v8.195h1.8v-2.576l6.192-5.62zM5.475 2.476v4.71h5.115l-5.115-4.71zm13.219 0l-5.115 4.71h5.115v-4.71z"></path></svg>
|
||||
|
After Width: | Height: | Size: 526 B |
1
surfsense_web/components/icons/providers/qwen.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg fill="currentColor" fill-rule="evenodd" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12.604 1.34c.393.69.784 1.382 1.174 2.075a.18.18 0 00.157.091h5.552c.174 0 .322.11.446.327l1.454 2.57c.19.337.24.478.024.837-.26.43-.513.864-.76 1.3l-.367.658c-.106.196-.223.28-.04.512l2.652 4.637c.172.301.111.494-.043.77-.437.785-.882 1.564-1.335 2.34-.159.272-.352.375-.68.37-.777-.016-1.552-.01-2.327.016a.099.099 0 00-.081.05 575.097 575.097 0 01-2.705 4.74c-.169.293-.38.363-.725.364-.997.003-2.002.004-3.017.002a.537.537 0 01-.465-.271l-1.335-2.323a.09.09 0 00-.083-.049H4.982c-.285.03-.553-.001-.805-.092l-1.603-2.77a.543.543 0 01-.002-.54l1.207-2.12a.198.198 0 000-.197 550.951 550.951 0 01-1.875-3.272l-.79-1.395c-.16-.31-.173-.496.095-.965.465-.813.927-1.625 1.387-2.436.132-.234.304-.334.584-.335a338.3 338.3 0 012.589-.001.124.124 0 00.107-.063l2.806-4.895a.488.488 0 01.422-.246c.524-.001 1.053 0 1.583-.006L11.704 1c.341-.003.724.032.9.34zm-3.432.403a.06.06 0 00-.052.03L6.254 6.788a.157.157 0 01-.135.078H3.253c-.056 0-.07.025-.041.074l5.81 10.156c.025.042.013.062-.034.063l-2.795.015a.218.218 0 00-.2.116l-1.32 2.31c-.044.078-.021.118.068.118l5.716.008c.046 0 .08.02.104.061l1.403 2.454c.046.081.092.082.139 0l5.006-8.76.783-1.382a.055.055 0 01.096 0l1.424 2.53a.122.122 0 00.107.062l2.763-.02a.04.04 0 00.035-.02.041.041 0 000-.04l-2.9-5.086a.108.108 0 010-.113l.293-.507 1.12-1.977c.024-.041.012-.062-.035-.062H9.2c-.059 0-.073-.026-.043-.077l1.434-2.505a.107.107 0 000-.114L9.225 1.774a.06.06 0 00-.053-.031zm6.29 8.02c.046 0 .058.02.034.06l-.832 1.465-2.613 4.585a.056.056 0 01-.05.029.058.058 0 01-.05-.029L8.498 9.841c-.02-.034-.01-.052.028-.054l.216-.012 6.722-.012z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
1
surfsense_web/components/icons/providers/recraft.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg fill="currentColor" fill-rule="evenodd" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M19.667 8.275c0-4.57-4.15-8.275-9.27-8.275-1.774 0-3.213 3.705-3.213 8.275 0 1.143.09 2.233.253 3.224H4.29L1 23h9.4v-6.447c5.117 0 9.266-3.707 9.266-8.275l.001-.002zm-9.27-6.76c.93 0 1.682 3.028 1.682 6.76 0 3.733-.752 6.76-1.681 6.76-.93 0-1.681-3.027-1.681-6.76 0-3.732.752-6.76 1.68-6.76z"></path><path d="M19.848 16.552h-9.44L14.028 23h9.438l-3.618-6.448z"></path></svg>
|
||||
|
After Width: | Height: | Size: 483 B |
1
surfsense_web/components/icons/providers/replicate.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg fill="currentColor" fill-rule="evenodd" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M22 10.552v2.26h-7.932V22H11.54V10.552H22zM22 2v2.264H4.528V22H2V2h20zm0 4.276V8.54H9.296V22H6.768V6.276H22z"></path></svg>
|
||||
|
After Width: | Height: | Size: 232 B |
1
surfsense_web/components/icons/providers/sambanova.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg fill="currentColor" fill-rule="evenodd" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M23 23h-1.223V8.028c0-3.118-2.568-5.806-5.744-5.806H8.027c-3.176 0-5.744 2.565-5.744 5.686 0 3.119 2.568 5.684 5.744 5.684h.794c1.346 0 2.445 1.1 2.445 2.444 0 1.346-1.1 2.446-2.445 2.446H1v-1.223h7.761c.671 0 1.223-.551 1.223-1.16 0-.67-.552-1.16-1.223-1.16h-.794C4.177 14.872 1 11.756 1 7.909 1 4.058 4.176 1 8.027 1h8.066C19.88 1 23 4.239 23 8.028V23z"></path><path d="M8.884 12.672c1.71.06 3.361 1.588 3.361 3.422 0 1.833-1.528 3.421-3.421 3.421H1v1.223h7.761c2.568 0 4.705-2.077 4.705-4.644 0-.672-.123-1.283-.43-1.894-.245-.551-.67-1.1-1.099-1.528-.489-.429-1.039-.734-1.65-.977-.525-.175-1.048-.193-1.594-.212-.218-.008-.441-.016-.669-.034-.428 0-1.406-.245-1.956-.61a3.369 3.369 0 01-1.223-1.406c-.183-.489-.305-.977-.305-1.528A3.417 3.417 0 017.96 4.482h8.066c1.895 0 3.422 1.65 3.422 3.483v15.032h1.223V8.027c0-2.568-2.077-4.768-4.645-4.768h-8c-2.568 0-4.705 2.077-4.705 4.646 0 .67.123 1.282.43 1.894a4.45 4.45 0 001.099 1.528c.429.428 1.039.734 1.588.976.306.123.611.183.976.246.857.06 1.406.123 1.466.123h.003z"></path><path d="M1 23h7.761v-.003c3.85 0 7.03-3.116 7.09-7.026 0-3.79-3.117-6.906-6.967-6.906H8.09c-.672 0-1.222-.552-1.222-1.16 0-.608.487-1.16 1.159-1.16h8.069c.608 0 1.159.611 1.159 1.283v14.97h1.223V8.024c0-1.345-1.1-2.505-2.445-2.505H7.967a2.451 2.451 0 00-2.445 2.445 2.45 2.45 0 002.445 2.445h.794c3.176 0 5.744 2.568 5.744 5.684s-2.568 5.684-5.744 5.684H1V23z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
1
surfsense_web/components/icons/providers/togetherai.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg fill="currentColor" fill-rule="evenodd" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M17.385 11.23a4.615 4.615 0 100-9.23 4.615 4.615 0 000 9.23zm0 10.77a4.615 4.615 0 100-9.23 4.615 4.615 0 000 9.23zm-10.77 0a4.615 4.615 0 100-9.23 4.615 4.615 0 000 9.23z" opacity=".2"></path><circle cx="6.615" cy="6.615" r="4.615"></circle></svg>
|
||||
|
After Width: | Height: | Size: 357 B |
1
surfsense_web/components/icons/providers/vertexai.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg fill="currentColor" fill-rule="evenodd" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M11.995 20.216a1.892 1.892 0 100 3.785 1.892 1.892 0 000-3.785zm0 2.806a.927.927 0 11.927-.914.914.914 0 01-.927.914z"></path><path clip-rule="evenodd" d="M21.687 14.144c.237.038.452.16.605.344a.978.978 0 01-.18 1.3l-8.24 6.082a1.892 1.892 0 00-1.147-1.508l8.28-6.08a.991.991 0 01.682-.138z"></path><path clip-rule="evenodd" d="M10.122 21.842l-8.217-6.066a.952.952 0 01-.206-1.287.978.978 0 011.287-.206l8.28 6.08a1.893 1.893 0 00-1.144 1.479z"></path><path d="M4.273 4.475a.978.978 0 01-.965-.965V1.09a.978.978 0 111.943 0v2.42a.978.978 0 01-.978.965zM4.247 13.034a.978.978 0 100-1.956.978.978 0 000 1.956zM4.247 10.19a.978.978 0 100-1.956.978.978 0 000 1.956zM4.247 7.332a.978.978 0 100-1.956.978.978 0 000 1.956z"></path><path d="M19.718 7.307a.978.978 0 01-.965-.979v-2.42a.965.965 0 011.93 0v2.42a.964.964 0 01-.965.979zM19.743 13.047a.978.978 0 100-1.956.978.978 0 000 1.956zM19.743 10.151a.978.978 0 100-1.956.978.978 0 000 1.956zM19.743 2.068a.978.978 0 100-1.956.978.978 0 000 1.956z"></path><path d="M11.995 15.917a.978.978 0 01-.965-.965v-2.459a.978.978 0 011.943 0v2.433a.976.976 0 01-.978.991zM11.995 18.762a.978.978 0 100-1.956.978.978 0 000 1.956zM11.995 10.64a.978.978 0 100-1.956.978.978 0 000 1.956zM11.995 7.783a.978.978 0 100-1.956.978.978 0 000 1.956z"></path><path d="M15.856 10.177a.978.978 0 01-.965-.965v-2.42a.977.977 0 011.702-.763.979.979 0 01.241.763v2.42a.978.978 0 01-.978.965zM15.869 4.913a.978.978 0 100-1.956.978.978 0 000 1.956zM15.869 15.853a.978.978 0 100-1.956.978.978 0 000 1.956zM15.869 12.996a.978.978 0 100-1.956.978.978 0 000 1.956z"></path><path d="M8.121 15.853a.978.978 0 100-1.956.978.978 0 000 1.956zM8.121 7.783a.978.978 0 100-1.956.978.978 0 000 1.956zM8.121 4.913a.978.978 0 100-1.957.978.978 0 000 1.957zM8.134 12.996a.978.978 0 01-.978-.94V9.611a.965.965 0 011.93 0v2.445a.966.966 0 01-.952.94z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
|
|
@ -0,0 +1 @@
|
|||
<svg fill="currentColor" fill-rule="evenodd" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path clip-rule="evenodd" d="M15.99 2.444h-2.135v4.69l2.134.006V2.444zM11.06 5.153l2.224 2.225L11.77 8.88 9.552 6.662l1.51-1.51zM6.845 9.455h4.696l-.007 2.133h-4.69V9.456zm2.71 4.928l2.222-2.224 1.505 1.514-2.218 2.217-1.51-1.509.001.002zm4.3 4.216v-4.696l2.134.007v4.69h-2.134zm4.928-2.706l-2.225-2.225 1.514-1.504 2.22 2.22-1.51 1.51h.001zM23 11.588h-4.696l.007-2.133H23v2.133zm-2.709-4.926l-2.223 2.223-1.504-1.513 2.22-2.22 1.507 1.51zM3.2 2.926V4.13H1.994v1.929H3.2v1.204h1.927V6.059h1.204V4.131H5.127V2.926H3.2zm0 18.835v-2.2H1v-1.927h2.2v-2.198h1.927v2.198h2.2v1.927h-2.2v2.2H3.2z"></path></svg>
|
||||
|
After Width: | Height: | Size: 702 B |
1
surfsense_web/components/icons/providers/xai.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M4.94 4.96a9.97 9.97 0 0 1 10.835-2.182a8.7 8.7 0 0 1 2.033 1.11l-3.006 1.39C12.003 4.101 8.797 4.9 6.84 6.86c-2.564 2.565-3.146 6.954-.36 9.922l.278.284L.124 23c1.875-1.973 3.771-4.427 2.636-7.19c-1.52-3.698-.635-8.03 2.18-10.85M23.9.1c-2.264 3.174-3.184 5.389-2.197 9.64l-.007-.007c.753 3.201-.052 6.75-2.653 9.355c-3.279 3.285-8.526 4.016-12.847 1.06L9.21 18.75c2.758 1.084 5.775.607 7.943-1.564c2.169-2.17 2.655-5.332 1.566-7.963c-.207-.5-.828-.625-1.263-.304L8.59 15.472l12.7-12.77v.01z"/></svg>
|
||||
|
After Width: | Height: | Size: 589 B |
1
surfsense_web/components/icons/providers/xinference.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg fill="currentColor" fill-rule="evenodd" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M5.223 9.692c.652 1.795 1.925 3.376 3.396 4.573 1.482 1.229 3.254 2.17 5.122 2.653a9.99 9.99 0 002.033.302c1.302.05 2.713-.206 3.758-1.04 1.297-1.036 1.651-2.625 1.318-4.21-.209-.993-.641-1.93-1.205-2.787a10.284 10.284 0 00-.366-.525.008.008 0 01.005-.007h.004c.002 0 .004 0 .006.002l.394.405a17.227 17.227 0 012.484 3.262c.579.993 1.023 2.046 1.255 3.144.369 1.747.07 3.546-1.306 4.777-.724.648-1.655 1.041-2.59 1.235-1.297.267-2.649.228-3.965.007-.669-.112-1.315-.26-1.937-.443-2.576-.756-5.012-2.051-7.143-3.677a20.968 20.968 0 01-3.484-3.296C1.949 12.813 1.046 11.396.487 9.853.12 8.845-.087 7.725.035 6.663c.267-2.306 1.98-3.654 4.174-4.06 1.265-.234 2.594-.186 3.879.037a17.71 17.71 0 013.978 1.192v.004a.006.006 0 01-.004.004h-.004a8.907 8.907 0 00-2.869-.29c-.807.048-1.666.263-2.357.656-1.034.588-1.67 1.463-1.907 2.625a4.567 4.567 0 00-.069 1.1c.025.58.163 1.198.367 1.761z"></path><path d="M18.02 7.235a.05.05 0 01-.007.03c-.461.916-.923 1.832-1.386 2.747-.424.837-.745 1.437-.965 1.8a17.877 17.877 0 01-2.98 3.707.027.027 0 01-.03.005 12.678 12.678 0 01-4.205-2.777c-.14-.14-.28-.288-.42-.447a.024.024 0 01-.005-.013c0-.005 0-.01.003-.014a17.718 17.718 0 011.68-2.379 18.27 18.27 0 012.7-2.606c.408-.32 1.39-1.094 2.95-2.323L21.652.002a.008.008 0 01.01 0 .01.01 0 01.004.005.01.01 0 010 .006l-3.648 7.222z"></path><path d="M2.027 24c.002 0 .004 0 .005-.002l5.843-4.58a.02.02 0 00.008-.017.02.02 0 00-.01-.016 26.743 26.743 0 01-2.584-1.842h-.006a.014.014 0 00-.005.002.012.012 0 00-.004.005L2.02 23.987a.01.01 0 000 .006c0 .002 0 .004.002.005a.009.009 0 00.006.002z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
1
surfsense_web/components/icons/providers/zhipu.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg fill="currentColor" fill-rule="evenodd" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M11.991 23.503a.24.24 0 00-.244.248.24.24 0 00.244.249.24.24 0 00.245-.249.24.24 0 00-.22-.247l-.025-.001zM9.671 5.365a1.697 1.697 0 011.099 2.132l-.071.172-.016.04-.018.054c-.07.16-.104.32-.104.498-.035.71.47 1.279 1.186 1.314h.366c1.309.053 2.338 1.173 2.286 2.523-.052 1.332-1.152 2.38-2.478 2.327h-.174c-.715.018-1.274.64-1.239 1.368 0 .124.018.23.053.337.209.373.54.658.96.8.75.23 1.517-.125 1.9-.782l.018-.035c.402-.64 1.17-.96 1.92-.711.854.284 1.378 1.226 1.099 2.167a1.661 1.661 0 01-2.077 1.102 1.711 1.711 0 01-.907-.711l-.017-.035c-.2-.323-.463-.58-.851-.711l-.056-.018a1.646 1.646 0 00-1.954.746 1.66 1.66 0 01-1.065.764 1.677 1.677 0 01-1.989-1.279c-.209-.906.332-1.83 1.257-2.043a1.51 1.51 0 01.296-.035h.018c.68-.071 1.151-.622 1.116-1.333a1.307 1.307 0 00-.227-.693 2.515 2.515 0 01-.366-1.403 2.39 2.39 0 01.366-1.208c.14-.195.21-.444.227-.693.018-.71-.506-1.261-1.186-1.332l-.07-.018a1.43 1.43 0 01-.299-.07l-.05-.019a1.7 1.7 0 01-1.047-2.114 1.68 1.68 0 012.094-1.101zm-5.575 10.11c.26-.264.639-.367.994-.27.355.096.633.379.728.74.095.362-.007.748-.267 1.013-.402.41-1.053.41-1.455 0a1.062 1.062 0 010-1.482zm14.845-.294c.359-.09.738.024.992.297.254.274.344.665.237 1.025-.107.36-.396.634-.756.718-.551.128-1.1-.22-1.23-.781a1.05 1.05 0 01.757-1.26zm-.064-4.39c.314.32.49.753.49 1.206 0 .452-.176.886-.49 1.206-.315.32-.74.5-1.185.5-.444 0-.87-.18-1.184-.5a1.727 1.727 0 010-2.412 1.654 1.654 0 012.369 0zm-11.243.163c.364.484.447 1.128.218 1.691a1.665 1.665 0 01-2.188.923c-.855-.36-1.26-1.358-.907-2.228a1.68 1.68 0 011.33-1.038c.593-.08 1.183.169 1.547.652zm11.545-4.221c.368 0 .708.2.892.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.892.524c-.568 0-1.03-.47-1.03-1.048 0-.579.462-1.048 1.03-1.048zm-14.358 0c.368 0 .707.2.891.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.891.524c-.569 0-1.03-.47-1.03-1.048 0-.579.461-1.048 1.03-1.048zm10.031-1.475c.925 0 1.675.764 1.675 1.706s-.75 1.705-1.675 1.705-1.674-.763-1.674-1.705c0-.942.75-1.706 1.674-1.706zm-2.626-.684c.362-.082.653-.356.761-.718a1.062 1.062 0 00-.238-1.028 1.017 1.017 0 00-.996-.294c-.547.14-.881.7-.752 1.257.13.558.675.907 1.225.783zm0 16.876c.359-.087.644-.36.75-.72a1.062 1.062 0 00-.237-1.019 1.018 1.018 0 00-.985-.301 1.037 1.037 0 00-.762.717c-.108.361-.017.754.239 1.028.245.263.606.377.953.305l.043-.01zM17.19 3.5a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64a.631.631 0 00-.628.64c0 .355.28.64.628.64zm-10.38 0a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64a.631.631 0 00-.628.64c0 .355.279.64.628.64zm-5.182 7.852a.631.631 0 00-.628.64c0 .354.28.639.628.639a.63.63 0 00.627-.606l.001-.034a.62.62 0 00-.628-.64zm5.182 9.13a.631.631 0 00-.628.64c0 .355.279.64.628.64a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm10.38.018a.631.631 0 00-.628.64c0 .355.28.64.628.64a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64zm5.182-9.148a.631.631 0 00-.628.64c0 .354.279.639.628.639a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm-.384-4.992a.24.24 0 00.244-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249c0 .142.122.249.244.249zM11.991.497a.24.24 0 00.245-.248A.24.24 0 0011.99 0a.24.24 0 00-.244.249c0 .133.108.236.223.247l.021.001zM2.011 6.36a.24.24 0 00.245-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249.24.24 0 00.244.249zm0 11.263a.24.24 0 00-.243.248.24.24 0 00.244.249.24.24 0 00.244-.249.252.252 0 00-.244-.248zm19.995-.018a.24.24 0 00-.245.248.24.24 0 00.245.25.24.24 0 00.244-.25.252.252 0 00-.244-.248z"></path></svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
|
|
@ -113,10 +113,6 @@ export function MobileSidebar({
|
|||
isShared={space.memberCount > 1}
|
||||
isOwner={space.isOwner}
|
||||
onClick={() => handleSearchSpaceSelect(space.id)}
|
||||
onDelete={onSearchSpaceDelete ? () => onSearchSpaceDelete(space) : undefined}
|
||||
onSettings={
|
||||
onSearchSpaceSettings ? () => onSearchSpaceSettings(space) : undefined
|
||||
}
|
||||
size="md"
|
||||
disableTooltip
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import type {
|
|||
NewLLMConfigPublic,
|
||||
} from "@/contracts/types/new-llm-config.types";
|
||||
import { ImageConfigSidebar } from "./image-config-sidebar";
|
||||
import { ImageModelSelector } from "./image-model-selector";
|
||||
import { ModelConfigSidebar } from "./model-config-sidebar";
|
||||
import { ModelSelector } from "./model-selector";
|
||||
|
||||
|
|
@ -34,7 +33,7 @@ export function ChatHeader({ searchSpaceId }: ChatHeaderProps) {
|
|||
const [imageSidebarMode, setImageSidebarMode] = useState<"create" | "edit" | "view">("view");
|
||||
|
||||
// LLM handlers
|
||||
const handleEditConfig = useCallback(
|
||||
const handleEditLLMConfig = useCallback(
|
||||
(config: NewLLMConfigPublic | GlobalNewLLMConfig, global: boolean) => {
|
||||
setSelectedConfig(config);
|
||||
setIsGlobal(global);
|
||||
|
|
@ -44,7 +43,7 @@ export function ChatHeader({ searchSpaceId }: ChatHeaderProps) {
|
|||
[]
|
||||
);
|
||||
|
||||
const handleAddNew = useCallback(() => {
|
||||
const handleAddNewLLM = useCallback(() => {
|
||||
setSelectedConfig(null);
|
||||
setIsGlobal(false);
|
||||
setSidebarMode("create");
|
||||
|
|
@ -81,8 +80,12 @@ export function ChatHeader({ searchSpaceId }: ChatHeaderProps) {
|
|||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<ModelSelector onEdit={handleEditConfig} onAddNew={handleAddNew} />
|
||||
<ImageModelSelector onEdit={handleEditImageConfig} onAddNew={handleAddImageModel} />
|
||||
<ModelSelector
|
||||
onEditLLM={handleEditLLMConfig}
|
||||
onAddNewLLM={handleAddNewLLM}
|
||||
onEditImage={handleEditImageConfig}
|
||||
onAddNewImage={handleAddImageModel}
|
||||
/>
|
||||
<ModelConfigSidebar
|
||||
open={sidebarOpen}
|
||||
onOpenChange={handleSidebarClose}
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ export function ImageConfigSidebar({
|
|||
|
||||
const getTitle = () => {
|
||||
if (mode === "create") return "Add Image Model";
|
||||
if (isAutoMode) return "Auto Mode (Load Balanced)";
|
||||
if (isAutoMode) return "Auto Mode (Fastest)";
|
||||
if (isGlobal) return "View Global Image Model";
|
||||
return "Edit Image Model";
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,361 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Edit3,
|
||||
Globe,
|
||||
ImageIcon,
|
||||
Plus,
|
||||
Shuffle,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
createImageGenConfigMutationAtom,
|
||||
updateImageGenConfigMutationAtom,
|
||||
} from "@/atoms/image-gen-config/image-gen-config-mutation.atoms";
|
||||
import {
|
||||
globalImageGenConfigsAtom,
|
||||
imageGenConfigsAtom,
|
||||
} from "@/atoms/image-gen-config/image-gen-config-query.atoms";
|
||||
import { updateLLMPreferencesMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
|
||||
import { llmPreferencesAtom } from "@/atoms/new-llm-config/new-llm-config-query.atoms";
|
||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import type {
|
||||
GlobalImageGenConfig,
|
||||
ImageGenerationConfig,
|
||||
} from "@/contracts/types/new-llm-config.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ImageModelSelectorProps {
|
||||
className?: string;
|
||||
onAddNew?: () => void;
|
||||
onEdit?: (config: ImageGenerationConfig | GlobalImageGenConfig, isGlobal: boolean) => void;
|
||||
}
|
||||
|
||||
export function ImageModelSelector({ className, onAddNew, onEdit }: ImageModelSelectorProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const { data: globalConfigs, isLoading: globalLoading } = useAtomValue(globalImageGenConfigsAtom);
|
||||
const { data: userConfigs, isLoading: userLoading } = useAtomValue(imageGenConfigsAtom);
|
||||
const { data: preferences, isLoading: prefsLoading } = useAtomValue(llmPreferencesAtom);
|
||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
|
||||
|
||||
const isLoading = globalLoading || userLoading || prefsLoading;
|
||||
|
||||
const currentConfig = useMemo(() => {
|
||||
if (!preferences) return null;
|
||||
const id = preferences.image_generation_config_id;
|
||||
if (id === null || id === undefined) return null;
|
||||
const globalMatch = globalConfigs?.find((c) => c.id === id);
|
||||
if (globalMatch) return globalMatch;
|
||||
return userConfigs?.find((c) => c.id === id) ?? null;
|
||||
}, [preferences, globalConfigs, userConfigs]);
|
||||
|
||||
const isCurrentAutoMode = useMemo(() => {
|
||||
return currentConfig && "is_auto_mode" in currentConfig && currentConfig.is_auto_mode;
|
||||
}, [currentConfig]);
|
||||
|
||||
const filteredGlobal = useMemo(() => {
|
||||
if (!globalConfigs) return [];
|
||||
if (!searchQuery) return globalConfigs;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return globalConfigs.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
c.model_name.toLowerCase().includes(q) ||
|
||||
c.provider.toLowerCase().includes(q)
|
||||
);
|
||||
}, [globalConfigs, searchQuery]);
|
||||
|
||||
const filteredUser = useMemo(() => {
|
||||
if (!userConfigs) return [];
|
||||
if (!searchQuery) return userConfigs;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return userConfigs.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
c.model_name.toLowerCase().includes(q) ||
|
||||
c.provider.toLowerCase().includes(q)
|
||||
);
|
||||
}, [userConfigs, searchQuery]);
|
||||
|
||||
const totalModels = (globalConfigs?.length ?? 0) + (userConfigs?.length ?? 0);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
async (configId: number) => {
|
||||
if (currentConfig?.id === configId) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
if (!searchSpaceId) {
|
||||
toast.error("No search space selected");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updatePreferences({
|
||||
search_space_id: Number(searchSpaceId),
|
||||
data: { image_generation_config_id: configId },
|
||||
});
|
||||
toast.success("Image model updated");
|
||||
setOpen(false);
|
||||
} catch {
|
||||
toast.error("Failed to switch image model");
|
||||
}
|
||||
},
|
||||
[currentConfig, searchSpaceId, updatePreferences]
|
||||
);
|
||||
|
||||
// Don't render if no configs at all
|
||||
if (!isLoading && totalModels === 0) {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onAddNew}
|
||||
className={cn("h-8 gap-2 px-3 text-sm border-border/60", className)}
|
||||
>
|
||||
<Plus className="size-4 text-teal-600" />
|
||||
<span className="hidden md:inline">Add Image Model</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn("h-8 gap-2 px-3 text-sm border-border/60", className)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Spinner size="sm" className="text-muted-foreground" />
|
||||
) : currentConfig ? (
|
||||
<>
|
||||
{isCurrentAutoMode ? (
|
||||
<Shuffle className="size-4 text-violet-500" />
|
||||
) : (
|
||||
<ImageIcon className="size-4 text-teal-500" />
|
||||
)}
|
||||
<span className="max-w-[100px] md:max-w-[120px] truncate hidden md:inline">
|
||||
{currentConfig.name}
|
||||
</span>
|
||||
{isCurrentAutoMode ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="ml-1 text-[10px] px-1.5 py-0 h-4 bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300"
|
||||
>
|
||||
Auto
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="ml-1 text-[10px] px-1.5 py-0 h-4 bg-teal-50 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300"
|
||||
>
|
||||
Image
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ImageIcon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground hidden md:inline">Image Model</span>
|
||||
</>
|
||||
)}
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 text-muted-foreground ml-1 shrink-0 transition-transform duration-200",
|
||||
open && "rotate-180"
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
className="w-[280px] md:w-[360px] p-0 rounded-lg shadow-lg border-border/60"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
>
|
||||
<Command shouldFilter={false} className="rounded-lg">
|
||||
{totalModels > 3 && (
|
||||
<div className="flex items-center gap-1 md:gap-2 px-2 md:px-3 py-1.5 md:py-2">
|
||||
<CommandInput
|
||||
placeholder="Search image models..."
|
||||
value={searchQuery}
|
||||
onValueChange={setSearchQuery}
|
||||
className="h-7 md:h-8 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">
|
||||
<CommandEmpty className="py-8 text-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ImageIcon className="size-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">No image models found</p>
|
||||
</div>
|
||||
</CommandEmpty>
|
||||
|
||||
{/* Global Image Gen Configs */}
|
||||
{filteredGlobal.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className="flex items-center gap-2 px-3 py-2 text-xs font-semibold text-muted-foreground tracking-wider">
|
||||
<Globe className="size-3.5" />
|
||||
Global Image Models
|
||||
</div>
|
||||
{filteredGlobal.map((config) => {
|
||||
const isSelected = currentConfig?.id === config.id;
|
||||
const isAuto = "is_auto_mode" in config && config.is_auto_mode;
|
||||
return (
|
||||
<CommandItem
|
||||
key={`g-${config.id}`}
|
||||
value={`g-${config.id}`}
|
||||
onSelect={() => handleSelect(config.id)}
|
||||
className={cn(
|
||||
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50",
|
||||
isSelected && "bg-accent/80",
|
||||
isAuto && "border border-violet-200 dark:border-violet-800/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className="shrink-0">
|
||||
{isAuto ? (
|
||||
<Shuffle className="size-4 text-violet-500" />
|
||||
) : (
|
||||
<ImageIcon className="size-4 text-teal-500" />
|
||||
)}
|
||||
</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-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300 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 load balancing" : config.model_name}
|
||||
</span>
|
||||
</div>
|
||||
{onEdit && (
|
||||
<ChevronRight
|
||||
className="size-3.5 text-muted-foreground shrink-0 opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
onEdit(config, true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* User Image Gen Configs */}
|
||||
{filteredUser.length > 0 && (
|
||||
<>
|
||||
{filteredGlobal.length > 0 && <CommandSeparator className="my-1 bg-border/30" />}
|
||||
<CommandGroup>
|
||||
<div className="flex items-center gap-2 px-3 py-2 text-xs font-semibold text-muted-foreground tracking-wider">
|
||||
<User className="size-3.5" />
|
||||
Your Image Models
|
||||
</div>
|
||||
{filteredUser.map((config) => {
|
||||
const isSelected = currentConfig?.id === config.id;
|
||||
return (
|
||||
<CommandItem
|
||||
key={`u-${config.id}`}
|
||||
value={`u-${config.id}`}
|
||||
onSelect={() => handleSelect(config.id)}
|
||||
className={cn(
|
||||
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50",
|
||||
isSelected && "bg-accent/80"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className="shrink-0">
|
||||
<ImageIcon className="size-4 text-teal-500" />
|
||||
</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>
|
||||
{onEdit && (
|
||||
<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);
|
||||
onEdit(config, false);
|
||||
}}
|
||||
>
|
||||
<Edit3 className="size-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Add New */}
|
||||
{onAddNew && (
|
||||
<div className="p-2 bg-muted/20">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-2 h-9 rounded-lg hover:bg-accent/50"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onAddNew();
|
||||
}}
|
||||
>
|
||||
<Plus className="size-4 text-teal-600" />
|
||||
<span className="text-sm font-medium">Add Image Model</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
|
@ -68,7 +68,7 @@ export function ModelConfigSidebar({
|
|||
// Get title based on mode
|
||||
const getTitle = () => {
|
||||
if (mode === "create") return "Add New Configuration";
|
||||
if (isAutoMode) return "Auto Mode (Load Balanced)";
|
||||
if (isAutoMode) return "Auto Mode (Fastest)";
|
||||
if (isGlobal) return "View Global Configuration";
|
||||
return "Edit Configuration";
|
||||
};
|
||||
|
|
@ -307,7 +307,7 @@ export function ModelConfigSidebar({
|
|||
<Zap className="size-4 text-violet-600 dark:text-violet-400 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-violet-900 dark:text-violet-100">
|
||||
Automatic Load Balancing
|
||||
Automatic (Fastest)
|
||||
</p>
|
||||
<p className="text-xs text-violet-700 dark:text-violet-300">
|
||||
Distributes requests across all configured LLM providers
|
||||
|
|
|
|||
|
|
@ -1,22 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import {
|
||||
Bot,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Cloud,
|
||||
Edit3,
|
||||
Globe,
|
||||
Plus,
|
||||
Settings2,
|
||||
Shuffle,
|
||||
Sparkles,
|
||||
User,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { Bot, Check, ChevronDown, Edit3, ImageIcon, Plus, Zap } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
globalImageGenConfigsAtom,
|
||||
imageGenConfigsAtom,
|
||||
} from "@/atoms/image-gen-config/image-gen-config-query.atoms";
|
||||
import { updateLLMPreferencesMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
|
||||
import {
|
||||
globalNewLLMConfigsAtom,
|
||||
|
|
@ -37,128 +28,152 @@ import {
|
|||
} from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import type {
|
||||
GlobalImageGenConfig,
|
||||
GlobalNewLLMConfig,
|
||||
ImageGenerationConfig,
|
||||
NewLLMConfigPublic,
|
||||
} from "@/contracts/types/new-llm-config.types";
|
||||
import { getProviderIcon } from "@/lib/provider-icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Provider icons mapping
|
||||
const getProviderIcon = (provider: string, isAutoMode?: boolean) => {
|
||||
const iconClass = "size-4";
|
||||
|
||||
// Special icon for Auto mode
|
||||
if (isAutoMode || provider?.toUpperCase() === "AUTO") {
|
||||
return <Shuffle className={cn(iconClass, "text-violet-500")} />;
|
||||
}
|
||||
|
||||
switch (provider?.toUpperCase()) {
|
||||
case "OPENAI":
|
||||
return <Sparkles className={cn(iconClass, "text-emerald-500")} />;
|
||||
case "ANTHROPIC":
|
||||
return <Bot className={cn(iconClass, "text-amber-600")} />;
|
||||
case "GOOGLE":
|
||||
return <Cloud className={cn(iconClass, "text-blue-500")} />;
|
||||
case "GROQ":
|
||||
return <Zap className={cn(iconClass, "text-orange-500")} />;
|
||||
case "OLLAMA":
|
||||
return <Settings2 className={cn(iconClass, "text-gray-500")} />;
|
||||
case "XAI":
|
||||
return <Bot className={cn(iconClass, "text-violet-500")} />;
|
||||
default:
|
||||
return <Bot className={cn(iconClass, "text-muted-foreground")} />;
|
||||
}
|
||||
};
|
||||
|
||||
interface ModelSelectorProps {
|
||||
onEdit: (config: NewLLMConfigPublic | GlobalNewLLMConfig, isGlobal: boolean) => void;
|
||||
onAddNew: () => void;
|
||||
onEditLLM: (config: NewLLMConfigPublic | GlobalNewLLMConfig, isGlobal: boolean) => void;
|
||||
onAddNewLLM: () => void;
|
||||
onEditImage?: (config: ImageGenerationConfig | GlobalImageGenConfig, isGlobal: boolean) => void;
|
||||
onAddNewImage?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProps) {
|
||||
export function ModelSelector({
|
||||
onEditLLM,
|
||||
onAddNewLLM,
|
||||
onEditImage,
|
||||
onAddNewImage,
|
||||
className,
|
||||
}: ModelSelectorProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [activeTab, setActiveTab] = useState<"llm" | "image">("llm");
|
||||
const [llmSearchQuery, setLlmSearchQuery] = useState("");
|
||||
const [imageSearchQuery, setImageSearchQuery] = useState("");
|
||||
|
||||
// Fetch configs
|
||||
const { data: userConfigs, isLoading: userConfigsLoading } = useAtomValue(newLLMConfigsAtom);
|
||||
const { data: globalConfigs, isLoading: globalConfigsLoading } =
|
||||
// LLM data
|
||||
const { data: llmUserConfigs, isLoading: llmUserLoading } = useAtomValue(newLLMConfigsAtom);
|
||||
const { data: llmGlobalConfigs, isLoading: llmGlobalLoading } =
|
||||
useAtomValue(globalNewLLMConfigsAtom);
|
||||
const { data: preferences, isLoading: preferencesLoading } = useAtomValue(llmPreferencesAtom);
|
||||
const { data: preferences, isLoading: prefsLoading } = useAtomValue(llmPreferencesAtom);
|
||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
|
||||
|
||||
const isLoading = userConfigsLoading || globalConfigsLoading || preferencesLoading;
|
||||
// Image data
|
||||
const { data: imageGlobalConfigs, isLoading: imageGlobalLoading } =
|
||||
useAtomValue(globalImageGenConfigsAtom);
|
||||
const { data: imageUserConfigs, isLoading: imageUserLoading } = useAtomValue(imageGenConfigsAtom);
|
||||
|
||||
// Get current agent LLM config
|
||||
const currentConfig = useMemo(() => {
|
||||
const isLoading =
|
||||
llmUserLoading || llmGlobalLoading || prefsLoading || imageGlobalLoading || imageUserLoading;
|
||||
|
||||
// ─── LLM current config ───
|
||||
const currentLLMConfig = useMemo(() => {
|
||||
if (!preferences) return null;
|
||||
|
||||
const agentLlmId = preferences.agent_llm_id;
|
||||
if (agentLlmId === null || agentLlmId === undefined) return null;
|
||||
|
||||
// Check if it's Auto mode (ID 0) or global config (negative ID)
|
||||
if (agentLlmId <= 0) {
|
||||
return globalConfigs?.find((c) => c.id === agentLlmId) ?? null;
|
||||
return llmGlobalConfigs?.find((c) => c.id === agentLlmId) ?? null;
|
||||
}
|
||||
// Otherwise, check user configs
|
||||
return userConfigs?.find((c) => c.id === agentLlmId) ?? null;
|
||||
}, [preferences, globalConfigs, userConfigs]);
|
||||
return llmUserConfigs?.find((c) => c.id === agentLlmId) ?? null;
|
||||
}, [preferences, llmGlobalConfigs, llmUserConfigs]);
|
||||
|
||||
// Check if current config is Auto mode
|
||||
const isCurrentAutoMode = useMemo(() => {
|
||||
return currentConfig && "is_auto_mode" in currentConfig && currentConfig.is_auto_mode;
|
||||
}, [currentConfig]);
|
||||
const isLLMAutoMode = useMemo(() => {
|
||||
return currentLLMConfig && "is_auto_mode" in currentLLMConfig && currentLLMConfig.is_auto_mode;
|
||||
}, [currentLLMConfig]);
|
||||
|
||||
// Filter configs based on search
|
||||
const filteredGlobalConfigs = useMemo(() => {
|
||||
if (!globalConfigs) return [];
|
||||
if (!searchQuery) return globalConfigs;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return globalConfigs.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(query) ||
|
||||
c.model_name.toLowerCase().includes(query) ||
|
||||
c.provider.toLowerCase().includes(query)
|
||||
// ─── Image current config ───
|
||||
const currentImageConfig = useMemo(() => {
|
||||
if (!preferences) return null;
|
||||
const id = preferences.image_generation_config_id;
|
||||
if (id === null || id === undefined) return null;
|
||||
const globalMatch = imageGlobalConfigs?.find((c) => c.id === id);
|
||||
if (globalMatch) return globalMatch;
|
||||
return imageUserConfigs?.find((c) => c.id === id) ?? null;
|
||||
}, [preferences, imageGlobalConfigs, imageUserConfigs]);
|
||||
|
||||
const isImageAutoMode = useMemo(() => {
|
||||
return (
|
||||
currentImageConfig && "is_auto_mode" in currentImageConfig && currentImageConfig.is_auto_mode
|
||||
);
|
||||
}, [globalConfigs, searchQuery]);
|
||||
}, [currentImageConfig]);
|
||||
|
||||
const filteredUserConfigs = useMemo(() => {
|
||||
if (!userConfigs) return [];
|
||||
if (!searchQuery) return userConfigs;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return userConfigs.filter(
|
||||
// ─── LLM filtering ───
|
||||
const filteredLLMGlobal = useMemo(() => {
|
||||
if (!llmGlobalConfigs) return [];
|
||||
if (!llmSearchQuery) return llmGlobalConfigs;
|
||||
const q = llmSearchQuery.toLowerCase();
|
||||
return llmGlobalConfigs.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(query) ||
|
||||
c.model_name.toLowerCase().includes(query) ||
|
||||
c.provider.toLowerCase().includes(query)
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
c.model_name.toLowerCase().includes(q) ||
|
||||
c.provider.toLowerCase().includes(q)
|
||||
);
|
||||
}, [userConfigs, searchQuery]);
|
||||
}, [llmGlobalConfigs, llmSearchQuery]);
|
||||
|
||||
// Total model count for conditional search display
|
||||
const totalModels = useMemo(() => {
|
||||
return (globalConfigs?.length ?? 0) + (userConfigs?.length ?? 0);
|
||||
}, [globalConfigs, userConfigs]);
|
||||
const filteredLLMUser = useMemo(() => {
|
||||
if (!llmUserConfigs) return [];
|
||||
if (!llmSearchQuery) return llmUserConfigs;
|
||||
const q = llmSearchQuery.toLowerCase();
|
||||
return llmUserConfigs.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
c.model_name.toLowerCase().includes(q) ||
|
||||
c.provider.toLowerCase().includes(q)
|
||||
);
|
||||
}, [llmUserConfigs, llmSearchQuery]);
|
||||
|
||||
const handleSelectConfig = useCallback(
|
||||
const totalLLMModels = (llmGlobalConfigs?.length ?? 0) + (llmUserConfigs?.length ?? 0);
|
||||
|
||||
// ─── Image filtering ───
|
||||
const filteredImageGlobal = useMemo(() => {
|
||||
if (!imageGlobalConfigs) return [];
|
||||
if (!imageSearchQuery) return imageGlobalConfigs;
|
||||
const q = imageSearchQuery.toLowerCase();
|
||||
return imageGlobalConfigs.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
c.model_name.toLowerCase().includes(q) ||
|
||||
c.provider.toLowerCase().includes(q)
|
||||
);
|
||||
}, [imageGlobalConfigs, imageSearchQuery]);
|
||||
|
||||
const filteredImageUser = useMemo(() => {
|
||||
if (!imageUserConfigs) return [];
|
||||
if (!imageSearchQuery) return imageUserConfigs;
|
||||
const q = imageSearchQuery.toLowerCase();
|
||||
return imageUserConfigs.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
c.model_name.toLowerCase().includes(q) ||
|
||||
c.provider.toLowerCase().includes(q)
|
||||
);
|
||||
}, [imageUserConfigs, imageSearchQuery]);
|
||||
|
||||
const totalImageModels = (imageGlobalConfigs?.length ?? 0) + (imageUserConfigs?.length ?? 0);
|
||||
|
||||
// ─── Handlers ───
|
||||
const handleSelectLLM = useCallback(
|
||||
async (config: NewLLMConfigPublic | GlobalNewLLMConfig) => {
|
||||
// If already selected, just close
|
||||
if (currentConfig?.id === config.id) {
|
||||
if (currentLLMConfig?.id === config.id) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!searchSpaceId) {
|
||||
toast.error("No search space selected");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updatePreferences({
|
||||
search_space_id: Number(searchSpaceId),
|
||||
data: {
|
||||
agent_llm_id: config.id,
|
||||
},
|
||||
data: { agent_llm_id: config.id },
|
||||
});
|
||||
toast.success(`Switched to ${config.name}`);
|
||||
setOpen(false);
|
||||
|
|
@ -167,16 +182,40 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
|
|||
toast.error("Failed to switch model");
|
||||
}
|
||||
},
|
||||
[currentConfig, searchSpaceId, updatePreferences]
|
||||
[currentLLMConfig, searchSpaceId, updatePreferences]
|
||||
);
|
||||
|
||||
const handleEditConfig = useCallback(
|
||||
const handleEditLLMConfig = useCallback(
|
||||
(e: React.MouseEvent, config: NewLLMConfigPublic | GlobalNewLLMConfig, isGlobal: boolean) => {
|
||||
e.stopPropagation();
|
||||
onEdit(config, isGlobal);
|
||||
onEditLLM(config, isGlobal);
|
||||
setOpen(false);
|
||||
},
|
||||
[onEdit]
|
||||
[onEditLLM]
|
||||
);
|
||||
|
||||
const handleSelectImage = useCallback(
|
||||
async (configId: number) => {
|
||||
if (currentImageConfig?.id === configId) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
if (!searchSpaceId) {
|
||||
toast.error("No search space selected");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updatePreferences({
|
||||
search_space_id: Number(searchSpaceId),
|
||||
data: { image_generation_config_id: configId },
|
||||
});
|
||||
toast.success("Image model updated");
|
||||
setOpen(false);
|
||||
} catch {
|
||||
toast.error("Failed to switch image model");
|
||||
}
|
||||
},
|
||||
[currentImageConfig, searchSpaceId, updatePreferences]
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -194,30 +233,41 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
|
|||
<Spinner size="sm" className="text-muted-foreground" />
|
||||
<span className="text-muted-foreground hidden md:inline">Loading</span>
|
||||
</>
|
||||
) : currentConfig ? (
|
||||
<>
|
||||
{getProviderIcon(currentConfig.provider, isCurrentAutoMode ?? false)}
|
||||
<span className="max-w-[100px] md:max-w-[150px] truncate hidden md:inline">
|
||||
{currentConfig.name}
|
||||
</span>
|
||||
{isCurrentAutoMode ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="ml-1 text-[10px] px-1.5 py-0 h-4 bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300"
|
||||
>
|
||||
Balanced
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="ml-1 text-[10px] px-1.5 py-0 h-4 bg-muted/80">
|
||||
{currentConfig.model_name.split("/").pop()?.slice(0, 10) ||
|
||||
currentConfig.model_name.slice(0, 10)}
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Bot className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground hidden md:inline">Select Model</span>
|
||||
{/* LLM section */}
|
||||
{currentLLMConfig ? (
|
||||
<>
|
||||
{getProviderIcon(currentLLMConfig.provider, {
|
||||
isAutoMode: isLLMAutoMode ?? false,
|
||||
})}
|
||||
<span className="max-w-[100px] md:max-w-[120px] truncate hidden md:inline">
|
||||
{currentLLMConfig.name}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Bot className="size-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground hidden md:inline">Select Model</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-4 w-px bg-border/60 mx-0.5" />
|
||||
|
||||
{/* Image section */}
|
||||
{currentImageConfig ? (
|
||||
<>
|
||||
{getProviderIcon(currentImageConfig.provider, {
|
||||
isAutoMode: isImageAutoMode ?? false,
|
||||
})}
|
||||
<span className="max-w-[80px] md:max-w-[100px] truncate hidden md:inline">
|
||||
{currentImageConfig.name}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<ImageIcon className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<ChevronDown
|
||||
|
|
@ -234,181 +284,375 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
|
|||
align="start"
|
||||
sideOffset={8}
|
||||
>
|
||||
<Command
|
||||
shouldFilter={false}
|
||||
className="rounded-lg relative [&_[data-slot=command-input-wrapper]]:border-0 [&_[data-slot=command-input-wrapper]]:px-0 [&_[data-slot=command-input-wrapper]]:gap-2"
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as "llm" | "image")}
|
||||
className="w-full"
|
||||
>
|
||||
{totalModels > 3 && (
|
||||
<div className="flex items-center gap-1 md:gap-2 px-2 md:px-3 py-1.5 md:py-2">
|
||||
<CommandInput
|
||||
placeholder="Search models"
|
||||
value={searchQuery}
|
||||
onValueChange={setSearchQuery}
|
||||
className="h-7 md:h-8 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">
|
||||
<CommandEmpty className="py-8 text-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Bot className="size-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">No models found</p>
|
||||
<p className="text-xs text-muted-foreground/60">Try a different search term</p>
|
||||
</div>
|
||||
</CommandEmpty>
|
||||
|
||||
{/* Global Configs Section */}
|
||||
{filteredGlobalConfigs.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className="flex items-center gap-2 px-3 py-2 text-xs font-semibold text-muted-foreground tracking-wider">
|
||||
<Globe className="size-3.5" />
|
||||
Global Models
|
||||
</div>
|
||||
{filteredGlobalConfigs.map((config) => {
|
||||
const isSelected = currentConfig?.id === config.id;
|
||||
const isAutoMode = "is_auto_mode" in config && config.is_auto_mode;
|
||||
return (
|
||||
<CommandItem
|
||||
key={`global-${config.id}`}
|
||||
value={`global-${config.id}`}
|
||||
onSelect={() => handleSelectConfig(config)}
|
||||
className={cn(
|
||||
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all",
|
||||
"hover:bg-accent/50",
|
||||
isSelected && "bg-accent/80",
|
||||
isAutoMode && "border border-violet-200 dark:border-violet-800/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full gap-2">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className="shrink-0">
|
||||
{getProviderIcon(config.provider, isAutoMode)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium truncate">{config.name}</span>
|
||||
{isAutoMode && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[9px] px-1 py-0 h-3.5 bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300 border-0"
|
||||
>
|
||||
Recommended
|
||||
</Badge>
|
||||
)}
|
||||
{isSelected && <Check className="size-3.5 text-primary shrink-0" />}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{isAutoMode ? "Auto load balancing" : config.model_name}
|
||||
</span>
|
||||
{!isAutoMode && config.citations_enabled && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[9px] px-1 py-0 h-3.5 bg-primary/10 text-primary border-primary/20"
|
||||
>
|
||||
Citations
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!isAutoMode && (
|
||||
<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) => handleEditConfig(e, config, true)}
|
||||
>
|
||||
<Edit3 className="size-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{filteredGlobalConfigs.length > 0 && filteredUserConfigs.length > 0 && (
|
||||
<CommandSeparator className="my-1 bg-border/30" />
|
||||
)}
|
||||
|
||||
{/* User Configs Section */}
|
||||
{filteredUserConfigs.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className="flex items-center gap-2 px-3 py-2 text-xs font-semibold text-muted-foreground tracking-wider">
|
||||
<User className="size-3.5" />
|
||||
Your Configurations
|
||||
</div>
|
||||
{filteredUserConfigs.map((config) => {
|
||||
const isSelected = currentConfig?.id === config.id;
|
||||
return (
|
||||
<CommandItem
|
||||
key={`user-${config.id}`}
|
||||
value={`user-${config.id}`}
|
||||
onSelect={() => handleSelectConfig(config)}
|
||||
className={cn(
|
||||
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all",
|
||||
"hover:bg-accent/50",
|
||||
isSelected && "bg-accent/80"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full gap-2">
|
||||
<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>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{config.model_name}
|
||||
</span>
|
||||
{config.citations_enabled && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[9px] px-1 py-0 h-3.5 bg-primary/10 text-primary border-primary/20"
|
||||
>
|
||||
Citations
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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) => handleEditConfig(e, config, false)}
|
||||
>
|
||||
<Edit3 className="size-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Add New Config Button */}
|
||||
<div className="p-2 bg-muted/20">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-2 h-9 rounded-lg hover:bg-accent/50"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onAddNew();
|
||||
}}
|
||||
<div className="border-b border-border/40">
|
||||
<TabsList className="w-full grid grid-cols-2 rounded-none rounded-t-lg bg-card h-11 p-0 gap-0">
|
||||
<TabsTrigger
|
||||
value="llm"
|
||||
className="relative gap-2 text-sm font-medium rounded-none text-muted-foreground/60 transition-all duration-200 h-full data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none data-[state=active]:after:absolute data-[state=active]:after:bottom-0 data-[state=active]:after:left-3 data-[state=active]:after:right-3 data-[state=active]:after:h-[2px] data-[state=active]:after:bg-white data-[state=active]:after:rounded-full"
|
||||
>
|
||||
<Plus className="size-4 text-primary" />
|
||||
<span className="text-sm font-medium">Add New Configuration</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CommandList>
|
||||
</Command>
|
||||
<Zap className="size-4" />
|
||||
LLM
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="image"
|
||||
className="relative gap-2 text-sm font-medium rounded-none text-muted-foreground/60 transition-all duration-200 h-full data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none data-[state=active]:after:absolute data-[state=active]:after:bottom-0 data-[state=active]:after:left-3 data-[state=active]:after:right-3 data-[state=active]:after:h-[2px] data-[state=active]:after:bg-white data-[state=active]:after:rounded-full"
|
||||
>
|
||||
<ImageIcon className="size-4" />
|
||||
Image
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{/* ─── LLM Tab ─── */}
|
||||
<TabsContent value="llm" className="mt-0">
|
||||
<Command
|
||||
shouldFilter={false}
|
||||
className="rounded-none rounded-b-lg relative [&_[data-slot=command-input-wrapper]]:border-0 [&_[data-slot=command-input-wrapper]]:px-0 [&_[data-slot=command-input-wrapper]]:gap-2"
|
||||
>
|
||||
{totalLLMModels > 3 && (
|
||||
<div className="px-2 md:px-3 py-1.5 md:py-2">
|
||||
<CommandInput
|
||||
placeholder="Search models"
|
||||
value={llmSearchQuery}
|
||||
onValueChange={setLlmSearchQuery}
|
||||
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">
|
||||
<CommandEmpty className="py-8 text-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Bot className="size-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">No models found</p>
|
||||
<p className="text-xs text-muted-foreground/60">Try a different search term</p>
|
||||
</div>
|
||||
</CommandEmpty>
|
||||
|
||||
{/* Global LLM Configs */}
|
||||
{filteredLLMGlobal.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className="flex items-center gap-2 px-3 py-2 text-xs font-semibold text-muted-foreground tracking-wider">
|
||||
Global Models
|
||||
</div>
|
||||
{filteredLLMGlobal.map((config) => {
|
||||
const isSelected = currentLLMConfig?.id === config.id;
|
||||
const isAutoMode = "is_auto_mode" in config && config.is_auto_mode;
|
||||
return (
|
||||
<CommandItem
|
||||
key={`llm-g-${config.id}`}
|
||||
value={`llm-g-${config.id}`}
|
||||
onSelect={() => handleSelectLLM(config)}
|
||||
className={cn(
|
||||
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all",
|
||||
"hover:bg-accent/50",
|
||||
isSelected && "bg-accent/80",
|
||||
isAutoMode && "border border-violet-800"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full gap-2">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className="shrink-0">
|
||||
{getProviderIcon(config.provider, { isAutoMode })}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium truncate">{config.name}</span>
|
||||
{isAutoMode && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[9px] px-1 py-0 h-3.5 bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-300 border-0"
|
||||
>
|
||||
Recommended
|
||||
</Badge>
|
||||
)}
|
||||
{isSelected && (
|
||||
<Check className="size-3.5 text-primary shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{isAutoMode ? "Auto Mode" : config.model_name}
|
||||
</span>
|
||||
{!isAutoMode && config.citations_enabled && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[9px] px-1 py-0 h-3.5 bg-primary/10 text-primary border-primary/20"
|
||||
>
|
||||
Citations
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!isAutoMode && (
|
||||
<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) => handleEditLLMConfig(e, config, true)}
|
||||
>
|
||||
<Edit3 className="size-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{filteredLLMGlobal.length > 0 && filteredLLMUser.length > 0 && (
|
||||
<CommandSeparator className="my-1 mx-4 bg-border/60" />
|
||||
)}
|
||||
|
||||
{/* User LLM Configs */}
|
||||
{filteredLLMUser.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className="flex items-center gap-2 px-3 py-2 text-xs font-semibold text-muted-foreground tracking-wider">
|
||||
Your Configurations
|
||||
</div>
|
||||
{filteredLLMUser.map((config) => {
|
||||
const isSelected = currentLLMConfig?.id === config.id;
|
||||
return (
|
||||
<CommandItem
|
||||
key={`llm-u-${config.id}`}
|
||||
value={`llm-u-${config.id}`}
|
||||
onSelect={() => handleSelectLLM(config)}
|
||||
className={cn(
|
||||
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all",
|
||||
"hover:bg-accent/50",
|
||||
isSelected && "bg-accent/80"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full gap-2">
|
||||
<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>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{config.model_name}
|
||||
</span>
|
||||
{config.citations_enabled && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[9px] px-1 py-0 h-3.5 bg-primary/10 text-primary border-primary/20"
|
||||
>
|
||||
Citations
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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) => handleEditLLMConfig(e, config, false)}
|
||||
>
|
||||
<Edit3 className="size-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Add New LLM Config */}
|
||||
<div className="p-2 bg-muted/20">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-2 h-9 rounded-lg hover:bg-accent/50"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onAddNewLLM();
|
||||
}}
|
||||
>
|
||||
<Plus className="size-4 text-primary" />
|
||||
<span className="text-sm font-medium">Add New Configuration</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</TabsContent>
|
||||
|
||||
{/* ─── Image Tab ─── */}
|
||||
<TabsContent value="image" className="mt-0">
|
||||
<Command
|
||||
shouldFilter={false}
|
||||
className="rounded-none rounded-b-lg [&_[data-slot=command-input-wrapper]]:border-0 [&_[data-slot=command-input-wrapper]]:px-0 [&_[data-slot=command-input-wrapper]]:gap-2"
|
||||
>
|
||||
{totalImageModels > 3 && (
|
||||
<div className="px-2 md:px-3 py-1.5 md:py-2">
|
||||
<CommandInput
|
||||
placeholder="Search models"
|
||||
value={imageSearchQuery}
|
||||
onValueChange={setImageSearchQuery}
|
||||
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">
|
||||
<CommandEmpty className="py-8 text-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ImageIcon className="size-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">No image models found</p>
|
||||
</div>
|
||||
</CommandEmpty>
|
||||
|
||||
{/* Global Image Configs */}
|
||||
{filteredImageGlobal.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className="flex items-center gap-2 px-3 py-2 text-xs font-semibold text-muted-foreground tracking-wider">
|
||||
Global Image Models
|
||||
</div>
|
||||
{filteredImageGlobal.map((config) => {
|
||||
const isSelected = currentImageConfig?.id === config.id;
|
||||
const isAuto = "is_auto_mode" in config && config.is_auto_mode;
|
||||
return (
|
||||
<CommandItem
|
||||
key={`img-g-${config.id}`}
|
||||
value={`img-g-${config.id}`}
|
||||
onSelect={() => handleSelectImage(config.id)}
|
||||
className={cn(
|
||||
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50",
|
||||
isSelected && "bg-accent/80",
|
||||
isAuto && "border border-violet-800"
|
||||
)}
|
||||
>
|
||||
<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-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-300 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>
|
||||
{onEditImage && !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);
|
||||
onEditImage(config, true);
|
||||
}}
|
||||
>
|
||||
<Edit3 className="size-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* User Image Configs */}
|
||||
{filteredImageUser.length > 0 && (
|
||||
<>
|
||||
{filteredImageGlobal.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 Image Models
|
||||
</div>
|
||||
{filteredImageUser.map((config) => {
|
||||
const isSelected = currentImageConfig?.id === config.id;
|
||||
return (
|
||||
<CommandItem
|
||||
key={`img-u-${config.id}`}
|
||||
value={`img-u-${config.id}`}
|
||||
onSelect={() => handleSelectImage(config.id)}
|
||||
className={cn(
|
||||
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50",
|
||||
isSelected && "bg-accent/80"
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
{onEditImage && (
|
||||
<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);
|
||||
onEditImage(config, false);
|
||||
}}
|
||||
>
|
||||
<Edit3 className="size-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Add New Image Config */}
|
||||
{onAddNewImage && (
|
||||
<div className="p-2 bg-muted/20">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-2 h-9 rounded-lg hover:bg-accent/50"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onAddNewImage();
|
||||
}}
|
||||
>
|
||||
<Plus className="size-4 text-primary" />
|
||||
<span className="text-sm font-medium">Add Image Model</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,15 +1,29 @@
|
|||
"use client";
|
||||
|
||||
import { Copy, MessageSquare, Trash2 } from "lucide-react";
|
||||
import { Check, Copy, ExternalLink, MessageSquare, Trash2 } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import type { PublicChatSnapshotDetail } from "@/contracts/types/chat-threads.types";
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
interface PublicChatSnapshotRowProps {
|
||||
snapshot: PublicChatSnapshotDetail;
|
||||
canDelete: boolean;
|
||||
onCopy: (snapshot: PublicChatSnapshotDetail) => void;
|
||||
onDelete: (snapshot: PublicChatSnapshotDetail) => void;
|
||||
isDeleting?: boolean;
|
||||
memberMap: Map<string, { name: string; email?: string; avatarUrl?: string }>;
|
||||
}
|
||||
|
||||
export function PublicChatSnapshotRow({
|
||||
|
|
@ -18,57 +32,155 @@ export function PublicChatSnapshotRow({
|
|||
onCopy,
|
||||
onDelete,
|
||||
isDeleting = false,
|
||||
memberMap,
|
||||
}: PublicChatSnapshotRowProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const handleCopyClick = useCallback(() => {
|
||||
onCopy(snapshot);
|
||||
setCopied(true);
|
||||
clearTimeout(copyTimeoutRef.current);
|
||||
copyTimeoutRef.current = setTimeout(() => setCopied(false), 2000);
|
||||
}, [onCopy, snapshot]);
|
||||
|
||||
const formattedDate = new Date(snapshot.created_at).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
const member = snapshot.created_by_user_id ? memberMap.get(snapshot.created_by_user_id) : null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between py-3 px-4 border-b last:border-b-0 hover:bg-muted/50 transition-colors">
|
||||
<div className="flex-1 min-w-0 mr-4">
|
||||
<h4 className="text-sm font-medium truncate" title={snapshot.thread_title}>
|
||||
{snapshot.thread_title}
|
||||
</h4>
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
|
||||
<span>{formattedDate}</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
{snapshot.message_count}
|
||||
</span>
|
||||
<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">
|
||||
{/* Header: Title + Actions */}
|
||||
<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"
|
||||
title={snapshot.thread_title}
|
||||
>
|
||||
{snapshot.thread_title}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 shrink-0 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity duration-150">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
asChild
|
||||
className="h-7 w-7 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<a href={snapshot.public_url} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Open link</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{canDelete && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onDelete(snapshot)}
|
||||
disabled={isDeleting}
|
||||
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>
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={snapshot.public_url}
|
||||
className="mt-2 w-full text-xs text-muted-foreground bg-muted/50 border rounded px-2 py-1 select-all focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onCopy(snapshot)}
|
||||
className="h-8 px-2"
|
||||
title="Copy link"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
{canDelete && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onDelete(snapshot)}
|
||||
disabled={isDeleting}
|
||||
className="h-8 px-2 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
title="Delete link"
|
||||
|
||||
{/* Message count badge */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] px-1.5 py-0.5 border-muted-foreground/20 text-muted-foreground"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<MessageSquare className="h-2.5 w-2.5 mr-1" />
|
||||
{snapshot.message_count} messages
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Public URL – selectable fallback for manual copy */}
|
||||
<div className="flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-2.5 py-1.5">
|
||||
<p
|
||||
className="min-w-0 flex-1 text-[10px] font-mono text-muted-foreground break-all select-all cursor-text"
|
||||
title={snapshot.public_url}
|
||||
>
|
||||
{snapshot.public_url}
|
||||
</p>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleCopyClick}
|
||||
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3 w-3 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{copied ? "Copied!" : "Copy link"}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
{/* Footer: Date + Creator */}
|
||||
<div className="flex items-center gap-2 pt-2 border-t border-border/40 mt-auto">
|
||||
<span className="text-[11px] text-muted-foreground/60">{formattedDate}</span>
|
||||
{member && (
|
||||
<>
|
||||
<span className="text-muted-foreground/30">·</span>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-1.5 cursor-default">
|
||||
{member.avatarUrl ? (
|
||||
<Image
|
||||
src={member.avatarUrl}
|
||||
alt={member.name}
|
||||
width={18}
|
||||
height={18}
|
||||
className="h-4.5 w-4.5 rounded-full object-cover shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-4.5 w-4.5 items-center justify-center rounded-full bg-gradient-to-br from-primary/20 to-primary/5 shrink-0">
|
||||
<span className="text-[9px] font-semibold text-primary">
|
||||
{getInitials(member.name)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ interface PublicChatSnapshotsListProps {
|
|||
onCopy: (snapshot: PublicChatSnapshotDetail) => void;
|
||||
onDelete: (snapshot: PublicChatSnapshotDetail) => void;
|
||||
deletingId?: number;
|
||||
memberMap: Map<string, { name: string; email?: string; avatarUrl?: string }>;
|
||||
}
|
||||
|
||||
export function PublicChatSnapshotsList({
|
||||
|
|
@ -18,13 +19,14 @@ export function PublicChatSnapshotsList({
|
|||
onCopy,
|
||||
onDelete,
|
||||
deletingId,
|
||||
memberMap,
|
||||
}: PublicChatSnapshotsListProps) {
|
||||
if (snapshots.length === 0) {
|
||||
return <PublicChatSnapshotsEmptyState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border rounded-md divide-y">
|
||||
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2">
|
||||
{snapshots.map((snapshot) => (
|
||||
<PublicChatSnapshotRow
|
||||
key={snapshot.id}
|
||||
|
|
@ -33,6 +35,7 @@ export function PublicChatSnapshotsList({
|
|||
onCopy={onCopy}
|
||||
onDelete={onDelete}
|
||||
isDeleting={deletingId === snapshot.id}
|
||||
memberMap={memberMap}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { AlertCircle, Globe, Info } from "lucide-react";
|
||||
import { AlertCircle, Info } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { myAccessAtom } from "@/atoms/members/members-query.atoms";
|
||||
import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
|
||||
import { deletePublicChatSnapshotMutationAtom } from "@/atoms/public-chat-snapshots/public-chat-snapshots-mutation.atoms";
|
||||
import { publicChatSnapshotsAtom } from "@/atoms/public-chat-snapshots/public-chat-snapshots-query.atoms";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type { PublicChatSnapshotDetail } from "@/contracts/types/chat-threads.types";
|
||||
import { PublicChatSnapshotsList } from "./public-chat-snapshots-list";
|
||||
|
|
@ -25,6 +25,22 @@ export function PublicChatSnapshotsManager({
|
|||
// Data fetching
|
||||
const { data: snapshotsData, isLoading, isError } = useAtomValue(publicChatSnapshotsAtom);
|
||||
|
||||
// Members for user resolution
|
||||
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]);
|
||||
|
||||
// Permissions
|
||||
const { data: access } = useAtomValue(myAccessAtom);
|
||||
const canView = useMemo(() => {
|
||||
|
|
@ -46,7 +62,6 @@ export function PublicChatSnapshotsManager({
|
|||
const handleCopy = useCallback((snapshot: PublicChatSnapshotDetail) => {
|
||||
const publicUrl = `${window.location.origin}/public/${snapshot.share_token}`;
|
||||
navigator.clipboard.writeText(publicUrl);
|
||||
toast.success("Link copied to clipboard");
|
||||
}, []);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
|
|
@ -69,16 +84,35 @@ export function PublicChatSnapshotsManager({
|
|||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
<Card>
|
||||
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
|
||||
<Skeleton className="h-5 md:h-6 w-36 md:w-48" />
|
||||
<Skeleton className="h-3 md:h-4 w-full max-w-md mt-2" />
|
||||
</CardHeader>
|
||||
<CardContent className="px-3 md:px-6 pb-3 md:pb-6">
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="space-y-4 md:space-y-5">
|
||||
{/* Info alert skeleton */}
|
||||
<Skeleton className="h-12 w-full rounded-lg" />
|
||||
|
||||
{/* Cards grid skeleton */}
|
||||
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2">
|
||||
{["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => (
|
||||
<Card key={key} className="border-border/60">
|
||||
<CardContent className="p-4 flex flex-col gap-3">
|
||||
{/* Header: Title */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<Skeleton className="h-4 w-36 md:w-44" />
|
||||
</div>
|
||||
{/* Message count badge */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Skeleton className="h-5 w-24 rounded-full" />
|
||||
</div>
|
||||
{/* URL skeleton */}
|
||||
<Skeleton className="h-3 w-full rounded" />
|
||||
{/* Footer: Date + Creator */}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -110,35 +144,23 @@ export function PublicChatSnapshotsManager({
|
|||
const snapshots = snapshotsData?.snapshots ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
<Alert className="py-3 md:py-4">
|
||||
<Globe className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
||||
<div className="space-y-4 md:space-y-5">
|
||||
<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">
|
||||
Public chat links allow anyone with the URL to view a snapshot of a chat. These links do
|
||||
not update when the original chat changes.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<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 flex items-center gap-2">
|
||||
<Globe className="h-4 w-4 md:h-5 md:w-5" />
|
||||
Public Chat Links
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs md:text-sm">
|
||||
Manage public links to chats in this search space.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-3 md:px-6 pb-3 md:pb-6">
|
||||
<PublicChatSnapshotsList
|
||||
snapshots={snapshots}
|
||||
canDelete={canDelete}
|
||||
onCopy={handleCopy}
|
||||
onDelete={handleDelete}
|
||||
deletingId={deletingId}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<PublicChatSnapshotsList
|
||||
snapshots={snapshots}
|
||||
canDelete={canDelete}
|
||||
onCopy={handleCopy}
|
||||
onDelete={handleDelete}
|
||||
deletingId={deletingId}
|
||||
memberMap={memberMap}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager
|
|||
|
||||
return (
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
<Alert className="py-3 md:py-4">
|
||||
<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">
|
||||
Update your search space name and description. These details help identify and organize
|
||||
|
|
|
|||
|
|
@ -5,19 +5,17 @@ import {
|
|||
AlertCircle,
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
Clock,
|
||||
Edit3,
|
||||
ImageIcon,
|
||||
Info,
|
||||
Key,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Shuffle,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
Wand2,
|
||||
} from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
createImageGenConfigMutationAtom,
|
||||
|
|
@ -28,8 +26,8 @@ import {
|
|||
globalImageGenConfigsAtom,
|
||||
imageGenConfigsAtom,
|
||||
} from "@/atoms/image-gen-config/image-gen-config-query.atoms";
|
||||
import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
|
||||
import { updateLLMPreferencesMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
|
||||
import { llmPreferencesAtom } from "@/atoms/new-llm-config/new-llm-config-query.atoms";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import {
|
||||
AlertDialog,
|
||||
|
|
@ -41,9 +39,8 @@ import {
|
|||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
|
|
@ -70,6 +67,7 @@ import {
|
|||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import {
|
||||
|
|
@ -77,6 +75,7 @@ import {
|
|||
IMAGE_GEN_PROVIDERS,
|
||||
} from "@/contracts/enums/image-gen-providers";
|
||||
import type { ImageGenerationConfig } from "@/contracts/types/new-llm-config.types";
|
||||
import { getProviderIcon } from "@/lib/provider-icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ImageModelManagerProps {
|
||||
|
|
@ -93,6 +92,14 @@ const item = {
|
|||
show: { opacity: 1, y: 0 },
|
||||
};
|
||||
|
||||
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 ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
||||
// Image gen config atoms
|
||||
const {
|
||||
|
|
@ -120,27 +127,46 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
|||
} = useAtomValue(imageGenConfigsAtom);
|
||||
const { data: globalConfigs = [], isFetching: globalLoading } =
|
||||
useAtomValue(globalImageGenConfigsAtom);
|
||||
const { data: preferences = {}, isFetching: prefsLoading } = useAtomValue(llmPreferencesAtom);
|
||||
|
||||
// Members for user resolution
|
||||
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]);
|
||||
|
||||
// Permissions
|
||||
const { data: access } = useAtomValue(myAccessAtom);
|
||||
const canCreate = useMemo(() => {
|
||||
if (!access) return false;
|
||||
if (access.is_owner) return true;
|
||||
return access.permissions?.includes("image_generations:create") ?? false;
|
||||
}, [access]);
|
||||
const canDelete = useMemo(() => {
|
||||
if (!access) return false;
|
||||
if (access.is_owner) return true;
|
||||
return access.permissions?.includes("image_generations:delete") ?? false;
|
||||
}, [access]);
|
||||
// Backend uses image_generations:create for update as well
|
||||
const canUpdate = canCreate;
|
||||
const isReadOnly = !canCreate && !canDelete;
|
||||
|
||||
// Local state
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [editingConfig, setEditingConfig] = useState<ImageGenerationConfig | null>(null);
|
||||
const [configToDelete, setConfigToDelete] = useState<ImageGenerationConfig | null>(null);
|
||||
|
||||
// Preference state
|
||||
const [selectedPrefId, setSelectedPrefId] = useState<string | number>(
|
||||
preferences.image_generation_config_id ?? ""
|
||||
);
|
||||
const [hasPrefChanges, setHasPrefChanges] = useState(false);
|
||||
const [isSavingPref, setIsSavingPref] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedPrefId(preferences.image_generation_config_id ?? "");
|
||||
setHasPrefChanges(false);
|
||||
}, [preferences]);
|
||||
|
||||
const isSubmitting = isCreating || isUpdating;
|
||||
const isLoading = configsLoading || globalLoading || prefsLoading;
|
||||
const isLoading = configsLoading || globalLoading;
|
||||
const errors = [createError, updateError, deleteError, fetchError].filter(Boolean) as Error[];
|
||||
|
||||
// Form state for create/edit dialog
|
||||
|
|
@ -248,40 +274,6 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
|||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handlePrefChange = (value: string) => {
|
||||
const newVal = value === "unassigned" ? "" : parseInt(value);
|
||||
setSelectedPrefId(newVal);
|
||||
setHasPrefChanges(newVal !== (preferences.image_generation_config_id ?? ""));
|
||||
};
|
||||
|
||||
const handleSavePref = async () => {
|
||||
setIsSavingPref(true);
|
||||
try {
|
||||
await updatePreferences({
|
||||
search_space_id: searchSpaceId,
|
||||
data: {
|
||||
image_generation_config_id:
|
||||
typeof selectedPrefId === "string"
|
||||
? selectedPrefId
|
||||
? parseInt(selectedPrefId)
|
||||
: undefined
|
||||
: selectedPrefId,
|
||||
},
|
||||
});
|
||||
setHasPrefChanges(false);
|
||||
toast.success("Image generation model preference saved!");
|
||||
} catch {
|
||||
toast.error("Failed to save preference");
|
||||
} finally {
|
||||
setIsSavingPref(false);
|
||||
}
|
||||
};
|
||||
|
||||
const allConfigs = [
|
||||
...globalConfigs.map((c) => ({ ...c, _source: "global" as const })),
|
||||
...(userConfigs ?? []).map((c) => ({ ...c, _source: "user" as const })),
|
||||
];
|
||||
|
||||
const selectedProvider = IMAGE_GEN_PROVIDERS.find((p) => p.value === formData.provider);
|
||||
const suggestedModels = getImageGenModelsByProvider(formData.provider);
|
||||
|
||||
|
|
@ -299,6 +291,14 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
|||
<RefreshCw className={cn("h-3 w-3 md:h-4 md:w-4", configsLoading && "animate-spin")} />
|
||||
Refresh
|
||||
</Button>
|
||||
{canCreate && (
|
||||
<Button
|
||||
onClick={openNewDialog}
|
||||
className="flex items-center gap-2 text-xs md:text-sm h-8 md:h-9"
|
||||
>
|
||||
Add Image Model
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Errors */}
|
||||
|
|
@ -318,11 +318,39 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
|||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Read-only / Limited permissions notice */}
|
||||
{access && !isLoading && isReadOnly && (
|
||||
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }}>
|
||||
<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 image generation
|
||||
configurations. Contact a space owner to request additional permissions.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</motion.div>
|
||||
)}
|
||||
{access && !isLoading && !isReadOnly && (!canCreate || !canDelete) && (
|
||||
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }}>
|
||||
<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 ")}{" "}
|
||||
image model configurations
|
||||
{!canDelete && ", but cannot delete them"}.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Global info */}
|
||||
{globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length > 0 && (
|
||||
<Alert className="border-teal-500/30 bg-teal-500/5 py-3">
|
||||
<Sparkles className="h-3 w-3 md:h-4 md:w-4 text-teal-600 dark:text-teal-400 shrink-0" />
|
||||
<AlertDescription className="text-teal-800 dark:text-teal-200 text-xs md:text-sm">
|
||||
<Alert className="flex flex-row items-center gap-2 bg-muted/50 py-3 [&>svg]:static [&>svg+div]:translate-y-0 [&>svg~*]:pl-0">
|
||||
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
||||
<AlertDescription className="text-xs md:text-sm">
|
||||
<span className="font-medium">
|
||||
{globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length} global
|
||||
image model(s)
|
||||
|
|
@ -332,139 +360,50 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
|||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Active Preference Card */}
|
||||
{!isLoading && allConfigs.length > 0 && (
|
||||
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }}>
|
||||
<Card className="border-l-4 border-l-teal-500">
|
||||
<CardHeader className="pb-2 px-3 md:px-6 pt-3 md:pt-6">
|
||||
<div className="flex items-center gap-2 md:gap-3">
|
||||
<div className="p-1.5 md:p-2 rounded-lg bg-teal-100 text-teal-800">
|
||||
<ImageIcon className="w-4 h-4 md:w-5 md:h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base md:text-lg">Active Image Model</CardTitle>
|
||||
<CardDescription className="text-xs md:text-sm">
|
||||
Select which model to use for image generation
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 px-3 md:px-6 pb-3 md:pb-6">
|
||||
<Select
|
||||
value={selectedPrefId?.toString() || "unassigned"}
|
||||
onValueChange={handlePrefChange}
|
||||
>
|
||||
<SelectTrigger className="h-9 md:h-10 text-xs md:text-sm">
|
||||
<SelectValue placeholder="Select an image model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="unassigned">
|
||||
<span className="text-muted-foreground">Unassigned</span>
|
||||
</SelectItem>
|
||||
{globalConfigs.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
|
||||
Global
|
||||
</div>
|
||||
{globalConfigs.map((c) => {
|
||||
const isAuto = "is_auto_mode" in c && c.is_auto_mode;
|
||||
return (
|
||||
<SelectItem key={`g-${c.id}`} value={c.id.toString()}>
|
||||
<div className="flex items-center gap-2">
|
||||
{isAuto ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300 border-violet-200"
|
||||
>
|
||||
<Shuffle className="size-3 mr-1" />
|
||||
AUTO
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs bg-teal-50 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300 border-teal-200"
|
||||
>
|
||||
{c.provider}
|
||||
</Badge>
|
||||
)}
|
||||
<span>{c.name}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{(userConfigs?.length ?? 0) > 0 && (
|
||||
<>
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
|
||||
Your Models
|
||||
</div>
|
||||
{userConfigs?.map((c) => (
|
||||
<SelectItem key={`u-${c.id}`} value={c.id.toString()}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{c.provider}
|
||||
</Badge>
|
||||
<span>{c.name}</span>
|
||||
<span className="text-muted-foreground">({c.model_name})</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{hasPrefChanges && (
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSavePref}
|
||||
disabled={isSavingPref}
|
||||
className="text-xs h-8"
|
||||
>
|
||||
{isSavingPref ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSelectedPrefId(preferences.image_generation_config_id ?? "");
|
||||
setHasPrefChanges(false);
|
||||
}}
|
||||
className="text-xs h-8"
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Loading */}
|
||||
{/* Loading Skeleton */}
|
||||
{isLoading && (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center py-10">
|
||||
<Spinner size="md" className="text-muted-foreground" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
{/* Your Image Models Section Skeleton */}
|
||||
<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>
|
||||
|
||||
{/* Cards Grid Skeleton */}
|
||||
<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">
|
||||
{/* Header */}
|
||||
<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>
|
||||
{/* Provider + Model */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
<Skeleton className="h-5 w-24 rounded-md" />
|
||||
</div>
|
||||
{/* Footer */}
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* User Configs */}
|
||||
{!isLoading && (
|
||||
<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">
|
||||
<h3 className="text-lg md:text-xl font-semibold tracking-tight">Your Image Models</h3>
|
||||
<Button
|
||||
onClick={openNewDialog}
|
||||
className="flex items-center gap-2 text-xs md:text-sm h-8 md:h-9"
|
||||
>
|
||||
<Plus className="h-3 w-3 md:h-4 md:w-4" />
|
||||
Add Image Model
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{(userConfigs?.length ?? 0) === 0 ? (
|
||||
<Card className="border-dashed border-2 border-muted-foreground/25">
|
||||
<CardContent className="flex flex-col items-center justify-center py-10 md:py-16 text-center">
|
||||
|
|
@ -473,99 +412,151 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
|||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">No Image Models Yet</h3>
|
||||
<p className="text-xs md:text-sm text-muted-foreground max-w-sm mb-4">
|
||||
Add your own image generation model (DALL-E 3, GPT Image 1, etc.)
|
||||
{canCreate
|
||||
? "Add your own image generation model (DALL-E 3, GPT Image 1, etc.)"
|
||||
: "No image models have been added to this space yet. Contact a space owner to add one."}
|
||||
</p>
|
||||
<Button onClick={openNewDialog} size="lg" className="gap-2 text-xs md:text-sm">
|
||||
<Plus className="h-3 w-3 md:h-4 md:w-4" />
|
||||
Add First Image Model
|
||||
</Button>
|
||||
{canCreate && (
|
||||
<Button
|
||||
onClick={openNewDialog}
|
||||
size="lg"
|
||||
className="gap-2 text-xs md:text-sm h-9 md:h-10"
|
||||
>
|
||||
<Plus className="h-3 w-3 md:h-4 md:w-4" />
|
||||
Add First Image Model
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<motion.div variants={container} initial="hidden" animate="show" className="grid gap-4">
|
||||
<motion.div
|
||||
variants={container}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3"
|
||||
>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{userConfigs?.map((config) => (
|
||||
<motion.div
|
||||
key={config.id}
|
||||
variants={item}
|
||||
layout
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
>
|
||||
<Card className="group overflow-hidden hover:shadow-lg transition-all duration-300 border-muted-foreground/10 hover:border-teal-500/30">
|
||||
<CardContent className="p-0">
|
||||
<div className="flex">
|
||||
<div className="w-1 md:w-1.5 bg-gradient-to-b from-teal-500/50 to-cyan-500/50 group-hover:from-teal-500 group-hover:to-cyan-500 transition-colors" />
|
||||
<div className="flex-1 p-3 md:p-5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-start gap-2 md:gap-4 flex-1 min-w-0">
|
||||
<div className="flex h-10 w-10 md:h-12 md:w-12 items-center justify-center rounded-lg md:rounded-xl bg-gradient-to-br from-teal-500/10 to-cyan-500/10 shrink-0">
|
||||
<ImageIcon className="h-5 w-5 md:h-6 md:w-6 text-teal-600 dark:text-teal-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<h4 className="text-sm md:text-base font-semibold truncate">
|
||||
{config.name}
|
||||
</h4>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[9px] md:text-[10px] px-1.5 py-0.5 bg-teal-500/10 text-teal-700 dark:text-teal-300 border-teal-500/20"
|
||||
>
|
||||
{config.provider}
|
||||
</Badge>
|
||||
</div>
|
||||
<code className="text-[10px] md:text-xs font-mono text-muted-foreground bg-muted/50 px-1.5 py-0.5 rounded-md inline-block">
|
||||
{config.model_name}
|
||||
</code>
|
||||
{config.description && (
|
||||
<p className="text-[10px] md:text-xs text-muted-foreground line-clamp-1">
|
||||
{config.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-1 text-[10px] md:text-xs text-muted-foreground pt-1">
|
||||
<Clock className="h-2.5 w-2.5 md:h-3 md:w-3" />
|
||||
{new Date(config.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => openEditDialog(config)}
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Edit3 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Edit</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setConfigToDelete(config)}
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
{userConfigs?.map((config) => {
|
||||
const member = config.user_id ? memberMap.get(config.user_id) : null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={config.id}
|
||||
variants={item}
|
||||
layout
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
>
|
||||
<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">
|
||||
{/* Header: Name + Actions */}
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{/* Provider + Model */}
|
||||
<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>
|
||||
|
||||
{/* Footer: Date + Creator */}
|
||||
<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 && (
|
||||
<>
|
||||
<span className="text-muted-foreground/30">·</span>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-1.5 cursor-default">
|
||||
{member.avatarUrl ? (
|
||||
<Image
|
||||
src={member.avatarUrl}
|
||||
alt={member.name}
|
||||
width={18}
|
||||
height={18}
|
||||
className="h-4.5 w-4.5 rounded-full object-cover shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-4.5 w-4.5 items-center justify-center rounded-full bg-gradient-to-br from-primary/20 to-primary/5 shrink-0">
|
||||
<span className="text-[9px] font-semibold text-primary">
|
||||
{getInitials(member.name)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)}
|
||||
|
|
@ -583,16 +574,12 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
|||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<DialogContent
|
||||
className="max-w-lg max-h-[90vh] overflow-y-auto"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{editingConfig ? (
|
||||
<Edit3 className="w-5 h-5 text-teal-600" />
|
||||
) : (
|
||||
<Plus className="w-5 h-5 text-teal-600" />
|
||||
)}
|
||||
{editingConfig ? "Edit Image Model" : "Add Image Model"}
|
||||
</DialogTitle>
|
||||
<DialogTitle>{editingConfig ? "Edit Image Model" : "Add Image Model"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingConfig
|
||||
? "Update your image generation model"
|
||||
|
|
|
|||
|
|
@ -5,15 +5,21 @@ import {
|
|||
AlertCircle,
|
||||
Bot,
|
||||
CheckCircle,
|
||||
CircleDashed,
|
||||
FileText,
|
||||
ImageIcon,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Save,
|
||||
Shuffle,
|
||||
} from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
globalImageGenConfigsAtom,
|
||||
imageGenConfigsAtom,
|
||||
} from "@/atoms/image-gen-config/image-gen-config-query.atoms";
|
||||
import { updateLLMPreferencesMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
|
||||
import {
|
||||
globalNewLLMConfigsAtom,
|
||||
|
|
@ -23,16 +29,19 @@ import {
|
|||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { getProviderIcon } from "@/lib/provider-icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ROLE_DESCRIPTIONS = {
|
||||
|
|
@ -40,17 +49,28 @@ const ROLE_DESCRIPTIONS = {
|
|||
icon: Bot,
|
||||
title: "Agent LLM",
|
||||
description: "Primary LLM for chat interactions and agent operations",
|
||||
color: "bg-blue-100 text-blue-800 border-blue-200",
|
||||
examples: "Chat responses, agent tasks, real-time interactions",
|
||||
characteristics: ["Fast responses", "Conversational", "Agent operations"],
|
||||
color: "text-blue-600 dark:text-blue-400",
|
||||
bgColor: "bg-blue-500/10",
|
||||
prefKey: "agent_llm_id" as const,
|
||||
configType: "llm" as const,
|
||||
},
|
||||
document_summary: {
|
||||
icon: FileText,
|
||||
title: "Document Summary LLM",
|
||||
description: "Handles document summarization",
|
||||
color: "bg-purple-100 text-purple-800 border-purple-200",
|
||||
examples: "Document analysis, podcasts, research synthesis",
|
||||
characteristics: ["Large context window", "Deep reasoning", "Summarization"],
|
||||
description: "Handles document summarization and research synthesis",
|
||||
color: "text-purple-600 dark:text-purple-400",
|
||||
bgColor: "bg-purple-500/10",
|
||||
prefKey: "document_summary_llm_id" as const,
|
||||
configType: "llm" as const,
|
||||
},
|
||||
image_generation: {
|
||||
icon: ImageIcon,
|
||||
title: "Image Generation Model",
|
||||
description: "Model used for AI image generation (DALL-E, GPT Image, etc.)",
|
||||
color: "text-teal-600 dark:text-teal-400",
|
||||
bgColor: "bg-teal-500/10",
|
||||
prefKey: "image_generation_config_id" as const,
|
||||
configType: "image" as const,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -59,7 +79,7 @@ interface LLMRoleManagerProps {
|
|||
}
|
||||
|
||||
export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
||||
// Use new LLM config system
|
||||
// LLM configs
|
||||
const {
|
||||
data: newLLMConfigs = [],
|
||||
isFetching: configsLoading,
|
||||
|
|
@ -70,8 +90,21 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
|||
data: globalConfigs = [],
|
||||
isFetching: globalConfigsLoading,
|
||||
error: globalConfigsError,
|
||||
refetch: refreshGlobalConfigs,
|
||||
} = useAtomValue(globalNewLLMConfigsAtom);
|
||||
|
||||
// Image gen configs
|
||||
const {
|
||||
data: userImageConfigs = [],
|
||||
isFetching: imageConfigsLoading,
|
||||
error: imageConfigsError,
|
||||
} = useAtomValue(imageGenConfigsAtom);
|
||||
const {
|
||||
data: globalImageConfigs = [],
|
||||
isFetching: globalImageConfigsLoading,
|
||||
error: globalImageConfigsError,
|
||||
} = useAtomValue(globalImageGenConfigsAtom);
|
||||
|
||||
// Preferences
|
||||
const {
|
||||
data: preferences = {},
|
||||
isFetching: preferencesLoading,
|
||||
|
|
@ -83,6 +116,7 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
|||
const [assignments, setAssignments] = useState({
|
||||
agent_llm_id: preferences.agent_llm_id ?? "",
|
||||
document_summary_llm_id: preferences.document_summary_llm_id ?? "",
|
||||
image_generation_config_id: preferences.image_generation_config_id ?? "",
|
||||
});
|
||||
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
|
@ -92,23 +126,24 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
|||
const newAssignments = {
|
||||
agent_llm_id: preferences.agent_llm_id ?? "",
|
||||
document_summary_llm_id: preferences.document_summary_llm_id ?? "",
|
||||
image_generation_config_id: preferences.image_generation_config_id ?? "",
|
||||
};
|
||||
setAssignments(newAssignments);
|
||||
setHasChanges(false);
|
||||
}, [preferences]);
|
||||
|
||||
const handleRoleAssignment = (role: string, configId: string) => {
|
||||
const handleRoleAssignment = (prefKey: string, configId: string) => {
|
||||
const newAssignments = {
|
||||
...assignments,
|
||||
[role]: configId === "unassigned" ? "" : parseInt(configId),
|
||||
[prefKey]: configId === "unassigned" ? "" : parseInt(configId),
|
||||
};
|
||||
|
||||
setAssignments(newAssignments);
|
||||
|
||||
// Check if there are changes compared to current preferences
|
||||
const currentPrefs = {
|
||||
agent_llm_id: preferences.agent_llm_id ?? "",
|
||||
document_summary_llm_id: preferences.document_summary_llm_id ?? "",
|
||||
image_generation_config_id: preferences.image_generation_config_id ?? "",
|
||||
};
|
||||
|
||||
const hasChangesNow = Object.keys(newAssignments).some(
|
||||
|
|
@ -123,19 +158,13 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
|||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
|
||||
const toNumericOrUndefined = (val: string | number) =>
|
||||
typeof val === "string" ? (val ? parseInt(val) : undefined) : val;
|
||||
|
||||
const numericAssignments = {
|
||||
agent_llm_id:
|
||||
typeof assignments.agent_llm_id === "string"
|
||||
? assignments.agent_llm_id
|
||||
? parseInt(assignments.agent_llm_id)
|
||||
: undefined
|
||||
: assignments.agent_llm_id,
|
||||
document_summary_llm_id:
|
||||
typeof assignments.document_summary_llm_id === "string"
|
||||
? assignments.document_summary_llm_id
|
||||
? parseInt(assignments.document_summary_llm_id)
|
||||
: undefined
|
||||
: assignments.document_summary_llm_id,
|
||||
agent_llm_id: toNumericOrUndefined(assignments.agent_llm_id),
|
||||
document_summary_llm_id: toNumericOrUndefined(assignments.document_summary_llm_id),
|
||||
image_generation_config_id: toNumericOrUndefined(assignments.image_generation_config_id),
|
||||
};
|
||||
|
||||
await updatePreferences({
|
||||
|
|
@ -144,7 +173,7 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
|||
});
|
||||
|
||||
setHasChanges(false);
|
||||
toast.success("LLM role assignments saved successfully!");
|
||||
toast.success("Role assignments saved successfully!");
|
||||
|
||||
setIsSaving(false);
|
||||
};
|
||||
|
|
@ -153,6 +182,7 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
|||
setAssignments({
|
||||
agent_llm_id: preferences.agent_llm_id ?? "",
|
||||
document_summary_llm_id: preferences.document_summary_llm_id ?? "",
|
||||
image_generation_config_id: preferences.image_generation_config_id ?? "",
|
||||
});
|
||||
setHasChanges(false);
|
||||
};
|
||||
|
|
@ -163,327 +193,396 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
|||
assignments.agent_llm_id !== undefined &&
|
||||
assignments.document_summary_llm_id !== "" &&
|
||||
assignments.document_summary_llm_id !== null &&
|
||||
assignments.document_summary_llm_id !== undefined;
|
||||
assignments.document_summary_llm_id !== undefined &&
|
||||
assignments.image_generation_config_id !== "" &&
|
||||
assignments.image_generation_config_id !== null &&
|
||||
assignments.image_generation_config_id !== undefined;
|
||||
|
||||
// Combine global and custom configs (new system)
|
||||
const allConfigs = [
|
||||
// Combine global and custom LLM configs
|
||||
const allLLMConfigs = [
|
||||
...globalConfigs.map((config) => ({ ...config, is_global: true })),
|
||||
...newLLMConfigs.filter((config) => config.id && config.id.toString().trim() !== ""),
|
||||
];
|
||||
|
||||
const availableConfigs = allConfigs;
|
||||
// Combine global and custom image gen configs
|
||||
const allImageConfigs = [
|
||||
...globalImageConfigs.map((config) => ({ ...config, is_global: true })),
|
||||
...(userImageConfigs ?? []).filter((config) => config.id && config.id.toString().trim() !== ""),
|
||||
];
|
||||
|
||||
const isLoading = configsLoading || preferencesLoading || globalConfigsLoading;
|
||||
const hasError = configsError || preferencesError || globalConfigsError;
|
||||
const isLoading =
|
||||
configsLoading ||
|
||||
preferencesLoading ||
|
||||
globalConfigsLoading ||
|
||||
imageConfigsLoading ||
|
||||
globalImageConfigsLoading;
|
||||
const hasError =
|
||||
configsError ||
|
||||
preferencesError ||
|
||||
globalConfigsError ||
|
||||
imageConfigsError ||
|
||||
globalImageConfigsError;
|
||||
const hasAnyConfigs = allLLMConfigs.length > 0 || allImageConfigs.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col space-y-4 lg:flex-row lg:items-center lg:justify-between lg:space-y-0">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
<div className="space-y-5 md:space-y-6">
|
||||
{/* Header actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => refreshConfigs()}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-2 text-xs md:text-sm h-8 md:h-9"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3 md:h-4 md:w-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
{isAssignmentComplete && !isLoading && !hasError && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => refreshConfigs()}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-2 text-xs md:text-sm h-8 md:h-9"
|
||||
className="text-xs gap-1.5 border-emerald-500/30 text-emerald-700 dark:text-emerald-300 bg-emerald-500/5"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-3 w-3 md:h-4 md:w-4 ${configsLoading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
<span className="hidden sm:inline">Refresh Configs</span>
|
||||
<span className="sm:hidden">Configs</span>
|
||||
</Button>
|
||||
</div>
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
All roles assigned
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error Alert */}
|
||||
{hasError && (
|
||||
<AnimatePresence>
|
||||
{hasError && (
|
||||
<motion.div
|
||||
key="error-alert"
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
>
|
||||
<Alert variant="destructive" className="py-3 md:py-4">
|
||||
<AlertCircle className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
||||
<AlertDescription className="text-xs md:text-sm">
|
||||
{(configsError?.message ?? "Failed to load LLM configurations") ||
|
||||
(preferencesError?.message ?? "Failed to load preferences") ||
|
||||
(globalConfigsError?.message ?? "Failed to load global configurations")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Loading Skeleton */}
|
||||
{isLoading && (
|
||||
<div className="grid gap-4 grid-cols-1 lg:grid-cols-2">
|
||||
{["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => (
|
||||
<Card key={key} className="border-border/60">
|
||||
<CardContent className="p-4 md:p-5 space-y-4">
|
||||
{/* Header: icon + title + status */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Skeleton className="h-9 w-9 rounded-lg shrink-0" />
|
||||
<div className="space-y-1.5 flex-1">
|
||||
<Skeleton className="h-4 w-24 md:w-28" />
|
||||
<Skeleton className="h-3 w-40 md:w-52" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-4 w-4 rounded-full shrink-0" />
|
||||
</div>
|
||||
{/* Label */}
|
||||
<div className="space-y-1.5">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="h-9 md:h-10 w-full rounded-md" />
|
||||
</div>
|
||||
{/* Summary block */}
|
||||
<div className="rounded-lg border border-border/50 p-3 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-3.5 w-3.5 rounded shrink-0" />
|
||||
<Skeleton className="h-3.5 w-28" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Skeleton className="h-4 w-14 rounded-full" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No configs warning */}
|
||||
{!isLoading && !hasError && !hasAnyConfigs && (
|
||||
<Alert variant="destructive" className="py-3 md:py-4">
|
||||
<AlertCircle className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
||||
<AlertDescription className="text-xs md:text-sm">
|
||||
{(configsError?.message ?? "Failed to load LLM configurations") ||
|
||||
(preferencesError?.message ?? "Failed to load preferences") ||
|
||||
(globalConfigsError?.message ?? "Failed to load global configurations")}
|
||||
No configurations found. Please add at least one LLM provider or image model in the
|
||||
respective settings tabs before assigning roles.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading && (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center py-8 md:py-12">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Spinner size="sm" className="md:h-5 md:w-5" />
|
||||
<span className="text-xs md:text-sm">
|
||||
{configsLoading && preferencesLoading
|
||||
? "Loading configurations and preferences..."
|
||||
: configsLoading
|
||||
? "Loading configurations..."
|
||||
: "Loading preferences..."}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{/* Role Assignment Cards */}
|
||||
{!isLoading && !hasError && hasAnyConfigs && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="grid gap-4 grid-cols-1 lg:grid-cols-2"
|
||||
>
|
||||
{Object.entries(ROLE_DESCRIPTIONS).map(([key, role], index) => {
|
||||
const IconComponent = role.icon;
|
||||
const isImageRole = role.configType === "image";
|
||||
const currentAssignment = assignments[role.prefKey as keyof typeof assignments];
|
||||
|
||||
{/* Info Alert */}
|
||||
{!isLoading && !hasError && (
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
{availableConfigs.length === 0 ? (
|
||||
<Alert variant="destructive" className="py-3 md:py-4">
|
||||
<AlertCircle className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
||||
<AlertDescription className="text-xs md:text-sm">
|
||||
No LLM configurations found. Please add at least one LLM provider in the Agent
|
||||
Configs tab before assigning roles.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : !isAssignmentComplete ? (
|
||||
<Alert className="py-3 md:py-4">
|
||||
<AlertCircle className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
||||
<AlertDescription className="text-xs md:text-sm">
|
||||
Complete all role assignments to enable full functionality. Each role serves
|
||||
different purposes in your workflow.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert className="py-3 md:py-4">
|
||||
<CheckCircle className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
||||
<AlertDescription className="text-xs md:text-sm">
|
||||
All roles are assigned and ready to use! Your LLM configuration is complete.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
// Pick the right config lists based on role type
|
||||
const roleGlobalConfigs = isImageRole ? globalImageConfigs : globalConfigs;
|
||||
const roleUserConfigs = isImageRole
|
||||
? (userImageConfigs ?? []).filter((c) => c.id && c.id.toString().trim() !== "")
|
||||
: newLLMConfigs.filter((c) => c.id && c.id.toString().trim() !== "");
|
||||
const roleAllConfigs = isImageRole ? allImageConfigs : allLLMConfigs;
|
||||
|
||||
{/* Role Assignment Cards */}
|
||||
{availableConfigs.length > 0 && (
|
||||
<div className="grid gap-4 md:gap-6">
|
||||
{Object.entries(ROLE_DESCRIPTIONS).map(([key, role]) => {
|
||||
const IconComponent = role.icon;
|
||||
const currentAssignment = assignments[`${key}_llm_id` as keyof typeof assignments];
|
||||
const assignedConfig = availableConfigs.find(
|
||||
(config) => config.id === currentAssignment
|
||||
);
|
||||
const assignedConfig = roleAllConfigs.find((config) => config.id === currentAssignment);
|
||||
const isAssigned =
|
||||
currentAssignment !== "" &&
|
||||
currentAssignment !== null &&
|
||||
currentAssignment !== undefined;
|
||||
const isAutoMode =
|
||||
assignedConfig && "is_auto_mode" in assignedConfig && assignedConfig.is_auto_mode;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={key}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: Object.keys(ROLE_DESCRIPTIONS).indexOf(key) * 0.1 }}
|
||||
>
|
||||
<Card
|
||||
className={`border-l-4 ${currentAssignment ? "border-l-primary" : "border-l-muted"} hover:shadow-md transition-shadow`}
|
||||
>
|
||||
<CardHeader className="pb-2 md:pb-3 px-3 md:px-6 pt-3 md:pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 md:gap-3">
|
||||
<div className={`p-1.5 md:p-2 rounded-lg ${role.color}`}>
|
||||
<IconComponent className="w-4 h-4 md:w-5 md:h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base md:text-lg">{role.title}</CardTitle>
|
||||
<CardDescription className="mt-0.5 md:mt-1 text-xs md:text-sm">
|
||||
{role.description}
|
||||
</CardDescription>
|
||||
return (
|
||||
<motion.div
|
||||
key={key}
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.08, duration: 0.3 }}
|
||||
>
|
||||
<Card className="group relative overflow-hidden transition-all duration-200 border-border/60 hover:shadow-md h-full">
|
||||
<CardContent className="p-4 md:p-5 space-y-4">
|
||||
{/* Role Header */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center w-9 h-9 rounded-lg shrink-0",
|
||||
role.bgColor
|
||||
)}
|
||||
>
|
||||
<IconComponent className={cn("w-4 h-4", role.color)} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h4 className="text-sm font-semibold tracking-tight">{role.title}</h4>
|
||||
<p className="text-[11px] text-muted-foreground/70 mt-0.5">
|
||||
{role.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{isAssigned ? (
|
||||
<CheckCircle className="w-4 h-4 text-emerald-500 shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<CircleDashed className="w-4 h-4 text-muted-foreground/40 shrink-0 mt-0.5" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selector */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium text-muted-foreground">
|
||||
Configuration
|
||||
</Label>
|
||||
<Select
|
||||
value={currentAssignment?.toString() || "unassigned"}
|
||||
onValueChange={(value) => handleRoleAssignment(role.prefKey, value)}
|
||||
>
|
||||
<SelectTrigger className="w-full h-9 md:h-10 text-xs md:text-sm">
|
||||
<SelectValue placeholder="Select a configuration" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-w-[calc(100vw-2rem)]">
|
||||
<SelectItem
|
||||
value="unassigned"
|
||||
className="text-xs md:text-sm py-1.5 md:py-2"
|
||||
>
|
||||
<span className="text-muted-foreground">Unassigned</span>
|
||||
</SelectItem>
|
||||
|
||||
{/* Global Configurations */}
|
||||
{roleGlobalConfigs.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel className="text-[11px] md:text-xs font-semibold text-muted-foreground px-2 py-1 md:py-1.5">
|
||||
Global Configurations
|
||||
</SelectLabel>
|
||||
{roleGlobalConfigs.map((config) => {
|
||||
const isAuto = "is_auto_mode" in config && config.is_auto_mode;
|
||||
return (
|
||||
<SelectItem
|
||||
key={config.id}
|
||||
value={config.id.toString()}
|
||||
className="text-xs md:text-sm py-1.5 md:py-2"
|
||||
>
|
||||
<div className="flex items-center gap-1 md:gap-1.5 flex-wrap min-w-0">
|
||||
{isAuto ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[9px] md:text-[10px] shrink-0 bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300 border-violet-200 dark:border-violet-700"
|
||||
>
|
||||
<Shuffle className="size-2 md:size-2.5 mr-0.5" />
|
||||
AUTO
|
||||
</Badge>
|
||||
) : (
|
||||
getProviderIcon(config.provider, {
|
||||
className: "size-3 md:size-3.5 shrink-0",
|
||||
})
|
||||
)}
|
||||
<span className="truncate text-xs md:text-sm">
|
||||
{config.name}
|
||||
</span>
|
||||
{!isAuto && (
|
||||
<span className="text-muted-foreground text-[10px] md:text-[11px] truncate">
|
||||
({config.model_name})
|
||||
</span>
|
||||
)}
|
||||
{isAuto && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[8px] md:text-[9px] shrink-0 bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300"
|
||||
>
|
||||
Recommended
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectGroup>
|
||||
)}
|
||||
|
||||
{/* Custom Configurations */}
|
||||
{roleUserConfigs.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel className="text-[11px] md:text-xs font-semibold text-muted-foreground px-2 py-1 md:py-1.5">
|
||||
Your Configurations
|
||||
</SelectLabel>
|
||||
{roleUserConfigs.map((config) => (
|
||||
<SelectItem
|
||||
key={config.id}
|
||||
value={config.id.toString()}
|
||||
className="text-xs md:text-sm py-1.5 md:py-2"
|
||||
>
|
||||
<div className="flex items-center gap-1 md:gap-1.5 flex-wrap min-w-0">
|
||||
{getProviderIcon(config.provider, {
|
||||
className: "size-3 md:size-3.5 shrink-0",
|
||||
})}
|
||||
<span className="truncate text-xs md:text-sm">
|
||||
{config.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-[10px] md:text-[11px] truncate">
|
||||
({config.model_name})
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Assigned Config Summary */}
|
||||
{assignedConfig && (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg p-3 border",
|
||||
isAutoMode
|
||||
? "bg-violet-50 dark:bg-violet-900/10 border-violet-200/50 dark:border-violet-800/30"
|
||||
: "bg-muted/40 border-border/50"
|
||||
)}
|
||||
>
|
||||
{isAutoMode ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Shuffle
|
||||
className={cn(
|
||||
"w-3.5 h-3.5 shrink-0 text-violet-600 dark:text-violet-400"
|
||||
)}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium text-violet-700 dark:text-violet-300">
|
||||
Auto Mode
|
||||
</p>
|
||||
<p className="text-[10px] text-violet-600/70 dark:text-violet-400/70 mt-0.5">
|
||||
Routes across all available providers
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{currentAssignment && (
|
||||
<CheckCircle className="w-4 h-4 md:w-5 md:h-5 text-green-500 shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 md:space-y-4 px-3 md:px-6 pb-3 md:pb-6">
|
||||
<div className="space-y-1.5 md:space-y-2">
|
||||
<Label className="text-xs md:text-sm font-medium">
|
||||
Assign LLM Configuration:
|
||||
</Label>
|
||||
<Select
|
||||
value={currentAssignment?.toString() || "unassigned"}
|
||||
onValueChange={(value) => handleRoleAssignment(`${key}_llm_id`, value)}
|
||||
>
|
||||
<SelectTrigger className="h-9 md:h-10 text-xs md:text-sm">
|
||||
<SelectValue placeholder="Select an LLM configuration" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="unassigned">
|
||||
<span className="text-muted-foreground">Unassigned</span>
|
||||
</SelectItem>
|
||||
|
||||
{/* Global Configurations */}
|
||||
{globalConfigs.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
|
||||
Global Configurations
|
||||
</div>
|
||||
{globalConfigs.map((config) => {
|
||||
const isAutoMode =
|
||||
"is_auto_mode" in config && config.is_auto_mode;
|
||||
return (
|
||||
<SelectItem key={config.id} value={config.id.toString()}>
|
||||
<div className="flex items-center gap-2">
|
||||
{isAutoMode ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300 border-violet-200 dark:border-violet-700"
|
||||
>
|
||||
<Shuffle className="size-3 mr-1" />
|
||||
AUTO
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{config.provider}
|
||||
</Badge>
|
||||
)}
|
||||
<span>{config.name}</span>
|
||||
{!isAutoMode && (
|
||||
<span className="text-muted-foreground">
|
||||
({config.model_name})
|
||||
</span>
|
||||
)}
|
||||
{isAutoMode ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-xs bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300"
|
||||
>
|
||||
Recommended
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
🌐 Global
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Custom Configurations */}
|
||||
{newLLMConfigs.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
|
||||
Your Configurations
|
||||
</div>
|
||||
{newLLMConfigs
|
||||
.filter(
|
||||
(config) => config.id && config.id.toString().trim() !== ""
|
||||
)
|
||||
.map((config) => (
|
||||
<SelectItem key={config.id} value={config.id.toString()}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{config.provider}
|
||||
</Badge>
|
||||
<span>{config.name}</span>
|
||||
<span className="text-muted-foreground">
|
||||
({config.model_name})
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{assignedConfig && (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-2 md:mt-3 p-2 md:p-3 rounded-lg",
|
||||
"is_auto_mode" in assignedConfig && assignedConfig.is_auto_mode
|
||||
? "bg-violet-50 dark:bg-violet-900/20 border border-violet-200 dark:border-violet-800/50"
|
||||
: "bg-muted/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 md:gap-2 text-xs md:text-sm flex-wrap">
|
||||
{"is_auto_mode" in assignedConfig && assignedConfig.is_auto_mode ? (
|
||||
<Shuffle className="w-3 h-3 md:w-4 md:h-4 shrink-0 text-violet-600 dark:text-violet-400" />
|
||||
) : (
|
||||
<Bot className="w-3 h-3 md:w-4 md:h-4 shrink-0" />
|
||||
)}
|
||||
<span className="font-medium">Assigned:</span>
|
||||
{"is_auto_mode" in assignedConfig && assignedConfig.is_auto_mode ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] md:text-xs bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300"
|
||||
>
|
||||
AUTO
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="text-[10px] md:text-xs">
|
||||
{assignedConfig.provider}
|
||||
</Badge>
|
||||
)}
|
||||
<span>{assignedConfig.name}</span>
|
||||
{"is_auto_mode" in assignedConfig && assignedConfig.is_auto_mode ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[9px] md:text-xs bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300 border-violet-200 dark:border-violet-700"
|
||||
>
|
||||
Recommended
|
||||
</Badge>
|
||||
) : (
|
||||
"is_global" in assignedConfig &&
|
||||
assignedConfig.is_global && (
|
||||
<Badge variant="outline" className="text-[9px] md:text-xs">
|
||||
) : (
|
||||
<div className="flex items-start gap-2">
|
||||
<IconComponent className="w-3.5 h-3.5 shrink-0 mt-0.5 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-xs font-medium">{assignedConfig.name}</span>
|
||||
{"is_global" in assignedConfig && assignedConfig.is_global && (
|
||||
<Badge variant="secondary" className="text-[9px] px-1.5 py-0">
|
||||
🌐 Global
|
||||
</Badge>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
{getProviderIcon(assignedConfig.provider, {
|
||||
className: "size-3 shrink-0",
|
||||
})}
|
||||
<code className="text-[10px] text-muted-foreground font-mono truncate">
|
||||
{assignedConfig.model_name}
|
||||
</code>
|
||||
</div>
|
||||
{assignedConfig.api_base && (
|
||||
<p className="text-[10px] text-muted-foreground/60 mt-1 truncate">
|
||||
{assignedConfig.api_base}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{"is_auto_mode" in assignedConfig && assignedConfig.is_auto_mode ? (
|
||||
<div className="text-[10px] md:text-xs text-violet-600 dark:text-violet-400 mt-0.5 md:mt-1">
|
||||
Automatically load balances across all available LLM providers
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-[10px] md:text-xs text-muted-foreground mt-0.5 md:mt-1">
|
||||
Model: {assignedConfig.model_name}
|
||||
</div>
|
||||
{assignedConfig.api_base && (
|
||||
<div className="text-[10px] md:text-xs text-muted-foreground">
|
||||
Base: {assignedConfig.api_base}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
{hasChanges && (
|
||||
<div className="flex justify-center gap-2 md:gap-3 pt-3 md:pt-4">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="flex items-center gap-2 text-xs md:text-sm h-9 md:h-10"
|
||||
>
|
||||
<Save className="w-3.5 h-3.5 md:w-4 md:h-4" />
|
||||
{isSaving ? "Saving" : "Save Changes"}
|
||||
</Button>
|
||||
{/* Save / Reset Bar */}
|
||||
<AnimatePresence>
|
||||
{hasChanges && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/50 p-3 md:p-4"
|
||||
>
|
||||
<p className="text-xs md:text-sm text-muted-foreground">You have unsaved changes</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
disabled={isSaving}
|
||||
className="flex items-center gap-2 text-xs md:text-sm h-9 md:h-10"
|
||||
className="h-8 text-xs gap-1.5"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5 md:w-4 md:h-4" />
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="h-8 text-xs gap-1.5"
|
||||
>
|
||||
<Save className="w-3 h-3" />
|
||||
{isSaving ? "Saving…" : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,19 +3,19 @@
|
|||
import { useAtomValue } from "jotai";
|
||||
import {
|
||||
AlertCircle,
|
||||
Bot,
|
||||
Clock,
|
||||
Edit3,
|
||||
FileText,
|
||||
Info,
|
||||
MessageSquareQuote,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
Wand2,
|
||||
} from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useCallback, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
|
||||
import {
|
||||
createNewLLMConfigMutationAtom,
|
||||
deleteNewLLMConfigMutationAtom,
|
||||
|
|
@ -47,9 +47,11 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import type { NewLLMConfig } from "@/contracts/types/new-llm-config.types";
|
||||
import { getProviderIcon } from "@/lib/provider-icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ModelConfigManagerProps {
|
||||
|
|
@ -71,23 +73,25 @@ const item = {
|
|||
show: { opacity: 1, y: 0 },
|
||||
};
|
||||
|
||||
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 ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
||||
// Mutations
|
||||
const {
|
||||
mutateAsync: createConfig,
|
||||
isPending: isCreating,
|
||||
error: createError,
|
||||
} = useAtomValue(createNewLLMConfigMutationAtom);
|
||||
const {
|
||||
mutateAsync: updateConfig,
|
||||
isPending: isUpdating,
|
||||
error: updateError,
|
||||
} = useAtomValue(updateNewLLMConfigMutationAtom);
|
||||
const {
|
||||
mutateAsync: deleteConfig,
|
||||
isPending: isDeleting,
|
||||
error: deleteError,
|
||||
} = useAtomValue(deleteNewLLMConfigMutationAtom);
|
||||
const { mutateAsync: createConfig, isPending: isCreating } = useAtomValue(
|
||||
createNewLLMConfigMutationAtom
|
||||
);
|
||||
const { mutateAsync: updateConfig, isPending: isUpdating } = useAtomValue(
|
||||
updateNewLLMConfigMutationAtom
|
||||
);
|
||||
const { mutateAsync: deleteConfig, isPending: isDeleting } = useAtomValue(
|
||||
deleteNewLLMConfigMutationAtom
|
||||
);
|
||||
|
||||
// Queries
|
||||
const {
|
||||
|
|
@ -98,13 +102,47 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
|||
} = useAtomValue(newLLMConfigsAtom);
|
||||
const { data: globalConfigs = [] } = useAtomValue(globalNewLLMConfigsAtom);
|
||||
|
||||
// Members for user resolution
|
||||
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]);
|
||||
|
||||
// Permissions
|
||||
const { data: access } = useAtomValue(myAccessAtom);
|
||||
const canCreate = useMemo(() => {
|
||||
if (!access) return false;
|
||||
if (access.is_owner) return true;
|
||||
return access.permissions?.includes("llm_configs:create") ?? false;
|
||||
}, [access]);
|
||||
const canUpdate = useMemo(() => {
|
||||
if (!access) return false;
|
||||
if (access.is_owner) return true;
|
||||
return access.permissions?.includes("llm_configs:update") ?? false;
|
||||
}, [access]);
|
||||
const canDelete = useMemo(() => {
|
||||
if (!access) return false;
|
||||
if (access.is_owner) return true;
|
||||
return access.permissions?.includes("llm_configs:delete") ?? false;
|
||||
}, [access]);
|
||||
const isReadOnly = !canCreate && !canUpdate && !canDelete;
|
||||
|
||||
// Local state
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [editingConfig, setEditingConfig] = useState<NewLLMConfig | null>(null);
|
||||
const [configToDelete, setConfigToDelete] = useState<NewLLMConfig | null>(null);
|
||||
|
||||
const isSubmitting = isCreating || isUpdating;
|
||||
const errors = [createError, updateError, deleteError, fetchError].filter(Boolean) as Error[];
|
||||
|
||||
const handleFormSubmit = useCallback(
|
||||
async (formData: LLMConfigFormData) => {
|
||||
|
|
@ -121,7 +159,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
|||
setIsDialogOpen(false);
|
||||
setEditingConfig(null);
|
||||
} catch {
|
||||
// Error handled by mutation
|
||||
// Error is displayed inside the dialog by the form
|
||||
}
|
||||
},
|
||||
[editingConfig, createConfig, updateConfig]
|
||||
|
|
@ -133,7 +171,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
|||
await deleteConfig({ id: configToDelete.id });
|
||||
setConfigToDelete(null);
|
||||
} catch {
|
||||
// Error handled by mutation
|
||||
// Error handled by mutation state
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -153,52 +191,86 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="space-y-5 md:space-y-6">
|
||||
{/* Header actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => refreshConfigs()}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-2 text-xs md:text-sm h-8 md:h-9"
|
||||
>
|
||||
<RefreshCw className={cn("h-3 w-3 md:h-4 md:w-4", isLoading && "animate-spin")} />
|
||||
Refresh
|
||||
</Button>
|
||||
{canCreate && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={openNewDialog}
|
||||
size="sm"
|
||||
onClick={() => refreshConfigs()}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-2 text-xs md:text-sm h-8 md:h-9"
|
||||
>
|
||||
<RefreshCw className={cn("h-3 w-3 md:h-4 md:w-4", isLoading && "animate-spin")} />
|
||||
Refresh
|
||||
Add Configuration
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error Alerts */}
|
||||
{/* Fetch Error Alert */}
|
||||
<AnimatePresence>
|
||||
{errors.length > 0 &&
|
||||
errors.map((err) => (
|
||||
<motion.div
|
||||
key={err?.message ?? `error-${Date.now()}-${Math.random()}`}
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
>
|
||||
<Alert variant="destructive" className="py-3 md:py-4">
|
||||
<AlertCircle className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
||||
<AlertDescription className="text-xs md:text-sm">
|
||||
{err?.message ?? "Something went wrong"}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</motion.div>
|
||||
))}
|
||||
{fetchError && (
|
||||
<motion.div
|
||||
key="fetch-error"
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
>
|
||||
<Alert variant="destructive" className="py-3 md:py-4">
|
||||
<AlertCircle className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
||||
<AlertDescription className="text-xs md:text-sm">
|
||||
{fetchError?.message ?? "Failed to load configurations"}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Read-only / Limited permissions notice */}
|
||||
{access && !isLoading && isReadOnly && (
|
||||
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }}>
|
||||
<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 LLM configurations.
|
||||
Contact a space owner to request additional permissions.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</motion.div>
|
||||
)}
|
||||
{access && !isLoading && !isReadOnly && (!canCreate || !canUpdate || !canDelete) && (
|
||||
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }}>
|
||||
<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", canUpdate && "edit", canDelete && "delete"]
|
||||
.filter(Boolean)
|
||||
.join(" and ")}{" "}
|
||||
configurations
|
||||
{!canDelete && ", but cannot delete them"}.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Global Configs Info */}
|
||||
{globalConfigs.length > 0 && (
|
||||
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }}>
|
||||
<Alert className="border-blue-500/30 bg-blue-500/5 py-3 md:py-4">
|
||||
<Sparkles className="h-3 w-3 md:h-4 md:w-4 text-blue-600 dark:text-blue-400 shrink-0" />
|
||||
<AlertDescription className="text-blue-800 dark:text-blue-200 text-xs md:text-sm">
|
||||
<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">
|
||||
<span className="font-medium">{globalConfigs.length} global configuration(s)</span>{" "}
|
||||
available from your administrator. These are pre-configured and ready to use.{" "}
|
||||
<span className="text-blue-600 dark:text-blue-300">
|
||||
<span className="text-muted-foreground">
|
||||
Global configs: {globalConfigs.map((g) => g.name).join(", ")}
|
||||
</span>
|
||||
</AlertDescription>
|
||||
|
|
@ -206,34 +278,44 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
|||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{/* Loading Skeleton */}
|
||||
{isLoading && (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center py-10 md:py-16">
|
||||
<div className="flex flex-col items-center gap-2 md:gap-3">
|
||||
<Spinner size="md" className="md:h-8 md:w-8 text-muted-foreground" />
|
||||
<span className="text-xs md:text-sm text-muted-foreground">
|
||||
Loading configurations...
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<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">
|
||||
{/* Header */}
|
||||
<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>
|
||||
{/* Provider + Model */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
<Skeleton className="h-5 w-24 rounded-md" />
|
||||
</div>
|
||||
{/* Feature badges */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Skeleton className="h-5 w-20 rounded-full" />
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
</div>
|
||||
{/* Footer */}
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Configurations List */}
|
||||
{!isLoading && (
|
||||
<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">
|
||||
<h3 className="text-lg md:text-xl font-semibold tracking-tight">Your Configurations</h3>
|
||||
<Button
|
||||
onClick={openNewDialog}
|
||||
className="flex items-center gap-2 text-xs md:text-sm h-8 md:h-9"
|
||||
>
|
||||
<Plus className="h-3 w-3 md:h-4 md:w-4" />
|
||||
Add Configuration
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{configs?.length === 0 ? (
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }}>
|
||||
<Card className="border-dashed border-2 border-muted-foreground/25">
|
||||
|
|
@ -244,24 +326,35 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
|||
<div className="space-y-2 mb-4 md:mb-6">
|
||||
<h3 className="text-lg md:text-xl font-semibold">No Configurations Yet</h3>
|
||||
<p className="text-xs md:text-sm text-muted-foreground max-w-sm">
|
||||
Create your first AI configuration to customize how your agent responds
|
||||
{canCreate
|
||||
? "Create your first AI configuration to customize how your agent responds"
|
||||
: "No AI configurations have been added to this space yet. Contact a space owner to add one."}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={openNewDialog}
|
||||
size="lg"
|
||||
className="gap-2 text-xs md:text-sm h-9 md:h-10"
|
||||
>
|
||||
<Plus className="h-3 w-3 md:h-4 md:w-4" />
|
||||
Create First Configuration
|
||||
</Button>
|
||||
{canCreate && (
|
||||
<Button
|
||||
onClick={openNewDialog}
|
||||
size="lg"
|
||||
className="gap-2 text-xs md:text-sm h-9 md:h-10"
|
||||
>
|
||||
<Plus className="h-3 w-3 md:h-4 md:w-4" />
|
||||
Create First Configuration
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div variants={container} initial="hidden" animate="show" className="grid gap-4">
|
||||
<motion.div
|
||||
variants={container}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3"
|
||||
>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{configs?.map((config) => {
|
||||
const member = config.user_id ? memberMap.get(config.user_id) : null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={config.id}
|
||||
|
|
@ -269,131 +362,134 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
|||
layout
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
>
|
||||
<Card className="group overflow-hidden hover:shadow-lg transition-all duration-300 border-muted-foreground/10 hover:border-violet-500/30">
|
||||
<CardContent className="p-0">
|
||||
<div className="flex">
|
||||
{/* Left accent bar */}
|
||||
<div className="w-1 md:w-1.5 transition-colors bg-gradient-to-b from-violet-500/50 to-purple-500/50 group-hover:from-violet-500 group-hover:to-purple-500" />
|
||||
|
||||
<div className="flex-1 p-3 md:p-5">
|
||||
<div className="flex items-start justify-between gap-2 md:gap-4">
|
||||
{/* Main content */}
|
||||
<div className="flex items-start gap-2 md:gap-4 flex-1 min-w-0">
|
||||
<div className="flex h-10 w-10 md:h-12 md:w-12 items-center justify-center rounded-lg md:rounded-xl bg-gradient-to-br from-violet-500/10 to-purple-500/10 group-hover:from-violet-500/20 group-hover:to-purple-500/20 transition-colors shrink-0">
|
||||
<Bot className="h-5 w-5 md:h-6 md:w-6 text-violet-600 dark:text-violet-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 space-y-2 md:space-y-3">
|
||||
{/* Title row */}
|
||||
<div className="flex items-center gap-1.5 md:gap-2 flex-wrap">
|
||||
<h4 className="text-sm md:text-base font-semibold tracking-tight truncate">
|
||||
{config.name}
|
||||
</h4>
|
||||
<div className="flex items-center gap-1 md:gap-1.5 flex-wrap">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[9px] md:text-[10px] font-medium px-1.5 md:px-2 py-0.5 bg-violet-500/10 text-violet-700 dark:text-violet-300 border-violet-500/20"
|
||||
>
|
||||
{config.provider}
|
||||
</Badge>
|
||||
{config.citations_enabled && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[9px] md:text-[10px] px-1.5 md:px-2 py-0.5 border-emerald-500/30 text-emerald-700 dark:text-emerald-300"
|
||||
>
|
||||
<MessageSquareQuote className="h-2.5 w-2.5 md:h-3 md:w-3 mr-0.5 md:mr-1" />
|
||||
Citations
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Citations are enabled for this configuration
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{!config.use_default_system_instructions &&
|
||||
config.system_instructions && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[9px] md:text-[10px] px-1.5 md:px-2 py-0.5 border-blue-500/30 text-blue-700 dark:text-blue-300"
|
||||
>
|
||||
<FileText className="h-2.5 w-2.5 md:h-3 md:w-3 mr-0.5 md:mr-1" />
|
||||
Custom
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Using custom system instructions
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model name */}
|
||||
<code className="text-[10px] md:text-xs font-mono text-muted-foreground bg-muted/50 px-1.5 md:px-2 py-0.5 md:py-1 rounded-md inline-block">
|
||||
{config.model_name}
|
||||
</code>
|
||||
|
||||
{/* Description if any */}
|
||||
{config.description && (
|
||||
<p className="text-[10px] md:text-xs text-muted-foreground line-clamp-1">
|
||||
{config.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Footer row */}
|
||||
<div className="flex items-center gap-2 md:gap-4 pt-1">
|
||||
<div className="flex items-center gap-1 md:gap-1.5 text-[10px] md:text-xs text-muted-foreground">
|
||||
<Clock className="h-2.5 w-2.5 md:h-3 md:w-3" />
|
||||
<span>
|
||||
{new Date(config.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-0.5 md:gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<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">
|
||||
{/* Header: Name + Actions */}
|
||||
<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>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
size="icon"
|
||||
onClick={() => openEditDialog(config)}
|
||||
className="h-7 w-7 md:h-8 md:w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Edit3 className="h-3.5 w-3.5 md:h-4 md:w-4" />
|
||||
<Edit3 className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Edit</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{canDelete && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
size="icon"
|
||||
onClick={() => setConfigToDelete(config)}
|
||||
className="h-7 w-7 md:h-8 md:w-8 p-0 text-muted-foreground hover:text-destructive"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 md:h-4 md:w-4" />
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Provider + Model */}
|
||||
<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>
|
||||
|
||||
{/* Feature badges */}
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{config.citations_enabled && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] px-1.5 py-0.5 border-emerald-500/30 text-emerald-700 dark:text-emerald-300 bg-emerald-500/5"
|
||||
>
|
||||
<MessageSquareQuote className="h-2.5 w-2.5 mr-1" />
|
||||
Citations
|
||||
</Badge>
|
||||
)}
|
||||
{!config.use_default_system_instructions &&
|
||||
config.system_instructions && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] px-1.5 py-0.5 border-blue-500/30 text-blue-700 dark:text-blue-300 bg-blue-500/5"
|
||||
>
|
||||
<FileText className="h-2.5 w-2.5 mr-1" />
|
||||
Custom
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer: Date + Creator */}
|
||||
<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 && (
|
||||
<>
|
||||
<span className="text-muted-foreground/30">·</span>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-1.5 cursor-default">
|
||||
{member.avatarUrl ? (
|
||||
<Image
|
||||
src={member.avatarUrl}
|
||||
alt={member.name}
|
||||
width={18}
|
||||
height={18}
|
||||
className="h-4.5 w-4.5 rounded-full object-cover shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-4.5 w-4.5 items-center justify-center rounded-full bg-gradient-to-br from-primary/20 to-primary/5 shrink-0">
|
||||
<span className="text-[9px] font-semibold text-primary">
|
||||
{getInitials(member.name)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
|
|
@ -408,14 +504,12 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
|||
|
||||
{/* Add/Edit Configuration Dialog */}
|
||||
<Dialog open={isDialogOpen} onOpenChange={(open) => !open && closeDialog()}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogContent
|
||||
className="max-w-2xl max-h-[90vh] overflow-y-auto"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{editingConfig ? (
|
||||
<Edit3 className="w-5 h-5 text-violet-600" />
|
||||
) : (
|
||||
<Plus className="w-5 h-5 text-violet-600" />
|
||||
)}
|
||||
<DialogTitle>
|
||||
{editingConfig ? "Edit Configuration" : "Create New Configuration"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps)
|
|||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Alert className="py-3 md:py-4">
|
||||
<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">
|
||||
System instructions apply to all AI interactions in this search space. They guide how the
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { IconLinkPlus, IconUsersGroup } from "@tabler/icons-react";
|
||||
import { IconUsersGroup } from "@tabler/icons-react";
|
||||
import {
|
||||
BookOpen,
|
||||
File,
|
||||
|
|
@ -15,11 +15,16 @@ import { EnumConnectorName } from "./connector";
|
|||
|
||||
export const getConnectorIcon = (connectorType: EnumConnectorName | string, className?: string) => {
|
||||
const iconProps = { className: className || "h-4 w-4" };
|
||||
const imgProps = { className: className || "h-5 w-5", width: 20, height: 20 };
|
||||
const imgProps = {
|
||||
className: `${className || "h-5 w-5"} select-none pointer-events-none`,
|
||||
width: 20,
|
||||
height: 20,
|
||||
draggable: false as const,
|
||||
};
|
||||
|
||||
switch (connectorType) {
|
||||
case EnumConnectorName.LINKUP_API:
|
||||
return <IconLinkPlus {...iconProps} />;
|
||||
return <Image src="/connectors/linkup.svg" alt="Linkup" {...imgProps} />;
|
||||
case EnumConnectorName.LINEAR_CONNECTOR:
|
||||
return <Image src="/connectors/linear.svg" alt="Linear" {...imgProps} />;
|
||||
case EnumConnectorName.GITHUB_CONNECTOR:
|
||||
|
|
@ -63,7 +68,7 @@ export const getConnectorIcon = (connectorType: EnumConnectorName | string, clas
|
|||
case EnumConnectorName.YOUTUBE_CONNECTOR:
|
||||
return <Image src="/connectors/youtube.svg" alt="YouTube" {...imgProps} />;
|
||||
case EnumConnectorName.CIRCLEBACK_CONNECTOR:
|
||||
return <IconUsersGroup {...iconProps} />;
|
||||
return <Image src="/connectors/circleback.svg" alt="Circleback" {...imgProps} />;
|
||||
case EnumConnectorName.MCP_CONNECTOR:
|
||||
return <Image src="/connectors/modelcontextprotocol.svg" alt="MCP" {...imgProps} />;
|
||||
case EnumConnectorName.OBSIDIAN_CONNECTOR:
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ export const publicChatSnapshotDetail = z.object({
|
|||
message_count: z.number(),
|
||||
thread_id: z.number(),
|
||||
thread_title: z.string(),
|
||||
created_by_user_id: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { z } from "zod";
|
|||
/**
|
||||
* Incentive task type enum - matches backend IncentiveTaskType
|
||||
*/
|
||||
export const incentiveTaskTypeEnum = z.enum(["GITHUB_STAR"]);
|
||||
export const incentiveTaskTypeEnum = z.enum(["GITHUB_STAR", "REDDIT_FOLLOW", "DISCORD_JOIN"]);
|
||||
|
||||
/**
|
||||
* Single incentive task info schema
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ export const newLLMConfig = z.object({
|
|||
// Metadata
|
||||
created_at: z.string(),
|
||||
search_space_id: z.number(),
|
||||
user_id: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
@ -76,6 +77,7 @@ export const newLLMConfigPublic = newLLMConfig.omit({ api_key: true });
|
|||
export const createNewLLMConfigRequest = newLLMConfig.omit({
|
||||
id: true,
|
||||
created_at: true,
|
||||
user_id: true,
|
||||
});
|
||||
|
||||
export const createNewLLMConfigResponse = newLLMConfig;
|
||||
|
|
@ -110,6 +112,7 @@ export const updateNewLLMConfigRequest = z.object({
|
|||
id: true,
|
||||
created_at: true,
|
||||
search_space_id: true,
|
||||
user_id: true,
|
||||
})
|
||||
.partial(),
|
||||
});
|
||||
|
|
@ -201,11 +204,13 @@ export const imageGenerationConfig = z.object({
|
|||
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 createImageGenConfigRequest = imageGenerationConfig.omit({
|
||||
id: true,
|
||||
created_at: true,
|
||||
user_id: true,
|
||||
});
|
||||
|
||||
export const createImageGenConfigResponse = imageGenerationConfig;
|
||||
|
|
@ -214,7 +219,9 @@ export const getImageGenConfigsResponse = z.array(imageGenerationConfig);
|
|||
|
||||
export const updateImageGenConfigRequest = z.object({
|
||||
id: z.number(),
|
||||
data: imageGenerationConfig.omit({ id: true, created_at: true, search_space_id: true }).partial(),
|
||||
data: imageGenerationConfig
|
||||
.omit({ id: true, created_at: true, search_space_id: true, user_id: true })
|
||||
.partial(),
|
||||
});
|
||||
|
||||
export const updateImageGenConfigResponse = imageGenerationConfig;
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@ const AUTH_ERROR_MESSAGES: AuthErrorMapping = {
|
|||
description: "Your account may be suspended or restricted",
|
||||
},
|
||||
"404": {
|
||||
title: "Account not found",
|
||||
description: "No account exists with this email address",
|
||||
title: "Not found",
|
||||
description: "The requested resource was not found",
|
||||
},
|
||||
"409": {
|
||||
title: "Account conflict",
|
||||
|
|
@ -31,6 +31,10 @@ const AUTH_ERROR_MESSAGES: AuthErrorMapping = {
|
|||
title: "Too many attempts",
|
||||
description: "Please wait before trying again",
|
||||
},
|
||||
RATE_LIMIT_EXCEEDED: {
|
||||
title: "Too many attempts",
|
||||
description: "You've made too many requests. Please wait a minute and try again.",
|
||||
},
|
||||
"500": {
|
||||
title: "Server error",
|
||||
description: "Something went wrong on our end. Please try again",
|
||||
|
|
@ -42,8 +46,8 @@ const AUTH_ERROR_MESSAGES: AuthErrorMapping = {
|
|||
|
||||
// FastAPI specific errors
|
||||
LOGIN_BAD_CREDENTIALS: {
|
||||
title: "Invalid credentials",
|
||||
description: "The email or password you entered is incorrect",
|
||||
title: "Login failed",
|
||||
description: "Invalid email or password. If you don't have an account, please sign up.",
|
||||
},
|
||||
LOGIN_USER_NOT_VERIFIED: {
|
||||
title: "Account not verified",
|
||||
|
|
|
|||
121
surfsense_web/lib/provider-icons.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { Bot, Shuffle } from "lucide-react";
|
||||
import {
|
||||
Ai21Icon,
|
||||
AnthropicIcon,
|
||||
AnyscaleIcon,
|
||||
BedrockIcon,
|
||||
CerebrasIcon,
|
||||
CloudflareIcon,
|
||||
CohereIcon,
|
||||
CometApiIcon,
|
||||
DatabricksIcon,
|
||||
DeepInfraIcon,
|
||||
DeepSeekIcon,
|
||||
FireworksAiIcon,
|
||||
GeminiIcon,
|
||||
GroqIcon,
|
||||
HuggingFaceIcon,
|
||||
MistralIcon,
|
||||
MoonshotIcon,
|
||||
NscaleIcon,
|
||||
OllamaIcon,
|
||||
OpenaiIcon,
|
||||
OpenRouterIcon,
|
||||
PerplexityIcon,
|
||||
QwenIcon,
|
||||
RecraftIcon,
|
||||
ReplicateIcon,
|
||||
SambaNovaIcon,
|
||||
TogetherAiIcon,
|
||||
VertexAiIcon,
|
||||
XaiIcon,
|
||||
XinferenceIcon,
|
||||
ZhipuIcon,
|
||||
} from "@/components/icons/providers";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Returns a Lucide icon element for the given LLM / image-gen provider.
|
||||
* Accepts an optional `className` override for the icon size.
|
||||
*/
|
||||
export function getProviderIcon(
|
||||
provider: string,
|
||||
{ isAutoMode, className = "size-4" }: { isAutoMode?: boolean; className?: string } = {}
|
||||
) {
|
||||
if (isAutoMode || provider?.toUpperCase() === "AUTO") {
|
||||
return <Shuffle className={cn(className, "text-violet-800")} />;
|
||||
}
|
||||
|
||||
switch (provider?.toUpperCase()) {
|
||||
case "AI21":
|
||||
return <Ai21Icon className={cn(className)} />;
|
||||
case "ALIBABA_QWEN":
|
||||
return <QwenIcon className={cn(className)} />;
|
||||
case "ANTHROPIC":
|
||||
return <AnthropicIcon className={cn(className)} />;
|
||||
case "ANYSCALE":
|
||||
return <AnyscaleIcon className={cn(className)} />;
|
||||
case "AZURE":
|
||||
case "AZURE_OPENAI":
|
||||
return <OpenaiIcon className={cn(className)} />;
|
||||
case "AWS_BEDROCK":
|
||||
case "BEDROCK":
|
||||
return <BedrockIcon className={cn(className)} />;
|
||||
case "CEREBRAS":
|
||||
return <CerebrasIcon className={cn(className)} />;
|
||||
case "CLOUDFLARE":
|
||||
return <CloudflareIcon className={cn(className)} />;
|
||||
case "COHERE":
|
||||
return <CohereIcon className={cn(className)} />;
|
||||
case "COMETAPI":
|
||||
return <CometApiIcon className={cn(className)} />;
|
||||
case "CUSTOM":
|
||||
return <Bot className={cn(className, "text-gray-400")} />;
|
||||
case "DATABRICKS":
|
||||
return <DatabricksIcon className={cn(className)} />;
|
||||
case "DEEPINFRA":
|
||||
return <DeepInfraIcon className={cn(className)} />;
|
||||
case "DEEPSEEK":
|
||||
return <DeepSeekIcon className={cn(className)} />;
|
||||
case "FIREWORKS_AI":
|
||||
return <FireworksAiIcon className={cn(className)} />;
|
||||
case "GOOGLE":
|
||||
return <GeminiIcon className={cn(className)} />;
|
||||
case "GROQ":
|
||||
return <GroqIcon className={cn(className)} />;
|
||||
case "HUGGINGFACE":
|
||||
return <HuggingFaceIcon className={cn(className)} />;
|
||||
case "MISTRAL":
|
||||
return <MistralIcon className={cn(className)} />;
|
||||
case "MOONSHOT":
|
||||
return <MoonshotIcon className={cn(className)} />;
|
||||
case "NSCALE":
|
||||
return <NscaleIcon className={cn(className)} />;
|
||||
case "OLLAMA":
|
||||
return <OllamaIcon className={cn(className)} />;
|
||||
case "OPENAI":
|
||||
return <OpenaiIcon className={cn(className)} />;
|
||||
case "OPENROUTER":
|
||||
return <OpenRouterIcon className={cn(className)} />;
|
||||
case "PERPLEXITY":
|
||||
return <PerplexityIcon className={cn(className)} />;
|
||||
case "RECRAFT":
|
||||
return <RecraftIcon className={cn(className)} />;
|
||||
case "REPLICATE":
|
||||
return <ReplicateIcon className={cn(className)} />;
|
||||
case "SAMBANOVA":
|
||||
return <SambaNovaIcon className={cn(className)} />;
|
||||
case "TOGETHER_AI":
|
||||
return <TogetherAiIcon className={cn(className)} />;
|
||||
case "VERTEX_AI":
|
||||
return <VertexAiIcon className={cn(className)} />;
|
||||
case "XAI":
|
||||
return <XaiIcon className={cn(className)} />;
|
||||
case "XINFERENCE":
|
||||
return <XinferenceIcon className={cn(className)} />;
|
||||
case "ZHIPU":
|
||||
return <ZhipuIcon className={cn(className)} />;
|
||||
default:
|
||||
return <Bot className={cn(className, "text-muted-foreground")} />;
|
||||
}
|
||||
}
|
||||
|
|
@ -23,12 +23,42 @@ const nextConfig: NextConfig = {
|
|||
// Mark BlockNote server packages as external
|
||||
serverExternalPackages: ["@blocknote/server-util"],
|
||||
|
||||
// Configure webpack to handle blocknote packages
|
||||
// Turbopack config (used during `next dev --turbopack`)
|
||||
turbopack: {
|
||||
rules: {
|
||||
"*.svg": {
|
||||
loaders: ["@svgr/webpack"],
|
||||
as: "*.js",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// Configure webpack to handle blocknote packages + SVGR
|
||||
webpack: (config, { isServer }) => {
|
||||
if (isServer) {
|
||||
// Don't bundle these packages on the server
|
||||
config.externals = [...(config.externals || []), "@blocknote/server-util"];
|
||||
}
|
||||
|
||||
// SVGR: import *.svg as React components
|
||||
const fileLoaderRule = config.module.rules.find((rule: any) => rule.test?.test?.(".svg"));
|
||||
config.module.rules.push(
|
||||
// Re-apply the existing file loader for *.svg?url imports
|
||||
{
|
||||
...fileLoaderRule,
|
||||
test: /\.svg$/i,
|
||||
resourceQuery: /url/, // e.g. import icon from './icon.svg?url'
|
||||
},
|
||||
// Convert all other *.svg imports to React components
|
||||
{
|
||||
test: /\.svg$/i,
|
||||
issuer: fileLoaderRule.issuer,
|
||||
resourceQuery: { not: [...fileLoaderRule.resourceQuery.not, /url/] },
|
||||
use: ["@svgr/webpack"],
|
||||
}
|
||||
);
|
||||
fileLoaderRule.exclude = /\.svg$/i;
|
||||
|
||||
return config;
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "surfsense_web",
|
||||
"version": "0.0.12",
|
||||
"version": "0.0.13",
|
||||
"private": true,
|
||||
"description": "SurfSense Frontend",
|
||||
"scripts": {
|
||||
|
|
@ -112,6 +112,7 @@
|
|||
"devDependencies": {
|
||||
"@biomejs/biome": "2.1.2",
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
|
|
|
|||
1934
surfsense_web/pnpm-lock.yaml
generated
19
surfsense_web/public/connectors/circleback.svg
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="340.000000pt" height="340.000000pt" viewBox="0 0 340.000000 340.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
|
||||
<g transform="translate(0.000000,340.000000) scale(0.100000,-0.100000)" stroke="none">
|
||||
<path fill="#F24D1D" d="M1385 2574 c-268 -38 -456 -142 -603 -335 -218 -286 -226 -755 -19
|
||||
-1054 110 -159 268 -272 467 -336 67 -21 94 -24 240 -24 185 0 243 11 367 70
|
||||
98 46 157 87 229 161 55 56 143 198 131 210 -8 8 -288 144 -296 144 -4 0 -16
|
||||
-19 -28 -43 -34 -69 -117 -153 -186 -187 -214 -105 -484 -39 -620 152 -121
|
||||
170 -141 455 -46 655 58 123 192 233 320 262 78 18 213 13 282 -10 101 -34
|
||||
202 -120 248 -211 12 -24 23 -44 24 -46 2 -2 295 136 304 144 2 2 -12 33 -30
|
||||
69 -82 165 -266 301 -477 356 -71 18 -247 31 -307 23z"/>
|
||||
<path fill="#4162FE" d="M2575 1135 c-114 -40 -147 -185 -61 -271 65 -65 167 -65 232 0 83 83
|
||||
59 213 -49 265 -50 24 -68 25 -122 6z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1 KiB |
|
|
@ -1,12 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<rect width="24" height="24" rx="6" fill="url(#composio-gradient)"/>
|
||||
<path d="M12 6L17 9V15L12 18L7 15V9L12 6Z" stroke="white" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<path d="M12 6V12M12 12L17 9M12 12L7 9M12 12V18" stroke="white" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<circle cx="12" cy="12" r="2" fill="white"/>
|
||||
<defs>
|
||||
<linearGradient id="composio-gradient" x1="0" y1="0" x2="24" y2="24" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#8B5CF6"/>
|
||||
<stop offset="1" stop-color="#A855F7"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 640 B |
50
surfsense_web/public/connectors/linkup.svg
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="512.000000pt" height="130.000000pt" viewBox="0 0 512.000000 130.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
|
||||
<g transform="translate(0.000000,130.000000) scale(0.100000,-0.100000)"
|
||||
fill="#000000" stroke="none">
|
||||
<path d="M470 1287 c-73 -21 -118 -47 -176 -99 -168 -154 -270 -444 -290 -825
|
||||
-9 -174 -12 -172 168 -104 215 79 557 174 779 214 l77 14 6 -36 c3 -20 6 -66
|
||||
6 -102 0 -95 8 -128 34 -135 50 -12 368 132 447 204 56 51 68 81 45 117 -22
|
||||
33 -72 45 -190 45 -89 0 -98 2 -91 17 34 89 46 269 23 347 -16 53 -81 118
|
||||
-134 134 -52 15 -161 16 -221 1 -45 -12 -45 -12 -71 22 -114 150 -278 225
|
||||
-412 186z m201 -74 c51 -23 105 -67 149 -124 l31 -38 -73 -37 c-121 -61 -245
|
||||
-153 -371 -274 -107 -103 -297 -324 -327 -380 -6 -12 -14 -20 -16 -17 -9 8 17
|
||||
220 37 316 68 312 196 513 362 567 55 18 151 12 208 -13z m509 -202 c21 -11
|
||||
45 -35 60 -62 21 -38 25 -56 24 -129 0 -55 -9 -114 -23 -166 l-23 -81 -45 -7
|
||||
c-93 -12 -88 -16 -107 82 -21 103 -65 242 -101 313 -15 28 -24 53 -22 55 9 9
|
||||
124 21 162 17 22 -3 56 -13 75 -22z m-260 -98 c18 -42 38 -97 46 -122 17 -55
|
||||
54 -215 54 -234 0 -9 -32 -19 -97 -31 -160 -30 -467 -110 -643 -169 -91 -30
|
||||
-166 -54 -167 -53 -1 1 37 53 85 116 105 140 318 354 429 433 85 60 224 137
|
||||
248 137 7 0 27 -34 45 -77z m584 -405 c17 -9 16 -12 -10 -39 -29 -30 -174
|
||||
-109 -280 -152 l-62 -25 54 112 53 111 58 6 c69 6 161 0 187 -13z m-314 -3 c0
|
||||
-10 -81 -167 -84 -164 -2 2 -6 39 -10 81 l-7 77 28 4 c45 7 73 7 73 2z"/>
|
||||
<path d="M1880 645 l0 -415 45 0 45 0 0 415 0 415 -45 0 -45 0 0 -415z"/>
|
||||
<path d="M3180 645 l0 -415 45 0 45 0 0 125 c0 82 4 125 11 125 6 0 72 -56
|
||||
147 -125 l136 -125 59 0 c32 0 57 4 55 9 -1 5 -72 71 -155 146 -84 76 -153
|
||||
141 -153 145 0 5 68 68 150 140 83 72 150 133 150 136 0 2 -28 4 -62 4 l-63 0
|
||||
-125 -115 c-69 -63 -131 -114 -137 -114 -10 -1 -13 53 -13 239 l0 240 -45 0
|
||||
-45 0 0 -415z"/>
|
||||
<path d="M2184 1002 c-23 -15 -35 -51 -24 -72 31 -58 110 -39 110 26 0 35 -57
|
||||
66 -86 46z"/>
|
||||
<path d="M4774 820 c-43 -10 -102 -47 -121 -76 -23 -36 -33 -29 -33 21 l0 45
|
||||
-45 0 -45 0 0 -405 0 -405 45 0 45 0 0 160 c0 88 3 160 6 160 4 0 26 -18 49
|
||||
-39 53 -48 108 -65 197 -59 152 11 248 129 248 303 0 97 -23 158 -83 220 -72
|
||||
73 -163 99 -263 75z m171 -114 c52 -40 75 -95 75 -184 0 -87 -15 -127 -63
|
||||
-169 -74 -65 -176 -69 -260 -11 -59 42 -92 165 -68 256 14 49 63 109 106 129
|
||||
19 8 57 12 101 11 60 -3 76 -8 109 -32z"/>
|
||||
<path d="M2654 802 c-22 -11 -55 -37 -72 -58 l-32 -39 0 53 0 52 -50 0 -50 0
|
||||
2 -287 3 -288 45 0 45 0 5 190 c5 199 11 226 57 267 29 26 88 48 130 48 49 -1
|
||||
118 -32 135 -62 9 -16 14 -85 18 -233 l5 -210 45 0 45 0 3 193 c1 109 -2 210
|
||||
-8 231 -14 54 -61 115 -106 136 -57 28 -168 31 -220 7z"/>
|
||||
<path d="M2170 520 l0 -290 45 0 45 0 0 290 0 290 -45 0 -45 0 0 -290z"/>
|
||||
<path d="M3800 619 c0 -212 8 -260 52 -313 46 -56 100 -79 184 -79 81 0 136
|
||||
24 176 77 l23 31 5 -50 5 -50 45 0 45 0 3 288 2 287 -50 0 -50 0 0 -177 c0
|
||||
-193 -9 -232 -61 -280 -52 -47 -158 -59 -216 -23 -58 35 -63 57 -63 280 l0
|
||||
200 -50 0 -50 0 0 -191z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3 KiB |
5
surfsense_web/svgr.d.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
declare module "*.svg" {
|
||||
import type { FC, SVGProps } from "react";
|
||||
const content: FC<SVGProps<SVGSVGElement>>;
|
||||
export default content;
|
||||
}
|
||||