merge upstream/dev: add user_id to configs, provider icons, i18n

This commit is contained in:
CREDO23 2026-02-11 20:05:00 +02:00
commit 78127243e2
87 changed files with 10445 additions and 5029 deletions

View file

@ -70,10 +70,33 @@ COPY . .
COPY scripts/docker/entrypoint.sh /app/scripts/docker/entrypoint.sh
RUN dos2unix /app/scripts/docker/entrypoint.sh && chmod +x /app/scripts/docker/entrypoint.sh
# Shared temp directory for file uploads between API and Worker containers.
# Python's tempfile module uses TMPDIR, so uploaded files land here.
# Mount the SAME volume at /shared_tmp on both API and Worker in Coolify.
RUN mkdir -p /shared_tmp
ENV TMPDIR=/shared_tmp
# Prevent uvloop compatibility issues
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"]

View file

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

View file

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

View file

@ -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 SlowAPIMiddleware
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,16 @@ 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 middleware for automatic rate limiting
# Uses Starlette BaseHTTPMiddleware (not the raw ASGI variant) to avoid
# corrupting StreamingResponse — SlowAPIASGIMiddleware re-sends
# http.response.start on every body chunk, breaking SSE/streaming endpoints.
app.add_middleware(SlowAPIMiddleware)
# 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 +251,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),

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -243,6 +243,7 @@ class PublicChatSnapshotDetail(BaseModel):
message_count: int
thread_id: int
thread_title: str
created_by_user_id: str | None = None
class PublicChatSnapshotsBySpaceResponse(BaseModel):

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

6441
surfsense_backend/uv.lock generated

File diff suppressed because it is too large Load diff

View file

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

View file

@ -203,5 +203,24 @@ button {
animation: pulse-subtle 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
/* 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';

View file

@ -21,6 +21,9 @@ export function LanguageSwitcher() {
// Supported languages configuration
const languages = [
{ code: "en" as const, name: "English", flag: "🇺🇸" },
{ code: "es" as const, name: "Español", flag: "🇪🇸" },
{ code: "pt" as const, name: "Português", flag: "🇧🇷" },
{ code: "hi" as const, name: "हिन्दी", flag: "🇮🇳" },
{ code: "zh" as const, name: "简体中文", flag: "🇨🇳" },
];
@ -29,7 +32,7 @@ export function LanguageSwitcher() {
* Updates locale in context and localStorage
*/
const handleLanguageChange = (newLocale: string) => {
setLocale(newLocale as "en" | "zh");
setLocale(newLocale as "en" | "es" | "pt" | "hi" | "zh");
};
return (

View file

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

View file

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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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="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

View 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

View 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

View 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

View file

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

View file

@ -24,6 +24,9 @@ import type { User } from "../../types/layout.types";
// Supported languages configuration
const LANGUAGES = [
{ code: "en" as const, name: "English", flag: "🇺🇸" },
{ code: "es" as const, name: "Español", flag: "🇪🇸" },
{ code: "pt" as const, name: "Português", flag: "🇧🇷" },
{ code: "hi" as const, name: "हिन्दी", flag: "🇮🇳" },
{ code: "zh" as const, name: "简体中文", flag: "🇨🇳" },
];
@ -131,7 +134,7 @@ export function SidebarUserProfile({
const initials = getInitials(user.email);
const displayName = user.name || user.email.split("@")[0];
const handleLanguageChange = (newLocale: "en" | "zh") => {
const handleLanguageChange = (newLocale: "en" | "es" | "pt" | "hi" | "zh") => {
setLocale(newLocale);
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,9 +3,20 @@
import type React from "react";
import { createContext, useContext, useEffect, useState } from "react";
import enMessages from "../messages/en.json";
import esMessages from "../messages/es.json";
import ptMessages from "../messages/pt.json";
import hiMessages from "../messages/hi.json";
import zhMessages from "../messages/zh.json";
type Locale = "en" | "zh";
type Locale = "en" | "es" | "pt" | "hi" | "zh";
const messagesMap: Record<Locale, typeof enMessages> = {
en: enMessages,
es: esMessages as typeof enMessages,
pt: ptMessages as typeof enMessages,
hi: hiMessages as typeof enMessages,
zh: zhMessages as typeof enMessages,
};
interface LocaleContextType {
locale: Locale;
@ -24,15 +35,15 @@ export function LocaleProvider({ children }: { children: React.ReactNode }) {
const [mounted, setMounted] = useState(false);
// Get messages based on current locale
const messages = locale === "zh" ? zhMessages : enMessages;
const messages = messagesMap[locale] || enMessages;
// Load locale from localStorage after component mounts (client-side only)
useEffect(() => {
setMounted(true);
if (typeof window !== "undefined") {
const stored = localStorage.getItem(LOCALE_STORAGE_KEY);
if (stored === "zh") {
setLocaleState("zh");
if (stored && (["en", "es", "pt", "hi", "zh"] as const).includes(stored as Locale)) {
setLocaleState(stored as Locale);
}
}
}, []);

View file

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

View file

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

View file

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

View file

@ -7,7 +7,7 @@ import { defineRouting } from "next-intl/routing";
*/
export const routing = defineRouting({
// A list of all locales that are supported
locales: ["en", "zh"],
locales: ["en", "es", "pt", "hi", "zh"],
// Used when no locale matches
defaultLocale: "en",

View file

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

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

View file

@ -0,0 +1,818 @@
{
"common": {
"app_name": "SurfSense",
"welcome": "Bienvenido",
"save": "Guardar",
"cancel": "Cancelar",
"delete": "Eliminar",
"edit": "Editar",
"create": "Crear",
"update": "Actualizar",
"search": "Buscar",
"close": "Cerrar",
"confirm": "Confirmar",
"back": "Volver",
"next": "Siguiente",
"submit": "Enviar",
"yes": "Sí",
"no": "No",
"add": "Agregar",
"remove": "Eliminar",
"select": "Seleccionar",
"all": "Todo",
"none": "Ninguno",
"error": "Error",
"success": "Éxito",
"warning": "Advertencia",
"info": "Información",
"required": "Obligatorio",
"optional": "Opcional",
"retry": "Reintentar",
"owner": "Propietario",
"shared": "Compartido",
"settings": "Configuración"
},
"auth": {
"login": "Iniciar sesión",
"register": "Registrarse",
"logout": "Cerrar sesión",
"email": "Correo electrónico",
"password": "Contraseña",
"confirm_password": "Confirmar contraseña",
"forgot_password": "¿Olvidaste tu contraseña?",
"show_password": "Mostrar contraseña",
"hide_password": "Ocultar contraseña",
"remember_me": "Recuérdame",
"sign_in": "Iniciar sesión",
"signing_in": "Iniciando sesión",
"sign_up": "Registrarse",
"sign_in_with": "Iniciar sesión con {provider}",
"dont_have_account": "¿No tienes una cuenta?",
"already_have_account": "¿Ya tienes una cuenta?",
"reset_password": "Restablecer contraseña",
"email_required": "El correo electrónico es obligatorio",
"password_required": "La contraseña es obligatoria",
"invalid_email": "Dirección de correo electrónico inválida",
"password_too_short": "La contraseña debe tener al menos 8 caracteres",
"welcome_back": "Bienvenido de nuevo",
"create_account": "Crea tu cuenta",
"login_subtitle": "Ingresa tus credenciales para acceder a tu cuenta",
"register_subtitle": "Regístrate para comenzar con SurfSense",
"or_continue_with": "O continuar con",
"by_continuing": "Al continuar, aceptas nuestros",
"terms_of_service": "Términos de servicio",
"and": "y",
"privacy_policy": "Política de privacidad",
"full_name": "Nombre completo",
"username": "Nombre de usuario",
"continue": "Continuar",
"back_to_login": "Volver al inicio de sesión",
"login_success": "Sesión iniciada correctamente",
"register_success": "Cuenta creada correctamente",
"continue_with_google": "Continuar con Google",
"cloud_dev_notice": "SurfSense Cloud está actualmente en desarrollo. Consulta",
"docs": "Documentación",
"cloud_dev_self_hosted": "para más información sobre la versión autoalojada.",
"passwords_no_match": "Las contraseñas no coinciden",
"password_mismatch": "Las contraseñas no coinciden",
"passwords_no_match_desc": "Las contraseñas que ingresaste no coinciden",
"creating_account": "Creando tu cuenta",
"creating_account_btn": "Creando cuenta",
"redirecting_login": "Redirigiendo a la página de inicio de sesión"
},
"searchSpace": {
"create_title": "Crear espacio de búsqueda",
"create_description": "Crea un nuevo espacio de búsqueda para organizar tu conocimiento",
"name_label": "Nombre",
"name_placeholder": "Ingresa el nombre del espacio de búsqueda",
"description_label": "Descripción",
"description_placeholder": "¿Para qué es este espacio de búsqueda?",
"create_button": "Crear",
"creating": "Creando",
"all_search_spaces": "Todos los espacios de búsqueda",
"search_spaces_count": "{count, plural, =0 {Sin espacios de búsqueda} =1 {1 espacio de búsqueda} other {# espacios de búsqueda}}",
"no_search_spaces": "Aún no hay espacios de búsqueda",
"create_first_search_space": "Crea tu primer espacio de búsqueda para comenzar",
"members_count": "{count, plural, =1 {1 miembro} other {# miembros}}",
"create_new_search_space": "Crear nuevo espacio de búsqueda",
"delete_title": "Eliminar espacio de búsqueda",
"delete_confirm": "¿Estás seguro de que deseas eliminar \"{name}\"? Esta acción no se puede deshacer y eliminará permanentemente todos los datos.",
"leave": "Salir",
"leave_title": "Salir del espacio de búsqueda",
"leave_confirm": "¿Estás seguro de que deseas salir de \"{name}\"? Perderás acceso a todos los documentos y chats en este espacio de búsqueda.",
"leaving": "Saliendo...",
"welcome_title": "Bienvenido a SurfSense",
"welcome_description": "Crea tu primer espacio de búsqueda para comenzar a organizar tu conocimiento, conectar fuentes y chatear con IA.",
"create_first_button": "Crea tu primer espacio de búsqueda"
},
"userSettings": {
"title": "Configuración de usuario",
"description": "Administra tu configuración de cuenta y acceso a la API",
"back_to_app": "Volver a la app",
"profile_nav_label": "Perfil",
"profile_nav_description": "Administra tu nombre y avatar",
"profile_title": "Perfil",
"profile_description": "Actualiza tu información personal",
"profile_avatar": "Foto de perfil",
"profile_display_name": "Nombre para mostrar",
"profile_display_name_hint": "Así aparecerá tu nombre en la aplicación",
"profile_email": "Correo electrónico",
"profile_save": "Guardar cambios",
"profile_saved": "Perfil actualizado correctamente",
"profile_save_error": "Error al actualizar el perfil",
"api_key_nav_label": "Clave API",
"api_key_nav_description": "Administra tu token de acceso a la API",
"api_key_title": "Clave API",
"api_key_description": "Usa esta clave para autenticar las solicitudes de la API",
"api_key_warning_title": "Mantenla en secreto",
"api_key_warning_description": "Tu clave API otorga acceso completo a tu cuenta. Nunca la compartas públicamente ni la incluyas en el control de versiones.",
"your_api_key": "Tu clave API",
"copied": "¡Copiado!",
"copy": "Copiar al portapapeles",
"no_api_key": "No se encontró clave API",
"usage_title": "Cómo usar",
"usage_description": "Incluye tu clave API en el encabezado de Authorization:"
},
"dashboard": {
"title": "Panel de control",
"search_spaces": "Espacios de búsqueda",
"documents": "Documentos",
"connectors": "Conectores",
"settings": "Configuración",
"chat": "Chat",
"api_keys": "Claves API",
"profile": "Perfil",
"loading_dashboard": "Cargando panel de control",
"loading_config": "Cargando configuración",
"config_error": "Error de configuración",
"failed_load_llm_config": "No se pudo cargar tu configuración de LLM",
"error_loading_chats": "Error al cargar chats",
"loading_chat": "Cargando chat",
"loading_document": "Cargando documento",
"no_recent_chats": "No hay chats recientes",
"error_loading_space": "Error al cargar el espacio de búsqueda",
"unknown_search_space": "Espacio de búsqueda desconocido",
"delete_chat": "Eliminar chat",
"delete_chat_confirm": "¿Estás seguro de que deseas eliminar",
"delete_note": "Eliminar nota",
"delete_note_confirm": "¿Estás seguro de que deseas eliminar",
"action_cannot_undone": "Esta acción no se puede deshacer.",
"deleting": "Eliminando",
"surfsense_dashboard": "Panel de SurfSense",
"welcome_message": "Bienvenido a tu panel de SurfSense.",
"your_search_spaces": "Tus espacios de búsqueda",
"shared": "Compartido",
"create_search_space": "Crear espacio de búsqueda",
"add_new_search_space": "Agregar nuevo espacio de búsqueda",
"loading": "Cargando",
"may_take_moment": "Esto puede tomar un momento",
"error": "Error",
"something_wrong": "Algo salió mal",
"error_details": "Detalles del error",
"try_again": "Intentar de nuevo",
"go_home": "Ir al inicio",
"delete_search_space": "Eliminar espacio de búsqueda",
"delete_space_confirm": "¿Estás seguro de que deseas eliminar \"{name}\"? Esta acción no se puede deshacer. Todos los documentos y chats en este espacio de búsqueda se eliminarán permanentemente.",
"leave": "Salir",
"leave_title": "Salir del espacio de búsqueda",
"leave_confirm": "¿Estás seguro de que deseas salir de \"{name}\"? Perderás acceso a todos los documentos y chats en este espacio de búsqueda.",
"leaving": "Saliendo...",
"no_spaces_found": "No se encontraron espacios de búsqueda",
"create_first_space": "Crea tu primer espacio de búsqueda para comenzar",
"created": "Creado"
},
"navigation": {
"home": "Inicio",
"docs": "Documentación",
"pricing": "Precios",
"contact": "Contacto",
"login": "Iniciar sesión",
"register": "Registrarse",
"dashboard": "Panel de control",
"sign_in": "Iniciar sesión",
"book_a_call": "Agendar una llamada"
},
"nav_menu": {
"settings": "Configuración",
"platform": "Plataforma",
"chat": "Chat",
"manage_llms": "Administrar LLMs",
"sources": "Fuentes",
"add_sources": "Agregar fuentes",
"documents": "Documentos",
"upload_documents": "Subir documentos",
"add_webpages": "Agregar páginas web",
"add_youtube": "Agregar videos de YouTube",
"add_youtube_videos": "Agregar videos de YouTube",
"manage_documents": "Administrar documentos",
"connectors": "Conectores",
"add_connector": "Agregar conector",
"manage_connectors": "Administrar conectores",
"logs": "Registros",
"all_search_spaces": "Todos los espacios de búsqueda",
"team": "Equipo"
},
"pricing": {
"title": "Precios de SurfSense",
"subtitle": "Elige lo que funcione para ti",
"community_name": "COMUNIDAD",
"enterprise_name": "EMPRESA",
"forever": "para siempre",
"contact_us": "Contáctanos",
"feature_llms": "Soporta más de 100 LLMs",
"feature_ollama": "Soporta configuraciones locales de Ollama o vLLM",
"feature_embeddings": "Más de 6000 modelos de embeddings",
"feature_files": "Más de 50 extensiones de archivo soportadas.",
"feature_podcasts": "Soporte de podcasts con proveedores TTS locales.",
"feature_sources": "Se conecta con más de 15 fuentes externas.",
"feature_extension": "Extensión para múltiples navegadores para páginas web dinámicas incluyendo contenido autenticado",
"upcoming_mindmaps": "Próximamente: Mapas mentales combinables",
"upcoming_notes": "Próximamente: Gestión de notas",
"community_desc": "Versión de código abierto con funciones potentes",
"get_started": "Comenzar",
"everything_community": "Todo lo de Comunidad",
"priority_support": "Soporte prioritario",
"access_controls": "Controles de acceso",
"collaboration": "Colaboración y funciones multijugador",
"video_gen": "Generación de video",
"advanced_security": "Funciones de seguridad avanzadas",
"enterprise_desc": "Para grandes organizaciones con necesidades específicas",
"contact_sales": "Contactar ventas"
},
"contact": {
"title": "Contacto",
"subtitle": "Nos encantaría saber de ti.",
"we_are_here": "Estamos aquí",
"full_name": "Nombre completo",
"email_address": "Dirección de correo electrónico",
"company": "Empresa",
"message": "Mensaje",
"optional": "opcional",
"name_placeholder": "Juan Pérez",
"email_placeholder": "juan.perez@ejemplo.com",
"company_placeholder": "Ejemplo S.A.",
"message_placeholder": "Escribe tu mensaje aquí",
"submit": "Enviar",
"submitting": "Enviando...",
"name_required": "El nombre es obligatorio",
"name_too_long": "El nombre es demasiado largo",
"invalid_email": "Dirección de correo electrónico inválida",
"email_too_long": "El correo electrónico es demasiado largo",
"company_required": "La empresa es obligatoria",
"company_too_long": "El nombre de la empresa es demasiado largo",
"message_sent": "¡Mensaje enviado correctamente!",
"we_will_contact": "Nos pondremos en contacto contigo lo antes posible.",
"send_failed": "Error al enviar el mensaje",
"try_again_later": "Por favor, inténtalo de nuevo más tarde.",
"something_wrong": "Algo salió mal"
},
"connectors": {
"title": "Conectores",
"subtitle": "Administra tus servicios conectados y fuentes de datos.",
"add_connector": "Agregar conector",
"your_connectors": "Tus conectores",
"view_manage": "Ver y administrar todos tus servicios conectados.",
"no_connectors": "No se encontraron conectores",
"no_connectors_desc": "Aún no has agregado ningún conector. Agrega uno para mejorar tus capacidades de búsqueda.",
"add_first": "Agrega tu primer conector",
"name": "Nombre",
"type": "Tipo",
"last_indexed": "Última indexación",
"periodic": "Periódico",
"actions": "Acciones",
"never": "Nunca",
"not_indexable": "No indexable",
"index_date_range": "Indexar con rango de fechas",
"quick_index": "Indexación rápida",
"quick_index_auto": "Indexación rápida (rango de fechas automático)",
"delete_connector": "Eliminar conector",
"delete_confirm": "¿Estás seguro de que deseas eliminar este conector? Esta acción no se puede deshacer.",
"select_date_range": "Seleccionar rango de fechas para indexación",
"select_date_range_desc": "Elige las fechas de inicio y fin para indexar contenido. Déjalo vacío para usar el rango predeterminado.",
"start_date": "Fecha de inicio",
"end_date": "Fecha de fin",
"pick_date": "Seleccionar fecha",
"clear_dates": "Limpiar fechas",
"last_30_days": "Últimos 30 días",
"last_year": "Último año",
"start_indexing": "Iniciar indexación",
"failed_load": "Error al cargar los conectores",
"delete_success": "Conector eliminado correctamente",
"delete_failed": "Error al eliminar el conector",
"indexing_started": "Indexación del contenido del conector iniciada",
"indexing_failed": "Error al indexar el contenido del conector"
},
"documents": {
"title": "Documentos",
"subtitle": "Administra tus documentos y archivos.",
"no_rows_selected": "No hay filas seleccionadas",
"delete_success_count": "Se eliminaron correctamente {count} documento(s)",
"delete_partial_failed": "Algunos documentos no se pudieron eliminar",
"delete_success": "Documento eliminado correctamente",
"delete_error": "Error al eliminar documentos",
"filter_by_title": "Filtrar por título...",
"bulk_delete": "Eliminar seleccionados",
"filter_types": "Filtrar tipos",
"columns": "Columnas",
"confirm_delete": "Confirmar eliminación",
"confirm_delete_desc": "¿Estás seguro de que deseas eliminar {count} documento(s)? Esta acción no se puede deshacer.",
"uploading": "Subiendo",
"upload_success": "Documento subido correctamente",
"upload_failed": "Error al subir el documento",
"loading": "Cargando documentos",
"error_loading": "Error al cargar documentos",
"retry": "Reintentar",
"no_documents": "No se encontraron documentos",
"type": "Tipo",
"content_summary": "Resumen del contenido",
"view_full": "Ver resumen",
"filter_placeholder": "Filtrar por título...",
"rows_per_page": "Filas por página",
"refresh": "Actualizar",
"upload_documents": "Subir documentos",
"create_shared_note": "Crear nota compartida",
"processing_documents": "Procesando documentos...",
"active_tasks_count": "{count} tarea(s) activa(s)"
},
"add_connector": {
"title": "Conecta tus herramientas",
"subtitle": "Integra con tus servicios favoritos para mejorar tus capacidades de investigación.",
"web_search": "Búsqueda web",
"messaging": "Mensajería",
"project_management": "Gestión de proyectos",
"documentation": "Documentación",
"development": "Desarrollo",
"databases": "Bases de datos",
"productivity": "Productividad",
"web_crawling": "Rastreo web",
"connect": "Conectar",
"coming_soon": "Próximamente",
"connected": "Conectado",
"manage": "Administrar",
"tavily_desc": "Busca en la web usando la API de Tavily",
"searxng_desc": "Usa tu propia instancia de SearxNG para resultados web.",
"linkup_desc": "Busca en la web usando la API de Linkup",
"elasticsearch_desc": "Conéctate a Elasticsearch para indexar y buscar documentos, registros y métricas.",
"baidu_desc": "Busca en la web china usando la API de Baidu AI Search",
"slack_desc": "Conéctate a tu espacio de trabajo de Slack para acceder a mensajes y canales.",
"teams_desc": "Conéctate a Microsoft Teams para acceder a las conversaciones de tu equipo.",
"discord_desc": "Conéctate a servidores de Discord para acceder a mensajes y canales.",
"linear_desc": "Conéctate a Linear para buscar problemas, comentarios y datos de proyectos.",
"jira_desc": "Conéctate a Jira para buscar problemas, tickets y datos de proyectos.",
"clickup_desc": "Conéctate a ClickUp para buscar tareas, comentarios y datos de proyectos.",
"notion_desc": "Conéctate a tu espacio de trabajo de Notion para acceder a páginas y bases de datos.",
"github_desc": "Conecta un PAT de GitHub para indexar código y documentos de repositorios accesibles.",
"confluence_desc": "Conéctate a Confluence para buscar páginas, comentarios y documentación.",
"bookstack_desc": "Conéctate a BookStack para buscar páginas wiki y documentación.",
"airtable_desc": "Conéctate a Airtable para buscar registros, tablas y contenido de bases de datos.",
"luma_desc": "Conéctate a Luma para buscar eventos, encuentros y reuniones.",
"circleback_desc": "Recibe notas de reuniones, transcripciones y elementos de acción de Circleback vía webhook.",
"calendar_desc": "Conéctate a Google Calendar para buscar eventos, reuniones y horarios.",
"gmail_desc": "Conéctate a tu cuenta de Gmail para buscar en tus correos electrónicos.",
"google_drive_desc": "Conéctate a Google Drive para buscar e indexar tus archivos y documentos.",
"zoom_desc": "Conéctate a Zoom para acceder a grabaciones y transcripciones de reuniones.",
"webcrawler_desc": "Rastrea e indexa contenido de cualquier página web pública."
},
"upload_documents": {
"title": "Subir documentos",
"subtitle": "Sube tus archivos para hacerlos buscables y accesibles a través de conversaciones con IA.",
"file_size_limit": "Tamaño máximo de archivo: 50 MB por archivo.",
"upload_limits": "Límite de subida: {maxFiles} archivos, {maxSizeMB} MB en total.",
"drop_files": "Suelta los archivos aquí",
"drag_drop": "Arrastra y suelta archivos aquí",
"or_browse": "o haz clic para explorar",
"browse_files": "Explorar archivos",
"selected_files": "Archivos seleccionados ({count})",
"total_size": "Tamaño total",
"clear_all": "Limpiar todo",
"uploading_files": "Subiendo archivos",
"uploading": "Subiendo",
"upload_button": "Subir {count} {count, plural, one {archivo} other {archivos}}",
"upload_initiated": "Tarea de subida iniciada",
"upload_initiated_desc": "La subida de archivos ha comenzado",
"upload_error": "Error de subida",
"upload_error_desc": "Error al subir archivos",
"supported_file_types": "Tipos de archivo soportados",
"file_types_desc": "Estos tipos de archivo son soportados según la configuración actual de tu servicio ETL.",
"max_files_exceeded": "Límite de archivos excedido",
"max_files_exceeded_desc": "Puedes subir un máximo de {max} archivos a la vez.",
"max_size_exceeded": "Límite de tamaño excedido",
"max_size_exceeded_desc": "El tamaño total de los archivos no puede exceder {max} MB.",
"file_limit_reached": "Máximo de archivos alcanzado",
"file_limit_reached_desc": "Elimina algunos archivos para agregar más (máximo {max} archivos).",
"remaining_capacity": "{files} archivos restantes • {sizeMB} MB disponibles"
},
"add_webpage": {
"title": "Agregar páginas web para rastreo",
"subtitle": "Ingresa URLs para rastrear y agregar a tu colección de documentos",
"label": "Ingresa URLs para rastrear",
"placeholder": "Ingresa una URL y presiona Enter",
"hint": "Agrega múltiples URLs presionando Enter después de cada una",
"tips_title": "Consejos para el rastreo de URLs:",
"tip_1": "Ingresa URLs completas incluyendo http:// o https://",
"tip_2": "Asegúrate de que los sitios web permitan el rastreo",
"tip_3": "Las páginas web públicas funcionan mejor",
"tip_4": "El rastreo puede tomar un tiempo dependiendo del tamaño del sitio web",
"cancel": "Cancelar",
"submit": "Enviar URLs para rastreo",
"submitting": "Enviando...",
"error_no_url": "Por favor agrega al menos una URL",
"error_invalid_urls": "URLs inválidas detectadas: {urls}",
"crawling_toast": "Rastreo de URL",
"crawling_toast_desc": "Iniciando proceso de rastreo de URL...",
"success_toast": "Rastreo exitoso",
"success_toast_desc": "Las URLs han sido enviadas para rastreo",
"error_toast": "Error de rastreo",
"error_toast_desc": "Error al rastrear URLs",
"error_generic": "Ocurrió un error al rastrear URLs",
"invalid_url_toast": "URL inválida",
"invalid_url_toast_desc": "Por favor ingresa una URL válida",
"duplicate_url_toast": "URL duplicada",
"duplicate_url_toast_desc": "Esta URL ya ha sido agregada"
},
"add_youtube": {
"title": "Agregar videos de YouTube",
"subtitle": "Ingresa URLs de videos de YouTube para agregar a tu colección de documentos",
"label": "Ingresa URLs de videos de YouTube",
"placeholder": "Ingresa una URL de YouTube y presiona Enter",
"hint": "Agrega múltiples URLs de YouTube presionando Enter después de cada una",
"tips_title": "Consejos para agregar videos de YouTube:",
"tip_1": "Usa URLs estándar de YouTube (youtube.com/watch?v= o youtu.be/)",
"tip_2": "Asegúrate de que los videos sean accesibles públicamente",
"tip_3": "Formatos soportados: youtube.com/watch?v=VIDEO_ID o youtu.be/VIDEO_ID",
"tip_4": "El procesamiento puede tomar un tiempo dependiendo de la duración del video",
"preview": "Vista previa",
"cancel": "Cancelar",
"submit": "Agregar",
"processing": "Procesando...",
"error_no_video": "Por favor agrega al menos una URL de video de YouTube",
"error_invalid_urls": "URLs de YouTube inválidas detectadas: {urls}",
"processing_toast": "Procesamiento de video de YouTube",
"processing_toast_desc": "Iniciando procesamiento de video de YouTube...",
"success_toast": "Procesamiento exitoso",
"success_toast_desc": "Los videos de YouTube han sido enviados para procesamiento",
"error_toast": "Error de procesamiento",
"error_toast_desc": "Error al procesar videos de YouTube",
"error_generic": "Ocurrió un error al procesar videos de YouTube",
"invalid_url_toast": "URL de YouTube inválida",
"invalid_url_toast_desc": "Por favor ingresa una URL válida de video de YouTube",
"duplicate_url_toast": "URL duplicada",
"duplicate_url_toast_desc": "Este video de YouTube ya ha sido agregado"
},
"settings": {
"title": "Configuración",
"subtitle": "Administra tus configuraciones de LLM y asignaciones de roles para este espacio de búsqueda.",
"back_to_dashboard": "Volver al panel de control",
"model_configs": "Configuraciones de modelos",
"models": "Modelos",
"llm_roles": "Roles de LLM",
"roles": "Roles",
"llm_role_management": "Gestión de roles de LLM",
"llm_role_desc": "Asigna tus configuraciones de LLM a roles específicos para diferentes propósitos.",
"no_llm_configs_found": "No se encontraron configuraciones de LLM. Agrega al menos un proveedor de LLM en la pestaña de Configuraciones de Modelos antes de asignar roles.",
"select_llm_config": "Selecciona una configuración de LLM",
"long_context_llm": "LLM de contexto largo",
"fast_llm": "LLM rápido",
"strategic_llm": "LLM estratégico",
"long_context_desc": "Maneja resúmenes de documentos largos y preguntas complejas",
"long_context_examples": "Análisis de documentos, síntesis de investigación, preguntas complejas",
"large_context_window": "Ventana de contexto amplia",
"deep_reasoning": "Razonamiento profundo",
"complex_analysis": "Análisis complejo",
"fast_llm_desc": "Optimizado para respuestas rápidas e interacciones en tiempo real",
"fast_llm_examples": "Búsquedas rápidas, preguntas simples, respuestas instantáneas",
"low_latency": "Baja latencia",
"quick_responses": "Respuestas rápidas",
"real_time_chat": "Chat en tiempo real",
"strategic_llm_desc": "Razonamiento avanzado para planificación y toma de decisiones estratégicas",
"strategic_llm_examples": "Planificación de flujos de trabajo, análisis estratégico, resolución de problemas complejos",
"strategic_thinking": "Pensamiento estratégico",
"long_term_planning": "Planificación a largo plazo",
"complex_reasoning": "Razonamiento complejo",
"use_cases": "Casos de uso",
"assign_llm_config": "Asignar configuración de LLM",
"unassigned": "Sin asignar",
"assigned": "Asignado",
"model": "Modelo",
"base": "Base",
"all_roles_assigned": "¡Todos los roles están asignados y listos para usar! Tu configuración de LLM está completa.",
"save_changes": "Guardar cambios",
"saving": "Guardando",
"reset": "Restablecer",
"status": "Estado",
"status_ready": "Listo",
"status_setup": "Configuración",
"complete_role_assignments": "Completa todas las asignaciones de roles para habilitar la funcionalidad completa. Cada rol tiene diferentes propósitos en tu flujo de trabajo.",
"all_roles_saved": "¡Todos los roles asignados y guardados!",
"progress": "Progreso",
"roles_assigned_count": "{assigned} de {total} roles asignados"
},
"logs": {
"title": "Registros de tareas",
"subtitle": "Monitorea y analiza todos los registros de ejecución de tareas",
"refresh": "Actualizar",
"delete_selected": "Eliminar seleccionados",
"confirm_title": "¿Estás completamente seguro?",
"confirm_delete_desc": "Esta acción no se puede deshacer. Esto eliminará permanentemente {count} registro(s) seleccionado(s).",
"cancel": "Cancelar",
"delete": "Eliminar",
"level": "Nivel",
"status": "Estado",
"source": "Fuente",
"message": "Mensaje",
"created_at": "Fecha de creación",
"actions": "Acciones",
"system": "Sistema",
"filter_by_message": "Filtrar por mensaje...",
"filter_by": "Filtrar por",
"total_logs": "Total de registros",
"active_tasks": "Tareas activas",
"success_rate": "Tasa de éxito",
"recent_failures": "Fallos recientes",
"last_hours": "Últimas {hours} horas",
"currently_running": "Ejecutándose actualmente",
"successful": "exitoso",
"need_attention": "Requiere atención",
"no_logs": "No se encontraron registros",
"loading": "Cargando registros...",
"error_loading": "Error al cargar registros",
"columns": "Columnas",
"failed_load_summary": "Error al cargar el resumen",
"retry": "Reintentar",
"view": "Ver",
"toggle_columns": "Alternar columnas",
"rows_per_page": "Filas por página",
"view_metadata": "Ver metadatos",
"log_deleted_success": "Registro eliminado correctamente",
"log_deleted_error": "Error al eliminar el registro",
"confirm_delete_log_title": "¿Estás seguro?",
"confirm_delete_log_desc": "Esta acción no se puede deshacer. Esto eliminará permanentemente la entrada del registro.",
"deleting": "Eliminando"
},
"onboard": {
"welcome_title": "Bienvenido a SurfSense",
"welcome_subtitle": "Vamos a configurar tus configuraciones de LLM para comenzar",
"step_of": "Paso {current} de {total}",
"percent_complete": "{percent}% completado",
"add_llm_provider": "Agregar proveedor de LLM",
"assign_llm_roles": "Asignar roles de LLM",
"setup_llm_configuration": "Configurar LLM",
"configure_providers_and_assign_roles": "Agrega tus proveedores de LLM y asígnalos a roles específicos",
"assign_llm_roles_title": "Asignar roles de LLM",
"complete_role_assignment": "Asigna tus configuraciones de LLM a roles específicos para continuar",
"setup_complete": "Configuración completa",
"configure_first_provider": "Configura tu primer proveedor de modelos",
"assign_specific_roles": "Asigna roles específicos a tus configuraciones de LLM",
"all_set": "¡Todo listo para comenzar a usar SurfSense!",
"loading_config": "Cargando tu configuración...",
"previous": "Anterior",
"next": "Siguiente",
"complete_setup": "Completar configuración",
"add_provider_instruction": "Agrega al menos un proveedor de LLM para continuar. Puedes configurar múltiples proveedores y elegir roles específicos para cada uno en el siguiente paso.",
"your_llm_configs": "Tus configuraciones de LLM",
"model": "Modelo",
"language": "Idioma",
"base": "Base",
"add_provider_title": "Agregar proveedor de LLM",
"add_provider_subtitle": "Configura tu primer proveedor de modelos para comenzar",
"add_provider_button": "Agregar proveedor",
"add_new_llm_provider": "Agregar nuevo proveedor de LLM",
"configure_new_provider": "Configura un nuevo proveedor de modelo de lenguaje para tu asistente de IA",
"config_name": "Nombre de configuración",
"config_name_required": "Nombre de configuración *",
"config_name_placeholder": "ej., Mi OpenAI GPT-4",
"provider": "Proveedor",
"provider_required": "Proveedor *",
"provider_placeholder": "Selecciona un proveedor",
"language_optional": "Idioma (opcional)",
"language_placeholder": "Selecciona idioma",
"custom_provider_name": "Nombre del proveedor personalizado *",
"custom_provider_placeholder": "ej., mi-proveedor-personalizado",
"model_name_required": "Nombre del modelo *",
"model_name_placeholder": "ej., gpt-4",
"examples": "Ejemplos",
"api_key_required": "Clave API *",
"api_key_placeholder": "Tu clave API",
"api_base_optional": "URL base de la API (opcional)",
"api_base_placeholder": "ej., https://api.openai.com/v1",
"adding": "Agregando...",
"add_provider": "Agregar proveedor",
"cancel": "Cancelar",
"assign_roles_instruction": "Asigna tus configuraciones de LLM a roles específicos. Cada rol tiene diferentes propósitos en tu flujo de trabajo.",
"no_llm_configs_found": "No se encontraron configuraciones de LLM",
"add_provider_before_roles": "Agrega al menos un proveedor de LLM en el paso anterior antes de asignar roles.",
"long_context_llm_title": "LLM de contexto largo",
"long_context_llm_desc": "Maneja resúmenes de documentos largos y preguntas complejas",
"long_context_llm_examples": "Análisis de documentos, síntesis de investigación, preguntas complejas",
"fast_llm_title": "LLM rápido",
"fast_llm_desc": "Optimizado para respuestas rápidas e interacciones en tiempo real",
"fast_llm_examples": "Búsquedas rápidas, preguntas simples, respuestas instantáneas",
"strategic_llm_title": "LLM estratégico",
"strategic_llm_desc": "Razonamiento avanzado para planificación y toma de decisiones estratégicas",
"strategic_llm_examples": "Planificación de flujos de trabajo, análisis estratégico, resolución de problemas complejos",
"use_cases": "Casos de uso",
"assign_llm_config": "Asignar configuración de LLM",
"select_llm_config": "Selecciona una configuración de LLM",
"assigned": "Asignado",
"all_roles_assigned_saved": "¡Todos los roles asignados y guardados!",
"progress": "Progreso",
"roles_assigned": "{assigned} de {total} roles asignados",
"global_configs": "Configuraciones globales",
"your_configs": "Tus configuraciones"
},
"model_config": {
"title": "Configuraciones de modelos",
"subtitle": "Administra las configuraciones de tus proveedores de LLM y ajustes de API.",
"refresh": "Actualizar",
"loading": "Cargando configuraciones...",
"total_configs": "Total de configuraciones",
"unique_providers": "Proveedores únicos",
"system_status": "Estado del sistema",
"active": "Activo",
"your_configs": "Tus configuraciones",
"manage_configs": "Administra y configura tus proveedores de LLM",
"add_config": "Agregar configuración",
"no_configs": "Aún no hay configuraciones",
"no_configs_desc": "Agrega tus propias configuraciones de proveedor de LLM.",
"add_first_config": "Agregar primera configuración",
"created": "Creado"
},
"breadcrumb": {
"dashboard": "Panel de control",
"search_space": "Espacio de búsqueda",
"chat": "Chat",
"documents": "Documentos",
"connectors": "Conectores",
"editor": "Editor",
"logs": "Registros",
"settings": "Configuración",
"upload_documents": "Subir documentos",
"add_youtube": "Agregar videos de YouTube",
"add_webpages": "Agregar páginas web",
"add_connector": "Agregar conector",
"manage_connectors": "Administrar conectores",
"edit_connector": "Editar conector",
"manage": "Administrar"
},
"sidebar": {
"chats": "Chats privados",
"shared_chats": "Chats compartidos",
"search_chats": "Buscar chats",
"no_chats_found": "No se encontraron chats",
"no_shared_chats": "No hay chats compartidos",
"view_all_shared_chats": "Ver todos los chats compartidos",
"view_all_private_chats": "Ver todos los chats privados",
"no_chats": "Aún no hay chats",
"start_new_chat_hint": "Iniciar un nuevo chat",
"error_loading_chats": "Error al cargar chats",
"chat_deleted": "Chat eliminado correctamente",
"error_deleting_chat": "Error al eliminar el chat",
"delete": "Eliminar",
"try_different_search": "Intenta con un término de búsqueda diferente",
"updated": "Actualizado",
"more_options": "Más opciones",
"clear_search": "Limpiar búsqueda",
"archive": "Archivar",
"unarchive": "Restaurar",
"chat_archived": "Chat archivado",
"chat_unarchived": "Chat restaurado",
"chat_renamed": "Chat renombrado",
"error_renaming_chat": "Error al renombrar el chat",
"rename": "Renombrar",
"rename_chat": "Renombrar chat",
"rename_chat_description": "Ingresa un nuevo nombre para esta conversación.",
"chat_title_placeholder": "Título del chat",
"renaming": "Renombrando...",
"no_archived_chats": "No hay chats archivados",
"error_archiving_chat": "Error al archivar el chat",
"new_chat": "Nuevo chat",
"select_search_space": "Seleccionar espacio de búsqueda",
"manage_members": "Administrar miembros",
"search_space_settings": "Configuración del espacio de búsqueda",
"logs": "Registros",
"see_all_search_spaces": "Ver todos los espacios de búsqueda",
"expand_sidebar": "Expandir barra lateral",
"collapse_sidebar": "Contraer barra lateral",
"user_settings": "Configuración de usuario",
"language": "Idioma",
"theme": "Tema",
"light": "Claro",
"dark": "Oscuro",
"system": "Sistema",
"logout": "Cerrar sesión",
"loggingOut": "Cerrando sesión...",
"inbox": "Bandeja de entrada",
"search_inbox": "Buscar en bandeja de entrada",
"mark_all_read": "Marcar todo como leído",
"mark_as_read": "Marcar como leído",
"mentions": "Menciones",
"comments": "Comentarios",
"status": "Estado",
"no_results_found": "No se encontraron resultados",
"no_mentions": "No hay menciones",
"no_mentions_hint": "Aquí verás las menciones de otros",
"no_comments": "No hay comentarios",
"no_comments_hint": "Aquí verás las menciones y respuestas",
"no_status_updates": "No hay actualizaciones de estado",
"no_status_updates_hint": "Las actualizaciones de documentos y conectores aparecerán aquí",
"filter": "Filtrar",
"all": "Todo",
"unread": "No leído",
"connectors": "Conectores",
"all_connectors": "Todos los conectores",
"close": "Cerrar"
},
"errors": {
"something_went_wrong": "Algo salió mal",
"try_again": "Por favor, inténtalo de nuevo",
"not_found": "No encontrado",
"unauthorized": "No autorizado",
"forbidden": "Prohibido",
"server_error": "Error del servidor",
"network_error": "Error de red"
},
"searchSpaceSettings": {
"title": "Configuración del espacio de búsqueda",
"back_to_app": "Volver a la app",
"nav_general": "General",
"nav_general_desc": "Nombre, descripción e información básica",
"nav_agent_configs": "Configuraciones de agente",
"nav_agent_configs_desc": "Modelos LLM con prompts y citas",
"nav_role_assignments": "Asignaciones de roles",
"nav_role_assignments_desc": "Asignar configuraciones a roles de agente",
"nav_image_models": "Modelos de imagen",
"nav_image_models_desc": "Configurar modelos de generación de imágenes",
"nav_system_instructions": "Instrucciones del sistema",
"nav_system_instructions_desc": "Instrucciones de IA a nivel del espacio de búsqueda",
"nav_public_links": "Enlaces de chat públicos",
"nav_public_links_desc": "Administrar enlaces de chat compartidos públicamente",
"general_name_label": "Nombre",
"general_name_placeholder": "Ingresa el nombre del espacio de búsqueda",
"general_name_description": "Un nombre único para tu espacio de búsqueda.",
"general_description_label": "Descripción",
"general_description_placeholder": "Ingresa la descripción del espacio de búsqueda",
"general_description_description": "Una breve descripción del uso de este espacio de búsqueda.",
"general_reset": "Restablecer cambios",
"general_save": "Guardar cambios",
"general_saving": "Guardando",
"general_unsaved_changes": "Tienes cambios sin guardar. Haz clic en \"Guardar cambios\" para aplicarlos."
},
"homepage": {
"hero_title_part1": "El espacio de trabajo con IA",
"hero_title_part2": "diseñado para equipos",
"hero_description": "Conecta cualquier LLM a tus fuentes de conocimiento internas y chatea con él en tiempo real junto a tu equipo.",
"cta_start_trial": "Comienza gratis",
"cta_explore": "Explorar",
"integrations_title": "Integraciones",
"integrations_subtitle": "Integra con las herramientas más importantes de tu equipo",
"features_title": "El centro de conocimiento impulsado por IA de tu equipo",
"features_subtitle": "Funciones potentes diseñadas para mejorar la colaboración, aumentar la productividad y optimizar tu flujo de trabajo.",
"feature_workflow_title": "Flujo de trabajo optimizado",
"feature_workflow_desc": "Centraliza todo tu conocimiento y recursos en un espacio de trabajo inteligente. Encuentra lo que necesitas al instante y acelera la toma de decisiones.",
"feature_collaboration_title": "Colaboración fluida",
"feature_collaboration_desc": "Trabaja junto a tu equipo sin esfuerzo con herramientas de colaboración en tiempo real que mantienen a todos alineados.",
"feature_customizable_title": "Totalmente personalizable",
"feature_customizable_desc": "Elige entre más de 100 LLMs líderes y llama a cualquier modelo bajo demanda sin problemas.",
"cta_transform": "Transforma cómo tu equipo",
"cta_transform_bold": "descubre y colabora",
"cta_unite_start": "Unifica el",
"cta_unite_knowledge": "conocimiento de tu equipo",
"cta_unite_middle": "en un espacio colaborativo con",
"cta_unite_search": "búsqueda inteligente",
"cta_talk_to_us": "Habla con nosotros",
"features": {
"find_ask_act": {
"title": "Encuentra, pregunta, actúa",
"description": "Obtén información instantánea, actualizaciones detalladas y respuestas con citas del conocimiento de la empresa y personal."
},
"real_time_collab": {
"title": "Trabaja juntos en tiempo real",
"description": "Transforma los documentos de tu empresa en espacios multijugador con ediciones en vivo, contenido sincronizado y presencia."
},
"beyond_text": {
"title": "Colabora más allá del texto",
"description": "Crea podcasts y multimedia que tu equipo puede comentar, compartir y perfeccionar juntos."
},
"context_counts": {
"title": "Contexto donde importa",
"description": "Agrega comentarios directamente a tus chats y documentos para retroalimentación clara y en el momento."
},
"citation_illustration_title": "Ilustración de la función de citas mostrando referencia de fuente clicable",
"referenced_chunk": "Fragmento referenciado",
"collab_illustration_label": "Ilustración de colaboración en tiempo real en un editor de texto.",
"real_time": "Tiempo real",
"collab_part1": "colabo",
"collab_part2": "raci",
"collab_part3": "ón",
"annotation_illustration_label": "Ilustración de un editor de texto con comentarios de anotación.",
"add_context_with": "Agrega contexto con",
"comments": "comentarios",
"example_comment": "¡Hablemos de esto mañana!"
}
},
"public_chat": {
"not_found_title": "Este chat ha sido eliminado.",
"click_here": "Haz clic aquí",
"sign_in_prompt": "para iniciar sesión en SurfSense y comenzar el tuyo."
}
}

View file

@ -0,0 +1,818 @@
{
"common": {
"app_name": "SurfSense",
"welcome": "स्वागत है",
"save": "सहेजें",
"cancel": "रद्द करें",
"delete": "हटाएं",
"edit": "संपादित करें",
"create": "बनाएं",
"update": "अपडेट करें",
"search": "खोजें",
"close": "बंद करें",
"confirm": "पुष्टि करें",
"back": "वापस",
"next": "अगला",
"submit": "सबमिट करें",
"yes": "हाँ",
"no": "नहीं",
"add": "जोड़ें",
"remove": "हटाएं",
"select": "चुनें",
"all": "सभी",
"none": "कोई नहीं",
"error": "त्रुटि",
"success": "सफल",
"warning": "चेतावनी",
"info": "जानकारी",
"required": "आवश्यक",
"optional": "वैकल्पिक",
"retry": "पुनः प्रयास करें",
"owner": "स्वामी",
"shared": "साझा",
"settings": "सेटिंग्स"
},
"auth": {
"login": "लॉगिन",
"register": "पंजीकरण",
"logout": "लॉगआउट",
"email": "ईमेल",
"password": "पासवर्ड",
"confirm_password": "पासवर्ड की पुष्टि करें",
"forgot_password": "पासवर्ड भूल गए?",
"show_password": "पासवर्ड दिखाएं",
"hide_password": "पासवर्ड छुपाएं",
"remember_me": "मुझे याद रखें",
"sign_in": "साइन इन करें",
"signing_in": "साइन इन हो रहा है",
"sign_up": "साइन अप करें",
"sign_in_with": "{provider} से साइन इन करें",
"dont_have_account": "खाता नहीं है?",
"already_have_account": "पहले से खाता है?",
"reset_password": "पासवर्ड रीसेट करें",
"email_required": "ईमेल आवश्यक है",
"password_required": "पासवर्ड आवश्यक है",
"invalid_email": "अमान्य ईमेल पता",
"password_too_short": "पासवर्ड कम से कम 8 अक्षरों का होना चाहिए",
"welcome_back": "वापसी पर स्वागत है",
"create_account": "अपना खाता बनाएं",
"login_subtitle": "अपने खाते तक पहुंचने के लिए अपनी जानकारी दर्ज करें",
"register_subtitle": "SurfSense के साथ शुरू करने के लिए साइन अप करें",
"or_continue_with": "या इसके साथ जारी रखें",
"by_continuing": "जारी रखकर, आप हमारी सहमति देते हैं",
"terms_of_service": "सेवा की शर्तें",
"and": "और",
"privacy_policy": "गोपनीयता नीति",
"full_name": "पूरा नाम",
"username": "उपयोगकर्ता नाम",
"continue": "जारी रखें",
"back_to_login": "लॉगिन पर वापस जाएं",
"login_success": "सफलतापूर्वक लॉगिन हुआ",
"register_success": "खाता सफलतापूर्वक बनाया गया",
"continue_with_google": "Google के साथ जारी रखें",
"cloud_dev_notice": "SurfSense Cloud वर्तमान में विकास में है। देखें",
"docs": "दस्तावेज़",
"cloud_dev_self_hosted": "सेल्फ-होस्टेड संस्करण के बारे में अधिक जानकारी के लिए।",
"passwords_no_match": "पासवर्ड मेल नहीं खाते",
"password_mismatch": "पासवर्ड मेल नहीं खाते",
"passwords_no_match_desc": "आपके द्वारा दर्ज किए गए पासवर्ड मेल नहीं खाते",
"creating_account": "आपका खाता बनाया जा रहा है",
"creating_account_btn": "खाता बनाया जा रहा है",
"redirecting_login": "लॉगिन पेज पर रीडायरेक्ट हो रहा है"
},
"searchSpace": {
"create_title": "सर्च स्पेस बनाएं",
"create_description": "अपने ज्ञान को व्यवस्थित करने के लिए एक नया सर्च स्पेस बनाएं",
"name_label": "नाम",
"name_placeholder": "सर्च स्पेस का नाम दर्ज करें",
"description_label": "विवरण",
"description_placeholder": "यह सर्च स्पेस किसके लिए है?",
"create_button": "बनाएं",
"creating": "बनाया जा रहा है",
"all_search_spaces": "सभी सर्च स्पेस",
"search_spaces_count": "{count, plural, =0 {कोई सर्च स्पेस नहीं} =1 {1 सर्च स्पेस} other {# सर्च स्पेस}}",
"no_search_spaces": "अभी तक कोई सर्च स्पेस नहीं",
"create_first_search_space": "शुरू करने के लिए अपना पहला सर्च स्पेस बनाएं",
"members_count": "{count, plural, =1 {1 सदस्य} other {# सदस्य}}",
"create_new_search_space": "नया सर्च स्पेस बनाएं",
"delete_title": "सर्च स्पेस हटाएं",
"delete_confirm": "क्या आप वाकई \"{name}\" को हटाना चाहते हैं? यह क्रिया पूर्ववत नहीं की जा सकती और सभी डेटा स्थायी रूप से हटा दिया जाएगा।",
"leave": "छोड़ें",
"leave_title": "सर्च स्पेस छोड़ें",
"leave_confirm": "क्या आप वाकई \"{name}\" छोड़ना चाहते हैं? आप इस सर्च स्पेस के सभी दस्तावेज़ों और चैट तक पहुंच खो देंगे।",
"leaving": "छोड़ा जा रहा है...",
"welcome_title": "SurfSense में आपका स्वागत है",
"welcome_description": "अपना ज्ञान व्यवस्थित करने, स्रोत जोड़ने और AI के साथ चैट करने के लिए अपना पहला सर्च स्पेस बनाएं।",
"create_first_button": "अपना पहला सर्च स्पेस बनाएं"
},
"userSettings": {
"title": "उपयोगकर्ता सेटिंग्स",
"description": "अपनी खाता सेटिंग्स और API एक्सेस प्रबंधित करें",
"back_to_app": "ऐप पर वापस जाएं",
"profile_nav_label": "प्रोफ़ाइल",
"profile_nav_description": "अपना नाम और अवतार प्रबंधित करें",
"profile_title": "प्रोफ़ाइल",
"profile_description": "अपनी व्यक्तिगत जानकारी अपडेट करें",
"profile_avatar": "प्रोफ़ाइल चित्र",
"profile_display_name": "प्रदर्शन नाम",
"profile_display_name_hint": "ऐप में आपका नाम इस तरह दिखाई देगा",
"profile_email": "ईमेल",
"profile_save": "परिवर्तन सहेजें",
"profile_saved": "प्रोफ़ाइल सफलतापूर्वक अपडेट की गई",
"profile_save_error": "प्रोफ़ाइल अपडेट करने में विफल",
"api_key_nav_label": "API कुंजी",
"api_key_nav_description": "अपना API एक्सेस टोकन प्रबंधित करें",
"api_key_title": "API कुंजी",
"api_key_description": "API अनुरोधों को प्रमाणित करने के लिए इस कुंजी का उपयोग करें",
"api_key_warning_title": "इसे गुप्त रखें",
"api_key_warning_description": "आपकी API कुंजी आपके खाते तक पूर्ण पहुंच प्रदान करती है। इसे कभी सार्वजनिक रूप से साझा न करें या संस्करण नियंत्रण में शामिल न करें।",
"your_api_key": "आपकी API कुंजी",
"copied": "कॉपी किया गया!",
"copy": "क्लिपबोर्ड पर कॉपी करें",
"no_api_key": "कोई API कुंजी नहीं मिली",
"usage_title": "कैसे उपयोग करें",
"usage_description": "Authorization हेडर में अपनी API कुंजी शामिल करें:"
},
"dashboard": {
"title": "डैशबोर्ड",
"search_spaces": "सर्च स्पेस",
"documents": "दस्तावेज़",
"connectors": "कनेक्टर",
"settings": "सेटिंग्स",
"chat": "चैट",
"api_keys": "API कुंजियां",
"profile": "प्रोफ़ाइल",
"loading_dashboard": "डैशबोर्ड लोड हो रहा है",
"loading_config": "कॉन्फ़िगरेशन लोड हो रहा है",
"config_error": "कॉन्फ़िगरेशन त्रुटि",
"failed_load_llm_config": "आपका LLM कॉन्फ़िगरेशन लोड करने में विफल",
"error_loading_chats": "चैट लोड करने में त्रुटि",
"loading_chat": "चैट लोड हो रहा है",
"loading_document": "दस्तावेज़ लोड हो रहा है",
"no_recent_chats": "कोई हालिया चैट नहीं",
"error_loading_space": "सर्च स्पेस लोड करने में त्रुटि",
"unknown_search_space": "अज्ञात सर्च स्पेस",
"delete_chat": "चैट हटाएं",
"delete_chat_confirm": "क्या आप वाकई हटाना चाहते हैं",
"delete_note": "नोट हटाएं",
"delete_note_confirm": "क्या आप वाकई हटाना चाहते हैं",
"action_cannot_undone": "यह क्रिया पूर्ववत नहीं की जा सकती।",
"deleting": "हटाया जा रहा है",
"surfsense_dashboard": "SurfSense डैशबोर्ड",
"welcome_message": "आपके SurfSense डैशबोर्ड में आपका स्वागत है।",
"your_search_spaces": "आपके सर्च स्पेस",
"shared": "साझा",
"create_search_space": "सर्च स्पेस बनाएं",
"add_new_search_space": "नया सर्च स्पेस जोड़ें",
"loading": "लोड हो रहा है",
"may_take_moment": "इसमें कुछ समय लग सकता है",
"error": "त्रुटि",
"something_wrong": "कुछ गलत हो गया",
"error_details": "त्रुटि विवरण",
"try_again": "पुनः प्रयास करें",
"go_home": "होम पर जाएं",
"delete_search_space": "सर्च स्पेस हटाएं",
"delete_space_confirm": "क्या आप वाकई \"{name}\" को हटाना चाहते हैं? यह क्रिया पूर्ववत नहीं की जा सकती। इस सर्च स्पेस के सभी दस्तावेज़ और चैट स्थायी रूप से हटा दिए जाएंगे।",
"leave": "छोड़ें",
"leave_title": "सर्च स्पेस छोड़ें",
"leave_confirm": "क्या आप वाकई \"{name}\" छोड़ना चाहते हैं? आप इस सर्च स्पेस के सभी दस्तावेज़ों और चैट तक पहुंच खो देंगे।",
"leaving": "छोड़ा जा रहा है...",
"no_spaces_found": "कोई सर्च स्पेस नहीं मिला",
"create_first_space": "शुरू करने के लिए अपना पहला सर्च स्पेस बनाएं",
"created": "बनाया गया"
},
"navigation": {
"home": "होम",
"docs": "दस्तावेज़",
"pricing": "मूल्य निर्धारण",
"contact": "संपर्क",
"login": "लॉगिन",
"register": "पंजीकरण",
"dashboard": "डैशबोर्ड",
"sign_in": "साइन इन",
"book_a_call": "कॉल बुक करें"
},
"nav_menu": {
"settings": "सेटिंग्स",
"platform": "प्लेटफ़ॉर्म",
"chat": "चैट",
"manage_llms": "LLM प्रबंधित करें",
"sources": "स्रोत",
"add_sources": "स्रोत जोड़ें",
"documents": "दस्तावेज़",
"upload_documents": "दस्तावेज़ अपलोड करें",
"add_webpages": "वेबपेज जोड़ें",
"add_youtube": "YouTube वीडियो जोड़ें",
"add_youtube_videos": "YouTube वीडियो जोड़ें",
"manage_documents": "दस्तावेज़ प्रबंधित करें",
"connectors": "कनेक्टर",
"add_connector": "कनेक्टर जोड़ें",
"manage_connectors": "कनेक्टर प्रबंधित करें",
"logs": "लॉग",
"all_search_spaces": "सभी सर्च स्पेस",
"team": "टीम"
},
"pricing": {
"title": "SurfSense मूल्य निर्धारण",
"subtitle": "अपने लिए सही योजना चुनें",
"community_name": "समुदाय",
"enterprise_name": "एंटरप्राइज़",
"forever": "हमेशा के लिए",
"contact_us": "हमसे संपर्क करें",
"feature_llms": "100+ LLM का समर्थन",
"feature_ollama": "स्थानीय Ollama या vLLM सेटअप का समर्थन",
"feature_embeddings": "6000+ एम्बेडिंग मॉडल",
"feature_files": "50+ फ़ाइल एक्सटेंशन समर्थित",
"feature_podcasts": "स्थानीय TTS प्रदाताओं के साथ पॉडकास्ट समर्थन",
"feature_sources": "15+ बाहरी स्रोतों से कनेक्ट",
"feature_extension": "प्रमाणित सामग्री सहित डायनामिक वेबपेजों के लिए क्रॉस-ब्राउज़र एक्सटेंशन",
"upcoming_mindmaps": "जल्द आ रहा है: मर्ज करने योग्य माइंड मैप",
"upcoming_notes": "जल्द आ रहा है: नोट प्रबंधन",
"community_desc": "शक्तिशाली सुविधाओं के साथ ओपन सोर्स संस्करण",
"get_started": "शुरू करें",
"everything_community": "समुदाय में सब कुछ शामिल",
"priority_support": "प्राथमिकता समर्थन",
"access_controls": "एक्सेस नियंत्रण",
"collaboration": "सहयोग और मल्टीप्लेयर सुविधाएं",
"video_gen": "वीडियो जनरेशन",
"advanced_security": "उन्नत सुरक्षा सुविधाएं",
"enterprise_desc": "विशेष आवश्यकताओं वाले बड़े संगठनों के लिए",
"contact_sales": "सेल्स से संपर्क करें"
},
"contact": {
"title": "संपर्क",
"subtitle": "हम आपसे सुनना चाहेंगे।",
"we_are_here": "हम यहां हैं",
"full_name": "पूरा नाम",
"email_address": "ईमेल पता",
"company": "कंपनी",
"message": "संदेश",
"optional": "वैकल्पिक",
"name_placeholder": "राहुल शर्मा",
"email_placeholder": "rahul.sharma@example.com",
"company_placeholder": "उदाहरण प्रा. लि.",
"message_placeholder": "अपना संदेश यहां लिखें",
"submit": "सबमिट करें",
"submitting": "सबमिट हो रहा है...",
"name_required": "नाम आवश्यक है",
"name_too_long": "नाम बहुत लंबा है",
"invalid_email": "अमान्य ईमेल पता",
"email_too_long": "ईमेल बहुत लंबा है",
"company_required": "कंपनी आवश्यक है",
"company_too_long": "कंपनी का नाम बहुत लंबा है",
"message_sent": "संदेश सफलतापूर्वक भेजा गया!",
"we_will_contact": "हम जल्द से जल्द आपसे संपर्क करेंगे।",
"send_failed": "संदेश भेजने में विफल",
"try_again_later": "कृपया बाद में पुनः प्रयास करें।",
"something_wrong": "कुछ गलत हो गया"
},
"connectors": {
"title": "कनेक्टर",
"subtitle": "अपने कनेक्टेड सेवाओं और डेटा स्रोतों को प्रबंधित करें।",
"add_connector": "कनेक्टर जोड़ें",
"your_connectors": "आपके कनेक्टर",
"view_manage": "अपनी सभी कनेक्टेड सेवाओं को देखें और प्रबंधित करें।",
"no_connectors": "कोई कनेक्टर नहीं मिला",
"no_connectors_desc": "आपने अभी तक कोई कनेक्टर नहीं जोड़ा है। अपनी खोज क्षमताओं को बढ़ाने के लिए एक जोड़ें।",
"add_first": "अपना पहला कनेक्टर जोड़ें",
"name": "नाम",
"type": "प्रकार",
"last_indexed": "अंतिम इंडेक्सिंग",
"periodic": "आवधिक",
"actions": "क्रियाएं",
"never": "कभी नहीं",
"not_indexable": "इंडेक्स करने योग्य नहीं",
"index_date_range": "तिथि सीमा के साथ इंडेक्स करें",
"quick_index": "त्वरित इंडेक्स",
"quick_index_auto": "त्वरित इंडेक्स (स्वचालित तिथि सीमा)",
"delete_connector": "कनेक्टर हटाएं",
"delete_confirm": "क्या आप वाकई इस कनेक्टर को हटाना चाहते हैं? यह क्रिया पूर्ववत नहीं की जा सकती।",
"select_date_range": "इंडेक्सिंग के लिए तिथि सीमा चुनें",
"select_date_range_desc": "सामग्री इंडेक्स करने के लिए प्रारंभ और समाप्ति तिथियां चुनें। डिफ़ॉल्ट सीमा का उपयोग करने के लिए खाली छोड़ दें।",
"start_date": "प्रारंभ तिथि",
"end_date": "समाप्ति तिथि",
"pick_date": "तिथि चुनें",
"clear_dates": "तिथियां साफ करें",
"last_30_days": "पिछले 30 दिन",
"last_year": "पिछला वर्ष",
"start_indexing": "इंडेक्सिंग शुरू करें",
"failed_load": "कनेक्टर लोड करने में विफल",
"delete_success": "कनेक्टर सफलतापूर्वक हटाया गया",
"delete_failed": "कनेक्टर हटाने में विफल",
"indexing_started": "कनेक्टर सामग्री इंडेक्सिंग शुरू हो गई",
"indexing_failed": "कनेक्टर सामग्री इंडेक्स करने में विफल"
},
"documents": {
"title": "दस्तावेज़",
"subtitle": "अपने दस्तावेज़ और फ़ाइलें प्रबंधित करें।",
"no_rows_selected": "कोई पंक्ति चयनित नहीं",
"delete_success_count": "{count} दस्तावेज़ सफलतापूर्वक हटाए गए",
"delete_partial_failed": "कुछ दस्तावेज़ हटाए नहीं जा सके",
"delete_success": "दस्तावेज़ सफलतापूर्वक हटाया गया",
"delete_error": "दस्तावेज़ हटाने में त्रुटि",
"filter_by_title": "शीर्षक से फ़िल्टर करें...",
"bulk_delete": "चयनित हटाएं",
"filter_types": "प्रकार फ़िल्टर करें",
"columns": "कॉलम",
"confirm_delete": "हटाने की पुष्टि करें",
"confirm_delete_desc": "क्या आप वाकई {count} दस्तावेज़ हटाना चाहते हैं? यह क्रिया पूर्ववत नहीं की जा सकती।",
"uploading": "अपलोड हो रहा है",
"upload_success": "दस्तावेज़ सफलतापूर्वक अपलोड किया गया",
"upload_failed": "दस्तावेज़ अपलोड करने में विफल",
"loading": "दस्तावेज़ लोड हो रहे हैं",
"error_loading": "दस्तावेज़ लोड करने में त्रुटि",
"retry": "पुनः प्रयास करें",
"no_documents": "कोई दस्तावेज़ नहीं मिला",
"type": "प्रकार",
"content_summary": "सामग्री सारांश",
"view_full": "सारांश देखें",
"filter_placeholder": "शीर्षक से फ़िल्टर करें...",
"rows_per_page": "प्रति पृष्ठ पंक्तियां",
"refresh": "रीफ्रेश करें",
"upload_documents": "दस्तावेज़ अपलोड करें",
"create_shared_note": "साझा नोट बनाएं",
"processing_documents": "दस्तावेज़ प्रोसेस हो रहे हैं...",
"active_tasks_count": "{count} सक्रिय कार्य"
},
"add_connector": {
"title": "अपने टूल कनेक्ट करें",
"subtitle": "अपनी शोध क्षमताओं को बढ़ाने के लिए अपनी पसंदीदा सेवाओं के साथ एकीकृत करें।",
"web_search": "वेब खोज",
"messaging": "मैसेजिंग",
"project_management": "प्रोजेक्ट प्रबंधन",
"documentation": "दस्तावेज़ीकरण",
"development": "विकास",
"databases": "डेटाबेस",
"productivity": "उत्पादकता",
"web_crawling": "वेब क्रॉलिंग",
"connect": "कनेक्ट करें",
"coming_soon": "जल्द आ रहा है",
"connected": "कनेक्टेड",
"manage": "प्रबंधित करें",
"tavily_desc": "Tavily API का उपयोग करके वेब खोजें",
"searxng_desc": "वेब परिणामों के लिए अपनी SearxNG मेटा-सर्च इंस्टेंस का उपयोग करें।",
"linkup_desc": "Linkup API का उपयोग करके वेब खोजें",
"elasticsearch_desc": "दस्तावेज़, लॉग और मेट्रिक्स को इंडेक्स और खोजने के लिए Elasticsearch से कनेक्ट करें।",
"baidu_desc": "Baidu AI Search API का उपयोग करके चीनी वेब खोजें",
"slack_desc": "संदेशों और चैनलों तक पहुंचने के लिए अपने Slack वर्कस्पेस से कनेक्ट करें।",
"teams_desc": "अपनी टीम की बातचीत तक पहुंचने के लिए Microsoft Teams से कनेक्ट करें।",
"discord_desc": "संदेशों और चैनलों तक पहुंचने के लिए Discord सर्वरों से कनेक्ट करें।",
"linear_desc": "इश्यू, टिप्पणियां और प्रोजेक्ट डेटा खोजने के लिए Linear से कनेक्ट करें।",
"jira_desc": "इश्यू, टिकट और प्रोजेक्ट डेटा खोजने के लिए Jira से कनेक्ट करें।",
"clickup_desc": "टास्क, टिप्पणियां और प्रोजेक्ट डेटा खोजने के लिए ClickUp से कनेक्ट करें।",
"notion_desc": "पेज और डेटाबेस तक पहुंचने के लिए अपने Notion वर्कस्पेस से कनेक्ट करें।",
"github_desc": "एक्सेसिबल रिपॉजिटरी से कोड और दस्तावेज़ इंडेक्स करने के लिए GitHub PAT कनेक्ट करें।",
"confluence_desc": "पेज, टिप्पणियां और दस्तावेज़ खोजने के लिए Confluence से कनेक्ट करें।",
"bookstack_desc": "विकी पेज और दस्तावेज़ खोजने के लिए BookStack से कनेक्ट करें।",
"airtable_desc": "रिकॉर्ड, टेबल और डेटाबेस सामग्री खोजने के लिए Airtable से कनेक्ट करें।",
"luma_desc": "इवेंट, मीटअप और गैदरिंग खोजने के लिए Luma से कनेक्ट करें।",
"circleback_desc": "वेबहुक के माध्यम से Circleback से मीटिंग नोट्स, ट्रांसक्रिप्ट और एक्शन आइटम प्राप्त करें।",
"calendar_desc": "इवेंट, मीटिंग और शेड्यूल खोजने के लिए Google Calendar से कनेक्ट करें।",
"gmail_desc": "अपने ईमेल में खोज करने के लिए अपने Gmail खाते से कनेक्ट करें।",
"google_drive_desc": "अपनी फ़ाइलें और दस्तावेज़ खोजने और इंडेक्स करने के लिए Google Drive से कनेक्ट करें।",
"zoom_desc": "मीटिंग रिकॉर्डिंग और ट्रांसक्रिप्ट तक पहुंचने के लिए Zoom से कनेक्ट करें।",
"webcrawler_desc": "किसी भी सार्वजनिक वेब पेज से सामग्री क्रॉल और इंडेक्स करें।"
},
"upload_documents": {
"title": "दस्तावेज़ अपलोड करें",
"subtitle": "AI-संचालित बातचीत के माध्यम से अपनी फ़ाइलों को खोजने योग्य और सुलभ बनाने के लिए अपलोड करें।",
"file_size_limit": "अधिकतम फ़ाइल आकार: प्रति फ़ाइल 50MB।",
"upload_limits": "अपलोड सीमा: {maxFiles} फ़ाइलें, कुल {maxSizeMB}MB।",
"drop_files": "फ़ाइलें यहां छोड़ें",
"drag_drop": "फ़ाइलें यहां खींचें और छोड़ें",
"or_browse": "या ब्राउज़ करने के लिए क्लिक करें",
"browse_files": "फ़ाइलें ब्राउज़ करें",
"selected_files": "चयनित फ़ाइलें ({count})",
"total_size": "कुल आकार",
"clear_all": "सभी साफ करें",
"uploading_files": "फ़ाइलें अपलोड हो रही हैं",
"uploading": "अपलोड हो रहा है",
"upload_button": "{count} {count, plural, one {फ़ाइल} other {फ़ाइलें}} अपलोड करें",
"upload_initiated": "अपलोड कार्य शुरू हुआ",
"upload_initiated_desc": "फ़ाइलों का अपलोड शुरू हो गया है",
"upload_error": "अपलोड त्रुटि",
"upload_error_desc": "फ़ाइलें अपलोड करने में त्रुटि",
"supported_file_types": "समर्थित फ़ाइल प्रकार",
"file_types_desc": "ये फ़ाइल प्रकार आपकी वर्तमान ETL सेवा कॉन्फ़िगरेशन के आधार पर समर्थित हैं।",
"max_files_exceeded": "फ़ाइल सीमा पार हो गई",
"max_files_exceeded_desc": "आप एक बार में अधिकतम {max} फ़ाइलें अपलोड कर सकते हैं।",
"max_size_exceeded": "आकार सीमा पार हो गई",
"max_size_exceeded_desc": "कुल फ़ाइल आकार {max}MB से अधिक नहीं हो सकता।",
"file_limit_reached": "अधिकतम फ़ाइलें पहुंच गई",
"file_limit_reached_desc": "और जोड़ने के लिए कुछ फ़ाइलें हटाएं (अधिकतम {max} फ़ाइलें)।",
"remaining_capacity": "{files} फ़ाइलें शेष • {sizeMB}MB उपलब्ध"
},
"add_webpage": {
"title": "क्रॉलिंग के लिए वेबपेज जोड़ें",
"subtitle": "अपने दस्तावेज़ संग्रह में क्रॉल करने और जोड़ने के लिए URL दर्ज करें",
"label": "क्रॉल करने के लिए URL दर्ज करें",
"placeholder": "URL दर्ज करें और Enter दबाएं",
"hint": "प्रत्येक के बाद Enter दबाकर कई URL जोड़ें",
"tips_title": "URL क्रॉलिंग के लिए सुझाव:",
"tip_1": "http:// या https:// सहित पूरा URL दर्ज करें",
"tip_2": "सुनिश्चित करें कि वेबसाइट क्रॉलिंग की अनुमति देती है",
"tip_3": "सार्वजनिक वेबपेज सबसे अच्छे काम करते हैं",
"tip_4": "वेबसाइट के आकार के आधार पर क्रॉलिंग में कुछ समय लग सकता है",
"cancel": "रद्द करें",
"submit": "क्रॉलिंग के लिए URL सबमिट करें",
"submitting": "सबमिट हो रहा है...",
"error_no_url": "कृपया कम से कम एक URL जोड़ें",
"error_invalid_urls": "अमान्य URL पाए गए: {urls}",
"crawling_toast": "URL क्रॉलिंग",
"crawling_toast_desc": "URL क्रॉलिंग प्रक्रिया शुरू हो रही है...",
"success_toast": "क्रॉलिंग सफल",
"success_toast_desc": "URL क्रॉलिंग के लिए सबमिट किए गए हैं",
"error_toast": "क्रॉलिंग त्रुटि",
"error_toast_desc": "URL क्रॉल करने में त्रुटि",
"error_generic": "URL क्रॉल करते समय त्रुटि हुई",
"invalid_url_toast": "अमान्य URL",
"invalid_url_toast_desc": "कृपया एक मान्य URL दर्ज करें",
"duplicate_url_toast": "डुप्लिकेट URL",
"duplicate_url_toast_desc": "यह URL पहले से जोड़ा जा चुका है"
},
"add_youtube": {
"title": "YouTube वीडियो जोड़ें",
"subtitle": "अपने दस्तावेज़ संग्रह में जोड़ने के लिए YouTube वीडियो URL दर्ज करें",
"label": "YouTube वीडियो URL दर्ज करें",
"placeholder": "YouTube URL दर्ज करें और Enter दबाएं",
"hint": "प्रत्येक के बाद Enter दबाकर कई YouTube URL जोड़ें",
"tips_title": "YouTube वीडियो जोड़ने के लिए सुझाव:",
"tip_1": "मानक YouTube URL का उपयोग करें (youtube.com/watch?v= या youtu.be/)",
"tip_2": "सुनिश्चित करें कि वीडियो सार्वजनिक रूप से सुलभ हैं",
"tip_3": "समर्थित प्रारूप: youtube.com/watch?v=VIDEO_ID या youtu.be/VIDEO_ID",
"tip_4": "वीडियो की अवधि के आधार पर प्रोसेसिंग में कुछ समय लग सकता है",
"preview": "पूर्वावलोकन",
"cancel": "रद्द करें",
"submit": "जोड़ें",
"processing": "प्रोसेस हो रहा है...",
"error_no_video": "कृपया कम से कम एक YouTube वीडियो URL जोड़ें",
"error_invalid_urls": "अमान्य YouTube URL पाए गए: {urls}",
"processing_toast": "YouTube वीडियो प्रोसेसिंग",
"processing_toast_desc": "YouTube वीडियो प्रोसेसिंग शुरू हो रही है...",
"success_toast": "प्रोसेसिंग सफल",
"success_toast_desc": "YouTube वीडियो प्रोसेसिंग के लिए सबमिट किए गए हैं",
"error_toast": "प्रोसेसिंग त्रुटि",
"error_toast_desc": "YouTube वीडियो प्रोसेस करने में त्रुटि",
"error_generic": "YouTube वीडियो प्रोसेस करते समय त्रुटि हुई",
"invalid_url_toast": "अमान्य YouTube URL",
"invalid_url_toast_desc": "कृपया एक मान्य YouTube वीडियो URL दर्ज करें",
"duplicate_url_toast": "डुप्लिकेट URL",
"duplicate_url_toast_desc": "यह YouTube वीडियो पहले से जोड़ा जा चुका है"
},
"settings": {
"title": "सेटिंग्स",
"subtitle": "इस सर्च स्पेस के लिए अपनी LLM कॉन्फ़िगरेशन और भूमिका असाइनमेंट प्रबंधित करें।",
"back_to_dashboard": "डैशबोर्ड पर वापस जाएं",
"model_configs": "मॉडल कॉन्फ़िगरेशन",
"models": "मॉडल",
"llm_roles": "LLM भूमिकाएं",
"roles": "भूमिकाएं",
"llm_role_management": "LLM भूमिका प्रबंधन",
"llm_role_desc": "विभिन्न उद्देश्यों के लिए अपनी LLM कॉन्फ़िगरेशन को विशिष्ट भूमिकाओं में असाइन करें।",
"no_llm_configs_found": "कोई LLM कॉन्फ़िगरेशन नहीं मिला। भूमिकाएं असाइन करने से पहले मॉडल कॉन्फ़िगरेशन टैब में कम से कम एक LLM प्रदाता जोड़ें।",
"select_llm_config": "एक LLM कॉन्फ़िगरेशन चुनें",
"long_context_llm": "लंबे कॉन्टेक्स्ट LLM",
"fast_llm": "तेज़ LLM",
"strategic_llm": "रणनीतिक LLM",
"long_context_desc": "लंबे दस्तावेज़ सारांश और जटिल प्रश्नोत्तर संभालता है",
"long_context_examples": "दस्तावेज़ विश्लेषण, शोध संश्लेषण, जटिल प्रश्नोत्तर",
"large_context_window": "बड़ी कॉन्टेक्स्ट विंडो",
"deep_reasoning": "गहन तर्क",
"complex_analysis": "जटिल विश्लेषण",
"fast_llm_desc": "त्वरित प्रतिक्रियाओं और रीयल-टाइम इंटरैक्शन के लिए अनुकूलित",
"fast_llm_examples": "त्वरित खोज, सरल प्रश्न, तत्काल प्रतिक्रियाएं",
"low_latency": "कम विलंबता",
"quick_responses": "त्वरित प्रतिक्रियाएं",
"real_time_chat": "रीयल-टाइम चैट",
"strategic_llm_desc": "योजना और रणनीतिक निर्णय लेने के लिए उन्नत तर्क",
"strategic_llm_examples": "वर्कफ़्लो योजना, रणनीतिक विश्लेषण, जटिल समस्या समाधान",
"strategic_thinking": "रणनीतिक सोच",
"long_term_planning": "दीर्घकालिक योजना",
"complex_reasoning": "जटिल तर्क",
"use_cases": "उपयोग के मामले",
"assign_llm_config": "LLM कॉन्फ़िगरेशन असाइन करें",
"unassigned": "असाइन नहीं",
"assigned": "असाइन किया गया",
"model": "मॉडल",
"base": "बेस",
"all_roles_assigned": "सभी भूमिकाएं असाइन और उपयोग के लिए तैयार हैं! आपका LLM कॉन्फ़िगरेशन पूरा हो गया है।",
"save_changes": "परिवर्तन सहेजें",
"saving": "सहेजा जा रहा है",
"reset": "रीसेट करें",
"status": "स्थिति",
"status_ready": "तैयार",
"status_setup": "सेटअप",
"complete_role_assignments": "पूर्ण कार्यक्षमता सक्षम करने के लिए सभी भूमिका असाइनमेंट पूरे करें। प्रत्येक भूमिका आपके वर्कफ़्लो में अलग-अलग उद्देश्य पूरा करती है।",
"all_roles_saved": "सभी भूमिकाएं असाइन और सहेजी गई!",
"progress": "प्रगति",
"roles_assigned_count": "{total} में से {assigned} भूमिकाएं असाइन की गईं"
},
"logs": {
"title": "कार्य लॉग",
"subtitle": "सभी कार्य निष्पादन लॉग की निगरानी और विश्लेषण करें",
"refresh": "रीफ्रेश करें",
"delete_selected": "चयनित हटाएं",
"confirm_title": "क्या आप पूरी तरह सुनिश्चित हैं?",
"confirm_delete_desc": "यह क्रिया पूर्ववत नहीं की जा सकती। यह {count} चयनित लॉग को स्थायी रूप से हटा देगी।",
"cancel": "रद्द करें",
"delete": "हटाएं",
"level": "स्तर",
"status": "स्थिति",
"source": "स्रोत",
"message": "संदेश",
"created_at": "बनाया गया",
"actions": "क्रियाएं",
"system": "सिस्टम",
"filter_by_message": "संदेश से फ़िल्टर करें...",
"filter_by": "इसके द्वारा फ़िल्टर करें",
"total_logs": "कुल लॉग",
"active_tasks": "सक्रिय कार्य",
"success_rate": "सफलता दर",
"recent_failures": "हालिया विफलताएं",
"last_hours": "पिछले {hours} घंटे",
"currently_running": "वर्तमान में चल रहा है",
"successful": "सफल",
"need_attention": "ध्यान देने की आवश्यकता",
"no_logs": "कोई लॉग नहीं मिला",
"loading": "लॉग लोड हो रहे हैं...",
"error_loading": "लॉग लोड करने में त्रुटि",
"columns": "कॉलम",
"failed_load_summary": "सारांश लोड करने में विफल",
"retry": "पुनः प्रयास करें",
"view": "देखें",
"toggle_columns": "कॉलम टॉगल करें",
"rows_per_page": "प्रति पृष्ठ पंक्तियां",
"view_metadata": "मेटाडेटा देखें",
"log_deleted_success": "लॉग सफलतापूर्वक हटाया गया",
"log_deleted_error": "लॉग हटाने में विफल",
"confirm_delete_log_title": "क्या आप सुनिश्चित हैं?",
"confirm_delete_log_desc": "यह क्रिया पूर्ववत नहीं की जा सकती। यह लॉग प्रविष्टि को स्थायी रूप से हटा देगी।",
"deleting": "हटाया जा रहा है"
},
"onboard": {
"welcome_title": "SurfSense में आपका स्वागत है",
"welcome_subtitle": "शुरू करने के लिए अपनी LLM कॉन्फ़िगरेशन सेट करें",
"step_of": "चरण {current} / {total}",
"percent_complete": "{percent}% पूर्ण",
"add_llm_provider": "LLM प्रदाता जोड़ें",
"assign_llm_roles": "LLM भूमिकाएं असाइन करें",
"setup_llm_configuration": "LLM कॉन्फ़िगरेशन सेटअप",
"configure_providers_and_assign_roles": "अपने LLM प्रदाता जोड़ें और उन्हें विशिष्ट भूमिकाओं में असाइन करें",
"assign_llm_roles_title": "LLM भूमिकाएं असाइन करें",
"complete_role_assignment": "जारी रखने के लिए अपनी LLM कॉन्फ़िगरेशन को विशिष्ट भूमिकाओं में असाइन करें",
"setup_complete": "सेटअप पूरा हुआ",
"configure_first_provider": "अपना पहला मॉडल प्रदाता कॉन्फ़िगर करें",
"assign_specific_roles": "अपनी LLM कॉन्फ़िगरेशन को विशिष्ट भूमिकाएं असाइन करें",
"all_set": "SurfSense का उपयोग शुरू करने के लिए आप तैयार हैं!",
"loading_config": "आपकी कॉन्फ़िगरेशन लोड हो रही है...",
"previous": "पिछला",
"next": "अगला",
"complete_setup": "सेटअप पूरा करें",
"add_provider_instruction": "जारी रखने के लिए कम से कम एक LLM प्रदाता जोड़ें। आप कई प्रदाता कॉन्फ़िगर कर सकते हैं और अगले चरण में प्रत्येक के लिए विशिष्ट भूमिकाएं चुन सकते हैं।",
"your_llm_configs": "आपकी LLM कॉन्फ़िगरेशन",
"model": "मॉडल",
"language": "भाषा",
"base": "बेस",
"add_provider_title": "LLM प्रदाता जोड़ें",
"add_provider_subtitle": "शुरू करने के लिए अपना पहला मॉडल प्रदाता कॉन्फ़िगर करें",
"add_provider_button": "प्रदाता जोड़ें",
"add_new_llm_provider": "नया LLM प्रदाता जोड़ें",
"configure_new_provider": "अपने AI सहायक के लिए एक नया भाषा मॉडल प्रदाता कॉन्फ़िगर करें",
"config_name": "कॉन्फ़िगरेशन नाम",
"config_name_required": "कॉन्फ़िगरेशन नाम *",
"config_name_placeholder": "उदा., मेरा OpenAI GPT-4",
"provider": "प्रदाता",
"provider_required": "प्रदाता *",
"provider_placeholder": "प्रदाता चुनें",
"language_optional": "भाषा (वैकल्पिक)",
"language_placeholder": "भाषा चुनें",
"custom_provider_name": "कस्टम प्रदाता नाम *",
"custom_provider_placeholder": "उदा., my-custom-provider",
"model_name_required": "मॉडल नाम *",
"model_name_placeholder": "उदा., gpt-4",
"examples": "उदाहरण",
"api_key_required": "API कुंजी *",
"api_key_placeholder": "आपकी API कुंजी",
"api_base_optional": "API बेस URL (वैकल्पिक)",
"api_base_placeholder": "उदा., https://api.openai.com/v1",
"adding": "जोड़ा जा रहा है...",
"add_provider": "प्रदाता जोड़ें",
"cancel": "रद्द करें",
"assign_roles_instruction": "अपनी LLM कॉन्फ़िगरेशन को विशिष्ट भूमिकाओं में असाइन करें। प्रत्येक भूमिका आपके वर्कफ़्लो में अलग-अलग उद्देश्य पूरा करती है।",
"no_llm_configs_found": "कोई LLM कॉन्फ़िगरेशन नहीं मिला",
"add_provider_before_roles": "भूमिकाएं असाइन करने से पहले कृपया पिछले चरण में कम से कम एक LLM प्रदाता जोड़ें।",
"long_context_llm_title": "लंबे कॉन्टेक्स्ट LLM",
"long_context_llm_desc": "लंबे दस्तावेज़ सारांश और जटिल प्रश्नोत्तर संभालता है",
"long_context_llm_examples": "दस्तावेज़ विश्लेषण, शोध संश्लेषण, जटिल प्रश्नोत्तर",
"fast_llm_title": "तेज़ LLM",
"fast_llm_desc": "त्वरित प्रतिक्रियाओं और रीयल-टाइम इंटरैक्शन के लिए अनुकूलित",
"fast_llm_examples": "त्वरित खोज, सरल प्रश्न, तत्काल प्रतिक्रियाएं",
"strategic_llm_title": "रणनीतिक LLM",
"strategic_llm_desc": "योजना और रणनीतिक निर्णय लेने के लिए उन्नत तर्क",
"strategic_llm_examples": "वर्कफ़्लो योजना, रणनीतिक विश्लेषण, जटिल समस्या समाधान",
"use_cases": "उपयोग के मामले",
"assign_llm_config": "LLM कॉन्फ़िगरेशन असाइन करें",
"select_llm_config": "एक LLM कॉन्फ़िगरेशन चुनें",
"assigned": "असाइन किया गया",
"all_roles_assigned_saved": "सभी भूमिकाएं असाइन और सहेजी गई!",
"progress": "प्रगति",
"roles_assigned": "{total} में से {assigned} भूमिकाएं असाइन की गईं",
"global_configs": "वैश्विक कॉन्फ़िगरेशन",
"your_configs": "आपकी कॉन्फ़िगरेशन"
},
"model_config": {
"title": "मॉडल कॉन्फ़िगरेशन",
"subtitle": "अपने LLM प्रदाता कॉन्फ़िगरेशन और API सेटिंग्स प्रबंधित करें।",
"refresh": "रीफ्रेश करें",
"loading": "कॉन्फ़िगरेशन लोड हो रही हैं...",
"total_configs": "कुल कॉन्फ़िगरेशन",
"unique_providers": "अद्वितीय प्रदाता",
"system_status": "सिस्टम स्थिति",
"active": "सक्रिय",
"your_configs": "आपकी कॉन्फ़िगरेशन",
"manage_configs": "अपने LLM प्रदाता प्रबंधित और कॉन्फ़िगर करें",
"add_config": "कॉन्फ़िगरेशन जोड़ें",
"no_configs": "अभी तक कोई कॉन्फ़िगरेशन नहीं",
"no_configs_desc": "अपनी LLM प्रदाता कॉन्फ़िगरेशन जोड़ें।",
"add_first_config": "पहली कॉन्फ़िगरेशन जोड़ें",
"created": "बनाया गया"
},
"breadcrumb": {
"dashboard": "डैशबोर्ड",
"search_space": "सर्च स्पेस",
"chat": "चैट",
"documents": "दस्तावेज़",
"connectors": "कनेक्टर",
"editor": "एडिटर",
"logs": "लॉग",
"settings": "सेटिंग्स",
"upload_documents": "दस्तावेज़ अपलोड करें",
"add_youtube": "YouTube वीडियो जोड़ें",
"add_webpages": "वेबपेज जोड़ें",
"add_connector": "कनेक्टर जोड़ें",
"manage_connectors": "कनेक्टर प्रबंधित करें",
"edit_connector": "कनेक्टर संपादित करें",
"manage": "प्रबंधित करें"
},
"sidebar": {
"chats": "निजी चैट",
"shared_chats": "साझा चैट",
"search_chats": "चैट खोजें",
"no_chats_found": "कोई चैट नहीं मिला",
"no_shared_chats": "कोई साझा चैट नहीं",
"view_all_shared_chats": "सभी साझा चैट देखें",
"view_all_private_chats": "सभी निजी चैट देखें",
"no_chats": "अभी तक कोई चैट नहीं",
"start_new_chat_hint": "नई चैट शुरू करें",
"error_loading_chats": "चैट लोड करने में त्रुटि",
"chat_deleted": "चैट सफलतापूर्वक हटाया गया",
"error_deleting_chat": "चैट हटाने में विफल",
"delete": "हटाएं",
"try_different_search": "कोई अलग खोज शब्द आज़माएं",
"updated": "अपडेट किया गया",
"more_options": "और विकल्प",
"clear_search": "खोज साफ करें",
"archive": "आर्काइव करें",
"unarchive": "पुनर्स्थापित करें",
"chat_archived": "चैट आर्काइव किया गया",
"chat_unarchived": "चैट पुनर्स्थापित किया गया",
"chat_renamed": "चैट का नाम बदला गया",
"error_renaming_chat": "चैट का नाम बदलने में विफल",
"rename": "नाम बदलें",
"rename_chat": "चैट का नाम बदलें",
"rename_chat_description": "इस वार्तालाप के लिए एक नया नाम दर्ज करें।",
"chat_title_placeholder": "चैट शीर्षक",
"renaming": "नाम बदला जा रहा है...",
"no_archived_chats": "कोई आर्काइव्ड चैट नहीं",
"error_archiving_chat": "चैट आर्काइव करने में विफल",
"new_chat": "नई चैट",
"select_search_space": "सर्च स्पेस चुनें",
"manage_members": "सदस्य प्रबंधित करें",
"search_space_settings": "सर्च स्पेस सेटिंग्स",
"logs": "लॉग",
"see_all_search_spaces": "सभी सर्च स्पेस देखें",
"expand_sidebar": "साइडबार विस्तृत करें",
"collapse_sidebar": "साइडबार संकुचित करें",
"user_settings": "उपयोगकर्ता सेटिंग्स",
"language": "भाषा",
"theme": "थीम",
"light": "लाइट",
"dark": "डार्क",
"system": "सिस्टम",
"logout": "लॉगआउट",
"loggingOut": "लॉगआउट हो रहा है...",
"inbox": "इनबॉक्स",
"search_inbox": "इनबॉक्स में खोजें",
"mark_all_read": "सभी पढ़ा हुआ चिह्नित करें",
"mark_as_read": "पढ़ा हुआ चिह्नित करें",
"mentions": "उल्लेख",
"comments": "टिप्पणियां",
"status": "स्थिति",
"no_results_found": "कोई परिणाम नहीं मिला",
"no_mentions": "कोई उल्लेख नहीं",
"no_mentions_hint": "आप यहां दूसरों के उल्लेख देखेंगे",
"no_comments": "कोई टिप्पणियां नहीं",
"no_comments_hint": "आप यहां उल्लेख और उत्तर देखेंगे",
"no_status_updates": "कोई स्थिति अपडेट नहीं",
"no_status_updates_hint": "दस्तावेज़ और कनेक्टर अपडेट यहां दिखाई देंगे",
"filter": "फ़िल्टर",
"all": "सभी",
"unread": "अपठित",
"connectors": "कनेक्टर",
"all_connectors": "सभी कनेक्टर",
"close": "बंद करें"
},
"errors": {
"something_went_wrong": "कुछ गलत हो गया",
"try_again": "कृपया पुनः प्रयास करें",
"not_found": "नहीं मिला",
"unauthorized": "अनधिकृत",
"forbidden": "प्रतिबंधित",
"server_error": "सर्वर त्रुटि",
"network_error": "नेटवर्क त्रुटि"
},
"searchSpaceSettings": {
"title": "सर्च स्पेस सेटिंग्स",
"back_to_app": "ऐप पर वापस जाएं",
"nav_general": "सामान्य",
"nav_general_desc": "नाम, विवरण और बुनियादी जानकारी",
"nav_agent_configs": "एजेंट कॉन्फ़िगरेशन",
"nav_agent_configs_desc": "प्रॉम्प्ट और उद्धरण के साथ LLM मॉडल",
"nav_role_assignments": "भूमिका असाइनमेंट",
"nav_role_assignments_desc": "एजेंट भूमिकाओं को कॉन्फ़िगरेशन असाइन करें",
"nav_image_models": "इमेज मॉडल",
"nav_image_models_desc": "इमेज जनरेशन मॉडल कॉन्फ़िगर करें",
"nav_system_instructions": "सिस्टम निर्देश",
"nav_system_instructions_desc": "सर्च स्पेस-व्यापी AI निर्देश",
"nav_public_links": "सार्वजनिक चैट लिंक",
"nav_public_links_desc": "सार्वजनिक रूप से साझा किए गए चैट लिंक प्रबंधित करें",
"general_name_label": "नाम",
"general_name_placeholder": "सर्च स्पेस का नाम दर्ज करें",
"general_name_description": "आपके सर्च स्पेस के लिए एक अद्वितीय नाम।",
"general_description_label": "विवरण",
"general_description_placeholder": "सर्च स्पेस का विवरण दर्ज करें",
"general_description_description": "इस सर्च स्पेस के उपयोग का संक्षिप्त विवरण।",
"general_reset": "परिवर्तन रीसेट करें",
"general_save": "परिवर्तन सहेजें",
"general_saving": "सहेजा जा रहा है",
"general_unsaved_changes": "आपके पास सहेजे नहीं गए परिवर्तन हैं। उन्हें लागू करने के लिए \"परिवर्तन सहेजें\" पर क्लिक करें।"
},
"homepage": {
"hero_title_part1": "AI कार्यक्षेत्र",
"hero_title_part2": "टीमों के लिए बनाया गया",
"hero_description": "किसी भी LLM को अपने आंतरिक ज्ञान स्रोतों से जोड़ें और अपनी टीम के साथ रीयल-टाइम में चैट करें।",
"cta_start_trial": "मुफ़्त शुरू करें",
"cta_explore": "एक्सप्लोर करें",
"integrations_title": "एकीकरण",
"integrations_subtitle": "अपनी टीम के सबसे महत्वपूर्ण टूल के साथ एकीकृत करें",
"features_title": "आपकी टीम का AI-संचालित ज्ञान केंद्र",
"features_subtitle": "सहयोग बढ़ाने, उत्पादकता बढ़ाने और अपने वर्कफ़्लो को सुव्यवस्थित करने के लिए डिज़ाइन की गई शक्तिशाली सुविधाएं।",
"feature_workflow_title": "सुव्यवस्थित वर्कफ़्लो",
"feature_workflow_desc": "अपने सभी ज्ञान और संसाधनों को एक बुद्धिमान कार्यक्षेत्र में केंद्रित करें। तुरंत वह खोजें जो आपको चाहिए और निर्णय लेने में तेज़ी लाएं।",
"feature_collaboration_title": "सहज सहयोग",
"feature_collaboration_desc": "रीयल-टाइम सहयोग टूल के साथ आसानी से एक साथ काम करें जो आपकी पूरी टीम को संरेखित रखें।",
"feature_customizable_title": "पूरी तरह अनुकूलन योग्य",
"feature_customizable_desc": "100+ प्रमुख LLM में से चुनें और मांग पर किसी भी मॉडल को सहजता से कॉल करें।",
"cta_transform": "बदलें कि आपकी टीम कैसे",
"cta_transform_bold": "खोजती और सहयोग करती है",
"cta_unite_start": "अपनी",
"cta_unite_knowledge": "टीम के ज्ञान",
"cta_unite_middle": "को एक सहयोगी स्थान में एकजुट करें",
"cta_unite_search": "बुद्धिमान खोज के साथ",
"cta_talk_to_us": "हमसे बात करें",
"features": {
"find_ask_act": {
"title": "खोजें, पूछें, कार्य करें",
"description": "कंपनी और व्यक्तिगत ज्ञान से तत्काल जानकारी, विस्तृत अपडेट और उद्धृत उत्तर प्राप्त करें।"
},
"real_time_collab": {
"title": "रीयल-टाइम में एक साथ काम करें",
"description": "अपनी कंपनी के दस्तावेज़ों को लाइव एडिट, सिंक्ड सामग्री और उपस्थिति के साथ मल्टीप्लेयर स्पेस में बदलें।"
},
"beyond_text": {
"title": "टेक्स्ट से परे सहयोग करें",
"description": "पॉडकास्ट और मल्टीमीडिया बनाएं जिन पर आपकी टीम टिप्पणी कर सकती है, साझा कर सकती है और एक साथ सुधार कर सकती है।"
},
"context_counts": {
"title": "जहां मायने रखता है वहां संदर्भ",
"description": "स्पष्ट, तात्कालिक प्रतिक्रिया के लिए सीधे अपने चैट और दस्तावेज़ों में टिप्पणियां जोड़ें।"
},
"citation_illustration_title": "क्लिक करने योग्य स्रोत संदर्भ दिखाने वाली उद्धरण सुविधा का चित्रण",
"referenced_chunk": "संदर्भित अंश",
"collab_illustration_label": "टेक्स्ट एडिटर में रीयल-टाइम सहयोग का चित्रण।",
"real_time": "रीयल-टाइम",
"collab_part1": "सह",
"collab_part2": "यो",
"collab_part3": "ग",
"annotation_illustration_label": "एनोटेशन टिप्पणियों वाले टेक्स्ट एडिटर का चित्रण।",
"add_context_with": "संदर्भ जोड़ें",
"comments": "टिप्पणियों के साथ",
"example_comment": "इस पर कल चर्चा करते हैं!"
}
},
"public_chat": {
"not_found_title": "यह चैट हटा दिया गया है।",
"click_here": "यहां क्लिक करें",
"sign_in_prompt": "SurfSense में लॉगिन करने और अपना शुरू करने के लिए।"
}
}

View file

@ -0,0 +1,818 @@
{
"common": {
"app_name": "SurfSense",
"welcome": "Bem-vindo",
"save": "Salvar",
"cancel": "Cancelar",
"delete": "Excluir",
"edit": "Editar",
"create": "Criar",
"update": "Atualizar",
"search": "Pesquisar",
"close": "Fechar",
"confirm": "Confirmar",
"back": "Voltar",
"next": "Próximo",
"submit": "Enviar",
"yes": "Sim",
"no": "Não",
"add": "Adicionar",
"remove": "Remover",
"select": "Selecionar",
"all": "Tudo",
"none": "Nenhum",
"error": "Erro",
"success": "Sucesso",
"warning": "Aviso",
"info": "Informação",
"required": "Obrigatório",
"optional": "Opcional",
"retry": "Tentar novamente",
"owner": "Proprietário",
"shared": "Compartilhado",
"settings": "Configurações"
},
"auth": {
"login": "Entrar",
"register": "Cadastrar",
"logout": "Sair",
"email": "E-mail",
"password": "Senha",
"confirm_password": "Confirmar senha",
"forgot_password": "Esqueceu a senha?",
"show_password": "Mostrar senha",
"hide_password": "Ocultar senha",
"remember_me": "Lembrar de mim",
"sign_in": "Entrar",
"signing_in": "Entrando",
"sign_up": "Cadastrar",
"sign_in_with": "Entrar com {provider}",
"dont_have_account": "Não tem uma conta?",
"already_have_account": "Já tem uma conta?",
"reset_password": "Redefinir senha",
"email_required": "O e-mail é obrigatório",
"password_required": "A senha é obrigatória",
"invalid_email": "Endereço de e-mail inválido",
"password_too_short": "A senha deve ter pelo menos 8 caracteres",
"welcome_back": "Bem-vindo de volta",
"create_account": "Crie sua conta",
"login_subtitle": "Insira suas credenciais para acessar sua conta",
"register_subtitle": "Cadastre-se para começar a usar o SurfSense",
"or_continue_with": "Ou continuar com",
"by_continuing": "Ao continuar, você concorda com nossos",
"terms_of_service": "Termos de serviço",
"and": "e",
"privacy_policy": "Política de privacidade",
"full_name": "Nome completo",
"username": "Nome de usuário",
"continue": "Continuar",
"back_to_login": "Voltar ao login",
"login_success": "Login realizado com sucesso",
"register_success": "Conta criada com sucesso",
"continue_with_google": "Continuar com Google",
"cloud_dev_notice": "O SurfSense Cloud está atualmente em desenvolvimento. Consulte",
"docs": "Documentação",
"cloud_dev_self_hosted": "para mais informações sobre a versão auto-hospedada.",
"passwords_no_match": "As senhas não coincidem",
"password_mismatch": "Senhas diferentes",
"passwords_no_match_desc": "As senhas que você digitou não coincidem",
"creating_account": "Criando sua conta",
"creating_account_btn": "Criando conta",
"redirecting_login": "Redirecionando para a página de login"
},
"searchSpace": {
"create_title": "Criar espaço de pesquisa",
"create_description": "Crie um novo espaço de pesquisa para organizar seu conhecimento",
"name_label": "Nome",
"name_placeholder": "Digite o nome do espaço de pesquisa",
"description_label": "Descrição",
"description_placeholder": "Para que é este espaço de pesquisa?",
"create_button": "Criar",
"creating": "Criando",
"all_search_spaces": "Todos os espaços de pesquisa",
"search_spaces_count": "{count, plural, =0 {Nenhum espaço de pesquisa} =1 {1 espaço de pesquisa} other {# espaços de pesquisa}}",
"no_search_spaces": "Nenhum espaço de pesquisa ainda",
"create_first_search_space": "Crie seu primeiro espaço de pesquisa para começar",
"members_count": "{count, plural, =1 {1 membro} other {# membros}}",
"create_new_search_space": "Criar novo espaço de pesquisa",
"delete_title": "Excluir espaço de pesquisa",
"delete_confirm": "Tem certeza de que deseja excluir \"{name}\"? Esta ação não pode ser desfeita e removerá permanentemente todos os dados.",
"leave": "Sair",
"leave_title": "Sair do espaço de pesquisa",
"leave_confirm": "Tem certeza de que deseja sair de \"{name}\"? Você perderá acesso a todos os documentos e chats neste espaço de pesquisa.",
"leaving": "Saindo...",
"welcome_title": "Bem-vindo ao SurfSense",
"welcome_description": "Crie seu primeiro espaço de pesquisa para começar a organizar seu conhecimento, conectar fontes e conversar com IA.",
"create_first_button": "Crie seu primeiro espaço de pesquisa"
},
"userSettings": {
"title": "Configurações do usuário",
"description": "Gerencie suas configurações de conta e acesso à API",
"back_to_app": "Voltar ao app",
"profile_nav_label": "Perfil",
"profile_nav_description": "Gerencie seu nome e avatar",
"profile_title": "Perfil",
"profile_description": "Atualize suas informações pessoais",
"profile_avatar": "Foto do perfil",
"profile_display_name": "Nome de exibição",
"profile_display_name_hint": "É assim que seu nome aparece no aplicativo",
"profile_email": "E-mail",
"profile_save": "Salvar alterações",
"profile_saved": "Perfil atualizado com sucesso",
"profile_save_error": "Falha ao atualizar o perfil",
"api_key_nav_label": "Chave API",
"api_key_nav_description": "Gerencie seu token de acesso à API",
"api_key_title": "Chave API",
"api_key_description": "Use esta chave para autenticar solicitações da API",
"api_key_warning_title": "Mantenha em segredo",
"api_key_warning_description": "Sua chave API concede acesso total à sua conta. Nunca a compartilhe publicamente nem a inclua no controle de versão.",
"your_api_key": "Sua chave API",
"copied": "Copiado!",
"copy": "Copiar para a área de transferência",
"no_api_key": "Nenhuma chave API encontrada",
"usage_title": "Como usar",
"usage_description": "Inclua sua chave API no cabeçalho Authorization:"
},
"dashboard": {
"title": "Painel",
"search_spaces": "Espaços de pesquisa",
"documents": "Documentos",
"connectors": "Conectores",
"settings": "Configurações",
"chat": "Chat",
"api_keys": "Chaves API",
"profile": "Perfil",
"loading_dashboard": "Carregando painel",
"loading_config": "Carregando configuração",
"config_error": "Erro de configuração",
"failed_load_llm_config": "Falha ao carregar sua configuração de LLM",
"error_loading_chats": "Erro ao carregar chats",
"loading_chat": "Carregando chat",
"loading_document": "Carregando documento",
"no_recent_chats": "Nenhum chat recente",
"error_loading_space": "Erro ao carregar o espaço de pesquisa",
"unknown_search_space": "Espaço de pesquisa desconhecido",
"delete_chat": "Excluir chat",
"delete_chat_confirm": "Tem certeza de que deseja excluir",
"delete_note": "Excluir nota",
"delete_note_confirm": "Tem certeza de que deseja excluir",
"action_cannot_undone": "Esta ação não pode ser desfeita.",
"deleting": "Excluindo",
"surfsense_dashboard": "Painel do SurfSense",
"welcome_message": "Bem-vindo ao seu painel do SurfSense.",
"your_search_spaces": "Seus espaços de pesquisa",
"shared": "Compartilhado",
"create_search_space": "Criar espaço de pesquisa",
"add_new_search_space": "Adicionar novo espaço de pesquisa",
"loading": "Carregando",
"may_take_moment": "Isso pode levar um momento",
"error": "Erro",
"something_wrong": "Algo deu errado",
"error_details": "Detalhes do erro",
"try_again": "Tentar novamente",
"go_home": "Ir para o início",
"delete_search_space": "Excluir espaço de pesquisa",
"delete_space_confirm": "Tem certeza de que deseja excluir \"{name}\"? Esta ação não pode ser desfeita. Todos os documentos e chats neste espaço de pesquisa serão excluídos permanentemente.",
"leave": "Sair",
"leave_title": "Sair do espaço de pesquisa",
"leave_confirm": "Tem certeza de que deseja sair de \"{name}\"? Você perderá acesso a todos os documentos e chats neste espaço de pesquisa.",
"leaving": "Saindo...",
"no_spaces_found": "Nenhum espaço de pesquisa encontrado",
"create_first_space": "Crie seu primeiro espaço de pesquisa para começar",
"created": "Criado"
},
"navigation": {
"home": "Início",
"docs": "Documentação",
"pricing": "Preços",
"contact": "Contato",
"login": "Entrar",
"register": "Cadastrar",
"dashboard": "Painel",
"sign_in": "Entrar",
"book_a_call": "Agendar uma chamada"
},
"nav_menu": {
"settings": "Configurações",
"platform": "Plataforma",
"chat": "Chat",
"manage_llms": "Gerenciar LLMs",
"sources": "Fontes",
"add_sources": "Adicionar fontes",
"documents": "Documentos",
"upload_documents": "Enviar documentos",
"add_webpages": "Adicionar páginas web",
"add_youtube": "Adicionar vídeos do YouTube",
"add_youtube_videos": "Adicionar vídeos do YouTube",
"manage_documents": "Gerenciar documentos",
"connectors": "Conectores",
"add_connector": "Adicionar conector",
"manage_connectors": "Gerenciar conectores",
"logs": "Logs",
"all_search_spaces": "Todos os espaços de pesquisa",
"team": "Equipe"
},
"pricing": {
"title": "Preços do SurfSense",
"subtitle": "Escolha o que funciona para você",
"community_name": "COMUNIDADE",
"enterprise_name": "EMPRESA",
"forever": "para sempre",
"contact_us": "Fale conosco",
"feature_llms": "Suporta mais de 100 LLMs",
"feature_ollama": "Suporta configurações locais de Ollama ou vLLM",
"feature_embeddings": "Mais de 6000 modelos de embeddings",
"feature_files": "Mais de 50 extensões de arquivo suportadas.",
"feature_podcasts": "Suporte a podcasts com provedores TTS locais.",
"feature_sources": "Conecta com mais de 15 fontes externas.",
"feature_extension": "Extensão multi-navegador para páginas web dinâmicas incluindo conteúdo autenticado",
"upcoming_mindmaps": "Em breve: Mapas mentais combináveis",
"upcoming_notes": "Em breve: Gerenciamento de notas",
"community_desc": "Versão de código aberto com recursos poderosos",
"get_started": "Começar",
"everything_community": "Tudo da Comunidade",
"priority_support": "Suporte prioritário",
"access_controls": "Controles de acesso",
"collaboration": "Colaboração e recursos multiplayer",
"video_gen": "Geração de vídeo",
"advanced_security": "Recursos de segurança avançados",
"enterprise_desc": "Para grandes organizações com necessidades específicas",
"contact_sales": "Falar com vendas"
},
"contact": {
"title": "Contato",
"subtitle": "Adoraríamos ouvir você.",
"we_are_here": "Estamos aqui",
"full_name": "Nome completo",
"email_address": "Endereço de e-mail",
"company": "Empresa",
"message": "Mensagem",
"optional": "opcional",
"name_placeholder": "João Silva",
"email_placeholder": "joao.silva@exemplo.com",
"company_placeholder": "Exemplo Ltda.",
"message_placeholder": "Digite sua mensagem aqui",
"submit": "Enviar",
"submitting": "Enviando...",
"name_required": "O nome é obrigatório",
"name_too_long": "O nome é muito longo",
"invalid_email": "Endereço de e-mail inválido",
"email_too_long": "O e-mail é muito longo",
"company_required": "A empresa é obrigatória",
"company_too_long": "O nome da empresa é muito longo",
"message_sent": "Mensagem enviada com sucesso!",
"we_will_contact": "Entraremos em contato o mais breve possível.",
"send_failed": "Falha ao enviar a mensagem",
"try_again_later": "Por favor, tente novamente mais tarde.",
"something_wrong": "Algo deu errado"
},
"connectors": {
"title": "Conectores",
"subtitle": "Gerencie seus serviços conectados e fontes de dados.",
"add_connector": "Adicionar conector",
"your_connectors": "Seus conectores",
"view_manage": "Visualize e gerencie todos os seus serviços conectados.",
"no_connectors": "Nenhum conector encontrado",
"no_connectors_desc": "Você ainda não adicionou nenhum conector. Adicione um para melhorar suas capacidades de pesquisa.",
"add_first": "Adicione seu primeiro conector",
"name": "Nome",
"type": "Tipo",
"last_indexed": "Última indexação",
"periodic": "Periódico",
"actions": "Ações",
"never": "Nunca",
"not_indexable": "Não indexável",
"index_date_range": "Indexar com intervalo de datas",
"quick_index": "Indexação rápida",
"quick_index_auto": "Indexação rápida (intervalo automático)",
"delete_connector": "Excluir conector",
"delete_confirm": "Tem certeza de que deseja excluir este conector? Esta ação não pode ser desfeita.",
"select_date_range": "Selecionar intervalo de datas para indexação",
"select_date_range_desc": "Escolha as datas de início e fim para indexar o conteúdo. Deixe vazio para usar o intervalo padrão.",
"start_date": "Data de início",
"end_date": "Data de fim",
"pick_date": "Escolher data",
"clear_dates": "Limpar datas",
"last_30_days": "Últimos 30 dias",
"last_year": "Último ano",
"start_indexing": "Iniciar indexação",
"failed_load": "Falha ao carregar conectores",
"delete_success": "Conector excluído com sucesso",
"delete_failed": "Falha ao excluir conector",
"indexing_started": "Indexação do conteúdo do conector iniciada",
"indexing_failed": "Falha ao indexar conteúdo do conector"
},
"documents": {
"title": "Documentos",
"subtitle": "Gerencie seus documentos e arquivos.",
"no_rows_selected": "Nenhuma linha selecionada",
"delete_success_count": "{count} documento(s) excluído(s) com sucesso",
"delete_partial_failed": "Alguns documentos não puderam ser excluídos",
"delete_success": "Documento excluído com sucesso",
"delete_error": "Erro ao excluir documentos",
"filter_by_title": "Filtrar por título...",
"bulk_delete": "Excluir selecionados",
"filter_types": "Filtrar tipos",
"columns": "Colunas",
"confirm_delete": "Confirmar exclusão",
"confirm_delete_desc": "Tem certeza de que deseja excluir {count} documento(s)? Esta ação não pode ser desfeita.",
"uploading": "Enviando",
"upload_success": "Documento enviado com sucesso",
"upload_failed": "Falha ao enviar documento",
"loading": "Carregando documentos",
"error_loading": "Erro ao carregar documentos",
"retry": "Tentar novamente",
"no_documents": "Nenhum documento encontrado",
"type": "Tipo",
"content_summary": "Resumo do conteúdo",
"view_full": "Ver resumo",
"filter_placeholder": "Filtrar por título...",
"rows_per_page": "Linhas por página",
"refresh": "Atualizar",
"upload_documents": "Enviar documentos",
"create_shared_note": "Criar nota compartilhada",
"processing_documents": "Processando documentos...",
"active_tasks_count": "{count} tarefa(s) ativa(s)"
},
"add_connector": {
"title": "Conecte suas ferramentas",
"subtitle": "Integre com seus serviços favoritos para melhorar suas capacidades de pesquisa.",
"web_search": "Pesquisa web",
"messaging": "Mensagens",
"project_management": "Gerenciamento de projetos",
"documentation": "Documentação",
"development": "Desenvolvimento",
"databases": "Bancos de dados",
"productivity": "Produtividade",
"web_crawling": "Rastreamento web",
"connect": "Conectar",
"coming_soon": "Em breve",
"connected": "Conectado",
"manage": "Gerenciar",
"tavily_desc": "Pesquise na web usando a API do Tavily",
"searxng_desc": "Use sua própria instância SearxNG para resultados web.",
"linkup_desc": "Pesquise na web usando a API do Linkup",
"elasticsearch_desc": "Conecte-se ao Elasticsearch para indexar e pesquisar documentos, logs e métricas.",
"baidu_desc": "Pesquise na web chinesa usando a API Baidu AI Search",
"slack_desc": "Conecte-se ao seu workspace do Slack para acessar mensagens e canais.",
"teams_desc": "Conecte-se ao Microsoft Teams para acessar as conversas da sua equipe.",
"discord_desc": "Conecte-se a servidores Discord para acessar mensagens e canais.",
"linear_desc": "Conecte-se ao Linear para pesquisar issues, comentários e dados de projetos.",
"jira_desc": "Conecte-se ao Jira para pesquisar issues, tickets e dados de projetos.",
"clickup_desc": "Conecte-se ao ClickUp para pesquisar tarefas, comentários e dados de projetos.",
"notion_desc": "Conecte-se ao seu workspace do Notion para acessar páginas e bancos de dados.",
"github_desc": "Conecte um PAT do GitHub para indexar código e docs de repositórios acessíveis.",
"confluence_desc": "Conecte-se ao Confluence para pesquisar páginas, comentários e documentação.",
"bookstack_desc": "Conecte-se ao BookStack para pesquisar páginas wiki e documentação.",
"airtable_desc": "Conecte-se ao Airtable para pesquisar registros, tabelas e conteúdo de bancos de dados.",
"luma_desc": "Conecte-se ao Luma para pesquisar eventos, encontros e reuniões.",
"circleback_desc": "Receba notas de reuniões, transcrições e itens de ação do Circleback via webhook.",
"calendar_desc": "Conecte-se ao Google Calendar para pesquisar eventos, reuniões e agendas.",
"gmail_desc": "Conecte-se à sua conta do Gmail para pesquisar seus e-mails.",
"google_drive_desc": "Conecte-se ao Google Drive para pesquisar e indexar seus arquivos e documentos.",
"zoom_desc": "Conecte-se ao Zoom para acessar gravações e transcrições de reuniões.",
"webcrawler_desc": "Rastreie e indexe conteúdo de qualquer página web pública."
},
"upload_documents": {
"title": "Enviar documentos",
"subtitle": "Envie seus arquivos para torná-los pesquisáveis e acessíveis através de conversas com IA.",
"file_size_limit": "Tamanho máximo do arquivo: 50 MB por arquivo.",
"upload_limits": "Limite de envio: {maxFiles} arquivos, {maxSizeMB} MB no total.",
"drop_files": "Solte os arquivos aqui",
"drag_drop": "Arraste e solte arquivos aqui",
"or_browse": "ou clique para navegar",
"browse_files": "Navegar arquivos",
"selected_files": "Arquivos selecionados ({count})",
"total_size": "Tamanho total",
"clear_all": "Limpar tudo",
"uploading_files": "Enviando arquivos",
"uploading": "Enviando",
"upload_button": "Enviar {count} {count, plural, one {arquivo} other {arquivos}}",
"upload_initiated": "Tarefa de envio iniciada",
"upload_initiated_desc": "O envio de arquivos foi iniciado",
"upload_error": "Erro no envio",
"upload_error_desc": "Erro ao enviar arquivos",
"supported_file_types": "Tipos de arquivo suportados",
"file_types_desc": "Estes tipos de arquivo são suportados com base na configuração atual do seu serviço ETL.",
"max_files_exceeded": "Limite de arquivos excedido",
"max_files_exceeded_desc": "Você pode enviar no máximo {max} arquivos de uma vez.",
"max_size_exceeded": "Limite de tamanho excedido",
"max_size_exceeded_desc": "O tamanho total dos arquivos não pode exceder {max} MB.",
"file_limit_reached": "Máximo de arquivos atingido",
"file_limit_reached_desc": "Remova alguns arquivos para adicionar mais (máximo {max} arquivos).",
"remaining_capacity": "{files} arquivos restantes • {sizeMB} MB disponíveis"
},
"add_webpage": {
"title": "Adicionar páginas web para rastreamento",
"subtitle": "Insira URLs para rastrear e adicionar à sua coleção de documentos",
"label": "Insira URLs para rastrear",
"placeholder": "Insira uma URL e pressione Enter",
"hint": "Adicione múltiplas URLs pressionando Enter após cada uma",
"tips_title": "Dicas para rastreamento de URLs:",
"tip_1": "Insira URLs completas incluindo http:// ou https://",
"tip_2": "Certifique-se de que os sites permitem rastreamento",
"tip_3": "Páginas web públicas funcionam melhor",
"tip_4": "O rastreamento pode levar algum tempo dependendo do tamanho do site",
"cancel": "Cancelar",
"submit": "Enviar URLs para rastreamento",
"submitting": "Enviando...",
"error_no_url": "Por favor, adicione pelo menos uma URL",
"error_invalid_urls": "URLs inválidas detectadas: {urls}",
"crawling_toast": "Rastreamento de URL",
"crawling_toast_desc": "Iniciando processo de rastreamento de URL...",
"success_toast": "Rastreamento bem-sucedido",
"success_toast_desc": "As URLs foram enviadas para rastreamento",
"error_toast": "Erro no rastreamento",
"error_toast_desc": "Erro ao rastrear URLs",
"error_generic": "Ocorreu um erro ao rastrear URLs",
"invalid_url_toast": "URL inválida",
"invalid_url_toast_desc": "Por favor, insira uma URL válida",
"duplicate_url_toast": "URL duplicada",
"duplicate_url_toast_desc": "Esta URL já foi adicionada"
},
"add_youtube": {
"title": "Adicionar vídeos do YouTube",
"subtitle": "Insira URLs de vídeos do YouTube para adicionar à sua coleção de documentos",
"label": "Insira URLs de vídeos do YouTube",
"placeholder": "Insira uma URL do YouTube e pressione Enter",
"hint": "Adicione múltiplas URLs do YouTube pressionando Enter após cada uma",
"tips_title": "Dicas para adicionar vídeos do YouTube:",
"tip_1": "Use URLs padrão do YouTube (youtube.com/watch?v= ou youtu.be/)",
"tip_2": "Certifique-se de que os vídeos sejam acessíveis publicamente",
"tip_3": "Formatos suportados: youtube.com/watch?v=VIDEO_ID ou youtu.be/VIDEO_ID",
"tip_4": "O processamento pode levar algum tempo dependendo da duração do vídeo",
"preview": "Visualizar",
"cancel": "Cancelar",
"submit": "Adicionar",
"processing": "Processando...",
"error_no_video": "Por favor, adicione pelo menos uma URL de vídeo do YouTube",
"error_invalid_urls": "URLs do YouTube inválidas detectadas: {urls}",
"processing_toast": "Processamento de vídeo do YouTube",
"processing_toast_desc": "Iniciando processamento de vídeo do YouTube...",
"success_toast": "Processamento bem-sucedido",
"success_toast_desc": "Os vídeos do YouTube foram enviados para processamento",
"error_toast": "Erro no processamento",
"error_toast_desc": "Erro ao processar vídeos do YouTube",
"error_generic": "Ocorreu um erro ao processar vídeos do YouTube",
"invalid_url_toast": "URL do YouTube inválida",
"invalid_url_toast_desc": "Por favor, insira uma URL válida de vídeo do YouTube",
"duplicate_url_toast": "URL duplicada",
"duplicate_url_toast_desc": "Este vídeo do YouTube já foi adicionado"
},
"settings": {
"title": "Configurações",
"subtitle": "Gerencie suas configurações de LLM e atribuições de funções para este espaço de pesquisa.",
"back_to_dashboard": "Voltar ao painel",
"model_configs": "Configurações de modelos",
"models": "Modelos",
"llm_roles": "Funções de LLM",
"roles": "Funções",
"llm_role_management": "Gerenciamento de funções de LLM",
"llm_role_desc": "Atribua suas configurações de LLM a funções específicas para diferentes propósitos.",
"no_llm_configs_found": "Nenhuma configuração de LLM encontrada. Adicione pelo menos um provedor de LLM na aba Configurações de Modelos antes de atribuir funções.",
"select_llm_config": "Selecione uma configuração de LLM",
"long_context_llm": "LLM de contexto longo",
"fast_llm": "LLM rápido",
"strategic_llm": "LLM estratégico",
"long_context_desc": "Lida com resumos de documentos longos e perguntas complexas",
"long_context_examples": "Análise de documentos, síntese de pesquisa, perguntas complexas",
"large_context_window": "Janela de contexto ampla",
"deep_reasoning": "Raciocínio profundo",
"complex_analysis": "Análise complexa",
"fast_llm_desc": "Otimizado para respostas rápidas e interações em tempo real",
"fast_llm_examples": "Pesquisas rápidas, perguntas simples, respostas instantâneas",
"low_latency": "Baixa latência",
"quick_responses": "Respostas rápidas",
"real_time_chat": "Chat em tempo real",
"strategic_llm_desc": "Raciocínio avançado para planejamento e tomada de decisões estratégicas",
"strategic_llm_examples": "Planejamento de fluxos de trabalho, análise estratégica, resolução de problemas complexos",
"strategic_thinking": "Pensamento estratégico",
"long_term_planning": "Planejamento a longo prazo",
"complex_reasoning": "Raciocínio complexo",
"use_cases": "Casos de uso",
"assign_llm_config": "Atribuir configuração de LLM",
"unassigned": "Não atribuído",
"assigned": "Atribuído",
"model": "Modelo",
"base": "Base",
"all_roles_assigned": "Todas as funções estão atribuídas e prontas para uso! Sua configuração de LLM está completa.",
"save_changes": "Salvar alterações",
"saving": "Salvando",
"reset": "Redefinir",
"status": "Status",
"status_ready": "Pronto",
"status_setup": "Configuração",
"complete_role_assignments": "Complete todas as atribuições de funções para habilitar a funcionalidade completa. Cada função tem diferentes propósitos no seu fluxo de trabalho.",
"all_roles_saved": "Todas as funções atribuídas e salvas!",
"progress": "Progresso",
"roles_assigned_count": "{assigned} de {total} funções atribuídas"
},
"logs": {
"title": "Logs de tarefas",
"subtitle": "Monitore e analise todos os logs de execução de tarefas",
"refresh": "Atualizar",
"delete_selected": "Excluir selecionados",
"confirm_title": "Tem certeza absoluta?",
"confirm_delete_desc": "Esta ação não pode ser desfeita. Isso excluirá permanentemente {count} log(s) selecionado(s).",
"cancel": "Cancelar",
"delete": "Excluir",
"level": "Nível",
"status": "Status",
"source": "Fonte",
"message": "Mensagem",
"created_at": "Criado em",
"actions": "Ações",
"system": "Sistema",
"filter_by_message": "Filtrar por mensagem...",
"filter_by": "Filtrar por",
"total_logs": "Total de logs",
"active_tasks": "Tarefas ativas",
"success_rate": "Taxa de sucesso",
"recent_failures": "Falhas recentes",
"last_hours": "Últimas {hours} horas",
"currently_running": "Em execução",
"successful": "bem-sucedido",
"need_attention": "Precisa de atenção",
"no_logs": "Nenhum log encontrado",
"loading": "Carregando logs...",
"error_loading": "Erro ao carregar logs",
"columns": "Colunas",
"failed_load_summary": "Falha ao carregar resumo",
"retry": "Tentar novamente",
"view": "Ver",
"toggle_columns": "Alternar colunas",
"rows_per_page": "Linhas por página",
"view_metadata": "Ver metadados",
"log_deleted_success": "Log excluído com sucesso",
"log_deleted_error": "Falha ao excluir log",
"confirm_delete_log_title": "Tem certeza?",
"confirm_delete_log_desc": "Esta ação não pode ser desfeita. Isso excluirá permanentemente a entrada do log.",
"deleting": "Excluindo"
},
"onboard": {
"welcome_title": "Bem-vindo ao SurfSense",
"welcome_subtitle": "Vamos configurar suas configurações de LLM para começar",
"step_of": "Passo {current} de {total}",
"percent_complete": "{percent}% concluído",
"add_llm_provider": "Adicionar provedor de LLM",
"assign_llm_roles": "Atribuir funções de LLM",
"setup_llm_configuration": "Configurar LLM",
"configure_providers_and_assign_roles": "Adicione seus provedores de LLM e atribua-os a funções específicas",
"assign_llm_roles_title": "Atribuir funções de LLM",
"complete_role_assignment": "Atribua suas configurações de LLM a funções específicas para continuar",
"setup_complete": "Configuração concluída",
"configure_first_provider": "Configure seu primeiro provedor de modelos",
"assign_specific_roles": "Atribua funções específicas às suas configurações de LLM",
"all_set": "Tudo pronto para começar a usar o SurfSense!",
"loading_config": "Carregando sua configuração...",
"previous": "Anterior",
"next": "Próximo",
"complete_setup": "Concluir configuração",
"add_provider_instruction": "Adicione pelo menos um provedor de LLM para continuar. Você pode configurar múltiplos provedores e escolher funções específicas para cada um no próximo passo.",
"your_llm_configs": "Suas configurações de LLM",
"model": "Modelo",
"language": "Idioma",
"base": "Base",
"add_provider_title": "Adicionar provedor de LLM",
"add_provider_subtitle": "Configure seu primeiro provedor de modelos para começar",
"add_provider_button": "Adicionar provedor",
"add_new_llm_provider": "Adicionar novo provedor de LLM",
"configure_new_provider": "Configure um novo provedor de modelo de linguagem para seu assistente de IA",
"config_name": "Nome da configuração",
"config_name_required": "Nome da configuração *",
"config_name_placeholder": "ex., Meu OpenAI GPT-4",
"provider": "Provedor",
"provider_required": "Provedor *",
"provider_placeholder": "Selecione um provedor",
"language_optional": "Idioma (opcional)",
"language_placeholder": "Selecione o idioma",
"custom_provider_name": "Nome do provedor personalizado *",
"custom_provider_placeholder": "ex., meu-provedor-personalizado",
"model_name_required": "Nome do modelo *",
"model_name_placeholder": "ex., gpt-4",
"examples": "Exemplos",
"api_key_required": "Chave API *",
"api_key_placeholder": "Sua chave API",
"api_base_optional": "URL base da API (opcional)",
"api_base_placeholder": "ex., https://api.openai.com/v1",
"adding": "Adicionando...",
"add_provider": "Adicionar provedor",
"cancel": "Cancelar",
"assign_roles_instruction": "Atribua suas configurações de LLM a funções específicas. Cada função tem diferentes propósitos no seu fluxo de trabalho.",
"no_llm_configs_found": "Nenhuma configuração de LLM encontrada",
"add_provider_before_roles": "Adicione pelo menos um provedor de LLM no passo anterior antes de atribuir funções.",
"long_context_llm_title": "LLM de contexto longo",
"long_context_llm_desc": "Lida com resumos de documentos longos e perguntas complexas",
"long_context_llm_examples": "Análise de documentos, síntese de pesquisa, perguntas complexas",
"fast_llm_title": "LLM rápido",
"fast_llm_desc": "Otimizado para respostas rápidas e interações em tempo real",
"fast_llm_examples": "Pesquisas rápidas, perguntas simples, respostas instantâneas",
"strategic_llm_title": "LLM estratégico",
"strategic_llm_desc": "Raciocínio avançado para planejamento e tomada de decisões estratégicas",
"strategic_llm_examples": "Planejamento de fluxos de trabalho, análise estratégica, resolução de problemas complexos",
"use_cases": "Casos de uso",
"assign_llm_config": "Atribuir configuração de LLM",
"select_llm_config": "Selecione uma configuração de LLM",
"assigned": "Atribuído",
"all_roles_assigned_saved": "Todas as funções atribuídas e salvas!",
"progress": "Progresso",
"roles_assigned": "{assigned} de {total} funções atribuídas",
"global_configs": "Configurações globais",
"your_configs": "Suas configurações"
},
"model_config": {
"title": "Configurações de modelos",
"subtitle": "Gerencie as configurações dos seus provedores de LLM e configurações de API.",
"refresh": "Atualizar",
"loading": "Carregando configurações...",
"total_configs": "Total de configurações",
"unique_providers": "Provedores únicos",
"system_status": "Status do sistema",
"active": "Ativo",
"your_configs": "Suas configurações",
"manage_configs": "Gerencie e configure seus provedores de LLM",
"add_config": "Adicionar configuração",
"no_configs": "Nenhuma configuração ainda",
"no_configs_desc": "Adicione suas próprias configurações de provedor de LLM.",
"add_first_config": "Adicionar primeira configuração",
"created": "Criado"
},
"breadcrumb": {
"dashboard": "Painel",
"search_space": "Espaço de pesquisa",
"chat": "Chat",
"documents": "Documentos",
"connectors": "Conectores",
"editor": "Editor",
"logs": "Logs",
"settings": "Configurações",
"upload_documents": "Enviar documentos",
"add_youtube": "Adicionar vídeos do YouTube",
"add_webpages": "Adicionar páginas web",
"add_connector": "Adicionar conector",
"manage_connectors": "Gerenciar conectores",
"edit_connector": "Editar conector",
"manage": "Gerenciar"
},
"sidebar": {
"chats": "Chats privados",
"shared_chats": "Chats compartilhados",
"search_chats": "Pesquisar chats",
"no_chats_found": "Nenhum chat encontrado",
"no_shared_chats": "Nenhum chat compartilhado",
"view_all_shared_chats": "Ver todos os chats compartilhados",
"view_all_private_chats": "Ver todos os chats privados",
"no_chats": "Nenhum chat ainda",
"start_new_chat_hint": "Iniciar um novo chat",
"error_loading_chats": "Erro ao carregar chats",
"chat_deleted": "Chat excluído com sucesso",
"error_deleting_chat": "Falha ao excluir chat",
"delete": "Excluir",
"try_different_search": "Tente um termo de pesquisa diferente",
"updated": "Atualizado",
"more_options": "Mais opções",
"clear_search": "Limpar pesquisa",
"archive": "Arquivar",
"unarchive": "Restaurar",
"chat_archived": "Chat arquivado",
"chat_unarchived": "Chat restaurado",
"chat_renamed": "Chat renomeado",
"error_renaming_chat": "Falha ao renomear chat",
"rename": "Renomear",
"rename_chat": "Renomear chat",
"rename_chat_description": "Insira um novo nome para esta conversa.",
"chat_title_placeholder": "Título do chat",
"renaming": "Renomeando...",
"no_archived_chats": "Nenhum chat arquivado",
"error_archiving_chat": "Falha ao arquivar chat",
"new_chat": "Novo chat",
"select_search_space": "Selecionar espaço de pesquisa",
"manage_members": "Gerenciar membros",
"search_space_settings": "Configurações do espaço de pesquisa",
"logs": "Logs",
"see_all_search_spaces": "Ver todos os espaços de pesquisa",
"expand_sidebar": "Expandir barra lateral",
"collapse_sidebar": "Recolher barra lateral",
"user_settings": "Configurações do usuário",
"language": "Idioma",
"theme": "Tema",
"light": "Claro",
"dark": "Escuro",
"system": "Sistema",
"logout": "Sair",
"loggingOut": "Saindo...",
"inbox": "Caixa de entrada",
"search_inbox": "Pesquisar caixa de entrada",
"mark_all_read": "Marcar tudo como lido",
"mark_as_read": "Marcar como lido",
"mentions": "Menções",
"comments": "Comentários",
"status": "Status",
"no_results_found": "Nenhum resultado encontrado",
"no_mentions": "Sem menções",
"no_mentions_hint": "Você verá as menções de outros aqui",
"no_comments": "Sem comentários",
"no_comments_hint": "Você verá menções e respostas aqui",
"no_status_updates": "Sem atualizações de status",
"no_status_updates_hint": "Atualizações de documentos e conectores aparecerão aqui",
"filter": "Filtrar",
"all": "Tudo",
"unread": "Não lido",
"connectors": "Conectores",
"all_connectors": "Todos os conectores",
"close": "Fechar"
},
"errors": {
"something_went_wrong": "Algo deu errado",
"try_again": "Por favor, tente novamente",
"not_found": "Não encontrado",
"unauthorized": "Não autorizado",
"forbidden": "Proibido",
"server_error": "Erro do servidor",
"network_error": "Erro de rede"
},
"searchSpaceSettings": {
"title": "Configurações do espaço de pesquisa",
"back_to_app": "Voltar ao app",
"nav_general": "Geral",
"nav_general_desc": "Nome, descrição e informações básicas",
"nav_agent_configs": "Configurações do agente",
"nav_agent_configs_desc": "Modelos LLM com prompts e citações",
"nav_role_assignments": "Atribuições de funções",
"nav_role_assignments_desc": "Atribuir configurações a funções do agente",
"nav_image_models": "Modelos de imagem",
"nav_image_models_desc": "Configurar modelos de geração de imagens",
"nav_system_instructions": "Instruções do sistema",
"nav_system_instructions_desc": "Instruções de IA em nível do espaço de pesquisa",
"nav_public_links": "Links de chat públicos",
"nav_public_links_desc": "Gerenciar links de chat compartilhados publicamente",
"general_name_label": "Nome",
"general_name_placeholder": "Insira o nome do espaço de pesquisa",
"general_name_description": "Um nome único para seu espaço de pesquisa.",
"general_description_label": "Descrição",
"general_description_placeholder": "Insira a descrição do espaço de pesquisa",
"general_description_description": "Uma breve descrição de para que este espaço de pesquisa será usado.",
"general_reset": "Redefinir alterações",
"general_save": "Salvar alterações",
"general_saving": "Salvando",
"general_unsaved_changes": "Você tem alterações não salvas. Clique em \"Salvar alterações\" para aplicá-las."
},
"homepage": {
"hero_title_part1": "O espaço de trabalho com IA",
"hero_title_part2": "feito para equipes",
"hero_description": "Conecte qualquer LLM às suas fontes de conhecimento internas e converse com ele em tempo real junto com sua equipe.",
"cta_start_trial": "Comece gratuitamente",
"cta_explore": "Explorar",
"integrations_title": "Integrações",
"integrations_subtitle": "Integre com as ferramentas mais importantes da sua equipe",
"features_title": "O hub de conhecimento da sua equipe com IA",
"features_subtitle": "Recursos poderosos projetados para melhorar a colaboração, aumentar a produtividade e otimizar seu fluxo de trabalho.",
"feature_workflow_title": "Fluxo de trabalho otimizado",
"feature_workflow_desc": "Centralize todo o seu conhecimento e recursos em um espaço de trabalho inteligente. Encontre o que precisa instantaneamente e acelere a tomada de decisões.",
"feature_collaboration_title": "Colaboração perfeita",
"feature_collaboration_desc": "Trabalhe junto com sua equipe sem esforço com ferramentas de colaboração em tempo real que mantêm todos alinhados.",
"feature_customizable_title": "Totalmente personalizável",
"feature_customizable_desc": "Escolha entre mais de 100 LLMs líderes e chame qualquer modelo sob demanda sem problemas.",
"cta_transform": "Transforme como sua equipe",
"cta_transform_bold": "descobre e colabora",
"cta_unite_start": "Unifique o",
"cta_unite_knowledge": "conhecimento da sua equipe",
"cta_unite_middle": "em um espaço colaborativo com",
"cta_unite_search": "pesquisa inteligente",
"cta_talk_to_us": "Fale conosco",
"features": {
"find_ask_act": {
"title": "Encontre, pergunte, aja",
"description": "Obtenha informações instantâneas, atualizações detalhadas e respostas com citações do conhecimento da empresa e pessoal."
},
"real_time_collab": {
"title": "Trabalhe junto em tempo real",
"description": "Transforme os documentos da sua empresa em espaços multiplayer com edições ao vivo, conteúdo sincronizado e presença."
},
"beyond_text": {
"title": "Colabore além do texto",
"description": "Crie podcasts e multimídia que sua equipe pode comentar, compartilhar e aprimorar juntos."
},
"context_counts": {
"title": "Contexto onde importa",
"description": "Adicione comentários diretamente aos seus chats e documentos para feedback claro e no momento."
},
"citation_illustration_title": "Ilustração do recurso de citação mostrando referência de fonte clicável",
"referenced_chunk": "Trecho referenciado",
"collab_illustration_label": "Ilustração de colaboração em tempo real em um editor de texto.",
"real_time": "Tempo real",
"collab_part1": "colabo",
"collab_part2": "raç",
"collab_part3": "ão",
"annotation_illustration_label": "Ilustração de um editor de texto com comentários de anotação.",
"add_context_with": "Adicione contexto com",
"comments": "comentários",
"example_comment": "Vamos discutir isso amanhã!"
}
},
"public_chat": {
"not_found_title": "Este chat foi excluído.",
"click_here": "Clique aqui",
"sign_in_prompt": "para entrar no SurfSense e começar o seu."
}
}

View file

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

View file

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

File diff suppressed because it is too large Load diff

View 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

View file

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

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

@ -0,0 +1,5 @@
declare module "*.svg" {
import type { FC, SVGProps } from "react";
const content: FC<SVGProps<SVGSVGElement>>;
export default content;
}