@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,224 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { FormBlock, FormField } from '@protoyard/ai/messages';
|
|
3
|
+
import { Button } from 'texuli/button';
|
|
4
|
+
import { Checkbox } from 'texuli/choice';
|
|
5
|
+
import { Field } from 'texuli/field';
|
|
6
|
+
import { Input, Textarea } from 'texuli/input';
|
|
7
|
+
import { SelectContent, SelectItem, SelectRoot, SelectTrigger, SelectValue } from 'texuli/select';
|
|
8
|
+
import { type MessageActionHandler, type MessageFormValue, settle } from './types.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A form in a message, drawn with Texuli's field, input, select, checkbox and
|
|
12
|
+
* button. Required fields are checked before anything is sent;
|
|
13
|
+
* a submission reports `{ kind: 'form', values }` with typed values (strings,
|
|
14
|
+
* numbers, booleans, string lists or null), stays busy while the host works,
|
|
15
|
+
* shows the host's error and keeps the input on failure, and locks with
|
|
16
|
+
* "Submitted" once it succeeds.
|
|
17
|
+
*/
|
|
18
|
+
let {
|
|
19
|
+
block,
|
|
20
|
+
blockIndex,
|
|
21
|
+
messageId,
|
|
22
|
+
onAction,
|
|
23
|
+
}: {
|
|
24
|
+
block: FormBlock;
|
|
25
|
+
blockIndex: number;
|
|
26
|
+
messageId: string | null;
|
|
27
|
+
onAction?: MessageActionHandler;
|
|
28
|
+
} = $props();
|
|
29
|
+
|
|
30
|
+
const uid = $props.id();
|
|
31
|
+
|
|
32
|
+
function initialValue(field: FormField): MessageFormValue {
|
|
33
|
+
const initial = field.initial;
|
|
34
|
+
switch (field.kind) {
|
|
35
|
+
case 'checkbox':
|
|
36
|
+
return typeof initial === 'boolean' ? initial : false;
|
|
37
|
+
case 'number':
|
|
38
|
+
return typeof initial === 'number' ? initial : null;
|
|
39
|
+
case 'multiselect':
|
|
40
|
+
return Array.isArray(initial) ? [...initial] : [];
|
|
41
|
+
case 'select':
|
|
42
|
+
return typeof initial === 'string' ? initial : null;
|
|
43
|
+
default:
|
|
44
|
+
return typeof initial === 'string' ? initial : '';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The block is fixed for this component's life; initial values only seed the inputs.
|
|
49
|
+
// svelte-ignore state_referenced_locally
|
|
50
|
+
let values = $state<Record<string, MessageFormValue>>(
|
|
51
|
+
Object.fromEntries(block.fields.map((field) => [field.key, initialValue(field)]))
|
|
52
|
+
);
|
|
53
|
+
let errors = $state<Record<string, string>>({});
|
|
54
|
+
let pending = $state(false);
|
|
55
|
+
let failure = $state('');
|
|
56
|
+
let submitted = $state(false);
|
|
57
|
+
|
|
58
|
+
function isEmpty(value: MessageFormValue): boolean {
|
|
59
|
+
return value === null || value === '' || (Array.isArray(value) && value.length === 0);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function validate(): boolean {
|
|
63
|
+
const next: Record<string, string> = {};
|
|
64
|
+
for (const field of block.fields) {
|
|
65
|
+
const value = values[field.key] ?? null;
|
|
66
|
+
if (field.kind === 'checkbox') {
|
|
67
|
+
if (field.required && value !== true) next[field.key] = `${field.label} must be checked.`;
|
|
68
|
+
} else if (field.required && isEmpty(value)) {
|
|
69
|
+
next[field.key] = `${field.label} is required.`;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
errors = next;
|
|
73
|
+
return Object.keys(next).length === 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function submit(event: SubmitEvent): Promise<void> {
|
|
77
|
+
event.preventDefault();
|
|
78
|
+
if (!onAction || pending || submitted) return;
|
|
79
|
+
if (!validate()) {
|
|
80
|
+
const first = block.fields.find((field) => errors[field.key]);
|
|
81
|
+
if (first) document.getElementById(`${uid}-field-${first.key}`)?.focus();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
pending = true;
|
|
85
|
+
failure = '';
|
|
86
|
+
const payload = $state.snapshot(values) as Record<string, MessageFormValue>;
|
|
87
|
+
const problem = await settle(() =>
|
|
88
|
+
onAction({ kind: 'form', messageId, blockIndex, action: block.action, values: payload })
|
|
89
|
+
);
|
|
90
|
+
pending = false;
|
|
91
|
+
if (problem) failure = problem;
|
|
92
|
+
else submitted = true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function setMulti(key: string, value: string, checked: boolean): void {
|
|
96
|
+
const current = (values[key] as string[] | null) ?? [];
|
|
97
|
+
values[key] = checked ? [...current, value] : current.filter((v) => v !== value);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const locked = $derived(!onAction || pending || submitted);
|
|
101
|
+
</script>
|
|
102
|
+
|
|
103
|
+
<form
|
|
104
|
+
class="grid gap-3 rounded-lg border border-border bg-elevated p-3"
|
|
105
|
+
data-py-message-block="form"
|
|
106
|
+
aria-labelledby="{uid}-heading"
|
|
107
|
+
novalidate
|
|
108
|
+
onsubmit={submit}
|
|
109
|
+
>
|
|
110
|
+
<p id="{uid}-heading" class="m-0 text-sm font-semibold">{block.title}</p>
|
|
111
|
+
<fieldset class="m-0 grid gap-3 border-0 p-0" disabled={locked}>
|
|
112
|
+
{#each block.fields as field (field.key)}
|
|
113
|
+
{@const id = `${uid}-field-${field.key}`}
|
|
114
|
+
{@const error = errors[field.key]}
|
|
115
|
+
{#if field.kind === 'checkbox'}
|
|
116
|
+
<div class="grid gap-1">
|
|
117
|
+
<Checkbox
|
|
118
|
+
{id}
|
|
119
|
+
label={field.label}
|
|
120
|
+
required={field.required}
|
|
121
|
+
disabled={locked}
|
|
122
|
+
checked={values[field.key] === true}
|
|
123
|
+
aria-invalid={error ? 'true' : undefined}
|
|
124
|
+
aria-describedby={error ? `${id}-error` : undefined}
|
|
125
|
+
onCheckedChange={(checked) => (values[field.key] = checked)}
|
|
126
|
+
/>
|
|
127
|
+
{#if error}<p id="{id}-error" class="m-0 text-xs text-destructive">{error}</p>{/if}
|
|
128
|
+
</div>
|
|
129
|
+
{:else if field.kind === 'multiselect'}
|
|
130
|
+
<fieldset class="m-0 grid gap-1 border-0 p-0" aria-describedby={error ? `${id}-error` : undefined}>
|
|
131
|
+
<legend class="mb-1 text-sm font-medium">
|
|
132
|
+
{field.label}{#if field.required}<span aria-hidden="true"> *</span>{/if}
|
|
133
|
+
</legend>
|
|
134
|
+
{#each field.options ?? [] as option, index (option.value)}
|
|
135
|
+
<Checkbox
|
|
136
|
+
id={index === 0 ? id : undefined}
|
|
137
|
+
label={option.label}
|
|
138
|
+
value={option.value}
|
|
139
|
+
disabled={locked}
|
|
140
|
+
checked={((values[field.key] as string[] | null) ?? []).includes(option.value)}
|
|
141
|
+
onCheckedChange={(checked) => setMulti(field.key, option.value, checked)}
|
|
142
|
+
/>
|
|
143
|
+
{/each}
|
|
144
|
+
{#if error}<p id="{id}-error" class="m-0 text-xs text-destructive">{error}</p>{/if}
|
|
145
|
+
</fieldset>
|
|
146
|
+
{:else}
|
|
147
|
+
<Field {id} label={field.label} required={field.required} {error}>
|
|
148
|
+
{#snippet children(control)}
|
|
149
|
+
{#if field.kind === 'textarea'}
|
|
150
|
+
<Textarea
|
|
151
|
+
{...control.props}
|
|
152
|
+
class="min-h-20"
|
|
153
|
+
placeholder={field.placeholder}
|
|
154
|
+
required={field.required}
|
|
155
|
+
maxlength={4000}
|
|
156
|
+
disabled={locked}
|
|
157
|
+
value={(values[field.key] as string | null) ?? ''}
|
|
158
|
+
onValueChange={(value) => (values[field.key] = value)}
|
|
159
|
+
/>
|
|
160
|
+
{:else if field.kind === 'select'}
|
|
161
|
+
<SelectRoot
|
|
162
|
+
value={(values[field.key] as string | null) ?? ''}
|
|
163
|
+
items={(field.options ?? []).map((option) => ({ value: option.value, label: option.label }))}
|
|
164
|
+
required={field.required}
|
|
165
|
+
disabled={locked}
|
|
166
|
+
onValueChange={(value) => (values[field.key] = value || null)}
|
|
167
|
+
>
|
|
168
|
+
<SelectTrigger {...control.props} disabled={locked} class="w-full">
|
|
169
|
+
<SelectValue placeholder={field.placeholder ?? 'Choose…'} />
|
|
170
|
+
</SelectTrigger>
|
|
171
|
+
<SelectContent>
|
|
172
|
+
{#each field.options ?? [] as option (option.value)}
|
|
173
|
+
<SelectItem value={option.value} label={option.label}>{option.label}</SelectItem>
|
|
174
|
+
{/each}
|
|
175
|
+
</SelectContent>
|
|
176
|
+
</SelectRoot>
|
|
177
|
+
{:else if field.kind === 'number'}
|
|
178
|
+
<Input
|
|
179
|
+
{...control.props}
|
|
180
|
+
type="number"
|
|
181
|
+
inputmode="decimal"
|
|
182
|
+
placeholder={field.placeholder}
|
|
183
|
+
required={field.required}
|
|
184
|
+
disabled={locked}
|
|
185
|
+
value={values[field.key] === null ? '' : String(values[field.key])}
|
|
186
|
+
onValueChange={(raw) => {
|
|
187
|
+
const parsed = Number(raw);
|
|
188
|
+
values[field.key] = raw === '' || !Number.isFinite(parsed) ? null : parsed;
|
|
189
|
+
}}
|
|
190
|
+
/>
|
|
191
|
+
{:else}
|
|
192
|
+
<Input
|
|
193
|
+
{...control.props}
|
|
194
|
+
type={field.kind === 'date' ? 'date' : 'text'}
|
|
195
|
+
placeholder={field.placeholder}
|
|
196
|
+
required={field.required}
|
|
197
|
+
maxlength={field.kind === 'text' ? 4000 : undefined}
|
|
198
|
+
disabled={locked}
|
|
199
|
+
value={(values[field.key] as string | null) ?? ''}
|
|
200
|
+
onValueChange={(value) => (values[field.key] = value)}
|
|
201
|
+
/>
|
|
202
|
+
{/if}
|
|
203
|
+
{/snippet}
|
|
204
|
+
</Field>
|
|
205
|
+
{/if}
|
|
206
|
+
{/each}
|
|
207
|
+
</fieldset>
|
|
208
|
+
|
|
209
|
+
<div class="flex flex-wrap items-center gap-3">
|
|
210
|
+
<!-- Pending follows the host's promise through `submit`, so autoLoading is off. -->
|
|
211
|
+
<Button type="submit" autoLoading={false} loading={pending} disabled={locked}>
|
|
212
|
+
{block.submitLabel ?? 'Submit'}
|
|
213
|
+
</Button>
|
|
214
|
+
<p class="m-0 text-xs text-muted-foreground" role="status" aria-live="polite">
|
|
215
|
+
{#if pending}Sending…{:else if submitted}Submitted.{/if}
|
|
216
|
+
</p>
|
|
217
|
+
</div>
|
|
218
|
+
{#if !onAction}
|
|
219
|
+
<p class="m-0 text-xs text-muted-foreground">This form cannot be sent from this view.</p>
|
|
220
|
+
{/if}
|
|
221
|
+
{#if failure}
|
|
222
|
+
<p class="m-0 text-xs text-destructive" role="alert">{failure}</p>
|
|
223
|
+
{/if}
|
|
224
|
+
</form>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { FormBlock } from '@protoyard/ai/messages';
|
|
2
|
+
import { type MessageActionHandler } from './types.ts';
|
|
3
|
+
type $$ComponentProps = {
|
|
4
|
+
block: FormBlock;
|
|
5
|
+
blockIndex: number;
|
|
6
|
+
messageId: string | null;
|
|
7
|
+
onAction?: MessageActionHandler;
|
|
8
|
+
};
|
|
9
|
+
declare const MessageForm: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
10
|
+
type MessageForm = ReturnType<typeof MessageForm>;
|
|
11
|
+
export default MessageForm;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { ExternalLink } from '@lucide/svelte';
|
|
3
|
+
import type { LinkPreviewBlock } from '@protoyard/ai/messages';
|
|
4
|
+
import { Card } from 'texuli/card';
|
|
5
|
+
import { LucideIcon } from 'texuli/icons/lucide';
|
|
6
|
+
import { hostOf, isInlineImageUrl } from './format.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A link card (Texuli's clickable `Card`). The page is never fetched: title,
|
|
10
|
+
* description and image come from the message, and the image is local or
|
|
11
|
+
* inline (the contract refuses remote images). The URL also goes through
|
|
12
|
+
* Texuli's URL policy at the untrusted tier; the link opens in a new tab
|
|
13
|
+
* without access to this page.
|
|
14
|
+
*/
|
|
15
|
+
let { block }: { block: LinkPreviewBlock } = $props();
|
|
16
|
+
|
|
17
|
+
const site = $derived(block.siteName ?? hostOf(block.url));
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<Card
|
|
21
|
+
class="flex max-w-md flex-row gap-3 p-3 text-sm no-underline"
|
|
22
|
+
data-py-message-block="link_preview"
|
|
23
|
+
href={block.url}
|
|
24
|
+
hrefTrust="untrusted"
|
|
25
|
+
target="_blank"
|
|
26
|
+
rel="noopener noreferrer"
|
|
27
|
+
>
|
|
28
|
+
{#if block.image && isInlineImageUrl(block.image)}
|
|
29
|
+
<img class="size-16 shrink-0 rounded-md border border-border object-cover" src={block.image} alt="" loading="lazy" />
|
|
30
|
+
{/if}
|
|
31
|
+
<span class="grid min-w-0 gap-0.5">
|
|
32
|
+
<span class="flex items-center gap-1 text-xs text-muted-foreground">
|
|
33
|
+
{site}
|
|
34
|
+
<LucideIcon icon={ExternalLink} size="xs" />
|
|
35
|
+
<span class="sr-only">(opens in a new tab)</span>
|
|
36
|
+
</span>
|
|
37
|
+
<span class="truncate font-medium text-foreground">{block.title ?? block.url}</span>
|
|
38
|
+
{#if block.description}
|
|
39
|
+
<span class="line-clamp-2 text-muted-foreground">{block.description}</span>
|
|
40
|
+
{/if}
|
|
41
|
+
</span>
|
|
42
|
+
</Card>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { LinkPreviewBlock } from '@protoyard/ai/messages';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
block: LinkPreviewBlock;
|
|
4
|
+
};
|
|
5
|
+
declare const MessageLinkPreview: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
6
|
+
type MessageLinkPreview = ReturnType<typeof MessageLinkPreview>;
|
|
7
|
+
export default MessageLinkPreview;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { SelectBlock } from '@protoyard/ai/messages';
|
|
3
|
+
import { Button } from 'texuli/button';
|
|
4
|
+
import { Checkbox } from 'texuli/choice';
|
|
5
|
+
import { SelectContent, SelectItem, SelectRoot, SelectTrigger, SelectValue } from 'texuli/select';
|
|
6
|
+
import { type MessageActionHandler, settle } from './types.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A choice, drawn with Texuli controls. A single select (`texuli/select`)
|
|
10
|
+
* reports `{ kind: 'select' }` as soon as it changes; a multiple select is a
|
|
11
|
+
* group of Texuli checkboxes with a Send button. A failed report restores the
|
|
12
|
+
* previous choice and shows why.
|
|
13
|
+
*/
|
|
14
|
+
let {
|
|
15
|
+
block,
|
|
16
|
+
blockIndex,
|
|
17
|
+
messageId,
|
|
18
|
+
onAction,
|
|
19
|
+
}: {
|
|
20
|
+
block: SelectBlock;
|
|
21
|
+
blockIndex: number;
|
|
22
|
+
messageId: string | null;
|
|
23
|
+
onAction?: MessageActionHandler;
|
|
24
|
+
} = $props();
|
|
25
|
+
|
|
26
|
+
const uid = $props.id();
|
|
27
|
+
// The block is fixed for this component's life; the initial choice is a starting value only.
|
|
28
|
+
// svelte-ignore state_referenced_locally
|
|
29
|
+
let selected = $state<string[]>([...(block.initial ?? [])]);
|
|
30
|
+
// svelte-ignore state_referenced_locally
|
|
31
|
+
let committed = $state<string[]>([...(block.initial ?? [])]);
|
|
32
|
+
let pending = $state(false);
|
|
33
|
+
let error = $state('');
|
|
34
|
+
let sent = $state(false);
|
|
35
|
+
|
|
36
|
+
async function send(values: string[]): Promise<void> {
|
|
37
|
+
if (!onAction || pending) return;
|
|
38
|
+
pending = true;
|
|
39
|
+
error = '';
|
|
40
|
+
const failure = await settle(() =>
|
|
41
|
+
onAction({ kind: 'select', messageId, blockIndex, action: block.action, values: [...values] })
|
|
42
|
+
);
|
|
43
|
+
pending = false;
|
|
44
|
+
if (failure) {
|
|
45
|
+
error = failure;
|
|
46
|
+
selected = [...committed];
|
|
47
|
+
} else {
|
|
48
|
+
committed = [...values];
|
|
49
|
+
sent = true;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function toggle(value: string, checked: boolean): void {
|
|
54
|
+
sent = false;
|
|
55
|
+
selected = checked ? [...selected, value] : selected.filter((v) => v !== value);
|
|
56
|
+
}
|
|
57
|
+
</script>
|
|
58
|
+
|
|
59
|
+
<div class="grid gap-2" data-py-message-block="select">
|
|
60
|
+
{#if block.multiple}
|
|
61
|
+
<fieldset class="m-0 grid gap-1.5 border-0 p-0" disabled={!onAction || pending}>
|
|
62
|
+
<legend class="mb-1 text-sm font-medium">{block.label ?? block.placeholder}</legend>
|
|
63
|
+
{#each block.options as option (option.value)}
|
|
64
|
+
<Checkbox
|
|
65
|
+
label={option.label}
|
|
66
|
+
description={option.description}
|
|
67
|
+
value={option.value}
|
|
68
|
+
checked={selected.includes(option.value)}
|
|
69
|
+
disabled={!onAction || pending}
|
|
70
|
+
onCheckedChange={(checked) => toggle(option.value, checked)}
|
|
71
|
+
/>
|
|
72
|
+
{/each}
|
|
73
|
+
</fieldset>
|
|
74
|
+
<div>
|
|
75
|
+
<!-- Pending is the component's own state (it also locks the choices), so autoLoading is off. -->
|
|
76
|
+
<Button
|
|
77
|
+
autoLoading={false}
|
|
78
|
+
loading={pending}
|
|
79
|
+
disabled={!onAction || pending || selected.length === 0}
|
|
80
|
+
onclick={() => send(selected)}
|
|
81
|
+
>
|
|
82
|
+
Send
|
|
83
|
+
</Button>
|
|
84
|
+
</div>
|
|
85
|
+
{:else}
|
|
86
|
+
<label class="text-sm font-medium" for="{uid}-select">{block.label ?? block.placeholder}</label>
|
|
87
|
+
<SelectRoot
|
|
88
|
+
value={selected[0] ?? ''}
|
|
89
|
+
items={block.options.map((option) => ({ value: option.value, label: option.label }))}
|
|
90
|
+
disabled={!onAction || pending}
|
|
91
|
+
onValueChange={(value) => {
|
|
92
|
+
selected = value ? [value] : [];
|
|
93
|
+
if (value) void send([value]);
|
|
94
|
+
}}
|
|
95
|
+
>
|
|
96
|
+
<SelectTrigger id="{uid}-select" class="w-full max-w-sm" aria-busy={pending ? 'true' : undefined}>
|
|
97
|
+
<SelectValue placeholder={block.placeholder} />
|
|
98
|
+
</SelectTrigger>
|
|
99
|
+
<SelectContent>
|
|
100
|
+
{#each block.options as option (option.value)}
|
|
101
|
+
<SelectItem value={option.value} label={option.label} description={option.description}>
|
|
102
|
+
{option.label}
|
|
103
|
+
</SelectItem>
|
|
104
|
+
{/each}
|
|
105
|
+
</SelectContent>
|
|
106
|
+
</SelectRoot>
|
|
107
|
+
{/if}
|
|
108
|
+
|
|
109
|
+
<p class="m-0 min-h-4 text-xs text-muted-foreground" role="status" aria-live="polite">
|
|
110
|
+
{#if pending}Sending…{:else if sent}Sent.{/if}
|
|
111
|
+
</p>
|
|
112
|
+
{#if !onAction}
|
|
113
|
+
<p class="m-0 text-xs text-muted-foreground">Choices are not available in this view.</p>
|
|
114
|
+
{/if}
|
|
115
|
+
{#if error}
|
|
116
|
+
<p class="m-0 text-xs text-destructive" role="alert">{error}</p>
|
|
117
|
+
{/if}
|
|
118
|
+
</div>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { SelectBlock } from '@protoyard/ai/messages';
|
|
2
|
+
import { type MessageActionHandler } from './types.ts';
|
|
3
|
+
type $$ComponentProps = {
|
|
4
|
+
block: SelectBlock;
|
|
5
|
+
blockIndex: number;
|
|
6
|
+
messageId: string | null;
|
|
7
|
+
onAction?: MessageActionHandler;
|
|
8
|
+
};
|
|
9
|
+
declare const MessageSelect: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
10
|
+
type MessageSelect = ReturnType<typeof MessageSelect>;
|
|
11
|
+
export default MessageSelect;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { TableBlock } from '@protoyard/ai/messages';
|
|
3
|
+
import {
|
|
4
|
+
Table,
|
|
5
|
+
TableBody,
|
|
6
|
+
TableCaption,
|
|
7
|
+
TableCell,
|
|
8
|
+
TableHead,
|
|
9
|
+
TableHeader,
|
|
10
|
+
TableRow,
|
|
11
|
+
} from 'texuli/table';
|
|
12
|
+
import { formatCell } from './format.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A data table on Texuli's `Table`: caption, column headers, aligned cells.
|
|
16
|
+
* When it overflows, Texuli's scroll area becomes a focusable region named by
|
|
17
|
+
* the title (WCAG 2.1.1), so wide tables stay reachable by keyboard.
|
|
18
|
+
*/
|
|
19
|
+
let { block }: { block: TableBlock } = $props();
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<div class="max-w-full" data-py-message-block="table">
|
|
23
|
+
<Table class="tabular-nums" label={block.title ?? 'Table'}>
|
|
24
|
+
{#if block.title}
|
|
25
|
+
<TableCaption>{block.title}</TableCaption>
|
|
26
|
+
{/if}
|
|
27
|
+
<TableHeader>
|
|
28
|
+
<TableRow>
|
|
29
|
+
{#each block.columns as column (column.key)}
|
|
30
|
+
<TableHead scope="col" align={column.align}>{column.header}</TableHead>
|
|
31
|
+
{/each}
|
|
32
|
+
</TableRow>
|
|
33
|
+
</TableHeader>
|
|
34
|
+
<TableBody>
|
|
35
|
+
{#each block.rows as row, index (index)}
|
|
36
|
+
<TableRow>
|
|
37
|
+
{#each block.columns as column (column.key)}
|
|
38
|
+
<TableCell align={column.align}>{formatCell(Object.hasOwn(row, column.key) ? row[column.key] : null)}</TableCell>
|
|
39
|
+
{/each}
|
|
40
|
+
</TableRow>
|
|
41
|
+
{:else}
|
|
42
|
+
<TableRow>
|
|
43
|
+
<TableCell class="text-muted-foreground" colspan={block.columns.length}>No rows.</TableCell>
|
|
44
|
+
</TableRow>
|
|
45
|
+
{/each}
|
|
46
|
+
</TableBody>
|
|
47
|
+
</Table>
|
|
48
|
+
</div>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { TableBlock } from '@protoyard/ai/messages';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
block: TableBlock;
|
|
4
|
+
};
|
|
5
|
+
declare const MessageTable: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
6
|
+
type MessageTable = ReturnType<typeof MessageTable>;
|
|
7
|
+
export default MessageTable;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Display helpers for message blocks. Every result is shown as text. */
|
|
2
|
+
/** A table cell as text: numbers localized, booleans as Yes/No, empty as an em dash. */
|
|
3
|
+
export declare function formatCell(value: string | number | boolean | null | undefined): string;
|
|
4
|
+
/** `1536` → `1.5 KB`. */
|
|
5
|
+
export declare function formatBytes(bytes: number): string;
|
|
6
|
+
/** The host of an http(s) URL without `www.`, or the URL itself if it does not parse. */
|
|
7
|
+
export declare function hostOf(url: string): string;
|
|
8
|
+
/** True for image MIME types the renderer may show inline (attachments are local or data: only). */
|
|
9
|
+
export declare function isImageMime(mime: string): boolean;
|
|
10
|
+
/**
|
|
11
|
+
* True when an attachment URL can be an `<img src>`: an inline raster image
|
|
12
|
+
* or a relative path. `attachment:<id>` references are resolved by the host,
|
|
13
|
+
* so they show as a named file instead.
|
|
14
|
+
*/
|
|
15
|
+
export declare function isInlineImageUrl(url: string): boolean;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** Display helpers for message blocks. Every result is shown as text. */
|
|
2
|
+
const NUMBER = new Intl.NumberFormat('en-US', { maximumFractionDigits: 4 });
|
|
3
|
+
/** A table cell as text: numbers localized, booleans as Yes/No, empty as an em dash. */
|
|
4
|
+
export function formatCell(value) {
|
|
5
|
+
if (value === null || value === undefined || value === '')
|
|
6
|
+
return '—';
|
|
7
|
+
if (typeof value === 'number')
|
|
8
|
+
return NUMBER.format(value);
|
|
9
|
+
if (typeof value === 'boolean')
|
|
10
|
+
return value ? 'Yes' : 'No';
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
/** `1536` → `1.5 KB`. */
|
|
14
|
+
export function formatBytes(bytes) {
|
|
15
|
+
if (bytes < 1024)
|
|
16
|
+
return `${bytes} B`;
|
|
17
|
+
const units = ['KB', 'MB', 'GB'];
|
|
18
|
+
let value = bytes / 1024;
|
|
19
|
+
let unit = 0;
|
|
20
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
21
|
+
value /= 1024;
|
|
22
|
+
unit += 1;
|
|
23
|
+
}
|
|
24
|
+
return `${value >= 10 ? Math.round(value) : value.toFixed(1)} ${units[unit]}`;
|
|
25
|
+
}
|
|
26
|
+
/** The host of an http(s) URL without `www.`, or the URL itself if it does not parse. */
|
|
27
|
+
export function hostOf(url) {
|
|
28
|
+
try {
|
|
29
|
+
return new URL(url).hostname.replace(/^www\./, '');
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return url;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** True for image MIME types the renderer may show inline (attachments are local or data: only). */
|
|
36
|
+
export function isImageMime(mime) {
|
|
37
|
+
return mime.startsWith('image/');
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* True when an attachment URL can be an `<img src>`: an inline raster image
|
|
41
|
+
* or a relative path. `attachment:<id>` references are resolved by the host,
|
|
42
|
+
* so they show as a named file instead.
|
|
43
|
+
*/
|
|
44
|
+
export function isInlineImageUrl(url) {
|
|
45
|
+
return url.startsWith('data:image/') || !/^[a-z][a-z0-9+.-]*:/i.test(url);
|
|
46
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@protoyard/ai-ui/messages` — renders `protoyard.message@1` chat messages
|
|
3
|
+
* from `@protoyard/ai/messages`: text (plain or safe markdown), buttons with
|
|
4
|
+
* confirmations, selects, forms, tables, link previews, attachments and the
|
|
5
|
+
* fallback for unknown blocks. A lazy subpath, never in the ai-ui root.
|
|
6
|
+
* Callbacks only: the prototype's services decide what an action does.
|
|
7
|
+
*/
|
|
8
|
+
export { default as ChatMessageBlocks } from './ChatMessageBlocks.svelte';
|
|
9
|
+
export { formatBytes, formatCell, hostOf } from './format.ts';
|
|
10
|
+
export type { MessageActionEvent, MessageActionHandler, MessageFormValue } from './types.ts';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@protoyard/ai-ui/messages` — renders `protoyard.message@1` chat messages
|
|
3
|
+
* from `@protoyard/ai/messages`: text (plain or safe markdown), buttons with
|
|
4
|
+
* confirmations, selects, forms, tables, link previews, attachments and the
|
|
5
|
+
* fallback for unknown blocks. A lazy subpath, never in the ai-ui root.
|
|
6
|
+
* Callbacks only: the prototype's services decide what an action does.
|
|
7
|
+
*/
|
|
8
|
+
export { default as ChatMessageBlocks } from './ChatMessageBlocks.svelte';
|
|
9
|
+
export { formatBytes, formatCell, hostOf } from "./format.js";
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** A value a chat form field submits. */
|
|
2
|
+
export type MessageFormValue = string | number | boolean | string[] | null;
|
|
3
|
+
interface ActionBase {
|
|
4
|
+
/** The message's `id`, or null when it has none. */
|
|
5
|
+
messageId: string | null;
|
|
6
|
+
/** Index of the block in `message.blocks`. */
|
|
7
|
+
blockIndex: number;
|
|
8
|
+
/** The block's or button's action id. */
|
|
9
|
+
action: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* What a chat message reports when someone acts on it. The host (a prototype
|
|
13
|
+
* service) decides what happens; the renderer only reports the typed event
|
|
14
|
+
* and shows pending, error or done feedback around the returned promise.
|
|
15
|
+
*/
|
|
16
|
+
export type MessageActionEvent = (ActionBase & {
|
|
17
|
+
kind: 'button';
|
|
18
|
+
value: string | null;
|
|
19
|
+
}) | (ActionBase & {
|
|
20
|
+
kind: 'select';
|
|
21
|
+
values: string[];
|
|
22
|
+
}) | (ActionBase & {
|
|
23
|
+
kind: 'form';
|
|
24
|
+
values: Record<string, MessageFormValue>;
|
|
25
|
+
});
|
|
26
|
+
/**
|
|
27
|
+
* Handles an action. Return a promise to keep the control busy until it
|
|
28
|
+
* settles; a rejection shows its message next to the control and re-enables it.
|
|
29
|
+
*/
|
|
30
|
+
export type MessageActionHandler = (event: MessageActionEvent) => unknown | Promise<unknown>;
|
|
31
|
+
/** Runs `work`, turning a throw or rejection into a short message. */
|
|
32
|
+
export declare function settle(work: () => unknown | Promise<unknown>): Promise<string | null>;
|
|
33
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Runs `work`, turning a throw or rejection into a short message. */
|
|
2
|
+
export async function settle(work) {
|
|
3
|
+
try {
|
|
4
|
+
await Promise.resolve(work());
|
|
5
|
+
return null;
|
|
6
|
+
}
|
|
7
|
+
catch (error) {
|
|
8
|
+
const message = error instanceof Error ? error.message.trim() : '';
|
|
9
|
+
return message ? message.slice(0, 200) : 'The action could not be completed.';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ChatReadingAnchor } from '@texuli/chat';
|
|
2
|
+
/**
|
|
3
|
+
* The reading position of one conversation, kept in sessionStorage so it
|
|
4
|
+
* survives switching conversations in the tab. `@texuli/chat`'s MessageList
|
|
5
|
+
* captures and restores it (`anchor`, `onAnchorChange`, G71); this module only
|
|
6
|
+
* stores it. A position at the latest message is not stored: the list opens
|
|
7
|
+
* there anyway.
|
|
8
|
+
*/
|
|
9
|
+
export type { ChatReadingAnchor };
|
|
10
|
+
/** Storage key for a conversation (v2: Texuli's anchor, offset measured from the viewport top). */
|
|
11
|
+
export declare const readingAnchorKey: (conversationId: string) => string;
|
|
12
|
+
export declare function saveReadingAnchor(key: string, anchor: ChatReadingAnchor): void;
|
|
13
|
+
export declare function loadReadingAnchor(key: string): ChatReadingAnchor | null;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Storage key for a conversation (v2: Texuli's anchor, offset measured from the viewport top). */
|
|
2
|
+
export const readingAnchorKey = (conversationId) => `protoyard:ai-ui:reading:${conversationId}`;
|
|
3
|
+
export function saveReadingAnchor(key, anchor) {
|
|
4
|
+
if (typeof sessionStorage === 'undefined')
|
|
5
|
+
return;
|
|
6
|
+
try {
|
|
7
|
+
if (anchor.atLatest)
|
|
8
|
+
sessionStorage.removeItem(key);
|
|
9
|
+
else
|
|
10
|
+
sessionStorage.setItem(key, JSON.stringify(anchor));
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
// Storage can be unavailable in private browsing or quota-constrained contexts.
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export function loadReadingAnchor(key) {
|
|
17
|
+
if (typeof sessionStorage === 'undefined')
|
|
18
|
+
return null;
|
|
19
|
+
try {
|
|
20
|
+
const value = JSON.parse(sessionStorage.getItem(key) ?? 'null');
|
|
21
|
+
if (typeof value === 'object' &&
|
|
22
|
+
value !== null &&
|
|
23
|
+
'messageId' in value &&
|
|
24
|
+
typeof value.messageId === 'string' &&
|
|
25
|
+
value.messageId !== '' &&
|
|
26
|
+
'offset' in value &&
|
|
27
|
+
typeof value.offset === 'number' &&
|
|
28
|
+
Number.isFinite(value.offset))
|
|
29
|
+
return { messageId: value.messageId, offset: value.offset, atLatest: false };
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// Ignore stale or malformed saved state.
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
package/dist/styles.css
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|