@protoyard/ai-ui 0.0.0-stage → 1.1.1
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/LICENSE +21 -0
- package/README.md +152 -2
- package/dist/MessageList.svelte +113 -0
- package/dist/MessageList.svelte.d.ts +13 -0
- package/dist/approvals/AuditTrail.svelte +72 -0
- package/dist/approvals/AuditTrail.svelte.d.ts +12 -0
- package/dist/approvals/AutonomyLevelPicker.svelte +100 -0
- package/dist/approvals/AutonomyLevelPicker.svelte.d.ts +13 -0
- package/dist/approvals/ChatApprovalCard.svelte +118 -0
- package/dist/approvals/ChatApprovalCard.svelte.d.ts +12 -0
- package/dist/approvals/SuggestionCard.svelte +248 -0
- package/dist/approvals/SuggestionCard.svelte.d.ts +18 -0
- package/dist/approvals/index.d.ts +9 -0
- package/dist/approvals/index.js +9 -0
- package/dist/approvals/labels.d.ts +12 -0
- package/dist/approvals/labels.js +53 -0
- package/dist/components/AssistantComposer.svelte +172 -0
- package/dist/components/AssistantComposer.svelte.d.ts +21 -0
- package/dist/components/AssistantWorkspace.svelte +335 -0
- package/dist/components/AssistantWorkspace.svelte.d.ts +30 -0
- package/dist/components/CanvasPanel.svelte +62 -0
- package/dist/components/CanvasPanel.svelte.d.ts +9 -0
- package/dist/components/ChartAnswer.svelte +121 -0
- package/dist/components/ChartAnswer.svelte.d.ts +6 -0
- package/dist/components/RichAnswer.svelte +129 -0
- package/dist/components/RichAnswer.svelte.d.ts +7 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +5 -0
- package/dist/messages/ChatMessageBlocks.svelte +73 -0
- package/dist/messages/ChatMessageBlocks.svelte.d.ts +13 -0
- package/dist/messages/MessageAttachments.svelte +61 -0
- package/dist/messages/MessageAttachments.svelte.d.ts +8 -0
- package/dist/messages/MessageButtons.svelte +162 -0
- package/dist/messages/MessageButtons.svelte.d.ts +11 -0
- package/dist/messages/MessageForm.svelte +224 -0
- package/dist/messages/MessageForm.svelte.d.ts +11 -0
- package/dist/messages/MessageLinkPreview.svelte +42 -0
- package/dist/messages/MessageLinkPreview.svelte.d.ts +7 -0
- package/dist/messages/MessageSelect.svelte +118 -0
- package/dist/messages/MessageSelect.svelte.d.ts +11 -0
- package/dist/messages/MessageTable.svelte +48 -0
- package/dist/messages/MessageTable.svelte.d.ts +7 -0
- package/dist/messages/format.d.ts +15 -0
- package/dist/messages/format.js +46 -0
- package/dist/messages/index.d.ts +10 -0
- package/dist/messages/index.js +9 -0
- package/dist/messages/types.d.ts +33 -0
- package/dist/messages/types.js +11 -0
- package/dist/scroll-anchor.d.ts +13 -0
- package/dist/scroll-anchor.js +35 -0
- package/dist/styles.css +5 -0
- package/dist/styles.css.d.ts +1 -0
- package/dist/types.d.ts +25 -0
- package/dist/types.js +1 -0
- package/dist/voice/VoiceMode.svelte +230 -0
- package/dist/voice/VoiceMode.svelte.d.ts +13 -0
- package/dist/voice/index.d.ts +1 -0
- package/dist/voice/index.js +1 -0
- package/dist/widget/AssistantWidget.svelte +167 -0
- package/dist/widget/AssistantWidget.svelte.d.ts +5 -0
- package/dist/widget/AssistantWidgetPanel.svelte +11 -0
- package/dist/widget/AssistantWidgetPanel.svelte.d.ts +4 -0
- package/dist/widget/index.d.ts +2 -0
- package/dist/widget/index.js +1 -0
- package/dist/widget/types.d.ts +7 -0
- package/dist/widget/types.js +1 -0
- package/package.json +97 -3
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { type MdBlock, type MdInline, parseMarkdown } from '@protoyard/ai';
|
|
3
|
+
import { Badge } from 'texuli/badge';
|
|
4
|
+
import { Code } from 'texuli/kbd-code';
|
|
5
|
+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'texuli/table';
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
content: string;
|
|
9
|
+
label?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
let { content, label = 'Assistant response' }: Props = $props();
|
|
13
|
+
// ```chart fences become chart blocks; their code (charts kit + zod) loads only when one appears.
|
|
14
|
+
const document = $derived(parseMarkdown(content, { charts: true }));
|
|
15
|
+
|
|
16
|
+
function sourceFor(id: string) {
|
|
17
|
+
return document.sources.find((source) => source.id === id);
|
|
18
|
+
}
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<div class="ai-rich-answer space-y-3 text-sm leading-7 text-foreground" aria-label={label}>
|
|
22
|
+
{#snippet renderInline(nodes: MdInline[])}
|
|
23
|
+
{#each nodes as node, index (`${node.type}-${index}`)}
|
|
24
|
+
{#if node.type === 'text'}
|
|
25
|
+
{node.text}
|
|
26
|
+
{:else if node.type === 'break'}
|
|
27
|
+
<br />
|
|
28
|
+
{:else if node.type === 'code'}
|
|
29
|
+
<Code value={node.text} />
|
|
30
|
+
{:else if node.type === 'link'}
|
|
31
|
+
<a class="text-primary underline decoration-primary/40 underline-offset-4 hover:decoration-primary" href={node.href} target={node.href.startsWith('http') ? '_blank' : undefined} rel={node.href.startsWith('http') ? 'noreferrer noopener' : undefined}>
|
|
32
|
+
{@render renderInline(node.children)}
|
|
33
|
+
</a>
|
|
34
|
+
{:else if node.type === 'image'}
|
|
35
|
+
{#if node.src}
|
|
36
|
+
<img class="my-2 max-h-80 max-w-full rounded-lg border border-border object-contain" src={node.src} alt={node.alt} loading="lazy" referrerpolicy="no-referrer" />
|
|
37
|
+
{:else}
|
|
38
|
+
<span class="rounded bg-muted px-2 py-1 text-xs text-muted-foreground">Image: {node.alt}</span>
|
|
39
|
+
{/if}
|
|
40
|
+
{:else if node.type === 'attachment'}
|
|
41
|
+
<Badge variant="outline" label={node.label} />
|
|
42
|
+
{:else if node.type === 'citation'}
|
|
43
|
+
{@const source = sourceFor(node.id)}
|
|
44
|
+
{#if source?.href}
|
|
45
|
+
<a class="rounded-full bg-muted px-1.5 text-xs text-primary" href={source.href} target="_blank" rel="noreferrer noopener" aria-label={`Source ${node.index}: ${source.title}`}>{node.index}</a>
|
|
46
|
+
{:else}
|
|
47
|
+
<span class="rounded-full bg-muted px-1.5 text-xs" aria-label={`Source ${node.index}`}>{node.index}</span>
|
|
48
|
+
{/if}
|
|
49
|
+
{:else}
|
|
50
|
+
<svelte:element this={node.type === 'strong' ? 'strong' : node.type === 'em' ? 'em' : 'del'}>
|
|
51
|
+
{@render renderInline(node.children)}
|
|
52
|
+
</svelte:element>
|
|
53
|
+
{/if}
|
|
54
|
+
{/each}
|
|
55
|
+
{/snippet}
|
|
56
|
+
|
|
57
|
+
{#snippet renderBlocks(blocks: MdBlock[])}
|
|
58
|
+
{#each blocks as block, index (`${block.type}-${index}`)}
|
|
59
|
+
{#if block.type === 'paragraph'}
|
|
60
|
+
<p>{@render renderInline(block.children)}</p>
|
|
61
|
+
{:else if block.type === 'heading'}
|
|
62
|
+
<svelte:element this={`h${block.level}`} class="font-semibold leading-tight tracking-tight">{@render renderInline(block.children)}</svelte:element>
|
|
63
|
+
{:else if block.type === 'code'}
|
|
64
|
+
<div class="overflow-hidden rounded-lg border border-border bg-muted/60">
|
|
65
|
+
<div class="flex items-center justify-between border-b border-border px-3 py-1.5 text-xs text-muted-foreground">
|
|
66
|
+
<span>{block.lang || 'code'}</span>
|
|
67
|
+
{#if !block.closed}<span role="status">Streaming</span>{/if}
|
|
68
|
+
</div>
|
|
69
|
+
<!-- The block code scrolls, so it takes focus and a name (WCAG 2.1.1 / axe scrollable-region-focusable). -->
|
|
70
|
+
<Code
|
|
71
|
+
variant="block"
|
|
72
|
+
value={block.text}
|
|
73
|
+
language={block.lang || undefined}
|
|
74
|
+
role="region"
|
|
75
|
+
tabindex={0}
|
|
76
|
+
aria-label={`${block.lang || 'code'} code`}
|
|
77
|
+
class="overflow-x-auto rounded-none border-0 p-3 text-xs leading-5 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring"
|
|
78
|
+
/>
|
|
79
|
+
</div>
|
|
80
|
+
{:else if block.type === 'chart'}
|
|
81
|
+
{#snippet chartStatus(text: string)}<p role="status" class="rounded-lg border border-border bg-muted/40 p-3 text-xs text-muted-foreground">{text}</p>{/snippet}
|
|
82
|
+
{#if !block.closed}
|
|
83
|
+
{@render chartStatus('Preparing chart…')}
|
|
84
|
+
{:else}
|
|
85
|
+
{#await import('./ChartAnswer.svelte')}
|
|
86
|
+
{@render chartStatus('Loading chart…')}
|
|
87
|
+
{:then { default: ChartAnswer }}
|
|
88
|
+
<ChartAnswer text={block.text} />
|
|
89
|
+
{:catch}
|
|
90
|
+
{@render chartStatus('The chart did not load.')}
|
|
91
|
+
{/await}
|
|
92
|
+
{/if}
|
|
93
|
+
{:else if block.type === 'list'}
|
|
94
|
+
<svelte:element this={block.ordered ? 'ol' : 'ul'} class="space-y-1 pl-6 marker:text-muted-foreground" start={block.ordered ? block.start : undefined}>
|
|
95
|
+
{#each block.items as item, itemIndex (`${index}-${itemIndex}`)}
|
|
96
|
+
<li>
|
|
97
|
+
{#if item.checked !== null}<span class="mr-2" aria-label={item.checked ? 'Complete' : 'Incomplete'}>{item.checked ? '☑' : '☐'}</span>{/if}
|
|
98
|
+
{@render renderBlocks(item.children)}
|
|
99
|
+
</li>
|
|
100
|
+
{/each}
|
|
101
|
+
</svelte:element>
|
|
102
|
+
{:else if block.type === 'quote'}
|
|
103
|
+
<blockquote class="border-l-2 border-primary/40 pl-4 text-muted-foreground">{@render renderBlocks(block.children)}</blockquote>
|
|
104
|
+
{:else if block.type === 'table'}
|
|
105
|
+
<Table class="text-xs">
|
|
106
|
+
<TableHeader>
|
|
107
|
+
<TableRow>{#each block.header as cell, cellIndex (`h-${cellIndex}`)}<TableHead>{@render renderInline(cell)}</TableHead>{/each}</TableRow>
|
|
108
|
+
</TableHeader>
|
|
109
|
+
<TableBody>
|
|
110
|
+
{#each block.rows as row, rowIndex (`r-${rowIndex}`)}<TableRow>{#each row as cell, cellIndex (`${rowIndex}-${cellIndex}`)}<TableCell>{@render renderInline(cell)}</TableCell>{/each}</TableRow>{/each}
|
|
111
|
+
</TableBody>
|
|
112
|
+
</Table>
|
|
113
|
+
{:else}
|
|
114
|
+
<hr class="border-border" />
|
|
115
|
+
{/if}
|
|
116
|
+
{/each}
|
|
117
|
+
{/snippet}
|
|
118
|
+
|
|
119
|
+
{@render renderBlocks(document.blocks)}
|
|
120
|
+
{#if document.sources.length}
|
|
121
|
+
<ol class="border-t border-border pt-2 text-xs leading-5 text-muted-foreground" aria-label="Sources">
|
|
122
|
+
{#each document.sources as source (source.id)}
|
|
123
|
+
<li value={source.index}>
|
|
124
|
+
{#if source.href}<a class="underline underline-offset-2" href={source.href} target="_blank" rel="noreferrer noopener">{source.title}</a>{:else}{source.title}{/if}
|
|
125
|
+
</li>
|
|
126
|
+
{/each}
|
|
127
|
+
</ol>
|
|
128
|
+
{/if}
|
|
129
|
+
</div>
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { default as AssistantComposer } from './components/AssistantComposer.svelte';
|
|
2
|
+
export { default as AssistantWorkspace } from './components/AssistantWorkspace.svelte';
|
|
3
|
+
export { default as CanvasPanel } from './components/CanvasPanel.svelte';
|
|
4
|
+
export { default as RichAnswer } from './components/RichAnswer.svelte';
|
|
5
|
+
export { default as MessageList } from './MessageList.svelte';
|
|
6
|
+
export type { AssistantCanvas, AssistantConversation, AssistantMessage, AssistantSendInput, FeedbackRating, } from './types.ts';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { default as AssistantComposer } from './components/AssistantComposer.svelte';
|
|
2
|
+
export { default as AssistantWorkspace } from './components/AssistantWorkspace.svelte';
|
|
3
|
+
export { default as CanvasPanel } from './components/CanvasPanel.svelte';
|
|
4
|
+
export { default as RichAnswer } from './components/RichAnswer.svelte';
|
|
5
|
+
export { default as MessageList } from './MessageList.svelte';
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { type ChatMessage, type MessageAttachment, parseChatMessage } from '@protoyard/ai/messages';
|
|
3
|
+
import RichAnswer from '../components/RichAnswer.svelte';
|
|
4
|
+
import MessageAttachments from './MessageAttachments.svelte';
|
|
5
|
+
import MessageButtons from './MessageButtons.svelte';
|
|
6
|
+
import MessageForm from './MessageForm.svelte';
|
|
7
|
+
import MessageLinkPreview from './MessageLinkPreview.svelte';
|
|
8
|
+
import MessageSelect from './MessageSelect.svelte';
|
|
9
|
+
import MessageTable from './MessageTable.svelte';
|
|
10
|
+
import type { MessageActionHandler } from './types.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Renders a `protoyard.message@1` chat message. Input is untrusted, so it is
|
|
14
|
+
* always validated with `parseChatMessage` first: an invalid message shows a
|
|
15
|
+
* short note (never its content), and an unknown block arrives as the plain
|
|
16
|
+
* text the parser put in its place. Text renders as text; markdown text goes
|
|
17
|
+
* through the safe markdown renderer. Nothing here uses `{@html}`.
|
|
18
|
+
*
|
|
19
|
+
* Actions (buttons, selects, forms) call `onAction` with a typed event; the
|
|
20
|
+
* returned promise drives each control's pending and error feedback.
|
|
21
|
+
*/
|
|
22
|
+
let {
|
|
23
|
+
message,
|
|
24
|
+
onAction,
|
|
25
|
+
onOpenAttachment,
|
|
26
|
+
label = 'Message',
|
|
27
|
+
class: className = '',
|
|
28
|
+
}: {
|
|
29
|
+
/** A message object or its JSON text. */
|
|
30
|
+
message: unknown;
|
|
31
|
+
onAction?: MessageActionHandler;
|
|
32
|
+
onOpenAttachment?: (attachment: MessageAttachment) => unknown | Promise<unknown>;
|
|
33
|
+
label?: string;
|
|
34
|
+
class?: string;
|
|
35
|
+
} = $props();
|
|
36
|
+
|
|
37
|
+
const result = $derived(parseChatMessage(message));
|
|
38
|
+
const parsed = $derived<ChatMessage | null>(result.success ? result.message : null);
|
|
39
|
+
const messageId = $derived(parsed?.id ?? null);
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
<div class="grid gap-3 text-sm text-foreground {className}" data-py-chat-message role="group" aria-label={label}>
|
|
43
|
+
{#if !result.success}
|
|
44
|
+
<p class="m-0 rounded-md border border-dashed border-border bg-muted/40 p-3 text-xs text-muted-foreground" role="note" data-py-message-invalid>
|
|
45
|
+
This message could not be shown ({result.issues.length === 1 ? '1 problem' : `${result.issues.length} problems`}).
|
|
46
|
+
</p>
|
|
47
|
+
{:else if parsed}
|
|
48
|
+
{#each parsed.blocks as block, index (index)}
|
|
49
|
+
{#if block.type === 'text'}
|
|
50
|
+
{#if block.replaces}
|
|
51
|
+
<p class="m-0 rounded-md border border-dashed border-border px-3 py-2 text-xs text-muted-foreground" role="note" data-py-message-block="unknown">{block.text}</p>
|
|
52
|
+
{:else if block.format === 'markdown'}
|
|
53
|
+
<div data-py-message-block="text"><RichAnswer content={block.text} label="Message text" /></div>
|
|
54
|
+
{:else}
|
|
55
|
+
<p class="m-0 whitespace-pre-wrap leading-6" data-py-message-block="text">{block.text}</p>
|
|
56
|
+
{/if}
|
|
57
|
+
{:else if block.type === 'buttons'}
|
|
58
|
+
<MessageButtons {block} blockIndex={index} {messageId} {onAction} />
|
|
59
|
+
{:else if block.type === 'select'}
|
|
60
|
+
<MessageSelect {block} blockIndex={index} {messageId} {onAction} />
|
|
61
|
+
{:else if block.type === 'form'}
|
|
62
|
+
<MessageForm {block} blockIndex={index} {messageId} {onAction} />
|
|
63
|
+
{:else if block.type === 'table'}
|
|
64
|
+
<MessageTable {block} />
|
|
65
|
+
{:else if block.type === 'link_preview'}
|
|
66
|
+
<MessageLinkPreview {block} />
|
|
67
|
+
{/if}
|
|
68
|
+
{/each}
|
|
69
|
+
{#if parsed.attachments.length > 0}
|
|
70
|
+
<MessageAttachments attachments={parsed.attachments} onOpen={onOpenAttachment} />
|
|
71
|
+
{/if}
|
|
72
|
+
{/if}
|
|
73
|
+
</div>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type MessageAttachment } from '@protoyard/ai/messages';
|
|
2
|
+
import type { MessageActionHandler } from './types.ts';
|
|
3
|
+
type $$ComponentProps = {
|
|
4
|
+
/** A message object or its JSON text. */
|
|
5
|
+
message: unknown;
|
|
6
|
+
onAction?: MessageActionHandler;
|
|
7
|
+
onOpenAttachment?: (attachment: MessageAttachment) => unknown | Promise<unknown>;
|
|
8
|
+
label?: string;
|
|
9
|
+
class?: string;
|
|
10
|
+
};
|
|
11
|
+
declare const ChatMessageBlocks: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
12
|
+
type ChatMessageBlocks = ReturnType<typeof ChatMessageBlocks>;
|
|
13
|
+
export default ChatMessageBlocks;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { FileText } from '@lucide/svelte';
|
|
3
|
+
import type { MessageAttachment } from '@protoyard/ai/messages';
|
|
4
|
+
import { Button } from 'texuli/button';
|
|
5
|
+
import { LucideIcon } from 'texuli/icons/lucide';
|
|
6
|
+
import { formatBytes, isImageMime, isInlineImageUrl } from './format.js';
|
|
7
|
+
import { settle } from './types.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Attachments: images inline (local or `data:` only, as the contract allows),
|
|
11
|
+
* other files as a labeled row. Opening a file is the host's job, through
|
|
12
|
+
* `onOpen`; without it, files are listed but not interactive.
|
|
13
|
+
*/
|
|
14
|
+
let {
|
|
15
|
+
attachments,
|
|
16
|
+
onOpen,
|
|
17
|
+
}: {
|
|
18
|
+
attachments: readonly MessageAttachment[];
|
|
19
|
+
onOpen?: (attachment: MessageAttachment) => unknown | Promise<unknown>;
|
|
20
|
+
} = $props();
|
|
21
|
+
|
|
22
|
+
let pending = $state<string | null>(null);
|
|
23
|
+
let error = $state('');
|
|
24
|
+
|
|
25
|
+
async function open(attachment: MessageAttachment): Promise<void> {
|
|
26
|
+
if (!onOpen || pending) return;
|
|
27
|
+
pending = attachment.id;
|
|
28
|
+
error = (await settle(() => onOpen(attachment))) ?? '';
|
|
29
|
+
pending = null;
|
|
30
|
+
}
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<ul class="m-0 grid list-none gap-2 p-0" data-py-message-attachments aria-label="Attachments">
|
|
34
|
+
{#each attachments as attachment (attachment.id)}
|
|
35
|
+
<li class="flex items-center gap-2 text-sm">
|
|
36
|
+
{#if isImageMime(attachment.mime) && isInlineImageUrl(attachment.url)}
|
|
37
|
+
<img class="max-h-40 max-w-60 rounded-md border border-border object-contain" src={attachment.url} alt={attachment.name} loading="lazy" />
|
|
38
|
+
{:else}
|
|
39
|
+
<LucideIcon icon={FileText} size="sm" class="shrink-0 text-muted-foreground" />
|
|
40
|
+
{#if onOpen}
|
|
41
|
+
<!-- One open at a time: pending is the component's own state, so autoLoading is off. -->
|
|
42
|
+
<Button
|
|
43
|
+
variant="link"
|
|
44
|
+
autoLoading={false}
|
|
45
|
+
loading={pending === attachment.id}
|
|
46
|
+
disabled={pending !== null}
|
|
47
|
+
onclick={() => open(attachment)}
|
|
48
|
+
>
|
|
49
|
+
{attachment.name}
|
|
50
|
+
</Button>
|
|
51
|
+
{:else}
|
|
52
|
+
<span>{attachment.name}</span>
|
|
53
|
+
{/if}
|
|
54
|
+
<span class="text-xs text-muted-foreground">{formatBytes(attachment.size)}</span>
|
|
55
|
+
{/if}
|
|
56
|
+
</li>
|
|
57
|
+
{/each}
|
|
58
|
+
</ul>
|
|
59
|
+
{#if error}
|
|
60
|
+
<p class="m-0 text-xs text-destructive" role="alert">{error}</p>
|
|
61
|
+
{/if}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { MessageAttachment } from '@protoyard/ai/messages';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
attachments: readonly MessageAttachment[];
|
|
4
|
+
onOpen?: (attachment: MessageAttachment) => unknown | Promise<unknown>;
|
|
5
|
+
};
|
|
6
|
+
declare const MessageAttachments: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
7
|
+
type MessageAttachments = ReturnType<typeof MessageAttachments>;
|
|
8
|
+
export default MessageAttachments;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { ExternalLink } from '@lucide/svelte';
|
|
3
|
+
import type { ButtonsBlock, MessageButton } from '@protoyard/ai/messages';
|
|
4
|
+
import { tick } from 'svelte';
|
|
5
|
+
import { Button, type ButtonVariant } from 'texuli/button';
|
|
6
|
+
import { LucideIcon } from 'texuli/icons/lucide';
|
|
7
|
+
import { type MessageActionHandler, settle } from './types.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Action and link buttons, drawn with Texuli's `Button`. An action button
|
|
11
|
+
* reports `{ kind: 'button' }` and stays busy (`loading`) while the host's
|
|
12
|
+
* promise is pending; a confirmation asks first, inline, with Cancel focused.
|
|
13
|
+
* Link buttons go through Texuli's URL policy at the untrusted tier (message
|
|
14
|
+
* content is untrusted) and open in a new tab without access to this page
|
|
15
|
+
* (`noopener noreferrer`).
|
|
16
|
+
*/
|
|
17
|
+
let {
|
|
18
|
+
block,
|
|
19
|
+
blockIndex,
|
|
20
|
+
messageId,
|
|
21
|
+
onAction,
|
|
22
|
+
}: {
|
|
23
|
+
block: ButtonsBlock;
|
|
24
|
+
blockIndex: number;
|
|
25
|
+
messageId: string | null;
|
|
26
|
+
onAction?: MessageActionHandler;
|
|
27
|
+
} = $props();
|
|
28
|
+
|
|
29
|
+
const uid = $props.id();
|
|
30
|
+
let pending = $state<number | null>(null);
|
|
31
|
+
let confirming = $state<number | null>(null);
|
|
32
|
+
let error = $state('');
|
|
33
|
+
let cancelButton = $state<HTMLElement | null>(null);
|
|
34
|
+
// One slot per button (the block is fixed for this component's life), so each binds a defined ref.
|
|
35
|
+
// svelte-ignore state_referenced_locally
|
|
36
|
+
const triggers: (HTMLElement | null)[] = $state(block.buttons.map(() => null));
|
|
37
|
+
|
|
38
|
+
const VARIANT: Record<MessageButton['style'], ButtonVariant> = {
|
|
39
|
+
default: 'outline',
|
|
40
|
+
primary: 'default',
|
|
41
|
+
danger: 'destructive',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
async function run(index: number): Promise<void> {
|
|
45
|
+
const button = block.buttons[index];
|
|
46
|
+
if (!button?.action || !onAction || pending !== null) return;
|
|
47
|
+
const fromConfirm = confirming !== null;
|
|
48
|
+
confirming = null;
|
|
49
|
+
pending = index;
|
|
50
|
+
error = '';
|
|
51
|
+
error =
|
|
52
|
+
(await settle(() =>
|
|
53
|
+
onAction({
|
|
54
|
+
kind: 'button',
|
|
55
|
+
messageId,
|
|
56
|
+
blockIndex,
|
|
57
|
+
action: button.action as string,
|
|
58
|
+
value: button.value ?? null,
|
|
59
|
+
})
|
|
60
|
+
)) ?? '';
|
|
61
|
+
pending = null;
|
|
62
|
+
// The confirmation closed under the keyboard; give focus back to the button.
|
|
63
|
+
if (fromConfirm) {
|
|
64
|
+
await tick();
|
|
65
|
+
triggers[index]?.focus();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function press(index: number): Promise<void> {
|
|
70
|
+
if (block.buttons[index]?.confirm) {
|
|
71
|
+
confirming = index;
|
|
72
|
+
await tick();
|
|
73
|
+
cancelButton?.focus();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
await run(index);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function cancel(): Promise<void> {
|
|
80
|
+
const index = confirming;
|
|
81
|
+
confirming = null;
|
|
82
|
+
await tick();
|
|
83
|
+
if (index !== null) triggers[index]?.focus();
|
|
84
|
+
}
|
|
85
|
+
</script>
|
|
86
|
+
|
|
87
|
+
<div class="grid gap-2" data-py-message-block="buttons">
|
|
88
|
+
<div class="flex flex-wrap gap-2">
|
|
89
|
+
{#each block.buttons as button, index (index)}
|
|
90
|
+
{#if button.url}
|
|
91
|
+
<Button
|
|
92
|
+
variant={VARIANT[button.style]}
|
|
93
|
+
href={button.url}
|
|
94
|
+
hrefTrust="untrusted"
|
|
95
|
+
target="_blank"
|
|
96
|
+
rel="noopener noreferrer"
|
|
97
|
+
>
|
|
98
|
+
{button.label}
|
|
99
|
+
<LucideIcon icon={ExternalLink} size="xs" />
|
|
100
|
+
<span class="sr-only">(opens in a new tab)</span>
|
|
101
|
+
</Button>
|
|
102
|
+
{:else}
|
|
103
|
+
<!-- Pending is the component's own state (it also blocks the other buttons), so autoLoading is off. -->
|
|
104
|
+
<Button
|
|
105
|
+
variant={VARIANT[button.style]}
|
|
106
|
+
bind:ref={triggers[index]}
|
|
107
|
+
autoLoading={false}
|
|
108
|
+
loading={pending === index}
|
|
109
|
+
disabled={!onAction || pending !== null}
|
|
110
|
+
aria-expanded={button.confirm ? confirming === index : undefined}
|
|
111
|
+
aria-controls={button.confirm ? `${uid}-confirm` : undefined}
|
|
112
|
+
onclick={() => press(index)}
|
|
113
|
+
>
|
|
114
|
+
{button.label}
|
|
115
|
+
</Button>
|
|
116
|
+
{/if}
|
|
117
|
+
{/each}
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
{#if confirming !== null}
|
|
121
|
+
{@const confirm = block.buttons[confirming]?.confirm}
|
|
122
|
+
{#if confirm}
|
|
123
|
+
<div
|
|
124
|
+
id="{uid}-confirm"
|
|
125
|
+
class="grid gap-2 rounded-md border border-border bg-muted/50 p-3"
|
|
126
|
+
role="alertdialog"
|
|
127
|
+
aria-modal="false"
|
|
128
|
+
aria-labelledby="{uid}-confirm-title"
|
|
129
|
+
aria-describedby="{uid}-confirm-body"
|
|
130
|
+
tabindex="-1"
|
|
131
|
+
onkeydown={(event) => {
|
|
132
|
+
if (event.key === 'Escape') {
|
|
133
|
+
event.preventDefault();
|
|
134
|
+
void cancel();
|
|
135
|
+
}
|
|
136
|
+
}}
|
|
137
|
+
>
|
|
138
|
+
<p id="{uid}-confirm-title" class="m-0 text-sm font-semibold">{confirm.title}</p>
|
|
139
|
+
<p id="{uid}-confirm-body" class="m-0 whitespace-pre-wrap text-sm text-muted-foreground">{confirm.body}</p>
|
|
140
|
+
<div class="flex flex-wrap gap-2">
|
|
141
|
+
<Button variant="outline" bind:ref={cancelButton} autoLoading={false} onclick={cancel}>
|
|
142
|
+
{confirm.cancelLabel}
|
|
143
|
+
</Button>
|
|
144
|
+
<Button
|
|
145
|
+
variant={block.buttons[confirming]?.style === 'danger' ? 'destructive' : 'default'}
|
|
146
|
+
autoLoading={false}
|
|
147
|
+
onclick={() => run(confirming as number)}
|
|
148
|
+
>
|
|
149
|
+
{confirm.confirmLabel}
|
|
150
|
+
</Button>
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
{/if}
|
|
154
|
+
{/if}
|
|
155
|
+
|
|
156
|
+
{#if !onAction && block.buttons.some((button) => button.action)}
|
|
157
|
+
<p class="m-0 text-xs text-muted-foreground">Actions are not available in this view.</p>
|
|
158
|
+
{/if}
|
|
159
|
+
{#if error}
|
|
160
|
+
<p class="m-0 text-xs text-destructive" role="alert">{error}</p>
|
|
161
|
+
{/if}
|
|
162
|
+
</div>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ButtonsBlock } from '@protoyard/ai/messages';
|
|
2
|
+
import { type MessageActionHandler } from './types.ts';
|
|
3
|
+
type $$ComponentProps = {
|
|
4
|
+
block: ButtonsBlock;
|
|
5
|
+
blockIndex: number;
|
|
6
|
+
messageId: string | null;
|
|
7
|
+
onAction?: MessageActionHandler;
|
|
8
|
+
};
|
|
9
|
+
declare const MessageButtons: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
10
|
+
type MessageButtons = ReturnType<typeof MessageButtons>;
|
|
11
|
+
export default MessageButtons;
|