diff --git a/.claude/launch.json b/.claude/launch.json index 1c4aac212..835729cc6 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -3,10 +3,11 @@ "configurations": [ { "name": "SurfSense Frontend", - "runtimeExecutable": "bun", + "runtimeExecutable": "pnpm", "runtimeArgs": ["dev"], "cwd": "surfsense_web", - "port": 3000 + "port": 3999, + "autoPort": false }, { "name": "SurfSense Backend", diff --git a/surfsense_backend/app/agents/new_chat/tools/__init__.py b/surfsense_backend/app/agents/new_chat/tools/__init__.py index df127b596..8f2b724a0 100644 --- a/surfsense_backend/app/agents/new_chat/tools/__init__.py +++ b/surfsense_backend/app/agents/new_chat/tools/__init__.py @@ -23,7 +23,6 @@ from .crypto_realtime import ( create_get_live_token_data_tool, create_get_live_token_price_tool, ) -from .display_image import create_display_image_tool from .generate_image import create_generate_image_tool from .knowledge_base import ( CONNECTOR_DESCRIPTIONS, @@ -57,7 +56,6 @@ __all__ = [ "create_generate_video_presentation_tool", "create_get_live_token_data_tool", "create_get_live_token_price_tool", - "create_link_preview_tool", "create_recall_memory_tool", "create_save_memory_tool", "create_scrape_webpage_tool", diff --git a/surfsense_backend/app/agents/new_chat/tools/knowledge_base.py b/surfsense_backend/app/agents/new_chat/tools/knowledge_base.py index ff9ef961c..430b46e96 100644 --- a/surfsense_backend/app/agents/new_chat/tools/knowledge_base.py +++ b/surfsense_backend/app/agents/new_chat/tools/knowledge_base.py @@ -781,269 +781,6 @@ async def search_knowledge_base_raw_async( for docs in connector_results: all_documents.extend(docs) - elif connector == "TEAMS_CONNECTOR": - _, chunks = await connector_service.search_teams( - user_query=query, - search_space_id=search_space_id, - top_k=top_k, - start_date=resolved_start_date, - end_date=resolved_end_date, - ) - all_documents.extend(chunks) - - elif connector == "NOTION_CONNECTOR": - _, chunks = await connector_service.search_notion( - user_query=query, - search_space_id=search_space_id, - top_k=top_k, - start_date=resolved_start_date, - end_date=resolved_end_date, - ) - all_documents.extend(chunks) - - elif connector == "GITHUB_CONNECTOR": - _, chunks = await connector_service.search_github( - user_query=query, - search_space_id=search_space_id, - top_k=top_k, - start_date=resolved_start_date, - end_date=resolved_end_date, - ) - all_documents.extend(chunks) - - elif connector == "LINEAR_CONNECTOR": - _, chunks = await connector_service.search_linear( - user_query=query, - search_space_id=search_space_id, - top_k=top_k, - start_date=resolved_start_date, - end_date=resolved_end_date, - ) - all_documents.extend(chunks) - - elif connector == "TAVILY_API": - _, chunks = await connector_service.search_tavily( - user_query=query, - search_space_id=search_space_id, - top_k=top_k, - ) - all_documents.extend(chunks) - - elif connector == "SEARXNG_API": - _, chunks = await connector_service.search_searxng( - user_query=query, - search_space_id=search_space_id, - top_k=top_k, - ) - all_documents.extend(chunks) - - elif connector == "LINKUP_API": - # Keep behavior aligned with researcher: default "standard" - _, chunks = await connector_service.search_linkup( - user_query=query, - search_space_id=search_space_id, - mode="standard", - ) - all_documents.extend(chunks) - - elif connector == "BAIDU_SEARCH_API": - _, chunks = await connector_service.search_baidu( - user_query=query, - search_space_id=search_space_id, - top_k=top_k, - ) - all_documents.extend(chunks) - - elif connector == "DISCORD_CONNECTOR": - _, chunks = await connector_service.search_discord( - user_query=query, - search_space_id=search_space_id, - top_k=top_k, - start_date=resolved_start_date, - end_date=resolved_end_date, - ) - all_documents.extend(chunks) - - elif connector == "JIRA_CONNECTOR": - _, chunks = await connector_service.search_jira( - user_query=query, - search_space_id=search_space_id, - top_k=top_k, - start_date=resolved_start_date, - end_date=resolved_end_date, - ) - all_documents.extend(chunks) - - elif connector == "GOOGLE_CALENDAR_CONNECTOR": - _, chunks = await connector_service.search_google_calendar( - user_query=query, - search_space_id=search_space_id, - top_k=top_k, - start_date=resolved_start_date, - end_date=resolved_end_date, - ) - all_documents.extend(chunks) - - elif connector == "AIRTABLE_CONNECTOR": - _, chunks = await connector_service.search_airtable( - user_query=query, - search_space_id=search_space_id, - top_k=top_k, - start_date=resolved_start_date, - end_date=resolved_end_date, - ) - all_documents.extend(chunks) - - elif connector == "GOOGLE_GMAIL_CONNECTOR": - _, chunks = await connector_service.search_google_gmail( - user_query=query, - search_space_id=search_space_id, - top_k=top_k, - start_date=resolved_start_date, - end_date=resolved_end_date, - ) - all_documents.extend(chunks) - - elif connector == "GOOGLE_DRIVE_FILE": - _, chunks = await connector_service.search_google_drive( - user_query=query, - search_space_id=search_space_id, - top_k=top_k, - start_date=resolved_start_date, - end_date=resolved_end_date, - ) - all_documents.extend(chunks) - - elif connector == "CONFLUENCE_CONNECTOR": - _, chunks = await connector_service.search_confluence( - user_query=query, - search_space_id=search_space_id, - top_k=top_k, - start_date=resolved_start_date, - end_date=resolved_end_date, - ) - all_documents.extend(chunks) - - elif connector == "CLICKUP_CONNECTOR": - _, chunks = await connector_service.search_clickup( - user_query=query, - search_space_id=search_space_id, - top_k=top_k, - start_date=resolved_start_date, - end_date=resolved_end_date, - ) - all_documents.extend(chunks) - - elif connector == "LUMA_CONNECTOR": - _, chunks = await connector_service.search_luma( - user_query=query, - search_space_id=search_space_id, - top_k=top_k, - start_date=resolved_start_date, - end_date=resolved_end_date, - ) - all_documents.extend(chunks) - - elif connector == "ELASTICSEARCH_CONNECTOR": - _, chunks = await connector_service.search_elasticsearch( - user_query=query, - search_space_id=search_space_id, - top_k=top_k, - start_date=resolved_start_date, - end_date=resolved_end_date, - ) - all_documents.extend(chunks) - - elif connector == "NOTE": - _, chunks = await connector_service.search_notes( - user_query=query, - search_space_id=search_space_id, - top_k=top_k, - start_date=resolved_start_date, - end_date=resolved_end_date, - ) - all_documents.extend(chunks) - - elif connector == "BOOKSTACK_CONNECTOR": - _, chunks = await connector_service.search_bookstack( - user_query=query, - search_space_id=search_space_id, - top_k=top_k, - start_date=resolved_start_date, - end_date=resolved_end_date, - ) - all_documents.extend(chunks) - - elif connector == "CIRCLEBACK": - _, chunks = await connector_service.search_circleback( - user_query=query, - search_space_id=search_space_id, - top_k=top_k, - start_date=resolved_start_date, - end_date=resolved_end_date, - ) - all_documents.extend(chunks) - - elif connector == "OBSIDIAN_CONNECTOR": - _, chunks = await connector_service.search_obsidian( - user_query=query, - search_space_id=search_space_id, - top_k=top_k, - start_date=resolved_start_date, - end_date=resolved_end_date, - ) - all_documents.extend(chunks) - - elif connector == "DEXSCREENER_CONNECTOR": - _, chunks = await connector_service.search_dexscreener( - user_query=query, - search_space_id=search_space_id, - top_k=top_k, - start_date=resolved_start_date, - end_date=resolved_end_date, - ) - print(f"[DEBUG] DexScreener search returned {len(chunks)} chunks") - if chunks: - print(f"[DEBUG] First chunk metadata: {chunks[0].get('document', {}).get('metadata', {})}") - all_documents.extend(chunks) - - # ========================================================= - # Composio Connectors - # ========================================================= - elif connector == "COMPOSIO_GOOGLE_DRIVE_CONNECTOR": - _, chunks = await connector_service.search_composio_google_drive( - user_query=query, - search_space_id=search_space_id, - top_k=top_k, - start_date=resolved_start_date, - end_date=resolved_end_date, - ) - all_documents.extend(chunks) - - elif connector == "COMPOSIO_GMAIL_CONNECTOR": - _, chunks = await connector_service.search_composio_gmail( - user_query=query, - search_space_id=search_space_id, - top_k=top_k, - start_date=resolved_start_date, - end_date=resolved_end_date, - ) - all_documents.extend(chunks) - - elif connector == "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR": - _, chunks = await connector_service.search_composio_google_calendar( - user_query=query, - search_space_id=search_space_id, - top_k=top_k, - start_date=resolved_start_date, - end_date=resolved_end_date, - ) - all_documents.extend(chunks) - - except Exception as e: - print(f"Error searching connector {connector}: {e}") - continue - # Deduplicate by content hash seen_doc_ids: set[Any] = set() seen_content_hashes: set[int] = set() diff --git a/surfsense_backend/app/agents/new_chat/tools/registry.py b/surfsense_backend/app/agents/new_chat/tools/registry.py index de768d615..1d7a1b98a 100644 --- a/surfsense_backend/app/agents/new_chat/tools/registry.py +++ b/surfsense_backend/app/agents/new_chat/tools/registry.py @@ -54,7 +54,6 @@ from .crypto_realtime import ( create_get_live_token_data_tool, create_get_live_token_price_tool, ) -from .display_image import create_display_image_tool from .dropbox import ( create_create_dropbox_file_tool, create_delete_dropbox_file_tool, @@ -85,8 +84,6 @@ from .linear import ( create_delete_linear_issue_tool, create_update_linear_issue_tool, ) -from .knowledge_base import create_search_knowledge_base_tool -from .link_preview import create_link_preview_tool from .mcp_tool import load_mcp_tools from .notion import ( create_create_notion_page_tool, diff --git a/surfsense_backend/app/connectors/notion_history.py b/surfsense_backend/app/connectors/notion_history.py index ab846a400..41774d297 100644 --- a/surfsense_backend/app/connectors/notion_history.py +++ b/surfsense_backend/app/connectors/notion_history.py @@ -8,6 +8,10 @@ from notion_client.errors import APIResponseError from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select + +class NotionAPIError(Exception): + """Raised when Notion API returns an error response.""" + from app.config import config from app.db import SearchSourceConnector from app.schemas.notion_auth_credentials import NotionAuthCredentialsBase diff --git a/surfsense_web/components/tool-ui/display-image.tsx b/surfsense_web/components/tool-ui/display-image.tsx new file mode 100644 index 000000000..25e19d496 --- /dev/null +++ b/surfsense_web/components/tool-ui/display-image.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { makeAssistantToolUI } from "@assistant-ui/react"; +import { z } from "zod"; +import { ImageIcon } from "lucide-react"; + +const DisplayImageArgsSchema = z.object({ + url: z.string().optional(), + alt: z.string().optional(), + caption: z.string().optional(), +}); + +const DisplayImageResultSchema = z.object({ + url: z.string().optional(), + alt: z.string().optional(), + caption: z.string().optional(), +}).passthrough(); + +type DisplayImageArgs = z.infer; +type DisplayImageResult = z.infer; + +export const DisplayImageToolUI = makeAssistantToolUI({ + toolName: "display_image", + render: ({ args, result, status }) => { + const isLoading = status.type === "running"; + const imageUrl = result?.url ?? args?.url; + const altText = result?.alt ?? args?.alt ?? "Image"; + const caption = result?.caption ?? args?.caption; + + if (isLoading) { + return ( +
+ + Loading image... +
+ ); + } + + if (!imageUrl) return null; + + return ( +
+ {altText} + {caption && ( +

{caption}

+ )} +
+ ); + }, +}); diff --git a/surfsense_web/components/tool-ui/generate-podcast.tsx b/surfsense_web/components/tool-ui/generate-podcast.tsx index 02f53efad..4e3937269 100644 --- a/surfsense_web/components/tool-ui/generate-podcast.tsx +++ b/surfsense_web/components/tool-ui/generate-podcast.tsx @@ -377,15 +377,19 @@ export const GeneratePodcastToolUI = ({ result, status, }: ToolCallMessagePartProps) => { - const title = args.podcast_title || "SurfSense Podcast"; + // Guard: when rendered without props (e.g. as in provider), + // render nothing — actual rendering happens via assistant-message.tsx by_name map. + if (!status && !result && !args) return null; + + const title = args?.podcast_title || "SurfSense Podcast"; // Loading state - tool is still running (agent processing) - if (status.type === "running" || status.type === "requires-action") { + if (status?.type === "running" || status?.type === "requires-action") { return ; } // Incomplete/cancelled state - if (status.type === "incomplete") { + if (status?.type === "incomplete") { if (status.reason === "cancelled") { return (
diff --git a/surfsense_web/components/tool-ui/link-preview.tsx b/surfsense_web/components/tool-ui/link-preview.tsx new file mode 100644 index 000000000..bee5e0fb2 --- /dev/null +++ b/surfsense_web/components/tool-ui/link-preview.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { makeAssistantToolUI } from "@assistant-ui/react"; +import { z } from "zod"; +import { ExternalLinkIcon, Loader2Icon } from "lucide-react"; + +const LinkPreviewArgsSchema = z.object({ + url: z.string(), +}).passthrough(); + +const LinkPreviewResultSchema = z.object({ + url: z.string().optional(), + title: z.string().optional(), + description: z.string().optional(), + image: z.string().optional(), + favicon: z.string().optional(), + error: z.string().optional(), +}).passthrough(); + +type LinkPreviewArgs = z.infer; +type LinkPreviewResult = z.infer; + +export const LinkPreviewToolUI = makeAssistantToolUI({ + toolName: "link_preview", + render: ({ args, result, status }) => { + const isLoading = status.type === "running"; + const url = result?.url ?? args?.url; + + if (isLoading) { + return ( +
+ + Loading preview... +
+ ); + } + + if (result?.error || !url) return null; + + return ( + + {result?.favicon && ( + + )} +
+

{result?.title ?? url}

+ {result?.description && ( +

{result.description}

+ )} +

{url}

+
+ +
+ ); + }, +}); diff --git a/surfsense_web/components/tool-ui/scrape-webpage.tsx b/surfsense_web/components/tool-ui/scrape-webpage.tsx new file mode 100644 index 000000000..0676734f0 --- /dev/null +++ b/surfsense_web/components/tool-ui/scrape-webpage.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { makeAssistantToolUI } from "@assistant-ui/react"; +import { z } from "zod"; +import { GlobeIcon, Loader2Icon, CheckCircle2Icon, XCircleIcon } from "lucide-react"; + +const ScrapeWebpageArgsSchema = z.object({ + url: z.string(), +}).passthrough(); + +const ScrapeWebpageResultSchema = z.object({ + url: z.string().optional(), + title: z.string().optional(), + content: z.string().optional(), + error: z.string().optional(), + success: z.boolean().optional(), +}).passthrough(); + +type ScrapeWebpageArgs = z.infer; +type ScrapeWebpageResult = z.infer; + +export const ScrapeWebpageToolUI = makeAssistantToolUI({ + toolName: "scrape_webpage", + render: ({ args, result, status }) => { + const isLoading = status.type === "running"; + const url = result?.url ?? args?.url; + const hasError = result?.error || result?.success === false; + const isSuccess = result?.success !== false && !result?.error && status.type === "complete"; + + return ( +
+
+ {isLoading ? ( + + ) : hasError ? ( + + ) : ( + + )} +
+
+

+ {isLoading ? "Scraping webpage..." : hasError ? "Failed to scrape" : "Scraped webpage"} +

+ {url && ( +

{url}

+ )} + {hasError && result?.error && ( +

{result.error}

+ )} +
+ {isSuccess && } +
+ ); + }, +}); diff --git a/surfsense_web/components/tool-ui/user-memory.tsx b/surfsense_web/components/tool-ui/user-memory.tsx index f7c446806..7996d5c54 100644 --- a/surfsense_web/components/tool-ui/user-memory.tsx +++ b/surfsense_web/components/tool-ui/user-memory.tsx @@ -85,6 +85,57 @@ export const UpdateMemoryToolUI = ({ return null; }; +// ============================================================================ +// Save Memory Tool UI (stub – tool not yet in backend) +// ============================================================================ + +export const SaveMemoryToolUI = ({ + status, +}: ToolCallMessagePartProps<{ content: string }, { status: string }>) => { + if (!status) return null; + const isRunning = status.type === "running" || status.type === "requires-action"; + return ( +
+
+ {isRunning ? ( + + ) : ( + + )} +
+

+ {isRunning ? "Saving to memory..." : "Memory saved"} +

+ {!isRunning && } +
+ ); +}; + +// ============================================================================ +// Recall Memory Tool UI (stub – tool not yet in backend) +// ============================================================================ + +export const RecallMemoryToolUI = ({ + status, +}: ToolCallMessagePartProps<{ query: string }, { memories: string[] }>) => { + if (!status) return null; + const isRunning = status.type === "running" || status.type === "requires-action"; + return ( +
+
+ {isRunning ? ( + + ) : ( + + )} +
+

+ {isRunning ? "Recalling from memory..." : "Memory recalled"} +

+
+ ); +}; + // ============================================================================ // Exports // ============================================================================