From 48e646607ba935cc1bae26b15879de5f13938127 Mon Sep 17 00:00:00 2001 From: Manoj Aggarwal Date: Mon, 2 Feb 2026 12:07:53 -0800 Subject: [PATCH 01/40] Fix google calendar and notion erros --- .../app/tasks/celery_tasks/connector_tasks.py | 43 +++++++++++++++++++ .../app/tasks/connector_indexers/base.py | 14 ++++++ .../google_calendar_indexer.py | 14 ++++++ .../connector_indexers/notion_indexer.py | 43 ++++++++++++++++--- .../connector_indexers/webcrawler_indexer.py | 20 +++++++-- 5 files changed, 124 insertions(+), 10 deletions(-) diff --git a/surfsense_backend/app/tasks/celery_tasks/connector_tasks.py b/surfsense_backend/app/tasks/celery_tasks/connector_tasks.py index d0710d246..760651589 100644 --- a/surfsense_backend/app/tasks/celery_tasks/connector_tasks.py +++ b/surfsense_backend/app/tasks/celery_tasks/connector_tasks.py @@ -1,6 +1,7 @@ """Celery tasks for connector indexing.""" import logging +import traceback from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.pool import NullPool @@ -11,6 +12,36 @@ from app.config import config logger = logging.getLogger(__name__) +def _handle_greenlet_error(e: Exception, task_name: str, connector_id: int) -> None: + """ + Handle greenlet_spawn errors with detailed logging for debugging. + + The 'greenlet_spawn has not been called' error occurs when: + 1. SQLAlchemy lazy-loads a relationship outside of an async context + 2. A sync operation is called from an async context (or vice versa) + 3. Session objects are accessed after the session is closed + + This helper logs detailed context to help identify the root cause. + """ + error_str = str(e) + if "greenlet_spawn has not been called" in error_str: + logger.error( + f"GREENLET ERROR in {task_name} for connector {connector_id}: {error_str}\n" + f"This error typically occurs when SQLAlchemy tries to lazy-load a relationship " + f"outside of an async context. Check for:\n" + f"1. Accessing relationship attributes (e.g., document.chunks, connector.search_space) " + f"without using selectinload() or joinedload()\n" + f"2. Accessing model attributes after the session is closed\n" + f"3. Passing ORM objects between different async contexts\n" + f"Stack trace:\n{traceback.format_exc()}" + ) + else: + logger.error( + f"Error in {task_name} for connector {connector_id}: {error_str}\n" + f"Stack trace:\n{traceback.format_exc()}" + ) + + def get_celery_session_maker(): """ Create a new async session maker for Celery tasks. @@ -46,6 +77,9 @@ def index_slack_messages_task( connector_id, search_space_id, user_id, start_date, end_date ) ) + except Exception as e: + _handle_greenlet_error(e, "index_slack_messages", connector_id) + raise finally: loop.close() @@ -89,6 +123,9 @@ def index_notion_pages_task( connector_id, search_space_id, user_id, start_date, end_date ) ) + except Exception as e: + _handle_greenlet_error(e, "index_notion_pages", connector_id) + raise finally: loop.close() @@ -347,6 +384,9 @@ def index_google_calendar_events_task( connector_id, search_space_id, user_id, start_date, end_date ) ) + except Exception as e: + _handle_greenlet_error(e, "index_google_calendar_events", connector_id) + raise finally: loop.close() @@ -696,6 +736,9 @@ def index_crawled_urls_task( connector_id, search_space_id, user_id, start_date, end_date ) ) + except Exception as e: + _handle_greenlet_error(e, "index_crawled_urls", connector_id) + raise finally: loop.close() diff --git a/surfsense_backend/app/tasks/connector_indexers/base.py b/surfsense_backend/app/tasks/connector_indexers/base.py index b390937f0..311fda996 100644 --- a/surfsense_backend/app/tasks/connector_indexers/base.py +++ b/surfsense_backend/app/tasks/connector_indexers/base.py @@ -159,6 +159,20 @@ def calculate_date_range( ) end_date_str = end_date if end_date else calculated_end_date.strftime("%Y-%m-%d") + # FIX: Ensure end_date is at least 1 day after start_date to avoid + # "start_date must be strictly before end_date" errors when dates are the same + # (e.g., when last_indexed_at is today) + if start_date_str == end_date_str: + logger.info( + f"Start date ({start_date_str}) equals end date ({end_date_str}), " + "adjusting end date to next day to ensure valid date range" + ) + # Parse end_date and add 1 day + end_dt = datetime.strptime(end_date_str, "%Y-%m-%d") + end_dt = end_dt + timedelta(days=1) + end_date_str = end_dt.strftime("%Y-%m-%d") + logger.info(f"Adjusted end date to {end_date_str}") + return start_date_str, end_date_str diff --git a/surfsense_backend/app/tasks/connector_indexers/google_calendar_indexer.py b/surfsense_backend/app/tasks/connector_indexers/google_calendar_indexer.py index f64a7a5c3..1d8ea32f2 100644 --- a/surfsense_backend/app/tasks/connector_indexers/google_calendar_indexer.py +++ b/surfsense_backend/app/tasks/connector_indexers/google_calendar_indexer.py @@ -217,6 +217,20 @@ async def index_google_calendar_events( start_date_str = start_date end_date_str = end_date + # FIX: Ensure end_date is at least 1 day after start_date to avoid + # "start_date must be strictly before end_date" errors when dates are the same + # (e.g., when last_indexed_at is today) + if start_date_str == end_date_str: + logger.info( + f"Start date ({start_date_str}) equals end date ({end_date_str}), " + "adjusting end date to next day to ensure valid date range" + ) + # Parse end_date and add 1 day + end_dt = datetime.strptime(end_date_str, "%Y-%m-%d") + end_dt = end_dt + timedelta(days=1) + end_date_str = end_dt.strftime("%Y-%m-%d") + logger.info(f"Adjusted end date to {end_date_str}") + await task_logger.log_task_progress( log_entry, f"Fetching Google Calendar events from {start_date_str} to {end_date_str}", diff --git a/surfsense_backend/app/tasks/connector_indexers/notion_indexer.py b/surfsense_backend/app/tasks/connector_indexers/notion_indexer.py index 52622471a..ee5bca5d8 100644 --- a/surfsense_backend/app/tasks/connector_indexers/notion_indexer.py +++ b/surfsense_backend/app/tasks/connector_indexers/notion_indexer.py @@ -196,13 +196,44 @@ async def index_notion_pages( "Recommend reconnecting with OAuth." ) except Exception as e: - await task_logger.log_task_failure( - log_entry, - f"Failed to get Notion pages for connector {connector_id}", - str(e), - {"error_type": "PageFetchError"}, + error_str = str(e) + # Check if this is an unsupported block type error (transcription, ai_block, etc.) + # These are known Notion API limitations and should be logged as warnings, not errors + unsupported_block_errors = [ + "transcription is not supported", + "ai_block is not supported", + "is not supported via the API", + ] + is_unsupported_block_error = any( + err in error_str.lower() for err in unsupported_block_errors ) - logger.error(f"Error fetching Notion pages: {e!s}", exc_info=True) + + if is_unsupported_block_error: + # Log as warning since this is a known Notion API limitation + logger.warning( + f"Notion API limitation for connector {connector_id}: {error_str}. " + "This is a known issue with Notion AI blocks (transcription, ai_block) " + "that are not accessible via the Notion API." + ) + await task_logger.log_task_failure( + log_entry, + f"Failed to get Notion pages: Notion API limitation", + f"{error_str} - This page contains Notion AI content (transcription/ai_block) that cannot be accessed via the API.", + {"error_type": "UnsupportedBlockType", "is_known_limitation": True}, + ) + else: + # Log as error for other failures + logger.error( + f"Error fetching Notion pages for connector {connector_id}: {error_str}", + exc_info=True, + ) + await task_logger.log_task_failure( + log_entry, + f"Failed to get Notion pages for connector {connector_id}", + str(e), + {"error_type": "PageFetchError"}, + ) + await notion_client.close() return 0, f"Failed to get Notion pages: {e!s}" diff --git a/surfsense_backend/app/tasks/connector_indexers/webcrawler_indexer.py b/surfsense_backend/app/tasks/connector_indexers/webcrawler_indexer.py index ac16ecde6..b3c24a4e3 100644 --- a/surfsense_backend/app/tasks/connector_indexers/webcrawler_indexer.py +++ b/surfsense_backend/app/tasks/connector_indexers/webcrawler_indexer.py @@ -108,10 +108,15 @@ async def index_crawled_urls( api_key = connector.config.get("FIRECRAWL_API_KEY") # Get URLs from connector config - urls = parse_webcrawler_urls(connector.config.get("INITIAL_URLS")) + raw_initial_urls = connector.config.get("INITIAL_URLS") + urls = parse_webcrawler_urls(raw_initial_urls) + # DEBUG: Log connector config details for troubleshooting empty URL issues logger.info( - f"Starting crawled web page indexing for connector {connector_id} with {len(urls)} URLs" + f"Starting crawled web page indexing for connector {connector_id} with {len(urls)} URLs. " + f"Connector name: {connector.name}, " + f"INITIAL_URLS type: {type(raw_initial_urls).__name__}, " + f"INITIAL_URLS value: {repr(raw_initial_urls)[:200] if raw_initial_urls else 'None'}" ) # Initialize webcrawler client @@ -128,11 +133,18 @@ async def index_crawled_urls( # Validate URLs if not urls: + # DEBUG: Log detailed connector config for troubleshooting + logger.error( + f"No URLs provided for indexing. Connector ID: {connector_id}, " + f"Connector name: {connector.name}, " + f"Config keys: {list(connector.config.keys()) if connector.config else 'None'}, " + f"INITIAL_URLS raw value: {repr(raw_initial_urls)}" + ) await task_logger.log_task_failure( log_entry, "No URLs provided for indexing", - "Empty URL list", - {"error_type": "ValidationError"}, + f"Empty URL list. INITIAL_URLS value: {repr(raw_initial_urls)[:100]}", + {"error_type": "ValidationError", "connector_name": connector.name}, ) return 0, "No URLs provided for indexing" From d761ca199211c1a35c9d8f5d7a9bcf852c1a7a35 Mon Sep 17 00:00:00 2001 From: Eric Lammertsma Date: Tue, 3 Feb 2026 20:47:18 -0500 Subject: [PATCH 02/40] Added chat renaming and auto naming by the active LLM --- surfsense_backend/app/prompts/__init__.py | 30 +++++++ .../app/routes/new_chat_routes.py | 26 +----- .../app/services/new_streaming_service.py | 25 ++++++ .../app/tasks/chat/stream_new_chat.py | 75 ++++++++++++++++ .../new-chat/[[...chat_id]]/page.tsx | 21 ++++- .../layout/providers/LayoutDataProvider.tsx | 88 ++++++++++++++++++- .../layout/ui/shell/LayoutShell.tsx | 4 + .../layout/ui/sidebar/ChatListItem.tsx | 33 ++++--- .../layout/ui/sidebar/MobileSidebar.tsx | 3 + .../components/layout/ui/sidebar/Sidebar.tsx | 4 + surfsense_web/messages/en.json | 7 ++ surfsense_web/messages/zh.json | 7 ++ 12 files changed, 287 insertions(+), 36 deletions(-) diff --git a/surfsense_backend/app/prompts/__init__.py b/surfsense_backend/app/prompts/__init__.py index 3b21cb9e1..efa31d612 100644 --- a/surfsense_backend/app/prompts/__init__.py +++ b/surfsense_backend/app/prompts/__init__.py @@ -104,3 +104,33 @@ SUMMARY_PROMPT = ( SUMMARY_PROMPT_TEMPLATE = PromptTemplate( input_variables=["document"], template=SUMMARY_PROMPT ) + +# ============================================================================= +# Chat Title Generation Prompt +# ============================================================================= + +TITLE_GENERATION_PROMPT = """Generate a concise, descriptive title for the following conversation. + + +- The title MUST be between 1 and 6 words +- The title MUST be on a single line +- Capture the main topic or intent of the conversation +- Do NOT use quotes, punctuation, or formatting +- Do NOT include words like "Chat about" or "Discussion of" +- Return ONLY the title, nothing else + + + +{user_query} + + + +{assistant_response} + + +Title:""" + +TITLE_GENERATION_PROMPT_TEMPLATE = PromptTemplate( + input_variables=["user_query", "assistant_response"], + template=TITLE_GENERATION_PROMPT, +) diff --git a/surfsense_backend/app/routes/new_chat_routes.py b/surfsense_backend/app/routes/new_chat_routes.py index 42b8a821b..06e929997 100644 --- a/surfsense_backend/app/routes/new_chat_routes.py +++ b/surfsense_backend/app/routes/new_chat_routes.py @@ -886,30 +886,8 @@ async def append_message( # Update thread's updated_at timestamp thread.updated_at = datetime.now(UTC) - # Auto-generate title from first user message if title is still default - if thread.title == "New Chat" and role_str == "user": - # Extract text content for title - content = message.content - if isinstance(content, str): - title_text = content - elif isinstance(content, list): - # Find first text content - title_text = "" - for part in content: - if isinstance(part, dict) and part.get("type") == "text": - title_text = part.get("text", "") - break - elif isinstance(part, str): - title_text = part - break - else: - title_text = str(content) - - # Truncate title - if title_text: - thread.title = title_text[:100] + ( - "..." if len(title_text) > 100 else "" - ) + # Note: Title generation now happens in stream_new_chat.py after the first response + # using LLM to generate a descriptive title (with truncation as fallback) await session.commit() await session.refresh(db_message) diff --git a/surfsense_backend/app/services/new_streaming_service.py b/surfsense_backend/app/services/new_streaming_service.py index 05dd2d4dd..57fbc9663 100644 --- a/surfsense_backend/app/services/new_streaming_service.py +++ b/surfsense_backend/app/services/new_streaming_service.py @@ -479,6 +479,31 @@ class VercelStreamingService: }, ) + def format_thread_title_update(self, thread_id: int, title: str) -> str: + """ + Format a thread title update notification (SurfSense specific). + + This is sent after the first response in a thread to update the + auto-generated title based on the conversation content. + + Args: + thread_id: The ID of the thread being updated + title: The new title for the thread + + Returns: + str: SSE formatted thread title update data part + + Example output: + data: {"type":"data-thread-title-update","data":{"threadId":123,"title":"New Title"}} + """ + return self.format_data( + "thread-title-update", + { + "threadId": thread_id, + "title": title, + }, + ) + # ========================================================================= # Error Part # ========================================================================= diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index 688777203..47e2a2293 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -32,6 +32,7 @@ from app.services.chat_session_state_service import ( clear_ai_responding, set_ai_responding, ) +from app.prompts import TITLE_GENERATION_PROMPT_TEMPLATE from app.services.connector_service import ConnectorService from app.services.new_streaming_service import VercelStreamingService from app.utils.content_utils import bootstrap_history_from_db @@ -1208,6 +1209,80 @@ async def stream_new_chat( if completion_event: yield completion_event + # Generate LLM title for new chats after first response + # Check if this is the first assistant response by counting existing assistant messages + from app.db import NewChatMessage, NewChatThread + from sqlalchemy import func + + assistant_count_result = await session.execute( + select(func.count(NewChatMessage.id)).filter( + NewChatMessage.thread_id == chat_id, + NewChatMessage.role == "assistant", + ) + ) + assistant_message_count = assistant_count_result.scalar() or 0 + + # Only generate title on the first response (no prior assistant messages) + if assistant_message_count == 0: + print(f"[stream_new_chat] First response - generating title for thread {chat_id}") + print(f"[stream_new_chat] Query length: {len(user_query)}, Response length: {len(accumulated_text)}") + + generated_title = None + try: + # Generate title using the same LLM + title_chain = TITLE_GENERATION_PROMPT_TEMPLATE | llm + # Truncate inputs to avoid context length issues + truncated_query = user_query[:500] + truncated_response = accumulated_text[:1000] + print(f"[stream_new_chat] Calling LLM for title generation...") + title_result = await title_chain.ainvoke({ + "user_query": truncated_query, + "assistant_response": truncated_response, + }) + print(f"[stream_new_chat] LLM title result type: {type(title_result)}") + print(f"[stream_new_chat] LLM title result: {title_result}") + + # Extract and clean the title + if title_result and hasattr(title_result, "content"): + raw_title = title_result.content.strip() + print(f"[stream_new_chat] Raw title content: '{raw_title}' (len={len(raw_title)})") + + # Validate the title (1-6 words, reasonable length) + if raw_title and len(raw_title) <= 100: + # Remove any quotes or extra formatting + generated_title = raw_title.strip('"\'') + print(f"[stream_new_chat] After stripping quotes: '{generated_title}'") + else: + print(f"[stream_new_chat] Title validation failed: empty={not raw_title}, len={len(raw_title)}") + generated_title = None + else: + print(f"[stream_new_chat] No content attribute on result") + except Exception as title_error: + print(f"[stream_new_chat] Title generation failed: {title_error}") + import traceback + traceback.print_exc() + generated_title = None + + # Only update if LLM succeeded (keep truncated prompt title as fallback) + if generated_title: + print(f"[stream_new_chat] Using LLM-generated title: '{generated_title}'") + + # Fetch thread and update title + thread_result = await session.execute( + select(NewChatThread).filter(NewChatThread.id == chat_id) + ) + thread = thread_result.scalars().first() + if thread: + thread.title = generated_title + await session.commit() + + # Notify frontend of the title update + yield streaming_service.format_thread_title_update( + chat_id, generated_title + ) + else: + print(f"[stream_new_chat] LLM title generation failed, keeping truncated prompt title") + # Finish the step and message yield streaming_service.format_finish_step() yield streaming_service.format_finish() diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index 9b462fcbc..4f99f8021 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -437,7 +437,10 @@ export default function NewChatPage() { let isNewThread = false; if (!currentThreadId) { try { - const newThread = await createThread(searchSpaceId, "New Chat"); + // Create thread with truncated prompt as initial title + const initialTitle = + userQuery.trim().slice(0, 100) + (userQuery.trim().length > 100 ? "..." : ""); + const newThread = await createThread(searchSpaceId, initialTitle); currentThreadId = newThread.id; setThreadId(currentThreadId); // Set currentThread so ChatHeader can show share button immediately @@ -827,6 +830,22 @@ export default function NewChatPage() { break; } + case "data-thread-title-update": { + // Handle thread title update from LLM-generated title + const titleData = parsed.data as { threadId: number; title: string }; + if (titleData?.title && titleData?.threadId === currentThreadId) { + // Update current thread state with new title + setCurrentThread((prev) => + prev ? { ...prev, title: titleData.title } : prev + ); + // Invalidate thread list to refresh sidebar + queryClient.invalidateQueries({ + queryKey: ["threads", String(searchSpaceId)], + }); + } + break; + } + case "error": throw new Error(parsed.errorText || "Server error"); } diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 2f71adad9..9a5c2ed40 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -2,7 +2,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useAtomValue, useSetAtom } from "jotai"; -import { AlertTriangle, Inbox, LogOut, SquareLibrary, Trash2 } from "lucide-react"; +import { AlertTriangle, Inbox, LogOut, PencilIcon, SquareLibrary, Trash2 } from "lucide-react"; import { useParams, usePathname, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useTheme } from "next-themes"; @@ -21,6 +21,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; import { isPageLimitExceededMetadata } from "@/contracts/types/inbox.types"; import { useInbox } from "@/hooks/use-inbox"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; @@ -207,6 +208,12 @@ export function LayoutDataProvider({ const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null); const [isDeletingChat, setIsDeletingChat] = useState(false); + // Rename dialog state + const [showRenameChatDialog, setShowRenameChatDialog] = useState(false); + const [chatToRename, setChatToRename] = useState<{ id: number; name: string } | null>(null); + const [newChatTitle, setNewChatTitle] = useState(""); + const [isRenamingChat, setIsRenamingChat] = useState(false); + // Delete/Leave search space dialog state const [showDeleteSearchSpaceDialog, setShowDeleteSearchSpaceDialog] = useState(false); const [showLeaveSearchSpaceDialog, setShowLeaveSearchSpaceDialog] = useState(false); @@ -421,6 +428,12 @@ export function LayoutDataProvider({ setShowDeleteChatDialog(true); }, []); + const handleChatRename = useCallback((chat: ChatItem) => { + setChatToRename({ id: chat.id, name: chat.name }); + setNewChatTitle(chat.name); + setShowRenameChatDialog(true); + }, []); + const handleChatArchive = useCallback( async (chat: ChatItem) => { const newArchivedState = !chat.archived; @@ -501,6 +514,27 @@ export function LayoutDataProvider({ } }, [chatToDelete, queryClient, searchSpaceId, router, currentChatId]); + // Rename handler + const confirmRenameChat = useCallback(async () => { + if (!chatToRename || !newChatTitle.trim()) return; + setIsRenamingChat(true); + try { + await updateThread(chatToRename.id, { title: newChatTitle.trim() }); + toast.success(tSidebar("chat_renamed") || "Chat renamed"); + queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] }); + queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] }); + queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] }); + } catch (error) { + console.error("Error renaming thread:", error); + toast.error(tSidebar("error_renaming_chat") || "Failed to rename chat"); + } finally { + setIsRenamingChat(false); + setShowRenameChatDialog(false); + setChatToRename(null); + setNewChatTitle(""); + } + }, [chatToRename, newChatTitle, queryClient, searchSpaceId, tSidebar]); + // Page usage const pageUsage = user ? { @@ -529,6 +563,7 @@ export function LayoutDataProvider({ activeChatId={currentChatId} onNewChat={handleNewChat} onChatSelect={handleChatSelect} + onChatRename={handleChatRename} onChatDelete={handleChatDelete} onChatArchive={handleChatArchive} onViewAllSharedChats={handleViewAllSharedChats} @@ -620,6 +655,57 @@ export function LayoutDataProvider({ + {/* Rename Chat Dialog */} + + + + + + {tSidebar("rename_chat") || "Rename Chat"} + + + {tSidebar("rename_chat_description") || "Enter a new name for this conversation."} + + + setNewChatTitle(e.target.value)} + placeholder={tSidebar("chat_title_placeholder") || "Chat title"} + onKeyDown={(e) => { + if (e.key === "Enter" && !isRenamingChat && newChatTitle.trim()) { + confirmRenameChat(); + } + }} + /> + + + + + + + {/* Delete Search Space Dialog */} diff --git a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx index 3624c90a3..8eae99b03 100644 --- a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx +++ b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx @@ -54,6 +54,7 @@ interface LayoutShellProps { activeChatId?: number | null; onNewChat: () => void; onChatSelect: (chat: ChatItem) => void; + onChatRename?: (chat: ChatItem) => void; onChatDelete?: (chat: ChatItem) => void; onChatArchive?: (chat: ChatItem) => void; onViewAllSharedChats?: () => void; @@ -90,6 +91,7 @@ export function LayoutShell({ activeChatId, onNewChat, onChatSelect, + onChatRename, onChatDelete, onChatArchive, onViewAllSharedChats, @@ -147,6 +149,7 @@ export function LayoutShell({ activeChatId={activeChatId} onNewChat={onNewChat} onChatSelect={onChatSelect} + onChatRename={onChatRename} onChatDelete={onChatDelete} onChatArchive={onChatArchive} onViewAllSharedChats={onViewAllSharedChats} @@ -215,6 +218,7 @@ export function LayoutShell({ activeChatId={activeChatId} onNewChat={onNewChat} onChatSelect={onChatSelect} + onChatRename={onChatRename} onChatDelete={onChatDelete} onChatArchive={onChatArchive} onViewAllSharedChats={onViewAllSharedChats} diff --git a/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx b/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx index 6db6782d0..ba2989145 100644 --- a/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx +++ b/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx @@ -1,6 +1,6 @@ "use client"; -import { ArchiveIcon, MessageSquare, MoreHorizontal, RotateCcwIcon, Trash2 } from "lucide-react"; +import { ArchiveIcon, MessageSquare, MoreHorizontal, PencilIcon, RotateCcwIcon, Trash2 } from "lucide-react"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { @@ -17,6 +17,7 @@ interface ChatListItemProps { isActive?: boolean; archived?: boolean; onClick?: () => void; + onRename?: () => void; onArchive?: () => void; onDelete?: () => void; } @@ -26,6 +27,7 @@ export function ChatListItem({ isActive, archived, onClick, + onRename, onArchive, onDelete, }: ChatListItemProps) { @@ -57,15 +59,26 @@ export function ChatListItem({ {t("more_options")} - - {onArchive && ( - { - e.stopPropagation(); - onArchive(); - }} - > - {archived ? ( + + {onRename && ( + { + e.stopPropagation(); + onRename(); + }} + > + + {t("rename") || "Rename"} + + )} + {onArchive && ( + { + e.stopPropagation(); + onArchive(); + }} + > + {archived ? ( <> {t("unarchive") || "Restore"} diff --git a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx index 85f907611..3ed2f9cca 100644 --- a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx @@ -24,6 +24,7 @@ interface MobileSidebarProps { activeChatId?: number | null; onNewChat: () => void; onChatSelect: (chat: ChatItem) => void; + onChatRename?: (chat: ChatItem) => void; onChatDelete?: (chat: ChatItem) => void; onChatArchive?: (chat: ChatItem) => void; onViewAllSharedChats?: () => void; @@ -64,6 +65,7 @@ export function MobileSidebar({ activeChatId, onNewChat, onChatSelect, + onChatRename, onChatDelete, onChatArchive, onViewAllSharedChats, @@ -142,6 +144,7 @@ export function MobileSidebar({ onOpenChange(false); }} onChatSelect={handleChatSelect} + onChatRename={onChatRename} onChatDelete={onChatDelete} onChatArchive={onChatArchive} onViewAllSharedChats={onViewAllSharedChats} diff --git a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx index db04bf6dc..8763056ed 100644 --- a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx @@ -25,6 +25,7 @@ interface SidebarProps { activeChatId?: number | null; onNewChat: () => void; onChatSelect: (chat: ChatItem) => void; + onChatRename?: (chat: ChatItem) => void; onChatDelete?: (chat: ChatItem) => void; onChatArchive?: (chat: ChatItem) => void; onViewAllSharedChats?: () => void; @@ -51,6 +52,7 @@ export function Sidebar({ activeChatId, onNewChat, onChatSelect, + onChatRename, onChatDelete, onChatArchive, onViewAllSharedChats, @@ -163,6 +165,7 @@ export function Sidebar({ isActive={chat.id === activeChatId} archived={chat.archived} onClick={() => onChatSelect(chat)} + onRename={() => onChatRename?.(chat)} onArchive={() => onChatArchive?.(chat)} onDelete={() => onChatDelete?.(chat)} /> @@ -215,6 +218,7 @@ export function Sidebar({ isActive={chat.id === activeChatId} archived={chat.archived} onClick={() => onChatSelect(chat)} + onRename={() => onChatRename?.(chat)} onArchive={() => onChatArchive?.(chat)} onDelete={() => onChatDelete?.(chat)} /> diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index 75b186420..b44287af5 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -676,6 +676,13 @@ "unarchive": "Restore", "chat_archived": "Chat archived", "chat_unarchived": "Chat restored", + "chat_renamed": "Chat renamed", + "error_renaming_chat": "Failed to rename chat", + "rename": "Rename", + "rename_chat": "Rename Chat", + "rename_chat_description": "Enter a new name for this conversation.", + "chat_title_placeholder": "Chat title", + "renaming": "Renaming...", "no_archived_chats": "No archived chats", "error_archiving_chat": "Failed to archive chat", "new_chat": "New chat", diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json index 81121ef3e..cbcef3691 100644 --- a/surfsense_web/messages/zh.json +++ b/surfsense_web/messages/zh.json @@ -661,6 +661,13 @@ "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": "新对话", From 07f89a426882d663ff9ac2b14a84850a7a7251b0 Mon Sep 17 00:00:00 2001 From: Eric Lammertsma Date: Tue, 3 Feb 2026 20:54:21 -0500 Subject: [PATCH 03/40] Show the chat title in the breadcrumbs instead of "Chat" --- .../new-chat/[[...chat_id]]/page.tsx | 4 ++++ .../components/dashboard-breadcrumb.tsx | 16 ++++++++++++++-- .../layout/providers/LayoutDataProvider.tsx | 2 ++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index 4f99f8021..1a535539d 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -842,6 +842,10 @@ export default function NewChatPage() { queryClient.invalidateQueries({ queryKey: ["threads", String(searchSpaceId)], }); + // Invalidate thread detail for breadcrumb update + queryClient.invalidateQueries({ + queryKey: ["threads", String(searchSpaceId), "detail", String(titleData.threadId)], + }); } break; } diff --git a/surfsense_web/components/dashboard-breadcrumb.tsx b/surfsense_web/components/dashboard-breadcrumb.tsx index 96bd0ef30..5c6399ce0 100644 --- a/surfsense_web/components/dashboard-breadcrumb.tsx +++ b/surfsense_web/components/dashboard-breadcrumb.tsx @@ -14,6 +14,7 @@ import { } from "@/components/ui/breadcrumb"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; import { authenticatedFetch, getBearerToken } from "@/lib/auth-utils"; +import { getThreadFull } from "@/lib/chat/thread-persistence"; import { cacheKeys } from "@/lib/query-client/cache-keys"; interface BreadcrumbItemInterface { @@ -34,6 +35,16 @@ export function DashboardBreadcrumb() { enabled: !!searchSpaceId, }); + // Extract chat thread ID from pathname for chat pages + const chatThreadId = segments[2] === "new-chat" && segments[3] ? segments[3] : null; + + // Fetch thread details when on a chat page with a thread ID + const { data: threadData } = useQuery({ + queryKey: ["threads", searchSpaceId, "detail", chatThreadId], + queryFn: () => getThreadFull(Number(chatThreadId)), + enabled: !!chatThreadId && !!searchSpaceId, + }); + // State to store document title for editor breadcrumb const [documentTitle, setDocumentTitle] = useState(null); @@ -144,10 +155,11 @@ export function DashboardBreadcrumb() { } // Handle new-chat sub-sections (thread IDs) - // Don't show thread ID in breadcrumb - users identify chats by content, not by ID + // Show the chat title if available, otherwise fall back to "Chat" if (section === "new-chat") { + const chatLabel = threadData?.title || t("chat") || "Chat"; breadcrumbs.push({ - label: t("chat") || "Chat", + label: chatLabel, }); return breadcrumbs; } diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 9a5c2ed40..4d8e2d23a 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -524,6 +524,8 @@ export function LayoutDataProvider({ queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] }); queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] }); queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] }); + // Invalidate thread detail for breadcrumb update + queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId, "detail", String(chatToRename.id)] }); } catch (error) { console.error("Error renaming thread:", error); toast.error(tSidebar("error_renaming_chat") || "Failed to rename chat"); From 318ad4a4ba2ff5a6e8edf4837846c649471e9606 Mon Sep 17 00:00:00 2001 From: Eric Lammertsma Date: Tue, 3 Feb 2026 20:59:50 -0500 Subject: [PATCH 04/40] Removed excessive logging around chat title generation --- .../app/tasks/chat/stream_new_chat.py | 25 ++----------------- 1 file changed, 2 insertions(+), 23 deletions(-) diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index 47e2a2293..a9751e5d1 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -1224,9 +1224,6 @@ async def stream_new_chat( # Only generate title on the first response (no prior assistant messages) if assistant_message_count == 0: - print(f"[stream_new_chat] First response - generating title for thread {chat_id}") - print(f"[stream_new_chat] Query length: {len(user_query)}, Response length: {len(accumulated_text)}") - generated_title = None try: # Generate title using the same LLM @@ -1234,39 +1231,23 @@ async def stream_new_chat( # Truncate inputs to avoid context length issues truncated_query = user_query[:500] truncated_response = accumulated_text[:1000] - print(f"[stream_new_chat] Calling LLM for title generation...") title_result = await title_chain.ainvoke({ "user_query": truncated_query, "assistant_response": truncated_response, }) - print(f"[stream_new_chat] LLM title result type: {type(title_result)}") - print(f"[stream_new_chat] LLM title result: {title_result}") # Extract and clean the title if title_result and hasattr(title_result, "content"): raw_title = title_result.content.strip() - print(f"[stream_new_chat] Raw title content: '{raw_title}' (len={len(raw_title)})") - - # Validate the title (1-6 words, reasonable length) + # Validate the title (reasonable length) if raw_title and len(raw_title) <= 100: # Remove any quotes or extra formatting generated_title = raw_title.strip('"\'') - print(f"[stream_new_chat] After stripping quotes: '{generated_title}'") - else: - print(f"[stream_new_chat] Title validation failed: empty={not raw_title}, len={len(raw_title)}") - generated_title = None - else: - print(f"[stream_new_chat] No content attribute on result") - except Exception as title_error: - print(f"[stream_new_chat] Title generation failed: {title_error}") - import traceback - traceback.print_exc() + except Exception: generated_title = None # Only update if LLM succeeded (keep truncated prompt title as fallback) if generated_title: - print(f"[stream_new_chat] Using LLM-generated title: '{generated_title}'") - # Fetch thread and update title thread_result = await session.execute( select(NewChatThread).filter(NewChatThread.id == chat_id) @@ -1280,8 +1261,6 @@ async def stream_new_chat( yield streaming_service.format_thread_title_update( chat_id, generated_title ) - else: - print(f"[stream_new_chat] LLM title generation failed, keeping truncated prompt title") # Finish the step and message yield streaming_service.format_finish_step() From 5bee04192c14f516e292f9978f1c85a30ebc9baf Mon Sep 17 00:00:00 2001 From: Eric Lammertsma Date: Tue, 3 Feb 2026 22:19:10 -0500 Subject: [PATCH 05/40] Overhauled pricing plans. --- surfsense_web/components/pricing.tsx | 5 +- .../components/pricing/pricing-section.tsx | 73 ++++++++++--------- 2 files changed, 42 insertions(+), 36 deletions(-) diff --git a/surfsense_web/components/pricing.tsx b/surfsense_web/components/pricing.tsx index 39757e91e..9d05e0262 100644 --- a/surfsense_web/components/pricing.tsx +++ b/surfsense_web/components/pricing.tsx @@ -17,6 +17,7 @@ interface PricingPlan { price: string; yearlyPrice: string; period: string; + billingText?: string; features: string[]; description: string; buttonText: string; @@ -35,7 +36,7 @@ export function Pricing({ title = "Simple, Transparent Pricing", description = "Choose the plan that works for you\nAll plans include access to our SurfSense AI workspace and community support.", }: PricingProps) { - const [isMonthly, setIsMonthly] = useState(true); + const [isMonthly, setIsMonthly] = useState(false); const isDesktop = useMediaQuery("(min-width: 768px)"); const switchRef = useRef(null); @@ -183,7 +184,7 @@ export function Pricing({

- {isNaN(Number(plan.price)) ? "" : isMonthly ? "billed monthly" : "billed annually"} + {plan.billingText ?? (isNaN(Number(plan.price)) ? "" : isMonthly ? "billed monthly" : "billed annually")}

    diff --git a/surfsense_web/components/pricing/pricing-section.tsx b/surfsense_web/components/pricing/pricing-section.tsx index fdad0796a..5b3513e51 100644 --- a/surfsense_web/components/pricing/pricing-section.tsx +++ b/surfsense_web/components/pricing/pricing-section.tsx @@ -4,44 +4,47 @@ import { Pricing } from "@/components/pricing"; const demoPlans = [ { - name: "COMMUNITY", + name: "FREE", price: "0", yearlyPrice: "0", - period: "forever", + period: "", + billingText: "Includes 30 day PRO trial", features: [ - "Community support", - "Supports 100+ LLMs", - "Supports OpenAI spec and LiteLLM", - "Supports local vLLM or Ollama setups", - "6000+ embedding models", + "Open source on GitHub", + "Upload and chat with up to 1,000 pages of content", + "Connects with 8 popular sources, like Drive and Notion.", + "Includes limited access to ChatGPT, Claude, and DeepSeek models", + "Supports 100+ more LLMs, including Gemini, Llama and many more.", "50+ File extensions supported.", - "Podcasts support with local TTS providers.", - "Connects with 15+ external sources, like Drive and Notion.", + "Generate podcasts in seconds.", "Cross-Browser Extension for dynamic webpages including authenticated content", - "Role-based access control (RBAC)", - "Collaboration and team features", + "Community support on Discord", ], - description: "Open source version with powerful features", - buttonText: "Dive In", - href: "/docs", + description: "Powerful features with some limitations", + buttonText: "Get Started", + href: "/", isPopular: false, }, { - name: "CLOUD", - price: "0", - yearlyPrice: "0", - period: "in beta", + name: "PRO", + price: "10", + yearlyPrice: "10", + period: "user / month", + billingText: "billed annually", features: [ - "Everything in Community", - "Email support", - "Get started in seconds", - "Instant access to new features", - "Easy access from anywhere", - "Remote team management and collaboration", + "Everything in Free", + "Upload and chat with up to 20,000 pages of content", + "Connects with 15+ external sources, like Slack and Airtable.", + "Includes extended access to ChatGPT, Claude, and DeepSeek models", + "Collaboration and commenting features", + "Centralized billing", + "Shared BYOK (Bring Your Own Key)", + "Team and role management", + "Priority support", ], - description: "Instant access for individuals and teams", - buttonText: "Get Started", - href: "/", + description: "The AIknowledge base for individuals and teams", + buttonText: "Upgrade", + href: "/contact", isPopular: true, }, { @@ -50,17 +53,19 @@ const demoPlans = [ yearlyPrice: "Contact Us", period: "", features: [ - "Everything in Community", - "Priority support", - "White-glove setup and deployment", - "Monthly managed updates and maintenance", + "Everything in Pro", + "Connect and chat with virtually unlimited pages of content", + "Limit models and/or providers", "On-prem or VPC deployment", "Audit logs and compliance", "SSO, OIDC & SAML", - "SLA guarantee", - "Uptime guarantee on VPC", + "Role-based access control (RBAC)", + "White-glove setup and deployment", + "Monthly managed updates and maintenance", + "SLA commitments", + "Dedicated support", ], - description: "Professional, customized setup for large organizations", + description: "Customized setup for large organizations", buttonText: "Contact Sales", href: "/contact", isPopular: false, From 40a304bd0b15ab834e0ba2523796413727e941eb Mon Sep 17 00:00:00 2001 From: Eric Lammertsma Date: Tue, 3 Feb 2026 22:49:38 -0500 Subject: [PATCH 06/40] Updated enterprise pricing plan t. --- surfsense_web/components/pricing/pricing-section.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/surfsense_web/components/pricing/pricing-section.tsx b/surfsense_web/components/pricing/pricing-section.tsx index 5b3513e51..c850852d8 100644 --- a/surfsense_web/components/pricing/pricing-section.tsx +++ b/surfsense_web/components/pricing/pricing-section.tsx @@ -49,9 +49,10 @@ const demoPlans = [ }, { name: "ENTERPRISE", - price: "Contact Us", - yearlyPrice: "Contact Us", - period: "", + price: "1000", + yearlyPrice: "1000", + period: "month", + billingText: "billed annually", features: [ "Everything in Pro", "Connect and chat with virtually unlimited pages of content", From fb333fdd541cb37e340759a0630e1e86532dd706 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 3 Feb 2026 21:39:18 +0200 Subject: [PATCH 07/40] Add CommentReplyNotificationHandler for comment thread notifications --- .../app/services/notification_service.py | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/surfsense_backend/app/services/notification_service.py b/surfsense_backend/app/services/notification_service.py index 1788d05e1..a759f3536 100644 --- a/surfsense_backend/app/services/notification_service.py +++ b/surfsense_backend/app/services/notification_service.py @@ -861,6 +861,98 @@ class MentionNotificationHandler(BaseNotificationHandler): raise +class CommentReplyNotificationHandler(BaseNotificationHandler): + """Handler for comment reply notifications.""" + + def __init__(self): + super().__init__("comment_reply") + + async def find_notification_by_reply( + self, + session: AsyncSession, + reply_id: int, + user_id: UUID, + ) -> Notification | None: + query = select(Notification).where( + Notification.type == self.notification_type, + Notification.user_id == user_id, + Notification.notification_metadata["reply_id"].astext == str(reply_id), + ) + result = await session.execute(query) + return result.scalar_one_or_none() + + async def notify_comment_reply( + self, + session: AsyncSession, + user_id: UUID, + reply_id: int, + parent_comment_id: int, + message_id: int, + thread_id: int, + thread_title: str, + author_id: str, + author_name: str, + author_avatar_url: str | None, + author_email: str, + content_preview: str, + search_space_id: int, + ) -> Notification: + existing = await self.find_notification_by_reply(session, reply_id, user_id) + if existing: + logger.info( + f"Notification already exists for reply {reply_id} to user {user_id}" + ) + return existing + + title = f"{author_name} replied in a thread" + message = content_preview[:100] + ("..." if len(content_preview) > 100 else "") + + metadata = { + "reply_id": reply_id, + "parent_comment_id": parent_comment_id, + "message_id": message_id, + "thread_id": thread_id, + "thread_title": thread_title, + "author_id": author_id, + "author_name": author_name, + "author_avatar_url": author_avatar_url, + "author_email": author_email, + "content_preview": content_preview[:200], + } + + try: + notification = Notification( + user_id=user_id, + search_space_id=search_space_id, + type=self.notification_type, + title=title, + message=message, + notification_metadata=metadata, + ) + session.add(notification) + await session.commit() + await session.refresh(notification) + logger.info( + f"Created comment_reply notification {notification.id} for user {user_id}" + ) + return notification + except Exception as e: + await session.rollback() + if ( + "duplicate key" in str(e).lower() + or "unique constraint" in str(e).lower() + ): + logger.warning( + f"Duplicate notification for reply {reply_id} to user {user_id}" + ) + existing = await self.find_notification_by_reply( + session, reply_id, user_id + ) + if existing: + return existing + raise + + class PageLimitNotificationHandler(BaseNotificationHandler): """Handler for page limit exceeded notifications.""" @@ -959,6 +1051,7 @@ class NotificationService: connector_indexing = ConnectorIndexingNotificationHandler() document_processing = DocumentProcessingNotificationHandler() mention = MentionNotificationHandler() + comment_reply = CommentReplyNotificationHandler() page_limit = PageLimitNotificationHandler() @staticmethod From bf91d0c3d21887f909225f50f9b7c55a0523d830 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 3 Feb 2026 21:57:07 +0200 Subject: [PATCH 08/40] Add get_comment_thread_participants helper function --- .../app/services/chat_comments_service.py | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/surfsense_backend/app/services/chat_comments_service.py b/surfsense_backend/app/services/chat_comments_service.py index dc3b51238..7350dc12f 100644 --- a/surfsense_backend/app/services/chat_comments_service.py +++ b/surfsense_backend/app/services/chat_comments_service.py @@ -5,7 +5,7 @@ Service layer for chat comments and mentions. from uuid import UUID from fastapi import HTTPException -from sqlalchemy import delete, select +from sqlalchemy import delete, or_, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -103,6 +103,37 @@ async def process_mentions( return mentions_map +async def get_comment_thread_participants( + session: AsyncSession, + parent_comment_id: int, + exclude_user_ids: set[UUID], +) -> list[UUID]: + """ + Get all unique authors in a comment thread (parent + replies), excluding specified users. + + Args: + session: Database session + parent_comment_id: ID of the parent comment + exclude_user_ids: Set of user IDs to exclude (e.g., replier, mentioned users) + + Returns: + List of user UUIDs who have participated in the thread + """ + query = select(ChatComment.author_id).where( + or_( + ChatComment.id == parent_comment_id, + ChatComment.parent_id == parent_comment_id, + ), + ChatComment.author_id.isnot(None), + ) + + if exclude_user_ids: + query = query.where(ChatComment.author_id.notin_(exclude_user_ids)) + + result = await session.execute(query.distinct()) + return [row[0] for row in result.fetchall()] + + async def get_comments_for_message( session: AsyncSession, message_id: int, From cf512153df2fdfccaca577ceeff9ea6cc4e161ed Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 3 Feb 2026 22:00:26 +0200 Subject: [PATCH 09/40] Notify thread participants on new reply --- .../app/services/chat_comments_service.py | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/surfsense_backend/app/services/chat_comments_service.py b/surfsense_backend/app/services/chat_comments_service.py index 7350dc12f..209606359 100644 --- a/surfsense_backend/app/services/chat_comments_service.py +++ b/surfsense_backend/app/services/chat_comments_service.py @@ -467,6 +467,28 @@ async def create_reply( search_space_id=search_space_id, ) + # Notify thread participants (excluding replier and mentioned users) + exclude_ids = {user.id, *mentions_map.keys()} + participants = await get_comment_thread_participants( + session, comment_id, exclude_ids + ) + for participant_id in participants: + await NotificationService.comment_reply.notify_comment_reply( + session=session, + user_id=participant_id, + reply_id=reply.id, + parent_comment_id=comment_id, + message_id=parent_comment.message_id, + thread_id=thread.id, + thread_title=thread.title or "Untitled thread", + author_id=str(user.id), + author_name=author_name, + author_avatar_url=user.avatar_url, + author_email=user.email, + content_preview=content_preview[:200], + search_space_id=search_space_id, + ) + author = AuthorResponse( id=user.id, display_name=user.display_name, From 1c8ec7bbdcd13dc0dfd7158f557a6ee39d33e69c Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 4 Feb 2026 14:45:28 +0200 Subject: [PATCH 10/40] Add comment_reply notification type to frontend types --- surfsense_web/contracts/types/inbox.types.ts | 31 ++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/surfsense_web/contracts/types/inbox.types.ts b/surfsense_web/contracts/types/inbox.types.ts index 8e4b9ae86..ebf1889a1 100644 --- a/surfsense_web/contracts/types/inbox.types.ts +++ b/surfsense_web/contracts/types/inbox.types.ts @@ -10,6 +10,7 @@ export const inboxItemTypeEnum = z.enum([ "connector_deletion", "document_processing", "new_mention", + "comment_reply", "page_limit_exceeded", ]); @@ -101,6 +102,19 @@ export const newMentionMetadata = z.object({ content_preview: z.string(), }); +export const commentReplyMetadata = z.object({ + reply_id: z.number(), + parent_comment_id: z.number(), + message_id: z.number(), + thread_id: z.number(), + thread_title: z.string(), + author_id: z.string(), + author_name: z.string(), + author_avatar_url: z.string().nullable().optional(), + author_email: z.string().optional(), + content_preview: z.string(), +}); + /** * Page limit exceeded metadata schema */ @@ -125,6 +139,7 @@ export const inboxItemMetadata = z.union([ connectorDeletionMetadata, documentProcessingMetadata, newMentionMetadata, + commentReplyMetadata, pageLimitExceededMetadata, baseInboxItemMetadata, ]); @@ -168,6 +183,11 @@ export const newMentionInboxItem = inboxItem.extend({ metadata: newMentionMetadata, }); +export const commentReplyInboxItem = inboxItem.extend({ + type: z.literal("comment_reply"), + metadata: commentReplyMetadata, +}); + export const pageLimitExceededInboxItem = inboxItem.extend({ type: z.literal("page_limit_exceeded"), metadata: pageLimitExceededMetadata, @@ -278,6 +298,10 @@ export function isNewMentionMetadata(metadata: unknown): metadata is NewMentionM return newMentionMetadata.safeParse(metadata).success; } +export function isCommentReplyMetadata(metadata: unknown): metadata is CommentReplyMetadata { + return commentReplyMetadata.safeParse(metadata).success; +} + /** * Type guard for PageLimitExceededMetadata */ @@ -298,6 +322,7 @@ export function parseInboxItemMetadata( | ConnectorDeletionMetadata | DocumentProcessingMetadata | NewMentionMetadata + | CommentReplyMetadata | PageLimitExceededMetadata | null { switch (type) { @@ -317,6 +342,10 @@ export function parseInboxItemMetadata( const result = newMentionMetadata.safeParse(metadata); return result.success ? result.data : null; } + case "comment_reply": { + const result = commentReplyMetadata.safeParse(metadata); + return result.success ? result.data : null; + } case "page_limit_exceeded": { const result = pageLimitExceededMetadata.safeParse(metadata); return result.success ? result.data : null; @@ -338,6 +367,7 @@ export type ConnectorIndexingMetadata = z.infer; export type DocumentProcessingMetadata = z.infer; export type NewMentionMetadata = z.infer; +export type CommentReplyMetadata = z.infer; export type PageLimitExceededMetadata = z.infer; export type InboxItemMetadata = z.infer; export type InboxItem = z.infer; @@ -345,6 +375,7 @@ export type ConnectorIndexingInboxItem = z.infer; export type DocumentProcessingInboxItem = z.infer; export type NewMentionInboxItem = z.infer; +export type CommentReplyInboxItem = z.infer; export type PageLimitExceededInboxItem = z.infer; // API Request/Response types From 21a4c254583d2cb604451d226e17c66492b71fe3 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 4 Feb 2026 14:47:03 +0200 Subject: [PATCH 11/40] Handle comment_reply in inbox sidebar (icon + click routing) --- .../layout/ui/sidebar/InboxSidebar.tsx | 46 +++++++++++++------ 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx index 9ef49c0d8..c1f5fcc99 100644 --- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx @@ -46,6 +46,7 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import { + isCommentReplyMetadata, isConnectorIndexingMetadata, isNewMentionMetadata, isPageLimitExceededMetadata, @@ -347,19 +348,33 @@ export function InboxSidebar({ } if (item.type === "new_mention") { - // Use type guard for safe metadata access if (isNewMentionMetadata(item.metadata)) { const searchSpaceId = item.search_space_id; const threadId = item.metadata.thread_id; const commentId = item.metadata.comment_id; if (searchSpaceId && threadId) { - // Pre-set target comment ID before navigation - // This also ensures comments panel is not collapsed if (commentId) { setTargetCommentId(commentId); } + const url = commentId + ? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${commentId}` + : `/dashboard/${searchSpaceId}/new-chat/${threadId}`; + onOpenChange(false); + onCloseMobileSidebar?.(); + router.push(url); + } + } + } else if (item.type === "comment_reply") { + if (isCommentReplyMetadata(item.metadata)) { + const searchSpaceId = item.search_space_id; + const threadId = item.metadata.thread_id; + const commentId = item.metadata.parent_comment_id; + if (searchSpaceId && threadId) { + if (commentId) { + setTargetCommentId(commentId); + } const url = commentId ? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${commentId}` : `/dashboard/${searchSpaceId}/new-chat/${threadId}`; @@ -411,24 +426,29 @@ export function InboxSidebar({ }; const getStatusIcon = (item: InboxItem) => { - // For mentions, show the author's avatar with initials fallback - if (item.type === "new_mention") { - // Use type guard for safe metadata access - if (isNewMentionMetadata(item.metadata)) { - const authorName = item.metadata.author_name; - const avatarUrl = item.metadata.author_avatar_url; - const authorEmail = item.metadata.author_email; + // For mentions and comment replies, show the author's avatar + if (item.type === "new_mention" || item.type === "comment_reply") { + const metadata = + item.type === "new_mention" + ? isNewMentionMetadata(item.metadata) + ? item.metadata + : null + : isCommentReplyMetadata(item.metadata) + ? item.metadata + : null; + if (metadata) { return ( - {avatarUrl && } + {metadata.author_avatar_url && ( + + )} - {getInitials(authorName, authorEmail)} + {getInitials(metadata.author_name, metadata.author_email)} ); } - // Fallback for invalid metadata return ( From d5b75956c36cca3098b1b69aae747a27a9ded01e Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 4 Feb 2026 15:13:28 +0200 Subject: [PATCH 12/40] Include comment_reply in status tab filter --- surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx index c1f5fcc99..a46ad5478 100644 --- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx @@ -238,7 +238,7 @@ export function InboxSidebar({ const currentDataSource = activeTab === "mentions" ? mentions : status; const { loading, loadingMore = false, hasMore = false, loadMore } = currentDataSource; - // Status tab includes: connector indexing, document processing, page limit exceeded, connector deletion + // Status tab includes: connector indexing, document processing, page limit exceeded, connector deletion, comment replies // Filter to only show status notification types const statusItems = useMemo( () => @@ -247,7 +247,8 @@ export function InboxSidebar({ item.type === "connector_indexing" || item.type === "document_processing" || item.type === "page_limit_exceeded" || - item.type === "connector_deletion" + item.type === "connector_deletion" || + item.type === "comment_reply" ), [status.items] ); From 6d1a3636830d934b76c908e24e755770bece26b6 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 4 Feb 2026 15:18:25 +0200 Subject: [PATCH 13/40] Navigate to reply instead of parent comment --- .../components/layout/ui/sidebar/InboxSidebar.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx index a46ad5478..b63e16317 100644 --- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx @@ -370,14 +370,14 @@ export function InboxSidebar({ if (isCommentReplyMetadata(item.metadata)) { const searchSpaceId = item.search_space_id; const threadId = item.metadata.thread_id; - const commentId = item.metadata.parent_comment_id; + const replyId = item.metadata.reply_id; if (searchSpaceId && threadId) { - if (commentId) { - setTargetCommentId(commentId); + if (replyId) { + setTargetCommentId(replyId); } - const url = commentId - ? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${commentId}` + const url = replyId + ? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${replyId}` : `/dashboard/${searchSpaceId}/new-chat/${threadId}`; onOpenChange(false); onCloseMobileSidebar?.(); From f610e42b9c588e60843e91090540b4abaf6712d3 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 4 Feb 2026 16:37:44 +0200 Subject: [PATCH 14/40] fix: prevent duplicate notifications and fix inbox count --- .../app/services/chat_comments_service.py | 7 +- .../layout/providers/LayoutDataProvider.tsx | 11 +- .../components/theme/theme-toggle.tsx | 787 +++++++++--------- 3 files changed, 396 insertions(+), 409 deletions(-) diff --git a/surfsense_backend/app/services/chat_comments_service.py b/surfsense_backend/app/services/chat_comments_service.py index 209606359..c9ca920f6 100644 --- a/surfsense_backend/app/services/chat_comments_service.py +++ b/surfsense_backend/app/services/chat_comments_service.py @@ -128,7 +128,7 @@ async def get_comment_thread_participants( ) if exclude_user_ids: - query = query.where(ChatComment.author_id.notin_(exclude_user_ids)) + query = query.where(ChatComment.author_id.notin_(list(exclude_user_ids))) result = await session.execute(query.distinct()) return [row[0] for row in result.fetchall()] @@ -468,11 +468,14 @@ async def create_reply( ) # Notify thread participants (excluding replier and mentioned users) - exclude_ids = {user.id, *mentions_map.keys()} + mentioned_user_ids = set(mentions_map.keys()) + exclude_ids = {user.id} | mentioned_user_ids participants = await get_comment_thread_participants( session, comment_id, exclude_ids ) for participant_id in participants: + if participant_id in mentioned_user_ids: + continue await NotificationService.comment_reply.notify_comment_reply( session=session, user_id=participant_id, diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 2f71adad9..feb34940a 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -109,7 +109,6 @@ export function LayoutDataProvider({ // This ensures each tab has independent pagination and data loading const userId = user?.id ? String(user.id) : null; - // Mentions: Only fetch "new_mention" type notifications const { inboxItems: mentionItems, unreadCount: mentionUnreadCount, @@ -121,11 +120,9 @@ export function LayoutDataProvider({ markAllAsRead: markAllMentionsAsRead, } = useInbox(userId, Number(searchSpaceId) || null, "new_mention"); - // Status: Fetch all types (will be filtered client-side to status types) - // We pass null to get all, then InboxSidebar filters to status types const { inboxItems: statusItems, - unreadCount: statusUnreadCount, + unreadCount: allUnreadCount, loading: statusLoading, loadingMore: statusLoadingMore, hasMore: statusHasMore, @@ -134,8 +131,8 @@ export function LayoutDataProvider({ markAllAsRead: markAllStatusAsRead, } = useInbox(userId, Number(searchSpaceId) || null, null); - // Combined unread count for nav badge (mentions take priority for visibility) - const totalUnreadCount = mentionUnreadCount + statusUnreadCount; + const totalUnreadCount = allUnreadCount; + const statusOnlyUnreadCount = Math.max(0, allUnreadCount - mentionUnreadCount); // Track seen notification IDs to detect new page_limit_exceeded notifications const seenPageLimitNotifications = useRef>(new Set()); @@ -561,7 +558,7 @@ export function LayoutDataProvider({ }, status: { items: statusItems, - unreadCount: statusUnreadCount, + unreadCount: statusOnlyUnreadCount, loading: statusLoading, loadingMore: statusLoadingMore, hasMore: statusHasMore, diff --git a/surfsense_web/components/theme/theme-toggle.tsx b/surfsense_web/components/theme/theme-toggle.tsx index 382d11087..b9b23656b 100644 --- a/surfsense_web/components/theme/theme-toggle.tsx +++ b/surfsense_web/components/theme/theme-toggle.tsx @@ -8,172 +8,167 @@ import { cn } from "@/lib/utils"; // /////////////////////////////////////////////////////////////////////////// // Types -export type AnimationVariant = - | "circle" - | "rectangle" - | "gif" - | "polygon" - | "circle-blur"; +export type AnimationVariant = "circle" | "rectangle" | "gif" | "polygon" | "circle-blur"; export type AnimationStart = - | "top-left" - | "top-right" - | "bottom-left" - | "bottom-right" - | "center" - | "top-center" - | "bottom-center" - | "bottom-up" - | "top-down" - | "left-right" - | "right-left"; + | "top-left" + | "top-right" + | "bottom-left" + | "bottom-right" + | "center" + | "top-center" + | "bottom-center" + | "bottom-up" + | "top-down" + | "left-right" + | "right-left"; interface Animation { - name: string; - css: string; + name: string; + css: string; } // /////////////////////////////////////////////////////////////////////////// // Helper functions const getPositionCoords = (position: AnimationStart) => { - switch (position) { - case "top-left": - return { cx: "0", cy: "0" }; - case "top-right": - return { cx: "40", cy: "0" }; - case "bottom-left": - return { cx: "0", cy: "40" }; - case "bottom-right": - return { cx: "40", cy: "40" }; - case "top-center": - return { cx: "20", cy: "0" }; - case "bottom-center": - return { cx: "20", cy: "40" }; - case "bottom-up": - case "top-down": - case "left-right": - case "right-left": - return { cx: "20", cy: "20" }; - } + switch (position) { + case "top-left": + return { cx: "0", cy: "0" }; + case "top-right": + return { cx: "40", cy: "0" }; + case "bottom-left": + return { cx: "0", cy: "40" }; + case "bottom-right": + return { cx: "40", cy: "40" }; + case "top-center": + return { cx: "20", cy: "0" }; + case "bottom-center": + return { cx: "20", cy: "40" }; + case "bottom-up": + case "top-down": + case "left-right": + case "right-left": + return { cx: "20", cy: "20" }; + } }; const generateSVG = (variant: AnimationVariant, start: AnimationStart) => { - if (variant === "circle-blur") { - if (start === "center") { - return `data:image/svg+xml,`; - } - const positionCoords = getPositionCoords(start); - if (!positionCoords) { - throw new Error(`Invalid start position: ${start}`); - } - const { cx, cy } = positionCoords; - return `data:image/svg+xml,`; - } + if (variant === "circle-blur") { + if (start === "center") { + return `data:image/svg+xml,`; + } + const positionCoords = getPositionCoords(start); + if (!positionCoords) { + throw new Error(`Invalid start position: ${start}`); + } + const { cx, cy } = positionCoords; + return `data:image/svg+xml,`; + } - if (start === "center") return; + if (start === "center") return; - if (variant === "rectangle") return ""; + if (variant === "rectangle") return ""; - const positionCoords = getPositionCoords(start); - if (!positionCoords) { - throw new Error(`Invalid start position: ${start}`); - } - const { cx, cy } = positionCoords; + const positionCoords = getPositionCoords(start); + if (!positionCoords) { + throw new Error(`Invalid start position: ${start}`); + } + const { cx, cy } = positionCoords; - if (variant === "circle") { - return `data:image/svg+xml,`; - } + if (variant === "circle") { + return `data:image/svg+xml,`; + } - return ""; + return ""; }; const getTransformOrigin = (start: AnimationStart) => { - switch (start) { - case "top-left": - return "top left"; - case "top-right": - return "top right"; - case "bottom-left": - return "bottom left"; - case "bottom-right": - return "bottom right"; - case "top-center": - return "top center"; - case "bottom-center": - return "bottom center"; - case "bottom-up": - case "top-down": - case "left-right": - case "right-left": - return "center"; - } + switch (start) { + case "top-left": + return "top left"; + case "top-right": + return "top right"; + case "bottom-left": + return "bottom left"; + case "bottom-right": + return "bottom right"; + case "top-center": + return "top center"; + case "bottom-center": + return "bottom center"; + case "bottom-up": + case "top-down": + case "left-right": + case "right-left": + return "center"; + } }; export const createAnimation = ( - variant: AnimationVariant, - start: AnimationStart = "center", - blur = false, - url?: string, + variant: AnimationVariant, + start: AnimationStart = "center", + blur = false, + url?: string ): Animation => { - const svg = generateSVG(variant, start); - const transformOrigin = getTransformOrigin(start); + const svg = generateSVG(variant, start); + const transformOrigin = getTransformOrigin(start); - if (variant === "rectangle") { - const getClipPath = (direction: AnimationStart) => { - switch (direction) { - case "bottom-up": - return { - from: "polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)", - to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", - }; - case "top-down": - return { - from: "polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)", - to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", - }; - case "left-right": - return { - from: "polygon(0% 0%, 0% 0%, 0% 100%, 0% 100%)", - to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", - }; - case "right-left": - return { - from: "polygon(100% 0%, 100% 0%, 100% 100%, 100% 100%)", - to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", - }; - case "top-left": - return { - from: "polygon(0% 0%, 0% 0%, 0% 0%, 0% 0%)", - to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", - }; - case "top-right": - return { - from: "polygon(100% 0%, 100% 0%, 100% 0%, 100% 0%)", - to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", - }; - case "bottom-left": - return { - from: "polygon(0% 100%, 0% 100%, 0% 100%, 0% 100%)", - to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", - }; - case "bottom-right": - return { - from: "polygon(100% 100%, 100% 100%, 100% 100%, 100% 100%)", - to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", - }; - default: - return { - from: "polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)", - to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", - }; - } - }; + if (variant === "rectangle") { + const getClipPath = (direction: AnimationStart) => { + switch (direction) { + case "bottom-up": + return { + from: "polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)", + to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", + }; + case "top-down": + return { + from: "polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)", + to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", + }; + case "left-right": + return { + from: "polygon(0% 0%, 0% 0%, 0% 100%, 0% 100%)", + to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", + }; + case "right-left": + return { + from: "polygon(100% 0%, 100% 0%, 100% 100%, 100% 100%)", + to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", + }; + case "top-left": + return { + from: "polygon(0% 0%, 0% 0%, 0% 0%, 0% 0%)", + to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", + }; + case "top-right": + return { + from: "polygon(100% 0%, 100% 0%, 100% 0%, 100% 0%)", + to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", + }; + case "bottom-left": + return { + from: "polygon(0% 100%, 0% 100%, 0% 100%, 0% 100%)", + to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", + }; + case "bottom-right": + return { + from: "polygon(100% 100%, 100% 100%, 100% 100%, 100% 100%)", + to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", + }; + default: + return { + from: "polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)", + to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", + }; + } + }; - const clipPath = getClipPath(start); + const clipPath = getClipPath(start); - return { - name: `${variant}-${start}${blur ? "-blur" : ""}`, - css: ` + return { + name: `${variant}-${start}${blur ? "-blur" : ""}`, + css: ` ::view-transition-group(root) { animation-duration: 0.7s; animation-timing-function: var(--expo-out); @@ -218,12 +213,12 @@ export const createAnimation = ( } } `, - }; - } - if (variant === "circle" && start == "center") { - return { - name: `${variant}-${start}${blur ? "-blur" : ""}`, - css: ` + }; + } + if (variant === "circle" && start == "center") { + return { + name: `${variant}-${start}${blur ? "-blur" : ""}`, + css: ` ::view-transition-group(root) { animation-duration: 0.7s; animation-timing-function: var(--expo-out); @@ -268,12 +263,12 @@ export const createAnimation = ( } } `, - }; - } - if (variant === "gif") { - return { - name: `${variant}-${start}`, - css: ` + }; + } + if (variant === "gif") { + return { + name: `${variant}-${start}`, + css: ` ::view-transition-group(root) { animation-timing-function: var(--expo-in); } @@ -302,14 +297,14 @@ export const createAnimation = ( mask-size: 2000vmax; } }`, - }; - } + }; + } - if (variant === "circle-blur") { - if (start === "center") { - return { - name: `${variant}-${start}`, - css: ` + if (variant === "circle-blur") { + if (start === "center") { + return { + name: `${variant}-${start}`, + css: ` ::view-transition-group(root) { animation-timing-function: var(--expo-out); } @@ -334,12 +329,12 @@ export const createAnimation = ( } } `, - }; - } + }; + } - return { - name: `${variant}-${start}`, - css: ` + return { + name: `${variant}-${start}`, + css: ` ::view-transition-group(root) { animation-timing-function: var(--expo-out); } @@ -364,41 +359,41 @@ export const createAnimation = ( } } `, - }; - } + }; + } - if (variant === "polygon") { - const getPolygonClipPaths = (position: AnimationStart) => { - switch (position) { - case "top-left": - return { - darkFrom: "polygon(50% -71%, -50% 71%, -50% 71%, 50% -71%)", - darkTo: "polygon(50% -71%, -50% 71%, 50% 171%, 171% 50%)", - lightFrom: "polygon(171% 50%, 50% 171%, 50% 171%, 171% 50%)", - lightTo: "polygon(171% 50%, 50% 171%, -50% 71%, 50% -71%)", - }; - case "top-right": - return { - darkFrom: "polygon(150% -71%, 250% 71%, 250% 71%, 150% -71%)", - darkTo: "polygon(150% -71%, 250% 71%, 50% 171%, -71% 50%)", - lightFrom: "polygon(-71% 50%, 50% 171%, 50% 171%, -71% 50%)", - lightTo: "polygon(-71% 50%, 50% 171%, 250% 71%, 150% -71%)", - }; - default: - return { - darkFrom: "polygon(50% -71%, -50% 71%, -50% 71%, 50% -71%)", - darkTo: "polygon(50% -71%, -50% 71%, 50% 171%, 171% 50%)", - lightFrom: "polygon(171% 50%, 50% 171%, 50% 171%, 171% 50%)", - lightTo: "polygon(171% 50%, 50% 171%, -50% 71%, 50% -71%)", - }; - } - }; + if (variant === "polygon") { + const getPolygonClipPaths = (position: AnimationStart) => { + switch (position) { + case "top-left": + return { + darkFrom: "polygon(50% -71%, -50% 71%, -50% 71%, 50% -71%)", + darkTo: "polygon(50% -71%, -50% 71%, 50% 171%, 171% 50%)", + lightFrom: "polygon(171% 50%, 50% 171%, 50% 171%, 171% 50%)", + lightTo: "polygon(171% 50%, 50% 171%, -50% 71%, 50% -71%)", + }; + case "top-right": + return { + darkFrom: "polygon(150% -71%, 250% 71%, 250% 71%, 150% -71%)", + darkTo: "polygon(150% -71%, 250% 71%, 50% 171%, -71% 50%)", + lightFrom: "polygon(-71% 50%, 50% 171%, 50% 171%, -71% 50%)", + lightTo: "polygon(-71% 50%, 50% 171%, 250% 71%, 150% -71%)", + }; + default: + return { + darkFrom: "polygon(50% -71%, -50% 71%, -50% 71%, 50% -71%)", + darkTo: "polygon(50% -71%, -50% 71%, 50% 171%, 171% 50%)", + lightFrom: "polygon(171% 50%, 50% 171%, 50% 171%, 171% 50%)", + lightTo: "polygon(171% 50%, 50% 171%, -50% 71%, 50% -71%)", + }; + } + }; - const clipPaths = getPolygonClipPaths(start); + const clipPaths = getPolygonClipPaths(start); - return { - name: `${variant}-${start}${blur ? "-blur" : ""}`, - css: ` + return { + name: `${variant}-${start}${blur ? "-blur" : ""}`, + css: ` ::view-transition-group(root) { animation-duration: 0.7s; animation-timing-function: var(--expo-out); @@ -443,35 +438,35 @@ export const createAnimation = ( } } `, - }; - } + }; + } - // Handle circle variants with start positions using clip-path - if (variant === "circle" && start !== "center") { - const getClipPathPosition = (position: AnimationStart) => { - switch (position) { - case "top-left": - return "0% 0%"; - case "top-right": - return "100% 0%"; - case "bottom-left": - return "0% 100%"; - case "bottom-right": - return "100% 100%"; - case "top-center": - return "50% 0%"; - case "bottom-center": - return "50% 100%"; - default: - return "50% 50%"; - } - }; + // Handle circle variants with start positions using clip-path + if (variant === "circle" && start !== "center") { + const getClipPathPosition = (position: AnimationStart) => { + switch (position) { + case "top-left": + return "0% 0%"; + case "top-right": + return "100% 0%"; + case "bottom-left": + return "0% 100%"; + case "bottom-right": + return "100% 100%"; + case "top-center": + return "50% 0%"; + case "bottom-center": + return "50% 100%"; + default: + return "50% 50%"; + } + }; - const clipPosition = getClipPathPosition(start); + const clipPosition = getClipPathPosition(start); - return { - name: `${variant}-${start}${blur ? "-blur" : ""}`, - css: ` + return { + name: `${variant}-${start}${blur ? "-blur" : ""}`, + css: ` ::view-transition-group(root) { animation-duration: 1s; animation-timing-function: var(--expo-out); @@ -516,12 +511,12 @@ export const createAnimation = ( } } `, - }; - } + }; + } - return { - name: `${variant}-${start}${blur ? "-blur" : ""}`, - css: ` + return { + name: `${variant}-${start}${blur ? "-blur" : ""}`, + css: ` ::view-transition-group(root) { animation-timing-function: var(--expo-in); } @@ -549,237 +544,229 @@ export const createAnimation = ( } } `, - }; + }; }; // /////////////////////////////////////////////////////////////////////////// // Custom hook for theme toggle functionality export const useThemeToggle = ({ - variant = "circle", - start = "center", - blur = false, - gifUrl = "", + variant = "circle", + start = "center", + blur = false, + gifUrl = "", }: { - variant?: AnimationVariant; - start?: AnimationStart; - blur?: boolean; - gifUrl?: string; + variant?: AnimationVariant; + start?: AnimationStart; + blur?: boolean; + gifUrl?: string; } = {}) => { - const { theme, setTheme, resolvedTheme } = useTheme(); + const { theme, setTheme, resolvedTheme } = useTheme(); - const [isDark, setIsDark] = useState(false); + const [isDark, setIsDark] = useState(false); - // Sync isDark state with resolved theme after hydration - useEffect(() => { - setIsDark(resolvedTheme === "dark"); - }, [resolvedTheme]); + // Sync isDark state with resolved theme after hydration + useEffect(() => { + setIsDark(resolvedTheme === "dark"); + }, [resolvedTheme]); - const styleId = "theme-transition-styles"; + const styleId = "theme-transition-styles"; - const updateStyles = useCallback((css: string) => { - if (typeof window === "undefined") return; + const updateStyles = useCallback((css: string) => { + if (typeof window === "undefined") return; - let styleElement = document.getElementById(styleId) as HTMLStyleElement; + let styleElement = document.getElementById(styleId) as HTMLStyleElement; - if (!styleElement) { - styleElement = document.createElement("style"); - styleElement.id = styleId; - document.head.appendChild(styleElement); - } + if (!styleElement) { + styleElement = document.createElement("style"); + styleElement.id = styleId; + document.head.appendChild(styleElement); + } - styleElement.textContent = css; - }, []); + styleElement.textContent = css; + }, []); - const toggleTheme = useCallback(() => { - setIsDark(!isDark); + const toggleTheme = useCallback(() => { + setIsDark(!isDark); - const animation = createAnimation(variant, start, blur, gifUrl); + const animation = createAnimation(variant, start, blur, gifUrl); - updateStyles(animation.css); + updateStyles(animation.css); - if (typeof window === "undefined") return; + if (typeof window === "undefined") return; - const switchTheme = () => { - setTheme(theme === "light" ? "dark" : "light"); - }; + const switchTheme = () => { + setTheme(theme === "light" ? "dark" : "light"); + }; - if (!document.startViewTransition) { - switchTheme(); - return; - } + if (!document.startViewTransition) { + switchTheme(); + return; + } - document.startViewTransition(switchTheme); - }, [theme, setTheme, variant, start, blur, gifUrl, updateStyles, isDark]); + document.startViewTransition(switchTheme); + }, [theme, setTheme, variant, start, blur, gifUrl, updateStyles, isDark]); - const setCrazyLightTheme = useCallback(() => { - setIsDark(false); + const setCrazyLightTheme = useCallback(() => { + setIsDark(false); - const animation = createAnimation(variant, start, blur, gifUrl); + const animation = createAnimation(variant, start, blur, gifUrl); - updateStyles(animation.css); + updateStyles(animation.css); - if (typeof window === "undefined") return; + if (typeof window === "undefined") return; - const switchTheme = () => { - setTheme("light"); - }; + const switchTheme = () => { + setTheme("light"); + }; - if (!document.startViewTransition) { - switchTheme(); - return; - } + if (!document.startViewTransition) { + switchTheme(); + return; + } - document.startViewTransition(switchTheme); - }, [setTheme, variant, start, blur, gifUrl, updateStyles]); + document.startViewTransition(switchTheme); + }, [setTheme, variant, start, blur, gifUrl, updateStyles]); - const setCrazyDarkTheme = useCallback(() => { - setIsDark(true); + const setCrazyDarkTheme = useCallback(() => { + setIsDark(true); - const animation = createAnimation(variant, start, blur, gifUrl); + const animation = createAnimation(variant, start, blur, gifUrl); - updateStyles(animation.css); + updateStyles(animation.css); - if (typeof window === "undefined") return; + if (typeof window === "undefined") return; - const switchTheme = () => { - setTheme("dark"); - }; + const switchTheme = () => { + setTheme("dark"); + }; - if (!document.startViewTransition) { - switchTheme(); - return; - } + if (!document.startViewTransition) { + switchTheme(); + return; + } - document.startViewTransition(switchTheme); - }, [setTheme, variant, start, blur, gifUrl, updateStyles]); + document.startViewTransition(switchTheme); + }, [setTheme, variant, start, blur, gifUrl, updateStyles]); - const setCrazySystemTheme = useCallback(() => { - if (typeof window === "undefined") return; + const setCrazySystemTheme = useCallback(() => { + if (typeof window === "undefined") return; - const prefersDark = window.matchMedia( - "(prefers-color-scheme: dark)", - ).matches; - setIsDark(prefersDark); + const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + setIsDark(prefersDark); - const animation = createAnimation(variant, start, blur, gifUrl); + const animation = createAnimation(variant, start, blur, gifUrl); - updateStyles(animation.css); + updateStyles(animation.css); - const switchTheme = () => { - setTheme("system"); - }; + const switchTheme = () => { + setTheme("system"); + }; - if (!document.startViewTransition) { - switchTheme(); - return; - } + if (!document.startViewTransition) { + switchTheme(); + return; + } - document.startViewTransition(switchTheme); - }, [setTheme, variant, start, blur, gifUrl, updateStyles]); + document.startViewTransition(switchTheme); + }, [setTheme, variant, start, blur, gifUrl, updateStyles]); - return { - isDark, - setIsDark, - toggleTheme, - setCrazyLightTheme, - setCrazyDarkTheme, - setCrazySystemTheme, - }; + return { + isDark, + setIsDark, + toggleTheme, + setCrazyLightTheme, + setCrazyDarkTheme, + setCrazySystemTheme, + }; }; // /////////////////////////////////////////////////////////////////////////// // Theme Toggle Button Component (Sun/Moon Style) export const ThemeToggleButton = ({ - className = "", - variant = "circle", - start = "center", - blur = false, - gifUrl = "", + className = "", + variant = "circle", + start = "center", + blur = false, + gifUrl = "", }: { - className?: string; - variant?: AnimationVariant; - start?: AnimationStart; - blur?: boolean; - gifUrl?: string; + className?: string; + variant?: AnimationVariant; + start?: AnimationStart; + blur?: boolean; + gifUrl?: string; }) => { - const { isDark, toggleTheme } = useThemeToggle({ - variant, - start, - blur, - gifUrl, - }); - const clipId = useId(); - const clipPathId = `theme-toggle-clip-${clipId}`; + const { isDark, toggleTheme } = useThemeToggle({ + variant, + start, + blur, + gifUrl, + }); + const clipId = useId(); + const clipPathId = `theme-toggle-clip-${clipId}`; - return ( - - ); + return ( + + ); }; // /////////////////////////////////////////////////////////////////////////// // Backwards compatible export (alias for ThemeToggleButton with default settings) export function ThemeTogglerComponent() { - return ( - - ); + return ; } /** From 17eab845d05517d9e5e011f18c5a3d5f7695b08b Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 4 Feb 2026 17:17:33 +0200 Subject: [PATCH 15/40] feat: rename Mentions tab to Comments with speech bubble icon --- .../layout/ui/sidebar/InboxSidebar.tsx | 58 +++++++++++-------- surfsense_web/messages/en.json | 3 + surfsense_web/messages/zh.json | 3 + 3 files changed, 40 insertions(+), 24 deletions(-) diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx index b63e16317..f313dd6f9 100644 --- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx @@ -4,7 +4,6 @@ import { useAtom } from "jotai"; import { AlertCircle, AlertTriangle, - AtSign, BellDot, Check, CheckCheck, @@ -15,6 +14,7 @@ import { Inbox, LayoutGrid, ListFilter, + MessageSquare, Search, X, } from "lucide-react"; @@ -134,7 +134,7 @@ function getConnectorTypeDisplayName(connectorType: string): string { ); } -type InboxTab = "mentions" | "status"; +type InboxTab = "comments" | "status"; type InboxFilter = "all" | "unread"; // Tab-specific data source with independent pagination @@ -187,7 +187,7 @@ export function InboxSidebar({ const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom); const [searchQuery, setSearchQuery] = useState(""); - const [activeTab, setActiveTab] = useState("mentions"); + const [activeTab, setActiveTab] = useState("comments"); const [activeFilter, setActiveFilter] = useState("all"); const [selectedConnector, setSelectedConnector] = useState(null); const [mounted, setMounted] = useState(false); @@ -234,12 +234,17 @@ export function InboxSidebar({ } }, [activeTab]); - // Get current tab's data source - each tab has independent data and pagination - const currentDataSource = activeTab === "mentions" ? mentions : status; - const { loading, loadingMore = false, hasMore = false, loadMore } = currentDataSource; + // Both tabs now derive items from status (all types), so use status for pagination + const { loading, loadingMore = false, hasMore = false, loadMore } = status; - // Status tab includes: connector indexing, document processing, page limit exceeded, connector deletion, comment replies - // Filter to only show status notification types + // Comments tab: mentions and comment replies + const commentsItems = useMemo( + () => + status.items.filter((item) => item.type === "new_mention" || item.type === "comment_reply"), + [status.items] + ); + + // Status tab: connector indexing, document processing, page limit exceeded, connector deletion const statusItems = useMemo( () => status.items.filter( @@ -247,8 +252,7 @@ export function InboxSidebar({ item.type === "connector_indexing" || item.type === "document_processing" || item.type === "page_limit_exceeded" || - item.type === "connector_deletion" || - item.type === "comment_reply" + item.type === "connector_deletion" ), [status.items] ); @@ -272,8 +276,8 @@ export function InboxSidebar({ })); }, [statusItems]); - // Get items for current tab - mentions use their source directly, status uses filtered items - const displayItems = activeTab === "mentions" ? mentions.items : statusItems; + // Get items for current tab + const displayItems = activeTab === "comments" ? commentsItems : statusItems; // Filter items based on filter type, connector filter, and search query const filteredItems = useMemo(() => { @@ -336,9 +340,15 @@ export function InboxSidebar({ return () => observer.disconnect(); }, [loadMore, hasMore, loadingMore, open, searchQuery]); - // Use unread counts from data sources (more accurate than client-side counting) - const unreadMentionsCount = mentions.unreadCount; - const unreadStatusCount = status.unreadCount; + // Unread counts derived from filtered items + const unreadCommentsCount = useMemo( + () => commentsItems.filter((item) => !item.read).length, + [commentsItems] + ); + const unreadStatusCount = useMemo( + () => statusItems.filter((item) => !item.read).length, + [statusItems] + ); const handleItemClick = useCallback( async (item: InboxItem) => { @@ -502,10 +512,10 @@ export function InboxSidebar({ }; const getEmptyStateMessage = () => { - if (activeTab === "mentions") { + if (activeTab === "comments") { return { - title: t("no_mentions") || "No mentions", - hint: t("no_mentions_hint") || "You'll see mentions from others here", + title: t("no_comments") || "No comments", + hint: t("no_comments_hint") || "You'll see mentions and replies here", }; } return { @@ -844,14 +854,14 @@ export function InboxSidebar({ > - - {t("mentions") || "Mentions"} + + {t("comments") || "Comments"} - {formatInboxCount(unreadMentionsCount)} + {formatInboxCount(unreadCommentsCount)} @@ -953,8 +963,8 @@ export function InboxSidebar({ ) : (
    - {activeTab === "mentions" ? ( - + {activeTab === "comments" ? ( + ) : ( )} diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index 75b186420..0646bdb52 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -698,10 +698,13 @@ "mark_all_read": "Mark all as read", "mark_as_read": "Mark as read", "mentions": "Mentions", + "comments": "Comments", "status": "Status", "no_results_found": "No results found", "no_mentions": "No mentions", "no_mentions_hint": "You'll see mentions from others here", + "no_comments": "No comments", + "no_comments_hint": "You'll see mentions and replies here", "no_status_updates": "No status updates", "no_status_updates_hint": "Document and connector updates will appear here", "filter": "Filter", diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json index 81121ef3e..67077c348 100644 --- a/surfsense_web/messages/zh.json +++ b/surfsense_web/messages/zh.json @@ -683,10 +683,13 @@ "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": "筛选", From 2711563e8be328b39fc58eca3157b5c8048948e6 Mon Sep 17 00:00:00 2001 From: Eric Lammertsma Date: Wed, 4 Feb 2026 11:03:31 -0500 Subject: [PATCH 16/40] Refined pricing plan descriptions by updating content limits and adding "planned" to future features. --- surfsense_web/components/pricing/pricing-section.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/surfsense_web/components/pricing/pricing-section.tsx b/surfsense_web/components/pricing/pricing-section.tsx index c850852d8..445969ec1 100644 --- a/surfsense_web/components/pricing/pricing-section.tsx +++ b/surfsense_web/components/pricing/pricing-section.tsx @@ -11,7 +11,7 @@ const demoPlans = [ billingText: "Includes 30 day PRO trial", features: [ "Open source on GitHub", - "Upload and chat with up to 1,000 pages of content", + "Upload and chat with 1,000+ pages of content", "Connects with 8 popular sources, like Drive and Notion.", "Includes limited access to ChatGPT, Claude, and DeepSeek models", "Supports 100+ more LLMs, including Gemini, Llama and many more.", @@ -33,13 +33,13 @@ const demoPlans = [ billingText: "billed annually", features: [ "Everything in Free", - "Upload and chat with up to 20,000 pages of content", + "Upload and chat with up to 20,000+ pages of content", "Connects with 15+ external sources, like Slack and Airtable.", "Includes extended access to ChatGPT, Claude, and DeepSeek models", "Collaboration and commenting features", - "Centralized billing", "Shared BYOK (Bring Your Own Key)", "Team and role management", + "Planned: Centralized billing", "Priority support", ], description: "The AIknowledge base for individuals and teams", @@ -58,9 +58,9 @@ const demoPlans = [ "Connect and chat with virtually unlimited pages of content", "Limit models and/or providers", "On-prem or VPC deployment", - "Audit logs and compliance", - "SSO, OIDC & SAML", - "Role-based access control (RBAC)", + "Planned: Audit logs and compliance", + "Planned: SSO, OIDC & SAML", + "Planned: Role-based access control (RBAC)", "White-glove setup and deployment", "Monthly managed updates and maintenance", "SLA commitments", From f5aa520743e25d3b600b4af310165eaa09d99696 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 4 Feb 2026 18:04:53 +0200 Subject: [PATCH 17/40] Remove branch picker and restrict edit/reload to last message --- .../assistant-ui/assistant-message.tsx | 22 ++++++----- .../components/assistant-ui/branch-picker.tsx | 32 ---------------- .../components/assistant-ui/user-message.tsx | 38 +++++++++++++++---- 3 files changed, 42 insertions(+), 50 deletions(-) delete mode 100644 surfsense_web/components/assistant-ui/branch-picker.tsx diff --git a/surfsense_web/components/assistant-ui/assistant-message.tsx b/surfsense_web/components/assistant-ui/assistant-message.tsx index 4fd2446c3..5cdd287de 100644 --- a/surfsense_web/components/assistant-ui/assistant-message.tsx +++ b/surfsense_web/components/assistant-ui/assistant-message.tsx @@ -4,20 +4,19 @@ import { ErrorPrimitive, MessagePrimitive, useAssistantState, + useMessage, } from "@assistant-ui/react"; -import { useAtom, useAtomValue, useSetAtom } from "jotai"; +import { useAtom, useAtomValue } from "jotai"; import { CheckIcon, CopyIcon, DownloadIcon, MessageSquare, RefreshCwIcon } from "lucide-react"; import type { FC } from "react"; import { useContext, useEffect, useMemo, useRef, useState } from "react"; import { addingCommentToMessageIdAtom, - clearTargetCommentIdAtom, commentsCollapsedAtom, commentsEnabledAtom, targetCommentIdAtom, } from "@/atoms/chat/current-thread.atom"; import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; -import { BranchPicker } from "@/components/assistant-ui/branch-picker"; import { MarkdownText } from "@/components/assistant-ui/markdown-text"; import { ThinkingStepsContext, @@ -84,7 +83,6 @@ const AssistantMessageInner: FC = () => {
    -
    @@ -126,7 +124,6 @@ export const AssistantMessage: FC = () => { // Target comment navigation - read target from global atom const targetCommentId = useAtomValue(targetCommentIdAtom); - const clearTargetCommentId = useSetAtom(clearTargetCommentIdAtom); // Check if target comment belongs to this message (including replies) const hasTargetComment = useMemo(() => { @@ -263,6 +260,8 @@ export const AssistantMessage: FC = () => { }; const AssistantActionBar: FC = () => { + const { isLast } = useMessage(); + return ( { - - - - - + {/* Only allow regenerating the last assistant message */} + {isLast && ( + + + + + + )} ); }; diff --git a/surfsense_web/components/assistant-ui/branch-picker.tsx b/surfsense_web/components/assistant-ui/branch-picker.tsx deleted file mode 100644 index ee4addd2a..000000000 --- a/surfsense_web/components/assistant-ui/branch-picker.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { BranchPickerPrimitive } from "@assistant-ui/react"; -import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; -import type { FC } from "react"; -import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; -import { cn } from "@/lib/utils"; - -export const BranchPicker: FC = ({ className, ...rest }) => { - return ( - - - - - - - - / - - - - - - - - ); -}; diff --git a/surfsense_web/components/assistant-ui/user-message.tsx b/surfsense_web/components/assistant-ui/user-message.tsx index 896b8c748..1ae8aef3c 100644 --- a/surfsense_web/components/assistant-ui/user-message.tsx +++ b/surfsense_web/components/assistant-ui/user-message.tsx @@ -4,7 +4,6 @@ import { FileText, PencilIcon } from "lucide-react"; import { type FC, useState } from "react"; import { messageDocumentsMapAtom } from "@/atoms/chat/mentioned-documents.atom"; import { UserMessageAttachments } from "@/components/assistant-ui/attachment"; -import { BranchPicker } from "@/components/assistant-ui/branch-picker"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; interface AuthorMetadata { @@ -95,24 +94,47 @@ export const UserMessage: FC = () => { )} - - ); }; const UserActionBar: FC = () => { + const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning); + + // Get current message ID + const currentMessageId = useAssistantState(({ message }) => message?.id); + + // Find the last user message ID in the thread (computed once, memoized by selector) + const lastUserMessageId = useAssistantState(({ thread }) => { + const messages = thread.messages; + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === "user") { + return messages[i].id; + } + } + return null; + }); + + // Simple comparison - no iteration needed per message + const isLastUserMessage = currentMessageId === lastUserMessageId; + + // Show edit button only on the last user message and when thread is not running + const canEdit = isLastUserMessage && !isThreadRunning; + return ( - - - - - + {/* Only allow editing the last user message */} + {canEdit && ( + + + + + + )} ); }; From fb371d09f51120498de77d7e27940a3b9cf3fa32 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 4 Feb 2026 18:26:38 +0200 Subject: [PATCH 18/40] Add globe indicator for chats with public links --- .../components/new-chat/chat-share-button.tsx | 237 ++++++++++-------- 1 file changed, 136 insertions(+), 101 deletions(-) diff --git a/surfsense_web/components/new-chat/chat-share-button.tsx b/surfsense_web/components/new-chat/chat-share-button.tsx index fa05f44c1..aae9f18c4 100644 --- a/surfsense_web/components/new-chat/chat-share-button.tsx +++ b/surfsense_web/components/new-chat/chat-share-button.tsx @@ -1,8 +1,9 @@ "use client"; -import { useQueryClient } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useAtomValue, useSetAtom } from "jotai"; import { Globe, User, Users } from "lucide-react"; +import { useParams, useRouter } from "next/navigation"; import { useCallback, useMemo, useState } from "react"; import { toast } from "sonner"; import { currentThreadAtom, setThreadVisibilityAtom } from "@/atoms/chat/current-thread.atom"; @@ -11,6 +12,7 @@ import { createPublicChatSnapshotMutationAtom } from "@/atoms/public-chat-snapsh import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { chatThreadsApiService } from "@/lib/apis/chat-threads-api.service"; import { type ChatVisibility, type ThreadRecord, @@ -46,6 +48,8 @@ const visibilityOptions: { export function ChatShareButton({ thread, onVisibilityChange, className }: ChatShareButtonProps) { const queryClient = useQueryClient(); + const router = useRouter(); + const params = useParams(); const [open, setOpen] = useState(false); // Use Jotai atom for visibility (single source of truth) @@ -65,6 +69,16 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS return access.permissions?.includes("public_sharing:create") ?? false; }, [access]); + // Query to check if thread has public snapshots + const { data: snapshotsData } = useQuery({ + queryKey: ["thread-snapshots", thread?.id], + queryFn: () => chatThreadsApiService.listPublicChatSnapshots({ thread_id: thread!.id }), + enabled: !!thread?.id, + staleTime: 30000, // Cache for 30 seconds + }); + const hasPublicSnapshots = (snapshotsData?.snapshots?.length ?? 0) > 0; + const snapshotCount = snapshotsData?.snapshots?.length ?? 0; + // Use Jotai visibility if available (synced from chat page), otherwise fall back to thread prop const currentVisibility = currentThreadState.visibility ?? thread?.visibility ?? "PRIVATE"; @@ -106,11 +120,13 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS try { await createSnapshot({ thread_id: thread.id }); + // Refetch snapshots to show the globe indicator + await queryClient.invalidateQueries({ queryKey: ["thread-snapshots", thread.id] }); setOpen(false); } catch (error) { console.error("Failed to create public link:", error); } - }, [thread, createSnapshot]); + }, [thread, createSnapshot, queryClient]); // Don't show if no thread (new chat that hasn't been created yet) if (!thread) { @@ -121,112 +137,131 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS const buttonLabel = currentVisibility === "PRIVATE" ? "Private" : "Shared"; return ( - - - - - - - - Share settings - - - e.preventDefault()} - > -
    - {/* Visibility Options */} - {visibilityOptions.map((option) => { - const isSelected = currentVisibility === option.value; - const Icon = option.icon; - - return ( - + + + Share settings + + + e.preventDefault()} + > +
    + {/* Visibility Options */} + {visibilityOptions.map((option) => { + const isSelected = currentVisibility === option.value; + const Icon = option.icon; + + return ( +
    -
    -
    - - {option.label} - + > +
    -

    - {option.description} -

    -
    - - ); - })} - - {canCreatePublicLink && ( - <> - {/* Divider */} -
    - - {/* Public Link Option */} - - - )} -
    -
    - + + ); + })} + + {canCreatePublicLink && ( + <> + {/* Divider */} +
    + + {/* Public Link Option */} + + + )} +
    + + + + {/* Globe indicator when public snapshots exist - clicks to settings */} + {hasPublicSnapshots && ( + + + + + + {snapshotCount === 1 + ? "This chat has a public link - Click to manage" + : `This chat has ${snapshotCount} public links - Click to manage`} + + + )} +
    ); } From 83aa8ef6892507193976c6246dee4f7fbcbff167 Mon Sep 17 00:00:00 2001 From: Eric Lammertsma Date: Wed, 4 Feb 2026 11:30:30 -0500 Subject: [PATCH 19/40] Added build version for cloud, local and Docker installs --- .../user/settings/components/UserSettingsSidebar.tsx | 6 ++++++ surfsense_web/lib/env-config.ts | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/surfsense_web/app/dashboard/user/settings/components/UserSettingsSidebar.tsx b/surfsense_web/app/dashboard/user/settings/components/UserSettingsSidebar.tsx index b7040b4e3..3424113a9 100644 --- a/surfsense_web/app/dashboard/user/settings/components/UserSettingsSidebar.tsx +++ b/surfsense_web/app/dashboard/user/settings/components/UserSettingsSidebar.tsx @@ -5,6 +5,7 @@ import { ArrowLeft, ChevronRight, X } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; +import { APP_VERSION } from "@/lib/env-config"; import { cn } from "@/lib/utils"; export interface SettingsNavItem { @@ -148,6 +149,11 @@ export function UserSettingsSidebar({ ); })} + + {/* Version display */} +
    +

    v{APP_VERSION}

    +
    ); diff --git a/surfsense_web/lib/env-config.ts b/surfsense_web/lib/env-config.ts index 2f9e92357..e36aff10a 100644 --- a/surfsense_web/lib/env-config.ts +++ b/surfsense_web/lib/env-config.ts @@ -9,6 +9,8 @@ * as it may prevent the sed replacement from working correctly. */ +import packageJson from "../package.json"; + // Auth type: "LOCAL" for email/password, "GOOGLE" for OAuth // Placeholder: __NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE__ export const AUTH_TYPE = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "GOOGLE"; @@ -28,6 +30,10 @@ export const ETL_SERVICE = process.env.NEXT_PUBLIC_ETL_SERVICE || "DOCLING"; // Placeholder: __NEXT_PUBLIC_DEPLOYMENT_MODE__ export const DEPLOYMENT_MODE = process.env.NEXT_PUBLIC_DEPLOYMENT_MODE || "self-hosted"; +// App version - defaults to package.json version +// Can be overridden at build time with NEXT_PUBLIC_APP_VERSION for full git tag version +export const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || packageJson.version; + // Helper to check if local auth is enabled export const isLocalAuth = () => AUTH_TYPE === "LOCAL"; From ab3d99d9e07ebd7aa47cccf929df4d8ee5ca849c Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 4 Feb 2026 18:46:12 +0200 Subject: [PATCH 20/40] Use RBAC for listing thread snapshots --- .../app/services/public_chat_service.py | 13 ++++++++----- .../components/new-chat/chat-share-button.tsx | 4 ++-- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/surfsense_backend/app/services/public_chat_service.py b/surfsense_backend/app/services/public_chat_service.py index 2125dd8ce..4da316240 100644 --- a/surfsense_backend/app/services/public_chat_service.py +++ b/surfsense_backend/app/services/public_chat_service.py @@ -366,11 +366,14 @@ async def list_snapshots_for_thread( if not thread: raise HTTPException(status_code=404, detail="Thread not found") - if thread.created_by_id != user.id: - raise HTTPException( - status_code=403, - detail="Only the creator can view snapshots", - ) + # Check permission to view public share links + await check_permission( + session, + user, + thread.search_space_id, + Permission.PUBLIC_SHARING_VIEW.value, + "You don't have permission to view public share links", + ) result = await session.execute( select(PublicChatSnapshot) diff --git a/surfsense_web/components/new-chat/chat-share-button.tsx b/surfsense_web/components/new-chat/chat-share-button.tsx index aae9f18c4..2e04fa3ba 100644 --- a/surfsense_web/components/new-chat/chat-share-button.tsx +++ b/surfsense_web/components/new-chat/chat-share-button.tsx @@ -257,8 +257,8 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS {snapshotCount === 1 - ? "This chat has a public link - Click to manage" - : `This chat has ${snapshotCount} public links - Click to manage`} + ? "This chat has a public link" + : `This chat has ${snapshotCount} public links`} )} From 1cf7205a81575f9cdc05c25cc26b653743418b2c Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 4 Feb 2026 18:54:59 +0200 Subject: [PATCH 21/40] Add clipboard utility with fallback and show selectable URLs --- .../public-chat-snapshot-row.tsx | 7 +++ surfsense_web/hooks/use-api-key.ts | 60 +++---------------- surfsense_web/lib/utils.ts | 41 +++++++++++++ 3 files changed, 57 insertions(+), 51 deletions(-) diff --git a/surfsense_web/components/public-chat-snapshots/public-chat-snapshot-row.tsx b/surfsense_web/components/public-chat-snapshots/public-chat-snapshot-row.tsx index 696d32466..5f0048100 100644 --- a/surfsense_web/components/public-chat-snapshots/public-chat-snapshot-row.tsx +++ b/surfsense_web/components/public-chat-snapshots/public-chat-snapshot-row.tsx @@ -38,6 +38,13 @@ export function PublicChatSnapshotRow({ {snapshot.message_count} + (e.target as HTMLInputElement).select()} + />
    + ); + } + + return ( + + + + + + + + {totalModels > 3 && ( +
    + +
    + )} + + +
    + +

    No image models found

    +
    +
    + + {/* Global Image Gen Configs */} + {filteredGlobal.length > 0 && ( + +
    + + Global Image Models +
    + {filteredGlobal.map((config) => { + const isSelected = currentConfig?.id === config.id; + const isAuto = "is_auto_mode" in config && config.is_auto_mode; + return ( + 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" + )} + > +
    +
    + {isAuto ? ( + + ) : ( + + )} +
    +
    +
    + {config.name} + {isAuto && ( + + Recommended + + )} + {isSelected && } +
    + + {isAuto ? "Auto load balancing" : config.model_name} + +
    +
    +
    + ); + })} +
    + )} + + {/* User Image Gen Configs */} + {filteredUser.length > 0 && ( + <> + {filteredGlobal.length > 0 && } + +
    + + Your Image Models +
    + {filteredUser.map((config) => { + const isSelected = currentConfig?.id === config.id; + return ( + handleSelect(config.id)} + className={cn( + "mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50", + isSelected && "bg-accent/80" + )} + > +
    +
    + +
    +
    +
    + {config.name} + {isSelected && ( + + )} +
    + + {config.model_name} + +
    +
    +
    + ); + })} +
    + + )} + + {/* Add New */} + {onAddNew && ( +
    + +
    + )} +
    +
    +
    +
    + ); +} diff --git a/surfsense_web/components/new-chat/model-selector.tsx b/surfsense_web/components/new-chat/model-selector.tsx index ec1143e04..148028df2 100644 --- a/surfsense_web/components/new-chat/model-selector.tsx +++ b/surfsense_web/components/new-chat/model-selector.tsx @@ -392,8 +392,8 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp )} - {/* Add New Config Button */} -
    + {/* Add New Config Button */} +
    +
    + + {/* Errors */} + + {errors.map((err) => ( + + + + {err?.message} + + + ))} + + + {/* Global info */} + {globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length > 0 && ( + + + + + {globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length} global image model(s) + {" "} + available from your administrator. + + + )} + + {/* Active Preference Card */} + {!isLoading && allConfigs.length > 0 && ( + + + +
    +
    + +
    +
    + Active Image Model + + Select which model to use for image generation + +
    +
    +
    + + + {hasPrefChanges && ( +
    + + +
    + )} +
    +
    +
    + )} + + {/* Loading */} + {isLoading && ( + + + + + + )} + + {/* User Configs */} + {!isLoading && ( +
    +
    +

    Your Image Models

    + +
    + + {(userConfigs?.length ?? 0) === 0 ? ( + + +
    + +
    +

    No Image Models Yet

    +

    + Add your own image generation model (DALL-E 3, GPT Image 1, etc.) +

    + +
    +
    + ) : ( + + + {userConfigs?.map((config) => ( + + + +
    +
    +
    +
    +
    +
    + +
    +
    +
    +

    {config.name}

    + + {config.provider} + +
    + + {config.model_name} + + {config.description && ( +

    {config.description}

    + )} +
    + + {new Date(config.created_at).toLocaleDateString()} +
    +
    +
    +
    + + + + + + Edit + + + + + + + + Delete + + +
    +
    +
    +
    + + + + ))} + + + )} +
    + )} + + {/* Create/Edit Dialog */} + { if (!open) { setIsDialogOpen(false); setEditingConfig(null); resetForm(); } }}> + + + + {editingConfig ? : } + {editingConfig ? "Edit Image Model" : "Add Image Model"} + + + {editingConfig ? "Update your image generation model" : "Configure a new image generation model (DALL-E 3, GPT Image 1, etc.)"} + + + +
    + {/* Name */} +
    + + setFormData((p) => ({ ...p, name: e.target.value }))} + /> +
    + + {/* Description */} +
    + + setFormData((p) => ({ ...p, description: e.target.value }))} + /> +
    + + + + {/* Provider */} +
    + + +
    + + {/* Model Name */} +
    + + {suggestedModels.length > 0 ? ( + + + + + + + setFormData((p) => ({ ...p, model_name: val }))} + /> + + + Type a custom model name + + + {suggestedModels.map((m) => ( + { + setFormData((p) => ({ ...p, model_name: m.value })); + setModelComboboxOpen(false); + }} + > + + {m.value} + {m.label} + + ))} + + + + + + ) : ( + setFormData((p) => ({ ...p, model_name: e.target.value }))} + /> + )} +
    + + {/* API Key */} +
    + + setFormData((p) => ({ ...p, api_key: e.target.value }))} + /> +
    + + {/* API Base (optional) */} +
    + + setFormData((p) => ({ ...p, api_base: e.target.value }))} + /> +
    + + {/* API Version (Azure) */} + {formData.provider === "AZURE_OPENAI" && ( +
    + + setFormData((p) => ({ ...p, api_version: e.target.value }))} + /> +
    + )} + + {/* Actions */} +
    + + +
    +
    +
    +
    + + {/* Delete Confirmation */} + !open && setConfigToDelete(null)}> + + + + + Delete Image Model + + + Are you sure you want to delete {configToDelete?.name}? + + + + Cancel + + {isDeleting ? <>Deleting : <>Delete} + + + + +
    + ); +} diff --git a/surfsense_web/components/settings/llm-role-manager.tsx b/surfsense_web/components/settings/llm-role-manager.tsx index dac68a358..22e3d8e08 100644 --- a/surfsense_web/components/settings/llm-role-manager.tsx +++ b/surfsense_web/components/settings/llm-role-manager.tsx @@ -255,15 +255,15 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) { )} - {/* Role Assignment Cards */} - {availableConfigs.length > 0 && ( -
    - {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 - ); + {/* Role Assignment Cards */} + {availableConfigs.length > 0 && ( +
    + {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 + ); return ( -
    - - handleRoleAssignment(`${key}_llm_id`, value)} + > + + + + + + Unassigned + - {/* Global Configurations */} - {globalConfigs.length > 0 && ( - <> -
    - Global Configurations -
    - {globalConfigs.map((config) => { - const isAutoMode = - "is_auto_mode" in config && config.is_auto_mode; - return ( - -
    - {isAutoMode ? ( - - - AUTO - - ) : ( - - {config.provider} - - )} - {config.name} - {!isAutoMode && ( - - ({config.model_name}) - - )} - {isAutoMode ? ( - - Recommended - - ) : ( - - 🌐 Global - - )} -
    -
    - ); - })} - - )} + {/* Global Configurations */} + {globalConfigs.length > 0 && ( + <> +
    + Global Configurations +
    + {globalConfigs.map((config) => { + const isAutoMode = + "is_auto_mode" in config && config.is_auto_mode; + return ( + +
    + {isAutoMode ? ( + + + AUTO + + ) : ( + + {config.provider} + + )} + {config.name} + {!isAutoMode && ( + + ({config.model_name}) + + )} + {isAutoMode ? ( + + Recommended + + ) : ( + + 🌐 Global + + )} +
    +
    + ); + })} + + )} - {/* Custom Configurations */} - {newLLMConfigs.length > 0 && ( - <> -
    - Your Configurations -
    - {newLLMConfigs - .filter( - (config) => config.id && config.id.toString().trim() !== "" - ) - .map((config) => ( - -
    - - {config.provider} - - {config.name} - - ({config.model_name}) - -
    -
    - ))} - - )} -
    - -
    + {/* Custom Configurations */} + {newLLMConfigs.length > 0 && ( + <> +
    + Your Configurations +
    + {newLLMConfigs + .filter( + (config) => config.id && config.id.toString().trim() !== "" + ) + .map((config) => ( + +
    + + {config.provider} + + {config.name} + + ({config.model_name}) + +
    +
    + ))} + + )} + + +
    {assignedConfig && (
    ; + return ; } /** diff --git a/surfsense_web/components/tool-ui/image/index.tsx b/surfsense_web/components/tool-ui/image/index.tsx index 42725d258..9ecfe4cfa 100644 --- a/surfsense_web/components/tool-ui/image/index.tsx +++ b/surfsense_web/components/tool-ui/image/index.tsx @@ -1,6 +1,6 @@ "use client"; -import { ExternalLinkIcon, ImageIcon } from "lucide-react"; +import { ExternalLinkIcon, ImageIcon, SparklesIcon } from "lucide-react"; import NextImage from "next/image"; import { Component, type ReactNode, useState } from "react"; import { z } from "zod"; @@ -25,7 +25,7 @@ const SerializableImageSchema = z.object({ id: z.string(), assetId: z.string(), src: z.string(), - alt: z.string().nullish(), // Made optional - will use fallback if missing + alt: z.string().nullish(), title: z.string().nullish(), description: z.string().nullish(), href: z.string().nullish(), @@ -49,7 +49,7 @@ export interface ImageProps { id: string; assetId: string; src: string; - alt?: string; // Optional with default fallback + alt?: string; title?: string; description?: string; href?: string; @@ -71,10 +71,8 @@ export function parseSerializableImage(result: unknown): SerializableImage & { a if (!parsed.success) { console.warn("Invalid image data:", parsed.error.issues); - // Try to extract basic info and return a fallback object const obj = (result && typeof result === "object" ? result : {}) as Record; - // If we have at least id, assetId, and src, we can still render the image if ( typeof obj.id === "string" && typeof obj.assetId === "string" && @@ -89,7 +87,7 @@ export function parseSerializableImage(result: unknown): SerializableImage & { a description: typeof obj.description === "string" ? obj.description : undefined, href: typeof obj.href === "string" ? obj.href : undefined, domain: typeof obj.domain === "string" ? obj.domain : undefined, - ratio: undefined, // Use default ratio + ratio: undefined, source: undefined, }; } @@ -97,7 +95,6 @@ export function parseSerializableImage(result: unknown): SerializableImage & { a throw new Error(`Invalid image: ${parsed.error.issues.map((i) => i.message).join(", ")}`); } - // Provide fallback for alt if it's null/undefined return { ...parsed.data, alt: parsed.data.alt ?? "Image", @@ -105,7 +102,7 @@ export function parseSerializableImage(result: unknown): SerializableImage & { a } /** - * Get aspect ratio class based on ratio prop + * Get aspect ratio class based on ratio prop (used for fixed-ratio images only) */ function getAspectRatioClass(ratio?: AspectRatio): string { switch (ratio) { @@ -119,7 +116,6 @@ function getAspectRatioClass(ratio?: AspectRatio): string { return "aspect-[9/16]"; case "21:9": return "aspect-[21/9]"; - case "auto": default: return "aspect-[4/3]"; } @@ -150,7 +146,7 @@ export class ImageErrorBoundary extends Component< if (this.state.hasError) { return ( -
    +

    Failed to load image

    @@ -167,10 +163,10 @@ export class ImageErrorBoundary extends Component< /** * Loading skeleton for Image */ -export function ImageSkeleton({ maxWidth = "420px" }: { maxWidth?: string }) { +export function ImageSkeleton({ maxWidth = "512px" }: { maxWidth?: string }) { return ( -
    +
    @@ -183,7 +179,7 @@ export function ImageSkeleton({ maxWidth = "420px" }: { maxWidth?: string }) { export function ImageLoading({ title = "Loading image..." }: { title?: string }) { return ( -
    +

    {title}

    @@ -197,7 +193,9 @@ export function ImageLoading({ title = "Loading image..." }: { title?: string }) * Image Component * * Display images with metadata and attribution. - * Features hover overlay with title and source attribution. + * - For "auto" ratio: renders the image at natural dimensions (no cropping) + * - For fixed ratios: uses a fixed aspect container with object-cover + * - Features hover overlay with title, description, and source attribution. */ export function Image({ id, @@ -207,16 +205,18 @@ export function Image({ description, href, domain, - ratio = "4:3", + ratio = "auto", fit = "cover", source, - maxWidth = "420px", + maxWidth = "512px", className, }: ImageProps) { const [isHovered, setIsHovered] = useState(false); const [imageError, setImageError] = useState(false); - const aspectRatioClass = getAspectRatioClass(ratio); + const [imageLoaded, setImageLoaded] = useState(false); const displayDomain = domain || source?.label; + const isGenerated = domain === "ai-generated"; + const isAutoRatio = !ratio || ratio === "auto"; const handleClick = () => { const targetUrl = href || source?.url || src; @@ -228,7 +228,7 @@ export function Image({ if (imageError) { return ( -
    +

    Image not available

    @@ -243,6 +243,7 @@ export function Image({ id={id} className={cn( "group w-full overflow-hidden cursor-pointer transition-shadow duration-200 hover:shadow-lg", + isGenerated && "ring-1 ring-primary/10", className )} style={{ maxWidth }} @@ -258,71 +259,98 @@ export function Image({ role="button" tabIndex={0} > -
    - {/* Image */} - setImageError(true)} - /> +
    + {isAutoRatio ? ( + /* Auto ratio: image renders at natural dimensions, no cropping */ + <> + {!imageLoaded && ( +
    + +
    + )} + {/* eslint-disable-next-line @next/next/no-img-element */} + {alt} setImageLoaded(true)} + onError={() => setImageError(true)} + /> + + ) : ( + /* Fixed ratio: constrained aspect container with fill */ +
    + setImageError(true)} + /> +
    + )} - {/* Hover overlay - appears on hover */} + {/* Hover overlay */}
    - {/* Content at bottom */} -
    - {/* Title */} +
    {title && ( -

    +

    {title}

    )} - - {/* Description */} {description && ( -

    {description}

    +

    {description}

    )} - - {/* Source attribution */} {displayDomain && (
    - {source?.iconUrl ? ( + {isGenerated ? ( + + ) : source?.iconUrl ? ( ) : ( - + )} - {displayDomain} + {displayDomain}
    )}
    - {/* Always visible domain badge (bottom right, shown when NOT hovered) */} + {/* Badge when not hovered */} {displayDomain && !isHovered && (
    + {isGenerated && } {displayDomain}
    diff --git a/surfsense_web/contracts/enums/image-gen-providers.ts b/surfsense_web/contracts/enums/image-gen-providers.ts new file mode 100644 index 000000000..8410aeb4b --- /dev/null +++ b/surfsense_web/contracts/enums/image-gen-providers.ts @@ -0,0 +1,105 @@ +export interface ImageGenProvider { + value: string; + label: string; + example: string; + description: string; + apiBase?: string; +} + +/** + * Image generation providers supported by LiteLLM. + * See: https://docs.litellm.ai/docs/image_generation#supported-providers + */ +export const IMAGE_GEN_PROVIDERS: ImageGenProvider[] = [ + { + value: "OPENAI", + label: "OpenAI", + example: "dall-e-3, gpt-image-1, dall-e-2", + description: "DALL-E and GPT Image models", + }, + { + value: "AZURE_OPENAI", + label: "Azure OpenAI", + example: "azure/dall-e-3, azure/gpt-image-1", + description: "OpenAI image models on Azure", + }, + { + value: "GOOGLE", + label: "Google AI Studio", + example: "gemini/imagen-3.0-generate-002", + description: "Google AI Studio image generation", + }, + { + value: "VERTEX_AI", + label: "Google Vertex AI", + example: "vertex_ai/imagegeneration@006", + description: "Vertex AI image generation models", + }, + { + value: "BEDROCK", + label: "AWS Bedrock", + example: "bedrock/stability.stable-diffusion-xl-v0", + description: "Stable Diffusion on AWS Bedrock", + }, + { + value: "RECRAFT", + label: "Recraft", + example: "recraft/recraftv3", + description: "AI-powered design and image generation", + }, + { + value: "OPENROUTER", + label: "OpenRouter", + example: "openrouter/google/gemini-2.5-flash-image", + description: "Image generation via OpenRouter", + }, + { + value: "XINFERENCE", + label: "Xinference", + example: "xinference/stable-diffusion-xl", + description: "Self-hosted Stable Diffusion models", + }, + { + value: "NSCALE", + label: "Nscale", + example: "nscale/flux.1-schnell", + description: "Nscale image generation", + }, +]; + +/** + * Image generation models organized by provider. + */ +export interface ImageGenModel { + value: string; + label: string; + provider: string; +} + +export const IMAGE_GEN_MODELS: ImageGenModel[] = [ + // OpenAI + { value: "gpt-image-1", label: "GPT Image 1", provider: "OPENAI" }, + { value: "dall-e-3", label: "DALL-E 3", provider: "OPENAI" }, + { value: "dall-e-2", label: "DALL-E 2", provider: "OPENAI" }, + // Azure OpenAI + { value: "azure/dall-e-3", label: "DALL-E 3 (Azure)", provider: "AZURE_OPENAI" }, + { value: "azure/gpt-image-1", label: "GPT Image 1 (Azure)", provider: "AZURE_OPENAI" }, + // Recraft + { value: "recraft/recraftv3", label: "Recraft V3", provider: "RECRAFT" }, + // Bedrock + { + value: "bedrock/stability.stable-diffusion-xl-v0", + label: "Stable Diffusion XL", + provider: "BEDROCK", + }, + // Vertex AI + { + value: "vertex_ai/imagegeneration@006", + label: "Imagen 3", + provider: "VERTEX_AI", + }, +]; + +export function getImageGenModelsByProvider(provider: string): ImageGenModel[] { + return IMAGE_GEN_MODELS.filter((m) => m.provider === provider); +} diff --git a/surfsense_web/contracts/types/new-llm-config.types.ts b/surfsense_web/contracts/types/new-llm-config.types.ts index f397d4f08..3f0d39e5a 100644 --- a/surfsense_web/contracts/types/new-llm-config.types.ts +++ b/surfsense_web/contracts/types/new-llm-config.types.ts @@ -161,19 +161,105 @@ export const globalNewLLMConfig = z.object({ export const getGlobalNewLLMConfigsResponse = z.array(globalNewLLMConfig); +// ============================================================================= +// Image Generation Config (separate table from NewLLMConfig) +// ============================================================================= + +/** + * ImageGenProvider enum - only providers that support image generation + * See: https://docs.litellm.ai/docs/image_generation#supported-providers + */ +export const imageGenProviderEnum = z.enum([ + "OPENAI", + "AZURE_OPENAI", + "GOOGLE", + "VERTEX_AI", + "BEDROCK", + "RECRAFT", + "OPENROUTER", + "XINFERENCE", + "NSCALE", +]); + +export type ImageGenProvider = z.infer; + +/** + * ImageGenerationConfig - user-created image gen model configs + * Separate from NewLLMConfig: no system_instructions, no citations_enabled. + */ +export const imageGenerationConfig = z.object({ + id: z.number(), + name: z.string().max(100), + description: z.string().max(500).nullable().optional(), + provider: imageGenProviderEnum, + custom_provider: z.string().max(100).nullable().optional(), + model_name: z.string().max(100), + api_key: z.string(), + api_base: z.string().max(500).nullable().optional(), + api_version: z.string().max(50).nullable().optional(), + litellm_params: z.record(z.string(), z.any()).nullable().optional(), + created_at: z.string(), + search_space_id: z.number(), +}); + +export const createImageGenConfigRequest = imageGenerationConfig.omit({ + id: true, + created_at: true, +}); + +export const createImageGenConfigResponse = imageGenerationConfig; + +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(), +}); + +export const updateImageGenConfigResponse = imageGenerationConfig; + +export const deleteImageGenConfigResponse = z.object({ + message: z.string(), + id: z.number(), +}); + +/** + * Global Image Generation Config - from YAML, has negative IDs + * ID 0 is reserved for "Auto" mode (LiteLLM Router load balancing) + */ +export const globalImageGenConfig = z.object({ + id: z.number(), + name: z.string(), + description: z.string().nullable().optional(), + provider: z.string(), + custom_provider: z.string().nullable().optional(), + model_name: z.string(), + api_base: z.string().nullable().optional(), + api_version: z.string().nullable().optional(), + litellm_params: z.record(z.string(), z.any()).nullable().optional(), + is_global: z.literal(true), + is_auto_mode: z.boolean().optional().default(false), +}); + +export const getGlobalImageGenConfigsResponse = z.array(globalImageGenConfig); + // ============================================================================= // LLM Preferences (Role Assignments) // ============================================================================= /** * LLM Preferences schemas - for role assignments - * The agent_llm and document_summary_llm fields contain the full NewLLMConfig objects + * image_generation uses image_generation_config_id (not llm_id) */ export const llmPreferences = z.object({ agent_llm_id: z.union([z.number(), z.null()]).optional(), document_summary_llm_id: z.union([z.number(), z.null()]).optional(), + image_generation_config_id: z.union([z.number(), z.null()]).optional(), agent_llm: z.union([z.record(z.string(), z.unknown()), z.null()]).optional(), document_summary_llm: z.union([z.record(z.string(), z.unknown()), z.null()]).optional(), + image_generation_config: z.union([z.record(z.string(), z.unknown()), z.null()]).optional(), }); /** @@ -193,6 +279,7 @@ export const updateLLMPreferencesRequest = z.object({ data: llmPreferences.pick({ agent_llm_id: true, document_summary_llm_id: true, + image_generation_config_id: true, }), }); @@ -219,6 +306,15 @@ export type GetDefaultSystemInstructionsResponse = z.infer< >; export type GlobalNewLLMConfig = z.infer; export type GetGlobalNewLLMConfigsResponse = z.infer; +export type ImageGenerationConfig = z.infer; +export type CreateImageGenConfigRequest = z.infer; +export type CreateImageGenConfigResponse = z.infer; +export type GetImageGenConfigsResponse = z.infer; +export type UpdateImageGenConfigRequest = z.infer; +export type UpdateImageGenConfigResponse = z.infer; +export type DeleteImageGenConfigResponse = z.infer; +export type GlobalImageGenConfig = z.infer; +export type GetGlobalImageGenConfigsResponse = z.infer; export type LLMPreferences = z.infer; export type GetLLMPreferencesRequest = z.infer; export type GetLLMPreferencesResponse = z.infer; diff --git a/surfsense_web/lib/apis/image-gen-config-api.service.ts b/surfsense_web/lib/apis/image-gen-config-api.service.ts new file mode 100644 index 000000000..84aeed3d8 --- /dev/null +++ b/surfsense_web/lib/apis/image-gen-config-api.service.ts @@ -0,0 +1,83 @@ +import { + type CreateImageGenConfigRequest, + createImageGenConfigRequest, + createImageGenConfigResponse, + type UpdateImageGenConfigRequest, + updateImageGenConfigRequest, + updateImageGenConfigResponse, + deleteImageGenConfigResponse, + getImageGenConfigsResponse, + getGlobalImageGenConfigsResponse, +} from "@/contracts/types/new-llm-config.types"; +import { ValidationError } from "../error"; +import { baseApiService } from "./base-api.service"; + +class ImageGenConfigApiService { + /** + * Get all global image generation configs (from YAML, negative IDs) + */ + getGlobalConfigs = async () => { + return baseApiService.get( + `/api/v1/global-image-generation-configs`, + getGlobalImageGenConfigsResponse + ); + }; + + /** + * Create a new image generation config for a search space + */ + createConfig = async (request: CreateImageGenConfigRequest) => { + const parsed = createImageGenConfigRequest.safeParse(request); + if (!parsed.success) { + const msg = parsed.error.issues.map((i) => i.message).join(", "); + throw new ValidationError(`Invalid request: ${msg}`); + } + return baseApiService.post( + `/api/v1/image-generation-configs`, + createImageGenConfigResponse, + { body: parsed.data } + ); + }; + + /** + * Get image generation configs for a search space + */ + getConfigs = async (searchSpaceId: number) => { + const params = new URLSearchParams({ + search_space_id: String(searchSpaceId), + }).toString(); + return baseApiService.get( + `/api/v1/image-generation-configs?${params}`, + getImageGenConfigsResponse + ); + }; + + /** + * Update an existing image generation config + */ + updateConfig = async (request: UpdateImageGenConfigRequest) => { + const parsed = updateImageGenConfigRequest.safeParse(request); + if (!parsed.success) { + const msg = parsed.error.issues.map((i) => i.message).join(", "); + throw new ValidationError(`Invalid request: ${msg}`); + } + const { id, data } = parsed.data; + return baseApiService.put( + `/api/v1/image-generation-configs/${id}`, + updateImageGenConfigResponse, + { body: data } + ); + }; + + /** + * Delete an image generation config + */ + deleteConfig = async (id: number) => { + return baseApiService.delete( + `/api/v1/image-generation-configs/${id}`, + deleteImageGenConfigResponse + ); + }; +} + +export const imageGenConfigApiService = new ImageGenConfigApiService(); diff --git a/surfsense_web/lib/query-client/cache-keys.ts b/surfsense_web/lib/query-client/cache-keys.ts index 4d220a62a..c6981b28a 100644 --- a/surfsense_web/lib/query-client/cache-keys.ts +++ b/surfsense_web/lib/query-client/cache-keys.ts @@ -34,6 +34,11 @@ export const cacheKeys = { defaultInstructions: () => ["new-llm-configs", "default-instructions"] as const, global: () => ["new-llm-configs", "global"] as const, }, + imageGenConfigs: { + all: (searchSpaceId: number) => ["image-gen-configs", searchSpaceId] as const, + byId: (configId: number) => ["image-gen-configs", "detail", configId] as const, + global: () => ["image-gen-configs", "global"] as const, + }, auth: { user: ["auth", "user"] as const, }, diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index a1ef1f248..5a18f80c3 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -740,6 +740,8 @@ "nav_agent_configs_desc": "LLM models with prompts & citations", "nav_role_assignments": "Role Assignments", "nav_role_assignments_desc": "Assign configs to agent roles", + "nav_image_models": "Image Models", + "nav_image_models_desc": "Configure image generation models", "nav_system_instructions": "System Instructions", "nav_system_instructions_desc": "SearchSpace-wide AI instructions", "nav_public_links": "Public Chat Links", diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json index 60a0d279f..1046b7296 100644 --- a/surfsense_web/messages/zh.json +++ b/surfsense_web/messages/zh.json @@ -725,6 +725,8 @@ "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": "公开聊天链接", From f85adefe5effc91680526cf0ce9ecb2e50a8385f Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Thu, 5 Feb 2026 17:18:27 -0800 Subject: [PATCH 36/40] chore: made generate_image more agnostic --- .../93_add_image_generations_table.py | 11 +- .../app/agents/new_chat/system_prompt.py | 10 +- .../agents/new_chat/tools/generate_image.py | 24 +-- .../app/connectors/airtable_history.py | 4 +- .../app/connectors/confluence_history.py | 4 +- .../app/connectors/jira_history.py | 4 +- .../app/connectors/linear_connector.py | 4 +- surfsense_backend/app/db.py | 9 +- surfsense_backend/app/routes/__init__.py | 2 +- .../app/routes/image_generation_routes.py | 176 ++++++++++++------ surfsense_backend/app/schemas/__init__.py | 6 +- .../app/schemas/image_generation.py | 1 - .../app/services/image_gen_router_service.py | 12 +- .../app/tasks/chat/stream_new_chat.py | 17 +- surfsense_backend/app/users.py | 4 +- 15 files changed, 176 insertions(+), 112 deletions(-) diff --git a/surfsense_backend/alembic/versions/93_add_image_generations_table.py b/surfsense_backend/alembic/versions/93_add_image_generations_table.py index 151208229..f24adc68f 100644 --- a/surfsense_backend/alembic/versions/93_add_image_generations_table.py +++ b/surfsense_backend/alembic/versions/93_add_image_generations_table.py @@ -60,8 +60,15 @@ def upgrade() -> None: sa.Column( "provider", sa.Enum( - "OPENAI", "AZURE_OPENAI", "GOOGLE", "VERTEX_AI", "BEDROCK", - "RECRAFT", "OPENROUTER", "XINFERENCE", "NSCALE", + "OPENAI", + "AZURE_OPENAI", + "GOOGLE", + "VERTEX_AI", + "BEDROCK", + "RECRAFT", + "OPENROUTER", + "XINFERENCE", + "NSCALE", name="imagegenprovider", create_type=False, ), diff --git a/surfsense_backend/app/agents/new_chat/system_prompt.py b/surfsense_backend/app/agents/new_chat/system_prompt.py index 38bae230d..01c762197 100644 --- a/surfsense_backend/app/agents/new_chat/system_prompt.py +++ b/surfsense_backend/app/agents/new_chat/system_prompt.py @@ -106,8 +106,6 @@ You have access to the following tools: - Trigger phrases: "generate an image of", "create a picture of", "draw me", "make an image", "design a logo", "create artwork" - Args: - prompt: A detailed text description of the image to generate. Be specific about subject, style, colors, composition, and mood. - - size: Image size. Options: "1024x1024" (square, default), "1536x1024" (landscape), "1024x1536" (portrait), "1792x1024" (wide) - - quality: Image quality. Options: "auto" (default), "high", "medium", "low" - n: Number of images to generate (1-4, default: 1) - Returns: A dictionary with the generated image URL in the "src" field, along with metadata. - CRITICAL: After calling generate_image, you MUST call `display_image` with the returned "src" URL @@ -300,19 +298,19 @@ You have access to the following tools: - Then provide your explanation, referencing the displayed image - User: "Generate an image of a cat" - - Step 1: `generate_image(prompt="A fluffy orange tabby cat sitting on a windowsill, bathed in warm golden sunlight, soft bokeh background with green houseplants, photorealistic style, cozy atmosphere", size="1024x1024", quality="auto")` + - Step 1: `generate_image(prompt="A fluffy orange tabby cat sitting on a windowsill, bathed in warm golden sunlight, soft bokeh background with green houseplants, photorealistic style, cozy atmosphere")` - Step 2: Use the returned "src" URL to display it: `display_image(src="", alt="A fluffy orange tabby cat on a windowsill", title="Generated Image")` - User: "Create a landscape painting of mountains" - - Step 1: `generate_image(prompt="Majestic snow-capped mountain range at sunset, dramatic orange and purple sky, alpine meadow with wildflowers in the foreground, oil painting style with visible brushstrokes, inspired by the Hudson River School art movement", size="1536x1024", quality="high")` + - Step 1: `generate_image(prompt="Majestic snow-capped mountain range at sunset, dramatic orange and purple sky, alpine meadow with wildflowers in the foreground, oil painting style with visible brushstrokes, inspired by the Hudson River School art movement")` - Step 2: `display_image(src="", alt="Mountain landscape painting", title="Generated Image")` - User: "Draw me a logo for a coffee shop called Bean Dream" - - Step 1: `generate_image(prompt="Minimalist modern logo design for a coffee shop called 'Bean Dream', featuring a stylized coffee bean with dream-like swirls of steam, clean vector style, warm brown and cream color palette, white background, professional branding", size="1024x1024", quality="high")` + - Step 1: `generate_image(prompt="Minimalist modern logo design for a coffee shop called 'Bean Dream', featuring a stylized coffee bean with dream-like swirls of steam, clean vector style, warm brown and cream color palette, white background, professional branding")` - Step 2: `display_image(src="", alt="Bean Dream coffee shop logo", title="Generated Image")` - User: "Make a wide banner image for my blog about AI" - - Step 1: `generate_image(prompt="Wide banner illustration for an AI technology blog, featuring abstract neural network patterns, glowing blue and purple connections, modern futuristic aesthetic, digital art style, clean and professional", size="1792x1024", quality="high")` + - Step 1: `generate_image(prompt="Wide banner illustration for an AI technology blog, featuring abstract neural network patterns, glowing blue and purple connections, modern futuristic aesthetic, digital art style, clean and professional")` - Step 2: `display_image(src="", alt="AI blog banner", title="Generated Image")` """ diff --git a/surfsense_backend/app/agents/new_chat/tools/generate_image.py b/surfsense_backend/app/agents/new_chat/tools/generate_image.py index 091fb122f..8ffa4ecde 100644 --- a/surfsense_backend/app/agents/new_chat/tools/generate_image.py +++ b/surfsense_backend/app/agents/new_chat/tools/generate_image.py @@ -21,12 +21,12 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import config from app.db import ImageGeneration, ImageGenerationConfig, SearchSpace -from app.utils.signed_image_urls import generate_image_token from app.services.image_gen_router_service import ( IMAGE_GEN_AUTO_MODE_ID, ImageGenRouterService, is_image_gen_auto_mode, ) +from app.utils.signed_image_urls import generate_image_token logger = logging.getLogger(__name__) @@ -76,8 +76,6 @@ def create_generate_image_tool( @tool async def generate_image( prompt: str, - size: str = "1024x1024", - quality: str = "auto", n: int = 1, ) -> dict[str, Any]: """ @@ -89,10 +87,6 @@ def create_generate_image_tool( Args: prompt: A detailed text description of the image to generate. Be specific about subject, style, colors, composition, and mood. - size: Image size. Options: "1024x1024" (square), "1536x1024" (landscape), - "1024x1536" (portrait), "1792x1024" (wide). Default: "1024x1024" - quality: Image quality. Options: "auto" (default), "high", "medium", "low". - Default: "auto" n: Number of images to generate (1-4). Default: 1 Returns: @@ -112,18 +106,14 @@ def create_generate_image_tool( ) # Build generation kwargs - # NOTE: 'style' is intentionally excluded from gen_kwargs because - # it is only supported by DALL-E 3 and causes errors with other - # models (e.g. gpt-image-1 rejects it as an unknown parameter). - # Since we can't predict which model auto-mode will route to, - # it's safest to omit it. + # NOTE: size, quality, and style are intentionally NOT passed. + # Different models support different values for these params + # (e.g. DALL-E 3 wants "hd"/"standard" for quality while + # gpt-image-1 wants "high"/"medium"/"low"; size options also + # differ). Letting the model use its own defaults avoids errors. gen_kwargs: dict[str, Any] = {} if n is not None and n > 1: gen_kwargs["n"] = n - if quality: - gen_kwargs["quality"] = quality - if size: - gen_kwargs["size"] = size # Call litellm based on config type if is_image_gen_auto_mode(config_id): @@ -199,8 +189,6 @@ def create_generate_image_tool( prompt=prompt, model=getattr(response, "_hidden_params", {}).get("model"), n=n, - quality=quality, - size=size, image_generation_config_id=config_id, response_data=response_dict, search_space_id=search_space_id, diff --git a/surfsense_backend/app/connectors/airtable_history.py b/surfsense_backend/app/connectors/airtable_history.py index 092485f77..49c2fcbdd 100644 --- a/surfsense_backend/app/connectors/airtable_history.py +++ b/surfsense_backend/app/connectors/airtable_history.py @@ -108,7 +108,9 @@ class AirtableHistoryConnector: # Final validation after decryption final_token = config_data.get("access_token") - if not final_token or (isinstance(final_token, str) and not final_token.strip()): + if not final_token or ( + isinstance(final_token, str) and not final_token.strip() + ): raise ValueError( "Airtable access token is invalid or empty. " "Please reconnect your Airtable account." diff --git a/surfsense_backend/app/connectors/confluence_history.py b/surfsense_backend/app/connectors/confluence_history.py index 908f532db..5d19edc54 100644 --- a/surfsense_backend/app/connectors/confluence_history.py +++ b/surfsense_backend/app/connectors/confluence_history.py @@ -128,7 +128,9 @@ class ConfluenceHistoryConnector: # Final validation after decryption final_token = config_data.get("access_token") - if not final_token or (isinstance(final_token, str) and not final_token.strip()): + if not final_token or ( + isinstance(final_token, str) and not final_token.strip() + ): raise ValueError( "Confluence access token is invalid or empty. " "Please reconnect your Confluence account." diff --git a/surfsense_backend/app/connectors/jira_history.py b/surfsense_backend/app/connectors/jira_history.py index 46a28324d..e9f28a2c4 100644 --- a/surfsense_backend/app/connectors/jira_history.py +++ b/surfsense_backend/app/connectors/jira_history.py @@ -129,7 +129,9 @@ class JiraHistoryConnector: # Final validation after decryption final_token = config_data.get("access_token") - if not final_token or (isinstance(final_token, str) and not final_token.strip()): + if not final_token or ( + isinstance(final_token, str) and not final_token.strip() + ): raise ValueError( "Jira access token is invalid or empty. " "Please reconnect your Jira account." diff --git a/surfsense_backend/app/connectors/linear_connector.py b/surfsense_backend/app/connectors/linear_connector.py index 6500b9027..534d70b89 100644 --- a/surfsense_backend/app/connectors/linear_connector.py +++ b/surfsense_backend/app/connectors/linear_connector.py @@ -153,7 +153,9 @@ class LinearConnector: # Final validation after decryption final_token = config_data.get("access_token") - if not final_token or (isinstance(final_token, str) and not final_token.strip()): + if not final_token or ( + isinstance(final_token, str) and not final_token.strip() + ): raise ValueError( "Linear access token is invalid or empty. " "Please reconnect your Linear account." diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index 320ff6d8d..a82c18470 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -100,7 +100,6 @@ class PodcastStatus(str, Enum): FAILED = "failed" - class LiteLLMProvider(str, Enum): """ Enum for LLM providers supported by LiteLLM. @@ -941,7 +940,9 @@ class ImageGenerationConfig(BaseModel, TimestampMixin): search_space_id = Column( Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False ) - search_space = relationship("SearchSpace", back_populates="image_generation_configs") + search_space = relationship( + "SearchSpace", back_populates="image_generation_configs" + ) class ImageGeneration(BaseModel, TimestampMixin): @@ -973,9 +974,7 @@ class ImageGeneration(BaseModel, TimestampMixin): String(50), nullable=True ) # "1024x1024", "1536x1024", "1024x1536", etc. style = Column(String(50), nullable=True) # Model-specific style parameter - response_format = Column( - String(50), nullable=True - ) # "url" or "b64_json" + response_format = Column(String(50), nullable=True) # "url" or "b64_json" # Image generation config reference # 0 = Auto mode (router), negative IDs = global configs from YAML, diff --git a/surfsense_backend/app/routes/__init__.py b/surfsense_backend/app/routes/__init__.py index 683f3548b..d9353284c 100644 --- a/surfsense_backend/app/routes/__init__.py +++ b/surfsense_backend/app/routes/__init__.py @@ -11,7 +11,6 @@ from .confluence_add_connector_route import router as confluence_add_connector_r from .discord_add_connector_route import router as discord_add_connector_router from .documents_routes import router as documents_router from .editor_routes import router as editor_router -from .image_generation_routes import router as image_generation_router from .google_calendar_add_connector_route import ( router as google_calendar_add_connector_router, ) @@ -21,6 +20,7 @@ from .google_drive_add_connector_route import ( from .google_gmail_add_connector_route import ( router as google_gmail_add_connector_router, ) +from .image_generation_routes import router as image_generation_router from .incentive_tasks_routes import router as incentive_tasks_router from .jira_add_connector_route import router as jira_add_connector_router from .linear_add_connector_route import router as linear_add_connector_router diff --git a/surfsense_backend/app/routes/image_generation_routes.py b/surfsense_backend/app/routes/image_generation_routes.py index 9b79771eb..9406867c6 100644 --- a/surfsense_backend/app/routes/image_generation_routes.py +++ b/surfsense_backend/app/routes/image_generation_routes.py @@ -54,9 +54,9 @@ logger = logging.getLogger(__name__) _PROVIDER_MAP = { "OPENAI": "openai", "AZURE_OPENAI": "azure", - "GOOGLE": "gemini", # Google AI Studio + "GOOGLE": "gemini", # Google AI Studio "VERTEX_AI": "vertex_ai", - "BEDROCK": "bedrock", # AWS Bedrock + "BEDROCK": "bedrock", # AWS Bedrock "RECRAFT": "recraft", "OPENROUTER": "openrouter", "XINFERENCE": "xinference", @@ -82,7 +82,9 @@ def _get_global_image_gen_config(config_id: int) -> dict | None: return None -def _build_model_string(provider: str, model_name: str, custom_provider: str | None) -> str: +def _build_model_string( + provider: str, model_name: str, custom_provider: str | None +) -> str: """Build a litellm model string from provider + model_name.""" if custom_provider: return f"{custom_provider}/{model_name}" @@ -210,38 +212,44 @@ async def get_global_image_gen_configs( safe_configs = [] if global_configs and len(global_configs) > 0: - safe_configs.append({ - "id": 0, - "name": "Auto (Load Balanced)", - "description": "Automatically routes across available image generation providers.", - "provider": "AUTO", - "custom_provider": None, - "model_name": "auto", - "api_base": None, - "api_version": None, - "litellm_params": {}, - "is_global": True, - "is_auto_mode": True, - }) + safe_configs.append( + { + "id": 0, + "name": "Auto (Load Balanced)", + "description": "Automatically routes across available image generation providers.", + "provider": "AUTO", + "custom_provider": None, + "model_name": "auto", + "api_base": None, + "api_version": None, + "litellm_params": {}, + "is_global": True, + "is_auto_mode": True, + } + ) for cfg in global_configs: - safe_configs.append({ - "id": cfg.get("id"), - "name": cfg.get("name"), - "description": cfg.get("description"), - "provider": cfg.get("provider"), - "custom_provider": cfg.get("custom_provider"), - "model_name": cfg.get("model_name"), - "api_base": cfg.get("api_base") or None, - "api_version": cfg.get("api_version") or None, - "litellm_params": cfg.get("litellm_params", {}), - "is_global": True, - }) + safe_configs.append( + { + "id": cfg.get("id"), + "name": cfg.get("name"), + "description": cfg.get("description"), + "provider": cfg.get("provider"), + "custom_provider": cfg.get("custom_provider"), + "model_name": cfg.get("model_name"), + "api_base": cfg.get("api_base") or None, + "api_version": cfg.get("api_version") or None, + "litellm_params": cfg.get("litellm_params", {}), + "is_global": True, + } + ) return safe_configs except Exception as e: logger.exception("Failed to fetch global image generation configs") - raise HTTPException(status_code=500, detail=f"Failed to fetch configs: {e!s}") from e + raise HTTPException( + status_code=500, detail=f"Failed to fetch configs: {e!s}" + ) from e # ============================================================================= @@ -258,7 +266,9 @@ async def create_image_gen_config( """Create a new image generation config for a search space.""" try: await check_permission( - session, user, config_data.search_space_id, + session, + user, + config_data.search_space_id, Permission.IMAGE_GENERATIONS_CREATE.value, "You don't have permission to create image generation configs in this search space", ) @@ -274,7 +284,9 @@ async def create_image_gen_config( except Exception as e: await session.rollback() logger.exception("Failed to create ImageGenerationConfig") - raise HTTPException(status_code=500, detail=f"Failed to create config: {e!s}") from e + raise HTTPException( + status_code=500, detail=f"Failed to create config: {e!s}" + ) from e @router.get("/image-generation-configs", response_model=list[ImageGenerationConfigRead]) @@ -288,7 +300,9 @@ async def list_image_gen_configs( """List image generation configs for a search space.""" try: await check_permission( - session, user, search_space_id, + session, + user, + search_space_id, Permission.IMAGE_GENERATIONS_READ.value, "You don't have permission to view image generation configs in this search space", ) @@ -306,10 +320,14 @@ async def list_image_gen_configs( raise except Exception as e: logger.exception("Failed to list ImageGenerationConfigs") - raise HTTPException(status_code=500, detail=f"Failed to fetch configs: {e!s}") from e + raise HTTPException( + status_code=500, detail=f"Failed to fetch configs: {e!s}" + ) from e -@router.get("/image-generation-configs/{config_id}", response_model=ImageGenerationConfigRead) +@router.get( + "/image-generation-configs/{config_id}", response_model=ImageGenerationConfigRead +) async def get_image_gen_config( config_id: int, session: AsyncSession = Depends(get_async_session), @@ -325,7 +343,9 @@ async def get_image_gen_config( raise HTTPException(status_code=404, detail="Config not found") await check_permission( - session, user, db_config.search_space_id, + session, + user, + db_config.search_space_id, Permission.IMAGE_GENERATIONS_READ.value, "You don't have permission to view image generation configs in this search space", ) @@ -335,10 +355,14 @@ async def get_image_gen_config( raise except Exception as e: logger.exception("Failed to get ImageGenerationConfig") - raise HTTPException(status_code=500, detail=f"Failed to fetch config: {e!s}") from e + raise HTTPException( + status_code=500, detail=f"Failed to fetch config: {e!s}" + ) from e -@router.put("/image-generation-configs/{config_id}", response_model=ImageGenerationConfigRead) +@router.put( + "/image-generation-configs/{config_id}", response_model=ImageGenerationConfigRead +) async def update_image_gen_config( config_id: int, update_data: ImageGenerationConfigUpdate, @@ -355,7 +379,9 @@ async def update_image_gen_config( raise HTTPException(status_code=404, detail="Config not found") await check_permission( - session, user, db_config.search_space_id, + session, + user, + db_config.search_space_id, Permission.IMAGE_GENERATIONS_CREATE.value, "You don't have permission to update image generation configs in this search space", ) @@ -372,7 +398,9 @@ async def update_image_gen_config( except Exception as e: await session.rollback() logger.exception("Failed to update ImageGenerationConfig") - raise HTTPException(status_code=500, detail=f"Failed to update config: {e!s}") from e + raise HTTPException( + status_code=500, detail=f"Failed to update config: {e!s}" + ) from e @router.delete("/image-generation-configs/{config_id}", response_model=dict) @@ -391,21 +419,28 @@ async def delete_image_gen_config( raise HTTPException(status_code=404, detail="Config not found") await check_permission( - session, user, db_config.search_space_id, + session, + user, + db_config.search_space_id, Permission.IMAGE_GENERATIONS_DELETE.value, "You don't have permission to delete image generation configs in this search space", ) await session.delete(db_config) await session.commit() - return {"message": "Image generation config deleted successfully", "id": config_id} + return { + "message": "Image generation config deleted successfully", + "id": config_id, + } except HTTPException: raise except Exception as e: await session.rollback() logger.exception("Failed to delete ImageGenerationConfig") - raise HTTPException(status_code=500, detail=f"Failed to delete config: {e!s}") from e + raise HTTPException( + status_code=500, detail=f"Failed to delete config: {e!s}" + ) from e # ============================================================================= @@ -422,7 +457,9 @@ async def create_image_generation( """Create and execute an image generation request.""" try: await check_permission( - session, user, data.search_space_id, + session, + user, + data.search_space_id, Permission.IMAGE_GENERATIONS_CREATE.value, "You don't have permission to create image generations in this search space", ) @@ -463,11 +500,15 @@ async def create_image_generation( raise except SQLAlchemyError: await session.rollback() - raise HTTPException(status_code=500, detail="Database error during image generation") from None + raise HTTPException( + status_code=500, detail="Database error during image generation" + ) from None except Exception as e: await session.rollback() logger.exception("Failed to create image generation") - raise HTTPException(status_code=500, detail=f"Image generation failed: {e!s}") from e + raise HTTPException( + status_code=500, detail=f"Image generation failed: {e!s}" + ) from e @router.get("/image-generations", response_model=list[ImageGenerationListRead]) @@ -487,7 +528,9 @@ async def list_image_generations( try: if search_space_id is not None: await check_permission( - session, user, search_space_id, + session, + user, + search_space_id, Permission.IMAGE_GENERATIONS_READ.value, "You don't have permission to read image generations in this search space", ) @@ -495,7 +538,8 @@ async def list_image_generations( select(ImageGeneration) .filter(ImageGeneration.search_space_id == search_space_id) .order_by(ImageGeneration.created_at.desc()) - .offset(skip).limit(limit) + .offset(skip) + .limit(limit) ) else: result = await session.execute( @@ -504,15 +548,21 @@ async def list_image_generations( .join(SearchSpaceMembership) .filter(SearchSpaceMembership.user_id == user.id) .order_by(ImageGeneration.created_at.desc()) - .offset(skip).limit(limit) + .offset(skip) + .limit(limit) ) - return [ImageGenerationListRead.from_orm_with_count(img) for img in result.scalars().all()] + return [ + ImageGenerationListRead.from_orm_with_count(img) + for img in result.scalars().all() + ] except HTTPException: raise except SQLAlchemyError: - raise HTTPException(status_code=500, detail="Database error fetching image generations") from None + raise HTTPException( + status_code=500, detail="Database error fetching image generations" + ) from None @router.get("/image-generations/{image_gen_id}", response_model=ImageGenerationRead) @@ -531,7 +581,9 @@ async def get_image_generation( raise HTTPException(status_code=404, detail="Image generation not found") await check_permission( - session, user, image_gen.search_space_id, + session, + user, + image_gen.search_space_id, Permission.IMAGE_GENERATIONS_READ.value, "You don't have permission to read image generations in this search space", ) @@ -540,7 +592,9 @@ async def get_image_generation( except HTTPException: raise except SQLAlchemyError: - raise HTTPException(status_code=500, detail="Database error fetching image generation") from None + raise HTTPException( + status_code=500, detail="Database error fetching image generation" + ) from None @router.delete("/image-generations/{image_gen_id}", response_model=dict) @@ -559,7 +613,9 @@ async def delete_image_generation( raise HTTPException(status_code=404, detail="Image generation not found") await check_permission( - session, user, db_image_gen.search_space_id, + session, + user, + db_image_gen.search_space_id, Permission.IMAGE_GENERATIONS_DELETE.value, "You don't have permission to delete image generations in this search space", ) @@ -572,13 +628,16 @@ async def delete_image_generation( raise except SQLAlchemyError: await session.rollback() - raise HTTPException(status_code=500, detail="Database error deleting image generation") from None + raise HTTPException( + status_code=500, detail="Database error deleting image generation" + ) from None # ============================================================================= # Image Serving (serves generated images from DB, protected by signed tokens) # ============================================================================= + @router.get("/image-generations/{image_gen_id}/image") async def serve_generated_image( image_gen_id: int, @@ -616,13 +675,16 @@ async def serve_generated_image( images = image_gen.response_data.get("data", []) if not images or index >= len(images): - raise HTTPException(status_code=404, detail="Image not found at the specified index") + raise HTTPException( + status_code=404, detail="Image not found at the specified index" + ) image_entry = images[index] # If there's a URL, redirect to it if image_entry.get("url"): from fastapi.responses import RedirectResponse + return RedirectResponse(url=image_entry["url"]) # If there's b64_json data, decode and serve it @@ -643,4 +705,6 @@ async def serve_generated_image( raise except Exception as e: logger.exception("Failed to serve generated image") - raise HTTPException(status_code=500, detail=f"Failed to serve image: {e!s}") from e + raise HTTPException( + status_code=500, detail=f"Failed to serve image: {e!s}" + ) from e diff --git a/surfsense_backend/app/schemas/__init__.py b/surfsense_backend/app/schemas/__init__.py index 332af55fd..ad5abf777 100644 --- a/surfsense_backend/app/schemas/__init__.py +++ b/surfsense_backend/app/schemas/__init__.py @@ -113,10 +113,12 @@ __all__ = [ "DriveItem", "ExtensionDocumentContent", "ExtensionDocumentMetadata", + "GlobalImageGenConfigRead", "GlobalNewLLMConfigRead", "GoogleDriveIndexRequest", "GoogleDriveIndexingOptions", - "GlobalImageGenConfigRead", + # Base schemas + "IDModel", # Image Generation Config schemas "ImageGenerationConfigCreate", "ImageGenerationConfigPublic", @@ -126,8 +128,6 @@ __all__ = [ "ImageGenerationCreate", "ImageGenerationListRead", "ImageGenerationRead", - # Base schemas - "IDModel", # RBAC schemas "InviteAcceptRequest", "InviteAcceptResponse", diff --git a/surfsense_backend/app/schemas/image_generation.py b/surfsense_backend/app/schemas/image_generation.py index 367a35a77..6ef4feff8 100644 --- a/surfsense_backend/app/schemas/image_generation.py +++ b/surfsense_backend/app/schemas/image_generation.py @@ -13,7 +13,6 @@ from pydantic import BaseModel, ConfigDict, Field from app.db import ImageGenProvider - # ============================================================================= # ImageGenerationConfig CRUD Schemas # ============================================================================= diff --git a/surfsense_backend/app/services/image_gen_router_service.py b/surfsense_backend/app/services/image_gen_router_service.py index 3b8a15d2a..eb6936efd 100644 --- a/surfsense_backend/app/services/image_gen_router_service.py +++ b/surfsense_backend/app/services/image_gen_router_service.py @@ -31,9 +31,9 @@ IMAGE_GEN_AUTO_MODE_ID = 0 IMAGE_GEN_PROVIDER_MAP = { "OPENAI": "openai", "AZURE_OPENAI": "azure", - "GOOGLE": "gemini", # Google AI Studio + "GOOGLE": "gemini", # Google AI Studio "VERTEX_AI": "vertex_ai", - "BEDROCK": "bedrock", # AWS Bedrock + "BEDROCK": "bedrock", # AWS Bedrock "RECRAFT": "recraft", "OPENROUTER": "openrouter", "XINFERENCE": "xinference", @@ -156,9 +156,7 @@ class ImageGenRouterService: model_string = f"{config['custom_provider']}/{config['model_name']}" else: provider = config.get("provider", "").upper() - provider_prefix = IMAGE_GEN_PROVIDER_MAP.get( - provider, provider.lower() - ) + provider_prefix = IMAGE_GEN_PROVIDER_MAP.get(provider, provider.lower()) model_string = f"{provider_prefix}/{config['model_name']}" # Build litellm params @@ -194,9 +192,7 @@ class ImageGenRouterService: return deployment except Exception as e: - logger.warning( - f"Failed to convert image gen config to deployment: {e}" - ) + logger.warning(f"Failed to convert image gen config to deployment: {e}") return None @classmethod diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index a9751e5d1..685f77e39 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -27,12 +27,12 @@ from app.agents.new_chat.llm_config import ( load_llm_config_from_yaml, ) from app.db import Document, SurfsenseDocsDocument +from app.prompts import TITLE_GENERATION_PROMPT_TEMPLATE from app.schemas.new_chat import ChatAttachment from app.services.chat_session_state_service import ( clear_ai_responding, set_ai_responding, ) -from app.prompts import TITLE_GENERATION_PROMPT_TEMPLATE from app.services.connector_service import ConnectorService from app.services.new_streaming_service import VercelStreamingService from app.utils.content_utils import bootstrap_history_from_db @@ -1211,9 +1211,10 @@ async def stream_new_chat( # Generate LLM title for new chats after first response # Check if this is the first assistant response by counting existing assistant messages - from app.db import NewChatMessage, NewChatThread from sqlalchemy import func + from app.db import NewChatMessage, NewChatThread + assistant_count_result = await session.execute( select(func.count(NewChatMessage.id)).filter( NewChatMessage.thread_id == chat_id, @@ -1231,10 +1232,12 @@ async def stream_new_chat( # Truncate inputs to avoid context length issues truncated_query = user_query[:500] truncated_response = accumulated_text[:1000] - title_result = await title_chain.ainvoke({ - "user_query": truncated_query, - "assistant_response": truncated_response, - }) + title_result = await title_chain.ainvoke( + { + "user_query": truncated_query, + "assistant_response": truncated_response, + } + ) # Extract and clean the title if title_result and hasattr(title_result, "content"): @@ -1242,7 +1245,7 @@ async def stream_new_chat( # Validate the title (reasonable length) if raw_title and len(raw_title) <= 100: # Remove any quotes or extra formatting - generated_title = raw_title.strip('"\'') + generated_title = raw_title.strip("\"'") except Exception: generated_title = None diff --git a/surfsense_backend/app/users.py b/surfsense_backend/app/users.py index 696cdf25e..ee07ba88f 100644 --- a/surfsense_backend/app/users.py +++ b/surfsense_backend/app/users.py @@ -219,7 +219,9 @@ class CustomBearerTransport(BearerTransport): # Decode JWT to get user_id for refresh token creation try: - payload = jwt.decode(token, SECRET, algorithms=["HS256"], options={"verify_aud": False}) + payload = jwt.decode( + token, SECRET, algorithms=["HS256"], options={"verify_aud": False} + ) user_id = uuid.UUID(payload.get("sub")) refresh_token = await create_refresh_token(user_id) except Exception as e: From af16b6656c83b162caa2ddcfafe43a41db7f8dbc Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Thu, 5 Feb 2026 17:32:43 -0800 Subject: [PATCH 37/40] feat: implement connection pooling for AsyncPostgresSaver in checkpointer --- .../app/agents/new_chat/checkpointer.py | 84 +++++++++++++++---- 1 file changed, 67 insertions(+), 17 deletions(-) diff --git a/surfsense_backend/app/agents/new_chat/checkpointer.py b/surfsense_backend/app/agents/new_chat/checkpointer.py index 637b2926f..04ecfbdea 100644 --- a/surfsense_backend/app/agents/new_chat/checkpointer.py +++ b/surfsense_backend/app/agents/new_chat/checkpointer.py @@ -3,15 +3,25 @@ PostgreSQL-based checkpointer for LangGraph agents. This module provides a persistent checkpointer using AsyncPostgresSaver that stores conversation state in the PostgreSQL database. + +Uses a connection pool (psycopg_pool.AsyncConnectionPool) to handle +connection lifecycle, health checks, and automatic reconnection, +preventing 'the connection is closed' errors in long-running deployments. """ +import logging + from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver +from psycopg.rows import dict_row +from psycopg_pool import AsyncConnectionPool from app.config import config +logger = logging.getLogger(__name__) + # Global checkpointer instance (initialized lazily) _checkpointer: AsyncPostgresSaver | None = None -_checkpointer_context = None # Store the context manager for cleanup +_connection_pool: AsyncConnectionPool | None = None _checkpointer_initialized: bool = False @@ -38,26 +48,65 @@ def get_postgres_connection_string() -> str: return db_url +async def _create_checkpointer() -> AsyncPostgresSaver: + """ + Create a new AsyncPostgresSaver backed by a connection pool. + + The connection pool automatically handles: + - Connection health checks before use + - Reconnection when connections die (idle timeout, DB restart, etc.) + - Connection lifecycle management (max_lifetime, max_idle) + """ + global _connection_pool + + conn_string = get_postgres_connection_string() + + _connection_pool = AsyncConnectionPool( + conninfo=conn_string, + min_size=2, + max_size=10, + # Connections are recycled after 30 minutes to avoid stale connections + max_lifetime=1800, + # Idle connections are closed after 5 minutes + max_idle=300, + open=False, + # Connection kwargs required by AsyncPostgresSaver: + # - autocommit: required for .setup() to commit checkpoint tables + # - prepare_threshold: disable prepared statements for compatibility + # - row_factory: checkpointer accesses rows as dicts (row["column"]) + kwargs={ + "autocommit": True, + "prepare_threshold": 0, + "row_factory": dict_row, + }, + ) + await _connection_pool.open(wait=True) + + checkpointer = AsyncPostgresSaver(conn=_connection_pool) + logger.info("[Checkpointer] Created AsyncPostgresSaver with connection pool") + return checkpointer + + async def get_checkpointer() -> AsyncPostgresSaver: """ Get or create the global AsyncPostgresSaver instance. This function: - 1. Creates the checkpointer if it doesn't exist + 1. Creates the checkpointer with a connection pool if it doesn't exist 2. Sets up the required database tables on first call 3. Returns the cached instance on subsequent calls + The underlying connection pool handles reconnection automatically, + so a stale/closed connection will not cause OperationalError. + Returns: AsyncPostgresSaver: The configured checkpointer instance """ - global _checkpointer, _checkpointer_context, _checkpointer_initialized + global _checkpointer, _checkpointer_initialized if _checkpointer is None: - conn_string = get_postgres_connection_string() - # from_conn_string returns an async context manager - # We need to enter the context to get the actual checkpointer - _checkpointer_context = AsyncPostgresSaver.from_conn_string(conn_string) - _checkpointer = await _checkpointer_context.__aenter__() + _checkpointer = await _create_checkpointer() + _checkpointer_initialized = False # Setup tables on first call (idempotent) if not _checkpointer_initialized: @@ -75,20 +124,21 @@ async def setup_checkpointer_tables() -> None: tables exist before any agent calls. """ await get_checkpointer() - print("[Checkpointer] PostgreSQL checkpoint tables ready") + logger.info("[Checkpointer] PostgreSQL checkpoint tables ready") async def close_checkpointer() -> None: """ - Close the checkpointer connection. + Close the checkpointer connection pool. This should be called during application shutdown. """ - global _checkpointer, _checkpointer_context, _checkpointer_initialized + global _checkpointer, _connection_pool, _checkpointer_initialized - if _checkpointer_context is not None: - await _checkpointer_context.__aexit__(None, None, None) - _checkpointer = None - _checkpointer_context = None - _checkpointer_initialized = False - print("[Checkpointer] PostgreSQL connection closed") + if _connection_pool is not None: + await _connection_pool.close() + logger.info("[Checkpointer] PostgreSQL connection pool closed") + + _checkpointer = None + _connection_pool = None + _checkpointer_initialized = False From 91fe7222b1e5d1020c2add815066a48e0c5c5026 Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Thu, 5 Feb 2026 17:41:28 -0800 Subject: [PATCH 38/40] feat: enhance chat header with image generation configuration support and sidebar integration --- .../components/new-chat/chat-header.tsx | 53 +- .../new-chat/image-config-sidebar.tsx | 522 ++++++++++++++++++ .../new-chat/image-model-selector.tsx | 175 +++--- 3 files changed, 668 insertions(+), 82 deletions(-) create mode 100644 surfsense_web/components/new-chat/image-config-sidebar.tsx diff --git a/surfsense_web/components/new-chat/chat-header.tsx b/surfsense_web/components/new-chat/chat-header.tsx index e19f97945..8a8fa11a0 100644 --- a/surfsense_web/components/new-chat/chat-header.tsx +++ b/surfsense_web/components/new-chat/chat-header.tsx @@ -1,11 +1,13 @@ "use client"; import { useCallback, useState } from "react"; -import { useParams, useRouter } from "next/navigation"; import type { + GlobalImageGenConfig, GlobalNewLLMConfig, + ImageGenerationConfig, 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"; @@ -15,7 +17,7 @@ interface ChatHeaderProps { } export function ChatHeader({ searchSpaceId }: ChatHeaderProps) { - const router = useRouter(); + // LLM config sidebar state const [sidebarOpen, setSidebarOpen] = useState(false); const [selectedConfig, setSelectedConfig] = useState< NewLLMConfigPublic | GlobalNewLLMConfig | null @@ -23,6 +25,15 @@ export function ChatHeader({ searchSpaceId }: ChatHeaderProps) { const [isGlobal, setIsGlobal] = useState(false); const [sidebarMode, setSidebarMode] = useState<"create" | "edit" | "view">("view"); + // Image config sidebar state + const [imageSidebarOpen, setImageSidebarOpen] = useState(false); + const [selectedImageConfig, setSelectedImageConfig] = useState< + ImageGenerationConfig | GlobalImageGenConfig | null + >(null); + const [isImageGlobal, setIsImageGlobal] = useState(false); + const [imageSidebarMode, setImageSidebarMode] = useState<"create" | "edit" | "view">("view"); + + // LLM handlers const handleEditConfig = useCallback( (config: NewLLMConfigPublic | GlobalNewLLMConfig, global: boolean) => { setSelectedConfig(config); @@ -42,20 +53,36 @@ export function ChatHeader({ searchSpaceId }: ChatHeaderProps) { const handleSidebarClose = useCallback((open: boolean) => { setSidebarOpen(open); - if (!open) { - setSelectedConfig(null); - } + if (!open) setSelectedConfig(null); }, []); + // Image model handlers const handleAddImageModel = useCallback(() => { - // Navigate to settings image-models tab - router.push(`/dashboard/${searchSpaceId}/settings?tab=image-models`); - }, [router, searchSpaceId]); + setSelectedImageConfig(null); + setIsImageGlobal(false); + setImageSidebarMode("create"); + setImageSidebarOpen(true); + }, []); + + const handleEditImageConfig = useCallback( + (config: ImageGenerationConfig | GlobalImageGenConfig, global: boolean) => { + setSelectedImageConfig(config); + setIsImageGlobal(global); + setImageSidebarMode(global ? "view" : "edit"); + setImageSidebarOpen(true); + }, + [] + ); + + const handleImageSidebarClose = useCallback((open: boolean) => { + setImageSidebarOpen(open); + if (!open) setSelectedImageConfig(null); + }, []); return (
    - + +
    ); } diff --git a/surfsense_web/components/new-chat/image-config-sidebar.tsx b/surfsense_web/components/new-chat/image-config-sidebar.tsx new file mode 100644 index 000000000..18f98acb7 --- /dev/null +++ b/surfsense_web/components/new-chat/image-config-sidebar.tsx @@ -0,0 +1,522 @@ +"use client"; + +import { useAtomValue } from "jotai"; +import { + AlertCircle, + Check, + ChevronsUpDown, + Globe, + ImageIcon, + Key, + Shuffle, + X, + Zap, +} from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; +import { toast } from "sonner"; +import { + createImageGenConfigMutationAtom, + updateImageGenConfigMutationAtom, +} from "@/atoms/image-gen-config/image-gen-config-mutation.atoms"; +import { updateLLMPreferencesMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; +import { Spinner } from "@/components/ui/spinner"; +import { IMAGE_GEN_MODELS, IMAGE_GEN_PROVIDERS } from "@/contracts/enums/image-gen-providers"; +import type { + GlobalImageGenConfig, + ImageGenerationConfig, +} from "@/contracts/types/new-llm-config.types"; +import { cn } from "@/lib/utils"; + +interface ImageConfigSidebarProps { + open: boolean; + onOpenChange: (open: boolean) => void; + config: ImageGenerationConfig | GlobalImageGenConfig | null; + isGlobal: boolean; + searchSpaceId: number; + mode: "create" | "edit" | "view"; +} + +const INITIAL_FORM = { + name: "", + description: "", + provider: "", + model_name: "", + api_key: "", + api_base: "", + api_version: "", +}; + +export function ImageConfigSidebar({ + open, + onOpenChange, + config, + isGlobal, + searchSpaceId, + mode, +}: ImageConfigSidebarProps) { + const [isSubmitting, setIsSubmitting] = useState(false); + const [mounted, setMounted] = useState(false); + const [formData, setFormData] = useState(INITIAL_FORM); + const [modelComboboxOpen, setModelComboboxOpen] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + // Reset form when opening + useEffect(() => { + if (open) { + if (mode === "edit" && config && !isGlobal) { + setFormData({ + name: config.name || "", + description: config.description || "", + provider: config.provider || "", + model_name: config.model_name || "", + api_key: (config as ImageGenerationConfig).api_key || "", + api_base: config.api_base || "", + api_version: config.api_version || "", + }); + } else if (mode === "create") { + setFormData(INITIAL_FORM); + } + } + }, [open, mode, config, isGlobal]); + + // Mutations + const { mutateAsync: createConfig } = useAtomValue(createImageGenConfigMutationAtom); + const { mutateAsync: updateConfig } = useAtomValue(updateImageGenConfigMutationAtom); + const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom); + + // Escape key + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === "Escape" && open) onOpenChange(false); + }; + window.addEventListener("keydown", handleEscape); + return () => window.removeEventListener("keydown", handleEscape); + }, [open, onOpenChange]); + + const isAutoMode = config && "is_auto_mode" in config && config.is_auto_mode; + + const suggestedModels = useMemo(() => { + if (!formData.provider) return []; + return IMAGE_GEN_MODELS.filter((m) => m.provider === formData.provider); + }, [formData.provider]); + + const getTitle = () => { + if (mode === "create") return "Add Image Model"; + if (isAutoMode) return "Auto Mode (Load Balanced)"; + if (isGlobal) return "View Global Image Model"; + return "Edit Image Model"; + }; + + const handleSubmit = useCallback(async () => { + setIsSubmitting(true); + try { + if (mode === "create") { + const result = await createConfig({ + name: formData.name, + provider: formData.provider, + model_name: formData.model_name, + api_key: formData.api_key, + api_base: formData.api_base || undefined, + api_version: formData.api_version || undefined, + description: formData.description || undefined, + search_space_id: searchSpaceId, + }); + // Set as active image model + if (result?.id) { + await updatePreferences({ + search_space_id: searchSpaceId, + data: { image_generation_config_id: result.id }, + }); + } + toast.success("Image model created and assigned!"); + onOpenChange(false); + } else if (!isGlobal && config) { + await updateConfig({ + id: config.id, + data: { + name: formData.name, + description: formData.description || undefined, + provider: formData.provider, + model_name: formData.model_name, + api_key: formData.api_key, + api_base: formData.api_base || undefined, + api_version: formData.api_version || undefined, + }, + }); + toast.success("Image model updated!"); + onOpenChange(false); + } + } catch (error) { + console.error("Failed to save image config:", error); + toast.error("Failed to save image model"); + } finally { + setIsSubmitting(false); + } + }, [mode, isGlobal, config, formData, searchSpaceId, createConfig, updateConfig, updatePreferences, onOpenChange]); + + const handleUseGlobalConfig = useCallback(async () => { + if (!config || !isGlobal) return; + setIsSubmitting(true); + try { + await updatePreferences({ + search_space_id: searchSpaceId, + data: { image_generation_config_id: config.id }, + }); + toast.success(`Now using ${config.name}`); + onOpenChange(false); + } catch (error) { + console.error("Failed to set image model:", error); + toast.error("Failed to set image model"); + } finally { + setIsSubmitting(false); + } + }, [config, isGlobal, searchSpaceId, updatePreferences, onOpenChange]); + + const isFormValid = formData.name && formData.provider && formData.model_name && formData.api_key; + const selectedProvider = IMAGE_GEN_PROVIDERS.find((p) => p.value === formData.provider); + + if (!mounted) return null; + + const sidebarContent = ( + + {open && ( + <> + {/* Backdrop */} + onOpenChange(false)} + /> + + {/* Sidebar */} + + {/* Header */} +
    +
    +
    + {isAutoMode ? ( + + ) : ( + + )} +
    +
    +

    {getTitle()}

    +
    + {isAutoMode ? ( + + + Recommended + + ) : isGlobal ? ( + + + Global + + ) : null} + {config && !isAutoMode && ( + {config.model_name} + )} +
    +
    +
    + +
    + + {/* Content */} +
    +
    + {/* Auto mode */} + {isAutoMode && ( + <> + + + + Auto mode distributes image generation requests across all configured providers for optimal performance and rate limit protection. + + +
    + + +
    + + )} + + {/* Global config (read-only) */} + {isGlobal && !isAutoMode && config && ( + <> + + + + Global configurations are read-only. To customize, create a new model. + + +
    +
    +
    +
    Name
    +

    {config.name}

    +
    + {config.description && ( +
    +
    Description
    +

    {config.description}

    +
    + )} +
    + +
    +
    +
    Provider
    +

    {config.provider}

    +
    +
    +
    Model
    +

    {config.model_name}

    +
    +
    +
    +
    + + +
    + + )} + + {/* Create / Edit form */} + {(mode === "create" || (mode === "edit" && !isGlobal)) && ( +
    + {/* Name */} +
    + + setFormData((p) => ({ ...p, name: e.target.value }))} + /> +
    + + {/* Description */} +
    + + setFormData((p) => ({ ...p, description: e.target.value }))} + /> +
    + + + + {/* Provider */} +
    + + +
    + + {/* Model Name */} +
    + + {suggestedModels.length > 0 ? ( + + + + + + + setFormData((p) => ({ ...p, model_name: val }))} + /> + + + Type a custom model name + + + {suggestedModels.map((m) => ( + { + setFormData((p) => ({ ...p, model_name: m.value })); + setModelComboboxOpen(false); + }} + > + + {m.value} + {m.label} + + ))} + + + + + + ) : ( + setFormData((p) => ({ ...p, model_name: e.target.value }))} + /> + )} +
    + + {/* API Key */} +
    + + setFormData((p) => ({ ...p, api_key: e.target.value }))} + /> +
    + + {/* API Base */} +
    + + setFormData((p) => ({ ...p, api_base: e.target.value }))} + /> +
    + + {/* Azure API Version */} + {formData.provider === "AZURE_OPENAI" && ( +
    + + setFormData((p) => ({ ...p, api_version: e.target.value }))} + /> +
    + )} + + {/* Actions */} +
    + + +
    +
    + )} +
    +
    +
    + + )} +
    + ); + + return typeof document !== "undefined" ? createPortal(sidebarContent, document.body) : null; +} diff --git a/surfsense_web/components/new-chat/image-model-selector.tsx b/surfsense_web/components/new-chat/image-model-selector.tsx index 8cae10345..b3422b264 100644 --- a/surfsense_web/components/new-chat/image-model-selector.tsx +++ b/surfsense_web/components/new-chat/image-model-selector.tsx @@ -38,14 +38,19 @@ import { } 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 }: ImageModelSelectorProps) { +export function ImageModelSelector({ className, onAddNew, onEdit }: ImageModelSelectorProps) { const [open, setOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); @@ -220,49 +225,59 @@ export function ImageModelSelector({ className, onAddNew }: ImageModelSelectorPr Global Image Models
    - {filteredGlobal.map((config) => { - const isSelected = currentConfig?.id === config.id; - const isAuto = "is_auto_mode" in config && config.is_auto_mode; - return ( - 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" - )} - > -
    -
    - {isAuto ? ( - - ) : ( - - )} -
    -
    -
    - {config.name} - {isAuto && ( - - Recommended - - )} - {isSelected && } -
    - - {isAuto ? "Auto load balancing" : config.model_name} - -
    + {filteredGlobal.map((config) => { + const isSelected = currentConfig?.id === config.id; + const isAuto = "is_auto_mode" in config && config.is_auto_mode; + return ( + 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" + )} + > +
    +
    + {isAuto ? ( + + ) : ( + + )}
    - - ); - })} +
    +
    + {config.name} + {isAuto && ( + + Recommended + + )} + {isSelected && } +
    + + {isAuto ? "Auto load balancing" : config.model_name} + +
    + {onEdit && ( + { + e.stopPropagation(); + setOpen(false); + onEdit(config, true); + }} + /> + )} +
    +
    + ); + })} )} @@ -275,37 +290,51 @@ export function ImageModelSelector({ className, onAddNew }: ImageModelSelectorPr Your Image Models
    - {filteredUser.map((config) => { - const isSelected = currentConfig?.id === config.id; - return ( - handleSelect(config.id)} - className={cn( - "mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50", - isSelected && "bg-accent/80" - )} - > -
    -
    - -
    -
    -
    - {config.name} - {isSelected && ( - - )} -
    - - {config.model_name} - -
    + {filteredUser.map((config) => { + const isSelected = currentConfig?.id === config.id; + return ( + handleSelect(config.id)} + className={cn( + "mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50", + isSelected && "bg-accent/80" + )} + > +
    +
    +
    - - ); - })} +
    +
    + {config.name} + {isSelected && ( + + )} +
    + + {config.model_name} + +
    + {onEdit && ( + + )} +
    +
    + ); + })} )} From 0d031cb2c235d3b0147b8e3aa7a1bb1b89610939 Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Thu, 5 Feb 2026 18:07:27 -0800 Subject: [PATCH 39/40] refactor: update image generation configuration to remove TPM references and clarify RPM usage in comments --- .../app/config/global_llm_config.example.yaml | 16 ++--------- .../app/services/image_gen_router_service.py | 28 ++++++------------- 2 files changed, 11 insertions(+), 33 deletions(-) diff --git a/surfsense_backend/app/config/global_llm_config.example.yaml b/surfsense_backend/app/config/global_llm_config.example.yaml index e727b8d56..0bb00c398 100644 --- a/surfsense_backend/app/config/global_llm_config.example.yaml +++ b/surfsense_backend/app/config/global_llm_config.example.yaml @@ -208,8 +208,7 @@ global_image_generation_configs: model_name: "dall-e-3" api_key: "sk-your-openai-api-key-here" api_base: "" - rpm: 50 - tpm: 100000 + rpm: 50 # Requests per minute (image gen is rate-limited by RPM, not tokens) litellm_params: {} # Example: OpenAI GPT Image 1 @@ -221,7 +220,6 @@ global_image_generation_configs: api_key: "sk-your-openai-api-key-here" api_base: "" rpm: 50 - tpm: 100000 litellm_params: {} # Example: Azure OpenAI DALL-E 3 @@ -234,7 +232,6 @@ global_image_generation_configs: api_base: "https://your-resource.openai.azure.com" api_version: "2024-02-15-preview" rpm: 50 - tpm: 100000 litellm_params: base_model: "dall-e-3" @@ -247,7 +244,6 @@ global_image_generation_configs: # api_key: "your-openrouter-api-key-here" # api_base: "" # rpm: 30 - # tpm: 50000 # litellm_params: {} # Notes: @@ -262,17 +258,11 @@ global_image_generation_configs: # - rpm/tpm: Optional rate limits for load balancing (requests/tokens per minute) # These help the router distribute load evenly and avoid rate limit errors # -# AZURE-SPECIFIC NOTES: -# - Always add 'base_model' in litellm_params for Azure deployments -# - This fixes "Could not identify azure model 'X'" warnings -# - base_model should match the underlying OpenAI model (e.g., gpt-4o, gpt-4-turbo, gpt-3.5-turbo) -# - model_name format: "azure/" -# - api_version: Use a recent Azure API version (e.g., "2024-02-15-preview") -# - See: https://docs.litellm.ai/docs/proxy/cost_tracking#spend-tracking-for-azure-openai-models # # IMAGE GENERATION NOTES: # - Image generation configs use the same ID scheme as LLM configs (negative for global) # - Supported models: dall-e-2, dall-e-3, gpt-image-1 (OpenAI), azure/* (Azure), # bedrock/* (AWS), vertex_ai/* (Google), recraft/* (Recraft), openrouter/* (OpenRouter) # - The router uses litellm.aimage_generation() for async image generation -# - api_version is required for Azure image generation deployments +# - Only RPM (requests per minute) is relevant for image generation rate limiting. +# TPM (tokens per minute) does not apply since image APIs are billed/rate-limited per request, not per token. diff --git a/surfsense_backend/app/services/image_gen_router_service.py b/surfsense_backend/app/services/image_gen_router_service.py index eb6936efd..f45a6ab63 100644 --- a/surfsense_backend/app/services/image_gen_router_service.py +++ b/surfsense_backend/app/services/image_gen_router_service.py @@ -183,11 +183,11 @@ class ImageGenRouterService: "litellm_params": litellm_params, } - # Add rate limits from config if available + # Add RPM rate limit from config if available + # Note: TPM (tokens per minute) is not applicable for image generation + # since image APIs are rate-limited by requests, not tokens. if config.get("rpm"): deployment["rpm"] = config["rpm"] - if config.get("tpm"): - deployment["tpm"] = config["tpm"] return deployment @@ -219,10 +219,6 @@ class ImageGenRouterService: prompt: str, model: str = "auto", n: int | None = None, - quality: str | None = None, - size: str | None = None, - style: str | None = None, - response_format: str | None = None, timeout: int = 600, **kwargs, ) -> ImageResponse: @@ -232,16 +228,16 @@ class ImageGenRouterService: Uses Router.aimage_generation() which distributes requests across configured image generation deployments. + Parameters like size, quality, style, and response_format are intentionally + omitted to keep the interface model-agnostic. Providers use their own + sensible defaults. If needed, pass them via **kwargs. + Args: prompt: Text description of the desired image(s) model: Model alias (default "auto" for router routing) n: Number of images to generate - quality: Image quality setting - size: Image size - style: Style parameter - response_format: "url" or "b64_json" timeout: Request timeout in seconds - **kwargs: Additional litellm params + **kwargs: Additional provider-specific params (size, quality, etc.) Returns: ImageResponse from litellm @@ -264,14 +260,6 @@ class ImageGenRouterService: } if n is not None: gen_kwargs["n"] = n - if quality is not None: - gen_kwargs["quality"] = quality - if size is not None: - gen_kwargs["size"] = size - if style is not None: - gen_kwargs["style"] = style - if response_format is not None: - gen_kwargs["response_format"] = response_format gen_kwargs.update(kwargs) return await instance._router.aimage_generation(**gen_kwargs) From 7ce6493caa79a5cb0038c0a9a1caa4fac717bbf9 Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Thu, 5 Feb 2026 18:16:21 -0800 Subject: [PATCH 40/40] fix: update ENUM import for PostgreSQL compatibility in image generations table migration --- .../alembic/versions/93_add_image_generations_table.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/surfsense_backend/alembic/versions/93_add_image_generations_table.py b/surfsense_backend/alembic/versions/93_add_image_generations_table.py index f24adc68f..eba9d7c86 100644 --- a/surfsense_backend/alembic/versions/93_add_image_generations_table.py +++ b/surfsense_backend/alembic/versions/93_add_image_generations_table.py @@ -13,6 +13,7 @@ Changes: from collections.abc import Sequence import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM from sqlalchemy.dialects.postgresql import JSONB, UUID from alembic import op @@ -59,7 +60,7 @@ def upgrade() -> None: sa.Column("description", sa.String(500), nullable=True), sa.Column( "provider", - sa.Enum( + PG_ENUM( "OPENAI", "AZURE_OPENAI", "GOOGLE",