mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-08 18:57:47 +08:00
Chats: - New Chat right after an answer saves that chat once. Saves run one at a time and read the chat on screen when their turn comes; a save scheduled for a chat that is no longer on screen is dropped. A chat whose id was still on its way to the URL no longer comes back after New Chat (the next answer went into it). - Crossing the 768 px breakpoint keeps the chat panel: a streaming answer, unsaved messages and attachments stay. The panel gets the sizes of each side, and a panel collapsed on desktop opens on mobile. - The chat's export waits for its own reply: an edit's history export still on its way no longer answers it with the older diagram, and two file saves at once no longer swap results. - A second edit in one answer is previewed on the first edit's result. - Stop also ends a running screenshot check; a chat that cannot be saved (storage full) can be left with "Continue without saving". - Small diagrams with shapes count as diagrams; the tool card no longer crashes on malformed operations. Quota and providers: - Requests that reach the server's own endpoints count toward the quota: EdgeOne (always its own endpoint now), a private base URL whatever key header is sent, keyless Ollama without a URL. With the quota on, a redirect is followed only to a public address. The output cap applies to these requests too. - Stop records the tokens of the steps that finished; the screenshot check counts its tokens without counting a request. - EdgeOne configured only by AI_PROVIDER works, also in the admin Test, which forwards the access code. Azure set up only in the admin panel works in chat. The Test sends a Bedrock session token. - The admin panel's Test of an entry without a URL uses the server's URL as the server does (no private address check for it); the admin panel no longer writes an Ollama URL. MCP server: - Write tools and start_session run one at a time, so two at once never drop each other's change; a cancelled call waiting its turn is skipped. get_diagram and export_diagram keep the session they started with. - Export to .drawio first gets the user's latest edits from the browser. - History thumbnails: one that arrives after the next AI write is dropped; a sync reply keeps the image; a version that changed only page settings is its own entry. - A diagram over the 10 MB limit is saved without its image, or the user is told to download it (the server now answers 413 instead of cutting the connection). - Labels holding text like id='1' or parent='1' are no longer read as attributes (a layer or a parent was deleted). A broken bare <mxGraphModel> file is refused. - After a sync reply the tab no longer sends its autosave copy again. Desktop and files: - A newer switch of the same preset is not rolled back by an older one that failed. .env values with escaped quotes are read whole. - MCP saved files: a file that could not be read stays protected while a folder without permission hides it, and is saved again once deleted. - The desktop app reports "no chats" only when the count was read and no model settings are stored.
268 lines
12 KiB
TypeScript
268 lines
12 KiB
TypeScript
"use client"
|
|
|
|
import { Check, ChevronDown, ChevronUp, Copy, Cpu } from "lucide-react"
|
|
import type { Dispatch, SetStateAction } from "react"
|
|
import { CodeBlock } from "@/components/code-block"
|
|
import { isMxCellXmlComplete } from "@/lib/utils"
|
|
import type { DiagramOperation, ToolPartLike } from "./types"
|
|
|
|
interface ToolCallCardProps {
|
|
part: ToolPartLike
|
|
expandedTools: Record<string, boolean>
|
|
setExpandedTools: Dispatch<SetStateAction<Record<string, boolean>>>
|
|
onCopy: (callId: string, text: string, isToolCall: boolean) => void
|
|
copiedToolCallId: string | null
|
|
copyFailedToolCallId: string | null
|
|
dict: {
|
|
tools: { complete: string }
|
|
chat: { copied: string; failedToCopy: string; copyResponse: string }
|
|
}
|
|
}
|
|
|
|
function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
|
|
// Streamed or invalid input can hold anything: show only what React can
|
|
// render (an object in place of a string would crash the whole chat)
|
|
const shown = operations.filter(
|
|
(op) => typeof (op as { operation?: unknown })?.operation === "string",
|
|
)
|
|
const text = (value: unknown) => (typeof value === "string" ? value : "")
|
|
return (
|
|
<div className="space-y-3">
|
|
{shown.map((op, index) => (
|
|
<div
|
|
key={`${op.operation}-${op.cell_id}-${index}`}
|
|
className="rounded-lg border border-border/50 overflow-hidden bg-background/50"
|
|
>
|
|
<div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2">
|
|
<span
|
|
className={`text-[10px] font-medium uppercase tracking-wide ${
|
|
op.operation === "delete"
|
|
? "text-red-600"
|
|
: op.operation === "add"
|
|
? "text-green-600"
|
|
: "text-blue-600"
|
|
}`}
|
|
>
|
|
{op.operation}
|
|
</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
cell_id: {text(op.cell_id)}
|
|
</span>
|
|
</div>
|
|
{text(op.new_xml) && (
|
|
<div className="px-3 py-2">
|
|
<pre className="text-[11px] font-mono text-foreground/80 bg-muted/30 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all">
|
|
{text(op.new_xml)}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function ToolCallCard({
|
|
part,
|
|
expandedTools,
|
|
setExpandedTools,
|
|
onCopy,
|
|
copiedToolCallId,
|
|
copyFailedToolCallId,
|
|
dict,
|
|
}: ToolCallCardProps) {
|
|
const callId = part.toolCallId
|
|
const { state, input, output, errorText } = part
|
|
// Default to expanded for all states (user can manually collapse if needed)
|
|
const isExpanded = expandedTools[callId] ?? true
|
|
const toolName = part.type?.replace("tool-", "")
|
|
const isCopied = copiedToolCallId === callId
|
|
|
|
const toggleExpanded = () => {
|
|
setExpandedTools((prev) => ({
|
|
...prev,
|
|
[callId]: !isExpanded,
|
|
}))
|
|
}
|
|
|
|
const getToolDisplayName = (name: string) => {
|
|
switch (name) {
|
|
case "display_diagram":
|
|
return "Generate Diagram"
|
|
case "edit_diagram":
|
|
return "Edit Diagram"
|
|
case "get_shape_library":
|
|
return "Get Shape Library"
|
|
default:
|
|
return name
|
|
}
|
|
}
|
|
|
|
// Incomplete XML means the output hit the length limit, unless the user
|
|
// stopped the generation themselves. Without an input the JSON was
|
|
// broken (the server repairs JSON cut short by the limit).
|
|
const isTruncated =
|
|
state === "output-error" &&
|
|
errorText !== "Stopped by user" &&
|
|
(toolName === "display_diagram" || toolName === "append_diagram") &&
|
|
typeof input?.xml === "string" &&
|
|
!isMxCellXmlComplete(input.xml)
|
|
|
|
const handleCopy = () => {
|
|
let textToCopy = ""
|
|
|
|
if (input && typeof input === "object") {
|
|
if (input.xml) {
|
|
textToCopy = input.xml
|
|
} else if (input.operations && Array.isArray(input.operations)) {
|
|
textToCopy = JSON.stringify(input.operations, null, 2)
|
|
} else if (Object.keys(input).length > 0) {
|
|
textToCopy = JSON.stringify(input, null, 2)
|
|
}
|
|
}
|
|
|
|
if (
|
|
output &&
|
|
toolName === "get_shape_library" &&
|
|
typeof output === "string"
|
|
) {
|
|
textToCopy = output
|
|
}
|
|
|
|
if (textToCopy) {
|
|
onCopy(callId, textToCopy, true)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="my-3 rounded-xl border border-border/60 bg-muted/30 overflow-hidden">
|
|
<div className="flex items-center justify-between px-4 py-3 bg-muted/50">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-6 h-6 rounded-md bg-primary/10 flex items-center justify-center">
|
|
<Cpu className="w-3.5 h-3.5 text-primary" />
|
|
</div>
|
|
<span className="text-sm font-medium text-foreground/80">
|
|
{getToolDisplayName(toolName)}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{state === "input-streaming" && (
|
|
<div className="h-4 w-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
|
)}
|
|
{state === "output-available" && (
|
|
<>
|
|
<span className="text-xs font-medium text-green-600 bg-green-50 px-2 py-0.5 rounded-full">
|
|
{dict.tools.complete}
|
|
</span>
|
|
{isExpanded && (
|
|
<button
|
|
type="button"
|
|
onClick={handleCopy}
|
|
className="p-1 rounded hover:bg-muted transition-colors"
|
|
title={
|
|
copiedToolCallId === callId
|
|
? dict.chat.copied
|
|
: copyFailedToolCallId === callId
|
|
? dict.chat.failedToCopy
|
|
: dict.chat.copyResponse
|
|
}
|
|
>
|
|
{isCopied ? (
|
|
<Check className="w-4 h-4 text-green-600" />
|
|
) : (
|
|
<Copy className="w-4 h-4 text-muted-foreground" />
|
|
)}
|
|
</button>
|
|
)}
|
|
</>
|
|
)}
|
|
{state === "output-error" &&
|
|
(isTruncated ? (
|
|
<span className="text-xs font-medium text-yellow-600 bg-yellow-50 px-2 py-0.5 rounded-full">
|
|
Truncated
|
|
</span>
|
|
) : (
|
|
<span className="text-xs font-medium text-red-600 bg-red-50 px-2 py-0.5 rounded-full">
|
|
Error
|
|
</span>
|
|
))}
|
|
{input && Object.keys(input).length > 0 && (
|
|
<button
|
|
type="button"
|
|
onClick={toggleExpanded}
|
|
className="p-1 rounded hover:bg-muted transition-colors"
|
|
>
|
|
{isExpanded ? (
|
|
<ChevronUp className="w-4 h-4 text-muted-foreground" />
|
|
) : (
|
|
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
|
)}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{input && isExpanded && (
|
|
<div className="px-4 py-3 border-t border-border/40 bg-muted/20">
|
|
{typeof input === "object" && input.xml ? (
|
|
state === "input-streaming" ||
|
|
state === "input-available" ? (
|
|
<pre
|
|
className="text-[11px] leading-relaxed overflow-x-auto overflow-y-auto max-h-48 scrollbar-thin break-all whitespace-pre-wrap"
|
|
style={{
|
|
fontFamily:
|
|
"var(--font-mono), ui-monospace, monospace",
|
|
margin: 0,
|
|
padding: 0,
|
|
}}
|
|
>
|
|
{input.xml}
|
|
</pre>
|
|
) : (
|
|
<CodeBlock code={input.xml} language="xml" />
|
|
)
|
|
) : typeof input === "object" &&
|
|
input.operations &&
|
|
Array.isArray(input.operations) ? (
|
|
<OperationsDisplay operations={input.operations} />
|
|
) : typeof input === "object" &&
|
|
Object.keys(input).length > 0 ? (
|
|
<CodeBlock
|
|
code={JSON.stringify(input, null, 2)}
|
|
language="json"
|
|
/>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
{/* AI SDK stores tool errors in errorText */}
|
|
{state === "output-error" && (errorText || output) && (
|
|
<div
|
|
className={`px-4 py-3 border-t border-border/40 text-sm whitespace-pre-wrap break-words ${isTruncated ? "text-yellow-600" : "text-red-600"}`}
|
|
>
|
|
{isTruncated
|
|
? "Output truncated due to length limits. Try a simpler request or increase Max Output Tokens in Settings."
|
|
: (errorText ?? output)}
|
|
</div>
|
|
)}
|
|
{/* Show get_shape_library output on success */}
|
|
{output &&
|
|
toolName === "get_shape_library" &&
|
|
state === "output-available" &&
|
|
isExpanded && (
|
|
<div className="px-4 py-3 border-t border-border/40">
|
|
<div className="text-xs text-muted-foreground mb-2">
|
|
Library loaded (
|
|
{typeof output === "string" ? output.length : 0}{" "}
|
|
chars)
|
|
</div>
|
|
<pre className="text-xs bg-muted/50 p-2 rounded-md overflow-auto max-h-32 whitespace-pre-wrap">
|
|
{typeof output === "string"
|
|
? output.substring(0, 800) +
|
|
(output.length > 800 ? "\n..." : "")
|
|
: String(output)}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|