SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
13 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
14.2 KB

# G — Search, command palette, share & export

Workstream G of the 2026-09-11 upgrade (docs/UPGRADE-PLAN.md). Everything below compiles (pnpm typecheck), lints (eslint on the files listed) and is covered by vitest (tests/unit/search-query.test.ts, tests/unit/export-html.test.ts, tests/integration/search-share.test.ts — the last one runs against the local Postgres when DATABASE_URL is reachable).

# Files

Area File What
Query language src/lib/search/query.ts Pure parseQuery(), addFilter/removeFilter/stripFilters, highlightSegments, makeSnippet, stripMarkdown, prefixTsQuery, escapeLike, parseDateToken (unit-tested)
Search service src/lib/search/service.ts searchAll(userId, raw, { limit, cursor, groups }) — Postgres FTS (to_tsvector('simple') + websearch_to_tsquery + prefix to_tsquery) with ILIKE fallback, filter resolution, opaque cursor pagination, grouped results
API src/app/api/search/route.ts GET /api/search (rewritten, same URL as before, superset response)
Migration drizzle/0003_search_index.sql + drizzle/meta/_journal.json (idx 3) GIN expression indexes on messages.content and conversations.title, partial index on active shares. Applied locally with pnpm db:migrate.
Conversations service (additive) src/lib/conversations/service.ts exportConversation(format: json|markdown|txt|html, { print, appUrl }), shareConversation(userId, id, { messageIds }), listShares, revokeShareById, readShareMeta, getPublicShare(id, { peek }), EXPORT_FORMATS, types ExportFormat, ShareLinkItem, ShareSnapshotMeta
Export renderer src/lib/export/markdown-html.ts, src/lib/export/html-document.ts Dependency-free, escaped Markdown → HTML (GFM subset) and the self-contained print document (inline CSS, brand mark, page-break rules)
Actions route (additive) src/app/api/conversations/[id]/actions/route.ts export accepts the 4 formats; share accepts { messageIds? }; new list-shares; unshare accepts { shareId? }; share-status also returns shares
New routes src/app/api/conversations/[id]/export/route.ts, src/app/api/shares/route.ts See API section
Palette src/components/app/command-palette.tsx Universal ⌘K palette (root commands + sub-lists + search mode), ShortcutsSheet, mounts SearchSheet and ShareSheetHost
Search UI src/components/search/search-sheet.tsx, use-search.ts, hits.tsx, filter-chips.tsx, highlight.tsx Phone full-screen sheet, SWR hook with pagination + recent searches, hit rows + navigation, filter chips with picker sheets, <Highlight />
Share UI src/components/share/share-sheet.tsx ShareSheet, useShareSheet(), openShareSheet(), ShareSheetHost, ShareLinksList
Export UI src/components/share/export.ts, src/components/share/export-menu.tsx exportConversation(id, format) client helper, EXPORT_OPTIONS, ExportMenu (dropdown / ActionSheet), EXPORT_ICONS
Share page src/app/share/[id]/page.tsx Mobile-first redesign, excerpt badge, view count, noindex, per-share OG/Twitter title
Types (append-only) src/lib/client/types.ts Re-exports SearchResponse, hit types, ShareLinkItem, ExportFormat, ParsedQuery, FilterToken, FilterKey
Tests tests/unit/search-query.test.ts, tests/unit/export-html.test.ts, tests/integration/search-share.test.ts 18 + 11 unit, 6 integration

src/lib/export/* is a new directory not claimed by any workstream (used only by the conversations service).

# Query language

text
free text  "exact phrase"  model:claude  provider:openai  project:research  project:"Q3 Research"
folder:clients  after:2026-08-01  after:2026-08  after:7d  after:today  after:yesterday  after:month
before:2026-09-01  role:user|assistant  is:pinned  is:archived  is:shared
  • model: substring of modelKey or of a registry display name (resolved server-side); provider: accepts ids and aliases (google→gemini, claude→anthropic, grok→xai…); project:/folder: match id or name substring; a filter that matches nothing returns an empty result (not "everything").
  • after: is inclusive (UTC midnight), before: exclusive; after:2026-08 = whole month; relative Nd|Nw|Nm|Ny.
  • role: applies to messages and hides the Conversations group. is:archived is the only way to see archived chats? No — archived conversations are searchable by default; is:archived restricts to them.
  • Unknown key:value tokens (URLs, 10:30) and invalid values (role:pirate) are kept as free text.
  • Words ≥ 2 chars or any filter make a query searchable. websearch_to_tsquery('simple') handles phrases and -neg; a prefix query ('quot':*) is OR-ed so the last half-typed word matches; if FTS returns nothing on the first page an ILIKE fallback (all words AND-ed) catches fragments inside words (configur → "Reconfiguring").

# API

# GET /api/search?q=&limit=12&cursor=&groups=

Auth: user. Rate limit LIMITS.search. groups = comma list of conversations,messages,models,prompts,projects,presets (default all; secondary groups only on the first page).

ts
interface SearchResponse {
  query: { raw; text; terms: string[]; tokens: FilterToken[]; hasFilters };
  conversations: { id; title; modelKey; provider; projectId; folderId; pinned; archived; shared; messageCount; updatedAt; lastMessageAt }[];
  messages: { id; conversationId; title; role; modelKey; createdAt; snippet }[];   // snippet = plain text, highlight with query.terms
  models: { key; displayName; provider; status }[];
  prompts: { id; name; description; kind: "library" | "legacy" }[];               // library = prompts table (D), legacy = prompt_presets
  projects: { id; name; description; icon; color }[];
  presets: { id; name; description; modelKey }[];
  nextCursor: string | null;   // opaque; pass back as ?cursor= (pages conversations + messages only)
  engine: "fts" | "ilike" | "none";
  tookMs: number;
}

Prompts are read from the prompts table directly (no internal HTTP to /api/prompts), so nothing 404s if D is not wired.

# POST /api/conversations/[id]/actions (existing, extended)

  • { action: "share", messageIds?: string[] } → { id, path: "/share/<id>", partial, messageCount, created }. Without messageIds the conversation's single "entire conversation" link is refreshed in place (same URL); with messageIds a new excerpt link is created (active messages only, conversation order).
  • { action: "unshare", shareId?: string } → { ok } (all links of the conversation, or one).
  • { action: "share-status" } → { share: { id, createdAt, viewCount } | null, shares: ShareLinkItem[] }.
  • { action: "list-shares" } → { shares: ShareLinkItem[] } (this conversation).
  • { action: "export", format: "json" | "markdown" | "txt" | "html" } → file (kept for the sidebar).

# GET /api/conversations/[id]/export?format=json|markdown|txt|html[&download=1][&print=1]

File download (Content-Disposition: attachment) for everything except html without download=1, which renders inline; print=1 embeds a window.print() on load (used by "PDF (print)"). Cache-Control: private, no-store, X-Robots-Tag: noindex. Rate limit 40/min.

# GET /api/shares → { shares: ShareLinkItem[] } · DELETE /api/shares?id=<shareId> → { ok }

ts
interface ShareLinkItem { id; conversationId; title; createdAt; viewCount; messageCount; partial; path: "/share/<id>" }

# Share snapshot v2

shared_conversations.snapshot now starts with { $meta: true, version: 2, partial, selectedCount, totalCount, generatedAt } followed by the messages. Renderers skip elements without role; readShareMeta(snapshot) reads it. Old snapshots (no meta) keep working. getPublicShare(id, { peek: true }) reads without counting a view (used by generateMetadata).

# Contracts for other workstreams

# A — chat header / composer

  • Share button → const share = useShareSheet(); share.open({ conversationId, title, messages }) (from @/components/share/share-sheet). messages (the active PublicMessage[] already in memory) avoids a refetch; messageIds preselects "Selected messages" (use it from the message long-press "Share from here…"). The host is already mounted by CommandPalette — nothing else to mount.
  • Export → <ExportMenu conversationId={id} title={title} /> from @/components/share/export-menu (icon button by default; pass your own trigger as children). Programmatic: exportConversation(id, "pdf") from @/components/share/export.
  • Events the palette dispatches on window:
    • polyllm:open-attach (CustomEvent, no detail) — "Upload file" command. The composer should open its attachment sheet (phone) or the file picker (desktop). When the user is not on a chat page the palette navigates to /app/chat first and dispatches ~450 ms later.
    • polyllm:switch-model (detail: { modelKey: string }, may be AUTO_MODEL_KEY) — "Switch model" and model search hits. The store's selectedModelKey is already set; the chat view should apply it to the current conversation (changeModel(detail.modelKey)) if one is open.
  • Deep links /app/chat/<id>#<messageId>: chat-view.tsx already scrolls to #hash on mount; when the same conversation is open the search UI calls scrollIntoView itself. Keep id={m.id} on message wrappers.

# E — Settings → Data

Mount <ShareLinksList /> from @/components/share/share-sheet in a card titled "Active share links" (it renders its own empty state, copy/revoke actions and a ConfirmDialog). It reads GET /api/shares.

# D — prompts / projects

Search links library prompts to /app/chat?promptId=<id> (your insertion contract) and legacy presets to /app/prompts?edit=<id>; projects to /app/projects/<id>. The palette's "Open project" reads GET /api/projects (tolerates errors) and sets activeProjectId before navigating. Filter chips read project names from the same route.

# Shell (integrator)

"Toggle sidebar" on desktop dispatches a synthetic ⌘B keydown because the collapsed state lives in shell.tsx (// TODO(integration: shell)): exposing toggleSidebarCollapsed in the store would make it explicit. On phones the command opens the drawer (setSidebarOpen(true)). store.searchOpen on ≥ md is consumed by the palette (opens in search mode and resets the flag) so setSearchOpen(true) works from anywhere on any breakpoint.

# Palette behaviour (desktop keyboard-first, phone bottom sheet)

  • Root: typing filters commands by label/keywords; ≥ 2 characters also shows live search results under the commands; a leading / is pure search mode. ⌫ on an empty input returns to the root, Esc closes.
  • Commands: New chat, New temporary chat (/app/chat?temporary=1), Search conversations, Switch model (AUTO + connected models, labels, favorites first, current ✓), Upload file, Open project, Go to Chat / Arena / Models / Usage / Providers / Projects / Library / Prompts / Model presets / Settings, Toggle theme (light/dark/system ✓), Toggle/Open sidebar, Keyboard shortcuts (sheet). On a chat page: Copy conversation URL, Share conversation, Export conversation (sub-list).
  • Recent searches (localStorage polyllm:recent-searches, 8 max) and syntax examples appear in empty search mode.

# Migration notes

  • drizzle/0003_search_index.sql is hand-written (expression indexes cannot be declared from schema-search.ts on tables defined in schema.ts); numbering follows E's 0002_endpoints. No snapshot file is needed (the indexes are not part of the drizzle schema, so drizzle-kit generate will neither re-create nor drop them; drizzle-kit push is not used). Index expressions must stay identical to msgTsv/titleTsv in src/lib/search/service.ts.
  • Plain CREATE INDEX IF NOT EXISTS (the migrator runs in a transaction; CONCURRENTLY is not possible). On prod the messages table is small; expect sub-second creation via the mld hook.
  • Verified with EXPLAIN: Bitmap Index Scan on messages_content_fts_idx for both the websearch and the prefix query.

# Not done / Coming soon

  • Nothing is labelled "Coming soon". Desktop filter chips are shown inside the palette's search mode (compact); the pickers are the same sheets as on phones.
  • Tags (tag:) are not a filter — conversation_tags has no UI yet.

# QA checklist (integration phase, 375/390/393/430 + 1440)

  1. Phone: sidebar search icon → full-screen sheet, keyboard opens, 16 px input, no zoom; chips scroll horizontally; Model/Project/Date/Role/Status pickers open as half sheets and append tokens; token chips remove on ×.
  2. Type quotas (or any word from a real chat): grouped results, <mark> highlights, tap a message → chat opens and scrolls to the message; recent search saved; "Load more" appears when > 10 message hits.
  3. Desktop ⌘K: ↑↓↵ across commands and results, / search mode, ⌫ back, Esc close; palette sits at 14 % from the top, max 72 vh; footer hints. Hybrid: typing "arena" shows the command and matching chats.
  4. Switch model from the palette on an open chat → chat header model changes (needs A's polyllm:switch-model listener). Upload file → composer attach opens (needs A's polyllm:open-attach listener).
  5. Share sheet (chat header / palette): warning visible, Entire vs Selected (checkbox list with role + snippet, All/None), Create → link copied + toast, Copy/Open/Share… (native share on phones), Active links list, revoke via ConfirmDialog. Sharing the whole conversation twice keeps the same URL; a selection creates a second link.
  6. /share/<id>: renders on 375 px without horizontal overflow, excerpt badge for partial shares, view count increments once per visit, <meta name="robots" content="noindex">, OG title = conversation title; revoked link → 404.
  7. Export: Markdown/TXT/JSON/HTML download with the right filename; "PDF (print)" opens a new tab with the print dialog (Safari/Chrome/iOS Safari); blocked pop-up → HTML download + warning toast.
  8. Settings → Data: ShareLinksList rows stack correctly on phones, revoke works, empty state text.
  9. Old sidebar actions (Copy share link, Export Markdown/JSON) still work through the actions route.