mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 00:36:31 +02:00
Merge pull request #830 from MODSetter/dev
feat: editor overhaul, Linear integration, KB sync & UI improvements
This commit is contained in:
commit
14ab1e4eb0
174 changed files with 31027 additions and 28325 deletions
38
.vscode/launch.json
vendored
38
.vscode/launch.json
vendored
|
|
@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
]
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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",
|
||||
],
|
||||
|
|
|
|||
14059
surfsense_backend/app/config/model_list_fallback.json
Normal file
14059
surfsense_backend/app/config/model_list_fallback.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
44
surfsense_backend/app/routes/model_list_routes.py
Normal file
44
surfsense_backend/app/routes/model_list_routes.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
"""
|
||||
API route for fetching the available LLM models catalogue.
|
||||
|
||||
Serves a dynamically-updated list sourced from the OpenRouter public API,
|
||||
with a local JSON fallback when the API is unreachable.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.db import User
|
||||
from app.services.model_list_service import get_model_list
|
||||
from app.users import current_active_user
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ModelListItem(BaseModel):
|
||||
value: str
|
||||
label: str
|
||||
provider: str
|
||||
context_window: str | None = None
|
||||
|
||||
|
||||
@router.get("/models", response_model=list[ModelListItem])
|
||||
async def list_available_models(
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Return all available LLM models grouped by provider.
|
||||
|
||||
The list is sourced from the OpenRouter public API and cached for 1 hour.
|
||||
If the API is unreachable, a local fallback file is used instead.
|
||||
"""
|
||||
try:
|
||||
return await get_model_list()
|
||||
except Exception as e:
|
||||
logger.exception("Failed to fetch model list")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to fetch model list: {e!s}"
|
||||
) from e
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (Markdown→Typst) + typst-py
|
||||
(Typst→PDF); 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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
13
surfsense_backend/app/services/linear/__init__.py
Normal file
13
surfsense_backend/app/services/linear/__init__.py
Normal 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",
|
||||
]
|
||||
182
surfsense_backend/app/services/linear/kb_sync_service.py
Normal file
182
surfsense_backend/app/services/linear/kb_sync_service.py
Normal 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")
|
||||
356
surfsense_backend/app/services/linear/tool_metadata_service.py
Normal file
356
surfsense_backend/app/services/linear/tool_metadata_service.py
Normal 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()
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
167
surfsense_backend/app/services/model_list_service.py
Normal file
167
surfsense_backend/app/services/model_list_service.py
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
"""
|
||||
Service for fetching and caching the available LLM model list.
|
||||
|
||||
Uses the OpenRouter public API as the primary source, with a local
|
||||
fallback JSON file when the API is unreachable.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
OPENROUTER_API_URL = "https://openrouter.ai/api/v1/models"
|
||||
FALLBACK_FILE = Path(__file__).parent.parent / "config" / "model_list_fallback.json"
|
||||
CACHE_TTL_SECONDS = 86400 # 24 hours
|
||||
|
||||
# In-memory cache
|
||||
_cache: list[dict] | None = None
|
||||
_cache_timestamp: float = 0
|
||||
|
||||
# Maps OpenRouter provider slug → our LiteLLMProvider enum value.
|
||||
# Only providers where the model-name part (after the slash) can be
|
||||
# used directly with the native provider's litellm prefix are listed.
|
||||
#
|
||||
# Excluded slugs and why:
|
||||
# "deepseek" - Native API only accepts "deepseek-chat" / "deepseek-reasoner";
|
||||
# OpenRouter uses different names (deepseek-v3.2, deepseek-r1, ...).
|
||||
# "qwen" - Most OpenRouter Qwen entries are open-source models (qwen3-32b, ...)
|
||||
# that are NOT available on the Dashscope API.
|
||||
# "ai21" - OpenRouter name "jamba-large-1.7" != AI21 API name "jamba-1.5-large".
|
||||
# "microsoft" - OpenRouter "microsoft/" = open-source Phi/WizardLM, NOT Azure
|
||||
# OpenAI deployments (which require deployment names, not model ids).
|
||||
OPENROUTER_SLUG_TO_PROVIDER: dict[str, str] = {
|
||||
"openai": "OPENAI",
|
||||
"anthropic": "ANTHROPIC",
|
||||
"google": "GOOGLE",
|
||||
"mistralai": "MISTRAL",
|
||||
"cohere": "COHERE",
|
||||
"x-ai": "XAI",
|
||||
"perplexity": "PERPLEXITY",
|
||||
}
|
||||
|
||||
|
||||
def _format_context_length(length: int | None) -> str | None:
|
||||
"""Convert a raw token count to a human-readable string (e.g. 128K, 1M)."""
|
||||
if not length:
|
||||
return None
|
||||
if length >= 1_000_000:
|
||||
return f"{length / 1_000_000:g}M"
|
||||
if length >= 1_000:
|
||||
return f"{length / 1_000:g}K"
|
||||
return str(length)
|
||||
|
||||
|
||||
async def _fetch_from_openrouter() -> list[dict] | None:
|
||||
"""Try fetching the model catalogue from the OpenRouter public API."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
response = await client.get(OPENROUTER_API_URL)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return data.get("data", [])
|
||||
except Exception as e:
|
||||
logger.warning("Failed to fetch from OpenRouter API: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def _load_fallback() -> list[dict]:
|
||||
"""Load the local fallback model list."""
|
||||
try:
|
||||
with open(FALLBACK_FILE, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return data.get("data", [])
|
||||
except Exception as e:
|
||||
logger.error("Failed to load fallback model list: %s", e)
|
||||
return []
|
||||
|
||||
|
||||
def _is_text_output_model(model: dict) -> bool:
|
||||
"""Return True if the model's output is text-only (no audio/image generation)."""
|
||||
output_mods = model.get("architecture", {}).get("output_modalities", [])
|
||||
return output_mods == ["text"]
|
||||
|
||||
|
||||
def _process_models(raw_models: list[dict]) -> list[dict]:
|
||||
"""
|
||||
Transform raw OpenRouter model entries into a flat list of
|
||||
{value, label, provider, context_window} dicts.
|
||||
|
||||
Only text-output models are included (audio/image generators are skipped).
|
||||
|
||||
Each OpenRouter model is emitted once for OPENROUTER (full id) and,
|
||||
when the slug maps to a native provider, once more with just the
|
||||
model-name portion.
|
||||
"""
|
||||
processed: list[dict] = []
|
||||
|
||||
for model in raw_models:
|
||||
model_id: str = model.get("id", "")
|
||||
name: str = model.get("name", "")
|
||||
context_length = model.get("context_length")
|
||||
|
||||
if "/" not in model_id:
|
||||
continue
|
||||
|
||||
if not _is_text_output_model(model):
|
||||
continue
|
||||
|
||||
provider_slug, model_name = model_id.split("/", 1)
|
||||
context_window = _format_context_length(context_length)
|
||||
|
||||
# 1) Always emit for OPENROUTER (value = full OpenRouter id)
|
||||
processed.append(
|
||||
{
|
||||
"value": model_id,
|
||||
"label": name,
|
||||
"provider": "OPENROUTER",
|
||||
"context_window": context_window,
|
||||
}
|
||||
)
|
||||
|
||||
# 2) Emit for the native provider when we have a mapping
|
||||
native_provider = OPENROUTER_SLUG_TO_PROVIDER.get(provider_slug)
|
||||
if native_provider:
|
||||
# Google's Gemini API only serves gemini-* models.
|
||||
# Open-source models like gemma-* are NOT available through it.
|
||||
if native_provider == "GOOGLE" and not model_name.startswith("gemini-"):
|
||||
continue
|
||||
|
||||
processed.append(
|
||||
{
|
||||
"value": model_name,
|
||||
"label": name,
|
||||
"provider": native_provider,
|
||||
"context_window": context_window,
|
||||
}
|
||||
)
|
||||
|
||||
return processed
|
||||
|
||||
|
||||
async def get_model_list() -> list[dict]:
|
||||
"""
|
||||
Return the processed model list, using in-memory cache when fresh.
|
||||
Tries the OpenRouter API first, falls back to the local JSON file.
|
||||
"""
|
||||
global _cache, _cache_timestamp
|
||||
|
||||
if _cache is not None and (time.time() - _cache_timestamp) < CACHE_TTL_SECONDS:
|
||||
return _cache
|
||||
|
||||
raw_models = await _fetch_from_openrouter()
|
||||
|
||||
if raw_models is None:
|
||||
logger.info("Using fallback model list")
|
||||
raw_models = _load_fallback()
|
||||
|
||||
processed = _process_models(raw_models)
|
||||
|
||||
_cache = processed
|
||||
_cache_timestamp = time.time()
|
||||
|
||||
return processed
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
170
surfsense_backend/app/services/notion/kb_sync_service.py
Normal file
170
surfsense_backend/app/services/notion/kb_sync_service.py
Normal 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)}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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}\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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
291
surfsense_backend/app/utils/blocknote_to_markdown.py
Normal file
291
surfsense_backend/app/utils/blocknote_to_markdown.py
Normal 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}")
|
||||
|
||||
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
|
||||
58
surfsense_backend/app/utils/notion_utils.py
Normal file
58
surfsense_backend/app/utils/notion_utils.py
Normal 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}\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
|
||||
|
|
@ -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
6487
surfsense_backend/uv.lock
generated
File diff suppressed because it is too large
Load diff
31
surfsense_web/.vscode/launch.json
vendored
31
surfsense_web/.vscode/launch.json
vendored
|
|
@ -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>/**"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 });
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
24
surfsense_web/components/editor/editor-save-context.tsx
Normal file
24
surfsense_web/components/editor/editor-save-context.tsx
Normal 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);
|
||||
}
|
||||
173
surfsense_web/components/editor/plate-editor.tsx
Normal file
173
surfsense_web/components/editor/plate-editor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
237
surfsense_web/components/editor/plugins/autoformat-kit.tsx
Normal file
237
surfsense_web/components/editor/plugins/autoformat-kit.tsx
Normal 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) },
|
||||
}),
|
||||
})
|
||||
),
|
||||
},
|
||||
}),
|
||||
];
|
||||
86
surfsense_web/components/editor/plugins/basic-blocks-kit.tsx
Normal file
86
surfsense_web/components/editor/plugins/basic-blocks-kit.tsx
Normal 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),
|
||||
];
|
||||
38
surfsense_web/components/editor/plugins/basic-marks-kit.tsx
Normal file
38
surfsense_web/components/editor/plugins/basic-marks-kit.tsx
Normal 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" } },
|
||||
}),
|
||||
];
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { BasicBlocksKit } from "./basic-blocks-kit";
|
||||
import { BasicMarksKit } from "./basic-marks-kit";
|
||||
|
||||
export const BasicNodesKit = [...BasicBlocksKit, ...BasicMarksKit];
|
||||
7
surfsense_web/components/editor/plugins/callout-kit.tsx
Normal file
7
surfsense_web/components/editor/plugins/callout-kit.tsx
Normal 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)];
|
||||
18
surfsense_web/components/editor/plugins/code-block-kit.tsx
Normal file
18
surfsense_web/components/editor/plugins/code-block-kit.tsx
Normal 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),
|
||||
];
|
||||
19
surfsense_web/components/editor/plugins/dnd-kit.tsx
Normal file
19
surfsense_web/components/editor/plugins/dnd-kit.tsx
Normal 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>,
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
|
@ -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>
|
||||
),
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
|
@ -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>
|
||||
),
|
||||
},
|
||||
}),
|
||||
];
|
||||
12
surfsense_web/components/editor/plugins/indent-kit.tsx
Normal file
12
surfsense_web/components/editor/plugins/indent-kit.tsx
Normal 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],
|
||||
},
|
||||
}),
|
||||
];
|
||||
15
surfsense_web/components/editor/plugins/link-kit.tsx
Normal file
15
surfsense_web/components/editor/plugins/link-kit.tsx
Normal 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 />,
|
||||
},
|
||||
}),
|
||||
];
|
||||
19
surfsense_web/components/editor/plugins/list-kit.tsx
Normal file
19
surfsense_web/components/editor/plugins/list-kit.tsx
Normal 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,
|
||||
},
|
||||
}),
|
||||
];
|
||||
10
surfsense_web/components/editor/plugins/math-kit.tsx
Normal file
10
surfsense_web/components/editor/plugins/math-kit.tsx
Normal 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),
|
||||
];
|
||||
23
surfsense_web/components/editor/plugins/selection-kit.tsx
Normal file
23
surfsense_web/components/editor/plugins/selection-kit.tsx
Normal 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;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
|
@ -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),
|
||||
];
|
||||
22
surfsense_web/components/editor/plugins/table-kit.tsx
Normal file
22
surfsense_web/components/editor/plugins/table-kit.tsx
Normal 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),
|
||||
];
|
||||
12
surfsense_web/components/editor/plugins/toggle-kit.tsx
Normal file
12
surfsense_web/components/editor/plugins/toggle-kit.tsx
Normal 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" } },
|
||||
}),
|
||||
];
|
||||
79
surfsense_web/components/editor/presets.ts
Normal file
79
surfsense_web/components/editor/presets.ts
Normal 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,
|
||||
};
|
||||
159
surfsense_web/components/editor/transforms.ts
Normal file
159
surfsense_web/components/editor/transforms.ts
Normal 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;
|
||||
};
|
||||
25
surfsense_web/components/editor/utils/escape-mdx.ts
Normal file
25
surfsense_web/components/editor/utils/escape-mdx.ts
Normal 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
Loading…
Add table
Add a link
Reference in a new issue