diff --git a/surfsense_backend/alembic/versions/126_add_report_content_type.py b/surfsense_backend/alembic/versions/126_add_report_content_type.py new file mode 100644 index 000000000..3d9e4860c --- /dev/null +++ b/surfsense_backend/alembic/versions/126_add_report_content_type.py @@ -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") diff --git a/surfsense_backend/alembic/versions/127_seed_build_resume_prompt.py b/surfsense_backend/alembic/versions/127_seed_build_resume_prompt.py new file mode 100644 index 000000000..9e05a0510 --- /dev/null +++ b/surfsense_backend/alembic/versions/127_seed_build_resume_prompt.py @@ -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'") diff --git a/surfsense_backend/app/agents/new_chat/system_prompt.py b/surfsense_backend/app/agents/new_chat/system_prompt.py index dc1dd19b7..b7b3d6b33 100644 --- a/surfsense_backend/app/agents/new_chat/system_prompt.py +++ b/surfsense_backend/app/agents/new_chat/system_prompt.py @@ -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=)` + - 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", diff --git a/surfsense_backend/app/agents/new_chat/tools/registry.py b/surfsense_backend/app/agents/new_chat/tools/registry.py index af00cc44d..265aabbbf 100644 --- a/surfsense_backend/app/agents/new_chat/tools/registry.py +++ b/surfsense_backend/app/agents/new_chat/tools/registry.py @@ -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", diff --git a/surfsense_backend/app/agents/new_chat/tools/resume.py b/surfsense_backend/app/agents/new_chat/tools/resume.py new file mode 100644 index 000000000..b1962f8d1 --- /dev/null +++ b/surfsense_backend/app/agents/new_chat/tools/resume.py @@ -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 diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index cf9801e17..16b40983e 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -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 diff --git a/surfsense_backend/app/prompts/system_defaults.py b/surfsense_backend/app/prompts/system_defaults.py index aaf9b64bd..cc2019b8f 100644 --- a/surfsense_backend/app/prompts/system_defaults.py +++ b/surfsense_backend/app/prompts/system_defaults.py @@ -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", + }, ] diff --git a/surfsense_backend/app/routes/public_chat_routes.py b/surfsense_backend/app/routes/public_chat_routes.py index e206bfd11..3181e117c 100644 --- a/surfsense_backend/app/routes/public_chat_routes.py +++ b/surfsense_backend/app/routes/public_chat_routes.py @@ -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, diff --git a/surfsense_backend/app/routes/reports_routes.py b/surfsense_backend/app/routes/reports_routes.py index 56ac5ec2d..19961e1a9 100644 --- a/surfsense_backend/app/routes/reports_routes.py +++ b/surfsense_backend/app/routes/reports_routes.py @@ -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) diff --git a/surfsense_backend/app/schemas/reports.py b/surfsense_backend/app/schemas/reports.py index 9a7765507..25ca50607 100644 --- a/surfsense_backend/app/schemas/reports.py +++ b/surfsense_backend/app/schemas/reports.py @@ -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] = [] diff --git a/surfsense_backend/app/services/public_chat_service.py b/surfsense_backend/app/services/public_chat_service.py index 376db974f..e4e0dd33a 100644 --- a/surfsense_backend/app/services/public_chat_service.py +++ b/surfsense_backend/app/services/public_chat_service.py @@ -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, diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index d45365557..4810f02e6 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -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", diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index b522bc913..6c94134b7 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -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", diff --git a/surfsense_web/atoms/chat/report-panel.atom.ts b/surfsense_web/atoms/chat/report-panel.atom.ts index edae8979d..c80230f05 100644 --- a/surfsense_web/atoms/chat/report-panel.atom.ts +++ b/surfsense_web/atoms/chat/report-panel.atom.ts @@ -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); diff --git a/surfsense_web/components/assistant-ui/assistant-message.tsx b/surfsense_web/components/assistant-ui/assistant-message.tsx index f159d42d2..ef7e217ec 100644 --- a/surfsense_web/components/assistant-ui/assistant-message.tsx +++ b/surfsense_web/components/assistant-ui/assistant-message.tsx @@ -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 = () => { )} -
+
diff --git a/surfsense_web/components/new-chat/source-detail-panel.tsx b/surfsense_web/components/new-chat/source-detail-panel.tsx index dbafd98f1..aded206c7 100644 --- a/surfsense_web/components/new-chat/source-detail-panel.tsx +++ b/surfsense_web/components/new-chat/source-detail-panel.tsx @@ -133,7 +133,6 @@ export function SourceDetailPanel({ const scrollTimersRef = useRef[]>([]); const [activeChunkIndex, setActiveChunkIndex] = useState(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 () => { diff --git a/surfsense_web/components/public-chat/public-chat-footer.tsx b/surfsense_web/components/public-chat/public-chat-footer.tsx index e341a9a0c..7d3263341 100644 --- a/surfsense_web/components/public-chat/public-chat-footer.tsx +++ b/surfsense_web/components/public-chat/public-chat-footer.tsx @@ -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 ? : } Copy and continue this chat diff --git a/surfsense_web/components/public-chat/public-thread.tsx b/surfsense_web/components/public-chat/public-thread.tsx index 1caaba299..627baf831 100644 --- a/surfsense_web/components/public-chat/public-thread.tsx +++ b/surfsense_web/components/public-chat/public-thread.tsx @@ -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, diff --git a/surfsense_web/components/report-panel/pdf-viewer.tsx b/surfsense_web/components/report-panel/pdf-viewer.tsx new file mode 100644 index 000000000..c4980dd7e --- /dev/null +++ b/surfsense_web/components/report-panel/pdf-viewer.tsx @@ -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(null); + + const scrollContainerRef = useRef(null); + const pdfDocRef = useRef(null); + const canvasRefs = useRef>(new Map()); + const renderTasksRef = useRef>(new Map()); + const renderedScalesRef = useRef>(new Map()); + const pageDimsRef = useRef([]); + const visiblePagesRef = useRef>(new Set()); + const scrollTimerRef = useRef | 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(); + + 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 ( +
+

Failed to load PDF

+

{loadError}

+
+ ); + } + + return ( +
+ {numPages > 0 && ( +
+ + + {Math.round(scale * 100)}% + + +
+ )} + +
+ {loading ? ( +
+ +
+ ) : ( +
+ {pageDimsRef.current.map((dims, i) => { + const pageNum = i + 1; + const scaledWidth = Math.floor(dims.width * scale); + const scaledHeight = Math.floor(dims.height * scale); + return ( +
+ setCanvasRef(pageNum, el)} + className="shadow-lg absolute inset-0" + /> + {numPages > 1 && ( + + Page {pageNum}/{numPages} + + )} +
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/surfsense_web/components/report-panel/report-panel.tsx b/surfsense_web/components/report-panel/report-panel.tsx index 6ec2a08eb..591155757 100644 --- a/surfsense_web/components/report-panel/report-panel.tsx +++ b/surfsense_web/components/report-panel/report-panel.tsx @@ -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: () => } ); +const PdfViewer = dynamic( + () => import("@/components/report-panel/pdf-viewer").then((m) => ({ default: m.PdfViewer })), + { ssr: false, loading: () => } +); + /** * 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,47 +287,63 @@ 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 */} -
+
- {/* Copy button */} - - - {/* Export dropdown */} - - - - - - + {copied ? "Copied" : "Copy"} + + )} + + {/* Export — plain button for resume (typst), dropdown for others */} + {reportContent?.content_type === "typst" ? ( + + ) : ( + + + + + + + + + )} {/* Version switcher — only shown when multiple versions exist */} {versions.length > 1 && ( @@ -329,7 +352,7 @@ export function ReportPanelContent({
); } diff --git a/surfsense_web/components/tool-ui/generate-resume.tsx b/surfsense_web/components/tool-ui/generate-resume.tsx new file mode 100644 index 000000000..f329ff95d --- /dev/null +++ b/surfsense_web/components/tool-ui/generate-resume.tsx @@ -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; +type GenerateResumeResult = z.infer; + +function ResumeGeneratingState() { + return ( +
+
+
+

Resume

+
+ +
+
+
+
+
+
+
+
+
+
+
+
+ ); +} + +function ResumeErrorState({ title, error }: { title: string; error: string }) { + return ( +
+
+
+

Resume Generation Failed

+
+
+
+
+ {title && title !== "Resume" && ( +

{title}

+ )} +

+ {error} +

+
+
+ ); +} + +function ResumeCancelledState() { + return ( +
+
+
+

Resume Cancelled

+
+

Resume generation was cancelled

+
+
+ ); +} + +function ThumbnailSkeleton() { + return ( +
+
+
+
+
+
+
+ ); +} + +function PdfThumbnail({ + pdfUrl, + onLoad, + onError, +}: { + pdfUrl: string; + onLoad: () => void; + onError: () => void; +}) { + const wrapperRef = useRef(null); + const canvasRef = useRef(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 ( +
+ +
+ ); +} + +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(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 ( +
+ +
+ ); +} + +export const GenerateResumeToolUI = ({ + result, + status, +}: ToolCallMessagePartProps) => { + 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 ; + } + + if (status.type === "incomplete") { + if (status.reason === "cancelled") { + return ; + } + if (status.reason === "error") { + return ( + + ); + } + } + + if (!result) { + return ; + } + + if (result.status === "failed") { + return ( + + ); + } + + if (result.status === "ready" && result.report_id) { + return ( + + ); + } + + return ; +}; diff --git a/surfsense_web/package.json b/surfsense_web/package.json index 63ab78aa6..58da8933a 100644 --- a/surfsense_web/package.json +++ b/surfsense_web/package.json @@ -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", diff --git a/surfsense_web/pnpm-lock.yaml b/surfsense_web/pnpm-lock.yaml index ebe9071ca..7cb492a05 100644 --- a/surfsense_web/pnpm-lock.yaml +++ b/surfsense_web/pnpm-lock.yaml @@ -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: