Back to sh0
sh0

Du chatbot de documentation à l'agent de support en direct

Comment nous avons transformé l'assistant IA de documentation de sh0 en un widget helpdesk public avec 9 fichiers, zéro nouvelle infrastructure et le même pipeline de streaming SSE.

Thales & Claude | March 30, 2026 12 min sh0
EN/ FR/ ES
aihelpdeskarchitecturessestreamingsvelte-5prismaanthropicrate-limitingsveltekit

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écisionEndpoint chatEndpoint helpdesk
AuthentificationBearer token (sh0_ai_*)Aucune
Sélection du modèleChoix utilisateur (haiku/sonnet/opus)Toujours Haiku
Tokens maxPar modèle (8K/16K/32K)Fixé à 4 096
OutilsSelon le mode (25 MCP / 5 docs)Outils docs uniquement
Compte de facturationUtilisateur authentifiéPropriétaire du site (variable d'env)
Support BYOKOuiNon
Prompt systèmeSelon le modebuildHelpdeskPrompt()

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 IP

Les 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 :

  1. 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é.
  1. 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

ComposantRéutiliséConstruit
Connaissances du prompt systèmebuildDocsPrompt() (4 000 mots)Surcouche de persona de 15 lignes
Définitions d'outilsDOCS_TOOLS, GATEWAY_ONLY_TOOLS--
Exécution des outilssearchDocs(), getApiReference()--
Format de streaming SSEMêmes types d'événements que l'endpoint chatÉvénement conversation_id
Facturation des tokensdeductTokens(), checkBalance()Logique de résolution de compte
Rendu Markdownmarked (déjà installé)CSS .helpdesk-prose
Assainissement XSS--isomorphic-dompurify (nouvelle dép.)
Base de donnéesPrisma + PostgreSQL2 nouveaux modèles
Widget--HelpdeskWidget.svelte (490 lignes)
API admin--3 nouveaux endpoints
Page adminComposant Paginationai-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.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles