diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index b5536eb34..de8e327dd 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -22,6 +22,7 @@ on: permissions: contents: write + id-token: write jobs: build: @@ -58,6 +59,30 @@ jobs: fi echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" + - name: Detect Windows signing eligibility + id: sign + shell: bash + run: | + # Sign Windows builds only on production v* tags (not beta-v*, not workflow_dispatch). + # This matches the single OIDC federated credential configured in Entra ID. + if [ "${{ matrix.os }}" = "windows-latest" ] \ + && [ "${{ github.event_name }}" = "push" ] \ + && [[ "$GITHUB_REF" == refs/tags/v* ]]; then + echo "enabled=true" >> "$GITHUB_OUTPUT" + echo "Windows signing: ENABLED (v* tag on windows-latest)" + else + echo "enabled=false" >> "$GITHUB_OUTPUT" + echo "Windows signing: skipped" + fi + + - name: Azure login (for Windows signing) + if: steps.sign.outputs.enabled == 'true' + uses: azure/login@v2 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + - name: Setup pnpm uses: pnpm/action-setup@v5 @@ -98,7 +123,25 @@ jobs: - name: Package & Publish shell: bash - run: pnpm exec electron-builder ${{ matrix.platform }} --config electron-builder.yml --publish ${{ inputs.publish || 'always' }} -c.extraMetadata.version=${{ steps.version.outputs.VERSION }} + run: | + CMD=(pnpm exec electron-builder ${{ matrix.platform }} \ + --config electron-builder.yml \ + --publish "${{ inputs.publish || 'always' }}" \ + -c.extraMetadata.version="${{ steps.version.outputs.VERSION }}") + + if [ "${{ steps.sign.outputs.enabled }}" = "true" ]; then + CMD+=(-c.win.publisherName="$WINDOWS_PUBLISHER_NAME") + CMD+=(-c.win.azureSignOptions.publisherName="$WINDOWS_PUBLISHER_NAME") + CMD+=(-c.win.azureSignOptions.endpoint="$AZURE_CODESIGN_ENDPOINT") + CMD+=(-c.win.azureSignOptions.codeSigningAccountName="$AZURE_CODESIGN_ACCOUNT") + CMD+=(-c.win.azureSignOptions.certificateProfileName="$AZURE_CODESIGN_PROFILE") + fi + + "${CMD[@]}" working-directory: surfsense_desktop env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + WINDOWS_PUBLISHER_NAME: ${{ vars.WINDOWS_PUBLISHER_NAME }} + AZURE_CODESIGN_ENDPOINT: ${{ vars.AZURE_CODESIGN_ENDPOINT }} + AZURE_CODESIGN_ACCOUNT: ${{ vars.AZURE_CODESIGN_ACCOUNT }} + AZURE_CODESIGN_PROFILE: ${{ vars.AZURE_CODESIGN_PROFILE }} diff --git a/surfsense_backend/alembic/versions/127_add_report_content_type.py b/surfsense_backend/alembic/versions/127_add_report_content_type.py new file mode 100644 index 000000000..93bf471af --- /dev/null +++ b/surfsense_backend/alembic/versions/127_add_report_content_type.py @@ -0,0 +1,42 @@ +"""127_add_report_content_type + +Revision ID: 127 +Revises: 126 +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 = "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() + 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/128_seed_build_resume_prompt.py b/surfsense_backend/alembic/versions/128_seed_build_resume_prompt.py new file mode 100644 index 000000000..886879a7b --- /dev/null +++ b/surfsense_backend/alembic/versions/128_seed_build_resume_prompt.py @@ -0,0 +1,43 @@ +"""128_seed_build_resume_prompt + +Revision ID: 128 +Revises: 127 +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 = "128" +down_revision: str | None = "127" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + conn = op.get_bind() + conn.execute( + sa.text( + """ + INSERT INTO prompts + (user_id, default_prompt_slug, name, prompt, mode, version, is_public, created_at) + SELECT u.id, 'build-resume', 'Build Resume', + E'Build me a professional resume. Here is my information:\\n\\n{selection}', + 'explore'::prompt_mode, 1, false, now() + FROM "user" u + ON CONFLICT (user_id, default_prompt_slug) DO NOTHING + """ + ) + ) + + +def downgrade() -> None: + op.execute("DELETE FROM prompts WHERE default_prompt_slug = 'build-resume'") diff --git a/surfsense_backend/app/agents/new_chat/system_prompt.py b/surfsense_backend/app/agents/new_chat/system_prompt.py index dc1dd19b7..b7b3d6b33 100644 --- a/surfsense_backend/app/agents/new_chat/system_prompt.py +++ b/surfsense_backend/app/agents/new_chat/system_prompt.py @@ -443,6 +443,52 @@ _TOOL_EXAMPLES["web_search"] = """ - Call: `web_search(query="weather New York today")` """ +_TOOL_INSTRUCTIONS["generate_resume"] = """ +- generate_resume: Generate or revise a professional resume as a Typst document. + - WHEN TO CALL: The user asks to create, build, generate, write, or draft a resume or CV. + Also when they ask to modify, update, or revise an existing resume from this conversation. + - WHEN NOT TO CALL: General career advice, resume tips, cover letters, or reviewing + a resume without making changes. For cover letters, use generate_report instead. + - The tool produces Typst source code that is compiled to a PDF preview automatically. + - Args: + - user_info: The user's resume content — work experience, education, skills, contact + info, etc. Can be structured or unstructured text. + CRITICAL: user_info must be COMPREHENSIVE. Do NOT just pass the user's raw message. + You MUST gather and consolidate ALL available information: + * Content from referenced/mentioned documents (e.g., uploaded resumes, CVs, LinkedIn profiles) + that appear in the conversation context — extract and include their FULL content. + * Information the user shared across multiple messages in the conversation. + * Any relevant details from knowledge base search results in the context. + The more complete the user_info, the better the resume. Include names, contact info, + work experience with dates, education, skills, projects, certifications — everything available. + - user_instructions: Optional style or content preferences (e.g. "emphasize leadership", + "keep it to one page"). For revisions, describe what to change. + - parent_report_id: Set this when the user wants to MODIFY an existing resume from + this conversation. Use the report_id from a previous generate_resume result. + - Returns: Dict with status, report_id, title, and content_type. + - After calling: Give a brief confirmation. Do NOT paste resume content in chat. Do NOT mention report_id or any internal IDs — the resume card is shown automatically. + - VERSIONING: Same rules as generate_report — set parent_report_id for modifications + of an existing resume, leave as None for new resumes. +""" + +_TOOL_EXAMPLES["generate_resume"] = """ +- User: "Build me a resume. I'm John Doe, engineer at Acme Corp..." + - Call: `generate_resume(user_info="John Doe, engineer at Acme Corp...")` + - WHY: Has creation verb "build" + resume → call the tool. +- User: "Create my CV with this info: [experience, education, skills]" + - Call: `generate_resume(user_info="[experience, education, skills]")` +- User: "Build me a resume" (and there is a resume/CV document in the conversation context) + - Extract the FULL content from the document in context, then call: + `generate_resume(user_info="Name: John Doe\\nEmail: john@example.com\\n\\nExperience:\\n- Senior Engineer at Acme Corp (2020-2024)\\n Led team of 5...\\n\\nEducation:\\n- BS Computer Science, MIT (2016-2020)\\n\\nSkills: Python, TypeScript, AWS...")` + - WHY: Document content is available in context — extract ALL of it into user_info. Do NOT ignore referenced documents. +- User: (after resume generated) "Change my title to Senior Engineer" + - Call: `generate_resume(user_info="", user_instructions="Change the job title to Senior Engineer", parent_report_id=)` + - WHY: Modification verb "change" + refers to existing resume → set parent_report_id. +- User: "How should I structure my resume?" + - Do NOT call generate_resume. Answer in chat with advice. + - WHY: No creation/modification verb. +""" + # All tool names that have prompt instructions (order matters for prompt readability) _ALL_TOOL_NAMES_ORDERED = [ "search_surfsense_docs", @@ -450,6 +496,7 @@ _ALL_TOOL_NAMES_ORDERED = [ "generate_podcast", "generate_video_presentation", "generate_report", + "generate_resume", "generate_image", "scrape_webpage", "update_memory", diff --git a/surfsense_backend/app/agents/new_chat/tools/registry.py b/surfsense_backend/app/agents/new_chat/tools/registry.py index af00cc44d..265aabbbf 100644 --- a/surfsense_backend/app/agents/new_chat/tools/registry.py +++ b/surfsense_backend/app/agents/new_chat/tools/registry.py @@ -92,6 +92,7 @@ from .onedrive import ( ) from .podcast import create_generate_podcast_tool from .report import create_generate_report_tool +from .resume import create_generate_resume_tool from .scrape_webpage import create_scrape_webpage_tool from .search_surfsense_docs import create_search_surfsense_docs_tool from .update_memory import create_update_memory_tool, create_update_team_memory_tool @@ -171,6 +172,16 @@ BUILTIN_TOOLS: list[ToolDefinition] = [ # are optional — when missing, source_strategy="kb_search" degrades # gracefully to "provided" ), + # Resume generation tool (Typst-based, uses rendercv package) + ToolDefinition( + name="generate_resume", + description="Generate a professional resume as a Typst document", + factory=lambda deps: create_generate_resume_tool( + search_space_id=deps["search_space_id"], + thread_id=deps["thread_id"], + ), + requires=["search_space_id", "thread_id"], + ), # Generate image tool - creates images using AI models (DALL-E, GPT Image, etc.) ToolDefinition( name="generate_image", diff --git a/surfsense_backend/app/agents/new_chat/tools/resume.py b/surfsense_backend/app/agents/new_chat/tools/resume.py new file mode 100644 index 000000000..b1962f8d1 --- /dev/null +++ b/surfsense_backend/app/agents/new_chat/tools/resume.py @@ -0,0 +1,665 @@ +""" +Resume generation tool for the SurfSense agent. + +Generates a structured resume as Typst source code using the rendercv package. +The LLM outputs only the content body (= heading, sections, entries) while +the template header (import + show rule) is hardcoded and prepended by the +backend. This eliminates LLM errors in the complex configuration block. + +Templates are stored in a registry so new designs can be added by defining +a new entry in _TEMPLATES. + +Uses the same short-lived session pattern as generate_report so no DB +connection is held during the long LLM call. +""" + +import logging +import re +from datetime import UTC, datetime +from typing import Any + +import typst +from langchain_core.callbacks import dispatch_custom_event +from langchain_core.messages import HumanMessage +from langchain_core.tools import tool + +from app.db import Report, shielded_async_session +from app.services.llm_service import get_document_summary_llm + +logger = logging.getLogger(__name__) + + +# ─── Template Registry ─────────────────────────────────────────────────────── +# Each template defines: +# header - Typst import + show rule with {name}, {year}, {month}, {day} placeholders +# component_reference - component docs shown to the LLM +# rules - generation rules for the LLM + +_TEMPLATES: dict[str, dict[str, str]] = { + "classic": { + "header": """\ +#import "@preview/rendercv:0.3.0": * + +#show: rendercv.with( + name: "{name}", + title: "{name} - Resume", + footer: context {{ [#emph[{name} -- #str(here().page())\\/#str(counter(page).final().first())]] }}, + top-note: [ #emph[Last updated in {month_name} {year}] ], + locale-catalog-language: "en", + text-direction: ltr, + page-size: "us-letter", + page-top-margin: 0.7in, + page-bottom-margin: 0.7in, + page-left-margin: 0.7in, + page-right-margin: 0.7in, + page-show-footer: false, + page-show-top-note: true, + colors-body: rgb(0, 0, 0), + colors-name: rgb(0, 0, 0), + colors-headline: rgb(0, 0, 0), + colors-connections: rgb(0, 0, 0), + colors-section-titles: rgb(0, 0, 0), + colors-links: rgb(0, 0, 0), + colors-footer: rgb(128, 128, 128), + colors-top-note: rgb(128, 128, 128), + typography-line-spacing: 0.6em, + typography-alignment: "justified", + typography-date-and-location-column-alignment: right, + typography-font-family-body: "XCharter", + typography-font-family-name: "XCharter", + typography-font-family-headline: "XCharter", + typography-font-family-connections: "XCharter", + typography-font-family-section-titles: "XCharter", + typography-font-size-body: 10pt, + typography-font-size-name: 25pt, + typography-font-size-headline: 10pt, + typography-font-size-connections: 10pt, + typography-font-size-section-titles: 1.2em, + typography-small-caps-name: false, + typography-small-caps-headline: false, + typography-small-caps-connections: false, + typography-small-caps-section-titles: false, + typography-bold-name: false, + typography-bold-headline: false, + typography-bold-connections: false, + typography-bold-section-titles: true, + links-underline: true, + links-show-external-link-icon: false, + header-alignment: center, + header-photo-width: 3.5cm, + header-space-below-name: 0.7cm, + header-space-below-headline: 0.7cm, + header-space-below-connections: 0.7cm, + header-connections-hyperlink: true, + header-connections-show-icons: false, + header-connections-display-urls-instead-of-usernames: true, + header-connections-separator: "|", + header-connections-space-between-connections: 0.5cm, + section-titles-type: "with_full_line", + section-titles-line-thickness: 0.5pt, + section-titles-space-above: 0.5cm, + section-titles-space-below: 0.3cm, + sections-allow-page-break: true, + sections-space-between-text-based-entries: 0.15cm, + sections-space-between-regular-entries: 0.42cm, + entries-date-and-location-width: 4.15cm, + entries-side-space: 0cm, + entries-space-between-columns: 0.1cm, + entries-allow-page-break: false, + entries-short-second-row: false, + entries-degree-width: 1cm, + entries-summary-space-left: 0cm, + entries-summary-space-above: 0.08cm, + entries-highlights-bullet: text(13pt, [\\u{2022}], baseline: -0.6pt), + entries-highlights-nested-bullet: text(13pt, [\\u{2022}], baseline: -0.6pt), + entries-highlights-space-left: 0cm, + entries-highlights-space-above: 0.08cm, + entries-highlights-space-between-items: 0.08cm, + entries-highlights-space-between-bullet-and-text: 0.3em, + date: datetime( + year: {year}, + month: {month}, + day: {day}, + ), +) + +""", + "component_reference": """\ +Available components (use ONLY these): + += Full Name // Top-level heading — person's full name + +#connections( // Contact info row (pipe-separated) + [City, Country], + [#link("mailto:email@example.com", icon: false, if-underline: false, if-color: false)[email\\@example.com]], + [#link("https://linkedin.com/in/user", icon: false, if-underline: false, if-color: false)[linkedin.com\\/in\\/user]], + [#link("https://github.com/user", icon: false, if-underline: false, if-color: false)[github.com\\/user]], +) + +== Section Title // Section heading (arbitrary name) + +#regular-entry( // Work experience, projects, publications, etc. + [ + #strong[Role/Title], Company Name -- Location + ], + [ + Start -- End + ], + main-column-second-row: [ + - Achievement or responsibility + - Another bullet point + ], +) + +#education-entry( // Education entries + [ + #strong[Institution], Degree in Field -- Location + ], + [ + Start -- End + ], + main-column-second-row: [ + - GPA, honours, relevant coursework + ], +) + +#summary([Short paragraph summary]) // Optional summary inside an entry +#content-area([Free-form content]) // Freeform text block + +For skills sections, use bold labels directly: +#strong[Category:] item1, item2, item3 + +For simple list sections (e.g. Honors), use plain bullet points: +- Item one +- Item two +""", + "rules": """\ +RULES: +- Do NOT include any #import or #show lines. Start directly with = Full Name. +- Output ONLY valid Typst content. No explanatory text before or after. +- Do NOT wrap output in ```typst code fences. +- The = heading MUST use the person's COMPLETE full name exactly as provided. NEVER shorten or abbreviate. +- Escape @ symbols inside link labels with a backslash: email\\@example.com +- Escape forward slashes in link display text: linkedin.com\\/in\\/user +- Every section MUST use == heading. +- Use #regular-entry() for experience, projects, publications, certifications, and similar entries. +- Use #education-entry() for education. +- Use #strong[Label:] for skills categories. +- Keep content professional, concise, and achievement-oriented. +- Use action verbs for bullet points (Led, Built, Designed, Reduced, etc.). +- This template works for ALL professions — adapt sections to the user's field. +""", + }, +} + +DEFAULT_TEMPLATE = "classic" + + +# ─── Template Helpers ───────────────────────────────────────────────────────── + + +def _get_template(template_id: str | None = None) -> dict[str, str]: + """Get a template by ID, falling back to default.""" + return _TEMPLATES.get(template_id or DEFAULT_TEMPLATE, _TEMPLATES[DEFAULT_TEMPLATE]) + + +_MONTH_NAMES = [ + "", + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +] + + +def _build_header(template: dict[str, str], name: str) -> str: + """Build the template header with the person's name and current date.""" + now = datetime.now(tz=UTC) + return ( + template["header"] + .replace("{name}", name) + .replace("{year}", str(now.year)) + .replace("{month}", str(now.month)) + .replace("{day}", str(now.day)) + .replace("{month_name}", _MONTH_NAMES[now.month]) + ) + + +def _strip_header(full_source: str) -> str: + """Strip the import + show rule from stored source to get the body only. + + Finds the closing parenthesis of the rendercv.with(...) block by tracking + nesting depth, then returns everything after it. + """ + show_match = re.search(r"#show:\s*rendercv\.with\(", full_source) + if not show_match: + return full_source + + start = show_match.end() + depth = 1 + i = start + while i < len(full_source) and depth > 0: + if full_source[i] == "(": + depth += 1 + elif full_source[i] == ")": + depth -= 1 + i += 1 + + return full_source[i:].lstrip("\n") + + +def _extract_name(body: str) -> str | None: + """Extract the person's full name from the = heading in the body.""" + match = re.search(r"^=\s+(.+)$", body, re.MULTILINE) + return match.group(1).strip() if match else None + + +def _strip_imports(body: str) -> str: + """Remove any #import or #show lines the LLM might accidentally include.""" + lines = body.split("\n") + cleaned: list[str] = [] + skip_show = False + depth = 0 + + for line in lines: + stripped = line.strip() + + if stripped.startswith("#import"): + continue + + if skip_show: + depth += stripped.count("(") - stripped.count(")") + if depth <= 0: + skip_show = False + continue + + if stripped.startswith("#show:") and "rendercv" in stripped: + depth = stripped.count("(") - stripped.count(")") + if depth > 0: + skip_show = True + continue + + cleaned.append(line) + + result = "\n".join(cleaned).strip() + return result + + +def _build_llm_reference(template: dict[str, str]) -> str: + """Build the LLM prompt reference from a template.""" + return f"""\ +You MUST output valid Typst content for a resume. +Do NOT include any #import or #show lines — those are handled automatically. +Start directly with the = Full Name heading. + +{template["component_reference"]} + +{template["rules"]}""" + + +# ─── Prompts ───────────────────────────────────────────────────────────────── + +_RESUME_PROMPT = """\ +You are an expert resume writer. Generate professional resume content as Typst markup. + +{llm_reference} + +**User Information:** +{user_info} + +{user_instructions_section} + +Generate the resume content now (starting with = Full Name): +""" + +_REVISION_PROMPT = """\ +You are an expert resume editor. Modify the existing resume according to the instructions. +Apply ONLY the requested changes — do NOT rewrite sections that are not affected. + +{llm_reference} + +**Modification Instructions:** {user_instructions} + +**EXISTING RESUME CONTENT:** + +{previous_content} + +--- + +Output the complete, updated resume content with the changes applied (starting with = Full Name): +""" + +_FIX_COMPILE_PROMPT = """\ +The resume content you generated failed to compile. Fix the error while preserving all content. + +{llm_reference} + +**Compilation Error:** +{error} + +**Full Typst Source (for context — error line numbers refer to this):** +{full_source} + +**Your content starts after the template header. Output ONLY the content portion \ +(starting with = Full Name), NOT the #import or #show rule:** +""" + + +# ─── Helpers ───────────────────────────────────────────────────────────────── + + +def _strip_typst_fences(text: str) -> str: + """Remove wrapping ```typst ... ``` fences that LLMs sometimes add.""" + stripped = text.strip() + m = re.match(r"^(`{3,})(?:typst|typ)?\s*\n", stripped) + if m: + fence = m.group(1) + if stripped.endswith(fence): + stripped = stripped[m.end() :] + stripped = stripped[: -len(fence)].rstrip() + return stripped + + +def _compile_typst(source: str) -> bytes: + """Compile Typst source to PDF bytes. Raises on failure.""" + return typst.compile(source.encode("utf-8")) + + +# ─── Tool Factory ─────────────────────────────────────────────────────────── + + +def create_generate_resume_tool( + search_space_id: int, + thread_id: int | None = None, +): + """ + Factory function to create the generate_resume tool. + + Generates a Typst-based resume, validates it via compilation, + and stores the source in the Report table with content_type='typst'. + The LLM generates only the content body; the template header is + prepended by the backend. + """ + + @tool + async def generate_resume( + user_info: str, + user_instructions: str | None = None, + parent_report_id: int | None = None, + ) -> dict[str, Any]: + """ + Generate a professional resume as a Typst document. + + Use this tool when the user asks to create, build, generate, write, + or draft a resume or CV. Also use it when the user wants to modify, + update, or revise an existing resume generated in this conversation. + + Trigger phrases include: + - "build me a resume", "create my resume", "generate a CV" + - "update my resume", "change my title", "add my new job" + - "make my resume more concise", "reformat my resume" + + Do NOT use this tool for: + - General questions about resumes or career advice + - Reviewing or critiquing a resume without changes + - Cover letters (use generate_report instead) + + VERSIONING — parent_report_id: + - Set parent_report_id when the user wants to MODIFY an existing + resume that was already generated in this conversation. + - Leave as None for new resumes. + + Args: + user_info: The user's resume content — work experience, + education, skills, contact info, etc. Can be structured + or unstructured text. + user_instructions: Optional style or content preferences + (e.g. "emphasize leadership", "keep it to one page", + "use a modern style"). For revisions, describe what to change. + parent_report_id: ID of a previous resume to revise (creates + new version in the same version group). + + Returns: + Dict with status, report_id, title, and content_type. + """ + report_group_id: int | None = None + parent_content: str | None = None + + template = _get_template() + llm_reference = _build_llm_reference(template) + + async def _save_failed_report(error_msg: str) -> int | None: + try: + async with shielded_async_session() as session: + failed = Report( + title="Resume", + content=None, + content_type="typst", + report_metadata={ + "status": "failed", + "error_message": error_msg, + }, + report_style="resume", + search_space_id=search_space_id, + thread_id=thread_id, + report_group_id=report_group_id, + ) + session.add(failed) + await session.commit() + await session.refresh(failed) + if not failed.report_group_id: + failed.report_group_id = failed.id + await session.commit() + logger.info( + f"[generate_resume] Saved failed report {failed.id}: {error_msg}" + ) + return failed.id + except Exception: + logger.exception( + "[generate_resume] Could not persist failed report row" + ) + return None + + try: + # ── Phase 1: READ ───────────────────────────────────────────── + async with shielded_async_session() as read_session: + if parent_report_id: + parent_report = await read_session.get(Report, parent_report_id) + if parent_report: + report_group_id = parent_report.report_group_id + parent_content = parent_report.content + logger.info( + f"[generate_resume] Revising from parent {parent_report_id} " + f"(group {report_group_id})" + ) + + llm = await get_document_summary_llm(read_session, search_space_id) + + if not llm: + error_msg = ( + "No LLM configured. Please configure a language model in Settings." + ) + report_id = await _save_failed_report(error_msg) + return { + "status": "failed", + "error": error_msg, + "report_id": report_id, + "title": "Resume", + "content_type": "typst", + } + + # ── Phase 2: LLM GENERATION ─────────────────────────────────── + + user_instructions_section = "" + if user_instructions: + user_instructions_section = ( + f"**Additional Instructions:** {user_instructions}" + ) + + if parent_content: + dispatch_custom_event( + "report_progress", + {"phase": "writing", "message": "Updating your resume"}, + ) + parent_body = _strip_header(parent_content) + prompt = _REVISION_PROMPT.format( + llm_reference=llm_reference, + user_instructions=user_instructions + or "Improve and refine the resume.", + previous_content=parent_body, + ) + else: + dispatch_custom_event( + "report_progress", + {"phase": "writing", "message": "Building your resume"}, + ) + prompt = _RESUME_PROMPT.format( + llm_reference=llm_reference, + user_info=user_info, + user_instructions_section=user_instructions_section, + ) + + response = await llm.ainvoke([HumanMessage(content=prompt)]) + body = response.content + + if not body or not isinstance(body, str): + error_msg = "LLM returned empty or invalid content" + report_id = await _save_failed_report(error_msg) + return { + "status": "failed", + "error": error_msg, + "report_id": report_id, + "title": "Resume", + "content_type": "typst", + } + + body = _strip_typst_fences(body) + body = _strip_imports(body) + + # ── Phase 3: ASSEMBLE + COMPILE ─────────────────────────────── + dispatch_custom_event( + "report_progress", + {"phase": "compiling", "message": "Compiling resume..."}, + ) + + name = _extract_name(body) or "Resume" + header = _build_header(template, name) + typst_source = header + body + + compile_error: str | None = None + for attempt in range(2): + try: + _compile_typst(typst_source) + compile_error = None + break + except Exception as e: + compile_error = str(e) + logger.warning( + f"[generate_resume] Compile attempt {attempt + 1} failed: {compile_error}" + ) + + if attempt == 0: + dispatch_custom_event( + "report_progress", + { + "phase": "fixing", + "message": "Fixing compilation issue...", + }, + ) + fix_prompt = _FIX_COMPILE_PROMPT.format( + llm_reference=llm_reference, + error=compile_error, + full_source=typst_source, + ) + fix_response = await llm.ainvoke( + [HumanMessage(content=fix_prompt)] + ) + if fix_response.content and isinstance( + fix_response.content, str + ): + body = _strip_typst_fences(fix_response.content) + body = _strip_imports(body) + name = _extract_name(body) or name + header = _build_header(template, name) + typst_source = header + body + + if compile_error: + error_msg = ( + f"Typst compilation failed after 2 attempts: {compile_error}" + ) + report_id = await _save_failed_report(error_msg) + return { + "status": "failed", + "error": error_msg, + "report_id": report_id, + "title": "Resume", + "content_type": "typst", + } + + # ── Phase 4: SAVE ───────────────────────────────────────────── + dispatch_custom_event( + "report_progress", + {"phase": "saving", "message": "Saving your resume"}, + ) + + resume_title = f"{name} - Resume" if name != "Resume" else "Resume" + + metadata: dict[str, Any] = { + "status": "ready", + "word_count": len(typst_source.split()), + "char_count": len(typst_source), + } + + async with shielded_async_session() as write_session: + report = Report( + title=resume_title, + content=typst_source, + content_type="typst", + report_metadata=metadata, + report_style="resume", + search_space_id=search_space_id, + thread_id=thread_id, + report_group_id=report_group_id, + ) + write_session.add(report) + await write_session.commit() + await write_session.refresh(report) + + if not report.report_group_id: + report.report_group_id = report.id + await write_session.commit() + + saved_id = report.id + + logger.info(f"[generate_resume] Created resume {saved_id}: {resume_title}") + + return { + "status": "ready", + "report_id": saved_id, + "title": resume_title, + "content_type": "typst", + "is_revision": bool(parent_content), + "message": f"Resume generated successfully: {resume_title}", + } + + except Exception as e: + error_message = str(e) + logger.exception(f"[generate_resume] Error: {error_message}") + report_id = await _save_failed_report(error_message) + return { + "status": "failed", + "error": error_message, + "report_id": report_id, + "title": "Resume", + "content_type": "typst", + } + + return generate_resume diff --git a/surfsense_backend/app/app.py b/surfsense_backend/app/app.py index 95aa1bf5d..a1795853a 100644 --- a/surfsense_backend/app/app.py +++ b/surfsense_backend/app/app.py @@ -114,8 +114,19 @@ def _surfsense_error_handler(request: Request, exc: SurfSenseError) -> JSONRespo def _http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse: - """Wrap FastAPI/Starlette HTTPExceptions into the standard envelope.""" + """Wrap FastAPI/Starlette HTTPExceptions into the standard envelope. + + 5xx sanitization policy: + - 500 responses are sanitized (replaced with ``GENERIC_5XX_MESSAGE``) because + they usually wrap raw internal errors and may leak sensitive info. + - Other 5xx statuses (501, 502, 503, 504, ...) are raised explicitly by + route code to communicate a specific, user-safe operational state + (e.g. 503 "Page purchases are temporarily unavailable."). Those details + are preserved so the frontend can render them, but the error is still + logged server-side. + """ rid = _get_request_id(request) + should_sanitize = exc.status_code == 500 # Structured dict details (e.g. {"code": "CAPTCHA_REQUIRED", "message": "..."}) # are preserved so the frontend can parse them. @@ -130,6 +141,7 @@ def _http_exception_handler(request: Request, exc: HTTPException) -> JSONRespons exc.status_code, message, ) + if should_sanitize: message = GENERIC_5XX_MESSAGE err_code = "INTERNAL_ERROR" body = { @@ -158,6 +170,7 @@ def _http_exception_handler(request: Request, exc: HTTPException) -> JSONRespons exc.status_code, detail, ) + if should_sanitize: detail = GENERIC_5XX_MESSAGE code = _status_to_code(exc.status_code, detail) return _build_error_response(exc.status_code, detail, code=code, request_id=rid) diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index cf9801e17..16b40983e 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -1198,12 +1198,13 @@ class VideoPresentation(BaseModel, TimestampMixin): class Report(BaseModel, TimestampMixin): - """Report model for storing generated Markdown reports.""" + """Report model for storing generated reports (Markdown or Typst).""" __tablename__ = "reports" title = Column(String(500), nullable=False) - content = Column(Text, nullable=True) # Markdown body + content = Column(Text, nullable=True) + content_type = Column(String(20), nullable=False, server_default="markdown") report_metadata = Column(JSONB, nullable=True) # section headings, word count, etc. report_style = Column( String(100), nullable=True diff --git a/surfsense_backend/app/prompts/system_defaults.py b/surfsense_backend/app/prompts/system_defaults.py index aaf9b64bd..cc2019b8f 100644 --- a/surfsense_backend/app/prompts/system_defaults.py +++ b/surfsense_backend/app/prompts/system_defaults.py @@ -71,4 +71,11 @@ SYSTEM_PROMPT_DEFAULTS: list[dict] = [ "prompt": "Search the web for information about:\n\n{selection}", "mode": "explore", }, + { + "slug": "build-resume", + "version": 1, + "name": "Build Resume", + "prompt": "Build me a professional resume. Here is my information:\n\n{selection}", + "mode": "explore", + }, ] diff --git a/surfsense_backend/app/routes/public_chat_routes.py b/surfsense_backend/app/routes/public_chat_routes.py index e206bfd11..3181e117c 100644 --- a/surfsense_backend/app/routes/public_chat_routes.py +++ b/surfsense_backend/app/routes/public_chat_routes.py @@ -231,6 +231,57 @@ def _replace_audio_paths_with_public_urls( return result +@router.get("/{share_token}/reports/{report_id}/preview") +async def preview_public_report_pdf( + share_token: str, + report_id: int, + session: AsyncSession = Depends(get_async_session), +): + """ + Return a compiled PDF preview for a Typst-based report in a public snapshot. + + No authentication required - the share_token provides access. + """ + import asyncio + import io + import re + + import typst as typst_compiler + + report_info = await get_snapshot_report(session, share_token, report_id) + + if not report_info: + raise HTTPException(status_code=404, detail="Report not found") + + content = report_info.get("content") + content_type = report_info.get("content_type", "markdown") + + if not content: + raise HTTPException(status_code=400, detail="Report has no content to preview") + + if content_type != "typst": + raise HTTPException( + status_code=400, + detail="Preview is only available for Typst-based reports", + ) + + def _compile() -> bytes: + return typst_compiler.compile(content.encode("utf-8")) + + pdf_bytes = await asyncio.to_thread(_compile) + + safe_title = re.sub(r"[^\w\s-]", "", report_info.get("title") or "Resume").strip() + filename = f"{safe_title}.pdf" + + return StreamingResponse( + io.BytesIO(pdf_bytes), + media_type="application/pdf", + headers={ + "Content-Disposition": f'inline; filename="{filename}"', + }, + ) + + @router.get("/{share_token}/reports/{report_id}/content") async def get_public_report_content( share_token: str, @@ -259,6 +310,7 @@ async def get_public_report_content( "id": report_info.get("original_id"), "title": report_info.get("title"), "content": report_info.get("content"), + "content_type": report_info.get("content_type", "markdown"), "report_metadata": report_info.get("report_metadata"), "report_group_id": report_info.get("report_group_id"), "versions": versions, diff --git a/surfsense_backend/app/routes/reports_routes.py b/surfsense_backend/app/routes/reports_routes.py index 56ac5ec2d..19961e1a9 100644 --- a/surfsense_backend/app/routes/reports_routes.py +++ b/surfsense_backend/app/routes/reports_routes.py @@ -279,6 +279,7 @@ async def read_report_content( id=report.id, title=report.title, content=report.content, + content_type=report.content_type, report_metadata=report.report_metadata, report_group_id=report.report_group_id, versions=versions, @@ -319,6 +320,7 @@ async def update_report_content( id=report.id, title=report.title, content=report.content, + content_type=report.content_type, report_metadata=report.report_metadata, report_group_id=report.report_group_id, versions=versions, @@ -333,6 +335,57 @@ async def update_report_content( ) from None +@router.get("/reports/{report_id}/preview") +async def preview_report_pdf( + report_id: int, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + """ + Return a compiled PDF preview for Typst-based reports (resumes). + + Reads the Typst source from the database and compiles it to PDF bytes + on-the-fly. Only works for reports with content_type='typst'. + """ + try: + report = await _get_report_with_access(report_id, session, user) + + if not report.content: + raise HTTPException( + status_code=400, detail="Report has no content to preview" + ) + + if report.content_type != "typst": + raise HTTPException( + status_code=400, + detail="Preview is only available for Typst-based reports", + ) + + def _compile() -> bytes: + return typst.compile(report.content.encode("utf-8")) + + pdf_bytes = await asyncio.to_thread(_compile) + + safe_title = re.sub(r"[^\w\s-]", "", report.title or "Resume").strip() + filename = f"{safe_title}.pdf" + + return StreamingResponse( + io.BytesIO(pdf_bytes), + media_type="application/pdf", + headers={ + "Content-Disposition": f'inline; filename="{filename}"', + }, + ) + except HTTPException: + raise + except Exception: + logger.exception("Failed to compile Typst preview for report %d", report_id) + raise HTTPException( + status_code=500, + detail="Failed to compile resume preview", + ) from None + + @router.get("/reports/{report_id}/export") async def export_report( report_id: int, @@ -354,6 +407,27 @@ async def export_report( status_code=400, detail="Report has no content to export" ) + # Typst-based reports (resumes): compile directly without Pandoc + if report.content_type == "typst": + if format != ExportFormat.PDF: + raise HTTPException( + status_code=400, + detail="Typst-based reports currently only support PDF export", + ) + + def _compile_typst() -> bytes: + return typst.compile(report.content.encode("utf-8")) + + pdf_bytes = await asyncio.to_thread(_compile_typst) + safe_title = re.sub(r"[^\w\s-]", "", report.title or "Resume").strip() + return StreamingResponse( + io.BytesIO(pdf_bytes), + media_type="application/pdf", + headers={ + "Content-Disposition": f'attachment; filename="{safe_title}.pdf"', + }, + ) + # Strip wrapping code fences that LLMs sometimes add around Markdown. # Without this, pandoc treats the entire content as a code block. markdown_content = _strip_wrapping_code_fences(report.content) diff --git a/surfsense_backend/app/schemas/reports.py b/surfsense_backend/app/schemas/reports.py index 9a7765507..25ca50607 100644 --- a/surfsense_backend/app/schemas/reports.py +++ b/surfsense_backend/app/schemas/reports.py @@ -23,6 +23,7 @@ class ReportRead(BaseModel): report_style: str | None = None report_metadata: dict[str, Any] | None = None report_group_id: int | None = None + content_type: str = "markdown" created_at: datetime class Config: @@ -40,11 +41,12 @@ class ReportVersionInfo(BaseModel): class ReportContentRead(BaseModel): - """Schema for reading a report with full Markdown content.""" + """Schema for reading a report with full content (Markdown or Typst).""" id: int title: str content: str | None = None + content_type: str = "markdown" report_metadata: dict[str, Any] | None = None report_group_id: int | None = None versions: list[ReportVersionInfo] = [] diff --git a/surfsense_backend/app/services/public_chat_service.py b/surfsense_backend/app/services/public_chat_service.py index 376db974f..e4e0dd33a 100644 --- a/surfsense_backend/app/services/public_chat_service.py +++ b/surfsense_backend/app/services/public_chat_service.py @@ -41,6 +41,7 @@ UI_TOOLS = { "generate_image", "generate_podcast", "generate_report", + "generate_resume", "generate_video_presentation", } @@ -239,7 +240,7 @@ async def create_snapshot( video_presentation_ids_seen.add(vp_id) part["result"] = {**result_data, "status": "ready"} - elif tool_name == "generate_report": + elif tool_name in ("generate_report", "generate_resume"): result_data = part.get("result", {}) report_id = result_data.get("report_id") if report_id and report_id not in report_ids_seen: @@ -247,7 +248,6 @@ async def create_snapshot( if report_info: reports_data.append(report_info) report_ids_seen.add(report_id) - # Update status to "ready" so frontend renders ReportCard part["result"] = {**result_data, "status": "ready"} messages_data.append( @@ -377,6 +377,7 @@ async def _get_report_for_snapshot( "original_id": report.id, "title": report.title, "content": report.content, + "content_type": report.content_type, "report_metadata": report.report_metadata, "report_group_id": report.report_group_id, "created_at": report.created_at.isoformat() if report.created_at else None, diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index d45365557..4810f02e6 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -953,6 +953,31 @@ async def _stream_agent_events( f"Report generation failed: {error_msg}", "error", ) + elif tool_name == "generate_resume": + yield streaming_service.format_tool_output_available( + tool_call_id, + tool_output + if isinstance(tool_output, dict) + else {"result": tool_output}, + ) + if ( + isinstance(tool_output, dict) + and tool_output.get("status") == "ready" + ): + yield streaming_service.format_terminal_info( + f"Resume generated: {tool_output.get('title', 'Resume')}", + "success", + ) + else: + error_msg = ( + tool_output.get("error", "Unknown error") + if isinstance(tool_output, dict) + else "Unknown error" + ) + yield streaming_service.format_terminal_info( + f"Resume generation failed: {error_msg}", + "error", + ) elif tool_name in ( "create_notion_page", "update_notion_page", diff --git a/surfsense_backend/tests/unit/test_error_contract.py b/surfsense_backend/tests/unit/test_error_contract.py index 8a1605dd1..81ec08b2d 100644 --- a/surfsense_backend/tests/unit/test_error_contract.py +++ b/surfsense_backend/tests/unit/test_error_contract.py @@ -70,6 +70,20 @@ def _make_test_app(): async def raise_http_500(): raise HTTPException(status_code=500, detail="secret db password leaked") + @app.get("/http-503") + async def raise_http_503(): + raise HTTPException( + status_code=503, + detail="Page purchases are temporarily unavailable.", + ) + + @app.get("/http-502") + async def raise_http_502(): + raise HTTPException( + status_code=502, + detail="Unable to create Stripe checkout session.", + ) + @app.get("/surfsense-connector") async def raise_connector(): raise ConnectorError("GitHub API returned 401") @@ -184,6 +198,20 @@ class TestHTTPExceptionHandler: assert body["error"]["message"] == GENERIC_5XX_MESSAGE assert body["error"]["code"] == "INTERNAL_ERROR" + def test_503_preserves_detail(self, client): + # Intentional 503s (e.g. feature flag off) must surface the developer + # message so the frontend can render actionable copy. + body = _assert_envelope(client.get("/http-503"), 503) + assert ( + body["error"]["message"] == "Page purchases are temporarily unavailable." + ) + assert body["error"]["message"] != GENERIC_5XX_MESSAGE + + def test_502_preserves_detail(self, client): + body = _assert_envelope(client.get("/http-502"), 502) + assert body["error"]["message"] == "Unable to create Stripe checkout session." + assert body["error"]["message"] != GENERIC_5XX_MESSAGE + # --------------------------------------------------------------------------- # SurfSenseError hierarchy diff --git a/surfsense_desktop/src/ipc/channels.ts b/surfsense_desktop/src/ipc/channels.ts index 61213eb46..6731ecbfa 100644 --- a/surfsense_desktop/src/ipc/channels.ts +++ b/surfsense_desktop/src/ipc/channels.ts @@ -43,4 +43,12 @@ export const IPC_CHANNELS = { // Active search space GET_ACTIVE_SEARCH_SPACE: 'search-space:get-active', SET_ACTIVE_SEARCH_SPACE: 'search-space:set-active', + // Launch on system startup + GET_AUTO_LAUNCH: 'auto-launch:get', + SET_AUTO_LAUNCH: 'auto-launch:set', + // 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..05c327436 100644 --- a/surfsense_desktop/src/ipc/handlers.ts +++ b/surfsense_desktop/src/ipc/handlers.ts @@ -24,10 +24,18 @@ import { type WatchedFolderConfig, } from '../modules/folder-watcher'; import { getShortcuts, setShortcuts, type ShortcutConfig } from '../modules/shortcuts'; +import { getAutoLaunchState, setAutoLaunch } from '../modules/auto-launch'; import { getActiveSearchSpaceId, setActiveSearchSpaceId } from '../modules/active-search-space'; 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; @@ -120,6 +128,21 @@ export function registerIpcHandlers(): void { ipcMain.handle(IPC_CHANNELS.GET_SHORTCUTS, () => getShortcuts()); + ipcMain.handle(IPC_CHANNELS.GET_AUTO_LAUNCH, () => getAutoLaunchState()); + + ipcMain.handle( + IPC_CHANNELS.SET_AUTO_LAUNCH, + async (_event, payload: { enabled: boolean; openAsHidden?: boolean }) => { + const next = await setAutoLaunch(payload.enabled, payload.openAsHidden); + trackEvent('desktop_auto_launch_toggled', { + enabled: next.enabled, + open_as_hidden: next.openAsHidden, + supported: next.supported, + }); + return next; + }, + ); + ipcMain.handle(IPC_CHANNELS.GET_ACTIVE_SEARCH_SPACE, () => getActiveSearchSpaceId()); ipcMain.handle(IPC_CHANNELS.SET_ACTIVE_SEARCH_SPACE, (_event, id: string) => @@ -131,6 +154,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..399144bed 100644 --- a/surfsense_desktop/src/main.ts +++ b/surfsense_desktop/src/main.ts @@ -1,10 +1,9 @@ -import { app, BrowserWindow } from 'electron'; +import { app } from 'electron'; -let isQuitting = false; import { registerGlobalErrorHandlers, showErrorDialog } from './modules/errors'; import { startNextServer } from './modules/server'; -import { createMainWindow, getMainWindow } from './modules/window'; -import { setupDeepLinks, handlePendingDeepLink } from './modules/deep-links'; +import { createMainWindow, getMainWindow, markQuitting } from './modules/window'; +import { setupDeepLinks, handlePendingDeepLink, hasPendingDeepLink } from './modules/deep-links'; import { setupAutoUpdater } from './modules/auto-updater'; import { setupMenu } from './modules/menu'; import { registerQuickAsk, unregisterQuickAsk } from './modules/quick-ask'; @@ -13,6 +12,12 @@ import { registerFolderWatcher, unregisterFolderWatcher } from './modules/folder import { registerIpcHandlers } from './ipc/handlers'; import { createTray, destroyTray } from './modules/tray'; import { initAnalytics, shutdownAnalytics, trackEvent } from './modules/analytics'; +import { + applyAutoLaunchDefaults, + shouldStartHidden, + syncAutoLaunchOnStartup, + wasLaunchedAtLogin, +} from './modules/auto-launch'; registerGlobalErrorHandlers(); @@ -24,7 +29,12 @@ registerIpcHandlers(); app.whenReady().then(async () => { initAnalytics(); - trackEvent('desktop_app_launched'); + const launchedAtLogin = wasLaunchedAtLogin(); + const startedHidden = shouldStartHidden(); + trackEvent('desktop_app_launched', { + launched_at_login: launchedAtLogin, + started_hidden: startedHidden, + }); setupMenu(); try { await startNextServer(); @@ -35,16 +45,19 @@ app.whenReady().then(async () => { } await createTray(); + const defaultsApplied = await applyAutoLaunchDefaults(); + if (defaultsApplied) { + trackEvent('desktop_auto_launch_defaulted_on'); + } + await syncAutoLaunchOnStartup(); - const win = createMainWindow('/dashboard'); - - // Minimize to tray instead of closing the app - win.on('close', (e) => { - if (!isQuitting) { - e.preventDefault(); - win.hide(); - } - }); + // When started by the OS at login we stay quietly in the tray. The window + // is created lazily on first user interaction (tray click / activate). + // Exception: if a deep link is queued, the user explicitly asked to land + // in the app — don't swallow it. + if (!startedHidden || hasPendingDeepLink()) { + createMainWindow('/dashboard'); + } await registerQuickAsk(); await registerAutocomplete(); @@ -55,6 +68,7 @@ app.whenReady().then(async () => { app.on('activate', () => { const mw = getMainWindow(); + trackEvent('desktop_app_activated'); if (!mw || mw.isDestroyed()) { createMainWindow('/dashboard'); } else { @@ -70,7 +84,8 @@ app.on('window-all-closed', () => { }); app.on('before-quit', () => { - isQuitting = true; + markQuitting(); + 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-launch.ts b/surfsense_desktop/src/modules/auto-launch.ts new file mode 100644 index 000000000..9759c4ef9 --- /dev/null +++ b/surfsense_desktop/src/modules/auto-launch.ts @@ -0,0 +1,304 @@ +import { app } from 'electron'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +// --------------------------------------------------------------------------- +// Launch on system startup ("auto-launch" / "open at login"). +// +// macOS + Windows : uses Electron's built-in `app.setLoginItemSettings()`. +// Linux : writes a freedesktop autostart `.desktop` file into +// `~/.config/autostart/`. Electron's API is a no-op there. +// +// The OS is the source of truth for whether we're enabled (so a user who +// disables us via System Settings / GNOME Tweaks isn't silently overridden). +// We persist a small companion record in electron-store for things the OS +// can't tell us — currently just `openAsHidden`, since on Windows we encode +// it as a CLI arg and on Linux as part of the Exec line, but on a fresh +// startup we still want the renderer toggle to reflect the user's intent. +// --------------------------------------------------------------------------- + +const STORE_KEY = 'launchAtLogin'; +const HIDDEN_FLAG = '--hidden'; +const LINUX_DESKTOP_FILENAME = 'surfsense.desktop'; + +export interface AutoLaunchState { + enabled: boolean; + openAsHidden: boolean; + supported: boolean; +} + +interface PersistedState { + enabled: boolean; + openAsHidden: boolean; + // True once we've run the first-launch defaults (opt-in to auto-launch). + // We never re-apply defaults if this is set, so a user who has explicitly + // turned auto-launch off stays off forever. + defaultsApplied: boolean; +} + +const DEFAULTS: PersistedState = { + enabled: false, + openAsHidden: true, + defaultsApplied: false, +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- lazily imported ESM module; matches shortcuts.ts pattern +let store: any = null; + +async function getStore() { + if (!store) { + const { default: Store } = await import('electron-store'); + store = new Store({ + name: 'auto-launch', + defaults: { [STORE_KEY]: DEFAULTS }, + }); + } + return store; +} + +async function readPersisted(): Promise { + const s = await getStore(); + const stored = s.get(STORE_KEY) as Partial | undefined; + return { ...DEFAULTS, ...(stored ?? {}) }; +} + +async function writePersisted(next: PersistedState): Promise { + const s = await getStore(); + s.set(STORE_KEY, next); +} + +// --------------------------------------------------------------------------- +// Platform support +// --------------------------------------------------------------------------- + +// Auto-launch only makes sense for the packaged app — in dev `process.execPath` +// is the local Electron binary, so registering it would point the OS at a +// throwaway path the next time the dev server isn't running. +function isSupported(): boolean { + if (!app.isPackaged) return false; + return ['darwin', 'win32', 'linux'].includes(process.platform); +} + +// --------------------------------------------------------------------------- +// Linux: ~/.config/autostart/surfsense.desktop +// --------------------------------------------------------------------------- + +function linuxAutostartDir(): string { + const xdg = process.env.XDG_CONFIG_HOME; + const base = xdg && xdg.length > 0 ? xdg : path.join(os.homedir(), '.config'); + return path.join(base, 'autostart'); +} + +function linuxAutostartFile(): string { + return path.join(linuxAutostartDir(), LINUX_DESKTOP_FILENAME); +} + +// AppImages move around with the user — `process.execPath` points at a temp +// mount, so we have to use the original AppImage path exposed via env. +function linuxExecPath(): string { + return process.env.APPIMAGE && process.env.APPIMAGE.length > 0 + ? process.env.APPIMAGE + : process.execPath; +} + +function escapeDesktopExecArg(value: string): string { + // Freedesktop `.desktop` Exec values require quoted args when spaces are + // present. We keep this intentionally minimal and escape only characters + // that can break quoted parsing. + return `"${value.replace(/(["\\`$])/g, '\\$1')}"`; +} + +function writeLinuxDesktopFile(openAsHidden: boolean): void { + const exec = escapeDesktopExecArg(linuxExecPath()); + const args = openAsHidden ? ` ${HIDDEN_FLAG}` : ''; + const contents = [ + '[Desktop Entry]', + 'Type=Application', + 'Version=1.0', + 'Name=SurfSense', + 'Comment=AI-powered research assistant', + `Exec=${exec}${args}`, + 'Terminal=false', + 'Categories=Utility;Office;', + 'X-GNOME-Autostart-enabled=true', + `X-GNOME-Autostart-Delay=${openAsHidden ? '5' : '0'}`, + '', + ].join('\n'); + + fs.mkdirSync(linuxAutostartDir(), { recursive: true }); + fs.writeFileSync(linuxAutostartFile(), contents, { mode: 0o644 }); +} + +function removeLinuxDesktopFile(): void { + try { + fs.unlinkSync(linuxAutostartFile()); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err; + } +} + +function readLinuxDesktopFile(): boolean { + return fs.existsSync(linuxAutostartFile()); +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export async function getAutoLaunchState(): Promise { + const supported = isSupported(); + const persisted = await readPersisted(); + + if (!supported) { + return { enabled: false, openAsHidden: persisted.openAsHidden, supported: false }; + } + + // Trust the OS state — the user may have disabled it from system settings. + return { enabled: readOsEnabled(), openAsHidden: persisted.openAsHidden, supported: true }; +} + +export async function setAutoLaunch( + enabled: boolean, + openAsHidden: boolean = DEFAULTS.openAsHidden, +): Promise { + const supported = isSupported(); + + if (!supported) { + return { enabled: false, openAsHidden, supported: false }; + } + + applySystemRegistration(enabled, openAsHidden); + // Preserve `defaultsApplied` (and any future fields) — and explicitly + // mark them as applied, since the user has now made an intentional choice. + await writePersisted({ enabled, openAsHidden, defaultsApplied: true }); + return { enabled, openAsHidden, supported: true }; +} + +function applySystemRegistration(enabled: boolean, openAsHidden: boolean): void { + if (process.platform === 'linux') { + if (enabled) writeLinuxDesktopFile(openAsHidden); + else removeLinuxDesktopFile(); + return; + } + + if (!enabled) { + app.setLoginItemSettings({ openAtLogin: false }); + return; + } + + if (process.platform === 'win32') { + // On Windows we can't tell the OS to "launch hidden" — instead we pass an + // arg the app introspects on boot to skip showing the main window. + app.setLoginItemSettings({ + openAtLogin: true, + args: openAsHidden ? [HIDDEN_FLAG] : [], + }); + return; + } + + // darwin + app.setLoginItemSettings({ + openAtLogin: true, + openAsHidden, + }); +} + +// First-launch opt-in: register SurfSense as a hidden login item so the tray, +// global shortcuts, and folder watchers are ready right after the user signs +// in. Runs at most once per installation — the `defaultsApplied` flag is +// flipped before we ever touch the OS so a failure to register doesn't cause +// us to retry on every boot, and a user who turns the toggle off afterwards +// is never silently re-enabled. +// +// Returns whether the defaults were actually applied this boot, so callers +// can fire an analytics event without coupling this module to PostHog. +export async function applyAutoLaunchDefaults(): Promise { + if (!isSupported()) return false; + const persisted = await readPersisted(); + if (persisted.defaultsApplied) return false; + + // Mark the defaults as applied *first*. If `applySystemRegistration` + // throws (e.g. read-only home dir on Linux), we'd rather silently leave + // the user un-registered than spam them with a failed registration on + // every single boot. + const next: PersistedState = { + enabled: true, + openAsHidden: true, + defaultsApplied: true, + }; + + try { + applySystemRegistration(true, true); + } catch (err) { + console.error('[auto-launch] First-run registration failed:', err); + next.enabled = false; + } + + await writePersisted(next); + return next.enabled; +} + +// Called once at startup. Goal: +// * If the OS-level entry is already enabled, re-assert it so a moved +// binary (Windows reinstall to a new dir, Linux AppImage moved by user) +// gets its registered path refreshed. +// * If the OS-level entry has been disabled — typically because the user +// turned it off in System Settings / GNOME Tweaks — *respect that* and +// reconcile our persisted state to match. We never silently re-enable +// a login item the user explicitly turned off. +export async function syncAutoLaunchOnStartup(): Promise { + if (!isSupported()) return; + + const persisted = await readPersisted(); + const osEnabled = readOsEnabled(); + + if (!osEnabled) { + // User (or some other tool) turned us off out-of-band. Don't re-enable; + // just bring our persisted state in sync so the settings UI reflects + // reality on the next render. + if (persisted.enabled) { + await writePersisted({ ...persisted, enabled: false }); + } + return; + } + + // OS says we're enabled — refresh the registration so the recorded path / + // args match this binary. Idempotent on macOS; corrects path drift on + // Windows and Linux. If our persisted state was somehow stale we also + // bring it back in line. + try { + applySystemRegistration(true, persisted.openAsHidden); + if (!persisted.enabled) { + await writePersisted({ ...persisted, enabled: true }); + } + } catch (err) { + console.error('[auto-launch] Failed to re-assert login item:', err); + } +} + +function readOsEnabled(): boolean { + if (process.platform === 'linux') return readLinuxDesktopFile(); + return app.getLoginItemSettings().openAtLogin; +} + +// True when the OS launched us as part of login (used for analytics). +export function wasLaunchedAtLogin(): boolean { + if (process.argv.includes(HIDDEN_FLAG)) return true; + if (process.platform === 'darwin') { + const settings = app.getLoginItemSettings(); + return settings.wasOpenedAtLogin || settings.wasOpenedAsHidden; + } + return false; +} + +// Used for boot UI behavior. On macOS we only start hidden when the OS +// explicitly launched the app as hidden, not merely "at login". +export function shouldStartHidden(): boolean { + if (process.argv.includes(HIDDEN_FLAG)) return true; + if (process.platform === 'darwin') { + const settings = app.getLoginItemSettings(); + return settings.wasOpenedAsHidden; + } + return false; +} 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..11b7bfcff 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}`); @@ -64,3 +69,10 @@ export function handlePendingDeepLink(): void { deepLinkUrl = null; } } + +// True when a deep link arrived before the main window existed. Callers can +// use this to force-create a window even on a "started hidden" boot, so we +// don't silently swallow a `surfsense://` URL the user actually clicked on. +export function hasPendingDeepLink(): boolean { + return deepLinkUrl !== null; +} 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/modules/window.ts b/surfsense_desktop/src/modules/window.ts index 9cd216501..c925bf947 100644 --- a/surfsense_desktop/src/modules/window.ts +++ b/surfsense_desktop/src/modules/window.ts @@ -8,11 +8,18 @@ const isDev = !app.isPackaged; const HOSTED_FRONTEND_URL = process.env.HOSTED_FRONTEND_URL as string; let mainWindow: BrowserWindow | null = null; +let isQuitting = false; export function getMainWindow(): BrowserWindow | null { return mainWindow; } +// Called from main.ts on `before-quit` so the close-to-tray handler knows +// to actually let the window die instead of hiding it. +export function markQuitting(): void { + isQuitting = true; +} + export function createMainWindow(initialPath = '/dashboard'): BrowserWindow { mainWindow = new BrowserWindow({ width: 1280, @@ -70,6 +77,16 @@ export function createMainWindow(initialPath = '/dashboard'): BrowserWindow { mainWindow.webContents.openDevTools(); } + // Hide-to-tray on close (don't actually destroy the window unless the + // user really is quitting). Applies to every instance — including the one + // created lazily after a launch-at-login boot. + mainWindow.on('close', (e) => { + if (!isQuitting && mainWindow) { + e.preventDefault(); + mainWindow.hide(); + } + }); + mainWindow.on('closed', () => { mainWindow = null; }); diff --git a/surfsense_desktop/src/preload.ts b/surfsense_desktop/src/preload.ts index e3d12c5e6..3a69f3239 100644 --- a/surfsense_desktop/src/preload.ts +++ b/surfsense_desktop/src/preload.ts @@ -82,8 +82,23 @@ contextBridge.exposeInMainWorld('electronAPI', { setShortcuts: (config: Record) => ipcRenderer.invoke(IPC_CHANNELS.SET_SHORTCUTS, config), + // Launch on system startup + getAutoLaunch: () => ipcRenderer.invoke(IPC_CHANNELS.GET_AUTO_LAUNCH), + setAutoLaunch: (enabled: boolean, openAsHidden?: boolean) => + ipcRenderer.invoke(IPC_CHANNELS.SET_AUTO_LAUNCH, { enabled, openAsHidden }), + // Active search space 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]/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/app/dashboard/[search_space_id]/user-settings/components/DesktopContent.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/DesktopContent.tsx index c3f457f96..3175268d2 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/DesktopContent.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/DesktopContent.tsx @@ -1,6 +1,6 @@ "use client"; -import { BrainCog, Rocket, Zap } from "lucide-react"; +import { BrainCog, Power, Rocket, Zap } from "lucide-react"; import { useEffect, useState } from "react"; import { toast } from "sonner"; import { DEFAULT_SHORTCUTS, ShortcutRecorder } from "@/components/desktop/shortcut-recorder"; @@ -30,6 +30,10 @@ export function DesktopContent() { const [searchSpaces, setSearchSpaces] = useState([]); const [activeSpaceId, setActiveSpaceId] = useState(null); + const [autoLaunchEnabled, setAutoLaunchEnabled] = useState(false); + const [autoLaunchHidden, setAutoLaunchHidden] = useState(true); + const [autoLaunchSupported, setAutoLaunchSupported] = useState(false); + useEffect(() => { if (!api) { setLoading(false); @@ -38,19 +42,28 @@ export function DesktopContent() { } let mounted = true; + const hasAutoLaunchApi = + typeof api.getAutoLaunch === "function" && typeof api.setAutoLaunch === "function"; + setAutoLaunchSupported(hasAutoLaunchApi); Promise.all([ api.getAutocompleteEnabled(), api.getShortcuts?.() ?? Promise.resolve(null), api.getActiveSearchSpace?.() ?? Promise.resolve(null), searchSpacesApiService.getSearchSpaces(), + hasAutoLaunchApi ? api.getAutoLaunch() : Promise.resolve(null), ]) - .then(([autoEnabled, config, spaceId, spaces]) => { + .then(([autoEnabled, config, spaceId, spaces, autoLaunch]) => { if (!mounted) return; setEnabled(autoEnabled); if (config) setShortcuts(config); setActiveSpaceId(spaceId); if (spaces) setSearchSpaces(spaces); + if (autoLaunch) { + setAutoLaunchEnabled(autoLaunch.enabled); + setAutoLaunchHidden(autoLaunch.openAsHidden); + setAutoLaunchSupported(autoLaunch.supported); + } setLoading(false); setShortcutsLoaded(true); }) @@ -106,6 +119,40 @@ export function DesktopContent() { updateShortcut(key, DEFAULT_SHORTCUTS[key]); }; + const handleAutoLaunchToggle = async (checked: boolean) => { + if (!autoLaunchSupported || !api.setAutoLaunch) { + toast.error("Please update the desktop app to configure launch on startup"); + return; + } + setAutoLaunchEnabled(checked); + try { + const next = await api.setAutoLaunch(checked, autoLaunchHidden); + if (next) { + setAutoLaunchEnabled(next.enabled); + setAutoLaunchHidden(next.openAsHidden); + setAutoLaunchSupported(next.supported); + } + toast.success(checked ? "SurfSense will launch on startup" : "Launch on startup disabled"); + } catch { + setAutoLaunchEnabled(!checked); + toast.error("Failed to update launch on startup"); + } + }; + + const handleAutoLaunchHiddenToggle = async (checked: boolean) => { + if (!autoLaunchSupported || !api.setAutoLaunch) { + toast.error("Please update the desktop app to configure startup behavior"); + return; + } + setAutoLaunchHidden(checked); + try { + await api.setAutoLaunch(autoLaunchEnabled, checked); + } catch { + setAutoLaunchHidden(!checked); + toast.error("Failed to update startup behavior"); + } + }; + const handleSearchSpaceChange = (value: string) => { setActiveSpaceId(value); api.setActiveSearchSpace?.(value); @@ -145,6 +192,60 @@ export function DesktopContent() { + {/* Launch on Startup */} + + + + + Launch on Startup + + + Automatically start SurfSense when you sign in to your computer so global + shortcuts and folder sync are always available. + + + +
+
+ +

+ {autoLaunchSupported + ? "Adds SurfSense to your system's login items." + : "Only available in the packaged desktop app."} +

+
+ +
+
+
+ +

+ Skip the main window on boot — SurfSense lives in the system tray until you need + it. +

+
+ +
+
+
+ {/* Keyboard Shortcuts */} 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/atoms/chat/report-panel.atom.ts b/surfsense_web/atoms/chat/report-panel.atom.ts index edae8979d..c80230f05 100644 --- a/surfsense_web/atoms/chat/report-panel.atom.ts +++ b/surfsense_web/atoms/chat/report-panel.atom.ts @@ -8,6 +8,8 @@ interface ReportPanelState { wordCount: number | null; /** When set, uses public endpoints for fetching report data (public shared chat) */ shareToken: string | null; + /** Content type of the report — "markdown" (default) or "typst" (resume) */ + contentType: string; } const initialState: ReportPanelState = { @@ -16,6 +18,7 @@ const initialState: ReportPanelState = { title: null, wordCount: null, shareToken: null, + contentType: "markdown", }; /** Core atom holding the report panel state */ @@ -38,7 +41,14 @@ export const openReportPanelAtom = atom( title, wordCount, shareToken, - }: { reportId: number; title: string; wordCount?: number; shareToken?: string | null } + contentType, + }: { + reportId: number; + title: string; + wordCount?: number; + shareToken?: string | null; + contentType?: string; + } ) => { if (!get(reportPanelAtom).isOpen) { set(preReportCollapsedAtom, get(rightPanelCollapsedAtom)); @@ -49,6 +59,7 @@ export const openReportPanelAtom = atom( title, wordCount: wordCount ?? null, shareToken: shareToken ?? null, + contentType: contentType ?? "markdown", }); set(rightPanelTabAtom, "report"); set(rightPanelCollapsedAtom, false); diff --git a/surfsense_web/components/assistant-ui/assistant-message.tsx b/surfsense_web/components/assistant-ui/assistant-message.tsx index f159d42d2..ef7e217ec 100644 --- a/surfsense_web/components/assistant-ui/assistant-message.tsx +++ b/surfsense_web/components/assistant-ui/assistant-message.tsx @@ -71,6 +71,13 @@ const GenerateReportToolUI = dynamic( })), { ssr: false } ); +const GenerateResumeToolUI = dynamic( + () => + import("@/components/tool-ui/generate-resume").then((m) => ({ + default: m.GenerateResumeToolUI, + })), + { ssr: false } +); const GeneratePodcastToolUI = dynamic( () => import("@/components/tool-ui/generate-podcast").then((m) => ({ @@ -487,6 +494,7 @@ const AssistantMessageInner: FC = () => { tools: { by_name: { generate_report: GenerateReportToolUI, + generate_resume: GenerateResumeToolUI, generate_podcast: GeneratePodcastToolUI, generate_video_presentation: GenerateVideoPresentationToolUI, display_image: GenerateImageToolUI, @@ -537,7 +545,7 @@ const AssistantMessageInner: FC = () => { )} -
+
diff --git a/surfsense_web/components/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/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/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/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 ( - + + + {Math.round(scale * 100)}% + + +
+ )} + +
+ {loading ? ( +
+ +
+ ) : ( +
+ {pageDimsRef.current.map((dims, i) => { + const pageNum = i + 1; + const scaledWidth = Math.floor(dims.width * scale); + const scaledHeight = Math.floor(dims.height * scale); + return ( +
+ setCanvasRef(pageNum, el)} + className="shadow-lg absolute inset-0" + /> + {numPages > 1 && ( + + Page {pageNum}/{numPages} + + )} +
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/surfsense_web/components/report-panel/report-panel.tsx b/surfsense_web/components/report-panel/report-panel.tsx index 6ec2a08eb..591155757 100644 --- a/surfsense_web/components/report-panel/report-panel.tsx +++ b/surfsense_web/components/report-panel/report-panel.tsx @@ -18,6 +18,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { Spinner } from "@/components/ui/spinner"; import { useMediaQuery } from "@/hooks/use-media-query"; import { baseApiService } from "@/lib/apis/base-api.service"; import { authenticatedFetch } from "@/lib/auth-utils"; @@ -53,6 +54,11 @@ const PlateEditor = dynamic( { ssr: false, loading: () => } ); +const PdfViewer = dynamic( + () => import("@/components/report-panel/pdf-viewer").then((m) => ({ default: m.PdfViewer })), + { ssr: false, loading: () => } +); + /** * Zod schema for a single version entry */ @@ -68,6 +74,7 @@ const ReportContentResponseSchema = z.object({ id: z.number(), title: z.string(), content: z.string().nullish(), + content_type: z.string().default("markdown"), report_metadata: z .object({ status: z.enum(["ready", "failed"]).nullish(), @@ -280,47 +287,63 @@ export function ReportPanelContent({ }, [activeReportId, currentMarkdown]); const activeVersionIndex = versions.findIndex((v) => v.id === activeReportId); + const isPublic = !!shareToken; + const btnBg = isPublic ? "bg-main-panel" : "bg-sidebar"; return ( <> {/* Action bar — always visible; buttons are disabled while loading */} -
+
- {/* Copy button */} - - - {/* Export dropdown */} - - - - - - + {copied ? "Copied" : "Copy"} + + )} + + {/* Export — plain button for resume (typst), dropdown for others */} + {reportContent?.content_type === "typst" ? ( + + ) : ( + + + + + + + + + )} {/* Version switcher — only shown when multiple versions exist */} {versions.length > 1 && ( @@ -329,7 +352,7 @@ export function ReportPanelContent({
); } diff --git a/surfsense_web/components/tool-ui/generate-resume.tsx b/surfsense_web/components/tool-ui/generate-resume.tsx new file mode 100644 index 000000000..f329ff95d --- /dev/null +++ b/surfsense_web/components/tool-ui/generate-resume.tsx @@ -0,0 +1,334 @@ +"use client"; + +import type { ToolCallMessagePartProps } from "@assistant-ui/react"; +import { useAtomValue, useSetAtom } from "jotai"; +import { useParams, usePathname } from "next/navigation"; +import * as pdfjsLib from "pdfjs-dist"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { z } from "zod"; +import { openReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom"; +import { TextShimmerLoader } from "@/components/prompt-kit/loader"; +import { useMediaQuery } from "@/hooks/use-media-query"; +import { getAuthHeaders } from "@/lib/auth-utils"; + +pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( + "pdfjs-dist/build/pdf.worker.min.mjs", + import.meta.url +).toString(); + +const GenerateResumeArgsSchema = z.object({ + user_info: z.string(), + user_instructions: z.string().nullish(), + parent_report_id: z.number().nullish(), +}); + +const GenerateResumeResultSchema = z.object({ + status: z.enum(["ready", "failed"]), + report_id: z.number().nullish(), + title: z.string().nullish(), + content_type: z.string().nullish(), + message: z.string().nullish(), + error: z.string().nullish(), +}); + +type GenerateResumeArgs = z.infer; +type GenerateResumeResult = z.infer; + +function ResumeGeneratingState() { + return ( +
+
+
+

Resume

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

Resume Generation Failed

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

{title}

+ )} +

+ {error} +

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

Resume Cancelled

+
+

Resume generation was cancelled

+
+
+ ); +} + +function ThumbnailSkeleton() { + return ( +
+
+
+
+
+
+
+ ); +} + +function PdfThumbnail({ + pdfUrl, + onLoad, + onError, +}: { + pdfUrl: string; + onLoad: () => void; + onError: () => void; +}) { + const wrapperRef = useRef(null); + const canvasRef = useRef(null); + const [ready, setReady] = useState(false); + + useEffect(() => { + let cancelled = false; + + const renderThumbnail = async () => { + try { + const loadingTask = pdfjsLib.getDocument({ + url: pdfUrl, + httpHeaders: getAuthHeaders(), + }); + + const pdf = await loadingTask.promise; + if (cancelled) { + pdf.destroy(); + return; + } + + const page = await pdf.getPage(1); + if (cancelled) { + pdf.destroy(); + return; + } + + const canvas = canvasRef.current; + if (!canvas) { + pdf.destroy(); + return; + } + + const containerWidth = wrapperRef.current?.clientWidth || 400; + const unscaledViewport = page.getViewport({ scale: 1 }); + const fitScale = containerWidth / unscaledViewport.width; + const viewport = page.getViewport({ scale: fitScale }); + const dpr = window.devicePixelRatio || 1; + + canvas.width = Math.ceil(viewport.width * dpr); + canvas.height = Math.ceil(viewport.height * dpr); + + await page.render({ + canvas, + viewport, + transform: dpr !== 1 ? [dpr, 0, 0, dpr, 0, 0] : undefined, + }).promise; + + if (!cancelled) { + setReady(true); + onLoad(); + } + + pdf.destroy(); + } catch { + if (!cancelled) onError(); + } + }; + + renderThumbnail(); + return () => { + cancelled = true; + }; + }, [pdfUrl, onLoad, onError]); + + return ( +
+ +
+ ); +} + +function ResumeCard({ + reportId, + title, + shareToken, + autoOpen = false, +}: { + reportId: number; + title: string; + shareToken?: string | null; + autoOpen?: boolean; +}) { + const openPanel = useSetAtom(openReportPanelAtom); + const panelState = useAtomValue(reportPanelAtom); + const isDesktop = useMediaQuery("(min-width: 768px)"); + const autoOpenedRef = useRef(false); + const [pdfUrl, setPdfUrl] = useState(null); + const [thumbState, setThumbState] = useState<"loading" | "ready" | "error">("loading"); + + useEffect(() => { + const previewPath = shareToken + ? `/api/v1/public/${shareToken}/reports/${reportId}/preview` + : `/api/v1/reports/${reportId}/preview`; + setPdfUrl(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}${previewPath}`); + + if (autoOpen && isDesktop && !autoOpenedRef.current) { + autoOpenedRef.current = true; + openPanel({ + reportId, + title, + shareToken, + contentType: "typst", + }); + } + }, [reportId, title, shareToken, autoOpen, isDesktop, openPanel]); + + const onThumbLoad = useCallback(() => setThumbState("ready"), []); + const onThumbError = useCallback(() => setThumbState("error"), []); + + const isActive = panelState.isOpen && panelState.reportId === reportId; + + const handleOpen = () => { + openPanel({ + reportId, + title, + shareToken, + contentType: "typst", + }); + }; + + return ( +
+ +
+ ); +} + +export const GenerateResumeToolUI = ({ + result, + status, +}: ToolCallMessagePartProps) => { + const params = useParams(); + const pathname = usePathname(); + const isPublicRoute = pathname?.startsWith("/public/"); + const shareToken = isPublicRoute && typeof params?.token === "string" ? params.token : null; + + const sawRunningRef = useRef(false); + if (status.type === "running" || status.type === "requires-action") { + sawRunningRef.current = true; + } + + if (status.type === "running" || status.type === "requires-action") { + return ; + } + + if (status.type === "incomplete") { + if (status.reason === "cancelled") { + return ; + } + if (status.reason === "error") { + return ( + + ); + } + } + + if (!result) { + return ; + } + + if (result.status === "failed") { + return ( + + ); + } + + if (result.status === "ready" && result.report_id) { + return ( + + ); + } + + return ; +}; diff --git a/surfsense_web/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= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/canvas-darwin-arm64@0.1.97': + resolution: {integrity: sha512-ok+SCEF4YejcxuJ9Rm+WWunHHpf2HmiPxfz6z1a/NFQECGXtsY7A4B8XocK1LmT1D7P174MzwPF9Wy3AUAwEPw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/canvas-darwin-x64@0.1.97': + resolution: {integrity: sha512-PUP6e6/UGlclUvAQNnuXCcnkpdUou6VYZfQOQxExLp86epOylmiwLkqXIvpFmjoTEDmPmXrI+coL/9EFU1gKPA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.97': + resolution: {integrity: sha512-XyXH2L/cic8eTNtbrXCcvqHtMX/nEOxN18+7rMrAM2XtLYC/EB5s0wnO1FsLMWmK+04ZSLN9FBGipo7kpIkcOw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/canvas-linux-arm64-gnu@0.1.97': + resolution: {integrity: sha512-Kuq/M3djq0K8ktgz6nPlK7Ne5d4uWeDxPpyKWOjWDK2RIOhHVtLtyLiJw2fuldw7Vn4mhw05EZXCEr4Q76rs9w==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-arm64-musl@0.1.97': + resolution: {integrity: sha512-kKmSkQVnWeqg7qdsiXvYxKhAFuHz3tkBjW/zyQv5YKUPhotpaVhpBGv5LqCngzyuRV85SXoe+OFj+Tv0a0QXkQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.97': + resolution: {integrity: sha512-Jc7I3A51jnEOIAXeLsN/M/+Z28LUeakcsXs07FLq9prXc0eYOtVwsDEv913Gr+06IRo34gJJVgT0TXvmz+N2VA==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + + '@napi-rs/canvas-linux-x64-gnu@0.1.97': + resolution: {integrity: sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-linux-x64-musl@0.1.97': + resolution: {integrity: sha512-AKLFd/v0Z5fvgqBDqhvqtAdx+fHMJ5t9JcUNKq4FIZ5WH+iegGm8HPdj00NFlCSnm83Fp3Ln8I2f7uq1aIiWaA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-win32-arm64-msvc@0.1.97': + resolution: {integrity: sha512-u883Yr6A6fO7Vpsy9YE4FVCIxzzo5sO+7pIUjjoDLjS3vQaNMkVzx5bdIpEL+ob+gU88WDK4VcxYMZ6nmnoX9A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/canvas-win32-x64-msvc@0.1.97': + resolution: {integrity: sha512-sWtD2EE3fV0IzN+iiQUqr/Q1SwqWhs2O1FKItFlxtdDkikpEj5g7DKQpY3x55H/MAOnL8iomnlk3mcEeGiUMoQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/canvas@0.1.97': + resolution: {integrity: sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ==} + engines: {node: '>= 10'} + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -7027,6 +7100,9 @@ packages: encoding: optional: true + node-readable-to-web-readable-stream@0.4.2: + resolution: {integrity: sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==} + node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} @@ -7168,6 +7244,10 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pdfjs-dist@5.6.205: + resolution: {integrity: sha512-tlUj+2IDa7G1SbvBNN74UHRLJybZDWYom+k6p5KIZl7huBvsA4APi6mKL+zCxd3tLjN5hOOEE9Tv7VdzO88pfg==} + engines: {node: '>=20.19.0 || >=22.13.0 || >=24'} + performance-now@2.1.0: resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} @@ -9992,6 +10072,54 @@ snapshots: dependencies: mediabunny: 1.39.2 + '@napi-rs/canvas-android-arm64@0.1.97': + optional: true + + '@napi-rs/canvas-darwin-arm64@0.1.97': + optional: true + + '@napi-rs/canvas-darwin-x64@0.1.97': + optional: true + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.97': + optional: true + + '@napi-rs/canvas-linux-arm64-gnu@0.1.97': + optional: true + + '@napi-rs/canvas-linux-arm64-musl@0.1.97': + optional: true + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.97': + optional: true + + '@napi-rs/canvas-linux-x64-gnu@0.1.97': + optional: true + + '@napi-rs/canvas-linux-x64-musl@0.1.97': + optional: true + + '@napi-rs/canvas-win32-arm64-msvc@0.1.97': + optional: true + + '@napi-rs/canvas-win32-x64-msvc@0.1.97': + optional: true + + '@napi-rs/canvas@0.1.97': + optionalDependencies: + '@napi-rs/canvas-android-arm64': 0.1.97 + '@napi-rs/canvas-darwin-arm64': 0.1.97 + '@napi-rs/canvas-darwin-x64': 0.1.97 + '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.97 + '@napi-rs/canvas-linux-arm64-gnu': 0.1.97 + '@napi-rs/canvas-linux-arm64-musl': 0.1.97 + '@napi-rs/canvas-linux-riscv64-gnu': 0.1.97 + '@napi-rs/canvas-linux-x64-gnu': 0.1.97 + '@napi-rs/canvas-linux-x64-musl': 0.1.97 + '@napi-rs/canvas-win32-arm64-msvc': 0.1.97 + '@napi-rs/canvas-win32-x64-msvc': 0.1.97 + optional: true + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.8.1 @@ -15830,6 +15958,9 @@ snapshots: dependencies: whatwg-url: 5.0.0 + node-readable-to-web-readable-stream@0.4.2: + optional: true + node-releases@2.0.27: {} npm-run-path@4.0.1: @@ -15992,6 +16123,11 @@ snapshots: path-type@4.0.0: {} + pdfjs-dist@5.6.205: + optionalDependencies: + '@napi-rs/canvas': 0.1.97 + node-readable-to-web-readable-stream: 0.4.2 + performance-now@2.1.0: {} pg-cloudflare@1.3.0: diff --git a/surfsense_web/types/window.d.ts b/surfsense_web/types/window.d.ts index 004aefcd5..a80520684 100644 --- a/surfsense_web/types/window.d.ts +++ b/surfsense_web/types/window.d.ts @@ -102,9 +102,29 @@ interface ElectronAPI { setShortcuts: ( config: Partial<{ generalAssist: string; quickAsk: string; autocomplete: string }> ) => Promise<{ generalAssist: string; quickAsk: string; autocomplete: string }>; + // Launch on system startup + getAutoLaunch: () => Promise<{ + enabled: boolean; + openAsHidden: boolean; + supported: boolean; + }>; + setAutoLaunch: ( + enabled: boolean, + openAsHidden?: boolean + ) => Promise<{ enabled: boolean; openAsHidden: boolean; supported: boolean }>; // Active search space getActiveSearchSpace: () => 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 {