feat: add persistent session favorites
This commit is contained in:
@@ -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
@@ -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) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user