mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-11 03:59:58 +08:00
fix: older defects (batch C) and the second batch's review
Chats: - New Chat right after an answer saves that chat once. Saves run one at a time and read the chat on screen when their turn comes; a save scheduled for a chat that is no longer on screen is dropped. A chat whose id was still on its way to the URL no longer comes back after New Chat (the next answer went into it). - Crossing the 768 px breakpoint keeps the chat panel: a streaming answer, unsaved messages and attachments stay. The panel gets the sizes of each side, and a panel collapsed on desktop opens on mobile. - The chat's export waits for its own reply: an edit's history export still on its way no longer answers it with the older diagram, and two file saves at once no longer swap results. - A second edit in one answer is previewed on the first edit's result. - Stop also ends a running screenshot check; a chat that cannot be saved (storage full) can be left with "Continue without saving". - Small diagrams with shapes count as diagrams; the tool card no longer crashes on malformed operations. Quota and providers: - Requests that reach the server's own endpoints count toward the quota: EdgeOne (always its own endpoint now), a private base URL whatever key header is sent, keyless Ollama without a URL. With the quota on, a redirect is followed only to a public address. The output cap applies to these requests too. - Stop records the tokens of the steps that finished; the screenshot check counts its tokens without counting a request. - EdgeOne configured only by AI_PROVIDER works, also in the admin Test, which forwards the access code. Azure set up only in the admin panel works in chat. The Test sends a Bedrock session token. - The admin panel's Test of an entry without a URL uses the server's URL as the server does (no private address check for it); the admin panel no longer writes an Ollama URL. MCP server: - Write tools and start_session run one at a time, so two at once never drop each other's change; a cancelled call waiting its turn is skipped. get_diagram and export_diagram keep the session they started with. - Export to .drawio first gets the user's latest edits from the browser. - History thumbnails: one that arrives after the next AI write is dropped; a sync reply keeps the image; a version that changed only page settings is its own entry. - A diagram over the 10 MB limit is saved without its image, or the user is told to download it (the server now answers 413 instead of cutting the connection). - Labels holding text like id='1' or parent='1' are no longer read as attributes (a layer or a parent was deleted). A broken bare <mxGraphModel> file is refused. - After a sync reply the tab no longer sends its autosave copy again. Desktop and files: - A newer switch of the same preset is not rolled back by an older one that failed. .env values with escaped quotes are read whole. - MCP saved files: a file that could not be read stays protected while a folder without permission hides it, and is saved again once deleted. - The desktop app reports "no chats" only when the count was read and no model settings are stored.
This commit is contained in:
@@ -211,7 +211,7 @@ export function ChatMessageDisplay({
|
||||
</p>
|
||||
)
|
||||
}
|
||||
const { chartXML, loadDiagram: onDisplayChart } = useDiagram()
|
||||
const { chartXML, chartXMLRef, loadDiagram: onDisplayChart } = useDiagram()
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const scrollTopRef = useRef<HTMLDivElement>(null)
|
||||
const previousXML = useRef<string>("")
|
||||
@@ -422,10 +422,12 @@ export function ChatMessageDisplay({
|
||||
// Previous messages are already processed and won't change
|
||||
const messagesToProcess =
|
||||
messages.length > 0 ? [messages[messages.length - 1]] : []
|
||||
// The diagram without streamed previews. Undoing a failed edit's
|
||||
// preview below changes it before chartXML catches up, and an edit
|
||||
// streaming right after must start from the undone diagram.
|
||||
let baseXml = chartXML
|
||||
// The diagram without streamed previews, as loaded last: the tool
|
||||
// handler's result of an earlier edit is there before the chartXML
|
||||
// state catches up. Undoing a failed edit's preview below changes it
|
||||
// too, and an edit streaming right after must start from the undone
|
||||
// diagram.
|
||||
let baseXml = chartXMLRef.current
|
||||
|
||||
messagesToProcess.forEach((message) => {
|
||||
// Messages restored from a saved session were applied before it was
|
||||
@@ -587,7 +589,7 @@ export function ChatMessageDisplay({
|
||||
})
|
||||
}
|
||||
})
|
||||
}, [messages, handleDisplayChart, chartXML])
|
||||
}, [messages, handleDisplayChart, chartXMLRef])
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full w-full scrollbar-thin">
|
||||
|
||||
+89
-47
@@ -110,7 +110,7 @@ export default function ChatPanel({
|
||||
loadDiagram: onDisplayChart,
|
||||
handleExport: onExport,
|
||||
handleExportWithoutHistory,
|
||||
resolverRef,
|
||||
exportResolversRef,
|
||||
chartXML,
|
||||
chartXMLRef: liveChartXMLRef,
|
||||
latestSvg,
|
||||
@@ -128,21 +128,15 @@ export default function ChatPanel({
|
||||
const urlSessionId = searchParams.get("session")
|
||||
|
||||
const onFetchChart = (saveToHistory = true) => {
|
||||
// Waits for the reply to its own export, by its tag
|
||||
const tag = saveToHistory ? onExport() : handleExportWithoutHistory()
|
||||
return Promise.race([
|
||||
new Promise<string>((resolve) => {
|
||||
resolverRef.current = resolve
|
||||
if (saveToHistory) {
|
||||
onExport()
|
||||
} else {
|
||||
handleExportWithoutHistory()
|
||||
}
|
||||
if (tag) exportResolversRef.current[tag] = resolve
|
||||
}),
|
||||
new Promise<string>((_, reject) => {
|
||||
const currentResolver = resolverRef.current
|
||||
setTimeout(() => {
|
||||
if (resolverRef.current === currentResolver) {
|
||||
resolverRef.current = null
|
||||
}
|
||||
delete exportResolversRef.current[tag]
|
||||
reject(new Error("Chart export timed out after 10 seconds"))
|
||||
}, 10000)
|
||||
}),
|
||||
@@ -335,7 +329,8 @@ export default function ChatPanel({
|
||||
const validationRetryCountRef = useRef(0)
|
||||
|
||||
// VLM validation hook using AI SDK's useObject
|
||||
const { validateWithFallback } = useValidateDiagram()
|
||||
const { validateWithFallback, cancel: cancelValidation } =
|
||||
useValidateDiagram()
|
||||
|
||||
// Diagram tool handlers (display_diagram, edit_diagram, append_diagram)
|
||||
const { handleToolCall } = useDiagramToolHandlers({
|
||||
@@ -352,6 +347,7 @@ export default function ChatPanel({
|
||||
validateDiagram: validateWithFallback,
|
||||
enableVlmValidation: vlmValidationEnabled,
|
||||
sessionId,
|
||||
isStopped: () => stoppedRef.current,
|
||||
onValidationStateChange: handleValidationStateChange,
|
||||
})
|
||||
|
||||
@@ -675,6 +671,7 @@ export default function ChatPanel({
|
||||
isAvailable: sessionIsAvailable,
|
||||
currentSessionId,
|
||||
saveCurrentSession,
|
||||
getChatGeneration,
|
||||
} = sessionManager
|
||||
|
||||
// Use ref for saveCurrentSession to avoid infinite loop
|
||||
@@ -699,13 +696,14 @@ export default function ChatPanel({
|
||||
clearTimeout(localStorageDebounceRef.current)
|
||||
}
|
||||
|
||||
// Capture current session ID at schedule time to verify at save time
|
||||
const scheduledForSessionId = currentSessionId
|
||||
// Capture the chat on screen at schedule time; the save is dropped
|
||||
// if another chat is on screen by the time it runs
|
||||
const scheduledForChat = getChatGeneration()
|
||||
// Capture whether there's a REAL diagram NOW (not just empty template)
|
||||
const hasDiagramNow = isRealDiagram(chartXMLRef.current)
|
||||
// Check if this session was just loaded without a diagram
|
||||
const isNodiagramSession =
|
||||
justLoadedSessionIdRef.current === scheduledForSessionId
|
||||
justLoadedSessionIdRef.current === currentSessionId
|
||||
|
||||
// Debounce: save after 1 second of no changes
|
||||
localStorageDebounceRef.current = setTimeout(async () => {
|
||||
@@ -717,7 +715,7 @@ export default function ChatPanel({
|
||||
})
|
||||
await saveCurrentSessionRef.current(
|
||||
sessionData,
|
||||
scheduledForSessionId,
|
||||
scheduledForChat,
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -737,6 +735,7 @@ export default function ChatPanel({
|
||||
status,
|
||||
sessionIsAvailable,
|
||||
currentSessionId,
|
||||
getChatGeneration,
|
||||
buildSessionData,
|
||||
])
|
||||
|
||||
@@ -921,25 +920,33 @@ export default function ChatPanel({
|
||||
}
|
||||
}
|
||||
|
||||
// The current chat could not be saved (storage full). The list where
|
||||
// old chats can be deleted shows only in an empty chat, so let the user
|
||||
// go on without saving (same toast id: it replaces the plain message)
|
||||
const offerToContinueUnsaved = useCallback(
|
||||
(proceed: () => void) => {
|
||||
toast.error(dict.errors.sessionSaveFailedLeave, {
|
||||
id: "session-save-failed",
|
||||
duration: 15000,
|
||||
action: {
|
||||
label: dict.errors.continueWithoutSaving,
|
||||
onClick: proceed,
|
||||
},
|
||||
})
|
||||
},
|
||||
[dict],
|
||||
)
|
||||
|
||||
// Handle session switching from history dropdown
|
||||
const handleSelectSession = useCallback(
|
||||
async (sessionId: string) => {
|
||||
if (!sessionManager.isAvailable) return
|
||||
|
||||
// Save current session before switching (also a diagram drawn
|
||||
// without messages); if that failed (storage full), stay on it
|
||||
if (messages.length > 0 || isRealDiagram(chartXMLRef.current)) {
|
||||
const sessionData = await buildSessionData({
|
||||
withThumbnail: true,
|
||||
})
|
||||
if (!(await sessionManager.saveCurrentSession(sessionData))) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Switch to selected session
|
||||
const sessionData = await sessionManager.switchSession(sessionId)
|
||||
if (sessionData) {
|
||||
const open = async () => {
|
||||
const sessionData =
|
||||
await sessionManager.switchSession(sessionId)
|
||||
if (!sessionData) return
|
||||
const hasRealDiagram = isRealDiagram(sessionData.diagramXml)
|
||||
justLoadedSessionRef.current = true
|
||||
|
||||
@@ -957,8 +964,29 @@ export default function ChatPanel({
|
||||
syncUIWithSession(sessionData)
|
||||
router.replace(`?session=${sessionId}`, { scroll: false })
|
||||
}
|
||||
|
||||
// Save current session before switching (also a diagram drawn
|
||||
// without messages); if that failed (storage full), stay on it
|
||||
// unless the user goes on without saving it
|
||||
if (messages.length > 0 || isRealDiagram(chartXMLRef.current)) {
|
||||
const sessionData = await buildSessionData({
|
||||
withThumbnail: true,
|
||||
})
|
||||
if (!(await sessionManager.saveCurrentSession(sessionData))) {
|
||||
offerToContinueUnsaved(open)
|
||||
return
|
||||
}
|
||||
}
|
||||
await open()
|
||||
},
|
||||
[sessionManager, messages, buildSessionData, syncUIWithSession, router],
|
||||
[
|
||||
sessionManager,
|
||||
messages,
|
||||
buildSessionData,
|
||||
syncUIWithSession,
|
||||
router,
|
||||
offerToContinueUnsaved,
|
||||
],
|
||||
)
|
||||
|
||||
// Handle session deletion from history dropdown
|
||||
@@ -976,20 +1004,7 @@ export default function ChatPanel({
|
||||
[sessionManager, syncUIWithSession, router, pathname],
|
||||
)
|
||||
|
||||
const handleNewChat = useCallback(async () => {
|
||||
// Save current session before creating new one (also a diagram
|
||||
// drawn without messages)
|
||||
if (
|
||||
sessionManager.isAvailable &&
|
||||
(messages.length > 0 || isRealDiagram(chartXMLRef.current))
|
||||
) {
|
||||
const sessionData = await buildSessionData({ withThumbnail: true })
|
||||
// Not saved (storage full): keep the chat on screen
|
||||
if (!(await sessionManager.saveCurrentSession(sessionData))) return
|
||||
// Refresh sessions list to ensure dropdown shows the saved session
|
||||
await sessionManager.refreshSessions()
|
||||
}
|
||||
|
||||
const startNewChat = useCallback(() => {
|
||||
// Clear session manager state BEFORE clearing URL to prevent race condition
|
||||
// (otherwise the URL update effect would restore the old session URL)
|
||||
sessionManager.clearCurrentSession()
|
||||
@@ -1021,14 +1036,38 @@ export default function ChatPanel({
|
||||
setMessages,
|
||||
setSessionId,
|
||||
sessionManager,
|
||||
messages,
|
||||
router,
|
||||
dict.dialogs.clearSuccess,
|
||||
buildSessionData,
|
||||
setDiagramHistory,
|
||||
pathname,
|
||||
])
|
||||
|
||||
const handleNewChat = useCallback(async () => {
|
||||
// Save current session before creating new one (also a diagram
|
||||
// drawn without messages)
|
||||
if (
|
||||
sessionManager.isAvailable &&
|
||||
(messages.length > 0 || isRealDiagram(chartXMLRef.current))
|
||||
) {
|
||||
const sessionData = await buildSessionData({ withThumbnail: true })
|
||||
// Not saved (storage full): keep the chat on screen, unless the
|
||||
// user goes on without saving it
|
||||
if (!(await sessionManager.saveCurrentSession(sessionData))) {
|
||||
offerToContinueUnsaved(startNewChat)
|
||||
return
|
||||
}
|
||||
// Refresh sessions list to ensure dropdown shows the saved session
|
||||
await sessionManager.refreshSessions()
|
||||
}
|
||||
startNewChat()
|
||||
}, [
|
||||
sessionManager,
|
||||
messages,
|
||||
buildSessionData,
|
||||
offerToContinueUnsaved,
|
||||
startNewChat,
|
||||
])
|
||||
|
||||
// Handle sending a template directly (called from TemplatePanel)
|
||||
const handleSendTemplate = useCallback(
|
||||
async (template: { prompt: string }) => {
|
||||
@@ -1089,6 +1128,9 @@ export default function ChatPanel({
|
||||
// Handle stop button click
|
||||
const handleStop = useCallback(() => {
|
||||
stoppedRef.current = true
|
||||
// A running screenshot check holds up the chat (the SDK waits for
|
||||
// the tool handler): end it, so the call gets its result now
|
||||
cancelValidation()
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
// Calls the tool handler already took can still show as streaming:
|
||||
// the messages update at most every 150 ms (useChat throttle)
|
||||
@@ -1111,7 +1153,7 @@ export default function ChatPanel({
|
||||
})
|
||||
|
||||
stop()
|
||||
}, [messages, addToolOutput, stop])
|
||||
}, [messages, addToolOutput, stop, cancelValidation])
|
||||
|
||||
// Send chat message with headers
|
||||
const sendChatMessage = (
|
||||
|
||||
@@ -20,9 +20,15 @@ interface ToolCallCardProps {
|
||||
}
|
||||
|
||||
function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
|
||||
// Streamed or invalid input can hold anything: show only what React can
|
||||
// render (an object in place of a string would crash the whole chat)
|
||||
const shown = operations.filter(
|
||||
(op) => typeof (op as { operation?: unknown })?.operation === "string",
|
||||
)
|
||||
const text = (value: unknown) => (typeof value === "string" ? value : "")
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{operations.map((op, index) => (
|
||||
{shown.map((op, index) => (
|
||||
<div
|
||||
key={`${op.operation}-${op.cell_id}-${index}`}
|
||||
className="rounded-lg border border-border/50 overflow-hidden bg-background/50"
|
||||
@@ -40,13 +46,13 @@ function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
|
||||
{op.operation}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
cell_id: {op.cell_id}
|
||||
cell_id: {text(op.cell_id)}
|
||||
</span>
|
||||
</div>
|
||||
{op.new_xml && (
|
||||
{text(op.new_xml) && (
|
||||
<div className="px-3 py-2">
|
||||
<pre className="text-[11px] font-mono text-foreground/80 bg-muted/30 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{op.new_xml}
|
||||
{text(op.new_xml)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -203,6 +203,7 @@ export function ModelConfigDialog({
|
||||
p?.awsAccessKeyId,
|
||||
p?.awsSecretAccessKey,
|
||||
p?.awsRegion,
|
||||
p?.awsSessionToken,
|
||||
p?.vertexApiKey,
|
||||
])
|
||||
}
|
||||
@@ -451,6 +452,9 @@ export function ModelConfigDialog({
|
||||
awsSecretAccessKey:
|
||||
selectedProvider.awsSecretAccessKey,
|
||||
awsRegion: selectedProvider.awsRegion,
|
||||
// Temporary AWS credentials, as the chat sends
|
||||
awsSessionToken:
|
||||
selectedProvider.awsSessionToken,
|
||||
// Vertex AI credentials (Express Mode)
|
||||
vertexApiKey: selectedProvider.vertexApiKey,
|
||||
}),
|
||||
@@ -492,19 +496,18 @@ export function ModelConfigDialog({
|
||||
validationWarning: undefined,
|
||||
}
|
||||
}
|
||||
// A newer test started: its own results and spinners count,
|
||||
// whatever the credentials are now (they may have come back)
|
||||
if (run !== validationRunRef.current) return
|
||||
// Credentials changed during the test: drop the result. A
|
||||
// change made in this tab already reset the spinners (and a
|
||||
// newer test may show its own); one from another tab did
|
||||
// not, so the latest test clears its own (model ids are
|
||||
// unique, whatever provider is shown).
|
||||
// change in another tab left the spinner on, so clear it
|
||||
// (model ids are unique, whatever provider is shown).
|
||||
if (credentialsOf(selectedProviderId) !== askedWith) {
|
||||
if (run === validationRunRef.current) {
|
||||
setValidatingModelIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(model.id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
setValidatingModelIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(model.id)
|
||||
return next
|
||||
})
|
||||
return
|
||||
}
|
||||
// So did this model's id: the result is for the old one
|
||||
@@ -532,13 +535,10 @@ export function ModelConfigDialog({
|
||||
})
|
||||
}),
|
||||
)
|
||||
if (run !== validationRunRef.current) return
|
||||
if (credentialsOf(selectedProviderId) !== askedWith) {
|
||||
// The status line belongs to the latest test, and to the
|
||||
// provider shown now
|
||||
if (
|
||||
run === validationRunRef.current &&
|
||||
selectedProviderIdRef.current === selectedProviderId
|
||||
) {
|
||||
// The status line is about the provider shown now
|
||||
if (selectedProviderIdRef.current === selectedProviderId) {
|
||||
setValidationStatus("idle")
|
||||
}
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user