Compare commits

..
Author SHA1 Message Date
dayuan.jiang d87c3c0bab Merge origin/main (#984, the canvas shell) into the cleanup branch
Conflicts resolved in 16 files. Where main's new shell, classic page and
tests depend on a wire format or a signature the cleanup had changed, main
wins; where main only extended code the cleanup had restructured, main's
change is carried over into the new structure:

- MCP HTTP server: the poll response keeps main's separate export fields
  (exportFormat, exportXml, exportOptions, exportId with a random id)
  instead of the cleanup's pendingExport object; the classic preview page
  is main's version
- MCP index: start_session keeps the cleanup's structure and gains main's
  BROWSER=none, previewUrl and same-origin note; get_selection is written
  in the registerTool style; create_new_diagram keeps the cleanup's
  helpers with main's comment
- history: addHistory takes main's source parameter and returns the index
  again (main's signature)
- new-diagram: the cleanup's prepareNewModel split plus main's page ids
  for diagrams without one
- diagram context: one ExportResult type (main's, with optional fields)
  for both the tagged exports and main's requestExport; the cleanup's
  single export resolver stays
- versions store and version-text test: main's VersionItem base type and
  describeChangeSummary rename, without the legacy history conversion the
  cleanup removed
- dictionaries: main's shell section added; openFileHint stays removed
  (unused on main too)
- tests: the server wiring test keeps the SDK client and gains main's
  session cases; the HTTP server test uses main's export fields; the DOM
  polyfill comes from tests/setup.ts
- package.json: main's postcss kept, the cleanup's removals kept; lockfile
  regenerated
2026-10-12 11:59:51 +09:00
dayuan.jiang 1333ed68c6 test: shared e2e helpers, vi.stubEnv, drop placeholder specs
- tests/e2e/lib: openApp(page, { path, config }), MODEL_CONFIGS_KEY,
  savedModelConfigs, settingsDialog, chunkedReplies, sse() and
  toolCallEvents() replace about 70 copies of goto + wait for iframe,
  three local openApp functions, 14 window.fetch overrides and the
  Node-side SSE serialisers; unused fixtures removed
- delete theme, save, history, model-config, upload and copy-paste
  specs (the copy-button test moves to chat.spec.ts); trim
  iframe-interaction, history-restore, keyboard and smoke to tests that
  can fail; file-upload asserts on the preview image
- settings-review-fixes.spec.ts split by topic into settings-bedrock,
  settings-tabs-sync and settings-providers
- unit tests use vi.stubEnv with unstubEnvs: true instead of
  hand-written process.env save and restore loops
2026-10-12 09:11:25 +09:00
dayuan.jiang 9a6a808187 chore: refresh package-lock.json and tidy leftovers from the merge
- package-lock.json regenerated for the removed dependencies, dotenv and
  the devDependency move of @opennextjs/cloudflare
- lib/admin: SettingType and getSettingsPath are file-internal
- components/chat/chat-engine.tsx: comment no longer mentions click counts
2026-10-12 08:30:43 +09:00
dayuan.jiang fb87291cde refactor(settings): remove wrapper layers, dead props and unused dictionary keys
- components/model-selector.tsx uses Dialog, Command and Tooltip
  directly; delete components/ai-elements/model-selector.tsx and
  components/button-with-tooltip.tsx; one renderGroups for both lists;
  no "Server · " prefix round trip
- components/ui: drop CommandDialog, CommandShortcut and six unused
  shadcn exports
- model-config-dialog: no type-to-confirm on provider delete;
  provider-credentials-fields: drop footer and bedrockApiKey
- hooks/use-model-config.ts and lib/types/model-config.ts: remove the
  2025-12 single-provider migration, resetConfig, selectedModel and
  fields nothing reads
- components/chat/TemplateFormDialog.tsx replaces the create and edit
  dialogs; TemplatePanel drops the setInput fallback, the
  templatesChanged listener, dictionary fallbacks and formatLastUsed;
  lib/template-storage.ts drops getTemplate, clickCount and indexes
- reasoning.tsx becomes one component; shimmer uses a CSS keyframe
  (motion and @radix-ui/react-use-controllable-state removed)
- error-toast.tsx deleted in favour of toast.error; ValidationCard
  drops the idle status; dev-xml-simulator uses English literals
- admin: no enum setting type, shared types from lib/admin
  (AdminProviderInput stays exported for that), ENABLE_HISTORY_XML_REPLACE
  entry removed
- app/globals.css: drop check-pop, --ease-spring, --duration-normal
  and the shadow aliases
- dictionaries: 81 unused keys removed from all four languages
2026-10-12 08:28:39 +09:00
dayuan.jiang 14f29af939 refactor(prompt): share the Styles paragraphs with the MCP drawing guide
lib/system-prompts.ts takes the three Styles paragraphs from
styleRules() in packages/mcp-server/src/xml-examples.ts; the generated
prompt is byte-identical. packages/mcp-server/tests/load-diagram.test.ts
drops its own DOM polyfill setup now that vitest installs it once.
2026-10-12 08:27:28 +09:00
dayuan.jiang fc8ffc5655 refactor(mcp-server): remove dead code and fold repeated tool plumbing
- index.ts: registerTool() does the session check, try/catch and the
  write queue for all 15 tools; fail()/ok(), syncFromBrowser(),
  commitDocument() and describePages() replace the copied blocks; drop
  pickPageSelector, currentSession.version, the input ?? {} defaults;
  add_page reuses prepareNewModel() split out of prepareNewDiagram
- xml-validation.ts: drop the hand-written syntax checks that saxes
  already reports, MAX_XML_SIZE and the stale header
- http-server.ts: one pendingExport object, no unread response fields,
  no CORS/OPTIONS branch for same-origin requests, fewer exports
- diagram-operations parseNewCell(), addHistory returns void,
  normalizeToMxfile without host, foldCells without layer, expandStyles
  removed, unused preview.css rule removed, no .d.ts output
- xml-examples.ts styleRules() shares the Styles paragraphs with the
  web prompt
- tests: setupFiles installs the DOM polyfill once, server-wiring uses
  the SDK client, mcp-core.test.ts drops the cases MCP tests already
  cover
2026-10-12 08:25:47 +09:00
dayuan.jiang 165175be20 chore: update comments that mentioned the removed preset system and useObject 2026-10-12 08:18:02 +09:00
dayuan.jiang 06621764b2 refactor(chat): drop redesign leftovers and old-format migrations
- stores/versions-store.ts, hooks/use-session-manager.ts,
  lib/session-storage.ts: remove the diagramHistory conversion layer,
  the localStorage chat migration and the empty templates store
- stores/canvas-store.ts, lib/drawio/editor-bridge.ts: remove the
  pages list nothing read
- components/chat/chat-engine.tsx: use the context's chartXMLRef and
  latestSvgRef instead of local copies, subscribe to one settings field,
  stop prefetching quota limits (lib/use-quota-manager.tsx takes the
  values from the 429 body), readDiagramFile delegates to the MCP
  loader (which now strips XML prologs and accepts <svg:svg>)
- contexts/diagram-context.tsx: one requestTaggedExport path for
  thumbnails, validation, fetch and save; fetchDiagramXml replaces
  handleExport plus the exported resolver map
- hooks/use-validate-diagram.ts and app/api/validate-diagram: plain
  fetch returning JSON instead of a streamed object nobody consumed
- hooks/use-diagram-tool-handlers.ts: drop the DEBUG blocks; canvas
  vars inheritance lives in placeOnPage (lib/diagram-pages.ts)
- lobby-hero, icon-button, drawio-frame: remove unreachable branches
  and unused props
2026-10-12 08:17:11 +09:00
dayuan.jiang 3a9168e5b3 refactor(pages): one about page per language, no R2 cache by default
- app/[lang]/about/page.tsx renders en, zh and ja (zh-Hant falls back
  to zh) from one JSX tree with per-language texts and generateMetadata;
  delete the cn/ and ja/ copies and redirect their old URLs
- app/[lang]/layout.tsx: use hasLocale, an OG locale table, and real
  zh/ja title and description
- components/icons/github.tsx replaces react-icons and lucide's
  deprecated Github icon
- quota-limit-toast: read the dictionary keys directly, shadow-float
- Cloudflare: defineCloudflareConfig({}) without R2, no r2_buckets, no
  dev-time OpenNext init, cf-typegen script removed,
  @opennextjs/cloudflare moves to devDependencies, docs updated
2026-10-12 08:16:49 +09:00
dayuan.jiang 8ca2d03e7f refactor(server): drop stale prompt text, duplicated routes and dead options
- lib/system-prompts.ts: delete the extended prompt kept only to reach
  the 4096-token cache threshold of two old Claude models; describe the
  current canvas-first UI in the App Features section
- app/api/chat/route.ts: remove the server-side example cache (the
  browser answers examples itself), the ENABLE_HISTORY_XML_REPLACE
  option and its helper, the unread messageMetadata fields; the
  DEBUG_LLM_PAYLOAD blocks become one describeMessages summary
- app/api/log-feedback and log-save share scoreLatestTrace in
  lib/langfuse.ts
- app/api/admin/test-model and validate-model call a shared
  testModelConnection() instead of one route faking a request to the
  other and passing trust through the body
- lib/dynamo-quota-manager.ts reads the failing item from the
  conditional update instead of a second GetItem
- lib/ai-providers.ts: geminiThinkingOptions helper for the google and
  vertexai cases, drop the unused fourth resolveBaseURL parameter
- edge-functions: always stream, shrink messageSchema, drop model
  aliases nothing sends
- delete app/api/chat/xml_guide.md, parseOutputTokenLimit, unused type
  exports and export keywords on file-internal symbols
- env.example: drop variables nothing reads, document the quota,
  validation and analytics variables, mention the apiKeyEnv array form
  (also in docs/*/ai-providers.md)
2026-10-12 08:15:15 +09:00
dayuan.jiang 610ba6872d refactor(electron): remove the configuration-preset system and read .env with dotenv
- delete electron/settings, the settings preload and window, the
  config-presets IPC channels, the Configuration menu and the preset
  watcher in scripts/electron-dev.mjs; config-manager.ts keeps only the
  user locale (still read from config-presets.json so the saved
  language survives)
- electron/main/env-loader.ts parses .env with dotenv instead of a
  hand-written parser; electron:compile bundles dependencies
- drop the unreachable development branches in port-manager.ts and
  next-server.ts, the duplicated locale normalisation in menu-i18n.ts,
  the unused electronAPI members, resources/entitlements.mac.plist
- scripts/copy-dereferenced.cjs shared by afterPack and
  prepare-electron-build
- CI: delete ci.yml, move type check and concurrency into test.yml,
  Node 24 everywhere, docker-build only on main
- remove unused dependencies (@xmldom/xmldom, remark-gfm,
  @anthropic-ai/tokenizer, @testing-library/user-event, concurrently,
  wait-on, eslint, eslint-config-next, @vitest/coverage-v8, shx),
  .eslintrc.json and the vitest coverage block
2026-10-12 08:11:10 +09:00
dayuan.jiang a3664367ce refactor(lib): reuse MCP helpers for streaming preview and SVG extraction
- lib/utils.ts: remove convertToLegalXml and replaceNodes; the streaming
  preview now goes through wrapCellsInModel, autoFixXml and
  replacePageModel like the final commit path does
- lib/utils.ts: extractDiagramXML reuses extractSvgEmbeddedXml,
  pageElement and decompressPageContent instead of hand-written SVG
  parsing and pako inflation
- lib/use-file-processor.tsx: messages parameter is required, drop the
  English default texts only tests used
- hooks/use-dictionary.ts, lib/i18n/utils.ts: drop duplicate default
  exports nobody imported
- tests updated accordingly
2026-10-12 08:05:13 +09:00
16 changed files with 439 additions and 578 deletions
+187 -51
View File
@@ -2,14 +2,12 @@ import {
APICallError, APICallError,
convertToModelMessages, convertToModelMessages,
InvalidToolInputError, InvalidToolInputError,
isFileUIPart,
isTextUIPart,
pruneMessages,
safeValidateUIMessages,
stepCountIs, stepCountIs,
streamText, streamText,
} from "ai" } from "ai"
import { jsonrepair } from "jsonrepair" import { jsonrepair } from "jsonrepair"
import path from "path"
import { z } from "zod"
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code" import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
import { import {
CACHE_POINT, CACHE_POINT,
@@ -27,8 +25,6 @@ import {
formatSelectionContext, formatSelectionContext,
validateFileParts, validateFileParts,
} from "@/lib/chat-helpers" } from "@/lib/chat-helpers"
import type { ChatUIMessage } from "@/lib/chat-message"
import { chatTools } from "@/lib/chat-tools"
import { withDeprecatedParamsFallback } from "@/lib/deprecated-params" import { withDeprecatedParamsFallback } from "@/lib/deprecated-params"
import { import {
checkAndIncrementRequest, checkAndIncrementRequest,
@@ -56,6 +52,14 @@ import { getSystemPrompt } from "@/lib/system-prompts"
import { normalizeBaseUrl } from "@/lib/types/model-config" import { normalizeBaseUrl } from "@/lib/types/model-config"
import { getUserIdFromRequest } from "@/lib/user-id" import { getUserIdFromRequest } from "@/lib/user-id"
import { foldCells } from "@/packages/mcp-server/src/compact-cells.ts" import { foldCells } from "@/packages/mcp-server/src/compact-cells.ts"
import {
getShapeLibrary,
SHAPE_LIBRARY_LIST,
} from "@/packages/mcp-server/src/shape-library.ts"
import {
STYLE_CLASS_EXAMPLE,
SWIMLANE_EXAMPLE,
} from "@/packages/mcp-server/src/xml-examples.ts"
// No explicit cap: a reasoning model can spend minutes planning before it emits // No explicit cap: a reasoning model can spend minutes planning before it emits
// the tool call, so take whatever the host allows. Vercel's own default is 300s, // the tool call, so take whatever the host allows. Vercel's own default is 300s,
@@ -95,19 +99,7 @@ async function handleChatRequest(req: Request): Promise<Response> {
if (accessDenied) return accessDenied if (accessDenied) return accessDenied
const body = await req.json() const body = await req.json()
// The messages in the SDK's UI shape. The route writes its own system const { messages, xml, previousXml, sessionId } = body
// messages, so none may come from the client.
const validated = await safeValidateUIMessages<ChatUIMessage>({
messages: body.messages,
})
if (!validated.success) {
return Response.json(
{ error: validated.error.message },
{ status: 400 },
)
}
const messages = validated.data.filter((m) => m.role !== "system")
const { xml, previousXml, sessionId } = body
const customSystemMessage = const customSystemMessage =
typeof body.customSystemMessage === "string" typeof body.customSystemMessage === "string"
? body.customSystemMessage.slice(0, 5000) ? body.customSystemMessage.slice(0, 5000)
@@ -126,8 +118,9 @@ async function handleChatRequest(req: Request): Promise<Response> {
// Find the last USER message, not just the last message (which could be assistant in multi-step tool flows) // Find the last USER message, not just the last message (which could be assistant in multi-step tool flows)
const lastUserMessage = [...messages] const lastUserMessage = [...messages]
.reverse() .reverse()
.find((m) => m.role === "user") .find((m: any) => m.role === "user")
const userInputText = lastUserMessage?.parts.find(isTextUIPart)?.text || "" const userInputText =
lastUserMessage?.parts?.find((p: any) => p.type === "text")?.text || ""
// Update Langfuse trace with input, session, and user // Update Langfuse trace with input, session, and user
setTraceInput({ setTraceInput({
@@ -325,7 +318,9 @@ async function handleChatRequest(req: Request): Promise<Response> {
: systemMessage : systemMessage
// Extract file parts (images) from the last user message // Extract file parts (images) from the last user message
const fileParts = lastUserMessage?.parts.filter(isFileUIPart) ?? [] const fileParts =
lastUserMessage?.parts?.filter((part: any) => part.type === "file") ||
[]
// Note: we used to pre-emptively reject images for models we guessed were // Note: we used to pre-emptively reject images for models we guessed were
// text-only (by name matching). That heuristic misfired on newer models // text-only (by name matching). That heuristic misfired on newer models
@@ -338,16 +333,12 @@ async function handleChatRequest(req: Request): Promise<Response> {
${userInputText} ${userInputText}
"""` """`
// Convert UIMessages to ModelMessages. A tool call that never got its // Convert UIMessages to ModelMessages and add system message. A tool
// result (the user stopped while it ran) is left out: the SDK would // call that never got its result (the user stopped while it ran) is
// refuse this and every later request of the chat // left out: the SDK would refuse this and every later request of the
// (MissingToolResultsError). Messages left without content (a notice // chat (MissingToolResultsError)
// the chat shows is a data part, which the model never sees) are const modelMessages = await convertToModelMessages(messages, {
// dropped: Bedrock rejects empty content. ignoreIncompleteToolCalls: true,
const modelMessages = pruneMessages({
messages: await convertToModelMessages(messages, {
ignoreIncompleteToolCalls: true,
}),
}) })
// DEBUG_LLM_PAYLOAD=true logs the incoming message structure // DEBUG_LLM_PAYLOAD=true logs the incoming message structure
@@ -358,10 +349,17 @@ ${userInputText}
) )
} }
// Filter out messages with empty content arrays (Bedrock API rejects these)
// This is a safety measure - ideally convertToModelMessages should handle all cases
let enhancedMessages = modelMessages.filter(
(msg: any) =>
msg.content && Array.isArray(msg.content) && msg.content.length > 0,
)
// Filter out tool-calls with invalid inputs (from failed repair or interrupted streaming) // Filter out tool-calls with invalid inputs (from failed repair or interrupted streaming)
// and their results. Bedrock API rejects messages where toolUse.input is not a valid // and their results. Bedrock API rejects messages where toolUse.input is not a valid
// JSON object, and every provider rejects a tool result whose call is gone. // JSON object, and every provider rejects a tool result whose call is gone.
let enhancedMessages = dropInvalidToolCalls(modelMessages) enhancedMessages = dropInvalidToolCalls(enhancedMessages)
// DEBUG_LLM_PAYLOAD=true logs what is sent to the model // DEBUG_LLM_PAYLOAD=true logs what is sent to the model
if (DEBUG_LLM_PAYLOAD) { if (DEBUG_LLM_PAYLOAD) {
@@ -473,10 +471,13 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
const allMessages = [...systemMessages, ...enhancedMessages] const allMessages = [...systemMessages, ...enhancedMessages]
// Set by onAbort, which records the finished steps' tokens itself
let stopped = false
const result = streamText({ const result = streamText({
model, model,
// The system messages carry cache points, so they go in messages // The system messages carry cache points, so they go in messages.
// (the client's own system messages were dropped above) // A client's own system messages have string content and were
// dropped by the empty-content filter above.
allowSystemInMessages: true, allowSystemInMessages: true,
abortSignal: req.signal, abortSignal: req.signal,
// Must be sent: unset means the provider's own default, and Bedrock's is // Must be sent: unset means the provider's own default, and Bedrock's is
@@ -523,31 +524,166 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
}, },
messages: allMessages, messages: allMessages,
...(providerOptions && { providerOptions }), // This now includes all reasoning configs ...(providerOptions && { providerOptions }), // This now includes all reasoning configs
// Langfuse telemetry (undefined when not configured) // Langfuse telemetry config (returns undefined if not configured)
experimental_telemetry: getTelemetryConfig({ ...(getTelemetryConfig({ sessionId: validSessionId, userId }) && {
sessionId: validSessionId, experimental_telemetry: getTelemetryConfig({
userId, sessionId: validSessionId,
userId,
}),
}), }),
// The quota counts each step as it ends: a later step that fails or onFinish: ({ text, totalUsage }) => {
// is stopped has still used these tokens (onFinish runs only for a
// completed answer). Awaited, so the write is done before the host
// may freeze the function after the answer ends.
onStepFinish: async ({ usage }) => {
if (countsQuota) {
await recordTokenUsage(userId, usage.totalTokens ?? 0)
}
},
onFinish: ({ text }) => {
// AI SDK 6 telemetry auto-reports token usage on its spans // AI SDK 6 telemetry auto-reports token usage on its spans
setTraceOutput(text) setTraceOutput(text)
// Record token usage for server-side quota tracking (if enabled)
// Use totalUsage (cumulative across all steps) instead of usage (final step only)
// inputTokens already includes cache reads and writes in AI SDK 6
if (countsQuota && totalUsage && !stopped) {
const totalTokens =
(totalUsage.inputTokens || 0) +
(totalUsage.outputTokens || 0)
recordTokenUsage(userId, totalTokens)
}
}, },
// onFinish is skipped when the stream fails or is aborted, so end the trace here // onFinish is skipped when the stream fails or is aborted, so end the trace here
onError: ({ error }) => { onError: ({ error }) => {
console.error(error) // what AI SDK does without an onError console.error(error) // what AI SDK does without an onError
endTrace() endTrace()
}, },
onAbort: () => endTrace(), onAbort: ({ steps }) => {
tools: chatTools, stopped = true
endTrace()
// Stopped (or disconnected) after some steps finished: their
// tokens were used, or stopping every request after a costly
// first step would get around the token limits
if (countsQuota) {
const tokens = steps.reduce(
(sum, step) =>
sum +
(step.usage.inputTokens || 0) +
(step.usage.outputTokens || 0),
0,
)
if (tokens > 0) recordTokenUsage(userId, tokens)
}
},
tools: {
// Client-side tool that will be executed on the client
display_diagram: {
description: `Display a diagram on draw.io. Pass ONLY the mxStyle definitions and the mxCell elements - wrapper tags and root cells are added automatically.
VALIDATION RULES (XML will be rejected if violated):
1. Generate ONLY mxStyle definitions and mxCell elements - NO wrapper tags (<mxfile>, <mxGraphModel>, <root>)
2. Do NOT include root cells (id="0" or id="1") - they are added automatically
3. All mxCell elements must be siblings - never nested
4. Every mxCell needs a unique id (start from "2")
5. parent defaults to "1"; write parent="<container-id>" only for shapes inside a container
6. Escape special chars in values: &lt; &gt; &amp; &quot;
A shape is one self-closing mxCell with x, y, w and h; an edge is one with source and target (a cell with source or target is always an edge). vertex="1", edge="1", parent="1" and the mxGeometry element are added automatically. Write parent only for a shape inside a container, and an mxGeometry element only for edge waypoints or for a separate label cell placed on an edge: <mxCell id="9" value="yes" style="edgeLabel;" parent="<edge id>" connectable="0"><mxGeometry x="-0.5" relative="1" as="geometry"/></mxCell>. An edge's own text simply goes in its value.
Example (generate ONLY this - no wrapper tags):
${SWIMLANE_EXAMPLE}
Styles: define a style used by several cells ONCE with <mxStyle name="..." value="..."/> before the cells and use the name in style like a CSS class; overrides after the name win. Name only styles that two or more cells share; names must not be draw.io's own style names (text, ellipse, rhombus, swimlane, label, image, blue, green, red, gray, yellow, orange, purple, pink). A definition applies to the call it is in. html=1 and whiteSpace=wrap are added automatically, never write them. Labels are HTML: use &lt;br&gt; for a line break, never \\n; a literal < or > is written &amp;lt; or &amp;gt;.
${STYLE_CLASS_EXAMPLE}
Notes:
- For AWS diagrams, use **AWS 2025 icons**.
- For animated connectors, add "flowAnimation=1" to edge style.
`,
inputSchema: z.object({
xml: z
.string()
.describe("XML string to be displayed on draw.io"),
}),
},
edit_diagram: {
description: `Edit the current diagram by ID-based operations (update/add/delete cells).
Operations:
- update: Replace an existing cell by its id. Provide cell_id and complete new_xml.
- add: Add a new cell. Provide cell_id (new unique id) and new_xml.
- delete: Remove a cell. Cascade is automatic: children AND edges (source/target) are auto-deleted. Only specify ONE cell_id.
For update/add, new_xml is the complete mxCell in the compact form (a shape with x, y, w, h; an edge with source and target), with its complete style (named styles are not available in edit_diagram).
⚠️ JSON ESCAPING: Every " inside new_xml MUST be escaped as \\". Example: id=\\"5\\" value=\\"Label\\"
Example - Add a rectangle:
{"operations": [{"operation": "add", "cell_id": "rect-1", "new_xml": "<mxCell id=\\"rect-1\\" value=\\"Hello\\" style=\\"rounded=1;\\" x=\\"100\\" y=\\"100\\" w=\\"120\\" h=\\"60\\"/>"}]}
Example - Delete container (children & edges auto-deleted):
{"operations": [{"operation": "delete", "cell_id": "2"}]}`,
inputSchema: z.object({
operations: z
.array(
z.object({
operation: z
.enum(["update", "add", "delete"])
.describe(
"Operation to perform: add, update, or delete",
),
cell_id: z
.string()
.describe(
"The id of the mxCell. Must match the id attribute in new_xml.",
),
new_xml: z
.string()
.optional()
.describe(
"Complete mxCell XML element (required for update/add)",
),
}),
)
.describe("Array of operations to apply"),
}),
},
append_diagram: {
description: `Continue generating diagram XML when previous display_diagram output was truncated due to length limits.
WHEN TO USE: Only call this tool after display_diagram was truncated (you'll see an error message about truncation).
CRITICAL INSTRUCTIONS:
1. Do NOT include any wrapper tags - just continue the mxCell elements
2. Continue from EXACTLY where your previous output stopped
3. Complete the remaining mxCell elements
4. If still truncated, call append_diagram again with the next fragment
Example: If previous output ended with '<mxCell id="x" style="rounded=1', continue with ';" x="40" y="40" w="120" h="60"/>' and complete the remaining elements.`,
inputSchema: z.object({
xml: z
.string()
.describe(
"Continuation XML fragment to append (NO wrapper tags)",
),
}),
},
get_shape_library: {
description: `Get draw.io shape/icon library documentation with style syntax and shape names.
Available libraries:
${SHAPE_LIBRARY_LIST}
Call this tool to get shape names and usage syntax for a specific library.`,
inputSchema: z.object({
library: z
.string()
.describe(
"Library name (e.g., 'aws4', 'kubernetes', 'flowchart')",
),
}),
execute: async ({ library }) => {
// Only known library names reach the file system
const result = await getShapeLibrary(
library,
path.join(process.cwd(), "docs/shape-libraries"),
)
return result.ok ? result.text : result.error
},
},
},
...(process.env.TEMPERATURE !== undefined && { ...(process.env.TEMPERATURE !== undefined && {
temperature: parseFloat(process.env.TEMPERATURE), temperature: parseFloat(process.env.TEMPERATURE),
}), }),
+5 -11
View File
@@ -11,7 +11,6 @@ import {
isQuotaEnabled, isQuotaEnabled,
recordTokenUsage, recordTokenUsage,
} from "@/lib/dynamo-quota-manager" } from "@/lib/dynamo-quota-manager"
import { getTelemetryConfig } from "@/lib/langfuse"
import { getUserIdFromRequest } from "@/lib/user-id" import { getUserIdFromRequest } from "@/lib/user-id"
import { VALIDATION_SYSTEM_PROMPT } from "@/lib/validation-prompts" import { VALIDATION_SYSTEM_PROMPT } from "@/lib/validation-prompts"
import { import {
@@ -147,18 +146,13 @@ export async function POST(req: Request): Promise<Response> {
}, },
], ],
maxOutputTokens: 1024, maxOutputTokens: 1024,
// Ends with the request: the user pressed Stop, or left abortSignal: AbortSignal.timeout(timeout),
abortSignal: req.signal,
timeout,
// Its usage and cost show in Langfuse next to the chat's
experimental_telemetry: getTelemetryConfig({
functionId: "validate-diagram",
sessionId,
userId,
}),
}) })
if (countsQuota) { if (countsQuota) {
await recordTokenUsage(userId, totalUsage.totalTokens ?? 0) await recordTokenUsage(
userId,
(totalUsage.inputTokens || 0) + (totalUsage.outputTokens || 0),
)
} }
if (sessionId) { if (sessionId) {
console.log( console.log(
+92 -102
View File
@@ -4,8 +4,8 @@ import { useChat } from "@ai-sdk/react"
import { import {
type ChatStatus, type ChatStatus,
DefaultChatTransport, DefaultChatTransport,
getToolName,
isToolUIPart, isToolUIPart,
type UIMessage,
} from "ai" } from "ai"
import { usePathname, useRouter, useSearchParams } from "next/navigation" import { usePathname, useRouter, useSearchParams } from "next/navigation"
import type React from "react" import type React from "react"
@@ -19,6 +19,7 @@ import {
useRef, useRef,
useState, useState,
} from "react" } from "react"
import { flushSync } from "react-dom"
import { toast } from "sonner" import { toast } from "sonner"
import type { ValidationState } from "@/components/chat/ValidationCard" import type { ValidationState } from "@/components/chat/ValidationCard"
import { useDiagram } from "@/contexts/diagram-context" import { useDiagram } from "@/contexts/diagram-context"
@@ -36,12 +37,6 @@ import {
import { useValidateDiagram } from "@/hooks/use-validate-diagram" import { useValidateDiagram } from "@/hooks/use-validate-diagram"
import { getApiEndpoint } from "@/lib/base-path" import { getApiEndpoint } from "@/lib/base-path"
import { findCachedResponse } from "@/lib/cached-responses" import { findCachedResponse } from "@/lib/cached-responses"
import {
type ChatToolName,
type ChatUIMessage,
type MessageSelection,
noticeMessage,
} from "@/lib/chat-message"
import { buildChatHeaders } from "@/lib/chat-request" import { buildChatHeaders } from "@/lib/chat-request"
import { pageModelXml, placeOnPage } from "@/lib/diagram-pages" import { pageModelXml, placeOnPage } from "@/lib/diagram-pages"
import { EMPTY_DRAWIO_DOCUMENT } from "@/lib/drawio/drawio-config" import { EMPTY_DRAWIO_DOCUMENT } from "@/lib/drawio/drawio-config"
@@ -81,7 +76,7 @@ const SAVE_DEBOUNCE_MS = 1000
* Only checks the LAST tool part (most recent tool call), not all tool parts. * Only checks the LAST tool part (most recent tool call), not all tool parts.
* A call the user stopped is not one to retry. * A call the user stopped is not one to retry.
*/ */
function hasToolErrors(messages: ChatUIMessage[]): boolean { function hasToolErrors(messages: UIMessage[]): boolean {
const lastMessage = messages[messages.length - 1] const lastMessage = messages[messages.length - 1]
if (lastMessage?.role !== "assistant") return false if (lastMessage?.role !== "assistant") return false
const lastToolPart = lastMessage.parts.filter(isToolUIPart).at(-1) const lastToolPart = lastMessage.parts.filter(isToolUIPart).at(-1)
@@ -108,9 +103,14 @@ function viewedPageId(): string | null {
return useCanvasStore.getState().currentPageId return useCanvasStore.getState().currentPageId
} }
function selectionOf(message: ChatUIMessage): MessageSelection { // Shapes sent with a user message (also kept in its metadata)
const selected = message.metadata?.selectedCells type MessageSelection = Pick<SelectedCell, "id" | "label">[]
return Array.isArray(selected) ? selected : []
function selectionOf(message: UIMessage): MessageSelection {
const meta = message.metadata as
| { selectedCells?: MessageSelection }
| undefined
return Array.isArray(meta?.selectedCells) ? meta.selectedCells : []
} }
function newLangfuseSessionId() { function newLangfuseSessionId() {
@@ -169,13 +169,15 @@ function withUniquePageIds(xml: string): string {
class FileReadError extends Error {} class FileReadError extends Error {}
export interface ChatEngine { export interface ChatEngine {
messages: ChatUIMessage[] messages: UIMessage[]
status: ChatStatus status: ChatStatus
error: Error | undefined error: Error | undefined
isBusy: boolean isBusy: boolean
/** The chat on screen is being left (saved, then another one shown) */ /** The chat on screen is being left (saved, then another one shown) */
isLeaving: boolean isLeaving: boolean
setMessages: (messages: ChatUIMessage[]) => void /** A message is being sent again; the chat is not empty meanwhile */
isResending: boolean
setMessages: (messages: UIMessage[]) => void
stop: () => void stop: () => void
input: string input: string
@@ -359,6 +361,9 @@ export function ChatEngineProvider({
const [isLeaving, setIsLeaving] = useState(false) const [isLeaving, setIsLeaving] = useState(false)
// The user's provider the current turn was sent to, for its errors // The user's provider the current turn was sent to, for its errors
const requestProviderIdRef = useRef<string | undefined>(undefined) const requestProviderIdRef = useRef<string | undefined>(undefined)
// A message is being sent again (retry, regenerate, edit), until the
// SDK has put it back
const [isResending, setIsResending] = useState(false)
// Presses of Stop: a check that began before one still knows of it after // Presses of Stop: a check that began before one still knows of it after
// the next message clears stoppedRef // the next message clears stoppedRef
const stopCountRef = useRef(0) const stopCountRef = useRef(0)
@@ -424,40 +429,9 @@ export function ChatEngineProvider({
onDisplayChart(original, true, "revert") onDisplayChart(original, true, "revert")
} }
// Requests made in the turn so far. The first carries the diagram the
// message was sent with (exported then, or the snapshot it is sent
// again from). Each later one, an automatic retry after a tool error,
// carries the canvas as it is then: a drawing is committed before its
// screenshot check can fail, and the model must go on from that
// drawing, not from the one before the turn.
const turnRequestsRef = useRef(0)
const transport = useMemo( const transport = useMemo(
() => () => new DefaultChatTransport({ api: getApiEndpoint("/api/chat") }),
new DefaultChatTransport<ChatUIMessage>({ [],
api: getApiEndpoint("/api/chat"),
prepareSendMessagesRequest: ({
id,
messages,
body,
trigger,
messageId,
}) => ({
body: {
...body,
...(turnRequestsRef.current++ > 0 && {
xml: getPageXml(
chartXMLRef.current,
turnPageIdRef.current,
),
}),
id,
messages,
trigger,
messageId,
},
}),
}),
[chartXMLRef],
) )
const { const {
@@ -468,19 +442,19 @@ export function ChatEngineProvider({
error, error,
setMessages, setMessages,
stop, stop,
} = useChat<ChatUIMessage>({ } = useChat({
transport, transport,
onToolCall: async ({ toolCall }) => { onToolCall: async ({ toolCall }) => {
await handleToolCall({ toolCall }, addToolOutputSafely) await handleToolCall({ toolCall }, addToolOutputSafely)
}, },
onFinish: ({ message, isAbort, isError, finishReason }) => { onFinish: ({ message, isAbort, isError }) => {
// Stopped, failed, or the reply just ended (a closed connection): // Stopped, failed, or the reply just ended (a closed connection):
// tool calls still streaming never reach the tool handler. Mark // tool calls still streaming never reach the tool handler. Mark
// them handled so a later render of the stream does not draw // them handled so a later render of the stream does not draw
// their preview again. // their preview again.
const cut = message.parts const cut = (message.parts as any[]).filter(
.filter(isToolUIPart) (part) => part.state === "input-streaming" && part.toolCallId,
.filter((part) => part.state === "input-streaming") )
for (const part of cut) { for (const part of cut) {
processedToolCallsRef.current.add(part.toolCallId) processedToolCallsRef.current.add(part.toolCallId)
} }
@@ -489,16 +463,18 @@ export function ChatEngineProvider({
// or the page going, aborts a reply). Not once another chat is // or the page going, aborts a reply). Not once another chat is
// on screen: the call is not among its messages. // on screen: the call is not among its messages.
if (isAbort && getChatGeneration() === turnChatRef.current) { if (isAbort && getChatGeneration() === turnChatRef.current) {
for (const part of cut) stopToolCall(part) for (const part of cut) {
addToolOutputSafely({
tool: part.type.replace("tool-", ""),
toolCallId: part.toolCallId,
state: "output-error",
errorText: "Stopped by user",
})
}
} }
if (isAbort || isError) return if (isAbort || isError || cut.length === 0) return
// The output budget ran out before the model called a tool (its // Ended with no error: undo the preview, and say so, as an
// thinking took it all): nothing was drawn // error does (with Retry)
const outOfTokens =
finishReason === "length" && !message.parts.some(isToolUIPart)
if (cut.length === 0 && !outOfTokens) return
// Ended with no error and no diagram: undo the preview, and say
// so, as an error does (with Retry)
const [originalXml] = editDiagramOriginalXmlRef.current.values() const [originalXml] = editDiagramOriginalXmlRef.current.values()
if (originalXml) onDisplayChart(originalXml, true, "revert") if (originalXml) onDisplayChart(originalXml, true, "revert")
editDiagramOriginalXmlRef.current.clear() editDiagramOriginalXmlRef.current.clear()
@@ -507,11 +483,16 @@ export function ChatEngineProvider({
if (getChatGeneration() !== turnChatRef.current) return if (getChatGeneration() !== turnChatRef.current) return
setMessages((currentMessages) => [ setMessages((currentMessages) => [
...currentMessages, ...currentMessages,
noticeMessage({ {
text: outOfTokens id: `error-${Date.now()}`,
? dict.errors.llm.output_truncated role: "system" as const,
: dict.errors.answerEndedEarly, parts: [
}), {
type: "text" as const,
text: dict.errors.answerEndedEarly,
},
],
},
]) ])
}, },
onError: (error) => { onError: (error) => {
@@ -580,15 +561,19 @@ export function ChatEngineProvider({
// the model selected may have changed since, e.g. in another tab // the model selected may have changed since, e.g. in another tab
const providerId = requestProviderIdRef.current const providerId = requestProviderIdRef.current
// In the chat, so it is cleared with the conversation; with a // A system message, so it can be cleared with the conversation
// button that opens model settings, on the page of the provider
// that refused
setMessages((currentMessages) => [ setMessages((currentMessages) => [
...currentMessages, ...currentMessages,
noticeMessage({ {
text, id: `error-${Date.now()}`,
...(openModelConfig && { openModelConfig, providerId }), role: "system" as const,
}), parts: [{ type: "text" as const, text }],
// The message shows a button that opens model settings,
// on the page of the provider that refused
...(openModelConfig && {
metadata: { openModelConfig: true, providerId },
}),
},
]) ])
if (isAccessCodeError) openSettings("general") if (isAccessCodeError) openSettings("general")
@@ -618,19 +603,9 @@ export function ChatEngineProvider({
) )
} }
// A call the user stopped before it reached the tool handler. The SDK
// finds the call by its id; the tool name is for the types only.
const stopToolCall = (part: Parameters<typeof getToolName>[0]) =>
addToolOutputSafely({
tool: getToolName(part) as ChatToolName,
toolCallId: part.toolCallId,
state: "output-error",
errorText: "Stopped by user",
})
// After each request: send the next one (retry, continue a cut off // After each request: send the next one (retry, continue a cut off
// drawing) or end the turn // drawing) or end the turn
const shouldSendAgain = (messages: ChatUIMessage[]) => { const shouldSendAgain = (messages: UIMessage[]) => {
// The user stopped: a tool result that arrives later (a VLM // The user stopped: a tool result that arrives later (a VLM
// check still running) must not start a new request // check still running) must not start a new request
if (stoppedRef.current) return false if (stoppedRef.current) return false
@@ -682,6 +657,9 @@ export function ChatEngineProvider({
} }
const isBusy = status === "streaming" || status === "submitted" const isBusy = status === "streaming" || status === "submitted"
useEffect(() => {
if (messages.length > 0) setIsResending(false)
}, [messages.length])
// isBusy of the latest render, for code that runs after an await // isBusy of the latest render, for code that runs after an await
const busyRef = useRef(isBusy) const busyRef = useRef(isBusy)
busyRef.current = isBusy busyRef.current = isBusy
@@ -793,7 +771,7 @@ export function ChatEngineProvider({
const [shownChats, setShownChats] = useState(0) const [shownChats, setShownChats] = useState(0)
// The messages shown when another chat was put on screen: useChat can // The messages shown when another chat was put on screen: useChat can
// show that chat's own up to 150 ms later than the rest of its state // show that chat's own up to 150 ms later than the rest of its state
const messagesBeforeShownRef = useRef<ChatUIMessage[] | null>(null) const messagesBeforeShownRef = useRef<UIMessage[] | null>(null)
const syncUIWithSession = useCallback( const syncUIWithSession = useCallback(
(data: SessionData | null) => { (data: SessionData | null) => {
@@ -833,7 +811,7 @@ export function ChatEngineProvider({
loadedMessageIdsRef.current = new Set( loadedMessageIdsRef.current = new Set(
(data.messages as { id: string }[]).map((m) => m.id), (data.messages as { id: string }[]).map((m) => m.id),
) )
setMessages(data.messages as unknown as ChatUIMessage[]) setMessages(data.messages as unknown as UIMessage[])
xmlSnapshotsRef.current = new Map(data.xmlSnapshots) xmlSnapshotsRef.current = new Map(data.xmlSnapshots)
if (data.diagramXml) onDisplayChart(data.diagramXml, true) if (data.diagramXml) onDisplayChart(data.diagramXml, true)
else clearDiagram() else clearDiagram()
@@ -1140,15 +1118,12 @@ export function ChatEngineProvider({
: "" : ""
} }
// messageId: the user message sent again (regenerate, edit, retry); the
// SDK puts the new one in its place and drops the answers after it
const sendChatMessage = ( const sendChatMessage = (
parts: any, parts: any,
xml: string, xml: string,
previousXml: string, previousXml: string,
turnIndex: number, turnIndex: number,
selectedCells: MessageSelection, selectedCells: MessageSelection,
messageId?: string,
) => { ) => {
autoRetryCountRef.current = 0 autoRetryCountRef.current = 0
continuationRetryCountRef.current = 0 continuationRetryCountRef.current = 0
@@ -1186,11 +1161,9 @@ export function ChatEngineProvider({
maxOutputTokens, maxOutputTokens,
}), }),
} }
turnRequestsRef.current = 0
// The selection stays with the message, for regenerate and retry // The selection stays with the message, for regenerate and retry
sendMessage( sendMessage(
{ {
...(messageId !== undefined && { messageId }),
parts, parts,
...(selected.length > 0 && { ...(selected.length > 0 && {
metadata: { selectedCells: selected }, metadata: { selectedCells: selected },
@@ -1443,13 +1416,18 @@ export function ChatEngineProvider({
dropLaterSnapshots(index) dropLaterSnapshots(index)
useVersionsStore.getState().removeFromTurn(index) useVersionsStore.getState().removeFromTurn(index)
// Remove the user message onwards (sendMessage re-adds it). For a
// moment there may be no message: that is no empty chat.
flushSync(() => {
setIsResending(true)
setMessages(messages.slice(0, index))
})
sendChatMessage( sendChatMessage(
parts, parts,
getPageXml(savedXml, viewedPageId()), getPageXml(savedXml, viewedPageId()),
previousXml, previousXml,
index, index,
selectionOf(messages[index]), selectionOf(messages[index]),
messages[index].id,
) )
return true return true
@@ -1515,13 +1493,16 @@ export function ChatEngineProvider({
chartXMLRef.current || chartXml, chartXMLRef.current || chartXml,
) )
} }
flushSync(() => {
setIsResending(true)
setMessages(messagesRef.current.slice(0, index))
})
sendChatMessage( sendChatMessage(
message.parts, message.parts,
chartXml, chartXml,
getPreviousXml(index), getPreviousXml(index),
index, index,
selectionOf(message), selectionOf(message),
message.id,
) )
} catch (error) { } catch (error) {
console.error("Error fetching chart data:", error) console.error("Error fetching chart data:", error)
@@ -1540,17 +1521,25 @@ export function ChatEngineProvider({
const lastMessage = messages[messages.length - 1] const lastMessage = messages[messages.length - 1]
// Calls the tool handler already took can still show as streaming: // Calls the tool handler already took can still show as streaming:
// the messages update at most every 150 ms (useChat throttle) // the messages update at most every 150 ms (useChat throttle)
const streaming = (lastMessage?.parts ?? []) const toolParts = lastMessage?.parts?.filter(
.filter(isToolUIPart) (part: any) =>
.filter( part.type?.startsWith("tool-") &&
(part) => part.state === "input-streaming" &&
part.state === "input-streaming" && !processedToolCallsRef.current.has(part.toolCallId),
!processedToolCallsRef.current.has(part.toolCallId), )
) for (const part of (toolParts ?? []) as any[]) {
for (const part of streaming) stopToolCall(part) if (part.toolCallId) {
addToolOutputSafely({
tool: part.type.replace("tool-", ""),
toolCallId: part.toolCallId,
state: "output-error",
errorText: "Stopped by user",
})
}
}
stop() stop()
abandonContinuation() abandonContinuation()
}, [messages, stopToolCall, stop, cancelValidation]) }, [messages, addToolOutputSafely, stop, cancelValidation])
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// Session actions // Session actions
@@ -1860,7 +1849,8 @@ export function ChatEngineProvider({
error, error,
isBusy, isBusy,
isLeaving, isLeaving,
setMessages: setMessages as (messages: ChatUIMessage[]) => void, isResending,
setMessages: setMessages as (messages: UIMessage[]) => void,
stop: handleStop, stop: handleStop,
input, input,
setInput, setInput,
+28 -23
View File
@@ -1,4 +1,4 @@
import { isToolUIPart, type UIMessage } from "ai" import type { UIMessage } from "ai"
import { import {
AlertCircle, AlertCircle,
BookmarkPlus, BookmarkPlus,
@@ -24,7 +24,6 @@ import { ValidationCard } from "@/components/chat/ValidationCard"
import Image from "@/components/image-with-basepath" import Image from "@/components/image-with-basepath"
import { useDictionary } from "@/hooks/use-dictionary" import { useDictionary } from "@/hooks/use-dictionary"
import { getApiEndpoint } from "@/lib/base-path" import { getApiEndpoint } from "@/lib/base-path"
import { noticeOf } from "@/lib/chat-message"
import { useCopy } from "@/lib/clipboard" import { useCopy } from "@/lib/clipboard"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { useUiStore } from "@/stores/ui-store" import { useUiStore } from "@/stores/ui-store"
@@ -370,7 +369,7 @@ function AssistantMessage({
parts.forEach((part, partIndex) => { parts.forEach((part, partIndex) => {
if (part.type === "reasoning") if (part.type === "reasoning")
blocks.push({ kind: "reasoning", index: partIndex, texts: [] }) blocks.push({ kind: "reasoning", index: partIndex, texts: [] })
else if (isToolUIPart(part)) else if (part.type?.startsWith("tool-"))
blocks.push({ kind: "tool", index: partIndex, texts: [] }) blocks.push({ kind: "tool", index: partIndex, texts: [] })
else if (part.type === "text") { else if (part.type === "text") {
const text = (part as { text: string }).text const text = (part as { text: string }).text
@@ -569,10 +568,9 @@ export function MessageList() {
} }
}, [messages]) }, [messages])
// A notice (a failed request) is an assistant message without an answer const lastAssistantIndex = messages
const lastAssistantIndex = messages.findLastIndex( .map((m) => m.role)
(m) => m.role === "assistant" && !noticeOf(m), .lastIndexOf("assistant")
)
const lastUserIndex = messages.map((m) => m.role).lastIndexOf("user") const lastUserIndex = messages.map((m) => m.role).lastIndexOf("user")
return ( return (
@@ -581,7 +579,6 @@ export function MessageList() {
const isRestored = engine.loadedMessageIdsRef.current.has( const isRestored = engine.loadedMessageIdsRef.current.has(
message.id, message.id,
) )
const notice = noticeOf(message)
return ( return (
<div <div
key={message.id} key={message.id}
@@ -593,18 +590,33 @@ export function MessageList() {
index={index} index={index}
isLast={index === lastUserIndex} isLast={index === lastUserIndex}
/> />
) : notice ? ( ) : message.role === "assistant" ? (
<AssistantMessage
message={message}
index={index}
isLastMessage={index === messages.length - 1}
isLastAssistant={index === lastAssistantIndex}
/>
) : (
<ErrorNotice <ErrorNotice
text={notice.text} text={getMessageTextContent(message)}
openModelConfig={!!notice.openModelConfig} openModelConfig={
onOpenModelConfig={() => !!(
message.metadata as
| { openModelConfig?: boolean }
| undefined
)?.openModelConfig
}
onOpenModelConfig={() => {
const { providerId } =
(message.metadata as
| { providerId?: string }
| undefined) ?? {}
openSettings( openSettings(
"models", "models",
notice.providerId providerId ? { providerId } : "list",
? { providerId: notice.providerId }
: "list",
) )
} }}
onRetry={ onRetry={
index === messages.length - 1 && index === messages.length - 1 &&
!engine.isBusy && !engine.isBusy &&
@@ -613,13 +625,6 @@ export function MessageList() {
: undefined : undefined
} }
/> />
) : (
<AssistantMessage
message={message}
index={index}
isLastMessage={index === messages.length - 1}
isLastAssistant={index === lastAssistantIndex}
/>
)} )}
</div> </div>
) )
+2 -3
View File
@@ -1,4 +1,3 @@
import { getToolName } from "ai"
import { import {
AlertCircle, AlertCircle,
Check, Check,
@@ -165,7 +164,7 @@ function ToolRow({
tone === "error" && "border-destructive/25", tone === "error" && "border-destructive/25",
)} )}
data-testid="tool-row" data-testid="tool-row"
data-tool-name={getToolName(part)} data-tool-name={part.type?.replace("tool-", "")}
data-tool-state={part.state} data-tool-state={part.state}
> >
<button <button
@@ -247,7 +246,7 @@ export function ToolActivity({
const t = dict.versions const t = dict.versions
const engine = useChatEngine() const engine = useChatEngine()
const versions = useVersionsStore((s) => s.versions) const versions = useVersionsStore((s) => s.versions)
const toolName = getToolName(part) const toolName = part.type?.replace("tool-", "")
const { state, errorText, output } = part const { state, errorText, output } = part
const versionIndex = versions.findIndex( const versionIndex = versions.findIndex(
+8 -12
View File
@@ -1,19 +1,15 @@
import type { DynamicToolUIPart, ToolUIPart, UITools } from "ai"
import type { DiagramOperation } from "@/packages/mcp-server/src/diagram-operations.ts" import type { DiagramOperation } from "@/packages/mcp-server/src/diagram-operations.ts"
export type { DiagramOperation } export type { DiagramOperation }
/** export interface ToolPartLike {
* A tool part as the UI reads it, in any state: input, output and errorText type: string
* are each there or not, instead of one shape per state. toolCallId: string
*/ state?: string
export type ToolPartLike = (ToolUIPart<UITools> | DynamicToolUIPart) & { input?: {
input?: xml?: string
| ({ operations?: DiagramOperation[]
xml?: string } & Record<string, unknown>
operations?: DiagramOperation[]
} & Record<string, unknown>)
| undefined
output?: string output?: string
errorText?: string errorText?: string
} }
+1
View File
@@ -150,6 +150,7 @@ function WorkspaceLayout() {
const showHero = const showHero =
engine.isRestored && engine.isRestored &&
engine.messages.length === 0 && engine.messages.length === 0 &&
!engine.isResending &&
!isRealDiagram(chartXML) && !isRealDiagram(chartXML) &&
!heroDismissed !heroDismissed
+19 -4
View File
@@ -1,4 +1,3 @@
import type { ChatAddToolOutputFunction } from "ai"
import type { RefObject } from "react" import type { RefObject } from "react"
import type { DiagramOperation } from "@/components/chat/types" import type { DiagramOperation } from "@/components/chat/types"
import type { import type {
@@ -6,7 +5,6 @@ import type {
ValidationStatus, ValidationStatus,
} from "@/components/chat/ValidationCard" } from "@/components/chat/ValidationCard"
import type { LoadMode } from "@/contexts/diagram-context" import type { LoadMode } from "@/contexts/diagram-context"
import type { ChatUIMessage } from "@/lib/chat-message"
import { pageModelXml, pageSelectorFor, placeOnPage } from "@/lib/diagram-pages" import { pageModelXml, pageSelectorFor, placeOnPage } from "@/lib/diagram-pages"
import type { ValidationResult } from "@/lib/diagram-validator" import type { ValidationResult } from "@/lib/diagram-validator"
import { formatValidationFeedback } from "@/lib/diagram-validator" import { formatValidationFeedback } from "@/lib/diagram-validator"
@@ -26,8 +24,25 @@ interface ToolCall {
input: unknown input: unknown
} }
// useChat's addToolOutput, typed with the chat's tools type AddToolOutputSuccess = {
type AddToolOutputFn = ChatAddToolOutputFunction<ChatUIMessage> tool: string
toolCallId: string
state?: "output-available"
output: string
errorText?: undefined
}
type AddToolOutputError = {
tool: string
toolCallId: string
state: "output-error"
output?: undefined
errorText: string
}
type AddToolOutputParams = AddToolOutputSuccess | AddToolOutputError
type AddToolOutputFn = (params: AddToolOutputParams) => void
const MAX_VALIDATION_RETRIES = 3 const MAX_VALIDATION_RETRIES = 3
-44
View File
@@ -1,44 +0,0 @@
import type { InferUITools, UIMessage } from "ai"
import type { chatTools } from "@/lib/chat-tools"
import type { SelectedCell } from "@/stores/canvas-store"
/** Shapes sent with a user message (also kept in its metadata) */
export type MessageSelection = Pick<SelectedCell, "id" | "label">[]
export interface ChatMessageMetadata {
selectedCells?: MessageSelection
}
/**
* A failed request or an answer that drew nothing, shown in the chat with
* what to do: an assistant message with this as its only part. The server
* ignores data parts, so it never reaches the model.
*/
export interface ChatNotice {
text: string
/** Show a button that opens model settings, on the provider that refused */
openModelConfig?: boolean
providerId?: string
}
export type ChatToolName = keyof InferUITools<typeof chatTools>
export type ChatUIMessage = UIMessage<
ChatMessageMetadata,
{ notice: ChatNotice },
InferUITools<typeof chatTools>
>
export function noticeMessage(notice: ChatNotice): ChatUIMessage {
return {
id: `notice-${Date.now()}`,
role: "assistant",
parts: [{ type: "data-notice", data: notice }],
}
}
/** The notice a message carries, if it is one */
export function noticeOf(message: ChatUIMessage): ChatNotice | undefined {
const part = message.parts.find((p) => p.type === "data-notice")
return part?.data
}
-138
View File
@@ -1,138 +0,0 @@
import { type InferUITools, tool } from "ai"
import path from "path"
import { z } from "zod"
import {
getShapeLibrary,
SHAPE_LIBRARY_LIST,
} from "@/packages/mcp-server/src/shape-library.ts"
import {
STYLE_CLASS_EXAMPLE,
SWIMLANE_EXAMPLE,
} from "@/packages/mcp-server/src/xml-examples.ts"
/**
* The chat's tools. display_diagram, edit_diagram and append_diagram run in
* the browser: the chat engine's tool handler puts their result on the
* canvas and reports it back as text, so they have an output schema and no
* execute. get_shape_library runs on the server.
*/
export const chatTools = {
display_diagram: tool({
description: `Display a diagram on draw.io. Pass ONLY the mxStyle definitions and the mxCell elements - wrapper tags and root cells are added automatically.
VALIDATION RULES (XML will be rejected if violated):
1. Generate ONLY mxStyle definitions and mxCell elements - NO wrapper tags (<mxfile>, <mxGraphModel>, <root>)
2. Do NOT include root cells (id="0" or id="1") - they are added automatically
3. All mxCell elements must be siblings - never nested
4. Every mxCell needs a unique id (start from "2")
5. parent defaults to "1"; write parent="<container-id>" only for shapes inside a container
6. Escape special chars in values: &lt; &gt; &amp; &quot;
A shape is one self-closing mxCell with x, y, w and h; an edge is one with source and target (a cell with source or target is always an edge). vertex="1", edge="1", parent="1" and the mxGeometry element are added automatically. Write parent only for a shape inside a container, and an mxGeometry element only for edge waypoints or for a separate label cell placed on an edge: <mxCell id="9" value="yes" style="edgeLabel;" parent="<edge id>" connectable="0"><mxGeometry x="-0.5" relative="1" as="geometry"/></mxCell>. An edge's own text simply goes in its value.
Example (generate ONLY this - no wrapper tags):
${SWIMLANE_EXAMPLE}
Styles: define a style used by several cells ONCE with <mxStyle name="..." value="..."/> before the cells and use the name in style like a CSS class; overrides after the name win. Name only styles that two or more cells share; names must not be draw.io's own style names (text, ellipse, rhombus, swimlane, label, image, blue, green, red, gray, yellow, orange, purple, pink). A definition applies to the call it is in. html=1 and whiteSpace=wrap are added automatically, never write them. Labels are HTML: use &lt;br&gt; for a line break, never \\n; a literal < or > is written &amp;lt; or &amp;gt;.
${STYLE_CLASS_EXAMPLE}
Notes:
- For AWS diagrams, use **AWS 2025 icons**.
- For animated connectors, add "flowAnimation=1" to edge style.
`,
inputSchema: z.object({
xml: z.string().describe("XML string to be displayed on draw.io"),
}),
outputSchema: z.string(),
}),
edit_diagram: tool({
description: `Edit the current diagram by ID-based operations (update/add/delete cells).
Operations:
- update: Replace an existing cell by its id. Provide cell_id and complete new_xml.
- add: Add a new cell. Provide cell_id (new unique id) and new_xml.
- delete: Remove a cell. Cascade is automatic: children AND edges (source/target) are auto-deleted. Only specify ONE cell_id.
For update/add, new_xml is the complete mxCell in the compact form (a shape with x, y, w, h; an edge with source and target), with its complete style (named styles are not available in edit_diagram).
⚠️ JSON ESCAPING: Every " inside new_xml MUST be escaped as \\". Example: id=\\"5\\" value=\\"Label\\"
Example - Add a rectangle:
{"operations": [{"operation": "add", "cell_id": "rect-1", "new_xml": "<mxCell id=\\"rect-1\\" value=\\"Hello\\" style=\\"rounded=1;\\" x=\\"100\\" y=\\"100\\" w=\\"120\\" h=\\"60\\"/>"}]}
Example - Delete container (children & edges auto-deleted):
{"operations": [{"operation": "delete", "cell_id": "2"}]}`,
inputSchema: z.object({
operations: z
.array(
z.object({
operation: z
.enum(["update", "add", "delete"])
.describe(
"Operation to perform: add, update, or delete",
),
cell_id: z
.string()
.describe(
"The id of the mxCell. Must match the id attribute in new_xml.",
),
new_xml: z
.string()
.optional()
.describe(
"Complete mxCell XML element (required for update/add)",
),
}),
)
.describe("Array of operations to apply"),
}),
outputSchema: z.string(),
}),
append_diagram: tool({
description: `Continue generating diagram XML when previous display_diagram output was truncated due to length limits.
WHEN TO USE: Only call this tool after display_diagram was truncated (you'll see an error message about truncation).
CRITICAL INSTRUCTIONS:
1. Do NOT include any wrapper tags - just continue the mxCell elements
2. Continue from EXACTLY where your previous output stopped
3. Complete the remaining mxCell elements
4. If still truncated, call append_diagram again with the next fragment
Example: If previous output ended with '<mxCell id="x" style="rounded=1', continue with ';" x="40" y="40" w="120" h="60"/>' and complete the remaining elements.`,
inputSchema: z.object({
xml: z
.string()
.describe(
"Continuation XML fragment to append (NO wrapper tags)",
),
}),
outputSchema: z.string(),
}),
get_shape_library: tool({
description: `Get draw.io shape/icon library documentation with style syntax and shape names.
Available libraries:
${SHAPE_LIBRARY_LIST}
Call this tool to get shape names and usage syntax for a specific library.`,
inputSchema: z.object({
library: z
.string()
.describe(
"Library name (e.g., 'aws4', 'kubernetes', 'flowchart')",
),
}),
execute: async ({ library }) => {
// Only known library names reach the file system
const result = await getShapeLibrary(
library,
path.join(process.cwd(), "docs/shape-libraries"),
)
return result.ok ? result.text : result.error
},
}),
}
/** The tool names and their input and output types, for the UI messages */
export type ChatTools = InferUITools<typeof chatTools>
+4 -8
View File
@@ -1,7 +1,6 @@
import { LangfuseClient } from "@langfuse/client" import { LangfuseClient } from "@langfuse/client"
import { observe, updateActiveTrace } from "@langfuse/tracing" import { observe, updateActiveTrace } from "@langfuse/tracing"
import * as api from "@opentelemetry/api" import * as api from "@opentelemetry/api"
import type { TelemetrySettings } from "ai"
import { randomUUID } from "crypto" import { randomUUID } from "crypto"
// Singleton LangfuseClient instance for direct API calls // Singleton LangfuseClient instance for direct API calls
@@ -116,23 +115,20 @@ export function endTrace() {
} }
} }
// Telemetry settings for streamText and generateText // Get telemetry config for streamText
export function getTelemetryConfig(params: { export function getTelemetryConfig(params: {
sessionId?: string sessionId?: string
userId?: string userId?: string
/** Names the trace of a model call outside the chat's observe() span */ }) {
functionId?: string
}): TelemetrySettings | undefined {
if (!isLangfuseEnabled()) return undefined if (!isLangfuseEnabled()) return undefined
return { return {
isEnabled: true, isEnabled: true,
functionId: params.functionId,
recordInputs: true, recordInputs: true,
recordOutputs: true, recordOutputs: true,
metadata: { metadata: {
...(params.sessionId && { sessionId: params.sessionId }), sessionId: params.sessionId,
...(params.userId && { userId: params.userId }), userId: params.userId,
}, },
} }
} }
+3 -8
View File
@@ -199,16 +199,13 @@ export async function testModelConnection(
tools: { ping: PING_TOOL }, tools: { ping: PING_TOOL },
maxOutputTokens: 1024, maxOutputTokens: 1024,
maxRetries: 0, maxRetries: 0,
// Ends with the request: the user closed the dialog or the page abortSignal: AbortSignal.timeout(TEST_TIMEOUT_MS),
abortSignal: req.signal,
timeout: TEST_TIMEOUT_MS,
}) })
let calledTool = false let calledTool = false
let finishReason: string | undefined let finishReason: string | undefined
for await (const part of result.fullStream) { for await (const part of result.fullStream) {
if (part.type === "error") throw part.error if (part.type === "error") throw part.error
// The timeout (or the request's end) ends the stream with an // The timeout ends the stream with an abort part, not an error
// abort part, not an error
if (part.type === "abort") { if (part.type === "abort") {
const timeout = new Error( const timeout = new Error(
`The model did not answer within ${TEST_TIMEOUT_MS / 1000} s.`, `The model did not answer within ${TEST_TIMEOUT_MS / 1000} s.`,
@@ -216,9 +213,7 @@ export async function testModelConnection(
timeout.name = "TimeoutError" timeout.name = "TimeoutError"
throw timeout throw timeout
} }
// A call of a tool that does not exist, or with unreadable if (part.type === "tool-call") {
// input, arrives marked invalid: no proof of tool support
if (part.type === "tool-call" && !part.invalid) {
calledTool = true calledTool = true
break break
} }
+79 -101
View File
@@ -28,89 +28,60 @@ const sse = (chunks: object[], end = true) =>
chunks.map((c) => `data: ${JSON.stringify(c)}\n\n`).join("") + chunks.map((c) => `data: ${JSON.stringify(c)}\n\n`).join("") +
(end ? "data: [DONE]\n\n" : "") (end ? "data: [DONE]\n\n" : "")
// Step 1: the model asks for a shape library (a tool run on the server) and
// reports its usage, so the chat makes a second request
const firstStep = () =>
new Response(
sse([
{
id: "c1",
choices: [
{
index: 0,
delta: {
role: "assistant",
tool_calls: [
{
index: 0,
id: "call_1",
type: "function",
function: {
name: "get_shape_library",
arguments: '{"library":"aws4"}',
},
},
],
},
finish_reason: null,
},
],
},
{
id: "c1",
choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }],
usage: { prompt_tokens: 1200, completion_tokens: 30 },
},
]),
{ headers: { "content-type": "text/event-stream" } },
)
const send = (signal?: AbortSignal) =>
chat(
new Request("http://localhost/api/chat", {
method: "POST",
signal,
headers: {
"Content-Type": "application/json",
"x-forwarded-for": "203.0.113.7",
// The server's own network: counted
"x-ai-provider": "glm",
"x-ai-base-url": "http://127.0.0.1:9000/v1",
"x-ai-api-key": "dummy",
"x-ai-model": "glm-5",
},
body: JSON.stringify({
messages: [
{
id: "u1",
role: "user",
parts: [{ type: "text", text: "Draw AWS" }],
},
],
xml: "",
}),
}),
)
const drain = async (res: Response) => {
const reader = res.body?.getReader()
while (
reader &&
!(await reader.read().catch(() => ({ done: true }))).done
) {
// drain
}
}
describe("a request stopped after a finished step", () => { describe("a request stopped after a finished step", () => {
it("counts that step's tokens", async () => { it("counts that step's tokens", async () => {
// Step 2 never ends, and the user stops // Step 1 asks for a shape library (run on the server) and reports
// its usage; step 2 never ends, and the user stops
let call = 0 let call = 0
vi.stubGlobal( vi.stubGlobal(
"fetch", "fetch",
vi.fn(async (_url: string, init?: RequestInit) => { vi.fn(async (_url: string, init?: RequestInit) => {
call++ call++
if (call === 1) return firstStep() if (call === 1) {
return new Response(
sse([
{
id: "c1",
choices: [
{
index: 0,
delta: {
role: "assistant",
tool_calls: [
{
index: 0,
id: "call_1",
type: "function",
function: {
name: "get_shape_library",
arguments:
'{"library":"aws4"}',
},
},
],
},
finish_reason: null,
},
],
},
{
id: "c1",
choices: [
{
index: 0,
delta: {},
finish_reason: "tool_calls",
},
],
usage: {
prompt_tokens: 1200,
completion_tokens: 30,
},
},
]),
{ headers: { "content-type": "text/event-stream" } },
)
}
// Never ends, until the request is aborted (as fetch does) // Never ends, until the request is aborted (as fetch does)
const body = new ReadableStream({ const body = new ReadableStream({
start(controller) { start(controller) {
@@ -127,35 +98,42 @@ describe("a request stopped after a finished step", () => {
}), }),
) )
const stop = new AbortController() const stop = new AbortController()
const res = await send(stop.signal) const res = await chat(
const draining = drain(res) new Request("http://localhost/api/chat", {
method: "POST",
signal: stop.signal,
headers: {
"Content-Type": "application/json",
"x-forwarded-for": "203.0.113.7",
// The server's own network: counted
"x-ai-provider": "glm",
"x-ai-base-url": "http://127.0.0.1:9000/v1",
"x-ai-api-key": "dummy",
"x-ai-model": "glm-5",
},
body: JSON.stringify({
messages: [
{
id: "u1",
role: "user",
parts: [{ type: "text", text: "Draw AWS" }],
},
],
xml: "",
}),
}),
)
const reader = res.body?.getReader()
// Read until the second step has started // Read until the second step has started
await vi.waitFor(() => expect(call).toBe(2), { timeout: 3000 }) await vi.waitFor(() => expect(call).toBe(2), { timeout: 3000 })
stop.abort() stop.abort()
// The answer stream ends; the SDK handles the stop as it is read // The answer stream ends; the SDK handles the stop as it is read
await draining while (
reader &&
!(await reader.read().catch(() => ({ done: true }))).done
) {
// drain
}
await vi.waitFor(() => expect(quota.recorded).toEqual([1230])) await vi.waitFor(() => expect(quota.recorded).toEqual([1230]))
}) })
}) })
describe("a request that fails after a finished step", () => {
it("counts that step's tokens", async () => {
// Step 2 is refused by the provider (a status the SDK does not
// retry): the stream ends with an error
let call = 0
vi.stubGlobal(
"fetch",
vi.fn(async () => {
call++
if (call === 1) return firstStep()
return new Response('{"error":{"message":"bad request"}}', {
status: 400,
headers: { "content-type": "application/json" },
})
}),
)
await drain(await send())
expect(call).toBe(2)
expect(quota.recorded).toEqual([1230])
})
})
-15
View File
@@ -205,21 +205,6 @@ describe("request checks", () => {
expect(await res.text()).toMatch(/Invalid AWS region/) expect(await res.text()).toMatch(/Invalid AWS region/)
expect(fetch).not.toHaveBeenCalled() expect(fetch).not.toHaveBeenCalled()
}) })
it("refuses messages that are not UI messages", async () => {
const res = await chat(
new Request("http://localhost/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
messages: [{ id: "u1", role: "user", text: "no parts" }],
xml: "",
}),
}),
)
expect(res.status).toBe(400)
expect(fetch).not.toHaveBeenCalled()
})
}) })
describe("server model allowlist", () => { describe("server model allowlist", () => {
+11 -40
View File
@@ -15,32 +15,19 @@ const RESULT = {
suggestions: ["Move box B to the right"], suggestions: ["Move box B to the right"],
} }
// A vision model that answers with the result as JSON text; told to wait, // A vision model that answers with the result as JSON text
// it answers only once its call is aborted
const model = vi.hoisted(() => ({ waitForAbort: false }))
vi.mock("@/lib/ai-providers", () => ({ vi.mock("@/lib/ai-providers", () => ({
getValidationModel: () => getValidationModel: () =>
new MockLanguageModelV3({ new MockLanguageModelV3({
doGenerate: (async (options: { abortSignal?: AbortSignal }) => { doGenerate: (async () => ({
if (model.waitForAbort) { content: [{ type: "text", text: JSON.stringify(RESULT) }],
const signal = options.abortSignal finishReason: { unified: "stop", raw: "stop" },
await new Promise((_, reject) => { usage: {
if (signal?.aborted) reject(signal.reason) inputTokens: { total: 1 },
signal?.addEventListener("abort", () => outputTokens: { total: 1 },
reject(signal.reason), },
) warnings: [],
}) })) as any,
}
return {
content: [{ type: "text", text: JSON.stringify(RESULT) }],
finishReason: { unified: "stop", raw: "stop" },
usage: {
inputTokens: { total: 1 },
outputTokens: { total: 1 },
},
warnings: [],
}
}) as any,
}), }),
})) }))
@@ -74,10 +61,9 @@ vi.mock("@/lib/dynamo-quota-manager", () => ({
}, },
})) }))
const post = (signal?: AbortSignal) => const post = () =>
new Request("http://localhost/api/validate-diagram", { new Request("http://localhost/api/validate-diagram", {
method: "POST", method: "POST",
signal,
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"x-forwarded-for": "203.0.113.7", "x-forwarded-for": "203.0.113.7",
@@ -86,7 +72,6 @@ const post = (signal?: AbortSignal) =>
}) })
afterEach(() => { afterEach(() => {
model.waitForAbort = false
quota.enabled = false quota.enabled = false
quota.allowed = true quota.allowed = true
quota.checks = [] quota.checks = []
@@ -121,20 +106,6 @@ describe("POST /api/validate-diagram", () => {
expect(await res.json()).toEqual(RESULT) expect(await res.json()).toEqual(RESULT)
}) })
it("ends its model call when the request is aborted", async () => {
// The user pressed Stop: the browser aborts the request, and the
// model call must not run on (and be paid for)
model.waitForAbort = true
const stop = new AbortController()
const pending = validateDiagram(post(stop.signal))
stop.abort()
expect(await (await pending).json()).toEqual({
valid: true,
issues: [],
suggestions: [],
})
})
it("answers valid when the check is turned off", async () => { it("answers valid when the check is turned off", async () => {
vi.stubEnv("ENABLE_VLM_VALIDATION", "false") vi.stubEnv("ENABLE_VLM_VALIDATION", "false")
const res = await validateDiagram(post()) const res = await validateDiagram(post())
-18
View File
@@ -141,24 +141,6 @@ describe("POST /api/validate-model", () => {
expect(data.valid).toBe(true) expect(data.valid).toBe(true)
expect(data.warning).toMatch(/without calling a tool/) expect(data.warning).toMatch(/without calling a tool/)
}) })
it("warns when the model calls a tool that does not exist", async () => {
// The SDK passes the call on, marked invalid
streamReply({
role: "assistant",
tool_calls: [
{
index: 0,
id: "call_1",
type: "function",
function: { name: "pong", arguments: "{}" },
},
],
})
const data = await testGlm()
expect(data.valid).toBe(true)
expect(data.warning).toMatch(/without calling a tool/)
})
}) })
describe("chat requests to a client base URL", () => { describe("chat requests to a client base URL", () => {