2026-03-14 15:52:27 +08:00
import { useState , useRef , useEffect } from "react" ;
import { Button } from "@/components/ui/button" ;
import { Input } from "@/components/ui/input" ;
import { ScrollArea } from "@/components/ui/scroll-area" ;
2026-03-15 00:10:01 +08:00
import { User , Loader2 , Sparkles , Search , ArrowUp , ChevronDown , Table , Paperclip , Check , X , File as FileIcon } from "lucide-react" ;
2026-03-14 15:52:27 +08:00
import { api } from "@/lib/api" ;
2026-03-15 01:29:36 +08:00
import { type ChartSpec , useVisualizationStore } from "@/store/visualizationStore" ;
2026-03-14 20:58:38 +08:00
import { Popover , PopoverContent , PopoverTrigger } from "@/components/ui/popover" ;
import { Command , CommandEmpty , CommandGroup , CommandInput , CommandItem , CommandList } from "@/components/ui/command" ;
import { cn } from "@/lib/utils" ;
2026-03-14 22:15:38 +08:00
import ReactMarkdown from 'react-markdown' ;
import remarkGfm from 'remark-gfm' ;
import rehypeRaw from 'rehype-raw' ;
2026-03-14 22:25:01 +08:00
import { useLocation } from "react-router-dom" ;
2026-03-14 15:52:27 +08:00
interface Message {
id : string ;
role : 'user' | 'assistant' ;
content : string ;
2026-03-14 23:15:41 +08:00
awaitingFirstToken? : boolean ;
2026-03-14 15:52:27 +08:00
}
2026-03-14 20:58:38 +08:00
interface ModelConfig {
id : string ;
name? : string ;
model : string ;
provider : string ;
is_active : boolean ;
}
2026-03-14 22:25:01 +08:00
interface SessionData {
key : string ;
messages : Array < {
role : string ;
content : string ;
[ key : string ] : any ;
} > ;
}
2026-03-14 15:52:27 +08:00
export function ChatInterface() {
2026-03-14 22:25:01 +08:00
const [ messages , setMessages ] = useState < Message [] >([]);
2026-03-14 15:52:27 +08:00
const [ input , setInput ] = useState ( "" );
2026-03-14 22:00:36 +08:00
const [ selectedCapability , setSelectedCapability ] = useState < string >( "智能问答" );
2026-03-14 15:52:27 +08:00
const selectedDataSource = "postgres-main" ;
const [ isLoading , setIsLoading ] = useState ( false );
const scrollRef = useRef < HTMLDivElement >( null );
const { setVisualization , setLoading : setVizLoading , setError : setVizError } = useVisualizationStore ();
2026-03-14 22:25:01 +08:00
const location = useLocation ();
2026-03-14 15:52:27 +08:00
2026-03-14 20:58:38 +08:00
// Model selection state
const [ models , setModels ] = useState < ModelConfig [] >([]);
const [ selectedModelId , setSelectedModelId ] = useState < string >( "" );
const [ modelOpen , setModelOpen ] = useState ( false );
2026-03-14 22:25:01 +08:00
// Try to parse active session from URL query
const queryParams = new URLSearchParams ( location . search );
const activeSessionKey = queryParams . get ( "session" ) || "api:default" ;
2026-03-15 00:10:01 +08:00
// File upload state
const [ attachedFile , setAttachedFile ] = useState < { filename : string ; url : string ; columns? : string []; summary? : string } | null > ( null );
const [ isUploading , setIsUploading ] = useState ( false );
const fileInputRef = useRef < HTMLInputElement >( null );
2026-03-14 20:58:38 +08:00
useEffect (() => {
fetchModels ();
}, []);
2026-03-14 22:25:01 +08:00
useEffect (() => {
const fetchSessionData = async () => {
setIsLoading ( true );
try {
const data = await api . get < SessionData >( `/nanobot/sessions/ ${ activeSessionKey } ` );
if ( data . messages && data . messages . length > 0 ) {
const formattedMessages = data . messages . map (( m , idx ) => ({
id : ` ${ Date . now () } - ${ idx } ` ,
role : m.role as 'user' | 'assistant' ,
content : m.content
}));
setMessages ( formattedMessages );
} else {
setMessages ([]);
}
} catch ( e ) {
console . error ( "Failed to fetch session messages" , e );
setMessages ([]);
} finally {
setIsLoading ( false );
}
};
fetchSessionData ();
}, [ activeSessionKey ]);
2026-03-14 20:58:38 +08:00
const fetchModels = async () => {
try {
const data = await api . get < ModelConfig [] >( "/api/v1/llm" );
setModels ( data );
// Set default model if available
const active = data . find ( m => m . is_active );
if ( active ) {
setSelectedModelId ( active . id );
} else if ( data . length > 0 ) {
setSelectedModelId ( data [ 0 ]. id );
}
} catch ( e ) {
console . error ( "Failed to fetch models" , e );
}
};
const currentModel = models . find ( m => m . id === selectedModelId );
2026-03-14 15:52:27 +08:00
const capabilities = [
{ icon : Sparkles , label : "智能问答" , color : "text-purple-500" , bg : "bg-purple-50" },
{ icon : Table , label : "表格问答" , color : "text-orange-500" , bg : "bg-orange-50" },
{ icon : Search , label : "深度问数" , color : "text-blue-500" , bg : "bg-blue-50" },
];
2026-03-15 00:10:01 +08:00
const handleFileUpload = async ( e : React.ChangeEvent < HTMLInputElement >) => {
const file = e . target . files ? .[ 0 ];
if ( ! file ) return ;
setIsUploading ( true );
const formData = new FormData ();
formData . append ( "file" , file );
try {
const response = await fetch ( "/api/v1/upload/file" , {
method : "POST" ,
body : formData ,
headers : {
...( localStorage . getItem ( "token" ) ? { Authorization : `Bearer ${ localStorage . getItem ( "token" ) } ` } : {}),
}
});
if ( ! response . ok ) {
throw new Error ( "Upload failed" );
}
const data = await response . json ();
setAttachedFile ({
filename : file.name ,
url : data.url ,
columns : data.columns ,
summary : data.summary ,
});
} catch ( error ) {
console . error ( "File upload error:" , error );
// Could show a toast notification here
} finally {
setIsUploading ( false );
if ( fileInputRef . current ) {
fileInputRef . current . value = "" ;
}
}
};
2026-03-14 15:52:27 +08:00
useEffect (() => {
if ( scrollRef . current ) {
scrollRef . current . scrollIntoView ({ behavior : 'smooth' });
}
}, [ messages ]);
const handleSend = async () => {
if ( ! input . trim () || isLoading ) return ;
const newMessage : Message = { id : Date.now (). toString (), role : 'user' , content : input };
setMessages ( prev => [... prev , newMessage ]);
setInput ( "" );
2026-03-15 00:10:01 +08:00
let messagePayload = newMessage . content ;
if ( attachedFile ) {
messagePayload = `[用户上传了文件: ${ attachedFile . filename } ] \ n[文件内容摘要: ${ attachedFile . summary || "无" } ] \ n[数据列: ${ attachedFile . columns ? . join ( ", " ) || "无" } ] \ n[文件下载链接: ${ attachedFile . url } ] \ n \ n ${ newMessage . content } ` ;
setAttachedFile ( null );
}
2026-03-14 15:52:27 +08:00
setIsLoading ( true );
setVizLoading ( true );
setVizError ( null );
try {
2026-03-14 22:00:36 +08:00
if ( selectedCapability === "智能问答" ) {
const assistantId = ( Date . now () + 1 ). toString ();
setMessages ( prev => [... prev , {
id : assistantId ,
role : "assistant" ,
2026-03-14 23:15:41 +08:00
content : "" ,
awaitingFirstToken : true
2026-03-14 22:00:36 +08:00
}]);
const token = localStorage . getItem ( "token" );
2026-03-14 23:15:41 +08:00
const effectiveModelId = selectedModelId || currentModel ? . id || "" ;
2026-03-14 22:00:36 +08:00
const response = await fetch ( "/nanobot/chat/stream" , {
method : "POST" ,
headers : {
"Content-Type" : "application/json" ,
...( token ? { Authorization : `Bearer ${ token } ` } : {}),
},
body : JSON.stringify ({
2026-03-15 00:10:01 +08:00
message : messagePayload ,
2026-03-14 22:25:01 +08:00
session_id : activeSessionKey ,
2026-03-14 23:15:41 +08:00
model_id : effectiveModelId ,
2026-03-14 22:25:01 +08:00
}),
2026-03-14 22:00:36 +08:00
});
if ( ! response . ok || ! response . body ) {
const err = await response . json (). catch (() => ({}));
throw new Error ( err . detail || "流式响应失败" );
}
const reader = response . body . getReader ();
const decoder = new TextDecoder ( "utf-8" );
let buffer = "" ;
let streamedText = "" ;
while ( true ) {
const { done , value } = await reader . read ();
if ( done ) break ;
buffer += decoder . decode ( value , { stream : true });
const events = buffer . split ( "\n\n" );
buffer = events . pop () || "" ;
for ( const eventBlock of events ) {
const line = eventBlock
. split ( "\n" )
. find (( item ) => item . startsWith ( "data:" ));
if ( ! line ) continue ;
const payloadText = line . slice ( 5 ). trim ();
if ( ! payloadText ) continue ;
const payload = JSON . parse ( payloadText ) as { type : string ; content? : string };
if ( payload . type === "delta" && payload . content ) {
2026-03-14 22:07:40 +08:00
streamedText = ` ${ streamedText }${ payload . content } ` ;
2026-03-14 22:00:36 +08:00
setMessages (( prev ) =>
prev . map (( msg ) =>
2026-03-14 23:15:41 +08:00
msg . id === assistantId ? { ... msg , content : streamedText , awaitingFirstToken : false } : msg
2026-03-14 22:00:36 +08:00
)
);
}
if ( payload . type === "final" && payload . content ) {
streamedText = payload . content ;
setMessages (( prev ) =>
prev . map (( msg ) =>
2026-03-14 23:15:41 +08:00
msg . id === assistantId ? { ... msg , content : payload.content || "" , awaitingFirstToken : false } : msg
2026-03-14 22:00:36 +08:00
)
);
}
if ( payload . type === "error" ) {
throw new Error ( payload . content || "流式响应错误" );
}
}
}
2026-03-14 23:15:41 +08:00
if ( ! streamedText ) {
const fallback = await api . post < { response : string } > ( "/nanobot/chat" , {
2026-03-15 00:10:01 +08:00
message : messagePayload ,
2026-03-14 23:15:41 +08:00
session_id : activeSessionKey ,
model_id : effectiveModelId ,
});
setMessages (( prev ) =>
prev . map (( msg ) =>
msg . id === assistantId ? { ... msg , content : fallback.response || "暂无回复" , awaitingFirstToken : false } : msg
)
);
}
2026-03-14 22:00:36 +08:00
} else {
// Fallback to existing NL2SQL or other skills (e.g. for "表格问答" or "深度问数")
2026-03-14 15:52:27 +08:00
const source = selectedDataSource . split ( '-' )[ 0 ]; // postgres-main -> postgres
2026-03-15 01:29:36 +08:00
const response = await api . post < {
sql? : string ,
result? : unknown ,
error? : string ,
chart ?: { chart_spec : ChartSpec , reasoning : string , can_visualize : boolean }
} > ( '/api/v1/agent/nl2sql' , {
2026-03-15 00:10:01 +08:00
query : messagePayload ,
2026-03-14 20:58:38 +08:00
source : source ,
2026-03-14 22:25:01 +08:00
session_id : activeSessionKey ,
2026-03-14 22:00:36 +08:00
model_id : selectedModelId
2026-03-14 15:52:27 +08:00
});
if ( response . error ) {
setMessages ( prev => [... prev , {
id : ( Date . now () + 1 ). toString (),
role : 'assistant' ,
content : `Error: ${ response . error } `
}]);
setVizError ( response . error );
} else {
const rows = Array . isArray ( response . result ) ? response . result : [];
const sql = typeof response . sql === "string" ? response . sql : "" ;
setMessages ( prev => [... prev , {
id : ( Date . now () + 1 ). toString (),
role : 'assistant' ,
2026-03-15 01:29:36 +08:00
content : `I've generated a SQL query and fetched ${ rows . length } rows for you. Check the visualization panel. ${ response . chart ? . reasoning ? ` \ n \ nVisualization reasoning: ${ response . chart . reasoning } ` : '' } `
2026-03-14 15:52:27 +08:00
}]);
2026-03-15 01:29:36 +08:00
setVisualization ( rows , sql , response . chart ? . chart_spec );
2026-03-14 15:52:27 +08:00
}
}
} catch ( error : any ) {
setMessages ( prev => [... prev , {
id : ( Date . now () + 1 ). toString (),
role : 'assistant' ,
content : `Sorry, something went wrong: ${ error . message } `
}]);
setVizError ( error . message );
} finally {
setIsLoading ( false );
setVizLoading ( false );
2026-03-14 23:15:41 +08:00
window . dispatchEvent ( new Event ( "nanobot:sessions-changed" ));
2026-03-14 15:52:27 +08:00
}
};
return (
< div className = "h-full bg-white relative flex flex-col" >
{ /* Top Bar */ }
2026-03-14 20:58:38 +08:00
< div className = "absolute top-0 left-0 w-full px-6 py-4 z-10 flex justify-between items-center" >
< Popover open = { modelOpen } onOpenChange = { setModelOpen }>
2026-03-14 22:28:25 +08:00
< PopoverTrigger className = "w-[200px] flex justify-between items-center bg-white/80 backdrop-blur-sm rounded-md px-3 py-2 text-sm hover:bg-zinc-50 hover:text-zinc-900 text-zinc-700 font-medium transition-all outline-none border-none shadow-none ring-0" >
2026-03-14 20:58:38 +08:00
{ currentModel ? ( currentModel . name || currentModel . model ) : "选择模型..." }
< ChevronDown className = "ml-2 h-4 w-4 shrink-0 opacity-50" />
</ PopoverTrigger >
< PopoverContent className = "w-[240px] p-0" align = "start" >
< Command >
< CommandInput placeholder = "搜索模型..." className = "h-9" />
< CommandList >
< CommandEmpty > 未找到模型 </ CommandEmpty >
< CommandGroup heading = "可用模型" >
{ models . map (( model ) => (
< CommandItem
key = { model . id }
value = { model . name || model . model }
onSelect = {() => {
setSelectedModelId ( model . id );
setModelOpen ( false );
}}
className = "cursor-pointer"
>
< div className = "flex flex-col" >
< span className = "font-medium" >{ model . name || model . model }</ span >
< span className = "text-xs text-zinc-400" >{ model . provider }</ span >
</ div >
< Check
className = { cn (
"ml-auto h-4 w-4" ,
selectedModelId === model . id ? "opacity-100" : "opacity-0"
)}
/>
</ CommandItem >
))}
</ CommandGroup >
</ CommandList >
</ Command >
</ PopoverContent >
</ Popover >
2026-03-14 15:52:27 +08:00
</ div >
2026-03-14 22:15:38 +08:00
< ScrollArea className = "flex-1 h-[calc(100vh-100px)]" >
2026-03-15 00:10:01 +08:00
{ /* Hidden file input available in all states */ }
< input
type = "file"
ref = { fileInputRef }
className = "hidden"
accept = ".csv,.xls,.xlsx"
onChange = { handleFileUpload }
/>
2026-03-14 15:52:27 +08:00
< div className = "min-h-full" >
{ messages . length <= 1 ? (
< div className = "h-full flex flex-col items-center justify-center pt-[20vh] px-4 pb-32" >
{ /* Logo Area */ }
< div className = "mb-16 flex items-center justify-center gap-4 select-none" >
< div className = "text-[64px] leading-none animate-bounce-slow pb-3" >
🦞
</ div >
< h1 className = "text-[56px] font-bold bg-clip-text text-transparent bg-gradient-to-r from-red-500 via-orange-500 to-amber-500 tracking-tight" >
DataClaw
</ h1 >
</ div >
{ /* Input Area */ }
< div className = "w-full max-w-3xl relative" >
< div className = "bg-white rounded-3xl shadow-[0_8px_30px_rgb(0,0,0,0.06)] border border-zinc-100 p-4 transition-shadow hover:shadow-[0_8px_40px_rgb(0,0,0,0.08)]" >
2026-03-15 00:10:01 +08:00
{ attachedFile && (
< div className = "mx-2 mb-3 p-2.5 bg-blue-50/50 border border-blue-100/50 rounded-xl flex items-center justify-between" >
< div className = "flex items-center gap-2.5 text-sm text-blue-900" >
< div className = "p-1.5 bg-blue-100 rounded-md" >
< FileIcon className = "h-4 w-4 text-blue-600" />
</ div >
< span className = "font-medium truncate max-w-[300px]" >{ attachedFile . filename }</ span >
</ div >
< button onClick = {() => setAttachedFile ( null )} className = "p-1 text-blue-400 hover:text-blue-600 hover:bg-blue-100/50 rounded-md transition-colors" >
< X className = "h-4 w-4" />
</ button >
</ div >
)}
2026-03-14 15:52:27 +08:00
< textarea
className = "w-full min-h-[60px] max-h-[200px] resize-none border-none focus:ring-0 text-lg text-zinc-700 placeholder:text-zinc-300 bg-transparent p-2"
placeholder = "先思考后回答,解决更有难度的问题"
value = { input }
onChange = {( e ) => setInput ( e . target . value )}
onKeyDown = {( e ) => {
if ( e . key === 'Enter' && ! e . shiftKey ) {
e . preventDefault ();
handleSend ();
}
}}
/>
< div className = "flex items-center justify-between mt-4 pt-2 border-t border-zinc-50" >
< div className = "flex items-center gap-2" >
{ capabilities . map (( cap ) => (
< button
key = { cap . label }
2026-03-14 22:00:36 +08:00
onClick = {() => setSelectedCapability ( cap . label )}
className = { `flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
selectedCapability === cap . label
? ` ${ cap . bg } ${ cap . color } ring-1 ring- ${ cap . color . split ( '-' )[ 1 ] } -200 shadow-sm`
: 'bg-zinc-50 text-zinc-500 hover:bg-zinc-100'
} ` }
2026-03-14 15:52:27 +08:00
>
< cap.icon className = "h-3.5 w-3.5" />
{ cap . label }
</ button >
))}
</ div >
< div className = "flex items-center gap-3" >
2026-03-15 00:10:01 +08:00
< Button
variant = "ghost"
size = "icon"
className = "h-9 w-9 text-zinc-400 hover:text-zinc-600 rounded-full"
onClick = {() => fileInputRef . current ? . click ()}
disabled = { isUploading }
>
{ isUploading ? < Loader2 className = "h-5 w-5 animate-spin" /> : < Paperclip className = "h-5 w-5" />}
2026-03-14 15:52:27 +08:00
</ Button >
< Button
onClick = { handleSend }
size = "icon"
disabled = { ! input . trim () || isLoading }
className = { `h-9 w-9 rounded-full transition-all ${
input . trim ()
? 'bg-zinc-100 text-zinc-900 hover:bg-zinc-200'
: 'bg-zinc-50 text-zinc-300 cursor-not-allowed'
} ` }
>
{ isLoading ? < Loader2 className = "h-4 w-4 animate-spin" /> : < ArrowUp className = "h-5 w-5" />}
</ Button >
</ div >
</ div >
</ div >
</ div >
</ div >
) : (
2026-03-14 22:15:38 +08:00
< div className = "max-w-3xl mx-auto pt-24 px-4 pb-48 space-y-8" >
2026-03-14 15:52:27 +08:00
{ messages . map (( msg ) => (
< div
key = { msg . id }
className = { `flex gap-4 ${ msg . role === 'user' ? 'justify-end' : 'justify-start' } ` }
>
{ msg . role !== "user" && (
< div className = "w-8 h-8 rounded-full bg-gradient-to-br from-blue-600 to-indigo-600 flex items-center justify-center text-white shrink-0 mt-1 shadow-sm" >
< span className = "font-bold text-xs" > Ai </ span >
</ div >
)}
< div
className = { `rounded-2xl px-5 py-3.5 text-[15px] leading-relaxed max-w-[85%] shadow-sm ${
msg . role === "user"
? "bg-zinc-100 text-zinc-800"
2026-03-14 22:15:38 +08:00
: "bg-white border border-zinc-100 text-zinc-700 overflow-hidden"
2026-03-14 15:52:27 +08:00
} ` }
>
2026-03-14 22:15:38 +08:00
{ msg . role === "assistant" ? (
2026-03-14 23:15:41 +08:00
msg . awaitingFirstToken && ! msg . content ? (
< div className = "flex items-center gap-2 text-zinc-500 text-sm py-1" >
< Loader2 className = "h-4 w-4 animate-spin" />
< span > 模型思考中,请稍候 ...</ span >
</ div >
) : (
< div className = "prose prose-sm prose-zinc max-w-none prose-p:leading-normal prose-p:my-2 prose-headings:my-3 prose-ul:my-2 prose-li:my-0.5 prose-pre:bg-zinc-50 prose-pre:text-zinc-800 prose-pre:border prose-pre:border-zinc-200" >
< ReactMarkdown remarkPlugins = {[ remarkGfm ]} rehypePlugins = {[ rehypeRaw ]}>
{ msg . content }
</ ReactMarkdown >
</ div >
)
2026-03-14 22:15:38 +08:00
) : (
msg . content
)}
2026-03-14 15:52:27 +08:00
</ div >
{ msg . role === "user" && (
< div className = "w-8 h-8 rounded-full bg-zinc-200 flex items-center justify-center text-zinc-500 shrink-0 mt-1" >
< User className = "h-4 w-4" />
</ div >
)}
</ div >
))}
< div ref = { scrollRef } />
</ div >
)}
</ div >
</ ScrollArea >
{ /* Floating Input for Chat State */ }
{ messages . length > 1 && (
< div className = "absolute bottom-6 left-0 right-0 px-4" >
< div className = "max-w-3xl mx-auto" >
2026-03-15 00:10:01 +08:00
< div className = "bg-white rounded-2xl shadow-xl border border-zinc-200/60 p-2 flex flex-col gap-2 ring-1 ring-zinc-100" >
{ attachedFile && (
< div className = "mx-2 mt-1 p-2 bg-blue-50/50 border border-blue-100/50 rounded-lg flex items-center justify-between" >
< div className = "flex items-center gap-2 text-xs text-blue-900" >
< FileIcon className = "h-3.5 w-3.5 text-blue-600" />
< span className = "font-medium truncate max-w-[200px]" >{ attachedFile . filename }</ span >
</ div >
< button onClick = {() => setAttachedFile ( null )} className = "text-blue-400 hover:text-blue-600" >
< X className = "h-3.5 w-3.5" />
</ button >
</ div >
)}
< div className = "flex items-center gap-2" >
< Button
variant = "ghost"
size = "icon"
className = "h-9 w-9 text-zinc-400 hover:text-zinc-600 rounded-full shrink-0"
onClick = {() => fileInputRef . current ? . click ()}
disabled = { isUploading || isLoading }
>
{ isUploading ? < Loader2 className = "h-5 w-5 animate-spin" /> : < Paperclip className = "h-5 w-5" />}
</ Button >
< Input
className = "flex-1 border-none shadow-none focus-visible:ring-0 text-base text-zinc-700 placeholder:text-zinc-400 h-11 bg-transparent"
placeholder = "Send a message..."
value = { input }
onChange = {( e ) => setInput ( e . target . value )}
onKeyDown = {( e ) => e . key === 'Enter' && handleSend ()}
disabled = { isLoading }
/>
< Button
onClick = { handleSend }
size = "icon"
disabled = { ! input . trim () || isLoading }
className = { `h-9 w-9 rounded-lg shrink-0 transition-all ${
input . trim ()
? 'bg-blue-600 hover:bg-blue-700 text-white shadow-md'
: 'bg-zinc-100 text-zinc-300 hover:bg-zinc-100 cursor-not-allowed'
} ` }
>
{ isLoading ? < Loader2 className = "h-4 w-4 animate-spin" /> : < ArrowUp className = "h-5 w-5" />}
</ Button >
</ div >
2026-03-14 15:52:27 +08:00
</ div >
</ div >
</ div >
)}
</ div >
);
}