Merge upstream/dev into feature/human-in-the-loop

This commit is contained in:
CREDO23 2026-02-13 20:17:59 +02:00
commit 66a6fb685e
47 changed files with 7257 additions and 4582 deletions

View file

@ -123,6 +123,23 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libpango-1.0-0 \
&& rm -rf /var/lib/apt/lists/*
# Install Pandoc 3.x from GitHub (apt ships 2.9 which has broken table rendering).
RUN ARCH=$(dpkg --print-architecture) && \
wget -qO /tmp/pandoc.deb "https://github.com/jgm/pandoc/releases/download/3.9/pandoc-3.9-1-${ARCH}.deb" && \
dpkg -i /tmp/pandoc.deb && \
rm /tmp/pandoc.deb
# Install Typst for PDF rendering (Typst has built-in professional styling
# for tables, headings, code blocks, etc., no CSS needed).
RUN ARCH=$(dpkg --print-architecture) && \
if [ "$ARCH" = "amd64" ]; then TYPST_ARCH="x86_64-unknown-linux-musl"; \
else TYPST_ARCH="aarch64-unknown-linux-musl"; fi && \
wget -qO /tmp/typst.tar.xz "https://github.com/typst/typst/releases/download/v0.14.2/typst-${TYPST_ARCH}.tar.xz" && \
tar -xf /tmp/typst.tar.xz -C /tmp && \
cp /tmp/typst-*/typst /usr/local/bin/typst && \
rm -rf /tmp/typst* && \
typst --version
# Install Node.js 20.x (for running frontend)
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \

264
README.es.md Normal file
View file

@ -0,0 +1,264 @@
<a href="https://www.surfsense.com/"><img width="1584" height="396" alt="readme_banner" src="https://github.com/user-attachments/assets/9361ef58-1753-4b6e-b275-5020d8847261" /></a>
<div align="center">
<a href="https://discord.gg/ejRNvftDp9">
<img src="https://img.shields.io/discord/1359368468260192417" alt="Discord">
</a>
<a href="https://www.reddit.com/r/SurfSense/">
<img src="https://img.shields.io/reddit/subreddit-subscribers/SurfSense?style=social" alt="Reddit">
</a>
</div>
<div align="center">
[English](README.md) | [Español](README.es.md) | [Português](README.pt-BR.md) | [हिन्दी](README.hi.md) | [简体中文](README.zh-CN.md)
</div>
# SurfSense
Conecta cualquier LLM a tus fuentes de conocimiento internas y chatea con él en tiempo real junto a tu equipo. Alternativa de código abierto a NotebookLM, Perplexity y Glean.
SurfSense es un agente de investigación de IA altamente personalizable, conectado a fuentes externas como motores de búsqueda (SearxNG, Tavily, LinkUp), Google Drive, Slack, Microsoft Teams, Linear, Jira, ClickUp, Confluence, BookStack, Gmail, Notion, YouTube, GitHub, Discord, Airtable, Google Calendar, Luma, Circleback, Elasticsearch, Obsidian y más por venir.
<div align="center">
<a href="https://trendshift.io/repositories/13606" target="_blank"><img src="https://trendshift.io/api/badge/repositories/13606" alt="MODSetter%2FSurfSense | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</div>
# Video
https://github.com/user-attachments/assets/cc0c84d3-1f2f-4f7a-b519-2ecce22310b1
## Ejemplo de Podcast
https://github.com/user-attachments/assets/a0a16566-6967-4374-ac51-9b3e07fbecd7
## Funcionalidades Principales
### 💡 **Idea**:
- Alternativa de código abierto a NotebookLM, Perplexity y Glean. Conecta cualquier LLM a tus fuentes de conocimiento internas y colabora con tu equipo en tiempo real.
### 📁 **Soporte de Múltiples Formatos de Archivo**
- Guarda contenido de tus archivos personales *(Documentos, imágenes, videos y soporta **más de 50 extensiones de archivo**)* en tu propia base de conocimiento personal.
### 🔍 **Búsqueda Potente**
- Investiga o encuentra rápidamente cualquier cosa en tu contenido guardado.
### 💬 **Chatea con tu Contenido Guardado**
- Interactúa en lenguaje natural y obtén respuestas con citas.
### 📄 **Respuestas con Citas**
- Obtén respuestas con citas como en Perplexity.
### 🧩 **Compatibilidad Universal**
- Conecta virtualmente cualquier proveedor de inferencia a través de la especificación OpenAI y LiteLLM.
### 🔔 **Privacidad y Soporte de LLM Local**
- Funciona perfectamente con LLMs locales como vLLM y Ollama.
### 🏠 **Auto-Hospedable**
- Código abierto y fácil de desplegar localmente.
### 👥 **Colaboración en Equipo con RBAC**
- Control de acceso basado en roles para los espacios de búsqueda
- Invita a miembros del equipo con roles personalizables (Propietario, Admin, Editor, Visor)
- Permisos granulares para documentos, chats, conectores y configuración
- Comparte bases de conocimiento de forma segura dentro de tu organización
- Los chats de equipo se actualizan en tiempo real y puedes "Chatear sobre el chat" en hilos de comentarios
### 🎙️ Podcasts
- Agente de generación de podcasts ultrarrápido. (Crea un podcast de 3 minutos en menos de 20 segundos.)
- Convierte tus conversaciones de chat en contenido de audio atractivo
- Soporte para proveedores TTS locales (Kokoro TTS)
- Soporte para múltiples proveedores TTS (OpenAI, Azure, Google Vertex AI)
### 🤖 **Arquitectura de Agentes Profundos**
- Impulsado por [LangChain Deep Agents](https://docs.langchain.com/oss/python/deepagents/overview) - agentes que pueden planificar, usar subagentes y aprovechar sistemas de archivos para tareas complejas.
### 📊 **Técnicas Avanzadas de RAG**
- Soporta más de 100 LLMs
- Soporta más de 6000 modelos de embeddings
- Soporta todos los principales rerankers (Pinecone, Cohere, Flashrank, etc.)
- Utiliza índices jerárquicos (configuración RAG de 2 niveles)
- Utiliza búsqueda híbrida (Semántica + Texto completo combinado con Reciprocal Rank Fusion)
### **Fuentes Externas**
- Motores de búsqueda (Tavily, LinkUp)
- SearxNG (instancias auto-hospedadas)
- Google Drive
- Slack
- Microsoft Teams
- Linear
- Jira
- ClickUp
- Confluence
- BookStack
- Notion
- Gmail
- Videos de YouTube
- GitHub
- Discord
- Airtable
- Google Calendar
- Luma
- Circleback
- Elasticsearch
- Obsidian
- y más por venir.....
## 📄 **Extensiones de Archivo Soportadas**
| Servicio ETL | Formatos | Notas |
|--------------|----------|-------|
| **LlamaCloud** | 50+ formatos | Documentos, presentaciones, hojas de cálculo, imágenes |
| **Unstructured** | 34+ formatos | Formatos principales + soporte de email |
| **Docling** | Formatos principales | Procesamiento local, no requiere clave API |
**Audio/Video** (vía servicio STT): `.mp3`, `.wav`, `.mp4`, `.webm`, etc.
### 🔖 Extensión Multi-Navegador
- La extensión de SurfSense se puede usar para guardar cualquier página web que desees.
- Su principal uso es guardar páginas web protegidas por autenticación.
## SOLICITUDES DE FUNCIONES Y FUTURO
**SurfSense está en desarrollo activo.** Aunque aún no está listo para producción, puedes ayudarnos a acelerar el proceso.
¡Únete al [Discord de SurfSense](https://discord.gg/ejRNvftDp9) y ayuda a dar forma al futuro de SurfSense!
## 🚀 Hoja de Ruta
¡Mantente al día con nuestro progreso de desarrollo y próximas funcionalidades!
Consulta nuestra hoja de ruta pública y contribuye con tus ideas o comentarios:
**📋 Discusión de la Hoja de Ruta:** [SurfSense 2025-2026 Roadmap: Deep Agents, Real-Time Collaboration & MCP Servers](https://github.com/MODSetter/SurfSense/discussions/565)
**📊 Tablero Kanban:** [SurfSense Project Board](https://github.com/users/MODSetter/projects/3)
## ¿Cómo empezar?
### Inicio Rápido con Docker 🐳
> [!TIP]
> Para despliegues en producción, usa la configuración completa de [Docker Compose](https://www.surfsense.com/docs/docker-installation) que ofrece más control y escalabilidad.
**Linux/macOS:**
```bash
docker run -d -p 3000:3000 -p 8000:8000 -p 5133:5133 \
-v surfsense-data:/data \
--name surfsense \
--restart unless-stopped \
ghcr.io/modsetter/surfsense:latest
```
**Windows (PowerShell):**
```powershell
docker run -d -p 3000:3000 -p 8000:8000 -p 5133:5133 `
-v surfsense-data:/data `
--name surfsense `
--restart unless-stopped `
ghcr.io/modsetter/surfsense:latest
```
**Con Configuración Personalizada:**
Puedes pasar cualquier variable de entorno usando flags `-e`:
```bash
docker run -d -p 3000:3000 -p 8000:8000 -p 5133:5133 \
-v surfsense-data:/data \
-e EMBEDDING_MODEL=openai://text-embedding-ada-002 \
-e OPENAI_API_KEY=your_openai_api_key \
-e AUTH_TYPE=GOOGLE \
-e GOOGLE_OAUTH_CLIENT_ID=your_google_client_id \
-e GOOGLE_OAUTH_CLIENT_SECRET=your_google_client_secret \
-e ETL_SERVICE=LLAMACLOUD \
-e LLAMA_CLOUD_API_KEY=your_llama_cloud_key \
--name surfsense \
--restart unless-stopped \
ghcr.io/modsetter/surfsense:latest
```
> [!NOTE]
> - Si despliegas detrás de un proxy inverso con HTTPS, agrega `-e BACKEND_URL=https://api.yourdomain.com`
Después de iniciar, accede a SurfSense en:
- **Frontend**: [http://localhost:3000](http://localhost:3000)
- **API Backend**: [http://localhost:8000](http://localhost:8000)
- **Documentación API**: [http://localhost:8000/docs](http://localhost:8000/docs)
- **Electric-SQL**: [http://localhost:5133](http://localhost:5133)
**Comandos Útiles:**
```bash
docker logs -f surfsense # Ver logs
docker stop surfsense # Detener
docker start surfsense # Iniciar
docker rm surfsense # Eliminar (datos preservados en el volumen)
```
### Opciones de Instalación
SurfSense ofrece múltiples opciones para empezar:
1. **[SurfSense Cloud](https://www.surfsense.com/login)** - La forma más fácil de probar SurfSense sin ninguna configuración.
- No requiere instalación
- Acceso instantáneo a todas las funcionalidades
- Perfecto para empezar rápidamente
2. **Inicio Rápido Docker (Arriba)** - Un solo comando para tener SurfSense funcionando localmente.
- Imagen todo-en-uno con PostgreSQL, Redis y todos los servicios incluidos
- Perfecto para evaluación, desarrollo y despliegues pequeños
- Datos persistidos vía volumen Docker
3. **[Docker Compose (Producción)](https://www.surfsense.com/docs/docker-installation)** - Despliegue de stack completo con servicios separados.
- Incluye pgAdmin para gestión de base de datos a través de interfaz web
- Soporta personalización de variables de entorno vía archivo `.env`
- Opciones de despliegue flexibles (stack completo o solo servicios principales)
- Mejor para producción con escalado independiente de servicios
4. **[Instalación Manual](https://www.surfsense.com/docs/manual-installation)** - Para usuarios que prefieren más control sobre su configuración o necesitan personalizar su despliegue.
Las guías de Docker e instalación manual incluyen instrucciones detalladas específicas para Windows, macOS y Linux.
Antes de la instalación auto-hospedada, asegúrate de completar los [pasos de configuración previos](https://www.surfsense.com/docs/) incluyendo:
- Configuración de autenticación (opcional - por defecto usa autenticación LOCAL)
- **Servicio ETL de Procesamiento de Archivos** (opcional - por defecto usa Docling):
- Docling (por defecto, procesamiento local, no requiere clave API, soporta PDF, documentos Office, imágenes, HTML, CSV)
- Clave API de Unstructured.io (soporta 34+ formatos)
- Clave API de LlamaIndex (análisis mejorado, soporta 50+ formatos)
- Otras claves API según sea necesario para tu caso de uso
## Contribuir
¡Las contribuciones son muy bienvenidas! Una contribución puede ser tan pequeña como una ⭐ o incluso encontrar y crear issues.
El ajuste fino del Backend siempre es deseado.
Para guías detalladas de contribución, consulta nuestro archivo [CONTRIBUTING.md](CONTRIBUTING.md).
## Historial de Stars
<a href="https://www.star-history.com/#MODSetter/SurfSense&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=MODSetter/SurfSense&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=MODSetter/SurfSense&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=MODSetter/SurfSense&type=Date" />
</picture>
</a>
---
---
<p align="center">
<img
src="https://github.com/user-attachments/assets/329c9bc2-6005-4aed-a629-700b5ae296b4"
alt="Catalyst Project"
width="200"
/>
</p>
---
---

264
README.hi.md Normal file
View file

@ -0,0 +1,264 @@
<a href="https://www.surfsense.com/"><img width="1584" height="396" alt="readme_banner" src="https://github.com/user-attachments/assets/9361ef58-1753-4b6e-b275-5020d8847261" /></a>
<div align="center">
<a href="https://discord.gg/ejRNvftDp9">
<img src="https://img.shields.io/discord/1359368468260192417" alt="Discord">
</a>
<a href="https://www.reddit.com/r/SurfSense/">
<img src="https://img.shields.io/reddit/subreddit-subscribers/SurfSense?style=social" alt="Reddit">
</a>
</div>
<div align="center">
[English](README.md) | [Español](README.es.md) | [Português](README.pt-BR.md) | [हिन्दी](README.hi.md) | [简体中文](README.zh-CN.md)
</div>
# SurfSense
किसी भी LLM को अपने आंतरिक ज्ञान स्रोतों से जोड़ें और अपनी टीम के साथ रीयल-टाइम में चैट करें। NotebookLM, Perplexity और Glean का ओपन सोर्स विकल्प।
SurfSense एक अत्यधिक अनुकूलन योग्य AI शोध एजेंट है, जो बाहरी स्रोतों से जुड़ा है जैसे सर्च इंजन (SearxNG, Tavily, LinkUp), Google Drive, Slack, Microsoft Teams, Linear, Jira, ClickUp, Confluence, BookStack, Gmail, Notion, YouTube, GitHub, Discord, Airtable, Google Calendar, Luma, Circleback, Elasticsearch, Obsidian और भी बहुत कुछ आने वाला है।
<div align="center">
<a href="https://trendshift.io/repositories/13606" target="_blank"><img src="https://trendshift.io/api/badge/repositories/13606" alt="MODSetter%2FSurfSense | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</div>
# वीडियो
https://github.com/user-attachments/assets/cc0c84d3-1f2f-4f7a-b519-2ecce22310b1
## पॉडकास्ट नमूना
https://github.com/user-attachments/assets/a0a16566-6967-4374-ac51-9b3e07fbecd7
## प्रमुख विशेषताएं
### 💡 **विचार**:
- NotebookLM, Perplexity और Glean का ओपन सोर्स विकल्प। किसी भी LLM को अपने आंतरिक ज्ञान स्रोतों से जोड़ें और अपनी टीम के साथ रीयल-टाइम में सहयोग करें।
### 📁 **कई फ़ाइल फ़ॉर्मेट अपलोड सपोर्ट**
- अपनी व्यक्तिगत फ़ाइलों *(दस्तावेज़, चित्र, वीडियो और **50+ फ़ाइल एक्सटेंशन** का समर्थन)* से सामग्री को अपने व्यक्तिगत ज्ञान आधार में सहेजें।
### 🔍 **शक्तिशाली खोज**
- अपनी सहेजी गई सामग्री में कुछ भी तुरंत खोजें या शोध करें।
### 💬 **अपनी सहेजी गई सामग्री के साथ चैट करें**
- प्राकृतिक भाषा में बातचीत करें और उद्धृत उत्तर प्राप्त करें।
### 📄 **उद्धृत उत्तर**
- Perplexity की तरह उद्धृत उत्तर प्राप्त करें।
### 🧩 **सार्वभौमिक अनुकूलता**
- OpenAI स्पेक और LiteLLM के माध्यम से लगभग किसी भी इंफ्रेंस प्रदाता को कनेक्ट करें।
### 🔔 **गोपनीयता और स्थानीय LLM सपोर्ट**
- vLLM और Ollama जैसे स्थानीय LLMs के साथ बेहतरीन काम करता है।
### 🏠 **सेल्फ-होस्ट करने योग्य**
- ओपन सोर्स और स्थानीय रूप से तैनात करना आसान।
### 👥 **RBAC के साथ टीम सहयोग**
- सर्च स्पेस के लिए भूमिका-आधारित एक्सेस नियंत्रण
- अनुकूलन योग्य भूमिकाओं (मालिक, एडमिन, संपादक, दर्शक) के साथ टीम सदस्यों को आमंत्रित करें
- दस्तावेज़ों, चैट, कनेक्टर और सेटिंग्स के लिए विस्तृत अनुमतियां
- अपने संगठन के भीतर सुरक्षित रूप से ज्ञान आधार साझा करें
- टीम चैट रीयल-टाइम में अपडेट होते हैं और कमेंट थ्रेड में "चैट के बारे में चैट" करें
### 🎙️ पॉडकास्ट
- अत्यंत तेज़ पॉडकास्ट जनरेशन एजेंट। (20 सेकंड से कम में 3 मिनट का पॉडकास्ट बनाता है।)
- अपनी चैट बातचीत को आकर्षक ऑडियो सामग्री में बदलें
- स्थानीय TTS प्रदाताओं का समर्थन (Kokoro TTS)
- कई TTS प्रदाताओं का समर्थन (OpenAI, Azure, Google Vertex AI)
### 🤖 **डीप एजेंट आर्किटेक्चर**
- [LangChain Deep Agents](https://docs.langchain.com/oss/python/deepagents/overview) द्वारा संचालित - ऐसे एजेंट जो योजना बना सकते हैं, सब-एजेंट का उपयोग कर सकते हैं, और जटिल कार्यों के लिए फ़ाइल सिस्टम का लाभ उठा सकते हैं।
### 📊 **उन्नत RAG तकनीकें**
- 100+ LLMs का समर्थन
- 6000+ एम्बेडिंग मॉडल का समर्थन
- सभी प्रमुख रीरैंकर्स का समर्थन (Pinecone, Cohere, Flashrank आदि)
- हायरार्किकल इंडाइसेस का उपयोग (2 स्तरीय RAG सेटअप)
- हाइब्रिड सर्च का उपयोग (सिमैंटिक + फुल टेक्स्ट सर्च, Reciprocal Rank Fusion के साथ)
### **बाहरी स्रोत**
- सर्च इंजन (Tavily, LinkUp)
- SearxNG (सेल्फ-होस्टेड इंस्टेंस)
- Google Drive
- Slack
- Microsoft Teams
- Linear
- Jira
- ClickUp
- Confluence
- BookStack
- Notion
- Gmail
- YouTube वीडियो
- GitHub
- Discord
- Airtable
- Google Calendar
- Luma
- Circleback
- Elasticsearch
- Obsidian
- और भी बहुत कुछ आने वाला है.....
## 📄 **समर्थित फ़ाइल एक्सटेंशन**
| ETL सेवा | फ़ॉर्मेट | नोट्स |
|-----------|----------|-------|
| **LlamaCloud** | 50+ फ़ॉर्मेट | दस्तावेज़, प्रस्तुतियां, स्प्रेडशीट, चित्र |
| **Unstructured** | 34+ फ़ॉर्मेट | मुख्य फ़ॉर्मेट + ईमेल समर्थन |
| **Docling** | मुख्य फ़ॉर्मेट | स्थानीय प्रोसेसिंग, API कुंजी की आवश्यकता नहीं |
**ऑडियो/वीडियो** (STT सेवा के माध्यम से): `.mp3`, `.wav`, `.mp4`, `.webm`, आदि।
### 🔖 क्रॉस-ब्राउज़र एक्सटेंशन
- SurfSense एक्सटेंशन का उपयोग किसी भी वेबपेज को सहेजने के लिए किया जा सकता है।
- इसका मुख्य उपयोग प्रमाणीकरण द्वारा संरक्षित वेबपेजों को सहेजना है।
## फ़ीचर अनुरोध और भविष्य
**SurfSense सक्रिय रूप से विकसित किया जा रहा है।** हालांकि यह अभी प्रोडक्शन-रेडी नहीं है, आप प्रक्रिया को तेज़ करने में हमारी मदद कर सकते हैं।
[SurfSense Discord](https://discord.gg/ejRNvftDp9) में शामिल हों और SurfSense के भविष्य को आकार देने में मदद करें!
## 🚀 रोडमैप
हमारे विकास की प्रगति और आने वाली सुविधाओं से अपडेट रहें!
हमारा सार्वजनिक रोडमैप देखें और अपने विचार या फ़ीडबैक दें:
**📋 रोडमैप चर्चा:** [SurfSense 2025-2026 Roadmap: Deep Agents, Real-Time Collaboration & MCP Servers](https://github.com/MODSetter/SurfSense/discussions/565)
**📊 कानबन बोर्ड:** [SurfSense Project Board](https://github.com/users/MODSetter/projects/3)
## कैसे शुरू करें?
### Docker के साथ त्वरित शुरुआत 🐳
> [!TIP]
> प्रोडक्शन डिप्लॉयमेंट के लिए, पूर्ण [Docker Compose सेटअप](https://www.surfsense.com/docs/docker-installation) का उपयोग करें जो अधिक नियंत्रण और स्केलेबिलिटी प्रदान करता है।
**Linux/macOS:**
```bash
docker run -d -p 3000:3000 -p 8000:8000 -p 5133:5133 \
-v surfsense-data:/data \
--name surfsense \
--restart unless-stopped \
ghcr.io/modsetter/surfsense:latest
```
**Windows (PowerShell):**
```powershell
docker run -d -p 3000:3000 -p 8000:8000 -p 5133:5133 `
-v surfsense-data:/data `
--name surfsense `
--restart unless-stopped `
ghcr.io/modsetter/surfsense:latest
```
**कस्टम कॉन्फ़िगरेशन के साथ:**
आप `-e` फ़्लैग का उपयोग करके कोई भी एन्वायरनमेंट वेरिएबल पास कर सकते हैं:
```bash
docker run -d -p 3000:3000 -p 8000:8000 -p 5133:5133 \
-v surfsense-data:/data \
-e EMBEDDING_MODEL=openai://text-embedding-ada-002 \
-e OPENAI_API_KEY=your_openai_api_key \
-e AUTH_TYPE=GOOGLE \
-e GOOGLE_OAUTH_CLIENT_ID=your_google_client_id \
-e GOOGLE_OAUTH_CLIENT_SECRET=your_google_client_secret \
-e ETL_SERVICE=LLAMACLOUD \
-e LLAMA_CLOUD_API_KEY=your_llama_cloud_key \
--name surfsense \
--restart unless-stopped \
ghcr.io/modsetter/surfsense:latest
```
> [!NOTE]
> - यदि HTTPS के साथ रिवर्स प्रॉक्सी के पीछे डिप्लॉय कर रहे हैं, तो `-e BACKEND_URL=https://api.yourdomain.com` जोड़ें
शुरू करने के बाद, SurfSense तक पहुंचें:
- **फ्रंटएंड**: [http://localhost:3000](http://localhost:3000)
- **बैकएंड API**: [http://localhost:8000](http://localhost:8000)
- **API डॉक्स**: [http://localhost:8000/docs](http://localhost:8000/docs)
- **Electric-SQL**: [http://localhost:5133](http://localhost:5133)
**उपयोगी कमांड:**
```bash
docker logs -f surfsense # लॉग देखें
docker stop surfsense # रोकें
docker start surfsense # शुरू करें
docker rm surfsense # हटाएं (डेटा वॉल्यूम में सुरक्षित रहता है)
```
### इंस्टॉलेशन विकल्प
SurfSense शुरू करने के लिए कई विकल्प प्रदान करता है:
1. **[SurfSense Cloud](https://www.surfsense.com/login)** - बिना किसी सेटअप के SurfSense आज़माने का सबसे आसान तरीका।
- इंस्टॉलेशन की आवश्यकता नहीं
- सभी सुविधाओं तक तत्काल पहुंच
- जल्दी शुरू करने के लिए बिल्कुल सही
2. **Docker त्वरित शुरुआत (ऊपर)** - एक ही कमांड से SurfSense को स्थानीय रूप से चलाएं।
- PostgreSQL, Redis और सभी सेवाओं के साथ ऑल-इन-वन इमेज
- मूल्यांकन, विकास और छोटे डिप्लॉयमेंट के लिए बिल्कुल सही
- Docker वॉल्यूम के माध्यम से डेटा पर्सिस्ट
3. **[Docker Compose (प्रोडक्शन)](https://www.surfsense.com/docs/docker-installation)** - अलग-अलग सेवाओं के साथ पूर्ण स्टैक डिप्लॉयमेंट।
- वेब UI के माध्यम से डेटाबेस प्रबंधन के लिए pgAdmin शामिल
- `.env` फ़ाइल के माध्यम से एन्वायरनमेंट वेरिएबल कस्टमाइज़ेशन का समर्थन
- लचीले डिप्लॉयमेंट विकल्प (पूर्ण स्टैक या केवल मुख्य सेवाएं)
- सेवाओं के स्वतंत्र स्केलिंग के साथ प्रोडक्शन के लिए बेहतर
4. **[मैनुअल इंस्टॉलेशन](https://www.surfsense.com/docs/manual-installation)** - उन उपयोगकर्ताओं के लिए जो अपने सेटअप पर अधिक नियंत्रण चाहते हैं या अपने डिप्लॉयमेंट को कस्टमाइज़ करना चाहते हैं।
Docker और मैनुअल इंस्टॉलेशन गाइड में Windows, macOS और Linux के लिए विस्तृत OS-विशिष्ट निर्देश शामिल हैं।
सेल्फ-होस्टिंग इंस्टॉलेशन से पहले, [पूर्वापेक्षा सेटअप चरण](https://www.surfsense.com/docs/) पूरा करना सुनिश्चित करें, जिसमें शामिल हैं:
- प्रमाणीकरण सेटअप (वैकल्पिक - डिफ़ॉल्ट LOCAL प्रमाणीकरण)
- **फ़ाइल प्रोसेसिंग ETL सेवा** (वैकल्पिक - डिफ़ॉल्ट Docling):
- Docling (डिफ़ॉल्ट, स्थानीय प्रोसेसिंग, API कुंजी की आवश्यकता नहीं, PDF, Office दस्तावेज़, चित्र, HTML, CSV का समर्थन)
- Unstructured.io API कुंजी (34+ फ़ॉर्मेट का समर्थन)
- LlamaIndex API कुंजी (उन्नत पार्सिंग, 50+ फ़ॉर्मेट का समर्थन)
- आपके उपयोग के अनुसार अन्य API कुंजियां
## योगदान करें
योगदान का बहुत स्वागत है! योगदान एक ⭐ जितना छोटा हो सकता है या issues खोजना और बनाना भी।
बैकएंड की फ़ाइन-ट्यूनिंग हमेशा वांछित है।
विस्तृत योगदान दिशानिर्देशों के लिए, कृपया हमारी [CONTRIBUTING.md](CONTRIBUTING.md) फ़ाइल देखें।
## Star इतिहास
<a href="https://www.star-history.com/#MODSetter/SurfSense&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=MODSetter/SurfSense&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=MODSetter/SurfSense&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=MODSetter/SurfSense&type=Date" />
</picture>
</a>
---
---
<p align="center">
<img
src="https://github.com/user-attachments/assets/329c9bc2-6005-4aed-a629-700b5ae296b4"
alt="Catalyst Project"
width="200"
/>
</p>
---
---

View file

@ -13,7 +13,7 @@
<div align="center">
[English](README.md) | [简体中文](README.zh-CN.md)
[English](README.md) | [Español](README.es.md) | [Português](README.pt-BR.md) | [हिन्दी](README.hi.md) | [简体中文](README.zh-CN.md)
</div>
@ -233,103 +233,11 @@ Before self-hosting installation, make sure to complete the [prerequisite setup
- Other API keys as needed for your use case
## Tech Stack
### **BackEnd**
- **LiteLLM**: Universal LLM integration supporting 100+ models (OpenAI, Anthropic, Ollama, etc.)
- **FastAPI**: Modern, fast web framework for building APIs with Python
- **PostgreSQL with pgvector**: Database with vector search capabilities for similarity searches
- **SQLAlchemy**: SQL toolkit and ORM (Object-Relational Mapping) for database interactions
- **Alembic**: A database migrations tool for SQLAlchemy.
- **FastAPI Users**: Authentication and user management with JWT and OAuth support
- **Deep Agents**: Custom agent framework built on LangGraph for reasoning and acting AI agents with configurable tools
- **LangGraph**: Framework for developing stateful AI agents with conversation persistence
- **LangChain**: Framework for developing AI-powered applications.
- **Rerankers**: Advanced result ranking for improved search relevance
- **Hybrid Search**: Combines vector similarity and full-text search for optimal results using Reciprocal Rank Fusion (RRF)
- **Vector Embeddings**: Document and text embeddings for semantic search
- **pgvector**: PostgreSQL extension for efficient vector similarity operations
- **Redis**: In-memory data structure store used as message broker and result backend for Celery
- **Celery**: Distributed task queue for handling asynchronous background jobs (document processing, podcast generation, etc.)
- **Flower**: Real-time monitoring and administration tool for Celery task queues
- **Chonkie**: Advanced document chunking and embedding library
---
### **FrontEnd**
- **Next.js**: React framework featuring App Router, server components, automatic code-splitting, and optimized rendering.
- **React**: JavaScript library for building user interfaces.
- **TypeScript**: Static type-checking for JavaScript, enhancing code quality and developer experience.
- **Vercel AI SDK Kit UI Stream Protocol**: To create scalable chat UI.
- **Tailwind CSS**: Utility-first CSS framework for building custom UI designs.
- **Shadcn**: Headless components library.
- **Motion (Framer Motion)**: Animation library for React.
### **DevOps**
- **Docker**: Container platform for consistent deployment across environments
- **Docker Compose**: Tool for defining and running multi-container Docker applications
- **pgAdmin**: Web-based PostgreSQL administration tool included in Docker setup
### **Extension**
Manifest v3 on Plasmo
## Contribute
Contributions are very welcome! A contribution can be as small as a ⭐ or even finding and creating issues.
Fine-tuning the Backend is always desired.
### Adding New Agent Tools
Want to add a new tool to the SurfSense agent? It's easy:
1. Create your tool file in `surfsense_backend/app/agents/new_chat/tools/my_tool.py`
2. Register it in `registry.py`:
```python
ToolDefinition(
name="my_tool",
description="What my tool does",
factory=lambda deps: create_my_tool(
search_space_id=deps["search_space_id"],
db_session=deps["db_session"],
),
requires=["search_space_id", "db_session"],
),
```
For detailed contribution guidelines, please see our [CONTRIBUTING.md](CONTRIBUTING.md) file.
## Star History

264
README.pt-BR.md Normal file
View file

@ -0,0 +1,264 @@
<a href="https://www.surfsense.com/"><img width="1584" height="396" alt="readme_banner" src="https://github.com/user-attachments/assets/9361ef58-1753-4b6e-b275-5020d8847261" /></a>
<div align="center">
<a href="https://discord.gg/ejRNvftDp9">
<img src="https://img.shields.io/discord/1359368468260192417" alt="Discord">
</a>
<a href="https://www.reddit.com/r/SurfSense/">
<img src="https://img.shields.io/reddit/subreddit-subscribers/SurfSense?style=social" alt="Reddit">
</a>
</div>
<div align="center">
[English](README.md) | [Español](README.es.md) | [Português](README.pt-BR.md) | [हिन्दी](README.hi.md) | [简体中文](README.zh-CN.md)
</div>
# SurfSense
Conecte qualquer LLM às suas fontes de conhecimento internas e converse com ele em tempo real junto com sua equipe. Alternativa de código aberto ao NotebookLM, Perplexity e Glean.
SurfSense é um agente de pesquisa de IA altamente personalizável, conectado a fontes externas como mecanismos de busca (SearxNG, Tavily, LinkUp), Google Drive, Slack, Microsoft Teams, Linear, Jira, ClickUp, Confluence, BookStack, Gmail, Notion, YouTube, GitHub, Discord, Airtable, Google Calendar, Luma, Circleback, Elasticsearch, Obsidian e mais por vir.
<div align="center">
<a href="https://trendshift.io/repositories/13606" target="_blank"><img src="https://trendshift.io/api/badge/repositories/13606" alt="MODSetter%2FSurfSense | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</div>
# Vídeo
https://github.com/user-attachments/assets/cc0c84d3-1f2f-4f7a-b519-2ecce22310b1
## Exemplo de Podcast
https://github.com/user-attachments/assets/a0a16566-6967-4374-ac51-9b3e07fbecd7
## Funcionalidades Principais
### 💡 **Ideia**:
- Alternativa de código aberto ao NotebookLM, Perplexity e Glean. Conecte qualquer LLM às suas fontes de conhecimento internas e colabore com sua equipe em tempo real.
### 📁 **Suporte a Múltiplos Formatos de Arquivo**
- Salve conteúdo dos seus arquivos pessoais *(Documentos, imagens, vídeos e suporta **mais de 50 extensões de arquivo**)* na sua própria base de conhecimento pessoal.
### 🔍 **Pesquisa Poderosa**
- Pesquise ou encontre rapidamente qualquer coisa no seu conteúdo salvo.
### 💬 **Converse com seu Conteúdo Salvo**
- Interaja em linguagem natural e obtenha respostas com citações.
### 📄 **Respostas com Citações**
- Obtenha respostas com citações como no Perplexity.
### 🧩 **Compatibilidade Universal**
- Conecte virtualmente qualquer provedor de inferência via especificação OpenAI e LiteLLM.
### 🔔 **Privacidade e Suporte a LLM Local**
- Funciona perfeitamente com LLMs locais como vLLM e Ollama.
### 🏠 **Auto-Hospedável**
- Código aberto e fácil de implantar localmente.
### 👥 **Colaboração em Equipe com RBAC**
- Controle de acesso baseado em funções para Espaços de Pesquisa
- Convide membros da equipe com funções personalizáveis (Proprietário, Admin, Editor, Visualizador)
- Permissões granulares para documentos, chats, conectores e configurações
- Compartilhe bases de conhecimento com segurança dentro da sua organização
- Chats de equipe atualizam em tempo real e "Converse sobre o chat" em threads de comentários
### 🎙️ Podcasts
- Agente de geração de podcasts ultrarrápido. (Cria um podcast de 3 minutos em menos de 20 segundos.)
- Converta suas conversas de chat em conteúdo de áudio envolvente
- Suporte para provedores TTS locais (Kokoro TTS)
- Suporte para múltiplos provedores TTS (OpenAI, Azure, Google Vertex AI)
### 🤖 **Arquitetura de Agentes Profundos**
- Alimentado por [LangChain Deep Agents](https://docs.langchain.com/oss/python/deepagents/overview) - agentes que podem planejar, usar subagentes e aproveitar sistemas de arquivos para tarefas complexas.
### 📊 **Técnicas Avançadas de RAG**
- Suporta mais de 100 LLMs
- Suporta mais de 6000 modelos de embeddings
- Suporta todos os principais rerankers (Pinecone, Cohere, Flashrank, etc.)
- Utiliza índices hierárquicos (configuração RAG de 2 níveis)
- Utiliza busca híbrida (Semântica + Texto completo combinado com Reciprocal Rank Fusion)
### **Fontes Externas**
- Mecanismos de busca (Tavily, LinkUp)
- SearxNG (instâncias auto-hospedadas)
- Google Drive
- Slack
- Microsoft Teams
- Linear
- Jira
- ClickUp
- Confluence
- BookStack
- Notion
- Gmail
- Vídeos do YouTube
- GitHub
- Discord
- Airtable
- Google Calendar
- Luma
- Circleback
- Elasticsearch
- Obsidian
- e mais por vir.....
## 📄 **Extensões de Arquivo Suportadas**
| Serviço ETL | Formatos | Notas |
|-------------|----------|-------|
| **LlamaCloud** | 50+ formatos | Documentos, apresentações, planilhas, imagens |
| **Unstructured** | 34+ formatos | Formatos principais + suporte a e-mail |
| **Docling** | Formatos principais | Processamento local, sem necessidade de chave API |
**Áudio/Vídeo** (via serviço STT): `.mp3`, `.wav`, `.mp4`, `.webm`, etc.
### 🔖 Extensão Multi-Navegador
- A extensão do SurfSense pode ser usada para salvar qualquer página web que você desejar.
- Seu principal uso é salvar páginas web protegidas por autenticação.
## SOLICITAÇÕES DE FUNCIONALIDADES E FUTURO
**O SurfSense está em desenvolvimento ativo.** Embora ainda não esteja pronto para produção, você pode nos ajudar a acelerar o processo.
Junte-se ao [Discord do SurfSense](https://discord.gg/ejRNvftDp9) e ajude a moldar o futuro do SurfSense!
## 🚀 Roadmap
Fique atualizado com nosso progresso de desenvolvimento e próximas funcionalidades!
Confira nosso roadmap público e contribua com suas ideias ou feedback:
**📋 Discussão do Roadmap:** [SurfSense 2025-2026 Roadmap: Deep Agents, Real-Time Collaboration & MCP Servers](https://github.com/MODSetter/SurfSense/discussions/565)
**📊 Quadro Kanban:** [SurfSense Project Board](https://github.com/users/MODSetter/projects/3)
## Como começar?
### Início Rápido com Docker 🐳
> [!TIP]
> Para implantações em produção, use a configuração completa do [Docker Compose](https://www.surfsense.com/docs/docker-installation) que oferece mais controle e escalabilidade.
**Linux/macOS:**
```bash
docker run -d -p 3000:3000 -p 8000:8000 -p 5133:5133 \
-v surfsense-data:/data \
--name surfsense \
--restart unless-stopped \
ghcr.io/modsetter/surfsense:latest
```
**Windows (PowerShell):**
```powershell
docker run -d -p 3000:3000 -p 8000:8000 -p 5133:5133 `
-v surfsense-data:/data `
--name surfsense `
--restart unless-stopped `
ghcr.io/modsetter/surfsense:latest
```
**Com Configuração Personalizada:**
Você pode passar qualquer variável de ambiente usando flags `-e`:
```bash
docker run -d -p 3000:3000 -p 8000:8000 -p 5133:5133 \
-v surfsense-data:/data \
-e EMBEDDING_MODEL=openai://text-embedding-ada-002 \
-e OPENAI_API_KEY=your_openai_api_key \
-e AUTH_TYPE=GOOGLE \
-e GOOGLE_OAUTH_CLIENT_ID=your_google_client_id \
-e GOOGLE_OAUTH_CLIENT_SECRET=your_google_client_secret \
-e ETL_SERVICE=LLAMACLOUD \
-e LLAMA_CLOUD_API_KEY=your_llama_cloud_key \
--name surfsense \
--restart unless-stopped \
ghcr.io/modsetter/surfsense:latest
```
> [!NOTE]
> - Se estiver implantando atrás de um proxy reverso com HTTPS, adicione `-e BACKEND_URL=https://api.yourdomain.com`
Após iniciar, acesse o SurfSense em:
- **Frontend**: [http://localhost:3000](http://localhost:3000)
- **API Backend**: [http://localhost:8000](http://localhost:8000)
- **Documentação da API**: [http://localhost:8000/docs](http://localhost:8000/docs)
- **Electric-SQL**: [http://localhost:5133](http://localhost:5133)
**Comandos Úteis:**
```bash
docker logs -f surfsense # Ver logs
docker stop surfsense # Parar
docker start surfsense # Iniciar
docker rm surfsense # Remover (dados preservados no volume)
```
### Opções de Instalação
O SurfSense oferece múltiplas opções para começar:
1. **[SurfSense Cloud](https://www.surfsense.com/login)** - A forma mais fácil de experimentar o SurfSense sem nenhuma configuração.
- Sem necessidade de instalação
- Acesso instantâneo a todas as funcionalidades
- Perfeito para começar rapidamente
2. **Início Rápido Docker (Acima)** - Um único comando para ter o SurfSense rodando localmente.
- Imagem tudo-em-um com PostgreSQL, Redis e todos os serviços incluídos
- Perfeito para avaliação, desenvolvimento e implantações pequenas
- Dados persistidos via volume Docker
3. **[Docker Compose (Produção)](https://www.surfsense.com/docs/docker-installation)** - Implantação de stack completo com serviços separados.
- Inclui pgAdmin para gerenciamento de banco de dados via interface web
- Suporta personalização de variáveis de ambiente via arquivo `.env`
- Opções de implantação flexíveis (stack completo ou apenas serviços principais)
- Melhor para produção com escalamento independente de serviços
4. **[Instalação Manual](https://www.surfsense.com/docs/manual-installation)** - Para usuários que preferem mais controle sobre sua configuração ou precisam personalizar sua implantação.
Os guias de Docker e instalação manual incluem instruções detalhadas específicas para Windows, macOS e Linux.
Antes da instalação auto-hospedada, certifique-se de completar os [passos de configuração prévia](https://www.surfsense.com/docs/) incluindo:
- Configuração de autenticação (opcional - padrão é autenticação LOCAL)
- **Serviço ETL de Processamento de Arquivos** (opcional - padrão é Docling):
- Docling (padrão, processamento local, sem necessidade de chave API, suporta PDF, documentos Office, imagens, HTML, CSV)
- Chave API do Unstructured.io (suporta 34+ formatos)
- Chave API do LlamaIndex (análise aprimorada, suporta 50+ formatos)
- Outras chaves API conforme necessário para seu caso de uso
## Contribuir
Contribuições são muito bem-vindas! Uma contribuição pode ser tão pequena quanto uma ⭐ ou até mesmo encontrar e criar issues.
O ajuste fino do Backend é sempre desejado.
Para diretrizes detalhadas de contribuição, consulte nosso arquivo [CONTRIBUTING.md](CONTRIBUTING.md).
## Histórico de Stars
<a href="https://www.star-history.com/#MODSetter/SurfSense&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=MODSetter/SurfSense&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=MODSetter/SurfSense&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=MODSetter/SurfSense&type=Date" />
</picture>
</a>
---
---
<p align="center">
<img
src="https://github.com/user-attachments/assets/329c9bc2-6005-4aed-a629-700b5ae296b4"
alt="Catalyst Project"
width="200"
/>
</p>
---
---

View file

@ -1,5 +1,5 @@
<a href="https://www.surfsense.com/"><img width="1584" height="396" alt="readme_banner" src="https://github.com/user-attachments/assets/9361ef58-1753-4b6e-b275-5020d8847261" /></a>
![new_header](https://github.com/user-attachments/assets/e236b764-0ddc-42ff-a1f1-8fbb3d2e0e65)
<div align="center">
@ -13,12 +13,11 @@
<div align="center">
[English](README.md) | [简体中文](README.zh-CN.md)
[English](README.md) | [Español](README.es.md) | [Português](README.pt-BR.md) | [हिन्दी](README.hi.md) | [简体中文](README.zh-CN.md)
</div>
# SurfSense
将任何 LLM 连接到您的内部知识源并与团队成员实时聊天。NotebookLM、Perplexity 和 Glean 的开源替代方案。
SurfSense 是一个高度可定制的 AI 研究助手可以连接外部数据源如搜索引擎SearxNG、Tavily、LinkUp、Google Drive、Slack、Microsoft Teams、Linear、Jira、ClickUp、Confluence、BookStack、Gmail、Notion、YouTube、GitHub、Discord、Airtable、Google Calendar、Luma、Circleback、Elasticsearch、Obsidian 等,未来还会支持更多。
@ -30,7 +29,7 @@ SurfSense 是一个高度可定制的 AI 研究助手,可以连接外部数据
# 视频演示
https://github.com/user-attachments/assets/42a29ea1-d4d8-4213-9c69-972b5b806d58
https://github.com/user-attachments/assets/cc0c84d3-1f2f-4f7a-b519-2ecce22310b1
## 播客示例
@ -44,32 +43,27 @@ https://github.com/user-attachments/assets/a0a16566-6967-4374-ac51-9b3e07fbecd7
### 💡 **理念**:
- NotebookLM、Perplexity 和 Glean 的开源替代方案。将任何 LLM 连接到您的内部知识源,并与团队实时协作。
### 📁 **支持多种文件格式上传**
- 将您个人文件中的内容(文档、图像、视频,支持 **50+ 种文件扩展名**)保存到您自己的个人知识库。
### 🔍 **强大的搜索功能**
- 快速研究或查找已保存内容中的任何信息。
### 💬 **与已保存内容对话**
- 使用自然语言交互并获得引用答案。
### 📄 **引用答案**
- 像 Perplexity 一样获得带引用的答案。
### 🧩 **通用兼容性**
- 通过 OpenAI 规范和 LiteLLM 连接几乎任何推理提供商。
### 🔔 **隐私保护与本地 LLM 支持**
- 完美支持 Ollama 本地大语言模型。
- 完美支持 vLLM 和 Ollama 等本地大语言模型。
### 🏠 **可自托管**
- 开源且易于本地部署。
### 👥 **团队协作与 RBAC**
- 搜索空间的基于角色的访问控制
- 使用可自定义的角色(所有者、管理员、编辑者、查看者)邀请团队成员
- 对文档、聊天、连接器和设置的细粒度权限控制
- 在组织内安全共享知识库
### 🎙️ **播客功能**
- 团队聊天实时更新,支持评论线程中的"关于聊天的讨论"
### 🎙️ 播客功能
- 超快速播客生成代理(在 20 秒内创建 3 分钟播客)
- 将聊天对话转换为引人入胜的音频内容
- 支持本地 TTS 提供商Kokoro TTS
@ -119,14 +113,15 @@ https://github.com/user-attachments/assets/a0a16566-6967-4374-ac51-9b3e07fbecd7
**音频/视频**(通过 STT 服务):`.mp3``.wav``.mp4``.webm`
### 🔖 **跨浏览器扩展**
- SurfSense 扩展可用于保存您喜欢的任何网页
- 主要用途是保存需要身份验证的受保护网页
### 🔖 跨浏览器扩展
- SurfSense 扩展可用于保存您喜欢的任何网页
- 主要用途是保存需要身份验证的受保护网页
## 功能请求与未来规划
**SurfSense 正在积极开发中。** 虽然它还未达到生产就绪状态,但您可以帮助我们加快进度。
加入 [SurfSense Discord](https://discord.gg/ejRNvftDp9) 一起塑造 SurfSense 的未来!
@ -238,102 +233,11 @@ Docker 和手动安装指南都包含适用于 Windows、macOS 和 Linux 的详
- 其他根据用例需要的 API 密钥
## 技术栈
### **后端**
- **FastAPI**:现代、快速的 Python Web 框架,用于构建 API
- **PostgreSQL with pgvector**:具有向量搜索功能的数据库,用于相似性搜索
- **SQLAlchemy**SQL 工具包和 ORM对象关系映射用于数据库交互
- **Alembic**SQLAlchemy 的数据库迁移工具
- **FastAPI Users**:使用 JWT 和 OAuth 支持的身份验证和用户管理
- **深度代理**:基于 LangGraph 构建的自定义代理框架,用于推理和行动的 AI 代理,支持可配置工具
- **LangGraph**:用于开发具有对话持久性的有状态 AI 代理的框架
- **LangChain**:用于开发 AI 驱动应用程序的框架
- **LiteLLM**:通用 LLM 集成,支持 100+ 种模型OpenAI、Anthropic、Ollama 等)
- **Rerankers**:先进的结果排序,提高搜索相关性
- **混合搜索**:结合向量相似性和全文搜索,使用倒数排名融合 (RRF) 获得最佳结果
- **向量嵌入**:文档和文本嵌入,用于语义搜索
- **pgvector**PostgreSQL 扩展,用于高效的向量相似性操作
- **Redis**:内存数据结构存储,用作 Celery 的消息代理和结果后端
- **Celery**:分布式任务队列,用于处理异步后台任务(文档处理、播客生成等)
- **Flower**Celery 任务队列的实时监控和管理工具
- **Chonkie**:先进的文档分块和嵌入库
---
### **前端**
- **Next.js**React 框架,具有应用路由器、服务器组件、自动代码拆分和优化渲染功能
- **React**:用于构建用户界面的 JavaScript 库
- **TypeScript**JavaScript 的静态类型检查,提升代码质量和开发体验
- **Vercel AI SDK Kit UI Stream Protocol**:创建可扩展的聊天 UI
- **Tailwind CSS**:实用优先的 CSS 框架,用于构建自定义 UI 设计
- **Shadcn**:无头组件库
- **MotionFramer Motion**React 动画库
### **DevOps**
- **Docker**:容器平台,用于跨环境的一致部署
- **Docker Compose**:用于定义和运行多容器 Docker 应用程序的工具
- **pgAdmin**Docker 设置中包含的基于 Web 的 PostgreSQL 管理工具
### **扩展**
基于 Plasmo 的 Manifest v3
## 贡献
非常欢迎贡献!贡献可以小到一个 ⭐,甚至是发现和创建问题。
后端的微调总是受欢迎的。
### 添加新的代理工具
想要为 SurfSense 代理添加新工具?非常简单:
1. 在 `surfsense_backend/app/agents/new_chat/tools/my_tool.py` 中创建您的工具文件
2. 在 `registry.py` 中注册:
```python
ToolDefinition(
name="my_tool",
description="What my tool does",
factory=lambda deps: create_my_tool(
search_space_id=deps["search_space_id"],
db_session=deps["db_session"],
),
requires=["search_space_id", "db_session"],
),
```
有关详细的贡献指南,请参阅我们的 [CONTRIBUTING.md](CONTRIBUTING.md) 文件。
## Star 历史

View file

@ -22,6 +22,23 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
git \
&& rm -rf /var/lib/apt/lists/*
# Install Pandoc 3.x from GitHub (apt ships 2.17 which has broken table rendering).
RUN ARCH=$(dpkg --print-architecture) && \
wget -qO /tmp/pandoc.deb "https://github.com/jgm/pandoc/releases/download/3.9/pandoc-3.9-1-${ARCH}.deb" && \
dpkg -i /tmp/pandoc.deb && \
rm /tmp/pandoc.deb
# Install Typst for PDF rendering (Typst has built-in professional styling
# for tables, headings, code blocks, etc., no CSS needed).
RUN ARCH=$(dpkg --print-architecture) && \
if [ "$ARCH" = "amd64" ]; then TYPST_ARCH="x86_64-unknown-linux-musl"; \
else TYPST_ARCH="aarch64-unknown-linux-musl"; fi && \
wget -qO /tmp/typst.tar.xz "https://github.com/typst/typst/releases/download/v0.14.2/typst-${TYPST_ARCH}.tar.xz" && \
tar -xf /tmp/typst.tar.xz -C /tmp && \
cp /tmp/typst-*/typst /usr/local/bin/typst && \
rm -rf /tmp/typst* && \
typst --version
# Update certificates and install SSL tools
RUN update-ca-certificates
RUN pip install --upgrade certifi pip-system-certs

View file

@ -0,0 +1,69 @@
"""Add report_group_id for report versioning
Revision ID: 100
Revises: 99
Create Date: 2026-02-11
Adds report_group_id column to reports table for grouping report versions.
Reports with the same report_group_id are versions of the same report.
For the first version (v1), report_group_id equals the report's own id.
Migration is idempotent safe to re-run.
"""
from collections.abc import Sequence
from alembic import op
revision: str = "100"
down_revision: str | None = "99"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# Add report_group_id column (idempotent)
op.execute(
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'reports' AND column_name = 'report_group_id'
) THEN
ALTER TABLE reports ADD COLUMN report_group_id INTEGER;
END IF;
END $$;
"""
)
# Backfill existing reports: set report_group_id = id (each is its own v1)
op.execute(
"""
UPDATE reports SET report_group_id = id WHERE report_group_id IS NULL;
"""
)
# Create index (idempotent)
op.execute(
"""
CREATE INDEX IF NOT EXISTS ix_reports_report_group_id
ON reports(report_group_id);
"""
)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_reports_report_group_id")
op.execute(
"""
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'reports' AND column_name = 'report_group_id'
) THEN
ALTER TABLE reports DROP COLUMN report_group_id;
END IF;
END $$;
"""
)

View file

@ -0,0 +1,66 @@
"""Add reports table
Revision ID: 99
Revises: 98
Create Date: 2026-02-11
Adds reports table for storing generated Markdown reports.
"""
from collections.abc import Sequence
from alembic import op
revision: str = "99"
down_revision: str | None = "98"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# Create the reports table
op.execute(
"""
CREATE TABLE IF NOT EXISTS reports (
id SERIAL PRIMARY KEY,
title VARCHAR(500) NOT NULL,
content TEXT,
report_metadata JSONB,
report_style VARCHAR(100),
search_space_id INTEGER NOT NULL
REFERENCES searchspaces(id) ON DELETE CASCADE,
thread_id INTEGER
REFERENCES new_chat_threads(id) ON DELETE SET NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
"""
)
# Add indexes
op.execute(
"""
CREATE INDEX IF NOT EXISTS ix_reports_search_space_id
ON reports(search_space_id);
"""
)
op.execute(
"""
CREATE INDEX IF NOT EXISTS ix_reports_thread_id
ON reports(thread_id);
"""
)
op.execute(
"""
CREATE INDEX IF NOT EXISTS ix_reports_created_at
ON reports(created_at);
"""
)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_reports_created_at")
op.execute("DROP INDEX IF EXISTS ix_reports_thread_id")
op.execute("DROP INDEX IF EXISTS ix_reports_search_space_id")
op.execute("DROP TABLE IF EXISTS reports")

View file

@ -50,7 +50,7 @@ def _get_system_instructions(
return SURFSENSE_SYSTEM_INSTRUCTIONS.format(resolved_today=resolved_today)
# Tools 0-6 (common to both private and shared prompts)
# Tools 0-7 (common to both private and shared prompts)
_TOOLS_INSTRUCTIONS_COMMON = """
<tools>
You have access to the following tools:
@ -92,7 +92,44 @@ You have access to the following tools:
- IMPORTANT: Only one podcast can be generated at a time. If a podcast is already being generated, the tool will return status "already_generating".
- After calling this tool, inform the user that podcast generation has started and they will see the player when it's ready (takes 3-5 minutes).
3. link_preview: Fetch metadata for a URL to display a rich preview card.
3. generate_report: Generate a structured Markdown report from provided content.
- Use this when the user asks to create, generate, write, produce, draft, or summarize into a report-style deliverable.
- DECISION RULE (HIGH PRIORITY): If the user asks for a report in any form, call `generate_report` instead of writing the full report directly in chat.
- Only skip `generate_report` if the user explicitly asks for chat-only output (e.g., "just answer in chat", "no report card", "don't generate a report").
- Trigger classes include:
* Direct trigger words: report, document, memo, letter, template
* Creation-intent phrases: "write a document/report/post/article"
* File-intent words: requests containing "save", "file", or "document" when intent is to create a report-like deliverable
* Word-doc specific triggers: professional report-style deliverable, professional document, Word doc, .docx
* Other report-like output intents: one-pager, blog post, article, standalone written content, comprehensive guide
* General artifact-style intents: analysis / writing as substantial deliverables
- Trigger phrases include:
* "generate a report about", "write a report", "produce a report"
* "create a detailed report about", "make a research report on"
* "summarize this into a report", "turn this into a report"
* "write a report/document", "draft a report"
* "create an executive summary", "make a briefing note", "write a one-pager"
* "write a blog post", "write an article", "create a comprehensive guide"
* "create a small report", "write a short report", "make a quick report", "brief report for class"
- IMPORTANT FORMAT RULE: Reports are ALWAYS generated in Markdown.
- Args:
- topic: The main topic or title of the report
- source_content: The text content to base the report on. This MUST be comprehensive and include:
* If discussing the current conversation: Include a detailed summary of the FULL chat history (all user questions and your responses)
* If based on knowledge base search: Include the key findings and insights from the search results
* You can combine both: conversation context + search results for richer reports
* The more detailed the source_content, the better the report quality
- report_style: Optional style. Options: "detailed" (default), "executive_summary", "deep_research", "brief"
- user_instructions: Optional specific instructions (e.g., "focus on financial impacts", "include recommendations")
- Returns: A dictionary with status "ready" or "failed", report_id, title, and word_count.
- The report is generated immediately in Markdown and displayed inline in the chat.
- Export/download formats (e.g., PDF/DOCX) are produced from the generated Markdown report.
- SOURCE-COLLECTION RULE:
* If the user already provided enough source material (current chat content, uploaded files, pasted text, or a summarized video/article), generate the report directly from that.
* Use search_knowledge_base first when additional context is needed or the user asks for information beyond what is already available in the conversation.
- AFTER CALLING THIS TOOL: Do NOT repeat, summarize, or reproduce the report content in the chat. The report is already displayed as an interactive card that the user can open, read, copy, and export. Simply confirm that the report was generated (e.g., "I've generated your report on [topic]. You can view the Markdown report now, and export to PDF/DOCX from the card."). NEVER write out the report text in the chat.
4. link_preview: Fetch metadata for a URL to display a rich preview card.
- IMPORTANT: Use this tool WHENEVER the user shares or mentions a URL/link in their message.
- This fetches the page's Open Graph metadata (title, description, thumbnail) to show a preview card.
- NOTE: This tool only fetches metadata, NOT the full page content. It cannot read the article text.
@ -105,7 +142,7 @@ You have access to the following tools:
- Returns: A rich preview card with title, description, thumbnail, and domain
- The preview card will automatically be displayed in the chat.
4. display_image: Display an image in the chat with metadata.
5. display_image: Display an image in the chat with metadata.
- Use this tool ONLY when you have a valid public HTTP/HTTPS image URL to show.
- This displays the image with an optional title, description, and source attribution.
- Valid use cases:
@ -130,7 +167,7 @@ You have access to the following tools:
- Returns: An image card with the image, title, and description
- The image will automatically be displayed in the chat.
5. generate_image: Generate images from text descriptions using AI image models.
6. generate_image: Generate images from text descriptions using AI image models.
- Use this when the user asks you to create, generate, draw, design, or make an image.
- Trigger phrases: "generate an image of", "create a picture of", "draw me", "make an image", "design a logo", "create artwork"
- Args:
@ -144,7 +181,7 @@ You have access to the following tools:
expand and improve the prompt with specific details about style, lighting, composition, and mood.
- If the user's request is vague (e.g., "make me an image of a cat"), enhance the prompt with artistic details.
6. scrape_webpage: Scrape and extract the main content from a webpage.
7. scrape_webpage: Scrape and extract the main content from a webpage.
- Use this when the user wants you to READ and UNDERSTAND the actual content of a webpage.
- IMPORTANT: This is different from link_preview:
* link_preview: Only fetches metadata (title, description, thumbnail) for display
@ -169,9 +206,9 @@ You have access to the following tools:
"""
# Private (user) memory: tools 7-8 + memory-specific examples
# Private (user) memory: tools 8-9 + memory-specific examples
_TOOLS_INSTRUCTIONS_MEMORY_PRIVATE = """
7. save_memory: Save facts, preferences, or context for personalized responses.
8. save_memory: Save facts, preferences, or context for personalized responses.
- Use this when the user explicitly or implicitly shares information worth remembering.
- Trigger scenarios:
* User says "remember this", "keep this in mind", "note that", or similar
@ -194,7 +231,7 @@ _TOOLS_INSTRUCTIONS_MEMORY_PRIVATE = """
- IMPORTANT: Only save information that would be genuinely useful for future conversations.
Don't save trivial or temporary information.
8. recall_memory: Retrieve relevant memories about the user for personalized responses.
9. recall_memory: Retrieve relevant memories about the user for personalized responses.
- Use this to access stored information about the user.
- Trigger scenarios:
* You need user context to give a better, more personalized answer
@ -232,7 +269,7 @@ _TOOLS_INSTRUCTIONS_MEMORY_PRIVATE = """
# Shared (team) memory: tools 7-8 + team memory examples
_TOOLS_INSTRUCTIONS_MEMORY_SHARED = """
7. save_memory: Save a fact, preference, or context to the team's shared memory for future reference.
8. save_memory: Save a fact, preference, or context to the team's shared memory for future reference.
- Use this when the user or a team member says "remember this", "keep this in mind", or similar in this shared chat.
- Use when the team agrees on something to remember (e.g., decisions, conventions).
- Someone shares a preference or fact that should be visible to the whole team.
@ -247,7 +284,7 @@ _TOOLS_INSTRUCTIONS_MEMORY_SHARED = """
- Returns: Confirmation of saved memory; returned context may include who added it (added_by).
- IMPORTANT: Only save information that would be genuinely useful for future team conversations in this space.
8. recall_memory: Recall relevant team memories for this space to provide contextual responses.
9. recall_memory: Recall relevant team memories for this space to provide contextual responses.
- Use when you need team context to answer (e.g., "where do we store X?", "what did we decide about Y?").
- Use when someone asks about something the team agreed to remember.
- Use when team preferences or conventions would improve the response.
@ -321,6 +358,17 @@ _TOOLS_INSTRUCTIONS_EXAMPLES_COMMON = """
- First search: `search_knowledge_base(query="quantum computing")`
- Then: `generate_podcast(source_content="Key insights about quantum computing from the knowledge base:\\n\\n[Comprehensive summary of all relevant search results with key facts, concepts, and findings]", podcast_title="Quantum Computing Explained")`
- User: "Generate a report about AI trends"
- First search: `search_knowledge_base(query="AI trends")`
- Then: `generate_report(topic="AI Trends Report", source_content="Key insights about AI trends from the knowledge base:\\n\\n[Comprehensive summary of all relevant search results with key facts, concepts, and findings]", report_style="detailed")`
- User: "Write a research report from this conversation"
- Call: `generate_report(topic="Research Report", source_content="Complete conversation summary:\\n\\nUser asked about [topic 1]:\\n[Your detailed response]\\n\\nUser then asked about [topic 2]:\\n[Your detailed response]\\n\\n[Continue for all exchanges in the conversation]", report_style="deep_research")`
- User: "Create a brief executive summary about our project progress"
- First search: `search_knowledge_base(query="project progress updates")`
- Then: `generate_report(topic="Project Progress Executive Summary", source_content="[Combined search results and conversation context]", report_style="executive_summary", user_instructions="Focus on milestones achieved and upcoming deadlines")`
- User: "Check out https://dev.to/some-article"
- Call: `link_preview(url="https://dev.to/some-article")`
- Call: `scrape_webpage(url="https://dev.to/some-article")`

View file

@ -56,6 +56,7 @@ from .notion import (
create_update_notion_page_tool,
)
from .podcast import create_generate_podcast_tool
from .report import create_generate_report_tool
from .scrape_webpage import create_scrape_webpage_tool
from .search_surfsense_docs import create_search_surfsense_docs_tool
from .shared_memory import (
@ -123,6 +124,17 @@ BUILTIN_TOOLS: list[ToolDefinition] = [
),
requires=["search_space_id", "db_session", "thread_id"],
),
# Report generation tool (inline, no Celery)
ToolDefinition(
name="generate_report",
description="Generate a structured Markdown report from provided content",
factory=lambda deps: create_generate_report_tool(
search_space_id=deps["search_space_id"],
db_session=deps["db_session"],
thread_id=deps["thread_id"],
),
requires=["search_space_id", "db_session", "thread_id"],
),
# Link preview tool - fetches Open Graph metadata for URLs
ToolDefinition(
name="link_preview",

View file

@ -0,0 +1,396 @@
"""
Report generation tool for the SurfSense agent.
This module provides a factory function for creating the generate_report tool
that generates a structured Markdown report inline (no Celery). The LLM is
called within the tool, the result is saved to the database, and the tool
returns immediately with a ready status.
This follows the same inline pattern as generate_image and display_image,
NOT the Celery-based podcast pattern.
"""
import logging
import re
from typing import Any
from langchain_core.tools import tool
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import Report
from app.services.llm_service import get_document_summary_llm
logger = logging.getLogger(__name__)
# Prompt template for report generation (new report from scratch)
_REPORT_PROMPT = """You are an expert report writer. Generate a well-structured, comprehensive Markdown report based on the provided information.
**Topic:** {topic}
**Report Style:** {report_style}
{user_instructions_section}
{previous_version_section}
**Source Content:**
{source_content}
---
**Instructions:**
1. Write the report in well-formatted Markdown.
2. Include a clear title (as a level-1 heading), an executive summary, and logically organized sections.
3. Use headings (##, ###), bullet points, numbered lists, bold/italic text, and tables where appropriate.
4. Cite specific facts, figures, and findings from the source content.
5. Be thorough and comprehensive include all relevant information from the source content.
6. End with a conclusion or key takeaways section.
7. The report should be professional and ready to export.
8. When including code examples, ALWAYS format them as proper fenced code blocks with the correct language identifier (e.g. ```java, ```python). Code inside code blocks MUST have proper line breaks and indentation NEVER put multiple statements on a single line. Each statement, brace, and logical block must be on its own line with correct indentation.
9. When including Mermaid diagrams, use ```mermaid fenced code blocks. Each Mermaid statement MUST be on its own line NEVER use semicolons to join multiple statements on one line. For line breaks inside node labels, use <br> (NOT <br/>). Example:
```mermaid
graph TD
A[Source Code] --> B[Compiler]
B --> C[Bytecode]
```
Write the report now:
"""
def _strip_wrapping_code_fences(text: str) -> str:
"""Remove wrapping code fences that LLMs often add around Markdown output.
Handles patterns like:
```markdown\\n...content...\\n```
```md\\n...content...\\n```
```\\n...content...\\n```
"""
stripped = text.strip()
# Match opening fence with optional language tag (markdown, md, or bare)
m = re.match(r"^```(?:markdown|md)?\s*\n", stripped)
if m and stripped.endswith("```"):
stripped = stripped[m.end() :] # remove opening fence
stripped = stripped[:-3].rstrip() # remove closing fence
return stripped
def _extract_metadata(content: str) -> dict[str, Any]:
"""Extract metadata from generated Markdown content."""
# Count section headings
headings = re.findall(r"^(#{1,6})\s+(.+)$", content, re.MULTILINE)
# Word count
word_count = len(content.split())
# Character count
char_count = len(content)
return {
"status": "ready",
"word_count": word_count,
"char_count": char_count,
"section_count": len(headings),
}
def create_generate_report_tool(
search_space_id: int,
db_session: AsyncSession,
thread_id: int | None = None,
):
"""
Factory function to create the generate_report tool with injected dependencies.
The tool generates a Markdown report inline using the search space's
document summary LLM, saves it to the database, and returns immediately.
Args:
search_space_id: The user's search space ID
db_session: Database session for creating the report record
thread_id: The chat thread ID for associating the report
Returns:
A configured tool function for generating reports
"""
@tool
async def generate_report(
topic: str,
source_content: str,
report_style: str = "detailed",
user_instructions: str | None = None,
parent_report_id: int | None = None,
) -> dict[str, Any]:
"""
Generate a structured Markdown report from provided content.
Use this tool when the user asks to create, generate, write, produce, draft,
or summarize into a report-style deliverable.
HIGH-PRIORITY DECISION RULE:
- If the user asks for a report in any form,
call this tool rather than writing the full report directly in chat.
- Only skip this tool when the user explicitly requests chat-only output and
says they do not want a generated report card.
Trigger classes include:
- Direct trigger words: report, document, memo, letter, template
- Creation-intent phrases: "write a document/report/post/article"
- File-intent words: requests containing "save", "file", or "document" when
intent is to create a report-like deliverable
- Word-doc specific triggers: professional report-style deliverable,
professional document, Word doc, .docx
- Other report-like output intents: one-pager, blog post, article,
standalone written content, comprehensive guide
- General artifact-style intents: analysis / writing as substantial deliverables
Common triggers include phrases like:
- "Generate a report about this"
- "Write a report from this conversation"
- "Create a detailed report about..."
- "Make a research report on..."
- "Summarize this into a report"
- "Turn this into a report"
- "Write a report/document"
- "Draft a report"
- "Create an executive summary"
- "Make a briefing note"
- "Write a one-pager"
- "Write a blog post"
- "Write an article"
- "Create a comprehensive guide"
- "Prepare a report"
- "Create a small report"
- "Write a short report"
- "Make a quick report"
- "Brief report for class"
FORMAT/EXPORT RULE:
- Always generate the report content in Markdown.
- If the user requests DOCX/Word/PDF or another file format, export from
the generated Markdown report.
SOURCE-COLLECTION RULE:
- If enough source material is already present in the conversation (chat
history, pasted text, uploaded files, or a provided video/article summary),
generate directly from that source_content.
- Use knowledge-base search first only when extra context is needed beyond
what the user already provided.
VERSIONING parent_report_id:
- Set parent_report_id when the user wants to MODIFY, REVISE, IMPROVE,
UPDATE, EXPAND, or ADD CONTENT TO an existing report that was already
generated in this conversation.
- This includes both explicit AND implicit modification requests. If the
user references the existing report using words like "it", "this",
"here", "the report", or clearly refers to a previously generated
report, treat it as a revision request.
- The value must be the report_id from a previous generate_report
result in this same conversation.
- Do NOT set parent_report_id when:
* The user asks for a report on a completely NEW/DIFFERENT topic
* The user says "generate another report" (new report, not a revision)
* There is no prior report to reference
- When parent_report_id is set, the previous report's content will be
used as a base. Your user_instructions should describe WHAT TO CHANGE.
Examples of when to SET parent_report_id:
User: "Make that report shorter" parent_report_id = <previous report_id>
User: "Add a cost analysis section to the report" parent_report_id = <previous report_id>
User: "Rewrite the report in a more formal tone" parent_report_id = <previous report_id>
User: "I want more details about pricing in here" parent_report_id = <previous report_id>
User: "Include more examples" parent_report_id = <previous report_id>
User: "Can you also cover security in this?" parent_report_id = <previous report_id>
User: "Make it more detailed" parent_report_id = <previous report_id>
User: "I want more about X for in here" parent_report_id = <previous report_id>
Examples of when to LEAVE parent_report_id as None:
User: "Generate a report on climate change" parent_report_id = None (new topic)
User: "Write me a report about the budget" parent_report_id = None (new topic)
User: "Create another report, this time about marketing" parent_report_id = None
Args:
topic: A short, concise title for the report (maximum 8 words). Keep it brief and descriptive e.g. "AI in Healthcare Analysis: A Comprehensive Report" instead of "Comprehensive Analysis of Artificial Intelligence Applications in Modern Healthcare Systems".
source_content: The text content to base the report on. This MUST be comprehensive and include:
* If discussing the current conversation: a detailed summary of the FULL chat history
* If based on knowledge base search: the key findings and insights from search results
* You can combine both: conversation context + search results for richer reports
* The more detailed the source_content, the better the report quality
report_style: Style of the report. Options: "detailed", "executive_summary", "deep_research", "brief". Default: "detailed"
user_instructions: Optional specific instructions for the report (e.g., "focus on financial impacts", "include recommendations"). When revising an existing report (parent_report_id is set), this should describe the changes to make.
parent_report_id: Optional ID of a previously generated report to revise. When set, the new report is created as a new version in the same version group. The previous report's content is included as context for the LLM to refine.
Returns:
A dictionary containing:
- status: "ready" or "failed"
- report_id: The report ID
- title: The report title
- word_count: Number of words in the report
- message: Status message (or "error" field if failed)
"""
# Resolve the parent report and its group (if versioning)
parent_report: Report | None = None
report_group_id: int | None = None
if parent_report_id:
parent_report = await db_session.get(Report, parent_report_id)
if parent_report:
report_group_id = parent_report.report_group_id
logger.info(
f"[generate_report] Creating new version from parent {parent_report_id} "
f"(group {report_group_id})"
)
else:
logger.warning(
f"[generate_report] parent_report_id={parent_report_id} not found, "
"creating standalone report"
)
async def _save_failed_report(error_msg: str) -> int | None:
"""Persist a failed report row so the error is visible later."""
try:
failed_report = Report(
title=topic,
content=None,
report_metadata={
"status": "failed",
"error_message": error_msg,
},
report_style=report_style,
search_space_id=search_space_id,
thread_id=thread_id,
report_group_id=report_group_id,
)
db_session.add(failed_report)
await db_session.commit()
await db_session.refresh(failed_report)
# If this is a new group (v1 failed), set group to self
if not failed_report.report_group_id:
failed_report.report_group_id = failed_report.id
await db_session.commit()
logger.info(
f"[generate_report] Saved failed report {failed_report.id}: {error_msg}"
)
return failed_report.id
except Exception:
logger.exception(
"[generate_report] Could not persist failed report row"
)
return None
try:
# Get the LLM instance for this search space
llm = await get_document_summary_llm(db_session, search_space_id)
if not llm:
error_msg = (
"No LLM configured. Please configure a language model in Settings."
)
report_id = await _save_failed_report(error_msg)
return {
"status": "failed",
"error": error_msg,
"report_id": report_id,
"title": topic,
}
# Build the prompt
user_instructions_section = ""
if user_instructions:
user_instructions_section = (
f"**Additional Instructions:** {user_instructions}"
)
# If revising, include previous version content
previous_version_section = ""
if parent_report and parent_report.content:
previous_version_section = (
"**Previous Version of This Report (refine this based on the instructions above — "
"preserve structure and quality, apply only the requested changes):**\n\n"
f"{parent_report.content}"
)
prompt = _REPORT_PROMPT.format(
topic=topic,
report_style=report_style,
user_instructions_section=user_instructions_section,
previous_version_section=previous_version_section,
source_content=source_content[:100000], # Cap source content
)
# Call the LLM inline
from langchain_core.messages import HumanMessage
response = await llm.ainvoke([HumanMessage(content=prompt)])
report_content = response.content
if not report_content or not isinstance(report_content, str):
error_msg = "LLM returned empty or invalid content"
report_id = await _save_failed_report(error_msg)
return {
"status": "failed",
"error": error_msg,
"report_id": report_id,
"title": topic,
}
# LLMs often wrap output in ```markdown ... ``` fences — strip them
# so the stored content is clean Markdown.
report_content = _strip_wrapping_code_fences(report_content)
if not report_content:
error_msg = "LLM returned empty or invalid content"
report_id = await _save_failed_report(error_msg)
return {
"status": "failed",
"error": error_msg,
"report_id": report_id,
"title": topic,
}
# Extract metadata (includes "status": "ready")
metadata = _extract_metadata(report_content)
# Save to database
report = Report(
title=topic,
content=report_content,
report_metadata=metadata,
report_style=report_style,
search_space_id=search_space_id,
thread_id=thread_id,
report_group_id=report_group_id, # None for v1, inherited for v2+
)
db_session.add(report)
await db_session.commit()
await db_session.refresh(report)
# If this is a brand-new report (v1), set report_group_id = own id
if not report.report_group_id:
report.report_group_id = report.id
await db_session.commit()
logger.info(
f"[generate_report] Created report {report.id} "
f"(group={report.report_group_id}): "
f"{metadata.get('word_count', 0)} words, "
f"{metadata.get('section_count', 0)} sections"
)
return {
"status": "ready",
"report_id": report.id,
"title": topic,
"word_count": metadata.get("word_count", 0),
"message": f"Report generated successfully: {topic}",
}
except Exception as e:
error_message = str(e)
logger.exception(f"[generate_report] Error: {error_message}")
report_id = await _save_failed_report(error_message)
return {
"status": "failed",
"error": error_message,
"report_id": report_id,
"title": topic,
}
return generate_report

View file

@ -1031,6 +1031,36 @@ class Podcast(BaseModel, TimestampMixin):
thread = relationship("NewChatThread")
class Report(BaseModel, TimestampMixin):
"""Report model for storing generated Markdown reports."""
__tablename__ = "reports"
title = Column(String(500), nullable=False)
content = Column(Text, nullable=True) # Markdown body
report_metadata = Column(JSONB, nullable=True) # section headings, word count, etc.
report_style = Column(
String(100), nullable=True
) # e.g. "executive_summary", "deep_research"
search_space_id = Column(
Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False
)
search_space = relationship("SearchSpace", back_populates="reports")
# Versioning: reports sharing the same report_group_id are versions of the same report.
# For v1, report_group_id = the report's own id (set after insert).
report_group_id = Column(Integer, nullable=True, index=True)
thread_id = Column(
Integer,
ForeignKey("new_chat_threads.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
thread = relationship("NewChatThread")
class ImageGenerationConfig(BaseModel, TimestampMixin):
"""
Dedicated configuration table for image generation models.
@ -1185,6 +1215,12 @@ class SearchSpace(BaseModel, TimestampMixin):
order_by="Podcast.id.desc()",
cascade="all, delete-orphan",
)
reports = relationship(
"Report",
back_populates="search_space",
order_by="Report.id.desc()",
cascade="all, delete-orphan",
)
image_generations = relationship(
"ImageGeneration",
back_populates="search_space",

View file

@ -34,6 +34,7 @@ from .notion_add_connector_route import router as notion_add_connector_router
from .podcasts_routes import router as podcasts_router
from .public_chat_routes import router as public_chat_router
from .rbac_routes import router as rbac_router
from .reports_routes import router as reports_router
from .search_source_connectors_routes import router as search_source_connectors_router
from .search_spaces_routes import router as search_spaces_router
from .slack_add_connector_route import router as slack_add_connector_router
@ -50,6 +51,7 @@ router.include_router(notes_router)
router.include_router(new_chat_router) # Chat with assistant-ui persistence
router.include_router(chat_comments_router)
router.include_router(podcasts_router) # Podcast task status and audio
router.include_router(reports_router) # Report CRUD and export (PDF/DOCX)
router.include_router(image_generation_router) # Image generation via litellm
router.include_router(search_source_connectors_router)
router.include_router(google_calendar_add_connector_router)

View file

@ -20,6 +20,7 @@ from app.services.public_chat_service import (
clone_from_snapshot,
get_public_chat,
get_snapshot_podcast,
get_snapshot_report,
)
from app.users import current_active_user
@ -114,3 +115,37 @@ async def stream_public_podcast(
"Content-Disposition": f"inline; filename={os.path.basename(file_path)}",
},
)
@router.get("/{share_token}/reports/{report_id}/content")
async def get_public_report_content(
share_token: str,
report_id: int,
session: AsyncSession = Depends(get_async_session),
):
"""
Get report content from a public chat snapshot.
No authentication required - the share_token provides access.
Returns report content including title, markdown body, metadata, and versions.
"""
from app.services.public_chat_service import get_snapshot_report_versions
report_info = await get_snapshot_report(session, share_token, report_id)
if not report_info:
raise HTTPException(status_code=404, detail="Report not found")
# Get version siblings from the same snapshot
versions = await get_snapshot_report_versions(
session, share_token, report_info.get("report_group_id")
)
return {
"id": report_info.get("original_id"),
"title": report_info.get("title"),
"content": report_info.get("content"),
"report_metadata": report_info.get("report_metadata"),
"report_group_id": report_info.get("report_group_id"),
"versions": versions,
}

View file

@ -0,0 +1,337 @@
"""
Report routes for read, export (PDF/DOCX), and delete operations.
No create or update endpoints here reports are generated inline by the
agent tool during chat and stored as Markdown in the database.
Export to PDF/DOCX is on-demand PDF uses pypandoc (MarkdownTypst) + typst-py
(TypstPDF); DOCX uses pypandoc directly.
Authorization: lightweight search-space membership checks (no granular RBAC)
since reports are chat-generated artifacts, not standalone managed resources.
"""
import asyncio
import io
import logging
import os
import re
import tempfile
from enum import Enum
import pypandoc
import typst
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import (
Report,
SearchSpace,
SearchSpaceMembership,
User,
get_async_session,
)
from app.schemas import ReportContentRead, ReportRead
from app.schemas.reports import ReportVersionInfo
from app.users import current_active_user
from app.utils.rbac import check_search_space_access
logger = logging.getLogger(__name__)
router = APIRouter()
MAX_REPORT_LIST_LIMIT = 500
class ExportFormat(str, Enum):
PDF = "pdf"
DOCX = "docx"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_CODE_FENCE_RE = re.compile(r"^```(?:markdown|md)?\s*\n", re.MULTILINE)
def _strip_wrapping_code_fences(text: str) -> str:
"""Remove wrapping code fences (```markdown...```) that LLMs often add."""
stripped = text.strip()
m = _CODE_FENCE_RE.match(stripped)
if m and stripped.endswith("```"):
stripped = stripped[m.end() : -3].rstrip()
return stripped
async def _get_report_with_access(
report_id: int,
session: AsyncSession,
user: User,
) -> Report:
"""Fetch a report and verify the user belongs to its search space.
Raises HTTPException(404) if not found, HTTPException(403) if no access.
"""
result = await session.execute(select(Report).filter(Report.id == report_id))
report = result.scalars().first()
if not report:
raise HTTPException(status_code=404, detail="Report not found")
# Lightweight membership check - no granular RBAC, just "is the user a
# member of the search space this report belongs to?"
await check_search_space_access(session, user, report.search_space_id)
return report
async def _get_version_siblings(
session: AsyncSession,
report: Report,
) -> list[ReportVersionInfo]:
"""Get all versions in the same report group, ordered by created_at."""
if not report.report_group_id:
# Legacy report without group — it's the only version
return [ReportVersionInfo(id=report.id, created_at=report.created_at)]
result = await session.execute(
select(Report.id, Report.created_at)
.filter(Report.report_group_id == report.report_group_id)
.order_by(Report.created_at.asc())
)
rows = result.all()
return [ReportVersionInfo(id=row[0], created_at=row[1]) for row in rows]
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@router.get("/reports", response_model=list[ReportRead])
async def read_reports(
skip: int = Query(default=0, ge=0),
limit: int = Query(default=100, ge=1, le=MAX_REPORT_LIST_LIMIT),
search_space_id: int | None = None,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""
List reports the user has access to.
Filters by search space membership.
"""
try:
if search_space_id is not None:
# Verify the caller is a member of the requested search space
await check_search_space_access(session, user, search_space_id)
result = await session.execute(
select(Report)
.filter(Report.search_space_id == search_space_id)
.order_by(Report.id.desc())
.offset(skip)
.limit(limit)
)
else:
result = await session.execute(
select(Report)
.join(SearchSpace)
.join(SearchSpaceMembership)
.filter(SearchSpaceMembership.user_id == user.id)
.order_by(Report.id.desc())
.offset(skip)
.limit(limit)
)
return result.scalars().all()
except HTTPException:
raise
except SQLAlchemyError:
raise HTTPException(
status_code=500, detail="Database error occurred while fetching reports"
) from None
@router.get("/reports/{report_id}", response_model=ReportRead)
async def read_report(
report_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""
Get a specific report by ID (metadata only, no content).
"""
try:
return await _get_report_with_access(report_id, session, user)
except HTTPException:
raise
except SQLAlchemyError:
raise HTTPException(
status_code=500, detail="Database error occurred while fetching report"
) from None
@router.get("/reports/{report_id}/content", response_model=ReportContentRead)
async def read_report_content(
report_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""
Get full Markdown content of a report, including version siblings.
"""
try:
report = await _get_report_with_access(report_id, session, user)
versions = await _get_version_siblings(session, report)
return ReportContentRead(
id=report.id,
title=report.title,
content=report.content,
report_metadata=report.report_metadata,
report_group_id=report.report_group_id,
versions=versions,
)
except HTTPException:
raise
except SQLAlchemyError:
raise HTTPException(
status_code=500,
detail="Database error occurred while fetching report content",
) from None
@router.get("/reports/{report_id}/export")
async def export_report(
report_id: int,
format: ExportFormat = Query(
ExportFormat.PDF, description="Export format: pdf or docx"
),
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""
Export a report as PDF or DOCX.
"""
try:
report = await _get_report_with_access(report_id, session, user)
if not report.content:
raise HTTPException(
status_code=400, detail="Report has no content to export"
)
# Strip wrapping code fences that LLMs sometimes add around Markdown.
# Without this, pandoc treats the entire content as a code block.
markdown_content = _strip_wrapping_code_fences(report.content)
# Convert Markdown to the requested format.
#
# DOCX: pypandoc (pandoc) handles the full conversion directly.
#
# PDF: two-step pipeline — pypandoc converts Markdown → Typst markup,
# then the `typst` Python library compiles Typst → PDF. This avoids
# requiring the Typst CLI on the system PATH; the typst pip package
# bundles the compiler as a native extension. Typst produces
# professional styling for tables, headings, code blocks, etc.
#
# Use "gfm" as the input format because LLM output uses GFM-style
# pipe tables that pandoc's stricter default "markdown" may mangle.
def _convert_and_read() -> bytes:
"""Run all blocking I/O (tempfile, pandoc/typst, file read, cleanup) in a thread."""
if format == ExportFormat.PDF:
# Step 1: Markdown → Typst markup via pandoc.
# We must set mainfont / monofont so the generated template's
# `font` parameter is non-empty; without it pandoc emits
# `font: ()` which makes Typst error with
# "font fallback list must not be empty".
# We use fonts that ship embedded inside typst-py so this
# works even on systems with no fonts installed.
typst_markup: str = pypandoc.convert_text(
markdown_content,
"typst",
format="gfm",
extra_args=[
"--standalone",
"-V",
"mainfont:Libertinus Serif",
"-V",
"monofont:DejaVu Sans Mono",
],
)
# Step 2: Typst markup → PDF via typst Python library
pdf_bytes: bytes = typst.compile(typst_markup.encode("utf-8"))
return pdf_bytes
else:
# DOCX: let pandoc handle the full conversion
fd, tmp_path = tempfile.mkstemp(suffix=f".{format.value}")
os.close(fd)
try:
pypandoc.convert_text(
markdown_content,
format.value,
format="gfm",
extra_args=["--standalone"],
outputfile=tmp_path,
)
with open(tmp_path, "rb") as f:
return f.read()
finally:
os.unlink(tmp_path)
loop = asyncio.get_running_loop()
output = await loop.run_in_executor(None, _convert_and_read)
# Sanitize filename
safe_title = (
"".join(
c if c.isalnum() or c in " -_" else "_" for c in report.title
).strip()[:80]
or "report"
)
media_types = {
ExportFormat.PDF: "application/pdf",
ExportFormat.DOCX: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
}
return StreamingResponse(
io.BytesIO(output),
media_type=media_types[format],
headers={
"Content-Disposition": f'attachment; filename="{safe_title}.{format.value}"',
},
)
except HTTPException:
raise
except Exception as e:
logger.exception("Report export failed")
raise HTTPException(status_code=500, detail=f"Export failed: {e!s}") from e
@router.delete("/reports/{report_id}", response_model=dict)
async def delete_report(
report_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""
Delete a report.
"""
try:
db_report = await _get_report_with_access(report_id, session, user)
await session.delete(db_report)
await session.commit()
return {"message": "Report deleted successfully"}
except HTTPException:
raise
except SQLAlchemyError:
await session.rollback()
raise HTTPException(
status_code=500, detail="Database error occurred while deleting report"
) from None

View file

@ -76,6 +76,7 @@ from .rbac_schemas import (
RoleUpdate,
UserSearchSpaceAccess,
)
from .reports import ReportBase, ReportContentRead, ReportRead, ReportVersionInfo
from .search_source_connector import (
MCPConnectorCreate,
MCPConnectorRead,
@ -185,6 +186,11 @@ __all__ = [
"PodcastUpdate",
"RefreshTokenRequest",
"RefreshTokenResponse",
# Report schemas
"ReportBase",
"ReportContentRead",
"ReportRead",
"ReportVersionInfo",
"RoleCreate",
"RoleRead",
"RoleUpdate",

View file

@ -0,0 +1,53 @@
"""Report schemas for API responses."""
from datetime import datetime
from typing import Any
from pydantic import BaseModel
class ReportBase(BaseModel):
"""Base report schema."""
title: str
content: str | None = None
report_style: str | None = None
search_space_id: int
class ReportRead(BaseModel):
"""Schema for reading a report (list view, no content)."""
id: int
title: str
report_style: str | None = None
report_metadata: dict[str, Any] | None = None
report_group_id: int | None = None
created_at: datetime
class Config:
from_attributes = True
class ReportVersionInfo(BaseModel):
"""Lightweight version entry for the version switcher UI."""
id: int
created_at: datetime
class Config:
from_attributes = True
class ReportContentRead(BaseModel):
"""Schema for reading a report with full Markdown content."""
id: int
title: str
content: str | None = None
report_metadata: dict[str, Any] | None = None
report_group_id: int | None = None
versions: list[ReportVersionInfo] = []
class Config:
from_attributes = True

View file

@ -29,6 +29,7 @@ from app.db import (
Podcast,
PodcastStatus,
PublicChatSnapshot,
Report,
SearchSpaceMembership,
User,
)
@ -38,6 +39,7 @@ UI_TOOLS = {
"display_image",
"link_preview",
"generate_podcast",
"generate_report",
"scrape_webpage",
"multi_link_preview",
}
@ -195,19 +197,22 @@ async def create_snapshot(
message_ids = []
podcasts_data = []
podcast_ids_seen: set[int] = set()
reports_data = []
report_ids_seen: set[int] = set()
for msg in sorted(thread.messages, key=lambda m: m.created_at):
author = await get_author_display(session, msg.author_id, user_cache)
sanitized_content = sanitize_content_for_public(msg.content)
# Extract podcast references and update status to "ready" for completed podcasts
# Extract podcast/report references and update status to "ready" for completed ones
if isinstance(sanitized_content, list):
for part in sanitized_content:
if (
isinstance(part, dict)
and part.get("type") == "tool-call"
and part.get("toolName") == "generate_podcast"
):
if not isinstance(part, dict) or part.get("type") != "tool-call":
continue
tool_name = part.get("toolName")
if tool_name == "generate_podcast":
result_data = part.get("result", {})
podcast_id = result_data.get("podcast_id")
if podcast_id and podcast_id not in podcast_ids_seen:
@ -220,6 +225,17 @@ async def create_snapshot(
# Update status to "ready" so frontend renders PodcastPlayer
part["result"] = {**result_data, "status": "ready"}
elif tool_name == "generate_report":
result_data = part.get("result", {})
report_id = result_data.get("report_id")
if report_id and report_id not in report_ids_seen:
report_info = await _get_report_for_snapshot(session, report_id)
if report_info:
reports_data.append(report_info)
report_ids_seen.add(report_id)
# Update status to "ready" so frontend renders ReportCard
part["result"] = {**result_data, "status": "ready"}
messages_data.append(
{
"id": msg.id,
@ -266,6 +282,7 @@ async def create_snapshot(
"author": thread_author,
"messages": messages_data,
"podcasts": podcasts_data,
"reports": reports_data,
}
# Create new snapshot
@ -309,6 +326,27 @@ async def _get_podcast_for_snapshot(
}
async def _get_report_for_snapshot(
session: AsyncSession,
report_id: int,
) -> dict | None:
"""Get report info for embedding in snapshot_data."""
result = await session.execute(select(Report).filter(Report.id == report_id))
report = result.scalars().first()
if not report:
return None
return {
"original_id": report.id,
"title": report.title,
"content": report.content,
"report_metadata": report.report_metadata,
"report_group_id": report.report_group_id,
"created_at": report.created_at.isoformat() if report.created_at else None,
}
# =============================================================================
# Snapshot Retrieval
# =============================================================================
@ -578,6 +616,7 @@ async def clone_from_snapshot(
data = snapshot.snapshot_data
messages_data = data.get("messages", [])
podcasts_lookup = {p.get("original_id"): p for p in data.get("podcasts", [])}
reports_lookup = {r.get("original_id"): r for r in data.get("reports", [])}
new_thread = NewChatThread(
title=data.get("title", "Cloned Chat"),
@ -594,6 +633,7 @@ async def clone_from_snapshot(
await session.flush()
podcast_id_mapping: dict[int, int] = {}
report_id_mapping: dict[int, int] = {}
# Check which authors from snapshot still exist in DB
author_ids_from_snapshot: set[UUID] = set()
@ -655,6 +695,37 @@ async def clone_from_snapshot(
"podcast_id": podcast_id_mapping[old_podcast_id],
}
if (
isinstance(part, dict)
and part.get("type") == "tool-call"
and part.get("toolName") == "generate_report"
):
result = part.get("result", {})
old_report_id = result.get("report_id")
if old_report_id and old_report_id not in report_id_mapping:
report_info = reports_lookup.get(old_report_id)
if report_info:
new_report = Report(
title=report_info.get("title", "Cloned Report"),
content=report_info.get("content"),
report_metadata=report_info.get("report_metadata"),
search_space_id=target_search_space_id,
thread_id=new_thread.id,
)
session.add(new_report)
await session.flush()
# For cloned reports, set report_group_id = own id
# (each cloned report starts as its own v1)
new_report.report_group_id = new_report.id
report_id_mapping[old_report_id] = new_report.id
if old_report_id and old_report_id in report_id_mapping:
part["result"] = {
**result,
"report_id": report_id_mapping[old_report_id],
}
new_message = NewChatMessage(
thread_id=new_thread.id,
role=role,
@ -696,3 +767,59 @@ async def get_snapshot_podcast(
return podcast
return None
async def get_snapshot_report(
session: AsyncSession,
share_token: str,
report_id: int,
) -> dict | None:
"""
Get report info from a snapshot by original report ID.
Used for displaying report content in public view.
Looks up the report by its original_id in the snapshot's reports array.
"""
snapshot = await get_snapshot_by_token(session, share_token)
if not snapshot:
return None
reports = snapshot.snapshot_data.get("reports", [])
# Find report by original_id
for report in reports:
if report.get("original_id") == report_id:
return report
return None
async def get_snapshot_report_versions(
session: AsyncSession,
share_token: str,
report_group_id: int | None,
) -> list[dict]:
"""
Get all report versions in the same group from a snapshot.
Returns a list of lightweight version entries (id + created_at)
for the version switcher UI, sorted by original_id (insertion order).
"""
if not report_group_id:
return []
snapshot = await get_snapshot_by_token(session, share_token)
if not snapshot:
return []
reports = snapshot.snapshot_data.get("reports", [])
siblings = [r for r in reports if r.get("report_group_id") == report_group_id]
# Sort by original_id (ascending = insertion order ≈ created_at order)
siblings.sort(key=lambda r: r.get("original_id", 0))
return [
{"id": r.get("original_id"), "created_at": r.get("created_at")}
for r in siblings
]

View file

@ -63,6 +63,8 @@ dependencies = [
"unstructured-client>=0.42.3",
"langchain-unstructured>=1.0.1",
"slowapi>=0.1.9",
"pypandoc_binary>=1.16.2",
"typst>=0.14.0",
]
[dependency-groups]

6437
surfsense_backend/uv.lock generated

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,350 @@
"use client";
import {
Bell,
BellOff,
CheckCheck,
ExternalLink,
Filter,
Info,
type Megaphone,
Rocket,
Wrench,
X,
Zap,
} from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Separator } from "@/components/ui/separator";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import type { AnnouncementCategory } from "@/contracts/types/announcement.types";
import { type AnnouncementWithState, useAnnouncements } from "@/hooks/use-announcements";
import { formatRelativeDate } from "@/lib/format-date";
// ---------------------------------------------------------------------------
// Category configuration
// ---------------------------------------------------------------------------
const categoryConfig: Record<
AnnouncementCategory,
{
label: string;
icon: typeof Megaphone;
color: string;
badgeVariant: "default" | "secondary" | "destructive" | "outline";
}
> = {
feature: {
label: "Feature",
icon: Rocket,
color: "text-emerald-500",
badgeVariant: "default",
},
update: {
label: "Update",
icon: Zap,
color: "text-blue-500",
badgeVariant: "secondary",
},
maintenance: {
label: "Maintenance",
icon: Wrench,
color: "text-amber-500",
badgeVariant: "outline",
},
info: {
label: "Info",
icon: Info,
color: "text-muted-foreground",
badgeVariant: "secondary",
},
};
// ---------------------------------------------------------------------------
// Announcement card
// ---------------------------------------------------------------------------
function AnnouncementCard({
announcement,
onMarkRead,
onDismiss,
}: {
announcement: AnnouncementWithState;
onMarkRead: (id: string) => void;
onDismiss: (id: string) => void;
}) {
const config = categoryConfig[announcement.category];
const Icon = config.icon;
return (
<Card
className={`group relative transition-all duration-200 hover:shadow-md ${
!announcement.isRead ? "border-l-4 border-l-primary bg-primary/2" : ""
}`}
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3 min-w-0">
<div
className={`mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted ${config.color}`}
>
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<CardTitle className="text-base leading-tight">{announcement.title}</CardTitle>
<Badge variant={config.badgeVariant} className="text-[10px] px-1.5 py-0">
{config.label}
</Badge>
{announcement.isImportant && (
<Badge variant="destructive" className="text-[10px] px-1.5 py-0 gap-0.5">
<Bell className="h-2.5 w-2.5" />
Important
</Badge>
)}
{!announcement.isRead && (
<span className="h-2 w-2 rounded-full bg-primary shrink-0" />
)}
</div>
<CardDescription className="mt-1 text-xs">
{formatRelativeDate(announcement.date)}
</CardDescription>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
{!announcement.isRead && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => onMarkRead(announcement.id)}
>
<CheckCheck className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Mark as read</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => onDismiss(announcement.id)}
>
<X className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Dismiss</TooltipContent>
</Tooltip>
</div>
</div>
</CardHeader>
<CardContent className="pb-3">
<p className="text-sm text-muted-foreground leading-relaxed">{announcement.description}</p>
</CardContent>
{announcement.link && (
<CardFooter className="pt-0 pb-4">
<Button variant="outline" size="sm" asChild className="gap-1.5">
<Link
href={announcement.link.url}
target={announcement.link.url.startsWith("http") ? "_blank" : undefined}
onClick={() => onMarkRead(announcement.id)}
>
{announcement.link.label}
<ExternalLink className="h-3 w-3" />
</Link>
</Button>
</CardFooter>
)}
</Card>
);
}
// ---------------------------------------------------------------------------
// Empty state
// ---------------------------------------------------------------------------
function EmptyState({ hasFilters }: { hasFilters: boolean }) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted mb-4">
{hasFilters ? (
<Filter className="h-7 w-7 text-muted-foreground" />
) : (
<BellOff className="h-7 w-7 text-muted-foreground" />
)}
</div>
<h3 className="text-lg font-semibold">
{hasFilters ? "No matching announcements" : "No announcements"}
</h3>
<p className="mt-1 text-sm text-muted-foreground max-w-sm">
{hasFilters
? "Try adjusting your filters to see more announcements."
: "You're all caught up! New announcements will appear here."}
</p>
</div>
);
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default function AnnouncementsPage() {
const [activeCategories, setActiveCategories] = useState<AnnouncementCategory[]>([]);
const [showOnlyUnread, setShowOnlyUnread] = useState(false);
const { announcements, unreadCount, markRead, markAllRead, dismiss } = useAnnouncements({
includeDismissed: false,
});
// Apply local filters
const filteredAnnouncements = announcements.filter((a) => {
if (activeCategories.length > 0 && !activeCategories.includes(a.category)) return false;
if (showOnlyUnread && a.isRead) return false;
return true;
});
const hasActiveFilters = activeCategories.length > 0 || showOnlyUnread;
const toggleCategory = (cat: AnnouncementCategory) => {
setActiveCategories((prev) =>
prev.includes(cat) ? prev.filter((c) => c !== cat) : [...prev, cat]
);
};
return (
<TooltipProvider delayDuration={0}>
<div className="min-h-screen relative pt-20">
{/* Header */}
<div className="border-b border-border/50">
<div className="max-w-5xl mx-auto relative">
<div className="p-6">
<h1 className="text-4xl font-bold tracking-tight bg-linear-to-r from-gray-900 to-gray-600 dark:from-white dark:to-gray-400 bg-clip-text text-transparent">
Announcements
</h1>
</div>
</div>
</div>
{/* Content */}
<div className="max-w-3xl mx-auto px-6 lg:px-10 pt-8 pb-20">
{/* Toolbar */}
<div className="flex items-center justify-between gap-3 mb-6">
<div className="flex items-center gap-2">
{/* Category filter dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-1.5">
<Filter className="h-3.5 w-3.5" />
Filter
{activeCategories.length > 0 && (
<Badge variant="secondary" className="ml-1 px-1.5 py-0 text-[10px]">
{activeCategories.length}
</Badge>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48">
<DropdownMenuLabel>Categories</DropdownMenuLabel>
<DropdownMenuSeparator />
{(Object.keys(categoryConfig) as AnnouncementCategory[]).map((cat) => {
const cfg = categoryConfig[cat];
const CatIcon = cfg.icon;
return (
<DropdownMenuCheckboxItem
key={cat}
checked={activeCategories.includes(cat)}
onCheckedChange={() => toggleCategory(cat)}
>
<CatIcon className={`mr-2 h-3.5 w-3.5 ${cfg.color}`} />
{cfg.label}
</DropdownMenuCheckboxItem>
);
})}
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
checked={showOnlyUnread}
onCheckedChange={() => setShowOnlyUnread((v) => !v)}
>
<Bell className="mr-2 h-3.5 w-3.5" />
Unread only
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
className="text-xs text-muted-foreground"
onClick={() => {
setActiveCategories([]);
setShowOnlyUnread(false);
}}
>
Clear filters
</Button>
)}
</div>
{/* Mark all read */}
{unreadCount > 0 && (
<Button variant="ghost" size="sm" className="gap-1.5 text-xs" onClick={markAllRead}>
<CheckCheck className="h-3.5 w-3.5" />
Mark all as read
<Badge variant="secondary" className="ml-1 px-1.5 py-0 text-[10px]">
{unreadCount}
</Badge>
</Button>
)}
</div>
<Separator className="mb-6" />
{/* Announcement list */}
{filteredAnnouncements.length === 0 ? (
<EmptyState hasFilters={hasActiveFilters} />
) : (
<div className="flex flex-col gap-4">
{filteredAnnouncements.map((announcement) => (
<AnnouncementCard
key={announcement.id}
announcement={announcement}
onMarkRead={markRead}
onDismiss={dismiss}
/>
))}
</div>
)}
</div>
</div>
</TooltipProvider>
);
}

View file

@ -28,15 +28,18 @@ import {
// extractWriteTodosFromContent,
hydratePlanStateAtom,
} from "@/atoms/chat/plan-state.atom";
import { closeReportPanelAtom } from "@/atoms/chat/report-panel.atom";
import { membersAtom } from "@/atoms/members/members-query.atoms";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { Thread } from "@/components/assistant-ui/thread";
import { ChatHeader } from "@/components/new-chat/chat-header";
import { CreateNotionPageToolUI } from "@/components/tool-ui/create-notion-page";
import { ReportPanel } from "@/components/report-panel/report-panel";
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
import { DeleteNotionPageToolUI } from "@/components/tool-ui/delete-notion-page";
import { DisplayImageToolUI } from "@/components/tool-ui/display-image";
import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
import { GenerateReportToolUI } from "@/components/tool-ui/generate-report";
import { LinkPreviewToolUI } from "@/components/tool-ui/link-preview";
import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage";
import { UpdateNotionPageToolUI } from "@/components/tool-ui/update-notion-page";
@ -131,6 +134,7 @@ function extractMentionedDocuments(content: unknown): MentionedDocumentInfo[] {
*/
const TOOLS_WITH_UI = new Set([
"generate_podcast",
"generate_report",
"link_preview",
"display_image",
"delete_notion_page",
@ -170,6 +174,7 @@ export default function NewChatPage() {
const setCurrentThreadState = useSetAtom(currentThreadAtom);
const setTargetCommentId = useSetAtom(setTargetCommentIdAtom);
const clearTargetCommentId = useSetAtom(clearTargetCommentIdAtom);
const closeReportPanel = useSetAtom(closeReportPanelAtom);
// Get current user for author info in shared chats
const { data: currentUser } = useAtomValue(currentUserAtom);
@ -263,6 +268,7 @@ export default function NewChatPage() {
setMentionedDocuments([]);
setMessageDocumentsMap({});
clearPlanOwnerRegistry(); // Reset plan ownership for new chat
closeReportPanel(); // Close report panel when switching chats
try {
if (urlChatId > 0) {
@ -327,6 +333,7 @@ export default function NewChatPage() {
setMentionedDocumentIds,
setMentionedDocuments,
hydratePlanState,
closeReportPanel,
]);
// Initialize on mount
@ -1635,6 +1642,7 @@ export default function NewChatPage() {
return (
<AssistantRuntimeProvider runtime={runtime}>
<GeneratePodcastToolUI />
<GenerateReportToolUI />
<LinkPreviewToolUI />
<DisplayImageToolUI />
<ScrapeWebpageToolUI />
@ -1644,11 +1652,14 @@ export default function NewChatPage() {
<UpdateNotionPageToolUI />
<DeleteNotionPageToolUI />
{/* <WriteTodosToolUI /> Disabled for now */}
<div className="flex flex-col h-[calc(100dvh-64px)] overflow-hidden">
<Thread
messageThinkingSteps={messageThinkingSteps}
header={<ChatHeader searchSpaceId={searchSpaceId} />}
/>
<div className="flex h-[calc(100dvh-64px)] overflow-hidden">
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
<Thread
messageThinkingSteps={messageThinkingSteps}
header={<ChatHeader searchSpaceId={searchSpaceId} />}
/>
</div>
<ReportPanel />
</div>
</AssistantRuntimeProvider>
);

View file

@ -225,3 +225,5 @@ button {
@source '../node_modules/@llamaindex/chat-ui/**/*.{ts,tsx}';
@source '../node_modules/streamdown/dist/*.js';
@source '../node_modules/@streamdown/code/dist/*.js';
@source '../node_modules/@streamdown/math/dist/*.js';

View file

@ -2,6 +2,7 @@ import type { Metadata, Viewport } from "next";
import "./globals.css";
import { RootProvider } from "fumadocs-ui/provider/next";
import { Roboto } from "next/font/google";
import { AnnouncementToastProvider } from "@/components/announcements/AnnouncementToastProvider";
import { ElectricProvider } from "@/components/providers/ElectricProvider";
import { GlobalLoadingProvider } from "@/components/providers/GlobalLoadingProvider";
import { I18nProvider } from "@/components/providers/I18nProvider";
@ -124,6 +125,7 @@ export default function RootLayout({
</ElectricProvider>
</ReactQueryClientProvider>
<Toaster />
<AnnouncementToastProvider />
</RootProvider>
</ThemeProvider>
</I18nProvider>

View file

@ -1,5 +1,6 @@
import { atom } from "jotai";
import type { ChatVisibility } from "@/lib/chat/thread-persistence";
import { reportPanelAtom, reportPanelOpenAtom } from "./report-panel.atom";
// TODO: Update `hasComments` to true when the first comment is created on a thread.
// Currently it only updates on thread load. The gutter still works because
@ -39,6 +40,8 @@ export const showCommentsGutterAtom = atom((get) => {
const thread = get(currentThreadAtom);
// Hide gutter if comments are collapsed
if (thread.commentsCollapsed) return false;
// Hide gutter if report panel is open (report panel takes the right side)
if (get(reportPanelOpenAtom)) return false;
return (
thread.visibility === "SEARCH_SPACE" &&
(thread.hasComments || thread.addingCommentToMessageId !== null)
@ -59,6 +62,8 @@ export const setThreadVisibilityAtom = atom(null, (get, set, newVisibility: Chat
export const resetCurrentThreadAtom = atom(null, (_, set) => {
set(currentThreadAtom, initialState);
// Also close the report panel when resetting the thread
set(reportPanelAtom, { isOpen: false, reportId: null, title: null, wordCount: null });
});
/** Atom to read whether comments panel is collapsed */

View file

@ -0,0 +1,52 @@
import { atom } from "jotai";
interface ReportPanelState {
isOpen: boolean;
reportId: number | null;
title: string | null;
wordCount: number | null;
/** When set, uses public endpoints for fetching report data (public shared chat) */
shareToken: string | null;
}
const initialState: ReportPanelState = {
isOpen: false,
reportId: null,
title: null,
wordCount: null,
shareToken: null,
};
/** Core atom holding the report panel state */
export const reportPanelAtom = atom<ReportPanelState>(initialState);
/** Derived read-only atom for checking if panel is open */
export const reportPanelOpenAtom = atom((get) => get(reportPanelAtom).isOpen);
/** Action atom to open the report panel with a specific report */
export const openReportPanelAtom = atom(
null,
(
_get,
set,
{
reportId,
title,
wordCount,
shareToken,
}: { reportId: number; title: string; wordCount?: number; shareToken?: string | null }
) => {
set(reportPanelAtom, {
isOpen: true,
reportId,
title,
wordCount: wordCount ?? null,
shareToken: shareToken ?? null,
});
}
);
/** Action atom to close the report panel */
export const closeReportPanelAtom = atom(null, (_, set) => {
set(reportPanelAtom, initialState);
});

View file

@ -0,0 +1,90 @@
"use client";
import { Megaphone } from "lucide-react";
import { useEffect, useRef } from "react";
import { toast } from "sonner";
import type { Announcement } from "@/contracts/types/announcement.types";
import { announcements } from "@/lib/announcements/announcements-data";
import {
isAnnouncementToasted,
markAnnouncementRead,
markAnnouncementToasted,
} from "@/lib/announcements/announcements-storage";
/** Map announcement category to the Sonner toast method */
const categoryToVariant: Record<string, "info" | "warning" | "success"> = {
update: "info",
feature: "success",
maintenance: "warning",
info: "info",
};
/** Show a single announcement as a toast */
function showAnnouncementToast(announcement: Announcement) {
const variant = categoryToVariant[announcement.category] ?? "info";
const options = {
description: truncateText(announcement.description, 120),
duration: 12000,
icon: <Megaphone className="h-4 w-4" />,
action: announcement.link
? {
label: announcement.link.label,
onClick: () => {
if (announcement.link?.url.startsWith("http")) {
window.open(announcement.link.url, "_blank");
} else if (announcement.link?.url) {
window.location.href = announcement.link.url;
}
},
}
: undefined,
onDismiss: () => {
markAnnouncementRead(announcement.id);
},
};
toast[variant](announcement.title, options);
markAnnouncementToasted(announcement.id);
}
/**
* Global provider that shows important announcements as toast notifications.
*
* Place this component once at the root layout level (alongside <Toaster />).
* On mount, it checks for unread important announcements that haven't been
* shown as toasts yet, and displays them with a short stagger delay.
*/
export function AnnouncementToastProvider() {
const hasChecked = useRef(false);
useEffect(() => {
// Only run once per page load
if (hasChecked.current) return;
hasChecked.current = true;
// Small delay to let the page settle before showing toasts
const timer = setTimeout(() => {
const importantUntoasted = announcements.filter(
(a) => a.isImportant && !isAnnouncementToasted(a.id)
);
// Show each important announcement as a toast with stagger
for (let i = 0; i < importantUntoasted.length; i++) {
const announcement = importantUntoasted[i];
setTimeout(() => showAnnouncementToast(announcement), i * 800);
}
}, 1500); // Initial delay for page to settle
return () => clearTimeout(timer);
}, []);
// This component renders nothing — it only triggers side effects
return null;
}
/** Truncate text to a maximum length, adding ellipsis if needed */
function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return `${text.slice(0, maxLength).trimEnd()}...`;
}

View file

@ -33,6 +33,10 @@ export function FooterNew() {
title: "Contact Us",
href: "/contact",
},
{
title: "Announcements",
href: "/announcements",
},
];
const socials = [

View file

@ -4,6 +4,7 @@ import {
IconBrandGithub,
IconBrandReddit,
IconMenu2,
IconSpeakerphone,
IconX,
} from "@tabler/icons-react";
import { AnimatePresence, motion } from "motion/react";
@ -12,6 +13,7 @@ import { useEffect, useState } from "react";
import { SignInButton } from "@/components/auth/sign-in-button";
import { Logo } from "@/components/Logo";
import { ThemeTogglerComponent } from "@/components/theme/theme-toggle";
import { useAnnouncements } from "@/hooks/use-announcements";
import { useGithubStars } from "@/hooks/use-github-stars";
import { cn } from "@/lib/utils";
@ -47,7 +49,11 @@ export const Navbar = () => {
const DesktopNav = ({ navItems, isScrolled }: any) => {
const [hovered, setHovered] = useState<number | null>(null);
const [mounted, setMounted] = useState(false);
const { compactFormat: githubStars, loading: loadingGithubStars } = useGithubStars();
const { unreadCount } = useAnnouncements();
useEffect(() => setMounted(true), []);
return (
<motion.div
onMouseLeave={() => {
@ -118,6 +124,17 @@ const DesktopNav = ({ navItems, isScrolled }: any) => {
</span>
)}
</Link>
<Link
href="/announcements"
className="relative hidden rounded-full p-2 hover:bg-gray-100 dark:hover:bg-neutral-800 transition-colors md:flex items-center justify-center"
>
<IconSpeakerphone className="h-5 w-5 text-neutral-600 dark:text-neutral-300" />
{mounted && unreadCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-bold text-white">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</Link>
<ThemeTogglerComponent />
<SignInButton variant="desktop" />
</div>
@ -127,7 +144,11 @@ const DesktopNav = ({ navItems, isScrolled }: any) => {
const MobileNav = ({ navItems, isScrolled }: any) => {
const [open, setOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const { compactFormat: githubStars, loading: loadingGithubStars } = useGithubStars();
const { unreadCount } = useAnnouncements();
useEffect(() => setMounted(true), []);
return (
<motion.div
@ -212,6 +233,17 @@ const MobileNav = ({ navItems, isScrolled }: any) => {
</span>
)}
</Link>
<Link
href="/announcements"
className="relative flex items-center justify-center rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-neutral-800 transition-colors touch-manipulation"
>
<IconSpeakerphone className="h-5 w-5 text-neutral-600 dark:text-neutral-300" />
{mounted && unreadCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-bold text-white">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</Link>
<ThemeTogglerComponent />
</div>
<SignInButton variant="mobile" />

View file

@ -2,7 +2,15 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtomValue, useSetAtom } from "jotai";
import { AlertTriangle, Inbox, LogOut, PencilIcon, SquareLibrary, Trash2 } from "lucide-react";
import {
AlertTriangle,
Inbox,
LogOut,
Megaphone,
PencilIcon,
SquareLibrary,
Trash2,
} from "lucide-react";
import { useParams, usePathname, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useTheme } from "next-themes";
@ -23,6 +31,7 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { isPageLimitExceededMetadata } from "@/contracts/types/inbox.types";
import { useAnnouncements } from "@/hooks/use-announcements";
import { useInbox } from "@/hooks/use-inbox";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { logout } from "@/lib/auth-utils";
@ -65,6 +74,9 @@ export function LayoutDataProvider({
const queryClient = useQueryClient();
const { theme, setTheme } = useTheme();
// Announcements
const { unreadCount: announcementUnreadCount } = useAnnouncements();
// Atoms
const { data: user } = useAtomValue(currentUserAtom);
const { data: searchSpacesData, refetch: refetchSearchSpaces } = useAtomValue(searchSpacesAtom);
@ -293,8 +305,15 @@ export function LayoutDataProvider({
icon: SquareLibrary,
isActive: pathname?.includes("/documents"),
},
{
title: "Announcements",
url: "/announcements",
icon: Megaphone,
isActive: pathname?.includes("/announcements"),
badge: announcementUnreadCount > 0 ? formatInboxCount(announcementUnreadCount) : undefined,
},
],
[searchSpaceId, pathname, isInboxSidebarOpen, totalUnreadCount]
[searchSpaceId, pathname, isInboxSidebarOpen, totalUnreadCount, announcementUnreadCount]
);
// Handlers

View file

@ -126,7 +126,7 @@ export function SearchSpaceAvatar({
type="button"
onClick={onClick}
className={cn(
"relative flex items-center justify-center rounded-lg font-semibold text-white transition-all",
"relative flex items-center justify-center rounded-lg font-semibold text-white transition-all select-none",
"hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
sizeClasses,
isActive && "ring-2 ring-primary ring-offset-1 ring-offset-background"

View file

@ -111,7 +111,7 @@ function UserAvatar({
return (
<div
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-xs font-semibold text-white"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-xs font-semibold text-white select-none"
style={{ backgroundColor: bgColor }}
>
{initials}

View file

@ -1,23 +1,67 @@
import { createCodePlugin } from "@streamdown/code";
import { createMathPlugin } from "@streamdown/math";
import Image from "next/image";
import { Streamdown, type StreamdownProps } from "streamdown";
import "katex/dist/katex.min.css";
import { cn } from "@/lib/utils";
const code = createCodePlugin({
themes: ["nord", "nord"],
});
const math = createMathPlugin({
singleDollarTextMath: true,
});
interface MarkdownViewerProps {
content: string;
className?: string;
}
/**
* If the entire content is wrapped in a single ```markdown or ```md
* code fence, strip the fence so the inner markdown renders properly.
*/
function stripOuterMarkdownFence(content: string): string {
const trimmed = content.trim();
const match = trimmed.match(/^```(?:markdown|md)?\s*\n([\s\S]+?)\n```\s*$/);
return match ? match[1] : content;
}
/**
* Convert various LaTeX delimiter styles to the dollar-sign syntax
* that remark-math understands, and normalise edge-cases that
* commonly appear in LLM-generated markdown.
*
* \[...\] $$ ... $$ (block / display math)
* \(...\) $ ... $ (inline math)
* same-line $$$$ $ ... $ (inline math display math
* can't live inside table cells)
* `$$$$` $$ $$ (strip wrapping backtick code)
* `$$` $ $ (strip wrapping backtick code)
*/
function convertLatexDelimiters(content: string): string {
// 1. Block math: \[...\] → $$...$$
content = content.replace(/\\\[([\s\S]*?)\\\]/g, (_match, inner) => {
return `$$${inner}$$`;
});
// 2. Inline math: \(...\) → $...$
content = content.replace(/\\\(([\s\S]*?)\\\)/g, (_match, inner) => {
return `$${inner}$`;
});
// 3. Strip backtick wrapping around math: `$$...$$` → $$...$$ and `$...$` → $...$
content = content.replace(/`(\${1,2})((?:(?!\1).)+)\1`/g, "$1$2$1");
// 4. Same-line $$...$$ → $...$ (inline math) so it works inside table cells.
// True display math has $$ on its own line, so this only affects inline usage.
content = content.replace(/\$\$([^\n]+?)\$\$/g, (_match, inner) => {
return `$${inner}$`;
});
return content;
}
export function MarkdownViewer({ content, className }: MarkdownViewerProps) {
const processedContent = convertLatexDelimiters(stripOuterMarkdownFence(content));
const components: StreamdownProps["components"] = {
// Define custom components for markdown elements
callout: ({ children, ...props }) => (
<div
className="my-4 rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-950"
{...props}
>
{children}
</div>
),
p: ({ children, ...props }) => (
<p className="my-2" {...props}>
{children}
@ -71,42 +115,41 @@ export function MarkdownViewer({ content, className }: MarkdownViewerProps) {
/>
),
table: ({ ...props }) => (
<div className="overflow-x-auto my-4">
<table className="min-w-full divide-y divide-border" {...props} />
<div className="overflow-x-auto my-4 rounded-lg border border-border w-full">
<table className="w-full divide-y divide-border" {...props} />
</div>
),
th: ({ ...props }) => <th className="px-3 py-2 text-left font-medium bg-muted" {...props} />,
td: ({ ...props }) => <td className="px-3 py-2 border-t border-border" {...props} />,
code: ({ className, children, ...props }) => {
const match = /language-(\w+)/.exec(className || "");
const isInline = !match;
if (isInline) {
return (
<code className="bg-muted px-1 py-0.5 rounded text-xs" {...props}>
{children}
</code>
);
}
// For code blocks, let Streamdown handle syntax highlighting
return (
<code className={className} {...props}>
{children}
</code>
);
},
th: ({ ...props }) => (
<th
className="px-4 py-2.5 text-left text-sm font-semibold text-muted-foreground/80 bg-muted/30 border-r border-border/40 last:border-r-0"
{...props}
/>
),
td: ({ ...props }) => (
<td
className="px-4 py-2.5 text-sm border-t border-r border-border/40 last:border-r-0"
{...props}
/>
),
};
return (
<div
className={cn(
"prose prose-sm dark:prose-invert max-w-none overflow-hidden [&_pre]:overflow-x-auto [&_code]:wrap-break-word [&_table]:block [&_table]:overflow-x-auto",
"max-w-none overflow-hidden",
"[&_[data-streamdown=code-block-header]]:!bg-transparent",
"[&_[data-streamdown=code-block]>*]:!border-none [&_[data-streamdown=code-block]>*]:![box-shadow:none]",
"[&_[data-streamdown=code-block-download-button]]:!hidden",
className
)}
>
<Streamdown components={components} shikiTheme={["github-light", "github-dark"]}>
{content}
<Streamdown
components={components}
plugins={{ code, math }}
controls={{ code: true }}
mode="static"
>
{processedContent}
</Streamdown>
</div>
);

View file

@ -138,6 +138,26 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
return (
<div className={cn("flex items-center gap-1", className)}>
{/* Globe indicator when public snapshots exist - clicks to settings */}
{hasPublicSnapshots && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => router.push(`/dashboard/${params.search_space_id}/settings`)}
className="flex items-center justify-center h-8 w-8 rounded-md bg-muted/50 hover:bg-muted transition-colors"
>
<Globe className="h-4 w-4 text-muted-foreground" />
</button>
</TooltipTrigger>
<TooltipContent>
{snapshotCount === 1
? "This chat has a public link"
: `This chat has ${snapshotCount} public links`}
</TooltipContent>
</Tooltip>
)}
<Popover open={open} onOpenChange={setOpen}>
<Tooltip>
<TooltipTrigger asChild>
@ -242,26 +262,6 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
</div>
</PopoverContent>
</Popover>
{/* Globe indicator when public snapshots exist - clicks to settings */}
{hasPublicSnapshots && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => router.push(`/dashboard/${params.search_space_id}/settings`)}
className="flex items-center justify-center h-8 w-8 rounded-md bg-muted/50 hover:bg-muted transition-colors"
>
<Globe className="h-4 w-4 text-muted-foreground" />
</button>
</TooltipTrigger>
<TooltipContent>
{snapshotCount === 1
? "This chat has a public link"
: `This chat has ${snapshotCount} public links`}
</TooltipContent>
</Tooltip>
)}
</div>
);
}

View file

@ -2,8 +2,10 @@
import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { Navbar } from "@/components/homepage/navbar";
import { ReportPanel } from "@/components/report-panel/report-panel";
import { DisplayImageToolUI } from "@/components/tool-ui/display-image";
import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
import { GenerateReportToolUI } from "@/components/tool-ui/generate-report";
import { LinkPreviewToolUI } from "@/components/tool-ui/link-preview";
import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage";
import { Spinner } from "@/components/ui/spinner";
@ -42,12 +44,16 @@ export function PublicChatView({ shareToken }: PublicChatViewProps) {
<AssistantRuntimeProvider runtime={runtime}>
{/* Tool UIs for rendering tool results */}
<GeneratePodcastToolUI />
<GenerateReportToolUI />
<LinkPreviewToolUI />
<DisplayImageToolUI />
<ScrapeWebpageToolUI />
<div className="flex h-screen flex-col pt-16">
<PublicThread footer={<PublicChatFooter shareToken={shareToken} />} />
<div className="flex h-screen pt-16 overflow-hidden">
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
<PublicThread footer={<PublicChatFooter shareToken={shareToken} />} />
</div>
<ReportPanel />
</div>
</AssistantRuntimeProvider>
</main>

View file

@ -0,0 +1,490 @@
"use client";
import { useAtomValue, useSetAtom } from "jotai";
import { ChevronDownIcon, XIcon } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { z } from "zod";
import { closeReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom";
import { MarkdownViewer } from "@/components/markdown-viewer";
import { Button } from "@/components/ui/button";
import { Drawer, DrawerContent, DrawerHandle } from "@/components/ui/drawer";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Spinner } from "@/components/ui/spinner";
import { useMediaQuery } from "@/hooks/use-media-query";
import { baseApiService } from "@/lib/apis/base-api.service";
import { authenticatedFetch } from "@/lib/auth-utils";
/**
* Zod schema for a single version entry
*/
const VersionInfoSchema = z.object({
id: z.number(),
created_at: z.string().nullish(),
});
/**
* Zod schema for the report content API response
*/
const ReportContentResponseSchema = z.object({
id: z.number(),
title: z.string(),
content: z.string().nullish(),
report_metadata: z
.object({
status: z.enum(["ready", "failed"]).nullish(),
error_message: z.string().nullish(),
word_count: z.number().nullish(),
char_count: z.number().nullish(),
section_count: z.number().nullish(),
})
.nullish(),
report_group_id: z.number().nullish(),
versions: z.array(VersionInfoSchema).nullish(),
});
type ReportContentResponse = z.infer<typeof ReportContentResponseSchema>;
type VersionInfo = z.infer<typeof VersionInfoSchema>;
/**
* Shimmer loading skeleton for report panel
*/
function ReportPanelSkeleton() {
return (
<div className="space-y-6 p-6">
{/* Title skeleton */}
<div className="h-6 w-3/4 rounded-md bg-muted/60 animate-pulse" />
{/* Paragraph 1 */}
<div className="space-y-2.5">
<div className="h-3 w-full rounded-md bg-muted/60 animate-pulse" />
<div className="h-3 w-[95%] rounded-md bg-muted/60 animate-pulse [animation-delay:100ms]" />
<div className="h-3 w-[88%] rounded-md bg-muted/60 animate-pulse [animation-delay:200ms]" />
<div className="h-3 w-[60%] rounded-md bg-muted/60 animate-pulse [animation-delay:300ms]" />
</div>
{/* Heading */}
<div className="h-5 w-2/5 rounded-md bg-muted/60 animate-pulse [animation-delay:400ms]" />
{/* Paragraph 2 */}
<div className="space-y-2.5">
<div className="h-3 w-full rounded-md bg-muted/60 animate-pulse [animation-delay:500ms]" />
<div className="h-3 w-[92%] rounded-md bg-muted/60 animate-pulse [animation-delay:600ms]" />
<div className="h-3 w-[97%] rounded-md bg-muted/60 animate-pulse [animation-delay:700ms]" />
</div>
{/* Heading */}
<div className="h-5 w-1/3 rounded-md bg-muted/60 animate-pulse [animation-delay:800ms]" />
{/* Paragraph 3 */}
<div className="space-y-2.5">
<div className="h-3 w-[90%] rounded-md bg-muted/60 animate-pulse [animation-delay:900ms]" />
<div className="h-3 w-full rounded-md bg-muted/60 animate-pulse [animation-delay:1000ms]" />
<div className="h-3 w-[75%] rounded-md bg-muted/60 animate-pulse [animation-delay:1100ms]" />
</div>
</div>
);
}
/**
* Inner content component used by both desktop panel and mobile drawer
*/
function ReportPanelContent({
reportId,
title,
onClose,
insideDrawer = false,
shareToken,
}: {
reportId: number;
title: string;
onClose?: () => void;
/** When true, adjusts dropdown behavior to work inside a Vaul drawer on mobile */
insideDrawer?: boolean;
/** When set, uses public endpoint for fetching report data (public shared chat) */
shareToken?: string | null;
}) {
const [reportContent, setReportContent] = useState<ReportContentResponse | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [exporting, setExporting] = useState<"pdf" | "docx" | "md" | null>(null);
// Version state
const [activeReportId, setActiveReportId] = useState(reportId);
const [versions, setVersions] = useState<VersionInfo[]>([]);
// Reset active version when the external reportId changes (e.g. clicking a different card)
useEffect(() => {
setActiveReportId(reportId);
}, [reportId]);
// Fetch report content (re-runs when activeReportId changes for version switching)
useEffect(() => {
let cancelled = false;
const fetchContent = async () => {
setIsLoading(true);
setError(null);
try {
const url = shareToken
? `/api/v1/public/${shareToken}/reports/${activeReportId}/content`
: `/api/v1/reports/${activeReportId}/content`;
const rawData = await baseApiService.get<unknown>(url);
if (cancelled) return;
const parsed = ReportContentResponseSchema.safeParse(rawData);
if (parsed.success) {
// Check if the report was marked as failed in metadata
if (parsed.data.report_metadata?.status === "failed") {
setError(parsed.data.report_metadata?.error_message || "Report generation failed");
} else {
setReportContent(parsed.data);
// Update versions from the response
if (parsed.data.versions && parsed.data.versions.length > 0) {
setVersions(parsed.data.versions);
}
}
} else {
console.warn("Invalid report content response:", parsed.error.issues);
setError("Invalid response format");
}
} catch (err) {
if (cancelled) return;
console.error("Error fetching report content:", err);
setError(err instanceof Error ? err.message : "Failed to load report");
} finally {
if (!cancelled) setIsLoading(false);
}
};
fetchContent();
return () => {
cancelled = true;
};
}, [activeReportId, shareToken]);
// Copy markdown content
const handleCopy = useCallback(async () => {
if (!reportContent?.content) return;
try {
await navigator.clipboard.writeText(reportContent.content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error("Failed to copy:", err);
}
}, [reportContent?.content]);
// Export report
const handleExport = useCallback(
async (format: "pdf" | "docx" | "md") => {
setExporting(format);
const safeTitle =
title
.replace(/[^a-zA-Z0-9 _-]/g, "_")
.trim()
.slice(0, 80) || "report";
try {
if (format === "md") {
// Download markdown content directly as a .md file
if (!reportContent?.content) return;
const blob = new Blob([reportContent.content], {
type: "text/markdown;charset=utf-8",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${safeTitle}.md`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} else {
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/reports/${activeReportId}/export?format=${format}`,
{ method: "GET" }
);
if (!response.ok) {
throw new Error(`Export failed: ${response.status}`);
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${safeTitle}.${format}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
} catch (err) {
console.error(`Export ${format} failed:`, err);
} finally {
setExporting(null);
}
},
[activeReportId, title, reportContent?.content]
);
// Show full-page skeleton only on initial load (no data loaded yet).
// Once we have versions/content from a prior fetch, keep the action bar visible.
const hasLoadedBefore = versions.length > 0 || reportContent !== null;
if (isLoading && !hasLoadedBefore) {
return (
<>
{/* Minimal top bar with close button even during initial load */}
<div className="flex items-center justify-end px-4 py-2 shrink-0">
{onClose && (
<Button variant="ghost" size="icon" onClick={onClose} className="size-7 shrink-0">
<XIcon className="size-4" />
<span className="sr-only">Close report panel</span>
</Button>
)}
</div>
<ReportPanelSkeleton />
</>
);
}
const activeVersionIndex = versions.findIndex((v) => v.id === activeReportId);
return (
<>
{/* Action bar — always visible after initial load */}
<div className="flex items-center justify-between px-4 py-2 shrink-0">
<div className="flex items-center gap-2">
{/* Copy button */}
<Button
variant="outline"
size="sm"
onClick={handleCopy}
disabled={isLoading || !reportContent?.content}
className="h-8 min-w-[80px] px-3.5 py-4 text-[15px]"
>
{copied ? "Copied" : "Copy"}
</Button>
{/* Export dropdown */}
<DropdownMenu modal={insideDrawer ? false : undefined}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={isLoading || !reportContent?.content}
className="h-8 px-3.5 py-4 text-[15px] gap-1.5"
>
Export
<ChevronDownIcon className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className={`min-w-[180px]${insideDrawer ? " z-[100]" : ""}`}
>
<DropdownMenuItem onClick={() => handleExport("md")}>
Download Markdown
</DropdownMenuItem>
{/* PDF/DOCX export requires server-side conversion via authenticated endpoint.
Hide for public viewers who have no auth token. */}
{!shareToken && (
<>
<DropdownMenuItem
onClick={() => handleExport("pdf")}
disabled={exporting !== null}
>
{exporting === "pdf" && <Spinner size="xs" />}
Download PDF
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleExport("docx")}
disabled={exporting !== null}
>
{exporting === "docx" && <Spinner size="xs" />}
Download DOCX
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
{/* Version switcher — only shown when multiple versions exist */}
{versions.length > 1 &&
(insideDrawer ? (
/* Mobile: compact dropdown */
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 px-3.5 py-4 text-[15px] gap-1.5"
>
v{activeVersionIndex + 1}
<ChevronDownIcon className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[120px] z-[100]">
{versions.map((v, i) => (
<DropdownMenuItem
key={v.id}
onClick={() => setActiveReportId(v.id)}
className={v.id === activeReportId ? "bg-accent font-medium" : ""}
>
Version {i + 1}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : (
/* Desktop: inline version buttons */
<div className="flex items-center gap-1">
<div className="flex items-center gap-0.5 rounded-lg border bg-muted/30 p-0.5">
{versions.map((v, i) => (
<button
key={v.id}
type="button"
onClick={() => setActiveReportId(v.id)}
className={`px-2 py-0.5 rounded-md text-xs font-medium transition-colors ${
v.id === activeReportId
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
}`}
>
v{i + 1}
</button>
))}
</div>
<span className="text-[10px] text-muted-foreground tabular-nums ml-1">
{activeVersionIndex + 1} of {versions.length}
</span>
</div>
))}
</div>
{onClose && (
<Button variant="ghost" size="icon" onClick={onClose} className="size-7 shrink-0">
<XIcon className="size-4" />
<span className="sr-only">Close report panel</span>
</Button>
)}
</div>
{/* Report content — skeleton/error/content shown only in this area */}
<div className="flex-1 overflow-y-auto scrollbar-thin">
{isLoading ? (
<ReportPanelSkeleton />
) : error || !reportContent ? (
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-6 text-center">
<div>
<p className="font-medium text-foreground">Failed to load report</p>
<p className="text-sm text-red-500 mt-1">{error || "An unknown error occurred"}</p>
</div>
</div>
) : (
<div className="px-5 py-5">
{reportContent.content ? (
<MarkdownViewer content={reportContent.content} />
) : (
<p className="text-muted-foreground italic">No content available.</p>
)}
</div>
)}
</div>
</>
);
}
/**
* Desktop report panel renders as a right-side flex sibling
*/
function DesktopReportPanel() {
const panelState = useAtomValue(reportPanelAtom);
const closePanel = useSetAtom(closeReportPanelAtom);
const panelRef = useRef<HTMLDivElement>(null);
// Close panel on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
closePanel();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [closePanel]);
if (!panelState.isOpen || !panelState.reportId) return null;
return (
<div
ref={panelRef}
className="flex w-[50%] max-w-[700px] min-w-[380px] flex-col border-l bg-background animate-in slide-in-from-right-4 duration-300 ease-out"
>
<ReportPanelContent
reportId={panelState.reportId}
title={panelState.title || "Report"}
onClose={closePanel}
shareToken={panelState.shareToken}
/>
</div>
);
}
/**
* Mobile report drawer uses Vaul (same pattern as comment sheet)
*/
function MobileReportDrawer() {
const panelState = useAtomValue(reportPanelAtom);
const closePanel = useSetAtom(closeReportPanelAtom);
if (!panelState.reportId) return null;
return (
<Drawer
open={panelState.isOpen}
onOpenChange={(open) => {
if (!open) closePanel();
}}
shouldScaleBackground={false}
>
<DrawerContent className="h-[90vh] max-h-[90vh] z-80" overlayClassName="z-80">
<DrawerHandle />
<div className="min-h-0 flex-1 flex flex-col overflow-hidden">
<ReportPanelContent
reportId={panelState.reportId}
title={panelState.title || "Report"}
insideDrawer
shareToken={panelState.shareToken}
/>
</div>
</DrawerContent>
</Drawer>
);
}
/**
* ReportPanel responsive report viewer
*
* On desktop (lg+): Renders as a right-side split panel (flex sibling to the chat thread)
* On mobile/tablet: Renders as a Vaul bottom drawer
*
* When open on desktop, the comments gutter is automatically suppressed
* (handled via showCommentsGutterAtom in current-thread.atom.ts)
*/
export function ReportPanel() {
const panelState = useAtomValue(reportPanelAtom);
const isDesktop = useMediaQuery("(min-width: 1024px)");
// Don't render anything if panel is not open
if (!panelState.isOpen || !panelState.reportId) return null;
if (isDesktop) {
return <DesktopReportPanel />;
}
return <MobileReportDrawer />;
}

View file

@ -8,7 +8,8 @@ import {
FileTextIcon,
UserIcon,
} from "lucide-react";
import { Component, type ReactNode, useCallback } from "react";
import Image from "next/image";
import { Component, type ReactNode, useCallback, useState } from "react";
import { z } from "zod";
import { Card, CardContent } from "@/components/ui/card";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
@ -126,6 +127,30 @@ function formatWordCount(count: number): string {
return `${count} words`;
}
/**
* Favicon component that fetches the site icon via Google's favicon service,
* falling back to BookOpenIcon on error.
*/
function SiteFavicon({ domain }: { domain: string }) {
const [failed, setFailed] = useState(false);
if (failed) {
return <BookOpenIcon className="size-5 text-primary" />;
}
return (
<Image
src={`https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=64`}
alt={`${domain} favicon`}
width={28}
height={28}
className="size-5 sm:size-7 rounded-sm"
onError={() => setFailed(true)}
unoptimized
/>
);
}
/**
* Article card component for displaying scraped webpage content
*/
@ -198,27 +223,35 @@ export function Article({
}}
>
{/* Header */}
<CardContent className="p-4">
<div className="flex items-start gap-3">
{/* Icon */}
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<BookOpenIcon className="size-5 text-primary" />
</div>
<CardContent className="p-3 sm:p-4">
<div className="flex items-start gap-2.5 sm:gap-3">
{/* Favicon / Icon */}
{domain ? (
<div className="flex size-8 sm:size-10 shrink-0 items-center justify-center">
<SiteFavicon domain={domain} />
</div>
) : (
<div className="flex size-8 sm:size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<BookOpenIcon className="size-4 sm:size-5 text-primary" />
</div>
)}
{/* Content */}
<div className="flex-1 min-w-0">
{/* Title */}
<h3 className="font-semibold text-sm line-clamp-2 group-hover:text-primary transition-colors">
<h3 className="font-semibold text-xs sm:text-sm line-clamp-2 group-hover:text-primary transition-colors">
{title}
</h3>
{/* Description */}
{description && (
<p className="text-muted-foreground text-xs mt-1 line-clamp-2">{description}</p>
<p className="text-muted-foreground text-[10px] sm:text-xs mt-1 line-clamp-2">
{description}
</p>
)}
{/* Metadata row */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 mt-2 text-xs text-muted-foreground">
<div className="flex flex-wrap items-center gap-x-2 sm:gap-x-3 gap-y-1 mt-1.5 sm:mt-2 text-[10px] sm:text-xs text-muted-foreground">
{domain && (
<Tooltip>
<TooltipTrigger asChild>
@ -274,13 +307,6 @@ export function Article({
)}
</div>
</div>
{/* External link indicator */}
{href && (
<div className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<ExternalLinkIcon className="size-4 text-muted-foreground" />
</div>
)}
</div>
{/* Response actions */}

View file

@ -0,0 +1,303 @@
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
import { useAtomValue, useSetAtom } from "jotai";
import { Dot, FileTextIcon } from "lucide-react";
import { useParams, usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { z } from "zod";
import { openReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { baseApiService } from "@/lib/apis/base-api.service";
/**
* Zod schemas for runtime validation
*/
const GenerateReportArgsSchema = z.object({
topic: z.string(),
source_content: z.string(),
report_style: z.string().nullish(),
user_instructions: z.string().nullish(),
parent_report_id: z.number().nullish(),
});
const GenerateReportResultSchema = z.object({
status: z.enum(["ready", "failed"]),
report_id: z.number().nullish(),
title: z.string().nullish(),
word_count: z.number().nullish(),
message: z.string().nullish(),
error: z.string().nullish(),
});
const ReportMetadataResponseSchema = z.object({
id: z.number(),
title: z.string(),
report_metadata: z
.object({
status: z.enum(["ready", "failed"]).nullish(),
error_message: z.string().nullish(),
word_count: z.number().nullish(),
section_count: z.number().nullish(),
})
.nullish(),
report_group_id: z.number().nullish(),
versions: z
.array(
z.object({
id: z.number(),
created_at: z.string().nullish(),
})
)
.nullish(),
});
/**
* Types derived from Zod schemas
*/
type GenerateReportArgs = z.infer<typeof GenerateReportArgsSchema>;
type GenerateReportResult = z.infer<typeof GenerateReportResultSchema>;
/**
* Loading state component shown while report is being generated.
* Matches the compact card layout of the completed ReportCard.
*/
function ReportGeneratingState({ topic }: { topic: string }) {
return (
<div className="my-4 overflow-hidden rounded-xl border bg-card">
<div className="flex w-full items-center gap-2 sm:gap-3 bg-muted/30 px-4 py-5 sm:px-6 sm:py-6">
<div className="flex size-8 sm:size-12 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<FileTextIcon className="size-4 sm:size-6 text-primary" />
</div>
<div className="min-w-0 flex-1">
<h3 className="font-semibold text-foreground text-sm sm:text-base leading-tight line-clamp-2">
{topic}
</h3>
<TextShimmerLoader text="Putting things together" size="sm" />
</div>
</div>
</div>
);
}
/**
* Error state component shown when report generation fails
*/
function ReportErrorState({ title, error }: { title: string; error: string }) {
return (
<div className="my-4 overflow-hidden rounded-xl border bg-card">
<div className="flex items-center gap-2 sm:gap-3 bg-muted/30 px-4 py-5 sm:px-6 sm:py-6">
<div className="flex size-8 sm:size-12 shrink-0 items-center justify-center rounded-lg bg-muted/60">
<FileTextIcon className="size-4 sm:size-6 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1">
<h3 className="font-semibold text-muted-foreground text-sm sm:text-base leading-tight line-clamp-2">
{title}
</h3>
<p className="text-muted-foreground/60 text-[11px] sm:text-xs mt-0.5 truncate">{error}</p>
</div>
</div>
</div>
);
}
/**
* Compact report card shown inline in the chat.
* Clicking it opens the report in the right-side panel (desktop) or Vaul drawer (mobile).
*/
function ReportCard({
reportId,
title,
wordCount,
shareToken,
}: {
reportId: number;
title: string;
wordCount?: number;
/** When set, uses public endpoint for fetching report data */
shareToken?: string | null;
}) {
const openPanel = useSetAtom(openReportPanelAtom);
const panelState = useAtomValue(reportPanelAtom);
const [metadata, setMetadata] = useState<{
title: string;
wordCount: number | null;
versionLabel: string | null;
}>({ title, wordCount: wordCount ?? null, versionLabel: null });
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Fetch lightweight metadata (title + counts + version info)
useEffect(() => {
let cancelled = false;
const fetchMetadata = async () => {
setIsLoading(true);
setError(null);
try {
const url = shareToken
? `/api/v1/public/${shareToken}/reports/${reportId}/content`
: `/api/v1/reports/${reportId}/content`;
const rawData = await baseApiService.get<unknown>(url);
if (cancelled) return;
const parsed = ReportMetadataResponseSchema.safeParse(rawData);
if (parsed.success) {
// Check if report was marked as failed in metadata
if (parsed.data.report_metadata?.status === "failed") {
setError(parsed.data.report_metadata?.error_message || "Report generation failed");
} else {
// Determine version label from versions array
let versionLabel: string | null = null;
const versions = parsed.data.versions;
if (versions && versions.length > 1) {
const idx = versions.findIndex((v) => v.id === reportId);
if (idx >= 0) {
versionLabel = `version ${idx + 1}`;
}
}
setMetadata({
title: parsed.data.title || title,
wordCount: parsed.data.report_metadata?.word_count ?? wordCount ?? null,
versionLabel,
});
}
}
} catch {
if (!cancelled) setError("No report found");
} finally {
if (!cancelled) setIsLoading(false);
}
};
fetchMetadata();
return () => {
cancelled = true;
};
}, [reportId, title, wordCount, shareToken]);
// Show non-clickable error card for any error (failed status, not found, etc.)
if (!isLoading && error) {
return <ReportErrorState title={title} error={error} />;
}
const isActive = panelState.isOpen && panelState.reportId === reportId;
const handleOpen = () => {
openPanel({
reportId,
title: metadata.title,
wordCount: metadata.wordCount ?? undefined,
shareToken,
});
};
return (
<div
className={`my-4 overflow-hidden rounded-xl border bg-card transition-colors ${isActive ? "ring-1 ring-primary/50" : ""}`}
>
<button
type="button"
onClick={handleOpen}
className="flex w-full items-center gap-2 sm:gap-3 bg-muted/30 px-4 py-5 sm:px-6 sm:py-6 text-left transition-colors hover:bg-muted/50 focus:outline-none focus-visible:outline-none"
>
<div className="flex size-8 sm:size-12 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<FileTextIcon className="size-4 sm:size-6 text-primary" />
</div>
<div className="min-w-0 flex-1">
<h3 className="font-semibold text-foreground text-sm sm:text-base leading-tight line-clamp-2">
{isLoading ? title : metadata.title}
</h3>
<p className="text-muted-foreground text-[10px] sm:text-xs mt-0.5">
{isLoading ? (
<span className="inline-block h-3 w-24 rounded bg-muted/60 animate-pulse" />
) : (
<>
{metadata.wordCount != null && `${metadata.wordCount.toLocaleString()} words`}
{metadata.wordCount != null && metadata.versionLabel && (
<Dot className="inline size-4" />
)}
{metadata.versionLabel}
</>
)}
</p>
</div>
</button>
</div>
);
}
/**
* Generate Report Tool UI Component
*
* This component is registered with assistant-ui to render custom UI
* when the generate_report tool is called by the agent.
*
* Unlike podcast (which uses polling), the report is generated inline
* and the result contains status: "ready" immediately.
*/
export const GenerateReportToolUI = makeAssistantToolUI<GenerateReportArgs, GenerateReportResult>({
toolName: "generate_report",
render: function GenerateReportUI({ args, result, status }) {
const params = useParams();
const pathname = usePathname();
const isPublicRoute = pathname?.startsWith("/public/");
const shareToken = isPublicRoute && typeof params?.token === "string" ? params.token : null;
const topic = args.topic || "Report";
// Loading state - tool is still running (LLM generating report)
if (status.type === "running" || status.type === "requires-action") {
return <ReportGeneratingState topic={topic} />;
}
// Incomplete/cancelled state
if (status.type === "incomplete") {
if (status.reason === "cancelled") {
return (
<div className="my-4 rounded-xl border border-muted p-3 sm:p-4 text-muted-foreground">
<p className="flex items-center gap-1.5 sm:gap-2 text-xs sm:text-sm">
<FileTextIcon className="size-3.5 sm:size-4" />
<span className="line-through">Report generation cancelled</span>
</p>
</div>
);
}
if (status.reason === "error") {
return (
<ReportErrorState
title={topic}
error={typeof status.error === "string" ? status.error : "An error occurred"}
/>
);
}
}
// No result yet
if (!result) {
return <ReportGeneratingState topic={topic} />;
}
// Failed result
if (result.status === "failed") {
return (
<ReportErrorState
title={result.title || topic}
error={result.error || "Generation failed"}
/>
);
}
// Ready with report_id
if (result.status === "ready" && result.report_id) {
return (
<ReportCard
reportId={result.report_id}
title={result.title || topic}
wordCount={result.word_count ?? undefined}
shareToken={shareToken}
/>
);
}
// Fallback - missing required data
return <ReportErrorState title={topic} error="Missing report ID" />;
},
});

View file

@ -32,6 +32,7 @@ export {
DisplayImageToolUI,
} from "./display-image";
export { GeneratePodcastToolUI } from "./generate-podcast";
export { GenerateReportToolUI } from "./generate-report";
export {
Image,
ImageErrorBoundary,

View file

@ -87,20 +87,9 @@ function ScrapeCancelledState({ url }: { url: string }) {
* Parsed Article component with error handling
*/
function ParsedArticle({ result }: { result: unknown }) {
const article = parseSerializableArticle(result);
const { description, ...article } = parseSerializableArticle(result);
return (
<Article
{...article}
maxWidth="480px"
responseActions={[{ id: "open", label: "Open Source", variant: "default" }]}
onResponseAction={(id) => {
if (id === "open" && article.href) {
window.open(article.href, "_blank", "noopener,noreferrer");
}
}}
/>
);
return <Article {...article} maxWidth="480px" />;
}
/**

View file

@ -0,0 +1,42 @@
/**
* Announcement system types
*
* Frontend-only announcement system that supports:
* - Multiple announcement categories (update, feature, maintenance, info)
* - Important flag for toast notifications
* - Read/dismissed state tracking via localStorage
*/
/** Announcement category */
export type AnnouncementCategory = "update" | "feature" | "maintenance" | "info";
/** Single announcement entry */
export interface Announcement {
/** Unique identifier */
id: string;
/** Short title */
title: string;
/** Full description (supports basic text) */
description: string;
/** Category for visual styling and filtering */
category: AnnouncementCategory;
/** ISO date string of when the announcement was published */
date: string;
/** If true, the user will see a toast notification for this announcement */
isImportant: boolean;
/** Optional CTA link */
link?: {
label: string;
url: string;
};
}
/** State stored in localStorage for tracking user interactions */
export interface AnnouncementUserState {
/** IDs of announcements the user has read (clicked/viewed) */
readIds: string[];
/** IDs of important announcements already shown as toasts */
toastedIds: string[];
/** IDs of announcements the user has explicitly dismissed */
dismissedIds: string[];
}

View file

@ -0,0 +1,119 @@
"use client";
import { useCallback, useMemo, useSyncExternalStore } from "react";
import type { Announcement, AnnouncementCategory } from "@/contracts/types/announcement.types";
import { announcements } from "@/lib/announcements/announcements-data";
import {
dismissAnnouncement,
getAnnouncementState,
isAnnouncementDismissed,
isAnnouncementRead,
markAllAnnouncementsRead,
markAnnouncementRead,
} from "@/lib/announcements/announcements-storage";
// ---------------------------------------------------------------------------
// External-store plumbing so React re-renders when localStorage changes
// ---------------------------------------------------------------------------
let stateVersion = 0;
const listeners = new Set<() => void>();
function subscribe(callback: () => void) {
listeners.add(callback);
return () => listeners.delete(callback);
}
function getSnapshot() {
return stateVersion;
}
function getServerSnapshot() {
return 0;
}
/** Bump the version so useSyncExternalStore triggers a re-render */
function notify() {
stateVersion++;
for (const listener of listeners) listener();
}
// ---------------------------------------------------------------------------
// Enriched announcement with read/dismissed state
// ---------------------------------------------------------------------------
export interface AnnouncementWithState extends Announcement {
isRead: boolean;
isDismissed: boolean;
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
interface UseAnnouncementsOptions {
/** Filter by category */
category?: AnnouncementCategory;
/** If true, include dismissed announcements (default: false) */
includeDismissed?: boolean;
}
export function useAnnouncements(options: UseAnnouncementsOptions = {}) {
const { category, includeDismissed = false } = options;
// Subscribe to state changes (re-renders when localStorage state is bumped)
useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const enriched: AnnouncementWithState[] = useMemo(() => {
let items = announcements.map((a) => ({
...a,
isRead: isAnnouncementRead(a.id),
isDismissed: isAnnouncementDismissed(a.id),
}));
if (category) {
items = items.filter((a) => a.category === category);
}
if (!includeDismissed) {
items = items.filter((a) => !a.isDismissed);
}
// Sort by date descending (newest first)
items.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
return items;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [category, includeDismissed, stateVersion]);
const unreadCount = useMemo(
() => enriched.filter((a) => !a.isRead && !a.isDismissed).length,
[enriched]
);
const handleMarkRead = useCallback((id: string) => {
markAnnouncementRead(id);
notify();
}, []);
const handleMarkAllRead = useCallback(() => {
const state = getAnnouncementState();
const unreadIds = announcements.filter((a) => !state.readIds.includes(a.id)).map((a) => a.id);
markAllAnnouncementsRead(unreadIds);
notify();
}, []);
const handleDismiss = useCallback((id: string) => {
dismissAnnouncement(id);
markAnnouncementRead(id);
notify();
}, []);
return {
announcements: enriched,
unreadCount,
markRead: handleMarkRead,
markAllRead: handleMarkAllRead,
dismiss: handleDismiss,
};
}

View file

@ -0,0 +1,65 @@
import type { Announcement } from "@/contracts/types/announcement.types";
/**
* Static announcements data.
*
* To add a new announcement, append an entry to this array.
* Set `isImportant: true` to trigger a toast notification for the user.
*
* This file can be replaced with an API call in the future.
*/
export const announcements: Announcement[] = [
{
id: "2026-02-12-announcement-syste",
title: "Introducing Announcements",
description:
"Stay up to date with the latest SurfSense news! Important announcements will appear as toast notifications so you never miss critical updates. Visit the Announcements page from the sidebar to browse all past announcements.",
category: "feature",
date: "2026-02-12T00:00:00Z",
isImportant: true,
link: {
label: "Learn more",
url: "/changelog",
},
},
// {
// id: "2026-02-10-podcast-improvements",
// title: "Podcast Generation Improvements",
// description:
// "We've improved podcast generation with faster processing, better audio quality, and support for longer documents. Try it out in any search space.",
// category: "update",
// date: "2026-02-10T00:00:00Z",
// isImportant: false,
// },
// {
// id: "2026-02-08-scheduled-maintenance",
// title: "Scheduled Maintenance — Feb 15",
// description:
// "SurfSense will undergo scheduled maintenance on February 15, 2026 from 2:00 AM to 4:00 AM UTC. During this window, the service may be temporarily unavailable. We apologize for any inconvenience.",
// category: "maintenance",
// date: "2026-02-08T00:00:00Z",
// isImportant: true,
// },
// {
// id: "2026-02-05-new-connectors",
// title: "New Connectors Available",
// description:
// "We've added support for new connectors including Linear, Jira, and Confluence. Connect your project management tools and start chatting with your data.",
// category: "feature",
// date: "2026-02-05T00:00:00Z",
// isImportant: false,
// link: {
// label: "View connectors",
// url: "#connectors",
// },
// },
// {
// id: "2026-01-28-team-collaboration",
// title: "Enhanced Team Collaboration",
// description:
// "Shared search spaces now support real-time mentions, comment threads, and role-based access control. Invite your team and collaborate more effectively.",
// category: "feature",
// date: "2026-01-28T00:00:00Z",
// isImportant: false,
// },
];

View file

@ -0,0 +1,107 @@
import type { AnnouncementUserState } from "@/contracts/types/announcement.types";
const STORAGE_KEY = "surfsense_announcements_state";
const defaultState: AnnouncementUserState = {
readIds: [],
toastedIds: [],
dismissedIds: [],
};
/**
* Get the current announcement user state from localStorage
*/
export function getAnnouncementState(): AnnouncementUserState {
if (typeof window === "undefined") return defaultState;
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return defaultState;
const parsed = JSON.parse(raw) as Partial<AnnouncementUserState>;
return {
readIds: Array.isArray(parsed.readIds) ? parsed.readIds : [],
toastedIds: Array.isArray(parsed.toastedIds) ? parsed.toastedIds : [],
dismissedIds: Array.isArray(parsed.dismissedIds) ? parsed.dismissedIds : [],
};
} catch {
return defaultState;
}
}
/**
* Save announcement user state to localStorage
*/
function saveAnnouncementState(state: AnnouncementUserState): void {
if (typeof window === "undefined") return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch {
// Silently fail if localStorage is full or unavailable
}
}
/**
* Mark an announcement as read
*/
export function markAnnouncementRead(id: string): void {
const state = getAnnouncementState();
if (!state.readIds.includes(id)) {
state.readIds.push(id);
saveAnnouncementState(state);
}
}
/**
* Mark all announcements as read
*/
export function markAllAnnouncementsRead(ids: string[]): void {
const state = getAnnouncementState();
const newIds = ids.filter((id) => !state.readIds.includes(id));
if (newIds.length > 0) {
state.readIds.push(...newIds);
saveAnnouncementState(state);
}
}
/**
* Dismiss an announcement (hide it from the list)
*/
export function dismissAnnouncement(id: string): void {
const state = getAnnouncementState();
if (!state.dismissedIds.includes(id)) {
state.dismissedIds.push(id);
saveAnnouncementState(state);
}
}
/**
* Mark an important announcement as already toasted (shown as toast)
*/
export function markAnnouncementToasted(id: string): void {
const state = getAnnouncementState();
if (!state.toastedIds.includes(id)) {
state.toastedIds.push(id);
saveAnnouncementState(state);
}
}
/**
* Check if an announcement has been read
*/
export function isAnnouncementRead(id: string): boolean {
return getAnnouncementState().readIds.includes(id);
}
/**
* Check if an announcement has been toasted
*/
export function isAnnouncementToasted(id: string): boolean {
return getAnnouncementState().toastedIds.includes(id);
}
/**
* Check if an announcement has been dismissed
*/
export function isAnnouncementDismissed(id: string): boolean {
return getAnnouncementState().dismissedIds.includes(id);
}

View file

@ -57,6 +57,8 @@
"@radix-ui/react-toggle": "^1.1.9",
"@radix-ui/react-toggle-group": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.7",
"@streamdown/code": "^1.0.2",
"@streamdown/math": "^1.0.2",
"@tabler/icons-react": "^3.34.1",
"@tanstack/query-core": "^5.90.7",
"@tanstack/react-query": "^5.90.7",
@ -79,6 +81,7 @@
"geist": "^1.4.2",
"jotai": "^2.15.1",
"jotai-tanstack-query": "^0.11.0",
"katex": "^0.16.28",
"lucide-react": "^0.477.0",
"motion": "^12.23.22",
"next": "^16.1.0",
@ -101,7 +104,7 @@
"remark-gfm": "^4.0.1",
"server-only": "^0.0.1",
"sonner": "^2.0.6",
"streamdown": "^1.6.10",
"streamdown": "^2.2.0",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"unist-util-visit": "^5.0.0",

File diff suppressed because it is too large Load diff