2025-03-23 12:48:31 +00:00
"use client" ;
2025-03-19 07:20:22 +00:00
2025-03-23 12:48:31 +00:00
import type React from "react" ;
import { useRef , useEffect , useState } from "react" ;
2025-12-04 22:56:59 +09:00
import { flushSync } from "react-dom" ;
2025-04-03 15:29:26 +00:00
import { FaGithub } from "react-icons/fa" ;
2025-12-05 18:07:25 +09:00
import { PanelRightClose , PanelRightOpen , CheckCircle } from "lucide-react" ;
feat: Separate SEO content to /about page (best practice)
- Remove header from main page for clean editor-only interface
- Create /app/about/page.tsx with comprehensive SEO content (1000+ words)
- Add About link next to 'Next-AI-Drawio' title in chat panel
- Add GitHub icon link to /about page navigation
- Update sitemap.ts to include /about page (priority: 0.8)
SEO improvements following industry best practices:
- Separate marketing content from app interface (Figma/Canva/Miro approach)
- Server-rendered /about page for optimal crawlability
- Clean URL structure for better internal linking
- Multiple indexable pages for broader keyword coverage
- Proper semantic HTML: H1, H2, H3, article, section tags
- 1000+ words of keyword-rich content
/about page includes:
- AI diagram generator overview with value proposition
- 6 detailed feature sections (AI creation, AWS diagrams, image replication, etc.)
- 3 popular use cases (AWS architecture, flowcharts, system design)
- Step-by-step usage guide (4 steps)
- Benefits section (save time, precision, free, privacy)
- Clear call-to-action with link back to editor
- GitHub link in navigation for social proof
This follows Google-approved architecture and avoids hidden content penalties.
2025-11-16 09:04:01 +09:00
import Link from "next/link" ;
2025-12-03 21:49:34 +09:00
import Image from "next/image" ;
2025-03-19 08:16:44 +00:00
2025-03-23 12:48:31 +00:00
import { useChat } from "@ai-sdk/react" ;
2025-08-31 12:54:14 +09:00
import { DefaultChatTransport } from "ai" ;
2025-03-23 12:48:31 +00:00
import { ChatInput } from "@/components/chat-input" ;
2025-03-25 02:58:11 +00:00
import { ChatMessageDisplay } from "./chat-message-display" ;
2025-03-26 00:30:00 +00:00
import { useDiagram } from "@/contexts/diagram-context" ;
2025-12-03 16:14:53 +09:00
import { replaceNodes , formatXML , validateMxCellStructure } from "@/lib/utils" ;
2025-11-15 12:09:32 +09:00
import { ButtonWithTooltip } from "@/components/button-with-tooltip" ;
2025-12-05 19:30:50 +09:00
import { Toaster } from "sonner" ;
2025-03-26 00:30:00 +00:00
2025-11-15 12:09:32 +09:00
interface ChatPanelProps {
isVisible : boolean ;
onToggleVisibility : ( ) = > void ;
}
2025-12-03 16:47:45 +09:00
export default function ChatPanel ( {
isVisible ,
onToggleVisibility ,
} : ChatPanelProps ) {
2025-03-26 00:30:00 +00:00
const {
loadDiagram : onDisplayChart ,
handleExport : onExport ,
2025-12-03 21:58:48 +09:00
handleExportWithoutHistory ,
2025-03-26 00:30:00 +00:00
resolverRef ,
2025-04-03 15:10:53 +00:00
chartXML ,
2025-03-27 08:09:22 +00:00
clearDiagram ,
2025-03-26 00:30:00 +00:00
} = useDiagram ( ) ;
2025-03-19 08:16:44 +00:00
2025-12-03 21:58:48 +09:00
const onFetchChart = ( saveToHistory = true ) = > {
2025-11-10 10:28:37 +09:00
return Promise . race ( [
new Promise < string > ( ( resolve ) = > {
if ( resolverRef && "current" in resolverRef ) {
resolverRef . current = resolve ;
}
2025-12-03 21:58:48 +09:00
if ( saveToHistory ) {
onExport ( ) ;
} else {
handleExportWithoutHistory ( ) ;
}
2025-11-10 10:28:37 +09:00
} ) ,
new Promise < string > ( ( _ , reject ) = >
2025-12-03 16:47:45 +09:00
setTimeout (
( ) = >
reject (
new Error ( "Chart export timed out after 10 seconds" )
) ,
10000
)
) ,
2025-11-10 10:28:37 +09:00
] ) ;
2025-03-26 00:30:00 +00:00
} ;
2025-03-25 04:23:38 +00:00
2025-03-27 08:02:03 +00:00
const [ files , setFiles ] = useState < File [ ] > ( [ ] ) ;
2025-03-23 13:54:21 +00:00
const [ showHistory , setShowHistory ] = useState ( false ) ;
2025-08-31 12:54:14 +09:00
const [ input , setInput ] = useState ( "" ) ;
2025-12-05 14:23:47 +09:00
const [ streamingError , setStreamingError ] = useState < Error | null > ( null ) ;
2025-08-31 12:54:14 +09:00
2025-12-04 22:56:59 +09:00
// Store XML snapshots for each user message (keyed by message index)
const xmlSnapshotsRef = useRef < Map < number , string > > ( new Map ( ) ) ;
2025-12-05 00:47:27 +09:00
// Ref to track latest chartXML for use in callbacks (avoids stale closure)
const chartXMLRef = useRef ( chartXML ) ;
useEffect ( ( ) = > {
chartXMLRef . current = chartXML ;
} , [ chartXML ] ) ;
2025-12-05 16:46:17 +09:00
const {
messages ,
sendMessage ,
addToolResult ,
status ,
error ,
setMessages ,
stop ,
} = useChat ( {
transport : new DefaultChatTransport ( {
api : "/api/chat" ,
} ) ,
async onToolCall ( { toolCall } ) {
if ( toolCall . toolName === "display_diagram" ) {
const { xml } = toolCall . input as { xml : string } ;
const validationError = validateMxCellStructure ( xml ) ;
if ( validationError ) {
addToolResult ( {
tool : "display_diagram" ,
toolCallId : toolCall.toolCallId ,
output : validationError ,
} ) ;
} else {
addToolResult ( {
tool : "display_diagram" ,
toolCallId : toolCall.toolCallId ,
output : "Successfully displayed the diagram." ,
} ) ;
}
} else if ( toolCall . toolName === "edit_diagram" ) {
const { edits } = toolCall . input as {
edits : Array < { search : string ; replace : string } > ;
} ;
let currentXml = "" ;
try {
console . log ( "[edit_diagram] Starting..." ) ;
// Use chartXML from ref directly - more reliable than export
// especially on Vercel where DrawIO iframe may have latency issues
// Using ref to avoid stale closure in callback
const cachedXML = chartXMLRef . current ;
if ( cachedXML ) {
currentXml = cachedXML ;
console . log (
"[edit_diagram] Using cached chartXML, length:" ,
currentXml . length
) ;
2025-12-03 16:14:53 +09:00
} else {
2025-12-05 16:46:17 +09:00
// Fallback to export only if no cached XML
console . log (
"[edit_diagram] No cached XML, fetching from DrawIO..."
) ;
currentXml = await onFetchChart ( false ) ;
console . log (
"[edit_diagram] Got XML from export, length:" ,
currentXml . length
) ;
2025-12-03 16:14:53 +09:00
}
2025-11-10 11:27:25 +09:00
2025-12-05 16:46:17 +09:00
const { replaceXMLParts } = await import ( "@/lib/utils" ) ;
const editedXml = replaceXMLParts ( currentXml , edits ) ;
onDisplayChart ( editedXml ) ;
addToolResult ( {
tool : "edit_diagram" ,
toolCallId : toolCall.toolCallId ,
output : ` Successfully applied ${ edits . length } edit(s) to the diagram. ` ,
} ) ;
console . log ( "[edit_diagram] Success" ) ;
} catch ( error ) {
console . error ( "[edit_diagram] Failed:" , error ) ;
2025-11-10 11:27:25 +09:00
2025-12-05 16:46:17 +09:00
const errorMessage =
error instanceof Error ? error.message : String ( error ) ;
addToolResult ( {
tool : "edit_diagram" ,
toolCallId : toolCall.toolCallId ,
output : ` Edit failed: ${ errorMessage }
2025-11-13 22:27:11 +09:00
Current diagram XML :
\ ` \` \` xml
2025-12-05 00:43:21 +09:00
$ { currentXml || "No XML available" }
2025-11-13 22:27:11 +09:00
\ ` \` \`
Please retry with an adjusted search pattern or use display_diagram if retries are exhausted . ` ,
2025-12-05 16:46:17 +09:00
} ) ;
2025-08-31 12:54:14 +09:00
}
2025-12-05 16:46:17 +09:00
}
} ,
onError : ( error ) = > {
console . error ( "Chat error:" , error ) ;
setStreamingError ( error ) ;
} ,
} ) ;
2025-12-03 21:49:34 +09:00
2025-12-05 14:23:47 +09:00
// Streaming timeout detection - detects when stream stalls mid-response (e.g., Bedrock 503)
// This catches cases where onError doesn't fire because headers were already sent
const lastMessageCountRef = useRef ( 0 ) ;
const lastMessagePartsRef = useRef ( 0 ) ;
useEffect ( ( ) = > {
2025-12-05 16:22:38 +09:00
// Clear streaming error when status changes to ready
2025-12-05 14:23:47 +09:00
if ( status === "ready" ) {
setStreamingError ( null ) ;
lastMessageCountRef . current = 0 ;
lastMessagePartsRef . current = 0 ;
return ;
}
if ( status !== "streaming" ) return ;
const STALL_TIMEOUT_MS = 15000 ; // 15 seconds without any update
// Capture current state BEFORE setting timeout
// This way we compare against values at the time timeout was set
const currentPartsCount = messages . reduce (
( acc , msg ) = > acc + ( msg . parts ? . length || 0 ) ,
0
) ;
const capturedMessageCount = messages . length ;
const capturedPartsCount = currentPartsCount ;
// Update refs immediately so next effect run has fresh values
lastMessageCountRef . current = messages . length ;
lastMessagePartsRef . current = currentPartsCount ;
const timeoutId = setTimeout ( ( ) = > {
// Re-count parts at timeout time
const newPartsCount = messages . reduce (
( acc , msg ) = > acc + ( msg . parts ? . length || 0 ) ,
0
) ;
// If no change since timeout was set, stream has stalled
if (
messages . length === capturedMessageCount &&
newPartsCount === capturedPartsCount
) {
2025-12-05 16:46:17 +09:00
console . error (
"[Streaming Timeout] No activity for 15s - forcing error state"
) ;
2025-12-05 14:23:47 +09:00
setStreamingError (
2025-12-05 16:46:17 +09:00
new Error (
"Connection lost. The AI service may be temporarily unavailable. Please try again."
)
2025-12-05 14:23:47 +09:00
) ;
stop ( ) ; // Allow user to retry by transitioning status to "ready"
}
} , STALL_TIMEOUT_MS ) ;
return ( ) = > clearTimeout ( timeoutId ) ;
} , [ status , messages , stop ] ) ;
2025-03-23 12:48:31 +00:00
const messagesEndRef = useRef < HTMLDivElement > ( null ) ;
2025-12-03 21:49:34 +09:00
2025-03-19 07:20:22 +00:00
useEffect ( ( ) = > {
if ( messagesEndRef . current ) {
2025-03-23 12:48:31 +00:00
messagesEndRef . current . scrollIntoView ( { behavior : "smooth" } ) ;
2025-03-19 07:20:22 +00:00
}
2025-03-23 12:48:31 +00:00
} , [ messages ] ) ;
2025-03-19 07:20:22 +00:00
2025-03-22 16:03:03 +00:00
const onFormSubmit = async ( e : React.FormEvent < HTMLFormElement > ) = > {
2025-03-23 12:48:31 +00:00
e . preventDefault ( ) ;
2025-12-05 14:23:47 +09:00
// Allow retry if there's a streaming error (workaround for stop() not transitioning status)
2025-12-05 16:46:17 +09:00
const isProcessing =
( status === "streaming" || status === "submitted" ) &&
! streamingError ;
2025-11-15 14:29:18 +09:00
if ( input . trim ( ) && ! isProcessing ) {
2025-12-05 14:23:47 +09:00
// Clear any previous streaming error before starting new request
setStreamingError ( null ) ;
2025-03-22 16:03:03 +00:00
try {
2025-08-31 20:52:04 +09:00
let chartXml = await onFetchChart ( ) ;
chartXml = formatXML ( chartXml ) ;
2025-08-31 12:54:14 +09:00
2025-12-05 00:54:35 +09:00
// 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 ;
2025-08-31 12:54:14 +09:00
const parts : any [ ] = [ { type : "text" , text : input } ] ;
if ( files . length > 0 ) {
for ( const file of files ) {
const reader = new FileReader ( ) ;
const dataUrl = await new Promise < string > ( ( resolve ) = > {
reader . onload = ( ) = >
resolve ( reader . result as string ) ;
reader . readAsDataURL ( file ) ;
} ) ;
parts . push ( {
type : "file" ,
url : dataUrl ,
mediaType : file.type ,
} ) ;
}
}
2025-12-04 22:56:59 +09:00
// Save XML snapshot for this message (will be at index = current messages.length)
const messageIndex = messages . length ;
xmlSnapshotsRef . current . set ( messageIndex , chartXml ) ;
2025-08-31 12:54:14 +09:00
sendMessage (
{ parts } ,
{
body : {
xml : chartXml ,
} ,
}
) ;
setInput ( "" ) ;
2025-03-27 08:02:03 +00:00
setFiles ( [ ] ) ;
2025-03-22 16:03:03 +00:00
} catch ( error ) {
console . error ( "Error fetching chart data:" , error ) ;
}
2025-03-19 07:20:22 +00:00
}
2025-03-23 12:48:31 +00:00
} ;
2025-03-19 07:20:22 +00:00
2025-08-31 12:54:14 +09:00
const handleInputChange = (
e : React.ChangeEvent < HTMLInputElement | HTMLTextAreaElement >
) = > {
setInput ( e . target . value ) ;
} ;
2025-03-27 08:02:03 +00:00
const handleFileChange = ( newFiles : File [ ] ) = > {
2025-03-23 11:03:25 +00:00
setFiles ( newFiles ) ;
2025-03-23 12:48:31 +00:00
} ;
2025-03-23 13:54:21 +00:00
2025-12-04 22:56:59 +09:00
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 ;
2025-12-05 16:46:17 +09:00
while (
userMessageIndex >= 0 &&
messages [ userMessageIndex ] . role !== "user"
) {
2025-12-04 22:56:59 +09:00
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 ) {
2025-12-05 16:46:17 +09:00
console . error (
"No saved XML snapshot for message index:" ,
userMessageIndex
) ;
2025-12-04 22:56:59 +09:00
return ;
}
// Restore the diagram to the saved state
onDisplayChart ( savedXml ) ;
2025-12-05 00:54:35 +09:00
// Update ref directly to ensure edit_diagram has the correct XML
chartXMLRef . current = savedXml ;
2025-12-04 22:56:59 +09:00
// Clean up snapshots for messages after the user message (they will be removed)
for ( const key of xmlSnapshotsRef . current . keys ( ) ) {
if ( key > userMessageIndex ) {
xmlSnapshotsRef . current . delete ( key ) ;
}
}
// 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
sendMessage (
{ parts : userParts } ,
{
body : {
xml : savedXml ,
} ,
}
) ;
} ;
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 ) {
2025-12-05 16:46:17 +09:00
console . error (
"No saved XML snapshot for message index:" ,
messageIndex
) ;
2025-12-04 22:56:59 +09:00
return ;
}
// Restore the diagram to the saved state
onDisplayChart ( savedXml ) ;
2025-12-05 00:54:35 +09:00
// Update ref directly to ensure edit_diagram has the correct XML
chartXMLRef . current = savedXml ;
2025-12-04 22:56:59 +09:00
// Clean up snapshots for messages after the user message (they will be removed)
for ( const key of xmlSnapshotsRef . current . keys ( ) ) {
if ( key > messageIndex ) {
xmlSnapshotsRef . current . delete ( key ) ;
}
}
// 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
sendMessage (
{ parts : newParts } ,
{
body : {
xml : savedXml ,
} ,
}
) ;
} ;
2025-12-03 21:49:34 +09:00
// Collapsed view
2025-11-15 12:09:32 +09:00
if ( ! isVisible ) {
return (
2025-12-03 21:49:34 +09:00
< div className = "h-full flex flex-col items-center pt-4 bg-card border border-border/30 rounded-xl" >
2025-11-15 12:09:32 +09:00
< ButtonWithTooltip
tooltipContent = "Show chat panel (Ctrl+B)"
variant = "ghost"
size = "icon"
onClick = { onToggleVisibility }
2025-12-03 21:49:34 +09:00
className = "hover:bg-accent transition-colors"
2025-11-15 12:09:32 +09:00
>
2025-12-03 21:49:34 +09:00
< PanelRightOpen className = "h-5 w-5 text-muted-foreground" / >
2025-11-15 12:09:32 +09:00
< / ButtonWithTooltip >
< div
2025-12-03 21:49:34 +09:00
className = "text-sm font-medium text-muted-foreground mt-8 tracking-wide"
2025-12-03 16:47:45 +09:00
style = { {
writingMode : "vertical-rl" ,
transform : "rotate(180deg)" ,
} }
2025-11-15 12:09:32 +09:00
>
2025-12-03 21:49:34 +09:00
AI Chat
2025-11-15 12:09:32 +09:00
< / div >
2025-12-03 21:49:34 +09:00
< / div >
2025-11-15 12:09:32 +09:00
) ;
}
2025-12-03 21:49:34 +09:00
// Full view
2025-03-19 07:20:22 +00:00
return (
2025-12-05 19:30:50 +09:00
< 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 style = { { position : "absolute" } } / >
2025-12-03 21:49:34 +09:00
{ /* Header */ }
< header className = "px-5 py-4 border-b border-border/50" >
< div className = "flex items-center justify-between" >
< div className = "flex items-center gap-3" >
< div className = "flex items-center gap-2" >
< Image
src = "/favicon.ico"
alt = "Next AI Drawio"
width = { 28 }
height = { 28 }
className = "rounded"
/ >
< h1 className = "text-base font-semibold tracking-tight whitespace-nowrap" >
Next AI Drawio
< / h1 >
< / div >
< Link
href = "/about"
className = "text-sm text-muted-foreground hover:text-foreground transition-colors ml-2"
>
About
< / Link >
2025-12-05 16:46:17 +09:00
< ButtonWithTooltip
2025-12-05 18:07:25 +09:00
tooltipContent = "Recent generation failures were caused by our AI provider's infrastructure issue, not the app code. After extensive debugging, I've switched providers and observed 30+ minutes of stability. If issues persist, please report on GitHub."
2025-12-05 16:46:17 +09:00
variant = "ghost"
size = "icon"
2025-12-05 18:07:25 +09:00
className = "h-6 w-6 text-green-500 hover:text-green-600"
2025-12-05 16:46:17 +09:00
>
2025-12-05 18:07:25 +09:00
< CheckCircle className = "h-4 w-4" / >
2025-12-05 16:46:17 +09:00
< / ButtonWithTooltip >
2025-12-03 21:49:34 +09:00
< / div >
< div className = "flex items-center gap-1" >
< a
href = "https://github.com/DayuanJiang/next-ai-draw-io"
target = "_blank"
rel = "noopener noreferrer"
className = "p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
< FaGithub className = "w-5 h-5" / >
< / a >
< ButtonWithTooltip
tooltipContent = "Hide chat panel (Ctrl+B)"
variant = "ghost"
size = "icon"
onClick = { onToggleVisibility }
className = "hover:bg-accent"
>
< PanelRightClose className = "h-5 w-5 text-muted-foreground" / >
< / ButtonWithTooltip >
< / div >
feat: Separate SEO content to /about page (best practice)
- Remove header from main page for clean editor-only interface
- Create /app/about/page.tsx with comprehensive SEO content (1000+ words)
- Add About link next to 'Next-AI-Drawio' title in chat panel
- Add GitHub icon link to /about page navigation
- Update sitemap.ts to include /about page (priority: 0.8)
SEO improvements following industry best practices:
- Separate marketing content from app interface (Figma/Canva/Miro approach)
- Server-rendered /about page for optimal crawlability
- Clean URL structure for better internal linking
- Multiple indexable pages for broader keyword coverage
- Proper semantic HTML: H1, H2, H3, article, section tags
- 1000+ words of keyword-rich content
/about page includes:
- AI diagram generator overview with value proposition
- 6 detailed feature sections (AI creation, AWS diagrams, image replication, etc.)
- 3 popular use cases (AWS architecture, flowcharts, system design)
- Step-by-step usage guide (4 steps)
- Benefits section (save time, precision, free, privacy)
- Clear call-to-action with link back to editor
- GitHub link in navigation for social proof
This follows Google-approved architecture and avoids hidden content penalties.
2025-11-16 09:04:01 +09:00
< / div >
2025-12-03 21:49:34 +09:00
< / header >
{ /* Messages */ }
< main className = "flex-1 overflow-hidden" >
2025-03-25 02:58:11 +00:00
< ChatMessageDisplay
messages = { messages }
2025-12-05 14:23:47 +09:00
error = { error || streamingError }
2025-03-25 02:58:11 +00:00
setInput = { setInput }
setFiles = { handleFileChange }
2025-12-04 22:56:59 +09:00
onRegenerate = { handleRegenerate }
onEditMessage = { handleEditMessage }
2025-03-25 02:58:11 +00:00
/ >
2025-12-03 21:49:34 +09:00
< / main >
2025-03-23 13:15:28 +00:00
2025-12-03 21:49:34 +09:00
{ /* Input */ }
< footer className = "p-4 border-t border-border/50 bg-card/50" >
2025-03-22 13:15:51 +00:00
< ChatInput
input = { input }
2025-03-22 13:26:14 +00:00
status = { status }
2025-03-22 13:15:51 +00:00
onSubmit = { onFormSubmit }
onChange = { handleInputChange }
2025-03-27 08:09:22 +00:00
onClearChat = { ( ) = > {
setMessages ( [ ] ) ;
clearDiagram ( ) ;
2025-12-04 22:56:59 +09:00
xmlSnapshotsRef . current . clear ( ) ;
2025-03-27 08:09:22 +00:00
} }
2025-03-23 11:03:25 +00:00
files = { files }
onFileChange = { handleFileChange }
2025-03-23 13:54:21 +00:00
showHistory = { showHistory }
2025-03-27 07:48:19 +00:00
onToggleHistory = { setShowHistory }
2025-12-05 16:22:38 +09:00
error = { error || streamingError }
2025-03-22 13:15:51 +00:00
/ >
2025-12-03 21:49:34 +09:00
< / footer >
< / div >
2025-03-23 12:48:31 +00:00
) ;
2025-03-19 07:20:22 +00:00
}