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%
13.1 KB

# D — Projects, prompt library, context/file library (2026-09-11)

Workstream D of the PolyLLM 1.0 upgrade. Everything below compiles (pnpm typecheck / pnpm lint clean for the D files; remaining typecheck errors at hand-off time are in A/B/C/E/F files still being edited) and pnpm test passes (tests/unit/prompt-variables.test.ts added, 7 cases).

# Files

# Services (server)

  • src/lib/projects/service.ts — listProjects, getProject, getProjectDetail, createProject, updateProject, deleteProject (keeps conversations: project_id → null; files cascade; prompts detached), moveConversations, listUnassignedConversations, toPublicProject (PublicProject, ProjectDetail types).
  • src/lib/projects/schemas.ts — zod projectBodySchema (POST; PATCH = .partial()), projectActionSchema.
  • src/lib/library/service.ts — listFiles, getFile, saveFile (kinds/limits mirror /api/attachments: 10 MB, 2 MB text; PNG/JPEG dimensions; estimatedTokens via estimateAttachmentTokens; 500 files/user cap), updateFile (rename / description / move to project), deleteFile, attachFiles (copies into message_attachments with messageId/conversationId null — exactly what POST /api/attachments produces; the chat service links them on send), toPublicFile (PublicProjectFile, PendingAttachment).
  • src/lib/prompts/variables.ts — pure, client-safe: parseVariables, mergeVariables, renderTemplate, labelFor, variablesToAsk, VARIABLE_RE ({{name}}, whitespace tolerated, names [A-Za-z_][\w.-]*; JSON braces untouched).
  • src/lib/prompts/service.ts — listPrompts (search over name/description/content/tags, kind/folder/tag/favorite/project filters, returns folders + tags facets), getPrompt, createPrompt, updatePrompt (variables re-merged with content), deletePrompt, usePrompt (uses++, lastUsedAt, renders), PROMPT_KINDS, toPublicPrompt (PublicPrompt, PromptKind, UsePromptResult).
  • src/lib/prompts/schemas.ts — zod promptBodySchema, promptVariableSchema, promptUseSchema.
  • src/lib/conversations/service.ts — additive: projectId in ListFilter ("none" supported), createConversation, updateConversation, and PublicConversation (now an explicit interface; projectId?: string | null is optional so A's optimistic conversation object stays assignable — A may set projectId: activeProjectId there).
  • src/lib/client/types.ts — appended re-exports: PublicProject, ProjectDetail, PublicProjectFile, PendingAttachment, PublicPrompt, PromptKind, UsePromptResult, PromptVariable.
  • Ids: prj_… (projects), pfl_… (library files) via newId(); prompts reuse ids.prompt() (prm_…, distinct table from prompt_presets).

# Routes

  • src/app/api/projects/route.ts, src/app/api/projects/[id]/route.ts
  • src/app/api/library/files/route.ts, src/app/api/library/files/[id]/route.ts, src/app/api/library/files/attach/route.ts
  • src/app/api/prompts/route.ts, src/app/api/prompts/[id]/route.ts, src/app/api/prompts/[id]/use/route.ts
  • src/app/api/conversations/route.ts (?projectId=, projectId in create body), src/app/api/conversations/[id]/route.ts (projectId in PATCH).

# UI

  • Pages: src/app/app/projects/page.tsx, src/app/app/projects/[id]/page.tsx, src/app/app/library/page.tsx, src/app/app/prompts/page.tsx (replaced).
  • src/components/projects/: page-header.tsx (WorkspacePageHeader 48 px bar with hamburger/back + WorkspacePageBody), project-icon.tsx, project-form-sheet.tsx (create/edit: name, emoji, colour, description), projects-view.tsx (grid, Active/Archived, search, card menu, ?new=1), project-detail.tsx (tabs Chats / Files / Prompts / Instructions & models / Notes, stats, ?tab=), project-conversations.tsx (rows, remove, "Add existing chats" picker), project-setup.tsx (instructions, preferred models via ModelSelector multiple, default settings via ParametersForm, sticky save bar), project-notes.tsx (markdown write/preview, ⌘S), project-switcher.tsx (ActionSheet on phone / dropdown on desktop), sidebar-section.tsx (finished: count, active dot + "Active", switcher button, overflow link, skeleton), row-menu.tsx (shared ⋯ menu: ActionSheet + long-press on phone, hover dropdown on desktop).
  • src/components/library/: file-library-picker.tsx (finished: search, scope chips, multi-select ≤ 10, token total, inline upload), file-list.tsx (FileList, FileThumb, FileKindIcon, preview sheet, move/describe/delete), library-view.tsx (filters by project & kind, drag-and-drop upload on desktop), upload-button.tsx (UploadButton, uploadLibraryFiles, reportUpload), use-in-chat.ts (files → new chat hand-off), format.ts.
  • src/components/prompts/: prompt-library.tsx (search, kind Segmented, favourites/folders/tags chips, ?new=1/?edit=/?content=&kind= deep links, legacy section), prompt-card.tsx (card + compact row), prompt-editor-sheet.tsx (live variable detection with label/default/options/required, JSON schema for structured, folder datalist, tag chips, default model, project), prompt-use-sheet.tsx (fill-in + preview + insertPromptIntoChat), legacy-presets.tsx (lists prompt_presets, Import / Import all / legacy Use / Delete), prompt-kind.tsx, insert.ts (event contract, see below).
  • tests/unit/prompt-variables.test.ts.

# API

All routes: withUser (401 otherwise), errors as { error: { code, message } }.

Method Path Body / query → response
GET /api/projects?archived=1 → { projects: PublicProject[] } (non-archived by default; each has conversationCount, fileCount, promptCount)
POST /api/projects { name, description?, icon?, color?, instructions?, preferredModelKeys?, defaultSettings?, notes?, archived?, sortOrder? } → 201 { project }
GET /api/projects/:id → { project, conversations: PublicConversation[], files: PublicProjectFile[], prompts: PublicPrompt[], stats: { conversations, messages, files, prompts, totalCostUsd, totalInputTokens, totalOutputTokens, fileBytes, fileTokens, lastActivityAt } }; ?unassigned=1 → { conversations } outside any project
PATCH /api/projects/:id any subset of the create body (defaultSettings validated by generationSettingsSchema) → { project }
POST /api/projects/:id { action: "add-conversations" | "remove-conversations", conversationIds } → { moved }
DELETE /api/projects/:id conversations kept (project_id null), files cascade, prompts detached → { ok }
GET /api/library/files?projectId=<id|none>&q=&kind=&limit= → { files: PublicProjectFile[] } (metadata only)
POST /api/library/files multipart file, projectId?, description? → 201 { file } (415 unsupported, 413 > 10 MB / text > 2 MB, 409 library full)
DELETE /api/library/files?id= → { ok }
GET /api/library/files/:id streams the payload (inline; ?download=1 → attachment; ?meta=1 → { file })
PATCH /api/library/files/:id { name?, description?, projectId? } (projectId: null = global) → { file }
DELETE /api/library/files/:id → { ok }
POST /api/library/files/attach { fileIds: string[] (1–10) } → 201 { attachments: [{ id, kind, name, mimeType, sizeBytes, width, height }] } — pending message_attachments rows; pass the ids in message.attachmentIds
GET /api/prompts?q=&kind=&folder=<name|none>&tag=&favorite=1&projectId=<id|none>&limit= → { prompts, folders: string[], tags: string[], total } (command palette: ?q=)
POST /api/prompts { kind?, name, content, description?, variables?, schema?, folder?, tags?, favorite?, defaultModelKey?, projectId? } → 201 { prompt } (variables auto-merged with {{…}} in content)
GET / PATCH / DELETE /api/prompts/:id → { prompt } / { prompt } / { ok }
POST /api/prompts/:id/use { variables? } → { prompt, kind, rendered, text, systemPrompt, schema, defaultModelKey, missing, defaulted } (uses++, lastUsedAt)
GET /api/conversations?projectId=<id|none> additive filter
POST / PATCH /api/conversations, /api/conversations/:id projectId: string | null accepted

# Prompt → composer contract (src/components/prompts/insert.ts) — for workstream A

  1. Library calls POST /api/prompts/:id/use { variables }, then dispatchPromptInsert(detail):
    • window.dispatchEvent(new CustomEvent("polyllm:insert-prompt", { detail })) (PROMPT_INSERT_EVENT), and
    • sessionStorage["polyllm:pending-prompt-insert"] = same JSON (PENDING_PROMPT_KEY), for a chat view that mounts later;
    • then router.push("/app/chat?promptInsert=<id>&vars=<base64url JSON>") (promptChatHref).
  2. PromptInsertDetail = { promptId, name, kind, text, systemPrompt, schema, defaultModelKey, variables, at }.
  3. Chat view should:
    • usePromptInsert(handler) for live inserts while mounted;
    • on mount of a new chat: consumePendingPromptInsert() (fast path, returns null if absent/older than 5 min); if only URL params are present (shared link, reload): POST /api/prompts/<promptInsert>/use with decodeVars(vars) and apply the result the same way;
    • apply: kind === "system" → set the conversation system prompt to systemPrompt; user/template → insert text into the draft; structured → insert text and, if schema, request responseFormat: { type: "json_schema", schema }; defaultModelKey may switch the model when usable.
    • Legacy ?prompt=<prompt_presets id> keeps its current behaviour (unchanged); optionally fall back to GET /api/prompts/:id when the id is not a legacy preset.
  4. "Save as prompt" from a message: navigate to /app/prompts?new=1&kind=<system|user>&content=<encoded> — the library opens the editor prefilled.

# Library files → composer contract (src/components/library/use-in-chat.ts) — for workstream A

prepareFilesForNewChat(fileIds) → POST attach, stashes { at, attachments } in sessionStorage["polyllm:pending-attachments"] (PENDING_ATTACHMENTS_KEY) and returns /app/chat?attachments=<id,id> (plus &project=<id> when launched from a project). Chat view: on mount of a new chat read ?attachments= and consumePendingAttachments() to seed composer chips; the ids go straight into message.attachmentIds. FileLibraryPicker (already imported by the composer) keeps its { open, onOpenChange, onPick(PickedAttachment[]), projectId? } API.

Project context for chat (already planned in A): with activeProjectId set, fetch /api/projects/:id → project.instructions (system prompt), project.preferredModelKeys[0] (default model), project.defaultSettings; create conversations with projectId: activeProjectId (POST /api/conversations accepts it; the chat service path that auto-creates a conversation should also set projectId — TODO(integration: chat) one field in lib/chat/service.ts).

# Coming soon / not done

  • No drag-to-reorder of preferred models (there is a "Make default" action instead) and no project sortOrder UI (API supports it).
  • Command palette entries (Open project, Search prompts) are G's; GET /api/prompts?q= and GET /api/projects are ready.
  • Text preview in the library caps at 40 KB; PDFs open in a new tab (no inline viewer).
  • Legacy presets can be imported/used/deleted but not edited on the new page (import, then edit in the library).

# QA checklist (visual, integration phase)

  • 375/390/430 px: /app/projects header 48 px with hamburger; cards stack 1-col; long-press a card → ActionSheet; "+" in sidebar opens the create sheet as a bottom sheet (emoji grid, colour radios ≥ 40 px).
  • /app/projects/:id: back chevron; Segmented tabs scroll horizontally without clipping; Chats tab rows 56 px, long-press → Remove; "Add existing" sheet full-height with search; Files tab upload (button) + thumbnails; Setup tab: ModelSelector opens with multi-select, list with Default badge, sticky "Save setup" bar appears only when dirty; Notes: Write/Preview segmented on phone, side-by-side ≥ lg.
  • Sidebar/drawer: active project shows dot + "Active"; ⇄ opens ActionSheet (phone) / dropdown (desktop); "+N more…" when > 8.
  • /app/library: chips row scrolls; kind chips appear when > 3 files; row ⋯ → Preview (image/text sheet), Download, Move, Description (PromptDialog), Delete (ConfirmDialog); desktop drag-and-drop overlay; upload errors toast per file (unsupported type, > 10 MB).
  • Composer "From library": picker shows scope chips when a project is active, ≤ 10 selection, token total in footer, inline Upload; attached chips appear in composer (A).
  • /app/prompts: editor sheet full-height on phone; typing {{x}} adds a variable row live; structured kind shows schema textarea with JSON validation; Use on a prompt with variables → fill-in sheet with preview → lands on /app/chat?promptInsert=… with the text inserted (A); Use without variables inserts directly; legacy presets section lists old prompts, Import creates a system prompt in folder "Imported presets".
  • No window.prompt/confirm anywhere; every icon button has an aria-label; all new inputs are ≥ 15 px on phones.