Ce guide explique comment intégrer correctement l'API de streaming chat completions de Devana dans votre application. Il couvre la gestion du streaming SSE (Server-Sent Events), le parsing des tokens, la gestion des balises spéciales (<think>, [tool:]), et les bonnes pratiques d'implémentation.
Le système de streaming repose sur le protocole SSE (Server-Sent Events) qui permet au serveur d'envoyer des mises à jour en temps réel au client. Chaque "chunk" (morceau) reçu contient un token qui peut être :
<think> pour les réflexions internes de l'IA[tool:] pour les appels d'outilsdata: {"choices":[{"delta":{"content":"token"}}],"conversation_id":"conv-123"}
data: [DONE]
POST /v1/chat/completions
{
"Content-Type": "application/json",
"Authorization": "Bearer YOUR_TOKEN"
}
{
"stream": true, // Active le streaming
"messages": [
{ "role": "user", "content": "..." } // Vos messages
],
"files": ["file-id-1", "file-id-2"], // IDs de fichiers (optionnel)
"clientModel": "model-name", // Modèle spécifique (optionnel)
"conversation_id": "conv-123", // ID de conversation (optionnel)
"model": "agent-id" // ID de l'agent
}
const response = await fetch("/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
stream: true,
messages: [{ role: "user", content: message }],
conversation_id: chatId,
model: agentId,
}),
signal: abortController.signal, // Pour permettre l'annulation
});
Le parsing du flux SSE nécessite une attention particulière car les données peuvent arriver en morceaux incomplets.
const reader = response.body.getReader();
let buffer = "";
let lastFailedChunk = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
// Ajouter les nouvelles données au buffer
buffer += new TextDecoder().decode(value);
let splitIndex: number;
// Traiter tous les messages complets dans le buffer
while ((splitIndex = buffer.indexOf("\n\n")) !== -1) {
const messageWithLastFail = buffer.slice(0, splitIndex).trim();
buffer = buffer.slice(splitIndex + 2);
// Ignorer les messages non-data
if (!messageWithLastFail.startsWith("data:")) continue;
// Fin du flux
if (messageWithLastFail === "data: [DONE]") continue;
try {
// Extraire et parser le JSON
const jsonString = messageWithLastFail.replace(, );
jsonData = .(jsonString);
lastFailedChunk = ;
token = jsonData?.?.[]?.?.;
(token) {
(token);
}
(jsonData.) {
conversationId = jsonData.;
}
} (e) {
(messageWithLastFail === lastFailedChunk) ;
lastFailedChunk = messageWithLastFail;
buffer = messageWithLastFail + + buffer;
;
}
}
}
\n\n)<think> - Réflexions internesLes balises <think> contiennent la chaîne de pensée de l'IA et ne doivent pas être affichées dans le message principal.
<think>
Je dois d'abord vérifier les données...
Ensuite calculer le résultat...
</think>
Voici la réponse finale
function extractThinkAndMainContent(message: string) {
let thinkContent = "";
let mainContent = "";
let currentPos = 0;
let hasThinkBlock = false;
while (currentPos < message.length) {
const thinkStart = message.indexOf("<think>", currentPos);
if (thinkStart === -1) {
// Plus de balises <think>, ajouter le reste au contenu principal
mainContent += message.substring(currentPos);
break;
}
hasThinkBlock = true;
// Ajouter le contenu avant <think> au contenu principal
mainContent += message.substring(currentPos, thinkStart);
const thinkEnd = message.indexOf("</think>", thinkStart);
if (thinkEnd === -1) {
// Bloc <think> ouvert (streaming en cours)
const content = message.substring(thinkStart + 7);
if (thinkContent && content) thinkContent += "\n\n---\n\n";
thinkContent += content;
break;
} else {
// Bloc <think> fermé
const content = message.substring(thinkStart + , thinkEnd);
(thinkContent && content) thinkContent += ;
thinkContent += content;
currentPos = thinkEnd + ;
}
}
thinkContent = thinkContent.();
mainContent = mainContent.(, ).();
{ thinkContent, mainContent, hasThinkBlock };
}
// Dans votre composant de message
const { thinkContent, mainContent, hasThinkBlock } = extractThinkAndMainContent(message);
return (
<>
{hasThinkBlock && (
<ThinkPanel
content={thinkContent}
isStreaming={isStreaming}
/>
)}
<MarkdownContent content={mainContent} />
</>
);
[tool:] - Exécution d'outilsLes balises [tool:] signalent qu'un outil est en cours d'exécution ou terminé.
Les balises tool peuvent avoir deux formats selon l'implémentation serveur :
// Démarrage d'un outil (toujours en JSON)
[tool:start:{"name":"web_search","args":{"query":"météo Paris"}}]
// Fin d'un outil - Format 1 : Simple (juste le nom)
[tool:end:web_search]
// Fin d'un outil - Format 2 : JSON structuré
[tool:end:{"name":"web_search"}]
Note importante : Le format [tool:end:] peut varier selon la configuration serveur. Votre code doit gérer les deux formats.
interface ToolInfo {
id: string;
name: string;
args: Record<string, unknown>;
startTime: number;
endTime?: number;
}
function processStreamChunk(token: string) {
// Détecter les balises tool
if (token.startsWith("[tool:")) {
const toolMatch = token.match(/\[tool:(start|end):(.+)\]/);
if (toolMatch) {
const [, action, dataString] = toolMatch;
try {
let toolData;
// Essayer de parser en JSON d'abord
try {
toolData = JSON.parse(dataString);
} catch {
// Si ce n'est pas du JSON, c'est probablement juste le nom (format simple)
// Format : [tool:end:ToolName]
toolData = { name: dataString };
}
if (action === "start") {
// Créer un ID unique pour tracer l'outil
const uniqueId = `${toolData.name}-${.now()}-`;
: = {
: uniqueId,
: toolData.,
: toolData. || {},
: .(),
};
( [...prev, toolInfo]);
(toolInfo);
} (action === ) {
( {
(prev?. === toolData.) {
(
tools.(
t. === prev. ? { ...t, : .() } : t,
),
);
;
}
prev;
});
}
} (e) {
.(, e);
}
}
;
}
( prev + token);
}
interface ToolExecutionPanelProps {
runnedTools: ToolInfo[];
activeTool: ToolInfo | null;
isStreaming: boolean;
}
function ToolExecutionPanel({ runnedTools, activeTool, isStreaming }: ToolExecutionPanelProps) {
return (
<div className="tool-panel">
<h4>
{isStreaming ? "Exécution des outils" : "Outils exécutés"}
<Badge count={runnedTools.length} />
</h4>
{runnedTools.map((tool, index) => (
<ToolStep
key={tool.id}
toolInfo={tool}
status={activeTool?.id === tool.id ? "running" : "completed"}
index={index}
/>
))}
{/* Afficher l'outil actif s'il n'est pas déjà dans la liste */}
{activeTool && !runnedTools.some(t => t.id === activeTool.id) && (
<ToolStep
toolInfo={activeTool}
status="running"
index=
/>
)}
);
}
[tool:confirm:] - Confirmation utilisateurCertains outils nécessitent une confirmation de l'utilisateur avant d'être exécutés.
[tool:confirm:{"messageId":"msg-123","toolName":"delete_file","args":{...}}]
const TOOL_CONFIRM_KEY = "[tool:confirm:";
// Dans le rendu des messages
if (message.startsWith(TOOL_CONFIRM_KEY)) {
return (
<ToolConfirmDialog
message={message}
onConfirm={async (confirmed) => {
// Envoyer la réponse de confirmation
await sendMessage(
`[tool:confirm:${JSON.stringify({
messageId: message.id,
confirm: confirmed
})}]`
);
}}
/>
);
}
interface Message {
id: string;
role: "USER" | "ASSISTANT" | "SYSTEM";
message: string;
status: "complete" | "streaming" | "temporary" | "error";
fiability?: "GOOD" | "BAD" | "DEFAULT";
comment?: string;
files?: File[];
sources?: Source[];
runnedTools?: ToolInfo[];
activeTool?: ToolInfo | null;
}
status: "streaming"status: "complete"function useChat() {
const [isStreaming, setIsStreaming] = useState(false);
const [streamedResponse, setStreamedResponse] = useState("");
const [temporaryQuestion, setTemporaryQuestion] = useState("");
const [waitingForResponse, setWaitingForResponse] = useState(false);
const [runnedTools, setRunnedTools] = useState<ToolInfo[]>([]);
const [activeTool, setActiveTool] = useState<ToolInfo | null>(null);
// Messages combinés : historique + messages temporaires
const messages = useMemo(() => {
const msgs: Message[] = [...historyMessages];
// Ajouter la question temporaire
if (temporaryQuestion) {
msgs.push({
id: "temp-user",
role: "USER",
message: temporaryQuestion,
status: "temporary",
});
}
// Ajouter la réponse en cours de streaming
if (
streamedResponse ||
isStreaming ||
activeTool ||
runnedTools.length > 0
) {
lastHistoryMsg = msgs[msgs. - ];
cleanStream = streamedResponse
.(, )
.();
cleanHistory = lastHistoryMsg?.
?.(, )
?.();
isDuplicate =
lastHistoryMsg?. === &&
!isStreaming &&
(lastHistoryMsg?. === streamedResponse ||
cleanHistory === cleanStream ||
(cleanStream. > && cleanHistory?.(cleanStream)));
(!isDuplicate) {
msgs.({
: ,
: ,
: streamedResponse,
: isStreaming ? : ,
runnedTools,
activeTool,
});
}
}
msgs;
}, [
historyMessages,
temporaryQuestion,
streamedResponse,
isStreaming,
runnedTools,
activeTool,
]);
{ messages };
}
Lors du passage du streaming à l'historique permanent, il faut éviter d'afficher le même message deux fois :
// Comparer le contenu sans les balises <think>
const cleanStream = streamedResponse
.replace(/<think>[\s\S]*?<\/think>/, "")
.trim();
const cleanHistory = lastHistoryMsg?.message
?.replace(/<think>[\s\S]*?<\/think>/, "")
?.trim();
const isDuplicate =
lastHistoryMsg?.role === "ASSISTANT" &&
!isStreaming &&
(lastHistoryMsg?.message === streamedResponse ||
cleanHistory === cleanStream ||
(cleanStream.length > 10 && cleanHistory?.includes(cleanStream)));
const abortControllerRef = useRef<AbortController | null>(null);
function sendMessage(message: string) {
// Créer un nouveau controller pour cette requête
abortControllerRef.current = new AbortController();
try {
const response = await fetch(url, {
// ...
signal: abortControllerRef.current.signal,
});
// ...
} catch (error) {
if (error.name !== "AbortError") {
// Erreur réelle, afficher un message
toast.error("Impossible d'envoyer le message");
}
// Si c'est AbortError, c'est une annulation volontaire
} finally {
abortControllerRef.current = null;
}
}
function stopRequest() {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
setIsStreaming(false);
setWaitingForResponse(false);
setTemporaryQuestion();
}
async function sendMessage(message: string) {
try {
const response = await fetch(url, {
/* ... */
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
if (!response.body) {
throw new Error("No response body");
}
// Traiter le flux...
} catch (error) {
if (error.name === "AbortError") {
// Annulation volontaire, ne rien faire
return;
}
// Erreur réelle
console.error("Stream error:", error);
toast.error("Erreur lors de l'envoi du message");
// Réinitialiser les états
setIsStreaming(false);
setWaitingForResponse(false);
setTemporaryQuestion("");
}
}
// Créer un timeout pour la requête
const timeoutId = setTimeout(() => {
abortControllerRef.current?.abort();
toast.error("La requête a pris trop de temps");
}, 60000); // 60 secondes
try {
// ... traitement du streaming
} finally {
clearTimeout(timeoutId);
}
Voici un exemple React complet d'intégration :
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
interface ToolInfo {
id: string;
name: string;
args: Record<string, unknown>;
startTime: number;
endTime?: number;
}
interface Message {
id: string;
role: "USER" | "ASSISTANT" | "SYSTEM";
message: string;
status: "complete" | "streaming" | "temporary" | "error";
runnedTools?: ToolInfo[];
activeTool?: ToolInfo | null;
}
export function useChat({ chatId, agentId, userToken }) {
const [isStreaming, setIsStreaming] = useState(false);
const [streamedResponse, setStreamedResponse] = useState("");
const [temporaryQuestion, setTemporaryQuestion] = useState();
[waitingForResponse, setWaitingForResponse] = ();
[runnedTools, setRunnedTools] = useState<[]>([]);
[activeTool, setActiveTool] = useState< | >();
abortControllerRef = useRef< | >();
historyMessages = ( {
[];
}, [chatId]);
messages = ( {
: [] = [...historyMessages];
(temporaryQuestion) {
msgs.({
: ,
: ,
: temporaryQuestion,
: ,
});
}
(
streamedResponse ||
isStreaming ||
activeTool ||
runnedTools. >
) {
msgs.({
: ,
: ,
: streamedResponse,
: isStreaming ? : ,
runnedTools,
activeTool,
});
}
msgs;
}, [
historyMessages,
temporaryQuestion,
streamedResponse,
isStreaming,
runnedTools,
activeTool,
]);
processStreamChunk = ( {
(token.()) {
toolMatch = token.();
(toolMatch) {
[, action, dataString] = toolMatch;
{
toolData;
{
toolData = .(dataString);
} {
toolData = { : dataString };
}
(action === ) {
uniqueId = ;
: = {
: uniqueId,
: toolData.,
: toolData. || {},
: .(),
};
( [...prev, toolInfo]);
(toolInfo);
} (action === ) {
( {
(prev?. === toolData.) {
(
tools.(
t. === prev. ? { ...t, : .() } : t,
),
);
;
}
prev;
});
}
} (e) {
.(, e);
}
}
;
}
( prev + token);
}, []);
sendMessage = (
(: , : [] = []) => {
(!message) ;
(message);
();
();
();
([]);
();
url = ;
abortControllerRef. = ();
{
response = (url, {
: ,
: {
: ,
: ,
},
: .({
: ,
: [{ : , : message }],
files,
: chatId,
: agentId,
}),
: abortControllerRef..,
});
(!response. || !response.) {
();
}
();
reader = response..();
buffer = ;
lastFailedChunk = ;
finalChatId = chatId;
() {
{ done, value } = reader.();
(done) ;
buffer += ().(value);
: ;
((splitIndex = buffer.()) !== -) {
messageWithLastFail = buffer.(, splitIndex).();
buffer = buffer.(splitIndex + );
(!messageWithLastFail.()) ;
(messageWithLastFail === ) ;
{
jsonString = messageWithLastFail.(, );
jsonData = .(jsonString);
lastFailedChunk = ;
token = jsonData?.?.[]?.?.;
(token) (token);
(
jsonData. &&
(!chatId || chatId !== jsonData.)
) {
finalChatId = jsonData.;
}
} (e) {
(messageWithLastFail === lastFailedChunk) ;
lastFailedChunk = messageWithLastFail;
buffer = messageWithLastFail + + buffer;
;
}
}
}
();
();
(finalChatId) {
}
} (error) {
(error. !== ) {
.(, error);
}
();
();
} {
abortControllerRef. = ;
}
},
[chatId, agentId, userToken, processStreamChunk],
);
stopRequest = ( {
(abortControllerRef.) {
abortControllerRef..();
}
();
();
();
}, []);
{
messages,
sendMessage,
stopRequest,
isStreaming,
waitingForResponse,
};
}
requestAnimationFrame pour le scroll automatique pendant le streamingReact.memo pour éviter les re-renders inutilesuseEffect(() => {
if (isStreaming && isAttachedToBottom) {
requestAnimationFrame(() => {
scrollContainerRef.current?.scrollTo({
top: scrollContainerRef.current.scrollHeight,
behavior: "auto",
});
});
}
}, [messages, isStreaming, isAttachedToBottom]);
<button
onClick={stopRequest}
disabled={!isStreaming}
>
{isStreaming ? "Arrêter la génération" : "Génération terminée"}
</button>
useEffect(() => {
// Reset quand la conversation change
setStreamedResponse("");
setIsStreaming(false);
setTemporaryQuestion("");
setWaitingForResponse(false);
setRunnedTools([]);
setActiveTool(null);
}, [chatId]);
<div role="log" aria-live="polite" aria-atomic="false">
{messages.map(msg => (
<div key={msg.id} aria-label={`Message de ${msg.role}`}>
{msg.message}
</div>
))}
</div>
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
// Appliquer uniquement en dehors des blocs de code
function addEscapeOutsideCodeBlocks(input: string): string {
const segments = input.split(/(```[\s\S]*?```|`[^`]*`)/);
return segments
.map((segment, i) => {
if (i % 2 === 0) {
// Échapper uniquement en dehors des blocs de code
return segment.replace(/</g, "<").replace(/>/g, ">");
}
return segment;
})
.join();
}
// Test du parsing SSE
describe("SSE Parsing", () => {
it("should parse complete messages", () => {
const chunk = 'data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n';
const result = parseSSEChunk(chunk);
expect(result.token).toBe("Hello");
});
it("should handle incomplete messages", () => {
const chunk1 = 'data: {"choices":[{"delta":';
const chunk2 = '{"content":"Hello"}}]}\n\n';
const buffer = chunk1 + chunk2;
const result = parseSSEChunk(buffer);
expect(result.token).toBe("Hello");
});
});
// Test de l'extraction des balises think
describe("Think Tag Extraction", () => {
it("should extract think content", () => {
const message = "<think>Thinking...</think>Response";
const { thinkContent, mainContent } = extractThinkAndMainContent(message);
expect(thinkContent).toBe("Thinking...");
(mainContent).();
});
(, {
message = ;
{ thinkContent, mainContent } = (message);
(thinkContent).();
(mainContent).();
});
});
(, {
(, {
token = ;
result = (token);
(result.).();
(result.).();
(result.).({ : });
});
});
L'intégration du streaming chat completions nécessite :
<think> pour les afficher séparément[tool:] pour afficher l'exécution des outilsEn suivant ce guide, vous pourrez intégrer de manière robuste et performante le système de streaming chat completions de Devana dans votre application.
Pour toute question ou problème d'intégration, n'hésitez pas à consulter notre documentation complète ou à contacter notre support technique.