mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-01-07 08:42:28 +08:00
* feat(session): add chat session history with IndexedDB storage - Add session-storage.ts with IndexedDB wrapper using idb library - Add use-session-manager.ts hook for session state management - Add session-history-dropdown.tsx for session selection UI - Integrate session system into chat-panel.tsx - Auto-generate session titles from first user message - Auto-save sessions on message completion - Support session switching, deletion, and creation - Migrate existing localStorage data to IndexedDB - Add i18n translations for session history UI * feat(session): improve history dropdown and persist diagram history - Add time-based grouping (Today, Yesterday, This Week, Earlier) - Add thumbnail previews using Next.js Image component - Add staggered entrance animations with fade-in effects - Improve active session indicator with left border accent - Fix scrolling by using native overflow instead of ScrollArea - Persist diagram version history to IndexedDB sessions - Remove redundant diagram XML from localStorage - Add i18n strings for time group labels (en, ja, zh) * fix(session): prevent data loss on theme change and tab close - Add isDrawioReady effect to restore diagram after DrawIO remount - Add visibilitychange handler to save session when page becomes hidden - Fix missing currentSessionId in saveCurrentSession dependency array - Remove unused sanitizeMessages import from use-session-manager * fix(session): fix diagram save and migration data loss bugs - Add diagramHistory to save effect dependency array so diagram-only edits trigger saves (previously only message changes did) - Destructure stable sessionManager values to prevent unnecessary effect re-runs on every render - Add try-catch wrapper around debounced async save operation - Make saveSession() return boolean to indicate success/failure - Verify IndexedDB write succeeded before deleting localStorage data during migration (prevents data loss if write silently fails) - Keep localStorage data for retry if migration fails instead of marking as complete anyway * refactor(session): extract helpers to reduce code duplication - Add syncUIWithSession helper to consolidate 4 duplicate UI sync blocks - Add buildSessionData helper to consolidate 4 duplicate save logic blocks - Remove unused saveTimeoutRef and its cleanup effect - Net reduction of ~80 lines of duplicate code * style(ui): improve history dropdown and delete dialog styling - Change destructive color from coral to muted rose for refined look - Make session history panel taller (400px fixed height) - Fix popover alignment to prevent truncation - Style delete button with soft red outline instead of solid fill - Make delete dialog more compact (max-w-sm) * fix(session): reset refs on new chat and show recent sessions - Fix cached example diagrams not displaying after creating new session - Reset previousXML, lastProcessedXmlRef and processedToolCalls when messages become empty (new chat or session switch) - Add recent chats section in empty chat state with collapsible examples - Pass sessions and onSelectSession to ChatMessageDisplay - Add loadedMessageIdsRef to skip animations on session restore - Add debug console.log for diagram processing flow * feat(session): add search bar and improve history UI - Remove session history dropdown, use main panel instead - Add search bar to filter history chats by title - Show minutes (Xm ago) instead of "Just now" for recent sessions - Scroll to top when switching to new/empty chat - Remove title truncation limit for better searchability - Remove debug console.log statements * refactor: remove redundant code and fix nested button hydration error - Remove unused 'sessions' from deleteSession dependency array - Remove unused 'switchedTo' variable and simplify return type - Remove unused 'restoredMessageIdsRef' (always empty) - Fix nested button hydration error by using div with role=button - Simplify handleDeleteSession callback * fix(session): fix migration bug, improve metadata perf, truncate titles - Fix migration retry loop when localStorage has empty array - Use cursor-based iteration for getAllSessionMetadata - Truncate session titles to 100 chars with ellipsis * refactor: remove dead code and extract diagram length constant - Remove unused exports: getAllSessions, createNewSession, updateSessionTitle - Remove write-only CURRENT_SESSION_KEY and all localStorage calls - Remove dead messagesEndRef and unused scroll effect - Extract magic number 300 to MIN_REAL_DIAGRAM_LENGTH constant - Add isRealDiagram() helper function for semantic clarity
1760 lines
99 KiB
TypeScript
1760 lines
99 KiB
TypeScript
"use client"
|
|
|
|
import type { UIMessage } from "ai"
|
|
|
|
import {
|
|
Check,
|
|
ChevronDown,
|
|
ChevronUp,
|
|
Copy,
|
|
Cpu,
|
|
FileCode,
|
|
FileText,
|
|
MessageSquare,
|
|
Pencil,
|
|
RotateCcw,
|
|
Search,
|
|
ThumbsDown,
|
|
ThumbsUp,
|
|
Trash2,
|
|
X,
|
|
} from "lucide-react"
|
|
import Image from "next/image"
|
|
import type { MutableRefObject } from "react"
|
|
import { useCallback, useEffect, useRef, useState } from "react"
|
|
import ReactMarkdown from "react-markdown"
|
|
import { toast } from "sonner"
|
|
import {
|
|
Reasoning,
|
|
ReasoningContent,
|
|
ReasoningTrigger,
|
|
} from "@/components/ai-elements/reasoning"
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from "@/components/ui/alert-dialog"
|
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
|
import { useDictionary } from "@/hooks/use-dictionary"
|
|
import { getApiEndpoint } from "@/lib/base-path"
|
|
import {
|
|
applyDiagramOperations,
|
|
convertToLegalXml,
|
|
extractCompleteMxCells,
|
|
isMxCellXmlComplete,
|
|
replaceNodes,
|
|
validateAndFixXml,
|
|
} from "@/lib/utils"
|
|
import ExamplePanel from "./chat-example-panel"
|
|
import { CodeBlock } from "./code-block"
|
|
|
|
interface DiagramOperation {
|
|
operation: "update" | "add" | "delete"
|
|
cell_id: string
|
|
new_xml?: string
|
|
}
|
|
|
|
// Helper to extract complete operations from streaming input
|
|
function getCompleteOperations(
|
|
operations: DiagramOperation[] | undefined,
|
|
): DiagramOperation[] {
|
|
if (!operations || !Array.isArray(operations)) return []
|
|
return operations.filter(
|
|
(op) =>
|
|
op &&
|
|
typeof op.operation === "string" &&
|
|
["update", "add", "delete"].includes(op.operation) &&
|
|
typeof op.cell_id === "string" &&
|
|
op.cell_id.length > 0 &&
|
|
// delete doesn't need new_xml, update/add do
|
|
(op.operation === "delete" || typeof op.new_xml === "string"),
|
|
)
|
|
}
|
|
|
|
// Tool part interface for type safety
|
|
interface ToolPartLike {
|
|
type: string
|
|
toolCallId: string
|
|
state?: string
|
|
input?: {
|
|
xml?: string
|
|
operations?: DiagramOperation[]
|
|
} & Record<string, unknown>
|
|
output?: string
|
|
}
|
|
|
|
function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
|
|
return (
|
|
<div className="space-y-3">
|
|
{operations.map((op, index) => (
|
|
<div
|
|
key={`${op.operation}-${op.cell_id}-${index}`}
|
|
className="rounded-lg border border-border/50 overflow-hidden bg-background/50"
|
|
>
|
|
<div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2">
|
|
<span
|
|
className={`text-[10px] font-medium uppercase tracking-wide ${
|
|
op.operation === "delete"
|
|
? "text-red-600"
|
|
: op.operation === "add"
|
|
? "text-green-600"
|
|
: "text-blue-600"
|
|
}`}
|
|
>
|
|
{op.operation}
|
|
</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
cell_id: {op.cell_id}
|
|
</span>
|
|
</div>
|
|
{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}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
import { useDiagram } from "@/contexts/diagram-context"
|
|
|
|
// Helper to split text content into regular text and file sections (PDF or text files)
|
|
interface TextSection {
|
|
type: "text" | "file"
|
|
content: string
|
|
filename?: string
|
|
charCount?: number
|
|
fileType?: "pdf" | "text"
|
|
}
|
|
|
|
function splitTextIntoFileSections(text: string): TextSection[] {
|
|
const sections: TextSection[] = []
|
|
// Match [PDF: filename] or [File: filename] patterns
|
|
const filePattern =
|
|
/\[(PDF|File):\s*([^\]]+)\]\n([\s\S]*?)(?=\n\n\[(PDF|File):|$)/g
|
|
let lastIndex = 0
|
|
let match
|
|
|
|
while ((match = filePattern.exec(text)) !== null) {
|
|
// Add text before this file section
|
|
const beforeText = text.slice(lastIndex, match.index).trim()
|
|
if (beforeText) {
|
|
sections.push({ type: "text", content: beforeText })
|
|
}
|
|
|
|
// Add file section
|
|
const fileType = match[1].toLowerCase() === "pdf" ? "pdf" : "text"
|
|
const filename = match[2].trim()
|
|
const fileContent = match[3].trim()
|
|
sections.push({
|
|
type: "file",
|
|
content: fileContent,
|
|
filename,
|
|
charCount: fileContent.length,
|
|
fileType,
|
|
})
|
|
|
|
lastIndex = match.index + match[0].length
|
|
}
|
|
|
|
// Add remaining text after last file section
|
|
const remainingText = text.slice(lastIndex).trim()
|
|
if (remainingText) {
|
|
sections.push({ type: "text", content: remainingText })
|
|
}
|
|
|
|
// If no file sections found, return original text
|
|
if (sections.length === 0) {
|
|
sections.push({ type: "text", content: text })
|
|
}
|
|
|
|
return sections
|
|
}
|
|
|
|
const getMessageTextContent = (message: UIMessage): string => {
|
|
if (!message.parts) return ""
|
|
return message.parts
|
|
.filter((part) => part.type === "text")
|
|
.map((part) => (part as { text: string }).text)
|
|
.join("\n")
|
|
}
|
|
|
|
// Get only the user's original text, excluding appended file content
|
|
const getUserOriginalText = (message: UIMessage): string => {
|
|
const fullText = getMessageTextContent(message)
|
|
// Strip out [PDF: ...] and [File: ...] sections that were appended
|
|
const filePattern = /\n\n\[(PDF|File):\s*[^\]]+\]\n[\s\S]*$/
|
|
return fullText.replace(filePattern, "").trim()
|
|
}
|
|
|
|
interface SessionMetadata {
|
|
id: string
|
|
title: string
|
|
updatedAt: number
|
|
thumbnailDataUrl?: string
|
|
}
|
|
|
|
interface ChatMessageDisplayProps {
|
|
messages: UIMessage[]
|
|
setInput: (input: string) => void
|
|
setFiles: (files: File[]) => void
|
|
processedToolCallsRef: MutableRefObject<Set<string>>
|
|
editDiagramOriginalXmlRef: MutableRefObject<Map<string, string>>
|
|
sessionId?: string
|
|
onRegenerate?: (messageIndex: number) => void
|
|
onEditMessage?: (messageIndex: number, newText: string) => void
|
|
status?: "streaming" | "submitted" | "idle" | "error" | "ready"
|
|
isRestored?: boolean
|
|
sessions?: SessionMetadata[]
|
|
onSelectSession?: (id: string) => void
|
|
onDeleteSession?: (id: string) => void
|
|
loadedMessageIdsRef?: MutableRefObject<Set<string>>
|
|
}
|
|
|
|
export function ChatMessageDisplay({
|
|
messages,
|
|
setInput,
|
|
setFiles,
|
|
processedToolCallsRef,
|
|
editDiagramOriginalXmlRef,
|
|
sessionId,
|
|
onRegenerate,
|
|
onEditMessage,
|
|
status = "idle",
|
|
isRestored = false,
|
|
sessions = [],
|
|
onSelectSession,
|
|
onDeleteSession,
|
|
loadedMessageIdsRef,
|
|
}: ChatMessageDisplayProps) {
|
|
const dict = useDictionary()
|
|
const { chartXML, loadDiagram: onDisplayChart } = useDiagram()
|
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
|
const scrollTopRef = useRef<HTMLDivElement>(null)
|
|
const previousXML = useRef<string>("")
|
|
const processedToolCalls = processedToolCallsRef
|
|
// Track the last processed XML per toolCallId to skip redundant processing during streaming
|
|
const lastProcessedXmlRef = useRef<Map<string, string>>(new Map())
|
|
|
|
// Reset refs when messages become empty (new chat or session switch)
|
|
// This ensures cached examples work correctly after starting a new session
|
|
useEffect(() => {
|
|
if (messages.length === 0) {
|
|
previousXML.current = ""
|
|
lastProcessedXmlRef.current.clear()
|
|
// Note: processedToolCalls is passed from parent, so we clear it too
|
|
processedToolCalls.current.clear()
|
|
// Scroll to top to show newest history items
|
|
scrollTopRef.current?.scrollIntoView({ behavior: "instant" })
|
|
}
|
|
}, [messages.length, processedToolCalls])
|
|
// Debounce streaming diagram updates - store pending XML and timeout
|
|
const pendingXmlRef = useRef<string | null>(null)
|
|
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
|
null,
|
|
)
|
|
const STREAMING_DEBOUNCE_MS = 150 // Only update diagram every 150ms during streaming
|
|
// Refs for edit_diagram streaming
|
|
const pendingEditRef = useRef<{
|
|
operations: DiagramOperation[]
|
|
toolCallId: string
|
|
} | null>(null)
|
|
const editDebounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
|
null,
|
|
)
|
|
const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>(
|
|
{},
|
|
)
|
|
const [copiedToolCallId, setCopiedToolCallId] = useState<string | null>(
|
|
null,
|
|
)
|
|
const [copyFailedToolCallId, setCopyFailedToolCallId] = useState<
|
|
string | null
|
|
>(null)
|
|
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null)
|
|
const [copyFailedMessageId, setCopyFailedMessageId] = useState<
|
|
string | null
|
|
>(null)
|
|
const [feedback, setFeedback] = useState<Record<string, "good" | "bad">>({})
|
|
const [editingMessageId, setEditingMessageId] = useState<string | null>(
|
|
null,
|
|
)
|
|
const editTextareaRef = useRef<HTMLTextAreaElement>(null)
|
|
const [editText, setEditText] = useState<string>("")
|
|
// Track which PDF sections are expanded (key: messageId-sectionIndex)
|
|
const [expandedPdfSections, setExpandedPdfSections] = useState<
|
|
Record<string, boolean>
|
|
>({})
|
|
// Track whether examples section is expanded (collapsed by default when there's history)
|
|
const [examplesExpanded, setExamplesExpanded] = useState(false)
|
|
// Delete confirmation dialog state
|
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
|
const [sessionToDelete, setSessionToDelete] = useState<string | null>(null)
|
|
// Search filter for history
|
|
const [searchQuery, setSearchQuery] = useState("")
|
|
|
|
const setCopyState = (
|
|
messageId: string,
|
|
isToolCall: boolean,
|
|
isSuccess: boolean,
|
|
) => {
|
|
if (isSuccess) {
|
|
if (isToolCall) {
|
|
setCopiedToolCallId(messageId)
|
|
setTimeout(() => setCopiedToolCallId(null), 2000)
|
|
} else {
|
|
setCopiedMessageId(messageId)
|
|
setTimeout(() => setCopiedMessageId(null), 2000)
|
|
}
|
|
} else {
|
|
if (isToolCall) {
|
|
setCopyFailedToolCallId(messageId)
|
|
setTimeout(() => setCopyFailedToolCallId(null), 2000)
|
|
} else {
|
|
setCopyFailedMessageId(messageId)
|
|
setTimeout(() => setCopyFailedMessageId(null), 2000)
|
|
}
|
|
}
|
|
}
|
|
|
|
const copyMessageToClipboard = async (
|
|
messageId: string,
|
|
text: string,
|
|
isToolCall = false,
|
|
) => {
|
|
try {
|
|
await navigator.clipboard.writeText(text)
|
|
setCopyState(messageId, isToolCall, true)
|
|
} catch (_err) {
|
|
// Fallback for non-secure contexts (HTTP) or permission denied
|
|
const textarea = document.createElement("textarea")
|
|
textarea.value = text
|
|
textarea.style.position = "fixed"
|
|
textarea.style.left = "-9999px"
|
|
textarea.style.opacity = "0"
|
|
document.body.appendChild(textarea)
|
|
|
|
try {
|
|
textarea.select()
|
|
const success = document.execCommand("copy")
|
|
if (!success) {
|
|
throw new Error("Copy command failed")
|
|
}
|
|
setCopyState(messageId, isToolCall, true)
|
|
} catch (fallbackErr) {
|
|
console.error("Failed to copy message:", fallbackErr)
|
|
toast.error(dict.chat.failedToCopyDetail)
|
|
setCopyState(messageId, isToolCall, false)
|
|
} finally {
|
|
document.body.removeChild(textarea)
|
|
}
|
|
}
|
|
}
|
|
|
|
const submitFeedback = async (messageId: string, value: "good" | "bad") => {
|
|
// Toggle off if already selected
|
|
if (feedback[messageId] === value) {
|
|
setFeedback((prev) => {
|
|
const next = { ...prev }
|
|
delete next[messageId]
|
|
return next
|
|
})
|
|
return
|
|
}
|
|
|
|
setFeedback((prev) => ({ ...prev, [messageId]: value }))
|
|
|
|
try {
|
|
await fetch(getApiEndpoint("/api/log-feedback"), {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
messageId,
|
|
feedback: value,
|
|
sessionId,
|
|
}),
|
|
})
|
|
} catch (error) {
|
|
console.error("Failed to log feedback:", error)
|
|
toast.error(dict.errors.failedToRecordFeedback)
|
|
// Revert optimistic UI update
|
|
setFeedback((prev) => {
|
|
const next = { ...prev }
|
|
delete next[messageId]
|
|
return next
|
|
})
|
|
}
|
|
}
|
|
|
|
const handleDisplayChart = useCallback(
|
|
(xml: string, showToast = false) => {
|
|
let currentXml = xml || ""
|
|
|
|
// During streaming (showToast=false), extract only complete mxCell elements
|
|
// This allows progressive rendering even with partial/incomplete trailing XML
|
|
if (!showToast) {
|
|
const completeCells = extractCompleteMxCells(currentXml)
|
|
if (!completeCells) {
|
|
return
|
|
}
|
|
currentXml = completeCells
|
|
}
|
|
|
|
const convertedXml = convertToLegalXml(currentXml)
|
|
if (convertedXml !== previousXML.current) {
|
|
// Parse and validate XML BEFORE calling replaceNodes
|
|
const parser = new DOMParser()
|
|
// Wrap in root element for parsing multiple mxCell elements
|
|
const testDoc = parser.parseFromString(
|
|
`<root>${convertedXml}</root>`,
|
|
"text/xml",
|
|
)
|
|
const parseError = testDoc.querySelector("parsererror")
|
|
|
|
if (parseError) {
|
|
// Only show toast if this is the final XML (not during streaming)
|
|
if (showToast) {
|
|
toast.error(dict.errors.malformedXml)
|
|
}
|
|
return // Skip this update
|
|
}
|
|
|
|
try {
|
|
// If chartXML is empty, create a default mxfile structure to use with replaceNodes
|
|
// This ensures the XML is properly wrapped in mxfile/diagram/mxGraphModel format
|
|
const baseXML =
|
|
chartXML ||
|
|
`<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
|
const replacedXML = replaceNodes(baseXML, convertedXml)
|
|
|
|
// During streaming (showToast=false), skip heavy validation for lower latency
|
|
// The quick DOM parse check above catches malformed XML
|
|
// Full validation runs on final output (showToast=true)
|
|
if (!showToast) {
|
|
previousXML.current = convertedXml
|
|
onDisplayChart(replacedXML, true)
|
|
return
|
|
}
|
|
|
|
// Final output: run full validation and auto-fix
|
|
const validation = validateAndFixXml(replacedXML)
|
|
if (validation.valid) {
|
|
previousXML.current = convertedXml
|
|
// Use fixed XML if available, otherwise use original
|
|
const xmlToLoad = validation.fixed || replacedXML
|
|
onDisplayChart(xmlToLoad, true)
|
|
} else {
|
|
toast.error(dict.errors.validationFailed)
|
|
}
|
|
} catch (error) {
|
|
console.error("Error processing XML:", error)
|
|
// Only show toast if this is the final XML (not during streaming)
|
|
if (showToast) {
|
|
toast.error(dict.errors.failedToProcess)
|
|
}
|
|
}
|
|
}
|
|
},
|
|
[chartXML, onDisplayChart],
|
|
)
|
|
|
|
// Track previous message count to detect bulk loads vs streaming
|
|
const prevMessageCountRef = useRef(0)
|
|
|
|
useEffect(() => {
|
|
if (messagesEndRef.current && messages.length > 0) {
|
|
const prevCount = prevMessageCountRef.current
|
|
const currentCount = messages.length
|
|
prevMessageCountRef.current = currentCount
|
|
|
|
// Bulk load (session restore) - instant scroll, no animation
|
|
if (prevCount === 0 || currentCount - prevCount > 1) {
|
|
messagesEndRef.current.scrollIntoView({ behavior: "instant" })
|
|
return
|
|
}
|
|
|
|
// Single message added - smooth scroll
|
|
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
|
|
}
|
|
}, [messages])
|
|
|
|
useEffect(() => {
|
|
if (editingMessageId && editTextareaRef.current) {
|
|
editTextareaRef.current.focus()
|
|
}
|
|
}, [editingMessageId])
|
|
|
|
useEffect(() => {
|
|
// Only process the last message for streaming performance
|
|
// Previous messages are already processed and won't change
|
|
const messagesToProcess =
|
|
messages.length > 0 ? [messages[messages.length - 1]] : []
|
|
|
|
messagesToProcess.forEach((message) => {
|
|
if (message.parts) {
|
|
message.parts.forEach((part) => {
|
|
if (part.type?.startsWith("tool-")) {
|
|
const toolPart = part as ToolPartLike
|
|
const { toolCallId, state, input } = toolPart
|
|
|
|
if (state === "output-available") {
|
|
setExpandedTools((prev) => ({
|
|
...prev,
|
|
[toolCallId]: false,
|
|
}))
|
|
}
|
|
|
|
if (
|
|
part.type === "tool-display_diagram" &&
|
|
input?.xml
|
|
) {
|
|
const xml = input.xml as string
|
|
|
|
// Skip if XML hasn't changed since last processing
|
|
const lastXml =
|
|
lastProcessedXmlRef.current.get(toolCallId)
|
|
if (lastXml === xml) {
|
|
return // Skip redundant processing
|
|
}
|
|
|
|
if (
|
|
state === "input-streaming" ||
|
|
state === "input-available"
|
|
) {
|
|
// Debounce streaming updates - queue the XML and process after delay
|
|
pendingXmlRef.current = xml
|
|
|
|
if (!debounceTimeoutRef.current) {
|
|
// No pending timeout - set one up
|
|
debounceTimeoutRef.current = setTimeout(
|
|
() => {
|
|
const pendingXml =
|
|
pendingXmlRef.current
|
|
debounceTimeoutRef.current = null
|
|
pendingXmlRef.current = null
|
|
if (pendingXml) {
|
|
handleDisplayChart(
|
|
pendingXml,
|
|
false,
|
|
)
|
|
lastProcessedXmlRef.current.set(
|
|
toolCallId,
|
|
pendingXml,
|
|
)
|
|
}
|
|
},
|
|
STREAMING_DEBOUNCE_MS,
|
|
)
|
|
}
|
|
} else if (
|
|
state === "output-available" &&
|
|
!processedToolCalls.current.has(toolCallId)
|
|
) {
|
|
// Final output - process immediately (clear any pending debounce)
|
|
if (debounceTimeoutRef.current) {
|
|
clearTimeout(debounceTimeoutRef.current)
|
|
debounceTimeoutRef.current = null
|
|
pendingXmlRef.current = null
|
|
}
|
|
// Show toast only if final XML is malformed
|
|
handleDisplayChart(xml, true)
|
|
processedToolCalls.current.add(toolCallId)
|
|
// Clean up the ref entry - tool is complete, no longer needed
|
|
lastProcessedXmlRef.current.delete(toolCallId)
|
|
}
|
|
}
|
|
|
|
// Handle edit_diagram streaming - apply operations incrementally for preview
|
|
// Uses shared editDiagramOriginalXmlRef to coordinate with tool handler
|
|
if (
|
|
part.type === "tool-edit_diagram" &&
|
|
input?.operations
|
|
) {
|
|
const completeOps = getCompleteOperations(
|
|
input.operations as DiagramOperation[],
|
|
)
|
|
|
|
if (completeOps.length === 0) return
|
|
|
|
// Capture original XML when streaming starts (store in shared ref)
|
|
if (
|
|
!editDiagramOriginalXmlRef.current.has(
|
|
toolCallId,
|
|
)
|
|
) {
|
|
if (!chartXML) {
|
|
console.warn(
|
|
"[edit_diagram streaming] No chart XML available",
|
|
)
|
|
return
|
|
}
|
|
editDiagramOriginalXmlRef.current.set(
|
|
toolCallId,
|
|
chartXML,
|
|
)
|
|
}
|
|
|
|
const originalXml =
|
|
editDiagramOriginalXmlRef.current.get(
|
|
toolCallId,
|
|
)
|
|
if (!originalXml) return
|
|
|
|
// Skip if no change from last processed state
|
|
const lastCount = lastProcessedXmlRef.current.get(
|
|
toolCallId + "-opCount",
|
|
)
|
|
if (lastCount === String(completeOps.length)) return
|
|
|
|
if (
|
|
state === "input-streaming" ||
|
|
state === "input-available"
|
|
) {
|
|
// Queue the operations for debounced processing
|
|
pendingEditRef.current = {
|
|
operations: completeOps,
|
|
toolCallId,
|
|
}
|
|
|
|
if (!editDebounceTimeoutRef.current) {
|
|
editDebounceTimeoutRef.current = setTimeout(
|
|
() => {
|
|
const pending =
|
|
pendingEditRef.current
|
|
editDebounceTimeoutRef.current =
|
|
null
|
|
pendingEditRef.current = null
|
|
|
|
if (pending) {
|
|
const origXml =
|
|
editDiagramOriginalXmlRef.current.get(
|
|
pending.toolCallId,
|
|
)
|
|
if (!origXml) return
|
|
|
|
try {
|
|
const {
|
|
result: editedXml,
|
|
} = applyDiagramOperations(
|
|
origXml,
|
|
pending.operations,
|
|
)
|
|
handleDisplayChart(
|
|
editedXml,
|
|
false,
|
|
)
|
|
lastProcessedXmlRef.current.set(
|
|
pending.toolCallId +
|
|
"-opCount",
|
|
String(
|
|
pending.operations
|
|
.length,
|
|
),
|
|
)
|
|
} catch (e) {
|
|
console.warn(
|
|
`[edit_diagram streaming] Operation failed:`,
|
|
e instanceof Error
|
|
? e.message
|
|
: e,
|
|
)
|
|
}
|
|
}
|
|
},
|
|
STREAMING_DEBOUNCE_MS,
|
|
)
|
|
}
|
|
} else if (
|
|
state === "output-available" &&
|
|
!processedToolCalls.current.has(toolCallId)
|
|
) {
|
|
// Final state - cleanup streaming refs (tool handler does final application)
|
|
if (editDebounceTimeoutRef.current) {
|
|
clearTimeout(editDebounceTimeoutRef.current)
|
|
editDebounceTimeoutRef.current = null
|
|
}
|
|
lastProcessedXmlRef.current.delete(
|
|
toolCallId + "-opCount",
|
|
)
|
|
processedToolCalls.current.add(toolCallId)
|
|
// Note: Don't delete editDiagramOriginalXmlRef here - tool handler needs it
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
// NOTE: Don't cleanup debounce timeouts here!
|
|
// The cleanup runs on every re-render (when messages changes),
|
|
// which would cancel the timeout before it fires.
|
|
// Let the timeouts complete naturally - they're harmless if component unmounts.
|
|
}, [messages, handleDisplayChart, chartXML])
|
|
|
|
const renderToolPart = (part: ToolPartLike) => {
|
|
const callId = part.toolCallId
|
|
const { state, input, output } = part
|
|
// Default to collapsed if tool is complete, expanded if still streaming
|
|
const isExpanded = expandedTools[callId] ?? state !== "output-available"
|
|
const toolName = part.type?.replace("tool-", "")
|
|
const isCopied = copiedToolCallId === callId
|
|
|
|
const toggleExpanded = () => {
|
|
setExpandedTools((prev) => ({
|
|
...prev,
|
|
[callId]: !isExpanded,
|
|
}))
|
|
}
|
|
|
|
const getToolDisplayName = (name: string) => {
|
|
switch (name) {
|
|
case "display_diagram":
|
|
return "Generate Diagram"
|
|
case "edit_diagram":
|
|
return "Edit Diagram"
|
|
case "get_shape_library":
|
|
return "Get Shape Library"
|
|
default:
|
|
return name
|
|
}
|
|
}
|
|
|
|
const handleCopy = () => {
|
|
let textToCopy = ""
|
|
|
|
if (input && typeof input === "object") {
|
|
if (input.xml) {
|
|
textToCopy = input.xml
|
|
} else if (
|
|
input.operations &&
|
|
Array.isArray(input.operations)
|
|
) {
|
|
textToCopy = JSON.stringify(input.operations, null, 2)
|
|
} else if (Object.keys(input).length > 0) {
|
|
textToCopy = JSON.stringify(input, null, 2)
|
|
}
|
|
}
|
|
|
|
if (
|
|
output &&
|
|
toolName === "get_shape_library" &&
|
|
typeof output === "string"
|
|
) {
|
|
textToCopy = output
|
|
}
|
|
|
|
if (textToCopy) {
|
|
copyMessageToClipboard(callId, textToCopy, true)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div
|
|
key={callId}
|
|
className="my-3 rounded-xl border border-border/60 bg-muted/30 overflow-hidden"
|
|
>
|
|
<div className="flex items-center justify-between px-4 py-3 bg-muted/50">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-6 h-6 rounded-md bg-primary/10 flex items-center justify-center">
|
|
<Cpu className="w-3.5 h-3.5 text-primary" />
|
|
</div>
|
|
<span className="text-sm font-medium text-foreground/80">
|
|
{getToolDisplayName(toolName)}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{state === "input-streaming" && (
|
|
<div className="h-4 w-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
|
)}
|
|
{state === "output-available" && (
|
|
<>
|
|
<span className="text-xs font-medium text-green-600 bg-green-50 px-2 py-0.5 rounded-full">
|
|
{dict.tools.complete}
|
|
</span>
|
|
{isExpanded && (
|
|
<button
|
|
type="button"
|
|
onClick={handleCopy}
|
|
className="p-1 rounded hover:bg-muted transition-colors"
|
|
title={
|
|
copiedToolCallId === callId
|
|
? dict.chat.copied
|
|
: copyFailedToolCallId ===
|
|
callId
|
|
? dict.chat.failedToCopy
|
|
: dict.chat.copyResponse
|
|
}
|
|
>
|
|
{isCopied ? (
|
|
<Check className="w-4 h-4 text-green-600" />
|
|
) : (
|
|
<Copy className="w-4 h-4 text-muted-foreground" />
|
|
)}
|
|
</button>
|
|
)}
|
|
</>
|
|
)}
|
|
{state === "output-error" &&
|
|
(() => {
|
|
// Check if this is a truncation (incomplete XML) vs real error
|
|
const isTruncated =
|
|
(toolName === "display_diagram" ||
|
|
toolName === "append_diagram") &&
|
|
!isMxCellXmlComplete(input?.xml)
|
|
return isTruncated ? (
|
|
<span className="text-xs font-medium text-yellow-600 bg-yellow-50 px-2 py-0.5 rounded-full">
|
|
Truncated
|
|
</span>
|
|
) : (
|
|
<span className="text-xs font-medium text-red-600 bg-red-50 px-2 py-0.5 rounded-full">
|
|
Error
|
|
</span>
|
|
)
|
|
})()}
|
|
{input && Object.keys(input).length > 0 && (
|
|
<button
|
|
type="button"
|
|
onClick={toggleExpanded}
|
|
className="p-1 rounded hover:bg-muted transition-colors"
|
|
>
|
|
{isExpanded ? (
|
|
<ChevronUp className="w-4 h-4 text-muted-foreground" />
|
|
) : (
|
|
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
|
)}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{input && isExpanded && (
|
|
<div className="px-4 py-3 border-t border-border/40 bg-muted/20">
|
|
{typeof input === "object" && input.xml ? (
|
|
<CodeBlock code={input.xml} language="xml" />
|
|
) : typeof input === "object" &&
|
|
input.operations &&
|
|
Array.isArray(input.operations) ? (
|
|
<OperationsDisplay operations={input.operations} />
|
|
) : typeof input === "object" &&
|
|
Object.keys(input).length > 0 ? (
|
|
<CodeBlock
|
|
code={JSON.stringify(input, null, 2)}
|
|
language="json"
|
|
/>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
{output &&
|
|
state === "output-error" &&
|
|
(() => {
|
|
const isTruncated =
|
|
(toolName === "display_diagram" ||
|
|
toolName === "append_diagram") &&
|
|
!isMxCellXmlComplete(input?.xml)
|
|
return (
|
|
<div
|
|
className={`px-4 py-3 border-t border-border/40 text-sm ${isTruncated ? "text-yellow-600" : "text-red-600"}`}
|
|
>
|
|
{isTruncated
|
|
? "Output truncated due to length limits. Try a simpler request or increase the maxOutputLength."
|
|
: output}
|
|
</div>
|
|
)
|
|
})()}
|
|
{/* Show get_shape_library output on success */}
|
|
{output &&
|
|
toolName === "get_shape_library" &&
|
|
state === "output-available" &&
|
|
isExpanded && (
|
|
<div className="px-4 py-3 border-t border-border/40">
|
|
<div className="text-xs text-muted-foreground mb-2">
|
|
Library loaded (
|
|
{typeof output === "string" ? output.length : 0}{" "}
|
|
chars)
|
|
</div>
|
|
<pre className="text-xs bg-muted/50 p-2 rounded-md overflow-auto max-h-32 whitespace-pre-wrap">
|
|
{typeof output === "string"
|
|
? output.substring(0, 800) +
|
|
(output.length > 800 ? "\n..." : "")
|
|
: String(output)}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Helper to format session date
|
|
const formatSessionDate = (timestamp: number): string => {
|
|
const date = new Date(timestamp)
|
|
const now = new Date()
|
|
const diffMs = now.getTime() - date.getTime()
|
|
const diffMins = Math.floor(diffMs / (1000 * 60))
|
|
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
|
|
|
if (diffMins < 1) return dict.sessionHistory?.justNow || "Just now"
|
|
if (diffMins < 60) return `${diffMins}m ago`
|
|
if (diffHours < 24) return `${diffHours}h ago`
|
|
|
|
return date.toLocaleDateString(undefined, {
|
|
month: "short",
|
|
day: "numeric",
|
|
})
|
|
}
|
|
|
|
const hasHistory = sessions.length > 0
|
|
|
|
return (
|
|
<ScrollArea className="h-full w-full scrollbar-thin">
|
|
<div ref={scrollTopRef} />
|
|
{messages.length === 0 && isRestored ? (
|
|
hasHistory ? (
|
|
// Show history + collapsible examples when there are sessions
|
|
<div className="py-6 px-2 animate-fade-in">
|
|
{/* Recent Chats Section */}
|
|
<div className="mb-6">
|
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1 mb-3">
|
|
{dict.sessionHistory?.recentChats ||
|
|
"Recent Chats"}
|
|
</p>
|
|
{/* Search Bar */}
|
|
<div className="relative mb-3">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
|
<input
|
|
type="text"
|
|
placeholder={
|
|
dict.sessionHistory
|
|
?.searchPlaceholder ||
|
|
"Search chats..."
|
|
}
|
|
value={searchQuery}
|
|
onChange={(e) =>
|
|
setSearchQuery(e.target.value)
|
|
}
|
|
className="w-full pl-9 pr-3 py-2 text-sm rounded-lg border border-border/60 bg-background focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary/50 transition-all"
|
|
/>
|
|
{searchQuery && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setSearchQuery("")}
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-muted transition-colors"
|
|
>
|
|
<X className="w-3 h-3 text-muted-foreground" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="space-y-2">
|
|
{sessions
|
|
.filter((session) =>
|
|
session.title
|
|
.toLowerCase()
|
|
.includes(
|
|
searchQuery.toLowerCase(),
|
|
),
|
|
)
|
|
.map((session) => (
|
|
// biome-ignore lint/a11y/useSemanticElements: Cannot use button - has nested delete button which causes hydration error
|
|
<div
|
|
key={session.id}
|
|
role="button"
|
|
tabIndex={0}
|
|
className="group w-full flex items-center gap-3 p-3 rounded-xl border border-border/60 bg-card hover:bg-accent/50 hover:border-primary/30 transition-all duration-200 cursor-pointer text-left"
|
|
onClick={() =>
|
|
onSelectSession?.(session.id)
|
|
}
|
|
onKeyDown={(e) => {
|
|
if (
|
|
e.key === "Enter" ||
|
|
e.key === " "
|
|
) {
|
|
e.preventDefault()
|
|
onSelectSession?.(
|
|
session.id,
|
|
)
|
|
}
|
|
}}
|
|
>
|
|
{session.thumbnailDataUrl ? (
|
|
<div className="w-12 h-12 shrink-0 rounded-lg border bg-white overflow-hidden">
|
|
<Image
|
|
src={
|
|
session.thumbnailDataUrl
|
|
}
|
|
alt=""
|
|
width={48}
|
|
height={48}
|
|
className="object-contain w-full h-full"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="w-12 h-12 shrink-0 rounded-lg bg-primary/10 flex items-center justify-center">
|
|
<MessageSquare className="w-5 h-5 text-primary" />
|
|
</div>
|
|
)}
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-sm font-medium truncate">
|
|
{session.title}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{formatSessionDate(
|
|
session.updatedAt,
|
|
)}
|
|
</div>
|
|
</div>
|
|
{onDeleteSession && (
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
setSessionToDelete(
|
|
session.id,
|
|
)
|
|
setDeleteDialogOpen(
|
|
true,
|
|
)
|
|
}}
|
|
className="p-1.5 rounded-lg opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-all"
|
|
title={dict.common.delete}
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
{sessions.filter((s) =>
|
|
s.title
|
|
.toLowerCase()
|
|
.includes(searchQuery.toLowerCase()),
|
|
).length === 0 &&
|
|
searchQuery && (
|
|
<p className="text-sm text-muted-foreground text-center py-4">
|
|
{dict.sessionHistory?.noResults ||
|
|
"No chats found"}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Collapsible Examples Section */}
|
|
<div className="border-t border-border/50 pt-4">
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
setExamplesExpanded(!examplesExpanded)
|
|
}
|
|
className="w-full flex items-center justify-between px-1 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors"
|
|
>
|
|
<span>
|
|
{dict.examples?.quickExamples ||
|
|
"Quick Examples"}
|
|
</span>
|
|
{examplesExpanded ? (
|
|
<ChevronUp className="w-4 h-4" />
|
|
) : (
|
|
<ChevronDown className="w-4 h-4" />
|
|
)}
|
|
</button>
|
|
{examplesExpanded && (
|
|
<div className="mt-2">
|
|
<ExamplePanel
|
|
setInput={setInput}
|
|
setFiles={setFiles}
|
|
minimal
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
// Show full examples when no history
|
|
<ExamplePanel setInput={setInput} setFiles={setFiles} />
|
|
)
|
|
) : messages.length === 0 ? null : (
|
|
<div className="py-4 px-4 space-y-4">
|
|
{messages.map((message, messageIndex) => {
|
|
const userMessageText =
|
|
message.role === "user"
|
|
? getMessageTextContent(message)
|
|
: ""
|
|
const isLastAssistantMessage =
|
|
message.role === "assistant" &&
|
|
(messageIndex === messages.length - 1 ||
|
|
messages
|
|
.slice(messageIndex + 1)
|
|
.every((m) => m.role !== "assistant"))
|
|
const isLastUserMessage =
|
|
message.role === "user" &&
|
|
(messageIndex === messages.length - 1 ||
|
|
messages
|
|
.slice(messageIndex + 1)
|
|
.every((m) => m.role !== "user"))
|
|
const isEditing = editingMessageId === message.id
|
|
// Skip animation for loaded messages (from session restore)
|
|
const isRestoredMessage =
|
|
loadedMessageIdsRef?.current.has(message.id) ??
|
|
false
|
|
return (
|
|
<div
|
|
key={message.id}
|
|
className={`flex w-full ${message.role === "user" ? "justify-end" : "justify-start"} ${isRestoredMessage ? "" : "animate-message-in"}`}
|
|
style={
|
|
isRestoredMessage
|
|
? undefined
|
|
: {
|
|
animationDelay: `${messageIndex * 50}ms`,
|
|
}
|
|
}
|
|
>
|
|
{message.role === "user" &&
|
|
userMessageText &&
|
|
!isEditing && (
|
|
<div className="flex items-center gap-1 self-center mr-2">
|
|
{/* Edit button - only on last user message */}
|
|
{onEditMessage &&
|
|
isLastUserMessage && (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setEditingMessageId(
|
|
message.id,
|
|
)
|
|
setEditText(
|
|
getUserOriginalText(
|
|
message,
|
|
),
|
|
)
|
|
}}
|
|
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted transition-colors"
|
|
title={
|
|
dict.chat
|
|
.editMessage
|
|
}
|
|
>
|
|
<Pencil className="h-3.5 w-3.5" />
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
copyMessageToClipboard(
|
|
message.id,
|
|
userMessageText,
|
|
)
|
|
}
|
|
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted transition-colors"
|
|
title={
|
|
copiedMessageId ===
|
|
message.id
|
|
? dict.chat.copied
|
|
: copyFailedMessageId ===
|
|
message.id
|
|
? dict.chat
|
|
.failedToCopy
|
|
: dict.chat
|
|
.copyResponse
|
|
}
|
|
>
|
|
{copiedMessageId ===
|
|
message.id ? (
|
|
<Check className="h-3.5 w-3.5 text-green-500" />
|
|
) : copyFailedMessageId ===
|
|
message.id ? (
|
|
<X className="h-3.5 w-3.5 text-red-500" />
|
|
) : (
|
|
<Copy className="h-3.5 w-3.5" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
)}
|
|
<div className="max-w-[85%] min-w-0">
|
|
{/* Reasoning blocks - displayed first for assistant messages */}
|
|
{message.role === "assistant" &&
|
|
message.parts?.map(
|
|
(part, partIndex) => {
|
|
if (part.type === "reasoning") {
|
|
const reasoningPart =
|
|
part as {
|
|
type: "reasoning"
|
|
text: string
|
|
}
|
|
const isLastPart =
|
|
partIndex ===
|
|
(message.parts
|
|
?.length ?? 0) -
|
|
1
|
|
const isLastMessage =
|
|
message.id ===
|
|
messages[
|
|
messages.length - 1
|
|
]?.id
|
|
const isStreamingReasoning =
|
|
status ===
|
|
"streaming" &&
|
|
isLastPart &&
|
|
isLastMessage
|
|
|
|
return (
|
|
<Reasoning
|
|
key={`${message.id}-reasoning-${partIndex}`}
|
|
className="w-full"
|
|
isStreaming={
|
|
isStreamingReasoning
|
|
}
|
|
defaultOpen={
|
|
!isRestoredMessage
|
|
}
|
|
>
|
|
<ReasoningTrigger />
|
|
<ReasoningContent>
|
|
{
|
|
reasoningPart.text
|
|
}
|
|
</ReasoningContent>
|
|
</Reasoning>
|
|
)
|
|
}
|
|
return null
|
|
},
|
|
)}
|
|
{/* Edit mode for user messages */}
|
|
{isEditing && message.role === "user" ? (
|
|
<div className="flex flex-col gap-2">
|
|
<textarea
|
|
ref={editTextareaRef}
|
|
value={editText}
|
|
onChange={(e) =>
|
|
setEditText(e.target.value)
|
|
}
|
|
className="w-full min-w-[300px] px-4 py-3 text-sm rounded-2xl border border-primary bg-background text-foreground resize-none focus:outline-none focus:ring-2 focus:ring-primary"
|
|
rows={Math.min(
|
|
editText.split("\n")
|
|
.length + 1,
|
|
6,
|
|
)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Escape") {
|
|
setEditingMessageId(
|
|
null,
|
|
)
|
|
setEditText("")
|
|
} else if (
|
|
e.key === "Enter" &&
|
|
(e.metaKey || e.ctrlKey)
|
|
) {
|
|
e.preventDefault()
|
|
if (
|
|
editText.trim() &&
|
|
onEditMessage
|
|
) {
|
|
onEditMessage(
|
|
messageIndex,
|
|
editText.trim(),
|
|
)
|
|
setEditingMessageId(
|
|
null,
|
|
)
|
|
setEditText("")
|
|
}
|
|
}
|
|
}}
|
|
/>
|
|
<div className="flex justify-end gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setEditingMessageId(
|
|
null,
|
|
)
|
|
setEditText("")
|
|
}}
|
|
className="px-3 py-1.5 text-xs rounded-lg bg-muted hover:bg-muted/80 transition-colors"
|
|
>
|
|
{dict.common.cancel}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (
|
|
editText.trim() &&
|
|
onEditMessage
|
|
) {
|
|
onEditMessage(
|
|
messageIndex,
|
|
editText.trim(),
|
|
)
|
|
setEditingMessageId(
|
|
null,
|
|
)
|
|
setEditText("")
|
|
}
|
|
}}
|
|
disabled={!editText.trim()}
|
|
className="px-3 py-1.5 text-xs rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
|
>
|
|
{dict.chat.saveAndSubmit}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
/* Render parts in order, grouping consecutive text/file parts into bubbles */
|
|
(() => {
|
|
const parts = message.parts || []
|
|
const groups: {
|
|
type: "content" | "tool"
|
|
parts: typeof parts
|
|
startIndex: number
|
|
}[] = []
|
|
|
|
parts.forEach((part, index) => {
|
|
const isToolPart =
|
|
part.type?.startsWith(
|
|
"tool-",
|
|
)
|
|
const isContentPart =
|
|
part.type === "text" ||
|
|
part.type === "file"
|
|
|
|
if (isToolPart) {
|
|
groups.push({
|
|
type: "tool",
|
|
parts: [part],
|
|
startIndex: index,
|
|
})
|
|
} else if (isContentPart) {
|
|
const lastGroup =
|
|
groups[
|
|
groups.length - 1
|
|
]
|
|
if (
|
|
lastGroup?.type ===
|
|
"content"
|
|
) {
|
|
lastGroup.parts.push(
|
|
part,
|
|
)
|
|
} else {
|
|
groups.push({
|
|
type: "content",
|
|
parts: [part],
|
|
startIndex: index,
|
|
})
|
|
}
|
|
}
|
|
})
|
|
|
|
return groups.map(
|
|
(group, groupIndex) => {
|
|
if (group.type === "tool") {
|
|
return renderToolPart(
|
|
group
|
|
.parts[0] as ToolPartLike,
|
|
)
|
|
}
|
|
|
|
// Content bubble
|
|
return (
|
|
<div
|
|
key={`${message.id}-content-${group.startIndex}`}
|
|
className={`px-4 py-3 text-sm leading-relaxed ${
|
|
message.role ===
|
|
"user"
|
|
? "bg-primary text-primary-foreground rounded-2xl rounded-br-md shadow-sm"
|
|
: message.role ===
|
|
"system"
|
|
? "bg-destructive/10 text-destructive border border-destructive/20 rounded-2xl rounded-bl-md"
|
|
: "bg-muted/60 text-foreground rounded-2xl rounded-bl-md"
|
|
} ${message.role === "user" && isLastUserMessage && onEditMessage ? "cursor-pointer hover:opacity-90 transition-opacity" : ""} ${groupIndex > 0 ? "mt-3" : ""}`}
|
|
role={
|
|
message.role ===
|
|
"user" &&
|
|
isLastUserMessage &&
|
|
onEditMessage
|
|
? "button"
|
|
: undefined
|
|
}
|
|
tabIndex={
|
|
message.role ===
|
|
"user" &&
|
|
isLastUserMessage &&
|
|
onEditMessage
|
|
? 0
|
|
: undefined
|
|
}
|
|
onClick={() => {
|
|
if (
|
|
message.role ===
|
|
"user" &&
|
|
isLastUserMessage &&
|
|
onEditMessage
|
|
) {
|
|
setEditingMessageId(
|
|
message.id,
|
|
)
|
|
setEditText(
|
|
getUserOriginalText(
|
|
message,
|
|
),
|
|
)
|
|
}
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (
|
|
(e.key ===
|
|
"Enter" ||
|
|
e.key ===
|
|
" ") &&
|
|
message.role ===
|
|
"user" &&
|
|
isLastUserMessage &&
|
|
onEditMessage
|
|
) {
|
|
e.preventDefault()
|
|
setEditingMessageId(
|
|
message.id,
|
|
)
|
|
setEditText(
|
|
getUserOriginalText(
|
|
message,
|
|
),
|
|
)
|
|
}
|
|
}}
|
|
title={
|
|
message.role ===
|
|
"user" &&
|
|
isLastUserMessage &&
|
|
onEditMessage
|
|
? dict.chat
|
|
.clickToEdit
|
|
: undefined
|
|
}
|
|
>
|
|
{group.parts.map(
|
|
(
|
|
part,
|
|
partIndex,
|
|
) => {
|
|
if (
|
|
part.type ===
|
|
"text"
|
|
) {
|
|
const textContent =
|
|
(
|
|
part as {
|
|
text: string
|
|
}
|
|
)
|
|
.text
|
|
const sections =
|
|
splitTextIntoFileSections(
|
|
textContent,
|
|
)
|
|
return (
|
|
<div
|
|
key={`${message.id}-text-${group.startIndex}-${partIndex}`}
|
|
className="space-y-2"
|
|
>
|
|
{sections.map(
|
|
(
|
|
section,
|
|
sectionIndex,
|
|
) => {
|
|
if (
|
|
section.type ===
|
|
"file"
|
|
) {
|
|
const pdfKey = `${message.id}-file-${partIndex}-${sectionIndex}`
|
|
const isExpanded =
|
|
expandedPdfSections[
|
|
pdfKey
|
|
] ??
|
|
false
|
|
const charDisplay =
|
|
section.charCount &&
|
|
section.charCount >=
|
|
1000
|
|
? `${(section.charCount / 1000).toFixed(1)}k`
|
|
: section.charCount
|
|
return (
|
|
<div
|
|
key={
|
|
pdfKey
|
|
}
|
|
className="rounded-lg border border-border/60 bg-muted/30 overflow-hidden"
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={(
|
|
e,
|
|
) => {
|
|
e.stopPropagation()
|
|
setExpandedPdfSections(
|
|
(
|
|
prev,
|
|
) => ({
|
|
...prev,
|
|
[pdfKey]:
|
|
!isExpanded,
|
|
}),
|
|
)
|
|
}}
|
|
className="w-full flex items-center justify-between px-3 py-2 hover:bg-muted/50 transition-colors"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
{section.fileType ===
|
|
"pdf" ? (
|
|
<FileText className="h-4 w-4 text-red-500" />
|
|
) : (
|
|
<FileCode className="h-4 w-4 text-blue-500" />
|
|
)}
|
|
<span className="text-xs font-medium">
|
|
{
|
|
section.filename
|
|
}
|
|
</span>
|
|
<span className="text-[10px] text-muted-foreground">
|
|
(
|
|
{
|
|
charDisplay
|
|
}{" "}
|
|
chars)
|
|
</span>
|
|
</div>
|
|
{isExpanded ? (
|
|
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
|
) : (
|
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
|
)}
|
|
</button>
|
|
{isExpanded && (
|
|
<div className="px-3 py-2 border-t border-border/40 max-h-48 overflow-y-auto bg-muted/30">
|
|
<pre className="text-xs whitespace-pre-wrap text-foreground/80">
|
|
{
|
|
section.content
|
|
}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
// Regular text section
|
|
return (
|
|
<div
|
|
key={`${message.id}-textsection-${partIndex}-${sectionIndex}`}
|
|
className={`prose prose-sm max-w-none break-words [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 ${
|
|
message.role ===
|
|
"user"
|
|
? "[&_*]:!text-primary-foreground prose-code:bg-white/20"
|
|
: "dark:prose-invert"
|
|
}`}
|
|
>
|
|
<ReactMarkdown>
|
|
{
|
|
section.content
|
|
}
|
|
</ReactMarkdown>
|
|
</div>
|
|
)
|
|
},
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
if (
|
|
part.type ===
|
|
"file"
|
|
) {
|
|
return (
|
|
<div
|
|
key={`${message.id}-file-${group.startIndex}-${partIndex}`}
|
|
className="mt-2"
|
|
>
|
|
<Image
|
|
src={
|
|
(
|
|
part as {
|
|
url: string
|
|
}
|
|
)
|
|
.url
|
|
}
|
|
width={
|
|
200
|
|
}
|
|
height={
|
|
200
|
|
}
|
|
alt={`Uploaded diagram or image for AI analysis`}
|
|
className="rounded-lg border border-white/20"
|
|
style={{
|
|
objectFit:
|
|
"contain",
|
|
}}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
return null
|
|
},
|
|
)}
|
|
</div>
|
|
)
|
|
},
|
|
)
|
|
})()
|
|
)}
|
|
{/* Action buttons for assistant messages */}
|
|
{message.role === "assistant" && (
|
|
<div className="flex items-center gap-1 mt-2">
|
|
{/* Copy button */}
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
copyMessageToClipboard(
|
|
message.id,
|
|
getMessageTextContent(
|
|
message,
|
|
),
|
|
)
|
|
}
|
|
className={`p-1.5 rounded-lg transition-colors ${
|
|
copiedMessageId ===
|
|
message.id
|
|
? "text-green-600 bg-green-100"
|
|
: "text-muted-foreground/60 hover:text-foreground hover:bg-muted"
|
|
}`}
|
|
title={
|
|
copiedMessageId ===
|
|
message.id
|
|
? dict.chat.copied
|
|
: dict.chat.copyResponse
|
|
}
|
|
>
|
|
{copiedMessageId ===
|
|
message.id ? (
|
|
<Check className="h-3.5 w-3.5" />
|
|
) : (
|
|
<Copy className="h-3.5 w-3.5" />
|
|
)}
|
|
</button>
|
|
{/* Regenerate button - only on last assistant message, not for cached examples */}
|
|
{onRegenerate &&
|
|
isLastAssistantMessage &&
|
|
!message.parts?.some((p: any) =>
|
|
p.toolCallId?.startsWith(
|
|
"cached-",
|
|
),
|
|
) && (
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
onRegenerate(
|
|
messageIndex,
|
|
)
|
|
}
|
|
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-foreground hover:bg-muted transition-colors"
|
|
title={
|
|
dict.chat.regenerate
|
|
}
|
|
>
|
|
<RotateCcw className="h-3.5 w-3.5" />
|
|
</button>
|
|
)}
|
|
{/* Divider */}
|
|
<div className="w-px h-4 bg-border mx-1" />
|
|
{/* Thumbs up */}
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
submitFeedback(
|
|
message.id,
|
|
"good",
|
|
)
|
|
}
|
|
className={`p-1.5 rounded-lg transition-colors ${
|
|
feedback[message.id] ===
|
|
"good"
|
|
? "text-green-600 bg-green-100"
|
|
: "text-muted-foreground/60 hover:text-green-600 hover:bg-green-50"
|
|
}`}
|
|
title={dict.chat.goodResponse}
|
|
>
|
|
<ThumbsUp className="h-3.5 w-3.5" />
|
|
</button>
|
|
{/* Thumbs down */}
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
submitFeedback(
|
|
message.id,
|
|
"bad",
|
|
)
|
|
}
|
|
className={`p-1.5 rounded-lg transition-colors ${
|
|
feedback[message.id] ===
|
|
"bad"
|
|
? "text-red-600 bg-red-100"
|
|
: "text-muted-foreground/60 hover:text-red-600 hover:bg-red-50"
|
|
}`}
|
|
title={dict.chat.badResponse}
|
|
>
|
|
<ThumbsDown className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
<div ref={messagesEndRef} />
|
|
|
|
{/* Delete Confirmation Dialog */}
|
|
<AlertDialog
|
|
open={deleteDialogOpen}
|
|
onOpenChange={setDeleteDialogOpen}
|
|
>
|
|
<AlertDialogContent className="max-w-sm">
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>
|
|
{dict.sessionHistory?.deleteTitle ||
|
|
"Delete this chat?"}
|
|
</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{dict.sessionHistory?.deleteDescription ||
|
|
"This will permanently delete this chat session and its diagram. This action cannot be undone."}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>
|
|
{dict.common.cancel}
|
|
</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={() => {
|
|
if (sessionToDelete && onDeleteSession) {
|
|
onDeleteSession(sessionToDelete)
|
|
}
|
|
setDeleteDialogOpen(false)
|
|
setSessionToDelete(null)
|
|
}}
|
|
className="border border-red-300 bg-red-50 text-red-700 hover:bg-red-100 hover:border-red-400"
|
|
>
|
|
{dict.common.delete}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</ScrollArea>
|
|
)
|
|
}
|