@protoyard/ai-ui 0.0.0-stage → 1.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.
Files changed (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +152 -2
  3. package/dist/MessageList.svelte +113 -0
  4. package/dist/MessageList.svelte.d.ts +13 -0
  5. package/dist/approvals/AuditTrail.svelte +72 -0
  6. package/dist/approvals/AuditTrail.svelte.d.ts +12 -0
  7. package/dist/approvals/AutonomyLevelPicker.svelte +100 -0
  8. package/dist/approvals/AutonomyLevelPicker.svelte.d.ts +13 -0
  9. package/dist/approvals/ChatApprovalCard.svelte +118 -0
  10. package/dist/approvals/ChatApprovalCard.svelte.d.ts +12 -0
  11. package/dist/approvals/SuggestionCard.svelte +248 -0
  12. package/dist/approvals/SuggestionCard.svelte.d.ts +18 -0
  13. package/dist/approvals/index.d.ts +9 -0
  14. package/dist/approvals/index.js +9 -0
  15. package/dist/approvals/labels.d.ts +12 -0
  16. package/dist/approvals/labels.js +53 -0
  17. package/dist/components/AssistantComposer.svelte +172 -0
  18. package/dist/components/AssistantComposer.svelte.d.ts +21 -0
  19. package/dist/components/AssistantWorkspace.svelte +335 -0
  20. package/dist/components/AssistantWorkspace.svelte.d.ts +30 -0
  21. package/dist/components/CanvasPanel.svelte +62 -0
  22. package/dist/components/CanvasPanel.svelte.d.ts +9 -0
  23. package/dist/components/ChartAnswer.svelte +121 -0
  24. package/dist/components/ChartAnswer.svelte.d.ts +6 -0
  25. package/dist/components/RichAnswer.svelte +129 -0
  26. package/dist/components/RichAnswer.svelte.d.ts +7 -0
  27. package/dist/index.d.ts +6 -0
  28. package/dist/index.js +5 -0
  29. package/dist/messages/ChatMessageBlocks.svelte +73 -0
  30. package/dist/messages/ChatMessageBlocks.svelte.d.ts +13 -0
  31. package/dist/messages/MessageAttachments.svelte +61 -0
  32. package/dist/messages/MessageAttachments.svelte.d.ts +8 -0
  33. package/dist/messages/MessageButtons.svelte +162 -0
  34. package/dist/messages/MessageButtons.svelte.d.ts +11 -0
  35. package/dist/messages/MessageForm.svelte +224 -0
  36. package/dist/messages/MessageForm.svelte.d.ts +11 -0
  37. package/dist/messages/MessageLinkPreview.svelte +42 -0
  38. package/dist/messages/MessageLinkPreview.svelte.d.ts +7 -0
  39. package/dist/messages/MessageSelect.svelte +118 -0
  40. package/dist/messages/MessageSelect.svelte.d.ts +11 -0
  41. package/dist/messages/MessageTable.svelte +48 -0
  42. package/dist/messages/MessageTable.svelte.d.ts +7 -0
  43. package/dist/messages/format.d.ts +15 -0
  44. package/dist/messages/format.js +46 -0
  45. package/dist/messages/index.d.ts +10 -0
  46. package/dist/messages/index.js +9 -0
  47. package/dist/messages/types.d.ts +33 -0
  48. package/dist/messages/types.js +11 -0
  49. package/dist/scroll-anchor.d.ts +13 -0
  50. package/dist/scroll-anchor.js +35 -0
  51. package/dist/styles.css +5 -0
  52. package/dist/styles.css.d.ts +1 -0
  53. package/dist/types.d.ts +25 -0
  54. package/dist/types.js +1 -0
  55. package/dist/voice/VoiceMode.svelte +230 -0
  56. package/dist/voice/VoiceMode.svelte.d.ts +13 -0
  57. package/dist/voice/index.d.ts +1 -0
  58. package/dist/voice/index.js +1 -0
  59. package/dist/widget/AssistantWidget.svelte +167 -0
  60. package/dist/widget/AssistantWidget.svelte.d.ts +5 -0
  61. package/dist/widget/AssistantWidgetPanel.svelte +11 -0
  62. package/dist/widget/AssistantWidgetPanel.svelte.d.ts +4 -0
  63. package/dist/widget/index.d.ts +2 -0
  64. package/dist/widget/index.js +1 -0
  65. package/dist/widget/types.d.ts +7 -0
  66. package/dist/widget/types.js +1 -0
  67. package/package.json +97 -3
@@ -0,0 +1,335 @@
1
+ <script lang="ts">
2
+ import {
3
+ ArrowDown,
4
+ Copy,
5
+ FileText,
6
+ FolderPlus,
7
+ MessageSquarePlus,
8
+ MoreHorizontal,
9
+ Pin,
10
+ Share2,
11
+ } from '@lucide/svelte';
12
+ import { AI_MODELS, type AiModel } from '@protoyard/ai';
13
+ import { onMount } from 'svelte';
14
+ import { Badge } from 'texuli/badge';
15
+ import { Button, IconButton } from 'texuli/button';
16
+ import { Icon } from 'texuli/icons';
17
+ import { LucideIcon } from 'texuli/icons/lucide';
18
+ import { Input } from 'texuli/input';
19
+ import { CopyButton, Kbd } from 'texuli/kbd-code';
20
+ import { DropdownMenu } from 'texuli/menus';
21
+ import { Select } from 'texuli/select';
22
+ import MessageList from '../MessageList.svelte';
23
+ import type {
24
+ AssistantCanvas,
25
+ AssistantConversation,
26
+ AssistantMessage,
27
+ AssistantSendInput,
28
+ FeedbackRating,
29
+ } from '../types.js';
30
+ import AssistantComposer from './AssistantComposer.svelte';
31
+ import CanvasPanel from './CanvasPanel.svelte';
32
+
33
+ interface Props {
34
+ conversations: readonly AssistantConversation[];
35
+ messages: readonly AssistantMessage[];
36
+ models?: readonly AiModel[];
37
+ selectedConversationId?: string;
38
+ selectedModelId?: string;
39
+ folders?: readonly string[];
40
+ streaming?: boolean;
41
+ canvas?: AssistantCanvas | null;
42
+ onSend: (conversationId: string, input: AssistantSendInput) => unknown;
43
+ onStop?: () => void;
44
+ onCreateConversation?: () => string | undefined;
45
+ onSelectConversation?: (id: string) => void;
46
+ onRenameConversation?: (id: string, title: string) => void;
47
+ onTogglePin?: (id: string) => void;
48
+ onMoveToFolder?: (id: string, folder: string | null) => void;
49
+ onCreateFolder?: (name: string) => void;
50
+ onRegenerate?: () => void;
51
+ onEditMessage?: (messageId: string, content: string) => void;
52
+ onBranchChange?: (messageId: string, direction: -1 | 1) => void;
53
+ onFeedback?: (messageId: string, rating: FeedbackRating) => void;
54
+ onShare?: (conversationId: string) => string | Promise<string>;
55
+ onCanvasChange?: (content: string) => void;
56
+ onCloseCanvas?: () => void;
57
+ }
58
+
59
+ const suggestions = [
60
+ 'Help me turn an idea into a product plan',
61
+ 'Summarize the key points in a document',
62
+ 'Write a first draft I can improve',
63
+ 'Explain a complex topic step by step',
64
+ ];
65
+
66
+ let {
67
+ conversations,
68
+ messages,
69
+ models = AI_MODELS,
70
+ selectedConversationId,
71
+ selectedModelId,
72
+ folders = [],
73
+ streaming = false,
74
+ canvas = null,
75
+ onSend,
76
+ onStop,
77
+ onCreateConversation,
78
+ onSelectConversation,
79
+ onRenameConversation,
80
+ onTogglePin,
81
+ onMoveToFolder,
82
+ onCreateFolder,
83
+ onRegenerate,
84
+ onEditMessage,
85
+ onBranchChange,
86
+ onFeedback,
87
+ onShare,
88
+ onCanvasChange,
89
+ onCloseCanvas,
90
+ }: Props = $props();
91
+
92
+ let activeId = $state('');
93
+ let modelId = $state('');
94
+ let query = $state('');
95
+ let folderFilter = $state('');
96
+ let sidebarOpen = $state(false);
97
+ let folderDraft = $state('');
98
+ let shareUrl = $state('');
99
+ let shareError = $state('');
100
+ let editingId = $state('');
101
+ let editDraft = $state('');
102
+ let searchInput = $state<HTMLElement | null>(null);
103
+ /** Texuli's Select keeps "" as "nothing chosen", so "all conversations" has its own value. */
104
+ const ALL_FOLDERS = '__all';
105
+ const folderItems = $derived([
106
+ { value: ALL_FOLDERS, label: 'All conversations' },
107
+ { value: '__unfiled', label: 'Unfiled' },
108
+ ...folders.map((folder) => ({ value: folder, label: folder })),
109
+ ]);
110
+ const activeConversation = $derived(
111
+ conversations.find((conversation) => conversation.id === activeId)
112
+ );
113
+ const sortedConversations = $derived.by(() => {
114
+ const normalized = query.trim().toLowerCase();
115
+ return [...conversations]
116
+ .filter((conversation) => !normalized || conversation.title.toLowerCase().includes(normalized))
117
+ .filter(
118
+ (conversation) =>
119
+ !folderFilter ||
120
+ (folderFilter === '__unfiled' ? !conversation.folder : conversation.folder === folderFilter)
121
+ )
122
+ .sort(
123
+ (left, right) =>
124
+ Number(Boolean(right.pinned)) - Number(Boolean(left.pinned)) ||
125
+ right.updatedAt.localeCompare(left.updatedAt)
126
+ );
127
+ });
128
+ const activeModel = $derived(models.find((model) => model.id === modelId));
129
+
130
+ $effect(() => {
131
+ if (selectedConversationId !== undefined && selectedConversationId !== activeId) {
132
+ activeId = selectedConversationId;
133
+ } else if (!conversations.some((conversation) => conversation.id === activeId)) {
134
+ activeId = conversations[0]?.id ?? '';
135
+ }
136
+ });
137
+
138
+ $effect(() => {
139
+ const preferredModelId = selectedModelId ?? models[0]?.id ?? '';
140
+ if (models.some((model) => model.id === preferredModelId)) modelId = preferredModelId;
141
+ });
142
+
143
+ onMount(() => {
144
+ const handleShortcut = (event: KeyboardEvent) => {
145
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
146
+ event.preventDefault();
147
+ searchInput?.focus();
148
+ } else if (event.key === 'Escape' && streaming) {
149
+ onStop?.();
150
+ }
151
+ };
152
+ window.addEventListener('keydown', handleShortcut);
153
+ return () => window.removeEventListener('keydown', handleShortcut);
154
+ });
155
+
156
+ function selectConversation(id: string): void {
157
+ activeId = id;
158
+ sidebarOpen = false;
159
+ shareUrl = '';
160
+ onSelectConversation?.(id);
161
+ }
162
+
163
+ function newConversation(): void {
164
+ if (!onCreateConversation) return;
165
+ const id = onCreateConversation();
166
+ if (id) selectConversation(id);
167
+ }
168
+
169
+ function chooseSuggestion(prompt: string): void {
170
+ if (!activeId || !activeModel) return;
171
+ void onSend(activeId, { prompt, model: activeModel, attachments: [] });
172
+ }
173
+
174
+ async function createShareLink(): Promise<void> {
175
+ if (!onShare || !activeId) return;
176
+ shareError = '';
177
+ try {
178
+ shareUrl = await onShare(activeId);
179
+ } catch {
180
+ shareError = 'A share link could not be created.';
181
+ }
182
+ }
183
+
184
+ function saveRename(id: string): void {
185
+ const title = editDraft.trim();
186
+ if (title) onRenameConversation?.(id, title);
187
+ editingId = '';
188
+ }
189
+
190
+ function addFolder(): void {
191
+ const folder = folderDraft.trim();
192
+ if (!folder || folders.includes(folder)) return;
193
+ onCreateFolder?.(folder);
194
+ folderDraft = '';
195
+ }
196
+
197
+ function formatDate(date: string): string {
198
+ const value = new Date(date);
199
+ if (Number.isNaN(value.getTime())) return '';
200
+ return new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' }).format(value);
201
+ }
202
+ </script>
203
+
204
+ <div class="@container relative flex h-full min-h-[32rem] min-w-0 overflow-hidden rounded-2xl border border-border bg-background text-foreground shadow-soft">
205
+ {#if sidebarOpen}<Button variant="ghost" class="absolute inset-0 z-20 h-auto rounded-none bg-black/30 hover:bg-black/30 @3xl:hidden" aria-label="Close conversation navigation" onclick={() => (sidebarOpen = false)} />{/if}
206
+ <aside class={`absolute inset-y-0 left-0 z-30 flex w-[min(19rem,85%)] min-w-0 flex-col border-r border-border bg-surface transition-transform @3xl:static @3xl:w-72 @3xl:translate-x-0 ${sidebarOpen ? 'translate-x-0' : '-translate-x-full @3xl:translate-x-0'}`} aria-label="Conversation history">
207
+ <div class="flex items-center gap-2 p-3">
208
+ <Button variant="outline" class="min-h-11 flex-1 justify-start gap-2" disabled={!onCreateConversation} onclick={newConversation}>
209
+ <LucideIcon icon={MessageSquarePlus} size="sm" /> New chat
210
+ <Kbd class="ml-auto" aria-hidden="true">⌘ K</Kbd>
211
+ </Button>
212
+ <IconButton variant="ghost" class="@3xl:hidden" label="Close conversation navigation" onclick={() => (sidebarOpen = false)}><Icon icon="close" size="sm" /></IconButton>
213
+ </div>
214
+ <div class="px-3 pb-2">
215
+ <label for="conversation-search" class="sr-only">Search conversations</label>
216
+ <div class="relative">
217
+ <Icon icon="search" size="sm" class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
218
+ <Input bind:ref={searchInput} bind:value={query} id="conversation-search" class="pl-9" placeholder="Search conversations" aria-keyshortcuts="Meta+K Control+K" />
219
+ </div>
220
+ <Select.Root
221
+ value={folderFilter || ALL_FOLDERS}
222
+ items={folderItems}
223
+ onValueChange={(value) => (folderFilter = value === ALL_FOLDERS ? '' : value)}
224
+ >
225
+ <Select.Trigger id="conversation-folder-filter" aria-label="Filter by folder" size="sm" class="mt-2 w-full text-xs text-muted-foreground">
226
+ <Select.Value />
227
+ </Select.Trigger>
228
+ <Select.Content>
229
+ {#each folderItems as item (item.value)}<Select.Item value={item.value} label={item.label}>{item.label}</Select.Item>{/each}
230
+ </Select.Content>
231
+ </Select.Root>
232
+ </div>
233
+ <nav class="min-h-0 flex-1 overflow-y-auto px-2 pb-2" aria-label="Conversations">
234
+ {#if sortedConversations.length === 0}<p class="px-3 py-6 text-center text-xs text-muted-foreground">No conversations found.</p>{/if}
235
+ <ul class="space-y-1">
236
+ {#each sortedConversations as conversation (conversation.id)}
237
+ <li class={`rounded-xl p-1 ${activeId === conversation.id ? 'bg-muted' : 'hover:bg-muted/60'}`}>
238
+ {#if editingId === conversation.id}
239
+ <form class="flex gap-1" onsubmit={(event) => { event.preventDefault(); saveRename(conversation.id); }}>
240
+ <label class="sr-only" for={`rename-${conversation.id}`}>Conversation title</label>
241
+ <Input id={`rename-${conversation.id}`} bind:value={editDraft} class="min-w-0 flex-1" maxlength={80} />
242
+ <IconButton type="submit" variant="ghost" label="Save conversation title"><Icon icon="check" size="sm" /></IconButton>
243
+ </form>
244
+ {:else}
245
+ <div class="flex min-w-0 items-center gap-1">
246
+ <Button variant="ghost" class="h-auto min-h-10 min-w-0 flex-1 flex-col items-stretch justify-center gap-0 px-2 py-1 text-left font-normal" aria-current={activeId === conversation.id ? 'page' : undefined} onclick={() => selectConversation(conversation.id)}>
247
+ <span class="flex items-center gap-1.5 truncate text-sm">{#if conversation.pinned}<LucideIcon icon={Pin} size="xs" label="Pinned" class="shrink-0 text-primary" />{/if}<span class="truncate">{conversation.title || 'New conversation'}</span></span>
248
+ <span class="mt-0.5 block truncate text-[0.68rem] text-muted-foreground">{conversation.folder ?? 'Personal'} · {formatDate(conversation.updatedAt)}</span>
249
+ </Button>
250
+ <DropdownMenu.Root>
251
+ <DropdownMenu.Trigger class="shrink-0" aria-label={`Actions for ${conversation.title}`}><LucideIcon icon={MoreHorizontal} size="sm" /></DropdownMenu.Trigger>
252
+ <DropdownMenu.Content class="w-48">
253
+ <DropdownMenu.Item disabled={!onRenameConversation} onSelect={() => { editingId = conversation.id; editDraft = conversation.title; }}>{conversation.title ? 'Rename' : 'Name conversation'}</DropdownMenu.Item>
254
+ <DropdownMenu.Item disabled={!onTogglePin} onSelect={() => onTogglePin?.(conversation.id)}>{conversation.pinned ? 'Unpin' : 'Pin'} conversation</DropdownMenu.Item>
255
+ {#if folders.length > 0}
256
+ <DropdownMenu.Separator />
257
+ <DropdownMenu.Group>
258
+ <DropdownMenu.Label>Move {conversation.title} to folder</DropdownMenu.Label>
259
+ <DropdownMenu.RadioGroup value={conversation.folder ?? ''} onValueChange={(folder) => onMoveToFolder?.(conversation.id, folder || null)}>
260
+ {#each folders as folder (folder)}<DropdownMenu.RadioItem value={folder} disabled={!onMoveToFolder}>{folder}</DropdownMenu.RadioItem>{/each}
261
+ </DropdownMenu.RadioGroup>
262
+ {#if conversation.folder}<DropdownMenu.Item disabled={!onMoveToFolder} onSelect={() => onMoveToFolder?.(conversation.id, null)}>Remove from folder</DropdownMenu.Item>{/if}
263
+ </DropdownMenu.Group>
264
+ {/if}
265
+ </DropdownMenu.Content>
266
+ </DropdownMenu.Root>
267
+ </div>
268
+ {/if}
269
+ </li>
270
+ {/each}
271
+ </ul>
272
+ </nav>
273
+ <form class="flex gap-2 border-t border-border p-3" onsubmit={(event) => { event.preventDefault(); addFolder(); }}>
274
+ <label for="new-conversation-folder" class="sr-only">New folder name</label>
275
+ <Input id="new-conversation-folder" bind:value={folderDraft} maxlength={40} class="min-w-0 flex-1 text-xs" placeholder="New folder" disabled={!onCreateFolder} />
276
+ <IconButton type="submit" variant="ghost" label="Create folder" disabled={!onCreateFolder || !folderDraft.trim()}><LucideIcon icon={FolderPlus} size="sm" /></IconButton>
277
+ </form>
278
+ <p class="px-4 pb-3 text-[0.65rem] text-muted-foreground">All conversations and models are simulated.</p>
279
+ </aside>
280
+
281
+ <section class="flex min-w-0 flex-1 flex-col" aria-label="Assistant conversation">
282
+ <header class="flex min-h-14 items-center gap-2 border-b border-border px-3 @xl:px-5">
283
+ <IconButton variant="ghost" class="@3xl:hidden" label="Open conversation history" onclick={() => (sidebarOpen = true)}><Icon icon="menu" size="sm" /></IconButton>
284
+ <div class="min-w-0 flex-1">
285
+ <h1 class="truncate text-sm font-semibold">{activeConversation?.title || 'New conversation'}</h1>
286
+ <p class="text-[0.68rem] text-muted-foreground">{activeModel?.name ?? 'Simulated assistant'} · Simulated</p>
287
+ </div>
288
+ {#if onShare}<Button variant="ghost" size="sm" aria-label="Create share link" disabled={!activeId} onclick={createShareLink}><LucideIcon icon={Share2} size="sm" /><span class="hidden @xl:inline">Share</span></Button>{/if}
289
+ {#if canvas}<Badge variant="outline" class="gap-1"><LucideIcon icon={FileText} size="xs" /> Canvas open</Badge>{/if}
290
+ </header>
291
+ {#if shareUrl || shareError}
292
+ <div class="flex items-center gap-2 border-b border-border bg-muted/40 px-4 py-2">
293
+ {#if shareUrl}<label for="assistant-share-link" class="sr-only">Share link</label><Input id="assistant-share-link" readonly value={shareUrl} class="min-w-0 flex-1 text-xs" /><CopyButton value={shareUrl} size="sm" label="Copy share link" copiedLabel="Share link copied" onCopy={() => (shareError = '')} onCopyError={() => (shareError = 'Clipboard access is unavailable. Copy the link from the field.')}><LucideIcon icon={Copy} size="sm" /></CopyButton>{/if}
294
+ {#if shareError}<p role="alert" class="text-xs text-destructive">{shareError}</p>{/if}
295
+ <IconButton variant="ghost" size="sm" label="Close share link" onclick={() => { shareUrl = ''; shareError = ''; }}><Icon icon="close" size="sm" /></IconButton>
296
+ </div>
297
+ {/if}
298
+ {#if streaming}<p role="status" class="border-b border-border px-5 py-2 text-xs text-primary">Generating a simulated response… Press Esc to stop.</p>{/if}
299
+ <div class="flex min-h-0 flex-1">
300
+ <div class="flex min-w-0 flex-1 flex-col">
301
+ {#if messages.length > 0}
302
+ <MessageList
303
+ {messages}
304
+ conversationId={activeId}
305
+ {streaming}
306
+ {onRegenerate}
307
+ {onEditMessage}
308
+ {onBranchChange}
309
+ {onFeedback}
310
+ />
311
+ {:else}
312
+ <div class="min-h-0 flex-1 overflow-y-auto px-4 py-5 @xl:px-8">
313
+ <div class="mx-auto flex min-h-full max-w-2xl flex-col justify-center py-8">
314
+ <div class="mb-8 text-center">
315
+ <div class="mx-auto mb-4 flex size-12 items-center justify-center rounded-2xl bg-primary/10 text-primary"><LucideIcon icon={MessageSquarePlus} size="md" /></div>
316
+ <h2 class="text-xl font-semibold tracking-tight @xl:text-2xl">What would you like to explore?</h2>
317
+ <p class="mt-2 text-sm text-muted-foreground">Draft, reason, and experiment with a deterministic simulated assistant.</p>
318
+ </div>
319
+ <div class="grid gap-2 @xl:grid-cols-2">
320
+ {#each suggestions as suggestion (suggestion)}
321
+ <Button variant="outline" class="h-auto min-h-14 flex-col items-start justify-start whitespace-normal px-4 py-3 text-left font-normal text-muted-foreground hover:text-foreground" disabled={!activeId || !activeModel || streaming} onclick={() => chooseSuggestion(suggestion)}><span>{suggestion}</span><LucideIcon icon={ArrowDown} size="xs" class="mt-1 text-muted-foreground/60" /></Button>
322
+ {/each}
323
+ </div>
324
+ </div>
325
+ </div>
326
+ {/if}
327
+ <footer class="space-y-2 border-t border-border bg-background/90 px-3 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] @xl:px-5">
328
+ <AssistantComposer models={models} modelId={modelId} streaming={streaming} disabled={!activeId} onModelChange={(id) => (modelId = id)} onStop={onStop} onSubmit={(input) => activeId ? onSend(activeId, input) : false} />
329
+ <p class="text-center text-[0.65rem] text-muted-foreground">AI responses are generated by local deterministic simulation and may be inaccurate.</p>
330
+ </footer>
331
+ </div>
332
+ {#if canvas}<div class="absolute inset-0 z-30 flex bg-background @3xl:static @3xl:z-auto @3xl:w-[min(42%,34rem)] @3xl:min-w-72"><CanvasPanel document={canvas} onChange={onCanvasChange} onClose={onCloseCanvas} /></div>{/if}
333
+ </div>
334
+ </section>
335
+ </div>
@@ -0,0 +1,30 @@
1
+ import { type AiModel } from '@protoyard/ai';
2
+ import type { AssistantCanvas, AssistantConversation, AssistantMessage, AssistantSendInput, FeedbackRating } from '../types.ts';
3
+ interface Props {
4
+ conversations: readonly AssistantConversation[];
5
+ messages: readonly AssistantMessage[];
6
+ models?: readonly AiModel[];
7
+ selectedConversationId?: string;
8
+ selectedModelId?: string;
9
+ folders?: readonly string[];
10
+ streaming?: boolean;
11
+ canvas?: AssistantCanvas | null;
12
+ onSend: (conversationId: string, input: AssistantSendInput) => unknown;
13
+ onStop?: () => void;
14
+ onCreateConversation?: () => string | undefined;
15
+ onSelectConversation?: (id: string) => void;
16
+ onRenameConversation?: (id: string, title: string) => void;
17
+ onTogglePin?: (id: string) => void;
18
+ onMoveToFolder?: (id: string, folder: string | null) => void;
19
+ onCreateFolder?: (name: string) => void;
20
+ onRegenerate?: () => void;
21
+ onEditMessage?: (messageId: string, content: string) => void;
22
+ onBranchChange?: (messageId: string, direction: -1 | 1) => void;
23
+ onFeedback?: (messageId: string, rating: FeedbackRating) => void;
24
+ onShare?: (conversationId: string) => string | Promise<string>;
25
+ onCanvasChange?: (content: string) => void;
26
+ onCloseCanvas?: () => void;
27
+ }
28
+ declare const AssistantWorkspace: import("svelte").Component<Props, {}, "">;
29
+ type AssistantWorkspace = ReturnType<typeof AssistantWorkspace>;
30
+ export default AssistantWorkspace;
@@ -0,0 +1,62 @@
1
+ <script lang="ts">
2
+ import { Copy } from '@lucide/svelte';
3
+ import { IconButton } from 'texuli/button';
4
+ import { Icon } from 'texuli/icons';
5
+ import { LucideIcon } from 'texuli/icons/lucide';
6
+ import { Textarea } from 'texuli/input';
7
+ import { Code, CopyButton } from 'texuli/kbd-code';
8
+ import type { AssistantCanvas } from '../types.js';
9
+
10
+ interface Props {
11
+ document: AssistantCanvas;
12
+ onChange?: (content: string) => void;
13
+ onClose?: () => void;
14
+ }
15
+
16
+ let { document, onChange, onClose }: Props = $props();
17
+ let copyError = $state(false);
18
+ </script>
19
+
20
+ <aside class="flex min-h-0 min-w-0 flex-col border-l border-border bg-surface" aria-label="Canvas">
21
+ <header class="flex min-h-14 items-center gap-2 border-b border-border px-4">
22
+ <div class="min-w-0 flex-1">
23
+ <h2 class="truncate text-sm font-semibold">{document.title}</h2>
24
+ <p class="text-[0.7rem] text-muted-foreground">{document.kind === 'code' ? document.language ?? 'Code' : 'Document'} canvas</p>
25
+ </div>
26
+ <!-- CopyButton announces "Canvas copied"; a failed clipboard write shows the alert below. -->
27
+ <CopyButton
28
+ value={document.content}
29
+ label="Copy canvas content"
30
+ copiedLabel="Canvas copied"
31
+ onCopy={() => (copyError = false)}
32
+ onCopyError={() => (copyError = true)}
33
+ >
34
+ <LucideIcon icon={Copy} size="sm" />
35
+ </CopyButton>
36
+ {#if onClose}<IconButton variant="ghost" label="Close canvas" onclick={onClose}><Icon icon="close" size="sm" /></IconButton>{/if}
37
+ </header>
38
+ {#if document.kind === 'code'}
39
+ <!-- The block code scrolls, so it takes focus and a name (WCAG 2.1.1 / axe scrollable-region-focusable). -->
40
+ <div class="flex min-h-0 flex-1 flex-col [&>pre]:min-h-0 [&>pre]:flex-1 [&>pre]:flex [&>pre]:flex-col">
41
+ <Code
42
+ variant="block"
43
+ value={document.content}
44
+ language={document.language || undefined}
45
+ role="region"
46
+ tabindex={0}
47
+ aria-label={`${document.language || 'Code'} canvas code`}
48
+ class="min-h-0 flex-1 overflow-auto rounded-none border-0 bg-muted/40 p-4 text-xs leading-6 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring"
49
+ />
50
+ </div>
51
+ {:else}
52
+ <label for="assistant-canvas-document" class="sr-only">Canvas document content</label>
53
+ <Textarea
54
+ id="assistant-canvas-document"
55
+ class="min-h-0 flex-1 resize-none rounded-none border-0 bg-transparent p-5 text-sm leading-7 focus-visible:ring-inset"
56
+ value={document.content}
57
+ readonly={!onChange}
58
+ onValueChange={(value) => onChange?.(value)}
59
+ />
60
+ {/if}
61
+ {#if copyError}<p role="alert" class="border-t border-border px-4 py-2 text-xs text-destructive">Clipboard access is unavailable in this browser.</p>{/if}
62
+ </aside>
@@ -0,0 +1,9 @@
1
+ import type { AssistantCanvas } from '../types.ts';
2
+ interface Props {
3
+ document: AssistantCanvas;
4
+ onChange?: (content: string) => void;
5
+ onClose?: () => void;
6
+ }
7
+ declare const CanvasPanel: import("svelte").Component<Props, {}, "">;
8
+ type CanvasPanel = ReturnType<typeof CanvasPanel>;
9
+ export default CanvasPanel;
@@ -0,0 +1,121 @@
1
+ <script lang="ts">
2
+ import { parseChartSpec } from '@protoyard/ai/chart';
3
+ import { Code } from 'texuli/kbd-code';
4
+ import {
5
+ Table,
6
+ TableBody,
7
+ TableCaption,
8
+ TableCell,
9
+ TableHead,
10
+ TableHeader,
11
+ TableRow,
12
+ } from 'texuli/table';
13
+
14
+ /*
15
+ * A ```chart fence from a simulated answer, loaded on demand by RichAnswer.
16
+ * The fence text is validated (JSON.parse + zod); a valid spec shows as a
17
+ * table at once and as a `@protoyard/charts-ui` chart when that chunk
18
+ * arrives, an invalid one shows a short note and the raw text. Spec strings
19
+ * only ever render as text.
20
+ */
21
+ let { text }: { text: string } = $props();
22
+
23
+ const result = $derived(parseChartSpec(text));
24
+ const spec = $derived(result.ok ? result.spec : null);
25
+ const cartesian = $derived(spec !== null && ['line', 'area', 'bar'].includes(spec.kind));
26
+ const units = $derived(
27
+ spec ? { unit: spec.unit ?? 'number', currency: spec.currency ?? 'USD' } : null
28
+ );
29
+ const valueFormat = $derived.by(() => {
30
+ if (!spec) return (v: number) => String(v);
31
+ const unit = spec.unit ?? 'number';
32
+ const format =
33
+ unit === 'cents'
34
+ ? new Intl.NumberFormat('en-US', { style: 'currency', currency: spec.currency ?? 'USD' })
35
+ : unit === 'percent'
36
+ ? new Intl.NumberFormat('en-US', { style: 'percent', maximumFractionDigits: 1 })
37
+ : new Intl.NumberFormat('en-US', unit === 'compact' ? { notation: 'compact' } : {});
38
+ return (v: number) => format.format(unit === 'cents' ? v / 100 : v);
39
+ });
40
+ const series = $derived(
41
+ spec
42
+ ? spec.series.map((s, i) => ({
43
+ id: `s${i}`,
44
+ label: s.label,
45
+ points: spec.labels.map((x, j) => ({ x, y: s.values[j] ?? null })),
46
+ }))
47
+ : []
48
+ );
49
+ const items = $derived(
50
+ spec
51
+ ? spec.labels.map((label, i) => ({ id: `i${i}`, label, value: spec.series[0]?.values[i] ?? 0 }))
52
+ : []
53
+ );
54
+ const load = () =>
55
+ cartesian ? import('@protoyard/charts-ui/cartesian') : import('@protoyard/charts-ui/part-whole');
56
+ </script>
57
+
58
+ {#snippet table()}
59
+ {#if spec}
60
+ <Table class="text-xs tabular-nums">
61
+ <TableCaption class="text-left font-semibold">{spec.title}</TableCaption>
62
+ <TableHeader>
63
+ <TableRow>
64
+ <TableHead scope="col">Label</TableHead>
65
+ {#each spec.series as s, i (i)}<TableHead scope="col" numeric>{s.label}</TableHead>{/each}
66
+ </TableRow>
67
+ </TableHeader>
68
+ <TableBody>
69
+ {#each spec.labels as label, j (j)}
70
+ <TableRow>
71
+ <TableHead scope="row">{label}</TableHead>
72
+ {#each spec.series as s, i (i)}
73
+ {@const v = s.values[j] ?? null}
74
+ <TableCell numeric>{v === null ? '—' : valueFormat(v)}</TableCell>
75
+ {/each}
76
+ </TableRow>
77
+ {/each}
78
+ </TableBody>
79
+ </Table>
80
+ {/if}
81
+ {/snippet}
82
+
83
+ {#if !result.ok}
84
+ <div role="note" class="rounded-lg border border-dashed border-border bg-muted/40 p-3 text-xs" data-chart-error>
85
+ <p class="font-medium">This chart could not be shown.</p>
86
+ <p class="mt-1 text-muted-foreground">{result.error}</p>
87
+ <details class="mt-2">
88
+ <summary class="cursor-pointer text-muted-foreground">Show the chart data</summary>
89
+ <!-- The raw data scrolls, so it takes focus and a name (WCAG 2.1.1 / axe scrollable-region-focusable). -->
90
+ <Code
91
+ variant="block"
92
+ value={text}
93
+ role="region"
94
+ tabindex={0}
95
+ aria-label="Chart data"
96
+ class="mt-2 max-h-48 overflow-auto whitespace-pre-wrap break-all font-mono focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring"
97
+ />
98
+ </details>
99
+ </div>
100
+ {:else if spec && units}
101
+ <figure class="m-0" data-chart-kind={spec.kind}>
102
+ {#await load()}
103
+ {@render table()}
104
+ {:then kit}
105
+ {#if 'BarChart' in kit && spec.kind === 'bar'}
106
+ <kit.BarChart title={spec.title} description={spec.description} {series} {...units} height={220} interactive={false} />
107
+ {:else if 'LineChart' in kit && (spec.kind === 'line' || spec.kind === 'area')}
108
+ <kit.LineChart title={spec.title} description={spec.description} {series} {...units} area={spec.kind === 'area'} height={220} interactive={false} />
109
+ {:else if 'FunnelChart' in kit && spec.kind === 'funnel'}
110
+ <kit.FunnelChart title={spec.title} description={spec.description} steps={items.map((d) => ({ id: d.id, label: d.label, count: d.value }))} unit={units.unit} height={220} />
111
+ {:else if 'PieChart' in kit && spec.kind === 'pie'}
112
+ <kit.PieChart title={spec.title} description={spec.description} data={items} {...units} height={220} />
113
+ {:else if 'DonutChart' in kit}
114
+ <kit.DonutChart title={spec.title} description={spec.description} data={items} {...units} height={220} />
115
+ {/if}
116
+ {:catch}
117
+ {@render table()}
118
+ <p role="note" class="mt-1 text-xs text-muted-foreground">The chart view did not load; the table shows the same data.</p>
119
+ {/await}
120
+ </figure>
121
+ {/if}
@@ -0,0 +1,6 @@
1
+ type $$ComponentProps = {
2
+ text: string;
3
+ };
4
+ declare const ChartAnswer: import("svelte").Component<$$ComponentProps, {}, "">;
5
+ type ChartAnswer = ReturnType<typeof ChartAnswer>;
6
+ export default ChartAnswer;