From 689e8af3dffaba9b9e11b93db681958eaa27980b Mon Sep 17 00:00:00 2001 From: Echo <1711788888@qq.com> Date: Fri, 27 Feb 2026 22:50:26 +0800 Subject: [PATCH] =?UTF-8?q?:art:=20=E4=BC=98=E5=8C=96=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E9=A1=B5=E9=9D=A2=20&&=20=E4=BC=98=E5=8C=96a?= =?UTF-8?q?i=E6=B5=81=E5=BC=8F=E4=BC=A0=E8=BE=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Echo <1711788888@qq.com> --- .gitignore | 4 +- server/api/v1/app/conversation.go | 77 +++ server/initialize/fix_world_info_table.sql | 9 - server/router/app/conversation.go | 1 + server/service/app/conversation.go | 305 +++++++++- web-app/src/api/conversation.ts | 5 + web-app/src/components/ChatArea.tsx | 640 +++++++++++---------- 7 files changed, 721 insertions(+), 320 deletions(-) delete mode 100644 server/initialize/fix_world_info_table.sql diff --git a/.gitignore b/.gitignore index 3d7c5e9..01c8e2e 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,6 @@ dist-ssr *.sw? uploads docs -.claude \ No newline at end of file +.claude +plugs +sillytavern \ No newline at end of file diff --git a/server/api/v1/app/conversation.go b/server/api/v1/app/conversation.go index 38444f0..f62e842 100644 --- a/server/api/v1/app/conversation.go +++ b/server/api/v1/app/conversation.go @@ -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 发送消息 diff --git a/server/initialize/fix_world_info_table.sql b/server/initialize/fix_world_info_table.sql deleted file mode 100644 index 5fd8bf3..0000000 --- a/server/initialize/fix_world_info_table.sql +++ /dev/null @@ -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 自动创建 --- 重启服务器即可 diff --git a/server/router/app/conversation.go b/server/router/app/conversation.go index 1d02520..727e49c 100644 --- a/server/router/app/conversation.go +++ b/server/router/app/conversation.go @@ -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) // 重新生成消息 } } diff --git a/server/service/app/conversation.go b/server/service/app/conversation.go index 2ed3d6b..8469f5d 100644 --- a/server/service/app/conversation.go +++ b/server/service/app/conversation.go @@ -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) diff --git a/web-app/src/api/conversation.ts b/web-app/src/api/conversation.ts index 434f3cf..9d05e30 100644 --- a/web-app/src/api/conversation.ts +++ b/web-app/src/api/conversation.ts @@ -106,4 +106,9 @@ export const conversationApi = { updateConversationSettings: (conversationId: number, settings: Record) => { return apiClient.put(`/app/conversation/${conversationId}/settings`, { settings }) }, + + // 重新生成最后一条 AI 回复(非流式) + regenerateMessage: (conversationId: number) => { + return apiClient.post(`/app/conversation/${conversationId}/regenerate`) + }, } diff --git a/web-app/src/components/ChatArea.tsx b/web-app/src/components/ChatArea.tsx index abcdd59..d0e2a3e 100644 --- a/web-app/src/components/ChatArea.tsx +++ b/web-app/src/components/ChatArea.tsx @@ -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([]) const [selectedConfigId, setSelectedConfigId] = useState() const [showModelSelector, setShowModelSelector] = useState(false) - const [streamEnabled, setStreamEnabled] = useState(true) // 默认启用流式传输 + const [streamEnabled, setStreamEnabled] = useState(true) const [presets, setPresets] = useState([]) const [selectedPresetId, setSelectedPresetId] = useState() const [showPresetSelector, setShowPresetSelector] = useState(false) @@ -46,27 +46,19 @@ export default function ChatArea({ conversation, character, onConversationUpdate const presetSelectorRef = useRef(null) const menuRef = useRef(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 ( -
-
-
-
-

{conversation.title}

-
- 与 {character.name} 对话中 -
+
+ {/* 顶部工具栏 */} +
+
+ {/* 左侧:标题 */} +
+

{conversation.title}

+

与 {character.name} 对话中

-
+ + {/* 右侧:工具按钮组 */} +
+ + {/* 模型选择器 */}
- {showModelSelector && createPortal( -
e.stopPropagation()} - > - {aiConfigs.length === 0 ? ( -
- 暂无可用配置 -
- ) : ( - aiConfigs.map((config) => ( + + {showModelSelector && ( +
+
+ + {aiConfigs.length > 0 &&
} + {aiConfigs.map(config => ( - )) - )} -
, - document.body + ))} + {aiConfigs.length === 0 && ( +
暂无可用配置
+ )} +
+
)}
{/* 预设选择器 */}
- {showPresetSelector && createPortal( -
e.stopPropagation()} - > - {presets.length === 0 ? ( -
- 暂无可用预设 -
- ) : ( - presets.map((preset) => ( + + {showPresetSelector && ( +
+
+ + {presets.length > 0 &&
} + {presets.map(preset => ( - )) - )} -
, - document.body + ))} + {presets.length === 0 && ( +
暂无可用预设
+ )} +
+
)}
+ {/* 分隔线 */} +
+ + {/* 流式传输切换 */} - + + {/* 更多菜单 */}
- {showMenu && createPortal( -
e.stopPropagation()} - > - - -
, - document.body + + {showMenu && ( +
+
+ +
+ +
+
)}
-
+ {/* 消息列表 */} +
{loading ? ( -
加载消息中...
+
+
+ {[0, 0.15, 0.3].map((delay, i) => ( +
+ ))} +
+
) : messages.length === 0 ? ( -
-

还没有消息

-

发送第一条消息开始对话吧!

+
+ {character.avatar && ( + {character.name} + )} +

发送第一条消息,开始和 {character.name} 对话吧

) : ( - messages.map((msg) => ( -
+ messages.map((msg) => { + const isLastAssistant = msg.id === lastAssistantMsgId + return (
+ {/* 助手头像 */} {msg.role === 'assistant' && ( -
- {character.avatar && ( - {character.name} +
+ {character.avatar ? ( + {character.name} + ) : ( +
+ {character.name[0]} +
)} - {character.name}
)} - { - setInputValue(choice) - // 自动聚焦到输入框 - textareaRef.current?.focus() - }} - /> -
- {formatTime(msg.createdAt)} - + {/* 最后一条 AI 消息显示重新生成按钮 */} + {msg.role === 'assistant' && isLastAssistant && ( + )} - +
+ + {/* 用户头像占位 */} + {msg.role === 'user' &&
}
-
- )) + ) + }) )} - {sending && ( + + {/* 发送中动画(流式时不需要,已有临时消息) */} + {sending && !streamEnabled && (
-
-
-
-
-
+
+ {character.name[0]} +
+
+
+ {[0, 0.2, 0.4].map((delay, i) => ( +
+ ))}
@@ -661,57 +704,50 @@ export default function ChatArea({ conversation, character, onConversationUpdate
-
+ {/* 输入区域 */} +
- -
+