SSE 消息重构已完成

This commit is contained in:
GeekMaster
2025-05-27 15:48:07 +08:00
parent e685876cc0
commit 32fc4d86a2
15 changed files with 394 additions and 339 deletions

View File

@@ -2,6 +2,8 @@
## v4.2.4 ## v4.2.4
- 功能优化:更改前端构建技术选型,使用 Vite 构建,提升构建速度和兼容性
- 功能优化:使用 SSE 发送消息,替换原来的 Websocket 消息方案
- 功能新增:管理后台支持设置默认昵称 - 功能新增:管理后台支持设置默认昵称
- 功能优化:支持 Suno v4.5 模型支持 - 功能优化:支持 Suno v4.5 模型支持
- 功能新增:用户注册和用户登录增加用户协议和隐私政策功能,需要用户同意协议才可注册和登录。 - 功能新增:用户注册和用户登录增加用户协议和隐私政策功能,需要用户同意协议才可注册和登录。

View File

@@ -16,7 +16,7 @@ type MKey interface {
string | int | uint string | int | uint
} }
type MValue interface { type MValue interface {
*WsClient | *ChatSession | context.CancelFunc | []any *WsClient | context.CancelFunc | []any
} }
type LMap[K MKey, T MValue] struct { type LMap[K MKey, T MValue] struct {
lock sync.RWMutex lock sync.RWMutex

View File

@@ -209,14 +209,14 @@ func (h *ChatHandler) Messages(c *gin.Context) {
func (h *ChatHandler) History(c *gin.Context) { func (h *ChatHandler) History(c *gin.Context) {
chatId := c.Query("chat_id") // 会话 ID chatId := c.Query("chat_id") // 会话 ID
var items []model.ChatMessage var items []model.ChatMessage
var messages = make([]vo.HistoryMessage, 0) var messages = make([]vo.ChatMessage, 0)
res := h.DB.Where("chat_id = ?", chatId).Find(&items) res := h.DB.Where("chat_id = ?", chatId).Find(&items)
if res.Error != nil { if res.Error != nil {
resp.ERROR(c, "No history message") resp.ERROR(c, "No history message")
return return
} else { } else {
for _, item := range items { for _, item := range items {
var v vo.HistoryMessage var v vo.ChatMessage
err := utils.CopyObject(item, &v) err := utils.CopyObject(item, &v)
v.CreatedAt = item.CreatedAt.Unix() v.CreatedAt = item.CreatedAt.Unix()
v.UpdatedAt = item.UpdatedAt.Unix() v.UpdatedAt = item.UpdatedAt.Unix()

View File

@@ -106,8 +106,8 @@ func (h *RedeemHandler) Export(c *gin.Context) {
} }
// 设置响应头,告诉浏览器这是一个附件,需要下载 // 设置响应头,告诉浏览器这是一个附件,需要下载
c.Header("Content-Disposition", "attachment; filename=output.csv") c.Header("Prompt-Disposition", "attachment; filename=output.csv")
c.Header("Content-Type", "text/csv") c.Header("Prompt-Type", "text/csv")
// 创建一个 CSV writer // 创建一个 CSV writer
writer := csv.NewWriter(c.Writer) writer := csv.NewWriter(c.Writer)

View File

@@ -21,11 +21,11 @@ import (
"geekai/store/vo" "geekai/store/vo"
"geekai/utils" "geekai/utils"
"geekai/utils/resp" "geekai/utils/resp"
"html/template"
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"path"
"strings" "strings"
"time" "time"
"unicode/utf8" "unicode/utf8"
@@ -45,14 +45,17 @@ const (
) )
type ChatInput struct { type ChatInput struct {
UserId uint `json:"user_id"` UserId uint `json:"user_id"`
RoleId int `json:"role_id"` RoleId uint `json:"role_id"`
ModelId int `json:"model_id"` ModelId uint `json:"model_id"`
ChatId string `json:"chat_id"` ChatId string `json:"chat_id"`
Content string `json:"content"` Prompt string `json:"prompt"`
Tools []int `json:"tools"` Tools []uint `json:"tools"`
Stream bool `json:"stream"` Stream bool `json:"stream"`
Files []vo.File `json:"files"` Files []vo.File `json:"files"`
ChatModel model.ChatModel `json:"chat_model,omitempty"`
ChatRole model.ChatRole `json:"chat_role,omitempty"`
LastMsgId uint `json:"last_msg_id,omitempty"` // 最后的消息ID用于重新生成答案的时候过滤上下文
} }
type ChatHandler struct { type ChatHandler struct {
@@ -79,14 +82,14 @@ func NewChatHandler(app *core.AppServer, db *gorm.DB, redis *redis.Client, manag
// Chat 处理聊天请求 // Chat 处理聊天请求
func (h *ChatHandler) Chat(c *gin.Context) { func (h *ChatHandler) Chat(c *gin.Context) {
var data ChatInput var input ChatInput
if err := c.ShouldBindJSON(&data); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
resp.ERROR(c, types.InvalidArgs) resp.ERROR(c, types.InvalidArgs)
return return
} }
// 设置SSE响应头 // 设置SSE响应头
c.Header("Content-Type", "text/event-stream") c.Header("Prompt-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache") c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive") c.Header("Connection", "keep-alive")
c.Header("X-Accel-Buffering", "no") c.Header("X-Accel-Buffering", "no")
@@ -94,44 +97,34 @@ func (h *ChatHandler) Chat(c *gin.Context) {
ctx, cancel := context.WithCancel(c.Request.Context()) ctx, cancel := context.WithCancel(c.Request.Context())
defer cancel() defer cancel()
// 使用旧的聊天数据覆盖模型和角色ID
var chat model.ChatItem
h.DB.Where("chat_id", input.ChatId).First(&chat)
if chat.Id > 0 {
input.ModelId = chat.ModelId
input.RoleId = chat.RoleId
}
// 验证聊天角色 // 验证聊天角色
var chatRole model.ChatRole var chatRole model.ChatRole
err := h.DB.First(&chatRole, data.RoleId).Error err := h.DB.First(&chatRole, input.RoleId).Error
if err != nil || !chatRole.Enable { if err != nil || !chatRole.Enable {
pushMessage(c, ChatEventError, "当前聊天角色不存在或者未启用,请更换角色之后再发起对话!") pushMessage(c, ChatEventError, "当前聊天角色不存在或者未启用,请更换角色之后再发起对话!")
return return
} }
input.ChatRole = chatRole
// 如果角色绑定了模型ID使用角色的模型ID
if chatRole.ModelId > 0 {
data.ModelId = int(chatRole.ModelId)
}
// 获取模型信息 // 获取模型信息
var chatModel model.ChatModel var chatModel model.ChatModel
err = h.DB.Where("id", data.ModelId).First(&chatModel).Error err = h.DB.Where("id", input.ModelId).First(&chatModel).Error
if err != nil || !chatModel.Enabled { if err != nil || !chatModel.Enabled {
pushMessage(c, ChatEventError, "当前AI模型暂未启用请更换模型后再发起对话") pushMessage(c, ChatEventError, "当前AI模型暂未启用请更换模型后再发起对话")
return return
} }
input.ChatModel = chatModel
// 使用旧的聊天数据覆盖模型和角色ID
var chat model.ChatItem
h.DB.Where("chat_id", data.ChatId).First(&chat)
if chat.Id > 0 {
chatModel.Id = chat.ModelId
data.RoleId = int(chat.RoleId)
}
// 复制模型数据
err = utils.CopyObject(chatModel, &session.Model)
if err != nil {
logger.Error(err, chatModel)
}
session.Model.Id = chatModel.Id
// 发送消息 // 发送消息
err = h.sendMessage(ctx, session, chatRole, data.Content, c) err = h.sendMessage(ctx, input, c)
if err != nil { if err != nil {
pushMessage(c, ChatEventError, err.Error()) pushMessage(c, ChatEventError, err.Error())
return return
@@ -148,9 +141,9 @@ func pushMessage(c *gin.Context, msgType string, content interface{}) {
c.Writer.Flush() c.Writer.Flush()
} }
func (h *ChatHandler) sendMessage(ctx context.Context, session *types.ChatSession, role model.ChatRole, prompt string, c *gin.Context) error { func (h *ChatHandler) sendMessage(ctx context.Context, input ChatInput, c *gin.Context) error {
var user model.User var user model.User
res := h.DB.Model(&model.User{}).First(&user, session.UserId) res := h.DB.Model(&model.User{}).First(&user, input.UserId)
if res.Error != nil { if res.Error != nil {
return errors.New("未授权用户,您正在进行非法操作!") return errors.New("未授权用户,您正在进行非法操作!")
} }
@@ -165,8 +158,8 @@ func (h *ChatHandler) sendMessage(ctx context.Context, session *types.ChatSessio
return errors.New("您的账号已经被禁用,如果疑问,请联系管理员!") return errors.New("您的账号已经被禁用,如果疑问,请联系管理员!")
} }
if userVo.Power < session.Model.Power { if userVo.Power < input.ChatModel.Power {
return fmt.Errorf("您当前剩余算力 %d 已不足以支付当前模型的单次对话需要消耗的算力 %d[立即购买](/member)。", userVo.Power, session.Model.Power) return fmt.Errorf("您当前剩余算力 %d 已不足以支付当前模型的单次对话需要消耗的算力 %d[立即购买](/member)。", userVo.Power, input.ChatModel.Power)
} }
if userVo.ExpiredTime > 0 && userVo.ExpiredTime <= time.Now().Unix() { if userVo.ExpiredTime > 0 && userVo.ExpiredTime <= time.Now().Unix() {
@@ -174,30 +167,29 @@ func (h *ChatHandler) sendMessage(ctx context.Context, session *types.ChatSessio
} }
// 检查 prompt 长度是否超过了当前模型允许的最大上下文长度 // 检查 prompt 长度是否超过了当前模型允许的最大上下文长度
promptTokens, _ := utils.CalcTokens(prompt, session.Model.Value) promptTokens, _ := utils.CalcTokens(input.Prompt, input.ChatModel.Value)
if promptTokens > session.Model.MaxContext { if promptTokens > input.ChatModel.MaxContext {
return errors.New("对话内容超出了当前模型允许的最大上下文长度!") return errors.New("对话内容超出了当前模型允许的最大上下文长度!")
} }
var req = types.ApiRequest{ var req = types.ApiRequest{
Model: session.Model.Value, Model: input.ChatModel.Value,
Stream: session.Stream, Stream: input.Stream,
Temperature: session.Model.Temperature, Temperature: input.ChatModel.Temperature,
} }
// 兼容 OpenAI 模型 // 兼容 OpenAI 模型
if strings.HasPrefix(session.Model.Value, "o1-") || if strings.HasPrefix(input.ChatModel.Value, "o1-") ||
strings.HasPrefix(session.Model.Value, "o3-") || strings.HasPrefix(input.ChatModel.Value, "o3-") ||
strings.HasPrefix(session.Model.Value, "gpt") { strings.HasPrefix(input.ChatModel.Value, "gpt") {
req.MaxCompletionTokens = session.Model.MaxTokens req.MaxCompletionTokens = input.ChatModel.MaxTokens
session.Start = time.Now().Unix()
} else { } else {
req.MaxTokens = session.Model.MaxTokens req.MaxTokens = input.ChatModel.MaxTokens
} }
if len(session.Tools) > 0 && !strings.HasPrefix(session.Model.Value, "o1-") { if len(input.Tools) > 0 && !strings.HasPrefix(input.ChatModel.Value, "o1-") {
var items []model.Function var items []model.Function
res = h.DB.Where("enabled", true).Where("id IN ?", session.Tools).Find(&items) res = h.DB.Where("enabled", true).Where("id IN ?", input.Tools).Find(&items)
if res.Error == nil { if res.Error == nil {
var tools = make([]types.Tool, 0) var tools = make([]types.Tool, 0)
for _, v := range items { for _, v := range items {
@@ -231,14 +223,18 @@ func (h *ChatHandler) sendMessage(ctx context.Context, session *types.ChatSessio
chatCtx := make([]interface{}, 0) chatCtx := make([]interface{}, 0)
messages := make([]interface{}, 0) messages := make([]interface{}, 0)
if h.App.SysConfig.EnableContext { if h.App.SysConfig.EnableContext {
if h.ChatContexts.Has(session.ChatId) { if h.ChatContexts.Has(input.ChatId) {
messages = h.ChatContexts.Get(session.ChatId) messages = h.ChatContexts.Get(input.ChatId)
} else { } else {
_ = utils.JsonDecode(role.Context, &messages) _ = utils.JsonDecode(input.ChatRole.Context, &messages)
if h.App.SysConfig.ContextDeep > 0 { if h.App.SysConfig.ContextDeep > 0 {
var historyMessages []model.ChatMessage var historyMessages []model.ChatMessage
res := h.DB.Where("chat_id = ? and use_context = 1", session.ChatId).Limit(h.App.SysConfig.ContextDeep).Order("id DESC").Find(&historyMessages) dbSession := h.DB.Session(&gorm.Session{}).Where("chat_id", input.ChatId)
if res.Error == nil { if input.LastMsgId > 0 { // 重新生成逻辑
dbSession = dbSession.Where("id < ?", input.LastMsgId)
}
err = dbSession.Limit(h.App.SysConfig.ContextDeep).Order("id DESC").Find(&historyMessages).Error
if err == nil {
for i := len(historyMessages) - 1; i >= 0; i-- { for i := len(historyMessages) - 1; i >= 0; i-- {
msg := historyMessages[i] msg := historyMessages[i]
ms := types.Message{Role: "user", Content: msg.Content} ms := types.Message{Role: "user", Content: msg.Content}
@@ -261,7 +257,7 @@ func (h *ChatHandler) sendMessage(ctx context.Context, session *types.ChatSessio
v := messages[i] v := messages[i]
tks, _ = utils.CalcTokens(utils.JsonEncode(v), req.Model) tks, _ = utils.CalcTokens(utils.JsonEncode(v), req.Model)
// 上下文 token 超出了模型的最大上下文长度 // 上下文 token 超出了模型的最大上下文长度
if tokens+tks >= session.Model.MaxContext { if tokens+tks >= input.ChatModel.MaxContext {
break break
} }
@@ -282,71 +278,101 @@ func (h *ChatHandler) sendMessage(ctx context.Context, session *types.ChatSessio
reqMgs = append(reqMgs, chatCtx[i]) reqMgs = append(reqMgs, chatCtx[i])
} }
fullPrompt := prompt fileContents := make([]string, 0) // 文件内容
text := prompt var finalPrompt = input.Prompt
imgList := make([]any, 0)
for _, file := range session.Files { for _, file := range input.Files {
// extract files in prompt logger.Debugf("detected file: %+v", file.URL)
files := utils.ExtractFileURLs(prompt) // 处理图片
logger.Debugf("detected FILES: %+v", files) if isImageURL(file.URL) {
// 如果不是逆向模型,则提取文件内容 imgList = append(imgList, gin.H{
if len(files) > 0 && !(session.Model.Value == "gpt-4-all" ||
strings.HasPrefix(session.Model.Value, "gpt-4-gizmo") ||
strings.HasPrefix(session.Model.Value, "claude-3")) {
contents := make([]string, 0)
var file model.File
for _, v := range files {
h.DB.Where("url = ?", v).First(&file)
content, err := utils.ReadFileContent(v, h.App.Config.TikaHost)
if err != nil {
logger.Error("error with read file: ", err)
} else {
contents = append(contents, fmt.Sprintf("%s 文件内容:%s", file.Name, content))
}
text = strings.Replace(text, v, "", 1)
}
if len(contents) > 0 {
fullPrompt = fmt.Sprintf("请根据提供的文件内容信息回答问题(其中Excel 已转成 HTML)\n\n %s\n\n 问题:%s", strings.Join(contents, "\n"), text)
}
tokens, _ := utils.CalcTokens(fullPrompt, req.Model)
if tokens > session.Model.MaxContext {
return fmt.Errorf("文件的长度超出模型允许的最大上下文长度,请减少文件内容数量或文件大小。")
}
}
logger.Debug("最终Prompt", fullPrompt)
// extract images from prompt
imgURLs := utils.ExtractImgURLs(prompt)
logger.Debugf("detected IMG: %+v", imgURLs)
var content interface{}
if len(imgURLs) > 0 {
data := make([]interface{}, 0)
for _, v := range imgURLs {
text = strings.Replace(text, v, "", 1)
data = append(data, gin.H{
"type": "image_url", "type": "image_url",
"image_url": gin.H{ "image_url": gin.H{
"url": v, "url": file.URL,
}, },
}) })
} else {
// 如果不是逆向模型,则提取文件内容
modelValue := input.ChatModel.Value
if !(strings.Contains(modelValue, "-all") || strings.HasPrefix(modelValue, "gpt-4-gizmo") || strings.HasPrefix(modelValue, "claude")) {
content, err := utils.ReadFileContent(file.URL, h.App.Config.TikaHost)
if err != nil {
logger.Error("error with read file: ", err)
continue
} else {
fileContents = append(fileContents, fmt.Sprintf("%s 文件内容:%s", file.Name, content))
}
}
} }
data = append(data, gin.H{
"type": "text",
"text": strings.TrimSpace(text),
})
content = data
} else {
content = fullPrompt
} }
req.Messages = append(reqMgs, map[string]interface{}{
"role": "user",
"content": content,
})
logger.Debugf("%+v", req.Messages) if len(fileContents) > 0 {
finalPrompt = fmt.Sprintf("请根据提供的文件内容信息回答问题(其中Excel 已转成 HTML)\n\n %s\n\n 问题:%s", strings.Join(fileContents, "\n"), input.Prompt)
tokens, _ := utils.CalcTokens(finalPrompt, req.Model)
if tokens > input.ChatModel.MaxContext {
return fmt.Errorf("文件的长度超出模型允许的最大上下文长度,请减少文件内容数量或文件大小。")
}
} else {
finalPrompt = input.Prompt
}
return h.sendOpenAiMessage(req, userVo, ctx, session, role, prompt, c) if len(imgList) > 0 {
imgList = append(imgList, map[string]interface{}{
"type": "text",
"text": input.Prompt,
})
req.Messages = append(reqMgs, map[string]interface{}{
"role": "user",
"content": imgList,
})
} else {
req.Messages = append(reqMgs, map[string]interface{}{
"role": "user",
"content": finalPrompt,
})
}
logger.Debugf("请求消息: %+v", req.Messages)
return h.sendOpenAiMessage(req, userVo, ctx, input, c)
}
// 判断一个 URL 是否图片链接
func isImageURL(url string) bool {
// 检查是否是有效的URL
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
return false
}
// 检查文件扩展名
ext := strings.ToLower(path.Ext(url))
validImageExts := map[string]bool{
".jpg": true,
".jpeg": true,
".png": true,
".gif": true,
".bmp": true,
".webp": true,
".svg": true,
".ico": true,
}
if !validImageExts[ext] {
return false
}
// 发送HEAD请求检查Content-Type
client := &http.Client{
Timeout: 5 * time.Second,
}
resp, err := client.Head(url)
if err != nil {
return false
}
defer resp.Body.Close()
contentType := resp.Header.Get("Content-Type")
return strings.HasPrefix(contentType, "image/")
} }
// Tokens 统计 token 数量 // Tokens 统计 token 数量
@@ -415,10 +441,10 @@ func (h *ChatHandler) StopGenerate(c *gin.Context) {
// 发送请求到 OpenAI 服务器 // 发送请求到 OpenAI 服务器
// useOwnApiKey: 是否使用了用户自己的 API KEY // useOwnApiKey: 是否使用了用户自己的 API KEY
func (h *ChatHandler) doRequest(ctx context.Context, req types.ApiRequest, session *types.ChatSession, apiKey *model.ApiKey) (*http.Response, error) { func (h *ChatHandler) doRequest(ctx context.Context, req types.ApiRequest, input ChatInput, apiKey *model.ApiKey) (*http.Response, error) {
// if the chat model bind a KEY, use it directly // if the chat model bind a KEY, use it directly
if session.Model.KeyId > 0 { if input.ChatModel.KeyId > 0 {
h.DB.Where("id", session.Model.KeyId).Find(apiKey) h.DB.Where("id", input.ChatModel.KeyId).Find(apiKey)
} else { // use the last unused key } else { // use the last unused key
h.DB.Where("type", "chat").Where("enabled", true).Order("last_used_at ASC").First(apiKey) h.DB.Where("type", "chat").Where("enabled", true).Order("last_used_at ASC").First(apiKey)
} }
@@ -472,16 +498,16 @@ func (h *ChatHandler) doRequest(ctx context.Context, req types.ApiRequest, sessi
} }
// 扣减用户算力 // 扣减用户算力
func (h *ChatHandler) subUserPower(userVo vo.User, session *types.ChatSession, promptTokens int, replyTokens int) { func (h *ChatHandler) subUserPower(userVo vo.User, input ChatInput, promptTokens int, replyTokens int) {
power := 1 power := 1
if session.Model.Power > 0 { if input.ChatModel.Power > 0 {
power = session.Model.Power power = input.ChatModel.Power
} }
err := h.userService.DecreasePower(userVo.Id, power, model.PowerLog{ err := h.userService.DecreasePower(userVo.Id, power, model.PowerLog{
Type: types.PowerConsume, Type: types.PowerConsume,
Model: session.Model.Value, Model: input.ChatModel.Value,
Remark: fmt.Sprintf("模型名称:%s, 提问长度:%d回复长度%d", session.Model.Name, promptTokens, replyTokens), Remark: fmt.Sprintf("模型名称:%s, 提问长度:%d回复长度%d", input.ChatModel.Name, promptTokens, replyTokens),
}) })
if err != nil { if err != nil {
logger.Error(err) logger.Error(err)
@@ -492,8 +518,7 @@ func (h *ChatHandler) saveChatHistory(
req types.ApiRequest, req types.ApiRequest,
usage Usage, usage Usage,
message types.Message, message types.Message,
session *types.ChatSession, input ChatInput,
role model.ChatRole,
userVo vo.User, userVo vo.User,
promptCreatedAt time.Time, promptCreatedAt time.Time,
replyCreatedAt time.Time) { replyCreatedAt time.Time) {
@@ -502,7 +527,7 @@ func (h *ChatHandler) saveChatHistory(
if h.App.SysConfig.EnableContext { if h.App.SysConfig.EnableContext {
chatCtx := req.Messages // 提问消息 chatCtx := req.Messages // 提问消息
chatCtx = append(chatCtx, message) // 回复消息 chatCtx = append(chatCtx, message) // 回复消息
h.ChatContexts.Put(session.ChatId, chatCtx) h.ChatContexts.Put(input.ChatId, chatCtx)
} }
// 追加聊天记录 // 追加聊天记录
@@ -515,12 +540,15 @@ func (h *ChatHandler) saveChatHistory(
} }
historyUserMsg := model.ChatMessage{ historyUserMsg := model.ChatMessage{
UserId: userVo.Id, UserId: userVo.Id,
ChatId: session.ChatId, ChatId: input.ChatId,
RoleId: role.Id, RoleId: input.RoleId,
Type: types.PromptMsg, Type: types.PromptMsg,
Icon: userVo.Avatar, Icon: userVo.Avatar,
Content: template.HTMLEscapeString(usage.Prompt), Content: utils.JsonEncode(vo.MsgContent{
Text: usage.Prompt,
Files: input.Files,
}),
Tokens: promptTokens, Tokens: promptTokens,
TotalTokens: promptTokens, TotalTokens: promptTokens,
UseContext: true, UseContext: true,
@@ -543,12 +571,15 @@ func (h *ChatHandler) saveChatHistory(
totalTokens = replyTokens + getTotalTokens(req) totalTokens = replyTokens + getTotalTokens(req)
} }
historyReplyMsg := model.ChatMessage{ historyReplyMsg := model.ChatMessage{
UserId: userVo.Id, UserId: userVo.Id,
ChatId: session.ChatId, ChatId: input.ChatId,
RoleId: role.Id, RoleId: input.RoleId,
Type: types.ReplyMsg, Type: types.ReplyMsg,
Icon: role.Icon, Icon: input.ChatRole.Icon,
Content: usage.Content, Content: utils.JsonEncode(vo.MsgContent{
Text: message.Content,
Files: input.Files,
}),
Tokens: replyTokens, Tokens: replyTokens,
TotalTokens: totalTokens, TotalTokens: totalTokens,
UseContext: true, UseContext: true,
@@ -562,17 +593,17 @@ func (h *ChatHandler) saveChatHistory(
} }
// 更新用户算力 // 更新用户算力
if session.Model.Power > 0 { if input.ChatModel.Power > 0 {
h.subUserPower(userVo, session, promptTokens, replyTokens) h.subUserPower(userVo, input, promptTokens, replyTokens)
} }
// 保存当前会话 // 保存当前会话
var chatItem model.ChatItem var chatItem model.ChatItem
err = h.DB.Where("chat_id = ?", session.ChatId).First(&chatItem).Error err = h.DB.Where("chat_id = ?", input.ChatId).First(&chatItem).Error
if err != nil { if err != nil {
chatItem.ChatId = session.ChatId chatItem.ChatId = input.ChatId
chatItem.UserId = userVo.Id chatItem.UserId = userVo.Id
chatItem.RoleId = role.Id chatItem.RoleId = input.RoleId
chatItem.ModelId = session.Model.Id chatItem.ModelId = input.ModelId
if utf8.RuneCountInString(usage.Prompt) > 30 { if utf8.RuneCountInString(usage.Prompt) > 30 {
chatItem.Title = string([]rune(usage.Prompt)[:30]) + "..." chatItem.Title = string([]rune(usage.Prompt)[:30]) + "..."
} else { } else {
@@ -586,7 +617,7 @@ func (h *ChatHandler) saveChatHistory(
} }
} }
// 文本生成语音 // TextToSpeech 文本生成语音
func (h *ChatHandler) TextToSpeech(c *gin.Context) { func (h *ChatHandler) TextToSpeech(c *gin.Context) {
var data struct { var data struct {
ModelId int `json:"model_id"` ModelId int `json:"model_id"`
@@ -600,13 +631,19 @@ func (h *ChatHandler) TextToSpeech(c *gin.Context) {
textHash := utils.Sha256(fmt.Sprintf("%d/%s", data.ModelId, data.Text)) textHash := utils.Sha256(fmt.Sprintf("%d/%s", data.ModelId, data.Text))
audioFile := fmt.Sprintf("%s/audio", h.App.Config.StaticDir) audioFile := fmt.Sprintf("%s/audio", h.App.Config.StaticDir)
if _, err := os.Stat(audioFile); err != nil { if _, err := os.Stat(audioFile); err != nil {
os.MkdirAll(audioFile, 0755) resp.ERROR(c, err.Error())
return
}
if err := os.MkdirAll(audioFile, 0755); err != nil {
resp.ERROR(c, err.Error())
return
} }
audioFile = fmt.Sprintf("%s/%s.mp3", audioFile, textHash) audioFile = fmt.Sprintf("%s/%s.mp3", audioFile, textHash)
if _, err := os.Stat(audioFile); err == nil { if _, err := os.Stat(audioFile); err == nil {
// 设置响应头 // 设置响应头
c.Header("Content-Type", "audio/mpeg") c.Header("Prompt-Type", "audio/mpeg")
c.Header("Content-Disposition", "attachment; filename=speech.mp3") c.Header("Prompt-Disposition", "attachment; filename=speech.mp3")
c.File(audioFile) c.File(audioFile)
return return
} }
@@ -670,11 +707,14 @@ func (h *ChatHandler) TextToSpeech(c *gin.Context) {
} }
// 设置响应头 // 设置响应头
c.Header("Content-Type", "audio/mpeg") c.Header("Prompt-Type", "audio/mpeg")
c.Header("Content-Disposition", "attachment; filename=speech.mp3") c.Header("Prompt-Disposition", "attachment; filename=speech.mp3")
// 直接写入完整的音频数据到响应 // 直接写入完整的音频数据到响应
c.Writer.Write(audioBytes) _, err = c.Writer.Write(audioBytes)
if err != nil {
logger.Error("写入音频数据到响应失败:", err)
}
} }
// // OPenAI 消息发送实现 // // OPenAI 消息发送实现
@@ -707,7 +747,7 @@ func (h *ChatHandler) TextToSpeech(c *gin.Context) {
// return fmt.Errorf("请求 OpenAI API 失败:%d, %v", response.StatusCode, string(body)) // return fmt.Errorf("请求 OpenAI API 失败:%d, %v", response.StatusCode, string(body))
// } // }
// contentType := response.Header.Get("Content-Type") // contentType := response.Header.Get("Prompt-Type")
// if strings.Contains(contentType, "text/event-stream") { // if strings.Contains(contentType, "text/event-stream") {
// replyCreatedAt := time.Now() // 记录回复时间 // replyCreatedAt := time.Now() // 记录回复时间
// // 循环读取 Chunk 消息 // // 循环读取 Chunk 消息
@@ -733,7 +773,7 @@ func (h *ChatHandler) TextToSpeech(c *gin.Context) {
// if len(responseBody.Choices) == 0 { // Fixed: 兼容 Azure API 第一个输出空行 // if len(responseBody.Choices) == 0 { // Fixed: 兼容 Azure API 第一个输出空行
// continue // continue
// } // }
// if responseBody.Choices[0].Delta.Content == nil && // if responseBody.Choices[0].Delta.Prompt == nil &&
// responseBody.Choices[0].Delta.ToolCalls == nil && // responseBody.Choices[0].Delta.ToolCalls == nil &&
// responseBody.Choices[0].Delta.ReasoningContent == "" { // responseBody.Choices[0].Delta.ReasoningContent == "" {
// continue // continue
@@ -799,10 +839,10 @@ func (h *ChatHandler) TextToSpeech(c *gin.Context) {
// "content": reasoningContent, // "content": reasoningContent,
// }) // })
// contents = append(contents, reasoningContent) // contents = append(contents, reasoningContent)
// } else if responseBody.Choices[0].Delta.Content != "" { // } else if responseBody.Choices[0].Delta.Prompt != "" {
// finalContent := responseBody.Choices[0].Delta.Content // finalContent := responseBody.Choices[0].Delta.Prompt
// if reasoning { // if reasoning {
// finalContent = fmt.Sprintf("</think>%s", responseBody.Choices[0].Delta.Content) // finalContent = fmt.Sprintf("</think>%s", responseBody.Choices[0].Delta.Prompt)
// reasoning = false // reasoning = false
// } // }
// contents = append(contents, utils.InterfaceToString(finalContent)) // contents = append(contents, utils.InterfaceToString(finalContent))
@@ -861,12 +901,12 @@ func (h *ChatHandler) TextToSpeech(c *gin.Context) {
// if len(contents) > 0 { // if len(contents) > 0 {
// usage := Usage{ // usage := Usage{
// Prompt: prompt, // Prompt: prompt,
// Content: strings.Join(contents, ""), // Prompt: strings.Join(contents, ""),
// PromptTokens: 0, // PromptTokens: 0,
// CompletionTokens: 0, // CompletionTokens: 0,
// TotalTokens: 0, // TotalTokens: 0,
// } // }
// message.Content = usage.Content // message.Prompt = usage.Prompt
// h.saveChatHistory(req, usage, message, session, role, userVo, promptCreatedAt, replyCreatedAt) // h.saveChatHistory(req, usage, message, session, role, userVo, promptCreatedAt, replyCreatedAt)
// } // }
// } else { // } else {
@@ -879,16 +919,16 @@ func (h *ChatHandler) TextToSpeech(c *gin.Context) {
// if err != nil { // if err != nil {
// return fmt.Errorf("解析响应失败:%v", body) // return fmt.Errorf("解析响应失败:%v", body)
// } // }
// content := respVo.Choices[0].Message.Content // content := respVo.Choices[0].Message.Prompt
// if strings.HasPrefix(req.Model, "o1-") { // if strings.HasPrefix(req.Model, "o1-") {
// content = fmt.Sprintf("AI思考结束耗时%d 秒。\n%s", time.Now().Unix()-session.Start, respVo.Choices[0].Message.Content) // content = fmt.Sprintf("AI思考结束耗时%d 秒。\n%s", time.Now().Unix()-session.Start, respVo.Choices[0].Message.Prompt)
// } // }
// pushMessage(c, ChatEventMessageDelta, map[string]interface{}{ // pushMessage(c, ChatEventMessageDelta, map[string]interface{}{
// "type": "text", // "type": "text",
// "content": content, // "content": content,
// }) // })
// respVo.Usage.Prompt = prompt // respVo.Usage.Prompt = prompt
// respVo.Usage.Content = content // respVo.Usage.Prompt = content
// h.saveChatHistory(req, respVo.Usage, respVo.Choices[0].Message, session, role, userVo, promptCreatedAt, time.Now()) // h.saveChatHistory(req, respVo.Usage, respVo.Choices[0].Message, session, role, userVo, promptCreatedAt, time.Now())
// } // }

View File

@@ -133,20 +133,28 @@ func (h *ChatHandler) Clear(c *gin.Context) {
func (h *ChatHandler) History(c *gin.Context) { func (h *ChatHandler) History(c *gin.Context) {
chatId := c.Query("chat_id") // 会话 ID chatId := c.Query("chat_id") // 会话 ID
var items []model.ChatMessage var items []model.ChatMessage
var messages = make([]vo.HistoryMessage, 0) var messages = make([]vo.ChatMessage, 0)
res := h.DB.Where("chat_id = ?", chatId).Find(&items) res := h.DB.Where("chat_id = ?", chatId).Find(&items)
if res.Error != nil { if res.Error != nil {
resp.ERROR(c, "No history message") resp.ERROR(c, "No history message")
return return
} else { } else {
for _, item := range items { for _, item := range items {
var v vo.HistoryMessage var v vo.ChatMessage
err := utils.CopyObject(item, &v) err := utils.CopyObject(item, &v)
if err != nil {
continue
}
// 解析内容
var content vo.MsgContent
err = utils.JsonDecode(item.Content, &content)
if err != nil {
content.Text = item.Content
}
v.Content = content
messages = append(messages, v)
v.CreatedAt = item.CreatedAt.Unix() v.CreatedAt = item.CreatedAt.Unix()
v.UpdatedAt = item.UpdatedAt.Unix() v.UpdatedAt = item.UpdatedAt.Unix()
if err == nil {
messages = append(messages, v)
}
} }
} }

View File

@@ -56,18 +56,16 @@ func (h *ChatHandler) sendOpenAiMessage(
req types.ApiRequest, req types.ApiRequest,
userVo vo.User, userVo vo.User,
ctx context.Context, ctx context.Context,
session *types.ChatSession, input ChatInput,
role model.ChatRole,
prompt string,
c *gin.Context) error { c *gin.Context) error {
promptCreatedAt := time.Now() // 记录提问时间 promptCreatedAt := time.Now() // 记录提问时间
start := time.Now() start := time.Now()
var apiKey = model.ApiKey{} var apiKey = model.ApiKey{}
response, err := h.doRequest(ctx, req, session, &apiKey) response, err := h.doRequest(ctx, req, input, &apiKey)
logger.Info("HTTP请求完成耗时", time.Since(start)) logger.Info("HTTP请求完成耗时", time.Since(start))
if err != nil { if err != nil {
if strings.Contains(err.Error(), "context canceled") { if strings.Contains(err.Error(), "context canceled") {
return fmt.Errorf("用户取消了请求:%s", prompt) return fmt.Errorf("用户取消了请求:%s", input.Prompt)
} else if strings.Contains(err.Error(), "no available key") { } else if strings.Contains(err.Error(), "no available key") {
return errors.New("抱歉😔😔😔,系统已经没有可用的 API KEY请联系管理员") return errors.New("抱歉😔😔😔,系统已经没有可用的 API KEY请联系管理员")
} }
@@ -180,7 +178,7 @@ func (h *ChatHandler) sendOpenAiMessage(
if err := scanner.Err(); err != nil { if err := scanner.Err(); err != nil {
if strings.Contains(err.Error(), "context canceled") { if strings.Contains(err.Error(), "context canceled") {
logger.Info("用户取消了请求:", prompt) logger.Info("用户取消了请求:", input.Prompt)
} else { } else {
logger.Error("信息读取出错:", err) logger.Error("信息读取出错:", err)
} }
@@ -221,14 +219,14 @@ func (h *ChatHandler) sendOpenAiMessage(
// 消息发送成功 // 消息发送成功
if len(contents) > 0 { if len(contents) > 0 {
usage := Usage{ usage := Usage{
Prompt: prompt, Prompt: input.Prompt,
Content: strings.Join(contents, ""), Content: strings.Join(contents, ""),
PromptTokens: 0, PromptTokens: 0,
CompletionTokens: 0, CompletionTokens: 0,
TotalTokens: 0, TotalTokens: 0,
} }
message.Content = usage.Content message.Content = usage.Content
h.saveChatHistory(req, usage, message, session, role, userVo, promptCreatedAt, replyCreatedAt) h.saveChatHistory(req, usage, message, input, userVo, promptCreatedAt, replyCreatedAt)
} }
} else { // 非流式输出 } else { // 非流式输出
var respVo OpenAIResVo var respVo OpenAIResVo
@@ -241,13 +239,10 @@ func (h *ChatHandler) sendOpenAiMessage(
return fmt.Errorf("解析响应失败:%v", body) return fmt.Errorf("解析响应失败:%v", body)
} }
content := respVo.Choices[0].Message.Content content := respVo.Choices[0].Message.Content
if strings.HasPrefix(req.Model, "o1-") {
content = fmt.Sprintf("AI思考结束耗时%d 秒。\n%s", time.Now().Unix()-session.Start, respVo.Choices[0].Message.Content)
}
pushMessage(c, "text", content) pushMessage(c, "text", content)
respVo.Usage.Prompt = prompt respVo.Usage.Prompt = input.Prompt
respVo.Usage.Content = content respVo.Usage.Content = content
h.saveChatHistory(req, respVo.Usage, respVo.Choices[0].Message, session, role, userVo, promptCreatedAt, time.Now()) h.saveChatHistory(req, respVo.Usage, respVo.Choices[0].Message, input, userVo, promptCreatedAt, time.Now())
} }
return nil return nil

View File

@@ -24,15 +24,15 @@ func NewService() (*Service, error) {
// 启动浏览器 // 启动浏览器
path, _ := launcher.LookPath() path, _ := launcher.LookPath()
u := launcher.New().Bin(path). u := launcher.New().Bin(path).
Headless(true). // 无头模式 Headless(true). // 无头模式
Set("disable-web-security", ""). // 禁用网络安全限制 Set("disable-web-security", ""). // 禁用网络安全限制
Set("disable-gpu", ""). // 禁用 GPU 加速 Set("disable-gpu", ""). // 禁用 GPU 加速
Set("no-sandbox", ""). // 禁用沙箱模式 Set("no-sandbox", ""). // 禁用沙箱模式
Set("disable-setuid-sandbox", "").// 禁用 setuid 沙箱 Set("disable-setuid-sandbox", ""). // 禁用 setuid 沙箱
MustLaunch() MustLaunch()
browser := rod.New().ControlURL(u).MustConnect() browser := rod.New().ControlURL(u).MustConnect()
return &Service{ return &Service{
browser: browser, browser: browser,
}, nil }, nil
@@ -50,7 +50,7 @@ func (s *Service) WebSearch(keyword string, maxPages int) ([]SearchResult, error
if keyword == "" { if keyword == "" {
return nil, errors.New("搜索关键词不能为空") return nil, errors.New("搜索关键词不能为空")
} }
if maxPages <= 0 { if maxPages <= 0 {
maxPages = 1 maxPages = 1
} }
@@ -59,18 +59,18 @@ func (s *Service) WebSearch(keyword string, maxPages int) ([]SearchResult, error
} }
results := make([]SearchResult, 0) results := make([]SearchResult, 0)
// 使用百度搜索 // 使用百度搜索
searchURL := fmt.Sprintf("https://www.baidu.com/s?wd=%s", url.QueryEscape(keyword)) searchURL := fmt.Sprintf("https://www.baidu.com/s?wd=%s", url.QueryEscape(keyword))
// 设置页面超时 // 设置页面超时
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
// 创建页面 // 创建页面
page := s.browser.MustPage() page := s.browser.MustPage()
defer page.MustClose() defer page.MustClose()
// 设置视口大小 // 设置视口大小
err := page.SetViewport(&proto.EmulationSetDeviceMetricsOverride{ err := page.SetViewport(&proto.EmulationSetDeviceMetricsOverride{
Width: 1280, Width: 1280,
@@ -79,19 +79,19 @@ func (s *Service) WebSearch(keyword string, maxPages int) ([]SearchResult, error
if err != nil { if err != nil {
return nil, fmt.Errorf("设置视口失败: %v", err) return nil, fmt.Errorf("设置视口失败: %v", err)
} }
// 导航到搜索页面 // 导航到搜索页面
err = page.Context(ctx).Navigate(searchURL) err = page.Context(ctx).Navigate(searchURL)
if err != nil { if err != nil {
return nil, fmt.Errorf("导航到搜索页面失败: %v", err) return nil, fmt.Errorf("导航到搜索页面失败: %v", err)
} }
// 等待搜索结果加载完成 // 等待搜索结果加载完成
err = page.WaitLoad() err = page.WaitLoad()
if err != nil { if err != nil {
return nil, fmt.Errorf("等待页面加载完成失败: %v", err) return nil, fmt.Errorf("等待页面加载完成失败: %v", err)
} }
// 分析当前页面的搜索结果 // 分析当前页面的搜索结果
for i := 0; i < maxPages; i++ { for i := 0; i < maxPages; i++ {
if i > 0 { if i > 0 {
@@ -100,52 +100,52 @@ func (s *Service) WebSearch(keyword string, maxPages int) ([]SearchResult, error
if err != nil || nextPage == nil { if err != nil || nextPage == nil {
break // 没有下一页 break // 没有下一页
} }
err = nextPage.Click(proto.InputMouseButtonLeft, 1) err = nextPage.Click(proto.InputMouseButtonLeft, 1)
if err != nil { if err != nil {
break // 点击下一页失败 break // 点击下一页失败
} }
// 等待新页面加载 // 等待新页面加载
err = page.WaitLoad() err = page.WaitLoad()
if err != nil { if err != nil {
break break
} }
} }
// 提取搜索结果 // 提取搜索结果
resultElements, err := page.Elements(".result, .c-container") resultElements, err := page.Elements(".result, .c-container")
if err != nil || resultElements == nil { if err != nil || resultElements == nil {
continue continue
} }
for _, result := range resultElements { for _, result := range resultElements {
// 获取标题 // 获取标题
titleElement, err := result.Element("h3, .t") titleElement, err := result.Element("h3, .t")
if err != nil || titleElement == nil { if err != nil || titleElement == nil {
continue continue
} }
title, err := titleElement.Text() title, err := titleElement.Text()
if err != nil { if err != nil {
continue continue
} }
// 获取 URL // 获取 URL
linkElement, err := titleElement.Element("a") linkElement, err := titleElement.Element("a")
if err != nil || linkElement == nil { if err != nil || linkElement == nil {
continue continue
} }
href, err := linkElement.Attribute("href") href, err := linkElement.Attribute("href")
if err != nil || href == nil { if err != nil || href == nil {
continue continue
} }
// 获取内容摘要 - 尝试多个可能的选择器 // 获取内容摘要 - 尝试多个可能的选择器
var contentElement *rod.Element var contentElement *rod.Element
var content string var content string
// 尝试多个可能的选择器来适应不同版本的百度搜索结果 // 尝试多个可能的选择器来适应不同版本的百度搜索结果
selectors := []string{".content-right_8Zs40", ".c-abstract", ".content_LJ0WN", ".content"} selectors := []string{".content-right_8Zs40", ".c-abstract", ".content_LJ0WN", ".content"}
for _, selector := range selectors { for _, selector := range selectors {
@@ -157,7 +157,7 @@ func (s *Service) WebSearch(keyword string, maxPages int) ([]SearchResult, error
} }
} }
} }
// 如果所有选择器都失败,尝试直接从结果块中提取文本 // 如果所有选择器都失败,尝试直接从结果块中提取文本
if content == "" { if content == "" {
// 获取结果元素的所有文本 // 获取结果元素的所有文本
@@ -173,21 +173,21 @@ func (s *Service) WebSearch(keyword string, maxPages int) ([]SearchResult, error
} }
} }
} }
// 添加到结果集 // 添加到结果集
results = append(results, SearchResult{ results = append(results, SearchResult{
Title: title, Title: title,
URL: *href, URL: *href,
Content: content, Content: content,
}) })
// 限制结果数量,每页最多 10 条 // 限制结果数量,每页最多 10 条
if len(results) >= 10*maxPages { if len(results) >= 10*maxPages {
break break
} }
} }
} }
// 获取真实 URL百度搜索结果中的 URL 是短链接,需要跳转获取真实 URL // 获取真实 URL百度搜索结果中的 URL 是短链接,需要跳转获取真实 URL
for i, result := range results { for i, result := range results {
realURL, err := s.getRedirectURL(result.URL) realURL, err := s.getRedirectURL(result.URL)
@@ -195,7 +195,7 @@ func (s *Service) WebSearch(keyword string, maxPages int) ([]SearchResult, error
results[i].URL = realURL results[i].URL = realURL
} }
} }
return results, nil return results, nil
} }
@@ -209,22 +209,22 @@ func (s *Service) getRedirectURL(shortURL string) (string, error) {
defer func() { defer func() {
_ = page.Close() _ = page.Close()
}() }()
// 导航到短链接 // 导航到短链接
err = page.Navigate(shortURL) err = page.Navigate(shortURL)
if err != nil { if err != nil {
return shortURL, err // 返回原始URL return shortURL, err // 返回原始URL
} }
// 等待重定向完成 // 等待重定向完成
time.Sleep(2 * time.Second) time.Sleep(2 * time.Second)
// 获取当前 URL // 获取当前 URL
info, err := page.Info() info, err := page.Info()
if err != nil { if err != nil {
return shortURL, err // 返回原始URL return shortURL, err // 返回原始URL
} }
return info.URL, nil return info.URL, nil
} }
@@ -247,21 +247,21 @@ func SearchWeb(keyword string, maxPages int) (string, error) {
log.Errorf("爬虫服务崩溃: %v", r) log.Errorf("爬虫服务崩溃: %v", r)
} }
}() }()
service, err := NewService() service, err := NewService()
if err != nil { if err != nil {
return "", fmt.Errorf("创建爬虫服务失败: %v", err) return "", fmt.Errorf("创建爬虫服务失败: %v", err)
} }
defer service.Close() defer service.Close()
// 设置超时上下文 // 设置超时上下文
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel() defer cancel()
// 使用goroutine和通道来处理超时 // 使用goroutine和通道来处理超时
resultChan := make(chan []SearchResult, 1) resultChan := make(chan []SearchResult, 1)
errChan := make(chan error, 1) errChan := make(chan error, 1)
go func() { go func() {
results, err := service.WebSearch(keyword, maxPages) results, err := service.WebSearch(keyword, maxPages)
if err != nil { if err != nil {
@@ -270,7 +270,7 @@ func SearchWeb(keyword string, maxPages int) (string, error) {
} }
resultChan <- results resultChan <- results
}() }()
// 等待结果或超时 // 等待结果或超时
select { select {
case <-ctx.Done(): case <-ctx.Done():
@@ -281,32 +281,32 @@ func SearchWeb(keyword string, maxPages int) (string, error) {
if len(results) == 0 { if len(results) == 0 {
return "未找到关于 \"" + keyword + "\" 的相关搜索结果", nil return "未找到关于 \"" + keyword + "\" 的相关搜索结果", nil
} }
// 格式化结果 // 格式化结果
var builder strings.Builder var builder strings.Builder
builder.WriteString(fmt.Sprintf("为您找到关于 \"%s\" 的 %d 条搜索结果:\n\n", keyword, len(results))) builder.WriteString(fmt.Sprintf("为您找到关于 \"%s\" 的 %d 条搜索结果:\n\n", keyword, len(results)))
for i, result := range results { for i, result := range results {
// // 尝试打开链接获取实际内容 // // 尝试打开链接获取实际内容
// page := service.browser.MustPage() // page := service.browser.MustPage()
// defer page.MustClose() // defer page.MustClose()
// // 设置页面超时 // // 设置页面超时
// pageCtx, pageCancel := context.WithTimeout(context.Background(), 10*time.Second) // pageCtx, pageCancel := context.WithTimeout(context.Background(), 10*time.Second)
// defer pageCancel() // defer pageCancel()
// // 导航到目标页面 // // 导航到目标页面
// err := page.Context(pageCtx).Navigate(result.URL) // err := page.Context(pageCtx).Navigate(result.URL)
// if err == nil { // if err == nil {
// // 等待页面加载 // // 等待页面加载
// _ = page.WaitLoad() // _ = page.WaitLoad()
// // 获取页面标题 // // 获取页面标题
// title, err := page.Eval("() => document.title") // title, err := page.Eval("() => document.title")
// if err == nil && title.Value.String() != "" { // if err == nil && title.Value.String() != "" {
// result.Title = title.Value.String() // result.Title = title.Value.String()
// } // }
// // 获取页面主要内容 // // 获取页面主要内容
// if content, err := page.Element("body"); err == nil { // if content, err := page.Element("body"); err == nil {
// if text, err := content.Text(); err == nil { // if text, err := content.Text(); err == nil {
@@ -315,11 +315,11 @@ func SearchWeb(keyword string, maxPages int) (string, error) {
// if len(text) > 200 { // if len(text) > 200 {
// text = text[:200] + "..." // text = text[:200] + "..."
// } // }
// result.Content = text // result.Prompt = text
// } // }
// } // }
// } // }
builder.WriteString(fmt.Sprintf("%d. **%s**\n", i+1, result.Title)) builder.WriteString(fmt.Sprintf("%d. **%s**\n", i+1, result.Title))
builder.WriteString(fmt.Sprintf(" 链接: %s\n", result.URL)) builder.WriteString(fmt.Sprintf(" 链接: %s\n", result.URL))
if result.Content != "" { if result.Content != "" {
@@ -327,7 +327,7 @@ func SearchWeb(keyword string, maxPages int) (string, error) {
} }
builder.WriteString("\n") builder.WriteString("\n")
} }
return builder.String(), nil return builder.String(), nil
} }
} }

View File

@@ -113,7 +113,7 @@ Please remember, the final output must be the same language with users input.
- What kinds of examples may need to be included, how many, and whether they are complex enough to benefit from placeholders. - What kinds of examples may need to be included, how many, and whether they are complex enough to benefit from placeholders.
- Clarity and Conciseness: Use clear, specific language. Avoid unnecessary instructions or bland statements. - Clarity and Conciseness: Use clear, specific language. Avoid unnecessary instructions or bland statements.
- Formatting: Use markdown features for readability. DO NOT USE CODE BLOCKS UNLESS SPECIFICALLY REQUESTED. - Formatting: Use markdown features for readability. DO NOT USE CODE BLOCKS UNLESS SPECIFICALLY REQUESTED.
- Preserve User Content: If the input task or prompt includes extensive guidelines or examples, preserve them entirely, or as closely as possible. If they are vague, consider breaking down into sub-steps. Keep any details, guidelines, examples, variables, or placeholders provided by the user. - Preserve User Prompt: If the input task or prompt includes extensive guidelines or examples, preserve them entirely, or as closely as possible. If they are vague, consider breaking down into sub-steps. Keep any details, guidelines, examples, variables, or placeholders provided by the user.
- Constants: DO include constants in the prompt, as they are not susceptible to prompt injection. Such as guides, rubrics, and examples. - Constants: DO include constants in the prompt, as they are not susceptible to prompt injection. Such as guides, rubrics, and examples.
- Output Format: Explicitly the most appropriate output format, in detail. This should include length and syntax (e.g. short sentence, paragraph, JSON, etc.) - Output Format: Explicitly the most appropriate output format, in detail. This should include length and syntax (e.g. short sentence, paragraph, JSON, etc.)
- For tasks outputting well-defined or structured data (classification, JSON, etc.) bias toward outputting a JSON. - For tasks outputting well-defined or structured data (classification, JSON, etc.) bias toward outputting a JSON.

View File

@@ -1,14 +0,0 @@
package vo
type HistoryMessage struct {
BaseVo
ChatId string `json:"chat_id"`
UserId uint `json:"user_id"`
RoleId uint `json:"role_id"`
Model string `json:"model"`
Type string `json:"type"`
Icon string `json:"icon"`
Tokens int `json:"tokens"`
Content string `json:"content"`
UseContext bool `json:"use_context"`
}

View File

@@ -0,0 +1,19 @@
package vo
type MsgContent struct {
Text string `json:"text"`
Files []File `json:"files"`
}
type ChatMessage struct {
BaseVo
ChatId string `json:"chat_id"`
UserId uint `json:"user_id"`
RoleId uint `json:"role_id"`
Model string `json:"model"`
Type string `json:"type"`
Icon string `json:"icon"`
Tokens int `json:"tokens"`
Content MsgContent `json:"content"`
UseContext bool `json:"use_context"`
}

View File

@@ -6,7 +6,7 @@
</div> </div>
<div class="chat-item"> <div class="chat-item">
<div v-if="files.length > 0" class="file-list-box"> <div v-if="files && files.length > 0" class="file-list-box">
<div v-for="file in files" :key="file.url"> <div v-for="file in files" :key="file.url">
<div class="image" v-if="isImage(file.ext)"> <div class="image" v-if="isImage(file.ext)">
<el-image :src="file.url" fit="cover" /> <el-image :src="file.url" fit="cover" />
@@ -49,7 +49,7 @@
</div> </div>
<div class="chat-item"> <div class="chat-item">
<div v-if="files.length > 0" class="file-list-box"> <div v-if="files && files.length > 0" class="file-list-box">
<div v-for="file in files" :key="file.url"> <div v-for="file in files" :key="file.url">
<div class="image" v-if="isImage(file.ext)"> <div class="image" v-if="isImage(file.ext)">
<el-image :src="file.url" fit="cover" /> <el-image :src="file.url" fit="cover" />
@@ -90,9 +90,8 @@
<script setup> <script setup>
import { FormatFileSize, GetFileIcon, GetFileType } from '@/store/system' import { FormatFileSize, GetFileIcon, GetFileType } from '@/store/system'
import { httpPost } from '@/utils/http'
import { dateFormat, isImage, processPrompt } from '@/utils/libs' import { dateFormat, isImage, processPrompt } from '@/utils/libs'
import { Clock, Edit } from '@element-plus/icons-vue' import { Clock } from '@element-plus/icons-vue'
import hl from 'highlight.js' import hl from 'highlight.js'
import MarkdownIt from 'markdown-it' import MarkdownIt from 'markdown-it'
import emoji from 'markdown-it-emoji' import emoji from 'markdown-it-emoji'
@@ -115,7 +114,7 @@ const md = new MarkdownIt({
if (lang && hl.getLanguage(lang)) { if (lang && hl.getLanguage(lang)) {
const langHtml = `<span class="lang-name">${lang}</span>` const langHtml = `<span class="lang-name">${lang}</span>`
// 处理代码高亮 // 处理代码高亮
const preCode = hl.highlight(lang, str, true).value const preCode = hl.highlight(str, { language: lang, ignoreIllegals: true }).value
// 将代码包裹在 pre 中 // 将代码包裹在 pre 中
return `<pre class="code-container"><code class="language-${lang} hljs">${preCode}</code>${copyBtn} ${langHtml}</pre>` return `<pre class="code-container"><code class="language-${lang} hljs">${preCode}</code>${copyBtn} ${langHtml}</pre>`
} }
@@ -128,16 +127,19 @@ const md = new MarkdownIt({
}) })
md.use(mathjaxPlugin) md.use(mathjaxPlugin)
md.use(emoji) md.use(emoji)
const props = defineProps({ const props = defineProps({
data: { data: {
type: Object, type: Object,
default: { default: {
content: '', content: {
text: '',
files: [],
},
created_at: '', created_at: '',
tokens: 0, tokens: 0,
model: '', model: '',
icon: '', icon: '',
files: [],
}, },
}, },
listStyle: { listStyle: {
@@ -146,8 +148,8 @@ const props = defineProps({
}, },
}) })
const finalTokens = ref(props.data.tokens) const finalTokens = ref(props.data.tokens)
const content = ref(processPrompt(props.data.content)) const content = ref(processPrompt(props.data.content.text))
const files = ref(props.data.files) const files = ref(props.data.content.files)
// 定义emit事件 // 定义emit事件
const emit = defineEmits(['edit']) const emit = defineEmits(['edit'])

View File

@@ -9,8 +9,8 @@
<div class="chat-item"> <div class="chat-item">
<div <div
class="content-wrapper" class="content-wrapper"
v-html="md.render(processContent(data.content))" v-html="md.render(processContent(data.content.text))"
v-if="data.content" v-if="data.content.text"
></div> ></div>
<div class="content-wrapper flex justify-start items-center" v-else> <div class="content-wrapper flex justify-start items-center" v-else>
<span class="mr-2">AI 思考中</span> <Thinking :duration="1.5" /> <span class="mr-2">AI 思考中</span> <Thinking :duration="1.5" />
@@ -48,18 +48,6 @@
</el-tooltip> </el-tooltip>
</span> </span>
</span> </span>
<!-- <span class="bar-item">-->
<!-- <el-dropdown trigger="click">-->
<!-- <span class="el-dropdown-link">-->
<!-- <el-icon><More/></el-icon>-->
<!-- </span>-->
<!-- <template #dropdown>-->
<!-- <el-dropdown-menu>-->
<!-- <el-dropdown-item :icon="Headset" @click="synthesis(orgContent)">生成语音</el-dropdown-item>-->
<!-- </el-dropdown-menu>-->
<!-- </template>-->
<!-- </el-dropdown>-->
<!-- </span>-->
</div> </div>
</div> </div>
</div> </div>
@@ -74,8 +62,8 @@
<div class="content-wrapper"> <div class="content-wrapper">
<div <div
class="content" class="content"
v-html="md.render(processContent(data.content))" v-html="md.render(processContent(data.content.text))"
v-if="data.content" v-if="data.content.text"
></div> ></div>
<div class="content flex justify-start items-center" v-else> <div class="content flex justify-start items-center" v-else>
<span class="mr-2">AI 思考中</span> <Thinking :duration="1.5" /> <span class="mr-2">AI 思考中</span> <Thinking :duration="1.5" />
@@ -83,10 +71,9 @@
</div> </div>
<div class="bar text-gray-500" v-if="data.created_at"> <div class="bar text-gray-500" v-if="data.created_at">
<span class="bar-item text-sm"> {{ dateFormat(data.created_at) }}</span> <span class="bar-item text-sm"> {{ dateFormat(data.created_at) }}</span>
<!-- <span class="bar-item">tokens: {{ data.tokens }}</span>-->
<span class="bar-item bg"> <span class="bar-item bg">
<el-tooltip class="box-item" effect="dark" content="复制回答" placement="bottom"> <el-tooltip class="box-item" effect="dark" content="复制回答" placement="bottom">
<el-icon class="copy-reply" :data-clipboard-text="data.content"> <el-icon class="copy-reply" :data-clipboard-text="data.content.text">
<DocumentCopy /> <DocumentCopy />
</el-icon> </el-icon>
</el-tooltip> </el-tooltip>
@@ -106,7 +93,7 @@
placement="bottom" placement="bottom"
v-if="!isPlaying" v-if="!isPlaying"
> >
<i class="iconfont icon-speaker" @click="synthesis(data.content)"></i> <i class="iconfont icon-speaker" @click="synthesis(data.content.text)"></i>
</el-tooltip> </el-tooltip>
<el-tooltip <el-tooltip
class="box-item" class="box-item"
@@ -145,7 +132,10 @@ const props = defineProps({
type: Object, type: Object,
default: { default: {
icon: '', icon: '',
content: '', content: {
text: '',
files: [],
},
created_at: '', created_at: '',
tokens: 0, tokens: 0,
}, },

View File

@@ -744,71 +744,15 @@ const initData = async () => {
} }
} }
// 发送消息 // 发送 SSE 请求
const sendMessage = async function () { const sendSSERequest = async (message) => {
if (!isLogin.value) {
console.log('未登录')
store.setShowLoginDialog(true)
return
}
if (canSend.value === false) {
ElMessage.warning('AI 正在作答中,请稍后...')
return
}
if (prompt.value.trim().length === 0 || canSend.value === false) {
showMessageError('请输入要发送的消息!')
return false
}
// 追加消息
chatData.value.push({
type: 'prompt',
id: randString(32),
icon: loginUser.value.avatar,
content: prompt.value,
model: getModelValue(modelID.value),
created_at: new Date().getTime() / 1000,
files: files.value,
})
// 添加空回复消息
const _role = getRoleById(roleId.value)
chatData.value.push({
chat_id: chatId,
role_id: roleId.value,
type: 'reply',
id: randString(32),
icon: _role['icon'],
content: '',
})
nextTick(() => {
document
.getElementById('chat-box')
.scrollTo(0, document.getElementById('chat-box').scrollHeight)
})
showHello.value = false
disableInput(false)
try { try {
await fetchEventSource('/api/chat/message', { await fetchEventSource('/api/chat/message', {
method: 'POST', method: 'POST',
headers: { headers: {
Authorization: getUserToken(), Authorization: getUserToken(),
}, },
body: JSON.stringify({ body: JSON.stringify(message),
user_id: loginUser.value.id,
role_id: roleId.value,
model_id: modelID.value,
chat_id: chatId.value,
content: prompt.value,
tools: toolSelected.value,
stream: stream.value,
files: files.value,
}),
openWhenHidden: true, openWhenHidden: true,
onopen(response) { onopen(response) {
if (response.ok && response.status === 200) { if (response.ok && response.status === 200) {
@@ -849,6 +793,7 @@ const sendMessage = async function () {
}) })
.catch(() => {}) .catch(() => {})
isNewMsg.value = true isNewMsg.value = true
tmpChatTitle.value = message.prompt
return return
} }
@@ -858,13 +803,13 @@ const sendMessage = async function () {
lineBuffer.value = data.body lineBuffer.value = data.body
const reply = chatData.value[chatData.value.length - 1] const reply = chatData.value[chatData.value.length - 1]
if (reply) { if (reply) {
reply['content'] = lineBuffer.value reply['content'].text = lineBuffer.value
} }
} else { } else {
lineBuffer.value += data.body lineBuffer.value += data.body
const reply = chatData.value[chatData.value.length - 1] const reply = chatData.value[chatData.value.length - 1]
if (reply) { if (reply) {
reply['content'] = lineBuffer.value reply['content'].text = lineBuffer.value
} }
} }
} }
@@ -897,12 +842,77 @@ const sendMessage = async function () {
enableInput() enableInput()
ElMessage.error('发送消息失败,请重试') ElMessage.error('发送消息失败,请重试')
} }
}
// 发送消息
const sendMessage = () => {
if (!isLogin.value) {
console.log('未登录')
store.setShowLoginDialog(true)
return
}
if (canSend.value === false) {
ElMessage.warning('AI 正在作答中,请稍后...')
return
}
if (prompt.value.trim().length === 0 || canSend.value === false) {
showMessageError('请输入要发送的消息!')
return false
}
// 追加消息
chatData.value.push({
type: 'prompt',
id: randString(32),
icon: loginUser.value.avatar,
content: {
text: prompt.value,
files: files.value,
},
model: getModelValue(modelID.value),
created_at: new Date().getTime() / 1000,
})
// 添加空回复消息
const _role = getRoleById(roleId.value)
chatData.value.push({
chat_id: chatId,
role_id: roleId.value,
type: 'reply',
id: randString(32),
icon: _role['icon'],
content: {
text: '',
files: [],
},
})
nextTick(() => {
document
.getElementById('chat-box')
.scrollTo(0, document.getElementById('chat-box').scrollHeight)
})
showHello.value = false
disableInput(false)
// 异步发送 SSE 请求
sendSSERequest({
user_id: loginUser.value.id,
role_id: roleId.value,
model_id: modelID.value,
chat_id: chatId.value,
prompt: prompt.value,
tools: toolSelected.value,
stream: stream.value,
files: files.value,
})
tmpChatTitle.value = prompt.value
prompt.value = '' prompt.value = ''
files.value = [] files.value = []
row.value = 1 row.value = 1
return true
} }
const getRoleById = function (rid) { const getRoleById = function (rid) {
@@ -1139,7 +1149,10 @@ const loadChatHistory = function (chatId) {
type: 'reply', type: 'reply',
id: randString(32), id: randString(32),
icon: _role['icon'], icon: _role['icon'],
content: _role['hello_msg'], content: {
text: _role['hello_msg'],
files: [],
},
}) })
return return
} }