feat: add OpenCode session management
This commit is contained in:
@@ -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 <id>` |
|
||||
| Claude | `~/.claude/projects/**/*.jsonl` | ~170 | `claude --resume <id>` |
|
||||
| Agy | `~/.gemini/antigravity-cli/conversation_summaries.db` | ~80 | `agy --conversation <id>` |
|
||||
| OpenCode | `~/.local/share/opencode/opencode.db` | ~520 | `opencode --session <id>` |
|
||||
|
||||
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` | 刷新缓存 |
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+62
-2
@@ -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) => {
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,7 @@
|
||||
<h1 class="logo-title">智能体观测中心</h1>
|
||||
<span class="logo-badge">混合引擎</span>
|
||||
</div>
|
||||
<p class="logo-sub">Codex · Claude · Agy 协同会话追踪平台</p>
|
||||
<p class="logo-sub">Codex · Claude · Agy · OpenCode 协同会话追踪平台</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -69,7 +69,6 @@
|
||||
<h4 class="card-title-sm">观测报告</h4>
|
||||
<p class="report-text">
|
||||
基于 {{ totalSessions }} 个会话 ({{ engineLabel }})。异常/中断率 {{ errorRate }}%。
|
||||
Agy 暂无本地会话数据源。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -32,6 +32,9 @@
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="session.metadata.custom_name" class="name-subtitle">{{ session.metadata.title || session.metadata.summary }}</p>
|
||||
<div v-if="session.metadata.can_delete" class="delete-row">
|
||||
<button class="delete-btn" @click="showDeleteConfirm">🗑 删除此会话</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="meta-grid">
|
||||
<div class="meta-item">
|
||||
@@ -121,14 +124,31 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<div v-if="deleteConfirmVisible" class="dialog-overlay" @click.self="deleteConfirmVisible = false">
|
||||
<div class="dialog">
|
||||
<div class="dialog-head">
|
||||
<h3>确认删除会话</h3>
|
||||
<button class="dialog-close" @click="deleteConfirmVisible = false">✕</button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<p class="dialog-hint">此操作将从 OpenCode 数据库中永久删除该会话,不可恢复。</p>
|
||||
<p class="dialog-id mono">{{ routeId }}</p>
|
||||
</div>
|
||||
<div class="dialog-foot">
|
||||
<button class="dialog-btn" @click="deleteConfirmVisible = false" :disabled="deleting">取消</button>
|
||||
<button class="dialog-btn danger" @click="confirmDelete" :disabled="deleting">{{ deleting ? '删除中...' : '确认删除' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { ref, computed, onMounted, watch, inject } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getSessionDetail, formatTimestamp, getEngineMeta, setCustomName, deleteCustomName, generateTitle } from '../api/index.js'
|
||||
import { getSessionDetail, formatTimestamp, getEngineMeta, setCustomName, deleteCustomName, generateTitle, deleteSession } from '../api/index.js'
|
||||
|
||||
const props = defineProps({ id: String })
|
||||
const route = useRoute()
|
||||
@@ -138,7 +158,7 @@ const routeId = computed(() => props.id || route.params.id)
|
||||
const engine = computed(() => route.query.engine || 'codex')
|
||||
const meta = computed(() => getEngineMeta(engine.value))
|
||||
const engineLabel = computed(() => meta.value.label)
|
||||
const resumeCmd = computed(() => engine.value === 'codex' ? `codex resume ${routeId.value}` : engine.value === 'claude' ? `claude --resume ${routeId.value}` : `agy --conversation ${routeId.value}`)
|
||||
const resumeCmd = computed(() => session.value?.metadata?.resume_command || '')
|
||||
|
||||
const session = ref(null)
|
||||
const messages = ref([])
|
||||
@@ -147,6 +167,9 @@ const loadError = ref(null)
|
||||
const editNameVisible = ref(false)
|
||||
const editNameValue = ref('')
|
||||
const aiLoading = ref(false)
|
||||
const deleteConfirmVisible = ref(false)
|
||||
const deleting = ref(false)
|
||||
const refreshDashboard = inject("refreshDashboard", () => {})
|
||||
|
||||
const topTools = computed(() => {
|
||||
if (!session.value?.summary?.tool_calls) return []
|
||||
@@ -241,6 +264,26 @@ async function generateAITitle() {
|
||||
}
|
||||
}
|
||||
|
||||
function showDeleteConfirm() {
|
||||
deleteConfirmVisible.value = true
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (deleting.value) return
|
||||
deleting.value = true
|
||||
try {
|
||||
await deleteSession(engine.value, routeId.value)
|
||||
ElMessage.success('会话已删除')
|
||||
deleteConfirmVisible.value = false
|
||||
await refreshDashboard()
|
||||
goBack()
|
||||
} catch (err) {
|
||||
ElMessage.error('删除失败: ' + (err.message || ''))
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadDetail)
|
||||
|
||||
// Reload when route params change
|
||||
@@ -252,7 +295,7 @@ watch(
|
||||
|
||||
<style scoped>
|
||||
.session-detail-page { width: 100%; }
|
||||
.back-btn { display: inline-flex; align-items: center; gap: 4px; margin-bottom: 12px; padding: 6px 14px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg-input); color: var(--text-secondary); font-size: 12px; cursor: pointer; }
|
||||
.back-btn { display: inline-flex; align-items: center; gap: 4px; margin-bottom: 12px; padding: 6px 14px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg-input); color: var(--text-secondary); font-size: 12px; cursor: pointer; min-height: 44px; min-width: 44px; }
|
||||
.back-btn:hover { border-color: var(--accent); color: var(--accent-light); }
|
||||
.card { border-radius: 12px; border: 1px solid var(--border); background: var(--bg-card); padding: 20px; margin-bottom: 16px; }
|
||||
.card-title-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; flex-wrap: wrap; gap: 8px; }
|
||||
@@ -318,7 +361,21 @@ watch(
|
||||
.dialog-input:focus { border-color: var(--accent); }
|
||||
.dialog-counter { text-align: right; font-size: 11px; color: var(--text-muted); margin-top: 4px; }
|
||||
.dialog-foot { display: flex; gap: 8px; padding: 12px 20px; border-top: 1px solid var(--border); justify-content: flex-end; }
|
||||
.dialog-btn { padding: 8px 16px; border-radius: 8px; border: 1px solid var(--border); background: transparent; color: var(--text-secondary); font-size: 12px; cursor: pointer; }
|
||||
.dialog-btn { padding: 8px 16px; border-radius: 8px; border: 1px solid var(--border); background: transparent; color: var(--text-secondary); font-size: 12px; cursor: pointer; min-height: 44px; min-width: 44px; }
|
||||
.dialog-btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
.dialog-btn.danger { background: var(--danger); border-color: var(--danger); color: #fff; }
|
||||
.dialog-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.dialog-id { font-size: 11px; color: var(--text-muted); font-family: 'SF Mono', monospace; word-break: break-all; }
|
||||
.dialog-btn:hover { opacity: 0.9; }
|
||||
|
||||
.delete-row { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); display: flex; gap: 8px; }
|
||||
.delete-btn {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 7px 14px; border-radius: 8px; font-size: 12px; font-weight: 600; cursor: pointer;
|
||||
border: 1px solid rgba(239,68,68,0.3); background: rgba(239,68,68,0.08);
|
||||
color: var(--danger); transition: all 0.12s;
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
.delete-btn:hover { background: var(--danger); color: #fff; }
|
||||
</style>
|
||||
|
||||
@@ -171,6 +171,9 @@
|
||||
<button class="footer-btn-detail" @click="goToDetailPage(drawerSession)">
|
||||
→ 进入详情页
|
||||
</button>
|
||||
<button v-if="drawerSession.can_delete" class="footer-btn-delete" @click="showDeleteConfirm(drawerSession)">
|
||||
🗑 删除会话
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -199,13 +202,31 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<div v-if="deleteConfirmVisible" class="dialog-overlay" @click.self="deleteConfirmVisible = false">
|
||||
<div class="dialog">
|
||||
<div class="dialog-head">
|
||||
<h3>确认删除会话</h3>
|
||||
<button class="dialog-close" @click="deleteConfirmVisible = false">✕</button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<p class="dialog-hint">此操作将从 OpenCode 数据库中永久删除该会话,不可恢复。</p>
|
||||
<p class="dialog-id mono">{{ deleteTarget?.id }}</p>
|
||||
</div>
|
||||
<div class="dialog-foot">
|
||||
<button class="dialog-btn" @click="deleteConfirmVisible = false" :disabled="deleting">取消</button>
|
||||
<button class="dialog-btn danger" @click="confirmDelete" :disabled="deleting">{{ deleting ? '删除中...' : '确认删除' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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 } from '../api/index.js'
|
||||
import { getSessions, getSessionDetail, getGlobalTools, formatTimestamp, getEngineMeta, ENGINE_META, setCustomName, deleteCustomName, generateTitle, deleteSession } from '../api/index.js'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const props = defineProps({ selectedEngine: { type: String, default: 'all' } })
|
||||
@@ -390,6 +411,35 @@ async function clearCustomName(session) {
|
||||
}
|
||||
}
|
||||
|
||||
const deleteConfirmVisible = ref(false)
|
||||
const deleteTarget = ref(null)
|
||||
const deleting = ref(false)
|
||||
const refreshDashboard = inject('refreshDashboard', () => {})
|
||||
|
||||
function showDeleteConfirm(session) {
|
||||
deleteTarget.value = session
|
||||
deleteConfirmVisible.value = true
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
const session = deleteTarget.value
|
||||
if (!session || deleting.value) return
|
||||
deleting.value = true
|
||||
try {
|
||||
await deleteSession(session.engine, session.id)
|
||||
ElMessage.success('会话已删除')
|
||||
deleteConfirmVisible.value = false
|
||||
deleteTarget.value = null
|
||||
closeDrawer()
|
||||
loadSessions()
|
||||
await refreshDashboard()
|
||||
} catch (err) {
|
||||
ElMessage.error('删除失败: ' + (err.message || ''))
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goToDetailPage(session) {
|
||||
if (!session) return
|
||||
closeDrawer()
|
||||
@@ -558,7 +608,7 @@ onMounted(loadSessions)
|
||||
.trace-msg { font-size: 10px; color: var(--text-secondary); }
|
||||
|
||||
.drawer-footer { display: flex; gap: 8px; padding: 16px 20px; border-top: 1px solid var(--border); background: rgba(15,23,42,0.2); }
|
||||
.footer-btn-copy, .footer-btn-uuid { flex: 1; display: flex; align-items: center; justify-content: center; gap: 6px; padding: 10px; border-radius: 8px; font-size: 12px; font-weight: 600; cursor: pointer; }
|
||||
.footer-btn-copy, .footer-btn-uuid { flex: 1; display: flex; align-items: center; justify-content: center; gap: 6px; padding: 10px; border-radius: 8px; font-size: 12px; font-weight: 600; cursor: pointer; min-height: 44px; min-width: 44px; }
|
||||
.footer-btn-copy { background: var(--accent); border: none; color: #fff; }
|
||||
.footer-btn-copy:hover { opacity: 0.9; }
|
||||
.footer-btn-uuid { border: 1px solid var(--border); background: var(--bg-input); color: var(--text-secondary); }
|
||||
@@ -582,8 +632,11 @@ onMounted(loadSessions)
|
||||
.dialog-input:focus { border-color: var(--accent); }
|
||||
.dialog-counter { text-align: right; font-size: 11px; color: var(--text-muted); margin-top: 4px; }
|
||||
.dialog-foot { display: flex; gap: 8px; padding: 12px 20px; border-top: 1px solid var(--border); justify-content: flex-end; }
|
||||
.dialog-btn { padding: 8px 16px; border-radius: 8px; border: 1px solid var(--border); background: transparent; color: var(--text-secondary); font-size: 12px; cursor: pointer; }
|
||||
.dialog-btn { padding: 8px 16px; border-radius: 8px; border: 1px solid var(--border); background: transparent; color: var(--text-secondary); font-size: 12px; cursor: pointer; min-height: 44px; min-width: 44px; }
|
||||
.dialog-btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
.dialog-btn.danger { background: var(--danger); border-color: var(--danger); color: #fff; }
|
||||
.dialog-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.dialog-id { font-size: 11px; color: var(--text-muted); font-family: 'SF Mono', monospace; word-break: break-all; }
|
||||
.dialog-btn:hover { opacity: 0.9; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@@ -597,8 +650,19 @@ onMounted(loadSessions)
|
||||
padding: 10px; border-radius: 8px; font-size: 12px; font-weight: 600; cursor: pointer;
|
||||
border: 1px solid var(--accent-border); background: var(--accent-bg);
|
||||
color: var(--accent-light); transition: all 0.12s;
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
.footer-btn-detail:hover { background: var(--accent); color: #fff; }
|
||||
.footer-btn-delete {
|
||||
flex: 1; display: flex; align-items: center; justify-content: center; gap: 6px;
|
||||
padding: 10px; border-radius: 8px; font-size: 12px; font-weight: 600; cursor: pointer;
|
||||
border: 1px solid rgba(239,68,68,0.3); background: rgba(239,68,68,0.08);
|
||||
color: var(--danger); transition: all 0.12s;
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
.footer-btn-delete:hover { background: var(--danger); color: #fff; }
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.drawer-footer { flex-wrap: wrap; }
|
||||
|
||||
Reference in New Issue
Block a user