mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-01-03 23:02:31 +08:00
Compare commits
1 Commits
63398d9f34
...
refactor/e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4dbda5ba8e |
@@ -27,6 +27,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { useDiagram } from "@/contexts/diagram-context"
|
||||
import { useDiagramToolHandlers } from "@/hooks/use-diagram-tool-handlers"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { getSelectedAIConfig, useModelConfig } from "@/hooks/use-model-config"
|
||||
import { getApiEndpoint } from "@/lib/base-path"
|
||||
@@ -34,7 +35,7 @@ import { findCachedResponse } from "@/lib/cached-responses"
|
||||
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
||||
import { type FileData, useFileProcessor } from "@/lib/use-file-processor"
|
||||
import { useQuotaManager } from "@/lib/use-quota-manager"
|
||||
import { formatXML, isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils"
|
||||
import { formatXML } from "@/lib/utils"
|
||||
import { ChatMessageDisplay } from "./chat-message-display"
|
||||
import { DevXmlSimulator } from "./dev-xml-simulator"
|
||||
|
||||
@@ -214,9 +215,6 @@ export default function ChatPanel({
|
||||
chartXMLRef.current = chartXML
|
||||
}, [chartXML])
|
||||
|
||||
// Ref to hold stop function for use in onToolCall (avoids stale closure)
|
||||
const stopRef = useRef<(() => void) | null>(null)
|
||||
|
||||
// Ref to track consecutive auto-retry count (reset on user action)
|
||||
const autoRetryCountRef = useRef(0)
|
||||
// Ref to track continuation retry count (for truncation handling)
|
||||
@@ -239,6 +237,16 @@ export default function ChatPanel({
|
||||
> | null>(null)
|
||||
const LOCAL_STORAGE_DEBOUNCE_MS = 1000 // Save at most once per second
|
||||
|
||||
// Diagram tool handlers (display_diagram, edit_diagram, append_diagram)
|
||||
const { handleToolCall } = useDiagramToolHandlers({
|
||||
partialXmlRef,
|
||||
editDiagramOriginalXmlRef,
|
||||
chartXMLRef,
|
||||
onDisplayChart,
|
||||
onFetchChart,
|
||||
onExport,
|
||||
})
|
||||
|
||||
const {
|
||||
messages,
|
||||
sendMessage,
|
||||
@@ -251,311 +259,8 @@ export default function ChatPanel({
|
||||
transport: new DefaultChatTransport({
|
||||
api: getApiEndpoint("/api/chat"),
|
||||
}),
|
||||
async onToolCall({ toolCall }) {
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
`[onToolCall] Tool: ${toolCall.toolName}, CallId: ${toolCall.toolCallId}`,
|
||||
)
|
||||
}
|
||||
|
||||
if (toolCall.toolName === "display_diagram") {
|
||||
const { xml } = toolCall.input as { xml: string }
|
||||
|
||||
// DEBUG: Log raw input to diagnose false truncation detection
|
||||
console.log(
|
||||
"[display_diagram] XML ending (last 100 chars):",
|
||||
xml.slice(-100),
|
||||
)
|
||||
console.log("[display_diagram] XML length:", xml.length)
|
||||
|
||||
// Check if XML is truncated (incomplete mxCell indicates truncated output)
|
||||
const isTruncated = !isMxCellXmlComplete(xml)
|
||||
console.log("[display_diagram] isTruncated:", isTruncated)
|
||||
|
||||
if (isTruncated) {
|
||||
// Store the partial XML for continuation via append_diagram
|
||||
partialXmlRef.current = xml
|
||||
|
||||
// Tell LLM to use append_diagram to continue
|
||||
const partialEnding = partialXmlRef.current.slice(-500)
|
||||
addToolOutput({
|
||||
tool: "display_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `Output was truncated due to length limits. Use the append_diagram tool to continue.
|
||||
|
||||
Your output ended with:
|
||||
\`\`\`
|
||||
${partialEnding}
|
||||
\`\`\`
|
||||
|
||||
NEXT STEP: Call append_diagram with the continuation XML.
|
||||
- Do NOT include wrapper tags or root cells (id="0", id="1")
|
||||
- Start from EXACTLY where you stopped
|
||||
- Complete all remaining mxCell elements`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Complete XML received - use it directly
|
||||
// (continuation is now handled via append_diagram tool)
|
||||
const finalXml = xml
|
||||
partialXmlRef.current = "" // Reset any partial from previous truncation
|
||||
|
||||
// Wrap raw XML with full mxfile structure for draw.io
|
||||
const fullXml = wrapWithMxFile(finalXml)
|
||||
|
||||
// loadDiagram validates and returns error if invalid
|
||||
const validationError = onDisplayChart(fullXml)
|
||||
|
||||
if (validationError) {
|
||||
console.warn(
|
||||
"[display_diagram] Validation error:",
|
||||
validationError,
|
||||
)
|
||||
// Return error to model - sendAutomaticallyWhen will trigger retry
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
"[display_diagram] Adding tool output with state: output-error",
|
||||
)
|
||||
}
|
||||
addToolOutput({
|
||||
tool: "display_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `${validationError}
|
||||
|
||||
Please fix the XML issues and call display_diagram again with corrected XML.
|
||||
|
||||
Your failed XML:
|
||||
\`\`\`xml
|
||||
${finalXml}
|
||||
\`\`\``,
|
||||
})
|
||||
} else {
|
||||
// Success - diagram will be rendered by chat-message-display
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
"[display_diagram] Success! Adding tool output with state: output-available",
|
||||
)
|
||||
}
|
||||
addToolOutput({
|
||||
tool: "display_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
output: "Successfully displayed the diagram.",
|
||||
})
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
"[display_diagram] Tool output added. Diagram should be visible now.",
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (toolCall.toolName === "edit_diagram") {
|
||||
const { operations } = toolCall.input as {
|
||||
operations: Array<{
|
||||
type: "update" | "add" | "delete"
|
||||
cell_id: string
|
||||
new_xml?: string
|
||||
}>
|
||||
}
|
||||
|
||||
let currentXml = ""
|
||||
try {
|
||||
// Use the original XML captured during streaming (shared with chat-message-display)
|
||||
// This ensures we apply operations to the same base XML that streaming used
|
||||
const originalXml = editDiagramOriginalXmlRef.current.get(
|
||||
toolCall.toolCallId,
|
||||
)
|
||||
if (originalXml) {
|
||||
currentXml = originalXml
|
||||
} else {
|
||||
// Fallback: use chartXML from ref if streaming didn't capture original
|
||||
const cachedXML = chartXMLRef.current
|
||||
if (cachedXML) {
|
||||
currentXml = cachedXML
|
||||
} else {
|
||||
// Last resort: export from iframe
|
||||
currentXml = await onFetchChart(false)
|
||||
}
|
||||
}
|
||||
|
||||
const { applyDiagramOperations } = await import(
|
||||
"@/lib/utils"
|
||||
)
|
||||
const { result: editedXml, errors } =
|
||||
applyDiagramOperations(currentXml, operations)
|
||||
|
||||
// Check for operation errors
|
||||
if (errors.length > 0) {
|
||||
const errorMessages = errors
|
||||
.map(
|
||||
(e) =>
|
||||
`- ${e.type} on cell_id="${e.cellId}": ${e.message}`,
|
||||
)
|
||||
.join("\n")
|
||||
|
||||
addToolOutput({
|
||||
tool: "edit_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `Some operations failed:\n${errorMessages}
|
||||
|
||||
Current diagram XML:
|
||||
\`\`\`xml
|
||||
${currentXml}
|
||||
\`\`\`
|
||||
|
||||
Please check the cell IDs and retry.`,
|
||||
})
|
||||
// Clean up the shared original XML ref
|
||||
editDiagramOriginalXmlRef.current.delete(
|
||||
toolCall.toolCallId,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// loadDiagram validates and returns error if invalid
|
||||
const validationError = onDisplayChart(editedXml)
|
||||
if (validationError) {
|
||||
console.warn(
|
||||
"[edit_diagram] Validation error:",
|
||||
validationError,
|
||||
)
|
||||
addToolOutput({
|
||||
tool: "edit_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `Edit produced invalid XML: ${validationError}
|
||||
|
||||
Current diagram XML:
|
||||
\`\`\`xml
|
||||
${currentXml}
|
||||
\`\`\`
|
||||
|
||||
Please fix the operations to avoid structural issues.`,
|
||||
})
|
||||
// Clean up the shared original XML ref
|
||||
editDiagramOriginalXmlRef.current.delete(
|
||||
toolCall.toolCallId,
|
||||
)
|
||||
return
|
||||
}
|
||||
onExport()
|
||||
addToolOutput({
|
||||
tool: "edit_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
output: `Successfully applied ${operations.length} operation(s) to the diagram.`,
|
||||
})
|
||||
// Clean up the shared original XML ref
|
||||
editDiagramOriginalXmlRef.current.delete(
|
||||
toolCall.toolCallId,
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("[edit_diagram] Failed:", error)
|
||||
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error)
|
||||
|
||||
addToolOutput({
|
||||
tool: "edit_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `Edit failed: ${errorMessage}
|
||||
|
||||
Current diagram XML:
|
||||
\`\`\`xml
|
||||
${currentXml || "No XML available"}
|
||||
\`\`\`
|
||||
|
||||
Please check cell IDs and retry, or use display_diagram to regenerate.`,
|
||||
})
|
||||
// Clean up the shared original XML ref even on error
|
||||
editDiagramOriginalXmlRef.current.delete(
|
||||
toolCall.toolCallId,
|
||||
)
|
||||
}
|
||||
} else if (toolCall.toolName === "append_diagram") {
|
||||
const { xml } = toolCall.input as { xml: string }
|
||||
|
||||
// Detect if LLM incorrectly started fresh instead of continuing
|
||||
// LLM should only output bare mxCells now, so wrapper tags indicate error
|
||||
const trimmed = xml.trim()
|
||||
const isFreshStart =
|
||||
trimmed.startsWith("<mxGraphModel") ||
|
||||
trimmed.startsWith("<root") ||
|
||||
trimmed.startsWith("<mxfile") ||
|
||||
trimmed.startsWith('<mxCell id="0"') ||
|
||||
trimmed.startsWith('<mxCell id="1"')
|
||||
|
||||
if (isFreshStart) {
|
||||
addToolOutput({
|
||||
tool: "append_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `ERROR: You started fresh with wrapper tags. Do NOT include wrapper tags or root cells (id="0", id="1").
|
||||
|
||||
Continue from EXACTLY where the partial ended:
|
||||
\`\`\`
|
||||
${partialXmlRef.current.slice(-500)}
|
||||
\`\`\`
|
||||
|
||||
Start your continuation with the NEXT character after where it stopped.`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Append to accumulated XML
|
||||
partialXmlRef.current += xml
|
||||
|
||||
// Check if XML is now complete (last mxCell is complete)
|
||||
const isComplete = isMxCellXmlComplete(partialXmlRef.current)
|
||||
|
||||
if (isComplete) {
|
||||
// Wrap and display the complete diagram
|
||||
const finalXml = partialXmlRef.current
|
||||
partialXmlRef.current = "" // Reset
|
||||
|
||||
const fullXml = wrapWithMxFile(finalXml)
|
||||
const validationError = onDisplayChart(fullXml)
|
||||
|
||||
if (validationError) {
|
||||
addToolOutput({
|
||||
tool: "append_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `Validation error after assembly: ${validationError}
|
||||
|
||||
Assembled XML:
|
||||
\`\`\`xml
|
||||
${finalXml.substring(0, 2000)}...
|
||||
\`\`\`
|
||||
|
||||
Please use display_diagram with corrected XML.`,
|
||||
})
|
||||
} else {
|
||||
addToolOutput({
|
||||
tool: "append_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
output: "Diagram assembly complete and displayed successfully.",
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Still incomplete - signal to continue
|
||||
addToolOutput({
|
||||
tool: "append_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `XML still incomplete (mxCell not closed). Call append_diagram again to continue.
|
||||
|
||||
Current ending:
|
||||
\`\`\`
|
||||
${partialXmlRef.current.slice(-500)}
|
||||
\`\`\`
|
||||
|
||||
Continue from EXACTLY where you stopped.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
onToolCall: async ({ toolCall }) => {
|
||||
await handleToolCall({ toolCall }, addToolOutput)
|
||||
},
|
||||
onError: (error) => {
|
||||
// Handle server-side quota limit (429 response)
|
||||
@@ -719,9 +424,6 @@ Continue from EXACTLY where you stopped.`,
|
||||
},
|
||||
})
|
||||
|
||||
// Update stopRef so onToolCall can access it
|
||||
stopRef.current = stop
|
||||
|
||||
// Ref to track latest messages for unload persistence
|
||||
const messagesRef = useRef(messages)
|
||||
useEffect(() => {
|
||||
|
||||
383
hooks/use-diagram-tool-handlers.ts
Normal file
383
hooks/use-diagram-tool-handlers.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
import type { MutableRefObject } from "react"
|
||||
import { isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils"
|
||||
|
||||
const DEBUG = process.env.NODE_ENV === "development"
|
||||
|
||||
interface ToolCall {
|
||||
toolCallId: string
|
||||
toolName: string
|
||||
input: unknown
|
||||
}
|
||||
|
||||
type AddToolOutputSuccess = {
|
||||
tool: string
|
||||
toolCallId: string
|
||||
state?: "output-available"
|
||||
output: string
|
||||
errorText?: undefined
|
||||
}
|
||||
|
||||
type AddToolOutputError = {
|
||||
tool: string
|
||||
toolCallId: string
|
||||
state: "output-error"
|
||||
output?: undefined
|
||||
errorText: string
|
||||
}
|
||||
|
||||
type AddToolOutputParams = AddToolOutputSuccess | AddToolOutputError
|
||||
|
||||
type AddToolOutputFn = (params: AddToolOutputParams) => void
|
||||
|
||||
interface DiagramOperation {
|
||||
type: "update" | "add" | "delete"
|
||||
cell_id: string
|
||||
new_xml?: string
|
||||
}
|
||||
|
||||
interface UseDiagramToolHandlersParams {
|
||||
partialXmlRef: MutableRefObject<string>
|
||||
editDiagramOriginalXmlRef: MutableRefObject<Map<string, string>>
|
||||
chartXMLRef: MutableRefObject<string>
|
||||
onDisplayChart: (xml: string, skipValidation?: boolean) => string | null
|
||||
onFetchChart: (saveToHistory?: boolean) => Promise<string>
|
||||
onExport: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that creates the onToolCall handler for diagram-related tools.
|
||||
* Handles display_diagram, edit_diagram, and append_diagram tools.
|
||||
*
|
||||
* Note: addToolOutput is passed at call time (not hook init) because
|
||||
* it comes from useChat which creates a circular dependency.
|
||||
*/
|
||||
export function useDiagramToolHandlers({
|
||||
partialXmlRef,
|
||||
editDiagramOriginalXmlRef,
|
||||
chartXMLRef,
|
||||
onDisplayChart,
|
||||
onFetchChart,
|
||||
onExport,
|
||||
}: UseDiagramToolHandlersParams) {
|
||||
const handleToolCall = async (
|
||||
{ toolCall }: { toolCall: ToolCall },
|
||||
addToolOutput: AddToolOutputFn,
|
||||
) => {
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
`[onToolCall] Tool: ${toolCall.toolName}, CallId: ${toolCall.toolCallId}`,
|
||||
)
|
||||
}
|
||||
|
||||
if (toolCall.toolName === "display_diagram") {
|
||||
await handleDisplayDiagram(toolCall, addToolOutput)
|
||||
} else if (toolCall.toolName === "edit_diagram") {
|
||||
await handleEditDiagram(toolCall, addToolOutput)
|
||||
} else if (toolCall.toolName === "append_diagram") {
|
||||
handleAppendDiagram(toolCall, addToolOutput)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDisplayDiagram = async (
|
||||
toolCall: ToolCall,
|
||||
addToolOutput: AddToolOutputFn,
|
||||
) => {
|
||||
const { xml } = toolCall.input as { xml: string }
|
||||
|
||||
// DEBUG: Log raw input to diagnose false truncation detection
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
"[display_diagram] XML ending (last 100 chars):",
|
||||
xml.slice(-100),
|
||||
)
|
||||
console.log("[display_diagram] XML length:", xml.length)
|
||||
}
|
||||
|
||||
// Check if XML is truncated (incomplete mxCell indicates truncated output)
|
||||
const isTruncated = !isMxCellXmlComplete(xml)
|
||||
if (DEBUG) {
|
||||
console.log("[display_diagram] isTruncated:", isTruncated)
|
||||
}
|
||||
|
||||
if (isTruncated) {
|
||||
// Store the partial XML for continuation via append_diagram
|
||||
partialXmlRef.current = xml
|
||||
|
||||
// Tell LLM to use append_diagram to continue
|
||||
const partialEnding = partialXmlRef.current.slice(-500)
|
||||
addToolOutput({
|
||||
tool: "display_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `Output was truncated due to length limits. Use the append_diagram tool to continue.
|
||||
|
||||
Your output ended with:
|
||||
\`\`\`
|
||||
${partialEnding}
|
||||
\`\`\`
|
||||
|
||||
NEXT STEP: Call append_diagram with the continuation XML.
|
||||
- Do NOT include wrapper tags or root cells (id="0", id="1")
|
||||
- Start from EXACTLY where you stopped
|
||||
- Complete all remaining mxCell elements`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Complete XML received - use it directly
|
||||
// (continuation is now handled via append_diagram tool)
|
||||
const finalXml = xml
|
||||
partialXmlRef.current = "" // Reset any partial from previous truncation
|
||||
|
||||
// Wrap raw XML with full mxfile structure for draw.io
|
||||
const fullXml = wrapWithMxFile(finalXml)
|
||||
|
||||
// loadDiagram validates and returns error if invalid
|
||||
const validationError = onDisplayChart(fullXml)
|
||||
|
||||
if (validationError) {
|
||||
console.warn("[display_diagram] Validation error:", validationError)
|
||||
// Return error to model - sendAutomaticallyWhen will trigger retry
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
"[display_diagram] Adding tool output with state: output-error",
|
||||
)
|
||||
}
|
||||
addToolOutput({
|
||||
tool: "display_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `${validationError}
|
||||
|
||||
Please fix the XML issues and call display_diagram again with corrected XML.
|
||||
|
||||
Your failed XML:
|
||||
\`\`\`xml
|
||||
${finalXml}
|
||||
\`\`\``,
|
||||
})
|
||||
} else {
|
||||
// Success - diagram will be rendered by chat-message-display
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
"[display_diagram] Success! Adding tool output with state: output-available",
|
||||
)
|
||||
}
|
||||
addToolOutput({
|
||||
tool: "display_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
output: "Successfully displayed the diagram.",
|
||||
})
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
"[display_diagram] Tool output added. Diagram should be visible now.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditDiagram = async (
|
||||
toolCall: ToolCall,
|
||||
addToolOutput: AddToolOutputFn,
|
||||
) => {
|
||||
const { operations } = toolCall.input as {
|
||||
operations: DiagramOperation[]
|
||||
}
|
||||
|
||||
let currentXml = ""
|
||||
try {
|
||||
// Use the original XML captured during streaming (shared with chat-message-display)
|
||||
// This ensures we apply operations to the same base XML that streaming used
|
||||
const originalXml = editDiagramOriginalXmlRef.current.get(
|
||||
toolCall.toolCallId,
|
||||
)
|
||||
if (originalXml) {
|
||||
currentXml = originalXml
|
||||
} else {
|
||||
// Fallback: use chartXML from ref if streaming didn't capture original
|
||||
const cachedXML = chartXMLRef.current
|
||||
if (cachedXML) {
|
||||
currentXml = cachedXML
|
||||
} else {
|
||||
// Last resort: export from iframe
|
||||
currentXml = await onFetchChart(false)
|
||||
}
|
||||
}
|
||||
|
||||
const { applyDiagramOperations } = await import("@/lib/utils")
|
||||
const { result: editedXml, errors } = applyDiagramOperations(
|
||||
currentXml,
|
||||
operations,
|
||||
)
|
||||
|
||||
// Check for operation errors
|
||||
if (errors.length > 0) {
|
||||
const errorMessages = errors
|
||||
.map(
|
||||
(e) =>
|
||||
`- ${e.type} on cell_id="${e.cellId}": ${e.message}`,
|
||||
)
|
||||
.join("\n")
|
||||
|
||||
addToolOutput({
|
||||
tool: "edit_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `Some operations failed:\n${errorMessages}
|
||||
|
||||
Current diagram XML:
|
||||
\`\`\`xml
|
||||
${currentXml}
|
||||
\`\`\`
|
||||
|
||||
Please check the cell IDs and retry.`,
|
||||
})
|
||||
// Clean up the shared original XML ref
|
||||
editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId)
|
||||
return
|
||||
}
|
||||
|
||||
// loadDiagram validates and returns error if invalid
|
||||
const validationError = onDisplayChart(editedXml)
|
||||
if (validationError) {
|
||||
console.warn(
|
||||
"[edit_diagram] Validation error:",
|
||||
validationError,
|
||||
)
|
||||
addToolOutput({
|
||||
tool: "edit_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `Edit produced invalid XML: ${validationError}
|
||||
|
||||
Current diagram XML:
|
||||
\`\`\`xml
|
||||
${currentXml}
|
||||
\`\`\`
|
||||
|
||||
Please fix the operations to avoid structural issues.`,
|
||||
})
|
||||
// Clean up the shared original XML ref
|
||||
editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId)
|
||||
return
|
||||
}
|
||||
onExport()
|
||||
addToolOutput({
|
||||
tool: "edit_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
output: `Successfully applied ${operations.length} operation(s) to the diagram.`,
|
||||
})
|
||||
// Clean up the shared original XML ref
|
||||
editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId)
|
||||
} catch (error) {
|
||||
console.error("[edit_diagram] Failed:", error)
|
||||
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error)
|
||||
|
||||
addToolOutput({
|
||||
tool: "edit_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `Edit failed: ${errorMessage}
|
||||
|
||||
Current diagram XML:
|
||||
\`\`\`xml
|
||||
${currentXml || "No XML available"}
|
||||
\`\`\`
|
||||
|
||||
Please check cell IDs and retry, or use display_diagram to regenerate.`,
|
||||
})
|
||||
// Clean up the shared original XML ref even on error
|
||||
editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAppendDiagram = (
|
||||
toolCall: ToolCall,
|
||||
addToolOutput: AddToolOutputFn,
|
||||
) => {
|
||||
const { xml } = toolCall.input as { xml: string }
|
||||
|
||||
// Detect if LLM incorrectly started fresh instead of continuing
|
||||
// LLM should only output bare mxCells now, so wrapper tags indicate error
|
||||
const trimmed = xml.trim()
|
||||
const isFreshStart =
|
||||
trimmed.startsWith("<mxGraphModel") ||
|
||||
trimmed.startsWith("<root") ||
|
||||
trimmed.startsWith("<mxfile") ||
|
||||
trimmed.startsWith('<mxCell id="0"') ||
|
||||
trimmed.startsWith('<mxCell id="1"')
|
||||
|
||||
if (isFreshStart) {
|
||||
addToolOutput({
|
||||
tool: "append_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `ERROR: You started fresh with wrapper tags. Do NOT include wrapper tags or root cells (id="0", id="1").
|
||||
|
||||
Continue from EXACTLY where the partial ended:
|
||||
\`\`\`
|
||||
${partialXmlRef.current.slice(-500)}
|
||||
\`\`\`
|
||||
|
||||
Start your continuation with the NEXT character after where it stopped.`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Append to accumulated XML
|
||||
partialXmlRef.current += xml
|
||||
|
||||
// Check if XML is now complete (last mxCell is complete)
|
||||
const isComplete = isMxCellXmlComplete(partialXmlRef.current)
|
||||
|
||||
if (isComplete) {
|
||||
// Wrap and display the complete diagram
|
||||
const finalXml = partialXmlRef.current
|
||||
partialXmlRef.current = "" // Reset
|
||||
|
||||
const fullXml = wrapWithMxFile(finalXml)
|
||||
const validationError = onDisplayChart(fullXml)
|
||||
|
||||
if (validationError) {
|
||||
addToolOutput({
|
||||
tool: "append_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `Validation error after assembly: ${validationError}
|
||||
|
||||
Assembled XML:
|
||||
\`\`\`xml
|
||||
${finalXml.substring(0, 2000)}...
|
||||
\`\`\`
|
||||
|
||||
Please use display_diagram with corrected XML.`,
|
||||
})
|
||||
} else {
|
||||
addToolOutput({
|
||||
tool: "append_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
output: "Diagram assembly complete and displayed successfully.",
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Still incomplete - signal to continue
|
||||
addToolOutput({
|
||||
tool: "append_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `XML still incomplete (mxCell not closed). Call append_diagram again to continue.
|
||||
|
||||
Current ending:
|
||||
\`\`\`
|
||||
${partialXmlRef.current.slice(-500)}
|
||||
\`\`\`
|
||||
|
||||
Continue from EXACTLY where you stopped.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { handleToolCall }
|
||||
}
|
||||
@@ -14,14 +14,22 @@ export function register() {
|
||||
publicKey: process.env.LANGFUSE_PUBLIC_KEY,
|
||||
secretKey: process.env.LANGFUSE_SECRET_KEY,
|
||||
baseUrl: process.env.LANGFUSE_BASEURL,
|
||||
// Whitelist approach: only export AI-related spans
|
||||
// Filter out Next.js HTTP request spans so AI SDK spans become root traces
|
||||
shouldExportSpan: ({ otelSpan }) => {
|
||||
const spanName = otelSpan.name
|
||||
// Only export AI SDK spans (ai.*) and our explicit "chat" wrapper
|
||||
if (spanName === "chat" || spanName.startsWith("ai.")) {
|
||||
return true
|
||||
// Skip Next.js HTTP infrastructure spans
|
||||
if (
|
||||
spanName.startsWith("POST") ||
|
||||
spanName.startsWith("GET") ||
|
||||
spanName.startsWith("RSC") ||
|
||||
spanName.includes("BaseServer") ||
|
||||
spanName.includes("handleRequest") ||
|
||||
spanName.includes("resolve page") ||
|
||||
spanName.includes("start response")
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return false
|
||||
return true
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -9,30 +9,6 @@ import {
|
||||
// OSS users who don't need quota tracking can simply not set this env var
|
||||
const TABLE = process.env.DYNAMODB_QUOTA_TABLE
|
||||
const DYNAMODB_REGION = process.env.DYNAMODB_REGION || "ap-northeast-1"
|
||||
// Timezone for daily quota reset (e.g., "Asia/Tokyo" for JST midnight reset)
|
||||
// Defaults to UTC if not set
|
||||
let QUOTA_TIMEZONE = process.env.QUOTA_TIMEZONE || "UTC"
|
||||
|
||||
// Validate timezone at module load
|
||||
try {
|
||||
new Intl.DateTimeFormat("en-CA", { timeZone: QUOTA_TIMEZONE }).format(
|
||||
new Date(),
|
||||
)
|
||||
} catch {
|
||||
console.warn(
|
||||
`[quota] Invalid QUOTA_TIMEZONE "${QUOTA_TIMEZONE}", using UTC`,
|
||||
)
|
||||
QUOTA_TIMEZONE = "UTC"
|
||||
}
|
||||
|
||||
/**
|
||||
* Get today's date string in the configured timezone (YYYY-MM-DD format)
|
||||
*/
|
||||
function getTodayInTimezone(): string {
|
||||
return new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: QUOTA_TIMEZONE,
|
||||
}).format(new Date())
|
||||
}
|
||||
|
||||
// Only create client if quota is enabled
|
||||
const client = TABLE ? new DynamoDBClient({ region: DYNAMODB_REGION }) : null
|
||||
@@ -73,67 +49,32 @@ export async function checkAndIncrementRequest(
|
||||
return { allowed: true }
|
||||
}
|
||||
|
||||
const today = getTodayInTimezone()
|
||||
const today = new Date().toISOString().split("T")[0]
|
||||
const currentMinute = Math.floor(Date.now() / 60000).toString()
|
||||
const ttl = Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60
|
||||
|
||||
try {
|
||||
// First, try to reset counts if it's a new day (atomic day reset)
|
||||
// This will succeed only if lastResetDate < today or doesn't exist
|
||||
try {
|
||||
await client.send(
|
||||
new UpdateItemCommand({
|
||||
TableName: TABLE,
|
||||
Key: { PK: { S: `IP#${ip}` } },
|
||||
// Reset all counts to 1/0 for the new day
|
||||
UpdateExpression: `
|
||||
SET lastResetDate = :today,
|
||||
dailyReqCount = :one,
|
||||
dailyTokenCount = :zero,
|
||||
lastMinute = :minute,
|
||||
tpmCount = :zero,
|
||||
#ttl = :ttl
|
||||
`,
|
||||
// Only succeed if it's a new day (or new item)
|
||||
ConditionExpression: `
|
||||
attribute_not_exists(lastResetDate) OR lastResetDate < :today
|
||||
`,
|
||||
ExpressionAttributeNames: { "#ttl": "ttl" },
|
||||
ExpressionAttributeValues: {
|
||||
":today": { S: today },
|
||||
":zero": { N: "0" },
|
||||
":one": { N: "1" },
|
||||
":minute": { S: currentMinute },
|
||||
":ttl": { N: String(ttl) },
|
||||
},
|
||||
}),
|
||||
)
|
||||
// New day reset successful
|
||||
return { allowed: true }
|
||||
} catch (resetError: any) {
|
||||
// If condition failed, it's the same day - continue to increment logic
|
||||
if (!(resetError instanceof ConditionalCheckFailedException)) {
|
||||
throw resetError // Re-throw unexpected errors
|
||||
}
|
||||
}
|
||||
|
||||
// Same day - increment request count with limit checks
|
||||
// Atomic check-and-increment with ConditionExpression
|
||||
// This prevents race conditions by failing if limits are exceeded
|
||||
await client.send(
|
||||
new UpdateItemCommand({
|
||||
TableName: TABLE,
|
||||
Key: { PK: { S: `IP#${ip}` } },
|
||||
// Increment request count, handle minute boundary for TPM
|
||||
// Reset counts if new day/minute, then increment request count
|
||||
UpdateExpression: `
|
||||
SET lastMinute = :minute,
|
||||
SET lastResetDate = :today,
|
||||
dailyReqCount = if_not_exists(dailyReqCount, :zero) + :one,
|
||||
dailyTokenCount = if_not_exists(dailyTokenCount, :zero),
|
||||
lastMinute = :minute,
|
||||
tpmCount = if_not_exists(tpmCount, :zero),
|
||||
#ttl = :ttl
|
||||
ADD dailyReqCount :one
|
||||
`,
|
||||
// Check all limits before allowing increment
|
||||
// Atomic condition: only succeed if ALL limits pass
|
||||
// Uses attribute_not_exists for new items, then checks limits for existing items
|
||||
ConditionExpression: `
|
||||
lastResetDate = :today AND
|
||||
(attribute_not_exists(dailyReqCount) OR dailyReqCount < :reqLimit) AND
|
||||
(attribute_not_exists(dailyTokenCount) OR dailyTokenCount < :tokenLimit) AND
|
||||
(attribute_not_exists(lastResetDate) OR lastResetDate < :today OR
|
||||
((attribute_not_exists(dailyReqCount) OR dailyReqCount < :reqLimit) AND
|
||||
(attribute_not_exists(dailyTokenCount) OR dailyTokenCount < :tokenLimit))) AND
|
||||
(attribute_not_exists(lastMinute) OR lastMinute <> :minute OR
|
||||
attribute_not_exists(tpmCount) OR tpmCount < :tpmLimit)
|
||||
`,
|
||||
|
||||
Reference in New Issue
Block a user