4
.gitignore
vendored
4
.gitignore
vendored
@@ -24,4 +24,6 @@ dist-ssr
|
|||||||
*.sw?
|
*.sw?
|
||||||
uploads
|
uploads
|
||||||
docs
|
docs
|
||||||
.claude
|
.claude
|
||||||
|
plugs
|
||||||
|
sillytavern
|
||||||
@@ -202,6 +202,83 @@ func (a *ConversationApi) GetMessageList(c *gin.Context) {
|
|||||||
commonResponse.OkWithData(resp, c)
|
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
|
// SendMessage
|
||||||
// @Tags AppConversation
|
// @Tags AppConversation
|
||||||
// @Summary 发送消息
|
// @Summary 发送消息
|
||||||
|
|||||||
@@ -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 自动创建
|
|
||||||
-- 重启服务器即可
|
|
||||||
@@ -21,5 +21,6 @@ func (r *ConversationRouter) InitConversationRouter(Router *gin.RouterGroup) {
|
|||||||
conversationRouter.DELETE(":id", conversationApi.DeleteConversation) // 删除对话
|
conversationRouter.DELETE(":id", conversationApi.DeleteConversation) // 删除对话
|
||||||
conversationRouter.GET(":id/messages", conversationApi.GetMessageList) // 获取消息列表
|
conversationRouter.GET(":id/messages", conversationApi.GetMessageList) // 获取消息列表
|
||||||
conversationRouter.POST(":id/message", conversationApi.SendMessage) // 发送消息
|
conversationRouter.POST(":id/message", conversationApi.SendMessage) // 发送消息
|
||||||
|
conversationRouter.POST(":id/regenerate", conversationApi.RegenerateMessage) // 重新生成消息
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -657,8 +657,31 @@ func (s *ConversationService) SendMessageStream(userID, conversationID uint, req
|
|||||||
return errors.New("未找到可用的 AI 配置")
|
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)
|
systemPrompt := s.buildSystemPrompt(character)
|
||||||
|
if streamPreset != nil && streamPreset.SystemPrompt != "" {
|
||||||
|
systemPrompt = systemPrompt + "\n\n" + streamPreset.SystemPrompt
|
||||||
|
}
|
||||||
apiMessages := s.buildAPIMessages(messages, systemPrompt)
|
apiMessages := s.buildAPIMessages(messages, systemPrompt)
|
||||||
|
|
||||||
// 打印发送给AI的完整内容(流式传输)
|
// 打印发送给AI的完整内容(流式传输)
|
||||||
@@ -685,9 +708,9 @@ func (s *ConversationService) SendMessageStream(userID, conversationID uint, req
|
|||||||
var fullContent string
|
var fullContent string
|
||||||
switch aiConfig.Provider {
|
switch aiConfig.Provider {
|
||||||
case "openai", "custom":
|
case "openai", "custom":
|
||||||
fullContent, err = s.callOpenAIAPIStream(&aiConfig, model, apiMessages, streamChan)
|
fullContent, err = s.callOpenAIAPIStream(&aiConfig, model, apiMessages, streamPreset, streamChan)
|
||||||
case "anthropic":
|
case "anthropic":
|
||||||
fullContent, err = s.callAnthropicAPIStream(&aiConfig, model, apiMessages, systemPrompt, streamChan)
|
fullContent, err = s.callAnthropicAPIStream(&aiConfig, model, apiMessages, systemPrompt, streamPreset, streamChan)
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("不支持的 AI 提供商: %s", aiConfig.Provider)
|
return fmt.Errorf("不支持的 AI 提供商: %s", aiConfig.Provider)
|
||||||
}
|
}
|
||||||
@@ -724,7 +747,7 @@ func (s *ConversationService) SendMessageStream(userID, conversationID uint, req
|
|||||||
}
|
}
|
||||||
|
|
||||||
// callOpenAIAPIStream 调用 OpenAI API 流式传输
|
// 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}
|
client := &http.Client{Timeout: 120 * time.Second}
|
||||||
|
|
||||||
if model == "" {
|
if model == "" {
|
||||||
@@ -734,15 +757,53 @@ func (s *ConversationService) callOpenAIAPIStream(config *app.AIConfig, model st
|
|||||||
model = "gpt-4"
|
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{}{
|
requestBody := map[string]interface{}{
|
||||||
"model": model,
|
"model": model,
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
"temperature": 0.7,
|
"temperature": temperature,
|
||||||
"max_tokens": 2000,
|
"max_tokens": maxTokens,
|
||||||
"stream": true,
|
"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)
|
bodyBytes, err := json.Marshal(requestBody)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("序列化请求失败: %v", err)
|
return "", fmt.Errorf("序列化请求失败: %v", err)
|
||||||
@@ -815,7 +876,7 @@ func (s *ConversationService) callOpenAIAPIStream(config *app.AIConfig, model st
|
|||||||
}
|
}
|
||||||
|
|
||||||
// callAnthropicAPIStream 调用 Anthropic API 流式传输
|
// 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}
|
client := &http.Client{Timeout: 120 * time.Second}
|
||||||
|
|
||||||
if model == "" {
|
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{}{
|
requestBody := map[string]interface{}{
|
||||||
"model": model,
|
"model": model,
|
||||||
"messages": apiMessages,
|
"messages": apiMessages,
|
||||||
"system": systemPrompt,
|
"system": systemPrompt,
|
||||||
"max_tokens": 2000,
|
"max_tokens": maxTokens,
|
||||||
"stream": true,
|
"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)
|
bodyBytes, err := json.Marshal(requestBody)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("序列化请求失败: %v", err)
|
return "", fmt.Errorf("序列化请求失败: %v", err)
|
||||||
@@ -910,6 +1000,205 @@ func (s *ConversationService) callAnthropicAPIStream(config *app.AIConfig, model
|
|||||||
return fullContent.String(), nil
|
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 {
|
func (s *ConversationService) buildAPIMessages(messages []app.Message, systemPrompt string) []map[string]string {
|
||||||
apiMessages := make([]map[string]string, 0, len(messages)+1)
|
apiMessages := make([]map[string]string, 0, len(messages)+1)
|
||||||
|
|
||||||
|
|||||||
@@ -106,4 +106,9 @@ export const conversationApi = {
|
|||||||
updateConversationSettings: (conversationId: number, settings: Record<string, any>) => {
|
updateConversationSettings: (conversationId: number, settings: Record<string, any>) => {
|
||||||
return apiClient.put(`/app/conversation/${conversationId}/settings`, { settings })
|
return apiClient.put(`/app/conversation/${conversationId}/settings`, { settings })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 重新生成最后一条 AI 回复(非流式)
|
||||||
|
regenerateMessage: (conversationId: number) => {
|
||||||
|
return apiClient.post<Message>(`/app/conversation/${conversationId}/regenerate`)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
|
ChevronDown,
|
||||||
Copy,
|
Copy,
|
||||||
Download,
|
Download,
|
||||||
Mic,
|
Mic,
|
||||||
@@ -13,7 +14,6 @@ import {
|
|||||||
Zap
|
Zap
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import {useEffect, useRef, useState} from 'react'
|
import {useEffect, useRef, useState} from 'react'
|
||||||
import {createPortal} from 'react-dom'
|
|
||||||
import {type Conversation, conversationApi, type Message} from '../api/conversation'
|
import {type Conversation, conversationApi, type Message} from '../api/conversation'
|
||||||
import {type Character} from '../api/character'
|
import {type Character} from '../api/character'
|
||||||
import {type AIConfig, aiConfigApi} from '../api/aiConfig'
|
import {type AIConfig, aiConfigApi} from '../api/aiConfig'
|
||||||
@@ -36,7 +36,7 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
|||||||
const [aiConfigs, setAiConfigs] = useState<AIConfig[]>([])
|
const [aiConfigs, setAiConfigs] = useState<AIConfig[]>([])
|
||||||
const [selectedConfigId, setSelectedConfigId] = useState<number>()
|
const [selectedConfigId, setSelectedConfigId] = useState<number>()
|
||||||
const [showModelSelector, setShowModelSelector] = useState(false)
|
const [showModelSelector, setShowModelSelector] = useState(false)
|
||||||
const [streamEnabled, setStreamEnabled] = useState(true) // 默认启用流式传输
|
const [streamEnabled, setStreamEnabled] = useState(true)
|
||||||
const [presets, setPresets] = useState<Preset[]>([])
|
const [presets, setPresets] = useState<Preset[]>([])
|
||||||
const [selectedPresetId, setSelectedPresetId] = useState<number>()
|
const [selectedPresetId, setSelectedPresetId] = useState<number>()
|
||||||
const [showPresetSelector, setShowPresetSelector] = useState(false)
|
const [showPresetSelector, setShowPresetSelector] = useState(false)
|
||||||
@@ -46,27 +46,19 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
|||||||
const presetSelectorRef = useRef<HTMLDivElement>(null)
|
const presetSelectorRef = useRef<HTMLDivElement>(null)
|
||||||
const menuRef = useRef<HTMLDivElement>(null)
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
// 点击外部关闭下拉菜单
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
const target = event.target as HTMLElement
|
const target = event.target as HTMLElement
|
||||||
|
|
||||||
// 检查是否点击在模型选择器外部
|
|
||||||
if (showModelSelector && modelSelectorRef.current && !modelSelectorRef.current.contains(target)) {
|
if (showModelSelector && modelSelectorRef.current && !modelSelectorRef.current.contains(target)) {
|
||||||
setShowModelSelector(false)
|
setShowModelSelector(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否点击在预设选择器外部
|
|
||||||
if (showPresetSelector && presetSelectorRef.current && !presetSelectorRef.current.contains(target)) {
|
if (showPresetSelector && presetSelectorRef.current && !presetSelectorRef.current.contains(target)) {
|
||||||
setShowPresetSelector(false)
|
setShowPresetSelector(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否点击在菜单外部
|
|
||||||
if (showMenu && menuRef.current && !menuRef.current.contains(target)) {
|
if (showMenu && menuRef.current && !menuRef.current.contains(target)) {
|
||||||
setShowMenu(false)
|
setShowMenu(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('mousedown', handleClickOutside)
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
}, [showModelSelector, showPresetSelector, showMenu])
|
}, [showModelSelector, showPresetSelector, showMenu])
|
||||||
@@ -86,10 +78,7 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
|||||||
const loadMessages = async () => {
|
const loadMessages = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const response = await conversationApi.getMessageList(conversation.id, {
|
const response = await conversationApi.getMessageList(conversation.id, { page: 1, pageSize: 100 })
|
||||||
page: 1,
|
|
||||||
pageSize: 100,
|
|
||||||
})
|
|
||||||
setMessages(response.data.list || [])
|
setMessages(response.data.list || [])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('加载消息失败:', err)
|
console.error('加载消息失败:', err)
|
||||||
@@ -101,8 +90,7 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
|||||||
const loadAIConfigs = async () => {
|
const loadAIConfigs = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await aiConfigApi.getAIConfigList()
|
const response = await aiConfigApi.getAIConfigList()
|
||||||
const activeConfigs = response.data.list.filter(config => config.isActive)
|
setAiConfigs(response.data.list.filter(config => config.isActive))
|
||||||
setAiConfigs(activeConfigs)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('加载 AI 配置失败:', err)
|
console.error('加载 AI 配置失败:', err)
|
||||||
}
|
}
|
||||||
@@ -114,9 +102,7 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
|||||||
const settings = typeof conversation.settings === 'string'
|
const settings = typeof conversation.settings === 'string'
|
||||||
? JSON.parse(conversation.settings)
|
? JSON.parse(conversation.settings)
|
||||||
: conversation.settings
|
: conversation.settings
|
||||||
if (settings.aiConfigId) {
|
if (settings.aiConfigId) setSelectedConfigId(settings.aiConfigId)
|
||||||
setSelectedConfigId(settings.aiConfigId)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('解析设置失败:', e)
|
console.error('解析设置失败:', e)
|
||||||
}
|
}
|
||||||
@@ -133,6 +119,10 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loadCurrentPreset = () => {
|
const loadCurrentPreset = () => {
|
||||||
|
if (conversation.presetId) {
|
||||||
|
setSelectedPresetId(conversation.presetId)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (conversation.settings) {
|
if (conversation.settings) {
|
||||||
try {
|
try {
|
||||||
const settings = typeof conversation.settings === 'string'
|
const settings = typeof conversation.settings === 'string'
|
||||||
@@ -140,25 +130,28 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
|||||||
: conversation.settings
|
: conversation.settings
|
||||||
if (settings.presetId) {
|
if (settings.presetId) {
|
||||||
setSelectedPresetId(settings.presetId)
|
setSelectedPresetId(settings.presetId)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('解析设置失败:', e)
|
console.error('解析设置失败:', e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
setSelectedPresetId(undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePresetChange = async (presetId: number) => {
|
const handlePresetChange = async (presetId: number | null) => {
|
||||||
try {
|
try {
|
||||||
const settings = conversation.settings
|
const settings = conversation.settings
|
||||||
? (typeof conversation.settings === 'string' ? JSON.parse(conversation.settings) : conversation.settings)
|
? (typeof conversation.settings === 'string' ? JSON.parse(conversation.settings) : { ...conversation.settings })
|
||||||
: {}
|
: {}
|
||||||
|
if (presetId === null) {
|
||||||
settings.presetId = presetId
|
delete settings.presetId
|
||||||
|
} else {
|
||||||
|
settings.presetId = presetId
|
||||||
|
}
|
||||||
await conversationApi.updateConversationSettings(conversation.id, settings)
|
await conversationApi.updateConversationSettings(conversation.id, settings)
|
||||||
setSelectedPresetId(presetId)
|
setSelectedPresetId(presetId ?? undefined)
|
||||||
setShowPresetSelector(false)
|
setShowPresetSelector(false)
|
||||||
|
|
||||||
const convResp = await conversationApi.getConversationById(conversation.id)
|
const convResp = await conversationApi.getConversationById(conversation.id)
|
||||||
onConversationUpdate(convResp.data)
|
onConversationUpdate(convResp.data)
|
||||||
} catch (err) {
|
} 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 {
|
try {
|
||||||
const settings = conversation.settings
|
const settings = conversation.settings
|
||||||
? (typeof conversation.settings === 'string' ? JSON.parse(conversation.settings) : conversation.settings)
|
? (typeof conversation.settings === 'string' ? JSON.parse(conversation.settings) : { ...conversation.settings })
|
||||||
: {}
|
: {}
|
||||||
|
if (configId === null) {
|
||||||
settings.aiConfigId = configId
|
delete settings.aiConfigId
|
||||||
|
} else {
|
||||||
|
settings.aiConfigId = configId
|
||||||
|
}
|
||||||
await conversationApi.updateConversationSettings(conversation.id, settings)
|
await conversationApi.updateConversationSettings(conversation.id, settings)
|
||||||
setSelectedConfigId(configId)
|
setSelectedConfigId(configId ?? undefined)
|
||||||
setShowModelSelector(false)
|
setShowModelSelector(false)
|
||||||
|
|
||||||
const convResp = await conversationApi.getConversationById(conversation.id)
|
const convResp = await conversationApi.getConversationById(conversation.id)
|
||||||
onConversationUpdate(convResp.data)
|
onConversationUpdate(convResp.data)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -192,16 +186,12 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSend = async () => {
|
const handleSend = async () => {
|
||||||
// 防止重复发送
|
|
||||||
if (!inputValue.trim() || sending) return
|
if (!inputValue.trim() || sending) return
|
||||||
|
|
||||||
const userMessage = inputValue.trim()
|
const userMessage = inputValue.trim()
|
||||||
|
|
||||||
// 立即清空输入框和设置发送状态,防止重复触发
|
|
||||||
setInputValue('')
|
setInputValue('')
|
||||||
setSending(true)
|
setSending(true)
|
||||||
|
|
||||||
// 立即显示用户消息
|
|
||||||
const tempUserMessage: Message = {
|
const tempUserMessage: Message = {
|
||||||
id: Date.now(),
|
id: Date.now(),
|
||||||
conversationId: conversation.id,
|
conversationId: conversation.id,
|
||||||
@@ -210,9 +200,8 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
|||||||
tokenCount: 0,
|
tokenCount: 0,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
}
|
}
|
||||||
setMessages((prev) => [...prev, tempUserMessage])
|
setMessages(prev => [...prev, tempUserMessage])
|
||||||
|
|
||||||
// 创建临时AI消息用于流式显示
|
|
||||||
const tempAIMessage: Message = {
|
const tempAIMessage: Message = {
|
||||||
id: Date.now() + 1,
|
id: Date.now() + 1,
|
||||||
conversationId: conversation.id,
|
conversationId: conversation.id,
|
||||||
@@ -224,10 +213,7 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (streamEnabled) {
|
if (streamEnabled) {
|
||||||
// 流式传输
|
setMessages(prev => [...prev, tempAIMessage])
|
||||||
console.log('[Stream] 开始流式传输...')
|
|
||||||
setMessages((prev) => [...prev, tempAIMessage])
|
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${import.meta.env.VITE_API_BASE_URL || '/api'}/app/conversation/${conversation.id}/message?stream=true`,
|
`${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 }),
|
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 reader = response.body?.getReader()
|
||||||
const decoder = new TextDecoder()
|
const decoder = new TextDecoder()
|
||||||
|
|
||||||
if (reader) {
|
if (reader) {
|
||||||
let fullContent = ''
|
let fullContent = ''
|
||||||
let buffer = ''
|
let buffer = ''
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read()
|
const { done, value } = await reader.read()
|
||||||
if (done) {
|
if (done) break
|
||||||
console.log('[Stream] 传输完成')
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
buffer += decoder.decode(value, { stream: true })
|
buffer += decoder.decode(value, { stream: true })
|
||||||
const lines = buffer.split('\n')
|
const lines = buffer.split('\n')
|
||||||
|
|
||||||
// 保留最后一行(可能不完整)
|
|
||||||
buffer = lines.pop() || ''
|
buffer = lines.pop() || ''
|
||||||
|
|
||||||
let currentEvent = ''
|
let currentEvent = ''
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line.startsWith('event: ')) {
|
if (line.startsWith('event: ')) {
|
||||||
currentEvent = line.slice(7).trim()
|
currentEvent = line.slice(7).trim()
|
||||||
console.log('[Stream] 事件类型:', currentEvent)
|
|
||||||
} else if (line.startsWith('data: ')) {
|
} else if (line.startsWith('data: ')) {
|
||||||
const data = line.slice(6).trim()
|
const data = line.slice(6).trim()
|
||||||
|
|
||||||
if (currentEvent === 'message') {
|
if (currentEvent === 'message') {
|
||||||
// 消息内容 - 后端现在发送的是纯文本,不再是JSON
|
|
||||||
fullContent += data
|
fullContent += data
|
||||||
console.log('[Stream] 接收内容片段:', data)
|
setMessages(prev =>
|
||||||
// 实时更新临时AI消息的内容
|
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') {
|
} else if (currentEvent === 'done') {
|
||||||
// 流式传输完成
|
|
||||||
console.log('[Stream] 收到完成信号,重新加载消息')
|
|
||||||
await loadMessages()
|
await loadMessages()
|
||||||
break
|
break
|
||||||
} else if (currentEvent === 'error') {
|
} else if (currentEvent === 'error') {
|
||||||
// 错误处理
|
|
||||||
console.error('[Stream] 错误:', data)
|
|
||||||
throw new Error(data)
|
throw new Error(data)
|
||||||
}
|
}
|
||||||
currentEvent = ''
|
currentEvent = ''
|
||||||
@@ -298,28 +260,18 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新对话信息
|
|
||||||
const convResp = await conversationApi.getConversationById(conversation.id)
|
const convResp = await conversationApi.getConversationById(conversation.id)
|
||||||
onConversationUpdate(convResp.data)
|
onConversationUpdate(convResp.data)
|
||||||
} else {
|
} else {
|
||||||
// 普通传输
|
await conversationApi.sendMessage(conversation.id, { content: userMessage })
|
||||||
const response = await conversationApi.sendMessage(conversation.id, {
|
|
||||||
content: userMessage,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 更新消息列表(包含AI回复)
|
|
||||||
await loadMessages()
|
await loadMessages()
|
||||||
|
|
||||||
// 更新对话信息
|
|
||||||
const convResp = await conversationApi.getConversationById(conversation.id)
|
const convResp = await conversationApi.getConversationById(conversation.id)
|
||||||
onConversationUpdate(convResp.data)
|
onConversationUpdate(convResp.data)
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('发送消息失败:', err)
|
console.error('发送消息失败:', err)
|
||||||
alert(err.response?.data?.msg || '发送消息失败,请重试')
|
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 {
|
} finally {
|
||||||
setSending(false)
|
setSending(false)
|
||||||
}
|
}
|
||||||
@@ -340,22 +292,82 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
|||||||
|
|
||||||
const handleRegenerateResponse = async () => {
|
const handleRegenerateResponse = async () => {
|
||||||
if (messages.length === 0 || sending) return
|
if (messages.length === 0 || sending) return
|
||||||
|
const hasAssistantMsg = messages.some(m => m.role === 'assistant')
|
||||||
// 找到最后一条用户消息
|
if (!hasAssistantMsg) return
|
||||||
const lastUserMessage = [...messages].reverse().find(m => m.role === 'user')
|
|
||||||
if (!lastUserMessage) return
|
|
||||||
|
|
||||||
setSending(true)
|
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 {
|
try {
|
||||||
await conversationApi.sendMessage(conversation.id, {
|
if (streamEnabled) {
|
||||||
content: lastUserMessage.content,
|
setMessages(prev => [...prev, tempAIMessage])
|
||||||
})
|
const response = await fetch(
|
||||||
await loadMessages()
|
`${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)
|
const convResp = await conversationApi.getConversationById(conversation.id)
|
||||||
onConversationUpdate(convResp.data)
|
onConversationUpdate(convResp.data)
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
console.error('重新生成失败:', err)
|
console.error('重新生成失败:', err)
|
||||||
alert('重新生成失败,请重试')
|
alert(err.message || '重新生成失败,请重试')
|
||||||
|
await loadMessages()
|
||||||
} finally {
|
} finally {
|
||||||
setSending(false)
|
setSending(false)
|
||||||
}
|
}
|
||||||
@@ -363,7 +375,6 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
|||||||
|
|
||||||
const handleDeleteConversation = async () => {
|
const handleDeleteConversation = async () => {
|
||||||
if (!confirm('确定要删除这个对话吗?')) return
|
if (!confirm('确定要删除这个对话吗?')) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await conversationApi.deleteConversation(conversation.id)
|
await conversationApi.deleteConversation(conversation.id)
|
||||||
window.location.href = '/chat'
|
window.location.href = '/chat'
|
||||||
@@ -374,9 +385,7 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleExportConversation = () => {
|
const handleExportConversation = () => {
|
||||||
const content = messages
|
const content = messages.map(msg => `[${msg.role}] ${msg.content}`).join('\n\n')
|
||||||
.map((msg) => `[${msg.role}] ${msg.content}`)
|
|
||||||
.join('\n\n')
|
|
||||||
const blob = new Blob([content], { type: 'text/plain' })
|
const blob = new Blob([content], { type: 'text/plain' })
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
@@ -393,7 +402,6 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
|||||||
const minutes = Math.floor(diff / 60000)
|
const minutes = Math.floor(diff / 60000)
|
||||||
const hours = Math.floor(diff / 3600000)
|
const hours = Math.floor(diff / 3600000)
|
||||||
const days = Math.floor(diff / 86400000)
|
const days = Math.floor(diff / 86400000)
|
||||||
|
|
||||||
if (minutes < 1) return '刚刚'
|
if (minutes < 1) return '刚刚'
|
||||||
if (minutes < 60) return `${minutes}分钟前`
|
if (minutes < 60) return `${minutes}分钟前`
|
||||||
if (hours < 24) return `${hours}小时前`
|
if (hours < 24) return `${hours}小时前`
|
||||||
@@ -401,259 +409,294 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
|||||||
return date.toLocaleDateString('zh-CN')
|
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 (
|
return (
|
||||||
<div className="flex-1 flex flex-col">
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
<div className="p-4 glass border-b border-white/10">
|
{/* 顶部工具栏 */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="px-4 py-3 glass border-b border-white/10">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<h2 className="text-lg font-semibold truncate">{conversation.title}</h2>
|
{/* 左侧:标题 */}
|
||||||
<div className="flex items-center gap-2 text-sm text-white/60">
|
<div className="min-w-0 flex-1">
|
||||||
<span>与 {character.name} 对话中</span>
|
<h2 className="text-base font-semibold truncate">{conversation.title}</h2>
|
||||||
</div>
|
<p className="text-xs text-white/50 truncate">与 {character.name} 对话中</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
|
{/* 右侧:工具按钮组 */}
|
||||||
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
|
|
||||||
|
{/* 模型选择器 */}
|
||||||
<div className="relative" ref={modelSelectorRef}>
|
<div className="relative" ref={modelSelectorRef}>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={() => {
|
||||||
e.stopPropagation()
|
setShowModelSelector(v => !v)
|
||||||
setShowModelSelector(!showModelSelector)
|
|
||||||
setShowPresetSelector(false)
|
setShowPresetSelector(false)
|
||||||
setShowMenu(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="切换模型"
|
title="切换模型"
|
||||||
>
|
>
|
||||||
<Zap className="w-4 h-4 text-secondary" />
|
<Zap className="w-3.5 h-3.5 text-yellow-400 flex-shrink-0" />
|
||||||
<span className="text-xs">
|
<span className="max-w-[80px] truncate">
|
||||||
{selectedConfigId
|
{selectedConfig ? selectedConfig.name : '默认模型'}
|
||||||
? aiConfigs.find(c => c.id === selectedConfigId)?.name || '默认模型'
|
|
||||||
: '默认模型'}
|
|
||||||
</span>
|
</span>
|
||||||
|
<ChevronDown className={`w-3 h-3 flex-shrink-0 transition-transform ${showModelSelector ? 'rotate-180' : ''}`} />
|
||||||
</button>
|
</button>
|
||||||
{showModelSelector && createPortal(
|
|
||||||
<div
|
{showModelSelector && (
|
||||||
className="fixed glass rounded-xl p-2 min-w-[200px] shadow-xl"
|
<div className="absolute right-0 top-full mt-1 z-50 w-56 glass rounded-xl shadow-2xl border border-white/15 overflow-hidden">
|
||||||
style={{
|
<div className="p-1">
|
||||||
top: modelSelectorRef.current ? modelSelectorRef.current.getBoundingClientRect().bottom + 8 : 0,
|
<button
|
||||||
left: modelSelectorRef.current ? modelSelectorRef.current.getBoundingClientRect().right - 200 : 0,
|
onClick={() => handleModelChange(null)}
|
||||||
zIndex: 9999
|
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'}`}
|
||||||
onClick={(e) => e.stopPropagation()}
|
>
|
||||||
>
|
<span className="flex-1">默认模型</span>
|
||||||
{aiConfigs.length === 0 ? (
|
{!selectedConfigId && <Check className="w-3.5 h-3.5 text-primary" />}
|
||||||
<div className="px-4 py-3 text-xs text-white/60 text-center">
|
</button>
|
||||||
暂无可用配置
|
{aiConfigs.length > 0 && <div className="my-1 border-t border-white/10" />}
|
||||||
</div>
|
{aiConfigs.map(config => (
|
||||||
) : (
|
|
||||||
aiConfigs.map((config) => (
|
|
||||||
<button
|
<button
|
||||||
key={config.id}
|
key={config.id}
|
||||||
onClick={(e) => {
|
onClick={() => handleModelChange(config.id)}
|
||||||
e.stopPropagation()
|
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left cursor-pointer transition-all
|
||||||
handleModelChange(config.id)
|
${selectedConfigId === config.id ? 'bg-primary/20 text-white' : 'text-white/70 hover:bg-white/10 hover:text-white'}`}
|
||||||
}}
|
|
||||||
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' : ''
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<div className="font-medium">{config.name}</div>
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-xs text-white/60 mt-0.5">
|
<div className="font-medium truncate">{config.name}</div>
|
||||||
{config.provider} • {config.defaultModel}
|
<div className="text-xs text-white/40 truncate">{config.provider} · {config.defaultModel}</div>
|
||||||
</div>
|
</div>
|
||||||
|
{selectedConfigId === config.id && <Check className="w-3.5 h-3.5 text-primary flex-shrink-0" />}
|
||||||
</button>
|
</button>
|
||||||
))
|
))}
|
||||||
)}
|
{aiConfigs.length === 0 && (
|
||||||
</div>,
|
<div className="px-3 py-3 text-xs text-white/40 text-center">暂无可用配置</div>
|
||||||
document.body
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 预设选择器 */}
|
{/* 预设选择器 */}
|
||||||
<div className="relative" ref={presetSelectorRef}>
|
<div className="relative" ref={presetSelectorRef}>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={() => {
|
||||||
e.stopPropagation()
|
setShowPresetSelector(v => !v)
|
||||||
setShowPresetSelector(!showPresetSelector)
|
|
||||||
setShowModelSelector(false)
|
setShowModelSelector(false)
|
||||||
setShowMenu(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="选择预设"
|
title="选择预设"
|
||||||
>
|
>
|
||||||
<Settings className="w-4 h-4 text-primary" />
|
<Settings className="w-3.5 h-3.5 text-purple-400 flex-shrink-0" />
|
||||||
<span className="text-xs">
|
<span className="max-w-[80px] truncate">
|
||||||
{selectedPresetId
|
{selectedPreset ? selectedPreset.name : '默认预设'}
|
||||||
? presets.find(p => p.id === selectedPresetId)?.name || '默认预设'
|
|
||||||
: '默认预设'}
|
|
||||||
</span>
|
</span>
|
||||||
|
<ChevronDown className={`w-3 h-3 flex-shrink-0 transition-transform ${showPresetSelector ? 'rotate-180' : ''}`} />
|
||||||
</button>
|
</button>
|
||||||
{showPresetSelector && createPortal(
|
|
||||||
<div
|
{showPresetSelector && (
|
||||||
className="fixed glass rounded-xl p-2 min-w-[200px] shadow-xl"
|
<div className="absolute right-0 top-full mt-1 z-50 w-56 glass rounded-xl shadow-2xl border border-white/15 overflow-hidden">
|
||||||
style={{
|
<div className="p-1">
|
||||||
top: presetSelectorRef.current ? presetSelectorRef.current.getBoundingClientRect().bottom + 8 : 0,
|
<button
|
||||||
left: presetSelectorRef.current ? presetSelectorRef.current.getBoundingClientRect().right - 200 : 0,
|
onClick={() => handlePresetChange(null)}
|
||||||
zIndex: 9999
|
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'}`}
|
||||||
onClick={(e) => e.stopPropagation()}
|
>
|
||||||
>
|
<span className="flex-1">默认预设</span>
|
||||||
{presets.length === 0 ? (
|
{!selectedPresetId && <Check className="w-3.5 h-3.5 text-primary" />}
|
||||||
<div className="px-4 py-3 text-xs text-white/60 text-center">
|
</button>
|
||||||
暂无可用预设
|
{presets.length > 0 && <div className="my-1 border-t border-white/10" />}
|
||||||
</div>
|
{presets.map(preset => (
|
||||||
) : (
|
|
||||||
presets.map((preset) => (
|
|
||||||
<button
|
<button
|
||||||
key={preset.id}
|
key={preset.id}
|
||||||
onClick={(e) => {
|
onClick={() => handlePresetChange(preset.id)}
|
||||||
e.stopPropagation()
|
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left cursor-pointer transition-all
|
||||||
handlePresetChange(preset.id)
|
${selectedPresetId === preset.id ? 'bg-primary/20 text-white' : 'text-white/70 hover:bg-white/10 hover:text-white'}`}
|
||||||
}}
|
|
||||||
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' : ''
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<div className="font-medium">{preset.name}</div>
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-xs text-white/60 mt-0.5">
|
<div className="font-medium truncate">{preset.name}</div>
|
||||||
{preset.description || `温度: ${preset.temperature}`}
|
<div className="text-xs text-white/40">温度 {preset.temperature}</div>
|
||||||
</div>
|
</div>
|
||||||
|
{selectedPresetId === preset.id && <Check className="w-3.5 h-3.5 text-primary flex-shrink-0" />}
|
||||||
</button>
|
</button>
|
||||||
))
|
))}
|
||||||
)}
|
{presets.length === 0 && (
|
||||||
</div>,
|
<div className="px-3 py-3 text-xs text-white/40 text-center">暂无可用预设</div>
|
||||||
document.body
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 分隔线 */}
|
||||||
|
<div className="w-px h-5 bg-white/10 mx-1" />
|
||||||
|
|
||||||
|
{/* 流式传输切换 */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setStreamEnabled(!streamEnabled)}
|
onClick={() => setStreamEnabled(v => !v)}
|
||||||
className={`p-2 glass-hover rounded-lg cursor-pointer ${
|
className={`p-1.5 rounded-lg cursor-pointer transition-all ${
|
||||||
streamEnabled ? 'text-green-400' : 'text-white/60'
|
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" />
|
<Waves className="w-4 h-4" />
|
||||||
</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" />
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* 更多菜单 */}
|
||||||
<div className="relative" ref={menuRef}>
|
<div className="relative" ref={menuRef}>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={() => {
|
||||||
e.stopPropagation()
|
setShowMenu(v => !v)
|
||||||
setShowMenu(!showMenu)
|
|
||||||
setShowModelSelector(false)
|
setShowModelSelector(false)
|
||||||
setShowPresetSelector(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>
|
</button>
|
||||||
{showMenu && createPortal(
|
|
||||||
<div
|
{showMenu && (
|
||||||
className="fixed glass rounded-xl p-2 min-w-[160px] shadow-xl"
|
<div className="absolute right-0 top-full mt-1 z-50 w-44 glass rounded-xl shadow-2xl border border-white/15 overflow-hidden">
|
||||||
style={{
|
<div className="p-1">
|
||||||
top: menuRef.current ? menuRef.current.getBoundingClientRect().bottom + 8 : 0,
|
<button
|
||||||
left: menuRef.current ? menuRef.current.getBoundingClientRect().right - 160 : 0,
|
onClick={() => { handleExportConversation(); setShowMenu(false) }}
|
||||||
zIndex: 9999
|
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"
|
||||||
}}
|
>
|
||||||
onClick={(e) => e.stopPropagation()}
|
<Download className="w-4 h-4" />
|
||||||
>
|
导出对话
|
||||||
<button
|
</button>
|
||||||
onClick={handleExportConversation}
|
<div className="my-1 border-t border-white/10" />
|
||||||
className="w-full px-4 py-2 glass-hover rounded-lg text-sm text-left cursor-pointer flex items-center gap-2"
|
<button
|
||||||
>
|
onClick={() => { handleDeleteConversation(); setShowMenu(false) }}
|
||||||
<Download className="w-4 h-4" />
|
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"
|
||||||
导出对话
|
>
|
||||||
</button>
|
<Trash2 className="w-4 h-4" />
|
||||||
<button
|
删除对话
|
||||||
onClick={handleDeleteConversation}
|
</button>
|
||||||
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"
|
</div>
|
||||||
>
|
</div>
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
删除对话
|
|
||||||
</button>
|
|
||||||
</div>,
|
|
||||||
document.body
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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 ? (
|
{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 ? (
|
) : messages.length === 0 ? (
|
||||||
<div className="text-center text-white/60 py-12">
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||||
<p className="mb-2">还没有消息</p>
|
{character.avatar && (
|
||||||
<p className="text-sm">发送第一条消息开始对话吧!</p>
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
messages.map((msg) => (
|
messages.map((msg) => {
|
||||||
<div
|
const isLastAssistant = msg.id === lastAssistantMsgId
|
||||||
key={msg.id}
|
return (
|
||||||
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'} group`}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className={`max-w-2xl min-w-0 relative ${
|
key={msg.id}
|
||||||
msg.role === 'user'
|
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'} group`}
|
||||||
? 'glass-hover rounded-2xl rounded-br-md p-4'
|
|
||||||
: 'glass-hover rounded-2xl rounded-bl-md p-4'
|
|
||||||
}`}
|
|
||||||
style={{ wordBreak: 'break-word', overflowWrap: 'anywhere' }}
|
|
||||||
>
|
>
|
||||||
|
{/* 助手头像 */}
|
||||||
{msg.role === 'assistant' && (
|
{msg.role === 'assistant' && (
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex-shrink-0 mr-2.5 mt-1">
|
||||||
{character.avatar && (
|
{character.avatar ? (
|
||||||
<img
|
<img src={character.avatar} alt={character.name} className="w-8 h-8 rounded-full object-cover ring-1 ring-white/10" />
|
||||||
src={character.avatar}
|
) : (
|
||||||
alt={character.name}
|
<div className="w-8 h-8 rounded-full bg-primary/30 flex items-center justify-center text-xs font-medium">
|
||||||
className="w-6 h-6 rounded-full object-cover"
|
{character.name[0]}
|
||||||
/>
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className="text-sm font-medium text-primary">{character.name}</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<MessageContent
|
|
||||||
content={msg.content}
|
<div className={`max-w-[70%] min-w-0 flex flex-col ${msg.role === 'user' ? 'items-end' : 'items-start'}`}>
|
||||||
role={msg.role}
|
{/* 助手名称 */}
|
||||||
onChoiceSelect={(choice) => {
|
{msg.role === 'assistant' && (
|
||||||
setInputValue(choice)
|
<span className="text-xs text-white/40 mb-1 ml-1">{character.name}</span>
|
||||||
// 自动聚焦到输入框
|
)}
|
||||||
textareaRef.current?.focus()
|
|
||||||
}}
|
{/* 消息气泡 */}
|
||||||
/>
|
<div
|
||||||
<div className="flex items-center justify-between mt-2">
|
className={`relative px-4 py-3 rounded-2xl ${
|
||||||
<span className="text-xs text-white/40">{formatTime(msg.createdAt)}</span>
|
msg.role === 'user'
|
||||||
<button
|
? 'bg-primary/25 border border-primary/20 rounded-br-md'
|
||||||
onClick={() => handleCopyMessage(msg.content, msg.id)}
|
: 'glass rounded-bl-md'
|
||||||
className="opacity-0 group-hover:opacity-100 p-1 glass-hover rounded transition-opacity cursor-pointer"
|
}`}
|
||||||
title="复制消息"
|
style={{ wordBreak: 'break-word', overflowWrap: 'anywhere' }}
|
||||||
>
|
>
|
||||||
{copiedId === msg.id ? (
|
<MessageContent
|
||||||
<Check className="w-3 h-3 text-green-400" />
|
content={msg.content}
|
||||||
) : (
|
role={msg.role}
|
||||||
<Copy className="w-3 h-3" />
|
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>
|
</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>
|
||||||
</div>
|
)
|
||||||
))
|
})
|
||||||
)}
|
)}
|
||||||
{sending && (
|
|
||||||
|
{/* 发送中动画(流式时不需要,已有临时消息) */}
|
||||||
|
{sending && !streamEnabled && (
|
||||||
<div className="flex justify-start">
|
<div className="flex justify-start">
|
||||||
<div className="glass-hover rounded-2xl rounded-bl-md p-4">
|
<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">
|
||||||
<div className="flex items-center gap-2">
|
{character.name[0]}
|
||||||
<div className="w-2 h-2 bg-primary rounded-full animate-pulse"></div>
|
</div>
|
||||||
<div className="w-2 h-2 bg-primary rounded-full animate-pulse" style={{ animationDelay: '0.2s' }}></div>
|
<div className="glass rounded-2xl rounded-bl-md px-4 py-3">
|
||||||
<div className="w-2 h-2 bg-primary rounded-full animate-pulse" style={{ animationDelay: '0.4s' }}></div>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -661,57 +704,50 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
|||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
</div>
|
</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">
|
<div className="flex items-end gap-2">
|
||||||
<button
|
<button className="p-2.5 glass-hover rounded-xl cursor-not-allowed opacity-30" title="附件(开发中)" disabled>
|
||||||
className="p-3 glass-hover rounded-lg cursor-pointer opacity-50"
|
|
||||||
title="附件(开发中)"
|
|
||||||
disabled
|
|
||||||
>
|
|
||||||
<Paperclip className="w-5 h-5" />
|
<Paperclip className="w-5 h-5" />
|
||||||
</button>
|
</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
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setInputValue(e.target.value)
|
setInputValue(e.target.value)
|
||||||
// 自动调整高度
|
|
||||||
if (textareaRef.current) {
|
if (textareaRef.current) {
|
||||||
textareaRef.current.style.height = 'auto'
|
textareaRef.current.style.height = 'auto'
|
||||||
textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 120) + 'px'
|
textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 120) + 'px'
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={sending ? '正在发送...' : '输入消息... (Enter发送,Shift+Enter换行)'}
|
placeholder={sending ? 'AI 正在思考...' : `和 ${character.name} 说点什么... (Enter 发送,Shift+Enter 换行)`}
|
||||||
rows={1}
|
rows={1}
|
||||||
className="w-full bg-transparent resize-none focus:outline-none text-sm"
|
className="w-full bg-transparent resize-none focus:outline-none text-sm placeholder:text-white/25"
|
||||||
style={{ maxHeight: '120px', minHeight: '24px' }}
|
style={{ maxHeight: '120px', minHeight: '22px' }}
|
||||||
disabled={sending}
|
disabled={sending}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button className="p-2.5 glass-hover rounded-xl cursor-not-allowed opacity-30" title="语音(开发中)" disabled>
|
||||||
className="p-3 glass-hover rounded-lg cursor-pointer opacity-50"
|
|
||||||
title="语音输入(开发中)"
|
|
||||||
disabled
|
|
||||||
>
|
|
||||||
<Mic className="w-5 h-5" />
|
<Mic className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleSend}
|
onClick={handleSend}
|
||||||
disabled={!inputValue.trim() || sending}
|
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"
|
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="发送消息"
|
title="发送 (Enter)"
|
||||||
>
|
>
|
||||||
<Send className="w-5 h-5" />
|
<Send className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex items-center justify-between text-xs text-white/40">
|
|
||||||
<span>消息: {conversation.messageCount} | Token: {conversation.tokenCount}</span>
|
<div className="flex items-center justify-between mt-2 px-1 text-xs text-white/25">
|
||||||
{sending && <span className="text-primary animate-pulse">AI 正在思考...</span>}
|
<span>{conversation.messageCount} 条消息 · {conversation.tokenCount} tokens</span>
|
||||||
|
{sending && <span className="text-primary/70 animate-pulse">AI 正在思考...</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user