🎨 优化前端对话页面 && 优化ai流式传输

Signed-off-by: Echo <1711788888@qq.com>
This commit is contained in:
2026-02-27 22:50:26 +08:00
parent f4e166c5ee
commit 689e8af3df
7 changed files with 721 additions and 320 deletions

4
.gitignore vendored
View File

@@ -24,4 +24,6 @@ dist-ssr
*.sw?
uploads
docs
.claude
.claude
plugs
sillytavern

View File

@@ -202,6 +202,83 @@ func (a *ConversationApi) GetMessageList(c *gin.Context) {
commonResponse.OkWithData(resp, c)
}
// RegenerateMessage
// @Tags AppConversation
// @Summary 重新生成最后一条 AI 回复
// @Produce application/json
// @Param id path int true "对话ID"
// @Param stream query bool false "是否流式传输"
// @Success 200 {object} commonResponse.Response{data=response.MessageResponse} "重新生成成功"
// @Router /app/conversation/:id/regenerate [post]
// @Security ApiKeyAuth
func (a *ConversationApi) RegenerateMessage(c *gin.Context) {
userID := common.GetAppUserID(c)
conversationID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
commonResponse.FailWithMessage("无效的对话ID", c)
return
}
if c.Query("stream") == "true" {
a.regenerateMessageStream(c, userID, uint(conversationID))
return
}
resp, err := service.ServiceGroupApp.AppServiceGroup.ConversationService.RegenerateMessage(userID, uint(conversationID))
if err != nil {
global.GVA_LOG.Error("重新生成消息失败", zap.Error(err))
commonResponse.FailWithMessage(err.Error(), c)
return
}
commonResponse.OkWithData(resp, c)
}
func (a *ConversationApi) regenerateMessageStream(c *gin.Context, userID, conversationID uint) {
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("X-Accel-Buffering", "no")
streamChan := make(chan string, 100)
errorChan := make(chan error, 1)
doneChan := make(chan bool, 1)
go func() {
if err := service.ServiceGroupApp.AppServiceGroup.ConversationService.RegenerateMessageStream(
userID, conversationID, streamChan, doneChan,
); err != nil {
errorChan <- err
}
}()
flusher, ok := c.Writer.(http.Flusher)
if !ok {
commonResponse.FailWithMessage("不支持流式传输", c)
return
}
for {
select {
case chunk := <-streamChan:
c.Writer.Write([]byte("event: message\n"))
c.Writer.Write([]byte(fmt.Sprintf("data: %s\n\n", chunk)))
flusher.Flush()
case err := <-errorChan:
c.Writer.Write([]byte("event: error\n"))
c.Writer.Write([]byte(fmt.Sprintf("data: %s\n\n", err.Error())))
flusher.Flush()
return
case <-doneChan:
c.Writer.Write([]byte("event: done\n"))
c.Writer.Write([]byte("data: \n\n"))
flusher.Flush()
return
case <-c.Request.Context().Done():
return
}
}
}
// SendMessage
// @Tags AppConversation
// @Summary 发送消息

View File

@@ -1,9 +0,0 @@
-- 修复 ai_world_info 表结构
-- 如果表存在旧的 name 字段,需要删除并重新创建
-- 删除旧表(如果存在)
DROP TABLE IF EXISTS ai_character_world_info CASCADE;
DROP TABLE IF EXISTS ai_world_info CASCADE;
-- 表将由 Gorm AutoMigrate 自动创建
-- 重启服务器即可

View File

@@ -21,5 +21,6 @@ func (r *ConversationRouter) InitConversationRouter(Router *gin.RouterGroup) {
conversationRouter.DELETE(":id", conversationApi.DeleteConversation) // 删除对话
conversationRouter.GET(":id/messages", conversationApi.GetMessageList) // 获取消息列表
conversationRouter.POST(":id/message", conversationApi.SendMessage) // 发送消息
conversationRouter.POST(":id/regenerate", conversationApi.RegenerateMessage) // 重新生成消息
}
}

View File

@@ -657,8 +657,31 @@ func (s *ConversationService) SendMessageStream(userID, conversationID uint, req
return errors.New("未找到可用的 AI 配置")
}
// 构建系统提示词和消息列表
// 加载预设
var streamPreset *app.AIPreset
var streamPresetID uint
if len(conversation.Settings) > 0 {
var settings map[string]interface{}
if err := json.Unmarshal(conversation.Settings, &settings); err == nil {
if id, ok := settings["presetId"].(float64); ok {
streamPresetID = uint(id)
}
}
}
if streamPresetID > 0 {
var loadedPreset app.AIPreset
if err := global.GVA_DB.First(&loadedPreset, streamPresetID).Error; err == nil {
streamPreset = &loadedPreset
global.GVA_LOG.Info(fmt.Sprintf("[流式传输] 使用预设: %s (Temperature: %.2f)", streamPreset.Name, streamPreset.Temperature))
global.GVA_DB.Model(streamPreset).Update("use_count", gorm.Expr("use_count + ?", 1))
}
}
// 构建系统提示词(应用预设)
systemPrompt := s.buildSystemPrompt(character)
if streamPreset != nil && streamPreset.SystemPrompt != "" {
systemPrompt = systemPrompt + "\n\n" + streamPreset.SystemPrompt
}
apiMessages := s.buildAPIMessages(messages, systemPrompt)
// 打印发送给AI的完整内容流式传输
@@ -685,9 +708,9 @@ func (s *ConversationService) SendMessageStream(userID, conversationID uint, req
var fullContent string
switch aiConfig.Provider {
case "openai", "custom":
fullContent, err = s.callOpenAIAPIStream(&aiConfig, model, apiMessages, streamChan)
fullContent, err = s.callOpenAIAPIStream(&aiConfig, model, apiMessages, streamPreset, streamChan)
case "anthropic":
fullContent, err = s.callAnthropicAPIStream(&aiConfig, model, apiMessages, systemPrompt, streamChan)
fullContent, err = s.callAnthropicAPIStream(&aiConfig, model, apiMessages, systemPrompt, streamPreset, streamChan)
default:
return fmt.Errorf("不支持的 AI 提供商: %s", aiConfig.Provider)
}
@@ -724,7 +747,7 @@ func (s *ConversationService) SendMessageStream(userID, conversationID uint, req
}
// callOpenAIAPIStream 调用 OpenAI API 流式传输
func (s *ConversationService) callOpenAIAPIStream(config *app.AIConfig, model string, messages []map[string]string, streamChan chan string) (string, error) {
func (s *ConversationService) callOpenAIAPIStream(config *app.AIConfig, model string, messages []map[string]string, preset *app.AIPreset, streamChan chan string) (string, error) {
client := &http.Client{Timeout: 120 * time.Second}
if model == "" {
@@ -734,15 +757,53 @@ func (s *ConversationService) callOpenAIAPIStream(config *app.AIConfig, model st
model = "gpt-4"
}
// 应用预设参数
temperature := 0.7
maxTokens := 2000
var topP *float64
var frequencyPenalty *float64
var presencePenalty *float64
var stopSequences []string
if preset != nil {
temperature = preset.Temperature
maxTokens = preset.MaxTokens
if preset.TopP > 0 {
topP = &preset.TopP
}
if preset.FrequencyPenalty != 0 {
frequencyPenalty = &preset.FrequencyPenalty
}
if preset.PresencePenalty != 0 {
presencePenalty = &preset.PresencePenalty
}
if len(preset.StopSequences) > 0 {
json.Unmarshal(preset.StopSequences, &stopSequences)
}
}
// 构建请求体,启用流式传输
requestBody := map[string]interface{}{
"model": model,
"messages": messages,
"temperature": 0.7,
"max_tokens": 2000,
"temperature": temperature,
"max_tokens": maxTokens,
"stream": true,
}
if topP != nil {
requestBody["top_p"] = *topP
}
if frequencyPenalty != nil {
requestBody["frequency_penalty"] = *frequencyPenalty
}
if presencePenalty != nil {
requestBody["presence_penalty"] = *presencePenalty
}
if len(stopSequences) > 0 {
requestBody["stop"] = stopSequences
}
bodyBytes, err := json.Marshal(requestBody)
if err != nil {
return "", fmt.Errorf("序列化请求失败: %v", err)
@@ -815,7 +876,7 @@ func (s *ConversationService) callOpenAIAPIStream(config *app.AIConfig, model st
}
// callAnthropicAPIStream 调用 Anthropic API 流式传输
func (s *ConversationService) callAnthropicAPIStream(config *app.AIConfig, model string, messages []map[string]string, systemPrompt string, streamChan chan string) (string, error) {
func (s *ConversationService) callAnthropicAPIStream(config *app.AIConfig, model string, messages []map[string]string, systemPrompt string, preset *app.AIPreset, streamChan chan string) (string, error) {
client := &http.Client{Timeout: 120 * time.Second}
if model == "" {
@@ -833,14 +894,43 @@ func (s *ConversationService) callAnthropicAPIStream(config *app.AIConfig, model
}
}
// 应用预设参数
maxTokens := 2000
var temperature *float64
var topP *float64
var stopSequences []string
if preset != nil {
maxTokens = preset.MaxTokens
if preset.Temperature > 0 {
temperature = &preset.Temperature
}
if preset.TopP > 0 {
topP = &preset.TopP
}
if len(preset.StopSequences) > 0 {
json.Unmarshal(preset.StopSequences, &stopSequences)
}
}
requestBody := map[string]interface{}{
"model": model,
"messages": apiMessages,
"system": systemPrompt,
"max_tokens": 2000,
"max_tokens": maxTokens,
"stream": true,
}
if temperature != nil {
requestBody["temperature"] = *temperature
}
if topP != nil {
requestBody["top_p"] = *topP
}
if len(stopSequences) > 0 {
requestBody["stop_sequences"] = stopSequences
}
bodyBytes, err := json.Marshal(requestBody)
if err != nil {
return "", fmt.Errorf("序列化请求失败: %v", err)
@@ -910,6 +1000,205 @@ func (s *ConversationService) callAnthropicAPIStream(config *app.AIConfig, model
return fullContent.String(), nil
}
// RegenerateMessage 重新生成最后一条 AI 回复(非流式)
func (s *ConversationService) RegenerateMessage(userID, conversationID uint) (*response.MessageResponse, error) {
var conversation app.Conversation
err := global.GVA_DB.Where("id = ? AND user_id = ?", conversationID, userID).First(&conversation).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("对话不存在或无权访问")
}
return nil, err
}
var character app.AICharacter
err = global.GVA_DB.Where("id = ?", conversation.CharacterID).First(&character).Error
if err != nil {
return nil, errors.New("角色卡不存在")
}
// 删除最后一条 AI 回复
var lastAssistantMsg app.Message
if err = global.GVA_DB.Where("conversation_id = ? AND role = ?", conversationID, "assistant").
Order("created_at DESC").First(&lastAssistantMsg).Error; err == nil {
global.GVA_DB.Delete(&lastAssistantMsg)
global.GVA_DB.Model(&conversation).Updates(map[string]interface{}{
"message_count": gorm.Expr("GREATEST(message_count - 1, 0)"),
"token_count": gorm.Expr("GREATEST(token_count - ?, 0)", lastAssistantMsg.TokenCount),
})
}
// 获取删除后的消息历史
var messages []app.Message
err = global.GVA_DB.Where("conversation_id = ?", conversationID).
Order("created_at DESC").Limit(10).Find(&messages).Error
if err != nil {
return nil, err
}
if len(messages) == 0 {
return nil, errors.New("没有可用的消息历史")
}
for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
messages[i], messages[j] = messages[j], messages[i]
}
aiResponse, err := s.callAIService(conversation, character, messages)
if err != nil {
return nil, err
}
assistantMessage := app.Message{
ConversationID: conversationID,
Role: "assistant",
Content: aiResponse,
TokenCount: len(aiResponse) / 4,
}
if err = global.GVA_DB.Create(&assistantMessage).Error; err != nil {
return nil, err
}
global.GVA_DB.Model(&conversation).Updates(map[string]interface{}{
"message_count": gorm.Expr("message_count + ?", 1),
"token_count": gorm.Expr("token_count + ?", assistantMessage.TokenCount),
})
resp := response.ToMessageResponse(&assistantMessage)
return &resp, nil
}
// RegenerateMessageStream 流式重新生成最后一条 AI 回复
func (s *ConversationService) RegenerateMessageStream(userID, conversationID uint, streamChan chan string, doneChan chan bool) error {
defer close(streamChan)
defer close(doneChan)
var conversation app.Conversation
err := global.GVA_DB.Where("id = ? AND user_id = ?", conversationID, userID).First(&conversation).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("对话不存在或无权访问")
}
return err
}
var character app.AICharacter
err = global.GVA_DB.Where("id = ?", conversation.CharacterID).First(&character).Error
if err != nil {
return errors.New("角色卡不存在")
}
// 删除最后一条 AI 回复
var lastAssistantMsg app.Message
if err = global.GVA_DB.Where("conversation_id = ? AND role = ?", conversationID, "assistant").
Order("created_at DESC").First(&lastAssistantMsg).Error; err == nil {
global.GVA_DB.Delete(&lastAssistantMsg)
global.GVA_DB.Model(&conversation).Updates(map[string]interface{}{
"message_count": gorm.Expr("GREATEST(message_count - 1, 0)"),
"token_count": gorm.Expr("GREATEST(token_count - ?, 0)", lastAssistantMsg.TokenCount),
})
}
// 获取删除后的消息历史
var messages []app.Message
err = global.GVA_DB.Where("conversation_id = ?", conversationID).
Order("created_at DESC").Limit(10).Find(&messages).Error
if err != nil {
return err
}
if len(messages) == 0 {
return errors.New("没有可用的消息历史")
}
for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
messages[i], messages[j] = messages[j], messages[i]
}
// 获取 AI 配置
var aiConfig app.AIConfig
var configID uint
if len(conversation.Settings) > 0 {
var settings map[string]interface{}
if err := json.Unmarshal(conversation.Settings, &settings); err == nil {
if id, ok := settings["aiConfigId"].(float64); ok {
configID = uint(id)
}
}
}
if configID > 0 {
err = global.GVA_DB.Where("id = ? AND is_active = ?", configID, true).First(&aiConfig).Error
}
if err != nil || configID == 0 {
err = global.GVA_DB.Where("is_active = ?", true).
Order("is_default DESC, created_at DESC").
First(&aiConfig).Error
}
if err != nil {
return errors.New("未找到可用的 AI 配置")
}
// 加载预设
var preset *app.AIPreset
var presetID uint
if len(conversation.Settings) > 0 {
var settings map[string]interface{}
if err := json.Unmarshal(conversation.Settings, &settings); err == nil {
if id, ok := settings["presetId"].(float64); ok {
presetID = uint(id)
}
}
}
if presetID > 0 {
var loadedPreset app.AIPreset
if err := global.GVA_DB.First(&loadedPreset, presetID).Error; err == nil {
preset = &loadedPreset
}
}
systemPrompt := s.buildSystemPrompt(character)
if preset != nil && preset.SystemPrompt != "" {
systemPrompt = systemPrompt + "\n\n" + preset.SystemPrompt
}
apiMessages := s.buildAPIMessages(messages, systemPrompt)
model := aiConfig.DefaultModel
if model == "" {
model = conversation.Model
}
if model == "" {
model = "gpt-4"
}
var fullContent string
switch aiConfig.Provider {
case "openai", "custom":
fullContent, err = s.callOpenAIAPIStream(&aiConfig, model, apiMessages, preset, streamChan)
case "anthropic":
fullContent, err = s.callAnthropicAPIStream(&aiConfig, model, apiMessages, systemPrompt, preset, streamChan)
default:
return fmt.Errorf("不支持的 AI 提供商: %s", aiConfig.Provider)
}
if err != nil {
return err
}
assistantMessage := app.Message{
ConversationID: conversationID,
Role: "assistant",
Content: fullContent,
TokenCount: len(fullContent) / 4,
}
if err = global.GVA_DB.Create(&assistantMessage).Error; err != nil {
return err
}
global.GVA_DB.Model(&conversation).Updates(map[string]interface{}{
"message_count": gorm.Expr("message_count + ?", 1),
"token_count": gorm.Expr("token_count + ?", assistantMessage.TokenCount),
})
doneChan <- true
return nil
}
func (s *ConversationService) buildAPIMessages(messages []app.Message, systemPrompt string) []map[string]string {
apiMessages := make([]map[string]string, 0, len(messages)+1)

View File

@@ -106,4 +106,9 @@ export const conversationApi = {
updateConversationSettings: (conversationId: number, settings: Record<string, any>) => {
return apiClient.put(`/app/conversation/${conversationId}/settings`, { settings })
},
// 重新生成最后一条 AI 回复(非流式)
regenerateMessage: (conversationId: number) => {
return apiClient.post<Message>(`/app/conversation/${conversationId}/regenerate`)
},
}

View File

@@ -1,5 +1,6 @@
import {
Check,
ChevronDown,
Copy,
Download,
Mic,
@@ -13,7 +14,6 @@ import {
Zap
} from 'lucide-react'
import {useEffect, useRef, useState} from 'react'
import {createPortal} from 'react-dom'
import {type Conversation, conversationApi, type Message} from '../api/conversation'
import {type Character} from '../api/character'
import {type AIConfig, aiConfigApi} from '../api/aiConfig'
@@ -36,7 +36,7 @@ export default function ChatArea({ conversation, character, onConversationUpdate
const [aiConfigs, setAiConfigs] = useState<AIConfig[]>([])
const [selectedConfigId, setSelectedConfigId] = useState<number>()
const [showModelSelector, setShowModelSelector] = useState(false)
const [streamEnabled, setStreamEnabled] = useState(true) // 默认启用流式传输
const [streamEnabled, setStreamEnabled] = useState(true)
const [presets, setPresets] = useState<Preset[]>([])
const [selectedPresetId, setSelectedPresetId] = useState<number>()
const [showPresetSelector, setShowPresetSelector] = useState(false)
@@ -46,27 +46,19 @@ export default function ChatArea({ conversation, character, onConversationUpdate
const presetSelectorRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
// 点击外部关闭下拉菜单
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement
// 检查是否点击在模型选择器外部
if (showModelSelector && modelSelectorRef.current && !modelSelectorRef.current.contains(target)) {
setShowModelSelector(false)
}
// 检查是否点击在预设选择器外部
if (showPresetSelector && presetSelectorRef.current && !presetSelectorRef.current.contains(target)) {
setShowPresetSelector(false)
}
// 检查是否点击在菜单外部
if (showMenu && menuRef.current && !menuRef.current.contains(target)) {
setShowMenu(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showModelSelector, showPresetSelector, showMenu])
@@ -86,10 +78,7 @@ export default function ChatArea({ conversation, character, onConversationUpdate
const loadMessages = async () => {
try {
setLoading(true)
const response = await conversationApi.getMessageList(conversation.id, {
page: 1,
pageSize: 100,
})
const response = await conversationApi.getMessageList(conversation.id, { page: 1, pageSize: 100 })
setMessages(response.data.list || [])
} catch (err) {
console.error('加载消息失败:', err)
@@ -101,8 +90,7 @@ export default function ChatArea({ conversation, character, onConversationUpdate
const loadAIConfigs = async () => {
try {
const response = await aiConfigApi.getAIConfigList()
const activeConfigs = response.data.list.filter(config => config.isActive)
setAiConfigs(activeConfigs)
setAiConfigs(response.data.list.filter(config => config.isActive))
} catch (err) {
console.error('加载 AI 配置失败:', err)
}
@@ -114,9 +102,7 @@ export default function ChatArea({ conversation, character, onConversationUpdate
const settings = typeof conversation.settings === 'string'
? JSON.parse(conversation.settings)
: conversation.settings
if (settings.aiConfigId) {
setSelectedConfigId(settings.aiConfigId)
}
if (settings.aiConfigId) setSelectedConfigId(settings.aiConfigId)
} catch (e) {
console.error('解析设置失败:', e)
}
@@ -133,6 +119,10 @@ export default function ChatArea({ conversation, character, onConversationUpdate
}
const loadCurrentPreset = () => {
if (conversation.presetId) {
setSelectedPresetId(conversation.presetId)
return
}
if (conversation.settings) {
try {
const settings = typeof conversation.settings === 'string'
@@ -140,25 +130,28 @@ export default function ChatArea({ conversation, character, onConversationUpdate
: conversation.settings
if (settings.presetId) {
setSelectedPresetId(settings.presetId)
return
}
} catch (e) {
console.error('解析设置失败:', e)
}
}
setSelectedPresetId(undefined)
}
const handlePresetChange = async (presetId: number) => {
const handlePresetChange = async (presetId: number | null) => {
try {
const settings = conversation.settings
? (typeof conversation.settings === 'string' ? JSON.parse(conversation.settings) : conversation.settings)
? (typeof conversation.settings === 'string' ? JSON.parse(conversation.settings) : { ...conversation.settings })
: {}
settings.presetId = presetId
if (presetId === null) {
delete settings.presetId
} else {
settings.presetId = presetId
}
await conversationApi.updateConversationSettings(conversation.id, settings)
setSelectedPresetId(presetId)
setSelectedPresetId(presetId ?? undefined)
setShowPresetSelector(false)
const convResp = await conversationApi.getConversationById(conversation.id)
onConversationUpdate(convResp.data)
} catch (err) {
@@ -167,18 +160,19 @@ export default function ChatArea({ conversation, character, onConversationUpdate
}
}
const handleModelChange = async (configId: number) => {
const handleModelChange = async (configId: number | null) => {
try {
const settings = conversation.settings
? (typeof conversation.settings === 'string' ? JSON.parse(conversation.settings) : conversation.settings)
? (typeof conversation.settings === 'string' ? JSON.parse(conversation.settings) : { ...conversation.settings })
: {}
settings.aiConfigId = configId
if (configId === null) {
delete settings.aiConfigId
} else {
settings.aiConfigId = configId
}
await conversationApi.updateConversationSettings(conversation.id, settings)
setSelectedConfigId(configId)
setSelectedConfigId(configId ?? undefined)
setShowModelSelector(false)
const convResp = await conversationApi.getConversationById(conversation.id)
onConversationUpdate(convResp.data)
} catch (err) {
@@ -192,16 +186,12 @@ export default function ChatArea({ conversation, character, onConversationUpdate
}
const handleSend = async () => {
// 防止重复发送
if (!inputValue.trim() || sending) return
const userMessage = inputValue.trim()
// 立即清空输入框和设置发送状态,防止重复触发
setInputValue('')
setSending(true)
// 立即显示用户消息
const tempUserMessage: Message = {
id: Date.now(),
conversationId: conversation.id,
@@ -210,9 +200,8 @@ export default function ChatArea({ conversation, character, onConversationUpdate
tokenCount: 0,
createdAt: new Date().toISOString(),
}
setMessages((prev) => [...prev, tempUserMessage])
setMessages(prev => [...prev, tempUserMessage])
// 创建临时AI消息用于流式显示
const tempAIMessage: Message = {
id: Date.now() + 1,
conversationId: conversation.id,
@@ -224,10 +213,7 @@ export default function ChatArea({ conversation, character, onConversationUpdate
try {
if (streamEnabled) {
// 流式传输
console.log('[Stream] 开始流式传输...')
setMessages((prev) => [...prev, tempAIMessage])
setMessages(prev => [...prev, tempAIMessage])
const response = await fetch(
`${import.meta.env.VITE_API_BASE_URL || '/api'}/app/conversation/${conversation.id}/message?stream=true`,
{
@@ -239,58 +225,34 @@ export default function ChatArea({ conversation, character, onConversationUpdate
body: JSON.stringify({ content: userMessage }),
}
)
if (!response.ok) throw new Error('流式传输失败')
if (!response.ok) {
throw new Error('流式传输失败')
}
console.log('[Stream] 连接成功,开始接收数据...')
const reader = response.body?.getReader()
const decoder = new TextDecoder()
if (reader) {
let fullContent = ''
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) {
console.log('[Stream] 传输完成')
break
}
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
// 保留最后一行(可能不完整)
buffer = lines.pop() || ''
let currentEvent = ''
for (const line of lines) {
if (line.startsWith('event: ')) {
currentEvent = line.slice(7).trim()
console.log('[Stream] 事件类型:', currentEvent)
} else if (line.startsWith('data: ')) {
const data = line.slice(6).trim()
if (currentEvent === 'message') {
// 消息内容 - 后端现在发送的是纯文本不再是JSON
fullContent += data
console.log('[Stream] 接收内容片段:', data)
// 实时更新临时AI消息的内容
setMessages((prev) =>
prev.map((m) =>
m.id === tempAIMessage.id ? { ...m, content: fullContent } : m
)
setMessages(prev =>
prev.map(m => m.id === tempAIMessage.id ? { ...m, content: fullContent } : m)
)
} else if (currentEvent === 'done') {
// 流式传输完成
console.log('[Stream] 收到完成信号,重新加载消息')
await loadMessages()
break
} else if (currentEvent === 'error') {
// 错误处理
console.error('[Stream] 错误:', data)
throw new Error(data)
}
currentEvent = ''
@@ -298,28 +260,18 @@ export default function ChatArea({ conversation, character, onConversationUpdate
}
}
}
// 更新对话信息
const convResp = await conversationApi.getConversationById(conversation.id)
onConversationUpdate(convResp.data)
} else {
// 普通传输
const response = await conversationApi.sendMessage(conversation.id, {
content: userMessage,
})
// 更新消息列表包含AI回复
await conversationApi.sendMessage(conversation.id, { content: userMessage })
await loadMessages()
// 更新对话信息
const convResp = await conversationApi.getConversationById(conversation.id)
onConversationUpdate(convResp.data)
}
} catch (err: any) {
console.error('发送消息失败:', err)
alert(err.response?.data?.msg || '发送消息失败,请重试')
// 移除临时消息
setMessages((prev) => prev.filter((m) => m.id !== tempUserMessage.id && m.id !== tempAIMessage.id))
setMessages(prev => prev.filter(m => m.id !== tempUserMessage.id && m.id !== tempAIMessage.id))
} finally {
setSending(false)
}
@@ -340,22 +292,82 @@ export default function ChatArea({ conversation, character, onConversationUpdate
const handleRegenerateResponse = async () => {
if (messages.length === 0 || sending) return
// 找到最后一条用户消息
const lastUserMessage = [...messages].reverse().find(m => m.role === 'user')
if (!lastUserMessage) return
const hasAssistantMsg = messages.some(m => m.role === 'assistant')
if (!hasAssistantMsg) return
setSending(true)
const lastAssistantIndex = [...messages].map(m => m.role).lastIndexOf('assistant')
if (lastAssistantIndex !== -1) {
setMessages(prev => prev.filter((_, i) => i !== lastAssistantIndex))
}
const tempAIMessage: Message = {
id: Date.now(),
conversationId: conversation.id,
role: 'assistant',
content: '',
tokenCount: 0,
createdAt: new Date().toISOString(),
}
try {
await conversationApi.sendMessage(conversation.id, {
content: lastUserMessage.content,
})
await loadMessages()
if (streamEnabled) {
setMessages(prev => [...prev, tempAIMessage])
const response = await fetch(
`${import.meta.env.VITE_API_BASE_URL || '/api'}/app/conversation/${conversation.id}/regenerate?stream=true`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
}
)
if (!response.ok) throw new Error('重新生成失败')
const reader = response.body?.getReader()
const decoder = new TextDecoder()
if (reader) {
let fullContent = ''
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() || ''
let currentEvent = ''
for (const line of lines) {
if (line.startsWith('event: ')) {
currentEvent = line.slice(7).trim()
} else if (line.startsWith('data: ')) {
const data = line.slice(6).trim()
if (currentEvent === 'message') {
fullContent += data
setMessages(prev =>
prev.map(m => m.id === tempAIMessage.id ? { ...m, content: fullContent } : m)
)
} else if (currentEvent === 'done') {
await loadMessages()
break
} else if (currentEvent === 'error') {
throw new Error(data)
}
currentEvent = ''
}
}
}
}
} else {
await conversationApi.regenerateMessage(conversation.id)
await loadMessages()
}
const convResp = await conversationApi.getConversationById(conversation.id)
onConversationUpdate(convResp.data)
} catch (err) {
} catch (err: any) {
console.error('重新生成失败:', err)
alert('重新生成失败,请重试')
alert(err.message || '重新生成失败,请重试')
await loadMessages()
} finally {
setSending(false)
}
@@ -363,7 +375,6 @@ export default function ChatArea({ conversation, character, onConversationUpdate
const handleDeleteConversation = async () => {
if (!confirm('确定要删除这个对话吗?')) return
try {
await conversationApi.deleteConversation(conversation.id)
window.location.href = '/chat'
@@ -374,9 +385,7 @@ export default function ChatArea({ conversation, character, onConversationUpdate
}
const handleExportConversation = () => {
const content = messages
.map((msg) => `[${msg.role}] ${msg.content}`)
.join('\n\n')
const content = messages.map(msg => `[${msg.role}] ${msg.content}`).join('\n\n')
const blob = new Blob([content], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
@@ -393,7 +402,6 @@ export default function ChatArea({ conversation, character, onConversationUpdate
const minutes = Math.floor(diff / 60000)
const hours = Math.floor(diff / 3600000)
const days = Math.floor(diff / 86400000)
if (minutes < 1) return '刚刚'
if (minutes < 60) return `${minutes}分钟前`
if (hours < 24) return `${hours}小时前`
@@ -401,259 +409,294 @@ export default function ChatArea({ conversation, character, onConversationUpdate
return date.toLocaleDateString('zh-CN')
}
const selectedConfig = aiConfigs.find(c => c.id === selectedConfigId)
const selectedPreset = presets.find(p => p.id === selectedPresetId)
const lastAssistantMsgId = [...messages].reverse().find(m => m.role === 'assistant')?.id
return (
<div className="flex-1 flex flex-col">
<div className="p-4 glass border-b border-white/10">
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<h2 className="text-lg font-semibold truncate">{conversation.title}</h2>
<div className="flex items-center gap-2 text-sm text-white/60">
<span> {character.name} </span>
</div>
<div className="flex-1 flex flex-col min-w-0">
{/* 顶部工具栏 */}
<div className="px-4 py-3 glass border-b border-white/10">
<div className="flex items-center justify-between gap-3">
{/* 左侧:标题 */}
<div className="min-w-0 flex-1">
<h2 className="text-base font-semibold truncate">{conversation.title}</h2>
<p className="text-xs text-white/50 truncate"> {character.name} </p>
</div>
<div className="flex items-center gap-2">
{/* 右侧:工具按钮组 */}
<div className="flex items-center gap-1 flex-shrink-0">
{/* 模型选择器 */}
<div className="relative" ref={modelSelectorRef}>
<button
onClick={(e) => {
e.stopPropagation()
setShowModelSelector(!showModelSelector)
onClick={() => {
setShowModelSelector(v => !v)
setShowPresetSelector(false)
setShowMenu(false)
}}
className="flex items-center gap-2 px-3 py-2 glass-hover rounded-lg cursor-pointer text-sm"
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all cursor-pointer
${showModelSelector ? 'bg-white/15 text-white' : 'glass-hover text-white/70 hover:text-white'}`}
title="切换模型"
>
<Zap className="w-4 h-4 text-secondary" />
<span className="text-xs">
{selectedConfigId
? aiConfigs.find(c => c.id === selectedConfigId)?.name || '默认模型'
: '默认模型'}
<Zap className="w-3.5 h-3.5 text-yellow-400 flex-shrink-0" />
<span className="max-w-[80px] truncate">
{selectedConfig ? selectedConfig.name : '默认模型'}
</span>
<ChevronDown className={`w-3 h-3 flex-shrink-0 transition-transform ${showModelSelector ? 'rotate-180' : ''}`} />
</button>
{showModelSelector && createPortal(
<div
className="fixed glass rounded-xl p-2 min-w-[200px] shadow-xl"
style={{
top: modelSelectorRef.current ? modelSelectorRef.current.getBoundingClientRect().bottom + 8 : 0,
left: modelSelectorRef.current ? modelSelectorRef.current.getBoundingClientRect().right - 200 : 0,
zIndex: 9999
}}
onClick={(e) => e.stopPropagation()}
>
{aiConfigs.length === 0 ? (
<div className="px-4 py-3 text-xs text-white/60 text-center">
</div>
) : (
aiConfigs.map((config) => (
{showModelSelector && (
<div className="absolute right-0 top-full mt-1 z-50 w-56 glass rounded-xl shadow-2xl border border-white/15 overflow-hidden">
<div className="p-1">
<button
onClick={() => handleModelChange(null)}
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left cursor-pointer transition-all
${!selectedConfigId ? 'bg-primary/20 text-white' : 'text-white/60 hover:bg-white/10 hover:text-white'}`}
>
<span className="flex-1"></span>
{!selectedConfigId && <Check className="w-3.5 h-3.5 text-primary" />}
</button>
{aiConfigs.length > 0 && <div className="my-1 border-t border-white/10" />}
{aiConfigs.map(config => (
<button
key={config.id}
onClick={(e) => {
e.stopPropagation()
handleModelChange(config.id)
}}
className={`w-full px-3 py-2 glass-hover rounded-lg text-sm text-left cursor-pointer ${
selectedConfigId === config.id ? 'bg-primary/20 ring-1 ring-primary' : ''
}`}
onClick={() => handleModelChange(config.id)}
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left cursor-pointer transition-all
${selectedConfigId === config.id ? 'bg-primary/20 text-white' : 'text-white/70 hover:bg-white/10 hover:text-white'}`}
>
<div className="font-medium">{config.name}</div>
<div className="text-xs text-white/60 mt-0.5">
{config.provider} {config.defaultModel}
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{config.name}</div>
<div className="text-xs text-white/40 truncate">{config.provider} · {config.defaultModel}</div>
</div>
{selectedConfigId === config.id && <Check className="w-3.5 h-3.5 text-primary flex-shrink-0" />}
</button>
))
)}
</div>,
document.body
))}
{aiConfigs.length === 0 && (
<div className="px-3 py-3 text-xs text-white/40 text-center"></div>
)}
</div>
</div>
)}
</div>
{/* 预设选择器 */}
<div className="relative" ref={presetSelectorRef}>
<button
onClick={(e) => {
e.stopPropagation()
setShowPresetSelector(!showPresetSelector)
onClick={() => {
setShowPresetSelector(v => !v)
setShowModelSelector(false)
setShowMenu(false)
}}
className="flex items-center gap-2 px-3 py-2 glass-hover rounded-lg cursor-pointer text-sm"
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all cursor-pointer
${showPresetSelector ? 'bg-white/15 text-white' : 'glass-hover text-white/70 hover:text-white'}`}
title="选择预设"
>
<Settings className="w-4 h-4 text-primary" />
<span className="text-xs">
{selectedPresetId
? presets.find(p => p.id === selectedPresetId)?.name || '默认预设'
: '默认预设'}
<Settings className="w-3.5 h-3.5 text-purple-400 flex-shrink-0" />
<span className="max-w-[80px] truncate">
{selectedPreset ? selectedPreset.name : '默认预设'}
</span>
<ChevronDown className={`w-3 h-3 flex-shrink-0 transition-transform ${showPresetSelector ? 'rotate-180' : ''}`} />
</button>
{showPresetSelector && createPortal(
<div
className="fixed glass rounded-xl p-2 min-w-[200px] shadow-xl"
style={{
top: presetSelectorRef.current ? presetSelectorRef.current.getBoundingClientRect().bottom + 8 : 0,
left: presetSelectorRef.current ? presetSelectorRef.current.getBoundingClientRect().right - 200 : 0,
zIndex: 9999
}}
onClick={(e) => e.stopPropagation()}
>
{presets.length === 0 ? (
<div className="px-4 py-3 text-xs text-white/60 text-center">
</div>
) : (
presets.map((preset) => (
{showPresetSelector && (
<div className="absolute right-0 top-full mt-1 z-50 w-56 glass rounded-xl shadow-2xl border border-white/15 overflow-hidden">
<div className="p-1">
<button
onClick={() => handlePresetChange(null)}
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left cursor-pointer transition-all
${!selectedPresetId ? 'bg-primary/20 text-white' : 'text-white/60 hover:bg-white/10 hover:text-white'}`}
>
<span className="flex-1"></span>
{!selectedPresetId && <Check className="w-3.5 h-3.5 text-primary" />}
</button>
{presets.length > 0 && <div className="my-1 border-t border-white/10" />}
{presets.map(preset => (
<button
key={preset.id}
onClick={(e) => {
e.stopPropagation()
handlePresetChange(preset.id)
}}
className={`w-full px-3 py-2 glass-hover rounded-lg text-sm text-left cursor-pointer ${
selectedPresetId === preset.id ? 'bg-primary/20 ring-1 ring-primary' : ''
}`}
onClick={() => handlePresetChange(preset.id)}
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left cursor-pointer transition-all
${selectedPresetId === preset.id ? 'bg-primary/20 text-white' : 'text-white/70 hover:bg-white/10 hover:text-white'}`}
>
<div className="font-medium">{preset.name}</div>
<div className="text-xs text-white/60 mt-0.5">
{preset.description || `温度: ${preset.temperature}`}
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{preset.name}</div>
<div className="text-xs text-white/40"> {preset.temperature}</div>
</div>
{selectedPresetId === preset.id && <Check className="w-3.5 h-3.5 text-primary flex-shrink-0" />}
</button>
))
)}
</div>,
document.body
))}
{presets.length === 0 && (
<div className="px-3 py-3 text-xs text-white/40 text-center"></div>
)}
</div>
</div>
)}
</div>
{/* 分隔线 */}
<div className="w-px h-5 bg-white/10 mx-1" />
{/* 流式传输切换 */}
<button
onClick={() => setStreamEnabled(!streamEnabled)}
className={`p-2 glass-hover rounded-lg cursor-pointer ${
streamEnabled ? 'text-green-400' : 'text-white/60'
onClick={() => setStreamEnabled(v => !v)}
className={`p-1.5 rounded-lg cursor-pointer transition-all ${
streamEnabled
? 'text-emerald-400 bg-emerald-400/10 hover:bg-emerald-400/20'
: 'text-white/30 glass-hover hover:text-white/60'
}`}
title={streamEnabled ? '流式传输已启用' : '流式传输已禁用'}
title={streamEnabled ? '流式传输已启用(点击关闭)' : '流式传输已关闭(点击开启)'}
>
<Waves className="w-5 h-5" />
</button>
<button
onClick={handleRegenerateResponse}
disabled={sending || messages.length === 0}
className="p-2 glass-hover rounded-lg cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
title="重新生成回复"
>
<RefreshCw className="w-5 h-5" />
<Waves className="w-4 h-4" />
</button>
{/* 更多菜单 */}
<div className="relative" ref={menuRef}>
<button
onClick={(e) => {
e.stopPropagation()
setShowMenu(!showMenu)
onClick={() => {
setShowMenu(v => !v)
setShowModelSelector(false)
setShowPresetSelector(false)
}}
className="p-2 glass-hover rounded-lg cursor-pointer"
className={`p-1.5 rounded-lg cursor-pointer transition-all
${showMenu ? 'bg-white/15 text-white' : 'glass-hover text-white/60 hover:text-white'}`}
>
<MoreVertical className="w-5 h-5" />
<MoreVertical className="w-4 h-4" />
</button>
{showMenu && createPortal(
<div
className="fixed glass rounded-xl p-2 min-w-[160px] shadow-xl"
style={{
top: menuRef.current ? menuRef.current.getBoundingClientRect().bottom + 8 : 0,
left: menuRef.current ? menuRef.current.getBoundingClientRect().right - 160 : 0,
zIndex: 9999
}}
onClick={(e) => e.stopPropagation()}
>
<button
onClick={handleExportConversation}
className="w-full px-4 py-2 glass-hover rounded-lg text-sm text-left cursor-pointer flex items-center gap-2"
>
<Download className="w-4 h-4" />
</button>
<button
onClick={handleDeleteConversation}
className="w-full px-4 py-2 glass-hover rounded-lg text-sm text-left cursor-pointer flex items-center gap-2 text-red-400"
>
<Trash2 className="w-4 h-4" />
</button>
</div>,
document.body
{showMenu && (
<div className="absolute right-0 top-full mt-1 z-50 w-44 glass rounded-xl shadow-2xl border border-white/15 overflow-hidden">
<div className="p-1">
<button
onClick={() => { handleExportConversation(); setShowMenu(false) }}
className="w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm text-left cursor-pointer text-white/70 hover:bg-white/10 hover:text-white transition-all"
>
<Download className="w-4 h-4" />
</button>
<div className="my-1 border-t border-white/10" />
<button
onClick={() => { handleDeleteConversation(); setShowMenu(false) }}
className="w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm text-left cursor-pointer text-red-400 hover:bg-red-400/10 transition-all"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
)}
</div>
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{/* 消息列表 */}
<div className="flex-1 overflow-y-auto px-4 py-6 space-y-5">
{loading ? (
<div className="text-center text-white/60">...</div>
<div className="flex justify-center items-center py-12">
<div className="flex gap-1.5">
{[0, 0.15, 0.3].map((delay, i) => (
<div key={i} className="w-2 h-2 bg-primary/60 rounded-full animate-bounce" style={{ animationDelay: `${delay}s` }} />
))}
</div>
</div>
) : messages.length === 0 ? (
<div className="text-center text-white/60 py-12">
<p className="mb-2"></p>
<p className="text-sm"></p>
<div className="flex flex-col items-center justify-center py-16 text-center">
{character.avatar && (
<img src={character.avatar} alt={character.name} className="w-16 h-16 rounded-full object-cover mb-4 ring-2 ring-white/10" />
)}
<p className="text-white/50 text-sm"> {character.name} </p>
</div>
) : (
messages.map((msg) => (
<div
key={msg.id}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'} group`}
>
messages.map((msg) => {
const isLastAssistant = msg.id === lastAssistantMsgId
return (
<div
className={`max-w-2xl min-w-0 relative ${
msg.role === 'user'
? 'glass-hover rounded-2xl rounded-br-md p-4'
: 'glass-hover rounded-2xl rounded-bl-md p-4'
}`}
style={{ wordBreak: 'break-word', overflowWrap: 'anywhere' }}
key={msg.id}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'} group`}
>
{/* 助手头像 */}
{msg.role === 'assistant' && (
<div className="flex items-center gap-2 mb-2">
{character.avatar && (
<img
src={character.avatar}
alt={character.name}
className="w-6 h-6 rounded-full object-cover"
/>
<div className="flex-shrink-0 mr-2.5 mt-1">
{character.avatar ? (
<img src={character.avatar} alt={character.name} className="w-8 h-8 rounded-full object-cover ring-1 ring-white/10" />
) : (
<div className="w-8 h-8 rounded-full bg-primary/30 flex items-center justify-center text-xs font-medium">
{character.name[0]}
</div>
)}
<span className="text-sm font-medium text-primary">{character.name}</span>
</div>
)}
<MessageContent
content={msg.content}
role={msg.role}
onChoiceSelect={(choice) => {
setInputValue(choice)
// 自动聚焦到输入框
textareaRef.current?.focus()
}}
/>
<div className="flex items-center justify-between mt-2">
<span className="text-xs text-white/40">{formatTime(msg.createdAt)}</span>
<button
onClick={() => handleCopyMessage(msg.content, msg.id)}
className="opacity-0 group-hover:opacity-100 p-1 glass-hover rounded transition-opacity cursor-pointer"
title="复制消息"
<div className={`max-w-[70%] min-w-0 flex flex-col ${msg.role === 'user' ? 'items-end' : 'items-start'}`}>
{/* 助手名称 */}
{msg.role === 'assistant' && (
<span className="text-xs text-white/40 mb-1 ml-1">{character.name}</span>
)}
{/* 消息气泡 */}
<div
className={`relative px-4 py-3 rounded-2xl ${
msg.role === 'user'
? 'bg-primary/25 border border-primary/20 rounded-br-md'
: 'glass rounded-bl-md'
}`}
style={{ wordBreak: 'break-word', overflowWrap: 'anywhere' }}
>
{copiedId === msg.id ? (
<Check className="w-3 h-3 text-green-400" />
) : (
<Copy className="w-3 h-3" />
<MessageContent
content={msg.content}
role={msg.role}
onChoiceSelect={(choice) => {
setInputValue(choice)
textareaRef.current?.focus()
}}
/>
</div>
{/* 底部操作栏 */}
<div className={`flex items-center gap-1 mt-1 px-1 opacity-0 group-hover:opacity-100 transition-opacity ${msg.role === 'user' ? 'flex-row-reverse' : ''}`}>
<span className="text-xs text-white/25">{formatTime(msg.createdAt)}</span>
<button
onClick={() => handleCopyMessage(msg.content, msg.id)}
className="p-1 rounded hover:bg-white/10 transition-colors cursor-pointer"
title="复制"
>
{copiedId === msg.id
? <Check className="w-3.5 h-3.5 text-emerald-400" />
: <Copy className="w-3.5 h-3.5 text-white/40 hover:text-white/70" />
}
</button>
{/* 最后一条 AI 消息显示重新生成按钮 */}
{msg.role === 'assistant' && isLastAssistant && (
<button
onClick={handleRegenerateResponse}
disabled={sending}
className="p-1 rounded hover:bg-white/10 transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
title="重新生成"
>
<RefreshCw className={`w-3.5 h-3.5 text-white/40 hover:text-white/70 ${sending ? 'animate-spin' : ''}`} />
</button>
)}
</button>
</div>
</div>
{/* 用户头像占位 */}
{msg.role === 'user' && <div className="flex-shrink-0 ml-2.5 mt-1 w-8 h-8 rounded-full bg-white/10 flex items-center justify-center text-xs"></div>}
</div>
</div>
))
)
})
)}
{sending && (
{/* 发送中动画(流式时不需要,已有临时消息) */}
{sending && !streamEnabled && (
<div className="flex justify-start">
<div className="glass-hover rounded-2xl rounded-bl-md p-4">
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-primary rounded-full animate-pulse"></div>
<div className="w-2 h-2 bg-primary rounded-full animate-pulse" style={{ animationDelay: '0.2s' }}></div>
<div className="w-2 h-2 bg-primary rounded-full animate-pulse" style={{ animationDelay: '0.4s' }}></div>
<div className="flex-shrink-0 mr-2.5 mt-1 w-8 h-8 rounded-full bg-primary/30 flex items-center justify-center text-xs">
{character.name[0]}
</div>
<div className="glass rounded-2xl rounded-bl-md px-4 py-3">
<div className="flex items-center gap-1.5">
{[0, 0.2, 0.4].map((delay, i) => (
<div key={i} className="w-1.5 h-1.5 bg-primary/60 rounded-full animate-bounce" style={{ animationDelay: `${delay}s` }} />
))}
</div>
</div>
</div>
@@ -661,57 +704,50 @@ export default function ChatArea({ conversation, character, onConversationUpdate
<div ref={messagesEndRef} />
</div>
<div className="p-4 glass border-t border-white/10">
{/* 输入区域 */}
<div className="px-4 pb-4 pt-2 glass border-t border-white/10">
<div className="flex items-end gap-2">
<button
className="p-3 glass-hover rounded-lg cursor-pointer opacity-50"
title="附件(开发中)"
disabled
>
<button className="p-2.5 glass-hover rounded-xl cursor-not-allowed opacity-30" title="附件(开发中)" disabled>
<Paperclip className="w-5 h-5" />
</button>
<div className="flex-1 glass rounded-2xl p-3 focus-within:ring-2 focus-within:ring-primary/50 transition-all">
<div className="flex-1 glass rounded-2xl px-4 py-2.5 focus-within:ring-1 focus-within:ring-primary/50 transition-all">
<textarea
ref={textareaRef}
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value)
// 自动调整高度
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 120) + 'px'
}
}}
onKeyDown={handleKeyDown}
placeholder={sending ? '正在发送...' : '输入消息... (Enter发送Shift+Enter换行)'}
placeholder={sending ? 'AI 正在思考...' : `${character.name} 说点什么... (Enter 发送Shift+Enter 换行)`}
rows={1}
className="w-full bg-transparent resize-none focus:outline-none text-sm"
style={{ maxHeight: '120px', minHeight: '24px' }}
className="w-full bg-transparent resize-none focus:outline-none text-sm placeholder:text-white/25"
style={{ maxHeight: '120px', minHeight: '22px' }}
disabled={sending}
/>
</div>
<button
className="p-3 glass-hover rounded-lg cursor-pointer opacity-50"
title="语音输入(开发中)"
disabled
>
<button className="p-2.5 glass-hover rounded-xl cursor-not-allowed opacity-30" title="语音(开发中)" disabled>
<Mic className="w-5 h-5" />
</button>
<button
onClick={handleSend}
disabled={!inputValue.trim() || sending}
className="p-3 bg-gradient-to-r from-primary to-secondary rounded-lg hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
title="发送消息"
className="p-2.5 bg-gradient-to-br from-primary to-secondary rounded-xl hover:opacity-90 active:scale-95 transition-all cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed disabled:active:scale-100"
title="发送 (Enter)"
>
<Send className="w-5 h-5" />
</button>
</div>
<div className="mt-2 flex items-center justify-between text-xs text-white/40">
<span>: {conversation.messageCount} | Token: {conversation.tokenCount}</span>
{sending && <span className="text-primary animate-pulse">AI ...</span>}
<div className="flex items-center justify-between mt-2 px-1 text-xs text-white/25">
<span>{conversation.messageCount} · {conversation.tokenCount} tokens</span>
{sending && <span className="text-primary/70 animate-pulse">AI ...</span>}
</div>
</div>
</div>