feat: SSE streaming runs + thinking/tool panel + typewriter effect
All checks were successful
Deploy Admin Frontend / build-and-deploy (push) Successful in 8s
All checks were successful
Deploy Admin Frontend / build-and-deploy (push) Successful in 8s
This commit is contained in:
parent
4292da4bf1
commit
b6174c4762
@ -1,10 +1,10 @@
|
|||||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||||
import { Input, Button, Avatar, Spin, theme, Typography, App } from 'antd'
|
import { Input, Button, Avatar, Spin, theme, Typography, App, Collapse } from 'antd'
|
||||||
import {
|
import {
|
||||||
SendOutlined, RobotOutlined, UserOutlined, PlusOutlined,
|
SendOutlined, RobotOutlined, UserOutlined, PlusOutlined,
|
||||||
DeleteOutlined, StopOutlined, MessageOutlined,
|
DeleteOutlined, StopOutlined, MessageOutlined, BulbOutlined, ToolOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import { sendMessage } from '@/services/ai-chat'
|
import { streamChat, type StreamEvent } from '@/services/ai-chat'
|
||||||
import {
|
import {
|
||||||
listConversations, createConversation, deleteConversation,
|
listConversations, createConversation, deleteConversation,
|
||||||
getMessages, updateConversation, type Conversation,
|
getMessages, updateConversation, type Conversation,
|
||||||
@ -18,6 +18,8 @@ interface Message {
|
|||||||
role: 'user' | 'assistant'
|
role: 'user' | 'assistant'
|
||||||
content: string
|
content: string
|
||||||
timestamp: number
|
timestamp: number
|
||||||
|
thinking?: string
|
||||||
|
toolCalls?: { name: string; result?: string }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChatPage() {
|
function ChatPage() {
|
||||||
@ -27,12 +29,14 @@ function ChatPage() {
|
|||||||
const [messages, setMessages] = useState<Message[]>([])
|
const [messages, setMessages] = useState<Message[]>([])
|
||||||
const [input, setInput] = useState('')
|
const [input, setInput] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [streaming, setStreaming] = useState(false)
|
||||||
const [editingId, setEditingId] = useState<string | null>(null)
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
const [editTitle, setEditTitle] = useState('')
|
const [editTitle, setEditTitle] = useState('')
|
||||||
const [deleting, setDeleting] = useState(false)
|
const [deleting, setDeleting] = useState(false)
|
||||||
const abortRef = useRef<AbortController | null>(null)
|
const abortRef = useRef<AbortController | null>(null)
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
const editInputRef = useRef<any>(null)
|
const editInputRef = useRef<any>(null)
|
||||||
|
const streamMsgRef = useRef<string>('')
|
||||||
const { token } = theme.useToken()
|
const { token } = theme.useToken()
|
||||||
|
|
||||||
const loadConversations = useCallback(async () => {
|
const loadConversations = useCallback(async () => {
|
||||||
@ -42,12 +46,11 @@ function ChatPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
}, [messages])
|
}, [messages, streamMsgRef.current])
|
||||||
|
|
||||||
const switchConversation = useCallback(async (id: string) => {
|
const switchConversation = useCallback(async (id: string) => {
|
||||||
if (loading) { abortRef.current?.abort(); setLoading(false) }
|
if (loading) { abortRef.current?.abort(); setLoading(false); setStreaming(false) }
|
||||||
setActiveId(id)
|
setActiveId(id); setMessages([]); streamMsgRef.current = ''
|
||||||
setMessages([])
|
|
||||||
try {
|
try {
|
||||||
const records = await getMessages(id)
|
const records = await getMessages(id)
|
||||||
setMessages(records.map(m => ({
|
setMessages(records.map(m => ({
|
||||||
@ -58,23 +61,17 @@ function ChatPage() {
|
|||||||
}, [loading])
|
}, [loading])
|
||||||
|
|
||||||
const handleNew = async () => {
|
const handleNew = async () => {
|
||||||
if (loading) { abortRef.current?.abort(); setLoading(false) }
|
if (loading) { abortRef.current?.abort(); setLoading(false); setStreaming(false) }
|
||||||
try {
|
try {
|
||||||
const conv = await createConversation()
|
const conv = await createConversation()
|
||||||
setConversations(prev => [conv, ...prev])
|
setConversations(prev => [conv, ...prev])
|
||||||
setActiveId(conv.id)
|
setActiveId(conv.id); setMessages([]); setInput(''); streamMsgRef.current = ''
|
||||||
setMessages([])
|
|
||||||
setInput('')
|
|
||||||
} catch { /* */ }
|
} catch { /* */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = (id: string) => {
|
const handleDelete = (id: string) => {
|
||||||
modal.confirm({
|
modal.confirm({
|
||||||
title: '删除对话',
|
title: '删除对话', content: '确定?', okText: '删除', okType: 'danger', cancelText: '取消',
|
||||||
content: '确定要删除这个对话吗?',
|
|
||||||
okText: '删除',
|
|
||||||
okType: 'danger',
|
|
||||||
cancelText: '取消',
|
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
setDeleting(true)
|
setDeleting(true)
|
||||||
try {
|
try {
|
||||||
@ -88,11 +85,9 @@ function ChatPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const startEdit = (conv: Conversation) => {
|
const startEdit = (conv: Conversation) => {
|
||||||
setEditingId(conv.id)
|
setEditingId(conv.id); setEditTitle(conv.title)
|
||||||
setEditTitle(conv.title)
|
|
||||||
setTimeout(() => editInputRef.current?.focus(), 50)
|
setTimeout(() => editInputRef.current?.focus(), 50)
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveTitle = async (id: string) => {
|
const saveTitle = async (id: string) => {
|
||||||
const title = editTitle.trim()
|
const title = editTitle.trim()
|
||||||
if (title && title !== conversations.find(c => c.id === id)?.title) {
|
if (title && title !== conversations.find(c => c.id === id)?.title) {
|
||||||
@ -102,59 +97,109 @@ function ChatPage() {
|
|||||||
setEditingId(null)
|
setEditingId(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Send with SSE streaming ──
|
||||||
const handleSend = async () => {
|
const handleSend = async () => {
|
||||||
const text = input.trim()
|
const text = input.trim()
|
||||||
if (!text || loading) return
|
if (!text || loading) return
|
||||||
|
|
||||||
const userMsg: Message = {
|
const userMsg: Message = { id: Date.now().toString(), role: 'user', content: text, timestamp: Date.now() }
|
||||||
id: Date.now().toString(), role: 'user', content: text, timestamp: Date.now(),
|
const prevMessages = [...messages, userMsg]
|
||||||
}
|
setMessages(prevMessages)
|
||||||
const newMessages = [...messages, userMsg]
|
|
||||||
setMessages(newMessages)
|
|
||||||
setInput('')
|
setInput('')
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
setStreaming(true)
|
||||||
|
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
abortRef.current = controller
|
abortRef.current = controller
|
||||||
|
|
||||||
try {
|
// Placeholder for streaming assistant message
|
||||||
const result = await sendMessage(
|
const streamMsgId = (Date.now() + 1).toString()
|
||||||
newMessages.map(m => ({ role: m.role, content: m.content })),
|
const streamMsg: Message = { id: streamMsgId, role: 'assistant', content: '', timestamp: Date.now(), thinking: '', toolCalls: [] }
|
||||||
activeId ?? undefined, controller.signal,
|
setMessages(prev => [...prev, streamMsg])
|
||||||
)
|
streamMsgRef.current = ''
|
||||||
|
|
||||||
const convId = result.conversationId || activeId
|
let currentContent = ''
|
||||||
if (convId && convId !== activeId) {
|
let currentThinking = ''
|
||||||
setActiveId(convId)
|
let currentTools: { name: string; result?: string }[] = []
|
||||||
loadConversations()
|
let completedConvId: string | undefined
|
||||||
|
|
||||||
|
const updateStreamMsg = (updates: Partial<Message>) => {
|
||||||
|
setMessages(prev => prev.map(m => m.id === streamMsgId ? { ...m, ...updates } : m))
|
||||||
}
|
}
|
||||||
|
|
||||||
setMessages(prev => [...prev, {
|
try {
|
||||||
id: (Date.now() + 1).toString(), role: 'assistant',
|
await streamChat(
|
||||||
content: result.content, timestamp: Date.now(),
|
prevMessages.map(m => ({ role: m.role, content: m.content })),
|
||||||
}])
|
activeId,
|
||||||
|
(event: StreamEvent) => {
|
||||||
|
switch (event.event) {
|
||||||
|
case 'meta':
|
||||||
|
completedConvId = event.conversationId
|
||||||
|
if (event.conversationId && event.conversationId !== activeId) {
|
||||||
|
setActiveId(event.conversationId)
|
||||||
loadConversations()
|
loadConversations()
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'message.delta':
|
||||||
|
currentContent += event.delta || ''
|
||||||
|
updateStreamMsg({ content: currentContent })
|
||||||
|
break
|
||||||
|
case 'reasoning.available':
|
||||||
|
currentThinking = event.text || ''
|
||||||
|
updateStreamMsg({ thinking: currentThinking })
|
||||||
|
break
|
||||||
|
case 'tool.start':
|
||||||
|
currentTools.push({ name: event.toolName || 'unknown' })
|
||||||
|
updateStreamMsg({ toolCalls: [...currentTools] })
|
||||||
|
break
|
||||||
|
case 'tool.result':
|
||||||
|
if (currentTools.length > 0) {
|
||||||
|
currentTools[currentTools.length - 1].result = event.output || ''
|
||||||
|
updateStreamMsg({ toolCalls: [...currentTools] })
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'run.completed':
|
||||||
|
if (event.output) { currentContent = event.output; updateStreamMsg({ content: currentContent }) }
|
||||||
|
break
|
||||||
|
case 'done':
|
||||||
|
completedConvId = event.conversationId || completedConvId
|
||||||
|
break
|
||||||
|
case 'error':
|
||||||
|
updateStreamMsg({ content: `❌ ${event.error}` })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
},
|
||||||
|
controller.signal,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (completedConvId) loadConversations()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.name === 'AbortError') return
|
if (err.name === 'AbortError') {
|
||||||
setMessages(prev => [...prev, {
|
updateStreamMsg({ content: currentContent || '(已停止)' })
|
||||||
id: (Date.now() + 1).toString(), role: 'assistant',
|
} else {
|
||||||
content: '请求失败:' + (err instanceof Error ? err.message : '未知错误'),
|
updateStreamMsg({ content: `❌ ${err.message}` })
|
||||||
timestamp: Date.now(),
|
}
|
||||||
}])
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
setStreaming(false)
|
||||||
abortRef.current = null
|
abortRef.current = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleStop = () => { abortRef.current?.abort(); setLoading(false) }
|
const handleStop = async () => {
|
||||||
|
abortRef.current?.abort()
|
||||||
|
setStreaming(false)
|
||||||
|
}
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() }
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const inputPlaceholder = activeId ? '输入消息,Enter 发送' : '请先新建或选择对话'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', height: 'calc(100vh - 112px)', gap: 12 }}>
|
<div style={{ display: 'flex', height: 'calc(100vh - 112px)', gap: 12 }}>
|
||||||
|
{/* Sidebar */}
|
||||||
<div style={{
|
<div style={{
|
||||||
width: 260, flexShrink: 0, display: 'flex', flexDirection: 'column',
|
width: 260, flexShrink: 0, display: 'flex', flexDirection: 'column',
|
||||||
background: token.colorBgContainer, borderRadius: token.borderRadiusLG,
|
background: token.colorBgContainer, borderRadius: token.borderRadiusLG,
|
||||||
@ -174,58 +219,39 @@ function ChatPage() {
|
|||||||
}}>
|
}}>
|
||||||
<MessageOutlined style={{ color: token.colorTextQuaternary, fontSize: 14, marginRight: 8, flexShrink: 0 }} />
|
<MessageOutlined style={{ color: token.colorTextQuaternary, fontSize: 14, marginRight: 8, flexShrink: 0 }} />
|
||||||
{editingId === conv.id ? (
|
{editingId === conv.id ? (
|
||||||
<Input
|
<Input ref={editInputRef} size="small" value={editTitle}
|
||||||
ref={editInputRef} size="small" value={editTitle}
|
|
||||||
onChange={e => setEditTitle(e.target.value)}
|
onChange={e => setEditTitle(e.target.value)}
|
||||||
onBlur={() => saveTitle(conv.id)}
|
onBlur={() => saveTitle(conv.id)} onPressEnter={() => saveTitle(conv.id)}
|
||||||
onPressEnter={() => saveTitle(conv.id)}
|
onClick={e => e.stopPropagation()} style={{ flex: 1, fontSize: 13 }} />
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
style={{ flex: 1, fontSize: 13 }}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<Text ellipsis style={{ flex: 1, fontSize: 13, lineHeight: '28px' }}
|
<Text ellipsis style={{ flex: 1, fontSize: 13, lineHeight: '28px' }}
|
||||||
onDoubleClick={e => { e.stopPropagation(); startEdit(conv) }}>
|
onDoubleClick={e => { e.stopPropagation(); startEdit(conv) }}>{conv.title}</Text>
|
||||||
{conv.title}
|
|
||||||
</Text>
|
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button type="text" size="small" danger icon={<DeleteOutlined />}
|
||||||
type="text" size="small" danger
|
disabled={deleting} onClick={e => { e.stopPropagation(); handleDelete(conv.id) }}
|
||||||
icon={<DeleteOutlined />}
|
style={{ marginLeft: 4, flexShrink: 0 }} />
|
||||||
disabled={deleting}
|
|
||||||
onClick={e => { e.stopPropagation(); handleDelete(conv.id) }}
|
|
||||||
style={{ marginLeft: 4, flexShrink: 0 }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{conversations.length === 0 && (
|
{conversations.length === 0 && (
|
||||||
<div style={{ textAlign: 'center', padding: 24, color: token.colorTextQuaternary }}>
|
<div style={{ textAlign: 'center', padding: 24, color: token.colorTextQuaternary }}>
|
||||||
<Text type="secondary" style={{ fontSize: 13 }}>暂无对话</Text>
|
<Text type="secondary" style={{ fontSize: 13 }}>暂无对话</Text>
|
||||||
<br />
|
|
||||||
<Text type="secondary" style={{ fontSize: 12, marginTop: 4 }}>双击标题可重命名</Text>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Chat area */}
|
||||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
flex: 1, overflowY: 'auto', background: token.colorBgContainer,
|
flex: 1, overflowY: 'auto', background: token.colorBgContainer,
|
||||||
borderRadius: `${token.borderRadiusLG}px ${token.borderRadiusLG}px 0 0`,
|
borderRadius: `${token.borderRadiusLG}px ${token.borderRadiusLG}px 0 0`,
|
||||||
padding: '20px 24px',
|
padding: '20px 24px', border: `1px solid ${token.colorBorderSecondary}`, borderBottom: 'none',
|
||||||
border: `1px solid ${token.colorBorderSecondary}`, borderBottom: 'none',
|
|
||||||
}}>
|
}}>
|
||||||
{messages.length === 0 && (
|
{messages.length === 0 && (
|
||||||
<div style={{
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
|
||||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
<RobotOutlined style={{ fontSize: 48, marginBottom: 16, color: token.colorTextQuaternary }} />
|
||||||
justifyContent: 'center', height: '100%', color: token.colorTextQuaternary,
|
<Text type="secondary" style={{ fontSize: 16 }}>{activeId ? '开始新对话' : '点击「新对话」开始'}</Text>
|
||||||
}}>
|
<Text type="secondary" style={{ fontSize: 13, marginTop: 8 }}>Hermes Agent · 流式响应 · 思考过程可见</Text>
|
||||||
<RobotOutlined style={{ fontSize: 48, marginBottom: 16 }} />
|
|
||||||
<Text type="secondary" style={{ fontSize: 16 }}>
|
|
||||||
{activeId ? '开始新对话' : '点击左侧「新对话」开始'}
|
|
||||||
</Text>
|
|
||||||
<Text type="secondary" style={{ fontSize: 13, marginTop: 8 }}>
|
|
||||||
Hermes Agent 通过 DeepSeek 提供回答
|
|
||||||
</Text>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -236,59 +262,68 @@ function ChatPage() {
|
|||||||
}}>
|
}}>
|
||||||
<Avatar size={32}
|
<Avatar size={32}
|
||||||
icon={msg.role === 'user' ? <UserOutlined /> : <RobotOutlined />}
|
icon={msg.role === 'user' ? <UserOutlined /> : <RobotOutlined />}
|
||||||
style={{
|
style={{ backgroundColor: msg.role === 'user' ? token.colorPrimary : token.colorSuccess, flexShrink: 0 }} />
|
||||||
backgroundColor: msg.role === 'user' ? token.colorPrimary : token.colorSuccess,
|
<div style={{ maxWidth: '75%', minWidth: 0 }}>
|
||||||
flexShrink: 0,
|
{/* Thinking panel */}
|
||||||
}}
|
{msg.thinking && (
|
||||||
/>
|
<Collapse size="small" ghost items={[{
|
||||||
|
key: 'thinking', label: <Text type="secondary" style={{ fontSize: 12 }}><BulbOutlined /> 思考过程</Text>,
|
||||||
|
children: <div style={{ fontSize: 13, color: token.colorTextSecondary, whiteSpace: 'pre-wrap', lineHeight: 1.7, maxHeight: 200, overflowY: 'auto' }}>{msg.thinking}</div>,
|
||||||
|
}]} style={{ marginBottom: 8 }} />
|
||||||
|
)}
|
||||||
|
{/* Tool calls */}
|
||||||
|
{msg.toolCalls && msg.toolCalls.length > 0 && (
|
||||||
|
<Collapse size="small" ghost items={[{
|
||||||
|
key: 'tools', label: <Text type="secondary" style={{ fontSize: 12 }}><ToolOutlined /> 工具调用 ({msg.toolCalls.length})</Text>,
|
||||||
|
children: msg.toolCalls.map((t, i) => (
|
||||||
|
<div key={i} style={{ marginBottom: 8, fontSize: 12 }}>
|
||||||
|
<Text code style={{ fontSize: 12 }}>{t.name}</Text>
|
||||||
|
{t.result && <div style={{ marginTop: 4, color: token.colorTextSecondary, maxHeight: 150, overflowY: 'auto', whiteSpace: 'pre-wrap' }}>{t.result}</div>}
|
||||||
|
</div>
|
||||||
|
)),
|
||||||
|
}]} style={{ marginBottom: 8 }} />
|
||||||
|
)}
|
||||||
|
{/* Message content */}
|
||||||
<div style={{
|
<div style={{
|
||||||
maxWidth: '75%', padding: '12px 16px', borderRadius: 12,
|
padding: '12px 16px', borderRadius: 12, lineHeight: 1.8, wordBreak: 'break-word',
|
||||||
background: msg.role === 'user' ? token.colorPrimary : token.colorFillAlter,
|
background: msg.role === 'user' ? token.colorPrimary : token.colorFillAlter,
|
||||||
color: msg.role === 'user' ? '#fff' : token.colorText,
|
color: msg.role === 'user' ? '#fff' : token.colorText,
|
||||||
lineHeight: 1.8, wordBreak: 'break-word',
|
|
||||||
}}>
|
}}>
|
||||||
{msg.role === 'assistant' ? <Markdown content={msg.content} /> : msg.content}
|
{msg.role === 'assistant' ? (
|
||||||
|
msg.content ? <Markdown content={msg.content} /> : <Spin size="small" />
|
||||||
|
) : msg.content}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{loading && (
|
{/* Streaming indicator */}
|
||||||
|
{streaming && (
|
||||||
<div style={{ display: 'flex', gap: 12, marginBottom: 20 }}>
|
<div style={{ display: 'flex', gap: 12, marginBottom: 20 }}>
|
||||||
<Avatar size={32} icon={<RobotOutlined />}
|
<Avatar size={32} icon={<RobotOutlined />} style={{ backgroundColor: token.colorSuccess, flexShrink: 0 }} />
|
||||||
style={{ backgroundColor: token.colorSuccess, flexShrink: 0 }} />
|
<div style={{ padding: '8px 16px', borderRadius: 12, background: token.colorFillAlter }}>
|
||||||
<div style={{
|
<Spin size="small" /> <Text type="secondary" style={{ fontSize: 13 }}>Hermes 执行中...</Text>
|
||||||
maxWidth: '75%', padding: '12px 16px', borderRadius: 12,
|
|
||||||
background: token.colorFillAlter,
|
|
||||||
}}>
|
|
||||||
<Spin size="small" /> <Text type="secondary">Hermes 思考中...</Text>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
<div style={{
|
<div style={{
|
||||||
background: token.colorBgContainer,
|
background: token.colorBgContainer, border: `1px solid ${token.colorBorderSecondary}`,
|
||||||
border: `1px solid ${token.colorBorderSecondary}`,
|
borderRadius: `0 0 ${token.borderRadiusLG}px ${token.borderRadiusLG}px`, padding: '16px 20px',
|
||||||
borderRadius: `0 0 ${token.borderRadiusLG}px ${token.borderRadiusLG}px`,
|
|
||||||
padding: '16px 20px',
|
|
||||||
}}>
|
}}>
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex', alignItems: 'flex-end', gap: 10,
|
display: 'flex', alignItems: 'flex-end', gap: 10,
|
||||||
background: token.colorFillTertiary, borderRadius: 12,
|
background: token.colorFillTertiary, borderRadius: 12,
|
||||||
padding: '8px 8px 8px 14px', border: `1px solid ${token.colorBorderSecondary}`,
|
padding: '8px 8px 8px 14px', border: `1px solid ${token.colorBorderSecondary}`,
|
||||||
}}>
|
}}>
|
||||||
<Input.TextArea
|
<Input.TextArea value={input} onChange={e => setInput(e.target.value)}
|
||||||
value={input}
|
onKeyDown={handleKeyDown} placeholder={inputPlaceholder}
|
||||||
onChange={e => setInput(e.target.value)}
|
autoSize={{ minRows: 1, maxRows: 5 }} disabled={loading || !activeId}
|
||||||
onKeyDown={handleKeyDown}
|
variant="borderless" style={{ flex: 1, resize: 'none', padding: '4px 0', fontSize: 14 }} />
|
||||||
placeholder={activeId ? '输入消息,Enter 发送,Shift+Enter 换行' : '请先新建或选择对话'}
|
{streaming ? (
|
||||||
autoSize={{ minRows: 1, maxRows: 5 }}
|
|
||||||
disabled={loading || !activeId}
|
|
||||||
variant="borderless"
|
|
||||||
style={{ flex: 1, resize: 'none', padding: '4px 0', fontSize: 14 }}
|
|
||||||
/>
|
|
||||||
{loading ? (
|
|
||||||
<Button danger type="primary" icon={<StopOutlined />} onClick={handleStop}
|
<Button danger type="primary" icon={<StopOutlined />} onClick={handleStop}
|
||||||
style={{ borderRadius: 8, height: 34, minWidth: 72 }}>停止</Button>
|
style={{ borderRadius: 8, height: 34, minWidth: 72 }}>停止</Button>
|
||||||
) : (
|
) : (
|
||||||
@ -304,9 +339,5 @@ function ChatPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function TaskAssistant() {
|
export default function TaskAssistant() {
|
||||||
return (
|
return <App><ChatPage /></App>
|
||||||
<App>
|
|
||||||
<ChatPage />
|
|
||||||
</App>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,22 +1,94 @@
|
|||||||
import { api } from './http-client'
|
import { getAccessToken } from './token-store'
|
||||||
|
|
||||||
interface ChatMessage {
|
interface ChatMessage { role: 'user' | 'assistant' | 'system'; content: string }
|
||||||
role: 'user' | 'assistant' | 'system'
|
|
||||||
content: string
|
export type StreamEvent =
|
||||||
|
| { event: 'meta'; conversationId: string }
|
||||||
|
| { event: 'message.delta'; delta: string; runId?: string }
|
||||||
|
| { event: 'reasoning.available'; text: string; runId?: string }
|
||||||
|
| { event: 'tool.start'; toolName?: string; input?: any; runId?: string }
|
||||||
|
| { event: 'tool.result'; output?: string; runId?: string }
|
||||||
|
| { event: 'run.completed'; output?: string; usage?: any; runId?: string }
|
||||||
|
| { event: 'done'; conversationId?: string }
|
||||||
|
| { event: 'stopped' }
|
||||||
|
| { event: 'error'; error: string }
|
||||||
|
|
||||||
|
export async function streamChat(
|
||||||
|
messages: ChatMessage[],
|
||||||
|
conversationId: string | null,
|
||||||
|
onEvent: (e: StreamEvent) => void,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<void> {
|
||||||
|
const token = getAccessToken()
|
||||||
|
const body: Record<string, unknown> = { messages }
|
||||||
|
if (conversationId) body.conversationId = conversationId
|
||||||
|
|
||||||
|
const resp = await fetch('/admin-api/ai/chat/stream', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.text().catch(() => '')
|
||||||
|
throw new Error(`Stream request failed ${resp.status}: ${err}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reader = resp.body?.getReader()
|
||||||
|
if (!reader) throw new Error('No response body')
|
||||||
|
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let buffer = ''
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
buffer += decoder.decode(value, { stream: true })
|
||||||
|
const lines = buffer.split('\n')
|
||||||
|
buffer = lines.pop() || ''
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
try {
|
||||||
|
const event: StreamEvent = JSON.parse(line.slice(6))
|
||||||
|
onEvent(event)
|
||||||
|
} catch { /* skip bad lines */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopChat(runId: string): Promise<void> {
|
||||||
|
const token = getAccessToken()
|
||||||
|
await fetch('/admin-api/ai/chat/stop', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify({ runId }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy non-streaming (kept for fallback)
|
||||||
interface ChatResponse {
|
interface ChatResponse {
|
||||||
content: string
|
content: string
|
||||||
conversationId?: string
|
conversationId?: string
|
||||||
usage?: { model?: string; inputTokens?: number; outputTokens?: number }
|
usage?: { model?: string; inputTokens?: number; outputTokens?: number }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendMessage(
|
export async function sendMessage(
|
||||||
messages: ChatMessage[],
|
messages: ChatMessage[], conversationId?: string, signal?: AbortSignal,
|
||||||
conversationId?: string,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
): Promise<ChatResponse> {
|
): Promise<ChatResponse> {
|
||||||
|
const token = getAccessToken()
|
||||||
const body: Record<string, unknown> = { messages }
|
const body: Record<string, unknown> = { messages }
|
||||||
if (conversationId) body.conversationId = conversationId
|
if (conversationId) body.conversationId = conversationId
|
||||||
return api.post<ChatResponse>('/admin-api/ai/chat', body, signal ? { signal } : undefined)
|
const resp = await fetch('/admin-api/ai/chat', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
const json = await resp.json()
|
||||||
|
if (!json.success) throw new Error(json.message || 'Chat failed')
|
||||||
|
return json.data
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user