@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.
- 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
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { AiMessage, AiModel } from '@protoyard/ai';
|
|
2
|
+
export interface AssistantConversation {
|
|
3
|
+
id: string;
|
|
4
|
+
title: string;
|
|
5
|
+
updatedAt: string;
|
|
6
|
+
pinned?: boolean;
|
|
7
|
+
folder?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface AssistantMessage extends AiMessage {
|
|
10
|
+
feedback?: FeedbackRating;
|
|
11
|
+
branchIndex?: number;
|
|
12
|
+
branchCount?: number;
|
|
13
|
+
}
|
|
14
|
+
export type FeedbackRating = 'up' | 'down';
|
|
15
|
+
export interface AssistantCanvas {
|
|
16
|
+
title: string;
|
|
17
|
+
content: string;
|
|
18
|
+
language?: string;
|
|
19
|
+
kind?: 'code' | 'document';
|
|
20
|
+
}
|
|
21
|
+
export interface AssistantSendInput {
|
|
22
|
+
prompt: string;
|
|
23
|
+
model: AiModel;
|
|
24
|
+
attachments: readonly File[];
|
|
25
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { AiClock } from '@protoyard/ai';
|
|
3
|
+
import {
|
|
4
|
+
createVoiceSession,
|
|
5
|
+
type VoiceMode as VoiceModeType,
|
|
6
|
+
type VoiceState,
|
|
7
|
+
} from '@protoyard/ai/voice';
|
|
8
|
+
import { onDestroy, onMount } from 'svelte';
|
|
9
|
+
import { Badge } from 'texuli/badge';
|
|
10
|
+
import { Button } from 'texuli/button';
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
clock: AiClock;
|
|
14
|
+
transcripts?: readonly string[];
|
|
15
|
+
reply?: string;
|
|
16
|
+
transcriptDelayMs?: number;
|
|
17
|
+
handsFreeIntervalMs?: number;
|
|
18
|
+
showMicLevelMeter?: boolean;
|
|
19
|
+
onTranscript?: (transcript: string) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const DEFAULT_TRANSCRIPTS = [
|
|
23
|
+
'Summarize the latest project update.',
|
|
24
|
+
'What should I focus on next?',
|
|
25
|
+
'Draft a short response for the team.',
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
let {
|
|
29
|
+
clock,
|
|
30
|
+
transcripts = DEFAULT_TRANSCRIPTS,
|
|
31
|
+
reply = '',
|
|
32
|
+
transcriptDelayMs,
|
|
33
|
+
handsFreeIntervalMs,
|
|
34
|
+
showMicLevelMeter = false,
|
|
35
|
+
onTranscript,
|
|
36
|
+
}: Props = $props();
|
|
37
|
+
|
|
38
|
+
let voiceState = $state<VoiceState>('idle');
|
|
39
|
+
let mode = $state<VoiceModeType>('push-to-talk');
|
|
40
|
+
let transcript = $state('');
|
|
41
|
+
let speechAvailable = $state(false);
|
|
42
|
+
let micLevel = $state(0);
|
|
43
|
+
let micEnabled = $state(false);
|
|
44
|
+
let micError = $state('');
|
|
45
|
+
let micPending = $state(false);
|
|
46
|
+
let mediaStream: MediaStream | null = null;
|
|
47
|
+
let audioContext: AudioContext | null = null;
|
|
48
|
+
let animationFrame = 0;
|
|
49
|
+
let utterance: SpeechSynthesisUtterance | null = null;
|
|
50
|
+
let lastSpokenReply = '';
|
|
51
|
+
/** Set in onDestroy so a late getUserMedia resolve cannot leak a stream. */
|
|
52
|
+
let destroyed = false;
|
|
53
|
+
|
|
54
|
+
const session = $derived.by(() =>
|
|
55
|
+
createVoiceSession({
|
|
56
|
+
clock,
|
|
57
|
+
transcripts,
|
|
58
|
+
...(transcriptDelayMs !== undefined ? { transcriptDelayMs } : {}),
|
|
59
|
+
...(handsFreeIntervalMs !== undefined ? { handsFreeIntervalMs } : {}),
|
|
60
|
+
onTranscript: (value) => {
|
|
61
|
+
transcript = value;
|
|
62
|
+
onTranscript?.(value);
|
|
63
|
+
},
|
|
64
|
+
onStateChange: (state) => (voiceState = state),
|
|
65
|
+
})
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
$effect(() => {
|
|
69
|
+
const currentSession = session;
|
|
70
|
+
return () => currentSession.stop();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
onMount(() => {
|
|
74
|
+
speechAvailable =
|
|
75
|
+
typeof window !== 'undefined' &&
|
|
76
|
+
'speechSynthesis' in window &&
|
|
77
|
+
'SpeechSynthesisUtterance' in window;
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
$effect(() => {
|
|
81
|
+
const value = reply.trim();
|
|
82
|
+
if (!value || value === lastSpokenReply || !speechAvailable) return;
|
|
83
|
+
lastSpokenReply = value;
|
|
84
|
+
window.speechSynthesis.cancel();
|
|
85
|
+
session.startSpeaking();
|
|
86
|
+
utterance = new SpeechSynthesisUtterance(value);
|
|
87
|
+
utterance.onend = () => session.finishSpeaking();
|
|
88
|
+
utterance.onerror = () => session.finishSpeaking();
|
|
89
|
+
window.speechSynthesis.speak(utterance);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
function chooseMode(nextMode: VoiceModeType): void {
|
|
93
|
+
mode = nextMode;
|
|
94
|
+
session.stop();
|
|
95
|
+
if (nextMode === 'hands-free') session.start('hands-free');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function togglePushToTalk(): void {
|
|
99
|
+
if (voiceState === 'listening') {
|
|
100
|
+
void session.finishListening();
|
|
101
|
+
} else {
|
|
102
|
+
mode = 'push-to-talk';
|
|
103
|
+
session.start('push-to-talk');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function interrupt(): void {
|
|
108
|
+
session.interrupt();
|
|
109
|
+
if (speechAvailable) window.speechSynthesis.cancel();
|
|
110
|
+
utterance = null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function toggleMicMeter(): Promise<void> {
|
|
114
|
+
micError = '';
|
|
115
|
+
if (micEnabled || micPending) {
|
|
116
|
+
if (micEnabled) stopMicMeter();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (!navigator.mediaDevices?.getUserMedia || typeof AudioContext === 'undefined') {
|
|
120
|
+
micError = 'A local microphone level meter is unavailable in this browser.';
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
micPending = true;
|
|
124
|
+
try {
|
|
125
|
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
126
|
+
if (destroyed || !micPending) {
|
|
127
|
+
stream.getTracks().forEach((track) => {
|
|
128
|
+
track.stop();
|
|
129
|
+
});
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
mediaStream = stream;
|
|
133
|
+
audioContext = new AudioContext();
|
|
134
|
+
const source = audioContext.createMediaStreamSource(mediaStream);
|
|
135
|
+
const analyser = audioContext.createAnalyser();
|
|
136
|
+
analyser.fftSize = 256;
|
|
137
|
+
source.connect(analyser);
|
|
138
|
+
const samples = new Uint8Array(analyser.fftSize);
|
|
139
|
+
micEnabled = true;
|
|
140
|
+
const sampleLevel = () => {
|
|
141
|
+
if (!audioContext || !micEnabled || destroyed) return;
|
|
142
|
+
analyser.getByteTimeDomainData(samples);
|
|
143
|
+
let squareSum = 0;
|
|
144
|
+
for (const sample of samples) {
|
|
145
|
+
const centered = (sample - 128) / 128;
|
|
146
|
+
squareSum += centered * centered;
|
|
147
|
+
}
|
|
148
|
+
micLevel = Math.min(1, Math.sqrt(squareSum / samples.length) * 3);
|
|
149
|
+
animationFrame = requestAnimationFrame(sampleLevel);
|
|
150
|
+
};
|
|
151
|
+
sampleLevel();
|
|
152
|
+
} catch {
|
|
153
|
+
stopMicMeter();
|
|
154
|
+
if (!destroyed) {
|
|
155
|
+
micError = 'Microphone access was not enabled. Voice simulation still works with captions.';
|
|
156
|
+
}
|
|
157
|
+
} finally {
|
|
158
|
+
micPending = false;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function stopMicMeter(): void {
|
|
163
|
+
cancelAnimationFrame(animationFrame);
|
|
164
|
+
mediaStream?.getTracks().forEach((track) => {
|
|
165
|
+
track.stop();
|
|
166
|
+
});
|
|
167
|
+
mediaStream = null;
|
|
168
|
+
if (audioContext && audioContext.state !== 'closed') void audioContext.close();
|
|
169
|
+
audioContext = null;
|
|
170
|
+
micEnabled = false;
|
|
171
|
+
micLevel = 0;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
onDestroy(() => {
|
|
175
|
+
destroyed = true;
|
|
176
|
+
micPending = false;
|
|
177
|
+
session.stop();
|
|
178
|
+
if (speechAvailable) window.speechSynthesis.cancel();
|
|
179
|
+
stopMicMeter();
|
|
180
|
+
});
|
|
181
|
+
</script>
|
|
182
|
+
|
|
183
|
+
<section class="voice-panel rounded-2xl border border-border bg-surface p-5 text-foreground" aria-label="Simulated voice chat">
|
|
184
|
+
<div class="flex items-center justify-center py-4">
|
|
185
|
+
<div class={`voice-orb ${voiceState}`} aria-hidden="true">
|
|
186
|
+
<span></span><span></span><span></span><span></span><span></span>
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
<p class="text-center text-sm font-medium">Voice chat <Badge variant="outline" class="ml-1">Simulated</Badge></p>
|
|
190
|
+
<p class="mt-1 text-center text-xs text-muted-foreground">Transcripts are scripted and timed by the simulated clock. No audio is recorded or sent.</p>
|
|
191
|
+
|
|
192
|
+
<div class="mt-4 flex justify-center gap-2" role="group" aria-label="Voice mode">
|
|
193
|
+
<Button size="sm" variant={mode === 'push-to-talk' ? 'secondary' : 'outline'} aria-pressed={mode === 'push-to-talk'} onclick={() => chooseMode('push-to-talk')}>Push to talk</Button>
|
|
194
|
+
<Button size="sm" variant={mode === 'hands-free' ? 'secondary' : 'outline'} aria-pressed={mode === 'hands-free'} onclick={() => chooseMode(mode === 'hands-free' ? 'push-to-talk' : 'hands-free')}>{mode === 'hands-free' ? 'Stop hands-free' : 'Hands-free'}</Button>
|
|
195
|
+
</div>
|
|
196
|
+
{#if mode === 'push-to-talk'}
|
|
197
|
+
<div class="mt-3 flex justify-center">
|
|
198
|
+
<Button size="lg" class="rounded-full" aria-pressed={voiceState === 'listening' || voiceState === 'transcribing'} onclick={togglePushToTalk} disabled={voiceState === 'transcribing'}>{voiceState === 'listening' ? 'Finish and transcribe' : 'Start push to talk'}</Button>
|
|
199
|
+
</div>
|
|
200
|
+
{/if}
|
|
201
|
+
{#if voiceState === 'interrupted'}
|
|
202
|
+
<div class="mt-3 flex justify-center"><Button size="sm" variant="ghost" onclick={() => session.start(mode)}>Resume voice mode</Button></div>
|
|
203
|
+
{:else if voiceState !== 'idle'}
|
|
204
|
+
<p class="mt-3 text-center text-xs text-muted-foreground" role="status">{voiceState === 'listening' ? (mode === 'hands-free' ? 'Listening for the next scripted turn…' : 'Listening…') : voiceState === 'transcribing' ? 'Preparing caption…' : 'Speaking simulated reply…'}</p>
|
|
205
|
+
{/if}
|
|
206
|
+
|
|
207
|
+
{#if transcript}<p class="mt-4 rounded-xl bg-background p-3 text-sm" aria-live="polite"><span class="mb-1 block text-[0.65rem] font-medium uppercase tracking-wide text-muted-foreground">You said · simulated caption</span>{transcript}</p>{/if}
|
|
208
|
+
{#if reply}<p class="mt-2 rounded-xl border border-border p-3 text-sm" aria-live="polite"><span class="mb-1 block text-[0.65rem] font-medium uppercase tracking-wide text-muted-foreground">Assistant · simulated caption</span>{reply}</p>{/if}
|
|
209
|
+
{#if !speechAvailable}<p class="mt-2 text-center text-xs text-muted-foreground">Speech output is muted in this browser; captions remain available.</p>{/if}
|
|
210
|
+
{#if showMicLevelMeter}
|
|
211
|
+
<div class="mt-4 border-t border-border pt-3">
|
|
212
|
+
<Button size="sm" variant="ghost" aria-pressed={micEnabled} autoLoading={false} loading={micPending} onclick={() => void toggleMicMeter()}>{micEnabled ? 'Disable local mic level meter' : micPending ? 'Waiting for microphone…' : 'Enable local mic level meter'}</Button>
|
|
213
|
+
{#if micEnabled}<div class="mt-2 h-2 overflow-hidden rounded-full bg-muted" role="meter" aria-label="Local microphone amplitude" aria-valuemin="0" aria-valuemax="100" aria-valuenow={Math.round(micLevel * 100)}><div class="h-full bg-primary transition-[width]" style={`width:${micLevel * 100}%`}></div></div><p class="mt-1 text-[0.65rem] text-muted-foreground">Amplitude only. Audio is not recorded or uploaded.</p>{/if}
|
|
214
|
+
{#if micError}<p class="mt-2 text-xs text-destructive" role="alert">{micError}</p>{/if}
|
|
215
|
+
</div>
|
|
216
|
+
{/if}
|
|
217
|
+
<div class="mt-3 flex justify-center"><Button size="sm" variant="ghost" onclick={interrupt} disabled={voiceState === 'idle' || voiceState === 'interrupted'}>Interrupt voice</Button></div>
|
|
218
|
+
</section>
|
|
219
|
+
|
|
220
|
+
<style>
|
|
221
|
+
.voice-orb { display: flex; width: 7rem; height: 7rem; align-items: center; justify-content: center; gap: 0.22rem; border: 1px solid color-mix(in srgb, currentColor 18%, transparent); border-radius: 50%; color: var(--color-primary); background: radial-gradient(circle at 35% 30%, color-mix(in srgb, currentColor 24%, transparent), transparent 68%); box-shadow: 0 0 2.5rem color-mix(in srgb, currentColor 18%, transparent); }
|
|
222
|
+
.voice-orb span { width: 0.22rem; height: 0.65rem; border-radius: 1rem; background: currentColor; opacity: 0.45; transform: scaleY(0.65); }
|
|
223
|
+
.voice-orb.listening span, .voice-orb.transcribing span, .voice-orb.speaking span { animation: voice-wave 0.9s ease-in-out infinite alternate; opacity: 0.9; }
|
|
224
|
+
.voice-orb span:nth-child(2), .voice-orb span:nth-child(4) { animation-delay: 0.12s; }
|
|
225
|
+
.voice-orb span:nth-child(3) { animation-delay: 0.24s; }
|
|
226
|
+
.voice-orb.interrupted { color: var(--color-destructive); box-shadow: none; }
|
|
227
|
+
@keyframes voice-wave { to { transform: scaleY(1.8); } }
|
|
228
|
+
@media (prefers-reduced-motion: reduce) { .voice-orb span { animation: none !important; } }
|
|
229
|
+
:global([data-py-motion='reduce']) .voice-orb span { animation: none !important; }
|
|
230
|
+
</style>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { AiClock } from '@protoyard/ai';
|
|
2
|
+
interface Props {
|
|
3
|
+
clock: AiClock;
|
|
4
|
+
transcripts?: readonly string[];
|
|
5
|
+
reply?: string;
|
|
6
|
+
transcriptDelayMs?: number;
|
|
7
|
+
handsFreeIntervalMs?: number;
|
|
8
|
+
showMicLevelMeter?: boolean;
|
|
9
|
+
onTranscript?: (transcript: string) => void;
|
|
10
|
+
}
|
|
11
|
+
declare const VoiceMode: import("svelte").Component<Props, {}, "">;
|
|
12
|
+
type VoiceMode = ReturnType<typeof VoiceMode>;
|
|
13
|
+
export default VoiceMode;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as VoiceMode } from './VoiceMode.svelte';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as VoiceMode } from './VoiceMode.svelte';
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { MessageCircle } from '@lucide/svelte';
|
|
3
|
+
import type { Component } from 'svelte';
|
|
4
|
+
import { onMount, tick } from 'svelte';
|
|
5
|
+
import { Button, IconButton } from 'texuli/button';
|
|
6
|
+
import { LucideIcon } from 'texuli/icons/lucide';
|
|
7
|
+
import type { AssistantWidgetMode, AssistantWidgetProps } from './types.js';
|
|
8
|
+
|
|
9
|
+
const MODE_KEY_PREFIX = 'protoyard:ai-widget-mode:';
|
|
10
|
+
const VALID_MODES = new Set<AssistantWidgetMode>(['launcher', 'sidebar', 'fullscreen']);
|
|
11
|
+
|
|
12
|
+
let { prototypeId, ...workspaceProps }: AssistantWidgetProps = $props();
|
|
13
|
+
let mode = $state<AssistantWidgetMode>('launcher');
|
|
14
|
+
let hasOpened = $state(false);
|
|
15
|
+
let Panel = $state<Component<AssistantWidgetProps> | null>(null);
|
|
16
|
+
let root = $state<HTMLDivElement | null>(null);
|
|
17
|
+
let panel = $state<HTMLDivElement | null>(null);
|
|
18
|
+
|
|
19
|
+
function getStorageKey(): string {
|
|
20
|
+
return `${MODE_KEY_PREFIX}${encodeURIComponent(prototypeId)}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
onMount(() => {
|
|
24
|
+
try {
|
|
25
|
+
const savedMode = window.localStorage.getItem(getStorageKey());
|
|
26
|
+
if (savedMode && VALID_MODES.has(savedMode as AssistantWidgetMode)) {
|
|
27
|
+
mode = savedMode as AssistantWidgetMode;
|
|
28
|
+
hasOpened = mode !== 'launcher';
|
|
29
|
+
if (hasOpened) void loadPanel();
|
|
30
|
+
}
|
|
31
|
+
} catch {
|
|
32
|
+
// Storage may be disabled; the widget remains usable for this session.
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
async function loadPanel(): Promise<void> {
|
|
37
|
+
if (Panel) return;
|
|
38
|
+
const loaded = await import('./AssistantWidgetPanel.svelte');
|
|
39
|
+
Panel = loaded.default;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function setMode(next: AssistantWidgetMode): Promise<void> {
|
|
43
|
+
mode = next;
|
|
44
|
+
if (next !== 'launcher') {
|
|
45
|
+
hasOpened = true;
|
|
46
|
+
await loadPanel();
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
window.localStorage.setItem(getStorageKey(), next);
|
|
50
|
+
} catch {
|
|
51
|
+
// Keep the selected mode in memory when storage is unavailable.
|
|
52
|
+
}
|
|
53
|
+
if (next === 'fullscreen') {
|
|
54
|
+
requestAnimationFrame(() => panel?.querySelector<HTMLElement>('[data-widget-close]')?.focus());
|
|
55
|
+
} else if (next === 'launcher') {
|
|
56
|
+
requestAnimationFrame(() =>
|
|
57
|
+
root?.querySelector<HTMLButtonElement>('[data-widget-launcher]')?.focus()
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function openSidebar(): Promise<void> {
|
|
63
|
+
if (!hasOpened) {
|
|
64
|
+
hasOpened = true;
|
|
65
|
+
await loadPanel();
|
|
66
|
+
await tick();
|
|
67
|
+
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
|
68
|
+
}
|
|
69
|
+
await setMode('sidebar');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function collapse(): void {
|
|
73
|
+
if (mode === 'fullscreen') void setMode('sidebar');
|
|
74
|
+
else if (mode === 'sidebar') void setMode('launcher');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function handleWindowKeydown(event: KeyboardEvent): void {
|
|
78
|
+
if (event.key !== 'Escape' || !root?.contains(event.target as Node)) return;
|
|
79
|
+
if (mode !== 'launcher') {
|
|
80
|
+
event.preventDefault();
|
|
81
|
+
event.stopPropagation();
|
|
82
|
+
collapse();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function trapFullscreenFocus(event: KeyboardEvent): void {
|
|
87
|
+
if (mode !== 'fullscreen' || event.key !== 'Tab' || !panel) return;
|
|
88
|
+
const focusable = Array.from(
|
|
89
|
+
panel.querySelectorAll<HTMLElement>(
|
|
90
|
+
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
91
|
+
)
|
|
92
|
+
).filter(
|
|
93
|
+
(element) =>
|
|
94
|
+
element.getClientRects().length > 0 &&
|
|
95
|
+
window.getComputedStyle(element).visibility !== 'hidden'
|
|
96
|
+
);
|
|
97
|
+
const first = focusable[0];
|
|
98
|
+
const last = focusable.at(-1);
|
|
99
|
+
if (!first || !last) {
|
|
100
|
+
event.preventDefault();
|
|
101
|
+
panel.focus();
|
|
102
|
+
} else if (event.shiftKey && document.activeElement === first) {
|
|
103
|
+
event.preventDefault();
|
|
104
|
+
last.focus();
|
|
105
|
+
} else if (!event.shiftKey && document.activeElement === last) {
|
|
106
|
+
event.preventDefault();
|
|
107
|
+
first.focus();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
</script>
|
|
111
|
+
|
|
112
|
+
<svelte:window onkeydown={handleWindowKeydown} />
|
|
113
|
+
|
|
114
|
+
<div bind:this={root} class="pointer-events-none absolute inset-0 z-40" data-ai-widget data-widget-mode={mode}>
|
|
115
|
+
<IconButton
|
|
116
|
+
variant="default"
|
|
117
|
+
class={`pointer-events-auto absolute bottom-4 right-4 size-14 rounded-full shadow-lg transition-transform duration-200 hover:scale-105 ${mode !== 'launcher' ? 'scale-0 opacity-0' : ''}`}
|
|
118
|
+
label="Open simulated assistant"
|
|
119
|
+
aria-expanded={mode !== 'launcher'}
|
|
120
|
+
aria-hidden={mode !== 'launcher'}
|
|
121
|
+
aria-controls="ai-widget-panel"
|
|
122
|
+
tabindex={mode === 'launcher' ? 0 : -1}
|
|
123
|
+
data-widget-launcher
|
|
124
|
+
onclick={() => void openSidebar()}
|
|
125
|
+
>
|
|
126
|
+
<LucideIcon icon={MessageCircle} size="lg" />
|
|
127
|
+
</IconButton>
|
|
128
|
+
|
|
129
|
+
{#if hasOpened && Panel}
|
|
130
|
+
<div
|
|
131
|
+
bind:this={panel}
|
|
132
|
+
id="ai-widget-panel"
|
|
133
|
+
class="pointer-events-auto absolute inset-y-0 right-0 flex min-h-0 flex-col overflow-hidden border border-border bg-background shadow-2xl transition-[width,height,opacity,transform,border-radius] duration-300 ease-out motion-reduce:transition-none"
|
|
134
|
+
class:w-full={mode === 'fullscreen'}
|
|
135
|
+
class:h-full={mode !== 'launcher'}
|
|
136
|
+
class:w-[min(28rem,100%)]={mode === 'sidebar'}
|
|
137
|
+
class:translate-x-full={mode === 'launcher'}
|
|
138
|
+
class:opacity-0={mode === 'launcher'}
|
|
139
|
+
class:rounded-none={mode === 'fullscreen'}
|
|
140
|
+
class:rounded-l-2xl={mode === 'sidebar'}
|
|
141
|
+
aria-hidden={mode === 'launcher'}
|
|
142
|
+
aria-modal={mode === 'fullscreen' ? 'true' : undefined}
|
|
143
|
+
aria-label="Simulated assistant"
|
|
144
|
+
role="dialog"
|
|
145
|
+
tabindex="-1"
|
|
146
|
+
onkeydown={trapFullscreenFocus}
|
|
147
|
+
inert={mode === 'launcher'}
|
|
148
|
+
data-widget-panel
|
|
149
|
+
data-widget-fullscreen={mode === 'fullscreen' ? 'true' : 'false'}
|
|
150
|
+
>
|
|
151
|
+
<header class="flex min-h-14 shrink-0 items-center justify-between gap-2 border-b border-border bg-surface px-3">
|
|
152
|
+
<span class="truncate text-sm font-semibold">Simulated assistant</span>
|
|
153
|
+
<div class="flex items-center gap-1">
|
|
154
|
+
{#if mode === 'sidebar'}
|
|
155
|
+
<Button size="sm" variant="ghost" aria-label="Expand assistant to full screen" onclick={() => void setMode('fullscreen')}>Expand</Button>
|
|
156
|
+
{:else}
|
|
157
|
+
<Button size="sm" variant="ghost" aria-label="Restore assistant sidebar" onclick={() => void setMode('sidebar')}>Restore</Button>
|
|
158
|
+
{/if}
|
|
159
|
+
<Button data-widget-close size="sm" variant="ghost" aria-label={mode === 'fullscreen' ? 'Restore assistant sidebar' : 'Close assistant'} onclick={collapse}>{mode === 'fullscreen' ? 'Restore' : 'Close'}</Button>
|
|
160
|
+
</div>
|
|
161
|
+
</header>
|
|
162
|
+
<div class="min-h-0 flex-1 overflow-hidden p-2 sm:p-4">
|
|
163
|
+
<Panel {prototypeId} {...workspaceProps} />
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
{/if}
|
|
167
|
+
</div>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import AssistantWorkspace from '../components/AssistantWorkspace.svelte';
|
|
3
|
+
import type { AssistantWidgetProps } from './types.js';
|
|
4
|
+
|
|
5
|
+
let { prototypeId: _prototypeId, ...workspaceProps }: AssistantWidgetProps = $props();
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<!-- The panel sets the height: the workspace's own 32rem minimum would overflow a short panel and clip the composer. -->
|
|
9
|
+
<div class="h-full min-h-0 w-full *:min-h-0">
|
|
10
|
+
<AssistantWorkspace {...workspaceProps} />
|
|
11
|
+
</div>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as AssistantWidget } from './AssistantWidget.svelte';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ComponentProps } from 'svelte';
|
|
2
|
+
import type AssistantWorkspace from '../components/AssistantWorkspace.svelte';
|
|
3
|
+
export type AssistantWidgetMode = 'launcher' | 'sidebar' | 'fullscreen';
|
|
4
|
+
export type AssistantWidgetProps = ComponentProps<typeof AssistantWorkspace> & {
|
|
5
|
+
/** Stable prototype slug used to isolate the saved display mode. */
|
|
6
|
+
prototypeId: string;
|
|
7
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,100 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protoyard/ai-ui",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Svelte 5 assistant interface components for simulated AI prototypes.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": [
|
|
8
|
+
"**/*.css",
|
|
9
|
+
"**/*.svelte",
|
|
10
|
+
"dist/**/*.css",
|
|
11
|
+
"dist/**/*.svelte"
|
|
12
|
+
],
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"svelte": "./dist/index.js",
|
|
17
|
+
"default": "./dist/index.js"
|
|
18
|
+
},
|
|
19
|
+
"./approvals": {
|
|
20
|
+
"types": "./dist/approvals/index.d.ts",
|
|
21
|
+
"svelte": "./dist/approvals/index.js",
|
|
22
|
+
"default": "./dist/approvals/index.js"
|
|
23
|
+
},
|
|
24
|
+
"./messages": {
|
|
25
|
+
"types": "./dist/messages/index.d.ts",
|
|
26
|
+
"svelte": "./dist/messages/index.js",
|
|
27
|
+
"default": "./dist/messages/index.js"
|
|
28
|
+
},
|
|
29
|
+
"./voice": {
|
|
30
|
+
"types": "./dist/voice/index.d.ts",
|
|
31
|
+
"svelte": "./dist/voice/index.js",
|
|
32
|
+
"default": "./dist/voice/index.js"
|
|
33
|
+
},
|
|
34
|
+
"./widget": {
|
|
35
|
+
"types": "./dist/widget/index.d.ts",
|
|
36
|
+
"svelte": "./dist/widget/index.js",
|
|
37
|
+
"default": "./dist/widget/index.js"
|
|
38
|
+
},
|
|
39
|
+
"./styles.css": {
|
|
40
|
+
"types": "./dist/styles.css.d.ts",
|
|
41
|
+
"svelte": "./dist/styles.css",
|
|
42
|
+
"default": "./dist/styles.css"
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@lucide/svelte": "^1.61.0",
|
|
47
|
+
"@protoyard/ai": "1.1.0",
|
|
48
|
+
"@protoyard/charts-ui": "1.1.0",
|
|
49
|
+
"@texuli/chat": "0.12.0",
|
|
50
|
+
"texuli": "0.12.0"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"axe-core": "^4.14.0",
|
|
54
|
+
"svelte": "^5.57.2"
|
|
55
|
+
},
|
|
56
|
+
"peerDependencies": {
|
|
57
|
+
"svelte": "^5.57.0"
|
|
58
|
+
},
|
|
59
|
+
"protoyard": {
|
|
60
|
+
"build": {
|
|
61
|
+
"assets": []
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
"files": [
|
|
65
|
+
"dist",
|
|
66
|
+
"README.md",
|
|
67
|
+
"LICENSE"
|
|
68
|
+
],
|
|
69
|
+
"publishConfig": {
|
|
70
|
+
"access": "public",
|
|
71
|
+
"provenance": true,
|
|
72
|
+
"files": [
|
|
73
|
+
"dist",
|
|
74
|
+
"README.md",
|
|
75
|
+
"LICENSE"
|
|
76
|
+
]
|
|
77
|
+
},
|
|
78
|
+
"repository": {
|
|
79
|
+
"type": "git",
|
|
80
|
+
"url": "https://github.com/DailybotHQ/protoyard",
|
|
81
|
+
"directory": "packages/ai-ui"
|
|
82
|
+
},
|
|
83
|
+
"homepage": "https://protoyard.app",
|
|
84
|
+
"bugs": {
|
|
85
|
+
"url": "https://github.com/DailybotHQ/protoyard/issues"
|
|
86
|
+
},
|
|
87
|
+
"engines": {
|
|
88
|
+
"node": ">=24.20.0"
|
|
89
|
+
},
|
|
90
|
+
"keywords": [
|
|
91
|
+
"protoyard",
|
|
92
|
+
"svelte",
|
|
93
|
+
"ai",
|
|
94
|
+
"components"
|
|
95
|
+
],
|
|
96
|
+
"scripts": {
|
|
97
|
+
"typecheck": "svelte-check --tsconfig ./tsconfig.json --threshold error"
|
|
98
|
+
},
|
|
99
|
+
"types": "./dist/index.d.ts"
|
|
6
100
|
}
|