fix: older defects (batch C) and the second batch's review

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.
This commit is contained in:
dayuan.jiang
2026-10-05 18:57:02 +09:00
parent c75f74a5a0
commit c0fa997186
67 changed files with 3152 additions and 531 deletions
+6
View File
@@ -33,6 +33,7 @@ import {
import { Switch } from "@/components/ui/switch"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils"
import { STORAGE_KEYS } from "@/lib/storage"
import {
FIXED_CRED_PROVIDERS,
generateId,
@@ -88,6 +89,11 @@ function ProviderDetail({
try {
const data = await adminFetch("/api/admin/test-model", password, {
method: "POST",
// EdgeOne's function also checks the access code
headers: {
"x-access-code":
localStorage.getItem(STORAGE_KEYS.accessCode) || "",
},
body: JSON.stringify({ provider, modelId }),
})
setTestResults((prev) => ({
+10 -3
View File
@@ -107,8 +107,8 @@ export default function Home() {
resetDrawioReady()
}
// Check mobile. The draw.io iframe is not remounted when crossing the
// breakpoint (only the chat panel is), so its ready state stays as is.
// Check mobile. No panel is remounted when crossing the breakpoint, so
// the draw.io ready state and the chat's turn stay as they are.
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 768)
@@ -119,6 +119,14 @@ export default function Home() {
return () => window.removeEventListener("resize", checkMobile)
}, [])
// Give the chat panel the size of this side of the breakpoint. It is
// open on both sides: the mobile panel cannot be collapsed, and one
// collapsed on desktop comes back open
useEffect(() => {
chatPanelRef.current?.resize(isMobile ? 50 : 33)
setIsChatVisible(true)
}, [isMobile])
const toggleChatPanel = () => {
const panel = chatPanelRef.current
if (panel) {
@@ -212,7 +220,6 @@ export default function Home() {
{/* Chat Panel */}
<ResizablePanel
key={isMobile ? "mobile" : "desktop"}
id="chat-panel"
ref={chatPanelRef}
defaultSize={isMobile ? 50 : 33}
+12 -2
View File
@@ -48,6 +48,7 @@ export async function POST(req: Request) {
sameEndpoint && stored ? [stored] : [],
)
const serverUrl = globalBaseUrl(resolved.provider)
return validateModel(
new Request(new URL("/api/validate-model", req.url), {
method: "POST",
@@ -55,14 +56,23 @@ export async function POST(req: Request) {
"Content-Type": "application/json",
// Checked again there, in place of an access code
"x-admin-password": req.headers.get("x-admin-password") || "",
// The EdgeOne function checks the access code and Pages
// cookies, and its URL is built from the page's origin
"x-access-code": req.headers.get("x-access-code") || "",
cookie: req.headers.get("cookie") || "",
...(req.headers.get("origin") && {
origin: req.headers.get("origin") as string,
}),
},
body: JSON.stringify({
provider: resolved.provider,
apiKey: resolved.apiKey,
// Without a URL of its own, chat sends the entry's key to
// the server's <P>_BASE_URL: test that endpoint, not
// another one
baseUrl: resolved.baseUrl || globalBaseUrl(resolved.provider),
// another one. It is the server's own, which chat uses
// without the checks for a URL a user typed.
baseUrl: resolved.baseUrl || serverUrl,
...(!resolved.baseUrl && serverUrl && { serverBaseUrl: true }),
modelId: body.modelId,
awsAccessKeyId: resolved.awsAccessKeyId,
awsSecretAccessKey: resolved.awsSecretAccessKey,
+60 -25
View File
@@ -13,6 +13,7 @@ import { z } from "zod"
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
import {
CACHE_POINT,
edgeOneEndpoint,
getAIModel,
getServerProvider,
SINGLE_SYSTEM_PROVIDERS,
@@ -189,15 +190,16 @@ async function handleChatRequest(req: Request): Promise<Response> {
}
// A server model's provider comes from its config: for one set up in
// the admin panel the header holds the provider name's slug
const isEdgeOne = (serverModelConfig.provider || provider) === "edgeone"
// the admin panel the header holds the provider name's slug. Without
// either, the server's own AI_PROVIDER.
const isEdgeOne =
(serverModelConfig.provider || provider || getServerProvider()) ===
"edgeone"
// For EdgeOne provider, construct full URL from request origin
// because createOpenAI needs absolute URL, not relative path
if (isEdgeOne && !baseUrl) {
const origin = req.headers.get("origin") || new URL(req.url).origin
baseUrl = `${origin}/api/edgeai`
}
// EdgeOne is this deployment's own function, whatever URL the request
// names: another host would get the user's EdgeOne cookies, and the
// quota counts it. Absolute, as the SDK needs.
if (isEdgeOne) baseUrl = edgeOneEndpoint(req)
// Same rule as validate-model: with ALLOW_PRIVATE_URLS=false a request may
// not point the server at a private or internal address
@@ -212,8 +214,12 @@ async function handleChatRequest(req: Request): Promise<Response> {
const cookieHeader = req.headers.get("cookie")
const clientOverrides = {
// Server model provider takes precedence over client header
provider: serverModelConfig.provider || provider,
// Server model provider takes precedence over client header; EdgeOne
// named only in AI_PROVIDER is named here, for its own base URL
provider:
serverModelConfig.provider ||
provider ||
(isEdgeOne ? "edgeone" : null),
baseUrl,
apiKey: req.headers.get("x-ai-api-key"),
// A server model runs the model it was configured with, whatever the header says
@@ -274,18 +280,24 @@ async function handleChatRequest(req: Request): Promise<Response> {
// === SERVER-SIDE QUOTA CHECK START ===
// Quota is opt-in (DYNAMODB_QUOTA_TABLE) and counts what runs on the
// server's keys, or on its keyless Ollama or EdgeOne. Decided by the key
// actually used: a key header the provider never reads must not skip it.
// EdgeOne never reads one; keyless Ollama at a private address is the
// server's own network.
// server's keys, or on the server's own endpoints: EdgeOne, its keyless
// Ollama, and anything at a private address (the server's network,
// which ignores a dummy key header). Bedrock and EdgeOne never use the
// base URL header. In the desktop app every endpoint is the user's.
const clientBaseUrl = normalizeBaseUrl(
req.headers.get("x-ai-base-url") ?? "",
)
const usesClientBaseUrl =
resolvedProvider !== "bedrock" && resolvedProvider !== "edgeone"
const onServerEndpoint =
(resolvedProvider === "edgeone" && !clientBaseUrl) ||
(resolvedProvider === "ollama" &&
!clientOverrides.apiKey &&
(!clientBaseUrl || (await isPrivateUrl(clientBaseUrl))))
process.env.NEXT_AI_DRAWIO_DESKTOP !== "1" &&
(resolvedProvider === "edgeone" ||
(resolvedProvider === "ollama" &&
!clientBaseUrl &&
!clientOverrides.apiKey) ||
(usesClientBaseUrl &&
!!clientBaseUrl &&
(await isPrivateUrl(clientBaseUrl))))
const countsQuota =
isQuotaEnabled() &&
(onServerCredentials || onServerEndpoint) &&
@@ -317,11 +329,11 @@ async function handleChatRequest(req: Request): Promise<Response> {
)
// The user setting can raise the budget only on their own key (in the
// desktop app every key is the user's); on the server's keys it can only
// lower it
// desktop app every key is the user's); on the server's keys or own
// endpoints it can only lower it
const maxOutputTokens = resolveMaxOutputTokens(
req.headers.get("x-max-output-tokens"),
onServerCredentials,
onServerCredentials || onServerEndpoint,
)
console.log(`[maxOutputTokens] ${maxOutputTokens}`)
@@ -353,8 +365,13 @@ async function handleChatRequest(req: Request): Promise<Response> {
${userInputText}
"""`
// Convert UIMessages to ModelMessages and add system message
const modelMessages = await convertToModelMessages(messages)
// Convert UIMessages to ModelMessages and add system message. A tool
// call that never got its result (the user stopped while it ran) is
// left out: the SDK would refuse this and every later request of the
// chat (MissingToolResultsError)
const modelMessages = await convertToModelMessages(messages, {
ignoreIncompleteToolCalls: true,
})
// DEBUG_LLM_PAYLOAD=true logs the incoming message structure
if (DEBUG_LLM_PAYLOAD) {
@@ -541,6 +558,8 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
const allMessages = [...systemMessages, ...enhancedMessages]
// Set by onAbort, which records the finished steps' tokens itself
let stopped = false
const result = streamText({
model,
// The system messages carry cache points, so they go in messages.
@@ -606,7 +625,7 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
// 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) {
if (countsQuota && totalUsage && !stopped) {
const totalTokens =
(totalUsage.inputTokens || 0) +
(totalUsage.outputTokens || 0)
@@ -618,7 +637,23 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
console.error(error) // what AI SDK does without an onError
endTrace()
},
onAbort: () => endTrace(),
onAbort: ({ steps }) => {
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: {
+43 -1
View File
@@ -6,6 +6,12 @@
import { Output, streamText } from "ai"
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
import { getValidationModel } from "@/lib/ai-providers"
import {
checkAndIncrementRequest,
isQuotaEnabled,
recordTokenUsage,
} from "@/lib/dynamo-quota-manager"
import { getUserIdFromRequest } from "@/lib/user-id"
import { VALIDATION_SYSTEM_PROMPT } from "@/lib/validation-prompts"
import {
type ValidationResult,
@@ -78,6 +84,35 @@ export async function POST(req: Request): Promise<Response> {
)
}
// It runs the server's vision model: with the quota on, the daily
// and per-minute token limits apply, and its tokens are counted. Not
// the request limit, which is for chats: the day's last chat still
// gets its check, and a check does not count as a chat.
const userId = getUserIdFromRequest(req)
const countsQuota = isQuotaEnabled() && userId !== "anonymous"
if (countsQuota) {
const quotaCheck = await checkAndIncrementRequest(
userId,
{
requests: 0,
tokens: Number(process.env.DAILY_TOKEN_LIMIT) || 200000,
tpm: Number(process.env.TPM_LIMIT) || 20000,
},
0,
)
if (!quotaCheck.allowed) {
return Response.json(
{
error: quotaCheck.error,
type: quotaCheck.type,
used: quotaCheck.used,
limit: quotaCheck.limit,
},
{ status: 429 },
)
}
}
// Get the validation model
let model
try {
@@ -120,7 +155,14 @@ export async function POST(req: Request): Promise<Response> {
],
maxOutputTokens: 1024,
abortSignal: AbortSignal.timeout(timeout),
onFinish: ({ output }) => {
onFinish: ({ output, totalUsage }) => {
if (countsQuota && totalUsage) {
recordTokenUsage(
userId,
(totalUsage.inputTokens || 0) +
(totalUsage.outputTokens || 0),
)
}
if (sessionId && output) {
console.log(
`[validate-diagram] Session ${sessionId}: valid=${output.valid}, issues=${output.issues?.length ?? 0}`,
+31 -3
View File
@@ -3,7 +3,12 @@ import { NextResponse } from "next/server"
import { z } from "zod"
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
import { checkAdminAuth } from "@/lib/admin/auth"
import { getAIModel, usesServerCredentials } from "@/lib/ai-providers"
import {
edgeOneEndpoint,
getAIModel,
globalBaseUrl,
usesServerCredentials,
} from "@/lib/ai-providers"
import { classifyLLMError } from "@/lib/llm-errors"
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
import type { ProviderName } from "@/lib/types/model-config"
@@ -19,8 +24,11 @@ interface ValidateRequest {
awsAccessKeyId?: string
awsSecretAccessKey?: string
awsRegion?: string
awsSessionToken?: string
// Vertex AI specific
vertexApiKey?: string // Express Mode API key
// Set by the admin panel's Test: baseUrl is the server's <P>_BASE_URL
serverBaseUrl?: boolean
}
const TEST_TIMEOUT_MS = 15_000
@@ -47,11 +55,11 @@ export async function POST(req: Request) {
const {
provider,
apiKey,
baseUrl,
modelId,
awsAccessKeyId,
awsSecretAccessKey,
awsRegion,
awsSessionToken,
// Note: Express Mode only needs vertexApiKey
vertexApiKey,
} = body
@@ -62,9 +70,26 @@ export async function POST(req: Request) {
{ status: 400 },
)
}
// EdgeOne is this site's own function, as in the chat; the admin
// panel's Test sends no URL, and a relative one cannot be fetched
const baseUrl =
provider === "edgeone" ? edgeOneEndpoint(req) : body.baseUrl
// The admin panel's Test of an entry without a URL sends the
// server's own <P>_BASE_URL, which chat uses as it is: not a URL a
// user chose, so no private-address or redirect rules
const serverUrl =
body.serverBaseUrl === true &&
!!baseUrl &&
baseUrl === globalBaseUrl(provider) &&
!checkAdminAuth(req)
// SECURITY: Block SSRF attacks via custom baseUrl
if (baseUrl && !allowPrivateUrls() && (await isPrivateUrl(baseUrl))) {
if (
baseUrl &&
!serverUrl &&
!allowPrivateUrls() &&
(await isPrivateUrl(baseUrl))
) {
return NextResponse.json(
{ valid: false, error: "Invalid base URL" },
{ status: 400 },
@@ -122,9 +147,12 @@ export async function POST(req: Request) {
modelId,
apiKey,
baseUrl,
trustedBaseUrl: serverUrl,
awsAccessKeyId,
awsSecretAccessKey,
awsRegion,
// Temporary AWS credentials need it, as in the chat
awsSessionToken,
vertexApiKey,
// EdgeOne checks the Pages cookies and the access code
...(provider === "edgeone" && {
+8 -6
View File
@@ -211,7 +211,7 @@ export function ChatMessageDisplay({
</p>
)
}
const { chartXML, loadDiagram: onDisplayChart } = useDiagram()
const { chartXML, chartXMLRef, loadDiagram: onDisplayChart } = useDiagram()
const messagesEndRef = useRef<HTMLDivElement>(null)
const scrollTopRef = useRef<HTMLDivElement>(null)
const previousXML = useRef<string>("")
@@ -422,10 +422,12 @@ export function ChatMessageDisplay({
// Previous messages are already processed and won't change
const messagesToProcess =
messages.length > 0 ? [messages[messages.length - 1]] : []
// The diagram without streamed previews. Undoing a failed edit's
// preview below changes it before chartXML catches up, and an edit
// streaming right after must start from the undone diagram.
let baseXml = chartXML
// The diagram without streamed previews, as loaded last: the tool
// handler's result of an earlier edit is there before the chartXML
// state catches up. Undoing a failed edit's preview below changes it
// too, and an edit streaming right after must start from the undone
// diagram.
let baseXml = chartXMLRef.current
messagesToProcess.forEach((message) => {
// Messages restored from a saved session were applied before it was
@@ -587,7 +589,7 @@ export function ChatMessageDisplay({
})
}
})
}, [messages, handleDisplayChart, chartXML])
}, [messages, handleDisplayChart, chartXMLRef])
return (
<ScrollArea className="h-full w-full scrollbar-thin">
+89 -47
View File
@@ -110,7 +110,7 @@ export default function ChatPanel({
loadDiagram: onDisplayChart,
handleExport: onExport,
handleExportWithoutHistory,
resolverRef,
exportResolversRef,
chartXML,
chartXMLRef: liveChartXMLRef,
latestSvg,
@@ -128,21 +128,15 @@ export default function ChatPanel({
const urlSessionId = searchParams.get("session")
const onFetchChart = (saveToHistory = true) => {
// Waits for the reply to its own export, by its tag
const tag = saveToHistory ? onExport() : handleExportWithoutHistory()
return Promise.race([
new Promise<string>((resolve) => {
resolverRef.current = resolve
if (saveToHistory) {
onExport()
} else {
handleExportWithoutHistory()
}
if (tag) exportResolversRef.current[tag] = resolve
}),
new Promise<string>((_, reject) => {
const currentResolver = resolverRef.current
setTimeout(() => {
if (resolverRef.current === currentResolver) {
resolverRef.current = null
}
delete exportResolversRef.current[tag]
reject(new Error("Chart export timed out after 10 seconds"))
}, 10000)
}),
@@ -335,7 +329,8 @@ export default function ChatPanel({
const validationRetryCountRef = useRef(0)
// VLM validation hook using AI SDK's useObject
const { validateWithFallback } = useValidateDiagram()
const { validateWithFallback, cancel: cancelValidation } =
useValidateDiagram()
// Diagram tool handlers (display_diagram, edit_diagram, append_diagram)
const { handleToolCall } = useDiagramToolHandlers({
@@ -352,6 +347,7 @@ export default function ChatPanel({
validateDiagram: validateWithFallback,
enableVlmValidation: vlmValidationEnabled,
sessionId,
isStopped: () => stoppedRef.current,
onValidationStateChange: handleValidationStateChange,
})
@@ -675,6 +671,7 @@ export default function ChatPanel({
isAvailable: sessionIsAvailable,
currentSessionId,
saveCurrentSession,
getChatGeneration,
} = sessionManager
// Use ref for saveCurrentSession to avoid infinite loop
@@ -699,13 +696,14 @@ export default function ChatPanel({
clearTimeout(localStorageDebounceRef.current)
}
// Capture current session ID at schedule time to verify at save time
const scheduledForSessionId = currentSessionId
// Capture the chat on screen at schedule time; the save is dropped
// if another chat is on screen by the time it runs
const scheduledForChat = getChatGeneration()
// Capture whether there's a REAL diagram NOW (not just empty template)
const hasDiagramNow = isRealDiagram(chartXMLRef.current)
// Check if this session was just loaded without a diagram
const isNodiagramSession =
justLoadedSessionIdRef.current === scheduledForSessionId
justLoadedSessionIdRef.current === currentSessionId
// Debounce: save after 1 second of no changes
localStorageDebounceRef.current = setTimeout(async () => {
@@ -717,7 +715,7 @@ export default function ChatPanel({
})
await saveCurrentSessionRef.current(
sessionData,
scheduledForSessionId,
scheduledForChat,
)
}
} catch (error) {
@@ -737,6 +735,7 @@ export default function ChatPanel({
status,
sessionIsAvailable,
currentSessionId,
getChatGeneration,
buildSessionData,
])
@@ -921,25 +920,33 @@ export default function ChatPanel({
}
}
// The current chat could not be saved (storage full). The list where
// old chats can be deleted shows only in an empty chat, so let the user
// go on without saving (same toast id: it replaces the plain message)
const offerToContinueUnsaved = useCallback(
(proceed: () => void) => {
toast.error(dict.errors.sessionSaveFailedLeave, {
id: "session-save-failed",
duration: 15000,
action: {
label: dict.errors.continueWithoutSaving,
onClick: proceed,
},
})
},
[dict],
)
// Handle session switching from history dropdown
const handleSelectSession = useCallback(
async (sessionId: string) => {
if (!sessionManager.isAvailable) return
// Save current session before switching (also a diagram drawn
// without messages); if that failed (storage full), stay on it
if (messages.length > 0 || isRealDiagram(chartXMLRef.current)) {
const sessionData = await buildSessionData({
withThumbnail: true,
})
if (!(await sessionManager.saveCurrentSession(sessionData))) {
return
}
}
// Switch to selected session
const sessionData = await sessionManager.switchSession(sessionId)
if (sessionData) {
const open = async () => {
const sessionData =
await sessionManager.switchSession(sessionId)
if (!sessionData) return
const hasRealDiagram = isRealDiagram(sessionData.diagramXml)
justLoadedSessionRef.current = true
@@ -957,8 +964,29 @@ export default function ChatPanel({
syncUIWithSession(sessionData)
router.replace(`?session=${sessionId}`, { scroll: false })
}
// Save current session before switching (also a diagram drawn
// without messages); if that failed (storage full), stay on it
// unless the user goes on without saving it
if (messages.length > 0 || isRealDiagram(chartXMLRef.current)) {
const sessionData = await buildSessionData({
withThumbnail: true,
})
if (!(await sessionManager.saveCurrentSession(sessionData))) {
offerToContinueUnsaved(open)
return
}
}
await open()
},
[sessionManager, messages, buildSessionData, syncUIWithSession, router],
[
sessionManager,
messages,
buildSessionData,
syncUIWithSession,
router,
offerToContinueUnsaved,
],
)
// Handle session deletion from history dropdown
@@ -976,20 +1004,7 @@ export default function ChatPanel({
[sessionManager, syncUIWithSession, router, pathname],
)
const handleNewChat = useCallback(async () => {
// Save current session before creating new one (also a diagram
// drawn without messages)
if (
sessionManager.isAvailable &&
(messages.length > 0 || isRealDiagram(chartXMLRef.current))
) {
const sessionData = await buildSessionData({ withThumbnail: true })
// Not saved (storage full): keep the chat on screen
if (!(await sessionManager.saveCurrentSession(sessionData))) return
// Refresh sessions list to ensure dropdown shows the saved session
await sessionManager.refreshSessions()
}
const startNewChat = useCallback(() => {
// Clear session manager state BEFORE clearing URL to prevent race condition
// (otherwise the URL update effect would restore the old session URL)
sessionManager.clearCurrentSession()
@@ -1021,14 +1036,38 @@ export default function ChatPanel({
setMessages,
setSessionId,
sessionManager,
messages,
router,
dict.dialogs.clearSuccess,
buildSessionData,
setDiagramHistory,
pathname,
])
const handleNewChat = useCallback(async () => {
// Save current session before creating new one (also a diagram
// drawn without messages)
if (
sessionManager.isAvailable &&
(messages.length > 0 || isRealDiagram(chartXMLRef.current))
) {
const sessionData = await buildSessionData({ withThumbnail: true })
// Not saved (storage full): keep the chat on screen, unless the
// user goes on without saving it
if (!(await sessionManager.saveCurrentSession(sessionData))) {
offerToContinueUnsaved(startNewChat)
return
}
// Refresh sessions list to ensure dropdown shows the saved session
await sessionManager.refreshSessions()
}
startNewChat()
}, [
sessionManager,
messages,
buildSessionData,
offerToContinueUnsaved,
startNewChat,
])
// Handle sending a template directly (called from TemplatePanel)
const handleSendTemplate = useCallback(
async (template: { prompt: string }) => {
@@ -1089,6 +1128,9 @@ export default function ChatPanel({
// Handle stop button click
const handleStop = useCallback(() => {
stoppedRef.current = true
// A running screenshot check holds up the chat (the SDK waits for
// the tool handler): end it, so the call gets its result now
cancelValidation()
const lastMessage = messages[messages.length - 1]
// Calls the tool handler already took can still show as streaming:
// the messages update at most every 150 ms (useChat throttle)
@@ -1111,7 +1153,7 @@ export default function ChatPanel({
})
stop()
}, [messages, addToolOutput, stop])
}, [messages, addToolOutput, stop, cancelValidation])
// Send chat message with headers
const sendChatMessage = (
+10 -4
View File
@@ -20,9 +20,15 @@ interface ToolCallCardProps {
}
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">
{operations.map((op, index) => (
{shown.map((op, index) => (
<div
key={`${op.operation}-${op.cell_id}-${index}`}
className="rounded-lg border border-border/50 overflow-hidden bg-background/50"
@@ -40,13 +46,13 @@ function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
{op.operation}
</span>
<span className="text-xs text-muted-foreground">
cell_id: {op.cell_id}
cell_id: {text(op.cell_id)}
</span>
</div>
{op.new_xml && (
{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">
{op.new_xml}
{text(op.new_xml)}
</pre>
</div>
)}
+17 -17
View File
@@ -203,6 +203,7 @@ export function ModelConfigDialog({
p?.awsAccessKeyId,
p?.awsSecretAccessKey,
p?.awsRegion,
p?.awsSessionToken,
p?.vertexApiKey,
])
}
@@ -451,6 +452,9 @@ export function ModelConfigDialog({
awsSecretAccessKey:
selectedProvider.awsSecretAccessKey,
awsRegion: selectedProvider.awsRegion,
// Temporary AWS credentials, as the chat sends
awsSessionToken:
selectedProvider.awsSessionToken,
// Vertex AI credentials (Express Mode)
vertexApiKey: selectedProvider.vertexApiKey,
}),
@@ -492,19 +496,18 @@ export function ModelConfigDialog({
validationWarning: undefined,
}
}
// A newer test started: its own results and spinners count,
// whatever the credentials are now (they may have come back)
if (run !== validationRunRef.current) return
// Credentials changed during the test: drop the result. A
// change made in this tab already reset the spinners (and a
// newer test may show its own); one from another tab did
// not, so the latest test clears its own (model ids are
// unique, whatever provider is shown).
// change in another tab left the spinner on, so clear it
// (model ids are unique, whatever provider is shown).
if (credentialsOf(selectedProviderId) !== askedWith) {
if (run === validationRunRef.current) {
setValidatingModelIds((prev) => {
const next = new Set(prev)
next.delete(model.id)
return next
})
}
setValidatingModelIds((prev) => {
const next = new Set(prev)
next.delete(model.id)
return next
})
return
}
// So did this model's id: the result is for the old one
@@ -532,13 +535,10 @@ export function ModelConfigDialog({
})
}),
)
if (run !== validationRunRef.current) return
if (credentialsOf(selectedProviderId) !== askedWith) {
// The status line belongs to the latest test, and to the
// provider shown now
if (
run === validationRunRef.current &&
selectedProviderIdRef.current === selectedProviderId
) {
// The status line is about the provider shown now
if (selectedProviderIdRef.current === selectedProviderId) {
setValidationStatus("idle")
}
return
+117 -119
View File
@@ -21,9 +21,14 @@ interface DiagramContextType {
diagramHistory: { svg: string; xml: string }[]
setDiagramHistory: (history: { svg: string; xml: string }[]) => void
loadDiagram: (chart: string, skipValidation?: boolean) => string | null
handleExport: () => void
handleExportWithoutHistory: () => void
resolverRef: React.MutableRefObject<((value: string) => void) | null>
// Both return the export's tag (empty when draw.io is not there yet)
handleExport: () => string
handleExportWithoutHistory: () => string
// Pending exports by tag; a history or plain export's resolver gets the
// first page's XML
exportResolversRef: React.MutableRefObject<
Record<string, (data: string, xml?: string) => void>
>
drawioRef: React.MutableRefObject<DrawIoEmbedRef | null>
handleDiagramExport: (data: EventExport) => void
handleDiagramAutoSave: (data: { xml?: string }) => void
@@ -45,12 +50,10 @@ interface DiagramContextType {
const DiagramContext = createContext<DiagramContextType | undefined>(undefined)
// Exports for thumbnails, validation PNGs, history entries and file saves
// carry a tag in the request's `message` field. draw.io echoes the request
// back in the export event, so each result reaches its own caller; untagged
// exports (chat-panel's onFetchChart) resolve resolverRef. Thumbnail,
// validation and history tags end in a request number, so a late result
// never answers a newer request.
// Every export carries a tag in the request's `message` field. draw.io
// echoes the request back in the export event, so each result reaches its
// own caller. Tags end in a request number, so a late result never answers
// a newer request.
type ExportTag = "thumbnail" | "validation"
export function DiagramProvider({ children }: { children: React.ReactNode }) {
@@ -63,11 +66,10 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
const [showSaveDialog, setShowSaveDialog] = useState(false)
const hasCalledOnLoadRef = useRef(false)
const drawioRef = useRef<DrawIoEmbedRef | null>(null)
const resolverRef = useRef<((value: string) => void) | null>(null)
// Pending thumbnail and validation PNG exports, keyed by their export tag
const taggedResolversRef = useRef<Record<string, (value: string) => void>>(
{},
)
// Pending exports, keyed by their export tag
const exportResolversRef = useRef<
Record<string, (data: string, xml?: string) => void>
>({})
// Pending history exports: the document each one was asked for
const historyXmlRef = useRef(new Map<string, string>())
const exportSeqRef = useRef(0)
@@ -97,32 +99,28 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
setChartXML(xml)
}
// Track if we're expecting an export for file save (stores raw export data)
const saveResolverRef = useRef<{
resolver: ((data: string, fullDiagramXML?: string) => void) | null
format: ExportFormat | null
}>({ resolver: null, format: null })
const handleExport = () => {
if (drawioRef.current) {
// Save this export to history, with the document shown now:
// chartXML can change before the result comes back
const tag = `history-${++exportSeqRef.current}`
historyXmlRef.current.set(tag, chartXMLRef.current)
drawioRef.current.exportDiagram({
format: "xmlsvg",
message: tag,
})
}
if (!drawioRef.current) return ""
// Save this export to history, with the document shown now:
// chartXML can change before the result comes back
const tag = `history-${++exportSeqRef.current}`
historyXmlRef.current.set(tag, chartXMLRef.current)
drawioRef.current.exportDiagram({
format: "xmlsvg",
message: tag,
})
return tag
}
const handleExportWithoutHistory = () => {
if (drawioRef.current) {
// Export without saving to history (for edit_diagram fetching current state)
drawioRef.current.exportDiagram({
format: "xmlsvg",
})
}
if (!drawioRef.current) return ""
// Export without saving to history (for edit_diagram fetching current state)
const tag = `fetch-${++exportSeqRef.current}`
drawioRef.current.exportDiagram({
format: "xmlsvg",
message: tag,
})
return tag
}
// Export with a tag in `message` (draw.io echoes it back in the export
@@ -137,11 +135,11 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
const id = `${tag}-${++exportSeqRef.current}`
const finish = (value: string | null) => {
clearTimeout(timer)
delete taggedResolversRef.current[id]
delete exportResolversRef.current[id]
resolve(value)
}
const timer = setTimeout(() => finish(null), timeoutMs)
taggedResolversRef.current[id] = finish
exportResolversRef.current[id] = finish
drawioRef.current?.exportDiagram({ format, message: id })
})
@@ -213,16 +211,11 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
}
const handleDiagramExport = (data: EventExport) => {
// Tagged exports (thumbnail, validation PNG, file save) go only to
// their own caller, so they never take the result meant for resolverRef
// Thumbnail, validation PNG and file save exports go only to their
// own caller
const tag = data.message?.message
if (/^(thumbnail|validation)-/.test(tag ?? "")) {
taggedResolversRef.current[tag as string]?.(data.data)
return
}
if (tag === "save") {
saveResolverRef.current.resolver?.(data.data, data.xml)
saveResolverRef.current = { resolver: null, format: null }
if (/^(thumbnail|validation|save)-/.test(tag ?? "")) {
exportResolversRef.current[tag as string]?.(data.data, data.xml)
return
}
@@ -256,9 +249,12 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
})
}
if (resolverRef.current) {
resolverRef.current(extractedXML)
resolverRef.current = null
// The chat's own export (onFetchChart), not another one in flight
const resolve =
tag !== undefined ? exportResolversRef.current[tag] : undefined
if (resolve) {
delete exportResolversRef.current[tag as string]
resolve(extractedXML)
}
}
@@ -297,85 +293,87 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
const drawioFormat =
format === "drawio" || format === "xmlsvg" ? "xmlsvg" : format
// Set up the resolver before triggering export
saveResolverRef.current = {
resolver: (exportData: string, fullDiagramXML?: string) => {
let fileContent: string | Blob
let mimeType: string
let extension: string
// Each save has its own tag, so two at once never swap results
const tag = `save-${++exportSeqRef.current}`
exportResolversRef.current[tag] = (
exportData: string,
fullDiagramXML?: string,
) => {
delete exportResolversRef.current[tag]
let fileContent: string | Blob
let mimeType: string
let extension: string
if (format === "drawio") {
// Prefer the complete document from the export event so all pages are saved.
const xml = fullDiagramXML?.trim()
? fullDiagramXML
: extractDiagramXML(exportData)
fileContent =
normalizeToMxfile(xml, {
pageId: "page-1",
pageName: "Page-1",
}) ?? xml
mimeType = "application/xml"
extension = ".drawio"
} else if (format === "png") {
// PNG data comes as base64 data URL
fileContent = exportData
mimeType = "image/png"
extension = ".png"
} else if (format === "xmlsvg") {
// Editable SVG: pass data URL directly (like PNG)
fileContent = exportData
mimeType = "image/svg+xml"
extension = ".drawio.svg"
} else {
// SVG format (view-only)
fileContent = exportData
mimeType = "image/svg+xml"
extension = ".svg"
}
if (format === "drawio") {
// Prefer the complete document from the export event so all pages are saved.
const xml = fullDiagramXML?.trim()
? fullDiagramXML
: extractDiagramXML(exportData)
fileContent =
normalizeToMxfile(xml, {
pageId: "page-1",
pageName: "Page-1",
}) ?? xml
mimeType = "application/xml"
extension = ".drawio"
} else if (format === "png") {
// PNG data comes as base64 data URL
fileContent = exportData
mimeType = "image/png"
extension = ".png"
} else if (format === "xmlsvg") {
// Editable SVG: pass data URL directly (like PNG)
fileContent = exportData
mimeType = "image/svg+xml"
extension = ".drawio.svg"
} else {
// SVG format (view-only)
fileContent = exportData
mimeType = "image/svg+xml"
extension = ".svg"
}
// Log save event to Langfuse (flags the trace)
logSaveToLangfuse(filename, format, sessionId)
// Log save event to Langfuse (flags the trace)
logSaveToLangfuse(filename, format, sessionId)
// Handle download
let url: string
if (
typeof fileContent === "string" &&
fileContent.startsWith("data:")
) {
// Already a data URL (PNG)
url = fileContent
} else {
const blob = new Blob([fileContent], { type: mimeType })
url = URL.createObjectURL(blob)
}
// Handle download
let url: string
if (
typeof fileContent === "string" &&
fileContent.startsWith("data:")
) {
// Already a data URL (PNG)
url = fileContent
} else {
const blob = new Blob([fileContent], { type: mimeType })
url = URL.createObjectURL(blob)
}
const a = document.createElement("a")
a.href = url
a.download = `${filename}${extension}`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
const a = document.createElement("a")
a.href = url
a.download = `${filename}${extension}`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
// Show success toast after download is initiated
if (successMessage) {
toast.success(successMessage, {
position: "bottom-left",
duration: 2500,
})
}
// Show success toast after download is initiated
if (successMessage) {
toast.success(successMessage, {
position: "bottom-left",
duration: 2500,
})
}
// Delay URL revocation to ensure download completes
if (!url.startsWith("data:")) {
setTimeout(() => URL.revokeObjectURL(url), 100)
}
},
format,
// Delay URL revocation to ensure download completes
if (!url.startsWith("data:")) {
setTimeout(() => URL.revokeObjectURL(url), 100)
}
}
// Export diagram - callback will be handled in handleDiagramExport
drawioRef.current.exportDiagram({
format: drawioFormat,
message: "save",
message: tag,
})
}
@@ -407,7 +405,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
loadDiagram,
handleExport,
handleExportWithoutHistory,
resolverRef,
exportResolversRef,
drawioRef,
handleDiagramExport,
handleDiagramAutoSave,
+7 -2
View File
@@ -32,6 +32,9 @@ export function rebuildAppMenu(): void {
buildAppMenu()
}
// Number of the latest preset switch
let lastSwitch = 0
/**
* Apply a preset and restart the server so it takes effect.
* If the restart fails, go back to the previous preset and restart again,
@@ -41,6 +44,7 @@ export function rebuildAppMenu(): void {
export async function switchPreset(
id: string,
): Promise<Record<string, string>> {
const switchNumber = ++lastSwitch
const previousPresetId = getCurrentPresetId()
const env = applyPresetToEnv(id)
if (!env) {
@@ -60,8 +64,9 @@ export async function switchPreset(
console.error("Failed to restart server:", error)
const reason = error instanceof Error ? error.message : String(error)
// Another preset was chosen meanwhile: its own restart follows
if (getCurrentPresetId() !== id) {
// A newer switch started meanwhile (also of this same preset): its
// own restart follows, and undoing would lose that choice
if (switchNumber !== lastSwitch) {
throw new Error(
`The server could not be restarted.\n\nError: ${reason}`,
)
+15 -1
View File
@@ -28,6 +28,20 @@ export function loadEnvFile(): void {
console.log("No .env file found, using system environment variables")
}
/**
* Index of the quote that closes a value starting with a quote, or -1. A
* backslash before the quote character escapes it, as in dotenv; the
* backslash stays in the value.
*/
function findClosingQuote(value: string): number {
const quote = value[0]
for (let i = 1; i < value.length; i++) {
if (value[i] === "\\" && value[i + 1] === quote) i++
else if (value[i] === quote) return i
}
return -1
}
/**
* Parse and load environment variables from a file
*/
@@ -50,7 +64,7 @@ function loadEnvFromFile(filePath: string): void {
const quote = value[0]
const closingQuote =
quote === '"' || quote === "'" ? value.indexOf(quote, 1) : -1
quote === '"' || quote === "'" ? findClosingQuote(value) : -1
if (
closingQuote > 0 &&
/^\s*(#.*)?$/.test(value.slice(closingQuote + 1))
+19 -1
View File
@@ -64,6 +64,9 @@ interface UseDiagramToolHandlersParams {
validateDiagram?: ValidateDiagramFn
enableVlmValidation?: boolean
sessionId?: string
// The user pressed Stop: a screenshot check that has not started is
// skipped (one already running is cancelled by the caller)
isStopped?: () => boolean
onValidationStateChange?: (
toolCallId: string,
state: ValidationState,
@@ -90,6 +93,7 @@ export function useDiagramToolHandlers({
validateDiagram,
enableVlmValidation = true,
sessionId,
isStopped,
onValidationStateChange,
}: UseDiagramToolHandlersParams) {
// Helper to update validation state
@@ -257,7 +261,11 @@ ${finalXml}
await new Promise((resolve) => setTimeout(resolve, 100))
capturedPngData = await captureValidationPng()
if (capturedPngData) {
// Stopped while the screenshot was taken: no check. The
// chat waits for this handler, so it must end now.
if (isStopped?.()) {
updateValidationState(toolCall.toolCallId, "skipped")
} else if (capturedPngData) {
if (DEBUG) {
console.log(
"[display_diagram] Captured PNG for validation",
@@ -363,6 +371,16 @@ ${finalXml}
updateValidationState(toolCall.toolCallId, "skipped")
}
} catch (error) {
// Cancelled by Stop: the diagram stays, unchecked
if ((error as Error)?.name === "AbortError") {
updateValidationState(toolCall.toolCallId, "skipped")
addToolOutput({
tool: "display_diagram",
toolCallId: toolCall.toolCallId,
output: "Successfully displayed the diagram.",
})
return
}
// VLM validation error - log but don't block the user
console.warn(
"[display_diagram] VLM validation error:",
+151 -110
View File
@@ -13,10 +13,12 @@ import {
getSession,
isIndexedDBAvailable,
migrateFromLocalStorage,
readSessionCount,
type SessionMetadata,
type StoredMessage,
saveSession,
} from "@/lib/session-storage"
import { STORAGE_KEYS } from "@/lib/storage"
export interface SessionData {
messages: StoredMessage[]
@@ -37,14 +39,17 @@ export interface UseSessionManagerReturn {
// Actions
switchSession: (id: string) => Promise<SessionData | null>
deleteSession: (id: string) => Promise<{ wasCurrentSession: boolean }>
// forSessionId: optional session ID to verify save targets correct session (prevents stale debounce writes)
// chatGeneration: getChatGeneration() when the save was scheduled (by
// default, now); the save is dropped if another chat is on screen when
// its turn comes
// Resolves to false when the save failed (the user was told)
saveCurrentSession: (
data: SessionData,
forSessionId?: string | null,
chatGeneration?: number,
) => Promise<boolean>
refreshSessions: () => Promise<void>
clearCurrentSession: () => void
getChatGeneration: () => number
}
// Reading the session list loads every stored session in full, and window
@@ -79,6 +84,20 @@ export function useSessionManager(
const isInitializedRef = useRef(false)
// Sequence guard for URL changes - prevents out-of-order async resolution
const urlChangeSequenceRef = useRef(0)
// The chat on screen, read by saves that run after a render or a wait
const currentSessionRef = useRef<ChatSession | null>(null)
// Goes up each time another chat is put on screen (creating the
// session of the chat on screen does not count)
const chatGenerationRef = useRef(0)
// Saves run one at a time, so two saves of a new chat create it once
const saveQueueRef = useRef<Promise<unknown>>(Promise.resolve())
const changeChat = useCallback((session: ChatSession | null) => {
chatGenerationRef.current++
currentSessionRef.current = session
setCurrentSession(session)
setCurrentSessionId(session?.id ?? null)
}, [])
// Load sessions list
const refreshSessions = useCallback(async () => {
@@ -115,18 +134,32 @@ export function useSessionManager(
const metadata = await getAllSessionMetadata()
setSessions(metadata)
// The desktop app may try its other port next launch, where
// an older version may have saved the chats
window.electronAPI
?.chatsLoaded?.(metadata.length)
.catch(() => {})
// an older version may have saved the chats: only when this
// origin surely has none (a failed read is not "none") and
// keeps no model settings or keys either
if (window.electronAPI?.chatsLoaded) {
const count = await readSessionCount()
// The app saves an empty config on its first load; the
// providers are what holds the keys
let hasSettings = true
try {
const config = JSON.parse(
localStorage.getItem(STORAGE_KEYS.modelConfigs) ??
"{}",
)
hasSettings = (config.providers?.length ?? 0) > 0
} catch {
// Unreadable: treat as settings, and stay
}
if (count !== null && !hasSettings) {
window.electronAPI.chatsLoaded(count).catch(() => {})
}
}
// Only load a session if initialSessionId is provided (from URL param)
if (initialSessionId) {
const session = await getSession(initialSessionId)
if (session) {
setCurrentSession(session)
setCurrentSessionId(session.id)
}
if (session) changeChat(session)
// If session not found, stay in blank state (URL has invalid session ID)
}
// If no initialSessionId, start with blank state (no auto-restore)
@@ -138,7 +171,7 @@ export function useSessionManager(
}
init()
}, [initialSessionId])
}, [initialSessionId, changeChat])
// Handle URL session ID changes after initialization
// Note: intentionally NOT including currentSessionId in deps to avoid race conditions
@@ -153,6 +186,7 @@ export function useSessionManager(
async function handleSessionIdChange() {
if (initialSessionId) {
const generation = chatGenerationRef.current
// URL has session ID - load it
const session = await getSession(initialSessionId)
@@ -161,16 +195,13 @@ export function useSessionManager(
if (currentSequence !== urlChangeSequenceRef.current) {
return
}
// Another chat was put on screen meanwhile (New Chat right
// after this one got its session id in the URL): keep it
if (generation !== chatGenerationRef.current) return
if (session) {
// Only update if the session is different from current
setCurrentSessionId((current) => {
if (current !== session.id) {
setCurrentSession(session)
return session.id
}
return current
})
// Only update if the session is different from current
if (session && currentSessionRef.current?.id !== session.id) {
changeChat(session)
}
}
// Removed: else clause that clears session
@@ -179,7 +210,7 @@ export function useSessionManager(
}
handleSessionIdChange()
}, [initialSessionId, isAvailable])
}, [initialSessionId, isAvailable, changeChat])
// Refresh sessions on window focus (multi-tab sync), at most once per interval
const lastFocusRefreshRef = useRef(0)
@@ -201,9 +232,11 @@ export function useSessionManager(
async (id: string): Promise<SessionData | null> => {
if (id === currentSessionId) return null
// Save current session first if it has messages
if (currentSession && currentSession.messages.length > 0) {
await saveSession(currentSession)
// Save current session first if it has messages (as saved
// last: the caller may have just saved it)
const current = currentSessionRef.current
if (current && current.messages.length > 0) {
await saveSession(current)
}
// Load the target session
@@ -213,9 +246,7 @@ export function useSessionManager(
return null
}
// Update state
setCurrentSession(session)
setCurrentSessionId(session.id)
changeChat(session)
return {
messages: session.messages,
@@ -225,7 +256,7 @@ export function useSessionManager(
diagramHistory: session.diagramHistory,
}
},
[currentSessionId, currentSession],
[currentSessionId, changeChat],
)
// Delete a session
@@ -235,112 +266,121 @@ export function useSessionManager(
await deleteSessionFromDB(id)
// If deleting current session, clear state (caller will show new empty session)
if (wasCurrentSession) {
setCurrentSession(null)
setCurrentSessionId(null)
}
if (wasCurrentSession) changeChat(null)
await refreshSessions()
return { wasCurrentSession }
},
[currentSessionId, refreshSessions],
[currentSessionId, refreshSessions, changeChat],
)
// Save current session data (debounced externally by caller)
// forSessionId: if provided, verify save targets correct session (prevents stale debounce writes)
const saveCurrentSession = useCallback(
async (
data: SessionData,
forSessionId?: string | null,
): Promise<boolean> => {
// If forSessionId is provided, verify it matches current session
// This prevents stale debounced saves from overwriting a newly switched session
if (
forSessionId !== undefined &&
forSessionId !== currentSessionId
) {
return true
}
// Nothing can be stored without IndexedDB
if (!isIndexedDBAvailable()) return true
(data: SessionData, chatGeneration?: number): Promise<boolean> => {
// The data is of the chat on screen when the save was asked for
const generation = chatGeneration ?? chatGenerationRef.current
const run = async (): Promise<boolean> => {
// That chat is no longer on screen (leaving it saved it)
if (generation !== chatGenerationRef.current) return true
// Nothing can be stored without IndexedDB
if (!isIndexedDBAvailable()) return true
// The user may put another chat on screen while this one is
// written; the stored copy is still right, the state is not
const stillOnScreen = () =>
chatGenerationRef.current === generation
const currentSession = currentSessionRef.current
if (!currentSession) {
// Create a new session if none exists
const newSession: ChatSession = {
...createEmptySession(),
if (!currentSession) {
// Create a new session if none exists
const newSession: ChatSession = {
...createEmptySession(),
messages: data.messages,
xmlSnapshots: data.xmlSnapshots,
diagramXml: data.diagramXml,
thumbnailDataUrl: data.thumbnailDataUrl,
diagramHistory: data.diagramHistory,
title: extractTitle(data.messages),
}
// Without a stored session, keep no session id (it would end
// up in the URL and point to nothing after a reload)
if (!(await saveSession(newSession))) {
notifySaveFailed(dict.errors.sessionSaveFailed)
return false
}
await enforceSessionLimit()
if (stillOnScreen()) {
currentSessionRef.current = newSession
setCurrentSession(newSession)
setCurrentSessionId(newSession.id)
}
await refreshSessions()
return true
}
// Update existing session
const updatedSession: ChatSession = {
...currentSession,
messages: data.messages,
xmlSnapshots: data.xmlSnapshots,
diagramXml: data.diagramXml,
thumbnailDataUrl: data.thumbnailDataUrl,
diagramHistory: data.diagramHistory,
title: extractTitle(data.messages),
thumbnailDataUrl:
data.thumbnailDataUrl ??
currentSession.thumbnailDataUrl,
diagramHistory:
data.diagramHistory ?? currentSession.diagramHistory,
updatedAt: Date.now(),
// Update title if it's still default and we have messages
title:
currentSession.title === "New Chat" &&
data.messages.length > 0
? extractTitle(data.messages)
: currentSession.title,
}
// Without a stored session, keep no session id (it would end
// up in the URL and point to nothing after a reload)
if (!(await saveSession(newSession))) {
if (!(await saveSession(updatedSession))) {
notifySaveFailed(dict.errors.sessionSaveFailed)
return false
}
await enforceSessionLimit()
setCurrentSession(newSession)
setCurrentSessionId(newSession.id)
await refreshSessions()
if (stillOnScreen()) {
currentSessionRef.current = updatedSession
setCurrentSession(updatedSession)
}
// Update sessions list metadata
setSessions((prev) =>
prev.map((s) =>
s.id === updatedSession.id
? {
...s,
title: updatedSession.title,
updatedAt: updatedSession.updatedAt,
messageCount: updatedSession.messages.length,
hasDiagram:
!!updatedSession.diagramXml &&
updatedSession.diagramXml.trim().length >
0,
thumbnailDataUrl:
updatedSession.thumbnailDataUrl,
}
: s,
),
)
return true
}
// Update existing session
const updatedSession: ChatSession = {
...currentSession,
messages: data.messages,
xmlSnapshots: data.xmlSnapshots,
diagramXml: data.diagramXml,
thumbnailDataUrl:
data.thumbnailDataUrl ?? currentSession.thumbnailDataUrl,
diagramHistory:
data.diagramHistory ?? currentSession.diagramHistory,
updatedAt: Date.now(),
// Update title if it's still default and we have messages
title:
currentSession.title === "New Chat" &&
data.messages.length > 0
? extractTitle(data.messages)
: currentSession.title,
}
if (!(await saveSession(updatedSession))) {
notifySaveFailed(dict.errors.sessionSaveFailed)
return false
}
setCurrentSession(updatedSession)
// Update sessions list metadata
setSessions((prev) =>
prev.map((s) =>
s.id === updatedSession.id
? {
...s,
title: updatedSession.title,
updatedAt: updatedSession.updatedAt,
messageCount: updatedSession.messages.length,
hasDiagram:
!!updatedSession.diagramXml &&
updatedSession.diagramXml.trim().length > 0,
thumbnailDataUrl: updatedSession.thumbnailDataUrl,
}
: s,
),
)
return true
const result = saveQueueRef.current.then(run)
saveQueueRef.current = result.catch(() => {})
return result
},
[currentSession, currentSessionId, refreshSessions, dict],
[refreshSessions, dict],
)
// Clear current session state (for starting fresh without loading another session)
const clearCurrentSession = useCallback(() => {
setCurrentSession(null)
setCurrentSessionId(null)
}, [])
changeChat(null)
}, [changeChat])
const getChatGeneration = useCallback(() => chatGenerationRef.current, [])
return {
sessions,
@@ -353,5 +393,6 @@ export function useSessionManager(
saveCurrentSession,
refreshSessions,
clearCurrentSession,
getChatGeneration,
}
}
+15
View File
@@ -103,9 +103,22 @@ export function useValidateDiagram(options: UseValidateDiagramOptions = {}) {
[submit],
)
/**
* End a running check (the user pressed Stop): its promise rejects with
* an AbortError, so the tool handler can finish at once.
*/
const cancel = useCallback(() => {
const pending = pendingValidationRef.current
if (!pending) return
pendingValidationRef.current = null
stop()
pending.reject(new DOMException("Validation cancelled", "AbortError"))
}, [stop])
/**
* Validate with fallback - returns default valid result on error.
* Use this to avoid blocking the user on validation failures.
* A cancelled check is passed on as its AbortError.
*/
const validateWithFallback = useCallback(
async (
@@ -115,6 +128,7 @@ export function useValidateDiagram(options: UseValidateDiagramOptions = {}) {
try {
return await validate(imageData, sessionId)
} catch (error) {
if ((error as Error)?.name === "AbortError") throw error
console.warn(
"[useValidateDiagram] Validation failed, using fallback:",
error,
@@ -130,6 +144,7 @@ export function useValidateDiagram(options: UseValidateDiagramOptions = {}) {
validate,
validateWithFallback,
stop,
cancel,
// State
isValidating: isLoading,
+1 -6
View File
@@ -257,12 +257,7 @@ export function deriveEnvUpdates(
if (p.baseUrl) updates.GOOGLE_VERTEX_BASE_URL = p.baseUrl
} else if (p.provider === "ollama") {
if (p.apiKey) updates.OLLAMA_API_KEY = p.apiKey
// A key without a URL is an Ollama Cloud key, as its Test sends
// it; chat sends a server key to OLLAMA_BASE_URL or local Ollama
if (p.baseUrl || p.apiKey) {
updates.OLLAMA_BASE_URL =
p.baseUrl || PROVIDER_INFO.ollama.defaultBaseUrl || null
}
if (p.baseUrl) updates.OLLAMA_BASE_URL = p.baseUrl
} else {
const env = credEnvNames(p.provider, index)
if (env.key && p.apiKey) updates[env.key] = p.apiKey
+37 -12
View File
@@ -21,6 +21,7 @@ import {
adminProvidersToConfig,
loadAdminProviders,
} from "@/lib/admin/providers"
import { getApiEndpoint } from "@/lib/base-path"
import { redirectGuardedFetch } from "@/lib/ssrf-protection"
import {
normalizeBaseUrl,
@@ -100,6 +101,9 @@ export interface ClientOverrides {
awsSessionToken?: string | null
// Vertex AI config
vertexApiKey?: string | null // Express Mode API key
// baseUrl is the server's own <P>_BASE_URL (the admin panel's Test),
// not one a user chose: no redirect guard
trustedBaseUrl?: boolean
// Custom headers (e.g., for EdgeOne cookie auth)
headers?: Record<string, string>
// Custom env var name(s) for server models
@@ -569,6 +573,7 @@ function detectProvider(): ProviderName | null {
function validateProviderCredentials(
provider: ProviderName,
customApiKeyEnv?: string | string[],
customBaseUrlEnv?: string,
): void {
// Handle array of env var names - at least one must be set
if (Array.isArray(customApiKeyEnv)) {
@@ -604,9 +609,12 @@ function validateProviderCredentials(
}
}
// Azure requires either AZURE_BASE_URL or AZURE_RESOURCE_NAME in addition to API key
// Azure requires either AZURE_BASE_URL or AZURE_RESOURCE_NAME in addition
// to API key, or a server model's own URL variable (an admin panel entry)
if (provider === "azure") {
const hasBaseUrl = !!process.env.AZURE_BASE_URL
const hasBaseUrl =
!!process.env.AZURE_BASE_URL ||
!!(customBaseUrlEnv && process.env[customBaseUrlEnv])
const hasResourceName = !!process.env.AZURE_RESOURCE_NAME
if (!hasBaseUrl && !hasResourceName) {
throw new Error(
@@ -879,13 +887,20 @@ export function getAIModel(clientOverrides?: ClientOverrides): ModelConfig {
// Only validate server credentials if client isn't providing their own API key
if (!isClientOverride) {
validateProviderCredentials(provider, overrides?.apiKeyEnv)
validateProviderCredentials(
provider,
overrides?.apiKeyEnv,
overrides?.baseUrlEnv,
)
}
console.log(`[AI Provider] Initializing ${provider} with model: ${modelId}`)
// Requests to a base URL the client chose must not follow redirects
const guardedFetch = overrides?.baseUrl ? redirectGuardedFetch() : undefined
const guardedFetch =
overrides?.baseUrl && !overrides.trustedBaseUrl
? redirectGuardedFetch()
: undefined
// Build provider-specific options from environment variables
let providerOptions = buildProviderOptions(provider, modelId)
let model: LanguageModel
@@ -1091,20 +1106,30 @@ export function getAIModel(clientOverrides?: ClientOverrides): ModelConfig {
return { model, providerOptions, modelId, provider }
}
/**
* The deployment's EdgeOne Pages function, as an absolute URL (the SDK
* needs one), under the deployment's base path
*/
export function edgeOneEndpoint(req: Request): string {
const origin = req.headers.get("origin") || new URL(req.url).origin
return `${origin}${getApiEndpoint("/api/edgeai")}`
}
/**
* The server's <P>_BASE_URL for a provider, which getAIModel uses for a
* server model without a URL variable of its own (an admin panel entry
* without a URL). Bedrock, EdgeOne and Ollama (the panel writes
* OLLAMA_BASE_URL itself) have none.
* without a URL). None for Bedrock and EdgeOne, and none for Ollama and
* Vertex AI, whose variables the panel writes itself (before a save they
* still hold the entry's previous URL).
*/
export function globalBaseUrl(provider: ProviderName): string | undefined {
if (["bedrock", "edgeone", "ollama"].includes(provider)) return undefined
if (["bedrock", "edgeone", "ollama", "vertexai"].includes(provider)) {
return undefined
}
const name =
provider === "vertexai"
? "GOOGLE_VERTEX_BASE_URL"
: provider === "gateway"
? "AI_GATEWAY_BASE_URL"
: `${provider.toUpperCase()}_BASE_URL`
provider === "gateway"
? "AI_GATEWAY_BASE_URL"
: `${provider.toUpperCase()}_BASE_URL`
return process.env[name] || undefined
}
+4 -1
View File
@@ -64,10 +64,13 @@ interface QuotaCheckResult {
* Check all quotas and increment request count atomically.
* Uses composite key (PK=user, SK=date) for per-day tracking.
* Each day automatically gets a new item - no explicit reset needed.
* A request limit of 0 means none; increment 0 checks the limits without
* counting a request (the screenshot check).
*/
export async function checkAndIncrementRequest(
ip: string,
limits: QuotaLimits,
increment = 1,
): Promise<QuotaCheckResult> {
// Skip if quota tracking not enabled
if (!client || !TABLE) {
@@ -99,7 +102,7 @@ export async function checkAndIncrementRequest(
attribute_not_exists(tpmCount) OR tpmCount < :tpmLimit)
`,
ExpressionAttributeValues: {
":one": { N: "1" },
":one": { N: String(increment) },
":minute": { S: currentMinute },
":reqLimit": { N: String(limits.requests || 999999) },
":tokenLimit": { N: String(limits.tokens || 999999) },
+2
View File
@@ -187,6 +187,8 @@
"failedToRecordFeedback": "Failed to record your feedback. Please try again.",
"storageUpdateFailed": "Chat cleared but browser storage could not be updated",
"sessionSaveFailed": "Could not save this chat. Browser storage may be full: delete old chats from history and try again.",
"sessionSaveFailedLeave": "Could not save this chat. Browser storage may be full. You can go on without saving it, then delete old chats from the list in the new chat.",
"continueWithoutSaving": "Continue without saving",
"llm": {
"invalid_api_key": "The provider rejected the API key. Check it in model settings.",
"forbidden": "The provider refused the request. The key may not have access to this model or region.",
+2
View File
@@ -187,6 +187,8 @@
"failedToRecordFeedback": "フィードバックの記録に失敗しました。もう一度お試しください。",
"storageUpdateFailed": "チャットはクリアされましたが、ブラウザストレージを更新できませんでした",
"sessionSaveFailed": "このチャットを保存できませんでした。ブラウザのストレージがいっぱいの可能性があります。履歴から古いチャットを削除して、もう一度お試しください。",
"sessionSaveFailedLeave": "このチャットを保存できませんでした。ブラウザのストレージがいっぱいの可能性があります。保存せずに続けて、新しいチャットの一覧から古いチャットを削除できます。",
"continueWithoutSaving": "保存せずに続ける",
"llm": {
"invalid_api_key": "プロバイダーが API キーを拒否しました。モデル設定で確認してください。",
"forbidden": "プロバイダーがリクエストを拒否しました。このキーにはこのモデルまたはリージョンの利用権限がない可能性があります。",
+2
View File
@@ -187,6 +187,8 @@
"failedToRecordFeedback": "記錄您的回饋失敗。請重試。",
"storageUpdateFailed": "聊天已清除,但無法更新瀏覽器儲存空間",
"sessionSaveFailed": "無法儲存這個對話。瀏覽器儲存空間可能已滿,請在歷史紀錄裡刪除舊對話後重試。",
"sessionSaveFailedLeave": "無法儲存這個對話,瀏覽器儲存空間可能已滿。可以不儲存它、直接繼續,再在新對話的列表裡刪除舊對話。",
"continueWithoutSaving": "不儲存,繼續",
"llm": {
"invalid_api_key": "服務商拒絕了這個 API Key,請在模型設定中檢查。",
"forbidden": "服務商拒絕了這次請求。這個 Key 可能沒有使用該模型或該地區的權限。",
+2
View File
@@ -187,6 +187,8 @@
"failedToRecordFeedback": "记录您的反馈失败。请重试。",
"storageUpdateFailed": "聊天已清除,但无法更新浏览器存储",
"sessionSaveFailed": "无法保存这个对话。浏览器存储空间可能已满,请在历史记录里删除旧对话后重试。",
"sessionSaveFailedLeave": "无法保存这个对话,浏览器存储空间可能已满。可以不保存它、直接继续,再在新对话的列表里删除旧对话。",
"continueWithoutSaving": "不保存,继续",
"llm": {
"invalid_api_key": "服务商拒绝了这个 API Key,请在模型设置里检查。",
"forbidden": "服务商拒绝了这次请求。这个 Key 可能没有使用该模型或该地区的权限。",
+7 -2
View File
@@ -199,13 +199,18 @@ export async function deleteSession(id: string): Promise<void> {
}
export async function getSessionCount(): Promise<number> {
if (!isIndexedDBAvailable()) return 0
return (await readSessionCount()) ?? 0
}
/** The number of saved chats, or null when it could not be read */
export async function readSessionCount(): Promise<number | null> {
if (!isIndexedDBAvailable()) return null
try {
const db = await getDB()
return await db.count(STORE_NAME)
} catch (error) {
console.error("Failed to get session count:", error)
return 0
return null
}
}
+36 -8
View File
@@ -118,24 +118,52 @@ export function allowPrivateUrls(): boolean {
/** A redirect the guard below refused; its text is safe to show */
export class RedirectRefusedError extends Error {
constructor() {
super("Redirects are not allowed for custom base URLs")
constructor(message = "Redirects are not allowed for custom base URLs") {
super(message)
this.name = "RedirectRefusedError"
}
}
const MAX_REDIRECTS = 5
/**
* A fetch for requests to a base URL the client chose. With private URLs
* blocked, a public URL could still redirect the request to an internal
* host, so redirects are refused. Undefined when private URLs are allowed.
* host, so redirects are refused. With private URLs allowed but the quota
* on (DYNAMODB_QUOTA_TABLE), a request to a private address counts as the
* server's: redirects are followed only to public addresses, or a public
* URL could reach the server's own network uncounted. Undefined otherwise.
*/
export function redirectGuardedFetch(): typeof fetch | undefined {
if (allowPrivateUrls()) return undefined
const blockAll = !allowPrivateUrls()
if (!blockAll && !process.env.DYNAMODB_QUOTA_TABLE) return undefined
return async (input, init) => {
const response = await fetch(input, { ...init, redirect: "manual" })
if (response.status >= 300 && response.status < 400) {
throw new RedirectRefusedError()
let url = input instanceof Request ? input.url : String(input)
let next = init
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
const response = await fetch(url, { ...next, redirect: "manual" })
const location = response.headers.get("location")
if (response.status < 300 || response.status >= 400 || !location) {
return response
}
if (blockAll) throw new RedirectRefusedError()
url = new URL(location, url).toString()
if (await isPrivateUrl(url)) {
throw new RedirectRefusedError(
"Redirects to private addresses are not allowed",
)
}
// As fetch itself does: 303, and 301 or 302 after a POST, go on
// as a GET without the body
const method = (next?.method ?? "GET").toUpperCase()
if (
response.status === 303 ||
((response.status === 301 || response.status === 302) &&
method === "POST")
) {
next = { ...next, method: "GET", body: undefined }
}
}
return response
throw new RedirectRefusedError("Too many redirects")
}
}
+5 -2
View File
@@ -1,6 +1,7 @@
import { type ClassValue, clsx } from "clsx"
import * as pako from "pako"
import { twMerge } from "tailwind-merge"
import { hasCells } from "@/packages/mcp-server/src/pages.ts"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
@@ -17,12 +18,14 @@ export function cn(...inputs: ClassValue[]) {
export const MIN_REAL_DIAGRAM_LENGTH = 300
/**
* Check if diagram XML represents a real diagram (not just empty template).
* Check if diagram XML represents a real diagram (not just empty template):
* it has a shape (however short), or is long enough to hold pages worth
* keeping.
* @param xml - The diagram XML string to check
* @returns true if the XML is a real diagram with content
*/
export function isRealDiagram(xml: string | undefined | null): boolean {
return !!xml && xml.length > MIN_REAL_DIAGRAM_LENGTH
return !!xml && (hasCells(xml) || xml.length > MIN_REAL_DIAGRAM_LENGTH)
}
// ============================================================================
+25
View File
@@ -0,0 +1,25 @@
/**
* A queue for tool handlers: each call waits until the previous one ended.
* A tool call the client cancelled while it waited (the MCP SDK aborts its
* extra.signal, the handler's last argument) is skipped.
*/
export function createExclusive() {
let tail: Promise<unknown> = Promise.resolve()
return function exclusive<T extends (...args: any[]) => Promise<unknown>>(
handler: T,
): T {
return ((...args: unknown[]) => {
const extra = args.at(-1) as { signal?: AbortSignal } | undefined
const run = tail.then(() =>
extra?.signal?.aborted
? {
content: [{ type: "text", text: "Cancelled." }],
isError: true,
}
: handler(...args),
)
tail = run.catch(() => {})
return run
}) as T
}
}
+4 -13
View File
@@ -3,7 +3,6 @@
* Stores {xml, svg} entries in a circular buffer
*/
import { contentFingerprint } from "./edit-gate.ts"
import { log } from "./logger.ts"
const MAX_HISTORY = 20
@@ -17,14 +16,6 @@ interface HistoryEntry {
let nextEntryId = 0
const historyStore = new Map<string, HistoryEntry[]>()
// The same pages and cells; a document without pages has an empty
// fingerprint and is compared as text only
function sameDiagram(a: string, b: string): boolean {
if (a === b) return true
const fingerprint = contentFingerprint(a)
return fingerprint !== "" && fingerprint === contentFingerprint(b)
}
export function addHistory(sessionId: string, xml: string, svg = ""): number {
let history = historyStore.get(sessionId)
if (!history) {
@@ -32,10 +23,10 @@ export function addHistory(sessionId: string, xml: string, svg = ""): number {
historyStore.set(sessionId, history)
}
// Dedupe: skip if same as last entry, also when only re-serialized
// (the browser's copy of the same diagram)
// Dedupe: skip if same as last entry (a change of page settings or
// background only is a new version)
const last = history[history.length - 1]
if (last && sameDiagram(last.xml, xml)) {
if (last && last.xml === xml) {
if (svg && !last.svg) last.svg = svg
return history.length - 1
}
@@ -79,7 +70,7 @@ export function updateLastHistorySvg(
const history = historyStore.get(sessionId)
if (!history || history.length === 0) return false
const last = history[history.length - 1]
if (!last.svg && sameDiagram(last.xml, shownXml)) {
if (!last.svg && last.xml === shownXml) {
last.svg = svg
return true
}
+36 -11
View File
@@ -20,17 +20,28 @@ function readBody(
// across two chunks.
const chunks: Buffer[] = []
let size = 0
let tooLarge = false
req.on("data", (chunk: Buffer) => {
if (tooLarge) return
size += chunk.length
if (size > MAX_BODY_BYTES) {
res.writeHead(413, { "Content-Type": "application/json" })
res.end(JSON.stringify({ error: "Payload too large" }))
req.destroy()
// Read the rest without keeping it and answer at the end: a
// connection closed mid-upload reaches the browser as a network
// error, without this answer
tooLarge = true
chunks.length = 0
return
}
chunks.push(chunk)
})
req.on("end", () => cb(Buffer.concat(chunks).toString("utf8")))
req.on("end", () => {
if (tooLarge) {
res.writeHead(413, { "Content-Type": "application/json" })
res.end(JSON.stringify({ error: "Payload too large" }))
return
}
cb(Buffer.concat(chunks).toString("utf8"))
})
}
import { contentFingerprint } from "./edit-gate.ts"
@@ -125,6 +136,8 @@ interface SessionState {
// Version of the last write the browser did not make itself (AI edit,
// restore). A browser push based on an older version is rejected.
serverVersion?: number
// The XML of that write: what a thumbnail taken after loading it shows
serverXml?: string
lastUpdated: Date
lastPolled?: number // Last browser poll; an open tab keeps the session alive
svg?: string // Cached SVG from last browser save
@@ -192,11 +205,15 @@ export function setState(
version: newVersion,
stateId: existing?.stateId ?? randomUUID(),
serverVersion: fromBrowser ? existing?.serverVersion : newVersion,
serverXml: fromBrowser ? existing?.serverXml : xml,
lastUpdated: new Date(),
lastPolled: existing?.lastPolled,
// The image of this XML, never an older one's: a write without an
// image (AI write, sync reply) leaves none until the browser sends it
svg: svg || undefined,
// image (AI write, sync reply) leaves none until the browser sends
// it, unless it is the same XML
svg:
svg ||
(existing && existing.xml === xml ? existing.svg : undefined),
syncRequested: undefined, // Clear sync request when browser pushes state
exportFormat: existing?.exportFormat, // Preserve pending export request
exportXml: existing?.exportXml, // Preserve pending projection
@@ -696,18 +713,26 @@ function handleHistorySvgApi(
readBody(req, res, (body) => {
try {
const { sessionId, svg } = JSON.parse(body)
const { sessionId, svg, stateId, version } = JSON.parse(body)
if (!sessionId || !svg) {
res.writeHead(400, { "Content-Type": "application/json" })
res.end(JSON.stringify({ error: "sessionId and svg required" }))
return
}
// The browser took it of the diagram it just loaded: the state
// The browser took it of the server write it loaded, named by
// the state and version. One that arrives after the next server
// write, or for a state since lost, is dropped; a browser write
// since (a sync reply) leaves that write's image valid.
const state = stateStore.get(sessionId)
if (state) {
updateLastHistorySvg(sessionId, svg, state.xml)
state.svg = svg
if (
state &&
state.stateId === stateId &&
state.serverVersion === version &&
state.serverXml !== undefined
) {
updateLastHistorySvg(sessionId, svg, state.serverXml)
if (state.xml === state.serverXml) state.svg = svg
}
res.writeHead(200, { "Content-Type": "application/json" })
res.end(JSON.stringify({ success: true }))
+81 -48
View File
@@ -28,6 +28,7 @@ import { installDomPolyfill } from "./dom.ts"
import { DRAWING_GUIDE } from "./drawing-guide.ts"
import { editDiagram, targetPageXml } from "./edit-diagram.ts"
import { checkEditGate, markPageSeen } from "./edit-gate.ts"
import { createExclusive } from "./exclusive.ts"
import { addHistory } from "./history.ts"
import {
type ExportFormat,
@@ -142,6 +143,18 @@ const server = new McpServer(
{ instructions: INSTRUCTIONS },
)
// The tools that write the diagram, and start_session, run one at a time:
// two writes at once would both build on the same document, and the second
// would drop the first one's change. start_session in the queue keeps a
// session switch from landing in the middle of a write.
const exclusive = createExclusive()
const registerWriteTool = ((name: string, config: any, handler: any) =>
server.registerTool(
name,
config,
exclusive(handler),
)) as typeof server.registerTool
// Shared Zod schema fragment for page-targeting parameters.
// Every multi-page-aware tool reuses these three optional fields so the LLM
// learns one consistent interface.
@@ -254,7 +267,7 @@ server.registerTool(
)
// Tool: start_session
server.registerTool(
registerWriteTool(
"start_session",
{
title: "Start session",
@@ -312,7 +325,7 @@ server.registerTool(
)
// Tool: create_new_diagram
server.registerTool(
registerWriteTool(
"create_new_diagram",
{
title: "Create new diagram",
@@ -430,7 +443,7 @@ Rules: cells are siblings (never nested), ids are unique per page and start from
)
// Tool: load_diagram
server.registerTool(
registerWriteTool(
"load_diagram",
{
title: "Load .drawio file",
@@ -549,7 +562,7 @@ server.registerTool(
)
// Tool: edit_diagram
server.registerTool(
registerWriteTool(
"edit_diagram",
{
title: "Edit diagram",
@@ -791,13 +804,15 @@ server.registerTool(
}
}
// start_session may replace currentSession while this waits
const session = currentSession
// Request browser to push fresh state and wait for it (an
// expired session first gets its saved file back to sync)
let staleNote = ""
restoreSavedSession(currentSession.id)
const syncRequested = requestSync(currentSession.id)
restoreSavedSession(session.id)
const syncRequested = requestSync(session.id)
if (syncRequested) {
const synced = await waitForSync(currentSession.id)
const synced = await waitForSync(session.id)
if (!synced) {
log.warn("get_diagram: sync timeout - state may be stale")
staleNote =
@@ -808,13 +823,13 @@ server.registerTool(
// Fetch latest state from browser, re-normalising to mxfile so a
// bare <mxGraphModel> pushed back by the embed/sync path doesn't
// strip page structure (see edit_diagram for the same guard).
const browserState = sessionState(currentSession.id)
const browserState = sessionState(session.id)
if (browserState?.xml) {
currentSession.xml =
session.xml =
normalizeToMxfile(browserState.xml) ?? browserState.xml
}
if (!currentSession.xml) {
if (!session.xml) {
return {
content: [
{
@@ -834,8 +849,8 @@ server.registerTool(
// The model is now looking at the current state. Record the raw
// store value — the gate's fast path is plain string equality
// against the store, with a structural comparison as fallback.
const liveXml = browserState?.xml || currentSession.xml
const doc = parseMxfile(currentSession.xml)
const liveXml = browserState?.xml || session.xml
const doc = parseMxfile(session.xml)
const pages = doc ? listPagesFromDoc(doc) : []
const pageList = pages.length
? `Pages (${pages.length}): ${pages.map((p) => `[${p.index}] id=${p.id} name="${p.name}" cells=${p.cellCount}`).join(" | ")}`
@@ -843,19 +858,19 @@ server.registerTool(
// No selector → return full mxfile
if (!hasPageSelector(pageSelector)) {
currentSession.lastSeenXml = liveXml
session.lastSeenXml = liveXml
return {
content: [
{
type: "text",
text: `Current diagram XML:\n\n${currentSession.xml}\n\n${pageList}${staleNote}`,
text: `Current diagram XML:\n\n${session.xml}\n\n${pageList}${staleNote}`,
},
],
}
}
// Selector → return a single-page projection
const projection = projectPage(currentSession.xml, pageSelector)
const projection = projectPage(session.xml, pageSelector)
if (!projection.ok) {
return {
content: [
@@ -872,13 +887,13 @@ server.registerTool(
}
// One page shown counts for all only if the others are as the
// model saw them last
currentSession.lastSeenXml = markPageSeen(
currentSession.lastSeenXml,
session.lastSeenXml = markPageSeen(
session.lastSeenXml,
liveXml,
pageSelector,
)
const otherPagesNote =
currentSession.lastSeenXml === liveXml
session.lastSeenXml === liveXml
? ""
: `\n\nNote: ${OTHER_PAGES_UNSEEN} Call get_diagram without a page selector before editing.`
return {
@@ -1148,15 +1163,48 @@ server.registerTool(
}
}
// start_session may replace currentSession while this waits
const session = currentSession
// Detect format from extension if not specified
const lowerPath = path.toLowerCase()
const detectedFormat =
format ||
(lowerPath.endsWith(".drawio.svg")
? "drawio.svg"
: lowerPath.endsWith(".png")
? "png"
: lowerPath.endsWith(".svg")
? "svg"
: "drawio")
// The .drawio file is written from the state, so get the
// user's latest edits into it first, as get_diagram does (the
// images are made by the browser from its canvas)
let syncNote = ""
if (detectedFormat === "drawio") {
restoreSavedSession(session.id)
if (!requestSync(session.id)) {
syncNote =
"\n\nNote: the preview was not reachable, so the file may not include the user's latest manual edits."
} else if (!(await waitForSync(session.id))) {
log.warn(
"export_diagram: sync timeout - state may be stale",
)
syncNote =
"\n\nNote: the browser did not respond, so the file may not include the user's latest manual edits (is the preview tab open?)."
}
}
// Fetch latest state, re-normalised to mxfile so a page
// selector works on a bare <mxGraphModel> pushed by the browser
const browserState = sessionState(currentSession.id)
const browserState = sessionState(session.id)
if (browserState?.xml) {
currentSession.xml =
session.xml =
normalizeToMxfile(browserState.xml) ?? browserState.xml
}
if (!currentSession.xml) {
if (!session.xml) {
return {
content: [
{
@@ -1177,18 +1225,6 @@ server.registerTool(
const fs = await import("node:fs/promises")
const nodePath = await import("node:path")
// Detect format from extension if not specified
const lowerPath = path.toLowerCase()
const detectedFormat =
format ||
(lowerPath.endsWith(".drawio.svg")
? "drawio.svg"
: lowerPath.endsWith(".png")
? "png"
: lowerPath.endsWith(".svg")
? "svg"
: "drawio")
// .drawio path - write XML directly (no browser round-trip).
if (detectedFormat === "drawio") {
let filePath = path
@@ -1197,12 +1233,9 @@ server.registerTool(
}
const absolutePath = nodePath.resolve(filePath)
let outXml = currentSession.xml
let outXml = session.xml
if (hasPageSelector(pageSelector)) {
const projection = projectPage(
currentSession.xml,
pageSelector,
)
const projection = projectPage(session.xml, pageSelector)
if (!projection.ok) {
return {
content: [
@@ -1226,7 +1259,7 @@ server.registerTool(
content: [
{
type: "text",
text: `Diagram exported successfully!\n\nFile: ${absolutePath}\nSize: ${outXml.length} characters`,
text: `Diagram exported successfully!\n\nFile: ${absolutePath}\nSize: ${outXml.length} characters${syncNote}`,
},
],
}
@@ -1248,7 +1281,7 @@ server.registerTool(
const browserFormat =
detectedFormat === "drawio.svg" ? "xmlsvg" : detectedFormat
const state = sessionState(currentSession.id)
const state = sessionState(session.id)
if (!state) {
return {
content: [
@@ -1260,8 +1293,8 @@ server.registerTool(
isError: true,
}
}
if (previewStalled(currentSession.id)) {
return previewStalledError(currentSession.id)
if (previewStalled(session.id)) {
return previewStalledError(session.id)
}
// -----------------------------------------------------------------
@@ -1281,10 +1314,10 @@ server.registerTool(
let projectionXml: string | undefined
let pngPageId: string | undefined
if (hasPageSelector(pageSelector) && detectedFormat === "png") {
pngPageId = pageIdFor(currentSession.xml, pageSelector)
pngPageId = pageIdFor(session.xml, pageSelector)
}
if (hasPageSelector(pageSelector) && !pngPageId) {
const projection = projectPage(currentSession.xml, pageSelector)
const projection = projectPage(session.xml, pageSelector)
if (!projection.ok) {
return {
content: [
@@ -1303,7 +1336,7 @@ server.registerTool(
}
const exportData = await exportViaBrowser(
currentSession.id,
session.id,
browserFormat,
projectionXml,
pngPageId ? { pageId: pngPageId } : undefined,
@@ -1493,7 +1526,7 @@ server.registerTool(
)
// Tool: add_page
server.registerTool(
registerWriteTool(
"add_page",
{
title: "Add page",
@@ -1610,7 +1643,7 @@ server.registerTool(
)
// Tool: rename_page
server.registerTool(
registerWriteTool(
"rename_page",
{
title: "Rename page",
@@ -1692,7 +1725,7 @@ server.registerTool(
)
// Tool: delete_page
server.registerTool(
registerWriteTool(
"delete_page",
{
title: "Delete page",
+6 -4
View File
@@ -50,14 +50,16 @@ export function decompressPageContent(compressed: string): string | null {
* any compressed pages.
*/
export function parseDrawioFileContent(content: string): LoadResult {
const trimmed = content.trim()
let trimmed = content.trim()
if (!trimmed) return { ok: false, error: "File is empty." }
if (isMxGraphModel(trimmed)) {
const normalized = normalizeToMxfile(trimmed)
return normalized
? { ok: true, xml: normalized }
: { ok: false, error: "Failed to parse <mxGraphModel> XML." }
if (!normalized) {
return { ok: false, error: "Failed to parse <mxGraphModel> XML." }
}
// Parsed below like any <mxfile>, so a broken model is an error
trimmed = normalized
}
if (!isMxFile(trimmed)) {
return {
+4 -6
View File
@@ -3,6 +3,7 @@
* and the web app's display_diagram tool.
*/
import { normalizeToMxfile, wrapCellsInModel } from "./pages.ts"
import { readAttributes } from "./xml-attributes.ts"
import { validateAndFixXml } from "./xml-validation.ts"
export type NewDiagram =
@@ -23,12 +24,9 @@ export function reservedIdError(input: string): string | null {
/<(mxCell|UserObject|object)\b((?:\s+[\w:.-]+\s*=\s*(?:"[^"]*"|'[^']*'))*)\s*\/?>/g,
)
for (const [, tag, attrText] of tags) {
const attrs = new Map<string, string>()
for (const [, name, double, single] of attrText.matchAll(
/([\w:.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g,
)) {
attrs.set(name, double ?? single)
}
const attrs = new Map(
readAttributes(attrText).map((a) => [a.name, a.value]),
)
const id = attrs.get("id")
if (id !== "0" && id !== "1") continue
// A wrapper's id is its cell's; an mxCell counts as a shape or edge
+7 -8
View File
@@ -17,6 +17,7 @@
* - how to add/rename/delete pages without re-parsing ad-hoc.
*/
import { readAttributes } from "./xml-attributes.ts"
import { getXmlSyntaxError } from "./xml-syntax.ts"
export interface PageInfo {
@@ -125,15 +126,13 @@ export function wrapCellsInModel(xml: string): string {
if (end !== -1 && /^(\s*<\/[^>]+>)*\s*$/.test(content.slice(end))) {
content = content.slice(0, end)
}
// The root cells come with the wrapper (a label holding id='1' is not
// an id)
content = content
.replace(
/<mxCell[^>]*\bid\s*=\s*["']0["'][^>]*(?:\/>|>\s*<\/mxCell>)/g,
"",
)
.replace(
/<mxCell[^>]*\bid\s*=\s*["']1["'][^>]*(?:\/>|>\s*<\/mxCell>)/g,
"",
)
.replace(/<mxCell\b[^>]*?(?:\/>|>\s*<\/mxCell>)/g, (cell) => {
const id = readAttributes(cell).find((a) => a.name === "id")?.value
return id === "0" || id === "1" ? "" : cell
})
.trim()
return `<mxGraphModel><root>${ROOT_CELLS}${content}</root></mxGraphModel>`
}
+25 -9
View File
@@ -38,6 +38,16 @@ export function defaultDataDir(): string | null {
return dir ? expandHome(dir) : join(homedir(), ".next-ai-drawio")
}
/** The file surely does not exist (not merely out of reach) */
function isGone(path: string): boolean {
try {
statSync(path)
return false
} catch (error) {
return (error as NodeJS.ErrnoException).code === "ENOENT"
}
}
export class Autosaver {
private pending = new Map<
string,
@@ -57,22 +67,23 @@ export class Autosaver {
// Saved files that could not be read back: never written over, since
// the session then shows something else than what they hold. Cleared
// once the file is read, or is gone.
// once the file is read, or is surely gone (a folder without permission
// also makes a file look missing).
private unreadable = new Set<string>()
/** The session's saved diagram, or null. */
load(sessionId: string): string | null {
const path = this.pathFor(sessionId)
if (!path) return null
if (!existsSync(path)) {
this.unreadable.delete(path)
return null
}
try {
const xml = readFileSync(path, "utf-8")
this.unreadable.delete(path)
return xml
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
this.unreadable.delete(path)
return null
}
log.warn(`Could not read the saved diagram ${path}: ${error}`)
this.unreadable.add(path)
return null
@@ -102,10 +113,15 @@ export class Autosaver {
const path = this.pathFor(sessionId)
if (!entry || !this.dir || !path) return
if (this.unreadable.has(path)) {
log.warn(
`Not saving ${path}: it could not be read, so it may hold work this session does not show`,
)
return
// Deleted meanwhile: nothing left to protect
if (isGone(path)) {
this.unreadable.delete(path)
} else {
log.warn(
`Not saving ${path}: it could not be read, so it may hold work this session does not show`,
)
return
}
}
try {
const isNew = !existsSync(path)
+50 -11
View File
@@ -7,11 +7,16 @@ let stateId = null;
// the last one the server has
let latestXml = null;
let pushFailing = false; // the last push could not reach the server
// After recovery replaced the canvas, until draw.io reports the load: an
// autosave still on its way belongs to the canvas being replaced
let awaitingLoad = false;
let pollSeq = 0, lastHandledPoll = 0; // polls overlap; older answers are dropped
let pendingSvgExport = null;
let pendingSvgBase = 0; // version the pending autosave was based on
let pendingSvgStateId = null; // and the state it belonged to
let pendingAiSvg = false;
// The latest thumbnail export of a loaded server write: its number (echoed
// by draw.io), the state and version it showed, and the XML loaded
let thumbExportSeq = 0, thumbExport = null;
let pendingMcpExport = null; // 'png', 'svg' or 'xmlsvg' when MCP requested export
let mcpExportSeq = 0; // number of the latest MCP export
let mcpExportId = null; // the server's id for it, sent back with the result
@@ -26,11 +31,16 @@ window.addEventListener('message', (e) => {
if (msg.event === 'init') {
isReady = true;
if (pendingXml) { loadDiagram(pendingXml); pendingXml = null; }
} else if (msg.event === 'load') {
awaitingLoad = false;
} else if ((msg.event === 'save' || msg.event === 'autosave') && msg.xml) {
// Ignore autosave while a single-page projection is on screen
// for a page-targeted export — otherwise we'd push the
// transient projection back as the canonical session state.
if (projectionExportActive) return;
// An edit of the canvas that recovery is replacing: kept in
// History, never over the recovered diagram
if (awaitingLoad) { pushState(msg.xml, '', currentVersion, 'recover'); return; }
// Also an edit undone back to what the server has
latestXml = msg.xml;
if (msg.xml === lastXml) return;
@@ -109,17 +119,21 @@ window.addEventListener('message', (e) => {
// Handle SVG export
let svg = msg.data;
if (!svg.startsWith('data:')) svg = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg)));
if (pendingSvgExport) {
const xml = pendingSvgExport;
pendingSvgExport = null;
pushState(xml, svg, pendingSvgBase, 'edit', pendingSvgStateId);
} else if (pendingAiSvg) {
pendingAiSvg = false;
if (msg.message && msg.message.thumbExport) {
// Only for the latest load, and only if the canvas still
// shows it: the export pictures the canvas as it is now
const t = thumbExport;
if (!t || msg.message.thumbExport !== t.n || latestXml !== t.xml) return;
thumbExport = null;
fetch('/api/history-svg', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, svg })
body: JSON.stringify({ sessionId, svg, stateId: t.stateId, version: t.version })
}).catch(() => {});
} else if (pendingSvgExport) {
const xml = pendingSvgExport;
pendingSvgExport = null;
pushState(xml, svg, pendingSvgBase, 'edit', pendingSvgStateId);
}
}
} catch {}
@@ -131,9 +145,12 @@ function loadDiagram(xml, capturePreview = false) {
latestXml = xml;
iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml, autosave: 1 }), '*');
if (capturePreview) {
// A server write: currentVersion is its version
const t = { n: ++thumbExportSeq, stateId, version: currentVersion, xml };
thumbExport = t;
setTimeout(() => {
pendingAiSvg = true;
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*');
if (thumbExport !== t) return; // a newer load takes its own
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg', thumbExport: t.n }), '*');
}, 500);
}
}
@@ -177,6 +194,19 @@ async function pushState(xml, svg = '', baseVersion = currentVersion, source = '
if (sid !== stateId) return;
currentVersion = d.version;
lastXml = xml;
// The canvas changed while this edit was on its way, to
// something no pending autosave will send (an undo back to the
// previous version): send it now. A sync reply is draw.io's
// export of the canvas, in another format than its autosave.
if (latestXml && latestXml !== xml && pendingSvgExport !== latestXml && source === 'edit') {
pushState(latestXml);
}
}
// Over the server's size limit: the image is most of it, so try once
// without it
else if (r.status === 413) {
if (svg) pushState(xml, '', baseVersion, source, sid);
else showNotice('This diagram is too large to save to the MCP server (over 10 MB). Use Download to keep it.');
}
// 409: the AI wrote a newer version, or the server lost the state
// this push was based on; the next poll sorts it out
@@ -216,6 +246,7 @@ function recoverState(s) {
// server was down are saved now)
if (projectionShown && mine) {
iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml: mine, autosave: 1 }), '*');
expectLoad();
}
if (mine && mine !== s.xml) pushState(mine, '', s.version);
} else {
@@ -223,10 +254,18 @@ function recoverState(s) {
// missed, a saved file): show that, and keep this tab's copy in
// History unless it is the same
loadDiagram(s.xml, true);
expectLoad();
if (mine && mine !== s.xml) pushState(mine, '', s.version, 'recover');
}
}
// Until draw.io reports the load (its messages come in order), an autosave
// is from the canvas being replaced; in case no report comes, not for long
function expectLoad() {
awaitingLoad = true;
setTimeout(() => { awaitingLoad = false; }, 5000);
}
let pendingSyncExport = false;
let pendingSyncBase = 0; // version the pending sync export was taken at
let pendingSyncStateId = null; // and the state it belonged to
@@ -368,7 +407,7 @@ saveConfirmBtn.onclick = () => {
saveConfirmBtn.textContent = 'Exporting...';
if (format === 'drawio') {
// Use lastXml directly instead of requesting export (avoids race with SVG exports).
// Use the XML directly instead of requesting export (avoids race with SVG exports).
// session.xml is canonically <mxfile> after the multi-page refactor,
// so no wrapper injection is needed. The legacy fallback below
// remains only for documents that somehow slipped past
+27
View File
@@ -0,0 +1,27 @@
/**
* The attributes of one tag as written: name="value" or name='value'
* pairs. A quoted value is read whole, so text inside it such as
* value="Use parent='1'" is never taken for an attribute.
*/
export interface TagAttribute {
name: string
value: string
// The attribute's text in the tag, with the whitespace before it
start: number
end: number
}
export function readAttributes(tag: string): TagAttribute[] {
const attributes: TagAttribute[] = []
for (const m of tag.matchAll(
/\s*([A-Za-z_:][\w:.-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g,
)) {
attributes.push({
name: m[1],
value: m[2] ?? m[3],
start: m.index,
end: m.index + m[0].length,
})
}
return attributes
}
+18 -27
View File
@@ -3,6 +3,7 @@
* Copied from lib/utils.ts to avoid cross-package imports
*/
import { readAttributes } from "./xml-attributes.ts"
import { getXmlSyntaxError } from "./xml-syntax.ts"
// ============================================================================
@@ -156,16 +157,10 @@ function replaceInOpeningTags(
/** Check for duplicate structural attributes in a tag */
function checkDuplicateAttributes(xml: string): string | null {
const structuralSet = new Set(STRUCTURAL_ATTRS)
const tagPattern = /<[^>]+>/g
let tagMatch
while ((tagMatch = tagPattern.exec(xml)) !== null) {
const tag = tagMatch[0]
const attrPattern = /\s([a-zA-Z_:][a-zA-Z0-9_:.-]*)\s*=/g
for (const [tag] of xml.matchAll(/<[^>]+>/g)) {
const attributes = new Map<string, number>()
let attrMatch
while ((attrMatch = attrPattern.exec(tag)) !== null) {
const attrName = attrMatch[1]
attributes.set(attrName, (attributes.get(attrName) || 0) + 1)
for (const { name } of readAttributes(tag)) {
attributes.set(name, (attributes.get(name) || 0) + 1)
}
const duplicates = Array.from(attributes.entries())
.filter(([name, count]) => count > 1 && structuralSet.has(name))
@@ -596,27 +591,23 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
// 3. Fix duplicate attributes
let dupAttrFixed = false
const structural = new Set(STRUCTURAL_ATTRS)
fixed = fixed.replace(/<[^>]+>/g, (tag) => {
let newTag = tag
for (const attr of STRUCTURAL_ATTRS) {
const attrRegex = new RegExp(
`\\s${attr}\\s*=\\s*["'][^"']*["']`,
"gi",
)
const matches = tag.match(attrRegex)
if (matches && matches.length > 1) {
let firstKept = false
newTag = newTag.replace(attrRegex, (m) => {
if (!firstKept) {
firstKept = true
return m
}
dupAttrFixed = true
return ""
})
// Keep the first of each, drop the later ones
const seen = new Set<string>()
let newTag = ""
let last = 0
for (const attr of readAttributes(tag)) {
if (!structural.has(attr.name)) continue
if (!seen.has(attr.name)) {
seen.add(attr.name)
continue
}
newTag += tag.slice(last, attr.start)
last = attr.end
dupAttrFixed = true
}
return newTag
return newTag + tag.slice(last)
})
if (dupAttrFixed) {
fixes.push("Removed duplicate structural attributes")
@@ -0,0 +1,59 @@
/**
* Tests for the queue the write tools run in (index.ts registerWriteTool).
*/
import { describe, expect, it } from "vitest"
import { createExclusive } from "../src/exclusive.ts"
const extra = (signal = new AbortController().signal) => ({ signal })
const tick = () => new Promise((r) => setTimeout(r, 5))
describe("createExclusive", () => {
it("runs the calls one at a time, in order", async () => {
const exclusive = createExclusive()
const events: string[] = []
// Reads the document, waits, then writes it back
const addPage = exclusive(async (args: { name: string }, _extra) => {
events.push(`read ${args.name}`)
await tick()
events.push(`write ${args.name}`)
return { content: [] }
})
await Promise.all([
addPage({ name: "A" }, extra()),
addPage({ name: "B" }, extra()),
])
expect(events).toEqual(["read A", "write A", "read B", "write B"])
})
it("goes on after a call that threw", async () => {
const exclusive = createExclusive()
const failing = exclusive(async (_extra: unknown) => {
throw new Error("broken")
})
const working = exclusive(async (_extra: unknown) => ({ content: [] }))
const first = failing(extra())
const second = working(extra())
await expect(first).rejects.toThrow("broken")
await expect(second).resolves.toEqual({ content: [] })
})
it("skips a call cancelled while it waited", async () => {
const exclusive = createExclusive()
let ran = false
const slow = exclusive(async (_extra: unknown) => {
await tick()
return { content: [] }
})
const deletePage = exclusive(async (_extra: unknown) => {
ran = true
return { content: [] }
})
const cancel = new AbortController()
const first = slow(extra())
const second = deletePage(extra(cancel.signal))
cancel.abort()
await first
expect(await second).toMatchObject({ isError: true })
expect(ran).toBe(false)
})
})
+124 -9
View File
@@ -467,22 +467,82 @@ describe("history restore", () => {
const page = (cellId: string) =>
`<mxfile><diagram id="p" name="P"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="${cellId}" vertex="1" parent="1"/></root></mxGraphModel></diagram></mxfile>`
// A thumbnail the tab took after loading the server write at `version`
const thumbnail = (id: string, svg: string, version: number) =>
postJson("/api/history-svg", {
sessionId: id,
svg,
stateId: getState(id)?.stateId,
version,
})
it("gives a thumbnail only to the entry it shows", async () => {
const id = "mcp-history-thumb"
setState(id, page("shown"))
const version = setState(id, page("shown"))
// The last entry is another diagram (a tab's copy kept on recovery)
addHistory(id, page("other"))
await postJson("/api/history-svg", {
sessionId: id,
svg: "SVG-OF-SHOWN",
})
await thumbnail(id, "SVG-OF-SHOWN", version)
expect(getHistory(id).at(-1)?.svg).toBe("")
addHistory(id, page("shown"))
await thumbnail(id, "SVG-OF-SHOWN", version)
expect(getHistory(id).at(-1)?.svg).toBe("SVG-OF-SHOWN")
expect(getState(id)?.svg).toBe("SVG-OF-SHOWN")
})
it("drops a thumbnail that arrives after the next AI write", async () => {
const id = "mcp-history-thumb-late"
const first = setState(id, page("first"))
addHistory(id, page("first"))
setState(id, page("second"))
addHistory(id, page("second"))
await thumbnail(id, "SVG-OF-FIRST", first)
expect(getHistory(id).map((e) => e.svg)).toEqual(["", ""])
expect(getState(id)?.svg).toBeUndefined()
})
it("drops a thumbnail of a state the server has since lost", async () => {
const id = "mcp-history-thumb-state"
const version = setState(id, page("shown"))
addHistory(id, page("shown"))
await postJson("/api/history-svg", {
sessionId: id,
svg: "SVG-OF-SHOWN",
stateId: "another-state",
version,
})
expect(getHistory(id).at(-1)?.svg).toBe("SVG-OF-SHOWN")
expect(getHistory(id).at(-1)?.svg).toBe("")
})
it("keeps a thumbnail in time after a sync reply", async () => {
const id = "mcp-history-thumb-sync"
const version = setState(id, page("ai"))
addHistory(id, page("ai"))
// draw.io's copy of the same diagram, sent back for a sync
const synced = page("ai").replace(
"<mxGraphModel>",
'<mxGraphModel dx="10">',
)
await postJson("/api/state", {
sessionId: id,
xml: synced,
baseVersion: version,
source: "sync",
stateId: getState(id)?.stateId,
})
await thumbnail(id, "SVG-OF-AI", version)
expect(getHistory(id).at(-1)?.svg).toBe("SVG-OF-AI")
// The state's own image is of the synced XML only
expect(getState(id)?.svg).toBeUndefined()
})
it("keeps the image when a write repeats the same XML", async () => {
const id = "mcp-history-thumb-same"
const version = setState(id, page("same"))
await thumbnail(id, "SVG-OF-SAME", version)
setState(id, page("same"), undefined, true)
expect(getState(id)?.svg).toBe("SVG-OF-SAME")
setState(id, page("changed"), undefined, true)
expect(getState(id)?.svg).toBeUndefined()
})
it("never pairs the image of an older diagram with a newer one", async () => {
@@ -515,14 +575,26 @@ describe("history restore", () => {
expect(getHistory(id).map((e) => e.xml)).toContain(cleared)
})
it("adds no entry for a re-serialized copy of the last one", () => {
it("adds no entry for a copy of the last one", () => {
const id = "mcp-history-dedupe"
addHistory(id, page("same"))
addHistory(id, page("same"), "SVG")
expect(getHistory(id)).toHaveLength(1)
// The missing image is filled in
expect(getHistory(id)[0].svg).toBe("SVG")
})
it("keeps a version that changed only the background", () => {
const id = "mcp-history-background"
addHistory(id, page("same"))
addHistory(
id,
page("same").replace("<mxGraphModel>", '<mxGraphModel dx="10">'),
page("same").replace(
"<mxGraphModel>",
'<mxGraphModel background="#FFE6CC">',
),
)
expect(getHistory(id)).toHaveLength(1)
expect(getHistory(id)).toHaveLength(2)
})
it("keeps manual edits in history before restoring", async () => {
@@ -545,3 +617,46 @@ describe("history restore", () => {
expect(getHistory(id).map((e) => e.xml)).toContain(doc("manual"))
})
})
describe("bodies over the size limit", () => {
it("answers 413 after reading the whole body", async () => {
const mib = Buffer.alloc(1024 * 1024, "x")
// Still sending when the limit is passed, as a browser would be.
// A browser whose upload is cut off reports a network error.
const result = await new Promise<{ status?: number; sent: boolean }>(
(resolve) => {
let sent = false
const req = http.request(
{
host: "127.0.0.1",
port,
path: "/api/state",
method: "POST",
headers: {
host: `localhost:${port}`,
"content-type": "application/json",
},
},
(res) => {
res.resume()
res.on("end", () =>
resolve({ status: res.statusCode, sent }),
)
},
)
req.on("error", () => resolve({ sent }))
const writeNext = (i: number) => {
if (i === 15) {
req.end(() => {
sent = true
})
return
}
req.write(mib, () => setTimeout(() => writeNext(i + 1), 10))
}
writeNext(0)
},
)
expect(result).toEqual({ status: 413, sent: true })
})
})
@@ -66,6 +66,13 @@ describe("parseDrawioFileContent", () => {
}
})
it("rejects a bare mxGraphModel that is not closed", () => {
const r = parseDrawioFileContent(
MODEL_XML.replace("</root></mxGraphModel>", ""),
)
expect(r.ok).toBe(false)
})
it("decompresses a compressed mxfile into plain XML pages", () => {
const r = parseDrawioFileContent(COMPRESSED_MXFILE)
expect(r.ok).toBe(true)
@@ -130,6 +130,43 @@ describe("Autosaver", () => {
expect(readFileSync(path, "utf-8")).toBe(DIAGRAM)
})
it("saves again when the unreadable file is deleted during the session", () => {
const saver = new Autosaver(tempDir(), 10)
saver.schedule("mcp-live", DIAGRAM)
saver.flush()
const path = saver.pathFor("mcp-live") as string
chmodSync(path, 0o000)
expect(saver.load("mcp-live")).toBeNull()
// The session goes on (load is not called again); the user removes
// the broken file
chmodSync(path, 0o644)
rmSync(path)
saver.schedule("mcp-live", DIAGRAM)
saver.flush()
expect(readFileSync(path, "utf-8")).toBe(DIAGRAM)
})
it("keeps protecting a file it cannot even look at", () => {
// A folder without permission makes the file look missing; it is not
const dir = tempDir()
const saver = new Autosaver(dir, 10)
saver.schedule("mcp-hidden", DIAGRAM)
saver.flush()
const path = saver.pathFor("mcp-hidden") as string
chmodSync(path, 0o000)
expect(saver.load("mcp-hidden")).toBeNull()
chmodSync(dir, 0o000)
try {
expect(saver.load("mcp-hidden")).toBeNull()
} finally {
chmodSync(dir, 0o755)
}
chmodSync(path, 0o644)
saver.schedule("mcp-hidden", BLANK)
saver.flush()
expect(readFileSync(path, "utf-8")).toBe(DIAGRAM)
})
it("does nothing when saving is off", () => {
const saver = new Autosaver(null)
expect(saver.pathFor("mcp-x")).toBeNull()
@@ -165,3 +165,18 @@ describe("prepareNewDiagram", () => {
expect(out.ok).toBe(true)
})
})
describe("labels that look like attributes", () => {
const layer = `<mxCell id="5" value="Move to id='1'" vertex="1" parent="1"/>`
it("keep their cell when the root cells are stripped", () => {
const wrapped = wrapCellsInModel(ROOTS + layer)
expect(wrapped).toBe(
`<mxGraphModel><root>${ROOTS}${layer}</root></mxGraphModel>`,
)
})
it("are not taken for a reserved id", () => {
expect(reservedIdError(layer)).toBeNull()
})
})
@@ -326,3 +326,30 @@ describe("text directly under a page", () => {
expect(r.error).toMatch(/not-base64/)
})
})
describe("attributes inside quoted values", () => {
const labelled = `<mxCell id="2" value="Use parent='1'" vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell>`
it("are not duplicates of the real ones", () => {
const r = validateAndFixXml(model(labelled))
expect(r.valid).toBe(true)
expect(r.fixed ?? model(labelled)).toContain(labelled)
})
it("are kept when a real duplicate is removed", () => {
// The bare & makes the repair run on the whole document
const cell = `<mxCell id="3" value="Use parent='1'" vertex="1" parent="1" parent="1"><mxGeometry as="geometry"/></mxCell>`
const r = validateAndFixXml(model(cell + BROKEN_CELL))
expect(r.valid).toBe(true)
expect(r.fixed).toContain(
`<mxCell id="3" value="Use parent='1'" vertex="1" parent="1">`,
)
})
it("leave two cells with an unbalanced quote their ids and parents", () => {
const broken = `<mxCell id="4" style="rounded=1;fillColor="#dae8fc" vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell><mxCell id="5" value="B" vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell>`
const r = validateAndFixXml(model(broken))
expect(r.fixed).toContain(`id="5" value="B" vertex="1" parent="1"`)
expect(r.fixed).toMatch(/id="4"[^>]*vertex="1" parent="1"/)
})
})
+86 -1
View File
@@ -1,4 +1,10 @@
import { expect, getIframe, test } from "./lib/fixtures"
import {
expect,
getChatInput,
getIframe,
sendMessage,
test,
} from "./lib/fixtures"
test.describe("Chat Panel", () => {
test.beforeEach(async ({ page }) => {
@@ -20,3 +26,82 @@ test.describe("Chat Panel", () => {
expect(src).toBeTruthy()
})
})
test.describe("Crossing the mobile breakpoint", () => {
test.beforeEach(async ({ page }) => {
// A text answer that arrives in parts over a few seconds
await page.addInitScript(() => {
const realFetch = window.fetch
window.fetch = async (input, init) => {
const url = input instanceof Request ? input.url : String(input)
if (!url.endsWith("/api/chat")) return realFetch(input, init)
const events = [
{ type: "start" },
{ type: "text-start", id: "t" },
{ type: "text-delta", id: "t", delta: "Once upon" },
{ type: "text-delta", id: "t", delta: " a time." },
{ type: "text-end", id: "t" },
{ type: "finish" },
]
const body = new ReadableStream({
async start(controller) {
for (const event of events) {
controller.enqueue(
new TextEncoder().encode(
`data: ${JSON.stringify(event)}\n\n`,
),
)
await new Promise((r) => setTimeout(r, 1500))
}
controller.enqueue(
new TextEncoder().encode("data: [DONE]\n\n"),
)
controller.close()
},
})
return new Response(body, {
headers: { "content-type": "text/event-stream" },
})
}
})
await page.setViewportSize({ width: 1280, height: 800 })
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
})
test("keeps the chat and its streaming answer", async ({ page }) => {
const chat = page.locator('[data-panel-id="chat-panel"]')
await sendMessage(page, "Tell me a story")
await expect(page.getByText("Once upon")).toBeVisible({
timeout: 10000,
})
await page.setViewportSize({ width: 600, height: 900 })
await expect(page.getByText("Tell me a story")).toBeVisible()
// Half the height on mobile
await expect
.poll(async () => (await chat.boundingBox())?.height ?? 0)
.toBeCloseTo(450, -1)
await page.setViewportSize({ width: 1280, height: 800 })
// A third of the width on desktop
await expect
.poll(async () => (await chat.boundingBox())?.width ?? 0)
.toBeCloseTo(1280 / 3, -1)
await expect(page.getByText("Once upon a time.")).toBeVisible({
timeout: 10000,
})
await expect(page.getByText("Tell me a story")).toBeVisible()
})
test("opens a chat collapsed on desktop", async ({ page }) => {
await page.locator("button:has(svg.lucide-panel-right-close)").click()
await expect(getChatInput(page)).toBeHidden()
await page.setViewportSize({ width: 600, height: 900 })
await expect(getChatInput(page)).toBeVisible()
await page.setViewportSize({ width: 1280, height: 800 })
await expect(getChatInput(page)).toBeVisible()
})
})
+50
View File
@@ -409,6 +409,14 @@ const drawReply = (id: string, xml: string) => {
const call = toolCallEvents(id, "display_diagram", { xml })
return `${sse([{ type: "start" }, call.start, ...call.deltas, call.done, { type: "finish" }])}data: [DONE]\n\n`
}
const textReply = (text: string) =>
`${sse([
{ type: "start" },
{ type: "text-start", id: "t" },
{ type: "text-delta", id: "t", delta: text },
{ type: "text-end", id: "t" },
{ type: "finish" },
])}data: [DONE]\n\n`
// SSE comments keep a stream open without sending anything
const KEEP_OPEN = Array(20).fill(":\n\n")
@@ -685,3 +693,45 @@ test("stopping during the screenshot check starts no new request", async ({
await p.waitForTimeout(5000)
expect(chatRequests).toBe(1)
})
test("stopping during the screenshot check lets the next message go at once", async ({
page: p,
}) => {
// The check was still running when the user stopped; it held up the
// chat until it ended, and its call never got a result
await p.addInitScript(() => {
localStorage.setItem("next-ai-draw-io-vlm-validation-enabled", "true")
})
const bodies: Array<{ messages: any[] }> = []
await p.route("**/api/chat", async (route) => {
bodies.push(route.request().postDataJSON())
const n = bodies.length
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body:
n === 1
? drawReply("d1", cell("a", "Alpha", 40))
: textReply("Second answer"),
})
})
let checking = false
await p.route("**/api/validate-diagram", async (route) => {
checking = true
// Much longer than this test waits for the second answer
await new Promise((r) => setTimeout(r, 30000))
await route.fulfill({ status: 200, body: "{}" }).catch(() => {})
})
await p.goto("/", { waitUntil: "networkidle" })
await getIframe(p).waitFor({ state: "visible", timeout: 30000 })
await sendMessage(p, "Draw a box")
await expect.poll(() => checking, { timeout: 15000 }).toBe(true)
await p.getByRole("button", { name: "Stop generation" }).click()
await sendMessage(p, "Thanks")
await expect(p.getByText("Second answer")).toBeVisible({ timeout: 8000 })
// The drawing call had its result when the next message was sent
const draw = bodies[1].messages
.flatMap((m: any) => m.parts ?? [])
.find((part: any) => part.type === "tool-display_diagram")
expect(draw?.state).toBe("output-available")
})
+86
View File
@@ -86,6 +86,40 @@ test.describe("History and Session Restore", () => {
).toBeVisible()
})
test("new chat can go on without saving when storage is full", async ({
page,
}) => {
// Old chats can only be deleted from the empty chat's list, so the
// user must be able to get there
await page.route("**/api/chat", async (route) => {
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: createMockSSEResponse(
SINGLE_BOX_XML,
"Created your test diagram.",
),
})
})
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await sendMessage(page, "Create a test diagram")
await waitForText(page, "Created your test diagram.")
await page.evaluate(() => {
IDBObjectStore.prototype.put = () => {
throw new DOMException("Storage is full", "QuotaExceededError")
}
})
await page.locator('[data-testid="new-chat-button"]').click()
await page
.getByRole("button", { name: "Continue without saving" })
.click({ timeout: 5000 })
await expect(
page.locator('text="Created your test diagram."'),
).toHaveCount(0, { timeout: 5000 })
await expect(page.getByText("Paper to Diagram")).toBeVisible()
})
// A diagram drawn by hand, without chat messages: loaded into draw.io
// directly, then moved with an arrow key, which draw.io reports as an
// edit like any manual change
@@ -338,3 +372,55 @@ test.describe("History and Session Restore", () => {
})
})
})
/** Number of chats stored in this origin's IndexedDB */
const countSessions = (page: Page) =>
page.evaluate(
() =>
new Promise<number>((resolve, reject) => {
const open = indexedDB.open("next-ai-drawio")
open.onerror = () => reject(open.error)
open.onsuccess = () => {
const db = open.result
if (!db.objectStoreNames.contains("sessions")) {
db.close()
return resolve(0)
}
const count = db
.transaction("sessions", "readonly")
.objectStore("sessions")
.count()
count.onsuccess = () => {
db.close()
resolve(count.result)
}
}
}),
)
test("new chat right after an answer saves that chat once", async ({
page,
}) => {
test.setTimeout(180_000)
await page.route("**/api/chat", async (route) => {
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: createMockSSEResponse(SINGLE_BOX_XML, "Drew the box."),
})
})
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
const newChat = page.locator('[data-testid="new-chat-button"]')
// The auto-save runs a second after the answer; New Chat around then
// waits for its thumbnail while the auto-save starts
for (let run = 1; run <= 10; run++) {
await sendMessage(page, `Draw box ${run}`)
await waitForText(page, "Drew the box.")
await page.waitForTimeout(500 + run * 100)
await newChat.click()
await expect(page.getByText("Drew the box.")).toHaveCount(0)
await page.waitForTimeout(2500)
expect(await countSessions(page), `run ${run}`).toBe(run)
}
})
+29
View File
@@ -302,6 +302,35 @@ test("an older test does not end a newer one's spinners", async ({ page }) => {
await expect(dialog.locator('[title="1.0 s"]')).toHaveCount(1)
})
test("an older test touches nothing, also when the key came back", async ({
page,
}) => {
const releases: Array<() => void> = []
await page.route("**/api/validate-model", async (route) => {
const n = releases.length
await new Promise<void>((r) => releases.push(r))
await route.fulfill({
status: 200,
// The older test's result would say 9.0 s
json: { valid: true, responseTime: n === 0 ? 9000 : 1000 },
})
})
const dialog = await openQwenSettings(page, TWO_PROVIDERS)
await dialog.getByRole("button", { name: "Test", exact: true }).click()
await expect.poll(() => releases.length).toBe(1)
// The key changes and comes back, and the user tests again
await dialog.locator("#api-key").fill("other-key")
await dialog.locator("#api-key").fill("test-key")
await dialog.getByRole("button", { name: "Test", exact: true }).click()
await expect.poll(() => releases.length).toBe(2)
releases[0]()
await page.waitForTimeout(500)
await expect(dialog.locator(".animate-spin").first()).toBeVisible()
await expect(dialog.locator('[title="9.0 s"]')).toHaveCount(0)
releases[1]()
await expect(dialog.locator('[title="1.0 s"]')).toHaveCount(1)
})
test("no spinner stays after another tab's change while elsewhere", async ({
page,
}) => {
+6 -6
View File
@@ -69,15 +69,15 @@ describe("deriveEnvUpdates", () => {
expect(updates.ADMIN_OPENAI_API_KEY_2).toBe("sk-second")
})
it("sends an Ollama key without a URL to Ollama Cloud, like its Test", () => {
// Chat sends a server Ollama key to OLLAMA_BASE_URL, or to local
// Ollama without one; the Test sends it to Ollama Cloud
const cloud = deriveEnvUpdates(
it("writes an Ollama URL only when the entry has one", () => {
// Without one, the operator's own OLLAMA_BASE_URL (or local Ollama)
// stays, also for the AI_PROVIDER=ollama default model
const keyOnly = deriveEnvUpdates(
[provider({ provider: "ollama", apiKey: "ollama-key" })],
[],
)
expect(cloud.OLLAMA_API_KEY).toBe("ollama-key")
expect(cloud.OLLAMA_BASE_URL).toBe("https://ollama.com/api")
expect(keyOnly.OLLAMA_API_KEY).toBe("ollama-key")
expect(keyOnly.OLLAMA_BASE_URL ?? null).toBeNull()
const own = deriveEnvUpdates(
[
provider({
+13
View File
@@ -48,6 +48,8 @@ describe("admin Test of an entry without a URL", () => {
process.env.OPENAI_BASE_URL = "https://operator-proxy.example.com/v1"
await test({ provider: "openai", apiKey: "panel-key" })
expect(sent.body.baseUrl).toBe("https://operator-proxy.example.com/v1")
// The server's own URL, tested without the rules for typed URLs
expect(sent.body.serverBaseUrl).toBe(true)
process.env.AI_GATEWAY_BASE_URL = "https://gateway.example.com/v3/ai"
await test({ provider: "gateway", apiKey: "k" })
@@ -65,4 +67,15 @@ describe("admin Test of an entry without a URL", () => {
await test({ provider: "deepseek", apiKey: "k" })
expect(sent.body.baseUrl).toBeUndefined()
})
it("does not use Vertex's variable, which the panel writes itself", async () => {
// Before a save it still holds the entry's previous URL
process.env.GOOGLE_VERTEX_BASE_URL = "https://old-proxy.example.com"
try {
await test({ provider: "vertexai", vertexApiKey: "new-key" })
expect(sent.body.baseUrl).toBeUndefined()
} finally {
delete process.env.GOOGLE_VERTEX_BASE_URL
}
})
})
@@ -28,6 +28,14 @@ vi.mock("@ai-sdk/openai", () => {
}
})
vi.mock("@ai-sdk/azure", () => {
const mockModel = { modelId: "test-model" }
const mockProviderFn = vi.fn(() => mockModel) as any
mockProviderFn.chat = vi.fn(() => mockModel)
mockProviderFn.responses = vi.fn(() => mockModel)
return { createAzure: vi.fn(() => mockProviderFn) }
})
vi.mock("@ai-sdk/amazon-bedrock", () => {
const mockProviderFn = vi.fn(() => ({ modelId: "test-model" }))
return { createAmazonBedrock: vi.fn(() => mockProviderFn) }
@@ -446,6 +454,43 @@ describe("whose keys a request uses", () => {
}
})
it("runs an Azure entry set up only in the admin panel", async () => {
// No AZURE_BASE_URL or AZURE_RESOURCE_NAME: the entry's own
// variables hold the key and the resource URL
process.env.ADMIN_AZURE_API_KEY = "panel-key"
process.env.ADMIN_AZURE_BASE_URL = "https://res.openai.azure.com/openai"
try {
const { createAzure } = await import("@ai-sdk/azure")
expect(() =>
getAIModel({
provider: "azure",
modelId: "gpt-4o",
apiKeyEnv: "ADMIN_AZURE_API_KEY",
baseUrlEnv: "ADMIN_AZURE_BASE_URL",
}),
).not.toThrow()
expect(createAzure).toHaveBeenLastCalledWith(
expect.objectContaining({
apiKey: "panel-key",
baseURL: "https://res.openai.azure.com/openai",
}),
)
// Without any URL it still says what is missing
delete process.env.ADMIN_AZURE_BASE_URL
expect(() =>
getAIModel({
provider: "azure",
modelId: "gpt-4o",
apiKeyEnv: "ADMIN_AZURE_API_KEY",
baseUrlEnv: "ADMIN_AZURE_BASE_URL",
}),
).toThrow(/AZURE_BASE_URL/)
} finally {
delete process.env.ADMIN_AZURE_API_KEY
delete process.env.ADMIN_AZURE_BASE_URL
}
})
it("needs a base URL with a user's Azure key", () => {
// The SDK would otherwise read the server's AZURE_RESOURCE_NAME
process.env.AZURE_RESOURCE_NAME = "operator-resource"
+16
View File
@@ -64,4 +64,20 @@ describe("switchPreset", () => {
await toC
expect(state.current).toBe("C")
})
it("keeps a newer choice of the same preset", async () => {
// A, then B, C, and B again while the first restart is pending
const first = switchPreset("B").catch(() => {})
const second = switchPreset("C").catch(() => {})
const third = switchPreset("B")
// The first restart fails: the current preset is B again, but it is
// the third switch's, which must not be undone
state.restarts[0].reject(new Error("timed out"))
await new Promise((r) => setTimeout(r, 0))
for (const r of state.restarts.slice(1)) r.resolve()
await first
await second
await third
expect(state.current).toBe("B")
})
})
@@ -0,0 +1,68 @@
import { render } from "@testing-library/react"
import { describe, expect, it, vi } from "vitest"
import en from "@/lib/i18n/dictionaries/en.json"
const page = (cells: string) =>
`<mxfile><diagram id="p" name="Page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root></mxGraphModel></diagram></mxfile>`
const box = (id: string) =>
`<mxCell id="${id}" value="${id}" vertex="1" parent="1"><mxGeometry x="0" y="0" width="80" height="40" as="geometry"/></mxCell>`
// The first edit's result is loaded (the ref has it); the chartXML state
// has not caught up yet
const BEFORE_FIRST_EDIT = page(box("a"))
const AFTER_FIRST_EDIT = page(box("a") + box("b"))
vi.mock("@/contexts/diagram-context", () => ({
useDiagram: () => ({
chartXML: BEFORE_FIRST_EDIT,
chartXMLRef: { current: AFTER_FIRST_EDIT },
loadDiagram: vi.fn(() => null),
}),
}))
vi.mock("@/hooks/use-dictionary", () => ({ useDictionary: () => en }))
import { ChatMessageDisplay } from "@/components/chat-message-display"
// jsdom has no layout
Element.prototype.scrollIntoView = () => {}
describe("the streaming preview of a second edit", () => {
it("starts from the first edit's result", () => {
const editDiagramOriginalXmlRef = { current: new Map<string, string>() }
const messages = [
{
id: "m1",
role: "assistant",
parts: [
{
type: "tool-edit_diagram",
toolCallId: "edit-2",
state: "input-streaming",
input: {
operations: [
{
operation: "add",
cell_id: "c",
new_xml: box("c"),
},
],
},
},
],
},
] as any
render(
<ChatMessageDisplay
messages={messages}
setInput={() => {}}
setFiles={() => {}}
processedToolCallsRef={{ current: new Set() }}
editDiagramOriginalXmlRef={editDiagramOriginalXmlRef}
status="streaming"
/>,
)
expect(editDiagramOriginalXmlRef.current.get("edit-2")).toBe(
AFTER_FIRST_EDIT,
)
})
})
+139
View File
@@ -0,0 +1,139 @@
// @vitest-environment node
import { afterEach, describe, expect, it, vi } from "vitest"
const quota = vi.hoisted(() => ({ recorded: [] as number[] }))
vi.mock("@/lib/dynamo-quota-manager", () => ({
isQuotaEnabled: () => true,
checkAndIncrementRequest: async () => ({ allowed: true }),
recordTokenUsage: async (_ip: string, tokens: number) => {
quota.recorded.push(tokens)
},
}))
// No DNS in tests: only loopback addresses are private
vi.mock("@/lib/ssrf-protection", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/ssrf-protection")>()),
isPrivateUrl: async (url: string) =>
/^https?:\/\/(127\.0\.0\.1|localhost)\b/.test(url),
}))
import { POST as chat } from "@/app/api/chat/route"
afterEach(() => {
quota.recorded = []
vi.unstubAllGlobals()
})
const sse = (chunks: object[], end = true) =>
chunks.map((c) => `data: ${JSON.stringify(c)}\n\n`).join("") +
(end ? "data: [DONE]\n\n" : "")
describe("a request stopped after a finished step", () => {
it("counts that step's tokens", async () => {
// 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
vi.stubGlobal(
"fetch",
vi.fn(async (_url: string, init?: RequestInit) => {
call++
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)
const body = new ReadableStream({
start(controller) {
init?.signal?.addEventListener("abort", () =>
controller.error(
new DOMException("aborted", "AbortError"),
),
)
},
})
return new Response(body, {
headers: { "content-type": "text/event-stream" },
})
}),
)
const stop = new AbortController()
const res = await chat(
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
await vi.waitFor(() => expect(call).toBe(2), { timeout: 3000 })
stop.abort()
// The answer stream ends; the SDK handles the stop as it is read
while (
reader &&
!(await reader.read().catch(() => ({ done: true }))).done
) {
// drain
}
await vi.waitFor(() => expect(quota.recorded).toEqual([1230]))
})
})
+60
View File
@@ -77,3 +77,63 @@ describe("EdgeOne as a server model", () => {
expect(calls[0]?.headers.get("cookie")).toBe("eo_token=t; eo_time=1")
})
})
const send = (headers: Record<string, string>) =>
chat(
new Request("http://localhost/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json", ...headers },
body: JSON.stringify({
messages: [
{
id: "u1",
role: "user",
parts: [{ type: "text", text: "Draw two boxes" }],
},
],
xml: "",
}),
}),
).then((r) => r.text())
describe("EdgeOne endpoints", () => {
it("works when the deployment names EdgeOne only in AI_PROVIDER", async () => {
process.env.AI_PROVIDER = "edgeone"
process.env.AI_MODEL = "@tx/deepseek-ai/deepseek-v3-0324"
await send({})
expect(calls[0]?.url).toBe(
"http://localhost/api/edgeai/chat/completions",
)
})
it("always calls the site's own function, whatever URL the request names", async () => {
// Another host would get the user's EdgeOne cookies
await send({
"x-ai-provider": "edgeone",
"x-ai-model": "@tx/deepseek-ai/deepseek-v3-0324",
"x-ai-base-url": "https://elsewhere.example/api/edgeai",
cookie: "eo_token=t",
})
expect(calls[0]?.url).toBe(
"http://localhost/api/edgeai/chat/completions",
)
})
it("keeps the deployment's base path", async () => {
const savedPath = process.env.NEXT_PUBLIC_BASE_PATH
process.env.NEXT_PUBLIC_BASE_PATH = "/draw"
try {
await send({
"x-ai-provider": "edgeone",
"x-ai-model": "@tx/deepseek-ai/deepseek-v3-0324",
})
expect(calls[0]?.url).toBe(
"http://localhost/draw/api/edgeai/chat/completions",
)
} finally {
if (savedPath === undefined)
delete process.env.NEXT_PUBLIC_BASE_PATH
else process.env.NEXT_PUBLIC_BASE_PATH = savedPath
}
})
})
+122
View File
@@ -89,6 +89,24 @@ describe("provider error texts in the stream", () => {
// The SDK retries a refused connection twice, waiting between
}, 20_000)
it("shows the server's keyless Ollama error on the web too", async () => {
// No key, no money involved; round three hid this text
process.env.AI_PROVIDER = "ollama"
process.env.AI_MODEL = "llama3"
process.env.OLLAMA_BASE_URL = "http://ollama.internal:11434/api"
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw Object.assign(new TypeError("fetch failed"), {
cause: new Error("connect ECONNREFUSED 10.0.0.9:11434"),
})
}),
)
expect(await streamedError({})).not.toBe(
"The provider returned an error.",
)
}, 20_000)
it("shows EdgeOne's own daily quota explanation", async () => {
// The function answers 429, which the SDK retries with a wait;
// the status does not decide whether the text is shown
@@ -124,3 +142,107 @@ describe("provider error texts in the stream", () => {
expect(message).toBe("The provider returned an error.")
})
})
describe("the output cap", () => {
it("holds for the server's own keyless endpoints too", async () => {
process.env.MAX_OUTPUT_TOKENS = "8000"
const sent: string[] = []
vi.stubGlobal(
"fetch",
vi.fn(async (_url: string, init?: RequestInit) => {
sent.push(String(init?.body ?? ""))
return new Response("{}", { status: 400 })
}),
)
try {
const res = await chat(
new Request("http://localhost/api/chat", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-ai-provider": "ollama",
"x-ai-base-url": "http://127.0.0.1:11434/api",
"x-ai-model": "llama3",
"x-max-output-tokens": "200000",
},
body: JSON.stringify({
messages: [
{
id: "u1",
role: "user",
parts: [{ type: "text", text: "Draw" }],
},
],
xml: "",
}),
}),
)
await res.text()
expect(sent[0]).toContain('"max_output_tokens":8000')
} finally {
delete process.env.MAX_OUTPUT_TOKENS
}
})
})
describe("a tool call that never got its result", () => {
it("is left out of the prompt instead of failing every later message", async () => {
// Stop while the screenshot check ran left display_diagram without
// a result, and the chat was saved like that
process.env.AI_PROVIDER = "openai"
process.env.AI_MODEL = "gpt-5.5"
process.env.OPENAI_API_KEY = "server-key"
const sent: string[] = []
vi.stubGlobal(
"fetch",
vi.fn(async (_url: string, init?: RequestInit) => {
sent.push(String(init?.body ?? ""))
return new Response(
JSON.stringify({ error: { message: "x" } }),
{
status: 400,
headers: { "Content-Type": "application/json" },
},
)
}),
)
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",
parts: [{ type: "text", text: "Draw a box" }],
},
{
id: "a1",
role: "assistant",
parts: [
{
type: "tool-display_diagram",
toolCallId: "call-without-result",
state: "input-available",
input: { xml: "<mxCell/>" },
},
],
},
{
id: "u2",
role: "user",
parts: [{ type: "text", text: "Make it red" }],
},
],
xml: "",
}),
}),
)
await res.text()
// The request reached the model, without the unanswered call
expect(sent).toHaveLength(1)
expect(sent[0]).toContain("Make it red")
expect(sent[0]).not.toContain("call-without-result")
})
})
+61
View File
@@ -140,6 +140,67 @@ describe("chat quota", () => {
expect(quota.checks).toBe(1)
})
it("counts the server's network whatever key header comes along", async () => {
// A keyless Ollama or a local SGLang ignores a dummy key
for (const headers of [
{
"x-ai-provider": "ollama",
"x-ai-base-url": "http://127.0.0.1:11434/api",
"x-ai-api-key": "dummy",
"x-ai-model": "llama3.2",
},
{
"x-ai-provider": "openai",
"x-ai-base-url": "http://127.0.0.1:30000/v1",
"x-ai-api-key": "dummy",
"x-ai-model": "m",
},
]) {
expect((await send(headers)).status).toBe(429)
}
expect(quota.checks).toBe(2)
})
it("does not count a provider that never uses the base URL header", async () => {
// Bedrock on the user's own AWS keys goes to AWS, whatever the
// leftover base URL says
const res = await send({
"x-ai-provider": "bedrock",
"x-ai-model": "amazon.nova-lite-v1:0",
"x-ai-base-url": "http://127.0.0.1:8080",
"x-aws-access-key-id": "id",
"x-aws-secret-access-key": "secret",
"x-aws-region": "us-east-1",
})
expect(res.status).not.toBe(429)
expect(quota.checks).toBe(0)
})
it("counts EdgeOne even with a base URL header", async () => {
const res = await send({
"x-ai-provider": "edgeone",
"x-ai-model": "@tx/deepseek-ai/deepseek-v3-0324",
"x-ai-base-url": "https://this-site.example/api/edgeai",
})
expect(res.status).toBe(429)
expect(quota.checks).toBe(1)
})
it("never counts in the desktop app, where every endpoint is the user's", async () => {
process.env.NEXT_AI_DRAWIO_DESKTOP = "1"
try {
const res = await send({
"x-ai-provider": "ollama",
"x-ai-base-url": "http://127.0.0.1:11434/api",
"x-ai-model": "llama3.2",
})
expect(res.status).not.toBe(429)
expect(quota.checks).toBe(0)
} finally {
delete process.env.NEXT_AI_DRAWIO_DESKTOP
}
})
it("does not count Ollama on the user's own server", async () => {
const res = await send({
"x-ai-provider": "ollama",
+119
View File
@@ -0,0 +1,119 @@
import { deflateRawSync } from "node:zlib"
import { act, renderHook } from "@testing-library/react"
import type React from "react"
import { afterEach, describe, expect, it, vi } from "vitest"
import { DiagramProvider, useDiagram } from "@/contexts/diagram-context"
vi.mock("sonner", () => ({ toast: { success: vi.fn() } }))
// The provider with a stand-in draw.io that records each export request
function setup() {
const { result } = renderHook(() => useDiagram(), {
wrapper: ({ children }: { children: React.ReactNode }) => (
<DiagramProvider>{children}</DiagramProvider>
),
})
const requests: { format: string; message: string }[] = []
result.current.drawioRef.current = {
exportDiagram: (r: any) => requests.push(r),
load: vi.fn(),
} as any
// draw.io's reply to a request: it echoes the request in `message`
const reply = (request: { message: string }, data: string, xml = "") =>
act(() =>
result.current.handleDiagramExport({
event: "export",
data,
xml,
format: "xmlsvg",
message: request,
} as any),
)
return { result, requests, reply }
}
// An editable SVG as draw.io exports it: the diagram, compressed, in its
// content attribute
const svgOf = (label: string) => {
const model = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" value="${label}" vertex="1" parent="1"/></root></mxGraphModel>`
const packed = deflateRawSync(
Buffer.from(encodeURIComponent(model)),
).toString("base64")
const content = `<mxfile><diagram id="p">${packed}</diagram></mxfile>`
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
const svg = `<svg xmlns="http://www.w3.org/2000/svg" content="${content}"></svg>`
return `data:image/svg+xml;base64,${btoa(svg)}`
}
afterEach(() => {
vi.restoreAllMocks()
})
describe("exports in flight at the same time", () => {
it("give the chat's export only its own reply", () => {
const { result, requests, reply } = setup()
// An edit's history export is still on its way when the chat exports
act(() => {
result.current.handleExport()
})
let tag = ""
const got: string[] = []
act(() => {
tag = result.current.handleExportWithoutHistory()
result.current.exportResolversRef.current[tag] = (xml) =>
got.push(xml)
})
reply(requests[0], svgOf("older"))
expect(got).toEqual([])
reply(requests[1], svgOf("current"))
expect(got).toHaveLength(1)
expect(got[0]).toContain('value="current"')
expect(result.current.exportResolversRef.current[tag]).toBeUndefined()
})
it("save each file with its own result", async () => {
const { result, requests, reply } = setup()
const saved: { name: string; href: string }[] = []
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(
function (this: HTMLAnchorElement) {
saved.push({ name: this.download, href: this.href })
},
)
const blobs = new Map<string, Blob>()
URL.createObjectURL = vi.fn((blob: Blob) => {
const url = `blob:test-${blobs.size}`
blobs.set(url, blob)
return url
})
URL.revokeObjectURL = vi.fn()
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("{}")),
)
const twoPages =
'<mxfile><diagram id="a" name="A"><mxGraphModel><root/></mxGraphModel></diagram><diagram id="b" name="B"><mxGraphModel><root/></mxGraphModel></diagram></mxfile>'
act(() => {
result.current.saveDiagramToFile("doc", "drawio")
result.current.saveDiagramToFile("pic", "png")
})
// The PNG answers first
reply(requests[1], "data:image/png;base64,iVBORw0KGgo=")
reply(requests[0], svgOf("doc"), twoPages)
expect(saved.map((s) => s.name)).toEqual(["pic.png", "doc.drawio"])
expect(saved[0].href).toMatch(/^data:image\/png/)
const file = blobs.get(saved[1].href)
const text = await new Promise<string>((resolve) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result))
reader.readAsText(file as Blob)
})
expect(text).toContain('name="A"')
expect(text).toContain('name="B"')
vi.unstubAllGlobals()
})
})
+20
View File
@@ -25,6 +25,9 @@ const KEYS = [
"T_HASH",
"T_AFTER",
"T_JOINED",
"T_ESC_HASH",
"T_ESC_INNER",
"T_ESC_COMMENT",
]
afterEach(() => {
for (const k of KEYS) delete process.env[k]
@@ -72,4 +75,21 @@ describe("loadEnvFile", () => {
expect(process.env.T_JOINED).toBe(`"a"b`)
expect(process.env.T_HASH).toBe("http://host/#/x")
})
it("does not end a quoted value at an escaped quote, like dotenv", () => {
dir.path = mkdtempSync(join(tmpdir(), "env-loader-"))
writeFileSync(
join(dir.path, ".env"),
[
'T_ESC_HASH="abc\\" #def"',
'T_ESC_INNER="a # \\"b\\""',
'T_ESC_COMMENT="x\\"y" # c',
].join("\n"),
)
loadEnvFile()
// Expected values from dotenv 16.6.1, which keeps the backslashes
expect(process.env.T_ESC_HASH).toBe('abc\\" #def')
expect(process.env.T_ESC_INNER).toBe('a # \\"b\\"')
expect(process.env.T_ESC_COMMENT).toBe('x\\"y')
})
})
+372
View File
@@ -0,0 +1,372 @@
import { readFileSync } from "node:fs"
import { join } from "node:path"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
// The MCP preview page's script, run in this document with a stubbed
// server (fetch) and draw.io iframe (its postMessage), so the tab's side of
// a recreated session can be driven step by step
const dir = join(process.cwd(), "packages/mcp-server/src/preview")
const DRAWIO = "https://embed.diagrams.net"
const html = readFileSync(join(dir, "index.html"), "utf8")
.replace("{{CSS}}", "")
.replace("{{SESSION_BADGE}}", "")
.replaceAll("{{DISABLED}}", "")
.replace("{{DRAWIO_URL}}", "about:blank")
.replace("{{SESSION_JSON}}", '"mcp-test"')
.replace("{{ORIGIN_JSON}}", JSON.stringify(DRAWIO))
const scripts = [...html.matchAll(/<script>([\s\S]*?)<\/script>/g)].map((m) =>
m[1].replace("{{SCRIPT}}", ""),
)
const preview = readFileSync(join(dir, "preview.js"), "utf8")
const pageListeners: Array<[string, EventListener]> = []
type Answer = { status: number; body: unknown }
interface Call {
url: string
method: string
body: any
answer: (a: Answer) => void
fail: () => void
}
function openPage() {
document.body.innerHTML = html.replace(/<script>[\s\S]*?<\/script>/g, "")
const toDrawio: any[] = []
const iframe = document.getElementById("drawio") as HTMLIFrameElement
Object.defineProperty(iframe, "contentWindow", {
value: { postMessage: (m: string) => toDrawio.push(JSON.parse(m)) },
})
// Every request waits until the test answers it
const calls: Call[] = []
vi.stubGlobal(
"fetch",
vi.fn(
(url: string, init?: RequestInit) =>
new Promise((resolve, reject) => {
calls.push({
url,
method: init?.method ?? "GET",
body: init?.body ? JSON.parse(String(init.body)) : null,
answer: ({ status, body }) =>
resolve(
new Response(JSON.stringify(body), { status }),
),
fail: () => reject(new TypeError("Failed to fetch")),
})
}),
),
)
// The page's window listeners, removed after the test
const addListener = window.addEventListener
window.addEventListener = ((type: string, listener: any, options?: any) => {
pageListeners.push([type, listener])
addListener.call(window, type, listener, options)
}) as typeof window.addEventListener
const run = new Function(
`${scripts.join("\n")}\n${preview}\nreturn { poll, read: () => ({ stateId, currentVersion, lastXml, latestXml }) }`,
)
let created: unknown
try {
created = run()
} finally {
window.addEventListener = addListener
}
const page = created as {
poll: () => Promise<void>
read: () => {
stateId: string | null
currentVersion: number
lastXml: string | null
latestXml: string | null
}
}
const fromDrawio = (msg: object) =>
window.dispatchEvent(
new MessageEvent("message", {
data: JSON.stringify(msg),
origin: DRAWIO,
}),
)
const settle = () => new Promise((r) => setTimeout(r, 0))
const next = (method: string) => {
const call = calls.find((c) => c.method === method)
if (!call) throw new Error(`no pending ${method}`)
calls.splice(calls.indexOf(call), 1)
return call
}
return { page, toDrawio, calls, fromDrawio, settle, next }
}
const state = (
stateId: string,
version: number,
xml: string,
blank = false,
) => ({
status: 200,
body: { stateId, version, xml, blank, syncRequested: false },
})
/** A tab in step with state S1 at version 2, showing diagram A */
async function inStep() {
const t = openPage()
t.next("GET").answer(state("S1", 2, "<mxfile>A</mxfile>"))
await t.settle()
t.fromDrawio({ event: "init" })
await t.settle()
expect(t.page.read().lastXml).toBe("<mxfile>A</mxfile>")
return t
}
beforeEach(() => {
vi.useFakeTimers({ toFake: ["setInterval"] })
})
afterEach(() => {
for (const [type, listener] of pageListeners.splice(0)) {
window.removeEventListener(type, listener)
}
vi.useRealTimers()
vi.unstubAllGlobals()
})
describe("MCP preview after the server recreated its session", () => {
it("keeps edits made while the server was down", async () => {
const t = await inStep()
// The user edits; the server is gone
t.fromDrawio({ event: "autosave", xml: "<mxfile>B</mxfile>" })
// draw.io's SVG export answers, then the push fails
t.fromDrawio({ event: "export", data: "<svg/>" })
await t.settle()
t.next("POST").fail()
await t.settle()
// A new process recovered the file, which holds A
const poll = t.page.poll()
t.next("GET").answer(state("S2", 1, "<mxfile>A</mxfile>"))
await poll
const push = t.next("POST")
expect(push.body).toMatchObject({
xml: "<mxfile>B</mxfile>",
stateId: "S2",
baseVersion: 1,
})
})
it("shows the server's diagram and keeps the tab's in History", async () => {
const t = await inStep()
const poll = t.page.poll()
t.next("GET").answer(state("S2", 1, "<mxfile>C</mxfile>"))
await poll
expect(t.toDrawio.at(-1)).toMatchObject({
action: "load",
xml: "<mxfile>C</mxfile>",
})
expect(t.next("POST").body).toMatchObject({
xml: "<mxfile>A</mxfile>",
source: "recover",
stateId: "S2",
})
})
it("sends an edit of the replaced canvas to History, until draw.io loaded", async () => {
const t = await inStep()
const poll = t.page.poll()
t.next("GET").answer(state("S2", 1, "<mxfile>C</mxfile>"))
await poll
t.next("POST") // the tab's copy, to History
// An autosave the old canvas sent before the load
t.fromDrawio({ event: "autosave", xml: "<mxfile>A edited</mxfile>" })
await t.settle()
expect(t.next("POST").body).toMatchObject({
xml: "<mxfile>A edited</mxfile>",
source: "recover",
})
// After the load, edits are edits again
t.fromDrawio({ event: "load" })
t.fromDrawio({ event: "autosave", xml: "<mxfile>C edited</mxfile>" })
t.fromDrawio({ event: "export", data: "<svg/>" })
await t.settle()
expect(t.next("POST").body).toMatchObject({
xml: "<mxfile>C edited</mxfile>",
source: "edit",
})
})
it("ignores the late answer to an old state's push", async () => {
const t = await inStep()
t.fromDrawio({ event: "autosave", xml: "<mxfile>B</mxfile>" })
t.fromDrawio({ event: "export", data: "<svg/>" })
await t.settle()
const oldPush = t.next("POST")
const poll = t.page.poll()
t.next("GET").answer(state("S2", 1, "<mxfile>B</mxfile>"))
await poll
oldPush.answer({ status: 200, body: { success: true, version: 21 } })
await t.settle()
expect(t.page.read()).toMatchObject({
stateId: "S2",
currentVersion: 1,
})
})
it("drops a poll answer older than one already handled", async () => {
const t = await inStep()
const first = t.page.poll()
const firstGet = t.next("GET")
const second = t.page.poll()
t.next("GET").answer(state("S2", 1, "<mxfile>A</mxfile>"))
await second
// The answer from before the restart comes last
firstGet.answer(state("S1", 3, "<mxfile>old</mxfile>"))
await first
expect(t.page.read().stateId).toBe("S2")
})
it("saves an undo made while a push was on its way", async () => {
const t = await inStep()
t.fromDrawio({ event: "autosave", xml: "<mxfile>B</mxfile>" })
t.fromDrawio({ event: "export", data: "<svg/>" })
await t.settle()
const pushB = t.next("POST")
// Undo back to A: equal to the last saved diagram, so no push
t.fromDrawio({ event: "autosave", xml: "<mxfile>A</mxfile>" })
await t.settle()
expect(t.calls.filter((c) => c.method === "POST")).toHaveLength(0)
pushB.answer({ status: 200, body: { success: true, version: 3 } })
await t.settle()
await t.settle()
expect(t.next("POST").body.xml).toBe("<mxfile>A</mxfile>")
})
it("sends nothing more after a sync reply", async () => {
const t = await inStep()
const poll = t.page.poll()
t.next("GET").answer({
status: 200,
body: {
...state("S1", 2, "<mxfile>A</mxfile>").body,
syncRequested: true,
},
})
await poll
const request = t.toDrawio.at(-1)
expect(request).toMatchObject({ action: "export", format: "xml" })
// draw.io's export of the canvas, formatted unlike its autosave
t.fromDrawio({
event: "export",
format: "xml",
xml: '<mxfile host="drawio">A</mxfile>',
message: request,
})
await t.settle()
const sync = t.next("POST")
expect(sync.body.source).toBe("sync")
sync.answer({ status: 200, body: { success: true, version: 3 } })
await t.settle()
await t.settle()
expect(t.calls.filter((c) => c.method === "POST")).toHaveLength(0)
})
})
describe("MCP preview thumbnails and downloads", () => {
/** The tab loads the server write B at version 3 and asks for its image */
async function loadedB() {
const t = await inStep()
const poll = t.page.poll()
t.next("GET").answer(state("S1", 3, "<mxfile>B</mxfile>"))
await poll
await new Promise((r) => setTimeout(r, 600))
const request = t.toDrawio.at(-1)
expect(request).toMatchObject({ action: "export", format: "svg" })
return { t, n: request.thumbExport as number }
}
const thumbnailPosts = (t: ReturnType<typeof openPage>) =>
t.calls.filter((c) => c.url === "/api/history-svg")
it("sends the image with the state and version it shows", async () => {
const { t, n } = await loadedB()
t.fromDrawio({
event: "export",
data: "<svg/>",
message: { thumbExport: n },
})
await t.settle()
expect(thumbnailPosts(t).map((c) => c.body)).toEqual([
expect.objectContaining({ stateId: "S1", version: 3 }),
])
})
it("drops the reply to an older thumbnail export", async () => {
const { t, n } = await loadedB()
t.fromDrawio({
event: "export",
data: "<svg/>",
message: { thumbExport: n - 1 },
})
await t.settle()
expect(thumbnailPosts(t)).toHaveLength(0)
})
it("drops the image when the user changed the canvas since the load", async () => {
const { t, n } = await loadedB()
t.fromDrawio({ event: "autosave", xml: "<mxfile>B edited</mxfile>" })
t.fromDrawio({
event: "export",
data: "<svg/>",
message: { thumbExport: n },
})
await t.settle()
expect(thumbnailPosts(t)).toHaveLength(0)
})
it("downloads the canvas with an edit the server did not get", async () => {
const t = await inStep()
t.fromDrawio({ event: "autosave", xml: "<mxfile>B</mxfile>" })
t.fromDrawio({ event: "export", data: "<svg/>" })
await t.settle()
t.next("POST").fail()
await t.settle()
let saved: Blob | undefined
URL.createObjectURL = vi.fn((blob: Blob) => {
saved = blob
return "blob:test"
})
URL.revokeObjectURL = vi.fn()
;(document.getElementById("save-format") as HTMLSelectElement).value =
"drawio"
document.getElementById("save-confirm-btn")?.click()
const text = await new Promise<string>((resolve) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result))
reader.readAsText(saved as Blob)
})
expect(text).toBe("<mxfile>B</mxfile>")
})
})
describe("MCP preview with a diagram over the size limit", () => {
it("retries without the image, then tells the user", async () => {
const t = await inStep()
t.fromDrawio({ event: "autosave", xml: "<mxfile>huge</mxfile>" })
t.fromDrawio({ event: "export", data: "<svg/>" })
await t.settle()
const first = t.next("POST")
expect(first.body.svg).not.toBe("")
first.answer({ status: 413, body: { error: "Payload too large" } })
await t.settle()
const retry = t.next("POST")
expect(retry.body).toMatchObject({
xml: "<mxfile>huge</mxfile>",
svg: "",
})
expect(
document.getElementById("notice")?.classList.contains("open"),
).toBe(false)
retry.answer({ status: 413, body: { error: "Payload too large" } })
await t.settle()
expect(t.calls.filter((c) => c.method === "POST")).toHaveLength(0)
expect(document.getElementById("notice")?.textContent).toContain(
"too large",
)
})
})
+73 -2
View File
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
import { isPrivateUrl } from "@/lib/ssrf-protection"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { isPrivateUrl, redirectGuardedFetch } from "@/lib/ssrf-protection"
// Mock DNS so tests are deterministic and never hit the network.
const lookupMock = vi.hoisted(() => vi.fn())
@@ -77,3 +77,74 @@ describe("isPrivateUrl", () => {
)
})
})
describe("redirectGuardedFetch with the quota on", () => {
const answers = (map: Record<string, Response>) =>
vi.fn(
async (url: string) =>
map[String(url)] ?? new Response("?", { status: 404 }),
)
beforeEach(() => {
lookupMock.mockReset()
// Hosts ending in .example are public
lookupMock.mockImplementation(async (host: string) =>
host.endsWith(".example")
? [{ address: "93.184.216.34", family: 4 }]
: [],
)
process.env.DYNAMODB_QUOTA_TABLE = "quota"
delete process.env.ALLOW_PRIVATE_URLS
})
afterEach(() => {
delete process.env.DYNAMODB_QUOTA_TABLE
vi.unstubAllGlobals()
})
it("follows a redirect to a public address", async () => {
// A user's own proxy that moves http to https
vi.stubGlobal(
"fetch",
answers({
"http://proxy.example/v1/chat": new Response(null, {
status: 308,
headers: { location: "https://proxy.example/v1/chat" },
}),
"https://proxy.example/v1/chat": new Response("ok"),
}),
)
const guarded = redirectGuardedFetch()
expect(guarded).toBeDefined()
const res = await guarded?.("http://proxy.example/v1/chat", {
method: "POST",
body: "{}",
})
expect(await res?.text()).toBe("ok")
})
it("refuses a redirect to the server's own network", async () => {
// It would be counted as a public endpoint while using the server's
vi.stubGlobal(
"fetch",
answers({
"https://public.example/api/chat": new Response(null, {
status: 307,
headers: { location: "http://127.0.0.1:11434/api/chat" },
}),
}),
)
await expect(
redirectGuardedFetch()?.("https://public.example/api/chat", {
method: "POST",
body: "{}",
}),
).rejects.toThrow(/private addresses/)
expect(fetch).toHaveBeenCalledTimes(1)
})
it("is not used without the quota", () => {
delete process.env.DYNAMODB_QUOTA_TABLE
expect(redirectGuardedFetch()).toBeUndefined()
})
})
+43
View File
@@ -0,0 +1,43 @@
import { cleanup, render } from "@testing-library/react"
import { afterEach, describe, expect, it } from "vitest"
import { ToolCallCard } from "@/components/chat/ToolCallCard"
afterEach(cleanup)
const dict = {
tools: { complete: "Complete" },
chat: { copied: "Copied", failedToCopy: "Failed", copyResponse: "Copy" },
}
describe("ToolCallCard", () => {
it("shows streamed operations without crashing on broken entries", () => {
// A partly streamed or invalid edit_diagram input
const operations = [
null,
{ operation: {} },
{ operation: "add", cell_id: {} },
{ operation: "add", cell_id: "2", new_xml: {} },
{ operation: "update", cell_id: "3", new_xml: '<mxCell id="3"/>' },
]
const { container } = render(
<ToolCallCard
part={
{
type: "tool-edit_diagram",
toolCallId: "t1",
state: "input-streaming",
input: { operations },
} as any
}
expandedTools={{ t1: true }}
setExpandedTools={() => {}}
onCopy={() => {}}
copiedToolCallId={null}
copyFailedToolCallId={null}
dict={dict}
/>,
)
expect(container.textContent).toContain("cell_id: 3")
expect(container.textContent).toContain('<mxCell id="3"/>')
})
})
@@ -45,6 +45,77 @@ function setup(partialXml: string) {
return { refs, onDisplayChart, addToolOutput, append }
}
describe("the screenshot check and Stop", () => {
const draw = async (opts: {
isStopped: () => boolean
validateDiagram: () => Promise<any>
}) => {
const onValidationStateChange = vi.fn()
const { result } = renderHook(() =>
useDiagramToolHandlers({
partialXmlRef: { current: "" },
editDiagramOriginalXmlRef: { current: new Map() },
processedToolCallsRef: { current: new Set() },
validationRetryCountRef: { current: 0 },
chartXMLRef: { current: "" },
onDisplayChart: () => null,
onFetchChart: async () => "",
onExport: () => {},
enableVlmValidation: true,
captureValidationPng: async () => "data:image/png;base64,AA",
validateDiagram: opts.validateDiagram,
isStopped: opts.isStopped,
onValidationStateChange,
}),
)
const addToolOutput = vi.fn()
await result.current.handleToolCall(
{
toolCall: {
toolCallId: "d1",
toolName: "display_diagram",
input: { xml: box("2") },
},
},
addToolOutput,
)
return { addToolOutput, onValidationStateChange }
}
it("skips a check that had not started when the user stopped", async () => {
const validateDiagram = vi.fn(async () => ({
valid: true,
issues: [],
suggestions: [],
}))
const { addToolOutput, onValidationStateChange } = await draw({
isStopped: () => true,
validateDiagram,
})
expect(validateDiagram).not.toHaveBeenCalled()
expect(onValidationStateChange.mock.lastCall?.[1].status).toBe(
"skipped",
)
expect(addToolOutput.mock.lastCall?.[0].output).toMatch(
/Successfully displayed/,
)
})
it("ends with the diagram's result when Stop cancels a running check", async () => {
const { addToolOutput, onValidationStateChange } = await draw({
isStopped: () => false,
validateDiagram: async () => {
throw new DOMException("Validation cancelled", "AbortError")
},
})
expect(onValidationStateChange.mock.lastCall?.[1].status).toBe(
"skipped",
)
expect(addToolOutput).toHaveBeenCalledTimes(1)
expect(addToolOutput.mock.lastCall?.[0].state).toBeUndefined()
})
})
describe("append_diagram and the stored previews", () => {
it("takes the stored originals when it draws the completed diagram", async () => {
// Otherwise the preview code later loads the failed edit's original
+170
View File
@@ -0,0 +1,170 @@
import { act, renderHook, waitFor } from "@testing-library/react"
import { beforeEach, describe, expect, it, vi } from "vitest"
const stored = new Map<string, any>()
// Each write waits for the test to let it finish
let pendingWrites: (() => void)[] = []
// Reads wait for this
let readGate: Promise<void> = Promise.resolve()
vi.mock("@/hooks/use-dictionary", () => ({
useDictionary: () => ({ errors: { sessionSaveFailed: "Not saved" } }),
}))
vi.mock("@/lib/session-storage", async (importActual) => {
const actual = await importActual<typeof import("@/lib/session-storage")>()
return {
createEmptySession: actual.createEmptySession,
extractTitle: actual.extractTitle,
isIndexedDBAvailable: () => true,
migrateFromLocalStorage: async () => null,
readSessionCount: async () => stored.size,
enforceSessionLimit: async () => {},
getSession: async (id: string) => {
await readGate
return stored.get(id) ?? null
},
deleteSession: async (id: string) => {
stored.delete(id)
},
getAllSessionMetadata: async () =>
[...stored.values()].map((s) => ({ id: s.id, title: s.title })),
saveSession: vi.fn(
(session: any) =>
new Promise<boolean>((resolve) => {
pendingWrites.push(() => {
stored.set(session.id, session)
resolve(true)
})
}),
),
}
})
import { useSessionManager } from "@/hooks/use-session-manager"
const data = {
messages: [
{
id: "m1",
role: "user" as const,
parts: [{ type: "text", text: "Draw a cat" }],
},
],
xmlSnapshots: [] as [number, string][],
diagramXml: "",
}
// Let every write waiting now (and those it leads to) finish
async function finishWrites() {
for (let i = 0; i < 10; i++) {
await act(async () => {
const writes = pendingWrites
pendingWrites = []
for (const finish of writes) finish()
await new Promise((r) => setTimeout(r, 0))
})
}
}
async function setup() {
const hook = renderHook(() => useSessionManager())
await waitFor(() => expect(hook.result.current.isLoading).toBe(false))
return hook
}
describe("saving the chat on screen", () => {
beforeEach(() => {
stored.clear()
pendingWrites = []
})
it("creates one session when two saves of a new chat overlap", async () => {
const { result } = await setup()
let saves!: Promise<boolean[]>
act(() => {
saves = Promise.all([
result.current.saveCurrentSession(data),
result.current.saveCurrentSession(data),
])
})
await finishWrites()
expect(await saves).toEqual([true, true])
expect(stored.size).toBe(1)
expect(result.current.currentSessionId).toBe([...stored.keys()][0])
})
it("drops a save scheduled before New Chat", async () => {
const { result } = await setup()
const scheduled = result.current.getChatGeneration()
act(() => result.current.clearCurrentSession())
let save!: Promise<boolean>
act(() => {
save = result.current.saveCurrentSession(data, scheduled)
})
await finishWrites()
expect(await save).toBe(true)
expect(stored.size).toBe(0)
})
it("drops a save of the old chat waiting behind New Chat's save", async () => {
const { result } = await setup()
// The auto-save is scheduled, then New Chat saves and clears
const scheduled = result.current.getChatGeneration()
let newChatSave!: Promise<boolean>
let autoSave!: Promise<boolean>
act(() => {
newChatSave = result.current.saveCurrentSession(data)
autoSave = result.current.saveCurrentSession(data, scheduled)
})
await act(async () => {
await waitFor(() => expect(pendingWrites).toHaveLength(1))
pendingWrites.shift()?.()
await newChatSave
result.current.clearCurrentSession()
})
await finishWrites()
await autoSave
expect(stored.size).toBe(1)
expect(result.current.currentSessionId).toBeNull()
})
it("keeps the new blank chat when a save of the old one ends later", async () => {
const { result } = await setup()
let save!: Promise<boolean>
act(() => {
save = result.current.saveCurrentSession(data)
})
await waitFor(() => expect(pendingWrites).toHaveLength(1))
// New Chat while the write runs
act(() => result.current.clearCurrentSession())
await finishWrites()
await save
expect(stored.size).toBe(1)
expect(result.current.currentSessionId).toBeNull()
expect(result.current.currentSession).toBeNull()
})
it("keeps New Chat when the URL's chat finishes loading after it", async () => {
stored.set("s1", { id: "s1", title: "Old", messages: [] })
const hook = renderHook(
({ id }: { id: string | null }) =>
useSessionManager({ initialSessionId: id }),
{ initialProps: { id: null as string | null } },
)
await waitFor(() => expect(hook.result.current.isLoading).toBe(false))
// The new chat's id reaches the URL; reading it takes a moment
let release!: () => void
readGate = new Promise((r) => {
release = r
})
hook.rerender({ id: "s1" })
act(() => hook.result.current.clearCurrentSession())
await act(async () => {
release()
await new Promise((r) => setTimeout(r, 0))
})
readGate = Promise.resolve()
expect(hook.result.current.currentSessionId).toBeNull()
})
})
+33 -1
View File
@@ -1,5 +1,37 @@
import { describe, expect, it } from "vitest"
import { cn, extractCompleteMxCells, isMxCellXmlComplete } from "@/lib/utils"
import {
cn,
extractCompleteMxCells,
isMxCellXmlComplete,
isRealDiagram,
} from "@/lib/utils"
import { BLANK_MXFILE } from "@/packages/mcp-server/src/pages.ts"
describe("isRealDiagram", () => {
it("counts a small diagram with one shape", () => {
// 234 characters: valid, shown, and saved with its chat
const xml =
'<mxfile><diagram id="p"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" value="A" vertex="1" parent="1"><mxGeometry width="80" height="30" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>'
expect(xml.length).toBeLessThan(300)
expect(isRealDiagram(xml)).toBe(true)
})
it("does not count a blank page or nothing", () => {
expect(isRealDiagram(BLANK_MXFILE)).toBe(false)
expect(isRealDiagram("")).toBe(false)
expect(isRealDiagram(null)).toBe(false)
})
it("still counts a longer document of empty named pages", () => {
// Pages and page settings are worth keeping, as before
const pages = Array.from(
{ length: 3 },
(_, i) =>
`<diagram id="p${i}" name="Page ${i}"><mxGraphModel pageWidth="1600" pageHeight="900"><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram>`,
).join("")
expect(isRealDiagram(`<mxfile>${pages}</mxfile>`)).toBe(true)
})
})
describe("isMxCellXmlComplete", () => {
it("returns false for empty/null input", () => {
+59 -1
View File
@@ -49,15 +49,73 @@ vi.mock("@/lib/ai-providers", () => ({
}),
}))
// The quota, off unless a test turns it on
const quota = vi.hoisted(() => ({
enabled: false,
allowed: true,
checks: [] as Array<{ limits: any; increment?: number }>,
recorded: [] as number[],
}))
vi.mock("@/lib/dynamo-quota-manager", () => ({
isQuotaEnabled: () => quota.enabled,
checkAndIncrementRequest: async (
_ip: string,
limits: unknown,
increment?: number,
) => {
quota.checks.push({ limits, increment })
return quota.allowed
? { allowed: true }
: {
allowed: false,
type: "token",
error: "Daily token limit exceeded",
used: 10,
limit: 10,
}
},
recordTokenUsage: async (_ip: string, tokens: number) => {
quota.recorded.push(tokens)
},
}))
const post = () =>
new Request("http://localhost/api/validate-diagram", {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: {
"Content-Type": "application/json",
"x-forwarded-for": "203.0.113.7",
},
body: JSON.stringify({ imageData: "data:image/png;base64,AAAA" }),
})
afterEach(() => {
delete process.env.ENABLE_VLM_VALIDATION
quota.enabled = false
quota.allowed = true
quota.checks = []
quota.recorded = []
})
describe("the quota", () => {
it("refuses a check once the daily tokens are used up", async () => {
quota.enabled = true
quota.allowed = false
const res = await validateDiagram(post())
expect(res.status).toBe(429)
})
it("applies the token limits only, and records the tokens", async () => {
// The request limit is for chats: the day's last chat must still
// get its check, and the check does not count as a chat
quota.enabled = true
const res = await validateDiagram(post())
expect(JSON.parse(await res.text())).toEqual(RESULT)
expect(quota.checks).toHaveLength(1)
expect(quota.checks[0].increment).toBe(0)
expect(quota.checks[0].limits.requests).toBe(0)
await vi.waitFor(() => expect(quota.recorded).toEqual([2]))
})
})
describe("POST /api/validate-diagram", () => {
+34
View File
@@ -0,0 +1,34 @@
// @vitest-environment node
import { describe, expect, it, vi } from "vitest"
// What the Test hands to the provider factory
const factory = vi.hoisted(() => ({ overrides: null as any }))
vi.mock("@/lib/ai-providers", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/ai-providers")>()),
getAIModel: (overrides: unknown) => {
factory.overrides = overrides
throw new Error("stop here")
},
}))
import { POST as validateModel } from "@/app/api/validate-model/route"
describe("testing Bedrock", () => {
it("passes temporary credentials' session token on, as the chat does", async () => {
await validateModel(
new Request("http://localhost/api/validate-model", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider: "bedrock",
modelId: "amazon.nova-lite-v1:0",
awsAccessKeyId: "ASIA-temporary",
awsSecretAccessKey: "secret",
awsRegion: "us-east-1",
awsSessionToken: "session-token",
}),
}),
)
expect(factory.overrides.awsSessionToken).toBe("session-token")
})
})
+117 -2
View File
@@ -8,10 +8,11 @@ import { getAIModel } from "@/lib/ai-providers"
// No saved admin providers
vi.mock("@/lib/admin/settings", () => ({ loadSettings: () => ({}) }))
// Treat every URL as public so no test hits DNS
// Every URL is public (no DNS in tests), unless a test says otherwise
const privateUrls = vi.hoisted(() => ({ all: false }))
vi.mock("@/lib/ssrf-protection", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/ssrf-protection")>()),
isPrivateUrl: async () => false,
isPrivateUrl: async () => privateUrls.all,
}))
afterEach(() => {
@@ -163,6 +164,120 @@ describe("chat requests to a client base URL", () => {
})
})
describe("testing EdgeOne", () => {
// The request validate-model sends to the EdgeOne function
const capture = () => {
const calls: Array<{ url: string; headers: Headers }> = []
vi.stubGlobal(
"fetch",
vi.fn(async (url: string, init?: RequestInit) => {
calls.push({
url: String(url),
headers: new Headers(init?.headers),
})
throw new Error("no network in tests")
}),
)
return calls
}
it("calls the site's own function, also without a base URL", async () => {
// The admin panel's Test sends none; a relative one cannot be fetched
const calls = capture()
await validateModel(
new Request("http://localhost/api/validate-model", {
method: "POST",
headers: {
"Content-Type": "application/json",
origin: "https://draw.example",
},
body: JSON.stringify({
provider: "edgeone",
modelId: "@tx/deepseek-ai/deepseek-v3-0324",
baseUrl: "https://elsewhere.example/api/edgeai",
}),
}),
)
expect(calls[0]?.url).toBe(
"https://draw.example/api/edgeai/chat/completions",
)
})
it("passes the admin's access code and cookies on", async () => {
// The EdgeOne function checks the access code too
process.env.ADMIN_PASSWORD = "admin-pw"
try {
const calls = capture()
await testModel(
new Request("http://localhost/api/admin/test-model", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-admin-password": "admin-pw",
"x-access-code": "visitor-code",
cookie: "eo_token=t",
origin: "https://draw.example",
},
body: JSON.stringify({
provider: {
id: "p1",
provider: "edgeone",
models: ["@tx/deepseek-ai/deepseek-v3-0324"],
},
modelId: "@tx/deepseek-ai/deepseek-v3-0324",
}),
}),
)
expect(calls[0]?.url).toBe(
"https://draw.example/api/edgeai/chat/completions",
)
expect(calls[0]?.headers.get("x-access-code")).toBe("visitor-code")
expect(calls[0]?.headers.get("cookie")).toBe("eo_token=t")
} finally {
delete process.env.ADMIN_PASSWORD
}
})
})
describe("the admin Test of the server's own base URL", () => {
const test = (headers: Record<string, string>) =>
validateModel(
new Request("http://localhost/api/validate-model", {
method: "POST",
headers: { "Content-Type": "application/json", ...headers },
body: JSON.stringify({
provider: "openai",
apiKey: "panel-key",
modelId: "gpt-5.5",
baseUrl: "http://10.0.0.5:8000/v1",
serverBaseUrl: true,
}),
}),
)
it("tests it as chat uses it: an internal address is allowed", async () => {
// ALLOW_PRIVATE_URLS=false guards URLs users type, not the server's
process.env.ALLOW_PRIVATE_URLS = "false"
process.env.OPENAI_BASE_URL = "http://10.0.0.5:8000/v1"
process.env.ADMIN_PASSWORD = "admin-pw"
privateUrls.all = true
try {
streamReply({ role: "assistant", content: "OK" })
const admin = await (
await test({ "x-admin-password": "admin-pw" })
).json()
expect(admin.valid).toBe(true)
// Anyone else claiming it is still refused
const other = await test({})
expect(other.status).toBe(400)
} finally {
privateUrls.all = false
delete process.env.OPENAI_BASE_URL
delete process.env.ADMIN_PASSWORD
}
})
})
describe("the admin panel's Test button", () => {
it("works when access codes are set", async () => {
// The admin password stands in for the visitor access code