mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-26 21:39:43 +02:00
feat: added improved llm model selector
This commit is contained in:
parent
dc19b43967
commit
a3cd598e01
23 changed files with 14733 additions and 126 deletions
|
|
@ -230,10 +230,12 @@ def create_create_linear_issue_tool(
|
||||||
raise
|
raise
|
||||||
|
|
||||||
logger.error(f"Error creating Linear issue: {e}", exc_info=True)
|
logger.error(f"Error creating Linear issue: {e}", exc_info=True)
|
||||||
if isinstance(e, (ValueError, LinearAPIError)):
|
if isinstance(e, ValueError | LinearAPIError):
|
||||||
message = str(e)
|
message = str(e)
|
||||||
else:
|
else:
|
||||||
message = "Something went wrong while creating the issue. Please try again."
|
message = (
|
||||||
|
"Something went wrong while creating the issue. Please try again."
|
||||||
|
)
|
||||||
return {"status": "error", "message": message}
|
return {"status": "error", "message": message}
|
||||||
|
|
||||||
return create_linear_issue
|
return create_linear_issue
|
||||||
|
|
|
||||||
|
|
@ -238,7 +238,9 @@ def create_delete_linear_issue_tool(
|
||||||
if result.get("status") == "success":
|
if result.get("status") == "success":
|
||||||
result["deleted_from_kb"] = deleted_from_kb
|
result["deleted_from_kb"] = deleted_from_kb
|
||||||
if issue_identifier:
|
if issue_identifier:
|
||||||
result["message"] = f"Issue {issue_identifier} archived successfully."
|
result["message"] = (
|
||||||
|
f"Issue {issue_identifier} archived successfully."
|
||||||
|
)
|
||||||
if deleted_from_kb:
|
if deleted_from_kb:
|
||||||
result["message"] = (
|
result["message"] = (
|
||||||
f"{result.get('message', '')} Also removed from the knowledge base."
|
f"{result.get('message', '')} Also removed from the knowledge base."
|
||||||
|
|
@ -253,10 +255,12 @@ def create_delete_linear_issue_tool(
|
||||||
raise
|
raise
|
||||||
|
|
||||||
logger.error(f"Error deleting Linear issue: {e}", exc_info=True)
|
logger.error(f"Error deleting Linear issue: {e}", exc_info=True)
|
||||||
if isinstance(e, (ValueError, LinearAPIError)):
|
if isinstance(e, ValueError | LinearAPIError):
|
||||||
message = str(e)
|
message = str(e)
|
||||||
else:
|
else:
|
||||||
message = "Something went wrong while deleting the issue. Please try again."
|
message = (
|
||||||
|
"Something went wrong while deleting the issue. Please try again."
|
||||||
|
)
|
||||||
return {"status": "error", "message": message}
|
return {"status": "error", "message": message}
|
||||||
|
|
||||||
return delete_linear_issue
|
return delete_linear_issue
|
||||||
|
|
|
||||||
|
|
@ -290,10 +290,12 @@ def create_update_linear_issue_tool(
|
||||||
raise
|
raise
|
||||||
|
|
||||||
logger.error(f"Error updating Linear issue: {e}", exc_info=True)
|
logger.error(f"Error updating Linear issue: {e}", exc_info=True)
|
||||||
if isinstance(e, (ValueError, LinearAPIError)):
|
if isinstance(e, ValueError | LinearAPIError):
|
||||||
message = str(e)
|
message = str(e)
|
||||||
else:
|
else:
|
||||||
message = "Something went wrong while updating the issue. Please try again."
|
message = (
|
||||||
|
"Something went wrong while updating the issue. Please try again."
|
||||||
|
)
|
||||||
return {"status": "error", "message": message}
|
return {"status": "error", "message": message}
|
||||||
|
|
||||||
return update_linear_issue
|
return update_linear_issue
|
||||||
|
|
|
||||||
|
|
@ -224,10 +224,12 @@ def create_create_notion_page_tool(
|
||||||
raise
|
raise
|
||||||
|
|
||||||
logger.error(f"Error creating Notion page: {e}", exc_info=True)
|
logger.error(f"Error creating Notion page: {e}", exc_info=True)
|
||||||
if isinstance(e, (ValueError, NotionAPIError)):
|
if isinstance(e, ValueError | NotionAPIError):
|
||||||
message = str(e)
|
message = str(e)
|
||||||
else:
|
else:
|
||||||
message = "Something went wrong while creating the page. Please try again."
|
message = (
|
||||||
|
"Something went wrong while creating the page. Please try again."
|
||||||
|
)
|
||||||
return {"status": "error", "message": message}
|
return {"status": "error", "message": message}
|
||||||
|
|
||||||
return create_notion_page
|
return create_notion_page
|
||||||
|
|
|
||||||
|
|
@ -262,10 +262,12 @@ def create_delete_notion_page_tool(
|
||||||
raise
|
raise
|
||||||
|
|
||||||
logger.error(f"Error deleting Notion page: {e}", exc_info=True)
|
logger.error(f"Error deleting Notion page: {e}", exc_info=True)
|
||||||
if isinstance(e, (ValueError, NotionAPIError)):
|
if isinstance(e, ValueError | NotionAPIError):
|
||||||
message = str(e)
|
message = str(e)
|
||||||
else:
|
else:
|
||||||
message = "Something went wrong while deleting the page. Please try again."
|
message = (
|
||||||
|
"Something went wrong while deleting the page. Please try again."
|
||||||
|
)
|
||||||
return {"status": "error", "message": message}
|
return {"status": "error", "message": message}
|
||||||
|
|
||||||
return delete_notion_page
|
return delete_notion_page
|
||||||
|
|
|
||||||
|
|
@ -261,10 +261,12 @@ def create_update_notion_page_tool(
|
||||||
raise
|
raise
|
||||||
|
|
||||||
logger.error(f"Error updating Notion page: {e}", exc_info=True)
|
logger.error(f"Error updating Notion page: {e}", exc_info=True)
|
||||||
if isinstance(e, (ValueError, NotionAPIError)):
|
if isinstance(e, ValueError | NotionAPIError):
|
||||||
message = str(e)
|
message = str(e)
|
||||||
else:
|
else:
|
||||||
message = "Something went wrong while updating the page. Please try again."
|
message = (
|
||||||
|
"Something went wrong while updating the page. Please try again."
|
||||||
|
)
|
||||||
return {"status": "error", "message": message}
|
return {"status": "error", "message": message}
|
||||||
|
|
||||||
return update_notion_page
|
return update_notion_page
|
||||||
|
|
|
||||||
14059
surfsense_backend/app/config/model_list_fallback.json
Normal file
14059
surfsense_backend/app/config/model_list_fallback.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -30,6 +30,7 @@ class LinearAPIError(Exception):
|
||||||
without any additional prefix or wrapping.
|
without any additional prefix or wrapping.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
ORGANIZATION_QUERY = """
|
ORGANIZATION_QUERY = """
|
||||||
query {
|
query {
|
||||||
organization {
|
organization {
|
||||||
|
|
@ -267,7 +268,10 @@ class LinearConnector:
|
||||||
if errors:
|
if errors:
|
||||||
ext = errors[0].get("extensions", {})
|
ext = errors[0].get("extensions", {})
|
||||||
code = ext.get("code", "")
|
code = ext.get("code", "")
|
||||||
if code == "INPUT_ERROR" and "too complex" in errors[0].get("message", "").lower():
|
if (
|
||||||
|
code == "INPUT_ERROR"
|
||||||
|
and "too complex" in errors[0].get("message", "").lower()
|
||||||
|
):
|
||||||
friendly = (
|
friendly = (
|
||||||
"Linear rejected the request because the workspace is too large "
|
"Linear rejected the request because the workspace is too large "
|
||||||
"to fetch in one query. Please try again — if the problem persists, "
|
"to fetch in one query. Please try again — if the problem persists, "
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ from .jira_add_connector_route import router as jira_add_connector_router
|
||||||
from .linear_add_connector_route import router as linear_add_connector_router
|
from .linear_add_connector_route import router as linear_add_connector_router
|
||||||
from .logs_routes import router as logs_router
|
from .logs_routes import router as logs_router
|
||||||
from .luma_add_connector_route import router as luma_add_connector_router
|
from .luma_add_connector_route import router as luma_add_connector_router
|
||||||
|
from .model_list_routes import router as model_list_router
|
||||||
from .new_chat_routes import router as new_chat_router
|
from .new_chat_routes import router as new_chat_router
|
||||||
from .new_llm_config_routes import router as new_llm_config_router
|
from .new_llm_config_routes import router as new_llm_config_router
|
||||||
from .notes_routes import router as notes_router
|
from .notes_routes import router as notes_router
|
||||||
|
|
@ -68,6 +69,7 @@ router.include_router(jira_add_connector_router)
|
||||||
router.include_router(confluence_add_connector_router)
|
router.include_router(confluence_add_connector_router)
|
||||||
router.include_router(clickup_add_connector_router)
|
router.include_router(clickup_add_connector_router)
|
||||||
router.include_router(new_llm_config_router) # LLM configs with prompt configuration
|
router.include_router(new_llm_config_router) # LLM configs with prompt configuration
|
||||||
|
router.include_router(model_list_router) # Dynamic LLM model catalogue from OpenRouter
|
||||||
router.include_router(logs_router)
|
router.include_router(logs_router)
|
||||||
router.include_router(circleback_webhook_router) # Circleback meeting webhooks
|
router.include_router(circleback_webhook_router) # Circleback meeting webhooks
|
||||||
router.include_router(surfsense_docs_router) # Surfsense documentation for citations
|
router.include_router(surfsense_docs_router) # Surfsense documentation for citations
|
||||||
|
|
|
||||||
44
surfsense_backend/app/routes/model_list_routes.py
Normal file
44
surfsense_backend/app/routes/model_list_routes.py
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
"""
|
||||||
|
API route for fetching the available LLM models catalogue.
|
||||||
|
|
||||||
|
Serves a dynamically-updated list sourced from the OpenRouter public API,
|
||||||
|
with a local JSON fallback when the API is unreachable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.db import User
|
||||||
|
from app.services.model_list_service import get_model_list
|
||||||
|
from app.users import current_active_user
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ModelListItem(BaseModel):
|
||||||
|
value: str
|
||||||
|
label: str
|
||||||
|
provider: str
|
||||||
|
context_window: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/models", response_model=list[ModelListItem])
|
||||||
|
async def list_available_models(
|
||||||
|
user: User = Depends(current_active_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Return all available LLM models grouped by provider.
|
||||||
|
|
||||||
|
The list is sourced from the OpenRouter public API and cached for 1 hour.
|
||||||
|
If the API is unreachable, a local fallback file is used instead.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return await get_model_list()
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Failed to fetch model list")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Failed to fetch model list: {e!s}"
|
||||||
|
) from e
|
||||||
|
|
@ -110,13 +110,15 @@ class LinearToolMetadataService:
|
||||||
teams = await self._fetch_teams_context(linear_client)
|
teams = await self._fetch_teams_context(linear_client)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"error": f"Failed to fetch Linear context: {e!s}"}
|
return {"error": f"Failed to fetch Linear context: {e!s}"}
|
||||||
workspaces.append({
|
workspaces.append(
|
||||||
"id": workspace.id,
|
{
|
||||||
"name": workspace.name,
|
"id": workspace.id,
|
||||||
"organization_name": workspace.organization_name,
|
"name": workspace.name,
|
||||||
"teams": teams,
|
"organization_name": workspace.organization_name,
|
||||||
"priorities": priorities,
|
"teams": teams,
|
||||||
})
|
"priorities": priorities,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return {"workspaces": workspaces}
|
return {"workspaces": workspaces}
|
||||||
|
|
||||||
|
|
@ -307,16 +309,10 @@ class LinearToolMetadataService:
|
||||||
Document.document_type == DocumentType.LINEAR_CONNECTOR,
|
Document.document_type == DocumentType.LINEAR_CONNECTOR,
|
||||||
SearchSourceConnector.user_id == user_id,
|
SearchSourceConnector.user_id == user_id,
|
||||||
or_(
|
or_(
|
||||||
func.lower(
|
func.lower(Document.document_metadata.op("->>")("issue_title"))
|
||||||
Document.document_metadata.op("->>")(
|
|
||||||
"issue_title"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
== ref_lower,
|
== ref_lower,
|
||||||
func.lower(
|
func.lower(
|
||||||
Document.document_metadata.op("->>")(
|
Document.document_metadata.op("->>")("issue_identifier")
|
||||||
"issue_identifier"
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
== ref_lower,
|
== ref_lower,
|
||||||
func.lower(Document.title) == ref_lower,
|
func.lower(Document.title) == ref_lower,
|
||||||
|
|
|
||||||
|
|
@ -162,7 +162,10 @@ async def validate_llm_config(
|
||||||
|
|
||||||
|
|
||||||
async def get_search_space_llm_instance(
|
async def get_search_space_llm_instance(
|
||||||
session: AsyncSession, search_space_id: int, role: str, disable_streaming: bool = False
|
session: AsyncSession,
|
||||||
|
search_space_id: int,
|
||||||
|
role: str,
|
||||||
|
disable_streaming: bool = False,
|
||||||
) -> ChatLiteLLM | ChatLiteLLMRouter | None:
|
) -> ChatLiteLLM | ChatLiteLLMRouter | None:
|
||||||
"""
|
"""
|
||||||
Get a ChatLiteLLM instance for a specific search space and role.
|
Get a ChatLiteLLM instance for a specific search space and role.
|
||||||
|
|
@ -384,16 +387,24 @@ async def get_document_summary_llm(
|
||||||
) -> ChatLiteLLM | ChatLiteLLMRouter | None:
|
) -> ChatLiteLLM | ChatLiteLLMRouter | None:
|
||||||
"""Get the search space's document summary LLM instance."""
|
"""Get the search space's document summary LLM instance."""
|
||||||
return await get_search_space_llm_instance(
|
return await get_search_space_llm_instance(
|
||||||
session, search_space_id, LLMRole.DOCUMENT_SUMMARY, disable_streaming=disable_streaming
|
session,
|
||||||
|
search_space_id,
|
||||||
|
LLMRole.DOCUMENT_SUMMARY,
|
||||||
|
disable_streaming=disable_streaming,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Backward-compatible alias (LLM preferences are now per-search-space, not per-user)
|
# Backward-compatible alias (LLM preferences are now per-search-space, not per-user)
|
||||||
async def get_user_long_context_llm(
|
async def get_user_long_context_llm(
|
||||||
session: AsyncSession, user_id: str, search_space_id: int, disable_streaming: bool = False
|
session: AsyncSession,
|
||||||
|
user_id: str,
|
||||||
|
search_space_id: int,
|
||||||
|
disable_streaming: bool = False,
|
||||||
) -> ChatLiteLLM | ChatLiteLLMRouter | None:
|
) -> ChatLiteLLM | ChatLiteLLMRouter | None:
|
||||||
"""
|
"""
|
||||||
Deprecated: Use get_document_summary_llm instead.
|
Deprecated: Use get_document_summary_llm instead.
|
||||||
The user_id parameter is ignored as LLM preferences are now per-search-space.
|
The user_id parameter is ignored as LLM preferences are now per-search-space.
|
||||||
"""
|
"""
|
||||||
return await get_document_summary_llm(session, search_space_id, disable_streaming=disable_streaming)
|
return await get_document_summary_llm(
|
||||||
|
session, search_space_id, disable_streaming=disable_streaming
|
||||||
|
)
|
||||||
|
|
|
||||||
167
surfsense_backend/app/services/model_list_service.py
Normal file
167
surfsense_backend/app/services/model_list_service.py
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
"""
|
||||||
|
Service for fetching and caching the available LLM model list.
|
||||||
|
|
||||||
|
Uses the OpenRouter public API as the primary source, with a local
|
||||||
|
fallback JSON file when the API is unreachable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
OPENROUTER_API_URL = "https://openrouter.ai/api/v1/models"
|
||||||
|
FALLBACK_FILE = Path(__file__).parent.parent / "config" / "model_list_fallback.json"
|
||||||
|
CACHE_TTL_SECONDS = 86400 # 24 hours
|
||||||
|
|
||||||
|
# In-memory cache
|
||||||
|
_cache: list[dict] | None = None
|
||||||
|
_cache_timestamp: float = 0
|
||||||
|
|
||||||
|
# Maps OpenRouter provider slug → our LiteLLMProvider enum value.
|
||||||
|
# Only providers where the model-name part (after the slash) can be
|
||||||
|
# used directly with the native provider's litellm prefix are listed.
|
||||||
|
#
|
||||||
|
# Excluded slugs and why:
|
||||||
|
# "deepseek" - Native API only accepts "deepseek-chat" / "deepseek-reasoner";
|
||||||
|
# OpenRouter uses different names (deepseek-v3.2, deepseek-r1, ...).
|
||||||
|
# "qwen" - Most OpenRouter Qwen entries are open-source models (qwen3-32b, ...)
|
||||||
|
# that are NOT available on the Dashscope API.
|
||||||
|
# "ai21" - OpenRouter name "jamba-large-1.7" != AI21 API name "jamba-1.5-large".
|
||||||
|
# "microsoft" - OpenRouter "microsoft/" = open-source Phi/WizardLM, NOT Azure
|
||||||
|
# OpenAI deployments (which require deployment names, not model ids).
|
||||||
|
OPENROUTER_SLUG_TO_PROVIDER: dict[str, str] = {
|
||||||
|
"openai": "OPENAI",
|
||||||
|
"anthropic": "ANTHROPIC",
|
||||||
|
"google": "GOOGLE",
|
||||||
|
"mistralai": "MISTRAL",
|
||||||
|
"cohere": "COHERE",
|
||||||
|
"x-ai": "XAI",
|
||||||
|
"perplexity": "PERPLEXITY",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _format_context_length(length: int | None) -> str | None:
|
||||||
|
"""Convert a raw token count to a human-readable string (e.g. 128K, 1M)."""
|
||||||
|
if not length:
|
||||||
|
return None
|
||||||
|
if length >= 1_000_000:
|
||||||
|
return f"{length / 1_000_000:g}M"
|
||||||
|
if length >= 1_000:
|
||||||
|
return f"{length / 1_000:g}K"
|
||||||
|
return str(length)
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_from_openrouter() -> list[dict] | None:
|
||||||
|
"""Try fetching the model catalogue from the OpenRouter public API."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
|
response = await client.get(OPENROUTER_API_URL)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
return data.get("data", [])
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to fetch from OpenRouter API: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _load_fallback() -> list[dict]:
|
||||||
|
"""Load the local fallback model list."""
|
||||||
|
try:
|
||||||
|
with open(FALLBACK_FILE, encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return data.get("data", [])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to load fallback model list: %s", e)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _is_text_output_model(model: dict) -> bool:
|
||||||
|
"""Return True if the model's output is text-only (no audio/image generation)."""
|
||||||
|
output_mods = model.get("architecture", {}).get("output_modalities", [])
|
||||||
|
return output_mods == ["text"]
|
||||||
|
|
||||||
|
|
||||||
|
def _process_models(raw_models: list[dict]) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Transform raw OpenRouter model entries into a flat list of
|
||||||
|
{value, label, provider, context_window} dicts.
|
||||||
|
|
||||||
|
Only text-output models are included (audio/image generators are skipped).
|
||||||
|
|
||||||
|
Each OpenRouter model is emitted once for OPENROUTER (full id) and,
|
||||||
|
when the slug maps to a native provider, once more with just the
|
||||||
|
model-name portion.
|
||||||
|
"""
|
||||||
|
processed: list[dict] = []
|
||||||
|
|
||||||
|
for model in raw_models:
|
||||||
|
model_id: str = model.get("id", "")
|
||||||
|
name: str = model.get("name", "")
|
||||||
|
context_length = model.get("context_length")
|
||||||
|
|
||||||
|
if "/" not in model_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not _is_text_output_model(model):
|
||||||
|
continue
|
||||||
|
|
||||||
|
provider_slug, model_name = model_id.split("/", 1)
|
||||||
|
context_window = _format_context_length(context_length)
|
||||||
|
|
||||||
|
# 1) Always emit for OPENROUTER (value = full OpenRouter id)
|
||||||
|
processed.append(
|
||||||
|
{
|
||||||
|
"value": model_id,
|
||||||
|
"label": name,
|
||||||
|
"provider": "OPENROUTER",
|
||||||
|
"context_window": context_window,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2) Emit for the native provider when we have a mapping
|
||||||
|
native_provider = OPENROUTER_SLUG_TO_PROVIDER.get(provider_slug)
|
||||||
|
if native_provider:
|
||||||
|
# Google's Gemini API only serves gemini-* models.
|
||||||
|
# Open-source models like gemma-* are NOT available through it.
|
||||||
|
if native_provider == "GOOGLE" and not model_name.startswith("gemini-"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
processed.append(
|
||||||
|
{
|
||||||
|
"value": model_name,
|
||||||
|
"label": name,
|
||||||
|
"provider": native_provider,
|
||||||
|
"context_window": context_window,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return processed
|
||||||
|
|
||||||
|
|
||||||
|
async def get_model_list() -> list[dict]:
|
||||||
|
"""
|
||||||
|
Return the processed model list, using in-memory cache when fresh.
|
||||||
|
Tries the OpenRouter API first, falls back to the local JSON file.
|
||||||
|
"""
|
||||||
|
global _cache, _cache_timestamp
|
||||||
|
|
||||||
|
if _cache is not None and (time.time() - _cache_timestamp) < CACHE_TTL_SECONDS:
|
||||||
|
return _cache
|
||||||
|
|
||||||
|
raw_models = await _fetch_from_openrouter()
|
||||||
|
|
||||||
|
if raw_models is None:
|
||||||
|
logger.info("Using fallback model list")
|
||||||
|
raw_models = _load_fallback()
|
||||||
|
|
||||||
|
processed = _process_models(raw_models)
|
||||||
|
|
||||||
|
_cache = processed
|
||||||
|
_cache_timestamp = time.time()
|
||||||
|
|
||||||
|
return processed
|
||||||
|
|
@ -57,7 +57,9 @@ class NotionKBSyncService:
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.debug(f"Fetching page content from Notion for page {page_id}")
|
logger.debug(f"Fetching page content from Notion for page {page_id}")
|
||||||
blocks, _ = await notion_connector.get_page_content(page_id, page_title=None)
|
blocks, _ = await notion_connector.get_page_content(
|
||||||
|
page_id, page_title=None
|
||||||
|
)
|
||||||
|
|
||||||
from app.utils.notion_utils import extract_all_block_ids, process_blocks
|
from app.utils.notion_utils import extract_all_block_ids, process_blocks
|
||||||
|
|
||||||
|
|
@ -100,11 +102,16 @@ class NotionKBSyncService:
|
||||||
full_content = fetched_content
|
full_content = fetched_content
|
||||||
content_verified = False
|
content_verified = False
|
||||||
|
|
||||||
logger.debug(f"Final content length: {len(full_content)} chars, verified={content_verified}")
|
logger.debug(
|
||||||
|
f"Final content length: {len(full_content)} chars, verified={content_verified}"
|
||||||
|
)
|
||||||
|
|
||||||
logger.debug("Generating summary and embeddings")
|
logger.debug("Generating summary and embeddings")
|
||||||
user_llm = await get_user_long_context_llm(
|
user_llm = await get_user_long_context_llm(
|
||||||
self.db_session, user_id, search_space_id, disable_streaming=True # disable streaming to avoid leaking into the chat
|
self.db_session,
|
||||||
|
user_id,
|
||||||
|
search_space_id,
|
||||||
|
disable_streaming=True, # disable streaming to avoid leaking into the chat
|
||||||
)
|
)
|
||||||
|
|
||||||
if user_llm:
|
if user_llm:
|
||||||
|
|
|
||||||
262
surfsense_web/app/(home)/uptime/page.tsx
Normal file
262
surfsense_web/app/(home)/uptime/page.tsx
Normal file
|
|
@ -0,0 +1,262 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
import { UPTIME_REPORT_URL } from "@/lib/env-config";
|
||||||
|
|
||||||
|
type UptimeStatus = "up" | "down";
|
||||||
|
|
||||||
|
interface LocationStat {
|
||||||
|
uptime_status: UptimeStatus;
|
||||||
|
response_time: number | null;
|
||||||
|
last_check: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UptimeMonitor {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
target: string;
|
||||||
|
last_check: number;
|
||||||
|
uptime_status: UptimeStatus;
|
||||||
|
monitor_status: string;
|
||||||
|
uptime: number;
|
||||||
|
locations?: Record<string, LocationStat>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UptimeMonitorsApiResponse {
|
||||||
|
monitors?: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const HETRIXTOOLS_API_BASE = "https://api.hetrixtools.com/v3";
|
||||||
|
|
||||||
|
function formatTimestamp(timestamp: number) {
|
||||||
|
if (!Number.isFinite(timestamp) || timestamp <= 0) return "n/a";
|
||||||
|
return new Date(timestamp * 1000).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLocationName(location: string) {
|
||||||
|
return location
|
||||||
|
.replaceAll("_", " ")
|
||||||
|
.split(" ")
|
||||||
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNumber(value: unknown, fallback = 0) {
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const parsed = Number.parseFloat(value);
|
||||||
|
if (Number.isFinite(parsed)) return parsed;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUptimeStatus(value: unknown): UptimeStatus {
|
||||||
|
return value === "down" ? "down" : "up";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMonitor(rawMonitor: unknown): UptimeMonitor | null {
|
||||||
|
if (!rawMonitor || typeof rawMonitor !== "object") return null;
|
||||||
|
|
||||||
|
const monitor = rawMonitor as Record<string, unknown>;
|
||||||
|
const rawLocations =
|
||||||
|
monitor.locations && typeof monitor.locations === "object"
|
||||||
|
? (monitor.locations as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const locations: Record<string, LocationStat> = {};
|
||||||
|
for (const [locationName, rawLocation] of Object.entries(rawLocations)) {
|
||||||
|
if (!rawLocation || typeof rawLocation !== "object") continue;
|
||||||
|
const location = rawLocation as Record<string, unknown>;
|
||||||
|
locations[locationName] = {
|
||||||
|
uptime_status: normalizeUptimeStatus(location.uptime_status),
|
||||||
|
response_time:
|
||||||
|
location.response_time === null ? null : toNumber(location.response_time, 0),
|
||||||
|
last_check: toNumber(location.last_check, 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(monitor.id ?? ""),
|
||||||
|
name: String(monitor.name ?? "Unnamed monitor"),
|
||||||
|
type: String(monitor.type ?? ""),
|
||||||
|
target: String(monitor.target ?? ""),
|
||||||
|
last_check: toNumber(monitor.last_check, 0),
|
||||||
|
uptime_status: normalizeUptimeStatus(monitor.uptime_status),
|
||||||
|
monitor_status: String(monitor.monitor_status ?? "unknown"),
|
||||||
|
uptime: toNumber(monitor.uptime, 0),
|
||||||
|
locations,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchUptimeMonitors(): Promise<{
|
||||||
|
monitors: UptimeMonitor[];
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
const apiKey = process.env.HETRIXTOOLS_API_KEY;
|
||||||
|
const monitorId = process.env.HETRIXTOOLS_MONITOR_ID;
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return {
|
||||||
|
monitors: [],
|
||||||
|
error:
|
||||||
|
"Missing HETRIXTOOLS_API_KEY. Add it to your server environment to enable custom uptime UI.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = monitorId
|
||||||
|
? `id=${encodeURIComponent(monitorId)}`
|
||||||
|
: "per_page=20&page=1&order_by=last_check&order=desc";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${HETRIXTOOLS_API_BASE}/uptime-monitors?${query}`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
},
|
||||||
|
next: { revalidate: 60 },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return {
|
||||||
|
monitors: [],
|
||||||
|
error: `HetrixTools API request failed (${response.status}).`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as UptimeMonitorsApiResponse;
|
||||||
|
const monitors = (data.monitors ?? [])
|
||||||
|
.map((monitor) => normalizeMonitor(monitor))
|
||||||
|
.filter((monitor): monitor is UptimeMonitor => monitor !== null);
|
||||||
|
return { monitors };
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
monitors: [],
|
||||||
|
error: "Could not reach HetrixTools API from the server.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function UptimePage() {
|
||||||
|
const { monitors, error } = await fetchUptimeMonitors();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="min-h-screen pt-24 pb-16">
|
||||||
|
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="rounded-2xl border border-neutral-200/70 bg-white/80 p-6 shadow-sm backdrop-blur-sm dark:border-neutral-800 dark:bg-neutral-950/70">
|
||||||
|
<p className="text-sm font-medium uppercase tracking-wide text-emerald-600 dark:text-emerald-400">
|
||||||
|
System Status
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-2 text-3xl font-bold tracking-tight text-neutral-900 dark:text-neutral-100">
|
||||||
|
SurfSense uptime dashboard
|
||||||
|
</h1>
|
||||||
|
<div className="mt-4 flex flex-wrap items-center gap-3 text-sm">
|
||||||
|
<Link
|
||||||
|
href={UPTIME_REPORT_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center rounded-full border border-neutral-300 px-4 py-2 font-medium text-neutral-700 transition-colors hover:bg-neutral-100 dark:border-neutral-700 dark:text-neutral-200 dark:hover:bg-neutral-900"
|
||||||
|
>
|
||||||
|
Open original report
|
||||||
|
</Link>
|
||||||
|
<span className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
Source: HetrixTools v3 API (`/uptime-monitors`).
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="rounded-2xl border border-amber-300 bg-amber-50 p-5 text-amber-900 dark:border-amber-700/70 dark:bg-amber-950/30 dark:text-amber-200">
|
||||||
|
<p className="font-semibold">Unable to load custom uptime data</p>
|
||||||
|
<p className="mt-1 text-sm">{error}</p>
|
||||||
|
</div>
|
||||||
|
) : monitors.length === 0 ? (
|
||||||
|
<div className="rounded-2xl border border-neutral-200/70 bg-white p-5 text-neutral-700 shadow-sm dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-300">
|
||||||
|
No uptime monitors returned by HetrixTools API.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{monitors.map((monitor) => {
|
||||||
|
const locations = Object.entries(monitor.locations ?? {});
|
||||||
|
const isUp = monitor.uptime_status === "up";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={monitor.id}
|
||||||
|
className="rounded-2xl border border-neutral-200/70 bg-white p-5 shadow-sm dark:border-neutral-800 dark:bg-neutral-950"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||||
|
{monitor.name}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
|
{monitor.target || "No target shown"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`rounded-full px-3 py-1 text-xs font-semibold ${
|
||||||
|
isUp
|
||||||
|
? "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300"
|
||||||
|
: "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isUp ? "Operational" : "Outage"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid gap-3 sm:grid-cols-3">
|
||||||
|
<div className="rounded-xl border border-neutral-200/70 p-3 dark:border-neutral-800">
|
||||||
|
<p className="text-xs text-neutral-500 dark:text-neutral-400">Uptime</p>
|
||||||
|
<p className="mt-1 text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||||
|
{monitor.uptime.toFixed(4)}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-neutral-200/70 p-3 dark:border-neutral-800">
|
||||||
|
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
Last check
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||||
|
{formatTimestamp(monitor.last_check)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-neutral-200/70 p-3 dark:border-neutral-800">
|
||||||
|
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
Monitor status
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm font-medium capitalize text-neutral-900 dark:text-neutral-100">
|
||||||
|
{monitor.monitor_status.replaceAll("_", " ")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{locations.length > 0 && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
|
||||||
|
Locations
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{locations.map(([locationName, locationData]) => (
|
||||||
|
<div
|
||||||
|
key={`${monitor.id}-${locationName}`}
|
||||||
|
className="rounded-xl border border-neutral-200/70 p-3 dark:border-neutral-800"
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||||
|
{formatLocationName(locationName)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-neutral-600 dark:text-neutral-400">
|
||||||
|
{locationData.uptime_status === "up" ? "Up" : "Down"} ·{" "}
|
||||||
|
{locationData.response_time ?? "n/a"} ms
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import { atomWithQuery } from "jotai-tanstack-query";
|
import { atomWithQuery } from "jotai-tanstack-query";
|
||||||
|
import type { LLMModel } from "@/contracts/enums/llm-models";
|
||||||
|
import { LLM_MODELS } from "@/contracts/enums/llm-models";
|
||||||
import { newLLMConfigApiService } from "@/lib/apis/new-llm-config-api.service";
|
import { newLLMConfigApiService } from "@/lib/apis/new-llm-config-api.service";
|
||||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||||
import { activeSearchSpaceIdAtom } from "../search-spaces/search-space-query.atoms";
|
import { activeSearchSpaceIdAtom } from "../search-spaces/search-space-query.atoms";
|
||||||
|
|
@ -62,3 +64,33 @@ export const defaultSystemInstructionsAtom = atomWithQuery(() => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query atom for the dynamic LLM model catalogue.
|
||||||
|
* Fetched from the backend (which proxies OpenRouter's public API).
|
||||||
|
* Falls back to the static hardcoded list on error.
|
||||||
|
*/
|
||||||
|
export const modelListAtom = atomWithQuery(() => {
|
||||||
|
return {
|
||||||
|
queryKey: cacheKeys.newLLMConfigs.modelList(),
|
||||||
|
staleTime: 60 * 60 * 1000, // 1 hour - models don't change often
|
||||||
|
placeholderData: LLM_MODELS,
|
||||||
|
queryFn: async (): Promise<LLMModel[]> => {
|
||||||
|
const data = await newLLMConfigApiService.getModels();
|
||||||
|
const dynamicModels = data.map((m) => ({
|
||||||
|
value: m.value,
|
||||||
|
label: m.label,
|
||||||
|
provider: m.provider,
|
||||||
|
contextWindow: m.context_window ?? undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Providers covered by the dynamic API (from OpenRouter mapping).
|
||||||
|
// For uncovered providers (Ollama, Groq, Bedrock, etc.) keep the
|
||||||
|
// hand-curated static suggestions so users still see model options.
|
||||||
|
const coveredProviders = new Set(dynamicModels.map((m) => m.provider));
|
||||||
|
const staticFallbacks = LLM_MODELS.filter((m) => !coveredProviders.has(m.provider));
|
||||||
|
|
||||||
|
return [...dynamicModels, ...staticFallbacks];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -147,7 +147,7 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
||||||
onClick={() => router.push(`/dashboard/${params.search_space_id}/settings`)}
|
onClick={() => router.push(`/dashboard/${params.search_space_id}/settings`)}
|
||||||
className="flex items-center justify-center h-8 w-8 rounded-md bg-muted/50 hover:bg-muted transition-colors"
|
className="flex items-center justify-center h-8 w-8 rounded-md bg-muted/50 hover:bg-muted transition-colors"
|
||||||
>
|
>
|
||||||
<Globe className="h-4 w-4 text-muted-foreground" />
|
<Earth className="h-4 w-4 text-muted-foreground" />
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,13 @@ import {
|
||||||
Sparkles,
|
Sparkles,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { defaultSystemInstructionsAtom } from "@/atoms/new-llm-config/new-llm-config-query.atoms";
|
import {
|
||||||
|
defaultSystemInstructionsAtom,
|
||||||
|
modelListAtom,
|
||||||
|
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||||
|
|
@ -50,7 +53,6 @@ import { Separator } from "@/components/ui/separator";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { getModelsByProvider } from "@/contracts/enums/llm-models";
|
|
||||||
import { LLM_PROVIDERS } from "@/contracts/enums/llm-providers";
|
import { LLM_PROVIDERS } from "@/contracts/enums/llm-providers";
|
||||||
import type { CreateNewLLMConfigRequest } from "@/contracts/types/new-llm-config.types";
|
import type { CreateNewLLMConfigRequest } from "@/contracts/types/new-llm-config.types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
@ -66,7 +68,7 @@ const formSchema = z.object({
|
||||||
api_key: z.string().min(1, "API key is required"),
|
api_key: z.string().min(1, "API key is required"),
|
||||||
api_base: z.string().max(500).optional().nullable(),
|
api_base: z.string().max(500).optional().nullable(),
|
||||||
litellm_params: z.record(z.string(), z.any()).optional().nullable(),
|
litellm_params: z.record(z.string(), z.any()).optional().nullable(),
|
||||||
system_instructions: z.string().optional().default(""),
|
system_instructions: z.string().default(""),
|
||||||
use_default_system_instructions: z.boolean().default(true),
|
use_default_system_instructions: z.boolean().default(true),
|
||||||
citations_enabled: z.boolean().default(true),
|
citations_enabled: z.boolean().default(true),
|
||||||
search_space_id: z.number(),
|
search_space_id: z.number(),
|
||||||
|
|
@ -74,7 +76,7 @@ const formSchema = z.object({
|
||||||
|
|
||||||
type FormValues = z.infer<typeof formSchema>;
|
type FormValues = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
export interface LLMConfigFormData extends CreateNewLLMConfigRequest {}
|
export type LLMConfigFormData = CreateNewLLMConfigRequest;
|
||||||
|
|
||||||
interface LLMConfigFormProps {
|
interface LLMConfigFormProps {
|
||||||
initialData?: Partial<LLMConfigFormData>;
|
initialData?: Partial<LLMConfigFormData>;
|
||||||
|
|
@ -102,12 +104,14 @@ export function LLMConfigForm({
|
||||||
const { data: defaultInstructions, isSuccess: defaultInstructionsLoaded } = useAtomValue(
|
const { data: defaultInstructions, isSuccess: defaultInstructionsLoaded } = useAtomValue(
|
||||||
defaultSystemInstructionsAtom
|
defaultSystemInstructionsAtom
|
||||||
);
|
);
|
||||||
|
const { data: dynamicModels } = useAtomValue(modelListAtom);
|
||||||
const [modelComboboxOpen, setModelComboboxOpen] = useState(false);
|
const [modelComboboxOpen, setModelComboboxOpen] = useState(false);
|
||||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||||
const [systemInstructionsOpen, setSystemInstructionsOpen] = useState(false);
|
const [systemInstructionsOpen, setSystemInstructionsOpen] = useState(false);
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
const form = useForm<FormValues>({
|
||||||
resolver: zodResolver(formSchema),
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
resolver: zodResolver(formSchema) as any,
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: initialData?.name ?? "",
|
name: initialData?.name ?? "",
|
||||||
description: initialData?.description ?? "",
|
description: initialData?.description ?? "",
|
||||||
|
|
@ -138,7 +142,10 @@ export function LLMConfigForm({
|
||||||
|
|
||||||
const watchProvider = form.watch("provider");
|
const watchProvider = form.watch("provider");
|
||||||
const selectedProvider = LLM_PROVIDERS.find((p) => p.value === watchProvider);
|
const selectedProvider = LLM_PROVIDERS.find((p) => p.value === watchProvider);
|
||||||
const availableModels = watchProvider ? getModelsByProvider(watchProvider) : [];
|
const availableModels = useMemo(
|
||||||
|
() => (dynamicModels ?? []).filter((m) => m.provider === watchProvider),
|
||||||
|
[dynamicModels, watchProvider]
|
||||||
|
);
|
||||||
|
|
||||||
const handleProviderChange = (value: string) => {
|
const handleProviderChange = (value: string) => {
|
||||||
form.setValue("provider", value);
|
form.setValue("provider", value);
|
||||||
|
|
@ -293,57 +300,58 @@ export function LLMConfigForm({
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-full p-0" align="start">
|
<PopoverContent className="w-full p-0" align="start">
|
||||||
<Command shouldFilter={false}>
|
<Command shouldFilter={false}>
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder={selectedProvider?.example || "Type model name..."}
|
placeholder={selectedProvider?.example || "Type model name..."}
|
||||||
value={field.value}
|
value={field.value}
|
||||||
onValueChange={field.onChange}
|
onValueChange={field.onChange}
|
||||||
/>
|
/>
|
||||||
<CommandList>
|
<CommandList className="max-h-[300px]">
|
||||||
<CommandEmpty>
|
<CommandEmpty>
|
||||||
<div className="py-3 text-center text-sm text-muted-foreground">
|
<div className="py-3 text-center text-sm text-muted-foreground">
|
||||||
{field.value ? `Using: "${field.value}"` : "Type your model name"}
|
{field.value ? `Using: "${field.value}"` : "Type your model name"}
|
||||||
</div>
|
</div>
|
||||||
</CommandEmpty>
|
</CommandEmpty>
|
||||||
{availableModels.length > 0 && (
|
{availableModels.length > 0 && (
|
||||||
<CommandGroup heading="Suggested Models">
|
<CommandGroup heading="Suggested Models">
|
||||||
{availableModels
|
{availableModels
|
||||||
.filter(
|
.filter(
|
||||||
(model) =>
|
(model) =>
|
||||||
!field.value ||
|
!field.value ||
|
||||||
model.value.toLowerCase().includes(field.value.toLowerCase())
|
model.value.toLowerCase().includes(field.value.toLowerCase()) ||
|
||||||
)
|
model.label.toLowerCase().includes(field.value.toLowerCase())
|
||||||
.slice(0, 8)
|
)
|
||||||
.map((model) => (
|
.slice(0, 50)
|
||||||
<CommandItem
|
.map((model) => (
|
||||||
key={model.value}
|
<CommandItem
|
||||||
value={model.value}
|
key={model.value}
|
||||||
onSelect={(value) => {
|
value={model.value}
|
||||||
field.onChange(value);
|
onSelect={(value) => {
|
||||||
setModelComboboxOpen(false);
|
field.onChange(value);
|
||||||
}}
|
setModelComboboxOpen(false);
|
||||||
className="py-2"
|
}}
|
||||||
>
|
className="py-2"
|
||||||
<Check
|
>
|
||||||
className={cn(
|
<Check
|
||||||
"mr-2 h-4 w-4",
|
className={cn(
|
||||||
field.value === model.value ? "opacity-100" : "opacity-0"
|
"mr-2 h-4 w-4",
|
||||||
)}
|
field.value === model.value ? "opacity-100" : "opacity-0"
|
||||||
/>
|
)}
|
||||||
<div>
|
/>
|
||||||
<div className="font-medium">{model.label}</div>
|
<div>
|
||||||
{model.contextWindow && (
|
<div className="font-medium">{model.label}</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
{model.contextWindow && (
|
||||||
Context: {model.contextWindow}
|
<div className="text-xs text-muted-foreground">
|
||||||
</div>
|
Context: {model.contextWindow}
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
</CommandItem>
|
</div>
|
||||||
))}
|
</CommandItem>
|
||||||
</CommandGroup>
|
))}
|
||||||
)}
|
</CommandGroup>
|
||||||
</CommandList>
|
)}
|
||||||
</Command>
|
</CommandList>
|
||||||
|
</Command>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
{selectedProvider?.example && (
|
{selectedProvider?.example && (
|
||||||
|
|
@ -376,7 +384,7 @@ export function LLMConfigForm({
|
||||||
</FormControl>
|
</FormControl>
|
||||||
{watchProvider === "OLLAMA" && (
|
{watchProvider === "OLLAMA" && (
|
||||||
<FormDescription className="text-[10px] sm:text-xs">
|
<FormDescription className="text-[10px] sm:text-xs">
|
||||||
Ollama doesn't require auth — enter any value
|
Ollama doesn't require auth — enter any value
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
)}
|
)}
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
|
@ -537,7 +545,7 @@ export function LLMConfigForm({
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription className="text-[10px] sm:text-xs">
|
<FormDescription className="text-[10px] sm:text-xs">
|
||||||
Use {"{resolved_today}"} to include today's date dynamically
|
Use {"{resolved_today}"} to include today's date dynamically
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|
|
||||||
|
|
@ -258,30 +258,6 @@ export const LLM_MODELS: LLMModel[] = [
|
||||||
provider: "DEEPSEEK",
|
provider: "DEEPSEEK",
|
||||||
contextWindow: "128K",
|
contextWindow: "128K",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
value: "deepseek-chat",
|
|
||||||
label: "DeepSeek Chat V3",
|
|
||||||
provider: "DEEPSEEK",
|
|
||||||
contextWindow: "66K",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "deepseek-v3",
|
|
||||||
label: "DeepSeek V3",
|
|
||||||
provider: "DEEPSEEK",
|
|
||||||
contextWindow: "66K",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "deepseek-r1",
|
|
||||||
label: "DeepSeek R1",
|
|
||||||
provider: "DEEPSEEK",
|
|
||||||
contextWindow: "66K",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "deepseek-r1-0528",
|
|
||||||
label: "DeepSeek R1 (0528)",
|
|
||||||
provider: "DEEPSEEK",
|
|
||||||
contextWindow: "65K",
|
|
||||||
},
|
|
||||||
|
|
||||||
// xAI (Grok)
|
// xAI (Grok)
|
||||||
{ value: "grok-4", label: "Grok 4", provider: "XAI", contextWindow: "256K" },
|
{ value: "grok-4", label: "Grok 4", provider: "XAI", contextWindow: "256K" },
|
||||||
|
|
@ -1134,7 +1110,7 @@ export const LLM_MODELS: LLMModel[] = [
|
||||||
contextWindow: "8K",
|
contextWindow: "8K",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "mixtral-8x7B-Instruct-v0.1",
|
value: "mixtral",
|
||||||
label: "Ollama Mixtral 8x7B",
|
label: "Ollama Mixtral 8x7B",
|
||||||
provider: "OLLAMA",
|
provider: "OLLAMA",
|
||||||
contextWindow: "33K",
|
contextWindow: "33K",
|
||||||
|
|
@ -1236,13 +1212,13 @@ export const LLM_MODELS: LLMModel[] = [
|
||||||
|
|
||||||
// Zhipu (GLM)
|
// Zhipu (GLM)
|
||||||
{
|
{
|
||||||
value: "z-ai/glm-4.6",
|
value: "glm-4.6",
|
||||||
label: "GLM 4.6",
|
label: "GLM 4.6",
|
||||||
provider: "ZHIPU",
|
provider: "ZHIPU",
|
||||||
contextWindow: "203K",
|
contextWindow: "203K",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "z-ai/glm-4.6:exacto",
|
value: "glm-4.6:exacto",
|
||||||
label: "GLM 4.6 Exacto",
|
label: "GLM 4.6 Exacto",
|
||||||
provider: "ZHIPU",
|
provider: "ZHIPU",
|
||||||
contextWindow: "203K",
|
contextWindow: "203K",
|
||||||
|
|
@ -1350,7 +1326,7 @@ export const LLM_MODELS: LLMModel[] = [
|
||||||
contextWindow: "128K",
|
contextWindow: "128K",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "openai/gpt-oss-120b",
|
value: "gpt-oss-120b",
|
||||||
label: "Cerebras GPT-OSS-120B",
|
label: "Cerebras GPT-OSS-120B",
|
||||||
provider: "CEREBRAS",
|
provider: "CEREBRAS",
|
||||||
contextWindow: "131K",
|
contextWindow: "131K",
|
||||||
|
|
|
||||||
|
|
@ -128,7 +128,7 @@ export const LLM_PROVIDERS: LLMProvider[] = [
|
||||||
{
|
{
|
||||||
value: "ZHIPU",
|
value: "ZHIPU",
|
||||||
label: "Zhipu (GLM)",
|
label: "Zhipu (GLM)",
|
||||||
example: "openrouter/z-ai/glm-4.6",
|
example: "glm-4.6, glm-4.6:exacto",
|
||||||
description: "GLM series models",
|
description: "GLM series models",
|
||||||
apiBase: "https://open.bigmodel.cn/api/paas/v4",
|
apiBase: "https://open.bigmodel.cn/api/paas/v4",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -291,6 +291,22 @@ export const updateLLMPreferencesRequest = z.object({
|
||||||
|
|
||||||
export const updateLLMPreferencesResponse = llmPreferences;
|
export const updateLLMPreferencesResponse = llmPreferences;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Model List (dynamic catalogue from OpenRouter API)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const modelListItem = z.object({
|
||||||
|
value: z.string(),
|
||||||
|
label: z.string(),
|
||||||
|
provider: z.string(),
|
||||||
|
context_window: z.string().nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getModelListResponse = z.array(modelListItem);
|
||||||
|
|
||||||
|
export type ModelListItem = z.infer<typeof modelListItem>;
|
||||||
|
export type GetModelListResponse = z.infer<typeof getModelListResponse>;
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Type Exports
|
// Type Exports
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
getDefaultSystemInstructionsResponse,
|
getDefaultSystemInstructionsResponse,
|
||||||
getGlobalNewLLMConfigsResponse,
|
getGlobalNewLLMConfigsResponse,
|
||||||
getLLMPreferencesResponse,
|
getLLMPreferencesResponse,
|
||||||
|
getModelListResponse,
|
||||||
getNewLLMConfigRequest,
|
getNewLLMConfigRequest,
|
||||||
getNewLLMConfigResponse,
|
getNewLLMConfigResponse,
|
||||||
getNewLLMConfigsRequest,
|
getNewLLMConfigsRequest,
|
||||||
|
|
@ -145,6 +146,13 @@ class NewLLMConfigApiService {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the dynamic LLM model catalogue (sourced from OpenRouter API)
|
||||||
|
*/
|
||||||
|
getModels = async () => {
|
||||||
|
return baseApiService.get(`/api/v1/models`, getModelListResponse);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update LLM preferences for a search space
|
* Update LLM preferences for a search space
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ export const cacheKeys = {
|
||||||
preferences: (searchSpaceId: number) => ["llm-preferences", searchSpaceId] as const,
|
preferences: (searchSpaceId: number) => ["llm-preferences", searchSpaceId] as const,
|
||||||
defaultInstructions: () => ["new-llm-configs", "default-instructions"] as const,
|
defaultInstructions: () => ["new-llm-configs", "default-instructions"] as const,
|
||||||
global: () => ["new-llm-configs", "global"] as const,
|
global: () => ["new-llm-configs", "global"] as const,
|
||||||
|
modelList: () => ["models", "catalogue"] as const,
|
||||||
},
|
},
|
||||||
imageGenConfigs: {
|
imageGenConfigs: {
|
||||||
all: (searchSpaceId: number) => ["image-gen-configs", searchSpaceId] as const,
|
all: (searchSpaceId: number) => ["image-gen-configs", searchSpaceId] as const,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue