diff --git a/README.md b/README.md index a349343..3d6ad54 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 智能体观测中心 -本地多引擎 AI 会话管理工具 — 浏览、搜索、复制恢复命令,支持 **Codex · Claude · Agy** 三引擎。 +本地多引擎 AI 会话管理工具 — 浏览、搜索、复制恢复命令,支持 **Codex · Claude · Agy · OpenCode** 四引擎。 ## 快速开始 @@ -24,12 +24,13 @@ npm start ## 功能 -- **三引擎会话列表** — 按引擎筛选(全部/Codex/Claude/Agy),搜索 ID、短 ID、路径、摘要 +- **四引擎会话列表** — 按引擎筛选(全部/Codex/Claude/Agy/OpenCode),搜索 ID、短 ID、路径、摘要 - **会话详情** — 引擎专属元数据、消息时间线、工具调用统计 - **短 ID 解析** — 输入部分 ID(如 `019f448b`)自动匹配 -- **一键恢复** — 复制引擎对应的恢复命令(`codex resume` / `claude --resume` / `agy --conversation`) +- **一键恢复** — 复制引擎对应的恢复命令(`codex resume` / `claude --resume` / `agy --conversation` / `opencode --session`) - **工具排行** — 全引擎工具调用聚合排行,附技能/插件/缓存清单 - **效能分析** — 会话趋势图、状态分布、异常率 +- **会话删除** — 支持 OpenCode 引擎的原始会话删除(通过 `opencode session delete` 执行) - **移动端适配** — 390px ~ 2560px 响应式布局 ## 数据来源 @@ -39,15 +40,19 @@ npm start | Codex | `~/.codex/state_5.sqlite` + `sessions/` | ~560 | `codex resume ` | | Claude | `~/.claude/projects/**/*.jsonl` | ~170 | `claude --resume ` | | Agy | `~/.gemini/antigravity-cli/conversation_summaries.db` | ~80 | `agy --conversation ` | +| OpenCode | `~/.local/share/opencode/opencode.db` | ~520 | `opencode --session ` | + +OpenCode 数据目录通过 `opencode debug paths` 自动检测,若 CLI 不可用则回退到 `~/.local/share/opencode`。 ## API | 路径 | 说明 | |---|---| -| `GET /api/health` | 服务状态 + 三引擎健康 | -| `GET /api/sessions?engine=all\|codex\|claude\|agy` | 会话列表 | +| `GET /api/health` | 服务状态 + 四引擎健康 | +| `GET /api/sessions?engine=all\|codex\|claude\|agy\|opencode` | 会话列表 | | `GET /api/sessions/:engine/:id` | 会话详情 | | `GET /api/sessions/:engine/:id/tools` | 会话内工具统计 | +| `DELETE /api/sessions/:engine/:id` | 删除会话(仅 OpenCode 引擎支持原始删除) | | `GET /api/tools?engine=...` | 全局工具排行 | | `GET /api/resolve/:prefix?engine=...` | 短 ID 解析 | | `POST /api/refresh` | 刷新缓存 | diff --git a/backend/src/engine/index.js b/backend/src/engine/index.js index fcf1f08..a96ab42 100644 --- a/backend/src/engine/index.js +++ b/backend/src/engine/index.js @@ -1,11 +1,13 @@ import { CodexAdapter } from './codex-adapter.js'; import { ClaudeAdapter } from './claude-adapter.js'; import { AgyAdapter } from './agy-adapter.js'; +import { OpenCodeAdapter } from './opencode-adapter.js'; const ENGINES = { codex: new CodexAdapter(), claude: new ClaudeAdapter(), agy: new AgyAdapter(), + opencode: new OpenCodeAdapter(), }; export function getEngine(name) { diff --git a/backend/src/engine/opencode-adapter.js b/backend/src/engine/opencode-adapter.js new file mode 100644 index 0000000..7bbd907 --- /dev/null +++ b/backend/src/engine/opencode-adapter.js @@ -0,0 +1,486 @@ +import { execSync } from 'child_process'; +import { existsSync } from 'fs'; +import { homedir } from 'os'; +import { join } from 'path'; +import Database from 'better-sqlite3'; +import { BaseEngineAdapter } from './base.js'; + +const DEFAULT_DATA_DIR = join(homedir(), '.local', 'share', 'opencode'); + +export class OpenCodeAdapter extends BaseEngineAdapter { + constructor() { + super('opencode', 'OpenCode'); + this._dataDir = null; + this._dbPath = null; + this._reason = ''; + this._db = null; + this.cliAvailable = false; + this._dbReadable = false; + this._sessionsLoaded = false; + this._messageCache = new Map(); + } + + async checkHealth() { + this._detectCli(); + this._findDataDir(); + return this.getHealthInfo(); + } + + async refresh() { + this._detectCli(); + this._findDataDir(); + this.lastRefresh = Date.now(); + this._messageCache.clear(); + this.error = null; // clear stale errors on successful refresh (codex-1-2) + + if (!this._dataDir || !this._dbPath) { + this._sessions = []; + this._sessionsMap.clear(); + this.available = false; + this._sessionsLoaded = false; + return this.getHealthInfo(); + } + + try { + this._openDb(); + this._readSessions(); + this._sessionsLoaded = true; + // available = DB is readable, regardless of CLI or session count (codex-1-2) + this.available = true; + } catch (err) { + this.error = err.message; + console.error('[OpenCodeAdapter] Refresh error:', err.message); + this._sessions = []; + this._sessionsMap.clear(); + this.available = false; + this._sessionsLoaded = false; + } finally { + this._closeDb(); + } + + return this.getHealthInfo(); + } + + _detectCli() { + try { + const result = execSync('which opencode 2>/dev/null || echo ""', { encoding: 'utf-8', timeout: 3000 }).trim(); + if (result) { + this.cliAvailable = true; + this._reason = ''; + return; + } + } catch {} + this.cliAvailable = false; + this._reason = '未检测到 opencode CLI'; + } + + _findDataDir() { + // 1. Honor XDG_DATA_HOME/opencode first (codex-1-2) + const xdgDataHome = process.env.XDG_DATA_HOME; + if (xdgDataHome) { + const xdgDir = join(xdgDataHome, 'opencode'); + const xdgDbPath = join(xdgDir, 'opencode.db'); + if (existsSync(xdgDbPath)) { + this._dataDir = xdgDir; + this._dbPath = xdgDbPath; + this._reason = ''; + return; + } + } + + // 2. Try opencode debug paths (CLI) – if CLI is available + try { + const output = execSync('opencode debug paths 2>/dev/null', { encoding: 'utf-8', timeout: 5000 }); + for (const line of output.split('\n')) { + const trimmed = line.trim(); + if (trimmed.startsWith('data')) { + const parts = trimmed.split(/\s+/); + if (parts.length >= 2) { + const dataDir = parts[1]; + const dbPath = join(dataDir, 'opencode.db'); + if (existsSync(dbPath)) { + this._dataDir = dataDir; + this._dbPath = dbPath; + this._reason = ''; + return; + } + } + } + } + } catch {} + + // 3. Fallback to ~/.local/share/opencode + const dbPath = join(DEFAULT_DATA_DIR, 'opencode.db'); + if (existsSync(dbPath)) { + this._dataDir = DEFAULT_DATA_DIR; + this._dbPath = dbPath; + this._reason = ''; + return; + } + + this._dataDir = null; + this._dbPath = null; + this._reason = `未找到 OpenCode 数据库: ${dbPath}`; + } + + _openDb() { + this._closeDb(); + if (!this._dbPath) return; + try { + this._db = new Database(this._dbPath, { readonly: true, fileMustExist: true, timeout: 10000 }); + this._db.pragma('query_only = true'); + this._dbReadable = true; + } catch (err) { + this._db = null; + this._dbReadable = false; + this._reason = `无法打开数据库: ${err.message}`; + throw err; + } + } + + _closeDb() { + if (this._db) { + try { this._db.close(); } catch {} + this._db = null; + } + this._dbReadable = false; + } + + _readSessions() { + this._sessions = []; + this._sessionsMap.clear(); + if (!this._db) return; + + const rows = this._db.prepare(` + SELECT id, parent_id, slug, directory, path, title, agent, model, version, + cost, tokens_input, tokens_output, tokens_reasoning, + tokens_cache_read, tokens_cache_write, + time_created, time_updated, time_compacting, time_archived, + summary_additions, summary_deletions, summary_files, + parent_id, metadata + FROM session + WHERE parent_id IS NULL AND time_archived IS NULL + ORDER BY time_created DESC + `).all(); + + for (const row of rows) { + const id = row.id; + if (!id) continue; + + let modelName = ''; + const modelStr = row.model || ''; + try { + const modelObj = JSON.parse(modelStr); + modelName = modelObj.id || modelStr; + } catch { + modelName = modelStr; + } + + const created = Number(row.time_created); + const updated = Number(row.time_updated); + const now = Date.now(); + let status = 'success'; + if (updated && (now - updated) < 300000) { + status = 'active'; + } else if (created && updated && (updated - created) < 5000 && !row.tokens_input && !row.tokens_output) { + status = 'interrupted'; + } + + const entry = { + key: `opencode:${id}`, + engine: 'opencode', + engine_label: 'OpenCode', + id, + can_delete: this.cliAvailable, + summary: row.title || row.slug || '', + cwd: row.directory || '', + model: modelName, + agent: row.agent || '', + cli_version: row.version || '', + source: 'cli', + created_at: created, + updated_at: updated, + duration_ms: created && updated ? updated - created : null, + status, + tool_call_count: 0, + tool_calls: {}, + message_count: 0, + tokens_input: row.tokens_input || 0, + tokens_output: row.tokens_output || 0, + tokens_reasoning: row.tokens_reasoning || 0, + tokens_cache_read: row.tokens_cache_read || 0, + tokens_cache_write: row.tokens_cache_write || 0, + cost: row.cost || 0, + summary_additions: row.summary_additions || 0, + summary_deletions: row.summary_deletions || 0, + summary_files: row.summary_files || 0, + parent_id: row.parent_id, + time_archived: row.time_archived, + resume_command: this.buildResumeCommand(id), + can_resume: true, + }; + + this._sessions.push(entry); + this._sessionsMap.set(id, entry); + } + + // Load tool usage and message counts from the part table (codex-1-1) + this._loadMessageStats(); + } + + /** Count tool calls and messages per session from the part table (codex-2-1) */ + _loadMessageStats() { + if (!this._db || !this._sessions.length) return; + + for (const session of this._sessions) { + try { + // JOIN message (for role) with part (for content) + const rows = this._db.prepare(` + SELECT m.id AS msg_id, m.data AS msg_data, p.data AS part_data + FROM message m + LEFT JOIN part p ON p.message_id = m.id + WHERE m.session_id = ? + ORDER BY m.time_created ASC, p.time_created ASC + `).all(session.id); + + const toolCalls = {}; + const seenMessages = new Set(); + let userMsgCount = 0; + let asstMsgCount = 0; + + for (const row of rows) { + // Count distinct messages by role (use msg_id to deduplicate) + if (row.msg_data && !seenMessages.has(row.msg_id)) { + try { + const parsed = JSON.parse(row.msg_data); + if (parsed.role === 'user') userMsgCount++; + else if (parsed.role === 'assistant') asstMsgCount++; + seenMessages.add(row.msg_id); + } catch { + // malformed JSON — skip (codex-2-1) + } + } + + // Count tool parts by tool name (production: type=tool, test-only: type=tool_use) + if (row.part_data) { + try { + const part = JSON.parse(row.part_data); + // Production OpenCode uses type='tool' with {tool, state}; + // test fixtures may use legacy type='tool_use' with {name}. + // Both are valid part types; backward compatible check (codex-2-1) + if (part.type === 'tool' && part.tool) { + toolCalls[part.tool] = (toolCalls[part.tool] || 0) + 1; + if (part.state && part.state.output) { + // also count completed tool_output entries — count them once per part + } + } else if (part.type === 'tool_use' && part.name) { + toolCalls[part.name] = (toolCalls[part.name] || 0) + 1; + } + } catch { + // malformed JSON — skip (codex-2-1) + } + } + } + + session.tool_call_count = Object.values(toolCalls).reduce((a, b) => a + b, 0); + session.tool_calls = toolCalls; + session.message_count = userMsgCount + asstMsgCount; + } catch (err) { + console.error(`[OpenCodeAdapter] _loadMessageStats error for ${String(session.id).slice(0, 16)}:`, err.message); + } + } + } + + /** Get a single session by ID (cache first, then DB) */ + getSession(id) { + // Check cache first + const cached = this._sessionsMap.get(id); + if (cached) return cached; + + // Fallback: load from DB directly + if (!this._dbPath) return null; + let db = null; + try { + db = new Database(this._dbPath, { readonly: true, fileMustExist: true, timeout: 10000 }); + db.pragma('query_only = true'); + + const row = db.prepare(` + SELECT id, parent_id, slug, directory, path, title, agent, model, version, + cost, tokens_input, tokens_output, tokens_reasoning, + tokens_cache_read, tokens_cache_write, + time_created, time_updated, time_compacting, time_archived, + summary_additions, summary_deletions, summary_files, + parent_id, metadata + FROM session WHERE id = ? + `).get(id); + + if (!row) return null; + + // Reject child or archived sessions (codex-2-3) + if (row.parent_id !== null || row.time_archived !== null) return null; + + let modelName = ''; + const modelStr = row.model || ''; + try { + const modelObj = JSON.parse(modelStr); + modelName = modelObj.id || modelStr; + } catch { + modelName = modelStr; + } + + const created = Number(row.time_created); + const updated = Number(row.time_updated); + const now = Date.now(); + let status = 'success'; + if (updated && (now - updated) < 300000) { + status = 'active'; + } else if (created && updated && (updated - created) < 5000 && !row.tokens_input && !row.tokens_output) { + status = 'interrupted'; + } + + return { + key: `opencode:${id}`, + engine: 'opencode', + engine_label: 'OpenCode', + id, + can_delete: this.cliAvailable, + summary: row.title || row.slug || '', + cwd: row.directory || '', + model: modelName, + agent: row.agent || '', + cli_version: row.version || '', + source: 'cli', + created_at: created, + updated_at: updated, + duration_ms: created && updated ? updated - created : null, + status, + tool_call_count: 0, + tool_calls: {}, + message_count: 0, + tokens_input: row.tokens_input || 0, + tokens_output: row.tokens_output || 0, + tokens_reasoning: row.tokens_reasoning || 0, + tokens_cache_read: row.tokens_cache_read || 0, + tokens_cache_write: row.tokens_cache_write || 0, + cost: row.cost || 0, + summary_additions: row.summary_additions || 0, + summary_deletions: row.summary_deletions || 0, + summary_files: row.summary_files || 0, + parent_id: row.parent_id, + time_archived: row.time_archived, + resume_command: this.buildResumeCommand(id), + can_resume: true, + }; + } catch (err) { + console.error(`[OpenCodeAdapter] getSession error for ${String(id).slice(0, 16)}:`, err.message); + return null; + } finally { + if (db) { try { db.close(); } catch {} } + } + } + + /** Get messages for a session — reads from part table (codex-1-1) */ + async getMessages(id) { + if (this._messageCache.has(id)) { + return this._messageCache.get(id); + } + + if (!this._dbPath) return []; + + let db = null; + try { + db = new Database(this._dbPath, { readonly: true, fileMustExist: true, timeout: 10000 }); + db.pragma('query_only = true'); + + // Join message (for role) with part (for content) + const rows = db.prepare(` + SELECT m.data AS msg_data, m.time_created AS msg_time, p.data AS part_data + FROM message m + LEFT JOIN part p ON p.message_id = m.id + WHERE m.session_id = ? + ORDER BY m.time_created ASC, p.time_created ASC + LIMIT 300 + `).all(id); + + const messages = []; + for (const row of rows) { + if (!row.msg_data || !row.part_data) continue; + + try { + const msgData = JSON.parse(row.msg_data); + const role = msgData.role || ''; + const partData = JSON.parse(row.part_data); + const type = partData.type || ''; + const timestamp = Number(row.msg_time) || 0; + + if (type === 'text' && partData.text) { + messages.push({ + type: role === 'assistant' ? 'assistant_message' : 'user_message', + message: partData.text, + time: timestamp, + }); + } else if (type === 'tool' && partData.tool) { + // Production OpenCode type=tool with tool name and state (codex-2-1) + messages.push({ + type: 'tool_call', + name: partData.tool, + arguments: partData.state?.input ? JSON.stringify(partData.state.input, null, 2) : '', + time: timestamp, + }); + if (partData.state?.status === 'completed' && partData.state?.output) { + messages.push({ + type: 'tool_output', + output_preview: (typeof partData.state.output === 'string' + ? partData.state.output + : JSON.stringify(partData.state.output)).substring(0, 500), + time: timestamp, + }); + } + } else if (type === 'tool_use' && partData.name) { + messages.push({ + type: 'tool_call', + name: partData.name, + arguments: partData.input ? JSON.stringify(partData.input, null, 2) : '', + time: timestamp, + }); + } else if (type === 'tool_result') { + const output = typeof partData.content === 'string' + ? partData.content + : partData.content ? JSON.stringify(partData.content) : ''; + messages.push({ + type: 'tool_output', + output_preview: output.substring(0, 500), + time: timestamp, + }); + } + // Skip reasoning, step — not rendered + } catch { + // malformed JSON — skip + } + } + + this._messageCache.set(id, messages); + return messages; + } catch (err) { + console.error(`[OpenCodeAdapter] getMessages error for ${String(id).slice(0, 16)}:`, err.message); + return []; + } finally { + if (db) { try { db.close(); } catch {} } + } + } + + buildResumeCommand(id) { + return `opencode --session ${id}`; + } + + getHealthInfo() { + return { + ...super.getHealthInfo(), + cli_available: this.cliAvailable, + session_source_available: this._sessionsLoaded, + data_dir: this._dataDir, + db_path: this._dbPath, + reason: this._reason, + }; + } +} diff --git a/backend/src/index.js b/backend/src/index.js index 111c438..a424b81 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -260,7 +260,7 @@ app.post('/api/refresh', async (req, res) => { }); // ─── Custom Name ────────────────────────────────────────────────── -const VALID_ENGINES = ['codex', 'claude', 'agy']; +const VALID_ENGINES = ['codex', 'claude', 'agy', 'opencode']; app.put('/api/sessions/:engine/:id/custom-name', (req, res) => { try { @@ -316,7 +316,7 @@ app.delete('/api/sessions/:engine/:id/custom-name', (req, res) => { app.post('/api/sessions/:engine/:id/generate-title', async (req, res) => { try { const { engine, id } = req.params; - if (!['codex', 'claude', 'agy'].includes(engine)) { + if (!['codex', 'claude', 'agy', 'opencode'].includes(engine)) { return res.status(400).json({ error: `Invalid engine: ${engine}` }); } const e = getEngine(engine); @@ -345,6 +345,66 @@ app.post('/api/sessions/:engine/:id/generate-title', async (req, res) => { } }); +// ─── Session Delete (OpenCode only) ────────────────────────────── +app.delete('/api/sessions/:engine/:id', async (req, res) => { + try { + const { engine, id } = req.params; + if (engine !== 'opencode') { + return res.status(400).json({ error: '原始会话删除仅支持 OpenCode 引擎' }); + } + + const e = getEngine(engine); + if (!e || !e.cliAvailable) { + return res.status(503).json({ error: 'OpenCode CLI 不可用,无法删除' }); + } + + // Re-verify session is top-level and unarchived (codex-1-4) + const session = e.getSession(id); + if (!session) { + return res.status(404).json({ error: '会话不存在' }); + } + if (session.parent_id) { + return res.status(400).json({ error: '只能删除顶级会话,子会话跟随父会话删除' }); + } + if (session.time_archived) { + return res.status(400).json({ error: '已归档会话不可直接删除,请先取消归档' }); + } + + // Execute delete via opencode CLI — no shell, arg array (codex-1-4) + const { spawnSync } = await import('child_process'); + const result = spawnSync('opencode', ['session', 'delete', id], { + stdio: 'pipe', + encoding: 'utf-8', + timeout: 30000, + shell: false, + }); + + if (result.error) { + return res.status(500).json({ error: `删除失败: ${result.error.message}` }); + } + if (result.signal) { + return res.status(500).json({ error: `删除进程被中断: ${result.signal}` }); + } + if (result.status !== 0) { + const errMsg = (result.stderr || result.stdout || '').trim() || '删除失败'; + return res.status(500).json({ error: `OpenCode 返回错误: ${errMsg}` }); + } + + // Clean up custom name metadata + try { + deleteCustomName(engine, id); + } catch {} + + // Trigger refresh + await refreshEngines(['opencode']); + + res.json({ status: 'ok' }); + } catch (err) { + console.error('[API] session delete error:', err); + res.status(500).json({ error: err.message }); + } +}); + // ─── SPA fallback ────────────────────────────────────────────────── if (existsSync(frontendDist)) { app.get('*', (req, res) => { diff --git a/backend/src/title-generator.js b/backend/src/title-generator.js index a08eacc..72c84a3 100644 --- a/backend/src/title-generator.js +++ b/backend/src/title-generator.js @@ -155,12 +155,26 @@ function fetchAgyMessages(session) { return result; } +/** Fetch OpenCode messages via adapter (reuses parsed message format). */ +async function fetchOpenCodeMessages(sessionId, adapter) { + try { + const messages = await adapter.getMessages(sessionId); + return messages + .filter(m => m.type === 'user_message' || m.type === 'assistant_message') + .map(m => ({ type: m.type, message: (m.message || '').slice(0, 2000) })); + } catch (err) { + console.warn(`[TitleGen] OpenCode fetch error for ${sessionId.slice(0, 16)}:`, err.message); + return []; + } +} + async function fetchMessages(engine, sessionId, adapter) { const session = adapter ? adapter.getSession(sessionId) : null; if (!session) return []; if (engine === 'codex') return await fetchCodexMessages(session); if (engine === 'claude') return await fetchClaudeMessages(sessionId); if (engine === 'agy') return fetchAgyMessages(session); + if (engine === 'opencode') return await fetchOpenCodeMessages(sessionId, adapter); return []; } diff --git a/backend/test/opencode-adapter.test.js b/backend/test/opencode-adapter.test.js new file mode 100644 index 0000000..3a98643 --- /dev/null +++ b/backend/test/opencode-adapter.test.js @@ -0,0 +1,381 @@ +import { describe, it, afterEach, mock } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import Database from 'better-sqlite3'; +import { OpenCodeAdapter } from '../src/engine/opencode-adapter.js'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/** + * Create an ephemeral OpenCode-format SQLite DB under $XDG_DATA_HOME/opencode/ + * and snapshot the original XDG_DATA_HOME so we can restore it after the test. + * + * Returns { restoreEnv, dbDir, dbPath } where restoreEnv restores XDG_DATA_HOME + * and cleans up the temp directory. + */ +function withTestDb({ sessions = [], messages = [], parts = [] } = {}) { + const tmpDir = mkdtempSync(join(tmpdir(), 'oc-adapter-test-')); + const opencodeDir = join(tmpDir, 'opencode'); + mkdirSync(opencodeDir, { recursive: true }); + const dbPath = join(opencodeDir, 'opencode.db'); + const db = new Database(dbPath, { timeout: 5000 }); + db.exec(` + CREATE TABLE IF NOT EXISTS session ( + id TEXT PRIMARY KEY, + parent_id TEXT, + slug TEXT, + directory TEXT, + path TEXT, + title TEXT, + agent TEXT, + model TEXT, + version TEXT, + cost REAL, + tokens_input INTEGER, + tokens_output INTEGER, + tokens_reasoning INTEGER, + tokens_cache_read INTEGER, + tokens_cache_write INTEGER, + time_created INTEGER, + time_updated INTEGER, + time_compacting INTEGER, + time_archived INTEGER, + summary_additions INTEGER, + summary_deletions INTEGER, + summary_files INTEGER, + metadata TEXT + ); + CREATE TABLE IF NOT EXISTS message ( + id TEXT PRIMARY KEY, + session_id TEXT, + time_created INTEGER, + time_updated INTEGER, + data TEXT + ); + CREATE TABLE IF NOT EXISTS part ( + id TEXT PRIMARY KEY, + message_id TEXT, + session_id TEXT, + time_created INTEGER, + time_updated INTEGER, + data TEXT + ); + `); + + const insertSession = db.prepare(` + INSERT INTO session (id, parent_id, slug, directory, path, title, agent, model, version, + cost, tokens_input, tokens_output, tokens_reasoning, + tokens_cache_read, tokens_cache_write, + time_created, time_updated, time_compacting, time_archived, + summary_additions, summary_deletions, summary_files, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + + for (const s of sessions) { + insertSession.run( + s.id, s.parent_id ?? null, s.slug ?? '', s.directory ?? '', s.path ?? '', + s.title ?? '', s.agent ?? '', s.model ?? '', s.version ?? '', + s.cost ?? 0, s.tokens_input ?? 0, s.tokens_output ?? 0, + s.tokens_reasoning ?? 0, s.tokens_cache_read ?? 0, s.tokens_cache_write ?? 0, + s.time_created ?? 0, s.time_updated ?? 0, s.time_compacting ?? null, + s.time_archived ?? null, s.summary_additions ?? 0, s.summary_deletions ?? 0, + s.summary_files ?? 0, s.metadata ?? null + ); + } + + const insertMsg = db.prepare(` + INSERT INTO message (id, session_id, time_created, time_updated, data) + VALUES (?, ?, ?, ?, ?) + `); + for (const m of messages) { + insertMsg.run(m.id, m.session_id, m.time_created ?? 0, m.time_updated ?? 0, m.data ?? '{}'); + } + + const insertPart = db.prepare(` + INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) + VALUES (?, ?, ?, ?, ?, ?) + `); + for (const p of parts) { + insertPart.run(p.id, p.message_id, p.session_id, p.time_created ?? 0, p.time_updated ?? 0, p.data ?? '{}'); + } + + db.close(); + + const origXdg = process.env.XDG_DATA_HOME; + process.env.XDG_DATA_HOME = tmpDir; + + return { + restoreEnv() { + if (origXdg === undefined) { + delete process.env.XDG_DATA_HOME; + } else { + process.env.XDG_DATA_HOME = origXdg; + } + try { rmSync(tmpDir, { recursive: true, force: true }); } catch {} + }, + dbDir: tmpDir, + dbPath, + }; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('OpenCodeAdapter health state', () => { + let env; + + afterEach(() => { + if (env) env.restoreEnv(); + }); + + it('reports session_source_available=true after successful empty-DB refresh', async () => { + env = withTestDb({ sessions: [] }); + const adapter = new OpenCodeAdapter(); + + await adapter.refresh(); + + const health = adapter.getHealthInfo(); + assert.equal(health.session_source_available, true, + 'empty DB with correct schema should report available'); + assert.equal(health.session_count, 0); + }); + + it('reports session_source_available=false when no DB file exists', async () => { + const adapter = new OpenCodeAdapter(); + + // Mock _findDataDir so it sets null paths without falling through to the real DB + mock.method(adapter, '_findDataDir', function () { + this._dataDir = null; + this._dbPath = null; + this._reason = 'Mocked: no DB file'; + }); + + await adapter.refresh(); + + const health = adapter.getHealthInfo(); + assert.equal(health.session_source_available, false); + assert.equal(health.session_count, 0); + + mock.restoreAll(); + }); +}); + +describe('OpenCodeAdapter production tool fixture', () => { + let env; + + afterEach(() => { + if (env) env.restoreEnv(); + }); + + it('parses production type=tool parts and returns correct timeline and count', async () => { + const sessionId = 'ses_prod_tool_test_001'; + const msgId = 'msg_prod_001'; + const now = Date.now(); + + env = withTestDb({ + sessions: [{ + id: sessionId, + title: 'Production Tool Test', + time_created: now - 60000, + time_updated: now, + tokens_input: 150, + tokens_output: 300, + }], + messages: [{ + id: msgId, + session_id: sessionId, + time_created: now - 50000, + time_updated: now - 50000, + data: JSON.stringify({ role: 'assistant' }), + }], + parts: [ + { + id: 'part_prod_001', + message_id: msgId, + session_id: sessionId, + time_created: now - 50000, + time_updated: now - 50000, + data: JSON.stringify({ type: 'text', text: 'Let me search the codebase…' }), + }, + { + id: 'part_prod_002', + message_id: msgId, + session_id: sessionId, + time_created: now - 49000, + time_updated: now - 49000, + data: JSON.stringify({ + type: 'tool', + tool: 'codebaseSearch', + state: { + input: { query: 'findSessions' }, + status: 'completed', + output: 'Found 3 results\n- session1\n- session2\n- session3', + }, + }), + }, + { + id: 'part_prod_003', + message_id: msgId, + session_id: sessionId, + time_created: now - 48000, + time_updated: now - 48000, + data: JSON.stringify({ + type: 'tool', + tool: 'readFile', + state: { + input: { path: '/tmp/test.txt' }, + status: 'completed', + output: 'file content line 1\nfile content line 2', + }, + }), + }, + { + id: 'part_prod_004', + message_id: msgId, + session_id: sessionId, + time_created: now - 47000, + time_updated: now - 47000, + data: JSON.stringify({ type: 'text', text: 'Here are the results…' }), + }, + ], + }); + + const adapter = new OpenCodeAdapter(); + await adapter.refresh(); + + // Check session-level tool_call_count + const session = adapter.getSession(sessionId); + assert.ok(session, 'session should be found'); + assert.equal(session.tool_call_count, 2, 'should count both tool parts'); + assert.deepEqual(session.tool_calls, { codebaseSearch: 1, readFile: 1 }); + + // Check getMessages returns full timeline with correct ordering + const messages = await adapter.getMessages(sessionId); + assert.ok(Array.isArray(messages)); + + // Expected ordering: text("Let me search…"), tool_call(codebaseSearch), + // tool_output(Fount 3 results…), tool_call(readFile), + // tool_output("file content…"), text("Here are the results…") + assert.equal(messages.length, 6, + 'timeline: text + tool_call + tool_output + tool_call + tool_output + text'); + + // Verify types and content in order + assert.equal(messages[0].type, 'assistant_message'); + assert.ok(messages[0].message.includes('Let me search')); + + assert.equal(messages[1].type, 'tool_call'); + assert.equal(messages[1].name, 'codebaseSearch'); + + assert.equal(messages[2].type, 'tool_output'); + assert.ok(messages[2].output_preview.includes('Found 3 results')); + + assert.equal(messages[3].type, 'tool_call'); + assert.equal(messages[3].name, 'readFile'); + + assert.equal(messages[4].type, 'tool_output'); + assert.ok(messages[4].output_preview.includes('file content')); + + assert.equal(messages[5].type, 'assistant_message'); + assert.ok(messages[5].message.includes('results')); + }); +}); + +describe('OpenCodeAdapter session filtering (child/archived)', () => { + let env; + + afterEach(() => { + if (env) env.restoreEnv(); + }); + + it('getSession returns null for child sessions', async () => { + const parentId = 'ses_parent_001'; + const childId = 'ses_child_001'; + const now = Date.now(); + + env = withTestDb({ + sessions: [ + { + id: parentId, parent_id: null, + title: 'Parent', time_created: now - 100000, time_updated: now, + }, + { + id: childId, parent_id: parentId, + title: 'Child', time_created: now - 50000, time_updated: now, + }, + ], + }); + + const adapter = new OpenCodeAdapter(); + await adapter.refresh(); + + // After refresh, only parent should be in the sessions list + assert.ok(adapter.getSession(parentId), 'parent session should be found'); + assert.equal(adapter.getSession(childId), null, 'child session should not be found in list'); + + // Fallback getSession also rejects children (direct DB lookup filters parent_id) + const fromDb = adapter.getSession(childId); + assert.equal(fromDb, null, 'getSession fallback should reject child'); + }); + + it('getSession returns null for archived sessions', async () => { + const activeId = 'ses_active_001'; + const archivedId = 'ses_archived_001'; + const now = Date.now(); + + env = withTestDb({ + sessions: [ + { + id: activeId, parent_id: null, time_archived: null, + title: 'Active', time_created: now - 100000, time_updated: now, + }, + { + id: archivedId, parent_id: null, time_archived: now - 10000, + title: 'Archived', time_created: now - 200000, time_updated: now - 10000, + }, + ], + }); + + const adapter = new OpenCodeAdapter(); + await adapter.refresh(); + + assert.ok(adapter.getSession(activeId), 'active session should be found'); + assert.equal(adapter.getSession(archivedId), null, 'archived session should not be found'); + }); +}); + +describe('OpenCodeAdapter CLI-based deletion (isolated, no real CLI)', () => { + let env; + + afterEach(() => { + if (env) env.restoreEnv(); + }); + + it('can_delete is false when CLI is unavailable', async () => { + const now = Date.now(); + env = withTestDb({ + sessions: [{ + id: 'ses_del_test_001', + title: 'Delete Test', + time_created: now - 60000, + time_updated: now, + }], + }); + + const adapter = new OpenCodeAdapter(); + + // Mock _detectCli so it does not find the CLI + mock.method(adapter, '_detectCli', function () { + this.cliAvailable = false; + this._reason = 'Mocked: no CLI'; + }); + + await adapter.refresh(); + + const session = adapter.getSession('ses_del_test_001'); + assert.ok(session); + assert.equal(session.can_delete, false, 'can_delete should be false when CLI is unavailable'); + + mock.restoreAll(); + }); +}); diff --git a/frontend/src/App.vue b/frontend/src/App.vue index b755c2a..8d2729c 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -17,7 +17,7 @@

智能体观测中心

混合引擎 -

Codex · Claude · Agy 协同会话追踪平台

+

Codex · Claude · Agy · OpenCode 协同会话追踪平台

@@ -158,6 +158,7 @@ const engineOptions = [ { key: 'codex', label: 'Codex', icon: '⚡', short: 'Codex' }, { key: 'claude', label: 'Claude', icon: '🧠', short: 'Claude' }, { key: 'agy', label: 'Agy', icon: '⚙', short: 'Agy' }, + { key: 'opencode', label: 'OpenCode', icon: '🔓', short: 'OpenCode' }, ] const tabs = [ @@ -260,8 +261,15 @@ async function handleRefresh() { } } +/** Lightweight refresh for dashboard stats (used by child delete handlers) */ +async function refreshDashboard() { + await checkHealth() + updateStats() +} + // Provide engine selection to child views provide('selectedEngine', selectedEngine) +provide('refreshDashboard', refreshDashboard) onMounted(() => { document.documentElement.setAttribute('data-theme', theme.value) diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 8b9dd47..5d02f9f 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -87,6 +87,12 @@ export async function generateTitle(engine, sessionId) { }) } +export async function deleteSession(engine, sessionId) { + return request(`/sessions/${encodeURIComponent(engine)}/${encodeURIComponent(sessionId)}`, { + method: 'DELETE', + }) +} + export function formatTimestamp(ts) { if (!ts) return '-' const d = typeof ts === 'number' && ts < 1e15 ? new Date(ts * 1000) : new Date(ts) @@ -110,6 +116,7 @@ export const ENGINE_META = { codex: { color: '#3b82f6', bg: 'rgba(59,130,246,0.1)', label: 'Codex', border: 'rgba(59,130,246,0.2)', icon: '⚡' }, claude: { color: '#a855f7', bg: 'rgba(168,85,247,0.1)', label: 'Claude', border: 'rgba(168,85,247,0.2)', icon: '🧠' }, agy: { color: '#10b981', bg: 'rgba(16,185,129,0.1)', label: 'Agy', border: 'rgba(16,185,129,0.2)', icon: '⚙' }, + opencode: { color: '#f59e0b', bg: 'rgba(245,158,11,0.1)', label: 'OpenCode', border: 'rgba(245,158,11,0.2)', icon: '🔓' }, } export function getEngineMeta(engine) { diff --git a/frontend/src/views/AnalyticsView.vue b/frontend/src/views/AnalyticsView.vue index b315c90..da4ab2c 100644 --- a/frontend/src/views/AnalyticsView.vue +++ b/frontend/src/views/AnalyticsView.vue @@ -69,7 +69,6 @@

观测报告

基于 {{ totalSessions }} 个会话 ({{ engineLabel }})。异常/中断率 {{ errorRate }}%。 - Agy 暂无本地会话数据源。

@@ -86,7 +85,7 @@ const sessions = ref([]) const totalSessions = ref(0) const errorCount = ref(0) const engineLabel = computed(() => { - const labels = { all: '全部引擎', codex: 'Codex', claude: 'Claude', agy: 'Agy' } + const labels = { all: '全部引擎', codex: 'Codex', claude: 'Claude', agy: 'Agy', opencode: 'OpenCode' } return labels[selectedEngine.value] || selectedEngine.value }) diff --git a/frontend/src/views/SessionDetail.vue b/frontend/src/views/SessionDetail.vue index bde2864..59fbe3d 100644 --- a/frontend/src/views/SessionDetail.vue +++ b/frontend/src/views/SessionDetail.vue @@ -32,6 +32,9 @@

{{ session.metadata.title || session.metadata.summary }}

+
+ +
@@ -121,14 +124,31 @@
+ +
+
+
+

确认删除会话

+ +
+
+

此操作将从 OpenCode 数据库中永久删除该会话,不可恢复。

+

{{ routeId }}

+
+
+ + +
+
+