固化聊天
This commit is contained in:
@@ -54,8 +54,11 @@ var allowedImageTypes = map[string]bool{
|
|||||||
|
|
||||||
// chatRequestFromFrontend is the expected POST body
|
// chatRequestFromFrontend is the expected POST body
|
||||||
type chatRequest struct {
|
type chatRequest struct {
|
||||||
Messages []chatMessage `json:"messages"`
|
Messages []chatMessage `json:"messages"`
|
||||||
OpenAIName string `json:"openaiName,omitempty"`
|
OpenAIName string `json:"openaiName,omitempty"`
|
||||||
|
ConversationID uint `json:"conversationId,omitempty"`
|
||||||
|
ClientLocalID string `json:"clientLocalId,omitempty"`
|
||||||
|
SaveToServer bool `json:"saveToServer,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type chatMessage struct {
|
type chatMessage struct {
|
||||||
@@ -156,6 +159,12 @@ func ApiAIChat(r *gin.RouterGroup) {
|
|||||||
r.GET("/openai", handleOpenAIProfiles)
|
r.GET("/openai", handleOpenAIProfiles)
|
||||||
r.POST("/chat", handleChat)
|
r.POST("/chat", handleChat)
|
||||||
|
|
||||||
|
conversations := r.Group("/conversations")
|
||||||
|
conversations.POST("/list", handleAIChatConversationList)
|
||||||
|
conversations.POST("/get", handleAIChatConversationGet)
|
||||||
|
conversations.POST("/update", handleAIChatConversationUpdate)
|
||||||
|
conversations.POST("/delete", handleAIChatConversationDelete)
|
||||||
|
|
||||||
admin := r.Group("/admin")
|
admin := r.Group("/admin")
|
||||||
admin.POST("/config", handleAIChatAdminGetConfig)
|
admin.POST("/config", handleAIChatAdminGetConfig)
|
||||||
admin.POST("/config/update", handleAIChatAdminUpdateConfig)
|
admin.POST("/config/update", handleAIChatAdminUpdateConfig)
|
||||||
@@ -193,7 +202,7 @@ func handleOpenAIProfiles(ctx *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handleChat(ctx *gin.Context) {
|
func handleChat(ctx *gin.Context) {
|
||||||
data, _ := SeparateData(ctx)
|
data, cookieValue := SeparateData(ctx)
|
||||||
|
|
||||||
if data == nil {
|
if data == nil {
|
||||||
sendSSEError(ctx, "请求数据为空")
|
sendSSEError(ctx, "请求数据为空")
|
||||||
@@ -211,6 +220,13 @@ func handleChat(ctx *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var currentUser *TabUser
|
||||||
|
if cookieValue != "" {
|
||||||
|
if user, err := AuthenticationAuthorityFromCookie(cookieValue); err == nil {
|
||||||
|
currentUser = user
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check ai config
|
// Check ai config
|
||||||
cfg := getAIChatConfig()
|
cfg := getAIChatConfig()
|
||||||
profile, ok := selectOpenAIProfile(cfg, req.OpenAIName)
|
profile, ok := selectOpenAIProfile(cfg, req.OpenAIName)
|
||||||
@@ -230,13 +246,28 @@ func handleChat(ctx *gin.Context) {
|
|||||||
flusher, _ := ctx.Writer.(http.Flusher)
|
flusher, _ := ctx.Writer.(http.Flusher)
|
||||||
tracker := newTokenUsageTracker()
|
tracker := newTokenUsageTracker()
|
||||||
|
|
||||||
|
traceEvents := []sseEvent{}
|
||||||
emitTrace := func(tool, stage, status, message string, data map[string]interface{}) {
|
emitTrace := func(tool, stage, status, message string, data map[string]interface{}) {
|
||||||
sendSSE(ctx, flusher, sseEvent{Type: "trace", Tool: tool, Stage: stage, Status: status, Message: message, Data: data})
|
event := sseEvent{Type: "trace", Tool: tool, Stage: stage, Status: status, Message: message, Data: data}
|
||||||
|
traceEvents = append(traceEvents, event)
|
||||||
|
sendSSE(ctx, flusher, event)
|
||||||
}
|
}
|
||||||
emitStats := func(stats tokenUsageStats) {
|
emitStats := func(stats tokenUsageStats) {
|
||||||
sendSSE(ctx, flusher, sseEvent{Type: "stats", Stats: &stats})
|
sendSSE(ctx, flusher, sseEvent{Type: "stats", Stats: &stats})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
conversation, persistErr := prepareAIChatPersistence(currentUser, req, profile.Name)
|
||||||
|
if persistErr != nil {
|
||||||
|
emitTrace("chat", "persist", "error", "聊天保存失败,将继续仅本次对话", map[string]interface{}{"error": persistErr.Error()})
|
||||||
|
conversation = nil
|
||||||
|
} else if conversation != nil {
|
||||||
|
sendSSE(ctx, flusher, sseEvent{Type: "conversation", Data: map[string]interface{}{
|
||||||
|
"id": conversation.ID,
|
||||||
|
"title": conversation.Title,
|
||||||
|
"clientLocalId": conversation.ClientLocalID,
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
|
||||||
toolConfigs := []agents.ToolConfig{}
|
toolConfigs := []agents.ToolConfig{}
|
||||||
if cfg.ToolRouter.Enabled {
|
if cfg.ToolRouter.Enabled {
|
||||||
toolConfigs = buildToolConfigs(cfg.ToolRouter.Tools)
|
toolConfigs = buildToolConfigs(cfg.ToolRouter.Tools)
|
||||||
@@ -289,6 +320,9 @@ func handleChat(ctx *gin.Context) {
|
|||||||
modelPromptTokens := estimateOpenAIMessagesTokens(apiReq.Messages)
|
modelPromptTokens := estimateOpenAIMessagesTokens(apiReq.Messages)
|
||||||
completionTokens := 0
|
completionTokens := 0
|
||||||
modelUsageReceived := false
|
modelUsageReceived := false
|
||||||
|
assistantContent := strings.Builder{}
|
||||||
|
reasoningContent := strings.Builder{}
|
||||||
|
var finalStats *tokenUsageStats
|
||||||
streamStarted := time.Now()
|
streamStarted := time.Now()
|
||||||
windowStarted := streamStarted
|
windowStarted := streamStarted
|
||||||
windowTokens := 0
|
windowTokens := 0
|
||||||
@@ -305,10 +339,12 @@ func handleChat(ctx *gin.Context) {
|
|||||||
reasoningText = choice.Delta.Thinking
|
reasoningText = choice.Delta.Thinking
|
||||||
}
|
}
|
||||||
if reasoningText != "" {
|
if reasoningText != "" {
|
||||||
|
reasoningContent.WriteString(reasoningText)
|
||||||
sendSSE(ctx, flusher, sseEvent{Type: "reasoning", Text: reasoningText})
|
sendSSE(ctx, flusher, sseEvent{Type: "reasoning", Text: reasoningText})
|
||||||
}
|
}
|
||||||
|
|
||||||
if choice.Delta.Content != "" {
|
if choice.Delta.Content != "" {
|
||||||
|
assistantContent.WriteString(choice.Delta.Content)
|
||||||
deltaTokens := estimateTokenCount(choice.Delta.Content)
|
deltaTokens := estimateTokenCount(choice.Delta.Content)
|
||||||
completionTokens += deltaTokens
|
completionTokens += deltaTokens
|
||||||
windowTokens += deltaTokens
|
windowTokens += deltaTokens
|
||||||
@@ -321,17 +357,23 @@ func handleChat(ctx *gin.Context) {
|
|||||||
peakTokensPerSecond = maxFloat(peakTokensPerSecond, float64(windowTokens)/elapsedWindow)
|
peakTokensPerSecond = maxFloat(peakTokensPerSecond, float64(windowTokens)/elapsedWindow)
|
||||||
}
|
}
|
||||||
stats := tracker.setModelEstimate(modelPromptTokens, completionTokens).snapshot(tokensPerSecond(completionTokens, streamStarted), peakTokensPerSecond)
|
stats := tracker.setModelEstimate(modelPromptTokens, completionTokens).snapshot(tokensPerSecond(completionTokens, streamStarted), peakTokensPerSecond)
|
||||||
|
finalStats = &stats
|
||||||
sendSSE(ctx, flusher, sseEvent{Type: "delta", Text: choice.Delta.Content, Stats: &stats})
|
sendSSE(ctx, flusher, sseEvent{Type: "delta", Text: choice.Delta.Content, Stats: &stats})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if chunk.Usage != nil {
|
if chunk.Usage != nil {
|
||||||
modelUsageReceived = true
|
modelUsageReceived = true
|
||||||
stats := tracker.setModelUsage(chunk.Usage).snapshot(tokensPerSecond(tracker.completionTokens, streamStarted), peakTokensPerSecond)
|
stats := tracker.setModelUsage(chunk.Usage).snapshot(tokensPerSecond(tracker.completionTokens, streamStarted), peakTokensPerSecond)
|
||||||
|
finalStats = &stats
|
||||||
emitStats(stats)
|
emitStats(stats)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
sendSSE(ctx, flusher, sseEvent{Type: "error", Error: "请求失败: " + err.Error()})
|
errorText := "请求失败: " + err.Error()
|
||||||
|
if conversation != nil && assistantContent.Len() > 0 {
|
||||||
|
_ = persistAIChatAssistantMessage(conversation, assistantContent.String(), reasoningContent.String(), traceEvents, finalStats)
|
||||||
|
}
|
||||||
|
sendSSE(ctx, flusher, sseEvent{Type: "error", Error: errorText})
|
||||||
sendSSEDone(ctx, flusher)
|
sendSSEDone(ctx, flusher)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -344,9 +386,16 @@ func handleChat(ctx *gin.Context) {
|
|||||||
}
|
}
|
||||||
emitTrace("model", "stream", "success", "模型回复完成", nil)
|
emitTrace("model", "stream", "success", "模型回复完成", nil)
|
||||||
if modelUsageReceived {
|
if modelUsageReceived {
|
||||||
emitStats(tracker.snapshot(tokensPerSecond(tracker.completionTokens, streamStarted), peakTokensPerSecond))
|
stats := tracker.snapshot(tokensPerSecond(tracker.completionTokens, streamStarted), peakTokensPerSecond)
|
||||||
|
finalStats = &stats
|
||||||
|
emitStats(stats)
|
||||||
} else {
|
} else {
|
||||||
emitStats(tracker.setModelEstimate(modelPromptTokens, completionTokens).snapshot(tokensPerSecond(completionTokens, streamStarted), peakTokensPerSecond))
|
stats := tracker.setModelEstimate(modelPromptTokens, completionTokens).snapshot(tokensPerSecond(completionTokens, streamStarted), peakTokensPerSecond)
|
||||||
|
finalStats = &stats
|
||||||
|
emitStats(stats)
|
||||||
|
}
|
||||||
|
if err := persistAIChatAssistantMessage(conversation, assistantContent.String(), reasoningContent.String(), traceEvents, finalStats); err != nil {
|
||||||
|
sendSSE(ctx, flusher, sseEvent{Type: "trace", Tool: "chat", Stage: "persist", Status: "error", Message: "助手回复保存失败", Data: map[string]interface{}{"error": err.Error()}})
|
||||||
}
|
}
|
||||||
sendSSEDone(ctx, flusher)
|
sendSSEDone(ctx, flusher)
|
||||||
flusher.Flush()
|
flusher.Flush()
|
||||||
|
|||||||
@@ -52,6 +52,33 @@ type TabAIChatTool struct {
|
|||||||
UpdatedAt *time.Time `gorm:"type:datetime;autoUpdateTime"`
|
UpdatedAt *time.Time `gorm:"type:datetime;autoUpdateTime"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TabAIChatConversation struct {
|
||||||
|
ID uint `gorm:"primaryKey;autoIncrement"`
|
||||||
|
UserID uint `gorm:"not null;index:idx_ai_chat_conversation_user_updated,priority:1"`
|
||||||
|
Title string `gorm:"size:200"`
|
||||||
|
OpenAIName string `gorm:"size:100;index"`
|
||||||
|
ClientLocalID string `gorm:"size:100;index"`
|
||||||
|
MessageCount int `gorm:"default:0"`
|
||||||
|
LastMessageAt *time.Time `gorm:"type:datetime;index"`
|
||||||
|
CreatedAt *time.Time `gorm:"type:datetime;autoCreateTime"`
|
||||||
|
UpdatedAt *time.Time `gorm:"type:datetime;autoUpdateTime;index:idx_ai_chat_conversation_user_updated,priority:2"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TabAIChatMessage struct {
|
||||||
|
ID uint `gorm:"primaryKey;autoIncrement"`
|
||||||
|
ConversationID uint `gorm:"not null;index:idx_ai_chat_message_conversation_seq,priority:1"`
|
||||||
|
UserID uint `gorm:"not null;index"`
|
||||||
|
Role string `gorm:"size:20;index"`
|
||||||
|
Content string `gorm:"type:text"`
|
||||||
|
ImageURL string `gorm:"type:text"`
|
||||||
|
Reasoning string `gorm:"type:text"`
|
||||||
|
TracesJSON string `gorm:"type:text"`
|
||||||
|
StatsJSON string `gorm:"type:text"`
|
||||||
|
Seq int `gorm:"index:idx_ai_chat_message_conversation_seq,priority:2"`
|
||||||
|
CreatedAt *time.Time `gorm:"type:datetime;autoCreateTime"`
|
||||||
|
}
|
||||||
|
|
||||||
var aiChatConfigMu sync.RWMutex
|
var aiChatConfigMu sync.RWMutex
|
||||||
|
|
||||||
func ApiAIChatInit() {
|
func ApiAIChatInit() {
|
||||||
@@ -60,6 +87,8 @@ func ApiAIChatInit() {
|
|||||||
&TabAIChatOpenAIProfile{},
|
&TabAIChatOpenAIProfile{},
|
||||||
&TabAIChatToolRouter{},
|
&TabAIChatToolRouter{},
|
||||||
&TabAIChatTool{},
|
&TabAIChatTool{},
|
||||||
|
&TabAIChatConversation{},
|
||||||
|
&TabAIChatMessage{},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
|
|||||||
@@ -0,0 +1,346 @@
|
|||||||
|
package routers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"ops/models"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type aiChatConversationListReq struct {
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"page_size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type aiChatConversationIDReq struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type aiChatConversationUpdateReq struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleAIChatConversationList(ctx *gin.Context) {
|
||||||
|
isAuth, user, data := AuthenticationAuthority(ctx)
|
||||||
|
if !isAuth {
|
||||||
|
ReturnJson(ctx, "userCookieError", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req aiChatConversationListReq
|
||||||
|
if data != nil {
|
||||||
|
_ = decodeJSON(data, &req)
|
||||||
|
}
|
||||||
|
if req.Page <= 0 {
|
||||||
|
req.Page = 1
|
||||||
|
}
|
||||||
|
if req.PageSize <= 0 || req.PageSize > 100 {
|
||||||
|
req.PageSize = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
query := models.DB.Model(&TabAIChatConversation{}).Where("user_id = ?", user.ID)
|
||||||
|
var total int64
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
ReturnJson(ctx, "apiErr", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var conversations []TabAIChatConversation
|
||||||
|
if err := query.Order("last_message_at desc, updated_at desc, id desc").
|
||||||
|
Offset((req.Page - 1) * req.PageSize).
|
||||||
|
Limit(req.PageSize).
|
||||||
|
Find(&conversations).Error; err != nil {
|
||||||
|
ReturnJson(ctx, "apiErr", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]map[string]interface{}, 0, len(conversations))
|
||||||
|
for _, conversation := range conversations {
|
||||||
|
items = append(items, aiChatConversationResponse(conversation))
|
||||||
|
}
|
||||||
|
ReturnJson(ctx, "apiOK", gin.H{
|
||||||
|
"conversations": items,
|
||||||
|
"total": total,
|
||||||
|
"page": req.Page,
|
||||||
|
"page_size": req.PageSize,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleAIChatConversationGet(ctx *gin.Context) {
|
||||||
|
isAuth, user, data := AuthenticationAuthority(ctx)
|
||||||
|
if !isAuth {
|
||||||
|
ReturnJson(ctx, "userCookieError", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req aiChatConversationIDReq
|
||||||
|
if err := decodeJSON(data, &req); err != nil || req.ID == 0 {
|
||||||
|
ReturnJson(ctx, "jsonErr", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var conversation TabAIChatConversation
|
||||||
|
if err := models.DB.Where("id = ? AND user_id = ?", req.ID, user.ID).First(&conversation).Error; err != nil {
|
||||||
|
ReturnJson(ctx, "apiErr", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var messages []TabAIChatMessage
|
||||||
|
if err := models.DB.Where("conversation_id = ? AND user_id = ?", conversation.ID, user.ID).
|
||||||
|
Order("seq asc, id asc").
|
||||||
|
Find(&messages).Error; err != nil {
|
||||||
|
ReturnJson(ctx, "apiErr", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]map[string]interface{}, 0, len(messages))
|
||||||
|
for _, message := range messages {
|
||||||
|
items = append(items, aiChatMessageResponse(message))
|
||||||
|
}
|
||||||
|
ReturnJson(ctx, "apiOK", gin.H{
|
||||||
|
"conversation": aiChatConversationResponse(conversation),
|
||||||
|
"messages": items,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleAIChatConversationUpdate(ctx *gin.Context) {
|
||||||
|
isAuth, user, data := AuthenticationAuthority(ctx)
|
||||||
|
if !isAuth {
|
||||||
|
ReturnJson(ctx, "userCookieError", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req aiChatConversationUpdateReq
|
||||||
|
if err := decodeJSON(data, &req); err != nil || req.ID == 0 {
|
||||||
|
ReturnJson(ctx, "jsonErr", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
title := strings.TrimSpace(req.Title)
|
||||||
|
if title == "" {
|
||||||
|
title = "新对话"
|
||||||
|
}
|
||||||
|
if len([]rune(title)) > 200 {
|
||||||
|
title = string([]rune(title)[:200])
|
||||||
|
}
|
||||||
|
|
||||||
|
result := models.DB.Model(&TabAIChatConversation{}).
|
||||||
|
Where("id = ? AND user_id = ?", req.ID, user.ID).
|
||||||
|
Updates(map[string]interface{}{"title": title})
|
||||||
|
if result.Error != nil || result.RowsAffected == 0 {
|
||||||
|
ReturnJson(ctx, "apiErr", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ReturnJson(ctx, "apiOK", gin.H{"id": req.ID, "title": title})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleAIChatConversationDelete(ctx *gin.Context) {
|
||||||
|
isAuth, user, data := AuthenticationAuthority(ctx)
|
||||||
|
if !isAuth {
|
||||||
|
ReturnJson(ctx, "userCookieError", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req aiChatConversationIDReq
|
||||||
|
if err := decodeJSON(data, &req); err != nil || req.ID == 0 {
|
||||||
|
ReturnJson(ctx, "jsonErr", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := models.DB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
result := tx.Where("id = ? AND user_id = ?", req.ID, user.ID).Delete(&TabAIChatConversation{})
|
||||||
|
if result.Error != nil {
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
|
return tx.Where("conversation_id = ? AND user_id = ?", req.ID, user.ID).Delete(&TabAIChatMessage{}).Error
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
ReturnJson(ctx, "apiErr", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ReturnJson(ctx, "apiOK", gin.H{"id": req.ID})
|
||||||
|
}
|
||||||
|
|
||||||
|
func aiChatConversationResponse(conversation TabAIChatConversation) map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"id": conversation.ID,
|
||||||
|
"title": conversation.Title,
|
||||||
|
"openaiName": conversation.OpenAIName,
|
||||||
|
"clientLocalId": conversation.ClientLocalID,
|
||||||
|
"messageCount": conversation.MessageCount,
|
||||||
|
"lastMessageAt": conversation.LastMessageAt,
|
||||||
|
"createdAt": conversation.CreatedAt,
|
||||||
|
"updatedAt": conversation.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func aiChatMessageResponse(message TabAIChatMessage) map[string]interface{} {
|
||||||
|
item := map[string]interface{}{
|
||||||
|
"id": message.ID,
|
||||||
|
"conversationId": message.ConversationID,
|
||||||
|
"role": message.Role,
|
||||||
|
"content": message.Content,
|
||||||
|
"image_url": message.ImageURL,
|
||||||
|
"reasoning": message.Reasoning,
|
||||||
|
"seq": message.Seq,
|
||||||
|
"createdAt": message.CreatedAt,
|
||||||
|
}
|
||||||
|
var traces []sseEvent
|
||||||
|
if message.TracesJSON != "" && json.Unmarshal([]byte(message.TracesJSON), &traces) == nil {
|
||||||
|
item["traces"] = traces
|
||||||
|
}
|
||||||
|
var stats tokenUsageStats
|
||||||
|
if message.StatsJSON != "" && json.Unmarshal([]byte(message.StatsJSON), &stats) == nil {
|
||||||
|
item["stats"] = stats
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareAIChatPersistence(user *TabUser, req chatRequest, openAIName string) (*TabAIChatConversation, error) {
|
||||||
|
if user == nil || !req.SaveToServer {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lastUserMessage, ok := lastAIChatUserMessage(req.Messages)
|
||||||
|
if !ok {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var conversation TabAIChatConversation
|
||||||
|
if req.ConversationID > 0 {
|
||||||
|
if err := models.DB.Where("id = ? AND user_id = ?", req.ConversationID, user.ID).First(&conversation).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else if strings.TrimSpace(req.ClientLocalID) != "" {
|
||||||
|
err := models.DB.Where("user_id = ? AND client_local_id = ?", user.ID, strings.TrimSpace(req.ClientLocalID)).First(&conversation).Error
|
||||||
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
conversation = TabAIChatConversation{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
if conversation.ID == 0 {
|
||||||
|
conversation = TabAIChatConversation{
|
||||||
|
UserID: user.ID,
|
||||||
|
Title: makeAIChatTitle(lastUserMessage),
|
||||||
|
OpenAIName: openAIName,
|
||||||
|
ClientLocalID: strings.TrimSpace(req.ClientLocalID),
|
||||||
|
LastMessageAt: &now,
|
||||||
|
}
|
||||||
|
if err := models.DB.Create(&conversation).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else if conversation.OpenAIName == "" && openAIName != "" {
|
||||||
|
_ = models.DB.Model(&conversation).Update("open_ai_name", openAIName).Error
|
||||||
|
conversation.OpenAIName = openAIName
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := createAIChatMessage(conversation.ID, user.ID, lastUserMessage, "", nil, nil); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := refreshAIChatConversationSummary(conversation.ID, user.ID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := models.DB.Where("id = ? AND user_id = ?", conversation.ID, user.ID).First(&conversation).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &conversation, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func persistAIChatAssistantMessage(conversation *TabAIChatConversation, content string, reasoning string, traces []sseEvent, stats *tokenUsageStats) error {
|
||||||
|
if conversation == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
message := chatMessage{Role: "assistant", Content: content}
|
||||||
|
if err := createAIChatMessage(conversation.ID, conversation.UserID, message, reasoning, traces, stats); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return refreshAIChatConversationSummary(conversation.ID, conversation.UserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createAIChatMessage(conversationID uint, userID uint, message chatMessage, reasoning string, traces []sseEvent, stats *tokenUsageStats) error {
|
||||||
|
var maxSeq int
|
||||||
|
models.DB.Model(&TabAIChatMessage{}).
|
||||||
|
Where("conversation_id = ? AND user_id = ?", conversationID, userID).
|
||||||
|
Select("COALESCE(MAX(seq), 0)").
|
||||||
|
Scan(&maxSeq)
|
||||||
|
|
||||||
|
imageURL := message.ImageURL
|
||||||
|
if imageURL == "" {
|
||||||
|
imageURL = message.ImageURLAlias
|
||||||
|
}
|
||||||
|
tracesJSON := ""
|
||||||
|
if len(traces) > 0 {
|
||||||
|
if data, err := json.Marshal(traces); err == nil {
|
||||||
|
tracesJSON = string(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
statsJSON := ""
|
||||||
|
if stats != nil {
|
||||||
|
if data, err := json.Marshal(stats); err == nil {
|
||||||
|
statsJSON = string(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
row := TabAIChatMessage{
|
||||||
|
ConversationID: conversationID,
|
||||||
|
UserID: userID,
|
||||||
|
Role: message.Role,
|
||||||
|
Content: message.Content,
|
||||||
|
ImageURL: imageURL,
|
||||||
|
Reasoning: reasoning,
|
||||||
|
TracesJSON: tracesJSON,
|
||||||
|
StatsJSON: statsJSON,
|
||||||
|
Seq: maxSeq + 1,
|
||||||
|
}
|
||||||
|
return models.DB.Create(&row).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshAIChatConversationSummary(conversationID uint, userID uint) error {
|
||||||
|
var count int64
|
||||||
|
if err := models.DB.Model(&TabAIChatMessage{}).Where("conversation_id = ? AND user_id = ?", conversationID, userID).Count(&count).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var lastMessage TabAIChatMessage
|
||||||
|
lastMessageAt := time.Now()
|
||||||
|
if err := models.DB.Where("conversation_id = ? AND user_id = ?", conversationID, userID).Order("seq desc, id desc").First(&lastMessage).Error; err == nil && lastMessage.CreatedAt != nil {
|
||||||
|
lastMessageAt = *lastMessage.CreatedAt
|
||||||
|
}
|
||||||
|
return models.DB.Model(&TabAIChatConversation{}).
|
||||||
|
Where("id = ? AND user_id = ?", conversationID, userID).
|
||||||
|
Updates(map[string]interface{}{
|
||||||
|
"message_count": int(count),
|
||||||
|
"last_message_at": lastMessageAt,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func lastAIChatUserMessage(messages []chatMessage) (chatMessage, bool) {
|
||||||
|
for i := len(messages) - 1; i >= 0; i-- {
|
||||||
|
if messages[i].Role == "user" {
|
||||||
|
return messages[i], true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chatMessage{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeAIChatTitle(message chatMessage) string {
|
||||||
|
title := strings.TrimSpace(message.Content)
|
||||||
|
if title == "" {
|
||||||
|
title = "新对话"
|
||||||
|
}
|
||||||
|
runes := []rune(title)
|
||||||
|
if len(runes) > 40 {
|
||||||
|
title = string(runes[:40])
|
||||||
|
}
|
||||||
|
return title
|
||||||
|
}
|
||||||
@@ -17,6 +17,22 @@ export async function refreshAIChatAdminConfig() {
|
|||||||
return api.post('/aichat/admin/refresh', {})
|
return api.post('/aichat/admin/refresh', {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchAIChatConversations(params = {}) {
|
||||||
|
return api.post('/aichat/conversations/list', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAIChatConversation(id) {
|
||||||
|
return api.post('/aichat/conversations/get', { id })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAIChatConversation(id, title) {
|
||||||
|
return api.post('/aichat/conversations/update', { id, title })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAIChatConversation(id) {
|
||||||
|
return api.post('/aichat/conversations/delete', { id })
|
||||||
|
}
|
||||||
|
|
||||||
function parseSSEBlock(block) {
|
function parseSSEBlock(block) {
|
||||||
const lines = block.split('\n')
|
const lines = block.split('\n')
|
||||||
const dataLines = []
|
const dataLines = []
|
||||||
@@ -43,7 +59,13 @@ export async function streamChat(messages, options = {}, handlers = {}) {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
userCookieValue: userStore.cookieValue || '',
|
userCookieValue: userStore.cookieValue || '',
|
||||||
data: { messages, openaiName: options.openaiName || '' },
|
data: {
|
||||||
|
messages,
|
||||||
|
openaiName: options.openaiName || '',
|
||||||
|
conversationId: options.conversationId || 0,
|
||||||
|
clientLocalId: options.clientLocalId || '',
|
||||||
|
saveToServer: options.saveToServer === true,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -92,6 +114,9 @@ export async function streamChat(messages, options = {}, handlers = {}) {
|
|||||||
case 'stats':
|
case 'stats':
|
||||||
handlers.onStats?.(frame.stats || null)
|
handlers.onStats?.(frame.stats || null)
|
||||||
break
|
break
|
||||||
|
case 'conversation':
|
||||||
|
handlers.onConversation?.(frame.data || {})
|
||||||
|
break
|
||||||
case 'error':
|
case 'error':
|
||||||
handlers.onError?.(frame.error || frame.message || 'AI request failed')
|
handlers.onError?.(frame.error || frame.message || 'AI request failed')
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -61,6 +61,8 @@
|
|||||||
"image_size_error": "Image is too large. Please choose an image smaller than 4 MB",
|
"image_size_error": "Image is too large. Please choose an image smaller than 4 MB",
|
||||||
"image_read_error": "Failed to read image. Please try another file",
|
"image_read_error": "Failed to read image. Please try another file",
|
||||||
"reasoning": "Reasoning",
|
"reasoning": "Reasoning",
|
||||||
|
"expand": "Expand",
|
||||||
|
"collapse": "Collapse",
|
||||||
"trace_details": "Call details",
|
"trace_details": "Call details",
|
||||||
"trace_database": "Database",
|
"trace_database": "Database",
|
||||||
"trace_rows": "Rows",
|
"trace_rows": "Rows",
|
||||||
@@ -76,7 +78,22 @@
|
|||||||
"tokens_prompt": "Input",
|
"tokens_prompt": "Input",
|
||||||
"tokens_completion": "Output",
|
"tokens_completion": "Output",
|
||||||
"tokens_tool": "Tools",
|
"tokens_tool": "Tools",
|
||||||
"tokens_estimated": "local estimate"
|
"tokens_estimated": "local estimate",
|
||||||
|
"new_chat": "New chat",
|
||||||
|
"server_chats": "Server chats",
|
||||||
|
"browser_chats": "Browser chats",
|
||||||
|
"local_chat": "Local chat",
|
||||||
|
"server_chat": "Server chat",
|
||||||
|
"no_server_chats": "No server chats",
|
||||||
|
"no_browser_chats": "No browser chats",
|
||||||
|
"delete_chat": "Delete chat",
|
||||||
|
"rename_chat": "Rename chat",
|
||||||
|
"load_conversations_failed": "Failed to load chat list",
|
||||||
|
"load_chat_failed": "Failed to load chat",
|
||||||
|
"delete_chat_failed": "Failed to delete chat",
|
||||||
|
"storage_full": "Browser storage is full; the chat may not be saved",
|
||||||
|
"login_to_sync": "Log in to save chats on the server",
|
||||||
|
"untitled_chat": "New chat"
|
||||||
},
|
},
|
||||||
"aiconfig": {
|
"aiconfig": {
|
||||||
"title": "AI Config",
|
"title": "AI Config",
|
||||||
|
|||||||
@@ -61,6 +61,8 @@
|
|||||||
"image_size_error": "图片过大,请选择小于 4MB 的图片",
|
"image_size_error": "图片过大,请选择小于 4MB 的图片",
|
||||||
"image_read_error": "图片读取失败,请尝试其他文件",
|
"image_read_error": "图片读取失败,请尝试其他文件",
|
||||||
"reasoning": "思考内容",
|
"reasoning": "思考内容",
|
||||||
|
"expand": "展开",
|
||||||
|
"collapse": "折叠",
|
||||||
"trace_details": "调用详情",
|
"trace_details": "调用详情",
|
||||||
"trace_database": "数据库",
|
"trace_database": "数据库",
|
||||||
"trace_rows": "行数",
|
"trace_rows": "行数",
|
||||||
@@ -76,7 +78,22 @@
|
|||||||
"tokens_prompt": "输入",
|
"tokens_prompt": "输入",
|
||||||
"tokens_completion": "输出",
|
"tokens_completion": "输出",
|
||||||
"tokens_tool": "工具",
|
"tokens_tool": "工具",
|
||||||
"tokens_estimated": "本地估算"
|
"tokens_estimated": "本地估算",
|
||||||
|
"new_chat": "新聊天",
|
||||||
|
"server_chats": "服务端聊天",
|
||||||
|
"browser_chats": "浏览器聊天",
|
||||||
|
"local_chat": "本地聊天",
|
||||||
|
"server_chat": "服务端聊天",
|
||||||
|
"no_server_chats": "暂无服务端聊天",
|
||||||
|
"no_browser_chats": "暂无浏览器聊天",
|
||||||
|
"delete_chat": "删除聊天",
|
||||||
|
"rename_chat": "重命名聊天",
|
||||||
|
"load_conversations_failed": "加载聊天列表失败",
|
||||||
|
"load_chat_failed": "加载聊天失败",
|
||||||
|
"delete_chat_failed": "删除聊天失败",
|
||||||
|
"storage_full": "浏览器存储空间不足,聊天可能无法保存",
|
||||||
|
"login_to_sync": "登录后可保存聊天到服务端",
|
||||||
|
"untitled_chat": "新对话"
|
||||||
},
|
},
|
||||||
"aiconfig": {
|
"aiconfig": {
|
||||||
"title": "AI 配置",
|
"title": "AI 配置",
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { nextTick, onMounted, ref } from 'vue'
|
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { IconLoader2, IconPhoto, IconRobot, IconSend, IconTrash, IconUser, IconX } from '@tabler/icons-vue'
|
import { IconCloud, IconDeviceFloppy, IconLoader2, IconPhoto, IconPlus, IconRobot, IconSend, IconTrash, IconUser, IconX } from '@tabler/icons-vue'
|
||||||
import { fetchOpenAIProfiles, streamChat } from '@/api/aichat'
|
import {
|
||||||
|
deleteAIChatConversation,
|
||||||
|
fetchAIChatConversation,
|
||||||
|
fetchAIChatConversations,
|
||||||
|
fetchOpenAIProfiles,
|
||||||
|
streamChat,
|
||||||
|
} from '@/api/aichat'
|
||||||
import { usePageTitle } from '@/composables/usePageTitle'
|
import { usePageTitle } from '@/composables/usePageTitle'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const toast = useToastStore()
|
const toast = useToastStore()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
usePageTitle('appname.aichat')
|
usePageTitle('appname.aichat')
|
||||||
|
|
||||||
@@ -16,7 +24,9 @@ const inputText = ref('')
|
|||||||
const selectedImage = ref(null)
|
const selectedImage = ref(null)
|
||||||
const pending = ref(false)
|
const pending = ref(false)
|
||||||
const traces = ref([])
|
const traces = ref([])
|
||||||
|
const tracesCollapsed = ref(false)
|
||||||
const reasoning = ref('')
|
const reasoning = ref('')
|
||||||
|
const reasoningCollapsed = ref(false)
|
||||||
const stats = ref(null)
|
const stats = ref(null)
|
||||||
const profiles = ref([])
|
const profiles = ref([])
|
||||||
const activeProfile = ref('')
|
const activeProfile = ref('')
|
||||||
@@ -24,10 +34,44 @@ const toolRouter = ref(null)
|
|||||||
const messageListRef = ref(null)
|
const messageListRef = ref(null)
|
||||||
const fileInputRef = ref(null)
|
const fileInputRef = ref(null)
|
||||||
|
|
||||||
|
const localConversations = ref([])
|
||||||
|
const serverConversations = ref([])
|
||||||
|
const activeSource = ref('local')
|
||||||
|
const activeLocalId = ref('')
|
||||||
|
const activeServerId = ref(0)
|
||||||
|
const loadingConversations = ref(false)
|
||||||
|
|
||||||
const MAX_IMAGE_SIZE = 4 * 1024 * 1024
|
const MAX_IMAGE_SIZE = 4 * 1024 * 1024
|
||||||
const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']
|
const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']
|
||||||
|
const LOCAL_STORAGE_KEY = 'ops:aichat:local:v1'
|
||||||
|
|
||||||
onMounted(loadProfiles)
|
const sortedLocalConversations = computed(() => sortConversations(localConversations.value))
|
||||||
|
const sortedServerConversations = computed(() => sortConversations(serverConversations.value))
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
loadLocalConversations()
|
||||||
|
if (userStore.isLoggedIn) {
|
||||||
|
activeSource.value = 'server'
|
||||||
|
activeLocalId.value = createLocalId()
|
||||||
|
} else if (localConversations.value.length === 0) {
|
||||||
|
createLocalConversation(false)
|
||||||
|
} else {
|
||||||
|
selectLocalConversation(localConversations.value[0].localId)
|
||||||
|
}
|
||||||
|
await Promise.all([loadProfiles(), loadServerConversations()])
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => userStore.isLoggedIn, async (loggedIn) => {
|
||||||
|
serverConversations.value = []
|
||||||
|
activeServerId.value = 0
|
||||||
|
if (loggedIn) {
|
||||||
|
activeSource.value = 'server'
|
||||||
|
activeLocalId.value = createLocalId()
|
||||||
|
await loadServerConversations()
|
||||||
|
} else if (!activeLocalId.value && localConversations.value.length) {
|
||||||
|
selectLocalConversation(localConversations.value[0].localId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
async function loadProfiles() {
|
async function loadProfiles() {
|
||||||
try {
|
try {
|
||||||
@@ -43,6 +87,204 @@ async function loadProfiles() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadServerConversations() {
|
||||||
|
if (!userStore.isLoggedIn) return
|
||||||
|
loadingConversations.value = true
|
||||||
|
try {
|
||||||
|
const res = await fetchAIChatConversations({ page: 1, page_size: 100 })
|
||||||
|
if (res.errCode === 0 && res.data) {
|
||||||
|
serverConversations.value = res.data.conversations || []
|
||||||
|
} else {
|
||||||
|
toast.error(t('aichat.load_conversations_failed'))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t('aichat.load_conversations_failed'))
|
||||||
|
} finally {
|
||||||
|
loadingConversations.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadLocalConversations() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(LOCAL_STORAGE_KEY)
|
||||||
|
const parsed = raw ? JSON.parse(raw) : []
|
||||||
|
localConversations.value = Array.isArray(parsed) ? parsed.map(normalizeLocalConversation) : []
|
||||||
|
} catch {
|
||||||
|
localConversations.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveLocalConversations() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(localConversations.value))
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
toast.error(t('aichat.storage_full'))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLocalConversation(conversation) {
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
return {
|
||||||
|
localId: conversation.localId || createLocalId(),
|
||||||
|
serverId: Number(conversation.serverId || 0),
|
||||||
|
title: conversation.title || t('aichat.untitled_chat'),
|
||||||
|
openaiName: conversation.openaiName || '',
|
||||||
|
messages: Array.isArray(conversation.messages) ? conversation.messages : [],
|
||||||
|
createdAt: conversation.createdAt || now,
|
||||||
|
updatedAt: conversation.updatedAt || now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLocalId() {
|
||||||
|
const suffix = globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||||
|
return `local-${suffix}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortConversations(items) {
|
||||||
|
return [...items].sort((a, b) => new Date(b.updatedAt || b.lastMessageAt || 0) - new Date(a.updatedAt || a.lastMessageAt || 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLocalConversation(select = true) {
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const conversation = {
|
||||||
|
localId: createLocalId(),
|
||||||
|
serverId: 0,
|
||||||
|
title: t('aichat.untitled_chat'),
|
||||||
|
openaiName: activeProfile.value || '',
|
||||||
|
messages: [],
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
}
|
||||||
|
localConversations.value.unshift(conversation)
|
||||||
|
saveLocalConversations()
|
||||||
|
if (select) {
|
||||||
|
selectLocalConversation(conversation.localId)
|
||||||
|
}
|
||||||
|
return conversation
|
||||||
|
}
|
||||||
|
|
||||||
|
function newChat() {
|
||||||
|
if (pending.value) return
|
||||||
|
if (userStore.isLoggedIn) {
|
||||||
|
activeSource.value = 'server'
|
||||||
|
activeServerId.value = 0
|
||||||
|
activeLocalId.value = createLocalId()
|
||||||
|
messages.value = []
|
||||||
|
resetStreamDetails()
|
||||||
|
scrollToBottom()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyConversation = sortedLocalConversations.value.find((conversation) => isEmptyLocalConversation(conversation))
|
||||||
|
if (emptyConversation) {
|
||||||
|
selectLocalConversation(emptyConversation.localId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
createLocalConversation(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEmptyLocalConversation(conversation) {
|
||||||
|
return !Array.isArray(conversation.messages) || conversation.messages.length === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectLocalConversation(localId) {
|
||||||
|
if (pending.value) return
|
||||||
|
const conversation = localConversations.value.find((item) => item.localId === localId)
|
||||||
|
if (!conversation) return
|
||||||
|
activeSource.value = 'local'
|
||||||
|
activeLocalId.value = conversation.localId
|
||||||
|
activeServerId.value = Number(conversation.serverId || 0)
|
||||||
|
messages.value = cloneMessages(conversation.messages)
|
||||||
|
activeProfile.value = conversation.openaiName || activeProfile.value
|
||||||
|
resetStreamDetails()
|
||||||
|
scrollToBottom()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectServerConversation(id) {
|
||||||
|
if (pending.value) return
|
||||||
|
const serverId = Number(id)
|
||||||
|
activeSource.value = 'server'
|
||||||
|
activeServerId.value = serverId
|
||||||
|
resetStreamDetails()
|
||||||
|
try {
|
||||||
|
const res = await fetchAIChatConversation(serverId)
|
||||||
|
if (res.errCode === 0 && res.data) {
|
||||||
|
const conversation = res.data.conversation || {}
|
||||||
|
messages.value = cloneMessages(res.data.messages || [])
|
||||||
|
activeLocalId.value = findLocalByServer(conversation.id, conversation.clientLocalId)?.localId || ''
|
||||||
|
activeProfile.value = conversation.openaiName || activeProfile.value
|
||||||
|
scrollToBottom()
|
||||||
|
} else {
|
||||||
|
toast.error(t('aichat.load_chat_failed'))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t('aichat.load_chat_failed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneMessages(items) {
|
||||||
|
return items.map((item) => ({ ...item }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function findLocalByServer(serverId, clientLocalId = '') {
|
||||||
|
return localConversations.value.find((item) => Number(item.serverId || 0) === Number(serverId))
|
||||||
|
|| localConversations.value.find((item) => clientLocalId && item.localId === clientLocalId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureActiveLocalConversation() {
|
||||||
|
let conversation = localConversations.value.find((item) => item.localId === activeLocalId.value)
|
||||||
|
if (conversation) return conversation
|
||||||
|
|
||||||
|
conversation = createLocalConversation(false)
|
||||||
|
conversation.messages = cloneMessages(messages.value)
|
||||||
|
activeLocalId.value = conversation.localId
|
||||||
|
saveLocalConversations()
|
||||||
|
return conversation
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateActiveLocalConversation() {
|
||||||
|
if (userStore.isLoggedIn) return
|
||||||
|
const conversation = ensureActiveLocalConversation()
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
conversation.messages = cloneMessages(messages.value)
|
||||||
|
conversation.title = makeTitle(conversation.messages)
|
||||||
|
conversation.openaiName = activeProfile.value || conversation.openaiName || ''
|
||||||
|
conversation.updatedAt = now
|
||||||
|
saveLocalConversations()
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateServerConversationList(conversation) {
|
||||||
|
if (!conversation?.id) return
|
||||||
|
const index = serverConversations.value.findIndex((item) => Number(item.id) === Number(conversation.id))
|
||||||
|
const next = {
|
||||||
|
...(index >= 0 ? serverConversations.value[index] : {}),
|
||||||
|
...conversation,
|
||||||
|
updatedAt: conversation.updatedAt || new Date().toISOString(),
|
||||||
|
}
|
||||||
|
if (index >= 0) {
|
||||||
|
serverConversations.value.splice(index, 1, next)
|
||||||
|
} else {
|
||||||
|
serverConversations.value.unshift(next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindServerConversation(conversation) {
|
||||||
|
if (!conversation?.id) return
|
||||||
|
activeServerId.value = Number(conversation.id)
|
||||||
|
updateServerConversationList(conversation)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetStreamDetails() {
|
||||||
|
traces.value = []
|
||||||
|
tracesCollapsed.value = false
|
||||||
|
reasoning.value = ''
|
||||||
|
reasoningCollapsed.value = false
|
||||||
|
stats.value = null
|
||||||
|
clearSelectedImage()
|
||||||
|
}
|
||||||
|
|
||||||
function scrollToBottom() {
|
function scrollToBottom() {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
const el = messageListRef.value
|
const el = messageListRef.value
|
||||||
@@ -62,10 +304,44 @@ function onKeydown(event) {
|
|||||||
function clearChat() {
|
function clearChat() {
|
||||||
if (pending.value) return
|
if (pending.value) return
|
||||||
messages.value = []
|
messages.value = []
|
||||||
traces.value = []
|
resetStreamDetails()
|
||||||
reasoning.value = ''
|
updateActiveLocalConversation()
|
||||||
stats.value = null
|
}
|
||||||
clearSelectedImage()
|
|
||||||
|
async function deleteLocalConversation(localId) {
|
||||||
|
if (pending.value) return
|
||||||
|
localConversations.value = localConversations.value.filter((item) => item.localId !== localId)
|
||||||
|
saveLocalConversations()
|
||||||
|
if (activeLocalId.value === localId) {
|
||||||
|
if (localConversations.value.length) {
|
||||||
|
selectLocalConversation(localConversations.value[0].localId)
|
||||||
|
} else {
|
||||||
|
createLocalConversation(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteServerConversation(id) {
|
||||||
|
if (pending.value) return
|
||||||
|
if (!window.confirm(t('aichat.delete_chat'))) return
|
||||||
|
try {
|
||||||
|
const res = await deleteAIChatConversation(id)
|
||||||
|
if (res.errCode === 0) {
|
||||||
|
serverConversations.value = serverConversations.value.filter((item) => Number(item.id) !== Number(id))
|
||||||
|
localConversations.value.forEach((item) => {
|
||||||
|
if (Number(item.serverId || 0) === Number(id)) item.serverId = 0
|
||||||
|
})
|
||||||
|
saveLocalConversations()
|
||||||
|
if (Number(activeServerId.value) === Number(id)) {
|
||||||
|
activeServerId.value = 0
|
||||||
|
activeSource.value = 'local'
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.error(t('aichat.delete_chat_failed'))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t('aichat.delete_chat_failed'))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function triggerImagePicker() {
|
function triggerImagePicker() {
|
||||||
@@ -117,6 +393,24 @@ function messageImage(message) {
|
|||||||
return message.image_url || message.imageURL || ''
|
return message.image_url || message.imageURL || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function messageTraces(message, index) {
|
||||||
|
if (Array.isArray(message.traces) && message.traces.length) return message.traces
|
||||||
|
if (message.role !== 'user' && index === messages.value.length - 1) return traces.value
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageReasoning(message, index) {
|
||||||
|
if (message.reasoning) return message.reasoning
|
||||||
|
if (message.role !== 'user' && index === messages.value.length - 1) return reasoning.value
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageStats(message, index) {
|
||||||
|
if (message.stats) return message.stats
|
||||||
|
if (message.role !== 'user' && index === messages.value.length - 1) return stats.value
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
function formatTraceData(data) {
|
function formatTraceData(data) {
|
||||||
if (!data) return []
|
if (!data) return []
|
||||||
const parts = []
|
const parts = []
|
||||||
@@ -155,15 +449,33 @@ function formatTokenStats(value) {
|
|||||||
return parts.join(' | ')
|
return parts.join(' | ')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDate(value) {
|
||||||
|
if (!value) return ''
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) return ''
|
||||||
|
return date.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTitle(items) {
|
||||||
|
const firstUser = items.find((item) => item.role === 'user' && (item.content || messageImage(item)))
|
||||||
|
if (!firstUser) return t('aichat.untitled_chat')
|
||||||
|
const text = (firstUser.content || t('aichat.attach_image')).trim()
|
||||||
|
return text.length > 40 ? `${text.slice(0, 40)}...` : text
|
||||||
|
}
|
||||||
|
|
||||||
async function sendMessage() {
|
async function sendMessage() {
|
||||||
const text = inputText.value.trim()
|
const text = inputText.value.trim()
|
||||||
const image = selectedImage.value
|
const image = selectedImage.value
|
||||||
if ((!text && !image) || pending.value) return
|
if ((!text && !image) || pending.value) return
|
||||||
|
|
||||||
|
const clientLocalId = activeLocalId.value || createLocalId()
|
||||||
|
activeLocalId.value = clientLocalId
|
||||||
inputText.value = ''
|
inputText.value = ''
|
||||||
clearSelectedImage()
|
clearSelectedImage()
|
||||||
traces.value = []
|
traces.value = []
|
||||||
|
tracesCollapsed.value = false
|
||||||
reasoning.value = ''
|
reasoning.value = ''
|
||||||
|
reasoningCollapsed.value = false
|
||||||
stats.value = null
|
stats.value = null
|
||||||
|
|
||||||
const userMessage = { role: 'user', content: text }
|
const userMessage = { role: 'user', content: text }
|
||||||
@@ -174,6 +486,7 @@ async function sendMessage() {
|
|||||||
const assistantMessage = { role: 'assistant', content: '' }
|
const assistantMessage = { role: 'assistant', content: '' }
|
||||||
messages.value.push(assistantMessage)
|
messages.value.push(assistantMessage)
|
||||||
pending.value = true
|
pending.value = true
|
||||||
|
updateActiveLocalConversation()
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
|
|
||||||
const history = messages.value
|
const history = messages.value
|
||||||
@@ -186,26 +499,42 @@ async function sendMessage() {
|
|||||||
.slice(0, -1)
|
.slice(0, -1)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await streamChat(history, { openaiName: activeProfile.value }, {
|
await streamChat(history, {
|
||||||
|
openaiName: activeProfile.value,
|
||||||
|
conversationId: activeServerId.value || 0,
|
||||||
|
clientLocalId,
|
||||||
|
saveToServer: userStore.isLoggedIn,
|
||||||
|
}, {
|
||||||
|
onConversation(conversation) {
|
||||||
|
bindServerConversation(conversation)
|
||||||
|
},
|
||||||
onDelta(delta) {
|
onDelta(delta) {
|
||||||
assistantMessage.content += delta
|
assistantMessage.content += delta
|
||||||
|
updateActiveLocalConversation()
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
},
|
},
|
||||||
onTrace(frame) {
|
onTrace(frame) {
|
||||||
traces.value.push(frame)
|
traces.value.push(frame)
|
||||||
|
assistantMessage.traces = [...traces.value]
|
||||||
|
updateActiveLocalConversation()
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
},
|
},
|
||||||
onReasoning(delta) {
|
onReasoning(delta) {
|
||||||
reasoning.value += delta
|
reasoning.value += delta
|
||||||
|
assistantMessage.reasoning = reasoning.value
|
||||||
|
updateActiveLocalConversation()
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
},
|
},
|
||||||
onStats(value) {
|
onStats(value) {
|
||||||
stats.value = value
|
stats.value = value
|
||||||
|
assistantMessage.stats = value
|
||||||
|
updateActiveLocalConversation()
|
||||||
},
|
},
|
||||||
onError(message) {
|
onError(message) {
|
||||||
if (!assistantMessage.content) {
|
if (!assistantMessage.content) {
|
||||||
assistantMessage.content = t('aichat.error_prefix') + message
|
assistantMessage.content = t('aichat.error_prefix') + message
|
||||||
}
|
}
|
||||||
|
updateActiveLocalConversation()
|
||||||
toast.error(message)
|
toast.error(message)
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
},
|
},
|
||||||
@@ -213,16 +542,27 @@ async function sendMessage() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error)
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
assistantMessage.content = t('aichat.error_prefix') + message
|
assistantMessage.content = t('aichat.error_prefix') + message
|
||||||
|
updateActiveLocalConversation()
|
||||||
toast.error(message)
|
toast.error(message)
|
||||||
} finally {
|
} finally {
|
||||||
pending.value = false
|
pending.value = false
|
||||||
|
if (traces.value.length) {
|
||||||
|
tracesCollapsed.value = true
|
||||||
|
}
|
||||||
|
if (reasoning.value) {
|
||||||
|
reasoningCollapsed.value = true
|
||||||
|
}
|
||||||
|
updateActiveLocalConversation()
|
||||||
|
if (userStore.isLoggedIn) {
|
||||||
|
loadServerConversations()
|
||||||
|
}
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="mx-auto flex h-[calc(100vh-7rem)] max-w-5xl flex-col px-4 py-6">
|
<div class="mx-auto flex h-[calc(100vh-7rem)] max-w-7xl flex-col px-4 py-6">
|
||||||
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
|
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-dk-text">
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-dk-text">
|
||||||
@@ -233,6 +573,15 @@ async function sendMessage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-2 rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
:disabled="pending"
|
||||||
|
@click="newChat"
|
||||||
|
>
|
||||||
|
<IconPlus :size="16" />
|
||||||
|
{{ t('aichat.new_chat') }}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex items-center gap-2 rounded-md border border-gray-200 px-3 py-2 text-sm text-gray-600 transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-60 dark:border-dk-muted dark:text-dk-subtle dark:hover:bg-dk-card"
|
class="inline-flex items-center gap-2 rounded-md border border-gray-200 px-3 py-2 text-sm text-gray-600 transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-60 dark:border-dk-muted dark:text-dk-subtle dark:hover:bg-dk-card"
|
||||||
@@ -245,153 +594,274 @@ async function sendMessage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-lg dark:border-dk-muted dark:bg-dk-card">
|
<div class="grid min-h-0 flex-1 gap-4 lg:grid-cols-[18rem_minmax(0,1fr)]">
|
||||||
<div ref="messageListRef" class="min-h-0 flex-1 overflow-y-auto px-4 py-5 sm:px-6">
|
<aside class="flex min-h-0 flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-lg dark:border-dk-muted dark:bg-dk-card">
|
||||||
<div v-if="messages.length === 0" class="flex h-full items-center justify-center text-center">
|
<div class="border-b border-gray-200 p-4 dark:border-dk-muted">
|
||||||
<div class="max-w-md">
|
<button
|
||||||
<div class="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-blue-50 text-blue-600 dark:bg-blue-900/20 dark:text-blue-300">
|
type="button"
|
||||||
<IconRobot :size="30" />
|
class="flex w-full items-center justify-center gap-2 rounded-lg bg-blue-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
</div>
|
:disabled="pending"
|
||||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-dk-text">
|
@click="newChat"
|
||||||
{{ t('aichat.empty_title') }}
|
>
|
||||||
</h2>
|
<IconPlus :size="16" />
|
||||||
<p class="mt-2 text-sm text-gray-500 dark:text-dk-subtle">
|
{{ t('aichat.new_chat') }}
|
||||||
{{ t('aichat.empty_hint') }}
|
</button>
|
||||||
</p>
|
<p v-if="!userStore.isLoggedIn" class="mt-3 text-xs text-gray-500 dark:text-dk-subtle">
|
||||||
</div>
|
{{ t('aichat.login_to_sync') }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="space-y-5">
|
<div class="min-h-0 flex-1 overflow-y-auto p-3">
|
||||||
<div
|
<section v-if="userStore.isLoggedIn" class="mb-5">
|
||||||
v-for="(message, index) in messages"
|
<div class="mb-2 flex items-center justify-between text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-dk-subtle">
|
||||||
:key="index"
|
<span>{{ t('aichat.server_chats') }}</span>
|
||||||
:class="['flex gap-3', message.role === 'user' ? 'justify-end' : 'justify-start']"
|
<IconLoader2 v-if="loadingConversations" :size="14" class="animate-spin" />
|
||||||
>
|
|
||||||
<div v-if="message.role !== 'user'" class="mt-1 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-blue-50 text-blue-600 dark:bg-blue-900/20 dark:text-blue-300">
|
|
||||||
<IconRobot :size="18" />
|
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="sortedServerConversations.length === 0" class="rounded-lg border border-dashed border-gray-200 p-3 text-xs text-gray-500 dark:border-dk-muted dark:text-dk-subtle">
|
||||||
<div :class="['max-w-[82%] rounded-2xl px-4 py-3 text-sm leading-6 shadow-sm', message.role === 'user' ? 'bg-blue-600 text-white' : 'border border-gray-200 bg-gray-50 text-gray-800 dark:border-dk-muted dark:bg-dk-base dark:text-dk-text']">
|
{{ t('aichat.no_server_chats') }}
|
||||||
<div v-if="message.role !== 'user' && index === messages.length - 1 && traces.length" class="mb-3 space-y-2">
|
</div>
|
||||||
<div
|
<div v-else class="space-y-2">
|
||||||
v-for="(trace, traceIndex) in traces"
|
<button
|
||||||
:key="traceIndex"
|
v-for="conversation in sortedServerConversations"
|
||||||
class="rounded-lg border border-blue-100 bg-blue-50 px-3 py-2 text-xs text-blue-700 dark:border-blue-900/40 dark:bg-blue-900/20 dark:text-blue-200"
|
:key="conversation.id"
|
||||||
|
type="button"
|
||||||
|
:class="[
|
||||||
|
'group flex w-full items-start gap-2 rounded-lg border px-3 py-2 text-left transition-colors',
|
||||||
|
activeSource === 'server' && Number(activeServerId) === Number(conversation.id)
|
||||||
|
? 'border-blue-200 bg-blue-50 dark:border-blue-900/50 dark:bg-blue-900/20'
|
||||||
|
: 'border-transparent hover:bg-gray-50 dark:hover:bg-dk-base',
|
||||||
|
]"
|
||||||
|
@click="selectServerConversation(conversation.id)"
|
||||||
|
>
|
||||||
|
<IconCloud :size="16" class="mt-0.5 shrink-0 text-blue-600 dark:text-blue-300" />
|
||||||
|
<span class="min-w-0 flex-1">
|
||||||
|
<span class="block truncate text-sm font-medium text-gray-800 dark:text-dk-text">
|
||||||
|
{{ conversation.title || t('aichat.untitled_chat') }}
|
||||||
|
</span>
|
||||||
|
<span class="mt-1 block truncate text-xs text-gray-500 dark:text-dk-subtle">
|
||||||
|
{{ formatDate(conversation.lastMessageAt || conversation.updatedAt) }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="rounded p-1 text-gray-400 opacity-0 transition hover:bg-red-50 hover:text-red-600 group-hover:opacity-100 dark:hover:bg-red-900/20"
|
||||||
|
:title="t('aichat.delete_chat')"
|
||||||
|
@click.stop="deleteServerConversation(conversation.id)"
|
||||||
>
|
>
|
||||||
<div class="font-medium">
|
<IconTrash :size="14" />
|
||||||
{{ trace.tool || 'tool' }} · {{ trace.status || trace.stage || 'trace' }}
|
</span>
|
||||||
</div>
|
</button>
|
||||||
<div v-if="trace.message" class="mt-1 opacity-90">
|
</div>
|
||||||
{{ trace.message }}
|
</section>
|
||||||
</div>
|
|
||||||
<div v-if="formatTraceData(trace.data).length" class="mt-2 space-y-1 rounded-md border border-blue-100 bg-white/70 px-2 py-1 font-mono text-[11px] leading-5 text-blue-800 dark:border-blue-900/40 dark:bg-dk-card/70 dark:text-blue-100">
|
<section>
|
||||||
<div v-for="(line, dataIndex) in formatTraceData(trace.data)" :key="dataIndex" class="whitespace-pre-wrap break-words">
|
<div class="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-dk-subtle">
|
||||||
{{ line }}
|
{{ t('aichat.browser_chats') }}
|
||||||
|
</div>
|
||||||
|
<div v-if="sortedLocalConversations.length === 0" class="rounded-lg border border-dashed border-gray-200 p-3 text-xs text-gray-500 dark:border-dk-muted dark:text-dk-subtle">
|
||||||
|
{{ t('aichat.no_browser_chats') }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<button
|
||||||
|
v-for="conversation in sortedLocalConversations"
|
||||||
|
:key="conversation.localId"
|
||||||
|
type="button"
|
||||||
|
:class="[
|
||||||
|
'group flex w-full items-start gap-2 rounded-lg border px-3 py-2 text-left transition-colors',
|
||||||
|
activeLocalId === conversation.localId && activeSource === 'local'
|
||||||
|
? 'border-blue-200 bg-blue-50 dark:border-blue-900/50 dark:bg-blue-900/20'
|
||||||
|
: 'border-transparent hover:bg-gray-50 dark:hover:bg-dk-base',
|
||||||
|
]"
|
||||||
|
@click="selectLocalConversation(conversation.localId)"
|
||||||
|
>
|
||||||
|
<IconDeviceFloppy :size="16" class="mt-0.5 shrink-0 text-gray-500 dark:text-dk-subtle" />
|
||||||
|
<span class="min-w-0 flex-1">
|
||||||
|
<span class="block truncate text-sm font-medium text-gray-800 dark:text-dk-text">
|
||||||
|
{{ conversation.title || t('aichat.untitled_chat') }}
|
||||||
|
</span>
|
||||||
|
<span class="mt-1 block truncate text-xs text-gray-500 dark:text-dk-subtle">
|
||||||
|
{{ formatDate(conversation.updatedAt) }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="rounded p-1 text-gray-400 opacity-0 transition hover:bg-red-50 hover:text-red-600 group-hover:opacity-100 dark:hover:bg-red-900/20"
|
||||||
|
:title="t('aichat.delete_chat')"
|
||||||
|
@click.stop="deleteLocalConversation(conversation.localId)"
|
||||||
|
>
|
||||||
|
<IconTrash :size="14" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-lg dark:border-dk-muted dark:bg-dk-card">
|
||||||
|
<div ref="messageListRef" class="min-h-0 flex-1 overflow-y-auto px-4 py-5 sm:px-6">
|
||||||
|
<div v-if="messages.length === 0" class="flex h-full items-center justify-center text-center">
|
||||||
|
<div class="max-w-md">
|
||||||
|
<div class="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-blue-50 text-blue-600 dark:bg-blue-900/20 dark:text-blue-300">
|
||||||
|
<IconRobot :size="30" />
|
||||||
|
</div>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-dk-text">
|
||||||
|
{{ t('aichat.empty_title') }}
|
||||||
|
</h2>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-dk-subtle">
|
||||||
|
{{ t('aichat.empty_hint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-5">
|
||||||
|
<div
|
||||||
|
v-for="(message, index) in messages"
|
||||||
|
:key="index"
|
||||||
|
:class="['flex gap-3', message.role === 'user' ? 'justify-end' : 'justify-start']"
|
||||||
|
>
|
||||||
|
<div v-if="message.role !== 'user'" class="mt-1 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-blue-50 text-blue-600 dark:bg-blue-900/20 dark:text-blue-300">
|
||||||
|
<IconRobot :size="18" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div :class="['max-w-[82%] rounded-2xl px-4 py-3 text-sm leading-6 shadow-sm', message.role === 'user' ? 'bg-blue-600 text-white' : 'border border-gray-200 bg-gray-50 text-gray-800 dark:border-dk-muted dark:bg-dk-base dark:text-dk-text']">
|
||||||
|
<div v-if="message.role !== 'user' && messageTraces(message, index).length" class="mb-3 rounded-lg border border-blue-100 bg-blue-50 px-3 py-2 text-xs text-blue-700 dark:border-blue-900/40 dark:bg-blue-900/20 dark:text-blue-200">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center justify-between gap-2 text-left font-medium"
|
||||||
|
@click="tracesCollapsed = !tracesCollapsed"
|
||||||
|
>
|
||||||
|
<span>{{ t('aichat.trace_details') }}</span>
|
||||||
|
<span class="text-[11px] opacity-75">
|
||||||
|
{{ tracesCollapsed ? t('aichat.expand') : t('aichat.collapse') }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div v-if="!tracesCollapsed" class="mt-2 space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="(trace, traceIndex) in messageTraces(message, index)"
|
||||||
|
:key="traceIndex"
|
||||||
|
class="rounded-lg border border-blue-100 bg-white/70 px-3 py-2 dark:border-blue-900/40 dark:bg-dk-card/70"
|
||||||
|
>
|
||||||
|
<div class="font-medium">
|
||||||
|
{{ trace.tool || 'tool' }} · {{ trace.status || trace.stage || 'trace' }}
|
||||||
|
</div>
|
||||||
|
<div v-if="trace.message" class="mt-1 opacity-90">
|
||||||
|
{{ trace.message }}
|
||||||
|
</div>
|
||||||
|
<div v-if="formatTraceData(trace.data).length" class="mt-2 space-y-1 rounded-md border border-blue-100 bg-white/70 px-2 py-1 font-mono text-[11px] leading-5 text-blue-800 dark:border-blue-900/40 dark:bg-dk-card/70 dark:text-blue-100">
|
||||||
|
<div v-for="(line, dataIndex) in formatTraceData(trace.data)" :key="dataIndex" class="whitespace-pre-wrap break-words">
|
||||||
|
{{ line }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="message.role !== 'user' && index === messages.length - 1 && reasoning" class="mb-3 rounded-lg border border-purple-100 bg-purple-50 px-3 py-2 text-xs text-purple-800 dark:border-purple-900/40 dark:bg-purple-900/20 dark:text-purple-100">
|
<div v-if="message.role !== 'user' && messageReasoning(message, index)" class="mb-3 rounded-lg border border-purple-100 bg-purple-50 px-3 py-2 text-xs text-purple-800 dark:border-purple-900/40 dark:bg-purple-900/20 dark:text-purple-100">
|
||||||
<div class="font-medium">
|
<button
|
||||||
{{ t('aichat.reasoning') }}
|
type="button"
|
||||||
|
class="flex w-full items-center justify-between gap-2 text-left font-medium"
|
||||||
|
@click="reasoningCollapsed = !reasoningCollapsed"
|
||||||
|
>
|
||||||
|
<span>{{ t('aichat.reasoning') }}</span>
|
||||||
|
<span class="text-[11px] opacity-75">
|
||||||
|
{{ reasoningCollapsed ? t('aichat.expand') : t('aichat.collapse') }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div v-if="!reasoningCollapsed" class="mt-1 whitespace-pre-wrap break-words">
|
||||||
|
{{ messageReasoning(message, index) }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 whitespace-pre-wrap break-words">
|
|
||||||
{{ reasoning }}
|
<img
|
||||||
|
v-if="messageImage(message)"
|
||||||
|
:src="messageImage(message)"
|
||||||
|
:alt="message.content || t('aichat.attach_image')"
|
||||||
|
class="mb-2 max-h-64 max-w-full rounded-lg object-contain"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p v-if="message.content || (message.role === 'assistant' && pending && index === messages.length - 1)" class="whitespace-pre-wrap break-words">
|
||||||
|
{{ message.content || (message.role === 'assistant' && pending ? t('aichat.thinking') : '') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="message.role !== 'user' && index === messages.length - 1 && pending" class="mt-2 inline-flex items-center gap-1 text-xs text-gray-500 dark:text-dk-subtle">
|
||||||
|
<IconLoader2 :size="14" class="animate-spin" />
|
||||||
|
{{ t('aichat.streaming') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="message.role !== 'user' && messageStats(message, index)" class="mt-3 text-xs text-gray-500 dark:text-dk-subtle">
|
||||||
|
{{ formatTokenStats(messageStats(message, index)) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<img
|
<div v-if="message.role === 'user'" class="mt-1 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gray-100 text-gray-600 dark:bg-dk-muted dark:text-dk-text">
|
||||||
v-if="messageImage(message)"
|
<IconUser :size="18" />
|
||||||
:src="messageImage(message)"
|
|
||||||
:alt="message.content || t('aichat.attach_image')"
|
|
||||||
class="mb-2 max-h-64 max-w-full rounded-lg object-contain"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p v-if="message.content || (message.role === 'assistant' && pending)" class="whitespace-pre-wrap break-words">
|
|
||||||
{{ message.content || (message.role === 'assistant' && pending ? t('aichat.thinking') : '') }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div v-if="message.role !== 'user' && index === messages.length - 1 && pending" class="mt-2 inline-flex items-center gap-1 text-xs text-gray-500 dark:text-dk-subtle">
|
|
||||||
<IconLoader2 :size="14" class="animate-spin" />
|
|
||||||
{{ t('aichat.streaming') }}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="message.role !== 'user' && index === messages.length - 1 && stats" class="mt-3 text-xs text-gray-500 dark:text-dk-subtle">
|
|
||||||
{{ formatTokenStats(stats) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="message.role === 'user'" class="mt-1 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gray-100 text-gray-600 dark:bg-dk-muted dark:text-dk-text">
|
|
||||||
<IconUser :size="18" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="border-t border-gray-200 bg-gray-50 p-4 dark:border-dk-muted dark:bg-dk-base">
|
<div class="border-t border-gray-200 bg-gray-50 p-4 dark:border-dk-muted dark:bg-dk-base">
|
||||||
<input
|
<input
|
||||||
ref="fileInputRef"
|
ref="fileInputRef"
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||||
class="hidden"
|
class="hidden"
|
||||||
:disabled="pending"
|
|
||||||
@change="onImageSelected"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div v-if="selectedImage" class="mb-3 flex items-center gap-3 rounded-lg border border-gray-200 bg-white p-2 dark:border-dk-muted dark:bg-dk-card">
|
|
||||||
<img :src="selectedImage.dataUrl" :alt="selectedImage.name" class="h-14 w-14 rounded object-cover" />
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<div class="truncate text-sm font-medium text-gray-800 dark:text-dk-text">
|
|
||||||
{{ selectedImage.name }}
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-gray-500 dark:text-dk-subtle">
|
|
||||||
{{ formatFileSize(selectedImage.size) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-gray-500 transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-60 dark:text-dk-subtle dark:hover:bg-dk-muted"
|
|
||||||
:title="t('aichat.remove_image')"
|
|
||||||
:disabled="pending"
|
:disabled="pending"
|
||||||
@click="clearSelectedImage"
|
@change="onImageSelected"
|
||||||
>
|
|
||||||
<IconX :size="16" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-end gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="inline-flex h-[52px] w-[52px] shrink-0 items-center justify-center rounded-lg border border-gray-300 bg-white text-gray-600 transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-60 dark:border-dk-muted dark:bg-dk-card dark:text-dk-subtle dark:hover:bg-dk-muted"
|
|
||||||
:title="t('aichat.attach_image')"
|
|
||||||
:disabled="pending"
|
|
||||||
@click="triggerImagePicker"
|
|
||||||
>
|
|
||||||
<IconPhoto :size="20" />
|
|
||||||
</button>
|
|
||||||
<textarea
|
|
||||||
v-model="inputText"
|
|
||||||
rows="2"
|
|
||||||
class="min-h-[52px] flex-1 resize-none rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 disabled:cursor-not-allowed disabled:opacity-60 dark:border-dk-muted dark:bg-dk-card dark:text-dk-text"
|
|
||||||
:placeholder="t('aichat.input_placeholder')"
|
|
||||||
:disabled="pending"
|
|
||||||
@keydown="onKeydown"
|
|
||||||
/>
|
/>
|
||||||
<button
|
|
||||||
type="button"
|
<div v-if="selectedImage" class="mb-3 flex items-center gap-3 rounded-lg border border-gray-200 bg-white p-2 dark:border-dk-muted dark:bg-dk-card">
|
||||||
class="inline-flex h-[52px] items-center gap-2 rounded-lg bg-blue-600 px-4 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60"
|
<img :src="selectedImage.dataUrl" :alt="selectedImage.name" class="h-14 w-14 rounded object-cover" />
|
||||||
:disabled="pending || (!inputText.trim() && !selectedImage)"
|
<div class="min-w-0 flex-1">
|
||||||
@click="sendMessage"
|
<div class="truncate text-sm font-medium text-gray-800 dark:text-dk-text">
|
||||||
>
|
{{ selectedImage.name }}
|
||||||
<IconLoader2 v-if="pending" :size="18" class="animate-spin" />
|
</div>
|
||||||
<IconSend v-else :size="18" />
|
<div class="text-xs text-gray-500 dark:text-dk-subtle">
|
||||||
{{ t('aichat.send') }}
|
{{ formatFileSize(selectedImage.size) }}
|
||||||
</button>
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-gray-500 transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-60 dark:text-dk-subtle dark:hover:bg-dk-muted"
|
||||||
|
:title="t('aichat.remove_image')"
|
||||||
|
:disabled="pending"
|
||||||
|
@click="clearSelectedImage"
|
||||||
|
>
|
||||||
|
<IconX :size="16" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex h-[52px] w-[52px] shrink-0 items-center justify-center rounded-lg border border-gray-300 bg-white text-gray-600 transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-60 dark:border-dk-muted dark:bg-dk-card dark:text-dk-subtle dark:hover:bg-dk-muted"
|
||||||
|
:title="t('aichat.attach_image')"
|
||||||
|
:disabled="pending"
|
||||||
|
@click="triggerImagePicker"
|
||||||
|
>
|
||||||
|
<IconPhoto :size="20" />
|
||||||
|
</button>
|
||||||
|
<textarea
|
||||||
|
v-model="inputText"
|
||||||
|
rows="2"
|
||||||
|
class="min-h-[52px] flex-1 resize-none rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 disabled:cursor-not-allowed disabled:opacity-60 dark:border-dk-muted dark:bg-dk-card dark:text-dk-text"
|
||||||
|
:placeholder="t('aichat.input_placeholder')"
|
||||||
|
:disabled="pending"
|
||||||
|
@keydown="onKeydown"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex h-[52px] items-center gap-2 rounded-lg bg-blue-600 px-4 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
:disabled="pending || (!inputText.trim() && !selectedImage)"
|
||||||
|
@click="sendMessage"
|
||||||
|
>
|
||||||
|
<IconLoader2 v-if="pending" :size="18" class="animate-spin" />
|
||||||
|
<IconSend v-else :size="18" />
|
||||||
|
{{ t('aichat.send') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-xs text-gray-500 dark:text-dk-subtle">
|
||||||
|
{{ t('aichat.enter_hint') }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-xs text-gray-500 dark:text-dk-subtle">
|
|
||||||
{{ t('aichat.enter_hint') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user