@ -1,4 +1,8 @@
import { useState , useEffect , useRef , useCallback } from 'react'
import { useState , useEffect , useRef , useCallback , memo } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'
import { apiClient } from '@/services/api'
import { apiClient } from '@/services/api'
import type { AIModelInfo , AIConversation , AIChatMessage , AIQuotaInfo } from '@/types'
import type { AIModelInfo , AIConversation , AIChatMessage , AIQuotaInfo } from '@/types'
@ -10,6 +14,103 @@ interface ChatMsg {
isStreaming? : boolean
isStreaming? : boolean
}
}
function CopyButton ( { text } : { text : string } ) {
const [ copied , setCopied ] = useState ( false )
const handleCopy = ( ) = > {
navigator . clipboard . writeText ( text )
setCopied ( true )
setTimeout ( ( ) = > setCopied ( false ) , 2000 )
}
return (
< button
onClick = { handleCopy }
className = "absolute top-2 right-2 px-2 py-1 rounded text-xs bg-white/10 hover:bg-white/20 text-gray-300 transition-colors"
>
{ copied ? '已复制' : '复制' }
< / button >
)
}
const MarkdownContent = memo ( ( { content } : { content : string } ) = > (
< ReactMarkdown
remarkPlugins = { [ remarkGfm ] }
components = { {
code ( { className , children , . . . props } ) {
const match = /language-(\w+)/ . exec ( className || '' )
const codeStr = String ( children ) . replace ( /\n$/ , '' )
if ( match ) {
return (
< div className = "relative group/code my-2 -mx-2" >
< div className = "flex items-center justify-between px-3 py-1.5 bg-[#1e1e1e] rounded-t-lg border-b border-white/10" >
< span className = "text-xs text-gray-400" > { match [ 1 ] } < / span >
< CopyButton text = { codeStr } / >
< / div >
< SyntaxHighlighter
style = { oneDark }
language = { match [ 1 ] }
PreTag = "div"
customStyle = { { margin : 0 , borderRadius : '0 0 0.5rem 0.5rem' , fontSize : '0.8125rem' } }
>
{ codeStr }
< / SyntaxHighlighter >
< / div >
)
}
return (
< code className = "px-1.5 py-0.5 rounded bg-black/10 dark:bg-white/10 text-[0.8125rem] font-mono" { ...props } >
{ children }
< / code >
)
} ,
p ( { children } ) {
return < p className = "mb-2 last:mb-0" > { children } < / p >
} ,
ul ( { children } ) {
return < ul className = "list-disc pl-5 mb-2 space-y-1" > { children } < / ul >
} ,
ol ( { children } ) {
return < ol className = "list-decimal pl-5 mb-2 space-y-1" > { children } < / ol >
} ,
li ( { children } ) {
return < li className = "leading-relaxed" > { children } < / li >
} ,
h1 ( { children } ) {
return < h1 className = "text-lg font-bold mb-2 mt-3 first:mt-0" > { children } < / h1 >
} ,
h2 ( { children } ) {
return < h2 className = "text-base font-bold mb-2 mt-3 first:mt-0" > { children } < / h2 >
} ,
h3 ( { children } ) {
return < h3 className = "text-sm font-bold mb-1.5 mt-2 first:mt-0" > { children } < / h3 >
} ,
blockquote ( { children } ) {
return < blockquote className = "border-l-3 border-primary/50 pl-3 my-2 text-muted-foreground italic" > { children } < / blockquote >
} ,
table ( { children } ) {
return (
< div className = "overflow-x-auto my-2 -mx-2" >
< table className = "min-w-full text-xs border-collapse" > { children } < / table >
< / div >
)
} ,
th ( { children } ) {
return < th className = "border border-border px-2 py-1 bg-muted font-medium text-left" > { children } < / th >
} ,
td ( { children } ) {
return < td className = "border border-border px-2 py-1" > { children } < / td >
} ,
a ( { href , children } ) {
return < a href = { href } target = "_blank" rel = "noopener noreferrer" className = "text-primary underline underline-offset-2 hover:opacity-80" > { children } < / a >
} ,
hr() {
return < hr className = "my-3 border-border" / >
} ,
} }
>
{ content }
< / ReactMarkdown >
) )
export function AIChatPage() {
export function AIChatPage() {
// State
// State
const [ models , setModels ] = useState < AIModelInfo [ ] > ( [ ] )
const [ models , setModels ] = useState < AIModelInfo [ ] > ( [ ] )
@ -195,14 +296,14 @@ export function AIChatPage() {
} , { } as Record < string , AIModelInfo [ ] > )
} , { } as Record < string , AIModelInfo [ ] > )
return (
return (
< div className = "flex h-[calc(100vh-4 rem)] overflow-hidden" >
< div className = "-mx-6 -mb-6 flex h-[calc(100vh-6 rem)] overflow-hidden" >
{ /* Left Sidebar - Conversations */ }
{ /* Left Sidebar - Conversations */ }
< div className = "w-72 border-r border-border bg-card flex flex-col" >
< div className = "w-72 border-r border-border bg-card flex flex-col" >
{ /* New Chat Button */ }
{ /* New Chat Button */ }
< div className = "p-3 border-b border-border" >
< div className = "h-11 flex items-center px -3 border-b border-border shrink-0 " >
< button
< button
onClick = { createConversation }
onClick = { createConversation }
className = "w-full flex items-center justify-center gap-2 px-4 py-2 .5 bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity font-medium"
className = "w-full flex items-center justify-center gap-2 px-3 py-1 .5 bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity text-sm font-medium"
>
>
< svg className = "w-4 h-4" fill = "none" stroke = "currentColor" viewBox = "0 0 24 24" > < path strokeLinecap = "round" strokeLinejoin = "round" strokeWidth = { 2 } d = "M12 4v16m8-8H4" / > < / svg >
< svg className = "w-4 h-4" fill = "none" stroke = "currentColor" viewBox = "0 0 24 24" > < path strokeLinecap = "round" strokeLinejoin = "round" strokeWidth = { 2 } d = "M12 4v16m8-8H4" / > < / svg >
新 对 话
新 对 话
@ -260,13 +361,10 @@ export function AIChatPage() {
< / div >
< / div >
{ /* Quota Display */ }
{ /* Quota Display */ }
< div className = "p-3 border-t border-border bg-muted/30" >
< div className = "h-14 flex items-center gap-3 px-3 border-t border-border bg-muted/30 shrink-0" >
< div className = "text-xs text-muted-foreground" > 余 额 < / div >
< div className = "flex-1 min-w-0" >
< div className = "text-lg font-semibold text-foreground" >
< div className = "text-sm font-semibold text-foreground" > ¥ { quota ? . balance ? . toFixed ( 4 ) || '0.0000' } < / div >
¥ { quota ? . balance ? . toFixed ( 4 ) || '0.0000' }
< div className = "text-xs text-muted-foreground" > 已 消 费 : ¥ { quota ? . totalConsumed ? . toFixed ( 4 ) || '0.0000' } < / div >
< / div >
< div className = "text-xs text-muted-foreground mt-0.5" >
已 消 费 : ¥ { quota ? . totalConsumed ? . toFixed ( 4 ) || '0.0000' }
< / div >
< / div >
< / div >
< / div >
< / div >
< / div >
@ -274,9 +372,9 @@ export function AIChatPage() {
{ /* Main Chat Area */ }
{ /* Main Chat Area */ }
< div className = "flex-1 flex flex-col bg-background" >
< div className = "flex-1 flex flex-col bg-background" >
{ /* Header */ }
{ /* Header */ }
< div className = "flex items-center justify-between px-4 py-2.5 border-b border-border bg-card" >
< div className = "h-11 flex items-center justify-between px-4 border-b border-border bg-card/60 shrink-0 " >
< div className = "text-sm font-medium text-foreground" >
< div className = "text-sm font-medium text-foreground truncate " >
{ currentConv ? . title || 'AI 对话' }
{ currentConv ? . title || '新 对话' }
< / div >
< / div >
< select
< select
value = { selectedModel }
value = { selectedModel }
@ -309,17 +407,25 @@ export function AIChatPage() {
? 'bg-primary text-primary-foreground'
? 'bg-primary text-primary-foreground'
: 'bg-muted text-foreground'
: 'bg-muted text-foreground'
} ` }>
} ` }>
< div className = "whitespace-pre-wrap break-words text-sm leading-relaxed" >
{ msg . role === 'assistant' ? (
{ msg . content || ( msg . isStreaming ? (
< div className = "text-sm leading-relaxed prose-chat" >
< span className = "inline-flex gap-1" >
{ msg . content ? (
< span className = "animate-bounce" style = { { animationDelay : '0ms' } } > . < / span >
< MarkdownContent content = { msg . content } / >
< span className = "animate-bounce" style = { { animationDelay : '150ms' } } > . < / span >
) : msg . isStreaming ? (
< span className = "animate-bounce" style = { { animationDelay : '300ms' } } > . < / span >
< span className = "inline-flex gap-1" >
< / span >
< span className = "animate-bounce" style = { { animationDelay : '0ms' } } > . < / span >
) : '' ) }
< span className = "animate-bounce" style = { { animationDelay : '150ms' } } > . < / span >
< / div >
< span className = "animate-bounce" style = { { animationDelay : '300ms' } } > . < / span >
{ msg . isStreaming && msg . content && (
< / span >
< span className = "inline-block w-1.5 h-4 ml-0.5 bg-current animate-pulse" / >
) : null }
{ msg . isStreaming && msg . content && (
< span className = "inline-block w-1.5 h-4 ml-0.5 bg-current animate-pulse" / >
) }
< / div >
) : (
< div className = "whitespace-pre-wrap break-words text-sm leading-relaxed" >
{ msg . content }
< / div >
) }
) }
< / div >
< / div >
< / div >
< / div >
@ -329,8 +435,8 @@ export function AIChatPage() {
< / div >
< / div >
{ /* Input Area */ }
{ /* Input Area */ }
< div className = "px-4 py-3 border-t border-border bg-card " >
< div className = "h-14 flex items-center px-4 border-t border-border bg-card shrink-0 " >
< div className = "flex items-end gap-2 max-w-4xl mx-auto " >
< div className = "flex items-center gap-2 max-w-4xl mx-auto w-full " >
< textarea
< textarea
ref = { textareaRef }
ref = { textareaRef }
value = { input }
value = { input }