Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 150 additions & 0 deletions frontend/cypress/e2e/article-operations.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
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<Record<string, unknown>> = [];
const messages = new Map<number, Array<Record<string, unknown>>>();
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: '<p>Article context for AI chat</p>', 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');
});

});
57 changes: 35 additions & 22 deletions frontend/src/components/article/ArticleChatPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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',
Expand All @@ -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'),
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand All @@ -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();
Expand Down Expand Up @@ -367,8 +372,10 @@ const currentSessionTitle = computed(() => {
<PhChatCircleText :size="20" class="text-accent" />
<button
class="flex items-center gap-1 text-sm font-medium hover:text-accent transition-colors"
:disabled="isLoading"
:title="t('article.chat.switchSession')"
@click="showSessions = !showSessions"
data-testid="chat-session-switcher"
@click.stop="showSessions = !showSessions"
>
<span>{{ currentSessionTitle }}</span>
<PhClockCounterClockwise :size="16" />
Expand All @@ -377,8 +384,10 @@ const currentSessionTitle = computed(() => {
<div class="flex items-center gap-1">
<button
class="p-1 hover:bg-bg-tertiary rounded-lg transition-colors"
:disabled="isLoading"
:title="t('article.chat.newChat')"
@click="createNewSession"
data-testid="chat-new-session"
@click.stop="createNewSession"
>
<PhPlus :size="18" class="text-text-secondary" />
</button>
Expand Down Expand Up @@ -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)"
>
<PhChatCircleText :size="16" class="text-text-secondary" />
<div v-if="editingSessionId === session.id" class="flex-1 flex items-center gap-1">
Expand Down Expand Up @@ -490,9 +503,9 @@ const currentSessionTitle = computed(() => {
</div>
<!-- Message content with pre-rendered HTML from backend -->
<div
v-if="msg.role === 'assistant'"
v-if="msg.role === 'assistant' && msg.html"
class="prose prose-sm max-w-none"
v-html="msg.html || msg.content"
v-html="msg.html"
></div>
<div v-else class="whitespace-pre-wrap break-words">{{ msg.content }}</div>
</div>
Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?',
Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ const zh: TranslationMessages = {
chat: {
aiChat: 'AI 聊天',
aiChatError: '无法获取 AI 响应,请重试。',
historySaveFailed: '回答已生成,但未能保存到历史记录。',
aiChatInputPlaceholder: '输入消息...',
aiChatWelcome: '请问关于这篇文章的任何问题!',
confirmDeleteSession: '确定要删除这个对话吗?',
Expand Down
33 changes: 33 additions & 0 deletions internal/database/article_db_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
4 changes: 2 additions & 2 deletions internal/database/chat_db.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Loading