sh0.dev disposait déjà de trois chemins IA : le mode MCP pour les utilisateurs du tableau de bord avec un serveur connecté, le mode legacy pour l'exécution d'outils depuis le tableau de bord, et le mode documentation pour le site marketing. Le helpdesk est un quatrième chemin, mais il partage 90 % de son implémentation avec le mode documentation. Cet article parcourt l'architecture -- ce que nous avons réutilisé, ce que nous avons construit, et les décisions qui ont façonné le design final.
La couche de prompt : une fonction, une surcouche
Le prompt de documentation est un prompt système de 4 000 mots qui enseigne à Claude les fonctionnalités de sh0, la documentation, les tarifs, le CLI et l'API. Il inclut les définitions d'outils pour search_docs et get_api_reference, les règles de formatage et des limites explicites (« vous n'avez PAS accès au serveur sh0 d'un utilisateur »).
Le helpdesk avait besoin des mêmes connaissances. La différence réside dans la persona. Un assistant de documentation est formel, exhaustif et renvoie vers les pages de documentation. Un agent helpdesk est chaleureux, concis et suggère les étapes suivantes.
La solution a été une fonction qui enveloppe le prompt existant :
typescriptexport function buildHelpdeskPrompt(): string {
return buildDocsPrompt() + `\n\n<helpdesk_overlay>
You are the sh0 Live Chat Support Agent. You are chatting with a visitor on sh0.dev.
Behavioral rules:
- Be warm, concise, and conversational. This is live chat, not documentation.
- Keep responses short (2-3 paragraphs max) unless the user asks for detail.
- Proactively offer next steps: "Would you like me to walk you through the installation?"
- For pricing questions, give clear comparisons and recommend the right plan.
- For technical questions, search the docs first, then provide a concise answer with a link.
- If the user has a bug report or is frustrated, acknowledge it and suggest emailing [email protected].
- Never ask for passwords, API keys, or sensitive credentials.
- Always use suggest_actions to offer 2-3 natural follow-ups.
</helpdesk_overlay>`;
}Quinze lignes. Pas de nouvelle base de connaissances, pas de données d'entraînement séparées, pas de base vectorielle. La surcouche modifie le comportement tout en préservant l'intégralité de la couche de connaissances en dessous.
C'est le principe architectural qui a rendu le helpdesk viable en tant que projet de fondateur solo : superposer le comportement au-dessus de la connaissance, ne jamais dupliquer la connaissance.
L'endpoint : un chemin docs simplifié
L'endpoint /api/ai/chat existant gère trois modes derrière une seule route, avec authentification, configuration BYOK, sélection de modèle et routage d'outils. Le helpdesk n'avait besoin d'aucune de cette complexité.
/api/ai/helpdesk est un endpoint public dédié qui prend chaque décision de manière statique :
| Décision | Endpoint chat | Endpoint helpdesk |
|---|---|---|
| Authentification | Bearer token (sh0_ai_*) | Aucune |
| Sélection du modèle | Choix utilisateur (haiku/sonnet/opus) | Toujours Haiku |
| Tokens max | Par modèle (8K/16K/32K) | Fixé à 4 096 |
| Outils | Selon le mode (25 MCP / 5 docs) | Outils docs uniquement |
| Compte de facturation | Utilisateur authentifié | Propriétaire du site (variable d'env) |
| Support BYOK | Oui | Non |
| Prompt système | Selon le mode | buildHelpdeskPrompt() |
En codant en dur chaque décision, l'endpoint fait 200 lignes de moins que l'endpoint chat et n'a aucun chemin conditionnel pour des fonctionnalités qui ne s'appliquent pas.
La boucle de streaming
La logique de streaming principale est identique au chemin docs de l'endpoint chat. Le même format d'événements SSE, la même boucle d'exécution d'outils, la même accumulation de blocs de contenu :
typescriptwhile (internalLoop < MAX_DOCS_LOOPS) {
const internalToolResults = [];
const gatewayToolResults = [];
const response = await client.messages.create({
model: modelString,
max_tokens: 4096,
system: systemPrompt,
messages: currentMessages,
tools: [...docsTools, WEB_SEARCH_TOOL],
stream: true,
});
// ... traitement des événements du flux ...
// Si Claude a appelé des outils, on reboucle avec les résultats
if (stopReason === 'tool_use' && allToolResults.length > 0) {
currentMessages = [
...currentMessages,
{ role: 'assistant', content: assistantContent },
{ role: 'user', content: toolResultContent },
];
internalLoop++;
continue;
}
break;
}Trois boucles maximum. Chaque boucle peut exécuter search_docs, get_api_reference ou suggest_actions en interne, puis renvoyer les résultats à Claude pour une réponse finale. Le visiteur ne voit jamais l'exécution des outils -- il voit une réponse en streaming fluide qui se trouve être informée par une recherche en temps réel dans la documentation.
Le nouvel événement SSE
Le helpdesk ajoute un type d'événement que l'endpoint chat n'émet pas : conversation_id. Il est envoyé immédiatement après l'ouverture du flux, avant tout contenu IA :
typescriptemit({ type: 'conversation_id', id: conversation.id });Le widget stocke cet identifiant dans localStorage. Au prochain message, il le renvoie. Le serveur reprend la même conversation au lieu d'en créer une nouvelle. C'est ainsi que la persistance de conversation fonctionne sans authentification -- le client détient un UUID de session et un UUID de conversation, et le serveur vérifie qu'ils correspondent.
Le widget : 490 lignes de Svelte 5
Le widget de chat est un seul composant : HelpdeskWidget.svelte. Il vit dans le layout racine, à l'intérieur du bloc {#if !hideChrome} qui contrôle déjà la navbar et le footer. Sur /account/<em>, /admin/</em> et /login, le widget ne se rend pas.
Architecture d'état
Tout l'état repose sur les runes Svelte 5 :
typescriptlet open = $state(false);
let input = $state('');
let messages = $state<Message[]>([]);
let suggestions = $state<Suggestion[]>([]);
let streaming = $state(false);
let error = $state('');
let conversationLimitReached = $state(false);
let sessionId = $state('');
let conversationId = $state('');Pas de stores. Pas de contexte. Pas d'état global. Le widget est autonome. Il charge depuis localStorage au montage et sauvegarde après chaque échange complété.
Le consommateur SSE
Le widget consomme le flux SSE en utilisant un lecteur ReadableStream, pas EventSource. C'est intentionnel -- EventSource ne supporte que les requêtes GET, et l'endpoint helpdesk est en POST (il envoie un corps JSON avec le message et les métadonnées).
typescriptconst reader = res.body?.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const data = JSON.parse(line.slice(6).trim());
if (data.type === 'delta') {
// Ajout de texte au message assistant en cours
} else if (data.type === 'conversation_id') {
conversationId = data.id;
} else if (data.type === 'suggestions') {
// Rendu des chips de suggestion
} else if (data.type === 'file') {
// Rendu du fichier de configuration en bloc de code
} else if (data.type === 'done') {
// Marquage du streaming comme terminé
} else if (data.type === 'error') {
// Affichage du message d'erreur
}
}
}L'accumulation dans le tampon gère le cas où un paquet TCP coupe un événement SSE entre deux lectures. Sans le tampon, du JSON partiel provoquerait des erreurs d'analyse sur les connexions lentes.
Rendu Markdown
Les messages de l'assistant sont rendus en HTML via marked + DOMPurify :
typescriptfunction renderMd(text: string): string {
const html = marked.parse(text) as string;
return DOMPurify.sanitize(html);
}La couche d'assainissement a été ajoutée par le premier auditeur (découverte critique C-1). Sans elle, une attaque par injection de prompt pouvait amener l'IA à produire <img onerror="alert(1)">, que marked rendrait comme du HTML valide et que {@html} injecterait dans le DOM. DOMPurify supprime les gestionnaires d'événements, les balises script et les autres motifs dangereux.
Le widget utilise du CSS personnalisé (.helpdesk-prose) au lieu du plugin @tailwindcss/typography de Tailwind. Les bulles de chat nécessitent un espacement compact -- des marges de paragraphe de 0,25em au lieu de 1,25em, une taille de police de code de 0,8em au lieu de 0,875em, et aucune contrainte max-width sur les tableaux. Une classe prose séparée évite de se battre avec la configuration typographique par défaut.
La base de données : deux tables, six index
prismamodel HelpdeskConversation {
id String @id @default(uuid())
sessionId String @map("session_id")
visitorName String? @map("visitor_name")
visitorEmail String? @map("visitor_email")
visitorIp String? @map("visitor_ip")
pageUrl String? @map("page_url")
status String @default("open")
messageCount Int @default(0) @map("message_count")
totalTokensIn Int @default(0) @map("total_tokens_in")
totalTokensOut Int @default(0) @map("total_tokens_out")
messages HelpdeskMessage[]
@@index([sessionId])
@@index([status, createdAt])
}
model HelpdeskMessage {
id String @id @default(uuid())
conversationId String @map("conversation_id")
role String
content String @db.Text
tokensIn Int @default(0) @map("tokens_in")
tokensOut Int @default(0) @map("tokens_out")
conversation HelpdeskConversation @relation(...)
@@index([conversationId, createdAt])
}La décision de stocker le nombre de tokens à la fois sur la conversation (agrégat) et sur le message (par échange) était délibérée. Les agrégats au niveau conversation évitent une requête SUM() coûteuse à chaque chargement de la page admin. Les compteurs au niveau message permettent de détailler le coût par échange dans la vue de transcription.
messageCount est incrémenté atomiquement via { increment: 2 } dans la même transaction qui crée les messages utilisateur et assistant. Cela évite une requête COUNT séparée et reste cohérent même sous requêtes concurrentes.
Limitation de débit : en mémoire, trois dimensions
Le limiteur de débit utilise trois Maps indépendantes, chacune suivant une dimension différente :
typescriptconst sessionRates = new Map<string, RateEntry>(); // 30 msg / 10 min par session
const ipRates = new Map<string, RateEntry>(); // 60 msg / 10 min par IP
const ipConvoRates = new Map<string, RateEntry>(); // 5 nouvelles convos / heure par IPLes trois dimensions servent des objectifs différents :
- Débit par session empêche un visiteur unique de saturer l'IA (30 messages suffisent pour n'importe quelle conversation réelle)
- Débit par IP empêche l'abus automatisé de scripts qui font tourner les identifiants de session (60/10min est généreux pour les humains, restrictif pour les bots)
- Débit de création de conversation empêche la pollution de la base de données (5 nouvelles conversations/heure/IP plafonne la croissance du stockage)
Un intervalle de nettoyage s'exécute toutes les 5 minutes pour supprimer les entrées expirées :
typescriptsetInterval(() => {
const now = Date.now();
for (const [key, entry] of sessionRates)
if (now > entry.resetAt) sessionRates.delete(key);
// ... idem pour ipRates et ipConvoRates
}, 5 * 60 * 1000);La limitation de débit en mémoire se réinitialise au redémarrage du serveur. C'est acceptable pour un site marketing. L'alternative -- une limitation de débit adossée à Redis ou PostgreSQL -- ajoute une complexité d'infrastructure qui n'est pas justifiée à cette échelle.
La vue admin : intelligence en lecture seule
Le tableau de bord admin est intentionnellement simple : une ligne de statistiques, un tableau filtrable et des transcriptions dépliables. Pas de capacité de réponse. Pas de workflow d'assignation. Pas de minuteurs SLA.
Les statistiques sont calculées côté serveur via les agrégats Prisma :
typescriptconst [totalConvos, openConvos, todayConvos, tokenAgg] = await Promise.all([
prisma.helpdeskConversation.count(),
prisma.helpdeskConversation.count({ where: { status: 'open' } }),
prisma.helpdeskConversation.count({
where: { createdAt: { gte: todayStart } },
}),
prisma.helpdeskConversation.aggregate({
_sum: { totalTokensIn: true, totalTokensOut: true },
}),
]);Quatre requêtes en parallèle. Le coût est calculé côté serveur en utilisant les tarifs réels de Haiku depuis AI_MODELS, pas codé en dur dans le frontend. Si les tarifs du modèle changent, le tableau de bord admin le reflète immédiatement.
La vue de transcription se charge à la demande -- cliquer sur une ligne de conversation récupère tous les messages via GET /api/admin/helpdesk/:id. Les messages sont plafonnés à 200 par transcription pour éviter les problèmes de mémoire sur les conversations extrêmement longues.
La limite de conversation
Une conversation est plafonnée à 200 messages (100 échanges). Quand la limite est atteinte, le serveur renvoie une erreur claire et le widget remplace la zone de saisie par un bouton « Nouvelle conversation ».
Ce plafond sert deux objectifs :
- Contrôle des coûts : Une conversation infiniment longue accumule un coût en tokens illimité. À 200 messages, la fenêtre de contexte envoie déjà environ 20 messages à l'API à chaque fois (les 20 derniers sont chargés pour le contexte). Le coût est prévisible et borné.
- Contrôle de la qualité : Après 100 échanges, la conversation a suffisamment dérivé pour qu'un nouveau départ produise de meilleures réponses que de continuer avec le contexte accumulé.
Ce que nous avons réutilisé vs. ce que nous avons construit
| Composant | Réutilisé | Construit |
|---|---|---|
| Connaissances du prompt système | buildDocsPrompt() (4 000 mots) | Surcouche de persona de 15 lignes |
| Définitions d'outils | DOCS_TOOLS, GATEWAY_ONLY_TOOLS | -- |
| Exécution des outils | searchDocs(), getApiReference() | -- |
| Format de streaming SSE | Mêmes types d'événements que l'endpoint chat | Événement conversation_id |
| Facturation des tokens | deductTokens(), checkBalance() | Logique de résolution de compte |
| Rendu Markdown | marked (déjà installé) | CSS .helpdesk-prose |
| Assainissement XSS | -- | isomorphic-dompurify (nouvelle dép.) |
| Base de données | Prisma + PostgreSQL | 2 nouveaux modèles |
| Widget | -- | HelpdeskWidget.svelte (490 lignes) |
| API admin | -- | 3 nouveaux endpoints |
| Page admin | Composant Pagination | ai-helpdesk/+page.svelte |
| Limitation de débit | -- | Limiteur en mémoire à 3 dimensions |
La colonne « Réutilisé » explique pourquoi cette fonctionnalité a pris des heures et non des semaines. L'infrastructure IA n'a pas été construite pour le helpdesk, mais elle a été construite d'une manière qui a rendu le helpdesk trivial à ajouter.
Prochain article de la série : Deux bugs critiques dans un widget IA public -- Ce que deux sessions d'audit indépendantes ont trouvé dans l'implémentation du helpdesk, et pourquoi le développeur ne pouvait pas les voir lui-même.