@pylonsync/create-pylon 0.3.274 → 0.3.276
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 +80 -0
- package/package.json +1 -1
- package/templates/ARCHETYPES.md +339 -0
- package/templates/agency/.env.example +12 -0
- package/templates/agency/AGENTS.md +61 -0
- package/templates/agency/README.md +90 -0
- package/templates/agency/app/auth-form.tsx +129 -0
- package/templates/agency/app/contact-form.tsx +258 -0
- package/templates/agency/app/dashboard/dashboard-client.tsx +1440 -0
- package/templates/agency/app/dashboard/page.tsx +70 -0
- package/templates/agency/app/error.tsx +26 -0
- package/templates/agency/app/globals.css +148 -0
- package/templates/agency/app/layout.tsx +174 -0
- package/templates/agency/app/login/page.tsx +39 -0
- package/templates/agency/app/not-found.tsx +19 -0
- package/templates/agency/app/page.tsx +249 -0
- package/templates/agency/app/robots.ts +12 -0
- package/templates/agency/app/seeder.tsx +26 -0
- package/templates/agency/app/sitemap.ts +9 -0
- package/templates/agency/app/work/[slug]/page.tsx +182 -0
- package/templates/agency/app/work/page.tsx +83 -0
- package/templates/agency/app.ts +284 -0
- package/templates/agency/components/marketing.tsx +187 -0
- package/templates/agency/components/section-scroller.tsx +35 -0
- package/templates/agency/components/ui/button.tsx +56 -0
- package/templates/agency/components/ui/card.tsx +90 -0
- package/templates/agency/components.json +20 -0
- package/templates/agency/functions/bookInquiry.ts +42 -0
- package/templates/agency/functions/clientsForOwner.ts +27 -0
- package/templates/agency/functions/declineInquiry.ts +41 -0
- package/templates/agency/functions/deleteClient.ts +27 -0
- package/templates/agency/functions/deleteInvoice.ts +19 -0
- package/templates/agency/functions/deleteProject.ts +20 -0
- package/templates/agency/functions/inquiriesForOwner.ts +31 -0
- package/templates/agency/functions/invoicesForOwner.ts +27 -0
- package/templates/agency/functions/seedCapacity.ts +26 -0
- package/templates/agency/functions/seedProjects.ts +41 -0
- package/templates/agency/functions/seedStudioBackoffice.ts +74 -0
- package/templates/agency/functions/setCapacity.ts +32 -0
- package/templates/agency/functions/setInvoiceStatus.ts +27 -0
- package/templates/agency/functions/setProjectFlags.ts +35 -0
- package/templates/agency/functions/submitInquiry.ts +55 -0
- package/templates/agency/functions/upsertClient.ts +73 -0
- package/templates/agency/functions/upsertInvoice.ts +113 -0
- package/templates/agency/functions/upsertProject.ts +97 -0
- package/templates/agency/gitignore +10 -0
- package/templates/agency/lib/agency.ts +189 -0
- package/templates/agency/lib/invoice-pdf.tsx +174 -0
- package/templates/agency/lib/owner.ts +26 -0
- package/templates/agency/lib/site.config.ts +418 -0
- package/templates/agency/lib/utils.ts +10 -0
- package/templates/agency/package.json +35 -0
- package/templates/agency/tsconfig.json +18 -0
- package/templates/ai-chat/.env.example +33 -0
- package/templates/ai-chat/AGENTS.md +61 -0
- package/templates/ai-chat/README.md +99 -0
- package/templates/ai-chat/app/auth-form.tsx +124 -0
- package/templates/ai-chat/app/chat-client.tsx +727 -0
- package/templates/ai-chat/app/error.tsx +26 -0
- package/templates/ai-chat/app/globals.css +148 -0
- package/templates/ai-chat/app/layout.tsx +75 -0
- package/templates/ai-chat/app/login/page.tsx +39 -0
- package/templates/ai-chat/app/not-found.tsx +19 -0
- package/templates/ai-chat/app/page.tsx +23 -0
- package/templates/ai-chat/app.ts +121 -0
- package/templates/ai-chat/components.json +20 -0
- package/templates/ai-chat/functions/deleteConversation.ts +33 -0
- package/templates/ai-chat/gitignore +10 -0
- package/templates/ai-chat/lib/site.config.ts +103 -0
- package/templates/ai-chat/lib/utils.ts +10 -0
- package/templates/ai-chat/package.json +34 -0
- package/templates/ai-chat/tsconfig.json +18 -0
- package/templates/ai-studio/.env.example +19 -0
- package/templates/ai-studio/AGENTS.md +61 -0
- package/templates/ai-studio/README.md +83 -0
- package/templates/ai-studio/app/auth-form.tsx +124 -0
- package/templates/ai-studio/app/error.tsx +26 -0
- package/templates/ai-studio/app/globals.css +148 -0
- package/templates/ai-studio/app/layout.tsx +75 -0
- package/templates/ai-studio/app/login/page.tsx +39 -0
- package/templates/ai-studio/app/not-found.tsx +19 -0
- package/templates/ai-studio/app/page.tsx +34 -0
- package/templates/ai-studio/app/studio-client.tsx +357 -0
- package/templates/ai-studio/app.ts +108 -0
- package/templates/ai-studio/components.json +20 -0
- package/templates/ai-studio/functions/_getGeneration.ts +25 -0
- package/templates/ai-studio/functions/_updateGeneration.ts +37 -0
- package/templates/ai-studio/functions/generate.ts +42 -0
- package/templates/ai-studio/functions/pollGeneration.ts +134 -0
- package/templates/ai-studio/gitignore +10 -0
- package/templates/ai-studio/lib/site.config.ts +80 -0
- package/templates/ai-studio/lib/studio.ts +52 -0
- package/templates/ai-studio/lib/utils.ts +10 -0
- package/templates/ai-studio/package.json +34 -0
- package/templates/ai-studio/tsconfig.json +18 -0
- package/templates/creator/.env.example +12 -0
- package/templates/creator/AGENTS.md +61 -0
- package/templates/creator/README.md +67 -0
- package/templates/creator/app/auth-form.tsx +129 -0
- package/templates/creator/app/dashboard/dashboard-client.tsx +297 -0
- package/templates/creator/app/dashboard/page.tsx +70 -0
- package/templates/creator/app/error.tsx +26 -0
- package/templates/creator/app/globals.css +148 -0
- package/templates/creator/app/layout.tsx +160 -0
- package/templates/creator/app/login/page.tsx +39 -0
- package/templates/creator/app/newsletter-signup.tsx +162 -0
- package/templates/creator/app/not-found.tsx +19 -0
- package/templates/creator/app/page.tsx +160 -0
- package/templates/creator/app/robots.ts +12 -0
- package/templates/creator/app/sitemap.ts +9 -0
- package/templates/creator/app.ts +134 -0
- package/templates/creator/components/marketing.tsx +148 -0
- package/templates/creator/components/section-scroller.tsx +35 -0
- package/templates/creator/components/ui/button.tsx +56 -0
- package/templates/creator/components/ui/card.tsx +90 -0
- package/templates/creator/components.json +20 -0
- package/templates/creator/functions/subscribe.ts +82 -0
- package/templates/creator/functions/subscriberStats.ts +75 -0
- package/templates/creator/gitignore +10 -0
- package/templates/creator/lib/owner.ts +26 -0
- package/templates/creator/lib/site.config.ts +173 -0
- package/templates/creator/lib/stats.ts +30 -0
- package/templates/creator/lib/utils.ts +10 -0
- package/templates/creator/package.json +34 -0
- package/templates/creator/tsconfig.json +18 -0
- package/templates/default/app/layout.tsx +26 -27
- package/templates/default/app/page.tsx +90 -274
- package/templates/default/lib/products.ts +9 -122
- package/templates/default/lib/site.config.ts +739 -0
- package/templates/default/lib/site.ts +14 -261
- package/templates/directory/.env.example +12 -0
- package/templates/directory/AGENTS.md +61 -0
- package/templates/directory/README.md +80 -0
- package/templates/directory/app/auth-form.tsx +129 -0
- package/templates/directory/app/dashboard/dashboard-client.tsx +205 -0
- package/templates/directory/app/dashboard/page.tsx +70 -0
- package/templates/directory/app/directory-browse.tsx +328 -0
- package/templates/directory/app/error.tsx +26 -0
- package/templates/directory/app/globals.css +148 -0
- package/templates/directory/app/layout.tsx +171 -0
- package/templates/directory/app/login/page.tsx +39 -0
- package/templates/directory/app/not-found.tsx +19 -0
- package/templates/directory/app/page.tsx +50 -0
- package/templates/directory/app/robots.ts +12 -0
- package/templates/directory/app/sitemap.ts +9 -0
- package/templates/directory/app/submit/page.tsx +30 -0
- package/templates/directory/app/submit-form.tsx +151 -0
- package/templates/directory/app.ts +146 -0
- package/templates/directory/components/marketing.tsx +148 -0
- package/templates/directory/components/section-scroller.tsx +35 -0
- package/templates/directory/components/ui/button.tsx +56 -0
- package/templates/directory/components/ui/card.tsx +90 -0
- package/templates/directory/components.json +20 -0
- package/templates/directory/functions/approveSubmission.ts +45 -0
- package/templates/directory/functions/rejectSubmission.ts +20 -0
- package/templates/directory/functions/seedListings.ts +33 -0
- package/templates/directory/functions/submissionsForOwner.ts +29 -0
- package/templates/directory/functions/submitListing.ts +63 -0
- package/templates/directory/functions/upvote.ts +24 -0
- package/templates/directory/gitignore +10 -0
- package/templates/directory/lib/directory.ts +45 -0
- package/templates/directory/lib/owner.ts +26 -0
- package/templates/directory/lib/site.config.ts +130 -0
- package/templates/directory/lib/utils.ts +10 -0
- package/templates/directory/package.json +34 -0
- package/templates/directory/tsconfig.json +18 -0
- package/templates/local-service/.env.example +12 -0
- package/templates/local-service/AGENTS.md +61 -0
- package/templates/local-service/README.md +82 -0
- package/templates/local-service/app/auth-form.tsx +129 -0
- package/templates/local-service/app/booking-widget.tsx +399 -0
- package/templates/local-service/app/dashboard/dashboard-client.tsx +304 -0
- package/templates/local-service/app/dashboard/page.tsx +63 -0
- package/templates/local-service/app/error.tsx +26 -0
- package/templates/local-service/app/globals.css +148 -0
- package/templates/local-service/app/layout.tsx +151 -0
- package/templates/local-service/app/login/page.tsx +39 -0
- package/templates/local-service/app/not-found.tsx +19 -0
- package/templates/local-service/app/page.tsx +233 -0
- package/templates/local-service/app/robots.ts +12 -0
- package/templates/local-service/app/sitemap.ts +9 -0
- package/templates/local-service/app.ts +131 -0
- package/templates/local-service/components/marketing.tsx +162 -0
- package/templates/local-service/components/section-scroller.tsx +35 -0
- package/templates/local-service/components/ui/button.tsx +56 -0
- package/templates/local-service/components/ui/card.tsx +90 -0
- package/templates/local-service/components.json +20 -0
- package/templates/local-service/functions/bookingsForOwner.ts +30 -0
- package/templates/local-service/functions/cancelBooking.ts +27 -0
- package/templates/local-service/functions/confirmBooking.ts +18 -0
- package/templates/local-service/functions/createBooking.ts +98 -0
- package/templates/local-service/gitignore +10 -0
- package/templates/local-service/lib/booking.ts +24 -0
- package/templates/local-service/lib/owner.ts +26 -0
- package/templates/local-service/lib/site.config.ts +232 -0
- package/templates/local-service/lib/slots.ts +97 -0
- package/templates/local-service/lib/utils.ts +10 -0
- package/templates/local-service/package.json +34 -0
- package/templates/local-service/tsconfig.json +18 -0
- package/templates/marketplace/.env.example +9 -0
- package/templates/marketplace/AGENTS.md +61 -0
- package/templates/marketplace/README.md +78 -0
- package/templates/marketplace/app/_components/CategoryIcon.tsx +40 -0
- package/templates/marketplace/app/error.tsx +26 -0
- package/templates/marketplace/app/globals.css +64 -0
- package/templates/marketplace/app/layout.tsx +60 -0
- package/templates/marketplace/app/listing/[id]/page.tsx +163 -0
- package/templates/marketplace/app/me/page.tsx +15 -0
- package/templates/marketplace/app/not-found.tsx +20 -0
- package/templates/marketplace/app/page.tsx +159 -0
- package/templates/marketplace/app/robots.ts +12 -0
- package/templates/marketplace/app/sell/page.tsx +26 -0
- package/templates/marketplace/app/sitemap.ts +14 -0
- package/templates/marketplace/app.ts +190 -0
- package/templates/marketplace/client/AuthNav.tsx +46 -0
- package/templates/marketplace/client/LiveTicker.tsx +104 -0
- package/templates/marketplace/client/LoginCard.tsx +130 -0
- package/templates/marketplace/client/MarketProvider.tsx +148 -0
- package/templates/marketplace/client/MyMarket.tsx +180 -0
- package/templates/marketplace/client/OfferPanel.tsx +355 -0
- package/templates/marketplace/client/SeedOnEmpty.tsx +26 -0
- package/templates/marketplace/client/SellForm.tsx +160 -0
- package/templates/marketplace/client/WatchButton.tsx +88 -0
- package/templates/marketplace/client/market.ts +341 -0
- package/templates/marketplace/functions/buyNow.ts +78 -0
- package/templates/marketplace/functions/makeOffer.ts +65 -0
- package/templates/marketplace/functions/respondToOffer.ts +62 -0
- package/templates/marketplace/functions/seedMarket.ts +90 -0
- package/templates/marketplace/gitignore +10 -0
- package/templates/marketplace/package.json +35 -0
- package/templates/marketplace/tsconfig.json +14 -0
- package/templates/marketplace/ui/badge.tsx +30 -0
- package/templates/marketplace/ui/button.tsx +49 -0
- package/templates/marketplace/ui/card.tsx +48 -0
- package/templates/marketplace/ui/input.tsx +17 -0
- package/templates/marketplace/ui/label.tsx +18 -0
- package/templates/marketplace/ui/textarea.tsx +17 -0
- package/templates/marketplace/ui/tokens.css +32 -0
- package/templates/marketplace/ui/utils.ts +6 -0
- package/templates/restaurant/.env.example +12 -0
- package/templates/restaurant/AGENTS.md +61 -0
- package/templates/restaurant/README.md +77 -0
- package/templates/restaurant/app/auth-form.tsx +129 -0
- package/templates/restaurant/app/dashboard/dashboard-client.tsx +263 -0
- package/templates/restaurant/app/dashboard/page.tsx +59 -0
- package/templates/restaurant/app/error.tsx +26 -0
- package/templates/restaurant/app/globals.css +148 -0
- package/templates/restaurant/app/layout.tsx +151 -0
- package/templates/restaurant/app/login/page.tsx +39 -0
- package/templates/restaurant/app/not-found.tsx +19 -0
- package/templates/restaurant/app/page.tsx +194 -0
- package/templates/restaurant/app/reservation-widget.tsx +359 -0
- package/templates/restaurant/app/robots.ts +12 -0
- package/templates/restaurant/app/sitemap.ts +9 -0
- package/templates/restaurant/app.ts +115 -0
- package/templates/restaurant/components/marketing.tsx +162 -0
- package/templates/restaurant/components/section-scroller.tsx +35 -0
- package/templates/restaurant/components/ui/button.tsx +56 -0
- package/templates/restaurant/components/ui/card.tsx +90 -0
- package/templates/restaurant/components.json +20 -0
- package/templates/restaurant/functions/cancelReservation.ts +26 -0
- package/templates/restaurant/functions/confirmReservation.ts +17 -0
- package/templates/restaurant/functions/createReservation.ts +92 -0
- package/templates/restaurant/functions/reservationsForOwner.ts +28 -0
- package/templates/restaurant/gitignore +10 -0
- package/templates/restaurant/lib/owner.ts +26 -0
- package/templates/restaurant/lib/reservation.ts +22 -0
- package/templates/restaurant/lib/site.config.ts +218 -0
- package/templates/restaurant/lib/slots.ts +55 -0
- package/templates/restaurant/lib/utils.ts +10 -0
- package/templates/restaurant/package.json +34 -0
- package/templates/restaurant/tsconfig.json +18 -0
- package/templates/shop/.env.example +32 -0
- package/templates/shop/AGENTS.md +61 -0
- package/templates/shop/README.md +102 -0
- package/templates/shop/app/auth-form.tsx +129 -0
- package/templates/shop/app/dashboard/dashboard-client.tsx +264 -0
- package/templates/shop/app/dashboard/page.tsx +59 -0
- package/templates/shop/app/error.tsx +26 -0
- package/templates/shop/app/globals.css +148 -0
- package/templates/shop/app/layout.tsx +160 -0
- package/templates/shop/app/login/page.tsx +39 -0
- package/templates/shop/app/not-found.tsx +19 -0
- package/templates/shop/app/page.tsx +95 -0
- package/templates/shop/app/robots.ts +12 -0
- package/templates/shop/app/shop-client.tsx +436 -0
- package/templates/shop/app/sitemap.ts +9 -0
- package/templates/shop/app/success/page.tsx +33 -0
- package/templates/shop/app.ts +134 -0
- package/templates/shop/components/marketing.tsx +96 -0
- package/templates/shop/components/section-scroller.tsx +35 -0
- package/templates/shop/components/ui/button.tsx +56 -0
- package/templates/shop/components/ui/card.tsx +90 -0
- package/templates/shop/components.json +20 -0
- package/templates/shop/functions/cancelOrder.ts +33 -0
- package/templates/shop/functions/checkout.ts +130 -0
- package/templates/shop/functions/fulfillOrder.ts +17 -0
- package/templates/shop/functions/markGroupPaid.ts +26 -0
- package/templates/shop/functions/ordersForOwner.ts +28 -0
- package/templates/shop/functions/releaseGroup.ts +36 -0
- package/templates/shop/functions/reserveCart.ts +87 -0
- package/templates/shop/functions/restockProduct.ts +23 -0
- package/templates/shop/functions/seedProducts.ts +30 -0
- package/templates/shop/functions/stripeWebhook.ts +72 -0
- package/templates/shop/gitignore +10 -0
- package/templates/shop/lib/owner.ts +26 -0
- package/templates/shop/lib/shop.ts +45 -0
- package/templates/shop/lib/site.config.ts +198 -0
- package/templates/shop/lib/utils.ts +10 -0
- package/templates/shop/package.json +35 -0
- package/templates/shop/tsconfig.json +18 -0
- package/templates/waitlist/.env.example +12 -0
- package/templates/waitlist/AGENTS.md +61 -0
- package/templates/waitlist/README.md +81 -0
- package/templates/waitlist/app/auth-form.tsx +129 -0
- package/templates/waitlist/app/dashboard/dashboard-client.tsx +297 -0
- package/templates/waitlist/app/dashboard/page.tsx +70 -0
- package/templates/waitlist/app/error.tsx +26 -0
- package/templates/waitlist/app/globals.css +148 -0
- package/templates/waitlist/app/layout.tsx +158 -0
- package/templates/waitlist/app/login/page.tsx +39 -0
- package/templates/waitlist/app/not-found.tsx +19 -0
- package/templates/waitlist/app/page.tsx +119 -0
- package/templates/waitlist/app/robots.ts +12 -0
- package/templates/waitlist/app/sitemap.ts +9 -0
- package/templates/waitlist/app/waitlist-hero.tsx +219 -0
- package/templates/waitlist/app.ts +134 -0
- package/templates/waitlist/components/marketing.tsx +96 -0
- package/templates/waitlist/components/ui/button.tsx +56 -0
- package/templates/waitlist/components/ui/card.tsx +90 -0
- package/templates/waitlist/components.json +20 -0
- package/templates/waitlist/functions/joinWaitlist.ts +82 -0
- package/templates/waitlist/functions/waitlistStats.ts +75 -0
- package/templates/waitlist/gitignore +10 -0
- package/templates/waitlist/lib/owner.ts +26 -0
- package/templates/waitlist/lib/site.config.ts +178 -0
- package/templates/waitlist/lib/stats.ts +30 -0
- package/templates/waitlist/lib/utils.ts +10 -0
- package/templates/waitlist/package.json +34 -0
- package/templates/waitlist/tsconfig.json +18 -0
|
@@ -0,0 +1,727 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { db, callFn } from "@pylonsync/react";
|
|
5
|
+
import { siteConfig } from "@/lib/site.config";
|
|
6
|
+
|
|
7
|
+
// The chat app — a client island, rendered only for a SIGNED-IN user (the page
|
|
8
|
+
// redirects anyone else to /login). Conversations + messages are sync-backed
|
|
9
|
+
// owner-scoped entities (`db.useQuery`), private to your account and in lockstep
|
|
10
|
+
// across your tabs + devices. Sending streams tokens from the built-in
|
|
11
|
+
// `POST /api/ai/stream` (SSE) — your PYLON_AI_API_KEY never reaches the browser.
|
|
12
|
+
//
|
|
13
|
+
// All state lives in <ChatInner> (which owns `currentId`) — the thread is a
|
|
14
|
+
// presentational child. That's deliberate: creating a conversation mid-send
|
|
15
|
+
// changes `currentId`, and if the thread remounted on that change it would kill
|
|
16
|
+
// the in-flight stream. One owner, no remounts.
|
|
17
|
+
|
|
18
|
+
interface ConversationRow {
|
|
19
|
+
id: string;
|
|
20
|
+
userId: string;
|
|
21
|
+
title: string;
|
|
22
|
+
createdAt: string;
|
|
23
|
+
}
|
|
24
|
+
interface MessageRow {
|
|
25
|
+
id: string;
|
|
26
|
+
conversationId: string;
|
|
27
|
+
userId: string;
|
|
28
|
+
role: string;
|
|
29
|
+
content: string;
|
|
30
|
+
createdAt: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function ChatApp() {
|
|
34
|
+
return <ChatInner />;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function ChatInner() {
|
|
38
|
+
const { chat } = siteConfig;
|
|
39
|
+
const { data: conversations } = db.useQuery<ConversationRow>("Conversation", {
|
|
40
|
+
orderBy: { createdAt: "desc" },
|
|
41
|
+
});
|
|
42
|
+
const [currentId, setCurrentId] = useState<string | null>(null);
|
|
43
|
+
const { data: messages } = db.useQuery<MessageRow>("Message", {
|
|
44
|
+
where: { conversationId: currentId ?? "__none__" },
|
|
45
|
+
orderBy: { createdAt: "asc" },
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const [streaming, setStreaming] = useState<string | null>(null);
|
|
49
|
+
const [sending, setSending] = useState(false);
|
|
50
|
+
const [notice, setNotice] = useState<string | null>(null);
|
|
51
|
+
const [input, setInput] = useState("");
|
|
52
|
+
const [model, setModel] = useState(chat.defaultModel);
|
|
53
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
54
|
+
const initialized = useRef(false);
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (!initialized.current && conversations.length > 0) {
|
|
58
|
+
initialized.current = true;
|
|
59
|
+
setCurrentId(conversations[0].id);
|
|
60
|
+
}
|
|
61
|
+
}, [conversations]);
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
|
|
65
|
+
}, [messages.length, streaming]);
|
|
66
|
+
|
|
67
|
+
function selectConversation(id: string) {
|
|
68
|
+
setCurrentId(id);
|
|
69
|
+
setStreaming(null);
|
|
70
|
+
setNotice(null);
|
|
71
|
+
}
|
|
72
|
+
function newChat() {
|
|
73
|
+
setCurrentId(null);
|
|
74
|
+
setStreaming(null);
|
|
75
|
+
setNotice(null);
|
|
76
|
+
}
|
|
77
|
+
async function renameConversation(id: string, title: string) {
|
|
78
|
+
const t = title.trim().slice(0, 80);
|
|
79
|
+
if (!t) return;
|
|
80
|
+
await db.update("Conversation", id, { title: t });
|
|
81
|
+
}
|
|
82
|
+
async function deleteConversation(id: string) {
|
|
83
|
+
if (currentId === id) setCurrentId(null);
|
|
84
|
+
try {
|
|
85
|
+
await callFn("deleteConversation", { conversationId: id });
|
|
86
|
+
} catch {
|
|
87
|
+
/* ignore */
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function send(text: string) {
|
|
92
|
+
const trimmed = text.trim();
|
|
93
|
+
if (!trimmed || sending) return;
|
|
94
|
+
setSending(true);
|
|
95
|
+
setNotice(null);
|
|
96
|
+
setInput("");
|
|
97
|
+
|
|
98
|
+
const history = messages.map((m) => ({ role: m.role, content: m.content }));
|
|
99
|
+
let convId = currentId;
|
|
100
|
+
if (!convId) {
|
|
101
|
+
convId = await db.insert("Conversation", { title: trimmed.slice(0, 48) });
|
|
102
|
+
setCurrentId(convId);
|
|
103
|
+
}
|
|
104
|
+
await db.insert("Message", { conversationId: convId, role: "user", content: trimmed });
|
|
105
|
+
|
|
106
|
+
const payload = [
|
|
107
|
+
{ role: "system", content: chat.systemPrompt },
|
|
108
|
+
...history,
|
|
109
|
+
{ role: "user", content: trimmed },
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
let acc = "";
|
|
113
|
+
try {
|
|
114
|
+
await streamCompletion(payload, model, (delta) => {
|
|
115
|
+
acc += delta;
|
|
116
|
+
setStreaming(acc);
|
|
117
|
+
});
|
|
118
|
+
if (acc.trim()) {
|
|
119
|
+
await db.insert("Message", { conversationId: convId, role: "assistant", content: acc });
|
|
120
|
+
}
|
|
121
|
+
} catch (err) {
|
|
122
|
+
const code = (err as { code?: string })?.code;
|
|
123
|
+
if (code === "AI_NOT_CONFIGURED") {
|
|
124
|
+
setNotice(
|
|
125
|
+
"AI isn't configured yet. Set PYLON_AI_PROVIDER and PYLON_AI_API_KEY in .env, then restart — see the README.",
|
|
126
|
+
);
|
|
127
|
+
} else if (code === "MODEL_OVERRIDE_FORBIDDEN" || code === "MODEL_NOT_ALLOWED") {
|
|
128
|
+
setNotice(
|
|
129
|
+
"That model isn't enabled. Add it to PYLON_AI_MODELS_ALLOWED in .env (comma-separated), then restart.",
|
|
130
|
+
);
|
|
131
|
+
} else if (code === "RATE_LIMITED") {
|
|
132
|
+
setNotice("You've hit the AI rate limit — try again in a little while.");
|
|
133
|
+
} else {
|
|
134
|
+
setNotice("Something went wrong reaching the model. Try again.");
|
|
135
|
+
}
|
|
136
|
+
} finally {
|
|
137
|
+
setStreaming(null);
|
|
138
|
+
setSending(false);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const empty = messages.length === 0 && streaming === null;
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<div className="flex h-[calc(100vh-3.5rem)]">
|
|
146
|
+
<Sidebar
|
|
147
|
+
conversations={conversations}
|
|
148
|
+
currentId={currentId}
|
|
149
|
+
onSelect={selectConversation}
|
|
150
|
+
onNew={newChat}
|
|
151
|
+
onRename={renameConversation}
|
|
152
|
+
onDelete={deleteConversation}
|
|
153
|
+
/>
|
|
154
|
+
<div className="flex flex-1 flex-col bg-white">
|
|
155
|
+
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
|
156
|
+
<div className="mx-auto max-w-3xl px-4 py-6">
|
|
157
|
+
{empty ? (
|
|
158
|
+
<EmptyState onPick={send} />
|
|
159
|
+
) : (
|
|
160
|
+
<div className="space-y-5">
|
|
161
|
+
{messages.map((m) => (
|
|
162
|
+
<Bubble key={m.id} role={m.role} content={m.content} at={m.createdAt} />
|
|
163
|
+
))}
|
|
164
|
+
{streaming !== null ? <Bubble role="assistant" content={streaming || "…"} streaming /> : null}
|
|
165
|
+
</div>
|
|
166
|
+
)}
|
|
167
|
+
{notice ? (
|
|
168
|
+
<div className="mt-5 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-[13px] leading-relaxed text-amber-800">
|
|
169
|
+
{notice}
|
|
170
|
+
</div>
|
|
171
|
+
) : null}
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
|
|
175
|
+
<Composer
|
|
176
|
+
value={input}
|
|
177
|
+
onChange={setInput}
|
|
178
|
+
onSend={() => send(input)}
|
|
179
|
+
disabled={sending}
|
|
180
|
+
placeholder={chat.inputPlaceholder}
|
|
181
|
+
model={model}
|
|
182
|
+
onModelChange={setModel}
|
|
183
|
+
/>
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function Sidebar({
|
|
190
|
+
conversations,
|
|
191
|
+
currentId,
|
|
192
|
+
onSelect,
|
|
193
|
+
onNew,
|
|
194
|
+
onRename,
|
|
195
|
+
onDelete,
|
|
196
|
+
}: {
|
|
197
|
+
conversations: ConversationRow[];
|
|
198
|
+
currentId: string | null;
|
|
199
|
+
onSelect: (id: string) => void;
|
|
200
|
+
onNew: () => void;
|
|
201
|
+
onRename: (id: string, title: string) => void;
|
|
202
|
+
onDelete: (id: string) => void;
|
|
203
|
+
}) {
|
|
204
|
+
return (
|
|
205
|
+
<aside className="hidden w-64 shrink-0 flex-col border-r border-zinc-200 bg-paper sm:flex">
|
|
206
|
+
<div className="p-3">
|
|
207
|
+
<button
|
|
208
|
+
type="button"
|
|
209
|
+
onClick={onNew}
|
|
210
|
+
className="flex w-full items-center justify-center gap-2 rounded-lg bg-brand px-3 py-2 text-[13.5px] font-medium text-white transition-opacity hover:opacity-90"
|
|
211
|
+
>
|
|
212
|
+
<PlusIcon /> New chat
|
|
213
|
+
</button>
|
|
214
|
+
</div>
|
|
215
|
+
<nav className="flex-1 overflow-y-auto px-2 pb-3">
|
|
216
|
+
{conversations.length === 0 ? (
|
|
217
|
+
<p className="px-2 py-4 text-[12.5px] text-zinc-400">No conversations yet.</p>
|
|
218
|
+
) : (
|
|
219
|
+
<ul className="space-y-0.5">
|
|
220
|
+
{conversations.map((c) => (
|
|
221
|
+
<ConversationRow
|
|
222
|
+
key={c.id}
|
|
223
|
+
convo={c}
|
|
224
|
+
active={c.id === currentId}
|
|
225
|
+
onSelect={() => onSelect(c.id)}
|
|
226
|
+
onRename={(t) => onRename(c.id, t)}
|
|
227
|
+
onDelete={() => onDelete(c.id)}
|
|
228
|
+
/>
|
|
229
|
+
))}
|
|
230
|
+
</ul>
|
|
231
|
+
)}
|
|
232
|
+
</nav>
|
|
233
|
+
</aside>
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function ConversationRow({
|
|
238
|
+
convo,
|
|
239
|
+
active,
|
|
240
|
+
onSelect,
|
|
241
|
+
onRename,
|
|
242
|
+
onDelete,
|
|
243
|
+
}: {
|
|
244
|
+
convo: ConversationRow;
|
|
245
|
+
active: boolean;
|
|
246
|
+
onSelect: () => void;
|
|
247
|
+
onRename: (title: string) => void;
|
|
248
|
+
onDelete: () => void;
|
|
249
|
+
}) {
|
|
250
|
+
const [editing, setEditing] = useState(false);
|
|
251
|
+
const [draft, setDraft] = useState(convo.title);
|
|
252
|
+
const [confirming, setConfirming] = useState(false);
|
|
253
|
+
|
|
254
|
+
function commit() {
|
|
255
|
+
setEditing(false);
|
|
256
|
+
if (draft.trim() && draft.trim() !== convo.title) onRename(draft);
|
|
257
|
+
else setDraft(convo.title);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (editing) {
|
|
261
|
+
return (
|
|
262
|
+
<li>
|
|
263
|
+
<input
|
|
264
|
+
autoFocus
|
|
265
|
+
value={draft}
|
|
266
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
267
|
+
onBlur={commit}
|
|
268
|
+
onKeyDown={(e) => {
|
|
269
|
+
if (e.key === "Enter") commit();
|
|
270
|
+
if (e.key === "Escape") {
|
|
271
|
+
setDraft(convo.title);
|
|
272
|
+
setEditing(false);
|
|
273
|
+
}
|
|
274
|
+
}}
|
|
275
|
+
className="w-full rounded-lg border border-brand bg-white px-2.5 py-2 text-[13.5px] outline-none ring-2 ring-brand/20"
|
|
276
|
+
/>
|
|
277
|
+
</li>
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return (
|
|
282
|
+
<li className="group relative">
|
|
283
|
+
<button
|
|
284
|
+
type="button"
|
|
285
|
+
onClick={onSelect}
|
|
286
|
+
className={
|
|
287
|
+
"flex w-full items-center rounded-lg py-2 pl-2.5 pr-14 text-left text-[13.5px] transition-colors " +
|
|
288
|
+
(active ? "bg-brand-soft font-medium text-brand" : "text-zinc-600 hover:bg-zinc-100")
|
|
289
|
+
}
|
|
290
|
+
title={convo.title}
|
|
291
|
+
>
|
|
292
|
+
<span className="truncate">{convo.title || "New chat"}</span>
|
|
293
|
+
</button>
|
|
294
|
+
{/* hover actions */}
|
|
295
|
+
<div className="absolute right-1.5 top-1/2 hidden -translate-y-1/2 items-center gap-0.5 group-hover:flex">
|
|
296
|
+
{confirming ? (
|
|
297
|
+
<button
|
|
298
|
+
type="button"
|
|
299
|
+
onClick={onDelete}
|
|
300
|
+
className="rounded px-1.5 py-0.5 text-[11px] font-medium text-red-600 hover:bg-red-50"
|
|
301
|
+
>
|
|
302
|
+
Sure?
|
|
303
|
+
</button>
|
|
304
|
+
) : (
|
|
305
|
+
<>
|
|
306
|
+
<button
|
|
307
|
+
type="button"
|
|
308
|
+
onClick={() => {
|
|
309
|
+
setDraft(convo.title);
|
|
310
|
+
setEditing(true);
|
|
311
|
+
}}
|
|
312
|
+
aria-label="Rename"
|
|
313
|
+
className="rounded p-1 text-zinc-400 hover:bg-zinc-200 hover:text-zinc-700"
|
|
314
|
+
>
|
|
315
|
+
<PencilIcon />
|
|
316
|
+
</button>
|
|
317
|
+
<button
|
|
318
|
+
type="button"
|
|
319
|
+
onClick={() => {
|
|
320
|
+
setConfirming(true);
|
|
321
|
+
setTimeout(() => setConfirming(false), 2500);
|
|
322
|
+
}}
|
|
323
|
+
aria-label="Delete"
|
|
324
|
+
className="rounded p-1 text-zinc-400 hover:bg-zinc-200 hover:text-red-600"
|
|
325
|
+
>
|
|
326
|
+
<TrashIcon />
|
|
327
|
+
</button>
|
|
328
|
+
</>
|
|
329
|
+
)}
|
|
330
|
+
</div>
|
|
331
|
+
</li>
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function EmptyState({ onPick }: { onPick: (text: string) => void }) {
|
|
336
|
+
const { chat, brand } = siteConfig;
|
|
337
|
+
return (
|
|
338
|
+
<div className="flex flex-col items-center pt-[12vh] text-center">
|
|
339
|
+
<span className="flex size-12 items-center justify-center rounded-2xl bg-brand text-xl font-bold text-white">
|
|
340
|
+
{brand.letter}
|
|
341
|
+
</span>
|
|
342
|
+
<h1 className="mt-5 text-2xl font-semibold tracking-tight text-zinc-900">{chat.emptyHeadline}</h1>
|
|
343
|
+
<p className="mt-2 max-w-md text-[15px] leading-relaxed text-zinc-500">{chat.emptySubcopy}</p>
|
|
344
|
+
<div className="mt-7 grid w-full max-w-xl gap-2 sm:grid-cols-2">
|
|
345
|
+
{chat.suggestions.map((s) => (
|
|
346
|
+
<button
|
|
347
|
+
key={s}
|
|
348
|
+
type="button"
|
|
349
|
+
onClick={() => onPick(s)}
|
|
350
|
+
className="rounded-xl border border-zinc-200 bg-white px-4 py-3 text-left text-[13.5px] text-zinc-600 transition-colors hover:border-brand hover:text-zinc-900"
|
|
351
|
+
>
|
|
352
|
+
{s}
|
|
353
|
+
</button>
|
|
354
|
+
))}
|
|
355
|
+
</div>
|
|
356
|
+
</div>
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function Bubble({
|
|
361
|
+
role,
|
|
362
|
+
content,
|
|
363
|
+
streaming,
|
|
364
|
+
at,
|
|
365
|
+
}: {
|
|
366
|
+
role: string;
|
|
367
|
+
content: string;
|
|
368
|
+
streaming?: boolean;
|
|
369
|
+
at?: string;
|
|
370
|
+
}) {
|
|
371
|
+
const { brand } = siteConfig;
|
|
372
|
+
const isUser = role === "user";
|
|
373
|
+
const [copied, setCopied] = useState(false);
|
|
374
|
+
|
|
375
|
+
async function copy() {
|
|
376
|
+
try {
|
|
377
|
+
await navigator.clipboard.writeText(content);
|
|
378
|
+
setCopied(true);
|
|
379
|
+
setTimeout(() => setCopied(false), 1500);
|
|
380
|
+
} catch {
|
|
381
|
+
/* clipboard unavailable */
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return (
|
|
386
|
+
<div className={"group flex gap-3 " + (isUser ? "flex-row-reverse" : "")}>
|
|
387
|
+
<span
|
|
388
|
+
className={
|
|
389
|
+
"mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full text-[11px] font-semibold " +
|
|
390
|
+
(isUser ? "bg-zinc-200 text-zinc-600" : "bg-brand text-white")
|
|
391
|
+
}
|
|
392
|
+
>
|
|
393
|
+
{isUser ? "You" : brand.letter}
|
|
394
|
+
</span>
|
|
395
|
+
<div className={"flex max-w-[80%] flex-col " + (isUser ? "items-end" : "items-start")}>
|
|
396
|
+
<div
|
|
397
|
+
className={
|
|
398
|
+
"rounded-2xl px-4 py-2.5 text-[14.5px] leading-relaxed " +
|
|
399
|
+
(isUser ? "whitespace-pre-wrap bg-zinc-900 text-white" : "bg-zinc-100 text-zinc-800")
|
|
400
|
+
}
|
|
401
|
+
>
|
|
402
|
+
{isUser || streaming ? (
|
|
403
|
+
<span className="whitespace-pre-wrap">{content}</span>
|
|
404
|
+
) : (
|
|
405
|
+
<Markdown text={content} />
|
|
406
|
+
)}
|
|
407
|
+
{streaming ? <span className="ml-0.5 inline-block h-4 w-1.5 animate-pulse bg-zinc-400 align-middle" /> : null}
|
|
408
|
+
</div>
|
|
409
|
+
{!streaming ? (
|
|
410
|
+
<div className={"mt-1 flex items-center gap-2 px-1 " + (isUser ? "flex-row-reverse" : "")}>
|
|
411
|
+
{at ? <span className="text-[10.5px] text-zinc-300">{relativeTime(at)}</span> : null}
|
|
412
|
+
{!isUser ? (
|
|
413
|
+
<button
|
|
414
|
+
type="button"
|
|
415
|
+
onClick={copy}
|
|
416
|
+
className="text-[10.5px] text-zinc-300 opacity-0 transition-opacity hover:text-zinc-600 group-hover:opacity-100"
|
|
417
|
+
>
|
|
418
|
+
{copied ? "Copied" : "Copy"}
|
|
419
|
+
</button>
|
|
420
|
+
) : null}
|
|
421
|
+
</div>
|
|
422
|
+
) : null}
|
|
423
|
+
</div>
|
|
424
|
+
</div>
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function Composer({
|
|
429
|
+
value,
|
|
430
|
+
onChange,
|
|
431
|
+
onSend,
|
|
432
|
+
disabled,
|
|
433
|
+
placeholder,
|
|
434
|
+
model,
|
|
435
|
+
onModelChange,
|
|
436
|
+
}: {
|
|
437
|
+
value: string;
|
|
438
|
+
onChange: (v: string) => void;
|
|
439
|
+
onSend: () => void;
|
|
440
|
+
disabled: boolean;
|
|
441
|
+
placeholder: string;
|
|
442
|
+
model: string;
|
|
443
|
+
onModelChange: (m: string) => void;
|
|
444
|
+
}) {
|
|
445
|
+
const { models } = siteConfig.chat;
|
|
446
|
+
function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
|
447
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
448
|
+
e.preventDefault();
|
|
449
|
+
onSend();
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return (
|
|
453
|
+
<div className="border-t border-zinc-200 bg-white px-4 py-3">
|
|
454
|
+
<div className="mx-auto flex max-w-3xl items-end gap-2">
|
|
455
|
+
<textarea
|
|
456
|
+
value={value}
|
|
457
|
+
onChange={(e) => onChange(e.target.value)}
|
|
458
|
+
onKeyDown={onKeyDown}
|
|
459
|
+
rows={1}
|
|
460
|
+
placeholder={placeholder}
|
|
461
|
+
aria-label="Message"
|
|
462
|
+
className="max-h-40 flex-1 resize-none rounded-2xl border border-zinc-300 bg-white px-4 py-2.5 text-[14.5px] text-zinc-900 outline-none transition placeholder:text-zinc-400 focus:border-brand focus:ring-2 focus:ring-brand/20"
|
|
463
|
+
/>
|
|
464
|
+
<button
|
|
465
|
+
type="button"
|
|
466
|
+
onClick={onSend}
|
|
467
|
+
disabled={disabled || !value.trim()}
|
|
468
|
+
aria-label="Send"
|
|
469
|
+
className="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-brand text-white transition-opacity hover:opacity-90 disabled:opacity-40"
|
|
470
|
+
>
|
|
471
|
+
<SendIcon />
|
|
472
|
+
</button>
|
|
473
|
+
</div>
|
|
474
|
+
<div className="mx-auto mt-1.5 flex max-w-3xl items-center justify-between gap-3">
|
|
475
|
+
<label className="flex items-center gap-1.5 text-[11px] text-zinc-400">
|
|
476
|
+
<span className="hidden sm:inline">Model</span>
|
|
477
|
+
<select
|
|
478
|
+
value={model}
|
|
479
|
+
onChange={(e) => onModelChange(e.target.value)}
|
|
480
|
+
aria-label="Model"
|
|
481
|
+
className="rounded-md border border-zinc-200 bg-white px-1.5 py-1 text-[11.5px] text-zinc-600 outline-none focus:border-brand"
|
|
482
|
+
>
|
|
483
|
+
{models.map((m) => (
|
|
484
|
+
<option key={m.id} value={m.id}>
|
|
485
|
+
{m.label} · {m.provider}
|
|
486
|
+
</option>
|
|
487
|
+
))}
|
|
488
|
+
</select>
|
|
489
|
+
</label>
|
|
490
|
+
<span className="text-[11px] text-zinc-400">Enter to send · Shift+Enter for a new line</span>
|
|
491
|
+
</div>
|
|
492
|
+
</div>
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/* --------------------------- markdown rendering --------------------------- */
|
|
497
|
+
// A small, dependency-free, XSS-safe Markdown renderer for assistant replies. It
|
|
498
|
+
// builds React elements directly — raw HTML is never injected into the DOM, so
|
|
499
|
+
// model output can't smuggle scripts. Covers what LLMs emit most: fenced code
|
|
500
|
+
// blocks, inline `code`, **bold**, *italic*, links, bullet + numbered lists,
|
|
501
|
+
// headings, and paragraphs. Swap in `react-markdown` for full CommonMark/GFM.
|
|
502
|
+
function Markdown({ text }: { text: string }) {
|
|
503
|
+
return <div className="space-y-2">{renderBlocks(text)}</div>;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function renderBlocks(text: string): React.ReactNode[] {
|
|
507
|
+
const lines = text.replace(/\r\n/g, "\n").split("\n");
|
|
508
|
+
const out: React.ReactNode[] = [];
|
|
509
|
+
let i = 0;
|
|
510
|
+
let key = 0;
|
|
511
|
+
while (i < lines.length) {
|
|
512
|
+
const line = lines[i];
|
|
513
|
+
if (line.trimStart().startsWith("```")) {
|
|
514
|
+
const lang = line.trim().slice(3).trim();
|
|
515
|
+
const code: string[] = [];
|
|
516
|
+
i++;
|
|
517
|
+
while (i < lines.length && !lines[i].trimStart().startsWith("```")) code.push(lines[i++]);
|
|
518
|
+
i++; // closing fence
|
|
519
|
+
out.push(<CodeBlock key={key++} code={code.join("\n")} lang={lang} />);
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
const h = line.match(/^(#{1,3})\s+(.*)$/);
|
|
523
|
+
if (h) {
|
|
524
|
+
const size = h[1].length === 1 ? "text-[17px]" : h[1].length === 2 ? "text-[15.5px]" : "text-[14.5px]";
|
|
525
|
+
out.push(
|
|
526
|
+
<p key={key++} className={`font-semibold text-zinc-900 ${size}`}>
|
|
527
|
+
{renderInline(h[2])}
|
|
528
|
+
</p>,
|
|
529
|
+
);
|
|
530
|
+
i++;
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
if (/^\s*[-*]\s+/.test(line)) {
|
|
534
|
+
const items: string[] = [];
|
|
535
|
+
while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) items.push(lines[i++].replace(/^\s*[-*]\s+/, ""));
|
|
536
|
+
out.push(
|
|
537
|
+
<ul key={key++} className="ml-4 list-disc space-y-1">
|
|
538
|
+
{items.map((it, j) => (
|
|
539
|
+
<li key={j}>{renderInline(it)}</li>
|
|
540
|
+
))}
|
|
541
|
+
</ul>,
|
|
542
|
+
);
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
if (/^\s*\d+\.\s+/.test(line)) {
|
|
546
|
+
const items: string[] = [];
|
|
547
|
+
while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) items.push(lines[i++].replace(/^\s*\d+\.\s+/, ""));
|
|
548
|
+
out.push(
|
|
549
|
+
<ol key={key++} className="ml-4 list-decimal space-y-1">
|
|
550
|
+
{items.map((it, j) => (
|
|
551
|
+
<li key={j}>{renderInline(it)}</li>
|
|
552
|
+
))}
|
|
553
|
+
</ol>,
|
|
554
|
+
);
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
if (line.trim() === "") {
|
|
558
|
+
i++;
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
const para: string[] = [];
|
|
562
|
+
while (
|
|
563
|
+
i < lines.length &&
|
|
564
|
+
lines[i].trim() !== "" &&
|
|
565
|
+
!lines[i].trimStart().startsWith("```") &&
|
|
566
|
+
!/^\s*[-*]\s+/.test(lines[i]) &&
|
|
567
|
+
!/^\s*\d+\.\s+/.test(lines[i]) &&
|
|
568
|
+
!/^#{1,3}\s+/.test(lines[i])
|
|
569
|
+
) {
|
|
570
|
+
para.push(lines[i++]);
|
|
571
|
+
}
|
|
572
|
+
out.push(
|
|
573
|
+
<p key={key++} className="leading-relaxed">
|
|
574
|
+
{para.flatMap((ln, j) => (j === 0 ? renderInline(ln) : [<br key={`b${j}`} />, ...renderInline(ln)]))}
|
|
575
|
+
</p>,
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
return out;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function renderInline(text: string): React.ReactNode[] {
|
|
582
|
+
const nodes: React.ReactNode[] = [];
|
|
583
|
+
const re = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|\[[^\]]+\]\([^)]+\))/g;
|
|
584
|
+
let last = 0;
|
|
585
|
+
let key = 0;
|
|
586
|
+
for (const m of text.matchAll(re)) {
|
|
587
|
+
const idx = m.index ?? 0;
|
|
588
|
+
if (idx > last) nodes.push(text.slice(last, idx));
|
|
589
|
+
const tok = m[0];
|
|
590
|
+
if (tok.startsWith("`")) {
|
|
591
|
+
nodes.push(
|
|
592
|
+
<code key={key++} className="rounded bg-zinc-200/70 px-1 py-0.5 font-mono text-[12.5px]">
|
|
593
|
+
{tok.slice(1, -1)}
|
|
594
|
+
</code>,
|
|
595
|
+
);
|
|
596
|
+
} else if (tok.startsWith("**")) {
|
|
597
|
+
nodes.push(<strong key={key++}>{tok.slice(2, -2)}</strong>);
|
|
598
|
+
} else if (tok.startsWith("*")) {
|
|
599
|
+
nodes.push(<em key={key++}>{tok.slice(1, -1)}</em>);
|
|
600
|
+
} else {
|
|
601
|
+
const lm = tok.match(/\[([^\]]+)\]\(([^)]+)\)/);
|
|
602
|
+
if (lm) {
|
|
603
|
+
nodes.push(
|
|
604
|
+
<a key={key++} href={lm[2]} target="_blank" rel="noopener noreferrer" className="text-brand underline">
|
|
605
|
+
{lm[1]}
|
|
606
|
+
</a>,
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
last = idx + tok.length;
|
|
611
|
+
}
|
|
612
|
+
if (last < text.length) nodes.push(text.slice(last));
|
|
613
|
+
return nodes;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function CodeBlock({ code, lang }: { code: string; lang?: string }) {
|
|
617
|
+
const [copied, setCopied] = useState(false);
|
|
618
|
+
async function copy() {
|
|
619
|
+
try {
|
|
620
|
+
await navigator.clipboard.writeText(code);
|
|
621
|
+
setCopied(true);
|
|
622
|
+
setTimeout(() => setCopied(false), 1500);
|
|
623
|
+
} catch {
|
|
624
|
+
/* ignore */
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
return (
|
|
628
|
+
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-zinc-950">
|
|
629
|
+
<div className="flex items-center justify-between border-b border-white/10 px-3 py-1.5">
|
|
630
|
+
<span className="font-mono text-[10.5px] uppercase tracking-wide text-zinc-400">{lang || "code"}</span>
|
|
631
|
+
<button type="button" onClick={copy} className="text-[10.5px] text-zinc-400 hover:text-white">
|
|
632
|
+
{copied ? "Copied" : "Copy"}
|
|
633
|
+
</button>
|
|
634
|
+
</div>
|
|
635
|
+
<pre className="overflow-x-auto p-3 text-[12.5px] leading-relaxed text-zinc-100">
|
|
636
|
+
<code className="font-mono">{code}</code>
|
|
637
|
+
</pre>
|
|
638
|
+
</div>
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
async function streamCompletion(
|
|
643
|
+
messages: { role: string; content: string }[],
|
|
644
|
+
model: string,
|
|
645
|
+
onDelta: (delta: string) => void,
|
|
646
|
+
): Promise<void> {
|
|
647
|
+
const res = await fetch("/api/ai/stream", {
|
|
648
|
+
method: "POST",
|
|
649
|
+
headers: { "content-type": "application/json" },
|
|
650
|
+
body: JSON.stringify({ messages, model }),
|
|
651
|
+
});
|
|
652
|
+
if (!res.ok || !res.body) {
|
|
653
|
+
let code = `HTTP_${res.status}`;
|
|
654
|
+
try {
|
|
655
|
+
code = (await res.json())?.error?.code ?? code;
|
|
656
|
+
} catch {
|
|
657
|
+
/* ignore */
|
|
658
|
+
}
|
|
659
|
+
throw { code };
|
|
660
|
+
}
|
|
661
|
+
const reader = res.body.getReader();
|
|
662
|
+
const decoder = new TextDecoder();
|
|
663
|
+
let buf = "";
|
|
664
|
+
for (;;) {
|
|
665
|
+
const { done, value } = await reader.read();
|
|
666
|
+
if (done) break;
|
|
667
|
+
buf += decoder.decode(value, { stream: true });
|
|
668
|
+
const lines = buf.split("\n");
|
|
669
|
+
buf = lines.pop() ?? "";
|
|
670
|
+
for (const raw of lines) {
|
|
671
|
+
const line = raw.trim();
|
|
672
|
+
if (!line.startsWith("data:")) continue;
|
|
673
|
+
const data = line.slice(5).trim();
|
|
674
|
+
if (data === "[DONE]") return;
|
|
675
|
+
try {
|
|
676
|
+
const j = JSON.parse(data);
|
|
677
|
+
if (j.error) throw { code: j.error.code ?? "STREAM_ERROR" };
|
|
678
|
+
const delta = j.choices?.[0]?.delta?.content;
|
|
679
|
+
if (delta) onDelta(delta);
|
|
680
|
+
} catch (e) {
|
|
681
|
+
if ((e as { code?: string })?.code) throw e;
|
|
682
|
+
/* ignore keep-alive / partial lines */
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function relativeTime(iso: string): string {
|
|
689
|
+
const t = new Date(iso).getTime();
|
|
690
|
+
if (Number.isNaN(t)) return "";
|
|
691
|
+
const s = Math.max(0, Math.floor((Date.now() - t) / 1000));
|
|
692
|
+
if (s < 45) return "just now";
|
|
693
|
+
const m = Math.floor(s / 60);
|
|
694
|
+
if (m < 60) return `${m}m ago`;
|
|
695
|
+
const h = Math.floor(m / 60);
|
|
696
|
+
if (h < 24) return `${h}h ago`;
|
|
697
|
+
return `${Math.floor(h / 24)}d ago`;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function PlusIcon() {
|
|
701
|
+
return (
|
|
702
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" aria-hidden>
|
|
703
|
+
<path d="M12 5v14M5 12h14" />
|
|
704
|
+
</svg>
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
function SendIcon() {
|
|
708
|
+
return (
|
|
709
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
710
|
+
<path d="M22 2 11 13M22 2l-7 20-4-9-9-4 20-7z" />
|
|
711
|
+
</svg>
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
function PencilIcon() {
|
|
715
|
+
return (
|
|
716
|
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
717
|
+
<path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z" />
|
|
718
|
+
</svg>
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
function TrashIcon() {
|
|
722
|
+
return (
|
|
723
|
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
724
|
+
<path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6" />
|
|
725
|
+
</svg>
|
|
726
|
+
);
|
|
727
|
+
}
|