From 699455bf0f24930c37b438508b8b35c7dd5ba98c Mon Sep 17 00:00:00 2001 From: Alex Schmitt Date: Tue, 18 Aug 2026 12:03:09 -0300 Subject: [PATCH] =?UTF-8?q?fix(canvas):=20canvas=20abre=20quando=20o=20usu?= =?UTF-8?q?=C3=A1rio=20pede=20+=20card=20imediato=20+=20abertura=20manual?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Instrução oculta enviada ao modelo quando a mensagem indica intenção de canvas (evita confusão com Canva.com e pede artefato em formato elegível) - Limiares do detector relaxados quando a intenção é explícita - Card de canvas anexado imediatamente à mensagem após criação (antes só aparecia ao recarregar a conversa) - Botão 'Canvas' nas ações da resposta do assistente: abre qualquer resposta existente no painel (detector com intenção explícita ou fallback markdown) - Testes: intenção (canvas/canva/criar jogo), limiar relaxado, card imediato --- components/chat/chat-page.tsx | 105 ++++++++++++++++++++++++++++-- lib/canvas-detector.repro.test.ts | 82 +++++++++++++++++++++++ lib/canvas-detector.test.ts | 35 +++++++++- lib/canvas-detector.ts | 39 +++++++++-- 4 files changed, 251 insertions(+), 10 deletions(-) create mode 100644 lib/canvas-detector.repro.test.ts diff --git a/components/chat/chat-page.tsx b/components/chat/chat-page.tsx index 5dd7bc5..7cbb016 100644 --- a/components/chat/chat-page.tsx +++ b/components/chat/chat-page.tsx @@ -91,7 +91,12 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip import { apiFetch, apiJson, apiJsonRequest } from "@/lib/api"; import { createCanvas, getCanvas, listCanvases, listProjects, updateCanvas } from "@/lib/canvas-client"; -import { buildDisplayText, detectCanvas } from "@/lib/canvas-detector"; +import { + buildDisplayText, + CANVAS_ASSISTANT_GUIDANCE, + detectCanvas, + shouldRequestCanvasGuidance, +} from "@/lib/canvas-detector"; import { saveProviderCredentials } from "@/lib/save-provider-credentials"; import { getBrowserChatProviderAdapter, @@ -916,8 +921,21 @@ export function ChatPage() { role: "user" as const, } : null; - const messagesToSend: ConversationMessage[] = canvasContextMessage - ? [...nextConversation.slice(0, -1), canvasContextMessage, nextConversation.at(-1)!] + // Intenção explícita de canvas: instrui o modelo a produzir formato elegível + const canvasIntent = shouldRequestCanvasGuidance(text); + const canvasGuidanceMessage: ConversationMessage | null = canvasIntent + ? { + id: crypto.randomUUID(), + parts: [{ text: CANVAS_ASSISTANT_GUIDANCE, type: "text" as const }], + role: "user" as const, + } + : null; + const contextualPrepend: ConversationMessage[] = [ + ...(canvasGuidanceMessage ? [canvasGuidanceMessage] : []), + ...(canvasContextMessage ? [canvasContextMessage] : []), + ]; + const messagesToSend: ConversationMessage[] = contextualPrepend.length > 0 + ? [...nextConversation.slice(0, -1), ...contextualPrepend, nextConversation.at(-1)!] : nextConversation; const projectHeaders: Record = activeProjectId ? { [MODELHUB_PROJECT_HEADER]: activeProjectId } @@ -1105,7 +1123,9 @@ export function ChatPage() { let convIdEarly = activeConversationId; let createdConversation = false; - const suggestion = temporaryChat ? null : detectCanvas(fullText); + const suggestion = temporaryChat + ? null + : detectCanvas(fullText, { explicitIntent: canvasIntent }); if (suggestion) { try { if (!convIdEarly) { @@ -1142,7 +1162,9 @@ export function ChatPage() { : [canvasPart]; setMessages((current) => current.map((message) => - message.id === assistantMessageId ? { ...message, content: displayContent } : message, + message.id === assistantMessageId + ? { ...message, content: displayContent, parts: assistantParts } + : message, ), ); } catch { @@ -1344,6 +1366,62 @@ export function ChatPage() { } } + // Converte manualmente uma resposta existente em Canvas. + // Útil para mensagens anteriores à detecção automática ou respostas de modelos + // que produziram um fence menor que os limiares normais. + async function handleOpenMessageInCanvas(message: ChatMessage) { + if (message.role !== "assistant" || !message.content.trim()) return; + + try { + const convId = activeConversationId ?? await ensureConversationId("Canvas"); + const suggestion = detectCanvas(message.content, { explicitIntent: true }); + const fallbackTitle = message.content + .split("\n") + .map((line) => line.replace(/^#+\s*/, "").trim()) + .find(Boolean) + ?.slice(0, 60) || "Canvas"; + const candidate = suggestion ?? { + content: message.content, + kind: "markdown" as const, + language: null, + title: fallbackTitle, + }; + const canvas = await createCanvas(convId, { + content: candidate.content, + kind: candidate.kind, + language: candidate.language, + title: candidate.title, + }); + const canvasPart: CanvasReferencePart = { + canvasId: canvas.id, + kind: canvas.kind, + title: canvas.title, + type: "canvas", + }; + const displayContent = suggestion + ? buildDisplayText(message.content, suggestion) + : message.content; + + setActiveCanvas(canvas); + setCanvasPanelOpen(true); + setMessages((current) => current.map((entry) => + entry.id === message.id + ? { + ...entry, + content: displayContent, + parts: [ + ...(entry.parts?.filter((part) => part.type !== "canvas") ?? []), + canvasPart, + ], + } + : entry, + )); + toast.success("Canvas aberto."); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Falha ao abrir o Canvas."); + } + } + // Continue generation — send "Continue" as a follow-up to get the model to keep going async function handleContinueGeneration() { if (pending || !selectedProvider) return; @@ -2021,6 +2099,23 @@ export function ChatPage() { )} + {/* Abrir uma resposta existente no Canvas */} + {message.role === "assistant" && + !message.isError && + message.content.trim().length >= 40 && + !message.parts?.some((part) => part.type === "canvas") ? ( + + ) : null} + {/* Regenerate (last assistant only) */} {message.role === "assistant" && messageIndex === messages.length - 1 && ( '); + const suggestion = detectCanvas(text, { explicitIntent: true }); + expect(suggestion).toMatchObject({ kind: "html", language: "html" }); + }); + + it("limiares normais seguem valendo sem intenção explícita", () => { + const text = fence("html", '

Clone do jogo

'); + expect(detectCanvas(text)).toBeNull(); + }); + it("detecta markdown quando a mensagem inteira passa de 1500 chars", () => { const text = `# Relatório\n\n${"parágrafo com conteúdo suficiente. ".repeat(80)}`; const suggestion = detectCanvas(text); @@ -110,6 +121,28 @@ describe("detectCanvas", () => { }); }); +describe("shouldRequestCanvasGuidance", () => { + it.each([ + "Não pode fazer o canva?", + "Eu quero ver o canvas", + "Abra um canvas para isso", + "Quero um clone de um jogo", + "Crie uma landing page", + "Monte um diagrama", + ])("ativa para intenção visual: %s", (text) => { + expect(shouldRequestCanvasGuidance(text)).toBe(true); + }); + + it.each([ + "oi", + "Explique o que é Python", + "Resuma esse texto", + "Qual a previsão do tempo?", + ])("não ativa para conversa comum: %s", (text) => { + expect(shouldRequestCanvasGuidance(text)).toBe(false); + }); +}); + describe("buildDisplayText", () => { it("remove o fence mermaid e mantém o texto ao redor", () => { const full = `Antes\n\n${fence("mermaid", longMermaid)}\n\nDepois`; diff --git a/lib/canvas-detector.ts b/lib/canvas-detector.ts index 54b6a2a..847f8db 100644 --- a/lib/canvas-detector.ts +++ b/lib/canvas-detector.ts @@ -17,6 +17,32 @@ export type CanvasSuggestion = { displayText?: string; }; +/** + * Instrução oculta enviada apenas quando a mensagem indica intenção de criar ou + * abrir um canvas. Evita que o modelo confunda o workspace com Canva.com e o + * orienta a produzir um formato que o detector consegue abrir. + */ +export const CANVAS_ASSISTANT_GUIDANCE = [ + "[capacidade do ModelHub: Canvas]", + "O usuário está pedindo um canvas interativo dentro do ModelHub, não o site Canva.com.", + "Crie o conteúdo diretamente nesta resposta em UM bloco fenced completo:", + "- interface, jogo ou página: use ```html ou ```tsx com implementação funcional e autocontida;", + "- diagrama: use ```mermaid;", + "- documento longo: use Markdown estruturado.", + "Não explique como acessar ferramentas externas. Entregue o artefato pronto para o painel Canvas.", +].join("\n"); + +const CANVAS_INTENT_RE = /\b(?:canvas|canva|quadro|artefato|preview|visualiza(?:r|ção)|abr(?:ir|a)|mostrar|ver)\b/i; +const CREATION_INTENT_RE = /\b(?:cri(?:ar|e)|faz(?:er|a)|mont(?:ar|e)|ger(?:ar|e)|constru(?:ir|a)|clone|prototip(?:ar|e))\b/i; +const VISUAL_ARTIFACT_RE = /\b(?:site|página|pagina|landing|interface|componente|app|aplicativo|jogo|game|diagrama|fluxograma|dashboard|layout|html|react|mermaid)\b/i; + +export function shouldRequestCanvasGuidance(userText: string): boolean { + const normalized = userText.trim(); + if (!normalized) return false; + return CANVAS_INTENT_RE.test(normalized) || + (CREATION_INTENT_RE.test(normalized) && VISUAL_ARTIFACT_RE.test(normalized)); +} + const MERMAID_MIN_CHARS = 40; const FENCED_MIN_LINES = 20; const FENCED_MIN_CHARS = 800; @@ -98,10 +124,15 @@ function fencedSuggestion( * Detecta a sugestão de canvas mais forte em um texto. * Prioridade: mermaid > react/html > code > markdown. Emite no máximo UMA. */ -export function detectCanvas(fullText: string): CanvasSuggestion | null { +export function detectCanvas( + fullText: string, + options?: { explicitIntent?: boolean }, +): CanvasSuggestion | null { if (!fullText.trim()) return null; const blocks = extractFencedBlocks(fullText); + const eligibleFence = (content: string) => + options?.explicitIntent ? content.trim().length >= MERMAID_MIN_CHARS : meetsSizeThreshold(content); const mermaid = blocks.find( (block) => block.language === "mermaid" && block.content.trim().length >= MERMAID_MIN_CHARS, @@ -109,12 +140,12 @@ export function detectCanvas(fullText: string): CanvasSuggestion | null { if (mermaid) return fencedSuggestion(mermaid, "mermaid", "mermaid"); const react = blocks.find( - (block) => REACT_LANGS.has(block.language) && meetsSizeThreshold(block.content), + (block) => REACT_LANGS.has(block.language) && eligibleFence(block.content), ); if (react) return fencedSuggestion(react, "react", react.language); const html = blocks.find( - (block) => block.language === "html" && meetsSizeThreshold(block.content), + (block) => block.language === "html" && eligibleFence(block.content), ); if (html) return fencedSuggestion(html, "html", "html"); @@ -124,7 +155,7 @@ export function detectCanvas(fullText: string): CanvasSuggestion | null { block.language !== "mermaid" && block.language !== "html" && !REACT_LANGS.has(block.language) && - meetsSizeThreshold(block.content), + eligibleFence(block.content), ); if (code) return fencedSuggestion(code, "code", code.language);