mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 00:36:31 +02:00
Merge commit '2b2453e015' into dev_mod
This commit is contained in:
commit
ba87d3d9e0
25 changed files with 1918 additions and 64 deletions
|
|
@ -0,0 +1,42 @@
|
|||
"""126_add_report_content_type
|
||||
|
||||
Revision ID: 126
|
||||
Revises: 125
|
||||
Create Date: 2026-04-15
|
||||
|
||||
Adds content_type column to reports table to distinguish between
|
||||
Markdown reports and Typst-based resumes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "126"
|
||||
down_revision: str | None = "125"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
columns = [c["name"] for c in sa.inspect(conn).get_columns("reports")]
|
||||
if "content_type" in columns:
|
||||
return
|
||||
op.add_column(
|
||||
"reports",
|
||||
sa.Column(
|
||||
"content_type",
|
||||
sa.String(20),
|
||||
nullable=False,
|
||||
server_default="markdown",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("reports", "content_type")
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
"""127_seed_build_resume_prompt
|
||||
|
||||
Revision ID: 127
|
||||
Revises: 126
|
||||
Create Date: 2026-04-15
|
||||
|
||||
Seeds the 'Build Resume' default prompt for all existing users.
|
||||
New users get it automatically via SYSTEM_PROMPT_DEFAULTS on signup.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "127"
|
||||
down_revision: str | None = "126"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO prompts
|
||||
(user_id, default_prompt_slug, name, prompt, mode, version, is_public, created_at)
|
||||
SELECT u.id, 'build-resume', 'Build Resume',
|
||||
E'Build me a professional resume. Here is my information:\\n\\n{selection}',
|
||||
'explore'::prompt_mode, 1, false, now()
|
||||
FROM "user" u
|
||||
ON CONFLICT (user_id, default_prompt_slug) DO NOTHING
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DELETE FROM prompts WHERE default_prompt_slug = 'build-resume'")
|
||||
|
|
@ -443,6 +443,52 @@ _TOOL_EXAMPLES["web_search"] = """
|
|||
- Call: `web_search(query="weather New York today")`
|
||||
"""
|
||||
|
||||
_TOOL_INSTRUCTIONS["generate_resume"] = """
|
||||
- generate_resume: Generate or revise a professional resume as a Typst document.
|
||||
- WHEN TO CALL: The user asks to create, build, generate, write, or draft a resume or CV.
|
||||
Also when they ask to modify, update, or revise an existing resume from this conversation.
|
||||
- WHEN NOT TO CALL: General career advice, resume tips, cover letters, or reviewing
|
||||
a resume without making changes. For cover letters, use generate_report instead.
|
||||
- The tool produces Typst source code that is compiled to a PDF preview automatically.
|
||||
- Args:
|
||||
- user_info: The user's resume content — work experience, education, skills, contact
|
||||
info, etc. Can be structured or unstructured text.
|
||||
CRITICAL: user_info must be COMPREHENSIVE. Do NOT just pass the user's raw message.
|
||||
You MUST gather and consolidate ALL available information:
|
||||
* Content from referenced/mentioned documents (e.g., uploaded resumes, CVs, LinkedIn profiles)
|
||||
that appear in the conversation context — extract and include their FULL content.
|
||||
* Information the user shared across multiple messages in the conversation.
|
||||
* Any relevant details from knowledge base search results in the context.
|
||||
The more complete the user_info, the better the resume. Include names, contact info,
|
||||
work experience with dates, education, skills, projects, certifications — everything available.
|
||||
- user_instructions: Optional style or content preferences (e.g. "emphasize leadership",
|
||||
"keep it to one page"). For revisions, describe what to change.
|
||||
- parent_report_id: Set this when the user wants to MODIFY an existing resume from
|
||||
this conversation. Use the report_id from a previous generate_resume result.
|
||||
- Returns: Dict with status, report_id, title, and content_type.
|
||||
- After calling: Give a brief confirmation. Do NOT paste resume content in chat. Do NOT mention report_id or any internal IDs — the resume card is shown automatically.
|
||||
- VERSIONING: Same rules as generate_report — set parent_report_id for modifications
|
||||
of an existing resume, leave as None for new resumes.
|
||||
"""
|
||||
|
||||
_TOOL_EXAMPLES["generate_resume"] = """
|
||||
- User: "Build me a resume. I'm John Doe, engineer at Acme Corp..."
|
||||
- Call: `generate_resume(user_info="John Doe, engineer at Acme Corp...")`
|
||||
- WHY: Has creation verb "build" + resume → call the tool.
|
||||
- User: "Create my CV with this info: [experience, education, skills]"
|
||||
- Call: `generate_resume(user_info="[experience, education, skills]")`
|
||||
- User: "Build me a resume" (and there is a resume/CV document in the conversation context)
|
||||
- Extract the FULL content from the document in context, then call:
|
||||
`generate_resume(user_info="Name: John Doe\\nEmail: john@example.com\\n\\nExperience:\\n- Senior Engineer at Acme Corp (2020-2024)\\n Led team of 5...\\n\\nEducation:\\n- BS Computer Science, MIT (2016-2020)\\n\\nSkills: Python, TypeScript, AWS...")`
|
||||
- WHY: Document content is available in context — extract ALL of it into user_info. Do NOT ignore referenced documents.
|
||||
- User: (after resume generated) "Change my title to Senior Engineer"
|
||||
- Call: `generate_resume(user_info="", user_instructions="Change the job title to Senior Engineer", parent_report_id=<previous_report_id>)`
|
||||
- WHY: Modification verb "change" + refers to existing resume → set parent_report_id.
|
||||
- User: "How should I structure my resume?"
|
||||
- Do NOT call generate_resume. Answer in chat with advice.
|
||||
- WHY: No creation/modification verb.
|
||||
"""
|
||||
|
||||
# All tool names that have prompt instructions (order matters for prompt readability)
|
||||
_ALL_TOOL_NAMES_ORDERED = [
|
||||
"search_surfsense_docs",
|
||||
|
|
@ -450,6 +496,7 @@ _ALL_TOOL_NAMES_ORDERED = [
|
|||
"generate_podcast",
|
||||
"generate_video_presentation",
|
||||
"generate_report",
|
||||
"generate_resume",
|
||||
"generate_image",
|
||||
"scrape_webpage",
|
||||
"update_memory",
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@ from .onedrive import (
|
|||
)
|
||||
from .podcast import create_generate_podcast_tool
|
||||
from .report import create_generate_report_tool
|
||||
from .resume import create_generate_resume_tool
|
||||
from .scrape_webpage import create_scrape_webpage_tool
|
||||
from .search_surfsense_docs import create_search_surfsense_docs_tool
|
||||
from .update_memory import create_update_memory_tool, create_update_team_memory_tool
|
||||
|
|
@ -171,6 +172,16 @@ BUILTIN_TOOLS: list[ToolDefinition] = [
|
|||
# are optional — when missing, source_strategy="kb_search" degrades
|
||||
# gracefully to "provided"
|
||||
),
|
||||
# Resume generation tool (Typst-based, uses rendercv package)
|
||||
ToolDefinition(
|
||||
name="generate_resume",
|
||||
description="Generate a professional resume as a Typst document",
|
||||
factory=lambda deps: create_generate_resume_tool(
|
||||
search_space_id=deps["search_space_id"],
|
||||
thread_id=deps["thread_id"],
|
||||
),
|
||||
requires=["search_space_id", "thread_id"],
|
||||
),
|
||||
# Generate image tool - creates images using AI models (DALL-E, GPT Image, etc.)
|
||||
ToolDefinition(
|
||||
name="generate_image",
|
||||
|
|
|
|||
665
surfsense_backend/app/agents/new_chat/tools/resume.py
Normal file
665
surfsense_backend/app/agents/new_chat/tools/resume.py
Normal file
|
|
@ -0,0 +1,665 @@
|
|||
"""
|
||||
Resume generation tool for the SurfSense agent.
|
||||
|
||||
Generates a structured resume as Typst source code using the rendercv package.
|
||||
The LLM outputs only the content body (= heading, sections, entries) while
|
||||
the template header (import + show rule) is hardcoded and prepended by the
|
||||
backend. This eliminates LLM errors in the complex configuration block.
|
||||
|
||||
Templates are stored in a registry so new designs can be added by defining
|
||||
a new entry in _TEMPLATES.
|
||||
|
||||
Uses the same short-lived session pattern as generate_report so no DB
|
||||
connection is held during the long LLM call.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import typst
|
||||
from langchain_core.callbacks import dispatch_custom_event
|
||||
from langchain_core.messages import HumanMessage
|
||||
from langchain_core.tools import tool
|
||||
|
||||
from app.db import Report, shielded_async_session
|
||||
from app.services.llm_service import get_document_summary_llm
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ─── Template Registry ───────────────────────────────────────────────────────
|
||||
# Each template defines:
|
||||
# header - Typst import + show rule with {name}, {year}, {month}, {day} placeholders
|
||||
# component_reference - component docs shown to the LLM
|
||||
# rules - generation rules for the LLM
|
||||
|
||||
_TEMPLATES: dict[str, dict[str, str]] = {
|
||||
"classic": {
|
||||
"header": """\
|
||||
#import "@preview/rendercv:0.3.0": *
|
||||
|
||||
#show: rendercv.with(
|
||||
name: "{name}",
|
||||
title: "{name} - Resume",
|
||||
footer: context {{ [#emph[{name} -- #str(here().page())\\/#str(counter(page).final().first())]] }},
|
||||
top-note: [ #emph[Last updated in {month_name} {year}] ],
|
||||
locale-catalog-language: "en",
|
||||
text-direction: ltr,
|
||||
page-size: "us-letter",
|
||||
page-top-margin: 0.7in,
|
||||
page-bottom-margin: 0.7in,
|
||||
page-left-margin: 0.7in,
|
||||
page-right-margin: 0.7in,
|
||||
page-show-footer: false,
|
||||
page-show-top-note: true,
|
||||
colors-body: rgb(0, 0, 0),
|
||||
colors-name: rgb(0, 0, 0),
|
||||
colors-headline: rgb(0, 0, 0),
|
||||
colors-connections: rgb(0, 0, 0),
|
||||
colors-section-titles: rgb(0, 0, 0),
|
||||
colors-links: rgb(0, 0, 0),
|
||||
colors-footer: rgb(128, 128, 128),
|
||||
colors-top-note: rgb(128, 128, 128),
|
||||
typography-line-spacing: 0.6em,
|
||||
typography-alignment: "justified",
|
||||
typography-date-and-location-column-alignment: right,
|
||||
typography-font-family-body: "XCharter",
|
||||
typography-font-family-name: "XCharter",
|
||||
typography-font-family-headline: "XCharter",
|
||||
typography-font-family-connections: "XCharter",
|
||||
typography-font-family-section-titles: "XCharter",
|
||||
typography-font-size-body: 10pt,
|
||||
typography-font-size-name: 25pt,
|
||||
typography-font-size-headline: 10pt,
|
||||
typography-font-size-connections: 10pt,
|
||||
typography-font-size-section-titles: 1.2em,
|
||||
typography-small-caps-name: false,
|
||||
typography-small-caps-headline: false,
|
||||
typography-small-caps-connections: false,
|
||||
typography-small-caps-section-titles: false,
|
||||
typography-bold-name: false,
|
||||
typography-bold-headline: false,
|
||||
typography-bold-connections: false,
|
||||
typography-bold-section-titles: true,
|
||||
links-underline: true,
|
||||
links-show-external-link-icon: false,
|
||||
header-alignment: center,
|
||||
header-photo-width: 3.5cm,
|
||||
header-space-below-name: 0.7cm,
|
||||
header-space-below-headline: 0.7cm,
|
||||
header-space-below-connections: 0.7cm,
|
||||
header-connections-hyperlink: true,
|
||||
header-connections-show-icons: false,
|
||||
header-connections-display-urls-instead-of-usernames: true,
|
||||
header-connections-separator: "|",
|
||||
header-connections-space-between-connections: 0.5cm,
|
||||
section-titles-type: "with_full_line",
|
||||
section-titles-line-thickness: 0.5pt,
|
||||
section-titles-space-above: 0.5cm,
|
||||
section-titles-space-below: 0.3cm,
|
||||
sections-allow-page-break: true,
|
||||
sections-space-between-text-based-entries: 0.15cm,
|
||||
sections-space-between-regular-entries: 0.42cm,
|
||||
entries-date-and-location-width: 4.15cm,
|
||||
entries-side-space: 0cm,
|
||||
entries-space-between-columns: 0.1cm,
|
||||
entries-allow-page-break: false,
|
||||
entries-short-second-row: false,
|
||||
entries-degree-width: 1cm,
|
||||
entries-summary-space-left: 0cm,
|
||||
entries-summary-space-above: 0.08cm,
|
||||
entries-highlights-bullet: text(13pt, [\\u{2022}], baseline: -0.6pt),
|
||||
entries-highlights-nested-bullet: text(13pt, [\\u{2022}], baseline: -0.6pt),
|
||||
entries-highlights-space-left: 0cm,
|
||||
entries-highlights-space-above: 0.08cm,
|
||||
entries-highlights-space-between-items: 0.08cm,
|
||||
entries-highlights-space-between-bullet-and-text: 0.3em,
|
||||
date: datetime(
|
||||
year: {year},
|
||||
month: {month},
|
||||
day: {day},
|
||||
),
|
||||
)
|
||||
|
||||
""",
|
||||
"component_reference": """\
|
||||
Available components (use ONLY these):
|
||||
|
||||
= Full Name // Top-level heading — person's full name
|
||||
|
||||
#connections( // Contact info row (pipe-separated)
|
||||
[City, Country],
|
||||
[#link("mailto:email@example.com", icon: false, if-underline: false, if-color: false)[email\\@example.com]],
|
||||
[#link("https://linkedin.com/in/user", icon: false, if-underline: false, if-color: false)[linkedin.com\\/in\\/user]],
|
||||
[#link("https://github.com/user", icon: false, if-underline: false, if-color: false)[github.com\\/user]],
|
||||
)
|
||||
|
||||
== Section Title // Section heading (arbitrary name)
|
||||
|
||||
#regular-entry( // Work experience, projects, publications, etc.
|
||||
[
|
||||
#strong[Role/Title], Company Name -- Location
|
||||
],
|
||||
[
|
||||
Start -- End
|
||||
],
|
||||
main-column-second-row: [
|
||||
- Achievement or responsibility
|
||||
- Another bullet point
|
||||
],
|
||||
)
|
||||
|
||||
#education-entry( // Education entries
|
||||
[
|
||||
#strong[Institution], Degree in Field -- Location
|
||||
],
|
||||
[
|
||||
Start -- End
|
||||
],
|
||||
main-column-second-row: [
|
||||
- GPA, honours, relevant coursework
|
||||
],
|
||||
)
|
||||
|
||||
#summary([Short paragraph summary]) // Optional summary inside an entry
|
||||
#content-area([Free-form content]) // Freeform text block
|
||||
|
||||
For skills sections, use bold labels directly:
|
||||
#strong[Category:] item1, item2, item3
|
||||
|
||||
For simple list sections (e.g. Honors), use plain bullet points:
|
||||
- Item one
|
||||
- Item two
|
||||
""",
|
||||
"rules": """\
|
||||
RULES:
|
||||
- Do NOT include any #import or #show lines. Start directly with = Full Name.
|
||||
- Output ONLY valid Typst content. No explanatory text before or after.
|
||||
- Do NOT wrap output in ```typst code fences.
|
||||
- The = heading MUST use the person's COMPLETE full name exactly as provided. NEVER shorten or abbreviate.
|
||||
- Escape @ symbols inside link labels with a backslash: email\\@example.com
|
||||
- Escape forward slashes in link display text: linkedin.com\\/in\\/user
|
||||
- Every section MUST use == heading.
|
||||
- Use #regular-entry() for experience, projects, publications, certifications, and similar entries.
|
||||
- Use #education-entry() for education.
|
||||
- Use #strong[Label:] for skills categories.
|
||||
- Keep content professional, concise, and achievement-oriented.
|
||||
- Use action verbs for bullet points (Led, Built, Designed, Reduced, etc.).
|
||||
- This template works for ALL professions — adapt sections to the user's field.
|
||||
""",
|
||||
},
|
||||
}
|
||||
|
||||
DEFAULT_TEMPLATE = "classic"
|
||||
|
||||
|
||||
# ─── Template Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _get_template(template_id: str | None = None) -> dict[str, str]:
|
||||
"""Get a template by ID, falling back to default."""
|
||||
return _TEMPLATES.get(template_id or DEFAULT_TEMPLATE, _TEMPLATES[DEFAULT_TEMPLATE])
|
||||
|
||||
|
||||
_MONTH_NAMES = [
|
||||
"",
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mar",
|
||||
"Apr",
|
||||
"May",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Oct",
|
||||
"Nov",
|
||||
"Dec",
|
||||
]
|
||||
|
||||
|
||||
def _build_header(template: dict[str, str], name: str) -> str:
|
||||
"""Build the template header with the person's name and current date."""
|
||||
now = datetime.now(tz=UTC)
|
||||
return (
|
||||
template["header"]
|
||||
.replace("{name}", name)
|
||||
.replace("{year}", str(now.year))
|
||||
.replace("{month}", str(now.month))
|
||||
.replace("{day}", str(now.day))
|
||||
.replace("{month_name}", _MONTH_NAMES[now.month])
|
||||
)
|
||||
|
||||
|
||||
def _strip_header(full_source: str) -> str:
|
||||
"""Strip the import + show rule from stored source to get the body only.
|
||||
|
||||
Finds the closing parenthesis of the rendercv.with(...) block by tracking
|
||||
nesting depth, then returns everything after it.
|
||||
"""
|
||||
show_match = re.search(r"#show:\s*rendercv\.with\(", full_source)
|
||||
if not show_match:
|
||||
return full_source
|
||||
|
||||
start = show_match.end()
|
||||
depth = 1
|
||||
i = start
|
||||
while i < len(full_source) and depth > 0:
|
||||
if full_source[i] == "(":
|
||||
depth += 1
|
||||
elif full_source[i] == ")":
|
||||
depth -= 1
|
||||
i += 1
|
||||
|
||||
return full_source[i:].lstrip("\n")
|
||||
|
||||
|
||||
def _extract_name(body: str) -> str | None:
|
||||
"""Extract the person's full name from the = heading in the body."""
|
||||
match = re.search(r"^=\s+(.+)$", body, re.MULTILINE)
|
||||
return match.group(1).strip() if match else None
|
||||
|
||||
|
||||
def _strip_imports(body: str) -> str:
|
||||
"""Remove any #import or #show lines the LLM might accidentally include."""
|
||||
lines = body.split("\n")
|
||||
cleaned: list[str] = []
|
||||
skip_show = False
|
||||
depth = 0
|
||||
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
|
||||
if stripped.startswith("#import"):
|
||||
continue
|
||||
|
||||
if skip_show:
|
||||
depth += stripped.count("(") - stripped.count(")")
|
||||
if depth <= 0:
|
||||
skip_show = False
|
||||
continue
|
||||
|
||||
if stripped.startswith("#show:") and "rendercv" in stripped:
|
||||
depth = stripped.count("(") - stripped.count(")")
|
||||
if depth > 0:
|
||||
skip_show = True
|
||||
continue
|
||||
|
||||
cleaned.append(line)
|
||||
|
||||
result = "\n".join(cleaned).strip()
|
||||
return result
|
||||
|
||||
|
||||
def _build_llm_reference(template: dict[str, str]) -> str:
|
||||
"""Build the LLM prompt reference from a template."""
|
||||
return f"""\
|
||||
You MUST output valid Typst content for a resume.
|
||||
Do NOT include any #import or #show lines — those are handled automatically.
|
||||
Start directly with the = Full Name heading.
|
||||
|
||||
{template["component_reference"]}
|
||||
|
||||
{template["rules"]}"""
|
||||
|
||||
|
||||
# ─── Prompts ─────────────────────────────────────────────────────────────────
|
||||
|
||||
_RESUME_PROMPT = """\
|
||||
You are an expert resume writer. Generate professional resume content as Typst markup.
|
||||
|
||||
{llm_reference}
|
||||
|
||||
**User Information:**
|
||||
{user_info}
|
||||
|
||||
{user_instructions_section}
|
||||
|
||||
Generate the resume content now (starting with = Full Name):
|
||||
"""
|
||||
|
||||
_REVISION_PROMPT = """\
|
||||
You are an expert resume editor. Modify the existing resume according to the instructions.
|
||||
Apply ONLY the requested changes — do NOT rewrite sections that are not affected.
|
||||
|
||||
{llm_reference}
|
||||
|
||||
**Modification Instructions:** {user_instructions}
|
||||
|
||||
**EXISTING RESUME CONTENT:**
|
||||
|
||||
{previous_content}
|
||||
|
||||
---
|
||||
|
||||
Output the complete, updated resume content with the changes applied (starting with = Full Name):
|
||||
"""
|
||||
|
||||
_FIX_COMPILE_PROMPT = """\
|
||||
The resume content you generated failed to compile. Fix the error while preserving all content.
|
||||
|
||||
{llm_reference}
|
||||
|
||||
**Compilation Error:**
|
||||
{error}
|
||||
|
||||
**Full Typst Source (for context — error line numbers refer to this):**
|
||||
{full_source}
|
||||
|
||||
**Your content starts after the template header. Output ONLY the content portion \
|
||||
(starting with = Full Name), NOT the #import or #show rule:**
|
||||
"""
|
||||
|
||||
|
||||
# ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _strip_typst_fences(text: str) -> str:
|
||||
"""Remove wrapping ```typst ... ``` fences that LLMs sometimes add."""
|
||||
stripped = text.strip()
|
||||
m = re.match(r"^(`{3,})(?:typst|typ)?\s*\n", stripped)
|
||||
if m:
|
||||
fence = m.group(1)
|
||||
if stripped.endswith(fence):
|
||||
stripped = stripped[m.end() :]
|
||||
stripped = stripped[: -len(fence)].rstrip()
|
||||
return stripped
|
||||
|
||||
|
||||
def _compile_typst(source: str) -> bytes:
|
||||
"""Compile Typst source to PDF bytes. Raises on failure."""
|
||||
return typst.compile(source.encode("utf-8"))
|
||||
|
||||
|
||||
# ─── Tool Factory ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def create_generate_resume_tool(
|
||||
search_space_id: int,
|
||||
thread_id: int | None = None,
|
||||
):
|
||||
"""
|
||||
Factory function to create the generate_resume tool.
|
||||
|
||||
Generates a Typst-based resume, validates it via compilation,
|
||||
and stores the source in the Report table with content_type='typst'.
|
||||
The LLM generates only the content body; the template header is
|
||||
prepended by the backend.
|
||||
"""
|
||||
|
||||
@tool
|
||||
async def generate_resume(
|
||||
user_info: str,
|
||||
user_instructions: str | None = None,
|
||||
parent_report_id: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Generate a professional resume as a Typst document.
|
||||
|
||||
Use this tool when the user asks to create, build, generate, write,
|
||||
or draft a resume or CV. Also use it when the user wants to modify,
|
||||
update, or revise an existing resume generated in this conversation.
|
||||
|
||||
Trigger phrases include:
|
||||
- "build me a resume", "create my resume", "generate a CV"
|
||||
- "update my resume", "change my title", "add my new job"
|
||||
- "make my resume more concise", "reformat my resume"
|
||||
|
||||
Do NOT use this tool for:
|
||||
- General questions about resumes or career advice
|
||||
- Reviewing or critiquing a resume without changes
|
||||
- Cover letters (use generate_report instead)
|
||||
|
||||
VERSIONING — parent_report_id:
|
||||
- Set parent_report_id when the user wants to MODIFY an existing
|
||||
resume that was already generated in this conversation.
|
||||
- Leave as None for new resumes.
|
||||
|
||||
Args:
|
||||
user_info: The user's resume content — work experience,
|
||||
education, skills, contact info, etc. Can be structured
|
||||
or unstructured text.
|
||||
user_instructions: Optional style or content preferences
|
||||
(e.g. "emphasize leadership", "keep it to one page",
|
||||
"use a modern style"). For revisions, describe what to change.
|
||||
parent_report_id: ID of a previous resume to revise (creates
|
||||
new version in the same version group).
|
||||
|
||||
Returns:
|
||||
Dict with status, report_id, title, and content_type.
|
||||
"""
|
||||
report_group_id: int | None = None
|
||||
parent_content: str | None = None
|
||||
|
||||
template = _get_template()
|
||||
llm_reference = _build_llm_reference(template)
|
||||
|
||||
async def _save_failed_report(error_msg: str) -> int | None:
|
||||
try:
|
||||
async with shielded_async_session() as session:
|
||||
failed = Report(
|
||||
title="Resume",
|
||||
content=None,
|
||||
content_type="typst",
|
||||
report_metadata={
|
||||
"status": "failed",
|
||||
"error_message": error_msg,
|
||||
},
|
||||
report_style="resume",
|
||||
search_space_id=search_space_id,
|
||||
thread_id=thread_id,
|
||||
report_group_id=report_group_id,
|
||||
)
|
||||
session.add(failed)
|
||||
await session.commit()
|
||||
await session.refresh(failed)
|
||||
if not failed.report_group_id:
|
||||
failed.report_group_id = failed.id
|
||||
await session.commit()
|
||||
logger.info(
|
||||
f"[generate_resume] Saved failed report {failed.id}: {error_msg}"
|
||||
)
|
||||
return failed.id
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"[generate_resume] Could not persist failed report row"
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
# ── Phase 1: READ ─────────────────────────────────────────────
|
||||
async with shielded_async_session() as read_session:
|
||||
if parent_report_id:
|
||||
parent_report = await read_session.get(Report, parent_report_id)
|
||||
if parent_report:
|
||||
report_group_id = parent_report.report_group_id
|
||||
parent_content = parent_report.content
|
||||
logger.info(
|
||||
f"[generate_resume] Revising from parent {parent_report_id} "
|
||||
f"(group {report_group_id})"
|
||||
)
|
||||
|
||||
llm = await get_document_summary_llm(read_session, search_space_id)
|
||||
|
||||
if not llm:
|
||||
error_msg = (
|
||||
"No LLM configured. Please configure a language model in Settings."
|
||||
)
|
||||
report_id = await _save_failed_report(error_msg)
|
||||
return {
|
||||
"status": "failed",
|
||||
"error": error_msg,
|
||||
"report_id": report_id,
|
||||
"title": "Resume",
|
||||
"content_type": "typst",
|
||||
}
|
||||
|
||||
# ── Phase 2: LLM GENERATION ───────────────────────────────────
|
||||
|
||||
user_instructions_section = ""
|
||||
if user_instructions:
|
||||
user_instructions_section = (
|
||||
f"**Additional Instructions:** {user_instructions}"
|
||||
)
|
||||
|
||||
if parent_content:
|
||||
dispatch_custom_event(
|
||||
"report_progress",
|
||||
{"phase": "writing", "message": "Updating your resume"},
|
||||
)
|
||||
parent_body = _strip_header(parent_content)
|
||||
prompt = _REVISION_PROMPT.format(
|
||||
llm_reference=llm_reference,
|
||||
user_instructions=user_instructions
|
||||
or "Improve and refine the resume.",
|
||||
previous_content=parent_body,
|
||||
)
|
||||
else:
|
||||
dispatch_custom_event(
|
||||
"report_progress",
|
||||
{"phase": "writing", "message": "Building your resume"},
|
||||
)
|
||||
prompt = _RESUME_PROMPT.format(
|
||||
llm_reference=llm_reference,
|
||||
user_info=user_info,
|
||||
user_instructions_section=user_instructions_section,
|
||||
)
|
||||
|
||||
response = await llm.ainvoke([HumanMessage(content=prompt)])
|
||||
body = response.content
|
||||
|
||||
if not body or not isinstance(body, str):
|
||||
error_msg = "LLM returned empty or invalid content"
|
||||
report_id = await _save_failed_report(error_msg)
|
||||
return {
|
||||
"status": "failed",
|
||||
"error": error_msg,
|
||||
"report_id": report_id,
|
||||
"title": "Resume",
|
||||
"content_type": "typst",
|
||||
}
|
||||
|
||||
body = _strip_typst_fences(body)
|
||||
body = _strip_imports(body)
|
||||
|
||||
# ── Phase 3: ASSEMBLE + COMPILE ───────────────────────────────
|
||||
dispatch_custom_event(
|
||||
"report_progress",
|
||||
{"phase": "compiling", "message": "Compiling resume..."},
|
||||
)
|
||||
|
||||
name = _extract_name(body) or "Resume"
|
||||
header = _build_header(template, name)
|
||||
typst_source = header + body
|
||||
|
||||
compile_error: str | None = None
|
||||
for attempt in range(2):
|
||||
try:
|
||||
_compile_typst(typst_source)
|
||||
compile_error = None
|
||||
break
|
||||
except Exception as e:
|
||||
compile_error = str(e)
|
||||
logger.warning(
|
||||
f"[generate_resume] Compile attempt {attempt + 1} failed: {compile_error}"
|
||||
)
|
||||
|
||||
if attempt == 0:
|
||||
dispatch_custom_event(
|
||||
"report_progress",
|
||||
{
|
||||
"phase": "fixing",
|
||||
"message": "Fixing compilation issue...",
|
||||
},
|
||||
)
|
||||
fix_prompt = _FIX_COMPILE_PROMPT.format(
|
||||
llm_reference=llm_reference,
|
||||
error=compile_error,
|
||||
full_source=typst_source,
|
||||
)
|
||||
fix_response = await llm.ainvoke(
|
||||
[HumanMessage(content=fix_prompt)]
|
||||
)
|
||||
if fix_response.content and isinstance(
|
||||
fix_response.content, str
|
||||
):
|
||||
body = _strip_typst_fences(fix_response.content)
|
||||
body = _strip_imports(body)
|
||||
name = _extract_name(body) or name
|
||||
header = _build_header(template, name)
|
||||
typst_source = header + body
|
||||
|
||||
if compile_error:
|
||||
error_msg = (
|
||||
f"Typst compilation failed after 2 attempts: {compile_error}"
|
||||
)
|
||||
report_id = await _save_failed_report(error_msg)
|
||||
return {
|
||||
"status": "failed",
|
||||
"error": error_msg,
|
||||
"report_id": report_id,
|
||||
"title": "Resume",
|
||||
"content_type": "typst",
|
||||
}
|
||||
|
||||
# ── Phase 4: SAVE ─────────────────────────────────────────────
|
||||
dispatch_custom_event(
|
||||
"report_progress",
|
||||
{"phase": "saving", "message": "Saving your resume"},
|
||||
)
|
||||
|
||||
resume_title = f"{name} - Resume" if name != "Resume" else "Resume"
|
||||
|
||||
metadata: dict[str, Any] = {
|
||||
"status": "ready",
|
||||
"word_count": len(typst_source.split()),
|
||||
"char_count": len(typst_source),
|
||||
}
|
||||
|
||||
async with shielded_async_session() as write_session:
|
||||
report = Report(
|
||||
title=resume_title,
|
||||
content=typst_source,
|
||||
content_type="typst",
|
||||
report_metadata=metadata,
|
||||
report_style="resume",
|
||||
search_space_id=search_space_id,
|
||||
thread_id=thread_id,
|
||||
report_group_id=report_group_id,
|
||||
)
|
||||
write_session.add(report)
|
||||
await write_session.commit()
|
||||
await write_session.refresh(report)
|
||||
|
||||
if not report.report_group_id:
|
||||
report.report_group_id = report.id
|
||||
await write_session.commit()
|
||||
|
||||
saved_id = report.id
|
||||
|
||||
logger.info(f"[generate_resume] Created resume {saved_id}: {resume_title}")
|
||||
|
||||
return {
|
||||
"status": "ready",
|
||||
"report_id": saved_id,
|
||||
"title": resume_title,
|
||||
"content_type": "typst",
|
||||
"is_revision": bool(parent_content),
|
||||
"message": f"Resume generated successfully: {resume_title}",
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
error_message = str(e)
|
||||
logger.exception(f"[generate_resume] Error: {error_message}")
|
||||
report_id = await _save_failed_report(error_message)
|
||||
return {
|
||||
"status": "failed",
|
||||
"error": error_message,
|
||||
"report_id": report_id,
|
||||
"title": "Resume",
|
||||
"content_type": "typst",
|
||||
}
|
||||
|
||||
return generate_resume
|
||||
|
|
@ -1198,12 +1198,13 @@ class VideoPresentation(BaseModel, TimestampMixin):
|
|||
|
||||
|
||||
class Report(BaseModel, TimestampMixin):
|
||||
"""Report model for storing generated Markdown reports."""
|
||||
"""Report model for storing generated reports (Markdown or Typst)."""
|
||||
|
||||
__tablename__ = "reports"
|
||||
|
||||
title = Column(String(500), nullable=False)
|
||||
content = Column(Text, nullable=True) # Markdown body
|
||||
content = Column(Text, nullable=True)
|
||||
content_type = Column(String(20), nullable=False, server_default="markdown")
|
||||
report_metadata = Column(JSONB, nullable=True) # section headings, word count, etc.
|
||||
report_style = Column(
|
||||
String(100), nullable=True
|
||||
|
|
|
|||
|
|
@ -71,4 +71,11 @@ SYSTEM_PROMPT_DEFAULTS: list[dict] = [
|
|||
"prompt": "Search the web for information about:\n\n{selection}",
|
||||
"mode": "explore",
|
||||
},
|
||||
{
|
||||
"slug": "build-resume",
|
||||
"version": 1,
|
||||
"name": "Build Resume",
|
||||
"prompt": "Build me a professional resume. Here is my information:\n\n{selection}",
|
||||
"mode": "explore",
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -231,6 +231,57 @@ def _replace_audio_paths_with_public_urls(
|
|||
return result
|
||||
|
||||
|
||||
@router.get("/{share_token}/reports/{report_id}/preview")
|
||||
async def preview_public_report_pdf(
|
||||
share_token: str,
|
||||
report_id: int,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""
|
||||
Return a compiled PDF preview for a Typst-based report in a public snapshot.
|
||||
|
||||
No authentication required - the share_token provides access.
|
||||
"""
|
||||
import asyncio
|
||||
import io
|
||||
import re
|
||||
|
||||
import typst as typst_compiler
|
||||
|
||||
report_info = await get_snapshot_report(session, share_token, report_id)
|
||||
|
||||
if not report_info:
|
||||
raise HTTPException(status_code=404, detail="Report not found")
|
||||
|
||||
content = report_info.get("content")
|
||||
content_type = report_info.get("content_type", "markdown")
|
||||
|
||||
if not content:
|
||||
raise HTTPException(status_code=400, detail="Report has no content to preview")
|
||||
|
||||
if content_type != "typst":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Preview is only available for Typst-based reports",
|
||||
)
|
||||
|
||||
def _compile() -> bytes:
|
||||
return typst_compiler.compile(content.encode("utf-8"))
|
||||
|
||||
pdf_bytes = await asyncio.to_thread(_compile)
|
||||
|
||||
safe_title = re.sub(r"[^\w\s-]", "", report_info.get("title") or "Resume").strip()
|
||||
filename = f"{safe_title}.pdf"
|
||||
|
||||
return StreamingResponse(
|
||||
io.BytesIO(pdf_bytes),
|
||||
media_type="application/pdf",
|
||||
headers={
|
||||
"Content-Disposition": f'inline; filename="{filename}"',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{share_token}/reports/{report_id}/content")
|
||||
async def get_public_report_content(
|
||||
share_token: str,
|
||||
|
|
@ -259,6 +310,7 @@ async def get_public_report_content(
|
|||
"id": report_info.get("original_id"),
|
||||
"title": report_info.get("title"),
|
||||
"content": report_info.get("content"),
|
||||
"content_type": report_info.get("content_type", "markdown"),
|
||||
"report_metadata": report_info.get("report_metadata"),
|
||||
"report_group_id": report_info.get("report_group_id"),
|
||||
"versions": versions,
|
||||
|
|
|
|||
|
|
@ -279,6 +279,7 @@ async def read_report_content(
|
|||
id=report.id,
|
||||
title=report.title,
|
||||
content=report.content,
|
||||
content_type=report.content_type,
|
||||
report_metadata=report.report_metadata,
|
||||
report_group_id=report.report_group_id,
|
||||
versions=versions,
|
||||
|
|
@ -319,6 +320,7 @@ async def update_report_content(
|
|||
id=report.id,
|
||||
title=report.title,
|
||||
content=report.content,
|
||||
content_type=report.content_type,
|
||||
report_metadata=report.report_metadata,
|
||||
report_group_id=report.report_group_id,
|
||||
versions=versions,
|
||||
|
|
@ -333,6 +335,57 @@ async def update_report_content(
|
|||
) from None
|
||||
|
||||
|
||||
@router.get("/reports/{report_id}/preview")
|
||||
async def preview_report_pdf(
|
||||
report_id: int,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Return a compiled PDF preview for Typst-based reports (resumes).
|
||||
|
||||
Reads the Typst source from the database and compiles it to PDF bytes
|
||||
on-the-fly. Only works for reports with content_type='typst'.
|
||||
"""
|
||||
try:
|
||||
report = await _get_report_with_access(report_id, session, user)
|
||||
|
||||
if not report.content:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Report has no content to preview"
|
||||
)
|
||||
|
||||
if report.content_type != "typst":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Preview is only available for Typst-based reports",
|
||||
)
|
||||
|
||||
def _compile() -> bytes:
|
||||
return typst.compile(report.content.encode("utf-8"))
|
||||
|
||||
pdf_bytes = await asyncio.to_thread(_compile)
|
||||
|
||||
safe_title = re.sub(r"[^\w\s-]", "", report.title or "Resume").strip()
|
||||
filename = f"{safe_title}.pdf"
|
||||
|
||||
return StreamingResponse(
|
||||
io.BytesIO(pdf_bytes),
|
||||
media_type="application/pdf",
|
||||
headers={
|
||||
"Content-Disposition": f'inline; filename="{filename}"',
|
||||
},
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("Failed to compile Typst preview for report %d", report_id)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to compile resume preview",
|
||||
) from None
|
||||
|
||||
|
||||
@router.get("/reports/{report_id}/export")
|
||||
async def export_report(
|
||||
report_id: int,
|
||||
|
|
@ -354,6 +407,27 @@ async def export_report(
|
|||
status_code=400, detail="Report has no content to export"
|
||||
)
|
||||
|
||||
# Typst-based reports (resumes): compile directly without Pandoc
|
||||
if report.content_type == "typst":
|
||||
if format != ExportFormat.PDF:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Typst-based reports currently only support PDF export",
|
||||
)
|
||||
|
||||
def _compile_typst() -> bytes:
|
||||
return typst.compile(report.content.encode("utf-8"))
|
||||
|
||||
pdf_bytes = await asyncio.to_thread(_compile_typst)
|
||||
safe_title = re.sub(r"[^\w\s-]", "", report.title or "Resume").strip()
|
||||
return StreamingResponse(
|
||||
io.BytesIO(pdf_bytes),
|
||||
media_type="application/pdf",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{safe_title}.pdf"',
|
||||
},
|
||||
)
|
||||
|
||||
# Strip wrapping code fences that LLMs sometimes add around Markdown.
|
||||
# Without this, pandoc treats the entire content as a code block.
|
||||
markdown_content = _strip_wrapping_code_fences(report.content)
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ class ReportRead(BaseModel):
|
|||
report_style: str | None = None
|
||||
report_metadata: dict[str, Any] | None = None
|
||||
report_group_id: int | None = None
|
||||
content_type: str = "markdown"
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
|
|
@ -40,11 +41,12 @@ class ReportVersionInfo(BaseModel):
|
|||
|
||||
|
||||
class ReportContentRead(BaseModel):
|
||||
"""Schema for reading a report with full Markdown content."""
|
||||
"""Schema for reading a report with full content (Markdown or Typst)."""
|
||||
|
||||
id: int
|
||||
title: str
|
||||
content: str | None = None
|
||||
content_type: str = "markdown"
|
||||
report_metadata: dict[str, Any] | None = None
|
||||
report_group_id: int | None = None
|
||||
versions: list[ReportVersionInfo] = []
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ UI_TOOLS = {
|
|||
"generate_image",
|
||||
"generate_podcast",
|
||||
"generate_report",
|
||||
"generate_resume",
|
||||
"generate_video_presentation",
|
||||
}
|
||||
|
||||
|
|
@ -239,7 +240,7 @@ async def create_snapshot(
|
|||
video_presentation_ids_seen.add(vp_id)
|
||||
part["result"] = {**result_data, "status": "ready"}
|
||||
|
||||
elif tool_name == "generate_report":
|
||||
elif tool_name in ("generate_report", "generate_resume"):
|
||||
result_data = part.get("result", {})
|
||||
report_id = result_data.get("report_id")
|
||||
if report_id and report_id not in report_ids_seen:
|
||||
|
|
@ -247,7 +248,6 @@ async def create_snapshot(
|
|||
if report_info:
|
||||
reports_data.append(report_info)
|
||||
report_ids_seen.add(report_id)
|
||||
# Update status to "ready" so frontend renders ReportCard
|
||||
part["result"] = {**result_data, "status": "ready"}
|
||||
|
||||
messages_data.append(
|
||||
|
|
@ -377,6 +377,7 @@ async def _get_report_for_snapshot(
|
|||
"original_id": report.id,
|
||||
"title": report.title,
|
||||
"content": report.content,
|
||||
"content_type": report.content_type,
|
||||
"report_metadata": report.report_metadata,
|
||||
"report_group_id": report.report_group_id,
|
||||
"created_at": report.created_at.isoformat() if report.created_at else None,
|
||||
|
|
|
|||
|
|
@ -953,6 +953,31 @@ async def _stream_agent_events(
|
|||
f"Report generation failed: {error_msg}",
|
||||
"error",
|
||||
)
|
||||
elif tool_name == "generate_resume":
|
||||
yield streaming_service.format_tool_output_available(
|
||||
tool_call_id,
|
||||
tool_output
|
||||
if isinstance(tool_output, dict)
|
||||
else {"result": tool_output},
|
||||
)
|
||||
if (
|
||||
isinstance(tool_output, dict)
|
||||
and tool_output.get("status") == "ready"
|
||||
):
|
||||
yield streaming_service.format_terminal_info(
|
||||
f"Resume generated: {tool_output.get('title', 'Resume')}",
|
||||
"success",
|
||||
)
|
||||
else:
|
||||
error_msg = (
|
||||
tool_output.get("error", "Unknown error")
|
||||
if isinstance(tool_output, dict)
|
||||
else "Unknown error"
|
||||
)
|
||||
yield streaming_service.format_terminal_info(
|
||||
f"Resume generation failed: {error_msg}",
|
||||
"error",
|
||||
)
|
||||
elif tool_name in (
|
||||
"create_notion_page",
|
||||
"update_notion_page",
|
||||
|
|
|
|||
|
|
@ -162,6 +162,7 @@ const TOOLS_WITH_UI = new Set([
|
|||
"web_search",
|
||||
"generate_podcast",
|
||||
"generate_report",
|
||||
"generate_resume",
|
||||
"generate_video_presentation",
|
||||
"display_image",
|
||||
"generate_image",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ interface ReportPanelState {
|
|||
wordCount: number | null;
|
||||
/** When set, uses public endpoints for fetching report data (public shared chat) */
|
||||
shareToken: string | null;
|
||||
/** Content type of the report — "markdown" (default) or "typst" (resume) */
|
||||
contentType: string;
|
||||
}
|
||||
|
||||
const initialState: ReportPanelState = {
|
||||
|
|
@ -16,6 +18,7 @@ const initialState: ReportPanelState = {
|
|||
title: null,
|
||||
wordCount: null,
|
||||
shareToken: null,
|
||||
contentType: "markdown",
|
||||
};
|
||||
|
||||
/** Core atom holding the report panel state */
|
||||
|
|
@ -38,7 +41,14 @@ export const openReportPanelAtom = atom(
|
|||
title,
|
||||
wordCount,
|
||||
shareToken,
|
||||
}: { reportId: number; title: string; wordCount?: number; shareToken?: string | null }
|
||||
contentType,
|
||||
}: {
|
||||
reportId: number;
|
||||
title: string;
|
||||
wordCount?: number;
|
||||
shareToken?: string | null;
|
||||
contentType?: string;
|
||||
}
|
||||
) => {
|
||||
if (!get(reportPanelAtom).isOpen) {
|
||||
set(preReportCollapsedAtom, get(rightPanelCollapsedAtom));
|
||||
|
|
@ -49,6 +59,7 @@ export const openReportPanelAtom = atom(
|
|||
title,
|
||||
wordCount: wordCount ?? null,
|
||||
shareToken: shareToken ?? null,
|
||||
contentType: contentType ?? "markdown",
|
||||
});
|
||||
set(rightPanelTabAtom, "report");
|
||||
set(rightPanelCollapsedAtom, false);
|
||||
|
|
|
|||
|
|
@ -71,6 +71,13 @@ const GenerateReportToolUI = dynamic(
|
|||
})),
|
||||
{ ssr: false }
|
||||
);
|
||||
const GenerateResumeToolUI = dynamic(
|
||||
() =>
|
||||
import("@/components/tool-ui/generate-resume").then((m) => ({
|
||||
default: m.GenerateResumeToolUI,
|
||||
})),
|
||||
{ ssr: false }
|
||||
);
|
||||
const GeneratePodcastToolUI = dynamic(
|
||||
() =>
|
||||
import("@/components/tool-ui/generate-podcast").then((m) => ({
|
||||
|
|
@ -487,6 +494,7 @@ const AssistantMessageInner: FC = () => {
|
|||
tools: {
|
||||
by_name: {
|
||||
generate_report: GenerateReportToolUI,
|
||||
generate_resume: GenerateResumeToolUI,
|
||||
generate_podcast: GeneratePodcastToolUI,
|
||||
generate_video_presentation: GenerateVideoPresentationToolUI,
|
||||
display_image: GenerateImageToolUI,
|
||||
|
|
@ -537,7 +545,7 @@ const AssistantMessageInner: FC = () => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="aui-assistant-message-footer mt-1 mb-5 ml-2 flex items-center gap-2">
|
||||
<div className="aui-assistant-message-footer mt-3 mb-5 ml-2 flex items-center gap-2">
|
||||
<AssistantActionBar />
|
||||
</div>
|
||||
</CitationMetadataProvider>
|
||||
|
|
|
|||
|
|
@ -133,7 +133,6 @@ export function SourceDetailPanel({
|
|||
const scrollTimersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||
const [activeChunkIndex, setActiveChunkIndex] = useState<number | null>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [_hasScrolledToCited, setHasScrolledToCited] = useState(false);
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -322,11 +321,10 @@ export function SourceDetailPanel({
|
|||
);
|
||||
});
|
||||
|
||||
// After final attempt, mark state as scrolled
|
||||
// After final attempt, mark the cited chunk as active
|
||||
scrollTimersRef.current.push(
|
||||
setTimeout(
|
||||
() => {
|
||||
setHasScrolledToCited(true);
|
||||
setActiveChunkIndex(citedChunkIndex);
|
||||
},
|
||||
scrollAttempts[scrollAttempts.length - 1] + 50
|
||||
|
|
@ -343,7 +341,6 @@ export function SourceDetailPanel({
|
|||
scrollTimersRef.current.forEach(clearTimeout);
|
||||
scrollTimersRef.current = [];
|
||||
hasScrolledRef.current = false;
|
||||
setHasScrolledToCited(false);
|
||||
setActiveChunkIndex(null);
|
||||
}
|
||||
return () => {
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ export function PublicChatFooter({ shareToken }: PublicChatFooterProps) {
|
|||
size="lg"
|
||||
onClick={handleCopyAndContinue}
|
||||
disabled={isCloning}
|
||||
className="gap-2 rounded-full px-6 shadow-lg transition-all duration-200 hover:scale-[1.02] hover:shadow-xl hover:brightness-110 hover:bg-primary"
|
||||
className="gap-2 rounded-full px-6 shadow-lg transition-al select-none duration-200 hover:scale-[1.02] hover:shadow-xl hover:brightness-110 hover:bg-primary"
|
||||
>
|
||||
{isCloning ? <Spinner size="sm" /> : <Copy className="size-4" />}
|
||||
Copy and continue this chat
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button
|
|||
import { GenerateImageToolUI } from "@/components/tool-ui/generate-image";
|
||||
import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
|
||||
import { GenerateReportToolUI } from "@/components/tool-ui/generate-report";
|
||||
import { GenerateResumeToolUI } from "@/components/tool-ui/generate-resume";
|
||||
|
||||
const GenerateVideoPresentationToolUI = dynamic(
|
||||
() =>
|
||||
|
|
@ -160,6 +161,7 @@ const PublicAssistantMessage: FC = () => {
|
|||
by_name: {
|
||||
generate_podcast: GeneratePodcastToolUI,
|
||||
generate_report: GenerateReportToolUI,
|
||||
generate_resume: GenerateResumeToolUI,
|
||||
generate_video_presentation: GenerateVideoPresentationToolUI,
|
||||
display_image: GenerateImageToolUI,
|
||||
generate_image: GenerateImageToolUI,
|
||||
|
|
|
|||
354
surfsense_web/components/report-panel/pdf-viewer.tsx
Normal file
354
surfsense_web/components/report-panel/pdf-viewer.tsx
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
"use client";
|
||||
|
||||
import { ZoomInIcon, ZoomOutIcon } from "lucide-react";
|
||||
import type { PDFDocumentProxy, RenderTask } from "pdfjs-dist";
|
||||
import * as pdfjsLib from "pdfjs-dist";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { getAuthHeaders } from "@/lib/auth-utils";
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||
"pdfjs-dist/build/pdf.worker.min.mjs",
|
||||
import.meta.url
|
||||
).toString();
|
||||
|
||||
interface PdfViewerProps {
|
||||
pdfUrl: string;
|
||||
isPublic?: boolean;
|
||||
}
|
||||
|
||||
interface PageDimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const ZOOM_STEP = 0.15;
|
||||
const MIN_ZOOM = 0.5;
|
||||
const MAX_ZOOM = 3;
|
||||
const PAGE_GAP = 12;
|
||||
const SCROLL_DEBOUNCE_MS = 30;
|
||||
const BUFFER_PAGES = 1;
|
||||
|
||||
export function PdfViewer({ pdfUrl, isPublic = false }: PdfViewerProps) {
|
||||
const [numPages, setNumPages] = useState(0);
|
||||
const [scale, setScale] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const pdfDocRef = useRef<PDFDocumentProxy | null>(null);
|
||||
const canvasRefs = useRef<Map<number, HTMLCanvasElement>>(new Map());
|
||||
const renderTasksRef = useRef<Map<number, RenderTask>>(new Map());
|
||||
const renderedScalesRef = useRef<Map<number, number>>(new Map());
|
||||
const pageDimsRef = useRef<PageDimensions[]>([]);
|
||||
const visiblePagesRef = useRef<Set<number>>(new Set());
|
||||
const scrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const getScaledHeight = useCallback(
|
||||
(pageIndex: number) => {
|
||||
const dims = pageDimsRef.current[pageIndex];
|
||||
return dims ? Math.floor(dims.height * scale) : 0;
|
||||
},
|
||||
[scale]
|
||||
);
|
||||
|
||||
const getVisibleRange = useCallback(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container || pageDimsRef.current.length === 0) return { first: 1, last: 1 };
|
||||
|
||||
const scrollTop = container.scrollTop;
|
||||
const viewportHeight = container.clientHeight;
|
||||
const scrollBottom = scrollTop + viewportHeight;
|
||||
|
||||
let cumTop = 16;
|
||||
let first = 1;
|
||||
let last = pageDimsRef.current.length;
|
||||
|
||||
for (let i = 0; i < pageDimsRef.current.length; i++) {
|
||||
const pageHeight = getScaledHeight(i);
|
||||
const pageBottom = cumTop + pageHeight;
|
||||
|
||||
if (pageBottom >= scrollTop && first === 1) {
|
||||
first = i + 1;
|
||||
}
|
||||
if (cumTop > scrollBottom) {
|
||||
last = i;
|
||||
break;
|
||||
}
|
||||
|
||||
cumTop = pageBottom + PAGE_GAP;
|
||||
}
|
||||
|
||||
first = Math.max(1, first - BUFFER_PAGES);
|
||||
last = Math.min(pageDimsRef.current.length, last + BUFFER_PAGES);
|
||||
|
||||
return { first, last };
|
||||
}, [getScaledHeight]);
|
||||
|
||||
const renderPage = useCallback(async (pageNum: number, currentScale: number) => {
|
||||
const pdf = pdfDocRef.current;
|
||||
const canvas = canvasRefs.current.get(pageNum);
|
||||
if (!pdf || !canvas) return;
|
||||
|
||||
if (renderedScalesRef.current.get(pageNum) === currentScale) return;
|
||||
|
||||
const existing = renderTasksRef.current.get(pageNum);
|
||||
if (existing) {
|
||||
existing.cancel();
|
||||
renderTasksRef.current.delete(pageNum);
|
||||
}
|
||||
|
||||
try {
|
||||
const page = await pdf.getPage(pageNum);
|
||||
const viewport = page.getViewport({ scale: currentScale });
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
canvas.width = Math.floor(viewport.width * dpr);
|
||||
canvas.height = Math.floor(viewport.height * dpr);
|
||||
canvas.style.width = `${Math.floor(viewport.width)}px`;
|
||||
canvas.style.height = `${Math.floor(viewport.height)}px`;
|
||||
|
||||
const renderTask = page.render({
|
||||
canvas,
|
||||
viewport,
|
||||
transform: dpr !== 1 ? [dpr, 0, 0, dpr, 0, 0] : undefined,
|
||||
});
|
||||
|
||||
renderTasksRef.current.set(pageNum, renderTask);
|
||||
|
||||
await renderTask.promise;
|
||||
renderTasksRef.current.delete(pageNum);
|
||||
renderedScalesRef.current.set(pageNum, currentScale);
|
||||
page.cleanup();
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.message?.includes("cancelled")) return;
|
||||
console.error(`Failed to render page ${pageNum}:`, err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const cleanupPage = useCallback((pageNum: number) => {
|
||||
const existing = renderTasksRef.current.get(pageNum);
|
||||
if (existing) {
|
||||
existing.cancel();
|
||||
renderTasksRef.current.delete(pageNum);
|
||||
}
|
||||
|
||||
const canvas = canvasRefs.current.get(pageNum);
|
||||
if (canvas) {
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (ctx) ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
}
|
||||
|
||||
renderedScalesRef.current.delete(pageNum);
|
||||
}, []);
|
||||
|
||||
const renderVisiblePages = useCallback(() => {
|
||||
if (!pdfDocRef.current || pageDimsRef.current.length === 0) return;
|
||||
|
||||
const { first, last } = getVisibleRange();
|
||||
const newVisible = new Set<number>();
|
||||
|
||||
for (let i = first; i <= last; i++) {
|
||||
newVisible.add(i);
|
||||
renderPage(i, scale);
|
||||
}
|
||||
|
||||
for (const pageNum of visiblePagesRef.current) {
|
||||
if (!newVisible.has(pageNum)) {
|
||||
cleanupPage(pageNum);
|
||||
}
|
||||
}
|
||||
|
||||
visiblePagesRef.current = newVisible;
|
||||
}, [getVisibleRange, renderPage, cleanupPage, scale]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const loadDocument = async () => {
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
setNumPages(0);
|
||||
pageDimsRef.current = [];
|
||||
|
||||
try {
|
||||
const loadingTask = pdfjsLib.getDocument({
|
||||
url: pdfUrl,
|
||||
httpHeaders: getAuthHeaders(),
|
||||
});
|
||||
|
||||
const pdf = await loadingTask.promise;
|
||||
if (cancelled) {
|
||||
pdf.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const dims: PageDimensions[] = [];
|
||||
for (let i = 1; i <= pdf.numPages; i++) {
|
||||
const page = await pdf.getPage(i);
|
||||
const viewport = page.getViewport({ scale: 1 });
|
||||
dims.push({ width: viewport.width, height: viewport.height });
|
||||
page.cleanup();
|
||||
}
|
||||
|
||||
if (cancelled) {
|
||||
pdf.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
pdfDocRef.current = pdf;
|
||||
pageDimsRef.current = dims;
|
||||
setNumPages(pdf.numPages);
|
||||
setLoading(false);
|
||||
} catch (err: unknown) {
|
||||
if (cancelled) return;
|
||||
const message = err instanceof Error ? err.message : "Failed to load PDF";
|
||||
setLoadError(message);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadDocument();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
for (const task of renderTasksRef.current.values()) {
|
||||
task.cancel();
|
||||
}
|
||||
renderTasksRef.current.clear();
|
||||
renderedScalesRef.current.clear();
|
||||
visiblePagesRef.current.clear();
|
||||
pdfDocRef.current?.destroy();
|
||||
pdfDocRef.current = null;
|
||||
};
|
||||
}, [pdfUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (numPages === 0) return;
|
||||
|
||||
renderedScalesRef.current.clear();
|
||||
visiblePagesRef.current.clear();
|
||||
|
||||
const frame = requestAnimationFrame(() => {
|
||||
renderVisiblePages();
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [numPages, renderVisiblePages]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container || numPages === 0) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
if (scrollTimerRef.current) clearTimeout(scrollTimerRef.current);
|
||||
scrollTimerRef.current = setTimeout(() => {
|
||||
renderVisiblePages();
|
||||
}, SCROLL_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
container.addEventListener("scroll", handleScroll, { passive: true });
|
||||
return () => {
|
||||
container.removeEventListener("scroll", handleScroll);
|
||||
if (scrollTimerRef.current) clearTimeout(scrollTimerRef.current);
|
||||
};
|
||||
}, [numPages, renderVisiblePages]);
|
||||
|
||||
const setCanvasRef = useCallback((pageNum: number, el: HTMLCanvasElement | null) => {
|
||||
if (el) {
|
||||
canvasRefs.current.set(pageNum, el);
|
||||
} else {
|
||||
canvasRefs.current.delete(pageNum);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const zoomIn = useCallback(() => {
|
||||
setScale((prev) => Math.min(MAX_ZOOM, +(prev + ZOOM_STEP).toFixed(2)));
|
||||
}, []);
|
||||
|
||||
const zoomOut = useCallback(() => {
|
||||
setScale((prev) => Math.max(MIN_ZOOM, +(prev - ZOOM_STEP).toFixed(2)));
|
||||
}, []);
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3 p-6 text-center">
|
||||
<p className="font-medium text-foreground">Failed to load PDF</p>
|
||||
<p className="text-sm text-muted-foreground">{loadError}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{numPages > 0 && (
|
||||
<div
|
||||
className={`flex items-center justify-center gap-2 px-4 py-2 border-b shrink-0 select-none ${isPublic ? "bg-main-panel" : "bg-sidebar"}`}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={zoomOut}
|
||||
disabled={scale <= MIN_ZOOM}
|
||||
className="size-7"
|
||||
>
|
||||
<ZoomOutIcon className="size-4" />
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground tabular-nums min-w-[40px] text-center">
|
||||
{Math.round(scale * 100)}%
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={zoomIn}
|
||||
disabled={scale >= MAX_ZOOM}
|
||||
className="size-7"
|
||||
>
|
||||
<ZoomInIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className={`relative flex-1 overflow-auto ${isPublic ? "bg-main-panel" : "bg-sidebar"}`}
|
||||
>
|
||||
{loading ? (
|
||||
<div
|
||||
className={`absolute inset-0 flex items-center justify-center ${isPublic ? "text-foreground" : "text-sidebar-foreground"}`}
|
||||
>
|
||||
<Spinner size="md" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center py-4" style={{ gap: `${PAGE_GAP}px` }}>
|
||||
{pageDimsRef.current.map((dims, i) => {
|
||||
const pageNum = i + 1;
|
||||
const scaledWidth = Math.floor(dims.width * scale);
|
||||
const scaledHeight = Math.floor(dims.height * scale);
|
||||
return (
|
||||
<div
|
||||
key={pageNum}
|
||||
className="relative shrink-0"
|
||||
style={{ width: scaledWidth, height: scaledHeight }}
|
||||
>
|
||||
<canvas
|
||||
ref={(el) => setCanvasRef(pageNum, el)}
|
||||
className="shadow-lg absolute inset-0"
|
||||
/>
|
||||
{numPages > 1 && (
|
||||
<span className="absolute bottom-2 right-3 text-[10px] tabular-nums text-white/80 bg-black/50 px-1.5 py-0.5 rounded pointer-events-none">
|
||||
Page {pageNum}/{numPages}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ import {
|
|||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { baseApiService } from "@/lib/apis/base-api.service";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
|
|
@ -53,6 +54,11 @@ const PlateEditor = dynamic(
|
|||
{ ssr: false, loading: () => <ReportPanelSkeleton /> }
|
||||
);
|
||||
|
||||
const PdfViewer = dynamic(
|
||||
() => import("@/components/report-panel/pdf-viewer").then((m) => ({ default: m.PdfViewer })),
|
||||
{ ssr: false, loading: () => <ReportPanelSkeleton /> }
|
||||
);
|
||||
|
||||
/**
|
||||
* Zod schema for a single version entry
|
||||
*/
|
||||
|
|
@ -68,6 +74,7 @@ const ReportContentResponseSchema = z.object({
|
|||
id: z.number(),
|
||||
title: z.string(),
|
||||
content: z.string().nullish(),
|
||||
content_type: z.string().default("markdown"),
|
||||
report_metadata: z
|
||||
.object({
|
||||
status: z.enum(["ready", "failed"]).nullish(),
|
||||
|
|
@ -280,31 +287,46 @@ export function ReportPanelContent({
|
|||
}, [activeReportId, currentMarkdown]);
|
||||
|
||||
const activeVersionIndex = versions.findIndex((v) => v.id === activeReportId);
|
||||
const isPublic = !!shareToken;
|
||||
const btnBg = isPublic ? "bg-main-panel" : "bg-sidebar";
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Action bar — always visible; buttons are disabled while loading */}
|
||||
<div className="flex items-center justify-between px-4 py-2 shrink-0">
|
||||
<div className="flex h-14 items-center justify-between px-4 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Copy button */}
|
||||
{/* Copy button — hidden for Typst (resume) */}
|
||||
{reportContent?.content_type !== "typst" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
disabled={isLoading || !reportContent?.content}
|
||||
className="h-8 min-w-[80px] px-3.5 py-4 text-[15px] bg-sidebar select-none"
|
||||
className={`h-8 min-w-[80px] px-3.5 py-4 text-[15px] ${btnBg} select-none`}
|
||||
>
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Export dropdown */}
|
||||
{/* Export — plain button for resume (typst), dropdown for others */}
|
||||
{reportContent?.content_type === "typst" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleExport("pdf")}
|
||||
disabled={isLoading || !reportContent?.content || exporting !== null}
|
||||
className={`h-8 min-w-[100px] px-3.5 py-4 text-[15px] ${btnBg} select-none`}
|
||||
>
|
||||
{exporting === "pdf" ? <Spinner size="xs" /> : "Download"}
|
||||
</Button>
|
||||
) : (
|
||||
<DropdownMenu modal={insideDrawer ? false : undefined}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isLoading || !reportContent?.content}
|
||||
className="h-8 px-3.5 py-4 text-[15px] gap-1.5 bg-sidebar select-none"
|
||||
className={`h-8 px-3.5 py-4 text-[15px] gap-1.5 ${btnBg} select-none`}
|
||||
>
|
||||
Export
|
||||
<ChevronDownIcon className="size-3" />
|
||||
|
|
@ -321,6 +343,7 @@ export function ReportPanelContent({
|
|||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{/* Version switcher — only shown when multiple versions exist */}
|
||||
{versions.length > 1 && (
|
||||
|
|
@ -329,7 +352,7 @@ export function ReportPanelContent({
|
|||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-3.5 py-4 text-[15px] gap-1.5 bg-sidebar select-none"
|
||||
className={`h-8 px-3.5 py-4 text-[15px] gap-1.5 ${btnBg} select-none`}
|
||||
>
|
||||
v{activeVersionIndex + 1}
|
||||
<ChevronDownIcon className="size-3" />
|
||||
|
|
@ -365,12 +388,17 @@ export function ReportPanelContent({
|
|||
{isLoading ? (
|
||||
<ReportPanelSkeleton />
|
||||
) : error || !reportContent ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-6 text-center">
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-6 text-center select-none">
|
||||
<div>
|
||||
<p className="font-medium text-foreground">Failed to load report</p>
|
||||
<p className="text-sm text-red-500 mt-1">{error || "An unknown error occurred"}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : reportContent.content_type === "typst" ? (
|
||||
<PdfViewer
|
||||
pdfUrl={`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}${shareToken ? `/api/v1/public/${shareToken}/reports/${activeReportId}/preview` : `/api/v1/reports/${activeReportId}/preview`}`}
|
||||
isPublic={isPublic}
|
||||
/>
|
||||
) : reportContent.content ? (
|
||||
isReadOnly ? (
|
||||
<div className="h-full overflow-y-auto px-5 py-4">
|
||||
|
|
@ -421,10 +449,12 @@ function DesktopReportPanel() {
|
|||
|
||||
if (!panelState.isOpen || !panelState.reportId) return null;
|
||||
|
||||
const isPublic = !!panelState.shareToken;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="flex w-[50%] max-w-[700px] min-w-[380px] flex-col border-l bg-sidebar text-sidebar-foreground animate-in slide-in-from-right-4 duration-300 ease-out"
|
||||
className={`flex w-[50%] max-w-[700px] min-w-[380px] flex-col border-l animate-in slide-in-from-right-4 duration-300 ease-out ${isPublic ? "bg-main-panel text-foreground" : "bg-sidebar text-sidebar-foreground"}`}
|
||||
>
|
||||
<ReportPanelContent
|
||||
reportId={panelState.reportId}
|
||||
|
|
@ -445,6 +475,8 @@ function MobileReportDrawer() {
|
|||
|
||||
if (!panelState.reportId) return null;
|
||||
|
||||
const isPublic = !!panelState.shareToken;
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={panelState.isOpen}
|
||||
|
|
@ -454,7 +486,7 @@ function MobileReportDrawer() {
|
|||
shouldScaleBackground={false}
|
||||
>
|
||||
<DrawerContent
|
||||
className="h-[90vh] max-h-[90vh] z-80 bg-sidebar overflow-hidden"
|
||||
className={`h-[90vh] max-h-[90vh] z-80 overflow-hidden ${isPublic ? "bg-main-panel" : "bg-sidebar"}`}
|
||||
overlayClassName="z-80"
|
||||
>
|
||||
<DrawerHandle />
|
||||
|
|
|
|||
|
|
@ -24,18 +24,30 @@ interface ExportMenuItemsProps {
|
|||
exporting: string | null;
|
||||
/** Hide server-side formats (PDF, DOCX, etc.) — only show md */
|
||||
showAllFormats?: boolean;
|
||||
/** When true, only show PDF export (used for Typst-based resumes) */
|
||||
pdfOnly?: boolean;
|
||||
}
|
||||
|
||||
export function ExportDropdownItems({
|
||||
onExport,
|
||||
exporting,
|
||||
showAllFormats = true,
|
||||
pdfOnly = false,
|
||||
}: ExportMenuItemsProps) {
|
||||
const handle = (format: string) => (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onExport(format);
|
||||
};
|
||||
|
||||
if (pdfOnly) {
|
||||
return (
|
||||
<DropdownMenuItem onClick={handle("pdf")} disabled={exporting !== null}>
|
||||
{exporting === "pdf" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
|
||||
PDF (.pdf)
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{showAllFormats && (
|
||||
|
|
|
|||
|
|
@ -96,8 +96,12 @@ function ReportErrorState({ title, error }: { title: string; error: string }) {
|
|||
</div>
|
||||
<div className="mx-5 h-px bg-border/50" />
|
||||
<div className="px-5 py-4">
|
||||
{title && title !== "Report" && (
|
||||
<p className="text-sm font-medium text-foreground line-clamp-2">{title}</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">{error}</p>
|
||||
)}
|
||||
<p className={`text-sm text-muted-foreground${title && title !== "Report" ? " mt-1" : ""}`}>
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -215,17 +219,9 @@ function ReportCard({
|
|||
<div
|
||||
className={`my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-[box-shadow] duration-300 ${isActive ? "ring-1 ring-primary/50" : ""}`}
|
||||
>
|
||||
{/* biome-ignore lint/a11y/useSemanticElements: can't use <button> here because PlateEditor renders nested <button> elements (e.g. CopyButton) */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpen}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleOpen();
|
||||
}
|
||||
}}
|
||||
className="w-full text-left transition-colors hover:bg-muted/50 focus:outline-none focus-visible:outline-none cursor-pointer"
|
||||
>
|
||||
<div className="px-5 pt-5 pb-4 select-none">
|
||||
|
|
@ -272,7 +268,7 @@ function ReportCard({
|
|||
<p className="text-sm text-muted-foreground italic">No content available</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
334
surfsense_web/components/tool-ui/generate-resume.tsx
Normal file
334
surfsense_web/components/tool-ui/generate-resume.tsx
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
"use client";
|
||||
|
||||
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import * as pdfjsLib from "pdfjs-dist";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { openReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom";
|
||||
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { getAuthHeaders } from "@/lib/auth-utils";
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||
"pdfjs-dist/build/pdf.worker.min.mjs",
|
||||
import.meta.url
|
||||
).toString();
|
||||
|
||||
const GenerateResumeArgsSchema = z.object({
|
||||
user_info: z.string(),
|
||||
user_instructions: z.string().nullish(),
|
||||
parent_report_id: z.number().nullish(),
|
||||
});
|
||||
|
||||
const GenerateResumeResultSchema = z.object({
|
||||
status: z.enum(["ready", "failed"]),
|
||||
report_id: z.number().nullish(),
|
||||
title: z.string().nullish(),
|
||||
content_type: z.string().nullish(),
|
||||
message: z.string().nullish(),
|
||||
error: z.string().nullish(),
|
||||
});
|
||||
|
||||
type GenerateResumeArgs = z.infer<typeof GenerateResumeArgsSchema>;
|
||||
type GenerateResumeResult = z.infer<typeof GenerateResumeResultSchema>;
|
||||
|
||||
function ResumeGeneratingState() {
|
||||
return (
|
||||
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
|
||||
<div className="px-5 pt-5 pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-semibold text-foreground">Resume</p>
|
||||
</div>
|
||||
<TextShimmerLoader text="Crafting your resume" size="sm" />
|
||||
</div>
|
||||
<div className="mx-5 h-px bg-border/50" />
|
||||
<div className="px-5 pt-3 pb-4">
|
||||
<div className="h-[7rem] space-y-2">
|
||||
<div className="h-3 w-full rounded bg-muted/60 animate-pulse" />
|
||||
<div className="h-3 w-[92%] rounded bg-muted/60 animate-pulse [animation-delay:100ms]" />
|
||||
<div className="h-3 w-[75%] rounded bg-muted/60 animate-pulse [animation-delay:200ms]" />
|
||||
<div className="h-3 w-[85%] rounded bg-muted/60 animate-pulse [animation-delay:300ms]" />
|
||||
<div className="h-3 w-[60%] rounded bg-muted/60 animate-pulse [animation-delay:400ms]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResumeErrorState({ title, error }: { title: string; error: string }) {
|
||||
return (
|
||||
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
|
||||
<div className="px-5 pt-5 pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-semibold text-destructive">Resume Generation Failed</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-5 h-px bg-border/50" />
|
||||
<div className="px-5 py-4">
|
||||
{title && title !== "Resume" && (
|
||||
<p className="text-sm font-medium text-foreground line-clamp-2">{title}</p>
|
||||
)}
|
||||
<p className={`text-sm text-muted-foreground${title && title !== "Resume" ? " mt-1" : ""}`}>
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResumeCancelledState() {
|
||||
return (
|
||||
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
|
||||
<div className="px-5 pt-5 pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-semibold text-muted-foreground">Resume Cancelled</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Resume generation was cancelled</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThumbnailSkeleton() {
|
||||
return (
|
||||
<div className="h-[7rem] space-y-2">
|
||||
<div className="h-3 w-full rounded bg-muted/60 animate-pulse" />
|
||||
<div className="h-3 w-[92%] rounded bg-muted/60 animate-pulse [animation-delay:100ms]" />
|
||||
<div className="h-3 w-[75%] rounded bg-muted/60 animate-pulse [animation-delay:200ms]" />
|
||||
<div className="h-3 w-[85%] rounded bg-muted/60 animate-pulse [animation-delay:300ms]" />
|
||||
<div className="h-3 w-[60%] rounded bg-muted/60 animate-pulse [animation-delay:400ms]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PdfThumbnail({
|
||||
pdfUrl,
|
||||
onLoad,
|
||||
onError,
|
||||
}: {
|
||||
pdfUrl: string;
|
||||
onLoad: () => void;
|
||||
onError: () => void;
|
||||
}) {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const renderThumbnail = async () => {
|
||||
try {
|
||||
const loadingTask = pdfjsLib.getDocument({
|
||||
url: pdfUrl,
|
||||
httpHeaders: getAuthHeaders(),
|
||||
});
|
||||
|
||||
const pdf = await loadingTask.promise;
|
||||
if (cancelled) {
|
||||
pdf.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const page = await pdf.getPage(1);
|
||||
if (cancelled) {
|
||||
pdf.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) {
|
||||
pdf.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const containerWidth = wrapperRef.current?.clientWidth || 400;
|
||||
const unscaledViewport = page.getViewport({ scale: 1 });
|
||||
const fitScale = containerWidth / unscaledViewport.width;
|
||||
const viewport = page.getViewport({ scale: fitScale });
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
canvas.width = Math.ceil(viewport.width * dpr);
|
||||
canvas.height = Math.ceil(viewport.height * dpr);
|
||||
|
||||
await page.render({
|
||||
canvas,
|
||||
viewport,
|
||||
transform: dpr !== 1 ? [dpr, 0, 0, dpr, 0, 0] : undefined,
|
||||
}).promise;
|
||||
|
||||
if (!cancelled) {
|
||||
setReady(true);
|
||||
onLoad();
|
||||
}
|
||||
|
||||
pdf.destroy();
|
||||
} catch {
|
||||
if (!cancelled) onError();
|
||||
}
|
||||
};
|
||||
|
||||
renderThumbnail();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [pdfUrl, onLoad, onError]);
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef}>
|
||||
<canvas ref={canvasRef} className={ready ? "w-full h-auto" : "hidden"} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResumeCard({
|
||||
reportId,
|
||||
title,
|
||||
shareToken,
|
||||
autoOpen = false,
|
||||
}: {
|
||||
reportId: number;
|
||||
title: string;
|
||||
shareToken?: string | null;
|
||||
autoOpen?: boolean;
|
||||
}) {
|
||||
const openPanel = useSetAtom(openReportPanelAtom);
|
||||
const panelState = useAtomValue(reportPanelAtom);
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
const autoOpenedRef = useRef(false);
|
||||
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
||||
const [thumbState, setThumbState] = useState<"loading" | "ready" | "error">("loading");
|
||||
|
||||
useEffect(() => {
|
||||
const previewPath = shareToken
|
||||
? `/api/v1/public/${shareToken}/reports/${reportId}/preview`
|
||||
: `/api/v1/reports/${reportId}/preview`;
|
||||
setPdfUrl(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}${previewPath}`);
|
||||
|
||||
if (autoOpen && isDesktop && !autoOpenedRef.current) {
|
||||
autoOpenedRef.current = true;
|
||||
openPanel({
|
||||
reportId,
|
||||
title,
|
||||
shareToken,
|
||||
contentType: "typst",
|
||||
});
|
||||
}
|
||||
}, [reportId, title, shareToken, autoOpen, isDesktop, openPanel]);
|
||||
|
||||
const onThumbLoad = useCallback(() => setThumbState("ready"), []);
|
||||
const onThumbError = useCallback(() => setThumbState("error"), []);
|
||||
|
||||
const isActive = panelState.isOpen && panelState.reportId === reportId;
|
||||
|
||||
const handleOpen = () => {
|
||||
openPanel({
|
||||
reportId,
|
||||
title,
|
||||
shareToken,
|
||||
contentType: "typst",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-[box-shadow] duration-300 ${isActive ? "ring-1 ring-primary/50" : ""}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpen}
|
||||
className="w-full text-left transition-colors hover:bg-muted/50 focus:outline-none focus-visible:outline-none cursor-pointer select-none"
|
||||
>
|
||||
<div className="px-5 pt-5 pb-4">
|
||||
<p className="text-base font-semibold text-foreground line-clamp-2">{title}</p>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">PDF</p>
|
||||
</div>
|
||||
|
||||
<div className="mx-5 h-px bg-border/50" />
|
||||
|
||||
<div className="px-5 pt-3 pb-4">
|
||||
{thumbState === "loading" && <ThumbnailSkeleton />}
|
||||
{thumbState === "error" && (
|
||||
<p className="text-sm text-muted-foreground">Preview unavailable</p>
|
||||
)}
|
||||
{pdfUrl && (
|
||||
<div
|
||||
className={`max-h-[7rem] overflow-hidden pointer-events-none mix-blend-multiply dark:mix-blend-screen ${thumbState !== "ready" ? "hidden" : ""}`}
|
||||
style={{
|
||||
maskImage: "linear-gradient(to bottom, black 50%, transparent 100%)",
|
||||
WebkitMaskImage: "linear-gradient(to bottom, black 50%, transparent 100%)",
|
||||
}}
|
||||
>
|
||||
<div className="dark:invert dark:hue-rotate-180">
|
||||
<PdfThumbnail pdfUrl={pdfUrl} onLoad={onThumbLoad} onError={onThumbError} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const GenerateResumeToolUI = ({
|
||||
result,
|
||||
status,
|
||||
}: ToolCallMessagePartProps<GenerateResumeArgs, GenerateResumeResult>) => {
|
||||
const params = useParams();
|
||||
const pathname = usePathname();
|
||||
const isPublicRoute = pathname?.startsWith("/public/");
|
||||
const shareToken = isPublicRoute && typeof params?.token === "string" ? params.token : null;
|
||||
|
||||
const sawRunningRef = useRef(false);
|
||||
if (status.type === "running" || status.type === "requires-action") {
|
||||
sawRunningRef.current = true;
|
||||
}
|
||||
|
||||
if (status.type === "running" || status.type === "requires-action") {
|
||||
return <ResumeGeneratingState />;
|
||||
}
|
||||
|
||||
if (status.type === "incomplete") {
|
||||
if (status.reason === "cancelled") {
|
||||
return <ResumeCancelledState />;
|
||||
}
|
||||
if (status.reason === "error") {
|
||||
return (
|
||||
<ResumeErrorState
|
||||
title="Resume"
|
||||
error={typeof status.error === "string" ? status.error : "An error occurred"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
return <ResumeGeneratingState />;
|
||||
}
|
||||
|
||||
if (result.status === "failed") {
|
||||
return (
|
||||
<ResumeErrorState
|
||||
title={result.title || "Resume"}
|
||||
error={
|
||||
result.error || "Resume generation failed. Please try again or rephrase your request."
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (result.status === "ready" && result.report_id) {
|
||||
return (
|
||||
<ResumeCard
|
||||
reportId={result.report_id}
|
||||
title={result.title || "Resume"}
|
||||
shareToken={shareToken}
|
||||
autoOpen={sawRunningRef.current}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <ResumeErrorState title="Resume" error="Missing report ID" />;
|
||||
};
|
||||
|
|
@ -110,6 +110,7 @@
|
|||
"next": "^16.1.0",
|
||||
"next-intl": "^4.6.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"pdfjs-dist": "^5.6.205",
|
||||
"pg": "^8.16.3",
|
||||
"platejs": "^52.0.17",
|
||||
"postgres": "^3.4.7",
|
||||
|
|
|
|||
136
surfsense_web/pnpm-lock.yaml
generated
136
surfsense_web/pnpm-lock.yaml
generated
|
|
@ -275,6 +275,9 @@ importers:
|
|||
next-themes:
|
||||
specifier: ^0.4.6
|
||||
version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
pdfjs-dist:
|
||||
specifier: ^5.6.205
|
||||
version: 5.6.205
|
||||
pg:
|
||||
specifier: ^8.16.3
|
||||
version: 8.18.0
|
||||
|
|
@ -1981,6 +1984,76 @@ packages:
|
|||
peerDependencies:
|
||||
mediabunny: ^1.0.0
|
||||
|
||||
'@napi-rs/canvas-android-arm64@0.1.97':
|
||||
resolution: {integrity: sha512-V1c/WVw+NzH8vk7ZK/O8/nyBSCQimU8sfMsB/9qeSvdkGKNU7+mxy/bIF0gTgeBFmHpj30S4E9WHMSrxXGQuVQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@napi-rs/canvas-darwin-arm64@0.1.97':
|
||||
resolution: {integrity: sha512-ok+SCEF4YejcxuJ9Rm+WWunHHpf2HmiPxfz6z1a/NFQECGXtsY7A4B8XocK1LmT1D7P174MzwPF9Wy3AUAwEPw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@napi-rs/canvas-darwin-x64@0.1.97':
|
||||
resolution: {integrity: sha512-PUP6e6/UGlclUvAQNnuXCcnkpdUou6VYZfQOQxExLp86epOylmiwLkqXIvpFmjoTEDmPmXrI+coL/9EFU1gKPA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.97':
|
||||
resolution: {integrity: sha512-XyXH2L/cic8eTNtbrXCcvqHtMX/nEOxN18+7rMrAM2XtLYC/EB5s0wnO1FsLMWmK+04ZSLN9FBGipo7kpIkcOw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-gnu@0.1.97':
|
||||
resolution: {integrity: sha512-Kuq/M3djq0K8ktgz6nPlK7Ne5d4uWeDxPpyKWOjWDK2RIOhHVtLtyLiJw2fuldw7Vn4mhw05EZXCEr4Q76rs9w==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-musl@0.1.97':
|
||||
resolution: {integrity: sha512-kKmSkQVnWeqg7qdsiXvYxKhAFuHz3tkBjW/zyQv5YKUPhotpaVhpBGv5LqCngzyuRV85SXoe+OFj+Tv0a0QXkQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/canvas-linux-riscv64-gnu@0.1.97':
|
||||
resolution: {integrity: sha512-Jc7I3A51jnEOIAXeLsN/M/+Z28LUeakcsXs07FLq9prXc0eYOtVwsDEv913Gr+06IRo34gJJVgT0TXvmz+N2VA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/canvas-linux-x64-gnu@0.1.97':
|
||||
resolution: {integrity: sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/canvas-linux-x64-musl@0.1.97':
|
||||
resolution: {integrity: sha512-AKLFd/v0Z5fvgqBDqhvqtAdx+fHMJ5t9JcUNKq4FIZ5WH+iegGm8HPdj00NFlCSnm83Fp3Ln8I2f7uq1aIiWaA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/canvas-win32-arm64-msvc@0.1.97':
|
||||
resolution: {integrity: sha512-u883Yr6A6fO7Vpsy9YE4FVCIxzzo5sO+7pIUjjoDLjS3vQaNMkVzx5bdIpEL+ob+gU88WDK4VcxYMZ6nmnoX9A==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@napi-rs/canvas-win32-x64-msvc@0.1.97':
|
||||
resolution: {integrity: sha512-sWtD2EE3fV0IzN+iiQUqr/Q1SwqWhs2O1FKItFlxtdDkikpEj5g7DKQpY3x55H/MAOnL8iomnlk3mcEeGiUMoQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@napi-rs/canvas@0.1.97':
|
||||
resolution: {integrity: sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
'@napi-rs/wasm-runtime@0.2.12':
|
||||
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
|
||||
|
||||
|
|
@ -7027,6 +7100,9 @@ packages:
|
|||
encoding:
|
||||
optional: true
|
||||
|
||||
node-readable-to-web-readable-stream@0.4.2:
|
||||
resolution: {integrity: sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==}
|
||||
|
||||
node-releases@2.0.27:
|
||||
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
||||
|
||||
|
|
@ -7168,6 +7244,10 @@ packages:
|
|||
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
pdfjs-dist@5.6.205:
|
||||
resolution: {integrity: sha512-tlUj+2IDa7G1SbvBNN74UHRLJybZDWYom+k6p5KIZl7huBvsA4APi6mKL+zCxd3tLjN5hOOEE9Tv7VdzO88pfg==}
|
||||
engines: {node: '>=20.19.0 || >=22.13.0 || >=24'}
|
||||
|
||||
performance-now@2.1.0:
|
||||
resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==}
|
||||
|
||||
|
|
@ -9992,6 +10072,54 @@ snapshots:
|
|||
dependencies:
|
||||
mediabunny: 1.39.2
|
||||
|
||||
'@napi-rs/canvas-android-arm64@0.1.97':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-darwin-arm64@0.1.97':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-darwin-x64@0.1.97':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.97':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-gnu@0.1.97':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-musl@0.1.97':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-riscv64-gnu@0.1.97':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-x64-gnu@0.1.97':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-x64-musl@0.1.97':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-win32-arm64-msvc@0.1.97':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-win32-x64-msvc@0.1.97':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas@0.1.97':
|
||||
optionalDependencies:
|
||||
'@napi-rs/canvas-android-arm64': 0.1.97
|
||||
'@napi-rs/canvas-darwin-arm64': 0.1.97
|
||||
'@napi-rs/canvas-darwin-x64': 0.1.97
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf': 0.1.97
|
||||
'@napi-rs/canvas-linux-arm64-gnu': 0.1.97
|
||||
'@napi-rs/canvas-linux-arm64-musl': 0.1.97
|
||||
'@napi-rs/canvas-linux-riscv64-gnu': 0.1.97
|
||||
'@napi-rs/canvas-linux-x64-gnu': 0.1.97
|
||||
'@napi-rs/canvas-linux-x64-musl': 0.1.97
|
||||
'@napi-rs/canvas-win32-arm64-msvc': 0.1.97
|
||||
'@napi-rs/canvas-win32-x64-msvc': 0.1.97
|
||||
optional: true
|
||||
|
||||
'@napi-rs/wasm-runtime@0.2.12':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.8.1
|
||||
|
|
@ -15830,6 +15958,9 @@ snapshots:
|
|||
dependencies:
|
||||
whatwg-url: 5.0.0
|
||||
|
||||
node-readable-to-web-readable-stream@0.4.2:
|
||||
optional: true
|
||||
|
||||
node-releases@2.0.27: {}
|
||||
|
||||
npm-run-path@4.0.1:
|
||||
|
|
@ -15992,6 +16123,11 @@ snapshots:
|
|||
|
||||
path-type@4.0.0: {}
|
||||
|
||||
pdfjs-dist@5.6.205:
|
||||
optionalDependencies:
|
||||
'@napi-rs/canvas': 0.1.97
|
||||
node-readable-to-web-readable-stream: 0.4.2
|
||||
|
||||
performance-now@2.1.0: {}
|
||||
|
||||
pg-cloudflare@1.3.0:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue