mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 00:36:31 +02:00
Merge pull request #1278 from MODSetter/dev
feat: resume builder, desktop analytics, windows signing & auto-launch, plus UI/perf fixes
This commit is contained in:
commit
afd1d87b5d
56 changed files with 3255 additions and 221 deletions
45
.github/workflows/desktop-release.yml
vendored
45
.github/workflows/desktop-release.yml
vendored
|
|
@ -22,6 +22,7 @@ on:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
|
@ -58,6 +59,30 @@ jobs:
|
||||||
fi
|
fi
|
||||||
echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT"
|
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
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v5
|
uses: pnpm/action-setup@v5
|
||||||
|
|
||||||
|
|
@ -98,7 +123,25 @@ jobs:
|
||||||
|
|
||||||
- name: Package & Publish
|
- name: Package & Publish
|
||||||
shell: bash
|
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
|
working-directory: surfsense_desktop
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
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 }}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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'")
|
||||||
|
|
@ -443,6 +443,52 @@ _TOOL_EXAMPLES["web_search"] = """
|
||||||
- Call: `web_search(query="weather New York today")`
|
- Call: `web_search(query="weather New York today")`
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
_TOOL_INSTRUCTIONS["generate_resume"] = """
|
||||||
|
- generate_resume: Generate or revise a professional resume as a Typst document.
|
||||||
|
- WHEN TO CALL: The user asks to create, build, generate, write, or draft a resume or CV.
|
||||||
|
Also when they ask to modify, update, or revise an existing resume from this conversation.
|
||||||
|
- WHEN NOT TO CALL: General career advice, resume tips, cover letters, or reviewing
|
||||||
|
a resume without making changes. For cover letters, use generate_report instead.
|
||||||
|
- The tool produces Typst source code that is compiled to a PDF preview automatically.
|
||||||
|
- Args:
|
||||||
|
- user_info: The user's resume content — work experience, education, skills, contact
|
||||||
|
info, etc. Can be structured or unstructured text.
|
||||||
|
CRITICAL: user_info must be COMPREHENSIVE. Do NOT just pass the user's raw message.
|
||||||
|
You MUST gather and consolidate ALL available information:
|
||||||
|
* Content from referenced/mentioned documents (e.g., uploaded resumes, CVs, LinkedIn profiles)
|
||||||
|
that appear in the conversation context — extract and include their FULL content.
|
||||||
|
* Information the user shared across multiple messages in the conversation.
|
||||||
|
* Any relevant details from knowledge base search results in the context.
|
||||||
|
The more complete the user_info, the better the resume. Include names, contact info,
|
||||||
|
work experience with dates, education, skills, projects, certifications — everything available.
|
||||||
|
- user_instructions: Optional style or content preferences (e.g. "emphasize leadership",
|
||||||
|
"keep it to one page"). For revisions, describe what to change.
|
||||||
|
- parent_report_id: Set this when the user wants to MODIFY an existing resume from
|
||||||
|
this conversation. Use the report_id from a previous generate_resume result.
|
||||||
|
- Returns: Dict with status, report_id, title, and content_type.
|
||||||
|
- After calling: Give a brief confirmation. Do NOT paste resume content in chat. Do NOT mention report_id or any internal IDs — the resume card is shown automatically.
|
||||||
|
- VERSIONING: Same rules as generate_report — set parent_report_id for modifications
|
||||||
|
of an existing resume, leave as None for new resumes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_TOOL_EXAMPLES["generate_resume"] = """
|
||||||
|
- User: "Build me a resume. I'm John Doe, engineer at Acme Corp..."
|
||||||
|
- Call: `generate_resume(user_info="John Doe, engineer at Acme Corp...")`
|
||||||
|
- WHY: Has creation verb "build" + resume → call the tool.
|
||||||
|
- User: "Create my CV with this info: [experience, education, skills]"
|
||||||
|
- Call: `generate_resume(user_info="[experience, education, skills]")`
|
||||||
|
- User: "Build me a resume" (and there is a resume/CV document in the conversation context)
|
||||||
|
- Extract the FULL content from the document in context, then call:
|
||||||
|
`generate_resume(user_info="Name: John Doe\\nEmail: john@example.com\\n\\nExperience:\\n- Senior Engineer at Acme Corp (2020-2024)\\n Led team of 5...\\n\\nEducation:\\n- BS Computer Science, MIT (2016-2020)\\n\\nSkills: Python, TypeScript, AWS...")`
|
||||||
|
- WHY: Document content is available in context — extract ALL of it into user_info. Do NOT ignore referenced documents.
|
||||||
|
- User: (after resume generated) "Change my title to Senior Engineer"
|
||||||
|
- Call: `generate_resume(user_info="", user_instructions="Change the job title to Senior Engineer", parent_report_id=<previous_report_id>)`
|
||||||
|
- WHY: Modification verb "change" + refers to existing resume → set parent_report_id.
|
||||||
|
- User: "How should I structure my resume?"
|
||||||
|
- Do NOT call generate_resume. Answer in chat with advice.
|
||||||
|
- WHY: No creation/modification verb.
|
||||||
|
"""
|
||||||
|
|
||||||
# All tool names that have prompt instructions (order matters for prompt readability)
|
# All tool names that have prompt instructions (order matters for prompt readability)
|
||||||
_ALL_TOOL_NAMES_ORDERED = [
|
_ALL_TOOL_NAMES_ORDERED = [
|
||||||
"search_surfsense_docs",
|
"search_surfsense_docs",
|
||||||
|
|
@ -450,6 +496,7 @@ _ALL_TOOL_NAMES_ORDERED = [
|
||||||
"generate_podcast",
|
"generate_podcast",
|
||||||
"generate_video_presentation",
|
"generate_video_presentation",
|
||||||
"generate_report",
|
"generate_report",
|
||||||
|
"generate_resume",
|
||||||
"generate_image",
|
"generate_image",
|
||||||
"scrape_webpage",
|
"scrape_webpage",
|
||||||
"update_memory",
|
"update_memory",
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,7 @@ from .onedrive import (
|
||||||
)
|
)
|
||||||
from .podcast import create_generate_podcast_tool
|
from .podcast import create_generate_podcast_tool
|
||||||
from .report import create_generate_report_tool
|
from .report import create_generate_report_tool
|
||||||
|
from .resume import create_generate_resume_tool
|
||||||
from .scrape_webpage import create_scrape_webpage_tool
|
from .scrape_webpage import create_scrape_webpage_tool
|
||||||
from .search_surfsense_docs import create_search_surfsense_docs_tool
|
from .search_surfsense_docs import create_search_surfsense_docs_tool
|
||||||
from .update_memory import create_update_memory_tool, create_update_team_memory_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
|
# are optional — when missing, source_strategy="kb_search" degrades
|
||||||
# gracefully to "provided"
|
# 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.)
|
# Generate image tool - creates images using AI models (DALL-E, GPT Image, etc.)
|
||||||
ToolDefinition(
|
ToolDefinition(
|
||||||
name="generate_image",
|
name="generate_image",
|
||||||
|
|
|
||||||
665
surfsense_backend/app/agents/new_chat/tools/resume.py
Normal file
665
surfsense_backend/app/agents/new_chat/tools/resume.py
Normal file
|
|
@ -0,0 +1,665 @@
|
||||||
|
"""
|
||||||
|
Resume generation tool for the SurfSense agent.
|
||||||
|
|
||||||
|
Generates a structured resume as Typst source code using the rendercv package.
|
||||||
|
The LLM outputs only the content body (= heading, sections, entries) while
|
||||||
|
the template header (import + show rule) is hardcoded and prepended by the
|
||||||
|
backend. This eliminates LLM errors in the complex configuration block.
|
||||||
|
|
||||||
|
Templates are stored in a registry so new designs can be added by defining
|
||||||
|
a new entry in _TEMPLATES.
|
||||||
|
|
||||||
|
Uses the same short-lived session pattern as generate_report so no DB
|
||||||
|
connection is held during the long LLM call.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import typst
|
||||||
|
from langchain_core.callbacks import dispatch_custom_event
|
||||||
|
from langchain_core.messages import HumanMessage
|
||||||
|
from langchain_core.tools import tool
|
||||||
|
|
||||||
|
from app.db import Report, shielded_async_session
|
||||||
|
from app.services.llm_service import get_document_summary_llm
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Template Registry ───────────────────────────────────────────────────────
|
||||||
|
# Each template defines:
|
||||||
|
# header - Typst import + show rule with {name}, {year}, {month}, {day} placeholders
|
||||||
|
# component_reference - component docs shown to the LLM
|
||||||
|
# rules - generation rules for the LLM
|
||||||
|
|
||||||
|
_TEMPLATES: dict[str, dict[str, str]] = {
|
||||||
|
"classic": {
|
||||||
|
"header": """\
|
||||||
|
#import "@preview/rendercv:0.3.0": *
|
||||||
|
|
||||||
|
#show: rendercv.with(
|
||||||
|
name: "{name}",
|
||||||
|
title: "{name} - Resume",
|
||||||
|
footer: context {{ [#emph[{name} -- #str(here().page())\\/#str(counter(page).final().first())]] }},
|
||||||
|
top-note: [ #emph[Last updated in {month_name} {year}] ],
|
||||||
|
locale-catalog-language: "en",
|
||||||
|
text-direction: ltr,
|
||||||
|
page-size: "us-letter",
|
||||||
|
page-top-margin: 0.7in,
|
||||||
|
page-bottom-margin: 0.7in,
|
||||||
|
page-left-margin: 0.7in,
|
||||||
|
page-right-margin: 0.7in,
|
||||||
|
page-show-footer: false,
|
||||||
|
page-show-top-note: true,
|
||||||
|
colors-body: rgb(0, 0, 0),
|
||||||
|
colors-name: rgb(0, 0, 0),
|
||||||
|
colors-headline: rgb(0, 0, 0),
|
||||||
|
colors-connections: rgb(0, 0, 0),
|
||||||
|
colors-section-titles: rgb(0, 0, 0),
|
||||||
|
colors-links: rgb(0, 0, 0),
|
||||||
|
colors-footer: rgb(128, 128, 128),
|
||||||
|
colors-top-note: rgb(128, 128, 128),
|
||||||
|
typography-line-spacing: 0.6em,
|
||||||
|
typography-alignment: "justified",
|
||||||
|
typography-date-and-location-column-alignment: right,
|
||||||
|
typography-font-family-body: "XCharter",
|
||||||
|
typography-font-family-name: "XCharter",
|
||||||
|
typography-font-family-headline: "XCharter",
|
||||||
|
typography-font-family-connections: "XCharter",
|
||||||
|
typography-font-family-section-titles: "XCharter",
|
||||||
|
typography-font-size-body: 10pt,
|
||||||
|
typography-font-size-name: 25pt,
|
||||||
|
typography-font-size-headline: 10pt,
|
||||||
|
typography-font-size-connections: 10pt,
|
||||||
|
typography-font-size-section-titles: 1.2em,
|
||||||
|
typography-small-caps-name: false,
|
||||||
|
typography-small-caps-headline: false,
|
||||||
|
typography-small-caps-connections: false,
|
||||||
|
typography-small-caps-section-titles: false,
|
||||||
|
typography-bold-name: false,
|
||||||
|
typography-bold-headline: false,
|
||||||
|
typography-bold-connections: false,
|
||||||
|
typography-bold-section-titles: true,
|
||||||
|
links-underline: true,
|
||||||
|
links-show-external-link-icon: false,
|
||||||
|
header-alignment: center,
|
||||||
|
header-photo-width: 3.5cm,
|
||||||
|
header-space-below-name: 0.7cm,
|
||||||
|
header-space-below-headline: 0.7cm,
|
||||||
|
header-space-below-connections: 0.7cm,
|
||||||
|
header-connections-hyperlink: true,
|
||||||
|
header-connections-show-icons: false,
|
||||||
|
header-connections-display-urls-instead-of-usernames: true,
|
||||||
|
header-connections-separator: "|",
|
||||||
|
header-connections-space-between-connections: 0.5cm,
|
||||||
|
section-titles-type: "with_full_line",
|
||||||
|
section-titles-line-thickness: 0.5pt,
|
||||||
|
section-titles-space-above: 0.5cm,
|
||||||
|
section-titles-space-below: 0.3cm,
|
||||||
|
sections-allow-page-break: true,
|
||||||
|
sections-space-between-text-based-entries: 0.15cm,
|
||||||
|
sections-space-between-regular-entries: 0.42cm,
|
||||||
|
entries-date-and-location-width: 4.15cm,
|
||||||
|
entries-side-space: 0cm,
|
||||||
|
entries-space-between-columns: 0.1cm,
|
||||||
|
entries-allow-page-break: false,
|
||||||
|
entries-short-second-row: false,
|
||||||
|
entries-degree-width: 1cm,
|
||||||
|
entries-summary-space-left: 0cm,
|
||||||
|
entries-summary-space-above: 0.08cm,
|
||||||
|
entries-highlights-bullet: text(13pt, [\\u{2022}], baseline: -0.6pt),
|
||||||
|
entries-highlights-nested-bullet: text(13pt, [\\u{2022}], baseline: -0.6pt),
|
||||||
|
entries-highlights-space-left: 0cm,
|
||||||
|
entries-highlights-space-above: 0.08cm,
|
||||||
|
entries-highlights-space-between-items: 0.08cm,
|
||||||
|
entries-highlights-space-between-bullet-and-text: 0.3em,
|
||||||
|
date: datetime(
|
||||||
|
year: {year},
|
||||||
|
month: {month},
|
||||||
|
day: {day},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
""",
|
||||||
|
"component_reference": """\
|
||||||
|
Available components (use ONLY these):
|
||||||
|
|
||||||
|
= Full Name // Top-level heading — person's full name
|
||||||
|
|
||||||
|
#connections( // Contact info row (pipe-separated)
|
||||||
|
[City, Country],
|
||||||
|
[#link("mailto:email@example.com", icon: false, if-underline: false, if-color: false)[email\\@example.com]],
|
||||||
|
[#link("https://linkedin.com/in/user", icon: false, if-underline: false, if-color: false)[linkedin.com\\/in\\/user]],
|
||||||
|
[#link("https://github.com/user", icon: false, if-underline: false, if-color: false)[github.com\\/user]],
|
||||||
|
)
|
||||||
|
|
||||||
|
== Section Title // Section heading (arbitrary name)
|
||||||
|
|
||||||
|
#regular-entry( // Work experience, projects, publications, etc.
|
||||||
|
[
|
||||||
|
#strong[Role/Title], Company Name -- Location
|
||||||
|
],
|
||||||
|
[
|
||||||
|
Start -- End
|
||||||
|
],
|
||||||
|
main-column-second-row: [
|
||||||
|
- Achievement or responsibility
|
||||||
|
- Another bullet point
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
#education-entry( // Education entries
|
||||||
|
[
|
||||||
|
#strong[Institution], Degree in Field -- Location
|
||||||
|
],
|
||||||
|
[
|
||||||
|
Start -- End
|
||||||
|
],
|
||||||
|
main-column-second-row: [
|
||||||
|
- GPA, honours, relevant coursework
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
#summary([Short paragraph summary]) // Optional summary inside an entry
|
||||||
|
#content-area([Free-form content]) // Freeform text block
|
||||||
|
|
||||||
|
For skills sections, use bold labels directly:
|
||||||
|
#strong[Category:] item1, item2, item3
|
||||||
|
|
||||||
|
For simple list sections (e.g. Honors), use plain bullet points:
|
||||||
|
- Item one
|
||||||
|
- Item two
|
||||||
|
""",
|
||||||
|
"rules": """\
|
||||||
|
RULES:
|
||||||
|
- Do NOT include any #import or #show lines. Start directly with = Full Name.
|
||||||
|
- Output ONLY valid Typst content. No explanatory text before or after.
|
||||||
|
- Do NOT wrap output in ```typst code fences.
|
||||||
|
- The = heading MUST use the person's COMPLETE full name exactly as provided. NEVER shorten or abbreviate.
|
||||||
|
- Escape @ symbols inside link labels with a backslash: email\\@example.com
|
||||||
|
- Escape forward slashes in link display text: linkedin.com\\/in\\/user
|
||||||
|
- Every section MUST use == heading.
|
||||||
|
- Use #regular-entry() for experience, projects, publications, certifications, and similar entries.
|
||||||
|
- Use #education-entry() for education.
|
||||||
|
- Use #strong[Label:] for skills categories.
|
||||||
|
- Keep content professional, concise, and achievement-oriented.
|
||||||
|
- Use action verbs for bullet points (Led, Built, Designed, Reduced, etc.).
|
||||||
|
- This template works for ALL professions — adapt sections to the user's field.
|
||||||
|
""",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_TEMPLATE = "classic"
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Template Helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _get_template(template_id: str | None = None) -> dict[str, str]:
|
||||||
|
"""Get a template by ID, falling back to default."""
|
||||||
|
return _TEMPLATES.get(template_id or DEFAULT_TEMPLATE, _TEMPLATES[DEFAULT_TEMPLATE])
|
||||||
|
|
||||||
|
|
||||||
|
_MONTH_NAMES = [
|
||||||
|
"",
|
||||||
|
"Jan",
|
||||||
|
"Feb",
|
||||||
|
"Mar",
|
||||||
|
"Apr",
|
||||||
|
"May",
|
||||||
|
"Jun",
|
||||||
|
"Jul",
|
||||||
|
"Aug",
|
||||||
|
"Sep",
|
||||||
|
"Oct",
|
||||||
|
"Nov",
|
||||||
|
"Dec",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_header(template: dict[str, str], name: str) -> str:
|
||||||
|
"""Build the template header with the person's name and current date."""
|
||||||
|
now = datetime.now(tz=UTC)
|
||||||
|
return (
|
||||||
|
template["header"]
|
||||||
|
.replace("{name}", name)
|
||||||
|
.replace("{year}", str(now.year))
|
||||||
|
.replace("{month}", str(now.month))
|
||||||
|
.replace("{day}", str(now.day))
|
||||||
|
.replace("{month_name}", _MONTH_NAMES[now.month])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_header(full_source: str) -> str:
|
||||||
|
"""Strip the import + show rule from stored source to get the body only.
|
||||||
|
|
||||||
|
Finds the closing parenthesis of the rendercv.with(...) block by tracking
|
||||||
|
nesting depth, then returns everything after it.
|
||||||
|
"""
|
||||||
|
show_match = re.search(r"#show:\s*rendercv\.with\(", full_source)
|
||||||
|
if not show_match:
|
||||||
|
return full_source
|
||||||
|
|
||||||
|
start = show_match.end()
|
||||||
|
depth = 1
|
||||||
|
i = start
|
||||||
|
while i < len(full_source) and depth > 0:
|
||||||
|
if full_source[i] == "(":
|
||||||
|
depth += 1
|
||||||
|
elif full_source[i] == ")":
|
||||||
|
depth -= 1
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
return full_source[i:].lstrip("\n")
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_name(body: str) -> str | None:
|
||||||
|
"""Extract the person's full name from the = heading in the body."""
|
||||||
|
match = re.search(r"^=\s+(.+)$", body, re.MULTILINE)
|
||||||
|
return match.group(1).strip() if match else None
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_imports(body: str) -> str:
|
||||||
|
"""Remove any #import or #show lines the LLM might accidentally include."""
|
||||||
|
lines = body.split("\n")
|
||||||
|
cleaned: list[str] = []
|
||||||
|
skip_show = False
|
||||||
|
depth = 0
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
stripped = line.strip()
|
||||||
|
|
||||||
|
if stripped.startswith("#import"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if skip_show:
|
||||||
|
depth += stripped.count("(") - stripped.count(")")
|
||||||
|
if depth <= 0:
|
||||||
|
skip_show = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
if stripped.startswith("#show:") and "rendercv" in stripped:
|
||||||
|
depth = stripped.count("(") - stripped.count(")")
|
||||||
|
if depth > 0:
|
||||||
|
skip_show = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
cleaned.append(line)
|
||||||
|
|
||||||
|
result = "\n".join(cleaned).strip()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _build_llm_reference(template: dict[str, str]) -> str:
|
||||||
|
"""Build the LLM prompt reference from a template."""
|
||||||
|
return f"""\
|
||||||
|
You MUST output valid Typst content for a resume.
|
||||||
|
Do NOT include any #import or #show lines — those are handled automatically.
|
||||||
|
Start directly with the = Full Name heading.
|
||||||
|
|
||||||
|
{template["component_reference"]}
|
||||||
|
|
||||||
|
{template["rules"]}"""
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Prompts ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_RESUME_PROMPT = """\
|
||||||
|
You are an expert resume writer. Generate professional resume content as Typst markup.
|
||||||
|
|
||||||
|
{llm_reference}
|
||||||
|
|
||||||
|
**User Information:**
|
||||||
|
{user_info}
|
||||||
|
|
||||||
|
{user_instructions_section}
|
||||||
|
|
||||||
|
Generate the resume content now (starting with = Full Name):
|
||||||
|
"""
|
||||||
|
|
||||||
|
_REVISION_PROMPT = """\
|
||||||
|
You are an expert resume editor. Modify the existing resume according to the instructions.
|
||||||
|
Apply ONLY the requested changes — do NOT rewrite sections that are not affected.
|
||||||
|
|
||||||
|
{llm_reference}
|
||||||
|
|
||||||
|
**Modification Instructions:** {user_instructions}
|
||||||
|
|
||||||
|
**EXISTING RESUME CONTENT:**
|
||||||
|
|
||||||
|
{previous_content}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Output the complete, updated resume content with the changes applied (starting with = Full Name):
|
||||||
|
"""
|
||||||
|
|
||||||
|
_FIX_COMPILE_PROMPT = """\
|
||||||
|
The resume content you generated failed to compile. Fix the error while preserving all content.
|
||||||
|
|
||||||
|
{llm_reference}
|
||||||
|
|
||||||
|
**Compilation Error:**
|
||||||
|
{error}
|
||||||
|
|
||||||
|
**Full Typst Source (for context — error line numbers refer to this):**
|
||||||
|
{full_source}
|
||||||
|
|
||||||
|
**Your content starts after the template header. Output ONLY the content portion \
|
||||||
|
(starting with = Full Name), NOT the #import or #show rule:**
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_typst_fences(text: str) -> str:
|
||||||
|
"""Remove wrapping ```typst ... ``` fences that LLMs sometimes add."""
|
||||||
|
stripped = text.strip()
|
||||||
|
m = re.match(r"^(`{3,})(?:typst|typ)?\s*\n", stripped)
|
||||||
|
if m:
|
||||||
|
fence = m.group(1)
|
||||||
|
if stripped.endswith(fence):
|
||||||
|
stripped = stripped[m.end() :]
|
||||||
|
stripped = stripped[: -len(fence)].rstrip()
|
||||||
|
return stripped
|
||||||
|
|
||||||
|
|
||||||
|
def _compile_typst(source: str) -> bytes:
|
||||||
|
"""Compile Typst source to PDF bytes. Raises on failure."""
|
||||||
|
return typst.compile(source.encode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Tool Factory ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def create_generate_resume_tool(
|
||||||
|
search_space_id: int,
|
||||||
|
thread_id: int | None = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Factory function to create the generate_resume tool.
|
||||||
|
|
||||||
|
Generates a Typst-based resume, validates it via compilation,
|
||||||
|
and stores the source in the Report table with content_type='typst'.
|
||||||
|
The LLM generates only the content body; the template header is
|
||||||
|
prepended by the backend.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@tool
|
||||||
|
async def generate_resume(
|
||||||
|
user_info: str,
|
||||||
|
user_instructions: str | None = None,
|
||||||
|
parent_report_id: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Generate a professional resume as a Typst document.
|
||||||
|
|
||||||
|
Use this tool when the user asks to create, build, generate, write,
|
||||||
|
or draft a resume or CV. Also use it when the user wants to modify,
|
||||||
|
update, or revise an existing resume generated in this conversation.
|
||||||
|
|
||||||
|
Trigger phrases include:
|
||||||
|
- "build me a resume", "create my resume", "generate a CV"
|
||||||
|
- "update my resume", "change my title", "add my new job"
|
||||||
|
- "make my resume more concise", "reformat my resume"
|
||||||
|
|
||||||
|
Do NOT use this tool for:
|
||||||
|
- General questions about resumes or career advice
|
||||||
|
- Reviewing or critiquing a resume without changes
|
||||||
|
- Cover letters (use generate_report instead)
|
||||||
|
|
||||||
|
VERSIONING — parent_report_id:
|
||||||
|
- Set parent_report_id when the user wants to MODIFY an existing
|
||||||
|
resume that was already generated in this conversation.
|
||||||
|
- Leave as None for new resumes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_info: The user's resume content — work experience,
|
||||||
|
education, skills, contact info, etc. Can be structured
|
||||||
|
or unstructured text.
|
||||||
|
user_instructions: Optional style or content preferences
|
||||||
|
(e.g. "emphasize leadership", "keep it to one page",
|
||||||
|
"use a modern style"). For revisions, describe what to change.
|
||||||
|
parent_report_id: ID of a previous resume to revise (creates
|
||||||
|
new version in the same version group).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with status, report_id, title, and content_type.
|
||||||
|
"""
|
||||||
|
report_group_id: int | None = None
|
||||||
|
parent_content: str | None = None
|
||||||
|
|
||||||
|
template = _get_template()
|
||||||
|
llm_reference = _build_llm_reference(template)
|
||||||
|
|
||||||
|
async def _save_failed_report(error_msg: str) -> int | None:
|
||||||
|
try:
|
||||||
|
async with shielded_async_session() as session:
|
||||||
|
failed = Report(
|
||||||
|
title="Resume",
|
||||||
|
content=None,
|
||||||
|
content_type="typst",
|
||||||
|
report_metadata={
|
||||||
|
"status": "failed",
|
||||||
|
"error_message": error_msg,
|
||||||
|
},
|
||||||
|
report_style="resume",
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
thread_id=thread_id,
|
||||||
|
report_group_id=report_group_id,
|
||||||
|
)
|
||||||
|
session.add(failed)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(failed)
|
||||||
|
if not failed.report_group_id:
|
||||||
|
failed.report_group_id = failed.id
|
||||||
|
await session.commit()
|
||||||
|
logger.info(
|
||||||
|
f"[generate_resume] Saved failed report {failed.id}: {error_msg}"
|
||||||
|
)
|
||||||
|
return failed.id
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"[generate_resume] Could not persist failed report row"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# ── Phase 1: READ ─────────────────────────────────────────────
|
||||||
|
async with shielded_async_session() as read_session:
|
||||||
|
if parent_report_id:
|
||||||
|
parent_report = await read_session.get(Report, parent_report_id)
|
||||||
|
if parent_report:
|
||||||
|
report_group_id = parent_report.report_group_id
|
||||||
|
parent_content = parent_report.content
|
||||||
|
logger.info(
|
||||||
|
f"[generate_resume] Revising from parent {parent_report_id} "
|
||||||
|
f"(group {report_group_id})"
|
||||||
|
)
|
||||||
|
|
||||||
|
llm = await get_document_summary_llm(read_session, search_space_id)
|
||||||
|
|
||||||
|
if not llm:
|
||||||
|
error_msg = (
|
||||||
|
"No LLM configured. Please configure a language model in Settings."
|
||||||
|
)
|
||||||
|
report_id = await _save_failed_report(error_msg)
|
||||||
|
return {
|
||||||
|
"status": "failed",
|
||||||
|
"error": error_msg,
|
||||||
|
"report_id": report_id,
|
||||||
|
"title": "Resume",
|
||||||
|
"content_type": "typst",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Phase 2: LLM GENERATION ───────────────────────────────────
|
||||||
|
|
||||||
|
user_instructions_section = ""
|
||||||
|
if user_instructions:
|
||||||
|
user_instructions_section = (
|
||||||
|
f"**Additional Instructions:** {user_instructions}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if parent_content:
|
||||||
|
dispatch_custom_event(
|
||||||
|
"report_progress",
|
||||||
|
{"phase": "writing", "message": "Updating your resume"},
|
||||||
|
)
|
||||||
|
parent_body = _strip_header(parent_content)
|
||||||
|
prompt = _REVISION_PROMPT.format(
|
||||||
|
llm_reference=llm_reference,
|
||||||
|
user_instructions=user_instructions
|
||||||
|
or "Improve and refine the resume.",
|
||||||
|
previous_content=parent_body,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
dispatch_custom_event(
|
||||||
|
"report_progress",
|
||||||
|
{"phase": "writing", "message": "Building your resume"},
|
||||||
|
)
|
||||||
|
prompt = _RESUME_PROMPT.format(
|
||||||
|
llm_reference=llm_reference,
|
||||||
|
user_info=user_info,
|
||||||
|
user_instructions_section=user_instructions_section,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await llm.ainvoke([HumanMessage(content=prompt)])
|
||||||
|
body = response.content
|
||||||
|
|
||||||
|
if not body or not isinstance(body, str):
|
||||||
|
error_msg = "LLM returned empty or invalid content"
|
||||||
|
report_id = await _save_failed_report(error_msg)
|
||||||
|
return {
|
||||||
|
"status": "failed",
|
||||||
|
"error": error_msg,
|
||||||
|
"report_id": report_id,
|
||||||
|
"title": "Resume",
|
||||||
|
"content_type": "typst",
|
||||||
|
}
|
||||||
|
|
||||||
|
body = _strip_typst_fences(body)
|
||||||
|
body = _strip_imports(body)
|
||||||
|
|
||||||
|
# ── Phase 3: ASSEMBLE + COMPILE ───────────────────────────────
|
||||||
|
dispatch_custom_event(
|
||||||
|
"report_progress",
|
||||||
|
{"phase": "compiling", "message": "Compiling resume..."},
|
||||||
|
)
|
||||||
|
|
||||||
|
name = _extract_name(body) or "Resume"
|
||||||
|
header = _build_header(template, name)
|
||||||
|
typst_source = header + body
|
||||||
|
|
||||||
|
compile_error: str | None = None
|
||||||
|
for attempt in range(2):
|
||||||
|
try:
|
||||||
|
_compile_typst(typst_source)
|
||||||
|
compile_error = None
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
compile_error = str(e)
|
||||||
|
logger.warning(
|
||||||
|
f"[generate_resume] Compile attempt {attempt + 1} failed: {compile_error}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if attempt == 0:
|
||||||
|
dispatch_custom_event(
|
||||||
|
"report_progress",
|
||||||
|
{
|
||||||
|
"phase": "fixing",
|
||||||
|
"message": "Fixing compilation issue...",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
fix_prompt = _FIX_COMPILE_PROMPT.format(
|
||||||
|
llm_reference=llm_reference,
|
||||||
|
error=compile_error,
|
||||||
|
full_source=typst_source,
|
||||||
|
)
|
||||||
|
fix_response = await llm.ainvoke(
|
||||||
|
[HumanMessage(content=fix_prompt)]
|
||||||
|
)
|
||||||
|
if fix_response.content and isinstance(
|
||||||
|
fix_response.content, str
|
||||||
|
):
|
||||||
|
body = _strip_typst_fences(fix_response.content)
|
||||||
|
body = _strip_imports(body)
|
||||||
|
name = _extract_name(body) or name
|
||||||
|
header = _build_header(template, name)
|
||||||
|
typst_source = header + body
|
||||||
|
|
||||||
|
if compile_error:
|
||||||
|
error_msg = (
|
||||||
|
f"Typst compilation failed after 2 attempts: {compile_error}"
|
||||||
|
)
|
||||||
|
report_id = await _save_failed_report(error_msg)
|
||||||
|
return {
|
||||||
|
"status": "failed",
|
||||||
|
"error": error_msg,
|
||||||
|
"report_id": report_id,
|
||||||
|
"title": "Resume",
|
||||||
|
"content_type": "typst",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Phase 4: SAVE ─────────────────────────────────────────────
|
||||||
|
dispatch_custom_event(
|
||||||
|
"report_progress",
|
||||||
|
{"phase": "saving", "message": "Saving your resume"},
|
||||||
|
)
|
||||||
|
|
||||||
|
resume_title = f"{name} - Resume" if name != "Resume" else "Resume"
|
||||||
|
|
||||||
|
metadata: dict[str, Any] = {
|
||||||
|
"status": "ready",
|
||||||
|
"word_count": len(typst_source.split()),
|
||||||
|
"char_count": len(typst_source),
|
||||||
|
}
|
||||||
|
|
||||||
|
async with shielded_async_session() as write_session:
|
||||||
|
report = Report(
|
||||||
|
title=resume_title,
|
||||||
|
content=typst_source,
|
||||||
|
content_type="typst",
|
||||||
|
report_metadata=metadata,
|
||||||
|
report_style="resume",
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
thread_id=thread_id,
|
||||||
|
report_group_id=report_group_id,
|
||||||
|
)
|
||||||
|
write_session.add(report)
|
||||||
|
await write_session.commit()
|
||||||
|
await write_session.refresh(report)
|
||||||
|
|
||||||
|
if not report.report_group_id:
|
||||||
|
report.report_group_id = report.id
|
||||||
|
await write_session.commit()
|
||||||
|
|
||||||
|
saved_id = report.id
|
||||||
|
|
||||||
|
logger.info(f"[generate_resume] Created resume {saved_id}: {resume_title}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ready",
|
||||||
|
"report_id": saved_id,
|
||||||
|
"title": resume_title,
|
||||||
|
"content_type": "typst",
|
||||||
|
"is_revision": bool(parent_content),
|
||||||
|
"message": f"Resume generated successfully: {resume_title}",
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_message = str(e)
|
||||||
|
logger.exception(f"[generate_resume] Error: {error_message}")
|
||||||
|
report_id = await _save_failed_report(error_message)
|
||||||
|
return {
|
||||||
|
"status": "failed",
|
||||||
|
"error": error_message,
|
||||||
|
"report_id": report_id,
|
||||||
|
"title": "Resume",
|
||||||
|
"content_type": "typst",
|
||||||
|
}
|
||||||
|
|
||||||
|
return generate_resume
|
||||||
|
|
@ -114,8 +114,19 @@ def _surfsense_error_handler(request: Request, exc: SurfSenseError) -> JSONRespo
|
||||||
|
|
||||||
|
|
||||||
def _http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
|
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)
|
rid = _get_request_id(request)
|
||||||
|
should_sanitize = exc.status_code == 500
|
||||||
|
|
||||||
# Structured dict details (e.g. {"code": "CAPTCHA_REQUIRED", "message": "..."})
|
# Structured dict details (e.g. {"code": "CAPTCHA_REQUIRED", "message": "..."})
|
||||||
# are preserved so the frontend can parse them.
|
# are preserved so the frontend can parse them.
|
||||||
|
|
@ -130,6 +141,7 @@ def _http_exception_handler(request: Request, exc: HTTPException) -> JSONRespons
|
||||||
exc.status_code,
|
exc.status_code,
|
||||||
message,
|
message,
|
||||||
)
|
)
|
||||||
|
if should_sanitize:
|
||||||
message = GENERIC_5XX_MESSAGE
|
message = GENERIC_5XX_MESSAGE
|
||||||
err_code = "INTERNAL_ERROR"
|
err_code = "INTERNAL_ERROR"
|
||||||
body = {
|
body = {
|
||||||
|
|
@ -158,6 +170,7 @@ def _http_exception_handler(request: Request, exc: HTTPException) -> JSONRespons
|
||||||
exc.status_code,
|
exc.status_code,
|
||||||
detail,
|
detail,
|
||||||
)
|
)
|
||||||
|
if should_sanitize:
|
||||||
detail = GENERIC_5XX_MESSAGE
|
detail = GENERIC_5XX_MESSAGE
|
||||||
code = _status_to_code(exc.status_code, detail)
|
code = _status_to_code(exc.status_code, detail)
|
||||||
return _build_error_response(exc.status_code, detail, code=code, request_id=rid)
|
return _build_error_response(exc.status_code, detail, code=code, request_id=rid)
|
||||||
|
|
|
||||||
|
|
@ -1198,12 +1198,13 @@ class VideoPresentation(BaseModel, TimestampMixin):
|
||||||
|
|
||||||
|
|
||||||
class Report(BaseModel, TimestampMixin):
|
class Report(BaseModel, TimestampMixin):
|
||||||
"""Report model for storing generated Markdown reports."""
|
"""Report model for storing generated reports (Markdown or Typst)."""
|
||||||
|
|
||||||
__tablename__ = "reports"
|
__tablename__ = "reports"
|
||||||
|
|
||||||
title = Column(String(500), nullable=False)
|
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_metadata = Column(JSONB, nullable=True) # section headings, word count, etc.
|
||||||
report_style = Column(
|
report_style = Column(
|
||||||
String(100), nullable=True
|
String(100), nullable=True
|
||||||
|
|
|
||||||
|
|
@ -71,4 +71,11 @@ SYSTEM_PROMPT_DEFAULTS: list[dict] = [
|
||||||
"prompt": "Search the web for information about:\n\n{selection}",
|
"prompt": "Search the web for information about:\n\n{selection}",
|
||||||
"mode": "explore",
|
"mode": "explore",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"slug": "build-resume",
|
||||||
|
"version": 1,
|
||||||
|
"name": "Build Resume",
|
||||||
|
"prompt": "Build me a professional resume. Here is my information:\n\n{selection}",
|
||||||
|
"mode": "explore",
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -231,6 +231,57 @@ def _replace_audio_paths_with_public_urls(
|
||||||
return result
|
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")
|
@router.get("/{share_token}/reports/{report_id}/content")
|
||||||
async def get_public_report_content(
|
async def get_public_report_content(
|
||||||
share_token: str,
|
share_token: str,
|
||||||
|
|
@ -259,6 +310,7 @@ async def get_public_report_content(
|
||||||
"id": report_info.get("original_id"),
|
"id": report_info.get("original_id"),
|
||||||
"title": report_info.get("title"),
|
"title": report_info.get("title"),
|
||||||
"content": report_info.get("content"),
|
"content": report_info.get("content"),
|
||||||
|
"content_type": report_info.get("content_type", "markdown"),
|
||||||
"report_metadata": report_info.get("report_metadata"),
|
"report_metadata": report_info.get("report_metadata"),
|
||||||
"report_group_id": report_info.get("report_group_id"),
|
"report_group_id": report_info.get("report_group_id"),
|
||||||
"versions": versions,
|
"versions": versions,
|
||||||
|
|
|
||||||
|
|
@ -279,6 +279,7 @@ async def read_report_content(
|
||||||
id=report.id,
|
id=report.id,
|
||||||
title=report.title,
|
title=report.title,
|
||||||
content=report.content,
|
content=report.content,
|
||||||
|
content_type=report.content_type,
|
||||||
report_metadata=report.report_metadata,
|
report_metadata=report.report_metadata,
|
||||||
report_group_id=report.report_group_id,
|
report_group_id=report.report_group_id,
|
||||||
versions=versions,
|
versions=versions,
|
||||||
|
|
@ -319,6 +320,7 @@ async def update_report_content(
|
||||||
id=report.id,
|
id=report.id,
|
||||||
title=report.title,
|
title=report.title,
|
||||||
content=report.content,
|
content=report.content,
|
||||||
|
content_type=report.content_type,
|
||||||
report_metadata=report.report_metadata,
|
report_metadata=report.report_metadata,
|
||||||
report_group_id=report.report_group_id,
|
report_group_id=report.report_group_id,
|
||||||
versions=versions,
|
versions=versions,
|
||||||
|
|
@ -333,6 +335,57 @@ async def update_report_content(
|
||||||
) from None
|
) 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")
|
@router.get("/reports/{report_id}/export")
|
||||||
async def export_report(
|
async def export_report(
|
||||||
report_id: int,
|
report_id: int,
|
||||||
|
|
@ -354,6 +407,27 @@ async def export_report(
|
||||||
status_code=400, detail="Report has no content to export"
|
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.
|
# Strip wrapping code fences that LLMs sometimes add around Markdown.
|
||||||
# Without this, pandoc treats the entire content as a code block.
|
# Without this, pandoc treats the entire content as a code block.
|
||||||
markdown_content = _strip_wrapping_code_fences(report.content)
|
markdown_content = _strip_wrapping_code_fences(report.content)
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ class ReportRead(BaseModel):
|
||||||
report_style: str | None = None
|
report_style: str | None = None
|
||||||
report_metadata: dict[str, Any] | None = None
|
report_metadata: dict[str, Any] | None = None
|
||||||
report_group_id: int | None = None
|
report_group_id: int | None = None
|
||||||
|
content_type: str = "markdown"
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
|
|
@ -40,11 +41,12 @@ class ReportVersionInfo(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class ReportContentRead(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
|
id: int
|
||||||
title: str
|
title: str
|
||||||
content: str | None = None
|
content: str | None = None
|
||||||
|
content_type: str = "markdown"
|
||||||
report_metadata: dict[str, Any] | None = None
|
report_metadata: dict[str, Any] | None = None
|
||||||
report_group_id: int | None = None
|
report_group_id: int | None = None
|
||||||
versions: list[ReportVersionInfo] = []
|
versions: list[ReportVersionInfo] = []
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ UI_TOOLS = {
|
||||||
"generate_image",
|
"generate_image",
|
||||||
"generate_podcast",
|
"generate_podcast",
|
||||||
"generate_report",
|
"generate_report",
|
||||||
|
"generate_resume",
|
||||||
"generate_video_presentation",
|
"generate_video_presentation",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -239,7 +240,7 @@ async def create_snapshot(
|
||||||
video_presentation_ids_seen.add(vp_id)
|
video_presentation_ids_seen.add(vp_id)
|
||||||
part["result"] = {**result_data, "status": "ready"}
|
part["result"] = {**result_data, "status": "ready"}
|
||||||
|
|
||||||
elif tool_name == "generate_report":
|
elif tool_name in ("generate_report", "generate_resume"):
|
||||||
result_data = part.get("result", {})
|
result_data = part.get("result", {})
|
||||||
report_id = result_data.get("report_id")
|
report_id = result_data.get("report_id")
|
||||||
if report_id and report_id not in report_ids_seen:
|
if report_id and report_id not in report_ids_seen:
|
||||||
|
|
@ -247,7 +248,6 @@ async def create_snapshot(
|
||||||
if report_info:
|
if report_info:
|
||||||
reports_data.append(report_info)
|
reports_data.append(report_info)
|
||||||
report_ids_seen.add(report_id)
|
report_ids_seen.add(report_id)
|
||||||
# Update status to "ready" so frontend renders ReportCard
|
|
||||||
part["result"] = {**result_data, "status": "ready"}
|
part["result"] = {**result_data, "status": "ready"}
|
||||||
|
|
||||||
messages_data.append(
|
messages_data.append(
|
||||||
|
|
@ -377,6 +377,7 @@ async def _get_report_for_snapshot(
|
||||||
"original_id": report.id,
|
"original_id": report.id,
|
||||||
"title": report.title,
|
"title": report.title,
|
||||||
"content": report.content,
|
"content": report.content,
|
||||||
|
"content_type": report.content_type,
|
||||||
"report_metadata": report.report_metadata,
|
"report_metadata": report.report_metadata,
|
||||||
"report_group_id": report.report_group_id,
|
"report_group_id": report.report_group_id,
|
||||||
"created_at": report.created_at.isoformat() if report.created_at else None,
|
"created_at": report.created_at.isoformat() if report.created_at else None,
|
||||||
|
|
|
||||||
|
|
@ -953,6 +953,31 @@ async def _stream_agent_events(
|
||||||
f"Report generation failed: {error_msg}",
|
f"Report generation failed: {error_msg}",
|
||||||
"error",
|
"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 (
|
elif tool_name in (
|
||||||
"create_notion_page",
|
"create_notion_page",
|
||||||
"update_notion_page",
|
"update_notion_page",
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,20 @@ def _make_test_app():
|
||||||
async def raise_http_500():
|
async def raise_http_500():
|
||||||
raise HTTPException(status_code=500, detail="secret db password leaked")
|
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")
|
@app.get("/surfsense-connector")
|
||||||
async def raise_connector():
|
async def raise_connector():
|
||||||
raise ConnectorError("GitHub API returned 401")
|
raise ConnectorError("GitHub API returned 401")
|
||||||
|
|
@ -184,6 +198,20 @@ class TestHTTPExceptionHandler:
|
||||||
assert body["error"]["message"] == GENERIC_5XX_MESSAGE
|
assert body["error"]["message"] == GENERIC_5XX_MESSAGE
|
||||||
assert body["error"]["code"] == "INTERNAL_ERROR"
|
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
|
# SurfSenseError hierarchy
|
||||||
|
|
|
||||||
|
|
@ -43,4 +43,12 @@ export const IPC_CHANNELS = {
|
||||||
// Active search space
|
// Active search space
|
||||||
GET_ACTIVE_SEARCH_SPACE: 'search-space:get-active',
|
GET_ACTIVE_SEARCH_SPACE: 'search-space:get-active',
|
||||||
SET_ACTIVE_SEARCH_SPACE: 'search-space:set-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;
|
} as const;
|
||||||
|
|
|
||||||
|
|
@ -24,10 +24,18 @@ import {
|
||||||
type WatchedFolderConfig,
|
type WatchedFolderConfig,
|
||||||
} from '../modules/folder-watcher';
|
} from '../modules/folder-watcher';
|
||||||
import { getShortcuts, setShortcuts, type ShortcutConfig } from '../modules/shortcuts';
|
import { getShortcuts, setShortcuts, type ShortcutConfig } from '../modules/shortcuts';
|
||||||
|
import { getAutoLaunchState, setAutoLaunch } from '../modules/auto-launch';
|
||||||
import { getActiveSearchSpaceId, setActiveSearchSpaceId } from '../modules/active-search-space';
|
import { getActiveSearchSpaceId, setActiveSearchSpaceId } from '../modules/active-search-space';
|
||||||
import { reregisterQuickAsk } from '../modules/quick-ask';
|
import { reregisterQuickAsk } from '../modules/quick-ask';
|
||||||
import { reregisterAutocomplete } from '../modules/autocomplete';
|
import { reregisterAutocomplete } from '../modules/autocomplete';
|
||||||
import { reregisterGeneralAssist } from '../modules/tray';
|
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;
|
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_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.GET_ACTIVE_SEARCH_SPACE, () => getActiveSearchSpaceId());
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.SET_ACTIVE_SEARCH_SPACE, (_event, id: string) =>
|
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.generalAssist) await reregisterGeneralAssist();
|
||||||
if (config.quickAsk) await reregisterQuickAsk();
|
if (config.quickAsk) await reregisterQuickAsk();
|
||||||
if (config.autocomplete) await reregisterAutocomplete();
|
if (config.autocomplete) await reregisterAutocomplete();
|
||||||
|
trackEvent('desktop_shortcut_updated', {
|
||||||
|
keys: Object.keys(config),
|
||||||
|
});
|
||||||
return updated;
|
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<string, unknown> }) => {
|
||||||
|
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<string, unknown> }) => {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
import { app, BrowserWindow } from 'electron';
|
import { app } from 'electron';
|
||||||
|
|
||||||
let isQuitting = false;
|
|
||||||
import { registerGlobalErrorHandlers, showErrorDialog } from './modules/errors';
|
import { registerGlobalErrorHandlers, showErrorDialog } from './modules/errors';
|
||||||
import { startNextServer } from './modules/server';
|
import { startNextServer } from './modules/server';
|
||||||
import { createMainWindow, getMainWindow } from './modules/window';
|
import { createMainWindow, getMainWindow, markQuitting } from './modules/window';
|
||||||
import { setupDeepLinks, handlePendingDeepLink } from './modules/deep-links';
|
import { setupDeepLinks, handlePendingDeepLink, hasPendingDeepLink } from './modules/deep-links';
|
||||||
import { setupAutoUpdater } from './modules/auto-updater';
|
import { setupAutoUpdater } from './modules/auto-updater';
|
||||||
import { setupMenu } from './modules/menu';
|
import { setupMenu } from './modules/menu';
|
||||||
import { registerQuickAsk, unregisterQuickAsk } from './modules/quick-ask';
|
import { registerQuickAsk, unregisterQuickAsk } from './modules/quick-ask';
|
||||||
|
|
@ -13,6 +12,12 @@ import { registerFolderWatcher, unregisterFolderWatcher } from './modules/folder
|
||||||
import { registerIpcHandlers } from './ipc/handlers';
|
import { registerIpcHandlers } from './ipc/handlers';
|
||||||
import { createTray, destroyTray } from './modules/tray';
|
import { createTray, destroyTray } from './modules/tray';
|
||||||
import { initAnalytics, shutdownAnalytics, trackEvent } from './modules/analytics';
|
import { initAnalytics, shutdownAnalytics, trackEvent } from './modules/analytics';
|
||||||
|
import {
|
||||||
|
applyAutoLaunchDefaults,
|
||||||
|
shouldStartHidden,
|
||||||
|
syncAutoLaunchOnStartup,
|
||||||
|
wasLaunchedAtLogin,
|
||||||
|
} from './modules/auto-launch';
|
||||||
|
|
||||||
registerGlobalErrorHandlers();
|
registerGlobalErrorHandlers();
|
||||||
|
|
||||||
|
|
@ -24,7 +29,12 @@ registerIpcHandlers();
|
||||||
|
|
||||||
app.whenReady().then(async () => {
|
app.whenReady().then(async () => {
|
||||||
initAnalytics();
|
initAnalytics();
|
||||||
trackEvent('desktop_app_launched');
|
const launchedAtLogin = wasLaunchedAtLogin();
|
||||||
|
const startedHidden = shouldStartHidden();
|
||||||
|
trackEvent('desktop_app_launched', {
|
||||||
|
launched_at_login: launchedAtLogin,
|
||||||
|
started_hidden: startedHidden,
|
||||||
|
});
|
||||||
setupMenu();
|
setupMenu();
|
||||||
try {
|
try {
|
||||||
await startNextServer();
|
await startNextServer();
|
||||||
|
|
@ -35,16 +45,19 @@ app.whenReady().then(async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
await createTray();
|
await createTray();
|
||||||
|
const defaultsApplied = await applyAutoLaunchDefaults();
|
||||||
|
if (defaultsApplied) {
|
||||||
|
trackEvent('desktop_auto_launch_defaulted_on');
|
||||||
|
}
|
||||||
|
await syncAutoLaunchOnStartup();
|
||||||
|
|
||||||
const win = createMainWindow('/dashboard');
|
// 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).
|
||||||
// Minimize to tray instead of closing the app
|
// Exception: if a deep link is queued, the user explicitly asked to land
|
||||||
win.on('close', (e) => {
|
// in the app — don't swallow it.
|
||||||
if (!isQuitting) {
|
if (!startedHidden || hasPendingDeepLink()) {
|
||||||
e.preventDefault();
|
createMainWindow('/dashboard');
|
||||||
win.hide();
|
}
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await registerQuickAsk();
|
await registerQuickAsk();
|
||||||
await registerAutocomplete();
|
await registerAutocomplete();
|
||||||
|
|
@ -55,6 +68,7 @@ app.whenReady().then(async () => {
|
||||||
|
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
const mw = getMainWindow();
|
const mw = getMainWindow();
|
||||||
|
trackEvent('desktop_app_activated');
|
||||||
if (!mw || mw.isDestroyed()) {
|
if (!mw || mw.isDestroyed()) {
|
||||||
createMainWindow('/dashboard');
|
createMainWindow('/dashboard');
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -70,7 +84,8 @@ app.on('window-all-closed', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on('before-quit', () => {
|
app.on('before-quit', () => {
|
||||||
isQuitting = true;
|
markQuitting();
|
||||||
|
trackEvent('desktop_app_quit');
|
||||||
});
|
});
|
||||||
|
|
||||||
let didCleanup = false;
|
let didCleanup = false;
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,27 @@ import { machineIdSync } from 'node-machine-id';
|
||||||
import { app } from 'electron';
|
import { app } from 'electron';
|
||||||
|
|
||||||
let client: PostHog | null = null;
|
let client: PostHog | null = null;
|
||||||
let distinctId = '';
|
let machineId = '';
|
||||||
|
let currentDistinctId = '';
|
||||||
|
let identifiedUserId: string | null = null;
|
||||||
|
|
||||||
|
function baseProperties(): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
platform: 'desktop',
|
||||||
|
app_version: app.getVersion(),
|
||||||
|
os: process.platform,
|
||||||
|
arch: process.arch,
|
||||||
|
machine_id: machineId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function initAnalytics(): void {
|
export function initAnalytics(): void {
|
||||||
const key = process.env.POSTHOG_KEY;
|
const key = process.env.POSTHOG_KEY;
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
distinctId = machineIdSync(true);
|
machineId = machineIdSync(true);
|
||||||
|
currentDistinctId = machineId;
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -22,17 +35,92 @@ export function initAnalytics(): void {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function trackEvent(event: string, properties?: Record<string, unknown>): 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<string, unknown>
|
||||||
|
): 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<string, unknown>
|
||||||
|
): void {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
client.capture({
|
client.capture({
|
||||||
distinctId,
|
distinctId: currentDistinctId || machineId,
|
||||||
event,
|
event,
|
||||||
properties: {
|
properties: {
|
||||||
platform: 'desktop',
|
...baseProperties(),
|
||||||
app_version: app.getVersion(),
|
|
||||||
os: process.platform,
|
|
||||||
...properties,
|
...properties,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
304
surfsense_desktop/src/modules/auto-launch.ts
Normal file
304
surfsense_desktop/src/modules/auto-launch.ts
Normal file
|
|
@ -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<PersistedState> {
|
||||||
|
const s = await getStore();
|
||||||
|
const stored = s.get(STORE_KEY) as Partial<PersistedState> | undefined;
|
||||||
|
return { ...DEFAULTS, ...(stored ?? {}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writePersisted(next: PersistedState): Promise<void> {
|
||||||
|
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<AutoLaunchState> {
|
||||||
|
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<AutoLaunchState> {
|
||||||
|
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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { app, dialog } from 'electron';
|
import { app, dialog } from 'electron';
|
||||||
|
import { trackEvent } from './analytics';
|
||||||
|
|
||||||
const SEMVER_RE = /^\d+\.\d+\.\d+/;
|
const SEMVER_RE = /^\d+\.\d+\.\d+/;
|
||||||
|
|
||||||
|
|
@ -17,10 +18,18 @@ export function setupAutoUpdater(): void {
|
||||||
|
|
||||||
autoUpdater.on('update-available', (info: { version: string }) => {
|
autoUpdater.on('update-available', (info: { version: string }) => {
|
||||||
console.log(`Update available: ${info.version}`);
|
console.log(`Update available: ${info.version}`);
|
||||||
|
trackEvent('desktop_update_available', {
|
||||||
|
current_version: version,
|
||||||
|
new_version: info.version,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
autoUpdater.on('update-downloaded', (info: { version: string }) => {
|
autoUpdater.on('update-downloaded', (info: { version: string }) => {
|
||||||
console.log(`Update downloaded: ${info.version}`);
|
console.log(`Update downloaded: ${info.version}`);
|
||||||
|
trackEvent('desktop_update_downloaded', {
|
||||||
|
current_version: version,
|
||||||
|
new_version: info.version,
|
||||||
|
});
|
||||||
dialog.showMessageBox({
|
dialog.showMessageBox({
|
||||||
type: 'info',
|
type: 'info',
|
||||||
buttons: ['Restart', 'Later'],
|
buttons: ['Restart', 'Later'],
|
||||||
|
|
@ -29,13 +38,19 @@ export function setupAutoUpdater(): void {
|
||||||
message: `Version ${info.version} has been downloaded. Restart to apply the update.`,
|
message: `Version ${info.version} has been downloaded. Restart to apply the update.`,
|
||||||
}).then(({ response }: { response: number }) => {
|
}).then(({ response }: { response: number }) => {
|
||||||
if (response === 0) {
|
if (response === 0) {
|
||||||
|
trackEvent('desktop_update_install_accepted', { new_version: info.version });
|
||||||
autoUpdater.quitAndInstall();
|
autoUpdater.quitAndInstall();
|
||||||
|
} else {
|
||||||
|
trackEvent('desktop_update_install_deferred', { new_version: info.version });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
autoUpdater.on('error', (err: Error) => {
|
autoUpdater.on('error', (err: Error) => {
|
||||||
console.log('Auto-updater: update check skipped —', err.message?.split('\n')[0]);
|
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(() => {});
|
autoUpdater.checkForUpdates().catch(() => {});
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { app } from 'electron';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { getMainWindow } from './window';
|
import { getMainWindow } from './window';
|
||||||
import { getServerPort } from './server';
|
import { getServerPort } from './server';
|
||||||
|
import { trackEvent } from './analytics';
|
||||||
|
|
||||||
const PROTOCOL = 'surfsense';
|
const PROTOCOL = 'surfsense';
|
||||||
|
|
||||||
|
|
@ -16,6 +17,10 @@ function handleDeepLink(url: string) {
|
||||||
if (!win) return;
|
if (!win) return;
|
||||||
|
|
||||||
const parsed = new URL(url);
|
const parsed = new URL(url);
|
||||||
|
trackEvent('desktop_deep_link_received', {
|
||||||
|
host: parsed.hostname,
|
||||||
|
path: parsed.pathname,
|
||||||
|
});
|
||||||
if (parsed.hostname === 'auth' && parsed.pathname === '/callback') {
|
if (parsed.hostname === 'auth' && parsed.pathname === '/callback') {
|
||||||
const params = parsed.searchParams.toString();
|
const params = parsed.searchParams.toString();
|
||||||
win.loadURL(`http://localhost:${getServerPort()}/auth/callback?${params}`);
|
win.loadURL(`http://localhost:${getServerPort()}/auth/callback?${params}`);
|
||||||
|
|
@ -64,3 +69,10 @@ export function handlePendingDeepLink(): void {
|
||||||
deepLinkUrl = null;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { randomUUID } from 'crypto';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import { IPC_CHANNELS } from '../ipc/channels';
|
import { IPC_CHANNELS } from '../ipc/channels';
|
||||||
|
import { trackEvent } from './analytics';
|
||||||
|
|
||||||
export interface WatchedFolderConfig {
|
export interface WatchedFolderConfig {
|
||||||
path: string;
|
path: string;
|
||||||
|
|
@ -401,6 +402,15 @@ export async function addWatchedFolder(
|
||||||
await startWatcher(config);
|
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;
|
return folders;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -409,6 +419,7 @@ export async function removeWatchedFolder(
|
||||||
): Promise<WatchedFolderConfig[]> {
|
): Promise<WatchedFolderConfig[]> {
|
||||||
const s = await getStore();
|
const s = await getStore();
|
||||||
const folders: WatchedFolderConfig[] = s.get(STORE_KEY, []);
|
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);
|
const updated = folders.filter((f: WatchedFolderConfig) => f.path !== folderPath);
|
||||||
s.set(STORE_KEY, updated);
|
s.set(STORE_KEY, updated);
|
||||||
|
|
||||||
|
|
@ -418,6 +429,13 @@ export async function removeWatchedFolder(
|
||||||
const ms = await getMtimeStore();
|
const ms = await getMtimeStore();
|
||||||
ms.delete(folderPath);
|
ms.delete(folderPath);
|
||||||
|
|
||||||
|
if (removed) {
|
||||||
|
trackEvent('desktop_folder_watch_removed', {
|
||||||
|
search_space_id: removed.searchSpaceId,
|
||||||
|
root_folder_id: removed.rootFolderId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { app, globalShortcut, Menu, nativeImage, Tray } from 'electron';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { getMainWindow, createMainWindow } from './window';
|
import { getMainWindow, createMainWindow } from './window';
|
||||||
import { getShortcuts } from './shortcuts';
|
import { getShortcuts } from './shortcuts';
|
||||||
|
import { trackEvent } from './analytics';
|
||||||
|
|
||||||
let tray: Tray | null = null;
|
let tray: Tray | null = null;
|
||||||
let currentShortcut: string | null = null;
|
let currentShortcut: string | null = null;
|
||||||
|
|
@ -15,14 +16,16 @@ function getTrayIcon(): nativeImage {
|
||||||
return img.resize({ width: 16, height: 16 });
|
return img.resize({ width: 16, height: 16 });
|
||||||
}
|
}
|
||||||
|
|
||||||
function showMainWindow(): void {
|
function showMainWindow(source: 'tray_click' | 'tray_menu' | 'shortcut' = 'tray_click'): void {
|
||||||
let win = getMainWindow();
|
const existing = getMainWindow();
|
||||||
if (!win || win.isDestroyed()) {
|
const reopened = !existing || existing.isDestroyed();
|
||||||
win = createMainWindow('/dashboard');
|
if (reopened) {
|
||||||
|
createMainWindow('/dashboard');
|
||||||
} else {
|
} else {
|
||||||
win.show();
|
existing.show();
|
||||||
win.focus();
|
existing.focus();
|
||||||
}
|
}
|
||||||
|
trackEvent('desktop_main_window_shown', { source, reopened });
|
||||||
}
|
}
|
||||||
|
|
||||||
function registerShortcut(accelerator: string): void {
|
function registerShortcut(accelerator: string): void {
|
||||||
|
|
@ -32,7 +35,7 @@ function registerShortcut(accelerator: string): void {
|
||||||
}
|
}
|
||||||
if (!accelerator) return;
|
if (!accelerator) return;
|
||||||
try {
|
try {
|
||||||
const ok = globalShortcut.register(accelerator, showMainWindow);
|
const ok = globalShortcut.register(accelerator, () => showMainWindow('shortcut'));
|
||||||
if (ok) {
|
if (ok) {
|
||||||
currentShortcut = accelerator;
|
currentShortcut = accelerator;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -50,13 +53,19 @@ export async function createTray(): Promise<void> {
|
||||||
tray.setToolTip('SurfSense');
|
tray.setToolTip('SurfSense');
|
||||||
|
|
||||||
const contextMenu = Menu.buildFromTemplate([
|
const contextMenu = Menu.buildFromTemplate([
|
||||||
{ label: 'Open SurfSense', click: showMainWindow },
|
{ label: 'Open SurfSense', click: () => showMainWindow('tray_menu') },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ label: 'Quit', click: () => { app.exit(0); } },
|
{
|
||||||
|
label: 'Quit',
|
||||||
|
click: () => {
|
||||||
|
trackEvent('desktop_tray_quit_clicked');
|
||||||
|
app.exit(0);
|
||||||
|
},
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
tray.setContextMenu(contextMenu);
|
tray.setContextMenu(contextMenu);
|
||||||
tray.on('double-click', showMainWindow);
|
tray.on('double-click', () => showMainWindow('tray_click'));
|
||||||
|
|
||||||
const shortcuts = await getShortcuts();
|
const shortcuts = await getShortcuts();
|
||||||
registerShortcut(shortcuts.generalAssist);
|
registerShortcut(shortcuts.generalAssist);
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,18 @@ const isDev = !app.isPackaged;
|
||||||
const HOSTED_FRONTEND_URL = process.env.HOSTED_FRONTEND_URL as string;
|
const HOSTED_FRONTEND_URL = process.env.HOSTED_FRONTEND_URL as string;
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null;
|
let mainWindow: BrowserWindow | null = null;
|
||||||
|
let isQuitting = false;
|
||||||
|
|
||||||
export function getMainWindow(): BrowserWindow | null {
|
export function getMainWindow(): BrowserWindow | null {
|
||||||
return mainWindow;
|
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 {
|
export function createMainWindow(initialPath = '/dashboard'): BrowserWindow {
|
||||||
mainWindow = new BrowserWindow({
|
mainWindow = new BrowserWindow({
|
||||||
width: 1280,
|
width: 1280,
|
||||||
|
|
@ -70,6 +77,16 @@ export function createMainWindow(initialPath = '/dashboard'): BrowserWindow {
|
||||||
mainWindow.webContents.openDevTools();
|
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.on('closed', () => {
|
||||||
mainWindow = null;
|
mainWindow = null;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -82,8 +82,23 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
setShortcuts: (config: Record<string, string>) =>
|
setShortcuts: (config: Record<string, string>) =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.SET_SHORTCUTS, config),
|
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
|
// Active search space
|
||||||
getActiveSearchSpace: () => ipcRenderer.invoke(IPC_CHANNELS.GET_ACTIVE_SEARCH_SPACE),
|
getActiveSearchSpace: () => ipcRenderer.invoke(IPC_CHANNELS.GET_ACTIVE_SEARCH_SPACE),
|
||||||
setActiveSearchSpace: (id: string) =>
|
setActiveSearchSpace: (id: string) =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.SET_ACTIVE_SEARCH_SPACE, id),
|
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<string, unknown>) =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_IDENTIFY, { userId, properties }),
|
||||||
|
analyticsReset: () => ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_RESET),
|
||||||
|
analyticsCapture: (event: string, properties?: Record<string, unknown>) =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_CAPTURE, { event, properties }),
|
||||||
|
getAnalyticsContext: () => ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_GET_CONTEXT),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -162,6 +162,7 @@ const TOOLS_WITH_UI = new Set([
|
||||||
"web_search",
|
"web_search",
|
||||||
"generate_podcast",
|
"generate_podcast",
|
||||||
"generate_report",
|
"generate_report",
|
||||||
|
"generate_resume",
|
||||||
"generate_video_presentation",
|
"generate_video_presentation",
|
||||||
"display_image",
|
"display_image",
|
||||||
"generate_image",
|
"generate_image",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { BrainCog, Rocket, Zap } from "lucide-react";
|
import { BrainCog, Power, Rocket, Zap } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { DEFAULT_SHORTCUTS, ShortcutRecorder } from "@/components/desktop/shortcut-recorder";
|
import { DEFAULT_SHORTCUTS, ShortcutRecorder } from "@/components/desktop/shortcut-recorder";
|
||||||
|
|
@ -30,6 +30,10 @@ export function DesktopContent() {
|
||||||
const [searchSpaces, setSearchSpaces] = useState<SearchSpace[]>([]);
|
const [searchSpaces, setSearchSpaces] = useState<SearchSpace[]>([]);
|
||||||
const [activeSpaceId, setActiveSpaceId] = useState<string | null>(null);
|
const [activeSpaceId, setActiveSpaceId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [autoLaunchEnabled, setAutoLaunchEnabled] = useState(false);
|
||||||
|
const [autoLaunchHidden, setAutoLaunchHidden] = useState(true);
|
||||||
|
const [autoLaunchSupported, setAutoLaunchSupported] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!api) {
|
if (!api) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
@ -38,19 +42,28 @@ export function DesktopContent() {
|
||||||
}
|
}
|
||||||
|
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
|
const hasAutoLaunchApi =
|
||||||
|
typeof api.getAutoLaunch === "function" && typeof api.setAutoLaunch === "function";
|
||||||
|
setAutoLaunchSupported(hasAutoLaunchApi);
|
||||||
|
|
||||||
Promise.all([
|
Promise.all([
|
||||||
api.getAutocompleteEnabled(),
|
api.getAutocompleteEnabled(),
|
||||||
api.getShortcuts?.() ?? Promise.resolve(null),
|
api.getShortcuts?.() ?? Promise.resolve(null),
|
||||||
api.getActiveSearchSpace?.() ?? Promise.resolve(null),
|
api.getActiveSearchSpace?.() ?? Promise.resolve(null),
|
||||||
searchSpacesApiService.getSearchSpaces(),
|
searchSpacesApiService.getSearchSpaces(),
|
||||||
|
hasAutoLaunchApi ? api.getAutoLaunch() : Promise.resolve(null),
|
||||||
])
|
])
|
||||||
.then(([autoEnabled, config, spaceId, spaces]) => {
|
.then(([autoEnabled, config, spaceId, spaces, autoLaunch]) => {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setEnabled(autoEnabled);
|
setEnabled(autoEnabled);
|
||||||
if (config) setShortcuts(config);
|
if (config) setShortcuts(config);
|
||||||
setActiveSpaceId(spaceId);
|
setActiveSpaceId(spaceId);
|
||||||
if (spaces) setSearchSpaces(spaces);
|
if (spaces) setSearchSpaces(spaces);
|
||||||
|
if (autoLaunch) {
|
||||||
|
setAutoLaunchEnabled(autoLaunch.enabled);
|
||||||
|
setAutoLaunchHidden(autoLaunch.openAsHidden);
|
||||||
|
setAutoLaunchSupported(autoLaunch.supported);
|
||||||
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setShortcutsLoaded(true);
|
setShortcutsLoaded(true);
|
||||||
})
|
})
|
||||||
|
|
@ -106,6 +119,40 @@ export function DesktopContent() {
|
||||||
updateShortcut(key, DEFAULT_SHORTCUTS[key]);
|
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) => {
|
const handleSearchSpaceChange = (value: string) => {
|
||||||
setActiveSpaceId(value);
|
setActiveSpaceId(value);
|
||||||
api.setActiveSearchSpace?.(value);
|
api.setActiveSearchSpace?.(value);
|
||||||
|
|
@ -145,6 +192,60 @@ export function DesktopContent() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Launch on Startup */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
|
||||||
|
<CardTitle className="text-base md:text-lg flex items-center gap-2">
|
||||||
|
<Power className="h-4 w-4" />
|
||||||
|
Launch on Startup
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-xs md:text-sm">
|
||||||
|
Automatically start SurfSense when you sign in to your computer so global
|
||||||
|
shortcuts and folder sync are always available.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-3 md:px-6 pb-3 md:pb-6 space-y-3">
|
||||||
|
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label htmlFor="auto-launch-toggle" className="text-sm font-medium cursor-pointer">
|
||||||
|
Open SurfSense at login
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{autoLaunchSupported
|
||||||
|
? "Adds SurfSense to your system's login items."
|
||||||
|
: "Only available in the packaged desktop app."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="auto-launch-toggle"
|
||||||
|
checked={autoLaunchEnabled}
|
||||||
|
onCheckedChange={handleAutoLaunchToggle}
|
||||||
|
disabled={!autoLaunchSupported}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label
|
||||||
|
htmlFor="auto-launch-hidden-toggle"
|
||||||
|
className="text-sm font-medium cursor-pointer"
|
||||||
|
>
|
||||||
|
Start minimized to tray
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Skip the main window on boot — SurfSense lives in the system tray until you need
|
||||||
|
it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="auto-launch-hidden-toggle"
|
||||||
|
checked={autoLaunchHidden}
|
||||||
|
onCheckedChange={handleAutoLaunchHiddenToggle}
|
||||||
|
disabled={!autoLaunchSupported || !autoLaunchEnabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Keyboard Shortcuts */}
|
{/* Keyboard Shortcuts */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
|
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQueries } from "@tanstack/react-query";
|
||||||
import { ReceiptText } from "lucide-react";
|
import { Coins, FileText, ReceiptText } from "lucide-react";
|
||||||
|
import { useMemo } from "react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import {
|
import {
|
||||||
|
|
@ -12,10 +13,26 @@ import {
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} 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 { stripeApiService } from "@/lib/apis/stripe-api.service";
|
||||||
import { cn } from "@/lib/utils";
|
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<PagePurchaseStatus, { label: string; className: string }> = {
|
const STATUS_STYLES: Record<PagePurchaseStatus, { label: string; className: string }> = {
|
||||||
completed: {
|
completed: {
|
||||||
label: "Completed",
|
label: "Completed",
|
||||||
|
|
@ -31,6 +48,22 @@ const STATUS_STYLES: Record<PagePurchaseStatus, { label: string; className: stri
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const KIND_META: Record<
|
||||||
|
PurchaseKind,
|
||||||
|
{ label: string; icon: React.ComponentType<{ className?: string }>; 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 {
|
function formatDate(iso: string): string {
|
||||||
return new Date(iso).toLocaleDateString(undefined, {
|
return new Date(iso).toLocaleDateString(undefined, {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
|
|
@ -39,19 +72,65 @@ function formatDate(iso: string): string {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatAmount(purchase: PagePurchase): string {
|
function formatAmount(amount: number | null, currency: string | null): string {
|
||||||
if (purchase.amount_total == null) return "—";
|
if (amount == null) return "—";
|
||||||
const dollars = purchase.amount_total / 100;
|
const dollars = amount / 100;
|
||||||
const currency = (purchase.currency ?? "usd").toUpperCase();
|
const code = (currency ?? "usd").toUpperCase();
|
||||||
return `$${dollars.toFixed(2)} ${currency}`;
|
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() {
|
export function PurchaseHistoryContent() {
|
||||||
const { data, isLoading } = useQuery({
|
const results = useQueries({
|
||||||
queryKey: ["stripe-purchases"],
|
queries: [
|
||||||
queryFn: () => stripeApiService.getPurchases(),
|
{
|
||||||
|
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<UnifiedPurchase[]>(() => {
|
||||||
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
|
|
@ -60,15 +139,13 @@ export function PurchaseHistoryContent() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const purchases = data?.purchases ?? [];
|
|
||||||
|
|
||||||
if (purchases.length === 0) {
|
if (purchases.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center gap-2 py-16 text-center">
|
<div className="flex flex-col items-center justify-center gap-2 py-16 text-center">
|
||||||
<ReceiptText className="h-8 w-8 text-muted-foreground" />
|
<ReceiptText className="h-8 w-8 text-muted-foreground" />
|
||||||
<p className="text-sm font-medium">No purchases yet</p>
|
<p className="text-sm font-medium">No purchases yet</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Your page-pack purchases will appear here after checkout.
|
Your page and premium token purchases will appear here after checkout.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -81,25 +158,36 @@ export function PurchaseHistoryContent() {
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Date</TableHead>
|
<TableHead>Date</TableHead>
|
||||||
<TableHead className="text-right">Pages</TableHead>
|
<TableHead>Type</TableHead>
|
||||||
|
<TableHead className="text-right">Granted</TableHead>
|
||||||
<TableHead className="text-right">Amount</TableHead>
|
<TableHead className="text-right">Amount</TableHead>
|
||||||
<TableHead className="text-center">Status</TableHead>
|
<TableHead className="text-center">Status</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{purchases.map((p) => {
|
{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 (
|
return (
|
||||||
<TableRow key={p.id}>
|
<TableRow key={`${p.kind}-${p.id}`}>
|
||||||
<TableCell className="text-sm">{formatDate(p.created_at)}</TableCell>
|
<TableCell className="text-sm">{formatDate(p.created_at)}</TableCell>
|
||||||
<TableCell className="text-right tabular-nums text-sm">
|
<TableCell className="text-sm">
|
||||||
{p.pages_granted.toLocaleString()}
|
<div className="flex items-center gap-2">
|
||||||
|
<KindIcon className={cn("h-4 w-4", kind.iconClass)} />
|
||||||
|
<span>{kind.label}</span>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right tabular-nums text-sm">
|
<TableCell className="text-right tabular-nums text-sm">
|
||||||
{formatAmount(p)}
|
{p.granted.toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums text-sm">
|
||||||
|
{formatAmount(p.amount_total, p.currency)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
<Badge className={cn("text-[10px]", style.className)}>{style.label}</Badge>
|
<Badge className={cn("text-[10px]", statusStyle.className)}>
|
||||||
|
{statusStyle.label}
|
||||||
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
|
|
@ -108,7 +196,8 @@ export function PurchaseHistoryContent() {
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-center text-xs text-muted-foreground">
|
<p className="text-center text-xs text-muted-foreground">
|
||||||
Showing your {purchases.length} most recent purchase{purchases.length !== 1 ? "s" : ""}.
|
Showing your {purchases.length} most recent purchase
|
||||||
|
{purchases.length !== 1 ? "s" : ""}.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ interface ReportPanelState {
|
||||||
wordCount: number | null;
|
wordCount: number | null;
|
||||||
/** When set, uses public endpoints for fetching report data (public shared chat) */
|
/** When set, uses public endpoints for fetching report data (public shared chat) */
|
||||||
shareToken: string | null;
|
shareToken: string | null;
|
||||||
|
/** Content type of the report — "markdown" (default) or "typst" (resume) */
|
||||||
|
contentType: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: ReportPanelState = {
|
const initialState: ReportPanelState = {
|
||||||
|
|
@ -16,6 +18,7 @@ const initialState: ReportPanelState = {
|
||||||
title: null,
|
title: null,
|
||||||
wordCount: null,
|
wordCount: null,
|
||||||
shareToken: null,
|
shareToken: null,
|
||||||
|
contentType: "markdown",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Core atom holding the report panel state */
|
/** Core atom holding the report panel state */
|
||||||
|
|
@ -38,7 +41,14 @@ export const openReportPanelAtom = atom(
|
||||||
title,
|
title,
|
||||||
wordCount,
|
wordCount,
|
||||||
shareToken,
|
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) {
|
if (!get(reportPanelAtom).isOpen) {
|
||||||
set(preReportCollapsedAtom, get(rightPanelCollapsedAtom));
|
set(preReportCollapsedAtom, get(rightPanelCollapsedAtom));
|
||||||
|
|
@ -49,6 +59,7 @@ export const openReportPanelAtom = atom(
|
||||||
title,
|
title,
|
||||||
wordCount: wordCount ?? null,
|
wordCount: wordCount ?? null,
|
||||||
shareToken: shareToken ?? null,
|
shareToken: shareToken ?? null,
|
||||||
|
contentType: contentType ?? "markdown",
|
||||||
});
|
});
|
||||||
set(rightPanelTabAtom, "report");
|
set(rightPanelTabAtom, "report");
|
||||||
set(rightPanelCollapsedAtom, false);
|
set(rightPanelCollapsedAtom, false);
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,13 @@ const GenerateReportToolUI = dynamic(
|
||||||
})),
|
})),
|
||||||
{ ssr: false }
|
{ ssr: false }
|
||||||
);
|
);
|
||||||
|
const GenerateResumeToolUI = dynamic(
|
||||||
|
() =>
|
||||||
|
import("@/components/tool-ui/generate-resume").then((m) => ({
|
||||||
|
default: m.GenerateResumeToolUI,
|
||||||
|
})),
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
const GeneratePodcastToolUI = dynamic(
|
const GeneratePodcastToolUI = dynamic(
|
||||||
() =>
|
() =>
|
||||||
import("@/components/tool-ui/generate-podcast").then((m) => ({
|
import("@/components/tool-ui/generate-podcast").then((m) => ({
|
||||||
|
|
@ -487,6 +494,7 @@ const AssistantMessageInner: FC = () => {
|
||||||
tools: {
|
tools: {
|
||||||
by_name: {
|
by_name: {
|
||||||
generate_report: GenerateReportToolUI,
|
generate_report: GenerateReportToolUI,
|
||||||
|
generate_resume: GenerateResumeToolUI,
|
||||||
generate_podcast: GeneratePodcastToolUI,
|
generate_podcast: GeneratePodcastToolUI,
|
||||||
generate_video_presentation: GenerateVideoPresentationToolUI,
|
generate_video_presentation: GenerateVideoPresentationToolUI,
|
||||||
display_image: GenerateImageToolUI,
|
display_image: GenerateImageToolUI,
|
||||||
|
|
@ -537,7 +545,7 @@ const AssistantMessageInner: FC = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="aui-assistant-message-footer mt-1 mb-5 ml-2 flex items-center gap-2">
|
<div className="aui-assistant-message-footer mt-3 mb-5 ml-2 flex items-center gap-2">
|
||||||
<AssistantActionBar />
|
<AssistantActionBar />
|
||||||
</div>
|
</div>
|
||||||
</CitationMetadataProvider>
|
</CitationMetadataProvider>
|
||||||
|
|
|
||||||
|
|
@ -340,5 +340,85 @@ export const AUTO_INDEX_DEFAULTS: Record<string, AutoIndexConfig> = {
|
||||||
|
|
||||||
export const AUTO_INDEX_CONNECTOR_TYPES = new Set<string>(Object.keys(AUTO_INDEX_DEFAULTS));
|
export const AUTO_INDEX_CONNECTOR_TYPES = new Set<string>(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<string, ConnectorTelemetryMeta> =
|
||||||
|
(() => {
|
||||||
|
const map = new Map<string, ConnectorTelemetryMeta>();
|
||||||
|
|
||||||
|
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
|
// Re-export IndexingConfigState from schemas for backward compatibility
|
||||||
export type { IndexingConfigState } from "./connector-popup.schemas";
|
export type { IndexingConfigState } from "./connector-popup.schemas";
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@ import { isSelfHosted } from "@/lib/env-config";
|
||||||
import {
|
import {
|
||||||
trackConnectorConnected,
|
trackConnectorConnected,
|
||||||
trackConnectorDeleted,
|
trackConnectorDeleted,
|
||||||
|
trackConnectorSetupFailure,
|
||||||
|
trackConnectorSetupStarted,
|
||||||
trackIndexWithDateRangeOpened,
|
trackIndexWithDateRangeOpened,
|
||||||
trackIndexWithDateRangeStarted,
|
trackIndexWithDateRangeStarted,
|
||||||
trackPeriodicIndexingStarted,
|
trackPeriodicIndexingStarted,
|
||||||
|
|
@ -232,10 +234,20 @@ export const useConnectorDialog = () => {
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
const oauthConnector = result.connector
|
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;
|
: null;
|
||||||
const name = oauthConnector?.title || "connector";
|
const name = oauthConnector?.title || "connector";
|
||||||
|
|
||||||
|
if (oauthConnector) {
|
||||||
|
trackConnectorSetupFailure(
|
||||||
|
Number(searchSpaceId),
|
||||||
|
oauthConnector.connectorType,
|
||||||
|
result.error,
|
||||||
|
"oauth_callback"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (result.error === "duplicate_account") {
|
if (result.error === "duplicate_account") {
|
||||||
toast.error(`This ${name} account is already connected`, {
|
toast.error(`This ${name} account is already connected`, {
|
||||||
description: "Please use a different account or manage the existing connection.",
|
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
|
// Set connecting state immediately to disable button and show spinner
|
||||||
setConnectingId(connector.id);
|
setConnectingId(connector.id);
|
||||||
|
|
||||||
|
trackConnectorSetupStarted(
|
||||||
|
Number(searchSpaceId),
|
||||||
|
connector.connectorType,
|
||||||
|
"oauth_click"
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if authEndpoint already has query parameters
|
// Check if authEndpoint already has query parameters
|
||||||
const separator = connector.authEndpoint.includes("?") ? "&" : "?";
|
const separator = connector.authEndpoint.includes("?") ? "&" : "?";
|
||||||
|
|
@ -369,6 +387,12 @@ export const useConnectorDialog = () => {
|
||||||
window.location.href = validatedData.auth_url;
|
window.location.href = validatedData.auth_url;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error connecting to ${connector.title}:`, 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")) {
|
if (error instanceof Error && error.message.includes("Invalid auth URL")) {
|
||||||
toast.error(`Invalid response from ${connector.title} OAuth endpoint`);
|
toast.error(`Invalid response from ${connector.title} OAuth endpoint`);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -392,6 +416,11 @@ export const useConnectorDialog = () => {
|
||||||
if (!searchSpaceId) return;
|
if (!searchSpaceId) return;
|
||||||
|
|
||||||
setConnectingId("webcrawler-connector");
|
setConnectingId("webcrawler-connector");
|
||||||
|
trackConnectorSetupStarted(
|
||||||
|
Number(searchSpaceId),
|
||||||
|
EnumConnectorName.WEBCRAWLER_CONNECTOR,
|
||||||
|
"webcrawler_quick_add"
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
await createConnector({
|
await createConnector({
|
||||||
data: {
|
data: {
|
||||||
|
|
@ -441,6 +470,12 @@ export const useConnectorDialog = () => {
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error creating webcrawler connector:", 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");
|
toast.error("Failed to create web crawler connector");
|
||||||
} finally {
|
} finally {
|
||||||
setConnectingId(null);
|
setConnectingId(null);
|
||||||
|
|
@ -452,6 +487,12 @@ export const useConnectorDialog = () => {
|
||||||
(connectorType: string) => {
|
(connectorType: string) => {
|
||||||
if (!searchSpaceId) return;
|
if (!searchSpaceId) return;
|
||||||
|
|
||||||
|
trackConnectorSetupStarted(
|
||||||
|
Number(searchSpaceId),
|
||||||
|
connectorType,
|
||||||
|
"non_oauth_click"
|
||||||
|
);
|
||||||
|
|
||||||
// Handle Obsidian specifically on Desktop & Cloud
|
// Handle Obsidian specifically on Desktop & Cloud
|
||||||
if (connectorType === EnumConnectorName.OBSIDIAN_CONNECTOR && !selfHosted && isDesktop) {
|
if (connectorType === EnumConnectorName.OBSIDIAN_CONNECTOR && !selfHosted && isDesktop) {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
|
|
@ -680,6 +721,12 @@ export const useConnectorDialog = () => {
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error creating connector:", 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");
|
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||||
} finally {
|
} finally {
|
||||||
isCreatingConnectorRef.current = false;
|
isCreatingConnectorRef.current = false;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -29,12 +29,16 @@ export function CreateFolderDialog({
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
const handleOpenChange = useCallback(
|
||||||
if (open) {
|
(next: boolean) => {
|
||||||
setName("");
|
if (next) {
|
||||||
setTimeout(() => inputRef.current?.focus(), 0);
|
setName("");
|
||||||
}
|
setTimeout(() => inputRef.current?.focus(), 0);
|
||||||
}, [open]);
|
}
|
||||||
|
onOpenChange(next);
|
||||||
|
},
|
||||||
|
[onOpenChange]
|
||||||
|
);
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
(e?: React.FormEvent) => {
|
(e?: React.FormEvent) => {
|
||||||
|
|
@ -50,7 +54,7 @@ export function CreateFolderDialog({
|
||||||
const isSubfolder = !!parentFolderName;
|
const isSubfolder = !!parentFolderName;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
<DialogContent className="select-none max-w-[90vw] sm:max-w-sm p-4 sm:p-5 data-[state=open]:animate-none data-[state=closed]:animate-none">
|
<DialogContent className="select-none max-w-[90vw] sm:max-w-sm p-4 sm:p-5 data-[state=open]:animate-none data-[state=closed]:animate-none">
|
||||||
<DialogHeader className="space-y-2 pb-2">
|
<DialogHeader className="space-y-2 pb-2">
|
||||||
<div className="flex items-center gap-2 sm:gap-3">
|
<div className="flex items-center gap-2 sm:gap-3">
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ChevronDown, ChevronRight, Folder, FolderOpen, Home } from "lucide-react";
|
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 { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -36,12 +36,16 @@ export function FolderPickerDialog({
|
||||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||||
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
|
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
useEffect(() => {
|
const handleOpenChange = useCallback(
|
||||||
if (open) {
|
(next: boolean) => {
|
||||||
setSelectedId(null);
|
if (next) {
|
||||||
setExpandedIds(new Set());
|
setSelectedId(null);
|
||||||
}
|
setExpandedIds(new Set());
|
||||||
}, [open]);
|
}
|
||||||
|
onOpenChange(next);
|
||||||
|
},
|
||||||
|
[onOpenChange]
|
||||||
|
);
|
||||||
|
|
||||||
const foldersByParent = useMemo(() => {
|
const foldersByParent = useMemo(() => {
|
||||||
const map: Record<string, FolderDisplay[]> = {};
|
const map: Record<string, FolderDisplay[]> = {};
|
||||||
|
|
@ -123,7 +127,7 @@ export function FolderPickerDialog({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
<DialogContent className="select-none max-w-[90vw] sm:max-w-sm p-4 sm:p-5 data-[state=open]:animate-none data-[state=closed]:animate-none">
|
<DialogContent className="select-none max-w-[90vw] sm:max-w-sm p-4 sm:p-5 data-[state=open]:animate-none data-[state=closed]:animate-none">
|
||||||
<DialogHeader className="space-y-2 pb-2">
|
<DialogHeader className="space-y-2 pb-2">
|
||||||
<div className="flex items-center gap-2 sm:gap-3">
|
<div className="flex items-center gap-2 sm:gap-3">
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import type { AnonModel, AnonQuotaResponse } from "@/contracts/types/anonymous-chat.types";
|
import type { AnonModel, AnonQuotaResponse } from "@/contracts/types/anonymous-chat.types";
|
||||||
import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service";
|
import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service";
|
||||||
import { readSSEStream } from "@/lib/chat/streaming-state";
|
import { readSSEStream } from "@/lib/chat/streaming-state";
|
||||||
|
import { trackAnonymousChatMessageSent } from "@/lib/posthog/events";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { QuotaBar } from "./quota-bar";
|
import { QuotaBar } from "./quota-bar";
|
||||||
import { QuotaWarningBanner } from "./quota-warning-banner";
|
import { QuotaWarningBanner } from "./quota-warning-banner";
|
||||||
|
|
@ -61,6 +62,12 @@ export function AnonymousChat({ model }: AnonymousChatProps) {
|
||||||
textareaRef.current.style.height = "auto";
|
textareaRef.current.style.height = "auto";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
trackAnonymousChatMessageSent({
|
||||||
|
modelSlug: model.seo_slug,
|
||||||
|
messageLength: trimmed.length,
|
||||||
|
surface: "free_model_page",
|
||||||
|
});
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
abortRef.current = controller;
|
abortRef.current = controller;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import {
|
||||||
updateToolCall,
|
updateToolCall,
|
||||||
} from "@/lib/chat/streaming-state";
|
} from "@/lib/chat/streaming-state";
|
||||||
import { BACKEND_URL } from "@/lib/env-config";
|
import { BACKEND_URL } from "@/lib/env-config";
|
||||||
|
import { trackAnonymousChatMessageSent } from "@/lib/posthog/events";
|
||||||
import { FreeModelSelector } from "./free-model-selector";
|
import { FreeModelSelector } from "./free-model-selector";
|
||||||
import { FreeThread } from "./free-thread";
|
import { FreeThread } from "./free-thread";
|
||||||
|
|
||||||
|
|
@ -206,6 +207,14 @@ export function FreeChatPage() {
|
||||||
}
|
}
|
||||||
if (!userQuery.trim()) return;
|
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()}`;
|
const userMsgId = `msg-user-${Date.now()}`;
|
||||||
setMessages((prev) => [
|
setMessages((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
|
|
|
||||||
|
|
@ -27,13 +27,14 @@ export function FreeModelSelector({ className }: { className?: string }) {
|
||||||
anonymousChatApiService.getModels().then(setModels).catch(console.error);
|
anonymousChatApiService.getModels().then(setModels).catch(console.error);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
const handleOpenChange = useCallback((next: boolean) => {
|
||||||
if (open) {
|
if (next) {
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
setFocusedIndex(-1);
|
setFocusedIndex(-1);
|
||||||
requestAnimationFrame(() => searchInputRef.current?.focus());
|
requestAnimationFrame(() => searchInputRef.current?.focus());
|
||||||
}
|
}
|
||||||
}, [open]);
|
setOpen(next);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const currentModel = useMemo(
|
const currentModel = useMemo(
|
||||||
() => models.find((m) => m.seo_slug === currentSlug) ?? null,
|
() => models.find((m) => m.seo_slug === currentSlug) ?? null,
|
||||||
|
|
@ -94,7 +95,7 @@ export function FreeModelSelector({ className }: { className?: string }) {
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,10 @@
|
||||||
|
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { activeTabAtom, type Tab } from "@/atoms/tabs/tabs.atom";
|
import { activeTabAtom, type Tab } from "@/atoms/tabs/tabs.atom";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import type { InboxItem } from "@/hooks/use-inbox";
|
import type { InboxItem } from "@/hooks/use-inbox";
|
||||||
import { useIsMobile } from "@/hooks/use-mobile";
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
|
|
@ -25,9 +27,20 @@ import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
} from "../sidebar";
|
} from "../sidebar";
|
||||||
import { SidebarSlideOutPanel } from "../sidebar/SidebarSlideOutPanel";
|
import { SidebarSlideOutPanel } from "../sidebar/SidebarSlideOutPanel";
|
||||||
import { DocumentTabContent } from "../tabs/DocumentTabContent";
|
|
||||||
import { TabBar } from "../tabs/TabBar";
|
import { TabBar } from "../tabs/TabBar";
|
||||||
|
|
||||||
|
const DocumentTabContent = dynamic(
|
||||||
|
() => import("../tabs/DocumentTabContent").then((m) => ({ default: m.DocumentTabContent })),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="flex-1 flex items-center justify-center h-full">
|
||||||
|
<Spinner size="lg" />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Per-tab data source
|
// Per-tab data source
|
||||||
interface TabDataSource {
|
interface TabDataSource {
|
||||||
items: InboxItem[];
|
items: InboxItem[];
|
||||||
|
|
|
||||||
|
|
@ -478,7 +478,7 @@ function AuthenticatedDocumentsSidebar({
|
||||||
setFolderPickerOpen(true);
|
setFolderPickerOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const [, setIsExportingKB] = useState(false);
|
const isExportingKBRef = useRef(false);
|
||||||
const [exportWarningOpen, setExportWarningOpen] = useState(false);
|
const [exportWarningOpen, setExportWarningOpen] = useState(false);
|
||||||
const [exportWarningContext, setExportWarningContext] = useState<{
|
const [exportWarningContext, setExportWarningContext] = useState<{
|
||||||
folder: FolderDisplay;
|
folder: FolderDisplay;
|
||||||
|
|
@ -508,7 +508,7 @@ function AuthenticatedDocumentsSidebar({
|
||||||
const ctx = exportWarningContext;
|
const ctx = exportWarningContext;
|
||||||
if (!ctx?.folder) return;
|
if (!ctx?.folder) return;
|
||||||
|
|
||||||
setIsExportingKB(true);
|
isExportingKBRef.current = true;
|
||||||
try {
|
try {
|
||||||
const safeName =
|
const safeName =
|
||||||
ctx.folder.name
|
ctx.folder.name
|
||||||
|
|
@ -524,7 +524,7 @@ function AuthenticatedDocumentsSidebar({
|
||||||
console.error("Folder export failed:", err);
|
console.error("Folder export failed:", err);
|
||||||
toast.error(err instanceof Error ? err.message : "Export failed");
|
toast.error(err instanceof Error ? err.message : "Export failed");
|
||||||
} finally {
|
} finally {
|
||||||
setIsExportingKB(false);
|
isExportingKBRef.current = false;
|
||||||
}
|
}
|
||||||
setExportWarningContext(null);
|
setExportWarningContext(null);
|
||||||
}, [exportWarningContext, searchSpaceId, doExport]);
|
}, [exportWarningContext, searchSpaceId, doExport]);
|
||||||
|
|
@ -560,7 +560,7 @@ function AuthenticatedDocumentsSidebar({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsExportingKB(true);
|
isExportingKBRef.current = true;
|
||||||
try {
|
try {
|
||||||
const safeName =
|
const safeName =
|
||||||
folder.name
|
folder.name
|
||||||
|
|
@ -576,7 +576,7 @@ function AuthenticatedDocumentsSidebar({
|
||||||
console.error("Folder export failed:", err);
|
console.error("Folder export failed:", err);
|
||||||
toast.error(err instanceof Error ? err.message : "Export failed");
|
toast.error(err instanceof Error ? err.message : "Export failed");
|
||||||
} finally {
|
} finally {
|
||||||
setIsExportingKB(false);
|
isExportingKBRef.current = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[searchSpaceId, getPendingCountInSubtree, doExport]
|
[searchSpaceId, getPendingCountInSubtree, doExport]
|
||||||
|
|
|
||||||
|
|
@ -269,6 +269,34 @@ export function ModelSelector({
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
const handleOpenChange = useCallback(
|
||||||
|
(next: boolean) => {
|
||||||
|
if (next) {
|
||||||
|
setSearchQuery("");
|
||||||
|
setSelectedProvider("all");
|
||||||
|
if (!isMobile) {
|
||||||
|
requestAnimationFrame(() => searchInputRef.current?.focus());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setOpen(next);
|
||||||
|
},
|
||||||
|
[isMobile]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTabChange = useCallback(
|
||||||
|
(next: "llm" | "image" | "vision") => {
|
||||||
|
setActiveTab(next);
|
||||||
|
setSelectedProvider("all");
|
||||||
|
setSearchQuery("");
|
||||||
|
setFocusedIndex(-1);
|
||||||
|
setModelScrollPos("top");
|
||||||
|
if (open && !isMobile) {
|
||||||
|
requestAnimationFrame(() => searchInputRef.current?.focus());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[open, isMobile]
|
||||||
|
);
|
||||||
|
|
||||||
const handleModelListScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
const handleModelListScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||||
const el = e.currentTarget;
|
const el = e.currentTarget;
|
||||||
const atTop = el.scrollTop <= 2;
|
const atTop = el.scrollTop <= 2;
|
||||||
|
|
@ -292,43 +320,19 @@ export function ModelSelector({
|
||||||
[isMobile]
|
[isMobile]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Reset search + provider when tab changes
|
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: activeTab is intentionally used as a trigger
|
|
||||||
useEffect(() => {
|
|
||||||
setSelectedProvider("all");
|
|
||||||
setSearchQuery("");
|
|
||||||
setFocusedIndex(-1);
|
|
||||||
setModelScrollPos("top");
|
|
||||||
}, [activeTab]);
|
|
||||||
|
|
||||||
// Reset on open
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
setSearchQuery("");
|
|
||||||
setSelectedProvider("all");
|
|
||||||
}
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
// Cmd/Ctrl+M shortcut (desktop only)
|
// Cmd/Ctrl+M shortcut (desktop only)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isMobile) return;
|
if (isMobile) return;
|
||||||
const handler = (e: KeyboardEvent) => {
|
const handler = (e: KeyboardEvent) => {
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === "m") {
|
if ((e.metaKey || e.ctrlKey) && e.key === "m") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setOpen((prev) => !prev);
|
// setOpen((prev) => !prev);
|
||||||
|
handleOpenChange(!open);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener("keydown", handler);
|
document.addEventListener("keydown", handler);
|
||||||
return () => document.removeEventListener("keydown", handler);
|
return () => document.removeEventListener("keydown", handler);
|
||||||
}, [isMobile]);
|
}, [isMobile, open, handleOpenChange]);
|
||||||
|
|
||||||
// Focus search input on open
|
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: activeTab is intentionally used as a trigger to re-focus on tab switch
|
|
||||||
useEffect(() => {
|
|
||||||
if (open && !isMobile) {
|
|
||||||
requestAnimationFrame(() => searchInputRef.current?.focus());
|
|
||||||
}
|
|
||||||
}, [open, isMobile, activeTab]);
|
|
||||||
|
|
||||||
// ─── Data ───
|
// ─── Data ───
|
||||||
const { data: llmUserConfigs, isLoading: llmUserLoading } = useAtomValue(newLLMConfigsAtom);
|
const { data: llmUserConfigs, isLoading: llmUserLoading } = useAtomValue(newLLMConfigsAtom);
|
||||||
|
|
@ -971,7 +975,8 @@ export function ModelSelector({
|
||||||
<button
|
<button
|
||||||
key={value}
|
key={value}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setActiveTab(value)}
|
// onClick={() => setActiveTab(value)}
|
||||||
|
onClick={() => handleTabChange(value)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center justify-center gap-1.5 text-sm font-medium transition-all duration-200 border-b-[1.5px]",
|
"flex items-center justify-center gap-1.5 text-sm font-medium transition-all duration-200 border-b-[1.5px]",
|
||||||
activeTab === value
|
activeTab === value
|
||||||
|
|
@ -1208,7 +1213,7 @@ export function ModelSelector({
|
||||||
// ─── Shell: Drawer on mobile, Popover on desktop ───
|
// ─── Shell: Drawer on mobile, Popover on desktop ───
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
<Drawer open={open} onOpenChange={setOpen}>
|
<Drawer open={open} onOpenChange={handleOpenChange}>
|
||||||
<DrawerTrigger asChild>{triggerButton}</DrawerTrigger>
|
<DrawerTrigger asChild>{triggerButton}</DrawerTrigger>
|
||||||
<DrawerContent className="max-h-[85vh]">
|
<DrawerContent className="max-h-[85vh]">
|
||||||
<DrawerHandle />
|
<DrawerHandle />
|
||||||
|
|
@ -1222,7 +1227,7 @@ export function ModelSelector({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||||
<PopoverTrigger asChild>{triggerButton}</PopoverTrigger>
|
<PopoverTrigger asChild>{triggerButton}</PopoverTrigger>
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
className="w-[300px] md:w-[380px] p-0 rounded-lg shadow-lg overflow-hidden bg-white border-border/60 dark:bg-neutral-900 dark:border dark:border-white/5 select-none"
|
className="w-[300px] md:w-[380px] p-0 rounded-lg shadow-lg overflow-hidden bg-white border-border/60 dark:bg-neutral-900 dark:border dark:border-white/5 select-none"
|
||||||
|
|
|
||||||
|
|
@ -133,7 +133,6 @@ export function SourceDetailPanel({
|
||||||
const scrollTimersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
const scrollTimersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||||
const [activeChunkIndex, setActiveChunkIndex] = useState<number | null>(null);
|
const [activeChunkIndex, setActiveChunkIndex] = useState<number | null>(null);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const [_hasScrolledToCited, setHasScrolledToCited] = useState(false);
|
|
||||||
const shouldReduceMotion = useReducedMotion();
|
const shouldReduceMotion = useReducedMotion();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -322,11 +321,10 @@ export function SourceDetailPanel({
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// After final attempt, mark state as scrolled
|
// After final attempt, mark the cited chunk as active
|
||||||
scrollTimersRef.current.push(
|
scrollTimersRef.current.push(
|
||||||
setTimeout(
|
setTimeout(
|
||||||
() => {
|
() => {
|
||||||
setHasScrolledToCited(true);
|
|
||||||
setActiveChunkIndex(citedChunkIndex);
|
setActiveChunkIndex(citedChunkIndex);
|
||||||
},
|
},
|
||||||
scrollAttempts[scrollAttempts.length - 1] + 50
|
scrollAttempts[scrollAttempts.length - 1] + 50
|
||||||
|
|
@ -343,7 +341,6 @@ export function SourceDetailPanel({
|
||||||
scrollTimersRef.current.forEach(clearTimeout);
|
scrollTimersRef.current.forEach(clearTimeout);
|
||||||
scrollTimersRef.current = [];
|
scrollTimersRef.current = [];
|
||||||
hasScrolledRef.current = false;
|
hasScrolledRef.current = false;
|
||||||
setHasScrolledToCited(false);
|
|
||||||
setActiveChunkIndex(null);
|
setActiveChunkIndex(null);
|
||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ export function PublicChatFooter({ shareToken }: PublicChatFooterProps) {
|
||||||
size="lg"
|
size="lg"
|
||||||
onClick={handleCopyAndContinue}
|
onClick={handleCopyAndContinue}
|
||||||
disabled={isCloning}
|
disabled={isCloning}
|
||||||
className="gap-2 rounded-full px-6 shadow-lg transition-all duration-200 hover:scale-[1.02] hover:shadow-xl hover:brightness-110 hover:bg-primary"
|
className="gap-2 rounded-full px-6 shadow-lg transition-al select-none duration-200 hover:scale-[1.02] hover:shadow-xl hover:brightness-110 hover:bg-primary"
|
||||||
>
|
>
|
||||||
{isCloning ? <Spinner size="sm" /> : <Copy className="size-4" />}
|
{isCloning ? <Spinner size="sm" /> : <Copy className="size-4" />}
|
||||||
Copy and continue this chat
|
Copy and continue this chat
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button
|
||||||
import { GenerateImageToolUI } from "@/components/tool-ui/generate-image";
|
import { GenerateImageToolUI } from "@/components/tool-ui/generate-image";
|
||||||
import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
|
import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
|
||||||
import { GenerateReportToolUI } from "@/components/tool-ui/generate-report";
|
import { GenerateReportToolUI } from "@/components/tool-ui/generate-report";
|
||||||
|
import { GenerateResumeToolUI } from "@/components/tool-ui/generate-resume";
|
||||||
|
|
||||||
const GenerateVideoPresentationToolUI = dynamic(
|
const GenerateVideoPresentationToolUI = dynamic(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -160,6 +161,7 @@ const PublicAssistantMessage: FC = () => {
|
||||||
by_name: {
|
by_name: {
|
||||||
generate_podcast: GeneratePodcastToolUI,
|
generate_podcast: GeneratePodcastToolUI,
|
||||||
generate_report: GenerateReportToolUI,
|
generate_report: GenerateReportToolUI,
|
||||||
|
generate_resume: GenerateResumeToolUI,
|
||||||
generate_video_presentation: GenerateVideoPresentationToolUI,
|
generate_video_presentation: GenerateVideoPresentationToolUI,
|
||||||
display_image: GenerateImageToolUI,
|
display_image: GenerateImageToolUI,
|
||||||
generate_image: GenerateImageToolUI,
|
generate_image: GenerateImageToolUI,
|
||||||
|
|
|
||||||
354
surfsense_web/components/report-panel/pdf-viewer.tsx
Normal file
354
surfsense_web/components/report-panel/pdf-viewer.tsx
Normal file
|
|
@ -0,0 +1,354 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ZoomInIcon, ZoomOutIcon } from "lucide-react";
|
||||||
|
import type { PDFDocumentProxy, RenderTask } from "pdfjs-dist";
|
||||||
|
import * as pdfjsLib from "pdfjs-dist";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { getAuthHeaders } from "@/lib/auth-utils";
|
||||||
|
|
||||||
|
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||||
|
"pdfjs-dist/build/pdf.worker.min.mjs",
|
||||||
|
import.meta.url
|
||||||
|
).toString();
|
||||||
|
|
||||||
|
interface PdfViewerProps {
|
||||||
|
pdfUrl: string;
|
||||||
|
isPublic?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageDimensions {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ZOOM_STEP = 0.15;
|
||||||
|
const MIN_ZOOM = 0.5;
|
||||||
|
const MAX_ZOOM = 3;
|
||||||
|
const PAGE_GAP = 12;
|
||||||
|
const SCROLL_DEBOUNCE_MS = 30;
|
||||||
|
const BUFFER_PAGES = 1;
|
||||||
|
|
||||||
|
export function PdfViewer({ pdfUrl, isPublic = false }: PdfViewerProps) {
|
||||||
|
const [numPages, setNumPages] = useState(0);
|
||||||
|
const [scale, setScale] = useState(1);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const pdfDocRef = useRef<PDFDocumentProxy | null>(null);
|
||||||
|
const canvasRefs = useRef<Map<number, HTMLCanvasElement>>(new Map());
|
||||||
|
const renderTasksRef = useRef<Map<number, RenderTask>>(new Map());
|
||||||
|
const renderedScalesRef = useRef<Map<number, number>>(new Map());
|
||||||
|
const pageDimsRef = useRef<PageDimensions[]>([]);
|
||||||
|
const visiblePagesRef = useRef<Set<number>>(new Set());
|
||||||
|
const scrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const getScaledHeight = useCallback(
|
||||||
|
(pageIndex: number) => {
|
||||||
|
const dims = pageDimsRef.current[pageIndex];
|
||||||
|
return dims ? Math.floor(dims.height * scale) : 0;
|
||||||
|
},
|
||||||
|
[scale]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getVisibleRange = useCallback(() => {
|
||||||
|
const container = scrollContainerRef.current;
|
||||||
|
if (!container || pageDimsRef.current.length === 0) return { first: 1, last: 1 };
|
||||||
|
|
||||||
|
const scrollTop = container.scrollTop;
|
||||||
|
const viewportHeight = container.clientHeight;
|
||||||
|
const scrollBottom = scrollTop + viewportHeight;
|
||||||
|
|
||||||
|
let cumTop = 16;
|
||||||
|
let first = 1;
|
||||||
|
let last = pageDimsRef.current.length;
|
||||||
|
|
||||||
|
for (let i = 0; i < pageDimsRef.current.length; i++) {
|
||||||
|
const pageHeight = getScaledHeight(i);
|
||||||
|
const pageBottom = cumTop + pageHeight;
|
||||||
|
|
||||||
|
if (pageBottom >= scrollTop && first === 1) {
|
||||||
|
first = i + 1;
|
||||||
|
}
|
||||||
|
if (cumTop > scrollBottom) {
|
||||||
|
last = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
cumTop = pageBottom + PAGE_GAP;
|
||||||
|
}
|
||||||
|
|
||||||
|
first = Math.max(1, first - BUFFER_PAGES);
|
||||||
|
last = Math.min(pageDimsRef.current.length, last + BUFFER_PAGES);
|
||||||
|
|
||||||
|
return { first, last };
|
||||||
|
}, [getScaledHeight]);
|
||||||
|
|
||||||
|
const renderPage = useCallback(async (pageNum: number, currentScale: number) => {
|
||||||
|
const pdf = pdfDocRef.current;
|
||||||
|
const canvas = canvasRefs.current.get(pageNum);
|
||||||
|
if (!pdf || !canvas) return;
|
||||||
|
|
||||||
|
if (renderedScalesRef.current.get(pageNum) === currentScale) return;
|
||||||
|
|
||||||
|
const existing = renderTasksRef.current.get(pageNum);
|
||||||
|
if (existing) {
|
||||||
|
existing.cancel();
|
||||||
|
renderTasksRef.current.delete(pageNum);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const page = await pdf.getPage(pageNum);
|
||||||
|
const viewport = page.getViewport({ scale: currentScale });
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
|
||||||
|
canvas.width = Math.floor(viewport.width * dpr);
|
||||||
|
canvas.height = Math.floor(viewport.height * dpr);
|
||||||
|
canvas.style.width = `${Math.floor(viewport.width)}px`;
|
||||||
|
canvas.style.height = `${Math.floor(viewport.height)}px`;
|
||||||
|
|
||||||
|
const renderTask = page.render({
|
||||||
|
canvas,
|
||||||
|
viewport,
|
||||||
|
transform: dpr !== 1 ? [dpr, 0, 0, dpr, 0, 0] : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderTasksRef.current.set(pageNum, renderTask);
|
||||||
|
|
||||||
|
await renderTask.promise;
|
||||||
|
renderTasksRef.current.delete(pageNum);
|
||||||
|
renderedScalesRef.current.set(pageNum, currentScale);
|
||||||
|
page.cleanup();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof Error && err.message?.includes("cancelled")) return;
|
||||||
|
console.error(`Failed to render page ${pageNum}:`, err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const cleanupPage = useCallback((pageNum: number) => {
|
||||||
|
const existing = renderTasksRef.current.get(pageNum);
|
||||||
|
if (existing) {
|
||||||
|
existing.cancel();
|
||||||
|
renderTasksRef.current.delete(pageNum);
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = canvasRefs.current.get(pageNum);
|
||||||
|
if (canvas) {
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (ctx) ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
canvas.width = 0;
|
||||||
|
canvas.height = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderedScalesRef.current.delete(pageNum);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const renderVisiblePages = useCallback(() => {
|
||||||
|
if (!pdfDocRef.current || pageDimsRef.current.length === 0) return;
|
||||||
|
|
||||||
|
const { first, last } = getVisibleRange();
|
||||||
|
const newVisible = new Set<number>();
|
||||||
|
|
||||||
|
for (let i = first; i <= last; i++) {
|
||||||
|
newVisible.add(i);
|
||||||
|
renderPage(i, scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const pageNum of visiblePagesRef.current) {
|
||||||
|
if (!newVisible.has(pageNum)) {
|
||||||
|
cleanupPage(pageNum);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
visiblePagesRef.current = newVisible;
|
||||||
|
}, [getVisibleRange, renderPage, cleanupPage, scale]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const loadDocument = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setLoadError(null);
|
||||||
|
setNumPages(0);
|
||||||
|
pageDimsRef.current = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const loadingTask = pdfjsLib.getDocument({
|
||||||
|
url: pdfUrl,
|
||||||
|
httpHeaders: getAuthHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const pdf = await loadingTask.promise;
|
||||||
|
if (cancelled) {
|
||||||
|
pdf.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dims: PageDimensions[] = [];
|
||||||
|
for (let i = 1; i <= pdf.numPages; i++) {
|
||||||
|
const page = await pdf.getPage(i);
|
||||||
|
const viewport = page.getViewport({ scale: 1 });
|
||||||
|
dims.push({ width: viewport.width, height: viewport.height });
|
||||||
|
page.cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancelled) {
|
||||||
|
pdf.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pdfDocRef.current = pdf;
|
||||||
|
pageDimsRef.current = dims;
|
||||||
|
setNumPages(pdf.numPages);
|
||||||
|
setLoading(false);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (cancelled) return;
|
||||||
|
const message = err instanceof Error ? err.message : "Failed to load PDF";
|
||||||
|
setLoadError(message);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadDocument();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
for (const task of renderTasksRef.current.values()) {
|
||||||
|
task.cancel();
|
||||||
|
}
|
||||||
|
renderTasksRef.current.clear();
|
||||||
|
renderedScalesRef.current.clear();
|
||||||
|
visiblePagesRef.current.clear();
|
||||||
|
pdfDocRef.current?.destroy();
|
||||||
|
pdfDocRef.current = null;
|
||||||
|
};
|
||||||
|
}, [pdfUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (numPages === 0) return;
|
||||||
|
|
||||||
|
renderedScalesRef.current.clear();
|
||||||
|
visiblePagesRef.current.clear();
|
||||||
|
|
||||||
|
const frame = requestAnimationFrame(() => {
|
||||||
|
renderVisiblePages();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => cancelAnimationFrame(frame);
|
||||||
|
}, [numPages, renderVisiblePages]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = scrollContainerRef.current;
|
||||||
|
if (!container || numPages === 0) return;
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (scrollTimerRef.current) clearTimeout(scrollTimerRef.current);
|
||||||
|
scrollTimerRef.current = setTimeout(() => {
|
||||||
|
renderVisiblePages();
|
||||||
|
}, SCROLL_DEBOUNCE_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
container.addEventListener("scroll", handleScroll, { passive: true });
|
||||||
|
return () => {
|
||||||
|
container.removeEventListener("scroll", handleScroll);
|
||||||
|
if (scrollTimerRef.current) clearTimeout(scrollTimerRef.current);
|
||||||
|
};
|
||||||
|
}, [numPages, renderVisiblePages]);
|
||||||
|
|
||||||
|
const setCanvasRef = useCallback((pageNum: number, el: HTMLCanvasElement | null) => {
|
||||||
|
if (el) {
|
||||||
|
canvasRefs.current.set(pageNum, el);
|
||||||
|
} else {
|
||||||
|
canvasRefs.current.delete(pageNum);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const zoomIn = useCallback(() => {
|
||||||
|
setScale((prev) => Math.min(MAX_ZOOM, +(prev + ZOOM_STEP).toFixed(2)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const zoomOut = useCallback(() => {
|
||||||
|
setScale((prev) => Math.max(MIN_ZOOM, +(prev - ZOOM_STEP).toFixed(2)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loadError) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full gap-3 p-6 text-center">
|
||||||
|
<p className="font-medium text-foreground">Failed to load PDF</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{loadError}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{numPages > 0 && (
|
||||||
|
<div
|
||||||
|
className={`flex items-center justify-center gap-2 px-4 py-2 border-b shrink-0 select-none ${isPublic ? "bg-main-panel" : "bg-sidebar"}`}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={zoomOut}
|
||||||
|
disabled={scale <= MIN_ZOOM}
|
||||||
|
className="size-7"
|
||||||
|
>
|
||||||
|
<ZoomOutIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-muted-foreground tabular-nums min-w-[40px] text-center">
|
||||||
|
{Math.round(scale * 100)}%
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={zoomIn}
|
||||||
|
disabled={scale >= MAX_ZOOM}
|
||||||
|
className="size-7"
|
||||||
|
>
|
||||||
|
<ZoomInIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={scrollContainerRef}
|
||||||
|
className={`relative flex-1 overflow-auto ${isPublic ? "bg-main-panel" : "bg-sidebar"}`}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div
|
||||||
|
className={`absolute inset-0 flex items-center justify-center ${isPublic ? "text-foreground" : "text-sidebar-foreground"}`}
|
||||||
|
>
|
||||||
|
<Spinner size="md" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center py-4" style={{ gap: `${PAGE_GAP}px` }}>
|
||||||
|
{pageDimsRef.current.map((dims, i) => {
|
||||||
|
const pageNum = i + 1;
|
||||||
|
const scaledWidth = Math.floor(dims.width * scale);
|
||||||
|
const scaledHeight = Math.floor(dims.height * scale);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={pageNum}
|
||||||
|
className="relative shrink-0"
|
||||||
|
style={{ width: scaledWidth, height: scaledHeight }}
|
||||||
|
>
|
||||||
|
<canvas
|
||||||
|
ref={(el) => setCanvasRef(pageNum, el)}
|
||||||
|
className="shadow-lg absolute inset-0"
|
||||||
|
/>
|
||||||
|
{numPages > 1 && (
|
||||||
|
<span className="absolute bottom-2 right-3 text-[10px] tabular-nums text-white/80 bg-black/50 px-1.5 py-0.5 rounded pointer-events-none">
|
||||||
|
Page {pageNum}/{numPages}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||||
import { baseApiService } from "@/lib/apis/base-api.service";
|
import { baseApiService } from "@/lib/apis/base-api.service";
|
||||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||||
|
|
@ -53,6 +54,11 @@ const PlateEditor = dynamic(
|
||||||
{ ssr: false, loading: () => <ReportPanelSkeleton /> }
|
{ ssr: false, loading: () => <ReportPanelSkeleton /> }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const PdfViewer = dynamic(
|
||||||
|
() => import("@/components/report-panel/pdf-viewer").then((m) => ({ default: m.PdfViewer })),
|
||||||
|
{ ssr: false, loading: () => <ReportPanelSkeleton /> }
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Zod schema for a single version entry
|
* Zod schema for a single version entry
|
||||||
*/
|
*/
|
||||||
|
|
@ -68,6 +74,7 @@ const ReportContentResponseSchema = z.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
content: z.string().nullish(),
|
content: z.string().nullish(),
|
||||||
|
content_type: z.string().default("markdown"),
|
||||||
report_metadata: z
|
report_metadata: z
|
||||||
.object({
|
.object({
|
||||||
status: z.enum(["ready", "failed"]).nullish(),
|
status: z.enum(["ready", "failed"]).nullish(),
|
||||||
|
|
@ -280,47 +287,63 @@ export function ReportPanelContent({
|
||||||
}, [activeReportId, currentMarkdown]);
|
}, [activeReportId, currentMarkdown]);
|
||||||
|
|
||||||
const activeVersionIndex = versions.findIndex((v) => v.id === activeReportId);
|
const activeVersionIndex = versions.findIndex((v) => v.id === activeReportId);
|
||||||
|
const isPublic = !!shareToken;
|
||||||
|
const btnBg = isPublic ? "bg-main-panel" : "bg-sidebar";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Action bar — always visible; buttons are disabled while loading */}
|
{/* Action bar — always visible; buttons are disabled while loading */}
|
||||||
<div className="flex items-center justify-between px-4 py-2 shrink-0">
|
<div className="flex h-14 items-center justify-between px-4 shrink-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* Copy button */}
|
{/* Copy button — hidden for Typst (resume) */}
|
||||||
<Button
|
{reportContent?.content_type !== "typst" && (
|
||||||
variant="outline"
|
<Button
|
||||||
size="sm"
|
variant="outline"
|
||||||
onClick={handleCopy}
|
size="sm"
|
||||||
disabled={isLoading || !reportContent?.content}
|
onClick={handleCopy}
|
||||||
className="h-8 min-w-[80px] px-3.5 py-4 text-[15px] bg-sidebar select-none"
|
disabled={isLoading || !reportContent?.content}
|
||||||
>
|
className={`h-8 min-w-[80px] px-3.5 py-4 text-[15px] ${btnBg} select-none`}
|
||||||
{copied ? "Copied" : "Copy"}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* Export dropdown */}
|
|
||||||
<DropdownMenu modal={insideDrawer ? false : undefined}>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={isLoading || !reportContent?.content}
|
|
||||||
className="h-8 px-3.5 py-4 text-[15px] gap-1.5 bg-sidebar select-none"
|
|
||||||
>
|
|
||||||
Export
|
|
||||||
<ChevronDownIcon className="size-3" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent
|
|
||||||
align="start"
|
|
||||||
className={`min-w-[200px] select-none${insideDrawer ? " z-[100]" : ""}`}
|
|
||||||
>
|
>
|
||||||
<ExportDropdownItems
|
{copied ? "Copied" : "Copy"}
|
||||||
onExport={handleExport}
|
</Button>
|
||||||
exporting={exporting}
|
)}
|
||||||
showAllFormats={!shareToken}
|
|
||||||
/>
|
{/* Export — plain button for resume (typst), dropdown for others */}
|
||||||
</DropdownMenuContent>
|
{reportContent?.content_type === "typst" ? (
|
||||||
</DropdownMenu>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleExport("pdf")}
|
||||||
|
disabled={isLoading || !reportContent?.content || exporting !== null}
|
||||||
|
className={`h-8 min-w-[100px] px-3.5 py-4 text-[15px] ${btnBg} select-none`}
|
||||||
|
>
|
||||||
|
{exporting === "pdf" ? <Spinner size="xs" /> : "Download"}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<DropdownMenu modal={insideDrawer ? false : undefined}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={isLoading || !reportContent?.content}
|
||||||
|
className={`h-8 px-3.5 py-4 text-[15px] gap-1.5 ${btnBg} select-none`}
|
||||||
|
>
|
||||||
|
Export
|
||||||
|
<ChevronDownIcon className="size-3" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
align="start"
|
||||||
|
className={`min-w-[200px] select-none${insideDrawer ? " z-[100]" : ""}`}
|
||||||
|
>
|
||||||
|
<ExportDropdownItems
|
||||||
|
onExport={handleExport}
|
||||||
|
exporting={exporting}
|
||||||
|
showAllFormats={!shareToken}
|
||||||
|
/>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Version switcher — only shown when multiple versions exist */}
|
{/* Version switcher — only shown when multiple versions exist */}
|
||||||
{versions.length > 1 && (
|
{versions.length > 1 && (
|
||||||
|
|
@ -329,7 +352,7 @@ export function ReportPanelContent({
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8 px-3.5 py-4 text-[15px] gap-1.5 bg-sidebar select-none"
|
className={`h-8 px-3.5 py-4 text-[15px] gap-1.5 ${btnBg} select-none`}
|
||||||
>
|
>
|
||||||
v{activeVersionIndex + 1}
|
v{activeVersionIndex + 1}
|
||||||
<ChevronDownIcon className="size-3" />
|
<ChevronDownIcon className="size-3" />
|
||||||
|
|
@ -365,12 +388,17 @@ export function ReportPanelContent({
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<ReportPanelSkeleton />
|
<ReportPanelSkeleton />
|
||||||
) : error || !reportContent ? (
|
) : error || !reportContent ? (
|
||||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-6 text-center">
|
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-6 text-center select-none">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-foreground">Failed to load report</p>
|
<p className="font-medium text-foreground">Failed to load report</p>
|
||||||
<p className="text-sm text-red-500 mt-1">{error || "An unknown error occurred"}</p>
|
<p className="text-sm text-red-500 mt-1">{error || "An unknown error occurred"}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : reportContent.content_type === "typst" ? (
|
||||||
|
<PdfViewer
|
||||||
|
pdfUrl={`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}${shareToken ? `/api/v1/public/${shareToken}/reports/${activeReportId}/preview` : `/api/v1/reports/${activeReportId}/preview`}`}
|
||||||
|
isPublic={isPublic}
|
||||||
|
/>
|
||||||
) : reportContent.content ? (
|
) : reportContent.content ? (
|
||||||
isReadOnly ? (
|
isReadOnly ? (
|
||||||
<div className="h-full overflow-y-auto px-5 py-4">
|
<div className="h-full overflow-y-auto px-5 py-4">
|
||||||
|
|
@ -421,10 +449,12 @@ function DesktopReportPanel() {
|
||||||
|
|
||||||
if (!panelState.isOpen || !panelState.reportId) return null;
|
if (!panelState.isOpen || !panelState.reportId) return null;
|
||||||
|
|
||||||
|
const isPublic = !!panelState.shareToken;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
className="flex w-[50%] max-w-[700px] min-w-[380px] flex-col border-l bg-sidebar text-sidebar-foreground animate-in slide-in-from-right-4 duration-300 ease-out"
|
className={`flex w-[50%] max-w-[700px] min-w-[380px] flex-col border-l animate-in slide-in-from-right-4 duration-300 ease-out ${isPublic ? "bg-main-panel text-foreground" : "bg-sidebar text-sidebar-foreground"}`}
|
||||||
>
|
>
|
||||||
<ReportPanelContent
|
<ReportPanelContent
|
||||||
reportId={panelState.reportId}
|
reportId={panelState.reportId}
|
||||||
|
|
@ -445,6 +475,8 @@ function MobileReportDrawer() {
|
||||||
|
|
||||||
if (!panelState.reportId) return null;
|
if (!panelState.reportId) return null;
|
||||||
|
|
||||||
|
const isPublic = !!panelState.shareToken;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
open={panelState.isOpen}
|
open={panelState.isOpen}
|
||||||
|
|
@ -454,7 +486,7 @@ function MobileReportDrawer() {
|
||||||
shouldScaleBackground={false}
|
shouldScaleBackground={false}
|
||||||
>
|
>
|
||||||
<DrawerContent
|
<DrawerContent
|
||||||
className="h-[90vh] max-h-[90vh] z-80 bg-sidebar overflow-hidden"
|
className={`h-[90vh] max-h-[90vh] z-80 overflow-hidden ${isPublic ? "bg-main-panel" : "bg-sidebar"}`}
|
||||||
overlayClassName="z-80"
|
overlayClassName="z-80"
|
||||||
>
|
>
|
||||||
<DrawerHandle />
|
<DrawerHandle />
|
||||||
|
|
|
||||||
|
|
@ -24,18 +24,30 @@ interface ExportMenuItemsProps {
|
||||||
exporting: string | null;
|
exporting: string | null;
|
||||||
/** Hide server-side formats (PDF, DOCX, etc.) — only show md */
|
/** Hide server-side formats (PDF, DOCX, etc.) — only show md */
|
||||||
showAllFormats?: boolean;
|
showAllFormats?: boolean;
|
||||||
|
/** When true, only show PDF export (used for Typst-based resumes) */
|
||||||
|
pdfOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExportDropdownItems({
|
export function ExportDropdownItems({
|
||||||
onExport,
|
onExport,
|
||||||
exporting,
|
exporting,
|
||||||
showAllFormats = true,
|
showAllFormats = true,
|
||||||
|
pdfOnly = false,
|
||||||
}: ExportMenuItemsProps) {
|
}: ExportMenuItemsProps) {
|
||||||
const handle = (format: string) => (e: React.MouseEvent) => {
|
const handle = (format: string) => (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onExport(format);
|
onExport(format);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (pdfOnly) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem onClick={handle("pdf")} disabled={exporting !== null}>
|
||||||
|
{exporting === "pdf" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
|
||||||
|
PDF (.pdf)
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{showAllFormats && (
|
{showAllFormats && (
|
||||||
|
|
|
||||||
|
|
@ -586,7 +586,7 @@ export const useThemeToggle = ({
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toggleTheme = useCallback(() => {
|
const toggleTheme = useCallback(() => {
|
||||||
setIsDark(!isDark);
|
setIsDark((prev) => !prev);
|
||||||
|
|
||||||
const animation = createAnimation(variant, start, blur, gifUrl);
|
const animation = createAnimation(variant, start, blur, gifUrl);
|
||||||
|
|
||||||
|
|
@ -604,7 +604,7 @@ export const useThemeToggle = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
document.startViewTransition(switchTheme);
|
document.startViewTransition(switchTheme);
|
||||||
}, [theme, setTheme, variant, start, blur, gifUrl, updateStyles, isDark]);
|
}, [theme, setTheme, variant, start, blur, gifUrl, updateStyles]);
|
||||||
|
|
||||||
const setCrazyLightTheme = useCallback(() => {
|
const setCrazyLightTheme = useCallback(() => {
|
||||||
setIsDark(false);
|
setIsDark(false);
|
||||||
|
|
|
||||||
|
|
@ -96,8 +96,12 @@ function ReportErrorState({ title, error }: { title: string; error: string }) {
|
||||||
</div>
|
</div>
|
||||||
<div className="mx-5 h-px bg-border/50" />
|
<div className="mx-5 h-px bg-border/50" />
|
||||||
<div className="px-5 py-4">
|
<div className="px-5 py-4">
|
||||||
<p className="text-sm font-medium text-foreground line-clamp-2">{title}</p>
|
{title && title !== "Report" && (
|
||||||
<p className="text-sm text-muted-foreground mt-1">{error}</p>
|
<p className="text-sm font-medium text-foreground line-clamp-2">{title}</p>
|
||||||
|
)}
|
||||||
|
<p className={`text-sm text-muted-foreground${title && title !== "Report" ? " mt-1" : ""}`}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -215,17 +219,9 @@ function ReportCard({
|
||||||
<div
|
<div
|
||||||
className={`my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-[box-shadow] duration-300 ${isActive ? "ring-1 ring-primary/50" : ""}`}
|
className={`my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-[box-shadow] duration-300 ${isActive ? "ring-1 ring-primary/50" : ""}`}
|
||||||
>
|
>
|
||||||
{/* biome-ignore lint/a11y/useSemanticElements: can't use <button> here because PlateEditor renders nested <button> elements (e.g. CopyButton) */}
|
<button
|
||||||
<div
|
type="button"
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={handleOpen}
|
onClick={handleOpen}
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
handleOpen();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="w-full text-left transition-colors hover:bg-muted/50 focus:outline-none focus-visible:outline-none cursor-pointer"
|
className="w-full text-left transition-colors hover:bg-muted/50 focus:outline-none focus-visible:outline-none cursor-pointer"
|
||||||
>
|
>
|
||||||
<div className="px-5 pt-5 pb-4 select-none">
|
<div className="px-5 pt-5 pb-4 select-none">
|
||||||
|
|
@ -272,7 +268,7 @@ function ReportCard({
|
||||||
<p className="text-sm text-muted-foreground italic">No content available</p>
|
<p className="text-sm text-muted-foreground italic">No content available</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
334
surfsense_web/components/tool-ui/generate-resume.tsx
Normal file
334
surfsense_web/components/tool-ui/generate-resume.tsx
Normal file
|
|
@ -0,0 +1,334 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
|
||||||
|
import { useAtomValue, useSetAtom } from "jotai";
|
||||||
|
import { useParams, usePathname } from "next/navigation";
|
||||||
|
import * as pdfjsLib from "pdfjs-dist";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { openReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom";
|
||||||
|
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
||||||
|
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||||
|
import { getAuthHeaders } from "@/lib/auth-utils";
|
||||||
|
|
||||||
|
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||||
|
"pdfjs-dist/build/pdf.worker.min.mjs",
|
||||||
|
import.meta.url
|
||||||
|
).toString();
|
||||||
|
|
||||||
|
const GenerateResumeArgsSchema = z.object({
|
||||||
|
user_info: z.string(),
|
||||||
|
user_instructions: z.string().nullish(),
|
||||||
|
parent_report_id: z.number().nullish(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const GenerateResumeResultSchema = z.object({
|
||||||
|
status: z.enum(["ready", "failed"]),
|
||||||
|
report_id: z.number().nullish(),
|
||||||
|
title: z.string().nullish(),
|
||||||
|
content_type: z.string().nullish(),
|
||||||
|
message: z.string().nullish(),
|
||||||
|
error: z.string().nullish(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type GenerateResumeArgs = z.infer<typeof GenerateResumeArgsSchema>;
|
||||||
|
type GenerateResumeResult = z.infer<typeof GenerateResumeResultSchema>;
|
||||||
|
|
||||||
|
function ResumeGeneratingState() {
|
||||||
|
return (
|
||||||
|
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
|
||||||
|
<div className="px-5 pt-5 pb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-sm font-semibold text-foreground">Resume</p>
|
||||||
|
</div>
|
||||||
|
<TextShimmerLoader text="Crafting your resume" size="sm" />
|
||||||
|
</div>
|
||||||
|
<div className="mx-5 h-px bg-border/50" />
|
||||||
|
<div className="px-5 pt-3 pb-4">
|
||||||
|
<div className="h-[7rem] space-y-2">
|
||||||
|
<div className="h-3 w-full rounded bg-muted/60 animate-pulse" />
|
||||||
|
<div className="h-3 w-[92%] rounded bg-muted/60 animate-pulse [animation-delay:100ms]" />
|
||||||
|
<div className="h-3 w-[75%] rounded bg-muted/60 animate-pulse [animation-delay:200ms]" />
|
||||||
|
<div className="h-3 w-[85%] rounded bg-muted/60 animate-pulse [animation-delay:300ms]" />
|
||||||
|
<div className="h-3 w-[60%] rounded bg-muted/60 animate-pulse [animation-delay:400ms]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResumeErrorState({ title, error }: { title: string; error: string }) {
|
||||||
|
return (
|
||||||
|
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
|
||||||
|
<div className="px-5 pt-5 pb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-sm font-semibold text-destructive">Resume Generation Failed</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mx-5 h-px bg-border/50" />
|
||||||
|
<div className="px-5 py-4">
|
||||||
|
{title && title !== "Resume" && (
|
||||||
|
<p className="text-sm font-medium text-foreground line-clamp-2">{title}</p>
|
||||||
|
)}
|
||||||
|
<p className={`text-sm text-muted-foreground${title && title !== "Resume" ? " mt-1" : ""}`}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResumeCancelledState() {
|
||||||
|
return (
|
||||||
|
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
|
||||||
|
<div className="px-5 pt-5 pb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-sm font-semibold text-muted-foreground">Resume Cancelled</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Resume generation was cancelled</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ThumbnailSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="h-[7rem] space-y-2">
|
||||||
|
<div className="h-3 w-full rounded bg-muted/60 animate-pulse" />
|
||||||
|
<div className="h-3 w-[92%] rounded bg-muted/60 animate-pulse [animation-delay:100ms]" />
|
||||||
|
<div className="h-3 w-[75%] rounded bg-muted/60 animate-pulse [animation-delay:200ms]" />
|
||||||
|
<div className="h-3 w-[85%] rounded bg-muted/60 animate-pulse [animation-delay:300ms]" />
|
||||||
|
<div className="h-3 w-[60%] rounded bg-muted/60 animate-pulse [animation-delay:400ms]" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PdfThumbnail({
|
||||||
|
pdfUrl,
|
||||||
|
onLoad,
|
||||||
|
onError,
|
||||||
|
}: {
|
||||||
|
pdfUrl: string;
|
||||||
|
onLoad: () => void;
|
||||||
|
onError: () => void;
|
||||||
|
}) {
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const renderThumbnail = async () => {
|
||||||
|
try {
|
||||||
|
const loadingTask = pdfjsLib.getDocument({
|
||||||
|
url: pdfUrl,
|
||||||
|
httpHeaders: getAuthHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const pdf = await loadingTask.promise;
|
||||||
|
if (cancelled) {
|
||||||
|
pdf.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = await pdf.getPage(1);
|
||||||
|
if (cancelled) {
|
||||||
|
pdf.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) {
|
||||||
|
pdf.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerWidth = wrapperRef.current?.clientWidth || 400;
|
||||||
|
const unscaledViewport = page.getViewport({ scale: 1 });
|
||||||
|
const fitScale = containerWidth / unscaledViewport.width;
|
||||||
|
const viewport = page.getViewport({ scale: fitScale });
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
|
||||||
|
canvas.width = Math.ceil(viewport.width * dpr);
|
||||||
|
canvas.height = Math.ceil(viewport.height * dpr);
|
||||||
|
|
||||||
|
await page.render({
|
||||||
|
canvas,
|
||||||
|
viewport,
|
||||||
|
transform: dpr !== 1 ? [dpr, 0, 0, dpr, 0, 0] : undefined,
|
||||||
|
}).promise;
|
||||||
|
|
||||||
|
if (!cancelled) {
|
||||||
|
setReady(true);
|
||||||
|
onLoad();
|
||||||
|
}
|
||||||
|
|
||||||
|
pdf.destroy();
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) onError();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
renderThumbnail();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [pdfUrl, onLoad, onError]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={wrapperRef}>
|
||||||
|
<canvas ref={canvasRef} className={ready ? "w-full h-auto" : "hidden"} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResumeCard({
|
||||||
|
reportId,
|
||||||
|
title,
|
||||||
|
shareToken,
|
||||||
|
autoOpen = false,
|
||||||
|
}: {
|
||||||
|
reportId: number;
|
||||||
|
title: string;
|
||||||
|
shareToken?: string | null;
|
||||||
|
autoOpen?: boolean;
|
||||||
|
}) {
|
||||||
|
const openPanel = useSetAtom(openReportPanelAtom);
|
||||||
|
const panelState = useAtomValue(reportPanelAtom);
|
||||||
|
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||||
|
const autoOpenedRef = useRef(false);
|
||||||
|
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
||||||
|
const [thumbState, setThumbState] = useState<"loading" | "ready" | "error">("loading");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const previewPath = shareToken
|
||||||
|
? `/api/v1/public/${shareToken}/reports/${reportId}/preview`
|
||||||
|
: `/api/v1/reports/${reportId}/preview`;
|
||||||
|
setPdfUrl(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}${previewPath}`);
|
||||||
|
|
||||||
|
if (autoOpen && isDesktop && !autoOpenedRef.current) {
|
||||||
|
autoOpenedRef.current = true;
|
||||||
|
openPanel({
|
||||||
|
reportId,
|
||||||
|
title,
|
||||||
|
shareToken,
|
||||||
|
contentType: "typst",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [reportId, title, shareToken, autoOpen, isDesktop, openPanel]);
|
||||||
|
|
||||||
|
const onThumbLoad = useCallback(() => setThumbState("ready"), []);
|
||||||
|
const onThumbError = useCallback(() => setThumbState("error"), []);
|
||||||
|
|
||||||
|
const isActive = panelState.isOpen && panelState.reportId === reportId;
|
||||||
|
|
||||||
|
const handleOpen = () => {
|
||||||
|
openPanel({
|
||||||
|
reportId,
|
||||||
|
title,
|
||||||
|
shareToken,
|
||||||
|
contentType: "typst",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-[box-shadow] duration-300 ${isActive ? "ring-1 ring-primary/50" : ""}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleOpen}
|
||||||
|
className="w-full text-left transition-colors hover:bg-muted/50 focus:outline-none focus-visible:outline-none cursor-pointer select-none"
|
||||||
|
>
|
||||||
|
<div className="px-5 pt-5 pb-4">
|
||||||
|
<p className="text-base font-semibold text-foreground line-clamp-2">{title}</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-0.5">PDF</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mx-5 h-px bg-border/50" />
|
||||||
|
|
||||||
|
<div className="px-5 pt-3 pb-4">
|
||||||
|
{thumbState === "loading" && <ThumbnailSkeleton />}
|
||||||
|
{thumbState === "error" && (
|
||||||
|
<p className="text-sm text-muted-foreground">Preview unavailable</p>
|
||||||
|
)}
|
||||||
|
{pdfUrl && (
|
||||||
|
<div
|
||||||
|
className={`max-h-[7rem] overflow-hidden pointer-events-none mix-blend-multiply dark:mix-blend-screen ${thumbState !== "ready" ? "hidden" : ""}`}
|
||||||
|
style={{
|
||||||
|
maskImage: "linear-gradient(to bottom, black 50%, transparent 100%)",
|
||||||
|
WebkitMaskImage: "linear-gradient(to bottom, black 50%, transparent 100%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="dark:invert dark:hue-rotate-180">
|
||||||
|
<PdfThumbnail pdfUrl={pdfUrl} onLoad={onThumbLoad} onError={onThumbError} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GenerateResumeToolUI = ({
|
||||||
|
result,
|
||||||
|
status,
|
||||||
|
}: ToolCallMessagePartProps<GenerateResumeArgs, GenerateResumeResult>) => {
|
||||||
|
const params = useParams();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const isPublicRoute = pathname?.startsWith("/public/");
|
||||||
|
const shareToken = isPublicRoute && typeof params?.token === "string" ? params.token : null;
|
||||||
|
|
||||||
|
const sawRunningRef = useRef(false);
|
||||||
|
if (status.type === "running" || status.type === "requires-action") {
|
||||||
|
sawRunningRef.current = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.type === "running" || status.type === "requires-action") {
|
||||||
|
return <ResumeGeneratingState />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.type === "incomplete") {
|
||||||
|
if (status.reason === "cancelled") {
|
||||||
|
return <ResumeCancelledState />;
|
||||||
|
}
|
||||||
|
if (status.reason === "error") {
|
||||||
|
return (
|
||||||
|
<ResumeErrorState
|
||||||
|
title="Resume"
|
||||||
|
error={typeof status.error === "string" ? status.error : "An error occurred"}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return <ResumeGeneratingState />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status === "failed") {
|
||||||
|
return (
|
||||||
|
<ResumeErrorState
|
||||||
|
title={result.title || "Resume"}
|
||||||
|
error={
|
||||||
|
result.error || "Resume generation failed. Please try again or rephrase your request."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status === "ready" && result.report_id) {
|
||||||
|
return (
|
||||||
|
<ResumeCard
|
||||||
|
reportId={result.report_id}
|
||||||
|
title={result.title || "Resume"}
|
||||||
|
shareToken={shareToken}
|
||||||
|
autoOpen={sawRunningRef.current}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ResumeErrorState title="Resume" error="Missing report ID" />;
|
||||||
|
};
|
||||||
|
|
@ -49,6 +49,8 @@ export const tokenStripeStatusResponse = z.object({
|
||||||
premium_tokens_remaining: z.number().default(0),
|
premium_tokens_remaining: z.number().default(0),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const tokenPurchaseStatusEnum = pagePurchaseStatusEnum;
|
||||||
|
|
||||||
export const tokenPurchase = z.object({
|
export const tokenPurchase = z.object({
|
||||||
id: z.uuid(),
|
id: z.uuid(),
|
||||||
stripe_checkout_session_id: z.string(),
|
stripe_checkout_session_id: z.string(),
|
||||||
|
|
@ -57,7 +59,7 @@ export const tokenPurchase = z.object({
|
||||||
tokens_granted: z.number(),
|
tokens_granted: z.number(),
|
||||||
amount_total: z.number().nullable(),
|
amount_total: z.number().nullable(),
|
||||||
currency: z.string().nullable(),
|
currency: z.string().nullable(),
|
||||||
status: z.string(),
|
status: tokenPurchaseStatusEnum,
|
||||||
completed_at: z.string().nullable(),
|
completed_at: z.string().nullable(),
|
||||||
created_at: z.string(),
|
created_at: z.string(),
|
||||||
});
|
});
|
||||||
|
|
@ -75,5 +77,6 @@ export type GetPagePurchasesResponse = z.infer<typeof getPagePurchasesResponse>;
|
||||||
export type CreateTokenCheckoutSessionRequest = z.infer<typeof createTokenCheckoutSessionRequest>;
|
export type CreateTokenCheckoutSessionRequest = z.infer<typeof createTokenCheckoutSessionRequest>;
|
||||||
export type CreateTokenCheckoutSessionResponse = z.infer<typeof createTokenCheckoutSessionResponse>;
|
export type CreateTokenCheckoutSessionResponse = z.infer<typeof createTokenCheckoutSessionResponse>;
|
||||||
export type TokenStripeStatusResponse = z.infer<typeof tokenStripeStatusResponse>;
|
export type TokenStripeStatusResponse = z.infer<typeof tokenStripeStatusResponse>;
|
||||||
|
export type TokenPurchaseStatus = z.infer<typeof tokenPurchaseStatusEnum>;
|
||||||
export type TokenPurchase = z.infer<typeof tokenPurchase>;
|
export type TokenPurchase = z.infer<typeof tokenPurchase>;
|
||||||
export type GetTokenPurchasesResponse = z.infer<typeof getTokenPurchasesResponse>;
|
export type GetTokenPurchasesResponse = z.infer<typeof getTokenPurchasesResponse>;
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,65 @@
|
||||||
import posthog from "posthog-js";
|
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<string | undefined> {
|
||||||
|
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 {
|
try {
|
||||||
if (!process.env.NEXT_PUBLIC_POSTHOG_KEY) return;
|
if (!process.env.NEXT_PUBLIC_POSTHOG_KEY) return;
|
||||||
|
|
||||||
|
const platform = currentPlatform();
|
||||||
|
const bootstrapDistinctId = await resolveBootstrapDistinctId();
|
||||||
|
|
||||||
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, {
|
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, {
|
||||||
api_host: "https://assets.surfsense.com",
|
api_host: "https://assets.surfsense.com",
|
||||||
ui_host: "https://us.posthog.com",
|
ui_host: "https://us.posthog.com",
|
||||||
defaults: "2026-01-30",
|
defaults: "2026-01-30",
|
||||||
capture_pageview: "history_change",
|
capture_pageview: "history_change",
|
||||||
capture_pageleave: true,
|
capture_pageleave: true,
|
||||||
|
...(bootstrapDistinctId
|
||||||
|
? {
|
||||||
|
bootstrap: {
|
||||||
|
distinctID: bootstrapDistinctId,
|
||||||
|
isIdentifiedID: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
before_send: (event) => {
|
before_send: (event) => {
|
||||||
if (event?.properties) {
|
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 params = new URLSearchParams(window.location.search);
|
||||||
const ref = params.get("ref");
|
const ref = params.get("ref");
|
||||||
|
|
@ -30,9 +77,14 @@ function initPostHog() {
|
||||||
|
|
||||||
event.properties.$set = {
|
event.properties.$set = {
|
||||||
...event.properties.$set,
|
...event.properties.$set,
|
||||||
platform: "web",
|
platform,
|
||||||
last_seen_at: new Date().toISOString(),
|
last_seen_at: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
event.properties.$set_once = {
|
||||||
|
...event.properties.$set_once,
|
||||||
|
first_seen_platform: platform,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return event;
|
return event;
|
||||||
},
|
},
|
||||||
|
|
@ -51,8 +103,12 @@ if (typeof window !== "undefined") {
|
||||||
window.posthog = posthog;
|
window.posthog = posthog;
|
||||||
|
|
||||||
if ("requestIdleCallback" in window) {
|
if ("requestIdleCallback" in window) {
|
||||||
requestIdleCallback(initPostHog);
|
requestIdleCallback(() => {
|
||||||
|
void initPostHog();
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
setTimeout(initPostHog, 3500);
|
setTimeout(() => {
|
||||||
|
void initPostHog();
|
||||||
|
}, 3500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import posthog from "posthog-js";
|
import posthog from "posthog-js";
|
||||||
|
import { getConnectorTelemetryMeta } from "@/components/assistant-ui/connector-popup/constants/connector-constants";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PostHog Analytics Event Definitions
|
* PostHog Analytics Event Definitions
|
||||||
|
|
@ -13,8 +14,8 @@ import posthog from "posthog-js";
|
||||||
* - auth: Authentication events
|
* - auth: Authentication events
|
||||||
* - search_space: Search space management
|
* - search_space: Search space management
|
||||||
* - document: Document management
|
* - document: Document management
|
||||||
* - chat: Chat and messaging
|
* - chat: Chat and messaging (authenticated + anonymous)
|
||||||
* - connector: External connector events
|
* - connector: External connector events (all lifecycle stages)
|
||||||
* - contact: Contact form events
|
* - contact: Contact form events
|
||||||
* - settings: Settings changes
|
* - settings: Settings changes
|
||||||
* - marketing: Marketing/referral tracking
|
* - marketing: Marketing/referral tracking
|
||||||
|
|
@ -28,6 +29,17 @@ function safeCapture(event: string, properties?: Record<string, unknown>) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop undefined values so PostHog doesn't log `"foo": undefined` noise.
|
||||||
|
*/
|
||||||
|
function compact<T extends Record<string, unknown>>(obj: T): Record<string, unknown> {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of Object.entries(obj)) {
|
||||||
|
if (v !== undefined) out[k] = v;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// AUTH EVENTS
|
// 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
|
// 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) {
|
export type ConnectorEventStage =
|
||||||
safeCapture("connector_setup_started", {
|
| "setup_started"
|
||||||
search_space_id: searchSpaceId,
|
| "setup_success"
|
||||||
connector_type: connectorType,
|
| "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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(
|
export function trackConnectorSetupSuccess(
|
||||||
searchSpaceId: number,
|
searchSpaceId: number,
|
||||||
connectorType: string,
|
connectorType: string,
|
||||||
connectorId: number
|
connectorId: number
|
||||||
) {
|
) {
|
||||||
safeCapture("connector_setup_success", {
|
trackConnectorEvent("setup_success", connectorType, { searchSpaceId, connectorId });
|
||||||
search_space_id: searchSpaceId,
|
|
||||||
connector_type: connectorType,
|
|
||||||
connector_id: connectorId,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function trackConnectorSetupFailure(
|
export function trackConnectorSetupFailure(
|
||||||
searchSpaceId: number,
|
searchSpaceId: number | null | undefined,
|
||||||
connectorType: string,
|
connectorType: string,
|
||||||
error?: string
|
error?: string,
|
||||||
|
source?: string
|
||||||
) {
|
) {
|
||||||
safeCapture("connector_setup_failure", {
|
trackConnectorEvent("setup_failure", connectorType, {
|
||||||
search_space_id: searchSpaceId,
|
searchSpaceId: searchSpaceId ?? undefined,
|
||||||
connector_type: connectorType,
|
|
||||||
error,
|
error,
|
||||||
|
source,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -218,11 +303,7 @@ export function trackConnectorDeleted(
|
||||||
connectorType: string,
|
connectorType: string,
|
||||||
connectorId: number
|
connectorId: number
|
||||||
) {
|
) {
|
||||||
safeCapture("connector_deleted", {
|
trackConnectorEvent("deleted", connectorType, { searchSpaceId, connectorId });
|
||||||
search_space_id: searchSpaceId,
|
|
||||||
connector_type: connectorType,
|
|
||||||
connector_id: connectorId,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function trackConnectorSynced(
|
export function trackConnectorSynced(
|
||||||
|
|
@ -230,11 +311,7 @@ export function trackConnectorSynced(
|
||||||
connectorType: string,
|
connectorType: string,
|
||||||
connectorId: number
|
connectorId: number
|
||||||
) {
|
) {
|
||||||
safeCapture("connector_synced", {
|
trackConnectorEvent("synced", connectorType, { searchSpaceId, connectorId });
|
||||||
search_space_id: searchSpaceId,
|
|
||||||
connector_type: connectorType,
|
|
||||||
connector_id: connectorId,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
@ -345,10 +422,9 @@ export function trackConnectorConnected(
|
||||||
connectorType: string,
|
connectorType: string,
|
||||||
connectorId?: number
|
connectorId?: number
|
||||||
) {
|
) {
|
||||||
safeCapture("connector_connected", {
|
trackConnectorEvent("connected", connectorType, {
|
||||||
search_space_id: searchSpaceId,
|
searchSpaceId,
|
||||||
connector_type: connectorType,
|
connectorId: connectorId ?? undefined,
|
||||||
connector_id: connectorId,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -467,8 +543,13 @@ export function trackReferralLanding(refCode: string, landingUrl: string) {
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Identify a user for PostHog analytics
|
* Identify a user for PostHog analytics.
|
||||||
* Call this after successful authentication
|
* 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<string, unknown>) {
|
export function identifyUser(userId: string, properties?: Record<string, unknown>) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -476,10 +557,19 @@ export function identifyUser(userId: string, properties?: Record<string, unknown
|
||||||
} catch {
|
} catch {
|
||||||
// Silently ignore – ad-blockers may break posthog
|
// Silently ignore – ad-blockers may break posthog
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (typeof window !== "undefined" && window.electronAPI?.analyticsIdentify) {
|
||||||
|
void window.electronAPI.analyticsIdentify(userId, properties);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// IPC errors must never break the app
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reset user identity (call on logout)
|
* Reset user identity (call on logout). Mirrors the reset into the
|
||||||
|
* Electron main process when running inside the desktop app.
|
||||||
*/
|
*/
|
||||||
export function resetUser() {
|
export function resetUser() {
|
||||||
try {
|
try {
|
||||||
|
|
@ -487,4 +577,12 @@ export function resetUser() {
|
||||||
} catch {
|
} catch {
|
||||||
// Silently ignore – ad-blockers may break posthog
|
// Silently ignore – ad-blockers may break posthog
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (typeof window !== "undefined" && window.electronAPI?.analyticsReset) {
|
||||||
|
void window.electronAPI.analyticsReset();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// IPC errors must never break the app
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,7 @@
|
||||||
"next": "^16.1.0",
|
"next": "^16.1.0",
|
||||||
"next-intl": "^4.6.1",
|
"next-intl": "^4.6.1",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"pdfjs-dist": "^5.6.205",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"platejs": "^52.0.17",
|
"platejs": "^52.0.17",
|
||||||
"postgres": "^3.4.7",
|
"postgres": "^3.4.7",
|
||||||
|
|
|
||||||
136
surfsense_web/pnpm-lock.yaml
generated
136
surfsense_web/pnpm-lock.yaml
generated
|
|
@ -275,6 +275,9 @@ importers:
|
||||||
next-themes:
|
next-themes:
|
||||||
specifier: ^0.4.6
|
specifier: ^0.4.6
|
||||||
version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
|
pdfjs-dist:
|
||||||
|
specifier: ^5.6.205
|
||||||
|
version: 5.6.205
|
||||||
pg:
|
pg:
|
||||||
specifier: ^8.16.3
|
specifier: ^8.16.3
|
||||||
version: 8.18.0
|
version: 8.18.0
|
||||||
|
|
@ -1981,6 +1984,76 @@ packages:
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
mediabunny: ^1.0.0
|
mediabunny: ^1.0.0
|
||||||
|
|
||||||
|
'@napi-rs/canvas-android-arm64@0.1.97':
|
||||||
|
resolution: {integrity: sha512-V1c/WVw+NzH8vk7ZK/O8/nyBSCQimU8sfMsB/9qeSvdkGKNU7+mxy/bIF0gTgeBFmHpj30S4E9WHMSrxXGQuVQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@napi-rs/canvas-darwin-arm64@0.1.97':
|
||||||
|
resolution: {integrity: sha512-ok+SCEF4YejcxuJ9Rm+WWunHHpf2HmiPxfz6z1a/NFQECGXtsY7A4B8XocK1LmT1D7P174MzwPF9Wy3AUAwEPw==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@napi-rs/canvas-darwin-x64@0.1.97':
|
||||||
|
resolution: {integrity: sha512-PUP6e6/UGlclUvAQNnuXCcnkpdUou6VYZfQOQxExLp86epOylmiwLkqXIvpFmjoTEDmPmXrI+coL/9EFU1gKPA==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.97':
|
||||||
|
resolution: {integrity: sha512-XyXH2L/cic8eTNtbrXCcvqHtMX/nEOxN18+7rMrAM2XtLYC/EB5s0wnO1FsLMWmK+04ZSLN9FBGipo7kpIkcOw==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-arm64-gnu@0.1.97':
|
||||||
|
resolution: {integrity: sha512-Kuq/M3djq0K8ktgz6nPlK7Ne5d4uWeDxPpyKWOjWDK2RIOhHVtLtyLiJw2fuldw7Vn4mhw05EZXCEr4Q76rs9w==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-arm64-musl@0.1.97':
|
||||||
|
resolution: {integrity: sha512-kKmSkQVnWeqg7qdsiXvYxKhAFuHz3tkBjW/zyQv5YKUPhotpaVhpBGv5LqCngzyuRV85SXoe+OFj+Tv0a0QXkQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-riscv64-gnu@0.1.97':
|
||||||
|
resolution: {integrity: sha512-Jc7I3A51jnEOIAXeLsN/M/+Z28LUeakcsXs07FLq9prXc0eYOtVwsDEv913Gr+06IRo34gJJVgT0TXvmz+N2VA==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-x64-gnu@0.1.97':
|
||||||
|
resolution: {integrity: sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-x64-musl@0.1.97':
|
||||||
|
resolution: {integrity: sha512-AKLFd/v0Z5fvgqBDqhvqtAdx+fHMJ5t9JcUNKq4FIZ5WH+iegGm8HPdj00NFlCSnm83Fp3Ln8I2f7uq1aIiWaA==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@napi-rs/canvas-win32-arm64-msvc@0.1.97':
|
||||||
|
resolution: {integrity: sha512-u883Yr6A6fO7Vpsy9YE4FVCIxzzo5sO+7pIUjjoDLjS3vQaNMkVzx5bdIpEL+ob+gU88WDK4VcxYMZ6nmnoX9A==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@napi-rs/canvas-win32-x64-msvc@0.1.97':
|
||||||
|
resolution: {integrity: sha512-sWtD2EE3fV0IzN+iiQUqr/Q1SwqWhs2O1FKItFlxtdDkikpEj5g7DKQpY3x55H/MAOnL8iomnlk3mcEeGiUMoQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@napi-rs/canvas@0.1.97':
|
||||||
|
resolution: {integrity: sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
|
||||||
'@napi-rs/wasm-runtime@0.2.12':
|
'@napi-rs/wasm-runtime@0.2.12':
|
||||||
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
|
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
|
||||||
|
|
||||||
|
|
@ -7027,6 +7100,9 @@ packages:
|
||||||
encoding:
|
encoding:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
node-readable-to-web-readable-stream@0.4.2:
|
||||||
|
resolution: {integrity: sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==}
|
||||||
|
|
||||||
node-releases@2.0.27:
|
node-releases@2.0.27:
|
||||||
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
||||||
|
|
||||||
|
|
@ -7168,6 +7244,10 @@ packages:
|
||||||
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
|
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
|
||||||
engines: {node: '>=8'}
|
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:
|
performance-now@2.1.0:
|
||||||
resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==}
|
resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==}
|
||||||
|
|
||||||
|
|
@ -9992,6 +10072,54 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
mediabunny: 1.39.2
|
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':
|
'@napi-rs/wasm-runtime@0.2.12':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emnapi/core': 1.8.1
|
'@emnapi/core': 1.8.1
|
||||||
|
|
@ -15830,6 +15958,9 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
whatwg-url: 5.0.0
|
whatwg-url: 5.0.0
|
||||||
|
|
||||||
|
node-readable-to-web-readable-stream@0.4.2:
|
||||||
|
optional: true
|
||||||
|
|
||||||
node-releases@2.0.27: {}
|
node-releases@2.0.27: {}
|
||||||
|
|
||||||
npm-run-path@4.0.1:
|
npm-run-path@4.0.1:
|
||||||
|
|
@ -15992,6 +16123,11 @@ snapshots:
|
||||||
|
|
||||||
path-type@4.0.0: {}
|
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: {}
|
performance-now@2.1.0: {}
|
||||||
|
|
||||||
pg-cloudflare@1.3.0:
|
pg-cloudflare@1.3.0:
|
||||||
|
|
|
||||||
20
surfsense_web/types/window.d.ts
vendored
20
surfsense_web/types/window.d.ts
vendored
|
|
@ -102,9 +102,29 @@ interface ElectronAPI {
|
||||||
setShortcuts: (
|
setShortcuts: (
|
||||||
config: Partial<{ generalAssist: string; quickAsk: string; autocomplete: string }>
|
config: Partial<{ generalAssist: string; quickAsk: string; autocomplete: string }>
|
||||||
) => Promise<{ 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
|
// Active search space
|
||||||
getActiveSearchSpace: () => Promise<string | null>;
|
getActiveSearchSpace: () => Promise<string | null>;
|
||||||
setActiveSearchSpace: (id: string) => Promise<void>;
|
setActiveSearchSpace: (id: string) => Promise<void>;
|
||||||
|
// Analytics bridge (PostHog mirror into the Electron main process)
|
||||||
|
analyticsIdentify: (userId: string, properties?: Record<string, unknown>) => Promise<void>;
|
||||||
|
analyticsReset: () => Promise<void>;
|
||||||
|
analyticsCapture: (event: string, properties?: Record<string, unknown>) => Promise<void>;
|
||||||
|
getAnalyticsContext: () => Promise<{
|
||||||
|
distinctId: string;
|
||||||
|
machineId: string;
|
||||||
|
appVersion: string;
|
||||||
|
platform: string;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue