mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-06 01:37:48 +08:00
fix: what the third round broke, and the first batch's review
MCP preview after the server lost a session (it expired, or the MCP process restarted): - Every server state has an id, made when the state is created. The tab notices a new id even when the version numbers happen to match, and every push names the state it was based on, so one based on a lost state is refused, also when it comes before the tab's first poll (the server recovers the saved file first). - The tab keeps the newest canvas XML, saved or not. When the server knows nothing (no file) or exactly what the tab last saved, the canvas wins and is saved, so edits made while the server was down are kept. Otherwise the server's diagram (an AI write the tab missed, a cleared document that was saved) is shown and the tab's copy goes to History. - Late answers to an old state's push or poll are dropped; a failed push says the server is unreachable; Download as .drawio saves the canvas. Settings and server: - Saved providers this version does not know stay in storage with their keys, and sending no longer trips over them. - The desktop "Ollama (Local)" preset with a key goes to local Ollama again; a server model's Ollama URL variable is read; the admin panel writes Ollama Cloud's URL for a key without one. - Provider error texts show again in the desktop app and for EdgeOne. - .env: a quoted value followed by a comment ending in a quote is read as dotenv reads it; unquoted values are unchanged. - Desktop app: the next launch opens the port where a chat was last saved; a launch elsewhere that saves nothing does not move it, and a page with no chats lets the next launch try the other port once. - The Test button no longer stays busy after another tab changed the key. - A completed append_diagram is no longer undone by an earlier failed edit's preview; a file read once in vain is saved again once it is read or gone. From the first batch's review: - The admin panel's Test of an entry without a URL now tests the server's <P>_BASE_URL, where chat sends the entry's key; chat is unchanged (the first fix rerouted working setups). - The model list ends downloads that are too large, accepts answers without a body, and keeps the "redirects are not allowed" explanation. - A test covers the preview's History rendering.
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
||||
loadAdminProviders,
|
||||
mergeSecrets,
|
||||
} from "@/lib/admin/providers"
|
||||
import { globalBaseUrl } from "@/lib/ai-providers"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
export const dynamic = "force-dynamic"
|
||||
@@ -58,7 +59,10 @@ export async function POST(req: Request) {
|
||||
body: JSON.stringify({
|
||||
provider: resolved.provider,
|
||||
apiKey: resolved.apiKey,
|
||||
baseUrl: resolved.baseUrl,
|
||||
// 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),
|
||||
modelId: body.modelId,
|
||||
awsAccessKeyId: resolved.awsAccessKeyId,
|
||||
awsSecretAccessKey: resolved.awsSecretAccessKey,
|
||||
|
||||
@@ -738,9 +738,10 @@ Call this tool to get shape names and usage syntax for a specific library.`,
|
||||
|
||||
const response = result.toUIMessageStreamResponse({
|
||||
sendReasoning: true,
|
||||
// The provider's text can name the server's account or hosts
|
||||
onError: (error) =>
|
||||
streamErrorText(error, onServerCredentials || onServerEndpoint),
|
||||
// On the server's keys the provider's text can name its account.
|
||||
// Keyless endpoints keep theirs: the desktop app's Ollama is the
|
||||
// user's own, and EdgeOne's text is our function's explanation.
|
||||
onError: (error) => streamErrorText(error, onServerCredentials),
|
||||
messageMetadata: ({ part }) => {
|
||||
if (part.type === "finish") {
|
||||
const usage = (part as any).totalUsage
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import {
|
||||
allowPrivateUrls,
|
||||
isPrivateUrl,
|
||||
RedirectRefusedError,
|
||||
redirectGuardedFetch,
|
||||
} from "@/lib/ssrf-protection"
|
||||
import type { ProviderName } from "@/lib/types/model-config"
|
||||
@@ -65,13 +66,10 @@ export async function POST(req: Request) {
|
||||
// Only our own explanations go back: the URL may be an internal
|
||||
// address, whose answer or host names must not reach the caller.
|
||||
// The Gateway SDK wraps them, keeping ours as the cause.
|
||||
const isOwn = (e: unknown): e is Error =>
|
||||
e instanceof ModelListError || e instanceof RedirectRefusedError
|
||||
const cause = (error as { cause?: unknown })?.cause
|
||||
const own =
|
||||
error instanceof ModelListError
|
||||
? error
|
||||
: cause instanceof ModelListError
|
||||
? cause
|
||||
: null
|
||||
const own = isOwn(error) ? error : isOwn(cause) ? cause : null
|
||||
const { code } = classifyLLMError(own ?? error)
|
||||
return NextResponse.json({
|
||||
code,
|
||||
|
||||
@@ -189,6 +189,9 @@ export function ModelConfigDialog({
|
||||
selectedProviderIdRef.current = selectedProviderId
|
||||
const configRef = useRef(config)
|
||||
configRef.current = config
|
||||
// Number of the latest Test click: only that test may reset the busy
|
||||
// state when its credentials changed meanwhile
|
||||
const validationRunRef = useRef(0)
|
||||
// A model list or test result belongs to the credentials it was asked
|
||||
// with; they can change meanwhile, here or in another tab
|
||||
const credentialsOf = (providerId: string) => {
|
||||
@@ -414,6 +417,7 @@ export function ModelConfigDialog({
|
||||
let errorCount = 0
|
||||
let idChanged = false
|
||||
const askedWith = credentialsOf(selectedProviderId)
|
||||
const run = ++validationRunRef.current
|
||||
|
||||
// For EdgeOne, construct baseUrl from current origin
|
||||
const baseUrl = isEdgeOne
|
||||
@@ -488,8 +492,21 @@ export function ModelConfigDialog({
|
||||
validationWarning: undefined,
|
||||
}
|
||||
}
|
||||
// Credentials changed during the test: drop the result
|
||||
if (credentialsOf(selectedProviderId) !== askedWith) 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).
|
||||
if (credentialsOf(selectedProviderId) !== askedWith) {
|
||||
if (run === validationRunRef.current) {
|
||||
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
|
||||
const current = configRef.current.providers
|
||||
.find((p) => p.id === selectedProviderId)
|
||||
@@ -515,7 +532,17 @@ export function ModelConfigDialog({
|
||||
})
|
||||
}),
|
||||
)
|
||||
if (credentialsOf(selectedProviderId) !== askedWith) 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
|
||||
) {
|
||||
setValidationStatus("idle")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// A model whose id changed was not tested
|
||||
if (allValid && !idChanged) {
|
||||
|
||||
Vendored
+4
@@ -75,6 +75,10 @@ declare global {
|
||||
* preset); returns a function that stops the calls
|
||||
*/
|
||||
onServerRestarted?: (callback: () => void) => () => void
|
||||
/** A chat was saved: open this port next launch */
|
||||
chatSaved?: () => Promise<void>
|
||||
/** The page loaded with this many chats */
|
||||
chatsLoaded?: (count: number) => Promise<void>
|
||||
}
|
||||
|
||||
/** Settings window Electron API */
|
||||
|
||||
@@ -51,17 +51,25 @@ function loadEnvFromFile(filePath: string): void {
|
||||
const quote = value[0]
|
||||
const closingQuote =
|
||||
quote === '"' || quote === "'" ? value.indexOf(quote, 1) : -1
|
||||
if (value.length > 1 && value.endsWith(quote) && closingQuote > 0) {
|
||||
// Quoted from start to end: the quotes inside belong to the
|
||||
// value (JSON with an apostrophe), as dotenv reads it
|
||||
value = value.slice(1, -1)
|
||||
} else if (closingQuote > 0) {
|
||||
// Quoted value: keep what's inside the quotes and drop
|
||||
// anything after them (e.g. a comment)
|
||||
if (
|
||||
closingQuote > 0 &&
|
||||
/^\s*(#.*)?$/.test(value.slice(closingQuote + 1))
|
||||
) {
|
||||
// Quoted value, then nothing or a comment: keep what is
|
||||
// inside the quotes, as dotenv reads it
|
||||
value = value.slice(1, closingQuote)
|
||||
} else {
|
||||
// Unquoted value: drop an inline comment ("value # comment")
|
||||
// Unquoted value: drop an inline comment ("value # comment").
|
||||
// A value quoted from start to end with quotes inside (JSON
|
||||
// with an apostrophe) loses only the outer two, as in dotenv.
|
||||
value = value.replace(/\s+#.*$/, "")
|
||||
if (
|
||||
closingQuote > 0 &&
|
||||
value.length > 1 &&
|
||||
value.endsWith(quote)
|
||||
) {
|
||||
value = value.slice(1, -1)
|
||||
}
|
||||
}
|
||||
|
||||
// Don't override existing environment variables
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
updatePreset,
|
||||
} from "./config-manager"
|
||||
import { restartNextServer } from "./next-server"
|
||||
import { noteNoChats, rememberChatPort } from "./port-manager"
|
||||
import {
|
||||
applyProxyToEnv,
|
||||
getProxyConfig,
|
||||
@@ -77,6 +78,15 @@ export function registerIpcHandlers(): void {
|
||||
return app.getVersion()
|
||||
})
|
||||
|
||||
// ==================== Where the chats are ====================
|
||||
|
||||
// The page saved a chat, or loaded without any: decides which port
|
||||
// (and so which origin's chats) the next launch opens
|
||||
handle("chat-saved", () => rememberChatPort())
|
||||
handle("chats-loaded", (_event, count: unknown) => {
|
||||
if (count === 0) noteNoChats()
|
||||
})
|
||||
|
||||
// ==================== Window Controls ====================
|
||||
|
||||
ipcMain.on("window-minimize", (event) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { existsSync } from "node:fs"
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs"
|
||||
import net from "node:net"
|
||||
import path from "node:path"
|
||||
import { app } from "electron"
|
||||
@@ -39,6 +39,55 @@ function hasStoredData(port: number): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
// The two fixed production ports, the only ones whose origin (and so its
|
||||
// chats and settings) is the same at every launch
|
||||
const HOME_PORTS = [PORT_CONFIG.legacyProduction, PORT_CONFIG.production]
|
||||
|
||||
const chatPortFile = () => path.join(app.getPath("userData"), "chat-port.json")
|
||||
|
||||
/** The fixed port where a chat was last saved, if known */
|
||||
function readChatPort(): number | null {
|
||||
try {
|
||||
const { port } = JSON.parse(readFileSync(chatPortFile(), "utf-8"))
|
||||
return HOME_PORTS.includes(port) ? port : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function writeChatPort(port: number): void {
|
||||
try {
|
||||
writeFileSync(chatPortFile(), JSON.stringify({ port }))
|
||||
} catch (error) {
|
||||
console.warn("Could not save the chat port:", error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The page saved a chat: open on this port next time. Chats of the two
|
||||
* ports cannot be shown together (each origin has its own storage), so the
|
||||
* app opens where the user last worked. A launch that had to use the other
|
||||
* port and saved nothing does not move it.
|
||||
*/
|
||||
export function rememberChatPort(): void {
|
||||
const port = allocatedPort
|
||||
if (!app.isPackaged || port === null || !HOME_PORTS.includes(port)) return
|
||||
if (readChatPort() !== port) writeChatPort(port)
|
||||
}
|
||||
|
||||
/**
|
||||
* The page loaded without any chats. Before any chat was saved under this
|
||||
* version (no file yet), the user's chats may be on the other fixed port,
|
||||
* where an older version opened: try it first next time.
|
||||
*/
|
||||
export function noteNoChats(): void {
|
||||
const port = allocatedPort
|
||||
if (!app.isPackaged || port === null || !HOME_PORTS.includes(port)) return
|
||||
if (existsSync(chatPortFile())) return
|
||||
const other = HOME_PORTS.find((p) => p !== port)
|
||||
if (other !== undefined && hasStoredData(other)) writeChatPort(other)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific port is available
|
||||
*/
|
||||
@@ -86,15 +135,19 @@ export async function findAvailablePort(reuseExisting = true): Promise<number> {
|
||||
allocatedPort = null
|
||||
}
|
||||
|
||||
// In production, try the legacy port first to preserve existing users'
|
||||
// data, unless only the new port has data: their app started on 13370
|
||||
// while Windows reserved 61337, and 61337 being free now would hide it
|
||||
// In production, first the port where a chat was last saved. Without
|
||||
// one, the legacy port first to preserve existing users' data, unless
|
||||
// only the new port has data: their app started on 13370 while Windows
|
||||
// reserved 61337, and 61337 being free now would hide it
|
||||
const chatPort = isDev ? null : readChatPort()
|
||||
const candidates = isDev
|
||||
? [preferredPort]
|
||||
: hasStoredData(PORT_CONFIG.production) &&
|
||||
!hasStoredData(PORT_CONFIG.legacyProduction)
|
||||
? [PORT_CONFIG.production, PORT_CONFIG.legacyProduction]
|
||||
: [PORT_CONFIG.legacyProduction, PORT_CONFIG.production]
|
||||
: chatPort !== null
|
||||
? [chatPort, ...HOME_PORTS.filter((p) => p !== chatPort)]
|
||||
: hasStoredData(PORT_CONFIG.production) &&
|
||||
!hasStoredData(PORT_CONFIG.legacyProduction)
|
||||
? [PORT_CONFIG.production, PORT_CONFIG.legacyProduction]
|
||||
: [PORT_CONFIG.legacyProduction, PORT_CONFIG.production]
|
||||
for (const port of candidates) {
|
||||
if (await isPortAvailable(port)) {
|
||||
allocatedPort = port
|
||||
|
||||
@@ -28,6 +28,11 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
setUserLocale: (locale: string) =>
|
||||
ipcRenderer.invoke("set-user-locale", locale),
|
||||
|
||||
// A chat was saved, or the page loaded with this many chats: the next
|
||||
// launch opens the port where the chats are
|
||||
chatSaved: () => ipcRenderer.invoke("chat-saved"),
|
||||
chatsLoaded: (count: number) => ipcRenderer.invoke("chats-loaded", count),
|
||||
|
||||
// The server restarted on the same port (another preset)
|
||||
onServerRestarted: (callback: () => void) => {
|
||||
const listener = () => callback()
|
||||
|
||||
+1
-1
@@ -70,7 +70,7 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
||||
# AZURE_REASONING_SUMMARY=detailed
|
||||
|
||||
# Ollama Configuration (Local or Cloud)
|
||||
# OLLAMA_BASE_URL=https://ollama.com/api # Optional, defaults to Ollama Cloud
|
||||
# OLLAMA_BASE_URL=https://ollama.com/api # Optional: Ollama Cloud; defaults to local Ollama (http://127.0.0.1:11434)
|
||||
# OLLAMA_API_KEY=your-ollama-cloud-api-key # Optional: For Ollama Cloud or authenticated remote instances
|
||||
# OLLAMA_ENABLE_THINKING=true # Optional: Enable thinking for models that support it (e.g., qwen3)
|
||||
|
||||
|
||||
@@ -122,34 +122,33 @@ export function useDiagramToolHandlers({
|
||||
}
|
||||
|
||||
processedToolCallsRef.current.add(toolCall.toolCallId)
|
||||
// Only these two put their result on the canvas. Other tools
|
||||
// (get_shape_library, which the server runs, still arrives here)
|
||||
// leave the stored originals for the preview code to undo.
|
||||
const drawsDiagram =
|
||||
toolCall.toolName === "display_diagram" ||
|
||||
toolCall.toolName === "edit_diagram"
|
||||
// Stored originals belong to previews not handled yet: this call's,
|
||||
// and those of earlier calls with invalid input, which never get
|
||||
// here. The first is the diagram before all of them. This call's
|
||||
// result replaces those previews, so the preview code must neither
|
||||
// draw them again nor undo them later.
|
||||
const [originalXml] = editDiagramOriginalXmlRef.current.values()
|
||||
if (drawsDiagram) {
|
||||
for (const id of editDiagramOriginalXmlRef.current.keys()) {
|
||||
processedToolCallsRef.current.add(id)
|
||||
}
|
||||
editDiagramOriginalXmlRef.current.clear()
|
||||
}
|
||||
|
||||
// Only display_diagram, edit_diagram and a completing append_diagram
|
||||
// put their result on the canvas. Other tools (get_shape_library,
|
||||
// which the server runs, still arrives here) leave the stored
|
||||
// originals for the preview code to undo.
|
||||
if (toolCall.toolName === "display_diagram") {
|
||||
await handleDisplayDiagram(toolCall, addToolOutput, originalXml)
|
||||
await handleDisplayDiagram(toolCall, addToolOutput, takeOriginals())
|
||||
} else if (toolCall.toolName === "edit_diagram") {
|
||||
await handleEditDiagram(toolCall, addToolOutput, originalXml)
|
||||
await handleEditDiagram(toolCall, addToolOutput, takeOriginals())
|
||||
} else if (toolCall.toolName === "append_diagram") {
|
||||
handleAppendDiagram(toolCall, addToolOutput)
|
||||
}
|
||||
}
|
||||
|
||||
// Stored originals belong to previews not handled yet: this call's, and
|
||||
// those of earlier calls with invalid input, which never get to the
|
||||
// handler. The first is the diagram before all of them. A call that
|
||||
// draws its result replaces those previews, so the preview code must
|
||||
// neither draw them again nor undo them later. Returns that first one.
|
||||
const takeOriginals = (): string | undefined => {
|
||||
const [originalXml] = editDiagramOriginalXmlRef.current.values()
|
||||
for (const id of editDiagramOriginalXmlRef.current.keys()) {
|
||||
processedToolCallsRef.current.add(id)
|
||||
}
|
||||
editDiagramOriginalXmlRef.current.clear()
|
||||
return originalXml
|
||||
}
|
||||
|
||||
// originalXml: the diagram before the streamed previews, if any were drawn
|
||||
const handleDisplayDiagram = async (
|
||||
toolCall: ToolCall,
|
||||
@@ -540,11 +539,17 @@ Start your continuation with the NEXT character after where it stopped.`,
|
||||
partialXmlRef.current = "" // Reset
|
||||
|
||||
const prepared = prepareNewDiagram(finalXml, NEW_PAGE)
|
||||
// It draws now: it takes the stored originals, as display_diagram
|
||||
const originalXml = prepared.ok ? takeOriginals() : undefined
|
||||
const validationError = prepared.ok
|
||||
? onDisplayChart(prepared.xml, true)
|
||||
: prepared.error
|
||||
|
||||
if (validationError) {
|
||||
// Loading failed: back to the diagram before the previews
|
||||
if (prepared.ok && originalXml) {
|
||||
onDisplayChart(originalXml, true)
|
||||
}
|
||||
addToolOutput({
|
||||
tool: "append_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
|
||||
+45
-17
@@ -64,6 +64,28 @@ function migrateOldConfig(): MultiModelConfig | null {
|
||||
return config
|
||||
}
|
||||
|
||||
const isKnownProvider = (p: { provider: string }) =>
|
||||
Object.hasOwn(PROVIDER_INFO, p.provider)
|
||||
|
||||
/**
|
||||
* The stored config without providers this version does not know (saved
|
||||
* by another version, or edited by hand): they would break every list of
|
||||
* models. They stay in storage (saveConfig keeps them). Throws on bad JSON.
|
||||
*/
|
||||
function parseStoredConfig(stored: string): MultiModelConfig {
|
||||
const config = JSON.parse(stored) as MultiModelConfig
|
||||
const known = config.providers.filter(isKnownProvider)
|
||||
if (known.length < config.providers.length) {
|
||||
console.warn(
|
||||
"Skipped saved providers this version does not know:",
|
||||
config.providers
|
||||
.filter((p) => !isKnownProvider(p))
|
||||
.map((p) => p.provider),
|
||||
)
|
||||
}
|
||||
return { ...config, providers: known }
|
||||
}
|
||||
|
||||
/**
|
||||
* Load config from localStorage
|
||||
*/
|
||||
@@ -74,21 +96,7 @@ function loadConfig(): MultiModelConfig {
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.modelConfigs)
|
||||
if (stored) {
|
||||
try {
|
||||
const config = JSON.parse(stored) as MultiModelConfig
|
||||
// A provider this version does not know (saved by another
|
||||
// version, or edited by hand) would break every list of models
|
||||
const known = config.providers.filter((p) =>
|
||||
Object.hasOwn(PROVIDER_INFO, p.provider),
|
||||
)
|
||||
if (known.length < config.providers.length) {
|
||||
console.warn(
|
||||
"Skipped saved providers this version does not know:",
|
||||
config.providers
|
||||
.filter((p) => !known.includes(p))
|
||||
.map((p) => p.provider),
|
||||
)
|
||||
}
|
||||
return { ...config, providers: known }
|
||||
return parseStoredConfig(stored)
|
||||
} catch {
|
||||
console.error("Failed to parse model config")
|
||||
}
|
||||
@@ -113,7 +121,26 @@ function loadConfig(): MultiModelConfig {
|
||||
*/
|
||||
function saveConfig(config: MultiModelConfig): void {
|
||||
if (typeof window === "undefined") return
|
||||
localStorage.setItem(STORAGE_KEYS.modelConfigs, JSON.stringify(config))
|
||||
// Providers this version does not know are not in config: keep them,
|
||||
// with their keys, for the version that saved them
|
||||
let unknown: MultiModelConfig["providers"] = []
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.modelConfigs)
|
||||
if (stored) {
|
||||
unknown = (JSON.parse(stored) as MultiModelConfig).providers.filter(
|
||||
(p) => !isKnownProvider(p),
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// Unreadable: nothing to keep
|
||||
}
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.modelConfigs,
|
||||
JSON.stringify({
|
||||
...config,
|
||||
providers: [...config.providers, ...unknown],
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -488,7 +515,8 @@ export function getSelectedAIConfig(): {
|
||||
|
||||
let config: MultiModelConfig
|
||||
try {
|
||||
config = JSON.parse(stored)
|
||||
// Unknown providers would break the model lookup below
|
||||
config = parseStoredConfig(stored)
|
||||
} catch {
|
||||
return { ...empty, accessCode }
|
||||
}
|
||||
|
||||
@@ -114,6 +114,11 @@ export function useSessionManager(
|
||||
// Load sessions list
|
||||
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(() => {})
|
||||
|
||||
// Only load a session if initialSessionId is provided (from URL param)
|
||||
if (initialSessionId) {
|
||||
|
||||
@@ -213,17 +213,12 @@ export function adminProvidersToConfig(
|
||||
indexByProvider.set(p.provider, index + 1)
|
||||
if (p.models.length === 0) continue
|
||||
const env = credEnvNames(p.provider, index)
|
||||
// An entry with its own key also names its own URL variable, unset
|
||||
// when the URL is empty: the global <P>_BASE_URL may be a proxy for
|
||||
// another key, and the Test used the official endpoint. An Azure
|
||||
// key belongs to one resource, so it keeps the server's.
|
||||
const ownUrl = !!p.baseUrl || (!!p.apiKey && p.provider !== "azure")
|
||||
config.providers.push({
|
||||
name: displayName(p),
|
||||
provider: p.provider,
|
||||
models: p.models,
|
||||
...(env.key && p.apiKey ? { apiKeyEnv: env.key } : {}),
|
||||
...(env.url && ownUrl ? { baseUrlEnv: env.url } : {}),
|
||||
...(env.url && p.baseUrl ? { baseUrlEnv: env.url } : {}),
|
||||
...(p.isDefault ? { default: true } : {}),
|
||||
})
|
||||
}
|
||||
@@ -262,7 +257,12 @@ 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
|
||||
if (p.baseUrl) updates.OLLAMA_BASE_URL = p.baseUrl
|
||||
// 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
|
||||
}
|
||||
} else {
|
||||
const env = credEnvNames(p.provider, index)
|
||||
if (env.key && p.apiKey) updates[env.key] = p.apiKey
|
||||
|
||||
+25
-14
@@ -997,17 +997,15 @@ export function getAIModel(clientOverrides?: ClientOverrides): ModelConfig {
|
||||
? overrides?.apiKey || undefined
|
||||
: resolveApiKey(overrides, "OLLAMA_API_KEY")
|
||||
// Like other providers, a user's key never goes to the server's
|
||||
// base URL. A key without a base URL is an Ollama Cloud key
|
||||
// (local Ollama has no keys); without either, the SDK's local
|
||||
// default.
|
||||
// base URL: without a URL of their own it goes to Ollama Cloud.
|
||||
// The server's key goes to OLLAMA_BASE_URL (or a server model's
|
||||
// own variable), else to the SDK's local default: the desktop
|
||||
// app's "Ollama (Local)" preset puts its key field there too.
|
||||
const baseURL =
|
||||
overrides?.baseUrl ||
|
||||
(overrides?.apiKey
|
||||
? PROVIDER_INFO.ollama.defaultBaseUrl
|
||||
: process.env.OLLAMA_BASE_URL ||
|
||||
(apiKey
|
||||
? PROVIDER_INFO.ollama.defaultBaseUrl
|
||||
: undefined))
|
||||
: resolveBaseUrlEnv(overrides, "OLLAMA_BASE_URL"))
|
||||
model = createOllama({
|
||||
...(baseURL && { baseURL }),
|
||||
...(apiKey && {
|
||||
@@ -1043,9 +1041,8 @@ export function getAIModel(clientOverrides?: ClientOverrides): ModelConfig {
|
||||
: `${provider.toUpperCase()}_BASE_URL`
|
||||
// A local default (SGLang's 127.0.0.1) only fills the settings
|
||||
// form; the server must not call its own machine for it. With a
|
||||
// user's key, or an admin entry's own (empty) URL variable, the
|
||||
// OpenAI SDK would read the server's OPENAI_BASE_URL, so name
|
||||
// the official endpoint.
|
||||
// user's key the OpenAI SDK would read the server's
|
||||
// OPENAI_BASE_URL, so name the official endpoint.
|
||||
const defaultUrl = PROVIDER_INFO[provider].defaultBaseUrl
|
||||
const publicDefault = defaultUrl?.startsWith("https://")
|
||||
? defaultUrl
|
||||
@@ -1058,10 +1055,7 @@ export function getAIModel(clientOverrides?: ClientOverrides): ModelConfig {
|
||||
const baseURL =
|
||||
configuredBaseURL ||
|
||||
(SDK_KNOWS_ENDPOINT.has(provider) &&
|
||||
!(
|
||||
provider === "openai" &&
|
||||
(overrides?.apiKey || overrides?.baseUrlEnv)
|
||||
)
|
||||
!(provider === "openai" && overrides?.apiKey)
|
||||
? undefined
|
||||
: publicDefault)
|
||||
// With a user's Azure key the SDK would read the server's
|
||||
@@ -1097,6 +1091,23 @@ export function getAIModel(clientOverrides?: ClientOverrides): ModelConfig {
|
||||
return { model, providerOptions, modelId, provider }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function globalBaseUrl(provider: ProviderName): string | undefined {
|
||||
if (["bedrock", "edgeone", "ollama"].includes(provider)) return undefined
|
||||
const name =
|
||||
provider === "vertexai"
|
||||
? "GOOGLE_VERTEX_BASE_URL"
|
||||
: provider === "gateway"
|
||||
? "AI_GATEWAY_BASE_URL"
|
||||
: `${provider.toUpperCase()}_BASE_URL`
|
||||
return process.env[name] || undefined
|
||||
}
|
||||
|
||||
/** The provider of the server's own config: AI_PROVIDER, or the one with a key */
|
||||
export function getServerProvider(): ProviderName | null {
|
||||
return (process.env.AI_PROVIDER as ProviderName) || detectProvider()
|
||||
|
||||
+11
-2
@@ -78,16 +78,25 @@ const MAX_LIST_BYTES = 2 * 1024 * 1024
|
||||
/** A fetch that reads at most MAX_LIST_BYTES of each response */
|
||||
function sizeLimitedFetch(fetchFn: typeof fetch): typeof fetch {
|
||||
return async (input, init) => {
|
||||
const response = await fetchFn(input, init)
|
||||
// Ends a download that is too large (the Gateway SDK passes no
|
||||
// signal of its own)
|
||||
const download = new AbortController()
|
||||
const signal = init?.signal
|
||||
? AbortSignal.any([init.signal, download.signal])
|
||||
: download.signal
|
||||
const response = await fetchFn(input, { ...init, signal })
|
||||
const body = await readLimitedBody(response, MAX_LIST_BYTES)
|
||||
if (body === null) {
|
||||
download.abort()
|
||||
throw new ModelListError("The model list is too large.")
|
||||
}
|
||||
// The body is already decoded and has its own length now
|
||||
const headers = new Headers(response.headers)
|
||||
headers.delete("content-encoding")
|
||||
headers.delete("content-length")
|
||||
return new Response(body, {
|
||||
// Some statuses must have no body at all
|
||||
const noBody = [101, 204, 205, 304].includes(response.status)
|
||||
return new Response(noBody ? null : body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Read a response body, giving up once it passes maxBytes, so a huge
|
||||
* download from a URL the client chose can't exhaust server memory.
|
||||
* Returns null when it is too large.
|
||||
* Returns null when it is too large; the caller then aborts the request,
|
||||
* which ends the download.
|
||||
*/
|
||||
export async function readLimitedBody(
|
||||
response: Response,
|
||||
@@ -20,7 +21,9 @@ export async function readLimitedBody(
|
||||
if (done) break
|
||||
total += value.byteLength
|
||||
if (total > maxBytes) {
|
||||
await reader.cancel()
|
||||
// Not awaited: a copy of the body that Next.js keeps (its fetch
|
||||
// dedupe) can hold the cancel back until it is read
|
||||
reader.cancel().catch(() => {})
|
||||
return null
|
||||
}
|
||||
chunks.push(value)
|
||||
|
||||
@@ -176,6 +176,8 @@ export async function saveSession(session: ChatSession): Promise<boolean> {
|
||||
try {
|
||||
const db = await getDB()
|
||||
await db.put(STORE_NAME, session)
|
||||
// The desktop app opens this port (this origin's chats) next launch
|
||||
window.electronAPI?.chatSaved?.().catch(() => {})
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error("Failed to save session:", error)
|
||||
|
||||
@@ -116,6 +116,14 @@ export function allowPrivateUrls(): boolean {
|
||||
return process.env.ALLOW_PRIVATE_URLS !== "false"
|
||||
}
|
||||
|
||||
/** 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")
|
||||
this.name = "RedirectRefusedError"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -126,7 +134,7 @@ export function redirectGuardedFetch(): typeof fetch | undefined {
|
||||
return async (input, init) => {
|
||||
const response = await fetch(input, { ...init, redirect: "manual" })
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
throw new Error("Redirects are not allowed for custom base URLs")
|
||||
throw new RedirectRefusedError()
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* Serves draw.io embed with state sync and history UI
|
||||
*/
|
||||
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { readFileSync } from "node:fs"
|
||||
import http from "node:http"
|
||||
import { dirname, join } from "node:path"
|
||||
@@ -105,11 +106,22 @@ function ensureSessionStateInitialized(sessionId: string): void {
|
||||
// A blank diagram keeps the draw.io spinner (spin=1) from waiting
|
||||
// forever when no load(xml) is ever sent
|
||||
setState(sessionId, BLANK_MXFILE, undefined, false, false)
|
||||
// Nothing is known about this session: a tab that still shows it keeps
|
||||
// its diagram
|
||||
const state = stateStore.get(sessionId)
|
||||
if (state) state.blank = true
|
||||
}
|
||||
|
||||
interface SessionState {
|
||||
xml: string
|
||||
version: number
|
||||
// Made when the state is created (first use, or again after it expired
|
||||
// or the MCP process restarted) and kept by every write. A tab tells by
|
||||
// it that the server lost what it knew, and every push names the state
|
||||
// it was based on, so one based on a lost state is refused.
|
||||
stateId: string
|
||||
// Created blank because nothing was saved; cleared by the first write
|
||||
blank?: boolean
|
||||
// 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
|
||||
@@ -178,6 +190,7 @@ export function setState(
|
||||
stateStore.set(sessionId, {
|
||||
xml,
|
||||
version: newVersion,
|
||||
stateId: existing?.stateId ?? randomUUID(),
|
||||
serverVersion: fromBrowser ? existing?.serverVersion : newVersion,
|
||||
lastUpdated: new Date(),
|
||||
lastPolled: existing?.lastPolled,
|
||||
@@ -444,6 +457,8 @@ function handleStateApi(
|
||||
JSON.stringify({
|
||||
xml: state?.xml || null,
|
||||
version: state?.version || 0,
|
||||
stateId: state?.stateId ?? null,
|
||||
blank: !!state?.blank,
|
||||
syncRequested: !!state?.syncRequested,
|
||||
exportFormat: state?.exportFormat || null,
|
||||
exportXml: state?.exportXml || null,
|
||||
@@ -486,12 +501,58 @@ function handleStateApi(
|
||||
return
|
||||
}
|
||||
|
||||
// A push can come before the tab's first poll after a
|
||||
// restart: recover the saved file first, so it is compared
|
||||
// with that and never overwrites it unseen
|
||||
ensureSessionStateInitialized(sessionId)
|
||||
const current = stateStore.get(sessionId)
|
||||
|
||||
// A tab of this version names the state its push is based
|
||||
// on. Another state (the server lost the one it knew, or
|
||||
// the tab has not polled yet): refused, and the tab's next
|
||||
// poll decides whose diagram wins.
|
||||
if (current && "stateId" in data) {
|
||||
if (data.stateId !== current.stateId) {
|
||||
res.writeHead(409, {
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: "Session was recreated",
|
||||
stateChanged: true,
|
||||
version: current.version,
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
// What a recovering tab showed: kept in history only
|
||||
if (data.source === "recover") {
|
||||
const saved =
|
||||
typeof data.xml === "string" &&
|
||||
!!data.xml &&
|
||||
data.xml !== current.xml
|
||||
if (saved) {
|
||||
addHistory(sessionId, data.xml, data.svg || "")
|
||||
}
|
||||
res.writeHead(409, {
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: "Diagram changed on the server",
|
||||
version: current.version,
|
||||
savedToHistory: saved,
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// The browser edited a version older than the latest AI write
|
||||
// (it has not loaded that write yet). Keep the AI write; the
|
||||
// browser loads it on its next poll. A sync reply is also
|
||||
// stale after a newer write of the browser's own (a user
|
||||
// edit saved while the export ran).
|
||||
const current = stateStore.get(sessionId)
|
||||
if (
|
||||
typeof data.baseVersion === "number" &&
|
||||
(data.baseVersion < (current?.serverVersion ?? 0) ||
|
||||
|
||||
@@ -56,15 +56,22 @@ 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
|
||||
// the session then shows something else than what they hold. Cleared
|
||||
// once the file is read, or is gone.
|
||||
private unreadable = new Set<string>()
|
||||
|
||||
/** The session's saved diagram, or null. */
|
||||
load(sessionId: string): string | null {
|
||||
const path = this.pathFor(sessionId)
|
||||
if (!path || !existsSync(path)) return null
|
||||
if (!path) return null
|
||||
if (!existsSync(path)) {
|
||||
this.unreadable.delete(path)
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return readFileSync(path, "utf-8")
|
||||
const xml = readFileSync(path, "utf-8")
|
||||
this.unreadable.delete(path)
|
||||
return xml
|
||||
} catch (error) {
|
||||
log.warn(`Could not read the saved diagram ${path}: ${error}`)
|
||||
this.unreadable.add(path)
|
||||
@@ -93,7 +100,13 @@ export class Autosaver {
|
||||
const entry = this.pending.get(sessionId)
|
||||
this.pending.delete(sessionId)
|
||||
const path = this.pathFor(sessionId)
|
||||
if (!entry || !this.dir || !path || this.unreadable.has(path)) return
|
||||
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
|
||||
}
|
||||
try {
|
||||
const isNew = !existsSync(path)
|
||||
// A blank page the browser shows before any drawing: nothing to keep
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
const iframe = document.getElementById('drawio');
|
||||
let currentVersion = 0, isReady = false, pendingXml = null, lastXml = null;
|
||||
// The server state this tab is in step with (see stateId in http-server.ts);
|
||||
// null until the first poll
|
||||
let stateId = null;
|
||||
// The newest diagram on the canvas, saved to the server or not: lastXml is
|
||||
// the last one the server has
|
||||
let latestXml = null;
|
||||
let pushFailing = false; // the last push could not reach the server
|
||||
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;
|
||||
let pendingMcpExport = null; // 'png', 'svg' or 'xmlsvg' when MCP requested export
|
||||
let mcpExportSeq = 0; // number of the latest MCP export
|
||||
@@ -17,19 +26,24 @@ window.addEventListener('message', (e) => {
|
||||
if (msg.event === 'init') {
|
||||
isReady = true;
|
||||
if (pendingXml) { loadDiagram(pendingXml); pendingXml = null; }
|
||||
} else if ((msg.event === 'save' || msg.event === 'autosave') && msg.xml && msg.xml !== lastXml) {
|
||||
} 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;
|
||||
// Also an edit undone back to what the server has
|
||||
latestXml = msg.xml;
|
||||
if (msg.xml === lastXml) return;
|
||||
// Request SVG export, then push state with SVG. Remember the
|
||||
// version this edit is based on, so the server can reject it
|
||||
// if the AI wrote a newer version that is not loaded yet.
|
||||
// version and state this edit is based on, so the server can
|
||||
// reject it if the AI wrote a newer version that is not loaded
|
||||
// yet, or if it lost that state.
|
||||
pendingSvgExport = msg.xml;
|
||||
pendingSvgBase = currentVersion;
|
||||
pendingSvgStateId = stateId;
|
||||
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*');
|
||||
// Fallback if export doesn't respond
|
||||
setTimeout(() => { if (pendingSvgExport === msg.xml) { pushState(msg.xml, '', pendingSvgBase); pendingSvgExport = null; } }, 2000);
|
||||
setTimeout(() => { if (pendingSvgExport === msg.xml) { pushState(msg.xml, '', pendingSvgBase, 'edit', pendingSvgStateId); pendingSvgExport = null; } }, 2000);
|
||||
} else if (msg.event === 'export' && msg.format === 'xml') {
|
||||
// Sync export requested by the server (get_diagram).
|
||||
// draw.io returns the XML in msg.xml, with no msg.data. A late
|
||||
@@ -39,7 +53,7 @@ window.addEventListener('message', (e) => {
|
||||
// Push with the version the export was taken at: a
|
||||
// newer AI write may have loaded meanwhile, and this
|
||||
// older XML must not overwrite it.
|
||||
pushState(msg.xml, '', pendingSyncBase, 'sync');
|
||||
pushState(msg.xml, '', pendingSyncBase, 'sync', pendingSyncStateId);
|
||||
}
|
||||
} else if (msg.event === 'export' && msg.data) {
|
||||
// Handle MCP server export request (png/svg). fireExport tags
|
||||
@@ -98,7 +112,7 @@ window.addEventListener('message', (e) => {
|
||||
if (pendingSvgExport) {
|
||||
const xml = pendingSvgExport;
|
||||
pendingSvgExport = null;
|
||||
pushState(xml, svg, pendingSvgBase);
|
||||
pushState(xml, svg, pendingSvgBase, 'edit', pendingSvgStateId);
|
||||
} else if (pendingAiSvg) {
|
||||
pendingAiSvg = false;
|
||||
fetch('/api/history-svg', {
|
||||
@@ -114,6 +128,7 @@ window.addEventListener('message', (e) => {
|
||||
function loadDiagram(xml, capturePreview = false) {
|
||||
if (!isReady) { pendingXml = xml; return; }
|
||||
lastXml = xml;
|
||||
latestXml = xml;
|
||||
iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml, autosave: 1 }), '*');
|
||||
if (capturePreview) {
|
||||
setTimeout(() => {
|
||||
@@ -144,24 +159,27 @@ function showNotice(text) {
|
||||
noticeTimer = setTimeout(() => el.classList.remove('open'), 8000);
|
||||
}
|
||||
|
||||
// Same rule as hasCells in pages.ts: a cell besides the root cells, or a
|
||||
// compressed page
|
||||
function hasCells(xml) {
|
||||
return /<(mxCell\b[^>]*\bid\s*=\s*["'](?![01]["'])|UserObject\b|object\b)|<diagram\b[^>]*>\s*[^\s<]/.test(xml || '');
|
||||
}
|
||||
|
||||
// source is 'sync' for replies to a server sync request, 'recover' for the
|
||||
// tab's copy after the server recovered the session, else 'edit'
|
||||
async function pushState(xml, svg = '', baseVersion = currentVersion, source = 'edit') {
|
||||
// tab's copy after the server recovered the session, else 'edit'. sid is the
|
||||
// server state the push is based on.
|
||||
async function pushState(xml, svg = '', baseVersion = currentVersion, source = 'edit', sid = stateId) {
|
||||
if (!sessionId) return;
|
||||
try {
|
||||
const r = await fetch('/api/state', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId, xml, svg, baseVersion, source })
|
||||
body: JSON.stringify({ sessionId, xml, svg, baseVersion, source, stateId: sid })
|
||||
});
|
||||
if (r.ok) { const d = await r.json(); currentVersion = d.version; lastXml = xml; }
|
||||
// 409: the AI wrote a newer version; load it now
|
||||
pushFailing = false;
|
||||
if (r.ok) {
|
||||
const d = await r.json();
|
||||
// An answer about a state this tab has left since
|
||||
if (sid !== stateId) return;
|
||||
currentVersion = d.version;
|
||||
lastXml = xml;
|
||||
}
|
||||
// 409: the AI wrote a newer version, or the server lost the state
|
||||
// this push was based on; the next poll sorts it out
|
||||
else if (r.status === 409) {
|
||||
const d = await r.json().catch(() => ({}));
|
||||
if (d.savedToHistory) {
|
||||
@@ -171,33 +189,63 @@ async function pushState(xml, svg = '', baseVersion = currentVersion, source = '
|
||||
}
|
||||
poll();
|
||||
}
|
||||
} catch (e) { console.error('Push failed:', e); }
|
||||
} catch (e) {
|
||||
console.error('Push failed:', e);
|
||||
if (!pushFailing) {
|
||||
pushFailing = true;
|
||||
showNotice("Can't reach the MCP server. Your changes are only in this tab for now; use Download to keep a copy.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The server made a new state for this session: it expired, or the MCP
|
||||
// process restarted. Decide whose diagram wins.
|
||||
function recoverState(s) {
|
||||
stateId = s.stateId;
|
||||
// The old state's pending work is gone with it
|
||||
const projectionShown = projectionExportActive;
|
||||
projectionExportActive = false;
|
||||
forceReload = false;
|
||||
pendingMcpExport = null;
|
||||
pendingSyncExport = false;
|
||||
const mine = latestXml;
|
||||
currentVersion = s.version;
|
||||
if (s.blank || s.xml === lastXml) {
|
||||
// The server knows nothing, or exactly what this tab last saved:
|
||||
// the canvas can only be newer, so it wins (edits made while the
|
||||
// server was down are saved now)
|
||||
if (projectionShown && mine) {
|
||||
iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml: mine, autosave: 1 }), '*');
|
||||
}
|
||||
if (mine && mine !== s.xml) pushState(mine, '', s.version);
|
||||
} else {
|
||||
// The server has a diagram this tab never showed (an AI write it
|
||||
// missed, a saved file): show that, and keep this tab's copy in
|
||||
// History unless it is the same
|
||||
loadDiagram(s.xml, true);
|
||||
if (mine && mine !== s.xml) pushState(mine, '', s.version, 'recover');
|
||||
}
|
||||
}
|
||||
|
||||
let pendingSyncExport = false;
|
||||
let pendingSyncBase = 0; // version the pending sync export was taken at
|
||||
let pendingSyncStateId = null; // and the state it belonged to
|
||||
let syncExportSeq = 0; // number of the latest sync export
|
||||
|
||||
async function poll() {
|
||||
if (!sessionId) return;
|
||||
const knownVersion = currentVersion;
|
||||
const seq = ++pollSeq;
|
||||
try {
|
||||
const r = await fetch('/api/state?sessionId=' + encodeURIComponent(sessionId));
|
||||
if (!r.ok) return;
|
||||
const s = await r.json();
|
||||
// The server lost this session (it expired, or the MCP process
|
||||
// restarted) and rebuilt it. Blank: push back what the browser
|
||||
// shows. From the auto-save file, which can hold an AI write this
|
||||
// tab never loaded: show that, and keep this tab's copy in History
|
||||
// (a push based on version 0 is refused and saved there).
|
||||
if (s.version < knownVersion && lastXml) {
|
||||
if (!hasCells(s.xml)) {
|
||||
pushState(lastXml);
|
||||
} else {
|
||||
currentVersion = 0;
|
||||
if (s.xml !== lastXml) pushState(lastXml, '', 0, 'recover');
|
||||
}
|
||||
}
|
||||
// An older answer than one already handled (the interval, the 409
|
||||
// handler and the projection restore each poll): it could name a
|
||||
// state that is gone
|
||||
if (seq < lastHandledPoll) return;
|
||||
lastHandledPoll = seq;
|
||||
if (stateId === null) stateId = s.stateId;
|
||||
else if (s.stateId && s.stateId !== stateId) recoverState(s);
|
||||
// Load new diagram from server (before export, so we export latest).
|
||||
// While a page-targeted projection is on screen, only the restore
|
||||
// (forceReload) replaces it, so a new version doesn't fight the
|
||||
@@ -217,6 +265,7 @@ async function poll() {
|
||||
if (s.syncRequested && !pendingSyncExport && isReady && !projectionExportActive) {
|
||||
pendingSyncExport = true;
|
||||
pendingSyncBase = currentVersion;
|
||||
pendingSyncStateId = stateId;
|
||||
// draw.io echoes the request in msg.message, so the reply can
|
||||
// be matched to this request
|
||||
const seq = ++syncExportSeq;
|
||||
@@ -324,7 +373,8 @@ saveConfirmBtn.onclick = () => {
|
||||
// so no wrapper injection is needed. The legacy fallback below
|
||||
// remains only for documents that somehow slipped past
|
||||
// normalisation (e.g. an older session loaded from external state).
|
||||
let xmlData = lastXml || '';
|
||||
// The canvas as it is, also edits not saved to the server yet
|
||||
let xmlData = latestXml || lastXml || '';
|
||||
if (xmlData && !xmlData.includes('<mxfile')) {
|
||||
xmlData = '<mxfile host="mcp"><diagram name="Page-1">' + xmlData + '</diagram></mxfile>';
|
||||
}
|
||||
|
||||
@@ -337,6 +337,87 @@ describe("export requests", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("a session state recreated after it was lost", () => {
|
||||
const SAVED = `<mxfile><diagram id="p" name="P"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="saved" vertex="1" parent="1"/></root></mxGraphModel></diagram></mxfile>`
|
||||
const getJson = async (id: string) =>
|
||||
JSON.parse((await request(`/api/state?sessionId=${id}`)).body)
|
||||
|
||||
it("names each state, and says when it was made blank", async () => {
|
||||
const first = await getJson("mcp-sid-blank")
|
||||
expect(first.stateId).toMatch(/^[0-9a-f-]{36}$/)
|
||||
expect(first.blank).toBe(true)
|
||||
setState("mcp-sid-blank", "<mxfile>AI write</mxfile>")
|
||||
const after = await getJson("mcp-sid-blank")
|
||||
// Same state, no longer blank
|
||||
expect(after.stateId).toBe(first.stateId)
|
||||
expect(after.blank).toBe(false)
|
||||
})
|
||||
|
||||
it("refuses a push made for another state, also before any poll", async () => {
|
||||
// The MCP process restarted; the tab's push comes before its poll
|
||||
onSessionRecreate((id) => (id === "mcp-sid-restart" ? SAVED : null))
|
||||
try {
|
||||
for (const stateId of ["from-before", null]) {
|
||||
const res = await postJson("/api/state", {
|
||||
sessionId: "mcp-sid-restart",
|
||||
xml: "<mxfile>tab's old copy</mxfile>",
|
||||
baseVersion: 7,
|
||||
stateId,
|
||||
})
|
||||
expect(res.status).toBe(409)
|
||||
expect(JSON.parse(res.body).stateChanged).toBe(true)
|
||||
// The saved file was recovered first and is kept
|
||||
expect(getState("mcp-sid-restart")?.xml).toBe(SAVED)
|
||||
}
|
||||
} finally {
|
||||
onSessionRecreate(() => null)
|
||||
}
|
||||
})
|
||||
|
||||
it("accepts a push for the current state", async () => {
|
||||
const { stateId, version } = await getJson("mcp-sid-ok")
|
||||
const res = await postJson("/api/state", {
|
||||
sessionId: "mcp-sid-ok",
|
||||
xml: "<mxfile>user edit</mxfile>",
|
||||
baseVersion: version,
|
||||
stateId,
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(getState("mcp-sid-ok")?.xml).toBe("<mxfile>user edit</mxfile>")
|
||||
})
|
||||
|
||||
it("keeps a recovering tab's copy in history, never on the canvas", async () => {
|
||||
setState("mcp-sid-recover", SAVED)
|
||||
const { stateId, version } = await getJson("mcp-sid-recover")
|
||||
const before = getHistory("mcp-sid-recover").length
|
||||
const res = await postJson("/api/state", {
|
||||
sessionId: "mcp-sid-recover",
|
||||
xml: "<mxfile>what the tab showed</mxfile>",
|
||||
baseVersion: version,
|
||||
stateId,
|
||||
source: "recover",
|
||||
})
|
||||
expect(res.status).toBe(409)
|
||||
expect(JSON.parse(res.body).savedToHistory).toBe(true)
|
||||
expect(getState("mcp-sid-recover")?.xml).toBe(SAVED)
|
||||
expect(getHistory("mcp-sid-recover")).toHaveLength(before + 1)
|
||||
expect(getHistory("mcp-sid-recover").at(-1)?.xml).toBe(
|
||||
"<mxfile>what the tab showed</mxfile>",
|
||||
)
|
||||
})
|
||||
|
||||
it("keeps the old rules for a tab from an older version", async () => {
|
||||
// Its pushes have no stateId field
|
||||
const version = setState("mcp-sid-legacy", "<mxfile>AI</mxfile>")
|
||||
const res = await postJson("/api/state", {
|
||||
sessionId: "mcp-sid-legacy",
|
||||
xml: "<mxfile>edit</mxfile>",
|
||||
baseVersion: version,
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
describe("preview page", () => {
|
||||
it("shows the saved diagram of a session whose state expired", async () => {
|
||||
const saved = `<mxfile><diagram id="p" name="P"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="kept" vertex="1" parent="1"/></root></mxGraphModel></diagram></mxfile>`
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
utimesSync,
|
||||
writeFileSync,
|
||||
} from "node:fs"
|
||||
@@ -102,6 +103,33 @@ describe("Autosaver", () => {
|
||||
expect(readFileSync(path, "utf-8")).toBe(DIAGRAM)
|
||||
})
|
||||
|
||||
it("saves again once the file was read, or is gone", () => {
|
||||
const saver = new Autosaver(tempDir(), 10)
|
||||
saver.schedule("mcp-fixed", DIAGRAM)
|
||||
saver.flush()
|
||||
const path = saver.pathFor("mcp-fixed") as string
|
||||
chmodSync(path, 0o000)
|
||||
expect(saver.load("mcp-fixed")).toBeNull()
|
||||
// Permissions fixed; the session is recreated and reads the file
|
||||
chmodSync(path, 0o644)
|
||||
expect(saver.load("mcp-fixed")).toBe(DIAGRAM)
|
||||
const edited = DIAGRAM.replace('id="a"', 'id="b"')
|
||||
saver.schedule("mcp-fixed", edited)
|
||||
saver.flush()
|
||||
expect(readFileSync(path, "utf-8")).toBe(edited)
|
||||
|
||||
// A file that could not be read and was then deleted protects
|
||||
// nothing any more
|
||||
chmodSync(path, 0o000)
|
||||
expect(saver.load("mcp-fixed")).toBeNull()
|
||||
chmodSync(path, 0o644)
|
||||
rmSync(path)
|
||||
expect(saver.load("mcp-fixed")).toBeNull()
|
||||
saver.schedule("mcp-fixed", DIAGRAM)
|
||||
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()
|
||||
|
||||
@@ -271,4 +271,60 @@ test("a test result for an old API key is dropped", async ({ page }) => {
|
||||
release()
|
||||
await page.waitForTimeout(500)
|
||||
await expect(dialog.locator('[title="1.0 s"]')).toHaveCount(0)
|
||||
// The test is over: the button works again and nothing spins
|
||||
await expect(
|
||||
dialog.getByRole("button", { name: "Test", exact: true }),
|
||||
).toBeEnabled()
|
||||
await expect(dialog.locator(".animate-spin")).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("an older test does not end a newer one's spinners", async ({ page }) => {
|
||||
// Each validate request waits for its own release
|
||||
const releases: Array<() => void> = []
|
||||
await page.route("**/api/validate-model", async (route) => {
|
||||
await new Promise<void>((r) => releases.push(r))
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
json: { valid: true, responseTime: 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 user corrects the key and tests again
|
||||
await dialog.locator("#api-key").fill("new-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()
|
||||
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,
|
||||
}) => {
|
||||
const release = await holdRoute(page, "**/api/validate-model", {
|
||||
valid: true,
|
||||
responseTime: 1000,
|
||||
})
|
||||
const dialog = await openQwenSettings(page, TWO_PROVIDERS)
|
||||
await dialog.getByRole("button", { name: "Test", exact: true }).click()
|
||||
// The user looks at the other provider while another tab changes the key
|
||||
await dialog.getByText("GLM (Zhipu)").first().click()
|
||||
await page.evaluate(() => {
|
||||
const key = "next-ai-draw-io-model-configs"
|
||||
const config = JSON.parse(localStorage.getItem(key) ?? "{}")
|
||||
config.providers[0].apiKey = "key-from-another-tab"
|
||||
const value = JSON.stringify(config)
|
||||
localStorage.setItem(key, value)
|
||||
window.dispatchEvent(
|
||||
new StorageEvent("storage", { key, newValue: value }),
|
||||
)
|
||||
})
|
||||
release()
|
||||
await page.waitForTimeout(500)
|
||||
await dialog.getByText("Qwen (Alibaba)").first().click()
|
||||
await expect(dialog.locator(".animate-spin")).toHaveCount(0)
|
||||
})
|
||||
|
||||
@@ -69,6 +69,34 @@ 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(
|
||||
[provider({ provider: "ollama", apiKey: "ollama-key" })],
|
||||
[],
|
||||
)
|
||||
expect(cloud.OLLAMA_API_KEY).toBe("ollama-key")
|
||||
expect(cloud.OLLAMA_BASE_URL).toBe("https://ollama.com/api")
|
||||
const own = deriveEnvUpdates(
|
||||
[
|
||||
provider({
|
||||
provider: "ollama",
|
||||
apiKey: "k",
|
||||
baseUrl: "https://ollama.internal/api",
|
||||
}),
|
||||
],
|
||||
[],
|
||||
)
|
||||
expect(own.OLLAMA_BASE_URL).toBe("https://ollama.internal/api")
|
||||
// No key: local Ollama, nothing to write
|
||||
const local = deriveEnvUpdates(
|
||||
[provider({ provider: "ollama", apiKey: undefined })],
|
||||
[],
|
||||
)
|
||||
expect(local.OLLAMA_BASE_URL ?? null).toBeNull()
|
||||
})
|
||||
|
||||
it("maps bedrock credentials to ADMIN_AWS_* env vars", () => {
|
||||
const updates = deriveEnvUpdates(
|
||||
[
|
||||
@@ -156,29 +184,6 @@ describe("adminProvidersToConfig", () => {
|
||||
expect(config.providers[1].apiKeyEnv).toBe("ADMIN_OPENAI_API_KEY_2")
|
||||
})
|
||||
|
||||
it("names its own URL variable when it has its own key, even empty", () => {
|
||||
// Otherwise chat reads the global OPENAI_BASE_URL, which may be a
|
||||
// proxy for another key, while the Test used the official endpoint
|
||||
const own = adminProvidersToConfig([provider()]).providers[0]
|
||||
expect(own.baseUrlEnv).toBe("ADMIN_OPENAI_BASE_URL")
|
||||
// Without a key or URL of its own: the global key and URL, a pair
|
||||
const shared = adminProvidersToConfig([provider({ apiKey: undefined })])
|
||||
.providers[0]
|
||||
expect(shared.baseUrlEnv).toBeUndefined()
|
||||
// An Azure key belongs to one resource: AZURE_BASE_URL stays
|
||||
const azure = adminProvidersToConfig([provider({ provider: "azure" })])
|
||||
.providers[0]
|
||||
expect(azure.baseUrlEnv).toBeUndefined()
|
||||
expect(
|
||||
adminProvidersToConfig([
|
||||
provider({
|
||||
provider: "azure",
|
||||
baseUrl: "https://r.openai.azure.com/openai",
|
||||
}),
|
||||
]).providers[0].baseUrlEnv,
|
||||
).toBe("ADMIN_AZURE_BASE_URL")
|
||||
})
|
||||
|
||||
it("skips providers without models and carries the default flag", () => {
|
||||
const config = adminProvidersToConfig([
|
||||
provider({ id: "p1", models: [] }),
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
// The request the admin Test hands to validate-model
|
||||
const sent = vi.hoisted(() => ({ body: null as any, headers: null as any }))
|
||||
vi.mock("@/app/api/validate-model/route", () => ({
|
||||
POST: async (req: Request) => {
|
||||
sent.body = await req.json()
|
||||
sent.headers = Object.fromEntries(req.headers)
|
||||
return Response.json({ valid: true })
|
||||
},
|
||||
}))
|
||||
vi.mock("@/lib/admin/auth", () => ({ checkAdminAuth: () => null }))
|
||||
vi.mock("@/lib/admin/settings", () => ({ loadSettings: () => ({}) }))
|
||||
|
||||
import { POST as testModel } from "@/app/api/admin/test-model/route"
|
||||
|
||||
const ENV = ["OPENAI_BASE_URL", "SGLANG_BASE_URL", "AI_GATEWAY_BASE_URL"]
|
||||
const saved: Record<string, string | undefined> = {}
|
||||
beforeEach(() => {
|
||||
for (const k of ENV) {
|
||||
saved[k] = process.env[k]
|
||||
delete process.env[k]
|
||||
}
|
||||
})
|
||||
afterEach(() => {
|
||||
for (const k of ENV) {
|
||||
if (saved[k] === undefined) delete process.env[k]
|
||||
else process.env[k] = saved[k]
|
||||
}
|
||||
})
|
||||
|
||||
const test = (provider: Record<string, unknown>) =>
|
||||
testModel(
|
||||
new Request("http://localhost/api/admin/test-model", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
provider: { id: "p1", models: ["m"], ...provider },
|
||||
modelId: "m",
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
describe("admin Test of an entry without a URL", () => {
|
||||
it("tests the server's <P>_BASE_URL, where chat sends the entry's key", async () => {
|
||||
// A server model without baseUrlEnv reads the global variable
|
||||
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")
|
||||
|
||||
process.env.AI_GATEWAY_BASE_URL = "https://gateway.example.com/v3/ai"
|
||||
await test({ provider: "gateway", apiKey: "k" })
|
||||
expect(sent.body.baseUrl).toBe("https://gateway.example.com/v3/ai")
|
||||
})
|
||||
|
||||
it("keeps the entry's own URL, and none when the server has none", async () => {
|
||||
process.env.SGLANG_BASE_URL = "http://gpu-box:8000/v1"
|
||||
await test({
|
||||
provider: "sglang",
|
||||
apiKey: "k",
|
||||
baseUrl: "http://other:8000/v1",
|
||||
})
|
||||
expect(sent.body.baseUrl).toBe("http://other:8000/v1")
|
||||
await test({ provider: "deepseek", apiKey: "k" })
|
||||
expect(sent.body.baseUrl).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -378,25 +378,6 @@ describe("whose keys a request uses", () => {
|
||||
expect(provider.chat).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("sends an admin OpenAI key without a URL to the official endpoint", () => {
|
||||
// Its URL variable is named but empty; the SDK would otherwise read
|
||||
// the server's OPENAI_BASE_URL, a proxy for another key
|
||||
process.env.OPENAI_BASE_URL = "https://operator-proxy.example.com/v1"
|
||||
process.env.ADMIN_OPENAI_API_KEY = "panel-key"
|
||||
getAIModel({
|
||||
provider: "openai",
|
||||
modelId: "gpt-5.5",
|
||||
apiKeyEnv: "ADMIN_OPENAI_API_KEY",
|
||||
baseUrlEnv: "ADMIN_OPENAI_BASE_URL",
|
||||
})
|
||||
expect(createOpenAI).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
apiKey: "panel-key",
|
||||
baseURL: "https://api.openai.com/v1",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("uses Chat Completions for any configured base URL", () => {
|
||||
// The settings form fills in the official URL for a new provider
|
||||
getAIModel({
|
||||
@@ -425,21 +406,44 @@ describe("whose keys a request uses", () => {
|
||||
)
|
||||
})
|
||||
|
||||
it("sends the server's Ollama key without a base URL to Ollama Cloud", async () => {
|
||||
// An Ollama key is an Ollama Cloud key: local Ollama has none.
|
||||
// The admin panel saves it as OLLAMA_API_KEY, without a base URL.
|
||||
it("sends the server's Ollama key where OLLAMA_BASE_URL says, or to local Ollama", async () => {
|
||||
// The desktop app's "Ollama (Local)" preset puts its API Key field
|
||||
// into OLLAMA_API_KEY; with no base URL that is the local Ollama
|
||||
process.env.OLLAMA_API_KEY = "server-key"
|
||||
const { createOllama } = await import("ollama-ai-provider-v2")
|
||||
getAIModel({ provider: "ollama", modelId: "m" })
|
||||
expect(createOllama).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ baseURL: "https://ollama.com/api" }),
|
||||
)
|
||||
// Without a key: the SDK's local default
|
||||
delete process.env.OLLAMA_API_KEY
|
||||
getAIModel({ provider: "ollama", modelId: "m" })
|
||||
expect(vi.mocked(createOllama).mock.lastCall?.[0]).not.toHaveProperty(
|
||||
"baseURL",
|
||||
)
|
||||
process.env.OLLAMA_BASE_URL = "https://ollama.com/api"
|
||||
getAIModel({ provider: "ollama", modelId: "m" })
|
||||
expect(createOllama).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ baseURL: "https://ollama.com/api" }),
|
||||
)
|
||||
})
|
||||
|
||||
it("uses a server model's own Ollama URL variable", async () => {
|
||||
process.env.OLLAMA_BASE_URL = "http://other.internal:11434/api"
|
||||
process.env.MY_OLLAMA_URL = "https://ollama.proxy.example/api"
|
||||
process.env.MY_OLLAMA_KEY = "proxy-key"
|
||||
try {
|
||||
const { createOllama } = await import("ollama-ai-provider-v2")
|
||||
getAIModel({
|
||||
provider: "ollama",
|
||||
modelId: "m",
|
||||
apiKeyEnv: "MY_OLLAMA_KEY",
|
||||
baseUrlEnv: "MY_OLLAMA_URL",
|
||||
})
|
||||
expect(createOllama).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
baseURL: "https://ollama.proxy.example/api",
|
||||
headers: { Authorization: "Bearer proxy-key" },
|
||||
}),
|
||||
)
|
||||
} finally {
|
||||
delete process.env.MY_OLLAMA_URL
|
||||
delete process.env.MY_OLLAMA_KEY
|
||||
}
|
||||
})
|
||||
|
||||
it("needs a base URL with a user's Azure key", () => {
|
||||
|
||||
@@ -462,11 +462,11 @@ describe("Ollama API key security", () => {
|
||||
|
||||
expect(createOllamaMock).toHaveBeenCalledTimes(1)
|
||||
const callArgs = createOllamaMock.mock.calls[0][0]
|
||||
// As env.example says: without OLLAMA_BASE_URL, Ollama Cloud (the
|
||||
// SDK's default is the local server, which has no keys)
|
||||
// The SDK's local default: the desktop app's "Ollama (Local)"
|
||||
// preset puts its API Key field into OLLAMA_API_KEY
|
||||
expect(callArgs).not.toHaveProperty("baseURL")
|
||||
expect(callArgs).toEqual(
|
||||
expect.objectContaining({
|
||||
baseURL: "https://ollama.com/api",
|
||||
headers: { Authorization: "Bearer server-key" },
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
// 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"
|
||||
|
||||
const ENV = [
|
||||
"AI_PROVIDER",
|
||||
"AI_MODEL",
|
||||
"OPENAI_API_KEY",
|
||||
"OLLAMA_BASE_URL",
|
||||
"OLLAMA_API_KEY",
|
||||
"NEXT_AI_DRAWIO_DESKTOP",
|
||||
]
|
||||
const saved: Record<string, string | undefined> = {}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const k of ENV) saved[k] = process.env[k]
|
||||
for (const k of ENV) delete process.env[k]
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const k of ENV) {
|
||||
if (saved[k] === undefined) delete process.env[k]
|
||||
else process.env[k] = saved[k]
|
||||
}
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
/** Every provider request answers with this status and text */
|
||||
const providerAnswers = (status: number, body: string) =>
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response(body, {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
/** The error text the chat panel gets from the stream */
|
||||
async function streamedError(headers: Record<string, string>) {
|
||||
const res = await 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: "",
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const text = await res.text()
|
||||
const line = text.split("\n").find((l) => l.includes('"type":"error"'))
|
||||
return line ? JSON.parse(JSON.parse(line.slice(6)).errorText).message : ""
|
||||
}
|
||||
|
||||
describe("provider error texts in the stream", () => {
|
||||
it("shows the user's own local Ollama error in the desktop app", async () => {
|
||||
process.env.NEXT_AI_DRAWIO_DESKTOP = "1"
|
||||
process.env.AI_PROVIDER = "ollama"
|
||||
process.env.AI_MODEL = "llama3"
|
||||
// Ollama is not running
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => {
|
||||
throw Object.assign(new TypeError("fetch failed"), {
|
||||
cause: new Error("connect ECONNREFUSED 127.0.0.1:11434"),
|
||||
})
|
||||
}),
|
||||
)
|
||||
expect(await streamedError({})).toMatch(
|
||||
/127\.0\.0\.1:11434|fetch failed/,
|
||||
)
|
||||
// The SDK retries a refused connection twice, waiting between
|
||||
}, 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
|
||||
providerAnswers(
|
||||
400,
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message:
|
||||
"The daily public quota has been exhausted. After deployment, you can enjoy a personal daily exclusive quota.",
|
||||
},
|
||||
}),
|
||||
)
|
||||
expect(
|
||||
await streamedError({
|
||||
"x-ai-provider": "edgeone",
|
||||
"x-ai-model": "@tx/deepseek-ai/deepseek-v3-0324",
|
||||
}),
|
||||
).toMatch(/daily public quota/)
|
||||
})
|
||||
|
||||
it("hides the provider's text on the server's own key", async () => {
|
||||
process.env.AI_PROVIDER = "openai"
|
||||
process.env.AI_MODEL = "gpt-5.5"
|
||||
process.env.OPENAI_API_KEY = "server-key"
|
||||
providerAnswers(
|
||||
403,
|
||||
JSON.stringify({
|
||||
error: { message: "Organization org-operator is suspended" },
|
||||
}),
|
||||
)
|
||||
const message = await streamedError({})
|
||||
expect(message).not.toMatch(/org-operator/)
|
||||
expect(message).toBe("The provider returned an error.")
|
||||
})
|
||||
})
|
||||
@@ -15,7 +15,17 @@ vi.mock("electron", () => ({
|
||||
|
||||
import { loadEnvFile } from "@/electron/main/env-loader"
|
||||
|
||||
const KEYS = ["T_JSON", "T_COMMENT", "T_PLAIN", "T_DOUBLE"]
|
||||
const KEYS = [
|
||||
"T_JSON",
|
||||
"T_COMMENT",
|
||||
"T_PLAIN",
|
||||
"T_DOUBLE",
|
||||
"T_QUOTED_COMMENT",
|
||||
"T_KEY_COMMENT",
|
||||
"T_HASH",
|
||||
"T_AFTER",
|
||||
"T_JOINED",
|
||||
]
|
||||
afterEach(() => {
|
||||
for (const k of KEYS) delete process.env[k]
|
||||
})
|
||||
@@ -39,4 +49,27 @@ describe("loadEnvFile", () => {
|
||||
expect(process.env.T_PLAIN).toBe("plain")
|
||||
expect(process.env.T_DOUBLE).toBe(`say "hi"`)
|
||||
})
|
||||
|
||||
it("drops a comment that ends with a quote, like dotenv", () => {
|
||||
// Expected values checked against dotenv 16.6.1's parse
|
||||
dir.path = mkdtempSync(join(tmpdir(), "env-loader-"))
|
||||
writeFileSync(
|
||||
join(dir.path, ".env"),
|
||||
[
|
||||
`T_QUOTED_COMMENT="gpt-5" # pick "fast"`,
|
||||
`T_KEY_COMMENT="sk-abc" # from "Team A"`,
|
||||
`T_AFTER='a' b`,
|
||||
`T_JOINED="a"b`,
|
||||
// Unquoted: a # without a space before it stays in the value
|
||||
// (dotenv would cut it; this loader never did)
|
||||
"T_HASH=http://host/#/x",
|
||||
].join("\n"),
|
||||
)
|
||||
loadEnvFile()
|
||||
expect(process.env.T_QUOTED_COMMENT).toBe("gpt-5")
|
||||
expect(process.env.T_KEY_COMMENT).toBe("sk-abc")
|
||||
expect(process.env.T_AFTER).toBe(`'a' b`)
|
||||
expect(process.env.T_JOINED).toBe(`"a"b`)
|
||||
expect(process.env.T_HASH).toBe("http://host/#/x")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { readFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
// The MCP preview page, as the server fills it in, with its script run in
|
||||
// this document (no session id, so it does not poll)
|
||||
const dir = join(process.cwd(), "packages/mcp-server/src/preview")
|
||||
const html = readFileSync(join(dir, "index.html"), "utf8")
|
||||
.replace("{{CSS}}", "")
|
||||
.replace("{{SESSION_BADGE}}", "")
|
||||
.replaceAll("{{DISABLED}}", "")
|
||||
.replace("{{DRAWIO_URL}}", "about:blank")
|
||||
.replace("{{SESSION_JSON}}", '""')
|
||||
.replace("{{ORIGIN_JSON}}", '"https://embed.diagrams.net"')
|
||||
const scripts = [...html.matchAll(/<script>([\s\S]*?)<\/script>/g)].map((m) =>
|
||||
m[1].replace("{{SCRIPT}}", ""),
|
||||
)
|
||||
const preview = readFileSync(join(dir, "preview.js"), "utf8")
|
||||
|
||||
function renderHistory(entries: unknown[]): HTMLElement {
|
||||
document.body.innerHTML = html.replace(/<script>[\s\S]*?<\/script>/g, "")
|
||||
// One scope, as the page's scripts share one; returns its renderHistory
|
||||
const run = new Function(
|
||||
`${scripts.join("\n")}\n${preview}\nreturn (d) => { historyData = d; renderHistory(); }`,
|
||||
)
|
||||
run()(entries)
|
||||
return document.getElementById("history-grid") as HTMLElement
|
||||
}
|
||||
|
||||
describe("MCP preview History", () => {
|
||||
it("never reads a stored thumbnail as HTML", () => {
|
||||
const grid = renderHistory([
|
||||
{ id: 1, index: 0, svg: 'x" onerror="window.__xss=1' },
|
||||
{ id: 2, index: 1, svg: "javascript:window.__xss=2" },
|
||||
{ id: 3, index: 2, svg: "data:image/svg+xml;base64,PHN2Zy8+" },
|
||||
])
|
||||
const images = [...grid.querySelectorAll("img")]
|
||||
expect(images.map((i) => i.getAttribute("src"))).toEqual([
|
||||
"data:image/svg+xml;base64,PHN2Zy8+",
|
||||
])
|
||||
expect(grid.querySelector("[onerror]")).toBeNull()
|
||||
// Entries without a usable picture show their number
|
||||
expect(grid.textContent).toContain("#0")
|
||||
expect(grid.textContent).toContain("#1")
|
||||
})
|
||||
})
|
||||
@@ -34,6 +34,8 @@ vi.mock("node:net", () => ({
|
||||
|
||||
import {
|
||||
findAvailablePort,
|
||||
noteNoChats,
|
||||
rememberChatPort,
|
||||
resetAllocatedPort,
|
||||
} from "@/electron/main/port-manager"
|
||||
|
||||
@@ -97,3 +99,55 @@ describe("findAvailablePort", () => {
|
||||
expect(await launch()).toBe(13371)
|
||||
})
|
||||
})
|
||||
|
||||
describe("the port where chats were last saved", () => {
|
||||
it("opens there first", async () => {
|
||||
// Windows reserved 61337 for a while, and the user kept working
|
||||
storeData(61337)
|
||||
busy.ports[61337] = "EACCES"
|
||||
expect(await launch()).toBe(13370)
|
||||
storeData(13370)
|
||||
rememberChatPort()
|
||||
busy.ports = {}
|
||||
expect(await launch()).toBe(13370)
|
||||
})
|
||||
|
||||
it("does not move after a launch elsewhere that saved nothing", async () => {
|
||||
storeData(61337)
|
||||
expect(await launch()).toBe(61337)
|
||||
rememberChatPort()
|
||||
busy.ports[61337] = "EADDRINUSE"
|
||||
expect(await launch()).toBe(13370)
|
||||
storeData(13370)
|
||||
busy.ports = {}
|
||||
expect(await launch()).toBe(61337)
|
||||
})
|
||||
|
||||
it("never stores a last-resort port, which changes between launches", async () => {
|
||||
busy.ports[61337] = "EACCES"
|
||||
busy.ports[13370] = "EADDRINUSE"
|
||||
expect(await launch()).toBe(13371)
|
||||
rememberChatPort()
|
||||
busy.ports = {}
|
||||
expect(await launch()).toBe(61337)
|
||||
})
|
||||
|
||||
it("tries the other port after opening on one without chats", async () => {
|
||||
// Split before this version: chats only on 13370, and a launch on
|
||||
// 61337 created that origin's folder
|
||||
storeData(13370)
|
||||
storeData(61337)
|
||||
expect(await launch()).toBe(61337)
|
||||
noteNoChats()
|
||||
expect(await launch()).toBe(13370)
|
||||
// Once a choice is stored, an empty page changes nothing
|
||||
noteNoChats()
|
||||
expect(await launch()).toBe(13370)
|
||||
})
|
||||
|
||||
it("stays put for a new user", async () => {
|
||||
expect(await launch()).toBe(61337)
|
||||
noteNoChats()
|
||||
expect(await launch()).toBe(61337)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
// 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 providerModels } from "@/app/api/provider-models/route"
|
||||
import {
|
||||
canListModels,
|
||||
@@ -227,6 +235,64 @@ describe("POST /api/provider-models", () => {
|
||||
}
|
||||
})
|
||||
|
||||
it("ends the download of a list that is too large", async () => {
|
||||
// The answer announces 4 MB and never finishes
|
||||
let signal: AbortSignal | undefined
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
signal = init?.signal ?? undefined
|
||||
const body = new ReadableStream({ start() {} })
|
||||
return new Response(body, {
|
||||
headers: { "content-length": String(4 * 1024 * 1024) },
|
||||
})
|
||||
}),
|
||||
)
|
||||
const data = await (
|
||||
await post({
|
||||
provider: "ollama",
|
||||
baseUrl: "https://big.example.com",
|
||||
})
|
||||
).json()
|
||||
expect(data.error).toBe("The model list is too large.")
|
||||
expect(signal?.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it("handles answers without a body", async () => {
|
||||
for (const status of [204, 304]) {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response(null, { status })),
|
||||
)
|
||||
const data = await (
|
||||
await post({ provider: "ollama", baseUrl: "https://x.example" })
|
||||
).json()
|
||||
expect(data.error).toMatch(/not valid JSON|failed \(304\)/)
|
||||
}
|
||||
})
|
||||
|
||||
it("explains a refused redirect", async () => {
|
||||
process.env.ALLOW_PRIVATE_URLS = "false"
|
||||
try {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response(null, {
|
||||
status: 301,
|
||||
headers: { location: "https://elsewhere.example" },
|
||||
}),
|
||||
),
|
||||
)
|
||||
const data = await (
|
||||
await post({ provider: "ollama", baseUrl: "https://x.example" })
|
||||
).json()
|
||||
expect(data.error).toMatch(/Redirects are not allowed/)
|
||||
} finally {
|
||||
delete process.env.ALLOW_PRIVATE_URLS
|
||||
}
|
||||
})
|
||||
|
||||
it("keeps its own explanations and hides other error texts", async () => {
|
||||
// Our own: no base URL for SGLang
|
||||
const own = await (
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { renderHook } from "@testing-library/react"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { useDiagramToolHandlers } from "@/hooks/use-diagram-tool-handlers"
|
||||
|
||||
const geometry =
|
||||
'<mxGeometry x="0" y="0" width="80" height="40" as="geometry"/>'
|
||||
const box = (id: string) =>
|
||||
`<mxCell id="${id}" value="${id}" vertex="1" parent="1">${geometry}</mxCell>`
|
||||
|
||||
function setup(partialXml: string) {
|
||||
const refs = {
|
||||
partialXmlRef: { current: partialXml },
|
||||
// A failed edit's preview is still on the canvas, its original kept
|
||||
editDiagramOriginalXmlRef: {
|
||||
current: new Map([["edit-1", "<mxfile>original</mxfile>"]]),
|
||||
},
|
||||
processedToolCallsRef: { current: new Set<string>() },
|
||||
validationRetryCountRef: { current: 0 },
|
||||
chartXMLRef: { current: "" },
|
||||
}
|
||||
const onDisplayChart = vi.fn(
|
||||
(_xml: string, _skipValidation?: boolean): string | null => null,
|
||||
)
|
||||
const { result } = renderHook(() =>
|
||||
useDiagramToolHandlers({
|
||||
...refs,
|
||||
onDisplayChart,
|
||||
onFetchChart: async () => "",
|
||||
onExport: () => {},
|
||||
enableVlmValidation: false,
|
||||
}),
|
||||
)
|
||||
const addToolOutput = vi.fn()
|
||||
const append = (xml: string) =>
|
||||
result.current.handleToolCall(
|
||||
{
|
||||
toolCall: {
|
||||
toolCallId: "append-1",
|
||||
toolName: "append_diagram",
|
||||
input: { xml },
|
||||
},
|
||||
},
|
||||
addToolOutput,
|
||||
)
|
||||
return { refs, onDisplayChart, addToolOutput, append }
|
||||
}
|
||||
|
||||
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
|
||||
// over the completed diagram
|
||||
const { refs, onDisplayChart, append } = setup(
|
||||
`${box("2")}<mxCell id="3" value="3" vertex="1" parent="1"><mxGeometry x="0" y="0" width="8`,
|
||||
)
|
||||
await append('0" height="40" as="geometry"/></mxCell>')
|
||||
expect(onDisplayChart).toHaveBeenCalledTimes(1)
|
||||
expect(onDisplayChart.mock.calls[0][0]).toContain('id="3"')
|
||||
expect(refs.editDiagramOriginalXmlRef.current.size).toBe(0)
|
||||
expect(refs.processedToolCallsRef.current.has("edit-1")).toBe(true)
|
||||
})
|
||||
|
||||
it("leaves them while the diagram is still incomplete", async () => {
|
||||
// Nothing is drawn, so the failed edit's preview must still be undone
|
||||
const { refs, onDisplayChart, append } = setup(
|
||||
`${box("2")}<mxCell id="3" value="3" vertex="1" parent="1"><mxGeometry x="0" y="0" width="8`,
|
||||
)
|
||||
await append('0" height="40"')
|
||||
expect(onDisplayChart).not.toHaveBeenCalled()
|
||||
expect(refs.editDiagramOriginalXmlRef.current.size).toBe(1)
|
||||
expect(refs.processedToolCallsRef.current.has("edit-1")).toBe(false)
|
||||
})
|
||||
|
||||
it("leaves them when the assembled diagram is invalid", async () => {
|
||||
const { refs, onDisplayChart, addToolOutput, append } = setup(
|
||||
`<mxCell id="1" value="root id" vertex="1" parent="1">${geometry}</mxCell><mxCell id="3" value="3" vertex="1" parent="1"><mxGeometry x="0" y="0" width="8`,
|
||||
)
|
||||
await append('0" height="40" as="geometry"/></mxCell>')
|
||||
expect(onDisplayChart).not.toHaveBeenCalled()
|
||||
expect(addToolOutput.mock.calls[0][0].state).toBe("output-error")
|
||||
expect(refs.editDiagramOriginalXmlRef.current.size).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { act, cleanup, renderHook, waitFor } from "@testing-library/react"
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { useModelConfig } from "@/hooks/use-model-config"
|
||||
import { getSelectedAIConfig, useModelConfig } from "@/hooks/use-model-config"
|
||||
import type { FlattenedServerModel } from "@/lib/server-model-config"
|
||||
import { STORAGE_KEYS } from "@/lib/storage"
|
||||
import type { MultiModelConfig } from "@/lib/types/model-config"
|
||||
@@ -126,6 +126,43 @@ describe("useModelConfig server model selection", () => {
|
||||
expect(result.current.models.map((m) => m.id)).toContain("m1")
|
||||
})
|
||||
|
||||
it("keeps an unknown provider and its key in storage", async () => {
|
||||
// The version that saved it may be opened again (an older desktop
|
||||
// build, another tab): the provider must still be there
|
||||
storeConfig({
|
||||
...USER_CONFIG,
|
||||
providers: [
|
||||
...USER_CONFIG.providers,
|
||||
{
|
||||
id: "p9",
|
||||
provider: "not-a-provider" as any,
|
||||
apiKey: "k9",
|
||||
models: [{ id: "m9", modelId: "x" }],
|
||||
},
|
||||
],
|
||||
selectedModelId: "m1",
|
||||
})
|
||||
const { result } = await renderLoaded()
|
||||
act(() => result.current.setSelectedModelId(undefined))
|
||||
await waitFor(() => {
|
||||
const stored = JSON.parse(
|
||||
localStorage.getItem(STORAGE_KEYS.modelConfigs) ?? "{}",
|
||||
)
|
||||
expect(stored.selectedModelId).toBeUndefined()
|
||||
expect(stored.providers.map((p: { id: string }) => p.id)).toEqual([
|
||||
"p1",
|
||||
"p9",
|
||||
])
|
||||
expect(stored.providers[1].apiKey).toBe("k9")
|
||||
})
|
||||
// Sending reads the stored config too, and must not trip over it
|
||||
act(() => result.current.setSelectedModelId("m1"))
|
||||
expect(getSelectedAIConfig()).toMatchObject({
|
||||
aiProvider: "openai",
|
||||
aiModel: "gpt-4o",
|
||||
})
|
||||
})
|
||||
|
||||
it("keeps a selected user model", async () => {
|
||||
storeConfig({ ...USER_CONFIG, selectedModelId: "m1" })
|
||||
const { result } = await renderLoaded()
|
||||
|
||||
Reference in New Issue
Block a user