@aprovan/patchwork-chat 0.1.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/src/index.css ADDED
@@ -0,0 +1,190 @@
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ @layer base {
6
+ :root {
7
+ --background: 0 0% 100%;
8
+ --foreground: 222.2 84% 4.9%;
9
+ --card: 0 0% 100%;
10
+ --card-foreground: 222.2 84% 4.9%;
11
+ --popover: 0 0% 100%;
12
+ --popover-foreground: 222.2 84% 4.9%;
13
+ --primary: 222.2 47.4% 11.2%;
14
+ --primary-foreground: 210 40% 98%;
15
+ --secondary: 210 40% 96.1%;
16
+ --secondary-foreground: 222.2 47.4% 11.2%;
17
+ --muted: 210 40% 96.1%;
18
+ --muted-foreground: 215.4 16.3% 46.9%;
19
+ --accent: 210 40% 96.1%;
20
+ --accent-foreground: 222.2 47.4% 11.2%;
21
+ --destructive: 0 84.2% 60.2%;
22
+ --destructive-foreground: 210 40% 98%;
23
+ --border: 214.3 31.8% 91.4%;
24
+ --input: 214.3 31.8% 91.4%;
25
+ --ring: 222.2 84% 4.9%;
26
+ --radius: 0.5rem;
27
+ }
28
+
29
+ .dark {
30
+ --background: 222.2 84% 4.9%;
31
+ --foreground: 210 40% 98%;
32
+ --card: 222.2 84% 4.9%;
33
+ --card-foreground: 210 40% 98%;
34
+ --popover: 222.2 84% 4.9%;
35
+ --popover-foreground: 210 40% 98%;
36
+ --primary: 210 40% 98%;
37
+ --primary-foreground: 222.2 47.4% 11.2%;
38
+ --secondary: 217.2 32.6% 17.5%;
39
+ --secondary-foreground: 210 40% 98%;
40
+ --muted: 217.2 32.6% 17.5%;
41
+ --muted-foreground: 215 20.2% 65.1%;
42
+ --accent: 217.2 32.6% 17.5%;
43
+ --accent-foreground: 210 40% 98%;
44
+ --destructive: 0 62.8% 30.6%;
45
+ --destructive-foreground: 210 40% 98%;
46
+ --border: 217.2 32.6% 17.5%;
47
+ --input: 217.2 32.6% 17.5%;
48
+ --ring: 212.7 26.8% 83.9%;
49
+ }
50
+ }
51
+
52
+ @layer base {
53
+ * {
54
+ @apply border-border;
55
+ }
56
+ body {
57
+ @apply bg-background text-foreground;
58
+ }
59
+ }
60
+
61
+ /* Tiptap Markdown Editor Styles */
62
+ .markdown-editor .tiptap {
63
+ outline: none;
64
+ }
65
+
66
+ .markdown-editor .tiptap p {
67
+ margin: 0;
68
+ }
69
+
70
+ .markdown-editor .tiptap > * + * {
71
+ margin-top: 0.5em;
72
+ }
73
+
74
+ /* Placeholder styling */
75
+ .markdown-editor .tiptap p.is-editor-empty:first-child::before {
76
+ color: hsl(var(--muted-foreground));
77
+ content: attr(data-placeholder);
78
+ float: left;
79
+ height: 0;
80
+ pointer-events: none;
81
+ }
82
+
83
+ /* Inline code */
84
+ .markdown-editor .tiptap code {
85
+ background-color: hsl(var(--muted));
86
+ border-radius: 0.25rem;
87
+ padding: 0.125rem 0.25rem;
88
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
89
+ font-size: 0.875em;
90
+ }
91
+
92
+ /* Code blocks */
93
+ .markdown-editor .tiptap pre {
94
+ background-color: hsl(var(--muted));
95
+ border-radius: 0.375rem;
96
+ padding: 0.5rem 0.75rem;
97
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
98
+ font-size: 0.875em;
99
+ overflow-x: auto;
100
+ }
101
+
102
+ .markdown-editor .tiptap pre code {
103
+ background: none;
104
+ padding: 0;
105
+ font-size: inherit;
106
+ }
107
+
108
+ /* Blockquotes */
109
+ .markdown-editor .tiptap blockquote {
110
+ border-left: 4px solid hsl(var(--muted-foreground) / 0.3);
111
+ padding-left: 1rem;
112
+ font-style: italic;
113
+ color: hsl(var(--muted-foreground));
114
+ }
115
+
116
+ /* Lists */
117
+ .markdown-editor .tiptap ul,
118
+ .markdown-editor .tiptap ol {
119
+ padding-left: 1.5rem;
120
+ margin: 0.25em 0;
121
+ }
122
+
123
+ .markdown-editor .tiptap ul {
124
+ list-style-type: disc;
125
+ }
126
+
127
+ .markdown-editor .tiptap ol {
128
+ list-style-type: decimal;
129
+ }
130
+
131
+ .markdown-editor .tiptap li {
132
+ margin: 0.125em 0;
133
+ }
134
+
135
+ /* Headings - more subtle for chat input */
136
+ .markdown-editor .tiptap h1,
137
+ .markdown-editor .tiptap h2,
138
+ .markdown-editor .tiptap h3 {
139
+ font-weight: 600;
140
+ line-height: 1.25;
141
+ }
142
+
143
+ .markdown-editor .tiptap h1 {
144
+ font-size: 1.25em;
145
+ }
146
+
147
+ .markdown-editor .tiptap h2 {
148
+ font-size: 1.125em;
149
+ }
150
+
151
+ .markdown-editor .tiptap h3 {
152
+ font-size: 1em;
153
+ }
154
+
155
+ /* Bold & Italic */
156
+ .markdown-editor .tiptap strong {
157
+ font-weight: 600;
158
+ }
159
+
160
+ .markdown-editor .tiptap em {
161
+ font-style: italic;
162
+ }
163
+
164
+ /* Strikethrough */
165
+ .markdown-editor .tiptap s {
166
+ text-decoration: line-through;
167
+ }
168
+
169
+ /* Code block wrapper with language input */
170
+ .markdown-editor .code-block-wrapper {
171
+ display: flex;
172
+ flex-direction: column;
173
+ }
174
+
175
+ .markdown-editor .code-block-wrapper pre {
176
+ margin: 0;
177
+ }
178
+
179
+ .markdown-editor .code-block-wrapper .language-input-wrapper {
180
+ user-select: none;
181
+ pointer-events: auto;
182
+ }
183
+
184
+ .markdown-editor .code-block-wrapper .language-input {
185
+ background: transparent;
186
+ user-select: text !important;
187
+ pointer-events: auto !important;
188
+ cursor: text;
189
+ -webkit-user-select: text !important;
190
+ }
@@ -0,0 +1,6 @@
1
+ import { type ClassValue, clsx } from 'clsx';
2
+ import { twMerge } from 'tailwind-merge';
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }
package/src/main.tsx ADDED
@@ -0,0 +1,10 @@
1
+ import React from 'react';
2
+ import ReactDOM from 'react-dom/client';
3
+ import App from './App';
4
+ import './index.css';
5
+
6
+ ReactDOM.createRoot(document.getElementById('root')!).render(
7
+ <React.StrictMode>
8
+ <App />
9
+ </React.StrictMode>,
10
+ );
@@ -0,0 +1,460 @@
1
+ import { useChat } from '@ai-sdk/react';
2
+ import { DefaultChatTransport } from 'ai';
3
+ import { useState, useRef, useEffect, useMemo, useCallback, createContext, useContext } from 'react';
4
+ import {
5
+ Send,
6
+ Loader2,
7
+ Wrench,
8
+ AlertCircle,
9
+ Brain,
10
+ ChevronDown,
11
+ } from 'lucide-react';
12
+ import { Button } from '@/components/ui/button';
13
+ import { ScrollArea } from '@/components/ui/scroll-area';
14
+ import { Avatar, AvatarFallback } from '@/components/ui/avatar';
15
+ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
16
+ import { Badge } from '@/components/ui/badge';
17
+ import {
18
+ Collapsible,
19
+ CollapsibleContent,
20
+ CollapsibleTrigger,
21
+ } from '@/components/ui/collapsible';
22
+ import Markdown from 'react-markdown';
23
+ import remarkGfm from 'remark-gfm';
24
+ import type { UIMessage } from 'ai';
25
+ import { createCompiler, type Compiler } from '@aprovan/patchwork-compiler';
26
+ import {
27
+ extractCodeBlocks,
28
+ CodePreview,
29
+ MarkdownEditor,
30
+ ServicesInspector,
31
+ type ServiceInfo,
32
+ } from '@aprovan/patchwork-editor';
33
+
34
+ const APROVAN_LOGO =
35
+ 'https://raw.githubusercontent.com/AprovanLabs/aprovan.com/main/docs/assets/social-labs.png';
36
+
37
+ interface PatchworkContext {
38
+ compiler: Compiler | null;
39
+ namespaces: string[];
40
+ }
41
+
42
+ const PatchworkCtx = createContext<PatchworkContext>({ compiler: null, namespaces: [] });
43
+ const useCompiler = () => useContext(PatchworkCtx).compiler;
44
+ const useServices = () => useContext(PatchworkCtx).namespaces;
45
+
46
+ function TextPart({ text, isUser }: { text: string; isUser: boolean }) {
47
+ const compiler = useCompiler();
48
+ const services = useServices();
49
+
50
+ if (isUser) {
51
+ return (
52
+ <div className="prose prose-sm prose-invert prose-p:my-1 prose-headings:my-2 prose-ul:my-1 prose-ol:my-1 prose-li:my-0 prose-pre:my-2 prose-code:before:content-none prose-code:after:content-none">
53
+ <Markdown remarkPlugins={[remarkGfm]}>{text}</Markdown>
54
+ </div>
55
+ );
56
+ }
57
+
58
+ const parts = extractCodeBlocks(text);
59
+
60
+ return (
61
+ <div className="prose prose-sm dark:prose-invert prose-p:my-1 prose-headings:my-2 prose-ul:my-1 prose-ol:my-1 prose-li:my-0 prose-pre:my-2 prose-code:before:content-none prose-code:after:content-none">
62
+ {parts.map((part, index) => {
63
+ if (part.type === 'code') {
64
+ return (
65
+ <CodePreview
66
+ key={index}
67
+ code={part.content}
68
+ compiler={compiler}
69
+ services={services}
70
+ filePath={part.attributes?.path}
71
+ />
72
+ );
73
+ }
74
+ return <Markdown key={index} remarkPlugins={[remarkGfm]}>{part.content}</Markdown>;
75
+ })}
76
+ </div>
77
+ );
78
+ }
79
+
80
+ function ReasoningPart({
81
+ text,
82
+ isStreaming,
83
+ }: {
84
+ text: string;
85
+ isStreaming?: boolean;
86
+ }) {
87
+ return (
88
+ <Collapsible defaultOpen={isStreaming}>
89
+ <CollapsibleTrigger className="flex items-center gap-2 text-yellow-700 dark:text-yellow-400 hover:opacity-80 w-full">
90
+ <Brain className="h-4 w-4" />
91
+ <span className="text-xs font-medium">Thinking</span>
92
+ {isStreaming && <Loader2 className="h-3 w-3 animate-spin" />}
93
+ <ChevronDown className="h-3 w-3 ml-auto transition-transform [[data-state=open]>&]:rotate-180" />
94
+ </CollapsibleTrigger>
95
+ <CollapsibleContent>
96
+ <div className="mt-2 p-3 rounded border-l-4 border-yellow-500 bg-yellow-50 dark:bg-yellow-950/50">
97
+ <p className="text-sm text-muted-foreground italic whitespace-pre-wrap">
98
+ {text}
99
+ </p>
100
+ </div>
101
+ </CollapsibleContent>
102
+ </Collapsible>
103
+ );
104
+ }
105
+
106
+ function ToolPart({
107
+ toolName,
108
+ state,
109
+ input,
110
+ output,
111
+ errorText,
112
+ }: {
113
+ toolName: string;
114
+ state: string;
115
+ input: unknown;
116
+ output?: unknown;
117
+ errorText?: string;
118
+ }) {
119
+ const isRunning = state === 'input-streaming' || state === 'input-available';
120
+ const hasError = state === 'output-error';
121
+
122
+ return (
123
+ <Collapsible className="my-1 w-full">
124
+ <CollapsibleTrigger className="inline-flex items-center gap-2 px-3 py-1 rounded-full border bg-muted/50 hover:bg-muted text-xs transition-colors">
125
+ <Wrench className="h-3 w-3 text-muted-foreground" />
126
+ <span className="font-mono">{toolName}</span>
127
+ <span className="w-3 h-3 flex items-center justify-center">
128
+ {isRunning && (
129
+ <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
130
+ )}
131
+ {hasError && <AlertCircle className="h-3 w-3 text-destructive" />}
132
+ </span>
133
+ <ChevronDown className="h-3 w-3 text-muted-foreground transition-transform [[data-state=open]>&]:rotate-180" />
134
+ </CollapsibleTrigger>
135
+
136
+ <CollapsibleContent className="mt-2 p-3 rounded-lg border bg-white space-y-2">
137
+ {input !== undefined && (
138
+ <div>
139
+ <span className="text-xs font-medium text-muted-foreground">
140
+ Input
141
+ </span>
142
+ <div className="mt-1 p-2 bg-muted/30 rounded text-xs overflow-auto max-h-48">
143
+ <pre className="whitespace-pre-wrap break-words m-0">
144
+ {typeof input === 'string'
145
+ ? input
146
+ : JSON.stringify(input, null, 2)}
147
+ </pre>
148
+ </div>
149
+ </div>
150
+ )}
151
+
152
+ {output !== undefined && (
153
+ <div>
154
+ <span className="text-xs font-medium text-muted-foreground">
155
+ Output
156
+ </span>
157
+ <div className="mt-1 p-2 bg-muted/30 rounded text-xs overflow-auto max-h-48">
158
+ <pre className="whitespace-pre-wrap break-words m-0">
159
+ {typeof output === 'string'
160
+ ? output
161
+ : JSON.stringify(output, null, 2)}
162
+ </pre>
163
+ </div>
164
+ </div>
165
+ )}
166
+
167
+ {errorText && (
168
+ <div className="text-sm text-destructive flex items-center gap-2">
169
+ <AlertCircle className="h-4 w-4 shrink-0" />
170
+ <span className="break-words">{errorText}</span>
171
+ </div>
172
+ )}
173
+ </CollapsibleContent>
174
+ </Collapsible>
175
+ );
176
+ }
177
+
178
+ function MessageBubble({ message }: { message: UIMessage }) {
179
+ const isUser = message.role === 'user';
180
+ const isStreaming = message.parts?.some(
181
+ (p) =>
182
+ 'state' in p &&
183
+ (p.state === 'input-streaming' || p.state === 'input-available'),
184
+ );
185
+
186
+ return (
187
+ <div className={`flex gap-3 ${isUser ? 'justify-end' : 'justify-start'}`}>
188
+ {!isUser && (
189
+ <Avatar className="h-8 w-8 shrink-0">
190
+ <img
191
+ src={APROVAN_LOGO}
192
+ alt="Assistant"
193
+ className="rounded-full"
194
+ />
195
+ <AvatarFallback className="bg-primary text-primary-foreground">
196
+ A
197
+ </AvatarFallback>
198
+ </Avatar>
199
+ )}
200
+
201
+ <div
202
+ className={`flex flex-col gap-1 max-w-[80%] min-w-0 ${
203
+ isUser ? 'items-end' : 'items-start'
204
+ }`}
205
+ >
206
+ <div className="flex items-center gap-2 h-5">
207
+ <span className="text-xs text-muted-foreground capitalize">
208
+ {message.role}
209
+ </span>
210
+ {isStreaming && (
211
+ <Badge
212
+ variant="outline"
213
+ className="text-xs"
214
+ >
215
+ <Loader2 className="h-3 w-3 mr-1 animate-spin" />
216
+ streaming
217
+ </Badge>
218
+ )}
219
+ </div>
220
+
221
+ <div
222
+ className={`rounded-lg px-4 py-2 overflow-hidden w-full ${
223
+ isUser ? 'bg-primary text-primary-foreground' : 'bg-muted'
224
+ }`}
225
+ >
226
+ {message.parts?.map((part, i) => {
227
+ if (part.type === 'text') {
228
+ return (
229
+ <TextPart
230
+ key={i}
231
+ text={part.text}
232
+ isUser={isUser}
233
+ />
234
+ );
235
+ }
236
+
237
+ if (part.type === 'reasoning') {
238
+ return (
239
+ <ReasoningPart
240
+ key={i}
241
+ text={part.text}
242
+ isStreaming={part.state === 'streaming'}
243
+ />
244
+ );
245
+ }
246
+
247
+ if (part.type.startsWith('tool-') || part.type === 'dynamic-tool') {
248
+ const toolPart = part as {
249
+ type: string;
250
+ toolName?: string;
251
+ toolCallId: string;
252
+ state: string;
253
+ input?: unknown;
254
+ output?: unknown;
255
+ errorText?: string;
256
+ };
257
+ const toolName =
258
+ toolPart.toolName ?? part.type.replace('tool-', '');
259
+ return (
260
+ <ToolPart
261
+ key={i}
262
+ toolName={toolName}
263
+ state={toolPart.state}
264
+ input={toolPart.input}
265
+ output={toolPart.output}
266
+ errorText={toolPart.errorText}
267
+ />
268
+ );
269
+ }
270
+
271
+ return null;
272
+ })}
273
+ </div>
274
+ </div>
275
+
276
+ {isUser && (
277
+ <Avatar className="h-8 w-8 shrink-0">
278
+ <AvatarFallback className="bg-secondary">U</AvatarFallback>
279
+ </Avatar>
280
+ )}
281
+ </div>
282
+ );
283
+ }
284
+
285
+ const PROXY_URL = '/api/proxy';
286
+ const IMAGE_SPEC = '@aprovan/patchwork-image-shadcn';
287
+ // Local proxy for loading image packages, esm.sh for widget imports
288
+ const IMAGE_CDN_URL = import.meta.env.DEV ? '/_local-packages' : 'https://esm.sh';
289
+ const WIDGET_CDN_URL = 'https://esm.sh'; // Widget imports need esm.sh bundles like @packagedcn
290
+
291
+ export default function ChatPage() {
292
+ const [input, setInput] = useState('What\'s the weather in Houston, Texas like?');
293
+ const [compiler, setCompiler] = useState<Compiler | null>(null);
294
+ const [namespaces, setNamespaces] = useState<string[]>([]);
295
+ const [services, setServices] = useState<ServiceInfo[]>([]);
296
+ const scrollRef = useRef<HTMLDivElement>(null);
297
+
298
+ useEffect(() => {
299
+ // Fetch available services
300
+ fetch('/api/services')
301
+ .then((res) => res.json())
302
+ .then((data) => {
303
+ setNamespaces(data.namespaces ?? []);
304
+ // In dev mode, also store full service details for inspection
305
+ if (import.meta.env.DEV && data.services) {
306
+ setServices(data.services);
307
+ }
308
+ })
309
+ .catch(() => {
310
+ setNamespaces([]);
311
+ setServices([]);
312
+ });
313
+
314
+ // Initialize compiler
315
+ createCompiler({
316
+ image: IMAGE_SPEC,
317
+ proxyUrl: PROXY_URL,
318
+ cdnBaseUrl: IMAGE_CDN_URL,
319
+ widgetCdnBaseUrl: WIDGET_CDN_URL,
320
+ })
321
+ .then(setCompiler)
322
+ .catch(console.error);
323
+ }, []);
324
+
325
+ const patchworkCtx = useMemo(() => ({ compiler, namespaces }), [compiler, namespaces]);
326
+
327
+ const transport = useMemo(
328
+ () =>
329
+ new DefaultChatTransport({
330
+ body: () => ({
331
+ metadata: {
332
+ patchwork: { compilers: [IMAGE_SPEC] },
333
+ },
334
+ }),
335
+ }),
336
+ [],
337
+ );
338
+
339
+ const { messages, sendMessage, status, error } = useChat({ transport });
340
+
341
+ const isLoading = status === 'submitted' || status === 'streaming';
342
+
343
+ const handleSubmit = useCallback((e?: React.FormEvent) => {
344
+ e?.preventDefault();
345
+ if (!input.trim()) return;
346
+ sendMessage({ text: input });
347
+ setInput('');
348
+ }, [input, sendMessage]);
349
+
350
+ useEffect(() => {
351
+ scrollRef.current?.scrollTo({
352
+ top: scrollRef.current.scrollHeight,
353
+ behavior: 'smooth',
354
+ });
355
+ }, [messages]);
356
+
357
+ return (
358
+ <PatchworkCtx.Provider value={patchworkCtx}>
359
+ <div className="flex flex-col h-screen max-w-4xl mx-auto p-4">
360
+ <Card className="flex-1 flex flex-col min-h-0 overflow-hidden border">
361
+ <CardHeader className="border-b py-3">
362
+ <CardTitle className="flex items-center gap-3">
363
+ <img
364
+ src={APROVAN_LOGO}
365
+ alt="Aprovan"
366
+ className="h-8 w-8 rounded-full"
367
+ />
368
+ <span className="text-lg">patchwork</span>
369
+ <ServicesInspector namespaces={namespaces} services={services} />
370
+ </CardTitle>
371
+ </CardHeader>
372
+
373
+ <CardContent className="flex-1 p-0 min-h-0">
374
+ <ScrollArea
375
+ className="h-full"
376
+ ref={scrollRef}
377
+ >
378
+ <div className="p-4 space-y-4">
379
+ {messages.length === 0 ? (
380
+ <div className="text-center text-muted-foreground py-12">
381
+ <img
382
+ src={APROVAN_LOGO}
383
+ alt=""
384
+ className="h-12 w-12 mx-auto mb-4 opacity-50 rounded-full"
385
+ />
386
+ <p>Start a conversation</p>
387
+ </div>
388
+ ) : (
389
+ messages.map((msg) => (
390
+ <MessageBubble
391
+ key={msg.id}
392
+ message={msg}
393
+ />
394
+ ))
395
+ )}
396
+
397
+ {isLoading &&
398
+ messages[messages.length - 1]?.role !== 'assistant' && (
399
+ <div className="flex gap-3 justify-start">
400
+ <Avatar className="h-8 w-8 shrink-0">
401
+ <img
402
+ src={APROVAN_LOGO}
403
+ alt=""
404
+ className="rounded-full"
405
+ />
406
+ <AvatarFallback>A</AvatarFallback>
407
+ </Avatar>
408
+ <div className="flex flex-col gap-1">
409
+ <div className="h-5" />
410
+ <div className="bg-muted rounded-lg px-4 py-2">
411
+ <Loader2 className="h-4 w-4 animate-spin" />
412
+ </div>
413
+ </div>
414
+ </div>
415
+ )}
416
+ </div>
417
+ </ScrollArea>
418
+ </CardContent>
419
+
420
+ {error && (
421
+ <div className="px-4 py-2 bg-destructive/10 text-destructive text-sm flex items-center gap-2">
422
+ <AlertCircle className="h-4 w-4" />
423
+ {error.message}
424
+ </div>
425
+ )}
426
+
427
+ <div className="p-4 border-t">
428
+ <form
429
+ onSubmit={handleSubmit}
430
+ className="flex gap-2 items-end"
431
+ >
432
+ <MarkdownEditor
433
+ value={input}
434
+ onChange={setInput}
435
+ onSubmit={() => {
436
+ if (!isLoading && input.trim()) {
437
+ handleSubmit();
438
+ }
439
+ }}
440
+ placeholder="Type a message... (Shift+Enter for new line)"
441
+ disabled={isLoading}
442
+ />
443
+ <Button
444
+ type="submit"
445
+ disabled={isLoading || !input.trim()}
446
+ className="shrink-0"
447
+ >
448
+ {isLoading ? (
449
+ <Loader2 className="h-4 w-4 animate-spin" />
450
+ ) : (
451
+ <Send className="h-4 w-4" />
452
+ )}
453
+ </Button>
454
+ </form>
455
+ </div>
456
+ </Card>
457
+ </div>
458
+ </PatchworkCtx.Provider>
459
+ );
460
+ }