mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-10 19:49:52 +08:00
fix(chat): keep the canvas after an unrelated error, and more review fixes
Found by the second PR review: - After a streamed edit, an older render of the stream stored the edit's original diagram again, and the next failed request (no quota, a lost connection) put that old diagram back on the canvas. The tool handler now marks its call as handled, so the preview code leaves it alone. - An edit applied before the UI showed an earlier broken edit's error was erased when that error undid its preview, or was built on that preview. The handler now starts from the diagram before all unhandled previews, and reads the diagram state that updates at once. - A failed or stopped display_diagram left its half drawn diagram on the canvas. Its preview is undone now, like an edit's. - "New chat" cleared a chat that could not be saved (storage full). - The settings dialog showed a model list, a fetch error or a test result on the provider that was opened after the request started, and marked a model id changed during the test as tested. - A tool call with broken JSON was shown as cut off by the output limit. - History entries and session thumbnails could pair with a later diagram when draw.io answered an export late. - A server model saved before non-ASCII provider names got into the id was reset to the default model.
This commit is contained in:
@@ -453,71 +453,81 @@ export function ChatMessageDisplay({
|
||||
if (isRestoredMessage) return
|
||||
|
||||
if (
|
||||
part.type === "tool-display_diagram" &&
|
||||
input?.xml
|
||||
part.type !== "tool-display_diagram" &&
|
||||
part.type !== "tool-edit_diagram"
|
||||
) {
|
||||
const xml = input.xml as string
|
||||
return
|
||||
}
|
||||
|
||||
// Failed or stopped: if the original XML is still
|
||||
// stored, the tool handler never ran (invalid JSON,
|
||||
// or the user pressed stop), so undo the streamed
|
||||
// preview here. Invalid JSON leaves no input, so
|
||||
// check this first.
|
||||
if (state === "output-error") {
|
||||
const originalXml =
|
||||
editDiagramOriginalXmlRef.current.get(
|
||||
toolCallId,
|
||||
)
|
||||
if (originalXml) {
|
||||
editDiagramOriginalXmlRef.current.delete(
|
||||
toolCallId,
|
||||
)
|
||||
onDisplayChart(originalXml, true)
|
||||
baseXml = originalXml
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Input complete, or the tool handler, a stop or an
|
||||
// error took the call already: the tool handler loads
|
||||
// the checked diagram (with the original XML). The
|
||||
// messages update at most every 150 ms (useChat
|
||||
// throttle in chat-panel), so they can still show the
|
||||
// call streaming after that.
|
||||
if (
|
||||
state !== "input-streaming" ||
|
||||
processedToolCalls.current.has(toolCallId)
|
||||
) {
|
||||
processedToolCalls.current.add(toolCallId)
|
||||
lastProcessedXmlRef.current.delete(toolCallId)
|
||||
lastProcessedXmlRef.current.delete(
|
||||
`${toolCallId}-opCount`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (part.type === "tool-display_diagram") {
|
||||
const xml = input?.xml as string | undefined
|
||||
// Skip if XML hasn't changed since last processing
|
||||
const lastXml =
|
||||
lastProcessedXmlRef.current.get(toolCallId)
|
||||
if (lastXml === xml) {
|
||||
return // Skip redundant processing
|
||||
}
|
||||
|
||||
// Messages update at most every 150 ms while
|
||||
// streaming (useChat throttle in chat-panel)
|
||||
if (state === "input-streaming") {
|
||||
handleDisplayChart(xml)
|
||||
lastProcessedXmlRef.current.set(toolCallId, xml)
|
||||
} else if (
|
||||
!processedToolCalls.current.has(toolCallId)
|
||||
if (
|
||||
!xml ||
|
||||
lastProcessedXmlRef.current.get(toolCallId) ===
|
||||
xml
|
||||
) {
|
||||
// Input complete: the tool handler loads the
|
||||
// validated diagram
|
||||
processedToolCalls.current.add(toolCallId)
|
||||
lastProcessedXmlRef.current.delete(toolCallId)
|
||||
return
|
||||
}
|
||||
// Keep the diagram from before the preview, to
|
||||
// undo it on a stop or an error
|
||||
if (
|
||||
!editDiagramOriginalXmlRef.current.has(
|
||||
toolCallId,
|
||||
)
|
||||
) {
|
||||
editDiagramOriginalXmlRef.current.set(
|
||||
toolCallId,
|
||||
baseXml || BLANK_MXFILE,
|
||||
)
|
||||
}
|
||||
handleDisplayChart(xml)
|
||||
lastProcessedXmlRef.current.set(toolCallId, xml)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle edit_diagram streaming - apply operations incrementally for preview
|
||||
// Uses shared editDiagramOriginalXmlRef to coordinate with tool handler
|
||||
if (part.type === "tool-edit_diagram") {
|
||||
// Failed or stopped: if the original XML is still
|
||||
// stored, the tool handler never ran (invalid
|
||||
// JSON, or the user pressed stop), so undo the
|
||||
// streamed preview here. Invalid JSON leaves no
|
||||
// operations in the input, so check this first.
|
||||
if (state === "output-error") {
|
||||
const originalXml =
|
||||
editDiagramOriginalXmlRef.current.get(
|
||||
toolCallId,
|
||||
)
|
||||
if (originalXml) {
|
||||
editDiagramOriginalXmlRef.current.delete(
|
||||
toolCallId,
|
||||
)
|
||||
onDisplayChart(originalXml, true)
|
||||
baseXml = originalXml
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!input?.operations) return
|
||||
|
||||
if (state !== "input-streaming") {
|
||||
// Input complete: the tool handler applies the
|
||||
// checked edit (it reads the original XML too)
|
||||
if (
|
||||
!processedToolCalls.current.has(toolCallId)
|
||||
) {
|
||||
lastProcessedXmlRef.current.delete(
|
||||
toolCallId + "-opCount",
|
||||
)
|
||||
processedToolCalls.current.add(toolCallId)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const completeOps = getCompleteOperations(
|
||||
input.operations as DiagramOperation[],
|
||||
)
|
||||
|
||||
@@ -112,6 +112,7 @@ export default function ChatPanel({
|
||||
handleExportWithoutHistory,
|
||||
resolverRef,
|
||||
chartXML,
|
||||
chartXMLRef: liveChartXMLRef,
|
||||
latestSvg,
|
||||
clearDiagram,
|
||||
getThumbnailSvg,
|
||||
@@ -280,8 +281,9 @@ export default function ChatPanel({
|
||||
// Persist processed tool call IDs so collapsing the chat doesn't replay old tool outputs
|
||||
const processedToolCallsRef = useRef<Set<string>>(new Set())
|
||||
|
||||
// Store original XML for edit_diagram streaming - shared between streaming preview and tool handler
|
||||
// Key: toolCallId, Value: original XML before any operations applied
|
||||
// Store original XML for display_diagram and edit_diagram streaming -
|
||||
// shared between streaming preview and tool handler
|
||||
// Key: toolCallId, Value: XML before the call's preview was drawn
|
||||
const editDiagramOriginalXmlRef = useRef<Map<string, string>>(new Map())
|
||||
|
||||
// Debounce timeout for localStorage writes (prevents blocking during streaming)
|
||||
@@ -336,8 +338,10 @@ export default function ChatPanel({
|
||||
const { handleToolCall } = useDiagramToolHandlers({
|
||||
partialXmlRef,
|
||||
editDiagramOriginalXmlRef,
|
||||
processedToolCallsRef,
|
||||
validationRetryCountRef,
|
||||
chartXMLRef,
|
||||
// A preview undone just before the tool call is in this one already
|
||||
chartXMLRef: liveChartXMLRef,
|
||||
onDisplayChart,
|
||||
onFetchChart,
|
||||
onExport,
|
||||
@@ -363,10 +367,22 @@ export default function ChatPanel({
|
||||
onToolCall: async ({ toolCall }) => {
|
||||
await handleToolCall({ toolCall }, addToolOutput)
|
||||
},
|
||||
onFinish: ({ message, isAbort, isError }) => {
|
||||
// Stopped or failed: tool calls still streaming never reach the
|
||||
// tool handler. Mark them handled so a later render of the
|
||||
// stream does not draw their preview again.
|
||||
if (!isAbort && !isError) return
|
||||
for (const part of message.parts as any[]) {
|
||||
if (part.state === "input-streaming" && part.toolCallId) {
|
||||
processedToolCallsRef.current.add(part.toolCallId)
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
// An edit still streaming when the request failed never reaches
|
||||
// the tool handler: undo its preview. The first stored original
|
||||
// is the diagram before any of them.
|
||||
// A diagram still streaming when the request failed never
|
||||
// reaches the tool handler: undo its preview. Only previews not
|
||||
// handled yet are stored, and the first one holds the diagram
|
||||
// before any of them.
|
||||
const [originalXml] = editDiagramOriginalXmlRef.current.values()
|
||||
if (originalXml) onDisplayChart(originalXml, true)
|
||||
editDiagramOriginalXmlRef.current.clear()
|
||||
@@ -948,7 +964,8 @@ export default function ChatPanel({
|
||||
// Save current session before creating new one
|
||||
if (sessionManager.isAvailable && messages.length > 0) {
|
||||
const sessionData = await buildSessionData({ withThumbnail: true })
|
||||
await sessionManager.saveCurrentSession(sessionData)
|
||||
// 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()
|
||||
}
|
||||
|
||||
@@ -93,12 +93,14 @@ export function ToolCallCard({
|
||||
}
|
||||
|
||||
// Incomplete XML means the output hit the length limit, unless the user
|
||||
// stopped the generation themselves
|
||||
// stopped the generation themselves. Without an input the JSON was
|
||||
// broken (the server repairs JSON cut short by the limit).
|
||||
const isTruncated =
|
||||
state === "output-error" &&
|
||||
errorText !== "Stopped by user" &&
|
||||
(toolName === "display_diagram" || toolName === "append_diagram") &&
|
||||
!isMxCellXmlComplete(input?.xml)
|
||||
typeof input?.xml === "string" &&
|
||||
!isMxCellXmlComplete(input.xml)
|
||||
|
||||
const handleCopy = () => {
|
||||
let textToCopy = ""
|
||||
|
||||
@@ -186,6 +186,12 @@ export function ModelConfigDialog({
|
||||
const selectedProvider = config.providers.find(
|
||||
(p) => p.id === selectedProviderId,
|
||||
)
|
||||
// For requests that finish after the user switched provider or edited
|
||||
// a model id
|
||||
const selectedProviderIdRef = useRef(selectedProviderId)
|
||||
selectedProviderIdRef.current = selectedProviderId
|
||||
const configRef = useRef(config)
|
||||
configRef.current = config
|
||||
|
||||
// Discard an unfinished model ID edit when the dialog closes
|
||||
useEffect(() => {
|
||||
@@ -231,13 +237,15 @@ export function ModelConfigDialog({
|
||||
},
|
||||
)
|
||||
const data = await response.json().catch(() => ({}))
|
||||
// The picker and the error belong to the provider shown
|
||||
const stillShown = selectedProviderIdRef.current === providerId
|
||||
if (Array.isArray(data.models)) {
|
||||
setFetchedModels((current) => ({
|
||||
...current,
|
||||
[providerId]: data.models,
|
||||
}))
|
||||
setModelPickerOpen(true)
|
||||
} else {
|
||||
if (stillShown) setModelPickerOpen(true)
|
||||
} else if (stillShown) {
|
||||
const hints = dict.errors.llm as Record<string, string>
|
||||
setFetchModelsError(
|
||||
[hints[data.code], data.error].filter(Boolean).join(" ") ||
|
||||
@@ -245,7 +253,9 @@ export function ModelConfigDialog({
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
setFetchModelsError(dict.errors.networkError)
|
||||
if (selectedProviderIdRef.current === providerId) {
|
||||
setFetchModelsError(dict.errors.networkError)
|
||||
}
|
||||
} finally {
|
||||
setFetchingModels(false)
|
||||
}
|
||||
@@ -387,6 +397,7 @@ export function ModelConfigDialog({
|
||||
|
||||
let allValid = true
|
||||
let errorCount = 0
|
||||
let idChanged = false
|
||||
const credentialsVersion = credentialsVersionRef.current
|
||||
|
||||
// For EdgeOne, construct baseUrl from current origin
|
||||
@@ -464,6 +475,19 @@ export function ModelConfigDialog({
|
||||
}
|
||||
// Credentials changed during the test: drop the result
|
||||
if (credentialsVersionRef.current !== credentialsVersion) return
|
||||
// So did this model's id: the result is for the old one
|
||||
const current = configRef.current.providers
|
||||
.find((p) => p.id === selectedProviderId)
|
||||
?.models.find((m) => m.id === model.id)
|
||||
if (current?.modelId !== model.modelId) {
|
||||
idChanged = true
|
||||
setValidatingModelIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(model.id)
|
||||
return next
|
||||
})
|
||||
return
|
||||
}
|
||||
if (update.validated === false) {
|
||||
allValid = false
|
||||
errorCount++
|
||||
@@ -478,9 +502,16 @@ export function ModelConfigDialog({
|
||||
)
|
||||
if (credentialsVersionRef.current !== credentialsVersion) return
|
||||
|
||||
if (allValid) {
|
||||
setValidationStatus("success")
|
||||
// A model whose id changed was not tested
|
||||
if (allValid && !idChanged) {
|
||||
updateProvider(selectedProviderId, { validated: true })
|
||||
}
|
||||
// The status line is about the provider shown now
|
||||
if (selectedProviderIdRef.current !== selectedProviderId) return
|
||||
if (idChanged) {
|
||||
setValidationStatus("idle")
|
||||
} else if (allValid) {
|
||||
setValidationStatus("success")
|
||||
// Reset to idle after showing success briefly (with cleanup)
|
||||
if (validationResetTimeoutRef.current) {
|
||||
clearTimeout(validationResetTimeoutRef.current)
|
||||
|
||||
Reference in New Issue
Block a user