diff --git a/backend/my_work/routers/apiAIChat.go b/backend/my_work/routers/apiAIChat.go index c58090a..ca45f63 100644 --- a/backend/my_work/routers/apiAIChat.go +++ b/backend/my_work/routers/apiAIChat.go @@ -54,8 +54,11 @@ var allowedImageTypes = map[string]bool{ // chatRequestFromFrontend is the expected POST body type chatRequest struct { - Messages []chatMessage `json:"messages"` - OpenAIName string `json:"openaiName,omitempty"` + Messages []chatMessage `json:"messages"` + OpenAIName string `json:"openaiName,omitempty"` + ConversationID uint `json:"conversationId,omitempty"` + ClientLocalID string `json:"clientLocalId,omitempty"` + SaveToServer bool `json:"saveToServer,omitempty"` } type chatMessage struct { @@ -156,6 +159,12 @@ func ApiAIChat(r *gin.RouterGroup) { r.GET("/openai", handleOpenAIProfiles) 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.POST("/config", handleAIChatAdminGetConfig) admin.POST("/config/update", handleAIChatAdminUpdateConfig) @@ -193,7 +202,7 @@ func handleOpenAIProfiles(ctx *gin.Context) { } func handleChat(ctx *gin.Context) { - data, _ := SeparateData(ctx) + data, cookieValue := SeparateData(ctx) if data == nil { sendSSEError(ctx, "请求数据为空") @@ -211,6 +220,13 @@ func handleChat(ctx *gin.Context) { return } + var currentUser *TabUser + if cookieValue != "" { + if user, err := AuthenticationAuthorityFromCookie(cookieValue); err == nil { + currentUser = user + } + } + // Check ai config cfg := getAIChatConfig() profile, ok := selectOpenAIProfile(cfg, req.OpenAIName) @@ -230,13 +246,28 @@ func handleChat(ctx *gin.Context) { flusher, _ := ctx.Writer.(http.Flusher) tracker := newTokenUsageTracker() + traceEvents := []sseEvent{} 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) { 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{} if cfg.ToolRouter.Enabled { toolConfigs = buildToolConfigs(cfg.ToolRouter.Tools) @@ -289,6 +320,9 @@ func handleChat(ctx *gin.Context) { modelPromptTokens := estimateOpenAIMessagesTokens(apiReq.Messages) completionTokens := 0 modelUsageReceived := false + assistantContent := strings.Builder{} + reasoningContent := strings.Builder{} + var finalStats *tokenUsageStats streamStarted := time.Now() windowStarted := streamStarted windowTokens := 0 @@ -305,10 +339,12 @@ func handleChat(ctx *gin.Context) { reasoningText = choice.Delta.Thinking } if reasoningText != "" { + reasoningContent.WriteString(reasoningText) sendSSE(ctx, flusher, sseEvent{Type: "reasoning", Text: reasoningText}) } if choice.Delta.Content != "" { + assistantContent.WriteString(choice.Delta.Content) deltaTokens := estimateTokenCount(choice.Delta.Content) completionTokens += deltaTokens windowTokens += deltaTokens @@ -321,17 +357,23 @@ func handleChat(ctx *gin.Context) { peakTokensPerSecond = maxFloat(peakTokensPerSecond, float64(windowTokens)/elapsedWindow) } stats := tracker.setModelEstimate(modelPromptTokens, completionTokens).snapshot(tokensPerSecond(completionTokens, streamStarted), peakTokensPerSecond) + finalStats = &stats sendSSE(ctx, flusher, sseEvent{Type: "delta", Text: choice.Delta.Content, Stats: &stats}) } } if chunk.Usage != nil { modelUsageReceived = true stats := tracker.setModelUsage(chunk.Usage).snapshot(tokensPerSecond(tracker.completionTokens, streamStarted), peakTokensPerSecond) + finalStats = &stats emitStats(stats) } }) 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) return } @@ -344,9 +386,16 @@ func handleChat(ctx *gin.Context) { } emitTrace("model", "stream", "success", "模型回复完成", nil) if modelUsageReceived { - emitStats(tracker.snapshot(tokensPerSecond(tracker.completionTokens, streamStarted), peakTokensPerSecond)) + stats := tracker.snapshot(tokensPerSecond(tracker.completionTokens, streamStarted), peakTokensPerSecond) + finalStats = &stats + emitStats(stats) } 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) flusher.Flush() diff --git a/backend/my_work/routers/apiAIChatConfig.go b/backend/my_work/routers/apiAIChatConfig.go index 3721299..cbdb12b 100644 --- a/backend/my_work/routers/apiAIChatConfig.go +++ b/backend/my_work/routers/apiAIChatConfig.go @@ -52,6 +52,33 @@ type TabAIChatTool struct { 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 func ApiAIChatInit() { @@ -60,6 +87,8 @@ func ApiAIChatInit() { &TabAIChatOpenAIProfile{}, &TabAIChatToolRouter{}, &TabAIChatTool{}, + &TabAIChatConversation{}, + &TabAIChatMessage{}, ) if err != nil { panic(err) diff --git a/backend/my_work/routers/apiAIChatConversation.go b/backend/my_work/routers/apiAIChatConversation.go new file mode 100644 index 0000000..5e6dbdc --- /dev/null +++ b/backend/my_work/routers/apiAIChatConversation.go @@ -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 +} diff --git a/frontend/ops_vue_js/src/api/aichat.js b/frontend/ops_vue_js/src/api/aichat.js index 0a319d8..c9ff735 100644 --- a/frontend/ops_vue_js/src/api/aichat.js +++ b/frontend/ops_vue_js/src/api/aichat.js @@ -17,6 +17,22 @@ export async function refreshAIChatAdminConfig() { 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) { const lines = block.split('\n') const dataLines = [] @@ -43,7 +59,13 @@ export async function streamChat(messages, options = {}, handlers = {}) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ 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': handlers.onStats?.(frame.stats || null) break + case 'conversation': + handlers.onConversation?.(frame.data || {}) + break case 'error': handlers.onError?.(frame.error || frame.message || 'AI request failed') break diff --git a/frontend/ops_vue_js/src/i18n/en.json b/frontend/ops_vue_js/src/i18n/en.json index fca507d..e9c109b 100644 --- a/frontend/ops_vue_js/src/i18n/en.json +++ b/frontend/ops_vue_js/src/i18n/en.json @@ -61,6 +61,8 @@ "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", "reasoning": "Reasoning", + "expand": "Expand", + "collapse": "Collapse", "trace_details": "Call details", "trace_database": "Database", "trace_rows": "Rows", @@ -76,7 +78,22 @@ "tokens_prompt": "Input", "tokens_completion": "Output", "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": { "title": "AI Config", diff --git a/frontend/ops_vue_js/src/i18n/zh-CN.json b/frontend/ops_vue_js/src/i18n/zh-CN.json index 1cb079e..5407759 100644 --- a/frontend/ops_vue_js/src/i18n/zh-CN.json +++ b/frontend/ops_vue_js/src/i18n/zh-CN.json @@ -61,6 +61,8 @@ "image_size_error": "图片过大,请选择小于 4MB 的图片", "image_read_error": "图片读取失败,请尝试其他文件", "reasoning": "思考内容", + "expand": "展开", + "collapse": "折叠", "trace_details": "调用详情", "trace_database": "数据库", "trace_rows": "行数", @@ -76,7 +78,22 @@ "tokens_prompt": "输入", "tokens_completion": "输出", "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": { "title": "AI 配置", diff --git a/frontend/ops_vue_js/src/views/aichat/AiChatView.vue b/frontend/ops_vue_js/src/views/aichat/AiChatView.vue index a805e6b..bc7920b 100644 --- a/frontend/ops_vue_js/src/views/aichat/AiChatView.vue +++ b/frontend/ops_vue_js/src/views/aichat/AiChatView.vue @@ -1,13 +1,21 @@