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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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 ( ); } From b440610e0419089a572259749982697135fdc0f9 Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Sat, 18 Apr 2026 14:35:14 -0700 Subject: [PATCH 23/32] feat: implement analytics tracking for desktop app events - Added event tracking for desktop app activation and quitting. - Introduced analytics bridge in preload script to handle user identification and event capturing. - Updated IPC channels to support analytics-related actions. - Enhanced analytics functionality in the main process to track user interactions and application updates. - Integrated analytics tracking for folder watching and deep link handling. - Improved connector setup tracking in the web application. This commit enhances the overall analytics capabilities of the application, ensuring better user behavior insights and event tracking across both desktop and web environments. --- surfsense_desktop/src/ipc/channels.ts | 5 + surfsense_desktop/src/ipc/handlers.ts | 42 +++++ surfsense_desktop/src/main.ts | 2 + surfsense_desktop/src/modules/analytics.ts | 102 ++++++++++- surfsense_desktop/src/modules/auto-updater.ts | 15 ++ surfsense_desktop/src/modules/deep-links.ts | 5 + .../src/modules/folder-watcher.ts | 18 ++ surfsense_desktop/src/modules/tray.ts | 29 +-- surfsense_desktop/src/preload.ts | 10 ++ .../components/PurchaseHistoryContent.tsx | 133 +++++++++++--- .../constants/connector-constants.ts | 80 +++++++++ .../hooks/use-connector-dialog.ts | 49 +++++- .../components/free-chat/anonymous-chat.tsx | 7 + .../components/free-chat/free-chat-page.tsx | 9 + surfsense_web/contracts/types/stripe.types.ts | 5 +- surfsense_web/instrumentation-client.ts | 66 ++++++- surfsense_web/lib/posthog/events.ts | 166 ++++++++++++++---- surfsense_web/types/window.d.ts | 10 ++ 18 files changed, 673 insertions(+), 80 deletions(-) diff --git a/surfsense_desktop/src/ipc/channels.ts b/surfsense_desktop/src/ipc/channels.ts index 61213eb46..410b924b9 100644 --- a/surfsense_desktop/src/ipc/channels.ts +++ b/surfsense_desktop/src/ipc/channels.ts @@ -43,4 +43,9 @@ export const IPC_CHANNELS = { // Active search space GET_ACTIVE_SEARCH_SPACE: 'search-space:get-active', SET_ACTIVE_SEARCH_SPACE: 'search-space:set-active', + // Analytics (PostHog) bridge: renderer <-> main + ANALYTICS_IDENTIFY: 'analytics:identify', + ANALYTICS_RESET: 'analytics:reset', + ANALYTICS_CAPTURE: 'analytics:capture', + ANALYTICS_GET_CONTEXT: 'analytics:get-context', } as const; diff --git a/surfsense_desktop/src/ipc/handlers.ts b/surfsense_desktop/src/ipc/handlers.ts index afb2ba038..63cce3d01 100644 --- a/surfsense_desktop/src/ipc/handlers.ts +++ b/surfsense_desktop/src/ipc/handlers.ts @@ -28,6 +28,13 @@ import { getActiveSearchSpaceId, setActiveSearchSpaceId } from '../modules/activ import { reregisterQuickAsk } from '../modules/quick-ask'; import { reregisterAutocomplete } from '../modules/autocomplete'; import { reregisterGeneralAssist } from '../modules/tray'; +import { + getDistinctId, + getMachineId, + identifyUser as analyticsIdentify, + resetUser as analyticsReset, + trackEvent, +} from '../modules/analytics'; let authTokens: { bearer: string; refresh: string } | null = null; @@ -131,6 +138,41 @@ export function registerIpcHandlers(): void { if (config.generalAssist) await reregisterGeneralAssist(); if (config.quickAsk) await reregisterQuickAsk(); if (config.autocomplete) await reregisterAutocomplete(); + trackEvent('desktop_shortcut_updated', { + keys: Object.keys(config), + }); return updated; }); + + // Analytics bridge — the renderer (web UI) hands the logged-in user down + // to the main process so desktop-only events are attributed to the same + // PostHog person, not just an anonymous machine ID. + ipcMain.handle( + IPC_CHANNELS.ANALYTICS_IDENTIFY, + (_event, payload: { userId: string; properties?: Record }) => { + if (!payload?.userId) return; + analyticsIdentify(String(payload.userId), payload.properties); + } + ); + + ipcMain.handle(IPC_CHANNELS.ANALYTICS_RESET, () => { + analyticsReset(); + }); + + ipcMain.handle( + IPC_CHANNELS.ANALYTICS_CAPTURE, + (_event, payload: { event: string; properties?: Record }) => { + if (!payload?.event) return; + trackEvent(payload.event, payload.properties); + } + ); + + ipcMain.handle(IPC_CHANNELS.ANALYTICS_GET_CONTEXT, () => { + return { + distinctId: getDistinctId(), + machineId: getMachineId(), + appVersion: app.getVersion(), + platform: process.platform, + }; + }); } diff --git a/surfsense_desktop/src/main.ts b/surfsense_desktop/src/main.ts index 231553f9a..7556a2743 100644 --- a/surfsense_desktop/src/main.ts +++ b/surfsense_desktop/src/main.ts @@ -55,6 +55,7 @@ app.whenReady().then(async () => { app.on('activate', () => { const mw = getMainWindow(); + trackEvent('desktop_app_activated'); if (!mw || mw.isDestroyed()) { createMainWindow('/dashboard'); } else { @@ -71,6 +72,7 @@ app.on('window-all-closed', () => { app.on('before-quit', () => { isQuitting = true; + trackEvent('desktop_app_quit'); }); let didCleanup = false; diff --git a/surfsense_desktop/src/modules/analytics.ts b/surfsense_desktop/src/modules/analytics.ts index 0bbcb3026..01dba60f0 100644 --- a/surfsense_desktop/src/modules/analytics.ts +++ b/surfsense_desktop/src/modules/analytics.ts @@ -3,14 +3,27 @@ import { machineIdSync } from 'node-machine-id'; import { app } from 'electron'; let client: PostHog | null = null; -let distinctId = ''; +let machineId = ''; +let currentDistinctId = ''; +let identifiedUserId: string | null = null; + +function baseProperties(): Record { + return { + platform: 'desktop', + app_version: app.getVersion(), + os: process.platform, + arch: process.arch, + machine_id: machineId, + }; +} export function initAnalytics(): void { const key = process.env.POSTHOG_KEY; if (!key) return; try { - distinctId = machineIdSync(true); + machineId = machineIdSync(true); + currentDistinctId = machineId; } catch { return; } @@ -22,17 +35,92 @@ export function initAnalytics(): void { }); } -export function trackEvent(event: string, properties?: Record): void { +export function getMachineId(): string { + return machineId; +} + +export function getDistinctId(): string { + return currentDistinctId; +} + +/** + * Identify the current logged-in user in PostHog so main-process desktop + * events (and linked anonymous machine events) are attributed to that person. + * + * Idempotent: calling identify repeatedly with the same userId is a no-op. + */ +export function identifyUser( + userId: string, + properties?: Record +): void { + if (!client || !userId) return; + if (identifiedUserId === userId) { + // Already identified — only refresh person properties + try { + client.identify({ + distinctId: userId, + properties: { + ...baseProperties(), + $set: { + ...(properties || {}), + platform: 'desktop', + last_seen_at: new Date().toISOString(), + }, + }, + }); + } catch { + // ignore + } + return; + } + + try { + // Link the anonymous machine distinct ID to the authenticated user + client.identify({ + distinctId: userId, + properties: { + ...baseProperties(), + $anon_distinct_id: machineId, + $set: { + ...(properties || {}), + platform: 'desktop', + last_seen_at: new Date().toISOString(), + }, + $set_once: { + first_seen_platform: 'desktop', + }, + }, + }); + + identifiedUserId = userId; + currentDistinctId = userId; + } catch { + // Analytics must never break the app + } +} + +/** + * Reset user identity on logout. Subsequent events are captured anonymously + * against the machine ID until the user logs in again. + */ +export function resetUser(): void { + if (!client) return; + identifiedUserId = null; + currentDistinctId = machineId; +} + +export function trackEvent( + event: string, + properties?: Record +): void { if (!client) return; try { client.capture({ - distinctId, + distinctId: currentDistinctId || machineId, event, properties: { - platform: 'desktop', - app_version: app.getVersion(), - os: process.platform, + ...baseProperties(), ...properties, }, }); diff --git a/surfsense_desktop/src/modules/auto-updater.ts b/surfsense_desktop/src/modules/auto-updater.ts index 47a85b730..e323abe53 100644 --- a/surfsense_desktop/src/modules/auto-updater.ts +++ b/surfsense_desktop/src/modules/auto-updater.ts @@ -1,4 +1,5 @@ import { app, dialog } from 'electron'; +import { trackEvent } from './analytics'; const SEMVER_RE = /^\d+\.\d+\.\d+/; @@ -17,10 +18,18 @@ export function setupAutoUpdater(): void { autoUpdater.on('update-available', (info: { version: string }) => { console.log(`Update available: ${info.version}`); + trackEvent('desktop_update_available', { + current_version: version, + new_version: info.version, + }); }); autoUpdater.on('update-downloaded', (info: { version: string }) => { console.log(`Update downloaded: ${info.version}`); + trackEvent('desktop_update_downloaded', { + current_version: version, + new_version: info.version, + }); dialog.showMessageBox({ type: 'info', buttons: ['Restart', 'Later'], @@ -29,13 +38,19 @@ export function setupAutoUpdater(): void { message: `Version ${info.version} has been downloaded. Restart to apply the update.`, }).then(({ response }: { response: number }) => { if (response === 0) { + trackEvent('desktop_update_install_accepted', { new_version: info.version }); autoUpdater.quitAndInstall(); + } else { + trackEvent('desktop_update_install_deferred', { new_version: info.version }); } }); }); autoUpdater.on('error', (err: Error) => { console.log('Auto-updater: update check skipped —', err.message?.split('\n')[0]); + trackEvent('desktop_update_error', { + message: err.message?.split('\n')[0], + }); }); autoUpdater.checkForUpdates().catch(() => {}); diff --git a/surfsense_desktop/src/modules/deep-links.ts b/surfsense_desktop/src/modules/deep-links.ts index 1a2b08395..bfd35bbaf 100644 --- a/surfsense_desktop/src/modules/deep-links.ts +++ b/surfsense_desktop/src/modules/deep-links.ts @@ -2,6 +2,7 @@ import { app } from 'electron'; import path from 'path'; import { getMainWindow } from './window'; import { getServerPort } from './server'; +import { trackEvent } from './analytics'; const PROTOCOL = 'surfsense'; @@ -16,6 +17,10 @@ function handleDeepLink(url: string) { if (!win) return; const parsed = new URL(url); + trackEvent('desktop_deep_link_received', { + host: parsed.hostname, + path: parsed.pathname, + }); if (parsed.hostname === 'auth' && parsed.pathname === '/callback') { const params = parsed.searchParams.toString(); win.loadURL(`http://localhost:${getServerPort()}/auth/callback?${params}`); diff --git a/surfsense_desktop/src/modules/folder-watcher.ts b/surfsense_desktop/src/modules/folder-watcher.ts index 96b490d7b..ee4214d8a 100644 --- a/surfsense_desktop/src/modules/folder-watcher.ts +++ b/surfsense_desktop/src/modules/folder-watcher.ts @@ -4,6 +4,7 @@ import { randomUUID } from 'crypto'; import * as path from 'path'; import * as fs from 'fs'; import { IPC_CHANNELS } from '../ipc/channels'; +import { trackEvent } from './analytics'; export interface WatchedFolderConfig { path: string; @@ -401,6 +402,15 @@ export async function addWatchedFolder( await startWatcher(config); } + trackEvent('desktop_folder_watch_added', { + search_space_id: config.searchSpaceId, + root_folder_id: config.rootFolderId, + active: config.active, + has_exclude_patterns: (config.excludePatterns?.length ?? 0) > 0, + has_extension_filter: !!config.fileExtensions && config.fileExtensions.length > 0, + is_update: existing >= 0, + }); + return folders; } @@ -409,6 +419,7 @@ export async function removeWatchedFolder( ): Promise { const s = await getStore(); const folders: WatchedFolderConfig[] = s.get(STORE_KEY, []); + const removed = folders.find((f: WatchedFolderConfig) => f.path === folderPath); const updated = folders.filter((f: WatchedFolderConfig) => f.path !== folderPath); s.set(STORE_KEY, updated); @@ -418,6 +429,13 @@ export async function removeWatchedFolder( const ms = await getMtimeStore(); ms.delete(folderPath); + if (removed) { + trackEvent('desktop_folder_watch_removed', { + search_space_id: removed.searchSpaceId, + root_folder_id: removed.rootFolderId, + }); + } + return updated; } diff --git a/surfsense_desktop/src/modules/tray.ts b/surfsense_desktop/src/modules/tray.ts index 1749145a1..88444cc54 100644 --- a/surfsense_desktop/src/modules/tray.ts +++ b/surfsense_desktop/src/modules/tray.ts @@ -2,6 +2,7 @@ import { app, globalShortcut, Menu, nativeImage, Tray } from 'electron'; import path from 'path'; import { getMainWindow, createMainWindow } from './window'; import { getShortcuts } from './shortcuts'; +import { trackEvent } from './analytics'; let tray: Tray | null = null; let currentShortcut: string | null = null; @@ -15,14 +16,16 @@ function getTrayIcon(): nativeImage { return img.resize({ width: 16, height: 16 }); } -function showMainWindow(): void { - let win = getMainWindow(); - if (!win || win.isDestroyed()) { - win = createMainWindow('/dashboard'); +function showMainWindow(source: 'tray_click' | 'tray_menu' | 'shortcut' = 'tray_click'): void { + const existing = getMainWindow(); + const reopened = !existing || existing.isDestroyed(); + if (reopened) { + createMainWindow('/dashboard'); } else { - win.show(); - win.focus(); + existing.show(); + existing.focus(); } + trackEvent('desktop_main_window_shown', { source, reopened }); } function registerShortcut(accelerator: string): void { @@ -32,7 +35,7 @@ function registerShortcut(accelerator: string): void { } if (!accelerator) return; try { - const ok = globalShortcut.register(accelerator, showMainWindow); + const ok = globalShortcut.register(accelerator, () => showMainWindow('shortcut')); if (ok) { currentShortcut = accelerator; } else { @@ -50,13 +53,19 @@ export async function createTray(): Promise { tray.setToolTip('SurfSense'); const contextMenu = Menu.buildFromTemplate([ - { label: 'Open SurfSense', click: showMainWindow }, + { label: 'Open SurfSense', click: () => showMainWindow('tray_menu') }, { type: 'separator' }, - { label: 'Quit', click: () => { app.exit(0); } }, + { + label: 'Quit', + click: () => { + trackEvent('desktop_tray_quit_clicked'); + app.exit(0); + }, + }, ]); tray.setContextMenu(contextMenu); - tray.on('double-click', showMainWindow); + tray.on('double-click', () => showMainWindow('tray_click')); const shortcuts = await getShortcuts(); registerShortcut(shortcuts.generalAssist); diff --git a/surfsense_desktop/src/preload.ts b/surfsense_desktop/src/preload.ts index e3d12c5e6..01248e7d0 100644 --- a/surfsense_desktop/src/preload.ts +++ b/surfsense_desktop/src/preload.ts @@ -86,4 +86,14 @@ contextBridge.exposeInMainWorld('electronAPI', { getActiveSearchSpace: () => ipcRenderer.invoke(IPC_CHANNELS.GET_ACTIVE_SEARCH_SPACE), setActiveSearchSpace: (id: string) => ipcRenderer.invoke(IPC_CHANNELS.SET_ACTIVE_SEARCH_SPACE, id), + + // Analytics bridge — lets posthog-js running inside the Next.js renderer + // mirror identify/reset/capture into the Electron main-process PostHog + // client so desktop-only events are attributed to the logged-in user. + analyticsIdentify: (userId: string, properties?: Record) => + ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_IDENTIFY, { userId, properties }), + analyticsReset: () => ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_RESET), + analyticsCapture: (event: string, properties?: Record) => + ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_CAPTURE, { event, properties }), + getAnalyticsContext: () => ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_GET_CONTEXT), }); diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PurchaseHistoryContent.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PurchaseHistoryContent.tsx index 9bc77edff..cb079db70 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PurchaseHistoryContent.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PurchaseHistoryContent.tsx @@ -1,7 +1,8 @@ "use client"; -import { useQuery } from "@tanstack/react-query"; -import { ReceiptText } from "lucide-react"; +import { useQueries } from "@tanstack/react-query"; +import { Coins, FileText, ReceiptText } from "lucide-react"; +import { useMemo } from "react"; import { Badge } from "@/components/ui/badge"; import { Spinner } from "@/components/ui/spinner"; import { @@ -12,10 +13,26 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import type { PagePurchase, PagePurchaseStatus } from "@/contracts/types/stripe.types"; +import type { + PagePurchase, + PagePurchaseStatus, + TokenPurchase, +} from "@/contracts/types/stripe.types"; import { stripeApiService } from "@/lib/apis/stripe-api.service"; import { cn } from "@/lib/utils"; +type PurchaseKind = "pages" | "tokens"; + +type UnifiedPurchase = { + id: string; + kind: PurchaseKind; + created_at: string; + status: PagePurchaseStatus; + granted: number; + amount_total: number | null; + currency: string | null; +}; + const STATUS_STYLES: Record = { completed: { label: "Completed", @@ -31,6 +48,22 @@ const STATUS_STYLES: Record; iconClass: string } +> = { + pages: { + label: "Pages", + icon: FileText, + iconClass: "text-sky-500", + }, + tokens: { + label: "Premium Tokens", + icon: Coins, + iconClass: "text-amber-500", + }, +}; + function formatDate(iso: string): string { return new Date(iso).toLocaleDateString(undefined, { year: "numeric", @@ -39,19 +72,65 @@ function formatDate(iso: string): string { }); } -function formatAmount(purchase: PagePurchase): string { - if (purchase.amount_total == null) return "—"; - const dollars = purchase.amount_total / 100; - const currency = (purchase.currency ?? "usd").toUpperCase(); - return `$${dollars.toFixed(2)} ${currency}`; +function formatAmount(amount: number | null, currency: string | null): string { + if (amount == null) return "—"; + const dollars = amount / 100; + const code = (currency ?? "usd").toUpperCase(); + return `$${dollars.toFixed(2)} ${code}`; +} + +function normalizePagePurchase(p: PagePurchase): UnifiedPurchase { + return { + id: p.id, + kind: "pages", + created_at: p.created_at, + status: p.status, + granted: p.pages_granted, + amount_total: p.amount_total, + currency: p.currency, + }; +} + +function normalizeTokenPurchase(p: TokenPurchase): UnifiedPurchase { + return { + id: p.id, + kind: "tokens", + created_at: p.created_at, + status: p.status, + granted: p.tokens_granted, + amount_total: p.amount_total, + currency: p.currency, + }; } export function PurchaseHistoryContent() { - const { data, isLoading } = useQuery({ - queryKey: ["stripe-purchases"], - queryFn: () => stripeApiService.getPurchases(), + const results = useQueries({ + queries: [ + { + queryKey: ["stripe-purchases"], + queryFn: () => stripeApiService.getPurchases(), + }, + { + queryKey: ["stripe-token-purchases"], + queryFn: () => stripeApiService.getTokenPurchases(), + }, + ], }); + const [pagesQuery, tokensQuery] = results; + const isLoading = pagesQuery.isLoading || tokensQuery.isLoading; + + const purchases = useMemo(() => { + const pagePurchases = pagesQuery.data?.purchases ?? []; + const tokenPurchases = tokensQuery.data?.purchases ?? []; + return [ + ...pagePurchases.map(normalizePagePurchase), + ...tokenPurchases.map(normalizeTokenPurchase), + ].sort( + (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime() + ); + }, [pagesQuery.data, tokensQuery.data]); + if (isLoading) { return (
@@ -60,15 +139,13 @@ export function PurchaseHistoryContent() { ); } - const purchases = data?.purchases ?? []; - if (purchases.length === 0) { return (

No purchases yet

- Your page-pack purchases will appear here after checkout. + Your page and premium token purchases will appear here after checkout.

); @@ -81,25 +158,36 @@ export function PurchaseHistoryContent() { Date - Pages + Type + Granted Amount Status {purchases.map((p) => { - const style = STATUS_STYLES[p.status]; + const statusStyle = STATUS_STYLES[p.status]; + const kind = KIND_META[p.kind]; + const KindIcon = kind.icon; return ( - + {formatDate(p.created_at)} - - {p.pages_granted.toLocaleString()} + +
+ + {kind.label} +
- {formatAmount(p)} + {p.granted.toLocaleString()} + + + {formatAmount(p.amount_total, p.currency)} - {style.label} + + {statusStyle.label} +
); @@ -108,7 +196,8 @@ export function PurchaseHistoryContent() {

- Showing your {purchases.length} most recent purchase{purchases.length !== 1 ? "s" : ""}. + Showing your {purchases.length} most recent purchase + {purchases.length !== 1 ? "s" : ""}.

); diff --git a/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts b/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts index da6885ffe..d430e0f6c 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts @@ -340,5 +340,85 @@ export const AUTO_INDEX_DEFAULTS: Record = { export const AUTO_INDEX_CONNECTOR_TYPES = new Set(Object.keys(AUTO_INDEX_DEFAULTS)); +// ============================================================================ +// CONNECTOR TELEMETRY REGISTRY +// ---------------------------------------------------------------------------- +// Single source of truth for "what does this connector_type look like in +// analytics?". Any connector added to the lists above is automatically +// picked up here, so adding a new integration does NOT require touching +// `lib/posthog/events.ts` or per-connector tracking code. +// ============================================================================ + +export type ConnectorTelemetryGroup = + | "oauth" + | "composio" + | "crawler" + | "other" + | "unknown"; + +export interface ConnectorTelemetryMeta { + connector_type: string; + connector_title: string; + connector_group: ConnectorTelemetryGroup; + is_oauth: boolean; +} + +const CONNECTOR_TELEMETRY_REGISTRY: ReadonlyMap = + (() => { + const map = new Map(); + + for (const c of OAUTH_CONNECTORS) { + map.set(c.connectorType, { + connector_type: c.connectorType, + connector_title: c.title, + connector_group: "oauth", + is_oauth: true, + }); + } + for (const c of COMPOSIO_CONNECTORS) { + map.set(c.connectorType, { + connector_type: c.connectorType, + connector_title: c.title, + connector_group: "composio", + is_oauth: true, + }); + } + for (const c of CRAWLERS) { + map.set(c.connectorType, { + connector_type: c.connectorType, + connector_title: c.title, + connector_group: "crawler", + is_oauth: false, + }); + } + for (const c of OTHER_CONNECTORS) { + map.set(c.connectorType, { + connector_type: c.connectorType, + connector_title: c.title, + connector_group: "other", + is_oauth: false, + }); + } + + return map; + })(); + +/** + * Returns telemetry metadata for a connector_type, or a minimal "unknown" + * record so tracking never no-ops for connectors that exist in the backend + * but were forgotten in the UI registry. + */ +export function getConnectorTelemetryMeta(connectorType: string): ConnectorTelemetryMeta { + const hit = CONNECTOR_TELEMETRY_REGISTRY.get(connectorType); + if (hit) return hit; + + return { + connector_type: connectorType, + connector_title: connectorType, + connector_group: "unknown", + is_oauth: false, + }; +} + // Re-export IndexingConfigState from schemas for backward compatibility export type { IndexingConfigState } from "./connector-popup.schemas"; diff --git a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts index caa85ba2d..7ac903342 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts @@ -24,6 +24,8 @@ import { isSelfHosted } from "@/lib/env-config"; import { trackConnectorConnected, trackConnectorDeleted, + trackConnectorSetupFailure, + trackConnectorSetupStarted, trackIndexWithDateRangeOpened, trackIndexWithDateRangeStarted, trackPeriodicIndexingStarted, @@ -232,10 +234,20 @@ export const useConnectorDialog = () => { if (result.error) { const oauthConnector = result.connector - ? OAUTH_CONNECTORS.find((c) => c.id === result.connector) + ? OAUTH_CONNECTORS.find((c) => c.id === result.connector) || + COMPOSIO_CONNECTORS.find((c) => c.id === result.connector) : null; const name = oauthConnector?.title || "connector"; + if (oauthConnector) { + trackConnectorSetupFailure( + Number(searchSpaceId), + oauthConnector.connectorType, + result.error, + "oauth_callback" + ); + } + if (result.error === "duplicate_account") { toast.error(`This ${name} account is already connected`, { description: "Please use a different account or manage the existing connection.", @@ -348,6 +360,12 @@ export const useConnectorDialog = () => { // Set connecting state immediately to disable button and show spinner setConnectingId(connector.id); + trackConnectorSetupStarted( + Number(searchSpaceId), + connector.connectorType, + "oauth_click" + ); + try { // Check if authEndpoint already has query parameters const separator = connector.authEndpoint.includes("?") ? "&" : "?"; @@ -369,6 +387,12 @@ export const useConnectorDialog = () => { window.location.href = validatedData.auth_url; } catch (error) { console.error(`Error connecting to ${connector.title}:`, error); + trackConnectorSetupFailure( + Number(searchSpaceId), + connector.connectorType, + error instanceof Error ? error.message : "oauth_initiation_failed", + "oauth_init" + ); if (error instanceof Error && error.message.includes("Invalid auth URL")) { toast.error(`Invalid response from ${connector.title} OAuth endpoint`); } else { @@ -392,6 +416,11 @@ export const useConnectorDialog = () => { if (!searchSpaceId) return; setConnectingId("webcrawler-connector"); + trackConnectorSetupStarted( + Number(searchSpaceId), + EnumConnectorName.WEBCRAWLER_CONNECTOR, + "webcrawler_quick_add" + ); try { await createConnector({ data: { @@ -441,6 +470,12 @@ export const useConnectorDialog = () => { } } catch (error) { console.error("Error creating webcrawler connector:", error); + trackConnectorSetupFailure( + Number(searchSpaceId), + EnumConnectorName.WEBCRAWLER_CONNECTOR, + error instanceof Error ? error.message : "webcrawler_create_failed", + "webcrawler_quick_add" + ); toast.error("Failed to create web crawler connector"); } finally { setConnectingId(null); @@ -452,6 +487,12 @@ export const useConnectorDialog = () => { (connectorType: string) => { if (!searchSpaceId) return; + trackConnectorSetupStarted( + Number(searchSpaceId), + connectorType, + "non_oauth_click" + ); + // Handle Obsidian specifically on Desktop & Cloud if (connectorType === EnumConnectorName.OBSIDIAN_CONNECTOR && !selfHosted && isDesktop) { setIsOpen(false); @@ -680,6 +721,12 @@ export const useConnectorDialog = () => { } } catch (error) { console.error("Error creating connector:", error); + trackConnectorSetupFailure( + Number(searchSpaceId), + connectingConnectorType ?? formData.connector_type, + error instanceof Error ? error.message : "connector_create_failed", + "non_oauth_form" + ); toast.error(error instanceof Error ? error.message : "Failed to create connector"); } finally { isCreatingConnectorRef.current = false; diff --git a/surfsense_web/components/free-chat/anonymous-chat.tsx b/surfsense_web/components/free-chat/anonymous-chat.tsx index 1ac6baad4..b286c5316 100644 --- a/surfsense_web/components/free-chat/anonymous-chat.tsx +++ b/surfsense_web/components/free-chat/anonymous-chat.tsx @@ -5,6 +5,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { AnonModel, AnonQuotaResponse } from "@/contracts/types/anonymous-chat.types"; import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service"; import { readSSEStream } from "@/lib/chat/streaming-state"; +import { trackAnonymousChatMessageSent } from "@/lib/posthog/events"; import { cn } from "@/lib/utils"; import { QuotaBar } from "./quota-bar"; import { QuotaWarningBanner } from "./quota-warning-banner"; @@ -61,6 +62,12 @@ export function AnonymousChat({ model }: AnonymousChatProps) { textareaRef.current.style.height = "auto"; } + trackAnonymousChatMessageSent({ + modelSlug: model.seo_slug, + messageLength: trimmed.length, + surface: "free_model_page", + }); + const controller = new AbortController(); abortRef.current = controller; diff --git a/surfsense_web/components/free-chat/free-chat-page.tsx b/surfsense_web/components/free-chat/free-chat-page.tsx index b1d0f6850..b389a8489 100644 --- a/surfsense_web/components/free-chat/free-chat-page.tsx +++ b/surfsense_web/components/free-chat/free-chat-page.tsx @@ -28,6 +28,7 @@ import { updateToolCall, } from "@/lib/chat/streaming-state"; import { BACKEND_URL } from "@/lib/env-config"; +import { trackAnonymousChatMessageSent } from "@/lib/posthog/events"; import { FreeModelSelector } from "./free-model-selector"; import { FreeThread } from "./free-thread"; @@ -206,6 +207,14 @@ export function FreeChatPage() { } if (!userQuery.trim()) return; + trackAnonymousChatMessageSent({ + modelSlug, + messageLength: userQuery.trim().length, + hasUploadedDoc: + anonMode.isAnonymous && anonMode.uploadedDoc !== null ? true : false, + surface: "free_chat_page", + }); + const userMsgId = `msg-user-${Date.now()}`; setMessages((prev) => [ ...prev, diff --git a/surfsense_web/contracts/types/stripe.types.ts b/surfsense_web/contracts/types/stripe.types.ts index c4c6f2d74..c8b017044 100644 --- a/surfsense_web/contracts/types/stripe.types.ts +++ b/surfsense_web/contracts/types/stripe.types.ts @@ -49,6 +49,8 @@ export const tokenStripeStatusResponse = z.object({ premium_tokens_remaining: z.number().default(0), }); +export const tokenPurchaseStatusEnum = pagePurchaseStatusEnum; + export const tokenPurchase = z.object({ id: z.uuid(), stripe_checkout_session_id: z.string(), @@ -57,7 +59,7 @@ export const tokenPurchase = z.object({ tokens_granted: z.number(), amount_total: z.number().nullable(), currency: z.string().nullable(), - status: z.string(), + status: tokenPurchaseStatusEnum, completed_at: z.string().nullable(), created_at: z.string(), }); @@ -75,5 +77,6 @@ export type GetPagePurchasesResponse = z.infer; export type CreateTokenCheckoutSessionRequest = z.infer; export type CreateTokenCheckoutSessionResponse = z.infer; export type TokenStripeStatusResponse = z.infer; +export type TokenPurchaseStatus = z.infer; export type TokenPurchase = z.infer; export type GetTokenPurchasesResponse = z.infer; diff --git a/surfsense_web/instrumentation-client.ts b/surfsense_web/instrumentation-client.ts index dff2e9bfe..3ae97fc0b 100644 --- a/surfsense_web/instrumentation-client.ts +++ b/surfsense_web/instrumentation-client.ts @@ -1,18 +1,65 @@ import posthog from "posthog-js"; -function initPostHog() { +/** + * PostHog initialisation for the Next.js renderer. + * + * The same bundle ships in two contexts: + * 1. A normal browser session on surfsense.com -> platform = "web" + * 2. The Electron desktop app (renders the Next app from localhost) + * -> platform = "desktop" + * + * When running inside Electron we also seed `posthog-js` with the main + * process's machine distinctId so that events fired from both the renderer + * (e.g. `chat_message_sent`, page views) and the Electron main process + * (e.g. `desktop_quick_ask_opened`) share a single PostHog person before + * login, and can be merged into the authenticated user afterwards. + */ + +function isElectron(): boolean { + return typeof window !== "undefined" && !!window.electronAPI; +} + +function currentPlatform(): "desktop" | "web" { + return isElectron() ? "desktop" : "web"; +} + +async function resolveBootstrapDistinctId(): Promise { + if (!isElectron() || !window.electronAPI?.getAnalyticsContext) return undefined; + try { + const ctx = await window.electronAPI.getAnalyticsContext(); + return ctx?.machineId || ctx?.distinctId || undefined; + } catch { + return undefined; + } +} + +async function initPostHog() { try { if (!process.env.NEXT_PUBLIC_POSTHOG_KEY) return; + const platform = currentPlatform(); + const bootstrapDistinctId = await resolveBootstrapDistinctId(); + posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, { api_host: "https://assets.surfsense.com", ui_host: "https://us.posthog.com", defaults: "2026-01-30", capture_pageview: "history_change", capture_pageleave: true, + ...(bootstrapDistinctId + ? { + bootstrap: { + distinctID: bootstrapDistinctId, + isIdentifiedID: false, + }, + } + : {}), before_send: (event) => { if (event?.properties) { - event.properties.platform = "web"; + event.properties.platform = platform; + if (platform === "desktop") { + event.properties.is_desktop = true; + } const params = new URLSearchParams(window.location.search); const ref = params.get("ref"); @@ -30,9 +77,14 @@ function initPostHog() { event.properties.$set = { ...event.properties.$set, - platform: "web", + platform, last_seen_at: new Date().toISOString(), }; + + event.properties.$set_once = { + ...event.properties.$set_once, + first_seen_platform: platform, + }; } return event; }, @@ -51,8 +103,12 @@ if (typeof window !== "undefined") { window.posthog = posthog; if ("requestIdleCallback" in window) { - requestIdleCallback(initPostHog); + requestIdleCallback(() => { + void initPostHog(); + }); } else { - setTimeout(initPostHog, 3500); + setTimeout(() => { + void initPostHog(); + }, 3500); } } diff --git a/surfsense_web/lib/posthog/events.ts b/surfsense_web/lib/posthog/events.ts index 53aaa71b9..34ed3044d 100644 --- a/surfsense_web/lib/posthog/events.ts +++ b/surfsense_web/lib/posthog/events.ts @@ -1,4 +1,5 @@ import posthog from "posthog-js"; +import { getConnectorTelemetryMeta } from "@/components/assistant-ui/connector-popup/constants/connector-constants"; /** * PostHog Analytics Event Definitions @@ -13,8 +14,8 @@ import posthog from "posthog-js"; * - auth: Authentication events * - search_space: Search space management * - document: Document management - * - chat: Chat and messaging - * - connector: External connector events + * - chat: Chat and messaging (authenticated + anonymous) + * - connector: External connector events (all lifecycle stages) * - contact: Contact form events * - settings: Settings changes * - marketing: Marketing/referral tracking @@ -28,6 +29,17 @@ function safeCapture(event: string, properties?: Record) { } } +/** + * Drop undefined values so PostHog doesn't log `"foo": undefined` noise. + */ +function compact>(obj: T): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(obj)) { + if (v !== undefined) out[k] = v; + } + return out; +} + // ============================================ // AUTH EVENTS // ============================================ @@ -127,6 +139,28 @@ export function trackChatError(searchSpaceId: number, chatId: number, error?: st }); } +/** + * Track a message sent from the unauthenticated "free" / anonymous chat + * flow. This is intentionally a separate event from `chat_message_sent` + * so WAU / retention queries on the authenticated event stay clean while + * still giving us visibility into top-of-funnel usage on /free/*. + */ +export function trackAnonymousChatMessageSent(options: { + modelSlug: string; + messageLength?: number; + hasUploadedDoc?: boolean; + webSearchEnabled?: boolean; + surface?: "free_chat_page" | "free_model_page"; +}) { + safeCapture("anonymous_chat_message_sent", { + model_slug: options.modelSlug, + message_length: options.messageLength, + has_uploaded_doc: options.hasUploadedDoc ?? false, + web_search_enabled: options.webSearchEnabled, + surface: options.surface, + }); +} + // ============================================ // DOCUMENT EVENTS // ============================================ @@ -179,37 +213,88 @@ export function trackYouTubeImport(searchSpaceId: number, url: string) { } // ============================================ -// CONNECTOR EVENTS +// CONNECTOR EVENTS (generic lifecycle dispatcher) // ============================================ +// +// All connector events go through `trackConnectorEvent`. The connector's +// human-readable title and its group (oauth/composio/crawler/other) are +// auto-attached from the shared registry in `connector-constants.ts`, so +// adding a new connector to that list is the only change required for it +// to show up correctly in PostHog dashboards. -export function trackConnectorSetupStarted(searchSpaceId: number, connectorType: string) { - safeCapture("connector_setup_started", { - search_space_id: searchSpaceId, - connector_type: connectorType, +export type ConnectorEventStage = + | "setup_started" + | "setup_success" + | "setup_failure" + | "oauth_initiated" + | "connected" + | "deleted" + | "synced"; + +export interface ConnectorEventOptions { + searchSpaceId?: number | null; + connectorId?: number | null; + /** Source of the action (e.g. "oauth_callback", "non_oauth_form", "webcrawler_quick_add"). */ + source?: string; + /** Free-form error message for failure events. */ + error?: string; + /** Extra properties specific to the stage (e.g. frequency_minutes for sync events). */ + extra?: Record; +} + +/** + * Generic connector lifecycle tracker. Every connector analytics event + * should funnel through here so the enrichment stays consistent. + */ +export function trackConnectorEvent( + stage: ConnectorEventStage, + connectorType: string, + options: ConnectorEventOptions = {} +) { + const meta = getConnectorTelemetryMeta(connectorType); + safeCapture(`connector_${stage}`, { + ...compact({ + search_space_id: options.searchSpaceId ?? undefined, + connector_id: options.connectorId ?? undefined, + source: options.source, + error: options.error, + }), + connector_type: meta.connector_type, + connector_title: meta.connector_title, + connector_group: meta.connector_group, + is_oauth: meta.is_oauth, + ...(options.extra ?? {}), }); } +// ---- Convenience wrappers kept for backward compatibility ---- + +export function trackConnectorSetupStarted( + searchSpaceId: number, + connectorType: string, + source?: string +) { + trackConnectorEvent("setup_started", connectorType, { searchSpaceId, source }); +} + export function trackConnectorSetupSuccess( searchSpaceId: number, connectorType: string, connectorId: number ) { - safeCapture("connector_setup_success", { - search_space_id: searchSpaceId, - connector_type: connectorType, - connector_id: connectorId, - }); + trackConnectorEvent("setup_success", connectorType, { searchSpaceId, connectorId }); } export function trackConnectorSetupFailure( - searchSpaceId: number, + searchSpaceId: number | null | undefined, connectorType: string, - error?: string + error?: string, + source?: string ) { - safeCapture("connector_setup_failure", { - search_space_id: searchSpaceId, - connector_type: connectorType, + trackConnectorEvent("setup_failure", connectorType, { + searchSpaceId: searchSpaceId ?? undefined, error, + source, }); } @@ -218,11 +303,7 @@ export function trackConnectorDeleted( connectorType: string, connectorId: number ) { - safeCapture("connector_deleted", { - search_space_id: searchSpaceId, - connector_type: connectorType, - connector_id: connectorId, - }); + trackConnectorEvent("deleted", connectorType, { searchSpaceId, connectorId }); } export function trackConnectorSynced( @@ -230,11 +311,7 @@ export function trackConnectorSynced( connectorType: string, connectorId: number ) { - safeCapture("connector_synced", { - search_space_id: searchSpaceId, - connector_type: connectorType, - connector_id: connectorId, - }); + trackConnectorEvent("synced", connectorType, { searchSpaceId, connectorId }); } // ============================================ @@ -345,10 +422,9 @@ export function trackConnectorConnected( connectorType: string, connectorId?: number ) { - safeCapture("connector_connected", { - search_space_id: searchSpaceId, - connector_type: connectorType, - connector_id: connectorId, + trackConnectorEvent("connected", connectorType, { + searchSpaceId, + connectorId: connectorId ?? undefined, }); } @@ -467,8 +543,13 @@ export function trackReferralLanding(refCode: string, landingUrl: string) { // ============================================ /** - * Identify a user for PostHog analytics - * Call this after successful authentication + * Identify a user for PostHog analytics. + * Call this after successful authentication. + * + * In the Electron desktop app the same call is mirrored into the + * main-process PostHog client so desktop-only events (e.g. + * `desktop_quick_ask_opened`, `desktop_autocomplete_accepted`) are + * attributed to the logged-in user rather than an anonymous machine ID. */ export function identifyUser(userId: string, properties?: Record) { try { @@ -476,10 +557,19 @@ export function identifyUser(userId: string, properties?: Record Promise; setActiveSearchSpace: (id: string) => Promise; + // Analytics bridge (PostHog mirror into the Electron main process) + analyticsIdentify: (userId: string, properties?: Record) => Promise; + analyticsReset: () => Promise; + analyticsCapture: (event: string, properties?: Record) => Promise; + getAnalyticsContext: () => Promise<{ + distinctId: string; + machineId: string; + appVersion: string; + platform: string; + }>; } declare global { From 7a389e7a25ead19dd2bd7c88f3872dd0d07ac8b6 Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Sat, 18 Apr 2026 16:05:18 -0700 Subject: [PATCH 24/32] fix: alembic migration nos --- ..._content_type.py => 127_add_report_content_type.py} | 10 +++++----- ...esume_prompt.py => 128_seed_build_resume_prompt.py} | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) rename surfsense_backend/alembic/versions/{126_add_report_content_type.py => 127_add_report_content_type.py} (87%) rename surfsense_backend/alembic/versions/{127_seed_build_resume_prompt.py => 128_seed_build_resume_prompt.py} (89%) diff --git a/surfsense_backend/alembic/versions/126_add_report_content_type.py b/surfsense_backend/alembic/versions/127_add_report_content_type.py similarity index 87% rename from surfsense_backend/alembic/versions/126_add_report_content_type.py rename to surfsense_backend/alembic/versions/127_add_report_content_type.py index 3d9e4860c..93bf471af 100644 --- a/surfsense_backend/alembic/versions/126_add_report_content_type.py +++ b/surfsense_backend/alembic/versions/127_add_report_content_type.py @@ -1,7 +1,7 @@ -"""126_add_report_content_type +"""127_add_report_content_type -Revision ID: 126 -Revises: 125 +Revision ID: 127 +Revises: 126 Create Date: 2026-04-15 Adds content_type column to reports table to distinguish between @@ -16,8 +16,8 @@ import sqlalchemy as sa from alembic import op -revision: str = "126" -down_revision: str | None = "125" +revision: str = "127" +down_revision: str | None = "126" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None diff --git a/surfsense_backend/alembic/versions/127_seed_build_resume_prompt.py b/surfsense_backend/alembic/versions/128_seed_build_resume_prompt.py similarity index 89% rename from surfsense_backend/alembic/versions/127_seed_build_resume_prompt.py rename to surfsense_backend/alembic/versions/128_seed_build_resume_prompt.py index 9e05a0510..886879a7b 100644 --- a/surfsense_backend/alembic/versions/127_seed_build_resume_prompt.py +++ b/surfsense_backend/alembic/versions/128_seed_build_resume_prompt.py @@ -1,7 +1,7 @@ -"""127_seed_build_resume_prompt +"""128_seed_build_resume_prompt -Revision ID: 127 -Revises: 126 +Revision ID: 128 +Revises: 127 Create Date: 2026-04-15 Seeds the 'Build Resume' default prompt for all existing users. @@ -16,8 +16,8 @@ import sqlalchemy as sa from alembic import op -revision: str = "127" -down_revision: str | None = "126" +revision: str = "128" +down_revision: str | None = "127" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None From c2e52fbb48254c573e5d73e6b1eb5c8c7229ac24 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sat, 18 Apr 2026 23:15:31 -0700 Subject: [PATCH 25/32] refactor(documents-sidebar): convert discarded isExportingKB state to ref Closes #1250 --- .../components/layout/ui/sidebar/DocumentsSidebar.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx index 103d95c12..daed8747d 100644 --- a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx @@ -478,7 +478,7 @@ function AuthenticatedDocumentsSidebar({ setFolderPickerOpen(true); }, []); - const [, setIsExportingKB] = useState(false); + const isExportingKBRef = useRef(false); const [exportWarningOpen, setExportWarningOpen] = useState(false); const [exportWarningContext, setExportWarningContext] = useState<{ folder: FolderDisplay; @@ -508,7 +508,7 @@ function AuthenticatedDocumentsSidebar({ const ctx = exportWarningContext; if (!ctx?.folder) return; - setIsExportingKB(true); + isExportingKBRef.current = true; try { const safeName = ctx.folder.name @@ -524,7 +524,7 @@ function AuthenticatedDocumentsSidebar({ console.error("Folder export failed:", err); toast.error(err instanceof Error ? err.message : "Export failed"); } finally { - setIsExportingKB(false); + isExportingKBRef.current = false; } setExportWarningContext(null); }, [exportWarningContext, searchSpaceId, doExport]); @@ -560,7 +560,7 @@ function AuthenticatedDocumentsSidebar({ return; } - setIsExportingKB(true); + isExportingKBRef.current = true; try { const safeName = folder.name @@ -576,7 +576,7 @@ function AuthenticatedDocumentsSidebar({ console.error("Folder export failed:", err); toast.error(err instanceof Error ? err.message : "Export failed"); } finally { - setIsExportingKB(false); + isExportingKBRef.current = false; } }, [searchSpaceId, getPendingCountInSubtree, doExport] From 2d4adcea6459b65ea163c57b4c670a8279010ea3 Mon Sep 17 00:00:00 2001 From: Aaron Sequeira Date: Sun, 19 Apr 2026 16:09:34 +0530 Subject: [PATCH 26/32] fix(dialogs): move open-reset effects into onOpenChange handlers --- .../documents/CreateFolderDialog.tsx | 20 +++++++++++-------- .../documents/FolderPickerDialog.tsx | 20 +++++++++++-------- .../free-chat/free-model-selector.tsx | 9 +++++---- 3 files changed, 29 insertions(+), 20 deletions(-) diff --git a/surfsense_web/components/documents/CreateFolderDialog.tsx b/surfsense_web/components/documents/CreateFolderDialog.tsx index 55548146f..5ecfebbe7 100644 --- a/surfsense_web/components/documents/CreateFolderDialog.tsx +++ b/surfsense_web/components/documents/CreateFolderDialog.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -29,12 +29,16 @@ export function CreateFolderDialog({ const [name, setName] = useState(""); const inputRef = useRef(null); - useEffect(() => { - if (open) { - setName(""); - setTimeout(() => inputRef.current?.focus(), 0); - } - }, [open]); + const handleOpenChange = useCallback( + (next: boolean) => { + if (next) { + setName(""); + setTimeout(() => inputRef.current?.focus(), 0); + } + onOpenChange(next); + }, + [onOpenChange] + ); const handleSubmit = useCallback( (e?: React.FormEvent) => { @@ -50,7 +54,7 @@ export function CreateFolderDialog({ const isSubfolder = !!parentFolderName; return ( - +
diff --git a/surfsense_web/components/documents/FolderPickerDialog.tsx b/surfsense_web/components/documents/FolderPickerDialog.tsx index 59e02f726..cb97caa62 100644 --- a/surfsense_web/components/documents/FolderPickerDialog.tsx +++ b/surfsense_web/components/documents/FolderPickerDialog.tsx @@ -1,7 +1,7 @@ "use client"; import { ChevronDown, ChevronRight, Folder, FolderOpen, Home } from "lucide-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -36,12 +36,16 @@ export function FolderPickerDialog({ const [selectedId, setSelectedId] = useState(null); const [expandedIds, setExpandedIds] = useState>(new Set()); - useEffect(() => { - if (open) { - setSelectedId(null); - setExpandedIds(new Set()); - } - }, [open]); + const handleOpenChange = useCallback( + (next: boolean) => { + if (next) { + setSelectedId(null); + setExpandedIds(new Set()); + } + onOpenChange(next); + }, + [onOpenChange] + ); const foldersByParent = useMemo(() => { const map: Record = {}; @@ -123,7 +127,7 @@ export function FolderPickerDialog({ } return ( - +
diff --git a/surfsense_web/components/free-chat/free-model-selector.tsx b/surfsense_web/components/free-chat/free-model-selector.tsx index 40112f780..b25d06db8 100644 --- a/surfsense_web/components/free-chat/free-model-selector.tsx +++ b/surfsense_web/components/free-chat/free-model-selector.tsx @@ -27,13 +27,14 @@ export function FreeModelSelector({ className }: { className?: string }) { anonymousChatApiService.getModels().then(setModels).catch(console.error); }, []); - useEffect(() => { - if (open) { + const handleOpenChange = useCallback((next: boolean) => { + if (next) { setSearchQuery(""); setFocusedIndex(-1); requestAnimationFrame(() => searchInputRef.current?.focus()); } - }, [open]); + setOpen(next); + }, []); const currentModel = useMemo( () => models.find((m) => m.seo_slug === currentSlug) ?? null, @@ -94,7 +95,7 @@ export function FreeModelSelector({ className }: { className?: string }) { ); return ( - +