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、路径、摘要
- **会话收藏** — 星标收藏会话,收藏项全局置顶,支持只看收藏模式
- **会话详情** — 引擎专属元数据、消息时间线、工具调用统计
- **短 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 解析 |
+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 { 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) => {
+83
View File
@@ -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<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
}
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)
+86 -2
View File
@@ -33,6 +33,10 @@
<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>
</div>
<div class="filter-group">
<button class="filter-btn" :class="{ active: favoriteOnly }" @click="onFavoriteOnlyChange()" title="只看收藏会话"
aria-label="只看收藏会话" :aria-pressed="String(favoriteOnly)"> 只看收藏</button>
</div>
</div>
<!-- Session Cards -->
@@ -43,7 +47,7 @@
<div v-if="loading" class="loading-state">加载中...</div>
<div v-else-if="!displayedSessions.length" class="empty-state">
<p>无符合当前引擎或状态过滤条件的会话实例</p>
<p>{{ favoriteOnly ? '暂无收藏的会话。' : '无符合当前引擎或状态过滤条件的会话实例。' }}</p>
</div>
<div v-else class="card-grid">
<div
@@ -64,6 +68,15 @@
</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="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>
<h4 class="card-summary">{{ s.custom_name || s.summary || '(无消息)' }}</h4>
<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="generateDrawerTitle" :disabled="drawerAiLoading">🤖 {{ drawerAiLoading ? '生成中...' : 'AI 生成' }}</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>
@@ -226,7 +248,7 @@
<script setup>
import { ref, computed, onMounted, inject, watch } from 'vue'
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'
const props = defineProps({ selectedEngine: { type: String, default: 'all' } })
@@ -253,6 +275,9 @@ const statusOpts = [
{ key: 'failed', label: '已中断' },
]
const favoriteOnly = ref(false)
const favoritePending = ref(false) // global lock: one favorite mutation at a time
const engineDist = computed(() => {
const dist = {}
for (const s of sessions.value) {
@@ -324,6 +349,7 @@ async function loadSessions() {
status: apiStatus,
limit: pageSize,
offset: (page.value - 1) * pageSize,
favorite: favoriteOnly.value ? '1' : undefined,
})
sessions.value = data.sessions || []
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(favoriteOnly, () => { page.value = 1; loadSessions() })
onMounted(loadSessions)
</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: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; }
</style>