mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-08 10:47:48 +08:00
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:
@@ -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
@@ -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}
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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" && {
|
||||
|
||||
@@ -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
@@ -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 = (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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}`,
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -187,6 +187,8 @@
|
||||
"failedToRecordFeedback": "フィードバックの記録に失敗しました。もう一度お試しください。",
|
||||
"storageUpdateFailed": "チャットはクリアされましたが、ブラウザストレージを更新できませんでした",
|
||||
"sessionSaveFailed": "このチャットを保存できませんでした。ブラウザのストレージがいっぱいの可能性があります。履歴から古いチャットを削除して、もう一度お試しください。",
|
||||
"sessionSaveFailedLeave": "このチャットを保存できませんでした。ブラウザのストレージがいっぱいの可能性があります。保存せずに続けて、新しいチャットの一覧から古いチャットを削除できます。",
|
||||
"continueWithoutSaving": "保存せずに続ける",
|
||||
"llm": {
|
||||
"invalid_api_key": "プロバイダーが API キーを拒否しました。モデル設定で確認してください。",
|
||||
"forbidden": "プロバイダーがリクエストを拒否しました。このキーにはこのモデルまたはリージョンの利用権限がない可能性があります。",
|
||||
|
||||
@@ -187,6 +187,8 @@
|
||||
"failedToRecordFeedback": "記錄您的回饋失敗。請重試。",
|
||||
"storageUpdateFailed": "聊天已清除,但無法更新瀏覽器儲存空間",
|
||||
"sessionSaveFailed": "無法儲存這個對話。瀏覽器儲存空間可能已滿,請在歷史紀錄裡刪除舊對話後重試。",
|
||||
"sessionSaveFailedLeave": "無法儲存這個對話,瀏覽器儲存空間可能已滿。可以不儲存它、直接繼續,再在新對話的列表裡刪除舊對話。",
|
||||
"continueWithoutSaving": "不儲存,繼續",
|
||||
"llm": {
|
||||
"invalid_api_key": "服務商拒絕了這個 API Key,請在模型設定中檢查。",
|
||||
"forbidden": "服務商拒絕了這次請求。這個 Key 可能沒有使用該模型或該地區的權限。",
|
||||
|
||||
@@ -187,6 +187,8 @@
|
||||
"failedToRecordFeedback": "记录您的反馈失败。请重试。",
|
||||
"storageUpdateFailed": "聊天已清除,但无法更新浏览器存储",
|
||||
"sessionSaveFailed": "无法保存这个对话。浏览器存储空间可能已满,请在历史记录里删除旧对话后重试。",
|
||||
"sessionSaveFailedLeave": "无法保存这个对话,浏览器存储空间可能已满。可以不保存它、直接继续,再在新对话的列表里删除旧对话。",
|
||||
"continueWithoutSaving": "不保存,继续",
|
||||
"llm": {
|
||||
"invalid_api_key": "服务商拒绝了这个 API Key,请在模型设置里检查。",
|
||||
"forbidden": "服务商拒绝了这次请求。这个 Key 可能没有使用该模型或该地区的权限。",
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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]))
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user