Skip to content

Commit ddc33a1

Browse files
committed
Add Carlos
1 parent 85031e3 commit ddc33a1

File tree

12 files changed

+602
-0
lines changed

12 files changed

+602
-0
lines changed

src/app.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ declare global {
2020
[key: string]: string;
2121
};
2222
};
23+
carlosContext?: {
24+
prompt: string;
25+
};
2326
}
2427
// interface PageState {}
2528
// interface Platform {}
Lines changed: 362 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,362 @@
1+
<script context="module" lang="ts">
2+
// Types
3+
export type Message = {
4+
content: string;
5+
role: 'user' | 'assistant';
6+
};
7+
8+
export type CarlosStatus = 'available' | 'thinking' | 'answering';
9+
10+
// Constants
11+
export const STATUS_MESSAGE = {
12+
available: 'Carlos est disponible',
13+
thinking: 'Carlos réfléchit...',
14+
answering: 'Carlos est en train de répondre...',
15+
} as const;
16+
</script>
17+
18+
<script lang="ts">
19+
import { Stream } from 'openai/streaming';
20+
import { createEventDispatcher, onDestroy } from 'svelte';
21+
import { bounceOut } from 'svelte/easing';
22+
import { fade, fly, scale } from 'svelte/transition';
23+
24+
import Close from '$lib/svg/Close.svelte';
25+
import Refresh from '$lib/svg/Refresh.svelte';
26+
import Send from '$lib/svg/Send.svelte';
27+
import { prefersReducedMotion } from '$lib/utils/preferences';
28+
import { requestAnimationFrame } from '$lib/utils/request-animation-frame';
29+
import { toasts } from '$lib/utils/toats';
30+
31+
import Card from './Card.svelte';
32+
33+
import type { ChatCompletionChunk } from 'openai/resources/index.mjs';
34+
35+
import { page } from '$app/stores';
36+
37+
// Props
38+
export let open = false;
39+
40+
// Constants
41+
const dispatch = createEventDispatcher();
42+
43+
// Variables
44+
let chatInput: HTMLInputElement | null = null;
45+
let inputValue = '';
46+
let messages: Message[] = [];
47+
let messageContainer: HTMLUListElement | null = null;
48+
let abortController: AbortController | null = null;
49+
let carlosStatus: CarlosStatus = 'available';
50+
51+
// Handle the readable stream
52+
const handleReadableStream = async (
53+
readableStream: ReadableStream<Uint8Array>,
54+
controller: AbortController,
55+
) => {
56+
if (controller.signal.aborted || readableStream.locked) {
57+
return;
58+
}
59+
60+
const stream = Stream.fromReadableStream<ChatCompletionChunk>(readableStream, controller);
61+
62+
// Read the stream chunk by chunk
63+
for await (const message of stream) {
64+
const newMessages = [...messages];
65+
let lastMessage = newMessages.pop();
66+
67+
// If there is no last message, create one
68+
if (!lastMessage || lastMessage.role !== 'assistant') {
69+
if (lastMessage) {
70+
newMessages.push(lastMessage);
71+
}
72+
73+
lastMessage = {
74+
content: '',
75+
role: 'assistant',
76+
};
77+
}
78+
79+
// Take the first choice
80+
const choice = message.choices[0];
81+
82+
if (choice.finish_reason === null) {
83+
// Append the choice to the current message (only if valid)
84+
lastMessage.content += choice.delta.content;
85+
86+
if (lastMessage.content.trim() !== '') {
87+
newMessages.push(lastMessage);
88+
89+
// Replace the last message with the new one
90+
messages = newMessages;
91+
}
92+
}
93+
}
94+
};
95+
96+
// Handle the form submit
97+
const handleFormSubmit = async (event: SubmitEvent) => {
98+
if (carlosStatus !== 'available') {
99+
return;
100+
}
101+
102+
carlosStatus = 'thinking';
103+
104+
const formData = new FormData(event.target as HTMLFormElement);
105+
const question = formData.get('question');
106+
107+
if (typeof question !== 'string' || question.trim() === '') {
108+
toasts.warning(`Veuillez entrer une ${messages.length > 0 ? 'réponse' : 'question'}.`);
109+
110+
carlosStatus = 'available';
111+
112+
return;
113+
}
114+
115+
if (question.trim().length > 255) {
116+
toasts.warning('Votre question ne doit pas dépasser 255 caractères.');
117+
118+
carlosStatus = 'available';
119+
120+
return;
121+
}
122+
123+
inputValue = '';
124+
125+
messages = [
126+
...messages,
127+
{
128+
content: question,
129+
role: 'user',
130+
},
131+
];
132+
133+
const context = {
134+
prompt: $page.data.carlosContext?.prompt,
135+
};
136+
137+
abortController = new AbortController();
138+
139+
const response = await fetch('/carlos', {
140+
method: 'POST',
141+
body: JSON.stringify({ messages, context }),
142+
headers: {
143+
'Content-Type': 'application/json',
144+
},
145+
signal: abortController.signal,
146+
});
147+
148+
if (!response.ok || !response.body) {
149+
toasts.error("Carlos n'est pas disponible. Veuillez réessayer plus tard.");
150+
151+
abortController = null;
152+
carlosStatus = 'available';
153+
154+
return;
155+
}
156+
157+
try {
158+
carlosStatus = 'answering';
159+
160+
await handleReadableStream(response.body, abortController);
161+
} catch (error) {
162+
toasts.error('Carlos a rencontré une erreur. Veuillez réessayer plus tard.');
163+
}
164+
165+
abortController = null;
166+
carlosStatus = 'available';
167+
};
168+
169+
// Clear the messages
170+
const clearMessages = () => {
171+
if (carlosStatus !== 'available') {
172+
return;
173+
}
174+
175+
messages = [];
176+
};
177+
178+
// Lifecycle
179+
// Focus the chat input when the component is opened
180+
$: if (open) {
181+
requestAnimationFrame(() => {
182+
if (chatInput) {
183+
chatInput.focus();
184+
}
185+
});
186+
}
187+
188+
// Scroll to the bottom of the messages container when a new message is added
189+
$: if (messages.length > 0) {
190+
requestAnimationFrame(() => {
191+
if (!messageContainer) {
192+
return;
193+
}
194+
195+
messageContainer.scrollIntoView(false);
196+
});
197+
}
198+
199+
// Abort the request when the component is destroyed
200+
onDestroy(() => {
201+
if (abortController) {
202+
abortController.abort();
203+
}
204+
});
205+
</script>
206+
207+
{#if open}
208+
<div
209+
role="dialog"
210+
aria-modal="false"
211+
aria-label="Discussion avec l'assistant personnel"
212+
aria-describedby="carlos-description"
213+
class="absolute bottom-0 max-w-xl w-[calc(100vw-1.25rem)]"
214+
transition:fly={{ duration: prefersReducedMotion() ? 0 : 1000, y: 575, easing: bounceOut }}
215+
>
216+
<Card
217+
containerClass="border-2 border-primary-700"
218+
innerContainerClass="sm:min-h-[50vh] min-h-[55vh] flex flex-col"
219+
>
220+
{@const carlosHasContext = $page.data.carlosContext?.prompt}
221+
222+
<div class="flex justify-between items-start">
223+
<div class="grid sm:flex gap-2.5 items-center">
224+
<enhanced:img
225+
src="$lib/assets/carlos.png"
226+
alt="Assistant personnel Carlos"
227+
class="rounded-full h-10 w-10 lg:h-20 lg:w-20"
228+
/>
229+
<div class="flex gap-1 items-center">
230+
<div class="h-3 w-3 bg-primary-700 rounded-full shadow" />
231+
232+
{#key carlosStatus}
233+
<p
234+
in:fade={{ duration: prefersReducedMotion() ? 0 : 500 }}
235+
role="status"
236+
aria-live="polite"
237+
>
238+
{STATUS_MESSAGE[carlosStatus]}
239+
</p>
240+
{/key}
241+
</div>
242+
</div>
243+
<div class="flex gap-2.5">
244+
{#if messages.length > 0}
245+
<button
246+
aria-label="Supprimer les messages"
247+
type="button"
248+
disabled={carlosStatus !== 'available'}
249+
class="btn | p-2.5"
250+
aria-controls="message-container"
251+
on:click={clearMessages}
252+
>
253+
<Refresh aria-hidden="true" />
254+
</button>
255+
{/if}
256+
257+
<button
258+
type="button"
259+
aria-label="Fermer la discussion"
260+
on:click={() => {
261+
dispatch('close');
262+
}}
263+
>
264+
<Close aria-hidden="true" class="w-5 h-5 text-gray-500" />
265+
</button>
266+
</div>
267+
</div>
268+
<div
269+
class="
270+
max-h-[40vh] sm:max-h-[33vh] overflow-auto sm:p-1 p-0.5 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-primary-700
271+
scrollbar-thumb-rounded-full scrollbar-track-rounded-full flex-grow
272+
"
273+
>
274+
{#if messages.length === 0}
275+
<div
276+
class="mb-3 text-gray-500 sm:max-w-sm md:max-w-md lg:max-w-lg flex flex-col gap-2"
277+
id="carlos-description"
278+
in:fade={{ duration: prefersReducedMotion() ? 0 : 500 }}
279+
>
280+
<p>Bonjour, je suis Carlos de CookConnect, et je suis votre assistant personnel.</p>
281+
<p>
282+
Demandez-moi n'importe quoi en rapport avec la cuisine, et je vous aiderai de mon
283+
mieux!
284+
</p>
285+
</div>
286+
{/if}
287+
288+
<ul
289+
id="message-container"
290+
aria-label="Conversation avec l'assistant personnel"
291+
role="region"
292+
aria-live="polite"
293+
class="flex flex-col gap-2"
294+
bind:this={messageContainer}
295+
>
296+
{#each messages as message, index (index)}
297+
{@const messageId = `message-${index}`}
298+
299+
<li
300+
class:ml-auto={message.role === 'user'}
301+
class:mr-auto={message.role === 'assistant'}
302+
class="w-11/12"
303+
aria-label={message.role === 'user' ? 'Mon message' : 'Message de Carlos'}
304+
aria-describedby={messageId}
305+
in:scale={{ duration: prefersReducedMotion() ? 0 : 500 }}
306+
>
307+
<div
308+
class="rounded-lg px-4 py-2.5"
309+
class:bg-primary-700={message.role === 'user'}
310+
class:font-medium={message.role === 'user'}
311+
class:text-white={message.role === 'user'}
312+
class:bg-gray-200={message.role === 'assistant'}
313+
>
314+
<p id={messageId}>
315+
{message.content}
316+
</p>
317+
</div>
318+
</li>
319+
{/each}
320+
</ul>
321+
</div>
322+
<form class="form" on:submit|preventDefault={handleFormSubmit}>
323+
<div>
324+
<label class="text-sm font-medium text-gray-900" for="carlos-question">
325+
{#if messages.length > 0 && messages.some((message) => message.role === 'user')}
326+
Ma réponse
327+
{:else}
328+
Ma question
329+
{/if}
330+
</label>
331+
<div class="relative">
332+
<input
333+
class="!p-4 !pr-14 border-primary-700"
334+
id="carlos-question"
335+
maxlength="255"
336+
name="question"
337+
required
338+
type="text"
339+
aria-describedby={carlosHasContext ? 'carlos-help' : undefined}
340+
bind:this={chatInput}
341+
bind:value={inputValue}
342+
/>
343+
<button
344+
aria-controls="message-container"
345+
aria-label="Envoyer"
346+
class="btn | p-2.5 absolute end-2.5 bottom-2.5"
347+
disabled={carlosStatus !== 'available' || inputValue.trim() === ''}
348+
type="submit"
349+
>
350+
<Send aria-hidden="true" />
351+
</button>
352+
</div>
353+
</div>
354+
</form>
355+
{#if carlosHasContext}
356+
<p class="text-xs text-gray-500" id="carlos-help">
357+
Carlos a accès à cette page et peut vous aider à trouver ce que vous cherchez.
358+
</p>
359+
{/if}
360+
</Card>
361+
</div>
362+
{/if}

src/lib/stores/assistant.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { writable } from 'svelte/store';
2+
3+
export const assistantOpen = writable(false);

0 commit comments

Comments
 (0)