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,ProjectDetailtypes).src/lib/projects/schemas.ts— zodprojectBodySchema(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;estimatedTokensviaestimateAttachmentTokens; 500 files/user cap),updateFile(rename / description / move to project),deleteFile,attachFiles(copies intomessage_attachmentswithmessageId/conversationIdnull — exactly what POST/api/attachmentsproduces; 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, returnsfolders+tagsfacets),getPrompt,createPrompt,updatePrompt(variables re-merged with content),deletePrompt,usePrompt(uses++,lastUsedAt, renders),PROMPT_KINDS,toPublicPrompt(PublicPrompt,PromptKind,UsePromptResult).src/lib/prompts/schemas.ts— zodpromptBodySchema,promptVariableSchema,promptUseSchema.src/lib/conversations/service.ts— additive:projectIdinListFilter("none"supported),createConversation,updateConversation, andPublicConversation(now an explicit interface;projectId?: string | nullis optional so A's optimistic conversation object stays assignable — A may setprojectId: activeProjectIdthere).src/lib/client/types.ts— appended re-exports:PublicProject,ProjectDetail,PublicProjectFile,PendingAttachment,PublicPrompt,PromptKind,UsePromptResult,PromptVariable.- Ids:
prj_…(projects),pfl_…(library files) vianewId(); prompts reuseids.prompt()(prm_…, distinct table fromprompt_presets).
Routes
src/app/api/projects/route.ts,src/app/api/projects/[id]/route.tssrc/app/api/library/files/route.ts,src/app/api/library/files/[id]/route.ts,src/app/api/library/files/attach/route.tssrc/app/api/prompts/route.ts,src/app/api/prompts/[id]/route.ts,src/app/api/prompts/[id]/use/route.tssrc/app/api/conversations/route.ts(?projectId=,projectIdin create body),src/app/api/conversations/[id]/route.ts(projectIdin 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(WorkspacePageHeader48 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 viaModelSelector multiple, default settings viaParametersForm, 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(listsprompt_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
- Library calls
POST /api/prompts/:id/use { variables }, thendispatchPromptInsert(detail):window.dispatchEvent(new CustomEvent("polyllm:insert-prompt", { detail }))(PROMPT_INSERT_EVENT), andsessionStorage["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).
PromptInsertDetail = { promptId, name, kind, text, systemPrompt, schema, defaultModelKey, variables, at }.- 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>/usewithdecodeVars(vars)and apply the result the same way; - apply:
kind === "system"→ set the conversation system prompt tosystemPrompt;user/template→ inserttextinto the draft;structured→ inserttextand, ifschema, requestresponseFormat: { type: "json_schema", schema };defaultModelKeymay switch the model when usable. - Legacy
?prompt=<prompt_presets id>keeps its current behaviour (unchanged); optionally fall back toGET /api/prompts/:idwhen the id is not a legacy preset.
- "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
sortOrderUI (API supports it). - Command palette entries (Open project, Search prompts) are G's;
GET /api/prompts?q=andGET /api/projectsare 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/projectsheader 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:ModelSelectoropens 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/confirmanywhere; every icon button has an aria-label; all new inputs are ≥ 15 px on phones.