mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-08 10:47:48 +08:00
fix: what the batch C review found
- A redirect followed for a custom base URL (quota on) no longer carries
the user's key or cookies to another origin, as fetch itself does, and
a private address may redirect to another private one (already counted).
- The admin Test of an Ollama or Vertex AI entry without a URL goes where
chat sends that entry's key: the environment's own URL variable, for
Ollama else the local default. The Test of an Ollama Cloud key without
a URL went to the cloud while chat went to local Ollama.
- Chat saves: each save notes the chat on screen and the order of the
reads before reading its data. A save read before switching chats no
longer writes into the chat switched to, and a copy that waited for its
thumbnail no longer replaces a newer one.
- "Continue without saving" keeps its button when a later auto-save fails,
and goes away when a new message is sent.
- A screenshot check that was waiting for its image when the user pressed
Stop stays skipped after the next message.
- MCP History: draw.io's own copy of a diagram (after get_diagram) no
longer adds an entry without a picture; a change of background is still
its own version. The tab ignores an edit's answer that arrives after a
newer AI write loaded.
- Desktop: a deleted preset is not brought back by a failed switch, and a
request naming no preset does not stop a rollback. An origin keeping
an access code counts as having settings.
- .env: a quoted value ending in a backslash ("C:\dir\") is read as dotenv
reads it.
- The tool card no longer crashes on an id that does not turn into text;
an older Test's success timer no longer ends a newer Test's spinner.
- Tests that passed without their fix now check it.
This commit is contained in:
+45
-10
@@ -277,6 +277,9 @@ export default function ChatPanel({
|
||||
|
||||
// Set by Stop until the user sends the next message
|
||||
const stoppedRef = useRef(false)
|
||||
// Presses of Stop: a check that began before one still knows of it after
|
||||
// the next message clears stoppedRef
|
||||
const stopCountRef = useRef(0)
|
||||
|
||||
// Store original XML for display_diagram and edit_diagram streaming -
|
||||
// shared between streaming preview and tool handler
|
||||
@@ -347,7 +350,11 @@ export default function ChatPanel({
|
||||
validateDiagram: validateWithFallback,
|
||||
enableVlmValidation: vlmValidationEnabled,
|
||||
sessionId,
|
||||
isStopped: () => stoppedRef.current,
|
||||
watchStop: () => {
|
||||
const stopsBefore = stopCountRef.current
|
||||
return () =>
|
||||
stoppedRef.current || stopCountRef.current !== stopsBefore
|
||||
},
|
||||
onValidationStateChange: handleValidationStateChange,
|
||||
})
|
||||
|
||||
@@ -672,6 +679,7 @@ export default function ChatPanel({
|
||||
currentSessionId,
|
||||
saveCurrentSession,
|
||||
getChatGeneration,
|
||||
getSaveTicket,
|
||||
} = sessionManager
|
||||
|
||||
// Use ref for saveCurrentSession to avoid infinite loop
|
||||
@@ -709,14 +717,17 @@ export default function ChatPanel({
|
||||
localStorageDebounceRef.current = setTimeout(async () => {
|
||||
try {
|
||||
if (messages.length > 0 || hasDiagramNow) {
|
||||
// Taken before the data is read, for the chat it was
|
||||
// scheduled for
|
||||
const ticket = {
|
||||
...getSaveTicket(),
|
||||
generation: scheduledForChat,
|
||||
}
|
||||
const sessionData = await buildSessionData({
|
||||
// Only capture thumbnail if there was a diagram AND this isn't a no-diagram session
|
||||
withThumbnail: hasDiagramNow && !isNodiagramSession,
|
||||
})
|
||||
await saveCurrentSessionRef.current(
|
||||
sessionData,
|
||||
scheduledForChat,
|
||||
)
|
||||
await saveCurrentSessionRef.current(sessionData, ticket)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to save session:", error)
|
||||
@@ -736,6 +747,7 @@ export default function ChatPanel({
|
||||
sessionIsAvailable,
|
||||
currentSessionId,
|
||||
getChatGeneration,
|
||||
getSaveTicket,
|
||||
buildSessionData,
|
||||
])
|
||||
|
||||
@@ -768,10 +780,11 @@ export default function ChatPanel({
|
||||
try {
|
||||
// Attempt to save session - browser may not wait for completion
|
||||
// Skip thumbnail capture as it may not complete in time
|
||||
const ticket = sessionManager.getSaveTicket()
|
||||
const sessionData = await buildSessionData({
|
||||
withThumbnail: false,
|
||||
})
|
||||
await sessionManager.saveCurrentSession(sessionData)
|
||||
await sessionManager.saveCurrentSession(sessionData, ticket)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Failed to save session on visibility change:",
|
||||
@@ -922,11 +935,13 @@ 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)
|
||||
// go on without saving. It replaces the plain message, and has its own
|
||||
// id so a later failed auto-save does not take its button away.
|
||||
const offerToContinueUnsaved = useCallback(
|
||||
(proceed: () => void) => {
|
||||
toast.dismiss("session-save-failed")
|
||||
toast.error(dict.errors.sessionSaveFailedLeave, {
|
||||
id: "session-save-failed",
|
||||
id: "session-save-leave",
|
||||
duration: 15000,
|
||||
action: {
|
||||
label: dict.errors.continueWithoutSaving,
|
||||
@@ -937,6 +952,14 @@ export default function ChatPanel({
|
||||
[dict],
|
||||
)
|
||||
|
||||
// A new turn makes the offer stale: going on would clear the chat while
|
||||
// the answer streams in
|
||||
useEffect(() => {
|
||||
if (status === "submitted" || status === "streaming") {
|
||||
toast.dismiss("session-save-leave")
|
||||
}
|
||||
}, [status])
|
||||
|
||||
// Handle session switching from history dropdown
|
||||
const handleSelectSession = useCallback(
|
||||
async (sessionId: string) => {
|
||||
@@ -969,10 +992,18 @@ export default function ChatPanel({
|
||||
// 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)) {
|
||||
// Of the chat on screen now, also if another one comes on
|
||||
// screen while the thumbnail is taken
|
||||
const ticket = sessionManager.getSaveTicket()
|
||||
const sessionData = await buildSessionData({
|
||||
withThumbnail: true,
|
||||
})
|
||||
if (!(await sessionManager.saveCurrentSession(sessionData))) {
|
||||
if (
|
||||
!(await sessionManager.saveCurrentSession(
|
||||
sessionData,
|
||||
ticket,
|
||||
))
|
||||
) {
|
||||
offerToContinueUnsaved(open)
|
||||
return
|
||||
}
|
||||
@@ -1049,10 +1080,13 @@ export default function ChatPanel({
|
||||
sessionManager.isAvailable &&
|
||||
(messages.length > 0 || isRealDiagram(chartXMLRef.current))
|
||||
) {
|
||||
const ticket = sessionManager.getSaveTicket()
|
||||
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))) {
|
||||
if (
|
||||
!(await sessionManager.saveCurrentSession(sessionData, ticket))
|
||||
) {
|
||||
offerToContinueUnsaved(startNewChat)
|
||||
return
|
||||
}
|
||||
@@ -1128,6 +1162,7 @@ export default function ChatPanel({
|
||||
// Handle stop button click
|
||||
const handleStop = useCallback(() => {
|
||||
stoppedRef.current = true
|
||||
stopCountRef.current++
|
||||
// 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()
|
||||
|
||||
@@ -30,7 +30,7 @@ function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
|
||||
<div className="space-y-3">
|
||||
{shown.map((op, index) => (
|
||||
<div
|
||||
key={`${op.operation}-${op.cell_id}-${index}`}
|
||||
key={`${op.operation}-${text(op.cell_id)}-${index}`}
|
||||
className="rounded-lg border border-border/50 overflow-hidden bg-background/50"
|
||||
>
|
||||
<div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2">
|
||||
|
||||
@@ -559,8 +559,9 @@ export function ModelConfigDialog({
|
||||
clearTimeout(validationResetTimeoutRef.current)
|
||||
}
|
||||
validationResetTimeoutRef.current = setTimeout(() => {
|
||||
setValidationStatus("idle")
|
||||
validationResetTimeoutRef.current = null
|
||||
if (run !== validationRunRef.current) return
|
||||
setValidationStatus("idle")
|
||||
}, 1500)
|
||||
} else {
|
||||
setValidationStatus("error")
|
||||
|
||||
@@ -44,12 +44,12 @@ let lastSwitch = 0
|
||||
export async function switchPreset(
|
||||
id: string,
|
||||
): Promise<Record<string, string>> {
|
||||
const switchNumber = ++lastSwitch
|
||||
const previousPresetId = getCurrentPresetId()
|
||||
const env = applyPresetToEnv(id)
|
||||
if (!env) {
|
||||
throw new Error("Preset not found")
|
||||
}
|
||||
const switchNumber = ++lastSwitch
|
||||
rebuildAppMenu()
|
||||
|
||||
// In development, scripts/electron-dev.mjs restarts the Next.js dev server
|
||||
@@ -64,9 +64,10 @@ export async function switchPreset(
|
||||
console.error("Failed to restart server:", error)
|
||||
const reason = error instanceof Error ? error.message : String(error)
|
||||
|
||||
// A newer switch started meanwhile (also of this same preset): its
|
||||
// own restart follows, and undoing would lose that choice
|
||||
if (switchNumber !== lastSwitch) {
|
||||
// A newer switch started meanwhile (also of this same preset), or
|
||||
// the preset was deleted: its own restart follows, and undoing
|
||||
// would lose that choice
|
||||
if (switchNumber !== lastSwitch || getCurrentPresetId() !== id) {
|
||||
throw new Error(
|
||||
`The server could not be restarted.\n\nError: ${reason}`,
|
||||
)
|
||||
|
||||
@@ -31,15 +31,20 @@ export function loadEnvFile(): void {
|
||||
/**
|
||||
* 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.
|
||||
* backslash stays in the value. As in dotenv, an escaped quote with only a
|
||||
* comment or nothing after it still closes the value when no other quote
|
||||
* does ("C:\dir\" keeps its trailing backslash).
|
||||
*/
|
||||
function findClosingQuote(value: string): number {
|
||||
const quote = value[0]
|
||||
let lastEscaped = -1
|
||||
for (let i = 1; i < value.length; i++) {
|
||||
if (value[i] === "\\" && value[i + 1] === quote) i++
|
||||
else if (value[i] === quote) return i
|
||||
if (value[i] === "\\" && value[i + 1] === quote) {
|
||||
i++
|
||||
if (/^\s*(#.*)?$/.test(value.slice(i + 1))) lastEscaped = i
|
||||
} else if (value[i] === quote) return i
|
||||
}
|
||||
return -1
|
||||
return lastEscaped
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -64,9 +64,11 @@ 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
|
||||
// Called when a screenshot check begins; the function it returns
|
||||
// tells whether the user pressed Stop in this turn, also after the next
|
||||
// message was sent. A check that has not started then is skipped (one
|
||||
// already running is cancelled by the caller).
|
||||
watchStop?: () => () => boolean
|
||||
onValidationStateChange?: (
|
||||
toolCallId: string,
|
||||
state: ValidationState,
|
||||
@@ -93,7 +95,7 @@ export function useDiagramToolHandlers({
|
||||
validateDiagram,
|
||||
enableVlmValidation = true,
|
||||
sessionId,
|
||||
isStopped,
|
||||
watchStop,
|
||||
onValidationStateChange,
|
||||
}: UseDiagramToolHandlersParams) {
|
||||
// Helper to update validation state
|
||||
@@ -252,6 +254,7 @@ ${finalXml}
|
||||
validateDiagram
|
||||
) {
|
||||
let capturedPngData: string | null = null
|
||||
const stopped = watchStop?.()
|
||||
try {
|
||||
// Notify UI that we're starting capture
|
||||
updateValidationState(toolCall.toolCallId, "capturing")
|
||||
@@ -263,7 +266,7 @@ ${finalXml}
|
||||
capturedPngData = await captureValidationPng()
|
||||
// Stopped while the screenshot was taken: no check. The
|
||||
// chat waits for this handler, so it must end now.
|
||||
if (isStopped?.()) {
|
||||
if (stopped?.()) {
|
||||
updateValidationState(toolCall.toolCallId, "skipped")
|
||||
} else if (capturedPngData) {
|
||||
if (DEBUG) {
|
||||
|
||||
@@ -28,6 +28,13 @@ export interface SessionData {
|
||||
diagramHistory?: { svg: string; xml: string }[]
|
||||
}
|
||||
|
||||
// Taken right before a save's data is read: the chat on screen then, and
|
||||
// the order of the reads
|
||||
export interface SaveTicket {
|
||||
generation: number
|
||||
seq: number
|
||||
}
|
||||
|
||||
export interface UseSessionManagerReturn {
|
||||
// State
|
||||
sessions: SessionMetadata[]
|
||||
@@ -39,17 +46,18 @@ export interface UseSessionManagerReturn {
|
||||
// Actions
|
||||
switchSession: (id: string) => Promise<SessionData | null>
|
||||
deleteSession: (id: string) => Promise<{ wasCurrentSession: boolean }>
|
||||
// chatGeneration: getChatGeneration() when the save was scheduled (by
|
||||
// default, now); the save is dropped if another chat is on screen when
|
||||
// its turn comes
|
||||
// ticket: getSaveTicket() before the data was read (by default, now).
|
||||
// The save is dropped if another chat is on screen when its turn comes,
|
||||
// or if a copy of this chat read later was saved already.
|
||||
// Resolves to false when the save failed (the user was told)
|
||||
saveCurrentSession: (
|
||||
data: SessionData,
|
||||
chatGeneration?: number,
|
||||
ticket?: SaveTicket,
|
||||
) => Promise<boolean>
|
||||
refreshSessions: () => Promise<void>
|
||||
clearCurrentSession: () => void
|
||||
getChatGeneration: () => number
|
||||
getSaveTicket: () => SaveTicket
|
||||
}
|
||||
|
||||
// Reading the session list loads every stored session in full, and window
|
||||
@@ -91,6 +99,9 @@ export function useSessionManager(
|
||||
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())
|
||||
// The last ticket number, and that of the newest data saved
|
||||
const saveSeqRef = useRef(0)
|
||||
const savedSeqRef = useRef(0)
|
||||
|
||||
const changeChat = useCallback((session: ChatSession | null) => {
|
||||
chatGenerationRef.current++
|
||||
@@ -140,14 +151,17 @@ export function useSessionManager(
|
||||
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
|
||||
// providers are what holds the keys, besides an access
|
||||
// code
|
||||
let hasSettings = true
|
||||
try {
|
||||
const config = JSON.parse(
|
||||
localStorage.getItem(STORAGE_KEYS.modelConfigs) ??
|
||||
"{}",
|
||||
)
|
||||
hasSettings = (config.providers?.length ?? 0) > 0
|
||||
hasSettings =
|
||||
(config.providers?.length ?? 0) > 0 ||
|
||||
!!localStorage.getItem(STORAGE_KEYS.accessCode)
|
||||
} catch {
|
||||
// Unreadable: treat as settings, and stay
|
||||
}
|
||||
@@ -277,12 +291,18 @@ export function useSessionManager(
|
||||
|
||||
// Save current session data (debounced externally by caller)
|
||||
const saveCurrentSession = useCallback(
|
||||
(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
|
||||
(data: SessionData, ticket?: SaveTicket): Promise<boolean> => {
|
||||
// The data is of the chat on screen when it was read
|
||||
const { generation, seq } = ticket ?? {
|
||||
generation: chatGenerationRef.current,
|
||||
seq: ++saveSeqRef.current,
|
||||
}
|
||||
const run = async (): Promise<boolean> => {
|
||||
// That chat is no longer on screen (leaving it saved it)
|
||||
if (generation !== chatGenerationRef.current) return true
|
||||
// A copy read later was saved already (one that waited for
|
||||
// its thumbnail must not undo it)
|
||||
if (seq < savedSeqRef.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
|
||||
@@ -308,6 +328,7 @@ export function useSessionManager(
|
||||
notifySaveFailed(dict.errors.sessionSaveFailed)
|
||||
return false
|
||||
}
|
||||
savedSeqRef.current = seq
|
||||
await enforceSessionLimit()
|
||||
if (stillOnScreen()) {
|
||||
currentSessionRef.current = newSession
|
||||
@@ -342,6 +363,7 @@ export function useSessionManager(
|
||||
notifySaveFailed(dict.errors.sessionSaveFailed)
|
||||
return false
|
||||
}
|
||||
savedSeqRef.current = seq
|
||||
if (stillOnScreen()) {
|
||||
currentSessionRef.current = updatedSession
|
||||
setCurrentSession(updatedSession)
|
||||
@@ -382,6 +404,14 @@ export function useSessionManager(
|
||||
|
||||
const getChatGeneration = useCallback(() => chatGenerationRef.current, [])
|
||||
|
||||
const getSaveTicket = useCallback(
|
||||
(): SaveTicket => ({
|
||||
generation: chatGenerationRef.current,
|
||||
seq: ++saveSeqRef.current,
|
||||
}),
|
||||
[],
|
||||
)
|
||||
|
||||
return {
|
||||
sessions,
|
||||
currentSessionId,
|
||||
@@ -394,5 +424,6 @@ export function useSessionManager(
|
||||
refreshSessions,
|
||||
clearCurrentSession,
|
||||
getChatGeneration,
|
||||
getSaveTicket,
|
||||
}
|
||||
}
|
||||
|
||||
+15
-5
@@ -21,6 +21,7 @@ import {
|
||||
adminProvidersToConfig,
|
||||
loadAdminProviders,
|
||||
} from "@/lib/admin/providers"
|
||||
import { getEnvFallback } from "@/lib/admin/settings"
|
||||
import { getApiEndpoint } from "@/lib/base-path"
|
||||
import { redirectGuardedFetch } from "@/lib/ssrf-protection"
|
||||
import {
|
||||
@@ -803,8 +804,11 @@ export function getAIModel(clientOverrides?: ClientOverrides): ModelConfig {
|
||||
// Exception: EdgeOne doesn't require API keys.
|
||||
// Ollama is exempt only when no server OLLAMA_API_KEY is configured;
|
||||
// when it IS configured, the outer guard also enforces client apiKey for custom baseUrls.
|
||||
// A trusted URL is the server's own (the admin Test of an entry without
|
||||
// one), not a user's
|
||||
if (
|
||||
overrides?.baseUrl &&
|
||||
!overrides?.trustedBaseUrl &&
|
||||
!overrides?.apiKey &&
|
||||
!(overrides?.provider === "vertexai" && overrides?.vertexApiKey) &&
|
||||
overrides?.provider !== "edgeone" &&
|
||||
@@ -1118,14 +1122,20 @@ export function edgeOneEndpoint(req: Request): string {
|
||||
/**
|
||||
* 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). 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).
|
||||
* without a URL). None for Bedrock and EdgeOne. Ollama and Vertex AI share
|
||||
* one variable with the panel, which writes an entry's URL into it: an
|
||||
* entry without a URL gets the environment's value once saved (before a
|
||||
* save the variable may still hold the entry's previous URL), and Ollama
|
||||
* without one goes to the SDK's local default.
|
||||
*/
|
||||
export function globalBaseUrl(provider: ProviderName): string | undefined {
|
||||
if (["bedrock", "edgeone", "ollama", "vertexai"].includes(provider)) {
|
||||
return undefined
|
||||
if (provider === "ollama") {
|
||||
return getEnvFallback("OLLAMA_BASE_URL") || "http://127.0.0.1:11434/api"
|
||||
}
|
||||
if (provider === "vertexai") {
|
||||
return getEnvFallback("GOOGLE_VERTEX_BASE_URL") || undefined
|
||||
}
|
||||
if (provider === "bedrock" || provider === "edgeone") return undefined
|
||||
const name =
|
||||
provider === "gateway"
|
||||
? "AI_GATEWAY_BASE_URL"
|
||||
|
||||
+28
-6
@@ -131,8 +131,8 @@ const MAX_REDIRECTS = 5
|
||||
* blocked, a public URL could still redirect the request to an internal
|
||||
* 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.
|
||||
* server's: a public URL's redirects are followed only to public addresses,
|
||||
* or it could reach the server's own network uncounted. Undefined otherwise.
|
||||
*/
|
||||
export function redirectGuardedFetch(): typeof fetch | undefined {
|
||||
const blockAll = !allowPrivateUrls()
|
||||
@@ -140,6 +140,8 @@ export function redirectGuardedFetch(): typeof fetch | undefined {
|
||||
return async (input, init) => {
|
||||
let url = input instanceof Request ? input.url : String(input)
|
||||
let next = init
|
||||
// A request to a private address already counts as the server's
|
||||
let startsPrivate: boolean | undefined
|
||||
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
||||
const response = await fetch(url, { ...next, redirect: "manual" })
|
||||
const location = response.headers.get("location")
|
||||
@@ -147,20 +149,40 @@ export function redirectGuardedFetch(): typeof fetch | undefined {
|
||||
return response
|
||||
}
|
||||
if (blockAll) throw new RedirectRefusedError()
|
||||
startsPrivate ??= await isPrivateUrl(url)
|
||||
const from = new URL(url)
|
||||
url = new URL(location, url).toString()
|
||||
if (await isPrivateUrl(url)) {
|
||||
if (!startsPrivate && (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()
|
||||
// The rest as fetch itself does it. Another origin gets no
|
||||
// credentials (the user's key, EdgeOne's cookies)
|
||||
const headers = new Headers(next?.headers)
|
||||
if (new URL(url).origin !== from.origin) {
|
||||
headers.delete("authorization")
|
||||
headers.delete("proxy-authorization")
|
||||
headers.delete("cookie")
|
||||
}
|
||||
next = { ...next, headers }
|
||||
// 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")
|
||||
) {
|
||||
for (const name of [
|
||||
"content-type",
|
||||
"content-length",
|
||||
"content-encoding",
|
||||
"content-language",
|
||||
"content-location",
|
||||
]) {
|
||||
headers.delete(name)
|
||||
}
|
||||
next = { ...next, method: "GET", body: undefined }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
* Stores {xml, svg} entries in a circular buffer
|
||||
*/
|
||||
|
||||
import { contentFingerprint } from "./edit-gate.ts"
|
||||
import { log } from "./logger.ts"
|
||||
import { normalizeToMxfile, parseMxfile } from "./pages.ts"
|
||||
|
||||
const MAX_HISTORY = 20
|
||||
|
||||
@@ -16,6 +18,30 @@ interface HistoryEntry {
|
||||
let nextEntryId = 0
|
||||
const historyStore = new Map<string, HistoryEntry[]>()
|
||||
|
||||
/** Each page's background colour */
|
||||
function backgrounds(xml: string): string {
|
||||
const doc = parseMxfile(normalizeToMxfile(xml) ?? xml)
|
||||
if (!doc) return ""
|
||||
return Array.from(doc.querySelectorAll("mxGraphModel"))
|
||||
.map((m) => m.getAttribute("background") || "none")
|
||||
.join(",")
|
||||
}
|
||||
|
||||
// The same pages, cells and backgrounds. draw.io's own copy of a diagram
|
||||
// (a sync reply) adds view and page attributes such as dx, grid and the
|
||||
// page size, which the model's XML leaves out, so those are not compared.
|
||||
// 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) &&
|
||||
backgrounds(a) === backgrounds(b)
|
||||
)
|
||||
}
|
||||
|
||||
export function addHistory(sessionId: string, xml: string, svg = ""): number {
|
||||
let history = historyStore.get(sessionId)
|
||||
if (!history) {
|
||||
@@ -23,10 +49,10 @@ export function addHistory(sessionId: string, xml: string, svg = ""): number {
|
||||
historyStore.set(sessionId, history)
|
||||
}
|
||||
|
||||
// Dedupe: skip if same as last entry (a change of page settings or
|
||||
// background only is a new version)
|
||||
// Dedupe: skip if same as last entry, also when only re-serialized
|
||||
// (a change of background only is a new version)
|
||||
const last = history[history.length - 1]
|
||||
if (last && last.xml === xml) {
|
||||
if (last && sameDiagram(last.xml, xml)) {
|
||||
if (svg && !last.svg) last.svg = svg
|
||||
return history.length - 1
|
||||
}
|
||||
|
||||
@@ -190,8 +190,9 @@ async function pushState(xml, svg = '', baseVersion = currentVersion, source = '
|
||||
pushFailing = false;
|
||||
if (r.ok) {
|
||||
const d = await r.json();
|
||||
// An answer about a state this tab has left since
|
||||
if (sid !== stateId) return;
|
||||
// An answer about a state this tab has left since, or one that
|
||||
// comes after a newer version was loaded or saved
|
||||
if (sid !== stateId || d.version < currentVersion) return;
|
||||
currentVersion = d.version;
|
||||
lastXml = xml;
|
||||
// The canvas changed while this edit was on its way, to
|
||||
|
||||
@@ -582,6 +582,15 @@ describe("history restore", () => {
|
||||
expect(getHistory(id)).toHaveLength(1)
|
||||
// The missing image is filled in
|
||||
expect(getHistory(id)[0].svg).toBe("SVG")
|
||||
// draw.io's copy of it, as a sync reply brings it back
|
||||
addHistory(
|
||||
id,
|
||||
page("same").replace(
|
||||
"<mxGraphModel>",
|
||||
'<mxGraphModel dx="1244" dy="534" grid="1" pageWidth="850">',
|
||||
),
|
||||
)
|
||||
expect(getHistory(id)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("keeps a version that changed only the background", () => {
|
||||
|
||||
@@ -11,13 +11,19 @@ vi.mock("@/app/api/validate-model/route", () => ({
|
||||
},
|
||||
}))
|
||||
vi.mock("@/lib/admin/auth", () => ({ checkAdminAuth: () => null }))
|
||||
vi.mock("@/lib/admin/settings", () => ({ loadSettings: () => ({}) }))
|
||||
// The environment's own values, under the panel's settings
|
||||
const envFallback = vi.hoisted(() => ({ values: {} as Record<string, string> }))
|
||||
vi.mock("@/lib/admin/settings", () => ({
|
||||
loadSettings: () => ({}),
|
||||
getEnvFallback: (key: string) => envFallback.values[key] ?? null,
|
||||
}))
|
||||
|
||||
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(() => {
|
||||
envFallback.values = {}
|
||||
for (const k of ENV) {
|
||||
saved[k] = process.env[k]
|
||||
delete process.env[k]
|
||||
@@ -74,8 +80,25 @@ describe("admin Test of an entry without a URL", () => {
|
||||
try {
|
||||
await test({ provider: "vertexai", vertexApiKey: "new-key" })
|
||||
expect(sent.body.baseUrl).toBeUndefined()
|
||||
// The environment's own URL, which chat uses once it is saved
|
||||
envFallback.values.GOOGLE_VERTEX_BASE_URL =
|
||||
"https://vertex-proxy.example.com"
|
||||
await test({ provider: "vertexai", vertexApiKey: "new-key" })
|
||||
expect(sent.body.baseUrl).toBe("https://vertex-proxy.example.com")
|
||||
expect(sent.body.serverBaseUrl).toBe(true)
|
||||
} finally {
|
||||
delete process.env.GOOGLE_VERTEX_BASE_URL
|
||||
}
|
||||
})
|
||||
|
||||
it("tests Ollama where chat sends the entry's key", async () => {
|
||||
// Chat on the saved entry: OLLAMA_BASE_URL of the environment, else
|
||||
// the SDK's local default (the Test used to go to Ollama Cloud)
|
||||
await test({ provider: "ollama", apiKey: "k" })
|
||||
expect(sent.body.baseUrl).toBe("http://127.0.0.1:11434/api")
|
||||
expect(sent.body.serverBaseUrl).toBe(true)
|
||||
envFallback.values.OLLAMA_BASE_URL = "http://gpu:11434/api"
|
||||
await test({ provider: "ollama", apiKey: "k" })
|
||||
expect(sent.body.baseUrl).toBe("http://gpu:11434/api")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ const settings = vi.hoisted(() => ({ values: {} as Record<string, string> }))
|
||||
|
||||
vi.mock("@/lib/admin/settings", () => ({
|
||||
loadSettings: () => settings.values,
|
||||
getEnvFallback: (key: string) => process.env[key] ?? null,
|
||||
}))
|
||||
|
||||
vi.mock("@ai-sdk/google-vertex", () => {
|
||||
|
||||
@@ -21,6 +21,7 @@ const state = vi.hoisted(() => ({
|
||||
}))
|
||||
vi.mock("@/electron/main/config-manager", () => ({
|
||||
applyPresetToEnv: (id: string) => {
|
||||
if (id === "missing") return null
|
||||
state.current = id
|
||||
return { AI_PROVIDER: id }
|
||||
},
|
||||
@@ -80,4 +81,24 @@ describe("switchPreset", () => {
|
||||
await third
|
||||
expect(state.current).toBe("B")
|
||||
})
|
||||
|
||||
it("does not bring back the old preset over a deletion", async () => {
|
||||
const toB = switchPreset("B").catch(() => {})
|
||||
// B is deleted while its restart is pending
|
||||
state.current = null
|
||||
state.restarts[0].reject(new Error("timed out"))
|
||||
await toB
|
||||
expect(state.current).toBeNull()
|
||||
expect(state.restarts).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("still rolls back when a later request named no preset", async () => {
|
||||
const toB = switchPreset("B").catch(() => {})
|
||||
await expect(switchPreset("missing")).rejects.toThrow("not found")
|
||||
state.restarts[0].reject(new Error("timed out"))
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
state.restarts[1]?.resolve()
|
||||
await toB
|
||||
expect(state.current).toBe("A")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -163,11 +163,13 @@ describe("chat quota", () => {
|
||||
|
||||
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
|
||||
// leftover base URL says (with a key header too, so the request gets
|
||||
// past the custom URL check to the quota decision)
|
||||
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-ai-api-key": "leftover",
|
||||
"x-aws-access-key-id": "id",
|
||||
"x-aws-secret-access-key": "secret",
|
||||
"x-aws-region": "us-east-1",
|
||||
|
||||
@@ -28,6 +28,8 @@ const KEYS = [
|
||||
"T_ESC_HASH",
|
||||
"T_ESC_INNER",
|
||||
"T_ESC_COMMENT",
|
||||
"T_DIR",
|
||||
"T_DIR_COMMENT",
|
||||
]
|
||||
afterEach(() => {
|
||||
for (const k of KEYS) delete process.env[k]
|
||||
@@ -92,4 +94,18 @@ describe("loadEnvFile", () => {
|
||||
expect(process.env.T_ESC_INNER).toBe('a # \\"b\\"')
|
||||
expect(process.env.T_ESC_COMMENT).toBe('x\\"y')
|
||||
})
|
||||
|
||||
it("keeps a backslash before the closing quote, like dotenv", () => {
|
||||
dir.path = mkdtempSync(join(tmpdir(), "env-loader-"))
|
||||
writeFileSync(
|
||||
join(dir.path, ".env"),
|
||||
['T_DIR="C:\\dir\\"', 'T_DIR_COMMENT="C:\\data\\" # dir'].join(
|
||||
"\n",
|
||||
),
|
||||
)
|
||||
loadEnvFile()
|
||||
// Windows folders; dotenv 16.6.1 reads them the same
|
||||
expect(process.env.T_DIR).toBe("C:\\dir\\")
|
||||
expect(process.env.T_DIR_COMMENT).toBe("C:\\data\\")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -238,6 +238,27 @@ describe("MCP preview after the server recreated its session", () => {
|
||||
expect(t.next("POST").body.xml).toBe("<mxfile>A</mxfile>")
|
||||
})
|
||||
|
||||
it("ignores an edit's answer that comes after a newer AI write loaded", 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")
|
||||
// The AI wrote X after B; the poll's answer comes first
|
||||
const poll = t.page.poll()
|
||||
t.next("GET").answer(state("S1", 4, "<mxfile>X</mxfile>"))
|
||||
await poll
|
||||
pushB.answer({ status: 200, body: { success: true, version: 3 } })
|
||||
await t.settle()
|
||||
await t.settle()
|
||||
expect(t.page.read()).toMatchObject({
|
||||
currentVersion: 4,
|
||||
lastXml: "<mxfile>X</mxfile>",
|
||||
})
|
||||
// No push of the AI's diagram as the user's edit
|
||||
expect(t.calls.filter((c) => c.method === "POST")).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("sends nothing more after a sync reply", async () => {
|
||||
const t = await inStep()
|
||||
const poll = t.page.poll()
|
||||
@@ -298,13 +319,27 @@ describe("MCP preview thumbnails and downloads", () => {
|
||||
|
||||
it("drops the reply to an older thumbnail export", async () => {
|
||||
const { t, n } = await loadedB()
|
||||
// The next AI write loads before draw.io answered the first export
|
||||
const poll = t.page.poll()
|
||||
t.next("GET").answer(state("S1", 4, "<mxfile>C</mxfile>"))
|
||||
await poll
|
||||
await new Promise((r) => setTimeout(r, 600))
|
||||
const newer = t.toDrawio.at(-1).thumbExport
|
||||
expect(newer).toBeGreaterThan(n)
|
||||
t.fromDrawio({
|
||||
event: "export",
|
||||
data: "<svg/>",
|
||||
message: { thumbExport: n - 1 },
|
||||
data: "<svg>B</svg>",
|
||||
message: { thumbExport: n },
|
||||
})
|
||||
await t.settle()
|
||||
expect(thumbnailPosts(t)).toHaveLength(0)
|
||||
t.fromDrawio({
|
||||
event: "export",
|
||||
data: "<svg>C</svg>",
|
||||
message: { thumbExport: newer },
|
||||
})
|
||||
await t.settle()
|
||||
expect(thumbnailPosts(t).map((c) => c.body.version)).toEqual([4])
|
||||
})
|
||||
|
||||
it("drops the image when the user changed the canvas since the load", async () => {
|
||||
@@ -312,11 +347,17 @@ describe("MCP preview thumbnails and downloads", () => {
|
||||
t.fromDrawio({ event: "autosave", xml: "<mxfile>B edited</mxfile>" })
|
||||
t.fromDrawio({
|
||||
event: "export",
|
||||
data: "<svg/>",
|
||||
data: "<svg>thumbnail</svg>",
|
||||
message: { thumbExport: n },
|
||||
})
|
||||
await t.settle()
|
||||
expect(thumbnailPosts(t)).toHaveLength(0)
|
||||
// The edit is saved with the image of its own export
|
||||
t.fromDrawio({ event: "export", data: "<svg>edit</svg>" })
|
||||
await t.settle()
|
||||
const push = t.next("POST")
|
||||
expect(push.body.xml).toBe("<mxfile>B edited</mxfile>")
|
||||
expect(atob(push.body.svg.split(",")[1])).toBe("<svg>edit</svg>")
|
||||
})
|
||||
|
||||
it("downloads the canvas with an edit the server did not get", async () => {
|
||||
|
||||
@@ -143,6 +143,58 @@ describe("redirectGuardedFetch with the quota on", () => {
|
||||
expect(fetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("follows a private address's redirect to another one", async () => {
|
||||
// Counted as the server's from the start
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
answers({
|
||||
"http://10.0.0.5:4000/v1/chat": new Response(null, {
|
||||
status: 307,
|
||||
headers: { location: "http://10.0.0.6:4000/v1/chat" },
|
||||
}),
|
||||
"http://10.0.0.6:4000/v1/chat": new Response("ok"),
|
||||
}),
|
||||
)
|
||||
const res = await redirectGuardedFetch()?.(
|
||||
"http://10.0.0.5:4000/v1/chat",
|
||||
{ method: "POST", body: "{}" },
|
||||
)
|
||||
expect(await res?.text()).toBe("ok")
|
||||
})
|
||||
|
||||
it("sends no credentials to another origin", async () => {
|
||||
const fetchMock = answers({
|
||||
"https://proxy.example/v1/chat": new Response(null, {
|
||||
status: 307,
|
||||
headers: { location: "https://other.example/v1/chat" },
|
||||
}),
|
||||
"https://other.example/v1/chat": new Response("ok"),
|
||||
})
|
||||
vi.stubGlobal("fetch", fetchMock)
|
||||
await redirectGuardedFetch()?.("https://proxy.example/v1/chat", {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
headers: {
|
||||
Authorization: "Bearer user-key",
|
||||
Cookie: "eo_token=1",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
const sent = (call: number) =>
|
||||
new Headers(
|
||||
(
|
||||
fetchMock.mock.calls[call] as unknown as [
|
||||
string,
|
||||
RequestInit,
|
||||
]
|
||||
)[1].headers,
|
||||
)
|
||||
expect(sent(0).get("authorization")).toBe("Bearer user-key")
|
||||
expect(sent(1).get("authorization")).toBeNull()
|
||||
expect(sent(1).get("cookie")).toBeNull()
|
||||
expect(sent(1).get("content-type")).toBe("application/json")
|
||||
})
|
||||
|
||||
it("is not used without the quota", () => {
|
||||
delete process.env.DYNAMODB_QUOTA_TABLE
|
||||
expect(redirectGuardedFetch()).toBeUndefined()
|
||||
|
||||
@@ -16,6 +16,8 @@ describe("ToolCallCard", () => {
|
||||
null,
|
||||
{ operation: {} },
|
||||
{ operation: "add", cell_id: {} },
|
||||
// JSON can hold an object that does not turn into text
|
||||
JSON.parse('{"operation":"add","cell_id":{"toString":null}}'),
|
||||
{ operation: "add", cell_id: "2", new_xml: {} },
|
||||
{ operation: "update", cell_id: "3", new_xml: '<mxCell id="3"/>' },
|
||||
]
|
||||
|
||||
@@ -47,8 +47,9 @@ function setup(partialXml: string) {
|
||||
|
||||
describe("the screenshot check and Stop", () => {
|
||||
const draw = async (opts: {
|
||||
isStopped: () => boolean
|
||||
watchStop: () => () => boolean
|
||||
validateDiagram: () => Promise<any>
|
||||
captureValidationPng?: () => Promise<string>
|
||||
}) => {
|
||||
const onValidationStateChange = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
@@ -62,9 +63,11 @@ describe("the screenshot check and Stop", () => {
|
||||
onFetchChart: async () => "",
|
||||
onExport: () => {},
|
||||
enableVlmValidation: true,
|
||||
captureValidationPng: async () => "data:image/png;base64,AA",
|
||||
captureValidationPng:
|
||||
opts.captureValidationPng ??
|
||||
(async () => "data:image/png;base64,AA"),
|
||||
validateDiagram: opts.validateDiagram,
|
||||
isStopped: opts.isStopped,
|
||||
watchStop: opts.watchStop,
|
||||
onValidationStateChange,
|
||||
}),
|
||||
)
|
||||
@@ -89,7 +92,7 @@ describe("the screenshot check and Stop", () => {
|
||||
suggestions: [],
|
||||
}))
|
||||
const { addToolOutput, onValidationStateChange } = await draw({
|
||||
isStopped: () => true,
|
||||
watchStop: () => () => true,
|
||||
validateDiagram,
|
||||
})
|
||||
expect(validateDiagram).not.toHaveBeenCalled()
|
||||
@@ -103,7 +106,7 @@ describe("the screenshot check and Stop", () => {
|
||||
|
||||
it("ends with the diagram's result when Stop cancels a running check", async () => {
|
||||
const { addToolOutput, onValidationStateChange } = await draw({
|
||||
isStopped: () => false,
|
||||
watchStop: () => () => false,
|
||||
validateDiagram: async () => {
|
||||
throw new DOMException("Validation cancelled", "AbortError")
|
||||
},
|
||||
@@ -114,6 +117,34 @@ describe("the screenshot check and Stop", () => {
|
||||
expect(addToolOutput).toHaveBeenCalledTimes(1)
|
||||
expect(addToolOutput.mock.lastCall?.[0].state).toBeUndefined()
|
||||
})
|
||||
|
||||
it("skips the check when Stop came during the screenshot", async () => {
|
||||
// As the chat panel counts it: the next message already cleared
|
||||
// the stop flag when the screenshot arrives
|
||||
let stops = 0
|
||||
let stoppedNow = false
|
||||
const validateDiagram = vi.fn(async () => ({
|
||||
valid: true,
|
||||
issues: [],
|
||||
suggestions: [],
|
||||
}))
|
||||
const { onValidationStateChange } = await draw({
|
||||
watchStop: () => {
|
||||
const before = stops
|
||||
return () => stoppedNow || stops !== before
|
||||
},
|
||||
captureValidationPng: async () => {
|
||||
stops++ // Stop
|
||||
stoppedNow = false // the next message
|
||||
return "data:image/png;base64,AA"
|
||||
},
|
||||
validateDiagram,
|
||||
})
|
||||
expect(validateDiagram).not.toHaveBeenCalled()
|
||||
expect(onValidationStateChange.mock.lastCall?.[1].status).toBe(
|
||||
"skipped",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("append_diagram and the stored previews", () => {
|
||||
|
||||
@@ -96,7 +96,7 @@ describe("saving the chat on screen", () => {
|
||||
|
||||
it("drops a save scheduled before New Chat", async () => {
|
||||
const { result } = await setup()
|
||||
const scheduled = result.current.getChatGeneration()
|
||||
const scheduled = result.current.getSaveTicket()
|
||||
act(() => result.current.clearCurrentSession())
|
||||
let save!: Promise<boolean>
|
||||
act(() => {
|
||||
@@ -110,7 +110,7 @@ describe("saving the chat on screen", () => {
|
||||
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()
|
||||
const scheduled = result.current.getSaveTicket()
|
||||
let newChatSave!: Promise<boolean>
|
||||
let autoSave!: Promise<boolean>
|
||||
act(() => {
|
||||
@@ -168,3 +168,65 @@ describe("saving the chat on screen", () => {
|
||||
expect(hook.result.current.currentSessionId).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("save tickets", () => {
|
||||
beforeEach(() => {
|
||||
stored.clear()
|
||||
pendingWrites = []
|
||||
})
|
||||
const textOf = (session: any) => session?.messages[0].parts[0].text
|
||||
const said = (text: string) => ({
|
||||
...data,
|
||||
messages: [{ ...data.messages[0], parts: [{ type: "text", text }] }],
|
||||
})
|
||||
|
||||
it("never put an older copy of a chat over a newer one", async () => {
|
||||
const { result } = await setup()
|
||||
let first!: Promise<boolean>
|
||||
act(() => {
|
||||
first = result.current.saveCurrentSession(said("first"))
|
||||
})
|
||||
await finishWrites()
|
||||
await first
|
||||
// An auto-save read its data, then waits for its thumbnail; a save
|
||||
// without a thumbnail reads newer data and is done first
|
||||
const older = result.current.getSaveTicket()
|
||||
const newer = result.current.getSaveTicket()
|
||||
let saves!: Promise<boolean[]>
|
||||
act(() => {
|
||||
saves = Promise.all([
|
||||
result.current.saveCurrentSession(said("newer"), newer),
|
||||
result.current.saveCurrentSession(said("older"), older),
|
||||
])
|
||||
})
|
||||
await finishWrites()
|
||||
await saves
|
||||
expect(textOf([...stored.values()][0])).toBe("newer")
|
||||
})
|
||||
|
||||
it("keep a chat read before a switch out of the chat switched to", async () => {
|
||||
stored.set("other", {
|
||||
...said("other chat"),
|
||||
id: "other",
|
||||
title: "Other",
|
||||
})
|
||||
const { result } = await setup()
|
||||
// New Chat reads this chat, then waits for its thumbnail
|
||||
const ticket = result.current.getSaveTicket()
|
||||
// Meanwhile the user opens the other chat
|
||||
let open!: Promise<unknown>
|
||||
act(() => {
|
||||
open = result.current.switchSession("other")
|
||||
})
|
||||
await finishWrites()
|
||||
await open
|
||||
let late!: Promise<boolean>
|
||||
act(() => {
|
||||
late = result.current.saveCurrentSession(said("this chat"), ticket)
|
||||
})
|
||||
await finishWrites()
|
||||
await late
|
||||
expect(textOf(stored.get("other"))).toBe("other chat")
|
||||
expect(stored.size).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,7 +6,10 @@ import { POST as validateModel } from "@/app/api/validate-model/route"
|
||||
import { getAIModel } from "@/lib/ai-providers"
|
||||
|
||||
// No saved admin providers
|
||||
vi.mock("@/lib/admin/settings", () => ({ loadSettings: () => ({}) }))
|
||||
vi.mock("@/lib/admin/settings", () => ({
|
||||
loadSettings: () => ({}),
|
||||
getEnvFallback: (key: string) => process.env[key] ?? null,
|
||||
}))
|
||||
|
||||
// Every URL is public (no DNS in tests), unless a test says otherwise
|
||||
const privateUrls = vi.hoisted(() => ({ all: false }))
|
||||
|
||||
Reference in New Issue
Block a user