From d49cc8a1c2a496a3dd9e5127edc458db3ba7c172 Mon Sep 17 00:00:00 2001 From: liujing Date: Mon, 20 Jul 2026 15:17:08 +0800 Subject: [PATCH] feat: add persistent session favorites --- README.md | 7 +- backend/src/index.js | 119 ++++++++++++-------- backend/src/metadata-db.js | 83 ++++++++++++++ backend/src/session-list.js | 173 +++++++++++++++++++++++++++++ backend/test/metadata-db.test.js | 110 ++++++++++++++++++ backend/test/session-list.test.js | 167 ++++++++++++++++++++++++++++ frontend/src/api/index.js | 15 ++- frontend/src/views/SessionList.vue | 88 ++++++++++++++- 8 files changed, 709 insertions(+), 53 deletions(-) create mode 100644 backend/src/session-list.js create mode 100644 backend/test/metadata-db.test.js create mode 100644 backend/test/session-list.test.js diff --git a/README.md b/README.md index 9cd5579..b972310 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ npm start ## 功能 - **五引擎会话列表** — 按引擎筛选(全部/Codex/Claude/Agy/OpenCode/Reasonix),搜索 ID、短 ID、路径、摘要 +- **会话收藏** — 星标收藏会话,收藏项全局置顶,支持只看收藏模式 - **会话详情** — 引擎专属元数据、消息时间线、工具调用统计 - **短 ID 解析** — 输入部分 ID(如 `019f448b`)自动匹配 - **一键恢复** — 复制引擎对应的恢复命令(`codex resume` / `claude --resume` / `agy --conversation` / `opencode --session` / `reasonix --resume`) @@ -51,9 +52,11 @@ Reasonix 数据目录默认是 `~/.reasonix`,可通过环境变量 `REASONIX_H | 路径 | 说明 | |---|---| | `GET /api/health` | 服务状态 + 五引擎健康 | -| `GET /api/sessions?engine=all\|codex\|claude\|agy\|opencode\|reasonix` | 会话列表 | -| `GET /api/sessions/:engine/:id` | 会话详情 | +| `GET /api/sessions?engine=all\|codex\|claude\|agy\|opencode\|reasonix` | 会话列表(支持 &favorite=1 只看收藏) | +| `GET /api/sessions/:engine/:id` | 会话详情(返回 is_favorite / favorited_at) | | `GET /api/sessions/:engine/:id/tools` | 会话内工具统计 | +| `PUT /api/sessions/:engine/:id/favorite` | 收藏会话(幂等) | +| `DELETE /api/sessions/:engine/:id/favorite` | 取消收藏(幂等) | | `DELETE /api/sessions/:engine/:id` | 删除会话(仅 OpenCode 引擎支持原始删除) | | `GET /api/tools?engine=...` | 全局工具排行 | | `GET /api/resolve/:prefix?engine=...` | 短 ID 解析 | diff --git a/backend/src/index.js b/backend/src/index.js index d75dd6c..b92785b 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -7,9 +7,10 @@ import { existsSync } from 'fs'; import { refreshEngines, listSessions, getSession, getSessionMessages, getToolUsage, resolvePrefix, getAllHealth, getEngine } from './engine/index.js'; import { getCodexHome, isDbReady } from './db.js'; import { getCacheInfo } from './sessions.js'; -import { initMetadataDb, setCustomName, deleteCustomName, getCustomNames, isMetadataReady } from './metadata-db.js'; +import { initMetadataDb, setCustomName, deleteCustomName, getCustomNames, isMetadataReady, setFavorite, deleteFavorite, getFavorites, deleteFavoriteBySession } from './metadata-db.js'; import { generateTitle } from './title-generator.js'; import { matchesSessionQuery, normalizeSearchText } from './search.js'; +import { getSessionList, getFavoriteEnrichedSession } from './session-list.js'; const __dirname = fileURLToPath(new URL('.', import.meta.url)); const app = express(); @@ -48,55 +49,25 @@ app.get('/api/health', (req, res) => { // ─── Sessions List (unified) ─────────────────────────────────────── app.get('/api/sessions', (req, res) => { try { - const { query, cwd, source, model, from, to, limit, offset, engine, status } = req.query; + const { query, cwd, source, model, from, to, limit, offset, engine, status, favorite } = req.query; const eng = engine || 'codex'; - const IS_STATUS_KEYWORD = source && ['success','failed','active','interrupted'].includes(source); - const statusFilter = status || (IS_STATUS_KEYWORD ? source : null); - const sourceParam = IS_STATUS_KEYWORD ? null : source; - - // Fetch all sessions from the registry, no filtering - const all = listSessions({ + // Use the centralized favorite-enriched listing + const result = getSessionList({ engine: eng, - limit: null, - offset: 0, + query, + cwd, + model, + source, + status, + from, + to, + favorite, + limit: Number(limit) || 50, + offset: Number(offset) || 0, }); - let list = all.sessions; - - // Merge custom names into ALL results before filtering - const customNames = getCustomNames(list); - list = list.map(s => ({ - ...s, - custom_name: customNames[`${s.engine}:${s.id}`] || null, - })); - - // Apply filters on the merged data - if (query) { - list = list.filter(s => matchesSessionQuery(s, query, { includeCustomName: true })); - } - if (cwd) list = list.filter(s => normalizeSearchText(s.cwd).includes(normalizeSearchText(cwd))); - if (model) list = list.filter(s => normalizeSearchText(s.model).includes(normalizeSearchText(model))); - if (sourceParam) list = list.filter(s => normalizeSearchText(s.source) === normalizeSearchText(sourceParam)); - if (statusFilter && statusFilter !== 'all') { - if (statusFilter === 'failed') { - list = list.filter(s => s.status === 'error' || s.status === 'interrupted'); - } else { - list = list.filter(s => s.status === statusFilter); - } - } - if (from) list = list.filter(s => (s.created_at || 0) >= Number(from)); - if (to) list = list.filter(s => (s.created_at || 0) <= Number(to)); - - // Sort by created_at desc - list.sort((a, b) => (b.created_at || 0) - (a.created_at || 0)); - - const total = list.length; - const off = Number(offset) || 0; - const lim = Math.min(Number(limit) || 50, 200); - const page = list.slice(off, off + lim); - - res.json({ sessions: page, total }); + res.json({ sessions: result.sessions, total: result.total }); } catch (err) { console.error('[API] /api/sessions error:', err); res.status(500).json({ error: err.message }); @@ -124,12 +95,12 @@ app.get('/api/sessions/:engine/:id', async (req, res) => { } catch {} } - const customName = isMetadataReady() ? getCustomNames([{ engine, id }])[`${engine}:${id}`] : null; + // Enrich with favorite and custom name metadata + const enriched = getFavoriteEnrichedSession(engine, id); res.json({ metadata: { - ...(metadata || { id, engine, error: 'Session not found' }), - custom_name: customName || null, + ...(enriched || { id, engine, error: 'Session not found' }), }, summary: { tool_call_count: metadata?.tool_call_count || 0, @@ -393,6 +364,7 @@ app.delete('/api/sessions/:engine/:id', async (req, res) => { // Clean up custom name metadata try { deleteCustomName(engine, id); + deleteFavoriteBySession(engine, id); } catch {} // Trigger refresh @@ -405,6 +377,57 @@ app.delete('/api/sessions/:engine/:id', async (req, res) => { } }); +// ─── Session Favorites ───────────────────────────────────────────── +app.put('/api/sessions/:engine/:id/favorite', (req, res) => { + try { + const { engine, id } = req.params; + if (!VALID_ENGINES.includes(engine)) { + return res.status(400).json({ error: `Invalid engine: ${engine}. Must be one of: ${VALID_ENGINES.join(', ')}` }); + } + + // Verify session exists + const e = getEngine(engine); + const session = e ? e.getSession(id) : null; + if (!session) { + return res.status(404).json({ error: 'Session not found' }); + } + + const favoritedAt = setFavorite(engine, id); + if (favoritedAt === null) { + return res.status(500).json({ error: '元数据库不可用,无法收藏' }); + } + res.json({ status: 'ok', is_favorite: true, favorited_at: favoritedAt }); + } catch (err) { + console.error('[API] favorite error:', err); + res.status(500).json({ error: err.message }); + } +}); + +app.delete('/api/sessions/:engine/:id/favorite', (req, res) => { + try { + const { engine, id } = req.params; + if (!VALID_ENGINES.includes(engine)) { + return res.status(400).json({ error: `Invalid engine: ${engine}. Must be one of: ${VALID_ENGINES.join(', ')}` }); + } + + // Verify session exists + const e = getEngine(engine); + const session = e ? e.getSession(id) : null; + if (!session) { + return res.status(404).json({ error: 'Session not found' }); + } + + const favResult = deleteFavorite(engine, id); + if (!favResult) { + return res.status(500).json({ error: '元数据库不可用,无法取消收藏' }); + } + res.json({ status: 'ok', is_favorite: false, favorited_at: null }); + } catch (err) { + console.error('[API] unfavorite error:', err); + res.status(500).json({ error: err.message }); + } +}); + // ─── SPA fallback ────────────────────────────────────────────────── if (existsSync(frontendDist)) { app.get('*', (req, res) => { diff --git a/backend/src/metadata-db.js b/backend/src/metadata-db.js index 8853105..fc9b4f8 100644 --- a/backend/src/metadata-db.js +++ b/backend/src/metadata-db.js @@ -29,6 +29,14 @@ export function initMetadataDb() { PRIMARY KEY (engine, session_id) ) `); + db.exec(` + CREATE TABLE IF NOT EXISTS session_favorites ( + engine TEXT NOT NULL, + session_id TEXT NOT NULL, + favorited_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), + PRIMARY KEY (engine, session_id) + ) + `); ready = true; console.log('[MetadataDB] Connected to', DB_PATH); } catch (err) { @@ -120,3 +128,78 @@ export function getCustomNames(sessions) { export function isMetadataReady() { return ready; } + +// ─── Session Favorites ──────────────────────────────────────────── + +/** + * Favorite a session (idempotent). Retains original favorited_at on repeat. + * @param {string} engine + * @param {string} sessionId + * @returns {number|null} favorited_at unix timestamp (original if already favorited), or null on failure + */ +export function setFavorite(engine, sessionId) { + if (!ready || !db) return null; + const now = Math.floor(Date.now() / 1000); + // INSERT OR IGNORE preserves the original favorited_at on conflict + db.prepare(` + INSERT OR IGNORE INTO session_favorites (engine, session_id, favorited_at) + VALUES (?, ?, ?) + `).run(engine, sessionId, now); + // Return the actual stored value (whether just inserted or pre-existing) + const row = db.prepare('SELECT favorited_at FROM session_favorites WHERE engine = ? AND session_id = ?').get(engine, sessionId); + return row ? row.favorited_at : null; +} + +/** + * Unfavorite a session (idempotent). + * @param {string} engine + * @param {string} sessionId + * @returns {boolean} + */ +export function deleteFavorite(engine, sessionId) { + if (!ready || !db) return false; + db.prepare('DELETE FROM session_favorites WHERE engine = ? AND session_id = ?').run(engine, sessionId); + return true; +} + +/** + * Get favorite info for multiple sessions, keyed by "engine:id". + * @param {Array<{engine: string, id: string}>} sessions + * @returns {Object} + */ +export function getFavorites(sessions) { + if (!ready || !db || !sessions.length) return {}; + const result = {}; + + const pairs = []; + const seen = new Set(); + for (const s of sessions) { + const key = `${s.engine}:${s.id}`; + if (seen.has(key)) continue; + seen.add(key); + pairs.push([s.engine, s.id]); + } + + if (!pairs.length) return result; + + const stmt = db.prepare('SELECT favorited_at FROM session_favorites WHERE engine = ? AND session_id = ?'); + for (const [engine, id] of pairs) { + const row = stmt.get(engine, id); + if (row) { + result[`${engine}:${id}`] = { favorited_at: row.favorited_at }; + } + } + + return result; +} + +/** + * Delete all favorites for a specific session (used when deleting a session). + * @param {string} engine + * @param {string} sessionId + */ +export function deleteFavoriteBySession(engine, sessionId) { + if (!ready || !db) return false; + db.prepare('DELETE FROM session_favorites WHERE engine = ? AND session_id = ?').run(engine, sessionId); + return true; +} diff --git a/backend/src/session-list.js b/backend/src/session-list.js new file mode 100644 index 0000000..c8c71c5 --- /dev/null +++ b/backend/src/session-list.js @@ -0,0 +1,173 @@ +import { listSessions, getSession } from './engine/index.js'; +import { getCustomNames, getFavorites, isMetadataReady } from './metadata-db.js'; +import { matchesSessionQuery, normalizeSearchText } from './search.js'; + +/** + * Pure filter/sort/paginate pipeline for already-enriched sessions. + * Accepts sessions with is_favorite / favorited_at already set. + * Does NOT fetch from engine or metadata DB — suitable for deterministic tests. + * + * @param {Array} list - sessions enriched with custom_name, is_favorite, favorited_at + * @param {Object} opts + * @param {string} [opts.query] + * @param {string} [opts.cwd] + * @param {string} [opts.model] + * @param {string} [opts.source] + * @param {string} [opts.status] + * @param {string} [opts.from] + * @param {string} [opts.to] + * @param {string} [opts.favorite] - '1' to show only favorites + * @param {number} [opts.limit=50] + * @param {number} [opts.offset=0] + * @returns {{ sessions: Array, total: number }} + */ +export function processSessionList(list, opts = {}) { + const { + query, + cwd, + model, + source, + status, + from, + to, + favorite, + limit = 50, + offset = 0, + } = opts; + + // 1. Apply filters (mirroring existing index.js logic) + const IS_STATUS_KEYWORD = source && ['success', 'failed', 'active', 'interrupted'].includes(source); + const statusFilter = status || (IS_STATUS_KEYWORD ? source : null); + const sourceParam = IS_STATUS_KEYWORD ? null : source; + + if (query) { + list = list.filter(s => matchesSessionQuery(s, query, { includeCustomName: true })); + } + if (cwd) list = list.filter(s => normalizeSearchText(s.cwd).includes(normalizeSearchText(cwd))); + if (model) list = list.filter(s => normalizeSearchText(s.model).includes(normalizeSearchText(model))); + if (sourceParam) list = list.filter(s => normalizeSearchText(s.source) === normalizeSearchText(sourceParam)); + if (statusFilter && statusFilter !== 'all') { + if (statusFilter === 'failed') { + list = list.filter(s => s.status === 'error' || s.status === 'interrupted'); + } else { + list = list.filter(s => s.status === statusFilter); + } + } + if (from) list = list.filter(s => (s.created_at || 0) >= Number(from)); + if (to) list = list.filter(s => (s.created_at || 0) <= Number(to)); + + // 2. Sort: favorited first (by favorited_at desc), then by created_at desc + // Equal favorited_at values fall through to created_at desc as tie-breaker + list.sort((a, b) => { + const aFav = a.is_favorite ? 1 : 0; + const bFav = b.is_favorite ? 1 : 0; + if (aFav !== bFav) return bFav - aFav; // favorited first + if (a.is_favorite && b.is_favorite) { + const favDiff = (b.favorited_at || 0) - (a.favorited_at || 0); + if (favDiff !== 0) return favDiff; // by favorited_at desc + // Equal favorited_at: fall through to created_at desc + } + return (b.created_at || 0) - (a.created_at || 0); // by created_at desc for all others + }); + + // 3. Favorite-only filter (applied AFTER sort so sorting reflects all items) + if (favorite === '1') { + list = list.filter(s => s.is_favorite); + } + + // 4. Paginate + const total = list.length; + const off = Number(offset) || 0; + const lim = Math.min(Number(limit) || 50, 200); + const page = list.slice(off, off + lim); + + return { sessions: page, total }; +} + +/** + * Build a favorite-enriched session list. + * + * 1. Fetch all sessions (unfiltered) from the engine registry + * 2. Merge custom names and favorite metadata + * 3. Delegate to processSessionList for filter/sort/paginate + * + * @param {Object} opts + * @param {string} [opts.engine='codex'] + * @param {string} [opts.query] + * @param {string} [opts.cwd] + * @param {string} [opts.model] + * @param {string} [opts.source] + * @param {string} [opts.status] + * @param {string} [opts.from] + * @param {string} [opts.to] + * @param {string} [opts.favorite] - '1' to show only favorites + * @param {number} [opts.limit=50] + * @param {number} [opts.offset=0] + * @returns {{ sessions: Array, total: number }} + */ +export function getSessionList(opts = {}) { + const { + engine = 'codex', + query, + cwd, + model, + source, + status, + from, + to, + favorite, + limit = 50, + offset = 0, + } = opts; + + // 1. Fetch all sessions from the registry + const all = listSessions({ + engine, + limit: null, + offset: 0, + }); + + let list = all.sessions; + + // 2. Merge custom names and favorites + const metadataReady = isMetadataReady(); + const customNames = metadataReady ? getCustomNames(list) : {}; + const favorites = metadataReady ? getFavorites(list) : {}; + + list = list.map(s => { + const key = `${s.engine}:${s.id}`; + const fav = favorites[key]; + return { + ...s, + custom_name: customNames[key] || null, + is_favorite: !!fav, + favorited_at: fav ? fav.favorited_at : null, + }; + }); + + // 3. Delegate to pure pipeline + return processSessionList(list, { query, cwd, model, source, status, from, to, favorite, limit, offset }); +} + +/** + * Get a single session with favorite metadata. + * @param {string} engine + * @param {string} id + * @returns {Object|null} session metadata with is_favorite / favorited_at, or null + */ +export function getFavoriteEnrichedSession(engine, id) { + const metadata = getSession(engine, id); + if (!metadata) return null; + + const key = `${engine}:${id}`; + const customNames = isMetadataReady() ? getCustomNames([{ engine, id }]) : {}; + const favorites = isMetadataReady() ? getFavorites([{ engine, id }]) : {}; + const fav = favorites[key]; + + return { + ...metadata, + custom_name: customNames[key] || null, + is_favorite: !!fav, + favorited_at: fav ? fav.favorited_at : null, + }; +} diff --git a/backend/test/metadata-db.test.js b/backend/test/metadata-db.test.js new file mode 100644 index 0000000..07f05df --- /dev/null +++ b/backend/test/metadata-db.test.js @@ -0,0 +1,110 @@ +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +let tmpDir; +let origDataDir; + +describe('Session Favorites (metadata-db)', () => { + let db; + + before(async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'meta-db-test-')); + origDataDir = process.env.AGENT_DATA_DIR; + process.env.AGENT_DATA_DIR = tmpDir; + // Import and init + db = await import('../src/metadata-db.js'); + db.initMetadataDb(); + }); + + after(() => { + if (db) db.closeMetadataDb(); + if (origDataDir !== undefined) { + process.env.AGENT_DATA_DIR = origDataDir; + } else { + delete process.env.AGENT_DATA_DIR; + } + if (tmpDir) { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('should persist a favorite and return the favorited_at timestamp', () => { + const ts = db.setFavorite('codex', 'session-1'); + assert.notEqual(ts, null); + assert.equal(typeof ts, 'number'); + assert(ts > 0); + + // Verify via getFavorites + const favs = db.getFavorites([{ engine: 'codex', id: 'session-1' }]); + assert.deepEqual(favs, { 'codex:session-1': { favorited_at: ts } }); + }); + + it('should return isMetadataReady() === true after init', () => { + assert.equal(db.isMetadataReady(), true); + }); + + it('should enforce engine + session_id isolation', () => { + db.setFavorite('codex', 'session-a'); + db.setFavorite('claude', 'session-b'); + + const favs = db.getFavorites([ + { engine: 'codex', id: 'session-a' }, + { engine: 'claude', id: 'session-b' }, + { engine: 'codex', id: 'session-b' }, + ]); + // codex:session-a and claude:session-b should be favorited, codex:session-b should not + assert.equal(favs['codex:session-a'] !== undefined, true); + assert.equal(favs['claude:session-b'] !== undefined, true); + assert.equal(favs['codex:session-b'], undefined); + }); + + it('should be idempotent: repeat favorite returns original favorited_at across different timestamps', async () => { + const first = db.setFavorite('codex', 'idempotent-ts'); + // Wait past a Unix-second boundary to prove the original timestamp is preserved + await new Promise(r => setTimeout(r, 1100)); + const second = db.setFavorite('codex', 'idempotent-ts'); + // Both calls must return the same timestamp (the original one) + assert.equal(first, second); + }); + + it('should unfavorite a session (idempotent)', () => { + db.setFavorite('codex', 'to-delete'); + assert.equal(db.getFavorites([{ engine: 'codex', id: 'to-delete' }])['codex:to-delete'] !== undefined, true); + + const result = db.deleteFavorite('codex', 'to-delete'); + assert.equal(result, true); + + const afterDel = db.getFavorites([{ engine: 'codex', id: 'to-delete' }]); + assert.equal(afterDel['codex:to-delete'], undefined); + + // Deleting again should be OK (idempotent) + const result2 = db.deleteFavorite('codex', 'to-delete'); + assert.equal(result2, true); + }); + + it('should delete favorites by session (deleteFavoriteBySession)', () => { + db.setFavorite('codex', 'delete-by-session'); + db.deleteFavoriteBySession('codex', 'delete-by-session'); + const after = db.getFavorites([{ engine: 'codex', id: 'delete-by-session' }]); + assert.equal(after['codex:delete-by-session'], undefined); + }); + + it('should return empty results for empty input', () => { + assert.deepEqual(db.getFavorites([]), {}); + }); + + it('should handle many sessions efficiently', () => { + const sessions = []; + for (let i = 0; i < 50; i++) { + const engine = i % 2 === 0 ? 'codex' : 'claude'; + const id = `mass-test-${i}`; + db.setFavorite(engine, id); + sessions.push({ engine, id }); + } + const favs = db.getFavorites(sessions); + assert.equal(Object.keys(favs).length, 50); + }); +}); diff --git a/backend/test/session-list.test.js b/backend/test/session-list.test.js new file mode 100644 index 0000000..b9e3f7e --- /dev/null +++ b/backend/test/session-list.test.js @@ -0,0 +1,167 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { processSessionList } from '../src/session-list.js'; + +/** + * Deterministic fixture builder for processSessionList. + * Returns already-enriched sessions (is_favorite, favorited_at, custom_name set). + */ +function makeSession(overrides = {}) { + return { + id: overrides.id || 'sess-1', + engine: overrides.engine || 'codex', + created_at: overrides.created_at || 1000, + status: overrides.status || 'success', + summary: overrides.summary || 'Test session', + model: overrides.model || 'gpt-4', + cwd: overrides.cwd || '/home/test', + source: overrides.source || 'chat', + custom_name: overrides.custom_name || null, + is_favorite: overrides.is_favorite || false, + favorited_at: overrides.favorited_at || null, + ...overrides, + }; +} + +describe('Session list pipeline (processSessionList)', () => { + + // ── Sorting ────────────────────────────────────────────────────── + + it('should place favorited sessions first, ordered by favorited_at desc', () => { + const list = [ + makeSession({ id: 'old-fav', is_favorite: true, favorited_at: 100, created_at: 500 }), + makeSession({ id: 'recent-fav', is_favorite: true, favorited_at: 200, created_at: 400 }), + makeSession({ id: 'normal', is_favorite: false, created_at: 900 }), + ]; + const result = processSessionList(list, { limit: 10 }); + assert.equal(result.sessions[0].id, 'recent-fav'); + assert.equal(result.sessions[1].id, 'old-fav'); + assert.equal(result.sessions[2].id, 'normal'); + }); + + it('should apply created_at desc tie-break for equal favorited_at values', () => { + const list = [ + makeSession({ id: 'older', is_favorite: true, favorited_at: 100, created_at: 1000 }), + makeSession({ id: 'newer', is_favorite: true, favorited_at: 100, created_at: 2000 }), + ]; + const result = processSessionList(list, { limit: 10 }); + // Equal favorited_at → created_at desc + assert.equal(result.sessions[0].id, 'newer'); + assert.equal(result.sessions[1].id, 'older'); + }); + + it('should order non-favorite sessions by created_at desc', () => { + const list = [ + makeSession({ id: 'old', created_at: 100 }), + makeSession({ id: 'mid', created_at: 200 }), + makeSession({ id: 'new', created_at: 300 }), + ]; + const result = processSessionList(list, { limit: 10 }); + assert.equal(result.sessions[0].id, 'new'); + assert.equal(result.sessions[1].id, 'mid'); + assert.equal(result.sessions[2].id, 'old'); + }); + + it('should sort before pagination (sort covers all items, pagination slices)', () => { + const list = []; + // 5 favorites at timestamps favoring higher i → higher favorited_at + for (let i = 0; i < 5; i++) { + list.push(makeSession({ id: `fav-${i}`, is_favorite: true, favorited_at: 500 - i, created_at: 1000 + i })); + } + // 5 non-favorites + for (let i = 0; i < 5; i++) { + list.push(makeSession({ id: `normal-${i}`, created_at: 100 + i })); + } + // fav-0 has favorited_at=500 (highest), fav-4 has 496 (lowest among favs) + const result = processSessionList(list, { limit: 3, offset: 0 }); + assert.equal(result.total, 10); + assert.equal(result.sessions.length, 3); + assert.equal(result.sessions[0].id, 'fav-0'); + assert.equal(result.sessions[1].id, 'fav-1'); + assert.equal(result.sessions[2].id, 'fav-2'); + }); + + // ── Filtering ──────────────────────────────────────────────────── + + it('should filter by favorite-only when opts.favorite="1"', () => { + const list = [ + makeSession({ id: 'fav', is_favorite: true, favorited_at: 100 }), + makeSession({ id: 'not-fav', is_favorite: false }), + ]; + const result = processSessionList(list, { favorite: '1', limit: 10 }); + assert.equal(result.sessions.length, 1); + assert.equal(result.sessions[0].id, 'fav'); + }); + + it('should combine favorite-only with status filter', () => { + const list = [ + makeSession({ id: 'fav-ok', is_favorite: true, favorited_at: 200, status: 'success' }), + makeSession({ id: 'fav-err', is_favorite: true, favorited_at: 100, status: 'error' }), + makeSession({ id: 'normal-ok', status: 'success' }), + ]; + // Only favorited AND status=error + const result = processSessionList(list, { favorite: '1', status: 'failed', limit: 10 }); + assert.equal(result.sessions.length, 1); + assert.equal(result.sessions[0].id, 'fav-err'); + }); + + it('should be deterministic when favorited_at and created_at both equal', () => { + const list = [ + makeSession({ id: 'a', is_favorite: true, favorited_at: 100, created_at: 1000 }), + makeSession({ id: 'b', is_favorite: true, favorited_at: 100, created_at: 1000 }), + ]; + // When both are equal, sort is stable (preserves original order) + const result = processSessionList(list, { limit: 10 }); + assert.equal(result.sessions[0].id, 'a'); + assert.equal(result.sessions[1].id, 'b'); + }); + + it('should combine favorite-only with query and status filter (composition test)', () => { + const list = [ + // Favorited, matches query, has error status + makeSession({ id: 'match-all', is_favorite: true, favorited_at: 300, status: 'error', summary: 'OAuth fix', custom_name: null }), + // Favorited, does NOT match query + makeSession({ id: 'fav-no-query', is_favorite: true, favorited_at: 200, status: 'error', summary: 'Other thing' }), + // Favorited, matches query but wrong status + makeSession({ id: 'fav-ok-status', is_favorite: true, favorited_at: 100, status: 'success', summary: 'OAuth callback' }), + // Not favorited, matches query and status + makeSession({ id: 'not-fav', is_favorite: false, status: 'error', summary: 'OAuth failure' }), + ]; + // Only favorited + query matching "OAuth" + status failed + const result = processSessionList(list, { favorite: '1', query: 'OAuth', status: 'failed', limit: 10 }); + assert.equal(result.sessions.length, 1); + assert.equal(result.sessions[0].id, 'match-all'); + }); + + // ── Edge cases ─────────────────────────────────────────────────── + + it('should return empty array for empty input', () => { + const result = processSessionList([], { limit: 10 }); + assert.deepEqual(result, { sessions: [], total: 0 }); + }); + + it('should respect limit and offset', () => { + const list = []; + for (let i = 0; i < 20; i++) { + list.push(makeSession({ id: `s-${i}`, created_at: 2000 - i })); + } + // s-0 has created_at=2000 (highest → first after sort desc) + const page1 = processSessionList(list, { limit: 5, offset: 0 }); + assert.equal(page1.sessions.length, 5); + assert.equal(page1.sessions[0].id, 's-0'); + assert.equal(page1.total, 20); + + const page2 = processSessionList(list, { limit: 5, offset: 5 }); + assert.equal(page2.sessions.length, 5); + assert.equal(page2.sessions[0].id, 's-5'); + }); + + it('should cap limit at 200', () => { + const list = Array.from({ length: 300 }, (_, i) => + makeSession({ id: `s-${i}`, created_at: 300 - i }) + ); + const result = processSessionList(list, { limit: 999 }); + assert.equal(result.sessions.length, 200); + assert.equal(result.total, 300); + }); +}); diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index c82a523..4f00924 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -30,7 +30,7 @@ export async function getHealth() { return data } -export async function getSessions({ engine = 'all', query, status, from, to, limit, offset } = {}) { +export async function getSessions({ engine = 'all', query, status, from, to, limit, offset, favorite } = {}) { const params = new URLSearchParams() if (engine) params.set('engine', engine) if (query) params.set('query', query) @@ -39,6 +39,7 @@ export async function getSessions({ engine = 'all', query, status, from, to, lim if (to) params.set('to', String(to)) if (limit) params.set('limit', String(limit)) if (offset) params.set('offset', String(offset)) + if (favorite) params.set('favorite', String(favorite)) const qs = params.toString() return request(`/sessions${qs ? '?' + qs : ''}`) } @@ -93,6 +94,18 @@ export async function deleteSession(engine, sessionId) { }) } +export async function favoriteSession(engine, sessionId) { + return request(`/sessions/${encodeURIComponent(engine)}/${encodeURIComponent(sessionId)}/favorite`, { + method: 'PUT', + }) +} + +export async function unfavoriteSession(engine, sessionId) { + return request(`/sessions/${encodeURIComponent(engine)}/${encodeURIComponent(sessionId)}/favorite`, { + method: 'DELETE', + }) +} + export function formatTimestamp(ts) { if (!ts) return '-' const d = typeof ts === 'number' && ts < 1e15 ? new Date(ts * 1000) : new Date(ts) diff --git a/frontend/src/views/SessionList.vue b/frontend/src/views/SessionList.vue index 8609b05..673ffe0 100644 --- a/frontend/src/views/SessionList.vue +++ b/frontend/src/views/SessionList.vue @@ -33,6 +33,10 @@ 状态过滤: +
+ +
@@ -43,7 +47,7 @@
加载中...
-

无符合当前引擎或状态过滤条件的会话实例。

+

{{ favoriteOnly ? '暂无收藏的会话。' : '无符合当前引擎或状态过滤条件的会话实例。' }}

会话 ID: {{ (s.id || '').substring(0, 15) }}... +

{{ s.custom_name || s.summary || '(无消息)' }}

{{ s.summary }}
@@ -121,6 +134,15 @@ +
@@ -226,7 +248,7 @@ @@ -497,6 +554,7 @@ onMounted(loadSessions) .filter-btn { padding: 5px 12px; border-radius: 8px; border: 1px solid var(--border); background: transparent; color: var(--text-secondary); font-size: 11px; cursor: pointer; } .filter-btn:hover { background: var(--accent-bg); } .filter-btn.active { background: var(--accent-bg); border-color: var(--accent); color: var(--accent-light); font-weight: 600; } +.filter-group + .filter-group .filter-btn { min-height: 44px; } /* Session Cards */ .session-list { } @@ -642,6 +700,9 @@ onMounted(loadSessions) @media (max-width: 768px) { .stats-row { grid-template-columns: repeat(2, 1fr); } .session-card { flex-direction: column; align-items: flex-start; gap: 8px; } + .card-main { padding-right: 0; width: 100%; } + .card-tags { gap: 4px; } + .id-preview { flex-shrink: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; } .card-meta { width: 100%; justify-content: space-around; } .drawer { max-width: 100%; } } @@ -668,4 +729,27 @@ onMounted(loadSessions) .drawer-footer { flex-wrap: wrap; } .footer-btn-copy, .footer-btn-uuid, .footer-btn-detail { flex: 1 1 100%; } } + +/* Favorite Buttons */ +.fav-btn-card { + background: none; border: none; font-size: 14px; cursor: pointer; + padding: 0; transition: transform 0.12s; line-height: 1; + color: var(--text-muted); + min-width: 44px; min-height: 44px; + display: inline-flex; align-items: center; justify-content: center; +} +.fav-btn-card:hover { transform: scale(1.2); } +.fav-btn-card.is-fav { color: #f59e0b; } +.fav-btn-card:disabled { opacity: 0.4; cursor: not-allowed; transform: none; } + +.fav-btn-drawer { + background: none; border: 1px solid var(--border); padding: 2px 8px; + border-radius: 4px; font-size: 10px; color: var(--text-secondary); cursor: pointer; + transition: all 0.12s; + min-width: 44px; min-height: 44px; + display: inline-flex; align-items: center; justify-content: center; +} +.fav-btn-drawer:hover { border-color: #f59e0b; color: #f59e0b; } +.fav-btn-drawer.is-fav { border-color: #f59e0b; color: #f59e0b; background: rgba(245,158,11,0.1); } +.fav-btn-drawer:disabled { opacity: 0.4; cursor: not-allowed; }