From 15aacf1a801e2ef8ed93227dbe407402c32b17ae Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Wed, 15 Apr 2026 20:39:03 +0530 Subject: [PATCH 01/22] feat: add react-pdf dependency and update pnpm-lock.yaml --- surfsense_web/package.json | 1 + surfsense_web/pnpm-lock.yaml | 183 +++++++++++++++++++++++++++++++++++ 2 files changed, 184 insertions(+) diff --git a/surfsense_web/package.json b/surfsense_web/package.json index 985add99f..96ef62445 100644 --- a/surfsense_web/package.json +++ b/surfsense_web/package.json @@ -123,6 +123,7 @@ "react-dropzone": "^14.3.8", "react-hook-form": "^7.61.1", "react-json-view-lite": "^2.4.1", + "react-pdf": "^10.4.1", "react-syntax-highlighter": "^15.6.1", "react-wrap-balancer": "^1.1.1", "rehype-katex": "^7.0.1", diff --git a/surfsense_web/pnpm-lock.yaml b/surfsense_web/pnpm-lock.yaml index f4b927abc..dff59c29d 100644 --- a/surfsense_web/pnpm-lock.yaml +++ b/surfsense_web/pnpm-lock.yaml @@ -314,6 +314,9 @@ importers: react-json-view-lite: specifier: ^2.4.1 version: 2.5.0(react@19.2.4) + react-pdf: + specifier: ^10.4.1 + version: 10.4.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react-syntax-highlighter: specifier: ^15.6.1 version: 15.6.6(react@19.2.4) @@ -1972,6 +1975,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==} @@ -6671,6 +6744,12 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + make-cancellable-promise@2.0.0: + resolution: {integrity: sha512-3SEQqTpV9oqVsIWqAcmDuaNeo7yBO3tqPtqGRcKkEo0lrzD3wqbKG9mkxO65KoOgXqj+zH2phJ2LiAsdzlogSw==} + + make-event-props@2.0.0: + resolution: {integrity: sha512-G/hncXrl4Qt7mauJEXSg3AcdYzmpkIITTNl5I+rH9sog5Yw0kK6vseJjCaPfOXqOqQuPUP89Rkhfz5kPS8ijtw==} + markdown-extensions@2.0.0: resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==} engines: {node: '>=16'} @@ -6752,6 +6831,14 @@ packages: mediabunny@1.39.2: resolution: {integrity: sha512-VcrisGRt+OI7tTPrziucJoCIPYIS/DEWY37TqzQVLWSUUHiyvsiRizEypQ3FOlhfIZ4ytAG/Mw4zxfetCTyKUg==} + merge-refs@2.0.0: + resolution: {integrity: sha512-3+B21mYK2IqUWnd2EivABLT7ueDhb0b8/dGK8LoFQPrU61YITeCMn14F7y7qZafWNZhUEKb24cJdiT5Wxs3prg==} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -7159,6 +7246,10 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pdfjs-dist@5.4.296: + resolution: {integrity: sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==} + engines: {node: '>=20.16.0 || >=22.3.0'} + performance-now@2.1.0: resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} @@ -7452,6 +7543,16 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-pdf@10.4.1: + resolution: {integrity: sha512-kS/35staVCBqS29verTQJQZXw7RfsRCPO3fdJoW1KXylcv7A9dw6DZ3vJXC2w+bIBgLw5FN4pOFvKSQtkQhPfA==} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + react-remove-scroll-bar@2.3.8: resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} engines: {node: '>=10'} @@ -8354,6 +8455,9 @@ packages: yaml: optional: true + warning@4.0.3: + resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==} + web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} @@ -9978,6 +10082,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 @@ -15214,6 +15366,10 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + make-cancellable-promise@2.0.0: {} + + make-event-props@2.0.0: {} + markdown-extensions@2.0.0: {} markdown-table@3.0.4: {} @@ -15408,6 +15564,10 @@ snapshots: '@types/dom-mediacapture-transform': 0.1.11 '@types/dom-webcodecs': 0.1.13 + merge-refs@2.0.0(@types/react@19.2.14): + optionalDependencies: + '@types/react': 19.2.14 + merge-stream@2.0.0: {} merge2@1.4.1: {} @@ -15978,6 +16138,10 @@ snapshots: path-type@4.0.0: {} + pdfjs-dist@5.4.296: + optionalDependencies: + '@napi-rs/canvas': 0.1.97 + performance-now@2.1.0: {} pg-cloudflare@1.3.0: @@ -16359,6 +16523,21 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) + react-pdf@10.4.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + clsx: 2.1.1 + dequal: 2.0.3 + make-cancellable-promise: 2.0.0 + make-event-props: 2.0.0 + merge-refs: 2.0.0(@types/react@19.2.14) + pdfjs-dist: 5.4.296 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + tiny-invariant: 1.3.1 + warning: 4.0.3 + optionalDependencies: + '@types/react': 19.2.14 + react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.4): dependencies: react: 19.2.4 @@ -17479,6 +17658,10 @@ snapshots: lightningcss: 1.31.1 tsx: 4.21.0 + warning@4.0.3: + dependencies: + loose-envify: 1.4.0 + web-namespaces@2.0.1: {} web-vitals@5.1.0: {} From 586199ae17be8262349407e405a83829eb95390c Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Wed, 15 Apr 2026 20:40:40 +0530 Subject: [PATCH 02/22] feat: add content_type column to reports and seed default prompt for resume generation --- .../versions/126_add_report_content_type.py | 42 ++++++++++++++++++ .../versions/127_seed_build_resume_prompt.py | 43 +++++++++++++++++++ surfsense_backend/app/db.py | 5 ++- 3 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 surfsense_backend/alembic/versions/126_add_report_content_type.py create mode 100644 surfsense_backend/alembic/versions/127_seed_build_resume_prompt.py 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/db.py b/surfsense_backend/app/db.py index 61bdd65cb..239f306f5 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -1191,12 +1191,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 From aac65e122e595f875fe1e22dc8f2761567371ebf Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Wed, 15 Apr 2026 21:08:08 +0530 Subject: [PATCH 03/22] feat: add resume generation tool and update system prompts --- .../app/agents/new_chat/system_prompt.py | 35 +++++++++++++++++++ .../app/agents/new_chat/tools/registry.py | 11 ++++++ 2 files changed, 46 insertions(+) diff --git a/surfsense_backend/app/agents/new_chat/system_prompt.py b/surfsense_backend/app/agents/new_chat/system_prompt.py index dc1dd19b7..ffc96554a 100644 --- a/surfsense_backend/app/agents/new_chat/system_prompt.py +++ b/surfsense_backend/app/agents/new_chat/system_prompt.py @@ -443,6 +443,40 @@ _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. Pass everything the user provides. + - 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. + - 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 Anish Sarkar, software engineer at SurfSense..." + - Call: `generate_resume(user_info="Anish Sarkar, software engineer at SurfSense...")` + - 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: (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 +484,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", From 39aaf92f0e960b376593721097d99dafb33630d2 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Wed, 15 Apr 2026 21:11:27 +0530 Subject: [PATCH 04/22] feat: add PDF preview and export functionality for Typst-based reports, enhance report content handling --- .../app/prompts/system_defaults.py | 7 ++ .../app/routes/reports_routes.py | 74 +++++++++++++++++++ 2 files changed, 81 insertions(+) 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/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) From ccf010175d2f39272388982d84c4cafb63ea0b8b Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Wed, 15 Apr 2026 21:11:58 +0530 Subject: [PATCH 05/22] feat: enhance report content schema and add resume generation output handling --- surfsense_backend/app/schemas/reports.py | 4 ++- .../app/tasks/chat/stream_new_chat.py | 25 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) 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/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index 478aa3671..8c2ed65ef 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", From 45eef24dbf28778657609d9b402f2605adb7cac4 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Wed, 15 Apr 2026 21:12:08 +0530 Subject: [PATCH 06/22] feat: implement resume generation tool with Typst output and validation --- .../app/agents/new_chat/tools/resume.py | 419 ++++++++++++++++++ 1 file changed, 419 insertions(+) create mode 100644 surfsense_backend/app/agents/new_chat/tools/resume.py 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..8295dd1b5 --- /dev/null +++ b/surfsense_backend/app/agents/new_chat/tools/resume.py @@ -0,0 +1,419 @@ +""" +Resume generation tool for the SurfSense agent. + +Generates a structured resume as Typst source code using the rendercv package. +The LLM outputs Typst markup which is validated via typst.compile() before +persisting. The compiled PDF is served on-demand by the preview endpoint. + +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 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__) + +# ─── Typst / rendercv Reference ────────────────────────────────────────────── +# Embedded in the generation prompt so the LLM knows the exact API. + +_RENDERCV_REFERENCE = """\ +You MUST output valid Typst source code using the rendercv package. +The file MUST start with the import and show rule below. + +```typst +#import "@preview/rendercv:0.3.0": * + +#show: rendercv.with( + name: "Full Name", + section-titles-type: "with_partial_line", +) +``` + +Available components (use ONLY these): + += Full Name // Top-level heading — the person's name +#headline([Job Title or Tagline]) // Subtitle below the name +#connections( // Contact info row + [City, Country], + [#link("mailto:email@example.com")[email\\@example.com]], + [#link("https://github.com/user")[github.com/user]], + [#link("https://linkedin.com/in/user")[linkedin.com/in/user]], +) + +== Section Title // Section heading (Experience, Education, Skills, etc.) + +#regular-entry( // Work experience, projects, publications + [*Role/Title*, Company Name -- Location], + [Start -- End], + main-column-second-row: [ + - Bullet point achievement + - Another achievement + ], +) + +#education-entry( // Education + [*Institution*, Degree in Field -- Location], + [Start -- End], + main-column-second-row: [ + - GPA, honours, relevant coursework + ], +) + +#summary([Short paragraph summary]) // Optional summary/objective +#content-area([Free-form content]) // Freeform text block + +RULES: +- Output ONLY valid Typst code. No explanatory text before or after. +- Do NOT wrap output in ```typst code fences. +- Escape @ symbols inside link labels with a backslash: email\\@example.com +- Every section MUST use == heading. +- Use #regular-entry() for experience, projects, publications, certifications. +- Use #education-entry() for education. +- For skills, use plain bold + text: *Languages:* Python, TypeScript +- Keep content professional, concise, and achievement-oriented. +- Use action verbs for bullet points (Led, Built, Designed, Reduced, etc.). +""" + +# ─── Prompts ───────────────────────────────────────────────────────────────── + +_RESUME_PROMPT = """\ +You are an expert resume writer. Generate a professional resume as Typst source code. + +{rendercv_reference} + +**User Information:** +{user_info} + +{user_instructions_section} + +Generate the complete Typst source file now: +""" + +_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. + +{rendercv_reference} + +**Modification Instructions:** {user_instructions} + +**EXISTING RESUME (Typst source):** + +{previous_content} + +--- + +Output the complete, updated Typst source file with the changes applied: +""" + +_FIX_COMPILE_PROMPT = """\ +The Typst source you generated failed to compile. Fix the error while preserving all content. + +**Compilation Error:** +{error} + +**Your Previous Output:** +{source} + +{rendercv_reference} + +Output the corrected Typst source file: +""" + + +# ─── 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'. + """ + + @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 + + 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"}, + ) + prompt = _REVISION_PROMPT.format( + rendercv_reference=_RENDERCV_REFERENCE, + user_instructions=user_instructions or "Improve and refine the resume.", + previous_content=parent_content, + ) + else: + dispatch_custom_event( + "report_progress", + {"phase": "writing", "message": "Building your resume"}, + ) + prompt = _RESUME_PROMPT.format( + rendercv_reference=_RENDERCV_REFERENCE, + user_info=user_info, + user_instructions_section=user_instructions_section, + ) + + response = await llm.ainvoke([HumanMessage(content=prompt)]) + typst_source = response.content + + if not typst_source or not isinstance(typst_source, 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", + } + + typst_source = _strip_typst_fences(typst_source) + + # ── Phase 3: COMPILE-VALIDATE-RETRY ─────────────────────────── + # Attempt 1 + dispatch_custom_event( + "report_progress", + {"phase": "compiling", "message": "Compiling resume..."}, + ) + + 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( + error=compile_error, + source=typst_source, + rendercv_reference=_RENDERCV_REFERENCE, + ) + fix_response = await llm.ainvoke( + [HumanMessage(content=fix_prompt)] + ) + if fix_response.content and isinstance(fix_response.content, str): + typst_source = _strip_typst_fences(fix_response.content) + + 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"}, + ) + + # Extract a title from the Typst source (the = heading) + title_match = re.search(r"^=\s+(.+)$", typst_source, re.MULTILINE) + resume_title = title_match.group(1).strip() if title_match 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 From 07bd076317bb75c35136968a5ac1b84fde977b7b Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Wed, 15 Apr 2026 21:13:56 +0530 Subject: [PATCH 07/22] feat: add content type support for reports and include new resume generation tool in dashboard --- surfsense_web/.npmrc | 1 + .../[search_space_id]/new-chat/[[...chat_id]]/page.tsx | 1 + surfsense_web/atoms/chat/report-panel.atom.ts | 7 ++++++- 3 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 surfsense_web/.npmrc diff --git a/surfsense_web/.npmrc b/surfsense_web/.npmrc new file mode 100644 index 000000000..150cdf725 --- /dev/null +++ b/surfsense_web/.npmrc @@ -0,0 +1 @@ +public-hoist-pattern[]=pdfjs-dist 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..60de50d67 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,8 @@ 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 +53,7 @@ export const openReportPanelAtom = atom( title, wordCount: wordCount ?? null, shareToken: shareToken ?? null, + contentType: contentType ?? "markdown", }); set(rightPanelTabAtom, "report"); set(rightPanelCollapsedAtom, false); From 06c344d66e60ec04a746f637039529b48176e6dc Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Wed, 15 Apr 2026 21:42:50 +0530 Subject: [PATCH 08/22] feat: implement PDF viewer for resume previews and integrate with report panel --- .../app/agents/new_chat/tools/resume.py | 5 +- .../assistant-ui/assistant-message.tsx | 8 + .../components/report-panel/pdf-viewer.tsx | 140 ++++++++++++++++++ .../components/report-panel/report-panel.tsx | 11 ++ .../components/shared/ExportMenuItems.tsx | 12 ++ 5 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 surfsense_web/components/report-panel/pdf-viewer.tsx diff --git a/surfsense_backend/app/agents/new_chat/tools/resume.py b/surfsense_backend/app/agents/new_chat/tools/resume.py index 8295dd1b5..087cb5e26 100644 --- a/surfsense_backend/app/agents/new_chat/tools/resume.py +++ b/surfsense_backend/app/agents/new_chat/tools/resume.py @@ -362,9 +362,10 @@ def create_generate_resume_tool( {"phase": "saving", "message": "Saving your resume"}, ) - # Extract a title from the Typst source (the = heading) + # Extract a title from the Typst source (the = heading is the person's name) title_match = re.search(r"^=\s+(.+)$", typst_source, re.MULTILINE) - resume_title = title_match.group(1).strip() if title_match else "Resume" + name = title_match.group(1).strip() if title_match else None + resume_title = f"{name} - Resume" if name else "Resume" metadata: dict[str, Any] = { "status": "ready", diff --git a/surfsense_web/components/assistant-ui/assistant-message.tsx b/surfsense_web/components/assistant-ui/assistant-message.tsx index f159d42d2..fd1f6518b 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, 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..eb79431be --- /dev/null +++ b/surfsense_web/components/report-panel/pdf-viewer.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { ChevronLeftIcon, ChevronRightIcon, ZoomInIcon, ZoomOutIcon } from "lucide-react"; +import { useCallback, useMemo, useRef, useState } from "react"; +import { Document, Page, pdfjs } from "react-pdf"; +import "react-pdf/dist/Page/AnnotationLayer.css"; +import "react-pdf/dist/Page/TextLayer.css"; +import { Button } from "@/components/ui/button"; +import { getAuthHeaders } from "@/lib/auth-utils"; + +pdfjs.GlobalWorkerOptions.workerSrc = new URL( + "pdfjs-dist/build/pdf.worker.min.mjs", + import.meta.url +).toString(); + +interface PdfViewerProps { + pdfUrl: string; +} + +const ZOOM_STEP = 0.15; +const MIN_ZOOM = 0.5; +const MAX_ZOOM = 3; + +export function PdfViewer({ pdfUrl }: PdfViewerProps) { + const [numPages, setNumPages] = useState(0); + const [pageNumber, setPageNumber] = useState(1); + const [scale, setScale] = useState(1); + const [loadError, setLoadError] = useState(null); + const containerRef = useRef(null); + + const documentOptions = useMemo(() => ({ httpHeaders: getAuthHeaders() }), []); + + const onDocumentLoadSuccess = useCallback(({ numPages }: { numPages: number }) => { + setNumPages(numPages); + setPageNumber(1); + setLoadError(null); + }, []); + + const onDocumentLoadError = useCallback((error: Error) => { + setLoadError(error.message || "Failed to load PDF"); + }, []); + + const goToPrevPage = useCallback(() => { + setPageNumber((prev) => Math.max(1, prev - 1)); + }, []); + + const goToNextPage = useCallback(() => { + setPageNumber((prev) => Math.min(numPages, prev + 1)); + }, [numPages]); + + const zoomIn = useCallback(() => { + setScale((prev) => Math.min(MAX_ZOOM, prev + ZOOM_STEP)); + }, []); + + const zoomOut = useCallback(() => { + setScale((prev) => Math.max(MIN_ZOOM, prev - ZOOM_STEP)); + }, []); + + if (loadError) { + return ( +
+

Failed to load resume preview

+

{loadError}

+
+ ); + } + + return ( +
+ {/* Controls bar */} + {numPages > 0 && ( +
+ {numPages > 1 && ( + <> + + + {pageNumber} / {numPages} + + +
+ + )} + + + {Math.round(scale * 100)}% + + +
+ )} + + {/* PDF content */} +
+ +
+
+ } + > + + Failed to render page {pageNumber} +
+ } + /> + +
+
+ ); +} diff --git a/surfsense_web/components/report-panel/report-panel.tsx b/surfsense_web/components/report-panel/report-panel.tsx index 6ec2a08eb..de1527a77 100644 --- a/surfsense_web/components/report-panel/report-panel.tsx +++ b/surfsense_web/components/report-panel/report-panel.tsx @@ -53,6 +53,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 +73,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(), @@ -318,6 +324,7 @@ export function ReportPanelContent({ onExport={handleExport} exporting={exporting} showAllFormats={!shareToken} + pdfOnly={reportContent?.content_type === "typst"} /> @@ -371,6 +378,10 @@ export function ReportPanelContent({

{error || "An unknown error occurred"}

+ ) : reportContent.content_type === "typst" ? ( + ) : reportContent.content ? ( isReadOnly ? (
diff --git a/surfsense_web/components/shared/ExportMenuItems.tsx b/surfsense_web/components/shared/ExportMenuItems.tsx index 86bc12c42..3f90bed77 100644 --- a/surfsense_web/components/shared/ExportMenuItems.tsx +++ b/surfsense_web/components/shared/ExportMenuItems.tsx @@ -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 ( + + {exporting === "pdf" && } + PDF (.pdf) + + ); + } + return ( <> {showAllFormats && ( From 45752a7e730b486636f7029bde3d7f92322f8522 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Wed, 15 Apr 2026 21:43:00 +0530 Subject: [PATCH 09/22] feat: add GenerateResumeToolUI component for managing resume generation states and display --- .../components/tool-ui/generate-resume.tsx | 233 ++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 surfsense_web/components/tool-ui/generate-resume.tsx 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..ffd21b07b --- /dev/null +++ b/surfsense_web/components/tool-ui/generate-resume.tsx @@ -0,0 +1,233 @@ +"use client"; + +import type { ToolCallMessagePartProps } from "@assistant-ui/react"; +import { useAtomValue, useSetAtom } from "jotai"; +import { FileTextIcon } from "lucide-react"; +import { useParams, usePathname } from "next/navigation"; +import { 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"; + +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}

+

{error}

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

Resume Cancelled

+
+

Resume generation was cancelled

+
+
+ ); +} + +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 [pdfThumbnailUrl, setPdfThumbnailUrl] = useState(null); + + useEffect(() => { + setPdfThumbnailUrl( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/reports/${reportId}/preview` + ); + + if (autoOpen && isDesktop && !autoOpenedRef.current) { + autoOpenedRef.current = true; + openPanel({ + reportId, + title, + shareToken, + contentType: "typst", + }); + } + }, [reportId, title, shareToken, autoOpen, isDesktop, openPanel]); + + const isActive = panelState.isOpen && panelState.reportId === reportId; + + const handleOpen = () => { + openPanel({ + reportId, + title, + shareToken, + contentType: "typst", + }); + }; + + return ( +
+ {/* biome-ignore lint/a11y/useSemanticElements: nested interactive content */} +
{ + 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" + > +
+
+ +

{title}

+
+

Resume • Click to preview

+
+ +
+ +
+ {pdfThumbnailUrl ? ( +
+ + PDF Resume ready — click to view +
+ ) : ( +
+
+
+
+
+ )} +
+
+
+ ); +} + +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 ; +}; From 4d577a20a9469c687fb56e816b55faed62e9effd Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Wed, 15 Apr 2026 21:56:18 +0530 Subject: [PATCH 10/22] refactor: update PDF viewer and report card components for improved accessibility and UI consistency --- .../components/report-panel/pdf-viewer.tsx | 28 +++++++++---------- .../components/tool-ui/generate-report.tsx | 20 ++++--------- .../components/tool-ui/generate-resume.tsx | 25 +++++------------ 3 files changed, 27 insertions(+), 46 deletions(-) diff --git a/surfsense_web/components/report-panel/pdf-viewer.tsx b/surfsense_web/components/report-panel/pdf-viewer.tsx index eb79431be..d161ed27c 100644 --- a/surfsense_web/components/report-panel/pdf-viewer.tsx +++ b/surfsense_web/components/report-panel/pdf-viewer.tsx @@ -1,11 +1,12 @@ "use client"; import { ChevronLeftIcon, ChevronRightIcon, ZoomInIcon, ZoomOutIcon } from "lucide-react"; -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import { Document, Page, pdfjs } from "react-pdf"; import "react-pdf/dist/Page/AnnotationLayer.css"; import "react-pdf/dist/Page/TextLayer.css"; import { Button } from "@/components/ui/button"; +import { Spinner } from "@/components/ui/spinner"; import { getAuthHeaders } from "@/lib/auth-utils"; pdfjs.GlobalWorkerOptions.workerSrc = new URL( @@ -27,8 +28,7 @@ export function PdfViewer({ pdfUrl }: PdfViewerProps) { const [scale, setScale] = useState(1); const [loadError, setLoadError] = useState(null); const containerRef = useRef(null); - - const documentOptions = useMemo(() => ({ httpHeaders: getAuthHeaders() }), []); + const documentOptionsRef = useRef({ httpHeaders: getAuthHeaders() }); const onDocumentLoadSuccess = useCallback(({ numPages }: { numPages: number }) => { setNumPages(numPages); @@ -109,17 +109,17 @@ export function PdfViewer({ pdfUrl }: PdfViewerProps) { )} {/* PDF content */} -
- -
-
- } +
+ + +
+ } > - {/* biome-ignore lint/a11y/useSemanticElements: can't use
+
); } diff --git a/surfsense_web/components/tool-ui/generate-resume.tsx b/surfsense_web/components/tool-ui/generate-resume.tsx index ffd21b07b..a2c1aa5c4 100644 --- a/surfsense_web/components/tool-ui/generate-resume.tsx +++ b/surfsense_web/components/tool-ui/generate-resume.tsx @@ -56,7 +56,6 @@ function ResumeErrorState({ title, error }: { title: string; error: string }) {
-

Resume Generation Failed

@@ -74,7 +73,6 @@ function ResumeCancelledState() {
-

Resume Cancelled

Resume generation was cancelled

@@ -131,22 +129,13 @@ function ResumeCard({
- {/* biome-ignore lint/a11y/useSemanticElements: nested interactive content */} -
{ - 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" - > +
+
); } From e3d9ab12636ac70615da21a5dc1cd3a1181aac92 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Wed, 15 Apr 2026 22:00:46 +0530 Subject: [PATCH 11/22] fix: update example user information in resume generation tool prompts for consistency --- surfsense_backend/app/agents/new_chat/system_prompt.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/surfsense_backend/app/agents/new_chat/system_prompt.py b/surfsense_backend/app/agents/new_chat/system_prompt.py index ffc96554a..252e133cb 100644 --- a/surfsense_backend/app/agents/new_chat/system_prompt.py +++ b/surfsense_backend/app/agents/new_chat/system_prompt.py @@ -464,8 +464,8 @@ _TOOL_INSTRUCTIONS["generate_resume"] = """ """ _TOOL_EXAMPLES["generate_resume"] = """ -- User: "Build me a resume. I'm Anish Sarkar, software engineer at SurfSense..." - - Call: `generate_resume(user_info="Anish Sarkar, software engineer at SurfSense...")` +- 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]")` From 2f58b14440b495f0d60fd7e10264509af12e0abb Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Wed, 15 Apr 2026 23:08:19 +0530 Subject: [PATCH 12/22] fix: enhance error handling and loading states in report panel and resume generation components --- .../components/report-panel/report-panel.tsx | 2 +- .../components/tool-ui/generate-resume.tsx | 93 ++++++++++++++----- 2 files changed, 70 insertions(+), 25 deletions(-) diff --git a/surfsense_web/components/report-panel/report-panel.tsx b/surfsense_web/components/report-panel/report-panel.tsx index de1527a77..582f341c2 100644 --- a/surfsense_web/components/report-panel/report-panel.tsx +++ b/surfsense_web/components/report-panel/report-panel.tsx @@ -372,7 +372,7 @@ export function ReportPanelContent({ {isLoading ? ( ) : error || !reportContent ? ( -
+

Failed to load report

{error || "An unknown error occurred"}

diff --git a/surfsense_web/components/tool-ui/generate-resume.tsx b/surfsense_web/components/tool-ui/generate-resume.tsx index a2c1aa5c4..d1ea95ea9 100644 --- a/surfsense_web/components/tool-ui/generate-resume.tsx +++ b/surfsense_web/components/tool-ui/generate-resume.tsx @@ -2,13 +2,22 @@ import type { ToolCallMessagePartProps } from "@assistant-ui/react"; import { useAtomValue, useSetAtom } from "jotai"; -import { FileTextIcon } from "lucide-react"; import { useParams, usePathname } from "next/navigation"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { Document, Page, pdfjs } from "react-pdf"; +import "react-pdf/dist/Page/AnnotationLayer.css"; +import "react-pdf/dist/Page/TextLayer.css"; import { z } from "zod"; import { openReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom"; import { TextShimmerLoader } from "@/components/prompt-kit/loader"; +import { Spinner } from "@/components/ui/spinner"; import { useMediaQuery } from "@/hooks/use-media-query"; +import { getAuthHeaders } from "@/lib/auth-utils"; + +pdfjs.GlobalWorkerOptions.workerSrc = new URL( + "pdfjs-dist/build/pdf.worker.min.mjs", + import.meta.url +).toString(); const GenerateResumeArgsSchema = z.object({ user_info: z.string(), @@ -35,7 +44,7 @@ function ResumeGeneratingState() {

Resume

- +
@@ -96,10 +105,13 @@ function ResumeCard({ const panelState = useAtomValue(reportPanelAtom); const isDesktop = useMediaQuery("(min-width: 768px)"); const autoOpenedRef = useRef(false); - const [pdfThumbnailUrl, setPdfThumbnailUrl] = useState(null); + const [pdfUrl, setPdfUrl] = useState(null); + const [pdfReady, setPdfReady] = useState(false); + const [pdfError, setPdfError] = useState(false); + const documentOptionsRef = useRef({ httpHeaders: getAuthHeaders() }); useEffect(() => { - setPdfThumbnailUrl( + setPdfUrl( `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/reports/${reportId}/preview` ); @@ -114,6 +126,15 @@ function ResumeCard({ } }, [reportId, title, shareToken, autoOpen, isDesktop, openPanel]); + const onPdfLoadSuccess = useCallback(() => { + setPdfReady(true); + }, []); + + const onPdfLoadError = useCallback(() => { + setPdfReady(true); + setPdfError(true); + }, []); + const isActive = panelState.isOpen && panelState.reportId === reportId; const handleOpen = () => { @@ -129,31 +150,55 @@ function ResumeCard({
- - {pageNumber} / {numPages} + {currentPage} / {numPages} -
)} @@ -108,32 +213,29 @@ export function PdfViewer({ pdfUrl }: PdfViewerProps) {
)} - {/* PDF content */} -
- - -
- } - > - - Failed to render page {pageNumber} -
- } - /> - +
+ {loading ? ( +
+ +
+ ) : ( +
+ {Array.from({ length: numPages }, (_, i) => i + 1).map((pageNum) => ( + setCanvasRef(pageNum, el)} + className="shadow-lg" + /> + ))} +
+ )}
); diff --git a/surfsense_web/components/tool-ui/generate-resume.tsx b/surfsense_web/components/tool-ui/generate-resume.tsx index d1ea95ea9..d540c7d18 100644 --- a/surfsense_web/components/tool-ui/generate-resume.tsx +++ b/surfsense_web/components/tool-ui/generate-resume.tsx @@ -3,10 +3,8 @@ import type { ToolCallMessagePartProps } from "@assistant-ui/react"; import { useAtomValue, useSetAtom } from "jotai"; import { useParams, usePathname } from "next/navigation"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { Document, Page, pdfjs } from "react-pdf"; -import "react-pdf/dist/Page/AnnotationLayer.css"; -import "react-pdf/dist/Page/TextLayer.css"; +import { useEffect, useRef, useState } from "react"; +import * as pdfjsLib from "pdfjs-dist"; import { z } from "zod"; import { openReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom"; import { TextShimmerLoader } from "@/components/prompt-kit/loader"; @@ -14,7 +12,7 @@ import { Spinner } from "@/components/ui/spinner"; import { useMediaQuery } from "@/hooks/use-media-query"; import { getAuthHeaders } from "@/lib/auth-utils"; -pdfjs.GlobalWorkerOptions.workerSrc = new URL( +pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( "pdfjs-dist/build/pdf.worker.min.mjs", import.meta.url ).toString(); @@ -90,6 +88,80 @@ function ResumeCancelledState() { ); } +function PdfThumbnail({ pdfUrl }: { pdfUrl: string }) { + const canvasRef = useRef(null); + const [loading, setLoading] = useState(true); + const [error, setError] = 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 = canvas.parentElement?.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.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`; + + await page.render({ + canvas, + viewport, + transform: dpr !== 1 ? [dpr, 0, 0, dpr, 0, 0] : undefined, + }).promise; + if (!cancelled) setLoading(false); + + pdf.destroy(); + } catch { + if (!cancelled) { + setError(true); + setLoading(false); + } + } + }; + + renderThumbnail(); + return () => { cancelled = true; }; + }, [pdfUrl]); + + if (error) { + return

Preview unavailable

; + } + + return ( + <> + {loading && ( +
+ +
+ )} + + + ); +} + function ResumeCard({ reportId, title, @@ -106,9 +178,6 @@ function ResumeCard({ const isDesktop = useMediaQuery("(min-width: 768px)"); const autoOpenedRef = useRef(false); const [pdfUrl, setPdfUrl] = useState(null); - const [pdfReady, setPdfReady] = useState(false); - const [pdfError, setPdfError] = useState(false); - const documentOptionsRef = useRef({ httpHeaders: getAuthHeaders() }); useEffect(() => { setPdfUrl( @@ -126,15 +195,6 @@ function ResumeCard({ } }, [reportId, title, shareToken, autoOpen, isDesktop, openPanel]); - const onPdfLoadSuccess = useCallback(() => { - setPdfReady(true); - }, []); - - const onPdfLoadError = useCallback(() => { - setPdfReady(true); - setPdfError(true); - }, []); - const isActive = panelState.isOpen && panelState.reportId === reportId; const handleOpen = () => { @@ -163,39 +223,18 @@ function ResumeCard({
- {pdfUrl && !pdfError ? ( -
-
- - -
- } - > - {pdfReady && ( - - )} - + {pdfUrl ? ( +
+
+ +
-
- ) : pdfError ? ( -

Preview unavailable

) : (
From 6037058a09a599e78c654aaf8bf9127b2aee48cf Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Thu, 16 Apr 2026 11:45:33 +0530 Subject: [PATCH 16/22] refactor: enhance PDF thumbnail loading and error handling in resume component, introducing skeleton loader and improved state management --- .../components/tool-ui/generate-resume.tsx | 75 ++++++++++--------- 1 file changed, 40 insertions(+), 35 deletions(-) diff --git a/surfsense_web/components/tool-ui/generate-resume.tsx b/surfsense_web/components/tool-ui/generate-resume.tsx index d540c7d18..cc00ce3e4 100644 --- a/surfsense_web/components/tool-ui/generate-resume.tsx +++ b/surfsense_web/components/tool-ui/generate-resume.tsx @@ -3,12 +3,11 @@ import type { ToolCallMessagePartProps } from "@assistant-ui/react"; import { useAtomValue, useSetAtom } from "jotai"; import { useParams, usePathname } from "next/navigation"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import * as pdfjsLib from "pdfjs-dist"; import { z } from "zod"; import { openReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom"; import { TextShimmerLoader } from "@/components/prompt-kit/loader"; -import { Spinner } from "@/components/ui/spinner"; import { useMediaQuery } from "@/hooks/use-media-query"; import { getAuthHeaders } from "@/lib/auth-utils"; @@ -88,10 +87,22 @@ function ResumeCancelledState() { ); } -function PdfThumbnail({ pdfUrl }: { pdfUrl: string }) { +function ThumbnailSkeleton() { + return ( +
+
+
+
+
+
+
+ ); +} + +function PdfThumbnail({ pdfUrl, onLoad, onError }: { pdfUrl: string; onLoad: () => void; onError: () => void }) { + const wrapperRef = useRef(null); const canvasRef = useRef(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(false); + const [ready, setReady] = useState(false); useEffect(() => { let cancelled = false; @@ -112,53 +123,43 @@ function PdfThumbnail({ pdfUrl }: { pdfUrl: string }) { const canvas = canvasRef.current; if (!canvas) { pdf.destroy(); return; } - const containerWidth = canvas.parentElement?.clientWidth || 400; + 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.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`; + 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) setLoading(false); + + if (!cancelled) { + setReady(true); + onLoad(); + } pdf.destroy(); } catch { - if (!cancelled) { - setError(true); - setLoading(false); - } + if (!cancelled) onError(); } }; renderThumbnail(); return () => { cancelled = true; }; - }, [pdfUrl]); - - if (error) { - return

Preview unavailable

; - } + }, [pdfUrl, onLoad, onError]); return ( - <> - {loading && ( -
- -
- )} +
- +
); } @@ -178,6 +179,7 @@ function ResumeCard({ const isDesktop = useMediaQuery("(min-width: 768px)"); const autoOpenedRef = useRef(false); const [pdfUrl, setPdfUrl] = useState(null); + const [thumbState, setThumbState] = useState<"loading" | "ready" | "error">("loading"); useEffect(() => { setPdfUrl( @@ -195,6 +197,9 @@ function ResumeCard({ } }, [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 = () => { @@ -223,22 +228,22 @@ function ResumeCard({
- {pdfUrl ? ( + {thumbState === "loading" && } + {thumbState === "error" && ( +

Preview unavailable

+ )} + {pdfUrl && (
- +
- ) : ( -
- -
)}
From e2cd0557a530c3c307d2cd4f3539d8602a3576b7 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Thu, 16 Apr 2026 22:51:36 +0530 Subject: [PATCH 17/22] feat: add public report PDF preview endpoint and update report content handling for Typst-based resumes --- .../app/agents/new_chat/system_prompt.py | 2 +- .../app/routes/public_chat_routes.py | 52 +++++++++++++++++++ .../app/services/public_chat_service.py | 5 +- .../components/public-chat/public-thread.tsx | 2 + .../components/report-panel/report-panel.tsx | 6 +-- .../components/tool-ui/generate-resume.tsx | 7 +-- 6 files changed, 65 insertions(+), 9 deletions(-) diff --git a/surfsense_backend/app/agents/new_chat/system_prompt.py b/surfsense_backend/app/agents/new_chat/system_prompt.py index 320626d54..b7b3d6b33 100644 --- a/surfsense_backend/app/agents/new_chat/system_prompt.py +++ b/surfsense_backend/app/agents/new_chat/system_prompt.py @@ -466,7 +466,7 @@ _TOOL_INSTRUCTIONS["generate_resume"] = """ - 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. + - 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. """ 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/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_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/report-panel.tsx b/surfsense_web/components/report-panel/report-panel.tsx index 582f341c2..80f4ea759 100644 --- a/surfsense_web/components/report-panel/report-panel.tsx +++ b/surfsense_web/components/report-panel/report-panel.tsx @@ -379,9 +379,9 @@ export function ReportPanelContent({
) : reportContent.content_type === "typst" ? ( - + ) : reportContent.content ? ( isReadOnly ? (
diff --git a/surfsense_web/components/tool-ui/generate-resume.tsx b/surfsense_web/components/tool-ui/generate-resume.tsx index cc00ce3e4..8718ef9fa 100644 --- a/surfsense_web/components/tool-ui/generate-resume.tsx +++ b/surfsense_web/components/tool-ui/generate-resume.tsx @@ -182,9 +182,10 @@ function ResumeCard({ const [thumbState, setThumbState] = useState<"loading" | "ready" | "error">("loading"); useEffect(() => { - setPdfUrl( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/reports/${reportId}/preview` - ); + 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; From 706d5b982115906d0a74b195087fca82070d3ade Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Thu, 16 Apr 2026 22:53:17 +0530 Subject: [PATCH 18/22] feat: enhance PDF viewer and report panel with public access handling and UI updates --- .../components/report-panel/pdf-viewer.tsx | 9 +++-- .../components/report-panel/report-panel.tsx | 37 ++++++++++++------- .../components/tool-ui/generate-report.tsx | 6 ++- .../components/tool-ui/generate-resume.tsx | 8 ++-- 4 files changed, 37 insertions(+), 23 deletions(-) diff --git a/surfsense_web/components/report-panel/pdf-viewer.tsx b/surfsense_web/components/report-panel/pdf-viewer.tsx index 75e56cb37..48fab89cb 100644 --- a/surfsense_web/components/report-panel/pdf-viewer.tsx +++ b/surfsense_web/components/report-panel/pdf-viewer.tsx @@ -15,6 +15,7 @@ pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( interface PdfViewerProps { pdfUrl: string; + isPublic?: boolean; } const ZOOM_STEP = 0.15; @@ -22,7 +23,7 @@ const MIN_ZOOM = 0.5; const MAX_ZOOM = 3; const PAGE_GAP = 12; -export function PdfViewer({ pdfUrl }: PdfViewerProps) { +export function PdfViewer({ pdfUrl, isPublic = false }: PdfViewerProps) { const [numPages, setNumPages] = useState(0); const [scale, setScale] = useState(1); const [loading, setLoading] = useState(true); @@ -192,7 +193,7 @@ export function PdfViewer({ pdfUrl }: PdfViewerProps) { return (
{numPages > 0 && ( -
+
{numPages > 1 && ( <> @@ -215,10 +216,10 @@ export function PdfViewer({ pdfUrl }: PdfViewerProps) {
{loading ? ( -
+
) : ( diff --git a/surfsense_web/components/report-panel/report-panel.tsx b/surfsense_web/components/report-panel/report-panel.tsx index 80f4ea759..3115378b8 100644 --- a/surfsense_web/components/report-panel/report-panel.tsx +++ b/surfsense_web/components/report-panel/report-panel.tsx @@ -286,22 +286,26 @@ 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 */} - + {/* Copy button — hidden for Typst (resume) */} + {reportContent?.content_type !== "typst" && ( + + )} {/* Export dropdown */} @@ -310,7 +314,7 @@ export function ReportPanelContent({ 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 @@ -336,7 +340,7 @@ export function ReportPanelContent({
-

{title}

-

{error}

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

{title}

+ )} +

{error}

); @@ -231,7 +233,7 @@ function ResumeCard({
{thumbState === "loading" && } {thumbState === "error" && ( -

Preview unavailable

+

Preview unavailable

)} {pdfUrl && (
Date: Thu, 16 Apr 2026 23:44:15 +0530 Subject: [PATCH 19/22] feat: optimize PDF viewer performance with improved page rendering and visibility management --- .../components/report-panel/pdf-viewer.tsx | 201 +++++++++++++----- 1 file changed, 148 insertions(+), 53 deletions(-) diff --git a/surfsense_web/components/report-panel/pdf-viewer.tsx b/surfsense_web/components/report-panel/pdf-viewer.tsx index 48fab89cb..828cadc4f 100644 --- a/surfsense_web/components/report-panel/pdf-viewer.tsx +++ b/surfsense_web/components/report-panel/pdf-viewer.tsx @@ -18,24 +18,73 @@ interface PdfViewerProps { 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 [currentPage, setCurrentPage] = useState(1); const scrollContainerRef = useRef(null); - const pagesContainerRef = 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; @@ -71,12 +120,51 @@ export function PdfViewer({ pdfUrl, isPublic = false }: PdfViewerProps) { 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; @@ -84,7 +172,7 @@ export function PdfViewer({ pdfUrl, isPublic = false }: PdfViewerProps) { setLoading(true); setLoadError(null); setNumPages(0); - setCurrentPage(1); + pageDimsRef.current = []; try { const loadingTask = pdfjsLib.getDocument({ @@ -98,7 +186,21 @@ export function PdfViewer({ pdfUrl, isPublic = false }: PdfViewerProps) { 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) { @@ -118,52 +220,42 @@ export function PdfViewer({ pdfUrl, isPublic = false }: PdfViewerProps) { } renderTasksRef.current.clear(); renderedScalesRef.current.clear(); + visiblePagesRef.current.clear(); pdfDocRef.current?.destroy(); pdfDocRef.current = null; }; }, [pdfUrl]); useEffect(() => { - if (!pdfDocRef.current || numPages === 0) return; + if (numPages === 0) return; renderedScalesRef.current.clear(); + visiblePagesRef.current.clear(); - for (let i = 1; i <= numPages; i++) { - renderPage(i, scale); - } - }, [scale, numPages, renderPage]); + const frame = requestAnimationFrame(() => { + renderVisiblePages(); + }); + + return () => cancelAnimationFrame(frame); + }, [numPages, renderVisiblePages]); useEffect(() => { const container = scrollContainerRef.current; - if (!container || numPages <= 1) return; + if (!container || numPages === 0) return; const handleScroll = () => { - const canvases = canvasRefs.current; - const containerTop = container.scrollTop; - const containerMid = containerTop + container.clientHeight / 2; - - let closest = 1; - let closestDist = Number.POSITIVE_INFINITY; - - for (let i = 1; i <= numPages; i++) { - const canvas = canvases.get(i); - if (!canvas) continue; - const rect = canvas.getBoundingClientRect(); - const containerRect = container.getBoundingClientRect(); - const canvasMid = rect.top - containerRect.top + containerTop + rect.height / 2; - const dist = Math.abs(canvasMid - containerMid); - if (dist < closestDist) { - closestDist = dist; - closest = i; - } - } - - setCurrentPage(closest); + if (scrollTimerRef.current) clearTimeout(scrollTimerRef.current); + scrollTimerRef.current = setTimeout(() => { + renderVisiblePages(); + }, SCROLL_DEBOUNCE_MS); }; container.addEventListener("scroll", handleScroll, { passive: true }); - return () => container.removeEventListener("scroll", handleScroll); - }, [numPages]); + return () => { + container.removeEventListener("scroll", handleScroll); + if (scrollTimerRef.current) clearTimeout(scrollTimerRef.current); + }; + }, [numPages, renderVisiblePages]); const setCanvasRef = useCallback((pageNum: number, el: HTMLCanvasElement | null) => { if (el) { @@ -184,7 +276,7 @@ export function PdfViewer({ pdfUrl, isPublic = false }: PdfViewerProps) { if (loadError) { return (
-

Failed to load resume preview

+

Failed to load PDF

{loadError}

); @@ -194,14 +286,6 @@ export function PdfViewer({ pdfUrl, isPublic = false }: PdfViewerProps) {
{numPages > 0 && (
- {numPages > 1 && ( - <> - - {currentPage} / {numPages} - -
- - )} @@ -223,18 +307,29 @@ export function PdfViewer({ pdfUrl, isPublic = false }: PdfViewerProps) {
) : ( -
- {Array.from({ length: numPages }, (_, i) => i + 1).map((pageNum) => ( - setCanvasRef(pageNum, el)} - className="shadow-lg" - /> - ))} +
+ {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} + + )} +
+ ); + })}
)}
From aef4c90107b5acce3d338831a30b7cb799b5f162 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Fri, 17 Apr 2026 09:03:34 +0530 Subject: [PATCH 20/22] refactor: update UI components for improved layout in assistant message, public chat footer, and report panel --- .../assistant-ui/assistant-message.tsx | 2 +- .../public-chat/public-chat-footer.tsx | 2 +- .../components/report-panel/pdf-viewer.tsx | 2 +- .../components/report-panel/report-panel.tsx | 18 +++++++++++++++--- 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/surfsense_web/components/assistant-ui/assistant-message.tsx b/surfsense_web/components/assistant-ui/assistant-message.tsx index fd1f6518b..ef7e217ec 100644 --- a/surfsense_web/components/assistant-ui/assistant-message.tsx +++ b/surfsense_web/components/assistant-ui/assistant-message.tsx @@ -545,7 +545,7 @@ const AssistantMessageInner: FC = () => {
)} -
+
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/report-panel/pdf-viewer.tsx b/surfsense_web/components/report-panel/pdf-viewer.tsx index 828cadc4f..71b880324 100644 --- a/surfsense_web/components/report-panel/pdf-viewer.tsx +++ b/surfsense_web/components/report-panel/pdf-viewer.tsx @@ -285,7 +285,7 @@ export function PdfViewer({ pdfUrl, isPublic = false }: PdfViewerProps) { return (
{numPages > 0 && ( -
+
diff --git a/surfsense_web/components/report-panel/report-panel.tsx b/surfsense_web/components/report-panel/report-panel.tsx index 3115378b8..81f9365a3 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"; @@ -292,7 +293,7 @@ export function ReportPanelContent({ return ( <> {/* Action bar — always visible; buttons are disabled while loading */} -
+
{/* Copy button — hidden for Typst (resume) */} {reportContent?.content_type !== "typst" && ( @@ -307,7 +308,18 @@ export function ReportPanelContent({ )} - {/* Export dropdown */} + {/* Export — plain button for resume (typst), dropdown for others */} + {reportContent?.content_type === "typst" ? ( + + ) : ( {Math.round(scale * 100)}% -
@@ -303,7 +317,9 @@ export function PdfViewer({ pdfUrl, isPublic = false }: PdfViewerProps) { className={`relative flex-1 overflow-auto ${isPublic ? "bg-main-panel" : "bg-sidebar"}`} > {loading ? ( -
+
) : ( diff --git a/surfsense_web/components/report-panel/report-panel.tsx b/surfsense_web/components/report-panel/report-panel.tsx index 81f9365a3..591155757 100644 --- a/surfsense_web/components/report-panel/report-panel.tsx +++ b/surfsense_web/components/report-panel/report-panel.tsx @@ -308,42 +308,42 @@ export function ReportPanelContent({ )} - {/* Export — plain button for resume (typst), dropdown for others */} - {reportContent?.content_type === "typst" ? ( - - ) : ( - - - - - 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" ? : "Download"} + + ) : ( + + + + + + + + + )} {/* Version switcher — only shown when multiple versions exist */} {versions.length > 1 && ( @@ -395,10 +395,10 @@ export function ReportPanelContent({
) : reportContent.content_type === "typst" ? ( - + ) : reportContent.content ? ( isReadOnly ? (
diff --git a/surfsense_web/components/tool-ui/generate-report.tsx b/surfsense_web/components/tool-ui/generate-report.tsx index eed16a6ac..32f97b6a4 100644 --- a/surfsense_web/components/tool-ui/generate-report.tsx +++ b/surfsense_web/components/tool-ui/generate-report.tsx @@ -99,7 +99,9 @@ function ReportErrorState({ title, error }: { title: string; error: string }) { {title && title !== "Report" && (

{title}

)} -

{error}

+

+ {error} +

); @@ -217,11 +219,11 @@ function ReportCard({
-
); @@ -101,7 +103,15 @@ function ThumbnailSkeleton() { ); } -function PdfThumbnail({ pdfUrl, onLoad, onError }: { pdfUrl: string; onLoad: () => void; onError: () => void }) { +function PdfThumbnail({ + pdfUrl, + onLoad, + onError, +}: { + pdfUrl: string; + onLoad: () => void; + onError: () => void; +}) { const wrapperRef = useRef(null); const canvasRef = useRef(null); const [ready, setReady] = useState(false); @@ -117,13 +127,22 @@ function PdfThumbnail({ pdfUrl, onLoad, onError }: { pdfUrl: string; onLoad: () }); const pdf = await loadingTask.promise; - if (cancelled) { pdf.destroy(); return; } + if (cancelled) { + pdf.destroy(); + return; + } const page = await pdf.getPage(1); - if (cancelled) { pdf.destroy(); return; } + if (cancelled) { + pdf.destroy(); + return; + } const canvas = canvasRef.current; - if (!canvas) { pdf.destroy(); return; } + if (!canvas) { + pdf.destroy(); + return; + } const containerWidth = wrapperRef.current?.clientWidth || 400; const unscaledViewport = page.getViewport({ scale: 1 }); @@ -152,15 +171,14 @@ function PdfThumbnail({ pdfUrl, onLoad, onError }: { pdfUrl: string; onLoad: () }; renderThumbnail(); - return () => { cancelled = true; }; + return () => { + cancelled = true; + }; }, [pdfUrl, onLoad, onError]); return (
- +
); } @@ -294,7 +312,9 @@ export const GenerateResumeToolUI = ({ return ( ); }