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
105 changes: 100 additions & 5 deletions components/chat/chat-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, string> = activeProjectId
? { [MODELHUB_PROJECT_HEADER]: activeProjectId }
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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") ? (
<Button
variant="ghost"
size="sm"
className="h-8 gap-1 text-xs md:h-6 md:text-[10px]"
onClick={() => void handleOpenMessageInCanvas(message)}
title="Abrir esta resposta no Canvas"
>
<FrameIcon className="size-3" />
Canvas
</Button>
) : null}

{/* Regenerate (last assistant only) */}
{message.role === "assistant" && messageIndex === messages.length - 1 && (
<Button
Expand Down
82 changes: 82 additions & 0 deletions lib/canvas-detector.repro.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import { buildDisplayText, detectCanvas } from "./canvas-detector";

// Reprodução fiel (estrutura) da resposta real do usuário em produção:
// fences malformados sem linguagem (com "markdown" como 1ª linha do conteúdo)
// e um fence python grande no final.
const realResponse = `Claro! Posso te ajudar a criar designs, layouts ou ideias para o Canva usando Markdown para explicar o passo a passo.

Aqui estão algumas sugestões:

\`\`\`
markdown
1. Acesse o site
2. Faça login
3. Escolha um modelo
\`\`\`

Mais texto explicativo suficiente para passar do limite de markdown da mensagem inteira.

\`\`\`
markdown
1. **Adicionar elementos**
- Clique em Elementos
- Procure formas
\`\`\`

4. Exemplo de Código para Gerar um Design:

\`\`\`python
from PIL import Image, ImageDraw, ImageFont

# Criar uma imagem em branco (90x50 mm = 354x194 pixels para 300 DPI)
width, height = 354, 194
img = Image.new("RGB", (width, height), color=(255, 255, 255))
draw = ImageDraw.Draw(img)

# Carregar uma fonte (voce pode baixar uma fonte como Arial.ttf)
try:
font = ImageFont.truetype("arial.ttf", 20)
except:
font = ImageFont.load_default()

# Definir cores
cor_texto = (0, 0, 0) # Preto
cor_fundo = (50, 50, 50) # Cinza escuro

# Desenhar texto
draw.text((20, 20), "Joao Silva", font=font, fill=cor_texto)
draw.text((20, 50), "Desenvolvedor Full Stack", font=font, fill=cor_texto)
draw.text((20, 80), "joao@example.com", font=font, fill=cor_texto)
draw.text((20, 110), "LinkedIn: linkedin.com/in/joao", font=font, fill=cor_texto)

# Salvar a imagem
img.save("cartao_visita.png")
print("Cartao de visita gerado!")
\`\`\`

Observação final do modelo aqui.

Como posso te ajudar especificamente? Diga-me: qual tipo de design você quer criar? Qual é o objetivo? Prefere um guia passo a passo ou um template pronto? Estou aqui para ajudar!`;

describe("reprodução do caso real reportado", () => {
it("detecta o fence python grande como canvas code", () => {
const suggestion = detectCanvas(realResponse);
expect(suggestion).not.toBeNull();
expect(suggestion!.kind).toBe("code");
expect(suggestion!.language).toBe("python");
});

it("remove o fence do texto da bolha e mantém o resto", () => {
const suggestion = detectCanvas(realResponse)!;
const display = buildDisplayText(realResponse, suggestion);
expect(display).not.toContain("from PIL");
expect(display).toContain("Claro! Posso te ajudar");
expect(display).toContain("Observação final");
});

it("fences sem linguagem não viram canvas code", () => {
const onlyMalformed = `Texto curto com fence sem linguagem:\n\`\`\`\nmarkdown\n1. item\n2. item\n\`\`\``;
expect(detectCanvas(onlyMalformed)).toBeNull();
});
});
35 changes: 34 additions & 1 deletion lib/canvas-detector.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";

import { buildDisplayText, detectCanvas } from "./canvas-detector";
import { buildDisplayText, detectCanvas, shouldRequestCanvasGuidance } from "./canvas-detector";

function fence(language: string, content: string): string {
return `\`\`\`${language}\n${content}\n\`\`\``;
Expand Down Expand Up @@ -77,6 +77,17 @@ describe("detectCanvas", () => {
expect(suggestion).toBeNull();
});

it("aceita fence menor quando o usuário pediu canvas explicitamente", () => {
const text = fence("html", '<main><h1>Clone do jogo</h1><button>Jogar</button></main>');
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", '<main><h1>Clone do jogo</h1><button>Jogar</button></main>');
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);
Expand Down Expand Up @@ -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`;
Expand Down
39 changes: 35 additions & 4 deletions lib/canvas-detector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -98,23 +124,28 @@ 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,
);
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");

Expand All @@ -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);

Expand Down
Loading