Skip to content

Instantly share code, notes, and snippets.

@celsowm
Created June 28, 2025 21:13
Show Gist options
  • Save celsowm/5290f65add6b4a3aa8d7ba6082de2b20 to your computer and use it in GitHub Desktop.
Save celsowm/5290f65add6b4a3aa8d7ba6082de2b20 to your computer and use it in GitHub Desktop.
Json stream
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<title>SimpleCanvasLLM with History - Chat Style</title>
<link
rel="stylesheet"
href="https://uicdn.toast.com/editor/latest/toastui-editor.min.css"
/>
<link
href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<style>
html,
body {
height: 100%;
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: "Poppins", Arial, sans-serif;
background-color: #f0f2f5;
}
body {
padding: 15px;
display: flex;
flex-direction: column;
gap: 10px;
}
.main-layout {
flex: 1;
display: flex;
flex-direction: row;
gap: 15px;
overflow: hidden;
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.left-panel {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
border-right: 1px solid #e0e0e0;
padding: 15px;
}
#chat-history {
flex-grow: 1;
overflow-y: auto;
padding: 10px;
display: flex;
flex-direction: column;
gap: 15px;
}
.chat-interaction {
display: flex;
flex-direction: column;
gap: 8px;
}
.message-bubble {
font-size: 0.75em;
padding: 10px 14px;
border-radius: 18px;
max-width: 80%;
word-wrap: break-word;
line-height: 1.5;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.user-bubble {
background-color: #007bff;
color: white;
align-self: flex-end;
border-bottom-right-radius: 5px;
}
.user-prompt-content {
white-space: pre-wrap;
}
.ai-bubble {
background-color: #e9ecef;
color: #343a40;
align-self: flex-start;
border-bottom-left-radius: 5px;
display: flex;
flex-direction: column;
gap: 8px;
}
.llm-comment-content {
white-space: pre-wrap;
min-height: 1.5em;
}
.load-artifact-button {
font-family: "Poppins", Arial, sans-serif;
font-size: 0.5em;
padding: 4px 8px;
cursor: pointer;
background-color: #6c757d;
color: white;
border: none;
border-radius: 4px;
display: none;
align-self: flex-start;
}
.load-artifact-button:hover {
background-color: #5a6268;
}
.prompt-input-area {
display: flex;
gap: 10px;
padding-top: 15px;
border-top: 1px solid #e0e0e0;
align-items: flex-start;
}
#user-prompt {
flex-grow: 1;
min-height: 42px;
max-height: 150px;
padding: 10px;
font-size: 14px;
box-sizing: border-box;
resize: vertical;
border: 1px solid #ced4da;
border-radius: 6px;
line-height: 1.4;
}
#user-prompt:focus {
border-color: #80bdff;
outline: 0;
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
button#send {
padding: 0 15px;
font-size: 14px;
cursor: pointer;
background-color: #007bff;
color: white;
border: none;
border-radius: 6px;
height: 42px;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.15s ease-in-out;
}
button#send:hover {
background-color: #0056b3;
}
button#send:disabled {
background-color: #6c757d;
cursor: not-allowed;
}
.right-panel {
flex: 2;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 15px;
}
.section {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
border: 1px solid #e0e0e0;
border-radius: 8px;
background: #fff;
}
.section h3 {
margin: 0;
padding: 12px 15px;
font-size: 0.95em;
font-weight: 600;
background: #f8f9fa;
border-bottom: 1px solid #e0e0e0;
border-top-left-radius: 7px;
border-top-right-radius: 7px;
color: #343a40;
}
#tui-editor-container {
flex: 1;
overflow: hidden;
padding: 1px;
min-height: 200px;
}
.toastui-editor-defaultUI {
border: none !important;
}
#status {
font-weight: 500;
padding: 8px 0;
text-align: center;
font-size: 0.9em;
min-height: 1.5em;
}
#artefato-wrapper {
position: relative;
}
#shimmer-overlay-artefato {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(200, 210, 220, 0.6);
z-index: 100;
display: none;
opacity: 0;
overflow: hidden;
border-radius: 8px;
transition: opacity 0.5s ease-out;
}
#shimmer-overlay-artefato::before {
content: "";
position: absolute;
top: 0;
left: -100%;
width: 75%;
height: 100%;
background: linear-gradient(
to right,
transparent 0%,
rgba(255, 255, 255, 0.5) 50%,
transparent 100%
);
animation: shimmer-effect-artefato 1.8s infinite linear;
}
@keyframes shimmer-effect-artefato {
0% {
transform: translateX(0);
}
100% {
transform: translateX(233%);
}
}
</style>
</head>
<body>
<div class="main-layout">
<div class="left-panel">
<div id="chat-history"></div>
<div class="prompt-input-area">
<textarea
id="user-prompt"
placeholder="Digite seu prompt aqui..."
></textarea>
<button id="send">Enviar</button>
</div>
</div>
<div class="right-panel">
<div class="section" id="artefato-wrapper">
<div id="shimmer-overlay-artefato"></div>
<h3>Artefato</h3>
<div id="tui-editor-container"></div>
</div>
</div>
</div>
<div id="status"></div>
<script src="https://uicdn.toast.com/editor/latest/toastui-editor-all.min.js"></script>
<script type="module">
import { LLMJsonStreamParser } from "./LLMJsonStreamParser.js";
function unescapeJsonChunk(jsonStringChunk) {
try {
return JSON.parse(`"${jsonStringChunk}"`);
} catch (e) {
let s = jsonStringChunk;
s = s.replace(/\\\\/g, "\\");
s = s.replace(/\\"/g, '"');
s = s.replace(/\\n/g, "\n");
s = s.replace(/\\r/g, "\r");
s = s.replace(/\\t/g, "\t");
s = s.replace(/\\b/g, "\b");
s = s.replace(/\\f/g, "\f");
return s;
}
}
function stripCodeFence(text) {
if (text && /^```[^\n]*\n/.test(text) && text.trim().endsWith("```")) {
let linhas = text.split(/\r?\n/);
linhas.shift();
if (linhas.length > 0 && linhas[linhas.length - 1].trim() === "```") {
linhas.pop();
}
return linhas.join("\n");
}
return text;
}
(async function () {
const ENDPOINT = "http://10.120.191.11:8000/v1/chat/completions";
const btnSend = document.getElementById("send");
const elmPromptInput = document.getElementById("user-prompt");
const elmStatus = document.getElementById("status");
const elmChatHistory = document.getElementById("chat-history");
const shimmerOverlayArtefato = document.getElementById(
"shimmer-overlay-artefato"
);
let lastArtefactContent = "";
let editor;
let chatHistoryData = [];
let shimmerFadeOutStarted = false;
let shimmerIsVisible = false;
try {
editor = new toastui.Editor({
el: document.querySelector("#tui-editor-container"),
height: "100%",
initialEditType: "wysiwyg",
previewStyle: "vertical",
initialValue: "",
usageStatistics: false,
});
} catch (e) {
console.error("Failed to initialize TUI Editor:", e);
document.getElementById("tui-editor-container").textContent =
"Erro ao carregar o editor de texto.";
}
const schema = {
type: "object",
properties: {
is_substantive_content: {
type: "boolean",
description:
"True se o 'artefato' gerado é um conteúdo principal e extenso (como uma redação, código, receita, história longa) que o usuário pode querer referenciar ou modificar em prompts subsequentes. False se o 'artefato' é uma resposta curta, conversacional, uma saudação, uma piada simples, ou uma pergunta que não constitui um documento principal.",
},
artefato: {
type: "string",
description:
"Conteúdo completo da resposta ao prompt. Se 'is_substantive_content' for true, este campo contém o documento principal. Se 'is_substantive_content' for false, este campo contém a mesma resposta conversacional curta que está no campo 'comentario'.",
},
comentario: {
type: "string",
description:
"Se 'is_substantive_content' for true, este é um comentário sobre o artefato gerado (descrevendo seu tipo, tema, utilidade). Se 'is_substantive_content' for false, este campo contém a própria resposta conversacional curta ao usuário.",
},
},
required: ["is_substantive_content", "artefato", "comentario"],
};
function setStatus(msg) {
elmStatus.textContent = msg;
if (!msg) {
elmStatus.style.color = "transparent";
return;
}
if (msg.toLowerCase().startsWith("ok")) {
elmStatus.style.color = "#28a745";
} else if (msg.toLowerCase().includes("erro")) {
elmStatus.style.color = "#dc3545";
} else {
elmStatus.style.color = "#6c757d";
}
}
function showArtefactShimmer() {
if (!shimmerOverlayArtefato) return;
shimmerOverlayArtefato.style.display = "block";
shimmerOverlayArtefato.offsetHeight;
shimmerOverlayArtefato.style.opacity = "1";
shimmerIsVisible = true;
shimmerFadeOutStarted = false;
}
function startArtefactShimmerFadeOut() {
if (
!shimmerOverlayArtefato ||
shimmerFadeOutStarted ||
!shimmerIsVisible
)
return;
shimmerFadeOutStarted = true;
shimmerOverlayArtefato.style.opacity = "0";
const handleTransitionEnd = () => {
if (shimmerOverlayArtefato.style.opacity === "0") {
shimmerOverlayArtefato.style.display = "none";
shimmerIsVisible = false;
}
shimmerOverlayArtefato.removeEventListener(
"transitionend",
handleTransitionEnd
);
};
shimmerOverlayArtefato.addEventListener(
"transitionend",
handleTransitionEnd
);
}
function addInteractionToHistory(
userPromptText,
initialLlmCommentText = "..."
) {
const interactionPairDiv = document.createElement("div");
interactionPairDiv.className = "chat-interaction";
const userBubbleDiv = document.createElement("div");
userBubbleDiv.className = "message-bubble user-bubble";
const userPromptContentDiv = document.createElement("div");
userPromptContentDiv.className = "user-prompt-content";
userPromptContentDiv.textContent = userPromptText;
userBubbleDiv.appendChild(userPromptContentDiv);
interactionPairDiv.appendChild(userBubbleDiv);
const aiBubbleDiv = document.createElement("div");
aiBubbleDiv.className = "message-bubble ai-bubble";
const llmCommentContentDiv = document.createElement("div");
llmCommentContentDiv.className = "llm-comment-content";
llmCommentContentDiv.textContent = initialLlmCommentText;
aiBubbleDiv.appendChild(llmCommentContentDiv);
const loadButton = document.createElement("button");
loadButton.className = "load-artifact-button";
loadButton.textContent = "⏪ Restaurar";
aiBubbleDiv.appendChild(loadButton);
interactionPairDiv.appendChild(aiBubbleDiv);
elmChatHistory.appendChild(interactionPairDiv);
elmChatHistory.scrollTop = elmChatHistory.scrollHeight;
const historyEntry = {
userPrompt: userPromptText,
llmComment: "",
artefactContent: null,
isSubstantive: false,
commentDisplayElement: llmCommentContentDiv,
loadButtonElement: loadButton,
};
chatHistoryData.push(historyEntry);
return historyEntry;
}
function addErrorInteractionToHistory(userPromptText, errorMessage) {
const interactionPairDiv = document.createElement("div");
interactionPairDiv.className = "chat-interaction";
const userBubbleDiv = document.createElement("div");
userBubbleDiv.className = "message-bubble user-bubble";
const userPromptContentDiv = document.createElement("div");
userPromptContentDiv.className = "user-prompt-content";
userPromptContentDiv.textContent = userPromptText;
userBubbleDiv.appendChild(userPromptContentDiv);
interactionPairDiv.appendChild(userBubbleDiv);
const aiBubbleDiv = document.createElement("div");
aiBubbleDiv.className = "message-bubble ai-bubble";
const llmCommentContentDiv = document.createElement("div");
llmCommentContentDiv.className = "llm-comment-content";
llmCommentContentDiv.style.color = "red";
llmCommentContentDiv.textContent = errorMessage;
aiBubbleDiv.appendChild(llmCommentContentDiv);
interactionPairDiv.appendChild(aiBubbleDiv);
elmChatHistory.appendChild(interactionPairDiv);
elmChatHistory.scrollTop = elmChatHistory.scrollHeight;
chatHistoryData.push({
userPrompt: userPromptText,
llmComment: `Erro: ${errorMessage}`,
artefactContent: null,
isSubstantive: false,
commentDisplayElement: llmCommentContentDiv,
loadButtonElement: null,
});
}
function updateEditorArtefact(rawMarkdown) {
console.log(`[EDITOR] Setting markdown:`, rawMarkdown);
if (!editor) {
console.error("[EDITOR] Editor not available!");
return;
}
let semFences = stripCodeFence(rawMarkdown);
editor.setMarkdown(semFences);
}
function handleLoadArtifact(historyIndex) {
if (historyIndex < 0 || historyIndex >= chatHistoryData.length) {
setStatus("Erro: Índice de histórico inválido.");
return;
}
const entry = chatHistoryData[historyIndex];
if (entry && entry.isSubstantive && entry.artefactContent) {
if (editor) {
editor.setMarkdown(entry.artefactContent);
lastArtefactContent = entry.artefactContent;
setStatus(
`Artefato de "${entry.userPrompt.substring(
0,
30
)}..." carregado no editor.`
);
elmPromptInput.focus();
}
} else {
setStatus(
"Nenhum artefato substantivo para carregar desta entrada."
);
}
}
btnSend.addEventListener("click", async () => {
const userPromptText = elmPromptInput.value.trim();
if (!userPromptText) {
setStatus("Por favor, digite um prompt.");
elmPromptInput.focus();
return;
}
if (!editor) {
setStatus("Editor não está pronto.");
return;
}
if (editor) {
lastArtefactContent = editor.getMarkdown();
}
setStatus("Gerando resposta...");
btnSend.disabled = true;
showArtefactShimmer();
const currentHistoryEntry = addInteractionToHistory(userPromptText);
const buf = {
is_substantive_content: null,
artefato: "",
comentario: "",
};
let currentKey = null;
// --- PROMPT DE SISTEMA REFINADO E COMPLETO ---
const systemPromptContent = `
Sua única e exclusiva saída deve ser um único objeto JSON que adere estritamente ao schema e às regras abaixo. Não inclua NENHUM texto, explicação ou markdown como \`\`\`json ... \`\`\` fora do objeto JSON. Sua resposta deve começar com '{' e terminar com '}'.
{
"is_substantive_content": <true_ou_false>,
"artefato": "<conteúdo completo da resposta ao prompt do usuário.>",
"comentario": "<comentário sobre o artefato OU a própria resposta conversacional>"
}
**DESCRIÇÕES DOS CAMPOS DO SCHEMA:**
- **is_substantive_content (booleano):** ${
schema.properties.is_substantive_content.description
}
- **artefato (string):** ${schema.properties.artefato.description}
- **comentario (string):** ${schema.properties.comentario.description}
**INSTRUÇÕES ADICIONAIS PARA OS CAMPOS:**
1. Instruções para o campo "is_substantive_content":
- Além da descrição acima, considere: Se o prompt atual é uma ação sobre um ARTEFATO ANTERIOR (ex: traduzir, resumir), o novo "artefato" (a tradução, resumo, etc.) geralmente também será 'substantive', então "is_substantive_content" deverá ser 'true'.
1. **Regra para 'artefato':**
- Este campo deve conter **APENAS** o conteúdo solicitado (texto, código, poema etc.).
- **NUNCA** inclua frases de enquadramento como "Aqui está o texto..." ou "Espero que ajude." neste campo.
- Se a resposta for curta e conversacional, o 'artefato' deve ser idêntico ao 'comentario'.
2. **Regra para 'comentario':**
- **Se 'is_substantive_content' for true:**
1. Comece com "Aqui está...", "Gerado(a)..." ou "Criei...".
2. Descreva o tipo de conteúdo gerado (ex: "uma redação", "um poema", "um código Python").
3. Mencione o tema principal (ex: "sobre inteligência artificial", "de bolo de cenoura").
4. Conclua indicando que o conteúdo está no editor e pode ser modificado (ex: "Está pronto no editor ao lado.", "Você pode editá-lo conforme necessário.").
- **Se 'is_substantive_content' for false:**
- O campo deve conter a resposta direta e curta ao prompt do usuário.
- 'comentario' e 'artefato' devem ser exatamente iguais.
- **Exemplos:**
\`\`\`json
{
"is_substantive_content": false,
"artefato": "Olá! Tudo ótimo por aqui, e com você?",
"comentario": "Olá! Tudo ótimo por aqui, e com você?"
}
\`\`\`
\`\`\`json
{
"is_substantive_content": false,
"artefato": "A capital da França é Paris.",
"comentario": "A capital da França é Paris."
}
\`\`\`
\`\`\`json
{
"is_substantive_content": false,
"artefato": "De nada! Se precisar de mais alguma coisa, é só chamar. 😊",
"comentario": "De nada! Se precisar de mais alguma coisa, é só chamar. 😊"
}
\`\`\`
3. **Regra para 'is_substantive_content':**
- true se o 'artefato' for um conteúdo principal e elaborado.
- false se a resposta for curta, saudação ou pergunta simples.
- se o prompt agir sobre artefato anterior (ex: traduzir), também use true.
${
lastArtefactContent && lastArtefactContent.trim() !== ""
? `
---
ARTEFATO DE CONTEXTO (DA INTERAÇÃO ANTERIOR):
${lastArtefactContent}
---
Considere o "ARTEFATO DE CONTEXTO" acima ao responder ao prompt atual. Se o prompt for uma ação sobre este artefato, o novo 'artefato' gerado deve ser o resultado dessa ação.
`
: ""
}
Lembre-se: APENAS o objeto JSON. Nada mais.
`.trim();
// --- FIM DO PROMPT DE SISTEMA ---
const body = {
messages: [
{ role: "system", content: systemPromptContent },
{ role: "user", content: userPromptText },
],
stream: true,
temperature: 0.7,
response_format: { type: "json_object", schema: schema },
};
try {
console.log("[REQUEST] Iniciando requisição para o LLM.");
const parser = new LLMJsonStreamParser();
parser.addEventListener("error", (e) => {
const errorMsg = `Erro no parser de JSON: ${e.detail.error.message}`;
console.error("[PARSER-EVENT] Error:", e.detail);
setStatus(errorMsg);
if (currentHistoryEntry.commentDisplayElement) {
currentHistoryEntry.commentDisplayElement.textContent =
errorMsg;
currentHistoryEntry.commentDisplayElement.style.color = "red";
currentHistoryEntry.llmComment = errorMsg;
} else {
addErrorInteractionToHistory(userPromptText, errorMsg);
}
});
parser.addEventListener("key", (e) => {
currentKey = e.detail.value;
console.log(
`%c[PARSER-EVENT] Key: ${currentKey}`,
"color: blue;"
);
});
parser.addEventListener("value", (e) => {
console.log(`%c[PARSER-EVENT] Value (Final):`, "color: green;", {
path: e.detail.path.join("/"),
value: e.detail.value,
});
const path = e.detail.path.join("/");
if (path === "is_substantive_content") {
buf.is_substantive_content = e.detail.value;
} else if (path === "artefato") {
buf.artefato = e.detail.value;
} else if (path === "comentario") {
buf.comentario = e.detail.value;
}
});
parser.addEventListener("valueChunk", (e) => {
const chunk = e.detail.chunk;
const unescapedChunk = unescapeJsonChunk(chunk);
switch (currentKey) {
case "artefato":
// Always buffer the artifact content
buf.artefato += unescapedChunk;
// But only stream to the editor if the content is substantive.
// We know this because `is_substantive_content` is parsed before `artefato`.
if (buf.is_substantive_content === true) {
updateEditorArtefact(buf.artefato);
if (!shimmerFadeOutStarted && shimmerIsVisible) {
startArtefactShimmerFadeOut();
}
}
break;
case "comentario":
buf.comentario += unescapedChunk;
if (currentHistoryEntry.commentDisplayElement) {
currentHistoryEntry.commentDisplayElement.textContent =
buf.comentario;
elmChatHistory.scrollTop = elmChatHistory.scrollHeight;
}
break;
}
});
parser.addEventListener("done", () => {
console.log(
"%c[PARSER-EVENT] Done!",
"font-weight: bold; color: purple;",
"Buffer final:",
buf
);
currentHistoryEntry.llmComment = buf.comentario;
if (
currentHistoryEntry.commentDisplayElement.textContent.trim() ===
"..."
) {
currentHistoryEntry.commentDisplayElement.textContent =
buf.comentario || "(Sem comentário recebido)";
}
currentHistoryEntry.isSubstantive = buf.is_substantive_content;
const finalArtefactContent = stripCodeFence(buf.artefato || "");
currentHistoryEntry.artefactContent = finalArtefactContent;
if (buf.is_substantive_content === true) {
lastArtefactContent = finalArtefactContent;
updateEditorArtefact(finalArtefactContent);
if (currentHistoryEntry.loadButtonElement) {
currentHistoryEntry.loadButtonElement.style.display =
"inline-block";
const historyIndex =
chatHistoryData.indexOf(currentHistoryEntry);
currentHistoryEntry.loadButtonElement.onclick = () =>
handleLoadArtifact(historyIndex);
}
} else {
lastArtefactContent = "";
}
setStatus("OK");
btnSend.disabled = false;
startArtefactShimmerFadeOut();
});
const resp = await fetch(ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!resp.ok) {
const errorText = `Erro HTTP: ${resp.status} - ${resp.statusText}`;
throw new Error(errorText);
}
const reader = resp.body.getReader();
const decoder = new TextDecoder("utf-8");
elmPromptInput.value = "";
while (true) {
const { done, value } = await reader.read();
if (done) {
console.log("[REQUEST] Stream finalizado.");
parser.flush();
break;
}
const decodedChunk = decoder.decode(value, { stream: true });
parser.transform(decodedChunk);
}
} catch (err) {
const errorMsg = "Erro na requisição: " + err.message;
console.error("[REQUEST-ERROR]", err);
setStatus(errorMsg);
if (
currentHistoryEntry.commentDisplayElement.textContent.trim() ===
"..."
) {
currentHistoryEntry.commentDisplayElement.textContent = errorMsg;
currentHistoryEntry.commentDisplayElement.style.color = "red";
currentHistoryEntry.llmComment = errorMsg;
} else {
addErrorInteractionToHistory(userPromptText, errorMsg);
}
btnSend.disabled = false;
startArtefactShimmerFadeOut();
}
});
elmPromptInput.addEventListener("keypress", function (e) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
btnSend.click();
}
});
})();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment