Browse Source

feat: AI chat markdown rendering + layout alignment fixes

- Add react-markdown, remark-gfm, react-syntax-highlighter for AI responses
- Code blocks with syntax highlighting, language labels, and copy button
- Fix AIChatPage height to fit within MainLayout (calc 100vh-6rem)
- Align top headers (Sidebar logo h-16 = Header h-16)
- Align bottom sections (User info h-14 = Quota h-14 = Input h-14)
- Add /ai/chat page title to MainLayout
master
dark 1 month ago
parent
commit
58a91f40ec
  1. 1688
      frontend/react-shadcn/pc/package-lock.json
  2. 4
      frontend/react-shadcn/pc/package.json
  3. 3
      frontend/react-shadcn/pc/src/components/layout/MainLayout.tsx
  4. 64
      frontend/react-shadcn/pc/src/components/layout/Sidebar.tsx
  5. 160
      frontend/react-shadcn/pc/src/pages/AIChatPage.tsx

1688
frontend/react-shadcn/pc/package-lock.json

File diff suppressed because it is too large

4
frontend/react-shadcn/pc/package.json

@ -18,7 +18,10 @@
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.13.0", "react-router-dom": "^7.13.0",
"react-syntax-highlighter": "^16.1.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7" "tailwindcss-animate": "^1.0.7"
}, },
@ -28,6 +31,7 @@
"@types/node": "^24.10.12", "@types/node": "^24.10.12",
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/react-syntax-highlighter": "^15.5.13",
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.24", "autoprefixer": "^10.4.24",
"eslint": "^9.39.1", "eslint": "^9.39.1",

3
frontend/react-shadcn/pc/src/components/layout/MainLayout.tsx

@ -11,6 +11,7 @@ const pageTitles: Record<string, { title: string; subtitle?: string }> = {
'/menus': { title: '菜单管理', subtitle: '配置系统导航菜单' }, '/menus': { title: '菜单管理', subtitle: '配置系统导航菜单' },
'/organizations': { title: '机构管理', subtitle: '管理组织架构与成员' }, '/organizations': { title: '机构管理', subtitle: '管理组织架构与成员' },
'/settings': { title: '系统设置', subtitle: '配置系统参数' }, '/settings': { title: '系统设置', subtitle: '配置系统参数' },
'/ai/chat': { title: 'AI 对话', subtitle: '智能助手' },
} }
export function MainLayout() { export function MainLayout() {
@ -18,7 +19,7 @@ export function MainLayout() {
const pageInfo = pageTitles[pathname] || { title: 'BASE', subtitle: '' } const pageInfo = pageTitles[pathname] || { title: 'BASE', subtitle: '' }
return ( return (
<div className="min-h-screen bg-gray-950"> <div className="min-h-screen bg-background">
<Sidebar /> <Sidebar />
<div className="ml-64"> <div className="ml-64">
<Header title={pageInfo.title} subtitle={pageInfo.subtitle} /> <Header title={pageInfo.title} subtitle={pageInfo.subtitle} />

64
frontend/react-shadcn/pc/src/components/layout/Sidebar.tsx

@ -40,16 +40,16 @@ export function Sidebar() {
const menuItems: MenuItem[] = userMenus.length > 0 ? userMenus : [] const menuItems: MenuItem[] = userMenus.length > 0 ? userMenus : []
return ( return (
<aside className="w-64 h-screen bg-gray-900/80 backdrop-blur-xl border-r border-gray-800 flex flex-col fixed left-0 top-0 z-40"> <aside className="w-64 h-screen bg-sidebar-bg backdrop-blur-xl border-r border-border flex flex-col fixed left-0 top-0 z-40">
{/* Logo */} {/* Logo */}
<div className="p-6 border-b border-gray-800"> <div className="h-16 flex items-center px-6 border-b border-border shrink-0">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-sky-500 to-blue-600 flex items-center justify-center glow-primary"> <div className="w-9 h-9 rounded-lg bg-gradient-to-br from-sky-500 to-blue-600 flex items-center justify-center glow-primary">
<span className="text-xl font-bold text-white font-display">B</span> <span className="text-lg font-bold text-white font-display">B</span>
</div> </div>
<div> <div>
<h1 className="text-lg font-bold text-white font-display">BASE</h1> <h1 className="text-lg font-bold text-foreground font-display leading-tight">BASE</h1>
<p className="text-xs text-gray-500 font-body"></p> <p className="text-xs text-text-muted font-body"></p>
</div> </div>
</div> </div>
</div> </div>
@ -59,13 +59,13 @@ export function Sidebar() {
<div className="px-4 pt-4" ref={dropdownRef}> <div className="px-4 pt-4" ref={dropdownRef}>
<button <button
onClick={() => setOrgDropdownOpen(!orgDropdownOpen)} onClick={() => setOrgDropdownOpen(!orgDropdownOpen)}
className="w-full flex items-center justify-between px-3 py-2 rounded-lg bg-gray-800/50 border border-gray-700 text-sm text-gray-300 hover:bg-gray-800 transition-colors" className="w-full flex items-center justify-between px-3 py-2 rounded-lg bg-muted border border-border-secondary text-sm text-text-secondary hover:bg-surface-hover transition-colors"
> >
<span className="truncate">{currentOrg?.name || '选择机构'}</span> <span className="truncate">{currentOrg?.name || '选择机构'}</span>
<ChevronDown className={cn('h-4 w-4 transition-transform', orgDropdownOpen && 'rotate-180')} /> <ChevronDown className={cn('h-4 w-4 transition-transform', orgDropdownOpen && 'rotate-180')} />
</button> </button>
{orgDropdownOpen && ( {orgDropdownOpen && (
<div className="mt-1 py-1 rounded-lg bg-gray-800 border border-gray-700 shadow-xl"> <div className="mt-1 py-1 rounded-lg bg-card border border-border-secondary shadow-xl">
{userOrgs.map((org) => ( {userOrgs.map((org) => (
<button <button
key={org.orgId} key={org.orgId}
@ -74,11 +74,11 @@ export function Sidebar() {
'w-full text-left px-3 py-2 text-sm transition-colors', 'w-full text-left px-3 py-2 text-sm transition-colors',
currentOrg?.id === org.orgId currentOrg?.id === org.orgId
? 'text-sky-400 bg-sky-500/10' ? 'text-sky-400 bg-sky-500/10'
: 'text-gray-400 hover:text-gray-200 hover:bg-gray-700/50' : 'text-text-secondary hover:text-foreground hover:bg-surface-hover'
)} )}
> >
<div className="truncate">{org.orgName}</div> <div className="truncate">{org.orgName}</div>
<div className="text-xs text-gray-500">{org.roleName}</div> <div className="text-xs text-text-muted">{org.roleName}</div>
</button> </button>
))} ))}
</div> </div>
@ -100,7 +100,7 @@ export function Sidebar() {
'font-body text-sm font-medium', 'font-body text-sm font-medium',
isActive isActive
? 'bg-gradient-to-r from-sky-500/20 to-blue-600/20 text-sky-400 border border-sky-500/30' ? 'bg-gradient-to-r from-sky-500/20 to-blue-600/20 text-sky-400 border border-sky-500/30'
: 'text-gray-400 hover:bg-gray-800/50 hover:text-gray-200' : 'text-text-secondary hover:bg-surface-hover hover:text-foreground'
) )
} }
> >
@ -112,29 +112,27 @@ export function Sidebar() {
</nav> </nav>
{/* User Info */} {/* User Info */}
<div className="p-4 border-t border-gray-800"> <div className="h-14 flex items-center gap-3 px-5 border-t border-border shrink-0">
<div className="flex items-center gap-3 p-3 rounded-lg bg-gray-800/50"> <div className="w-8 h-8 rounded-lg bg-gradient-to-br from-amber-500 to-orange-600 flex items-center justify-center">
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-amber-500 to-orange-600 flex items-center justify-center"> <span className="text-sm font-bold text-foreground font-display">
<span className="text-lg font-bold text-white font-display"> {user?.username?.[0]?.toUpperCase() || 'U'}
{user?.username?.[0]?.toUpperCase() || 'U'} </span>
</span> </div>
</div> <div className="flex-1 min-w-0">
<div className="flex-1 min-w-0"> <p className="text-sm font-medium text-foreground font-body truncate">
<p className="text-sm font-medium text-white font-body truncate"> {user?.username || 'User'}
{user?.username || 'User'} </p>
</p> <p className="text-xs text-text-muted font-body truncate">
<p className="text-xs text-gray-500 font-body truncate"> {user?.role || ''}
{user?.role || ''} </p>
</p>
</div>
<button
onClick={logout}
className="p-2 text-gray-500 hover:text-red-400 transition-colors"
title="退出登录"
>
<LogOut className="h-4 w-4" />
</button>
</div> </div>
<button
onClick={logout}
className="p-1.5 text-text-muted hover:text-red-400 transition-colors"
title="退出登录"
>
<LogOut className="h-4 w-4" />
</button>
</div> </div>
</aside> </aside>
) )

160
frontend/react-shadcn/pc/src/pages/AIChatPage.tsx

@ -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-4rem)] overflow-hidden"> <div className="-mx-6 -mb-6 flex h-[calc(100vh-6rem)] 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}

Loading…
Cancel
Save