mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-01-02 14:22:28 +08:00
* feat: add toggle to show unvalidated models in model selector Add a toggle switch in the model configuration dialog to allow users to display models that haven't been validated. This helps users who work with model providers that have disabled their verification endpoints. Changes: - Add showUnvalidatedModels field to MultiModelConfig type - Add setShowUnvalidatedModels method to useModelConfig hook - Add Switch toggle in model-config-dialog footer - Update model-selector to filter based on showUnvalidatedModels setting - Add warning icon for unvalidated models in the selector - Add i18n translations for en/zh/ja Closes #410 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: wrap AlertTriangle in span for title attribute The AlertTriangle icon from lucide-react doesn't support the title prop directly. Wrapped it in a span element to properly display the tooltip. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1103 lines
42 KiB
TypeScript
1103 lines
42 KiB
TypeScript
"use client"
|
|
|
|
import { useChat } from "@ai-sdk/react"
|
|
import { DefaultChatTransport } from "ai"
|
|
import {
|
|
AlertTriangle,
|
|
MessageSquarePlus,
|
|
PanelRightClose,
|
|
PanelRightOpen,
|
|
Settings,
|
|
} from "lucide-react"
|
|
import Image from "next/image"
|
|
import Link from "next/link"
|
|
import type React from "react"
|
|
import { useCallback, useEffect, useRef, useState } from "react"
|
|
import { flushSync } from "react-dom"
|
|
import { FaGithub } from "react-icons/fa"
|
|
import { Toaster, toast } from "sonner"
|
|
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
|
import { ChatInput } from "@/components/chat-input"
|
|
import { ModelConfigDialog } from "@/components/model-config-dialog"
|
|
import { ResetWarningModal } from "@/components/reset-warning-modal"
|
|
import { SettingsDialog } from "@/components/settings-dialog"
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
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"
|
|
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 } from "@/lib/utils"
|
|
import { ChatMessageDisplay } from "./chat-message-display"
|
|
import { DevXmlSimulator } from "./dev-xml-simulator"
|
|
|
|
// localStorage keys for persistence
|
|
const STORAGE_MESSAGES_KEY = "next-ai-draw-io-messages"
|
|
const STORAGE_XML_SNAPSHOTS_KEY = "next-ai-draw-io-xml-snapshots"
|
|
const STORAGE_SESSION_ID_KEY = "next-ai-draw-io-session-id"
|
|
export const STORAGE_DIAGRAM_XML_KEY = "next-ai-draw-io-diagram-xml"
|
|
|
|
// sessionStorage keys
|
|
const SESSION_STORAGE_INPUT_KEY = "next-ai-draw-io-input"
|
|
|
|
// Type for message parts (tool calls and their states)
|
|
interface MessagePart {
|
|
type: string
|
|
state?: string
|
|
toolName?: string
|
|
input?: { xml?: string; [key: string]: unknown }
|
|
[key: string]: unknown
|
|
}
|
|
|
|
interface ChatMessage {
|
|
role: string
|
|
parts?: MessagePart[]
|
|
[key: string]: unknown
|
|
}
|
|
|
|
interface ChatPanelProps {
|
|
isVisible: boolean
|
|
onToggleVisibility: () => void
|
|
drawioUi: "min" | "sketch"
|
|
onToggleDrawioUi: () => void
|
|
darkMode: boolean
|
|
onToggleDarkMode: () => void
|
|
isMobile?: boolean
|
|
onCloseProtectionChange?: (enabled: boolean) => void
|
|
}
|
|
|
|
// Constants for tool states
|
|
const TOOL_ERROR_STATE = "output-error" as const
|
|
const DEBUG = process.env.NODE_ENV === "development"
|
|
const MAX_AUTO_RETRY_COUNT = 1
|
|
|
|
const MAX_CONTINUATION_RETRY_COUNT = 2 // Limit for truncation continuation retries
|
|
|
|
/**
|
|
* Check if auto-resubmit should happen based on tool errors.
|
|
* Only checks the LAST tool part (most recent tool call), not all tool parts.
|
|
*/
|
|
function hasToolErrors(messages: ChatMessage[]): boolean {
|
|
const lastMessage = messages[messages.length - 1]
|
|
if (!lastMessage || lastMessage.role !== "assistant") {
|
|
return false
|
|
}
|
|
|
|
const toolParts =
|
|
(lastMessage.parts as MessagePart[] | undefined)?.filter((part) =>
|
|
part.type?.startsWith("tool-"),
|
|
) || []
|
|
|
|
if (toolParts.length === 0) {
|
|
return false
|
|
}
|
|
|
|
const lastToolPart = toolParts[toolParts.length - 1]
|
|
return lastToolPart?.state === TOOL_ERROR_STATE
|
|
}
|
|
|
|
export default function ChatPanel({
|
|
isVisible,
|
|
onToggleVisibility,
|
|
drawioUi,
|
|
onToggleDrawioUi,
|
|
darkMode,
|
|
onToggleDarkMode,
|
|
isMobile = false,
|
|
onCloseProtectionChange,
|
|
}: ChatPanelProps) {
|
|
const {
|
|
loadDiagram: onDisplayChart,
|
|
handleExport: onExport,
|
|
handleExportWithoutHistory,
|
|
resolverRef,
|
|
chartXML,
|
|
clearDiagram,
|
|
} = useDiagram()
|
|
|
|
const dict = useDictionary()
|
|
|
|
const onFetchChart = (saveToHistory = true) => {
|
|
return Promise.race([
|
|
new Promise<string>((resolve) => {
|
|
if (resolverRef && "current" in resolverRef) {
|
|
resolverRef.current = resolve
|
|
}
|
|
if (saveToHistory) {
|
|
onExport()
|
|
} else {
|
|
handleExportWithoutHistory()
|
|
}
|
|
}),
|
|
new Promise<string>((_, reject) =>
|
|
setTimeout(
|
|
() =>
|
|
reject(
|
|
new Error(
|
|
"Chart export timed out after 10 seconds",
|
|
),
|
|
),
|
|
10000,
|
|
),
|
|
),
|
|
])
|
|
}
|
|
|
|
// File processing using extracted hook
|
|
const { files, pdfData, handleFileChange, setFiles } = useFileProcessor()
|
|
|
|
const [showHistory, setShowHistory] = useState(false)
|
|
const [showSettingsDialog, setShowSettingsDialog] = useState(false)
|
|
const [showModelConfigDialog, setShowModelConfigDialog] = useState(false)
|
|
|
|
// Model configuration hook
|
|
const modelConfig = useModelConfig()
|
|
const [input, setInput] = useState("")
|
|
const [dailyRequestLimit, setDailyRequestLimit] = useState(0)
|
|
const [dailyTokenLimit, setDailyTokenLimit] = useState(0)
|
|
const [tpmLimit, setTpmLimit] = useState(0)
|
|
const [showNewChatDialog, setShowNewChatDialog] = useState(false)
|
|
const [minimalStyle, setMinimalStyle] = useState(false)
|
|
|
|
// Restore input from sessionStorage on mount (when ChatPanel remounts due to key change)
|
|
useEffect(() => {
|
|
const savedInput = sessionStorage.getItem(SESSION_STORAGE_INPUT_KEY)
|
|
if (savedInput) {
|
|
setInput(savedInput)
|
|
}
|
|
}, [])
|
|
|
|
// Check config on mount
|
|
useEffect(() => {
|
|
fetch(getApiEndpoint("/api/config"))
|
|
.then((res) => res.json())
|
|
.then((data) => {
|
|
setDailyRequestLimit(data.dailyRequestLimit || 0)
|
|
setDailyTokenLimit(data.dailyTokenLimit || 0)
|
|
setTpmLimit(data.tpmLimit || 0)
|
|
})
|
|
.catch(() => {})
|
|
}, [])
|
|
|
|
// Quota management using extracted hook
|
|
const quotaManager = useQuotaManager({
|
|
dailyRequestLimit,
|
|
dailyTokenLimit,
|
|
tpmLimit,
|
|
})
|
|
|
|
// Generate a unique session ID for Langfuse tracing (restore from localStorage if available)
|
|
const [sessionId, setSessionId] = useState(() => {
|
|
if (typeof window !== "undefined") {
|
|
const saved = localStorage.getItem(STORAGE_SESSION_ID_KEY)
|
|
if (saved) return saved
|
|
}
|
|
return `session-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
|
})
|
|
|
|
// Store XML snapshots for each user message (keyed by message index)
|
|
const xmlSnapshotsRef = useRef<Map<number, string>>(new Map())
|
|
|
|
// Flag to track if we've restored from localStorage
|
|
const hasRestoredRef = useRef(false)
|
|
|
|
// Ref to track latest chartXML for use in callbacks (avoids stale closure)
|
|
const chartXMLRef = useRef(chartXML)
|
|
useEffect(() => {
|
|
chartXMLRef.current = chartXML
|
|
}, [chartXML])
|
|
|
|
// Ref to track consecutive auto-retry count (reset on user action)
|
|
const autoRetryCountRef = useRef(0)
|
|
// Ref to track continuation retry count (for truncation handling)
|
|
const continuationRetryCountRef = useRef(0)
|
|
|
|
// Ref to accumulate partial XML when output is truncated due to maxOutputTokens
|
|
// When partialXmlRef.current.length > 0, we're in continuation mode
|
|
const partialXmlRef = useRef<string>("")
|
|
|
|
// 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
|
|
const editDiagramOriginalXmlRef = useRef<Map<string, string>>(new Map())
|
|
|
|
// Debounce timeout for localStorage writes (prevents blocking during streaming)
|
|
const localStorageDebounceRef = useRef<ReturnType<
|
|
typeof setTimeout
|
|
> | 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,
|
|
addToolOutput,
|
|
stop,
|
|
status,
|
|
error,
|
|
setMessages,
|
|
} = useChat({
|
|
transport: new DefaultChatTransport({
|
|
api: getApiEndpoint("/api/chat"),
|
|
}),
|
|
onToolCall: async ({ toolCall }) => {
|
|
await handleToolCall({ toolCall }, addToolOutput)
|
|
},
|
|
onError: (error) => {
|
|
// Handle server-side quota limit (429 response)
|
|
// AI SDK puts the full response body in error.message for non-OK responses
|
|
try {
|
|
const data = JSON.parse(error.message)
|
|
if (data.type === "request") {
|
|
quotaManager.showQuotaLimitToast(data.used, data.limit)
|
|
return
|
|
}
|
|
if (data.type === "token") {
|
|
quotaManager.showTokenLimitToast(data.used, data.limit)
|
|
return
|
|
}
|
|
if (data.type === "tpm") {
|
|
quotaManager.showTPMLimitToast(data.limit)
|
|
return
|
|
}
|
|
} catch {
|
|
// Not JSON, fall through to string matching for backwards compatibility
|
|
}
|
|
|
|
// Fallback to string matching
|
|
if (error.message.includes("Daily request limit")) {
|
|
quotaManager.showQuotaLimitToast()
|
|
return
|
|
}
|
|
if (error.message.includes("Daily token limit")) {
|
|
quotaManager.showTokenLimitToast()
|
|
return
|
|
}
|
|
if (
|
|
error.message.includes("Rate limit exceeded") ||
|
|
error.message.includes("tokens per minute")
|
|
) {
|
|
quotaManager.showTPMLimitToast()
|
|
return
|
|
}
|
|
|
|
// Silence access code error in console since it's handled by UI
|
|
if (!error.message.includes("Invalid or missing access code")) {
|
|
console.error("Chat error:", error)
|
|
// Debug: Log messages structure when error occurs
|
|
console.log("[onError] messages count:", messages.length)
|
|
messages.forEach((msg, idx) => {
|
|
console.log(`[onError] Message ${idx}:`, {
|
|
role: msg.role,
|
|
partsCount: msg.parts?.length,
|
|
})
|
|
if (msg.parts) {
|
|
msg.parts.forEach((part: any, partIdx: number) => {
|
|
console.log(
|
|
`[onError] Part ${partIdx}:`,
|
|
JSON.stringify({
|
|
type: part.type,
|
|
toolName: part.toolName,
|
|
hasInput: !!part.input,
|
|
inputType: typeof part.input,
|
|
inputKeys:
|
|
part.input &&
|
|
typeof part.input === "object"
|
|
? Object.keys(part.input)
|
|
: null,
|
|
}),
|
|
)
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
// Translate technical errors into user-friendly messages
|
|
// The server now handles detailed error messages, so we can display them directly.
|
|
// But we still handle connection/network errors that happen before reaching the server.
|
|
let friendlyMessage = error.message
|
|
|
|
// Simple check for network errors if message is generic
|
|
if (friendlyMessage === "Failed to fetch") {
|
|
friendlyMessage = "Network error. Please check your connection."
|
|
}
|
|
|
|
// Truncated tool input error (model output limit too low)
|
|
if (friendlyMessage.includes("toolUse.input is invalid")) {
|
|
friendlyMessage =
|
|
"Output was truncated before the diagram could be generated. Try a simpler request or increase the maxOutputLength."
|
|
}
|
|
|
|
// Translate image not supported error
|
|
if (friendlyMessage.includes("image content block")) {
|
|
friendlyMessage = "This model doesn't support image input."
|
|
}
|
|
|
|
// Add system message for error so it can be cleared
|
|
setMessages((currentMessages) => {
|
|
const errorMessage = {
|
|
id: `error-${Date.now()}`,
|
|
role: "system" as const,
|
|
content: friendlyMessage,
|
|
parts: [{ type: "text" as const, text: friendlyMessage }],
|
|
}
|
|
return [...currentMessages, errorMessage]
|
|
})
|
|
|
|
if (error.message.includes("Invalid or missing access code")) {
|
|
// Show settings dialog to help user fix it
|
|
setShowSettingsDialog(true)
|
|
}
|
|
},
|
|
onFinish: ({ message }) => {
|
|
// Track actual token usage from server metadata
|
|
const metadata = message?.metadata as
|
|
| Record<string, unknown>
|
|
| undefined
|
|
|
|
// DEBUG: Log finish reason to diagnose truncation
|
|
console.log("[onFinish] finishReason:", metadata?.finishReason)
|
|
},
|
|
sendAutomaticallyWhen: ({ messages }) => {
|
|
const isInContinuationMode = partialXmlRef.current.length > 0
|
|
|
|
const shouldRetry = hasToolErrors(
|
|
messages as unknown as ChatMessage[],
|
|
)
|
|
|
|
if (!shouldRetry) {
|
|
// No error, reset retry count and clear state
|
|
autoRetryCountRef.current = 0
|
|
continuationRetryCountRef.current = 0
|
|
partialXmlRef.current = ""
|
|
return false
|
|
}
|
|
|
|
// Continuation mode: limited retries for truncation handling
|
|
if (isInContinuationMode) {
|
|
if (
|
|
continuationRetryCountRef.current >=
|
|
MAX_CONTINUATION_RETRY_COUNT
|
|
) {
|
|
toast.error(
|
|
`Continuation retry limit reached (${MAX_CONTINUATION_RETRY_COUNT}). The diagram may be too complex.`,
|
|
)
|
|
continuationRetryCountRef.current = 0
|
|
partialXmlRef.current = ""
|
|
return false
|
|
}
|
|
continuationRetryCountRef.current++
|
|
} else {
|
|
// Regular error: check retry count limit
|
|
if (autoRetryCountRef.current >= MAX_AUTO_RETRY_COUNT) {
|
|
toast.error(
|
|
`Auto-retry limit reached (${MAX_AUTO_RETRY_COUNT}). Please try again manually.`,
|
|
)
|
|
autoRetryCountRef.current = 0
|
|
partialXmlRef.current = ""
|
|
return false
|
|
}
|
|
// Increment retry count for actual errors
|
|
autoRetryCountRef.current++
|
|
}
|
|
|
|
return true
|
|
},
|
|
})
|
|
|
|
// Ref to track latest messages for unload persistence
|
|
const messagesRef = useRef(messages)
|
|
useEffect(() => {
|
|
messagesRef.current = messages
|
|
}, [messages])
|
|
|
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
|
|
|
// Restore messages and XML snapshots from localStorage on mount
|
|
useEffect(() => {
|
|
if (hasRestoredRef.current) return
|
|
hasRestoredRef.current = true
|
|
|
|
try {
|
|
// Restore messages
|
|
const savedMessages = localStorage.getItem(STORAGE_MESSAGES_KEY)
|
|
if (savedMessages) {
|
|
const parsed = JSON.parse(savedMessages)
|
|
if (Array.isArray(parsed) && parsed.length > 0) {
|
|
setMessages(parsed)
|
|
}
|
|
}
|
|
|
|
// Restore XML snapshots
|
|
const savedSnapshots = localStorage.getItem(
|
|
STORAGE_XML_SNAPSHOTS_KEY,
|
|
)
|
|
if (savedSnapshots) {
|
|
const parsed = JSON.parse(savedSnapshots)
|
|
xmlSnapshotsRef.current = new Map(parsed)
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to restore from localStorage:", error)
|
|
// On complete failure, clear storage to allow recovery
|
|
localStorage.removeItem(STORAGE_MESSAGES_KEY)
|
|
localStorage.removeItem(STORAGE_XML_SNAPSHOTS_KEY)
|
|
toast.error("Session data was corrupted. Starting fresh.")
|
|
}
|
|
}, [setMessages])
|
|
|
|
// Save messages to localStorage whenever they change (debounced to prevent blocking during streaming)
|
|
useEffect(() => {
|
|
if (!hasRestoredRef.current) return
|
|
|
|
// Clear any pending save
|
|
if (localStorageDebounceRef.current) {
|
|
clearTimeout(localStorageDebounceRef.current)
|
|
}
|
|
|
|
// Debounce: save after 1 second of no changes
|
|
localStorageDebounceRef.current = setTimeout(() => {
|
|
try {
|
|
localStorage.setItem(
|
|
STORAGE_MESSAGES_KEY,
|
|
JSON.stringify(messages),
|
|
)
|
|
} catch (error) {
|
|
console.error("Failed to save messages to localStorage:", error)
|
|
}
|
|
}, LOCAL_STORAGE_DEBOUNCE_MS)
|
|
|
|
// Cleanup on unmount
|
|
return () => {
|
|
if (localStorageDebounceRef.current) {
|
|
clearTimeout(localStorageDebounceRef.current)
|
|
}
|
|
}
|
|
}, [messages])
|
|
|
|
// Save XML snapshots to localStorage whenever they change
|
|
const saveXmlSnapshots = useCallback(() => {
|
|
try {
|
|
const snapshotsArray = Array.from(xmlSnapshotsRef.current.entries())
|
|
localStorage.setItem(
|
|
STORAGE_XML_SNAPSHOTS_KEY,
|
|
JSON.stringify(snapshotsArray),
|
|
)
|
|
} catch (error) {
|
|
console.error(
|
|
"Failed to save XML snapshots to localStorage:",
|
|
error,
|
|
)
|
|
}
|
|
}, [])
|
|
|
|
// Save session ID to localStorage
|
|
useEffect(() => {
|
|
localStorage.setItem(STORAGE_SESSION_ID_KEY, sessionId)
|
|
}, [sessionId])
|
|
|
|
useEffect(() => {
|
|
if (messagesEndRef.current) {
|
|
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
|
|
}
|
|
}, [messages])
|
|
|
|
// Save state right before page unload (refresh/close)
|
|
useEffect(() => {
|
|
const handleBeforeUnload = () => {
|
|
try {
|
|
localStorage.setItem(
|
|
STORAGE_MESSAGES_KEY,
|
|
JSON.stringify(messagesRef.current),
|
|
)
|
|
localStorage.setItem(
|
|
STORAGE_XML_SNAPSHOTS_KEY,
|
|
JSON.stringify(
|
|
Array.from(xmlSnapshotsRef.current.entries()),
|
|
),
|
|
)
|
|
const xml = chartXMLRef.current
|
|
if (xml && xml.length > 300) {
|
|
localStorage.setItem(STORAGE_DIAGRAM_XML_KEY, xml)
|
|
}
|
|
localStorage.setItem(STORAGE_SESSION_ID_KEY, sessionId)
|
|
} catch (error) {
|
|
console.error("Failed to persist state before unload:", error)
|
|
}
|
|
}
|
|
|
|
window.addEventListener("beforeunload", handleBeforeUnload)
|
|
return () =>
|
|
window.removeEventListener("beforeunload", handleBeforeUnload)
|
|
}, [sessionId])
|
|
|
|
const onFormSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
e.preventDefault()
|
|
const isProcessing = status === "streaming" || status === "submitted"
|
|
if (input.trim() && !isProcessing) {
|
|
// Check if input matches a cached example (only when no messages yet)
|
|
if (messages.length === 0) {
|
|
const cached = findCachedResponse(
|
|
input.trim(),
|
|
files.length > 0,
|
|
)
|
|
if (cached) {
|
|
// Add user message and fake assistant response to messages
|
|
// The chat-message-display useEffect will handle displaying the diagram
|
|
const toolCallId = `cached-${Date.now()}`
|
|
|
|
// Build user message text including any file content
|
|
const userText = await processFilesAndAppendContent(
|
|
input,
|
|
files,
|
|
pdfData,
|
|
)
|
|
|
|
setMessages([
|
|
{
|
|
id: `user-${Date.now()}`,
|
|
role: "user" as const,
|
|
parts: [{ type: "text" as const, text: userText }],
|
|
},
|
|
{
|
|
id: `assistant-${Date.now()}`,
|
|
role: "assistant" as const,
|
|
parts: [
|
|
{
|
|
type: "tool-display_diagram" as const,
|
|
toolCallId,
|
|
state: "output-available" as const,
|
|
input: { xml: cached.xml },
|
|
output: "Successfully displayed the diagram.",
|
|
},
|
|
],
|
|
},
|
|
] as any)
|
|
setInput("")
|
|
sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY)
|
|
setFiles([])
|
|
return
|
|
}
|
|
}
|
|
|
|
try {
|
|
let chartXml = await onFetchChart()
|
|
chartXml = formatXML(chartXml)
|
|
|
|
// Update ref directly to avoid race condition with React's async state update
|
|
// This ensures edit_diagram has the correct XML before AI responds
|
|
chartXMLRef.current = chartXml
|
|
|
|
// Build user text by concatenating input with pre-extracted text
|
|
// (Backend only reads first text part, so we must combine them)
|
|
const parts: any[] = []
|
|
const userText = await processFilesAndAppendContent(
|
|
input,
|
|
files,
|
|
pdfData,
|
|
parts,
|
|
)
|
|
|
|
// Add the combined text as the first part
|
|
parts.unshift({ type: "text", text: userText })
|
|
|
|
// Get previous XML from the last snapshot (before this message)
|
|
const snapshotKeys = Array.from(
|
|
xmlSnapshotsRef.current.keys(),
|
|
).sort((a, b) => b - a)
|
|
const previousXml =
|
|
snapshotKeys.length > 0
|
|
? xmlSnapshotsRef.current.get(snapshotKeys[0]) || ""
|
|
: ""
|
|
|
|
// Save XML snapshot for this message (will be at index = current messages.length)
|
|
const messageIndex = messages.length
|
|
xmlSnapshotsRef.current.set(messageIndex, chartXml)
|
|
saveXmlSnapshots()
|
|
|
|
sendChatMessage(parts, chartXml, previousXml, sessionId)
|
|
|
|
// Token count is tracked in onFinish with actual server usage
|
|
setInput("")
|
|
sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY)
|
|
setFiles([])
|
|
} catch (error) {
|
|
console.error("Error fetching chart data:", error)
|
|
}
|
|
}
|
|
}
|
|
|
|
const handleNewChat = useCallback(() => {
|
|
setMessages([])
|
|
clearDiagram()
|
|
handleFileChange([]) // Use handleFileChange to also clear pdfData
|
|
const newSessionId = `session-${Date.now()}-${Math.random()
|
|
.toString(36)
|
|
.slice(2, 9)}`
|
|
setSessionId(newSessionId)
|
|
xmlSnapshotsRef.current.clear()
|
|
// Clear localStorage with error handling
|
|
try {
|
|
localStorage.removeItem(STORAGE_MESSAGES_KEY)
|
|
localStorage.removeItem(STORAGE_XML_SNAPSHOTS_KEY)
|
|
localStorage.removeItem(STORAGE_DIAGRAM_XML_KEY)
|
|
localStorage.setItem(STORAGE_SESSION_ID_KEY, newSessionId)
|
|
sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY)
|
|
toast.success("Started a fresh chat")
|
|
} catch (error) {
|
|
console.error("Failed to clear localStorage:", error)
|
|
toast.warning(
|
|
"Chat cleared but browser storage could not be updated",
|
|
)
|
|
}
|
|
|
|
setShowNewChatDialog(false)
|
|
}, [clearDiagram, handleFileChange, setMessages, setSessionId])
|
|
|
|
const handleInputChange = (
|
|
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
|
) => {
|
|
saveInputToSessionStorage(e.target.value)
|
|
setInput(e.target.value)
|
|
}
|
|
|
|
const saveInputToSessionStorage = (input: string) => {
|
|
sessionStorage.setItem(SESSION_STORAGE_INPUT_KEY, input)
|
|
}
|
|
|
|
// Helper functions for message actions (regenerate/edit)
|
|
// Extract previous XML snapshot before a given message index
|
|
const getPreviousXml = (beforeIndex: number): string => {
|
|
const snapshotKeys = Array.from(xmlSnapshotsRef.current.keys())
|
|
.filter((k) => k < beforeIndex)
|
|
.sort((a, b) => b - a)
|
|
return snapshotKeys.length > 0
|
|
? xmlSnapshotsRef.current.get(snapshotKeys[0]) || ""
|
|
: ""
|
|
}
|
|
|
|
// Restore diagram from snapshot and update ref
|
|
const restoreDiagramFromSnapshot = (savedXml: string) => {
|
|
onDisplayChart(savedXml, true) // Skip validation for trusted snapshots
|
|
chartXMLRef.current = savedXml
|
|
}
|
|
|
|
// Clean up snapshots after a given message index
|
|
const cleanupSnapshotsAfter = (messageIndex: number) => {
|
|
for (const key of xmlSnapshotsRef.current.keys()) {
|
|
if (key > messageIndex) {
|
|
xmlSnapshotsRef.current.delete(key)
|
|
}
|
|
}
|
|
saveXmlSnapshots()
|
|
}
|
|
|
|
// Send chat message with headers
|
|
const sendChatMessage = (
|
|
parts: any,
|
|
xml: string,
|
|
previousXml: string,
|
|
sessionId: string,
|
|
) => {
|
|
// Reset all retry/continuation state on user-initiated message
|
|
autoRetryCountRef.current = 0
|
|
continuationRetryCountRef.current = 0
|
|
partialXmlRef.current = ""
|
|
|
|
const config = getSelectedAIConfig()
|
|
|
|
sendMessage(
|
|
{ parts },
|
|
{
|
|
body: { xml, previousXml, sessionId },
|
|
headers: {
|
|
"x-access-code": config.accessCode,
|
|
...(config.aiProvider && {
|
|
"x-ai-provider": config.aiProvider,
|
|
...(config.aiBaseUrl && {
|
|
"x-ai-base-url": config.aiBaseUrl,
|
|
}),
|
|
...(config.aiApiKey && {
|
|
"x-ai-api-key": config.aiApiKey,
|
|
}),
|
|
...(config.aiModel && { "x-ai-model": config.aiModel }),
|
|
// AWS Bedrock credentials
|
|
...(config.awsAccessKeyId && {
|
|
"x-aws-access-key-id": config.awsAccessKeyId,
|
|
}),
|
|
...(config.awsSecretAccessKey && {
|
|
"x-aws-secret-access-key":
|
|
config.awsSecretAccessKey,
|
|
}),
|
|
...(config.awsRegion && {
|
|
"x-aws-region": config.awsRegion,
|
|
}),
|
|
...(config.awsSessionToken && {
|
|
"x-aws-session-token": config.awsSessionToken,
|
|
}),
|
|
}),
|
|
...(minimalStyle && {
|
|
"x-minimal-style": "true",
|
|
}),
|
|
},
|
|
},
|
|
)
|
|
}
|
|
|
|
// Process files and append content to user text (handles PDF, text, and optionally images)
|
|
const processFilesAndAppendContent = async (
|
|
baseText: string,
|
|
files: File[],
|
|
pdfData: Map<File, FileData>,
|
|
imageParts?: any[],
|
|
): Promise<string> => {
|
|
let userText = baseText
|
|
|
|
for (const file of files) {
|
|
if (isPdfFile(file)) {
|
|
const extracted = pdfData.get(file)
|
|
if (extracted?.text) {
|
|
userText += `\n\n[PDF: ${file.name}]\n${extracted.text}`
|
|
}
|
|
} else if (isTextFile(file)) {
|
|
const extracted = pdfData.get(file)
|
|
if (extracted?.text) {
|
|
userText += `\n\n[File: ${file.name}]\n${extracted.text}`
|
|
}
|
|
} else if (imageParts) {
|
|
// Handle as image (only if imageParts array provided)
|
|
const reader = new FileReader()
|
|
const dataUrl = await new Promise<string>((resolve) => {
|
|
reader.onload = () => resolve(reader.result as string)
|
|
reader.readAsDataURL(file)
|
|
})
|
|
|
|
imageParts.push({
|
|
type: "file",
|
|
url: dataUrl,
|
|
mediaType: file.type,
|
|
})
|
|
}
|
|
}
|
|
|
|
return userText
|
|
}
|
|
|
|
const handleRegenerate = async (messageIndex: number) => {
|
|
const isProcessing = status === "streaming" || status === "submitted"
|
|
if (isProcessing) return
|
|
|
|
// Find the user message before this assistant message
|
|
let userMessageIndex = messageIndex - 1
|
|
while (
|
|
userMessageIndex >= 0 &&
|
|
messages[userMessageIndex].role !== "user"
|
|
) {
|
|
userMessageIndex--
|
|
}
|
|
|
|
if (userMessageIndex < 0) return
|
|
|
|
const userMessage = messages[userMessageIndex]
|
|
const userParts = userMessage.parts
|
|
|
|
// Get the text from the user message
|
|
const textPart = userParts?.find((p: any) => p.type === "text")
|
|
if (!textPart) return
|
|
|
|
// Get the saved XML snapshot for this user message
|
|
const savedXml = xmlSnapshotsRef.current.get(userMessageIndex)
|
|
if (!savedXml) {
|
|
console.error(
|
|
"No saved XML snapshot for message index:",
|
|
userMessageIndex,
|
|
)
|
|
return
|
|
}
|
|
|
|
// Get previous XML and restore diagram state
|
|
const previousXml = getPreviousXml(userMessageIndex)
|
|
restoreDiagramFromSnapshot(savedXml)
|
|
|
|
// Clean up snapshots for messages after the user message (they will be removed)
|
|
cleanupSnapshotsAfter(userMessageIndex)
|
|
|
|
// Remove the user message AND assistant message onwards (sendMessage will re-add the user message)
|
|
// Use flushSync to ensure state update is processed synchronously before sending
|
|
const newMessages = messages.slice(0, userMessageIndex)
|
|
flushSync(() => {
|
|
setMessages(newMessages)
|
|
})
|
|
|
|
// Now send the message after state is guaranteed to be updated
|
|
sendChatMessage(userParts, savedXml, previousXml, sessionId)
|
|
}
|
|
|
|
const handleEditMessage = async (messageIndex: number, newText: string) => {
|
|
const isProcessing = status === "streaming" || status === "submitted"
|
|
if (isProcessing) return
|
|
|
|
const message = messages[messageIndex]
|
|
if (!message || message.role !== "user") return
|
|
|
|
// Get the saved XML snapshot for this user message
|
|
const savedXml = xmlSnapshotsRef.current.get(messageIndex)
|
|
if (!savedXml) {
|
|
console.error(
|
|
"No saved XML snapshot for message index:",
|
|
messageIndex,
|
|
)
|
|
return
|
|
}
|
|
|
|
// Get previous XML and restore diagram state
|
|
const previousXml = getPreviousXml(messageIndex)
|
|
restoreDiagramFromSnapshot(savedXml)
|
|
|
|
// Clean up snapshots for messages after the user message (they will be removed)
|
|
cleanupSnapshotsAfter(messageIndex)
|
|
|
|
// Create new parts with updated text
|
|
const newParts = message.parts?.map((part: any) => {
|
|
if (part.type === "text") {
|
|
return { ...part, text: newText }
|
|
}
|
|
return part
|
|
}) || [{ type: "text", text: newText }]
|
|
|
|
// Remove the user message AND assistant message onwards (sendMessage will re-add the user message)
|
|
// Use flushSync to ensure state update is processed synchronously before sending
|
|
const newMessages = messages.slice(0, messageIndex)
|
|
flushSync(() => {
|
|
setMessages(newMessages)
|
|
})
|
|
|
|
// Now send the edited message after state is guaranteed to be updated
|
|
sendChatMessage(newParts, savedXml, previousXml, sessionId)
|
|
}
|
|
|
|
// Collapsed view (desktop only)
|
|
if (!isVisible && !isMobile) {
|
|
return (
|
|
<div className="h-full flex flex-col items-center pt-4 bg-card border border-border/30 rounded-xl">
|
|
<ButtonWithTooltip
|
|
tooltipContent="Show chat panel (Ctrl+B)"
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={onToggleVisibility}
|
|
className="hover:bg-accent transition-colors"
|
|
>
|
|
<PanelRightOpen className="h-5 w-5 text-muted-foreground" />
|
|
</ButtonWithTooltip>
|
|
<div
|
|
className="text-sm font-medium text-muted-foreground mt-8 tracking-wide"
|
|
style={{
|
|
writingMode: "vertical-rl",
|
|
transform: "rotate(180deg)",
|
|
}}
|
|
>
|
|
AI Chat
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Full view
|
|
return (
|
|
<div className="h-full flex flex-col bg-card shadow-soft animate-slide-in-right rounded-xl border border-border/30 relative">
|
|
<Toaster
|
|
position="bottom-center"
|
|
richColors
|
|
expand
|
|
style={{ position: "absolute" }}
|
|
toastOptions={{
|
|
style: {
|
|
maxWidth: "480px",
|
|
},
|
|
duration: 2000,
|
|
}}
|
|
/>
|
|
{/* Header */}
|
|
<header
|
|
className={`${isMobile ? "px-3 py-2" : "px-5 py-4"} border-b border-border/50`}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2 overflow-x-hidden">
|
|
<div className="flex items-center gap-2">
|
|
<Image
|
|
src="/favicon.ico"
|
|
alt="Next AI Drawio"
|
|
width={isMobile ? 24 : 28}
|
|
height={isMobile ? 24 : 28}
|
|
className="rounded flex-shrink-0"
|
|
/>
|
|
<h1
|
|
className={`${isMobile ? "text-sm" : "text-base"} font-semibold tracking-tight whitespace-nowrap`}
|
|
>
|
|
Next AI Drawio
|
|
</h1>
|
|
</div>
|
|
{!isMobile &&
|
|
process.env.NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE ===
|
|
"true" && (
|
|
<Link
|
|
href="/about"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-sm text-muted-foreground hover:text-foreground transition-colors ml-2"
|
|
>
|
|
About
|
|
</Link>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-1 justify-end overflow-visible">
|
|
<ButtonWithTooltip
|
|
tooltipContent={dict.nav.newChat}
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => setShowNewChatDialog(true)}
|
|
className="hover:bg-accent"
|
|
>
|
|
<MessageSquarePlus
|
|
className={`${isMobile ? "h-4 w-4" : "h-5 w-5"} text-muted-foreground`}
|
|
/>
|
|
</ButtonWithTooltip>
|
|
<div className="w-px h-5 bg-border mx-1" />
|
|
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<a
|
|
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="inline-flex items-center justify-center h-9 w-9 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
|
>
|
|
<FaGithub
|
|
className={`${isMobile ? "w-4 h-4" : "w-5 h-5"}`}
|
|
/>
|
|
</a>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{dict.nav.github}</TooltipContent>
|
|
</Tooltip>
|
|
|
|
<ButtonWithTooltip
|
|
tooltipContent={dict.nav.settings}
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => setShowSettingsDialog(true)}
|
|
className="hover:bg-accent"
|
|
>
|
|
<Settings
|
|
className={`${isMobile ? "h-4 w-4" : "h-5 w-5"} text-muted-foreground`}
|
|
/>
|
|
</ButtonWithTooltip>
|
|
<div className="hidden sm:flex items-center gap-2">
|
|
{!isMobile && (
|
|
<ButtonWithTooltip
|
|
tooltipContent={dict.nav.hidePanel}
|
|
variant="ghost"
|
|
size="icon"
|
|
className="hover:bg-accent"
|
|
onClick={onToggleVisibility}
|
|
>
|
|
<PanelRightClose className="h-5 w-5 text-muted-foreground" />
|
|
</ButtonWithTooltip>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Messages */}
|
|
<main className="flex-1 w-full overflow-hidden">
|
|
<ChatMessageDisplay
|
|
messages={messages}
|
|
setInput={setInput}
|
|
setFiles={handleFileChange}
|
|
processedToolCallsRef={processedToolCallsRef}
|
|
editDiagramOriginalXmlRef={editDiagramOriginalXmlRef}
|
|
sessionId={sessionId}
|
|
onRegenerate={handleRegenerate}
|
|
status={status}
|
|
onEditMessage={handleEditMessage}
|
|
/>
|
|
</main>
|
|
|
|
{/* Dev XML Streaming Simulator - only in development */}
|
|
{DEBUG && (
|
|
<DevXmlSimulator
|
|
setMessages={setMessages}
|
|
onDisplayChart={onDisplayChart}
|
|
/>
|
|
)}
|
|
|
|
{/* Input */}
|
|
<footer
|
|
className={`${isMobile ? "p-2" : "p-4"} border-t border-border/50 bg-card/50`}
|
|
>
|
|
<ChatInput
|
|
input={input}
|
|
status={status}
|
|
onSubmit={onFormSubmit}
|
|
onChange={handleInputChange}
|
|
onClearChat={handleNewChat}
|
|
files={files}
|
|
onFileChange={handleFileChange}
|
|
pdfData={pdfData}
|
|
showHistory={showHistory}
|
|
onToggleHistory={setShowHistory}
|
|
sessionId={sessionId}
|
|
error={error}
|
|
minimalStyle={minimalStyle}
|
|
onMinimalStyleChange={setMinimalStyle}
|
|
models={modelConfig.models}
|
|
selectedModelId={modelConfig.selectedModelId}
|
|
onModelSelect={modelConfig.setSelectedModelId}
|
|
showUnvalidatedModels={modelConfig.showUnvalidatedModels}
|
|
onConfigureModels={() => setShowModelConfigDialog(true)}
|
|
/>
|
|
</footer>
|
|
|
|
<SettingsDialog
|
|
open={showSettingsDialog}
|
|
onOpenChange={setShowSettingsDialog}
|
|
onCloseProtectionChange={onCloseProtectionChange}
|
|
drawioUi={drawioUi}
|
|
onToggleDrawioUi={onToggleDrawioUi}
|
|
darkMode={darkMode}
|
|
onToggleDarkMode={onToggleDarkMode}
|
|
/>
|
|
|
|
<ModelConfigDialog
|
|
open={showModelConfigDialog}
|
|
onOpenChange={setShowModelConfigDialog}
|
|
modelConfig={modelConfig}
|
|
/>
|
|
|
|
<ResetWarningModal
|
|
open={showNewChatDialog}
|
|
onOpenChange={setShowNewChatDialog}
|
|
onClear={handleNewChat}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|