Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions components/chat/chat-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1167,8 +1167,13 @@ export function ChatPage() {
: message,
),
);
} catch {
// Canvas é best-effort: mantém o texto completo na bolha.
} catch (error) {
// Canvas é best-effort (a resposta permanece íntegra), mas o erro
// precisa ser visível para diagnóstico em produção.
console.error("[canvas] falha ao criar/atualizar canvas", error);
toast.error(
`Falha ao abrir o Canvas: ${error instanceof Error ? error.message : "erro desconhecido"}`,
);
}
}

Expand Down
133 changes: 133 additions & 0 deletions lib/canvas-detector.flappy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { describe, expect, it } from "vitest";
import { buildDisplayText, detectCanvas } from "./canvas-detector";

// Reprodução fiel da resposta real de 15:30 (mistral/ministral-8b):
// fence ```html GRANDE contendo template literals com backticks no meio das
// linhas (`${birdY}px`), CSS com #ids e estrutura HTML completa.
const flappyHtml = [
"<!DOCTYPE html>",
'<html lang="pt-BR">',
"<head>",
' <meta charset="UTF-8">',
" <style>",
" body {",
" margin: 0;",
" display: flex;",
" height: 100vh;",
" background-color: #f0f0f0;",
" overflow: hidden;",
" }",
" #game-container {",
" position: relative;",
" width: 500px;",
" height: 600px;",
" overflow: hidden;",
" }",
" #score-display {",
" position: absolute;",
" top: 10px;",
" font-size: 24px;",
" font-weight: bold;",
" }",
" #bird {",
" position: absolute;",
" width: 50px;",
" height: 50px;",
" }",
" #pipe {",
" position: absolute;",
" width: 60px;",
" background-color: #4CAF50;",
" }",
" </style>",
"</head>",
"<body>",
' <div id="game-container">',
' <div id="score-display">Pontuação: 0</div>',
' <canvas id="canvas" width="500" height="600"></canvas>',
' <div id="bird"></div>',
" </div>",
" <script>",
" const canvas = document.getElementById('canvas');",
" const bird = document.getElementById('bird');",
" let birdY = 300;",
" let birdVelocity = 0;",
" let gravity = 0.5;",
" let jumpForce = -10;",
" let score = 0;",
" let gameRunning = true;",
" let pipes = [];",
" function initGame() {",
" bird.style.left = '50px';",
" bird.style.top = `${birdY}px`;",
" scoreDisplay.textContent = `Pontuação: ${score}`;",
" pipes = [];",
" requestAnimationFrame(gameLoop);",
" }",
" function gameLoop(timestamp) {",
" if (!gameRunning) return;",
" birdVelocity += gravity;",
" birdY += birdVelocity;",
" bird.style.top = `${birdY}px`;",
" if (birdY > 600 || birdY < 0) {",
" gameOver();",
" return;",
" }",
" updatePipes();",
" gameLoopId = requestAnimationFrame(gameLoop);",
" }",
" function addPipe() {",
" const pipeHeight = Math.floor(Math.random() * 200) + 100;",
" const pipeTop = document.createElement('div');",
" pipeTop.style.height = `${pipeHeight}px`;",
" pipeTop.style.top = '0';",
" pipes.push({ top: pipeTop, x: 500, passed: false });",
" }",
" function updatePipes() {",
" pipes.forEach(pipe => {",
" pipe.x -= 2;",
" pipe.top.style.left = `${pipe.x}px`;",
" });",
" }",
" initGame();",
" <\/script>",
"</body>",
"</html>",
].join("\n");

const realResponse = [
"Aqui está um clone simplificado do estilo de FapBird (jogo de clicar em pássaros) implementado como um canvas interativo usando HTML, CSS e JavaScript. Este exemplo é autocontido e pode ser inserido diretamente em um painel Canvas do ModelHub.",
"",
"```html",
flappyHtml,
"```",
"",
"Características do Clone:",
"Controles: Clique no canvas ou pressione Espaço para fazer o pássaro subir.",
"Nota: Este código é autocontido e não requer bibliotecas externas.",
].join("\n");

describe("reprodução: resposta flappy bird de 15:30", () => {
it("detecta o fence html grande mesmo com template literals contendo backticks", () => {
const suggestion = detectCanvas(realResponse, { explicitIntent: true });
expect(suggestion).not.toBeNull();
expect(suggestion!.kind).toBe("html");
});

it("detecta também SEM intenção explícita (fence > 800 chars)", () => {
const suggestion = detectCanvas(realResponse);
expect(suggestion?.kind).toBe("html");
});

it("display text remove o html e mantém a conclusão", () => {
const suggestion = detectCanvas(realResponse, { explicitIntent: true })!;
const display = buildDisplayText(realResponse, suggestion);
expect(display).not.toContain("<!DOCTYPE");
expect(display).toContain("Características do Clone");
});

it("fence html com indentação de até 3 espaços também é detectado (tolerância CommonMark)", () => {
const indented = `Intro:\n\n \`\`\`html\n${"<p>x</p>".repeat(30)}\n \`\`\`\n\nFim.`;
expect(detectCanvas(indented, { explicitIntent: true })?.kind).toBe("html");
});
});
7 changes: 6 additions & 1 deletion lib/canvas-detector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,12 @@ type FencedBlock = {

function extractFencedBlocks(text: string): FencedBlock[] {
const blocks: FencedBlock[] = [];
const fenceRegex = /^```([\w-]*)[^\n]*\n([\s\S]*?)\n?^```\s*$/gm;
/**
* Fences no padrão CommonMark: até 3 espaços de indentação na abertura e no
* fechamento, espaço opcional antes da linguagem ("``` html") e sufixos de
* info-string tolerados. O range retornado inclui a indentação.
*/
const fenceRegex = /^ {0,3}```[ \t]*([\w-]*)[^\n]*\n([\s\S]*?)\n?^ {0,3}```[ \t]*$/gm;
let match: RegExpExecArray | null;
while ((match = fenceRegex.exec(text)) !== null) {
blocks.push({
Expand Down
Loading