From 89a76b0c0d12e00c896df16b00fde1f4b2544e47 Mon Sep 17 00:00:00 2001 From: marcomarcogd <35049765+marcomarcogd@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:00:18 +0800 Subject: [PATCH] fix(chat): persist article conversations --- frontend/cypress/e2e/article-operations.cy.ts | 150 ++++++++++++++++++ .../components/article/ArticleChatPanel.vue | 57 ++++--- frontend/src/i18n/locales/en.ts | 1 + frontend/src/i18n/locales/zh.ts | 1 + internal/database/article_db_test.go | 33 ++++ internal/database/chat_db.go | 4 +- internal/handlers/chat/chat_handlers.go | 104 ++++++++++-- 7 files changed, 309 insertions(+), 41 deletions(-) diff --git a/frontend/cypress/e2e/article-operations.cy.ts b/frontend/cypress/e2e/article-operations.cy.ts index 8cb0d71ca..29ae89cc3 100644 --- a/frontend/cypress/e2e/article-operations.cy.ts +++ b/frontend/cypress/e2e/article-operations.cy.ts @@ -309,4 +309,154 @@ describe('Article Operations', () => { cy.wait('@searchArticleContent'); cy.contains('Body for search result 102').should('be.visible'); }); + + it('should preserve AI chat history across new conversations and request failures', () => { + const settingsState: Record = { + language: 'en-US', + theme: 'light', + layout_mode: 'normal', + default_view_mode: 'rendered', + ai_chat_enabled: 'true', + translation_enabled: 'false', + summary_enabled: 'false', + full_text_fetch_enabled: 'false', + update_check_enabled: 'false', + }; + const feed = { + id: 1, + title: 'Chat Feed', + url: 'https://example.com/chat.xml', + category: '', + article_view_mode: 'global', + }; + const article = { + id: 1, + feed_id: 1, + feed_title: feed.title, + title: 'Chat article', + url: 'https://example.com/chat/article', + published_at: '2026-08-25T00:00:00Z', + is_read: false, + is_favorite: false, + is_hidden: false, + is_read_later: false, + }; + let nextSessionID = 1; + const sessions: Array> = []; + const messages = new Map>>(); + const timestamp = () => new Date().toISOString(); + + cy.intercept('/api/**', { statusCode: 200, body: {} }); + cy.intercept('GET', '/api/settings', { statusCode: 200, body: settingsState }); + cy.intercept('GET', '/api/feeds', { statusCode: 200, body: [feed] }).as('chatFeeds'); + cy.intercept('GET', '/api/tags', { statusCode: 200, body: [] }); + cy.intercept('GET', '/api/saved-filters', { statusCode: 200, body: [] }); + cy.intercept( + { method: 'GET', pathname: '/api/articles' }, + { statusCode: 200, body: [article] } + ).as('chatArticles'); + cy.intercept('GET', '/api/articles/unread-counts', { statusCode: 200, body: {} }); + cy.intercept('GET', '/api/articles/filter-counts', { statusCode: 200, body: {} }); + cy.intercept('GET', '/api/progress', { + statusCode: 200, + body: { is_running: true, pool_task_count: 0, queue_task_count: 0 }, + }); + cy.intercept('GET', '/api/articles/content*', { + statusCode: 200, + body: { content: '

Article context for AI chat

', cached: true }, + }).as('chatArticleContent'); + cy.intercept('POST', '/api/articles/read*', { statusCode: 200, body: { success: true } }); + cy.intercept('GET', '/api/ai/chat/sessions*', (req) => { + req.reply({ statusCode: 200, body: sessions }); + }).as('chatSessions'); + cy.intercept('GET', '/api/ai/chat/messages*', (req) => { + req.reply({ statusCode: 200, body: messages.get(Number(req.query.session_id)) || [] }); + }).as('chatMessages'); + cy.intercept('POST', '/api/ai/chat/session/create', (req) => { + const id = nextSessionID++; + const session = { + id, + article_id: 1, + title: req.body.title, + created_at: timestamp(), + updated_at: timestamp(), + message_count: 0, + }; + sessions.unshift(session); + messages.set(id, []); + req.reply({ statusCode: 200, body: session }); + }).as('createChatSession'); + cy.intercept('POST', '/api/ai-chat', (req) => { + const lastMessage = req.body.messages.at(-1)?.content || ''; + let sessionID = Number(req.body.session_id || 0); + if (!sessionID) { + sessionID = nextSessionID++; + sessions.unshift({ + id: sessionID, + article_id: 1, + title: lastMessage.slice(0, 60), + created_at: timestamp(), + updated_at: timestamp(), + message_count: 0, + }); + } + const stored = messages.get(sessionID) || []; + stored.push({ + id: stored.length + 1, + role: 'user', + content: lastMessage, + created_at: timestamp(), + }); + messages.set(sessionID, stored); + + if (lastMessage === 'trigger failure') { + req.reply({ + statusCode: 500, + body: { error: 'Failed to get response from AI. Please try again.', session_id: sessionID }, + }); + return; + } + + stored.push({ + id: stored.length + 1, + role: 'assistant', + content: 'Persisted answer', + created_at: timestamp(), + }); + const session = sessions.find((item) => item.id === sessionID); + if (session) session.message_count = stored.length; + req.reply({ + statusCode: 200, + body: { response: 'Persisted answer', session_id: sessionID, history_saved: true }, + }); + }).as('aiChat'); + + cy.reload(); + cy.wait('@chatFeeds'); + cy.wait('@chatArticles'); + cy.get('[data-article-id="1"]').click(); + cy.wait('@chatArticleContent'); + cy.get('button[title="AI Chat"]').click(); + cy.wait('@chatSessions'); + + cy.get('input[placeholder="Type a message..."]').type('First question{enter}'); + cy.wait('@aiChat'); + cy.contains('.chat-panel', 'Persisted answer').should('be.visible'); + + cy.get('[data-testid="chat-new-session"]').click(); + cy.wait('@createChatSession'); + cy.wait('@chatMessages'); + cy.contains('.chat-panel', 'Persisted answer').should('not.exist'); + cy.get('[data-testid="chat-session-switcher"]').click(); + cy.get('.chat-panel [data-session-id="1"]').click(); + cy.wait('@chatMessages'); + cy.contains('.chat-panel', 'Persisted answer').should('be.visible'); + + cy.get('input[placeholder="Type a message..."]').type('trigger failure{enter}'); + cy.wait('@aiChat'); + cy.wait('@chatMessages'); + cy.contains('.chat-panel', 'trigger failure').should('be.visible'); + cy.contains('Failed to get response from AI. Please try again.').should('be.visible'); + }); + }); diff --git a/frontend/src/components/article/ArticleChatPanel.vue b/frontend/src/components/article/ArticleChatPanel.vue index f9990b480..bee2d2ed1 100644 --- a/frontend/src/components/article/ArticleChatPanel.vue +++ b/frontend/src/components/article/ArticleChatPanel.vue @@ -86,7 +86,8 @@ async function loadSessions() { } } -async function selectSession(sessionId: number) { +async function selectSession(sessionId: number, force = false) { + if (isLoading.value && !force) return; try { const response = await fetch(`/api/ai/chat/messages?session_id=${sessionId}`); if (response.ok) { @@ -106,6 +107,7 @@ async function selectSession(sessionId: number) { } async function createNewSession() { + if (isLoading.value) return; try { const response = await fetch('/api/ai/chat/session/create', { method: 'POST', @@ -131,6 +133,7 @@ async function createNewSession() { async function deleteSession(sessionId: number, e: Event) { e.stopPropagation(); + if (isLoading.value) return; const confirmed = await window.showConfirm({ title: t('common.confirm'), message: t('article.chat.confirmDeleteSession'), @@ -158,6 +161,7 @@ async function deleteSession(sessionId: number, e: Event) { function startEditSession(session: ChatSession, e: Event) { e.stopPropagation(); + if (isLoading.value) return; editingSessionId.value = session.id; editingSessionTitle.value = session.title; } @@ -277,19 +281,25 @@ async function sendMessage() { created_at: new Date().toISOString(), }); - if (data.session_id && data.session_id !== currentSessionId.value) { + if (data.session_id) { currentSessionId.value = data.session_id; await loadSessions(); } + if (data.history_saved === false) { + window.showToast(t('article.chat.historySaveFailed'), 'warning'); + } + isFirstMessage.value = false; } else { const errorText = await response.text(); - console.error('AI chat error response:', response.status, errorText); + console.error('AI chat request failed:', response.status); let errorMessage = t('article.chat.aiChatError'); + let persistedSessionID = 0; try { const errorData = JSON.parse(errorText); + persistedSessionID = Number(errorData.session_id || 0); // Extract error message from various possible formats if (typeof errorData.error === 'string') { errorMessage = errorData.error; @@ -303,24 +313,19 @@ async function sendMessage() { errorMessage = t('article.chat.aiChatError'); } } catch { - errorMessage = errorText || t('article.chat.aiChatError'); + errorMessage = t('article.chat.aiChatError'); } - messages.value.push({ - id: 0, - role: 'assistant', - content: errorMessage, - created_at: new Date().toISOString(), - }); + if (persistedSessionID > 0) { + currentSessionId.value = persistedSessionID; + await loadSessions(); + await selectSession(persistedSessionID, true); + } + window.showToast(errorMessage, 'error'); } } catch (e) { console.error('AI chat error:', e); - messages.value.push({ - id: 0, - role: 'assistant', - content: t('article.chat.aiChatError'), - created_at: new Date().toISOString(), - }); + window.showToast(t('article.chat.aiChatError'), 'error'); } finally { isLoading.value = false; await nextTick(); @@ -367,8 +372,10 @@ const currentSessionTitle = computed(() => { @@ -412,8 +421,12 @@ const currentSessionTitle = computed(() => { v-for="session in sessions" :key="session.id" class="flex items-center gap-2 p-2 rounded-lg hover:bg-bg-tertiary cursor-pointer group" - :class="{ 'bg-bg-tertiary': session.id === currentSessionId }" - @click="selectSession(session.id)" + :class="{ + 'bg-bg-tertiary': session.id === currentSessionId, + 'pointer-events-none opacity-60': isLoading, + }" + :data-session-id="session.id" + @click.stop="selectSession(session.id)" >
@@ -490,9 +503,9 @@ const currentSessionTitle = computed(() => {
{{ msg.content }}
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 78901587b..70ea9ce9b 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -64,6 +64,7 @@ const en: TranslationMessages = { chat: { aiChat: 'AI Chat', aiChatError: 'Failed to get response from AI. Please try again.', + historySaveFailed: 'The answer was generated but could not be saved to chat history.', aiChatInputPlaceholder: 'Type a message...', aiChatWelcome: 'Ask me anything about this article!', confirmDeleteSession: 'Are you sure you want to delete this chat session?', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 8bc59a006..d5399f17f 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -61,6 +61,7 @@ const zh: TranslationMessages = { chat: { aiChat: 'AI 聊天', aiChatError: '无法获取 AI 响应,请重试。', + historySaveFailed: '回答已生成,但未能保存到历史记录。', aiChatInputPlaceholder: '输入消息...', aiChatWelcome: '请问关于这篇文章的任何问题!', confirmDeleteSession: '确定要删除这个对话吗?', diff --git a/internal/database/article_db_test.go b/internal/database/article_db_test.go index 9d24be969..a6c4ef6bf 100644 --- a/internal/database/article_db_test.go +++ b/internal/database/article_db_test.go @@ -605,6 +605,39 @@ func TestSaveArticlesUpdatePreservesRelatedData(t *testing.T) { if session == nil || session.MessageCount != 1 { t.Fatalf("chat data was not preserved: session=%+v", session) } + + newerSessionID, err := db.CreateChatSession(articleID, "New conversation") + if err != nil { + t.Fatalf("CreateChatSession(newer) error: %v", err) + } + if _, err := db.Exec( + `UPDATE chat_sessions SET updated_at = '2026-08-25 10:00:00' WHERE id IN (?, ?)`, + sessionID, newerSessionID, + ); err != nil { + t.Fatalf("align chat session timestamps: %v", err) + } + firstMessageID, err := db.CreateChatMessage(newerSessionID, "user", "First in the same second", "") + if err != nil { + t.Fatalf("CreateChatMessage(first) error: %v", err) + } + secondMessageID, err := db.CreateChatMessage(newerSessionID, "assistant", "Second in the same second", "thinking") + if err != nil { + t.Fatalf("CreateChatMessage(second) error: %v", err) + } + if _, err := db.Exec( + `UPDATE chat_sessions SET updated_at = '2026-08-25 10:00:00' WHERE id IN (?, ?)`, + sessionID, newerSessionID, + ); err != nil { + t.Fatalf("restore aligned chat session timestamps: %v", err) + } + sessions, err := db.GetChatSessionsByArticle(articleID) + if err != nil || len(sessions) != 2 || sessions[0].ID != newerSessionID { + t.Fatalf("chat session order = %+v, err=%v", sessions, err) + } + messages, err := db.GetChatMessages(newerSessionID) + if err != nil || len(messages) != 2 || messages[0].ID != firstMessageID || messages[1].ID != secondMessageID { + t.Fatalf("chat message order = %+v, err=%v", messages, err) + } } func TestArticleDeduplicationByUniqueID(t *testing.T) { diff --git a/internal/database/chat_db.go b/internal/database/chat_db.go index 5dd98a663..826774e10 100644 --- a/internal/database/chat_db.go +++ b/internal/database/chat_db.go @@ -67,7 +67,7 @@ func (db *DB) GetChatSessionsByArticle(articleID int64) ([]ChatSession, error) { (SELECT COUNT(*) FROM chat_messages WHERE session_id = chat_sessions.id) as message_count FROM chat_sessions WHERE article_id = ? - ORDER BY updated_at DESC + ORDER BY updated_at DESC, id DESC `, articleID) if err != nil { return nil, fmt.Errorf("failed to get chat sessions: %w", err) @@ -149,7 +149,7 @@ func (db *DB) GetChatMessages(sessionID int64) ([]ChatMessage, error) { SELECT id, session_id, role, content, thinking, created_at FROM chat_messages WHERE session_id = ? - ORDER BY created_at ASC + ORDER BY created_at ASC, id ASC `, sessionID) if err != nil { return nil, fmt.Errorf("failed to get chat messages: %w", err) diff --git a/internal/handlers/chat/chat_handlers.go b/internal/handlers/chat/chat_handlers.go index 9adbf0552..7887f8357 100644 --- a/internal/handlers/chat/chat_handlers.go +++ b/internal/handlers/chat/chat_handlers.go @@ -24,6 +24,8 @@ type ChatMessage struct { // ChatRequest represents the incoming chat request type ChatRequest struct { Messages []ChatMessage `json:"messages"` + SessionID int64 `json:"session_id,omitempty"` + ArticleID int64 `json:"article_id,omitempty"` ArticleTitle string `json:"article_title,omitempty"` ArticleURL string `json:"article_url,omitempty"` ArticleContent string `json:"article_content,omitempty"` @@ -32,8 +34,16 @@ type ChatRequest struct { // ChatResponse represents the response from the AI chat type ChatResponse struct { - Response string `json:"response"` - HTML string `json:"html,omitempty"` // Rendered HTML version of markdown response + Response string `json:"response"` + HTML string `json:"html,omitempty"` // Rendered HTML version of markdown response + Thinking string `json:"thinking,omitempty"` + SessionID int64 `json:"session_id,omitempty"` + HistorySaved bool `json:"history_saved"` +} + +type chatErrorResponse struct { + Error string `json:"error"` + SessionID int64 `json:"session_id,omitempty"` } // HandleAIChat handles chat requests for article discussions @@ -72,13 +82,17 @@ func HandleAIChat(h *core.Handler, w http.ResponseWriter, r *http.Request) { return } + sessionID, historyEnabled, err := persistUserChatMessage(h, &req) + if err != nil { + log.Printf("AI chat history preparation failed session=%d", req.SessionID) + writeChatError(w, "Failed to save chat history", http.StatusInternalServerError, sessionID) + return + } + // Check if AI usage limit is reached if h.AITracker.IsLimitReached() { log.Printf("AI usage limit reached for chat") - w.WriteHeader(http.StatusTooManyRequests) - response.JSON(w, map[string]string{ - "error": "AI usage limit reached", - }) + writeChatError(w, "AI usage limit reached", http.StatusTooManyRequests, sessionID) return } @@ -93,7 +107,7 @@ func HandleAIChat(h *core.Handler, w http.ResponseWriter, r *http.Request) { apiKey = cfg.APIKey endpoint = cfg.Endpoint model = cfg.Model - log.Printf("Using AI profile for chat (endpoint: %s, model: %s)", endpoint, model) + log.Printf("Using AI profile for chat (model: %s)", model) } } @@ -110,7 +124,7 @@ func HandleAIChat(h *core.Handler, w http.ResponseWriter, r *http.Request) { if model == "" { model = "gpt-4o-mini" } - log.Printf("Using global AI settings for chat (endpoint: %s, model: %s)", endpoint, model) + log.Printf("Using global AI settings for chat (model: %s)", model) } // Optimize context to reduce token usage @@ -128,7 +142,7 @@ func HandleAIChat(h *core.Handler, w http.ResponseWriter, r *http.Request) { // Create HTTP client with proxy support if configured httpClient, err := createHTTPClientWithProxy(h) if err != nil { - log.Printf("Failed to create HTTP client with proxy: %v", err) + log.Printf("Failed to create HTTP client with proxy") httpClient = &http.Client{Timeout: 60 * time.Second} } else { httpClient.Timeout = 60 * time.Second @@ -146,8 +160,8 @@ func HandleAIChat(h *core.Handler, w http.ResponseWriter, r *http.Request) { // Send chat request using universal client result, err := client.RequestWithMessages(messagesMap) if err != nil { - log.Printf("AI chat request failed: %v", err) - response.Error(w, err, http.StatusInternalServerError) + log.Printf("AI chat request failed") + writeChatError(w, "Failed to get response from AI. Please try again.", http.StatusInternalServerError, sessionID) return } @@ -159,11 +173,6 @@ func HandleAIChat(h *core.Handler, w http.ResponseWriter, r *http.Request) { // Convert markdown response to HTML htmlResponse := textutil.ConvertMarkdownToHTML(respContent) - // Log thinking if present (for debugging) - if thinking != "" { - log.Printf("AI chat thinking: %s", thinking) - } - // Track AI usage (estimate tokens from input and output) estimatedTokens := estimateChatTokens(optimizedMessages, respContent) if err := h.AITracker.AddUsage(int64(estimatedTokens)); err != nil { @@ -173,7 +182,68 @@ func HandleAIChat(h *core.Handler, w http.ResponseWriter, r *http.Request) { // Track statistics _ = h.DB.IncrementStat("ai_chat") - response.JSON(w, ChatResponse{Response: respContent, HTML: htmlResponse}) + historySaved := historyEnabled + if historyEnabled { + if _, saveErr := h.DB.CreateChatMessage(sessionID, "assistant", respContent, thinking); saveErr != nil { + log.Printf("AI chat assistant history save failed session=%d", sessionID) + historySaved = false + } + } + + response.JSON(w, ChatResponse{ + Response: respContent, HTML: htmlResponse, Thinking: thinking, + SessionID: sessionID, HistorySaved: historySaved, + }) +} + +func persistUserChatMessage(h *core.Handler, req *ChatRequest) (int64, bool, error) { + if req.ArticleID <= 0 { + // Backward compatibility for callers that do not send article_id. + return req.SessionID, false, nil + } + + lastUserMessage := "" + for index := len(req.Messages) - 1; index >= 0; index-- { + if req.Messages[index].Role == "user" { + lastUserMessage = strings.TrimSpace(req.Messages[index].Content) + break + } + } + if lastUserMessage == "" { + return req.SessionID, false, fmt.Errorf("latest user message is missing") + } + + sessionID := req.SessionID + if sessionID > 0 { + session, err := h.DB.GetChatSession(sessionID) + if err != nil { + return sessionID, false, err + } + if session == nil || session.ArticleID != req.ArticleID { + return sessionID, false, fmt.Errorf("chat session does not belong to the article") + } + } else { + title := []rune(lastUserMessage) + if len(title) > 60 { + title = title[:60] + } + var err error + sessionID, err = h.DB.CreateChatSession(req.ArticleID, string(title)) + if err != nil { + return 0, false, err + } + } + + if _, err := h.DB.CreateChatMessage(sessionID, "user", lastUserMessage, ""); err != nil { + return sessionID, false, err + } + return sessionID, true, nil +} + +func writeChatError(w http.ResponseWriter, message string, status int, sessionID int64) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(chatErrorResponse{Error: message, SessionID: sessionID}) } // optimizeChatContext reduces the chat context to save tokens while preserving important information