@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,248 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
type ApprovalActor,
|
|
4
|
+
mayDecide,
|
|
5
|
+
type Suggestion,
|
|
6
|
+
type SuggestionTarget,
|
|
7
|
+
statusLine,
|
|
8
|
+
} from '@protoyard/ai';
|
|
9
|
+
import { Badge } from 'texuli/badge';
|
|
10
|
+
import { Button } from 'texuli/button';
|
|
11
|
+
import { Card } from 'texuli/card';
|
|
12
|
+
import { Label } from 'texuli/field';
|
|
13
|
+
import { SelectContent, SelectItem, SelectRoot, SelectTrigger, SelectValue } from 'texuli/select';
|
|
14
|
+
import { modeNeedsApproval, SUGGESTION_STATUS_LABELS } from './labels.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Evidence-backed suggestion card. Every decision is an explicit callback the
|
|
18
|
+
* prototype routes through its services. Pending feedback is local UI only.
|
|
19
|
+
* Texuli card, badge, buttons and select.
|
|
20
|
+
* Simulated — not real authority or security.
|
|
21
|
+
*/
|
|
22
|
+
type Decision = 'confirm' | 'approve' | 'dismiss' | 'edit';
|
|
23
|
+
|
|
24
|
+
let {
|
|
25
|
+
suggestion,
|
|
26
|
+
viewer,
|
|
27
|
+
targets = [],
|
|
28
|
+
notified = false,
|
|
29
|
+
resultHref,
|
|
30
|
+
onConfirm,
|
|
31
|
+
onApprove,
|
|
32
|
+
onDismiss,
|
|
33
|
+
onEdit,
|
|
34
|
+
class: className = '',
|
|
35
|
+
}: {
|
|
36
|
+
suggestion: Suggestion;
|
|
37
|
+
viewer: ApprovalActor;
|
|
38
|
+
/** Optional retarget choices for Edit. */
|
|
39
|
+
targets?: readonly SuggestionTarget[];
|
|
40
|
+
/** Whether a notify audit row already exists (status line wording). */
|
|
41
|
+
notified?: boolean;
|
|
42
|
+
resultHref?: string;
|
|
43
|
+
onConfirm?: (suggestion: Suggestion) => unknown | Promise<unknown>;
|
|
44
|
+
onApprove?: (suggestion: Suggestion) => unknown | Promise<unknown>;
|
|
45
|
+
onDismiss?: (suggestion: Suggestion) => unknown | Promise<unknown>;
|
|
46
|
+
onEdit?: (suggestion: Suggestion, target: SuggestionTarget) => unknown | Promise<unknown>;
|
|
47
|
+
class?: string;
|
|
48
|
+
} = $props();
|
|
49
|
+
|
|
50
|
+
let pending = $state<Decision | null>(null);
|
|
51
|
+
let error = $state('');
|
|
52
|
+
let editOpen = $state(false);
|
|
53
|
+
let pickedId = $state<string | null>(null);
|
|
54
|
+
|
|
55
|
+
const needsApproval = $derived(modeNeedsApproval(suggestion.mode));
|
|
56
|
+
const waiting = $derived(suggestion.status === 'pending');
|
|
57
|
+
const canConfirm = $derived(waiting && !needsApproval && mayDecide(viewer, suggestion, 'confirm'));
|
|
58
|
+
const canApprove = $derived(waiting && needsApproval && mayDecide(viewer, suggestion, 'approve'));
|
|
59
|
+
const canDismiss = $derived(waiting && mayDecide(viewer, suggestion, 'dismiss'));
|
|
60
|
+
const canEdit = $derived(waiting && mayDecide(viewer, suggestion, 'edit') && targets.length > 0);
|
|
61
|
+
const line = $derived(statusLine(suggestion, viewer, notified));
|
|
62
|
+
const busy = $derived(pending !== null);
|
|
63
|
+
|
|
64
|
+
async function run(decision: Decision, work: () => unknown | Promise<unknown>): Promise<void> {
|
|
65
|
+
if (busy) return;
|
|
66
|
+
pending = decision;
|
|
67
|
+
error = '';
|
|
68
|
+
try {
|
|
69
|
+
await Promise.resolve(work());
|
|
70
|
+
} catch (err) {
|
|
71
|
+
error = err instanceof Error ? err.message : 'The action could not be completed.';
|
|
72
|
+
} finally {
|
|
73
|
+
pending = null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function openEdit(): void {
|
|
78
|
+
pickedId = suggestion.targetId ?? targets[0]?.targetId ?? null;
|
|
79
|
+
editOpen = true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function saveEdit(): void {
|
|
83
|
+
const target = targets.find((row) => row.targetId === pickedId);
|
|
84
|
+
if (!target || !onEdit) return;
|
|
85
|
+
void run('edit', () => onEdit(suggestion, target)).then(() => {
|
|
86
|
+
if (!error) editOpen = false;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
</script>
|
|
90
|
+
|
|
91
|
+
<Card
|
|
92
|
+
class="grid gap-3 p-4 {className}"
|
|
93
|
+
data-py-suggestion-card
|
|
94
|
+
data-status={suggestion.status}
|
|
95
|
+
data-mode={suggestion.mode}
|
|
96
|
+
aria-labelledby="suggestion-{suggestion.id}-title"
|
|
97
|
+
>
|
|
98
|
+
<header class="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
|
99
|
+
<Badge variant="secondary" data-py-agent-chip>Simulated agent</Badge>
|
|
100
|
+
<span>{SUGGESTION_STATUS_LABELS[suggestion.status]}</span>
|
|
101
|
+
<span class="ml-auto text-[0.7rem]">Simulated · no real authority</span>
|
|
102
|
+
</header>
|
|
103
|
+
|
|
104
|
+
<h3 id="suggestion-{suggestion.id}-title" class="m-0 text-base font-semibold text-foreground">
|
|
105
|
+
{suggestion.summary}
|
|
106
|
+
</h3>
|
|
107
|
+
|
|
108
|
+
{#if suggestion.targetLabel || suggestion.targetKey}
|
|
109
|
+
<p class="m-0 text-sm text-muted-foreground">
|
|
110
|
+
Target:
|
|
111
|
+
<span class="font-medium text-foreground"
|
|
112
|
+
>{suggestion.targetLabel ?? suggestion.targetKey}</span
|
|
113
|
+
>
|
|
114
|
+
{#if suggestion.targetKey && suggestion.targetLabel}
|
|
115
|
+
<span class="text-muted-foreground"> ({suggestion.targetKey})</span>
|
|
116
|
+
{/if}
|
|
117
|
+
</p>
|
|
118
|
+
{/if}
|
|
119
|
+
|
|
120
|
+
{#if suggestion.preparedApplySummary || suggestion.preparedNotifySummary}
|
|
121
|
+
<dl class="m-0 grid gap-1 rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm">
|
|
122
|
+
{#if suggestion.preparedApplySummary}
|
|
123
|
+
<div>
|
|
124
|
+
<dt class="font-medium text-foreground">Prepared change</dt>
|
|
125
|
+
<dd class="m-0 text-muted-foreground">{suggestion.preparedApplySummary}</dd>
|
|
126
|
+
</div>
|
|
127
|
+
{/if}
|
|
128
|
+
{#if suggestion.preparedNotifySummary}
|
|
129
|
+
<div>
|
|
130
|
+
<dt class="font-medium text-foreground">Prepared notification</dt>
|
|
131
|
+
<dd class="m-0 text-muted-foreground">{suggestion.preparedNotifySummary}</dd>
|
|
132
|
+
</div>
|
|
133
|
+
{/if}
|
|
134
|
+
</dl>
|
|
135
|
+
{/if}
|
|
136
|
+
|
|
137
|
+
{#if waiting && needsApproval && !viewer.canApprove}
|
|
138
|
+
<p class="m-0 text-sm text-muted-foreground" role="status">Waiting for an approver.</p>
|
|
139
|
+
{/if}
|
|
140
|
+
|
|
141
|
+
{#if waiting && (canApprove || canConfirm || canDismiss || canEdit)}
|
|
142
|
+
<div class="flex flex-wrap gap-2" aria-label="Suggestion actions">
|
|
143
|
+
{#if canApprove && onApprove}
|
|
144
|
+
<Button
|
|
145
|
+
variant="default"
|
|
146
|
+
autoLoading={false}
|
|
147
|
+
loading={pending === 'approve'}
|
|
148
|
+
disabled={busy}
|
|
149
|
+
onclick={() => run('approve', () => onApprove(suggestion))}
|
|
150
|
+
>
|
|
151
|
+
Approve
|
|
152
|
+
</Button>
|
|
153
|
+
{:else if canConfirm && onConfirm}
|
|
154
|
+
<Button
|
|
155
|
+
variant="default"
|
|
156
|
+
autoLoading={false}
|
|
157
|
+
loading={pending === 'confirm'}
|
|
158
|
+
disabled={busy}
|
|
159
|
+
onclick={() => run('confirm', () => onConfirm(suggestion))}
|
|
160
|
+
>
|
|
161
|
+
Confirm
|
|
162
|
+
</Button>
|
|
163
|
+
{/if}
|
|
164
|
+
{#if canEdit && onEdit}
|
|
165
|
+
<Button variant="outline" disabled={busy} onclick={openEdit}>Edit</Button>
|
|
166
|
+
{/if}
|
|
167
|
+
{#if canDismiss && onDismiss}
|
|
168
|
+
<Button
|
|
169
|
+
variant="outline"
|
|
170
|
+
autoLoading={false}
|
|
171
|
+
loading={pending === 'dismiss'}
|
|
172
|
+
disabled={busy}
|
|
173
|
+
onclick={() => run('dismiss', () => onDismiss(suggestion))}
|
|
174
|
+
>
|
|
175
|
+
Dismiss
|
|
176
|
+
</Button>
|
|
177
|
+
{/if}
|
|
178
|
+
</div>
|
|
179
|
+
{:else if !waiting}
|
|
180
|
+
<p class="m-0 text-sm text-muted-foreground" role="status">
|
|
181
|
+
{#if suggestion.status === 'confirmed' && resultHref}
|
|
182
|
+
<a class="font-medium text-primary underline" href={resultHref}>View result</a>
|
|
183
|
+
{:else}
|
|
184
|
+
{line}
|
|
185
|
+
{/if}
|
|
186
|
+
</p>
|
|
187
|
+
{/if}
|
|
188
|
+
|
|
189
|
+
{#if pending}
|
|
190
|
+
<p class="m-0 text-xs text-muted-foreground" role="status" aria-live="polite">
|
|
191
|
+
{pending === 'approve'
|
|
192
|
+
? 'Approving…'
|
|
193
|
+
: pending === 'dismiss'
|
|
194
|
+
? 'Dismissing…'
|
|
195
|
+
: pending === 'edit'
|
|
196
|
+
? 'Saving…'
|
|
197
|
+
: 'Confirming…'}
|
|
198
|
+
</p>
|
|
199
|
+
{/if}
|
|
200
|
+
{#if error}
|
|
201
|
+
<p class="m-0 text-sm text-destructive" role="alert">{error}</p>
|
|
202
|
+
{/if}
|
|
203
|
+
|
|
204
|
+
{#if editOpen}
|
|
205
|
+
<div
|
|
206
|
+
class="grid gap-3 rounded-lg border border-border bg-muted/30 p-3"
|
|
207
|
+
data-py-suggestion-edit
|
|
208
|
+
>
|
|
209
|
+
<div class="grid gap-1 text-sm">
|
|
210
|
+
<Label for="suggestion-edit-{suggestion.id}">Link to target</Label>
|
|
211
|
+
<SelectRoot
|
|
212
|
+
items={targets.map((target) => ({
|
|
213
|
+
value: target.targetId,
|
|
214
|
+
label: `${target.targetKey} — ${target.targetLabel}`,
|
|
215
|
+
}))}
|
|
216
|
+
value={pickedId ?? undefined}
|
|
217
|
+
onValueChange={(next) => {
|
|
218
|
+
pickedId = next;
|
|
219
|
+
}}
|
|
220
|
+
>
|
|
221
|
+
<SelectTrigger id="suggestion-edit-{suggestion.id}">
|
|
222
|
+
<SelectValue placeholder="Choose a target" />
|
|
223
|
+
</SelectTrigger>
|
|
224
|
+
<SelectContent>
|
|
225
|
+
{#each targets as target (target.targetId)}
|
|
226
|
+
<SelectItem value={target.targetId} label={`${target.targetKey} — ${target.targetLabel}`}
|
|
227
|
+
>{target.targetKey} — {target.targetLabel}</SelectItem
|
|
228
|
+
>
|
|
229
|
+
{/each}
|
|
230
|
+
</SelectContent>
|
|
231
|
+
</SelectRoot>
|
|
232
|
+
</div>
|
|
233
|
+
<div class="flex flex-wrap gap-2">
|
|
234
|
+
<Button
|
|
235
|
+
autoLoading={false}
|
|
236
|
+
loading={pending === 'edit'}
|
|
237
|
+
disabled={busy || !pickedId}
|
|
238
|
+
onclick={saveEdit}
|
|
239
|
+
>
|
|
240
|
+
Save
|
|
241
|
+
</Button>
|
|
242
|
+
<Button variant="outline" disabled={busy} onclick={() => (editOpen = false)}>
|
|
243
|
+
Cancel
|
|
244
|
+
</Button>
|
|
245
|
+
</div>
|
|
246
|
+
</div>
|
|
247
|
+
{/if}
|
|
248
|
+
</Card>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type ApprovalActor, type Suggestion, type SuggestionTarget } from '@protoyard/ai';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
suggestion: Suggestion;
|
|
4
|
+
viewer: ApprovalActor;
|
|
5
|
+
/** Optional retarget choices for Edit. */
|
|
6
|
+
targets?: readonly SuggestionTarget[];
|
|
7
|
+
/** Whether a notify audit row already exists (status line wording). */
|
|
8
|
+
notified?: boolean;
|
|
9
|
+
resultHref?: string;
|
|
10
|
+
onConfirm?: (suggestion: Suggestion) => unknown | Promise<unknown>;
|
|
11
|
+
onApprove?: (suggestion: Suggestion) => unknown | Promise<unknown>;
|
|
12
|
+
onDismiss?: (suggestion: Suggestion) => unknown | Promise<unknown>;
|
|
13
|
+
onEdit?: (suggestion: Suggestion, target: SuggestionTarget) => unknown | Promise<unknown>;
|
|
14
|
+
class?: string;
|
|
15
|
+
};
|
|
16
|
+
declare const SuggestionCard: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
17
|
+
type SuggestionCard = ReturnType<typeof SuggestionCard>;
|
|
18
|
+
export default SuggestionCard;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Suggestion approval UI for `@protoyard/ai` approval loop. Lazy subpath
|
|
3
|
+
* `@protoyard/ai-ui/approvals`. Callbacks only — prototypes own services.
|
|
4
|
+
*/
|
|
5
|
+
export { default as AuditTrail } from './AuditTrail.svelte';
|
|
6
|
+
export { default as AutonomyLevelPicker } from './AutonomyLevelPicker.svelte';
|
|
7
|
+
export { default as ChatApprovalCard } from './ChatApprovalCard.svelte';
|
|
8
|
+
export { AUDIT_ACTION_LABELS, AUTONOMY_AREA_LABELS, AUTONOMY_LEVEL_LABELS, AUTONOMY_LEVELS, areaLabel, modeNeedsApproval, SUGGESTION_STATUS_LABELS, } from './labels.ts';
|
|
9
|
+
export { default as SuggestionCard } from './SuggestionCard.svelte';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Suggestion approval UI for `@protoyard/ai` approval loop. Lazy subpath
|
|
3
|
+
* `@protoyard/ai-ui/approvals`. Callbacks only — prototypes own services.
|
|
4
|
+
*/
|
|
5
|
+
export { default as AuditTrail } from './AuditTrail.svelte';
|
|
6
|
+
export { default as AutonomyLevelPicker } from './AutonomyLevelPicker.svelte';
|
|
7
|
+
export { default as ChatApprovalCard } from './ChatApprovalCard.svelte';
|
|
8
|
+
export { AUDIT_ACTION_LABELS, AUTONOMY_AREA_LABELS, AUTONOMY_LEVEL_LABELS, AUTONOMY_LEVELS, areaLabel, modeNeedsApproval, SUGGESTION_STATUS_LABELS, } from "./labels.js";
|
|
9
|
+
export { default as SuggestionCard } from './SuggestionCard.svelte';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { AUTONOMY_AREAS, AUTONOMY_LEVELS, type AuditAction, type AutonomyLevel, type PolicyMode, type SuggestionStatus } from '@protoyard/ai';
|
|
2
|
+
/** Human labels for the four autonomy levels (simulated product model). */
|
|
3
|
+
export declare const AUTONOMY_LEVEL_LABELS: Record<AutonomyLevel, {
|
|
4
|
+
name: string;
|
|
5
|
+
definition: string;
|
|
6
|
+
}>;
|
|
7
|
+
export declare const AUTONOMY_AREA_LABELS: Record<(typeof AUTONOMY_AREAS)[number], string>;
|
|
8
|
+
export declare const AUDIT_ACTION_LABELS: Record<AuditAction, string>;
|
|
9
|
+
export declare const SUGGESTION_STATUS_LABELS: Record<SuggestionStatus, string>;
|
|
10
|
+
export declare function modeNeedsApproval(mode: PolicyMode): boolean;
|
|
11
|
+
export declare function areaLabel(area: string): string;
|
|
12
|
+
export { AUTONOMY_LEVELS };
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { AUTONOMY_AREAS, AUTONOMY_LEVELS, } from '@protoyard/ai';
|
|
2
|
+
/** Human labels for the four autonomy levels (simulated product model). */
|
|
3
|
+
export const AUTONOMY_LEVEL_LABELS = {
|
|
4
|
+
1: {
|
|
5
|
+
name: 'Inform',
|
|
6
|
+
definition: 'The agent only tells the team. No waiting card.',
|
|
7
|
+
},
|
|
8
|
+
2: {
|
|
9
|
+
name: 'Suggest',
|
|
10
|
+
definition: 'The agent proposes a change and waits for a person to confirm.',
|
|
11
|
+
},
|
|
12
|
+
3: {
|
|
13
|
+
name: 'Act with approval',
|
|
14
|
+
definition: 'The agent prepares the change; an approver must approve first.',
|
|
15
|
+
},
|
|
16
|
+
4: {
|
|
17
|
+
name: 'Act within limits',
|
|
18
|
+
definition: 'The agent may apply the change on its own (still simulated).',
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
export const AUTONOMY_AREA_LABELS = {
|
|
22
|
+
records: 'Records',
|
|
23
|
+
notifications: 'Notifications',
|
|
24
|
+
workspace: 'Workspace',
|
|
25
|
+
external: 'External messaging',
|
|
26
|
+
};
|
|
27
|
+
export const AUDIT_ACTION_LABELS = {
|
|
28
|
+
suggested: 'Suggested',
|
|
29
|
+
confirmed: 'Confirmed',
|
|
30
|
+
approved: 'Approved',
|
|
31
|
+
dismissed: 'Dismissed',
|
|
32
|
+
edited: 'Edited',
|
|
33
|
+
auto_handled: 'Handled automatically',
|
|
34
|
+
notified: 'Notified',
|
|
35
|
+
informed: 'Informed',
|
|
36
|
+
};
|
|
37
|
+
export const SUGGESTION_STATUS_LABELS = {
|
|
38
|
+
pending: 'Waiting',
|
|
39
|
+
confirmed: 'Confirmed',
|
|
40
|
+
dismissed: 'Dismissed',
|
|
41
|
+
auto: 'Handled automatically',
|
|
42
|
+
informed: 'Informed',
|
|
43
|
+
};
|
|
44
|
+
export function modeNeedsApproval(mode) {
|
|
45
|
+
return mode === 'act_with_approval';
|
|
46
|
+
}
|
|
47
|
+
export function areaLabel(area) {
|
|
48
|
+
if (AUTONOMY_AREAS.includes(area)) {
|
|
49
|
+
return AUTONOMY_AREA_LABELS[area];
|
|
50
|
+
}
|
|
51
|
+
return area;
|
|
52
|
+
}
|
|
53
|
+
export { AUTONOMY_LEVELS };
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Paperclip } from '@lucide/svelte';
|
|
3
|
+
import { AI_MODELS, type AiModel } from '@protoyard/ai';
|
|
4
|
+
import { Composer } from '@texuli/chat';
|
|
5
|
+
import { Tag } from 'texuli/badge';
|
|
6
|
+
import { Button, IconButton } from 'texuli/button';
|
|
7
|
+
import { LucideIcon } from 'texuli/icons/lucide';
|
|
8
|
+
import { Input } from 'texuli/input';
|
|
9
|
+
import { Select } from 'texuli/select';
|
|
10
|
+
import type { AssistantSendInput } from '../types.js';
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
models?: readonly AiModel[];
|
|
14
|
+
modelId?: string;
|
|
15
|
+
value?: string;
|
|
16
|
+
streaming?: boolean;
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
placeholder?: string;
|
|
19
|
+
slashCommands?: readonly string[];
|
|
20
|
+
mentions?: readonly { id: string; label: string }[];
|
|
21
|
+
onModelChange?: (modelId: string) => void;
|
|
22
|
+
onSubmit: (input: AssistantSendInput) => unknown;
|
|
23
|
+
onStop?: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let {
|
|
27
|
+
models = AI_MODELS,
|
|
28
|
+
modelId = models[0]?.id ?? '',
|
|
29
|
+
value = $bindable(''),
|
|
30
|
+
streaming = false,
|
|
31
|
+
disabled = false,
|
|
32
|
+
placeholder = 'Ask anything',
|
|
33
|
+
slashCommands = ['/summarize', '/brainstorm', '/explain'],
|
|
34
|
+
mentions = [],
|
|
35
|
+
onModelChange,
|
|
36
|
+
onSubmit,
|
|
37
|
+
onStop,
|
|
38
|
+
}: Props = $props();
|
|
39
|
+
|
|
40
|
+
let files = $state<File[]>([]);
|
|
41
|
+
let picked = $state<FileList | null>(null);
|
|
42
|
+
let picker = $state<HTMLElement | null>(null);
|
|
43
|
+
let root = $state<HTMLDivElement | null>(null);
|
|
44
|
+
let activeSuggestion = $state(0);
|
|
45
|
+
const modelItems = $derived(
|
|
46
|
+
models.map((model) => ({ value: model.id, label: `${model.name} · Simulated` }))
|
|
47
|
+
);
|
|
48
|
+
const trigger = $derived(/(?:^|\s)([/@][\w-]*)$/.exec(value));
|
|
49
|
+
const suggestions = $derived.by(() => {
|
|
50
|
+
if (!trigger?.[1]) return [];
|
|
51
|
+
const token = trigger[1].toLowerCase();
|
|
52
|
+
if (token.startsWith('/')) {
|
|
53
|
+
return slashCommands
|
|
54
|
+
.filter((command) => command.toLowerCase().startsWith(token))
|
|
55
|
+
.slice(0, 6)
|
|
56
|
+
.map((command) => ({ id: command, label: command, kind: 'command' as const }));
|
|
57
|
+
}
|
|
58
|
+
return mentions
|
|
59
|
+
.filter((mention) => mention.label.toLowerCase().startsWith(token.slice(1)))
|
|
60
|
+
.slice(0, 6)
|
|
61
|
+
.map((mention) => ({ ...mention, kind: 'mention' as const }));
|
|
62
|
+
});
|
|
63
|
+
const selectedModel = $derived(models.find((model) => model.id === modelId));
|
|
64
|
+
|
|
65
|
+
function chooseSuggestion(item: (typeof suggestions)[number]): void {
|
|
66
|
+
if (!trigger?.[1]) return;
|
|
67
|
+
const start = value.lastIndexOf(trigger[1]);
|
|
68
|
+
const insertion = item.kind === 'mention' ? `@${item.label} ` : `${item.label} `;
|
|
69
|
+
value = `${value.slice(0, start)}${insertion}`;
|
|
70
|
+
activeSuggestion = 0;
|
|
71
|
+
requestAnimationFrame(() => root?.querySelector('textarea')?.focus());
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The composer sends the trimmed text; the model and attachments travel with it. */
|
|
75
|
+
async function send(prompt: string): Promise<unknown> {
|
|
76
|
+
if (!selectedModel) return false;
|
|
77
|
+
const result = await onSubmit({ prompt, model: selectedModel, attachments: files });
|
|
78
|
+
if (result !== false) files = [];
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The suggestion list takes the arrows and Enter; Texuli's Composer keeps Enter to send and Escape to stop. */
|
|
83
|
+
function handleKeydown(event: KeyboardEvent): void {
|
|
84
|
+
if (!suggestions.length) return;
|
|
85
|
+
if (event.key === 'ArrowDown') {
|
|
86
|
+
event.preventDefault();
|
|
87
|
+
activeSuggestion = (activeSuggestion + 1) % suggestions.length;
|
|
88
|
+
} else if (event.key === 'ArrowUp') {
|
|
89
|
+
event.preventDefault();
|
|
90
|
+
activeSuggestion = (activeSuggestion - 1 + suggestions.length) % suggestions.length;
|
|
91
|
+
} else if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
|
|
92
|
+
event.preventDefault();
|
|
93
|
+
const item = suggestions[activeSuggestion];
|
|
94
|
+
if (item) chooseSuggestion(item);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function addFiles(event: Event): void {
|
|
99
|
+
const input = event.currentTarget;
|
|
100
|
+
if (input instanceof HTMLInputElement && input.files)
|
|
101
|
+
files = [...files, ...Array.from(input.files)];
|
|
102
|
+
picked = null;
|
|
103
|
+
}
|
|
104
|
+
</script>
|
|
105
|
+
|
|
106
|
+
<!--
|
|
107
|
+
@texuli/chat's Composer: Enter to send, Shift+Enter for a new line, Stop and
|
|
108
|
+
Escape while a reply streams, pending and error states. ai-ui adds the
|
|
109
|
+
slash/mention list (through its keydown hook), attachments and the model
|
|
110
|
+
choice (through its attachments and actions snippets).
|
|
111
|
+
-->
|
|
112
|
+
<div bind:this={root} class="@container relative mx-auto w-full max-w-3xl">
|
|
113
|
+
{#if suggestions.length}
|
|
114
|
+
<div class="absolute bottom-full left-3 z-10 mb-2 max-h-52 w-64 overflow-y-auto rounded-xl border border-border bg-elevated p-1 shadow-float" role="listbox" aria-label={trigger?.[1]?.startsWith('/') ? 'Slash commands' : 'People'}>
|
|
115
|
+
{#each suggestions as item, index (`${item.kind}-${item.id}`)}
|
|
116
|
+
<Button variant="ghost" role="option" aria-selected={index === activeSuggestion} class={`w-full justify-start ${index === activeSuggestion ? 'bg-muted' : ''}`} onclick={() => chooseSuggestion(item)}>{item.kind === 'mention' ? `@${item.label}` : item.label}</Button>
|
|
117
|
+
{/each}
|
|
118
|
+
</div>
|
|
119
|
+
{/if}
|
|
120
|
+
<Composer
|
|
121
|
+
bind:value
|
|
122
|
+
onSubmit={send}
|
|
123
|
+
{streaming}
|
|
124
|
+
{onStop}
|
|
125
|
+
disabled={disabled || !selectedModel}
|
|
126
|
+
maxRows={8}
|
|
127
|
+
onkeydown={handleKeydown}
|
|
128
|
+
labels={{
|
|
129
|
+
input: 'Message the simulated assistant',
|
|
130
|
+
placeholder,
|
|
131
|
+
hint: 'Press Enter to send. Shift plus Enter starts a new line. Type slash for commands or at sign to mention a person.',
|
|
132
|
+
send: 'Send message',
|
|
133
|
+
sending: 'Sending message',
|
|
134
|
+
stop: 'Stop response',
|
|
135
|
+
sendError: 'The message could not be sent. Your draft is still here.',
|
|
136
|
+
}}
|
|
137
|
+
class="rounded-3xl bg-surface shadow-soft"
|
|
138
|
+
>
|
|
139
|
+
{#snippet attachments()}
|
|
140
|
+
<IconButton variant="ghost" class="rounded-full" label="Attach files" {disabled} onclick={() => picker?.click()}>
|
|
141
|
+
<LucideIcon icon={Paperclip} size="sm" />
|
|
142
|
+
</IconButton>
|
|
143
|
+
<Input type="file" multiple hidden tabindex={-1} aria-label="Choose attachments" bind:ref={picker} bind:files={picked} onchange={addFiles} />
|
|
144
|
+
{#if files.length}
|
|
145
|
+
<ul class="m-0 flex list-none flex-wrap gap-2 p-0" aria-label="Attachments">
|
|
146
|
+
{#each files as file, index (`${file.name}-${index}`)}
|
|
147
|
+
<li><Tag label={file.name} onRemove={() => (files = files.filter((_, itemIndex) => itemIndex !== index))} /></li>
|
|
148
|
+
{/each}
|
|
149
|
+
</ul>
|
|
150
|
+
{/if}
|
|
151
|
+
{/snippet}
|
|
152
|
+
{#snippet actions()}
|
|
153
|
+
<span class="hidden text-[0.7rem] text-muted-foreground @md:inline">Simulated AI · no provider connection</span>
|
|
154
|
+
<label for="assistant-model" class="sr-only">Simulated model</label>
|
|
155
|
+
<Select.Root
|
|
156
|
+
value={modelId}
|
|
157
|
+
items={modelItems}
|
|
158
|
+
disabled={disabled || streaming || !onModelChange}
|
|
159
|
+
onValueChange={(id) => onModelChange?.(id)}
|
|
160
|
+
>
|
|
161
|
+
<Select.Trigger id="assistant-model" aria-label="Simulated model" size="sm" class="min-w-0 max-w-48 rounded-full text-xs text-muted-foreground">
|
|
162
|
+
<Select.Value />
|
|
163
|
+
</Select.Trigger>
|
|
164
|
+
<Select.Content>
|
|
165
|
+
{#each modelItems as item (item.value)}
|
|
166
|
+
<Select.Item value={item.value} label={item.label}>{item.label}</Select.Item>
|
|
167
|
+
{/each}
|
|
168
|
+
</Select.Content>
|
|
169
|
+
</Select.Root>
|
|
170
|
+
{/snippet}
|
|
171
|
+
</Composer>
|
|
172
|
+
</div>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type AiModel } from '@protoyard/ai';
|
|
2
|
+
import type { AssistantSendInput } from '../types.ts';
|
|
3
|
+
interface Props {
|
|
4
|
+
models?: readonly AiModel[];
|
|
5
|
+
modelId?: string;
|
|
6
|
+
value?: string;
|
|
7
|
+
streaming?: boolean;
|
|
8
|
+
disabled?: boolean;
|
|
9
|
+
placeholder?: string;
|
|
10
|
+
slashCommands?: readonly string[];
|
|
11
|
+
mentions?: readonly {
|
|
12
|
+
id: string;
|
|
13
|
+
label: string;
|
|
14
|
+
}[];
|
|
15
|
+
onModelChange?: (modelId: string) => void;
|
|
16
|
+
onSubmit: (input: AssistantSendInput) => unknown;
|
|
17
|
+
onStop?: () => void;
|
|
18
|
+
}
|
|
19
|
+
declare const AssistantComposer: import("svelte").Component<Props, {}, "value">;
|
|
20
|
+
type AssistantComposer = ReturnType<typeof AssistantComposer>;
|
|
21
|
+
export default AssistantComposer;
|