diff --git a/CHANGELOG.md b/CHANGELOG.md index be755a3b3..7e84e0c19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ All notable changes to MrRSS will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.3.28] - 2026-08-29 + +### Added + +- Persist article AI chat exchanges in the existing chat session tables so conversations remain accessible after creating a new conversation, switching sessions, or restarting the app. (#1038) (@marcomarcogd) + +### Changed + +- Makes AI search results explainable and navigable. (#1027) (@marcomarcogd) +- Updates the AI usage card immediately while the user edits the token limit and refreshes actual usage while the AI settings page remains visible. (#1028) (@marcomarcogd) +- Classifies AI provider and transport failures into stable, user-facing error codes and short localized messages. (#1034) (@marcomarcogd) + +### Fixed + +- Makes SaveArticles atomic under SQLite write contention. (#1029) (@marcomarcogd) +- Keep toast notifications inside narrow application windows even when messages contain long continuous text. (#1036) (@marcomarcogd) +- Restore a stable, aligned feed-management table in Settings and keep row interaction inside the settings workflow. (#1023) (@marcomarcogd) + ## [1.3.27] - 2026-08-22 ### Added diff --git a/README.md b/README.md index 794dc1c35..913afecb5 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ English | 简体中文
-[](https://github.com/DevXDojo/MrRSS/releases) +[](https://github.com/DevXDojo/MrRSS/releases) [](LICENSE) [](https://go.dev/) [](https://wails.io/) diff --git a/README_zh.md b/README_zh.md index 60fb90468..d34e7e82c 100644 --- a/README_zh.md +++ b/README_zh.md @@ -8,7 +8,7 @@ English | 简体中文 -[](https://github.com/DevXDojo/MrRSS/releases) +[](https://github.com/DevXDojo/MrRSS/releases) [](LICENSE) [](https://go.dev/) [](https://wails.io/) diff --git a/build/config.yml b/build/config.yml index 2b4ef5184..3e19293f8 100644 --- a/build/config.yml +++ b/build/config.yml @@ -11,7 +11,7 @@ info: description: "A modern, standalone RSS reader" # The application description copyright: "Copyright © 2026" # Copyright text comments: "Built with Wails" # Comments - version: "1.3.27" # The application version + version: "1.3.28" # The application version # iOS build configuration (uncomment to customise iOS project generation) # Note: Keys under `ios` OVERRIDE values under `info` when set. @@ -21,7 +21,7 @@ info: # # The display name shown under the app icon (CFBundleDisplayName/CFBundleName) # displayName: "MrRSS" # # The app version to embed in Info.plist (CFBundleShortVersionString/CFBundleVersion) -# version: "1.3.27" +# version: "1.3.28" # # The company/organisation name for templates and project settings # company: "Ch3nyang" # # Additional comments to embed in Info.plist metadata diff --git a/build/linux/Taskfile.yml b/build/linux/Taskfile.yml index ce2a09501..6cd23c03c 100644 --- a/build/linux/Taskfile.yml +++ b/build/linux/Taskfile.yml @@ -124,7 +124,7 @@ tasks: cmds: - cd {{.BIN_DIR}} && tar -czf {{.APP_NAME}}-{{.VERSION}}-linux-{{.ARCH}}.tar.gz {{.APP_NAME}} vars: - VERSION: '1.3.27' + VERSION: '1.3.28' ARCH: '{{.ARCH | default "amd64"}}' generate:dotdesktop: diff --git a/build/linux/nfpm/nfpm.yaml b/build/linux/nfpm/nfpm.yaml index 0a39500b3..c35c35ede 100644 --- a/build/linux/nfpm/nfpm.yaml +++ b/build/linux/nfpm/nfpm.yaml @@ -6,7 +6,7 @@ name: "MrRSS" arch: ${GOARCH} platform: "linux" -version: "1.3.27" +version: "1.3.28" section: "default" priority: "extra" maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}> diff --git a/docs/SERVER_MODE/swagger.json b/docs/SERVER_MODE/swagger.json index d7bbfd6a0..7a8de4e64 100644 --- a/docs/SERVER_MODE/swagger.json +++ b/docs/SERVER_MODE/swagger.json @@ -12,7 +12,7 @@ "name": "GPL-3.0", "url": "https://www.gnu.org/licenses/gpl-3.0.en.html" }, - "version": "1.3.27" + "version": "1.3.28" }, "host": "localhost:1234", "basePath": "/api", diff --git a/frontend/cypress/e2e/article-operations.cy.ts b/frontend/cypress/e2e/article-operations.cy.ts index 5b17e4405..29ae89cc3 100644 --- a/frontend/cypress/e2e/article-operations.cy.ts +++ b/frontend/cypress/e2e/article-operations.cy.ts @@ -163,4 +163,300 @@ describe('Article Operations', () => { } }); }); + + it('should explain AI search results and keep list and card navigation in search context', () => { + const settingsState: RecordBody for search result ${req.query.id}
`, cached: true }, + }); + }).as('searchArticleContent'); + cy.intercept('POST', '/api/articles/read*', { statusCode: 200, body: { success: true } }); + cy.intercept('POST', '/api/articles/favorite*', { + statusCode: 200, + body: { success: true }, + }); + cy.intercept('POST', '/api/articles/toggle-read-later*', { + statusCode: 200, + body: { success: true }, + }); + + cy.window().then((win) => win.localStorage.setItem('showOnlyUnread', 'true')); + cy.reload(); + cy.wait('@searchFeeds'); + cy.wait('@timelineArticles'); + + cy.get('input[placeholder="Describe what you want to find..."]').type('privacy'); + cy.contains('button', /^AI Search$/).click(); + cy.wait('@aiSearch'); + cy.contains('Title match').should('be.visible'); + cy.contains('Summary match').should('be.visible'); + cy.contains('This privacy excerpt explains result 1').should('be.visible'); + + cy.get('[data-article-id="101"]').click(); + cy.wait('@searchArticleContent'); + cy.contains('Body for search result 101').should('be.visible'); + + // The next result is marked read when opened, but must remain selected and + // renderable while the unread-only preference is active. + cy.get('button[title="Next Article"]').click(); + cy.wait('@searchArticleContent'); + cy.contains('Body for search result 102').should('be.visible'); + cy.get('[data-article-id="102"]').should('exist'); + + // Global shortcuts must use the same ordered search context instead of the + // separately paginated timeline. + cy.get('body').trigger('keydown', { key: 'j' }); + cy.wait('@searchArticleContent'); + cy.contains('Body for search result 103').should('be.visible'); + cy.get('button[title="Previous Article"]').click(); + cy.wait('@searchArticleContent'); + cy.contains('Body for search result 102').should('be.visible'); + + cy.contains('button', /^Clear$/).click(); + cy.get('input[placeholder="Describe what you want to find..."]').type('nothing matches'); + cy.contains('button', /^AI Search$/).click(); + cy.wait('@aiSearch'); + cy.contains('No articles found matching your search').should('be.visible'); + + // Card mode uses ArticleDetailModal, which must use the same navigation + // context instead of hiding navigation for off-page search results. + cy.then(() => { + settingsState.layout_mode = 'card'; + }); + cy.reload(); + cy.wait('@searchFeeds'); + cy.wait('@timelineArticles'); + cy.get('input[placeholder="Describe what you want to find..."]').type('privacy'); + cy.contains('button', /^AI Search$/).click(); + cy.wait('@aiSearch'); + cy.get('.article-card-item[data-article-id="101"]').click(); + cy.wait('@searchArticleContent'); + cy.contains('Body for search result 101').should('be.visible'); + cy.get('button[title="Next Article"]').should('be.visible').click(); + 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: RecordArticle 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/cypress/e2e/feed-management.cy.ts b/frontend/cypress/e2e/feed-management.cy.ts index 4736352d8..a650d593a 100644 --- a/frontend/cypress/e2e/feed-management.cy.ts +++ b/frontend/cypress/e2e/feed-management.cy.ts @@ -15,26 +15,39 @@ describe('Feed Management', () => { it('should add a new feed', () => { // Look for add feed button in the sidebar footer (+ icon) - cy.get('button').filter('[title="Add Feed"], [title="添加订阅"]').should('exist').click({ force: true }); + cy.get('button') + .filter('[title="Add Feed"], [title="添加订阅"]') + .should('exist') + .click({ force: true }); // Wait for add feed modal to appear cy.wait(1000); // Check if modal opened cy.get('body').then(($body) => { - if ($body.find(/add.*feed|添加.*feed|add.*subscription/i).length > 0 || - $body.find('[class*="modal"]').length > 0) { + if ( + $body.find(/add.*feed|添加.*feed|add.*subscription/i).length > 0 || + $body.find('[class*="modal"]').length > 0 + ) { cy.log('Add feed modal opened'); // Try to fill in the feed URL if input exists cy.get('body').then(($body2) => { if ($body2.find('input[type="url"], input[type="text"]').length > 0) { - cy.get('input[type="url"], input[type="text"]').first().type('https://example.com/feed.xml'); + cy.get('input[type="url"], input[type="text"]') + .first() + .type('https://example.com/feed.xml'); // Submit the form if submit button exists cy.get('body').then(($body3) => { - if ($body3.find('button').filter((i, el) => /add|submit|确定|添加/i.test(el.textContent || '')).length > 0) { - cy.get('button').contains(/add|submit|确定|添加/i).click({ force: true }); + if ( + $body3 + .find('button') + .filter((i, el) => /add|submit|确定|添加/i.test(el.textContent || '')).length > 0 + ) { + cy.get('button') + .contains(/add|submit|确定|添加/i) + .click({ force: true }); } }); } @@ -165,7 +178,10 @@ describe('Feed Management', () => { // Look for search input in the sidebar cy.get('body').then(($body) => { - if ($body.find('input[type="search"], input[placeholder*="search"], input[placeholder*="搜索"]').length > 0) { + if ( + $body.find('input[type="search"], input[placeholder*="search"], input[placeholder*="搜索"]') + .length > 0 + ) { cy.get('input[type="search"], input[placeholder*="search"], input[placeholder*="搜索"]') .first() .type('test'); @@ -181,3 +197,257 @@ describe('Feed Management', () => { }); }); }); + +describe('Feed Management settings list', () => { + beforeEach(() => { + cy.fixture('settings').then((settings) => { + cy.intercept('/api/**', { statusCode: 200, body: {} }); + cy.intercept('GET', '/api/settings', { statusCode: 200, body: settings }); + 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: [] }); + 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: false } }); + }); + }); + + const openFeedSettings = () => { + cy.get('button') + .filter('[title="Settings"], [title="设置"]') + .should('exist') + .click({ force: true }); + cy.get('[data-settings-modal="true"] .sidebar-tab-btn') + .contains(/Feeds|订阅源|订阅/i) + .click({ force: true }); + cy.get('[data-testid="feed-list"]').should('be.visible'); + }; + + const makeFeed = (id: number, title: string, url = `https://feeds.example.com/${id}`) => ({ + id, + title, + url, + category: id % 2 ? 'Technology/Frontend' : 'News', + last_fetched_at: '2026-08-17T08:00:00Z', + latest_article_time: '2026-08-17T07:00:00Z', + articles_per_month: id, + last_update_status: id % 3 === 0 ? 'failed' : 'success', + last_error: id % 3 === 0 ? 'connection timeout' : '', + }); + + it('keeps API order and renders every feed column for 120 rows without page overflow', () => { + const longChineseTitle = `这是一个用于验证订阅源列表不会被超长中文标题撑开的标题${'非常长'.repeat(20)}`; + const longEnglishTitle = `An exceptionally long English feed title ${'with more words '.repeat(20)}`; + const feeds = [ + makeFeed( + 900, + longChineseTitle, + `https://example.com/${'very-long-path/'.repeat(20)}feed.xml` + ), + makeFeed(100, longEnglishTitle), + { + ...makeFeed(500, 'No icon feed', 'rsshub://invalid-icon-source'), + category: '', + latest_article_time: undefined, + articles_per_month: undefined, + }, + ...Array.from({ length: 117 }, (_, index) => + makeFeed(index + 1, `Feed ${String(index + 1).padStart(3, '0')}`) + ), + ]; + + cy.intercept('GET', '/api/feeds', { statusCode: 200, body: feeds }).as('layoutFeeds'); + cy.viewport(960, 720); + cy.visit('/'); + cy.wait('@layoutFeeds'); + openFeedSettings(); + + cy.get('[data-testid="feed-row"]').should('have.length', 120); + cy.get('[data-testid="feed-row"]').eq(0).should('have.attr', 'data-feed-id', '900'); + cy.get('[data-testid="feed-row"]').eq(1).should('have.attr', 'data-feed-id', '100'); + cy.get('[data-testid="feed-row"]').eq(2).should('have.attr', 'data-feed-id', '500'); + + cy.get('[data-testid="feed-title"]').eq(0).should('have.attr', 'title', longChineseTitle); + cy.get('[data-testid="feed-title"]').eq(1).should('have.attr', 'title', longEnglishTitle); + cy.get('[data-testid="feed-source"]') + .eq(0) + .should('have.attr', 'title') + .and('include', 'very-long-path'); + cy.get('[data-feed-id="500"] [data-testid="feed-icon-fallback"]').should('exist'); + cy.get('[data-testid="feed-column-name"]').should('be.visible'); + cy.get('[data-testid="feed-column-category"]').should('be.visible'); + cy.get('[data-testid="feed-column-latest"]').should('exist'); + cy.get('[data-testid="feed-column-frequency"]').should('exist'); + cy.get('[data-testid="feed-column-status"]').should('exist'); + cy.get('[data-testid="feed-column-actions"]').should('exist'); + + cy.get('[data-feed-id="900"] [data-testid="feed-category"]') + .should('have.attr', 'title', 'News') + .and('contain.text', 'News'); + cy.get('[data-feed-id="900"] [data-testid="feed-latest"]') + .should('have.attr', 'title', '2026-08-17T07:00:00Z') + .and('not.be.empty'); + cy.get('[data-feed-id="900"] [data-testid="feed-frequency"]').should('have.text', '900'); + cy.get('[data-feed-id="500"] [data-testid="feed-category"]').should('have.text', '-'); + cy.get('[data-feed-id="500"] [data-testid="feed-latest"]').should('have.text', '-'); + cy.get('[data-feed-id="500"] [data-testid="feed-frequency"]').should('have.text', '0'); + + cy.get('[data-testid="feed-row"]') + .first() + .then(($row) => { + const height = $row[0].getBoundingClientRect().height; + expect(height).to.be.at.least(60); + expect(height).to.be.at.most(72); + }); + cy.get('[data-testid="feed-list"]').then(($list) => { + expect($list[0].scrollWidth).to.be.greaterThan($list[0].clientWidth); + }); + cy.document().then((document) => { + expect(document.documentElement.scrollWidth).to.be.at.most( + document.documentElement.clientWidth + 1 + ); + }); + }); + + it('moves optional sorting into one compact menu without changing the stored order', () => { + const feeds = [makeFeed(30, 'Zebra'), makeFeed(10, 'Alpha'), makeFeed(20, 'Beta')]; + cy.intercept('GET', '/api/feeds', { statusCode: 200, body: feeds }).as('sortableFeeds'); + cy.visit('/'); + cy.wait('@sortableFeeds'); + openFeedSettings(); + + cy.get('[data-testid="feed-row"]').first().should('have.attr', 'data-feed-id', '30'); + cy.get('[data-testid="feed-sort-select"] button.select-trigger').click(); + cy.contains('.select-option', /Name|名称/).click({ force: true }); + cy.get('[data-testid="feed-title"]').first().should('have.text', 'Alpha'); + + cy.get('[data-testid="feed-sort-direction"]').click(); + cy.get('[data-testid="feed-title"]').first().should('have.text', 'Zebra'); + + cy.get('[data-testid="feed-sort-select"] button.select-trigger').click(); + cy.contains('.select-option', /Original order|原始顺序/).click({ force: true }); + cy.get('[data-testid="feed-row"]').first().should('have.attr', 'data-feed-id', '30'); + }); + + it('shows a retryable error state instead of breaking the settings layout', () => { + cy.intercept('GET', '/api/feeds', { + statusCode: 503, + body: { error: 'service unavailable' }, + }).as('failedFeeds'); + cy.visit('/'); + cy.wait('@failedFeeds'); + openFeedSettings(); + + cy.get('[data-testid="feed-load-error"]').should('be.visible'); + + cy.intercept('GET', '/api/feeds', { + statusCode: 200, + body: [makeFeed(42, 'Recovered feed')], + }).as('recoveredFeeds'); + cy.get('[data-testid="feed-load-error"] button') + .contains(/Retry|重试/) + .click(); + cy.wait('@recoveredFeeds'); + cy.get('[data-testid="feed-row"]').should('have.length', 1); + cy.get('[data-testid="feed-title"]').should('have.text', 'Recovered feed'); + }); + + it('keeps status and existing edit/delete controls aligned', () => { + cy.intercept('GET', '/api/feeds', { + statusCode: 200, + body: [makeFeed(1, 'Healthy feed'), makeFeed(3, 'Failed feed')], + }).as('statusFeeds'); + cy.visit('/'); + cy.wait('@statusFeeds'); + openFeedSettings(); + + cy.get('[data-testid="feed-row"]').each(($row) => { + cy.wrap($row).find('[data-testid="feed-edit"]').should('exist'); + cy.wrap($row).find('[data-testid="feed-delete"]').should('exist'); + }); + cy.get('[data-feed-id="1"] [title*="success"], [data-feed-id="1"] [title*="成功"]').should( + 'exist' + ); + cy.get('[data-feed-id="3"] [title]') + .filter('[title*="timeout"], [title*="超时"]') + .should('exist'); + }); + + it('opens edit from an editable row without closing Settings or reacting to locked rows', () => { + const lockedFeed = { ...makeFeed(2, 'FreshRSS feed'), is_freshrss_source: true }; + cy.intercept('GET', '/api/feeds', { + statusCode: 200, + body: [makeFeed(1, 'Editable feed'), lockedFeed], + }).as('editableFeeds'); + cy.visit('/'); + cy.wait('@editableFeeds'); + openFeedSettings(); + + cy.get('[data-settings-modal="true"] [data-feed-id="1"] input[type="checkbox"]').click(); + cy.contains('h3', /Edit Feed|编辑订阅/).should('not.exist'); + + cy.get('[data-settings-modal="true"] [data-feed-id="1"]').click(); + cy.get('[data-settings-modal="true"]').should('exist'); + cy.contains('h3', /Edit Feed|编辑订阅/) + .should('be.visible') + .parent() + .parent() + .find('button') + .click(); + + cy.get('[data-settings-modal="true"]').should('be.visible'); + cy.contains('h3', /Edit Feed|编辑订阅/).should('not.exist'); + cy.get('[data-settings-modal="true"] [data-feed-id="2"]').click(); + cy.contains('h3', /Edit Feed|编辑订阅/).should('not.exist'); + cy.get('[data-settings-modal="true"]').should('be.visible'); + }); + + it('keeps Manage Tags open for empty and legacy null tag responses', () => { + cy.intercept('GET', '/api/feeds', { + statusCode: 200, + body: [makeFeed(1, 'Tagged feed')], + }).as('tagFeeds'); + cy.intercept('GET', '/api/tags', { statusCode: 200, body: null }).as('nullTags'); + cy.visit('/'); + cy.wait('@tagFeeds'); + openFeedSettings(); + + cy.contains('button', /Manage Tags|管理标签/).click(); + cy.get('[data-testid="tag-management-empty"]').should('be.visible'); + cy.get('[data-settings-modal="true"]').should('exist'); + cy.contains('h3', /Manage Tags|管理标签/).parent().parent().find('button').click(); + cy.get('[data-settings-modal="true"]').should('be.visible'); + }); + + it('keeps the tag dialog open and retries after a tag request failure', () => { + let failTagRequests = false; + cy.intercept('GET', '/api/feeds', { + statusCode: 200, + body: [makeFeed(1, 'Tagged feed')], + }).as('retryTagFeeds'); + cy.intercept('GET', '/api/tags', (request) => { + request.reply( + failTagRequests + ? { statusCode: 503, body: { error: 'service unavailable' } } + : { statusCode: 200, body: [] } + ); + }); + cy.visit('/'); + cy.wait('@retryTagFeeds'); + openFeedSettings(); + cy.then(() => { + failTagRequests = true; + }); + + cy.contains('button', /Manage Tags|管理标签/).click(); + cy.get('[data-testid="tag-management-error"]').should('be.visible'); + cy.get('[data-settings-modal="true"]').should('exist'); + cy.then(() => { + failTagRequests = false; + }); + cy.get('[data-testid="tag-management-error"] button') + .contains(/Retry|重试/) + .click(); + cy.get('[data-testid="tag-management-empty"]').should('be.visible'); + }); +}); diff --git a/frontend/cypress/e2e/settings-persistence.cy.ts b/frontend/cypress/e2e/settings-persistence.cy.ts index 638fec300..8fafebf78 100644 --- a/frontend/cypress/e2e/settings-persistence.cy.ts +++ b/frontend/cypress/e2e/settings-persistence.cy.ts @@ -487,4 +487,65 @@ describe('Settings Persistence', () => { expect(layoutMode).to.eq('normal'); }); }); + + it('updates the AI usage limit immediately and refreshes usage while visible', () => { + let usage = 120; + let savedLimit = '20000'; + let usageRequests = 0; + + cy.intercept('GET', '/api/ai/profiles', { statusCode: 200, body: [] }); + cy.intercept('GET', '/api/ai-usage', (req) => { + usageRequests += 1; + const limit = Number(savedLimit); + req.reply({ + statusCode: 200, + body: { + usage, + limit, + limit_reached: limit > 0 && usage >= limit, + }, + }); + }).as('getAIUsage'); + cy.intercept('POST', '/api/settings', (req) => { + if (req.body.ai_usage_limit !== undefined) { + savedLimit = String(req.body.ai_usage_limit); + } + req.reply({ statusCode: 200, body: req.body }); + }).as('saveAIUsageLimit'); + + cy.clock(); + cy.get('button') + .filter('[title="Settings"], [title="设置"]') + .should('exist') + .click({ force: true }); + cy.wait('@getSettings'); + cy.contains('button', /^AI$/i).click({ force: true }); + cy.wait('@getAIUsage'); + + cy.get('[data-testid="ai-usage-status"]').should('contain.text', '120 / 20,000'); + cy.get('[data-testid="ai-usage-limit-input"]').clear().type('400'); + cy.get('[data-testid="ai-usage-status"]').should('contain.text', '120 / 400'); + + cy.tick(500); + cy.wait('@saveAIUsageLimit').its('request.body.ai_usage_limit').should('eq', '400'); + cy.wait('@getAIUsage'); + cy.get('[data-testid="ai-usage-status"]').should('contain.text', '120 / 400'); + + cy.then(() => { + usage = 300; + }); + cy.tick(15_000); + cy.wait('@getAIUsage'); + cy.get('[data-testid="ai-usage-status"]').should('contain.text', '300 / 400'); + + let requestsBeforeUnmount = 0; + cy.then(() => { + requestsBeforeUnmount = usageRequests; + }); + cy.contains('button', /^(General|常规)$/i).click({ force: true }); + cy.tick(30_000); + cy.then(() => { + expect(usageRequests).to.eq(requestsBeforeUnmount); + }); + }); }); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1015cbe9f..5e8248367 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mrrss-frontend", - "version": "1.3.27", + "version": "1.3.28", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mrrss-frontend", - "version": "1.3.27", + "version": "1.3.28", "dependencies": { "@eslint/js": "^10.0.1", "@phosphor-icons/vue": "^2.2.1", @@ -28,9 +28,9 @@ "@vitejs/plugin-vue": "^6.0.8", "@vue/eslint-config-prettier": "^10.2.0", "@vue/test-utils": "^2.4.11", - "@wailsio/runtime": "^3.0.0-beta.8", + "@wailsio/runtime": "^3.0.0-beta.11", "autoprefixer": "^10.5.4", - "cypress": "^15.20.1", + "cypress": "^15.21.0", "eslint": "^10.8.1", "eslint-plugin-vue": "^10.10.0", "husky": "^9.1.7", @@ -40,8 +40,8 @@ "postcss": "^8.5.26", "prettier": "^3.9.6", "typescript": "^7.0.2", - "vite": "^8.2.1", - "vitest": "^4.1.10", + "vite": "^8.2.2", + "vitest": "^4.1.11", "vue-eslint-parser": "^10.4.1" } }, @@ -131,7 +131,6 @@ "integrity": "sha512-5FgxM4dLQpMJHSiVATk8foW263dVHQHBVpXYiimNECVWG01f4nFyEbQixeT6Mwvg7TayREJ2gpKl3o2RoMdnqw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^8.0.0", "@babel/generator": "^8.0.0", @@ -703,7 +702,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -752,7 +750,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -850,7 +847,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" @@ -863,7 +859,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -2883,7 +2878,6 @@ "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~8.3.0" } @@ -2916,7 +2910,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2933,7 +2926,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2950,7 +2942,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2967,7 +2958,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2984,7 +2974,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3001,7 +2990,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3018,7 +3006,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3035,7 +3022,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3052,7 +3038,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3069,7 +3054,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3086,7 +3070,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3103,7 +3086,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3120,7 +3102,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3137,7 +3118,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3154,7 +3134,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3171,7 +3150,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3188,7 +3166,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3205,7 +3182,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3222,7 +3198,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3239,7 +3214,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3267,16 +3241,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", - "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -3285,13 +3259,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", - "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.10", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -3312,9 +3286,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", - "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { @@ -3325,13 +3299,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", - "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.10", + "@vitest/utils": "4.1.11", "pathe": "^2.0.3" }, "funding": { @@ -3339,14 +3313,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", - "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -3355,9 +3329,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", - "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", "funding": { @@ -3365,13 +3339,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", - "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", + "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -3415,7 +3389,6 @@ "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.41.tgz", "integrity": "sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-core": "3.5.41", "@vue/shared": "3.5.41" @@ -3469,6 +3442,7 @@ "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.2.1.tgz", "integrity": "sha512-FIGIuq3AWReEpbAHY/cRGeHDfI0qOb8OCQ3YjbEAX04uaxIDbGc9rhkbVcG7rnfHPXE3RsU5KrWOu9V/okd8AQ==", "license": "MIT", + "peer": true, "dependencies": { "@vue/devtools-shared": "^8.2.1", "birpc": "^2.6.1", @@ -3480,7 +3454,8 @@ "version": "8.2.1", "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.2.1.tgz", "integrity": "sha512-Fkac7lUdGReh6pVOi3AYPRGe82LQqRmAfThW7RRligOAP0ZA/Z1z9XLHDM9dv34pV2HRc79DK8uKPeG2fLnA/g==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@vue/eslint-config-prettier": { "version": "10.2.0", @@ -3567,9 +3542,9 @@ } }, "node_modules/@wailsio/runtime": { - "version": "3.0.0-beta.8", - "resolved": "https://registry.npmjs.org/@wailsio/runtime/-/runtime-3.0.0-beta.8.tgz", - "integrity": "sha512-c9PZJcOR9z1a6cxtBS2q5cygNajlxDE8Oxv/vvcTFYRbZSoOGBszq4uzlkPTOd0Bot1xkM81jnpaBgKWRpBh2g==", + "version": "3.0.0-beta.11", + "resolved": "https://registry.npmjs.org/@wailsio/runtime/-/runtime-3.0.0-beta.11.tgz", + "integrity": "sha512-pMHts/Qunm4jPh67f+FCGb4YngMag7VSxol0Gw+8Ka5E/qn4StGX0w72haONH8bjgJZYnwgZvydIKIcoMsnAgA==", "dev": true, "license": "MIT" }, @@ -3589,7 +3564,6 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "devOptional": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3854,6 +3828,7 @@ "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/antfu" } @@ -3909,7 +3884,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001803", @@ -4058,6 +4032,27 @@ "node": ">=8" } }, + "node_modules/chrome-remote-interface": { + "version": "0.33.3", + "resolved": "https://registry.npmjs.org/chrome-remote-interface/-/chrome-remote-interface-0.33.3.tgz", + "integrity": "sha512-zNnn0prUL86Teru6UCAZ1yU1XeXljHl3gj7OrfPcarEfU62OUU4IujDPdTDW3dAWwRqN3ZMG/Chhkh2gPL/wiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "2.11.x", + "ws": "^7.2.0" + }, + "bin": { + "chrome-remote-interface": "bin/client.js" + } + }, + "node_modules/chrome-remote-interface/node_modules/commander": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.11.0.tgz", + "integrity": "sha512-b0553uYA5YAEGgyYIGYROzKQ7X5RAqedkfjiZxwi0kL1g3bOaBNNZfYkzt/CL0umgD5wc9Jec2FbB98CjkMRvQ==", + "dev": true, + "license": "MIT" + }, "node_modules/ci-info": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", @@ -4351,13 +4346,12 @@ "license": "MIT" }, "node_modules/cypress": { - "version": "15.20.1", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-15.20.1.tgz", - "integrity": "sha512-7GV3O7vQQG+EVVv9BGs6lCHY49x25jVi/z1+zdjuuq/o3eZeDwmGPJpRYnOWeGvQxTCtkydXWHBPhV8xuGXPLg==", + "version": "15.21.0", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-15.21.0.tgz", + "integrity": "sha512-6uPy5GUjao97t/QDIwlPyJz4JUqPpFIPq5lNazN95LSA3ynJoYC1sHZ3W+aj4f+K1OFGbMfno0+OX2Gx0OmlPA==", "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@cypress/request": "^4.0.0", "@cypress/xvfb": "^1.2.4", @@ -4370,6 +4364,7 @@ "buffer": "^5.7.1", "cachedir": "^2.4.0", "chalk": "^4.1.0", + "chrome-remote-interface": "0.33.3", "ci-info": "^4.1.0", "cli-table3": "0.6.1", "commander": "^6.2.1", @@ -4784,7 +4779,6 @@ "integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==", "devOptional": true, "license": "MIT", - "peer": true, "workspaces": [ "packages/*" ], @@ -4844,7 +4838,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -5764,7 +5757,8 @@ "version": "5.5.3", "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", @@ -6006,7 +6000,7 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -7414,7 +7408,8 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/performance-now": { "version": "2.1.0", @@ -7496,7 +7491,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", @@ -7543,7 +7537,6 @@ "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -8374,7 +8367,6 @@ "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc" }, @@ -8515,17 +8507,16 @@ } }, "node_modules/vite": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", - "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", - "postcss": "^8.5.25", - "rolldown": "~1.2.1", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", "tinyglobby": "^0.2.17" }, "bin": { @@ -8542,7 +8533,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.4.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -8594,19 +8585,19 @@ } }, "node_modules/vitest": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", - "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.10", - "@vitest/mocker": "4.1.10", - "@vitest/pretty-format": "4.1.10", - "@vitest/runner": "4.1.10", - "@vitest/snapshot": "4.1.10", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -8634,12 +8625,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.10", - "@vitest/browser-preview": "4.1.10", - "@vitest/browser-webdriverio": "4.1.10", - "@vitest/coverage-istanbul": "4.1.10", - "@vitest/coverage-v8": "4.1.10", - "@vitest/ui": "4.1.10", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -8688,7 +8679,6 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.41.tgz", "integrity": "sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.41", "@vue/compiler-sfc": "3.5.41", @@ -8718,7 +8708,6 @@ "integrity": "sha512-Gk6gRDj0n/fkRa3C3l0bBheoBckUq/Rs0F/TvMWIS6nzzx67amAViMe9CkNgsP2tXyQONvGiHQESHwFtZ3aYDA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "debug": "^4.4.0", "eslint-scope": "^8.2.0 || ^9.0.0", @@ -8989,6 +8978,28 @@ "dev": true, "license": "ISC" }, + "node_modules/ws": { + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", + "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index c41f0fd83..83e4f26f4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mrrss-frontend", "private": true, - "version": "1.3.27", + "version": "1.3.28", "type": "module", "scripts": { "dev": "vite", @@ -47,9 +47,9 @@ "@vitejs/plugin-vue": "^6.0.8", "@vue/eslint-config-prettier": "^10.2.0", "@vue/test-utils": "^2.4.11", - "@wailsio/runtime": "^3.0.0-beta.8", + "@wailsio/runtime": "^3.0.0-beta.11", "autoprefixer": "^10.5.4", - "cypress": "^15.20.1", + "cypress": "^15.21.0", "eslint": "^10.8.1", "eslint-plugin-vue": "^10.10.0", "husky": "^9.1.7", @@ -59,8 +59,8 @@ "postcss": "^8.5.26", "prettier": "^3.9.6", "typescript": "^7.0.2", - "vite": "^8.2.1", - "vitest": "^4.1.10", + "vite": "^8.2.2", + "vitest": "^4.1.11", "vue-eslint-parser": "^10.4.1" }, "lint-staged": { diff --git a/frontend/src/App.test.ts b/frontend/src/App.test.ts index 2605314e5..6ab727990 100644 --- a/frontend/src/App.test.ts +++ b/frontend/src/App.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; import { nextTick } from 'vue'; import { mount } from '@vue/test-utils'; import { createPinia } from 'pinia'; @@ -15,6 +16,14 @@ const createStub = (name: string) => ({ }); describe('App', () => { + it('keeps long toast messages inside narrow viewports', () => { + const toast = readFileSync('src/components/common/Toast.vue', 'utf8'); + expect(toast).toContain('calc(100vw-2rem)'); + expect(toast).toContain('overflow-wrap: anywhere'); + expect(toast).toContain('min-w-0 flex-1'); + expect(toast).toContain('shrink-0'); + }); + it('renders and reacts to interface typography settings', async () => { setSettingsFromRawData({}); const pinia = createPinia(); diff --git a/frontend/src/components/article/AISearchBar.vue b/frontend/src/components/article/AISearchBar.vue index d1d607087..97019445e 100644 --- a/frontend/src/components/article/AISearchBar.vue +++ b/frontend/src/components/article/AISearchBar.vue @@ -3,6 +3,7 @@ import { ref, computed } from 'vue'; import { useI18n } from 'vue-i18n'; import { PhMagnifyingGlass, PhX, PhSparkle, PhSpinner } from '@phosphor-icons/vue'; import type { Article } from '@/types/models'; +import { getAIErrorMessage } from '@/utils/aiError'; const { t } = useI18n(); @@ -37,7 +38,7 @@ async function performAISearch() { const data = await response.json(); if (!data.success) { - errorMessage.value = data.error || t('aiSearch.searchFailed'); + errorMessage.value = getAIErrorMessage(data, response.status); window.showToast(errorMessage.value, 'error'); return; } @@ -60,6 +61,13 @@ async function performAISearch() { author: item.author as string, translated_title: item.translated_title as string, summary: item.summary as string, + original_summary: item.original_summary as string, + relevance_score: item.relevance_score as number, + matched_terms: Array.isArray(item.matched_terms) ? (item.matched_terms as string[]) : [], + matched_fields: Array.isArray(item.matched_fields) + ? (item.matched_fields as Article['matched_fields']) + : [], + excerpt: typeof item.excerpt === 'string' ? item.excerpt : '', })); hasResults.value = true; 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(() => {