Merge pull request #830 from MODSetter/dev

feat: editor overhaul, Linear integration, KB sync & UI improvements
This commit is contained in:
Rohan Verma 2026-02-20 22:51:34 -08:00 committed by GitHub
commit 14ab1e4eb0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
174 changed files with 31027 additions and 28325 deletions

38
.vscode/launch.json vendored
View file

@ -24,6 +24,16 @@
"cwd": "${workspaceFolder}/surfsense_backend",
"python": "${command:python.interpreterPath}"
},
{
"name": "Backend: FastAPI (No Reload)",
"type": "debugpy",
"request": "launch",
"program": "${workspaceFolder}/surfsense_backend/main.py",
"console": "integratedTerminal",
"justMyCode": false,
"cwd": "${workspaceFolder}/surfsense_backend",
"python": "${command:python.interpreterPath}"
},
{
"name": "Backend: FastAPI (main.py)",
"type": "debugpy",
@ -124,6 +134,34 @@
"group": "Full Stack",
"order": 2
}
},
{
"name": "Full Stack: Backend (No Reload) + Frontend + Celery",
"configurations": [
"Backend: FastAPI (No Reload)",
"Frontend: Next.js",
"Celery: Worker",
"Celery: Beat Scheduler"
],
"stopAll": true,
"presentation": {
"hidden": false,
"group": "Full Stack",
"order": 3
}
},
{
"name": "Full Stack: Backend (No Reload) + Frontend",
"configurations": [
"Backend: FastAPI (No Reload)",
"Frontend: Next.js"
],
"stopAll": true,
"presentation": {
"hidden": false,
"group": "Full Stack",
"order": 4
}
}
]
}

View file

@ -89,6 +89,12 @@ docker run -d -p 3000:3000 -p 8000:8000 -p 5133:5133 -v surfsense-data:/data --n
After starting, open [http://localhost:3000](http://localhost:3000) in your browser.
**Update (Automatic updates with Watchtower):**
```bash
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock nickfedor/watchtower --run-once surfsense
```
For Docker Compose, manual installation, and other deployment options, check the [docs](https://www.surfsense.com/docs/).
### How to Realtime Collaborate (Beta)

View file

@ -212,11 +212,10 @@ run_migrations() {
echo "✅ Database migrations complete"
}
# Run migrations on first start or when explicitly requested
if [ ! -f /data/.migrations_run ] || [ "${FORCE_MIGRATIONS:-false}" = "true" ]; then
run_migrations
touch /data/.migrations_run
fi
# Always run migrations on startup - alembic upgrade head is safe to run
# every time. It only applies pending migrations (never re-runs applied ones,
# never calls downgrade). This ensures updates are applied automatically.
run_migrations
# ================================================
# Environment Variables Info

View file

@ -0,0 +1,40 @@
"""101_add_source_markdown_to_documents
Revision ID: 101
Revises: 100
Create Date: 2026-02-17
Adds source_markdown column to documents. All existing rows start as NULL
and get populated lazily by the editor route when a user first opens them.
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "101"
down_revision: str | None = "100"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
conn = op.get_bind()
existing_columns = [
col["name"] for col in sa.inspect(conn).get_columns("documents")
]
if "source_markdown" not in existing_columns:
op.add_column(
"documents",
sa.Column("source_markdown", sa.Text(), nullable=True),
)
def downgrade() -> None:
op.drop_column("documents", "source_markdown")

View file

@ -256,6 +256,18 @@ async def create_surfsense_deep_agent(
]
modified_disabled_tools.extend(notion_tools)
# Disable Linear action tools if no Linear connector is configured
has_linear_connector = (
available_connectors is not None and "LINEAR_CONNECTOR" in available_connectors
)
if not has_linear_connector:
linear_tools = [
"create_linear_issue",
"update_linear_issue",
"delete_linear_issue",
]
modified_disabled_tools.extend(linear_tools)
# Build tools using the async registry (includes MCP tools)
tools = await build_tools_async(
dependencies=dependencies,

View file

@ -23,6 +23,8 @@ Today's date (UTC): {resolved_today}
When writing mathematical formulas or equations, ALWAYS use LaTeX notation. NEVER use backtick code spans or Unicode symbols for math.
NEVER expose internal tool parameter names, backend IDs, or implementation details to the user. Always use natural, user-friendly language instead.
</system_instruction>
"""
@ -37,6 +39,8 @@ Today's date (UTC): {resolved_today}
When writing mathematical formulas or equations, ALWAYS use LaTeX notation. NEVER use backtick code spans or Unicode symbols for math.
NEVER expose internal tool parameter names, backend IDs, or implementation details to the user. Always use natural, user-friendly language instead.
</system_instruction>
"""
@ -59,6 +63,15 @@ _TOOLS_INSTRUCTIONS_COMMON = """
<tools>
You have access to the following tools:
CRITICAL BEHAVIORAL RULE SEARCH FIRST, ANSWER LATER:
For ANY user query that is ambiguous, open-ended, or could potentially have relevant context in the
knowledge base, you MUST call `search_knowledge_base` BEFORE attempting to answer from your own
general knowledge. This includes (but is not limited to) questions about concepts, topics, projects,
people, events, recommendations, or anything the user might have stored notes/documents about.
Only fall back to your own general knowledge if the search returns NO relevant results.
Do NOT skip the search and answer directly the user's knowledge base may contain personalized,
up-to-date, or domain-specific information that is more relevant than your general training data.
0. search_surfsense_docs: Search the official SurfSense documentation.
- Use this tool when the user asks anything about SurfSense itself (the application they are using).
- Args:
@ -67,9 +80,21 @@ You have access to the following tools:
- Returns: Documentation content with chunk IDs for citations (prefixed with 'doc-', e.g., [citation:doc-123])
1. search_knowledge_base: Search the user's personal knowledge base for relevant information.
- DEFAULT ACTION: For any user question or ambiguous query, ALWAYS call this tool first to check
for relevant context before answering from general knowledge. When in doubt, search.
- IMPORTANT: When searching for information (meetings, schedules, notes, tasks, etc.), ALWAYS search broadly
across ALL sources first by omitting connectors_to_search. The user may store information in various places
including calendar apps, note-taking apps (Obsidian, Notion), chat apps (Slack, Discord), and more.
- IMPORTANT (REAL-TIME / PUBLIC WEB QUERIES): For questions that require current public web data
(e.g., live exchange rates, stock prices, breaking news, weather, current events), you MUST call
`search_knowledge_base` using live web connectors via `connectors_to_search`:
["LINKUP_API", "TAVILY_API", "SEARXNG_API", "BAIDU_SEARCH_API"].
- For these real-time/public web queries, DO NOT answer from memory and DO NOT say you lack internet
access before attempting a live connector search.
- If the live connectors return no relevant results, explain that live web sources did not return enough
data and ask the user if they want you to retry with a refined query.
- FALLBACK BEHAVIOR: If the search returns no relevant results, you MAY then answer using your own
general knowledge, but clearly indicate that no matching information was found in the knowledge base.
- Only narrow to specific connectors if the user explicitly asks (e.g., "check my Slack" or "in my calendar").
- Personal notes in Obsidian, Notion, or NOTE often contain schedules, meeting times, reminders, and other
important information that may not be in calendars.
@ -96,41 +121,43 @@ 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. 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"
3. generate_report: Generate or revise a structured Markdown report artifact.
- WHEN TO CALL THIS TOOL the message must contain a creation or modification VERB directed at producing a deliverable:
* Creation verbs: write, create, generate, draft, produce, summarize into, turn into, make
* Modification verbs: revise, update, expand, add (a section), rewrite, make (it shorter/longer/formal)
* Example triggers: "generate a report about...", "write a document on...", "add a section about budget", "make the report shorter", "rewrite in formal tone"
- WHEN NOT TO CALL THIS TOOL (answer in chat instead):
* Questions or discussion about the report: "What can we add?", "What's missing?", "Is the data accurate?", "How could this be improved?"
* Suggestions or brainstorming: "What other topics could be covered?", "What else could be added?", "What would make this better?"
* Asking for explanations: "Can you explain section 2?", "Why did you include that?", "What does this part mean?"
* Quick follow-ups or critiques: "Is the conclusion strong enough?", "Are there any gaps?", "What about the competitors?"
* THE TEST: Does the message contain a creation/modification VERB (from the list above) directed at producing or changing a deliverable? If NO verb answer conversationally in chat. Do NOT assume the user wants a revision just because a report exists in the conversation.
- 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")
- topic: Short title for the report (max ~8 words).
- source_content: The text content to base the report on.
* For source_strategy="conversation" or "provided": Include a comprehensive summary of the relevant content.
* For source_strategy="kb_search": Can be empty or minimal the tool handles searching internally.
* For source_strategy="auto": Include what you have; the tool searches KB if it's not enough.
- source_strategy: Controls how the tool collects source material. One of:
* "conversation" The conversation already contains enough context (prior Q&A, discussion, pasted text, scraped pages). Pass a thorough summary as source_content. Do NOT call search_knowledge_base separately.
* "kb_search" The tool will search the knowledge base internally. Provide search_queries with 1-5 targeted queries. Do NOT call search_knowledge_base separately.
* "auto" Use source_content if sufficient, otherwise fall back to internal KB search using search_queries.
* "provided" Use only what is in source_content (default, backward-compatible).
- search_queries: When source_strategy is "kb_search" or "auto", provide 1-5 specific search queries for the knowledge base. These should be precise, not just the topic name repeated.
- report_style: Controls report depth. Options: "detailed" (DEFAULT), "deep_research", "brief".
Use "brief" ONLY when the user explicitly asks for a short/concise/one-page report (e.g., "one page", "keep it short", "brief report", "500 words"). Default to "detailed" for all other requests.
- user_instructions: Optional specific instructions (e.g., "focus on financial impacts", "include recommendations"). When revising (parent_report_id set), describe WHAT TO CHANGE. If the user mentions a length preference (e.g., "one page", "500 words", "2 pages"), include that VERBATIM here AND set report_style="brief".
- parent_report_id: Set this to the report_id from a previous generate_report result when the user wants to MODIFY an existing report. Do NOT set it for new reports or questions about reports.
- 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.
- SOURCE STRATEGY DECISION (HIGH PRIORITY follow this exactly):
* If the conversation already has substantive Q&A / discussion on the topic use source_strategy="conversation" with a comprehensive summary as source_content. Do NOT call search_knowledge_base first.
* If the user wants a report on a topic not yet discussed use source_strategy="kb_search" with targeted search_queries. Do NOT call search_knowledge_base first.
* If you have some content but might need more use source_strategy="auto" with both source_content and search_queries.
* When revising an existing report (parent_report_id set) and the conversation has relevant context use source_strategy="conversation". The revision will use the previous report content plus your source_content.
* NEVER call search_knowledge_base and then pass its results to generate_report. The tool handles KB search internally.
- 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.
@ -190,6 +217,11 @@ You have access to the following tools:
- IMPORTANT: This is different from link_preview:
* link_preview: Only fetches metadata (title, description, thumbnail) for display
* scrape_webpage: Actually reads the FULL page content so you can analyze/summarize it
- CRITICAL WHEN TO USE (always attempt scraping, never refuse before trying):
* When a user asks to "get", "fetch", "pull", "grab", "scrape", or "read" content from a URL
* When the user wants live/dynamic data from a specific webpage (e.g., tables, scores, stats, prices)
* When a URL was mentioned earlier in the conversation and the user asks for its actual content
* When link_preview or search_knowledge_base returned insufficient data and the user wants more
- Trigger scenarios:
* "Read this article and summarize it"
* "What does this page say about X?"
@ -197,6 +229,10 @@ You have access to the following tools:
* "Tell me the key points from this article"
* "What's in this webpage?"
* "Can you analyze this article?"
* "Can you get the live table/data from [URL]?"
* "Scrape it" / "Can you scrape that?" (referring to a previously mentioned URL)
* "Fetch the content from [URL]"
* "Pull the data from that page"
- Args:
- url: The URL of the webpage to scrape (must be HTTP/HTTPS)
- max_length: Maximum content length to return (default: 50000 chars)
@ -352,6 +388,14 @@ _TOOLS_INSTRUCTIONS_EXAMPLES_COMMON = """
- User: "What's in my Obsidian vault about project ideas?"
- Call: `search_knowledge_base(query="project ideas", connectors_to_search=["OBSIDIAN_CONNECTOR"])`
- User: "search me current usd to inr rate"
- Call: `search_knowledge_base(query="current USD to INR exchange rate", connectors_to_search=["LINKUP_API", "TAVILY_API", "SEARXNG_API", "BAIDU_SEARCH_API"])`
- Then answer using the returned live web results with citations.
- User: "cant you search using linkup?"
- Call: `search_knowledge_base(query="<refined user request>", connectors_to_search=["LINKUP_API"])`
- Then answer from retrieved results (or clearly state that Linkup returned no data).
- User: "Give me a podcast about AI trends based on what we discussed"
- First search for relevant content, then call: `generate_podcast(source_content="Based on our conversation and search results: [detailed summary of chat + search findings]", podcast_title="AI Trends Podcast")`
@ -363,15 +407,36 @@ _TOOLS_INSTRUCTIONS_EXAMPLES_COMMON = """
- 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")`
- Call: `generate_report(topic="AI Trends Report", source_strategy="kb_search", search_queries=["AI trends recent developments", "artificial intelligence industry trends", "AI market growth and predictions"], report_style="detailed")`
- WHY: Has creation verb "generate" call the tool. No prior discussion use kb_search.
- 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")`
- Call: `generate_report(topic="Research Report", source_strategy="conversation", 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")`
- WHY: Has creation verb "write" call the tool. Conversation has the content use source_strategy="conversation".
- 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")`
- Call: `generate_report(topic="Project Progress Executive Summary", source_strategy="kb_search", search_queries=["project progress updates", "project milestones completed", "upcoming project deadlines"], report_style="executive_summary", user_instructions="Focus on milestones achieved and upcoming deadlines")`
- WHY: Has creation verb "create" call the tool. New topic use kb_search.
- User: (after extensive Q&A about React performance) "Turn this into a report"
- Call: `generate_report(topic="React Performance Optimization Guide", source_strategy="conversation", source_content="[Thorough summary of all Q&A from this conversation about React performance...]", report_style="detailed")`
- WHY: Has creation verb "turn into" call the tool. Conversation has the content use source_strategy="conversation".
- User: (after a report on Climate Change was generated) "Add a section about carbon capture technologies"
- Call: `generate_report(topic="Climate Crisis: Causes, Impacts, and Solutions", source_strategy="conversation", source_content="[summary of conversation context if any]", parent_report_id=<previous_report_id>, user_instructions="Add a new section about carbon capture technologies")`
- WHY: Has modification verb "add" + specific deliverable target call the tool with parent_report_id. Use source_strategy="conversation" since the report already exists.
- User: (after a report was generated) "What else could we add to have more depth?"
- Do NOT call generate_report. Answer in chat with suggestions, e.g.: "Here are some areas we could expand: 1. ... 2. ... 3. ... Would you like me to add any of these to the report?"
- WHY: No creation/modification verb directed at producing a deliverable. This is a question asking for suggestions.
- User: (after a report was generated) "Is the conclusion strong enough?"
- Do NOT call generate_report. Answer in chat, e.g.: "The conclusion covers X and Y well, but could be strengthened by adding Z. Want me to revise it?"
- WHY: This is a question/critique, not a modification request.
- User: (after a report was generated) "What's missing from this report?"
- Do NOT call generate_report. Answer in chat with analysis of gaps.
- WHY: This is a question. The user is asking you to identify gaps, not to fix them yet.
- User: "Check out https://dev.to/some-article"
- Call: `link_preview(url="https://dev.to/some-article")`
@ -434,6 +499,15 @@ _TOOLS_INSTRUCTIONS_EXAMPLES_COMMON = """
- Call: `display_image(src="https://example.com/nn-diagram.png", alt="Neural Network Diagram", title="Neural Network Architecture")`
- Then provide your explanation, referencing the displayed image
- User: (after discussing https://example.com/stats in the conversation) "Can you get the live data from that page?"
- Call: `scrape_webpage(url="https://example.com/stats")`
- IMPORTANT: Always attempt scraping first. Never refuse before trying the tool.
- Then present the extracted data to the user.
- User: "Pull the table from https://example.com/leaderboard"
- Call: `scrape_webpage(url="https://example.com/leaderboard")`
- Then parse and present the table data from the scraped content.
- User: "Generate an image of a cat"
- Step 1: `generate_image(prompt="A fluffy orange tabby cat sitting on a windowsill, bathed in warm golden sunlight, soft bokeh background with green houseplants, photorealistic style, cozy atmosphere")`
- Step 2: Use the returned "src" URL to display it: `display_image(src="<returned_url>", alt="A fluffy orange tabby cat on a windowsill", title="Generated Image")`
@ -496,6 +570,7 @@ CRITICAL CITATION REQUIREMENTS:
<document_structure_example>
The documents you receive are structured like this:
**Knowledge base documents (numeric chunk IDs):**
<document>
<document_metadata>
<document_id>42</document_id>
@ -511,7 +586,24 @@ The documents you receive are structured like this:
</document_content>
</document>
IMPORTANT: You MUST cite using the chunk ids (e.g. 123, 124, doc-45). Do NOT cite document_id.
**Live web search results (URL chunk IDs):**
<document>
<document_metadata>
<document_id>TAVILY_API::Some Title::https://example.com/article</document_id>
<document_type>TAVILY_API</document_type>
<title><![CDATA[Some web search result]]></title>
<url><![CDATA[https://example.com/article]]></url>
</document_metadata>
<document_content>
<chunk id='https://example.com/article'><![CDATA[Content from web search...]]></chunk>
</document_content>
</document>
IMPORTANT: You MUST cite using the EXACT chunk ids from the `<chunk id='...'>` tags.
- For knowledge base documents, chunk ids are numeric (e.g. 123, 124) or prefixed (e.g. doc-45).
- For live web search results, chunk ids are URLs (e.g. https://example.com/article).
Do NOT cite document_id. Always use the chunk id.
</document_structure_example>
<citation_format>
@ -523,13 +615,15 @@ IMPORTANT: You MUST cite using the chunk ids (e.g. 123, 124, doc-45). Do NOT cit
- NEVER format citations as clickable links or as markdown links like "([citation:5](https://example.com))". Always use plain square brackets only
- NEVER make up chunk IDs if you are unsure about the chunk_id. It is better to omit the citation than to guess
- Copy the EXACT chunk id from the XML - if it says `<chunk id='doc-123'>`, use [citation:doc-123]
- If the chunk id is a URL like `<chunk id='https://example.com/page'>`, use [citation:https://example.com/page]
</citation_format>
<citation_examples>
CORRECT citation formats:
- [citation:5]
- [citation:5] (numeric chunk ID from knowledge base)
- [citation:doc-123] (for Surfsense documentation chunks)
- [citation:chunk_id1], [citation:chunk_id2], [citation:chunk_id3]
- [citation:https://example.com/article] (URL chunk ID from web search results)
- [citation:chunk_id1], [citation:chunk_id2], [citation:chunk_id3] (multiple citations)
INCORRECT citation formats (DO NOT use):
- Using parentheses and markdown links: ([citation:5](https://github.com/MODSetter/SurfSense))
@ -544,7 +638,7 @@ INCORRECT citation formats (DO NOT use):
<citation_output_example>
Based on your GitHub repositories and video content, Python's asyncio library provides tools for writing concurrent code using the async/await syntax [citation:5]. It's particularly useful for I/O-bound and high-level structured network code [citation:5].
The key advantage of asyncio is that it can improve performance by allowing other code to run while waiting for I/O operations to complete [citation:12]. This makes it excellent for scenarios like web scraping, API calls, database operations, or any situation where your program spends time waiting for external resources.
According to web search results, the key advantage of asyncio is that it can improve performance by allowing other code to run while waiting for I/O operations to complete [citation:https://docs.python.org/3/library/asyncio.html]. This makes it excellent for scenarios like web scraping, API calls, database operations, or any situation where your program spends time waiting for external resources.
However, from your video learning, it's important to note that asyncio is not suitable for CPU-bound tasks as it runs on a single thread [citation:12]. For computationally intensive work, you'd want to use multiprocessing instead.
</citation_output_example>

View file

@ -210,6 +210,7 @@ def format_documents_for_context(documents: list[dict[str, Any]]) -> str:
source = (
(doc.get("source") if isinstance(doc, dict) else None)
or document_info.get("document_type")
or metadata.get("document_type")
or "UNKNOWN"
)
@ -268,10 +269,20 @@ def format_documents_for_context(documents: list[dict[str, Any]]) -> str:
continue
grouped[doc_key]["chunks"].append({"chunk_id": chunk_id, "content": content})
# Live search connectors whose results should be cited by URL rather than
# a numeric chunk_id (the numeric IDs are meaningless auto-incremented counters).
live_search_connectors = {
"TAVILY_API",
"SEARXNG_API",
"LINKUP_API",
"BAIDU_SEARCH_API",
}
# Render XML expected by citation instructions
parts: list[str] = []
for g in grouped.values():
metadata_json = json.dumps(g["metadata"], ensure_ascii=False)
is_live_search = g["document_type"] in live_search_connectors
parts.append("<document>")
parts.append("<document_metadata>")
@ -286,7 +297,10 @@ def format_documents_for_context(documents: list[dict[str, Any]]) -> str:
for ch in g["chunks"]:
ch_content = ch["content"]
ch_id = ch["chunk_id"]
# For live search connectors, use the document URL as the chunk id
# so the LLM outputs [citation:https://...] which the frontend
# renders as a clickable link.
ch_id = g["url"] if (is_live_search and g["url"]) else ch["chunk_id"]
if ch_id is None:
parts.append(f" <chunk><![CDATA[{ch_content}]]></chunk>")
else:
@ -579,6 +593,9 @@ IMPORTANT:
- If the user requests a specific source type (e.g. "my notes", "Slack messages"), pass `connectors_to_search=[...]` using the enums below.
- If `connectors_to_search` is omitted/empty, the system will search broadly.
- Only connectors that are enabled/configured for this search space are available.{doc_types_info}
- For real-time/public web queries (e.g., current exchange rates, stock prices, breaking news, weather),
explicitly include live web connectors in `connectors_to_search`, prioritizing:
["LINKUP_API", "TAVILY_API", "SEARXNG_API", "BAIDU_SEARCH_API"].
## Available connector enums for `connectors_to_search`

View file

@ -0,0 +1,11 @@
"""Linear tools for creating, updating, and deleting issues."""
from .create_issue import create_create_linear_issue_tool
from .delete_issue import create_delete_linear_issue_tool
from .update_issue import create_update_linear_issue_tool
__all__ = [
"create_create_linear_issue_tool",
"create_delete_linear_issue_tool",
"create_update_linear_issue_tool",
]

View file

@ -0,0 +1,241 @@
import logging
from typing import Any
from langchain_core.tools import tool
from langgraph.types import interrupt
from sqlalchemy.ext.asyncio import AsyncSession
from app.connectors.linear_connector import LinearAPIError, LinearConnector
from app.services.linear import LinearToolMetadataService
logger = logging.getLogger(__name__)
def create_create_linear_issue_tool(
db_session: AsyncSession | None = None,
search_space_id: int | None = None,
user_id: str | None = None,
connector_id: int | None = None,
):
"""
Factory function to create the create_linear_issue tool.
Args:
db_session: Database session for accessing the Linear connector
search_space_id: Search space ID to find the Linear connector
user_id: User ID for fetching user-specific context
connector_id: Optional specific connector ID (if known)
Returns:
Configured create_linear_issue tool
"""
@tool
async def create_linear_issue(
title: str,
description: str | None = None,
) -> dict[str, Any]:
"""Create a new issue in Linear.
Use this tool when the user explicitly asks to create, add, or file
a new issue / ticket / task in Linear.
Args:
title: Short, descriptive issue title.
description: Optional markdown body for the issue.
Returns:
Dictionary with:
- status: "success", "rejected", or "error"
- issue_id: Linear issue UUID (if success)
- identifier: Human-readable ID like "ENG-42" (if success)
- url: URL to the created issue (if success)
- message: Result message
IMPORTANT: If status is "rejected", the user explicitly declined the action.
Respond with a brief acknowledgment (e.g., "Understood, I won't create the issue.")
and move on. Do NOT retry, troubleshoot, or suggest alternatives.
Examples:
- "Create a Linear issue titled 'Fix login bug'"
- "Add a ticket for the payment timeout problem"
- "File an issue about the broken search feature"
"""
logger.info(f"create_linear_issue called: title='{title}'")
if db_session is None or search_space_id is None or user_id is None:
logger.error(
"Linear tool not properly configured - missing required parameters"
)
return {
"status": "error",
"message": "Linear tool not properly configured. Please contact support.",
}
try:
metadata_service = LinearToolMetadataService(db_session)
context = await metadata_service.get_creation_context(
search_space_id, user_id
)
if "error" in context:
logger.error(f"Failed to fetch creation context: {context['error']}")
return {"status": "error", "message": context["error"]}
logger.info(f"Requesting approval for creating Linear issue: '{title}'")
approval = interrupt(
{
"type": "linear_issue_creation",
"action": {
"tool": "create_linear_issue",
"params": {
"title": title,
"description": description,
"team_id": None,
"state_id": None,
"assignee_id": None,
"priority": None,
"label_ids": [],
"connector_id": connector_id,
},
},
"context": context,
}
)
decisions_raw = (
approval.get("decisions", []) if isinstance(approval, dict) else []
)
decisions = (
decisions_raw if isinstance(decisions_raw, list) else [decisions_raw]
)
decisions = [d for d in decisions if isinstance(d, dict)]
if not decisions:
logger.warning("No approval decision received")
return {"status": "error", "message": "No approval decision received"}
decision = decisions[0]
decision_type = decision.get("type") or decision.get("decision_type")
logger.info(f"User decision: {decision_type}")
if decision_type == "reject":
logger.info("Linear issue creation rejected by user")
return {
"status": "rejected",
"message": "User declined. The issue was not created. Do not ask again or suggest alternatives.",
}
final_params: dict[str, Any] = {}
edited_action = decision.get("edited_action")
if isinstance(edited_action, dict):
edited_args = edited_action.get("args")
if isinstance(edited_args, dict):
final_params = edited_args
elif isinstance(decision.get("args"), dict):
final_params = decision["args"]
final_title = final_params.get("title", title)
final_description = final_params.get("description", description)
final_team_id = final_params.get("team_id")
final_state_id = final_params.get("state_id")
final_assignee_id = final_params.get("assignee_id")
final_priority = final_params.get("priority")
final_label_ids = final_params.get("label_ids") or []
final_connector_id = final_params.get("connector_id", connector_id)
if not final_title or not final_title.strip():
logger.error("Title is empty or contains only whitespace")
return {"status": "error", "message": "Issue title cannot be empty."}
if not final_team_id:
return {
"status": "error",
"message": "A team must be selected to create an issue.",
}
from sqlalchemy.future import select
from app.db import SearchSourceConnector, SearchSourceConnectorType
actual_connector_id = final_connector_id
if actual_connector_id is None:
result = await db_session.execute(
select(SearchSourceConnector).filter(
SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.LINEAR_CONNECTOR,
)
)
connector = result.scalars().first()
if not connector:
return {
"status": "error",
"message": "No Linear connector found. Please connect Linear in your workspace settings.",
}
actual_connector_id = connector.id
logger.info(f"Found Linear connector: id={actual_connector_id}")
else:
result = await db_session.execute(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == actual_connector_id,
SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.LINEAR_CONNECTOR,
)
)
connector = result.scalars().first()
if not connector:
return {
"status": "error",
"message": "Selected Linear connector is invalid or has been disconnected.",
}
logger.info(f"Validated Linear connector: id={actual_connector_id}")
logger.info(
f"Creating Linear issue with final params: title='{final_title}'"
)
linear_client = LinearConnector(
session=db_session, connector_id=actual_connector_id
)
result = await linear_client.create_issue(
team_id=final_team_id,
title=final_title,
description=final_description,
state_id=final_state_id,
assignee_id=final_assignee_id,
priority=final_priority,
label_ids=final_label_ids if final_label_ids else None,
)
if result.get("status") == "error":
logger.error(f"Failed to create Linear issue: {result.get('message')}")
return {"status": "error", "message": result.get("message")}
logger.info(
f"Linear issue created: {result.get('identifier')} - {result.get('title')}"
)
return {
"status": "success",
"issue_id": result.get("id"),
"identifier": result.get("identifier"),
"url": result.get("url"),
"message": result.get("message"),
}
except Exception as e:
from langgraph.errors import GraphInterrupt
if isinstance(e, GraphInterrupt):
raise
logger.error(f"Error creating Linear issue: {e}", exc_info=True)
if isinstance(e, ValueError | LinearAPIError):
message = str(e)
else:
message = (
"Something went wrong while creating the issue. Please try again."
)
return {"status": "error", "message": message}
return create_linear_issue

View file

@ -0,0 +1,266 @@
import logging
from typing import Any
from langchain_core.tools import tool
from langgraph.types import interrupt
from sqlalchemy.ext.asyncio import AsyncSession
from app.connectors.linear_connector import LinearAPIError, LinearConnector
from app.services.linear import LinearToolMetadataService
logger = logging.getLogger(__name__)
def create_delete_linear_issue_tool(
db_session: AsyncSession | None = None,
search_space_id: int | None = None,
user_id: str | None = None,
connector_id: int | None = None,
):
"""
Factory function to create the delete_linear_issue tool.
Args:
db_session: Database session for accessing the Linear connector
search_space_id: Search space ID to find the Linear connector
user_id: User ID for finding the correct Linear connector
connector_id: Optional specific connector ID (if known)
Returns:
Configured delete_linear_issue tool
"""
@tool
async def delete_linear_issue(
issue_ref: str,
delete_from_kb: bool = False,
) -> dict[str, Any]:
"""Archive (delete) a Linear issue.
Use this tool when the user asks to delete, remove, or archive a Linear issue.
Note that Linear archives issues rather than permanently deleting them
(they can be restored from the archive).
Args:
issue_ref: The issue to delete. Can be the issue title (e.g. "Fix login bug"),
the identifier (e.g. "ENG-42"), or the full document title
(e.g. "ENG-42: Fix login bug").
delete_from_kb: Whether to also remove the issue from the knowledge base.
Default is False. Set to True to remove from both Linear
and the knowledge base.
Returns:
Dictionary with:
- status: "success", "rejected", "not_found", or "error"
- identifier: Human-readable ID like "ENG-42" (if success)
- message: Success or error message
- deleted_from_kb: Whether the issue was also removed from the knowledge base (if success)
IMPORTANT:
- If status is "rejected", the user explicitly declined the action.
Respond with a brief acknowledgment (e.g., "Understood, I won't delete the issue.")
and move on. Do NOT ask for alternatives or troubleshoot.
- If status is "not_found", inform the user conversationally using the exact message
provided. Do NOT treat this as an error. Simply relay the message and ask the user
to verify the issue title or identifier, or check if it has been indexed.
Examples:
- "Delete the 'Fix login bug' Linear issue"
- "Archive ENG-42"
- "Remove the 'Old payment flow' issue from Linear"
"""
logger.info(
f"delete_linear_issue called: issue_ref='{issue_ref}', delete_from_kb={delete_from_kb}"
)
if db_session is None or search_space_id is None or user_id is None:
logger.error(
"Linear tool not properly configured - missing required parameters"
)
return {
"status": "error",
"message": "Linear tool not properly configured. Please contact support.",
}
try:
metadata_service = LinearToolMetadataService(db_session)
context = await metadata_service.get_delete_context(
search_space_id, user_id, issue_ref
)
if "error" in context:
error_msg = context["error"]
if "not found" in error_msg.lower():
logger.warning(f"Issue not found: {error_msg}")
return {"status": "not_found", "message": error_msg}
else:
logger.error(f"Failed to fetch delete context: {error_msg}")
return {"status": "error", "message": error_msg}
issue_id = context["issue"]["id"]
issue_identifier = context["issue"].get("identifier", "")
document_id = context["issue"]["document_id"]
connector_id_from_context = context.get("workspace", {}).get("id")
logger.info(
f"Requesting approval for deleting Linear issue: '{issue_ref}' "
f"(id={issue_id}, delete_from_kb={delete_from_kb})"
)
approval = interrupt(
{
"type": "linear_issue_deletion",
"action": {
"tool": "delete_linear_issue",
"params": {
"issue_id": issue_id,
"connector_id": connector_id_from_context,
"delete_from_kb": delete_from_kb,
},
},
"context": context,
}
)
decisions_raw = (
approval.get("decisions", []) if isinstance(approval, dict) else []
)
decisions = (
decisions_raw if isinstance(decisions_raw, list) else [decisions_raw]
)
decisions = [d for d in decisions if isinstance(d, dict)]
if not decisions:
logger.warning("No approval decision received")
return {"status": "error", "message": "No approval decision received"}
decision = decisions[0]
decision_type = decision.get("type") or decision.get("decision_type")
logger.info(f"User decision: {decision_type}")
if decision_type == "reject":
logger.info("Linear issue deletion rejected by user")
return {
"status": "rejected",
"message": "User declined. The issue was not deleted. Do not ask again or suggest alternatives.",
}
edited_action = decision.get("edited_action")
final_params: dict[str, Any] = {}
if isinstance(edited_action, dict):
edited_args = edited_action.get("args")
if isinstance(edited_args, dict):
final_params = edited_args
elif isinstance(decision.get("args"), dict):
final_params = decision["args"]
final_issue_id = final_params.get("issue_id", issue_id)
final_connector_id = final_params.get(
"connector_id", connector_id_from_context
)
final_delete_from_kb = final_params.get("delete_from_kb", delete_from_kb)
logger.info(
f"Deleting Linear issue with final params: issue_id={final_issue_id}, "
f"connector_id={final_connector_id}, delete_from_kb={final_delete_from_kb}"
)
from sqlalchemy.future import select
from app.db import SearchSourceConnector, SearchSourceConnectorType
if final_connector_id:
result = await db_session.execute(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == final_connector_id,
SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.LINEAR_CONNECTOR,
)
)
connector = result.scalars().first()
if not connector:
logger.error(
f"Invalid connector_id={final_connector_id} for search_space_id={search_space_id}"
)
return {
"status": "error",
"message": "Selected Linear connector is invalid or has been disconnected.",
}
actual_connector_id = connector.id
logger.info(f"Validated Linear connector: id={actual_connector_id}")
else:
logger.error("No connector found for this issue")
return {
"status": "error",
"message": "No connector found for this issue.",
}
linear_client = LinearConnector(
session=db_session, connector_id=actual_connector_id
)
result = await linear_client.archive_issue(issue_id=final_issue_id)
logger.info(
f"archive_issue result: {result.get('status')} - {result.get('message', '')}"
)
deleted_from_kb = False
if (
result.get("status") == "success"
and final_delete_from_kb
and document_id
):
try:
from app.db import Document
doc_result = await db_session.execute(
select(Document).filter(Document.id == document_id)
)
document = doc_result.scalars().first()
if document:
await db_session.delete(document)
await db_session.commit()
deleted_from_kb = True
logger.info(
f"Deleted document {document_id} from knowledge base"
)
else:
logger.warning(f"Document {document_id} not found in KB")
except Exception as e:
logger.error(f"Failed to delete document from KB: {e}")
await db_session.rollback()
result["warning"] = (
f"Issue archived in Linear, but failed to remove from knowledge base: {e!s}"
)
if result.get("status") == "success":
result["deleted_from_kb"] = deleted_from_kb
if issue_identifier:
result["message"] = (
f"Issue {issue_identifier} archived successfully."
)
if deleted_from_kb:
result["message"] = (
f"{result.get('message', '')} Also removed from the knowledge base."
)
return result
except Exception as e:
from langgraph.errors import GraphInterrupt
if isinstance(e, GraphInterrupt):
raise
logger.error(f"Error deleting Linear issue: {e}", exc_info=True)
if isinstance(e, ValueError | LinearAPIError):
message = str(e)
else:
message = (
"Something went wrong while deleting the issue. Please try again."
)
return {"status": "error", "message": message}
return delete_linear_issue

View file

@ -0,0 +1,334 @@
import logging
from typing import Any
from langchain_core.tools import tool
from langgraph.types import interrupt
from sqlalchemy.ext.asyncio import AsyncSession
from app.connectors.linear_connector import LinearAPIError, LinearConnector
from app.services.linear import LinearKBSyncService, LinearToolMetadataService
logger = logging.getLogger(__name__)
def create_update_linear_issue_tool(
db_session: AsyncSession | None = None,
search_space_id: int | None = None,
user_id: str | None = None,
connector_id: int | None = None,
):
"""
Factory function to create the update_linear_issue tool.
Args:
db_session: Database session for accessing the Linear connector
search_space_id: Search space ID to find the Linear connector
user_id: User ID for fetching user-specific context
connector_id: Optional specific connector ID (if known)
Returns:
Configured update_linear_issue tool
"""
@tool
async def update_linear_issue(
issue_ref: str,
new_title: str | None = None,
new_description: str | None = None,
new_state_name: str | None = None,
new_assignee_email: str | None = None,
new_priority: int | None = None,
new_label_names: list[str] | None = None,
) -> dict[str, Any]:
"""Update an existing Linear issue that has been indexed in the knowledge base.
Use this tool when the user asks to modify, change, or update a Linear issue
for example, changing its status, reassigning it, updating its title or description,
adjusting its priority, or changing its labels.
Only issues already indexed in the knowledge base can be updated.
Args:
issue_ref: The issue to update. Can be the issue title (e.g. "Fix login bug"),
the identifier (e.g. "ENG-42"), or the full document title
(e.g. "ENG-42: Fix login bug"). Matched case-insensitively.
new_title: New title for the issue (optional).
new_description: New markdown body for the issue (optional).
new_state_name: New workflow state name (e.g. "In Progress", "Done").
Matched case-insensitively against the team's states.
new_assignee_email: Email address of the new assignee.
Matched case-insensitively against the team's members.
new_priority: New priority (0 = No Priority, 1 = Urgent, 2 = High,
3 = Medium, 4 = Low).
new_label_names: New set of label names to apply.
Matched case-insensitively against the team's labels.
Unrecognised names are silently skipped.
Returns:
Dictionary with:
- status: "success", "rejected", "not_found", or "error"
- identifier: Human-readable ID like "ENG-42" (if success)
- url: URL to the updated issue (if success)
- message: Result message
IMPORTANT:
- If status is "rejected", the user explicitly declined the action.
Respond with a brief acknowledgment (e.g., "Understood, I didn't update the issue.")
and move on. Do NOT ask for alternatives or troubleshoot.
- If status is "not_found", inform the user conversationally using the exact message
provided. Do NOT treat this as an error. Simply relay the message and ask the user
to verify the issue title or identifier, or check if it has been indexed.
Examples:
- "Mark the 'Fix login bug' issue as done"
- "Assign ENG-42 to john@company.com"
- "Change the priority of 'Payment timeout' to urgent"
"""
logger.info(f"update_linear_issue called: issue_ref='{issue_ref}'")
if db_session is None or search_space_id is None or user_id is None:
logger.error(
"Linear tool not properly configured - missing required parameters"
)
return {
"status": "error",
"message": "Linear tool not properly configured. Please contact support.",
}
try:
metadata_service = LinearToolMetadataService(db_session)
context = await metadata_service.get_update_context(
search_space_id, user_id, issue_ref
)
if "error" in context:
error_msg = context["error"]
if "not found" in error_msg.lower():
logger.warning(f"Issue not found: {error_msg}")
return {"status": "not_found", "message": error_msg}
else:
logger.error(f"Failed to fetch update context: {error_msg}")
return {"status": "error", "message": error_msg}
issue_id = context["issue"]["id"]
document_id = context["issue"]["document_id"]
connector_id_from_context = context.get("workspace", {}).get("id")
team = context.get("team", {})
new_state_id = _resolve_state(team, new_state_name)
new_assignee_id = _resolve_assignee(team, new_assignee_email)
new_label_ids = _resolve_labels(team, new_label_names)
logger.info(
f"Requesting approval for updating Linear issue: '{issue_ref}' (id={issue_id})"
)
approval = interrupt(
{
"type": "linear_issue_update",
"action": {
"tool": "update_linear_issue",
"params": {
"issue_id": issue_id,
"document_id": document_id,
"new_title": new_title,
"new_description": new_description,
"new_state_id": new_state_id,
"new_assignee_id": new_assignee_id,
"new_priority": new_priority,
"new_label_ids": new_label_ids,
"connector_id": connector_id_from_context,
},
},
"context": context,
}
)
decisions_raw = (
approval.get("decisions", []) if isinstance(approval, dict) else []
)
decisions = (
decisions_raw if isinstance(decisions_raw, list) else [decisions_raw]
)
decisions = [d for d in decisions if isinstance(d, dict)]
if not decisions:
logger.warning("No approval decision received")
return {"status": "error", "message": "No approval decision received"}
decision = decisions[0]
decision_type = decision.get("type") or decision.get("decision_type")
logger.info(f"User decision: {decision_type}")
if decision_type == "reject":
logger.info("Linear issue update rejected by user")
return {
"status": "rejected",
"message": "User declined. The issue was not updated. Do not ask again or suggest alternatives.",
}
edited_action = decision.get("edited_action")
final_params: dict[str, Any] = {}
if isinstance(edited_action, dict):
edited_args = edited_action.get("args")
if isinstance(edited_args, dict):
final_params = edited_args
elif isinstance(decision.get("args"), dict):
final_params = decision["args"]
final_issue_id = final_params.get("issue_id", issue_id)
final_document_id = final_params.get("document_id", document_id)
final_new_title = final_params.get("new_title", new_title)
final_new_description = final_params.get("new_description", new_description)
final_new_state_id = final_params.get("new_state_id", new_state_id)
final_new_assignee_id = final_params.get("new_assignee_id", new_assignee_id)
final_new_priority = final_params.get("new_priority", new_priority)
final_new_label_ids: list[str] | None = final_params.get(
"new_label_ids", new_label_ids
)
final_connector_id = final_params.get(
"connector_id", connector_id_from_context
)
if not final_connector_id:
logger.error("No connector found for this issue")
return {
"status": "error",
"message": "No connector found for this issue.",
}
from sqlalchemy.future import select
from app.db import SearchSourceConnector, SearchSourceConnectorType
result = await db_session.execute(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == final_connector_id,
SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.LINEAR_CONNECTOR,
)
)
connector = result.scalars().first()
if not connector:
logger.error(
f"Invalid connector_id={final_connector_id} for search_space_id={search_space_id}"
)
return {
"status": "error",
"message": "Selected Linear connector is invalid or has been disconnected.",
}
logger.info(f"Validated Linear connector: id={final_connector_id}")
logger.info(
f"Updating Linear issue with final params: issue_id={final_issue_id}"
)
linear_client = LinearConnector(
session=db_session, connector_id=final_connector_id
)
updated_issue = await linear_client.update_issue(
issue_id=final_issue_id,
title=final_new_title,
description=final_new_description,
state_id=final_new_state_id,
assignee_id=final_new_assignee_id,
priority=final_new_priority,
label_ids=final_new_label_ids,
)
if updated_issue.get("status") == "error":
logger.error(
f"Failed to update Linear issue: {updated_issue.get('message')}"
)
return {
"status": "error",
"message": updated_issue.get("message"),
}
logger.info(
f"update_issue result: {updated_issue.get('identifier')} - {updated_issue.get('title')}"
)
if final_document_id is not None:
logger.info(
f"Updating knowledge base for document {final_document_id}..."
)
kb_service = LinearKBSyncService(db_session)
kb_result = await kb_service.sync_after_update(
document_id=final_document_id,
issue_id=final_issue_id,
user_id=user_id,
search_space_id=search_space_id,
)
if kb_result["status"] == "success":
logger.info(
f"Knowledge base successfully updated for issue {final_issue_id}"
)
kb_message = " Your knowledge base has also been updated."
elif kb_result["status"] == "not_indexed":
kb_message = " This issue will be added to your knowledge base in the next scheduled sync."
else:
logger.warning(
f"KB update failed for issue {final_issue_id}: {kb_result.get('message')}"
)
kb_message = " Your knowledge base will be updated in the next scheduled sync."
else:
kb_message = ""
identifier = updated_issue.get("identifier")
default_msg = f"Issue {identifier} updated successfully."
return {
"status": "success",
"identifier": identifier,
"url": updated_issue.get("url"),
"message": f"{updated_issue.get('message', default_msg)}{kb_message}",
}
except Exception as e:
from langgraph.errors import GraphInterrupt
if isinstance(e, GraphInterrupt):
raise
logger.error(f"Error updating Linear issue: {e}", exc_info=True)
if isinstance(e, ValueError | LinearAPIError):
message = str(e)
else:
message = (
"Something went wrong while updating the issue. Please try again."
)
return {"status": "error", "message": message}
return update_linear_issue
def _resolve_state(team: dict, state_name: str | None) -> str | None:
if not state_name:
return None
name_lower = state_name.lower()
for state in team.get("states", []):
if state.get("name", "").lower() == name_lower:
return state["id"]
return None
def _resolve_assignee(team: dict, assignee_email: str | None) -> str | None:
if not assignee_email:
return None
email_lower = assignee_email.lower()
for member in team.get("members", []):
if member.get("email", "").lower() == email_lower:
return member["id"]
return None
def _resolve_labels(team: dict, label_names: list[str] | None) -> list[str] | None:
if label_names is None:
return None
if not label_names:
return []
name_set = {n.lower() for n in label_names}
return [
label["id"]
for label in team.get("labels", [])
if label.get("name", "").lower() in name_set
]

View file

@ -5,6 +5,7 @@ This module provides a tool for fetching URL metadata (title, description,
Open Graph image, etc.) to display rich link previews in the chat UI.
"""
import asyncio
import hashlib
import logging
import re
@ -15,7 +16,7 @@ import httpx
import trafilatura
from fake_useragent import UserAgent
from langchain_core.tools import tool
from playwright.async_api import async_playwright
from playwright.sync_api import sync_playwright
from app.utils.proxy_config import get_playwright_proxy, get_residential_proxy_url
@ -175,6 +176,9 @@ async def fetch_with_chromium(url: str) -> dict[str, Any] | None:
Fetch page content using headless Chromium browser via Playwright.
Used as a fallback when simple HTTP requests are blocked (403, etc.).
Runs the sync Playwright API in a thread so it works on any event
loop, including Windows ``SelectorEventLoop``.
Args:
url: URL to fetch
@ -182,65 +186,63 @@ async def fetch_with_chromium(url: str) -> dict[str, Any] | None:
Dict with title, description, image, and raw_html, or None if failed
"""
try:
logger.info(f"[link_preview] Falling back to Chromium for {url}")
# Generate a realistic User-Agent to avoid bot detection
ua = UserAgent()
user_agent = ua.random
# Use residential proxy if configured
playwright_proxy = get_playwright_proxy()
# Use Playwright to fetch the page
async with async_playwright() as p:
launch_kwargs: dict = {"headless": True}
if playwright_proxy:
launch_kwargs["proxy"] = playwright_proxy
browser = await p.chromium.launch(**launch_kwargs)
context = await browser.new_context(user_agent=user_agent)
page = await context.new_page()
try:
await page.goto(url, wait_until="domcontentloaded", timeout=30000)
raw_html = await page.content()
finally:
await browser.close()
if not raw_html or len(raw_html.strip()) == 0:
logger.warning(f"[link_preview] Chromium returned empty content for {url}")
return None
# Extract metadata using Trafilatura
trafilatura_metadata = trafilatura.extract_metadata(raw_html)
# Extract OG image from raw HTML (trafilatura doesn't extract this)
image = extract_image(raw_html)
result = {
"title": None,
"description": None,
"image": image,
"raw_html": raw_html,
}
if trafilatura_metadata:
result["title"] = trafilatura_metadata.title
result["description"] = trafilatura_metadata.description
# If trafilatura didn't get the title/description, try OG tags
if not result["title"]:
result["title"] = extract_title(raw_html)
if not result["description"]:
result["description"] = extract_description(raw_html)
logger.info(f"[link_preview] Successfully fetched {url} via Chromium")
return result
return await asyncio.to_thread(_fetch_with_chromium_sync, url)
except Exception as e:
logger.error(f"[link_preview] Chromium fallback failed for {url}: {e}")
return None
def _fetch_with_chromium_sync(url: str) -> dict[str, Any] | None:
"""Synchronous Playwright fetch executed in a worker thread."""
logger.info(f"[link_preview] Falling back to Chromium for {url}")
ua = UserAgent()
user_agent = ua.random
playwright_proxy = get_playwright_proxy()
with sync_playwright() as p:
launch_kwargs: dict = {"headless": True}
if playwright_proxy:
launch_kwargs["proxy"] = playwright_proxy
browser = p.chromium.launch(**launch_kwargs)
context = browser.new_context(user_agent=user_agent)
page = context.new_page()
try:
page.goto(url, wait_until="domcontentloaded", timeout=30000)
raw_html = page.content()
finally:
browser.close()
if not raw_html or len(raw_html.strip()) == 0:
logger.warning(f"[link_preview] Chromium returned empty content for {url}")
return None
trafilatura_metadata = trafilatura.extract_metadata(raw_html)
image = extract_image(raw_html)
result: dict[str, Any] = {
"title": None,
"description": None,
"image": image,
"raw_html": raw_html,
}
if trafilatura_metadata:
result["title"] = trafilatura_metadata.title
result["description"] = trafilatura_metadata.description
if not result["title"]:
result["title"] = extract_title(raw_html)
if not result["description"]:
result["description"] = extract_description(raw_html)
logger.info(f"[link_preview] Successfully fetched {url} via Chromium")
return result
def create_link_preview_tool():
"""
Factory function to create the link_preview tool.

View file

@ -5,7 +5,7 @@ from langchain_core.tools import tool
from langgraph.types import interrupt
from sqlalchemy.ext.asyncio import AsyncSession
from app.connectors.notion_history import NotionHistoryConnector
from app.connectors.notion_history import NotionAPIError, NotionHistoryConnector
from app.services.notion import NotionToolMetadataService
logger = logging.getLogger(__name__)
@ -34,7 +34,6 @@ def create_create_notion_page_tool(
async def create_notion_page(
title: str,
content: str,
parent_page_id: str | None = None,
) -> dict[str, Any]:
"""Create a new page in Notion with the given title and content.
@ -45,8 +44,6 @@ def create_create_notion_page_tool(
Args:
title: The title of the Notion page.
content: The markdown content for the page body (supports headings, lists, paragraphs).
parent_page_id: Optional parent page ID to create as a subpage.
If not provided, will ask for one.
Returns:
Dictionary with:
@ -58,15 +55,13 @@ def create_create_notion_page_tool(
IMPORTANT: If status is "rejected", the user explicitly declined the action.
Respond with a brief acknowledgment (e.g., "Understood, I didn't create the page.")
and move on. Do NOT ask for parent page IDs, troubleshoot, or suggest alternatives.
and move on. Do NOT troubleshoot or suggest alternatives.
Examples:
- "Create a Notion page titled 'Meeting Notes' with content 'Discussed project timeline'"
- "Save this to Notion with title 'Research Summary'"
"""
logger.info(
f"create_notion_page called: title='{title}', parent_page_id={parent_page_id}"
)
logger.info(f"create_notion_page called: title='{title}'")
if db_session is None or search_space_id is None or user_id is None:
logger.error(
@ -99,7 +94,7 @@ def create_create_notion_page_tool(
"params": {
"title": title,
"content": content,
"parent_page_id": parent_page_id,
"parent_page_id": None,
"connector_id": connector_id,
},
},
@ -144,7 +139,7 @@ def create_create_notion_page_tool(
final_title = final_params.get("title", title)
final_content = final_params.get("content", content)
final_parent_page_id = final_params.get("parent_page_id", parent_page_id)
final_parent_page_id = final_params.get("parent_page_id")
final_connector_id = final_params.get("connector_id", connector_id)
if not final_title or not final_title.strip():
@ -229,11 +224,12 @@ def create_create_notion_page_tool(
raise
logger.error(f"Error creating Notion page: {e}", exc_info=True)
return {
"status": "error",
"message": str(e)
if isinstance(e, ValueError)
else f"Unexpected error: {e!s}",
}
if isinstance(e, ValueError | NotionAPIError):
message = str(e)
else:
message = (
"Something went wrong while creating the page. Please try again."
)
return {"status": "error", "message": message}
return create_notion_page

View file

@ -5,7 +5,7 @@ from langchain_core.tools import tool
from langgraph.types import interrupt
from sqlalchemy.ext.asyncio import AsyncSession
from app.connectors.notion_history import NotionHistoryConnector
from app.connectors.notion_history import NotionAPIError, NotionHistoryConnector
from app.services.notion.tool_metadata_service import NotionToolMetadataService
logger = logging.getLogger(__name__)
@ -33,7 +33,7 @@ def create_delete_notion_page_tool(
@tool
async def delete_notion_page(
page_title: str,
delete_from_db: bool = False,
delete_from_kb: bool = False,
) -> dict[str, Any]:
"""Delete (archive) a Notion page.
@ -43,8 +43,8 @@ def create_delete_notion_page_tool(
Args:
page_title: The title of the Notion page to delete.
delete_from_db: Whether to also remove the page from the knowledge base.
Default is False (in Notion).
delete_from_kb: Whether to also remove the page from the knowledge base.
Default is False.
Set to True to permanently remove from both Notion and knowledge base.
Returns:
@ -52,7 +52,7 @@ def create_delete_notion_page_tool(
- status: "success", "rejected", "not_found", or "error"
- page_id: Deleted page ID (if success)
- message: Success or error message
- deleted_from_db: Whether the page was also removed from knowledge base (if success)
- deleted_from_kb: Whether the page was also removed from knowledge base (if success)
Examples:
- "Delete the 'Meeting Notes' Notion page"
@ -60,7 +60,7 @@ def create_delete_notion_page_tool(
- "Archive the 'Draft Ideas' Notion page"
"""
logger.info(
f"delete_notion_page called: page_title='{page_title}', delete_from_db={delete_from_db}"
f"delete_notion_page called: page_title='{page_title}', delete_from_kb={delete_from_kb}"
)
if db_session is None or search_space_id is None or user_id is None:
@ -100,7 +100,7 @@ def create_delete_notion_page_tool(
document_id = context.get("document_id")
logger.info(
f"Requesting approval for deleting Notion page: '{page_title}' (page_id={page_id}, delete_from_db={delete_from_db})"
f"Requesting approval for deleting Notion page: '{page_title}' (page_id={page_id}, delete_from_kb={delete_from_kb})"
)
# Request approval before deleting
@ -112,7 +112,7 @@ def create_delete_notion_page_tool(
"params": {
"page_id": page_id,
"connector_id": connector_id_from_context,
"delete_from_db": delete_from_db,
"delete_from_kb": delete_from_kb,
},
},
"context": context,
@ -159,10 +159,10 @@ def create_delete_notion_page_tool(
final_connector_id = final_params.get(
"connector_id", connector_id_from_context
)
final_delete_from_db = final_params.get("delete_from_db", delete_from_db)
final_delete_from_kb = final_params.get("delete_from_kb", delete_from_kb)
logger.info(
f"Deleting Notion page with final params: page_id={final_page_id}, connector_id={final_connector_id}, delete_from_db={final_delete_from_db}"
f"Deleting Notion page with final params: page_id={final_page_id}, connector_id={final_connector_id}, delete_from_kb={final_delete_from_kb}"
)
from sqlalchemy.future import select
@ -211,11 +211,11 @@ def create_delete_notion_page_tool(
f"delete_page result: {result.get('status')} - {result.get('message', '')}"
)
# If deletion was successful and user wants to delete from DB
deleted_from_db = False
# If deletion was successful and user wants to delete from KB
deleted_from_kb = False
if (
result.get("status") == "success"
and final_delete_from_db
and final_delete_from_kb
and document_id
):
try:
@ -232,24 +232,23 @@ def create_delete_notion_page_tool(
if document:
await db_session.delete(document)
await db_session.commit()
deleted_from_db = True
deleted_from_kb = True
logger.info(
f"Deleted document {document_id} from knowledge base"
)
else:
logger.warning(f"Document {document_id} not found in DB")
logger.warning(f"Document {document_id} not found in KB")
except Exception as e:
logger.error(f"Failed to delete document from DB: {e}")
# Don't fail the whole operation if DB deletion fails
# The page is already deleted from Notion, so inform the user
logger.error(f"Failed to delete document from KB: {e}")
await db_session.rollback()
result["warning"] = (
f"Page deleted from Notion, but failed to remove from knowledge base: {e!s}"
)
# Update result with DB deletion status
# Update result with KB deletion status
if result.get("status") == "success":
result["deleted_from_db"] = deleted_from_db
if deleted_from_db:
result["deleted_from_kb"] = deleted_from_kb
if deleted_from_kb:
result["message"] = (
f"{result.get('message', '')} (also removed from knowledge base)"
)
@ -263,11 +262,12 @@ def create_delete_notion_page_tool(
raise
logger.error(f"Error deleting Notion page: {e}", exc_info=True)
return {
"status": "error",
"message": str(e)
if isinstance(e, ValueError)
else f"Unexpected error: {e!s}",
}
if isinstance(e, ValueError | NotionAPIError):
message = str(e)
else:
message = (
"Something went wrong while deleting the page. Please try again."
)
return {"status": "error", "message": message}
return delete_notion_page

View file

@ -5,7 +5,7 @@ from langchain_core.tools import tool
from langgraph.types import interrupt
from sqlalchemy.ext.asyncio import AsyncSession
from app.connectors.notion_history import NotionHistoryConnector
from app.connectors.notion_history import NotionAPIError, NotionHistoryConnector
from app.services.notion import NotionToolMetadataService
logger = logging.getLogger(__name__)
@ -108,6 +108,7 @@ def create_update_notion_page_tool(
}
page_id = context.get("page_id")
document_id = context.get("document_id")
connector_id_from_context = context.get("account", {}).get("id")
logger.info(
@ -218,6 +219,39 @@ def create_update_notion_page_tool(
logger.info(
f"update_page result: {result.get('status')} - {result.get('message', '')}"
)
if result.get("status") == "success" and document_id is not None:
from app.services.notion import NotionKBSyncService
logger.info(f"Updating knowledge base for document {document_id}...")
kb_service = NotionKBSyncService(db_session)
kb_result = await kb_service.sync_after_update(
document_id=document_id,
appended_content=final_content,
user_id=user_id,
search_space_id=search_space_id,
appended_block_ids=result.get("appended_block_ids"),
)
if kb_result["status"] == "success":
result["message"] = (
f"{result['message']}. Your knowledge base has also been updated."
)
logger.info(
f"Knowledge base successfully updated for page {final_page_id}"
)
elif kb_result["status"] == "not_indexed":
result["message"] = (
f"{result['message']}. This page will be added to your knowledge base in the next scheduled sync."
)
else:
result["message"] = (
f"{result['message']}. Your knowledge base will be updated in the next scheduled sync."
)
logger.warning(
f"KB update failed for page {final_page_id}: {kb_result['message']}"
)
return result
except Exception as e:
@ -227,11 +261,12 @@ def create_update_notion_page_tool(
raise
logger.error(f"Error updating Notion page: {e}", exc_info=True)
return {
"status": "error",
"message": str(e)
if isinstance(e, ValueError)
else f"Unexpected error: {e!s}",
}
if isinstance(e, ValueError | NotionAPIError):
message = str(e)
else:
message = (
"Something went wrong while updating the page. Please try again."
)
return {"status": "error", "message": message}
return update_notion_page

View file

@ -48,6 +48,11 @@ from app.db import ChatVisibility
from .display_image import create_display_image_tool
from .generate_image import create_generate_image_tool
from .knowledge_base import create_search_knowledge_base_tool
from .linear import (
create_create_linear_issue_tool,
create_delete_linear_issue_tool,
create_update_linear_issue_tool,
)
from .link_preview import create_link_preview_tool
from .mcp_tool import load_mcp_tools
from .notion import (
@ -125,14 +130,20 @@ BUILTIN_TOOLS: list[ToolDefinition] = [
requires=["search_space_id", "db_session", "thread_id"],
),
# Report generation tool (inline, short-lived sessions for DB ops)
# Supports internal KB search via source_strategy so the agent doesn't
# need to call search_knowledge_base separately before generating.
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"],
thread_id=deps["thread_id"],
connector_service=deps.get("connector_service"),
available_connectors=deps.get("available_connectors"),
),
requires=["search_space_id", "thread_id"],
# connector_service and available_connectors are optional —
# when missing, source_strategy="kb_search" degrades gracefully to "provided"
),
# Link preview tool - fetches Open Graph metadata for URLs
ToolDefinition(
@ -216,6 +227,39 @@ BUILTIN_TOOLS: list[ToolDefinition] = [
requires=["user_id", "search_space_id", "db_session", "thread_visibility"],
),
# =========================================================================
# LINEAR TOOLS - create, update, delete issues
# =========================================================================
ToolDefinition(
name="create_linear_issue",
description="Create a new issue in the user's Linear workspace",
factory=lambda deps: create_create_linear_issue_tool(
db_session=deps["db_session"],
search_space_id=deps["search_space_id"],
user_id=deps["user_id"],
),
requires=["db_session", "search_space_id", "user_id"],
),
ToolDefinition(
name="update_linear_issue",
description="Update an existing indexed Linear issue",
factory=lambda deps: create_update_linear_issue_tool(
db_session=deps["db_session"],
search_space_id=deps["search_space_id"],
user_id=deps["user_id"],
),
requires=["db_session", "search_space_id", "user_id"],
),
ToolDefinition(
name="delete_linear_issue",
description="Archive (delete) an existing indexed Linear issue",
factory=lambda deps: create_delete_linear_issue_tool(
db_session=deps["db_session"],
search_space_id=deps["search_space_id"],
user_id=deps["user_id"],
),
requires=["db_session", "search_space_id", "user_id"],
),
# =========================================================================
# NOTION TOOLS - create, update, delete pages
# =========================================================================
ToolDefinition(

File diff suppressed because it is too large Load diff

View file

@ -79,7 +79,6 @@ celery_app = Celery(
"app.tasks.celery_tasks.podcast_tasks",
"app.tasks.celery_tasks.connector_tasks",
"app.tasks.celery_tasks.schedule_checker_task",
"app.tasks.celery_tasks.blocknote_migration_tasks",
"app.tasks.celery_tasks.document_reindex_tasks",
"app.tasks.celery_tasks.stale_notification_cleanup_task",
],

File diff suppressed because it is too large Load diff

View file

@ -10,7 +10,6 @@ from datetime import datetime
from typing import Any
import httpx
import requests
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
@ -23,6 +22,15 @@ logger = logging.getLogger(__name__)
LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql"
class LinearAPIError(Exception):
"""Raised when the Linear API returns a non-200 response.
The message is always user-presentable; callers should surface it directly
without any additional prefix or wrapping.
"""
ORGANIZATION_QUERY = """
query {
organization {
@ -244,6 +252,40 @@ class LinearConnector:
"Authorization": f"Bearer {self._credentials.access_token}",
}
@staticmethod
def _raise_api_error(status_code: int, body: str) -> None:
"""Parse a non-200 Linear API response and raise a clean exception.
Translates known Linear error codes into user-readable messages so that
raw GraphQL payloads never reach the end user.
"""
import json as _json
friendly = None
try:
payload = _json.loads(body)
errors = payload.get("errors", [])
if errors:
ext = errors[0].get("extensions", {})
code = ext.get("code", "")
if (
code == "INPUT_ERROR"
and "too complex" in errors[0].get("message", "").lower()
):
friendly = (
"Linear rejected the request because the workspace is too large "
"to fetch in one query. Please try again — if the problem persists, "
"contact support."
)
elif ext.get("userPresentableMessage"):
friendly = ext["userPresentableMessage"]
elif errors[0].get("message"):
friendly = errors[0]["message"]
except Exception:
pass
raise LinearAPIError(friendly or f"Linear API error (HTTP {status_code})")
async def execute_graphql_query(
self, query: str, variables: dict[str, Any] | None = None
) -> dict[str, Any]:
@ -274,14 +316,15 @@ class LinearConnector:
if variables:
payload["variables"] = variables
response = requests.post(self.api_url, headers=headers, json=payload)
async with httpx.AsyncClient() as client:
response = await client.post(
self.api_url, headers=headers, json=payload, timeout=30.0
)
if response.status_code == 200:
return response.json()
else:
raise Exception(
f"Query failed with status code {response.status_code}: {response.text}"
)
self._raise_api_error(response.status_code, response.text)
async def get_all_issues(
self, include_comments: bool = True
@ -588,6 +631,148 @@ class LinearConnector:
return formatted
async def create_issue(
self,
team_id: str,
title: str,
description: str | None = None,
state_id: str | None = None,
assignee_id: str | None = None,
priority: int | None = None,
label_ids: list[str] | None = None,
) -> dict[str, Any]:
try:
mutation = """
mutation IssueCreate($input: IssueCreateInput!) {
issueCreate(input: $input) {
success
issue { id identifier title url }
}
}
"""
input_data: dict[str, Any] = {"teamId": team_id, "title": title}
if description is not None:
input_data["description"] = description
if state_id is not None:
input_data["stateId"] = state_id
if assignee_id is not None:
input_data["assigneeId"] = assignee_id
if priority is not None:
input_data["priority"] = priority
if label_ids:
input_data["labelIds"] = label_ids
result = await self.execute_graphql_query(mutation, {"input": input_data})
payload = result.get("data", {}).get("issueCreate", {})
if not payload.get("success"):
errors = result.get("errors", [])
msg = (
errors[0].get("message", "Unknown error")
if errors
else "Unknown error"
)
return {"status": "error", "message": f"issueCreate failed: {msg}"}
issue = payload.get("issue", {})
return {
"status": "success",
"id": issue.get("id"),
"identifier": issue.get("identifier"),
"title": issue.get("title"),
"url": issue.get("url"),
"message": f"Issue {issue.get('identifier')} created successfully.",
}
except Exception as e:
logger.error(f"Error creating Linear issue: {e}")
return {"status": "error", "message": str(e)}
async def update_issue(
self,
issue_id: str,
title: str | None = None,
description: str | None = None,
state_id: str | None = None,
assignee_id: str | None = None,
priority: int | None = None,
label_ids: list[str] | None = None,
) -> dict[str, Any]:
try:
mutation = """
mutation IssueUpdate($id: String!, $input: IssueUpdateInput!) {
issueUpdate(id: $id, input: $input) {
success
issue { id identifier title url }
}
}
"""
input_data: dict[str, Any] = {}
if title is not None:
input_data["title"] = title
if description is not None:
input_data["description"] = description
if state_id is not None:
input_data["stateId"] = state_id
if assignee_id is not None:
input_data["assigneeId"] = assignee_id
if priority is not None:
input_data["priority"] = priority
if label_ids is not None:
input_data["labelIds"] = label_ids
if not input_data:
return {
"status": "error",
"message": "No fields provided for update. Please specify at least one field to change.",
}
result = await self.execute_graphql_query(
mutation, {"id": issue_id, "input": input_data}
)
payload = result.get("data", {}).get("issueUpdate", {})
if not payload.get("success"):
errors = result.get("errors", [])
msg = (
errors[0].get("message", "Unknown error")
if errors
else "Unknown error"
)
return {"status": "error", "message": f"issueUpdate failed: {msg}"}
issue = payload.get("issue", {})
return {
"status": "success",
"id": issue.get("id"),
"identifier": issue.get("identifier"),
"title": issue.get("title"),
"url": issue.get("url"),
"message": f"Issue {issue.get('identifier')} updated successfully.",
}
except Exception as e:
logger.error(f"Error updating Linear issue: {e}")
return {"status": "error", "message": str(e)}
async def archive_issue(self, issue_id: str) -> dict[str, Any]:
try:
mutation = """
mutation IssueArchive($id: String!) {
issueArchive(id: $id) {
success
}
}
"""
result = await self.execute_graphql_query(mutation, {"id": issue_id})
payload = result.get("data", {}).get("issueArchive", {})
if not payload.get("success"):
errors = result.get("errors", [])
msg = (
errors[0].get("message", "Unknown error")
if errors
else "Unknown error"
)
return {"status": "error", "message": f"issueArchive failed: {msg}"}
return {"status": "success", "message": "Issue archived successfully."}
except Exception as e:
logger.error(f"Error archiving Linear issue: {e}")
return {"status": "error", "message": str(e)}
def format_issue_to_markdown(self, issue: dict[str, Any]) -> str:
"""
Convert an issue to markdown format.

View file

@ -17,6 +17,15 @@ from app.utils.oauth_security import TokenEncryption
logger = logging.getLogger(__name__)
class NotionAPIError(Exception):
"""Raised when the Notion API returns a non-200 response.
The message is always user-presentable; callers should surface it directly
without any additional prefix or wrapping.
"""
# Type variable for generic return type
T = TypeVar("T")
@ -250,8 +259,9 @@ class NotionHistoryConnector:
logger.error(
f"Failed to refresh Notion token for connector {self._connector_id}: {e!s}"
)
raise Exception(
f"Failed to refresh Notion OAuth credentials: {e!s}"
raise NotionAPIError(
"Failed to refresh your Notion connection. "
"Please try again or reconnect your Notion account."
) from e
return self._credentials.access_token
@ -1041,7 +1051,7 @@ class NotionHistoryConnector:
try:
notion = await self._get_client()
# Append content if provided
appended_block_ids = []
if content:
# Convert new content to blocks
try:
@ -1065,14 +1075,23 @@ class NotionHistoryConnector:
try:
for i in range(0, len(children), 100):
batch = children[i : i + 100]
await self._api_call_with_retry(
response = await self._api_call_with_retry(
notion.blocks.children.append,
block_id=page_id,
children=batch,
)
batch_block_ids = [
block["id"] for block in response.get("results", [])
]
appended_block_ids.extend(batch_block_ids)
logger.info(
f"Successfully appended {len(children)} new blocks to page {page_id}"
)
logger.debug(
f"Appended block IDs: {appended_block_ids[:5]}..."
if len(appended_block_ids) > 5
else f"Appended block IDs: {appended_block_ids}"
)
except Exception as e:
logger.error(f"Failed to append content blocks: {e}")
return {
@ -1092,6 +1111,7 @@ class NotionHistoryConnector:
"page_id": page_id,
"url": page_url,
"title": page_title,
"appended_block_ids": appended_block_ids,
"message": f"Updated Notion page '{page_title}' (content appended)",
}

View file

@ -1,20 +1,28 @@
"""
WebCrawler Connector Module
A module for crawling web pages and extracting content using Firecrawl or Playwright.
Provides a unified interface for web scraping.
A module for crawling web pages and extracting content using Firecrawl,
plain HTTP+Trafilatura, or Playwright. Provides a unified interface for
web scraping.
Fallback order:
1. Firecrawl (if API key is configured)
2. HTTP + Trafilatura (lightweight, works on any event loop)
3. Playwright / Chromium (runs in a thread to avoid event-loop limitations)
"""
import asyncio
import logging
from typing import Any
import httpx
import trafilatura
import validators
from fake_useragent import UserAgent
from firecrawl import AsyncFirecrawlApp
from playwright.async_api import async_playwright
from playwright.sync_api import sync_playwright
from app.utils.proxy_config import get_playwright_proxy
from app.utils.proxy_config import get_playwright_proxy, get_residential_proxy_url
logger = logging.getLogger(__name__)
@ -50,8 +58,10 @@ class WebCrawlerConnector:
"""
Crawl a single URL and extract its content.
If Firecrawl API key is provided, tries Firecrawl first and falls back to Chromium
if Firecrawl fails. If no Firecrawl API key is provided, uses Chromium directly.
Fallback order:
1. Firecrawl (if API key configured)
2. Plain HTTP + Trafilatura (lightweight, no subprocess)
3. Playwright / Chromium (needs subprocess-capable event loop)
Args:
url: URL to crawl
@ -63,37 +73,55 @@ class WebCrawlerConnector:
- content: Extracted content (markdown or HTML)
- metadata: Page metadata (title, description, etc.)
- source: Original URL
- crawler_type: Type of crawler used ("firecrawl" or "chromium")
- crawler_type: Type of crawler used
# Validate URL
"""
try:
# Validate URL
if not validators.url(url):
return None, f"Invalid URL: {url}"
# Try Firecrawl first if API key is provided
errors: list[str] = []
# --- 1. Firecrawl (premium, if configured) ---
if self.use_firecrawl:
try:
logger.info(f"[webcrawler] Using Firecrawl for: {url}")
result = await self._crawl_with_firecrawl(url, formats)
return await self._crawl_with_firecrawl(url, formats), None
except Exception as exc:
errors.append(f"Firecrawl: {exc!s}")
logger.warning(f"[webcrawler] Firecrawl failed for {url}: {exc!s}")
# --- 2. HTTP + Trafilatura (no subprocess required) ---
try:
logger.info(f"[webcrawler] Using HTTP+Trafilatura for: {url}")
result = await self._crawl_with_http(url)
if result:
return result, None
except Exception as firecrawl_error:
# Firecrawl failed, fallback to Chromium
logger.warning(
f"[webcrawler] Firecrawl failed, falling back to Chromium+Trafilatura for: {url}"
)
try:
result = await self._crawl_with_chromium(url)
return result, None
except Exception as chromium_error:
return (
None,
f"Both Firecrawl and Chromium failed. Firecrawl error: {firecrawl_error!s}, Chromium error: {chromium_error!s}",
)
else:
# No Firecrawl API key, use Chromium directly
errors.append("HTTP+Trafilatura: empty extraction")
except Exception as exc:
errors.append(f"HTTP+Trafilatura: {exc!s}")
logger.warning(
f"[webcrawler] HTTP+Trafilatura failed for {url}: {exc!s}"
)
# --- 3. Playwright / Chromium (full browser, last resort) ---
try:
logger.info(f"[webcrawler] Using Chromium+Trafilatura for: {url}")
result = await self._crawl_with_chromium(url)
return result, None
return await self._crawl_with_chromium(url), None
except NotImplementedError:
errors.append(
"Chromium: event loop does not support subprocesses "
"(common on Windows with uvicorn --reload)"
)
logger.warning(
f"[webcrawler] Chromium unavailable for {url}: "
"current event loop does not support subprocesses"
)
except Exception as exc:
errors.append(f"Chromium: {exc!s}")
logger.warning(f"[webcrawler] Chromium failed for {url}: {exc!s}")
return None, f"All crawl methods failed for {url}. {'; '.join(errors)}"
except Exception as e:
return None, f"Error crawling URL {url}: {e!s}"
@ -149,11 +177,80 @@ class WebCrawlerConnector:
"crawler_type": "firecrawl",
}
async def _crawl_with_http(self, url: str) -> dict[str, Any] | None:
"""
Crawl URL using a plain HTTP request + Trafilatura content extraction.
This method avoids launching a browser subprocess, making it safe to
call from any asyncio event loop (including Windows SelectorEventLoop
which does not support ``create_subprocess_exec``).
Returns ``None`` when Trafilatura cannot extract meaningful content
(e.g. JS-rendered SPAs) so the caller can fall through to Chromium.
"""
ua = UserAgent()
user_agent = ua.random
proxy_url = get_residential_proxy_url()
async with httpx.AsyncClient(
timeout=20.0,
follow_redirects=True,
proxy=proxy_url,
headers={
"User-Agent": user_agent,
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
"Accept-Encoding": "gzip, deflate, br",
},
) as client:
response = await client.get(url)
response.raise_for_status()
raw_html = response.text
if not raw_html or len(raw_html.strip()) == 0:
return None
extracted_content = trafilatura.extract(
raw_html,
output_format="markdown",
include_comments=False,
include_tables=True,
include_images=True,
include_links=True,
)
if not extracted_content or len(extracted_content.strip()) == 0:
return None
trafilatura_metadata = trafilatura.extract_metadata(raw_html)
metadata: dict[str, str] = {"source": url}
if trafilatura_metadata:
if trafilatura_metadata.title:
metadata["title"] = trafilatura_metadata.title
if trafilatura_metadata.description:
metadata["description"] = trafilatura_metadata.description
if trafilatura_metadata.author:
metadata["author"] = trafilatura_metadata.author
if trafilatura_metadata.date:
metadata["date"] = trafilatura_metadata.date
metadata.setdefault("title", url)
return {
"content": extracted_content,
"metadata": metadata,
"crawler_type": "http",
}
async def _crawl_with_chromium(self, url: str) -> dict[str, Any]:
"""
Crawl URL using Playwright with Trafilatura for content extraction.
Falls back to raw HTML if Trafilatura extraction fails.
Runs the sync Playwright API in a thread so it works on any event
loop, including Windows ``SelectorEventLoop`` which cannot spawn
subprocesses.
Args:
url: URL to crawl
@ -163,51 +260,48 @@ class WebCrawlerConnector:
Raises:
Exception: If crawling fails
"""
# Generate a realistic User-Agent to avoid bot detection
return await asyncio.to_thread(self._crawl_with_chromium_sync, url)
def _crawl_with_chromium_sync(self, url: str) -> dict[str, Any]:
"""Synchronous Playwright crawl executed in a worker thread."""
ua = UserAgent()
user_agent = ua.random
# Use residential proxy if configured
playwright_proxy = get_playwright_proxy()
# Use Playwright to fetch the page
async with async_playwright() as p:
with sync_playwright() as p:
launch_kwargs: dict = {"headless": True}
if playwright_proxy:
launch_kwargs["proxy"] = playwright_proxy
browser = await p.chromium.launch(**launch_kwargs)
context = await browser.new_context(user_agent=user_agent)
page = await context.new_page()
browser = p.chromium.launch(**launch_kwargs)
context = browser.new_context(user_agent=user_agent)
page = context.new_page()
try:
await page.goto(url, wait_until="domcontentloaded", timeout=30000)
raw_html = await page.content()
page_title = await page.title()
page.goto(url, wait_until="domcontentloaded", timeout=30000)
raw_html = page.content()
page_title = page.title()
finally:
await browser.close()
browser.close()
if not raw_html:
raise ValueError(f"Failed to load content from {url}")
# Extract basic metadata from the page
base_metadata = {"title": page_title} if page_title else {}
# Try to extract main content using Trafilatura
extracted_content = None
trafilatura_metadata = None
try:
# Extract main content as markdown
extracted_content = trafilatura.extract(
raw_html,
output_format="markdown", # Get clean markdown
include_comments=False, # Exclude comments
include_tables=True, # Keep tables
include_images=True, # Keep image references
include_links=True, # Keep links
output_format="markdown",
include_comments=False,
include_tables=True,
include_images=True,
include_links=True,
)
# Extract metadata using Trafilatura
trafilatura_metadata = trafilatura.extract_metadata(raw_html)
if not extracted_content or len(extracted_content.strip()) == 0:
@ -216,7 +310,6 @@ class WebCrawlerConnector:
except Exception:
extracted_content = None
# Build metadata, preferring Trafilatura metadata when available
metadata = {
"source": url,
"title": (
@ -226,7 +319,6 @@ class WebCrawlerConnector:
),
}
# Add additional metadata from Trafilatura if available
if trafilatura_metadata:
if trafilatura_metadata.description:
metadata["description"] = trafilatura_metadata.description
@ -235,7 +327,6 @@ class WebCrawlerConnector:
if trafilatura_metadata.date:
metadata["date"] = trafilatura_metadata.date
# Add any remaining base metadata
metadata.update(base_metadata)
return {

View file

@ -894,9 +894,15 @@ class Document(BaseModel, TimestampMixin):
embedding = Column(Vector(config.embedding_model_instance.dimension))
# BlockNote live editing state (NULL when never edited)
# DEPRECATED: Will be removed in a future migration. Use source_markdown instead.
blocknote_document = Column(JSONB, nullable=True)
# blocknote background reindex flag
# Full raw markdown content for the Plate.js editor.
# This is the source of truth for document content in the editor.
# Populated from markdown at ingestion time, or from blocknote_document migration.
source_markdown = Column(Text, nullable=True)
# Background reindex flag (set when editor content is saved)
content_needs_reindexing = Column(
Boolean, nullable=False, default=False, server_default=text("false")
)

View file

@ -26,6 +26,7 @@ from .jira_add_connector_route import router as jira_add_connector_router
from .linear_add_connector_route import router as linear_add_connector_router
from .logs_routes import router as logs_router
from .luma_add_connector_route import router as luma_add_connector_router
from .model_list_routes import router as model_list_router
from .new_chat_routes import router as new_chat_router
from .new_llm_config_routes import router as new_llm_config_router
from .notes_routes import router as notes_router
@ -68,6 +69,7 @@ router.include_router(jira_add_connector_router)
router.include_router(confluence_add_connector_router)
router.include_router(clickup_add_connector_router)
router.include_router(new_llm_config_router) # LLM configs with prompt configuration
router.include_router(model_list_router) # Dynamic LLM model catalogue from OpenRouter
router.include_router(logs_router)
router.include_router(circleback_webhook_router) # Circleback meeting webhooks
router.include_router(surfsense_docs_router) # Surfsense documentation for citations

View file

@ -1,5 +1,5 @@
"""
Editor routes for BlockNote document editing.
Editor routes for document editing with markdown (Plate.js frontend).
"""
from datetime import UTC, datetime
@ -27,8 +27,8 @@ async def get_editor_content(
"""
Get document content for editing.
Returns BlockNote JSON document. If blocknote_document is NULL,
attempts to generate it from chunks (lazy migration).
Returns source_markdown for the Plate.js editor.
Falls back to blocknote_document markdown conversion, then chunk reconstruction.
Requires DOCUMENTS_READ permission.
"""
@ -54,54 +54,61 @@ async def get_editor_content(
if not document:
raise HTTPException(status_code=404, detail="Document not found")
# If blocknote_document exists, return it
# Priority 1: Return source_markdown if it exists (check `is not None` to allow empty strings)
if document.source_markdown is not None:
return {
"document_id": document.id,
"title": document.title,
"document_type": document.document_type.value,
"source_markdown": document.source_markdown,
"updated_at": document.updated_at.isoformat()
if document.updated_at
else None,
}
# Priority 2: Lazy-migrate from blocknote_document (pure Python, no external deps)
if document.blocknote_document:
return {
"document_id": document.id,
"title": document.title,
"document_type": document.document_type.value,
"blocknote_document": document.blocknote_document,
"updated_at": document.updated_at.isoformat()
if document.updated_at
else None,
}
from app.utils.blocknote_to_markdown import blocknote_to_markdown
# For NOTE type documents, return empty BlockNote structure if no content exists
if document.document_type == DocumentType.NOTE:
# Return empty BlockNote structure
empty_blocknote = [
{
"type": "paragraph",
"content": [],
"children": [],
}
]
# Save empty structure if not already saved
if not document.blocknote_document:
document.blocknote_document = empty_blocknote
markdown = blocknote_to_markdown(document.blocknote_document)
if markdown:
# Persist the migration so we don't repeat it
document.source_markdown = markdown
await session.commit()
return {
"document_id": document.id,
"title": document.title,
"document_type": document.document_type.value,
"source_markdown": markdown,
"updated_at": document.updated_at.isoformat()
if document.updated_at
else None,
}
# Priority 3: For NOTE type with no content, return empty markdown
if document.document_type == DocumentType.NOTE:
empty_markdown = ""
document.source_markdown = empty_markdown
await session.commit()
return {
"document_id": document.id,
"title": document.title,
"document_type": document.document_type.value,
"blocknote_document": empty_blocknote,
"source_markdown": empty_markdown,
"updated_at": document.updated_at.isoformat()
if document.updated_at
else None,
}
# Lazy migration: Try to generate blocknote_document from chunks (for other document types)
from app.utils.blocknote_converter import convert_markdown_to_blocknote
# Priority 4: Reconstruct from chunks
chunks = sorted(document.chunks, key=lambda c: c.id)
if not chunks:
raise HTTPException(
status_code=400,
detail="This document has no chunks and cannot be edited. Please re-upload to enable editing.",
detail="This document has no content and cannot be edited. Please re-upload to enable editing.",
)
# Reconstruct markdown from chunks
markdown_content = "\n\n".join(chunk.content for chunk in chunks)
if not markdown_content.strip():
@ -110,25 +117,15 @@ async def get_editor_content(
detail="This document has empty content and cannot be edited.",
)
# Convert to BlockNote
blocknote_json = await convert_markdown_to_blocknote(markdown_content)
if not blocknote_json:
raise HTTPException(
status_code=500,
detail="Failed to convert document to editable format. Please try again later.",
)
# Save the generated blocknote_document (lazy migration)
document.blocknote_document = blocknote_json
document.content_needs_reindexing = False
# Persist the lazy migration
document.source_markdown = markdown_content
await session.commit()
return {
"document_id": document.id,
"title": document.title,
"document_type": document.document_type.value,
"blocknote_document": blocknote_json,
"source_markdown": markdown_content,
"updated_at": document.updated_at.isoformat() if document.updated_at else None,
}
@ -142,9 +139,11 @@ async def save_document(
user: User = Depends(current_active_user),
):
"""
Save BlockNote document and trigger reindexing.
Save document markdown and trigger reindexing.
Called when user clicks 'Save & Exit'.
Accepts { "source_markdown": "...", "title": "..." (optional) }.
Requires DOCUMENTS_UPDATE permission.
"""
from app.tasks.celery_tasks.document_reindex_tasks import reindex_document_task
@ -169,49 +168,36 @@ async def save_document(
if not document:
raise HTTPException(status_code=404, detail="Document not found")
blocknote_document = data.get("blocknote_document")
if not blocknote_document:
raise HTTPException(status_code=400, detail="blocknote_document is required")
source_markdown = data.get("source_markdown")
if source_markdown is None:
raise HTTPException(status_code=400, detail="source_markdown is required")
# Add type validation
if not isinstance(blocknote_document, list):
raise HTTPException(status_code=400, detail="blocknote_document must be a list")
if not isinstance(source_markdown, str):
raise HTTPException(status_code=400, detail="source_markdown must be a string")
# For NOTE type documents, extract title from first block (heading)
if (
document.document_type == DocumentType.NOTE
and blocknote_document
and len(blocknote_document) > 0
):
first_block = blocknote_document[0]
if (
first_block
and first_block.get("content")
and isinstance(first_block["content"], list)
):
# Extract text from first block content
# Match the frontend extractTitleFromBlockNote logic exactly
title_parts = []
for item in first_block["content"]:
if isinstance(item, str):
title_parts.append(item)
elif (
isinstance(item, dict)
and "text" in item
and isinstance(item["text"], str)
):
# BlockNote structure: {"type": "text", "text": "...", "styles": {}}
title_parts.append(item["text"])
# For NOTE type, extract title from first heading line if present
if document.document_type == DocumentType.NOTE:
# If the frontend sends a title, use it; otherwise extract from markdown
new_title = data.get("title")
if not new_title:
# Extract title from the first line of markdown (# Heading)
for line in source_markdown.split("\n"):
stripped = line.strip()
if stripped.startswith("# "):
new_title = stripped[2:].strip()
break
elif stripped:
# First non-empty non-heading line
new_title = stripped[:100]
break
new_title = "".join(title_parts).strip()
if new_title:
document.title = new_title
else:
# Only set to "Untitled" if content exists but is empty
document.title = "Untitled"
if new_title:
document.title = new_title.strip()
else:
document.title = "Untitled"
# Save BlockNote document
document.blocknote_document = blocknote_document
# Save source_markdown
document.source_markdown = source_markdown
document.updated_at = datetime.now(UTC)
document.content_needs_reindexing = True

View file

@ -0,0 +1,44 @@
"""
API route for fetching the available LLM models catalogue.
Serves a dynamically-updated list sourced from the OpenRouter public API,
with a local JSON fallback when the API is unreachable.
"""
import logging
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from app.db import User
from app.services.model_list_service import get_model_list
from app.users import current_active_user
router = APIRouter()
logger = logging.getLogger(__name__)
class ModelListItem(BaseModel):
value: str
label: str
provider: str
context_window: str | None = None
@router.get("/models", response_model=list[ModelListItem])
async def list_available_models(
user: User = Depends(current_active_user),
):
"""
Return all available LLM models grouped by provider.
The list is sourced from the OpenRouter public API and cached for 1 hour.
If the API is unreachable, a local fallback file is used instead.
"""
try:
return await get_model_list()
except Exception as e:
logger.exception("Failed to fetch model list")
raise HTTPException(
status_code=500, detail=f"Failed to fetch model list: {e!s}"
) from e

View file

@ -1042,7 +1042,11 @@ async def handle_new_chat(
search_space.agent_llm_id if search_space.agent_llm_id is not None else -1
)
# Return streaming response
# Release the read-transaction so we don't hold ACCESS SHARE locks
# on searchspaces/documents for the entire duration of the stream.
# expire_on_commit=False keeps loaded ORM attrs usable.
await session.commit()
return StreamingResponse(
stream_new_chat(
user_query=request.user_query,
@ -1269,6 +1273,11 @@ async def regenerate_response(
search_space.agent_llm_id if search_space.agent_llm_id is not None else -1
)
# Release the read-transaction so we don't hold ACCESS SHARE locks
# on searchspaces/documents for the entire duration of the stream.
# expire_on_commit=False keeps loaded ORM attrs (including messages_to_delete PKs) usable.
await session.commit()
# Create a wrapper generator that deletes messages only AFTER streaming succeeds
# This prevents data loss if streaming fails (network error, LLM error, etc.)
async def stream_with_cleanup():
@ -1382,6 +1391,10 @@ async def resume_chat(
decisions = [d.model_dump() for d in request.decisions]
# Release the read-transaction so we don't hold ACCESS SHARE locks
# on searchspaces/documents for the entire duration of the stream.
await session.commit()
return StreamingResponse(
stream_resume_chat(
chat_id=thread_id,

View file

@ -1,9 +1,8 @@
"""
Notes routes for creating and managing BlockNote documents.
Notes routes for creating and managing note documents.
"""
from datetime import UTC, datetime
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
@ -20,7 +19,7 @@ router = APIRouter()
class CreateNoteRequest(BaseModel):
title: str
blocknote_document: list[dict[str, Any]] | None = None
source_markdown: str | None = None
@router.post("/search-spaces/{search_space_id}/notes", response_model=DocumentRead)
@ -31,7 +30,7 @@ async def create_note(
user: User = Depends(current_active_user),
):
"""
Create a new note (BlockNote document).
Create a new note document.
Requires DOCUMENTS_CREATE permission.
"""
@ -47,16 +46,8 @@ async def create_note(
if not request.title or not request.title.strip():
raise HTTPException(status_code=400, detail="Title is required")
# Default empty BlockNote structure if not provided
blocknote_document = request.blocknote_document
if blocknote_document is None:
blocknote_document = [
{
"type": "paragraph",
"content": [],
"children": [],
}
]
# Default empty markdown if not provided
source_markdown = request.source_markdown if request.source_markdown else ""
# Generate content hash (use title for now, will be updated on save)
import hashlib
@ -64,14 +55,13 @@ async def create_note(
content_hash = hashlib.sha256(request.title.encode()).hexdigest()
# Create document with NOTE type
document = Document(
search_space_id=search_space_id,
title=request.title.strip(),
document_type=DocumentType.NOTE,
content="", # Empty initially, will be populated on first save/reindex
content_hash=content_hash,
blocknote_document=blocknote_document,
source_markdown=source_markdown,
content_needs_reindexing=False, # Will be set to True on first save
document_metadata={"NOTE": True},
embedding=None, # Will be generated on first reindex

View file

@ -1,8 +1,9 @@
"""
Report routes for read, export (PDF/DOCX), and delete operations.
Report routes for read, update, 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.
Reports are generated inline by the agent tool during chat and stored as
Markdown in the database. Users can edit report content via the Plate editor
and save changes through the PUT endpoint.
Export to PDF/DOCX is on-demand PDF uses pypandoc (MarkdownTypst) + typst-py
(TypstPDF); DOCX uses pypandoc directly.
@ -33,7 +34,7 @@ from app.db import (
User,
get_async_session,
)
from app.schemas import ReportContentRead, ReportRead
from app.schemas import ReportContentRead, ReportContentUpdate, ReportRead
from app.schemas.reports import ReportVersionInfo
from app.users import current_active_user
from app.utils.rbac import check_search_space_access
@ -259,6 +260,47 @@ async def read_report_content(
) from None
@router.put("/reports/{report_id}/content", response_model=ReportContentRead)
async def update_report_content(
report_id: int,
body: ReportContentUpdate,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""
Update the Markdown content of a report.
The caller must be a member of the search space the report belongs to.
Returns the updated report content including version siblings.
"""
try:
report = await _get_report_with_access(report_id, session, user)
report.content = body.content
session.add(report)
await session.commit()
await session.refresh(report)
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:
await session.rollback()
raise HTTPException(
status_code=500,
detail="Database error occurred while updating report content",
) from None
@router.get("/reports/{report_id}/export")
async def export_report(
report_id: int,

View file

@ -76,7 +76,13 @@ from .rbac_schemas import (
RoleUpdate,
UserSearchSpaceAccess,
)
from .reports import ReportBase, ReportContentRead, ReportRead, ReportVersionInfo
from .reports import (
ReportBase,
ReportContentRead,
ReportContentUpdate,
ReportRead,
ReportVersionInfo,
)
from .search_source_connector import (
MCPConnectorCreate,
MCPConnectorRead,
@ -189,6 +195,7 @@ __all__ = [
# Report schemas
"ReportBase",
"ReportContentRead",
"ReportContentUpdate",
"ReportRead",
"ReportVersionInfo",
"RoleCreate",

View file

@ -51,3 +51,9 @@ class ReportContentRead(BaseModel):
class Config:
from_attributes = True
class ReportContentUpdate(BaseModel):
"""Schema for updating a report's Markdown content."""
content: str

View file

@ -0,0 +1,13 @@
from app.services.linear.kb_sync_service import LinearKBSyncService
from app.services.linear.tool_metadata_service import (
LinearIssue,
LinearToolMetadataService,
LinearWorkspace,
)
__all__ = [
"LinearIssue",
"LinearKBSyncService",
"LinearToolMetadataService",
"LinearWorkspace",
]

View file

@ -0,0 +1,182 @@
import logging
from datetime import datetime
from sqlalchemy import delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import config
from app.connectors.linear_connector import LinearConnector
from app.db import Chunk, Document
from app.services.llm_service import get_user_long_context_llm
from app.utils.document_converters import (
create_document_chunks,
generate_content_hash,
generate_document_summary,
)
logger = logging.getLogger(__name__)
class LinearKBSyncService:
"""Re-indexes a single Linear issue document after a successful update.
Mirrors the indexer's Phase-2 logic exactly: fetch fresh issue content,
run generate_document_summary, create_document_chunks, then update the
document row in the knowledge base.
"""
def __init__(self, db_session: AsyncSession):
self.db_session = db_session
async def sync_after_update(
self,
document_id: int,
issue_id: str,
user_id: str,
search_space_id: int,
) -> dict:
"""Re-index a Linear issue document after it has been updated via the API.
Args:
document_id: The KB document ID to update.
issue_id: The Linear issue UUID to fetch fresh content from.
user_id: Used to select the correct LLM configuration.
search_space_id: Used to select the correct LLM configuration.
Returns:
dict with 'status': 'success' | 'not_indexed' | 'error'.
"""
from app.tasks.connector_indexers.base import (
get_current_timestamp,
safe_set_chunks,
)
try:
document = await self.db_session.get(Document, document_id)
if not document:
logger.warning(f"Document {document_id} not found in KB")
return {"status": "not_indexed"}
connector_id = document.connector_id
if not connector_id:
return {"status": "error", "message": "Document has no connector_id"}
linear_client = LinearConnector(
session=self.db_session, connector_id=connector_id
)
issue_raw = await self._fetch_issue(linear_client, issue_id)
if not issue_raw:
return {"status": "error", "message": "Issue not found in Linear API"}
formatted_issue = linear_client.format_issue(issue_raw)
issue_content = linear_client.format_issue_to_markdown(formatted_issue)
if not issue_content:
return {"status": "error", "message": "Issue produced empty content"}
issue_identifier = formatted_issue.get("identifier", "")
issue_title = formatted_issue.get("title", "")
state = formatted_issue.get("state", "Unknown")
priority = issue_raw.get("priorityLabel", "Unknown")
comment_count = len(formatted_issue.get("comments", []))
description = formatted_issue.get("description", "")
user_llm = await get_user_long_context_llm(
self.db_session, user_id, search_space_id, disable_streaming=True
)
if user_llm:
document_metadata_for_summary = {
"issue_id": issue_identifier,
"issue_title": issue_title,
"state": state,
"priority": priority,
"comment_count": comment_count,
"document_type": "Linear Issue",
"connector_type": "Linear",
}
summary_content, summary_embedding = await generate_document_summary(
issue_content, user_llm, document_metadata_for_summary
)
else:
if description and len(description) > 1000:
description = description[:997] + "..."
summary_content = (
f"Linear Issue {issue_identifier}: {issue_title}\n\n"
f"Status: {state}\n\n"
)
if description:
summary_content += f"Description: {description}\n\n"
summary_content += f"Comments: {comment_count}"
summary_embedding = config.embedding_model_instance.embed(
summary_content
)
await self.db_session.execute(
delete(Chunk).where(Chunk.document_id == document.id)
)
chunks = await create_document_chunks(issue_content)
document.title = f"{issue_identifier}: {issue_title}"
document.content = summary_content
document.content_hash = generate_content_hash(
issue_content, search_space_id
)
document.embedding = summary_embedding
from sqlalchemy.orm.attributes import flag_modified
document.document_metadata = {
**(document.document_metadata or {}),
"issue_id": issue_id,
"issue_identifier": issue_identifier,
"issue_title": issue_title,
"state": state,
"priority": priority,
"comment_count": comment_count,
"indexed_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"connector_id": connector_id,
}
flag_modified(document, "document_metadata")
safe_set_chunks(document, chunks)
document.updated_at = get_current_timestamp()
await self.db_session.commit()
logger.info(
f"KB sync successful for document {document_id} "
f"({issue_identifier}: {issue_title})"
)
return {"status": "success"}
except Exception as e:
logger.error(
f"KB sync failed for document {document_id}: {e}", exc_info=True
)
await self.db_session.rollback()
return {"status": "error", "message": str(e)}
@staticmethod
async def _fetch_issue(client: LinearConnector, issue_id: str) -> dict | None:
"""Fetch a full issue from Linear, matching the fields used by format_issue."""
query = """
query LinearIssueSync($id: String!) {
issue(id: $id) {
id identifier title description priority priorityLabel
createdAt updatedAt url
state { id name type color }
creator { id name email }
assignee { id name email }
comments {
nodes {
id body createdAt updatedAt
user { id name email }
}
}
team { id name key }
}
}
"""
result = await client.execute_graphql_query(query, {"id": issue_id})
return (result.get("data") or {}).get("issue")

View file

@ -0,0 +1,356 @@
from dataclasses import dataclass
from sqlalchemy import and_, func, or_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from app.connectors.linear_connector import LinearConnector
from app.db import (
Document,
DocumentType,
SearchSourceConnector,
SearchSourceConnectorType,
)
@dataclass
class LinearWorkspace:
"""Represents a Linear connector as a workspace for tool context."""
id: int
name: str
organization_name: str
@classmethod
def from_connector(cls, connector: SearchSourceConnector) -> "LinearWorkspace":
return cls(
id=connector.id,
name=connector.name,
organization_name=connector.config.get(
"organization_name", "Linear Workspace"
),
)
def to_dict(self) -> dict:
return {
"id": self.id,
"name": self.name,
"organization_name": self.organization_name,
}
@dataclass
class LinearIssue:
"""Represents an indexed Linear issue resolved from the knowledge base."""
id: str
identifier: str
title: str
state: str
connector_id: int
document_id: int
indexed_at: str | None
@classmethod
def from_document(cls, document: Document) -> "LinearIssue":
meta = document.document_metadata or {}
return cls(
id=meta.get("issue_id", ""),
identifier=meta.get("issue_identifier", ""),
title=meta.get("issue_title", document.title),
state=meta.get("state", ""),
connector_id=document.connector_id,
document_id=document.id,
indexed_at=meta.get("indexed_at"),
)
def to_dict(self) -> dict:
return {
"id": self.id,
"identifier": self.identifier,
"title": self.title,
"state": self.state,
"connector_id": self.connector_id,
"document_id": self.document_id,
"indexed_at": self.indexed_at,
}
class LinearToolMetadataService:
"""Builds interrupt context for Linear HITL tools.
All context queries (GraphQL reads) live here.
Write mutations live in LinearConnector.
"""
def __init__(self, db_session: AsyncSession):
self._db_session = db_session
async def get_creation_context(self, search_space_id: int, user_id: str) -> dict:
"""Return context needed to create a new Linear issue.
Fetches all connected Linear workspaces, and for each one fetches
its teams with states, members, and labels from the Linear API.
Returns a dict with key: workspaces (each entry has id, name, organization_name, teams, priorities).
Returns a dict with key 'error' on failure.
"""
connectors = await self._get_all_linear_connectors(search_space_id, user_id)
if not connectors:
return {"error": "No Linear account connected"}
workspaces = []
for connector in connectors:
workspace = LinearWorkspace.from_connector(connector)
linear_client = LinearConnector(
session=self._db_session, connector_id=connector.id
)
try:
priorities = await self._fetch_priority_values(linear_client)
teams = await self._fetch_teams_context(linear_client)
except Exception as e:
return {"error": f"Failed to fetch Linear context: {e!s}"}
workspaces.append(
{
"id": workspace.id,
"name": workspace.name,
"organization_name": workspace.organization_name,
"teams": teams,
"priorities": priorities,
}
)
return {"workspaces": workspaces}
async def get_update_context(
self, search_space_id: int, user_id: str, issue_ref: str
) -> dict:
"""Return context needed to update an indexed Linear issue.
Resolves the issue from the KB (title identifier full title),
then fetches its current state, assignee, labels, and team context
from the Linear API.
Returns a dict with keys: workspace, priorities, issue, team.
Returns a dict with key 'error' if the issue is not found or API fails.
"""
document = await self._resolve_issue(search_space_id, user_id, issue_ref)
if not document:
return {
"error": f"Issue '{issue_ref}' not found in your indexed Linear issues. "
"This could mean: (1) the issue doesn't exist, (2) it hasn't been indexed yet, "
"or (3) the title or identifier is different."
}
connector = await self._get_connector_for_document(document, user_id)
if not connector:
return {"error": "Connector not found or access denied"}
workspace = LinearWorkspace.from_connector(connector)
issue = LinearIssue.from_document(document)
linear_client = LinearConnector(
session=self._db_session, connector_id=connector.id
)
try:
priorities = await self._fetch_priority_values(linear_client)
issue_api = await self._fetch_issue_context(linear_client, issue.id)
except Exception as e:
return {"error": f"Failed to fetch Linear issue context: {e!s}"}
if not issue_api:
return {
"error": f"Issue '{issue_ref}' could not be fetched from Linear API"
}
team_raw = issue_api.get("team") or {}
labels_raw = issue_api.get("labels") or {}
states_raw = team_raw.get("states") or {}
members_raw = team_raw.get("members") or {}
team_labels_raw = team_raw.get("labels") or {}
return {
"workspace": workspace.to_dict(),
"priorities": priorities,
"issue": {
"id": issue_api.get("id"),
"identifier": issue_api.get("identifier"),
"title": issue_api.get("title"),
"description": issue_api.get("description"),
"priority": issue_api.get("priority"),
"url": issue_api.get("url"),
"current_state": issue_api.get("state"),
"current_assignee": issue_api.get("assignee"),
"current_labels": labels_raw.get("nodes", []),
"team_id": team_raw.get("id"),
"document_id": issue.document_id,
"indexed_at": issue.indexed_at,
},
"team": {
"id": team_raw.get("id"),
"name": team_raw.get("name"),
"key": team_raw.get("key"),
"states": states_raw.get("nodes", []),
"members": members_raw.get("nodes", []),
"labels": team_labels_raw.get("nodes", []),
},
}
async def get_delete_context(
self, search_space_id: int, user_id: str, issue_ref: str
) -> dict:
"""Return context needed to archive an indexed Linear issue.
Resolves the issue from the KB only no Linear API call required.
Returns a dict with keys: workspace, issue.
Returns a dict with key 'error' if the issue is not found.
"""
document = await self._resolve_issue(search_space_id, user_id, issue_ref)
if not document:
return {
"error": f"Issue '{issue_ref}' not found in your indexed Linear issues. "
"This could mean: (1) the issue doesn't exist, (2) it hasn't been indexed yet, "
"or (3) the title or identifier is different."
}
connector = await self._get_connector_for_document(document, user_id)
if not connector:
return {"error": "Connector not found or access denied"}
workspace = LinearWorkspace.from_connector(connector)
issue = LinearIssue.from_document(document)
return {
"workspace": workspace.to_dict(),
"issue": issue.to_dict(),
}
@staticmethod
async def _fetch_priority_values(client: LinearConnector) -> list[dict]:
"""Fetch Linear priority values (0-4) with their display labels."""
query = "{ issuePriorityValues { priority label } }"
result = await client.execute_graphql_query(query)
return result.get("data", {}).get("issuePriorityValues", [])
@staticmethod
async def _fetch_teams_context(client: LinearConnector) -> list[dict]:
"""Fetch all teams with their states, members, and labels."""
query = """
query {
teams(first: 25) {
nodes {
id name key
states { nodes { id name type color position } }
members { nodes { id name displayName email avatarUrl active } }
labels { nodes { id name color } }
}
}
}
"""
result = await client.execute_graphql_query(query)
raw_teams = result.get("data", {}).get("teams", {}).get("nodes", [])
return [
{
"id": t.get("id"),
"name": t.get("name"),
"key": t.get("key"),
"states": (t.get("states") or {}).get("nodes", []),
"members": (t.get("members") or {}).get("nodes", []),
"labels": (t.get("labels") or {}).get("nodes", []),
}
for t in raw_teams
]
@staticmethod
async def _fetch_issue_context(
client: LinearConnector, issue_id: str
) -> dict | None:
"""Fetch a single issue with its current state, assignee, labels, and team context."""
query = """
query LinearIssueContext($id: String!) {
issue(id: $id) {
id identifier title description priority url
state { id name type color }
assignee { id name displayName email }
labels { nodes { id name color } }
team {
id name key
states { nodes { id name type color position } }
members { nodes { id name displayName email avatarUrl active } }
labels { nodes { id name color } }
}
}
}
"""
result = await client.execute_graphql_query(query, {"id": issue_id})
return result.get("data", {}).get("issue")
async def _resolve_issue(
self, search_space_id: int, user_id: str, issue_ref: str
) -> Document | None:
"""Resolve an issue from the KB using a 3-step fallback.
Order: issue_title (most natural) issue_identifier (e.g. ENG-42) document.title.
All comparisons are case-insensitive.
"""
ref_lower = issue_ref.lower()
result = await self._db_session.execute(
select(Document)
.join(
SearchSourceConnector, Document.connector_id == SearchSourceConnector.id
)
.filter(
and_(
Document.search_space_id == search_space_id,
Document.document_type == DocumentType.LINEAR_CONNECTOR,
SearchSourceConnector.user_id == user_id,
or_(
func.lower(Document.document_metadata.op("->>")("issue_title"))
== ref_lower,
func.lower(
Document.document_metadata.op("->>")("issue_identifier")
)
== ref_lower,
func.lower(Document.title) == ref_lower,
),
)
)
.limit(1)
)
return result.scalars().first()
async def _get_all_linear_connectors(
self, search_space_id: int, user_id: str
) -> list[SearchSourceConnector]:
"""Fetch all Linear connectors for the given search space and user."""
result = await self._db_session.execute(
select(SearchSourceConnector).filter(
and_(
SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.LINEAR_CONNECTOR,
)
)
)
return result.scalars().all()
async def _get_connector_for_document(
self, document: Document, user_id: str
) -> SearchSourceConnector | None:
"""Fetch the connector associated with a document, scoped to the user."""
if not document.connector_id:
return None
result = await self._db_session.execute(
select(SearchSourceConnector).filter(
and_(
SearchSourceConnector.id == document.connector_id,
SearchSourceConnector.user_id == user_id,
)
)
)
return result.scalars().first()

View file

@ -162,7 +162,10 @@ async def validate_llm_config(
async def get_search_space_llm_instance(
session: AsyncSession, search_space_id: int, role: str
session: AsyncSession,
search_space_id: int,
role: str,
disable_streaming: bool = False,
) -> ChatLiteLLM | ChatLiteLLMRouter | None:
"""
Get a ChatLiteLLM instance for a specific search space and role.
@ -218,7 +221,7 @@ async def get_search_space_llm_instance(
logger.debug(
f"Using Auto mode (LLM Router) for search space {search_space_id}, role {role}"
)
return ChatLiteLLMRouter()
return ChatLiteLLMRouter(disable_streaming=disable_streaming)
except Exception as e:
logger.error(f"Failed to create ChatLiteLLMRouter: {e}")
return None
@ -284,6 +287,9 @@ async def get_search_space_llm_instance(
if global_config.get("litellm_params"):
litellm_kwargs.update(global_config["litellm_params"])
if disable_streaming:
litellm_kwargs["disable_streaming"] = True
return ChatLiteLLM(**litellm_kwargs)
# Get the LLM configuration from database (NewLLMConfig)
@ -357,6 +363,9 @@ async def get_search_space_llm_instance(
if llm_config.litellm_params:
litellm_kwargs.update(llm_config.litellm_params)
if disable_streaming:
litellm_kwargs["disable_streaming"] = True
return ChatLiteLLM(**litellm_kwargs)
except Exception as e:
@ -374,20 +383,28 @@ async def get_agent_llm(
async def get_document_summary_llm(
session: AsyncSession, search_space_id: int
session: AsyncSession, search_space_id: int, disable_streaming: bool = False
) -> ChatLiteLLM | ChatLiteLLMRouter | None:
"""Get the search space's document summary LLM instance."""
return await get_search_space_llm_instance(
session, search_space_id, LLMRole.DOCUMENT_SUMMARY
session,
search_space_id,
LLMRole.DOCUMENT_SUMMARY,
disable_streaming=disable_streaming,
)
# Backward-compatible alias (LLM preferences are now per-search-space, not per-user)
async def get_user_long_context_llm(
session: AsyncSession, user_id: str, search_space_id: int
session: AsyncSession,
user_id: str,
search_space_id: int,
disable_streaming: bool = False,
) -> ChatLiteLLM | ChatLiteLLMRouter | None:
"""
Deprecated: Use get_document_summary_llm instead.
The user_id parameter is ignored as LLM preferences are now per-search-space.
"""
return await get_document_summary_llm(session, search_space_id)
return await get_document_summary_llm(
session, search_space_id, disable_streaming=disable_streaming
)

View file

@ -0,0 +1,167 @@
"""
Service for fetching and caching the available LLM model list.
Uses the OpenRouter public API as the primary source, with a local
fallback JSON file when the API is unreachable.
"""
import json
import logging
import time
from pathlib import Path
import httpx
logger = logging.getLogger(__name__)
OPENROUTER_API_URL = "https://openrouter.ai/api/v1/models"
FALLBACK_FILE = Path(__file__).parent.parent / "config" / "model_list_fallback.json"
CACHE_TTL_SECONDS = 86400 # 24 hours
# In-memory cache
_cache: list[dict] | None = None
_cache_timestamp: float = 0
# Maps OpenRouter provider slug → our LiteLLMProvider enum value.
# Only providers where the model-name part (after the slash) can be
# used directly with the native provider's litellm prefix are listed.
#
# Excluded slugs and why:
# "deepseek" - Native API only accepts "deepseek-chat" / "deepseek-reasoner";
# OpenRouter uses different names (deepseek-v3.2, deepseek-r1, ...).
# "qwen" - Most OpenRouter Qwen entries are open-source models (qwen3-32b, ...)
# that are NOT available on the Dashscope API.
# "ai21" - OpenRouter name "jamba-large-1.7" != AI21 API name "jamba-1.5-large".
# "microsoft" - OpenRouter "microsoft/" = open-source Phi/WizardLM, NOT Azure
# OpenAI deployments (which require deployment names, not model ids).
OPENROUTER_SLUG_TO_PROVIDER: dict[str, str] = {
"openai": "OPENAI",
"anthropic": "ANTHROPIC",
"google": "GOOGLE",
"mistralai": "MISTRAL",
"cohere": "COHERE",
"x-ai": "XAI",
"perplexity": "PERPLEXITY",
}
def _format_context_length(length: int | None) -> str | None:
"""Convert a raw token count to a human-readable string (e.g. 128K, 1M)."""
if not length:
return None
if length >= 1_000_000:
return f"{length / 1_000_000:g}M"
if length >= 1_000:
return f"{length / 1_000:g}K"
return str(length)
async def _fetch_from_openrouter() -> list[dict] | None:
"""Try fetching the model catalogue from the OpenRouter public API."""
try:
async with httpx.AsyncClient(timeout=15) as client:
response = await client.get(OPENROUTER_API_URL)
response.raise_for_status()
data = response.json()
return data.get("data", [])
except Exception as e:
logger.warning("Failed to fetch from OpenRouter API: %s", e)
return None
def _load_fallback() -> list[dict]:
"""Load the local fallback model list."""
try:
with open(FALLBACK_FILE, encoding="utf-8") as f:
data = json.load(f)
return data.get("data", [])
except Exception as e:
logger.error("Failed to load fallback model list: %s", e)
return []
def _is_text_output_model(model: dict) -> bool:
"""Return True if the model's output is text-only (no audio/image generation)."""
output_mods = model.get("architecture", {}).get("output_modalities", [])
return output_mods == ["text"]
def _process_models(raw_models: list[dict]) -> list[dict]:
"""
Transform raw OpenRouter model entries into a flat list of
{value, label, provider, context_window} dicts.
Only text-output models are included (audio/image generators are skipped).
Each OpenRouter model is emitted once for OPENROUTER (full id) and,
when the slug maps to a native provider, once more with just the
model-name portion.
"""
processed: list[dict] = []
for model in raw_models:
model_id: str = model.get("id", "")
name: str = model.get("name", "")
context_length = model.get("context_length")
if "/" not in model_id:
continue
if not _is_text_output_model(model):
continue
provider_slug, model_name = model_id.split("/", 1)
context_window = _format_context_length(context_length)
# 1) Always emit for OPENROUTER (value = full OpenRouter id)
processed.append(
{
"value": model_id,
"label": name,
"provider": "OPENROUTER",
"context_window": context_window,
}
)
# 2) Emit for the native provider when we have a mapping
native_provider = OPENROUTER_SLUG_TO_PROVIDER.get(provider_slug)
if native_provider:
# Google's Gemini API only serves gemini-* models.
# Open-source models like gemma-* are NOT available through it.
if native_provider == "GOOGLE" and not model_name.startswith("gemini-"):
continue
processed.append(
{
"value": model_name,
"label": name,
"provider": native_provider,
"context_window": context_window,
}
)
return processed
async def get_model_list() -> list[dict]:
"""
Return the processed model list, using in-memory cache when fresh.
Tries the OpenRouter API first, falls back to the local JSON file.
"""
global _cache, _cache_timestamp
if _cache is not None and (time.time() - _cache_timestamp) < CACHE_TTL_SECONDS:
return _cache
raw_models = await _fetch_from_openrouter()
if raw_models is None:
logger.info("Using fallback model list")
raw_models = _load_fallback()
processed = _process_models(raw_models)
_cache = processed
_cache_timestamp = time.time()
return processed

View file

@ -1,3 +1,4 @@
from app.services.notion.kb_sync_service import NotionKBSyncService
from app.services.notion.tool_metadata_service import (
NotionAccount,
NotionPage,
@ -6,6 +7,7 @@ from app.services.notion.tool_metadata_service import (
__all__ = [
"NotionAccount",
"NotionKBSyncService",
"NotionPage",
"NotionToolMetadataService",
]

View file

@ -0,0 +1,170 @@
import logging
from datetime import datetime
from sqlalchemy import delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import config
from app.db import Chunk, Document
from app.services.llm_service import get_user_long_context_llm
from app.utils.document_converters import (
create_document_chunks,
generate_content_hash,
generate_document_summary,
)
logger = logging.getLogger(__name__)
class NotionKBSyncService:
def __init__(self, db_session: AsyncSession):
self.db_session = db_session
async def sync_after_update(
self,
document_id: int,
appended_content: str,
user_id: str,
search_space_id: int,
appended_block_ids: list[str] | None = None,
) -> dict:
from app.tasks.connector_indexers.base import (
get_current_timestamp,
safe_set_chunks,
)
try:
logger.debug(f"Starting KB sync for document {document_id}")
document = await self.db_session.get(Document, document_id)
if not document:
logger.warning(f"Document {document_id} not found in KB")
return {"status": "not_indexed"}
page_id = document.document_metadata.get("page_id")
if not page_id:
logger.error(f"Document {document_id} missing page_id in metadata")
return {"status": "error", "message": "Missing page_id in metadata"}
logger.debug(
f"Document found: id={document_id}, page_id={page_id}, connector_id={document.connector_id}"
)
from app.connectors.notion_history import NotionHistoryConnector
notion_connector = NotionHistoryConnector(
session=self.db_session, connector_id=document.connector_id
)
logger.debug(f"Fetching page content from Notion for page {page_id}")
blocks, _ = await notion_connector.get_page_content(
page_id, page_title=None
)
from app.utils.notion_utils import extract_all_block_ids, process_blocks
fetched_content = process_blocks(blocks)
logger.debug(f"Fetched content length: {len(fetched_content)} chars")
if not fetched_content or not fetched_content.strip():
logger.warning(
f"Fetched empty content for page {page_id} - document will have minimal searchable text"
)
content_verified = False
if appended_block_ids:
fetched_block_ids = set(extract_all_block_ids(blocks))
found_blocks = [
bid for bid in appended_block_ids if bid in fetched_block_ids
]
logger.debug(
f"Block verification: {len(found_blocks)}/{len(appended_block_ids)} blocks found"
)
logger.debug(
f"Appended IDs (first 3): {appended_block_ids[:3]}, Fetched IDs count: {len(fetched_block_ids)}"
)
if len(found_blocks) >= len(appended_block_ids) * 0.8: # 80% threshold
logger.info(
f"Content verified fresh: found {len(found_blocks)}/{len(appended_block_ids)} appended blocks"
)
full_content = fetched_content
content_verified = True
else:
logger.warning(
"No appended blocks found in fetched content - appending manually"
)
full_content = fetched_content + "\n\n" + appended_content
content_verified = False
else:
logger.warning("No block IDs provided - using fetched content as-is")
full_content = fetched_content
content_verified = False
logger.debug(
f"Final content length: {len(full_content)} chars, verified={content_verified}"
)
logger.debug("Generating summary and embeddings")
user_llm = await get_user_long_context_llm(
self.db_session,
user_id,
search_space_id,
disable_streaming=True, # disable streaming to avoid leaking into the chat
)
if user_llm:
document_metadata_for_summary = {
"page_title": document.document_metadata.get("page_title"),
"page_id": document.document_metadata.get("page_id"),
"document_type": "Notion Page",
"connector_type": "Notion",
}
summary_content, summary_embedding = await generate_document_summary(
full_content, user_llm, document_metadata_for_summary
)
logger.debug(f"Generated summary length: {len(summary_content)} chars")
else:
logger.warning("No LLM configured - using fallback summary")
summary_content = f"Notion Page: {document.document_metadata.get('page_title')}\n\n{full_content[:500]}..."
summary_embedding = config.embedding_model_instance.embed(
summary_content
)
logger.debug(f"Deleting old chunks for document {document_id}")
await self.db_session.execute(
delete(Chunk).where(Chunk.document_id == document.id)
)
logger.debug("Creating new chunks")
chunks = await create_document_chunks(full_content)
logger.debug(f"Created {len(chunks)} chunks")
logger.debug("Updating document fields")
document.content = summary_content
document.content_hash = generate_content_hash(full_content, search_space_id)
document.embedding = summary_embedding
document.document_metadata = {
**document.document_metadata,
"indexed_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
}
safe_set_chunks(document, chunks)
document.updated_at = get_current_timestamp()
logger.debug("Committing changes to database")
await self.db_session.commit()
logger.info(
f"Successfully synced KB for document {document_id}: "
f"summary={len(summary_content)} chars, chunks={len(chunks)}, "
f"content_verified={content_verified}"
)
return {"status": "success"}
except Exception as e:
logger.error(
f"Failed to sync KB for document {document_id}: {e}", exc_info=True
)
await self.db_session.rollback()
return {"status": "error", "message": str(e)}

View file

@ -1,168 +0,0 @@
"""Celery tasks for populating blocknote_document for existing documents."""
import logging
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.orm import selectinload
from sqlalchemy.pool import NullPool
from app.celery_app import celery_app
from app.config import config
from app.db import Document
from app.utils.blocknote_converter import convert_markdown_to_blocknote
logger = logging.getLogger(__name__)
def get_celery_session_maker():
"""
Create a new async session maker for Celery tasks.
This is necessary because Celery tasks run in a new event loop,
and the default session maker is bound to the main app's event loop.
"""
engine = create_async_engine(
config.DATABASE_URL,
poolclass=NullPool,
echo=False,
)
return async_sessionmaker(engine, expire_on_commit=False)
@celery_app.task(name="populate_blocknote_for_documents", bind=True)
def populate_blocknote_for_documents_task(
self, document_ids: list[int] | None = None, batch_size: int = 50
):
"""
Celery task to populate blocknote_document for existing documents.
Args:
document_ids: Optional list of specific document IDs to process.
If None, processes all documents with blocknote_document IS NULL.
batch_size: Number of documents to process in each batch (default: 50)
"""
import asyncio
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(
_populate_blocknote_for_documents(document_ids, batch_size)
)
finally:
loop.close()
async def _populate_blocknote_for_documents(
document_ids: list[int] | None = None, batch_size: int = 50
):
"""
Async function to populate blocknote_document for documents.
Args:
document_ids: Optional list of specific document IDs to process
batch_size: Number of documents to process per batch
"""
async with get_celery_session_maker()() as session:
try:
# Build query for documents that need blocknote_document populated
query = select(Document).where(Document.blocknote_document.is_(None))
# If specific document IDs provided, filter by them
if document_ids:
query = query.where(Document.id.in_(document_ids))
# Load chunks relationship to avoid N+1 queries
query = query.options(selectinload(Document.chunks))
# Execute query
result = await session.execute(query)
documents = result.scalars().all()
total_documents = len(documents)
logger.info(f"Found {total_documents} documents to process")
if total_documents == 0:
logger.info("No documents to process")
return
# Process documents in batches
processed = 0
failed = 0
for i in range(0, total_documents, batch_size):
batch = documents[i : i + batch_size]
logger.info(
f"Processing batch {i // batch_size + 1}: documents {i + 1}-{min(i + batch_size, total_documents)}"
)
for document in batch:
try:
# Use preloaded chunks from selectinload - no need to query again
chunks = sorted(document.chunks, key=lambda c: c.id)
if not chunks:
logger.warning(
f"Document {document.id} ({document.title}) has no chunks, skipping"
)
failed += 1
continue
# Reconstruct markdown by concatenating chunk contents
markdown_content = "\n\n".join(
chunk.content for chunk in chunks
)
if not markdown_content or not markdown_content.strip():
logger.warning(
f"Document {document.id} ({document.title}) has empty markdown content, skipping"
)
failed += 1
continue
# Convert markdown to BlockNote JSON
blocknote_json = await convert_markdown_to_blocknote(
markdown_content
)
if not blocknote_json:
logger.warning(
f"Failed to convert markdown to BlockNote for document {document.id} ({document.title})"
)
failed += 1
continue
# Update document with blocknote_document (other fields already have correct defaults)
document.blocknote_document = blocknote_json
processed += 1
# Commit every batch_size documents to avoid long transactions
if processed % batch_size == 0:
await session.commit()
logger.info(
f"Committed batch: {processed} documents processed so far"
)
except Exception as e:
logger.error(
f"Error processing document {document.id} ({document.title}): {e}",
exc_info=True,
)
failed += 1
# Continue with next document instead of failing entire batch
continue
# Commit remaining changes in the batch
await session.commit()
logger.info(f"Completed batch {i // batch_size + 1}")
logger.info(
f"Migration complete: {processed} documents processed, {failed} failed"
)
except Exception as e:
await session.rollback()
logger.error(f"Error in blocknote migration task: {e}", exc_info=True)
raise

View file

@ -13,7 +13,6 @@ from app.config import config
from app.db import Document
from app.services.llm_service import get_user_long_context_llm
from app.services.task_logging_service import TaskLoggingService
from app.utils.blocknote_converter import convert_blocknote_to_markdown
from app.utils.document_converters import (
create_document_chunks,
generate_document_summary,
@ -84,48 +83,37 @@ async def _reindex_document(document_id: int, user_id: str):
)
try:
if not document.blocknote_document:
# Read markdown directly from source_markdown
markdown_content = document.source_markdown
if not markdown_content:
await task_logger.log_task_failure(
log_entry,
f"Document {document_id} has no BlockNote content to reindex",
"No BlockNote content",
{"error_type": "NoBlockNoteContent"},
f"Document {document_id} has no source_markdown to reindex",
"No source_markdown content",
{"error_type": "NoSourceMarkdown"},
)
return
logger.info(f"Reindexing document {document_id} ({document.title})")
# 1. Convert BlockNote → Markdown
markdown_content = await convert_blocknote_to_markdown(
document.blocknote_document
)
if not markdown_content:
await task_logger.log_task_failure(
log_entry,
f"Failed to convert document {document_id} to markdown",
"Markdown conversion failed",
{"error_type": "ConversionError"},
)
return
# 2. Delete old chunks explicitly
# 1. Delete old chunks explicitly
from app.db import Chunk
await session.execute(delete(Chunk).where(Chunk.document_id == document_id))
await session.flush() # Ensure old chunks are deleted
# 3. Create new chunks
# 2. Create new chunks from source_markdown
new_chunks = await create_document_chunks(markdown_content)
# 4. Add new chunks to session
# 3. Add new chunks to session
for chunk in new_chunks:
chunk.document_id = document_id
session.add(chunk)
logger.info(f"Created {len(new_chunks)} chunks for document {document_id}")
# 5. Regenerate summary
# 4. Regenerate summary
user_llm = await get_user_long_context_llm(
session, user_id, document.search_space_id
)
@ -139,7 +127,7 @@ async def _reindex_document(document_id: int, user_id: str):
markdown_content, user_llm, document_metadata
)
# 6. Update document
# 5. Update document
document.content = summary_content
document.embedding = summary_embedding
document.content_needs_reindexing = False

View file

@ -28,7 +28,7 @@ from app.agents.new_chat.llm_config import (
load_agent_config,
load_llm_config_from_yaml,
)
from app.db import ChatVisibility, Document, SurfsenseDocsDocument
from app.db import ChatVisibility, Document, Report, SurfsenseDocsDocument
from app.prompts import TITLE_GENERATION_PROMPT_TEMPLATE
from app.services.chat_session_state_service import (
clear_ai_responding,
@ -226,6 +226,7 @@ async def _stream_agent_events(
last_active_step_title: str = initial_step_title
last_active_step_items: list[str] = initial_step_items or []
just_finished_tool: bool = False
active_tool_depth: int = 0 # Track nesting: >0 means we're inside a tool
def next_thinking_step_id() -> str:
nonlocal thinking_step_counter
@ -250,6 +251,8 @@ async def _stream_agent_events(
event_type = event.get("event", "")
if event_type == "on_chat_model_stream":
if active_tool_depth > 0:
continue # Suppress inner-tool LLM tokens from leaking into chat
chunk = event.get("data", {}).get("chunk")
if chunk and hasattr(chunk, "content"):
content = chunk.content
@ -269,6 +272,7 @@ async def _stream_agent_events(
accumulated_text += content
elif event_type == "on_tool_start":
active_tool_depth += 1
tool_name = event.get("name", "unknown_tool")
run_id = event.get("run_id", "")
tool_input = event.get("data", {}).get("input", {})
@ -383,26 +387,18 @@ async def _stream_agent_events(
if isinstance(tool_input, dict)
else "Report"
)
report_style = (
tool_input.get("report_style", "detailed")
if isinstance(tool_input, dict)
else "detailed"
is_revision = bool(
isinstance(tool_input, dict) and tool_input.get("parent_report_id")
)
content_len = len(
tool_input.get("source_content", "")
if isinstance(tool_input, dict)
else ""
)
last_active_step_title = "Generating report"
step_title = "Revising report" if is_revision else "Generating report"
last_active_step_title = step_title
last_active_step_items = [
f"Topic: {report_topic}",
f"Style: {report_style}",
f"Source content: {content_len:,} characters",
"Generating report with LLM...",
"Analyzing source content...",
]
yield streaming_service.format_thinking_step(
step_id=tool_step_id,
title="Generating report",
title=step_title,
status="in_progress",
items=last_active_step_items,
)
@ -428,6 +424,7 @@ async def _stream_agent_events(
)
elif event_type == "on_tool_end":
active_tool_depth = max(0, active_tool_depth - 1)
run_id = event.get("run_id", "")
tool_name = event.get("name", "unknown_tool")
raw_output = event.get("data", {}).get("output", "")
@ -589,12 +586,18 @@ async def _stream_agent_events(
if isinstance(tool_output, dict)
else 0
)
is_revision = (
tool_output.get("is_revision", False)
if isinstance(tool_output, dict)
else False
)
step_title = "Revising report" if is_revision else "Generating report"
if report_status == "ready":
completed_items = [
f"Title: {report_title}",
f"Words: {word_count:,}",
"Report generated successfully",
f"Topic: {report_title}",
f"{word_count:,} words",
"Report ready",
]
elif report_status == "failed":
error_msg = (
@ -603,7 +606,7 @@ async def _stream_agent_events(
else "Unknown error"
)
completed_items = [
f"Title: {report_title}",
f"Topic: {report_title}",
f"Error: {error_msg[:50]}",
]
else:
@ -611,7 +614,7 @@ async def _stream_agent_events(
yield streaming_service.format_thinking_step(
step_id=original_step_id,
title="Generating report",
title=step_title,
status="completed",
items=completed_items,
)
@ -796,6 +799,9 @@ async def _stream_agent_events(
"create_notion_page",
"update_notion_page",
"delete_notion_page",
"create_linear_issue",
"update_linear_issue",
"delete_linear_issue",
):
yield streaming_service.format_tool_output_available(
tool_call_id,
@ -812,6 +818,43 @@ async def _stream_agent_events(
f"Tool {tool_name} completed", "success"
)
elif event_type == "on_custom_event" and event.get("name") == "report_progress":
# Live progress updates from inside the generate_report tool
data = event.get("data", {})
message = data.get("message", "")
if message and last_active_step_id:
phase = data.get("phase", "")
# Always keep the "Topic: ..." line
topic_items = [
item for item in last_active_step_items if item.startswith("Topic:")
]
if phase in ("revising_section", "adding_section"):
# During section-level ops: keep plan summary + show current op
plan_items = [
item
for item in last_active_step_items
if item.startswith("Topic:")
or item.startswith("Modifying ")
or item.startswith("Adding ")
or item.startswith("Removing ")
]
# Only keep plan_items that don't end with "..." (not progress lines)
plan_items = [
item for item in plan_items if not item.endswith("...")
]
last_active_step_items = [*plan_items, message]
else:
# Phase transitions: replace everything after topic
last_active_step_items = [*topic_items, message]
yield streaming_service.format_thinking_step(
step_id=last_active_step_id,
title=last_active_step_title,
status="in_progress",
items=last_active_step_items,
)
elif event_type in ("on_chain_end", "on_agent_end"):
if current_text_id is not None:
yield streaming_service.format_text_end(current_text_id)
@ -993,6 +1036,20 @@ async def stream_new_chat(
)
mentioned_surfsense_docs = list(result.scalars().all())
# Fetch the most recent report(s) in this thread so the LLM can
# easily find report_id for versioning decisions, instead of
# having to dig through conversation history.
recent_reports_result = await session.execute(
select(Report)
.filter(
Report.thread_id == chat_id,
Report.content.isnot(None), # exclude failed reports
)
.order_by(Report.id.desc())
.limit(3)
)
recent_reports = list(recent_reports_result.scalars().all())
# Format the user query with context (mentioned documents + SurfSense docs)
final_query = user_query
context_parts = []
@ -1007,6 +1064,27 @@ async def stream_new_chat(
format_mentioned_surfsense_docs_as_context(mentioned_surfsense_docs)
)
# Surface report IDs prominently so the LLM doesn't have to
# retrieve them from old tool responses in conversation history.
if recent_reports:
report_lines = []
for r in recent_reports:
report_lines.append(
f' - report_id={r.id}, title="{r.title}", '
f'style="{r.report_style or "detailed"}"'
)
reports_listing = "\n".join(report_lines)
context_parts.append(
"<report_context>\n"
"Previously generated reports in this conversation:\n"
f"{reports_listing}\n\n"
"If the user wants to MODIFY, REVISE, UPDATE, or ADD to one of "
"these reports, set parent_report_id to the relevant report_id above.\n"
"If the user wants a completely NEW report on a different topic, "
"leave parent_report_id unset.\n"
"</report_context>"
)
if context_parts:
context = "\n\n".join(context_parts)
final_query = f"{context}\n\n<user_query>{user_query}</user_query>"
@ -1032,6 +1110,13 @@ async def stream_new_chat(
"search_space_id": search_space_id,
}
# All pre-streaming DB reads are done. Commit to release the
# transaction and its ACCESS SHARE locks so we don't block DDL
# (e.g. migrations) for the entire duration of LLM streaming.
# Tools that need DB access during streaming will start their own
# short-lived transactions (or use isolated sessions).
await session.commit()
# Configure LangGraph with thread_id for memory
# If checkpoint_id is provided, fork from that checkpoint (for edit/reload)
configurable = {"thread_id": str(chat_id)}
@ -1267,6 +1352,9 @@ async def stream_resume_chat(
thread_visibility=visibility,
)
# Release the transaction before streaming (same rationale as stream_new_chat).
await session.commit()
from langgraph.types import Command
config = {

View file

@ -24,6 +24,7 @@ from app.utils.document_converters import (
generate_document_summary,
generate_unique_identifier_hash,
)
from app.utils.notion_utils import process_blocks
from .base import (
build_document_metadata_string,
@ -280,53 +281,6 @@ async def index_notion_pages(
pages_to_process = [] # List of dicts with document and page data
new_documents_created = False
# Helper function to convert page content to markdown
def process_blocks(blocks, level=0):
result = ""
for block in blocks:
block_type = block.get("type")
block_content = block.get("content", "")
children = block.get("children", [])
# Add indentation based on level
indent = " " * level
# Format based on block type
if block_type in ["paragraph", "text"]:
result += f"{indent}{block_content}\n\n"
elif block_type in ["heading_1", "header"]:
result += f"{indent}# {block_content}\n\n"
elif block_type == "heading_2":
result += f"{indent}## {block_content}\n\n"
elif block_type == "heading_3":
result += f"{indent}### {block_content}\n\n"
elif block_type == "bulleted_list_item":
result += f"{indent}* {block_content}\n"
elif block_type == "numbered_list_item":
result += f"{indent}1. {block_content}\n"
elif block_type == "to_do":
result += f"{indent}- [ ] {block_content}\n"
elif block_type == "toggle":
result += f"{indent}> {block_content}\n"
elif block_type == "code":
result += f"{indent}```\n{block_content}\n```\n\n"
elif block_type == "quote":
result += f"{indent}> {block_content}\n\n"
elif block_type == "callout":
result += f"{indent}> **Note:** {block_content}\n\n"
elif block_type == "image":
result += f"{indent}![Image]({block_content})\n\n"
else:
# Default for other block types
if block_content:
result += f"{indent}{block_content}\n\n"
# Process children recursively
if children:
result += process_blocks(children, level + 1)
return result
for page in pages:
try:
page_id = page.get("page_id")

View file

@ -208,14 +208,7 @@ async def add_circleback_meeting_document(
# Process chunks
chunks = await create_document_chunks(markdown_content)
# Convert to BlockNote JSON for editing capability
from app.utils.blocknote_converter import convert_markdown_to_blocknote
blocknote_json = await convert_markdown_to_blocknote(markdown_content)
if not blocknote_json:
logger.warning(
f"Failed to convert Circleback meeting {meeting_id} to BlockNote JSON, document will not be editable"
)
# No BlockNote conversion needed — store raw markdown for Plate.js editor
# Prepare final document metadata
document_metadata = {
@ -235,7 +228,7 @@ async def add_circleback_meeting_document(
document.embedding = summary_embedding
document.document_metadata = document_metadata
safe_set_chunks(document, chunks)
document.blocknote_document = blocknote_json
document.source_markdown = markdown_content
document.content_needs_reindexing = False
document.updated_at = get_current_timestamp()
document.status = DocumentStatus.ready()

View file

@ -146,16 +146,6 @@ async def add_extension_received_document(
# Process chunks
chunks = await create_document_chunks(content.pageContent)
from app.utils.blocknote_converter import convert_markdown_to_blocknote
# Convert markdown to BlockNote JSON
blocknote_json = await convert_markdown_to_blocknote(combined_document_string)
if not blocknote_json:
logging.warning(
f"Failed to convert extension document '{content.metadata.VisitedWebPageTitle}' "
f"to BlockNote JSON, document will not be editable"
)
# Update or create document
if existing_document:
# Update existing document
@ -165,7 +155,7 @@ async def add_extension_received_document(
existing_document.embedding = summary_embedding
existing_document.document_metadata = content.metadata.model_dump()
existing_document.chunks = chunks
existing_document.blocknote_document = blocknote_json
existing_document.source_markdown = combined_document_string
existing_document.updated_at = get_current_timestamp()
await session.commit()
@ -183,7 +173,7 @@ async def add_extension_received_document(
chunks=chunks,
content_hash=content_hash,
unique_identifier_hash=unique_identifier_hash,
blocknote_document=blocknote_json,
source_markdown=combined_document_string,
updated_at=get_current_timestamp(),
created_by_id=user_id,
)

View file

@ -476,15 +476,6 @@ async def add_received_file_document_using_unstructured(
# Process chunks
chunks = await create_document_chunks(file_in_markdown)
from app.utils.blocknote_converter import convert_markdown_to_blocknote
# Convert markdown to BlockNote JSON
blocknote_json = await convert_markdown_to_blocknote(file_in_markdown)
if not blocknote_json:
logging.warning(
f"Failed to convert {file_name} to BlockNote JSON, document will not be editable"
)
# Update or create document
if existing_document:
# Update existing document
@ -497,7 +488,7 @@ async def add_received_file_document_using_unstructured(
"ETL_SERVICE": "UNSTRUCTURED",
}
existing_document.chunks = chunks
existing_document.blocknote_document = blocknote_json
existing_document.source_markdown = file_in_markdown
existing_document.content_needs_reindexing = False
existing_document.updated_at = get_current_timestamp()
existing_document.status = DocumentStatus.ready() # Mark as ready
@ -525,7 +516,7 @@ async def add_received_file_document_using_unstructured(
chunks=chunks,
content_hash=content_hash,
unique_identifier_hash=primary_hash,
blocknote_document=blocknote_json,
source_markdown=file_in_markdown,
content_needs_reindexing=False,
updated_at=get_current_timestamp(),
created_by_id=user_id,
@ -619,15 +610,6 @@ async def add_received_file_document_using_llamacloud(
# Process chunks
chunks = await create_document_chunks(file_in_markdown)
from app.utils.blocknote_converter import convert_markdown_to_blocknote
# Convert markdown to BlockNote JSON
blocknote_json = await convert_markdown_to_blocknote(file_in_markdown)
if not blocknote_json:
logging.warning(
f"Failed to convert {file_name} to BlockNote JSON, document will not be editable"
)
# Update or create document
if existing_document:
# Update existing document
@ -640,7 +622,7 @@ async def add_received_file_document_using_llamacloud(
"ETL_SERVICE": "LLAMACLOUD",
}
existing_document.chunks = chunks
existing_document.blocknote_document = blocknote_json
existing_document.source_markdown = file_in_markdown
existing_document.content_needs_reindexing = False
existing_document.updated_at = get_current_timestamp()
existing_document.status = DocumentStatus.ready() # Mark as ready
@ -668,7 +650,7 @@ async def add_received_file_document_using_llamacloud(
chunks=chunks,
content_hash=content_hash,
unique_identifier_hash=primary_hash,
blocknote_document=blocknote_json,
source_markdown=file_in_markdown,
content_needs_reindexing=False,
updated_at=get_current_timestamp(),
created_by_id=user_id,
@ -787,15 +769,6 @@ async def add_received_file_document_using_docling(
# Process chunks
chunks = await create_document_chunks(file_in_markdown)
from app.utils.blocknote_converter import convert_markdown_to_blocknote
# Convert markdown to BlockNote JSON
blocknote_json = await convert_markdown_to_blocknote(file_in_markdown)
if not blocknote_json:
logging.warning(
f"Failed to convert {file_name} to BlockNote JSON, document will not be editable"
)
# Update or create document
if existing_document:
# Update existing document
@ -808,7 +781,7 @@ async def add_received_file_document_using_docling(
"ETL_SERVICE": "DOCLING",
}
existing_document.chunks = chunks
existing_document.blocknote_document = blocknote_json
existing_document.source_markdown = file_in_markdown
existing_document.content_needs_reindexing = False
existing_document.updated_at = get_current_timestamp()
existing_document.status = DocumentStatus.ready() # Mark as ready
@ -836,7 +809,7 @@ async def add_received_file_document_using_docling(
chunks=chunks,
content_hash=content_hash,
unique_identifier_hash=primary_hash,
blocknote_document=blocknote_json,
source_markdown=file_in_markdown,
content_needs_reindexing=False,
updated_at=get_current_timestamp(),
created_by_id=user_id,
@ -1658,7 +1631,6 @@ async def process_file_in_background_with_document(
from app.config import config as app_config
from app.services.llm_service import get_user_long_context_llm
from app.utils.blocknote_converter import convert_markdown_to_blocknote
try:
markdown_content = None
@ -1917,9 +1889,6 @@ async def process_file_in_background_with_document(
chunks = await create_document_chunks(markdown_content)
# Convert to BlockNote for editing
blocknote_json = await convert_markdown_to_blocknote(markdown_content)
# ===== STEP 4: Update document to READY =====
from sqlalchemy.orm.attributes import flag_modified
@ -1937,7 +1906,7 @@ async def process_file_in_background_with_document(
# Use safe_set_chunks to avoid async issues
safe_set_chunks(document, chunks)
document.blocknote_document = blocknote_json
document.source_markdown = markdown_content
document.content_needs_reindexing = False
document.updated_at = get_current_timestamp()
document.status = DocumentStatus.ready() # Shows checkmark in UI

View file

@ -248,15 +248,6 @@ async def add_received_markdown_file_document(
# Process chunks
chunks = await create_document_chunks(file_in_markdown)
from app.utils.blocknote_converter import convert_markdown_to_blocknote
# Convert to BlockNote JSON
blocknote_json = await convert_markdown_to_blocknote(file_in_markdown)
if not blocknote_json:
logging.warning(
f"Failed to convert {file_name} to BlockNote JSON, document will not be editable"
)
# Update or create document
if existing_document:
# Update existing document
@ -268,7 +259,7 @@ async def add_received_markdown_file_document(
"FILE_NAME": file_name,
}
existing_document.chunks = chunks
existing_document.blocknote_document = blocknote_json
existing_document.source_markdown = file_in_markdown
existing_document.updated_at = get_current_timestamp()
existing_document.status = DocumentStatus.ready() # Mark as ready
@ -294,7 +285,7 @@ async def add_received_markdown_file_document(
chunks=chunks,
content_hash=content_hash,
unique_identifier_hash=primary_hash,
blocknote_document=blocknote_json,
source_markdown=file_in_markdown,
updated_at=get_current_timestamp(),
created_by_id=user_id,
connector_id=connector.get("connector_id") if connector else None,

View file

@ -397,16 +397,6 @@ async def add_youtube_video_document(
{"stage": "chunk_processing"},
)
from app.utils.blocknote_converter import convert_markdown_to_blocknote
# Convert transcript to BlockNote JSON
blocknote_json = await convert_markdown_to_blocknote(combined_document_string)
if not blocknote_json:
logging.warning(
f"Failed to convert YouTube video '{video_id}' to BlockNote JSON, "
"document will not be editable"
)
chunks = await create_document_chunks(combined_document_string)
# =======================================================================
@ -430,7 +420,7 @@ async def add_youtube_video_document(
"thumbnail": video_data.get("thumbnail_url", ""),
}
safe_set_chunks(document, chunks)
document.blocknote_document = blocknote_json
document.source_markdown = combined_document_string
document.status = DocumentStatus.ready() # READY status - fully processed
document.updated_at = get_current_timestamp()

View file

@ -1,123 +0,0 @@
import logging
from typing import Any
import httpx
from app.config import config
logger = logging.getLogger(__name__)
async def convert_markdown_to_blocknote(markdown: str) -> dict[str, Any] | None:
"""
Convert markdown to BlockNote JSON via Next.js API.
Args:
markdown: Markdown string to convert
Returns:
BlockNote document as dict, or None if conversion fails
"""
if not markdown or not markdown.strip():
logger.warning("Empty markdown provided for conversion")
return None
if not markdown or len(markdown) < 10:
logger.warning("Markdown became too short after sanitization")
# Return a minimal BlockNote document
return [
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "Document content could not be converted for editing.",
"styles": {},
}
],
"children": [],
}
]
async with httpx.AsyncClient() as client:
try:
response = await client.post(
f"{config.NEXT_FRONTEND_URL}/api/convert-to-blocknote",
json={"markdown": markdown},
timeout=30.0,
)
response.raise_for_status()
data = response.json()
blocknote_document = data.get("blocknote_document")
if blocknote_document:
logger.info(
f"Successfully converted markdown to BlockNote (original: {len(markdown)} chars, sanitized: {len(markdown)} chars)"
)
return blocknote_document
else:
logger.warning("Next.js API returned empty blocknote_document")
return None
except httpx.TimeoutException:
logger.error("Timeout converting markdown to BlockNote after 30s")
return None
except httpx.HTTPStatusError as e:
logger.error(
f"HTTP error converting markdown to BlockNote: {e.response.status_code} - {e.response.text}"
)
# Log first 1000 chars of problematic markdown for debugging
logger.debug(f"Problematic markdown sample: {markdown[:1000]}")
return None
except Exception as e:
logger.error(f"Failed to convert markdown to BlockNote: {e}", exc_info=True)
return None
async def convert_blocknote_to_markdown(
blocknote_document: dict[str, Any] | list[dict[str, Any]],
) -> str | None:
"""
Convert BlockNote JSON to markdown via Next.js API.
Args:
blocknote_document: BlockNote document as dict or list of blocks
Returns:
Markdown string, or None if conversion fails
"""
if not blocknote_document:
logger.warning("Empty BlockNote document provided for conversion")
return None
async with httpx.AsyncClient() as client:
try:
response = await client.post(
f"{config.NEXT_FRONTEND_URL}/api/convert-to-markdown",
json={"blocknote_document": blocknote_document},
timeout=30.0,
)
response.raise_for_status()
data = response.json()
markdown = data.get("markdown")
if markdown:
logger.info(
f"Successfully converted BlockNote to markdown ({len(markdown)} chars)"
)
return markdown
else:
logger.warning("Next.js API returned empty markdown")
return None
except httpx.TimeoutException:
logger.error("Timeout converting BlockNote to markdown after 30s")
return None
except httpx.HTTPStatusError as e:
logger.error(
f"HTTP error converting BlockNote to markdown: {e.response.status_code} - {e.response.text}"
)
return None
except Exception as e:
logger.error(f"Failed to convert BlockNote to markdown: {e}", exc_info=True)
return None

View file

@ -0,0 +1,291 @@
"""Pure-Python converter: BlockNote JSON → Markdown.
No external dependencies (no Node.js, no npm packages, no HTTP calls).
Handles all standard BlockNote block types. Produces output equivalent to
BlockNote's own ``blocksToMarkdownLossy()``.
Usage:
from app.utils.blocknote_to_markdown import blocknote_to_markdown
markdown = blocknote_to_markdown(blocknote_json)
"""
from __future__ import annotations
import logging
from typing import Any
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Inline content → markdown text
# ---------------------------------------------------------------------------
def _render_inline_content(content: list[dict[str, Any]] | None) -> str:
"""Convert BlockNote inline content array to a markdown string."""
if not content:
return ""
parts: list[str] = []
for item in content:
if not isinstance(item, dict):
continue
item_type = item.get("type", "text")
if item_type == "text":
text = item.get("text", "")
styles: dict[str, Any] = item.get("styles", {})
# Apply inline styles (order: code first so nested marks don't break it)
if styles.get("code"):
text = f"`{text}`"
else:
if styles.get("bold"):
text = f"**{text}**"
if styles.get("italic"):
text = f"*{text}*"
if styles.get("strikethrough"):
text = f"~~{text}~~"
# underline has no markdown equivalent — keep as plain text (lossy)
parts.append(text)
elif item_type == "link":
href = item.get("href", "")
link_content = item.get("content", [])
link_text = _render_inline_content(link_content) if link_content else href
parts.append(f"[{link_text}]({href})")
else:
# Unknown inline type — extract text if possible
text = item.get("text", "")
if text:
parts.append(text)
return "".join(parts)
# ---------------------------------------------------------------------------
# Block → markdown lines
# ---------------------------------------------------------------------------
def _render_block(
block: dict[str, Any], indent: int = 0, numbered_list_counter: int = 0
) -> tuple[list[str], int]:
"""Convert a single BlockNote block (and its children) to markdown lines.
Args:
block: A BlockNote block dict.
indent: Current indentation level (for nested children).
numbered_list_counter: Current counter for consecutive numbered list items.
Returns:
A tuple of (list of markdown lines without trailing newlines,
updated numbered_list_counter).
"""
block_type = block.get("type", "paragraph")
props: dict[str, Any] = block.get("props", {})
content = block.get("content")
children: list[dict[str, Any]] = block.get("children", [])
prefix = " " * indent # 2-space indent per nesting level
lines: list[str] = []
# --- Block type handlers ---
if block_type == "paragraph":
text = _render_inline_content(content) if content else ""
lines.append(f"{prefix}{text}")
elif block_type == "heading":
level = props.get("level", 1)
hashes = "#" * min(max(level, 1), 6)
text = _render_inline_content(content) if content else ""
lines.append(f"{prefix}{hashes} {text}")
elif block_type == "bulletListItem":
text = _render_inline_content(content) if content else ""
lines.append(f"{prefix}- {text}")
elif block_type == "numberedListItem":
# Use props.start if present, otherwise increment counter
start = props.get("start")
if start is not None:
numbered_list_counter = int(start)
else:
numbered_list_counter += 1
text = _render_inline_content(content) if content else ""
lines.append(f"{prefix}{numbered_list_counter}. {text}")
elif block_type == "checkListItem":
checked = props.get("checked", False)
marker = "[x]" if checked else "[ ]"
text = _render_inline_content(content) if content else ""
lines.append(f"{prefix}- {marker} {text}")
elif block_type == "codeBlock":
language = props.get("language", "")
# Code blocks store content as a single text item
code_text = _render_inline_content(content) if content else ""
lines.append(f"{prefix}```{language}")
for code_line in code_text.split("\n"):
lines.append(f"{prefix}{code_line}")
lines.append(f"{prefix}```")
elif block_type == "table":
# Table content is a nested structure: content.rows[].cells[][]
table_content = block.get("content", {})
rows: list[dict[str, Any]] = []
if isinstance(table_content, dict):
rows = table_content.get("rows", [])
elif isinstance(table_content, list):
# Some versions store rows directly as a list
rows = table_content
if rows:
for row_idx, row in enumerate(rows):
cells = row.get("cells", []) if isinstance(row, dict) else row
cell_texts: list[str] = []
for cell in cells:
if isinstance(cell, list):
# Cell is a list of inline content
cell_texts.append(_render_inline_content(cell))
elif isinstance(cell, dict):
# Cell is a tableCell object with its own content
cell_content = cell.get("content")
if isinstance(cell_content, list):
cell_texts.append(_render_inline_content(cell_content))
else:
cell_texts.append("")
elif isinstance(cell, str):
cell_texts.append(cell)
else:
cell_texts.append(str(cell))
lines.append(f"{prefix}| {' | '.join(cell_texts)} |")
# Add header separator after first row
if row_idx == 0:
lines.append(f"{prefix}| {' | '.join('---' for _ in cell_texts)} |")
elif block_type == "image":
url = props.get("url", "")
caption = props.get("caption", "") or props.get("name", "")
if url:
lines.append(f"{prefix}![{caption}]({url})")
elif block_type == "video":
url = props.get("url", "")
caption = props.get("caption", "") or "video"
if url:
lines.append(f"{prefix}[{caption}]({url})")
elif block_type == "audio":
url = props.get("url", "")
caption = props.get("caption", "") or "audio"
if url:
lines.append(f"{prefix}[{caption}]({url})")
elif block_type == "file":
url = props.get("url", "")
name = props.get("name", "") or props.get("caption", "") or "file"
if url:
lines.append(f"{prefix}[{name}]({url})")
else:
# Unknown block type — extract text content if possible, skip otherwise
if content:
text = _render_inline_content(content) if isinstance(content, list) else ""
if text:
lines.append(f"{prefix}{text}")
# If no content at all, silently skip (lossy)
# --- Render nested children (indented) ---
if children:
for child in children:
child_lines, numbered_list_counter = _render_block(
child, indent=indent + 1, numbered_list_counter=numbered_list_counter
)
lines.extend(child_lines)
return lines, numbered_list_counter
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def blocknote_to_markdown(
blocks: list[dict[str, Any]] | dict[str, Any] | None,
) -> str | None:
"""Convert a BlockNote document (list of blocks) to a markdown string.
Args:
blocks: BlockNote JSON either a list of block dicts, or a single
block dict, or None.
Returns:
Markdown string, or None if input is empty / unconvertible.
Examples:
>>> blocknote_to_markdown([
... {"type": "heading", "props": {"level": 2},
... "content": [{"type": "text", "text": "Hello", "styles": {}}],
... "children": []},
... {"type": "paragraph",
... "content": [{"type": "text", "text": "World", "styles": {"bold": True}}],
... "children": []},
... ])
'## Hello\\n\\nWorld'
"""
if not blocks:
return None
# Normalise: accept a single block as well as a list
if isinstance(blocks, dict):
blocks = [blocks]
if not isinstance(blocks, list):
logger.warning(
f"blocknote_to_markdown received unexpected type: {type(blocks)}"
)
return None
all_lines: list[str] = []
prev_type: str | None = None
numbered_list_counter: int = 0
for block in blocks:
if not isinstance(block, dict):
continue
block_type = block.get("type", "paragraph")
# Reset numbered list counter when we leave a numbered list run
if block_type != "numberedListItem" and prev_type == "numberedListItem":
numbered_list_counter = 0
block_lines, numbered_list_counter = _render_block(
block, numbered_list_counter=numbered_list_counter
)
# Add a blank line between blocks (standard markdown spacing)
# Exception: consecutive list items of the same type don't get extra blank lines
if all_lines and block_lines:
same_list = block_type == prev_type and block_type in (
"bulletListItem",
"numberedListItem",
"checkListItem",
)
if not same_list:
all_lines.append("")
all_lines.extend(block_lines)
prev_type = block_type
result = "\n".join(all_lines).strip()
return result if result else None

View file

@ -0,0 +1,58 @@
"""Utility functions for processing Notion blocks and content."""
def extract_all_block_ids(blocks_list):
ids = []
for block in blocks_list:
if isinstance(block, dict) and "id" in block:
ids.append(block["id"])
if isinstance(block, dict) and block.get("children"):
ids.extend(extract_all_block_ids(block["children"]))
return ids
def process_blocks(blocks, level=0):
result = ""
for block in blocks:
block_type = block.get("type")
block_content = block.get("content", "")
children = block.get("children", [])
# Add indentation based on level
indent = " " * level
# Format based on block type
if block_type in ["paragraph", "text"]:
result += f"{indent}{block_content}\n\n"
elif block_type in ["heading_1", "header"]:
result += f"{indent}# {block_content}\n\n"
elif block_type == "heading_2":
result += f"{indent}## {block_content}\n\n"
elif block_type == "heading_3":
result += f"{indent}### {block_content}\n\n"
elif block_type == "bulleted_list_item":
result += f"{indent}* {block_content}\n"
elif block_type == "numbered_list_item":
result += f"{indent}1. {block_content}\n"
elif block_type == "to_do":
result += f"{indent}- [ ] {block_content}\n"
elif block_type == "toggle":
result += f"{indent}> {block_content}\n"
elif block_type == "code":
result += f"{indent}```\n{block_content}\n```\n\n"
elif block_type == "quote":
result += f"{indent}> {block_content}\n\n"
elif block_type == "callout":
result += f"{indent}> **Note:** {block_content}\n\n"
elif block_type == "image":
result += f"{indent}![Image]({block_content})\n\n"
else:
# Default for other block types
if block_content:
result += f"{indent}{block_content}\n\n"
# Process children recursively
if children:
result += process_blocks(children, level + 1)
return result

View file

@ -56,7 +56,6 @@ dependencies = [
"sse-starlette>=3.1.1,<3.1.2",
"gitingest>=0.3.1",
"composio>=0.10.9",
"deepagents>=0.3.8",
"langchain>=1.2.6",
"langgraph>=1.0.5",
"unstructured[all-docs]>=0.18.31",
@ -65,6 +64,7 @@ dependencies = [
"slowapi>=0.1.9",
"pypandoc_binary>=1.16.2",
"typst>=0.14.0",
"deepagents>=0.4.3",
]
[dependency-groups]

6487
surfsense_backend/uv.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,31 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Next.js: debug client-side",
"type": "chrome",
"request": "launch",
"url": "http://localhost:3000",
"webRoot": "${workspaceFolder}"
},
{
"name": "Next.js: debug server-side",
"type": "node-terminal",
"request": "launch",
"command": "pnpm run debug:server",
"skipFiles": ["<node_internals>/**"]
},
{
"name": "Next.js: debug full stack",
"type": "node-terminal",
"request": "launch",
"command": "pnpm run debug",
"serverReadyAction": {
"pattern": "- Local:.+(https?://.+)",
"uriFormat": "%s",
"action": "debugWithChrome"
},
"skipFiles": ["<node_internals>/**"]
}
]
}

View file

@ -3,18 +3,15 @@
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 { useEffect } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@ -25,16 +22,6 @@ import {
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";
@ -82,24 +69,12 @@ const categoryConfig: Record<
// Announcement card
// ---------------------------------------------------------------------------
function AnnouncementCard({
announcement,
onMarkRead,
onDismiss,
}: {
announcement: AnnouncementWithState;
onMarkRead: (id: string) => void;
onDismiss: (id: string) => void;
}) {
const config = categoryConfig[announcement.category];
function AnnouncementCard({ announcement }: { announcement: AnnouncementWithState }) {
const config = categoryConfig[announcement.category] ?? categoryConfig.info;
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" : ""
}`}
>
<Card className="group relative transition-all duration-200 hover:shadow-md">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3 min-w-0">
@ -120,47 +95,12 @@ function AnnouncementCard({
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>
@ -174,7 +114,6 @@ function AnnouncementCard({
<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" />
@ -190,23 +129,15 @@ function AnnouncementCard({
// Empty state
// ---------------------------------------------------------------------------
function EmptyState({ hasFilters }: { hasFilters: boolean }) {
function EmptyState() {
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" />
)}
<BellOff className="h-7 w-7 text-muted-foreground" />
</div>
<h3 className="text-lg font-semibold">
{hasFilters ? "No matching announcements" : "No announcements"}
</h3>
<h3 className="text-lg font-semibold">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."}
You're all caught up! New announcements will appear here.
</p>
</div>
);
@ -217,134 +148,38 @@ function EmptyState({ hasFilters }: { hasFilters: boolean }) {
// ---------------------------------------------------------------------------
export default function AnnouncementsPage() {
const [activeCategories, setActiveCategories] = useState<AnnouncementCategory[]>([]);
const [showOnlyUnread, setShowOnlyUnread] = useState(false);
const { announcements, markAllRead } = useAnnouncements();
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]
);
};
// Auto-mark all visible announcements as read when the page is opened
useEffect(() => {
markAllRead();
}, [markAllRead]);
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 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>
{/* 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>
{/* Content */}
<div className="max-w-3xl mx-auto px-6 lg:px-10 pt-8 pb-20">
{announcements.length === 0 ? (
<EmptyState />
) : (
<div className="flex flex-col gap-4">
{announcements.map((announcement) => (
<AnnouncementCard key={announcement.id} announcement={announcement} />
))}
</div>
)}
</div>
</div>
);
}

View file

@ -1,40 +0,0 @@
import { ServerBlockNoteEditor } from "@blocknote/server-util";
import { type NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
try {
const { markdown } = await request.json();
if (!markdown || typeof markdown !== "string") {
return NextResponse.json({ error: "Markdown string is required" }, { status: 400 });
}
// Log raw markdown input before conversion
// console.log(`\n${"=".repeat(80)}`);
// console.log("RAW MARKDOWN INPUT (BEFORE CONVERSION):");
// console.log("=".repeat(80));
// console.log(markdown);
// console.log(`${"=".repeat(80)}\n`);
// Create server-side editor instance
const editor = ServerBlockNoteEditor.create();
// Convert markdown directly to BlockNote blocks
const blocks = await editor.tryParseMarkdownToBlocks(markdown);
if (!blocks || blocks.length === 0) {
throw new Error("Markdown parsing returned no blocks");
}
return NextResponse.json({ blocknote_document: blocks });
} catch (error: any) {
console.error("Failed to convert markdown to BlockNote:", error);
return NextResponse.json(
{
error: "Failed to convert markdown to BlockNote blocks",
details: error.message,
},
{ status: 500 }
);
}
}

View file

@ -1,28 +0,0 @@
import { ServerBlockNoteEditor } from "@blocknote/server-util";
import { type NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
try {
const { blocknote_document } = await request.json();
if (!blocknote_document || !Array.isArray(blocknote_document)) {
return NextResponse.json({ error: "BlockNote document array is required" }, { status: 400 });
}
// Create server-side editor instance
const editor = ServerBlockNoteEditor.create();
// Convert BlockNote blocks to markdown
const markdown = await editor.blocksToMarkdownLossy(blocknote_document);
return NextResponse.json({
markdown,
});
} catch (error) {
console.error("Failed to convert BlockNote to markdown:", error);
return NextResponse.json(
{ error: "Failed to convert BlockNote blocks to markdown" },
{ status: 500 }
);
}
}

View file

@ -1,6 +1,6 @@
"use client";
import { MoreHorizontal, Pencil, Trash2 } from "lucide-react";
import { MoreHorizontal, PenLine, Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "sonner";
@ -115,7 +115,7 @@ export function RowActions({
isEditDisabled ? "text-muted-foreground cursor-not-allowed opacity-50" : ""
}
>
<Pencil className="mr-2 h-4 w-4" />
<PenLine className="mr-2 h-4 w-4" />
<span>Edit</span>
</DropdownMenuItem>
{shouldShowDelete && (
@ -170,7 +170,7 @@ export function RowActions({
isEditDisabled ? "text-muted-foreground cursor-not-allowed opacity-50" : ""
}
>
<Pencil className="mr-2 h-4 w-4" />
<PenLine className="mr-2 h-4 w-4" />
<span>Edit</span>
</DropdownMenuItem>
{shouldShowDelete && (

View file

@ -1,13 +1,13 @@
"use client";
import { useAtom } from "jotai";
import { AlertCircle, ArrowLeft, FileText, Save } from "lucide-react";
import { AlertCircle, ArrowLeft, FileText } from "lucide-react";
import { motion } from "motion/react";
import dynamic from "next/dynamic";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { hasUnsavedEditorChangesAtom, pendingEditorNavigationAtom } from "@/atoms/editor/ui.atoms";
import { BlockNoteEditor } from "@/components/DynamicBlockNoteEditor";
import {
AlertDialog,
AlertDialogAction,
@ -20,58 +20,53 @@ import {
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Spinner } from "@/components/ui/spinner";
import { Skeleton } from "@/components/ui/skeleton";
import { notesApiService } from "@/lib/apis/notes-api.service";
import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
// BlockNote types
type BlockNoteInlineContent =
| string
| { text?: string; type?: string; styles?: Record<string, unknown> };
interface BlockNoteBlock {
type: string;
content?: BlockNoteInlineContent[];
children?: BlockNoteBlock[];
props?: Record<string, unknown>;
}
type BlockNoteDocument = BlockNoteBlock[] | null | undefined;
// Dynamically import PlateEditor (uses 'use client' internally)
const PlateEditor = dynamic(
() => import("@/components/editor/plate-editor").then((mod) => ({ default: mod.PlateEditor })),
{
ssr: false,
loading: () => (
<div className="mx-auto w-full max-w-[900px] px-6 md:px-12 pt-10 space-y-4">
<Skeleton className="h-8 w-3/5 rounded" />
<div className="space-y-3 pt-4">
<Skeleton className="h-4 w-full rounded" />
<Skeleton className="h-4 w-full rounded" />
<Skeleton className="h-4 w-4/5 rounded" />
</div>
<div className="space-y-3 pt-3">
<Skeleton className="h-4 w-full rounded" />
<Skeleton className="h-4 w-5/6 rounded" />
<Skeleton className="h-4 w-3/4 rounded" />
</div>
<div className="space-y-3 pt-3">
<Skeleton className="h-4 w-full rounded" />
<Skeleton className="h-4 w-2/3 rounded" />
</div>
</div>
),
}
);
interface EditorContent {
document_id: number;
title: string;
document_type?: string;
blocknote_document: BlockNoteDocument;
source_markdown: string;
updated_at: string | null;
}
// Helper function to extract title from BlockNote document
// Takes the text content from the first block (should be a heading for notes)
function extractTitleFromBlockNote(blocknoteDocument: BlockNoteDocument): string {
if (!blocknoteDocument || !Array.isArray(blocknoteDocument) || blocknoteDocument.length === 0) {
return "Untitled";
/** Extract title from markdown: first # heading, or first non-empty line. */
function extractTitleFromMarkdown(markdown: string | null | undefined): string {
if (!markdown) return "Untitled";
for (const line of markdown.split("\n")) {
const trimmed = line.trim();
if (trimmed.startsWith("# ")) return trimmed.slice(2).trim() || "Untitled";
if (trimmed) return trimmed.slice(0, 100);
}
const firstBlock = blocknoteDocument[0];
if (!firstBlock) {
return "Untitled";
}
// Extract text from block content
// BlockNote blocks have a content array with inline content
if (firstBlock.content && Array.isArray(firstBlock.content)) {
const textContent = firstBlock.content
.map((item: BlockNoteInlineContent) => {
if (typeof item === "string") return item;
if (typeof item === "object" && item?.text) return item.text;
return "";
})
.join("")
.trim();
return textContent || "Untitled";
}
return "Untitled";
}
@ -85,11 +80,14 @@ export default function EditorPage() {
const [document, setDocument] = useState<EditorContent | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [editorContent, setEditorContent] = useState<BlockNoteDocument>(null);
const [error, setError] = useState<string | null>(null);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [showUnsavedDialog, setShowUnsavedDialog] = useState(false);
// Store the latest markdown from the editor
const markdownRef = useRef<string>("");
const initialLoadDone = useRef(false);
// Global state for cross-component communication
const [, setGlobalHasUnsavedChanges] = useAtom(hasUnsavedEditorChangesAtom);
const [pendingNavigation, setPendingNavigation] = useAtom(pendingEditorNavigationAtom);
@ -107,51 +105,46 @@ export default function EditorPage() {
};
}, [setGlobalHasUnsavedChanges, setPendingNavigation]);
// Handle pending navigation from sidebar (e.g., when user clicks "+" to create new note)
// Handle pending navigation from sidebar
useEffect(() => {
if (pendingNavigation) {
if (hasUnsavedChanges) {
// Show dialog to confirm navigation
setShowUnsavedDialog(true);
} else {
// No unsaved changes, navigate immediately
router.push(pendingNavigation);
setPendingNavigation(null);
}
}
}, [pendingNavigation, hasUnsavedChanges, router, setPendingNavigation]);
// Reset state when documentId changes (e.g., navigating from existing note to new note)
// Reset state when documentId changes
useEffect(() => {
setDocument(null);
setEditorContent(null);
setError(null);
setHasUnsavedChanges(false);
setLoading(true);
}, []);
initialLoadDone.current = false;
}, [documentId]);
// Fetch document content - DIRECT CALL TO FASTAPI
// Skip fetching if this is a new note
// Fetch document content
useEffect(() => {
async function fetchDocument() {
// For new notes, initialize with empty state
if (isNewNote) {
markdownRef.current = "";
setDocument({
document_id: 0,
title: "Untitled",
document_type: "NOTE",
blocknote_document: null,
source_markdown: "",
updated_at: null,
});
setEditorContent(null);
setLoading(false);
initialLoadDone.current = true;
return;
}
const token = getBearerToken();
if (!token) {
console.error("No auth token found");
// Redirect to login with current path saved
redirectToLogin();
return;
}
@ -166,29 +159,28 @@ export default function EditorPage() {
const errorData = await response
.json()
.catch(() => ({ detail: "Failed to fetch document" }));
const errorMessage = errorData.detail || "Failed to fetch document";
throw new Error(errorMessage);
throw new Error(errorData.detail || "Failed to fetch document");
}
const data = await response.json();
// Check if blocknote_document exists
if (!data.blocknote_document) {
const errorMsg =
"This document does not have BlockNote content. Please re-upload the document to enable editing.";
setError(errorMsg);
if (data.source_markdown === undefined || data.source_markdown === null) {
setError(
"This document does not have editable content. Please re-upload to enable editing."
);
setLoading(false);
return;
}
markdownRef.current = data.source_markdown;
setDocument(data);
setEditorContent(data.blocknote_document);
setError(null);
initialLoadDone.current = true;
} catch (error) {
console.error("Error fetching document:", error);
const errorMessage =
error instanceof Error ? error.message : "Failed to fetch document. Please try again.";
setError(errorMessage);
setError(
error instanceof Error ? error.message : "Failed to fetch document. Please try again."
);
} finally {
setLoading(false);
}
@ -199,29 +191,27 @@ export default function EditorPage() {
}
}, [documentId, params.search_space_id, isNewNote]);
// Track changes to mark as unsaved
useEffect(() => {
if (editorContent && document) {
setHasUnsavedChanges(true);
}
}, [editorContent, document]);
// Check if this is a NOTE type document
const isNote = isNewNote || document?.document_type === "NOTE";
// Extract title dynamically from editor content for notes, otherwise use document title
// Extract title dynamically from current markdown for notes
const displayTitle = useMemo(() => {
if (isNote && editorContent) {
return extractTitleFromBlockNote(editorContent);
if (isNote) {
return extractTitleFromMarkdown(markdownRef.current || document?.source_markdown);
}
return document?.title || "Untitled";
}, [isNote, editorContent, document?.title]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isNote, document?.title, document?.source_markdown, hasUnsavedChanges]);
// TODO: Maybe add Auto-save every 30 seconds - DIRECT CALL TO FASTAPI
// Handle markdown changes from the Plate editor
const handleMarkdownChange = useCallback((md: string) => {
markdownRef.current = md;
if (initialLoadDone.current) {
setHasUnsavedChanges(true);
}
}, []);
// Save and exit - DIRECT CALL TO FASTAPI
// For new notes, create the note first, then save
const handleSave = async () => {
// Save handler
const handleSave = useCallback(async () => {
const token = getBearerToken();
if (!token) {
toast.error("Please login to save");
@ -233,25 +223,26 @@ export default function EditorPage() {
setError(null);
try {
// If this is a new note, create it first
if (isNewNote) {
const title = extractTitleFromBlockNote(editorContent);
const currentMarkdown = markdownRef.current;
// Create the note first
if (isNewNote) {
const title = extractTitleFromMarkdown(currentMarkdown);
// Create the note
const note = await notesApiService.createNote({
search_space_id: searchSpaceId,
title: title,
blocknote_document: editorContent || undefined,
title,
source_markdown: currentMarkdown || undefined,
});
// If there's content, save it properly and trigger reindexing
if (editorContent) {
// If there's content, save & trigger reindexing
if (currentMarkdown) {
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${note.id}/save`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ blocknote_document: editorContent }),
body: JSON.stringify({ source_markdown: currentMarkdown }),
}
);
@ -265,24 +256,15 @@ export default function EditorPage() {
setHasUnsavedChanges(false);
toast.success("Note created successfully! Reindexing in background...");
// Redirect to documents page after successful save
router.push(`/dashboard/${searchSpaceId}/documents`);
} else {
// Existing document - save normally
if (!editorContent) {
toast.error("No content to save");
setSaving(false);
return;
}
// Save blocknote_document and trigger reindexing in background
// Existing document — save
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${params.search_space_id}/documents/${documentId}/save`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ blocknote_document: editorContent }),
body: JSON.stringify({ source_markdown: currentMarkdown }),
}
);
@ -295,8 +277,6 @@ export default function EditorPage() {
setHasUnsavedChanges(false);
toast.success("Document saved! Reindexing in background...");
// Redirect to documents page after successful save
router.push(`/dashboard/${searchSpaceId}/documents`);
}
} catch (error) {
@ -312,7 +292,7 @@ export default function EditorPage() {
} finally {
setSaving(false);
}
};
}, [isNewNote, searchSpaceId, documentId, params.search_space_id, router]);
const handleBack = () => {
if (hasUnsavedChanges) {
@ -324,11 +304,9 @@ export default function EditorPage() {
const handleConfirmLeave = () => {
setShowUnsavedDialog(false);
// Clear global unsaved state
setGlobalHasUnsavedChanges(false);
setHasUnsavedChanges(false);
// If there's a pending navigation (from sidebar), use that; otherwise go back to documents
if (pendingNavigation) {
router.push(pendingNavigation);
setPendingNavigation(null);
@ -337,26 +315,67 @@ export default function EditorPage() {
}
};
const handleSaveAndLeave = async () => {
setShowUnsavedDialog(false);
setPendingNavigation(null);
await handleSave();
};
const handleCancelLeave = () => {
setShowUnsavedDialog(false);
// Clear pending navigation if user cancels
setPendingNavigation(null);
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px] p-6">
<Card className="w-full max-w-md">
<CardContent className="flex flex-col items-center justify-center py-12">
<Spinner size="xl" className="text-primary mb-4" />
<p className="text-muted-foreground">Loading editor</p>
</CardContent>
</Card>
<div className="flex flex-col h-screen w-full overflow-hidden">
{/* Top bar skeleton — real back button & file icon, skeleton title */}
<div className="flex h-14 md:h-16 shrink-0 items-center border-b bg-background pl-1.5 pr-3 md:pl-3 md:pr-6">
<div className="flex items-center gap-1.5 md:gap-2 flex-1 min-w-0">
<Button
variant="ghost"
size="icon"
onClick={() => router.push(`/dashboard/${searchSpaceId}/documents`)}
className="h-7 w-7 shrink-0 p-0"
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Back</span>
</Button>
<FileText className="h-4 w-4 md:h-5 md:w-5 text-muted-foreground shrink-0" />
<Skeleton className="h-5 w-40 rounded" />
</div>
</div>
{/* Fixed toolbar placeholder — matches real toolbar styling */}
<div className="sticky top-0 left-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 h-10" />
{/* Content area skeleton — mimics document text lines */}
<div className="flex-1 min-h-0 overflow-hidden">
<div className="mx-auto w-full max-w-[900px] px-6 md:px-12 pt-10 space-y-4">
{/* Title-like line */}
<Skeleton className="h-8 w-3/5 rounded" />
{/* Paragraph lines */}
<div className="space-y-3 pt-4">
<Skeleton className="h-4 w-full rounded" />
<Skeleton className="h-4 w-full rounded" />
<Skeleton className="h-4 w-4/5 rounded" />
</div>
<div className="space-y-3 pt-3">
<Skeleton className="h-4 w-full rounded" />
<Skeleton className="h-4 w-5/6 rounded" />
<Skeleton className="h-4 w-3/4 rounded" />
</div>
<div className="space-y-3 pt-3">
<Skeleton className="h-4 w-full rounded" />
<Skeleton className="h-4 w-2/3 rounded" />
</div>
</div>
</div>
</div>
);
}
if (error) {
if (error && !document) {
return (
<div className="flex items-center justify-center min-h-[400px] p-6">
<motion.div
@ -405,11 +424,21 @@ export default function EditorPage() {
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex flex-col min-h-screen w-full"
className="flex flex-col h-screen w-full overflow-hidden"
>
{/* Toolbar */}
<div className="sticky top-0 z-40 flex h-14 md:h-16 shrink-0 items-center gap-2 md:gap-4 bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 px-3 md:px-6">
<div className="flex items-center gap-2 md:gap-3 flex-1 min-w-0">
<div className="flex h-14 md:h-16 shrink-0 items-center border-b bg-background pl-1.5 pr-3 md:pl-3 md:pr-6">
<div className="flex items-center gap-1.5 md:gap-2 flex-1 min-w-0">
<Button
variant="ghost"
size="icon"
onClick={handleBack}
disabled={saving}
className="h-7 w-7 shrink-0 p-0"
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Back</span>
</Button>
<FileText className="h-4 w-4 md:h-5 md:w-5 text-muted-foreground shrink-0" />
<div className="flex flex-col min-w-0">
<h1 className="text-base md:text-lg font-semibold truncate">{displayTitle}</h1>
@ -418,60 +447,33 @@ export default function EditorPage() {
)}
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={handleBack}
disabled={saving}
className="gap-1 md:gap-2 px-2 md:px-4 h-8 md:h-10"
>
<ArrowLeft className="h-3.5 w-3.5 md:h-4 md:w-4" />
<span className="text-xs md:text-sm">Back</span>
</Button>
<Button
onClick={handleSave}
disabled={saving}
className="gap-1 md:gap-2 px-2 md:px-4 h-8 md:h-10"
>
{saving ? (
<>
<Spinner size="sm" className="h-3.5 w-3.5 md:h-4 md:w-4" />
<span className="text-xs md:text-sm">{isNewNote ? "Creating" : "Saving"}</span>
</>
) : (
<>
<Save className="h-3.5 w-3.5 md:h-4 md:w-4" />
<span className="text-xs md:text-sm">Save</span>
</>
)}
</Button>
</div>
</div>
{/* Editor Container */}
<div className="flex-1 min-h-0 overflow-hidden relative">
<div className="h-full w-full overflow-auto p-3 md:p-6">
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="mb-6 max-w-4xl mx-auto"
>
<div className="flex items-center gap-2 p-4 rounded-lg border border-destructive/50 bg-destructive/10 text-destructive">
<AlertCircle className="h-5 w-5 shrink-0" />
<p className="text-sm">{error}</p>
</div>
</motion.div>
)}
<div className="max-w-4xl mx-auto">
<BlockNoteEditor
key={documentId} // Force re-mount when document changes
initialContent={isNewNote ? undefined : editorContent}
onChange={setEditorContent}
useTitleBlock={isNote}
/>
</div>
<div className="flex-1 min-h-0 flex flex-col overflow-hidden relative">
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="px-3 md:px-6 pt-3 md:pt-6"
>
<div className="flex items-center gap-2 p-4 rounded-lg border border-destructive/50 bg-destructive/10 text-destructive max-w-4xl mx-auto">
<AlertCircle className="h-5 w-5 shrink-0" />
<p className="text-sm">{error}</p>
</div>
</motion.div>
)}
<div className="flex-1 min-h-0">
<PlateEditor
key={documentId}
preset="full"
markdown={document?.source_markdown ?? ""}
onMarkdownChange={handleMarkdownChange}
onSave={handleSave}
hasUnsavedChanges={hasUnsavedChanges}
isSaving={saving}
defaultEditing={true}
/>
</div>
</div>
@ -491,7 +493,13 @@ export default function EditorPage() {
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleCancelLeave}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmLeave}>OK</AlertDialogAction>
<AlertDialogAction onClick={handleSaveAndLeave}>Save</AlertDialogAction>
<AlertDialogAction
onClick={handleConfirmLeave}
className="border border-input bg-background text-foreground hover:bg-accent hover:text-accent-foreground"
>
Leave without saving
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>

View file

@ -34,15 +34,22 @@ import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { Thread } from "@/components/assistant-ui/thread";
import { ChatHeader } from "@/components/new-chat/chat-header";
import { ReportPanel } from "@/components/report-panel/report-panel";
import { CreateNotionPageToolUI } from "@/components/tool-ui/create-notion-page";
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 {
CreateLinearIssueToolUI,
DeleteLinearIssueToolUI,
UpdateLinearIssueToolUI,
} from "@/components/tool-ui/linear";
import { LinkPreviewToolUI } from "@/components/tool-ui/link-preview";
import {
CreateNotionPageToolUI,
DeleteNotionPageToolUI,
UpdateNotionPageToolUI,
} from "@/components/tool-ui/notion";
import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage";
import { UpdateNotionPageToolUI } from "@/components/tool-ui/update-notion-page";
import { RecallMemoryToolUI, SaveMemoryToolUI } from "@/components/tool-ui/user-memory";
import { Skeleton } from "@/components/ui/skeleton";
import { useChatSessionStateSync } from "@/hooks/use-chat-session-state";
@ -141,6 +148,9 @@ const TOOLS_WITH_UI = new Set([
"scrape_webpage",
"create_notion_page",
"update_notion_page",
"create_linear_issue",
"update_linear_issue",
"delete_linear_issue",
// "write_todos", // Disabled for now
]);
@ -1651,6 +1661,9 @@ export default function NewChatPage() {
<CreateNotionPageToolUI />
<UpdateNotionPageToolUI />
<DeleteNotionPageToolUI />
<CreateLinearIssueToolUI />
<UpdateLinearIssueToolUI />
<DeleteLinearIssueToolUI />
{/* <WriteTodosToolUI /> Disabled for now */}
<div className="flex h-[calc(100dvh-64px)] overflow-hidden">
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">

View file

@ -4,6 +4,8 @@
@plugin "tailwindcss-animate";
@plugin "tailwind-scrollbar-hide";
@custom-variant dark (&:is(.dark *));
@theme {
@ -46,6 +48,8 @@
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--syntax-bg: #f5f5f5;
--brand: oklch(0.623 0.214 259.815);
--highlight: oklch(0.852 0.199 91.936);
}
.dark {
@ -82,6 +86,8 @@
--sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0);
--syntax-bg: #1e1e1e;
--brand: oklch(0.707 0.165 254.624);
--highlight: oklch(0.852 0.199 91.936);
}
@theme inline {
@ -123,6 +129,8 @@
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-brand: var(--brand);
--color-highlight: var(--highlight);
}
@layer base {

View file

@ -1,4 +1,6 @@
import { atomWithQuery } from "jotai-tanstack-query";
import type { LLMModel } from "@/contracts/enums/llm-models";
import { LLM_MODELS } from "@/contracts/enums/llm-models";
import { newLLMConfigApiService } from "@/lib/apis/new-llm-config-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { activeSearchSpaceIdAtom } from "../search-spaces/search-space-query.atoms";
@ -62,3 +64,33 @@ export const defaultSystemInstructionsAtom = atomWithQuery(() => {
},
};
});
/**
* Query atom for the dynamic LLM model catalogue.
* Fetched from the backend (which proxies OpenRouter's public API).
* Falls back to the static hardcoded list on error.
*/
export const modelListAtom = atomWithQuery(() => {
return {
queryKey: cacheKeys.newLLMConfigs.modelList(),
staleTime: 60 * 60 * 1000, // 1 hour - models don't change often
placeholderData: LLM_MODELS,
queryFn: async (): Promise<LLMModel[]> => {
const data = await newLLMConfigApiService.getModels();
const dynamicModels = data.map((m) => ({
value: m.value,
label: m.label,
provider: m.provider,
contextWindow: m.context_window ?? undefined,
}));
// Providers covered by the dynamic API (from OpenRouter mapping).
// For uncovered providers (Ollama, Groq, Bedrock, etc.) keep the
// hand-curated static suggestions so users still see model options.
const coveredProviders = new Set(dynamicModels.map((m) => m.provider));
const staticFallbacks = LLM_MODELS.filter((m) => !coveredProviders.has(m.provider));
return [...dynamicModels, ...staticFallbacks];
},
};
});

View file

@ -10,6 +10,7 @@
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
@ -17,5 +18,7 @@
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
"registries": {
"@plate": "https://platejs.org/r/{name}.json"
}
}

View file

@ -1,213 +0,0 @@
"use client";
import { useTheme } from "next-themes";
import { useEffect, useMemo, useRef } from "react";
import "@blocknote/core/fonts/inter.css";
import "@blocknote/mantine/style.css";
import { BlockNoteView } from "@blocknote/mantine";
import { useCreateBlockNote } from "@blocknote/react";
interface BlockNoteEditorProps {
initialContent?: any;
onChange?: (content: any) => void;
useTitleBlock?: boolean; // Whether to use first block as title (Notion-style)
}
// Helper to ensure first block is a heading for title
function ensureTitleBlock(content: any[] | undefined): any[] {
if (!content || content.length === 0) {
// Return empty heading block for new notes
return [
{
type: "heading",
props: { level: 1 },
content: [],
children: [],
},
];
}
// If first block is not a heading, convert it to one
const firstBlock = content[0];
if (firstBlock?.type !== "heading") {
// Extract text from first block
let titleText = "";
if (firstBlock?.content && Array.isArray(firstBlock.content)) {
titleText = firstBlock.content
.map((item: any) => {
if (typeof item === "string") return item;
if (item?.text) return item.text;
return "";
})
.join("")
.trim();
}
// Create heading block with extracted text
const titleBlock = {
type: "heading",
props: { level: 1 },
content: titleText
? [
{
type: "text",
text: titleText,
styles: {},
},
]
: [],
children: [],
};
// Replace first block with heading, keep rest
return [titleBlock, ...content.slice(1)];
}
return content;
}
export default function BlockNoteEditor({
initialContent,
onChange,
useTitleBlock = false,
}: BlockNoteEditorProps) {
const { resolvedTheme } = useTheme();
// Track the initial content to prevent re-initialization
const initialContentRef = useRef<any>(null);
const isInitializedRef = useRef(false);
// Prepare initial content - ensure first block is a heading if useTitleBlock is true
const preparedInitialContent = useMemo(() => {
if (initialContentRef.current !== null) {
return undefined; // Already initialized
}
if (initialContent === undefined) {
// New note - create empty heading block
return useTitleBlock
? [
{
type: "heading",
props: { level: 1 },
content: [],
children: [],
},
]
: undefined;
}
// Existing note - ensure first block is heading
return useTitleBlock ? ensureTitleBlock(initialContent) : initialContent;
}, [initialContent, useTitleBlock]);
// Creates a new editor instance - only use initialContent on first render
const editor = useCreateBlockNote({
initialContent: initialContentRef.current === null ? preparedInitialContent : undefined,
});
// Store initial content on first render only
useEffect(() => {
if (preparedInitialContent !== undefined && initialContentRef.current === null) {
initialContentRef.current = preparedInitialContent;
isInitializedRef.current = true;
} else if (preparedInitialContent === undefined && initialContentRef.current === null) {
// Mark as initialized even when initialContent is undefined (for new notes)
isInitializedRef.current = true;
}
}, [preparedInitialContent]);
// Call onChange when document changes (but don't update from props)
useEffect(() => {
if (!onChange || !editor) return;
// For new notes (no initialContent), we need to wait for editor to be ready
// Use a small delay to ensure editor is fully initialized
if (!isInitializedRef.current) {
const timer = setTimeout(() => {
isInitializedRef.current = true;
}, 100);
return () => clearTimeout(timer);
}
const handleChange = () => {
onChange(editor.document);
};
// Subscribe to document changes
const unsubscribe = editor.onChange(handleChange);
// Also call onChange once with current document to capture initial state
// This ensures we capture content even if user doesn't make changes
if (editor.document) {
onChange(editor.document);
}
return () => {
unsubscribe();
};
}, [editor, onChange]);
// Determine theme for BlockNote with custom dark mode background
const blockNoteTheme = useMemo(() => {
if (resolvedTheme === "dark") {
// Custom dark theme - only override editor background, let BlockNote handle the rest
return {
colors: {
editor: {
background: "#0A0A0A", // Custom dark background
},
},
};
}
return "light" as const;
}, [resolvedTheme]);
// Renders the editor instance
return (
<div className="bn-container">
<style>{`
@media (max-width: 640px) {
.bn-container .bn-editor {
padding-inline: 12px !important;
}
/* Heading Level 1 (Title) */
.bn-container [data-content-type="heading"][data-level="1"] {
font-size: 1.75rem !important;
line-height: 1.2 !important;
margin-top: 1rem !important;
margin-bottom: 0.5rem !important;
}
/* Heading Level 2 */
.bn-container [data-content-type="heading"][data-level="2"] {
font-size: 1.5rem !important;
line-height: 1.2 !important;
margin-top: 0.875rem !important;
margin-bottom: 0.375rem !important;
}
/* Heading Level 3 */
.bn-container [data-content-type="heading"][data-level="3"] {
font-size: 1.25rem !important;
line-height: 1.2 !important;
margin-top: 0.75rem !important;
margin-bottom: 0.25rem !important;
}
/* Paragraphs and regular content */
.bn-container .bn-block-content {
font-size: 0.9375rem !important;
line-height: 1.5 !important;
}
/* Adjust lists */
.bn-container ul,
.bn-container ol {
padding-left: 1.25rem !important;
}
}
`}</style>
<BlockNoteView editor={editor} theme={blockNoteTheme} />
</div>
);
}

View file

@ -1,6 +0,0 @@
"use client";
import dynamic from "next/dynamic";
// Dynamically import BlockNote editor with SSR disabled
export const BlockNoteEditor = dynamic(() => import("./BlockNoteEditor"), { ssr: false });

View file

@ -10,6 +10,8 @@ import {
markAnnouncementRead,
markAnnouncementToasted,
} from "@/lib/announcements/announcements-storage";
import { getActiveAnnouncements } from "@/lib/announcements/announcements-utils";
import { isAuthenticated } from "@/lib/auth-utils";
/** Map announcement category to the Sonner toast method */
const categoryToVariant: Record<string, "info" | "warning" | "success"> = {
@ -52,34 +54,33 @@ function showAnnouncementToast(announcement: Announcement) {
* 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.
* On mount, it checks for active, audience-matched, 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(
const authed = isAuthenticated();
const active = getActiveAnnouncements(announcements, authed);
const importantUntoasted = active.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
}, 1500);
return () => clearTimeout(timer);
}, []);
// This component renders nothing — it only triggers side effects
return null;
}

View file

@ -418,7 +418,7 @@ export const ConnectorIndicator: FC<{ hideTrigger?: boolean }> = ({ hideTrigger
</div>
</div>
{/* Bottom fade shadow */}
<div className="absolute bottom-0 left-0 right-0 h-7 bg-gradient-to-t from-muted via-muted/80 to-transparent pointer-events-none z-10" />
<div className="absolute bottom-0 left-0 right-0 h-7 bg-linear-to-t from-muted via-muted/80 to-transparent pointer-events-none z-10" />
</div>
</Tabs>
)}

View file

@ -57,6 +57,7 @@ export const BaiduSearchApiConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSu
BAIDU_API_KEY: values.api_key,
},
is_indexable: false,
is_active: true,
last_indexed_at: null,
periodic_indexing_enabled: false,
indexing_frequency_minutes: null,

View file

@ -51,6 +51,7 @@ export const CirclebackConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmit
connector_type: EnumConnectorName.CIRCLEBACK_CONNECTOR,
config: {},
is_indexable: false,
is_active: true,
last_indexed_at: null,
periodic_indexing_enabled: false,
indexing_frequency_minutes: null,

View file

@ -155,6 +155,7 @@ export const ElasticsearchConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSub
connector_type: EnumConnectorName.ELASTICSEARCH_CONNECTOR,
config,
is_indexable: true,
is_active: true,
last_indexed_at: null,
periodic_indexing_enabled: periodicEnabled,
indexing_frequency_minutes: periodicEnabled ? parseInt(frequencyMinutes, 10) : null,

View file

@ -57,6 +57,7 @@ export const LinkupApiConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitt
LINKUP_API_KEY: values.api_key,
},
is_indexable: false,
is_active: true,
last_indexed_at: null,
periodic_indexing_enabled: false,
indexing_frequency_minutes: null,

View file

@ -71,6 +71,7 @@ export const LumaConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }
LUMA_API_KEY: values.api_key,
},
is_indexable: true,
is_active: true,
last_indexed_at: null,
periodic_indexing_enabled: periodicEnabled,
indexing_frequency_minutes: periodicEnabled ? parseInt(frequencyMinutes, 10) : null,

View file

@ -110,6 +110,7 @@ export const SearxngConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmittin
connector_type: EnumConnectorName.SEARXNG_API,
config,
is_indexable: false,
is_active: true,
last_indexed_at: null,
periodic_indexing_enabled: false,
indexing_frequency_minutes: null,

View file

@ -57,6 +57,7 @@ export const TavilyApiConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitt
TAVILY_API_KEY: values.api_key,
},
is_indexable: false,
is_active: true,
last_indexed_at: null,
periodic_indexing_enabled: false,
indexing_frequency_minutes: null,

View file

@ -1,7 +1,7 @@
"use client";
import { ArrowLeft } from "lucide-react";
import { type FC, useMemo } from "react";
import { type FC, useMemo, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import type { EnumConnectorName } from "@/contracts/enums/connector";
@ -9,6 +9,20 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { getConnectorTypeDisplay } from "@/lib/connectors/utils";
import { getConnectFormComponent } from "../../connect-forms";
const FORM_ID_MAP: Record<string, string> = {
TAVILY_API: "tavily-connect-form",
SEARXNG_API: "searxng-connect-form",
LINKUP_API: "linkup-api-connect-form",
BAIDU_SEARCH_API: "baidu-search-api-connect-form",
ELASTICSEARCH_CONNECTOR: "elasticsearch-connect-form",
BOOKSTACK_CONNECTOR: "bookstack-connect-form",
GITHUB_CONNECTOR: "github-connect-form",
LUMA_CONNECTOR: "luma-connect-form",
CIRCLEBACK_CONNECTOR: "circleback-connect-form",
MCP_CONNECTOR: "mcp-connect-form",
OBSIDIAN_CONNECTOR: "obsidian-connect-form",
};
interface ConnectorConnectViewProps {
connectorType: string;
onSubmit: (data: {
@ -35,6 +49,7 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
onBack,
isSubmitting,
}) => {
const formContainerRef = useRef<HTMLDivElement | null>(null);
// Get connector-specific form component
const ConnectFormComponent = useMemo(
() => getConnectFormComponent(connectorType),
@ -46,26 +61,16 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
if (isSubmitting) {
return;
}
// Map connector types to their form IDs
const formIdMap: Record<string, string> = {
TAVILY_API: "tavily-connect-form",
SEARXNG_API: "searxng-connect-form",
LINKUP_API: "linkup-api-connect-form",
BAIDU_SEARCH_API: "baidu-search-api-connect-form",
ELASTICSEARCH_CONNECTOR: "elasticsearch-connect-form",
BOOKSTACK_CONNECTOR: "bookstack-connect-form",
GITHUB_CONNECTOR: "github-connect-form",
LUMA_CONNECTOR: "luma-connect-form",
CIRCLEBACK_CONNECTOR: "circleback-connect-form",
MCP_CONNECTOR: "mcp-connect-form",
OBSIDIAN_CONNECTOR: "obsidian-connect-form",
};
const formId = formIdMap[connectorType];
if (formId) {
const form = document.getElementById(formId) as HTMLFormElement;
if (form) {
form.requestSubmit();
}
const formId = FORM_ID_MAP[connectorType];
const root = formContainerRef.current;
const mappedForm =
root && formId ? (root.querySelector(`[id="${formId}"]`) as HTMLFormElement | null) : null;
// Fallback to currently rendered form to avoid silent no-op
// when a connector type or form id mapping drifts.
const fallbackForm = root?.querySelector("form") as HTMLFormElement | null;
const form = mappedForm ?? fallbackForm;
if (form) {
form.requestSubmit();
}
};
@ -114,7 +119,10 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
</div>
{/* Form Content - Scrollable */}
<div className="flex-1 min-h-0 overflow-y-auto px-6 sm:px-12">
<div
ref={formContainerRef}
className="connector-connect-form-root flex-1 min-h-0 overflow-y-auto px-6 sm:px-12"
>
<ConnectFormComponent
onSubmit={onSubmit}
onBack={onBack}
@ -134,6 +142,7 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
Cancel
</Button>
<Button
type="button"
onClick={handleFormSubmit}
disabled={isSubmitting}
className="text-xs sm:text-sm min-w-[140px] disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none"

View file

@ -558,7 +558,9 @@ export const useConnectorDialog = () => {
},
onIndexingStart?: (connectorId: number) => void
) => {
if (!searchSpaceId || !connectingConnectorType) return;
if (!searchSpaceId || !connectingConnectorType) {
return;
}
// Prevent multiple submissions using ref for immediate check
if (isCreatingConnectorRef.current) return;
@ -582,7 +584,6 @@ export const useConnectorDialog = () => {
search_space_id: searchSpaceId,
},
});
// Refetch connectors to get the new one
const result = await refetchAllConnectors();
if (result.data) {

View file

@ -1,25 +1,20 @@
"use client";
import { ExternalLink } from "lucide-react";
import type { FC } from "react";
import { useState } from "react";
import { SourceDetailPanel } from "@/components/new-chat/source-detail-panel";
interface InlineCitationProps {
chunkId: number;
citationNumber: number;
isDocsChunk?: boolean;
}
/**
* Inline citation component for the new chat.
* Renders a clickable numbered badge that opens the SourceDetailPanel with document chunk details.
* Supports both regular knowledge base chunks and Surfsense documentation chunks.
* Inline citation for knowledge-base chunks (numeric chunk IDs).
* Renders a clickable badge showing the actual chunk ID that opens the SourceDetailPanel.
*/
export const InlineCitation: FC<InlineCitationProps> = ({
chunkId,
citationNumber,
isDocsChunk = false,
}) => {
export const InlineCitation: FC<InlineCitationProps> = ({ chunkId, isDocsChunk = false }) => {
const [isOpen, setIsOpen] = useState(false);
return (
@ -37,12 +32,46 @@ export const InlineCitation: FC<InlineCitationProps> = ({
onClick={() => setIsOpen(true)}
onKeyDown={(e) => e.key === "Enter" && setIsOpen(true)}
className="text-[10px] font-bold bg-primary/80 hover:bg-primary text-primary-foreground rounded-full min-w-4 h-4 px-1 inline-flex items-center justify-center align-super cursor-pointer transition-colors ml-0.5"
title={`View source #${citationNumber}`}
title={`View source chunk #${chunkId}`}
role="button"
tabIndex={0}
>
{citationNumber}
{chunkId}
</span>
</SourceDetailPanel>
);
};
function extractDomain(url: string): string {
try {
const hostname = new URL(url).hostname;
return hostname.replace(/^www\./, "");
} catch {
return url;
}
}
interface UrlCitationProps {
url: string;
}
/**
* Inline citation for live web search results (URL-based chunk IDs).
* Renders a clickable badge showing the source domain that opens the URL in a new tab.
*/
export const UrlCitation: FC<UrlCitationProps> = ({ url }) => {
const domain = extractDomain(url);
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-[10px] font-bold bg-primary/80 hover:bg-primary text-primary-foreground rounded-full h-4 px-1.5 inline-flex items-center gap-0.5 align-super cursor-pointer transition-colors ml-0.5 no-underline"
title={url}
>
<ExternalLink className="size-2.5 shrink-0" />
{domain}
</a>
);
};

View file

@ -14,17 +14,36 @@ import rehypeKatex from "rehype-katex";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import "katex/dist/katex.min.css";
import { InlineCitation } from "@/components/assistant-ui/inline-citation";
import { InlineCitation, UrlCitation } from "@/components/assistant-ui/inline-citation";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { cn } from "@/lib/utils";
// Storage for URL citations replaced during preprocess to avoid GFM autolink interference.
// Populated in preprocessMarkdown, consumed in parseTextWithCitations.
let _pendingUrlCitations = new Map<string, string>();
let _urlCiteIdx = 0;
/**
* Convert all LaTeX delimiter styles to the dollar-sign syntax
* that remark-math understands. LLMs use various delimiters
* (\(...\), \[...\], \begin{equation}, etc.) and we need to
* normalise them all to $ / $$ before the markdown parser runs.
* Preprocess raw markdown before it reaches the remark/rehype pipeline.
* - Replaces URL-based citations with safe placeholders (prevents GFM autolinks)
* - Normalises LaTeX delimiters to dollar-sign syntax for remark-math
*/
function convertLatexDelimiters(content: string): string {
function preprocessMarkdown(content: string): string {
// Replace URL-based citations with safe placeholders BEFORE markdown parsing.
// GFM autolinks would otherwise convert the https://... inside [citation:URL]
// into an <a> element, splitting the text and preventing our citation regex
// from matching the full pattern.
_pendingUrlCitations = new Map();
_urlCiteIdx = 0;
content = content.replace(
/[[【]\u200B?citation:\s*(https?:\/\/[^\]】\u200B]+)\s*\u200B?[\]】]/g,
(_, url) => {
const key = `urlcite${_urlCiteIdx++}`;
_pendingUrlCitations.set(key, url.trim());
return `[citation:${key}]`;
}
);
// 1. Block math: \[...\] → $$...$$
content = content.replace(/\\\[([\s\S]*?)\\\]/g, (_, inner) => `$$${inner}$$`);
// 2. Inline math: \(...\) → $...$
@ -50,40 +69,19 @@ function convertLatexDelimiters(content: string): string {
return content;
}
// Citation pattern: [citation:CHUNK_ID] or [citation:doc-CHUNK_ID]
// Also matches Chinese brackets 【】 and handles zero-width spaces that LLM sometimes inserts
const CITATION_REGEX = /[[【]\u200B?citation:(doc-)?(\d+)\u200B?[\]】]/g;
// Track chunk IDs to citation numbers mapping for consistent numbering
// This map is reset when a new message starts rendering
// Uses string keys to differentiate between doc and regular chunks (e.g., "doc-123" vs "123")
let chunkIdToCitationNumber: Map<string, number> = new Map();
let nextCitationNumber = 1;
// Matches [citation:...] with numeric IDs (incl. doc- prefix, comma-separated),
// URL-based IDs from live web search, or urlciteN placeholders from preprocess.
// Also matches Chinese brackets 【】 and handles zero-width spaces that LLM sometimes inserts.
const CITATION_REGEX =
/[[【]\u200B?citation:\s*(https?:\/\/[^\]】\u200B]+|urlcite\d+|(?:doc-)?\d+(?:\s*,\s*(?:doc-)?\d+)*)\s*\u200B?[\]】]/g;
/**
* Resets the citation counter - should be called at the start of each message
*/
export function resetCitationCounter() {
chunkIdToCitationNumber = new Map();
nextCitationNumber = 1;
}
/**
* Gets or assigns a citation number for a chunk ID
* Uses string key to differentiate between doc and regular chunks
*/
function getCitationNumber(chunkId: number, isDocsChunk: boolean): number {
const key = isDocsChunk ? `doc-${chunkId}` : String(chunkId);
const existingNumber = chunkIdToCitationNumber.get(key);
if (existingNumber === undefined) {
chunkIdToCitationNumber.set(key, nextCitationNumber++);
}
return chunkIdToCitationNumber.get(key)!;
}
/**
* Parses text and replaces [citation:XXX] patterns with InlineCitation components
* Supports both regular chunks [citation:123] and docs chunks [citation:doc-123]
* Parses text and replaces [citation:XXX] patterns with citation components.
* Supports:
* - Numeric chunk IDs: [citation:123]
* - Doc-prefixed IDs: [citation:doc-123]
* - Comma-separated IDs: [citation:4149, 4150, 4151]
* - URL-based citations from live search: [citation:https://example.com/page]
*/
function parseTextWithCitations(text: string): ReactNode[] {
const parts: ReactNode[] = [];
@ -91,35 +89,45 @@ function parseTextWithCitations(text: string): ReactNode[] {
let match: RegExpExecArray | null;
let instanceIndex = 0;
// Reset regex state
CITATION_REGEX.lastIndex = 0;
match = CITATION_REGEX.exec(text);
while (match !== null) {
// Add text before the citation
if (match.index > lastIndex) {
parts.push(text.substring(lastIndex, match.index));
}
// Check if this is a docs chunk (has "doc-" prefix)
const isDocsChunk = match[1] === "doc-";
const chunkId = Number.parseInt(match[2], 10);
const citationNumber = getCitationNumber(chunkId, isDocsChunk);
parts.push(
<InlineCitation
key={`citation-${isDocsChunk ? "doc-" : ""}${chunkId}-${instanceIndex}`}
chunkId={chunkId}
citationNumber={citationNumber}
isDocsChunk={isDocsChunk}
/>
);
const captured = match[1];
if (captured.startsWith("http://") || captured.startsWith("https://")) {
parts.push(<UrlCitation key={`citation-url-${instanceIndex}`} url={captured.trim()} />);
instanceIndex++;
} else if (captured.startsWith("urlcite")) {
const url = _pendingUrlCitations.get(captured);
if (url) {
parts.push(<UrlCitation key={`citation-url-${instanceIndex}`} url={url} />);
}
instanceIndex++;
} else {
const rawIds = captured.split(",").map((s) => s.trim());
for (const rawId of rawIds) {
const isDocsChunk = rawId.startsWith("doc-");
const chunkId = Number.parseInt(isDocsChunk ? rawId.slice(4) : rawId, 10);
parts.push(
<InlineCitation
key={`citation-${isDocsChunk ? "doc-" : ""}${chunkId}-${instanceIndex}`}
chunkId={chunkId}
isDocsChunk={isDocsChunk}
/>
);
instanceIndex++;
}
}
lastIndex = match.index + match[0].length;
instanceIndex++;
match = CITATION_REGEX.exec(text);
}
// Add any remaining text after the last citation
if (lastIndex < text.length) {
parts.push(text.substring(lastIndex));
}
@ -134,7 +142,7 @@ const MarkdownTextImpl = () => {
rehypePlugins={[rehypeKatex]}
className="aui-md"
components={defaultComponents}
preprocess={convertLatexDelimiters}
preprocess={preprocessMarkdown}
/>
);
};

View file

@ -253,6 +253,7 @@ const Composer: FC = () => {
const editorRef = useRef<InlineMentionEditorRef>(null);
const editorContainerRef = useRef<HTMLDivElement>(null);
const uploadInputRef = useRef<HTMLInputElement>(null);
const isFileDialogOpenRef = useRef(false);
const documentPickerRef = useRef<DocumentMentionPickerRef>(null);
const { search_space_id, chat_id } = useParams();
const setMentionedDocumentIds = useSetAtom(mentionedDocumentIdsAtom);
@ -516,11 +517,18 @@ const Composer: FC = () => {
);
const handleUploadClick = useCallback(() => {
if (isFileDialogOpenRef.current) return;
isFileDialogOpenRef.current = true;
uploadInputRef.current?.click();
// Reset after a delay to handle cancellation (which doesn't fire the change event).
setTimeout(() => {
isFileDialogOpenRef.current = false;
}, 1000);
}, []);
const handleUploadInputChange = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
isFileDialogOpenRef.current = false;
const files = Array.from(event.target.files ?? []);
event.target.value = "";
if (files.length === 0 || !search_space_id) return;

View file

@ -0,0 +1,24 @@
"use client";
import { createContext, useContext } from "react";
interface EditorSaveContextValue {
/** Callback to save the current editor content */
onSave?: () => void;
/** Whether there are unsaved changes */
hasUnsavedChanges: boolean;
/** Whether a save operation is in progress */
isSaving: boolean;
/** Whether the user can toggle between editing and viewing modes */
canToggleMode: boolean;
}
export const EditorSaveContext = createContext<EditorSaveContextValue>({
hasUnsavedChanges: false,
isSaving: false,
canToggleMode: false,
});
export function useEditorSave() {
return useContext(EditorSaveContext);
}

View file

@ -0,0 +1,173 @@
"use client";
import { MarkdownPlugin, remarkMdx } from "@platejs/markdown";
import type { AnyPluginConfig } from "platejs";
import { createPlatePlugin, Key, Plate, usePlateEditor } from "platejs/react";
import { useEffect, useMemo, useRef } from "react";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import { EditorSaveContext } from "@/components/editor/editor-save-context";
import { type EditorPreset, presetMap } from "@/components/editor/presets";
import { escapeMdxExpressions } from "@/components/editor/utils/escape-mdx";
import { Editor, EditorContainer } from "@/components/ui/editor";
export interface PlateEditorProps {
/** Markdown string to load as initial content */
markdown?: string;
/** Called when the editor content changes, with serialized markdown */
onMarkdownChange?: (markdown: string) => void;
/**
* Force permanent read-only mode (e.g. public/shared view).
* When true, the editor cannot be toggled to editing mode.
* When false (default), the editor starts in viewing mode but
* the user can switch to editing via the mode toolbar button.
*/
readOnly?: boolean;
/** Placeholder text */
placeholder?: string;
/** Editor container variant */
variant?: "default" | "demo" | "comment" | "select";
/** Editor text variant */
editorVariant?: "default" | "demo" | "fullWidth" | "none";
/** Additional className for the container */
className?: string;
/** Save callback. When provided, ⌘+S / Ctrl+S shortcut is registered and save button appears. */
onSave?: () => void;
/** Whether there are unsaved changes */
hasUnsavedChanges?: boolean;
/** Whether a save is in progress */
isSaving?: boolean;
/** Start the editor in editing mode instead of viewing mode. Ignored when readOnly is true. */
defaultEditing?: boolean;
/**
* Plugin preset to use. Controls which plugin kits are loaded.
* - "full" all plugins (toolbars, slash commands, DnD, etc.)
* - "minimal" core formatting only (no fixed toolbar, slash commands, DnD, block selection)
* - "readonly" rendering support for all rich content, no editing UI
* @default "full"
*/
preset?: EditorPreset;
/**
* Additional plugins to append after the preset plugins.
* Use this to inject feature-specific plugins (e.g. approve/reject blocks)
* without modifying the core editor component.
*/
extraPlugins?: AnyPluginConfig[];
}
export function PlateEditor({
markdown,
onMarkdownChange,
readOnly = false,
placeholder = "Type...",
variant = "default",
editorVariant = "default",
className,
onSave,
hasUnsavedChanges = false,
isSaving = false,
defaultEditing = false,
preset = "full",
extraPlugins = [],
}: PlateEditorProps) {
const lastMarkdownRef = useRef(markdown);
// Keep a stable ref to the latest onSave callback so the plugin shortcut
// always calls the most recent version without re-creating the editor.
const onSaveRef = useRef(onSave);
useEffect(() => {
onSaveRef.current = onSave;
}, [onSave]);
// Stable Plate plugin for ⌘+S / Ctrl+S save shortcut.
// Only included when onSave is provided.
const SaveShortcutPlugin = useMemo(
() =>
createPlatePlugin({
key: "save-shortcut",
shortcuts: {
save: {
keys: [[Key.Mod, "s"]],
handler: () => {
onSaveRef.current?.();
},
preventDefault: true,
},
},
}),
[]
);
// Resolve the plugin set from the chosen preset
const presetPlugins = presetMap[preset];
// When readOnly is forced, always start in readOnly.
// Otherwise, respect defaultEditing to decide initial mode.
// The user can still toggle between editing/viewing via ModeToolbarButton.
const editor = usePlateEditor({
readOnly: readOnly || !defaultEditing,
plugins: [
...presetPlugins,
// Only register save shortcut when a save handler is provided
...(onSave ? [SaveShortcutPlugin] : []),
// Consumer-provided extra plugins
...extraPlugins,
MarkdownPlugin.configure({
options: {
remarkPlugins: [remarkGfm, remarkMath, remarkMdx],
},
}),
],
// Use markdown deserialization for initial value if provided
value: markdown
? (editor) =>
editor.getApi(MarkdownPlugin).markdown.deserialize(escapeMdxExpressions(markdown))
: undefined,
});
// Update editor content when markdown prop changes externally
// (e.g., version switching in report panel)
useEffect(() => {
if (markdown !== undefined && markdown !== lastMarkdownRef.current) {
lastMarkdownRef.current = markdown;
const newValue = editor
.getApi(MarkdownPlugin)
.markdown.deserialize(escapeMdxExpressions(markdown));
editor.tf.reset();
editor.tf.setValue(newValue);
}
}, [markdown, editor]);
// When not forced read-only, the user can toggle between editing/viewing.
const canToggleMode = !readOnly;
return (
<EditorSaveContext.Provider
value={{
onSave,
hasUnsavedChanges,
isSaving,
canToggleMode,
}}
>
<Plate
editor={editor}
// Only pass readOnly as a controlled prop when forced (permanently read-only).
// For non-forced mode, the Plate store manages readOnly internally
// (initialized to true via usePlateEditor, toggled via ModeToolbarButton).
{...(readOnly ? { readOnly: true } : {})}
onChange={({ value }) => {
if (onMarkdownChange) {
const md = editor.getApi(MarkdownPlugin).markdown.serialize({ value });
lastMarkdownRef.current = md;
onMarkdownChange(md);
}
}}
>
<EditorContainer variant={variant} className={className}>
<Editor variant={editorVariant} placeholder={placeholder} />
</EditorContainer>
</Plate>
</EditorSaveContext.Provider>
);
}

View file

@ -0,0 +1,237 @@
"use client";
import type { AutoformatRule } from "@platejs/autoformat";
import {
AutoformatPlugin,
autoformatArrow,
autoformatLegal,
autoformatLegalHtml,
autoformatMath,
autoformatPunctuation,
autoformatSmartQuotes,
} from "@platejs/autoformat";
import { insertEmptyCodeBlock } from "@platejs/code-block";
import { toggleList } from "@platejs/list";
import { openNextToggles } from "@platejs/toggle/react";
import { KEYS } from "platejs";
const autoformatMarks: AutoformatRule[] = [
{
match: "***",
mode: "mark",
type: [KEYS.bold, KEYS.italic],
},
{
match: "__*",
mode: "mark",
type: [KEYS.underline, KEYS.italic],
},
{
match: "__**",
mode: "mark",
type: [KEYS.underline, KEYS.bold],
},
{
match: "___***",
mode: "mark",
type: [KEYS.underline, KEYS.bold, KEYS.italic],
},
{
match: "**",
mode: "mark",
type: KEYS.bold,
},
{
match: "__",
mode: "mark",
type: KEYS.underline,
},
{
match: "*",
mode: "mark",
type: KEYS.italic,
},
{
match: "_",
mode: "mark",
type: KEYS.italic,
},
{
match: "~~",
mode: "mark",
type: KEYS.strikethrough,
},
{
match: "^",
mode: "mark",
type: KEYS.sup,
},
{
match: "~",
mode: "mark",
type: KEYS.sub,
},
{
match: "==",
mode: "mark",
type: KEYS.highlight,
},
{
match: "≡",
mode: "mark",
type: KEYS.highlight,
},
{
match: "`",
mode: "mark",
type: KEYS.code,
},
];
const autoformatBlocks: AutoformatRule[] = [
{
match: "# ",
mode: "block",
type: KEYS.h1,
},
{
match: "## ",
mode: "block",
type: KEYS.h2,
},
{
match: "### ",
mode: "block",
type: KEYS.h3,
},
{
match: "#### ",
mode: "block",
type: KEYS.h4,
},
{
match: "##### ",
mode: "block",
type: KEYS.h5,
},
{
match: "###### ",
mode: "block",
type: KEYS.h6,
},
{
match: "> ",
mode: "block",
type: KEYS.blockquote,
},
{
match: "```",
mode: "block",
type: KEYS.codeBlock,
format: (editor) => {
insertEmptyCodeBlock(editor, {
defaultType: KEYS.p,
insertNodesOptions: { select: true },
});
},
},
{
match: "+ ",
mode: "block",
preFormat: openNextToggles,
type: KEYS.toggle,
},
{
match: ["---", "—-", "___ "],
mode: "block",
type: KEYS.hr,
format: (editor) => {
editor.tf.setNodes({ type: KEYS.hr });
editor.tf.insertNodes({
children: [{ text: "" }],
type: KEYS.p,
});
},
},
];
const autoformatLists: AutoformatRule[] = [
{
match: ["* ", "- "],
mode: "block",
type: "list",
format: (editor) => {
toggleList(editor, {
listStyleType: KEYS.ul,
});
},
},
{
match: [String.raw`^\d+\.$ `, String.raw`^\d+\)$ `],
matchByRegex: true,
mode: "block",
type: "list",
format: (editor, { matchString }) => {
toggleList(editor, {
listRestartPolite: Number(matchString) || 1,
listStyleType: KEYS.ol,
});
},
},
{
match: ["[] "],
mode: "block",
type: "list",
format: (editor) => {
toggleList(editor, {
listStyleType: KEYS.listTodo,
});
editor.tf.setNodes({
checked: false,
listStyleType: KEYS.listTodo,
});
},
},
{
match: ["[x] "],
mode: "block",
type: "list",
format: (editor) => {
toggleList(editor, {
listStyleType: KEYS.listTodo,
});
editor.tf.setNodes({
checked: true,
listStyleType: KEYS.listTodo,
});
},
},
];
export const AutoformatKit = [
AutoformatPlugin.configure({
options: {
enableUndoOnDelete: true,
rules: [
...autoformatBlocks,
...autoformatMarks,
...autoformatSmartQuotes,
...autoformatPunctuation,
...autoformatLegal,
...autoformatLegalHtml,
...autoformatArrow,
...autoformatMath,
...autoformatLists,
].map(
(rule): AutoformatRule => ({
...rule,
query: (editor) =>
!editor.api.some({
match: { type: editor.getType(KEYS.codeBlock) },
}),
})
),
},
}),
];

View file

@ -0,0 +1,86 @@
"use client";
import {
BlockquotePlugin,
H1Plugin,
H2Plugin,
H3Plugin,
H4Plugin,
H5Plugin,
H6Plugin,
HorizontalRulePlugin,
} from "@platejs/basic-nodes/react";
import { ParagraphPlugin } from "platejs/react";
import { BlockquoteElement } from "@/components/ui/blockquote-node";
import {
H1Element,
H2Element,
H3Element,
H4Element,
H5Element,
H6Element,
} from "@/components/ui/heading-node";
import { HrElement } from "@/components/ui/hr-node";
import { ParagraphElement } from "@/components/ui/paragraph-node";
export const BasicBlocksKit = [
ParagraphPlugin.withComponent(ParagraphElement),
H1Plugin.configure({
node: {
component: H1Element,
},
rules: {
break: { empty: "reset" },
},
shortcuts: { toggle: { keys: "mod+alt+1" } },
}),
H2Plugin.configure({
node: {
component: H2Element,
},
rules: {
break: { empty: "reset" },
},
shortcuts: { toggle: { keys: "mod+alt+2" } },
}),
H3Plugin.configure({
node: {
component: H3Element,
},
rules: {
break: { empty: "reset" },
},
shortcuts: { toggle: { keys: "mod+alt+3" } },
}),
H4Plugin.configure({
node: {
component: H4Element,
},
rules: {
break: { empty: "reset" },
},
shortcuts: { toggle: { keys: "mod+alt+4" } },
}),
H5Plugin.configure({
node: {
component: H5Element,
},
rules: {
break: { empty: "reset" },
},
}),
H6Plugin.configure({
node: {
component: H6Element,
},
rules: {
break: { empty: "reset" },
},
}),
BlockquotePlugin.configure({
node: { component: BlockquoteElement },
shortcuts: { toggle: { keys: "mod+shift+period" } },
}),
HorizontalRulePlugin.withComponent(HrElement),
];

View file

@ -0,0 +1,38 @@
"use client";
import {
BoldPlugin,
CodePlugin,
HighlightPlugin,
ItalicPlugin,
StrikethroughPlugin,
SubscriptPlugin,
SuperscriptPlugin,
UnderlinePlugin,
} from "@platejs/basic-nodes/react";
import { CodeLeaf } from "@/components/ui/code-node";
import { HighlightLeaf } from "@/components/ui/highlight-node";
export const BasicMarksKit = [
BoldPlugin,
ItalicPlugin,
UnderlinePlugin,
CodePlugin.configure({
node: { component: CodeLeaf },
shortcuts: { toggle: { keys: "mod+e" } },
}),
StrikethroughPlugin.configure({
shortcuts: { toggle: { keys: "mod+shift+x" } },
}),
SubscriptPlugin.configure({
shortcuts: { toggle: { keys: "mod+comma" } },
}),
SuperscriptPlugin.configure({
shortcuts: { toggle: { keys: "mod+period" } },
}),
HighlightPlugin.configure({
node: { component: HighlightLeaf },
shortcuts: { toggle: { keys: "mod+shift+h" } },
}),
];

View file

@ -0,0 +1,6 @@
"use client";
import { BasicBlocksKit } from "./basic-blocks-kit";
import { BasicMarksKit } from "./basic-marks-kit";
export const BasicNodesKit = [...BasicBlocksKit, ...BasicMarksKit];

View file

@ -0,0 +1,7 @@
"use client";
import { CalloutPlugin } from "@platejs/callout/react";
import { CalloutElement } from "@/components/ui/callout-node";
export const CalloutKit = [CalloutPlugin.withComponent(CalloutElement)];

View file

@ -0,0 +1,18 @@
"use client";
import { CodeBlockPlugin, CodeLinePlugin, CodeSyntaxPlugin } from "@platejs/code-block/react";
import { all, createLowlight } from "lowlight";
import { CodeBlockElement, CodeLineElement, CodeSyntaxLeaf } from "@/components/ui/code-block-node";
const lowlight = createLowlight(all);
export const CodeBlockKit = [
CodeBlockPlugin.configure({
node: { component: CodeBlockElement },
options: { lowlight },
shortcuts: { toggle: { keys: "mod+alt+8" } },
}),
CodeLinePlugin.withComponent(CodeLineElement),
CodeSyntaxPlugin.withComponent(CodeSyntaxLeaf),
];

View file

@ -0,0 +1,19 @@
"use client";
import { DndPlugin } from "@platejs/dnd";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { BlockDraggable } from "@/components/ui/block-draggable";
export const DndKit = [
DndPlugin.configure({
options: {
enableScroller: true,
},
render: {
aboveNodes: BlockDraggable,
aboveSlate: ({ children }) => <DndProvider backend={HTML5Backend}>{children}</DndProvider>,
},
}),
];

View file

@ -0,0 +1,19 @@
"use client";
import { createPlatePlugin } from "platejs/react";
import { FixedToolbar } from "@/components/ui/fixed-toolbar";
import { FixedToolbarButtons } from "@/components/ui/fixed-toolbar-buttons";
export const FixedToolbarKit = [
createPlatePlugin({
key: "fixed-toolbar",
render: {
beforeEditable: () => (
<FixedToolbar>
<FixedToolbarButtons />
</FixedToolbar>
),
},
}),
];

View file

@ -0,0 +1,19 @@
"use client";
import { createPlatePlugin } from "platejs/react";
import { FloatingToolbar } from "@/components/ui/floating-toolbar";
import { FloatingToolbarButtons } from "@/components/ui/floating-toolbar-buttons";
export const FloatingToolbarKit = [
createPlatePlugin({
key: "floating-toolbar",
render: {
afterEditable: () => (
<FloatingToolbar>
<FloatingToolbarButtons />
</FloatingToolbar>
),
},
}),
];

View file

@ -0,0 +1,12 @@
"use client";
import { IndentPlugin } from "@platejs/indent/react";
import { KEYS } from "platejs";
export const IndentKit = [
IndentPlugin.configure({
inject: {
targetPlugins: [...KEYS.heading, KEYS.p, KEYS.blockquote, KEYS.codeBlock, KEYS.toggle],
},
}),
];

View file

@ -0,0 +1,15 @@
"use client";
import { LinkPlugin } from "@platejs/link/react";
import { LinkElement } from "@/components/ui/link-node";
import { LinkFloatingToolbar } from "@/components/ui/link-toolbar";
export const LinkKit = [
LinkPlugin.configure({
render: {
node: LinkElement,
afterEditable: () => <LinkFloatingToolbar />,
},
}),
];

View file

@ -0,0 +1,19 @@
"use client";
import { ListPlugin } from "@platejs/list/react";
import { KEYS } from "platejs";
import { IndentKit } from "@/components/editor/plugins/indent-kit";
import { BlockList } from "@/components/ui/block-list";
export const ListKit = [
...IndentKit,
ListPlugin.configure({
inject: {
targetPlugins: [...KEYS.heading, KEYS.p, KEYS.blockquote, KEYS.codeBlock, KEYS.toggle],
},
render: {
belowNodes: BlockList,
},
}),
];

View file

@ -0,0 +1,10 @@
"use client";
import { EquationPlugin, InlineEquationPlugin } from "@platejs/math/react";
import { EquationElement, InlineEquationElement } from "@/components/ui/equation-node";
export const MathKit = [
EquationPlugin.withComponent(EquationElement),
InlineEquationPlugin.withComponent(InlineEquationElement),
];

View file

@ -0,0 +1,23 @@
"use client";
import { BlockSelectionPlugin } from "@platejs/selection/react";
import { BlockSelection } from "@/components/ui/block-selection";
export const SelectionKit = [
BlockSelectionPlugin.configure({
render: {
belowRootNodes: BlockSelection as any,
},
options: {
isSelectable: (element) => {
// Exclude specific block types from selection
if (["code_line", "td", "th"].includes(element.type as string)) {
return false;
}
return true;
},
},
}),
];

View file

@ -0,0 +1,20 @@
"use client";
import { SlashInputPlugin, SlashPlugin } from "@platejs/slash-command/react";
import { KEYS } from "platejs";
import { SlashInputElement } from "@/components/ui/slash-node";
export const SlashCommandKit = [
SlashPlugin.configure({
options: {
trigger: "/",
triggerPreviousCharPattern: /^\s?$/,
triggerQuery: (editor) =>
!editor.api.some({
match: { type: editor.getType(KEYS.codeBlock) },
}),
},
}),
SlashInputPlugin.withComponent(SlashInputElement),
];

View file

@ -0,0 +1,22 @@
"use client";
import {
TableCellHeaderPlugin,
TableCellPlugin,
TablePlugin,
TableRowPlugin,
} from "@platejs/table/react";
import {
TableCellElement,
TableCellHeaderElement,
TableElement,
TableRowElement,
} from "@/components/ui/table-node";
export const TableKit = [
TablePlugin.withComponent(TableElement),
TableRowPlugin.withComponent(TableRowElement),
TableCellPlugin.withComponent(TableCellElement),
TableCellHeaderPlugin.withComponent(TableCellHeaderElement),
];

View file

@ -0,0 +1,12 @@
"use client";
import { TogglePlugin } from "@platejs/toggle/react";
import { ToggleElement } from "@/components/ui/toggle-node";
export const ToggleKit = [
TogglePlugin.configure({
node: { component: ToggleElement },
shortcuts: { toggle: { keys: "mod+alt+9" } },
}),
];

View file

@ -0,0 +1,79 @@
"use client";
import type { AnyPluginConfig } from "platejs";
import { AutoformatKit } from "@/components/editor/plugins/autoformat-kit";
import { BasicNodesKit } from "@/components/editor/plugins/basic-nodes-kit";
import { CalloutKit } from "@/components/editor/plugins/callout-kit";
import { CodeBlockKit } from "@/components/editor/plugins/code-block-kit";
import { DndKit } from "@/components/editor/plugins/dnd-kit";
import { FixedToolbarKit } from "@/components/editor/plugins/fixed-toolbar-kit";
import { FloatingToolbarKit } from "@/components/editor/plugins/floating-toolbar-kit";
import { LinkKit } from "@/components/editor/plugins/link-kit";
import { ListKit } from "@/components/editor/plugins/list-kit";
import { MathKit } from "@/components/editor/plugins/math-kit";
import { SelectionKit } from "@/components/editor/plugins/selection-kit";
import { SlashCommandKit } from "@/components/editor/plugins/slash-command-kit";
import { TableKit } from "@/components/editor/plugins/table-kit";
import { ToggleKit } from "@/components/editor/plugins/toggle-kit";
/**
* Full preset every plugin kit enabled.
* Used by the Documents editor and Reports editor (rich editing experience).
*/
export const fullPreset: AnyPluginConfig[] = [
...BasicNodesKit,
...TableKit,
...ListKit,
...CodeBlockKit,
...LinkKit,
...CalloutKit,
...ToggleKit,
...MathKit,
...SelectionKit,
...SlashCommandKit,
...FixedToolbarKit,
...FloatingToolbarKit,
...AutoformatKit,
...DndKit,
];
/**
* Minimal preset lightweight editing with core formatting only.
* No fixed toolbar, no slash commands, no DnD, no block selection.
* Ideal for inline editors like human-in-the-loop agent actions.
*/
export const minimalPreset: AnyPluginConfig[] = [
...BasicNodesKit,
...ListKit,
...CodeBlockKit,
...LinkKit,
...FloatingToolbarKit,
...AutoformatKit,
];
/**
* Read-only preset rendering support for all rich content, but no editing UI.
* No toolbars, no autoformat, no DnD, no slash commands, no block selection.
* Ideal for pure display / viewer contexts.
*/
export const readonlyPreset: AnyPluginConfig[] = [
...BasicNodesKit,
...TableKit,
...ListKit,
...CodeBlockKit,
...LinkKit,
...CalloutKit,
...ToggleKit,
...MathKit,
];
/** All available preset names */
export type EditorPreset = "full" | "minimal" | "readonly";
/** Map from preset name to plugin array */
export const presetMap: Record<EditorPreset, AnyPluginConfig[]> = {
full: fullPreset,
minimal: minimalPreset,
readonly: readonlyPreset,
};

View file

@ -0,0 +1,159 @@
"use client";
import { insertCallout } from "@platejs/callout";
import { insertCodeBlock, toggleCodeBlock } from "@platejs/code-block";
import { triggerFloatingLink } from "@platejs/link/react";
import { insertInlineEquation } from "@platejs/math";
import { TablePlugin } from "@platejs/table/react";
import { KEYS, type NodeEntry, type Path, PathApi, type TElement } from "platejs";
import type { PlateEditor } from "platejs/react";
const insertList = (editor: PlateEditor, type: string) => {
editor.tf.insertNodes(
editor.api.create.block({
indent: 1,
listStyleType: type,
}),
{ select: true }
);
};
const insertBlockMap: Record<string, (editor: PlateEditor, type: string) => void> = {
[KEYS.listTodo]: insertList,
[KEYS.ol]: insertList,
[KEYS.ul]: insertList,
[KEYS.codeBlock]: (editor) => insertCodeBlock(editor, { select: true }),
[KEYS.table]: (editor) => editor.getTransforms(TablePlugin).insert.table({}, { select: true }),
[KEYS.callout]: (editor) => insertCallout(editor, { select: true }),
[KEYS.toggle]: (editor) => {
editor.tf.insertNodes(editor.api.create.block({ type: KEYS.toggle }), { select: true });
},
};
const insertInlineMap: Record<string, (editor: PlateEditor, type: string) => void> = {
[KEYS.link]: (editor) => triggerFloatingLink(editor, { focused: true }),
[KEYS.equation]: (editor) => insertInlineEquation(editor),
};
type InsertBlockOptions = {
upsert?: boolean;
};
export const insertBlock = (
editor: PlateEditor,
type: string,
options: InsertBlockOptions = {}
) => {
const { upsert = false } = options;
editor.tf.withoutNormalizing(() => {
const block = editor.api.block();
if (!block) return;
const [currentNode, path] = block;
const isCurrentBlockEmpty = editor.api.isEmpty(currentNode);
const currentBlockType = getBlockType(currentNode);
const isSameBlockType = type === currentBlockType;
if (upsert && isCurrentBlockEmpty && isSameBlockType) {
return;
}
if (type in insertBlockMap) {
insertBlockMap[type](editor, type);
} else {
editor.tf.insertNodes(editor.api.create.block({ type }), {
at: PathApi.next(path),
select: true,
});
}
if (!isSameBlockType) {
editor.tf.removeNodes({ previousEmptyBlock: true });
}
});
};
export const insertInlineElement = (editor: PlateEditor, type: string) => {
if (insertInlineMap[type]) {
insertInlineMap[type](editor, type);
}
};
const setList = (editor: PlateEditor, type: string, entry: NodeEntry<TElement>) => {
editor.tf.setNodes(
editor.api.create.block({
indent: 1,
listStyleType: type,
}),
{
at: entry[1],
}
);
};
const setBlockMap: Record<
string,
(editor: PlateEditor, type: string, entry: NodeEntry<TElement>) => void
> = {
[KEYS.listTodo]: setList,
[KEYS.ol]: setList,
[KEYS.ul]: setList,
[KEYS.codeBlock]: (editor) => toggleCodeBlock(editor),
[KEYS.callout]: (editor, _type, entry) => {
editor.tf.setNodes({ type: KEYS.callout }, { at: entry[1] });
},
[KEYS.toggle]: (editor, _type, entry) => {
editor.tf.setNodes({ type: KEYS.toggle }, { at: entry[1] });
},
};
export const setBlockType = (editor: PlateEditor, type: string, { at }: { at?: Path } = {}) => {
editor.tf.withoutNormalizing(() => {
const setEntry = (entry: NodeEntry<TElement>) => {
const [node, path] = entry;
if (node[KEYS.listType]) {
editor.tf.unsetNodes([KEYS.listType, "indent"], { at: path });
}
if (type in setBlockMap) {
return setBlockMap[type](editor, type, entry);
}
if (node.type !== type) {
editor.tf.setNodes({ type }, { at: path });
}
};
if (at) {
const entry = editor.api.node<TElement>(at);
if (entry) {
setEntry(entry);
return;
}
}
const entries = editor.api.blocks({ mode: "lowest" });
entries.forEach((entry) => {
setEntry(entry);
});
});
};
export const getBlockType = (block: TElement) => {
if (block[KEYS.listType]) {
if (block[KEYS.listType] === KEYS.ol) {
return KEYS.ol;
}
if (block[KEYS.listType] === KEYS.listTodo) {
return KEYS.listTodo;
}
return KEYS.ul;
}
return block.type;
};

View file

@ -0,0 +1,25 @@
// ---------------------------------------------------------------------------
// MDX curly-brace escaping helper
// ---------------------------------------------------------------------------
// remarkMdx treats { } as JSX expression delimiters. Arbitrary markdown
// (e.g. AI-generated reports) can contain curly braces that are NOT valid JS
// expressions, which makes acorn throw "Could not parse expression".
// We escape unescaped { and } *outside* of fenced code blocks and inline code
// so remarkMdx treats them as literal characters while still parsing
// <mark>, <u>, <kbd>, etc. tags correctly.
// ---------------------------------------------------------------------------
const FENCED_OR_INLINE_CODE = /(```[\s\S]*?```|`[^`\n]+`)/g;
export function escapeMdxExpressions(md: string): string {
const parts = md.split(FENCED_OR_INLINE_CODE);
return parts
.map((part, i) => {
// Odd indices are code blocks / inline code leave untouched
if (i % 2 === 1) return part;
// Escape { and } that are NOT already escaped (no preceding \)
return part.replace(/(?<!\\)\{/g, "\\{").replace(/(?<!\\)\}/g, "\\}");
})
.join("");
}

Some files were not shown because too many files have changed in this diff Show more