feat: add persistent session favorites

This commit is contained in:
liujing
2026-07-20 15:17:08 +08:00
parent 5118e1f0ab
commit d49cc8a1c2
8 changed files with 709 additions and 53 deletions
+5 -2
View File
@@ -25,6 +25,7 @@ npm start
## 功能 ## 功能
- **五引擎会话列表** — 按引擎筛选(全部/Codex/Claude/Agy/OpenCode/Reasonix),搜索 ID、短 ID、路径、摘要 - **五引擎会话列表** — 按引擎筛选(全部/Codex/Claude/Agy/OpenCode/Reasonix),搜索 ID、短 ID、路径、摘要
- **会话收藏** — 星标收藏会话,收藏项全局置顶,支持只看收藏模式
- **会话详情** — 引擎专属元数据、消息时间线、工具调用统计 - **会话详情** — 引擎专属元数据、消息时间线、工具调用统计
- **短 ID 解析** — 输入部分 ID(如 `019f448b`)自动匹配 - **短 ID 解析** — 输入部分 ID(如 `019f448b`)自动匹配
- **一键恢复** — 复制引擎对应的恢复命令(`codex resume` / `claude --resume` / `agy --conversation` / `opencode --session` / `reasonix --resume` - **一键恢复** — 复制引擎对应的恢复命令(`codex resume` / `claude --resume` / `agy --conversation` / `opencode --session` / `reasonix --resume`
@@ -51,9 +52,11 @@ Reasonix 数据目录默认是 `~/.reasonix`,可通过环境变量 `REASONIX_H
| 路径 | 说明 | | 路径 | 说明 |
|---|---| |---|---|
| `GET /api/health` | 服务状态 + 五引擎健康 | | `GET /api/health` | 服务状态 + 五引擎健康 |
| `GET /api/sessions?engine=all\|codex\|claude\|agy\|opencode\|reasonix` | 会话列表 | | `GET /api/sessions?engine=all\|codex\|claude\|agy\|opencode\|reasonix` | 会话列表(支持 &favorite=1 只看收藏) |
| `GET /api/sessions/:engine/:id` | 会话详情 | | `GET /api/sessions/:engine/:id` | 会话详情(返回 is_favorite / favorited_at |
| `GET /api/sessions/:engine/:id/tools` | 会话内工具统计 | | `GET /api/sessions/:engine/:id/tools` | 会话内工具统计 |
| `PUT /api/sessions/:engine/:id/favorite` | 收藏会话(幂等) |
| `DELETE /api/sessions/:engine/:id/favorite` | 取消收藏(幂等) |
| `DELETE /api/sessions/:engine/:id` | 删除会话(仅 OpenCode 引擎支持原始删除) | | `DELETE /api/sessions/:engine/:id` | 删除会话(仅 OpenCode 引擎支持原始删除) |
| `GET /api/tools?engine=...` | 全局工具排行 | | `GET /api/tools?engine=...` | 全局工具排行 |
| `GET /api/resolve/:prefix?engine=...` | 短 ID 解析 | | `GET /api/resolve/:prefix?engine=...` | 短 ID 解析 |
+71 -48
View File
@@ -7,9 +7,10 @@ import { existsSync } from 'fs';
import { refreshEngines, listSessions, getSession, getSessionMessages, getToolUsage, resolvePrefix, getAllHealth, getEngine } from './engine/index.js'; import { refreshEngines, listSessions, getSession, getSessionMessages, getToolUsage, resolvePrefix, getAllHealth, getEngine } from './engine/index.js';
import { getCodexHome, isDbReady } from './db.js'; import { getCodexHome, isDbReady } from './db.js';
import { getCacheInfo } from './sessions.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 { generateTitle } from './title-generator.js';
import { matchesSessionQuery, normalizeSearchText } from './search.js'; import { matchesSessionQuery, normalizeSearchText } from './search.js';
import { getSessionList, getFavoriteEnrichedSession } from './session-list.js';
const __dirname = fileURLToPath(new URL('.', import.meta.url)); const __dirname = fileURLToPath(new URL('.', import.meta.url));
const app = express(); const app = express();
@@ -48,55 +49,25 @@ app.get('/api/health', (req, res) => {
// ─── Sessions List (unified) ─────────────────────────────────────── // ─── Sessions List (unified) ───────────────────────────────────────
app.get('/api/sessions', (req, res) => { app.get('/api/sessions', (req, res) => {
try { 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 eng = engine || 'codex';
const IS_STATUS_KEYWORD = source && ['success','failed','active','interrupted'].includes(source); // Use the centralized favorite-enriched listing
const statusFilter = status || (IS_STATUS_KEYWORD ? source : null); const result = getSessionList({
const sourceParam = IS_STATUS_KEYWORD ? null : source;
// Fetch all sessions from the registry, no filtering
const all = listSessions({
engine: eng, engine: eng,
limit: null, query,
offset: 0, cwd,
model,
source,
status,
from,
to,
favorite,
limit: Number(limit) || 50,
offset: Number(offset) || 0,
}); });
let list = all.sessions; res.json({ sessions: result.sessions, total: result.total });
// 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 });
} catch (err) { } catch (err) {
console.error('[API] /api/sessions error:', err); console.error('[API] /api/sessions error:', err);
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });
@@ -124,12 +95,12 @@ app.get('/api/sessions/:engine/:id', async (req, res) => {
} catch {} } catch {}
} }
const customName = isMetadataReady() ? getCustomNames([{ engine, id }])[`${engine}:${id}`] : null; // Enrich with favorite and custom name metadata
const enriched = getFavoriteEnrichedSession(engine, id);
res.json({ res.json({
metadata: { metadata: {
...(metadata || { id, engine, error: 'Session not found' }), ...(enriched || { id, engine, error: 'Session not found' }),
custom_name: customName || null,
}, },
summary: { summary: {
tool_call_count: metadata?.tool_call_count || 0, 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 // Clean up custom name metadata
try { try {
deleteCustomName(engine, id); deleteCustomName(engine, id);
deleteFavoriteBySession(engine, id);
} catch {} } catch {}
// Trigger refresh // 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 ────────────────────────────────────────────────── // ─── SPA fallback ──────────────────────────────────────────────────
if (existsSync(frontendDist)) { if (existsSync(frontendDist)) {
app.get('*', (req, res) => { app.get('*', (req, res) => {
+83
View File
@@ -29,6 +29,14 @@ export function initMetadataDb() {
PRIMARY KEY (engine, session_id) 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; ready = true;
console.log('[MetadataDB] Connected to', DB_PATH); console.log('[MetadataDB] Connected to', DB_PATH);
} catch (err) { } catch (err) {
@@ -120,3 +128,78 @@ export function getCustomNames(sessions) {
export function isMetadataReady() { export function isMetadataReady() {
return ready; 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<string, {favorited_at: number}>}
*/
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;
}
+173
View File
@@ -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,
};
}
+110
View File
@@ -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);
});
});
+167
View File
@@ -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);
});
});
+14 -1
View File
@@ -30,7 +30,7 @@ export async function getHealth() {
return data 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() const params = new URLSearchParams()
if (engine) params.set('engine', engine) if (engine) params.set('engine', engine)
if (query) params.set('query', query) 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 (to) params.set('to', String(to))
if (limit) params.set('limit', String(limit)) if (limit) params.set('limit', String(limit))
if (offset) params.set('offset', String(offset)) if (offset) params.set('offset', String(offset))
if (favorite) params.set('favorite', String(favorite))
const qs = params.toString() const qs = params.toString()
return request(`/sessions${qs ? '?' + qs : ''}`) 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) { export function formatTimestamp(ts) {
if (!ts) return '-' if (!ts) return '-'
const d = typeof ts === 'number' && ts < 1e15 ? new Date(ts * 1000) : new Date(ts) const d = typeof ts === 'number' && ts < 1e15 ? new Date(ts * 1000) : new Date(ts)
+86 -2
View File
@@ -33,6 +33,10 @@
<span class="filter-lbl">状态过滤:</span> <span class="filter-lbl">状态过滤:</span>
<button v-for="opt in statusOpts" :key="opt.key" class="filter-btn" :class="{ active: statusFilter === opt.key }" @click="statusFilter = opt.key; doSearch()">{{ opt.label }}</button> <button v-for="opt in statusOpts" :key="opt.key" class="filter-btn" :class="{ active: statusFilter === opt.key }" @click="statusFilter = opt.key; doSearch()">{{ opt.label }}</button>
</div> </div>
<div class="filter-group">
<button class="filter-btn" :class="{ active: favoriteOnly }" @click="onFavoriteOnlyChange()" title="只看收藏会话"
aria-label="只看收藏会话" :aria-pressed="String(favoriteOnly)"> 只看收藏</button>
</div>
</div> </div>
<!-- Session Cards --> <!-- Session Cards -->
@@ -43,7 +47,7 @@
<div v-if="loading" class="loading-state">加载中...</div> <div v-if="loading" class="loading-state">加载中...</div>
<div v-else-if="!displayedSessions.length" class="empty-state"> <div v-else-if="!displayedSessions.length" class="empty-state">
<p>无符合当前引擎或状态过滤条件的会话实例</p> <p>{{ favoriteOnly ? '暂无收藏的会话。' : '无符合当前引擎或状态过滤条件的会话实例。' }}</p>
</div> </div>
<div v-else class="card-grid"> <div v-else class="card-grid">
<div <div
@@ -64,6 +68,15 @@
</span> </span>
<span class="id-preview mono">会话 ID: <span class="id-highlight">{{ (s.id || '').substring(0, 15) }}...</span></span> <span class="id-preview mono">会话 ID: <span class="id-highlight">{{ (s.id || '').substring(0, 15) }}...</span></span>
<button class="name-btn-card" @click.stop="openNameEditor(s)" title="自定义名称"></button> <button class="name-btn-card" @click.stop="openNameEditor(s)" title="自定义名称"></button>
<button
class="fav-btn-card"
:class="{ 'is-fav': s.is_favorite }"
:disabled="favoritePending"
@click.stop="toggleFavorite(s)"
:title="s.is_favorite ? '取消收藏' : '收藏'"
:aria-label="s.is_favorite ? '取消收藏此会话' : '收藏此会话'"
:aria-pressed="String(s.is_favorite)"
>{{ s.is_favorite ? '★' : '☆' }}</button>
</div> </div>
<h4 class="card-summary">{{ s.custom_name || s.summary || '(无消息)' }}</h4> <h4 class="card-summary">{{ s.custom_name || s.summary || '(无消息)' }}</h4>
<div v-if="s.custom_name" class="card-original-summary">{{ s.summary }}</div> <div v-if="s.custom_name" class="card-original-summary">{{ s.summary }}</div>
@@ -121,6 +134,15 @@
<button class="name-edit-btn" @click.stop="openNameEditor(drawerSession)"> 命名</button> <button class="name-edit-btn" @click.stop="openNameEditor(drawerSession)"> 命名</button>
<button class="name-edit-btn" @click.stop="generateDrawerTitle" :disabled="drawerAiLoading">🤖 {{ drawerAiLoading ? '生成中...' : 'AI 生成' }}</button> <button class="name-edit-btn" @click.stop="generateDrawerTitle" :disabled="drawerAiLoading">🤖 {{ drawerAiLoading ? '生成中...' : 'AI 生成' }}</button>
<button v-if="drawerSession.custom_name" class="name-edit-btn" @click.stop="clearCustomName(drawerSession)">🗑 清除</button> <button v-if="drawerSession.custom_name" class="name-edit-btn" @click.stop="clearCustomName(drawerSession)">🗑 清除</button>
<button
class="fav-btn-drawer"
:class="{ 'is-fav': drawerSession.is_favorite }"
:disabled="favoritePending"
@click.stop="toggleFavorite(drawerSession)"
:title="drawerSession.is_favorite ? '取消收藏' : '收藏'"
:aria-label="drawerSession.is_favorite ? '取消收藏此会话' : '收藏此会话'"
:aria-pressed="String(drawerSession.is_favorite)"
>{{ drawerSession.is_favorite ? '★' : '☆' }} {{ drawerSession.is_favorite ? '取消收藏' : '收藏' }}</button>
</div> </div>
</div> </div>
@@ -226,7 +248,7 @@
<script setup> <script setup>
import { ref, computed, onMounted, inject, watch } from 'vue' import { ref, computed, onMounted, inject, watch } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { getSessions, getSessionDetail, getGlobalTools, formatTimestamp, getEngineMeta, ENGINE_META, setCustomName, deleteCustomName, generateTitle, deleteSession } from '../api/index.js' import { getSessions, getSessionDetail, getGlobalTools, formatTimestamp, getEngineMeta, ENGINE_META, setCustomName, deleteCustomName, generateTitle, deleteSession, favoriteSession, unfavoriteSession } from '../api/index.js'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
const props = defineProps({ selectedEngine: { type: String, default: 'all' } }) const props = defineProps({ selectedEngine: { type: String, default: 'all' } })
@@ -253,6 +275,9 @@ const statusOpts = [
{ key: 'failed', label: '已中断' }, { key: 'failed', label: '已中断' },
] ]
const favoriteOnly = ref(false)
const favoritePending = ref(false) // global lock: one favorite mutation at a time
const engineDist = computed(() => { const engineDist = computed(() => {
const dist = {} const dist = {}
for (const s of sessions.value) { for (const s of sessions.value) {
@@ -324,6 +349,7 @@ async function loadSessions() {
status: apiStatus, status: apiStatus,
limit: pageSize, limit: pageSize,
offset: (page.value - 1) * pageSize, offset: (page.value - 1) * pageSize,
favorite: favoriteOnly.value ? '1' : undefined,
}) })
sessions.value = data.sessions || [] sessions.value = data.sessions || []
total.value = data.total || 0 total.value = data.total || 0
@@ -467,7 +493,38 @@ async function generateDrawerTitle() {
} }
} }
function toggleFavorite(s) {
if (!s || favoritePending.value) return
favoritePending.value = true
const action = s.is_favorite ? unfavoriteSession : favoriteSession
action(s.engine, s.id)
.then((result) => {
// Use API response as immediate source of truth
s.is_favorite = result.is_favorite
s.favorited_at = result.favorited_at
// Sync drawer immediately if it matches the acted-on session
if (drawerSession.value && drawerSession.value.id === s.id && drawerSession.value.engine === s.engine) {
drawerSession.value.is_favorite = result.is_favorite
drawerSession.value.favorited_at = result.favorited_at
}
page.value = 1
return loadSessions()
})
.catch((err) => {
ElMessage.error('收藏操作失败: ' + (err.message || ''))
})
.finally(() => {
favoritePending.value = false
})
}
function onFavoriteOnlyChange() {
favoriteOnly.value = !favoriteOnly.value
}
watch(() => props.selectedEngine, () => { page.value = 1; loadSessions() }) watch(() => props.selectedEngine, () => { page.value = 1; loadSessions() })
watch(favoriteOnly, () => { page.value = 1; loadSessions() })
onMounted(loadSessions) onMounted(loadSessions)
</script> </script>
@@ -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 { 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:hover { background: var(--accent-bg); }
.filter-btn.active { background: var(--accent-bg); border-color: var(--accent); color: var(--accent-light); font-weight: 600; } .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 Cards */
.session-list { } .session-list { }
@@ -642,6 +700,9 @@ onMounted(loadSessions)
@media (max-width: 768px) { @media (max-width: 768px) {
.stats-row { grid-template-columns: repeat(2, 1fr); } .stats-row { grid-template-columns: repeat(2, 1fr); }
.session-card { flex-direction: column; align-items: flex-start; gap: 8px; } .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; } .card-meta { width: 100%; justify-content: space-around; }
.drawer { max-width: 100%; } .drawer { max-width: 100%; }
} }
@@ -668,4 +729,27 @@ onMounted(loadSessions)
.drawer-footer { flex-wrap: wrap; } .drawer-footer { flex-wrap: wrap; }
.footer-btn-copy, .footer-btn-uuid, .footer-btn-detail { flex: 1 1 100%; } .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; }
</style> </style>