@pylonsync/create-pylon 0.19.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/create-pylon.js +30 -7
- package/package.json +1 -1
- package/templates/agency/.env.example +7 -0
- package/templates/agency/README.md +28 -3
- package/templates/agency/app/(marketing)/contact-form.tsx +20 -18
- package/templates/agency/app/(marketing)/page.tsx +3 -3
- package/templates/agency/app/(marketing)/work/page.tsx +3 -3
- package/templates/agency/app/auth-form.tsx +4 -4
- package/templates/agency/app/dashboard/dashboard-client.tsx +110 -64
- package/templates/agency/app/dashboard/page.tsx +14 -16
- package/templates/agency/app/icon.svg +3 -0
- package/templates/agency/app/login/page.tsx +12 -14
- package/templates/agency/components/marketing.tsx +12 -5
- package/templates/agency/functions/seedStudioBackoffice.ts +42 -11
- package/templates/agency/lib/agency.ts +22 -0
- package/templates/agency/lib/demo.ts +118 -0
- package/templates/agency/lib/site.config.ts +19 -13
- package/templates/agency/public/favicon.ico +0 -0
- package/templates/agency/tests/agency.test.ts +57 -0
- package/templates/ai-chat/.env.example +13 -27
- package/templates/ai-chat/README.md +53 -60
- package/templates/ai-chat/app/(chat)/c/[id]/page.tsx +19 -0
- package/templates/ai-chat/app/(chat)/chat-client.tsx +657 -622
- package/templates/ai-chat/app/(chat)/layout.tsx +6 -43
- package/templates/ai-chat/app/(chat)/not-found.tsx +6 -10
- package/templates/ai-chat/app/(chat)/page.tsx +7 -11
- package/templates/ai-chat/app/auth-form.tsx +45 -38
- package/templates/ai-chat/app/error.tsx +9 -12
- package/templates/ai-chat/app/globals.css +464 -102
- package/templates/ai-chat/app/layout.tsx +11 -19
- package/templates/ai-chat/app/login/page.tsx +39 -24
- package/templates/ai-chat/app.ts +118 -43
- package/templates/ai-chat/components/chat/composer.tsx +193 -0
- package/templates/ai-chat/components/chat/empty-state.tsx +65 -0
- package/templates/ai-chat/components/chat/icons.tsx +198 -0
- package/templates/ai-chat/components/chat/markdown.tsx +164 -0
- package/templates/ai-chat/components/chat/message.tsx +183 -0
- package/templates/ai-chat/components/chat/setup.tsx +71 -0
- package/templates/ai-chat/components/chat/sidebar.tsx +288 -0
- package/templates/ai-chat/functions/_beginTurn.ts +145 -0
- package/templates/ai-chat/functions/_finishTurn.ts +55 -0
- package/templates/ai-chat/functions/assistantStatus.ts +13 -0
- package/templates/ai-chat/functions/claimGuestHistory.ts +39 -0
- package/templates/ai-chat/functions/createConversation.ts +28 -0
- package/templates/ai-chat/functions/deleteConversation.ts +15 -20
- package/templates/ai-chat/functions/prepareGuestClaim.ts +32 -0
- package/templates/ai-chat/functions/renameConversation.ts +19 -0
- package/templates/ai-chat/functions/respond.ts +88 -0
- package/templates/ai-chat/functions/seedExamples.ts +46 -0
- package/templates/ai-chat/functions/stopResponse.ts +30 -0
- package/templates/ai-chat/lib/brand-icon.ts +7 -0
- package/templates/ai-chat/lib/chat.ts +277 -0
- package/templates/ai-chat/lib/claim.ts +15 -0
- package/templates/ai-chat/lib/examples.ts +179 -0
- package/templates/ai-chat/lib/highlight.ts +107 -0
- package/templates/ai-chat/lib/markdown.ts +317 -0
- package/templates/ai-chat/lib/provider.ts +38 -0
- package/templates/ai-chat/lib/site.config.ts +55 -39
- package/templates/ai-chat/tests/chat.test.ts +174 -0
- package/templates/ai-chat/tests/components.test.tsx +57 -0
- package/templates/ai-chat/tests/markdown.test.ts +88 -0
- package/templates/ai-chat/tests/provider.test.ts +32 -0
- package/templates/backend/consumer/apps/api/app/layout.tsx +33 -0
- package/templates/backend/consumer/apps/api/app/page.tsx +54 -0
- package/templates/backend/consumer/apps/api/app.ts +166 -0
- package/templates/backend/consumer/apps/api/functions/addComment.ts +24 -0
- package/templates/backend/consumer/apps/api/functions/createPost.ts +19 -36
- package/templates/backend/consumer/apps/api/functions/deletePost.ts +15 -13
- package/templates/backend/consumer/apps/api/functions/myProfile.ts +5 -8
- package/templates/backend/consumer/apps/api/functions/seedDemo.ts +95 -0
- package/templates/backend/consumer/apps/api/functions/setFollow.ts +36 -0
- package/templates/backend/consumer/apps/api/functions/setLike.ts +36 -0
- package/templates/backend/consumer/apps/api/functions/upsertProfile.ts +43 -37
- package/templates/backend/consumer/apps/api/lib/seed.ts +89 -0
- package/templates/backend/consumer/apps/api/lib/social.ts +50 -0
- package/templates/backend/consumer/apps/api/package.json +8 -2
- package/templates/backend/consumer/apps/api/public/images/posts/arch.jpg +0 -0
- package/templates/backend/consumer/apps/api/public/images/posts/coffee.jpg +0 -0
- package/templates/backend/consumer/apps/api/public/images/posts/dog.jpg +0 -0
- package/templates/backend/consumer/apps/api/public/images/posts/lisbon.jpg +0 -0
- package/templates/backend/consumer/apps/api/public/images/posts/ramen.jpg +0 -0
- package/templates/backend/consumer/apps/api/public/images/posts/run.jpg +0 -0
- package/templates/backend/consumer/apps/api/public/images/posts/sunset.jpg +0 -0
- package/templates/backend/consumer/apps/api/tsconfig.json +12 -2
- package/templates/chat/README.md +43 -21
- package/templates/chat/app/chat-client.tsx +495 -85
- package/templates/chat/app/error.tsx +1 -1
- package/templates/chat/app/globals.css +170 -54
- package/templates/chat/app/layout.tsx +12 -9
- package/templates/chat/app/not-found.tsx +4 -2
- package/templates/chat/app/page.tsx +9 -19
- package/templates/chat/app.ts +96 -17
- package/templates/chat/components/chat/avatar.tsx +45 -0
- package/templates/chat/components/chat/composer.tsx +110 -0
- package/templates/chat/components/chat/message-list.tsx +285 -0
- package/templates/chat/components/chat/sidebar.tsx +190 -0
- package/templates/chat/functions/joinChat.ts +83 -0
- package/templates/chat/functions/renameMember.ts +25 -0
- package/templates/chat/lib/chat.ts +188 -0
- package/templates/chat/lib/seed.ts +78 -0
- package/templates/chat/tests/chat.test.ts +159 -0
- package/templates/chat/tests/composer.test.tsx +54 -0
- package/templates/consumer/README.md +3 -3
- package/templates/consumer/app/globals.css +10 -3
- package/templates/consumer/app/icon.svg +1 -0
- package/templates/consumer/components/social/avatar.tsx +25 -11
- package/templates/consumer/functions/seedFeed.ts +3 -3
- package/templates/consumer/lib/seed.ts +43 -85
- package/templates/consumer/public/favicon.ico +0 -0
- package/templates/consumer/public/images/posts/arch.jpg +0 -0
- package/templates/consumer/public/images/posts/coffee.jpg +0 -0
- package/templates/consumer/public/images/posts/dog.jpg +0 -0
- package/templates/consumer/public/images/posts/lisbon.jpg +0 -0
- package/templates/consumer/public/images/posts/ramen.jpg +0 -0
- package/templates/consumer/public/images/posts/run.jpg +0 -0
- package/templates/consumer/public/images/posts/sunset.jpg +0 -0
- package/templates/consumer/tests/seed.test.ts +39 -0
- package/templates/creator/.env.example +7 -0
- package/templates/creator/README.md +24 -5
- package/templates/creator/app/(marketing)/newsletter-signup.tsx +33 -12
- package/templates/creator/app/auth-form.tsx +7 -7
- package/templates/creator/app/dashboard/dashboard-client.tsx +137 -100
- package/templates/creator/app/dashboard/page.tsx +18 -18
- package/templates/creator/app/icon.svg +2 -0
- package/templates/creator/app/login/page.tsx +14 -14
- package/templates/creator/app.ts +4 -4
- package/templates/creator/functions/seedDemo.ts +32 -0
- package/templates/creator/functions/subscribe.ts +4 -15
- package/templates/creator/lib/demo.ts +94 -0
- package/templates/creator/lib/site.config.ts +15 -10
- package/templates/creator/lib/stats.ts +35 -0
- package/templates/creator/public/favicon.ico +0 -0
- package/templates/creator/tests/counter.test.ts +92 -0
- package/templates/creator/tests/live-count.test.tsx +45 -0
- package/templates/crm/README.md +22 -1
- package/templates/crm/app/apple-icon.png +0 -0
- package/templates/crm/app/companies/companies-view.tsx +32 -14
- package/templates/crm/app/companies/page.tsx +2 -1
- package/templates/crm/app/contacts/contacts-view.tsx +34 -10
- package/templates/crm/app/contacts/page.tsx +2 -1
- package/templates/crm/app/deals/[id]/deal-view.tsx +137 -98
- package/templates/crm/app/deals/[id]/page.tsx +2 -1
- package/templates/crm/app/globals.css +29 -14
- package/templates/crm/app/icon.svg +1 -0
- package/templates/crm/app/layout.tsx +7 -5
- package/templates/crm/app/page.tsx +2 -1
- package/templates/crm/app/pipeline-view.tsx +5 -5
- package/templates/crm/app/workspace.tsx +29 -17
- package/templates/crm/app.ts +5 -5
- package/templates/crm/components/activity-timeline.tsx +14 -4
- package/templates/crm/components/app-shell.tsx +94 -0
- package/templates/crm/components/auth-form.tsx +35 -5
- package/templates/crm/components/avatar.tsx +5 -1
- package/templates/crm/components/board-skeleton.tsx +5 -5
- package/templates/crm/components/brand-mark.tsx +25 -0
- package/templates/crm/components/command-palette.tsx +17 -17
- package/templates/crm/components/data-table.tsx +97 -36
- package/templates/crm/components/deal-card.tsx +23 -27
- package/templates/crm/components/filter-tabs.tsx +101 -0
- package/templates/crm/components/metrics-bar.tsx +6 -6
- package/templates/crm/components/nav.tsx +15 -0
- package/templates/crm/components/page-header.tsx +43 -7
- package/templates/crm/components/pipeline-board.tsx +16 -10
- package/templates/crm/components/require-auth.tsx +24 -26
- package/templates/crm/components/sidebar.tsx +54 -49
- package/templates/crm/components/stage-badge.tsx +4 -2
- package/templates/crm/components/ui/button.tsx +5 -4
- package/templates/crm/components/ui/dialog.tsx +1 -1
- package/templates/crm/components/ui/input.tsx +1 -1
- package/templates/crm/components/ui/select.tsx +1 -1
- package/templates/crm/components/ui/textarea.tsx +1 -1
- package/templates/crm/functions/seedWorkspace.ts +32 -17
- package/templates/crm/lib/brand.ts +9 -0
- package/templates/crm/lib/demo-team.ts +51 -0
- package/templates/crm/lib/pipeline.ts +37 -0
- package/templates/crm/lib/search.ts +4 -3
- package/templates/crm/lib/seed.ts +53 -8
- package/templates/crm/public/favicon.ico +0 -0
- package/templates/crm/tests/components.test.tsx +5 -5
- package/templates/crm/tests/demo-team.test.ts +18 -0
- package/templates/crm/tests/pipeline.test.ts +59 -3
- package/templates/directory/.env.example +7 -0
- package/templates/directory/README.md +22 -3
- package/templates/directory/app/(marketing)/directory-browse.tsx +30 -12
- package/templates/directory/app/(marketing)/page.tsx +6 -4
- package/templates/directory/app/auth-form.tsx +4 -4
- package/templates/directory/app/dashboard/dashboard-client.tsx +177 -90
- package/templates/directory/app/dashboard/page.tsx +16 -14
- package/templates/directory/app/icon.svg +4 -0
- package/templates/directory/app/login/page.tsx +16 -15
- package/templates/directory/functions/seedDemo.ts +43 -0
- package/templates/directory/lib/demo.ts +145 -0
- package/templates/directory/lib/directory.ts +27 -0
- package/templates/directory/lib/site.config.ts +21 -16
- package/templates/directory/public/favicon.ico +0 -0
- package/templates/directory/tests/directory.test.ts +46 -0
- package/templates/expo/consumer/apps/expo/App.tsx +360 -288
- package/templates/expo/consumer/apps/expo/app.json +9 -1
- package/templates/expo/consumer/apps/expo/package.json +2 -1
- package/templates/helpdesk/README.md +21 -0
- package/templates/helpdesk/app/apple-icon.png +0 -0
- package/templates/helpdesk/app/customers/customers-view.tsx +23 -7
- package/templates/helpdesk/app/customers/page.tsx +2 -1
- package/templates/helpdesk/app/globals.css +29 -14
- package/templates/helpdesk/app/icon.svg +1 -0
- package/templates/helpdesk/app/inbox-view.tsx +66 -47
- package/templates/helpdesk/app/layout.tsx +7 -5
- package/templates/helpdesk/app/page.tsx +4 -7
- package/templates/helpdesk/app/tickets/[id]/page.tsx +2 -1
- package/templates/helpdesk/app/tickets/[id]/ticket-view.tsx +134 -97
- package/templates/helpdesk/app/workspace.tsx +33 -22
- package/templates/helpdesk/app.ts +2 -2
- package/templates/helpdesk/components/app-shell.tsx +94 -0
- package/templates/helpdesk/components/auth-form.tsx +35 -5
- package/templates/helpdesk/components/avatar.tsx +5 -1
- package/templates/helpdesk/components/brand-mark.tsx +28 -0
- package/templates/helpdesk/components/command-palette.tsx +17 -17
- package/templates/helpdesk/components/data-table.tsx +97 -36
- package/templates/helpdesk/components/filter-tabs.tsx +101 -0
- package/templates/helpdesk/components/nav.tsx +14 -0
- package/templates/helpdesk/components/page-header.tsx +43 -7
- package/templates/helpdesk/components/priority-badge.tsx +49 -6
- package/templates/helpdesk/components/require-auth.tsx +24 -26
- package/templates/helpdesk/components/sidebar.tsx +54 -48
- package/templates/helpdesk/components/sla-indicator.tsx +5 -8
- package/templates/helpdesk/components/thread.tsx +7 -7
- package/templates/helpdesk/components/ticket-list.tsx +79 -19
- package/templates/helpdesk/components/ui/button.tsx +5 -4
- package/templates/helpdesk/components/ui/dialog.tsx +1 -1
- package/templates/helpdesk/components/ui/input.tsx +1 -1
- package/templates/helpdesk/components/ui/select.tsx +1 -1
- package/templates/helpdesk/components/ui/textarea.tsx +1 -1
- package/templates/helpdesk/functions/seedWorkspace.ts +25 -15
- package/templates/helpdesk/lib/brand.ts +9 -0
- package/templates/helpdesk/lib/demo-team.ts +51 -0
- package/templates/helpdesk/lib/search.ts +4 -3
- package/templates/helpdesk/lib/seed.ts +43 -2
- package/templates/helpdesk/lib/tickets.ts +68 -20
- package/templates/helpdesk/public/favicon.ico +0 -0
- package/templates/helpdesk/tests/components.test.tsx +10 -0
- package/templates/helpdesk/tests/demo-team.test.ts +18 -0
- package/templates/helpdesk/tests/tickets.test.ts +70 -0
- package/templates/imessage/.env.example +49 -0
- package/templates/imessage/AGENTS.md +153 -0
- package/templates/imessage/CLAUDE.md +7 -0
- package/templates/imessage/README.md +187 -0
- package/templates/imessage/app/console.tsx +382 -0
- package/templates/imessage/app/contacts/page.tsx +13 -0
- package/templates/imessage/app/error.tsx +16 -0
- package/templates/imessage/app/gate.tsx +31 -0
- package/templates/imessage/app/globals.css +183 -0
- package/templates/imessage/app/layout.tsx +17 -0
- package/templates/imessage/app/login/login-form.tsx +115 -0
- package/templates/imessage/app/login/page.tsx +30 -0
- package/templates/imessage/app/not-found.tsx +13 -0
- package/templates/imessage/app/page.tsx +14 -0
- package/templates/imessage/app/robots.ts +6 -0
- package/templates/imessage/app/setup/page.tsx +13 -0
- package/templates/imessage/app/sign-out.tsx +20 -0
- package/templates/imessage/app.ts +299 -0
- package/templates/imessage/bunfig +4 -0
- package/templates/imessage/components/avatar.tsx +46 -0
- package/templates/imessage/components/contact-panel.tsx +263 -0
- package/templates/imessage/components/contacts-view.tsx +172 -0
- package/templates/imessage/components/conversation-list.tsx +129 -0
- package/templates/imessage/components/nav-rail.tsx +67 -0
- package/templates/imessage/components/setup-view.tsx +408 -0
- package/templates/imessage/components/thread.tsx +229 -0
- package/templates/imessage/components/ui/badge.tsx +37 -0
- package/templates/imessage/components/ui/button.tsx +56 -0
- package/templates/imessage/components/ui/card.tsx +90 -0
- package/templates/imessage/components/ui/dialog.tsx +110 -0
- package/templates/imessage/components/ui/input.tsx +30 -0
- package/templates/imessage/components/ui/label.tsx +21 -0
- package/templates/imessage/components/ui/select.tsx +39 -0
- package/templates/imessage/components/ui/table.tsx +124 -0
- package/templates/imessage/components/ui/textarea.tsx +24 -0
- package/templates/imessage/components.json +20 -0
- package/templates/imessage/functions/answerNow.ts +34 -0
- package/templates/imessage/functions/assistant.ts +96 -0
- package/templates/imessage/functions/beginTurn.ts +101 -0
- package/templates/imessage/functions/cancelReminder.ts +13 -0
- package/templates/imessage/functions/claimOutbound.ts +44 -0
- package/templates/imessage/functions/clearDemo.ts +18 -0
- package/templates/imessage/functions/deleteNote.ts +11 -0
- package/templates/imessage/functions/deliverOutbound.ts +55 -0
- package/templates/imessage/functions/finishTurn.ts +84 -0
- package/templates/imessage/functions/fireReminder.ts +42 -0
- package/templates/imessage/functions/ingestInbound.ts +22 -0
- package/templates/imessage/functions/ownerAccount.ts +13 -0
- package/templates/imessage/functions/processTurn.ts +74 -0
- package/templates/imessage/functions/relayApply.ts +102 -0
- package/templates/imessage/functions/relaySync.ts +63 -0
- package/templates/imessage/functions/releaseOutbound.ts +18 -0
- package/templates/imessage/functions/resolveTurn.ts +25 -0
- package/templates/imessage/functions/saveContact.ts +27 -0
- package/templates/imessage/functions/seedDemo.ts +70 -0
- package/templates/imessage/functions/sendOwnerMessage.ts +28 -0
- package/templates/imessage/functions/sendblueWebhook.ts +49 -0
- package/templates/imessage/functions/setContactStatus.ts +17 -0
- package/templates/imessage/functions/settleOutbound.ts +35 -0
- package/templates/imessage/functions/setupStatus.ts +33 -0
- package/templates/imessage/functions/sweep.ts +58 -0
- package/templates/imessage/functions/toolCancelReminder.ts +17 -0
- package/templates/imessage/functions/toolForgetNote.ts +15 -0
- package/templates/imessage/functions/toolListNotes.ts +16 -0
- package/templates/imessage/functions/toolListReminders.ts +17 -0
- package/templates/imessage/functions/toolSaveNote.ts +22 -0
- package/templates/imessage/functions/toolSetReminder.ts +55 -0
- package/templates/imessage/functions/updateSettings.ts +48 -0
- package/templates/imessage/gitignore +11 -0
- package/templates/imessage/lib/agent-call.ts +251 -0
- package/templates/imessage/lib/assistant.ts +74 -0
- package/templates/imessage/lib/chunk.ts +135 -0
- package/templates/imessage/lib/demo.ts +68 -0
- package/templates/imessage/lib/format.ts +170 -0
- package/templates/imessage/lib/gate.ts +104 -0
- package/templates/imessage/lib/handles.ts +67 -0
- package/templates/imessage/lib/provider.ts +38 -0
- package/templates/imessage/lib/relay-protocol.ts +95 -0
- package/templates/imessage/lib/reminders.ts +41 -0
- package/templates/imessage/lib/secrets.ts +59 -0
- package/templates/imessage/lib/sendblue.ts +173 -0
- package/templates/imessage/lib/store.ts +407 -0
- package/templates/imessage/lib/transport.ts +135 -0
- package/templates/imessage/lib/utils.ts +10 -0
- package/templates/imessage/package.json +40 -0
- package/templates/imessage/relay/.env.example +14 -0
- package/templates/imessage/relay/applescript.ts +70 -0
- package/templates/imessage/relay/chatdb.ts +165 -0
- package/templates/imessage/relay/client.ts +41 -0
- package/templates/imessage/relay/main.ts +137 -0
- package/templates/imessage/relay/state.ts +35 -0
- package/templates/imessage/tests/agent-call.test.ts +112 -0
- package/templates/imessage/tests/applescript.test.ts +88 -0
- package/templates/imessage/tests/chatdb.test.ts +139 -0
- package/templates/imessage/tests/chunk.test.ts +61 -0
- package/templates/imessage/tests/fake-ctx.ts +88 -0
- package/templates/imessage/tests/ingest.test.ts +141 -0
- package/templates/imessage/tests/lib.test.ts +99 -0
- package/templates/imessage/tests/review-fixes.test.ts +138 -0
- package/templates/imessage/tests/secrets.test.ts +36 -0
- package/templates/imessage/tests/sendblue.test.ts +143 -0
- package/templates/imessage/tests/setup.ts +5 -0
- package/templates/imessage/tsconfig.json +18 -0
- package/templates/inventory/README.md +21 -0
- package/templates/inventory/app/apple-icon.png +0 -0
- package/templates/inventory/app/globals.css +29 -14
- package/templates/inventory/app/icon.svg +1 -0
- package/templates/inventory/app/layout.tsx +7 -5
- package/templates/inventory/app/movements/movements-view.tsx +32 -10
- package/templates/inventory/app/movements/page.tsx +2 -1
- package/templates/inventory/app/page.tsx +4 -6
- package/templates/inventory/app/products/[id]/page.tsx +2 -1
- package/templates/inventory/app/products/[id]/product-view.tsx +55 -25
- package/templates/inventory/app/products-view.tsx +74 -33
- package/templates/inventory/app/workspace.tsx +34 -16
- package/templates/inventory/app.ts +10 -2
- package/templates/inventory/components/app-shell.tsx +94 -0
- package/templates/inventory/components/auth-form.tsx +35 -5
- package/templates/inventory/components/avatar.tsx +5 -1
- package/templates/inventory/components/brand-mark.tsx +25 -0
- package/templates/inventory/components/category-tile.tsx +49 -0
- package/templates/inventory/components/command-palette.tsx +17 -17
- package/templates/inventory/components/data-table.tsx +97 -36
- package/templates/inventory/components/filter-tabs.tsx +101 -0
- package/templates/inventory/components/nav.tsx +14 -0
- package/templates/inventory/components/page-header.tsx +43 -7
- package/templates/inventory/components/require-auth.tsx +24 -26
- package/templates/inventory/components/sidebar.tsx +54 -48
- package/templates/inventory/components/stock-level.tsx +30 -14
- package/templates/inventory/components/summary-bar.tsx +8 -15
- package/templates/inventory/components/ui/button.tsx +5 -4
- package/templates/inventory/components/ui/dialog.tsx +1 -1
- package/templates/inventory/components/ui/input.tsx +1 -1
- package/templates/inventory/components/ui/select.tsx +1 -1
- package/templates/inventory/components/ui/textarea.tsx +1 -1
- package/templates/inventory/functions/seedWorkspace.ts +18 -8
- package/templates/inventory/lib/brand.ts +9 -0
- package/templates/inventory/lib/demo-team.ts +51 -0
- package/templates/inventory/lib/search.ts +4 -3
- package/templates/inventory/lib/seed.ts +29 -2
- package/templates/inventory/lib/stock.ts +32 -0
- package/templates/inventory/public/favicon.ico +0 -0
- package/templates/inventory/tests/demo-team.test.ts +18 -0
- package/templates/inventory/tests/stock.test.ts +29 -0
- package/templates/invoices/README.md +16 -0
- package/templates/invoices/app/apple-icon.png +0 -0
- package/templates/invoices/app/clients/clients-view.tsx +18 -6
- package/templates/invoices/app/clients/page.tsx +2 -1
- package/templates/invoices/app/globals.css +29 -14
- package/templates/invoices/app/icon.svg +1 -0
- package/templates/invoices/app/invoices/[id]/invoice-view.tsx +72 -48
- package/templates/invoices/app/invoices/[id]/page.tsx +2 -1
- package/templates/invoices/app/invoices-view.tsx +81 -32
- package/templates/invoices/app/layout.tsx +7 -5
- package/templates/invoices/app/page.tsx +4 -6
- package/templates/invoices/app/workspace.tsx +26 -16
- package/templates/invoices/app.ts +2 -2
- package/templates/invoices/components/app-shell.tsx +94 -0
- package/templates/invoices/components/auth-form.tsx +35 -5
- package/templates/invoices/components/avatar.tsx +5 -1
- package/templates/invoices/components/brand-mark.tsx +26 -0
- package/templates/invoices/components/command-palette.tsx +17 -17
- package/templates/invoices/components/data-table.tsx +97 -36
- package/templates/invoices/components/filter-tabs.tsx +101 -0
- package/templates/invoices/components/line-items.tsx +19 -14
- package/templates/invoices/components/nav.tsx +14 -0
- package/templates/invoices/components/page-header.tsx +43 -7
- package/templates/invoices/components/require-auth.tsx +24 -26
- package/templates/invoices/components/sidebar.tsx +54 -48
- package/templates/invoices/components/status-badge.tsx +7 -7
- package/templates/invoices/components/summary-bar.tsx +5 -5
- package/templates/invoices/components/ui/button.tsx +5 -4
- package/templates/invoices/components/ui/dialog.tsx +1 -1
- package/templates/invoices/components/ui/input.tsx +1 -1
- package/templates/invoices/components/ui/select.tsx +1 -1
- package/templates/invoices/components/ui/textarea.tsx +1 -1
- package/templates/invoices/functions/recordPayment.ts +3 -0
- package/templates/invoices/lib/billing.ts +40 -0
- package/templates/invoices/lib/brand.ts +9 -0
- package/templates/invoices/lib/search.ts +4 -3
- package/templates/invoices/public/favicon.ico +0 -0
- package/templates/invoices/tests/billing.test.ts +27 -0
- package/templates/ios/consumer/apps/ios/Package.swift +4 -3
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/ActivityView.swift +107 -0
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/AppModel.swift +171 -0
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/AuthView.swift +182 -0
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/CommentsView.swift +136 -0
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/ComposeView.swift +152 -0
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/ExploreView.swift +117 -0
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/FeedView.swift +175 -159
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/MainTabView.swift +54 -0
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/Media.swift +271 -0
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/Models.swift +101 -4
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/PostCard.swift +245 -0
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/ProfileEditorView.swift +208 -0
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/ProfileView.swift +183 -0
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/RootView.swift +25 -31
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/SocialStore.swift +285 -0
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/StoryViewer.swift +155 -0
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/Theme.swift +82 -0
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +4 -47
- package/templates/ios/consumer/apps/ios/project.yml +13 -9
- package/templates/local-service/.env.example +7 -0
- package/templates/local-service/README.md +26 -2
- package/templates/local-service/app/(marketing)/booking-widget.tsx +48 -9
- package/templates/local-service/app/(marketing)/page.tsx +66 -42
- package/templates/local-service/app/auth-form.tsx +8 -8
- package/templates/local-service/app/dashboard/dashboard-client.tsx +119 -92
- package/templates/local-service/app/dashboard/page.tsx +14 -14
- package/templates/local-service/app/icon.svg +2 -0
- package/templates/local-service/app/login/page.tsx +15 -15
- package/templates/local-service/functions/seedDemo.ts +49 -0
- package/templates/local-service/lib/demo.ts +135 -0
- package/templates/local-service/lib/hours.ts +44 -0
- package/templates/local-service/lib/site.config.ts +8 -3
- package/templates/local-service/lib/slots.ts +3 -0
- package/templates/local-service/public/favicon.ico +0 -0
- package/templates/local-service/tests/booking.test.ts +104 -0
- package/templates/marketplace/README.md +23 -15
- package/templates/marketplace/app/globals.css +172 -114
- package/templates/marketplace/app/layout.tsx +75 -30
- package/templates/marketplace/app/listing/[id]/page.tsx +210 -123
- package/templates/marketplace/app/me/page.tsx +4 -4
- package/templates/marketplace/app/page.tsx +221 -298
- package/templates/marketplace/app/sell/page.tsx +8 -11
- package/templates/marketplace/app.ts +32 -7
- package/templates/marketplace/client/ActivityFeed.tsx +311 -0
- package/templates/marketplace/client/AuthNav.tsx +24 -16
- package/templates/marketplace/client/ListingCard.tsx +118 -0
- package/templates/marketplace/client/LiveGrid.tsx +92 -0
- package/templates/marketplace/client/LiveListingStatus.tsx +32 -24
- package/templates/marketplace/client/LoginCard.tsx +114 -136
- package/templates/marketplace/client/MarketProvider.tsx +3 -5
- package/templates/marketplace/client/MyMarket.tsx +371 -185
- package/templates/marketplace/client/OfferPanel.tsx +421 -287
- package/templates/marketplace/client/SellForm.tsx +271 -192
- package/templates/marketplace/client/ThemeToggle.tsx +1 -0
- package/templates/marketplace/client/WatchButton.tsx +16 -16
- package/templates/marketplace/client/market.ts +61 -14
- package/templates/marketplace/functions/seedMarket.ts +143 -36
- package/templates/marketplace/functions/seedOffers.ts +101 -0
- package/templates/marketplace/lib/catalog.ts +72 -1
- package/templates/marketplace/public/images/listings/ceramic-lamp-640.webp +0 -0
- package/templates/marketplace/public/images/listings/ceramic-lamp.webp +0 -0
- package/templates/marketplace/public/images/listings/duffel-640.webp +0 -0
- package/templates/marketplace/public/images/listings/duffel.webp +0 -0
- package/templates/marketplace/public/images/listings/film-camera-640.webp +0 -0
- package/templates/marketplace/public/images/listings/film-camera.webp +0 -0
- package/templates/marketplace/public/images/listings/lounge-chair-640.webp +0 -0
- package/templates/marketplace/public/images/listings/lounge-chair.webp +0 -0
- package/templates/marketplace/public/images/listings/road-bike-640.webp +0 -0
- package/templates/marketplace/public/images/listings/road-bike.webp +0 -0
- package/templates/marketplace/public/images/listings/slipper-chair-640.webp +0 -0
- package/templates/marketplace/public/images/listings/slipper-chair.webp +0 -0
- package/templates/marketplace/public/images/listings/turntable-640.webp +0 -0
- package/templates/marketplace/public/images/listings/turntable.webp +0 -0
- package/templates/marketplace/public/images/listings/wardrobe-640.webp +0 -0
- package/templates/marketplace/public/images/listings/wardrobe.webp +0 -0
- package/templates/marketplace/public/images/listings/watch-640.webp +0 -0
- package/templates/marketplace/public/images/listings/watch.webp +0 -0
- package/templates/marketplace/public/images/listings/weekender-bag-640.webp +0 -0
- package/templates/marketplace/public/images/listings/weekender-bag.webp +0 -0
- package/templates/marketplace/tests/example.test.ts +64 -2
- package/templates/marketplace/tests/listing-card.test.tsx +61 -0
- package/templates/projects/README.md +24 -3
- package/templates/projects/app/apple-icon.png +0 -0
- package/templates/projects/app/clients/clients-view.tsx +20 -6
- package/templates/projects/app/clients/page.tsx +2 -1
- package/templates/projects/app/globals.css +29 -14
- package/templates/projects/app/icon.svg +1 -0
- package/templates/projects/app/layout.tsx +7 -5
- package/templates/projects/app/page.tsx +3 -5
- package/templates/projects/app/projects/[id]/page.tsx +2 -1
- package/templates/projects/app/projects/[id]/project-view.tsx +45 -30
- package/templates/projects/app/projects-view.tsx +105 -52
- package/templates/projects/app/workspace.tsx +41 -20
- package/templates/projects/app.ts +2 -2
- package/templates/projects/components/app-shell.tsx +94 -0
- package/templates/projects/components/auth-form.tsx +35 -5
- package/templates/projects/components/avatar.tsx +5 -1
- package/templates/projects/components/board-skeleton.tsx +3 -3
- package/templates/projects/components/brand-mark.tsx +25 -0
- package/templates/projects/components/command-palette.tsx +17 -17
- package/templates/projects/components/data-table.tsx +97 -36
- package/templates/projects/components/filter-tabs.tsx +101 -0
- package/templates/projects/components/meter.tsx +48 -0
- package/templates/projects/components/nav.tsx +14 -0
- package/templates/projects/components/page-header.tsx +43 -7
- package/templates/projects/components/require-auth.tsx +24 -26
- package/templates/projects/components/sidebar.tsx +54 -48
- package/templates/projects/components/summary-bar.tsx +49 -0
- package/templates/projects/components/task-board.tsx +10 -9
- package/templates/projects/components/time-dialog.tsx +29 -5
- package/templates/projects/components/ui/button.tsx +5 -4
- package/templates/projects/components/ui/dialog.tsx +1 -1
- package/templates/projects/components/ui/input.tsx +1 -1
- package/templates/projects/components/ui/select.tsx +1 -1
- package/templates/projects/components/ui/textarea.tsx +1 -1
- package/templates/projects/functions/seedWorkspace.ts +26 -12
- package/templates/projects/lib/brand.ts +9 -0
- package/templates/projects/lib/demo-team.ts +51 -0
- package/templates/projects/lib/search.ts +4 -3
- package/templates/projects/lib/seed.ts +35 -1
- package/templates/projects/lib/work.ts +63 -0
- package/templates/projects/public/favicon.ico +0 -0
- package/templates/projects/tests/demo-team.test.ts +18 -0
- package/templates/projects/tests/work.test.ts +65 -0
- package/templates/restaurant/.env.example +7 -0
- package/templates/restaurant/README.md +29 -3
- package/templates/restaurant/app/(marketing)/layout.tsx +4 -2
- package/templates/restaurant/app/(marketing)/page.tsx +47 -23
- package/templates/restaurant/app/(marketing)/reservation-widget.tsx +37 -7
- package/templates/restaurant/app/auth-form.tsx +8 -8
- package/templates/restaurant/app/dashboard/dashboard-client.tsx +241 -109
- package/templates/restaurant/app/dashboard/page.tsx +14 -14
- package/templates/restaurant/app/icon.svg +4 -0
- package/templates/restaurant/app/login/page.tsx +15 -15
- package/templates/restaurant/functions/seedDemo.ts +44 -0
- package/templates/restaurant/lib/demo.ts +144 -0
- package/templates/restaurant/lib/hours.ts +45 -0
- package/templates/restaurant/lib/site.config.ts +3 -3
- package/templates/restaurant/public/favicon.ico +0 -0
- package/templates/restaurant/tests/reservations.test.ts +67 -0
- package/templates/saas/README.md +54 -35
- package/templates/saas/app/(auth)/auth-form.tsx +25 -23
- package/templates/saas/app/(auth)/forgot-password/page.tsx +1 -1
- package/templates/saas/app/(auth)/layout.tsx +27 -33
- package/templates/saas/app/(auth)/login/page.tsx +1 -1
- package/templates/saas/app/(auth)/reset-password/page.tsx +1 -1
- package/templates/saas/app/(auth)/signup/page.tsx +1 -1
- package/templates/saas/app/(marketing)/layout.tsx +127 -253
- package/templates/saas/app/(marketing)/page.tsx +133 -372
- package/templates/saas/app/(marketing)/pricing/page.tsx +21 -19
- package/templates/saas/app/(marketing)/products/[slug]/page.tsx +40 -95
- package/templates/saas/app/dashboard/board-client.tsx +416 -0
- package/templates/saas/app/dashboard/dashboard-client.tsx +30 -615
- package/templates/saas/app/dashboard/layout.tsx +27 -13
- package/templates/saas/app/dashboard/overview-client.tsx +332 -0
- package/templates/saas/app/dashboard/page.tsx +29 -35
- package/templates/saas/app/dashboard/projects/[id]/page.tsx +29 -0
- package/templates/saas/app/dashboard/projects/page.tsx +11 -7
- package/templates/saas/app/dashboard/projects-client.tsx +398 -0
- package/templates/saas/app/globals.css +5 -4
- package/templates/saas/app/icon.svg +1 -0
- package/templates/saas/app/onboarding/onboarding-client.tsx +93 -26
- package/templates/saas/app/onboarding/page.tsx +6 -2
- package/templates/saas/app.ts +68 -13
- package/templates/saas/components/dashboard-shell.tsx +212 -132
- package/templates/saas/components/marketing.tsx +137 -274
- package/templates/saas/components/pricing-table.tsx +12 -11
- package/templates/saas/components/setup-checklist.tsx +31 -29
- package/templates/saas/components/task-ui.tsx +199 -0
- package/templates/saas/functions/createProject.ts +7 -13
- package/templates/saas/functions/createTask.ts +67 -0
- package/templates/saas/functions/deleteProject.ts +17 -0
- package/templates/saas/functions/deleteTask.ts +14 -0
- package/templates/saas/functions/seedWorkspace.ts +46 -0
- package/templates/saas/functions/setProjectStatus.ts +41 -0
- package/templates/saas/functions/updateProfile.ts +13 -0
- package/templates/saas/functions/updateProject.ts +4 -5
- package/templates/saas/functions/updateTask.ts +88 -0
- package/templates/saas/lib/members.ts +53 -0
- package/templates/saas/lib/names.ts +53 -0
- package/templates/saas/lib/plans.ts +11 -0
- package/templates/saas/lib/sample-workspace.ts +117 -0
- package/templates/saas/lib/site.config.ts +200 -463
- package/templates/saas/lib/tasks.ts +101 -0
- package/templates/saas/lib/use-live.ts +25 -0
- package/templates/saas/public/favicon.ico +0 -0
- package/templates/saas/public/screenshots/board.png +0 -0
- package/templates/saas/public/screenshots/members.png +0 -0
- package/templates/saas/public/screenshots/overview.png +0 -0
- package/templates/saas/public/screenshots/projects.png +0 -0
- package/templates/saas/scripts/capture-screenshots.mjs +156 -0
- package/templates/saas/tests/names.test.ts +35 -0
- package/templates/saas/tests/plans.test.ts +12 -0
- package/templates/saas/tests/sample-workspace.test.ts +46 -0
- package/templates/saas/tests/tasks.test.ts +81 -0
- package/templates/shop/README.md +25 -13
- package/templates/shop/app/(marketing)/layout.tsx +94 -67
- package/templates/shop/app/(marketing)/not-found.tsx +13 -12
- package/templates/shop/app/(marketing)/page.tsx +120 -59
- package/templates/shop/app/(marketing)/shop-client.tsx +364 -237
- package/templates/shop/app/(marketing)/success/page.tsx +75 -22
- package/templates/shop/app/globals.css +94 -1
- package/templates/shop/app/layout.tsx +4 -4
- package/templates/shop/app/login/page.tsx +4 -4
- package/templates/shop/app.ts +18 -6
- package/templates/shop/components/cart-button.tsx +33 -0
- package/templates/shop/components/marketing.tsx +17 -87
- package/templates/shop/components/shelf-count.tsx +36 -0
- package/templates/shop/functions/checkout.ts +17 -1
- package/templates/shop/lib/cart-store.ts +57 -0
- package/templates/shop/lib/shelf-store.ts +35 -0
- package/templates/shop/lib/shop.ts +27 -0
- package/templates/shop/lib/site.config.ts +108 -98
- package/templates/shop/public/images/hero.jpg +0 -0
- package/templates/shop/public/images/products/cedar-smoke.jpg +0 -0
- package/templates/shop/public/images/products/fig-leaf.jpg +0 -0
- package/templates/shop/public/images/products/linen.jpg +0 -0
- package/templates/shop/public/images/products/sea-salt.jpg +0 -0
- package/templates/shop/public/images/products/tobacco-oak.jpg +0 -0
- package/templates/shop/tests/shop.test.ts +64 -0
- package/templates/waitlist/.env.example +7 -0
- package/templates/waitlist/README.md +29 -13
- package/templates/waitlist/app/(marketing)/page.tsx +9 -7
- package/templates/waitlist/app/(marketing)/waitlist-hero.tsx +64 -31
- package/templates/waitlist/app/auth-form.tsx +8 -8
- package/templates/waitlist/app/dashboard/dashboard-client.tsx +132 -95
- package/templates/waitlist/app/dashboard/page.tsx +17 -16
- package/templates/waitlist/app/icon.svg +2 -0
- package/templates/waitlist/app/login/page.tsx +14 -14
- package/templates/waitlist/app.ts +6 -6
- package/templates/waitlist/functions/joinWaitlist.ts +6 -18
- package/templates/waitlist/functions/seedDemo.ts +32 -0
- package/templates/waitlist/lib/demo.ts +94 -0
- package/templates/waitlist/lib/launch.ts +19 -0
- package/templates/waitlist/lib/site.config.ts +14 -9
- package/templates/waitlist/lib/stats.ts +35 -0
- package/templates/waitlist/public/favicon.ico +0 -0
- package/templates/waitlist/tests/counter.test.ts +107 -0
- package/templates/waitlist/tests/live-count.test.tsx +48 -0
- package/templates/ai-chat/tests/example.test.ts +0 -12
- package/templates/backend/consumer/apps/api/functions/feed.ts +0 -57
- package/templates/backend/consumer/apps/api/functions/profilePosts.ts +0 -17
- package/templates/backend/consumer/apps/api/functions/toggleLike.ts +0 -48
- package/templates/backend/consumer/apps/api/schema.ts +0 -143
- package/templates/chat/functions/_keep.ts +0 -13
- package/templates/chat/tests/example.test.ts +0 -12
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/ProfileSetupView.swift +0 -68
- package/templates/marketplace/client/LiveTicker.tsx +0 -116
- package/templates/marketplace/client/ScrollToListingsLink.tsx +0 -29
- package/templates/marketplace/public/images/marketplace-hero.webp +0 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { action, v } from "@pylonsync/functions";
|
|
2
|
+
import { siteConfig } from "../lib/site.config";
|
|
3
|
+
import { isAllowedModel, modelAllowedFor, type LlmTurn } from "../lib/chat";
|
|
4
|
+
|
|
5
|
+
// Generate the assistant's reply to the latest message in a conversation.
|
|
6
|
+
//
|
|
7
|
+
// Call it with `streamFn("respond", …)`: text streams back as it generates.
|
|
8
|
+
// Every function stream is resumable, and `_beginTurn` stores this call's
|
|
9
|
+
// stream id on the assistant message, so another tab (or this tab after a
|
|
10
|
+
// reload) attaches to the same reply with `resumeStream(streamId)`. The final
|
|
11
|
+
// text is written to the message row, which syncs to every open tab.
|
|
12
|
+
//
|
|
13
|
+
// Guests can call it. `_beginTurn` enforces the hourly limits from
|
|
14
|
+
// lib/site.config.ts and allows one generation per person at a time; the model
|
|
15
|
+
// must be one listed there, and guests are limited to `guestModels`.
|
|
16
|
+
export default action({
|
|
17
|
+
auth: "guest",
|
|
18
|
+
// Streaming does not extend the call deadline; long replies need the room.
|
|
19
|
+
timeout: 300,
|
|
20
|
+
args: {
|
|
21
|
+
conversationId: v.string(),
|
|
22
|
+
content: v.optional(v.string()),
|
|
23
|
+
regenerate: v.optional(v.boolean()),
|
|
24
|
+
model: v.string(),
|
|
25
|
+
},
|
|
26
|
+
async handler(ctx, args) {
|
|
27
|
+
// A refused turn (limit, busy, bad input) resolves with its code instead
|
|
28
|
+
// of throwing: a thrown code does not survive `streamFn`, and the client
|
|
29
|
+
// branches on it.
|
|
30
|
+
if (!isAllowedModel(args.model)) {
|
|
31
|
+
return { status: "rejected" as const, errorCode: "MODEL_NOT_ALLOWED", message: "That model is not available." };
|
|
32
|
+
}
|
|
33
|
+
if (!modelAllowedFor(args.model, ctx.auth.isGuest)) {
|
|
34
|
+
return { status: "rejected" as const, errorCode: "MODEL_NEEDS_ACCOUNT", message: "Sign in to use that model." };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let turn: { messageId: string; history: LlmTurn[] };
|
|
38
|
+
try {
|
|
39
|
+
turn = await ctx.runMutation("_beginTurn", {
|
|
40
|
+
conversationId: args.conversationId,
|
|
41
|
+
content: args.content,
|
|
42
|
+
regenerate: args.regenerate ?? false,
|
|
43
|
+
model: args.model,
|
|
44
|
+
streamId: ctx.stream.id,
|
|
45
|
+
});
|
|
46
|
+
} catch (err) {
|
|
47
|
+
const e = err as { code?: string; message?: string };
|
|
48
|
+
return { status: "rejected" as const, errorCode: e.code ?? "INTERNAL", message: e.code ? (e.message ?? "") : "" };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let streamed = "";
|
|
52
|
+
try {
|
|
53
|
+
const res = await ctx.llm.stream(
|
|
54
|
+
{
|
|
55
|
+
system: siteConfig.chat.systemPrompt,
|
|
56
|
+
messages: turn.history,
|
|
57
|
+
model: args.model,
|
|
58
|
+
max_tokens: siteConfig.chat.maxOutputTokens,
|
|
59
|
+
},
|
|
60
|
+
(e) => {
|
|
61
|
+
if (e.type === "text_delta") {
|
|
62
|
+
streamed += e.text;
|
|
63
|
+
ctx.stream.write(e.text);
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
);
|
|
67
|
+
const text = res.content
|
|
68
|
+
.filter((b): b is { type: "text"; text: string } => b.type === "text")
|
|
69
|
+
.map((b) => b.text)
|
|
70
|
+
.join("");
|
|
71
|
+
await ctx.runMutation("_finishTurn", {
|
|
72
|
+
messageId: turn.messageId,
|
|
73
|
+
content: text || streamed,
|
|
74
|
+
status: "done",
|
|
75
|
+
});
|
|
76
|
+
return { messageId: turn.messageId, status: "done" as const };
|
|
77
|
+
} catch (err) {
|
|
78
|
+
const errorCode = (err as { code?: string }).code ?? "LLM_FAILED";
|
|
79
|
+
await ctx.runMutation("_finishTurn", {
|
|
80
|
+
messageId: turn.messageId,
|
|
81
|
+
content: streamed,
|
|
82
|
+
status: "error",
|
|
83
|
+
errorCode,
|
|
84
|
+
});
|
|
85
|
+
return { messageId: turn.messageId, status: "error" as const, errorCode };
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { mutation } from "@pylonsync/functions";
|
|
2
|
+
import { exampleConversations } from "../lib/examples";
|
|
3
|
+
import { providerStatus } from "../lib/provider";
|
|
4
|
+
|
|
5
|
+
// Write the example conversations from lib/examples.ts into the caller's
|
|
6
|
+
// history. Runs only while no provider key is configured and only for a
|
|
7
|
+
// caller with no conversations, so it never adds to a real history. Each row
|
|
8
|
+
// is marked `example: true`; the thread labels it and keeps it read-only.
|
|
9
|
+
export default mutation({
|
|
10
|
+
auth: "guest",
|
|
11
|
+
args: {},
|
|
12
|
+
async handler(ctx) {
|
|
13
|
+
const userId = ctx.auth.userId;
|
|
14
|
+
if (!userId || providerStatus(ctx.env).configured) return { seeded: 0 };
|
|
15
|
+
|
|
16
|
+
await ctx.db.advisoryLock(`ai-chat:seed:${userId}`);
|
|
17
|
+
const existing = (await ctx.db.unsafe.query("Conversation", { userId, $limit: 1 })) as unknown[];
|
|
18
|
+
if (existing.length > 0) return { seeded: 0 };
|
|
19
|
+
|
|
20
|
+
const now = Date.now();
|
|
21
|
+
// Conversation and Message inserts are denied to clients by policy; these
|
|
22
|
+
// rows belong to the caller.
|
|
23
|
+
for (const ex of exampleConversations) {
|
|
24
|
+
const start = now - ex.hoursAgo * 60 * 60 * 1000;
|
|
25
|
+
const last = new Date(start + (ex.messages.length - 1) * 60_000).toISOString();
|
|
26
|
+
const conversationId = await ctx.db.unsafe.insert("Conversation", {
|
|
27
|
+
userId,
|
|
28
|
+
title: ex.title,
|
|
29
|
+
example: true,
|
|
30
|
+
createdAt: new Date(start).toISOString(),
|
|
31
|
+
updatedAt: last,
|
|
32
|
+
});
|
|
33
|
+
for (const [i, m] of ex.messages.entries()) {
|
|
34
|
+
await ctx.db.unsafe.insert("Message", {
|
|
35
|
+
conversationId,
|
|
36
|
+
userId,
|
|
37
|
+
role: m.role,
|
|
38
|
+
content: m.content,
|
|
39
|
+
status: "done",
|
|
40
|
+
createdAt: new Date(start + i * 60_000).toISOString(),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return { seeded: exampleConversations.length };
|
|
45
|
+
},
|
|
46
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { mutation, v } from "@pylonsync/functions";
|
|
2
|
+
import { siteConfig } from "../lib/site.config";
|
|
3
|
+
|
|
4
|
+
// Stop the reply streaming in a conversation. The client sends the text it
|
|
5
|
+
// has shown so far; the message keeps it with status "stopped", and
|
|
6
|
+
// `_finishTurn` checks it against the model's output when the call ends.
|
|
7
|
+
//
|
|
8
|
+
// The provider call itself runs to completion on the server: `ctx.llm.stream`
|
|
9
|
+
// has no mid-generation cancel, so tokens after Stop are generated and billed
|
|
10
|
+
// but not stored. `_beginTurn` refuses a new turn until that call ends.
|
|
11
|
+
export default mutation({
|
|
12
|
+
auth: "guest",
|
|
13
|
+
args: { conversationId: v.string(), content: v.string() },
|
|
14
|
+
async handler(ctx, args) {
|
|
15
|
+
// Message updates are denied to clients by policy; only the caller's own
|
|
16
|
+
// streaming reply in this conversation is touched.
|
|
17
|
+
const rows = (await ctx.db.unsafe.query("Message", {
|
|
18
|
+
conversationId: args.conversationId,
|
|
19
|
+
role: "assistant",
|
|
20
|
+
status: "streaming",
|
|
21
|
+
})) as { id: string; userId: string }[];
|
|
22
|
+
const msg = rows.find((m) => m.userId === ctx.auth.userId);
|
|
23
|
+
if (!msg) return { ok: true, stopped: false };
|
|
24
|
+
await ctx.db.unsafe.update("Message", msg.id, {
|
|
25
|
+
status: "stopped",
|
|
26
|
+
content: args.content.slice(0, siteConfig.chat.maxOutputTokens * 8),
|
|
27
|
+
});
|
|
28
|
+
return { ok: true, stopped: true };
|
|
29
|
+
},
|
|
30
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { siteConfig } from "./site.config";
|
|
2
|
+
|
|
3
|
+
// The brand mark as an SVG data URL, used as the browser tab icon. Same
|
|
4
|
+
// shapes as BrandMark in components/chat/icons.tsx.
|
|
5
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="9" fill="${siteConfig.colors.brand}"/><circle cx="16" cy="16" r="8.5" fill="none" stroke="#fff" stroke-opacity="0.35" stroke-width="1.5"/><circle cx="13.5" cy="13.5" r="4" fill="#fff"/></svg>`;
|
|
6
|
+
|
|
7
|
+
export const brandIcon = { url: `data:image/svg+xml,${encodeURIComponent(svg)}`, type: "image/svg+xml" };
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// Pure chat logic shared by the server functions and the UI. No React, no db,
|
|
2
|
+
// so every rule here is covered by tests/chat.test.ts without a server.
|
|
3
|
+
|
|
4
|
+
import { siteConfig, type ChatModel } from "./site.config";
|
|
5
|
+
|
|
6
|
+
/* ------------------------------ row shapes ------------------------------ */
|
|
7
|
+
|
|
8
|
+
export type MessageRole = "user" | "assistant";
|
|
9
|
+
|
|
10
|
+
// `streaming`: the reply is being generated. `done`: finished. `stopped`: the
|
|
11
|
+
// user pressed Stop, content holds what they saw. `error`: the provider call
|
|
12
|
+
// failed, `errorCode` says why.
|
|
13
|
+
export type MessageStatus = "streaming" | "done" | "stopped" | "error";
|
|
14
|
+
|
|
15
|
+
export interface ConversationRow {
|
|
16
|
+
id: string;
|
|
17
|
+
userId: string;
|
|
18
|
+
title: string;
|
|
19
|
+
example?: boolean;
|
|
20
|
+
createdAt: string;
|
|
21
|
+
updatedAt?: string | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface MessageRow {
|
|
25
|
+
id: string;
|
|
26
|
+
conversationId: string;
|
|
27
|
+
userId: string;
|
|
28
|
+
role: string;
|
|
29
|
+
content: string;
|
|
30
|
+
status?: string | null;
|
|
31
|
+
errorCode?: string | null;
|
|
32
|
+
streamId?: string | null;
|
|
33
|
+
model?: string | null;
|
|
34
|
+
createdAt: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/* ------------------------------- models -------------------------------- */
|
|
38
|
+
|
|
39
|
+
export function findModel(id: string | null | undefined): ChatModel | undefined {
|
|
40
|
+
return siteConfig.chat.models.find((m) => m.id === id);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function isAllowedModel(id: string | null | undefined): id is string {
|
|
44
|
+
return findModel(id) !== undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function modelLabel(id: string | null | undefined): string {
|
|
48
|
+
return findModel(id)?.label ?? id ?? "";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/* ------------------------------- titles -------------------------------- */
|
|
52
|
+
|
|
53
|
+
const TITLE_MAX = 60;
|
|
54
|
+
|
|
55
|
+
// A conversation title from the first message: first non-empty line, collapsed
|
|
56
|
+
// whitespace, cut at a word boundary.
|
|
57
|
+
export function titleFromMessage(text: string): string {
|
|
58
|
+
const line =
|
|
59
|
+
text
|
|
60
|
+
.split("\n")
|
|
61
|
+
.map((l) => l.replace(/\s+/g, " ").trim())
|
|
62
|
+
.find((l) => l.length > 0) ?? "";
|
|
63
|
+
if (line.length === 0) return "New chat";
|
|
64
|
+
if (line.length <= TITLE_MAX) return line;
|
|
65
|
+
const cut = line.slice(0, TITLE_MAX);
|
|
66
|
+
const space = cut.lastIndexOf(" ");
|
|
67
|
+
return `${(space > 30 ? cut.slice(0, space) : cut).trimEnd()}...`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function cleanTitle(text: string): string | null {
|
|
71
|
+
const t = text.replace(/\s+/g, " ").trim().slice(0, 80);
|
|
72
|
+
return t.length > 0 ? t : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/* ------------------------------ history -------------------------------- */
|
|
76
|
+
|
|
77
|
+
export interface LlmTurn {
|
|
78
|
+
role: MessageRole;
|
|
79
|
+
content: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const HISTORY_MAX_MESSAGES = 40;
|
|
83
|
+
const HISTORY_MAX_CHARS = 60_000;
|
|
84
|
+
|
|
85
|
+
// The transcript sent to the model. Keeps finished and stopped replies, drops
|
|
86
|
+
// failed and in-flight ones, joins consecutive turns from the same role (a
|
|
87
|
+
// failed reply leaves two user turns in a row, which providers reject), and
|
|
88
|
+
// keeps the newest turns within the size caps. The result always starts with
|
|
89
|
+
// a user turn.
|
|
90
|
+
export function buildHistory(messages: MessageRow[]): LlmTurn[] {
|
|
91
|
+
const turns: LlmTurn[] = [];
|
|
92
|
+
for (const m of sortMessages(messages)) {
|
|
93
|
+
if (m.role !== "user" && m.role !== "assistant") continue;
|
|
94
|
+
if (m.role === "assistant" && m.status !== "done" && m.status !== "stopped") continue;
|
|
95
|
+
const content = m.content.trim();
|
|
96
|
+
if (content.length === 0) continue;
|
|
97
|
+
const last = turns[turns.length - 1];
|
|
98
|
+
if (last && last.role === m.role) {
|
|
99
|
+
last.content = `${last.content}\n\n${content}`;
|
|
100
|
+
} else {
|
|
101
|
+
turns.push({ role: m.role, content });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let kept = turns.slice(-HISTORY_MAX_MESSAGES);
|
|
106
|
+
let chars = kept.reduce((n, t) => n + t.content.length, 0);
|
|
107
|
+
while (kept.length > 1 && chars > HISTORY_MAX_CHARS) {
|
|
108
|
+
chars -= kept[0].content.length;
|
|
109
|
+
kept = kept.slice(1);
|
|
110
|
+
}
|
|
111
|
+
while (kept.length > 0 && kept[0].role !== "user") kept = kept.slice(1);
|
|
112
|
+
return kept;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function sortMessages<T extends { createdAt: string; id: string }>(rows: T[]): T[] {
|
|
116
|
+
return [...rows].sort((a, b) =>
|
|
117
|
+
a.createdAt === b.createdAt ? a.id.localeCompare(b.id) : a.createdAt < b.createdAt ? -1 : 1,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/* ---------------------------- stream liveness ---------------------------- */
|
|
122
|
+
|
|
123
|
+
// A reply still marked `streaming` after this long belongs to a generation
|
|
124
|
+
// that died (server restart, crash). The UI shows it as interrupted and the
|
|
125
|
+
// server lets a new turn start.
|
|
126
|
+
export const STALE_STREAM_MS = 5 * 60 * 1000;
|
|
127
|
+
|
|
128
|
+
export function isStaleStream(m: Pick<MessageRow, "status" | "createdAt">, now: number): boolean {
|
|
129
|
+
if (m.status !== "streaming") return false;
|
|
130
|
+
const t = Date.parse(m.createdAt);
|
|
131
|
+
return Number.isFinite(t) && now - t > STALE_STREAM_MS;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/* ---------------------------- sidebar groups ---------------------------- */
|
|
135
|
+
|
|
136
|
+
export interface ConversationGroup {
|
|
137
|
+
label: string;
|
|
138
|
+
items: ConversationRow[];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function startOfDay(ms: number): number {
|
|
142
|
+
const d = new Date(ms);
|
|
143
|
+
d.setHours(0, 0, 0, 0);
|
|
144
|
+
return d.getTime();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function lastActivity(c: ConversationRow): string {
|
|
148
|
+
return c.updatedAt || c.createdAt;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Newest first, bucketed the way chat apps show history: Today, Yesterday,
|
|
152
|
+
// Previous 7 days, Previous 30 days, then one group per month.
|
|
153
|
+
export function groupConversations(rows: ConversationRow[], now: number): ConversationGroup[] {
|
|
154
|
+
const today = startOfDay(now);
|
|
155
|
+
const day = 24 * 60 * 60 * 1000;
|
|
156
|
+
const sorted = [...rows].sort((a, b) => (lastActivity(a) < lastActivity(b) ? 1 : -1));
|
|
157
|
+
const groups: ConversationGroup[] = [];
|
|
158
|
+
for (const c of sorted) {
|
|
159
|
+
const t = Date.parse(lastActivity(c));
|
|
160
|
+
let label: string;
|
|
161
|
+
if (!Number.isFinite(t) || t >= today) label = "Today";
|
|
162
|
+
else if (t >= today - day) label = "Yesterday";
|
|
163
|
+
else if (t >= today - 7 * day) label = "Previous 7 days";
|
|
164
|
+
else if (t >= today - 30 * day) label = "Previous 30 days";
|
|
165
|
+
else label = new Date(t).toLocaleDateString("en-US", { month: "long", year: "numeric" });
|
|
166
|
+
const last = groups[groups.length - 1];
|
|
167
|
+
if (last && last.label === label) last.items.push(c);
|
|
168
|
+
else groups.push({ label, items: [c] });
|
|
169
|
+
}
|
|
170
|
+
return groups;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/* ----------------------------- rate limits ------------------------------ */
|
|
174
|
+
|
|
175
|
+
export const RATE_WINDOW_MS = 60 * 60 * 1000;
|
|
176
|
+
|
|
177
|
+
export function replyLimit(isGuest: boolean): number {
|
|
178
|
+
const { guest, user } = siteConfig.chat.repliesPerHour;
|
|
179
|
+
return isGuest ? guest : user;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function modelAllowedFor(id: string | null | undefined, isGuest: boolean): boolean {
|
|
183
|
+
if (!isAllowedModel(id)) return false;
|
|
184
|
+
return !isGuest || siteConfig.chat.guestModels.includes(id);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/* ---------------------------- error messages ---------------------------- */
|
|
188
|
+
|
|
189
|
+
export interface ErrorCopy {
|
|
190
|
+
title: string;
|
|
191
|
+
body: string;
|
|
192
|
+
retry: boolean;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// What the thread shows for a failed reply, by the error code the server
|
|
196
|
+
// stored on the message.
|
|
197
|
+
export function describeError(code: string | null | undefined): ErrorCopy {
|
|
198
|
+
switch (code) {
|
|
199
|
+
case "LLM_NOT_CONFIGURED":
|
|
200
|
+
return {
|
|
201
|
+
title: "No model provider is configured",
|
|
202
|
+
body: "Add a provider key to .env and restart the server. Your message is saved.",
|
|
203
|
+
retry: true,
|
|
204
|
+
};
|
|
205
|
+
case "MODEL_NEEDS_ACCOUNT":
|
|
206
|
+
return {
|
|
207
|
+
title: "That model needs an account",
|
|
208
|
+
body: "Sign in to use it, or pick another model.",
|
|
209
|
+
retry: true,
|
|
210
|
+
};
|
|
211
|
+
case "BUSY":
|
|
212
|
+
return {
|
|
213
|
+
title: "A reply is already being written",
|
|
214
|
+
body: "Wait for it to finish, then send again.",
|
|
215
|
+
retry: true,
|
|
216
|
+
};
|
|
217
|
+
case "MODEL_NOT_ALLOWED":
|
|
218
|
+
return {
|
|
219
|
+
title: "That model is not enabled",
|
|
220
|
+
body: "Pick another model, or add this one to chat.models in lib/site.config.ts.",
|
|
221
|
+
retry: true,
|
|
222
|
+
};
|
|
223
|
+
case "RATE_LIMITED":
|
|
224
|
+
case "PROVIDER_HTTP_429":
|
|
225
|
+
return {
|
|
226
|
+
title: "Rate limit reached",
|
|
227
|
+
body: "Too many replies in the last hour. Try again later.",
|
|
228
|
+
retry: true,
|
|
229
|
+
};
|
|
230
|
+
case "PROVIDER_HTTP_401":
|
|
231
|
+
case "PROVIDER_HTTP_403":
|
|
232
|
+
return {
|
|
233
|
+
title: "The provider rejected the API key",
|
|
234
|
+
body: "Check the key in .env and restart the server.",
|
|
235
|
+
retry: true,
|
|
236
|
+
};
|
|
237
|
+
case "PROVIDER_UNREACHABLE":
|
|
238
|
+
return {
|
|
239
|
+
title: "Could not reach the model provider",
|
|
240
|
+
body: "Check the network connection and try again.",
|
|
241
|
+
retry: true,
|
|
242
|
+
};
|
|
243
|
+
default:
|
|
244
|
+
return {
|
|
245
|
+
title: "The reply failed",
|
|
246
|
+
body: "Something went wrong while generating this reply. Try again.",
|
|
247
|
+
retry: true,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/* ------------------------------- guests -------------------------------- */
|
|
253
|
+
|
|
254
|
+
export function isGuestId(userId: string | null | undefined): boolean {
|
|
255
|
+
return typeof userId === "string" && userId.startsWith("guest_");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/* ------------------------------ redirects ------------------------------ */
|
|
259
|
+
|
|
260
|
+
// A post-sign-in destination from `?next=`. Only same-site paths are
|
|
261
|
+
// accepted, so the parameter cannot send a user to another origin.
|
|
262
|
+
export function safeNextPath(next: string | null | undefined): string {
|
|
263
|
+
if (!next) return "/";
|
|
264
|
+
// Any control character is refused: browsers strip tabs and newlines while
|
|
265
|
+
// parsing, which turns "/\t/evil.test" into "//evil.test".
|
|
266
|
+
if (/[\u0000-\u001f\u007f\\]/.test(next)) return "/";
|
|
267
|
+
if (!next.startsWith("/") || next.startsWith("//")) return "/";
|
|
268
|
+
const base = "http://same.invalid";
|
|
269
|
+
let url: URL;
|
|
270
|
+
try {
|
|
271
|
+
url = new URL(next, base);
|
|
272
|
+
} catch {
|
|
273
|
+
return "/";
|
|
274
|
+
}
|
|
275
|
+
if (url.origin !== base) return "/";
|
|
276
|
+
return url.pathname + url.search + url.hash;
|
|
277
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// One-time codes for moving a guest's history to an account. The code is 32
|
|
2
|
+
// random bytes as hex; only its SHA-256 hash is stored.
|
|
3
|
+
|
|
4
|
+
export const CLAIM_TTL_MS = 15 * 60 * 1000;
|
|
5
|
+
|
|
6
|
+
export function newClaimCode(): string {
|
|
7
|
+
const bytes = new Uint8Array(32);
|
|
8
|
+
crypto.getRandomValues(bytes);
|
|
9
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function hashClaimCode(code: string): Promise<string> {
|
|
13
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(code));
|
|
14
|
+
return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
15
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// Example conversations for a fresh install with no model provider. The
|
|
2
|
+
// `seedExamples` function writes them into a visitor's history (marked
|
|
3
|
+
// `example: true`) so the sidebar and thread show real layouts before a key is
|
|
4
|
+
// set. The thread labels each one as an example written for the template; none
|
|
5
|
+
// of this text came from a model. Delete this file and `functions/seedExamples.ts`
|
|
6
|
+
// once your app has a provider.
|
|
7
|
+
|
|
8
|
+
export interface ExampleMessage {
|
|
9
|
+
role: "user" | "assistant";
|
|
10
|
+
content: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ExampleConversation {
|
|
14
|
+
title: string;
|
|
15
|
+
// How long ago the conversation happened, in hours. Spreads the examples
|
|
16
|
+
// across the sidebar's day groups.
|
|
17
|
+
hoursAgo: number;
|
|
18
|
+
messages: ExampleMessage[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const exampleConversations: ExampleConversation[] = [
|
|
22
|
+
{
|
|
23
|
+
title: "Debounce a search input in React",
|
|
24
|
+
hoursAgo: 2,
|
|
25
|
+
messages: [
|
|
26
|
+
{
|
|
27
|
+
role: "user",
|
|
28
|
+
content:
|
|
29
|
+
"My search box fires a request on every keystroke. How do I debounce it in React without a library, and cancel requests that come back out of order?",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
role: "assistant",
|
|
33
|
+
content: `There are two separate problems here, and they need two separate fixes:
|
|
34
|
+
|
|
35
|
+
1. **Too many requests.** Debounce the *value*, so a request only starts after the user pauses typing.
|
|
36
|
+
2. **Out-of-order responses.** A slow response for \`"rea"\` can land after a fast one for \`"react"\`. Cancel the previous request with an \`AbortController\` when a new one starts.
|
|
37
|
+
|
|
38
|
+
## A \`useDebouncedValue\` hook
|
|
39
|
+
|
|
40
|
+
Debouncing the value (not the handler) keeps the input responsive: the text field updates on every keystroke, and only the derived value waits.
|
|
41
|
+
|
|
42
|
+
\`\`\`tsx
|
|
43
|
+
import { useEffect, useState } from "react";
|
|
44
|
+
|
|
45
|
+
export function useDebouncedValue<T>(value: T, delayMs = 250): T {
|
|
46
|
+
const [debounced, setDebounced] = useState(value);
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
const id = setTimeout(() => setDebounced(value), delayMs);
|
|
50
|
+
// A new keystroke clears the pending timer before it fires.
|
|
51
|
+
return () => clearTimeout(id);
|
|
52
|
+
}, [value, delayMs]);
|
|
53
|
+
|
|
54
|
+
return debounced;
|
|
55
|
+
}
|
|
56
|
+
\`\`\`
|
|
57
|
+
|
|
58
|
+
## Fetch with cancellation
|
|
59
|
+
|
|
60
|
+
The effect's cleanup runs before the next effect, so aborting there cancels the stale request every time the query changes.
|
|
61
|
+
|
|
62
|
+
\`\`\`tsx
|
|
63
|
+
function Search() {
|
|
64
|
+
const [query, setQuery] = useState("");
|
|
65
|
+
const debounced = useDebouncedValue(query.trim(), 250);
|
|
66
|
+
const [results, setResults] = useState<Result[]>([]);
|
|
67
|
+
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (!debounced) return setResults([]);
|
|
70
|
+
const controller = new AbortController();
|
|
71
|
+
|
|
72
|
+
fetch(\`/api/search?q=\${encodeURIComponent(debounced)}\`, {
|
|
73
|
+
signal: controller.signal,
|
|
74
|
+
})
|
|
75
|
+
.then((res) => res.json())
|
|
76
|
+
.then(setResults)
|
|
77
|
+
.catch((err) => {
|
|
78
|
+
if (err.name !== "AbortError") throw err;
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
return () => controller.abort();
|
|
82
|
+
}, [debounced]);
|
|
83
|
+
|
|
84
|
+
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
|
|
85
|
+
}
|
|
86
|
+
\`\`\`
|
|
87
|
+
|
|
88
|
+
## Choosing the delay
|
|
89
|
+
|
|
90
|
+
| Delay | Feels like | Use it for |
|
|
91
|
+
| --- | --- | --- |
|
|
92
|
+
| 150 ms | Instant | Local filtering, small lists |
|
|
93
|
+
| 250 ms | Responsive | Most server search |
|
|
94
|
+
| 400 ms+ | Laggy | Expensive queries only |
|
|
95
|
+
|
|
96
|
+
A few details that matter in production:
|
|
97
|
+
|
|
98
|
+
- **Trim before debouncing**, so \`"react "\` and \`"react"\` do not trigger two requests.
|
|
99
|
+
- **Ignore \`AbortError\`**. It is the expected result of a cancelled request, not a failure.
|
|
100
|
+
- **Keep the last results visible** while the next request is in flight. Clearing them on every keystroke makes the list flicker.
|
|
101
|
+
|
|
102
|
+
If you later add a data library such as TanStack Query, keep the debounced value and pass it as part of the query key. The library then handles cancellation for you.`,
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
role: "user",
|
|
106
|
+
content: "Does the cleanup still run if the component unmounts mid-request?",
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
role: "assistant",
|
|
110
|
+
content: `Yes. React runs the effect's cleanup on unmount as well as before each re-run, so \`controller.abort()\` fires and the pending \`fetch\` rejects with an \`AbortError\`.
|
|
111
|
+
|
|
112
|
+
That also means \`setResults\` is never called on an unmounted component, so you do not need an \`isMounted\` flag.`,
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
title: "Index for a status and created_at query",
|
|
118
|
+
hoursAgo: 30,
|
|
119
|
+
messages: [
|
|
120
|
+
{
|
|
121
|
+
role: "user",
|
|
122
|
+
content:
|
|
123
|
+
"This Postgres query is slow on a 40M row table:\n\nSELECT * FROM orders WHERE status = 'pending' ORDER BY created_at DESC LIMIT 50;\n\nWhat index should I add?",
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
role: "assistant",
|
|
127
|
+
content: `Add a composite index with the equality column first and the sort column second:
|
|
128
|
+
|
|
129
|
+
\`\`\`sql
|
|
130
|
+
CREATE INDEX CONCURRENTLY orders_status_created_at_idx
|
|
131
|
+
ON orders (status, created_at DESC);
|
|
132
|
+
\`\`\`
|
|
133
|
+
|
|
134
|
+
With this index, Postgres jumps to the \`pending\` entries, reads them already sorted by \`created_at\`, and stops after 50 rows. There is no sort step and no scan of the other statuses.
|
|
135
|
+
|
|
136
|
+
If \`pending\` is a small share of the table, a **partial index** is smaller and faster to maintain:
|
|
137
|
+
|
|
138
|
+
\`\`\`sql
|
|
139
|
+
CREATE INDEX CONCURRENTLY orders_pending_created_at_idx
|
|
140
|
+
ON orders (created_at DESC)
|
|
141
|
+
WHERE status = 'pending';
|
|
142
|
+
\`\`\`
|
|
143
|
+
|
|
144
|
+
Check the plan after you add it:
|
|
145
|
+
|
|
146
|
+
\`\`\`sql
|
|
147
|
+
EXPLAIN (ANALYZE, BUFFERS)
|
|
148
|
+
SELECT * FROM orders
|
|
149
|
+
WHERE status = 'pending'
|
|
150
|
+
ORDER BY created_at DESC
|
|
151
|
+
LIMIT 50;
|
|
152
|
+
\`\`\`
|
|
153
|
+
|
|
154
|
+
You want to see an \`Index Scan\` on the new index and a \`Limit\` node with no \`Sort\` above it. Use \`CONCURRENTLY\` on a live table so the build does not block writes.`,
|
|
155
|
+
},
|
|
156
|
+
],
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
title: "Shorter product update email",
|
|
160
|
+
hoursAgo: 24 * 9,
|
|
161
|
+
messages: [
|
|
162
|
+
{
|
|
163
|
+
role: "user",
|
|
164
|
+
content:
|
|
165
|
+
"Make this shorter: \"We wanted to take a moment to reach out and let you know that, after a lot of careful consideration, we have decided to make some changes to the way our pricing works, which will take effect starting next month.\"",
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
role: "assistant",
|
|
169
|
+
content: `Here are three versions, from shortest to most formal:
|
|
170
|
+
|
|
171
|
+
1. "Our pricing changes next month."
|
|
172
|
+
2. "Starting next month, our pricing is changing. Here is what that means for you."
|
|
173
|
+
3. "We are updating our pricing, effective next month. The details and your options are below."
|
|
174
|
+
|
|
175
|
+
The second works best for most customers: it states the change and promises the details in one line. Put the date and the new prices directly under it.`,
|
|
176
|
+
},
|
|
177
|
+
],
|
|
178
|
+
},
|
|
179
|
+
];
|