From eafef1470599c72626b40b5ca7fd1d29107926bc Mon Sep 17 00:00:00 2001 From: Alex Schmitt Date: Tue, 18 Aug 2026 16:10:48 -0300 Subject: [PATCH 1/6] fix(db): rotas de canvas/artefato sem $transaction nem nested writes O adapter PrismaNeonHttp nao suporta transacoes nem writes aninhados ($transaction interno), quebrando com "Transactions are not supported in HTTP mode" (HTTP 500) na criacao/versao/pin de canvas em producao e local. - POST /conversations/:id/canvases: canvas + versao 1 sequenciais com compensacao (delete do canvas se a versao falhar) - PATCH /canvas/:id e restore: versao + update sequenciais com compensacao (remove a versao orfa se o update falhar) - POST /canvas/:id/pin: artefato + versao 1 sequenciais com compensacao - POST /projects/:id/artifacts/:id/refresh: versao + update sequenciais - Resposta serializa versoes via findMany explicito (sem include) - Testes unitarios atualizados (12/12); validado contra adapter Neon HTTP real com banco Neon (todos os shapes pos-fix funcionam) --- server/routes/canvas.ts | 99 ++++++++++++++++++++---------- server/routes/conversations.ts | 36 +++++++---- server/routes/projects.ts | 21 ++++--- server/tests/canvas-routes.test.ts | 14 +++++ 4 files changed, 118 insertions(+), 52 deletions(-) diff --git a/server/routes/canvas.ts b/server/routes/canvas.ts index b359638..32344c0 100644 --- a/server/routes/canvas.ts +++ b/server/routes/canvas.ts @@ -134,27 +134,35 @@ app.patch("/:id", async (c) => { select: { version: true }, }); - await prisma.$transaction([ - prisma.canvasVersion.create({ - data: { - canvasId, - content, - kind: kind ?? canvas.kind, - language: language ?? canvas.language, - version: (lastVersion?.version ?? 0) + 1, - }, - }), - prisma.canvas.update({ + // Adapter Neon HTTP não suporta $transaction: writes sequenciais. + // Se a atualização falhar após criar a versão, remove-a (compensação). + const nextVersion = (lastVersion?.version ?? 0) + 1; + await prisma.canvasVersion.create({ + data: { + canvasId, + content, + kind: kind ?? canvas.kind, + language: language ?? canvas.language, + version: nextVersion, + }, + }); + try { + await prisma.canvas.update({ where: { id: canvasId }, data: { - activeVersion: (lastVersion?.version ?? 0) + 1, + activeVersion: nextVersion, ...(content !== undefined ? { content } : {}), ...(kind !== undefined ? { kind } : {}), ...(language !== undefined ? { language } : {}), ...(title !== undefined ? { title } : {}), }, - }), - ]); + }); + } catch (error) { + await prisma.canvasVersion + .delete({ where: { canvasId_version: { canvasId, version: nextVersion } } }) + .catch(() => undefined); + throw error; + } } else { await prisma.canvas.update({ where: { id: canvasId }, @@ -238,26 +246,34 @@ app.post("/:id/versions/:version/restore", async (c) => { select: { version: true }, }); - await prisma.$transaction([ - prisma.canvasVersion.create({ - data: { - canvasId, - content: source.content, - kind: source.kind, - language: source.language, - version: (lastVersion?.version ?? 0) + 1, - }, - }), - prisma.canvas.update({ + // Adapter Neon HTTP não suporta $transaction: writes sequenciais com + // compensação se a atualização do canvas falhar. + const nextVersion = (lastVersion?.version ?? 0) + 1; + await prisma.canvasVersion.create({ + data: { + canvasId, + content: source.content, + kind: source.kind, + language: source.language, + version: nextVersion, + }, + }); + try { + await prisma.canvas.update({ where: { id: canvasId }, data: { - activeVersion: (lastVersion?.version ?? 0) + 1, + activeVersion: nextVersion, content: source.content, kind: source.kind, language: source.language, }, - }), - ]); + }); + } catch (error) { + await prisma.canvasVersion + .delete({ where: { canvasId_version: { canvasId, version: nextVersion } } }) + .catch(() => undefined); + throw error; + } const detail = await resolveCanvasDetail(canvasId); if (!detail) return jsonErrorResponse(404, "Canvas not found"); @@ -317,6 +333,8 @@ app.post("/:id/pin", async (c) => { return c.json({ artifactId: existing.id }, 409); } + // Adapter Neon HTTP não suporta writes aninhados: artefato + versão 1 + // sequenciais, com compensação se a versão falhar. const artifact = await prisma.projectArtifact.create({ data: { currentVersion: 1, @@ -326,11 +344,26 @@ app.post("/:id/pin", async (c) => { sourceCanvasId: canvasId, sourceConversationId: canvas.conversationId, title: canvas.title, - versions: { - create: { content: canvas.content, version: 1 }, - }, }, - include: { versions: { orderBy: { version: "desc" }, select: { createdAt: true, version: true } } }, + }); + + try { + await prisma.artifactVersion.create({ + data: { + artifactId: artifact.id, + content: canvas.content, + version: 1, + }, + }); + } catch (error) { + await prisma.projectArtifact.delete({ where: { id: artifact.id } }).catch(() => undefined); + throw error; + } + + const versions = await prisma.artifactVersion.findMany({ + where: { artifactId: artifact.id }, + orderBy: { version: "desc" }, + select: { createdAt: true, version: true }, }); const activeContent = await prisma.artifactVersion.findUnique({ @@ -353,7 +386,7 @@ app.post("/:id/pin", async (c) => { sourceConversationId: artifact.sourceConversationId, title: artifact.title, updatedAt: artifact.updatedAt, - versions: artifact.versions, + versions, }, }, 201, diff --git a/server/routes/conversations.ts b/server/routes/conversations.ts index fb6a75f..40de757 100644 --- a/server/routes/conversations.ts +++ b/server/routes/conversations.ts @@ -588,6 +588,8 @@ app.post("/:id/canvases", async (c) => { return jsonErrorResponse(400, "Invalid canvas payload"); } + // Adapter Neon HTTP não suporta writes aninhados ($transaction interno): + // cria o canvas e a versão 1 sequencialmente, com ação compensatória. const canvas = await prisma.canvas.create({ data: { content: parsed.data.content, @@ -595,20 +597,30 @@ app.post("/:id/canvases", async (c) => { kind: parsed.data.kind, language: parsed.data.language ?? null, title: parsed.data.title ?? "Canvas", - versions: { - create: { - content: parsed.data.content, - kind: parsed.data.kind, - language: parsed.data.language ?? null, - version: 1, - }, - }, - }, - include: { - versions: { orderBy: { version: "desc" }, select: { createdAt: true, kind: true, language: true, version: true } }, }, }); + try { + await prisma.canvasVersion.create({ + data: { + canvasId: canvas.id, + content: parsed.data.content, + kind: parsed.data.kind, + language: parsed.data.language ?? null, + version: 1, + }, + }); + } catch (error) { + await prisma.canvas.delete({ where: { id: canvas.id } }).catch(() => undefined); + throw error; + } + + const versions = await prisma.canvasVersion.findMany({ + where: { canvasId: canvas.id }, + orderBy: { version: "desc" }, + select: { createdAt: true, kind: true, language: true, version: true }, + }); + return c.json( { canvas: { @@ -622,7 +634,7 @@ app.post("/:id/canvases", async (c) => { shareToken: canvas.shareToken, title: canvas.title, updatedAt: canvas.updatedAt, - versions: canvas.versions, + versions, }, }, 201, diff --git a/server/routes/projects.ts b/server/routes/projects.ts index 9944b45..6c9fc49 100644 --- a/server/routes/projects.ts +++ b/server/routes/projects.ts @@ -417,11 +417,13 @@ app.post("/:id/artifacts/:artifactId/refresh", async (c) => { }); const nextVersion = (lastVersion?.version ?? 0) + 1; - await prisma.$transaction([ - prisma.artifactVersion.create({ - data: { artifactId, content: canvas.content, version: nextVersion }, - }), - prisma.projectArtifact.update({ + // Adapter Neon HTTP não suporta $transaction: writes sequenciais com + // compensação se a atualização do artefato falhar. + await prisma.artifactVersion.create({ + data: { artifactId, content: canvas.content, version: nextVersion }, + }); + try { + await prisma.projectArtifact.update({ where: { id: artifactId }, data: { currentVersion: nextVersion, @@ -429,8 +431,13 @@ app.post("/:id/artifacts/:artifactId/refresh", async (c) => { language: canvas.language, title: canvas.title, }, - }), - ]); + }); + } catch (error) { + await prisma.artifactVersion + .delete({ where: { artifactId_version: { artifactId, version: nextVersion } } }) + .catch(() => undefined); + throw error; + } const refreshed = await prisma.projectArtifact.findFirst({ where: { id: artifactId, projectId }, diff --git a/server/tests/canvas-routes.test.ts b/server/tests/canvas-routes.test.ts index b216cb3..bacf83c 100644 --- a/server/tests/canvas-routes.test.ts +++ b/server/tests/canvas-routes.test.ts @@ -135,6 +135,20 @@ const mockPrisma = { state.artifactVersions.push(version); return version; }), + delete: vi.fn(async ({ where }: { where: { artifactId_version: { artifactId: string; version: number } } }) => { + state.artifactVersions = state.artifactVersions.filter( + (version) => + !( + version.artifactId === where.artifactId_version.artifactId && + version.version === where.artifactId_version.version + ), + ); + return { id: "removed" }; + }), + findMany: vi.fn(async ({ where }: { where: { artifactId: string } }) => + state.artifactVersions + .filter((version) => version.artifactId === where.artifactId) + .sort((left, right) => right.version - left.version)), findUnique: vi.fn(async ({ where }: { where: { artifactId_version: { artifactId: string; version: number } } }) => state.artifactVersions.find( (version) => From 7b34033cf83c3d9320e7150b761ae4ffc0c35359 Mon Sep 17 00:00:00 2001 From: Alex Schmitt Date: Tue, 18 Aug 2026 19:45:22 -0300 Subject: [PATCH 2/6] feat(chat): bastidores da resposta (roteamento, fallback, TTFT) + nota no feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Painel "Bastidores" por mensagem do assistente: provider/modelo efetivo, tier e motivo do roteamento automático, tentativas de fallback entre provedores, TTFT e custo — visível na linha de timestamp e num dialogo detalhado (Dialog/Table reaproveitados de components/ui). Feedback: thumbs-down agora aceita uma nota curta opcional (MessageReaction.note). Corrige dois bugs que impediam os bastidores de aparecer: - O id gerado no cliente para a mensagem do assistente nunca era usado como Message.id persistido (o servidor sempre gerava um cuid novo), entao o UsageLog nunca encontrava a mensagem certa via join. - UsageLog.messageId era @unique, mas o roteamento automatico pode tentar varios provedores (fallback) para a mesma mensagem, cada um gravando sua propria linha - a constraint descartava silenciosamente a tentativa bem-sucedida quando uma tentativa anterior ja tinha ocupado o messageId. Agora messageId nao e mais unico e GET /conversations/:id/messages agrupa todas as tentativas por messageId, escolhendo a bem-sucedida como resumo e as demais como "attempts" (fallback cross-provider). Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 10 + components/chat/chat-page.tsx | 3671 ++++++++++-------- components/chat/message-backstage-dialog.tsx | 166 + lib/chat-utils.ts | 476 ++- lib/contracts.ts | 7 + prisma/schema.prisma | 17 +- server/lib/provider-core.ts | 981 +++-- server/routes/conversations.ts | 702 ++-- server/routes/v1.ts | 362 +- server/tests/conversations-routes.test.ts | 989 +++-- server/tests/provider-core.test.ts | 483 ++- 11 files changed, 4945 insertions(+), 2919 deletions(-) create mode 100644 components/chat/message-backstage-dialog.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 95f03f3..2fbf027 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,3 +72,13 @@ GitHub Actions runs lint, typecheck, test, and build in parallel on push/PR to m - 2-space indentation, single quotes, semicolons (Prettier configured in `.prettierrc`) - Prefer Server Components; use `"use client"` only when needed - Tests colocated with source files (e.g., `lib/chat-stream.test.ts`) + + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/components/chat/chat-page.tsx b/components/chat/chat-page.tsx index 49b2df6..d25f0f9 100644 --- a/components/chat/chat-page.tsx +++ b/components/chat/chat-page.tsx @@ -1,16 +1,18 @@ -"use client"; +"use client" -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import Image from "next/image"; -import { useSearchParams } from "next/navigation"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import Image from "next/image" +import { useSearchParams } from "next/navigation" import { type CloudDeploymentSummary, type CanvasDetail, type ProviderModel, type ProjectSummary, type UiProvider, + MODELHUB_CONVERSATION_HEADER, + MODELHUB_MESSAGE_HEADER, MODELHUB_PROJECT_HEADER, -} from "@/lib/contracts"; +} from "@/lib/contracts" import { type AttachmentExtractionStatus, type AttachmentKind, @@ -19,7 +21,7 @@ import { type HydratedAttachmentPart, type HydratedConversationMessagePart, createMessageContentFallback, -} from "@/lib/chat-parts"; +} from "@/lib/chat-parts" import { BotIcon, CheckIcon, @@ -35,6 +37,7 @@ import { PaperclipIcon, PencilIcon, RefreshCwIcon, + RouteIcon, SendHorizontalIcon, Settings2Icon, PlayIcon, @@ -46,25 +49,32 @@ import { ThumbsUpIcon, UserIcon, XIcon, -} from "lucide-react"; -import { toast } from "sonner"; - -import { useAppState } from "@/components/app-state-provider"; -import { CanvasPanel } from "@/components/canvas/canvas-panel"; -import { ChatHistorySidebar } from "@/components/chat/chat-history-sidebar"; -import { SettingsDialog } from "@/components/chat/settings-dialog"; -import { MarkdownRenderer } from "@/components/markdown-renderer"; -import { useIsMobile } from "@/hooks/use-mobile"; +} from "lucide-react" +import { toast } from "sonner" + +import { useAppState } from "@/components/app-state-provider" +import { CanvasPanel } from "@/components/canvas/canvas-panel" +import { ChatHistorySidebar } from "@/components/chat/chat-history-sidebar" +import { MessageBackstageDialog } from "@/components/chat/message-backstage-dialog" +import { SettingsDialog } from "@/components/chat/settings-dialog" +import { MarkdownRenderer } from "@/components/markdown-renderer" +import { useIsMobile } from "@/hooks/use-mobile" import { buildExportFilename, conversationToJson, conversationToMarkdown, downloadTextFile, -} from "@/lib/conversation-export"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +} from "@/lib/conversation-export" +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" import { DropdownMenu, DropdownMenuCheckboxItem, @@ -72,37 +82,74 @@ import { DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"; -import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field"; -import { Input } from "@/components/ui/input"; +} from "@/components/ui/dropdown-menu" +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty" +import { + Field, + FieldDescription, + FieldGroup, + FieldLabel, +} from "@/components/ui/field" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea, InputGroupText, -} from "@/components/ui/input-group"; +} from "@/components/ui/input-group" -import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"; -import { Skeleton } from "@/components/ui/skeleton"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectSeparator, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Skeleton } from "@/components/ui/skeleton" +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 { apiFetch, apiJson, apiJsonRequest } from "@/lib/api" +import { + createCanvas, + getCanvas, + listCanvases, + listProjects, + updateCanvas, +} from "@/lib/canvas-client" import { buildDisplayText, CANVAS_ASSISTANT_GUIDANCE, detectCanvas, shouldRequestCanvasGuidance, -} from "@/lib/canvas-detector"; -import { saveProviderCredentials } from "@/lib/save-provider-credentials"; +} from "@/lib/canvas-detector" +import { saveProviderCredentials } from "@/lib/save-provider-credentials" import { getBrowserChatProviderAdapter, type BrowserProviderAuthState, -} from "@/lib/browser-chat-providers"; -import { useProviderModels } from "@/lib/use-provider-models"; +} from "@/lib/browser-chat-providers" +import { useProviderModels } from "@/lib/use-provider-models" import { estimateSerializedPayloadBytes, formatBytes, @@ -112,8 +159,8 @@ import { MAX_TOTAL_DOCUMENT_ATTACHMENT_BYTES, MAX_TOTAL_ATTACHMENT_BYTES, validateFileSelection, -} from "@/lib/chat-attachments"; -import { parseChatStream, type ParsedToolCall } from "@/lib/chat-stream"; +} from "@/lib/chat-attachments" +import { parseChatStream, type ParsedToolCall } from "@/lib/chat-stream" import { providerAuthMode, providerCredentialIds, @@ -121,9 +168,9 @@ import { providerUsesBrowserSession, providerUsesStoredCredentials, sortProvidersByConfiguredCredentials, -} from "@/lib/provider-credentials"; -import { resolveMaxOutputTokens } from "@/lib/model-output-limits"; -import { cn } from "@/lib/utils"; +} from "@/lib/provider-credentials" +import { resolveMaxOutputTokens } from "@/lib/model-output-limits" +import { cn } from "@/lib/utils" import { ACCEPTED_DOCUMENT_TYPES, ACCEPTED_IMAGE_TYPES, @@ -132,6 +179,7 @@ import { buildUserMessageParts, DUCKAI_TEMPORARY_INLINE_MESSAGE, EMPTY_STATE_PROMPTS, + formatBackstageInline, formatMessageTimestamp, getUserMessageText, hydrateChatMessage, @@ -151,78 +199,89 @@ import { type ComposerAttachment, type ConversationMessage, type PersistedConversationMessage, -} from "@/lib/chat-utils"; +} from "@/lib/chat-utils" -const AUTO_PROVIDER_ID = "modelhub-auto"; +const AUTO_PROVIDER_ID = "modelhub-auto" const AUTO_MODEL: ProviderModel = { capabilities: { documents: true, images: true, reasoning: true }, id: "auto", name: "Auto · Smart Routing", -}; +} export function ChatPage() { - const { credentials, providers, refreshCredentials } = useAppState(); - const [selectedProviderId, setSelectedProviderId] = useState(""); - const [messages, setMessages] = useState([]); - const [conversation, setConversation] = useState([]); - const [input, setInput] = useState(""); - const [pending, setPending] = useState(false); - const [credentialDialogOpen, setCredentialDialogOpen] = useState(false); - const [credentialValues, setCredentialValues] = useState>({}); - const [savingCredentials, setSavingCredentials] = useState(false); - - const scrollViewportRef = useRef(null); + const { credentials, providers, refreshCredentials } = useAppState() + const [selectedProviderId, setSelectedProviderId] = useState("") + const [messages, setMessages] = useState([]) + const [conversation, setConversation] = useState([]) + const [input, setInput] = useState("") + const [pending, setPending] = useState(false) + const [credentialDialogOpen, setCredentialDialogOpen] = useState(false) + const [credentialValues, setCredentialValues] = useState< + Record + >({}) + const [savingCredentials, setSavingCredentials] = useState(false) + + const scrollViewportRef = useRef(null) // Conversation persistence state - const [activeConversationId, setActiveConversationId] = useState(null); - const [sidebarRefreshKey, setSidebarRefreshKey] = useState(0); + const [activeConversationId, setActiveConversationId] = useState< + string | null + >(null) + const [sidebarRefreshKey, setSidebarRefreshKey] = useState(0) // Temporary chat mode — messages are never persisted - const [temporaryChat, setTemporaryChat] = useState(false); + const [temporaryChat, setTemporaryChat] = useState(false) // Settings/personalization dialog - const [settingsOpen, setSettingsOpen] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false) const [browserProviderAuthState, setBrowserProviderAuthState] = - useState("unknown"); + useState("unknown") - const [mobileHistoryOpen, setMobileHistoryOpen] = useState(false); - const isMobile = useIsMobile(); + const [mobileHistoryOpen, setMobileHistoryOpen] = useState(false) + const isMobile = useIsMobile() // Canvas workspace (spec v2 — fase 2) - const [activeCanvas, setActiveCanvas] = useState(null); - const [canvasPanelOpen, setCanvasPanelOpen] = useState(false); - const [includeCanvasContext, setIncludeCanvasContext] = useState(true); + const [activeCanvas, setActiveCanvas] = useState(null) + const [canvasPanelOpen, setCanvasPanelOpen] = useState(false) + const [includeCanvasContext, setIncludeCanvasContext] = useState(true) const [canvasWidth, setCanvasWidth] = useState(() => { - if (typeof window === "undefined") return 420; - const stored = Number(window.localStorage.getItem("canvas-panel-width")); - return Number.isFinite(stored) && stored >= 340 && stored <= 760 ? stored : 420; - }); - const canvasResizeRef = useRef<{ startX: number; startWidth: number } | null>(null); + if (typeof window === "undefined") return 420 + const stored = Number(window.localStorage.getItem("canvas-panel-width")) + return Number.isFinite(stored) && stored >= 340 && stored <= 760 + ? stored + : 420 + }) + const canvasResizeRef = useRef<{ startX: number; startWidth: number } | null>( + null, + ) // Projeto ativo (contexto de projeto na conversa) - const [activeProjectId, setActiveProjectId] = useState(null); - const [projectOptions, setProjectOptions] = useState([]); + const [activeProjectId, setActiveProjectId] = useState(null) + const [projectOptions, setProjectOptions] = useState([]) function beginCanvasResize(event: React.PointerEvent) { - event.preventDefault(); - canvasResizeRef.current = { startX: event.clientX, startWidth: canvasWidth }; + event.preventDefault() + canvasResizeRef.current = { startX: event.clientX, startWidth: canvasWidth } const onMove = (moveEvent: PointerEvent) => { - const state = canvasResizeRef.current; - if (!state) return; - const next = Math.min(760, Math.max(340, state.startWidth - (moveEvent.clientX - state.startX))); - setCanvasWidth(next); - }; + const state = canvasResizeRef.current + if (!state) return + const next = Math.min( + 760, + Math.max(340, state.startWidth - (moveEvent.clientX - state.startX)), + ) + setCanvasWidth(next) + } const onUp = () => { - canvasResizeRef.current = null; - window.removeEventListener("pointermove", onMove); - window.removeEventListener("pointerup", onUp); + canvasResizeRef.current = null + window.removeEventListener("pointermove", onMove) + window.removeEventListener("pointerup", onUp) setCanvasWidth((current) => { - window.localStorage.setItem("canvas-panel-width", String(current)); - return current; - }); - }; - window.addEventListener("pointermove", onMove); - window.addEventListener("pointerup", onUp); + window.localStorage.setItem("canvas-panel-width", String(current)) + return current + }) + } + window.addEventListener("pointermove", onMove) + window.addEventListener("pointerup", onUp) } function loadProjectOptions() { @@ -230,135 +289,157 @@ export function ChatPage() { .then(setProjectOptions) .catch(() => { // silencioso — seletor mostra apenas "Sem projeto" - }); + }) } async function openCanvasById(canvasId: string) { try { - setActiveCanvas(await getCanvas(canvasId)); - setCanvasPanelOpen(true); + setActiveCanvas(await getCanvas(canvasId)) + setCanvasPanelOpen(true) } catch { - toast.error("Falha ao abrir o canvas."); + toast.error("Falha ao abrir o canvas.") } } // Stop generation - const abortControllerRef = useRef(null); + const abortControllerRef = useRef(null) // Smart auto-scroll: só acompanha o fim enquanto o usuário não rolar para cima - const stickToBottomRef = useRef(true); - const lastScrollTopRef = useRef(0); + const stickToBottomRef = useRef(true) + const lastScrollTopRef = useRef(0) // Copy message feedback - const [copiedMessageId, setCopiedMessageId] = useState(null); + const [copiedMessageId, setCopiedMessageId] = useState(null) // Message reactions - const [reactions, setReactions] = useState>({}); + const [reactions, setReactions] = useState>({}) + const [reactionNotes, setReactionNotes] = useState>({}) + + // Bastidores da resposta (roteamento, fallback, tempo, tokens de uma resposta) + const [backstageOpenMessageId, setBackstageOpenMessageId] = useState< + string | null + >(null) // Edit message - const [editingMessageId, setEditingMessageId] = useState(null); - const [editingContent, setEditingContent] = useState(""); + const [editingMessageId, setEditingMessageId] = useState(null) + const [editingContent, setEditingContent] = useState("") // File attachments - const [attachments, setAttachments] = useState([]); - const attachmentsRef = useRef([]); - const fileInputRef = useRef(null); + const [attachments, setAttachments] = useState([]) + const attachmentsRef = useRef([]) + const fileInputRef = useRef(null) // Dynamic OpenClaw destinations: every healthy OpenClaw deployment with a // managed config becomes a virtual provider whose base points at the chat-proxy endpoint. - const [openclawDeployments, setOpenclawDeployments] = useState([]); + const [openclawDeployments, setOpenclawDeployments] = useState< + CloudDeploymentSummary[] + >([]) useEffect(() => { - let cancelled = false; - apiJson<{ deployments: CloudDeploymentSummary[] }>("/user/cloud/deployments") + let cancelled = false + apiJson<{ deployments: CloudDeploymentSummary[] }>( + "/user/cloud/deployments", + ) .then((payload) => { - if (cancelled) return; - setOpenclawDeployments(payload.deployments.filter((d) => d.status === "healthy" && d.openclaw)); + if (cancelled) return + setOpenclawDeployments( + payload.deployments.filter( + (d) => d.status === "healthy" && d.openclaw, + ), + ) }) .catch(() => { - if (!cancelled) setOpenclawDeployments([]); - }); + if (!cancelled) setOpenclawDeployments([]) + }) return () => { - cancelled = true; - }; - }, []); + cancelled = true + } + }, []) - const searchParams = useSearchParams(); + const searchParams = useSearchParams() useEffect(() => { - const openclawId = searchParams.get("openclaw"); + const openclawId = searchParams.get("openclaw") if (openclawId) { - const targetProviderId = `openclaw:${openclawId}`; - const exists = openclawDeployments.some((d) => d.id === openclawId); + const targetProviderId = `openclaw:${openclawId}` + const exists = openclawDeployments.some((d) => d.id === openclawId) if (exists) { - setSelectedProviderId(targetProviderId); - const url = new URL(window.location.href); - url.searchParams.delete("openclaw"); - window.history.replaceState({}, "", url.toString()); + setSelectedProviderId(targetProviderId) + const url = new URL(window.location.href) + url.searchParams.delete("openclaw") + window.history.replaceState({}, "", url.toString()) } } // ?project=&new=1 — nova conversa vinculada ao projeto (spec v2) - const projectId = searchParams.get("project"); + const projectId = searchParams.get("project") if (projectId) { - setActiveProjectId(projectId); + setActiveProjectId(projectId) if (searchParams.get("new") === "1") { - attachmentsRef.current.forEach(releaseAttachmentPreview); - setActiveConversationId(null); - setMessages([]); - setConversation([]); - setInput(""); - setAttachments([]); - setActiveCanvas(null); - setCanvasPanelOpen(false); + attachmentsRef.current.forEach(releaseAttachmentPreview) + setActiveConversationId(null) + setMessages([]) + setConversation([]) + setInput("") + setAttachments([]) + setActiveCanvas(null) + setCanvasPanelOpen(false) } - const url = new URL(window.location.href); - url.searchParams.delete("project"); - url.searchParams.delete("new"); - window.history.replaceState({}, "", url.toString()); + const url = new URL(window.location.href) + url.searchParams.delete("project") + url.searchParams.delete("new") + window.history.replaceState({}, "", url.toString()) } - }, [searchParams, openclawDeployments]); + }, [searchParams, openclawDeployments]) // Carrega opções de projeto para o seletor do composer (uma vez) useEffect(() => { - loadProjectOptions(); - }, []); - - const autoProvider = useMemo(() => ({ - base: "/v1", - category: "gateway", - hasModels: true, - id: AUTO_PROVIDER_ID, - label: "ModelHub", - localModels: [AUTO_MODEL], - runtime: { - authMode: "none", - externalApi: false, - kind: "server", - openAiCompatible: true, - transport: "openai-compatible", - }, - }), []); + loadProjectOptions() + }, []) + + const autoProvider = useMemo( + () => ({ + base: "/v1", + category: "gateway", + hasModels: true, + id: AUTO_PROVIDER_ID, + label: "ModelHub", + localModels: [AUTO_MODEL], + runtime: { + authMode: "none", + externalApi: false, + kind: "server", + openAiCompatible: true, + transport: "openai-compatible", + }, + }), + [], + ) - const openclawProviders = useMemo( - () => { - const providerLabels = new Map(providers.map((provider) => [provider.id, provider.label])); + const openclawProviders = useMemo(() => { + const providerLabels = new Map( + providers.map((provider) => [provider.id, provider.label]), + ) - return openclawDeployments.flatMap((deployment) => { - const openclaw = deployment.openclaw; - if (!openclaw) return []; + return openclawDeployments.flatMap((deployment) => { + const openclaw = deployment.openclaw + if (!openclaw) return [] - const providerLabel = providerLabels.get(openclaw.provider) ?? openclaw.provider; + const providerLabel = + providerLabels.get(openclaw.provider) ?? openclaw.provider - return [{ + return [ + { base: `/user/cloud/deployments/${deployment.id}`, hasModels: true, id: `openclaw:${deployment.id}`, label: `OpenClaw · ${deployment.name}`, - localModels: [{ - capabilities: { documents: true, images: false }, - id: openclaw.model, - name: `${providerLabel} · ${openclaw.model}`, - }], + localModels: [ + { + capabilities: { documents: true, images: false }, + id: openclaw.model, + name: `${providerLabel} · ${openclaw.model}`, + }, + ], runtime: { authMode: "none", externalApi: false, @@ -366,156 +447,170 @@ export function ChatPage() { openAiCompatible: true, transport: "modelhub-proxy", }, - }]; - }); - }, - [openclawDeployments, providers], - ); + }, + ] + }) + }, [openclawDeployments, providers]) const selectedProvider = useMemo( () => - [autoProvider, ...providers, ...openclawProviders].find((provider) => provider.id === selectedProviderId) ?? - null, + [autoProvider, ...providers, ...openclawProviders].find( + (provider) => provider.id === selectedProviderId, + ) ?? null, [autoProvider, providers, openclawProviders, selectedProviderId], - ); + ) const browserProviderAdapter = useMemo( () => getBrowserChatProviderAdapter(selectedProviderId), [selectedProviderId], - ); + ) const providersWithoutApiKey = useMemo( () => providers.filter((provider) => providerAuthMode(provider) === "none"), [providers], - ); + ) const providersWithBrowserSession = useMemo( () => providers.filter(providerUsesBrowserSession), [providers], - ); + ) const providersWithApiKey = useMemo( - () => sortProvidersByConfiguredCredentials( - providers.filter(providerUsesStoredCredentials), - credentials, - ), + () => + sortProvidersByConfiguredCredentials( + providers.filter(providerUsesStoredCredentials), + credentials, + ), [credentials, providers], - ); + ) const configuredProvidersWithApiKey = useMemo( - () => providersWithApiKey.filter((provider) => providerHasRequiredCredentials(provider, credentials)), + () => + providersWithApiKey.filter((provider) => + providerHasRequiredCredentials(provider, credentials), + ), [credentials, providersWithApiKey], - ); + ) const unconfiguredProvidersWithApiKey = useMemo( - () => providersWithApiKey.filter((provider) => !providerHasRequiredCredentials(provider, credentials)), + () => + providersWithApiKey.filter( + (provider) => !providerHasRequiredCredentials(provider, credentials), + ), [credentials, providersWithApiKey], - ); - const selectedProviderReady = providerHasRequiredCredentials(selectedProvider, credentials); + ) + const selectedProviderReady = providerHasRequiredCredentials( + selectedProvider, + credentials, + ) const providerModels = useProviderModels({ selectedProvider, selectedProviderId, selectedProviderReady, - }); - const { models, selectedModel, selectedModelId, setSelectedModelId } = providerModels; - const loadingModels = providerModels.loading; + }) + const { models, selectedModel, selectedModelId, setSelectedModelId } = + providerModels + const loadingModels = providerModels.loading const showConfiguredCheck = useCallback( (provider: UiProvider) => { if (providerUsesStoredCredentials(provider)) { - return providerHasRequiredCredentials(provider, credentials); + return providerHasRequiredCredentials(provider, credentials) } - return false; + return false }, [credentials], - ); + ) const refreshBrowserProviderAuthState = useCallback(async () => { if (!browserProviderAdapter) { - setBrowserProviderAuthState("unknown"); - return; + setBrowserProviderAuthState("unknown") + return } - setBrowserProviderAuthState("loading"); + setBrowserProviderAuthState("loading") try { - setBrowserProviderAuthState(await browserProviderAdapter.auth.getState()); + setBrowserProviderAuthState(await browserProviderAdapter.auth.getState()) } catch { - setBrowserProviderAuthState("signed-out"); + setBrowserProviderAuthState("signed-out") } - }, [browserProviderAdapter]); + }, [browserProviderAdapter]) async function handleBrowserProviderSignIn() { if (!browserProviderAdapter || !selectedProvider) { - return; + return } - setBrowserProviderAuthState("loading"); + setBrowserProviderAuthState("loading") try { - await browserProviderAdapter.auth.signIn(); - setBrowserProviderAuthState("signed-in"); - toast.success(`${selectedProvider.label} conectado.`); + await browserProviderAdapter.auth.signIn() + setBrowserProviderAuthState("signed-in") + toast.success(`${selectedProvider.label} conectado.`) } catch (error) { - setBrowserProviderAuthState("signed-out"); - toast.error(error instanceof Error ? error.message : `Nao foi possivel entrar em ${selectedProvider.label}.`); + setBrowserProviderAuthState("signed-out") + toast.error( + error instanceof Error + ? error.message + : `Nao foi possivel entrar em ${selectedProvider.label}.`, + ) } } - - const allowImageAttachments = selectedModel?.capabilities.images ?? false; - const allowDocumentAttachments = selectedModel?.capabilities.documents ?? true; - const composerHasUploadingAttachments = attachments.some((attachment) => attachment.status === "uploading"); + const allowImageAttachments = selectedModel?.capabilities.images ?? false + const allowDocumentAttachments = selectedModel?.capabilities.documents ?? true + const composerHasUploadingAttachments = attachments.some( + (attachment) => attachment.status === "uploading", + ) useEffect(() => { if (providers.length === 0 || selectedProviderId) { - return; + return } const preferred = - (globalThis.window?.localStorage.getItem("selected-provider") ?? null) ?? - AUTO_PROVIDER_ID; - setSelectedProviderId(preferred); - }, [providers, selectedProviderId]); + globalThis.window?.localStorage.getItem("selected-provider") ?? + AUTO_PROVIDER_ID + setSelectedProviderId(preferred) + }, [providers, selectedProviderId]) useEffect(() => { if (!selectedProviderId || typeof globalThis === "undefined") { - return; + return } - globalThis.localStorage.setItem("selected-provider", selectedProviderId); - }, [selectedProviderId]); + globalThis.localStorage.setItem("selected-provider", selectedProviderId) + }, [selectedProviderId]) useEffect(() => { if (!browserProviderAdapter) { - setBrowserProviderAuthState("unknown"); - return; + setBrowserProviderAuthState("unknown") + return } - void refreshBrowserProviderAuthState(); - }, [browserProviderAdapter, refreshBrowserProviderAuthState]); - + void refreshBrowserProviderAuthState() + }, [browserProviderAdapter, refreshBrowserProviderAuthState]) useEffect(() => { - attachmentsRef.current = attachments; - }, [attachments]); - + attachmentsRef.current = attachments + }, [attachments]) useEffect(() => { return () => { - attachmentsRef.current.forEach(releaseAttachmentPreview); - }; - }, []); + attachmentsRef.current.forEach(releaseAttachmentPreview) + } + }, []) // Smart auto-scroll: scroll to bottom only if user hasn't scrolled up useEffect(() => { - const viewport = scrollViewportRef.current; - if (!viewport) return; + const viewport = scrollViewportRef.current + if (!viewport) return if (stickToBottomRef.current) { - viewport.scrollTop = viewport.scrollHeight; + viewport.scrollTop = viewport.scrollHeight // Mantém a referência em dia para que o `scroll` disparado por esta // escrita não seja lido como rolagem do usuário. - lastScrollTopRef.current = viewport.scrollTop; + lastScrollTopRef.current = viewport.scrollTop } - }, [messages]); + }, [messages]) // Detect if user scrolled up useEffect(() => { - const viewport = scrollViewportRef.current; - if (!viewport) return; + const viewport = scrollViewportRef.current + if (!viewport) return const handleScroll = () => { stickToBottomRef.current = resolveStickToBottom({ @@ -524,125 +619,150 @@ export function ChatPage() { scrollHeight: viewport.scrollHeight, scrollTop: viewport.scrollTop, sticking: stickToBottomRef.current, - }); - lastScrollTopRef.current = viewport.scrollTop; - }; + }) + lastScrollTopRef.current = viewport.scrollTop + } - viewport.addEventListener("scroll", handleScroll, { passive: true }); - return () => viewport.removeEventListener("scroll", handleScroll); - }, []); + viewport.addEventListener("scroll", handleScroll, { passive: true }) + return () => viewport.removeEventListener("scroll", handleScroll) + }, []) async function saveCredentials() { if (!selectedProvider) { - return; + return } - const requiredKeys = selectedProvider.requiredKeys ?? []; - if (requiredKeys.some((field) => !credentialValues[field.envName]?.trim())) { - toast.error("Preencha todas as credenciais do provider."); - return; + const requiredKeys = selectedProvider.requiredKeys ?? [] + if ( + requiredKeys.some((field) => !credentialValues[field.envName]?.trim()) + ) { + toast.error("Preencha todas as credenciais do provider.") + return } - setSavingCredentials(true); + setSavingCredentials(true) try { - const result = await saveProviderCredentials(selectedProvider, credentialValues); + const result = await saveProviderCredentials( + selectedProvider, + credentialValues, + ) if (!result.ok) { - toast.error(result.error); - return; + toast.error(result.error) + return } - await refreshCredentials(); - setCredentialDialogOpen(false); - setCredentialValues({}); - toast.success(`Credenciais salvas para ${selectedProvider.label}.`); + await refreshCredentials() + setCredentialDialogOpen(false) + setCredentialValues({}) + toast.success(`Credenciais salvas para ${selectedProvider.label}.`) } catch (error) { - toast.error(error instanceof Error ? error.message : "Falha ao salvar credenciais."); + toast.error( + error instanceof Error ? error.message : "Falha ao salvar credenciais.", + ) } finally { - setSavingCredentials(false); + setSavingCredentials(false) } } async function clearCredentials() { if (!selectedProvider) { - return; + return } try { - const ids = providerCredentialIds(selectedProvider.id, credentials); - await Promise.all(ids.map((id) => apiJsonRequest(`/user/credentials/${id}`, "DELETE"))); - await refreshCredentials(); - setCredentialDialogOpen(false); - toast.success(`Credenciais removidas de ${selectedProvider.label}.`); + const ids = providerCredentialIds(selectedProvider.id, credentials) + await Promise.all( + ids.map((id) => apiJsonRequest(`/user/credentials/${id}`, "DELETE")), + ) + await refreshCredentials() + setCredentialDialogOpen(false) + toast.success(`Credenciais removidas de ${selectedProvider.label}.`) } catch (error) { - toast.error(error instanceof Error ? error.message : "Falha ao limpar credenciais."); + toast.error( + error instanceof Error ? error.message : "Falha ao limpar credenciais.", + ) } } async function ensureConversationId(titleSeed: string) { if (activeConversationId) { - return activeConversationId; + return activeConversationId } const response = await apiJsonRequest<{ - conversation: { id: string }; + conversation: { id: string } }>("/conversations", "POST", { modelId: selectedModelId || undefined, projectId: activeProjectId ?? undefined, providerId: selectedProviderId || undefined, title: titleSeed || "Nova conversa", - }); + }) - setActiveConversationId(response.conversation.id); - setSidebarRefreshKey((current) => current + 1); - return response.conversation.id; + setActiveConversationId(response.conversation.id) + setSidebarRefreshKey((current) => current + 1) + return response.conversation.id } async function handleFileSelect(e: React.ChangeEvent) { - const files = e.target.files; - if (!files || !selectedProvider) return; + const files = e.target.files + if (!files || !selectedProvider) return if (!selectedProviderReady) { - setCredentialDialogOpen(true); - return; + setCredentialDialogOpen(true) + return } if (selectedProvider.hasModels && !selectedModelId) { - toast.error("Selecione um modelo antes de anexar arquivos."); - return; + toast.error("Selecione um modelo antes de anexar arquivos.") + return } const { accepted, errors } = validateFileSelection( Array.from(files), - attachments.map((attachment) => ({ kind: attachment.kind, size: attachment.byteSize })), - { allowImages: allowImageAttachments, allowDocuments: allowDocumentAttachments }, - ); - errors.forEach((message) => toast.error(message)); - - const uploadQueue: Array<{ file: File; kind: AttachmentKind; previewUrl?: string }> = accepted.map( - ({ file, kind }) => ({ - file, - kind, - previewUrl: kind === "image" ? URL.createObjectURL(file) : undefined, - }), - ); + attachments.map((attachment) => ({ + kind: attachment.kind, + size: attachment.byteSize, + })), + { + allowImages: allowImageAttachments, + allowDocuments: allowDocumentAttachments, + }, + ) + errors.forEach((message) => toast.error(message)) + + const uploadQueue: Array<{ + file: File + kind: AttachmentKind + previewUrl?: string + }> = accepted.map(({ file, kind }) => ({ + file, + kind, + previewUrl: kind === "image" ? URL.createObjectURL(file) : undefined, + })) if (uploadQueue.length === 0) { - if (fileInputRef.current) fileInputRef.current.value = ""; - return; + if (fileInputRef.current) fileInputRef.current.value = "" + return } - let conversationId: string; + let conversationId: string try { - conversationId = await ensureConversationId(input.trim() || uploadQueue[0]?.file.name || "Nova conversa"); + conversationId = await ensureConversationId( + input.trim() || uploadQueue[0]?.file.name || "Nova conversa", + ) } catch (error) { uploadQueue.forEach((entry) => { if (entry.previewUrl?.startsWith("blob:")) { - URL.revokeObjectURL(entry.previewUrl); + URL.revokeObjectURL(entry.previewUrl) } - }); - toast.error(error instanceof Error ? error.message : "Falha ao preparar a conversa para upload."); - if (fileInputRef.current) fileInputRef.current.value = ""; - return; + }) + toast.error( + error instanceof Error + ? error.message + : "Falha ao preparar a conversa para upload.", + ) + if (fileInputRef.current) fileInputRef.current.value = "" + return } const tempAttachments = uploadQueue.map(({ file, kind, previewUrl }) => ({ @@ -655,23 +775,25 @@ export function ChatPage() { mimeType: file.type, previewUrl, status: "uploading" as const, - })); + })) - setAttachments((current) => [...current, ...tempAttachments]); + setAttachments((current) => [...current, ...tempAttachments]) for (const [index, tempAttachment] of tempAttachments.entries()) { - const source = uploadQueue[index]; + const source = uploadQueue[index] if (!source) { - continue; + continue } try { - const formData = new FormData(); - formData.append("file", source.file); - const uploaded = await apiJson<{ attachment: ConversationAttachmentDescriptor }>( - `/conversations/${conversationId}/attachments`, - { body: formData, method: "POST" }, - ); + const formData = new FormData() + formData.append("file", source.file) + const uploaded = await apiJson<{ + attachment: ConversationAttachmentDescriptor + }>(`/conversations/${conversationId}/attachments`, { + body: formData, + method: "POST", + }) setAttachments((current) => current.map((attachment) => @@ -683,128 +805,177 @@ export function ChatPage() { } : attachment, ), - ); + ) } catch (error) { - toast.error(error instanceof Error ? error.message : `Falha ao enviar ${tempAttachment.fileName}.`); + toast.error( + error instanceof Error + ? error.message + : `Falha ao enviar ${tempAttachment.fileName}.`, + ) setAttachments((current) => { - const target = current.find((attachment) => attachment.id === tempAttachment.id); + const target = current.find( + (attachment) => attachment.id === tempAttachment.id, + ) if (target) { - releaseAttachmentPreview(target); + releaseAttachmentPreview(target) } - return current.filter((attachment) => attachment.id !== tempAttachment.id); - }); + return current.filter( + (attachment) => attachment.id !== tempAttachment.id, + ) + }) } } - if (fileInputRef.current) fileInputRef.current.value = ""; + if (fileInputRef.current) fileInputRef.current.value = "" } function removeAttachment(id: string) { setAttachments((prev) => { - const target = prev.find((attachment) => attachment.id === id); + const target = prev.find((attachment) => attachment.id === id) if (target) { - releaseAttachmentPreview(target); + releaseAttachmentPreview(target) } - return prev.filter((attachment) => attachment.id !== id); - }); + return prev.filter((attachment) => attachment.id !== id) + }) } const handleNewChat = useCallback(() => { - attachmentsRef.current.forEach(releaseAttachmentPreview); - setActiveConversationId(null); - setMessages([]); - setConversation([]); - setInput(""); - setEditingMessageId(null); - setAttachments([]); - setTemporaryChat(false); - setActiveCanvas(null); - setCanvasPanelOpen(false); - }, []); + attachmentsRef.current.forEach(releaseAttachmentPreview) + setActiveConversationId(null) + setMessages([]) + setConversation([]) + setInput("") + setEditingMessageId(null) + setAttachments([]) + setTemporaryChat(false) + setActiveCanvas(null) + setCanvasPanelOpen(false) + }, []) // Stop generation const handleStopGeneration = useCallback(() => { - abortControllerRef.current?.abort(); - abortControllerRef.current = null; - setPending(false); - }, []); + abortControllerRef.current?.abort() + abortControllerRef.current = null + setPending(false) + }, []) // Copy message content - const handleCopyMessage = useCallback((messageId: string, content: string) => { - void navigator.clipboard.writeText(content).then(() => { - setCopiedMessageId(messageId); - setTimeout(() => setCopiedMessageId(null), 2000); - }); - }, []); + const handleCopyMessage = useCallback( + (messageId: string, content: string) => { + void navigator.clipboard.writeText(content).then(() => { + setCopiedMessageId(messageId) + setTimeout(() => setCopiedMessageId(null), 2000) + }) + }, + [], + ) // Toggle reaction on a message - async function handleReaction(messageId: string, type: "thumbs_up" | "thumbs_down") { - if (!activeConversationId) return; - const current = reactions[messageId]; + async function handleReaction( + messageId: string, + type: "thumbs_up" | "thumbs_down", + ) { + if (!activeConversationId) return + const current = reactions[messageId] // Optimistic update - setReactions((prev) => ({ ...prev, [messageId]: current === type ? null : type })); + setReactions((prev) => ({ + ...prev, + [messageId]: current === type ? null : type, + })) try { - await apiJsonRequest(`/conversations/${activeConversationId}/messages/${messageId}/reaction`, "POST", { type }); + await apiJsonRequest( + `/conversations/${activeConversationId}/messages/${messageId}/reaction`, + "POST", + { type }, + ) } catch { // Revert on failure - setReactions((prev) => ({ ...prev, [messageId]: current ?? null })); + setReactions((prev) => ({ ...prev, [messageId]: current ?? null })) } } - const handleSelectConversation = useCallback(async (id: string) => { + // Salva a nota do thumbs-down (mantém a reação, só atualiza o texto) + async function handleSaveReactionNote(messageId: string) { + if (!activeConversationId) return + const note = reactionNotes[messageId] ?? "" try { - const data = await apiJson<{ - messages: PersistedConversationMessage[]; - conversation: { providerId: string | null; modelId: string | null; projectId: string | null }; - }>(`/conversations/${id}/messages`); - const persistedAssistantModelLabel = resolveAssistantModelLabel({ - modelId: data.conversation.modelId ?? undefined, - models: [], - providerLabel: providers.find((provider) => provider.id === data.conversation.providerId)?.label, - }); - - attachmentsRef.current.forEach(releaseAttachmentPreview); - setActiveConversationId(id); - setAttachments([]); - setActiveProjectId(data.conversation.projectId ?? null); - setMessages(data.messages.map((message) => hydrateChatMessage({ - assistantModelLabel: persistedAssistantModelLabel, - message, - }))); - setConversation( - data.messages.map((message) => ({ - id: message.id, - parts: message.parts, - role: message.role, - })), - ); - - // Abre o canvas mais recente da conversa (se houver) sem forçar foco. - listCanvases(id) - .then((canvases) => { - if (canvases.length > 0) { - return getCanvas(canvases[0]!.id); + await apiJsonRequest( + `/conversations/${activeConversationId}/messages/${messageId}/reaction`, + "POST", + { note, type: "thumbs_down" }, + ) + } catch { + // Silencioso — a nota fica só no estado local se a chamada falhar. + } + } + + const handleSelectConversation = useCallback( + async (id: string) => { + try { + const data = await apiJson<{ + messages: PersistedConversationMessage[] + conversation: { + providerId: string | null + modelId: string | null + projectId: string | null } - return null; + }>(`/conversations/${id}/messages`) + const persistedAssistantModelLabel = resolveAssistantModelLabel({ + modelId: data.conversation.modelId ?? undefined, + models: [], + providerLabel: providers.find( + (provider) => provider.id === data.conversation.providerId, + )?.label, }) - .then((canvas) => { - if (canvas) setActiveCanvas(canvas); - }) - .catch(() => { - // Canvas é opcional ao abrir conversa - }); - // Restore provider/model if available - if (data.conversation.providerId) { - setSelectedProviderId(data.conversation.providerId); - } - if (data.conversation.modelId) { - setSelectedModelId(data.conversation.modelId); + attachmentsRef.current.forEach(releaseAttachmentPreview) + setActiveConversationId(id) + setAttachments([]) + setActiveProjectId(data.conversation.projectId ?? null) + setMessages( + data.messages.map((message) => + hydrateChatMessage({ + assistantModelLabel: persistedAssistantModelLabel, + message, + }), + ), + ) + setConversation( + data.messages.map((message) => ({ + id: message.id, + parts: message.parts, + role: message.role, + })), + ) + + // Abre o canvas mais recente da conversa (se houver) sem forçar foco. + listCanvases(id) + .then((canvases) => { + if (canvases.length > 0) { + return getCanvas(canvases[0]!.id) + } + return null + }) + .then((canvas) => { + if (canvas) setActiveCanvas(canvas) + }) + .catch(() => { + // Canvas é opcional ao abrir conversa + }) + + // Restore provider/model if available + if (data.conversation.providerId) { + setSelectedProviderId(data.conversation.providerId) + } + if (data.conversation.modelId) { + setSelectedModelId(data.conversation.modelId) + } + } catch { + toast.error("Falha ao carregar conversa.") } - } catch { - toast.error("Falha ao carregar conversa."); - } - }, [providers, setSelectedModelId]); + }, + [providers, setSelectedModelId], + ) function updateAssistantToolCall( assistantMessageId: string, @@ -813,91 +984,105 @@ export function ChatPage() { setMessages((current) => current.map((message) => { if (message.id !== assistantMessageId) { - return message; + return message } - const existingIndex = message.toolCalls.findIndex((item) => item.toolCallId === toolCall.toolCallId); + const existingIndex = message.toolCalls.findIndex( + (item) => item.toolCallId === toolCall.toolCallId, + ) if (existingIndex === -1) { return { ...message, toolCalls: [...message.toolCalls, toolCall], - }; + } } - const nextToolCalls = [...message.toolCalls]; + const nextToolCalls = [...message.toolCalls] nextToolCalls[existingIndex] = { ...nextToolCalls[existingIndex], ...toolCall, - }; + } return { ...message, toolCalls: nextToolCalls, - }; + } }), - ); + ) } async function sendMessage(options?: { - baseConversation?: ConversationMessage[]; - overrideAttachments?: HydratedAttachmentPart[]; - overrideText?: string; + baseConversation?: ConversationMessage[] + overrideAttachments?: HydratedAttachmentPart[] + overrideText?: string }) { - const text = (options?.overrideText ?? input).trim(); - const currentAttachments = options?.overrideAttachments ?? attachments.filter((attachment) => attachment.status === "uploaded").map((attachment) => ({ - ...attachment, - attachmentId: attachment.id, - type: "attachment" as const, - })); - const hasAttachments = currentAttachments.length > 0; - const hasPendingComposerAttachments = attachments.some((attachment) => attachment.status === "uploading"); + const text = (options?.overrideText ?? input).trim() + const currentAttachments = + options?.overrideAttachments ?? + attachments + .filter((attachment) => attachment.status === "uploaded") + .map((attachment) => ({ + ...attachment, + attachmentId: attachment.id, + type: "attachment" as const, + })) + const hasAttachments = currentAttachments.length > 0 + const hasPendingComposerAttachments = attachments.some( + (attachment) => attachment.status === "uploading", + ) if ((!text && !hasAttachments) || pending || !selectedProvider) { - return; + return } if (!options?.overrideAttachments && hasPendingComposerAttachments) { - toast.error("Aguarde o processamento dos anexos antes de enviar."); - return; + toast.error("Aguarde o processamento dos anexos antes de enviar.") + return } if (!selectedProviderReady) { - setCredentialDialogOpen(true); - return; + setCredentialDialogOpen(true) + return } if (selectedProvider.hasModels && !selectedModelId) { - toast.error("Selecione um modelo."); - return; + toast.error("Selecione um modelo.") + return } const compatibilityError = validateAttachmentCompatibility( currentAttachments, - { allowImages: allowImageAttachments, allowDocuments: allowDocumentAttachments }, + { + allowImages: allowImageAttachments, + allowDocuments: allowDocumentAttachments, + }, selectedProvider.label, browserProviderAdapter?.attachments, - ); + ) if (compatibilityError) { - toast.error(compatibilityError); - return; + toast.error(compatibilityError) + return } - const userMessageId = crypto.randomUUID(); - const assistantMessageId = crypto.randomUUID(); + const userMessageId = crypto.randomUUID() + const assistantMessageId = crypto.randomUUID() const assistantModelLabel = resolveAssistantModelLabel({ modelId: selectedProvider.hasModels ? selectedModelId : undefined, models, providerLabel: selectedProvider.label, - }); - - const messageParts = buildUserMessageParts(text, currentAttachments.map((attachment) => ({ - byteSize: attachment.byteSize, - contentUrl: attachment.contentUrl, - extractionStatus: attachment.extractionStatus, - fileName: attachment.fileName, - id: attachment.attachmentId, - kind: attachment.kind, - mimeType: attachment.mimeType, - }))); - const baseConversation = options?.baseConversation ?? conversation; + }) + + const messageParts = buildUserMessageParts( + text, + currentAttachments.map((attachment) => ({ + byteSize: attachment.byteSize, + contentUrl: attachment.contentUrl, + extractionStatus: attachment.extractionStatus, + fileName: attachment.fileName, + id: attachment.attachmentId, + kind: attachment.kind, + mimeType: attachment.mimeType, + })), + ) + const baseConversation = options?.baseConversation ?? conversation const nextConversation: ConversationMessage[] = [ ...baseConversation, { @@ -905,7 +1090,7 @@ export function ChatPage() { parts: messageParts, role: "user" as const, }, - ]; + ] // Contexto do canvas ativo (spec: role user prefixada, cap 20k, não persiste/não aparece na UI) const canvasContextMessage: ConversationMessage | null = @@ -920,53 +1105,72 @@ export function ChatPage() { ], role: "user" as const, } - : null; + : null // Intenção explícita de canvas: instrui o modelo a produzir formato elegível - const canvasIntent = shouldRequestCanvasGuidance(text); + 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; + : null const contextualPrepend: ConversationMessage[] = [ ...(canvasGuidanceMessage ? [canvasGuidanceMessage] : []), ...(canvasContextMessage ? [canvasContextMessage] : []), - ]; - const messagesToSend: ConversationMessage[] = contextualPrepend.length > 0 - ? [...nextConversation.slice(0, -1), ...contextualPrepend, nextConversation.at(-1)!] - : nextConversation; + ] + const messagesToSend: ConversationMessage[] = + contextualPrepend.length > 0 + ? [ + ...nextConversation.slice(0, -1), + ...contextualPrepend, + nextConversation.at(-1)!, + ] + : nextConversation const projectHeaders: Record = activeProjectId ? { [MODELHUB_PROJECT_HEADER]: activeProjectId } - : {}; + : {} + const backstageHeaders: Record = { + ...(activeConversationId + ? { [MODELHUB_CONVERSATION_HEADER]: activeConversationId } + : {}), + [MODELHUB_MESSAGE_HEADER]: assistantMessageId, + } const maxOutputTokens = resolveMaxOutputTokens({ model: selectedModel, modelId: selectedModelId, providerId: selectedProvider.id, - }); + }) const requestPayload = { id: crypto.randomUUID(), max_tokens: maxOutputTokens, messages: nextConversation, modelId: selectedProvider.hasModels ? selectedModelId : undefined, trigger: "submit-message", - }; - + } - const estimatedPayloadBytes = estimateSerializedPayloadBytes(requestPayload); + const estimatedPayloadBytes = estimateSerializedPayloadBytes(requestPayload) if (estimatedPayloadBytes > MAX_SERIALIZED_CHAT_REQUEST_BYTES) { toast.error( `Mensagem muito grande para o runtime serverless. Reduza texto/anexos para ficar abaixo de ${formatBytes(MAX_SERIALIZED_CHAT_REQUEST_BYTES)} por request.`, - ); - return; + ) + return } - setConversation(nextConversation); + setConversation(nextConversation) setMessages((current) => [ - ...(options?.baseConversation ? current.slice(0, options.baseConversation.length) : current), - { content: text, createdAt: new Date().toISOString(), id: userMessageId, parts: messageParts, role: "user", toolCalls: [] }, + ...(options?.baseConversation + ? current.slice(0, options.baseConversation.length) + : current), + { + content: text, + createdAt: new Date().toISOString(), + id: userMessageId, + parts: messageParts, + role: "user", + toolCalls: [], + }, { content: "", createdAt: new Date().toISOString(), @@ -975,26 +1179,26 @@ export function ChatPage() { role: "assistant", toolCalls: [], }, - ]); + ]) if (!options?.overrideAttachments) { - attachmentsRef.current.forEach(releaseAttachmentPreview); - setInput(""); - setAttachments([]); + attachmentsRef.current.forEach(releaseAttachmentPreview) + setInput("") + setAttachments([]) } - setPending(true); - stickToBottomRef.current = true; + setPending(true) + stickToBottomRef.current = true - const controller = new AbortController(); - abortControllerRef.current = controller; + const controller = new AbortController() + abortControllerRef.current = controller try { - let fullText = ""; - let effectiveModelLabel = assistantModelLabel; + let fullText = "" + let effectiveModelLabel = assistantModelLabel if (browserProviderAdapter) { if (browserProviderAuthState !== "signed-in") { - setBrowserProviderAuthState("loading"); - await browserProviderAdapter.auth.signIn(); - setBrowserProviderAuthState("signed-in"); + setBrowserProviderAuthState("loading") + await browserProviderAdapter.auth.signIn() + setBrowserProviderAuthState("signed-in") } fullText = await browserProviderAdapter.stream({ @@ -1009,133 +1213,161 @@ export function ChatPage() { ? { ...message, content: `${message.content}${delta}` } : message, ), - ); + ) }, - }); - setBrowserProviderAuthState("signed-in"); + }) + setBrowserProviderAuthState("signed-in") } else { - let parsedStream: Awaited> | null = null; - const response = await apiFetch( - selectedProviderId === AUTO_PROVIDER_ID ? "/v1/chat/completions" : `${selectedProvider.base}/api/chat`, - { - body: JSON.stringify( - selectedProviderId === AUTO_PROVIDER_ID - ? { max_tokens: requestPayload.max_tokens, messages: messagesToSend, model: selectedModelId } - : { ...requestPayload, messages: messagesToSend }, - ), - headers: { - "Content-Type": "application/json", - ...projectHeaders, + let parsedStream: Awaited> | null = + null + const response = await apiFetch( + selectedProviderId === AUTO_PROVIDER_ID + ? "/v1/chat/completions" + : `${selectedProvider.base}/api/chat`, + { + body: JSON.stringify( + selectedProviderId === AUTO_PROVIDER_ID + ? { + max_tokens: requestPayload.max_tokens, + messages: messagesToSend, + model: selectedModelId, + } + : { ...requestPayload, messages: messagesToSend }, + ), + headers: { + "Content-Type": "application/json", + ...projectHeaders, + ...backstageHeaders, + }, + method: "POST", + signal: controller.signal, }, - method: "POST", - signal: controller.signal, - }, - ); - - if (!response.ok) { - const errorMessage = await parseApiErrorResponse(response); - const requestError = new Error(errorMessage) as ChatRequestError; - requestError.status = response.status; - requestError.suppressToast = selectedProviderId === "duckai" && response.status === 503; - throw requestError; - } + ) + + if (!response.ok) { + const errorMessage = await parseApiErrorResponse(response) + const requestError = new Error(errorMessage) as ChatRequestError + requestError.status = response.status + requestError.suppressToast = + selectedProviderId === "duckai" && response.status === 503 + throw requestError + } - const { resolvedLabel: resolvedAssistantLabel, fallbackMeta: modelFallbackMeta } = - resolveModelFallbackFromHeaders(response, assistantModelLabel, models, selectedProvider.label); + const { + resolvedLabel: resolvedAssistantLabel, + fallbackMeta: modelFallbackMeta, + } = resolveModelFallbackFromHeaders( + response, + assistantModelLabel, + models, + selectedProvider.label, + ) - effectiveModelLabel = resolvedAssistantLabel ?? assistantModelLabel; + effectiveModelLabel = resolvedAssistantLabel ?? assistantModelLabel - setMessages((current) => - current.map((message) => { - if (message.id !== assistantMessageId) { - return message; - } - const next: ChatMessage = { - ...message, - modelLabel: resolvedAssistantLabel, - }; - if (modelFallbackMeta) { - next.modelFallbackMeta = modelFallbackMeta; - } else { - delete next.modelFallbackMeta; - } - return next; - }), - ); + setMessages((current) => + current.map((message) => { + if (message.id !== assistantMessageId) { + return message + } + const next: ChatMessage = { + ...message, + modelLabel: resolvedAssistantLabel, + } + if (modelFallbackMeta) { + next.modelFallbackMeta = modelFallbackMeta + } else { + delete next.modelFallbackMeta + } + return next + }), + ) - const toolMap = new Map(); - parsedStream = await parseChatStream(response, { - onTextDelta(delta) { + const toolMap = new Map() + parsedStream = await parseChatStream(response, { + onTextDelta(delta) { + setMessages((current) => + current.map((message) => + message.id === assistantMessageId + ? { ...message, content: `${message.content}${delta}` } + : message, + ), + ) + }, + onToolResult(toolCallId, result) { + const existing = toolMap.get(toolCallId) + if (!existing) { + return + } + + const updated: ParsedToolCall = { + ...existing, + result, + status: "completed", + } + toolMap.set(toolCallId, updated) + updateAssistantToolCall(assistantMessageId, updated) + }, + onToolStart(toolCall) { + toolMap.set(toolCall.toolCallId, toolCall) + updateAssistantToolCall(assistantMessageId, toolCall) + }, + }) + + if (!parsedStream) { + throw new Error("Nenhum stream recebido.") + } + fullText = parsedStream.text + + if (parsedStream.finishReason === "length") { setMessages((current) => current.map((message) => message.id === assistantMessageId - ? { ...message, content: `${message.content}${delta}` } + ? { ...message, truncated: true } : message, ), - ); - }, - onToolResult(toolCallId, result) { - const existing = toolMap.get(toolCallId); - if (!existing) { - return; - } - - const updated: ParsedToolCall = { ...existing, result, status: "completed" }; - toolMap.set(toolCallId, updated); - updateAssistantToolCall(assistantMessageId, updated); - }, - onToolStart(toolCall) { - toolMap.set(toolCall.toolCallId, toolCall); - updateAssistantToolCall(assistantMessageId, toolCall); - }, - }); - - if (!parsedStream) { - throw new Error("Nenhum stream recebido."); - } - fullText = parsedStream.text; - - if (parsedStream.finishReason === "length") { - setMessages((current) => - current.map((message) => - message.id === assistantMessageId ? { ...message, truncated: true } : message, - ), - ); - } + ) + } - const errorContent = resolveStreamErrorContent(parsedStream, fullText, selectedProviderId); - if (errorContent) { - setMessages((current) => - current.map((message) => - message.id === assistantMessageId - ? { ...message, content: errorContent, isError: true } - : message, - ), - ); - return; - } + const errorContent = resolveStreamErrorContent( + parsedStream, + fullText, + selectedProviderId, + ) + if (errorContent) { + setMessages((current) => + current.map((message) => + message.id === assistantMessageId + ? { ...message, content: errorContent, isError: true } + : message, + ), + ) + return + } } if (fullText) { // Detecção de canvas (client-side, pós-stream — spec v2) - let assistantParts: HydratedConversationMessagePart[] = [{ text: fullText, type: "text" }]; - let displayContent = fullText; - let convIdEarly = activeConversationId; - let createdConversation = false; + let assistantParts: HydratedConversationMessagePart[] = [ + { text: fullText, type: "text" }, + ] + let displayContent = fullText + let convIdEarly = activeConversationId + let createdConversation = false const suggestion = temporaryChat ? null - : detectCanvas(fullText, { explicitIntent: canvasIntent }); + : detectCanvas(fullText, { explicitIntent: canvasIntent }) if (suggestion) { try { if (!convIdEarly) { convIdEarly = await ensureConversationId( text || currentAttachments[0]?.fileName || suggestion.title, - ); - createdConversation = true; + ) + createdConversation = true } const activeInConversation = - activeCanvas && activeCanvas.conversationId === convIdEarly; + activeCanvas && activeCanvas.conversationId === convIdEarly const canvas = activeInConversation ? await updateCanvas(activeCanvas.id, { content: suggestion.content, @@ -1147,33 +1379,37 @@ export function ChatPage() { kind: suggestion.kind, language: suggestion.language, title: suggestion.title, - }); - setActiveCanvas(canvas); - setCanvasPanelOpen(true); - displayContent = buildDisplayText(fullText, suggestion); + }) + setActiveCanvas(canvas) + setCanvasPanelOpen(true) + displayContent = buildDisplayText(fullText, suggestion) const canvasPart: CanvasReferencePart = { canvasId: canvas.id, kind: canvas.kind, title: canvas.title, type: "canvas", - }; + } assistantParts = displayContent ? [{ text: displayContent, type: "text" }, canvasPart] - : [canvasPart]; + : [canvasPart] setMessages((current) => current.map((message) => message.id === assistantMessageId - ? { ...message, content: displayContent, parts: assistantParts } + ? { + ...message, + content: displayContent, + parts: assistantParts, + } : message, ), - ); + ) } 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); + console.error("[canvas] falha ao criar/atualizar canvas", error) toast.error( `Falha ao abrir o Canvas: ${error instanceof Error ? error.message : "erro desconhecido"}`, - ); + ) } } @@ -1184,128 +1420,154 @@ export function ChatPage() { parts: assistantParts, role: "assistant", }, - ]); + ]) // Persist conversation (skip in temporary chat mode) - if (!temporaryChat) try { - let convId = convIdEarly; - let isNewConversation = createdConversation; - if (!convId) { - convId = await ensureConversationId(text || currentAttachments[0]?.fileName || "Nova conversa"); - isNewConversation = true; - } - const persisted = await persistMessagesForConversation(convId, [ - { parts: messageParts, role: "user" }, - { content: displayContent, modelLabel: effectiveModelLabel, parts: assistantParts, role: "assistant" }, - ]); - const [persistedUserMessage, persistedAssistantMessage] = persisted.messages; - if (persistedUserMessage && persistedAssistantMessage) { - setConversation((current) => - current.map((message) => { - if (message.id === userMessageId) { - return { - id: persistedUserMessage.id, - parts: persistedUserMessage.parts, - role: persistedUserMessage.role, - }; - } + if (!temporaryChat) + try { + let convId = convIdEarly + let isNewConversation = createdConversation + if (!convId) { + convId = await ensureConversationId( + text || currentAttachments[0]?.fileName || "Nova conversa", + ) + isNewConversation = true + } + const persisted = await persistMessagesForConversation(convId, [ + { parts: messageParts, role: "user" }, + { + content: displayContent, + id: assistantMessageId, + modelLabel: effectiveModelLabel, + parts: assistantParts, + role: "assistant", + }, + ]) + const [persistedUserMessage, persistedAssistantMessage] = + persisted.messages + if (persistedUserMessage && persistedAssistantMessage) { + setConversation((current) => + current.map((message) => { + if (message.id === userMessageId) { + return { + id: persistedUserMessage.id, + parts: persistedUserMessage.parts, + role: persistedUserMessage.role, + } + } - if (message.id === assistantMessageId) { - return { - id: persistedAssistantMessage.id, - parts: persistedAssistantMessage.parts, - role: persistedAssistantMessage.role, - }; - } + if (message.id === assistantMessageId) { + return { + id: persistedAssistantMessage.id, + parts: persistedAssistantMessage.parts, + role: persistedAssistantMessage.role, + } + } - return message; - }), - ); - setMessages((current) => - current.map((message) => { - if (message.id === userMessageId) { - return hydrateChatMessage({ message: persistedUserMessage }); - } + return message + }), + ) + setMessages((current) => + current.map((message) => { + if (message.id === userMessageId) { + return hydrateChatMessage({ message: persistedUserMessage }) + } - if (message.id === assistantMessageId) { - return { - ...message, - content: persistedAssistantMessage.content, - id: persistedAssistantMessage.id, - }; - } + if (message.id === assistantMessageId) { + return { + ...message, + content: persistedAssistantMessage.content, + id: persistedAssistantMessage.id, + } + } - return message; - }), - ); - } - setSidebarRefreshKey((k) => k + 1); - - // Generate AI title for new conversations (fire-and-forget) - if ( - isNewConversation && - selectedProvider && - browserProviderAdapter?.titleGeneration !== "unsupported" - ) { - const titleConvId = convId; - void (async () => { - try { - const titleMessages = [ - { - role: "user", - parts: [{ type: "text", text: buildTitleGenerationPrompt(text, fullText) }], - }, - ]; - const titleResponse = await apiFetch( - selectedProviderId === AUTO_PROVIDER_ID ? "/v1/chat/completions" : `${selectedProvider.base}/api/chat`, - { - body: JSON.stringify( - selectedProviderId === AUTO_PROVIDER_ID - ? { messages: titleMessages, model: selectedModelId } - : { - messages: titleMessages, - modelId: selectedProvider.hasModels ? selectedModelId : undefined, - }, - ), - headers: { "Content-Type": "application/json" }, - method: "POST", - }, - ); - if (titleResponse.ok) { - const titleResult = await parseChatStream(titleResponse, {}); - const cleanTitle = titleResult.text.trim().replaceAll(/^["']|["']$/g, "").slice(0, 100); - if (cleanTitle) { - await apiJsonRequest(`/conversations/${titleConvId}`, "PATCH", { title: cleanTitle }); - setSidebarRefreshKey((k) => k + 1); + return message + }), + ) + } + setSidebarRefreshKey((k) => k + 1) + + // Generate AI title for new conversations (fire-and-forget) + if ( + isNewConversation && + selectedProvider && + browserProviderAdapter?.titleGeneration !== "unsupported" + ) { + const titleConvId = convId + void (async () => { + try { + const titleMessages = [ + { + role: "user", + parts: [ + { + type: "text", + text: buildTitleGenerationPrompt(text, fullText), + }, + ], + }, + ] + const titleResponse = await apiFetch( + selectedProviderId === AUTO_PROVIDER_ID + ? "/v1/chat/completions" + : `${selectedProvider.base}/api/chat`, + { + body: JSON.stringify( + selectedProviderId === AUTO_PROVIDER_ID + ? { messages: titleMessages, model: selectedModelId } + : { + messages: titleMessages, + modelId: selectedProvider.hasModels + ? selectedModelId + : undefined, + }, + ), + headers: { "Content-Type": "application/json" }, + method: "POST", + }, + ) + if (titleResponse.ok) { + const titleResult = await parseChatStream(titleResponse, {}) + const cleanTitle = titleResult.text + .trim() + .replaceAll(/^["']|["']$/g, "") + .slice(0, 100) + if (cleanTitle) { + await apiJsonRequest( + `/conversations/${titleConvId}`, + "PATCH", + { title: cleanTitle }, + ) + setSidebarRefreshKey((k) => k + 1) + } } + } catch { + // Title generation failure is non-blocking } - } catch { - // Title generation failure is non-blocking - } - })(); + })() + } + } catch { + // Persistence failure is non-blocking } - } catch { - // Persistence failure is non-blocking - } } } catch (error) { // Don't show error for user-initiated abort if (error instanceof DOMException && error.name === "AbortError") { // Keep whatever text was streamed so far - return; + return } - const requestError = error as ChatRequestError; + const requestError = error as ChatRequestError if (browserProviderAdapter) { - setBrowserProviderAuthState("signed-out"); + setBrowserProviderAuthState("signed-out") } - let errorMsg: string; + let errorMsg: string if (requestError.suppressToast) { - errorMsg = DUCKAI_TEMPORARY_INLINE_MESSAGE; + errorMsg = DUCKAI_TEMPORARY_INLINE_MESSAGE } else if (error instanceof Error) { - errorMsg = `Erro: ${error.message}`; + errorMsg = `Erro: ${error.message}` } else { - errorMsg = "Erro ao enviar mensagem."; + errorMsg = "Erro ao enviar mensagem." } setMessages((current) => current.map((message) => @@ -1313,61 +1575,72 @@ export function ChatPage() { ? { ...message, content: errorMsg, isError: true } : message, ), - ); + ) if (!requestError.suppressToast) { - toast.error(error instanceof Error ? error.message : "Falha ao enviar mensagem."); + toast.error( + error instanceof Error ? error.message : "Falha ao enviar mensagem.", + ) } } finally { - setPending(false); - abortControllerRef.current = null; + setPending(false) + abortControllerRef.current = null } } // Regenerate last assistant response async function handleRegenerate() { - if (pending || !selectedProvider || messages.length < 2) return; - - const lastUserMsgIndex = messages.map((m) => m.role).lastIndexOf("user"); - if (lastUserMsgIndex === -1) return; - - const lastUserMsg = messages[lastUserMsgIndex]; - const regenerateText = getUserMessageText(lastUserMsg); - const regenerateAttachments = (lastUserMsg.parts ?? []).filter(isHydratedAttachmentPart); - const conversationUserIndex = conversation.findIndex((entry) => entry.id === lastUserMsg.id); - const baseConversation = conversationUserIndex >= 0 ? conversation.slice(0, conversationUserIndex) : conversation.slice(0, lastUserMsgIndex); - const lastAssistantMessage = messages.at(-1); + if (pending || !selectedProvider || messages.length < 2) return + + const lastUserMsgIndex = messages.map((m) => m.role).lastIndexOf("user") + if (lastUserMsgIndex === -1) return + + const lastUserMsg = messages[lastUserMsgIndex] + const regenerateText = getUserMessageText(lastUserMsg) + const regenerateAttachments = (lastUserMsg.parts ?? []).filter( + isHydratedAttachmentPart, + ) + const conversationUserIndex = conversation.findIndex( + (entry) => entry.id === lastUserMsg.id, + ) + const baseConversation = + conversationUserIndex >= 0 + ? conversation.slice(0, conversationUserIndex) + : conversation.slice(0, lastUserMsgIndex) + const lastAssistantMessage = messages.at(-1) if (activeConversationId && lastAssistantMessage?.role === "assistant") { try { - await trimConversation(activeConversationId, { fromMessageId: lastAssistantMessage.id }); + await trimConversation(activeConversationId, { + fromMessageId: lastAssistantMessage.id, + }) } catch { - toast.error("Falha ao preparar a conversa para regeneracao."); - return; + toast.error("Falha ao preparar a conversa para regeneracao.") + return } } - setMessages((current) => current.slice(0, lastUserMsgIndex)); - setConversation(baseConversation); + setMessages((current) => current.slice(0, lastUserMsgIndex)) + setConversation(baseConversation) void sendMessage({ baseConversation, overrideAttachments: regenerateAttachments, overrideText: regenerateText, - }); + }) } // Share conversation async function handleShareConversation() { - if (!activeConversationId) return; + if (!activeConversationId) return try { const data = await apiJsonRequest<{ shareToken: string }>( `/conversations/${activeConversationId}/share`, "POST", - ); - const shareUrl = `${globalThis.location.origin}/share/${data.shareToken}`; - await navigator.clipboard.writeText(shareUrl); - toast.success("Link de compartilhamento copiado!"); + ) + const shareUrl = `${globalThis.location.origin}/share/${data.shareToken}` + await navigator.clipboard.writeText(shareUrl) + toast.success("Link de compartilhamento copiado!") } catch { - toast.error("Falha ao gerar link de compartilhamento."); + toast.error("Falha ao gerar link de compartilhamento.") } } @@ -1375,131 +1648,148 @@ export function ChatPage() { // Ú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; + 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 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."); + : 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."); + 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; - void sendMessage({ overrideText: "Continue" }); + if (pending || !selectedProvider) return + void sendMessage({ overrideText: "Continue" }) } // Edit a user message and re-send function handleStartEdit(messageId: string, content: string) { - setEditingMessageId(messageId); - setEditingContent(content); + setEditingMessageId(messageId) + setEditingContent(content) } function handleCancelEdit() { - setEditingMessageId(null); - setEditingContent(""); + setEditingMessageId(null) + setEditingContent("") } async function handleSubmitEdit(messageId: string) { - if (!editingContent.trim() || pending) return; - - const msgIndex = messages.findIndex((m) => m.id === messageId); - if (msgIndex === -1) return; - - const editText = editingContent.trim(); - const editedMessage = messages[msgIndex]; - const editedAttachments = (editedMessage.parts ?? []).filter(isHydratedAttachmentPart); - const convIndex = conversation.findIndex((item) => item.id === messageId); - const baseConversation = convIndex >= 0 ? conversation.slice(0, convIndex) : conversation.slice(0, msgIndex); + if (!editingContent.trim() || pending) return + + const msgIndex = messages.findIndex((m) => m.id === messageId) + if (msgIndex === -1) return + + const editText = editingContent.trim() + const editedMessage = messages[msgIndex] + const editedAttachments = (editedMessage.parts ?? []).filter( + isHydratedAttachmentPart, + ) + const convIndex = conversation.findIndex((item) => item.id === messageId) + const baseConversation = + convIndex >= 0 + ? conversation.slice(0, convIndex) + : conversation.slice(0, msgIndex) if (activeConversationId) { try { - await trimConversation(activeConversationId, { fromMessageId: messageId }); + await trimConversation(activeConversationId, { + fromMessageId: messageId, + }) } catch { - toast.error("Falha ao atualizar a conversa antes da edicao."); - return; + toast.error("Falha ao atualizar a conversa antes da edicao.") + return } } - setMessages((current) => current.slice(0, msgIndex)); - setConversation(baseConversation); + setMessages((current) => current.slice(0, msgIndex)) + setConversation(baseConversation) - setEditingMessageId(null); - setEditingContent(""); + setEditingMessageId(null) + setEditingContent("") void sendMessage({ baseConversation, overrideAttachments: editedAttachments, overrideText: editText, - }); + }) } - // Export conversation function getExportableContent(m: ChatMessage): string { return m.role === "user" && m.parts?.length ? createMessageContentFallback(m.parts) - : m.content; + : m.content } function handleExportMarkdown() { - if (messages.length === 0) return; + if (messages.length === 0) return const mdContent = conversationToMarkdown( null, messages.map((m) => ({ content: getExportableContent(m), role: m.role })), - ); - - downloadTextFile(buildExportFilename(null, "md"), "text/markdown", mdContent); - toast.success("Conversa exportada como Markdown."); + ) + + downloadTextFile( + buildExportFilename(null, "md"), + "text/markdown", + mdContent, + ) + toast.success("Conversa exportada como Markdown.") } function handleExportJson() { - if (messages.length === 0) return; + if (messages.length === 0) return const jsonContent = conversationToJson( null, @@ -1510,871 +1800,1150 @@ export function ChatPage() { modelLabel: m.modelLabel ?? null, role: m.role, })), - ); - - downloadTextFile(buildExportFilename(null, "json"), "application/json", jsonContent); - toast.success("Conversa exportada como JSON."); + ) + + downloadTextFile( + buildExportFilename(null, "json"), + "application/json", + jsonContent, + ) + toast.success("Conversa exportada como JSON.") } return (
- {/* Toolbar: provider/modelo rolam horizontalmente; as ações ficam fixas à direita */} -
-
- + + + + - Ambientes OpenClaw - {openclawProviders.map((provider) => ( - - {provider.label} - - ))} + Roteamento + + + ModelHub + + + - )} - {openclawProviders.length > 0 ? : null} - {configuredProvidersWithApiKey.length > 0 && ( - - Configurados - {configuredProvidersWithApiKey.map((provider) => ( - - + + {openclawProviders.length > 0 && ( + + Ambientes OpenClaw + {openclawProviders.map((provider) => ( + {provider.label} - - - - ))} - - )} - {configuredProvidersWithApiKey.length > 0 ? : null} - {providersWithoutApiKey.length > 0 && ( - - Sem chave de API - {providersWithoutApiKey.map((provider) => ( - - {provider.label} - - ))} - - )} - {providersWithoutApiKey.length > 0 ? : null} - {providersWithBrowserSession.length > 0 && ( - - Sessao do navegador - {providersWithBrowserSession.map((provider) => ( - - {provider.label} - - ))} - - )} - {providersWithBrowserSession.length > 0 ? : null} - {unconfiguredProvidersWithApiKey.length > 0 ? : null} - {unconfiguredProvidersWithApiKey.length > 0 && ( - - Com chave de API - {unconfiguredProvidersWithApiKey.map((provider) => { - const check = showConfiguredCheck(provider); - return ( + + ))} + + )} + {openclawProviders.length > 0 ? : null} + {configuredProvidersWithApiKey.length > 0 && ( + + Configurados + {configuredProvidersWithApiKey.map((provider) => ( {provider.label} - {check ? ( - - ) : null} + - ); - })} - - )} - - - - {loadingModels ? ( - - ) : ( - - )} - - - - - {browserProviderAdapter ? ( - browserProviderAuthState === "loading" - ? "Conectando..." - : browserProviderAuthState === "signed-in" - ? "Sessao conectada" - : "Login necessario" - ) : selectedProviderReady ? ( - "Conectado" - ) : ( - <> - Pendente - Credenciais pendentes - - )} - - - - {browserProviderAdapter - ? "Este provider usa uma sessao autenticada no navegador. O envio abre o login se necessario." - : selectedProviderReady - ? "Provider configurado e pronto para uso." - : "Este provider ainda precisa de credenciais antes de enviar mensagens."} - - -
- - {/* Ações sempre visíveis (fora do scroll) — resolvem a sobreposição no mobile */} -
- - - - - Nova conversa - - - - - - -
- {providerUsesStoredCredentials(selectedProvider) && !selectedProviderReady ? ( - setCredentialDialogOpen(true)}> - - Configurar credenciais - - ) : null} + {/* Ações sempre visíveis (fora do scroll) — resolvem a sobreposição no mobile */} +
+ + + + + Nova conversa + + + + + + + + + + {browserProviderAdapter ? ( + void handleBrowserProviderSignIn()} + > + {browserProviderAuthState === "loading" ? ( + + ) : ( + + )} + {browserProviderAuthState === "signed-in" + ? "Sessão conectada" + : "Entrar no provider"} + + ) : null} + + {providerUsesStoredCredentials(selectedProvider) && + !selectedProviderReady ? ( + setCredentialDialogOpen(true)} + > + + Configurar credenciais + + ) : null} + + {browserProviderAdapter || + (providerUsesStoredCredentials(selectedProvider) && + !selectedProviderReady) ? ( + + ) : null} - {browserProviderAdapter || - (providerUsesStoredCredentials(selectedProvider) && !selectedProviderReady) ? ( - - ) : null} + setTemporaryChat(!!checked)} + > + Chat temporário + - setTemporaryChat(!!checked)} - > - Chat temporário - - - setSettingsOpen(true)}> - - Personalizar - - - {activeConversationId ? ( - void handleShareConversation()}> - - Compartilhar conversa + setSettingsOpen(true)}> + + Personalizar - ) : null} - {messages.length > 0 ? ( - <> - - - Exportar (Markdown) - - - - Exportar (JSON) + {activeConversationId ? ( + void handleShareConversation()} + > + + Compartilhar conversa - - ) : null} - - -
-
+ ) : null} - {/* Banner de chat temporário */} - {temporaryChat && ( -
- - Chat temporário — as mensagens não serão salvas no histórico -
- )} - - {/* Alerta de credenciais pendentes */} - {!selectedProviderReady && selectedProvider && providerUsesStoredCredentials(selectedProvider) ? ( -
- - - {selectedProvider.label} exige credenciais - - Salve as chaves necessárias antes de carregar modelos ou enviar mensagens. - - + {messages.length > 0 ? ( + <> + + + Exportar (Markdown) + + + + Exportar (JSON) + + + ) : null} + + +
- ) : null} - {/* Área de mensagens (scroll nativo para evitar bugs de thumb em produção) */} -
-
- {messages.length === 0 ? ( -
- - - - - - Comece uma nova conversa - - Escolha um provider e um modelo, ou use uma sugestão abaixo para reduzir a barreira de entrada. - - - -
- {EMPTY_STATE_PROMPTS.map((prompt) => ( - - ))} -
-
-
-
- ) : ( - messages.map((message, messageIndex) => ( -
+ {/* Banner de chat temporário */} + {temporaryChat && ( +
+ + Chat temporário — as mensagens não serão salvas no histórico +
+ )} + + {/* Alerta de credenciais pendentes */} + {!selectedProviderReady && + selectedProvider && + providerUsesStoredCredentials(selectedProvider) ? ( +
+ + + + {selectedProvider.label} exige credenciais + + + Salve as chaves necessárias antes de carregar modelos ou enviar + mensagens. + + +
+ ) : null} + + {/* Área de mensagens (scroll nativo para evitar bugs de thumb em produção) */} +
+
+ {messages.length === 0 ? ( +
+ + + + + + Comece uma nova conversa + + Escolha um provider e um modelo, ou use uma sugestão + abaixo para reduzir a barreira de entrada. + + + +
+ {EMPTY_STATE_PROMPTS.map((prompt) => ( + + ))} +
+
+
+
+ ) : ( + messages.map((message, messageIndex) => (
- {message.role === "user" ? : } -
-
- {message.role === "assistant" && message.modelFallbackMeta ? ( - - Modelo diferente do que você selecionou - - A API não aceitou{" "} - - {message.modelFallbackMeta.requestedLabel} - {" "} - nesta requisição (por exemplo{" "} - model_not_found ou - sem acesso). O backend tentou, nesta ordem:{" "} - - {message.modelFallbackMeta.attemptedIds.join(" → ")} - - . O texto abaixo foi gerado por{" "} - - {message.modelFallbackMeta.effectiveLabel} - - , não pelo modelo escolhido no seletor. - - - ) : null}
- {/* Editing mode */} - {editingMessageId === message.id ? ( -
-