@pylonsync/create-pylon 0.3.275 → 0.3.277
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 +1 -1
- package/package.json +1 -1
- package/templates/agency/app/dashboard/dashboard-client.tsx +1213 -59
- package/templates/agency/app/layout.tsx +1 -1
- package/templates/agency/app/page.tsx +72 -30
- package/templates/agency/app/seeder.tsx +26 -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 +168 -19
- package/templates/agency/components/marketing.tsx +39 -0
- package/templates/agency/functions/clientsForOwner.ts +27 -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/invoicesForOwner.ts +27 -0
- package/templates/agency/functions/seedProjects.ts +41 -0
- package/templates/agency/functions/seedStudioBackoffice.ts +74 -0
- package/templates/agency/functions/setInvoiceStatus.ts +27 -0
- package/templates/agency/functions/setProjectFlags.ts +35 -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/lib/agency.ts +165 -3
- package/templates/agency/lib/invoice-pdf.tsx +174 -0
- package/templates/agency/lib/site.config.ts +180 -1
- package/templates/agency/package.json +2 -1
- package/templates/ai-chat/app/chat-client.tsx +354 -41
- package/templates/ai-chat/functions/deleteConversation.ts +33 -0
- package/templates/ai-studio/app/studio-client.tsx +172 -29
- package/templates/ai-studio/app.ts +7 -7
- package/templates/ai-studio/lib/studio.ts +5 -5
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import React, { useEffect, useRef, useState } from "react";
|
|
4
|
-
import { db } from "@pylonsync/react";
|
|
4
|
+
import { db, callFn } from "@pylonsync/react";
|
|
5
5
|
import { siteConfig } from "@/lib/site.config";
|
|
6
6
|
|
|
7
7
|
// The chat app — a client island, rendered only for a SIGNED-IN user (the page
|
|
8
8
|
// redirects anyone else to /login). Conversations + messages are sync-backed
|
|
9
|
-
// owner-scoped entities (`db.useQuery`),
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// never reaches the browser.
|
|
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.
|
|
13
12
|
//
|
|
14
13
|
// All state lives in <ChatInner> (which owns `currentId`) — the thread is a
|
|
15
14
|
// presentational child. That's deliberate: creating a conversation mid-send
|
|
@@ -54,7 +53,6 @@ function ChatInner() {
|
|
|
54
53
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
55
54
|
const initialized = useRef(false);
|
|
56
55
|
|
|
57
|
-
// On first load, drop into the most recent conversation (if any).
|
|
58
56
|
useEffect(() => {
|
|
59
57
|
if (!initialized.current && conversations.length > 0) {
|
|
60
58
|
initialized.current = true;
|
|
@@ -62,7 +60,6 @@ function ChatInner() {
|
|
|
62
60
|
}
|
|
63
61
|
}, [conversations]);
|
|
64
62
|
|
|
65
|
-
// Keep the latest turn in view as content streams in.
|
|
66
63
|
useEffect(() => {
|
|
67
64
|
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
|
|
68
65
|
}, [messages.length, streaming]);
|
|
@@ -77,6 +74,19 @@ function ChatInner() {
|
|
|
77
74
|
setStreaming(null);
|
|
78
75
|
setNotice(null);
|
|
79
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
|
+
}
|
|
80
90
|
|
|
81
91
|
async function send(text: string) {
|
|
82
92
|
const trimmed = text.trim();
|
|
@@ -85,18 +95,12 @@ function ChatInner() {
|
|
|
85
95
|
setNotice(null);
|
|
86
96
|
setInput("");
|
|
87
97
|
|
|
88
|
-
// Snapshot the history BEFORE the async work (messages is for the current
|
|
89
|
-
// conversation; empty for a brand-new chat).
|
|
90
98
|
const history = messages.map((m) => ({ role: m.role, content: m.content }));
|
|
91
|
-
|
|
92
|
-
// Make sure we have a conversation to attach to.
|
|
93
99
|
let convId = currentId;
|
|
94
100
|
if (!convId) {
|
|
95
101
|
convId = await db.insert("Conversation", { title: trimmed.slice(0, 48) });
|
|
96
102
|
setCurrentId(convId);
|
|
97
103
|
}
|
|
98
|
-
|
|
99
|
-
// Persist the user's turn (optimistic — paints immediately).
|
|
100
104
|
await db.insert("Message", { conversationId: convId, role: "user", content: trimmed });
|
|
101
105
|
|
|
102
106
|
const payload = [
|
|
@@ -144,6 +148,8 @@ function ChatInner() {
|
|
|
144
148
|
currentId={currentId}
|
|
145
149
|
onSelect={selectConversation}
|
|
146
150
|
onNew={newChat}
|
|
151
|
+
onRename={renameConversation}
|
|
152
|
+
onDelete={deleteConversation}
|
|
147
153
|
/>
|
|
148
154
|
<div className="flex flex-1 flex-col bg-white">
|
|
149
155
|
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
|
@@ -153,7 +159,7 @@ function ChatInner() {
|
|
|
153
159
|
) : (
|
|
154
160
|
<div className="space-y-5">
|
|
155
161
|
{messages.map((m) => (
|
|
156
|
-
<Bubble key={m.id} role={m.role} content={m.content} />
|
|
162
|
+
<Bubble key={m.id} role={m.role} content={m.content} at={m.createdAt} />
|
|
157
163
|
))}
|
|
158
164
|
{streaming !== null ? <Bubble role="assistant" content={streaming || "…"} streaming /> : null}
|
|
159
165
|
</div>
|
|
@@ -185,11 +191,15 @@ function Sidebar({
|
|
|
185
191
|
currentId,
|
|
186
192
|
onSelect,
|
|
187
193
|
onNew,
|
|
194
|
+
onRename,
|
|
195
|
+
onDelete,
|
|
188
196
|
}: {
|
|
189
197
|
conversations: ConversationRow[];
|
|
190
198
|
currentId: string | null;
|
|
191
199
|
onSelect: (id: string) => void;
|
|
192
200
|
onNew: () => void;
|
|
201
|
+
onRename: (id: string, title: string) => void;
|
|
202
|
+
onDelete: (id: string) => void;
|
|
193
203
|
}) {
|
|
194
204
|
return (
|
|
195
205
|
<aside className="hidden w-64 shrink-0 flex-col border-r border-zinc-200 bg-paper sm:flex">
|
|
@@ -208,19 +218,14 @@ function Sidebar({
|
|
|
208
218
|
) : (
|
|
209
219
|
<ul className="space-y-0.5">
|
|
210
220
|
{conversations.map((c) => (
|
|
211
|
-
<
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
title={c.title}
|
|
220
|
-
>
|
|
221
|
-
{c.title || "New chat"}
|
|
222
|
-
</button>
|
|
223
|
-
</li>
|
|
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
|
+
/>
|
|
224
229
|
))}
|
|
225
230
|
</ul>
|
|
226
231
|
)}
|
|
@@ -229,6 +234,104 @@ function Sidebar({
|
|
|
229
234
|
);
|
|
230
235
|
}
|
|
231
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
|
+
|
|
232
335
|
function EmptyState({ onPick }: { onPick: (text: string) => void }) {
|
|
233
336
|
const { chat, brand } = siteConfig;
|
|
234
337
|
return (
|
|
@@ -254,11 +357,33 @@ function EmptyState({ onPick }: { onPick: (text: string) => void }) {
|
|
|
254
357
|
);
|
|
255
358
|
}
|
|
256
359
|
|
|
257
|
-
function Bubble({
|
|
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
|
+
}) {
|
|
258
371
|
const { brand } = siteConfig;
|
|
259
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
|
+
|
|
260
385
|
return (
|
|
261
|
-
<div className={"flex gap-3 " + (isUser ? "flex-row-reverse" : "")}>
|
|
386
|
+
<div className={"group flex gap-3 " + (isUser ? "flex-row-reverse" : "")}>
|
|
262
387
|
<span
|
|
263
388
|
className={
|
|
264
389
|
"mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full text-[11px] font-semibold " +
|
|
@@ -267,14 +392,34 @@ function Bubble({ role, content, streaming }: { role: string; content: string; s
|
|
|
267
392
|
>
|
|
268
393
|
{isUser ? "You" : brand.letter}
|
|
269
394
|
</span>
|
|
270
|
-
<div
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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}
|
|
278
423
|
</div>
|
|
279
424
|
</div>
|
|
280
425
|
);
|
|
@@ -348,9 +493,152 @@ function Composer({
|
|
|
348
493
|
);
|
|
349
494
|
}
|
|
350
495
|
|
|
351
|
-
|
|
352
|
-
//
|
|
353
|
-
//
|
|
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
|
+
|
|
354
642
|
async function streamCompletion(
|
|
355
643
|
messages: { role: string; content: string }[],
|
|
356
644
|
model: string,
|
|
@@ -397,6 +685,18 @@ async function streamCompletion(
|
|
|
397
685
|
}
|
|
398
686
|
}
|
|
399
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
|
+
|
|
400
700
|
function PlusIcon() {
|
|
401
701
|
return (
|
|
402
702
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" aria-hidden>
|
|
@@ -411,4 +711,17 @@ function SendIcon() {
|
|
|
411
711
|
</svg>
|
|
412
712
|
);
|
|
413
713
|
}
|
|
414
|
-
|
|
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
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { mutation, v } from "@pylonsync/functions";
|
|
2
|
+
|
|
3
|
+
// deleteConversation — remove a conversation AND all its messages in one
|
|
4
|
+
// transaction. The client could delete the Conversation row itself (it's
|
|
5
|
+
// owner-scoped), but that would orphan the messages; this cascades. Gated to the
|
|
6
|
+
// owner: it verifies the conversation belongs to the caller before deleting.
|
|
7
|
+
export default mutation<{ conversationId: string }, { ok: boolean; deleted: number }>({
|
|
8
|
+
auth: "user",
|
|
9
|
+
args: { conversationId: v.id("Conversation") },
|
|
10
|
+
async handler(ctx, args) {
|
|
11
|
+
const convo = (await ctx.db.get("Conversation", args.conversationId)) as
|
|
12
|
+
| { userId: string }
|
|
13
|
+
| null;
|
|
14
|
+
if (!convo) return { ok: true, deleted: 0 };
|
|
15
|
+
if (convo.userId !== ctx.auth.userId) {
|
|
16
|
+
throw ctx.error("POLICY_DENIED", "You can only delete your own conversations.");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const messages = (await ctx.db.unsafe.list("Message")) as unknown as {
|
|
20
|
+
id: string;
|
|
21
|
+
conversationId: string;
|
|
22
|
+
}[];
|
|
23
|
+
let deleted = 0;
|
|
24
|
+
for (const m of messages) {
|
|
25
|
+
if (m.conversationId === args.conversationId) {
|
|
26
|
+
await ctx.db.unsafe.delete("Message", m.id);
|
|
27
|
+
deleted++;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
await ctx.db.unsafe.delete("Conversation", args.conversationId);
|
|
31
|
+
return { ok: true, deleted };
|
|
32
|
+
},
|
|
33
|
+
});
|